| | |
| | | package audio |
| | | |
| | | import "testing" |
| | | 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{ |
| | |
| | | } |
| | | } |
| | | |
| | | 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 { |
| | |
| | | } |
| | | 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") |
| | | } |