diff --git a/internal/playback/audio_attempt.go b/internal/playback/audio_attempt.go new file mode 100644 index 0000000..ba316d6 --- /dev/null +++ b/internal/playback/audio_attempt.go @@ -0,0 +1,55 @@ +package playback + +import ( + "context" + "errors" + "fmt" +) + +type audioSinkError struct { + err error +} + +func (e *audioSinkError) Error() string { + return fmt.Sprintf("consume audio: %v", e.err) +} + +func (e *audioSinkError) Unwrap() error { + return e.err +} + +func runAudioAttempt( + ctx context.Context, + factory AudioReaderFactory, + sink AudioSink, + config FeedConfig, +) (resultErr error) { + reader, err := factory.OpenAudio(ctx, config) + if err != nil { + return fmt.Errorf("open audio: %w", err) + } + + defer func() { + if closeErr := reader.Close(); closeErr != nil { + closeErr = fmt.Errorf("close audio: %w", closeErr) + resultErr = errors.Join(resultErr, closeErr) + } + }() + + for { + frame, err := reader.ReadAudio(ctx) + if err != nil { + if ctx.Err() != nil { + return ctx.Err() + } + return fmt.Errorf("read audio: %w", err) + } + + if err := sink.ConsumeAudio(ctx, frame); err != nil { + if ctx.Err() != nil { + return ctx.Err() + } + return &audioSinkError{err: err} + } + } +} diff --git a/internal/playback/audio_attempt_test.go b/internal/playback/audio_attempt_test.go new file mode 100644 index 0000000..5d43330 --- /dev/null +++ b/internal/playback/audio_attempt_test.go @@ -0,0 +1,204 @@ +package playback + +import ( + "context" + "errors" + "testing" +) + +type fakeAudioFactory struct { + reader AudioReader + err error + calls int +} + +func (f *fakeAudioFactory) OpenAudio( + context.Context, + FeedConfig, +) (AudioReader, error) { + f.calls++ + return f.reader, f.err +} + +type fakeAudioReader struct { + frames []AudioFrame + readErr error + closeErr error + readCalls int + closed bool + read func(context.Context) (AudioFrame, error) +} + +func (r *fakeAudioReader) ReadAudio(ctx context.Context) (AudioFrame, error) { + r.readCalls++ + if r.read != nil { + return r.read(ctx) + } + if len(r.frames) == 0 { + return AudioFrame{}, r.readErr + } + frame := r.frames[0] + r.frames = r.frames[1:] + return frame, nil +} + +func (r *fakeAudioReader) Close() error { + r.closed = true + return r.closeErr +} + +type fakeAudioSink struct { + frames []AudioFrame + err error +} + +func (s *fakeAudioSink) ConsumeAudio(_ context.Context, frame AudioFrame) error { + s.frames = append(s.frames, frame) + return s.err +} + +func TestRunAudioAttemptOpenFailure(t *testing.T) { + openErr := errors.New("open failed") + factory := &fakeAudioFactory{err: openErr} + sink := &fakeAudioSink{} + + err := runAudioAttempt(context.Background(), factory, sink, FeedConfig{}) + + if !errors.Is(err, openErr) { + t.Fatalf("runAudioAttempt() error = %v, want %v", err, openErr) + } + if factory.calls != 1 { + t.Errorf("factory calls = %d, want 1", factory.calls) + } + if len(sink.frames) != 0 { + t.Fatalf("consumed frame count = %d, want 0", len(sink.frames)) + } +} + +func TestRunAudioAttemptConsumesFrameWithoutCopyThenReturnsReadFailure(t *testing.T) { + readErr := errors.New("read failed") + wantFrame := AudioFrame{ + Index: 42, + SampleCount: 2, + Channels: 2, + SampleRateNumerator: 48000, + SampleRateDenominator: 1, + Samples: [][]byte{ + {1, 2, 3, 4}, + {5, 6, 7, 8}, + }, + } + reader := &fakeAudioReader{ + frames: []AudioFrame{wantFrame}, + readErr: readErr, + } + sink := &fakeAudioSink{} + + err := runAudioAttempt( + context.Background(), + &fakeAudioFactory{reader: reader}, + sink, + FeedConfig{}, + ) + + if !errors.Is(err, readErr) { + t.Fatalf("runAudioAttempt() error = %v, want %v", err, readErr) + } + if !reader.closed { + t.Fatal("reader was not closed") + } + if reader.readCalls != 2 { + t.Errorf("read calls = %d, want 2", reader.readCalls) + } + if len(sink.frames) != 1 { + t.Fatalf("consumed frame count = %d, want 1", len(sink.frames)) + } + got := sink.frames[0] + if got.Index != wantFrame.Index || + got.SampleCount != wantFrame.SampleCount || + got.Channels != wantFrame.Channels || + got.SampleRateNumerator != wantFrame.SampleRateNumerator || + got.SampleRateDenominator != wantFrame.SampleRateDenominator { + t.Errorf("consumed frame metadata = %+v, want %+v", got, wantFrame) + } + for channel := range wantFrame.Samples { + if &got.Samples[channel][0] != &wantFrame.Samples[channel][0] { + t.Fatalf("channel %d samples were copied", channel) + } + } +} + +func TestRunAudioAttemptSinkFailureStopsReadingAndCloses(t *testing.T) { + sinkErr := errors.New("audio output unavailable") + reader := &fakeAudioReader{ + frames: []AudioFrame{ + {Index: 1, Samples: [][]byte{{1}}}, + {Index: 2, Samples: [][]byte{{2}}}, + }, + } + + err := runAudioAttempt( + context.Background(), + &fakeAudioFactory{reader: reader}, + &fakeAudioSink{err: sinkErr}, + FeedConfig{}, + ) + + if !errors.Is(err, sinkErr) { + t.Fatalf("runAudioAttempt() error = %v, want %v", err, sinkErr) + } + var typedErr *audioSinkError + if !errors.As(err, &typedErr) { + t.Fatalf("runAudioAttempt() error type = %T, want *audioSinkError", err) + } + if reader.readCalls != 1 { + t.Errorf("read calls = %d, want 1", reader.readCalls) + } + if !reader.closed { + t.Fatal("reader was not closed") + } +} + +func TestRunAudioAttemptCanceledRead(t *testing.T) { + ctx, cancel := context.WithCancel(context.Background()) + reader := &fakeAudioReader{ + read: func(ctx context.Context) (AudioFrame, error) { + cancel() + return AudioFrame{}, ctx.Err() + }, + } + + err := runAudioAttempt( + ctx, + &fakeAudioFactory{reader: reader}, + &fakeAudioSink{}, + FeedConfig{}, + ) + + if !errors.Is(err, context.Canceled) { + t.Fatalf("runAudioAttempt() error = %v, want context.Canceled", err) + } + if !reader.closed { + t.Fatal("reader was not closed") + } +} + +func TestRunAudioAttemptJoinsReadAndCloseErrors(t *testing.T) { + readErr := errors.New("read failed") + closeErr := errors.New("close failed") + reader := &fakeAudioReader{readErr: readErr, closeErr: closeErr} + + err := runAudioAttempt( + context.Background(), + &fakeAudioFactory{reader: reader}, + &fakeAudioSink{}, + FeedConfig{}, + ) + + if !errors.Is(err, readErr) { + t.Errorf("runAudioAttempt() error does not contain read error: %v", err) + } + if !errors.Is(err, closeErr) { + t.Errorf("runAudioAttempt() error does not contain close error: %v", err) + } +}