package audio import ( "errors" "os" "sync" "sync/atomic" "testing" "time" "github.com/gen2brain/malgo" ) func TestMain(m *testing.M) { oldEnsure := ensureMicrophoneAuthorization ensureMicrophoneAuthorization = func() microphoneAuthorization { return microphoneAuthorization{granted: true, state: "granted"} } code := m.Run() ensureMicrophoneAuthorization = oldEnsure os.Exit(code) } func TestReadSamplesSinceReturnsOnlyNewSamples(t *testing.T) { r := &Recorder{ pcmBuf: pcm16LE(0, 16384, -32768), } samples, nextOffset := r.ReadSamplesSince(1) if nextOffset != 3 { t.Fatalf("next offset = %d, want 3", nextOffset) } if len(samples) != 2 { t.Fatalf("sample count = %d, want 2", len(samples)) } if samples[0] != 0.5 { t.Fatalf("samples[0] = %f, want 0.5", samples[0]) } if samples[1] != -1 { t.Fatalf("samples[1] = %f, want -1", samples[1]) } } func TestReadSamplesSinceClampsOffset(t *testing.T) { r := &Recorder{ pcmBuf: pcm16LE(0, 16384), } samples, nextOffset := r.ReadSamplesSince(99) if nextOffset != 2 { t.Fatalf("next offset = %d, want 2", nextOffset) } if len(samples) != 0 { t.Fatalf("sample count = %d, want 0", len(samples)) } } func TestOnDataCapsRecordingBuffer(t *testing.T) { r := &Recorder{ isRecording: true, pcmBuf: make([]byte, maxPCMBufferBytes-2), } r.onData(pcm16LE(1, 2)) if got := len(r.pcmBuf); got != maxPCMBufferBytes { t.Fatalf("pcm buffer length = %d, want capped %d", got, maxPCMBufferBytes) } if !r.bufferLimitHit { t.Fatal("bufferLimitHit should be set after truncating at cap") } r.onData(pcm16LE(3)) if got := len(r.pcmBuf); got != maxPCMBufferBytes { t.Fatalf("pcm buffer length after cap = %d, want %d", got, maxPCMBufferBytes) } } func TestStartWithoutMicrophonePermissionDoesNotInitializeDevice(t *testing.T) { oldEnsure := ensureMicrophoneAuthorization ensureMicrophoneAuthorization = func() microphoneAuthorization { return microphoneAuthorization{granted: false, state: "denied"} } defer func() { ensureMicrophoneAuthorization = oldEnsure }() device := newFakeRecorderDevice() ctx := &fakeRecorderContext{device: device} r := &Recorder{ctx: ctx} err := r.Start() if err == nil { t.Fatal("Start returned nil without microphone permission") } if ctx.initEntered.Load() { t.Fatal("initDevice should not run without microphone permission") } if device.startEntered.Load() { t.Fatal("device Start should not run without microphone permission") } r.mu.Lock() defer r.mu.Unlock() if r.startRequested || r.isStarting || r.isRecording || r.stopAfterStart || r.device != nil { t.Fatalf("recorder state leaked after microphone denial: startRequested=%t isStarting=%t isRecording=%t stopAfterStart=%t deviceNil=%t", r.startRequested, r.isStarting, r.isRecording, r.stopAfterStart, r.device == nil) } } func TestStopWhileMicrophoneAuthorizationPendingPreventsNativeStart(t *testing.T) { oldEnsure := ensureMicrophoneAuthorization entered := make(chan struct{}) allow := make(chan struct{}) var enteredOnce sync.Once ensureMicrophoneAuthorization = func() microphoneAuthorization { enteredOnce.Do(func() { close(entered) }) <-allow return microphoneAuthorization{granted: true, state: "granted"} } defer func() { ensureMicrophoneAuthorization = oldEnsure }() device := newFakeRecorderDevice() ctx := &fakeRecorderContext{device: device} r := &Recorder{ctx: ctx} startDone := make(chan error, 1) go func() { startDone <- r.Start() }() select { case <-entered: case <-time.After(time.Second): t.Fatal("microphone authorization was not entered") } r.Stop() close(allow) if err := <-startDone; err != nil { t.Fatalf("Start returned error = %v", err) } if ctx.initEntered.Load() { t.Fatal("initDevice should not run after Stop during microphone authorization") } if device.startEntered.Load() { t.Fatal("device Start should not run after Stop during microphone authorization") } r.mu.Lock() defer r.mu.Unlock() if r.startRequested || r.isStarting || r.isRecording || r.stopAfterStart || r.device != nil { t.Fatalf("recorder state leaked after pending authorization stop: startRequested=%t isStarting=%t isRecording=%t stopAfterStart=%t deviceNil=%t", r.startRequested, r.isStarting, r.isRecording, r.stopAfterStart, r.device == nil) } } func TestStopAndGetSamplesWhileMicrophoneAuthorizationPendingPreventsNativeStart(t *testing.T) { oldEnsure := ensureMicrophoneAuthorization entered := make(chan struct{}) allow := make(chan struct{}) var enteredOnce sync.Once ensureMicrophoneAuthorization = func() microphoneAuthorization { enteredOnce.Do(func() { close(entered) }) <-allow return microphoneAuthorization{granted: true, state: "granted"} } defer func() { ensureMicrophoneAuthorization = oldEnsure }() device := newFakeRecorderDevice() ctx := &fakeRecorderContext{device: device} r := &Recorder{ctx: ctx} startDone := make(chan error, 1) go func() { startDone <- r.Start() }() select { case <-entered: case <-time.After(time.Second): t.Fatal("microphone authorization was not entered") } samples := r.StopAndGetSamples() if len(samples) != 0 { t.Fatalf("samples while microphone authorization pending = %d, want 0", len(samples)) } close(allow) if err := <-startDone; err != nil { t.Fatalf("Start returned error = %v", err) } if ctx.initEntered.Load() { t.Fatal("initDevice should not run after StopAndGetSamples during microphone authorization") } if device.startEntered.Load() { t.Fatal("device Start should not run after StopAndGetSamples during microphone authorization") } r.mu.Lock() defer r.mu.Unlock() if r.startRequested || r.isStarting || r.isRecording || r.stopAfterStart || r.device != nil { t.Fatalf("recorder state leaked after pending authorization samples stop: startRequested=%t isStarting=%t isRecording=%t stopAfterStart=%t deviceNil=%t", r.startRequested, r.isStarting, r.isRecording, r.stopAfterStart, r.device == nil) } } func TestCloseWaitsForBlockedStartBeforeFreeingContext(t *testing.T) { device := newFakeRecorderDevice() ctx := &fakeRecorderContext{device: device} r := &Recorder{ctx: ctx} startDone := make(chan error, 1) go func() { startDone <- r.Start() }() waitForRecorderTest(t, func() bool { return device.startEntered.Load() }) closeDone := make(chan struct{}) go func() { r.Close() close(closeDone) }() select { case <-closeDone: t.Fatal("Close returned while recorder Start was still blocked") case <-time.After(50 * time.Millisecond): } if got := ctx.freeCount.Load(); got != 0 { t.Fatalf("context freed before Start returned = %d, want 0", got) } if got := device.uninitCount.Load(); got != 0 { t.Fatalf("device uninitialized before Start returned = %d, want 0", got) } close(device.allowStart) if err := <-startDone; err == nil { t.Fatal("Start returned nil after Close was requested during startup") } waitForRecorderTest(t, func() bool { select { case <-closeDone: return true default: return false } }) if got := ctx.freeCount.Load(); got != 1 { t.Fatalf("context free count = %d, want 1", got) } if got := device.stopCount.Load(); got != 1 { t.Fatalf("device stop count = %d, want 1", got) } if got := device.uninitCount.Load(); got != 1 { t.Fatalf("device uninit count = %d, want 1", got) } } func TestStopWhileStartBlockedDefersDeviceCleanup(t *testing.T) { device := newFakeRecorderDevice() ctx := &fakeRecorderContext{device: device} r := &Recorder{ctx: ctx} startDone := make(chan error, 1) go func() { startDone <- r.Start() }() waitForRecorderTest(t, func() bool { return device.startEntered.Load() }) stopDone := make(chan struct{}) go func() { r.Stop() close(stopDone) }() select { case <-stopDone: case <-time.After(time.Second): t.Fatal("Stop should return while recorder Start is still blocked") } if got := device.stopCount.Load(); got != 0 { t.Fatalf("device stopped before Start returned = %d, want 0", got) } if got := device.uninitCount.Load(); got != 0 { t.Fatalf("device uninitialized before Start returned = %d, want 0", got) } close(device.allowStart) if err := <-startDone; err != nil { t.Fatalf("Start returned error = %v", err) } waitForRecorderTest(t, func() bool { return device.uninitCount.Load() == 1 }) if got := device.stopCount.Load(); got != 1 { t.Fatalf("device stop count = %d, want 1", got) } } func TestStopAndGetSamplesWhileStartBlockedReturnsBufferedPCM(t *testing.T) { device := newFakeRecorderDevice() ctx := &fakeRecorderContext{device: device} r := &Recorder{ctx: ctx} startDone := make(chan error, 1) go func() { startDone <- r.Start() }() waitForRecorderTest(t, func() bool { return device.startEntered.Load() }) r.onData(pcm16LE(16384, -32768)) samples := r.StopAndGetSamples() if len(samples) != 2 { t.Fatalf("samples len = %d, want 2", len(samples)) } if samples[0] != 0.5 || samples[1] != -1 { t.Fatalf("samples = %#v, want [0.5 -1]", samples) } if got := device.stopCount.Load(); got != 0 { t.Fatalf("device stopped before Start returned = %d, want 0", got) } if got := device.uninitCount.Load(); got != 0 { t.Fatalf("device uninitialized before Start returned = %d, want 0", got) } close(device.allowStart) if err := <-startDone; err != nil { t.Fatalf("Start returned error = %v", err) } if got := device.stopCount.Load(); got != 1 { t.Fatalf("device stop count after Start returned = %d, want 1", got) } if got := device.uninitCount.Load(); got != 1 { t.Fatalf("device uninit count after Start returned = %d, want 1", got) } } func TestStopAndGetSamplesBeforeDevicePublishDefersIntentAndCleansInitializedDevice(t *testing.T) { device := newFakeRecorderDevice() initBlock := make(chan struct{}) ctx := &fakeRecorderContext{device: device, initBlock: initBlock} r := &Recorder{ctx: ctx} startDone := make(chan error, 1) go func() { startDone <- r.Start() }() waitForRecorderTest(t, func() bool { return ctx.initEntered.Load() }) samples := r.StopAndGetSamples() if len(samples) != 0 { t.Fatalf("samples len before device publish = %d, want 0", len(samples)) } if device.startEntered.Load() { t.Fatal("device Start should not be entered while initDevice is blocked") } if got := device.stopCount.Load(); got != 0 { t.Fatalf("device stopped before it was published = %d, want 0", got) } if got := device.uninitCount.Load(); got != 0 { t.Fatalf("device uninit before initDevice returned = %d, want 0", got) } close(initBlock) if err := <-startDone; err != nil { t.Fatalf("Start returned error = %v", err) } if device.startEntered.Load() { t.Fatal("device Start should not run after stop intent arrived before publish") } if got := device.stopCount.Load(); got != 0 { t.Fatalf("device stop count = %d, want 0 for never-started device", got) } if got := device.uninitCount.Load(); got != 1 { t.Fatalf("device uninit count = %d, want 1", got) } r.mu.Lock() defer r.mu.Unlock() if r.startRequested || r.isStarting || r.isRecording || r.stopAfterStart || r.device != nil { t.Fatalf("recorder state leaked after pre-publish stop: startRequested=%t isStarting=%t isRecording=%t stopAfterStart=%t deviceNil=%t", r.startRequested, r.isStarting, r.isRecording, r.stopAfterStart, r.device == nil) } } func TestStopBeforeDevicePublishDefersIntentAndCleansInitializedDevice(t *testing.T) { device := newFakeRecorderDevice() initBlock := make(chan struct{}) ctx := &fakeRecorderContext{device: device, initBlock: initBlock} r := &Recorder{ctx: ctx} startDone := make(chan error, 1) go func() { startDone <- r.Start() }() waitForRecorderTest(t, func() bool { return ctx.initEntered.Load() }) r.Stop() if device.startEntered.Load() { t.Fatal("device Start should not be entered while initDevice is blocked") } if got := device.stopCount.Load(); got != 0 { t.Fatalf("device stopped before it was published = %d, want 0", got) } if got := device.uninitCount.Load(); got != 0 { t.Fatalf("device uninit before initDevice returned = %d, want 0", got) } close(initBlock) if err := <-startDone; err != nil { t.Fatalf("Start returned error = %v", err) } if device.startEntered.Load() { t.Fatal("device Start should not run after stop intent arrived before publish") } if got := device.stopCount.Load(); got != 0 { t.Fatalf("device stop count = %d, want 0 for never-started device", got) } if got := device.uninitCount.Load(); got != 1 { t.Fatalf("device uninit count = %d, want 1", got) } } func TestStopWhileStartBlockedLetsStartErrorCleanup(t *testing.T) { startErr := errors.New("start failed") device := newFakeRecorderDevice() device.startErr = startErr ctx := &fakeRecorderContext{device: device} r := &Recorder{ctx: ctx} startDone := make(chan error, 1) go func() { startDone <- r.Start() }() waitForRecorderTest(t, func() bool { return device.startEntered.Load() }) stopDone := make(chan struct{}) go func() { r.Stop() close(stopDone) }() select { case <-stopDone: case <-time.After(time.Second): t.Fatal("Stop should return while recorder Start is still blocked") } if got := device.uninitCount.Load(); got != 0 { t.Fatalf("device uninit before Start error returned = %d, want 0", got) } close(device.allowStart) if err := <-startDone; !errors.Is(err, startErr) { t.Fatalf("Start error = %v, want %v", err, startErr) } if got := device.uninitCount.Load(); got != 1 { t.Fatalf("device uninit count = %d, want 1", got) } } func pcm16LE(values ...int16) []byte { buf := make([]byte, len(values)*2) for i, value := range values { buf[i*2] = byte(value) buf[i*2+1] = byte(value >> 8) } return buf } type fakeRecorderContext struct { device *fakeRecorderDevice initErr error initBlock <-chan struct{} initEntered atomic.Bool freeCount atomic.Int32 } func (c *fakeRecorderContext) devices(malgo.DeviceType) ([]malgo.DeviceInfo, error) { return nil, nil } func (c *fakeRecorderContext) initDevice(malgo.DeviceConfig, malgo.DeviceCallbacks) (recorderDevice, error) { c.initEntered.Store(true) if c.initBlock != nil { <-c.initBlock } if c.initErr != nil { return nil, c.initErr } return c.device, nil } func (c *fakeRecorderContext) free() { c.freeCount.Add(1) } type fakeRecorderDevice struct { startEntered atomic.Bool startOnce sync.Once allowStart chan struct{} startErr error stopCount atomic.Int32 uninitCount atomic.Int32 } func newFakeRecorderDevice() *fakeRecorderDevice { return &fakeRecorderDevice{allowStart: make(chan struct{})} } func (d *fakeRecorderDevice) Start() error { d.startOnce.Do(func() { d.startEntered.Store(true) }) <-d.allowStart return d.startErr } func (d *fakeRecorderDevice) Stop() error { d.stopCount.Add(1) return nil } func (d *fakeRecorderDevice) Uninit() { d.uninitCount.Add(1) } func waitForRecorderTest(t *testing.T, cond func() bool) { t.Helper() deadline := time.Now().Add(2 * time.Second) for time.Now().Before(deadline) { if cond() { return } time.Sleep(10 * time.Millisecond) } t.Fatal("condition not met before timeout") }