Use a Raspberry Pi5 (16 GB RAM 512 GB NVMe) to transcribe phone calls etc locally

Creating a software or image request, pls use GitHub

I want to upload a .md file in a tutorial to show the steps I took to enable transcribing audio files on my raspberry pi5

There people have the possibility to vote on your request :wink:

Could you be a little more specific about what you want to upload and where? If it’s supposed to be a community tutorial, you can post it here in the forum in the appropriate category.

Yes, i have recently, with the help of AI been able to configure my raspberry Pi5 (16G) so it transcribes audio such as phone calls. I also if you choose to do so uses diarization if you want Speaker 1 Speaker 2.

I asked AI to compile a list of instructions, I have not tried them myself, but the steps ought to be a reasonable guide for anyone else who might like to do the same, that are contained in an .md file.

It all seems to work very well but I would add I have added much more cooling because the raspberry runs hot at around 69C when doing this task. If necessary you can also choose to use less cores to keep things cooler.

The installation uses sherpa_onnx environment, all the transcription is done locally on the Pi5

This is a native Markdown forum, so you can/should just copy&paste the content of the md file into a comment/topic. A round trip through downloading the md file first here makes things unnecessarily complicated, and it cannot be edited like a post, only reuploaded, and not searched with the forum search.

I will Paste the text here but it looks so much better in a .md file.

Raspberry Pi 5 Local Call Transcription & Speaker Diarization
Setup Guide
Purpose
This guide allows another person to recreate the local Raspberry Pi 5 call-transcription system.
When complete:
transcribe = local transcription + two-speaker diarization
transcribe --no-diarization = local transcription only
WAV, MP3 and AMR are supported
optimized/ is ignored
original recordings are never modified
completed recordings are skipped
TXT and JSON transcripts are written to transcripts/
The reference system used a Raspberry Pi 5, ARM64/aarch64, 16 GB RAM, DietPi/Debian, whisper.cpp, the Whisper medium.en model, and sherpa
onnx.

  1. Check the Pi
    uname -m
    cat /etc/os-release
    df -h

Architecture should normally be:
aarch64

Use a 64-bit Debian-based Raspberry Pi OS/DietPi installation.
2. Update the OS
Update DietPi

Run DietPi’s normal update process:

sudo dietpi-update

Follow the prompts and reboot if DietPi requests it.

Do not use sudo apt-get upgrade -y as a replacement for
dietpi-update on a DietPi installation.

Reboot if required:
sudo reboot

  1. Install system packages
    sudo apt-get install -y git cmake g++ make ffmpeg python3 python3-pip python3-venv

Check:
git --version
cmake --version
g++ --version
ffmpeg -version
python3 --version

whisper.cpp supports Raspberry Pi and its CLI uses 16-bit WAV input; ffmpeg is therefore used to normalize source audio.
4. Choose the recordings directory
Example:
/mnt/eass-calls

Create it:sudo mkdir -p /mnt/eass-calls

Example layout:
/mnt/eass-calls/
├── call1.wav
├── call2.mp3
├── call3.amr
└── optimized/
└── duplicate.wav

Only files directly in the main directory are processed. optimized/ is deliberately ignored.
If another Pi uses a different directory, change INPUT_DIR in Step 13.
5. Build whisper.cpp
sudo git clone GitHub - ggml-org/whisper.cpp: Port of OpenAI's Whisper model in C/C++ · GitHub /opt/whisper.cpp
cd /opt/whisper.cpp
cmake -B build
cmake --build build -j4

Test:
/opt/whisper.cpp/build/bin/whisper-cli --help

The official project documents this CMake build and whisper-cli workflow.
6. Download the Whisper model
cd /opt/whisper.cpp
./models/download-ggml-model.sh medium.en

Verify:
ls -lh /opt/whisper.cpp/models/ggml-medium.en.bin

The production configuration uses medium.en for better accuracy rather than maximum speed.
7. Test Whisper
Convert a short recording:
ffmpeg -y -i /path/to/test-recording.mp3 -ar 16000 -ac 1 -c:a pcm_s16le /tmp/test.wav

Run:
/opt/whisper.cpp/build/bin/whisper-cli -m /opt/whisper.cpp/models/ggml-medium.en.bin -f /tmp/test.wav
If this works, the Whisper stage is ready.
8. Create the sherpa-onnx environment
python3 -m venv /opt/sherpa-onnx-env
/opt/sherpa-onnx-env/bin/pip install --upgrade pip
/opt/sherpa-onnx-env/bin/pip install sherpa-onnx sherpa-onnx-bin

The official sherpa-onnx documentation provides CPU wheels for Linux aarch64, so a Raspberry Pi 5 can use the CPU package.
Verify:
/opt/sherpa-onnx-env/bin/python -c “import sherpa_onnx; print(sherpa_onnx.version)”
-otxt-oj -ojf -l en -t 4
9. Create the diarization model directory
sudo mkdir -p /opt/sherpa-onnx-models

  1. Install the pyannote segmentation model
    Create:
    sudo mkdir -p /opt/sherpa-onnx-models/sherpa-onnx-pyannote-segmentation-3-0

Download the official sherpa-onnx pyannote segmentation model and place:
model.int8.onnx

at:
/opt/sherpa-onnx-models/sherpa-onnx-pyannote-segmentation-3-0/model.int8.onnx

Verify:
ls -lh /opt/sherpa-onnx-models/sherpa-onnx-pyannote-segmentation-3-0/model.int8.onnx

  1. Install the speaker embedding model
    Use:
    3dspeaker_speech_eres2net_base_sv_zh-cn_3dspeaker_16k.onnx

Place it at:
/opt/sherpa-onnx-models/3dspeaker_speech_eres2net_base_sv_zh-cn_3dspeaker_16k.onnx

Verify:
ls -lh /opt/sherpa-onnx-models/3dspeaker_speech_eres2net_base_sv_zh-cn_3dspeaker_16k.onnx

  1. Verify sherpa-onnx and models
    /opt/sherpa-onnx-env/bin/python - <<‘PY’
    import sherpa_onnx
    from pathlib import Path
    seg = Path(“/opt/sherpa-onnx-models/sherpa-onnx-pyannote-segmentation-3-0/model.int8.onnx”)
    emb = Path(“/opt/sherpa-onnx-models/3dspeaker_speech_eres2net_base_sv_zh-cn_3dspeaker_16k.onnx”)
    print(“sherpa-onnx:”, sherpa_onnx.version)
    print(“segmentation:”, seg.exists())
    print(“embedding:”, emb.exists())
    PY

Both model checks must be True.
13. Create the production script
Create:
sudo nano /opt/transcribe_calls.py

Paste the complete production script from Appendix A.
Change only this value if the recording directory differs:
INPUT_DIR = Path(“/mnt/eass-calls”)
The important fixed paths are:
WORK_DIR = INPUT_DIR / “.transcription_work”
OUTPUT_DIR = INPUT_DIR / “transcripts”
WHISPER_BIN = Path(“/opt/whisper.cpp/build/bin/whisper-cli”)
WHISPER_MODEL = Path(“/opt/whisper.cpp/models/ggml-medium.en.bin”)
SEGMENTATION_MODEL = Path(
“/opt/sherpa-onnx-models/”
“sherpa-onnx-pyannote-segmentation-3-0/model.int8.onnx”
)
EMBEDDING_MODEL = Path(
“/opt/sherpa-onnx-models/”
“3dspeaker_speech_eres2net_base_sv_zh-cn_3dspeaker_16k.onnx”
)

  1. Check the script
    python3 -m py_compile /opt/transcribe_calls.py

No output means the syntax check passed.
15. Create the wrapper
sudo mkdir -p /opt/call-pipeline
sudo nano /opt/call-pipeline/run-calls.sh

Put:
#!/bin/sh
exec /opt/sherpa-onnx-env/bin/python /opt/transcribe_calls.py “$@”

Then:
sudo chmod +x /opt/call-pipeline/run-calls.sh

  1. Create the transcribe command
    sudo ln -sf /opt/call-pipeline/run-calls.sh /usr/local/bin/transcribe

Check:
ls -l /usr/local/bin/transcribe

  1. Test the command
    transcribe --help

It should show:
–no-diarization

and explain that normal operation performs transcription plus two-speaker diarization.
18. Test transcription-only mode
Put a short recording in the input directory and run:
transcribe --no-diarization
Check:
ls -lh /mnt/eass-calls/transcripts/

Adjust the path if INPUT_DIR is different.
19. Test the complete pipeline
Run:
transcribe

This enables two-speaker diarization.
Test with a short recording before processing important long calls.
20. Run production processing
Once testing is successful:
transcribe

Supported files:
.wav
.mp3
.amr

Only top-level recordings are processed.
21. Use transcription without speaker diarization
transcribe --no-diarization

This skips speaker identification but still creates timestamped transcription.
22. Output structure
Example:
/mnt/eass-calls/
├── call.wav
├── optimized/
├── .transcription_work/
└── transcripts/
├── call.txt
└── call.json

optimized/ is ignored.
.transcription_work/ stores intermediate work.
transcripts/ stores final results.
23. TXT output
Example:
[00:00.00 - 00:05.44] Speaker 2:
Hello, we’d like to speak to you, John. Speaking.
[00:05.44 - 00:09.24] Speaker 2:
Hi, my name is Terri, I’m calling to ask you if you’d like to speak to me?
[00:09.24 - 00:16.04] Speaker 1:Oh, hello. Yeah, I was just on my way out but I’ll come back in again to speak to you.

  1. JSON output
    Each entry contains:
    {
    “start”: 0.0,
    “end”: 5.44,
    “speaker”: 1,
    “speaker_label”: “Speaker 2”,
    “speaker_overlap”: 1.5018751621246338,
    “text”: “Hello, we’d like to speak to you, John. Speaking.”
    }

Speaker 1 and Speaker 2 are anonymous diarization labels, not real identities.
25. Understand the two-speaker setting
The production configuration uses:
num_clusters=2

It is intended for two-person calls.
If a recording has three or more speakers, assignments can be wrong.
26. Understand restartability
If both final files already exist:
transcripts/.txt
transcripts/.json

the recording is skipped.
Example:
SKIP: call.wav (completed)

Therefore it is safe to run:
transcribe

again after adding new recordings.
27. Original recordings are protected
The original WAV/MP3/AMR files are not overwritten.
ffmpeg creates normalized audio inside:
.transcription_work/

  1. Run long jobs safely over SSH
    Install tmux:
    sudo apt-get install -y tmux

Start:
tmux new -s transcription

Run:transcribe

Detach with:
Ctrl+B
D

Later:
tmux attach -t transcription

  1. Do not run two jobs simultaneously
    Do not start two transcribe processes against the same input directory.
    Use one production process at a time.
  2. Monitor disk space
    df -h

Long recordings create temporary normalized audio and require space for the model and final outputs.
31. Monitor memory
free -h

The reference setup used 16 GB RAM.
32. Keep the Pi cool
Use active cooling and good ventilation for long transcription jobs.
33. Check all important components
test -x /opt/whisper.cpp/build/bin/whisper-cli && echo “whisper-cli OK”
test -f /opt/whisper.cpp/models/ggml-medium.en.bin && echo “Whisper model OK”
test -f /opt/sherpa-onnx-models/sherpa-onnx-pyannote-segmentation-3-0/model.int8.onnx && echo “Segmentation OK”
test -f /opt/sherpa-onnx-models/3dspeaker_speech_eres2net_base_sv_zh-cn_3dspeaker_16k.onnx && echo “Embedding OK”
test -x /opt/call-pipeline/run-calls.sh && echo “Wrapper OK”
test -L /usr/local/bin/transcribe && echo “Command OK”

  1. Troubleshoot a missing command
    ls -l /usr/local/bin/transcribe

If necessary:
sudo ln -sf /opt/call-pipeline/run-calls.sh /usr/local/bin/transcribe
hash -r
transcribe --help

  1. Troubleshoot sherpa-onnx
    /opt/sherpa-onnx-env/bin/python -c “import sherpa_onnx; print(sherpa_onnx.version)”

If missing:
/opt/sherpa-onnx-env/bin/pip install --upgrade sherpa-onnx sherpa-onnx-bin

  1. Troubleshoot modelsls -lh /opt/whisper.cpp/models/ggml-medium.en.bin
    ls -lh /opt/sherpa-onnx-models/sherpa-onnx-pyannote-segmentation-3-0/model.int8.onnx
    ls -lh /opt/sherpa-onnx-models/3dspeaker_speech_eres2net_base_sv_zh-cn_3dspeaker_16k.onnx

  2. Troubleshoot an audio file
    ffprobe /path/to/file.wav

The pipeline normalizes audio to:
16 kHz
mono
16-bit PCM WAV

  1. Troubleshoot diarization
    If transcription works but diarization fails:
    Check both diarization model files.
    Check the sherpa-onnx import.
    Test a short call.
    Read the error message.
    Only then retry long calls.
  2. Privacy
    The processing path is local:
    recording
    → local ffmpeg
    → local whisper.cpp
    → local sherpa-onnx
    → local TXT/JSON

No OpenRouter audio-transcription credit is required.
Keep API keys/tokens out of the script and guide.
40. Back up the configuration
sudo tar -czf /root/call-transcription-config.tar.gz
If offline rebuilding is important, also preserve the model files.
/opt/transcribe_calls.py/opt/call-pipeline/run-calls.sh
41. Record versions
uname -m
python3 --version
ffmpeg -version | head -n 1
cmake --version | head -n 1
/opt/sherpa-onnx-env/bin/python -c
Also:
cd /opt/whisper.cpp
git rev-parse HEAD

“import sherpa_onnx; print(‘sherpa-onnx’, sherpa_onnx.version)”
42. Final test
Put one short test call in the input directory.
Run:
transcribe
Confirm:
transcripts/test-call.txt
transcripts/test-call.json

Then:
transcribe --no-diarization

The Pi is ready when both modes work.
43. Normal operation
Default:
transcribe

Transcription only:
transcribe --no-diarization

Help:
transcribe --help

Appendix A — Production script
Paste this into /opt/transcribe_calls.py:
#!/usr/bin/env python3
import argparse
import json
import subprocess
import sys
from pathlib import Path
import numpy as np
import sherpa_onnx
INPUT_DIR = Path(“/mnt/eass-calls”)
WORK_DIR = INPUT_DIR / “.transcription_work”
OUTPUT_DIR = INPUT_DIR / “transcripts”
WHISPER_BIN = Path(“/opt/whisper.cpp/build/bin/whisper-cli”)
WHISPER_MODEL = Path(“/opt/whisper.cpp/models/ggml-medium.en.bin”)
SEGMENTATION_MODEL = Path(
“/opt/sherpa-onnx-models/”
“sherpa-onnx-pyannote-segmentation-3-0/model.int8.onnx”
)
EMBEDDING_MODEL = Path(
“/opt/sherpa-onnx-models/”
“3dspeaker_speech_eres2net_base_sv_zh-cn_3dspeaker_16k.onnx”
)
SUPPORTED_EXTENSIONS = {“.wav”, “.mp3”, “.amr”}
def die(message):
print(f"ERROR: {message}“, file=sys.stderr)
raise SystemExit(1)
def run_command(command):
print(”+“, " “.join(str(x) for x in command))subprocess.run(command, check=True)
def prepare_audio(source, destination):
destination.parent.mkdir(parents=True, exist_ok=True)
if destination.exists():
return
run_command([
“ffmpeg”, “-y”, “-i”, str(source),
“-ar”, “16000”, “-ac”, “1”,
“-c:a”, “pcm_s16le”, str(destination)
])
def run_whisper(audio_path, whisper_work):
whisper_work.mkdir(parents=True, exist_ok=True)
json_path = whisper_work / “whisper.json”
if json_path.exists():
return json_path
prefix = whisper_work / “whisper”
run_command([
str(WHISPER_BIN),
“-m”, str(WHISPER_MODEL),
“-f”, str(audio_path),
“-otxt”, “-oj”, “-ojf”,
“-of”, str(prefix),
“-l”, “en”,
“-t”, “4”,
])
if not json_path.exists():
die(f"Whisper did not create {json_path}”)
return json_path
def load_whisper_segments(json_path):
with json_path.open(“r”, encoding=“utf-8”) as f:
data = json.load(f)
segments =
for item in data.get(“transcription”, ):
start = float(item.get(“offsets”, {}).get(“from”, 0)) / 1000.0
end = float(item.get(“offsets”, {}).get(“to”, 0)) / 1000.0
text = item.get(“text”, “”).strip()
if text and end > start:
segments.append({“start”: start, “end”: end, “text”: text})
return segments
def load_audio_samples(wav_path):
import wave
with wave.open(str(wav_path), “rb”) as wf:
channels = wf.getnchannels()
sample_width = wf.getsampwidth()
sample_rate = wf.getframerate()
frames = wf.readframes(wf.getnframes())
if channels != 1:
die(f"Expected mono audio, got {channels} channels”)
if sample_width != 2:
die(f"Expected 16-bit PCM audio, got {sample_width * 8}-bit")
if sample_rate != 16000:
die(f"Expected 16 kHz audio, got {sample_rate} Hz")
return np.frombuffer(frames, dtype=np.int16).astype(np.float32) / 32768.0
def make_diarization_config():
seg_config = sherpa_onnx.OfflineSpeakerSegmentationPyannoteModelConfig(
model=str(SEGMENTATION_MODEL),
window_shift_ratio=0.1,
)
seg_base = sherpa_onnx.OfflineSpeakerSegmentationModelConfig(
pyannote=seg_config
)
emb_config = sherpa_onnx.SpeakerEmbeddingExtractorConfig(
model=str(EMBEDDING_MODEL),
num_threads=4,
provider=“cpu”,
)cluster_config = sherpa_onnx.FastClusteringConfig(
num_clusters=2,
threshold=0.5,
)
return sherpa_onnx.OfflineSpeakerDiarizationConfig(
segmentation=seg_base,
embedding=emb_config,
clustering=cluster_config,
min_duration_on=0.3,
min_duration_off=0.5,
)
def run_diarization(audio_path):
samples = load_audio_samples(audio_path)
diarization = sherpa_onnx.OfflineSpeakerDiarization(
make_diarization_config()
)
result = diarization.process(samples)
segments =
for segment in result:
start = float(segment.start)
end = float(segment.end)
speaker = int(segment.speaker)
if end > start:
segments.append({
“start”: start,
“end”: end,
“speaker”: speaker,
})
return segments
def overlap(a_start, a_end, b_start, b_end):
return max(0.0, min(a_end, b_end) - max(a_start, b_start))
def assign_speaker(transcript_segment, diarization_segments):
best_speaker = None
best_overlap = 0.0
for diar in diarization_segments:
amount = overlap(
transcript_segment[“start”],
transcript_segment[“end”],
diar[“start”],
diar[“end”],
)
if amount > best_overlap:
best_overlap = amount
best_speaker = diar[“speaker”]
return best_speaker, best_overlap
def speaker_label(speaker):
return None if speaker is None else f"Speaker {speaker + 1}"
def write_outputs(source, transcript_segments, diarization_segments):
OUTPUT_DIR.mkdir(parents=True, exist_ok=True)
txt_path = OUTPUT_DIR / f"{source.stem}.txt"
json_path = OUTPUT_DIR / f"{source.stem}.json"
final =
for item in transcript_segments:
speaker, speaker_overlap = assign_speaker(
item, diarization_segments
)
final.append({
“start”: item[“start”],
“end”: item[“end”],
“speaker”: None if speaker is None else speaker,
“speaker_label”: speaker_label(speaker),
“speaker_overlap”: speaker_overlap,
“text”: item[“text”],
})
with json_path.open(“w”, encoding=“utf-8”) as f:
json.dump(final, f, ensure_ascii=False, indent=2)with txt_path.open(“w”, encoding=“utf-8”) as f:
for item in final:
start = item[“start”]
end = item[“end”]
sm = int(start // 60)
ss = start - sm * 60
em = int(end // 60)
es = end - em * 60
f.write(
f"[{sm:02d}:{ss:05.2f} - {em:02d}:{es:05.2f}] "
f"{item[‘speaker_label’]}:
"
)
f.write(item[“text”])
f.write("
“)
return txt_path, json_path
def write_transcription_only_outputs(source, transcript_segments):
OUTPUT_DIR.mkdir(parents=True, exist_ok=True)
txt_path = OUTPUT_DIR / f”{source.stem}.txt"
json_path = OUTPUT_DIR / f"{source.stem}.json"
final = [{
“start”: item[“start”],
“end”: item[“end”],
“speaker”: None,
“speaker_label”: None,
“speaker_overlap”: 0.0,
“text”: item[“text”],
} for item in transcript_segments]
with json_path.open(“w”, encoding=“utf-8”) as f:
json.dump(final, f, ensure_ascii=False, indent=2)
with txt_path.open(“w”, encoding=“utf-8”) as f:
for item in final:
start = item[“start”]
end = item[“end”]
sm = int(start // 60)
ss = start - sm * 60
em = int(end // 60)
es = end - em * 60
f.write(f"[{sm:02d}:{ss:05.2f} - {em:02d}:{es:05.2f}]:
“)
f.write(item[“text”])
f.write(”
“)
return txt_path, json_path
def process_recording(source, diarize):
WORK_DIR.mkdir(parents=True, exist_ok=True)
OUTPUT_DIR.mkdir(parents=True, exist_ok=True)
txt_path = OUTPUT_DIR / f”{source.stem}.txt"
json_path = OUTPUT_DIR / f"{source.stem}.json"
if txt_path.exists() and json_path.exists():
print(f"SKIP: {source.name} (completed)“)
return
work = WORK_DIR / source.stem
audio_path = work / “audio-16k-mono.wav”
whisper_work = work / “whisper”
print()
print(”=" * 70)
print(f"PROCESSING: {source.name}“)
print(”=" * 70)
prepare_audio(source, audio_path)
whisper_json = run_whisper(audio_path, whisper_work)transcript_segments = load_whisper_segments(whisper_json)
if not transcript_segments:
die(f"No transcript segments found for {source}“)
if diarize:
print(“Running 2-speaker diarization…”)
diarization_segments = run_diarization(audio_path)
txt, js = write_outputs(
source, transcript_segments, diarization_segments
)
else:
print(“Diarization disabled.”)
txt, js = write_transcription_only_outputs(
source, transcript_segments
)
print(f"TXT: {txt}”)
print(f"JSON: {js}“)
def main():
parser = argparse.ArgumentParser(
description=“Transcribe and diarize WAV, MP3, and AMR call recordings.”
)
parser.add_argument(
“–no-diarization”,
action=“store_true”,
help=“Transcribe only; skip speaker diarization.”,
)
args = parser.parse_args()
if not INPUT_DIR.exists():
die(f"Input directory does not exist: {INPUT_DIR}”)
if not WHISPER_BIN.exists():
die(f"Whisper executable not found: {WHISPER_BIN}“)
if not WHISPER_MODEL.exists():
die(f"Whisper model not found: {WHISPER_MODEL}”)
if not args.no_diarization:
if not SEGMENTATION_MODEL.exists():
die(f"Segmentation model not found: {SEGMENTATION_MODEL}“)
if not EMBEDDING_MODEL.exists():
die(f"Embedding model not found: {EMBEDDING_MODEL}”)
recordings = sorted(
p for p in INPUT_DIR.iterdir()
if p.is_file() and p.suffix.lower() in SUPPORTED_EXTENSIONS
)
print(f"Found {len(recordings)} recording(s).“)
print(
“Mode: transcription only”
if args.no_diarization
else “Mode: transcription + diarization”
)
for source in recordings:
process_recording(source, diarize=not args.no_diarization)
print()
print(”=" * 70)
print(“ALL DONE”)
print(“=” * 70)
if name == “main”:
main()

Appendix B — Final checklistRun:
uname -m
/opt/whisper.cpp/build/bin/whisper-cli --help
ls -lh /opt/whisper.cpp/models/ggml-medium.en.bin
/opt/sherpa-onnx-env/bin/python -c “import sherpa_onnx; print(sherpa_onnx.version)”
python3 -m py_compile /opt/transcribe_calls.py
transcribe --help

Then test:
transcribe

and:
transcribe --no-diarization

Quick reference
Default:
transcribe

Transcription only:
transcribe --no-diarization

Help:
transcribe --help

Final results:
<INPUT_DIR>/transcripts/

Temporary processing:
<INPUT_DIR>/.transcription_work/

Original recordings are left untouched