Skip to content
3 changes: 3 additions & 0 deletions audio/audio.go
Original file line number Diff line number Diff line change
Expand Up @@ -145,6 +145,9 @@ type DeviceInfo struct {

type Context interface {
Devices() ([]DeviceInfo, error)
// NewCapture does not fail because no input device can be opened yet (no
// mic attached): the device is then opened at Start, and Start reports
// the error if there is still none.
NewCapture(device *DeviceInfo, config CaptureConfig) (CaptureDevice, error)
Close()
}
Expand Down
5 changes: 3 additions & 2 deletions audio/capture_darwin.go
Original file line number Diff line number Diff line change
Expand Up @@ -79,9 +79,10 @@ func (m *malgoContext) NewCapture(device *DeviceInfo, config CaptureConfig) (Cap
err := c.initDevice()
deviceMu.Unlock()
if err != nil {
return nil, err
// No usable input right now — e.g. a Mac mini or Studio with no mic
// attached at login. Not fatal: Start builds the device on first use.
log.Warnf("capture device init failed (%v); retrying at the first recording", err)
}

return c, nil
}

Expand Down
47 changes: 47 additions & 0 deletions docs/design-notes.md
Original file line number Diff line number Diff line change
Expand Up @@ -2203,3 +2203,50 @@ clip" in the Qwen entry above, may be English speech. whisper `-l en`,
Nemotron and NeMo-Speech give the same coherent English, and whisper-auto's
Turkish loops. If so, Qwen did not translate; whisper-auto did. Listen to
the clip before editing that entry.

## The silence warning clears on a 1 s window, not the 8 s one that raised it (2026-10-20)

The warning fires when under 10% of the last 8 s (80 × 100 ms ticks) holds
speech. It used to clear when that same 8 s window reached 25%. At the moment
it fires the window is near-empty, so clearing needed 20 speech ticks — at
least 2 s of unbroken speech, about 3 s with normal pauses. The rule dated from
February, but it only became noticeable once the overlay (2026-07-25) put the
"Silent" label in front of the user while its level bars, gated on a 250 ms
VAD hold, visibly reacted at once.

Now: clear when 5 of the last 10 ticks hold speech (0.5 s minimum). A lone VAD
false positive is 1 of 10 and does not clear. On a clear the 8 s warn window
restarts — otherwise the few speech ticks that cleared it leave the 8 s ratio
under 10% and the warning re-fires (with its error beep) on the next tick.

## A stalled stream fails the session; it never blocks capture or drops chunks (2026-10-20)

`streamSession.Feed` runs on the capture callback, under the recording lock.
It used to hand chunks to the sender with a blocking channel send. On a network
stall the websocket write blocked (the stream context has no deadline), the
128-chunk queue filled, and Feed blocked for good: `isRecording` stayed true and
the hotkey was dead until restart.

Now each write has its own timeout, Feed never blocks, and a full queue fails the
stream. Close aborts a failed stream at once and gives a healthy one a bounded
time to flush. Rejected: dropping chunks on a full queue and carrying on. That
would paste a transcript with silent holes in it. A failed stream instead takes
the existing error path, and the full PCM (kept regardless of stream health) is
auto-saved to `samples/` for a retry.

## A local session waits for the model in Close, not NewSession (2026-10-20)

`localProvider.NewSession` used to wait out the model load (read + warm-up)
before it returned. `handleRecording` calls it before `rec.Start()`, so while a
model loaded at startup or after a switch, the start beep and overlay said
"Recording" but no audio was captured, and the speech was lost. The comment
saying "recording overlaps the load" was wrong.

Now NewSession returns at once and the session resolves the engine in Close.
Only a model file missing from disk still fails in NewSession, because no
recording could be transcribed with it. Rejected: starting a `go p.load()` from
NewSession to also overlap loads that no background warm started (after a
failed load, or after `Close`). That goroutine can outlive the session and get
`loadMu` after a provider switch has called `Close`. It would then load the old
provider's model again, and nothing would ever free it. Those rare cases load
in Close instead.
16 changes: 12 additions & 4 deletions hotkey/hotkey.go
Original file line number Diff line number Diff line change
Expand Up @@ -47,7 +47,7 @@ func AwaitRecord(hk Hotkey, longPress, timeout time.Duration) (stop <-chan struc
}
stopCh := make(chan struct{})
go func() {
WaitStop(hk, longPress, nil)
WaitStop(hk, longPress, nil, nil)
close(stopCh)
}()
return stopCh, true
Expand All @@ -60,11 +60,16 @@ func AwaitRecord(hk Hotkey, longPress, timeout time.Duration) (stop <-chan struc
// (the app uses it to arm silence auto-close). Shared by the main record loop
// and `zee doctor`, so both behave identically.
//
// ended (optional; nil never fires) closes when the recording stops some other
// way — silence auto-close, the tray, an error. Toggle mode then returns at
// once instead of waiting for a tap: otherwise the user's next press, meant to
// start a new recording, would be eaten as the stop tap of the finished one.
//
// It returns how long keydown→keyup took as observed (downToUp) and whether it
// resolved to toggle mode — surfaced for diagnostics: a downToUp far above
// longPress on a quick tap means the keyup event arrived late (a stalled run
// loop), which is what makes a tap misfire as a hold.
func WaitStop(hk Hotkey, longPress time.Duration, onToggle func()) (downToUp time.Duration, toggled bool) {
func WaitStop(hk Hotkey, longPress time.Duration, ended <-chan struct{}, onToggle func()) (downToUp time.Duration, toggled bool) {
start := time.Now()
timer := time.NewTimer(longPress)
defer timer.Stop()
Expand All @@ -77,8 +82,11 @@ func WaitStop(hk Hotkey, longPress time.Duration, onToggle func()) (downToUp tim
if onToggle != nil {
onToggle()
}
<-hk.Keydown()
<-hk.Keyup()
select {
case <-hk.Keydown():
<-hk.Keyup()
case <-ended:
}
return downToUp, true
}
}
Expand Down
46 changes: 46 additions & 0 deletions hotkey/hotkey_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ package hotkey
import (
"errors"
"testing"
"time"
)

func TestDefaultComboValid(t *testing.T) {
Expand Down Expand Up @@ -66,3 +67,48 @@ func TestComboEqual(t *testing.T) {
t.Fatal("different mods should not be equal")
}
}

// TestWaitStopTogglePress: in toggle mode the next tap stops the recording.
func TestWaitStopTogglePress(t *testing.T) {
hk := NewFake()
done := make(chan bool, 1)
go func() {
_, toggled := WaitStop(hk, time.Hour, nil, nil)
done <- toggled
}()
hk.SimKeyup() // short tap → toggle mode
hk.SimKeydown()
hk.SimKeyup()
select {
case toggled := <-done:
if !toggled {
t.Fatal("a short tap should resolve to toggle mode")
}
case <-time.After(time.Second):
t.Fatal("WaitStop did not return on the stop tap")
}
}

// TestWaitStopToggleEndedElsewhere: a toggle recording that ends another way
// (silence auto-close, tray stop, error) must release WaitStop without
// consuming a key event — the next press belongs to a new recording.
func TestWaitStopToggleEndedElsewhere(t *testing.T) {
hk := NewFake()
ended := make(chan struct{})
done := make(chan struct{})
go func() {
WaitStop(hk, time.Hour, ended, nil)
close(done)
}()
hk.SimKeyup() // short tap → toggle mode
close(ended)
select {
case <-done:
case <-time.After(time.Second):
t.Fatal("WaitStop still waiting for a stop tap after the recording ended")
}
hk.SimKeydown()
if len(hk.Keydown()) != 1 {
t.Fatal("the next press was consumed by the finished recording")
}
}
67 changes: 41 additions & 26 deletions main.go
Original file line number Diff line number Diff line change
Expand Up @@ -96,7 +96,8 @@ func modelSupportsStream(tr transcriber.Transcriber) bool {
type recSession struct {
Stop <-chan struct{}
SilenceClose *atomic.Bool
PressedAt time.Time // when the press was accepted; drives the reflex-latency metric
PressedAt time.Time // when the press was accepted; drives the reflex-latency metric
Ended chan struct{} // closed by recordSessions when the recording phase ends, however it ends
}

type recordingConfig struct {
Expand Down Expand Up @@ -464,22 +465,23 @@ func run() {
// then).
preferredDevice := *deviceFlag
tray.SetBTCheck(audio.IsBluetooth)
if devices, err := ctx.Devices(); err == nil && len(devices) > 0 {
tray.SetDevices(deviceNames(devices), preferredDevice, func(name string) {
if guardBusy("Can't change the microphone while recording or transcribing.") {
return
}
captureMu.Lock()
preferredDevice = name
captureMu.Unlock()
config.Update(func(s *config.Settings) { s.Device = name })
if name == "" {
applyDeviceSwitch(ctx, captureConfig, &captureDevice, &selectedDevice, nil)
} else {
switchDeviceByName(ctx, captureConfig, &captureDevice, &selectedDevice, name)
}
})
}
// Registered even with no mic attached yet: the device watcher fills the
// menu in when one appears, and its items need this callback to work.
attached, _ := ctx.Devices()
tray.SetDevices(deviceNames(attached), preferredDevice, func(name string) {
if guardBusy("Can't change the microphone while recording or transcribing.") {
return
}
captureMu.Lock()
preferredDevice = name
captureMu.Unlock()
config.Update(func(s *config.Settings) { s.Device = name })
if name == "" {
applyDeviceSwitch(ctx, captureConfig, &captureDevice, &selectedDevice, nil)
} else {
switchDeviceByName(ctx, captureConfig, &captureDevice, &selectedDevice, name)
}
})
tray.SetAutoPaste(autoPaste)

var trayModels []tray.Model
Expand Down Expand Up @@ -694,6 +696,12 @@ func run() {
if slices.Equal(last, names) {
continue
}
// 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.

continue
}
last = names
// Snapshot the device state under captureMu, then release before the
// switch calls (which re-lock it). preferredDevice is also written by
Expand Down Expand Up @@ -961,10 +969,10 @@ func denyBusy(warning string) {

// tryStartSession enqueues a fresh recording session unless a cycle is already
// active (recording OR still transcribing) — in which case guardBusy denies it.
// Returns the SilenceClose handle when it started a session, nil when it denied.
// Returns the session it started, nil when it denied.
// The hotkey and the tray "Start Recording" button both funnel through here, so
// neither can queue an unattended recording that fires the instant inference ends.
func tryStartSession(sessions chan<- recSession) *atomic.Bool {
func tryStartSession(sessions chan<- recSession) *recSession {
// Claiming the cycle IS the guard: a plain check-then-send is not atomic
// (isRecording only went true once recordSessions picked the session up), so
// a hotkey press and a tray click landing together could both pass and both
Expand All @@ -984,10 +992,10 @@ func tryStartSession(sessions chan<- recSession) *atomic.Bool {
if t, ok := tr.(transcriber.Toucher); ok {
go t.Touch()
}
sc := &atomic.Bool{}
audio.PlayStart() // reflexive: sound the press now, not after the record loop spins up (playOne is non-blocking)
sessions <- recSession{Stop: resetStop(), SilenceClose: sc, PressedAt: time.Now()}
return sc
sess := recSession{Stop: resetStop(), SilenceClose: &atomic.Bool{}, PressedAt: time.Now(), Ended: make(chan struct{})}
sessions <- sess
return &sess
}

// recordSessions is the core record→transcribe loop, shared by the live app and
Expand All @@ -1009,6 +1017,7 @@ func recordSessions(getCapture func() audio.CaptureDevice, sessions <-chan recSe
overlay.Show() // every path — hotkey, toggle, tray — funnels through here

done, err := handleRecording(capture, sess)
close(sess.Ended)
if err != nil {
log.Errorf("recording error: %v", err)
tray.SetError(err.Error())
Expand Down Expand Up @@ -1041,21 +1050,27 @@ func listenHotkey(hk hotkey.Hotkey, longPress time.Duration, sessions chan<- rec
}
continue
}
sc := tryStartSession(sessions)
if sc == nil {
sess := tryStartSession(sessions)
if sess == nil {
log.HotkeyPress(0, "denied")
<-hk.Keyup() // denied (a cycle began between the guard above and here)
continue
}
// Shared press semantics (hold vs toggle) — see hotkey.WaitStop. Toggle
// mode arms silence auto-close the moment it is entered.
downToUp, toggled := hotkey.WaitStop(hk, longPress, func() { sc.Store(true) })
downToUp, toggled := hotkey.WaitStop(hk, longPress, sess.Ended, func() { sess.SilenceClose.Store(true) })
mode := "hold"
if toggled {
mode = "toggle"
}
log.HotkeyPress(float64(downToUp.Milliseconds()), mode)
requestStop()
// A recording that already ended needs no stop — and stopCh may belong
// to a newer session by now.
select {
case <-sess.Ended:
default:
requestStop()
}
}
}

Expand Down
34 changes: 20 additions & 14 deletions main_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -49,7 +49,7 @@ func TestRecordSessionsBlocksDuringInference(t *testing.T) {

// Start a recording, let it capture briefly, then stop it (as a keyup would)
// so the 800ms "inference" begins.
sessions <- recSession{Stop: resetStop(), SilenceClose: &atomic.Bool{}}
sessions <- recSession{Stop: resetStop(), SilenceClose: &atomic.Bool{}, Ended: make(chan struct{})}
time.Sleep(150 * time.Millisecond)
requestStop()

Expand Down Expand Up @@ -120,7 +120,7 @@ func TestRecordSessionsPicksUpDeviceSwitch(t *testing.T) {

runSession := func() {
want := atomic.LoadInt32(&cycles) + 1
sessions <- recSession{Stop: resetStop(), SilenceClose: &atomic.Bool{}}
sessions <- recSession{Stop: resetStop(), SilenceClose: &atomic.Bool{}, Ended: make(chan struct{})}
time.Sleep(120 * time.Millisecond)
requestStop()
deadline := time.Now().Add(3 * time.Second)
Expand Down Expand Up @@ -151,6 +151,10 @@ func TestRecordSessionsPicksUpDeviceSwitch(t *testing.T) {
}
}

// TestListenHotkey_TrayStopNoStaleSignal: a toggle recording ended from the
// tray must leave the hotkey ready — the very next tap starts a new session
// (it used to be eaten as the stop tap of the finished one), and that session
// is not stopped by a stale signal from the old one.
func TestListenHotkey_TrayStopNoStaleSignal(t *testing.T) {
hk := hotkey.NewFake()
sessions := make(chan recSession, 3)
Expand All @@ -162,37 +166,39 @@ func TestListenHotkey_TrayStopNoStaleSignal(t *testing.T) {
isRecording.Store(false)
hk.SimKeydown()
sess1 := <-sessions
isRecording.Store(true)
time.Sleep(10 * time.Millisecond)
hk.SimKeyup()

// 2. Tray stop ends the recording externally
// 2. Tray stop ends the recording externally; recordSessions closes Ended
// and releases the cycle.
requestStop()
isRecording.Store(false)
select {
case <-sess1.Stop:
case <-time.After(time.Second):
t.Fatal("tray stop did not end session")
}
close(sess1.Ended)
isRecording.Store(false)
time.Sleep(20 * time.Millisecond) // let listenHotkey leave WaitStop

// 3. Still in toggleRecording — this tap transitions back to idle
hk.SimKeydown()
hk.SimKeyup()
time.Sleep(20 * time.Millisecond) // let state machine settle

// 4. New tap should start a session that stays alive
// 3. The next tap starts a new session at once
hk.SimKeydown()
sess2 := <-sessions
isRecording.Store(true)
var sess2 recSession
select {
case sess2 = <-sessions:
case <-time.After(time.Second):
t.Fatal("first tap after a tray stop was swallowed instead of starting a recording")
}
time.Sleep(10 * time.Millisecond)
hk.SimKeyup()

select {
case <-sess2.Stop:
t.Fatal("new session immediately stopped by stale stopCh signal")
case <-time.After(300 * time.Millisecond):
// session stayed alive — fix works
// session stayed alive
}
isRecording.Store(false)
}

func TestListenHotkey_StopsTrayRecording(t *testing.T) {
Expand Down
Loading
Loading