Skip to content

scriba

scriba takes a recorded conversation and writes a Markdown document that says who said what. Transcription is whisperX, diarization is pyannote, and both run on your own machine. The voice registry is the part worth having: pyannote computes a 256-dimensional embedding per speaker anyway, and scriba files it under the name you assign. The same person is picked out by name in the next recording instead of arriving as SPEAKER_02.

Terminal window
scriba run meeting.m4a
scriba name meeting.m4a SPEAKER_00=Ada SPEAKER_01=Rafiq
scriba run tomorrow.m4a # Ada and Rafiq are picked out on their own

A SwiftUI app sits on top of the Python command line tool. It implements none of the work: it runs the same scriba and reads its JSON state.

  • It does not run anywhere except macOS on Apple Silicon, with Python 3.10 or newer, ffmpeg, and a Hugging Face account for the pyannote models.
  • It does not transcribe faster than the recording lasts on a stock install. ctranslate2, the engine under faster-whisper, has no Metal backend in any released version, so transcription stays on the CPU. Metal sits behind an open pull request you have to build yourself.
  • It does not guess a name when two enrolled voices come out close. A match wants cosine similarity of 0.75 and a margin of 0.05 over the runner-up; between 0.55 and 0.75 it suggests and waits. A wrong name raises no error and quietly poisons every transcript downstream, so an unanswered question is the better failure.
  • It does not treat those thresholds as published constants. They are a starting point to calibrate on your own material, and for diarization in Italian there is no benchmark to argue with.
  • It does not hide the voices it could not identify. Every line spoken by one says so inside the document, and the header lists them too.
  • It is an afternoon’s worth of code, written in one sitting, paired with Claude. Take it for what it is.
Terminal window
brew install ffmpeg
conda create -n scriba python=3.11 -y && conda activate scriba
pip install git+https://github.com/nerln/scriba

A plain virtual environment does the same job. The scriba command lives only inside that environment, so every use starts by activating it. The README carries a launcher for ~/.local/bin that finds it wherever conda put it.

The pyannote token goes into the Keychain rather than into a file:

Terminal window
scriba token hf_xxxxxxxx

Then accept the conditions on speaker-diarization-3.1, segmentation-3.0 and speaker-diarization-community-1 with the same account. Skip the third and 3.1 breaks too under pyannote 4, which pulls one of its files from there, and the failure is a bare 403.

pyannote 4 ships a telemetry module that reports on every model load and every file processed. scriba turns it off in scriba/__init__.py, before any import can reach pyannote.

Terminal window
scriba run recording.m4a
scriba run meeting.m4a --lang es --min-speakers 2 --max-speakers 2
scriba dossier meeting.m4a # who talks how much, longest turns, names said out loud
scriba name meeting.m4a SPEAKER_00=Ada SPEAKER_01=Rafiq
scriba show meeting.m4a # where the document went
scriba voices list
scriba watch ~/Memos
scriba jobs list

scriba whoami ~/Recordings diarizes a folder without transcribing it, compares speakers across recordings while ignoring pairs from inside the same one, and reports the voice present in the most, which is whoever owns the microphone. Confirm with --name and every print of that voice is enrolled at once. The same scan prints where the same-speaker and different-speaker populations separate in your own audio, so the thresholds above are measured rather than assumed.

Every stage is cached under ~/.scriba/jobs/<name>/, so redoing the names re-transcribes nothing. --force asr|diar|lang|all reruns the stage you name.

  • lang.py samples five windows across the file and votes, weighting each by confidence. Left alone, Whisper picks the language from the first 30 seconds, which in a conversation is small talk, and a wrong guess raises no error: the output is fluent invented text.
  • diarize.py calls pyannote directly, which keeps one file working on pyannote 3 and 4 alike and preserves the segmentation whisperX’s wrapper drops.
  • Whisper cuts roughly every 30 seconds whoever is speaking. On a seven-minute test file that gave 16 blocks, each holding more than one speaker. Word-level realignment turned it into 41 turns.
  • export.py writes a readable document first: participants, duration and language in a header, then turns with the name in bold and a timestamp. Five other formats come out of the same run: plain Markdown, text, SRT, VTT, JSON.

pytest runs the suite in about eight seconds, because none of the tests load a model. They were written by agents pointed at one module each, told to report defects rather than fix them, which turned up around twenty. The commit log holds the ones worth reading: a registry save that could hand one person’s voice print to another, and a language vote that reported a file nobody could identify as its most confident result.

Metal diarization became the default only after its output was compared against the CPU: same 165 turns, same labels, every boundary matching to the millisecond. pyannote issue 1337 reports wrong timestamps under Metal and was closed with no fix, so diarize_device=cpu stays available.

Measured on an M4 with 16 GB, 6:45 of Spanish audio, same settings, warm cache.

Stage whisperX 3.3.1 whisperX 3.8.6
Load large-v3 int8 9.1s 34.7s
Transcription, 8 threads 601.8s 443.1s
Load the aligner 0.5s 2.8s
Forced alignment, word level 13.3s 14.1s
Diarization, pyannote 3.1 on CPU 372.3s 211.0s
Diarization, community-1 on CPU 225.9s
Diarization, community-1 on Metal 35.7s
Total, best of each 16m 37s 8m 13s

The newer whisperX is 26% faster on the transcription. That is not the reason to move to it. In 3.3.1 every token containing digits came out of the aligner with no timestamp at all, 5 out of 5 on the test file. In 3.8.6 that is 0 out of 7.

Two checks run before a commit. tools/stylecheck.py holds the writing rules for prose and comments. tools/check-output-style.py renders all six formats plus the briefing from stand-in data, because a document assembled from fragments can pass line by line and still be wrong as a whole.

Licence MIT. Source: github.com/nerln/scriba.