fix: harden PipeWire player runtime and bindings
- commit
- 8b5fe8bf7179f426595b1367201798e4d5acb2ef
- author
- brice · · 2026-04-11 06:09 UTC
- parent
- 8f9cb67
- signature
- GPG Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· arch-gpg Verified for the committer email
15 files · +434 -157
M cmd/pipewiregen/internal/emitter/emitter.go +32 -43
| @@ -6,6 +6,7 @@ import ( | |||
| 6 | 6 | "fmt" | |
| 7 | 7 | "os" | |
| 8 | 8 | "path/filepath" | |
| 9 | + | "regexp" | |
| 9 | 10 | "strings" | |
| 10 | 11 | "text/template" | |
| 11 | 12 | "unicode" | |
| @@ -14,28 +15,22 @@ import ( | |||
| 14 | 15 | ) | |
| 15 | 16 | ||
| 16 | 17 | var ( | |
| 17 | - | capiTmpl *template.Template | |
| 18 | - | portTmpl *template.Template | |
| 19 | - | adapterTmpl *template.Template | |
| 20 | - | compositeTmpl *template.Template | |
| 18 | + | callbackFieldPattern = regexp.MustCompile(` \*func\([^)]*\)`) | |
| 19 | + | capiTmpl *template.Template | |
| 20 | + | portTmpl *template.Template | |
| 21 | + | adapterTmpl *template.Template | |
| 22 | + | compositeTmpl *template.Template | |
| 21 | 23 | ) | |
| 22 | 24 | ||
| 23 | 25 | func init() { | |
| 24 | - | capiTmpl = template.Must(template.New("capi").Funcs(template.FuncMap{ | |
| 25 | - | "title": titleCase, | |
| 26 | - | }).Parse(capiTemplate)) | |
| 27 | - | ||
| 28 | - | portTmpl = template.Must(template.New("port").Funcs(template.FuncMap{ | |
| 29 | - | "title": titleCase, | |
| 30 | - | }).Parse(portTemplate)) | |
| 31 | - | ||
| 32 | - | adapterTmpl = template.Must(template.New("adapter").Funcs(template.FuncMap{ | |
| 33 | - | "title": titleCase, | |
| 34 | - | }).Parse(adapterTemplate)) | |
| 26 | + | capiTmpl = mustTemplate("capi", capiTemplate) | |
| 27 | + | portTmpl = mustTemplate("port", portTemplate) | |
| 28 | + | adapterTmpl = mustTemplate("adapter", adapterTemplate) | |
| 29 | + | compositeTmpl = mustTemplate("composite", compositeTemplate) | |
| 30 | + | } | |
| 35 | 31 | ||
| 36 | - | compositeTmpl = template.Must(template.New("composite").Funcs(template.FuncMap{ | |
| 37 | - | "title": titleCase, | |
| 38 | - | }).Parse(compositeTemplate)) | |
| 32 | + | func mustTemplate(name, text string) *template.Template { | |
| 33 | + | return template.Must(template.New(name).Funcs(template.FuncMap{"title": titleCase}).Parse(text)) | |
| 39 | 34 | } | |
| 40 | 35 | ||
| 41 | 36 | // titleCase converts a string to title case (first letter uppercase, rest lowercase). | |
| @@ -112,17 +107,13 @@ func renderCAPI(group model.Group, symbols []model.Symbol, libraries []model.Lib | |||
| 112 | 107 | ||
| 113 | 108 | // Filter callbacks and event structs for this group | |
| 114 | 109 | var groupCallbacks []model.Callback | |
| 115 | - | var groupEventStructs []model.EventStruct | |
| 116 | 110 | for _, cb := range callbacks { | |
| 117 | 111 | if cb.Group == group.Name { | |
| 112 | + | cb.Signature = sanitizeCallbackSignature(cb.Signature) | |
| 118 | 113 | groupCallbacks = append(groupCallbacks, cb) | |
| 119 | 114 | } | |
| 120 | 115 | } | |
| 121 | - | for _, es := range eventStructs { | |
| 122 | - | if es.Group == group.Name { | |
| 123 | - | groupEventStructs = append(groupEventStructs, es) | |
| 124 | - | } | |
| 125 | - | } | |
| 116 | + | groupEventStructs := filterEventStructsByGroup(eventStructs, group.Name) | |
| 126 | 117 | // Check if callbacks need unsafe | |
| 127 | 118 | for _, cb := range groupCallbacks { | |
| 128 | 119 | if strings.Contains(cb.Signature, "unsafe.Pointer") { | |
| @@ -156,6 +147,10 @@ func renderCAPI(group model.Group, symbols []model.Symbol, libraries []model.Lib | |||
| 156 | 147 | return buf.Bytes(), nil | |
| 157 | 148 | } | |
| 158 | 149 | ||
| 150 | + | func sanitizeCallbackSignature(sig string) string { | |
| 151 | + | return callbackFieldPattern.ReplaceAllString(sig, " uintptr") | |
| 152 | + | } | |
| 153 | + | ||
| 159 | 154 | // renderPort generates the outbound interface definitions. | |
| 160 | 155 | func renderPort(group model.Group, symbols []model.Symbol, eventStructs []model.EventStruct) ([]byte, error) { | |
| 161 | 156 | groupSymbols := filterSymbolsByGroup(symbols, group.Name) | |
| @@ -186,13 +181,7 @@ func renderPort(group model.Group, symbols []model.Symbol, eventStructs []model. | |||
| 186 | 181 | } | |
| 187 | 182 | } | |
| 188 | 183 | ||
| 189 | - | // Filter event structs for this group | |
| 190 | - | var groupEventStructs []model.EventStruct | |
| 191 | - | for _, es := range eventStructs { | |
| 192 | - | if es.Group == group.Name { | |
| 193 | - | groupEventStructs = append(groupEventStructs, es) | |
| 194 | - | } | |
| 195 | - | } | |
| 184 | + | groupEventStructs := filterEventStructsByGroup(eventStructs, group.Name) | |
| 196 | 185 | ||
| 197 | 186 | data := struct { | |
| 198 | 187 | Group model.Group | |
| @@ -304,23 +293,23 @@ func filterSymbolsByGroup(symbols []model.Symbol, groupName string) []model.Symb | |||
| 304 | 293 | return result | |
| 305 | 294 | } | |
| 306 | 295 | ||
| 296 | + | func filterEventStructsByGroup(eventStructs []model.EventStruct, groupName string) []model.EventStruct { | |
| 297 | + | var result []model.EventStruct | |
| 298 | + | for _, es := range eventStructs { | |
| 299 | + | if es.Group == groupName { | |
| 300 | + | result = append(result, es) | |
| 301 | + | } | |
| 302 | + | } | |
| 303 | + | return result | |
| 304 | + | } | |
| 305 | + | ||
| 307 | 306 | // toGoName converts a C function name like pw_init to a Go name like PWInit. | |
| 308 | 307 | func toGoName(cName string) string { | |
| 309 | 308 | // Handle pw_ prefix specially | |
| 310 | 309 | if strings.HasPrefix(cName, "pw_") { | |
| 311 | - | name := cName[3:] | |
| 312 | - | parts := strings.Split(name, "_") | |
| 313 | - | for i, p := range parts { | |
| 314 | - | parts[i] = titleCase(p) | |
| 315 | - | } | |
| 316 | - | return "PW" + strings.Join(parts, "") | |
| 310 | + | return "PW" + toCamelCase(cName[3:]) | |
| 317 | 311 | } | |
| 318 | - | // Generic conversion | |
| 319 | - | parts := strings.Split(cName, "_") | |
| 320 | - | for i, p := range parts { | |
| 321 | - | parts[i] = titleCase(p) | |
| 322 | - | } | |
| 323 | - | return strings.Join(parts, "") | |
| 312 | + | return toCamelCase(cName) | |
| 324 | 313 | } | |
| 325 | 314 | ||
| 326 | 315 | // toCamelCase converts a snake_case name like stream_playback to StreamPlayback. | |
| @@ -6,6 +6,7 @@ import ( | |||||
| 6 | "fmt" | 6 | "fmt" | ||
| 7 | "os" | 7 | "os" | ||
| 8 | "path/filepath" | 8 | "path/filepath" | ||
| 9 | + | "regexp" | |||
| 9 | "strings" | 10 | "strings" | ||
| 10 | "text/template" | 11 | "text/template" | ||
| 11 | "unicode" | 12 | "unicode" | ||
| @@ -14,28 +15,22 @@ import ( | |||||
| 14 | ) | 15 | ) | ||
| 15 | 16 | ||||
| 16 | var ( | 17 | var ( | ||
| 17 | - | capiTmpl *template.Template | 18 | + | callbackFieldPattern = regexp.MustCompile(` \*func\([^)]*\)`) |
| 18 | - | portTmpl *template.Template | 19 | + | capiTmpl *template.Template |
| 19 | - | adapterTmpl *template.Template | 20 | + | portTmpl *template.Template |
| 20 | - | compositeTmpl *template.Template | 21 | + | adapterTmpl *template.Template |
| 22 | + | compositeTmpl *template.Template | |||
| 21 | ) | 23 | ) | ||
| 22 | 24 | ||||
| 23 | func init() { | 25 | func init() { | ||
| 24 | - | capiTmpl = template.Must(template.New("capi").Funcs(template.FuncMap{ | 26 | + | capiTmpl = mustTemplate("capi", capiTemplate) |
| 25 | - | "title": titleCase, | 27 | + | portTmpl = mustTemplate("port", portTemplate) |
| 26 | - | }).Parse(capiTemplate)) | 28 | + | adapterTmpl = mustTemplate("adapter", adapterTemplate) |
| 27 | - | 29 | + | compositeTmpl = mustTemplate("composite", compositeTemplate) | |
| 28 | - | portTmpl = template.Must(template.New("port").Funcs(template.FuncMap{ | 30 | + | } |
| 29 | - | "title": titleCase, | |||
| 30 | - | }).Parse(portTemplate)) | |||
| 31 | - | ||||
| 32 | - | adapterTmpl = template.Must(template.New("adapter").Funcs(template.FuncMap{ | |||
| 33 | - | "title": titleCase, | |||
| 34 | - | }).Parse(adapterTemplate)) | |||
| 35 | 31 | ||||
| 36 | - | compositeTmpl = template.Must(template.New("composite").Funcs(template.FuncMap{ | 32 | + | func mustTemplate(name, text string) *template.Template { |
| 37 | - | "title": titleCase, | 33 | + | return template.Must(template.New(name).Funcs(template.FuncMap{"title": titleCase}).Parse(text)) |
| 38 | - | }).Parse(compositeTemplate)) | |||
| 39 | } | 34 | } | ||
| 40 | 35 | ||||
| 41 | // titleCase converts a string to title case (first letter uppercase, rest lowercase). | 36 | // titleCase converts a string to title case (first letter uppercase, rest lowercase). | ||
| @@ -112,17 +107,13 @@ func renderCAPI(group model.Group, symbols []model.Symbol, libraries []model.Lib | |||||
| 112 | 107 | ||||
| 113 | // Filter callbacks and event structs for this group | 108 | // Filter callbacks and event structs for this group | ||
| 114 | var groupCallbacks []model.Callback | 109 | var groupCallbacks []model.Callback | ||
| 115 | - | var groupEventStructs []model.EventStruct | |||
| 116 | for _, cb := range callbacks { | 110 | for _, cb := range callbacks { | ||
| 117 | if cb.Group == group.Name { | 111 | if cb.Group == group.Name { | ||
| 112 | + | cb.Signature = sanitizeCallbackSignature(cb.Signature) | |||
| 118 | groupCallbacks = append(groupCallbacks, cb) | 113 | groupCallbacks = append(groupCallbacks, cb) | ||
| 119 | } | 114 | } | ||
| 120 | } | 115 | } | ||
| 121 | - | for _, es := range eventStructs { | 116 | + | groupEventStructs := filterEventStructsByGroup(eventStructs, group.Name) |
| 122 | - | if es.Group == group.Name { | |||
| 123 | - | groupEventStructs = append(groupEventStructs, es) | |||
| 124 | - | } | |||
| 125 | - | } | |||
| 126 | // Check if callbacks need unsafe | 117 | // Check if callbacks need unsafe | ||
| 127 | for _, cb := range groupCallbacks { | 118 | for _, cb := range groupCallbacks { | ||
| 128 | if strings.Contains(cb.Signature, "unsafe.Pointer") { | 119 | if strings.Contains(cb.Signature, "unsafe.Pointer") { | ||
| @@ -156,6 +147,10 @@ func renderCAPI(group model.Group, symbols []model.Symbol, libraries []model.Lib | |||||
| 156 | return buf.Bytes(), nil | 147 | return buf.Bytes(), nil | ||
| 157 | } | 148 | } | ||
| 158 | 149 | ||||
| 150 | + | func sanitizeCallbackSignature(sig string) string { | |||
| 151 | + | return callbackFieldPattern.ReplaceAllString(sig, " uintptr") | |||
| 152 | + | } | |||
| 153 | + | ||||
| 159 | // renderPort generates the outbound interface definitions. | 154 | // renderPort generates the outbound interface definitions. | ||
| 160 | func renderPort(group model.Group, symbols []model.Symbol, eventStructs []model.EventStruct) ([]byte, error) { | 155 | func renderPort(group model.Group, symbols []model.Symbol, eventStructs []model.EventStruct) ([]byte, error) { | ||
| 161 | groupSymbols := filterSymbolsByGroup(symbols, group.Name) | 156 | groupSymbols := filterSymbolsByGroup(symbols, group.Name) | ||
| @@ -186,13 +181,7 @@ func renderPort(group model.Group, symbols []model.Symbol, eventStructs []model. | |||||
| 186 | } | 181 | } | ||
| 187 | } | 182 | } | ||
| 188 | 183 | ||||
| 189 | - | // Filter event structs for this group | 184 | + | groupEventStructs := filterEventStructsByGroup(eventStructs, group.Name) |
| 190 | - | var groupEventStructs []model.EventStruct | |||
| 191 | - | for _, es := range eventStructs { | |||
| 192 | - | if es.Group == group.Name { | |||
| 193 | - | groupEventStructs = append(groupEventStructs, es) | |||
| 194 | - | } | |||
| 195 | - | } | |||
| 196 | 185 | ||||
| 197 | data := struct { | 186 | data := struct { | ||
| 198 | Group model.Group | 187 | Group model.Group | ||
| @@ -304,23 +293,23 @@ func filterSymbolsByGroup(symbols []model.Symbol, groupName string) []model.Symb | |||||
| 304 | return result | 293 | return result | ||
| 305 | } | 294 | } | ||
| 306 | 295 | ||||
| 296 | + | func filterEventStructsByGroup(eventStructs []model.EventStruct, groupName string) []model.EventStruct { | |||
| 297 | + | var result []model.EventStruct | |||
| 298 | + | for _, es := range eventStructs { | |||
| 299 | + | if es.Group == groupName { | |||
| 300 | + | result = append(result, es) | |||
| 301 | + | } | |||
| 302 | + | } | |||
| 303 | + | return result | |||
| 304 | + | } | |||
| 305 | + | ||||
| 307 | // toGoName converts a C function name like pw_init to a Go name like PWInit. | 306 | // toGoName converts a C function name like pw_init to a Go name like PWInit. | ||
| 308 | func toGoName(cName string) string { | 307 | func toGoName(cName string) string { | ||
| 309 | // Handle pw_ prefix specially | 308 | // Handle pw_ prefix specially | ||
| 310 | if strings.HasPrefix(cName, "pw_") { | 309 | if strings.HasPrefix(cName, "pw_") { | ||
| 311 | - | name := cName[3:] | 310 | + | return "PW" + toCamelCase(cName[3:]) |
| 312 | - | parts := strings.Split(name, "_") | |||
| 313 | - | for i, p := range parts { | |||
| 314 | - | parts[i] = titleCase(p) | |||
| 315 | - | } | |||
| 316 | - | return "PW" + strings.Join(parts, "") | |||
| 317 | } | 311 | } | ||
| 318 | - | // Generic conversion | 312 | + | return toCamelCase(cName) |
| 319 | - | parts := strings.Split(cName, "_") | |||
| 320 | - | for i, p := range parts { | |||
| 321 | - | parts[i] = titleCase(p) | |||
| 322 | - | } | |||
| 323 | - | return strings.Join(parts, "") | |||
| 324 | } | 313 | } | ||
| 325 | 314 | ||||
| 326 | // toCamelCase converts a snake_case name like stream_playback to StreamPlayback. | 315 | // toCamelCase converts a snake_case name like stream_playback to StreamPlayback. | ||
M cmd/pipewiregen/internal/emitter/emitter_test.go +16 -12
| @@ -114,17 +114,17 @@ func TestPWStreamEventsABILayout(t *testing.T) { | |||
| 114 | 114 | // The correct C ABI field order from <pipewire/stream.h> | |
| 115 | 115 | expectedFields := []string{ | |
| 116 | 116 | "version uint32", | |
| 117 | - | "destroy *func(", | |
| 118 | - | "state_changed *func(", | |
| 119 | - | "control_info *func(", | |
| 120 | - | "io_changed *func(", | |
| 121 | - | "param_changed *func(", | |
| 122 | - | "add_buffer *func(", | |
| 123 | - | "remove_buffer *func(", | |
| 124 | - | "process *func(", | |
| 125 | - | "drained *func(", | |
| 126 | - | "command *func(", | |
| 127 | - | "trigger_done *func(", | |
| 117 | + | "destroy uintptr", | |
| 118 | + | "state_changed uintptr", | |
| 119 | + | "control_info uintptr", | |
| 120 | + | "io_changed uintptr", | |
| 121 | + | "param_changed uintptr", | |
| 122 | + | "add_buffer uintptr", | |
| 123 | + | "remove_buffer uintptr", | |
| 124 | + | "process uintptr", | |
| 125 | + | "drained uintptr", | |
| 126 | + | "command uintptr", | |
| 127 | + | "trigger_done uintptr", | |
| 128 | 128 | } | |
| 129 | 129 | ||
| 130 | 130 | // Verify all expected fields are present | |
| @@ -135,10 +135,14 @@ func TestPWStreamEventsABILayout(t *testing.T) { | |||
| 135 | 135 | } | |
| 136 | 136 | ||
| 137 | 137 | // Verify the spurious "flush" field is NOT present | |
| 138 | - | if strings.Contains(capiContent, "flush *func(") { | |
| 138 | + | if strings.Contains(capiContent, "flush uintptr") { | |
| 139 | 139 | t.Error("pw_stream_events contains spurious 'flush' field — this does not exist in the PipeWire C ABI") | |
| 140 | 140 | } | |
| 141 | 141 | ||
| 142 | + | if strings.Contains(capiContent, "*func(") { | |
| 143 | + | t.Error("pw_stream_events callback fields must be emitted as uintptr function pointers, not Go func pointers") | |
| 144 | + | } | |
| 145 | + | ||
| 142 | 146 | // Verify field order: each field must appear after the previous one | |
| 143 | 147 | lastIdx := 0 | |
| 144 | 148 | for _, field := range expectedFields { | |
| @@ -114,17 +114,17 @@ func TestPWStreamEventsABILayout(t *testing.T) { | |||||
| 114 | // The correct C ABI field order from <pipewire/stream.h> | 114 | // The correct C ABI field order from <pipewire/stream.h> | ||
| 115 | expectedFields := []string{ | 115 | expectedFields := []string{ | ||
| 116 | "version uint32", | 116 | "version uint32", | ||
| 117 | - | "destroy *func(", | 117 | + | "destroy uintptr", |
| 118 | - | "state_changed *func(", | 118 | + | "state_changed uintptr", |
| 119 | - | "control_info *func(", | 119 | + | "control_info uintptr", |
| 120 | - | "io_changed *func(", | 120 | + | "io_changed uintptr", |
| 121 | - | "param_changed *func(", | 121 | + | "param_changed uintptr", |
| 122 | - | "add_buffer *func(", | 122 | + | "add_buffer uintptr", |
| 123 | - | "remove_buffer *func(", | 123 | + | "remove_buffer uintptr", |
| 124 | - | "process *func(", | 124 | + | "process uintptr", |
| 125 | - | "drained *func(", | 125 | + | "drained uintptr", |
| 126 | - | "command *func(", | 126 | + | "command uintptr", |
| 127 | - | "trigger_done *func(", | 127 | + | "trigger_done uintptr", |
| 128 | } | 128 | } | ||
| 129 | 129 | ||||
| 130 | // Verify all expected fields are present | 130 | // Verify all expected fields are present | ||
| @@ -135,10 +135,14 @@ func TestPWStreamEventsABILayout(t *testing.T) { | |||||
| 135 | } | 135 | } | ||
| 136 | 136 | ||||
| 137 | // Verify the spurious "flush" field is NOT present | 137 | // Verify the spurious "flush" field is NOT present | ||
| 138 | - | if strings.Contains(capiContent, "flush *func(") { | 138 | + | if strings.Contains(capiContent, "flush uintptr") { |
| 139 | t.Error("pw_stream_events contains spurious 'flush' field — this does not exist in the PipeWire C ABI") | 139 | t.Error("pw_stream_events contains spurious 'flush' field — this does not exist in the PipeWire C ABI") | ||
| 140 | } | 140 | } | ||
| 141 | 141 | ||||
| 142 | + | if strings.Contains(capiContent, "*func(") { | |||
| 143 | + | t.Error("pw_stream_events callback fields must be emitted as uintptr function pointers, not Go func pointers") | |||
| 144 | + | } | |||
| 145 | + | ||||
| 142 | // Verify field order: each field must appear after the previous one | 146 | // Verify field order: each field must appear after the previous one | ||
| 143 | lastIdx := 0 | 147 | lastIdx := 0 | ||
| 144 | for _, field := range expectedFields { | 148 | for _, field := range expectedFields { | ||
M cmd/pipewiregen/internal/parser/testdata/callback_unknown_group.json +1 -1
| @@ -11,4 +11,4 @@ | |||
| 11 | 11 | "callbacks": [ | |
| 12 | 12 | {"name": "pw_stream_events", "signature": "struct{}", "group": "nonexistent"} | |
| 13 | 13 | ] | |
| 14 | - | }⏎̸ | |
| 14 | + | } | |
| @@ -11,4 +11,4 @@ | |||||
| 11 | "callbacks": [ | 11 | "callbacks": [ | ||
| 12 | {"name": "pw_stream_events", "signature": "struct{}", "group": "nonexistent"} | 12 | {"name": "pw_stream_events", "signature": "struct{}", "group": "nonexistent"} | ||
| 13 | ] | 13 | ] | ||
| 14 | - | }⏎̸ | 14 | + | } |
M internal/capi/stream_ops.go +6 -12
| @@ -6,6 +6,8 @@ import ( | |||
| 6 | 6 | "sync" | |
| 7 | 7 | "unsafe" | |
| 8 | 8 | ||
| 9 | + | "github.com/ebitengine/purego" | |
| 10 | + | ||
| 9 | 11 | portout "github.com/bnema/purego-pipewire/internal/ports/out" | |
| 10 | 12 | ) | |
| 11 | 13 | ||
| @@ -43,7 +45,7 @@ const ( | |||
| 43 | 45 | // Without this, the stack-allocated values in CreatePlaybackStream | |
| 44 | 46 | // would be collected while PipeWire still holds pointers to them. | |
| 45 | 47 | type streamCallbackStorage struct { | |
| 46 | - | processCb func(unsafe.Pointer) | |
| 48 | + | processCb uintptr | |
| 47 | 49 | events pw_stream_events | |
| 48 | 50 | } | |
| 49 | 51 | ||
| @@ -78,12 +80,12 @@ func (s *streamOpsImpl) CreatePlaybackStream(loopPtr unsafe.Pointer, name string | |||
| 78 | 80 | ||
| 79 | 81 | // Build persistent callback storage that outlives this function. | |
| 80 | 82 | storage := &streamCallbackStorage{} | |
| 81 | - | storage.processCb = func(_ unsafe.Pointer) { | |
| 83 | + | storage.processCb = purego.NewCallback(func(_ unsafe.Pointer) { | |
| 82 | 84 | onProcess() | |
| 83 | - | } | |
| 85 | + | }) | |
| 84 | 86 | storage.events = pw_stream_events{ | |
| 85 | 87 | version: pwVersionStreamEvents, | |
| 86 | - | process: &storage.processCb, | |
| 88 | + | process: storage.processCb, | |
| 87 | 89 | } | |
| 88 | 90 | ||
| 89 | 91 | ptr := pw_stream_new_simple( | |
| @@ -109,14 +111,6 @@ func (s *streamOpsImpl) CreatePlaybackStream(loopPtr unsafe.Pointer, name string | |||
| 109 | 111 | } | |
| 110 | 112 | ||
| 111 | 113 | func (s *streamOpsImpl) ConnectPlaybackStream(streamPtr unsafe.Pointer, format portout.PlaybackFormat) error { | |
| 112 | - | // Validate format before making the C call. | |
| 113 | - | // FramesPerBuffer is not used by buildRawAudioParams but is still required | |
| 114 | - | // for a well-formed playback setup. | |
| 115 | - | if format.SampleRate <= 0 || format.Channels <= 0 || format.FramesPerBuffer <= 0 { | |
| 116 | - | return fmt.Errorf("%w: SampleRate=%d, Channels=%d, FramesPerBuffer=%d", | |
| 117 | - | ErrInvalidPlaybackFormat, format.SampleRate, format.Channels, format.FramesPerBuffer) | |
| 118 | - | } | |
| 119 | - | ||
| 120 | 114 | // Build the SPA POD params that describe the stream's audio format. | |
| 121 | 115 | cp, err := buildRawAudioParams(format) | |
| 122 | 116 | if err != nil { | |
| @@ -6,6 +6,8 @@ import ( | |||||
| 6 | "sync" | 6 | "sync" | ||
| 7 | "unsafe" | 7 | "unsafe" | ||
| 8 | 8 | ||||
| 9 | + | "github.com/ebitengine/purego" | |||
| 10 | + | ||||
| 9 | portout "github.com/bnema/purego-pipewire/internal/ports/out" | 11 | portout "github.com/bnema/purego-pipewire/internal/ports/out" | ||
| 10 | ) | 12 | ) | ||
| 11 | 13 | ||||
| @@ -43,7 +45,7 @@ const ( | |||||
| 43 | // Without this, the stack-allocated values in CreatePlaybackStream | 45 | // Without this, the stack-allocated values in CreatePlaybackStream | ||
| 44 | // would be collected while PipeWire still holds pointers to them. | 46 | // would be collected while PipeWire still holds pointers to them. | ||
| 45 | type streamCallbackStorage struct { | 47 | type streamCallbackStorage struct { | ||
| 46 | - | processCb func(unsafe.Pointer) | 48 | + | processCb uintptr |
| 47 | events pw_stream_events | 49 | events pw_stream_events | ||
| 48 | } | 50 | } | ||
| 49 | 51 | ||||
| @@ -78,12 +80,12 @@ func (s *streamOpsImpl) CreatePlaybackStream(loopPtr unsafe.Pointer, name string | |||||
| 78 | 80 | ||||
| 79 | // Build persistent callback storage that outlives this function. | 81 | // Build persistent callback storage that outlives this function. | ||
| 80 | storage := &streamCallbackStorage{} | 82 | storage := &streamCallbackStorage{} | ||
| 81 | - | storage.processCb = func(_ unsafe.Pointer) { | 83 | + | storage.processCb = purego.NewCallback(func(_ unsafe.Pointer) { |
| 82 | onProcess() | 84 | onProcess() | ||
| 83 | - | } | 85 | + | }) |
| 84 | storage.events = pw_stream_events{ | 86 | storage.events = pw_stream_events{ | ||
| 85 | version: pwVersionStreamEvents, | 87 | version: pwVersionStreamEvents, | ||
| 86 | - | process: &storage.processCb, | 88 | + | process: storage.processCb, |
| 87 | } | 89 | } | ||
| 88 | 90 | ||||
| 89 | ptr := pw_stream_new_simple( | 91 | ptr := pw_stream_new_simple( | ||
| @@ -109,14 +111,6 @@ func (s *streamOpsImpl) CreatePlaybackStream(loopPtr unsafe.Pointer, name string | |||||
| 109 | } | 111 | } | ||
| 110 | 112 | ||||
| 111 | func (s *streamOpsImpl) ConnectPlaybackStream(streamPtr unsafe.Pointer, format portout.PlaybackFormat) error { | 113 | func (s *streamOpsImpl) ConnectPlaybackStream(streamPtr unsafe.Pointer, format portout.PlaybackFormat) error { | ||
| 112 | - | // Validate format before making the C call. | |||
| 113 | - | // FramesPerBuffer is not used by buildRawAudioParams but is still required | |||
| 114 | - | // for a well-formed playback setup. | |||
| 115 | - | if format.SampleRate <= 0 || format.Channels <= 0 || format.FramesPerBuffer <= 0 { | |||
| 116 | - | return fmt.Errorf("%w: SampleRate=%d, Channels=%d, FramesPerBuffer=%d", | |||
| 117 | - | ErrInvalidPlaybackFormat, format.SampleRate, format.Channels, format.FramesPerBuffer) | |||
| 118 | - | } | |||
| 119 | - | ||||
| 120 | // Build the SPA POD params that describe the stream's audio format. | 114 | // Build the SPA POD params that describe the stream's audio format. | ||
| 121 | cp, err := buildRawAudioParams(format) | 115 | cp, err := buildRawAudioParams(format) | ||
| 122 | if err != nil { | 116 | if err != nil { | ||
M internal/capi/stream_ops_test.go +79 -11
| @@ -2,6 +2,8 @@ package capi | |||
| 2 | 2 | ||
| 3 | 3 | import ( | |
| 4 | 4 | "errors" | |
| 5 | + | "reflect" | |
| 6 | + | "strings" | |
| 5 | 7 | "testing" | |
| 6 | 8 | "unsafe" | |
| 7 | 9 | ||
| @@ -44,6 +46,18 @@ func TestDestroyStreamCallsPwStreamDestroy(t *testing.T) { | |||
| 44 | 46 | } | |
| 45 | 47 | } | |
| 46 | 48 | ||
| 49 | + | // TestPWStreamEventsUsesFunctionPointerABI verifies that the generated callback | |
| 50 | + | // fields use raw function-pointer storage, not Go func pointers. | |
| 51 | + | func TestPWStreamEventsUsesFunctionPointerABI(t *testing.T) { | |
| 52 | + | field, ok := reflect.TypeOf(pw_stream_events{}).FieldByName("process") | |
| 53 | + | if !ok { | |
| 54 | + | t.Fatal("pw_stream_events.process field missing") | |
| 55 | + | } | |
| 56 | + | if field.Type.Kind() != reflect.Uintptr { | |
| 57 | + | t.Fatalf("pw_stream_events.process has kind %s, want uintptr", field.Type.Kind()) | |
| 58 | + | } | |
| 59 | + | } | |
| 60 | + | ||
| 47 | 61 | // TestDestroyStreamDoubleDestroySkipsSecondCall verifies idempotency: | |
| 48 | 62 | // a second DestroyStream on the same pointer does NOT call pw_stream_destroy again. | |
| 49 | 63 | func TestDestroyStreamDoubleDestroySkipsSecondCall(t *testing.T) { | |
| @@ -267,32 +281,86 @@ func TestDisconnectStreamReturnsNilOnSuccess(t *testing.T) { | |||
| 267 | 281 | } | |
| 268 | 282 | } | |
| 269 | 283 | ||
| 270 | - | // TestConnectPlaybackStreamRejectsMissingFormat verifies that ConnectPlaybackStream | |
| 271 | - | // rejects a zero-valued PlaybackFormat at the StreamOps layer by returning | |
| 272 | - | // ErrInvalidPlaybackFormat. This is a temporary guard; once SPA params are | |
| 273 | - | // wired, the rejection will also cover missing/invalid SPA param construction. | |
| 274 | - | func TestConnectPlaybackStreamRejectsMissingFormat(t *testing.T) { | |
| 284 | + | // TestConnectPlaybackStreamDelegatesFramesPerBufferValidation verifies that | |
| 285 | + | // ConnectPlaybackStream rejects invalid FramesPerBuffer values via the helper | |
| 286 | + | // and does not reach pw_stream_connect. | |
| 287 | + | func TestConnectPlaybackStreamDelegatesFramesPerBufferValidation(t *testing.T) { | |
| 275 | 288 | origConnect := pw_stream_connect | |
| 276 | 289 | t.Cleanup(func() { pw_stream_connect = origConnect }) | |
| 277 | 290 | ||
| 278 | - | // Even if pw_stream_connect succeeds, a zero PlaybackFormat should be rejected | |
| 279 | - | // at the StreamOps layer before the C call is made. | |
| 291 | + | var called bool | |
| 280 | 292 | pw_stream_connect = func(stream unsafe.Pointer, direction int32, id uint32, flags uint32, ports unsafe.Pointer, n_ports uint32) int32 { | |
| 293 | + | called = true | |
| 281 | 294 | return 0 | |
| 282 | 295 | } | |
| 283 | 296 | ||
| 284 | 297 | ops := &streamOpsImpl{} | |
| 285 | 298 | fakePtr := unsafe.Pointer(uintptr(0x1234)) | |
| 286 | 299 | ||
| 287 | - | // Zero-valued PlaybackFormat must be rejected. | |
| 288 | - | zeroFmt := portout.PlaybackFormat{} | |
| 289 | - | err := ops.ConnectPlaybackStream(fakePtr, zeroFmt) | |
| 300 | + | fmt := portout.PlaybackFormat{SampleRate: 48000, Channels: 2, FramesPerBuffer: 0} | |
| 301 | + | err := ops.ConnectPlaybackStream(fakePtr, fmt) | |
| 290 | 302 | if err == nil { | |
| 291 | - | t.Fatal("expected error for zero-valued PlaybackFormat, got nil") | |
| 303 | + | t.Fatal("expected error for missing FramesPerBuffer, got nil") | |
| 292 | 304 | } | |
| 293 | 305 | if !errors.Is(err, ErrInvalidPlaybackFormat) { | |
| 294 | 306 | t.Fatalf("expected error wrapping ErrInvalidPlaybackFormat, got: %v", err) | |
| 295 | 307 | } | |
| 308 | + | if !strings.Contains(err.Error(), "frames per buffer") { | |
| 309 | + | t.Fatalf("expected helper FramesPerBuffer validation, got: %v", err) | |
| 310 | + | } | |
| 311 | + | if called { | |
| 312 | + | t.Fatal("pw_stream_connect should not be called for invalid FramesPerBuffer") | |
| 313 | + | } | |
| 314 | + | } | |
| 315 | + | ||
| 316 | + | // TestConnectPlaybackStreamDelegatesSampleRateAndChannelsValidation verifies | |
| 317 | + | // that invalid sample rate and channel values are rejected by buildRawAudioParams. | |
| 318 | + | func TestConnectPlaybackStreamDelegatesSampleRateAndChannelsValidation(t *testing.T) { | |
| 319 | + | origConnect := pw_stream_connect | |
| 320 | + | t.Cleanup(func() { pw_stream_connect = origConnect }) | |
| 321 | + | ||
| 322 | + | pw_stream_connect = func(stream unsafe.Pointer, direction int32, id uint32, flags uint32, ports unsafe.Pointer, n_ports uint32) int32 { | |
| 323 | + | t.Fatal("pw_stream_connect should not be called for invalid sample rate or channels") | |
| 324 | + | return 0 | |
| 325 | + | } | |
| 326 | + | ||
| 327 | + | ops := &streamOpsImpl{} | |
| 328 | + | fakePtr := unsafe.Pointer(uintptr(0x1234)) | |
| 329 | + | ||
| 330 | + | tests := []struct { | |
| 331 | + | name string | |
| 332 | + | fmt portout.PlaybackFormat | |
| 333 | + | wantMsg string | |
| 334 | + | }{ | |
| 335 | + | { | |
| 336 | + | name: "zero_sample_rate", | |
| 337 | + | fmt: portout.PlaybackFormat{SampleRate: 0, Channels: 2, FramesPerBuffer: 1024}, | |
| 338 | + | wantMsg: "sample rate must be positive", | |
| 339 | + | }, | |
| 340 | + | { | |
| 341 | + | name: "zero_channels", | |
| 342 | + | fmt: portout.PlaybackFormat{SampleRate: 48000, Channels: 0, FramesPerBuffer: 1024}, | |
| 343 | + | wantMsg: "channels must be positive", | |
| 344 | + | }, | |
| 345 | + | } | |
| 346 | + | ||
| 347 | + | for _, tt := range tests { | |
| 348 | + | t.Run(tt.name, func(t *testing.T) { | |
| 349 | + | err := ops.ConnectPlaybackStream(fakePtr, tt.fmt) | |
| 350 | + | if err == nil { | |
| 351 | + | t.Fatalf("expected error for %s, got nil", tt.name) | |
| 352 | + | } | |
| 353 | + | if !errors.Is(err, ErrInvalidPlaybackFormat) { | |
| 354 | + | t.Fatalf("expected error wrapping ErrInvalidPlaybackFormat, got: %v", err) | |
| 355 | + | } | |
| 356 | + | if !strings.Contains(err.Error(), tt.wantMsg) { | |
| 357 | + | t.Fatalf("expected error to contain %q, got: %v", tt.wantMsg, err) | |
| 358 | + | } | |
| 359 | + | if strings.Contains(err.Error(), "FramesPerBuffer") { | |
| 360 | + | t.Fatalf("expected helper validation to report only sample rate/channels, got: %v", err) | |
| 361 | + | } | |
| 362 | + | }) | |
| 363 | + | } | |
| 296 | 364 | } | |
| 297 | 365 | ||
| 298 | 366 | // TestConnectPlaybackStreamAcceptsValidFormat verifies that ConnectPlaybackStream | |
| @@ -2,6 +2,8 @@ package capi | |||||
| 2 | 2 | ||||
| 3 | import ( | 3 | import ( | ||
| 4 | "errors" | 4 | "errors" | ||
| 5 | + | "reflect" | |||
| 6 | + | "strings" | |||
| 5 | "testing" | 7 | "testing" | ||
| 6 | "unsafe" | 8 | "unsafe" | ||
| 7 | 9 | ||||
| @@ -44,6 +46,18 @@ func TestDestroyStreamCallsPwStreamDestroy(t *testing.T) { | |||||
| 44 | } | 46 | } | ||
| 45 | } | 47 | } | ||
| 46 | 48 | ||||
| 49 | + | // TestPWStreamEventsUsesFunctionPointerABI verifies that the generated callback | |||
| 50 | + | // fields use raw function-pointer storage, not Go func pointers. | |||
| 51 | + | func TestPWStreamEventsUsesFunctionPointerABI(t *testing.T) { | |||
| 52 | + | field, ok := reflect.TypeOf(pw_stream_events{}).FieldByName("process") | |||
| 53 | + | if !ok { | |||
| 54 | + | t.Fatal("pw_stream_events.process field missing") | |||
| 55 | + | } | |||
| 56 | + | if field.Type.Kind() != reflect.Uintptr { | |||
| 57 | + | t.Fatalf("pw_stream_events.process has kind %s, want uintptr", field.Type.Kind()) | |||
| 58 | + | } | |||
| 59 | + | } | |||
| 60 | + | ||||
| 47 | // TestDestroyStreamDoubleDestroySkipsSecondCall verifies idempotency: | 61 | // TestDestroyStreamDoubleDestroySkipsSecondCall verifies idempotency: | ||
| 48 | // a second DestroyStream on the same pointer does NOT call pw_stream_destroy again. | 62 | // a second DestroyStream on the same pointer does NOT call pw_stream_destroy again. | ||
| 49 | func TestDestroyStreamDoubleDestroySkipsSecondCall(t *testing.T) { | 63 | func TestDestroyStreamDoubleDestroySkipsSecondCall(t *testing.T) { | ||
| @@ -267,32 +281,86 @@ func TestDisconnectStreamReturnsNilOnSuccess(t *testing.T) { | |||||
| 267 | } | 281 | } | ||
| 268 | } | 282 | } | ||
| 269 | 283 | ||||
| 270 | - | // TestConnectPlaybackStreamRejectsMissingFormat verifies that ConnectPlaybackStream | 284 | + | // TestConnectPlaybackStreamDelegatesFramesPerBufferValidation verifies that |
| 271 | - | // rejects a zero-valued PlaybackFormat at the StreamOps layer by returning | 285 | + | // ConnectPlaybackStream rejects invalid FramesPerBuffer values via the helper |
| 272 | - | // ErrInvalidPlaybackFormat. This is a temporary guard; once SPA params are | 286 | + | // and does not reach pw_stream_connect. |
| 273 | - | // wired, the rejection will also cover missing/invalid SPA param construction. | 287 | + | func TestConnectPlaybackStreamDelegatesFramesPerBufferValidation(t *testing.T) { |
| 274 | - | func TestConnectPlaybackStreamRejectsMissingFormat(t *testing.T) { | |||
| 275 | origConnect := pw_stream_connect | 288 | origConnect := pw_stream_connect | ||
| 276 | t.Cleanup(func() { pw_stream_connect = origConnect }) | 289 | t.Cleanup(func() { pw_stream_connect = origConnect }) | ||
| 277 | 290 | ||||
| 278 | - | // Even if pw_stream_connect succeeds, a zero PlaybackFormat should be rejected | 291 | + | var called bool |
| 279 | - | // at the StreamOps layer before the C call is made. | |||
| 280 | pw_stream_connect = func(stream unsafe.Pointer, direction int32, id uint32, flags uint32, ports unsafe.Pointer, n_ports uint32) int32 { | 292 | pw_stream_connect = func(stream unsafe.Pointer, direction int32, id uint32, flags uint32, ports unsafe.Pointer, n_ports uint32) int32 { | ||
| 293 | + | called = true | |||
| 281 | return 0 | 294 | return 0 | ||
| 282 | } | 295 | } | ||
| 283 | 296 | ||||
| 284 | ops := &streamOpsImpl{} | 297 | ops := &streamOpsImpl{} | ||
| 285 | fakePtr := unsafe.Pointer(uintptr(0x1234)) | 298 | fakePtr := unsafe.Pointer(uintptr(0x1234)) | ||
| 286 | 299 | ||||
| 287 | - | // Zero-valued PlaybackFormat must be rejected. | 300 | + | fmt := portout.PlaybackFormat{SampleRate: 48000, Channels: 2, FramesPerBuffer: 0} |
| 288 | - | zeroFmt := portout.PlaybackFormat{} | 301 | + | err := ops.ConnectPlaybackStream(fakePtr, fmt) |
| 289 | - | err := ops.ConnectPlaybackStream(fakePtr, zeroFmt) | |||
| 290 | if err == nil { | 302 | if err == nil { | ||
| 291 | - | t.Fatal("expected error for zero-valued PlaybackFormat, got nil") | 303 | + | t.Fatal("expected error for missing FramesPerBuffer, got nil") |
| 292 | } | 304 | } | ||
| 293 | if !errors.Is(err, ErrInvalidPlaybackFormat) { | 305 | if !errors.Is(err, ErrInvalidPlaybackFormat) { | ||
| 294 | t.Fatalf("expected error wrapping ErrInvalidPlaybackFormat, got: %v", err) | 306 | t.Fatalf("expected error wrapping ErrInvalidPlaybackFormat, got: %v", err) | ||
| 295 | } | 307 | } | ||
| 308 | + | if !strings.Contains(err.Error(), "frames per buffer") { | |||
| 309 | + | t.Fatalf("expected helper FramesPerBuffer validation, got: %v", err) | |||
| 310 | + | } | |||
| 311 | + | if called { | |||
| 312 | + | t.Fatal("pw_stream_connect should not be called for invalid FramesPerBuffer") | |||
| 313 | + | } | |||
| 314 | + | } | |||
| 315 | + | ||||
| 316 | + | // TestConnectPlaybackStreamDelegatesSampleRateAndChannelsValidation verifies | |||
| 317 | + | // that invalid sample rate and channel values are rejected by buildRawAudioParams. | |||
| 318 | + | func TestConnectPlaybackStreamDelegatesSampleRateAndChannelsValidation(t *testing.T) { | |||
| 319 | + | origConnect := pw_stream_connect | |||
| 320 | + | t.Cleanup(func() { pw_stream_connect = origConnect }) | |||
| 321 | + | ||||
| 322 | + | pw_stream_connect = func(stream unsafe.Pointer, direction int32, id uint32, flags uint32, ports unsafe.Pointer, n_ports uint32) int32 { | |||
| 323 | + | t.Fatal("pw_stream_connect should not be called for invalid sample rate or channels") | |||
| 324 | + | return 0 | |||
| 325 | + | } | |||
| 326 | + | ||||
| 327 | + | ops := &streamOpsImpl{} | |||
| 328 | + | fakePtr := unsafe.Pointer(uintptr(0x1234)) | |||
| 329 | + | ||||
| 330 | + | tests := []struct { | |||
| 331 | + | name string | |||
| 332 | + | fmt portout.PlaybackFormat | |||
| 333 | + | wantMsg string | |||
| 334 | + | }{ | |||
| 335 | + | { | |||
| 336 | + | name: "zero_sample_rate", | |||
| 337 | + | fmt: portout.PlaybackFormat{SampleRate: 0, Channels: 2, FramesPerBuffer: 1024}, | |||
| 338 | + | wantMsg: "sample rate must be positive", | |||
| 339 | + | }, | |||
| 340 | + | { | |||
| 341 | + | name: "zero_channels", | |||
| 342 | + | fmt: portout.PlaybackFormat{SampleRate: 48000, Channels: 0, FramesPerBuffer: 1024}, | |||
| 343 | + | wantMsg: "channels must be positive", | |||
| 344 | + | }, | |||
| 345 | + | } | |||
| 346 | + | ||||
| 347 | + | for _, tt := range tests { | |||
| 348 | + | t.Run(tt.name, func(t *testing.T) { | |||
| 349 | + | err := ops.ConnectPlaybackStream(fakePtr, tt.fmt) | |||
| 350 | + | if err == nil { | |||
| 351 | + | t.Fatalf("expected error for %s, got nil", tt.name) | |||
| 352 | + | } | |||
| 353 | + | if !errors.Is(err, ErrInvalidPlaybackFormat) { | |||
| 354 | + | t.Fatalf("expected error wrapping ErrInvalidPlaybackFormat, got: %v", err) | |||
| 355 | + | } | |||
| 356 | + | if !strings.Contains(err.Error(), tt.wantMsg) { | |||
| 357 | + | t.Fatalf("expected error to contain %q, got: %v", tt.wantMsg, err) | |||
| 358 | + | } | |||
| 359 | + | if strings.Contains(err.Error(), "FramesPerBuffer") { | |||
| 360 | + | t.Fatalf("expected helper validation to report only sample rate/channels, got: %v", err) | |||
| 361 | + | } | |||
| 362 | + | }) | |||
| 363 | + | } | |||
| 296 | } | 364 | } | ||
| 297 | 365 | ||||
| 298 | // TestConnectPlaybackStreamAcceptsValidFormat verifies that ConnectPlaybackStream | 366 | // TestConnectPlaybackStreamAcceptsValidFormat verifies that ConnectPlaybackStream | ||
M internal/capi/stream_params.go +5 -3
| @@ -10,7 +10,7 @@ import ( | |||
| 10 | 10 | ||
| 11 | 11 | // SPA POD type identifiers (from spa/utils/type.h). | |
| 12 | 12 | const ( | |
| 13 | - | spaTypeNone uint32 = iota + 1 // 1 | |
| 13 | + | spaTypeNone uint32 = iota + 1 // 1; reserved so iota matches SPA type numbering | |
| 14 | 14 | spaTypeBool // 2 | |
| 15 | 15 | spaTypeId // 3 | |
| 16 | 16 | spaTypeInt // 4 | |
| @@ -44,7 +44,6 @@ const ( | |||
| 44 | 44 | ||
| 45 | 45 | // Audio format keys (SPA_FORMAT_START_Audio = 0x10000). | |
| 46 | 46 | spaFormatAudioFormat uint32 = 0x10001 // SPA_FORMAT_AUDIO_format | |
| 47 | - | spaFormatAudioFlags uint32 = 0x10002 // SPA_FORMAT_AUDIO_flags | |
| 48 | 47 | spaFormatAudioRate uint32 = 0x10003 // SPA_FORMAT_AUDIO_rate | |
| 49 | 48 | spaFormatAudioChannels uint32 = 0x10004 // SPA_FORMAT_AUDIO_channels | |
| 50 | 49 | ) | |
| @@ -53,7 +52,6 @@ const ( | |||
| 53 | 52 | const ( | |
| 54 | 53 | spaMediaTypeAudio uint32 = 1 // SPA_MEDIA_TYPE_audio | |
| 55 | 54 | spaMediaSubtypeRaw uint32 = 1 // SPA_MEDIA_SUBTYPE_raw | |
| 56 | - | spaMediaSubtypeDSP uint32 = 2 // SPA_MEDIA_SUBTYPE_dsp | |
| 57 | 55 | ) | |
| 58 | 56 | ||
| 59 | 57 | // SPA audio format identifiers (from spa/param/audio/raw.h). | |
| @@ -79,6 +77,10 @@ func buildRawAudioParams(format portout.PlaybackFormat) (*connectParams, error) | |||
| 79 | 77 | return nil, fmt.Errorf("%w: channels must be positive, got %d", | |
| 80 | 78 | ErrInvalidPlaybackFormat, format.Channels) | |
| 81 | 79 | } | |
| 80 | + | if format.FramesPerBuffer <= 0 { | |
| 81 | + | return nil, fmt.Errorf("%w: frames per buffer must be positive, got %d", | |
| 82 | + | ErrInvalidPlaybackFormat, format.FramesPerBuffer) | |
| 83 | + | } | |
| 82 | 84 | ||
| 83 | 85 | // Layout of an SPA POD Object for EnumFormat with 5 properties: | |
| 84 | 86 | // | |
| @@ -10,7 +10,7 @@ import ( | |||||
| 10 | 10 | ||||
| 11 | // SPA POD type identifiers (from spa/utils/type.h). | 11 | // SPA POD type identifiers (from spa/utils/type.h). | ||
| 12 | const ( | 12 | const ( | ||
| 13 | - | spaTypeNone uint32 = iota + 1 // 1 | 13 | + | spaTypeNone uint32 = iota + 1 // 1; reserved so iota matches SPA type numbering |
| 14 | spaTypeBool // 2 | 14 | spaTypeBool // 2 | ||
| 15 | spaTypeId // 3 | 15 | spaTypeId // 3 | ||
| 16 | spaTypeInt // 4 | 16 | spaTypeInt // 4 | ||
| @@ -44,7 +44,6 @@ const ( | |||||
| 44 | 44 | ||||
| 45 | // Audio format keys (SPA_FORMAT_START_Audio = 0x10000). | 45 | // Audio format keys (SPA_FORMAT_START_Audio = 0x10000). | ||
| 46 | spaFormatAudioFormat uint32 = 0x10001 // SPA_FORMAT_AUDIO_format | 46 | spaFormatAudioFormat uint32 = 0x10001 // SPA_FORMAT_AUDIO_format | ||
| 47 | - | spaFormatAudioFlags uint32 = 0x10002 // SPA_FORMAT_AUDIO_flags | |||
| 48 | spaFormatAudioRate uint32 = 0x10003 // SPA_FORMAT_AUDIO_rate | 47 | spaFormatAudioRate uint32 = 0x10003 // SPA_FORMAT_AUDIO_rate | ||
| 49 | spaFormatAudioChannels uint32 = 0x10004 // SPA_FORMAT_AUDIO_channels | 48 | spaFormatAudioChannels uint32 = 0x10004 // SPA_FORMAT_AUDIO_channels | ||
| 50 | ) | 49 | ) | ||
| @@ -53,7 +52,6 @@ const ( | |||||
| 53 | const ( | 52 | const ( | ||
| 54 | spaMediaTypeAudio uint32 = 1 // SPA_MEDIA_TYPE_audio | 53 | spaMediaTypeAudio uint32 = 1 // SPA_MEDIA_TYPE_audio | ||
| 55 | spaMediaSubtypeRaw uint32 = 1 // SPA_MEDIA_SUBTYPE_raw | 54 | spaMediaSubtypeRaw uint32 = 1 // SPA_MEDIA_SUBTYPE_raw | ||
| 56 | - | spaMediaSubtypeDSP uint32 = 2 // SPA_MEDIA_SUBTYPE_dsp | |||
| 57 | ) | 55 | ) | ||
| 58 | 56 | ||||
| 59 | // SPA audio format identifiers (from spa/param/audio/raw.h). | 57 | // SPA audio format identifiers (from spa/param/audio/raw.h). | ||
| @@ -79,6 +77,10 @@ func buildRawAudioParams(format portout.PlaybackFormat) (*connectParams, error) | |||||
| 79 | return nil, fmt.Errorf("%w: channels must be positive, got %d", | 77 | return nil, fmt.Errorf("%w: channels must be positive, got %d", | ||
| 80 | ErrInvalidPlaybackFormat, format.Channels) | 78 | ErrInvalidPlaybackFormat, format.Channels) | ||
| 81 | } | 79 | } | ||
| 80 | + | if format.FramesPerBuffer <= 0 { | |||
| 81 | + | return nil, fmt.Errorf("%w: frames per buffer must be positive, got %d", | |||
| 82 | + | ErrInvalidPlaybackFormat, format.FramesPerBuffer) | |||
| 83 | + | } | |||
| 82 | 84 | ||||
| 83 | // Layout of an SPA POD Object for EnumFormat with 5 properties: | 85 | // Layout of an SPA POD Object for EnumFormat with 5 properties: | ||
| 84 | // | 86 | // | ||
M internal/capi/stream_params_test.go +13 -3
| @@ -49,10 +49,10 @@ func TestBuildRawAudioParamsProducesOneConnectParam(t *testing.T) { | |||
| 49 | 49 | objType := binary.LittleEndian.Uint32(cp.storage[8:12]) | |
| 50 | 50 | objID := binary.LittleEndian.Uint32(cp.storage[12:16]) | |
| 51 | 51 | ||
| 52 | - | const spaTypeObjectFORMAT uint32 = 0x40003 | |
| 52 | + | const wantSpaTypeObjectFormat uint32 = 0x40003 | |
| 53 | 53 | const spaParamEnumFormat uint32 = 3 | |
| 54 | - | if objType != spaTypeObjectFormat { | |
| 55 | - | t.Fatalf("expected object type SPA_TYPE_OBJECT_Format (0x%x), got 0x%x", spaTypeObjectFormat, objType) | |
| 54 | + | if objType != wantSpaTypeObjectFormat { | |
| 55 | + | t.Fatalf("expected object type SPA_TYPE_OBJECT_Format (0x%x), got 0x%x", wantSpaTypeObjectFormat, objType) | |
| 56 | 56 | } | |
| 57 | 57 | if objID != spaParamEnumFormat { | |
| 58 | 58 | t.Fatalf("expected object id SPA_PARAM_EnumFormat (%d), got %d", spaParamEnumFormat, objID) | |
| @@ -114,6 +114,16 @@ func TestBuildRawAudioParamsRejectsInvalidFormat(t *testing.T) { | |||
| 114 | 114 | fmt: portout.PlaybackFormat{SampleRate: 48000, Channels: -1, FramesPerBuffer: 1024}, | |
| 115 | 115 | errMsg: "channels", | |
| 116 | 116 | }, | |
| 117 | + | { | |
| 118 | + | name: "zero_frames_per_buffer", | |
| 119 | + | fmt: portout.PlaybackFormat{SampleRate: 48000, Channels: 2, FramesPerBuffer: 0}, | |
| 120 | + | errMsg: "frames per buffer", | |
| 121 | + | }, | |
| 122 | + | { | |
| 123 | + | name: "negative_frames_per_buffer", | |
| 124 | + | fmt: portout.PlaybackFormat{SampleRate: 48000, Channels: 2, FramesPerBuffer: -1}, | |
| 125 | + | errMsg: "frames per buffer", | |
| 126 | + | }, | |
| 117 | 127 | } | |
| 118 | 128 | ||
| 119 | 129 | for _, tt := range tests { | |
| @@ -49,10 +49,10 @@ func TestBuildRawAudioParamsProducesOneConnectParam(t *testing.T) { | |||||
| 49 | objType := binary.LittleEndian.Uint32(cp.storage[8:12]) | 49 | objType := binary.LittleEndian.Uint32(cp.storage[8:12]) | ||
| 50 | objID := binary.LittleEndian.Uint32(cp.storage[12:16]) | 50 | objID := binary.LittleEndian.Uint32(cp.storage[12:16]) | ||
| 51 | 51 | ||||
| 52 | - | const spaTypeObjectFORMAT uint32 = 0x40003 | 52 | + | const wantSpaTypeObjectFormat uint32 = 0x40003 |
| 53 | const spaParamEnumFormat uint32 = 3 | 53 | const spaParamEnumFormat uint32 = 3 | ||
| 54 | - | if objType != spaTypeObjectFormat { | 54 | + | if objType != wantSpaTypeObjectFormat { |
| 55 | - | t.Fatalf("expected object type SPA_TYPE_OBJECT_Format (0x%x), got 0x%x", spaTypeObjectFormat, objType) | 55 | + | t.Fatalf("expected object type SPA_TYPE_OBJECT_Format (0x%x), got 0x%x", wantSpaTypeObjectFormat, objType) |
| 56 | } | 56 | } | ||
| 57 | if objID != spaParamEnumFormat { | 57 | if objID != spaParamEnumFormat { | ||
| 58 | t.Fatalf("expected object id SPA_PARAM_EnumFormat (%d), got %d", spaParamEnumFormat, objID) | 58 | t.Fatalf("expected object id SPA_PARAM_EnumFormat (%d), got %d", spaParamEnumFormat, objID) | ||
| @@ -114,6 +114,16 @@ func TestBuildRawAudioParamsRejectsInvalidFormat(t *testing.T) { | |||||
| 114 | fmt: portout.PlaybackFormat{SampleRate: 48000, Channels: -1, FramesPerBuffer: 1024}, | 114 | fmt: portout.PlaybackFormat{SampleRate: 48000, Channels: -1, FramesPerBuffer: 1024}, | ||
| 115 | errMsg: "channels", | 115 | errMsg: "channels", | ||
| 116 | }, | 116 | }, | ||
| 117 | + | { | |||
| 118 | + | name: "zero_frames_per_buffer", | |||
| 119 | + | fmt: portout.PlaybackFormat{SampleRate: 48000, Channels: 2, FramesPerBuffer: 0}, | |||
| 120 | + | errMsg: "frames per buffer", | |||
| 121 | + | }, | |||
| 122 | + | { | |||
| 123 | + | name: "negative_frames_per_buffer", | |||
| 124 | + | fmt: portout.PlaybackFormat{SampleRate: 48000, Channels: 2, FramesPerBuffer: -1}, | |||
| 125 | + | errMsg: "frames per buffer", | |||
| 126 | + | }, | |||
| 117 | } | 127 | } | ||
| 118 | 128 | ||||
| 119 | for _, tt := range tests { | 129 | for _, tt := range tests { | ||
M internal/capi/stream_playback_gen.go +1 -1 generated file: collapsed
M internal/core/player.go +15 -11
| @@ -129,7 +129,7 @@ func (p *player) Start() error { | |||
| 129 | 129 | ||
| 130 | 130 | // Check if we already have PipeWire resources (restart after Stop) | |
| 131 | 131 | p.mu.Lock() | |
| 132 | - | hasResources := p.loopPtr != nil && p.streamPtr != nil | |
| 132 | + | hasResources := p.loopPtr != nil && atomic.LoadPointer((*unsafe.Pointer)(unsafe.Pointer(&p.streamPtr))) != nil | |
| 133 | 133 | p.mu.Unlock() | |
| 134 | 134 | ||
| 135 | 135 | if !hasResources { | |
| @@ -139,9 +139,7 @@ func (p *player) Start() error { | |||
| 139 | 139 | } | |
| 140 | 140 | } else { | |
| 141 | 141 | // Restart — just reactivate existing stream | |
| 142 | - | p.mu.Lock() | |
| 143 | - | streamPtr := p.streamPtr | |
| 144 | - | p.mu.Unlock() | |
| 142 | + | streamPtr := atomic.LoadPointer((*unsafe.Pointer)(unsafe.Pointer(&p.streamPtr))) | |
| 145 | 143 | ||
| 146 | 144 | if err := p.streamOps.SetStreamActive(streamPtr, true); err != nil { | |
| 147 | 145 | // Activation failed — clean up owned resources so stale pointers | |
| @@ -205,7 +203,7 @@ func (p *player) startCreateResourcesAndActivate() error { | |||
| 205 | 203 | // All steps succeeded — store owned resources | |
| 206 | 204 | p.mu.Lock() | |
| 207 | 205 | p.loopPtr = loopPtr | |
| 208 | - | p.streamPtr = streamPtr | |
| 206 | + | atomic.StorePointer((*unsafe.Pointer)(unsafe.Pointer(&p.streamPtr)), streamPtr) | |
| 209 | 207 | p.mu.Unlock() | |
| 210 | 208 | ||
| 211 | 209 | // Start main loop in an internal goroutine | |
| @@ -276,7 +274,7 @@ func (p *player) Close() error { | |||
| 276 | 274 | } | |
| 277 | 275 | } | |
| 278 | 276 | ||
| 279 | - | // Teardown resources (placeholder) | |
| 277 | + | // Teardown the player-owned stream and main-loop resources. | |
| 280 | 278 | p.teardown() | |
| 281 | 279 | ||
| 282 | 280 | // Transition to Closed | |
| @@ -285,8 +283,9 @@ func (p *player) Close() error { | |||
| 285 | 283 | ||
| 286 | 284 | // ensureRuntime ensures the player has a usable StreamOps implementation. | |
| 287 | 285 | // If streamOps is already set (e.g., by tests), it is used as-is. | |
| 288 | - | // Otherwise, PipeWire is registered via capi.Register() and the default | |
| 289 | - | // StreamOps implementation is obtained. | |
| 286 | + | // Otherwise, PipeWire is registered via capi.Register(), the default CAPI | |
| 287 | + | // instance is retrieved, and the shared process-level init guard is used | |
| 288 | + | // before obtaining the default StreamOps implementation. | |
| 290 | 289 | func (p *player) ensureRuntime() error { | |
| 291 | 290 | if p.streamOps != nil { | |
| 292 | 291 | return nil | |
| @@ -294,6 +293,11 @@ func (p *player) ensureRuntime() error { | |||
| 294 | 293 | if err := capi.Register(); err != nil { | |
| 295 | 294 | return fmt.Errorf("register pipewire: %w", err) | |
| 296 | 295 | } | |
| 296 | + | capiInstance := capi.Default() | |
| 297 | + | if capiInstance == nil { | |
| 298 | + | return errors.New("failed to get default CAPI instance") | |
| 299 | + | } | |
| 300 | + | initPipeWire(capiInstance) | |
| 297 | 301 | ops := capi.DefaultStreamOps() | |
| 298 | 302 | if ops == nil { | |
| 299 | 303 | return errors.New("failed to obtain default stream operations") | |
| @@ -312,7 +316,7 @@ func (p *player) setPaused(paused bool) { | |||
| 312 | 316 | func (p *player) deactivateStream() error { | |
| 313 | 317 | p.mu.Lock() | |
| 314 | 318 | streamOps := p.streamOps | |
| 315 | - | streamPtr := p.streamPtr | |
| 319 | + | streamPtr := atomic.LoadPointer((*unsafe.Pointer)(unsafe.Pointer(&p.streamPtr))) | |
| 316 | 320 | p.mu.Unlock() | |
| 317 | 321 | ||
| 318 | 322 | if streamOps == nil || streamPtr == nil { | |
| @@ -328,9 +332,9 @@ func (p *player) deactivateStream() error { | |||
| 328 | 332 | func (p *player) teardown() { | |
| 329 | 333 | p.mu.Lock() | |
| 330 | 334 | streamOps := p.streamOps | |
| 331 | - | streamPtr := p.streamPtr | |
| 335 | + | streamPtr := atomic.LoadPointer((*unsafe.Pointer)(unsafe.Pointer(&p.streamPtr))) | |
| 332 | 336 | loopPtr := p.loopPtr | |
| 333 | - | p.streamPtr = nil | |
| 337 | + | atomic.StorePointer((*unsafe.Pointer)(unsafe.Pointer(&p.streamPtr)), nil) | |
| 334 | 338 | p.loopPtr = nil | |
| 335 | 339 | p.mu.Unlock() | |
| 336 | 340 | ||
| @@ -129,7 +129,7 @@ func (p *player) Start() error { | |||||
| 129 | 129 | ||||
| 130 | // Check if we already have PipeWire resources (restart after Stop) | 130 | // Check if we already have PipeWire resources (restart after Stop) | ||
| 131 | p.mu.Lock() | 131 | p.mu.Lock() | ||
| 132 | - | hasResources := p.loopPtr != nil && p.streamPtr != nil | 132 | + | hasResources := p.loopPtr != nil && atomic.LoadPointer((*unsafe.Pointer)(unsafe.Pointer(&p.streamPtr))) != nil |
| 133 | p.mu.Unlock() | 133 | p.mu.Unlock() | ||
| 134 | 134 | ||||
| 135 | if !hasResources { | 135 | if !hasResources { | ||
| @@ -139,9 +139,7 @@ func (p *player) Start() error { | |||||
| 139 | } | 139 | } | ||
| 140 | } else { | 140 | } else { | ||
| 141 | // Restart — just reactivate existing stream | 141 | // Restart — just reactivate existing stream | ||
| 142 | - | p.mu.Lock() | 142 | + | streamPtr := atomic.LoadPointer((*unsafe.Pointer)(unsafe.Pointer(&p.streamPtr))) |
| 143 | - | streamPtr := p.streamPtr | |||
| 144 | - | p.mu.Unlock() | |||
| 145 | 143 | ||||
| 146 | if err := p.streamOps.SetStreamActive(streamPtr, true); err != nil { | 144 | if err := p.streamOps.SetStreamActive(streamPtr, true); err != nil { | ||
| 147 | // Activation failed — clean up owned resources so stale pointers | 145 | // Activation failed — clean up owned resources so stale pointers | ||
| @@ -205,7 +203,7 @@ func (p *player) startCreateResourcesAndActivate() error { | |||||
| 205 | // All steps succeeded — store owned resources | 203 | // All steps succeeded — store owned resources | ||
| 206 | p.mu.Lock() | 204 | p.mu.Lock() | ||
| 207 | p.loopPtr = loopPtr | 205 | p.loopPtr = loopPtr | ||
| 208 | - | p.streamPtr = streamPtr | 206 | + | atomic.StorePointer((*unsafe.Pointer)(unsafe.Pointer(&p.streamPtr)), streamPtr) |
| 209 | p.mu.Unlock() | 207 | p.mu.Unlock() | ||
| 210 | 208 | ||||
| 211 | // Start main loop in an internal goroutine | 209 | // Start main loop in an internal goroutine | ||
| @@ -276,7 +274,7 @@ func (p *player) Close() error { | |||||
| 276 | } | 274 | } | ||
| 277 | } | 275 | } | ||
| 278 | 276 | ||||
| 279 | - | // Teardown resources (placeholder) | 277 | + | // Teardown the player-owned stream and main-loop resources. |
| 280 | p.teardown() | 278 | p.teardown() | ||
| 281 | 279 | ||||
| 282 | // Transition to Closed | 280 | // Transition to Closed | ||
| @@ -285,8 +283,9 @@ func (p *player) Close() error { | |||||
| 285 | 283 | ||||
| 286 | // ensureRuntime ensures the player has a usable StreamOps implementation. | 284 | // ensureRuntime ensures the player has a usable StreamOps implementation. | ||
| 287 | // If streamOps is already set (e.g., by tests), it is used as-is. | 285 | // If streamOps is already set (e.g., by tests), it is used as-is. | ||
| 288 | - | // Otherwise, PipeWire is registered via capi.Register() and the default | 286 | + | // Otherwise, PipeWire is registered via capi.Register(), the default CAPI |
| 289 | - | // StreamOps implementation is obtained. | 287 | + | // instance is retrieved, and the shared process-level init guard is used |
| 288 | + | // before obtaining the default StreamOps implementation. | |||
| 290 | func (p *player) ensureRuntime() error { | 289 | func (p *player) ensureRuntime() error { | ||
| 291 | if p.streamOps != nil { | 290 | if p.streamOps != nil { | ||
| 292 | return nil | 291 | return nil | ||
| @@ -294,6 +293,11 @@ func (p *player) ensureRuntime() error { | |||||
| 294 | if err := capi.Register(); err != nil { | 293 | if err := capi.Register(); err != nil { | ||
| 295 | return fmt.Errorf("register pipewire: %w", err) | 294 | return fmt.Errorf("register pipewire: %w", err) | ||
| 296 | } | 295 | } | ||
| 296 | + | capiInstance := capi.Default() | |||
| 297 | + | if capiInstance == nil { | |||
| 298 | + | return errors.New("failed to get default CAPI instance") | |||
| 299 | + | } | |||
| 300 | + | initPipeWire(capiInstance) | |||
| 297 | ops := capi.DefaultStreamOps() | 301 | ops := capi.DefaultStreamOps() | ||
| 298 | if ops == nil { | 302 | if ops == nil { | ||
| 299 | return errors.New("failed to obtain default stream operations") | 303 | return errors.New("failed to obtain default stream operations") | ||
| @@ -312,7 +316,7 @@ func (p *player) setPaused(paused bool) { | |||||
| 312 | func (p *player) deactivateStream() error { | 316 | func (p *player) deactivateStream() error { | ||
| 313 | p.mu.Lock() | 317 | p.mu.Lock() | ||
| 314 | streamOps := p.streamOps | 318 | streamOps := p.streamOps | ||
| 315 | - | streamPtr := p.streamPtr | 319 | + | streamPtr := atomic.LoadPointer((*unsafe.Pointer)(unsafe.Pointer(&p.streamPtr))) |
| 316 | p.mu.Unlock() | 320 | p.mu.Unlock() | ||
| 317 | 321 | ||||
| 318 | if streamOps == nil || streamPtr == nil { | 322 | if streamOps == nil || streamPtr == nil { | ||
| @@ -328,9 +332,9 @@ func (p *player) deactivateStream() error { | |||||
| 328 | func (p *player) teardown() { | 332 | func (p *player) teardown() { | ||
| 329 | p.mu.Lock() | 333 | p.mu.Lock() | ||
| 330 | streamOps := p.streamOps | 334 | streamOps := p.streamOps | ||
| 331 | - | streamPtr := p.streamPtr | 335 | + | streamPtr := atomic.LoadPointer((*unsafe.Pointer)(unsafe.Pointer(&p.streamPtr))) |
| 332 | loopPtr := p.loopPtr | 336 | loopPtr := p.loopPtr | ||
| 333 | - | p.streamPtr = nil | 337 | + | atomic.StorePointer((*unsafe.Pointer)(unsafe.Pointer(&p.streamPtr)), nil) |
| 334 | p.loopPtr = nil | 338 | p.loopPtr = nil | ||
| 335 | p.mu.Unlock() | 339 | p.mu.Unlock() | ||
| 336 | 340 | ||||
M internal/core/player_rt.go +20 -14
| @@ -4,6 +4,8 @@ import ( | |||
| 4 | 4 | "errors" | |
| 5 | 5 | "fmt" | |
| 6 | 6 | "io" | |
| 7 | + | "sync/atomic" | |
| 8 | + | "unsafe" | |
| 7 | 9 | ) | |
| 8 | 10 | ||
| 9 | 11 | // UnderrunPolicy determines how to handle underruns | |
| @@ -37,9 +39,7 @@ func (b *PCMBuffer) allocate() { | |||
| 37 | 39 | ||
| 38 | 40 | // PlayerConfig holds configuration for the player | |
| 39 | 41 | type PlayerConfig struct { | |
| 40 | - | // SampleRate is carried here for upcoming runtime format negotiation. | |
| 41 | - | // It is not yet consumed by the core player but will be used when the | |
| 42 | - | // StreamOps layer builds SPA params for PipeWire. | |
| 42 | + | // SampleRate is the playback sample rate used when opening the stream. | |
| 43 | 43 | SampleRate int | |
| 44 | 44 | FramesPerBuffer int | |
| 45 | 45 | Channels int | |
| @@ -161,12 +161,13 @@ func (p *player) fail(err error) { | |||
| 161 | 161 | // queues the buffer back. If any step fails, it routes the error | |
| 162 | 162 | // through p.fail. | |
| 163 | 163 | func (p *player) onProcess() { | |
| 164 | - | p.mu.Lock() | |
| 165 | 164 | ops := p.streamOps | |
| 166 | - | sp := p.streamPtr | |
| 167 | - | p.mu.Unlock() | |
| 165 | + | if ops == nil { | |
| 166 | + | return | |
| 167 | + | } | |
| 168 | 168 | ||
| 169 | - | if ops == nil || sp == nil { | |
| 169 | + | sp := atomic.LoadPointer((*unsafe.Pointer)(unsafe.Pointer(&p.streamPtr))) | |
| 170 | + | if sp == nil { | |
| 170 | 171 | return | |
| 171 | 172 | } | |
| 172 | 173 | ||
| @@ -175,21 +176,26 @@ func (p *player) onProcess() { | |||
| 175 | 176 | return | |
| 176 | 177 | } | |
| 177 | 178 | ||
| 178 | - | view, err := newPWBufferView(bufPtr, p.config.Channels, p.config.FramesPerBuffer) | |
| 179 | + | queueOnError := true | |
| 180 | + | defer func() { | |
| 181 | + | if err := ops.QueueBuffer(sp, bufPtr); err != nil && queueOnError { | |
| 182 | + | p.fail(fmt.Errorf("queue buffer: %w", err)) | |
| 183 | + | } | |
| 184 | + | }() | |
| 185 | + | ||
| 186 | + | frames := p.config.FramesPerBuffer | |
| 187 | + | view, err := newPWBufferView(bufPtr, p.config.Channels, frames) | |
| 179 | 188 | if err != nil { | |
| 189 | + | queueOnError = false | |
| 180 | 190 | p.fail(fmt.Errorf("buffer view: %w", err)) | |
| 181 | 191 | return | |
| 182 | 192 | } | |
| 183 | 193 | ||
| 184 | - | frames, err := p.processPCM(view.PCM()) | |
| 194 | + | frames, err = p.processPCM(view.PCM()) | |
| 185 | 195 | if err != nil { | |
| 186 | - | // processPCM error: do not queue the buffer; let existing | |
| 187 | - | // state/error behavior stand. | |
| 196 | + | queueOnError = false | |
| 188 | 197 | return | |
| 189 | 198 | } | |
| 190 | 199 | ||
| 191 | 200 | view.Commit(frames) | |
| 192 | - | if err := ops.QueueBuffer(sp, bufPtr); err != nil { | |
| 193 | - | p.fail(fmt.Errorf("queue buffer: %w", err)) | |
| 194 | - | } | |
| 195 | 201 | } | |
| @@ -4,6 +4,8 @@ import ( | |||||
| 4 | "errors" | 4 | "errors" | ||
| 5 | "fmt" | 5 | "fmt" | ||
| 6 | "io" | 6 | "io" | ||
| 7 | + | "sync/atomic" | |||
| 8 | + | "unsafe" | |||
| 7 | ) | 9 | ) | ||
| 8 | 10 | ||||
| 9 | // UnderrunPolicy determines how to handle underruns | 11 | // UnderrunPolicy determines how to handle underruns | ||
| @@ -37,9 +39,7 @@ func (b *PCMBuffer) allocate() { | |||||
| 37 | 39 | ||||
| 38 | // PlayerConfig holds configuration for the player | 40 | // PlayerConfig holds configuration for the player | ||
| 39 | type PlayerConfig struct { | 41 | type PlayerConfig struct { | ||
| 40 | - | // SampleRate is carried here for upcoming runtime format negotiation. | 42 | + | // SampleRate is the playback sample rate used when opening the stream. |
| 41 | - | // It is not yet consumed by the core player but will be used when the | |||
| 42 | - | // StreamOps layer builds SPA params for PipeWire. | |||
| 43 | SampleRate int | 43 | SampleRate int | ||
| 44 | FramesPerBuffer int | 44 | FramesPerBuffer int | ||
| 45 | Channels int | 45 | Channels int | ||
| @@ -161,12 +161,13 @@ func (p *player) fail(err error) { | |||||
| 161 | // queues the buffer back. If any step fails, it routes the error | 161 | // queues the buffer back. If any step fails, it routes the error | ||
| 162 | // through p.fail. | 162 | // through p.fail. | ||
| 163 | func (p *player) onProcess() { | 163 | func (p *player) onProcess() { | ||
| 164 | - | p.mu.Lock() | |||
| 165 | ops := p.streamOps | 164 | ops := p.streamOps | ||
| 166 | - | sp := p.streamPtr | 165 | + | if ops == nil { |
| 167 | - | p.mu.Unlock() | 166 | + | return |
| 167 | + | } | |||
| 168 | 168 | ||||
| 169 | - | if ops == nil || sp == nil { | 169 | + | sp := atomic.LoadPointer((*unsafe.Pointer)(unsafe.Pointer(&p.streamPtr))) |
| 170 | + | if sp == nil { | |||
| 170 | return | 171 | return | ||
| 171 | } | 172 | } | ||
| 172 | 173 | ||||
| @@ -175,21 +176,26 @@ func (p *player) onProcess() { | |||||
| 175 | return | 176 | return | ||
| 176 | } | 177 | } | ||
| 177 | 178 | ||||
| 178 | - | view, err := newPWBufferView(bufPtr, p.config.Channels, p.config.FramesPerBuffer) | 179 | + | queueOnError := true |
| 180 | + | defer func() { | |||
| 181 | + | if err := ops.QueueBuffer(sp, bufPtr); err != nil && queueOnError { | |||
| 182 | + | p.fail(fmt.Errorf("queue buffer: %w", err)) | |||
| 183 | + | } | |||
| 184 | + | }() | |||
| 185 | + | ||||
| 186 | + | frames := p.config.FramesPerBuffer | |||
| 187 | + | view, err := newPWBufferView(bufPtr, p.config.Channels, frames) | |||
| 179 | if err != nil { | 188 | if err != nil { | ||
| 189 | + | queueOnError = false | |||
| 180 | p.fail(fmt.Errorf("buffer view: %w", err)) | 190 | p.fail(fmt.Errorf("buffer view: %w", err)) | ||
| 181 | return | 191 | return | ||
| 182 | } | 192 | } | ||
| 183 | 193 | ||||
| 184 | - | frames, err := p.processPCM(view.PCM()) | 194 | + | frames, err = p.processPCM(view.PCM()) |
| 185 | if err != nil { | 195 | if err != nil { | ||
| 186 | - | // processPCM error: do not queue the buffer; let existing | 196 | + | queueOnError = false |
| 187 | - | // state/error behavior stand. | |||
| 188 | return | 197 | return | ||
| 189 | } | 198 | } | ||
| 190 | 199 | ||||
| 191 | view.Commit(frames) | 200 | view.Commit(frames) | ||
| 192 | - | if err := ops.QueueBuffer(sp, bufPtr); err != nil { | |||
| 193 | - | p.fail(fmt.Errorf("queue buffer: %w", err)) | |||
| 194 | - | } | |||
| 195 | } | 201 | } | ||
M internal/core/player_rt_test.go +108 -9
| @@ -747,7 +747,7 @@ func TestProcessCallbackQueueBufferFailsRoutesThroughFail(t *testing.T) { | |||
| 747 | 747 | ||
| 748 | 748 | // TestProcessCallbackBufferViewFailsRoutesThroughFail verifies that when | |
| 749 | 749 | // newPWBufferView fails, onProcess routes the error through p.fail() | |
| 750 | - | // and does not attempt to queue the buffer. | |
| 750 | + | // and still re-queues the buffer. | |
| 751 | 751 | func TestProcessCallbackBufferViewFailsRoutesThroughFail(t *testing.T) { | |
| 752 | 752 | mockOps := mocks.NewMockStreamOps(t) | |
| 753 | 753 | fakeLoop := unsafe.Pointer(uintptr(0xCAFE)) | |
| @@ -796,9 +796,10 @@ func TestProcessCallbackBufferViewFailsRoutesThroughFail(t *testing.T) { | |||
| 796 | 796 | } | |
| 797 | 797 | waitOnMainLoop(t, wg) | |
| 798 | 798 | ||
| 799 | - | // DequeueBuffer returns a buffer that will fail newPWBufferView | |
| 800 | - | // QueueBuffer should NOT be called | |
| 799 | + | // DequeueBuffer returns a buffer that will fail newPWBufferView. | |
| 800 | + | // The buffer should still be re-queued. | |
| 801 | 801 | mockOps.EXPECT().DequeueBuffer(fakeStream).Return(bufPtr) | |
| 802 | + | mockOps.EXPECT().QueueBuffer(fakeStream, bufPtr).Return(nil) | |
| 802 | 803 | ||
| 803 | 804 | p.onProcess() | |
| 804 | 805 | ||
| @@ -816,10 +817,10 @@ func TestProcessCallbackBufferViewFailsRoutesThroughFail(t *testing.T) { | |||
| 816 | 817 | } | |
| 817 | 818 | } | |
| 818 | 819 | ||
| 819 | - | // TestProcessCallbackProcessCMErrorSkipsQueue verifies that when processPCM | |
| 820 | - | // returns an error, onProcess does not queue the buffer and lets the existing | |
| 820 | + | // TestProcessCallbackProcessCMErrorRequeuesBuffer verifies that when processPCM | |
| 821 | + | // returns an error, onProcess still re-queues the buffer and lets the existing | |
| 821 | 822 | // error behavior stand. | |
| 822 | - | func TestProcessCallbackProcessCMErrorSkipsQueue(t *testing.T) { | |
| 823 | + | func TestProcessCallbackProcessCMErrorRequeuesBuffer(t *testing.T) { | |
| 823 | 824 | mockOps := mocks.NewMockStreamOps(t) | |
| 824 | 825 | fakeLoop := unsafe.Pointer(uintptr(0xCAFE)) | |
| 825 | 826 | fakeStream := unsafe.Pointer(uintptr(0xBEEF)) | |
| @@ -880,10 +881,10 @@ func TestProcessCallbackProcessCMErrorSkipsQueue(t *testing.T) { | |||
| 880 | 881 | } | |
| 881 | 882 | waitOnMainLoop(t, wg) | |
| 882 | 883 | ||
| 883 | - | // DequeueBuffer returns a buffer; QueueBuffer should NOT be called | |
| 884 | - | // because processPCM (via Fill) returns an error | |
| 884 | + | // DequeueBuffer returns a buffer; the buffer should still be re-queued | |
| 885 | + | // even though processPCM (via Fill) returns an error. | |
| 885 | 886 | mockOps.EXPECT().DequeueBuffer(fakeStream).Return(bufPtr) | |
| 886 | - | // Note: no QueueBuffer expectation | |
| 887 | + | mockOps.EXPECT().QueueBuffer(fakeStream, bufPtr).Return(nil) | |
| 887 | 888 | ||
| 888 | 889 | p.onProcess() | |
| 889 | 890 | ||
| @@ -1049,3 +1050,101 @@ func TestPlayerStartFailedCreateStreamDestroysLoop(t *testing.T) { | |||
| 1049 | 1050 | t.Error("expected streamPtr to be nil after failed start") | |
| 1050 | 1051 | } | |
| 1051 | 1052 | } | |
| 1053 | + | ||
| 1054 | + | // TestProcessCallbackUsesConfigFramesPerBuffer verifies that onProcess uses | |
| 1055 | + | // config.FramesPerBuffer even when pwBuffer.Requested is smaller. | |
| 1056 | + | func TestProcessCallbackUsesConfigFramesPerBuffer(t *testing.T) { | |
| 1057 | + | mockOps := mocks.NewMockStreamOps(t) | |
| 1058 | + | fakeLoop := unsafe.Pointer(uintptr(0xCAFE)) | |
| 1059 | + | fakeStream := unsafe.Pointer(uintptr(0xBEEF)) | |
| 1060 | + | ||
| 1061 | + | // Config requests 1024 frames per buffer, but PipeWire will request | |
| 1062 | + | // fewer (941) due to rate conversion. | |
| 1063 | + | const configFrames = 1024 | |
| 1064 | + | const requestedFrames = 941 | |
| 1065 | + | const channels = 2 | |
| 1066 | + | ||
| 1067 | + | cfg := PlayerConfig{ | |
| 1068 | + | SampleRate: 48000, | |
| 1069 | + | Channels: channels, | |
| 1070 | + | FramesPerBuffer: configFrames, | |
| 1071 | + | } | |
| 1072 | + | ||
| 1073 | + | // Build a real pwBuffer with backing storage for the full configFrames | |
| 1074 | + | // so that a view up to configFrames would succeed — but set | |
| 1075 | + | // pwBuf.Requested smaller to ensure onProcess still uses configFrames. | |
| 1076 | + | channelData := make([][]float32, channels) | |
| 1077 | + | floatPtrs := make([]unsafe.Pointer, channels) | |
| 1078 | + | for ch := 0; ch < channels; ch++ { | |
| 1079 | + | channelData[ch] = make([]float32, configFrames) | |
| 1080 | + | floatPtrs[ch] = unsafe.Pointer(&channelData[ch][0]) | |
| 1081 | + | } | |
| 1082 | + | chunkSlice := make([]spaChunk, channels) | |
| 1083 | + | dataSlice := make([]spaData, channels) | |
| 1084 | + | for ch := 0; ch < channels; ch++ { | |
| 1085 | + | dataSlice[ch] = spaData{ | |
| 1086 | + | Type: spaDataTypeMemPtr, | |
| 1087 | + | Flags: spaDataFlagRW, | |
| 1088 | + | Maxsize: uint32(configFrames * 4), | |
| 1089 | + | Data: floatPtrs[ch], | |
| 1090 | + | Chunk: &chunkSlice[ch], | |
| 1091 | + | } | |
| 1092 | + | } | |
| 1093 | + | spaBuf := spaBuffer{ | |
| 1094 | + | NDatas: uint32(channels), | |
| 1095 | + | Datas: unsafe.Pointer(&dataSlice[0]), | |
| 1096 | + | } | |
| 1097 | + | pwBuf := pwBuffer{ | |
| 1098 | + | Buffer: &spaBuf, | |
| 1099 | + | Requested: uint64(requestedFrames), // PipeWire asks for 941, not 1024 | |
| 1100 | + | } | |
| 1101 | + | bufPtr := unsafe.Pointer(&pwBuf) | |
| 1102 | + | ||
| 1103 | + | wg := expectStartWithSync(mockOps, fakeLoop, fakeStream, cfg) | |
| 1104 | + | mockOps.EXPECT().DequeueBuffer(fakeStream).Return(bufPtr) | |
| 1105 | + | mockOps.EXPECT().QueueBuffer(fakeStream, bufPtr).Return(nil) | |
| 1106 | + | ||
| 1107 | + | var receivedFrames int | |
| 1108 | + | p := newPlayer(cfg, PlayerCallbacks{ | |
| 1109 | + | Fill: func(buf *PCMBuffer) (int, error) { | |
| 1110 | + | receivedFrames = buf.Frames | |
| 1111 | + | // Write data into the buffer to verify propagation | |
| 1112 | + | for ch := 0; ch < buf.Channels; ch++ { | |
| 1113 | + | for f := 0; f < buf.Frames; f++ { | |
| 1114 | + | buf.Samples[ch][f] = float32(f) | |
| 1115 | + | } | |
| 1116 | + | } | |
| 1117 | + | return buf.Frames, nil | |
| 1118 | + | }, | |
| 1119 | + | }) | |
| 1120 | + | p.streamOps = mockOps | |
| 1121 | + | ||
| 1122 | + | if err := p.Start(); err != nil { | |
| 1123 | + | t.Fatalf("Start failed: %v", err) | |
| 1124 | + | } | |
| 1125 | + | ||
| 1126 | + | p.onProcess() | |
| 1127 | + | ||
| 1128 | + | // Fill must have been called with the configured frame count. | |
| 1129 | + | if receivedFrames != configFrames { | |
| 1130 | + | t.Errorf("Fill received %d frames, want %d (config.FramesPerBuffer)", receivedFrames, configFrames) | |
| 1131 | + | } | |
| 1132 | + | ||
| 1133 | + | // Commit must set chunk sizes and pwBuf.Size to reflect configFrames. | |
| 1134 | + | for ch := 0; ch < channels; ch++ { | |
| 1135 | + | wantChunkSize := uint32(configFrames * 4) | |
| 1136 | + | if chunkSlice[ch].Size != wantChunkSize { | |
| 1137 | + | t.Errorf("chunk[%d].Size = %d, want %d (configFrames*4)", ch, chunkSlice[ch].Size, wantChunkSize) | |
| 1138 | + | } | |
| 1139 | + | if chunkSlice[ch].Stride != 4 { | |
| 1140 | + | t.Errorf("chunk[%d].Stride = %d, want 4", ch, chunkSlice[ch].Stride) | |
| 1141 | + | } | |
| 1142 | + | } | |
| 1143 | + | ||
| 1144 | + | if pwBuf.Size != uint64(configFrames) { | |
| 1145 | + | t.Errorf("pwBuf.Size = %d, want %d (configFrames)", pwBuf.Size, configFrames) | |
| 1146 | + | } | |
| 1147 | + | ||
| 1148 | + | _ = channelData // prevent escape | |
| 1149 | + | waitOnMainLoop(t, wg) | |
| 1150 | + | } | |
| @@ -747,7 +747,7 @@ func TestProcessCallbackQueueBufferFailsRoutesThroughFail(t *testing.T) { | |||||
| 747 | 747 | ||||
| 748 | // TestProcessCallbackBufferViewFailsRoutesThroughFail verifies that when | 748 | // TestProcessCallbackBufferViewFailsRoutesThroughFail verifies that when | ||
| 749 | // newPWBufferView fails, onProcess routes the error through p.fail() | 749 | // newPWBufferView fails, onProcess routes the error through p.fail() | ||
| 750 | - | // and does not attempt to queue the buffer. | 750 | + | // and still re-queues the buffer. |
| 751 | func TestProcessCallbackBufferViewFailsRoutesThroughFail(t *testing.T) { | 751 | func TestProcessCallbackBufferViewFailsRoutesThroughFail(t *testing.T) { | ||
| 752 | mockOps := mocks.NewMockStreamOps(t) | 752 | mockOps := mocks.NewMockStreamOps(t) | ||
| 753 | fakeLoop := unsafe.Pointer(uintptr(0xCAFE)) | 753 | fakeLoop := unsafe.Pointer(uintptr(0xCAFE)) | ||
| @@ -796,9 +796,10 @@ func TestProcessCallbackBufferViewFailsRoutesThroughFail(t *testing.T) { | |||||
| 796 | } | 796 | } | ||
| 797 | waitOnMainLoop(t, wg) | 797 | waitOnMainLoop(t, wg) | ||
| 798 | 798 | ||||
| 799 | - | // DequeueBuffer returns a buffer that will fail newPWBufferView | 799 | + | // DequeueBuffer returns a buffer that will fail newPWBufferView. |
| 800 | - | // QueueBuffer should NOT be called | 800 | + | // The buffer should still be re-queued. |
| 801 | mockOps.EXPECT().DequeueBuffer(fakeStream).Return(bufPtr) | 801 | mockOps.EXPECT().DequeueBuffer(fakeStream).Return(bufPtr) | ||
| 802 | + | mockOps.EXPECT().QueueBuffer(fakeStream, bufPtr).Return(nil) | |||
| 802 | 803 | ||||
| 803 | p.onProcess() | 804 | p.onProcess() | ||
| 804 | 805 | ||||
| @@ -816,10 +817,10 @@ func TestProcessCallbackBufferViewFailsRoutesThroughFail(t *testing.T) { | |||||
| 816 | } | 817 | } | ||
| 817 | } | 818 | } | ||
| 818 | 819 | ||||
| 819 | - | // TestProcessCallbackProcessCMErrorSkipsQueue verifies that when processPCM | 820 | + | // TestProcessCallbackProcessCMErrorRequeuesBuffer verifies that when processPCM |
| 820 | - | // returns an error, onProcess does not queue the buffer and lets the existing | 821 | + | // returns an error, onProcess still re-queues the buffer and lets the existing |
| 821 | // error behavior stand. | 822 | // error behavior stand. | ||
| 822 | - | func TestProcessCallbackProcessCMErrorSkipsQueue(t *testing.T) { | 823 | + | func TestProcessCallbackProcessCMErrorRequeuesBuffer(t *testing.T) { |
| 823 | mockOps := mocks.NewMockStreamOps(t) | 824 | mockOps := mocks.NewMockStreamOps(t) | ||
| 824 | fakeLoop := unsafe.Pointer(uintptr(0xCAFE)) | 825 | fakeLoop := unsafe.Pointer(uintptr(0xCAFE)) | ||
| 825 | fakeStream := unsafe.Pointer(uintptr(0xBEEF)) | 826 | fakeStream := unsafe.Pointer(uintptr(0xBEEF)) | ||
| @@ -880,10 +881,10 @@ func TestProcessCallbackProcessCMErrorSkipsQueue(t *testing.T) { | |||||
| 880 | } | 881 | } | ||
| 881 | waitOnMainLoop(t, wg) | 882 | waitOnMainLoop(t, wg) | ||
| 882 | 883 | ||||
| 883 | - | // DequeueBuffer returns a buffer; QueueBuffer should NOT be called | 884 | + | // DequeueBuffer returns a buffer; the buffer should still be re-queued |
| 884 | - | // because processPCM (via Fill) returns an error | 885 | + | // even though processPCM (via Fill) returns an error. |
| 885 | mockOps.EXPECT().DequeueBuffer(fakeStream).Return(bufPtr) | 886 | mockOps.EXPECT().DequeueBuffer(fakeStream).Return(bufPtr) | ||
| 886 | - | // Note: no QueueBuffer expectation | 887 | + | mockOps.EXPECT().QueueBuffer(fakeStream, bufPtr).Return(nil) |
| 887 | 888 | ||||
| 888 | p.onProcess() | 889 | p.onProcess() | ||
| 889 | 890 | ||||
| @@ -1049,3 +1050,101 @@ func TestPlayerStartFailedCreateStreamDestroysLoop(t *testing.T) { | |||||
| 1049 | t.Error("expected streamPtr to be nil after failed start") | 1050 | t.Error("expected streamPtr to be nil after failed start") | ||
| 1050 | } | 1051 | } | ||
| 1051 | } | 1052 | } | ||
| 1053 | + | ||||
| 1054 | + | // TestProcessCallbackUsesConfigFramesPerBuffer verifies that onProcess uses | |||
| 1055 | + | // config.FramesPerBuffer even when pwBuffer.Requested is smaller. | |||
| 1056 | + | func TestProcessCallbackUsesConfigFramesPerBuffer(t *testing.T) { | |||
| 1057 | + | mockOps := mocks.NewMockStreamOps(t) | |||
| 1058 | + | fakeLoop := unsafe.Pointer(uintptr(0xCAFE)) | |||
| 1059 | + | fakeStream := unsafe.Pointer(uintptr(0xBEEF)) | |||
| 1060 | + | ||||
| 1061 | + | // Config requests 1024 frames per buffer, but PipeWire will request | |||
| 1062 | + | // fewer (941) due to rate conversion. | |||
| 1063 | + | const configFrames = 1024 | |||
| 1064 | + | const requestedFrames = 941 | |||
| 1065 | + | const channels = 2 | |||
| 1066 | + | ||||
| 1067 | + | cfg := PlayerConfig{ | |||
| 1068 | + | SampleRate: 48000, | |||
| 1069 | + | Channels: channels, | |||
| 1070 | + | FramesPerBuffer: configFrames, | |||
| 1071 | + | } | |||
| 1072 | + | ||||
| 1073 | + | // Build a real pwBuffer with backing storage for the full configFrames | |||
| 1074 | + | // so that a view up to configFrames would succeed — but set | |||
| 1075 | + | // pwBuf.Requested smaller to ensure onProcess still uses configFrames. | |||
| 1076 | + | channelData := make([][]float32, channels) | |||
| 1077 | + | floatPtrs := make([]unsafe.Pointer, channels) | |||
| 1078 | + | for ch := 0; ch < channels; ch++ { | |||
| 1079 | + | channelData[ch] = make([]float32, configFrames) | |||
| 1080 | + | floatPtrs[ch] = unsafe.Pointer(&channelData[ch][0]) | |||
| 1081 | + | } | |||
| 1082 | + | chunkSlice := make([]spaChunk, channels) | |||
| 1083 | + | dataSlice := make([]spaData, channels) | |||
| 1084 | + | for ch := 0; ch < channels; ch++ { | |||
| 1085 | + | dataSlice[ch] = spaData{ | |||
| 1086 | + | Type: spaDataTypeMemPtr, | |||
| 1087 | + | Flags: spaDataFlagRW, | |||
| 1088 | + | Maxsize: uint32(configFrames * 4), | |||
| 1089 | + | Data: floatPtrs[ch], | |||
| 1090 | + | Chunk: &chunkSlice[ch], | |||
| 1091 | + | } | |||
| 1092 | + | } | |||
| 1093 | + | spaBuf := spaBuffer{ | |||
| 1094 | + | NDatas: uint32(channels), | |||
| 1095 | + | Datas: unsafe.Pointer(&dataSlice[0]), | |||
| 1096 | + | } | |||
| 1097 | + | pwBuf := pwBuffer{ | |||
| 1098 | + | Buffer: &spaBuf, | |||
| 1099 | + | Requested: uint64(requestedFrames), // PipeWire asks for 941, not 1024 | |||
| 1100 | + | } | |||
| 1101 | + | bufPtr := unsafe.Pointer(&pwBuf) | |||
| 1102 | + | ||||
| 1103 | + | wg := expectStartWithSync(mockOps, fakeLoop, fakeStream, cfg) | |||
| 1104 | + | mockOps.EXPECT().DequeueBuffer(fakeStream).Return(bufPtr) | |||
| 1105 | + | mockOps.EXPECT().QueueBuffer(fakeStream, bufPtr).Return(nil) | |||
| 1106 | + | ||||
| 1107 | + | var receivedFrames int | |||
| 1108 | + | p := newPlayer(cfg, PlayerCallbacks{ | |||
| 1109 | + | Fill: func(buf *PCMBuffer) (int, error) { | |||
| 1110 | + | receivedFrames = buf.Frames | |||
| 1111 | + | // Write data into the buffer to verify propagation | |||
| 1112 | + | for ch := 0; ch < buf.Channels; ch++ { | |||
| 1113 | + | for f := 0; f < buf.Frames; f++ { | |||
| 1114 | + | buf.Samples[ch][f] = float32(f) | |||
| 1115 | + | } | |||
| 1116 | + | } | |||
| 1117 | + | return buf.Frames, nil | |||
| 1118 | + | }, | |||
| 1119 | + | }) | |||
| 1120 | + | p.streamOps = mockOps | |||
| 1121 | + | ||||
| 1122 | + | if err := p.Start(); err != nil { | |||
| 1123 | + | t.Fatalf("Start failed: %v", err) | |||
| 1124 | + | } | |||
| 1125 | + | ||||
| 1126 | + | p.onProcess() | |||
| 1127 | + | ||||
| 1128 | + | // Fill must have been called with the configured frame count. | |||
| 1129 | + | if receivedFrames != configFrames { | |||
| 1130 | + | t.Errorf("Fill received %d frames, want %d (config.FramesPerBuffer)", receivedFrames, configFrames) | |||
| 1131 | + | } | |||
| 1132 | + | ||||
| 1133 | + | // Commit must set chunk sizes and pwBuf.Size to reflect configFrames. | |||
| 1134 | + | for ch := 0; ch < channels; ch++ { | |||
| 1135 | + | wantChunkSize := uint32(configFrames * 4) | |||
| 1136 | + | if chunkSlice[ch].Size != wantChunkSize { | |||
| 1137 | + | t.Errorf("chunk[%d].Size = %d, want %d (configFrames*4)", ch, chunkSlice[ch].Size, wantChunkSize) | |||
| 1138 | + | } | |||
| 1139 | + | if chunkSlice[ch].Stride != 4 { | |||
| 1140 | + | t.Errorf("chunk[%d].Stride = %d, want 4", ch, chunkSlice[ch].Stride) | |||
| 1141 | + | } | |||
| 1142 | + | } | |||
| 1143 | + | ||||
| 1144 | + | if pwBuf.Size != uint64(configFrames) { | |||
| 1145 | + | t.Errorf("pwBuf.Size = %d, want %d (configFrames)", pwBuf.Size, configFrames) | |||
| 1146 | + | } | |||
| 1147 | + | ||||
| 1148 | + | _ = channelData // prevent escape | |||
| 1149 | + | waitOnMainLoop(t, wg) | |||
| 1150 | + | } | |||
M internal/core/pwbuffer.go +17 -12
| @@ -40,9 +40,11 @@ type spaBuffer struct { | |||
| 40 | 40 | ||
| 41 | 41 | // pwBuffer mirrors struct pw_buffer from <pipewire/stream.h>. | |
| 42 | 42 | type pwBuffer struct { | |
| 43 | - | Buffer *spaBuffer | |
| 44 | - | UserData unsafe.Pointer | |
| 45 | - | Size uint64 | |
| 43 | + | Buffer *spaBuffer | |
| 44 | + | UserData unsafe.Pointer | |
| 45 | + | Size uint64 | |
| 46 | + | Requested uint64 | |
| 47 | + | Time uint64 | |
| 46 | 48 | } | |
| 47 | 49 | ||
| 48 | 50 | // pwBufferView provides a safe Go view over a dequeue'd PipeWire buffer, | |
| @@ -52,6 +54,8 @@ type pwBufferView struct { | |||
| 52 | 54 | buf *pwBuffer | |
| 53 | 55 | channels int | |
| 54 | 56 | frames int | |
| 57 | + | data []spaData | |
| 58 | + | pcm PCMBuffer | |
| 55 | 59 | samples [][]float32 | |
| 56 | 60 | } | |
| 57 | 61 | ||
| @@ -102,7 +106,14 @@ func newPWBufferView(bufPtr unsafe.Pointer, channels, frames int) (*pwBufferView | |||
| 102 | 106 | buf: pw, | |
| 103 | 107 | channels: channels, | |
| 104 | 108 | frames: frames, | |
| 105 | - | samples: samples, | |
| 109 | + | data: dataSlice, | |
| 110 | + | pcm: PCMBuffer{ | |
| 111 | + | Frames: frames, | |
| 112 | + | Channels: channels, | |
| 113 | + | Stride: 4, | |
| 114 | + | Samples: samples, | |
| 115 | + | }, | |
| 116 | + | samples: samples, | |
| 106 | 117 | }, nil | |
| 107 | 118 | } | |
| 108 | 119 | ||
| @@ -110,12 +121,7 @@ func newPWBufferView(bufPtr unsafe.Pointer, channels, frames int) (*pwBufferView | |||
| 110 | 121 | // The caller can write into Samples and then call Commit. | |
| 111 | 122 | // Stride is set to 4 (bytes per float32 sample) for planar float32 layout. | |
| 112 | 123 | func (v *pwBufferView) PCM() *PCMBuffer { | |
| 113 | - | return &PCMBuffer{ | |
| 114 | - | Frames: v.frames, | |
| 115 | - | Channels: v.channels, | |
| 116 | - | Stride: 4, // sizeof(float32) — planar float32 byte stride per channel | |
| 117 | - | Samples: v.samples, | |
| 118 | - | } | |
| 124 | + | return &v.pcm | |
| 119 | 125 | } | |
| 120 | 126 | ||
| 121 | 127 | // Commit updates each data chunk's size and stride to reflect the number of | |
| @@ -123,9 +129,8 @@ func (v *pwBufferView) PCM() *PCMBuffer { | |||
| 123 | 129 | // audio data into the PCM buffer. | |
| 124 | 130 | func (v *pwBufferView) Commit(frames int) { | |
| 125 | 131 | v.buf.Size = uint64(frames) | |
| 126 | - | dataSlice := unsafe.Slice((*spaData)(v.buf.Buffer.Datas), int(v.buf.Buffer.NDatas)) | |
| 127 | 132 | for i := 0; i < v.channels; i++ { | |
| 128 | - | data := &dataSlice[i] | |
| 133 | + | data := &v.data[i] | |
| 129 | 134 | if data.Chunk != nil { | |
| 130 | 135 | data.Chunk.Size = uint32(frames * 4) // float32 = 4 bytes | |
| 131 | 136 | data.Chunk.Stride = 4 // stride for planar float32 | |
| @@ -40,9 +40,11 @@ type spaBuffer struct { | |||||
| 40 | 40 | ||||
| 41 | // pwBuffer mirrors struct pw_buffer from <pipewire/stream.h>. | 41 | // pwBuffer mirrors struct pw_buffer from <pipewire/stream.h>. | ||
| 42 | type pwBuffer struct { | 42 | type pwBuffer struct { | ||
| 43 | - | Buffer *spaBuffer | 43 | + | Buffer *spaBuffer |
| 44 | - | UserData unsafe.Pointer | 44 | + | UserData unsafe.Pointer |
| 45 | - | Size uint64 | 45 | + | Size uint64 |
| 46 | + | Requested uint64 | |||
| 47 | + | Time uint64 | |||
| 46 | } | 48 | } | ||
| 47 | 49 | ||||
| 48 | // pwBufferView provides a safe Go view over a dequeue'd PipeWire buffer, | 50 | // pwBufferView provides a safe Go view over a dequeue'd PipeWire buffer, | ||
| @@ -52,6 +54,8 @@ type pwBufferView struct { | |||||
| 52 | buf *pwBuffer | 54 | buf *pwBuffer | ||
| 53 | channels int | 55 | channels int | ||
| 54 | frames int | 56 | frames int | ||
| 57 | + | data []spaData | |||
| 58 | + | pcm PCMBuffer | |||
| 55 | samples [][]float32 | 59 | samples [][]float32 | ||
| 56 | } | 60 | } | ||
| 57 | 61 | ||||
| @@ -102,7 +106,14 @@ func newPWBufferView(bufPtr unsafe.Pointer, channels, frames int) (*pwBufferView | |||||
| 102 | buf: pw, | 106 | buf: pw, | ||
| 103 | channels: channels, | 107 | channels: channels, | ||
| 104 | frames: frames, | 108 | frames: frames, | ||
| 105 | - | samples: samples, | 109 | + | data: dataSlice, |
| 110 | + | pcm: PCMBuffer{ | |||
| 111 | + | Frames: frames, | |||
| 112 | + | Channels: channels, | |||
| 113 | + | Stride: 4, | |||
| 114 | + | Samples: samples, | |||
| 115 | + | }, | |||
| 116 | + | samples: samples, | |||
| 106 | }, nil | 117 | }, nil | ||
| 107 | } | 118 | } | ||
| 108 | 119 | ||||
| @@ -110,12 +121,7 @@ func newPWBufferView(bufPtr unsafe.Pointer, channels, frames int) (*pwBufferView | |||||
| 110 | // The caller can write into Samples and then call Commit. | 121 | // The caller can write into Samples and then call Commit. | ||
| 111 | // Stride is set to 4 (bytes per float32 sample) for planar float32 layout. | 122 | // Stride is set to 4 (bytes per float32 sample) for planar float32 layout. | ||
| 112 | func (v *pwBufferView) PCM() *PCMBuffer { | 123 | func (v *pwBufferView) PCM() *PCMBuffer { | ||
| 113 | - | return &PCMBuffer{ | 124 | + | return &v.pcm |
| 114 | - | Frames: v.frames, | |||
| 115 | - | Channels: v.channels, | |||
| 116 | - | Stride: 4, // sizeof(float32) — planar float32 byte stride per channel | |||
| 117 | - | Samples: v.samples, | |||
| 118 | - | } | |||
| 119 | } | 125 | } | ||
| 120 | 126 | ||||
| 121 | // Commit updates each data chunk's size and stride to reflect the number of | 127 | // Commit updates each data chunk's size and stride to reflect the number of | ||
| @@ -123,9 +129,8 @@ func (v *pwBufferView) PCM() *PCMBuffer { | |||||
| 123 | // audio data into the PCM buffer. | 129 | // audio data into the PCM buffer. | ||
| 124 | func (v *pwBufferView) Commit(frames int) { | 130 | func (v *pwBufferView) Commit(frames int) { | ||
| 125 | v.buf.Size = uint64(frames) | 131 | v.buf.Size = uint64(frames) | ||
| 126 | - | dataSlice := unsafe.Slice((*spaData)(v.buf.Buffer.Datas), int(v.buf.Buffer.NDatas)) | |||
| 127 | for i := 0; i < v.channels; i++ { | 132 | for i := 0; i < v.channels; i++ { | ||
| 128 | - | data := &dataSlice[i] | 133 | + | data := &v.data[i] |
| 129 | if data.Chunk != nil { | 134 | if data.Chunk != nil { | ||
| 130 | data.Chunk.Size = uint32(frames * 4) // float32 = 4 bytes | 135 | data.Chunk.Size = uint32(frames * 4) // float32 = 4 bytes | ||
| 131 | data.Chunk.Stride = 4 // stride for planar float32 | 136 | data.Chunk.Stride = 4 // stride for planar float32 | ||
M internal/core/runtime.go +61 -6
| @@ -1,27 +1,82 @@ | |||
| 1 | 1 | package core | |
| 2 | 2 | ||
| 3 | 3 | import ( | |
| 4 | + | "fmt" | |
| 4 | 5 | "sync" | |
| 5 | 6 | ||
| 6 | 7 | portout "github.com/bnema/purego-pipewire/internal/ports/out" | |
| 7 | 8 | ) | |
| 8 | 9 | ||
| 9 | 10 | type Runtime struct { | |
| 10 | - | capi portout.CAPI | |
| 11 | - | once sync.Once | |
| 11 | + | mu sync.Mutex | |
| 12 | + | capi portout.CAPI | |
| 13 | + | initialized bool | |
| 12 | 14 | } | |
| 13 | 15 | ||
| 16 | + | var ( | |
| 17 | + | pipewireMu sync.Mutex | |
| 18 | + | pipewireRefCnt int | |
| 19 | + | pipewireCAPI portout.CAPI | |
| 20 | + | ) | |
| 21 | + | ||
| 14 | 22 | func NewRuntime(capi portout.CAPI) *Runtime { | |
| 15 | 23 | return &Runtime{capi: capi} | |
| 16 | 24 | } | |
| 17 | 25 | ||
| 18 | 26 | func (r *Runtime) Init() error { | |
| 19 | - | r.once.Do(func() { | |
| 20 | - | r.capi.PWInit(nil, nil) | |
| 21 | - | }) | |
| 27 | + | r.mu.Lock() | |
| 28 | + | defer r.mu.Unlock() | |
| 29 | + | ||
| 30 | + | if r.initialized { | |
| 31 | + | return nil | |
| 32 | + | } | |
| 33 | + | if err := initPipeWire(r.capi); err != nil { | |
| 34 | + | return err | |
| 35 | + | } | |
| 36 | + | r.initialized = true | |
| 37 | + | return nil | |
| 38 | + | } | |
| 39 | + | ||
| 40 | + | // initPipeWire initializes the process-level PipeWire API once. | |
| 41 | + | func initPipeWire(capi portout.CAPI) error { | |
| 42 | + | pipewireMu.Lock() | |
| 43 | + | defer pipewireMu.Unlock() | |
| 44 | + | ||
| 45 | + | if capi == nil { | |
| 46 | + | return fmt.Errorf("runtime requires a non-nil CAPI implementation") | |
| 47 | + | } | |
| 48 | + | ||
| 49 | + | if pipewireRefCnt > 0 && pipewireCAPI != nil && capi != pipewireCAPI { | |
| 50 | + | return fmt.Errorf("runtime already initialized with a different CAPI implementation") | |
| 51 | + | } | |
| 52 | + | ||
| 53 | + | if pipewireRefCnt == 0 { | |
| 54 | + | pipewireCAPI = capi | |
| 55 | + | capi.PWInit(nil, nil) | |
| 56 | + | } | |
| 57 | + | pipewireRefCnt++ | |
| 22 | 58 | return nil | |
| 23 | 59 | } | |
| 24 | 60 | ||
| 25 | 61 | func (r *Runtime) Deinit() { | |
| 26 | - | r.capi.PWDeinit() | |
| 62 | + | r.mu.Lock() | |
| 63 | + | defer r.mu.Unlock() | |
| 64 | + | ||
| 65 | + | if !r.initialized { | |
| 66 | + | return | |
| 67 | + | } | |
| 68 | + | r.initialized = false | |
| 69 | + | ||
| 70 | + | pipewireMu.Lock() | |
| 71 | + | defer pipewireMu.Unlock() | |
| 72 | + | ||
| 73 | + | if pipewireRefCnt == 0 { | |
| 74 | + | return | |
| 75 | + | } | |
| 76 | + | ||
| 77 | + | pipewireRefCnt-- | |
| 78 | + | if pipewireRefCnt == 0 { | |
| 79 | + | pipewireCAPI.PWDeinit() | |
| 80 | + | pipewireCAPI = nil | |
| 81 | + | } | |
| 27 | 82 | } | |
| @@ -1,27 +1,82 @@ | |||||
| 1 | package core | 1 | package core | ||
| 2 | 2 | ||||
| 3 | import ( | 3 | import ( | ||
| 4 | + | "fmt" | |||
| 4 | "sync" | 5 | "sync" | ||
| 5 | 6 | ||||
| 6 | portout "github.com/bnema/purego-pipewire/internal/ports/out" | 7 | portout "github.com/bnema/purego-pipewire/internal/ports/out" | ||
| 7 | ) | 8 | ) | ||
| 8 | 9 | ||||
| 9 | type Runtime struct { | 10 | type Runtime struct { | ||
| 10 | - | capi portout.CAPI | 11 | + | mu sync.Mutex |
| 11 | - | once sync.Once | 12 | + | capi portout.CAPI |
| 13 | + | initialized bool | |||
| 12 | } | 14 | } | ||
| 13 | 15 | ||||
| 16 | + | var ( | |||
| 17 | + | pipewireMu sync.Mutex | |||
| 18 | + | pipewireRefCnt int | |||
| 19 | + | pipewireCAPI portout.CAPI | |||
| 20 | + | ) | |||
| 21 | + | ||||
| 14 | func NewRuntime(capi portout.CAPI) *Runtime { | 22 | func NewRuntime(capi portout.CAPI) *Runtime { | ||
| 15 | return &Runtime{capi: capi} | 23 | return &Runtime{capi: capi} | ||
| 16 | } | 24 | } | ||
| 17 | 25 | ||||
| 18 | func (r *Runtime) Init() error { | 26 | func (r *Runtime) Init() error { | ||
| 19 | - | r.once.Do(func() { | 27 | + | r.mu.Lock() |
| 20 | - | r.capi.PWInit(nil, nil) | 28 | + | defer r.mu.Unlock() |
| 21 | - | }) | 29 | + | |
| 30 | + | if r.initialized { | |||
| 31 | + | return nil | |||
| 32 | + | } | |||
| 33 | + | if err := initPipeWire(r.capi); err != nil { | |||
| 34 | + | return err | |||
| 35 | + | } | |||
| 36 | + | r.initialized = true | |||
| 37 | + | return nil | |||
| 38 | + | } | |||
| 39 | + | ||||
| 40 | + | // initPipeWire initializes the process-level PipeWire API once. | |||
| 41 | + | func initPipeWire(capi portout.CAPI) error { | |||
| 42 | + | pipewireMu.Lock() | |||
| 43 | + | defer pipewireMu.Unlock() | |||
| 44 | + | ||||
| 45 | + | if capi == nil { | |||
| 46 | + | return fmt.Errorf("runtime requires a non-nil CAPI implementation") | |||
| 47 | + | } | |||
| 48 | + | ||||
| 49 | + | if pipewireRefCnt > 0 && pipewireCAPI != nil && capi != pipewireCAPI { | |||
| 50 | + | return fmt.Errorf("runtime already initialized with a different CAPI implementation") | |||
| 51 | + | } | |||
| 52 | + | ||||
| 53 | + | if pipewireRefCnt == 0 { | |||
| 54 | + | pipewireCAPI = capi | |||
| 55 | + | capi.PWInit(nil, nil) | |||
| 56 | + | } | |||
| 57 | + | pipewireRefCnt++ | |||
| 22 | return nil | 58 | return nil | ||
| 23 | } | 59 | } | ||
| 24 | 60 | ||||
| 25 | func (r *Runtime) Deinit() { | 61 | func (r *Runtime) Deinit() { | ||
| 26 | - | r.capi.PWDeinit() | 62 | + | r.mu.Lock() |
| 63 | + | defer r.mu.Unlock() | |||
| 64 | + | ||||
| 65 | + | if !r.initialized { | |||
| 66 | + | return | |||
| 67 | + | } | |||
| 68 | + | r.initialized = false | |||
| 69 | + | ||||
| 70 | + | pipewireMu.Lock() | |||
| 71 | + | defer pipewireMu.Unlock() | |||
| 72 | + | ||||
| 73 | + | if pipewireRefCnt == 0 { | |||
| 74 | + | return | |||
| 75 | + | } | |||
| 76 | + | ||||
| 77 | + | pipewireRefCnt-- | |||
| 78 | + | if pipewireRefCnt == 0 { | |||
| 79 | + | pipewireCAPI.PWDeinit() | |||
| 80 | + | pipewireCAPI = nil | |||
| 81 | + | } | |||
| 27 | } | 82 | } | ||
M internal/core/runtime_test.go +56 -11
| @@ -6,23 +6,68 @@ import ( | |||
| 6 | 6 | portmocks "github.com/bnema/purego-pipewire/internal/ports/out/mocks" | |
| 7 | 7 | ) | |
| 8 | 8 | ||
| 9 | - | func TestRuntimeInitCallsPWInitOnce(t *testing.T) { | |
| 10 | - | api := portmocks.NewMockCAPI(t) | |
| 11 | - | api.EXPECT().PWInit((*int32)(nil), (***byte)(nil)).Once() | |
| 9 | + | func TestRuntimeInitRejectsDifferentCAPIAfterProcessWideInit(t *testing.T) { | |
| 10 | + | api1 := portmocks.NewMockCAPI(t) | |
| 11 | + | api2 := portmocks.NewMockCAPI(t) | |
| 12 | 12 | ||
| 13 | - | r := NewRuntime(api) | |
| 14 | - | if err := r.Init(); err != nil { | |
| 13 | + | api1.EXPECT().PWDeinit().Once() | |
| 14 | + | api1.EXPECT().PWInit((*int32)(nil), (***byte)(nil)).Once() | |
| 15 | + | ||
| 16 | + | r1 := NewRuntime(api1) | |
| 17 | + | if err := r1.Init(); err != nil { | |
| 15 | 18 | t.Fatalf("Init returned error: %v", err) | |
| 16 | 19 | } | |
| 17 | - | if err := r.Init(); err != nil { | |
| 18 | - | t.Fatalf("second Init returned error: %v", err) | |
| 20 | + | r2 := NewRuntime(api2) | |
| 21 | + | if err := r2.Init(); err == nil { | |
| 22 | + | t.Fatal("second Init returned nil error, want mismatch rejection") | |
| 19 | 23 | } | |
| 24 | + | ||
| 25 | + | r2.Deinit() | |
| 26 | + | r1.Deinit() | |
| 27 | + | ||
| 28 | + | api2.AssertNotCalled(t, "PWInit", (*int32)(nil), (***byte)(nil)) | |
| 29 | + | api2.AssertNotCalled(t, "PWDeinit") | |
| 20 | 30 | } | |
| 21 | 31 | ||
| 22 | - | func TestRuntimeDeinitCallsPWDeinit(t *testing.T) { | |
| 23 | - | api := portmocks.NewMockCAPI(t) | |
| 24 | - | api.EXPECT().PWDeinit().Once() | |
| 32 | + | func TestRuntimeInitRejectsNilCAPI(t *testing.T) { | |
| 33 | + | r := NewRuntime(nil) | |
| 34 | + | if err := r.Init(); err == nil { | |
| 35 | + | t.Fatal("Init returned nil error, want nil CAPI rejection") | |
| 36 | + | } | |
| 25 | 37 | ||
| 26 | - | r := NewRuntime(api) | |
| 27 | 38 | r.Deinit() | |
| 28 | 39 | } | |
| 40 | + | ||
| 41 | + | func TestRuntimeDeinitWaitsForLastInitializedRuntime(t *testing.T) { | |
| 42 | + | api1 := portmocks.NewMockCAPI(t) | |
| 43 | + | ||
| 44 | + | deinitCalls := 0 | |
| 45 | + | api1.EXPECT().PWInit((*int32)(nil), (***byte)(nil)).Once() | |
| 46 | + | api1.EXPECT().PWDeinit().Run(func() { | |
| 47 | + | deinitCalls++ | |
| 48 | + | }).Once() | |
| 49 | + | ||
| 50 | + | r1 := NewRuntime(api1) | |
| 51 | + | if err := r1.Init(); err != nil { | |
| 52 | + | t.Fatalf("first Init returned error: %v", err) | |
| 53 | + | } | |
| 54 | + | r2 := NewRuntime(api1) | |
| 55 | + | if err := r2.Init(); err != nil { | |
| 56 | + | t.Fatalf("second Init returned error: %v", err) | |
| 57 | + | } | |
| 58 | + | ||
| 59 | + | r1.Deinit() | |
| 60 | + | if deinitCalls != 0 { | |
| 61 | + | t.Fatalf("PWDeinit called after first Deinit, want 0 calls before last runtime deinitializes") | |
| 62 | + | } | |
| 63 | + | ||
| 64 | + | NewRuntime(api1).Deinit() | |
| 65 | + | if deinitCalls != 0 { | |
| 66 | + | t.Fatalf("PWDeinit called after non-initialized runtime Deinit, want 0 calls before last runtime deinitializes") | |
| 67 | + | } | |
| 68 | + | ||
| 69 | + | r2.Deinit() | |
| 70 | + | if deinitCalls != 1 { | |
| 71 | + | t.Fatalf("PWDeinit called %d times, want 1", deinitCalls) | |
| 72 | + | } | |
| 73 | + | } | |
| @@ -6,23 +6,68 @@ import ( | |||||
| 6 | portmocks "github.com/bnema/purego-pipewire/internal/ports/out/mocks" | 6 | portmocks "github.com/bnema/purego-pipewire/internal/ports/out/mocks" | ||
| 7 | ) | 7 | ) | ||
| 8 | 8 | ||||
| 9 | - | func TestRuntimeInitCallsPWInitOnce(t *testing.T) { | 9 | + | func TestRuntimeInitRejectsDifferentCAPIAfterProcessWideInit(t *testing.T) { |
| 10 | - | api := portmocks.NewMockCAPI(t) | 10 | + | api1 := portmocks.NewMockCAPI(t) |
| 11 | - | api.EXPECT().PWInit((*int32)(nil), (***byte)(nil)).Once() | 11 | + | api2 := portmocks.NewMockCAPI(t) |
| 12 | 12 | ||||
| 13 | - | r := NewRuntime(api) | 13 | + | api1.EXPECT().PWDeinit().Once() |
| 14 | - | if err := r.Init(); err != nil { | 14 | + | api1.EXPECT().PWInit((*int32)(nil), (***byte)(nil)).Once() |
| 15 | + | ||||
| 16 | + | r1 := NewRuntime(api1) | |||
| 17 | + | if err := r1.Init(); err != nil { | |||
| 15 | t.Fatalf("Init returned error: %v", err) | 18 | t.Fatalf("Init returned error: %v", err) | ||
| 16 | } | 19 | } | ||
| 17 | - | if err := r.Init(); err != nil { | 20 | + | r2 := NewRuntime(api2) |
| 18 | - | t.Fatalf("second Init returned error: %v", err) | 21 | + | if err := r2.Init(); err == nil { |
| 22 | + | t.Fatal("second Init returned nil error, want mismatch rejection") | |||
| 19 | } | 23 | } | ||
| 24 | + | ||||
| 25 | + | r2.Deinit() | |||
| 26 | + | r1.Deinit() | |||
| 27 | + | ||||
| 28 | + | api2.AssertNotCalled(t, "PWInit", (*int32)(nil), (***byte)(nil)) | |||
| 29 | + | api2.AssertNotCalled(t, "PWDeinit") | |||
| 20 | } | 30 | } | ||
| 21 | 31 | ||||
| 22 | - | func TestRuntimeDeinitCallsPWDeinit(t *testing.T) { | 32 | + | func TestRuntimeInitRejectsNilCAPI(t *testing.T) { |
| 23 | - | api := portmocks.NewMockCAPI(t) | 33 | + | r := NewRuntime(nil) |
| 24 | - | api.EXPECT().PWDeinit().Once() | 34 | + | if err := r.Init(); err == nil { |
| 35 | + | t.Fatal("Init returned nil error, want nil CAPI rejection") | |||
| 36 | + | } | |||
| 25 | 37 | ||||
| 26 | - | r := NewRuntime(api) | |||
| 27 | r.Deinit() | 38 | r.Deinit() | ||
| 28 | } | 39 | } | ||
| 40 | + | ||||
| 41 | + | func TestRuntimeDeinitWaitsForLastInitializedRuntime(t *testing.T) { | |||
| 42 | + | api1 := portmocks.NewMockCAPI(t) | |||
| 43 | + | ||||
| 44 | + | deinitCalls := 0 | |||
| 45 | + | api1.EXPECT().PWInit((*int32)(nil), (***byte)(nil)).Once() | |||
| 46 | + | api1.EXPECT().PWDeinit().Run(func() { | |||
| 47 | + | deinitCalls++ | |||
| 48 | + | }).Once() | |||
| 49 | + | ||||
| 50 | + | r1 := NewRuntime(api1) | |||
| 51 | + | if err := r1.Init(); err != nil { | |||
| 52 | + | t.Fatalf("first Init returned error: %v", err) | |||
| 53 | + | } | |||
| 54 | + | r2 := NewRuntime(api1) | |||
| 55 | + | if err := r2.Init(); err != nil { | |||
| 56 | + | t.Fatalf("second Init returned error: %v", err) | |||
| 57 | + | } | |||
| 58 | + | ||||
| 59 | + | r1.Deinit() | |||
| 60 | + | if deinitCalls != 0 { | |||
| 61 | + | t.Fatalf("PWDeinit called after first Deinit, want 0 calls before last runtime deinitializes") | |||
| 62 | + | } | |||
| 63 | + | ||||
| 64 | + | NewRuntime(api1).Deinit() | |||
| 65 | + | if deinitCalls != 0 { | |||
| 66 | + | t.Fatalf("PWDeinit called after non-initialized runtime Deinit, want 0 calls before last runtime deinitializes") | |||
| 67 | + | } | |||
| 68 | + | ||||
| 69 | + | r2.Deinit() | |||
| 70 | + | if deinitCalls != 1 { | |||
| 71 | + | t.Fatalf("PWDeinit called %d times, want 1", deinitCalls) | |||
| 72 | + | } | |||
| 73 | + | } | |||
M internal/ports/out/stream_ops.go +4 -8
| @@ -2,11 +2,8 @@ package out | |||
| 2 | 2 | ||
| 3 | 3 | import "unsafe" | |
| 4 | 4 | ||
| 5 | - | // PlaybackFormat describes the audio format for a playback stream. | |
| 6 | - | // The fields are carried through the public API and core config now so the | |
| 7 | - | // plumbing is in place for upcoming runtime format negotiation. The values | |
| 8 | - | // are not yet forwarded to PipeWire; that will happen when SPA param | |
| 9 | - | // building is wired in a subsequent task. | |
| 5 | + | // PlaybackFormat describes the audio format used when connecting a playback | |
| 6 | + | // stream. | |
| 10 | 7 | type PlaybackFormat struct { | |
| 11 | 8 | SampleRate int | |
| 12 | 9 | Channels int | |
| @@ -22,9 +19,8 @@ type StreamOps interface { | |||
| 22 | 19 | // stream needs more audio data. Returns the stream pointer or an error. | |
| 23 | 20 | CreatePlaybackStream(loopPtr unsafe.Pointer, name string, onProcess func()) (streamPtr unsafe.Pointer, err error) | |
| 24 | 21 | ||
| 25 | - | // ConnectPlaybackStream connects the stream for playback output. | |
| 26 | - | // The format is validated but not yet forwarded to PipeWire as SPA params; | |
| 27 | - | // that wiring will happen in a subsequent task. | |
| 22 | + | // ConnectPlaybackStream connects the stream for playback output using the | |
| 23 | + | // supplied format. | |
| 28 | 24 | ConnectPlaybackStream(streamPtr unsafe.Pointer, format PlaybackFormat) error | |
| 29 | 25 | ||
| 30 | 26 | // SetStreamActive activates or deactivates the stream. | |
| @@ -2,11 +2,8 @@ package out | |||||
| 2 | 2 | ||||
| 3 | import "unsafe" | 3 | import "unsafe" | ||
| 4 | 4 | ||||
| 5 | - | // PlaybackFormat describes the audio format for a playback stream. | 5 | + | // PlaybackFormat describes the audio format used when connecting a playback |
| 6 | - | // The fields are carried through the public API and core config now so the | 6 | + | // stream. |
| 7 | - | // plumbing is in place for upcoming runtime format negotiation. The values | |||
| 8 | - | // are not yet forwarded to PipeWire; that will happen when SPA param | |||
| 9 | - | // building is wired in a subsequent task. | |||
| 10 | type PlaybackFormat struct { | 7 | type PlaybackFormat struct { | ||
| 11 | SampleRate int | 8 | SampleRate int | ||
| 12 | Channels int | 9 | Channels int | ||
| @@ -22,9 +19,8 @@ type StreamOps interface { | |||||
| 22 | // stream needs more audio data. Returns the stream pointer or an error. | 19 | // stream needs more audio data. Returns the stream pointer or an error. | ||
| 23 | CreatePlaybackStream(loopPtr unsafe.Pointer, name string, onProcess func()) (streamPtr unsafe.Pointer, err error) | 20 | CreatePlaybackStream(loopPtr unsafe.Pointer, name string, onProcess func()) (streamPtr unsafe.Pointer, err error) | ||
| 24 | 21 | ||||
| 25 | - | // ConnectPlaybackStream connects the stream for playback output. | 22 | + | // ConnectPlaybackStream connects the stream for playback output using the |
| 26 | - | // The format is validated but not yet forwarded to PipeWire as SPA params; | 23 | + | // supplied format. |
| 27 | - | // that wiring will happen in a subsequent task. | |||
| 28 | ConnectPlaybackStream(streamPtr unsafe.Pointer, format PlaybackFormat) error | 24 | ConnectPlaybackStream(streamPtr unsafe.Pointer, format PlaybackFormat) error | ||
| 29 | 25 | ||||
| 30 | // SetStreamActive activates or deactivates the stream. | 26 | // SetStreamActive activates or deactivates the stream. | ||