Notes2026.05.10
The model already knew Hebrew. I built the Mac app around it.
A Mac app that transcribes Hebrew locally with ivrit-ai’s Whisper model. My work was everything around the model, down to how long it says you’ll wait.
- Status
- Published
- Date
- 2026.05.10
Meluli is a macOS app I built for transcribing Hebrew, and the transcription runs on the Mac itself. I didn’t train a speech model for it. ivrit-ai already had a Hebrew one, based on Whisper Large V3 Turbo, and I chose to build on that. My part was everything between that model and someone who just wants what they said as text.
Transcribing is one moment in that. Before it, you need to know how to start and whether the mic is even working. While it runs, you want to see that something is happening. After it, you want to find the text again, even after you’ve closed the window. Meluli covers the whole stretch, from the first launch to copying a transcript or exporting subtitles. (It’s an Electron app, and the engine is faster-whisper running ivrit-ai/whisper-large-v3-turbo-ct2.)
Building it showed me how many design decisions sit on a technical detail. How long you wait depends on whether the model stays in memory. Subtitle export only exists because the timestamps are kept. And the floating window can have one narrow job because each window gets its own permissions.
Hebrew from the first screen
The interface runs right to left and every string is in Hebrew. The first run walks you through the microphone permission and the model download. After that you can transcribe from the microphone, an audio file in one of six formats, or a YouTube link.
Next to the main window there’s a small floating recording window with keyboard shortcuts, plus the plumbing to put the text straight into whatever field is active, through macOS’s Accessibility permission. That one choice changes what kind of app Meluli is. Recording can happen inside the work you’re already doing on the computer.
The model stays loaded
Meluli has three layers: the React interface, Electron’s main process, and a local Python worker that runs the model. Messages to the worker carry a job ID, progress updates, results and a cancel. Keeping transcription out of the interface also gave me one clear place for the awkward cases, like a missing model, a second job started while one is running, or someone hitting stop halfway.
The worker keeps the loaded model in memory and reuses it as long as the model path hasn’t changed, so a run of short recordings doesn’t rebuild the same model every time. That’s the difference you feel between the first recording and the ones after it.
On a Mac this path runs on the CPU, with INT8 as the compute type.
One guess or five
Whisper writes text a step at a time, and at each step it can keep several guesses going. That’s the beam size, and it was one of the main things I tuned. The code has three modes:
| When | Beam size |
|---|---|
| Microphone, balanced mode | 1 |
| File or YouTube, balanced mode | 3 |
| The mode called “Accurate” | 5 |
Short dictation gets one, and the comment above that line in the code says why: Microphone dictation prefers lower latency over small accuracy gains. Files get a wider search.
I also set the language to Hebrew instead of letting the model detect it, and kept timestamps per segment, with word timestamps off. Segment timestamps are all the SRT export needs.
Cutting the quiet parts
Audio gets handled differently depending on where it came from. For files and YouTube, Meluli runs voice activity detection, which finds the speech and drops the silences. A silence has to last 500 ms before it’s cut, and 200 ms of margin stays on each side of the speech. For the microphone, VAD is off.
Cut too eagerly and you clip the edges of words. So the worker logs how many segments it found, how long they are on average and how much of the file they cover, and it warns when something looks off.
Waiting is part of the interface
One of my favorite details is the processing estimate. It works from the audio’s length and from past runs, kept separately for each source and speed setting, and it tracks setup time apart from time per second of audio. After the first run, each new measurement counts for 25% of the running average, so one odd run only pulls it a quarter of the way toward itself.
While a job runs, the estimate also watches the actual pace. Early on the pace is jumpy, so it gets 20% of the say and the history gets the rest. Past 12% of the job it gets 45%, past 28% it gets 65%, and past 55% it gets 82%. And the estimate always stays at least 750 ms ahead of the time already spent, so it never says the job is done while it’s still going.
When a wait runs long anyway, there’s tooling to time the app’s startup and save Electron traces, so I can see where it’s coming from.
Text you can actually use
After the speech is recognized, a cleanup layer fixes spacing and punctuation and splits the text into paragraphs. It can also remove filler words and immediate repeats. It works by rules, using things like question words and cues that the topic changed to suggest a structure. That keeps it local and simple, and it also means it doesn’t understand what a sentence says.
Transcripts are saved in SQLite next to plain text files, and searching the history uses FTS5. A transcript with segment timestamps can be exported as SRT, and older ones from before the timestamps still open fine. Adding the export didn’t mean converting the whole history.
Benchmarking on one M3
The most measurable part of the work is a whisper.cpp experiment saved in the project on March 19, 2026. On an Apple M3 I tried 24 configurations: beam size 1, 2 or 5, two, four, six or eight threads, and Flash Attention on or off. Each one ran cold and warm on three files: a short clean recording, a long one with pauses and a noisy one. There were no repeat runs, so small gaps can be noise.
The baseline was beam 5, four threads and Flash Attention off. The report recommended beam 1, two threads and Flash Attention on. Here are the warm times:
| Recording | Audio | Baseline | Recommended | Time saved |
|---|---|---|---|---|
| Short and clean | 23.79 s | 2.95 s | 2.11 s | 28.5% |
| Long, with pauses | 408.48 s | 81.52 s | 50.78 s | 37.7% |
| Noisy | 92.47 s | 18.20 s | 14.38 s | 21.0% |
| All three | 524.74 s | 102.66 s | 67.27 s | 34.5% |
So about 8 minutes and 45 seconds of audio took about 67 seconds, roughly 7.8 times faster than real time. These are whisper.cpp numbers, and the app as it ships runs faster-whisper, for reasons further down.
More threads didn’t help. With beam 1 and Flash Attention on, two threads had the lowest average warm time of the four thread counts, by a small margin. That gave me a cautious default for the M3. The profiles for other chips are marked in the code as estimates, since I didn’t measure every one.
To see what the tuning did to the output, the report compares each transcript with the baseline’s using a normalized edit distance. The recommended setup differed by about 1.79% on average, under the 5% limit the experiment set.
That’s a gap between two outputs. Neither was checked against what was actually said, and an edit distance can’t tell whether an important word changed. How accurate Meluli is in Hebrew is still open, and answering it takes Hebrew recordings with transcripts a person has checked.
Why the fast path isn’t the one running
The code has groundwork for whisper.cpp, including Metal and its own speech detection. The reports from March 20 show two models failing on the Metal path while the CPU control runs passed in the same runtime. Then I ran whisper-cli on its own, and it failed outside Meluli’s worker too. That points at something wider, in the Metal path or in the setup on that machine, and I never pinned down the root cause.
So in the current code the whisper.cpp path stays limited, and faster-whisper is the one that runs.
Each window gets its own permissions
In a desktop app the interface can ask for things that touch the whole computer, so Meluli draws lines between the windows and the main process. The windows run with context isolation and no direct Node access, with the sandbox on where it’s compatible. Every IPC call is checked against the window that sent it, where it came from and whether that window is allowed to do that particular thing.
The main window and the floating one have different jobs, so they get different permissions. Managing the history and the models belongs to the main window, and typing into the active field belongs to the floating one. The automated tests check that split too.