Skip to content

feat(local): local speech recognition, for recordings as sources and voice input - #2264

Merged
MODSetter merged 19 commits into
devfrom
feat/local-speech-recognition
Oct 14, 2026
Merged

MODSetter merged 19 commits into
devfrom
feat/local-speech-recognition

Conversation

@MODSetter

@MODSetter MODSetter commented Oct 14, 2026 •

Copy link
Copy Markdown
Owner

What

Local speech recognition v1, as docs/proposals/local-speech-to-text.md (merged in #2254) lays out. It covers the proposal's Order of work, steps 2 to 7.

  • Audio files as sources.
    • .wav .mp3 .flac .ogg .oga uploads become documents.
    • The ingest worker decodes them in a child process (pyminiaudio, MIT). Silero VAD finds the speech, which goes in windows of at most 28 s through the API's gate to a second audiocpp_server.
    • The transcript (a header plus [hh:mm:ss] paragraphs) is stored as content, so chunking, search, citations and the agent read it unchanged.
    • Progress shows per window, cancel works between windows, and a retry resumes from a checkpoint.
    • A long recording gives way to other uploads every minute.
    • Recordings uploaded before any speech model exists wait. They are queued again when one is chosen.
  • Voice input in the composer.
    • A mic beside Send. Phrases are cut at pauses and sent one at a time, and text lands as you speak.
    • Audio stays in memory only.
    • Enter stops and inserts, Escape cancels and restores the text, a thread switch cancels, and holding the mic is push-to-talk.
    • Text is clamped at the 4,096-character limit.
    • assistant-ui's DictationAdapter is used; nothing goes to Google's Web Speech.
  • Runtime.
    • A second instance of the audio.cpp v0.8.2 server already shipped for podcast voices, with nemotron_asr compiled in. It has its own config, port, watcher and crash restart, and only the API calls it (ADR 0056).
    • The model is Nemotron 3.5 ASR Streaming 0.6B Q8_0 (930.6 MB, OpenMDW-1.1, 26 languages at ≤30% FLEURS error), downloaded on request.
  • Settings › Speech recognition, the new speech_to_text slot (local only, migration 0030), the language setting, and a set-up popover shared by the mic and a waiting recording.
  • Electron.
    • media is granted only to the main window's own page, for audio only.
    • A microphone status, request and settings bridge.
    • The macOS audio-input entitlement and NSMicrophoneUsageDescription.

Every step was built and then reviewed by a separate pass, which found and fixed real bugs before this PR. Each step is its own commit (feat then fix), in two lines merged at the end: backend and the interface. Reading commit by commit is the easiest way through.

Why

A user asked for speech-to-text. The aim was as many languages as possible on a model light on the user's machine.

Measured on audio.cpp's CPU path (proposal, Measurements), Nemotron 3.5 took half the memory of Qwen3-ASR-0.6B (1.19 GB against 2.37 GB loaded) and ran three times as fast (11.5× against 3.9× real time).

Fixes #

How to test

Once, in your checkout:

  • Delete surfsense_local/electron/audiocpp/. Staging never rebuilds over an existing one, and the current one has no nemotron_asr.
  • uv sync in surfsense_local/backend (adds miniaudio).
  • pnpm build:vad in surfsense_local/electron (stages Silero).

Then pnpm dev:

  • Settings › Speech recognition: download Nemotron, choose a language or leave "Detect automatically".
  • Drop an .mp3 into the sources. The row shows progress and then the transcript.
  • Press the mic in the composer and speak; release or click to stop.

Run on the merged branch (Windows 11, Ryzen 7 5800X):

Suite Result
Backend unit 4,354 passed, 0 failed. The base was 4,190 passed.
Backend integration 1,348 passed, 9 failed. All 9 are in plugins/bundles and fail the same way on the base on Windows: CRLF output, os.killpg, a FileExistsError. model_route/test_studio.py::test_a_request_queued_past_the_silence_limit_still_completes was deselected; it hangs on this machine on the base too.
Speech packaging, against the real audio.cpp v0.8.2 server and the Nemotron Q8_0 6 passed. Kokoro voices sentences in six languages and Nemotron writes them down; a recording goes end to end through the ingest worker.
Frontend 954 of 955 passed. app-bootstrap.test.tsx timed out once under load and passes 8 of 8 alone; the branch doesn't touch it.
Frontend typecheck, lint, translations:verify Clean
Electron 246 of 250 passed. The 3 failures are Windows-only and in files this branch doesn't touch: secret.test.ts file mode, the llama.cpp licence CRLF test, opencode-leftovers kill ESRCH.
Electron typecheck, ruff, uv lock --check, check_docs.py Clean

Not done in this PR

  • macOS: no notarized build has shown the microphone prompt yet. The x-apple.systempreferences address for the microphone pane is unchecked.
  • Packaged builds: capture (the 16 kHz context and the worklet over file://) was checked only in dev Chromium over http.
  • Open measurements: the proposal's laptop measurements (i5-1235U and an 8 GB machine with a chat model), and its language-flip, hallucination, yielding-latency and short-phrase language-ID measurements, are still open.
  • The proposal stays in-progress. Folding v1 into architecture/ and moving steps 8 to 11 into a follow-up proposal waits for those measurements.
  • Two existing issues, outside this change:
    • watch-audio-models.ts (the voices' watcher) has the same restart race that this PR fixed in the new watcher.
    • A crash toast says "Restart SurfSense" even for servers that restart themselves.

…p popover

Step 3 of the local speech recognition proposal, frontend half. Users can
now download and choose a speech model, and set its spoken language; nothing
reads the choice yet, which selection allows.

- `speech_to_text` joins the model types. The local catalog slice
  (`features/models/local/transcription/`) reads rows whose `selectable_for`
  names it, by their `transcription` block: size, memory while transcribing,
  whether the model detects the language, and its listed languages, counted
  and opened to their names in the interface's language.
- Settings › Speech recognition sits beside Audio on `ModelSlotSettings`,
  with a new `localOnly` option: no server can fill this slot, so neither the
  list nor Add model offers one. A download installs with `select: true`,
  because one speech model serves recordings and voice input. The language
  select reads and writes /llm/selection/speech_to_text/language and offers
  "Detect automatically" only for a model that detects.
- `features/speech-setup/` is the set-up popover the proposal shares between
  a waiting recording's row and the mic: one card per curated model, Download
  and set up through the existing install job and its egress consent, its
  progress, then Start talking or Done, and More choices for Settings. The
  consent prompt nests inside the popover (a new Base UI `Popover` wrapper
  hosts app dialogs), so answering it does not dismiss the popover.
- Every new string is in all ten catalogs; four `{slot, select}` messages
  gain a `speech` branch. The glossary gains speech recognition, voice input
  and transcript. The catalog doc describes both screens.
…rted

Step 2 of the local speech recognition proposal. Nemotron 3.5 ASR runs on
the audiocpp_server the app already ships for voices, as a second process
with its own config, port and crash restart, so a voice install, a
podcast's unload or a busy voice model never touches a transcription.

- pins.mjs builds nemotron_asr beside the voice families, so its spec is
  staged too; staging now starts the server once per curated family on an
  empty model package and fails unless that family's loader runs, which
  --list-devices could not show. It also prints how long staging took.
- build-audiocpp.yml gains a macOS job that stages upstream's archive the
  same way; the Linux and Windows gates re-run because the pin changes the
  cache key.
- Electron has the audiocpp-transcription sidecar (transcription.json, its
  own free port, the voices' flags without eSpeak) and a watcher that starts,
  restarts and stops it with that file, and restarts it after a crash at
  most once per 10 s, giving up after 3 crashes in 5 minutes until the file
  changes.
- The API alone receives SURFSENSE_LOCAL_TRANSCRIPTION_BASE_URL, read into
  LLMSettings.transcription_base_url; workers will go through the API.

Nothing writes transcription.json yet, so the server never starts.
ADR 0055 records the decision; packaging.md and overview.md describe it.
…servers

Review of the Speech recognition section found two wrong screens and a
glossary row at odds with the app.

- A local catalog that fails to load left the section saying "This
  computer can't run one", which is false. With no server to fall back on,
  the slot has nothing else to show, so the catalog's error now becomes the
  section's error, as a failed selection already did.
- `ModelSlotSettings` still read the connections query to decide whether a
  `localOnly` slot is empty, so a failed or slow connections request hid
  the empty state behind "No speech recognition model chosen". A local-only
  slot no longer counts servers.
- The glossary's new transcript row gave Korean 대본 (a script to perform)
  and Chinese 转录文本, where the podcast viewer already says 스크립트 and
  文字稿 for "Transcript"; the row now matches the strings in use.
- The Audio page gains a test that a recogniser, on disk or downloadable,
  never appears there, as the catalog doc says.
The transcription watcher stops the old server and then starts the new one,
and that stop can take seconds while the server exits. Two cases started a
server that nothing would ever stop:

- A poll that came during the stop saw the rewritten config, found no child
  under the name (the supervisor forgets it before waiting) and started one;
  the first poll then started another over it, orphaning the first with its
  port and its model's memory. Polls now skip while one is still running.
- A quit that began during the stop stopped only the children it saw, and
  the poll then started a fresh server after it. A start now checks for the
  quit again.

The proposal's code list also names shared/config.py, which step 2 changed.
…anscript is

Step 5 of the local speech recognition proposal, frontend half. Audio files
can be added as sources, and their rows say how transcription is going,
what they wait for, and how search will read them.

- `.wav .mp3 .flac .ogg .oga` join the upload mirror of the backend's
  suffixes, so the panel's and the composer's Upload sources pick them up;
  `.opus` stays out until a decoder ships, and an Ogg Opus file is refused
  per file by the server.
- `WorkspaceDocument` gains `transcription {language, progress, awaiting}`,
  and `reuseUnchanged` compares it by value, or every re-read would render
  every recording's row again.
- A documents event may carry a transient `progress`. The panel keeps it on
  the row without reading the list again when the row already has the
  event's status, and times the windows for the estimate; any other event
  still reloads.
- A recording's row has a second line (`features/sources/transcription/`):
  "Transcribing · 23 of 58 min · about 7 min left" over a thin bar while it
  runs, "Queued · 40% transcribed" once it has made way for other sources,
  and "Waiting for speech recognition" with Set up, which opens the set-up
  popover, for one added before any model. That wait is not an error: no
  alert, no Retry, and the 10-second re-read skips it.
- A ready transcript not in English, or in a language not identified, says
  search finds it best with questions in its own language while the index
  uses bge-small. No route says which languages an embedder reads, so the
  frontend knows bge-small by its id.
- Recordings get no preview: viewers are keyed by MIME type, and a test
  now holds that no audio type has one.
- Every new string is in all ten catalogs; the glossary gains "recording".
  The documents doc describes the rows and the progress events.
…ge is unlisted

Review of the recordings step (step 5 of the local speech recognition
proposal, frontend half).

- The set-up popover lived inside the "Waiting for speech recognition"
  line, which unmounted the moment choosing a model queued the recording
  again. Downloading from a row's Set up closed the popover at 100%, before
  it could say speech recognition is ready, and dropped focus. The line now
  stays while its popover is open.
- The proposal's Languages section has the row say in one line when the
  model detected a language it is not listed for. Rows now read
  "Estonian (detected) · the transcript may be less accurate", comparing
  with the chosen model's languages from the language route; nothing shows
  while no speech model is chosen.
- A test covers a recording that resumes after making way: the list is
  read again, and its estimate starts afresh without the queued time.
- The recordings test fed every /events stream to the workspace's feed, so
  a test that opens the popover lost its events to the installs stream.
… system's switch

Electron approves every permission a session has no handler for, so any frame
in the app could open the microphone silently. Speech recognition is about to
ask for it, so the main process now installs request and check handlers on
every session as it is created, before the app is ready: `media` goes only to
the main window's main frame on the app's own page (a file in the SPA's own
folder packaged, Vite's origin in development) and only for audio; a camera, a
desktop or tab capture and `display-capture` are refused. Every other
permission keeps Electron's own answer, so clipboard writes keep working until
a strict allowlist has its own audit. The Word snapshot session, which prints
untrusted documents, now refuses every permission.

The page also needs the operating system's side: `window.surfsense.microphone`
reports the switch (getMediaAccessStatus on Windows and macOS; Linux has none),
asks macOS before getUserMedia, and opens Windows' or macOS's microphone
settings through its own handler, since allowedExternalUrl passes only https.
It answers the main window's main frame only.

macOS gets the audio-input entitlement, which the hardened runtime requires and
without which askForMediaAccess can crash the app, and its own usage sentence
in place of Electron's.
Step 3 of the local speech recognition proposal, the backend half. A user
can now download Nemotron 3.5 ASR Streaming 0.6B and choose it for
speech_to_text, and the API writes the transcription.json that step 2's
watcher starts the second audio.cpp server from. Nothing calls the server
yet, which selection allows.

- ModelType.SPEECH_TO_TEXT is a local-only slot: a model nothing recognises
  and every connection stop short of it, since no remote speech client
  exists, and a confirmed unlisted model cannot get round that.
- Revision 0030 rebuilds selected_models so audiocpp may hold audio_gen or
  speech_to_text and the type CHECK lists six slots. It carries every column;
  0018's rebuild predates 0019's settings, so copying it would drop the voices
  users added. Downgrading drops only a speech_to_text row.
- The manifest gains a reviewed transcription block (sample rate, measured
  peak, window, languages, the family's locales, detected-only locales,
  request options). The schema refuses a locale or option the pinned
  nemotron_asr does not take, since the server fails such a request, and makes
  each audio.cpp entry carry the block its type names.
- Nemotron's Q8_0 is pinned at audio-cpp/audio.cpp-gguf f861648, validated on
  v0.8.2, listed for 26 languages; OpenMDW-1.1 joins the licence allowlist
  with its "without restriction" clause quoted.
- transcription.json names each installed recogniser with
  default_request_options (keep_language_tags) and is written only when its
  bytes change, so an API start, a voice install or a delete never restarts
  that server. A recogniser's install leaves server.json alone, which a
  podcast may be voicing from; a delete the server holds open rewrites
  transcription.json unchanged, which is what restarts it.
- Rows carry a transcription summary, every engine leads with every name
  chosen for any of its slots, and an install with select fills the row's own
  slot rather than the engine's first.
- resolve_speech_to_text() returns the selection and its installed
  recogniser; GET and PUT /llm/selection/speech_to_text/language keep the
  language intent under the row's transcription settings, auto or a listed
  code, and answer 409 needs_speech_model without one.

Docs: selection.md, catalog.md, data-model.md and overview.md, and glossary
rows for speech recognition, dictation and transcript.
…ntitlement

The test script's `scripts/**/*.test.mjs` is expanded by the shell before
node sees it. pnpm runs scripts with sh on Linux, where `**` without
globstar matches one folder level only, so CI never ran
scripts/mac-microphone.test.mjs, nor deb-control.test.mjs beside it; cmd on
Windows passes the pattern through to node's own glob, which found them and
hid the gap. `scripts/*.test.mjs` adds the top level; node runs a file both
patterns match once.

electron-builder signs every file but the app's executable with
entitlementsInherit, so the sidecars carry the audio-input entitlement too:
once macOS lets SurfSense use the microphone, the Studio worker running a
model's analysis script could open it without the page. packaging.md's
Known gaps now say so.
…mily

Review of the speech_to_text slot step. The manifest schema refused a locale
or request option the pinned nemotron_asr does not take, but let a recogniser
list a language with no family_codes locale. Settings' language PUT accepts
any listed language, so pinning that one would send the bare code, which
Nemotron fails like any unknown locale. The schema now refuses it for a
family that takes locales.

Docs the step left stale: connections.md still said a key connection serves,
and an unknown model fills, every slot, and ADR 0055 still said nothing
writes transcription.json.
The composer gets a mic beside Send that turns speech into text on this
computer, through the API's transcription routes and the speech model chosen
for speech recognition (step 7 of the local speech recognition proposal).
Chromium's own speech recognition sends audio to Google, so capture, phrase
cutting and the session with the API are the app's own.

Capture opens the default microphone after the system bridge's request (the
macOS prompt; a switch that is off reads as NotAllowedError), into a 16 kHz
AudioContext and an AudioWorklet loaded by URL, which Vite emits as a file
rather than a data: URL. A platform that refuses 16 kHz is resampled per
phrase with an OfflineAudioContext. Phrases end at a 700 ms pause after a
second of speech, against a noise floor that is the 10th percentile of the
last 3 s, or at 20 s at the quietest 200 ms of their second half, with 460 ms
kept either side; they go up as in-memory WAVs, one in flight at a time, with
a keepalive every 10 s and one retry after 2 s for a server mid-restart.

Text lands through assistant-ui's DictationAdapter, passed whatever the model
reads: each phrase re-emits everything so far (no space between zh, ja, yue
and th phrases), and stop emits one final before the session ends, since the
runtime drops anything after. The box is read-only meanwhile; text is clamped
to the 4,096-character limit at a word (a character in Han, kana and Thai) and
recording stops there, warns at 4:30 and stops at 5:00. Enter and Send stop
and insert instead of sending, because assistant-ui's send() cancels a running
session and would drop untranscribed audio; Escape cancels and restores the
text from before the mic; a hold of 300 ms is push to talk. A thread switch
cancels, so no late phrase lands in the next thread. With no speech model the
mic opens the set-up popover. Each error is one line with one action, such as
Open settings for a microphone that is off and Retry for a phrase that failed.

Docs: chat.md gains a Voice input section, accessibility notes and its gaps.
…ed a phrase

Review of the composer's voice input (step 7 of the local speech recognition
proposal).

A word too short to end at a pause, followed by silence, reached the 20 s
forced cut and left a remainder with no voice in it. That remainder stayed
an open phrase, so every further 10 s of silence went up as a phrase of its
own and the next real phrase started with up to 20 s of quiet ahead of it.
A forced cut that leaves only quiet now ends the phrase at its last word,
and the quiet waits as pre-roll like any other.

The API checks memory again on every phrase, and refuses one with 409
transcription_needs_memory once a podcast's or an image's hold has unloaded
the speech recognition model. That read as "Couldn't turn part of your speech
into text"; it now names both sizes, as the refusal at the mic press does,
and keeps the audio for Retry. A Retry refused for memory says so too.

A phrase that came back with no language and ends in CJK punctuation (。,
」) was joined to the next with a space; the join now reads the nearest
letter, as punctuation belongs to no script.

"Loading the speech model" and "The speech model was removed" used the
glossary's "speech", which means text read aloud; they now say "speech
recognition model", as every other locale already did. The German and
Russian lines for a failed phrase read as "part of your language" and the
grammatical "part of speech"; both now say part of what was said.
Step 4 of the local speech recognition proposal: the API slice that the
composer's mic and recordings as sources will call. The transcription server
runs one model and one request at a time, and only the API reaches it, so
the API decides the order: a speaker never waits behind a recording, and a
podcast or an image gets its memory back.

- modules/llm/transcription/: the dictation routes (status, open, phrases,
  keepalive, close) under /llm/transcription, and the ingest worker's
  POST /internal/transcription/windows, which answers 503 waiting with its
  reason (dictation, hold, memory with both sizes, or busy) after 30 s.
- The gate sends one request at a time, a phrase ahead of any window. An open
  dictation (until its last phrase, 20 s without one, or 6 minutes) starts no
  window. A text-runtime hold, which /internal/models/text/yield now takes,
  starts no window, waits for the request in flight, and unloads the speech
  model by id; dictation goes on behind the memory check.
- Memory: a loaded model needs no check; otherwise the entry's measured peak
  plus 1 GiB against the operating system's figure, refused in a sentence.
  memory.py's check now takes the verb ("Transcribing", "Voicing").
- providers/audiocpp/transcription.py behind a new Transcriber protocol:
  the WAV from memory as multipart to /details, a deadline of
  max(120 s, 20 x the audio), one retry after 2 s on a dropped connection or
  a 503, and the model held in use so a delete mid-request is refused.
- Phrases are checked with the standard library and by Silero VAD on the
  onnxruntime already in the API; under 250 ms of speech never reaches the
  model. Text loses Nemotron's locale tags, the last naming the language, and
  goes through the proposal's fail-open cleanup.
- The packaging test found French and Hindi sentences untagged, past the
  proposal's 1 in 10, so with no tag under auto the language is read from a
  script only one listed language writes.
- scripts/fetch_silero_vad.py stages Silero's 16 kHz ONNX build and its MIT
  licence from v6.2.3's commit, sha256-checked, into the models pack; build:vad
  and the release workflow run it beside the bundled voice.
- tests/packaging/test_audiocpp_transcription.py has Kokoro voice six
  languages and the real server write them down through the app's client
  (peak 1,743 to 1,774 MiB here, against the entry's 1,776); build-audiocpp.yml
  runs it and the config test, with the recogniser cached by its hash.

Docs: admission.md gains speech recognition and the hold, studio.md the hold's
effect, packaging.md the pack and the tests, ADR 0055 the gate.
…CJK text

Review of the speech recognition API slice.

- A transcription request past its deadline raised httpx's ReadTimeout out of
  the client, so the phrase and windows routes answered a bare 500 instead of
  503 transcription_unavailable, which the worker retries and the composer
  explains. Any HTTP error the client meets, and a 200 with no JSON text, now
  become TranscriptionUnavailableError; a timed-out request is not asked again.
- The cleanup's character-level collapse for zh, ja, yue and th counted a
  number's digits as characters, so 100000 in a Chinese transcript became 10,
  and Hmmmm became Hm. A run of digits or Latin letters now counts as one unit.
- A WAV cut mid-sample passed the check with half a sample, which made Silero
  fail (the clip then skipped the speech check) and reached the server as is.
  The check keeps whole samples only.
- The keepalive and close routes were sync handlers, so they changed the
  dictation sessions from the threadpool while the gate read them on the event
  loop. They are async now, like every other use.

Docs: the proposal's Transport table gains the windows route's `busy` wait and
a phrase's 409 transcription_needs_memory after a hold, both of which the step
documented in admission.md only; the cleanup rule names the unit; admission.md
says a timed-out request answers 503.
Step 5 of the local speech recognition proposal, backend half. A recording
uploaded as a source becomes a timestamped transcript in its content, so
chunking, search, citations and the agent take it unchanged, without one long
file holding every other upload behind it on the one-thread ingest queue.

- Upload: .wav .mp3 .flac .ogg .oga with server-owned audio/* types, checked
  by their bytes (RIFF/WAVE, ID3 or an MPEG frame, fLaC, Ogg with a Vorbis
  first packet). Ogg Opus is rejected per file: "Opus support comes in a
  later update". can_hold_figures is false for audio/*, so Docling never
  reads a recording.
- worker/audio_decode/: the worker's own binary, started as
  `worker --decode-audio <in> <out>` ahead of the consumer's imports, checks
  the disk from the file's header length, streams it through pyminiaudio
  (MIT) to 16 kHz mono s16 and exits when its parent does. A malformed file
  kills the child, not the consumer. miniaudio joins pyproject, uv.lock and
  worker.spec (with its licence metadata).
- worker/ingestion/audio/, reached from parsing before Docling: readiness
  through the API's status route, the decode, Silero over the file a minute at
  a time (the API's reader, now able to stream), spans padded and joined,
  windows of at most 28 s cut at the longest pause or a run's quietest
  100 ms, each window sent to /internal/transcription/windows with retries and
  waits, windows.jsonl and progress.json as the checkpoint, a progress event
  per window, then the header and [hh:mm:ss] paragraphs, segments.json and
  transcription {language, duration_s, model} in the row's metadata.
- Yielding: after a window or a 503 waiting, once a minute has passed and a
  source that is not a recording is pending, the job raises JobYieldedError;
  the pipeline sets the row back to pending (jobs.pause_job) and queues it at
  PRIORITY_BULK. Recordings never yield to each other.
- No speech model: the job fails without retry and marks
  transcription.awaiting; choosing a speech model, by the route or an install
  with select, queues every waiting recording again. Refusals (no room, no
  speech, no audio, undecodable, memory short for 10 minutes) are
  CancelExecution(retry=False); every other retry resumes from windows.jsonl
  when the model and language intent match.
- DocumentRead gains transcription {language, progress, awaiting} for audio
  rows, progress read from progress.json; InternalEvent and the SSE frame
  gain an optional transient progress {done_s, total_s}.

Measured here: an 8.6-minute file from pending to ready in 60 s at 5.83% word
error; an hour of MP3 decodes in 3.6 s; miniaudio's linear resampler read the
44.1 kHz copy at 5.91% against scipy's 5.83%, under the proposal's 2% relative,
so it stays. Docs: documents.md gains Recordings; data-model.md, overview.md,
selection.md and packaging.md follow; the proposal records the two step 5
measurements.
…recording's audio

Review of the step 5 backend half (553048c).

- An Ogg Vorbis recording whose path held a character outside the Windows
  ANSI code page (a file named 会議.ogg, or a data folder under an account
  named José) was refused for good as "This recording could not be decoded.":
  miniaudio's vorbis_get_file_info opens the path with fopen, while its stream
  reader opens a wide one. The decode child now reads an Ogg file's length from
  its own pages, the last page's granule position over the rate its first
  packet names; it matched miniaudio's reading on every fixture.
- A speech model chosen midway restarted the recording under the old language
  intent, so the header read "Japanese" and the checkpoint kept "ja" although
  the new model, whose settings start empty, detected the language. The job now
  asks the status route again and starts over under what it reports.
- A cancel of a recording queued again after giving way left its decoded audio,
  115 MB an hour, with no run left to clear it. The cancel route now drops it
  and progress.json, keeping the plan and windows for a retry, as a run's own
  end does (recordings/ended_run.py, shared with the worker, best-effort for a
  file another process holds). A cancel that lands just as a job would give
  way now ends it, instead of pause_job refusing quietly with the audio kept.
- build-audiocpp.yml's path filter takes modules/documents/recordings/, which
  the end-to-end gate reads too.
…h-recognition

The interface half of local speech recognition: Settings > Speech recognition
and its set-up popover, recordings in the sources panel, the microphone in
Electron, and dictation in the composer.
@vercel

vercel Bot commented Oct 14, 2026 •

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated
surf-sense-frontend Ready Ready Preview Oct 14, 2026 10:05pm UTC

Request Review

@coderabbitai

coderabbitai Bot commented Oct 14, 2026 •

Copy link
Copy Markdown

Important

Review skipped

Too many files!

This PR contains 316 files, which is 16 over the limit of 300.

To get a review, reduce the PR to 300 files or fewer by splitting it into smaller PRs or changing its base branch.

Usage-priced reviews support at most 300 files.

⚙️ Run configuration
  • Configuration used: Repository: MODSetter/SurfSense/.coderabbit.yaml
  • Review profile: CHILL
  • Plan: Advanced
  • Run ID: dd66c300-5bd0-4738-adf1-03be2938fa3f
📥 Commits

Reviewing files that changed from the base of the PR and between af8eef8 and 531c1f6.

⛔ Files ignored due to path filters: 6
  • surfsense_local/backend/tests/fixtures/audio/speech.ogg is excluded by !**/*.ogg
  • surfsense_local/backend/tests/fixtures/audio/tone-opus.ogg is excluded by !**/*.ogg
  • surfsense_local/backend/tests/fixtures/audio/tone.flac is excluded by !**/*.flac
  • surfsense_local/backend/tests/fixtures/audio/tone.mp3 is excluded by !**/*.mp3
  • surfsense_local/backend/tests/fixtures/audio/tone.ogg is excluded by !**/*.ogg
  • surfsense_local/backend/uv.lock is excluded by !**/*.lock
📒 Files selected for processing: 316
  • .agents/skills/translate/glossary.md
  • .github/workflows/build-audiocpp.yml
  • .github/workflows/release-local.yml
  • docs/adr/0056-speech-recognition-runs-on-a-second-audiocpp-server.md
  • docs/adr/README.md
  • docs/architecture/agent.md
  • docs/architecture/chat.md
  • docs/architecture/connections.md
  • docs/architecture/data-model.md
  • docs/architecture/documents.md
  • docs/architecture/local-models/admission.md
  • docs/architecture/local-models/catalog.md
  • docs/architecture/local-models/selection.md
  • docs/architecture/overview.md
  • docs/architecture/packaging.md
  • docs/architecture/studio.md
  • docs/proposals/local-speech-to-text.md
  • docs/proposals/model-catalog.md
  • surfsense_local/backend/alembic/versions/0030_speech_to_text_slot.py
  • surfsense_local/backend/api/main.py
  • surfsense_local/backend/bundling/worker.spec
  • surfsense_local/backend/modules/documents/recordings/__init__.py
  • surfsense_local/backend/modules/documents/recordings/awaiting.py
  • surfsense_local/backend/modules/documents/recordings/ended_run.py
  • surfsense_local/backend/modules/documents/recordings/kind.py
  • surfsense_local/backend/modules/documents/recordings/layout.py
  • surfsense_local/backend/modules/documents/recordings/metadata.py
  • surfsense_local/backend/modules/documents/recordings/progress_file.py
  • surfsense_local/backend/modules/documents/recordings/transcription_read.py
  • surfsense_local/backend/modules/documents/router.py
  • surfsense_local/backend/modules/documents/schemas.py
  • surfsense_local/backend/modules/documents/source_figures/layout.py
  • surfsense_local/backend/modules/documents/storage.py
  • surfsense_local/backend/modules/documents/tasks.py
  • surfsense_local/backend/modules/events/broker.py
  • surfsense_local/backend/modules/events/router.py
  • surfsense_local/backend/modules/events/schemas.py
  • surfsense_local/backend/modules/llm/catalog/local/classifier.py
  • surfsense_local/backend/modules/llm/catalog/local/engines/audiocpp/audio_folder/installed.py
  • surfsense_local/backend/modules/llm/catalog/local/engines/audiocpp/audio_folder/installed_recognisers.py
  • surfsense_local/backend/modules/llm/catalog/local/engines/audiocpp/audio_folder/transcription_config.py
  • surfsense_local/backend/modules/llm/catalog/local/engines/audiocpp/engine.py
  • surfsense_local/backend/modules/llm/catalog/local/engines/audiocpp/manifest_fields.py
  • surfsense_local/backend/modules/llm/catalog/local/engines/audiocpp/rows/catalog.py
  • surfsense_local/backend/modules/llm/catalog/local/engines/audiocpp/rows/lead_build.py
  • surfsense_local/backend/modules/llm/catalog/local/engines/engine.py
  • surfsense_local/backend/modules/llm/catalog/local/engines/llamacpp/engine.py
  • surfsense_local/backend/modules/llm/catalog/local/engines/llamacpp/rows/catalog.py
  • surfsense_local/backend/modules/llm/catalog/local/engines/llamacpp/rows/lead_build.py
  • surfsense_local/backend/modules/llm/catalog/local/engines/onnxruntime/engine.py
  • surfsense_local/backend/modules/llm/catalog/local/engines/registry.py
  • surfsense_local/backend/modules/llm/catalog/local/engines/sdcpp/engine.py
  • surfsense_local/backend/modules/llm/catalog/local/engines/sdcpp/rows/catalog.py
  • surfsense_local/backend/modules/llm/catalog/local/engines/sdcpp/rows/lead_build.py
  • surfsense_local/backend/modules/llm/catalog/local/install_jobs/router.py
  • surfsense_local/backend/modules/llm/catalog/local/install_jobs/steps.py
  • surfsense_local/backend/modules/llm/catalog/local/manifest/models.json
  • surfsense_local/backend/modules/llm/catalog/local/manifest/schema.py
  • surfsense_local/backend/modules/llm/catalog/local/router.py
  • surfsense_local/backend/modules/llm/catalog/local/rows.py
  • surfsense_local/backend/modules/llm/catalog/local/schemas.py
  • surfsense_local/backend/modules/llm/catalog/local/service.py
  • surfsense_local/backend/modules/llm/connections/serves.py
  • surfsense_local/backend/modules/llm/model_route/router.py
  • surfsense_local/backend/modules/llm/model_type.py
  • surfsense_local/backend/modules/llm/models.py
  • surfsense_local/backend/modules/llm/providers/audiocpp/memory.py
  • surfsense_local/backend/modules/llm/providers/audiocpp/speech.py
  • surfsense_local/backend/modules/llm/providers/audiocpp/transcription.py
  • surfsense_local/backend/modules/llm/providers/protocols.py
  • surfsense_local/backend/modules/llm/resolution.py
  • surfsense_local/backend/modules/llm/router.py
  • surfsense_local/backend/modules/llm/selectable.py
  • surfsense_local/backend/modules/llm/selection.py
  • surfsense_local/backend/modules/llm/transcription/__init__.py
  • surfsense_local/backend/modules/llm/transcription/audio_checks.py
  • surfsense_local/backend/modules/llm/transcription/cleanup.py
  • surfsense_local/backend/modules/llm/transcription/dependencies.py
  • surfsense_local/backend/modules/llm/transcription/gate.py
  • surfsense_local/backend/modules/llm/transcription/http_errors.py
  • surfsense_local/backend/modules/llm/transcription/internal_router.py
  • surfsense_local/backend/modules/llm/transcription/language_intent.py
  • surfsense_local/backend/modules/llm/transcription/language_router.py
  • surfsense_local/backend/modules/llm/transcription/languages.py
  • surfsense_local/backend/modules/llm/transcription/memory_admission.py
  • surfsense_local/backend/modules/llm/transcription/recognition.py
  • surfsense_local/backend/modules/llm/transcription/router.py
  • surfsense_local/backend/modules/llm/transcription/selected_model.py
  • surfsense_local/backend/modules/llm/transcription/server.py
  • surfsense_local/backend/modules/llm/transcription/sessions.py
  • surfsense_local/backend/modules/llm/transcription/uploaded_clip.py
  • surfsense_local/backend/modules/llm/transcription/voice_activity/__init__.py
  • surfsense_local/backend/modules/llm/transcription/voice_activity/model_file.py
  • surfsense_local/backend/modules/llm/transcription/voice_activity/silero.py
  • surfsense_local/backend/modules/llm/transcription/voice_activity/speech_spans.py
  • surfsense_local/backend/modules/llm/transcription/warm_up.py
  • surfsense_local/backend/modules/llm/transcription/written_script.py
  • surfsense_local/backend/pyproject.toml
  • surfsense_local/backend/scripts/fetch_silero_vad.py
  • surfsense_local/backend/scripts/local_manifest/audiocpp/assemble.py
  • surfsense_local/backend/scripts/local_manifest/audiocpp/entry.py
  • surfsense_local/backend/scripts/local_manifest/audiocpp/refresh.py
  • surfsense_local/backend/scripts/local_manifest/entries.py
  • surfsense_local/backend/scripts/local_manifest/licence.py
  • surfsense_local/backend/shared/config.py
  • surfsense_local/backend/tests/conftest.py
  • surfsense_local/backend/tests/integration/documents/test_source_figures.py
  • surfsense_local/backend/tests/integration/documents/test_upload.py
  • surfsense_local/backend/tests/integration/events/test_events.py
  • surfsense_local/backend/tests/integration/llm/conftest.py
  • surfsense_local/backend/tests/integration/llm/installs.py
  • surfsense_local/backend/tests/integration/llm/test_audio_catalog_routes.py
  • surfsense_local/backend/tests/integration/llm/test_connections.py
  • surfsense_local/backend/tests/integration/llm/test_speech_catalog_routes.py
  • surfsense_local/backend/tests/integration/llm/test_speech_language_routes.py
  • surfsense_local/backend/tests/integration/llm/test_speech_model_requeues_recordings.py
  • surfsense_local/backend/tests/integration/llm/test_transcription_routes.py
  • surfsense_local/backend/tests/integration/llm/transcription_stand_in.py
  • surfsense_local/backend/tests/integration/test_migration_0030.py
  • surfsense_local/backend/tests/integration/worker/conftest.py
  • surfsense_local/backend/tests/integration/worker/speech_api_stand_in.py
  • surfsense_local/backend/tests/integration/worker/test_decode_child.py
  • surfsense_local/backend/tests/integration/worker/test_recordings.py
  • surfsense_local/backend/tests/packaging/test_audiocpp_transcription.py
  • surfsense_local/backend/tests/packaging/test_audiocpp_transcription_config.py
  • surfsense_local/backend/tests/packaging/test_frozen_document_scripts.py
  • surfsense_local/backend/tests/packaging/test_recording_transcription.py
  • surfsense_local/backend/tests/unit/documents/test_upload_validation.py
  • surfsense_local/backend/tests/unit/llm/catalog/local/engines/audiocpp/rows/test_catalog.py
  • surfsense_local/backend/tests/unit/llm/catalog/local/engines/llamacpp/rows/test_catalog.py
  • surfsense_local/backend/tests/unit/llm/catalog/local/engines/llamacpp/rows/test_lead_build.py
  • surfsense_local/backend/tests/unit/llm/catalog/local/engines/sdcpp/images_folder/test_installed.py
  • surfsense_local/backend/tests/unit/llm/catalog/local/engines/sdcpp/rows/test_catalog.py
  • surfsense_local/backend/tests/unit/llm/catalog/local/test_classifier.py
  • surfsense_local/backend/tests/unit/llm/catalog/local/test_install_steps.py
  • surfsense_local/backend/tests/unit/llm/catalog/local/test_manifest.py
  • surfsense_local/backend/tests/unit/llm/catalog/remote/test_catalog.py
  • surfsense_local/backend/tests/unit/llm/providers/audiocpp/test_memory.py
  • surfsense_local/backend/tests/unit/llm/providers/audiocpp/test_transcription.py
  • surfsense_local/backend/tests/unit/llm/test_selectable.py
  • surfsense_local/backend/tests/unit/llm/transcription/__init__.py
  • surfsense_local/backend/tests/unit/llm/transcription/test_audio_checks.py
  • surfsense_local/backend/tests/unit/llm/transcription/test_cleanup.py
  • surfsense_local/backend/tests/unit/llm/transcription/test_gate.py
  • surfsense_local/backend/tests/unit/llm/transcription/test_languages.py
  • surfsense_local/backend/tests/unit/llm/transcription/test_memory_admission.py
  • surfsense_local/backend/tests/unit/llm/transcription/test_sessions.py
  • surfsense_local/backend/tests/unit/llm/transcription/voice_activity/__init__.py
  • surfsense_local/backend/tests/unit/llm/transcription/voice_activity/test_silero.py
  • surfsense_local/backend/tests/unit/llm/transcription/voice_activity/test_speech_spans.py
  • surfsense_local/backend/tests/unit/scripts/test_fetch_silero_vad.py
  • surfsense_local/backend/tests/unit/scripts/test_local_manifest.py
  • surfsense_local/backend/tests/unit/scripts/test_local_manifest_audiocpp.py
  • surfsense_local/backend/tests/unit/scripts/test_local_manifest_licence.py
  • surfsense_local/backend/tests/unit/shared/test_llm_settings.py
  • surfsense_local/backend/tests/unit/worker/audio/__init__.py
  • surfsense_local/backend/tests/unit/worker/audio/test_checkpoint.py
  • surfsense_local/backend/tests/unit/worker/audio/test_speech_regions.py
  • surfsense_local/backend/tests/unit/worker/audio/test_transcript_text.py
  • surfsense_local/backend/tests/unit/worker/audio/test_windows.py
  • surfsense_local/backend/tests/unit/worker/audio_decode/__init__.py
  • surfsense_local/backend/tests/unit/worker/audio_decode/test_decode.py
  • surfsense_local/backend/tests/unit/worker/test_notify.py
  • surfsense_local/backend/worker.py
  • surfsense_local/backend/worker/audio_decode/__init__.py
  • surfsense_local/backend/worker/audio_decode/child.py
  • surfsense_local/backend/worker/audio_decode/decode.py
  • surfsense_local/backend/worker/audio_decode/ogg_length.py
  • surfsense_local/backend/worker/audio_decode/outcome.py
  • surfsense_local/backend/worker/audio_decode/room.py
  • surfsense_local/backend/worker/audio_decode/run.py
  • surfsense_local/backend/worker/ingestion/audio/__init__.py
  • surfsense_local/backend/worker/ingestion/audio/checkpoint.py
  • surfsense_local/backend/worker/ingestion/audio/failures.py
  • surfsense_local/backend/worker/ingestion/audio/language_names.py
  • surfsense_local/backend/worker/ingestion/audio/model_name.py
  • surfsense_local/backend/worker/ingestion/audio/plan_file.py
  • surfsense_local/backend/worker/ingestion/audio/queue_turn.py
  • surfsense_local/backend/worker/ingestion/audio/readiness.py
  • surfsense_local/backend/worker/ingestion/audio/segments_file.py
  • surfsense_local/backend/worker/ingestion/audio/speech_regions.py
  • surfsense_local/backend/worker/ingestion/audio/transcript_job.py
  • surfsense_local/backend/worker/ingestion/audio/transcript_text.py
  • surfsense_local/backend/worker/ingestion/audio/transcription_api.py
  • surfsense_local/backend/worker/ingestion/audio/waiting_work.py
  • surfsense_local/backend/worker/ingestion/audio/window_loop.py
  • surfsense_local/backend/worker/ingestion/audio/windows.py
  • surfsense_local/backend/worker/ingestion/parsing.py
  • surfsense_local/backend/worker/ingestion/pipeline.py
  • surfsense_local/backend/worker/jobs.py
  • surfsense_local/backend/worker/notify.py
  • surfsense_local/electron/build/entitlements.mac.plist
  • surfsense_local/electron/electron-builder.yml
  • surfsense_local/electron/package.json
  • surfsense_local/electron/scripts/audiocpp/built-families.mjs
  • surfsense_local/electron/scripts/audiocpp/built-families.test.mjs
  • surfsense_local/electron/scripts/audiocpp/pins.mjs
  • surfsense_local/electron/scripts/audiocpp/recipe.test.mjs
  • surfsense_local/electron/scripts/audiocpp/server-files.test.mjs
  • surfsense_local/electron/scripts/audiocpp/stage.mjs
  • surfsense_local/electron/scripts/mac-microphone.test.mjs
  • surfsense_local/electron/src/main/docx-snapshot/confine-snapshot-session.test.ts
  • surfsense_local/electron/src/main/docx-snapshot/confine-snapshot-session.ts
  • surfsense_local/electron/src/main/index.ts
  • surfsense_local/electron/src/main/microphone/ipc.test.ts
  • surfsense_local/electron/src/main/microphone/ipc.ts
  • surfsense_local/electron/src/main/microphone/system-access.ts
  • surfsense_local/electron/src/main/permissions/handlers.test.ts
  • surfsense_local/electron/src/main/permissions/handlers.ts
  • surfsense_local/electron/src/main/permissions/media.ts
  • surfsense_local/electron/src/main/sidecars/audiocpp-transcription.test.ts
  • surfsense_local/electron/src/main/sidecars/audiocpp-transcription.ts
  • surfsense_local/electron/src/main/sidecars/boot.ts
  • surfsense_local/electron/src/main/sidecars/python.test.ts
  • surfsense_local/electron/src/main/sidecars/python.ts
  • surfsense_local/electron/src/main/sidecars/types.ts
  • surfsense_local/electron/src/main/sidecars/watch-transcription-models.test.ts
  • surfsense_local/electron/src/main/sidecars/watch-transcription-models.ts
  • surfsense_local/electron/src/preload/index.ts
  • surfsense_local/frontend/src/components/ui/icons.tsx
  • surfsense_local/frontend/src/components/ui/popover.tsx
  • surfsense_local/frontend/src/features/chat/chat-composer.tsx
  • surfsense_local/frontend/src/features/chat/thread-panel.test.tsx
  • surfsense_local/frontend/src/features/chat/thread-panel.tsx
  • surfsense_local/frontend/src/features/chat/use-chat-runtime.test.tsx
  • surfsense_local/frontend/src/features/chat/use-chat-runtime.ts
  • surfsense_local/frontend/src/features/dashboard/dashboard-page.tsx
  • surfsense_local/frontend/src/features/dictation/api.ts
  • surfsense_local/frontend/src/features/dictation/capture/encode-wav.ts
  • surfsense_local/frontend/src/features/dictation/capture/fake-microphone.ts
  • surfsense_local/frontend/src/features/dictation/capture/frame-worklet.js
  • surfsense_local/frontend/src/features/dictation/capture/noise-floor.ts
  • surfsense_local/frontend/src/features/dictation/capture/open-microphone.ts
  • surfsense_local/frontend/src/features/dictation/capture/phrase-cutter.test.ts
  • surfsense_local/frontend/src/features/dictation/capture/phrase-cutter.ts
  • surfsense_local/frontend/src/features/dictation/capture/resample.ts
  • surfsense_local/frontend/src/features/dictation/clamp-to-room.ts
  • surfsense_local/frontend/src/features/dictation/dictation-adapter.test.ts
  • surfsense_local/frontend/src/features/dictation/dictation-adapter.ts
  • surfsense_local/frontend/src/features/dictation/dictation-composer.test.tsx
  • surfsense_local/frontend/src/features/dictation/dictation-problem.ts
  • surfsense_local/frontend/src/features/dictation/dictation-session.test.ts
  • surfsense_local/frontend/src/features/dictation/dictation-session.ts
  • surfsense_local/frontend/src/features/dictation/fake-dictation-api.ts
  • surfsense_local/frontend/src/features/dictation/join-phrases.ts
  • surfsense_local/frontend/src/features/dictation/mic/download-ring.tsx
  • surfsense_local/frontend/src/features/dictation/mic/mic-button.tsx
  • surfsense_local/frontend/src/features/dictation/recording.ts
  • surfsense_local/frontend/src/features/dictation/strip/dictation-strip.tsx
  • surfsense_local/frontend/src/features/dictation/strip/level-bars.tsx
  • surfsense_local/frontend/src/features/dictation/strip/problem-line.tsx
  • surfsense_local/frontend/src/features/dictation/strip/problem-text.ts
  • surfsense_local/frontend/src/features/dictation/strip/recording-timer.tsx
  • surfsense_local/frontend/src/features/dictation/use-dictation-keys.ts
  • surfsense_local/frontend/src/features/dictation/use-dictation-view.ts
  • surfsense_local/frontend/src/features/dictation/use-transcription-status.ts
  • surfsense_local/frontend/src/features/file-viewers/registry.test.ts
  • surfsense_local/frontend/src/features/models/add-model/add-model-options.tsx
  • surfsense_local/frontend/src/features/models/local/chat/api.ts
  • surfsense_local/frontend/src/features/models/local/transcription/api.ts
  • surfsense_local/frontend/src/features/models/local/transcription/describe-speech-model.ts
  • surfsense_local/frontend/src/features/models/local/transcription/download-speech-models.tsx
  • surfsense_local/frontend/src/features/models/local/transcription/fake-speech-rows.ts
  • surfsense_local/frontend/src/features/models/local/transcription/language-names.ts
  • surfsense_local/frontend/src/features/models/local/transcription/language/api.ts
  • surfsense_local/frontend/src/features/models/local/transcription/language/speech-language-select.tsx
  • surfsense_local/frontend/src/features/models/local/transcription/language/use-speech-language.ts
  • surfsense_local/frontend/src/features/models/local/transcription/listed-languages.tsx
  • surfsense_local/frontend/src/features/models/local/transcription/use-delete-local-speech-model.ts
  • surfsense_local/frontend/src/features/models/local/transcription/use-local-speech-catalog.ts
  • surfsense_local/frontend/src/features/models/model-type.ts
  • surfsense_local/frontend/src/features/models/remote/models/server-models.tsx
  • surfsense_local/frontend/src/features/models/your-models/in-use-summary.tsx
  • surfsense_local/frontend/src/features/models/your-models/use-speech-models.ts
  • surfsense_local/frontend/src/features/settings/models/audio-models-settings.test.tsx
  • surfsense_local/frontend/src/features/settings/models/model-slot-settings.tsx
  • surfsense_local/frontend/src/features/settings/models/speech-models-settings.test.tsx
  • surfsense_local/frontend/src/features/settings/models/speech-models-settings.tsx
  • surfsense_local/frontend/src/features/settings/settings-dialog.test.tsx
  • surfsense_local/frontend/src/features/settings/settings-dialog.tsx
  • surfsense_local/frontend/src/features/sources/api.ts
  • surfsense_local/frontend/src/features/sources/reuse-unchanged.test.ts
  • surfsense_local/frontend/src/features/sources/reuse-unchanged.ts
  • surfsense_local/frontend/src/features/sources/source-upload.test.tsx
  • surfsense_local/frontend/src/features/sources/sources-panel.tsx
  • surfsense_local/frontend/src/features/sources/transcription/awaiting-speech-line.tsx
  • surfsense_local/frontend/src/features/sources/transcription/pace.ts
  • surfsense_local/frontend/src/features/sources/transcription/recording-line.tsx
  • surfsense_local/frontend/src/features/sources/transcription/recording-rows.test.tsx
  • surfsense_local/frontend/src/features/sources/transcription/search-caveat-line.tsx
  • surfsense_local/frontend/src/features/sources/transcription/transcription-progress-line.tsx
  • surfsense_local/frontend/src/features/sources/transcription/unlisted-language-line.tsx
  • surfsense_local/frontend/src/features/sources/transcription/use-transcription-paces.ts
  • surfsense_local/frontend/src/features/sources/transcription/with-progress.ts
  • surfsense_local/frontend/src/features/sources/tree/document-row.tsx
  • surfsense_local/frontend/src/features/sources/tree/source-tree.tsx
  • surfsense_local/frontend/src/features/sources/use-sources.ts
  • surfsense_local/frontend/src/features/speech-setup/speech-model-card.tsx
  • surfsense_local/frontend/src/features/speech-setup/speech-setup-popover.test.tsx
  • surfsense_local/frontend/src/features/speech-setup/speech-setup-popover.tsx
  • surfsense_local/frontend/src/features/speech-setup/use-speech-setup.ts
  • surfsense_local/frontend/src/features/workspaces/read-sse.ts
  • surfsense_local/frontend/src/features/workspaces/use-workspace-changes.ts
  • surfsense_local/frontend/src/features/workspaces/workspace-changes.test.ts
  • surfsense_local/frontend/src/features/workspaces/workspace-changes.ts
  • surfsense_local/frontend/src/lib/api.ts
  • surfsense_local/frontend/translations/de.json
  • surfsense_local/frontend/translations/en.json
  • surfsense_local/frontend/translations/es.json
  • surfsense_local/frontend/translations/fr.json
  • surfsense_local/frontend/translations/hi.json
  • surfsense_local/frontend/translations/ja.json
  • surfsense_local/frontend/translations/ko.json
  • surfsense_local/frontend/translations/pt-BR.json
  • surfsense_local/frontend/translations/ru.json
  • surfsense_local/frontend/translations/zh-CN.json

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

  • Autofix · Keep fixing CodeRabbit findings and required CI, and resolving merge conflicts

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.

…gnition

dev took ADR 0055 for the plugin registry's Verified rule, so the speech
recognition record is now 0056, Accepted with the change it decides, and
every link to it follows. Its 'Where the code stands' section went: the
code now holds the whole decision.
… match v1

After dev's one-click ChatGPT sign-in, the local-only speech section must
still offer no remote model; a test now proves it with a plan that claims
the slot, and fails without the localOnly gate. The docs drop two stale
Known gaps (no speech screen; no transcription test), name the settings nav
as it reads, count migrations to 0030, mark steps 2 to 7 done in the
proposal, and note the curated recogniser's type in the catalog proposal.
AnishSarkar22 pushed a commit to AnishSarkar22/SurfSense that referenced this pull request Oct 16, 2026
The 2.2.2 entry for the website's changelog: sources ready in seconds
(MODSetter#2289), speech recognition for recordings and voice input (MODSetter#2264, MODSetter#2273),
plugin search, permissions, licensing and speed (MODSetter#2256, MODSetter#2260, MODSetter#2261,
MODSetter#2262, MODSetter#2270, MODSetter#2291), ChatGPT plan sign-in (MODSetter#2259), MP3 podcasts (MODSetter#2183),
and the fixes, known issues and developer notes since v2.2.1.

This branch was successfully deployed

1 active deployment
Preview — 531c1f68 Deployed Oct 14, 2026 by vercel[bot]
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.

1 participant