Repository navigation
feat(local): local speech recognition, for recordings as sources and voice input - #2264
Merged
Merged
Conversation
…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.
|
The latest updates on your projects. Learn more about Vercel for GitHub.
|
|
Important Review skippedToo 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
⛔ Files ignored due to path filters: 6
📒 Files selected for processing: 316
You can disable this status message by setting the
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. Comment |
…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.
This was referenced Oct 15, 2026
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
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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..wav .mp3 .flac .ogg .ogauploads become documents.audiocpp_server.[hh:mm:ss]paragraphs) is stored ascontent, so chunking, search, citations and the agent read it unchanged.DictationAdapteris used; nothing goes to Google's Web Speech.nemotron_asrcompiled in. It has its own config, port, watcher and crash restart, and only the API calls it (ADR 0056).Q8_0(930.6 MB, OpenMDW-1.1, 26 languages at ≤30% FLEURS error), downloaded on request.speech_to_textslot (local only, migration 0030), the language setting, and a set-up popover shared by the mic and a waiting recording.mediais granted only to the main window's own page, for audio only.audio-inputentitlement andNSMicrophoneUsageDescription.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 (
featthenfix), 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:
surfsense_local/electron/audiocpp/. Staging never rebuilds over an existing one, and the current one has nonemotron_asr.uv syncinsurfsense_local/backend(addsminiaudio).pnpm build:vadinsurfsense_local/electron(stages Silero).Then
pnpm dev:.mp3into the sources. The row shows progress and then the transcript.Run on the merged branch (Windows 11, Ryzen 7 5800X):
plugins/bundlesand fail the same way on the base on Windows: CRLF output,os.killpg, aFileExistsError.model_route/test_studio.py::test_a_request_queued_past_the_silence_limit_still_completeswas deselected; it hangs on this machine on the base too.Q8_0app-bootstrap.test.tsxtimed out once under load and passes 8 of 8 alone; the branch doesn't touch it.translations:verifysecret.test.tsfile mode, the llama.cpp licence CRLF test,opencode-leftoverskill ESRCH.uv lock --check,check_docs.pyNot done in this PR
x-apple.systempreferencesaddress for the microphone pane is unchecked.file://) was checked only in dev Chromium over http.in-progress. Folding v1 intoarchitecture/and moving steps 8 to 11 into a follow-up proposal waits for those measurements.watch-audio-models.ts(the voices' watcher) has the same restart race that this PR fixed in the new watcher.