Skip to content

feat: background HTTP pre-transcription while recording#252

Open
JaPossert wants to merge 2 commits into
zachlatta:mainfrom
JaPossert:feat/prefetch-transcription
Open

feat: background HTTP pre-transcription while recording#252
JaPossert wants to merge 2 commits into
zachlatta:mainfrom
JaPossert:feat/prefetch-transcription

Conversation

@JaPossert

@JaPossert JaPossert commented Jul 3, 2026

Copy link
Copy Markdown

Summary

  • Adds a new "Begin transcribing while recording" setting that submits audio to the transcription provider in 28-second background chunks while the user speaks
  • By the time the user presses stop, the majority of the recording is already processed — only the trailing segment (< 28 s) needs transcribing on stop, dramatically reducing perceived latency for long dictations
  • Works with any standard HTTP transcription endpoint (Groq, local Whisper, faster-whisper, etc.) — no WebSocket or special server required, unlike the existing realtime mode
  • Each completed chunk's transcript is appended to a durable .txt file in the audio directory immediately after transcription — an app crash loses at most one 28-second chunk
  • The setting defaults to enabled on first launch (same nil-check pattern as the Press Enter voice command), so users benefit immediately without manual configuration

How it works

A new PrefetchTranscriber class accumulates the raw PCM16 audio stream (AudioRecorder.onPCM16Samples, 24 kHz mono). Every 28 seconds it writes a temporary WAV file and submits it to the configured TranscriptionService endpoint in a serial background task chain. When recording stops, commitAndAwaitFinal() waits for any in-flight chunk, transcribes the remaining tail, and returns the joined transcript. If the merged result is empty (network error, etc.) it falls back to the regular full-file upload path.

Each chunk's transcript is appended via FileHandle to a timestamped .txt file in ~/Library/Application Support/FreeFlow Dev/audio/. In a 3-hour session the transcriber holds at most ~2 × 1.35 MB of PCM16 in RAM at any time (one chunk being transcribed + partial next chunk buffer).

The existing realtime WebSocket mode (realtimeStreamingEnabled) takes priority when both settings are enabled, so the two paths are non-conflicting.

Files changed

File Change
PrefetchTranscriber.swift New class: PCM16 accumulation, 28-second chunking, serial task chain, durable file append
AppState.swift New prefetchTranscriptionEnabled property (default: on); recording start/stop/cancel wiring; durable save-URL generation
SettingsView.swift New "Begin transcribing while recording" toggle above existing realtime toggle

Relates to #250 (URLSession timeout fixes for local LLM inference).

Summary by CodeRabbit

  • New Features
    • Added an option to transcribe in background while recording by prefetching audio chunks so results are ready sooner.
  • Bug Fixes
    • Improved transcript selection on stop: realtime transcription is preferred, with background prefetch used as fallback before file-based transcription.
    • Ensured background prefetch processing is correctly started, torn down, and discarded when realtime is disabled or recording ends.
  • Documentation
    • Updated the settings text to clarify background transcription behavior and that realtime takes priority when both are enabled.

Add a new "Begin transcribing while recording" setting that submits audio
to the transcription provider in 28-second background chunks while the
user speaks.  By the time the user presses stop, the majority of the
recording is already processed — only the tail (< 28 s) needs
transcribing on stop, dramatically reducing perceived latency for long
dictations.

Unlike the existing realtime WebSocket mode this works with any standard
OpenAI-compatible HTTP transcription endpoint (Groq, local Whisper/
faster-whisper, etc.) with no additional server requirements.

Implementation:
- PrefetchTranscriber.swift: lightweight class that accumulates raw
  PCM16 samples (24 kHz mono, from AudioRecorder.onPCM16Samples),
  writes 28-second chunks to temporary WAV files, and submits them to
  the existing TranscriptionService serially in the background.
  commitAndAwaitFinal() waits for in-flight chunks then transcribes the
  remaining tail, returning the joined transcript.
- AppState: new @published prefetchTranscriptionEnabled property, wired
  into the recording start/stop/cancel flow alongside the existing
  realtimeService path.  Realtime WebSocket takes priority if both
  settings are enabled.
- SettingsView: new toggle above the existing realtime toggle, with a
  description clarifying it requires no special server support.

Relates to zachlatta#250 (URLSession timeout fixes for local LLM inference).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
@github-actions github-actions Bot added the size/m label Jul 3, 2026
@coderabbitai

coderabbitai Bot commented Jul 3, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

📝 Walkthrough

Walkthrough

This PR adds background prefetch transcription during recording, integrates it into AppState lifecycle and stop-time transcript resolution, and updates the settings text for the recording toggles.

Changes

Prefetch Transcription Feature

Layer / File(s) Summary
PrefetchTranscriber chunking and transcription logic
Sources/PrefetchTranscriber.swift
Buffers PCM16 audio, emits ~28-second WAV chunks, transcribes them serially, and merges successful chunk text on commit, with optional save-file persistence.
AppState lifecycle and transcript resolution integration
Sources/AppState.swift
Adds the persisted prefetch setting and active transcriber state, starts and tears down prefetch capture with recording, and resolves transcripts in realtime → prefetch → file order.
Settings toggle text updates
Sources/SettingsView.swift
Revises the toggle descriptions to match the updated prefetch and realtime recording behavior.

Estimated code review effort: 3 (Moderate) | ~25 minutes

Sequence Diagram(s)

sequenceDiagram
  participant Recorder
  participant AppState
  participant PrefetchTranscriber
  participant RealtimeService
  participant TranscriptionService
  participant FileService

  AppState->>AppState: startPrefetchTranscriberIfEnabled()
  AppState->>PrefetchTranscriber: init(TranscriptionService)

  Recorder->>PrefetchTranscriber: appendPCM16(samples)
  PrefetchTranscriber->>TranscriptionService: transcribe(fileURL) per chunk

  AppState->>RealtimeService: final realtime transcript
  alt realtime succeeds
    RealtimeService-->>AppState: transcript
  else realtime fails
    AppState->>PrefetchTranscriber: commitAndAwaitFinal()
    PrefetchTranscriber-->>AppState: merged transcript or empty
    alt prefetch empty
      AppState->>FileService: fallback transcription
      FileService-->>AppState: transcript
    end
  end
  AppState->>AppState: tearDownPrefetchTranscriber()
Loading

Possibly related PRs

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly summarizes the main change: background pre-transcription while recording.
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 2

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
Sources/AppState.swift (1)

2637-2639: 🔒 Security & Privacy | 🟠 Major | ⚡ Quick win

Cancel prefetch uploads during teardown.

prefetchTranscriber = nil only drops the reference; any already-started chunk task can keep uploading audio after cancellation/failure paths. Mirror realtime teardown by adding PrefetchTranscriber.cancel() and calling it from teardown/defer paths.

Proposed direction
 private func tearDownRealtimeService() {
     audioRecorder.onPCM16Samples = nil
     realtimeService?.cancel()
     realtimeService = nil
-    prefetchTranscriber = nil   // discard any in-progress accumulation
+    prefetchTranscriber?.cancel()
+    prefetchTranscriber = nil   // discard any in-progress accumulation
 }
@@
 private func tearDownPrefetchTranscriber() {
     audioRecorder.onPCM16Samples = nil
+    prefetchTranscriber?.cancel()
     prefetchTranscriber = nil
 }
// In PrefetchTranscriber
func cancel() {
    let task = lock.withLock {
        buffer.removeAll()
        let task = chainTail
        chainTail = Task { nil }
        return task
    }
    task.cancel()
}

Also applies to: 2900-2923

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@Sources/AppState.swift` around lines 2637 - 2639, The teardown path only nils
out prefetch state, so any in-flight chunk upload can continue running after
cancellation or failure. Add a cancel capability to PrefetchTranscriber, using
its existing state/lock and chainTail handling to stop pending work and clear
buffered audio. Then call that cancel method from the same teardown/defer paths
that already cancel activeRealtime so both realtime and prefetch uploads are
shut down consistently.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@Sources/PrefetchTranscriber.swift`:
- Around line 59-61: `commitAndAwaitFinal()` in `PrefetchTranscriber` is
returning before all collected chunk results are guaranteed to be appended,
because the collector task writes to the merged transcript outside the serial
chain while `appendPCM16(_:)` only protects its own work. Move the
result-appending logic into the same serial chain behind `chainTail` (and keep
all `chainTail` mutations under the same lock/ordering used by
`appendPCM16(_:)`) so `commitAndAwaitFinal()` can safely await the full
transcript before returning.
- Around line 100-109: The transcribeWAV(_:) path in PrefetchTranscriber is
swallowing chunk upload/transcription failures by returning nil, which lets
callers skip failed segments and can produce a partial merged transcript. Update
PrefetchTranscriber to track any chunk failure during commitAndAwaitFinal() and
surface an explicit failure or empty result instead of silently dropping that
chunk, so AppState.resolveRawTranscript() falls back to the full-file
transcription when any prefetch chunk fails.

---

Outside diff comments:
In `@Sources/AppState.swift`:
- Around line 2637-2639: The teardown path only nils out prefetch state, so any
in-flight chunk upload can continue running after cancellation or failure. Add a
cancel capability to PrefetchTranscriber, using its existing state/lock and
chainTail handling to stop pending work and clear buffered audio. Then call that
cancel method from the same teardown/defer paths that already cancel
activeRealtime so both realtime and prefetch uploads are shut down consistently.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: 4f668f48-5d67-403e-bb5b-18e5c9f6e727

📥 Commits

Reviewing files that changed from the base of the PR and between 13e2788 and 5aeacd8.

📒 Files selected for processing (3)
  • Sources/AppState.swift
  • Sources/PrefetchTranscriber.swift
  • Sources/SettingsView.swift

Comment thread Sources/PrefetchTranscriber.swift
Comment on lines +100 to +109
private func transcribeWAV(_ wav: Data) async -> String? {
let url = FileManager.default.temporaryDirectory
.appendingPathComponent("prefetch_\(UUID().uuidString).wav")
do {
try wav.write(to: url)
defer { try? FileManager.default.removeItem(at: url) }
return try await service.transcribe(fileURL: url)
} catch {
return nil
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

Do not silently accept partial transcripts after chunk failures.

Line 107 turns any upload/transcription failure into nil, and callers skip that segment. Since AppState.resolveRawTranscript() returns any non-empty merged prefetch result, one failed 28-second chunk can silently drop dictated audio instead of falling back to the full file.

Track chunk failures and return an empty result or explicit failure from commitAndAwaitFinal() so AppState uses the full-file fallback.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@Sources/PrefetchTranscriber.swift` around lines 100 - 109, The
transcribeWAV(_:) path in PrefetchTranscriber is swallowing chunk
upload/transcription failures by returning nil, which lets callers skip failed
segments and can produce a partial merged transcript. Update PrefetchTranscriber
to track any chunk failure during commitAndAwaitFinal() and surface an explicit
failure or empty result instead of silently dropping that chunk, so
AppState.resolveRawTranscript() falls back to the full-file transcription when
any prefetch chunk fails.

Each 28-second chunk is appended to a timestamped .txt file in the audio
directory immediately after transcription — a crash loses at most one
in-flight chunk rather than the entire session.

Prefetch transcription now defaults to enabled on first launch so
background transcription starts without requiring a manual settings toggle.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant