The open-source alternative to MacWhisper. Free, native, offline audio & video transcription for Apple Silicon, powered by Apple's MLX framework.
mlx-whisper is genuinely fast on Apple Silicon and 100% local, but the CLI is friction for non-engineers. MacWhisper is polished but $59 and closed source. There was a gap for a free, open-source, native Mac app that runs Whisper offline with a real UI.
Echo Script is that app.
brew install --cask cresenciof/echo-script/echo-scriptHomebrew taps cresenciof/homebrew-echo-script, downloads the DMG, and drops Echo Script.app into /Applications.
Because the app is not Apple-signed, macOS Gatekeeper will block the first launch. Clear the quarantine flag from the terminal:
xattr -dr com.apple.quarantine "/Applications/Echo Script.app"Then open the app normally. On macOS Sequoia and later the old "right-click → Open" trick no longer works for unsigned apps — xattr is the reliable path.
- Download the latest
.dmgfrom Releases. - Drag Echo Script into
/Applications. - Clear quarantine the same way as above:
xattr -dr com.apple.quarantine "/Applications/Echo Script.app".
The default model (mlx-community/whisper-large-v3-mlx-4bit, ~0.8 GB) downloads from Hugging Face on first use into ~/.cache/huggingface/hub. The download indicator is shown in-app. To pre-download:
pip install huggingface_hub
python -c "from huggingface_hub import snapshot_download; snapshot_download('mlx-community/whisper-large-v3-mlx-4bit')"- Drag-and-drop audio or video —
.mp3,.wav,.m4a,.flac,.ogg,.mp4,.mov,.mkv,.webm,.avi,.m4v - Waveform editor — pick a region of the file to transcribe (instead of the whole thing)
- Live segment streaming — segments appear as they're decoded, not after
- Cancel in-flight transcription — no waiting through a 30-minute file you no longer need
- Click-to-seek transcript — click a segment, the audio jumps there
- Export — TXT, SRT, VTT, Markdown, and for videos: soft subtitles (track) or burned-in subtitles
- Idle model unloading — frees ~3 GB of unified memory when idle for 5 min
- Offline after setup — the only network access is the one-time model download from Hugging Face; transcription itself never touches the network. No cloud, no telemetry, no analytics.
- Multiple Whisper models — 4-bit quantized (default), turbo, large, medium, small
| Feature | Echo Script | MacWhisper | Aiko | whisper.cpp |
|---|---|---|---|---|
| Price | Free (MIT) | $59 | Free | Free |
| Open source | ✅ | ❌ | ❌ | ✅ |
| Native macOS UI | ✅ | ✅ | ✅ | ❌ (CLI) |
| Apple Silicon optimized | ✅ MLX | ✅ Core ML | ✅ Core ML | ✅ Metal |
| Offline transcription¹ | ✅ | ✅ | ✅ | ✅ |
| Drag-and-drop video | ✅ | ✅ | ❌ | ❌ |
| Waveform region selection | ✅ | ✅ | ❌ | ❌ |
| Live segment streaming | ✅ | ✅ | ❌ | partial |
| Cancel in-flight | ✅ | ✅ | ❌ | n/a |
| Burned-in subtitles for video | ✅ | ✅ | ❌ | ❌ |
| Speaker diarization | ❌ | ✅ | ❌ | ❌ |
| Translation | planned | ✅ | ✅ | ✅ |
| Batch queue | ❌ | ✅ | ❌ | n/a |
¹ All four download the Whisper model once over the network; after that, transcription runs fully offline.
Honest note: MacWhisper has years of polish and features Echo Script doesn't (yet). Echo Script targets the core "drop file → get transcript" workflow at zero cost, with the code open for inspection.
┌────────────────────────────────────────────────────────────┐
│ Tauri shell (Rust) │
│ • spawns Python sidecar on startup │
│ • reads `SIDECAR_READY <port>` handshake from stdout │
│ • bundles ffmpeg + ffprobe (LGPL static build) │
│ • Tauri command: compute_audio_peaks (ffmpeg → PCM) │
│ • Tauri command: export_subtitled_video (soft / burn-in) │
└─────────────────────────┬──────────────────────────────────┘
│
┌─────────────────────────▼──────────────────────────────────┐
│ React + Vite (WKWebView) │
│ • Zustand store for jobs & audio state │
│ • TanStack Query for /models, /health │
│ • EventSource (SSE) for /jobs/{id}/stream │
│ • WaveSurfer.js with externally-supplied peaks (ffmpeg) │
└─────────────────────────┬──────────────────────────────────┘
│ HTTP + SSE on 127.0.0.1:<random>
┌─────────────────────────▼──────────────────────────────────┐
│ Python sidecar (FastAPI + uvicorn) │
│ • POST /transcribe → returns job_id │
│ • GET /jobs/{id}/stream → SSE: progress|segment|done │
│ • POST /jobs/{id}/cancel → non-destructive cancel │
│ • GET /models → catalog + installed status │
│ • parent-PID watchdog (kills self if Tauri dies) │
│ • idle model unloader (frees unified memory after 5 min) │
└────────────────────────────────────────────────────────────┘
The sidecar ships as a fully-relocatable Python installation (via python-build-standalone) so the DMG runs on any Apple Silicon Mac without Homebrew or pyenv.
mlx-whisper is Python and depends on Apple's MLX framework which is Python-first. Reimplementing it in Rust would mean either reinventing MLX or wrapping it via PyO3 anyway. A small FastAPI sidecar spawned by Tauri keeps the boundary clean: Rust handles the OS shell, Python handles the model.
Python (python-sidecar/src/whisper_sidecar/__main__.py) pre-binds a socket on port 0, reads the OS-assigned port, prints SIDECAR_READY <port>\n to stdout, flushes, then hands the bound socket to uvicorn. This eliminates the race where the line could be printed before the port is actually listening.
Rust (src-tauri/src/lib.rs) spawns the sidecar, reads stdout on a dedicated thread, parses the line, and delivers the port back to the setup hook via a sync_channel(1) with a 10-second timeout.
mlx-whisper doesn't expose progress callbacks. The sidecar temporarily redirects sys.stdout for the duration of mlx_whisper.transcribe(..., verbose=True) to a custom file-like that:
- Parses
[mm:ss.SSS --> mm:ss.SSS] textsegment lines via regex - Pushes parsed segments onto a
queue.Queue - Bridges to the asyncio event loop via
loop.call_soon_threadsafe(...) - SSE generator drains the queue and fans events out to subscribers
Hugging Face model-download progress is captured the same way from stderr — tqdm uses \r overwrites, so the capture stream splits on both \n and \r.
Echo Script holds up to ~3 GB of unified memory while a model is loaded. Three guards prevent that from becoming system-wide pressure:
- Parent PID watchdog — a daemon thread polls
os.getppid()every 5 s. If Tauri crashes and the sidecar gets reparented to launchd, it self-SIGTERMs within seconds instead of lingering as a zombie. - Idle model unloader — after 5 min of inactivity, an asyncio task nulls
mlx_whisper.transcribe.ModelHolder.modeland callsmx.metal.clear_cache(). - Bounded peaks decoder —
compute_audio_peaksreads ffmpeg PCM in 64 KB chunks with a 50 MB cap, so a 2-hour video can't OOM the renderer.
| Tool | Minimum | Install |
|---|---|---|
| macOS | 13+ | Apple Silicon only (M1/M2/M3/M4). MLX has no Intel path. |
| Xcode CLT | current | xcode-select --install |
| Rust | 1.78+ | curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y |
| Node.js | 20+ | brew install node (or use nvm/fnm/Volta) |
| pnpm | 10+ | corepack enable && corepack prepare pnpm@latest --activate |
| uv | latest | curl -LsSf https://astral.sh/uv/install.sh | sh |
| ffmpeg | optional | brew install ffmpeg (only for dev; the DMG bundles its own static build) |
git clone https://github.com/cresenciof/echo-script.git
cd echo-script
pnpm install
cd python-sidecar && uv sync && cd ..
pnpm tauri devThe first pnpm tauri dev takes a few minutes — Rust compiles the entire dependency tree. After that, incremental rebuilds are seconds.
./scripts/build-sidecar-bundle.sh # bundles a portable Python + ffmpeg
pnpm tauri buildOutput lives in src-tauri/target/release/bundle/dmg/. The .app is unsigned; end users hit Gatekeeper on first launch (see Install).
echo-script/
├── src/ # React + TS frontend
│ ├── components/ # WaveformSelector, JobList, ExportBar, etc.
│ ├── hooks/ # useTranscription, useTauriDragDrop, useExporter
│ ├── lib/ # api, sse, audioFormats, timeFormat
│ ├── state/ # Zustand stores
│ └── styles.css # Tailwind v4 + oklch design tokens
├── src-tauri/ # Tauri Rust shell
│ ├── src/lib.rs # sidecar spawn, peaks, subtitle export
│ ├── tauri.conf.json
│ └── Cargo.toml
├── python-sidecar/ # FastAPI + mlx-whisper
│ ├── src/whisper_sidecar/
│ ├── tests/
│ └── pyproject.toml
├── scripts/build-sidecar-bundle.sh
├── .github/workflows/ # SHA-pinned CI + release
├── LICENSE
├── SECURITY.md
├── CONTRIBUTING.md
└── README.md
pnpm exec tsc --noEmit
cargo check --manifest-path src-tauri/Cargo.toml
cd python-sidecar && uv run pytest -qAll models are public, MLX-converted variants from the mlx-community Hugging Face organization. The first time you select a model, mlx-whisper downloads it to ~/.cache/huggingface/hub.
| Model | Size | When to use |
|---|---|---|
mlx-community/whisper-large-v3-mlx-4bit |
~0.8 GB | Default. Quantized, low-RAM, good quality. |
mlx-community/whisper-large-v3-turbo |
~1.6 GB | Best speed/quality ratio in full precision. |
mlx-community/whisper-large-v3-mlx |
~3.1 GB | Maximum quality. Slower. More RAM. |
mlx-community/whisper-medium-mlx |
~1.5 GB | Balanced. |
mlx-community/whisper-small-mlx |
~0.5 GB | Fast. Use only with clean audio. |
Pre-download a model from the terminal (skips the in-app wait):
cd python-sidecar
uv run python -c "from huggingface_hub import snapshot_download; snapshot_download('mlx-community/whisper-large-v3-mlx-4bit')"Common causes:
uvnot on the search paths Tauri probes. The Rust shell looks in~/.local/bin/uv,~/.cargo/bin/uv,/opt/homebrew/bin/uv,/usr/local/bin/uvin that order. Symlink or installuvto one of those.python-sidecar/.venvmissing →cd python-sidecar && uv sync.- Model download in progress on first run → wait, or pre-download (see Models).
You're passing a short model name. mlx-whisper does NOT accept large-v3 — it expects a full Hugging Face repo ID. Always use mlx-community/whisper-....
- Confirm the sidecar process is alive:
pgrep -f whisper_sidecar - Confirm
window.__SIDECAR_URL__was injected: open dev tools console, evaluate it - If undefined, the Tauri Rust setup hook timed out. Restart the app and watch the terminal for errors.
The first compile is ~3–5 min. After that it's incremental. A full recompile usually means a Cargo.toml changed.
- macOS Apple Silicon only. MLX has no Intel or non-Apple GPU support. No fallback path is planned.
- No parallel jobs. MLX is GPU-bound; running multiple jobs in parallel saturates the same hardware.
- No speaker diarization. Out of scope for the current release.
- No persistence. Job history is in-memory; closing the app loses it.
- No code signing. The
.appis unsigned. End users hit Gatekeeper on first launch.
Nothing here is promised — these are directions I'm interested in, not commitments.
- Translation mode (Whisper natively supports it, just needs a UI toggle)
- Job history persistence
- Auto-updater via Tauri updater plugin
- Apple Developer ID signing + notarization (removes Gatekeeper friction)
PRs are welcome, but this is a small project — please open an issue first for anything beyond a typo or obvious bug fix. See CONTRIBUTING.md for setup, commit style, and pre-flight checks.
mlx-whisperand Apple's MLX framework- The
mlx-communityHugging Face org for the MLX-converted Whisper models - Tauri, React, Vite, Tailwind CSS, shadcn/ui, Zustand, TanStack Query, WaveSurfer.js
- FastAPI, sse-starlette, uv, python-build-standalone
MIT.
If you find a vulnerability, please do not open a public issue. See SECURITY.md for the disclosure process.