diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index e509ee2..1f0ddfd 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -27,11 +27,25 @@ jobs: needs: test steps: - uses: actions/checkout@v5 + with: + submodules: recursive - uses: actions/setup-go@v6 with: go-version: '1.24' + # Cache the on-device Parakeet ggufs (~940MB) so the local-model tests + # run without re-downloading every time. Bump the key when the models + # release changes (localmodel.Version). + - name: Cache local models + uses: actions/cache@v4 + with: + path: models/parakeet/v1 + key: parakeet-models-v1 + + - name: Download local models + run: make download-models + - name: Integration tests env: GROQ_API_KEY: ${{ secrets.GROQ_API_KEY }} diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 92497d6..f2a1e6f 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -23,6 +23,8 @@ jobs: runs-on: macos-15 steps: - uses: actions/checkout@v5 + with: + submodules: recursive - uses: actions/setup-go@v6 with: @@ -36,6 +38,8 @@ jobs: runs-on: macos-15 steps: - uses: actions/checkout@v5 + with: + submodules: recursive - uses: actions/setup-go@v6 with: @@ -54,6 +58,7 @@ jobs: - uses: actions/checkout@v5 with: fetch-depth: 0 + submodules: recursive - uses: actions/setup-go@v6 with: @@ -72,9 +77,9 @@ jobs: VERSION: ${{ github.ref_name }} GITHUB_TOKEN: ${{ github.token }} run: | - universal=$(find dist -path "*universal*" -name "zee" -type f | head -1) - test -n "$universal" || { echo "universal binary not found"; find dist -type f; exit 1; } - chmod +x "$universal" - packaging/mkdmg.sh "$universal" "$VERSION" "Zee-${VERSION}.dmg" + bin=$(find dist -name "zee" -type f | head -1) + test -n "$bin" || { echo "arm64 binary not found"; find dist -type f; exit 1; } + chmod +x "$bin" + packaging/mkdmg.sh "$bin" "$VERSION" "Zee-${VERSION}.dmg" shasum -a 256 "Zee-${VERSION}.dmg" >> dist/checksums.txt gh release upload "$VERSION" "Zee-${VERSION}.dmg" dist/checksums.txt --clobber diff --git a/.gitignore b/.gitignore index 00e7e6e..653fa32 100644 --- a/.gitignore +++ b/.gitignore @@ -5,6 +5,9 @@ zee-gui Zee-*.dmg packaging/Zee.icns +# Local STT models (downloaded / dev-placed ggufs — never committed) +/models/ + # Environment .env diff --git a/.gitmodules b/.gitmodules new file mode 100644 index 0000000..1ad600b --- /dev/null +++ b/.gitmodules @@ -0,0 +1,4 @@ +[submodule "parakeet.cpp"] + path = third_party/parakeet.cpp + url = https://github.com/mudler/parakeet.cpp + ignore = dirty diff --git a/.goreleaser.yml b/.goreleaser.yml index 7c3ca58..5ff83b7 100644 --- a/.goreleaser.yml +++ b/.goreleaser.yml @@ -2,26 +2,28 @@ version: 2 before: hooks: + # Init the parakeet.cpp submodule and build its static archives before the + # arm64 cgo build links them (darwin/arm64 only; no-op elsewhere). + - git submodule update --init --recursive + - make parakeet-lib - packaging/mkicns.sh packaging/appicon.png builds: - id: zee env: - CGO_ENABLED=1 + # Stamp the macOS 11.0 deploy target so the binary runs on every supported + # Mac (matches the -mcpu=apple-m1 / deploy target the archives were built with). + - CGO_CFLAGS=-mmacosx-version-min=11.0 + - CGO_LDFLAGS=-mmacosx-version-min=11.0 + - MACOSX_DEPLOYMENT_TARGET=11.0 goos: - darwin goarch: - - amd64 - - arm64 + - arm64 # Apple Silicon only — local STT (parakeet.cpp) is arm64-only ldflags: - -s -w -X main.version={{ .Version }} -universal_binaries: - - id: zee-universal - ids: - - zee - replace: false - archives: - builds: - zee diff --git a/CHANGELOG.md b/CHANGELOG.md index 8f05719..35649f0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,42 @@ # Changelog ## Unreleased + +- `install.sh` reads the model list (filenames, hashes, prefetch flags) from a generated `localmodel/manifest.txt` instead of a hand-copied list, making `localmodel.go` the single source of truth; a test fails the build if the manifest drifts. Renamed `cmd/modeldl` → `cmd/localmodels` with `download` and `manifest` subcommands +- Fix data races on tray state: a single `trayMu` now guards the recording flag, model list, device list, and language fields (previously the language/recording fields had no lock while the model-switch goroutine and the systray click thread both touched them) +- Fix a data race on the selected/preferred microphone: the device-monitor goroutine read them unlocked while the tray callback wrote them; both now go through `captureMu` +- Fix dictation lost when a model switch (tray menu or a finished background download) lands mid-inference: the switch is now deferred to the end of the record/transcribe cycle instead of freeing the model an in-flight session is using +- Free the outgoing local (Parakeet) model when switching provider, instead of leaking its 255 MB–1.4 GB of C/ggml memory for the life of the app +- Tray "Start Recording" during inference is now denied (like the hotkey) instead of queuing an unattended recording that fired the moment inference ended; both paths share one guarded entry point +- Fix a race in the recording stop machinery (`requestStop` touched the stop channel/Once without the lock held) +- `install.sh` refuses to install on Intel Macs (releases are Apple-Silicon-only) instead of "succeeding" with a binary that can't launch +- Local models now resolve to an absolute directory instead of a cwd-relative path: dev builds use the versioned folder next to the binary (found from any working directory), installed apps use `/models`, so a binary launched from elsewhere no longer reports downloaded models as missing (dev override via `ZEE_MODELS_DIR` unchanged) +- Model downloads abort after 60s with no data (stalled connection) instead of wedging the tray menu at "downloading N%" until restart +- `install.sh` verifies pre-fetched model downloads against the release's `checksums.txt` instead of hashes hardcoded in the script; add `make model-release MODELS_DIR=… MODELS_TAG=models-vN` to publish the GGUF models (generates `checksums.txt`, uploads with `--latest=false` so the app-release "latest" pointer can't be hijacked) + +- Fix the "no engine available" startup error listing only 3 cloud keys: it now surfaces all five providers plus the offline-on-Apple-Silicon hint (the message is no longer hardcoded and can't drift as providers change) + +- Fix language preference being clobbered by English-only models: switching to a model that can't offer the selected language (e.g. Parakeet English-only while on Auto-detect or Turkish) no longer overwrites the saved choice. The fallback is applied transiently; switching back to a capable model restores the original language + +- Fix Auto-detect language not persisting: selecting Auto-detect (empty language code) was coerced back to English on the next launch. Settings now load over defaults so an explicit empty language is kept while an unset one still defaults to English + +- Add offline, on-device transcription via Parakeet (parakeet.cpp, CPU) on Apple Silicon +- Works out of the box with no API key on Apple Silicon (falls back to the local 110M model) +- Add local model picker in the tray: 110M English (default), 0.6B v3 multilingual, 0.6B v2 English (opt-in download) +- Download missing local models on demand from the tray with progress +- `-transcribe` supports local WAV (16 kHz mono) transcription without a network call, and accepts multiple files in one invocation (model loaded once; one transcript printed per line) +- Block starting a new recording while the previous transcription is still in progress (the recording guard now spans inference, not just capture); show a blue status-dot tray icon during transcription and play a short "denied" beep if the hotkey is pressed then +- `-doctor` reports local model status (present, path, size, decoder) +- `-doctor` transcription test uses the app's default engine (local Parakeet, else first cloud key) instead of prompting for a provider + API key +- Idle tray icon adapts to the menubar appearance (template tinting) — renders white on dark/transparent menubars instead of black +- Diagnostics log per-transcription process RSS (`rss_mb`, from gopsutil — includes cgo/mmap model memory) for both batch and stream sessions +- "Save Last Recording" now works for the local (Parakeet) model — captured PCM is saved as WAV (was cloud-only before) +- Add `-provider` and `-model` flags to override the saved provider/model from the CLI (an unavailable explicit `-provider` is now a hard error) +- Fix recording immediately aborting ("Start" auto-releases) after the selected microphone was unplugged: the record loop kept using a stale reference to the removed device instead of the system-default it was switched to. It now reads the current capture device for each recording (device swaps guarded by a mutex) +- Fix a crash (SIGSEGV/SIGABRT double-free in `ma_device_uninit`) when recording after a sleep/wake or audio-device change: the per-call device reinit left the device pointer dangling when reinit failed, so the next call uninited the already-freed device. Null the device after uninit in both capture and beep playback. Also serialize all miniaudio device lifecycle calls behind a process-wide lock as defense against concurrent capture/playback init/uninit +- Merge the `beep` package into `audio` so one package owns both capture and playback feedback tones: the miniaudio lifecycle lock is now a private `audio` detail shared by both directions (was the exported `internal/malgolock`), and capture/playback share one malgo context instead of two. Removes `beep/` and `internal/malgolock/` +- Fix the tray language menu to always reflect the active model's languages — both at startup and on model switch. English-only Parakeet models no longer offer Auto-detect, and switching providers (e.g. to Groq) now updates the list + ## v0.3.8 - Update dialog points to install instructions diff --git a/CLAUDE.md b/CLAUDE.md index 595239c..d6104f0 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -6,6 +6,7 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co ## Rules - **CHANGELOG.md** — only log code/behavior changes. No docs, README, or comment-only updates. Be concise. +- **Clean package interface** — every package must expose a single, platform-neutral interface describing *what it provides*, defined once (typically in `.go`). Public API, shared types, and guard logic live there; platform/provider files (build-tag variants) only implement the backend hooks. Never duplicate the public API across build-tag files (see `audio/`: `audio.go` owns the capture interface plus `PlayStart/PlayEnd/...`, platform files provide only the backends — `initSound`/`playOne` for playback, the malgo/pulse capture impls). ## Build & Run @@ -68,8 +69,7 @@ Ctrl+Shift+Space keydown → record audio → encode (mode-based) → API call - `transcriber/` - STT providers (Groq, OpenAI, Deepgram, Mistral, ElevenLabs) with shared TracedClient for HTTP timing metrics - `hotkey/` - global hotkey registration (Ctrl+Shift+Space) with platform-specific backends - `clipboard/` - platform-specific clipboard and paste operations (Cmd+V / Ctrl+V) -- `audio/` - platform-specific audio capture (malgo on macOS, PulseAudio on Linux) -- `beep/` - platform-specific audio playback for feedback sounds +- `audio/` - platform-specific audio I/O: capture (malgo on macOS, PulseAudio on Linux) and feedback-tone playback (`PlayStart/PlayEnd/...`); on macOS both share one malgo context + a private device-lifecycle lock - `doctor/` - system diagnostics (`-doctor` flag) - `internal/mp3/` - vendored shine-mp3 encoder (with mono fix) - `device.go` - microphone picker with arrow-key navigation @@ -80,8 +80,8 @@ Ctrl+Shift+Space keydown → record audio → encode (mode-based) → API call ## Design Philosophy -- **Unix philosophy packages** - Each subfolder is a self-contained utility that does one thing: `beep/` plays sounds, `clipboard/` copies and pastes, `audio/` captures mic input, `transcriber/` talks to STT APIs, `hotkey/` registers global keys. They expose a minimal interface and hide all platform/provider details behind build tags. -- **Root files are pure business logic** - `main.go` and other root files orchestrate the workflow but never import OS-specific APIs or know implementation details of subpackages. When `main.go` calls `clipboard.Paste()`, it doesn't know whether that's pbcopy, xclip, or Win32 — and it shouldn't. Same for `beep.PlayEnd()`, `audio.Start()`, `transcriber.Transcribe()`, etc. +- **Unix philosophy packages** - Each subfolder is a self-contained utility that does one thing: `audio/` does audio device I/O (mic capture + feedback-tone playback), `clipboard/` copies and pastes, `transcriber/` talks to STT APIs, `hotkey/` registers global keys. They expose a minimal interface and hide all platform/provider details behind build tags. +- **Root files are pure business logic** - `main.go` and other root files orchestrate the workflow but never import OS-specific APIs or know implementation details of subpackages. When `main.go` calls `clipboard.Paste()`, it doesn't know whether that's pbcopy, xclip, or Win32 — and it shouldn't. Same for `audio.PlayEnd()`, `audio.NewContext()`, `transcriber.Transcribe()`, etc. - **No leaky abstractions** - Never add provider-specific, OS-specific, or library-specific logic to root files. If a new STT provider needs special handling, that belongs in `transcriber/`. If a new platform needs a different paste mechanism, that belongs in `clipboard/`. - **Shared constants in one place** - No duplicate magic numbers; extract to package-level constants. @@ -116,6 +116,17 @@ Requires `ZEE_RELEASE_TOKEN` repo secret (fine-grained PAT with Contents read/wr Users install via the one-liner in README (`install.sh` fetches the DMG and verifies the checksum). +### Releasing a new local model (Parakeet GGUF) + +Models are hosted in an immutable, never-"latest" `models-vN` GitHub release, **separate** from app releases. `localmodel/localmodel.go` is the single source of truth (filenames, SHA256s, `PreFetch` flags); `localmodel/manifest.txt` is its generated, committed projection that `install.sh` reads from `main`. Nothing model-related is hardcoded in `install.sh`. + +1. Produce the `.gguf` locally (parakeet.cpp conversion/quantization) into a folder, e.g. `./out`. +2. Add/edit its entry in `localmodel.go` — `Filename`, `SHA256` (`shasum -a 256`), `SizeBytes` (`ls -l`), `Decoder`, `Multilingual`, `PreFetch`. Re-quantizing existing files → also bump `localmodel.Version` and `install.sh`'s `MODELS_TAG`. +3. `make model-release MODELS_DIR=./out MODELS_TAG=models-vN` — regenerates `manifest.txt`, verifies the ggufs against the registry SHA256s, and uploads them with `--latest=false`. +4. Commit `localmodel.go` + `localmodel/manifest.txt` (+ `install.sh` if `MODELS_TAG` changed). `TestManifestUpToDate` fails the build if the manifest is stale. + +`make manifest` regenerates the manifest alone. Dev builds pull prefetch models via `make download-models` (`localmodels download`). The CLI `cmd/localmodels` has exactly two verbs: `download` (dev) and `manifest` (release). + ## Packaging - `packaging/appicon.png` - source icon (1024px, stack Z design: shadow square + framed Z, transparent background) diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..da56e94 --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 Sumer Cip + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/Makefile b/Makefile index e595292..a7f5ebb 100644 --- a/Makefile +++ b/Makefile @@ -1,9 +1,53 @@ -.PHONY: build build-linux-amd64 build-linux-arm64 test test-integration benchmark integration-test clean bump-version release icns app +.PHONY: build build-linux-amd64 build-linux-arm64 test test-integration benchmark integration-test clean bump-version release icns app parakeet-lib download-models manifest model-release VERSION ?= $(shell git describe --tags --always --dirty 2>/dev/null || echo "dev") -build: - go build -ldflags="-X main.version=$(VERSION)" -o zee +# Local STT (Parakeet) is a darwin/arm64-only cgo feature. On that host we build +# the static parakeet.cpp + ggml archives first and stamp the macOS deploy +# target; everywhere else the no-cgo stub is compiled and these are no-ops. +MACOS_MIN := 11.0 +PARAKEET_DIR := third_party/parakeet.cpp +PARAKEET_LIB := $(PARAKEET_DIR)/build-release/libparakeet.a +HOST := $(shell go env GOOS)/$(shell go env GOARCH) +ifeq ($(HOST),darwin/arm64) +CGO_ENV := MACOSX_DEPLOYMENT_TARGET=$(MACOS_MIN) CGO_CFLAGS=-mmacosx-version-min=$(MACOS_MIN) CGO_LDFLAGS=-mmacosx-version-min=$(MACOS_MIN) +endif + +build: parakeet-lib download-models + $(CGO_ENV) go build -ldflags="-X main.version=$(VERSION)" -o zee + +# Fetch the mandatory (PreFetch) local models into the dev folder from the +# pinned models- GitHub release. Reuses the localmodel registry + +# downloader (single source of truth) and is a per-file no-op when present. +download-models: + go run ./cmd/localmodels download + +# Regenerate localmodel/manifest.txt (the bash-readable projection of the +# registry that install.sh reads) from localmodel.go. Commit the result; +# TestManifestUpToDate fails if it drifts. +manifest: + go run ./cmd/localmodels manifest > localmodel/manifest.txt + @echo "==> localmodel/manifest.txt regenerated — commit it" + +# Configure once (submodule init + cmake, which auto-applies the in-tree ggml +# patches), then always `cmake --build` so source changes recompile incrementally +# and relink — a no-op when nothing changed. After a submodule bump, delete +# build-release to force a reconfigure (re-applies the patch to the new ggml). +parakeet-lib: + @if [ "$(HOST)" != "darwin/arm64" ]; then exit 0; fi; \ + if [ ! -f $(PARAKEET_DIR)/CMakeLists.txt ]; then \ + echo "==> initializing parakeet.cpp submodule (first checkout)"; \ + git submodule update --init --recursive $(PARAKEET_DIR); \ + fi; \ + if [ ! -d $(PARAKEET_DIR)/build-release ]; then \ + echo "==> configuring parakeet.cpp (one-time)"; \ + cmake -S $(PARAKEET_DIR) -B $(PARAKEET_DIR)/build-release \ + -DBUILD_SHARED_LIBS=OFF -DPARAKEET_SHARED=OFF -DPARAKEET_BUILD_CLI=OFF \ + -DPARAKEET_GGML_METAL=OFF -DGGML_NATIVE=OFF \ + -DCMAKE_OSX_DEPLOYMENT_TARGET=$(MACOS_MIN) \ + -DCMAKE_C_FLAGS="-mcpu=apple-m1" -DCMAKE_CXX_FLAGS="-mcpu=apple-m1"; \ + fi && \ + cmake --build $(PARAKEET_DIR)/build-release -j build-linux-amd64: GOOS=linux GOARCH=amd64 go build -ldflags="-X main.version=$(VERSION) -s -w" -o zee-linux-amd64 @@ -11,24 +55,24 @@ build-linux-amd64: build-linux-arm64: GOOS=linux GOARCH=arm64 go build -ldflags="-X main.version=$(VERSION) -s -w" -o zee-linux-arm64 -test: - go test -race -v ./... +test: parakeet-lib + $(CGO_ENV) go test -race -v ./... -integration-test: +integration-test: parakeet-lib @test -n "$(WAV)" || (echo "Usage: make integration-test WAV=file.wav" && exit 1) @if [ -f .env ]; then export $$(grep -v '^#' .env | xargs); fi; \ test -n "$$GROQ_API_KEY" || (echo "Error: GROQ_API_KEY not set (create .env or export it)" && exit 1); \ - go run test/integration_test.go $(WAV) + $(CGO_ENV) go run test/integration_test.go $(WAV) benchmark: build @test -n "$(WAV)" || (echo "Usage: make benchmark WAV=file.wav [RUNS=5]" && exit 1) @if [ -f .env ]; then export $$(grep -v '^#' .env | xargs); fi; \ ./zee -benchmark $(WAV) -runs $(or $(RUNS),3) -test-integration: +test-integration: parakeet-lib @tmp=$$(mktemp -d) && \ - go build -o "$$tmp/zee-test-bin" . && \ - ZEE_TEST_BIN="$$tmp/zee-test-bin" go test -race -tags integration -v -timeout 120s -count=1 ./test/ ; \ + $(CGO_ENV) go build -o "$$tmp/zee-test-bin" . && \ + ZEE_TEST_BIN="$$tmp/zee-test-bin" $(CGO_ENV) go test -race -tags integration -v -timeout 600s -count=1 ./test/ ; \ status=$$? ; rm -rf "$$tmp" ; exit $$status icns: @@ -52,6 +96,36 @@ bump-version: rm -f /tmp/zee-changelog-entry; \ echo "CHANGELOG.md updated — review and edit as needed" +# Publish the offline Parakeet GGUF models as an immutable, never-"latest" +# GitHub release. Prereq: each .gguf's entry (Filename + SHA256) already exists +# in localmodel.go. Copy the ggufs into a folder, then: +# make model-release MODELS_DIR=./out MODELS_TAG=models-v2 +# It regenerates the manifest, verifies the local ggufs against the registry's +# SHA256s, and uploads them with --latest=false so the app-release "latest" +# pointer is never hijacked. install.sh reads localmodel/manifest.txt (from main) +# for filenames + hashes + prefetch flags — nothing is hardcoded there. Adopting +# the models is a separate commit: localmodel.go + the regenerated manifest.txt +# (+ Version / install.sh MODELS_TAG if the tag changed). +model-release: manifest + @test -n "$(MODELS_TAG)" || (echo "usage: make model-release MODELS_DIR=./dir MODELS_TAG=models-vN" && exit 1) + @test -d "$(MODELS_DIR)" || (echo "ERROR: MODELS_DIR '$(MODELS_DIR)' not found" && exit 1) + @ls "$(MODELS_DIR)"/*.gguf >/dev/null 2>&1 || (echo "ERROR: no .gguf files in $(MODELS_DIR)" && exit 1) + @case "$(MODELS_TAG)" in models-*) ;; *) echo "ERROR: MODELS_TAG must start with 'models-'" && exit 1;; esac + @echo "==> verifying $(MODELS_DIR) ggufs against the localmodel registry..." + @cd "$(MODELS_DIR)" && for f in *.gguf; do \ + want=$$(awk -v f="$$f" '$$1==f {print $$2}' "$(CURDIR)/localmodel/manifest.txt"); \ + test -n "$$want" || { echo "ERROR: $$f is not in localmodel.go — add its entry first" && exit 1; }; \ + got=$$(shasum -a 256 "$$f" | awk '{print $$1}'); \ + test "$$want" = "$$got" || { echo "ERROR: $$f sha mismatch (registry $$want, file $$got)" && exit 1; }; \ + echo " ok $$f"; \ + done + gh release create "$(MODELS_TAG)" --repo sumerc/zee --latest=false \ + --title "Parakeet $(MODELS_TAG)" \ + --notes "GGUF models for zee local STT. Derived from NVIDIA NeMo Parakeet (CC-BY-4.0)." \ + "$(MODELS_DIR)"/*.gguf + @echo "==> published $(MODELS_TAG) (not marked latest)." + @echo "==> commit: localmodel.go + localmodel/manifest.txt (+ install.sh MODELS_TAG if bumped)." + release: @branch=$$(git rev-parse --abbrev-ref HEAD); \ if [ "$$branch" != "main" ]; then echo "ERROR: must be on main branch" && exit 1; fi; \ diff --git a/README.md b/README.md index 18cf162..3345b18 100644 --- a/README.md +++ b/README.md @@ -15,6 +15,7 @@ ## Highlights +- **Offline, on-device** — on Apple Silicon, transcribes fully locally via Parakeet (parakeet.cpp, CPU) with **no API key and no network**. Cloud providers are optional and switchable from the tray. - **System tray app** — lives in the menu bar. Switch microphones, transcription providers, and languages from the tray menu. Dynamic icons show recording and warning states. - **Two recording modes** — push-to-talk (hold hotkey) or tap-to-toggle (tap to start/stop). - **Real-time streaming** — when a streaming-capable model is selected (e.g. Deepgram Nova-3), words appear as you speak and auto-paste into the focused window incrementally. @@ -25,7 +26,7 @@ - **Multiple providers** — Groq, OpenAI, Mistral, ElevenLabs, and Deepgram, switchable from the tray menu at runtime. - **36 languages** — select transcription language from the tray menu or via `-lang` flag. - **Cross-platform** — minimal dependencies, pure Go where possible. - - [x] macOS + - [x] macOS (Apple Silicon) - [ ] Linux - [ ] Windows @@ -50,15 +51,13 @@ Downloads the latest DMG, verifies its SHA256 against `checksums.txt`, copies `Z For terminal usage: ```bash -# Apple Silicon +# Apple Silicon (the only supported target) curl -L https://github.com/sumerc/zee/releases/latest/download/zee_darwin_arm64.tar.gz | tar xz - -# Intel -curl -L https://github.com/sumerc/zee/releases/latest/download/zee_darwin_amd64.tar.gz | tar xz ``` ```bash -GROQ_API_KEY=xxx ./zee # Groq Whisper +./zee # offline, on-device (no key needed) +GROQ_API_KEY=xxx ./zee # Groq Whisper (cloud) DEEPGRAM_API_KEY=xxx ./zee # Deepgram (streaming auto-enabled when a streaming model is selected from the tray) ./zee -debug-transcribe # include transcription text logs ``` @@ -67,15 +66,20 @@ DEEPGRAM_API_KEY=xxx ./zee # Deepgram (streaming auto-enabled when a st ### Build from source +Requires **Apple Silicon**, plus `cmake` and the Xcode Command Line Tools (for the one-time on-device STT engine build). + ```bash git clone https://github.com/sumerc/zee && cd zee -make build # CLI binary +make build # builds the local STT engine (cmake) + CLI binary; + # first run also fetches the default models (~900 MB) into models/parakeet/v1/ make app # macOS DMG ``` +The submodule, static libraries, and models are all set up automatically by `make build` — no manual steps. + ## Usage -Set at least one API key, then run zee: +On Apple Silicon, zee works offline out of the box — no key required. To use a cloud provider instead, set its key (pick the provider from the tray), then run zee: ```bash export GROQ_API_KEY=your_key # batch mode (Groq Whisper) diff --git a/THIRD_PARTY_LICENSES b/THIRD_PARTY_LICENSES new file mode 100644 index 0000000..1082bff --- /dev/null +++ b/THIRD_PARTY_LICENSES @@ -0,0 +1,88 @@ +Third-Party Licenses and Attribution +===================================== + +Zee statically links the components below and bundles/downloads the local +speech-to-text models below. Their licenses and the required attributions are +reproduced here. + + +-------------------------------------------------------------------------------- +1. parakeet.cpp — local speech-to-text engine (statically linked) + https://github.com/mudler/parakeet.cpp + License: MIT +-------------------------------------------------------------------------------- + +MIT License + +Copyright (c) 2026 the parakeet.cpp authors + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. + + +-------------------------------------------------------------------------------- +2. ggml — tensor library (statically linked via parakeet.cpp) + https://github.com/ggml-org/ggml + License: MIT +-------------------------------------------------------------------------------- + +MIT License + +Copyright (c) 2023-2026 The ggml authors + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. + + +-------------------------------------------------------------------------------- +3. Parakeet speech-to-text models (GGUF) + License: Creative Commons Attribution 4.0 International (CC-BY-4.0) + https://creativecommons.org/licenses/by/4.0/ +-------------------------------------------------------------------------------- + +The local transcription models that Zee bundles and/or downloads are GGUF +conversions of the NVIDIA NeMo Parakeet checkpoints: + + - nvidia/parakeet-tdt_ctc-110m (110M, English) + - nvidia/parakeet-tdt-0.6b-v2 (0.6B, English) + - nvidia/parakeet-tdt-0.6b-v3 (0.6B, multilingual) + + Source: NVIDIA NeMo — https://github.com/NVIDIA/NeMo + License: CC-BY-4.0 — https://creativecommons.org/licenses/by/4.0/ + +Changes made: the original NeMo checkpoints were converted to the GGUF format +for use with parakeet.cpp; the v3 model is additionally quantized (q4_k). No +other modifications were made to the model weights. + +"Parakeet", "NeMo", and "NVIDIA" are trademarks of NVIDIA Corporation. Zee is +not affiliated with, sponsored by, or endorsed by NVIDIA. diff --git a/audio/audio.go b/audio/audio.go index 02a5017..482f75b 100644 --- a/audio/audio.go +++ b/audio/audio.go @@ -1,9 +1,104 @@ package audio -import "strings" +import ( + "encoding/binary" + "fmt" + "math" + "strings" + "sync" + "sync/atomic" +) const WAVHeaderSize = 44 +// WAVToPCM parses a RIFF/WAVE file and returns the raw PCM data chunk, after +// validating it is 16 kHz mono signed-16-bit little-endian — the format the +// local Parakeet engine expects. It walks the chunk list so padding chunks +// (FLLR, LIST, fact, …) between the header and `data` are handled correctly. +// +// Use case: the `-transcribe ` flow (main.go transcribeFile), which +// feeds a WAV from disk to a local transcriber. Live recording captures raw PCM +// and never hits this. In practice that flow is driven mostly by the integration +// tests (test/integration_test.go transcribeFiles), so this is largely test-path +// code, not the hot path. +func WAVToPCM(b []byte) ([]byte, error) { + if len(b) < 12 || string(b[0:4]) != "RIFF" || string(b[8:12]) != "WAVE" { + return nil, fmt.Errorf("not a RIFF/WAVE file") + } + var ( + gotFmt bool + channels, bits uint16 + sampleRate uint32 + data []byte + ) + for off := 12; off+8 <= len(b); { + id := string(b[off : off+4]) + size := int(binary.LittleEndian.Uint32(b[off+4 : off+8])) + body := off + 8 + if body+size > len(b) { + size = len(b) - body // tolerate a truncated final chunk + } + switch id { + case "fmt ": + if size < 16 { + return nil, fmt.Errorf("short fmt chunk") + } + channels = binary.LittleEndian.Uint16(b[body+2 : body+4]) + sampleRate = binary.LittleEndian.Uint32(b[body+4 : body+8]) + bits = binary.LittleEndian.Uint16(b[body+14 : body+16]) + gotFmt = true + case "data": + data = b[body : body+size] + } + off = body + size + if size%2 == 1 { + off++ // chunks are word-aligned + } + } + if !gotFmt { + return nil, fmt.Errorf("no fmt chunk") + } + if data == nil { + return nil, fmt.Errorf("no data chunk") + } + if channels != 1 || sampleRate != 16000 || bits != 16 { + return nil, fmt.Errorf("unsupported WAV format: %d-bit %d ch %d Hz (need 16-bit mono 16000 Hz)", bits, channels, sampleRate) + } + return data, nil +} + +// PCMToWAV wraps raw 16 kHz mono signed-16-bit little-endian PCM in a minimal +// 44-byte RIFF/WAVE container — the inverse of WAVToPCM. +// +// Use case: the live local-transcriber (Parakeet) path. API providers encode +// captured audio to mp3/flac, but Parakeet consumes raw PCM and never encodes, +// so to hand back a real, saveable file in SessionResult.AudioData it gets +// wrapped here. That's what the "Save Last Recording" feature +// (ZEE_SAVE_LAST_AUDIO, main.go) writes to disk. Unlike WAVToPCM, this is on +// the hot path, not test-only. +func PCMToWAV(pcm []byte) []byte { + const sampleRate, channels, bits = 16000, 1, 16 + byteRate := sampleRate * channels * bits / 8 + blockAlign := channels * bits / 8 + + buf := make([]byte, WAVHeaderSize+len(pcm)) + copy(buf[0:4], "RIFF") + binary.LittleEndian.PutUint32(buf[4:8], uint32(36+len(pcm))) + copy(buf[8:12], "WAVE") + copy(buf[12:16], "fmt ") + binary.LittleEndian.PutUint32(buf[16:20], 16) // PCM fmt chunk size + binary.LittleEndian.PutUint16(buf[20:22], 1) // format = PCM + binary.LittleEndian.PutUint16(buf[22:24], channels) + binary.LittleEndian.PutUint32(buf[24:28], sampleRate) + binary.LittleEndian.PutUint32(buf[28:32], uint32(byteRate)) + binary.LittleEndian.PutUint16(buf[32:34], uint16(blockAlign)) + binary.LittleEndian.PutUint16(buf[34:36], bits) + copy(buf[36:40], "data") + binary.LittleEndian.PutUint32(buf[40:44], uint32(len(pcm))) + copy(buf[WAVHeaderSize:], pcm) + return buf +} + var btKeywords = []string{ "airpods", "beats", "bose", "wh-1000", "wf-1000", "sony wh-", "sony wf-", @@ -49,3 +144,111 @@ type CaptureDevice interface { ClearCallback() DeviceName() string } + +// --- Feedback tones (record start/end, error, denied) --- +// +// Playback shares the OS audio device with capture — on darwin the same malgo +// context and lifecycle lock (see capture_darwin.go). This file owns the +// platform-neutral half: the public API, the enable guard, and tone synthesis. +// Each platform file provides exactly two backend hooks, initSound() and +// playOne(sound), so the guard logic lives here once instead of per platform. + +// sound identifies a tone; it indexes the canonical sample table below. +type sound int + +const ( + startSound sound = iota + endSound + errorSound + deniedSound + numSounds // count sentinel; sizes the sample table +) + +// samples is the canonical tone table: 16-bit mono PCM, synthesized once by +// buildSamples. Each platform's playback adapts these to its device format +// (darwin: mono S16 little-endian bytes; linux: stereo) as it copies into the +// device buffer — so the tone data and synthesis live here once, never per OS. +var samples [numSounds][]int16 + +// disabled is set once at startup (or by tests) but read from recording +// goroutines, so it's atomic to stay race-free. +var disabled atomic.Bool + +// soundOnce guards the one-time device + buffer init done by initSound. +var soundOnce sync.Once + +// DisableBeep silences all feedback tones (used by test mode). +func DisableBeep() { disabled.Store(true) } + +// InitBeep eagerly performs the one-time setup so the first real beep isn't delayed. +func InitBeep() { soundOnce.Do(initSound) } + +func PlayStart() { play(startSound) } +func PlayEnd() { play(endSound) } +func PlayError() { play(errorSound) } +func PlayDenied() { play(deniedSound) } + +func play(s sound) { + if disabled.Load() { + return + } + soundOnce.Do(initSound) + playOne(s) +} + +const ( + beepSampleRate = 44100 + + // Start beep: high pitch, short + startFreq = 1200 + startVolume = 0.5 + startDecay = 60 + + // End beep: medium pitch, slightly longer + endFreq = 900 + endVolume = 0.5 + endDecay = 40 + + // Error beep: low pitch double-beep + errorFreq = 350 + errorVolume = 0.6 + errorDecay = 30 + + // Denied beep: low, short single tick — a press was ignored (e.g. while a + // transcription is still in progress). + deniedFreq = 240 + deniedVolume = 0.45 + deniedDecay = 40 +) + +// generateTick synthesizes a single decaying sine tone as 16-bit mono PCM. +func generateTick(freq, duration, volume, decay float64) []int16 { + n := int(float64(beepSampleRate) * duration) + buf := make([]int16, n) + for i := range n { + t := float64(i) / float64(beepSampleRate) + env := math.Exp(-t * decay) + buf[i] = int16(math.Sin(2*math.Pi*freq*t) * 32767 * volume * env) + } + return buf +} + +// generateDoubleBeep is two ticks separated by a silent gap (used for errors). +func generateDoubleBeep(freq, beepDur, gapDur, volume, decay float64) []int16 { + beep := generateTick(freq, beepDur, volume, decay) + gap := make([]int16, int(float64(beepSampleRate)*gapDur)) + out := make([]int16, 0, len(beep)*2+len(gap)) + out = append(out, beep...) + out = append(out, gap...) + out = append(out, beep...) + return out +} + +// buildSamples synthesizes the canonical tone table. Durations are shared +// across platforms so the feedback sounds are identical on every OS. +func buildSamples() { + samples[startSound] = generateTick(startFreq, 0.2, startVolume, startDecay) + samples[endSound] = generateTick(endFreq, 0.2, endVolume, endDecay) + samples[errorSound] = generateDoubleBeep(errorFreq, 0.08, 0.05, errorVolume, errorDecay) + samples[deniedSound] = generateTick(deniedFreq, 0.12, deniedVolume, deniedDecay) +} diff --git a/audio/audio_other.go b/audio/audio_other.go deleted file mode 100644 index 601c9fb..0000000 --- a/audio/audio_other.go +++ /dev/null @@ -1,131 +0,0 @@ -//go:build !linux - -package audio - -import ( - "encoding/hex" - "fmt" - "sync/atomic" - - "github.com/gen2brain/malgo" -) - -type malgoContext struct { - ctx *malgo.AllocatedContext -} - -func NewContext() (Context, error) { - ctx, err := malgo.InitContext(nil, malgo.ContextConfig{}, nil) - if err != nil { - return nil, err - } - return &malgoContext{ctx: ctx}, nil -} - -func (m *malgoContext) Devices() ([]DeviceInfo, error) { - devices, err := m.ctx.Devices(malgo.Capture) - if err != nil { - return nil, fmt.Errorf("malgo devices: %w", err) - } - var result []DeviceInfo - for _, d := range devices { - result = append(result, DeviceInfo{ - ID: hex.EncodeToString(d.ID[:]), - Name: d.Name(), - }) - } - return result, nil -} - -func (m *malgoContext) NewCapture(device *DeviceInfo, config CaptureConfig) (CaptureDevice, error) { - c := &malgoCapture{ - malgoCtx: m, - deviceInfo: device, - config: config, - } - - if err := c.initDevice(); err != nil { - return nil, err - } - - return c, nil -} - -func (m *malgoContext) Close() { - m.ctx.Uninit() - m.ctx.Free() -} - -type malgoCapture struct { - malgoCtx *malgoContext - deviceInfo *DeviceInfo - config CaptureConfig - device *malgo.Device - callback atomic.Pointer[DataCallback] -} - -func (c *malgoCapture) initDevice() error { - deviceConfig := malgo.DefaultDeviceConfig(malgo.Capture) - deviceConfig.Capture.Format = malgo.FormatS16 - deviceConfig.Capture.Channels = c.config.Channels - deviceConfig.SampleRate = c.config.SampleRate - - if c.deviceInfo != nil { - idBytes, err := hex.DecodeString(c.deviceInfo.ID) - if err != nil { - return fmt.Errorf("invalid device ID: %w", err) - } - var devID malgo.DeviceID - copy(devID[:], idBytes) - deviceConfig.Capture.DeviceID = devID.Pointer() - } - - callbacks := malgo.DeviceCallbacks{ - Data: func(_, data []byte, frameCount uint32) { - if cb := c.callback.Load(); cb != nil { - (*cb)(data, frameCount) - } - }, - } - - dev, err := malgo.InitDevice(c.malgoCtx.ctx.Context, deviceConfig, callbacks) - if err != nil { - return err - } - - c.device = dev - return nil -} - -func (c *malgoCapture) Start() error { - // Always reinitialize before starting — handles macOS sleep/wake - // where the device handle goes stale without returning errors - c.device.Uninit() - if err := c.initDevice(); err != nil { - return fmt.Errorf("device reinit failed: %w", err) - } - return c.device.Start() -} - -func (c *malgoCapture) Stop() { - c.device.Stop() -} - -func (c *malgoCapture) Close() { - c.device.Uninit() -} - -func (c *malgoCapture) SetCallback(cb DataCallback) { - c.callback.Store(&cb) -} - -func (c *malgoCapture) ClearCallback() { - c.callback.Store(nil) -} - -func (c *malgoCapture) DeviceName() string { - if c.deviceInfo != nil { - return c.deviceInfo.Name - } - return "system default" -} diff --git a/audio/beep_darwin.go b/audio/beep_darwin.go new file mode 100644 index 0000000..3b30a43 --- /dev/null +++ b/audio/beep_darwin.go @@ -0,0 +1,115 @@ +//go:build darwin + +package audio + +import ( + "sync/atomic" + + "github.com/gen2brain/malgo" +) + +var ( + playbackDevice *malgo.Device + + // Playback state, read from the device callback: the canonical mono buffer + // being played and the current sample offset into it. + playMono atomic.Pointer[[]int16] + playPos atomic.Uint32 +) + +// initPlaybackDevice is lock-free; callers must hold deviceMu around it. +func initPlaybackDevice() error { + config := malgo.DefaultDeviceConfig(malgo.Playback) + config.Playback.Format = malgo.FormatS16 + config.Playback.Channels = 1 + config.SampleRate = beepSampleRate + + callbacks := malgo.DeviceCallbacks{ + Data: dataCallback, + } + + var err error + playbackDevice, err = malgo.InitDevice(maCtx.Context, config, callbacks) + return err +} + +func initSound() { + deviceMu.Lock() + defer deviceMu.Unlock() + + if err := ensureContext(); err != nil { + return + } + + buildSamples() + initPlaybackDevice() // best-effort; playInt16 reinits per play anyway +} + +// dataCallback fills the mono S16 device buffer, converting each canonical +// int16 sample to two little-endian bytes as it copies. +func dataCallback(pOutput, _ []byte, frameCount uint32) { + silence := func() { + for i := range pOutput { + pOutput[i] = 0 + } + } + + mono := playMono.Load() + if mono == nil { + silence() + return + } + pos := playPos.Load() + total := uint32(len(*mono)) + if pos >= total { + playMono.Store(nil) + silence() + return + } + + frames := min(total-pos, frameCount) + s := *mono + for i := uint32(0); i < frames; i++ { + v := s[pos+i] + pOutput[i*2] = byte(v) + pOutput[i*2+1] = byte(v >> 8) + } + for i := frames * 2; i < frameCount*2; i++ { + pOutput[i] = 0 // zero-fill any frames past the buffer + } + playPos.Store(pos + frames) +} + +func playInt16(mono []int16) { + if maCtx == nil || len(mono) == 0 { + return + } + + deviceMu.Lock() + defer deviceMu.Unlock() + + // Always reinitialize to pick up current default output device + // (handles BT connect/disconnect, sleep/wake). Null device after Uninit + // so a failed reinit can't leave it pointing at the freed device — the + // next call would otherwise Uninit it again and double-free. + if playbackDevice != nil { + playbackDevice.Stop() + playbackDevice.Uninit() + playbackDevice = nil + } + if err := initPlaybackDevice(); err != nil { + playbackDevice = nil + return + } + + playPos.Store(0) + playMono.Store(&mono) + + if err := playbackDevice.Start(); err != nil { + playMono.Store(nil) + } +} + +func playOne(s sound) { + playInt16(samples[s]) +} diff --git a/audio/beep_linux.go b/audio/beep_linux.go new file mode 100644 index 0000000..8f52a82 --- /dev/null +++ b/audio/beep_linux.go @@ -0,0 +1,59 @@ +//go:build linux + +package audio + +import ( + "github.com/jfreymuth/pulse" + "github.com/jfreymuth/pulse/proto" +) + +func initSound() { + buildSamples() +} + +// playSamples plays a canonical mono buffer through PulseAudio, duplicating each +// sample to both channels of the stereo stream as it fills the read buffer. +func playSamples(mono []int16) { + if len(mono) == 0 { + return + } + c, err := pulse.NewClient() + if err != nil { + return + } + defer c.Close() + + pos := 0 + reader := pulse.Int16Reader(func(buf []int16) (int, error) { + if pos >= len(mono) { + return 0, pulse.EndOfData + } + n := 0 + for n+1 < len(buf) && pos < len(mono) { + buf[n] = mono[pos] // left + buf[n+1] = mono[pos] // right + n += 2 + pos++ + } + return n, nil + }) + stream, err := c.NewPlayback(reader, + pulse.PlaybackStereo, + pulse.PlaybackSampleRate(beepSampleRate), + pulse.PlaybackLatency(0.1), + pulse.PlaybackRawOption(func(p *proto.CreatePlaybackStream) { + p.ChannelVolumes = proto.ChannelVolumes{uint32(proto.VolumeNorm), uint32(proto.VolumeNorm)} + }), + ) + if err != nil { + return + } + stream.Start() + stream.Drain() + stream.Stop() + stream.Close() +} + +func playOne(s sound) { + go playSamples(samples[s]) +} diff --git a/audio/beep_windows.go b/audio/beep_windows.go new file mode 100644 index 0000000..972a9a7 --- /dev/null +++ b/audio/beep_windows.go @@ -0,0 +1,9 @@ +//go:build windows + +package audio + +// No audio playback on Windows — beeps are no-ops. (Capture still works via +// the malgo backend in capture_other.go.) + +func initSound() {} +func playOne(sound) {} diff --git a/audio/capture_darwin.go b/audio/capture_darwin.go new file mode 100644 index 0000000..6e38114 --- /dev/null +++ b/audio/capture_darwin.go @@ -0,0 +1,179 @@ +//go:build darwin + +package audio + +import ( + "encoding/hex" + "fmt" + "sync" + "sync/atomic" + + "github.com/gen2brain/malgo" +) + +// deviceMu serializes every malgo context/device lifecycle call across capture +// AND playback (beep_darwin.go). miniaudio's CoreAudio backend keeps process- +// global default-device state that ma_device_init/uninit/start/stop mutate; two +// threads touching it concurrently corrupt the heap (observed SIGSEGV in +// ma_device_uninit). It does NOT guard the audio data callbacks — those run on +// miniaudio's own thread and don't touch lifecycle state. Non-reentrant: +// acquire once per logical operation, never nest. +var deviceMu sync.Mutex + +// maCtx is the single process-wide malgo context, shared by capture and +// playback so there's one piece of CoreAudio global state, not two. Created +// lazily under deviceMu and never freed — its lifetime is the process. +var maCtx *malgo.AllocatedContext + +// ensureContext creates the shared context on first use. Caller must hold deviceMu. +func ensureContext() error { + if maCtx != nil { + return nil + } + ctx, err := malgo.InitContext(nil, malgo.ContextConfig{}, nil) + if err != nil { + return err + } + maCtx = ctx + return nil +} + +type malgoContext struct{} + +func NewContext() (Context, error) { + deviceMu.Lock() + defer deviceMu.Unlock() + if err := ensureContext(); err != nil { + return nil, err + } + return &malgoContext{}, nil +} + +func (m *malgoContext) Devices() ([]DeviceInfo, error) { + deviceMu.Lock() + devices, err := maCtx.Devices(malgo.Capture) + deviceMu.Unlock() + if err != nil { + return nil, fmt.Errorf("malgo devices: %w", err) + } + var result []DeviceInfo + for _, d := range devices { + result = append(result, DeviceInfo{ + ID: hex.EncodeToString(d.ID[:]), + Name: d.Name(), + }) + } + return result, nil +} + +func (m *malgoContext) NewCapture(device *DeviceInfo, config CaptureConfig) (CaptureDevice, error) { + c := &malgoCapture{ + deviceInfo: device, + config: config, + } + + deviceMu.Lock() + err := c.initDevice() + deviceMu.Unlock() + if err != nil { + return nil, err + } + + return c, nil +} + +// Close is a no-op: the shared malgo context is process-wide and outlives any +// single Context (playback may still need it). Capture devices are torn down +// individually via malgoCapture.Close. +func (m *malgoContext) Close() {} + +type malgoCapture struct { + deviceInfo *DeviceInfo + config CaptureConfig + device *malgo.Device + callback atomic.Pointer[DataCallback] +} + +// initDevice is lock-free; callers must hold deviceMu around it. +func (c *malgoCapture) initDevice() error { + deviceConfig := malgo.DefaultDeviceConfig(malgo.Capture) + deviceConfig.Capture.Format = malgo.FormatS16 + deviceConfig.Capture.Channels = c.config.Channels + deviceConfig.SampleRate = c.config.SampleRate + + if c.deviceInfo != nil { + idBytes, err := hex.DecodeString(c.deviceInfo.ID) + if err != nil { + return fmt.Errorf("invalid device ID: %w", err) + } + var devID malgo.DeviceID + copy(devID[:], idBytes) + deviceConfig.Capture.DeviceID = devID.Pointer() + } + + callbacks := malgo.DeviceCallbacks{ + Data: func(_, data []byte, frameCount uint32) { + if cb := c.callback.Load(); cb != nil { + (*cb)(data, frameCount) + } + }, + } + + dev, err := malgo.InitDevice(maCtx.Context, deviceConfig, callbacks) + if err != nil { + return err + } + + c.device = dev + return nil +} + +func (c *malgoCapture) Start() error { + deviceMu.Lock() + defer deviceMu.Unlock() + // Always reinitialize before starting — handles macOS sleep/wake where the + // device handle goes stale without returning errors. Null the pointer after + // Uninit: if the reinit below fails (transient CoreAudio error during a + // route/sleep-wake change), c.device must not be left pointing at the freed + // device, or the next Start uninits it again and double-frees. + if c.device != nil { + c.device.Uninit() + c.device = nil + } + if err := c.initDevice(); err != nil { + return fmt.Errorf("device reinit failed: %w", err) + } + return c.device.Start() +} + +func (c *malgoCapture) Stop() { + deviceMu.Lock() + if c.device != nil { + c.device.Stop() + } + deviceMu.Unlock() +} + +func (c *malgoCapture) Close() { + deviceMu.Lock() + if c.device != nil { + c.device.Uninit() + c.device = nil + } + deviceMu.Unlock() +} + +func (c *malgoCapture) SetCallback(cb DataCallback) { + c.callback.Store(&cb) +} + +func (c *malgoCapture) ClearCallback() { + c.callback.Store(nil) +} + +func (c *malgoCapture) DeviceName() string { + if c.deviceInfo != nil { + return c.deviceInfo.Name + } + return "system default" +} diff --git a/audio/audio_linux.go b/audio/capture_linux.go similarity index 100% rename from audio/audio_linux.go rename to audio/capture_linux.go diff --git a/audio/capture_windows.go b/audio/capture_windows.go new file mode 100644 index 0000000..385df13 --- /dev/null +++ b/audio/capture_windows.go @@ -0,0 +1,13 @@ +//go:build windows + +package audio + +import "errors" + +// Windows audio capture is not implemented. The app currently ships for +// darwin/arm64 only (see .goreleaser.yml); these stubs exist solely so the +// package compiles under GOOS=windows. When Windows becomes a real target, +// give it its own backend here — it need not mirror darwin's malgo path. +func NewContext() (Context, error) { + return nil, errors.New("audio: capture not implemented on windows") +} diff --git a/audio/fake.go b/audio/fake.go index 05acda1..f645c25 100644 --- a/audio/fake.go +++ b/audio/fake.go @@ -3,6 +3,7 @@ package audio import ( "os" "sync" + "sync/atomic" "time" "zee/encoder" ) @@ -35,10 +36,20 @@ func (f *FakeContext) NewCapture(_ *DeviceInfo, _ CaptureConfig) (CaptureDevice, return &FakeCapture{pcm: f.pcm, realtime: f.realtime, audioDone: make(chan struct{})}, nil } +// NewNamedCapture is like NewCapture but tags the device with a name, so tests +// can tell two fake devices apart (e.g. to verify a device switch took effect). +func (f *FakeContext) NewNamedCapture(name string) (CaptureDevice, error) { + return &FakeCapture{pcm: f.pcm, realtime: f.realtime, audioDone: make(chan struct{}), name: name}, nil +} + type FakeCapture struct { pcm []byte realtime bool audioDone chan struct{} + name string + + // Starts counts Start() calls; tests read it to see which device was used. + Starts atomic.Int32 mu sync.Mutex cb DataCallback @@ -60,7 +71,12 @@ func (f *FakeCapture) ClearCallback() { f.mu.Unlock() } -func (f *FakeCapture) DeviceName() string { return "fake" } +func (f *FakeCapture) DeviceName() string { + if f.name != "" { + return f.name + } + return "fake" +} func (f *FakeCapture) feedChunk(cb DataCallback, pos, chunkBytes int) int { end := min(pos+chunkBytes, len(f.pcm)) @@ -71,6 +87,7 @@ func (f *FakeCapture) feedChunk(cb DataCallback, pos, chunkBytes int) int { } func (f *FakeCapture) Start() error { + f.Starts.Add(1) f.stopCh = make(chan struct{}) f.feedDone = make(chan struct{}) // audioDone is NOT recreated here -- callers may already be waiting on it. diff --git a/beep/beep.go b/beep/beep.go deleted file mode 100644 index 5099361..0000000 --- a/beep/beep.go +++ /dev/null @@ -1,26 +0,0 @@ -package beep - -var disabled bool - -func Disable() { disabled = true } - -const ( - sampleRate = 44100 - - // Start beep: high pitch, short - startFreq = 1200 - startVolume = 0.5 - startDecay = 60 - - // End beep: medium pitch, slightly longer - endFreq = 900 - endVolume = 0.5 - endDecay = 40 - - // Error beep: low pitch double-beep - errorFreq = 350 - errorVolume = 0.6 - errorDecay = 30 -) - -// Platform-specific durations (darwin uses shorter durations) diff --git a/beep/beep_darwin.go b/beep/beep_darwin.go deleted file mode 100644 index 9783497..0000000 --- a/beep/beep_darwin.go +++ /dev/null @@ -1,171 +0,0 @@ -//go:build darwin - -package beep - -import ( - "math" - "sync" - "sync/atomic" - - "github.com/gen2brain/malgo" -) - -var ( - malgoCtx *malgo.AllocatedContext - device *malgo.Device - startSamples []byte - endSamples []byte - errorSamples []byte - soundOnce sync.Once - - // Playback state - accessed atomically from callback - playSamples atomic.Pointer[[]byte] - playPos atomic.Uint32 - playMu sync.Mutex -) - -func initDevice() error { - config := malgo.DefaultDeviceConfig(malgo.Playback) - config.Playback.Format = malgo.FormatS16 - config.Playback.Channels = 1 - config.SampleRate = sampleRate - - callbacks := malgo.DeviceCallbacks{ - Data: dataCallback, - } - - var err error - device, err = malgo.InitDevice(malgoCtx.Context, config, callbacks) - return err -} - -func initSound() { - var err error - malgoCtx, err = malgo.InitContext(nil, malgo.ContextConfig{}, nil) - if err != nil { - return - } - - startSamples = generateTickBytes(sampleRate, startFreq, 0.03, startVolume, startDecay) - endSamples = generateTickBytes(sampleRate, endFreq, 0.05, endVolume, endDecay) - errorSamples = generateDoubleBeepBytes(sampleRate, errorFreq, 0.08, 0.05, errorVolume, errorDecay) - - if err := initDevice(); err != nil { - malgoCtx.Uninit() - malgoCtx = nil - return - } -} - -func dataCallback(pOutput, _ []byte, frameCount uint32) { - samples := playSamples.Load() - if samples == nil || len(*samples) == 0 { - // Silence when not playing - for i := range pOutput { - pOutput[i] = 0 - } - return - } - - pos := playPos.Load() - total := uint32(len(*samples)) - bytesToWrite := frameCount * 2 - remaining := total - pos - - if remaining == 0 { - playSamples.Store(nil) - for i := range pOutput { - pOutput[i] = 0 - } - return - } - - if bytesToWrite > remaining { - bytesToWrite = remaining - } - - copy(pOutput[:bytesToWrite], (*samples)[pos:pos+bytesToWrite]) - playPos.Store(pos + bytesToWrite) - - // Zero-fill remainder - for i := bytesToWrite; i < frameCount*2; i++ { - pOutput[i] = 0 - } -} - -func generateTickBytes(sampleRate int, freq float64, duration float64, volume float64, decay float64) []byte { - n := int(float64(sampleRate) * duration) - buf := make([]byte, n*2) - for i := 0; i < n; i++ { - t := float64(i) / float64(sampleRate) - envelope := math.Exp(-t * decay) - sample := int16(math.Sin(2*math.Pi*freq*t) * 32767 * volume * envelope) - buf[i*2] = byte(sample) - buf[i*2+1] = byte(sample >> 8) - } - return buf -} - -func generateDoubleBeepBytes(sampleRate int, freq float64, beepDur float64, gapDur float64, volume float64, decay float64) []byte { - beep := generateTickBytes(sampleRate, freq, beepDur, volume, decay) - gap := make([]byte, int(float64(sampleRate)*gapDur)*2) - result := make([]byte, 0, len(beep)*2+len(gap)) - result = append(result, beep...) - result = append(result, gap...) - result = append(result, beep...) - return result -} - -func playBytes(samples []byte) { - if malgoCtx == nil || len(samples) == 0 { - return - } - - playMu.Lock() - defer playMu.Unlock() - - // Always reinitialize to pick up current default output device - // (handles BT connect/disconnect, sleep/wake) - if device != nil { - device.Stop() - device.Uninit() - } - if err := initDevice(); err != nil { - return - } - - playPos.Store(0) - playSamples.Store(&samples) - - if err := device.Start(); err != nil { - playSamples.Store(nil) - } -} - -func Init() { - soundOnce.Do(initSound) -} - -func PlayStart() { - if disabled { - return - } - soundOnce.Do(initSound) - playBytes(startSamples) -} - -func PlayEnd() { - if disabled { - return - } - soundOnce.Do(initSound) - playBytes(endSamples) -} - -func PlayError() { - if disabled { - return - } - soundOnce.Do(initSound) - playBytes(errorSamples) -} diff --git a/beep/beep_linux.go b/beep/beep_linux.go deleted file mode 100644 index 18b47dc..0000000 --- a/beep/beep_linux.go +++ /dev/null @@ -1,111 +0,0 @@ -//go:build linux - -package beep - -import ( - "math" - "sync" - - "github.com/jfreymuth/pulse" - "github.com/jfreymuth/pulse/proto" -) - -var ( - startSamples []int16 - endSamples []int16 - errorSamples []int16 - soundOnce sync.Once -) - -func initSound() { - startSamples = generateTick(sampleRate, startFreq, 0.2, startVolume, startDecay) - endSamples = generateTick(sampleRate, endFreq, 0.2, endVolume, endDecay) - errorSamples = generateDoubleBeep(sampleRate, errorFreq, 0.08, 0.05, errorVolume, errorDecay) -} - -func generateTick(sampleRate int, freq float64, duration float64, volume float64, decay float64) []int16 { - n := int(float64(sampleRate) * duration) - samples := make([]int16, n*2) - for i := 0; i < n; i++ { - t := float64(i) / float64(sampleRate) - envelope := math.Exp(-t * decay) - s := int16(math.Sin(2*math.Pi*freq*t) * 32767 * volume * envelope) - samples[i*2] = s - samples[i*2+1] = s - } - return samples -} - -func generateDoubleBeep(sampleRate int, freq float64, beepDur float64, gapDur float64, volume float64, decay float64) []int16 { - beep := generateTick(sampleRate, freq, beepDur, volume, decay) - gap := make([]int16, int(float64(sampleRate)*gapDur)*2) - result := make([]int16, 0, len(beep)*2+len(gap)) - result = append(result, beep...) - result = append(result, gap...) - result = append(result, beep...) - return result -} - -func playSamples(samples []int16) { - if len(samples) == 0 { - return - } - c, err := pulse.NewClient() - if err != nil { - return - } - defer c.Close() - - pos := 0 - reader := pulse.Int16Reader(func(buf []int16) (int, error) { - if pos >= len(samples) { - return 0, pulse.EndOfData - } - n := copy(buf, samples[pos:]) - pos += n - return n, nil - }) - stream, err := c.NewPlayback(reader, - pulse.PlaybackStereo, - pulse.PlaybackSampleRate(sampleRate), - pulse.PlaybackLatency(0.1), - pulse.PlaybackRawOption(func(p *proto.CreatePlaybackStream) { - p.ChannelVolumes = proto.ChannelVolumes{uint32(proto.VolumeNorm), uint32(proto.VolumeNorm)} - }), - ) - if err != nil { - return - } - stream.Start() - stream.Drain() - stream.Stop() - stream.Close() -} - -func Init() { - soundOnce.Do(initSound) -} - -func PlayStart() { - if disabled { - return - } - soundOnce.Do(initSound) - go playSamples(startSamples) -} - -func PlayEnd() { - if disabled { - return - } - soundOnce.Do(initSound) - go playSamples(endSamples) -} - -func PlayError() { - if disabled { - return - } - soundOnce.Do(initSound) - go playSamples(errorSamples) -} diff --git a/beep/beep_windows.go b/beep/beep_windows.go deleted file mode 100644 index 630d83d..0000000 --- a/beep/beep_windows.go +++ /dev/null @@ -1,10 +0,0 @@ -//go:build windows - -package beep - -// No audio playback on Windows - beeps disabled. - -func Init() {} -func PlayStart() {} -func PlayEnd() {} -func PlayError() {} diff --git a/cmd/localmodels/main.go b/cmd/localmodels/main.go new file mode 100644 index 0000000..3cca9f3 --- /dev/null +++ b/cmd/localmodels/main.go @@ -0,0 +1,59 @@ +// Command localmodels is the CLI face of the localmodel registry +// (localmodel.go, the single source of truth). Two verbs, both thin projections +// of that registry: +// +// localmodels download fetch the prefetch models into the dev folder +// (models/parakeet/); used by `make build`. +// localmodels manifest print the registry as flat text (filename, sha256, +// prefetch) — written to localmodel/manifest.txt by +// `make manifest`, which install.sh reads. +package main + +import ( + "fmt" + "os" + "path/filepath" + + "zee/localmodel" +) + +func main() { + cmd := "" + if len(os.Args) > 1 { + cmd = os.Args[1] + } + switch cmd { + case "download": + download() + case "manifest": + fmt.Print(localmodel.Manifest()) + default: + fmt.Fprintln(os.Stderr, "usage: localmodels ") + os.Exit(2) + } +} + +// download fetches the mandatory (PreFetch) models into the versioned dev folder. +// Opt-in models are skipped — fetch those from the tray at runtime. Best-effort: +// a failure warns but never fails the build (a dev may copy the ggufs in +// manually, or the release isn't up yet). +func download() { + dir := filepath.Join("models", "parakeet", localmodel.Version) + os.Setenv("ZEE_MODELS_DIR", dir) // force the dev folder regardless of cwd state + + for _, m := range localmodel.All() { + if !m.PreFetch { + continue + } + if localmodel.Present(m) { + fmt.Printf("✓ %s present\n", m.Filename) + continue + } + fmt.Printf("↓ %s (%s)...\n", m.Filename, m.HumanSize()) + if err := localmodel.Download(m, nil); err != nil { + fmt.Fprintf(os.Stderr, " warning: %v\n", err) + fmt.Fprintf(os.Stderr, " place %s in %s/ manually, or publish the models-%s release\n", + m.Filename, dir, localmodel.Version) + } + } +} diff --git a/config/config.go b/config/config.go index c3dd740..180e66d 100644 --- a/config/config.go +++ b/config/config.go @@ -5,6 +5,7 @@ import ( "os" "path/filepath" "runtime" + "strings" "sync" "zee/log" @@ -62,6 +63,18 @@ func settingsPath() string { return filepath.Join(Dir(), settingsFile) } +// IsAppBundle reports whether this binary is the installed Zee.app rather than a +// local dev build, keyed off the executable path (not cwd). It's the single +// "am I the installed app?" signal — used to pick app-vs-dev locations +// consistently (login-item plist, local models dir). +func IsAppBundle() bool { + exe, err := os.Executable() + if err != nil { + return false + } + return strings.Contains(exe, ".app/Contents/MacOS/") +} + func Load() error { dir = Dir() current = defaults @@ -74,16 +87,16 @@ func Load() error { return err } - var s Settings + // Unmarshal into a defaults-seeded copy so fields absent from the file keep + // their defaults, while fields present in the file win. This distinguishes + // "language not set" (→ default "en") from an explicit "language":"" + // (Auto-detect), which must persist. + s := defaults if err := json.Unmarshal(data, &s); err != nil { log.Warnf("settings: corrupt config.json, using defaults: %v", err) return nil } - current = s - if current.Language == "" { - current.Language = defaults.Language - } return nil } diff --git a/config/config_test.go b/config/config_test.go index 0546398..269bc53 100644 --- a/config/config_test.go +++ b/config/config_test.go @@ -66,6 +66,39 @@ func TestSettingsRoundTrip(t *testing.T) { } } +// TestAutoDetectLanguagePersists reproduces the bug where selecting Auto-detect +// (empty language code) is not remembered across restarts: Load coerced the +// saved "" back to the default "en", so auto-detect silently reverted to +// English. After the fix an explicit empty language round-trips. +func TestAutoDetectLanguagePersists(t *testing.T) { + SetDir(t.TempDir()) + if err := Load(); err != nil { + t.Fatalf("Load: %v", err) + } + Update(func(s *Settings) { s.Language = "" }) // user picks Auto-detect + if err := Load(); err != nil { + t.Fatalf("reload: %v", err) + } + if got := Get().Language; got != "" { + t.Fatalf("auto-detect not persisted: Language = %q, want \"\"", got) + } +} + +// TestMissingLanguageDefaultsToEn guards that a config file with no language +// field still falls back to "en" (so the fix above doesn't turn unset into +// auto-detect). +func TestMissingLanguageDefaultsToEn(t *testing.T) { + d := t.TempDir() + SetDir(d) + os.WriteFile(filepath.Join(d, "config.json"), []byte(`{"device":"x"}`), 0644) + if err := Load(); err != nil { + t.Fatalf("Load: %v", err) + } + if got := Get().Language; got != "en" { + t.Fatalf("missing language should default to en, got %q", got) + } +} + func TestSettingsCopySafety(t *testing.T) { SetDir(t.TempDir()) Load() diff --git a/config/hints.go b/config/hints.go index f73ec91..c8ff3a7 100644 --- a/config/hints.go +++ b/config/hints.go @@ -13,6 +13,24 @@ const hintsFile = "hints.txt" const hintsHeader = `# Vocabulary hints for transcription (one per line) # These help the model recognize domain-specific terms # Empty lines and lines starting with # are ignored +Opus +Claude +Sonnet +Fable +Pi.dev +JSON +Codex +Harness +Gzip +OpenAI +Anthropic +App Router +Grafana +favicon +Mistral +ElevenLabs +Bun +Node.js ` func HintsPath() string { diff --git a/docs/design-notes.md b/docs/design-notes.md new file mode 100644 index 0000000..c1793eb --- /dev/null +++ b/docs/design-notes.md @@ -0,0 +1,107 @@ +# Design Notes + +Ideas and tradeoffs decided while building zee. Each entry captures *why* a +choice was made, so we don't relitigate it (or silently regress it) later. + +## Why the audio device is re-init'd on every recording + +`audio.Start()` tears down the capture device (`Uninit`) and rebuilds it +(`InitDevice`) on **every** recording, instead of initializing it once at +startup and reusing it. + +**Reason:** after macOS sleep/wake (and some device/route changes) the malgo +(miniaudio/CoreAudio) device handle goes **stale silently** — `device.Start()` +returns no error, but the mic produces only silence. There's no reliable signal +to detect this, so the blunt-but-safe fix is to rebuild the device every time. +This replaced an earlier "start, and only recreate on error" approach that +didn't work because the stale handle never reported an error. + +**Tradeoff:** constant `Uninit`/`InitDevice` churn enlarges the surface for +miniaudio lifecycle bugs. It directly caused a double-free crash: if the rebuild +failed transiently, the old (already-freed) device pointer was retained and the +next `Start` uninited it a second time (SIGABRT/SIGSEGV in `ma_device_uninit`). +Fixed by never keeping a freed pointer — store `nil` if the rebuild fails (see +`audio/capture_darwin.go`). The same teardown-on-use pattern (and the same fix) +applies to `beep` playback (`audio/beep_darwin.go`). + +**Better long-term options (not yet done):** +- Init once + reinit only on a real signal — macOS `NSWorkspace` sleep/wake + notification, or miniaudio's reroute/stop notification callback. Most correct, + but only manually verifiable (`pmset sleepnow`, wake, record). +- Init once + stale-by-behavior detection — keep the device, and after `Start` + watch the data callback for actual frames; reinit only if none arrive. Catches + every stale cause, and is unit-testable with a fake device, but it's heuristic. + +All miniaudio device lifecycle calls (capture + playback) are also serialized +behind a process-wide lock (`deviceMu` in `audio/capture_darwin.go`) as defense +against concurrent init/uninit across the two malgo contexts. + +## Why Parakeet (local default), CPU, and which model + +zee runs NVIDIA NeMo **Parakeet** locally (via `parakeet.cpp`/`libparakeet`/ggml) +as the default engine, with cloud providers (Groq, OpenAI, …) as accuracy/noise +fallbacks. Local-first wins on privacy and latency; the cloud path is there for +when accuracy matters or the environment is noisy. All numbers below were +measured on an M1 Pro. + +**CPU backend, not Metal.** ggml's Metal backend doesn't implement `CONV_2D_DW`, +the depthwise conv the FastConformer encoder needs, and Parakeet runs the whole +graph on one backend with no per-op CPU fallback — so a Metal build *aborts*, it +doesn't degrade. Still true on ggml v0.13.0. The CPU path runs at 20–65x +real-time, which is plenty for dictation-length clips; Metal would only matter +for batching hours of audio. (A different Metal-capable backend exists but only +beats CPU on the 0.6b model and *loses* on our 110m default.) + +**Static-linked** (`BUILD_SHARED_LIBS=OFF` → `.a` folded into the Go binary): +one self-contained arm64 binary, no shipped `lib/`, no dylib version-skew. Cost +is ~4 MB and a relink when the C side changes — worth it for a drop-in app. + +**Model defaults (measured):** + +``` ++-------------------+-------+--------+----------+--------+------------------------+ +| Use | Model | Size | Load | RTFx | Felt latency (warm) | ++-------------------+-------+--------+----------+--------+------------------------+ +| English (default) | 110m | 255 MB | ~55 ms | ~36x | 10s clip → ~150-300 ms | +| Multilingual | v3- | 1.44GB | ~2.4 s | ~10x | 10s clip → ~0.7-1.6 s | +| (25 languages) | 0.6b | | | | | ++-------------------+-------+--------+----------+--------+------------------------+ +``` + +- **Both use the TDT head → one decode path, just swap the `.gguf`.** TDT's + implicit LM wins short/dictation clips (CTC is steadier on long-form, so + reconsider CTC if we add a meetings mode). +- **110m ties the 0.6b-v2 on accuracy** on our 9-sample corpus (~14% WER, within + noise) at **1/5 the size and 2.5x the speed** — the 0.6b buys nothing for + English. (Handy/VoiceInk ship the 0.6b; revisit only if a larger eval shows a + real gap.) +- **Keep the multilingual model warm** — its ~2.4 s load must never land + per-utterance. Load once at startup, transcribe per clip. +- **Don't quantize for speed.** q4_k is ~26% *slower* on CPU (per-matmul dequant + vs the f16 Accelerate/AMX path); it's a footprint/load-time win only. + +**What we tested (so read the WER as relative, not absolute):** a 9-sample +corpus, 539 reference words — a mix of dictation-length clips plus one 350-word +(~3-min) clip that alone is 65% of the words; the RTFx/leaderboard figures come +from a single 184 s clip (and the `-mcpu` tuning bench from a 33.8 s clip). +References are cloud-transcription-derived, not human gold, so WER is a relative +ranking signal (±noise), not ground truth. "Tied on accuracy" means within that +noise; TDT's dictation edge is 4–2 on the *short* clips, while the lone long clip +(which CTC won) dominates the corpus average. + +**"Instant Parakeet" is datacenter RTFx, not laptop.** The 1500–3400x figures are +A100/H100 batched runs (a 3-min clip in tens of ms). On M-series CPU the encoder +is the bottleneck and even a working Metal build couldn't reach ms (the TDT +decoder is autoregressive, latency-bound). Honest local target: English is +perceptually instant (fixed overhead dominates), multilingual scales ~linearly. + +**The biggest unused lever is VAD trimming** (drop silence before inference — +encoder cost scales with frames). Not done because it would let a VAD *false +negative silently drop real speech*; zee only uses VAD for non-critical "still +recording" UI, never to gate what reaches the model. Safe partial win: VAD for +auto-stop only, still send the full buffer. + +Full benchmarks, decoder/threading/deployment-target details, and the +provider-WER comparison live in internal notes (`mynotes/notes/local-stt-models.md`, +`stt-model-comparisons.md`); the `wer-wolf` skill is the repeatable way to re-run +provider/model evals on saved samples. diff --git a/doctor/doctor.go b/doctor/doctor.go index 36dacf9..2bd673d 100644 --- a/doctor/doctor.go +++ b/doctor/doctor.go @@ -13,6 +13,8 @@ import ( "zee/clipboard" "zee/encoder" "zee/hotkey" + "zee/internal/parakeet" + "zee/localmodel" "zee/transcriber" ) @@ -24,6 +26,8 @@ func Run(_ string) int { fmt.Println("zee doctor - interactive system diagnostics") fmt.Println("============================================") + printLocalModels() + allPass := true if !checkHotkey() { @@ -49,6 +53,25 @@ func Run(_ string) int { return 1 } +// printLocalModels reports the offline engine + which models are on disk. +// Informational only — it never fails the run (cloud-only users have none). +func printLocalModels() { + fmt.Println() + fmt.Println("Local models (Parakeet)") + if !parakeet.Available() { + fmt.Println(" engine: not available on this platform (Apple Silicon only)") + return + } + fmt.Printf(" engine available — models dir: %s\n", localmodel.Dir()) + for _, m := range localmodel.All() { + state := "missing" + if localmodel.Present(m) { + state = "present" + } + fmt.Printf(" %-34s %8s %s\n", m.Label, m.HumanSize(), state) + } +} + func checkHotkey() bool { fmt.Println() fmt.Println("[1/3] Hotkey detection") @@ -132,49 +155,18 @@ func checkMicAndTranscription() bool { fmt.Printf("Selected: %s\n", device.Name) } - // Select provider - fmt.Println() - fmt.Println("Select transcription provider:") - fmt.Println(" 1. Groq") - fmt.Println(" 2. DeepGram") - fmt.Println(" 3. OpenAI") - fmt.Print("Choice [1/2/3]: ") - - choice, _ := reader.ReadString('\n') - choice = strings.TrimSpace(choice) - - var provider string - switch choice { - case "1", "": - provider = "groq" - case "2": - provider = "deepgram" - case "3": - provider = "openai" - default: - fmt.Printf(" FAIL: invalid choice %q\n", choice) - return false - } - - // Get API key - fmt.Printf("Enter %s API key: ", provider) - apiKey, _ := reader.ReadString('\n') - apiKey = strings.TrimSpace(apiKey) - if apiKey == "" { - fmt.Println(" FAIL: API key required") + // Use the same engine the app would: local Parakeet when a model is present, + // else the first cloud provider whose API key is set. No interactive prompt. + trans, err := transcriber.New() + if err != nil { + fmt.Printf(" FAIL: no transcription engine available: %v\n", err) return false } - - // Create transcriber - var trans transcriber.Transcriber - switch provider { - case "groq": - trans = transcriber.NewGroq(apiKey) - case "deepgram": - trans = transcriber.NewDeepgram(apiKey) - case "openai": - trans = transcriber.NewOpenAI(apiKey) + engine := "cloud" + if transcriber.IsLocal(trans) { + engine = "Local (Parakeet)" } + fmt.Printf("Using engine: %s\n", engine) fmt.Println() fmt.Print("Press Enter and speak for 3 seconds...") diff --git a/go.mod b/go.mod index 3f92e0f..d1efb3b 100644 --- a/go.mod +++ b/go.mod @@ -19,12 +19,20 @@ require ( ) require ( + github.com/ebitengine/purego v0.10.0 // indirect + github.com/go-ole/go-ole v1.2.6 // indirect github.com/godbus/dbus/v5 v5.1.0 // indirect github.com/icza/bitio v1.1.0 // indirect + github.com/lufia/plan9stats v0.0.0-20211012122336-39d0f177ccd0 // indirect github.com/mattn/go-colorable v0.1.13 // indirect github.com/mattn/go-isatty v0.0.20 // indirect github.com/mewkiz/pkg v0.0.0-20250417130911-3f050ff8c56d // indirect github.com/mewpkg/term v0.0.0-20241026122259-37a80af23985 // indirect + github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55 // indirect + github.com/shirou/gopsutil/v4 v4.26.5 // indirect + github.com/tklauser/go-sysconf v0.3.16 // indirect + github.com/tklauser/numcpus v0.11.0 // indirect + github.com/yusufpapurcu/wmi v1.2.4 // indirect golang.design/x/mainthread v0.3.0 // indirect - golang.org/x/sys v0.40.0 // indirect + golang.org/x/sys v0.41.0 // indirect ) diff --git a/go.sum b/go.sum index 57c34d2..f197db4 100644 --- a/go.sum +++ b/go.sum @@ -1,19 +1,26 @@ github.com/atotto/clipboard v0.1.4 h1:EH0zSVneZPSuFR11BlR9YppQTVDbh5+16AmcJi4g1z4= github.com/atotto/clipboard v0.1.4/go.mod h1:ZY9tmq7sm5xIbd9bOK4onWV4S6X0u6GY7Vn0Yu86PYI= github.com/coreos/go-systemd/v22 v22.5.0/go.mod h1:Y58oyj3AT4RCenI/lSvhwexgC+NSVTIJ3seZv2GcEnc= +github.com/ebitengine/purego v0.10.0 h1:QIw4xfpWT6GWTzaW5XEKy3HXoqrJGx1ijYHzTF0/ISU= +github.com/ebitengine/purego v0.10.0/go.mod h1:iIjxzd6CiRiOG0UyXP+V1+jWqUXVjPKLAI0mRfJZTmQ= github.com/energye/systray v1.0.3 h1:XnyjJCeRU5z00bpNOic2fGTKz/7yHZMZjWiGIVXDS+4= github.com/energye/systray v1.0.3/go.mod h1:HelKhC3PXwv3ryDxbuQqV+7kAxAYNzE5cfdrerGOZTc= github.com/gen2brain/malgo v0.11.25-0.20251120102819-856f60956a65 h1:fcKNzdcFB4fVELL3TludxzRVGWEPSQ/ICyDTbJ9LK5Y= github.com/gen2brain/malgo v0.11.25-0.20251120102819-856f60956a65/go.mod h1:f9TtuN7DVrXMiV/yIceMeWpvanyVzJQMlBecJFVMxww= +github.com/go-ole/go-ole v1.2.6 h1:/Fpf6oFPoeFik9ty7siob0G6Ke8QvQEuVcuChpwXzpY= +github.com/go-ole/go-ole v1.2.6/go.mod h1:pprOEPIfldk/42T2oK7lQ4v4JSDwmV0As9GaiUsvbm0= github.com/godbus/dbus/v5 v5.0.4/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA= github.com/godbus/dbus/v5 v5.1.0 h1:4KLkAxT3aOY8Li4FRJe/KvhoNFFxo0m6fNuFUO8QJUk= github.com/godbus/dbus/v5 v5.1.0/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA= +github.com/google/go-cmp v0.5.6/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= github.com/icza/bitio v1.1.0 h1:ysX4vtldjdi3Ygai5m1cWy4oLkhWTAi+SyO6HC8L9T0= github.com/icza/bitio v1.1.0/go.mod h1:0jGnlLAx8MKMr9VGnn/4YrvZiprkvBelsVIbA9Jjr9A= github.com/icza/mighty v0.0.0-20180919140131-cfd07d671de6 h1:8UsGZ2rr2ksmEru6lToqnXgA8Mz1DP11X4zSJ159C3k= github.com/icza/mighty v0.0.0-20180919140131-cfd07d671de6/go.mod h1:xQig96I1VNBDIWGCdTt54nHt6EeI639SmHycLYL7FkA= github.com/jfreymuth/pulse v0.1.1 h1:9WLNBNCijmtZ14ZJpatgJPu/NjwAl3TIKItSFnTh+9A= github.com/jfreymuth/pulse v0.1.1/go.mod h1:cpYspI6YljhkUf1WLXLLDmeaaPFc3CnGLjDZf9dZ4no= +github.com/lufia/plan9stats v0.0.0-20211012122336-39d0f177ccd0 h1:6E+4a0GO5zZEnZ81pIr0yLvtUWk2if982qA3F3QD6H4= +github.com/lufia/plan9stats v0.0.0-20211012122336-39d0f177ccd0/go.mod h1:zJYVVT2jmtg6P3p1VtQj7WsuWi/y4VnjVBn7F8KPB3I= github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA= github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg= github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM= @@ -31,20 +38,35 @@ github.com/mewpkg/term v0.0.0-20241026122259-37a80af23985/go.mod h1:uiPmbdUbdt1N github.com/micmonay/keybd_event v1.1.2 h1:RpgvPJKOh4Jc+ZYe0OrVzGd2eNMCfuVg3dFTCsuSah4= github.com/micmonay/keybd_event v1.1.2/go.mod h1:CGMWMDNgsfPljzrAWoybUOSKafQPZpv+rLigt2LzNGI= github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= +github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55 h1:o4JXh1EVt9k/+g42oCprj/FisM4qX9L3sZB3upGN2ZU= +github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55/go.mod h1:OmDBASR4679mdNQnz2pUhc2G8CO2JrUAVFDRBDP/hJE= github.com/rs/xid v1.6.0/go.mod h1:7XoLgs4eV+QndskICGsho+ADou8ySMSjJKDIan90Nz0= github.com/rs/zerolog v1.34.0 h1:k43nTLIwcTVQAncfCw4KZ2VY6ukYoZaBPNOE8txlOeY= github.com/rs/zerolog v1.34.0/go.mod h1:bJsvje4Z08ROH4Nhs5iH600c3IkWhwp44iRc54W6wYQ= +github.com/shirou/gopsutil/v4 v4.26.5 h1:RPcBXkpz7kOj9PqGFQOlBPZHsyaPvPVQc098y9RmCNM= +github.com/shirou/gopsutil/v4 v4.26.5/go.mod h1:LZ6ewCSkBqUpvSOf+LsTGnRinC6iaNUNMGBtDkJBaLQ= +github.com/tklauser/go-sysconf v0.3.16 h1:frioLaCQSsF5Cy1jgRBrzr6t502KIIwQ0MArYICU0nA= +github.com/tklauser/go-sysconf v0.3.16/go.mod h1:/qNL9xxDhc7tx3HSRsLWNnuzbVfh3e7gh/BmM179nYI= +github.com/tklauser/numcpus v0.11.0 h1:nSTwhKH5e1dMNsCdVBukSZrURJRoHbSEQjdEbY+9RXw= +github.com/tklauser/numcpus v0.11.0/go.mod h1:z+LwcLq54uWZTX0u/bGobaV34u6V7KNlTZejzM6/3MQ= +github.com/yusufpapurcu/wmi v1.2.4 h1:zFUKzehAFReQwLys1b/iSMl+JQGSCSjtVqQn9bBrPo0= +github.com/yusufpapurcu/wmi v1.2.4/go.mod h1:SBZ9tNy3G9/m5Oi98Zks0QjeHVDvuK0qfxQmPyzfmi0= golang.design/x/hotkey v0.4.1 h1:zLP/2Pztl4WjyxURdW84GoZ5LUrr6hr69CzJFJ5U1go= golang.design/x/hotkey v0.4.1/go.mod h1:M8SGcwFYHnKRa83FpTFQoZvPO5vVT+kWPztFqTQKmXA= golang.design/x/mainthread v0.3.0 h1:UwFus0lcPodNpMOGoQMe87jSFwbSsEY//CA7yVmu4j8= golang.design/x/mainthread v0.3.0/go.mod h1:vYX7cF2b3pTJMGM/hc13NmN6kblKnf4/IyvHeu259L0= +golang.org/x/sys v0.0.0-20190916202348-b4ddaad3f8a3/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20201022201747-fb209a7c41cd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20201204225414-ed752295db88/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.40.0 h1:DBZZqJ2Rkml6QMQsZywtnjnnGvHza6BTfYFWY9kjEWQ= golang.org/x/sys v0.40.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= +golang.org/x/sys v0.41.0 h1:Ivj+2Cp/ylzLiEU89QhWblYnOE9zerudt9Ftecq2C6k= +golang.org/x/sys v0.41.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= golang.org/x/term v0.39.0 h1:RclSuaJf32jOqZz74CkPA9qFuVTX7vhLlpfj/IGWlqY= golang.org/x/term v0.39.0/go.mod h1:yxzUCTP/U+FzoxfdKmLaA0RV1WgE0VY7hXBwKtY/4ww= +golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= nhooyr.io/websocket v1.8.17 h1:KEVeLJkUywCKVsnLIDlD/5gtayKp8VoCkksHCGGfT9Y= nhooyr.io/websocket v1.8.17/go.mod h1:rN9OFWIUwuxg4fR5tELlYC04bXYowCP9GX47ivo2l+c= diff --git a/install.sh b/install.sh index 21d0fe9..b2a168f 100755 --- a/install.sh +++ b/install.sh @@ -21,6 +21,7 @@ run_or_sudo() { trap cleanup EXIT [[ "$(uname -s)" == "Darwin" ]] || err "Zee currently supports macOS only." +[[ "$(uname -m)" == "arm64" ]] || err "Zee requires Apple Silicon (arm64) — the local engine is arm64-only, and releases ship no Intel build." VERSION="${VERSION:-}" if [[ -z "$VERSION" ]]; then @@ -34,6 +35,45 @@ log "Installing Zee ${VERSION}" DMG="Zee-${VERSION}.dmg" BASE="https://github.com/${REPO}/releases/download/${VERSION}" +# Offline models live under an immutable, app-version-independent release tag. +# The registry — filenames, hashes, and which models to pre-fetch — is generated +# from localmodel.go into localmodel/manifest.txt and read here from main, so +# nothing is hardcoded. Columns: filenamesha256prefetch. +MODELS_TAG="models-v1" +MODELS_BASE="https://github.com/${REPO}/releases/download/${MODELS_TAG}" +MODELS_DIR="${HOME}/Library/Application Support/zee/models" +MANIFEST_URL="https://raw.githubusercontent.com/${REPO}/main/localmodel/manifest.txt" + +# Best-effort: pre-download the offline models so Apple Silicon works with no +# API key on first launch. Never fails the install — the in-app downloader +# recovers anything missing. +prefetch_models() { + [[ "$(uname -m)" == "arm64" ]] || return 0 + mkdir -p "$MODELS_DIR" 2>/dev/null || return 0 + local manifest f sum dest + manifest="$(curl -fsSL "$MANIFEST_URL" 2>/dev/null)" || { + log "Model manifest unavailable — the app will fetch models on first launch" + return 0 + } + # Each prefetch=true row: download the gguf from the release, verify its sha. + while read -r f sum; do + [[ -n "$f" ]] || continue + dest="${MODELS_DIR}/${f}" + if [[ -f "$dest" ]] && shasum -a 256 "$dest" | grep -q "$sum"; then + log "Model ${f} already present"; continue + fi + log "Downloading model ${f} (best-effort)..." + if curl -fL --progress-bar "${MODELS_BASE}/${f}" -o "${dest}.part" \ + && shasum -a 256 "${dest}.part" | grep -q "$sum"; then + mv -f "${dest}.part" "$dest" + log "Model ${f} OK" + else + log "Model ${f} unavailable — the app will fetch it on first launch" + rm -f "${dest}.part" + fi + done < <(awk '!/^#/ && $3=="true" {print $1, $2}' <<<"$manifest") +} + log "Downloading ${DMG}..." curl -fL --progress-bar "${BASE}/${DMG}" -o "${TMP}/${DMG}" \ || err "download failed: ${BASE}/${DMG}" @@ -64,21 +104,26 @@ run_or_sudo cp -R "$MOUNT/Zee.app" "${APP_DIR}/" log "Clearing quarantine attribute..." run_or_sudo xattr -cr "${APP_DIR}/Zee.app" +log "Fetching offline models (best-effort)..." +prefetch_models || true + cat < +#include "parakeet_capi.h" +*/ +import "C" + +import ( + "fmt" + "sync" + "unsafe" +) + +// Decoder selects the model head passed to the C-API. +const ( + DecoderDefault = 0 // by arch: transducer for tdt/rnnt, CTC for ctc + DecoderCTC = 1 // force the CTC head + DecoderTDT = 2 // force the transducer (TDT/RNN-T) head +) + +// Available reports whether local Parakeet transcription is compiled in. +func Available() bool { return true } + +// Ctx wraps one loaded GGUF model. Transcribe is serialised by an internal +// mutex (push-to-talk is serial; the C ctx is not concurrency-safe), and Close +// waits for any in-flight Transcribe before freeing the model. +type Ctx struct { + mu sync.Mutex + ptr *C.parakeet_ctx +} + +// New loads a GGUF model from path. The returned Ctx must be Closed. +func New(path string) (*Ctx, error) { + cPath := C.CString(path) + defer C.free(unsafe.Pointer(cPath)) + + ptr := C.parakeet_capi_load(cPath) + if ptr == nil { + // load failures have no ctx, so pass NULL to last_error. + return nil, fmt.Errorf("parakeet: load %q: %s", path, C.GoString(C.parakeet_capi_last_error(nil))) + } + return &Ctx{ptr: ptr}, nil +} + +// Transcribe runs the model over mono 16 kHz float32 PCM and returns the +// transcript. decoder is one of the Decoder* constants. +func (c *Ctx) Transcribe(pcm []float32, decoder int) (string, error) { + if len(pcm) == 0 { + return "", nil + } + c.mu.Lock() + defer c.mu.Unlock() + if c.ptr == nil { + return "", fmt.Errorf("parakeet: transcribe on closed model") + } + + out := C.parakeet_capi_transcribe_pcm(c.ptr, + (*C.float)(unsafe.Pointer(&pcm[0])), C.int(len(pcm)), 16000, C.int(decoder)) + if out == nil { + return "", fmt.Errorf("parakeet: transcribe: %s", C.GoString(C.parakeet_capi_last_error(c.ptr))) + } + defer C.parakeet_capi_free_string(out) + return C.GoString(out), nil +} + +// Close frees the model. Safe to call more than once. +func (c *Ctx) Close() { + c.mu.Lock() + defer c.mu.Unlock() + if c.ptr != nil { + C.parakeet_capi_free(c.ptr) + c.ptr = nil + } +} diff --git a/internal/parakeet/parakeet_stub.go b/internal/parakeet/parakeet_stub.go new file mode 100644 index 0000000..5c25bd0 --- /dev/null +++ b/internal/parakeet/parakeet_stub.go @@ -0,0 +1,28 @@ +//go:build !darwin || !arm64 + +// Stub for platforms without the parakeet.cpp static libs (everything except +// darwin/arm64). Available() is false; nothing links any C dependency, so the +// universal-binary release pipeline and Linux/Intel builds are untouched. +package parakeet + +import "errors" + +const ( + DecoderDefault = 0 + DecoderCTC = 1 + DecoderTDT = 2 +) + +// Available reports whether local Parakeet transcription is compiled in. +func Available() bool { return false } + +// Ctx is an empty placeholder on unsupported platforms. +type Ctx struct{} + +var errUnavailable = errors.New("parakeet: local transcription is only available on Apple Silicon") + +func New(string) (*Ctx, error) { return nil, errUnavailable } + +func (c *Ctx) Transcribe([]float32, int) (string, error) { return "", errUnavailable } + +func (c *Ctx) Close() {} diff --git a/localmodel/diskspace_other.go b/localmodel/diskspace_other.go new file mode 100644 index 0000000..55c9bac --- /dev/null +++ b/localmodel/diskspace_other.go @@ -0,0 +1,7 @@ +//go:build !darwin && !linux + +package localmodel + +// checkDiskSpace is a no-op where we don't have statfs. Local models are an +// Apple Silicon feature; other platforms never reach the downloader in practice. +func checkDiskSpace(string, int64) error { return nil } diff --git a/localmodel/diskspace_unix.go b/localmodel/diskspace_unix.go new file mode 100644 index 0000000..83e6d22 --- /dev/null +++ b/localmodel/diskspace_unix.go @@ -0,0 +1,22 @@ +//go:build darwin || linux + +package localmodel + +import ( + "fmt" + "syscall" +) + +// checkDiskSpace fails if `dir`'s filesystem has less than need bytes free +// (plus a small margin), so a download aborts before filling the disk. +func checkDiskSpace(dir string, need int64) error { + var st syscall.Statfs_t + if err := syscall.Statfs(dir, &st); err != nil { + return nil // can't tell — don't block the download + } + free := int64(st.Bavail) * int64(st.Bsize) + if free < need+(64<<20) { // 64 MB margin + return fmt.Errorf("not enough disk space: need %d MB, %d MB free", need>>20, free>>20) + } + return nil +} diff --git a/localmodel/localmodel.go b/localmodel/localmodel.go new file mode 100644 index 0000000..18c66a8 --- /dev/null +++ b/localmodel/localmodel.go @@ -0,0 +1,269 @@ +// Package localmodel is the single source of truth for the offline Parakeet +// GGUF models: their filenames, download URLs, checksums, sizes, and decoder +// head. It resolves where models live on disk and downloads missing ones +// atomically (tmp → verify sha256 → rename). +// +// Decoder values match internal/parakeet.Decoder* (0=default, 1=ctc, 2=tdt). +// Keeping them here as plain ints keeps this package free of the cgo engine so +// it stays cross-platform (the tray and installer reference it everywhere). +package localmodel + +import ( + "context" + "crypto/sha256" + "encoding/hex" + "fmt" + "io" + "net" + "net/http" + "os" + "path/filepath" + "strings" + "time" + + "zee/config" +) + +// Version is the pinned model-set version. It drives BOTH the download tag and +// the dev folder, so they never drift (decision #5: models are pinned to the +// binary, never "latest"). Bump it when the parakeet.cpp commit changes. +const Version = "v1" + +// baseURL hosts the immutable models- release assets. +const baseURL = "https://github.com/sumerc/zee/releases/download/models-" + Version + "/" + +// Model IDs (stable; persisted in config.json and shown in the tray). +const ( + ID110mEN = "parakeet-110m-en" // default, English-only, loaded at startup + IDV3Multi = "parakeet-v3-multi" // multilingual (25 lang), pre-fetched + IDV2Large = "parakeet-v2-en-large" // English long-form, opt-in download +) + +// Model is one downloadable GGUF plus everything zee needs to load, route to, +// and verify it. +type Model struct { + ID string + Label string + Filename string + SHA256 string + SizeBytes int64 + Decoder int // parakeet head: 0=default, 1=ctc, 2=tdt + Multilingual bool // true => non-English supported (v3); false => English-only + PreFetch bool // install.sh pre-fetches it (110m + v3); v2 never +} + +// URL is where the gguf is hosted under the pinned models tag. +func (m Model) URL() string { return baseURL + m.Filename } + +// HumanSize renders the on-disk size as "1.4 GB" or "267 MB". +func (m Model) HumanSize() string { + if m.SizeBytes >= 1<<30 { + return fmt.Sprintf("%.1f GB", float64(m.SizeBytes)/(1<<30)) + } + return fmt.Sprintf("%d MB", m.SizeBytes>>20) +} + +// models is ordered: default first, then the pre-fetched multilingual option, +// then the opt-in large English model. +var models = []Model{ + { + ID: ID110mEN, + Label: "Parakeet 110M (English)", + Filename: "tdt_ctc-110m-f16.gguf", + SHA256: "7f9a6376edde6a74592ace48b2ebdc27a1ac972d0be9dfcc29e668d99381faf1", + SizeBytes: 267452544, + Decoder: 2, // TDT head + PreFetch: true, + }, + { + ID: IDV3Multi, + Label: "Parakeet 0.6B v3 (multilingual)", + Filename: "tdt-0.6b-v3-q4_k.gguf", + SHA256: "993d73feb4206dadda865ab25bd64b50c48dc4d013c3bf6126a721f28b1d5ee8", + SizeBytes: 675200864, + Decoder: 0, // default head + Multilingual: true, + PreFetch: true, + }, + { + ID: IDV2Large, + Label: "Parakeet 0.6B v2 (English, large)", + Filename: "tdt-0.6b-v2-f16.gguf", + SHA256: "f8df7f5dc7b9ceb5cd0637a81194aab5d93022ace555ce81c8969c7a694b8f3d", + SizeBytes: 1404218656, + Decoder: 0, // default head + PreFetch: false, + }, +} + +// All returns the registry in display order. +func All() []Model { return models } + +// Manifest renders the registry as the flat, bash-parseable text install.sh +// consumes: one model per line, `filenamesha256prefetch`. It is the +// single serialization of the registry for non-Go consumers. localmodel/ +// manifest.txt is this output committed to the repo (regenerated by +// `make manifest`); TestManifestUpToDate fails if the two drift. +func Manifest() string { + var b strings.Builder + b.WriteString("# zee local model manifest — generated from localmodel.go (make manifest); do not edit by hand\n") + b.WriteString("# filename\tsha256\tprefetch\n") + for _, m := range models { + fmt.Fprintf(&b, "%s\t%s\t%t\n", m.Filename, m.SHA256, m.PreFetch) + } + return b.String() +} + +// ByID looks up a model by its stable ID. +func ByID(id string) (Model, bool) { + for _, m := range models { + if m.ID == id { + return m, true + } + } + return Model{}, false +} + +// Default returns the model loaded at startup (110m English). +func Default() Model { m, _ := ByID(ID110mEN); return m } + +// Dir is where ggufs live, in priority order: +// - $ZEE_MODELS_DIR override (set by `make download-models` and the tests); +// - dev builds: the versioned folder next to the binary, +// /models/parakeet/, when it exists (populated by +// `make download-models`) — resolved against the executable, not the cwd, so +// `./zee` finds it from any working directory; +// - otherwise the stable per-user /models (the .app bundle and +// tar.gz installs, which have no in-repo folder). +func Dir() string { + if d := os.Getenv("ZEE_MODELS_DIR"); d != "" { + return d + } + if !config.IsAppBundle() { + if exe, err := os.Executable(); err == nil { + dev := filepath.Join(filepath.Dir(exe), "models", "parakeet", Version) + if fi, err := os.Stat(dev); err == nil && fi.IsDir() { + return dev + } + } + } + return filepath.Join(config.Dir(), "models") +} + +// Path is the on-disk location of a model's gguf (whether or not it exists). +func Path(m Model) string { return filepath.Join(Dir(), m.Filename) } + +// Present reports whether the model's gguf exists on disk at the right size. +// (A size check is cheap and catches truncated/aborted downloads; the full +// sha256 is verified at download time, not on every startup.) +func Present(m Model) bool { + fi, err := os.Stat(Path(m)) + return err == nil && fi.Size() == m.SizeBytes +} + +// stallTimeout aborts a download when no bytes arrive for this long. A wedged +// connection (lid closed mid-transfer, dropped Wi-Fi) must not block io.Copy +// forever and leave the tray menu stuck at "downloading N%" until restart. +const stallTimeout = 60 * time.Second + +// Download fetches a model to Dir() atomically: stream to a temp file, verify +// the sha256, then rename into place. progress (may be nil) is called with the +// fraction downloaded in [0,1]. A no-op if the model is already present. The +// transfer is cancelled if it stalls for stallTimeout. +func Download(m Model, progress func(fraction float64)) error { + if Present(m) { + return nil + } + dir := Dir() + if err := os.MkdirAll(dir, 0o755); err != nil { + return fmt.Errorf("create models dir: %w", err) + } + if err := checkDiskSpace(dir, m.SizeBytes); err != nil { + return err + } + + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + // Watchdog: every Read resets the timer (via progressReader.onRead); if it + // fires, no data has arrived for stallTimeout and we cancel the request. + stall := time.AfterFunc(stallTimeout, cancel) + defer stall.Stop() + + req, err := http.NewRequestWithContext(ctx, http.MethodGet, m.URL(), nil) + if err != nil { + return fmt.Errorf("download %s: %w", m.Filename, err) + } + client := &http.Client{Transport: &http.Transport{ + DialContext: (&net.Dialer{Timeout: 15 * time.Second}).DialContext, + TLSHandshakeTimeout: 15 * time.Second, + ResponseHeaderTimeout: 30 * time.Second, + }} + resp, err := client.Do(req) + if err != nil { + return fmt.Errorf("download %s: %w", m.Filename, err) + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusOK { + return fmt.Errorf("download %s: HTTP %d", m.Filename, resp.StatusCode) + } + + tmp, err := os.CreateTemp(dir, "."+m.Filename+".*.part") + if err != nil { + return fmt.Errorf("create temp: %w", err) + } + tmpPath := tmp.Name() + defer os.Remove(tmpPath) // no-op after a successful rename + + h := sha256.New() + pr := &progressReader{r: resp.Body, total: m.SizeBytes, cb: progress, onRead: func() { stall.Reset(stallTimeout) }} + if _, err := io.Copy(io.MultiWriter(tmp, h), pr); err != nil { + tmp.Close() + if ctx.Err() != nil { + return fmt.Errorf("download %s: stalled — no data for %s", m.Filename, stallTimeout) + } + return fmt.Errorf("download %s: %w", m.Filename, err) + } + if err := tmp.Close(); err != nil { + return fmt.Errorf("close temp: %w", err) + } + + if got := hex.EncodeToString(h.Sum(nil)); got != m.SHA256 { + return fmt.Errorf("checksum mismatch for %s (got %s, want %s)", m.Filename, got, m.SHA256) + } + + if err := os.Rename(tmpPath, Path(m)); err != nil { + return fmt.Errorf("install %s: %w", m.Filename, err) + } + return nil +} + +// progressReader reports download progress at most ~10x/sec via cb, and calls +// onRead on every non-empty read so the caller can reset a stall watchdog. +type progressReader struct { + r io.Reader + total int64 + read int64 + cb func(float64) + onRead func() + lastAt time.Time +} + +func (p *progressReader) Read(b []byte) (int, error) { + n, err := p.r.Read(b) + if n > 0 && p.onRead != nil { + p.onRead() + } + p.read += int64(n) + if p.cb != nil && p.total > 0 { + now := time.Now() + if err != nil || now.Sub(p.lastAt) > 100*time.Millisecond { + p.lastAt = now + frac := float64(p.read) / float64(p.total) + if frac > 1 { + frac = 1 + } + p.cb(frac) + } + } + return n, err +} diff --git a/localmodel/manifest.txt b/localmodel/manifest.txt new file mode 100644 index 0000000..a115abd --- /dev/null +++ b/localmodel/manifest.txt @@ -0,0 +1,5 @@ +# zee local model manifest — generated from localmodel.go (make manifest); do not edit by hand +# filename sha256 prefetch +tdt_ctc-110m-f16.gguf 7f9a6376edde6a74592ace48b2ebdc27a1ac972d0be9dfcc29e668d99381faf1 true +tdt-0.6b-v3-q4_k.gguf 993d73feb4206dadda865ab25bd64b50c48dc4d013c3bf6126a721f28b1d5ee8 true +tdt-0.6b-v2-f16.gguf f8df7f5dc7b9ceb5cd0637a81194aab5d93022ace555ce81c8969c7a694b8f3d false diff --git a/localmodel/manifest_test.go b/localmodel/manifest_test.go new file mode 100644 index 0000000..9bb4674 --- /dev/null +++ b/localmodel/manifest_test.go @@ -0,0 +1,21 @@ +package localmodel + +import ( + "os" + "testing" +) + +// TestManifestUpToDate fails if localmodel/manifest.txt has drifted from the +// registry. The committed file must be the exact output of Manifest() — +// regenerate it with `make manifest`. This keeps the bash-readable manifest +// install.sh consumes in lockstep with localmodel.go (the source of truth). +func TestManifestUpToDate(t *testing.T) { + want := Manifest() + got, err := os.ReadFile("manifest.txt") + if err != nil { + t.Fatalf("read manifest.txt: %v (regenerate with `make manifest`)", err) + } + if string(got) != want { + t.Fatal("localmodel/manifest.txt is stale — regenerate with `make manifest`") + } +} diff --git a/log/log.go b/log/log.go index b6d165c..76b7737 100644 --- a/log/log.go +++ b/log/log.go @@ -12,14 +12,14 @@ import ( ) var ( - diagLog zerolog.Logger - diagFile *os.File - transcribeFile *os.File - logMu sync.Mutex - logReady atomic.Bool - transcribeOn bool - pid int - dir string + diagLog zerolog.Logger + diagFile *os.File + transcribeFile *os.File + logMu sync.Mutex + logReady atomic.Bool + transcribeOn bool + pid int + dir string ) type Metrics struct { @@ -32,8 +32,7 @@ type Metrics struct { TLSTimeMs float64 TTFBMs float64 TotalTimeMs float64 - MemoryAllocMB float64 - MemoryPeakMB float64 + ProcessRSSMB float64 InferenceMs float64 } @@ -197,8 +196,7 @@ func TranscriptionMetrics(m Metrics, mode, format, provider string, connReused b Float64("tls_ms", m.TLSTimeMs). Float64("ttfb_ms", m.TTFBMs). Float64("total_ms", m.TotalTimeMs). - Float64("mem_mb", m.MemoryAllocMB). - Float64("peak_mb", m.MemoryPeakMB) + Float64("rss_mb", m.ProcessRSSMB) if m.InferenceMs > 0 { ev = ev.Float64("inference_ms", m.InferenceMs) } @@ -235,6 +233,7 @@ type StreamMetricsData struct { RecvMessages int RecvFinal int CommitEvents int + ProcessRSSMB float64 } func StreamMetrics(m StreamMetricsData) { @@ -252,6 +251,7 @@ func StreamMetrics(m StreamMetricsData) { Int("recv_messages", m.RecvMessages). Int("recv_final", m.RecvFinal). Int("commit_events", m.CommitEvents). + Float64("rss_mb", m.ProcessRSSMB). Msg("stream_transcription") } diff --git a/login/login_darwin.go b/login/login_darwin.go index 10af335..bfdf651 100644 --- a/login/login_darwin.go +++ b/login/login_darwin.go @@ -9,12 +9,13 @@ import ( "os/exec" "path/filepath" "strings" + + "zee/config" ) const ( plistNameApp = "com.zee.app.plist" // installed /Applications/Zee.app plistNameDev = "com.zee.app.dev.plist" // local dev build - bundleSig = ".app/Contents/MacOS/" ) func xmlEscape(s string) string { @@ -23,20 +24,11 @@ func xmlEscape(s string) string { return b.String() } -// isRunningFromApp reports whether this binary is the installed Zee.app bundle -// rather than a local dev build. The login item (plist filename, launchd Label, -// and target binary) is keyed off this so a dev build never clobbers — or gets -// clobbered by — the installed app's entry. -func isRunningFromApp() bool { - exe, err := os.Executable() - if err != nil { - return false - } - return strings.Contains(exe, bundleSig) -} - +// plistName keys the login item (plist filename, launchd Label, target binary) +// off config.IsAppBundle so a dev build never clobbers — or gets clobbered by — +// the installed app's entry. func plistName() string { - if isRunningFromApp() { + if config.IsAppBundle() { return plistNameApp } return plistNameDev diff --git a/main.go b/main.go index a68f7b4..6ab3df3 100644 --- a/main.go +++ b/main.go @@ -18,9 +18,8 @@ import ( "zee/alert" "zee/audio" - "zee/config" - "zee/beep" "zee/clipboard" + "zee/config" "zee/doctor" "zee/encoder" "zee/hotkey" @@ -62,6 +61,17 @@ var ( lastRec *savedRecording ) +func trayModelState(s transcriber.ModelStatus) tray.ModelState { + switch { + case s.Ready: + return tray.ModelReady + case s.Downloadable: + return tray.ModelNeedsDownload + default: + return tray.ModelUnavailable + } +} + func modelSupportsStream(tr transcriber.Transcriber) bool { id := tr.GetModel() for _, m := range tr.Models() { @@ -88,9 +98,21 @@ type recordingConfig struct { var configMu sync.Mutex +// captureMu guards the live captureDevice, plus selectedDevice and +// preferredDevice, which the device-monitor goroutine and the tray device +// callback hot-swap on connect/disconnect while recordSessions reads the capture +// device for each new recording. +var captureMu sync.Mutex + var trayRecordChan = make(chan struct{}, 1) var isRecording atomic.Bool +// isTranscribing is true while a recording has stopped but its transcription is +// still running. isRecording stays true across this phase too (so a re-press is +// blocked); isTranscribing distinguishes "stop the live recording" from "denied, +// transcription in progress" for the hotkey feedback. +var isTranscribing atomic.Bool + var ( stopMu sync.Mutex stopCh chan struct{} // closed to stop the active recording @@ -107,14 +129,15 @@ func resetStop() <-chan struct{} { return ch } -// requestStop stops the active recording (safe to call from any goroutine, multiple times). +// requestStop stops the active recording (safe to call from any goroutine, +// multiple times). The Once/channel are touched under stopMu so this can't race +// with resetStop resetting them for the next session; close() is non-blocking +// and never re-enters stopMu, so holding it here is safe. func requestStop() { stopMu.Lock() - once := &stopOnce - ch := stopCh - stopMu.Unlock() - if ch != nil { - once.Do(func() { close(ch) }) + defer stopMu.Unlock() + if stopCh != nil { + stopOnce.Do(func() { close(stopCh) }) } } @@ -170,7 +193,9 @@ func run() { logPathFlag := flag.String("logpath", "", "log directory path (default: OS-specific location, use ./ for current dir)") testFlag := flag.Bool("test", false, "Test mode (headless, stdin-driven)") hintsFlag := flag.String("hints", "", "Vocabulary hints for transcription (comma-separated)") - transcribeFlag := flag.String("transcribe", "", "Transcribe an audio file and exit") + transcribeFlag := flag.String("transcribe", "", "Transcribe audio file(s) and exit; extra files may follow as positional args (one transcript printed per line)") + providerFlag := flag.String("provider", "", "Transcription provider (e.g. parakeet, groq); overrides saved config") + modelFlag := flag.String("model", "", "Model ID for the selected provider; overrides saved config") flag.Parse() // Resolve log directory early @@ -223,7 +248,9 @@ func run() { cfg := config.Get() flagSet := map[string]bool{} flag.Visit(func(f *flag.Flag) { flagSet[f.Name] = true }) - if !flagSet["lang"] && cfg.Language != "" { + if !flagSet["lang"] { + // Apply the saved language even when empty — "" is Auto-detect, a real + // choice, not "unset". A never-configured config yields "en" via defaults. *langFlag = cfg.Language } if !flagSet["device"] && cfg.Device != "" { @@ -245,25 +272,34 @@ func run() { fatal("Unknown format %q (use mp3@16, mp3@64, or flac)", *formatFlag) } + // CLI -provider/-model override the saved provider/model (also lets the + // integration test pick a specific local model). + if flagSet["provider"] { + cfg.Provider = *providerFlag + } + if flagSet["model"] { + cfg.Model = *modelFlag + } + // Restore saved provider/model or fall back to auto-detection if cfg.Provider != "" { - for _, p := range transcriber.Providers() { - if p.Name == cfg.Provider { - if key := os.Getenv(p.EnvKey); key != "" { - activeTranscriber = p.NewFn(key) - if cfg.Model != "" { - activeTranscriber.SetModel(cfg.Model) - } - } - break + if p, ok := providerByName(cfg.Provider); ok && p.Available() { + activeTranscriber = p.New() + if cfg.Model != "" { + activeTranscriber.SetModel(cfg.Model) } } + // An explicit -provider that didn't resolve is a hard error (don't + // silently fall back to a different engine under the test's feet). + if activeTranscriber == nil && flagSet["provider"] { + fatal("Provider %q is not available", *providerFlag) + } } if activeTranscriber == nil { var initErr error activeTranscriber, initErr = transcriber.New() if initErr != nil { - fatal("No API key set.\n\nSet GROQ_API_KEY, OPENAI_API_KEY, or DEEPGRAM_API_KEY.") + fatal("%v", initErr) } } streamEnabled = modelSupportsStream(activeTranscriber) @@ -307,7 +343,9 @@ func run() { } if *transcribeFlag != "" { - runTranscribeFile(*transcribeFlag) + // First file is the flag value; any remaining positionals are extra + // files transcribed in the same process (the model loads once). + runTranscribeFiles(append([]string{*transcribeFlag}, flag.Args()...)) return } @@ -360,7 +398,12 @@ func run() { tray.OnCopyLast(clip.CopyLast) tray.OnRecord( - func() { select { case trayRecordChan <- struct{}{}: default: } }, + func() { + select { + case trayRecordChan <- struct{}{}: + default: + } + }, func() { requestStop() }, ) // preferredDevice remembers the user's choice so we can auto-reconnect @@ -374,8 +417,10 @@ func run() { for i := range devices { names[i] = devices[i].Name } - tray.SetDevices(names, preferredDevice, func(name string) { + tray.SetDevices(names, preferredDevice, func(name string) { + captureMu.Lock() preferredDevice = name + captureMu.Unlock() config.Update(func(s *config.Settings) { s.Device = name }) if name == "" { applyDeviceSwitch(ctx, captureConfig, &captureDevice, &selectedDevice, nil) @@ -389,59 +434,113 @@ func run() { var trayModels []tray.Model modelIndex := map[string]transcriber.ModelInfo{} for _, p := range transcriber.Providers() { - key := os.Getenv(p.EnvKey) for _, m := range p.Models { + st := p.Status(m.ID) trayModels = append(trayModels, tray.Model{ Provider: p.Name, ProviderLabel: p.Label, ModelID: m.ID, Label: m.Label, - HasKey: key != "", + State: trayModelState(st), + Detail: st.Detail, Active: activeTranscriber.Name() == p.Name && activeTranscriber.GetModel() == m.ID, }) modelIndex[p.Name+":"+m.ID] = m } } - tray.SetLanguages(transcriber.AllLanguages()) + tray.SetLanguages(activeTranscriber.SupportedLanguages()) - tray.SetModels(trayModels, func(provider, model string) { + // applySwitch makes (provider, model) active, reusing the current instance + // when the provider is unchanged so we don't reload a local model twice. On a + // provider change it frees the outgoing model — Parakeet holds C/ggml memory + // (255 MB–1.4 GB) the GC can't reclaim, so dropping it without Close leaks. + // It must run only when no record/inference cycle is active (guaranteed by + // switchModel), so the freed model can't be one an in-flight session uses. + applySwitch := func(p transcriber.ProviderInfo, model string) { configMu.Lock() - defer configMu.Unlock() - - currentLang := activeTranscriber.GetLanguage() + var outgoing transcriber.Transcriber + if activeTranscriber.Name() != p.Name { + outgoing = activeTranscriber + newTr := p.New() + newTr.SetLanguage(activeTranscriber.GetLanguage()) + activeTranscriber = newTr + } + activeTranscriber.SetModel(model) // local: blocks here during gguf load + streamEnabled = modelIndex[p.Name+":"+model].Stream + if !streamEnabled { + activeFormat = *formatFlag + } + langs := activeTranscriber.SupportedLanguages() + local := transcriber.IsLocal(activeTranscriber) + configMu.Unlock() - var newTr transcriber.Transcriber - for _, p := range transcriber.Providers() { - if p.Name == provider { - if key := os.Getenv(p.EnvKey); key != "" { - newTr = p.NewFn(key) - } - break - } + // Only Parakeet has a provider-level Close (frees the gguf); cloud + // providers don't implement it and are skipped. + if c, ok := outgoing.(interface{ Close() }); ok { + c.Close() } - if newTr == nil { + + config.Update(func(s *config.Settings) { s.Provider = p.Name; s.Model = model }) + tray.SetLanguages(langs) + tray.SetHintsEnabled(!local) + tray.SetActiveModel(p.Name, model) + } + + // switchModel applies the swap immediately when idle, or defers it to the end + // of the current record/inference cycle when one is active — so neither the + // gguf reload nor the Close of the outgoing model can free a ctx an in-flight + // session is using. The tray menu and the download-complete goroutine both + // funnel through here. + switchModel := func(p transcriber.ProviderInfo, model string) { + if isRecording.Load() { + pendingMu.Lock() + pendingSwitch = func() { applySwitch(p, model) } + pendingMu.Unlock() return } - newTr.SetLanguage(currentLang) - newTr.SetModel(model) + applySwitch(p, model) + } - activeTranscriber = newTr - streamEnabled = modelIndex[provider+":"+model].Stream - if !streamEnabled { - activeFormat = *formatFlag + tray.SetModels(trayModels, func(provider, model string) { + p, ok := providerByName(provider) + if !ok { + return + } + st := p.Status(model) + switch { + case st.Ready: + switchModel(p, model) + case st.Downloadable: + // Async: a model download takes minutes; show progress in the menu. + go func() { + tray.UpdateModelState(provider, model, tray.ModelDownloading, "0%") + err := p.Download(model, func(f float64) { + tray.UpdateModelState(provider, model, tray.ModelDownloading, fmt.Sprintf("%.0f%%", f*100)) + }) + if err != nil { + log.Errorf("model download: %v", err) + tray.SetError("Download failed: " + err.Error()) + tray.UpdateModelState(provider, model, tray.ModelNeedsDownload, st.Detail) + return + } + tray.UpdateModelState(provider, model, tray.ModelReady, "") + switchModel(p, model) + }() } - - config.Update(func(s *config.Settings) { s.Provider = provider; s.Model = model }) - tray.SetLanguages(newTr.SupportedLanguages()) }) - tray.SetLanguage(*langFlag, func(code string) { + tray.SetLanguage(*langFlag, func(code string, persist bool) { configMu.Lock() activeTranscriber.SetLanguage(code) configMu.Unlock() - config.Update(func(s *config.Settings) { s.Language = code }) + // Only a real user choice persists. A model-constraint fallback applies + // to the transcriber but must not overwrite the saved preference. + if persist { + config.Update(func(s *config.Settings) { s.Language = code }) + } }) + tray.SetHintsEnabled(!transcriber.IsLocal(activeTranscriber)) tray.SetLogin(login.Enabled()) tray.SetVersion(version) tray.OnSaveAudio(saveLastRecording) @@ -490,18 +589,24 @@ func run() { continue } last = names + // Snapshot the device state under captureMu, then release before the + // switch calls (which re-lock it). preferredDevice is also written by + // the tray callback on another thread, so both are read under the lock. + captureMu.Lock() selName := "" if selectedDevice != nil { selName = selectedDevice.Name } + pref := preferredDevice + captureMu.Unlock() if selName != "" && !slices.Contains(names, selName) { log.Info("device_disconnected: " + selName) applyDeviceSwitch(ctx, captureConfig, &captureDevice, &selectedDevice, nil) selName = "" - } else if selName == "" && preferredDevice != "" && slices.Contains(names, preferredDevice) { - log.Info("device_reconnected: " + preferredDevice) - switchDeviceByName(ctx, captureConfig, &captureDevice, &selectedDevice, preferredDevice) - selName = preferredDevice + } else if selName == "" && pref != "" && slices.Contains(names, pref) { + log.Info("device_reconnected: " + pref) + switchDeviceByName(ctx, captureConfig, &captureDevice, &selectedDevice, pref) + selName = pref } tray.RefreshDevices(names, selName) } @@ -535,7 +640,7 @@ func run() { gracefulShutdown() }() - go beep.Init() + go audio.InitBeep() hk := hotkey.New() if err := hk.Register(); err != nil { @@ -544,33 +649,96 @@ func run() { } defer hk.Unregister() - logRecordDevice := func() { - log.Info("recording_device: " + captureDevice.DeviceName()) - } - sessions := make(chan recSession, 1) go listenHotkey(hk, longPressDuration(), sessions) go func() { for range trayRecordChan { - sessions <- recSession{Stop: resetStop(), SilenceClose: &atomic.Bool{}} + tryStartSession(sessions) } }() + recordSessions(func() audio.CaptureDevice { + captureMu.Lock() + defer captureMu.Unlock() + return captureDevice + }, sessions) +} + +// afterRecordCycle, when non-nil, is called by recordSessions at the end of each +// record+transcribe cycle. Test-only hook (lets the harness know a cycle ended). +var afterRecordCycle func() + +// pendingSwitch holds a model switch deferred because it was requested during a +// record/inference cycle; applyPendingSwitch runs it at cycle end, when no +// session is in flight (see switchModel). pendingMu guards it across the tray and +// download goroutines and the record loop. +var ( + pendingMu sync.Mutex + pendingSwitch func() +) + +func applyPendingSwitch() { + pendingMu.Lock() + fn := pendingSwitch + pendingSwitch = nil + pendingMu.Unlock() + if fn != nil { + fn() + } +} + +// tryStartSession enqueues a fresh recording session unless a cycle is already +// active (recording OR still transcribing) — in which case it denies audibly, +// the same guard the hotkey uses. Returns the SilenceClose handle when it +// started a session, nil when it denied. The hotkey and the tray "Start +// Recording" button both funnel through here, so neither can queue an +// unattended recording that fires the instant inference ends. +func tryStartSession(sessions chan<- recSession) *atomic.Bool { + if isRecording.Load() { + go audio.PlayDenied() + return nil + } + sc := &atomic.Bool{} + sessions <- recSession{Stop: resetStop(), SilenceClose: sc} + return sc +} + +// recordSessions is the core record→transcribe loop, shared by the live app and +// tests. isRecording stays true for the WHOLE cycle — recording AND inference — +// so listenHotkey blocks a new recording while a transcription is still running +// (handleRecording returns a `done` channel that closes when inference ends). +// +// getCapture is called fresh each iteration (not captured once) so a device +// hot-swap — e.g. the mic being unplugged and the monitor switching to system +// default — is picked up on the next recording instead of reusing a stale, +// now-invalid device. +func recordSessions(getCapture func() audio.CaptureDevice, sessions <-chan recSession) { for sess := range sessions { + capture := getCapture() log.Info("recording_start") - logRecordDevice() + log.Info("recording_device: " + capture.DeviceName()) isRecording.Store(true) tray.SetRecording(true) - go beep.PlayStart() + go audio.PlayStart() - _, err := handleRecording(captureDevice, sess) - isRecording.Store(false) - tray.SetRecording(false) + done, err := handleRecording(capture, sess) if err != nil { log.Errorf("recording error: %v", err) tray.SetError(err.Error()) } + if done != nil { + isTranscribing.Store(true) + tray.SetTranscribing(true) // blue status dot while inference runs + <-done // hold isRecording too — blocks re-record + isTranscribing.Store(false) + } + isRecording.Store(false) + tray.SetRecording(false) + applyPendingSwitch() // apply any model switch deferred during this cycle + if afterRecordCycle != nil { + afterRecordCycle() + } } } @@ -599,11 +767,18 @@ func listenHotkey(hk hotkey.Hotkey, longPress time.Duration, sessions chan<- rec <-hk.Keydown() if isRecording.Load() { <-hk.Keyup() - requestStop() + if isTranscribing.Load() { + go audio.PlayDenied() // ignored: transcription still in progress + } else { + requestStop() + } + continue + } + sc := tryStartSession(sessions) + if sc == nil { + <-hk.Keyup() // denied (a cycle began between the guard above and here) continue } - sc := &atomic.Bool{} - sessions <- recSession{Stop: resetStop(), SilenceClose: sc} timer := time.NewTimer(longPress) select { case <-timer.C: @@ -611,7 +786,12 @@ func listenHotkey(hk hotkey.Hotkey, longPress time.Duration, sessions chan<- rec requestStop() st = idle case <-hk.Keyup(): - if !timer.Stop() { select { case <-timer.C: default: } } + if !timer.Stop() { + select { + case <-timer.C: + default: + } + } sc.Store(true) st = toggleRecording } @@ -645,6 +825,8 @@ func applyDeviceSwitch(ctx audio.Context, captureConfig audio.CaptureConfig, cap name = newDevice.Name } log.Info("device_switch: " + name) + captureMu.Lock() + defer captureMu.Unlock() (*captureDevice).Close() newCapture, err := ctx.NewCapture(newDevice, captureConfig) if err != nil { @@ -768,8 +950,7 @@ func finishTranscription(sess transcriber.Session, clipCh chan string, updatesDo TLSTimeMs: bs.TLSTimeMs, TTFBMs: bs.TTFBMs, TotalTimeMs: bs.TotalTimeMs, - MemoryAllocMB: result.MemoryAllocMB, - MemoryPeakMB: result.MemoryPeakMB, + ProcessRSSMB: result.ProcessRSSMB, InferenceMs: bs.InferenceMs, } transcriptionsMu.Lock() @@ -792,6 +973,7 @@ func finishTranscription(sess transcriber.Session, clipCh chan string, updatesDo RecvMessages: ss.RecvMessages, RecvFinal: ss.RecvFinal, CommitEvents: ss.CommitEvents, + ProcessRSSMB: result.ProcessRSSMB, }) } @@ -856,14 +1038,44 @@ func saveLastRecording() { alert.Info("Saved to " + dir) } -func runTranscribeFile(audioFile string) { +// directTranscriber transcribes encoded audio bytes in one call. Every provider +// implements it — cloud providers POST the bytes; Parakeet decodes WAV → PCM and +// runs a local batch inference — so the file path has a single shape. +type directTranscriber interface { + Transcribe(audio []byte, format, lang, hints string) (*transcriber.Result, error) +} + +// providerByName finds a registered provider by its Name. +func providerByName(name string) (transcriber.ProviderInfo, bool) { + for _, p := range transcriber.Providers() { + if p.Name == name { + return p, true + } + } + return transcriber.ProviderInfo{}, false +} + +// runTranscribeFiles transcribes one or more files with the already-loaded +// engine — the model is loaded once at startup and reused across files — and +// prints one transcript per line, in input order. +func runTranscribeFiles(files []string) { + for _, f := range files { + text, err := transcribeFile(f) + if err != nil { + fatal("%s: %v", f, err) + } + fmt.Println(text) + } +} + +func transcribeFile(audioFile string) (string, error) { data, err := os.ReadFile(audioFile) if err != nil { - fatal("Error reading file: %v", err) + return "", err } ext := filepath.Ext(audioFile) - format := "mp3" + var format string switch ext { case ".flac": format = "flac" @@ -872,24 +1084,18 @@ func runTranscribeFile(audioFile string) { case ".mp3": format = "mp3" default: - fatal("Unsupported audio format: %s", ext) - } - - type directTranscriber interface { - Transcribe(audio []byte, format, lang, hints string) (*transcriber.Result, error) + return "", fmt.Errorf("unsupported audio format %q", ext) } dt, ok := activeTranscriber.(directTranscriber) if !ok { - fatal("Provider %q does not support direct file transcription", activeTranscriber.Name()) + return "", fmt.Errorf("provider %q cannot transcribe files", activeTranscriber.Name()) } - result, err := dt.Transcribe(data, format, activeTranscriber.GetLanguage(), config.GetHints()) if err != nil { - fatal("Transcription error: %v", err) + return "", err } - - fmt.Println(result.Text) + return result.Text, nil } func runBenchmark(wavFile string, runs int) { diff --git a/main_test.go b/main_test.go index 9016188..c092b44 100644 --- a/main_test.go +++ b/main_test.go @@ -1,11 +1,156 @@ package main import ( + "sync" + "sync/atomic" "testing" "time" + + "zee/audio" + "zee/encoder" "zee/hotkey" + "zee/transcriber" ) +// TestRecordSessionsBlocksDuringInference verifies the guard's missing half: +// isRecording must stay true for the WHOLE record+transcribe cycle, not just +// while recording. It drives the real recordSessions loop with a fake capture +// and a fake transcriber whose "inference" takes 800ms, then checks isRecording +// is still set mid-inference. Combined with TestListenHotkey_StopsTrayRecording +// (a press while isRecording is true starts no new session), this proves a +// hotkey press during inference is blocked. +func TestRecordSessionsBlocksDuringInference(t *testing.T) { + audio.DisableBeep() + isRecording.Store(false) + + fake := transcriber.NewFake("hello", nil) + fake.SetDelay(800 * time.Millisecond) // simulated inference window + activeTranscriber = fake + + ctx, err := audio.NewFakeContext("test/data/short.wav", false) + if err != nil { + t.Fatalf("fake audio context: %v", err) + } + capture, err := ctx.NewCapture(nil, audio.CaptureConfig{ + SampleRate: encoder.SampleRate, Channels: encoder.Channels, + }) + if err != nil { + t.Fatalf("fake capture: %v", err) + } + defer capture.Close() + + var cycles int32 + afterRecordCycle = func() { atomic.AddInt32(&cycles, 1) } + defer func() { afterRecordCycle = nil }() + + sessions := make(chan recSession, 1) + loopDone := make(chan struct{}) + go func() { recordSessions(func() audio.CaptureDevice { return capture }, sessions); close(loopDone) }() + + // Start a recording, let it capture briefly, then stop it (as a keyup would) + // so the 800ms "inference" begins. + sessions <- recSession{Stop: resetStop(), SilenceClose: &atomic.Bool{}} + time.Sleep(150 * time.Millisecond) + requestStop() + + // Mid-inference: the guard must still be engaged, and isTranscribing (which + // drives the blue icon + denied beep) must be set. + time.Sleep(250 * time.Millisecond) + if !isRecording.Load() { + t.Fatal("isRecording cleared during inference — a re-record would NOT be blocked") + } + if !isTranscribing.Load() { + t.Fatal("isTranscribing not set during inference — no blue icon / denied beep") + } + + // Wait out the cycle, confirm both flags were released after inference. + deadline := time.Now().Add(3 * time.Second) + for atomic.LoadInt32(&cycles) < 1 { + if time.Now().After(deadline) { + t.Fatal("record cycle never completed") + } + time.Sleep(10 * time.Millisecond) + } + if isRecording.Load() || isTranscribing.Load() { + t.Fatal("isRecording/isTranscribing still set after inference completed") + } + + // Terminate the loop cleanly so it doesn't leak into other tests. + close(sessions) + <-loopDone +} + +// TestRecordSessionsPicksUpDeviceSwitch reproduces the "Start auto-releases" +// bug: when the selected mic is unplugged mid-run, the device monitor swaps the +// capture device to system default, but recordSessions kept using a frozen +// reference to the old (now-gone) device — so every recording aborted with +// "device reinit failed: No device". recordSessions must read the current +// device (via getCapture) each iteration. With the old by-value behavior the +// post-swap session still uses device A and this test fails. +func TestRecordSessionsPicksUpDeviceSwitch(t *testing.T) { + audio.DisableBeep() + isRecording.Store(false) + + activeTranscriber = transcriber.NewFake("hello", nil) + + ctx, err := audio.NewFakeContext("test/data/short.wav", false) + if err != nil { + t.Fatalf("fake audio context: %v", err) + } + capA, _ := ctx.NewNamedCapture("A") + capB, _ := ctx.NewNamedCapture("B") + fa := capA.(*audio.FakeCapture) + fb := capB.(*audio.FakeCapture) + + var mu sync.Mutex + current := capA + getCapture := func() audio.CaptureDevice { + mu.Lock() + defer mu.Unlock() + return current + } + + var cycles int32 + afterRecordCycle = func() { atomic.AddInt32(&cycles, 1) } + defer func() { afterRecordCycle = nil }() + + sessions := make(chan recSession, 1) + loopDone := make(chan struct{}) + go func() { recordSessions(getCapture, sessions); close(loopDone) }() + + runSession := func() { + want := atomic.LoadInt32(&cycles) + 1 + sessions <- recSession{Stop: resetStop(), SilenceClose: &atomic.Bool{}} + time.Sleep(120 * time.Millisecond) + requestStop() + deadline := time.Now().Add(3 * time.Second) + for atomic.LoadInt32(&cycles) < want { + if time.Now().After(deadline) { + t.Fatalf("record cycle %d never completed", want) + } + time.Sleep(5 * time.Millisecond) + } + } + + runSession() // session 1 → device A + + mu.Lock() // mic unplugged: monitor swapped to device B + current = capB + mu.Unlock() + + runSession() // session 2 → must be device B, not the stale A + + close(sessions) + <-loopDone + + if fb.Starts.Load() == 0 { + t.Fatal("device switch ignored: the session after the swap did not use the new device") + } + if fa.Starts.Load() != 1 { + t.Fatalf("stale device A was used %d times, want 1 (only the pre-swap session)", fa.Starts.Load()) + } +} + func TestListenHotkey_TrayStopNoStaleSignal(t *testing.T) { hk := hotkey.NewFake() sessions := make(chan recSession, 3) @@ -78,3 +223,60 @@ func TestListenHotkey_StopsTrayRecording(t *testing.T) { case <-time.After(100 * time.Millisecond): } } + +// TestTryStartSessionDeniesDuringCycle pins the shared record-request guard: a +// request while a cycle is active (recording OR still transcribing — both keep +// isRecording set) is denied and enqueues nothing. Both the hotkey and the tray +// "Start Recording" button route through tryStartSession, so this closes the bug +// where a tray click during inference queued an unattended recording. +func TestTryStartSessionDeniesDuringCycle(t *testing.T) { + isRecording.Store(true) + defer isRecording.Store(false) + + sessions := make(chan recSession, 1) + if sc := tryStartSession(sessions); sc != nil { + t.Fatal("tryStartSession should deny (return nil) while a cycle is active") + } + select { + case <-sessions: + t.Fatal("no session should be enqueued while a cycle is active") + default: + } +} + +// TestTryStartSessionEnqueuesWhenIdle verifies the happy path: idle → a session +// is enqueued and its SilenceClose handle returned. +func TestTryStartSessionEnqueuesWhenIdle(t *testing.T) { + isRecording.Store(false) + + sessions := make(chan recSession, 1) + sc := tryStartSession(sessions) + if sc == nil { + t.Fatal("tryStartSession should return a handle when idle") + } + select { + case <-sessions: + default: + t.Fatal("tryStartSession should enqueue a session when idle") + } +} + +// TestApplyPendingSwitchRunsOnceAtCycleEnd pins the deferred-switch mechanism a +// model switch requested mid-cycle rides on: recordSessions calls +// applyPendingSwitch at cycle end, which runs the deferred swap exactly once +// (when no session is in flight, so the freed model can't be one in use). +func TestApplyPendingSwitchRunsOnceAtCycleEnd(t *testing.T) { + var ran int32 + pendingMu.Lock() + pendingSwitch = func() { atomic.AddInt32(&ran, 1) } + pendingMu.Unlock() + + applyPendingSwitch() + if atomic.LoadInt32(&ran) != 1 { + t.Fatalf("deferred switch ran %d times, want 1", ran) + } + applyPendingSwitch() // nothing pending now — must be a no-op + if atomic.LoadInt32(&ran) != 1 { + t.Fatalf("deferred switch ran again after being cleared (%d)", ran) + } +} diff --git a/packaging/mkdmg.sh b/packaging/mkdmg.sh index b850287..6b0dce3 100755 --- a/packaging/mkdmg.sh +++ b/packaging/mkdmg.sh @@ -27,6 +27,16 @@ else echo "warning: $SCRIPT_DIR/Zee.icns not found, DMG will have no app icon" >&2 fi +# Ship the license + third-party attribution with the app (MIT requires the +# notices in distributions; the models are CC-BY-4.0 and need attribution). +for doc in LICENSE THIRD_PARTY_LICENSES; do + if [ -f "$SCRIPT_DIR/../$doc" ]; then + cp "$SCRIPT_DIR/../$doc" "$APP/Contents/Resources/$doc" + else + echo "warning: $doc not found, not shipped in DMG" >&2 + fi +done + codesign --force --sign - --identifier com.zee.app "$APP" ln -s /Applications "$STAGING/Applications" diff --git a/recording.go b/recording.go index aa97242..d4d5fec 100644 --- a/recording.go +++ b/recording.go @@ -7,7 +7,6 @@ import ( "time" "zee/audio" - "zee/beep" "zee/log" "zee/transcriber" "zee/tray" @@ -84,16 +83,16 @@ func (r *recordingSession) monitorSilence() { case SilenceWarn: log.Info("no_voice_warning") tray.SetWarning(true) - beep.PlayError() + audio.PlayError() case SilenceWarnClear: tray.SetWarning(false) case SilenceRepeat: log.Info("silence_during_warning") - beep.PlayError() + audio.PlayError() case SilenceAutoClose: log.Info("silence_auto_close") tray.SetRecording(false) - go beep.PlayEnd() + go audio.PlayEnd() r.autoClosed.Store(true) r.close() return @@ -110,7 +109,7 @@ func (r *recordingSession) awaitStop() { } log.Info("recording_stop") tray.SetRecording(false) - go beep.PlayEnd() + go audio.PlayEnd() if r.stream { time.Sleep(recordTail) } diff --git a/test/data/en.wav b/test/data/en.wav new file mode 100644 index 0000000..77502a5 Binary files /dev/null and b/test/data/en.wav differ diff --git a/test/data/fr.wav b/test/data/fr.wav new file mode 100644 index 0000000..736bbc2 Binary files /dev/null and b/test/data/fr.wav differ diff --git a/test/data/ru.wav b/test/data/ru.wav new file mode 100644 index 0000000..bb456e1 Binary files /dev/null and b/test/data/ru.wav differ diff --git a/test/integration_test.go b/test/integration_test.go index b6cd8df..92bfd68 100644 --- a/test/integration_test.go +++ b/test/integration_test.go @@ -3,16 +3,20 @@ package test_test import ( + "bytes" "encoding/binary" "fmt" "os" "os/exec" "path/filepath" + "runtime" "strings" "testing" "time" + "unicode" "zee/clipboard" + "zee/localmodel" ) var testBinary string @@ -288,3 +292,160 @@ func TestClipboardRestoreOnError(t *testing.T) { t.Errorf("clipboard not restored on error: got %q, want %q", strings.TrimSpace(clip), sentinel) } } + +// --- Local model (Parakeet) tests --- +// +// End-to-end check that the on-device models transcribe their own languages: +// the default English 110m, and the multilingual v3 across English, French and +// Russian (auto-detect). Audio fixtures are committed WAVs synthesized with +// macOS `say` so the expected transcript is known. Each case self-skips when +// its gguf isn't downloaded (run `make download-models`), so the suite stays +// green on machines/CI without the local models. + +// localModelsDir is the dev gguf location relative to the test working dir +// (/test): models live at /models/parakeet/. +func localModelsDir(t *testing.T) string { + t.Helper() + dir, err := filepath.Abs(filepath.Join("..", "models", "parakeet", localmodel.Version)) + if err != nil { + t.Fatalf("resolve models dir: %v", err) + } + return dir +} + +// transcribeFiles runs `zee -transcribe` over one or more files in a SINGLE +// process (the model is loaded once and reused across files) and returns the +// per-file transcripts — one per stdout line, in input order. Skips if the +// model's gguf isn't present. +func transcribeFiles(t *testing.T, modelID, lang string, files ...string) []string { + t.Helper() + m, ok := localmodel.ByID(modelID) + if !ok { + t.Fatalf("unknown local model %q", modelID) + } + modelsDir := localModelsDir(t) + if fi, err := os.Stat(filepath.Join(modelsDir, m.Filename)); err != nil || fi.Size() != m.SizeBytes { + t.Skipf("model %q not downloaded (run: make download-models)", modelID) + } + + // Flags must precede the positional files: Go's flag parser stops at the + // first non-flag arg, so -transcribe and the files come last. + args := append([]string{"-logpath", t.TempDir(), "-provider", "parakeet", + "-model", modelID, "-lang", lang, "-transcribe"}, files...) + cmd := exec.Command(testBinary, args...) + cmd.Env = append(os.Environ(), "ZEE_MODELS_DIR="+modelsDir) + var stdout, stderr bytes.Buffer + cmd.Stdout, cmd.Stderr = &stdout, &stderr + + start := time.Now() + if err := cmd.Run(); err != nil { + t.Fatalf("transcribe %v failed: %v\nstderr: %s", files, err, stderr.String()) + } + t.Logf("%s: %d file(s) in %s", modelID, len(files), time.Since(start).Round(time.Millisecond)) + + lines := strings.Split(strings.TrimRight(stdout.String(), "\n"), "\n") + if len(lines) != len(files) { + t.Fatalf("expected %d transcripts, got %d:\n%s", len(files), len(lines), stdout.String()) + } + return lines +} + +// assertTranscript checks the transcript matches the expected text by normalized +// token overlap (TTS+ASR drifts slightly, so we don't require an exact match). +func assertTranscript(t *testing.T, got, want string) { + t.Helper() + g, w := normalizeText(got), normalizeText(want) + if o := tokenOverlap(g, w); o < 0.8 { + t.Errorf("token overlap %.2f below 0.8\n got: %q\n want: %q", o, g, w) + } +} + +// normalizeText lowercases and collapses everything but letters/digits to single +// spaces, so punctuation and casing don't fail the comparison. +func normalizeText(s string) string { + var b strings.Builder + for _, r := range strings.ToLower(s) { + if unicode.IsLetter(r) || unicode.IsNumber(r) { + b.WriteRune(r) + } else { + b.WriteRune(' ') + } + } + return strings.Join(strings.Fields(b.String()), " ") +} + +// tokenOverlap is the fraction of want's tokens present in got (multiset). TTS+ +// ASR can drift slightly, so we assert a high overlap rather than exact match. +func tokenOverlap(got, want string) float64 { + w := strings.Fields(want) + if len(w) == 0 { + return 0 + } + have := map[string]int{} + for _, tok := range strings.Fields(got) { + have[tok]++ + } + hits := 0 + for _, tok := range w { + if have[tok] > 0 { + have[tok]-- + hits++ + } + } + return float64(hits) / float64(len(w)) +} + +func TestLocalParakeetModels(t *testing.T) { + if runtime.GOOS != "darwin" || runtime.GOARCH != "arm64" { + t.Skip("local Parakeet transcription is darwin/arm64 only") + } + + const wantEN = "The quick brown fox jumps." + + t.Run("english-110m", func(t *testing.T) { + got := transcribeFiles(t, "parakeet-110m-en", "en", "data/en.wav") + assertTranscript(t, got[0], wantEN) + }) + + // One process, one v3 load, three languages (auto-detect). + t.Run("v3-multilingual", func(t *testing.T) { + got := transcribeFiles(t, "parakeet-v3-multi", "", + "data/en.wav", "data/fr.wav", "data/ru.wav") + assertTranscript(t, got[0], wantEN) + assertTranscript(t, got[1], "Je m'appelle Thomas Dupont.") + assertTranscript(t, got[2], "Меня зовут Милена Иванова.") + }) +} + +// TestLocalModelDiagnostics checks that a local-model transcription emits the +// same diagnostics/metrics record as the cloud path. We assert on the presence +// of stable markers (provider, and a few metric keys) rather than parsing the +// line, so the log format can evolve without breaking the test. The recording +// path (-test) is what logs metrics; -transcribe is a quiet one-shot. +func TestLocalModelDiagnostics(t *testing.T) { + if runtime.GOOS != "darwin" || runtime.GOARCH != "arm64" { + t.Skip("local Parakeet transcription is darwin/arm64 only") + } + const modelID = "parakeet-v3-multi" + m, ok := localmodel.ByID(modelID) + if !ok { + t.Fatalf("unknown local model %q", modelID) + } + modelsDir := localModelsDir(t) + if fi, err := os.Stat(filepath.Join(modelsDir, m.Filename)); err != nil || fi.Size() != m.SizeBytes { + t.Skipf("model %q not downloaded (run: make download-models)", modelID) + } + + // Flags must precede the positional WAV: Go's flag parsing stops at the + // first non-flag argument. runZeeOpts already orders them correctly. + logDir := runZeeOpts(t, cmds("KEYDOWN", "KEYUP", "WAIT", "SLEEP 500", "QUIT"), + runOpts{env: []string{"ZEE_MODELS_DIR=" + modelsDir}}, + "-provider", "parakeet", "-model", modelID, "-lang", "", "-test", "data/fr.wav") + + diag := readLog(t, logDir, "diagnostics_log.txt") + for _, marker := range []string{"transcription", "provider=parakeet", "inference_ms", "rss_mb", "audio_s"} { + if !strings.Contains(diag, marker) { + t.Errorf("diagnostics missing %q marker\n--- diagnostics ---\n%s", marker, diag) + } + } +} diff --git a/testenv.go b/testenv.go index 35f790e..92233a9 100644 --- a/testenv.go +++ b/testenv.go @@ -10,7 +10,6 @@ import ( "time" "zee/audio" - "zee/beep" "zee/clipboard" "zee/encoder" "zee/hotkey" @@ -18,7 +17,7 @@ import ( ) func runTestMode(wavPath string) { - beep.Disable() + audio.DisableBeep() if err := log.Init(); err != nil { fmt.Fprintf(os.Stderr, "Warning: could not init logging: %v\n", err) diff --git a/third_party/parakeet.cpp b/third_party/parakeet.cpp new file mode 160000 index 0000000..b8012f1 --- /dev/null +++ b/third_party/parakeet.cpp @@ -0,0 +1 @@ +Subproject commit b8012f11e5269126eddb7f4fd02f891a2ccc29b0 diff --git a/transcriber/batch_session.go b/transcriber/batch_session.go index 325c5a8..8d555ea 100644 --- a/transcriber/batch_session.go +++ b/transcriber/batch_session.go @@ -133,7 +133,7 @@ func (bs *batchSession) Close() (SessionResult, error) { }, Metrics: bs.formatMetrics(rawSize, encodedSize, compressionPct, audioDuration, result), } - sr.captureMemStats() + sr.captureRSS() return sr, nil } diff --git a/transcriber/fake.go b/transcriber/fake.go index ec69b08..5a8ed1b 100644 --- a/transcriber/fake.go +++ b/transcriber/fake.go @@ -12,12 +12,21 @@ type FakeTranscriber struct { err error lang string stream bool + delay time.Duration // simulated inference time (Close blocks for this long) } func NewFake(text string, err error) *FakeTranscriber { - return &FakeTranscriber{text: text, err: err, stream: os.Getenv("ZEE_FAKE_STREAM") == "1"} + f := &FakeTranscriber{text: text, err: err, stream: os.Getenv("ZEE_FAKE_STREAM") == "1"} + if d := os.Getenv("ZEE_FAKE_DELAY"); d != "" { + f.delay, _ = time.ParseDuration(d) + } + return f } +// SetDelay makes Close block for d, simulating inference latency (for tests that +// need a window where transcription is in progress). +func (f *FakeTranscriber) SetDelay(d time.Duration) { f.delay = d } + func (f *FakeTranscriber) Name() string { return "fake" } func (f *FakeTranscriber) SupportedLanguages() []Language { return nil } func (f *FakeTranscriber) SetLanguage(lang string) { f.lang = lang } @@ -47,13 +56,14 @@ func (f *FakeTranscriber) NewSession(_ context.Context, cfg SessionConfig) (Sess } else { close(updates) } - return &fakeSession{text: f.text, err: f.err, updates: updates}, nil + return &fakeSession{text: f.text, err: f.err, updates: updates, delay: f.delay}, nil } type fakeSession struct { text string err error updates chan string + delay time.Duration } func (s *fakeSession) Feed([]byte) {} @@ -61,6 +71,9 @@ func (s *fakeSession) Feed([]byte) {} func (s *fakeSession) Updates() <-chan string { return s.updates } func (s *fakeSession) Close() (SessionResult, error) { + if s.delay > 0 { + time.Sleep(s.delay) + } if s.err != nil { return SessionResult{}, fmt.Errorf("fake transcriber error: %w", s.err) } @@ -73,6 +86,6 @@ func (s *fakeSession) Close() (SessionResult, error) { }, Metrics: []string{"total: 10ms (fake)"}, } - r.captureMemStats() + r.captureRSS() return r, nil } diff --git a/transcriber/parakeet.go b/transcriber/parakeet.go new file mode 100644 index 0000000..4a6f268 --- /dev/null +++ b/transcriber/parakeet.go @@ -0,0 +1,292 @@ +package transcriber + +import ( + "context" + "encoding/binary" + "fmt" + "os" + "strings" + "sync" + "time" + + "zee/audio" + "zee/encoder" + "zee/internal/parakeet" + "zee/localmodel" +) + +// saveLastAudio mirrors the ZEE_SAVE_LAST_AUDIO tray feature: only then does the +// local path retain a WAV copy of the recording. Cloud paths keep the encoded +// bytes they sent regardless; local decode has none, so the copy is pure +// overhead on the common path. +var saveLastAudio = os.Getenv("ZEE_SAVE_LAST_AUDIO") != "" + +// Parakeet is the offline, on-device provider. It wraps one loaded GGUF model +// (decision #2: a single shared ctx; push-to-talk is serial) and swaps the +// gguf to change models (decision #3: 110m loaded at startup, switching freezes +// briefly). The C-API has no usable language parameter for these models, so +// language is model-driven (decision #1): English-only for 110m/v2, and the +// multilingual v3 auto-detects (it is not prompt-conditioned, so a target +// language cannot be forced). +type Parakeet struct { + mu sync.Mutex + modelID string + ctx *parakeet.Ctx + loadErr error + lang string +} + +// parakeetAvailable reports whether local transcription is compiled in and the +// default model is on disk — the gate for the no-key fallback. +func parakeetAvailable() bool { + return parakeet.Available() && localmodel.Present(localmodel.Default()) +} + +// parakeetProvider is the registry entry: availability is "default model on +// disk", status is per-gguf presence, and missing models are downloadable. +func parakeetProvider() ProviderInfo { + return ProviderInfo{ + Name: "parakeet", + Label: "Local (Parakeet)", + Models: ParakeetModels(), + Available: parakeetAvailable, + New: func() Transcriber { return NewParakeet() }, + Status: func(id string) ModelStatus { + m, ok := localmodel.ByID(id) + if !ok || !parakeet.Available() { + return ModelStatus{} // unknown, or not compiled in → Unavailable + } + if localmodel.Present(m) { + return ModelStatus{Ready: true} + } + return ModelStatus{Downloadable: true, Detail: m.HumanSize()} + }, + Download: func(id string, progress func(float64)) error { + m, ok := localmodel.ByID(id) + if !ok { + return fmt.Errorf("unknown local model %q", id) + } + return localmodel.Download(m, progress) + }, + } +} + +// ParakeetModels lists the on-device models as ModelInfo (for the tray) without +// needing a loaded provider instance. +func ParakeetModels() []ModelInfo { + out := make([]ModelInfo, 0, len(localmodel.All())) + for _, m := range localmodel.All() { + out = append(out, ModelInfo{ID: m.ID, Label: m.Label, Stream: false, Languages: parakeetLanguages(m)}) + } + return out +} + +// NewParakeet builds the provider and eagerly loads the default model (110m, +// ~55 ms) so the first recording is instant. A missing/failed model is deferred +// to NewSession as an error. +func NewParakeet() *Parakeet { + p := &Parakeet{modelID: localmodel.ID110mEN, lang: "en"} + p.mu.Lock() + p.load() + p.mu.Unlock() + return p +} + +// load (mu held) swaps the loaded ctx to p.modelID, freeing the previous one. +func (p *Parakeet) load() { + m, ok := localmodel.ByID(p.modelID) + if !ok { + p.loadErr = fmt.Errorf("unknown local model %q", p.modelID) + return + } + if !localmodel.Present(m) { + p.loadErr = fmt.Errorf("model %q not downloaded", m.Label) + return + } + if p.ctx != nil { + p.ctx.Close() + p.ctx = nil + } + ctx, err := parakeet.New(localmodel.Path(m)) + p.ctx, p.loadErr = ctx, err +} + +// IsLocal reports whether tr is the on-device provider. Local decode has no +// hint biasing, no streaming, and no audio encoding, so the UI greys those out. +func IsLocal(tr Transcriber) bool { + _, ok := tr.(*Parakeet) + return ok +} + +func (p *Parakeet) Name() string { return "parakeet" } + +func (p *Parakeet) Models() []ModelInfo { return ParakeetModels() } + +func parakeetLanguages(m localmodel.Model) []Language { + if m.Multilingual { + // v3 auto-detects and is not prompt-conditioned, so a target language + // cannot be forced — Auto-detect is the only meaningful option. + return []Language{{Code: "", Label: "Auto-detect"}} + } + return []Language{{Code: "en", Label: "English"}} +} + +func (p *Parakeet) SupportedLanguages() []Language { + if m, ok := localmodel.ByID(p.GetModel()); ok { + return parakeetLanguages(m) + } + return []Language{{Code: "en", Label: "English"}} +} + +func (p *Parakeet) SetLanguage(lang string) { p.mu.Lock(); p.lang = lang; p.mu.Unlock() } +func (p *Parakeet) GetLanguage() string { p.mu.Lock(); defer p.mu.Unlock(); return p.lang } + +func (p *Parakeet) GetModel() string { p.mu.Lock(); defer p.mu.Unlock(); return p.modelID } + +// SetModel swaps the active model, loading its gguf eagerly. The caller (tray) +// is intentionally blocked during the load (decision #3); a load failure is +// surfaced at NewSession. +func (p *Parakeet) SetModel(id string) { + p.mu.Lock() + defer p.mu.Unlock() + if id == p.modelID && p.ctx != nil { + return + } + p.modelID = id + p.load() +} + +// Transcribe decodes a WAV file to PCM and runs one batch inference, satisfying +// the same direct-transcribe interface as the cloud providers so the file path +// (-transcribe) has a single shape. Local decode accepts WAV only and ignores +// hints (greedy decode has no biasing). +func (p *Parakeet) Transcribe(audioData []byte, format, lang, _ string) (*Result, error) { + if format != "wav" { + return nil, fmt.Errorf("local transcription supports WAV files only (got %s)", format) + } + pcm, err := audio.WAVToPCM(audioData) + if err != nil { + return nil, fmt.Errorf("cannot read WAV: %w", err) + } + sess, err := p.NewSession(context.Background(), SessionConfig{Language: lang}) + if err != nil { + return nil, err + } + sess.Feed(pcm) + sr, err := sess.Close() + if err != nil { + return nil, err + } + res := &Result{Text: sr.Text} + if sr.Batch != nil { + res.InferenceMs = sr.Batch.InferenceMs + res.Duration = sr.Batch.AudioLengthS + } + return res, nil +} + +func (p *Parakeet) NewSession(_ context.Context, cfg SessionConfig) (Session, error) { + if cfg.Stream { + return nil, fmt.Errorf("parakeet does not support streaming") + } + p.mu.Lock() + if p.ctx == nil && p.loadErr == nil { + p.load() + } + ctx, err := p.ctx, p.loadErr + decoder := 0 + if m, ok := localmodel.ByID(p.modelID); ok { + decoder = m.Decoder + } + p.mu.Unlock() + if err != nil { + return nil, err + } + return &pcmSession{ctx: ctx, decoder: decoder, updates: make(chan string)}, nil +} + +// Close frees the loaded model. The provider is reusable afterwards (the next +// NewSession reloads lazily). +func (p *Parakeet) Close() { + p.mu.Lock() + defer p.mu.Unlock() + if p.ctx != nil { + p.ctx.Close() + p.ctx = nil + } +} + +// pcmSession buffers raw S16LE PCM during recording, then runs one batch +// inference on Close. Same Session interface as the cloud batch path, so the +// live hotkey and -transcribe share it — no encoder, no network. +type pcmSession struct { + ctx *parakeet.Ctx + decoder int + mu sync.Mutex + pcm []byte + updates chan string +} + +func (s *pcmSession) Feed(pcm []byte) { + s.mu.Lock() + s.pcm = append(s.pcm, pcm...) + s.mu.Unlock() +} + +func (s *pcmSession) Updates() <-chan string { return s.updates } + +func (s *pcmSession) Close() (SessionResult, error) { + close(s.updates) + + s.mu.Lock() + raw := s.pcm + s.mu.Unlock() + + n := len(raw) / 2 + if n == 0 { + return SessionResult{NoSpeech: true}, nil + } + + f32 := make([]float32, n) + for i := 0; i < n; i++ { + f32[i] = float32(int16(binary.LittleEndian.Uint16(raw[i*2:]))) / 32768.0 + } + + start := time.Now() + text, err := s.ctx.Transcribe(f32, s.decoder) + if err != nil { + return SessionResult{}, err + } + inferenceMs := float64(time.Since(start).Microseconds()) / 1000 + + text = strings.TrimSpace(text) + noSpeech := text == "" + audioSec := float64(n) / float64(encoder.SampleRate) + rawKB := float64(len(raw)) / 1024 + + var audioData []byte + if saveLastAudio { + audioData = audio.PCMToWAV(raw) + } + + sr := SessionResult{ + Text: text, + HasText: !noSpeech, + NoSpeech: noSpeech, + AudioData: audioData, + AudioFormat: "wav", + Batch: &BatchStats{ + AudioLengthS: audioSec, + RawSizeKB: rawKB, + InferenceMs: inferenceMs, + TotalTimeMs: inferenceMs, + }, + Metrics: []string{ + fmt.Sprintf("audio: %.1fs | %.1f KB (raw PCM, no encoding)", audioSec, rawKB), + fmt.Sprintf("inference: %.0fms (local, CPU)", inferenceMs), + fmt.Sprintf("rtfx: %.1fx", audioSec/(inferenceMs/1000)), + }, + } + sr.captureRSS() + return sr, nil +} diff --git a/transcriber/session.go b/transcriber/session.go index 4edac56..ad3b150 100644 --- a/transcriber/session.go +++ b/transcriber/session.go @@ -1,12 +1,20 @@ package transcriber -import "runtime" +import ( + "os" -func (r *SessionResult) captureMemStats() { - var m runtime.MemStats - runtime.ReadMemStats(&m) - r.MemoryAllocMB = float64(m.Alloc) / 1024 / 1024 - r.MemoryPeakMB = float64(m.TotalAlloc) / 1024 / 1024 + "github.com/shirou/gopsutil/v4/process" +) + +// captureRSS records the process resident set size (from gopsutil, not Go's +// heap) — it includes cgo/mmap memory like the loaded model, so it's the one +// memory figure that's meaningful across every provider, local or remote. +func (r *SessionResult) captureRSS() { + if p, err := process.NewProcess(int32(os.Getpid())); err == nil { + if mi, err := p.MemoryInfo(); err == nil { + r.ProcessRSSMB = float64(mi.RSS) / 1024 / 1024 + } + } } type SessionConfig struct { @@ -46,17 +54,16 @@ type StreamStats struct { } type SessionResult struct { - Text string - HasText bool - NoSpeech bool - RateLimit string // "remaining/limit" or empty - MemoryAllocMB float64 - MemoryPeakMB float64 - Batch *BatchStats // non-nil for batch sessions - Stream *StreamStats // non-nil for stream sessions - Metrics []string // pre-formatted metric lines - AudioData []byte // exact bytes sent to the model - AudioFormat string // "mp3", "flac", or "wav" + Text string + HasText bool + NoSpeech bool + RateLimit string // "remaining/limit" or empty + ProcessRSSMB float64 // resident set size (incl. cgo/mmap), all providers + Batch *BatchStats // non-nil for batch sessions + Stream *StreamStats // non-nil for stream sessions + Metrics []string // pre-formatted metric lines + AudioData []byte // exact bytes sent to the model + AudioFormat string // "mp3", "flac", or "wav" } type Session interface { diff --git a/transcriber/stream_session.go b/transcriber/stream_session.go index 9acb4a1..e7ee92f 100644 --- a/transcriber/stream_session.go +++ b/transcriber/stream_session.go @@ -233,7 +233,7 @@ func (s *streamSession) Close() (SessionResult, error) { AudioS: audioDuration, }, } - sr.captureMemStats() + sr.captureRSS() return sr, sessionErr } diff --git a/transcriber/transcriber.go b/transcriber/transcriber.go index 44c0a0e..2821863 100644 --- a/transcriber/transcriber.go +++ b/transcriber/transcriber.go @@ -11,15 +11,15 @@ import ( ) type NetworkMetrics struct { - DNS time.Duration - ConnWait time.Duration - TCP time.Duration - TLS time.Duration - ReqHeaders time.Duration - ReqBody time.Duration - TTFB time.Duration - Download time.Duration - Total time.Duration + DNS time.Duration + ConnWait time.Duration + TCP time.Duration + TLS time.Duration + ReqHeaders time.Duration + ReqBody time.Duration + TTFB time.Duration + Download time.Duration + Total time.Duration ConnReused bool TLSProtocol string } @@ -122,6 +122,18 @@ func langsFromCodes(codes []string) []Language { return langs } +// AllLanguages returns every known language (Auto-detect first, then the rest +// sorted alphabetically). The tray uses this as the fixed universe of language +// menu items; SetLanguages then shows only the active model's subset. +func AllLanguages() []Language { + codes := make([]string, 0, len(langLabels)) + for c := range langLabels { + codes = append(codes, c) + } + sort.Strings(codes) + return langsFromCodes(codes) +} + type baseTranscriber struct { client *TracedClient apiURL string @@ -142,16 +154,6 @@ func (b *baseTranscriber) GetLanguage() string { return b.lang } -// AllLanguages returns every known language, sorted alphabetically. -func AllLanguages() []Language { - codes := make([]string, 0, len(langLabels)) - for c := range langLabels { - codes = append(codes, c) - } - sort.Strings(codes) - return langsFromCodes(codes) -} - func (b *baseTranscriber) Models() []ModelInfo { return nil } func (b *baseTranscriber) SetModel(m string) { b.model = m } func (b *baseTranscriber) GetModel() string { return b.model } @@ -165,24 +167,58 @@ func modelLanguages(models []ModelInfo, current string) []Language { return nil } +// ModelStatus describes whether one model is usable now, and if not whether the +// user can make it usable. Cloud providers are binary (Ready when the key is +// present, else not Downloadable → Unavailable); the local provider adds the +// downloadable middle ground. +type ModelStatus struct { + Ready bool + Downloadable bool // missing but the user can fetch it (local only) + Detail string // human-readable size when downloadable & missing +} + +// ProviderInfo is a uniform descriptor for every backend — cloud or local. No +// provider is special-cased: New() and the tray treat them all through these +// fields. Download is nil for providers that have nothing to fetch (cloud). type ProviderInfo struct { - Name string - Label string - EnvKey string - Models []ModelInfo - NewFn func(string) Transcriber + Name string + Label string + Models []ModelInfo + Available func() bool // at least one model usable right now + New func() Transcriber // keyless: closes over the key / model dir + Status func(modelID string) ModelStatus + Download func(modelID string, progress func(fraction float64)) error +} + +// cloudProvider builds a key-gated ProviderInfo. Availability is "key present"; +// every model shares that status and nothing is downloadable. +func cloudProvider(name, label, envKey string, models []ModelInfo, mk func(string) Transcriber) ProviderInfo { + hasKey := func() bool { return os.Getenv(envKey) != "" } + return ProviderInfo{ + Name: name, + Label: label, + Models: models, + Available: hasKey, + New: func() Transcriber { return mk(os.Getenv(envKey)) }, + Status: func(string) ModelStatus { return ModelStatus{Ready: hasKey()} }, + } } func Providers() []ProviderInfo { return []ProviderInfo{ - {"deepgram", "Deepgram", "DEEPGRAM_API_KEY", DeepgramModels, func(k string) Transcriber { return NewDeepgram(k) }}, - {"openai", "OpenAI", "OPENAI_API_KEY", OpenAIModels, func(k string) Transcriber { return NewOpenAI(k) }}, - {"groq", "Groq", "GROQ_API_KEY", GroqModels, func(k string) Transcriber { return NewGroq(k) }}, - {"mistral", "Mistral", "MISTRAL_API_KEY", MistralModels, func(k string) Transcriber { return NewMistral(k) }}, - {"elevenlabs", "ElevenLabs", "ELEVENLABS_API_KEY", ElevenLabsModels, func(k string) Transcriber { return NewElevenLabs(k) }}, + // Local is first so it's the default on a fresh machine even when cloud + // keys are set; cloud is opt-in via the tray (the choice persists). + parakeetProvider(), + cloudProvider("deepgram", "Deepgram", "DEEPGRAM_API_KEY", DeepgramModels, func(k string) Transcriber { return NewDeepgram(k) }), + cloudProvider("openai", "OpenAI", "OPENAI_API_KEY", OpenAIModels, func(k string) Transcriber { return NewOpenAI(k) }), + cloudProvider("groq", "Groq", "GROQ_API_KEY", GroqModels, func(k string) Transcriber { return NewGroq(k) }), + cloudProvider("mistral", "Mistral", "MISTRAL_API_KEY", MistralModels, func(k string) Transcriber { return NewMistral(k) }), + cloudProvider("elevenlabs", "ElevenLabs", "ELEVENLABS_API_KEY", ElevenLabsModels, func(k string) Transcriber { return NewElevenLabs(k) }), } } +// New returns the default transcriber: the local model when available (even if +// cloud keys are set), else the first cloud provider with a key. func New() (Transcriber, error) { if fakeText, ok := os.LookupEnv("ZEE_FAKE_TEXT"); ok { var fakeErr error @@ -193,10 +229,10 @@ func New() (Transcriber, error) { } for _, p := range Providers() { - if key := os.Getenv(p.EnvKey); key != "" { - return p.NewFn(key), nil + if p.Available() { + return p.New(), nil } } - return nil, fmt.Errorf("set DEEPGRAM_API_KEY, OPENAI_API_KEY, GROQ_API_KEY, MISTRAL_API_KEY, or ELEVENLABS_API_KEY environment variable") + return nil, fmt.Errorf("set DEEPGRAM_API_KEY, OPENAI_API_KEY, GROQ_API_KEY, MISTRAL_API_KEY, or ELEVENLABS_API_KEY (or install on Apple Silicon to run offline)") } diff --git a/transcriber/transcriber_test.go b/transcriber/transcriber_test.go index 1c96325..2187b5f 100644 --- a/transcriber/transcriber_test.go +++ b/transcriber/transcriber_test.go @@ -3,11 +3,46 @@ package transcriber import ( "encoding/binary" "net/http" + "os" + "reflect" + "strings" "testing" "time" "zee/encoder" ) +// TestParakeetModelsMethodMatchesFunc pins the delegation: the (*Parakeet).Models +// method and the package ParakeetModels function must return identical lists so +// the tray and a loaded provider can never disagree on the model set. +func TestParakeetModelsMethodMatchesFunc(t *testing.T) { + p := &Parakeet{} + if got, want := p.Models(), ParakeetModels(); !reflect.DeepEqual(got, want) { + t.Errorf("Parakeet.Models() = %v, want %v (must delegate to ParakeetModels)", got, want) + } +} + +// TestNewErrorListsEveryProvider guards the message main.go now surfaces verbatim: +// with no engine available, New()'s error must name every cloud key plus the +// offline hint, so a fresh user is told all their options. Deterministic where no +// provider is available (CI: parakeet not compiled in, keys empty); skipped when a +// local model or key makes New() succeed on a dev machine. +func TestNewErrorListsEveryProvider(t *testing.T) { + os.Unsetenv("ZEE_FAKE_TEXT") + for _, k := range []string{"DEEPGRAM_API_KEY", "OPENAI_API_KEY", "GROQ_API_KEY", "MISTRAL_API_KEY", "ELEVENLABS_API_KEY"} { + t.Setenv(k, "") + } + + _, err := New() + if err == nil { + t.Skip("a provider is available on this machine; cannot exercise the no-engine path") + } + for _, want := range []string{"DEEPGRAM_API_KEY", "OPENAI_API_KEY", "GROQ_API_KEY", "MISTRAL_API_KEY", "ELEVENLABS_API_KEY", "offline"} { + if !strings.Contains(err.Error(), want) { + t.Errorf("New() error %q missing %q", err.Error(), want) + } + } +} + func TestNetworkMetricsSum(t *testing.T) { m := &NetworkMetrics{ ConnWait: 10 * time.Millisecond, diff --git a/tray/icon_busy.png b/tray/icon_busy.png new file mode 100644 index 0000000..d0fc056 Binary files /dev/null and b/tray/icon_busy.png differ diff --git a/tray/icon_idle.png b/tray/icon_idle.png new file mode 100644 index 0000000..0392266 Binary files /dev/null and b/tray/icon_idle.png differ diff --git a/tray/icon_rec.png b/tray/icon_rec.png new file mode 100644 index 0000000..04200a0 Binary files /dev/null and b/tray/icon_rec.png differ diff --git a/tray/icon_warn.png b/tray/icon_warn.png new file mode 100644 index 0000000..d59a723 Binary files /dev/null and b/tray/icon_warn.png differ diff --git a/tray/icons.go b/tray/icons.go index 2ab3b8b..14bef5f 100644 --- a/tray/icons.go +++ b/tray/icons.go @@ -14,4 +14,7 @@ var ( //go:embed icon_warn.png iconWarnHi []byte + + //go:embed icon_busy.png + iconBusyHi []byte ) diff --git a/tray/tray.go b/tray/tray.go index cacf79f..1cc4d4c 100644 --- a/tray/tray.go +++ b/tray/tray.go @@ -7,12 +7,23 @@ import ( "zee/transcriber" ) +// ModelState drives how a model entry renders in the menu. +type ModelState int + +const ( + ModelReady ModelState = iota // selectable now + ModelNeedsDownload // missing, user can fetch it (local) + ModelDownloading // fetch in progress (shows %) + ModelUnavailable // can't be used (e.g. cloud, no key) +) + type Model struct { - Provider string // e.g. "groq", "openai", "deepgram" + Provider string // e.g. "groq", "openai", "parakeet" ProviderLabel string // e.g. "Groq" ModelID string // e.g. "whisper-large-v3-turbo" Label string // model display name - HasKey bool + State ModelState + Detail string // size when NeedsDownload, percent when Downloading Active bool } @@ -24,10 +35,15 @@ var ( recordFn func() stopFn func() + // trayMu guards all mutable tray state below (recording/warning, the device + // list, the model list, the language fields, the hints toggle). It is held + // only around field reads/writes — never across a systray update or a + // callback, both of which re-enter these accessors and would deadlock. + trayMu sync.Mutex + recording bool warning bool - deviceMu sync.Mutex deviceNames []string deviceSel string deviceCb func(string) @@ -38,33 +54,35 @@ var ( loginOn bool loginCb func(bool) error - modelMu sync.Mutex - models []Model - modelCb func(provider, model string) + models []Model + modelCb func(provider, model string) isBTFn func(string) bool - langCode string // current language code ("" = auto-detect) - langCb func(string) + langCode string // effective language shown for the active model ("" = auto-detect) + langIntent string // user's persisted choice; survives models that can't offer it + langCb func(code string, persist bool) appVersion string - checkUpdateCb func() - saveAudioCb func() - editHintsCb func() + checkUpdateCb func() + saveAudioCb func() + editHintsCb func() ) var languages []transcriber.Language // set via SetLanguages -func OnCopyLast(fn func()) { copyLastFn = fn } -func OnRecord(start, stop func()) { recordFn = start; stopFn = stop } -func SetAutoPaste(on bool) { autoPasteOn = on } -func OnAutoPaste(fn func(bool)) { autoPasteCb = fn } -func SetLogin(on bool) { loginOn = on } -func OnLogin(fn func(bool) error) { loginCb = fn } +func OnCopyLast(fn func()) { copyLastFn = fn } +func OnRecord(start, stop func()) { recordFn = start; stopFn = stop } +func SetAutoPaste(on bool) { autoPasteOn = on } +func OnAutoPaste(fn func(bool)) { autoPasteCb = fn } +func SetLogin(on bool) { loginOn = on } +func OnLogin(fn func(bool) error) { loginCb = fn } func SetRecording(rec bool) { + trayMu.Lock() recording = rec warning = false + trayMu.Unlock() updateRecordingIcon(rec) if rec { disableDevices() @@ -76,13 +94,22 @@ func SetRecording(rec bool) { } func SetWarning(on bool) { + trayMu.Lock() if !recording { + trayMu.Unlock() return } warning = on + trayMu.Unlock() updateWarningIcon(on) } +// SetTranscribing shows the "transcription in progress" icon (a blue status +// dot). The icon returns to idle on the next SetRecording(false). +func SetTranscribing(on bool) { + updateTranscribingIcon(on) +} + func SetError(msg string) { updateTooltip("zee – " + msg) go func() { @@ -96,47 +123,139 @@ func Quit() { } func SetDevices(names []string, selected string, onSwitch func(name string)) { - deviceMu.Lock() + trayMu.Lock() deviceNames = names deviceSel = selected if onSwitch != nil { deviceCb = onSwitch } - deviceMu.Unlock() + trayMu.Unlock() } func SetModels(m []Model, onSwitch func(provider, model string)) { - modelMu.Lock() + trayMu.Lock() models = m modelCb = onSwitch - modelMu.Unlock() + trayMu.Unlock() +} + +// UpdateModelState re-renders a single model entry (used while a local model +// downloads, and when it becomes ready). Safe to call from any goroutine. +func UpdateModelState(provider, modelID string, state ModelState, detail string) { + trayMu.Lock() + idx := -1 + for i := range models { + if models[i].Provider == provider && models[i].ModelID == modelID { + models[i].State = state + models[i].Detail = detail + idx = i + break + } + } + trayMu.Unlock() + if idx >= 0 { + updateModelItem(idx) + } +} + +// SetActiveModel marks one model active (checked) and the rest inactive, +// re-rendering only the entries that changed. Called by the app after a +// successful model switch. +func SetActiveModel(provider, modelID string) { + trayMu.Lock() + var changed []int + for i := range models { + want := models[i].Provider == provider && models[i].ModelID == modelID + if models[i].Active != want { + models[i].Active = want + changed = append(changed, i) + } + } + trayMu.Unlock() + for _, i := range changed { + updateModelItem(i) + } + updateStatus() +} + +// modelTitle is the menu label for a model given its state. +func modelTitle(m Model) string { + switch m.State { + case ModelNeedsDownload: + if m.Detail != "" { + return m.Label + " — download " + m.Detail + } + return m.Label + " — download" + case ModelDownloading: + if m.Detail != "" { + return m.Label + " — downloading " + m.Detail + } + return m.Label + " — downloading…" + default: + return m.Label + } } func SetLastRecording(dur time.Duration, totalMs float64) { updateCopyLastTitle(fmt.Sprintf("Copy Last Recorded Text (%.1fs | %dms)", dur.Seconds(), int(totalMs))) } +// hintsEnabled gates the "Edit Hints…" item: local providers ignore hints +// (greedy decode has no biasing), so the item is greyed out when local is active. +var hintsEnabled = true + +// SetHintsEnabled greys out / restores the "Edit Hints…" menu item. Safe to +// call before Init (the state is applied when the menu is built). +func SetHintsEnabled(on bool) { + trayMu.Lock() + hintsEnabled = on + trayMu.Unlock() + setHintsEnabled(on) +} + func SetVersion(v string) { appVersion = v } func OnCheckUpdate(fn func()) { checkUpdateCb = fn } -func OnSaveAudio(fn func()) { saveAudioCb = fn } -func OnEditHints(fn func()) { editHintsCb = fn } +func OnSaveAudio(fn func()) { saveAudioCb = fn } +func OnEditHints(fn func()) { editHintsCb = fn } -func SetLanguage(code string, onSwitch func(string)) { +func SetLanguage(code string, onSwitch func(code string, persist bool)) { + trayMu.Lock() langCode = code + langIntent = code langCb = onSwitch + trayMu.Unlock() } func SetLanguages(langs []transcriber.Language) { + trayMu.Lock() languages = langs + trayMu.Unlock() refreshLanguageMenu() } +// effectiveLang picks the language to actually use for a model that offers +// `langs`, given the user's intended choice. Intent wins when the model can +// offer it; otherwise it falls back to the model's first language (Auto-detect +// for multilingual models, English for English-only ones). The intent itself is +// never mutated here, so switching back to a capable model restores it. +func effectiveLang(intent string, langs []transcriber.Language) string { + for _, l := range langs { + if l.Code == intent { + return intent + } + } + if len(langs) > 0 { + return langs[0].Code + } + return "" +} + func SetBTCheck(fn func(string) bool) { isBTFn = fn } func statusText() string { - modelMu.Lock() + trayMu.Lock() var provider, model string for _, m := range models { if m.Active { @@ -145,7 +264,6 @@ func statusText() string { break } } - modelMu.Unlock() lang := "Auto" if langCode != "" { lang = langCode @@ -154,6 +272,7 @@ func statusText() string { if appVersion != "" && appVersion != "dev" { ver = " · " + appVersion } + trayMu.Unlock() if provider == "" { return "𝘻𝘦𝘦" } diff --git a/tray/tray_darwin.go b/tray/tray_darwin.go index 1c0e8b6..de73916 100644 --- a/tray/tray_darwin.go +++ b/tray/tray_darwin.go @@ -7,22 +7,25 @@ import ( "github.com/energye/systray" "golang.design/x/hotkey/mainthread" + + "zee/transcriber" ) var ( - mStatus *systray.MenuItem - mRecord *systray.MenuItem - mCopy *systray.MenuItem + mStatus *systray.MenuItem + mRecord *systray.MenuItem + mCopy *systray.MenuItem mDevices *systray.MenuItem mDefaultDevice *systray.MenuItem deviceItems []*systray.MenuItem deviceReady chan struct{} - mSettings *systray.MenuItem - mAutoPaste *systray.MenuItem - mLogin *systray.MenuItem - mBackend *systray.MenuItem - mLanguage *systray.MenuItem + mSettings *systray.MenuItem + mAutoPaste *systray.MenuItem + mLogin *systray.MenuItem + mEditHints *systray.MenuItem + mBackend *systray.MenuItem + mLanguage *systray.MenuItem langEntries []struct { item *systray.MenuItem code string @@ -51,7 +54,7 @@ func updateRecordingIcon(rec bool) { mRecord.SetTitle("● Stop Recording (Shift+Control+Space)") } } else { - systray.SetIcon(iconIdleHi) + systray.SetTemplateIcon(iconIdleHi, iconIdleHi) if mRecord != nil { mRecord.SetTitle("○ Start Recording (Shift+Control+Space)") } @@ -78,6 +81,14 @@ func updateWarningIcon(on bool) { } } +func updateTranscribingIcon(on bool) { + if on { + systray.SetIcon(iconBusyHi) + } else { + systray.SetTemplateIcon(iconIdleHi, iconIdleHi) + } +} + func updateTooltip(msg string) { systray.SetTooltip(msg) } @@ -86,17 +97,17 @@ func addDeviceItem(parent *systray.MenuItem, idx int, name string, checked bool) label := deviceDisplayName(name) item := parent.AddSubMenuItemCheckbox(label, label, checked) item.Click(func() { - deviceMu.Lock() + trayMu.Lock() currentName := "" if idx < len(deviceNames) { currentName = deviceNames[idx] } cb := deviceCb - deviceMu.Unlock() + trayMu.Unlock() if cb != nil && currentName != "" { cb(currentName) } - deviceMu.Lock() + trayMu.Lock() if mDefaultDevice != nil { mDefaultDevice.Uncheck() } @@ -106,7 +117,7 @@ func addDeviceItem(parent *systray.MenuItem, idx int, name string, checked bool) if idx < len(deviceItems) { deviceItems[idx].Check() } - deviceMu.Unlock() + trayMu.Unlock() }) return item } @@ -117,8 +128,8 @@ func RefreshDevices(names []string, selected string) { } <-deviceReady - deviceMu.Lock() - defer deviceMu.Unlock() + trayMu.Lock() + defer trayMu.Unlock() deviceNames = names deviceSel = selected @@ -155,7 +166,7 @@ func RefreshDevices(names []string, selected string) { } func onReady() { - systray.SetIcon(iconIdleHi) + systray.SetTemplateIcon(iconIdleHi, iconIdleHi) systray.SetTooltip("zee – push to talk") mStatus = systray.AddMenuItem(statusText(), "") @@ -165,7 +176,10 @@ func onReady() { mRecord = systray.AddMenuItem("○ Start Recording (Shift+Control+Space)", "Start or stop recording") mRecord.Click(func() { - if recording { + trayMu.Lock() + rec := recording + trayMu.Unlock() + if rec { if stopFn != nil { stopFn() } @@ -222,90 +236,99 @@ func onReady() { } }) - mEditHints := mSettings.AddSubMenuItem("Edit Hints…", "Edit vocabulary hints file") + mEditHints = mSettings.AddSubMenuItem("Edit Hints…", "Edit vocabulary hints file") mEditHints.Click(func() { if editHintsCb != nil { go editHintsCb() } }) + trayMu.Lock() + he := hintsEnabled + trayMu.Unlock() + if !he { + mEditHints.Disable() + } sep := mSettings.AddSubMenuItem("─────────", "") sep.Disable() mDevices = mSettings.AddSubMenuItem("Microphone", "Select input device") - deviceMu.Lock() + trayMu.Lock() mDefaultDevice = mDevices.AddSubMenuItemCheckbox("System Default", "Use system default device", deviceSel == "") mDefaultDevice.Click(func() { - deviceMu.Lock() + trayMu.Lock() cb := deviceCb - deviceMu.Unlock() + trayMu.Unlock() if cb != nil { cb("") } - deviceMu.Lock() + trayMu.Lock() for _, it := range deviceItems { it.Uncheck() } mDefaultDevice.Check() - deviceMu.Unlock() + trayMu.Unlock() }) deviceItems = make([]*systray.MenuItem, 0, len(deviceNames)) for i, name := range deviceNames { item := addDeviceItem(mDevices, i, name, name == deviceSel) deviceItems = append(deviceItems, item) } - deviceMu.Unlock() + trayMu.Unlock() - modelMu.Lock() + trayMu.Lock() if len(models) > 0 { mBackend = mSettings.AddSubMenuItem("Model", "Select transcription model") - modelItems = make([]*systray.MenuItem, 0, len(models)) - var curProvider string - var provMenu *systray.MenuItem - for i, m := range models { - if m.Provider != curProvider { - curProvider = m.Provider - label := m.ProviderLabel - if !m.HasKey { - label += " (no API key)" - } - provMenu = mBackend.AddSubMenuItem(label, label) - if !m.HasKey { - provMenu.Disable() + modelItems = make([]*systray.MenuItem, len(models)) + // models are grouped by provider (contiguous); one submenu per provider. + for i := 0; i < len(models); { + prov := models[i].Provider + j, anyUsable := i, false + for j < len(models) && models[j].Provider == prov { + if models[j].State != ModelUnavailable { + anyUsable = true } + j++ + } + label := models[i].ProviderLabel + if !anyUsable { + label += " (no API key)" } - idx := i - item := provMenu.AddSubMenuItemCheckbox(m.Label, m.Label, m.Active) - item.Click(func() { - modelMu.Lock() - mm := models[idx] - cb := modelCb - modelMu.Unlock() - if !mm.HasKey || cb == nil { - return + provMenu := mBackend.AddSubMenuItem(label, label) + if !anyUsable { + provMenu.Disable() + } + for k := i; k < j; k++ { + idx := k + m := models[k] + item := provMenu.AddSubMenuItemCheckbox(modelTitle(m), m.Label, m.Active && m.State == ModelReady) + if m.State == ModelUnavailable || m.State == ModelDownloading { + item.Disable() } - cb(mm.Provider, mm.ModelID) - modelMu.Lock() - for j, it := range modelItems { - if j == idx { - it.Check() - models[j].Active = true - } else { - it.Uncheck() - models[j].Active = false + item.Click(func() { + trayMu.Lock() + mm := models[idx] + cb := modelCb + trayMu.Unlock() + // Ready → switch; NeedsDownload → fetch. The handler (main) + // dispatches and drives checkmarks via SetActiveModel. + if cb == nil || (mm.State != ModelReady && mm.State != ModelNeedsDownload) { + return } - } - modelMu.Unlock() - updateStatus() - }) - modelItems = append(modelItems, item) + cb(mm.Provider, mm.ModelID) + }) + modelItems[idx] = item + } + i = j } } - modelMu.Unlock() + trayMu.Unlock() + // Build a fixed item per known language (systray can't add items after + // CreateMenu). refreshLanguageMenu then shows only the active model's set. mLanguage = mSettings.AddSubMenuItem("Language", "Select transcription language") - for _, lang := range languages { + for _, lang := range transcriber.AllLanguages() { addLangEntry(lang.Code, lang.Label) } @@ -322,6 +345,8 @@ func onReady() { mQuit.Click(func() { Quit() }) systray.CreateMenu() + refreshLanguageMenu() // constrain the freshly-built menu to the active model + close(deviceReady) } @@ -332,18 +357,53 @@ func updateCopyLastTitle(title string) { } } +// updateModelItem re-renders one model entry (title, checkmark, enabled) from +// its current state. Called on download progress and on model switch. +func updateModelItem(idx int) { + trayMu.Lock() + if idx < 0 || idx >= len(modelItems) || idx >= len(models) { + trayMu.Unlock() + return + } + m := models[idx] + it := modelItems[idx] + trayMu.Unlock() + if it == nil { + return + } + it.SetTitle(modelTitle(m)) + if m.Active && m.State == ModelReady { + it.Check() + } else { + it.Uncheck() + } + if m.State == ModelReady || m.State == ModelNeedsDownload { + it.Enable() + } else { + it.Disable() + } +} func addLangEntry(code, label string) { idx := len(langEntries) - item := mLanguage.AddSubMenuItemCheckbox(label, label, code == langCode) + trayMu.Lock() + checked := code == langCode + trayMu.Unlock() + item := mLanguage.AddSubMenuItemCheckbox(label, label, checked) item.Click(func() { + // langEntries is built once in onReady and never mutated after, so it's + // safe to read here without the lock; only langCode/langIntent need it. for _, e := range langEntries { e.item.Uncheck() } langEntries[idx].item.Check() + trayMu.Lock() langCode = langEntries[idx].code - if langCb != nil { - langCb(langCode) + langIntent = langCode // a user click is a real choice — remember it + cb, code := langCb, langCode + trayMu.Unlock() + if cb != nil { + cb(code, true) } updateStatus() }) @@ -357,32 +417,36 @@ func refreshLanguageMenu() { if mLanguage == nil { return } + // Derive the effective language from the user's intent every refresh. The + // fallback (when the model can't offer the intent) is applied to the + // transcriber but never persisted, so switching back to a capable model + // restores the intent. Field access is done under the lock; the systray + // updates and the callback run outside it. + trayMu.Lock() want := make(map[string]bool, len(languages)) for _, l := range languages { want[l.Code] = true } - langValid := false + langCode = effectiveLang(langIntent, languages) + cb, code := langCb, langCode + trayMu.Unlock() + + if cb != nil { + cb(code, false) + } for _, e := range langEntries { - if e.code == "" || want[e.code] { + if want[e.code] { e.item.Show() - if e.code == langCode { - langValid = true + if e.code == code { e.item.Check() + } else { + e.item.Uncheck() } } else { e.item.Hide() e.item.Uncheck() } } - if !langValid { - langCode = "" - if len(langEntries) > 0 { - langEntries[0].item.Check() - } - if langCb != nil { - langCb("") - } - } updateStatus() } @@ -392,6 +456,17 @@ func updateStatusItem(text string) { } } +func setHintsEnabled(on bool) { + if mEditHints == nil { + return + } + if on { + mEditHints.Enable() + } else { + mEditHints.Disable() + } +} + func disableBackend() { if mBackend != nil { mBackend.Disable() diff --git a/tray/tray_other.go b/tray/tray_other.go index 2289aa0..8990eec 100644 --- a/tray/tray_other.go +++ b/tray/tray_other.go @@ -2,16 +2,19 @@ package tray -func Init() <-chan struct{} { return make(chan struct{}) } -func RefreshDevices(names []string, selected string) {} -func refreshLanguageMenu() {} -func updateRecordingIcon(bool) {} -func updateWarningIcon(bool) {} -func updateTooltip(string) {} -func updateCopyLastTitle(string) {} -func addUpdateMenuItem(string) {} -func disableDevices() {} -func enableDevices() {} -func disableBackend() {} -func enableBackend() {} -func updateStatusItem(string) {} +func Init() <-chan struct{} { return make(chan struct{}) } +func RefreshDevices(names []string, selected string) {} +func refreshLanguageMenu() {} +func updateRecordingIcon(bool) {} +func updateWarningIcon(bool) {} +func updateTranscribingIcon(bool) {} +func updateTooltip(string) {} +func updateCopyLastTitle(string) {} +func addUpdateMenuItem(string) {} +func disableDevices() {} +func enableDevices() {} +func disableBackend() {} +func enableBackend() {} +func updateStatusItem(string) {} +func updateModelItem(int) {} +func setHintsEnabled(bool) {} diff --git a/tray/tray_test.go b/tray/tray_test.go new file mode 100644 index 0000000..bec32f8 --- /dev/null +++ b/tray/tray_test.go @@ -0,0 +1,56 @@ +package tray + +import ( + "testing" + + "zee/transcriber" +) + +// TestEffectiveLang covers the language-preference derivation: a model that +// can't offer the user's intended language falls back to its own default, but +// the intent is never mutated — so switching back to a capable model restores +// it. This is the regression guard for the bug where selecting an English-only +// model (e.g. Parakeet) permanently clobbered a saved Auto-detect / Turkish +// choice. +func TestEffectiveLang(t *testing.T) { + multilingual := []transcriber.Language{{Code: ""}, {Code: "en"}, {Code: "tr"}} + englishOnly := []transcriber.Language{{Code: "en"}} + + tests := []struct { + name string + intent string + langs []transcriber.Language + want string + }{ + {"auto-detect kept on multilingual", "", multilingual, ""}, + {"auto-detect falls back to en on english-only", "", englishOnly, "en"}, + {"turkish kept on multilingual", "tr", multilingual, "tr"}, + {"turkish falls back to en on english-only", "tr", englishOnly, "en"}, + {"english passes through", "en", englishOnly, "en"}, + {"empty language set yields auto-detect", "tr", nil, ""}, + } + + for _, tt := range tests { + if got := effectiveLang(tt.intent, tt.langs); got != tt.want { + t.Errorf("%s: effectiveLang(%q, ...) = %q, want %q", tt.name, tt.intent, got, tt.want) + } + } +} + +// TestEffectiveLangRoundTrip is the crux of the fix: forcing a fallback on an +// English-only model must leave the intent untouched so the next capable model +// gets it back. effectiveLang is pure, so we assert the round-trip at the value +// level. +func TestEffectiveLangRoundTrip(t *testing.T) { + multilingual := []transcriber.Language{{Code: ""}, {Code: "tr"}} + englishOnly := []transcriber.Language{{Code: "en"}} + + intent := "tr" + if got := effectiveLang(intent, englishOnly); got != "en" { + t.Fatalf("english-only should force en, got %q", got) + } + // intent is a plain value the caller preserves; the capable model restores it. + if got := effectiveLang(intent, multilingual); got != "tr" { + t.Fatalf("switching back should restore tr, got %q", got) + } +}