Skip to content

Commit 5a0d40a

Browse files
sangmin7648claude
andauthored
fix: notify Go when SCStream stops unexpectedly and auto-restart speaker source (#5)
When macOS stops the ScreenCaptureKit stream (screen lock, display change, system error), didStopWithError: was only logging — the Go channel stayed open but received no data, causing silent hang and the menu-bar indicator to disappear. Changes: - speaker_darwin.m: call tacitSpeakerStoppedCallback from didStopWithError: when the stream is not already in a deliberate-stop path (self.stopped) - speaker_darwin.h: declare tacitSpeakerStoppedCallback - speaker_darwin.go: replace bare chan[]int16 cgo handle with speakerChan (data channel + sync.Once + self-referencing handle) so both the unexpected-stop callback and the ctx.Done() goroutine can safely close the channel exactly once; add tacitSpeakerStoppedCallback export - pipeline.go: split runSource into runSource (retry loop) + runSourceOnce so that an unexpected stream closure triggers automatic restart after 5s instead of silently stopping the pipeline https://claude.ai/code/session_01LRhvpcLHSUZT1fz9XvbPny Co-authored-by: Claude <noreply@anthropic.com>
1 parent bb9309f commit 5a0d40a

4 files changed

Lines changed: 87 additions & 19 deletions

File tree

pkg/capture/speaker_darwin.go

Lines changed: 47 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ import (
1616
"fmt"
1717
"log"
1818
"runtime/cgo"
19+
"sync"
1920
"unsafe"
2021
)
2122

@@ -35,41 +36,56 @@ func NewSpeaker() (*Speaker, error) {
3536
return &Speaker{}, nil
3637
}
3738

39+
// speakerChan wraps the audio channel with a once-guarded close so both the
40+
// context-cancellation path and the unexpected-stop callback can safely signal
41+
// the pipeline without a double-close panic.
42+
type speakerChan struct {
43+
data chan []int16
44+
handle cgo.Handle // points back to itself; used for self-deletion
45+
once sync.Once
46+
}
47+
48+
func (sc *speakerChan) closeAndFree() {
49+
sc.once.Do(func() {
50+
close(sc.data)
51+
sc.handle.Delete()
52+
})
53+
}
54+
3855
// Stream starts system-audio capture and returns a channel of int16 chunks.
39-
// The channel is closed when ctx is cancelled.
56+
// The channel is closed when ctx is cancelled or when SCStream stops unexpectedly.
4057
func (s *Speaker) Stream(ctx context.Context) (<-chan []int16, error) {
41-
ch := make(chan []int16, 128)
42-
43-
// Register the channel in the cgo handle registry.
44-
handle := cgo.NewHandle(ch)
58+
sc := &speakerChan{data: make(chan []int16, 128)}
59+
// Register sc in the cgo handle registry; the handle value is stored in sc
60+
// itself so the ObjC callbacks can call closeAndFree via a single pointer.
61+
sc.handle = cgo.NewHandle(sc)
4562

4663
var errCStr *C.char
47-
cap := C.speaker_create(C.uintptr_t(handle), &errCStr)
64+
cap := C.speaker_create(C.uintptr_t(sc.handle), &errCStr)
4865
if errCStr != nil {
4966
msg := C.GoString(errCStr)
5067
C.free(unsafe.Pointer(errCStr))
51-
handle.Delete()
52-
close(ch)
68+
sc.closeAndFree()
5369
return nil, fmt.Errorf("speaker capture: %s", msg)
5470
}
5571
if cap == nil {
56-
handle.Delete()
57-
close(ch)
72+
sc.closeAndFree()
5873
return nil, fmt.Errorf("speaker capture: unknown error")
5974
}
6075

6176
s.cap = cap
6277

6378
go func() {
6479
<-ctx.Done()
80+
// Set output.stopped = YES before stopping so didStopWithError won't
81+
// fire the Go callback again after we deliberately stop the stream.
6582
C.speaker_stop(cap)
6683
s.cap = nil
67-
handle.Delete()
68-
close(ch)
84+
sc.closeAndFree()
6985
}()
7086

7187
log.Printf("System audio capture started (ScreenCaptureKit, 16kHz mono)")
72-
return ch, nil
88+
return sc.data, nil
7389
}
7490

7591
// Close stops capture and releases resources if Stream was called.
@@ -86,7 +102,7 @@ func (s *Speaker) Close() {
86102
//export tacitSpeakerSamplesCallback
87103
func tacitSpeakerSamplesCallback(h C.uintptr_t, samples *C.int16_t, count C.int) {
88104
handle := cgo.Handle(h)
89-
ch, ok := handle.Value().(chan []int16)
105+
sc, ok := handle.Value().(*speakerChan)
90106
if !ok {
91107
return
92108
}
@@ -97,8 +113,24 @@ func tacitSpeakerSamplesCallback(h C.uintptr_t, samples *C.int16_t, count C.int)
97113
copy(dst, buf)
98114

99115
select {
100-
case ch <- dst:
116+
case sc.data <- dst:
101117
default:
102118
// Drop frame if the pipeline is slow — prevents audio thread stall.
103119
}
104120
}
121+
122+
// tacitSpeakerStoppedCallback is called from Objective-C when SCStream stops
123+
// unexpectedly (not due to an explicit speaker_stop call). Closing the channel
124+
// unblocks the pipeline's "for chunk := range stream" loop so it can detect
125+
// the outage and restart if desired.
126+
//
127+
//export tacitSpeakerStoppedCallback
128+
func tacitSpeakerStoppedCallback(h C.uintptr_t) {
129+
handle := cgo.Handle(h)
130+
sc, ok := handle.Value().(*speakerChan)
131+
if !ok {
132+
return
133+
}
134+
log.Printf("System audio capture: SCStream stopped unexpectedly, closing stream channel")
135+
sc.closeAndFree()
136+
}

pkg/capture/speaker_darwin.h

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,10 @@ typedef struct SpeakerCapture SpeakerCapture;
99
// Declared here so the .m file can call it.
1010
extern void tacitSpeakerSamplesCallback(uintptr_t handle, int16_t* samples, int count);
1111

12+
// tacitSpeakerStoppedCallback is called when SCStream stops unexpectedly.
13+
// Defined in speaker_darwin.go (//export).
14+
extern void tacitSpeakerStoppedCallback(uintptr_t handle);
15+
1216
// speaker_create starts ScreenCaptureKit system audio capture (macOS 13+).
1317
// On success returns a non-NULL handle and sets *errMsg to NULL.
1418
// On failure returns NULL and sets *errMsg to a malloc'd error string (caller must free).

pkg/capture/speaker_darwin.m

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -84,8 +84,14 @@ - (void)stream:(SCStream *)stream
8484
}
8585

8686
- (void)stream:(SCStream *)stream didStopWithError:(NSError *)error API_AVAILABLE(macos(12.3)) {
87-
if (error && !self.stopped) {
88-
NSLog(@"[tacit] speaker stream stopped with error: %@", error.localizedDescription);
87+
if (!self.stopped) {
88+
if (error) {
89+
NSLog(@"[tacit] speaker stream stopped with error: %@", error.localizedDescription);
90+
} else {
91+
NSLog(@"[tacit] speaker stream stopped unexpectedly (no error)");
92+
}
93+
// Notify Go so it can close the channel and let the pipeline restart.
94+
tacitSpeakerStoppedCallback(self.goHandle);
8995
}
9096
}
9197

pkg/pipeline/pipeline.go

Lines changed: 28 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -117,9 +117,32 @@ func (p *Pipeline) Run(ctx context.Context, sources []capture.AudioSource, label
117117
}
118118

119119
// runSource runs a single audio source through VAD→STT and enqueues results
120-
// onto classifyCh. It returns when ctx is cancelled or the source stream
121-
// closes.
120+
// onto classifyCh. It retries automatically when the source stream closes
121+
// unexpectedly (e.g. SCStream stopped by macOS), and returns only when ctx is
122+
// cancelled or a fatal initialisation error occurs.
122123
func (p *Pipeline) runSource(ctx context.Context, src capture.AudioSource, label string, classifyCh chan<- classifyItem) error {
124+
const retryDelay = 5 * time.Second
125+
for {
126+
err := p.runSourceOnce(ctx, src, label, classifyCh)
127+
if err != nil {
128+
return err
129+
}
130+
if ctx.Err() != nil {
131+
return nil
132+
}
133+
// Stream closed unexpectedly — wait briefly then restart.
134+
log.Printf("[%s] stream closed unexpectedly, restarting in %v", label, retryDelay)
135+
select {
136+
case <-time.After(retryDelay):
137+
case <-ctx.Done():
138+
return nil
139+
}
140+
}
141+
}
142+
143+
// runSourceOnce runs one capture session for a source. It returns when ctx is
144+
// cancelled or the source's stream channel is closed (normal or unexpected).
145+
func (p *Pipeline) runSourceOnce(ctx context.Context, src capture.AudioSource, label string, classifyCh chan<- classifyItem) error {
123146
// Init per-source VAD (256 samples = 16 ms at 16 kHz).
124147
const hopSize = 256
125148
v, err := vad.New(hopSize, float32(p.cfg.SpeechThreshold))
@@ -130,6 +153,9 @@ func (p *Pipeline) runSource(ctx context.Context, src capture.AudioSource, label
130153

131154
stream, err := src.Stream(ctx)
132155
if err != nil {
156+
if ctx.Err() != nil {
157+
return nil // ctx cancelled during restart, not a real error
158+
}
133159
return fmt.Errorf("start stream: %w", err)
134160
}
135161

0 commit comments

Comments
 (0)