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")
|
}
|