The project's README from github.com/per-simmons/murmur-youtube. As one markdown file: README.md.
Push-to-talk dictation for macOS. Hold a key, talk, release — cleaned-up text lands in whatever text field has focus. A Wispr Flow-shaped app, built native and fully on-device.
Status: working skeleton. Builds, launches, arms the hotkey, transcribes, injects. Branding and the LLM cleanup tier are the next passes.
This app is built to run alongside other dictation tools without colliding with them, which is not automatic on macOS and is worth understanding before changing anything:
ai.pivotstudio.murmur-youtube — TCC keys Accessibility and Microphone
grants to the bundle ID, so granting or revoking a permission here has no effect on any
other app, and vice versa.MurmurYouTube — distinct enough that pkill -x MurmurYouTube cannot
match a differently-named binary. The Makefile only ever targets $(EXEC).If you run more than one dictation app, give each a different push-to-talk key. Two apps on the same key both record, and whichever injects text will fight the other.
make install # builds, bundles, signs, copies to /Applications, launches
Then grant two permissions — neither is optional, and neither can be requested silently:
| Permission | Where | Needed for |
|---|---|---|
| Accessibility | System Settings ▸ Privacy & Security ▸ Accessibility | The CGEventTap that sees the hotkey, and the AX text insert |
| Microphone | Prompted on first dictation | Audio capture |
Restart Murmur YouTube after granting Accessibility. Then hold Right ⌥ and talk.
TCC stores a code-signing requirement per entry, not just a path. An ad-hoc signature changes on every build, so the rebuilt binary stops satisfying the stored requirement — and the symptom is nasty: the Accessibility toggle still shows as on while the app is reported untrusted, and flipping it changes nothing because the stale row is the problem.
The Makefile therefore signs with a stable Developer ID (auto-detected via
security find-identity, falling back to ad-hoc). Verified: rebuild + reinstall keeps both
grants with no re-prompt.
If a grant ever does get wedged, reset that one row and re-add — never toggle:
tccutil reset Accessibility ai.pivotstudio.murmur-youtube
tccutil reset Microphone ai.pivotstudio.murmur-youtube
Always pass the bundle ID. A bare tccutil reset Accessibility wipes every app on the
machine. Then quit System Settings entirely (⌘Q) before reopening — that pane caches its
list and will otherwise show the row you just deleted.
Keep the build out of iCloud.
~/Desktopand~/Documentsare file-provider synced on this machine; the sync engine can materialize/dematerialize files inside an.appand corrupt its signature.make installputs the running copy in/Applications.
Other targets: make app (bundle only), make run (run in place), make clean.
hold key ─► HotkeyMonitor ──► DictationController ◄── Settings
│
┌──────────┼──────────┐
▼ ▼ ▼
AudioCapture HUDPanel TranscriptionEngine
│ │
(AudioChunk) ──ordered──► AppleSpeechEngine
│
(transcript)
▼
TextFormatter
▼
TextInjector ─► focused app
The HUD must never take focus. HUDPanel is a .nonactivatingPanel with
canBecomeKey == false. This is the load-bearing detail of the whole app: if the overlay
took key status, the user's text field would lose focus and there'd be nothing left to
inject into. Everything else is replaceable; this isn't.
The hotkey needs a CGEventTap, not NSEvent. fn and left/right modifier
discrimination don't surface through NSEvent.addGlobalMonitorForEvents or the Carbon
hotkey API. A session event tap is the only way to see them — which is why Accessibility
permission is a hard requirement rather than a nicety.
Audio ordering is explicit. AudioCapture yields into an AsyncStream drained by a
single task. Spawning a Task per buffer would be simpler and would silently corrupt the
transcript, because unstructured tasks have no ordering guarantee.
Buffers are copied, never borrowed. AVAudioEngine recycles the buffer it hands to a
tap the instant the callback returns. AudioChunk's @unchecked Sendable is only sound
because AudioCapture always allocates fresh storage before handing off.
Two swappable seams. TranscriptionEngine and TextFormatter are protocols so the
two components most likely to change can change without touching anything else.
Sources/MurmurYouTube/
├── MurmurYouTubeApp.swift @main, AppDelegate, MenuBarExtra
├── Core/
│ ├── DictationController.swift state machine, wires everything
│ ├── HotkeyMonitor.swift CGEventTap on .flagsChanged
│ ├── AudioCapture.swift AVAudioEngine tap + format conversion + RMS
│ └── TextInjector.swift AX insert, pasteboard+⌘V fallback
├── Transcription/
│ ├── TranscriptionEngine.swift protocol + AudioChunk
│ └── AppleSpeechEngine.swift SpeechAnalyzer / SpeechTranscriber
├── Formatting/
│ └── TextFormatter.swift protocol + RuleBasedFormatter
├── UI/
│ ├── HUDPanel.swift non-activating floating panel
│ └── HUDView.swift waveform + live transcript, Brand palette
└── Support/
├── Settings.swift, Permissions.swift, Log.swift
Default is Apple's SpeechAnalyzer / SpeechTranscriber, new in macOS 26: no
dependency, no bundled model, no cloud path, real streaming with .volatileResults so
text appears while you're still talking. The OS downloads and manages model assets, so the
first run for a locale may pause on AssetInstallationRequest.
The intended upgrade is Parakeet v3 via FluidAudio (CoreML on the Neural Engine) —
measurably better English WER, ~110× realtime, ~66 MB resident. Implementing
TranscriptionEngine is the entire cost of switching; DictationController doesn't
change.
| Apple SpeechTranscriber | Parakeet v3 (FluidAudio) | Whisper large-v3 (WhisperKit) | |
|---|---|---|---|
| Dependency | none | SwiftPM | SwiftPM |
| Model download | OS-managed | ~600 MB | ~1.5 GB |
| English accuracy | good | best | good |
| Languages | many | 25 | 99 |
| Latency | low | ~80 ms | 200–500 ms |
RuleBasedFormatter strips fillers, fixes spacing, capitalizes
sentences and adds terminal punctuation — genuinely useful, entirely deterministic. The
real win is a second TextFormatter backed by Apple's on-device Foundation Models
(macOS 26) for tone, list formatting, and honoring spoken corrections, with Claude as an
optional higher-quality tier.kAXSelectedTextAttribute plus an LLM round-trip.SpeechAnalyzer
supports this through AnalysisContext / SFCustomLanguageModelData.Brand in HUDView.swift is a two-color placeholder gradient. App icon,
real palette, HUD motion design, onboarding.Driven with a synthetic Right ⌥ hold (scratchpad/ptt/ptt2.swift posts flagsChanged
events) and confirmed via /usr/bin/log show --predicate 'subsystem == "ai.pivotstudio.murmur-youtube"':
starting → listening → finishing → idle, no errors.SpeechAnalyzer starts; models already installed, no download stall.{{790, 96}, {340, 76}} without taking focus.Not yet verified: speech → transcript → cleanup → injection. Synthetic key events can't produce audio, so this needs a human to hold the key and talk.
logis shadowed in this shell — use/usr/bin/logexplicitly or it returns nothing.