detached ef1527c

Merge branch 'feature/player-implementation'

# Conflicts:
#	.mockery.yml
#	cmd/pipewiregen/internal/parser/parser_test.go
#	pipewire/mocks/mock_Player.go
#	pipewire/player.go
#	pipewire/player_test.go
commit
ef1527c08eee7afccd158bc05203e18ba7028561
author
brice · · 2026-04-09 17:33 UTC
merge of
6ca7ef7 + 6e8cc4b · diff against the first parent
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
16 files · +1821 -200
M .mockery.yml +0 -1
@@ -23,4 +23,3 @@ packages:
2323 Runtime:
2424 MainLoop:
2525 Player:
26- PlaybackStream:
@@ -23,4 +23,3 @@ packages:
23 Runtime:23 Runtime:
24 MainLoop:24 MainLoop:
25 Player:25 Player:
26- PlaybackStream:
M README.md +6 -0
@@ -38,3 +38,9 @@ The sync script is currently a placeholder for the planned header-import workflo
3838
3939 - Go 1.26.1+
4040 - Linux with PipeWire 0.3 runtime libraries
41+
42+## Integration tests
43+
44+Run integration tests (requires PipeWire runtime):
45+
46+ go test -tags integration ./integration ./pipewire -v
@@ -38,3 +38,9 @@ The sync script is currently a placeholder for the planned header-import workflo
38 38
39 - Go 1.26.1+39 - Go 1.26.1+
40 - Linux with PipeWire 0.3 runtime libraries40 - Linux with PipeWire 0.3 runtime libraries
41+
42+## Integration tests
43+
44+Run integration tests (requires PipeWire runtime):
45+
46+ go test -tags integration ./integration ./pipewire -v
M cmd/pipewiregen/internal/parser/parser_test.go +3 -3
@@ -121,7 +121,7 @@ func TestLoadEmptyCallbackNameReturnsError(t *testing.T) {
121121 if !strings.Contains(err.Error(), "validation failed") {
122122 t.Fatalf("error message should contain 'validation failed', got: %v", err)
123123 }
124- if !strings.Contains(err.Error(), "callback") && !strings.Contains(err.Error(), "empty name") {
124+ if !strings.Contains(err.Error(), "callback") || !strings.Contains(err.Error(), "empty name") {
125125 t.Fatalf("error message should mention callback empty name, got: %v", err)
126126 }
127127 }
@@ -134,7 +134,7 @@ func TestLoadEmptyCallbackSignatureReturnsError(t *testing.T) {
134134 if !strings.Contains(err.Error(), "validation failed") {
135135 t.Fatalf("error message should contain 'validation failed', got: %v", err)
136136 }
137- if !strings.Contains(err.Error(), "callback") && !strings.Contains(err.Error(), "empty signature") {
137+ if !strings.Contains(err.Error(), "callback") || !strings.Contains(err.Error(), "empty signature") {
138138 t.Fatalf("error message should mention callback empty signature, got: %v", err)
139139 }
140140 }
@@ -147,7 +147,7 @@ func TestLoadEmptyEventStructNameReturnsError(t *testing.T) {
147147 if !strings.Contains(err.Error(), "validation failed") {
148148 t.Fatalf("error message should contain 'validation failed', got: %v", err)
149149 }
150- if !strings.Contains(err.Error(), "event struct") && !strings.Contains(err.Error(), "empty name") {
150+ if !strings.Contains(err.Error(), "event struct") || !strings.Contains(err.Error(), "empty name") {
151151 t.Fatalf("error message should mention event struct empty name, got: %v", err)
152152 }
153153 }
@@ -121,7 +121,7 @@ func TestLoadEmptyCallbackNameReturnsError(t *testing.T) {
121 if !strings.Contains(err.Error(), "validation failed") {121 if !strings.Contains(err.Error(), "validation failed") {
122 t.Fatalf("error message should contain 'validation failed', got: %v", err)122 t.Fatalf("error message should contain 'validation failed', got: %v", err)
123 }123 }
124- if !strings.Contains(err.Error(), "callback") && !strings.Contains(err.Error(), "empty name") {124+ if !strings.Contains(err.Error(), "callback") || !strings.Contains(err.Error(), "empty name") {
125 t.Fatalf("error message should mention callback empty name, got: %v", err)125 t.Fatalf("error message should mention callback empty name, got: %v", err)
126 }126 }
127 }127 }
@@ -134,7 +134,7 @@ func TestLoadEmptyCallbackSignatureReturnsError(t *testing.T) {
134 if !strings.Contains(err.Error(), "validation failed") {134 if !strings.Contains(err.Error(), "validation failed") {
135 t.Fatalf("error message should contain 'validation failed', got: %v", err)135 t.Fatalf("error message should contain 'validation failed', got: %v", err)
136 }136 }
137- if !strings.Contains(err.Error(), "callback") && !strings.Contains(err.Error(), "empty signature") {137+ if !strings.Contains(err.Error(), "callback") || !strings.Contains(err.Error(), "empty signature") {
138 t.Fatalf("error message should mention callback empty signature, got: %v", err)138 t.Fatalf("error message should mention callback empty signature, got: %v", err)
139 }139 }
140 }140 }
@@ -147,7 +147,7 @@ func TestLoadEmptyEventStructNameReturnsError(t *testing.T) {
147 if !strings.Contains(err.Error(), "validation failed") {147 if !strings.Contains(err.Error(), "validation failed") {
148 t.Fatalf("error message should contain 'validation failed', got: %v", err)148 t.Fatalf("error message should contain 'validation failed', got: %v", err)
149 }149 }
150- if !strings.Contains(err.Error(), "event struct") && !strings.Contains(err.Error(), "empty name") {150+ if !strings.Contains(err.Error(), "event struct") || !strings.Contains(err.Error(), "empty name") {
151 t.Fatalf("error message should mention event struct empty name, got: %v", err)151 t.Fatalf("error message should mention event struct empty name, got: %v", err)
152 }152 }
153 }153 }
A integration/player_test.go +53 -0
@@ -0,0 +1,53 @@
1+//go:build integration
2+
3+package integration
4+
5+import (
6+ "testing"
7+
8+ "github.com/bnema/purego-pipewire/pipewire"
9+)
10+
11+func TestPlayerStartStopClose(t *testing.T) {
12+ // Initialize PipeWire
13+ initResult, err := pipewire.Init()
14+ if err != nil {
15+ t.Fatalf("Init returned error: %v", err)
16+ }
17+ defer initResult.Deinit()
18+
19+ // Create player with specified configuration
20+ config := pipewire.PlayerConfig{
21+ SampleRate: 48000,
22+ Channels: 2,
23+ FramesPerBuffer: 256,
24+ SampleFormat: pipewire.SampleFormatF32,
25+ UnderrunPolicy: pipewire.UnderrunFillSilence,
26+ }
27+
28+ callbacks := pipewire.PlayerCallbacks{
29+ Fill: func(buf *pipewire.PCMBuffer) (int, error) {
30+ return buf.Frames, nil
31+ },
32+ }
33+
34+ player, err := pipewire.NewPlayer(config, callbacks)
35+ if err != nil {
36+ t.Fatalf("NewPlayer returned error: %v", err)
37+ }
38+
39+ // Test Start
40+ if err := player.Start(); err != nil {
41+ t.Errorf("Start returned error: %v", err)
42+ }
43+
44+ // Test Stop
45+ if err := player.Stop(); err != nil {
46+ t.Errorf("Stop returned error: %v", err)
47+ }
48+
49+ // Test Close
50+ if err := player.Close(); err != nil {
51+ t.Errorf("Close returned error: %v", err)
52+ }
53+}
@@ -0,0 +1,53 @@
1+//go:build integration
2+
3+package integration
4+
5+import (
6+ "testing"
7+
8+ "github.com/bnema/purego-pipewire/pipewire"
9+)
10+
11+func TestPlayerStartStopClose(t *testing.T) {
12+ // Initialize PipeWire
13+ initResult, err := pipewire.Init()
14+ if err != nil {
15+ t.Fatalf("Init returned error: %v", err)
16+ }
17+ defer initResult.Deinit()
18+
19+ // Create player with specified configuration
20+ config := pipewire.PlayerConfig{
21+ SampleRate: 48000,
22+ Channels: 2,
23+ FramesPerBuffer: 256,
24+ SampleFormat: pipewire.SampleFormatF32,
25+ UnderrunPolicy: pipewire.UnderrunFillSilence,
26+ }
27+
28+ callbacks := pipewire.PlayerCallbacks{
29+ Fill: func(buf *pipewire.PCMBuffer) (int, error) {
30+ return buf.Frames, nil
31+ },
32+ }
33+
34+ player, err := pipewire.NewPlayer(config, callbacks)
35+ if err != nil {
36+ t.Fatalf("NewPlayer returned error: %v", err)
37+ }
38+
39+ // Test Start
40+ if err := player.Start(); err != nil {
41+ t.Errorf("Start returned error: %v", err)
42+ }
43+
44+ // Test Stop
45+ if err := player.Stop(); err != nil {
46+ t.Errorf("Stop returned error: %v", err)
47+ }
48+
49+ // Test Close
50+ if err := player.Close(); err != nil {
51+ t.Errorf("Close returned error: %v", err)
52+ }
53+}
M internal/capi/init_gen.go +1 -0
@@ -12,6 +12,7 @@ type pw_initFunc func(argc *int32, argv ***byte)
1212 type pw_deinitFunc func()
1313
1414
15+
1516 var (
1617
1718 pw_init pw_initFunc
@@ -12,6 +12,7 @@ type pw_initFunc func(argc *int32, argv ***byte)
12 type pw_deinitFunc func()12 type pw_deinitFunc func()
13 13
14 14
15+
15 var (16 var (
16 17
17 pw_init pw_initFunc18 pw_init pw_initFunc
M internal/capi/loop_gen.go +1 -0 generated file: collapsed
A internal/core/player.go +254 -0
@@ -0,0 +1,254 @@
1+package core
2+
3+import (
4+ "errors"
5+ "sync"
6+ "sync/atomic"
7+)
8+
9+var (
10+ ErrInvalidPlayerState = errors.New("invalid player state transition")
11+ ErrPlayerClosed = errors.New("player is closed")
12+)
13+
14+// player is the internal player shell with state machine
15+type player struct {
16+ config playerConfig
17+ callbacks playerCallbacks
18+ state atomic.Int32
19+ paused atomic.Bool
20+ mu sync.Mutex
21+}
22+
23+// newPlayer creates a new player instance
24+func newPlayer(config PlayerConfig, callbacks PlayerCallbacks) *player {
25+ p := &player{
26+ config: config,
27+ callbacks: callbacks,
28+ }
29+ p.state.Store(int32(PlayerStateIdle))
30+ return p
31+}
32+
33+// State returns the current player state
34+func (p *player) State() PlayerState {
35+ return PlayerState(p.state.Load())
36+}
37+
38+// setState atomically sets the player state
39+func (p *player) setState(next PlayerState) {
40+ p.state.Store(int32(next))
41+}
42+
43+// transition attempts to transition to the next state
44+func (p *player) transition(next PlayerState) error {
45+ p.mu.Lock()
46+
47+ current := p.State()
48+
49+ // Validate transition
50+ if !isValidTransition(current, next) {
51+ p.mu.Unlock()
52+ return ErrInvalidPlayerState
53+ }
54+
55+ // Perform transition
56+ p.setState(next)
57+
58+ // Capture callback under lock
59+ cb := p.callbacks.OnStateChange
60+
61+ p.mu.Unlock()
62+
63+ // Invoke callback after releasing lock to avoid deadlock on reentry
64+ if cb != nil {
65+ cb(next)
66+ }
67+
68+ return nil
69+}
70+
71+// isValidTransition checks if a state transition is valid
72+func isValidTransition(current, next PlayerState) bool {
73+ switch current {
74+ case PlayerStateIdle:
75+ return next == PlayerStateStarting || next == PlayerStateClosing
76+ case PlayerStateStarting:
77+ return next == PlayerStatePlaying || next == PlayerStateError
78+ case PlayerStatePlaying:
79+ return next == PlayerStatePaused || next == PlayerStateStopped || next == PlayerStateClosing || next == PlayerStateError
80+ case PlayerStatePaused:
81+ return next == PlayerStatePlaying || next == PlayerStateStopped || next == PlayerStateClosing || next == PlayerStateError
82+ case PlayerStateStopped:
83+ return next == PlayerStateStarting || next == PlayerStateClosing
84+ case PlayerStateClosing:
85+ return next == PlayerStateClosed
86+ case PlayerStateClosed:
87+ return false // Terminal state
88+ case PlayerStateError:
89+ return next == PlayerStateClosing
90+ default:
91+ return false
92+ }
93+}
94+
95+// Start begins playback, transitioning from Idle or Stopped to Playing
96+func (p *player) Start() error {
97+ // Check if player is in terminal state
98+ current := p.State()
99+ if current == PlayerStateClosed || current == PlayerStateClosing {
100+ return ErrPlayerClosed
101+ }
102+
103+ // Only valid from Idle or Stopped
104+ if current != PlayerStateIdle && current != PlayerStateStopped {
105+ return ErrInvalidPlayerState
106+ }
107+
108+ // Ensure runtime is available (placeholder)
109+ if err := p.ensureRuntime(); err != nil {
110+ return err
111+ }
112+
113+ // Transition to Starting then Playing
114+ if err := p.transition(PlayerStateStarting); err != nil {
115+ return err
116+ }
117+
118+ // Clear paused flag
119+ p.setPaused(false)
120+
121+ // Transition to Playing
122+ return p.transition(PlayerStatePlaying)
123+}
124+
125+// Pause temporarily pauses playback
126+func (p *player) Pause() error {
127+ current := p.State()
128+ if current == PlayerStateClosed || current == PlayerStateClosing {
129+ return ErrPlayerClosed
130+ }
131+
132+ // Only valid from Playing
133+ if current != PlayerStatePlaying {
134+ return ErrInvalidPlayerState
135+ }
136+
137+ p.setPaused(true)
138+ return p.transition(PlayerStatePaused)
139+}
140+
141+// Stop stops playback but allows restart.
142+func (p *player) Stop() error {
143+ current := p.State()
144+ if current == PlayerStateClosed || current == PlayerStateClosing {
145+ return ErrPlayerClosed
146+ }
147+
148+ // Valid from Playing or Paused.
149+ if current != PlayerStatePlaying && current != PlayerStatePaused {
150+ return ErrInvalidPlayerState
151+ }
152+
153+ // Clear paused flag
154+ p.setPaused(false)
155+
156+ // Deactivate stream (placeholder)
157+ p.deactivateStream()
158+
159+ return p.transition(PlayerStateStopped)
160+}
161+
162+// Close permanently shuts down the player
163+func (p *player) Close() error {
164+ current := p.State()
165+
166+ // Idempotent on already closed
167+ if current == PlayerStateClosed {
168+ return nil
169+ }
170+
171+ // Transition to Closing if not already there
172+ if current != PlayerStateClosing {
173+ if err := p.transition(PlayerStateClosing); err != nil {
174+ return err
175+ }
176+ }
177+
178+ // Teardown resources (placeholder)
179+ p.teardown()
180+
181+ // Transition to Closed
182+ return p.transition(PlayerStateClosed)
183+}
184+
185+// ensureRuntime ensures the runtime is available (placeholder)
186+func (p *player) ensureRuntime() error {
187+ // Minimal placeholder - will be implemented in future tasks
188+ return nil
189+}
190+
191+// setPaused sets the paused flag
192+func (p *player) setPaused(paused bool) {
193+ p.paused.Store(paused)
194+}
195+
196+// deactivateStream deactivates the stream (placeholder)
197+func (p *player) deactivateStream() {
198+ // Minimal placeholder - will be implemented in future tasks
199+}
200+
201+// teardown performs cleanup (placeholder)
202+func (p *player) teardown() {
203+ // Minimal placeholder - will be implemented in future tasks
204+}
205+
206+// Player is the exported wrapper for the internal player
207+type Player struct {
208+ p *player
209+}
210+
211+// NewPlayer creates a new exported Player instance
212+func NewPlayer(config playerConfig, callbacks playerCallbacks) *Player {
213+ return &Player{p: newPlayer(config, callbacks)}
214+}
215+
216+// State returns the current player state
217+func (p *Player) State() PlayerState {
218+ if p.p == nil {
219+ return PlayerStateIdle
220+ }
221+ return p.p.State()
222+}
223+
224+// Start begins playback
225+func (p *Player) Start() error {
226+ if p.p == nil {
227+ return ErrPlayerClosed
228+ }
229+ return p.p.Start()
230+}
231+
232+// Pause temporarily pauses playback
233+func (p *Player) Pause() error {
234+ if p.p == nil {
235+ return ErrPlayerClosed
236+ }
237+ return p.p.Pause()
238+}
239+
240+// Stop stops playback but allows restart
241+func (p *Player) Stop() error {
242+ if p.p == nil {
243+ return ErrPlayerClosed
244+ }
245+ return p.p.Stop()
246+}
247+
248+// Close permanently shuts down the player
249+func (p *Player) Close() error {
250+ if p.p == nil {
251+ return nil
252+ }
253+ return p.p.Close()
254+}
@@ -0,0 +1,254 @@
1+package core
2+
3+import (
4+ "errors"
5+ "sync"
6+ "sync/atomic"
7+)
8+
9+var (
10+ ErrInvalidPlayerState = errors.New("invalid player state transition")
11+ ErrPlayerClosed = errors.New("player is closed")
12+)
13+
14+// player is the internal player shell with state machine
15+type player struct {
16+ config playerConfig
17+ callbacks playerCallbacks
18+ state atomic.Int32
19+ paused atomic.Bool
20+ mu sync.Mutex
21+}
22+
23+// newPlayer creates a new player instance
24+func newPlayer(config PlayerConfig, callbacks PlayerCallbacks) *player {
25+ p := &player{
26+ config: config,
27+ callbacks: callbacks,
28+ }
29+ p.state.Store(int32(PlayerStateIdle))
30+ return p
31+}
32+
33+// State returns the current player state
34+func (p *player) State() PlayerState {
35+ return PlayerState(p.state.Load())
36+}
37+
38+// setState atomically sets the player state
39+func (p *player) setState(next PlayerState) {
40+ p.state.Store(int32(next))
41+}
42+
43+// transition attempts to transition to the next state
44+func (p *player) transition(next PlayerState) error {
45+ p.mu.Lock()
46+
47+ current := p.State()
48+
49+ // Validate transition
50+ if !isValidTransition(current, next) {
51+ p.mu.Unlock()
52+ return ErrInvalidPlayerState
53+ }
54+
55+ // Perform transition
56+ p.setState(next)
57+
58+ // Capture callback under lock
59+ cb := p.callbacks.OnStateChange
60+
61+ p.mu.Unlock()
62+
63+ // Invoke callback after releasing lock to avoid deadlock on reentry
64+ if cb != nil {
65+ cb(next)
66+ }
67+
68+ return nil
69+}
70+
71+// isValidTransition checks if a state transition is valid
72+func isValidTransition(current, next PlayerState) bool {
73+ switch current {
74+ case PlayerStateIdle:
75+ return next == PlayerStateStarting || next == PlayerStateClosing
76+ case PlayerStateStarting:
77+ return next == PlayerStatePlaying || next == PlayerStateError
78+ case PlayerStatePlaying:
79+ return next == PlayerStatePaused || next == PlayerStateStopped || next == PlayerStateClosing || next == PlayerStateError
80+ case PlayerStatePaused:
81+ return next == PlayerStatePlaying || next == PlayerStateStopped || next == PlayerStateClosing || next == PlayerStateError
82+ case PlayerStateStopped:
83+ return next == PlayerStateStarting || next == PlayerStateClosing
84+ case PlayerStateClosing:
85+ return next == PlayerStateClosed
86+ case PlayerStateClosed:
87+ return false // Terminal state
88+ case PlayerStateError:
89+ return next == PlayerStateClosing
90+ default:
91+ return false
92+ }
93+}
94+
95+// Start begins playback, transitioning from Idle or Stopped to Playing
96+func (p *player) Start() error {
97+ // Check if player is in terminal state
98+ current := p.State()
99+ if current == PlayerStateClosed || current == PlayerStateClosing {
100+ return ErrPlayerClosed
101+ }
102+
103+ // Only valid from Idle or Stopped
104+ if current != PlayerStateIdle && current != PlayerStateStopped {
105+ return ErrInvalidPlayerState
106+ }
107+
108+ // Ensure runtime is available (placeholder)
109+ if err := p.ensureRuntime(); err != nil {
110+ return err
111+ }
112+
113+ // Transition to Starting then Playing
114+ if err := p.transition(PlayerStateStarting); err != nil {
115+ return err
116+ }
117+
118+ // Clear paused flag
119+ p.setPaused(false)
120+
121+ // Transition to Playing
122+ return p.transition(PlayerStatePlaying)
123+}
124+
125+// Pause temporarily pauses playback
126+func (p *player) Pause() error {
127+ current := p.State()
128+ if current == PlayerStateClosed || current == PlayerStateClosing {
129+ return ErrPlayerClosed
130+ }
131+
132+ // Only valid from Playing
133+ if current != PlayerStatePlaying {
134+ return ErrInvalidPlayerState
135+ }
136+
137+ p.setPaused(true)
138+ return p.transition(PlayerStatePaused)
139+}
140+
141+// Stop stops playback but allows restart.
142+func (p *player) Stop() error {
143+ current := p.State()
144+ if current == PlayerStateClosed || current == PlayerStateClosing {
145+ return ErrPlayerClosed
146+ }
147+
148+ // Valid from Playing or Paused.
149+ if current != PlayerStatePlaying && current != PlayerStatePaused {
150+ return ErrInvalidPlayerState
151+ }
152+
153+ // Clear paused flag
154+ p.setPaused(false)
155+
156+ // Deactivate stream (placeholder)
157+ p.deactivateStream()
158+
159+ return p.transition(PlayerStateStopped)
160+}
161+
162+// Close permanently shuts down the player
163+func (p *player) Close() error {
164+ current := p.State()
165+
166+ // Idempotent on already closed
167+ if current == PlayerStateClosed {
168+ return nil
169+ }
170+
171+ // Transition to Closing if not already there
172+ if current != PlayerStateClosing {
173+ if err := p.transition(PlayerStateClosing); err != nil {
174+ return err
175+ }
176+ }
177+
178+ // Teardown resources (placeholder)
179+ p.teardown()
180+
181+ // Transition to Closed
182+ return p.transition(PlayerStateClosed)
183+}
184+
185+// ensureRuntime ensures the runtime is available (placeholder)
186+func (p *player) ensureRuntime() error {
187+ // Minimal placeholder - will be implemented in future tasks
188+ return nil
189+}
190+
191+// setPaused sets the paused flag
192+func (p *player) setPaused(paused bool) {
193+ p.paused.Store(paused)
194+}
195+
196+// deactivateStream deactivates the stream (placeholder)
197+func (p *player) deactivateStream() {
198+ // Minimal placeholder - will be implemented in future tasks
199+}
200+
201+// teardown performs cleanup (placeholder)
202+func (p *player) teardown() {
203+ // Minimal placeholder - will be implemented in future tasks
204+}
205+
206+// Player is the exported wrapper for the internal player
207+type Player struct {
208+ p *player
209+}
210+
211+// NewPlayer creates a new exported Player instance
212+func NewPlayer(config playerConfig, callbacks playerCallbacks) *Player {
213+ return &Player{p: newPlayer(config, callbacks)}
214+}
215+
216+// State returns the current player state
217+func (p *Player) State() PlayerState {
218+ if p.p == nil {
219+ return PlayerStateIdle
220+ }
221+ return p.p.State()
222+}
223+
224+// Start begins playback
225+func (p *Player) Start() error {
226+ if p.p == nil {
227+ return ErrPlayerClosed
228+ }
229+ return p.p.Start()
230+}
231+
232+// Pause temporarily pauses playback
233+func (p *Player) Pause() error {
234+ if p.p == nil {
235+ return ErrPlayerClosed
236+ }
237+ return p.p.Pause()
238+}
239+
240+// Stop stops playback but allows restart
241+func (p *Player) Stop() error {
242+ if p.p == nil {
243+ return ErrPlayerClosed
244+ }
245+ return p.p.Stop()
246+}
247+
248+// Close permanently shuts down the player
249+func (p *Player) Close() error {
250+ if p.p == nil {
251+ return nil
252+ }
253+ return p.p.Close()
254+}
A internal/core/player_rt.go +151 -0
@@ -0,0 +1,151 @@
1+package core
2+
3+import (
4+ "errors"
5+ "io"
6+)
7+
8+// UnderrunPolicy determines how to handle underruns
9+type UnderrunPolicy int
10+
11+const (
12+ UnderrunFillSilence UnderrunPolicy = iota
13+ UnderrunFailFast
14+)
15+
16+// PCMBuffer represents a PCM audio buffer with deinterleaved samples
17+// Samples[channel][frame] format for cache efficiency
18+type PCMBuffer struct {
19+ Frames int
20+ Channels int
21+ Stride int
22+ Samples [][]float32
23+}
24+
25+// allocate allocates the Samples slice structure
26+func (b *PCMBuffer) allocate() {
27+ if len(b.Samples) != b.Channels {
28+ b.Samples = make([][]float32, b.Channels)
29+ }
30+ for ch := 0; ch < b.Channels; ch++ {
31+ if len(b.Samples[ch]) != b.Frames {
32+ b.Samples[ch] = make([]float32, b.Frames)
33+ }
34+ }
35+}
36+
37+// PlayerConfig holds configuration for the player
38+type PlayerConfig struct {
39+ FramesPerBuffer int
40+ Channels int
41+ UnderrunPolicy UnderrunPolicy
42+}
43+
44+// PlayerCallbacks holds callbacks for player events
45+type PlayerCallbacks struct {
46+ OnStateChange func(PlayerState)
47+ Fill func(*PCMBuffer) (int, error)
48+ OnUnderrun func(int)
49+ OnDrain func()
50+ OnError func(error)
51+}
52+
53+// playerConfig is the internal alias for PlayerConfig
54+type playerConfig = PlayerConfig
55+
56+// playerCallbacks is the internal alias for PlayerCallbacks
57+type playerCallbacks = PlayerCallbacks
58+
59+// processPCM processes PCM audio data for the given buffer
60+// Returns the number of frames processed, or an error
61+func (p *player) processPCM(buf *PCMBuffer) (int, error) {
62+ // Ensure buffer is allocated
63+ buf.allocate()
64+
65+ state := p.State()
66+
67+ // If paused or stopped, fill with silence and return
68+ if state == PlayerStatePaused || state == PlayerStateStopped || state == PlayerStateIdle {
69+ p.fillSilence(buf, 0, buf.Frames)
70+ return buf.Frames, nil
71+ }
72+
73+ // If not playing, fill with silence
74+ if state != PlayerStatePlaying {
75+ p.fillSilence(buf, 0, buf.Frames)
76+ return buf.Frames, nil
77+ }
78+
79+ // Call Fill callback
80+ if p.callbacks.Fill == nil {
81+ p.fillSilence(buf, 0, buf.Frames)
82+ return buf.Frames, nil
83+ }
84+
85+ frames, err := p.callbacks.Fill(buf)
86+ if err != nil {
87+ if errors.Is(err, io.EOF) {
88+ // Drain condition - emit drain callback and return frames
89+ p.emitDrain()
90+ return frames, nil
91+ }
92+ // Other error - fail the player
93+ p.fail(err)
94+ return 0, err
95+ }
96+
97+ // Handle underrun if Fill returned fewer frames than requested
98+ if frames < buf.Frames {
99+ return p.handleUnderrun(buf, frames)
100+ }
101+
102+ return frames, nil
103+}
104+
105+// fillSilence fills the buffer with silence (0.0) from startFrame to endFrame
106+func (p *player) fillSilence(buf *PCMBuffer, startFrame, endFrame int) {
107+ if startFrame < 0 {
108+ startFrame = 0
109+ }
110+ if endFrame > buf.Frames {
111+ endFrame = buf.Frames
112+ }
113+ for ch := 0; ch < buf.Channels; ch++ {
114+ clear(buf.Samples[ch][startFrame:endFrame])
115+ }
116+}
117+
118+// handleUnderrun applies the underrun policy when Fill returns fewer frames than requested
119+func (p *player) handleUnderrun(buf *PCMBuffer, filledFrames int) (int, error) {
120+ // Emit underrun callback
121+ if p.callbacks.OnUnderrun != nil {
122+ p.callbacks.OnUnderrun(buf.Frames - filledFrames)
123+ }
124+
125+ switch p.config.UnderrunPolicy {
126+ case UnderrunFailFast:
127+ err := errors.New("underrun occurred")
128+ p.fail(err)
129+ return 0, err
130+ case UnderrunFillSilence:
131+ fallthrough
132+ default:
133+ p.fillSilence(buf, filledFrames, buf.Frames)
134+ return buf.Frames, nil
135+ }
136+}
137+
138+// emitDrain invokes the OnDrain callback
139+func (p *player) emitDrain() {
140+ if p.callbacks.OnDrain != nil {
141+ p.callbacks.OnDrain()
142+ }
143+}
144+
145+// fail transitions the player to error state and invokes OnError
146+func (p *player) fail(err error) {
147+ p.transition(PlayerStateError)
148+ if p.callbacks.OnError != nil {
149+ p.callbacks.OnError(err)
150+ }
151+}
@@ -0,0 +1,151 @@
1+package core
2+
3+import (
4+ "errors"
5+ "io"
6+)
7+
8+// UnderrunPolicy determines how to handle underruns
9+type UnderrunPolicy int
10+
11+const (
12+ UnderrunFillSilence UnderrunPolicy = iota
13+ UnderrunFailFast
14+)
15+
16+// PCMBuffer represents a PCM audio buffer with deinterleaved samples
17+// Samples[channel][frame] format for cache efficiency
18+type PCMBuffer struct {
19+ Frames int
20+ Channels int
21+ Stride int
22+ Samples [][]float32
23+}
24+
25+// allocate allocates the Samples slice structure
26+func (b *PCMBuffer) allocate() {
27+ if len(b.Samples) != b.Channels {
28+ b.Samples = make([][]float32, b.Channels)
29+ }
30+ for ch := 0; ch < b.Channels; ch++ {
31+ if len(b.Samples[ch]) != b.Frames {
32+ b.Samples[ch] = make([]float32, b.Frames)
33+ }
34+ }
35+}
36+
37+// PlayerConfig holds configuration for the player
38+type PlayerConfig struct {
39+ FramesPerBuffer int
40+ Channels int
41+ UnderrunPolicy UnderrunPolicy
42+}
43+
44+// PlayerCallbacks holds callbacks for player events
45+type PlayerCallbacks struct {
46+ OnStateChange func(PlayerState)
47+ Fill func(*PCMBuffer) (int, error)
48+ OnUnderrun func(int)
49+ OnDrain func()
50+ OnError func(error)
51+}
52+
53+// playerConfig is the internal alias for PlayerConfig
54+type playerConfig = PlayerConfig
55+
56+// playerCallbacks is the internal alias for PlayerCallbacks
57+type playerCallbacks = PlayerCallbacks
58+
59+// processPCM processes PCM audio data for the given buffer
60+// Returns the number of frames processed, or an error
61+func (p *player) processPCM(buf *PCMBuffer) (int, error) {
62+ // Ensure buffer is allocated
63+ buf.allocate()
64+
65+ state := p.State()
66+
67+ // If paused or stopped, fill with silence and return
68+ if state == PlayerStatePaused || state == PlayerStateStopped || state == PlayerStateIdle {
69+ p.fillSilence(buf, 0, buf.Frames)
70+ return buf.Frames, nil
71+ }
72+
73+ // If not playing, fill with silence
74+ if state != PlayerStatePlaying {
75+ p.fillSilence(buf, 0, buf.Frames)
76+ return buf.Frames, nil
77+ }
78+
79+ // Call Fill callback
80+ if p.callbacks.Fill == nil {
81+ p.fillSilence(buf, 0, buf.Frames)
82+ return buf.Frames, nil
83+ }
84+
85+ frames, err := p.callbacks.Fill(buf)
86+ if err != nil {
87+ if errors.Is(err, io.EOF) {
88+ // Drain condition - emit drain callback and return frames
89+ p.emitDrain()
90+ return frames, nil
91+ }
92+ // Other error - fail the player
93+ p.fail(err)
94+ return 0, err
95+ }
96+
97+ // Handle underrun if Fill returned fewer frames than requested
98+ if frames < buf.Frames {
99+ return p.handleUnderrun(buf, frames)
100+ }
101+
102+ return frames, nil
103+}
104+
105+// fillSilence fills the buffer with silence (0.0) from startFrame to endFrame
106+func (p *player) fillSilence(buf *PCMBuffer, startFrame, endFrame int) {
107+ if startFrame < 0 {
108+ startFrame = 0
109+ }
110+ if endFrame > buf.Frames {
111+ endFrame = buf.Frames
112+ }
113+ for ch := 0; ch < buf.Channels; ch++ {
114+ clear(buf.Samples[ch][startFrame:endFrame])
115+ }
116+}
117+
118+// handleUnderrun applies the underrun policy when Fill returns fewer frames than requested
119+func (p *player) handleUnderrun(buf *PCMBuffer, filledFrames int) (int, error) {
120+ // Emit underrun callback
121+ if p.callbacks.OnUnderrun != nil {
122+ p.callbacks.OnUnderrun(buf.Frames - filledFrames)
123+ }
124+
125+ switch p.config.UnderrunPolicy {
126+ case UnderrunFailFast:
127+ err := errors.New("underrun occurred")
128+ p.fail(err)
129+ return 0, err
130+ case UnderrunFillSilence:
131+ fallthrough
132+ default:
133+ p.fillSilence(buf, filledFrames, buf.Frames)
134+ return buf.Frames, nil
135+ }
136+}
137+
138+// emitDrain invokes the OnDrain callback
139+func (p *player) emitDrain() {
140+ if p.callbacks.OnDrain != nil {
141+ p.callbacks.OnDrain()
142+ }
143+}
144+
145+// fail transitions the player to error state and invokes OnError
146+func (p *player) fail(err error) {
147+ p.transition(PlayerStateError)
148+ if p.callbacks.OnError != nil {
149+ p.callbacks.OnError(err)
150+ }
151+}
A internal/core/player_rt_test.go +487 -0
@@ -0,0 +1,487 @@
1+package core
2+
3+import (
4+ "errors"
5+ "io"
6+ "testing"
7+)
8+
9+// TestProcessCallbackSilenceFillUnderrun tests that when Fill returns fewer frames
10+// than requested, the remaining frames are filled with silence
11+func TestProcessCallbackSilenceFillUnderrun(t *testing.T) {
12+ fillCallCount := 0
13+ var receivedBuf *PCMBuffer
14+
15+ p := newPlayer(
16+ PlayerConfig{
17+ FramesPerBuffer: 4,
18+ Channels: 2,
19+ UnderrunPolicy: UnderrunFillSilence,
20+ },
21+ PlayerCallbacks{
22+ Fill: func(buf *PCMBuffer) (int, error) {
23+ fillCallCount++
24+ receivedBuf = buf
25+ // Return only 2 frames, leaving 2 frames as underrun
26+ return 2, nil
27+ },
28+ },
29+ )
30+
31+ // Set player to playing state
32+ p.setState(PlayerStatePlaying)
33+
34+ buf := &PCMBuffer{
35+ Frames: 4,
36+ Channels: 2,
37+ }
38+ buf.allocate()
39+
40+ frames, err := p.processPCM(buf)
41+ if err != nil {
42+ t.Fatalf("processPCM returned error: %v", err)
43+ }
44+
45+ if frames != 4 {
46+ t.Errorf("processPCM returned %d frames, want 4", frames)
47+ }
48+
49+ if fillCallCount != 1 {
50+ t.Errorf("Fill callback called %d times, want 1", fillCallCount)
51+ }
52+
53+ // Verify the buffer was silence-filled
54+ // Frames 0-1 should have data (we don't verify the actual values since Fill could write anything)
55+ // Frames 2-3 should be silence (0.0)
56+ for ch := 0; ch < 2; ch++ {
57+ for frame := 2; frame < 4; frame++ {
58+ if buf.Samples[ch][frame] != 0.0 {
59+ t.Errorf("Expected silence at channel %d frame %d, got %f", ch, frame, buf.Samples[ch][frame])
60+ }
61+ }
62+ }
63+
64+ if receivedBuf != buf {
65+ t.Error("Fill callback received different buffer than passed to processPCM")
66+ }
67+}
68+
69+// TestProcessCallbackFailFastUnderrun tests that when configured to fail on underrun,
70+// the player transitions to error state and invokes OnError callback
71+func TestProcessCallbackFailFastUnderrun(t *testing.T) {
72+ errCalled := false
73+ var errReceived error
74+
75+ p := newPlayer(
76+ PlayerConfig{
77+ FramesPerBuffer: 4,
78+ Channels: 2,
79+ UnderrunPolicy: UnderrunFailFast,
80+ },
81+ PlayerCallbacks{
82+ Fill: func(buf *PCMBuffer) (int, error) {
83+ // Return only 2 frames, triggering underrun
84+ return 2, nil
85+ },
86+ OnError: func(err error) {
87+ errCalled = true
88+ errReceived = err
89+ },
90+ },
91+ )
92+
93+ // Set player to playing state
94+ p.setState(PlayerStatePlaying)
95+
96+ buf := &PCMBuffer{
97+ Frames: 4,
98+ Channels: 2,
99+ }
100+ buf.allocate()
101+
102+ _, err := p.processPCM(buf)
103+ if err == nil {
104+ t.Fatal("processPCM should have returned error for underrun with FailFast policy")
105+ }
106+
107+ if p.State() != PlayerStateError {
108+ t.Errorf("expected state Error after underrun, got %v", p.State())
109+ }
110+
111+ if !errCalled {
112+ t.Error("OnError callback was not called")
113+ }
114+
115+ if errReceived == nil {
116+ t.Error("OnError callback received nil error")
117+ }
118+}
119+
120+// TestProcessCallbackPausedSuppressesFillAndEmitsSilence tests that when paused,
121+// Fill is not called and the buffer is filled with silence
122+func TestProcessCallbackPausedSuppressesFillAndEmitsSilence(t *testing.T) {
123+ fillCallCount := 0
124+
125+ p := newPlayer(
126+ PlayerConfig{
127+ FramesPerBuffer: 4,
128+ Channels: 2,
129+ UnderrunPolicy: UnderrunFillSilence,
130+ },
131+ PlayerCallbacks{
132+ Fill: func(buf *PCMBuffer) (int, error) {
133+ fillCallCount++
134+ return 4, nil
135+ },
136+ },
137+ )
138+
139+ // Set player to paused state
140+ p.setState(PlayerStatePaused)
141+
142+ buf := &PCMBuffer{
143+ Frames: 4,
144+ Channels: 2,
145+ }
146+ buf.allocate()
147+
148+ frames, err := p.processPCM(buf)
149+ if err != nil {
150+ t.Fatalf("processPCM returned error: %v", err)
151+ }
152+
153+ if frames != 4 {
154+ t.Errorf("processPCM returned %d frames, want 4", frames)
155+ }
156+
157+ if fillCallCount != 0 {
158+ t.Errorf("Fill callback called %d times while paused, want 0", fillCallCount)
159+ }
160+
161+ // Verify the entire buffer is silence
162+ for ch := 0; ch < 2; ch++ {
163+ for frame := 0; frame < 4; frame++ {
164+ if buf.Samples[ch][frame] != 0.0 {
165+ t.Errorf("Expected silence at channel %d frame %d while paused, got %f", ch, frame, buf.Samples[ch][frame])
166+ }
167+ }
168+ }
169+}
170+
171+// TestProcessCallbackStoppedFillsSilence tests that when stopped,
172+// Fill is not called and the buffer is filled with silence
173+func TestProcessCallbackStoppedFillsSilence(t *testing.T) {
174+ fillCallCount := 0
175+
176+ p := newPlayer(
177+ PlayerConfig{
178+ FramesPerBuffer: 4,
179+ Channels: 2,
180+ UnderrunPolicy: UnderrunFillSilence,
181+ },
182+ PlayerCallbacks{
183+ Fill: func(buf *PCMBuffer) (int, error) {
184+ fillCallCount++
185+ return 4, nil
186+ },
187+ },
188+ )
189+
190+ // Set player to stopped state
191+ p.setState(PlayerStateStopped)
192+
193+ buf := &PCMBuffer{
194+ Frames: 4,
195+ Channels: 2,
196+ }
197+ buf.allocate()
198+
199+ frames, err := p.processPCM(buf)
200+ if err != nil {
201+ t.Fatalf("processPCM returned error: %v", err)
202+ }
203+
204+ if frames != 4 {
205+ t.Errorf("processPCM returned %d frames, want 4", frames)
206+ }
207+
208+ if fillCallCount != 0 {
209+ t.Errorf("Fill callback called %d times while stopped, want 0", fillCallCount)
210+ }
211+
212+ // Verify the entire buffer is silence
213+ for ch := 0; ch < 2; ch++ {
214+ for frame := 0; frame < 4; frame++ {
215+ if buf.Samples[ch][frame] != 0.0 {
216+ t.Errorf("Expected silence at channel %d frame %d while stopped, got %f", ch, frame, buf.Samples[ch][frame])
217+ }
218+ }
219+ }
220+}
221+
222+// TestProcessCallbackDrainOnEOF tests that io.EOF from Fill triggers drain callback
223+func TestProcessCallbackDrainOnEOF(t *testing.T) {
224+ drainCalled := false
225+ fillCallCount := 0
226+
227+ p := newPlayer(
228+ PlayerConfig{
229+ FramesPerBuffer: 4,
230+ Channels: 2,
231+ UnderrunPolicy: UnderrunFillSilence,
232+ },
233+ PlayerCallbacks{
234+ Fill: func(buf *PCMBuffer) (int, error) {
235+ fillCallCount++
236+ // Return EOF to signal drain
237+ return 2, io.EOF
238+ },
239+ OnDrain: func() {
240+ drainCalled = true
241+ },
242+ },
243+ )
244+
245+ // Set player to playing state
246+ p.setState(PlayerStatePlaying)
247+
248+ buf := &PCMBuffer{
249+ Frames: 4,
250+ Channels: 2,
251+ }
252+ buf.allocate()
253+
254+ frames, err := p.processPCM(buf)
255+ if err != nil {
256+ t.Fatalf("processPCM returned error: %v", err)
257+ }
258+
259+ if frames != 2 {
260+ t.Errorf("processPCM returned %d frames, want 2", frames)
261+ }
262+
263+ if !drainCalled {
264+ t.Error("OnDrain callback was not called on io.EOF")
265+ }
266+
267+ if fillCallCount != 1 {
268+ t.Errorf("Fill callback called %d times, want 1", fillCallCount)
269+ }
270+}
271+
272+// TestProcessCallbackErrorHandling tests that errors from Fill (other than EOF)
273+// fail the player and invoke OnError callback
274+func TestProcessCallbackErrorHandling(t *testing.T) {
275+ testErr := errors.New("fill error")
276+ errCalled := false
277+ var errReceived error
278+
279+ p := newPlayer(
280+ PlayerConfig{
281+ FramesPerBuffer: 4,
282+ Channels: 2,
283+ UnderrunPolicy: UnderrunFillSilence,
284+ },
285+ PlayerCallbacks{
286+ Fill: func(buf *PCMBuffer) (int, error) {
287+ return 0, testErr
288+ },
289+ OnError: func(err error) {
290+ errCalled = true
291+ errReceived = err
292+ },
293+ },
294+ )
295+
296+ // Set player to playing state
297+ p.setState(PlayerStatePlaying)
298+
299+ buf := &PCMBuffer{
300+ Frames: 4,
301+ Channels: 2,
302+ }
303+ buf.allocate()
304+
305+ _, err := p.processPCM(buf)
306+ if err == nil {
307+ t.Fatal("processPCM should have returned error")
308+ }
309+
310+ if p.State() != PlayerStateError {
311+ t.Errorf("expected state Error after fill error, got %v", p.State())
312+ }
313+
314+ if !errCalled {
315+ t.Error("OnError callback was not called")
316+ }
317+
318+ if !errors.Is(errReceived, testErr) {
319+ t.Errorf("OnError received error %v, want %v", errReceived, testErr)
320+ }
321+}
322+
323+// TestProcessCallbackIdleFillsSilence tests that when idle,
324+// Fill is not called and the buffer is filled with silence
325+func TestProcessCallbackIdleFillsSilence(t *testing.T) {
326+ fillCallCount := 0
327+
328+ p := newPlayer(
329+ PlayerConfig{
330+ FramesPerBuffer: 4,
331+ Channels: 2,
332+ UnderrunPolicy: UnderrunFillSilence,
333+ },
334+ PlayerCallbacks{
335+ Fill: func(buf *PCMBuffer) (int, error) {
336+ fillCallCount++
337+ return 4, nil
338+ },
339+ },
340+ )
341+
342+ // Player starts in Idle state
343+ buf := &PCMBuffer{
344+ Frames: 4,
345+ Channels: 2,
346+ }
347+ buf.allocate()
348+
349+ frames, err := p.processPCM(buf)
350+ if err != nil {
351+ t.Fatalf("processPCM returned error: %v", err)
352+ }
353+
354+ if frames != 4 {
355+ t.Errorf("processPCM returned %d frames, want 4", frames)
356+ }
357+
358+ if fillCallCount != 0 {
359+ t.Errorf("Fill callback called %d times while idle, want 0", fillCallCount)
360+ }
361+}
362+
363+// TestProcessCallbackUnderrunCallback tests that OnUnderrun is called when underrun occurs
364+func TestProcessCallbackUnderrunCallback(t *testing.T) {
365+ underrunCalled := false
366+ underrunFrames := 0
367+
368+ p := newPlayer(
369+ PlayerConfig{
370+ FramesPerBuffer: 4,
371+ Channels: 2,
372+ UnderrunPolicy: UnderrunFillSilence,
373+ },
374+ PlayerCallbacks{
375+ Fill: func(buf *PCMBuffer) (int, error) {
376+ return 2, nil
377+ },
378+ OnUnderrun: func(frames int) {
379+ underrunCalled = true
380+ underrunFrames = frames
381+ },
382+ },
383+ )
384+
385+ // Set player to playing state
386+ p.setState(PlayerStatePlaying)
387+
388+ buf := &PCMBuffer{
389+ Frames: 4,
390+ Channels: 2,
391+ }
392+ buf.allocate()
393+
394+ _, err := p.processPCM(buf)
395+ if err != nil {
396+ t.Fatalf("processPCM returned error: %v", err)
397+ }
398+
399+ if !underrunCalled {
400+ t.Error("OnUnderrun callback was not called")
401+ }
402+
403+ if underrunFrames != 2 {
404+ t.Errorf("OnUnderrun received %d frames, want 2", underrunFrames)
405+ }
406+}
407+
408+// TestProcessCallbackFillReturnsFullFrames tests normal operation when Fill returns all requested frames
409+func TestProcessCallbackFillReturnsFullFrames(t *testing.T) {
410+ fillCallCount := 0
411+
412+ p := newPlayer(
413+ PlayerConfig{
414+ FramesPerBuffer: 4,
415+ Channels: 2,
416+ UnderrunPolicy: UnderrunFillSilence,
417+ },
418+ PlayerCallbacks{
419+ Fill: func(buf *PCMBuffer) (int, error) {
420+ fillCallCount++
421+ // Write some data to verify Fill was called
422+ for ch := 0; ch < buf.Channels; ch++ {
423+ for frame := 0; frame < buf.Frames; frame++ {
424+ buf.Samples[ch][frame] = float32(frame + ch*10)
425+ }
426+ }
427+ return 4, nil
428+ },
429+ },
430+ )
431+
432+ // Set player to playing state
433+ p.setState(PlayerStatePlaying)
434+
435+ buf := &PCMBuffer{
436+ Frames: 4,
437+ Channels: 2,
438+ }
439+ buf.allocate()
440+
441+ frames, err := p.processPCM(buf)
442+ if err != nil {
443+ t.Fatalf("processPCM returned error: %v", err)
444+ }
445+
446+ if frames != 4 {
447+ t.Errorf("processPCM returned %d frames, want 4", frames)
448+ }
449+
450+ if fillCallCount != 1 {
451+ t.Errorf("Fill callback called %d times, want 1", fillCallCount)
452+ }
453+
454+ // Verify the data was written by Fill
455+ for ch := 0; ch < 2; ch++ {
456+ for frame := 0; frame < 4; frame++ {
457+ expected := float32(frame + ch*10)
458+ if buf.Samples[ch][frame] != expected {
459+ t.Errorf("Expected %f at channel %d frame %d, got %f", expected, ch, frame, buf.Samples[ch][frame])
460+ }
461+ }
462+ }
463+}
464+
465+// TestPCMBufferAllocate tests the allocate method
466+func TestPCMBufferAllocate(t *testing.T) {
467+ buf := &PCMBuffer{
468+ Frames: 4,
469+ Channels: 2,
470+ }
471+
472+ buf.allocate()
473+
474+ if buf.Samples == nil {
475+ t.Fatal("Samples is nil after allocate")
476+ }
477+
478+ if len(buf.Samples) != 2 {
479+ t.Errorf("len(Samples) = %d, want 2", len(buf.Samples))
480+ }
481+
482+ for ch := 0; ch < 2; ch++ {
483+ if len(buf.Samples[ch]) != 4 {
484+ t.Errorf("len(Samples[%d]) = %d, want 4", ch, len(buf.Samples[ch]))
485+ }
486+ }
487+}
@@ -0,0 +1,487 @@
1+package core
2+
3+import (
4+ "errors"
5+ "io"
6+ "testing"
7+)
8+
9+// TestProcessCallbackSilenceFillUnderrun tests that when Fill returns fewer frames
10+// than requested, the remaining frames are filled with silence
11+func TestProcessCallbackSilenceFillUnderrun(t *testing.T) {
12+ fillCallCount := 0
13+ var receivedBuf *PCMBuffer
14+
15+ p := newPlayer(
16+ PlayerConfig{
17+ FramesPerBuffer: 4,
18+ Channels: 2,
19+ UnderrunPolicy: UnderrunFillSilence,
20+ },
21+ PlayerCallbacks{
22+ Fill: func(buf *PCMBuffer) (int, error) {
23+ fillCallCount++
24+ receivedBuf = buf
25+ // Return only 2 frames, leaving 2 frames as underrun
26+ return 2, nil
27+ },
28+ },
29+ )
30+
31+ // Set player to playing state
32+ p.setState(PlayerStatePlaying)
33+
34+ buf := &PCMBuffer{
35+ Frames: 4,
36+ Channels: 2,
37+ }
38+ buf.allocate()
39+
40+ frames, err := p.processPCM(buf)
41+ if err != nil {
42+ t.Fatalf("processPCM returned error: %v", err)
43+ }
44+
45+ if frames != 4 {
46+ t.Errorf("processPCM returned %d frames, want 4", frames)
47+ }
48+
49+ if fillCallCount != 1 {
50+ t.Errorf("Fill callback called %d times, want 1", fillCallCount)
51+ }
52+
53+ // Verify the buffer was silence-filled
54+ // Frames 0-1 should have data (we don't verify the actual values since Fill could write anything)
55+ // Frames 2-3 should be silence (0.0)
56+ for ch := 0; ch < 2; ch++ {
57+ for frame := 2; frame < 4; frame++ {
58+ if buf.Samples[ch][frame] != 0.0 {
59+ t.Errorf("Expected silence at channel %d frame %d, got %f", ch, frame, buf.Samples[ch][frame])
60+ }
61+ }
62+ }
63+
64+ if receivedBuf != buf {
65+ t.Error("Fill callback received different buffer than passed to processPCM")
66+ }
67+}
68+
69+// TestProcessCallbackFailFastUnderrun tests that when configured to fail on underrun,
70+// the player transitions to error state and invokes OnError callback
71+func TestProcessCallbackFailFastUnderrun(t *testing.T) {
72+ errCalled := false
73+ var errReceived error
74+
75+ p := newPlayer(
76+ PlayerConfig{
77+ FramesPerBuffer: 4,
78+ Channels: 2,
79+ UnderrunPolicy: UnderrunFailFast,
80+ },
81+ PlayerCallbacks{
82+ Fill: func(buf *PCMBuffer) (int, error) {
83+ // Return only 2 frames, triggering underrun
84+ return 2, nil
85+ },
86+ OnError: func(err error) {
87+ errCalled = true
88+ errReceived = err
89+ },
90+ },
91+ )
92+
93+ // Set player to playing state
94+ p.setState(PlayerStatePlaying)
95+
96+ buf := &PCMBuffer{
97+ Frames: 4,
98+ Channels: 2,
99+ }
100+ buf.allocate()
101+
102+ _, err := p.processPCM(buf)
103+ if err == nil {
104+ t.Fatal("processPCM should have returned error for underrun with FailFast policy")
105+ }
106+
107+ if p.State() != PlayerStateError {
108+ t.Errorf("expected state Error after underrun, got %v", p.State())
109+ }
110+
111+ if !errCalled {
112+ t.Error("OnError callback was not called")
113+ }
114+
115+ if errReceived == nil {
116+ t.Error("OnError callback received nil error")
117+ }
118+}
119+
120+// TestProcessCallbackPausedSuppressesFillAndEmitsSilence tests that when paused,
121+// Fill is not called and the buffer is filled with silence
122+func TestProcessCallbackPausedSuppressesFillAndEmitsSilence(t *testing.T) {
123+ fillCallCount := 0
124+
125+ p := newPlayer(
126+ PlayerConfig{
127+ FramesPerBuffer: 4,
128+ Channels: 2,
129+ UnderrunPolicy: UnderrunFillSilence,
130+ },
131+ PlayerCallbacks{
132+ Fill: func(buf *PCMBuffer) (int, error) {
133+ fillCallCount++
134+ return 4, nil
135+ },
136+ },
137+ )
138+
139+ // Set player to paused state
140+ p.setState(PlayerStatePaused)
141+
142+ buf := &PCMBuffer{
143+ Frames: 4,
144+ Channels: 2,
145+ }
146+ buf.allocate()
147+
148+ frames, err := p.processPCM(buf)
149+ if err != nil {
150+ t.Fatalf("processPCM returned error: %v", err)
151+ }
152+
153+ if frames != 4 {
154+ t.Errorf("processPCM returned %d frames, want 4", frames)
155+ }
156+
157+ if fillCallCount != 0 {
158+ t.Errorf("Fill callback called %d times while paused, want 0", fillCallCount)
159+ }
160+
161+ // Verify the entire buffer is silence
162+ for ch := 0; ch < 2; ch++ {
163+ for frame := 0; frame < 4; frame++ {
164+ if buf.Samples[ch][frame] != 0.0 {
165+ t.Errorf("Expected silence at channel %d frame %d while paused, got %f", ch, frame, buf.Samples[ch][frame])
166+ }
167+ }
168+ }
169+}
170+
171+// TestProcessCallbackStoppedFillsSilence tests that when stopped,
172+// Fill is not called and the buffer is filled with silence
173+func TestProcessCallbackStoppedFillsSilence(t *testing.T) {
174+ fillCallCount := 0
175+
176+ p := newPlayer(
177+ PlayerConfig{
178+ FramesPerBuffer: 4,
179+ Channels: 2,
180+ UnderrunPolicy: UnderrunFillSilence,
181+ },
182+ PlayerCallbacks{
183+ Fill: func(buf *PCMBuffer) (int, error) {
184+ fillCallCount++
185+ return 4, nil
186+ },
187+ },
188+ )
189+
190+ // Set player to stopped state
191+ p.setState(PlayerStateStopped)
192+
193+ buf := &PCMBuffer{
194+ Frames: 4,
195+ Channels: 2,
196+ }
197+ buf.allocate()
198+
199+ frames, err := p.processPCM(buf)
200+ if err != nil {
201+ t.Fatalf("processPCM returned error: %v", err)
202+ }
203+
204+ if frames != 4 {
205+ t.Errorf("processPCM returned %d frames, want 4", frames)
206+ }
207+
208+ if fillCallCount != 0 {
209+ t.Errorf("Fill callback called %d times while stopped, want 0", fillCallCount)
210+ }
211+
212+ // Verify the entire buffer is silence
213+ for ch := 0; ch < 2; ch++ {
214+ for frame := 0; frame < 4; frame++ {
215+ if buf.Samples[ch][frame] != 0.0 {
216+ t.Errorf("Expected silence at channel %d frame %d while stopped, got %f", ch, frame, buf.Samples[ch][frame])
217+ }
218+ }
219+ }
220+}
221+
222+// TestProcessCallbackDrainOnEOF tests that io.EOF from Fill triggers drain callback
223+func TestProcessCallbackDrainOnEOF(t *testing.T) {
224+ drainCalled := false
225+ fillCallCount := 0
226+
227+ p := newPlayer(
228+ PlayerConfig{
229+ FramesPerBuffer: 4,
230+ Channels: 2,
231+ UnderrunPolicy: UnderrunFillSilence,
232+ },
233+ PlayerCallbacks{
234+ Fill: func(buf *PCMBuffer) (int, error) {
235+ fillCallCount++
236+ // Return EOF to signal drain
237+ return 2, io.EOF
238+ },
239+ OnDrain: func() {
240+ drainCalled = true
241+ },
242+ },
243+ )
244+
245+ // Set player to playing state
246+ p.setState(PlayerStatePlaying)
247+
248+ buf := &PCMBuffer{
249+ Frames: 4,
250+ Channels: 2,
251+ }
252+ buf.allocate()
253+
254+ frames, err := p.processPCM(buf)
255+ if err != nil {
256+ t.Fatalf("processPCM returned error: %v", err)
257+ }
258+
259+ if frames != 2 {
260+ t.Errorf("processPCM returned %d frames, want 2", frames)
261+ }
262+
263+ if !drainCalled {
264+ t.Error("OnDrain callback was not called on io.EOF")
265+ }
266+
267+ if fillCallCount != 1 {
268+ t.Errorf("Fill callback called %d times, want 1", fillCallCount)
269+ }
270+}
271+
272+// TestProcessCallbackErrorHandling tests that errors from Fill (other than EOF)
273+// fail the player and invoke OnError callback
274+func TestProcessCallbackErrorHandling(t *testing.T) {
275+ testErr := errors.New("fill error")
276+ errCalled := false
277+ var errReceived error
278+
279+ p := newPlayer(
280+ PlayerConfig{
281+ FramesPerBuffer: 4,
282+ Channels: 2,
283+ UnderrunPolicy: UnderrunFillSilence,
284+ },
285+ PlayerCallbacks{
286+ Fill: func(buf *PCMBuffer) (int, error) {
287+ return 0, testErr
288+ },
289+ OnError: func(err error) {
290+ errCalled = true
291+ errReceived = err
292+ },
293+ },
294+ )
295+
296+ // Set player to playing state
297+ p.setState(PlayerStatePlaying)
298+
299+ buf := &PCMBuffer{
300+ Frames: 4,
301+ Channels: 2,
302+ }
303+ buf.allocate()
304+
305+ _, err := p.processPCM(buf)
306+ if err == nil {
307+ t.Fatal("processPCM should have returned error")
308+ }
309+
310+ if p.State() != PlayerStateError {
311+ t.Errorf("expected state Error after fill error, got %v", p.State())
312+ }
313+
314+ if !errCalled {
315+ t.Error("OnError callback was not called")
316+ }
317+
318+ if !errors.Is(errReceived, testErr) {
319+ t.Errorf("OnError received error %v, want %v", errReceived, testErr)
320+ }
321+}
322+
323+// TestProcessCallbackIdleFillsSilence tests that when idle,
324+// Fill is not called and the buffer is filled with silence
325+func TestProcessCallbackIdleFillsSilence(t *testing.T) {
326+ fillCallCount := 0
327+
328+ p := newPlayer(
329+ PlayerConfig{
330+ FramesPerBuffer: 4,
331+ Channels: 2,
332+ UnderrunPolicy: UnderrunFillSilence,
333+ },
334+ PlayerCallbacks{
335+ Fill: func(buf *PCMBuffer) (int, error) {
336+ fillCallCount++
337+ return 4, nil
338+ },
339+ },
340+ )
341+
342+ // Player starts in Idle state
343+ buf := &PCMBuffer{
344+ Frames: 4,
345+ Channels: 2,
346+ }
347+ buf.allocate()
348+
349+ frames, err := p.processPCM(buf)
350+ if err != nil {
351+ t.Fatalf("processPCM returned error: %v", err)
352+ }
353+
354+ if frames != 4 {
355+ t.Errorf("processPCM returned %d frames, want 4", frames)
356+ }
357+
358+ if fillCallCount != 0 {
359+ t.Errorf("Fill callback called %d times while idle, want 0", fillCallCount)
360+ }
361+}
362+
363+// TestProcessCallbackUnderrunCallback tests that OnUnderrun is called when underrun occurs
364+func TestProcessCallbackUnderrunCallback(t *testing.T) {
365+ underrunCalled := false
366+ underrunFrames := 0
367+
368+ p := newPlayer(
369+ PlayerConfig{
370+ FramesPerBuffer: 4,
371+ Channels: 2,
372+ UnderrunPolicy: UnderrunFillSilence,
373+ },
374+ PlayerCallbacks{
375+ Fill: func(buf *PCMBuffer) (int, error) {
376+ return 2, nil
377+ },
378+ OnUnderrun: func(frames int) {
379+ underrunCalled = true
380+ underrunFrames = frames
381+ },
382+ },
383+ )
384+
385+ // Set player to playing state
386+ p.setState(PlayerStatePlaying)
387+
388+ buf := &PCMBuffer{
389+ Frames: 4,
390+ Channels: 2,
391+ }
392+ buf.allocate()
393+
394+ _, err := p.processPCM(buf)
395+ if err != nil {
396+ t.Fatalf("processPCM returned error: %v", err)
397+ }
398+
399+ if !underrunCalled {
400+ t.Error("OnUnderrun callback was not called")
401+ }
402+
403+ if underrunFrames != 2 {
404+ t.Errorf("OnUnderrun received %d frames, want 2", underrunFrames)
405+ }
406+}
407+
408+// TestProcessCallbackFillReturnsFullFrames tests normal operation when Fill returns all requested frames
409+func TestProcessCallbackFillReturnsFullFrames(t *testing.T) {
410+ fillCallCount := 0
411+
412+ p := newPlayer(
413+ PlayerConfig{
414+ FramesPerBuffer: 4,
415+ Channels: 2,
416+ UnderrunPolicy: UnderrunFillSilence,
417+ },
418+ PlayerCallbacks{
419+ Fill: func(buf *PCMBuffer) (int, error) {
420+ fillCallCount++
421+ // Write some data to verify Fill was called
422+ for ch := 0; ch < buf.Channels; ch++ {
423+ for frame := 0; frame < buf.Frames; frame++ {
424+ buf.Samples[ch][frame] = float32(frame + ch*10)
425+ }
426+ }
427+ return 4, nil
428+ },
429+ },
430+ )
431+
432+ // Set player to playing state
433+ p.setState(PlayerStatePlaying)
434+
435+ buf := &PCMBuffer{
436+ Frames: 4,
437+ Channels: 2,
438+ }
439+ buf.allocate()
440+
441+ frames, err := p.processPCM(buf)
442+ if err != nil {
443+ t.Fatalf("processPCM returned error: %v", err)
444+ }
445+
446+ if frames != 4 {
447+ t.Errorf("processPCM returned %d frames, want 4", frames)
448+ }
449+
450+ if fillCallCount != 1 {
451+ t.Errorf("Fill callback called %d times, want 1", fillCallCount)
452+ }
453+
454+ // Verify the data was written by Fill
455+ for ch := 0; ch < 2; ch++ {
456+ for frame := 0; frame < 4; frame++ {
457+ expected := float32(frame + ch*10)
458+ if buf.Samples[ch][frame] != expected {
459+ t.Errorf("Expected %f at channel %d frame %d, got %f", expected, ch, frame, buf.Samples[ch][frame])
460+ }
461+ }
462+ }
463+}
464+
465+// TestPCMBufferAllocate tests the allocate method
466+func TestPCMBufferAllocate(t *testing.T) {
467+ buf := &PCMBuffer{
468+ Frames: 4,
469+ Channels: 2,
470+ }
471+
472+ buf.allocate()
473+
474+ if buf.Samples == nil {
475+ t.Fatal("Samples is nil after allocate")
476+ }
477+
478+ if len(buf.Samples) != 2 {
479+ t.Errorf("len(Samples) = %d, want 2", len(buf.Samples))
480+ }
481+
482+ for ch := 0; ch < 2; ch++ {
483+ if len(buf.Samples[ch]) != 4 {
484+ t.Errorf("len(Samples[%d]) = %d, want 4", ch, len(buf.Samples[ch]))
485+ }
486+ }
487+}
A internal/core/player_state.go +15 -0
@@ -0,0 +1,15 @@
1+package core
2+
3+// PlayerState represents the current state of the player
4+type PlayerState int32
5+
6+const (
7+ PlayerStateIdle PlayerState = iota
8+ PlayerStateStarting
9+ PlayerStatePlaying
10+ PlayerStatePaused
11+ PlayerStateStopped
12+ PlayerStateClosing
13+ PlayerStateClosed
14+ PlayerStateError
15+)
@@ -0,0 +1,15 @@
1+package core
2+
3+// PlayerState represents the current state of the player
4+type PlayerState int32
5+
6+const (
7+ PlayerStateIdle PlayerState = iota
8+ PlayerStateStarting
9+ PlayerStatePlaying
10+ PlayerStatePaused
11+ PlayerStateStopped
12+ PlayerStateClosing
13+ PlayerStateClosed
14+ PlayerStateError
15+)
A internal/core/player_state_test.go +85 -0
@@ -0,0 +1,85 @@
1+package core
2+
3+import (
4+ "testing"
5+)
6+
7+func TestPlayerStateCallbackFanout(t *testing.T) {
8+ var callbackStates []PlayerState
9+ callbacks := playerCallbacks{
10+ OnStateChange: func(state PlayerState) {
11+ callbackStates = append(callbackStates, state)
12+ },
13+ }
14+
15+ p := newPlayer(playerConfig{}, callbacks)
16+
17+ // Trigger a transition
18+ if err := p.transition(PlayerStateStarting); err != nil {
19+ t.Errorf("transition to Starting failed: %v", err)
20+ }
21+
22+ // Verify callback was invoked with the new state
23+ if len(callbackStates) != 1 {
24+ t.Errorf("callback invoked %d times, want 1", len(callbackStates))
25+ }
26+ if len(callbackStates) > 0 && callbackStates[0] != PlayerStateStarting {
27+ t.Errorf("callback received state %v, want %v", callbackStates[0], PlayerStateStarting)
28+ }
29+
30+ // Trigger another transition
31+ if err := p.transition(PlayerStatePlaying); err != nil {
32+ t.Errorf("transition to Playing failed: %v", err)
33+ }
34+
35+ // Verify callback was invoked again
36+ if len(callbackStates) != 2 {
37+ t.Errorf("callback invoked %d times, want 2", len(callbackStates))
38+ }
39+ if len(callbackStates) > 1 && callbackStates[1] != PlayerStatePlaying {
40+ t.Errorf("callback received state %v, want %v", callbackStates[1], PlayerStatePlaying)
41+ }
42+}
43+
44+func TestPlayerStateTransitionsIdleStartStopClose(t *testing.T) {
45+ p := newPlayer(playerConfig{}, playerCallbacks{})
46+
47+ if got := p.State(); got != PlayerStateIdle {
48+ t.Errorf("initial state = %v, want %v", got, PlayerStateIdle)
49+ }
50+
51+ if err := p.transition(PlayerStateStarting); err != nil {
52+ t.Errorf("transition to Starting failed: %v", err)
53+ }
54+ if got := p.State(); got != PlayerStateStarting {
55+ t.Errorf("after transition to Starting, state = %v, want %v", got, PlayerStateStarting)
56+ }
57+
58+ if err := p.transition(PlayerStatePlaying); err != nil {
59+ t.Errorf("transition to Playing failed: %v", err)
60+ }
61+ if got := p.State(); got != PlayerStatePlaying {
62+ t.Errorf("after transition to Playing, state = %v, want %v", got, PlayerStatePlaying)
63+ }
64+
65+ if err := p.transition(PlayerStateStopped); err != nil {
66+ t.Errorf("transition to Stopped failed: %v", err)
67+ }
68+ if got := p.State(); got != PlayerStateStopped {
69+ t.Errorf("after transition to Stopped, state = %v, want %v", got, PlayerStateStopped)
70+ }
71+
72+ if err := p.transition(PlayerStateClosing); err != nil {
73+ t.Errorf("transition to Closing failed: %v", err)
74+ }
75+ if got := p.State(); got != PlayerStateClosing {
76+ t.Errorf("after transition to Closing, state = %v, want %v", got, PlayerStateClosing)
77+ }
78+
79+ if err := p.transition(PlayerStateClosed); err != nil {
80+ t.Errorf("transition to Closed failed: %v", err)
81+ }
82+ if got := p.State(); got != PlayerStateClosed {
83+ t.Errorf("after transition to Closed, state = %v, want %v", got, PlayerStateClosed)
84+ }
85+}
@@ -0,0 +1,85 @@
1+package core
2+
3+import (
4+ "testing"
5+)
6+
7+func TestPlayerStateCallbackFanout(t *testing.T) {
8+ var callbackStates []PlayerState
9+ callbacks := playerCallbacks{
10+ OnStateChange: func(state PlayerState) {
11+ callbackStates = append(callbackStates, state)
12+ },
13+ }
14+
15+ p := newPlayer(playerConfig{}, callbacks)
16+
17+ // Trigger a transition
18+ if err := p.transition(PlayerStateStarting); err != nil {
19+ t.Errorf("transition to Starting failed: %v", err)
20+ }
21+
22+ // Verify callback was invoked with the new state
23+ if len(callbackStates) != 1 {
24+ t.Errorf("callback invoked %d times, want 1", len(callbackStates))
25+ }
26+ if len(callbackStates) > 0 && callbackStates[0] != PlayerStateStarting {
27+ t.Errorf("callback received state %v, want %v", callbackStates[0], PlayerStateStarting)
28+ }
29+
30+ // Trigger another transition
31+ if err := p.transition(PlayerStatePlaying); err != nil {
32+ t.Errorf("transition to Playing failed: %v", err)
33+ }
34+
35+ // Verify callback was invoked again
36+ if len(callbackStates) != 2 {
37+ t.Errorf("callback invoked %d times, want 2", len(callbackStates))
38+ }
39+ if len(callbackStates) > 1 && callbackStates[1] != PlayerStatePlaying {
40+ t.Errorf("callback received state %v, want %v", callbackStates[1], PlayerStatePlaying)
41+ }
42+}
43+
44+func TestPlayerStateTransitionsIdleStartStopClose(t *testing.T) {
45+ p := newPlayer(playerConfig{}, playerCallbacks{})
46+
47+ if got := p.State(); got != PlayerStateIdle {
48+ t.Errorf("initial state = %v, want %v", got, PlayerStateIdle)
49+ }
50+
51+ if err := p.transition(PlayerStateStarting); err != nil {
52+ t.Errorf("transition to Starting failed: %v", err)
53+ }
54+ if got := p.State(); got != PlayerStateStarting {
55+ t.Errorf("after transition to Starting, state = %v, want %v", got, PlayerStateStarting)
56+ }
57+
58+ if err := p.transition(PlayerStatePlaying); err != nil {
59+ t.Errorf("transition to Playing failed: %v", err)
60+ }
61+ if got := p.State(); got != PlayerStatePlaying {
62+ t.Errorf("after transition to Playing, state = %v, want %v", got, PlayerStatePlaying)
63+ }
64+
65+ if err := p.transition(PlayerStateStopped); err != nil {
66+ t.Errorf("transition to Stopped failed: %v", err)
67+ }
68+ if got := p.State(); got != PlayerStateStopped {
69+ t.Errorf("after transition to Stopped, state = %v, want %v", got, PlayerStateStopped)
70+ }
71+
72+ if err := p.transition(PlayerStateClosing); err != nil {
73+ t.Errorf("transition to Closing failed: %v", err)
74+ }
75+ if got := p.State(); got != PlayerStateClosing {
76+ t.Errorf("after transition to Closing, state = %v, want %v", got, PlayerStateClosing)
77+ }
78+
79+ if err := p.transition(PlayerStateClosed); err != nil {
80+ t.Errorf("transition to Closed failed: %v", err)
81+ }
82+ if got := p.State(); got != PlayerStateClosed {
83+ t.Errorf("after transition to Closed, state = %v, want %v", got, PlayerStateClosed)
84+ }
85+}
A internal/core/player_test.go +161 -0
@@ -0,0 +1,161 @@
1+package core
2+
3+import (
4+ "errors"
5+ "testing"
6+)
7+
8+func TestPlayerStopIsRestartableButCloseIsTerminal(t *testing.T) {
9+ p := newPlayer(PlayerConfig{}, PlayerCallbacks{})
10+
11+ // Start from Stopped state should work
12+ p.setState(PlayerStateStopped)
13+ if err := p.Start(); err != nil {
14+ t.Fatalf("Start from Stopped failed: %v", err)
15+ }
16+
17+ // Should be in Playing state after Start
18+ if p.State() != PlayerStatePlaying {
19+ t.Fatalf("expected Playing state after Start, got %v", p.State())
20+ }
21+
22+ // Stop should transition to Stopped
23+ if err := p.Stop(); err != nil {
24+ t.Fatalf("Stop failed: %v", err)
25+ }
26+ if p.State() != PlayerStateStopped {
27+ t.Fatalf("expected Stopped state after Stop, got %v", p.State())
28+ }
29+
30+ // Start from Stopped should work again (restartable)
31+ if err := p.Start(); err != nil {
32+ t.Fatalf("Start from Stopped (restart) failed: %v", err)
33+ }
34+ if p.State() != PlayerStatePlaying {
35+ t.Fatalf("expected Playing state after restart, got %v", p.State())
36+ }
37+
38+ // Close should transition to Closed
39+ if err := p.Close(); err != nil {
40+ t.Fatalf("Close failed: %v", err)
41+ }
42+ if p.State() != PlayerStateClosed {
43+ t.Fatalf("expected Closed state after Close, got %v", p.State())
44+ }
45+
46+ // Start from Closed should fail (terminal)
47+ if err := p.Start(); err == nil {
48+ t.Fatal("expected error starting from Closed state, got nil")
49+ }
50+}
51+
52+func TestPlayerStartFromClosedReturnsError(t *testing.T) {
53+ p := newPlayer(PlayerConfig{}, PlayerCallbacks{})
54+ p.setState(PlayerStateClosed)
55+
56+ err := p.Start()
57+ if err == nil {
58+ t.Fatal("expected error starting from Closed state, got nil")
59+ }
60+ if !errors.Is(err, ErrPlayerClosed) {
61+ t.Fatalf("expected ErrPlayerClosed, got %v", err)
62+ }
63+}
64+
65+func TestPlayerStartFromClosingReturnsError(t *testing.T) {
66+ p := newPlayer(PlayerConfig{}, PlayerCallbacks{})
67+ p.setState(PlayerStateClosing)
68+
69+ err := p.Start()
70+ if err == nil {
71+ t.Fatal("expected error starting from Closing state, got nil")
72+ }
73+ if !errors.Is(err, ErrPlayerClosed) {
74+ t.Fatalf("expected ErrPlayerClosed, got %v", err)
75+ }
76+}
77+
78+func TestPlayerStartTransitionsThroughStartingToPlaying(t *testing.T) {
79+ p := newPlayer(PlayerConfig{}, PlayerCallbacks{})
80+
81+ // Initial state should be Idle
82+ if p.State() != PlayerStateIdle {
83+ t.Fatalf("expected initial state Idle, got %v", p.State())
84+ }
85+
86+ // Start should transition to Playing
87+ if err := p.Start(); err != nil {
88+ t.Fatalf("Start failed: %v", err)
89+ }
90+
91+ if p.State() != PlayerStatePlaying {
92+ t.Fatalf("expected Playing state after Start, got %v", p.State())
93+ }
94+}
95+
96+func TestPlayerPauseTransitionsToPaused(t *testing.T) {
97+ p := newPlayer(PlayerConfig{}, PlayerCallbacks{})
98+
99+ // Start first
100+ if err := p.Start(); err != nil {
101+ t.Fatalf("Start failed: %v", err)
102+ }
103+
104+ // Pause should transition to Paused
105+ if err := p.Pause(); err != nil {
106+ t.Fatalf("Pause failed: %v", err)
107+ }
108+
109+ if p.State() != PlayerStatePaused {
110+ t.Fatalf("expected Paused state after Pause, got %v", p.State())
111+ }
112+}
113+
114+func TestPlayerStopClearsPausedState(t *testing.T) {
115+ p := newPlayer(PlayerConfig{}, PlayerCallbacks{})
116+
117+ // Start then pause
118+ if err := p.Start(); err != nil {
119+ t.Fatalf("Start failed: %v", err)
120+ }
121+ if err := p.Pause(); err != nil {
122+ t.Fatalf("Pause failed: %v", err)
123+ }
124+ if p.State() != PlayerStatePaused {
125+ t.Fatalf("expected Paused state, got %v", p.State())
126+ }
127+
128+ // Stop should transition to Stopped (clearing paused state)
129+ if err := p.Stop(); err != nil {
130+ t.Fatalf("Stop failed: %v", err)
131+ }
132+
133+ if p.State() != PlayerStateStopped {
134+ t.Fatalf("expected Stopped state after Stop, got %v", p.State())
135+ }
136+}
137+
138+func TestPlayerCloseIsIdempotent(t *testing.T) {
139+ p := newPlayer(PlayerConfig{}, PlayerCallbacks{})
140+
141+ // Start first
142+ if err := p.Start(); err != nil {
143+ t.Fatalf("Start failed: %v", err)
144+ }
145+
146+ // Close should work
147+ if err := p.Close(); err != nil {
148+ t.Fatalf("first Close failed: %v", err)
149+ }
150+ if p.State() != PlayerStateClosed {
151+ t.Fatalf("expected Closed state, got %v", p.State())
152+ }
153+
154+ // Second close should also succeed (idempotent)
155+ if err := p.Close(); err != nil {
156+ t.Fatalf("second Close failed: %v", err)
157+ }
158+ if p.State() != PlayerStateClosed {
159+ t.Fatalf("expected Closed state after second close, got %v", p.State())
160+ }
161+}
@@ -0,0 +1,161 @@
1+package core
2+
3+import (
4+ "errors"
5+ "testing"
6+)
7+
8+func TestPlayerStopIsRestartableButCloseIsTerminal(t *testing.T) {
9+ p := newPlayer(PlayerConfig{}, PlayerCallbacks{})
10+
11+ // Start from Stopped state should work
12+ p.setState(PlayerStateStopped)
13+ if err := p.Start(); err != nil {
14+ t.Fatalf("Start from Stopped failed: %v", err)
15+ }
16+
17+ // Should be in Playing state after Start
18+ if p.State() != PlayerStatePlaying {
19+ t.Fatalf("expected Playing state after Start, got %v", p.State())
20+ }
21+
22+ // Stop should transition to Stopped
23+ if err := p.Stop(); err != nil {
24+ t.Fatalf("Stop failed: %v", err)
25+ }
26+ if p.State() != PlayerStateStopped {
27+ t.Fatalf("expected Stopped state after Stop, got %v", p.State())
28+ }
29+
30+ // Start from Stopped should work again (restartable)
31+ if err := p.Start(); err != nil {
32+ t.Fatalf("Start from Stopped (restart) failed: %v", err)
33+ }
34+ if p.State() != PlayerStatePlaying {
35+ t.Fatalf("expected Playing state after restart, got %v", p.State())
36+ }
37+
38+ // Close should transition to Closed
39+ if err := p.Close(); err != nil {
40+ t.Fatalf("Close failed: %v", err)
41+ }
42+ if p.State() != PlayerStateClosed {
43+ t.Fatalf("expected Closed state after Close, got %v", p.State())
44+ }
45+
46+ // Start from Closed should fail (terminal)
47+ if err := p.Start(); err == nil {
48+ t.Fatal("expected error starting from Closed state, got nil")
49+ }
50+}
51+
52+func TestPlayerStartFromClosedReturnsError(t *testing.T) {
53+ p := newPlayer(PlayerConfig{}, PlayerCallbacks{})
54+ p.setState(PlayerStateClosed)
55+
56+ err := p.Start()
57+ if err == nil {
58+ t.Fatal("expected error starting from Closed state, got nil")
59+ }
60+ if !errors.Is(err, ErrPlayerClosed) {
61+ t.Fatalf("expected ErrPlayerClosed, got %v", err)
62+ }
63+}
64+
65+func TestPlayerStartFromClosingReturnsError(t *testing.T) {
66+ p := newPlayer(PlayerConfig{}, PlayerCallbacks{})
67+ p.setState(PlayerStateClosing)
68+
69+ err := p.Start()
70+ if err == nil {
71+ t.Fatal("expected error starting from Closing state, got nil")
72+ }
73+ if !errors.Is(err, ErrPlayerClosed) {
74+ t.Fatalf("expected ErrPlayerClosed, got %v", err)
75+ }
76+}
77+
78+func TestPlayerStartTransitionsThroughStartingToPlaying(t *testing.T) {
79+ p := newPlayer(PlayerConfig{}, PlayerCallbacks{})
80+
81+ // Initial state should be Idle
82+ if p.State() != PlayerStateIdle {
83+ t.Fatalf("expected initial state Idle, got %v", p.State())
84+ }
85+
86+ // Start should transition to Playing
87+ if err := p.Start(); err != nil {
88+ t.Fatalf("Start failed: %v", err)
89+ }
90+
91+ if p.State() != PlayerStatePlaying {
92+ t.Fatalf("expected Playing state after Start, got %v", p.State())
93+ }
94+}
95+
96+func TestPlayerPauseTransitionsToPaused(t *testing.T) {
97+ p := newPlayer(PlayerConfig{}, PlayerCallbacks{})
98+
99+ // Start first
100+ if err := p.Start(); err != nil {
101+ t.Fatalf("Start failed: %v", err)
102+ }
103+
104+ // Pause should transition to Paused
105+ if err := p.Pause(); err != nil {
106+ t.Fatalf("Pause failed: %v", err)
107+ }
108+
109+ if p.State() != PlayerStatePaused {
110+ t.Fatalf("expected Paused state after Pause, got %v", p.State())
111+ }
112+}
113+
114+func TestPlayerStopClearsPausedState(t *testing.T) {
115+ p := newPlayer(PlayerConfig{}, PlayerCallbacks{})
116+
117+ // Start then pause
118+ if err := p.Start(); err != nil {
119+ t.Fatalf("Start failed: %v", err)
120+ }
121+ if err := p.Pause(); err != nil {
122+ t.Fatalf("Pause failed: %v", err)
123+ }
124+ if p.State() != PlayerStatePaused {
125+ t.Fatalf("expected Paused state, got %v", p.State())
126+ }
127+
128+ // Stop should transition to Stopped (clearing paused state)
129+ if err := p.Stop(); err != nil {
130+ t.Fatalf("Stop failed: %v", err)
131+ }
132+
133+ if p.State() != PlayerStateStopped {
134+ t.Fatalf("expected Stopped state after Stop, got %v", p.State())
135+ }
136+}
137+
138+func TestPlayerCloseIsIdempotent(t *testing.T) {
139+ p := newPlayer(PlayerConfig{}, PlayerCallbacks{})
140+
141+ // Start first
142+ if err := p.Start(); err != nil {
143+ t.Fatalf("Start failed: %v", err)
144+ }
145+
146+ // Close should work
147+ if err := p.Close(); err != nil {
148+ t.Fatalf("first Close failed: %v", err)
149+ }
150+ if p.State() != PlayerStateClosed {
151+ t.Fatalf("expected Closed state, got %v", p.State())
152+ }
153+
154+ // Second close should also succeed (idempotent)
155+ if err := p.Close(); err != nil {
156+ t.Fatalf("second Close failed: %v", err)
157+ }
158+ if p.State() != PlayerStateClosed {
159+ t.Fatalf("expected Closed state after second close, got %v", p.State())
160+ }
161+}
D pipewire/mocks/mock_PlaybackStream.go +0 -131
@@ -1,131 +0,0 @@
1-// Code generated by mockery; DO NOT EDIT.
2-// github.com/vektra/mockery
3-// template: testify
4-
5-package mocks
6-
7-import (
8- mock "github.com/stretchr/testify/mock"
9-)
10-
11-// NewMockPlaybackStream creates a new instance of MockPlaybackStream. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations.
12-// The first argument is typically a *testing.T value.
13-func NewMockPlaybackStream(t interface {
14- mock.TestingT
15- Cleanup(func())
16-}) *MockPlaybackStream {
17- mock := &MockPlaybackStream{}
18- mock.Mock.Test(t)
19-
20- t.Cleanup(func() { mock.AssertExpectations(t) })
21-
22- return mock
23-}
24-
25-// MockPlaybackStream is an autogenerated mock type for the PlaybackStream type
26-type MockPlaybackStream struct {
27- mock.Mock
28-}
29-
30-type MockPlaybackStream_Expecter struct {
31- mock *mock.Mock
32-}
33-
34-func (_m *MockPlaybackStream) EXPECT() *MockPlaybackStream_Expecter {
35- return &MockPlaybackStream_Expecter{mock: &_m.Mock}
36-}
37-
38-// Close provides a mock function for the type MockPlaybackStream
39-func (_mock *MockPlaybackStream) Close() error {
40- ret := _mock.Called()
41-
42- if len(ret) == 0 {
43- panic("no return value specified for Close")
44- }
45-
46- var r0 error
47- if returnFunc, ok := ret.Get(0).(func() error); ok {
48- r0 = returnFunc()
49- } else {
50- r0 = ret.Error(0)
51- }
52- return r0
53-}
54-
55-// MockPlaybackStream_Close_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Close'
56-type MockPlaybackStream_Close_Call struct {
57- *mock.Call
58-}
59-
60-// Close is a helper method to define mock.On call
61-func (_e *MockPlaybackStream_Expecter) Close() *MockPlaybackStream_Close_Call {
62- return &MockPlaybackStream_Close_Call{Call: _e.mock.On("Close")}
63-}
64-
65-func (_c *MockPlaybackStream_Close_Call) Run(run func()) *MockPlaybackStream_Close_Call {
66- _c.Call.Run(func(args mock.Arguments) {
67- run()
68- })
69- return _c
70-}
71-
72-func (_c *MockPlaybackStream_Close_Call) Return(err error) *MockPlaybackStream_Close_Call {
73- _c.Call.Return(err)
74- return _c
75-}
76-
77-func (_c *MockPlaybackStream_Close_Call) RunAndReturn(run func() error) *MockPlaybackStream_Close_Call {
78- _c.Call.Return(run)
79- return _c
80-}
81-
82-// Write provides a mock function for the type MockPlaybackStream
83-func (_mock *MockPlaybackStream) Write(samples [][]float32) error {
84- ret := _mock.Called(samples)
85-
86- if len(ret) == 0 {
87- panic("no return value specified for Write")
88- }
89-
90- var r0 error
91- if returnFunc, ok := ret.Get(0).(func([][]float32) error); ok {
92- r0 = returnFunc(samples)
93- } else {
94- r0 = ret.Error(0)
95- }
96- return r0
97-}
98-
99-// MockPlaybackStream_Write_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Write'
100-type MockPlaybackStream_Write_Call struct {
101- *mock.Call
102-}
103-
104-// Write is a helper method to define mock.On call
105-// - samples [][]float32
106-func (_e *MockPlaybackStream_Expecter) Write(samples interface{}) *MockPlaybackStream_Write_Call {
107- return &MockPlaybackStream_Write_Call{Call: _e.mock.On("Write", samples)}
108-}
109-
110-func (_c *MockPlaybackStream_Write_Call) Run(run func(samples [][]float32)) *MockPlaybackStream_Write_Call {
111- _c.Call.Run(func(args mock.Arguments) {
112- var arg0 [][]float32
113- if args[0] != nil {
114- arg0 = args[0].([][]float32)
115- }
116- run(
117- arg0,
118- )
119- })
120- return _c
121-}
122-
123-func (_c *MockPlaybackStream_Write_Call) Return(err error) *MockPlaybackStream_Write_Call {
124- _c.Call.Return(err)
125- return _c
126-}
127-
128-func (_c *MockPlaybackStream_Write_Call) RunAndReturn(run func(samples [][]float32) error) *MockPlaybackStream_Write_Call {
129- _c.Call.Return(run)
130- return _c
131-}
@@ -1,131 +0,0 @@
1-// Code generated by mockery; DO NOT EDIT.
2-// github.com/vektra/mockery
3-// template: testify
4-
5-package mocks
6-
7-import (
8- mock "github.com/stretchr/testify/mock"
9-)
10-
11-// NewMockPlaybackStream creates a new instance of MockPlaybackStream. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations.
12-// The first argument is typically a *testing.T value.
13-func NewMockPlaybackStream(t interface {
14- mock.TestingT
15- Cleanup(func())
16-}) *MockPlaybackStream {
17- mock := &MockPlaybackStream{}
18- mock.Mock.Test(t)
19-
20- t.Cleanup(func() { mock.AssertExpectations(t) })
21-
22- return mock
23-}
24-
25-// MockPlaybackStream is an autogenerated mock type for the PlaybackStream type
26-type MockPlaybackStream struct {
27- mock.Mock
28-}
29-
30-type MockPlaybackStream_Expecter struct {
31- mock *mock.Mock
32-}
33-
34-func (_m *MockPlaybackStream) EXPECT() *MockPlaybackStream_Expecter {
35- return &MockPlaybackStream_Expecter{mock: &_m.Mock}
36-}
37-
38-// Close provides a mock function for the type MockPlaybackStream
39-func (_mock *MockPlaybackStream) Close() error {
40- ret := _mock.Called()
41-
42- if len(ret) == 0 {
43- panic("no return value specified for Close")
44- }
45-
46- var r0 error
47- if returnFunc, ok := ret.Get(0).(func() error); ok {
48- r0 = returnFunc()
49- } else {
50- r0 = ret.Error(0)
51- }
52- return r0
53-}
54-
55-// MockPlaybackStream_Close_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Close'
56-type MockPlaybackStream_Close_Call struct {
57- *mock.Call
58-}
59-
60-// Close is a helper method to define mock.On call
61-func (_e *MockPlaybackStream_Expecter) Close() *MockPlaybackStream_Close_Call {
62- return &MockPlaybackStream_Close_Call{Call: _e.mock.On("Close")}
63-}
64-
65-func (_c *MockPlaybackStream_Close_Call) Run(run func()) *MockPlaybackStream_Close_Call {
66- _c.Call.Run(func(args mock.Arguments) {
67- run()
68- })
69- return _c
70-}
71-
72-func (_c *MockPlaybackStream_Close_Call) Return(err error) *MockPlaybackStream_Close_Call {
73- _c.Call.Return(err)
74- return _c
75-}
76-
77-func (_c *MockPlaybackStream_Close_Call) RunAndReturn(run func() error) *MockPlaybackStream_Close_Call {
78- _c.Call.Return(run)
79- return _c
80-}
81-
82-// Write provides a mock function for the type MockPlaybackStream
83-func (_mock *MockPlaybackStream) Write(samples [][]float32) error {
84- ret := _mock.Called(samples)
85-
86- if len(ret) == 0 {
87- panic("no return value specified for Write")
88- }
89-
90- var r0 error
91- if returnFunc, ok := ret.Get(0).(func([][]float32) error); ok {
92- r0 = returnFunc(samples)
93- } else {
94- r0 = ret.Error(0)
95- }
96- return r0
97-}
98-
99-// MockPlaybackStream_Write_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Write'
100-type MockPlaybackStream_Write_Call struct {
101- *mock.Call
102-}
103-
104-// Write is a helper method to define mock.On call
105-// - samples [][]float32
106-func (_e *MockPlaybackStream_Expecter) Write(samples interface{}) *MockPlaybackStream_Write_Call {
107- return &MockPlaybackStream_Write_Call{Call: _e.mock.On("Write", samples)}
108-}
109-
110-func (_c *MockPlaybackStream_Write_Call) Run(run func(samples [][]float32)) *MockPlaybackStream_Write_Call {
111- _c.Call.Run(func(args mock.Arguments) {
112- var arg0 [][]float32
113- if args[0] != nil {
114- arg0 = args[0].([][]float32)
115- }
116- run(
117- arg0,
118- )
119- })
120- return _c
121-}
122-
123-func (_c *MockPlaybackStream_Write_Call) Return(err error) *MockPlaybackStream_Write_Call {
124- _c.Call.Return(err)
125- return _c
126-}
127-
128-func (_c *MockPlaybackStream_Write_Call) RunAndReturn(run func(samples [][]float32) error) *MockPlaybackStream_Write_Call {
129- _c.Call.Return(run)
130- return _c
131-}
M pipewire/mocks/mock_Player.go +148 -42
@@ -5,8 +5,6 @@
55 package mocks
66
77 import (
8- "context"
9-
108 "github.com/bnema/purego-pipewire/pipewire"
119 mock "github.com/stretchr/testify/mock"
1210 )
@@ -82,70 +80,178 @@ func (_c *MockPlayer_Close_Call) RunAndReturn(run func() error) *MockPlayer_Clos
8280 return _c
8381 }
8482
85-// NewStream provides a mock function for the type MockPlayer
86-func (_mock *MockPlayer) NewStream(ctx context.Context, format pipewire.AudioFormat) (pipewire.PlaybackStream, error) {
87- ret := _mock.Called(ctx, format)
83+// Pause provides a mock function for the type MockPlayer
84+func (_mock *MockPlayer) Pause() error {
85+ ret := _mock.Called()
8886
8987 if len(ret) == 0 {
90- panic("no return value specified for NewStream")
88+ panic("no return value specified for Pause")
9189 }
9290
93- var r0 pipewire.PlaybackStream
94- var r1 error
95- if returnFunc, ok := ret.Get(0).(func(context.Context, pipewire.AudioFormat) (pipewire.PlaybackStream, error)); ok {
96- return returnFunc(ctx, format)
91+ var r0 error
92+ if returnFunc, ok := ret.Get(0).(func() error); ok {
93+ r0 = returnFunc()
94+ } else {
95+ r0 = ret.Error(0)
9796 }
98- if returnFunc, ok := ret.Get(0).(func(context.Context, pipewire.AudioFormat) pipewire.PlaybackStream); ok {
99- r0 = returnFunc(ctx, format)
97+ return r0
98+}
99+
100+// MockPlayer_Pause_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Pause'
101+type MockPlayer_Pause_Call struct {
102+ *mock.Call
103+}
104+
105+// Pause is a helper method to define mock.On call
106+func (_e *MockPlayer_Expecter) Pause() *MockPlayer_Pause_Call {
107+ return &MockPlayer_Pause_Call{Call: _e.mock.On("Pause")}
108+}
109+
110+func (_c *MockPlayer_Pause_Call) Run(run func()) *MockPlayer_Pause_Call {
111+ _c.Call.Run(func(args mock.Arguments) {
112+ run()
113+ })
114+ return _c
115+}
116+
117+func (_c *MockPlayer_Pause_Call) Return(err error) *MockPlayer_Pause_Call {
118+ _c.Call.Return(err)
119+ return _c
120+}
121+
122+func (_c *MockPlayer_Pause_Call) RunAndReturn(run func() error) *MockPlayer_Pause_Call {
123+ _c.Call.Return(run)
124+ return _c
125+}
126+
127+// Start provides a mock function for the type MockPlayer
128+func (_mock *MockPlayer) Start() error {
129+ ret := _mock.Called()
130+
131+ if len(ret) == 0 {
132+ panic("no return value specified for Start")
133+ }
134+
135+ var r0 error
136+ if returnFunc, ok := ret.Get(0).(func() error); ok {
137+ r0 = returnFunc()
100138 } else {
101- if ret.Get(0) != nil {
102- r0 = ret.Get(0).(pipewire.PlaybackStream)
103- }
139+ r0 = ret.Error(0)
104140 }
105- if returnFunc, ok := ret.Get(1).(func(context.Context, pipewire.AudioFormat) error); ok {
106- r1 = returnFunc(ctx, format)
141+ return r0
142+}
143+
144+// MockPlayer_Start_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Start'
145+type MockPlayer_Start_Call struct {
146+ *mock.Call
147+}
148+
149+// Start is a helper method to define mock.On call
150+func (_e *MockPlayer_Expecter) Start() *MockPlayer_Start_Call {
151+ return &MockPlayer_Start_Call{Call: _e.mock.On("Start")}
152+}
153+
154+func (_c *MockPlayer_Start_Call) Run(run func()) *MockPlayer_Start_Call {
155+ _c.Call.Run(func(args mock.Arguments) {
156+ run()
157+ })
158+ return _c
159+}
160+
161+func (_c *MockPlayer_Start_Call) Return(err error) *MockPlayer_Start_Call {
162+ _c.Call.Return(err)
163+ return _c
164+}
165+
166+func (_c *MockPlayer_Start_Call) RunAndReturn(run func() error) *MockPlayer_Start_Call {
167+ _c.Call.Return(run)
168+ return _c
169+}
170+
171+// State provides a mock function for the type MockPlayer
172+func (_mock *MockPlayer) State() pipewire.PlayerState {
173+ ret := _mock.Called()
174+
175+ if len(ret) == 0 {
176+ panic("no return value specified for State")
177+ }
178+
179+ var r0 pipewire.PlayerState
180+ if returnFunc, ok := ret.Get(0).(func() pipewire.PlayerState); ok {
181+ r0 = returnFunc()
182+ } else {
183+ r0 = ret.Get(0).(pipewire.PlayerState)
184+ }
185+ return r0
186+}
187+
188+// MockPlayer_State_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'State'
189+type MockPlayer_State_Call struct {
190+ *mock.Call
191+}
192+
193+// State is a helper method to define mock.On call
194+func (_e *MockPlayer_Expecter) State() *MockPlayer_State_Call {
195+ return &MockPlayer_State_Call{Call: _e.mock.On("State")}
196+}
197+
198+func (_c *MockPlayer_State_Call) Run(run func()) *MockPlayer_State_Call {
199+ _c.Call.Run(func(args mock.Arguments) {
200+ run()
201+ })
202+ return _c
203+}
204+
205+func (_c *MockPlayer_State_Call) Return(playerState pipewire.PlayerState) *MockPlayer_State_Call {
206+ _c.Call.Return(playerState)
207+ return _c
208+}
209+
210+func (_c *MockPlayer_State_Call) RunAndReturn(run func() pipewire.PlayerState) *MockPlayer_State_Call {
211+ _c.Call.Return(run)
212+ return _c
213+}
214+
215+// Stop provides a mock function for the type MockPlayer
216+func (_mock *MockPlayer) Stop() error {
217+ ret := _mock.Called()
218+
219+ if len(ret) == 0 {
220+ panic("no return value specified for Stop")
221+ }
222+
223+ var r0 error
224+ if returnFunc, ok := ret.Get(0).(func() error); ok {
225+ r0 = returnFunc()
107226 } else {
108- r1 = ret.Error(1)
227+ r0 = ret.Error(0)
109228 }
110- return r0, r1
229+ return r0
111230 }
112231
113-// MockPlayer_NewStream_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'NewStream'
114-type MockPlayer_NewStream_Call struct {
232+// MockPlayer_Stop_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Stop'
233+type MockPlayer_Stop_Call struct {
115234 *mock.Call
116235 }
117236
118-// NewStream is a helper method to define mock.On call
119-// - ctx context.Context
120-// - format pipewire.AudioFormat
121-func (_e *MockPlayer_Expecter) NewStream(ctx interface{}, format interface{}) *MockPlayer_NewStream_Call {
122- return &MockPlayer_NewStream_Call{Call: _e.mock.On("NewStream", ctx, format)}
237+// Stop is a helper method to define mock.On call
238+func (_e *MockPlayer_Expecter) Stop() *MockPlayer_Stop_Call {
239+ return &MockPlayer_Stop_Call{Call: _e.mock.On("Stop")}
123240 }
124241
125-func (_c *MockPlayer_NewStream_Call) Run(run func(ctx context.Context, format pipewire.AudioFormat)) *MockPlayer_NewStream_Call {
242+func (_c *MockPlayer_Stop_Call) Run(run func()) *MockPlayer_Stop_Call {
126243 _c.Call.Run(func(args mock.Arguments) {
127- var arg0 context.Context
128- if args[0] != nil {
129- arg0 = args[0].(context.Context)
130- }
131- var arg1 pipewire.AudioFormat
132- if args[1] != nil {
133- arg1 = args[1].(pipewire.AudioFormat)
134- }
135- run(
136- arg0,
137- arg1,
138- )
244+ run()
139245 })
140246 return _c
141247 }
142248
143-func (_c *MockPlayer_NewStream_Call) Return(playbackStream pipewire.PlaybackStream, err error) *MockPlayer_NewStream_Call {
144- _c.Call.Return(playbackStream, err)
249+func (_c *MockPlayer_Stop_Call) Return(err error) *MockPlayer_Stop_Call {
250+ _c.Call.Return(err)
145251 return _c
146252 }
147253
148-func (_c *MockPlayer_NewStream_Call) RunAndReturn(run func(ctx context.Context, format pipewire.AudioFormat) (pipewire.PlaybackStream, error)) *MockPlayer_NewStream_Call {
254+func (_c *MockPlayer_Stop_Call) RunAndReturn(run func() error) *MockPlayer_Stop_Call {
149255 _c.Call.Return(run)
150256 return _c
151257 }
@@ -5,8 +5,6 @@
5 package mocks5 package mocks
6 6
7 import (7 import (
8- "context"
9-
10 "github.com/bnema/purego-pipewire/pipewire"8 "github.com/bnema/purego-pipewire/pipewire"
11 mock "github.com/stretchr/testify/mock"9 mock "github.com/stretchr/testify/mock"
12 )10 )
@@ -82,70 +80,178 @@ func (_c *MockPlayer_Close_Call) RunAndReturn(run func() error) *MockPlayer_Clos
82 return _c80 return _c
83 }81 }
84 82
85-// NewStream provides a mock function for the type MockPlayer83+// Pause provides a mock function for the type MockPlayer
86-func (_mock *MockPlayer) NewStream(ctx context.Context, format pipewire.AudioFormat) (pipewire.PlaybackStream, error) {84+func (_mock *MockPlayer) Pause() error {
87- ret := _mock.Called(ctx, format)85+ ret := _mock.Called()
88 86
89 if len(ret) == 0 {87 if len(ret) == 0 {
90- panic("no return value specified for NewStream")88+ panic("no return value specified for Pause")
91 }89 }
92 90
93- var r0 pipewire.PlaybackStream91+ var r0 error
94- var r1 error92+ if returnFunc, ok := ret.Get(0).(func() error); ok {
95- if returnFunc, ok := ret.Get(0).(func(context.Context, pipewire.AudioFormat) (pipewire.PlaybackStream, error)); ok {93+ r0 = returnFunc()
96- return returnFunc(ctx, format)94+ } else {
95+ r0 = ret.Error(0)
97 }96 }
98- if returnFunc, ok := ret.Get(0).(func(context.Context, pipewire.AudioFormat) pipewire.PlaybackStream); ok {97+ return r0
99- r0 = returnFunc(ctx, format)98+}
99+
100+// MockPlayer_Pause_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Pause'
101+type MockPlayer_Pause_Call struct {
102+ *mock.Call
103+}
104+
105+// Pause is a helper method to define mock.On call
106+func (_e *MockPlayer_Expecter) Pause() *MockPlayer_Pause_Call {
107+ return &MockPlayer_Pause_Call{Call: _e.mock.On("Pause")}
108+}
109+
110+func (_c *MockPlayer_Pause_Call) Run(run func()) *MockPlayer_Pause_Call {
111+ _c.Call.Run(func(args mock.Arguments) {
112+ run()
113+ })
114+ return _c
115+}
116+
117+func (_c *MockPlayer_Pause_Call) Return(err error) *MockPlayer_Pause_Call {
118+ _c.Call.Return(err)
119+ return _c
120+}
121+
122+func (_c *MockPlayer_Pause_Call) RunAndReturn(run func() error) *MockPlayer_Pause_Call {
123+ _c.Call.Return(run)
124+ return _c
125+}
126+
127+// Start provides a mock function for the type MockPlayer
128+func (_mock *MockPlayer) Start() error {
129+ ret := _mock.Called()
130+
131+ if len(ret) == 0 {
132+ panic("no return value specified for Start")
133+ }
134+
135+ var r0 error
136+ if returnFunc, ok := ret.Get(0).(func() error); ok {
137+ r0 = returnFunc()
100 } else {138 } else {
101- if ret.Get(0) != nil {139+ r0 = ret.Error(0)
102- r0 = ret.Get(0).(pipewire.PlaybackStream)
103- }
104 }140 }
105- if returnFunc, ok := ret.Get(1).(func(context.Context, pipewire.AudioFormat) error); ok {141+ return r0
106- r1 = returnFunc(ctx, format)142+}
143+
144+// MockPlayer_Start_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Start'
145+type MockPlayer_Start_Call struct {
146+ *mock.Call
147+}
148+
149+// Start is a helper method to define mock.On call
150+func (_e *MockPlayer_Expecter) Start() *MockPlayer_Start_Call {
151+ return &MockPlayer_Start_Call{Call: _e.mock.On("Start")}
152+}
153+
154+func (_c *MockPlayer_Start_Call) Run(run func()) *MockPlayer_Start_Call {
155+ _c.Call.Run(func(args mock.Arguments) {
156+ run()
157+ })
158+ return _c
159+}
160+
161+func (_c *MockPlayer_Start_Call) Return(err error) *MockPlayer_Start_Call {
162+ _c.Call.Return(err)
163+ return _c
164+}
165+
166+func (_c *MockPlayer_Start_Call) RunAndReturn(run func() error) *MockPlayer_Start_Call {
167+ _c.Call.Return(run)
168+ return _c
169+}
170+
171+// State provides a mock function for the type MockPlayer
172+func (_mock *MockPlayer) State() pipewire.PlayerState {
173+ ret := _mock.Called()
174+
175+ if len(ret) == 0 {
176+ panic("no return value specified for State")
177+ }
178+
179+ var r0 pipewire.PlayerState
180+ if returnFunc, ok := ret.Get(0).(func() pipewire.PlayerState); ok {
181+ r0 = returnFunc()
182+ } else {
183+ r0 = ret.Get(0).(pipewire.PlayerState)
184+ }
185+ return r0
186+}
187+
188+// MockPlayer_State_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'State'
189+type MockPlayer_State_Call struct {
190+ *mock.Call
191+}
192+
193+// State is a helper method to define mock.On call
194+func (_e *MockPlayer_Expecter) State() *MockPlayer_State_Call {
195+ return &MockPlayer_State_Call{Call: _e.mock.On("State")}
196+}
197+
198+func (_c *MockPlayer_State_Call) Run(run func()) *MockPlayer_State_Call {
199+ _c.Call.Run(func(args mock.Arguments) {
200+ run()
201+ })
202+ return _c
203+}
204+
205+func (_c *MockPlayer_State_Call) Return(playerState pipewire.PlayerState) *MockPlayer_State_Call {
206+ _c.Call.Return(playerState)
207+ return _c
208+}
209+
210+func (_c *MockPlayer_State_Call) RunAndReturn(run func() pipewire.PlayerState) *MockPlayer_State_Call {
211+ _c.Call.Return(run)
212+ return _c
213+}
214+
215+// Stop provides a mock function for the type MockPlayer
216+func (_mock *MockPlayer) Stop() error {
217+ ret := _mock.Called()
218+
219+ if len(ret) == 0 {
220+ panic("no return value specified for Stop")
221+ }
222+
223+ var r0 error
224+ if returnFunc, ok := ret.Get(0).(func() error); ok {
225+ r0 = returnFunc()
107 } else {226 } else {
108- r1 = ret.Error(1)227+ r0 = ret.Error(0)
109 }228 }
110- return r0, r1229+ return r0
111 }230 }
112 231
113-// MockPlayer_NewStream_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'NewStream'232+// MockPlayer_Stop_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Stop'
114-type MockPlayer_NewStream_Call struct {233+type MockPlayer_Stop_Call struct {
115 *mock.Call234 *mock.Call
116 }235 }
117 236
118-// NewStream is a helper method to define mock.On call237+// Stop is a helper method to define mock.On call
119-// - ctx context.Context238+func (_e *MockPlayer_Expecter) Stop() *MockPlayer_Stop_Call {
120-// - format pipewire.AudioFormat239+ return &MockPlayer_Stop_Call{Call: _e.mock.On("Stop")}
121-func (_e *MockPlayer_Expecter) NewStream(ctx interface{}, format interface{}) *MockPlayer_NewStream_Call {
122- return &MockPlayer_NewStream_Call{Call: _e.mock.On("NewStream", ctx, format)}
123 }240 }
124 241
125-func (_c *MockPlayer_NewStream_Call) Run(run func(ctx context.Context, format pipewire.AudioFormat)) *MockPlayer_NewStream_Call {242+func (_c *MockPlayer_Stop_Call) Run(run func()) *MockPlayer_Stop_Call {
126 _c.Call.Run(func(args mock.Arguments) {243 _c.Call.Run(func(args mock.Arguments) {
127- var arg0 context.Context244+ run()
128- if args[0] != nil {
129- arg0 = args[0].(context.Context)
130- }
131- var arg1 pipewire.AudioFormat
132- if args[1] != nil {
133- arg1 = args[1].(pipewire.AudioFormat)
134- }
135- run(
136- arg0,
137- arg1,
138- )
139 })245 })
140 return _c246 return _c
141 }247 }
142 248
143-func (_c *MockPlayer_NewStream_Call) Return(playbackStream pipewire.PlaybackStream, err error) *MockPlayer_NewStream_Call {249+func (_c *MockPlayer_Stop_Call) Return(err error) *MockPlayer_Stop_Call {
144- _c.Call.Return(playbackStream, err)250+ _c.Call.Return(err)
145 return _c251 return _c
146 }252 }
147 253
148-func (_c *MockPlayer_NewStream_Call) RunAndReturn(run func(ctx context.Context, format pipewire.AudioFormat) (pipewire.PlaybackStream, error)) *MockPlayer_NewStream_Call {254+func (_c *MockPlayer_Stop_Call) RunAndReturn(run func() error) *MockPlayer_Stop_Call {
149 _c.Call.Return(run)255 _c.Call.Return(run)
150 return _c256 return _c
151 }257 }
M pipewire/player.go +198 -15
@@ -1,34 +1,217 @@
11 package pipewire
22
33 import (
4- "context"
54 "errors"
5+
6+ "github.com/bnema/purego-pipewire/internal/core"
7+)
8+
9+// SampleFormat represents the audio sample format type.
10+type SampleFormat int
11+
12+const (
13+ // SampleFormatF32 is the 32-bit floating point sample format.
14+ SampleFormatF32 SampleFormat = iota
15+)
16+
17+// UnderrunPolicy defines the behavior when a buffer underrun occurs.
18+type UnderrunPolicy int
19+
20+const (
21+ // UnderrunFillSilence fills the buffer with silence on underrun.
22+ UnderrunFillSilence UnderrunPolicy = iota
23+ // UnderrunFail fails playback on underrun.
24+ UnderrunFail
25+)
26+
27+// PlayerState represents the current state of the audio player.
28+type PlayerState int
29+
30+const (
31+ // PlayerStateIdle is the initial state before the player is started.
32+ PlayerStateIdle PlayerState = iota
33+ // PlayerStateStarting indicates the player is initializing.
34+ PlayerStateStarting
35+ // PlayerStatePlaying indicates the player is actively outputting audio.
36+ PlayerStatePlaying
37+ // PlayerStatePaused indicates the player is temporarily stopped.
38+ PlayerStatePaused
39+ // PlayerStateStopped indicates the player has been stopped.
40+ PlayerStateStopped
41+ // PlayerStateClosing indicates the player is shutting down.
42+ PlayerStateClosing
43+ // PlayerStateClosed indicates the player has been fully closed.
44+ PlayerStateClosed
45+ // PlayerStateError indicates the player encountered an error.
46+ PlayerStateError
647 )
748
8-// ErrNotImplemented is returned by stub functions that are not yet implemented.
9-var ErrNotImplemented = errors.New("not implemented")
49+// Legacy aliases for backwards compatibility (deprecated)
50+const (
51+ Idle = PlayerStateIdle
52+ Starting = PlayerStateStarting
53+ Playing = PlayerStatePlaying
54+ Paused = PlayerStatePaused
55+ Stopped = PlayerStateStopped
56+ Closing = PlayerStateClosing
57+ Closed = PlayerStateClosed
58+ Error = PlayerStateError
59+)
1060
11-// AudioFormat describes the audio format for a playback stream.
12-type AudioFormat struct {
61+// PlayerConfig holds the configuration for a new audio player.
62+type PlayerConfig struct {
1363 SampleRate int
1464 Channels int
1565 FramesPerBuffer int
66+ SampleFormat SampleFormat
67+ UnderrunPolicy UnderrunPolicy
1668 }
1769
18-// PlaybackStream is the public interface for writing audio samples to PipeWire.
19-type PlaybackStream interface {
20- Write(samples [][]float32) error
21- Close() error
70+// PCMBuffer represents a buffer of PCM audio data.
71+type PCMBuffer struct {
72+ Frames int
73+ Channels int
74+ Stride int
75+ Samples [][]float32
2276 }
2377
24-// Player is the public interface for creating audio playback streams.
78+// PlayerCallbacks contains all callback functions for player events.
79+type PlayerCallbacks struct {
80+ Fill func(*PCMBuffer) (int, error)
81+ OnUnderrun func(int)
82+ OnDrain func()
83+ OnError func(error)
84+ OnStateChange func(PlayerState)
85+}
86+
87+// Player is the public interface for audio playback.
2588 type Player interface {
26- NewStream(ctx context.Context, format AudioFormat) (PlaybackStream, error)
89+ Start() error
90+ Pause() error
91+ Stop() error
2792 Close() error
93+ State() PlayerState
94+}
95+
96+// ErrInvalidPlayerConfig is returned when the player configuration is invalid.
97+var ErrInvalidPlayerConfig = errors.New("invalid player config")
98+
99+// playerImpl wraps the internal core player
100+type playerImpl struct {
101+ config PlayerConfig
102+ callbacks PlayerCallbacks
103+ internal *core.Player
104+ scratchBuf PCMBuffer // reusable buffer for Fill callback to avoid allocation
105+}
106+
107+func (p *playerImpl) Start() error {
108+ if p.internal == nil {
109+ return errors.New("player not initialized")
110+ }
111+ return p.internal.Start()
112+}
113+
114+func (p *playerImpl) Pause() error {
115+ if p.internal == nil {
116+ return errors.New("player not initialized")
117+ }
118+ return p.internal.Pause()
119+}
120+
121+func (p *playerImpl) Stop() error {
122+ if p.internal == nil {
123+ return errors.New("player not initialized")
124+ }
125+ return p.internal.Stop()
126+}
127+
128+func (p *playerImpl) Close() error {
129+ if p.internal == nil {
130+ return nil
131+ }
132+ return p.internal.Close()
133+}
134+
135+func (p *playerImpl) State() PlayerState {
136+ if p.internal == nil {
137+ return PlayerStateIdle
138+ }
139+ return PlayerState(p.internal.State())
28140 }
29141
30-// NewPlayer creates a new Player for audio playback.
31-// This is currently a stub and returns ErrNotImplemented.
32-func NewPlayer() (Player, error) {
33- return nil, ErrNotImplemented
142+// NewPlayer creates a new audio player with the given configuration and callbacks.
143+// Returns ErrInvalidPlayerConfig if the configuration is invalid.
144+func NewPlayer(config PlayerConfig, callbacks PlayerCallbacks) (Player, error) {
145+ if config.SampleRate <= 0 {
146+ return nil, ErrInvalidPlayerConfig
147+ }
148+ if config.Channels <= 0 {
149+ return nil, ErrInvalidPlayerConfig
150+ }
151+ if config.FramesPerBuffer <= 0 {
152+ return nil, ErrInvalidPlayerConfig
153+ }
154+ if config.SampleFormat != SampleFormatF32 {
155+ return nil, ErrInvalidPlayerConfig
156+ }
157+
158+ // Convert public UnderrunPolicy to internal UnderrunPolicy
159+ var coreUnderrunPolicy core.UnderrunPolicy
160+ switch config.UnderrunPolicy {
161+ case UnderrunFail:
162+ coreUnderrunPolicy = core.UnderrunFailFast
163+ default:
164+ coreUnderrunPolicy = core.UnderrunFillSilence
165+ }
166+
167+ // Create the player implementation early so we can use its scratch buffer
168+ p := &playerImpl{
169+ config: config,
170+ callbacks: callbacks,
171+ }
172+
173+ // Convert public callbacks to internal callbacks
174+ coreCallbacks := core.PlayerCallbacks{
175+ OnStateChange: func(state core.PlayerState) {
176+ if callbacks.OnStateChange != nil {
177+ callbacks.OnStateChange(PlayerState(state))
178+ }
179+ },
180+ Fill: func(buf *core.PCMBuffer) (int, error) {
181+ if callbacks.Fill == nil {
182+ return buf.Frames, nil
183+ }
184+ // Reuse scratch buffer by updating its fields
185+ p.scratchBuf.Frames = buf.Frames
186+ p.scratchBuf.Channels = buf.Channels
187+ p.scratchBuf.Stride = buf.Stride
188+ p.scratchBuf.Samples = buf.Samples
189+ return callbacks.Fill(&p.scratchBuf)
190+ },
191+ OnUnderrun: func(frames int) {
192+ if callbacks.OnUnderrun != nil {
193+ callbacks.OnUnderrun(frames)
194+ }
195+ },
196+ OnDrain: func() {
197+ if callbacks.OnDrain != nil {
198+ callbacks.OnDrain()
199+ }
200+ },
201+ OnError: func(err error) {
202+ if callbacks.OnError != nil {
203+ callbacks.OnError(err)
204+ }
205+ },
206+ }
207+
208+ // Create internal player with mapped config
209+ coreConfig := core.PlayerConfig{
210+ FramesPerBuffer: config.FramesPerBuffer,
211+ Channels: config.Channels,
212+ UnderrunPolicy: coreUnderrunPolicy,
213+ }
214+ p.internal = core.NewPlayer(coreConfig, coreCallbacks)
215+
216+ return p, nil
34217 }
@@ -1,34 +1,217 @@
1 package pipewire1 package pipewire
2 2
3 import (3 import (
4- "context"
5 "errors"4 "errors"
5+
6+ "github.com/bnema/purego-pipewire/internal/core"
7+)
8+
9+// SampleFormat represents the audio sample format type.
10+type SampleFormat int
11+
12+const (
13+ // SampleFormatF32 is the 32-bit floating point sample format.
14+ SampleFormatF32 SampleFormat = iota
15+)
16+
17+// UnderrunPolicy defines the behavior when a buffer underrun occurs.
18+type UnderrunPolicy int
19+
20+const (
21+ // UnderrunFillSilence fills the buffer with silence on underrun.
22+ UnderrunFillSilence UnderrunPolicy = iota
23+ // UnderrunFail fails playback on underrun.
24+ UnderrunFail
25+)
26+
27+// PlayerState represents the current state of the audio player.
28+type PlayerState int
29+
30+const (
31+ // PlayerStateIdle is the initial state before the player is started.
32+ PlayerStateIdle PlayerState = iota
33+ // PlayerStateStarting indicates the player is initializing.
34+ PlayerStateStarting
35+ // PlayerStatePlaying indicates the player is actively outputting audio.
36+ PlayerStatePlaying
37+ // PlayerStatePaused indicates the player is temporarily stopped.
38+ PlayerStatePaused
39+ // PlayerStateStopped indicates the player has been stopped.
40+ PlayerStateStopped
41+ // PlayerStateClosing indicates the player is shutting down.
42+ PlayerStateClosing
43+ // PlayerStateClosed indicates the player has been fully closed.
44+ PlayerStateClosed
45+ // PlayerStateError indicates the player encountered an error.
46+ PlayerStateError
6 )47 )
7 48
8-// ErrNotImplemented is returned by stub functions that are not yet implemented.49+// Legacy aliases for backwards compatibility (deprecated)
9-var ErrNotImplemented = errors.New("not implemented")50+const (
51+ Idle = PlayerStateIdle
52+ Starting = PlayerStateStarting
53+ Playing = PlayerStatePlaying
54+ Paused = PlayerStatePaused
55+ Stopped = PlayerStateStopped
56+ Closing = PlayerStateClosing
57+ Closed = PlayerStateClosed
58+ Error = PlayerStateError
59+)
10 60
11-// AudioFormat describes the audio format for a playback stream.61+// PlayerConfig holds the configuration for a new audio player.
12-type AudioFormat struct {62+type PlayerConfig struct {
13 SampleRate int63 SampleRate int
14 Channels int64 Channels int
15 FramesPerBuffer int65 FramesPerBuffer int
66+ SampleFormat SampleFormat
67+ UnderrunPolicy UnderrunPolicy
16 }68 }
17 69
18-// PlaybackStream is the public interface for writing audio samples to PipeWire.70+// PCMBuffer represents a buffer of PCM audio data.
19-type PlaybackStream interface {71+type PCMBuffer struct {
20- Write(samples [][]float32) error72+ Frames int
21- Close() error73+ Channels int
74+ Stride int
75+ Samples [][]float32
22 }76 }
23 77
24-// Player is the public interface for creating audio playback streams.78+// PlayerCallbacks contains all callback functions for player events.
79+type PlayerCallbacks struct {
80+ Fill func(*PCMBuffer) (int, error)
81+ OnUnderrun func(int)
82+ OnDrain func()
83+ OnError func(error)
84+ OnStateChange func(PlayerState)
85+}
86+
87+// Player is the public interface for audio playback.
25 type Player interface {88 type Player interface {
26- NewStream(ctx context.Context, format AudioFormat) (PlaybackStream, error)89+ Start() error
90+ Pause() error
91+ Stop() error
27 Close() error92 Close() error
93+ State() PlayerState
94+}
95+
96+// ErrInvalidPlayerConfig is returned when the player configuration is invalid.
97+var ErrInvalidPlayerConfig = errors.New("invalid player config")
98+
99+// playerImpl wraps the internal core player
100+type playerImpl struct {
101+ config PlayerConfig
102+ callbacks PlayerCallbacks
103+ internal *core.Player
104+ scratchBuf PCMBuffer // reusable buffer for Fill callback to avoid allocation
105+}
106+
107+func (p *playerImpl) Start() error {
108+ if p.internal == nil {
109+ return errors.New("player not initialized")
110+ }
111+ return p.internal.Start()
112+}
113+
114+func (p *playerImpl) Pause() error {
115+ if p.internal == nil {
116+ return errors.New("player not initialized")
117+ }
118+ return p.internal.Pause()
119+}
120+
121+func (p *playerImpl) Stop() error {
122+ if p.internal == nil {
123+ return errors.New("player not initialized")
124+ }
125+ return p.internal.Stop()
126+}
127+
128+func (p *playerImpl) Close() error {
129+ if p.internal == nil {
130+ return nil
131+ }
132+ return p.internal.Close()
133+}
134+
135+func (p *playerImpl) State() PlayerState {
136+ if p.internal == nil {
137+ return PlayerStateIdle
138+ }
139+ return PlayerState(p.internal.State())
28 }140 }
29 141
30-// NewPlayer creates a new Player for audio playback.142+// NewPlayer creates a new audio player with the given configuration and callbacks.
31-// This is currently a stub and returns ErrNotImplemented.143+// Returns ErrInvalidPlayerConfig if the configuration is invalid.
32-func NewPlayer() (Player, error) {144+func NewPlayer(config PlayerConfig, callbacks PlayerCallbacks) (Player, error) {
33- return nil, ErrNotImplemented145+ if config.SampleRate <= 0 {
146+ return nil, ErrInvalidPlayerConfig
147+ }
148+ if config.Channels <= 0 {
149+ return nil, ErrInvalidPlayerConfig
150+ }
151+ if config.FramesPerBuffer <= 0 {
152+ return nil, ErrInvalidPlayerConfig
153+ }
154+ if config.SampleFormat != SampleFormatF32 {
155+ return nil, ErrInvalidPlayerConfig
156+ }
157+
158+ // Convert public UnderrunPolicy to internal UnderrunPolicy
159+ var coreUnderrunPolicy core.UnderrunPolicy
160+ switch config.UnderrunPolicy {
161+ case UnderrunFail:
162+ coreUnderrunPolicy = core.UnderrunFailFast
163+ default:
164+ coreUnderrunPolicy = core.UnderrunFillSilence
165+ }
166+
167+ // Create the player implementation early so we can use its scratch buffer
168+ p := &playerImpl{
169+ config: config,
170+ callbacks: callbacks,
171+ }
172+
173+ // Convert public callbacks to internal callbacks
174+ coreCallbacks := core.PlayerCallbacks{
175+ OnStateChange: func(state core.PlayerState) {
176+ if callbacks.OnStateChange != nil {
177+ callbacks.OnStateChange(PlayerState(state))
178+ }
179+ },
180+ Fill: func(buf *core.PCMBuffer) (int, error) {
181+ if callbacks.Fill == nil {
182+ return buf.Frames, nil
183+ }
184+ // Reuse scratch buffer by updating its fields
185+ p.scratchBuf.Frames = buf.Frames
186+ p.scratchBuf.Channels = buf.Channels
187+ p.scratchBuf.Stride = buf.Stride
188+ p.scratchBuf.Samples = buf.Samples
189+ return callbacks.Fill(&p.scratchBuf)
190+ },
191+ OnUnderrun: func(frames int) {
192+ if callbacks.OnUnderrun != nil {
193+ callbacks.OnUnderrun(frames)
194+ }
195+ },
196+ OnDrain: func() {
197+ if callbacks.OnDrain != nil {
198+ callbacks.OnDrain()
199+ }
200+ },
201+ OnError: func(err error) {
202+ if callbacks.OnError != nil {
203+ callbacks.OnError(err)
204+ }
205+ },
206+ }
207+
208+ // Create internal player with mapped config
209+ coreConfig := core.PlayerConfig{
210+ FramesPerBuffer: config.FramesPerBuffer,
211+ Channels: config.Channels,
212+ UnderrunPolicy: coreUnderrunPolicy,
213+ }
214+ p.internal = core.NewPlayer(coreConfig, coreCallbacks)
215+
216+ return p, nil
34 }217 }
M pipewire/player_test.go +258 -8
@@ -5,16 +5,266 @@ import (
55 "testing"
66 )
77
8-func TestNewPlayerReturnsError(t *testing.T) {
9- p, err := NewPlayer()
8+func TestNewPlayerRejectsInvalidConfig(t *testing.T) {
9+ _, err := NewPlayer(PlayerConfig{}, PlayerCallbacks{})
1010 if err == nil {
11- t.Fatal("expected NewPlayer to return an error (not yet implemented)")
11+ t.Fatal("NewPlayer returned nil error for invalid config")
1212 }
13- if p != nil {
14- t.Fatal("expected NewPlayer to return nil player when erroring")
13+ if !errors.Is(err, ErrInvalidPlayerConfig) {
14+ t.Fatalf("expected ErrInvalidPlayerConfig, got %v", err)
1515 }
16- // Verify the error message indicates not-implemented.
17- if !errors.Is(err, ErrNotImplemented) {
18- t.Fatalf("expected ErrNotImplemented, got: %v", err)
16+}
17+
18+func TestNewPlayerAcceptsValidConfig(t *testing.T) {
19+ config := PlayerConfig{
20+ SampleRate: 44100,
21+ Channels: 2,
22+ FramesPerBuffer: 256,
23+ SampleFormat: SampleFormatF32,
24+ }
25+ player, err := NewPlayer(config, PlayerCallbacks{})
26+ if err != nil {
27+ t.Fatalf("NewPlayer returned error for valid config: %v", err)
28+ }
29+ if player == nil {
30+ t.Fatal("NewPlayer returned nil player for valid config")
31+ }
32+}
33+
34+func TestPlayerStateReflectsLifecycle(t *testing.T) {
35+ config := PlayerConfig{
36+ SampleRate: 44100,
37+ Channels: 2,
38+ FramesPerBuffer: 256,
39+ SampleFormat: SampleFormatF32,
40+ }
41+ player, err := NewPlayer(config, PlayerCallbacks{})
42+ if err != nil {
43+ t.Fatalf("NewPlayer returned error: %v", err)
44+ }
45+ if player.State() != PlayerStateIdle {
46+ t.Fatalf("expected initial state PlayerStateIdle, got %v", player.State())
47+ }
48+}
49+
50+func TestPlayerLifecycleStartStopClose(t *testing.T) {
51+ config := PlayerConfig{
52+ SampleRate: 44100,
53+ Channels: 2,
54+ FramesPerBuffer: 256,
55+ SampleFormat: SampleFormatF32,
56+ }
57+ player, err := NewPlayer(config, PlayerCallbacks{})
58+ if err != nil {
59+ t.Fatalf("NewPlayer returned error: %v", err)
60+ }
61+
62+ // Start should transition to Playing
63+ if err := player.Start(); err != nil {
64+ t.Fatalf("Start failed: %v", err)
65+ }
66+ if player.State() != PlayerStatePlaying {
67+ t.Fatalf("expected Playing state after Start, got %v", player.State())
68+ }
69+
70+ // Pause should transition to Paused
71+ if err := player.Pause(); err != nil {
72+ t.Fatalf("Pause failed: %v", err)
73+ }
74+ if player.State() != PlayerStatePaused {
75+ t.Fatalf("expected Paused state after Pause, got %v", player.State())
76+ }
77+
78+ // Stop should transition to Stopped
79+ if err := player.Stop(); err != nil {
80+ t.Fatalf("Stop failed: %v", err)
81+ }
82+ if player.State() != PlayerStateStopped {
83+ t.Fatalf("expected Stopped state after Stop, got %v", player.State())
84+ }
85+
86+ // Restart should work from Stopped
87+ if err := player.Start(); err != nil {
88+ t.Fatalf("Start from Stopped failed: %v", err)
89+ }
90+ if player.State() != PlayerStatePlaying {
91+ t.Fatalf("expected Playing state after restart, got %v", player.State())
92+ }
93+
94+ // Close should transition to Closed
95+ if err := player.Close(); err != nil {
96+ t.Fatalf("Close failed: %v", err)
97+ }
98+ if player.State() != PlayerStateClosed {
99+ t.Fatalf("expected Closed state after Close, got %v", player.State())
100+ }
101+
102+ // Start from Closed should fail
103+ if err := player.Start(); err == nil {
104+ t.Fatal("expected error starting from Closed state, got nil")
105+ }
106+}
107+
108+func TestPlayerCloseIsIdempotent(t *testing.T) {
109+ config := PlayerConfig{
110+ SampleRate: 44100,
111+ Channels: 2,
112+ FramesPerBuffer: 256,
113+ SampleFormat: SampleFormatF32,
114+ }
115+ player, err := NewPlayer(config, PlayerCallbacks{})
116+ if err != nil {
117+ t.Fatalf("NewPlayer returned error: %v", err)
118+ }
119+
120+ // Start first
121+ if err := player.Start(); err != nil {
122+ t.Fatalf("Start failed: %v", err)
123+ }
124+
125+ // First close should work
126+ if err := player.Close(); err != nil {
127+ t.Fatalf("first Close failed: %v", err)
128+ }
129+ if player.State() != PlayerStateClosed {
130+ t.Fatalf("expected Closed state, got %v", player.State())
131+ }
132+
133+ // Second close should succeed (idempotent)
134+ if err := player.Close(); err != nil {
135+ t.Fatalf("second Close failed: %v", err)
136+ }
137+ if player.State() != PlayerStateClosed {
138+ t.Fatalf("expected Closed state after second close, got %v", player.State())
139+ }
140+}
141+
142+func TestPlayerCallbacksAreWired(t *testing.T) {
143+ var stateChanges []PlayerState
144+ var fillCalled bool
145+ var underrunFrames int
146+ var drainCalled bool
147+
148+ config := PlayerConfig{
149+ SampleRate: 44100,
150+ Channels: 2,
151+ FramesPerBuffer: 256,
152+ SampleFormat: SampleFormatF32,
153+ UnderrunPolicy: UnderrunFillSilence,
154+ }
155+
156+ callbacks := PlayerCallbacks{
157+ Fill: func(buf *PCMBuffer) (int, error) {
158+ fillCalled = true
159+ _ = fillCalled // Suppress unused warning for now
160+ return buf.Frames, nil
161+ },
162+ OnUnderrun: func(frames int) {
163+ underrunFrames = frames
164+ _ = underrunFrames // Suppress unused warning for now
165+ },
166+ OnDrain: func() {
167+ drainCalled = true
168+ _ = drainCalled // Suppress unused warning for now
169+ },
170+ OnStateChange: func(state PlayerState) {
171+ stateChanges = append(stateChanges, state)
172+ },
173+ }
174+
175+ player, err := NewPlayer(config, callbacks)
176+ if err != nil {
177+ t.Fatalf("NewPlayer returned error: %v", err)
178+ }
179+
180+ // Verify initial state
181+ if player.State() != PlayerStateIdle {
182+ t.Fatalf("expected PlayerStateIdle, got %v", player.State())
183+ }
184+
185+ // Start should trigger state change callback
186+ if err := player.Start(); err != nil {
187+ t.Fatalf("Start failed: %v", err)
188+ }
189+
190+ // Verify we got state change callbacks
191+ if len(stateChanges) == 0 {
192+ t.Fatal("expected OnStateChange callbacks, got none")
193+ }
194+
195+ // Clean up
196+ if err := player.Close(); err != nil {
197+ t.Fatalf("Close failed: %v", err)
198+ }
199+}
200+
201+func TestPlayerConfigMapsToCore(t *testing.T) {
202+ config := PlayerConfig{
203+ SampleRate: 48000,
204+ Channels: 4,
205+ FramesPerBuffer: 512,
206+ SampleFormat: SampleFormatF32,
207+ UnderrunPolicy: UnderrunFail,
208+ }
209+
210+ player, err := NewPlayer(config, PlayerCallbacks{})
211+ if err != nil {
212+ t.Fatalf("NewPlayer returned error: %v", err)
213+ }
214+ if player == nil {
215+ t.Fatal("NewPlayer returned nil player")
216+ }
217+
218+ // Player should be usable after creation
219+ if player.State() != PlayerStateIdle {
220+ t.Fatalf("expected PlayerStateIdle, got %v", player.State())
221+ }
222+
223+ // Clean up
224+ if err := player.Close(); err != nil {
225+ t.Fatalf("Close failed: %v", err)
226+ }
227+}
228+
229+func TestFillAdapterReusesBuffer(t *testing.T) {
230+ var callCount int
231+ var firstBuf *PCMBuffer
232+
233+ config := PlayerConfig{
234+ SampleRate: 44100,
235+ Channels: 2,
236+ FramesPerBuffer: 256,
237+ SampleFormat: SampleFormatF32,
238+ }
239+
240+ callbacks := PlayerCallbacks{
241+ Fill: func(buf *PCMBuffer) (int, error) {
242+ callCount++
243+ if callCount == 1 {
244+ firstBuf = buf
245+ _ = firstBuf // Avoid unused variable warning - we'll use this for assertion later
246+ } else if callCount == 2 {
247+ // Verify the buffer is reusable - on second call it should be the same
248+ // pointer but with potentially updated contents
249+ if buf == nil {
250+ t.Fatal("Fill received nil buffer on second call")
251+ }
252+ }
253+ return buf.Frames, nil
254+ },
255+ }
256+
257+ player, err := NewPlayer(config, callbacks)
258+ if err != nil {
259+ t.Fatalf("NewPlayer returned error: %v", err)
260+ }
261+
262+ // Simulate what would happen if the Fill callback is invoked twice
263+ // by directly testing that the adapter setup works correctly
264+ // The actual audio callback invocation would be tested in integration tests
265+
266+ // Clean up
267+ if err := player.Close(); err != nil {
268+ t.Fatalf("Close failed: %v", err)
19269 }
20270 }
@@ -5,16 +5,266 @@ import (
5 "testing"5 "testing"
6 )6 )
7 7
8-func TestNewPlayerReturnsError(t *testing.T) {8+func TestNewPlayerRejectsInvalidConfig(t *testing.T) {
9- p, err := NewPlayer()9+ _, err := NewPlayer(PlayerConfig{}, PlayerCallbacks{})
10 if err == nil {10 if err == nil {
11- t.Fatal("expected NewPlayer to return an error (not yet implemented)")11+ t.Fatal("NewPlayer returned nil error for invalid config")
12 }12 }
13- if p != nil {13+ if !errors.Is(err, ErrInvalidPlayerConfig) {
14- t.Fatal("expected NewPlayer to return nil player when erroring")14+ t.Fatalf("expected ErrInvalidPlayerConfig, got %v", err)
15 }15 }
16- // Verify the error message indicates not-implemented.16+}
17- if !errors.Is(err, ErrNotImplemented) {17+
18- t.Fatalf("expected ErrNotImplemented, got: %v", err)18+func TestNewPlayerAcceptsValidConfig(t *testing.T) {
19+ config := PlayerConfig{
20+ SampleRate: 44100,
21+ Channels: 2,
22+ FramesPerBuffer: 256,
23+ SampleFormat: SampleFormatF32,
24+ }
25+ player, err := NewPlayer(config, PlayerCallbacks{})
26+ if err != nil {
27+ t.Fatalf("NewPlayer returned error for valid config: %v", err)
28+ }
29+ if player == nil {
30+ t.Fatal("NewPlayer returned nil player for valid config")
31+ }
32+}
33+
34+func TestPlayerStateReflectsLifecycle(t *testing.T) {
35+ config := PlayerConfig{
36+ SampleRate: 44100,
37+ Channels: 2,
38+ FramesPerBuffer: 256,
39+ SampleFormat: SampleFormatF32,
40+ }
41+ player, err := NewPlayer(config, PlayerCallbacks{})
42+ if err != nil {
43+ t.Fatalf("NewPlayer returned error: %v", err)
44+ }
45+ if player.State() != PlayerStateIdle {
46+ t.Fatalf("expected initial state PlayerStateIdle, got %v", player.State())
47+ }
48+}
49+
50+func TestPlayerLifecycleStartStopClose(t *testing.T) {
51+ config := PlayerConfig{
52+ SampleRate: 44100,
53+ Channels: 2,
54+ FramesPerBuffer: 256,
55+ SampleFormat: SampleFormatF32,
56+ }
57+ player, err := NewPlayer(config, PlayerCallbacks{})
58+ if err != nil {
59+ t.Fatalf("NewPlayer returned error: %v", err)
60+ }
61+
62+ // Start should transition to Playing
63+ if err := player.Start(); err != nil {
64+ t.Fatalf("Start failed: %v", err)
65+ }
66+ if player.State() != PlayerStatePlaying {
67+ t.Fatalf("expected Playing state after Start, got %v", player.State())
68+ }
69+
70+ // Pause should transition to Paused
71+ if err := player.Pause(); err != nil {
72+ t.Fatalf("Pause failed: %v", err)
73+ }
74+ if player.State() != PlayerStatePaused {
75+ t.Fatalf("expected Paused state after Pause, got %v", player.State())
76+ }
77+
78+ // Stop should transition to Stopped
79+ if err := player.Stop(); err != nil {
80+ t.Fatalf("Stop failed: %v", err)
81+ }
82+ if player.State() != PlayerStateStopped {
83+ t.Fatalf("expected Stopped state after Stop, got %v", player.State())
84+ }
85+
86+ // Restart should work from Stopped
87+ if err := player.Start(); err != nil {
88+ t.Fatalf("Start from Stopped failed: %v", err)
89+ }
90+ if player.State() != PlayerStatePlaying {
91+ t.Fatalf("expected Playing state after restart, got %v", player.State())
92+ }
93+
94+ // Close should transition to Closed
95+ if err := player.Close(); err != nil {
96+ t.Fatalf("Close failed: %v", err)
97+ }
98+ if player.State() != PlayerStateClosed {
99+ t.Fatalf("expected Closed state after Close, got %v", player.State())
100+ }
101+
102+ // Start from Closed should fail
103+ if err := player.Start(); err == nil {
104+ t.Fatal("expected error starting from Closed state, got nil")
105+ }
106+}
107+
108+func TestPlayerCloseIsIdempotent(t *testing.T) {
109+ config := PlayerConfig{
110+ SampleRate: 44100,
111+ Channels: 2,
112+ FramesPerBuffer: 256,
113+ SampleFormat: SampleFormatF32,
114+ }
115+ player, err := NewPlayer(config, PlayerCallbacks{})
116+ if err != nil {
117+ t.Fatalf("NewPlayer returned error: %v", err)
118+ }
119+
120+ // Start first
121+ if err := player.Start(); err != nil {
122+ t.Fatalf("Start failed: %v", err)
123+ }
124+
125+ // First close should work
126+ if err := player.Close(); err != nil {
127+ t.Fatalf("first Close failed: %v", err)
128+ }
129+ if player.State() != PlayerStateClosed {
130+ t.Fatalf("expected Closed state, got %v", player.State())
131+ }
132+
133+ // Second close should succeed (idempotent)
134+ if err := player.Close(); err != nil {
135+ t.Fatalf("second Close failed: %v", err)
136+ }
137+ if player.State() != PlayerStateClosed {
138+ t.Fatalf("expected Closed state after second close, got %v", player.State())
139+ }
140+}
141+
142+func TestPlayerCallbacksAreWired(t *testing.T) {
143+ var stateChanges []PlayerState
144+ var fillCalled bool
145+ var underrunFrames int
146+ var drainCalled bool
147+
148+ config := PlayerConfig{
149+ SampleRate: 44100,
150+ Channels: 2,
151+ FramesPerBuffer: 256,
152+ SampleFormat: SampleFormatF32,
153+ UnderrunPolicy: UnderrunFillSilence,
154+ }
155+
156+ callbacks := PlayerCallbacks{
157+ Fill: func(buf *PCMBuffer) (int, error) {
158+ fillCalled = true
159+ _ = fillCalled // Suppress unused warning for now
160+ return buf.Frames, nil
161+ },
162+ OnUnderrun: func(frames int) {
163+ underrunFrames = frames
164+ _ = underrunFrames // Suppress unused warning for now
165+ },
166+ OnDrain: func() {
167+ drainCalled = true
168+ _ = drainCalled // Suppress unused warning for now
169+ },
170+ OnStateChange: func(state PlayerState) {
171+ stateChanges = append(stateChanges, state)
172+ },
173+ }
174+
175+ player, err := NewPlayer(config, callbacks)
176+ if err != nil {
177+ t.Fatalf("NewPlayer returned error: %v", err)
178+ }
179+
180+ // Verify initial state
181+ if player.State() != PlayerStateIdle {
182+ t.Fatalf("expected PlayerStateIdle, got %v", player.State())
183+ }
184+
185+ // Start should trigger state change callback
186+ if err := player.Start(); err != nil {
187+ t.Fatalf("Start failed: %v", err)
188+ }
189+
190+ // Verify we got state change callbacks
191+ if len(stateChanges) == 0 {
192+ t.Fatal("expected OnStateChange callbacks, got none")
193+ }
194+
195+ // Clean up
196+ if err := player.Close(); err != nil {
197+ t.Fatalf("Close failed: %v", err)
198+ }
199+}
200+
201+func TestPlayerConfigMapsToCore(t *testing.T) {
202+ config := PlayerConfig{
203+ SampleRate: 48000,
204+ Channels: 4,
205+ FramesPerBuffer: 512,
206+ SampleFormat: SampleFormatF32,
207+ UnderrunPolicy: UnderrunFail,
208+ }
209+
210+ player, err := NewPlayer(config, PlayerCallbacks{})
211+ if err != nil {
212+ t.Fatalf("NewPlayer returned error: %v", err)
213+ }
214+ if player == nil {
215+ t.Fatal("NewPlayer returned nil player")
216+ }
217+
218+ // Player should be usable after creation
219+ if player.State() != PlayerStateIdle {
220+ t.Fatalf("expected PlayerStateIdle, got %v", player.State())
221+ }
222+
223+ // Clean up
224+ if err := player.Close(); err != nil {
225+ t.Fatalf("Close failed: %v", err)
226+ }
227+}
228+
229+func TestFillAdapterReusesBuffer(t *testing.T) {
230+ var callCount int
231+ var firstBuf *PCMBuffer
232+
233+ config := PlayerConfig{
234+ SampleRate: 44100,
235+ Channels: 2,
236+ FramesPerBuffer: 256,
237+ SampleFormat: SampleFormatF32,
238+ }
239+
240+ callbacks := PlayerCallbacks{
241+ Fill: func(buf *PCMBuffer) (int, error) {
242+ callCount++
243+ if callCount == 1 {
244+ firstBuf = buf
245+ _ = firstBuf // Avoid unused variable warning - we'll use this for assertion later
246+ } else if callCount == 2 {
247+ // Verify the buffer is reusable - on second call it should be the same
248+ // pointer but with potentially updated contents
249+ if buf == nil {
250+ t.Fatal("Fill received nil buffer on second call")
251+ }
252+ }
253+ return buf.Frames, nil
254+ },
255+ }
256+
257+ player, err := NewPlayer(config, callbacks)
258+ if err != nil {
259+ t.Fatalf("NewPlayer returned error: %v", err)
260+ }
261+
262+ // Simulate what would happen if the Fill callback is invoked twice
263+ // by directly testing that the adapter setup works correctly
264+ // The actual audio callback invocation would be tested in integration tests
265+
266+ // Clean up
267+ if err := player.Close(); err != nil {
268+ t.Fatalf("Close failed: %v", err)
19 }269 }
20 }270 }

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 1

Tags 8

↑↓ moveenter openesc close