detached 177250e

feat: add PipeWire playback format and buffer helpers

commit
177250ec0bd310bd3642e8291b22efbd7da1a4d3
author
brice · · 2026-04-10 13:18 UTC
parent
918a3b9
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
4 files · +786 -0
A internal/capi/stream_params.go +170 -0
@@ -0,0 +1,170 @@
1+package capi
2+
3+import (
4+ "encoding/binary"
5+ "fmt"
6+ "unsafe"
7+
8+ portout "github.com/bnema/purego-pipewire/internal/ports/out"
9+)
10+
11+// SPA POD type identifiers (from spa/utils/type.h).
12+const (
13+ spaTypeNone uint32 = iota + 1 // 1
14+ spaTypeBool // 2
15+ spaTypeId // 3
16+ spaTypeInt // 4
17+ spaTypeLong // 5
18+ spaTypeFloat // 6
19+ spaTypeDouble // 7
20+ spaTypeString // 8
21+ spaTypeBytes // 9
22+ spaTypeRectangle // 10
23+ spaTypeFraction // 11
24+ spaTypeBitmap // 12
25+ spaTypeArray // 13
26+ spaTypeStruct // 14
27+ spaTypeObject // 15
28+)
29+
30+// SPA object type identifiers (from spa/utils/type.h).
31+const (
32+ spaTypeObjectFormat uint32 = 0x40003 // SPA_TYPE_OBJECT_Format
33+)
34+
35+// SPA param type identifiers (from spa/param/param.h).
36+const (
37+ spaParamEnumFormat uint32 = 3 // SPA_PARAM_EnumFormat
38+)
39+
40+// SPA format property keys (from spa/param/format.h).
41+const (
42+ spaFormatMediaSubtype uint32 = 2 // SPA_FORMAT_mediaSubtype
43+ spaFormatMediaType uint32 = 1 // SPA_FORMAT_mediaType
44+
45+ // Audio format keys (SPA_FORMAT_START_Audio = 0x10000).
46+ spaFormatAudioFormat uint32 = 0x10001 // SPA_FORMAT_AUDIO_format
47+ spaFormatAudioFlags uint32 = 0x10002 // SPA_FORMAT_AUDIO_flags
48+ spaFormatAudioRate uint32 = 0x10003 // SPA_FORMAT_AUDIO_rate
49+ spaFormatAudioChannels uint32 = 0x10004 // SPA_FORMAT_AUDIO_channels
50+)
51+
52+// SPA media type and subtype identifiers (from spa/param/format.h).
53+const (
54+ spaMediaTypeAudio uint32 = 1 // SPA_MEDIA_TYPE_audio
55+ spaMediaSubtypeRaw uint32 = 1 // SPA_MEDIA_SUBTYPE_raw
56+ spaMediaSubtypeDSP uint32 = 2 // SPA_MEDIA_SUBTYPE_dsp
57+)
58+
59+// SPA audio format identifiers (from spa/param/audio/raw.h).
60+// Planar formats start at SPA_AUDIO_FORMAT_START_Planar = 0x200.
61+const (
62+ spaAudioFormatF32P uint32 = 0x206 // SPA_AUDIO_FORMAT_F32P
63+)
64+
65+// buildRawAudioParams constructs one minimal SPA POD for SPA_PARAM_EnumFormat
66+// describing a raw audio stream with planar float32 sample format. The returned
67+// connectParams holds the storage buffer and the params slice suitable for
68+// passing to pw_stream_connect.
69+//
70+// This is a focused, handwritten builder for the first playable target format.
71+// It is intentionally minimal: no choice PODs, no format negotiation, no
72+// general-purpose SPA param generation.
73+func buildRawAudioParams(format portout.PlaybackFormat) (*connectParams, error) {
74+ if format.SampleRate <= 0 {
75+ return nil, fmt.Errorf("%w: sample rate must be positive, got %d",
76+ ErrInvalidPlaybackFormat, format.SampleRate)
77+ }
78+ if format.Channels <= 0 {
79+ return nil, fmt.Errorf("%w: channels must be positive, got %d",
80+ ErrInvalidPlaybackFormat, format.Channels)
81+ }
82+
83+ // Layout of an SPA POD Object for EnumFormat with 5 properties:
84+ //
85+ // spa_pod header (8 bytes): {size, type}
86+ // object_body body (8 bytes): {objType, id}
87+ // prop[0] mediaType (24 bytes): {key, flags, {4,Id}, value, pad}
88+ // prop[1] mediaSubtype (24 bytes): {key, flags, {4,Id}, value, pad}
89+ // prop[2] audioFormat (24 bytes): {key, flags, {4,Id}, value, pad}
90+ // prop[3] audioRate (24 bytes): {key, flags, {4,Int}, value, pad}
91+ // prop[4] audioChannels (24 bytes): {key, flags, {4,Int}, value, pad}
92+ //
93+ // Total: 8 + 8 + 5×24 = 136 bytes.
94+ // Pod body size (excl. header): 8 + 120 = 128 bytes.
95+
96+ const propSize = 24 // key(4) + flags(4) + pod{4,4}(8) + value(4) + pad(4)
97+ const objBodySize = 8 + 5*propSize // 128
98+ const totalSize = 8 + objBodySize // 136
99+
100+ buf := make([]byte, totalSize)
101+
102+ // POD header
103+ binary.LittleEndian.PutUint32(buf[0:4], objBodySize) // size
104+ binary.LittleEndian.PutUint32(buf[4:8], spaTypeObject) // type
105+
106+ // Object body
107+ binary.LittleEndian.PutUint32(buf[8:12], spaTypeObjectFormat) // objType
108+ binary.LittleEndian.PutUint32(buf[12:16], spaParamEnumFormat) // id
109+
110+ // Properties
111+ off := 16
112+ off += writeIdProp(buf[off:], spaFormatMediaType, spaMediaTypeAudio)
113+ off += writeIdProp(buf[off:], spaFormatMediaSubtype, spaMediaSubtypeRaw)
114+ off += writeIdProp(buf[off:], spaFormatAudioFormat, spaAudioFormatF32P)
115+ off += writeIntProp(buf[off:], spaFormatAudioRate, int32(format.SampleRate))
116+ off += writeIntProp(buf[off:], spaFormatAudioChannels, int32(format.Channels))
117+
118+ cp := &connectParams{
119+ storage: buf,
120+ params: []unsafe.Pointer{unsafe.Pointer(&buf[0])},
121+ }
122+
123+ return cp, nil
124+}
125+
126+// connectParams holds the backing storage and parameter slice for a
127+// pw_stream_connect call. The storage buffer contains the SPA POD data,
128+// and params points into that storage.
129+type connectParams struct {
130+ storage []byte
131+ params []unsafe.Pointer
132+}
133+
134+// Pointer returns a pointer to the first param, suitable for passing as
135+// the params argument to pw_stream_connect.
136+func (cp *connectParams) Pointer() unsafe.Pointer {
137+ if len(cp.params) == 0 {
138+ return nil
139+ }
140+ return cp.params[0]
141+}
142+
143+// Count returns the number of params in the params slice.
144+func (cp *connectParams) Count() uint32 {
145+ return uint32(len(cp.params))
146+}
147+
148+// writeIdProp writes an SPA POD property with an Id value into buf.
149+// Layout: key(4) | flags(4) | pod{4,Id}(8) | value(4) | pad(4) = 24 bytes.
150+func writeIdProp(buf []byte, key uint32, value uint32) int {
151+ binary.LittleEndian.PutUint32(buf[0:4], key)
152+ binary.LittleEndian.PutUint32(buf[4:8], 0) // flags
153+ binary.LittleEndian.PutUint32(buf[8:12], 4) // pod body size
154+ binary.LittleEndian.PutUint32(buf[12:16], spaTypeId)
155+ binary.LittleEndian.PutUint32(buf[16:20], value)
156+ binary.LittleEndian.PutUint32(buf[20:24], 0) // padding
157+ return 24
158+}
159+
160+// writeIntProp writes an SPA POD property with an Int value into buf.
161+// Layout: key(4) | flags(4) | pod{4,Int}(8) | value(4) | pad(4) = 24 bytes.
162+func writeIntProp(buf []byte, key uint32, value int32) int {
163+ binary.LittleEndian.PutUint32(buf[0:4], key)
164+ binary.LittleEndian.PutUint32(buf[4:8], 0) // flags
165+ binary.LittleEndian.PutUint32(buf[8:12], 4) // pod body size
166+ binary.LittleEndian.PutUint32(buf[12:16], spaTypeInt)
167+ binary.LittleEndian.PutUint32(buf[16:20], uint32(value))
168+ binary.LittleEndian.PutUint32(buf[20:24], 0) // padding
169+ return 24
170+}
@@ -0,0 +1,170 @@
1+package capi
2+
3+import (
4+ "encoding/binary"
5+ "fmt"
6+ "unsafe"
7+
8+ portout "github.com/bnema/purego-pipewire/internal/ports/out"
9+)
10+
11+// SPA POD type identifiers (from spa/utils/type.h).
12+const (
13+ spaTypeNone uint32 = iota + 1 // 1
14+ spaTypeBool // 2
15+ spaTypeId // 3
16+ spaTypeInt // 4
17+ spaTypeLong // 5
18+ spaTypeFloat // 6
19+ spaTypeDouble // 7
20+ spaTypeString // 8
21+ spaTypeBytes // 9
22+ spaTypeRectangle // 10
23+ spaTypeFraction // 11
24+ spaTypeBitmap // 12
25+ spaTypeArray // 13
26+ spaTypeStruct // 14
27+ spaTypeObject // 15
28+)
29+
30+// SPA object type identifiers (from spa/utils/type.h).
31+const (
32+ spaTypeObjectFormat uint32 = 0x40003 // SPA_TYPE_OBJECT_Format
33+)
34+
35+// SPA param type identifiers (from spa/param/param.h).
36+const (
37+ spaParamEnumFormat uint32 = 3 // SPA_PARAM_EnumFormat
38+)
39+
40+// SPA format property keys (from spa/param/format.h).
41+const (
42+ spaFormatMediaSubtype uint32 = 2 // SPA_FORMAT_mediaSubtype
43+ spaFormatMediaType uint32 = 1 // SPA_FORMAT_mediaType
44+
45+ // Audio format keys (SPA_FORMAT_START_Audio = 0x10000).
46+ spaFormatAudioFormat uint32 = 0x10001 // SPA_FORMAT_AUDIO_format
47+ spaFormatAudioFlags uint32 = 0x10002 // SPA_FORMAT_AUDIO_flags
48+ spaFormatAudioRate uint32 = 0x10003 // SPA_FORMAT_AUDIO_rate
49+ spaFormatAudioChannels uint32 = 0x10004 // SPA_FORMAT_AUDIO_channels
50+)
51+
52+// SPA media type and subtype identifiers (from spa/param/format.h).
53+const (
54+ spaMediaTypeAudio uint32 = 1 // SPA_MEDIA_TYPE_audio
55+ spaMediaSubtypeRaw uint32 = 1 // SPA_MEDIA_SUBTYPE_raw
56+ spaMediaSubtypeDSP uint32 = 2 // SPA_MEDIA_SUBTYPE_dsp
57+)
58+
59+// SPA audio format identifiers (from spa/param/audio/raw.h).
60+// Planar formats start at SPA_AUDIO_FORMAT_START_Planar = 0x200.
61+const (
62+ spaAudioFormatF32P uint32 = 0x206 // SPA_AUDIO_FORMAT_F32P
63+)
64+
65+// buildRawAudioParams constructs one minimal SPA POD for SPA_PARAM_EnumFormat
66+// describing a raw audio stream with planar float32 sample format. The returned
67+// connectParams holds the storage buffer and the params slice suitable for
68+// passing to pw_stream_connect.
69+//
70+// This is a focused, handwritten builder for the first playable target format.
71+// It is intentionally minimal: no choice PODs, no format negotiation, no
72+// general-purpose SPA param generation.
73+func buildRawAudioParams(format portout.PlaybackFormat) (*connectParams, error) {
74+ if format.SampleRate <= 0 {
75+ return nil, fmt.Errorf("%w: sample rate must be positive, got %d",
76+ ErrInvalidPlaybackFormat, format.SampleRate)
77+ }
78+ if format.Channels <= 0 {
79+ return nil, fmt.Errorf("%w: channels must be positive, got %d",
80+ ErrInvalidPlaybackFormat, format.Channels)
81+ }
82+
83+ // Layout of an SPA POD Object for EnumFormat with 5 properties:
84+ //
85+ // spa_pod header (8 bytes): {size, type}
86+ // object_body body (8 bytes): {objType, id}
87+ // prop[0] mediaType (24 bytes): {key, flags, {4,Id}, value, pad}
88+ // prop[1] mediaSubtype (24 bytes): {key, flags, {4,Id}, value, pad}
89+ // prop[2] audioFormat (24 bytes): {key, flags, {4,Id}, value, pad}
90+ // prop[3] audioRate (24 bytes): {key, flags, {4,Int}, value, pad}
91+ // prop[4] audioChannels (24 bytes): {key, flags, {4,Int}, value, pad}
92+ //
93+ // Total: 8 + 8 + 5×24 = 136 bytes.
94+ // Pod body size (excl. header): 8 + 120 = 128 bytes.
95+
96+ const propSize = 24 // key(4) + flags(4) + pod{4,4}(8) + value(4) + pad(4)
97+ const objBodySize = 8 + 5*propSize // 128
98+ const totalSize = 8 + objBodySize // 136
99+
100+ buf := make([]byte, totalSize)
101+
102+ // POD header
103+ binary.LittleEndian.PutUint32(buf[0:4], objBodySize) // size
104+ binary.LittleEndian.PutUint32(buf[4:8], spaTypeObject) // type
105+
106+ // Object body
107+ binary.LittleEndian.PutUint32(buf[8:12], spaTypeObjectFormat) // objType
108+ binary.LittleEndian.PutUint32(buf[12:16], spaParamEnumFormat) // id
109+
110+ // Properties
111+ off := 16
112+ off += writeIdProp(buf[off:], spaFormatMediaType, spaMediaTypeAudio)
113+ off += writeIdProp(buf[off:], spaFormatMediaSubtype, spaMediaSubtypeRaw)
114+ off += writeIdProp(buf[off:], spaFormatAudioFormat, spaAudioFormatF32P)
115+ off += writeIntProp(buf[off:], spaFormatAudioRate, int32(format.SampleRate))
116+ off += writeIntProp(buf[off:], spaFormatAudioChannels, int32(format.Channels))
117+
118+ cp := &connectParams{
119+ storage: buf,
120+ params: []unsafe.Pointer{unsafe.Pointer(&buf[0])},
121+ }
122+
123+ return cp, nil
124+}
125+
126+// connectParams holds the backing storage and parameter slice for a
127+// pw_stream_connect call. The storage buffer contains the SPA POD data,
128+// and params points into that storage.
129+type connectParams struct {
130+ storage []byte
131+ params []unsafe.Pointer
132+}
133+
134+// Pointer returns a pointer to the first param, suitable for passing as
135+// the params argument to pw_stream_connect.
136+func (cp *connectParams) Pointer() unsafe.Pointer {
137+ if len(cp.params) == 0 {
138+ return nil
139+ }
140+ return cp.params[0]
141+}
142+
143+// Count returns the number of params in the params slice.
144+func (cp *connectParams) Count() uint32 {
145+ return uint32(len(cp.params))
146+}
147+
148+// writeIdProp writes an SPA POD property with an Id value into buf.
149+// Layout: key(4) | flags(4) | pod{4,Id}(8) | value(4) | pad(4) = 24 bytes.
150+func writeIdProp(buf []byte, key uint32, value uint32) int {
151+ binary.LittleEndian.PutUint32(buf[0:4], key)
152+ binary.LittleEndian.PutUint32(buf[4:8], 0) // flags
153+ binary.LittleEndian.PutUint32(buf[8:12], 4) // pod body size
154+ binary.LittleEndian.PutUint32(buf[12:16], spaTypeId)
155+ binary.LittleEndian.PutUint32(buf[16:20], value)
156+ binary.LittleEndian.PutUint32(buf[20:24], 0) // padding
157+ return 24
158+}
159+
160+// writeIntProp writes an SPA POD property with an Int value into buf.
161+// Layout: key(4) | flags(4) | pod{4,Int}(8) | value(4) | pad(4) = 24 bytes.
162+func writeIntProp(buf []byte, key uint32, value int32) int {
163+ binary.LittleEndian.PutUint32(buf[0:4], key)
164+ binary.LittleEndian.PutUint32(buf[4:8], 0) // flags
165+ binary.LittleEndian.PutUint32(buf[8:12], 4) // pod body size
166+ binary.LittleEndian.PutUint32(buf[12:16], spaTypeInt)
167+ binary.LittleEndian.PutUint32(buf[16:20], uint32(value))
168+ binary.LittleEndian.PutUint32(buf[20:24], 0) // padding
169+ return 24
170+}
A internal/capi/stream_params_test.go +235 -0
@@ -0,0 +1,235 @@
1+package capi
2+
3+import (
4+ "encoding/binary"
5+ "errors"
6+ "testing"
7+ "unsafe"
8+
9+ portout "github.com/bnema/purego-pipewire/internal/ports/out"
10+)
11+
12+// TestBuildRawAudioParamsProducesOneConnectParam verifies that buildRawAudioParams
13+// produces exactly one connect param with the correct SPA_PARAM_EnumFormat structure
14+// for a valid PlaybackFormat with planar float32.
15+func TestBuildRawAudioParamsProducesOneConnectParam(t *testing.T) {
16+ fmt := portout.PlaybackFormat{
17+ SampleRate: 48000,
18+ Channels: 2,
19+ FramesPerBuffer: 1024,
20+ }
21+
22+ cp, err := buildRawAudioParams(fmt)
23+ if err != nil {
24+ t.Fatalf("unexpected error: %v", err)
25+ }
26+
27+ if cp.Count() != 1 {
28+ t.Fatalf("expected 1 param, got %d", cp.Count())
29+ }
30+
31+ // The param must be a non-nil pointer.
32+ ptr := cp.Pointer()
33+ if ptr == nil {
34+ t.Fatal("param pointer must not be nil")
35+ }
36+
37+ // Read the POD header from storage to verify structure.
38+ // An SPA POD Object starts with: {uint32 size, uint32 type}
39+ // type must be SPA_TYPE_Object (15).
40+ podSize := binary.LittleEndian.Uint32(cp.storage[0:4])
41+ podType := binary.LittleEndian.Uint32(cp.storage[4:8])
42+
43+ const spaTypeObject uint32 = 15
44+ if podType != spaTypeObject {
45+ t.Fatalf("expected POD type SPA_TYPE_Object (%d), got %d", spaTypeObject, podType)
46+ }
47+
48+ // Immediately after the pod header is object body: {uint32 objType, uint32 id}
49+ objType := binary.LittleEndian.Uint32(cp.storage[8:12])
50+ objID := binary.LittleEndian.Uint32(cp.storage[12:16])
51+
52+ const spaTypeObjectFORMAT uint32 = 0x40003
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)
56+ }
57+ if objID != spaParamEnumFormat {
58+ t.Fatalf("expected object id SPA_PARAM_EnumFormat (%d), got %d", spaParamEnumFormat, objID)
59+ }
60+
61+ // The POD body size must cover all properties.
62+ // Object body (8 bytes) + 5 properties × 24 bytes = 128 bytes
63+ expectedBodySize := uint32(8 + 5*24)
64+ if podSize != expectedBodySize {
65+ t.Errorf("expected POD body size %d, got %d", expectedBodySize, podSize)
66+ }
67+
68+ // Verify the first property: SPA_FORMAT_mediaType → Id(SPA_MEDIA_TYPE_audio)
69+ dissectProperty(t, cp.storage[16:40], "mediaType",
70+ spaFormatMediaType, spaTypeId, uint32(spaMediaTypeAudio))
71+
72+ // Verify the second property: SPA_FORMAT_mediaSubtype → Id(SPA_MEDIA_SUBTYPE_raw)
73+ dissectProperty(t, cp.storage[40:64], "mediaSubtype",
74+ spaFormatMediaSubtype, spaTypeId, uint32(spaMediaSubtypeRaw))
75+
76+ // Verify the third property: SPA_FORMAT_AUDIO_format → Id(SPA_AUDIO_FORMAT_F32P)
77+ dissectProperty(t, cp.storage[64:88], "audio.format",
78+ spaFormatAudioFormat, spaTypeId, spaAudioFormatF32P)
79+
80+ // Verify the fourth property: SPA_FORMAT_AUDIO_rate → Int(sample_rate)
81+ dissectIntProperty(t, cp.storage[88:112], "audio.rate",
82+ spaFormatAudioRate, 48000)
83+
84+ // Verify the fifth property: SPA_FORMAT_AUDIO_channels → Int(channels)
85+ dissectIntProperty(t, cp.storage[112:136], "audio.channels",
86+ spaFormatAudioChannels, 2)
87+}
88+
89+// TestBuildRawAudioParamsRejectsInvalidFormat verifies that buildRawAudioParams
90+// rejects zero or negative format values.
91+func TestBuildRawAudioParamsRejectsInvalidFormat(t *testing.T) {
92+ tests := []struct {
93+ name string
94+ fmt portout.PlaybackFormat
95+ errMsg string
96+ }{
97+ {
98+ name: "zero_sample_rate",
99+ fmt: portout.PlaybackFormat{SampleRate: 0, Channels: 2, FramesPerBuffer: 1024},
100+ errMsg: "sample rate",
101+ },
102+ {
103+ name: "negative_sample_rate",
104+ fmt: portout.PlaybackFormat{SampleRate: -1, Channels: 2, FramesPerBuffer: 1024},
105+ errMsg: "sample rate",
106+ },
107+ {
108+ name: "zero_channels",
109+ fmt: portout.PlaybackFormat{SampleRate: 48000, Channels: 0, FramesPerBuffer: 1024},
110+ errMsg: "channels",
111+ },
112+ {
113+ name: "negative_channels",
114+ fmt: portout.PlaybackFormat{SampleRate: 48000, Channels: -1, FramesPerBuffer: 1024},
115+ errMsg: "channels",
116+ },
117+ }
118+
119+ for _, tt := range tests {
120+ t.Run(tt.name, func(t *testing.T) {
121+ _, err := buildRawAudioParams(tt.fmt)
122+ if err == nil {
123+ t.Fatalf("expected error for %s, got nil", tt.name)
124+ }
125+ if !errors.Is(err, ErrInvalidPlaybackFormat) {
126+ t.Errorf("expected error wrapping ErrInvalidPlaybackFormat, got: %v", err)
127+ }
128+ })
129+ }
130+}
131+
132+// TestBuildRawAudioParamsDifferentRates verifies the builder encodes different
133+// sample rates and channel counts correctly.
134+func TestBuildRawAudioParamsDifferentRates(t *testing.T) {
135+ fmt := portout.PlaybackFormat{
136+ SampleRate: 44100,
137+ Channels: 1,
138+ FramesPerBuffer: 512,
139+ }
140+
141+ cp, err := buildRawAudioParams(fmt)
142+ if err != nil {
143+ t.Fatalf("unexpected error: %v", err)
144+ }
145+
146+ // The fourth property should be Int(44100)
147+ dissectIntProperty(t, cp.storage[88:112], "audio.rate",
148+ spaFormatAudioRate, 44100)
149+
150+ // The fifth property should be Int(1)
151+ dissectIntProperty(t, cp.storage[112:136], "audio.channels",
152+ spaFormatAudioChannels, 1)
153+}
154+
155+// TestConnectParamsHelpers verifies the connectParams helper methods.
156+func TestConnectParamsHelpers(t *testing.T) {
157+ fmt := portout.PlaybackFormat{
158+ SampleRate: 48000,
159+ Channels: 2,
160+ FramesPerBuffer: 1024,
161+ }
162+
163+ cp, err := buildRawAudioParams(fmt)
164+ if err != nil {
165+ t.Fatalf("unexpected error: %v", err)
166+ }
167+
168+ if cp.Count() != 1 {
169+ t.Errorf("Count() = %d, want 1", cp.Count())
170+ }
171+
172+ ptr := cp.Pointer()
173+ if ptr == nil {
174+ t.Error("Pointer() returned nil")
175+ }
176+
177+ // The pointer should point to the start of the storage buffer.
178+ expected := unsafe.Pointer(&cp.storage[0])
179+ if ptr != expected {
180+ t.Errorf("Pointer() = %v, want %v", ptr, expected)
181+ }
182+}
183+
184+// dissectProperty checks an Id-valued property at the given byte slice.
185+func dissectProperty(t *testing.T, prop []byte, label string, wantKey uint32, wantType uint32, wantValue uint32) {
186+ t.Helper()
187+ key := binary.LittleEndian.Uint32(prop[0:4])
188+ flags := binary.LittleEndian.Uint32(prop[4:8])
189+ valPodSize := binary.LittleEndian.Uint32(prop[8:12])
190+ valPodType := binary.LittleEndian.Uint32(prop[12:16])
191+ val := binary.LittleEndian.Uint32(prop[16:20])
192+
193+ if key != wantKey {
194+ t.Errorf("%s: key = 0x%x, want 0x%x", label, key, wantKey)
195+ }
196+ if flags != 0 {
197+ t.Errorf("%s: flags = %d, want 0", label, flags)
198+ }
199+ if valPodSize != 4 {
200+ t.Errorf("%s: value pod size = %d, want 4", label, valPodSize)
201+ }
202+ if valPodType != wantType {
203+ t.Errorf("%s: value pod type = %d, want %d", label, valPodType, wantType)
204+ }
205+ if val != wantValue {
206+ t.Errorf("%s: value = %d, want %d", label, val, wantValue)
207+ }
208+}
209+
210+// dissectIntProperty checks an Int-valued property at the given byte slice.
211+func dissectIntProperty(t *testing.T, prop []byte, label string, wantKey uint32, wantValue int32) {
212+ t.Helper()
213+ key := binary.LittleEndian.Uint32(prop[0:4])
214+ flags := binary.LittleEndian.Uint32(prop[4:8])
215+ valPodSize := binary.LittleEndian.Uint32(prop[8:12])
216+ valPodType := binary.LittleEndian.Uint32(prop[12:16])
217+ val := int32(binary.LittleEndian.Uint32(prop[16:20]))
218+
219+ const spaTypeInt uint32 = 4
220+ if key != wantKey {
221+ t.Errorf("%s: key = 0x%x, want 0x%x", label, key, wantKey)
222+ }
223+ if flags != 0 {
224+ t.Errorf("%s: flags = %d, want 0", label, flags)
225+ }
226+ if valPodSize != 4 {
227+ t.Errorf("%s: value pod size = %d, want 4", label, valPodSize)
228+ }
229+ if valPodType != spaTypeInt {
230+ t.Errorf("%s: value pod type = %d, want %d", label, valPodType, spaTypeInt)
231+ }
232+ if val != wantValue {
233+ t.Errorf("%s: value = %d, want %d", label, val, wantValue)
234+ }
235+}
@@ -0,0 +1,235 @@
1+package capi
2+
3+import (
4+ "encoding/binary"
5+ "errors"
6+ "testing"
7+ "unsafe"
8+
9+ portout "github.com/bnema/purego-pipewire/internal/ports/out"
10+)
11+
12+// TestBuildRawAudioParamsProducesOneConnectParam verifies that buildRawAudioParams
13+// produces exactly one connect param with the correct SPA_PARAM_EnumFormat structure
14+// for a valid PlaybackFormat with planar float32.
15+func TestBuildRawAudioParamsProducesOneConnectParam(t *testing.T) {
16+ fmt := portout.PlaybackFormat{
17+ SampleRate: 48000,
18+ Channels: 2,
19+ FramesPerBuffer: 1024,
20+ }
21+
22+ cp, err := buildRawAudioParams(fmt)
23+ if err != nil {
24+ t.Fatalf("unexpected error: %v", err)
25+ }
26+
27+ if cp.Count() != 1 {
28+ t.Fatalf("expected 1 param, got %d", cp.Count())
29+ }
30+
31+ // The param must be a non-nil pointer.
32+ ptr := cp.Pointer()
33+ if ptr == nil {
34+ t.Fatal("param pointer must not be nil")
35+ }
36+
37+ // Read the POD header from storage to verify structure.
38+ // An SPA POD Object starts with: {uint32 size, uint32 type}
39+ // type must be SPA_TYPE_Object (15).
40+ podSize := binary.LittleEndian.Uint32(cp.storage[0:4])
41+ podType := binary.LittleEndian.Uint32(cp.storage[4:8])
42+
43+ const spaTypeObject uint32 = 15
44+ if podType != spaTypeObject {
45+ t.Fatalf("expected POD type SPA_TYPE_Object (%d), got %d", spaTypeObject, podType)
46+ }
47+
48+ // Immediately after the pod header is object body: {uint32 objType, uint32 id}
49+ objType := binary.LittleEndian.Uint32(cp.storage[8:12])
50+ objID := binary.LittleEndian.Uint32(cp.storage[12:16])
51+
52+ const spaTypeObjectFORMAT uint32 = 0x40003
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)
56+ }
57+ if objID != spaParamEnumFormat {
58+ t.Fatalf("expected object id SPA_PARAM_EnumFormat (%d), got %d", spaParamEnumFormat, objID)
59+ }
60+
61+ // The POD body size must cover all properties.
62+ // Object body (8 bytes) + 5 properties × 24 bytes = 128 bytes
63+ expectedBodySize := uint32(8 + 5*24)
64+ if podSize != expectedBodySize {
65+ t.Errorf("expected POD body size %d, got %d", expectedBodySize, podSize)
66+ }
67+
68+ // Verify the first property: SPA_FORMAT_mediaType → Id(SPA_MEDIA_TYPE_audio)
69+ dissectProperty(t, cp.storage[16:40], "mediaType",
70+ spaFormatMediaType, spaTypeId, uint32(spaMediaTypeAudio))
71+
72+ // Verify the second property: SPA_FORMAT_mediaSubtype → Id(SPA_MEDIA_SUBTYPE_raw)
73+ dissectProperty(t, cp.storage[40:64], "mediaSubtype",
74+ spaFormatMediaSubtype, spaTypeId, uint32(spaMediaSubtypeRaw))
75+
76+ // Verify the third property: SPA_FORMAT_AUDIO_format → Id(SPA_AUDIO_FORMAT_F32P)
77+ dissectProperty(t, cp.storage[64:88], "audio.format",
78+ spaFormatAudioFormat, spaTypeId, spaAudioFormatF32P)
79+
80+ // Verify the fourth property: SPA_FORMAT_AUDIO_rate → Int(sample_rate)
81+ dissectIntProperty(t, cp.storage[88:112], "audio.rate",
82+ spaFormatAudioRate, 48000)
83+
84+ // Verify the fifth property: SPA_FORMAT_AUDIO_channels → Int(channels)
85+ dissectIntProperty(t, cp.storage[112:136], "audio.channels",
86+ spaFormatAudioChannels, 2)
87+}
88+
89+// TestBuildRawAudioParamsRejectsInvalidFormat verifies that buildRawAudioParams
90+// rejects zero or negative format values.
91+func TestBuildRawAudioParamsRejectsInvalidFormat(t *testing.T) {
92+ tests := []struct {
93+ name string
94+ fmt portout.PlaybackFormat
95+ errMsg string
96+ }{
97+ {
98+ name: "zero_sample_rate",
99+ fmt: portout.PlaybackFormat{SampleRate: 0, Channels: 2, FramesPerBuffer: 1024},
100+ errMsg: "sample rate",
101+ },
102+ {
103+ name: "negative_sample_rate",
104+ fmt: portout.PlaybackFormat{SampleRate: -1, Channels: 2, FramesPerBuffer: 1024},
105+ errMsg: "sample rate",
106+ },
107+ {
108+ name: "zero_channels",
109+ fmt: portout.PlaybackFormat{SampleRate: 48000, Channels: 0, FramesPerBuffer: 1024},
110+ errMsg: "channels",
111+ },
112+ {
113+ name: "negative_channels",
114+ fmt: portout.PlaybackFormat{SampleRate: 48000, Channels: -1, FramesPerBuffer: 1024},
115+ errMsg: "channels",
116+ },
117+ }
118+
119+ for _, tt := range tests {
120+ t.Run(tt.name, func(t *testing.T) {
121+ _, err := buildRawAudioParams(tt.fmt)
122+ if err == nil {
123+ t.Fatalf("expected error for %s, got nil", tt.name)
124+ }
125+ if !errors.Is(err, ErrInvalidPlaybackFormat) {
126+ t.Errorf("expected error wrapping ErrInvalidPlaybackFormat, got: %v", err)
127+ }
128+ })
129+ }
130+}
131+
132+// TestBuildRawAudioParamsDifferentRates verifies the builder encodes different
133+// sample rates and channel counts correctly.
134+func TestBuildRawAudioParamsDifferentRates(t *testing.T) {
135+ fmt := portout.PlaybackFormat{
136+ SampleRate: 44100,
137+ Channels: 1,
138+ FramesPerBuffer: 512,
139+ }
140+
141+ cp, err := buildRawAudioParams(fmt)
142+ if err != nil {
143+ t.Fatalf("unexpected error: %v", err)
144+ }
145+
146+ // The fourth property should be Int(44100)
147+ dissectIntProperty(t, cp.storage[88:112], "audio.rate",
148+ spaFormatAudioRate, 44100)
149+
150+ // The fifth property should be Int(1)
151+ dissectIntProperty(t, cp.storage[112:136], "audio.channels",
152+ spaFormatAudioChannels, 1)
153+}
154+
155+// TestConnectParamsHelpers verifies the connectParams helper methods.
156+func TestConnectParamsHelpers(t *testing.T) {
157+ fmt := portout.PlaybackFormat{
158+ SampleRate: 48000,
159+ Channels: 2,
160+ FramesPerBuffer: 1024,
161+ }
162+
163+ cp, err := buildRawAudioParams(fmt)
164+ if err != nil {
165+ t.Fatalf("unexpected error: %v", err)
166+ }
167+
168+ if cp.Count() != 1 {
169+ t.Errorf("Count() = %d, want 1", cp.Count())
170+ }
171+
172+ ptr := cp.Pointer()
173+ if ptr == nil {
174+ t.Error("Pointer() returned nil")
175+ }
176+
177+ // The pointer should point to the start of the storage buffer.
178+ expected := unsafe.Pointer(&cp.storage[0])
179+ if ptr != expected {
180+ t.Errorf("Pointer() = %v, want %v", ptr, expected)
181+ }
182+}
183+
184+// dissectProperty checks an Id-valued property at the given byte slice.
185+func dissectProperty(t *testing.T, prop []byte, label string, wantKey uint32, wantType uint32, wantValue uint32) {
186+ t.Helper()
187+ key := binary.LittleEndian.Uint32(prop[0:4])
188+ flags := binary.LittleEndian.Uint32(prop[4:8])
189+ valPodSize := binary.LittleEndian.Uint32(prop[8:12])
190+ valPodType := binary.LittleEndian.Uint32(prop[12:16])
191+ val := binary.LittleEndian.Uint32(prop[16:20])
192+
193+ if key != wantKey {
194+ t.Errorf("%s: key = 0x%x, want 0x%x", label, key, wantKey)
195+ }
196+ if flags != 0 {
197+ t.Errorf("%s: flags = %d, want 0", label, flags)
198+ }
199+ if valPodSize != 4 {
200+ t.Errorf("%s: value pod size = %d, want 4", label, valPodSize)
201+ }
202+ if valPodType != wantType {
203+ t.Errorf("%s: value pod type = %d, want %d", label, valPodType, wantType)
204+ }
205+ if val != wantValue {
206+ t.Errorf("%s: value = %d, want %d", label, val, wantValue)
207+ }
208+}
209+
210+// dissectIntProperty checks an Int-valued property at the given byte slice.
211+func dissectIntProperty(t *testing.T, prop []byte, label string, wantKey uint32, wantValue int32) {
212+ t.Helper()
213+ key := binary.LittleEndian.Uint32(prop[0:4])
214+ flags := binary.LittleEndian.Uint32(prop[4:8])
215+ valPodSize := binary.LittleEndian.Uint32(prop[8:12])
216+ valPodType := binary.LittleEndian.Uint32(prop[12:16])
217+ val := int32(binary.LittleEndian.Uint32(prop[16:20]))
218+
219+ const spaTypeInt uint32 = 4
220+ if key != wantKey {
221+ t.Errorf("%s: key = 0x%x, want 0x%x", label, key, wantKey)
222+ }
223+ if flags != 0 {
224+ t.Errorf("%s: flags = %d, want 0", label, flags)
225+ }
226+ if valPodSize != 4 {
227+ t.Errorf("%s: value pod size = %d, want 4", label, valPodSize)
228+ }
229+ if valPodType != spaTypeInt {
230+ t.Errorf("%s: value pod type = %d, want %d", label, valPodType, spaTypeInt)
231+ }
232+ if val != wantValue {
233+ t.Errorf("%s: value = %d, want %d", label, val, wantValue)
234+ }
235+}
A internal/core/pwbuffer.go +131 -0
@@ -0,0 +1,131 @@
1+package core
2+
3+import (
4+ "errors"
5+ "unsafe"
6+)
7+
8+// PipeWire buffer layout structs matching the C ABI.
9+// These are low-level mappings that allow interpreting a dequeue'd buffer
10+// pointer as writable planar float32 data. They are intentionally minimal
11+// and local — not a general SPA buffer parser.
12+
13+// spaChunk mirrors struct spa_chunk from <spa/buffer/buffer.h>.
14+type spaChunk struct {
15+ Offset uint32
16+ Size uint32
17+ Stride int32
18+ Flags int32
19+}
20+
21+// spaData mirrors struct spa_data from <spa/buffer/buffer.h>.
22+type spaData struct {
23+ Type uint32
24+ Flags uint32
25+ Fd int64
26+ MapOffset uint32
27+ Maxsize uint32
28+ Data unsafe.Pointer
29+ Chunk *spaChunk
30+}
31+
32+// spaBuffer mirrors struct spa_buffer from <spa/buffer/buffer.h>.
33+type spaBuffer struct {
34+ NMetas uint32
35+ NDatas uint32
36+ Metas unsafe.Pointer // struct spa_meta * — pointer to array of spa_meta
37+ Datas unsafe.Pointer // struct spa_data * — pointer to array of spa_data
38+}
39+
40+// pwBuffer mirrors struct pw_buffer from <pipewire/stream.h>.
41+type pwBuffer struct {
42+ Buffer *spaBuffer
43+ UserData unsafe.Pointer
44+ Size uint64
45+}
46+
47+// pwBufferView provides a safe Go view over a dequeue'd PipeWire buffer,
48+// exposing planar float32 channel data. Call Commit after writing frames
49+// to update chunk size/stride and pw buffer size.
50+type pwBufferView struct {
51+ buf *pwBuffer
52+ channels int
53+ frames int
54+ samples [][]float32
55+}
56+
57+var (
58+ ErrNilBufferPointer = errors.New("nil buffer pointer")
59+ ErrChannelMismatch = errors.New("buffer data count does not match channels")
60+ ErrUnmappedData = errors.New("buffer data pointer is nil")
61+)
62+
63+// newPWBufferView interprets bufPtr as a *pwBuffer and returns a view that
64+// exposes planar float32 samples for each channel. The caller must ensure
65+// bufPtr is a valid pw_buffer pointer returned by pw_stream_dequeue_buffer.
66+// The view holds a pointer directly into the original pwBuffer so that Commit
67+// writes back into the shared memory structure, not a Go copy.
68+func newPWBufferView(bufPtr unsafe.Pointer, channels, frames int) (*pwBufferView, error) {
69+ if bufPtr == nil {
70+ return nil, ErrNilBufferPointer
71+ }
72+
73+ pw := (*pwBuffer)(bufPtr)
74+ if pw.Buffer == nil {
75+ return nil, ErrNilBufferPointer
76+ }
77+
78+ if pw.Buffer.NDatas != uint32(channels) {
79+ return nil, ErrChannelMismatch
80+ }
81+
82+ // Datas is a pointer to a C array of spaData structs.
83+ dataSlice := unsafe.Slice((*spaData)(pw.Buffer.Datas), int(pw.Buffer.NDatas))
84+
85+ samples := make([][]float32, channels)
86+ for i := 0; i < channels; i++ {
87+ data := dataSlice[i]
88+ if data.Data == nil {
89+ return nil, ErrUnmappedData
90+ }
91+ // Slice the raw memory as []float32 for this channel.
92+ // Data.Maxsize gives the total buffer size in bytes for this channel.
93+ nFloats := frames
94+ if maxSize := int(data.Maxsize) / 4; nFloats > maxSize {
95+ nFloats = maxSize
96+ }
97+ samples[i] = unsafe.Slice((*float32)(data.Data), nFloats)
98+ }
99+
100+ return &pwBufferView{
101+ buf: pw,
102+ channels: channels,
103+ frames: frames,
104+ samples: samples,
105+ }, nil
106+}
107+
108+// PCM returns a PCMBuffer pointing at the buffer's planar float32 data.
109+// The caller can write into Samples and then call Commit.
110+func (v *pwBufferView) PCM() *PCMBuffer {
111+ return &PCMBuffer{
112+ Frames: v.frames,
113+ Channels: v.channels,
114+ Samples: v.samples,
115+ }
116+}
117+
118+// Commit updates each data chunk's size and stride to reflect the number of
119+// frames written, and sets the pw buffer size field. Call this after writing
120+// audio data into the PCM buffer.
121+func (v *pwBufferView) Commit(frames int) {
122+ v.buf.Size = uint64(frames)
123+ dataSlice := unsafe.Slice((*spaData)(v.buf.Buffer.Datas), int(v.buf.Buffer.NDatas))
124+ for i := 0; i < v.channels; i++ {
125+ data := &dataSlice[i]
126+ if data.Chunk != nil {
127+ data.Chunk.Size = uint32(frames * 4) // float32 = 4 bytes
128+ data.Chunk.Stride = 4 // stride for planar float32
129+ }
130+ }
131+}
@@ -0,0 +1,131 @@
1+package core
2+
3+import (
4+ "errors"
5+ "unsafe"
6+)
7+
8+// PipeWire buffer layout structs matching the C ABI.
9+// These are low-level mappings that allow interpreting a dequeue'd buffer
10+// pointer as writable planar float32 data. They are intentionally minimal
11+// and local — not a general SPA buffer parser.
12+
13+// spaChunk mirrors struct spa_chunk from <spa/buffer/buffer.h>.
14+type spaChunk struct {
15+ Offset uint32
16+ Size uint32
17+ Stride int32
18+ Flags int32
19+}
20+
21+// spaData mirrors struct spa_data from <spa/buffer/buffer.h>.
22+type spaData struct {
23+ Type uint32
24+ Flags uint32
25+ Fd int64
26+ MapOffset uint32
27+ Maxsize uint32
28+ Data unsafe.Pointer
29+ Chunk *spaChunk
30+}
31+
32+// spaBuffer mirrors struct spa_buffer from <spa/buffer/buffer.h>.
33+type spaBuffer struct {
34+ NMetas uint32
35+ NDatas uint32
36+ Metas unsafe.Pointer // struct spa_meta * — pointer to array of spa_meta
37+ Datas unsafe.Pointer // struct spa_data * — pointer to array of spa_data
38+}
39+
40+// pwBuffer mirrors struct pw_buffer from <pipewire/stream.h>.
41+type pwBuffer struct {
42+ Buffer *spaBuffer
43+ UserData unsafe.Pointer
44+ Size uint64
45+}
46+
47+// pwBufferView provides a safe Go view over a dequeue'd PipeWire buffer,
48+// exposing planar float32 channel data. Call Commit after writing frames
49+// to update chunk size/stride and pw buffer size.
50+type pwBufferView struct {
51+ buf *pwBuffer
52+ channels int
53+ frames int
54+ samples [][]float32
55+}
56+
57+var (
58+ ErrNilBufferPointer = errors.New("nil buffer pointer")
59+ ErrChannelMismatch = errors.New("buffer data count does not match channels")
60+ ErrUnmappedData = errors.New("buffer data pointer is nil")
61+)
62+
63+// newPWBufferView interprets bufPtr as a *pwBuffer and returns a view that
64+// exposes planar float32 samples for each channel. The caller must ensure
65+// bufPtr is a valid pw_buffer pointer returned by pw_stream_dequeue_buffer.
66+// The view holds a pointer directly into the original pwBuffer so that Commit
67+// writes back into the shared memory structure, not a Go copy.
68+func newPWBufferView(bufPtr unsafe.Pointer, channels, frames int) (*pwBufferView, error) {
69+ if bufPtr == nil {
70+ return nil, ErrNilBufferPointer
71+ }
72+
73+ pw := (*pwBuffer)(bufPtr)
74+ if pw.Buffer == nil {
75+ return nil, ErrNilBufferPointer
76+ }
77+
78+ if pw.Buffer.NDatas != uint32(channels) {
79+ return nil, ErrChannelMismatch
80+ }
81+
82+ // Datas is a pointer to a C array of spaData structs.
83+ dataSlice := unsafe.Slice((*spaData)(pw.Buffer.Datas), int(pw.Buffer.NDatas))
84+
85+ samples := make([][]float32, channels)
86+ for i := 0; i < channels; i++ {
87+ data := dataSlice[i]
88+ if data.Data == nil {
89+ return nil, ErrUnmappedData
90+ }
91+ // Slice the raw memory as []float32 for this channel.
92+ // Data.Maxsize gives the total buffer size in bytes for this channel.
93+ nFloats := frames
94+ if maxSize := int(data.Maxsize) / 4; nFloats > maxSize {
95+ nFloats = maxSize
96+ }
97+ samples[i] = unsafe.Slice((*float32)(data.Data), nFloats)
98+ }
99+
100+ return &pwBufferView{
101+ buf: pw,
102+ channels: channels,
103+ frames: frames,
104+ samples: samples,
105+ }, nil
106+}
107+
108+// PCM returns a PCMBuffer pointing at the buffer's planar float32 data.
109+// The caller can write into Samples and then call Commit.
110+func (v *pwBufferView) PCM() *PCMBuffer {
111+ return &PCMBuffer{
112+ Frames: v.frames,
113+ Channels: v.channels,
114+ Samples: v.samples,
115+ }
116+}
117+
118+// Commit updates each data chunk's size and stride to reflect the number of
119+// frames written, and sets the pw buffer size field. Call this after writing
120+// audio data into the PCM buffer.
121+func (v *pwBufferView) Commit(frames int) {
122+ v.buf.Size = uint64(frames)
123+ dataSlice := unsafe.Slice((*spaData)(v.buf.Buffer.Datas), int(v.buf.Buffer.NDatas))
124+ for i := 0; i < v.channels; i++ {
125+ data := &dataSlice[i]
126+ if data.Chunk != nil {
127+ data.Chunk.Size = uint32(frames * 4) // float32 = 4 bytes
128+ data.Chunk.Stride = 4 // stride for planar float32
129+ }
130+ }
131+}
A internal/core/pwbuffer_test.go +250 -0
@@ -0,0 +1,250 @@
1+package core
2+
3+import (
4+ "errors"
5+ "testing"
6+ "unsafe"
7+)
8+
9+// TestNewPWBufferViewWrapsWritablePlanarFloatData verifies that newPWBufferView
10+// correctly interprets a C-layout pwBuffer as planar float32 channel data.
11+func TestNewPWBufferViewWrapsWritablePlanarFloatData(t *testing.T) {
12+ const channels = 2
13+ const frames = 64
14+
15+ // Allocate planar float32 backing memory for each channel.
16+ channelData := make([][]float32, channels)
17+ channelPtrs := make([]unsafe.Pointer, channels)
18+ for ch := 0; ch < channels; ch++ {
19+ channelData[ch] = make([]float32, frames)
20+ channelPtrs[ch] = unsafe.Pointer(&channelData[ch][0])
21+ }
22+
23+ // Build the C-layout chunk structs.
24+ chunks := make([]spaChunk, channels)
25+ for ch := 0; ch < channels; ch++ {
26+ chunks[ch] = spaChunk{
27+ Offset: 0,
28+ Size: 0, // will be set on Commit
29+ Stride: 0,
30+ Flags: 0,
31+ }
32+ }
33+
34+ // Build the C-layout spaData structs.
35+ datas := make([]spaData, channels)
36+ for ch := 0; ch < channels; ch++ {
37+ datas[ch] = spaData{
38+ Type: 2, // SPA_DATA_MemPtr
39+ Flags: 3, // SPA_DATA_FLAG_READABLE | SPA_DATA_FLAG_WRITABLE
40+ Maxsize: uint32(frames * 4), // bytes
41+ Data: channelPtrs[ch],
42+ Chunk: &chunks[ch],
43+ }
44+ }
45+
46+ // Build the C-layout spaBuffer.
47+ buf := spaBuffer{
48+ NMetas: 0,
49+ NDatas: uint32(channels),
50+ Metas: nil,
51+ Datas: unsafe.Pointer(&datas[0]),
52+ }
53+
54+ // Build the C-layout pwBuffer.
55+ pwBuf := pwBuffer{
56+ Buffer: &buf,
57+ UserData: nil,
58+ Size: 0,
59+ }
60+
61+ view, err := newPWBufferView(unsafe.Pointer(&pwBuf), channels, frames)
62+ if err != nil {
63+ t.Fatalf("unexpected error: %v", err)
64+ }
65+
66+ pcm := view.PCM()
67+ if pcm.Channels != channels {
68+ t.Errorf("expected %d channels, got %d", channels, pcm.Channels)
69+ }
70+ if pcm.Frames != frames {
71+ t.Errorf("expected %d frames, got %d", frames, pcm.Frames)
72+ }
73+ if len(pcm.Samples) != channels {
74+ t.Fatalf("expected %d sample slices, got %d", channels, len(pcm.Samples))
75+ }
76+ for ch := 0; ch < channels; ch++ {
77+ if len(pcm.Samples[ch]) != frames {
78+ t.Errorf("channel %d: expected %d frames, got %d", ch, frames, len(pcm.Samples[ch]))
79+ }
80+ }
81+
82+ // Verify the PCM buffer shares memory with channelData (not a copy).
83+ pcm.Samples[0][0] = 1.0
84+ if channelData[0][0] != 1.0 {
85+ t.Error("PCM buffer does not share memory with underlying channel data — write did not propagate")
86+ }
87+
88+ // Commit and verify chunk updates.
89+ const commitFrames = 32
90+ view.Commit(commitFrames)
91+
92+ if pwBuf.Size != uint64(commitFrames) {
93+ t.Errorf("pwBuffer.Size = %d, want %d", pwBuf.Size, commitFrames)
94+ }
95+ for ch := 0; ch < channels; ch++ {
96+ if chunks[ch].Size != uint32(commitFrames*4) {
97+ t.Errorf("chunk[%d].Size = %d, want %d", ch, chunks[ch].Size, commitFrames*4)
98+ }
99+ if chunks[ch].Stride != 4 {
100+ t.Errorf("chunk[%d].Stride = %d, want 4", ch, chunks[ch].Stride)
101+ }
102+ }
103+}
104+
105+// TestNewPWBufferViewRejectsNilPointer verifies that a nil buffer pointer
106+// returns ErrNilBufferPointer.
107+func TestNewPWBufferViewRejectsNilPointer(t *testing.T) {
108+ _, err := newPWBufferView(nil, 2, 64)
109+ if !errors.Is(err, ErrNilBufferPointer) {
110+ t.Fatalf("expected ErrNilBufferPointer, got %v", err)
111+ }
112+}
113+
114+// TestNewPWBufferViewRejectsChannelMismatch verifies that a buffer whose
115+// NDatas does not match the requested channel count returns ErrChannelMismatch.
116+func TestNewPWBufferViewRejectsChannelMismatch(t *testing.T) {
117+ // Build a buffer claiming 1 data slot.
118+ singleData := spaData{
119+ Type: 2,
120+ Maxsize: 256,
121+ Data: unsafe.Pointer(new(float32)),
122+ Chunk: &spaChunk{},
123+ }
124+ buf := spaBuffer{
125+ NMetas: 0,
126+ NDatas: 1, // only 1 channel
127+ Datas: unsafe.Pointer(&singleData),
128+ }
129+ pwBuf := pwBuffer{
130+ Buffer: &buf,
131+ }
132+
133+ _, err := newPWBufferView(unsafe.Pointer(&pwBuf), 2, 64) // requesting 2 channels
134+ if !errors.Is(err, ErrChannelMismatch) {
135+ t.Fatalf("expected ErrChannelMismatch, got %v", err)
136+ }
137+}
138+
139+// TestNewPWBufferViewRejectsUnmappedData verifies that a nil data pointer
140+// in a spaData slot returns ErrUnmappedData.
141+func TestNewPWBufferViewRejectsUnmappedData(t *testing.T) {
142+ datas := make([]spaData, 2)
143+ datas[0] = spaData{
144+ Type: 2,
145+ Maxsize: 256,
146+ Data: nil, // nil — should trigger ErrUnmappedData
147+ Chunk: &spaChunk{},
148+ }
149+ datas[1] = spaData{
150+ Type: 2,
151+ Maxsize: 256,
152+ Data: unsafe.Pointer(new(float32)),
153+ Chunk: &spaChunk{},
154+ }
155+
156+ buf := spaBuffer{
157+ NMetas: 0,
158+ NDatas: 2,
159+ Datas: unsafe.Pointer(&datas[0]),
160+ }
161+ pwBuf := pwBuffer{
162+ Buffer: &buf,
163+ }
164+
165+ _, err := newPWBufferView(unsafe.Pointer(&pwBuf), 2, 64)
166+ if !errors.Is(err, ErrUnmappedData) {
167+ t.Fatalf("expected ErrUnmappedData, got %v", err)
168+ }
169+}
170+
171+// TestNewPWBufferViewRejectsNilSpaBuffer verifies that a nil spa_buffer
172+// pointer inside the pwBuffer returns ErrNilBufferPointer.
173+func TestNewPWBufferViewRejectsNilSpaBuffer(t *testing.T) {
174+ pwBuf := pwBuffer{
175+ Buffer: nil, // nil — should trigger ErrNilBufferPointer
176+ UserData: nil,
177+ Size: 0,
178+ }
179+
180+ _, err := newPWBufferView(unsafe.Pointer(&pwBuf), 2, 64)
181+ if !errors.Is(err, ErrNilBufferPointer) {
182+ t.Fatalf("expected ErrNilBufferPointer, got %v", err)
183+ }
184+}
185+
186+// TestCommitUpdatesSizeAndStride verifies that Commit writes the correct
187+// byte sizes and strides for planar float32 data.
188+func TestCommitUpdatesSizeAndStride(t *testing.T) {
189+ const channels = 2
190+ const frames = 128
191+
192+ channelData := make([][]float32, channels)
193+ channelPtrs := make([]unsafe.Pointer, channels)
194+ for ch := 0; ch < channels; ch++ {
195+ channelData[ch] = make([]float32, frames)
196+ channelPtrs[ch] = unsafe.Pointer(&channelData[ch][0])
197+ }
198+
199+ chunks := make([]spaChunk, channels)
200+ datas := make([]spaData, channels)
201+ for ch := 0; ch < channels; ch++ {
202+ datas[ch] = spaData{
203+ Type: 2,
204+ Maxsize: uint32(frames * 4),
205+ Data: channelPtrs[ch],
206+ Chunk: &chunks[ch],
207+ }
208+ }
209+
210+ buf := spaBuffer{
211+ NMetas: 0,
212+ NDatas: uint32(channels),
213+ Datas: unsafe.Pointer(&datas[0]),
214+ }
215+ pwBuf := pwBuffer{
216+ Buffer: &buf,
217+ }
218+
219+ view, err := newPWBufferView(unsafe.Pointer(&pwBuf), channels, frames)
220+ if err != nil {
221+ t.Fatalf("unexpected error: %v", err)
222+ }
223+
224+ // Write some audio data to the PCM buffer.
225+ const writtenFrames = 96
226+ pcm := view.PCM()
227+ for ch := 0; ch < channels; ch++ {
228+ for f := 0; f < writtenFrames; f++ {
229+ pcm.Samples[ch][f] = float32(f) * 0.5
230+ }
231+ }
232+
233+ view.Commit(writtenFrames)
234+
235+ // Verify byte size = frames × sizeof(float32) per chunk.
236+ for ch := 0; ch < channels; ch++ {
237+ wantSize := uint32(writtenFrames * 4)
238+ if chunks[ch].Size != wantSize {
239+ t.Errorf("chunk[%d].Size = %d, want %d", ch, chunks[ch].Size, wantSize)
240+ }
241+ if chunks[ch].Stride != 4 {
242+ t.Errorf("chunk[%d].Stride = %d, want 4", ch, chunks[ch].Stride)
243+ }
244+ }
245+
246+ // Verify pwBuffer.Size reflects frame count (not bytes).
247+ if pwBuf.Size != uint64(writtenFrames) {
248+ t.Errorf("pwBuffer.Size = %d, want %d", pwBuf.Size, writtenFrames)
249+ }
250+}
@@ -0,0 +1,250 @@
1+package core
2+
3+import (
4+ "errors"
5+ "testing"
6+ "unsafe"
7+)
8+
9+// TestNewPWBufferViewWrapsWritablePlanarFloatData verifies that newPWBufferView
10+// correctly interprets a C-layout pwBuffer as planar float32 channel data.
11+func TestNewPWBufferViewWrapsWritablePlanarFloatData(t *testing.T) {
12+ const channels = 2
13+ const frames = 64
14+
15+ // Allocate planar float32 backing memory for each channel.
16+ channelData := make([][]float32, channels)
17+ channelPtrs := make([]unsafe.Pointer, channels)
18+ for ch := 0; ch < channels; ch++ {
19+ channelData[ch] = make([]float32, frames)
20+ channelPtrs[ch] = unsafe.Pointer(&channelData[ch][0])
21+ }
22+
23+ // Build the C-layout chunk structs.
24+ chunks := make([]spaChunk, channels)
25+ for ch := 0; ch < channels; ch++ {
26+ chunks[ch] = spaChunk{
27+ Offset: 0,
28+ Size: 0, // will be set on Commit
29+ Stride: 0,
30+ Flags: 0,
31+ }
32+ }
33+
34+ // Build the C-layout spaData structs.
35+ datas := make([]spaData, channels)
36+ for ch := 0; ch < channels; ch++ {
37+ datas[ch] = spaData{
38+ Type: 2, // SPA_DATA_MemPtr
39+ Flags: 3, // SPA_DATA_FLAG_READABLE | SPA_DATA_FLAG_WRITABLE
40+ Maxsize: uint32(frames * 4), // bytes
41+ Data: channelPtrs[ch],
42+ Chunk: &chunks[ch],
43+ }
44+ }
45+
46+ // Build the C-layout spaBuffer.
47+ buf := spaBuffer{
48+ NMetas: 0,
49+ NDatas: uint32(channels),
50+ Metas: nil,
51+ Datas: unsafe.Pointer(&datas[0]),
52+ }
53+
54+ // Build the C-layout pwBuffer.
55+ pwBuf := pwBuffer{
56+ Buffer: &buf,
57+ UserData: nil,
58+ Size: 0,
59+ }
60+
61+ view, err := newPWBufferView(unsafe.Pointer(&pwBuf), channels, frames)
62+ if err != nil {
63+ t.Fatalf("unexpected error: %v", err)
64+ }
65+
66+ pcm := view.PCM()
67+ if pcm.Channels != channels {
68+ t.Errorf("expected %d channels, got %d", channels, pcm.Channels)
69+ }
70+ if pcm.Frames != frames {
71+ t.Errorf("expected %d frames, got %d", frames, pcm.Frames)
72+ }
73+ if len(pcm.Samples) != channels {
74+ t.Fatalf("expected %d sample slices, got %d", channels, len(pcm.Samples))
75+ }
76+ for ch := 0; ch < channels; ch++ {
77+ if len(pcm.Samples[ch]) != frames {
78+ t.Errorf("channel %d: expected %d frames, got %d", ch, frames, len(pcm.Samples[ch]))
79+ }
80+ }
81+
82+ // Verify the PCM buffer shares memory with channelData (not a copy).
83+ pcm.Samples[0][0] = 1.0
84+ if channelData[0][0] != 1.0 {
85+ t.Error("PCM buffer does not share memory with underlying channel data — write did not propagate")
86+ }
87+
88+ // Commit and verify chunk updates.
89+ const commitFrames = 32
90+ view.Commit(commitFrames)
91+
92+ if pwBuf.Size != uint64(commitFrames) {
93+ t.Errorf("pwBuffer.Size = %d, want %d", pwBuf.Size, commitFrames)
94+ }
95+ for ch := 0; ch < channels; ch++ {
96+ if chunks[ch].Size != uint32(commitFrames*4) {
97+ t.Errorf("chunk[%d].Size = %d, want %d", ch, chunks[ch].Size, commitFrames*4)
98+ }
99+ if chunks[ch].Stride != 4 {
100+ t.Errorf("chunk[%d].Stride = %d, want 4", ch, chunks[ch].Stride)
101+ }
102+ }
103+}
104+
105+// TestNewPWBufferViewRejectsNilPointer verifies that a nil buffer pointer
106+// returns ErrNilBufferPointer.
107+func TestNewPWBufferViewRejectsNilPointer(t *testing.T) {
108+ _, err := newPWBufferView(nil, 2, 64)
109+ if !errors.Is(err, ErrNilBufferPointer) {
110+ t.Fatalf("expected ErrNilBufferPointer, got %v", err)
111+ }
112+}
113+
114+// TestNewPWBufferViewRejectsChannelMismatch verifies that a buffer whose
115+// NDatas does not match the requested channel count returns ErrChannelMismatch.
116+func TestNewPWBufferViewRejectsChannelMismatch(t *testing.T) {
117+ // Build a buffer claiming 1 data slot.
118+ singleData := spaData{
119+ Type: 2,
120+ Maxsize: 256,
121+ Data: unsafe.Pointer(new(float32)),
122+ Chunk: &spaChunk{},
123+ }
124+ buf := spaBuffer{
125+ NMetas: 0,
126+ NDatas: 1, // only 1 channel
127+ Datas: unsafe.Pointer(&singleData),
128+ }
129+ pwBuf := pwBuffer{
130+ Buffer: &buf,
131+ }
132+
133+ _, err := newPWBufferView(unsafe.Pointer(&pwBuf), 2, 64) // requesting 2 channels
134+ if !errors.Is(err, ErrChannelMismatch) {
135+ t.Fatalf("expected ErrChannelMismatch, got %v", err)
136+ }
137+}
138+
139+// TestNewPWBufferViewRejectsUnmappedData verifies that a nil data pointer
140+// in a spaData slot returns ErrUnmappedData.
141+func TestNewPWBufferViewRejectsUnmappedData(t *testing.T) {
142+ datas := make([]spaData, 2)
143+ datas[0] = spaData{
144+ Type: 2,
145+ Maxsize: 256,
146+ Data: nil, // nil — should trigger ErrUnmappedData
147+ Chunk: &spaChunk{},
148+ }
149+ datas[1] = spaData{
150+ Type: 2,
151+ Maxsize: 256,
152+ Data: unsafe.Pointer(new(float32)),
153+ Chunk: &spaChunk{},
154+ }
155+
156+ buf := spaBuffer{
157+ NMetas: 0,
158+ NDatas: 2,
159+ Datas: unsafe.Pointer(&datas[0]),
160+ }
161+ pwBuf := pwBuffer{
162+ Buffer: &buf,
163+ }
164+
165+ _, err := newPWBufferView(unsafe.Pointer(&pwBuf), 2, 64)
166+ if !errors.Is(err, ErrUnmappedData) {
167+ t.Fatalf("expected ErrUnmappedData, got %v", err)
168+ }
169+}
170+
171+// TestNewPWBufferViewRejectsNilSpaBuffer verifies that a nil spa_buffer
172+// pointer inside the pwBuffer returns ErrNilBufferPointer.
173+func TestNewPWBufferViewRejectsNilSpaBuffer(t *testing.T) {
174+ pwBuf := pwBuffer{
175+ Buffer: nil, // nil — should trigger ErrNilBufferPointer
176+ UserData: nil,
177+ Size: 0,
178+ }
179+
180+ _, err := newPWBufferView(unsafe.Pointer(&pwBuf), 2, 64)
181+ if !errors.Is(err, ErrNilBufferPointer) {
182+ t.Fatalf("expected ErrNilBufferPointer, got %v", err)
183+ }
184+}
185+
186+// TestCommitUpdatesSizeAndStride verifies that Commit writes the correct
187+// byte sizes and strides for planar float32 data.
188+func TestCommitUpdatesSizeAndStride(t *testing.T) {
189+ const channels = 2
190+ const frames = 128
191+
192+ channelData := make([][]float32, channels)
193+ channelPtrs := make([]unsafe.Pointer, channels)
194+ for ch := 0; ch < channels; ch++ {
195+ channelData[ch] = make([]float32, frames)
196+ channelPtrs[ch] = unsafe.Pointer(&channelData[ch][0])
197+ }
198+
199+ chunks := make([]spaChunk, channels)
200+ datas := make([]spaData, channels)
201+ for ch := 0; ch < channels; ch++ {
202+ datas[ch] = spaData{
203+ Type: 2,
204+ Maxsize: uint32(frames * 4),
205+ Data: channelPtrs[ch],
206+ Chunk: &chunks[ch],
207+ }
208+ }
209+
210+ buf := spaBuffer{
211+ NMetas: 0,
212+ NDatas: uint32(channels),
213+ Datas: unsafe.Pointer(&datas[0]),
214+ }
215+ pwBuf := pwBuffer{
216+ Buffer: &buf,
217+ }
218+
219+ view, err := newPWBufferView(unsafe.Pointer(&pwBuf), channels, frames)
220+ if err != nil {
221+ t.Fatalf("unexpected error: %v", err)
222+ }
223+
224+ // Write some audio data to the PCM buffer.
225+ const writtenFrames = 96
226+ pcm := view.PCM()
227+ for ch := 0; ch < channels; ch++ {
228+ for f := 0; f < writtenFrames; f++ {
229+ pcm.Samples[ch][f] = float32(f) * 0.5
230+ }
231+ }
232+
233+ view.Commit(writtenFrames)
234+
235+ // Verify byte size = frames × sizeof(float32) per chunk.
236+ for ch := 0; ch < channels; ch++ {
237+ wantSize := uint32(writtenFrames * 4)
238+ if chunks[ch].Size != wantSize {
239+ t.Errorf("chunk[%d].Size = %d, want %d", ch, chunks[ch].Size, wantSize)
240+ }
241+ if chunks[ch].Stride != 4 {
242+ t.Errorf("chunk[%d].Stride = %d, want 4", ch, chunks[ch].Stride)
243+ }
244+ }
245+
246+ // Verify pwBuffer.Size reflects frame count (not bytes).
247+ if pwBuf.Size != uint64(writtenFrames) {
248+ t.Errorf("pwBuffer.Size = %d, want %d", pwBuf.Size, writtenFrames)
249+ }
250+}

Keyboard shortcuts

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

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

Branches 1

Tags 8

↑↓ moveenter openesc close