Repository navigation
fix: blocking-bug and silence-warning fixes - #38
Conversation
The warning cleared only when 25% of the last 8s held speech; that window is near-empty when the warning fires, so it stayed up for 2-3s after speech resumed. Clear on 5 of the last 10 ticks and restart the warn window on a clear so it does not re-fire on the next tick. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Feed ran under the recording lock and did a blocking send into the sender queue. On a network stall the websocket write (no deadline) blocked, the queue filled and Feed hung, leaving isRecording stuck and the hotkey dead. Feed now enqueues without blocking and fails the stream on a full queue (the PCM is still kept for saving). Each websocket write has its own timeout. Close aborts a failed stream at once, bounds the wait for the sender, and no longer blocks on audioCh. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
energye/systray applies every menu change with performSelectorOnMainThread:waitUntilDone:YES. RefreshDevices ran on the device-watcher goroutine and held trayMu for the whole render, while menu click handlers run on the main thread and take trayMu: a lock cycle that froze the app for good. RefreshDevices now swaps state under trayMu and draws the menu after releasing it, serialized by its own lock (it has two callers). The device click handlers and the onReady menu build (also off the main thread) follow the same rule. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
handleRecording calls NewSession before rec.Start(), and the local NewSession blocked in load() (model read + warm-up) while the beep and overlay already said Recording, so speech during a load was lost. NewSession now returns at once and the session waits for the engine in Close. A missing model file still fails fast in NewSession. The model resolution (foreign-ID fallback, presence) moves into resolve() so both paths share it. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The device watcher called applyDeviceSwitch / switchDeviceByName, which Close() the live capture device, with no recording guard (tray switches have one). A mic plugged in or out mid-dictation swapped the device out from under the session. While a cycle is active the watcher now skips the tick and leaves its last-seen list stale, so the first tick after the cycle sees the change and applies it (and refreshes the tray menu then). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
In toggle mode WaitStop waited for the next tap. When the session ended another way (silence auto-close, tray Stop Recording, an error), listenHotkey was still parked there and took the user's next press, meant to start a new dictation, as the stop tap: that dictation was lost. recSession now carries an Ended channel that recordSessions closes when the recording phase ends; WaitStop returns on it, and listenHotkey skips requestStop for a session that already ended (stopCh may belong to a newer one by then). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
With no input device at login (a Mac mini or Studio with no mic), ctx.NewCapture(nil, ...) failed and the app called fatal and exited. The darwin NewCapture now logs a warning and returns the capture with no device; malgoCapture.Start already builds it on first use and reports the error if there is still no mic. The Context interface documents this (the Linux backend was already lazy). main also registers the tray device callback when no mic is attached, so the items the watcher adds later work. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
|
Upsun Dispatch™ did not run this request: no model provider API key is registered for this organization. Register one in settings, then trigger again. |
There was a problem hiding this comment.
Warning
Changes suggested — 🟡 1 warning · 🔵 1 minor point
🔍 Full review · 16 files reviewed
Verification
- The stream dial-failure path in
Closerelies ons.wsbeing nil, and that is safe:wsis assigned beforeclose(connected), so reading it after<-s.connectedis ordered after the write. - The stall test's sender unblocks:
Closecallsws.Close()whensessionErris set, so the blockedSendreturns andsendDonecloses before the receiver wait. close(sess.Ended)runs once per session inrecordSessions.testenv.gocallshandleRecordingdirectly with a nilEnded, and that is safe becausehandleRecordingnever readsEnded.localSession.Closewaits for the model throughreadyEngine/load(), which is idempotent, and on a load error it still returns the WAVAudioData, so auto-save keeps the audio.RefreshDevices,checkDeviceandonReadycopydeviceItems/modelsundertrayMuand release it before any systray call.
New unit tests cover the silence clear and re-warn (silence_test.go), the stream stall and the bounded Close (stream_session_test.go), the slow local model load (parakeet_async_test.go), and WaitStop with ended and the listenHotkey tray-stop flow. CI's make test (ci.yml) runs them. The tray lock changes, hotplug deferral and no-mic NewCapture have no automated coverage.
Review details
- Commit: cc3dbe7
- Model: claude-opus-5-5
Review 1 of 10 for this pull request · View the full run
| // A switch closes the live capture device, so it waits for the | ||
| // cycle to end, like a tray switch. last is left stale on purpose: | ||
| // the first tick after the cycle sees the change and applies it. | ||
| if isRecording.Load() { |
There was a problem hiding this comment.
🟡 Warning — A hotplug that lands at the same moment as a press can still break the recording and leak a CoreAudio device.
The hotplug deferral checks isRecording.Load() and then runs the switch later, with no lock that tryStartSession also takes. A press can land between the check (or the captureMu snapshot) and applyDeviceSwitch. When that happens, recordSessions calls getCapture() and gets the old device, and the watcher then calls (*captureDevice).Close() on that same device while handleRecording is starting or using it. On darwin, Close sets c.device to nil and Start then rebuilds a malgo device on a capture object that captureDevice no longer references, so nothing ever closes it. If the switch happened because the selected mic vanished, the recording goes to the vanished device and fails. So the fix makes the swap much less likely but does not stop the mic from being swapped mid-recording.
| time.Sleep(streamFinalizeIdle) | ||
| case <-time.After(streamFinalizeMax): | ||
| case <-s.sendDone: | ||
| case <-time.After(streamDrainMax): |
There was a problem hiding this comment.
🔵 Minor — Dictation on slow but working connections becomes an error and an auto-save instead of a pasted transcript.
Close now gives even a healthy sender only streamDrainMax (5 s) to flush audioCh, then records an error and closes the websocket. The queue can legitimately hold up to 128 × 200 ms chunks (25.6 s of 256 kbps linear16) without being treated as stalled. On a slow uplink that keeps up at less than real time but never stalls, the backlog at release can take more than 5 s to send. That session is now failed with "stream send did not finish", even though the connection was working and Deepgram had already returned most of the transcript. The old code waited for it to finish.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
There was a problem hiding this comment.
Note
Reviewed — No new blocking findings · 🔵 1 minor point · 2 still open
🔍 Full review · 16 files reviewed
🔵 Minor point
transcriber/local_session.go:81—localSession.Closenow resolves the engine withs.engine()(which runsp.load()and waits out an in-flight model load) for any non-empty buffer.handleRecording(main.go, therec.totalFrames < encoder.SampleRate/10branch) callstSess.Close()synchronously to throw away a too-short tap, before it returns and beforerecordSessionsclosessess.Endedor setsisTranscribing. Trigger: a tap that captures 1–1599 frames while a local model is still loading at startup or after a switch. The discarded clip then blocks the cycle for the whole load and runs a full inference on audio that is never used. Meanwhile the overlay still says Recording andisRecordingstays set. BecauseisTranscribingis false, a press in that window goes torequestStop()(a no-op) and the user gets no denied beep. Before this change the wait happened in NewSession and was not attached to this discard path.
Outstanding from earlier reviews:
- 🟡 #4324061383 —
main.go:702: A hotplug that lands at the same moment as a press can still break the recording and leak a CoreAudio device. — The watcher still checksisRecording.Load()and then callsapplyDeviceSwitchwith no lock shared withtryStartSession, so a press can still land between the two and the device gets closed while that recording is starting. - 🔵 #4324061387 —
transcriber/stream_session.go:207: Dictation on slow but working connections becomes an error and an auto-save instead of a pasted transcript. —Closestill gives a healthy sender onlystreamDrainMax(5 s) to flush its queue before it fails the session. The tail enqueue in Close also fails a slow but healthy stream aserrStreamStalledwhen the queue is full.
Verification
close(sess.Ended)runs right afterhandleRecordingreturns, on every path including errors. EveryrecSessionthatrecordSessionsreceives is built with a non-nilEnded, intryStartSessionand in the tests.streamSession.Feedkeeps every byte inpcmBufbefore the stall check, soattachAudiostill returns the full PCM aftererrStreamStalled.checkDevice,RefreshDevicesandonReadynow only snapshot or swapdeviceItems/modelItemsundertrayMu, and they make no systray call while holding it.resolvekeeps the old fallback from another engine's ID todefaultID, andloadwrites the fallback ID back intomodelID, as the inline code did before.
New unit tests cover the silence clear and re-arm (silence_test.go), WaitStop's ended channel (hotkey_test.go), the listenHotkey tray stop, a stream stall and a bounded Close (stream_session_test.go), and NewSession not blocking on a load (parakeet_async_test.go). No test covers the tray lock changes, the hotplug deferral, the darwin no-mic NewCapture path, or the short-tap discard path that now waits on the model load.
Review details
- Commit: a2fa903
- Model: claude-opus-5-5
Review 2 of 10 for this pull request · View the full run
Fixes seven robustness bugs, one commit each.
Feednever blocks, each websocket write has a timeout,Closeis time-bounded, and the full audio is still kept for the auto-save.trayMuis held. systray waits on the main thread, and menu click handlers on the main thread taketrayMu.NewSessionreturns at once, so capture starts immediately; the session waits for the engine inClose.WaitStopnow returns when the session ends.New unit tests cover the silence clear, the stream stall, the slow local model load and toggle-stop handling. Tray, hotplug and no-mic changes need real hardware and were not tested by hand. Rejected alternatives are recorded in
docs/design-notes.md.🤖 Generated with Claude Code