feat: add PipeWire playback format and buffer helpers
- commit
- 177250ec0bd310bd3642e8291b22efbd7da1a4d3
- author
- brice · · 2026-04-10 13:18 UTC
- parent
- 918a3b9
- signature
- GPG Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· 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 | + | } | |||