Skip to content

fix: blocking-bug and silence-warning fixes - #38

Merged
sumerc merged 8 commits into
mainfrom
bug-improvements
Oct 23, 2026
Merged

sumerc merged 8 commits into
mainfrom
bug-improvements

Conversation

@sumerc

@sumerc sumerc commented Oct 20, 2026

Copy link
Copy Markdown
Owner

Fixes seven robustness bugs, one commit each.

  • Silence warning clears faster. The "no voice" warning cleared only when 25% of the last 8 s held speech, so it stayed up 2–3 s after speech resumed. It now clears on 5 of the last 10 ticks, and the warn window restarts on a clear.
  • Stalled Deepgram stream no longer hangs the app. Feed never blocks, each websocket write has a timeout, Close is time-bounded, and the full audio is still kept for the auto-save.
  • No tray deadlock. No systray call runs while trayMu is held. systray waits on the main thread, and menu click handlers on the main thread take trayMu.
  • No lost speech while a local model loads. NewSession returns at once, so capture starts immediately; the session waits for the engine in Close.
  • Hotplug no longer swaps the mic mid-recording. The device watcher defers switches until the recording cycle ends.
  • First press after a toggle recording ended elsewhere is no longer swallowed. After a silence auto-close, a tray stop or an error, WaitStop now returns when the session ends.
  • No exit at startup without a microphone. The capture device opens on first use.

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

sumerc and others added 7 commits October 20, 2026 11:16
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

Copy link
Copy Markdown

Upsun Dispatch™ did not run this request: no model provider API key is registered for this organization.

Register one in settings, then trigger again.

@upsun-dispatch-dev upsun-dispatch-dev Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Warning

Changes suggested — 🟡 1 warning · 🔵 1 minor point

🔍 Full review · 16 files reviewed

Verification
  • The stream dial-failure path in Close relies on s.ws being nil, and that is safe: ws is assigned before close(connected), so reading it after <-s.connected is ordered after the write.
  • The stall test's sender unblocks: Close calls ws.Close() when sessionErr is set, so the blocked Send returns and sendDone closes before the receiver wait.
  • close(sess.Ended) runs once per session in recordSessions. testenv.go calls handleRecording directly with a nil Ended, and that is safe because handleRecording never reads Ended.
  • localSession.Close waits for the model through readyEngine/load(), which is idempotent, and on a load error it still returns the WAV AudioData, so auto-save keeps the audio.
  • RefreshDevices, checkDevice and onReady copy deviceItems/models under trayMu and 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

Comment thread main.go
// 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() {

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🟡 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):

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🔵 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>

@upsun-dispatch-dev upsun-dispatch-dev Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

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.Close now resolves the engine with s.engine() (which runs p.load() and waits out an in-flight model load) for any non-empty buffer. handleRecording (main.go, the rec.totalFrames < encoder.SampleRate/10 branch) calls tSess.Close() synchronously to throw away a too-short tap, before it returns and before recordSessions closes sess.Ended or sets isTranscribing. 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 and isRecording stays set. Because isTranscribing is false, a press in that window goes to requestStop() (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 checks isRecording.Load() and then calls applyDeviceSwitch with no lock shared with tryStartSession, 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. — Close still gives a healthy sender only streamDrainMax (5 s) to flush its queue before it fails the session. The tail enqueue in Close also fails a slow but healthy stream as errStreamStalled when the queue is full.
Verification
  • close(sess.Ended) runs right after handleRecording returns, on every path including errors. Every recSession that recordSessions receives is built with a non-nil Ended, in tryStartSession and in the tests.
  • streamSession.Feed keeps every byte in pcmBuf before the stall check, so attachAudio still returns the full PCM after errStreamStalled.
  • checkDevice, RefreshDevices and onReady now only snapshot or swap deviceItems/modelItems under trayMu, and they make no systray call while holding it.
  • resolve keeps the old fallback from another engine's ID to defaultID, and load writes the fallback ID back into modelID, 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

@sumerc
sumerc merged commit ad84f83 into main Oct 23, 2026
3 checks passed
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