Source: https://oliviercouthaud.com/mactranscriber · Status: Working · 2026 · Tags: Python, Whisper, macOS # MacTranscriber A double-click app that turns a French or English recording into a text file on an Apple Silicon Mac. Once it is set up, it works offline. Figure: Diagram. The window with its default settings. It is a drawing, not a screenshot. ## Why, and what it is I built this for my mom, who uses it to transcribe audio recordings. Whisper already does the hard part, so what I was working on was access: she doesn't use a command line, and the app had to be usable without one. That is why setup is a script that runs itself on the first double-click, why the window has only four choices, and why there is nothing to configure. It has been used, by her, for the thing it was made for. You start it by double-clicking Transcriber.command, pick an audio or video file, choose French or English and one or two speakers, and a .txt appears next to the file. It never overwrites: if name.txt exists you get name 2.txt, and if the folder isn't writable the file goes in Documents. The first launch downloads Python and the models, about 2 to 3 GB. After that it runs offline, except that choosing Best for the first time downloads about 3 GB more. All of it, models included, sits in one folder, so uninstalling is deleting the folder. Almost all of it. The launcher installs uv into ~/.local/bin if it isn't there, and uv keeps the Python it downloads, and its cache of downloaded packages, in its own places outside the folder, so deleting the folder leaves uv, that Python and that cache behind. Figure: Diagram. What the launcher creates and where. Setting HF_HOME to the folder is what keeps the Whisper models inside it. ## How it works The window is the boring part. What matters is the path from a long recording to that text file, and it is one Python file, engine.py. Tap or click a box below to see what it does, or choose one speaker or two to see what changes. ### Step through the pipeline Whisper gets one chunk at a time, with no text carried over from the chunk before. With two speakers I also ask for a timestamp on every word, and the progress bar gives transcription 70% of its length and speaker detection the rest. Figure: Diagram. The path through engine.py. With one speaker the lower lane is skipped and the filters feed straight into the renderer. Tap or click a box in the diagram, or use the buttons under it. Transcription runs on mlx-whisper. Fast uses whisper-large-v3-turbo. Best uses whisper-large-v3, the larger model, which I expect to be slower; I haven't timed the two against each other. WHISPER_MODELS = { "fast": "mlx-community/whisper-large-v3-turbo", "best": "mlx-community/whisper-large-v3-mlx", } **What is Whisper?** A speech-recognition model released by OpenAI, trained on a very large amount of audio in many languages. It takes sound in and gives text out, with a timestamp on every segment and, if you ask, on every word. I use it as it is and don't train or change it. **What is MLX?** Apple's array framework for the chips in recent Macs. The mlx-whisper package is Whisper rewritten to run on it, so the model runs on the Mac's GPU. That is why the app only runs on Apple Silicon. ## Cutting at the quiet bits Audio is decoded to mono 16 kHz with the ffmpeg binary that imageio-ffmpeg ships, so nothing has to be installed separately. A long file is then cut into chunks of about five minutes. Each cut goes at the quietest 100 ms frame within 15 seconds of the nominal boundary, so words are rarely split. I chunk because Whisper is prone to repetition loops on multi-hour audio. Each chunk is transcribed without the previous text as context (condition_on_previous_text=False), so a loop can't carry on past the end of its chunk. ### How many pieces does a recording become ### Finding the quiet moment The loudness below is made up. The rule is the real one: look at the 30 seconds around the five-minute mark and cut at the quietest 100 ms frame. Cut at 300 s + a few seconds A pause between two sentences is a better place to cut than the exact five-minute mark. Two cheap filters run after Whisper. One drops a segment that contains a phrase from a short list of things Whisper invents on silence or music, such as subtitle credits and "thanks for watching". The other drops a segment that would be the third identical one in a row. **Why would a speech model make up text?** A common explanation is that Whisper's training data included a lot of subtitled video, much of which opens or ends with a stock line, such as a subtitle credit or "thanks for watching". With silence or music and nothing to transcribe, the model tends to fall back on those lines. ## Telling two voices apart Speaker labels come from sherpa-onnx: a pyannote segmentation model finds where speech happens, a WeSpeaker embedding turns each stretch into a fingerprint of the voice, and the fingerprints are clustered into exactly two groups. It runs in a separate process, so it overlaps with transcription and can be killed on cancel. Afterwards each word gets the speaker whose segment overlaps it most. A single word sandwiched between two words of the other speaker is relabelled, and speakers are numbered by first appearance. If the diarizer fails, the transcript is still saved, with a note at the top saying it has no labels. **What is diarization?** Working out who spoke when, without knowing who anyone is. It gives you stretches of time labelled "voice A" and "voice B", and nothing more. Matching those stretches to the words from the transcription is a separate job, and it is the one the next widget shows. ### From segments to speaker labels The words and segments below are made up to show one case. The assignment code is the real rule, ported line by line from assign_speakers. In this example the word "fine" overlaps a short segment from the other voice, so overlap alone makes it a one-word Speaker 2 line in the middle of Speaker 1's sentence. The fix puts it back. ## The window The window is a small local web page. A server on 127.0.0.1 at a random port serves it, and pywebview shows it in a native window; if that isn't available it opens in the browser instead. Every API request needs a token generated at launch, and a request with a Host header other than 127.0.0.1 or localhost is refused. The window polls for status about every 0.7 seconds. It shows the phase, a progress bar, an estimate of the time left once 4% is done, and the text of each chunk as it finishes. While a job runs the app calls caffeinate so the Mac stays awake. The file picker is an AppleScript dialog. The text file is only written at the end. If you cancel, nothing is saved. ## Scope It covers French and English, and one or two speakers. The two-speaker mode always splits the voices into exactly two groups, so if you ask for two when only one person is talking it will still try to hand out two labels. The speaker model is called wespeaker_en_voxceleb, which points to English training data (VoxCeleb), and how well it separates French voices is still to be measured. The filters are simple. The phrase list is short, so anything Whisper invents that is not on it gets through. And three identical segments in a row can be real speech, in which case the third one is dropped. It runs on a Mac with an Apple chip. The first launch needs the internet, and so does the first use of Best. The double-click mostly avoids the terminal: if macOS refuses to open the file the first time, it takes a right-click and Open, and if the file arrives without permission to run, the fix is a Terminal command, which is the thing the app is meant to spare her. Deleting the folder doesn't remove uv, the copy of Python or uv's download cache that setup put outside it. ## Where it stands It works and it has been used. Next I want a timing run on a real long recording, an error rate against a transcript I know is right, and a check of the two-speaker labels on French audio. The models and libraries are other people's work: OpenAI's Whisper, Apple's MLX and the mlx-whisper package, sherpa-onnx from k2-fsa, pyannote segmentation 3.0 and WeSpeaker. - **Stack**: Python 3.12 (installed by uv), mlx-whisper, sherpa-onnx, imageio-ffmpeg, numpy, pywebview - **Made for**: My mom, to transcribe her audio recordings - **Platform**: macOS on Apple Silicon - **Status**: Working - **Code**: Not public