Skip to content

Commit 62a25ff

Browse files
hajimehoshiclaude
andauthored
oto: add a non-cgo ALSA fallback for the PulseAudio driver (#283)
The PulseAudio driver requires a running PulseAudio server, which is absent on many minimal, container, kiosk, and embedded systems. When no server is reachable, fall back to ALSA by loading libasound dynamically through purego, keeping the fallback Cgo-free and preserving FreeBSD and NetBSD support. The context now picks a backend at startup much like the Windows driver chooses between WASAPI and WinMM. Closes #282 Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent 7c4d917 commit 62a25ff

4 files changed

Lines changed: 563 additions & 100 deletions

File tree

‎README.md‎

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -58,6 +58,14 @@ though BSD systems are not tested well.
5858

5959
If the PulseAudio server is not discoverable automatically, set `PULSE_SERVER`.
6060

61+
When no PulseAudio server is reachable, Oto falls back to ALSA. This fallback also requires no Cgo:
62+
`libasound.so.2` is loaded dynamically at runtime, so no ALSA development headers are needed to
63+
build, though `libasound.so.2` itself must be present at runtime.
64+
65+
On FreeBSD, building with `CGO_ENABLED=0` (for example when cross-compiling) additionally requires
66+
`-gcflags="github.com/ebitengine/purego/internal/fakecgo=-std"`; native FreeBSD builds, where Cgo
67+
is enabled by default, need nothing extra.
68+
6169
## Usage
6270

6371
The two main components of Oto are a `Context` and `Players`. The context handles interactions with

‎driver_alsa_unix.go‎

Lines changed: 348 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,348 @@
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(&params); 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

Comments
 (0)