Skip to content

Add on-device custom speech models (transcribe.cpp port from Handy) - #118

Open
ilkerulusoy wants to merge 5 commits into
f:masterfrom
ilkerulusoy:local-speech-models
Open

ilkerulusoy wants to merge 5 commits into
f:masterfrom
ilkerulusoy:local-speech-models

Conversation

@ilkerulusoy

Copy link
Copy Markdown

Summary

Adds an optional Local Model speech engine to Word Tracking. Users can run
custom Whisper-family .bin/.gguf models entirely on-device, with no audio
leaving the machine. Apple Speech remains the default engine.

Ported from Handy: the same engine
(transcribe.cpp), the same custom-model discovery idea (drop a .bin/.gguf
into the models folder and it shows up), and the same language run-plan
semantics.

AI Assistance

This work was done with DeepSeek v4.1 Flash.

Engine / library

  • transcribe.cpp via its official Swift bindings (TranscribeCpp),
    consumed as the pinned prebuilt TranscribeCpp.xcframework v0.2.3
    (SHA-256 verified at build time by SwiftPM). No custom Rust/FFI bridge.
  • Backend cross-platform inference is ggml (Metal on Apple silicon,
    CPU on Intel).
  • The package is vendored under Textream/Vendor/TranscribeCpp (MIT) and
    linked only to the macOS target.

How it works

  • Settings → Guidance → Engine: Apple Speech (default) or Local Model.
    When Local is selected, the model list lives in the models folder:
    Application Support/Textream/Models (sandbox container), scanned for
    .bin/.gguf. Import… copies a file in; Show Folder / Delete included.
  • Streaming models (Nemotron, Moonshine Streaming, Parakeet) use
    transcribe.cpp's native low-latency stream API.
  • Whisper models are batch-only upstream, so their transcripts come from a
    rolling window: re-transcribe every ~1.5 s, 26 s cap, 1 s audio overlap; the
    highlight matcher re-anchors when the window rolls.
  • Language: resolved like Handy's run plan — the model's own concrete code
    is used when it matches (en-US, tr-TR, …), otherwise auto-detect when
    supported, otherwise English/first supported language.
  • The microphone tap's audio is converted to 16 kHz mono and fed to a serial
    engine queue, so the realtime tap never blocks.
  • The model is released from RAM when a reading session ends (dismiss / force
    close / failure). Pause/resume keeps it loaded.
  • Native transcribe.cpp diagnostics are routed to os_log
    (LocalSpeech category); the model is unloaded synchronously at termination
    to avoid ggml-Metal's static-destructor assertion.

Sample models

Model Files Notes
Nemotron 3.5 ASR Streaming 0.6B Q8_0 · 716 MB Streaming, 32 locales incl. Turkish — best live tracking
Whisper Large v3 Turbo Q8_0 · 845 MB Batch/rolling window, 100 languages
Moonshine Streaming Tiny Q8_0 · 48 MB Tiny streaming model, quick demo
Whisper Tiny (English) Q8_0 · 43 MB Smallest Whisper test model

Any GGML/GGUF Whisper-family model works. Handy's full catalog lives in
catalog.json
(repo/revision/filename/sha256 per model), all mirrored on the
handy-computer HF org.

Testing

  • macOS Debug + AppStore builds succeed; iOS Simulator build succeeds.
  • Engine verified with real models: progressive partials and correct final
    text for whisper-tiny.en, whisper-large-v3-turbo,
    moonshine-streaming-tiny, and nemotron-3.5-asr-streaming (streaming
    path), including rapid stop→start.
  • iOS is untouched: the new sources belong to the macOS target's synchronized
    group only, no package linkage, and the iOS binary contains no local-engine
    symbols.

Scope / limitations

  • macOS only in this PR; the vendored xcframework already ships iOS slices,
    so wiring the iOS app is a follow-up.
  • No in-app model downloader/browser yet (models are imported or downloaded
    manually from the links above).

Acknowledgments

  • Handy by cjpais — this feature is a port of
    its custom-model approach, and Handy's local speech-to-text is built on
    transcribe.cpp and ggml.
  • transcribe.cpp by handy-computer
    for cross-platform speech-to-text inference/acceleration.
  • ggml by Georgi Gerganov and contributors —
    thanks to the amazing work by Georgi Gerganov and contributors.
  • Whisper by OpenAI for the speech recognition model.

@f

f commented Sep 15, 2026

Copy link
Copy Markdown
Owner

Can you add a feature to download from Hugging Face maybe? With custom URL and preset values.

The local speech engine could only use models the user copied in by hand.
Add a bundled catalog of ten Whisper-family models and download them
directly from Hugging Face, which is the only source: there is no mirror
and no bundled copy, so a failed download is reported rather than
silently substituted.

SpeechModelCatalog.json is compiled into the app, so the model list is
complete without network access. Each entry pins an immutable repo
revision plus per-quant size and SHA-256, which makes the downloaded
bytes provable regardless of CDN routing.

ModelDownloadManager streams into `<filename>.partial` and resumes it
with a Range header. A 200 to a Range request means the server ignored
it, so the partial is truncated instead of having a whole file appended
to it; a 206 that starts at the wrong offset discards the partial. Bytes
past the catalog size are cut off rather than trusted, and the file is
only renamed into place after the SHA-256 matches. Cancel keeps the
partial so the next attempt resumes; delete discards it.

Settings keeps only the status line and the installed-model picker.
Downloading, quant selection, cancel, delete and manual import live in
the Manage Models sheet, which stays available while a transfer runs.
Four issues found auditing the download paths:

A cancel followed immediately by a restart gave two writers the same
`.partial` file handle. `Task.cancel()` only requests a stop, so the
first writer was still writing when the second one truncated the file.
The SHA-256 check caught the corruption, so no bad model was installed,
but the download failed for no reason the user could see. Cancelled tasks
are now retained as `retiring` and awaited before the restart, or before
a partial delete, touches the file.

A finished download did not become the selection, so the first model a
user downloaded still needed a separate click to use. It is now selected
on completion when nothing usable is selected.

A download that failed while the manager sheet was closed left the
settings status dot grey with no message. The status line now reports the
failure in red.

The two "not found" messages pointed at importing a model, which was the
only option before this. They now point at Manage Models.
The catalog recorded a license per model but never showed it. Three of the
ten models are NVIDIA's under CC-BY-4.0, where attribution is a condition
of use rather than a courtesy, so the credit has to be visible wherever
the model is offered.

Each model card now carries a link to the repo the weights come from, the
upstream model they were converted from, and the license, so the card
states who made the model before the user downloads it. The sheet header
credits the GGUF conversions to handy-computer and transcribe.cpp.

`base_model` is kept in the catalog for this, having previously been
filtered out when the catalog was generated.
The bundled catalog was the whole list, so a GGUF conversion published by
anyone else was unreachable. The model manager now takes a repo id and
resolves it against the Hub API, which supplies the commit sha, the file
sizes and the LFS SHA-256 for every candidate file. That is the same shape
the bundled catalog already has, so an added repo downloads through the
same resumable, checksum-verified path with the same guarantee.

The sha is pinned when the repo is added, matching the bundled entries: the
bytes downloaded later are provably the ones that were inspected.

A file is accepted on its first four bytes, not its extension. `.bin` is
ambiguous on the Hub, where `openai/whisper-tiny` ships a `pytorch_model.bin`
that is a zip archive, so extension alone would have offered a file the
engine cannot load.

File labels fall back to the full stem when a repo holds several distinct
models rather than several quants of one, which is the case for
`ggerganov/whisper.cpp`, where quant suffixes repeat across models.

Nothing is claimed about an added model's capabilities. The engine reports
those when the model loads, so the catalog does not guess.
@ilkerulusoy

Copy link
Copy Markdown
Author

I've add this feature to download models from Hugging Face.

area-15-09-2026-15-29.webm

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants