100% local, privacy-first voice-to-text. A subscription-free alternative to SuperWhisper and Wispr Flow.
- 100% Local - No cloud, no data collection, no subscriptions
- Whisper STT - OpenAI Whisper via whisper.cpp for accurate transcription
- AI Formatting - Optional LLM polish via llama.cpp (also local)
- 100+ Languages - Full Whisper language support
- Universal Paste - Auto-paste to any app
- Customizable - Personal dictionary, modes, hotkeys
┌─────────────────────────────────────────┐
│ Swift macOS App │
│ (MenuBar UI, Settings, Hotkey) │
└─────────────────┬───────────────────────┘
│ C ABI / FFI
┌─────────────────▼───────────────────────┐
│ Zig Core (libbobrwhisper) │
│ ┌─────────┐ ┌─────────┐ ┌───────────┐ │
│ │ Audio │ │ Whisper │ │ llama.cpp │ │
│ │ Capture │→│ STT │→│ Formatter │ │
│ └─────────┘ └─────────┘ └───────────┘ │
└─────────────────────────────────────────┘
- macOS 15+ (Apple Silicon recommended)
- Zig 0.15.0+
- Xcode 15+
# Clone
git clone https://github.com/uzaaft/bobrwhisper
cd bobrwhisper
# Build Zig library + CLI
zig build
# Test CLI
./zig-out/bin/bobrwhisper-cli help
./zig-out/bin/bobrwhisper-cli models # Show model download URLs
./zig-out/bin/bobrwhisper-cli languages # Show supported languagesThree concerns, three libraries, all first-party code under src/ — the app is a
consumer of them too, so they live next to everything else that builds them
rather than in a separate tree. Each has a C ABI root at src/lib_*.zig and a
header under include/bobrwhisper/, following ghostty's src/lib_vt.zig and
include/ghostty/vt.h.
| library | what it is | dependencies |
|---|---|---|
libbobrwhisper-audio |
WAV decode, resample, downmix, level, VAD, chunking | none |
libbobrwhisper-capture |
microphone capture (CoreAudio, ALSA) | audio |
libbobrwhisper |
Whisper transcription (see below) | whisper.cpp + ggml |
The graph is shallow on purpose. audio depends on nothing, which makes it the
easiest thing to work on: zig build test-audio needs no model, no microphone
and no Apple toolchain. capture depends on audio for format conversion.
libwhisper depends on neither — it takes 16 kHz mono float PCM and nothing
else, which is what keeps it embeddable by callers who already have audio in that
form.
zig build libaudio # or libcapture, or libwhisper
zig build test-audio # or test-capture, or test-libwhisperCapture is polled rather than callback-driven: a backend fills a fixed-size ring
and you drain it with bobrwhisper_capture_read when convenient. A callback
would run on the audio thread, where an embedder that blocks or allocates causes
dropouts, and across a C ABI there is no way to stop one. Falling behind costs
the oldest audio, reported by bobrwhisper_capture_dropped_samples, rather than
glitching the recording. Hosts without a backend — Windows for now — link and
load, and report UNSUPPORTED_PLATFORM; check bobrwhisper_capture_is_supported
up front.
libwhisper is the UI-independent transcription core for C and Zig consumers.
It loads a caller-provided whisper.cpp model and accepts caller-provided 16 kHz
mono float PCM; it does not depend on Swift, audio capture, app settings, or
LLM formatting.
# Installs shared/static libraries, the C header, and pkg-config metadata.
zig build libwhisper -Doptimize=ReleaseFast
# Zig unit tests plus a C ABI smoke test linked against the static archive.
zig build test-libwhisper
# Benchmark the public C ABI with a 16 kHz WAV file.
zig build bench-libwhisper -Doptimize=ReleaseFast -- \
~/.bobrwhisper/models/ggml-tiny.bin sample.wav
# Reproducible Nix package (Debug and ReleaseSafe variants are also exposed).
nix build .#libwhisperInstalled outputs:
lib/libbobrwhisper.{so,dylib}andlib/libbobrwhisper.ainclude/libwhisper.hshare/pkgconfig/bobrwhisper.pc
The shared library needs nothing beyond libc and libm — Zig links libc++ into it
statically. Linking the static archive additionally requires libc++
(pkg-config --static --libs bobrwhisper reports -lc++); libstdc++ cannot
substitute, because the archive references std::__1:: symbols from libc++'s
inline namespace.
The file is named libbobrwhisper because whisper.cpp installs its own
libwhisper.so with SONAME libwhisper.so.0 and an unrelated ABI, so sharing
the name would make the two unco-installable. The API keeps the libwhisper_
prefix, which does not collide with whisper.cpp's whisper_.
#include <libwhisper.h>
libwhisper_config_s config;
libwhisper_config_init(&config);
config.model_path = "/path/to/ggml-base.en.bin";
libwhisper_t *transcriber = NULL;
if (libwhisper_create(&config, &transcriber) != LIBWHISPER_SUCCESS) return 1;
libwhisper_result_t *result = NULL;
if (libwhisper_transcribe(transcriber, samples, sample_count, NULL, &result) == LIBWHISPER_SUCCESS) {
/* Success always yields a result, so there is nothing to null-check. An
empty transcript is "" with zero segments. */
printf("%s\n", libwhisper_result_text(result, NULL));
libwhisper_result_summary_s summary;
summary.struct_size = sizeof(summary);
libwhisper_result_summary(result, &summary);
printf("%s, %zu segment(s), avg logprob %f\n",
summary.language, summary.segment_count, summary.average_logprobability);
libwhisper_result_free(result);
}
libwhisper_destroy(transcriber);A result carries the evidence the model produced while decoding — per-segment
text ranges and timestamps, average and minimum token probabilities, no-speech
probability, and the language it actually decoded — and owns all of it in one
allocation until libwhisper_result_free. It is independent of the transcriber,
so starting the next transcription cannot invalidate it and it can be read from
another thread.
libwhisper reports that evidence; it does not decide whether a transcript is
trustworthy. Which combination of weak tokens, low average probability and
no-speech evidence should reject a transcript, ask the user, or pass is caller
policy — and only the caller can explain that decision to whoever it affects.
Absent metrics are NaN rather than zero, because zero reads as maximum
confidence for a log probability; write threshold tests so a missing value falls
to the cautious branch (if (!(value > threshold))).
examples/c-smoke/main.c is the executable version of that contract, and runs
as part of zig build test-libwhisper. It stays hand-written C on purpose: it is
the only thing that checks the header compiles as C and that the pointer
contracts above hold for a C caller.
examples/bench/main.zig measures model initialization and repeated end-to-end
transcription through that same C ABI, so its numbers include the overhead an
embedder pays. It reports mean/median/p95 latency, standard deviation, real-time
factor, and audio throughput. Use --json for machine-readable output, or
inspect all options with:
zig build bench-libwhisper -- --helpscripts/fetch-speeches.nu fetches public-domain speech recordings from
LibriVox and converts them to the 16 kHz mono WAV the benchmark expects, writing
a manifest alongside them:
nu scripts/fetch-speeches.nu --max-seconds 30 # -> corpus/speeches/Profiling with poop on a 24-core machine is
what motivated the default of 4 decoder threads: 1 → 4 threads cuts wall time
71% for 4% more CPU cycles, while 4 → 8 buys only another 31% and costs 28% more
cycles with roughly flat instruction count — past 4 threads it is waiting on
memory, not computing. ReleaseSmall is not worth it here: 40% slower for 2%
less resident memory, since the footprint is model weights rather than code.
The flake exposes libwhisper, libwhisper-debug, libwhisper-releasesafe, and
libwhisper-releasefast on Darwin and Linux; libwhisper is an alias for the
ReleaseFast variant. nix flake check builds the library and runs its tests,
including the C ABI smoke test, inside the sandbox.
whisper.cpp and llama.cpp are lazy dependencies, so a plain zig build fetches
them on first use. The Nix build cannot reach the network, so it feeds Zig a
prebuilt package set via --system, described by build.zig.zon.nix. That file
is generated from build.zig.zon — regenerate it in the same commit as any
dependency change, using the zon2nix provided by the dev shell:
zon2nix --16 --nix=build.zig.zon.nix build.zig.zonBecause the Nix pins are derived rather than restated, they cannot drift from
build.zig.zon.
As a Zig package dependency, import the bobrwhisper module; it carries the
whisper.cpp bridge and its link dependencies, so no extra artifact wiring is
needed:
const bobrwhisper = b.dependency("bobrwhisper", .{ .target = target, .optimize = optimize });
exe.root_module.addImport("bobrwhisper", bobrwhisper.module("bobrwhisper"));By default, Zig-driven Xcode builds keep iOS signing disabled for fast local iteration.
Enable signing explicitly for release/distribution builds:
# Signed iOS build (Team ID required)
zig build ios -Doptimize=Release -Dxcode-sign=true -Dapple-team-id=FCWK5WR45W
# Signed macOS build (uses Team ID override; identity optional)
zig build macos -Doptimize=Release -Dxcode-sign=true -Dapple-team-id=FCWK5WR45W
# Optional explicit identity override (Developer ID example)
zig build macos -Doptimize=Release -Dxcode-sign=true \
-Dapple-team-id=FCWK5WR45W \
-Dcode-sign-identity="Developer ID Application: Your Name (FCWK5WR45W)"If -Dxcode-sign=true is set without -Dapple-team-id, the build fails fast with a clear error.
Download a Whisper model to ~/.bobrwhisper/models/:
mkdir -p ~/.bobrwhisper/models
cd ~/.bobrwhisper/models
# Tiny (75 MB) - fastest, lower accuracy
curl -LO https://huggingface.co/ggerganov/whisper.cpp/resolve/main/ggml-tiny.bin
# Small (466 MB) - good balance
curl -LO https://huggingface.co/ggerganov/whisper.cpp/resolve/main/ggml-small.bin
# Large (3.1 GB) - best accuracy
curl -LO https://huggingface.co/ggerganov/whisper.cpp/resolve/main/ggml-large-v3.binDownload a GGUF model to ~/.bobrwhisper/models/:
# Llama 3.2 1B (700 MB) - recommended
curl -L -o ~/.bobrwhisper/models/llama-3.2-1b-q4_k_m.gguf \
https://huggingface.co/bartowski/Llama-3.2-1B-Instruct-GGUF/resolve/main/Llama-3.2-1B-Instruct-Q4_K_M.gguf
# Or Qwen 2.5 0.5B (400 MB) - faster, smaller
curl -L -o ~/.bobrwhisper/models/qwen2.5-0.5b-q4_k_m.gguf \
https://huggingface.co/Qwen/Qwen2.5-0.5B-Instruct-GGUF/resolve/main/qwen2.5-0.5b-instruct-q4_k_m.gguf- Launch BobrWhisper (menubar app)
- Hold Fn key and speak
- Release to transcribe
- Text is auto-pasted to active app
- Zero network calls - Everything runs locally
- No telemetry or usage tracking
- All processing on-device
- Audio never leaves your machine
| Feature | SuperWhisper | Wispr Flow | BobrWhisper |
|---|---|---|---|
| Price | $249 lifetime | $15/mo | Free |
| Open Source | ❌ | ❌ | ✅ |
| STT Location | Local | Cloud | Local |
| LLM Location | Cloud | Cloud | Local |
| Data Training | No | Opt-out | Never |
bobrwhisper/
├── build.zig # Zig build configuration
├── build.zig.zon # Dependencies
├── include/
│ ├── bobrwhisper.h # C API header
│ └── module.modulemap
├── src/
│ ├── main.zig # C API exports
│ ├── c_api.zig # C type definitions
│ ├── App.zig # Main application
│ ├── Transcriber.zig # Whisper integration
│ ├── audio/
│ │ └── AudioCapture.zig
│ └── build/ # Build helpers (whisper.cpp, llama.cpp)
└── macos/
└── BobrWhisper/
├── App.swift
├── AppDelegate.swift
├── AppState.swift
└── Views/
├── MenuBarView.swift
└── SettingsView.swift
Business Source License 1.1 (BSL). See LICENSE for details.