|
| 1 | +// Copyright 2026 The Oto Authors |
| 2 | +// |
| 3 | +// Licensed under the Apache License, Version 2.0 (the "License"); |
| 4 | +// you may not use this file except in compliance with the License. |
| 5 | +// You may obtain a copy of the License at |
| 6 | +// |
| 7 | +// http://www.apache.org/licenses/LICENSE-2.0 |
| 8 | +// |
| 9 | +// Unless required by applicable law or agreed to in writing, software |
| 10 | +// distributed under the License is distributed on an "AS IS" BASIS, |
| 11 | +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. |
| 12 | +// See the License for the specific language governing permissions and |
| 13 | +// limitations under the License. |
| 14 | + |
| 15 | +//go:build ((linux && !android) || freebsd || netbsd) && !nintendosdk && !playstation5 |
| 16 | + |
| 17 | +package oto |
| 18 | + |
| 19 | +import ( |
| 20 | + "fmt" |
| 21 | + "strings" |
| 22 | + "sync" |
| 23 | + "unsafe" |
| 24 | + |
| 25 | + "github.com/ebitengine/purego" |
| 26 | + |
| 27 | + "github.com/ebitengine/oto/v3/internal/mux" |
| 28 | +) |
| 29 | + |
| 30 | +const ( |
| 31 | + _SND_PCM_STREAM_PLAYBACK = 0 |
| 32 | + _SND_PCM_FORMAT_FLOAT_LE = 14 |
| 33 | + _SND_PCM_ACCESS_RW_INTERLEAVED = 3 |
| 34 | +) |
| 35 | + |
| 36 | +var ( |
| 37 | + _snd_strerror func(errnum int32) string |
| 38 | + _snd_pcm_open func(pcm *uintptr, name string, stream int32, mode int32) int32 |
| 39 | + _snd_pcm_close func(pcm uintptr) int32 |
| 40 | + _snd_pcm_hw_params_malloc func(ptr *uintptr) int32 |
| 41 | + _snd_pcm_hw_params_free func(obj uintptr) |
| 42 | + _snd_pcm_hw_params_any func(pcm, params uintptr) int32 |
| 43 | + _snd_pcm_hw_params_set_access func(pcm, params uintptr, access uint32) int32 |
| 44 | + _snd_pcm_hw_params_set_format func(pcm, params uintptr, format int32) int32 |
| 45 | + _snd_pcm_hw_params_set_channels func(pcm, params uintptr, val uint32) int32 |
| 46 | + _snd_pcm_hw_params_set_rate_resample func(pcm, params uintptr, val uint32) int32 |
| 47 | + _snd_pcm_hw_params_set_rate_near func(pcm, params uintptr, val *uint32, dir *int32) int32 |
| 48 | + _snd_pcm_hw_params_set_buffer_size_near func(pcm, params uintptr, val *uint) int32 |
| 49 | + _snd_pcm_hw_params_set_period_size_near func(pcm, params uintptr, val *uint, dir *int32) int32 |
| 50 | + _snd_pcm_hw_params func(pcm, params uintptr) int32 |
| 51 | + _snd_pcm_writei func(pcm uintptr, buf []float32, size uint) int |
| 52 | + _snd_pcm_recover func(pcm uintptr, err int32, silent int32) int32 |
| 53 | + |
| 54 | + _snd_device_name_hint func(card int32, iface string, hints *unsafe.Pointer) int32 |
| 55 | + _snd_device_name_free_hint func(hints unsafe.Pointer) int32 |
| 56 | + _snd_device_name_get_hint func(hint unsafe.Pointer, id string) unsafe.Pointer |
| 57 | + |
| 58 | + _free func(ptr unsafe.Pointer) |
| 59 | +) |
| 60 | + |
| 61 | +func init() { |
| 62 | + newALSAContext = func(sampleRate, channelCount int, mux *mux.Mux, bufferSizeInBytes int) (unixBackend, error) { |
| 63 | + c, err := newALSAContextImpl(sampleRate, channelCount, mux, bufferSizeInBytes) |
| 64 | + if err != nil { |
| 65 | + return nil, err |
| 66 | + } |
| 67 | + return c, nil |
| 68 | + } |
| 69 | +} |
| 70 | + |
| 71 | +// loadALSA loads libasound and binds the functions above. A context is created at most once |
| 72 | +// per process (see NewContext), so this runs at most once and needs no synchronization. |
| 73 | +func loadALSA() error { |
| 74 | + var handle uintptr |
| 75 | + var err error |
| 76 | + for _, name := range []string{"libasound.so.2", "libasound.so"} { |
| 77 | + handle, err = purego.Dlopen(name, purego.RTLD_LAZY|purego.RTLD_GLOBAL) |
| 78 | + if err == nil { |
| 79 | + break |
| 80 | + } |
| 81 | + } |
| 82 | + if handle == 0 { |
| 83 | + return fmt.Errorf("oto: failed to load libasound: %w", err) |
| 84 | + } |
| 85 | + |
| 86 | + purego.RegisterLibFunc(&_snd_strerror, handle, "snd_strerror") |
| 87 | + purego.RegisterLibFunc(&_snd_pcm_open, handle, "snd_pcm_open") |
| 88 | + purego.RegisterLibFunc(&_snd_pcm_close, handle, "snd_pcm_close") |
| 89 | + purego.RegisterLibFunc(&_snd_pcm_hw_params_malloc, handle, "snd_pcm_hw_params_malloc") |
| 90 | + purego.RegisterLibFunc(&_snd_pcm_hw_params_free, handle, "snd_pcm_hw_params_free") |
| 91 | + purego.RegisterLibFunc(&_snd_pcm_hw_params_any, handle, "snd_pcm_hw_params_any") |
| 92 | + purego.RegisterLibFunc(&_snd_pcm_hw_params_set_access, handle, "snd_pcm_hw_params_set_access") |
| 93 | + purego.RegisterLibFunc(&_snd_pcm_hw_params_set_format, handle, "snd_pcm_hw_params_set_format") |
| 94 | + purego.RegisterLibFunc(&_snd_pcm_hw_params_set_channels, handle, "snd_pcm_hw_params_set_channels") |
| 95 | + purego.RegisterLibFunc(&_snd_pcm_hw_params_set_rate_resample, handle, "snd_pcm_hw_params_set_rate_resample") |
| 96 | + purego.RegisterLibFunc(&_snd_pcm_hw_params_set_rate_near, handle, "snd_pcm_hw_params_set_rate_near") |
| 97 | + purego.RegisterLibFunc(&_snd_pcm_hw_params_set_buffer_size_near, handle, "snd_pcm_hw_params_set_buffer_size_near") |
| 98 | + purego.RegisterLibFunc(&_snd_pcm_hw_params_set_period_size_near, handle, "snd_pcm_hw_params_set_period_size_near") |
| 99 | + purego.RegisterLibFunc(&_snd_pcm_hw_params, handle, "snd_pcm_hw_params") |
| 100 | + purego.RegisterLibFunc(&_snd_pcm_writei, handle, "snd_pcm_writei") |
| 101 | + purego.RegisterLibFunc(&_snd_pcm_recover, handle, "snd_pcm_recover") |
| 102 | + purego.RegisterLibFunc(&_snd_device_name_hint, handle, "snd_device_name_hint") |
| 103 | + purego.RegisterLibFunc(&_snd_device_name_free_hint, handle, "snd_device_name_free_hint") |
| 104 | + purego.RegisterLibFunc(&_snd_device_name_get_hint, handle, "snd_device_name_get_hint") |
| 105 | + |
| 106 | + // libc free, for the strings snd_device_name_get_hint allocates. It resolves through the |
| 107 | + // libasound handle, whose dependency tree includes libc, so no OS-specific libc name is needed. |
| 108 | + purego.RegisterLibFunc(&_free, handle, "free") |
| 109 | + return nil |
| 110 | +} |
| 111 | + |
| 112 | +type alsaContext struct { |
| 113 | + channelCount int |
| 114 | + |
| 115 | + suspended bool |
| 116 | + |
| 117 | + handle uintptr |
| 118 | + |
| 119 | + cond *sync.Cond |
| 120 | + |
| 121 | + mux *mux.Mux |
| 122 | + err atomicError |
| 123 | +} |
| 124 | + |
| 125 | +func newALSAContextImpl(sampleRate int, channelCount int, mux *mux.Mux, bufferSizeInBytes int) (*alsaContext, error) { |
| 126 | + if channelCount != 1 && channelCount != 2 { |
| 127 | + return nil, fmt.Errorf("oto: ALSA backend supports only mono or stereo output: %d", channelCount) |
| 128 | + } |
| 129 | + if err := loadALSA(); err != nil { |
| 130 | + return nil, err |
| 131 | + } |
| 132 | + |
| 133 | + c := &alsaContext{ |
| 134 | + channelCount: channelCount, |
| 135 | + cond: sync.NewCond(&sync.Mutex{}), |
| 136 | + mux: mux, |
| 137 | + } |
| 138 | + |
| 139 | + // Open a default ALSA audio device for blocking stream playback. |
| 140 | + var openErrs []string |
| 141 | + var handle uintptr |
| 142 | + var found bool |
| 143 | + for _, name := range deviceCandidates() { |
| 144 | + if err := _snd_pcm_open(&handle, name, _SND_PCM_STREAM_PLAYBACK, 0); err < 0 { |
| 145 | + openErrs = append(openErrs, fmt.Sprintf("%q: %s", name, _snd_strerror(err))) |
| 146 | + continue |
| 147 | + } |
| 148 | + found = true |
| 149 | + break |
| 150 | + } |
| 151 | + if !found { |
| 152 | + return nil, fmt.Errorf("oto: ALSA error at snd_pcm_open: %s", strings.Join(openErrs, ", ")) |
| 153 | + } |
| 154 | + c.handle = handle |
| 155 | + |
| 156 | + // TODO: Should snd_pcm_hw_params_set_periods be called explicitly? |
| 157 | + const periods = 2 |
| 158 | + var periodSize uint |
| 159 | + if bufferSizeInBytes != 0 { |
| 160 | + periodSize = uint(bufferSizeInBytes / (channelCount * 4 * periods)) |
| 161 | + if periodSize == 0 { |
| 162 | + periodSize = 1 |
| 163 | + } |
| 164 | + } else { |
| 165 | + periodSize = 1024 |
| 166 | + } |
| 167 | + bufferSize := periodSize * periods |
| 168 | + if err := c.alsaPCMHwParams(sampleRate, channelCount, &bufferSize, &periodSize); err != nil { |
| 169 | + _snd_pcm_close(c.handle) |
| 170 | + return nil, err |
| 171 | + } |
| 172 | + |
| 173 | + go func() { |
| 174 | + // The loop only returns when readAndWrite hits a permanent error, so close the |
| 175 | + // handle here to avoid leaking it after a terminal audio failure. |
| 176 | + defer _snd_pcm_close(c.handle) |
| 177 | + buf32 := make([]float32, int(periodSize)*channelCount) |
| 178 | + for { |
| 179 | + if !c.readAndWrite(buf32) { |
| 180 | + return |
| 181 | + } |
| 182 | + } |
| 183 | + }() |
| 184 | + |
| 185 | + return c, nil |
| 186 | +} |
| 187 | + |
| 188 | +func (c *alsaContext) alsaPCMHwParams(sampleRate, channelCount int, bufferSize, periodSize *uint) error { |
| 189 | + var params uintptr |
| 190 | + if err := _snd_pcm_hw_params_malloc(¶ms); err < 0 { |
| 191 | + return alsaError("snd_pcm_hw_params_malloc", err) |
| 192 | + } |
| 193 | + defer _snd_pcm_hw_params_free(params) |
| 194 | + |
| 195 | + if err := _snd_pcm_hw_params_any(c.handle, params); err < 0 { |
| 196 | + return alsaError("snd_pcm_hw_params_any", err) |
| 197 | + } |
| 198 | + if err := _snd_pcm_hw_params_set_access(c.handle, params, _SND_PCM_ACCESS_RW_INTERLEAVED); err < 0 { |
| 199 | + return alsaError("snd_pcm_hw_params_set_access", err) |
| 200 | + } |
| 201 | + if err := _snd_pcm_hw_params_set_format(c.handle, params, _SND_PCM_FORMAT_FLOAT_LE); err < 0 { |
| 202 | + return alsaError("snd_pcm_hw_params_set_format", err) |
| 203 | + } |
| 204 | + if err := _snd_pcm_hw_params_set_channels(c.handle, params, uint32(channelCount)); err < 0 { |
| 205 | + return alsaError("snd_pcm_hw_params_set_channels", err) |
| 206 | + } |
| 207 | + if err := _snd_pcm_hw_params_set_rate_resample(c.handle, params, 1); err < 0 { |
| 208 | + return alsaError("snd_pcm_hw_params_set_rate_resample", err) |
| 209 | + } |
| 210 | + sr := uint32(sampleRate) |
| 211 | + if err := _snd_pcm_hw_params_set_rate_near(c.handle, params, &sr, nil); err < 0 { |
| 212 | + return alsaError("snd_pcm_hw_params_set_rate_near", err) |
| 213 | + } |
| 214 | + if err := _snd_pcm_hw_params_set_buffer_size_near(c.handle, params, bufferSize); err < 0 { |
| 215 | + return alsaError("snd_pcm_hw_params_set_buffer_size_near", err) |
| 216 | + } |
| 217 | + if err := _snd_pcm_hw_params_set_period_size_near(c.handle, params, periodSize, nil); err < 0 { |
| 218 | + return alsaError("snd_pcm_hw_params_set_period_size_near", err) |
| 219 | + } |
| 220 | + if err := _snd_pcm_hw_params(c.handle, params); err < 0 { |
| 221 | + return alsaError("snd_pcm_hw_params", err) |
| 222 | + } |
| 223 | + return nil |
| 224 | +} |
| 225 | + |
| 226 | +func (c *alsaContext) readAndWrite(buf32 []float32) bool { |
| 227 | + c.cond.L.Lock() |
| 228 | + defer c.cond.L.Unlock() |
| 229 | + |
| 230 | + for c.suspended && c.err.Load() == nil { |
| 231 | + c.cond.Wait() |
| 232 | + } |
| 233 | + if c.err.Load() != nil { |
| 234 | + return false |
| 235 | + } |
| 236 | + |
| 237 | + c.mux.ReadFloat32s(buf32) |
| 238 | + |
| 239 | + buf := buf32 |
| 240 | + for len(buf) > 0 { |
| 241 | + n := _snd_pcm_writei(c.handle, buf, uint(len(buf)/c.channelCount)) |
| 242 | + if n < 0 { |
| 243 | + n = int(_snd_pcm_recover(c.handle, int32(n), 1)) |
| 244 | + } |
| 245 | + if n < 0 { |
| 246 | + c.err.TryStore(alsaError("snd_pcm_writei or snd_pcm_recover", int32(n))) |
| 247 | + return false |
| 248 | + } |
| 249 | + buf = buf[n*c.channelCount:] |
| 250 | + } |
| 251 | + return true |
| 252 | +} |
| 253 | + |
| 254 | +func (c *alsaContext) Suspend() error { |
| 255 | + c.cond.L.Lock() |
| 256 | + defer c.cond.L.Unlock() |
| 257 | + |
| 258 | + if err := c.err.Load(); err != nil { |
| 259 | + return err |
| 260 | + } |
| 261 | + |
| 262 | + c.suspended = true |
| 263 | + |
| 264 | + // Do not use snd_pcm_pause as not all devices support this. |
| 265 | + // Do not use snd_pcm_drop as this might hang (https://github.com/libsdl-org/SDL/blob/a5c610b0a3857d3138f3f3da1f6dc3172c5ea4a8/src/audio/alsa/SDL_alsa_audio.c#L478). |
| 266 | + return nil |
| 267 | +} |
| 268 | + |
| 269 | +func (c *alsaContext) Resume() error { |
| 270 | + c.cond.L.Lock() |
| 271 | + defer c.cond.L.Unlock() |
| 272 | + |
| 273 | + if err := c.err.Load(); err != nil { |
| 274 | + return err |
| 275 | + } |
| 276 | + |
| 277 | + c.suspended = false |
| 278 | + c.cond.Signal() |
| 279 | + return nil |
| 280 | +} |
| 281 | + |
| 282 | +func (c *alsaContext) Err() error { |
| 283 | + return c.err.Load() |
| 284 | +} |
| 285 | + |
| 286 | +func alsaError(name string, errno int32) error { |
| 287 | + return fmt.Errorf("oto: ALSA error at %s: %s", name, _snd_strerror(errno)) |
| 288 | +} |
| 289 | + |
| 290 | +func deviceCandidates() []string { |
| 291 | + const getAllDevices = -1 |
| 292 | + |
| 293 | + var hints unsafe.Pointer |
| 294 | + if _snd_device_name_hint(getAllDevices, "pcm", &hints) != 0 { |
| 295 | + return []string{"default", "plug:default"} |
| 296 | + } |
| 297 | + defer _snd_device_name_free_hint(hints) |
| 298 | + |
| 299 | + var devices []string |
| 300 | + ptrSize := unsafe.Sizeof(uintptr(0)) |
| 301 | + for i := uintptr(0); ; i++ { |
| 302 | + hint := *(*unsafe.Pointer)(unsafe.Pointer(uintptr(hints) + i*ptrSize)) |
| 303 | + if hint == nil { |
| 304 | + break |
| 305 | + } |
| 306 | + |
| 307 | + if hintString(hint, "IOID") == "Input" { |
| 308 | + continue |
| 309 | + } |
| 310 | + |
| 311 | + name := hintString(hint, "NAME") |
| 312 | + switch name { |
| 313 | + case "", "null", "default": |
| 314 | + continue |
| 315 | + } |
| 316 | + devices = append(devices, name) |
| 317 | + } |
| 318 | + |
| 319 | + return append([]string{"default", "plug:default"}, devices...) |
| 320 | +} |
| 321 | + |
| 322 | +// hintString returns the value of the given hint key as a Go string, freeing the C string that |
| 323 | +// libasound allocated for it. |
| 324 | +func hintString(hint unsafe.Pointer, id string) string { |
| 325 | + p := _snd_device_name_get_hint(hint, id) |
| 326 | + if p == nil { |
| 327 | + return "" |
| 328 | + } |
| 329 | + s := goString(p) |
| 330 | + _free(p) |
| 331 | + return s |
| 332 | +} |
| 333 | + |
| 334 | +// goString copies a NUL-terminated C string into a Go string. |
| 335 | +func goString(p unsafe.Pointer) string { |
| 336 | + if p == nil { |
| 337 | + return "" |
| 338 | + } |
| 339 | + var bs []byte |
| 340 | + for i := uintptr(0); ; i++ { |
| 341 | + b := *(*byte)(unsafe.Pointer(uintptr(p) + i)) |
| 342 | + if b == 0 { |
| 343 | + break |
| 344 | + } |
| 345 | + bs = append(bs, b) |
| 346 | + } |
| 347 | + return string(bs) |
| 348 | +} |
0 commit comments