Skip to content

Live (resumable) streaming transcription#147

Open
AlexCherrypi wants to merge 16 commits into
Starmel:masterfrom
AlexCherrypi:claude/whisper-transcription-chunking-87lqdh
Open

Live (resumable) streaming transcription#147
AlexCherrypi wants to merge 16 commits into
Starmel:masterfrom
AlexCherrypi:claude/whisper-transcription-chunking-87lqdh

Conversation

@AlexCherrypi

@AlexCherrypi AlexCherrypi commented Jun 9, 2026

Copy link
Copy Markdown

What

Live, "pipelined" transcription: audio is transcribed while recording via whisper's resumable API, so when you stop only the trailing <30s window remains to decode (near-instant), at full large-model quality.

  • New StreamingWhisperEngine: own AVAudioEngine tap → 16k mono → serial whisper queue driving whisper_append_audio + whisper_full_resumable.
  • Runs in parallel with the existing WAV recorder (playback / re-transcribe stay intact); falls back to a file pass if streaming can't run.
  • Opt-in via a new "Live Streaming (Whisper)" preference (whisper engine only). Both the menubar hotkey and the in-app record button use it; re-transcribing existing recordings stays file-based.

whisper.cpp dependency — works today via my fork

This uses a resumable streaming API I added to whisper.cpp, submitted upstream as ggml-org/whisper.cpp#3869. In the meantime it works as-is: the libwhisper/whisper.cpp submodule points at my fork (AlexCherrypi/whisper.cpp, branch master), which already has the API, so this branch builds and runs right now. Once #3869 lands upstream, the submodule can simply be repointed to the canonical repo — no code changes needed.

Also included

Happy to adjust — feedback welcome.

claude and others added 15 commits June 8, 2026 21:49
Point the whisper.cpp submodule at the fork branch that adds the
resumable streaming API (whisper_append_audio / whisper_full_resumable /
whisper_resumable_reset + configurable mel normalization).

- WhisperFullParams: expose mel_norm_mode / half_life / max_drop
- Whis.swift: bind resumableReset / appendAudio / fullResumable
- StreamingWhisperEngine: AVAudioEngine tap -> append -> resumable loop,
  emitting committed segments live; finalize() flushes the trailing
  window on stop. Mel norm mode configurable (defaults to WINDOW for
  the live mic path).
Behind a new "Live Streaming (Whisper)" setting (default off, whisper
engine only). When enabled, StreamingWhisperEngine captures in parallel
to the file recorder: the WAV is still written (playback / re-transcribe
intact), and on stop the live (resumable) text is used instead of a
fresh file-based pass -> near-instant result for long recordings.

- AppPreferences: liveStreamingEnabled flag
- Settings: SettingsViewModel mirror + toggle in Output Options
- IndicatorViewModel: start/stop/cancel the streaming engine, expose
  liveText, persist() saves the WAV + inserts the streamed text;
  preload the streaming model so the lead-in is not dropped
Recording must start immediately; the model load must not drop the
lead-in (the streamed text is what gets saved). StreamingWhisperEngine
now installs the tap and starts the AVAudioEngine right away, and queues
the model load as the first item on its serial whisper queue. Captured
audio appended by the tap is processed only after the load completes, so
nothing is lost. stop() still flushes only the trailing window.
Allow starting a new recording while a previous one is still
finalizing/transcribing (live mode only; the file path still blocks
since it shares one context). The shared StreamingWhisperEngine's single
serial queue guarantees recordings are finalized — and therefore
inserted — strictly in the order they were made.

- StreamingWhisperEngine is now a shared singleton (model loaded once);
  per-recording state resets run on the serial queue, and stop() captures
  the final text inside the sync block so the next recording's reset
  can't clear it first. Removed the unused live-text callback.
- IndicatorViewModel uses the shared engine; live mode skips the busy
  block; finalize/insert run via the engine queue (ordered). Insertion
  honors the existing autoPaste/autoCopy prefs (cursor vs clipboard).
- didFinishDecoding(for:) is VM-aware: a stale session finishing in the
  background no longer hides a newer recording's indicator (overshadow);
  the stale VM is just cleaned up.
When recording #2 starts while #1 is still finalizing on the shared
engine, setting self.settings on the caller thread raced with #1's
finalize reading it via makeParams() on the whisper queue. Move the
per-recording settings assignment into the serial queue block so #1
reads its own settings before #2 overwrites them.
If the streaming engine never actually ran for a recording (no model
selected, mic/AVAudioEngine start error, or model load failure), the WAV
was saved but no text was produced or inserted -> silent data loss.

stop() now returns a FinalizeResult distinguishing a real transcription
(even an empty "no speech" one, which must NOT trigger a fallback) from
.unavailable (infrastructure failure). On .unavailable the indicator
falls back to transcriptionService.transcribeAudio(url:) so the
recording is never lost.
The streaming AVAudioEngine tap did not react to the mic being unplugged
or the input device/format changing, so audio would silently stop. Now
the engine observes AVAudioEngineConfigurationChange and rebuilds the tap
+ converter for the new input format (keeping the resumable state so the
stream continues), restarting the engine if it stopped. The observer is
removed on stop()/cancel(). Tap setup is extracted into configureCapture().
isRunning / isCancelled are touched from the caller, the audio render
thread and main (config-change handler). Guard them with an NSLock
(matching WhisperEngine's pattern) for well-defined cross-thread access.

Also verified (no change needed): stream segment timestamps are absolute
over the whole stream (t1 = seek + ..., seek is the absolute resumable
position), so showTimestamps is correct in live mode.
Fixes the two verified high-severity concurrency bugs in the live path:
- Guard every AVAudioEngine graph mutation (start/stop/tap/converter/observer)
  with a new engineLock so an overlapping start (recording B) and stop
  (recording A) can't mutate the shared engine concurrently — CoreAudio is not
  safe for concurrent start/stop/tap changes and could crash or wedge capture.
- Order A's finalize strictly before B's reset by enqueuing both under
  engineLock; the lock is released before the (long) decode, which now runs via
  async + semaphore, so B's capture is not delayed by A's finalize. Prevents a
  recording's transcription from being lost when recordings overlap.
- Roll back the tap/observer if start() throws after partial setup.
- Correct misleading comments asserting a "separate context" / guaranteed FIFO.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
FluidAudio 0.11.0 fails to compile under Xcode 26.5 (Swift 6 "sending"
data-race errors inside FluidAudio itself). Bump to 0.15.2 (within the
project's existing <1.0.0 constraint) and adapt to its API changes:
- AsrManager.initialize(models:) -> loadModels(_:)  (FluidAudioEngine,
  Settings, OnboardingView)
- transcribe(url) now requires an explicit decoder state: pass a fresh
  TdtDecoderState (correct for one-shot file transcription).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Bump the libwhisper/whisper.cpp submodule from 51f67d4 to 109efaae so the app
links the hardened resumable API (finalize guard, doc/edge-case fixes). API is
unchanged, so the app builds identically; this just folds in the cpp review fixes.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…gging

- ContentViewModel (main-window record button) now streams transcription during
  recording via the shared StreamingWhisperEngine, like the hotkey indicator,
  and falls back to a file pass if streaming can't run. Re-transcribing existing
  recordings stays file-based (no live capture to stream).
- Gate whisper realtime segment logging in the streaming path on the app's debug
  setting, so the live path is as verbose as the file path when logging is on,
  and quiet otherwise.
- Log the live transcription result (parity with the file path's result log).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The realtime segment log printed a [t0 --> t1] header even with timestamps
disabled (no_timestamps), producing a bogus constant-negative time because the
header was built from an unset timestamp token. Now, when debug logging is on,
timestamps are computed so the log shows valid times; otherwise the header is
printed only when the user enabled "show timestamps". The stored transcription
text is unchanged (its formatting still follows settings.showTimestamps).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…sh fix

- Line-buffer stdout at app launch (setvbuf _IOLBF) so log output is flushed per
  line when redirected to a file; block buffering otherwise withheld output until
  the buffer filled (and abort() on quit dropped it), making live/long logs look
  stalled or truncated even though transcription was complete.
- Bump libwhisper to bf2c1277, which downgrades the ggml-metal residency-set
  teardown GGML_ASSERT to a warning so the app no longer crashes on quit (macOS 15+).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The resumable/streaming whisper API (plus the ggml-metal quit-crash workaround)
is now merged into AlexCherrypi/whisper.cpp master, so track `master` instead of
the feature branch. The pinned commit is unchanged (bf2c1277, == master tip).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
AlexCherrypi added a commit to AlexCherrypi/OpenSuperWhisper that referenced this pull request Jun 9, 2026
Integrating the live-streaming and custom-dictionary features surfaced a
composition gap: StreamingWhisperEngine never applied the custom dictionary,
so with both enabled live dictation got neither prompt-biasing nor word
replacements (the file path via WhisperEngine/FluidAudioEngine did).

Mirror the file path, gated by settings.shouldApplyCustomDictionary:
- weave CustomDictionary.promptBoost into the live initialPrompt
- run CustomDictionary.apply on the final live text only (in cleaned(),
  never on partial segments), after Asian autocorrect

Fork-master integration only; the upstream chunking branch (PR Starmel#147) stays
free of the dictionary dependency.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Root-cause fix (app side) for the macOS 15+ quit-time abort: the whisper
contexts live in singletons (StreamingWhisperEngine.shared and the engine held
by TranscriptionService.shared), which are never deinit'd at process exit, so
whisper_free never runs — leaving the model's Metal buffers and their residency
sets registered when ggml's device static destructor tears the GPU down.

Add a graceful applicationShouldTerminate that returns .terminateLater and:
- waits for the in-flight transcription queue to drain (TranscriptionQueue.waitUntilIdle),
- frees the live engine's context on its serial whisper queue (StreamingWhisperEngine.shutdown),
- releases the file engine so its context deallocates (TranscriptionService.releaseEngine),
then replies to allow termination. Freeing the contexts runs whisper_free, which
drains the residency sets before device teardown — so OSW shuts down cleanly even
against an unpatched ggml. (libwhisper still carries the defensive ggml-metal fix.)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
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