From d27383d7654ec8d60fb0ca65be01537e9f790613 Mon Sep 17 00:00:00 2001 From: Andrew Montgomery Date: Sat, 28 Mar 2026 10:36:10 +0000 Subject: [PATCH 01/14] feat: add non-cgo linux pulseaudio driver Add pulseaudio support on linux via github.com/jfreymuth/pulse that does not require CGO to build. Makes it the default linux audio option, falling back to the ALSA implementation if CGO is enabled. Written entirely with Copilot, plus some manual human testing via the example app. --- .github/workflows/test.yml | 1 + README.md | 12 +- driver_alsa_linux.go | 257 +++++++++++++++++++++++++++++++++++++ driver_linux.go | 102 +++++++++++++++ driver_linux_nocgo.go | 81 ++++++++++++ driver_pulseaudio_linux.go | 137 ++++++++++++++++++++ driver_unix.go | 2 +- go.mod | 1 + go.sum | 2 + 9 files changed, 591 insertions(+), 4 deletions(-) create mode 100644 driver_alsa_linux.go create mode 100644 driver_linux.go create mode 100644 driver_linux_nocgo.go create mode 100644 driver_pulseaudio_linux.go diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index ed81710..25e4cff 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -42,6 +42,7 @@ jobs: - name: go build run: | go build -v ./... + env CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -v ./... # Compile without optimization to check potential stack overflow. # The option '-gcflags=all=-N -l' is often used at Visual Studio Code. # See also https://go.googlesource.com/vscode-go/+/HEAD/docs/debugging.md#launch and the issue hajimehoshi/ebiten#2120. diff --git a/README.md b/README.md index 6d0baaa..f535916 100644 --- a/README.md +++ b/README.md @@ -22,7 +22,7 @@ A low-level library to play sound. - Windows (no Cgo required!) - macOS (no Cgo required!) -- Linux +- Linux (PulseAudio, no Cgo required with `CGO_ENABLED=0`) - FreeBSD - OpenBSD - Android @@ -37,7 +37,7 @@ On some platforms you will need a C/C++ compiler in your path that Go can use. - iOS: On newer macOS versions type `clang` on your terminal and a dialog with installation instructions will appear if you don't have it - If you get an error with clang use xcode instead `xcode-select --install` -- Linux and other Unix systems: Should be installed by default, but if not try [GCC](https://gcc.gnu.org/) or [Clang](https://releases.llvm.org/download.html) +- Linux and other Unix systems with Cgo enabled: Should be installed by default, but if not try [GCC](https://gcc.gnu.org/) or [Clang](https://releases.llvm.org/download.html) ### macOS @@ -54,7 +54,13 @@ Add them to "Linked Frameworks and Libraries" on your Xcode project. ### Linux -ALSA is required. On Ubuntu or Debian, run this command: +Oto prefers PulseAudio on Linux via the pure-Go package `github.com/jfreymuth/pulse`. +This backend does not require Cgo or PulseAudio development headers. + +If you build with `CGO_ENABLED=0`, Oto uses the PulseAudio backend only. +If you build with Cgo enabled, Oto also compiles an ALSA fallback backend. + +For Cgo-enabled builds, ALSA development headers are required. On Ubuntu or Debian, run this command: ```sh apt install libasound2-dev diff --git a/driver_alsa_linux.go b/driver_alsa_linux.go new file mode 100644 index 0000000..f9f239c --- /dev/null +++ b/driver_alsa_linux.go @@ -0,0 +1,257 @@ +// Copyright 2026 The Oto Authors +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +//go:build linux && !android && cgo + +package oto + +// #cgo pkg-config: alsa +// +// #include +import "C" + +import ( + "fmt" + "strings" + "sync" + "unsafe" + + "github.com/ebitengine/oto/v3/internal/mux" +) + +type alsaContext struct { + channelCount int + + suspended bool + + handle *C.snd_pcm_t + + cond *sync.Cond + + mux *mux.Mux + err atomicError +} + +func alsaError(name string, err C.int) error { + return fmt.Errorf("oto: ALSA error at %s: %s", name, C.GoString(C.snd_strerror(err))) +} + +func deviceCandidates() []string { + const getAllDevices = -1 + + cPCMInterfaceName := C.CString("pcm") + defer C.free(unsafe.Pointer(cPCMInterfaceName)) + + var hints *unsafe.Pointer + err := C.snd_device_name_hint(getAllDevices, cPCMInterfaceName, &hints) + if err != 0 { + return []string{"default", "plug:default"} + } + defer C.snd_device_name_free_hint(hints) + + var devices []string + + cIoHintName := C.CString("IOID") + defer C.free(unsafe.Pointer(cIoHintName)) + cNameHintName := C.CString("NAME") + defer C.free(unsafe.Pointer(cNameHintName)) + + for it := hints; *it != nil; it = (*unsafe.Pointer)(unsafe.Pointer(uintptr(unsafe.Pointer(it)) + unsafe.Sizeof(uintptr(0)))) { + io := C.snd_device_name_get_hint(*it, cIoHintName) + defer func() { + if io != nil { + C.free(unsafe.Pointer(io)) + } + }() + if C.GoString(io) == "Input" { + continue + } + + name := C.snd_device_name_get_hint(*it, cNameHintName) + defer func() { + if name != nil { + C.free(unsafe.Pointer(name)) + } + }() + if name == nil { + continue + } + goName := C.GoString(name) + if goName == "null" { + continue + } + if goName == "default" { + continue + } + devices = append(devices, goName) + } + + devices = append([]string{"default", "plug:default"}, devices...) + + return devices +} + +func newALSAContext(sampleRate int, channelCount int, mux *mux.Mux, bufferSizeInBytes int) (*alsaContext, error) { + c := &alsaContext{ + channelCount: channelCount, + cond: sync.NewCond(&sync.Mutex{}), + mux: mux, + } + + type openError struct { + device string + err C.int + } + var openErrs []openError + var found bool + + for _, name := range deviceCandidates() { + cname := C.CString(name) + defer C.free(unsafe.Pointer(cname)) + if err := C.snd_pcm_open(&c.handle, cname, C.SND_PCM_STREAM_PLAYBACK, 0); err < 0 { + openErrs = append(openErrs, openError{ + device: name, + err: err, + }) + continue + } + found = true + break + } + if !found { + var msgs []string + for _, e := range openErrs { + msgs = append(msgs, fmt.Sprintf("%q: %s", e.device, C.GoString(C.snd_strerror(e.err)))) + } + return nil, fmt.Errorf("oto: ALSA error at snd_pcm_open: %s", strings.Join(msgs, ", ")) + } + + const periods = 2 + var periodSize C.snd_pcm_uframes_t + if bufferSizeInBytes != 0 { + periodSize = C.snd_pcm_uframes_t(bufferSizeInBytes / (channelCount * 4 * periods)) + } else { + periodSize = C.snd_pcm_uframes_t(1024) + } + bufferSize := periodSize * periods + if err := c.alsaPcmHwParams(sampleRate, channelCount, &bufferSize, &periodSize); err != nil { + return nil, err + } + + go func() { + buf32 := make([]float32, int(periodSize)*channelCount) + for { + if !c.readAndWrite(buf32) { + return + } + } + }() + + return c, nil +} + +func (c *alsaContext) alsaPcmHwParams(sampleRate, channelCount int, bufferSize, periodSize *C.snd_pcm_uframes_t) error { + var params *C.snd_pcm_hw_params_t + C.snd_pcm_hw_params_malloc(¶ms) + defer C.free(unsafe.Pointer(params)) + + if err := C.snd_pcm_hw_params_any(c.handle, params); err < 0 { + return alsaError("snd_pcm_hw_params_any", err) + } + if err := C.snd_pcm_hw_params_set_access(c.handle, params, C.SND_PCM_ACCESS_RW_INTERLEAVED); err < 0 { + return alsaError("snd_pcm_hw_params_set_access", err) + } + if err := C.snd_pcm_hw_params_set_format(c.handle, params, C.SND_PCM_FORMAT_FLOAT_LE); err < 0 { + return alsaError("snd_pcm_hw_params_set_format", err) + } + if err := C.snd_pcm_hw_params_set_channels(c.handle, params, C.unsigned(channelCount)); err < 0 { + return alsaError("snd_pcm_hw_params_set_channels", err) + } + if err := C.snd_pcm_hw_params_set_rate_resample(c.handle, params, 1); err < 0 { + return alsaError("snd_pcm_hw_params_set_rate_resample", err) + } + sr := C.unsigned(sampleRate) + if err := C.snd_pcm_hw_params_set_rate_near(c.handle, params, &sr, nil); err < 0 { + return alsaError("snd_pcm_hw_params_set_rate_near", err) + } + if err := C.snd_pcm_hw_params_set_buffer_size_near(c.handle, params, bufferSize); err < 0 { + return alsaError("snd_pcm_hw_params_set_buffer_size_near", err) + } + if err := C.snd_pcm_hw_params_set_period_size_near(c.handle, params, periodSize, nil); err < 0 { + return alsaError("snd_pcm_hw_params_set_period_size_near", err) + } + if err := C.snd_pcm_hw_params(c.handle, params); err < 0 { + return alsaError("snd_pcm_hw_params", err) + } + return nil +} + +func (c *alsaContext) readAndWrite(buf32 []float32) bool { + c.cond.L.Lock() + defer c.cond.L.Unlock() + + for c.suspended && c.err.Load() == nil { + c.cond.Wait() + } + if err := c.err.Load(); err != nil { + return false + } + + c.mux.ReadFloat32s(buf32) + + for len(buf32) > 0 { + n := C.snd_pcm_writei(c.handle, unsafe.Pointer(&buf32[0]), C.snd_pcm_uframes_t(len(buf32)/c.channelCount)) + if n < 0 { + n = C.long(C.snd_pcm_recover(c.handle, C.int(n), 1)) + } + if n < 0 { + c.err.TryStore(alsaError("snd_pcm_writei or snd_pcm_recover", C.int(n))) + return false + } + buf32 = buf32[int(n)*c.channelCount:] + } + return true +} + +func (c *alsaContext) Suspend() error { + c.cond.L.Lock() + defer c.cond.L.Unlock() + + if err := c.err.Load(); err != nil { + return err + } + + c.suspended = true + return nil +} + +func (c *alsaContext) Resume() error { + c.cond.L.Lock() + defer c.cond.L.Unlock() + + if err := c.err.Load(); err != nil { + return err + } + + c.suspended = false + c.cond.Signal() + return nil +} + +func (c *alsaContext) Err() error { + if err := c.err.Load(); err != nil { + return err + } + return nil +} diff --git a/driver_linux.go b/driver_linux.go new file mode 100644 index 0000000..b84bf10 --- /dev/null +++ b/driver_linux.go @@ -0,0 +1,102 @@ +// Copyright 2026 The Oto Authors +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +//go:build linux && !android && cgo + +package oto + +import ( + "fmt" + + "github.com/ebitengine/oto/v3/internal/mux" +) + +type context struct { + pulseAudioContext *pulseAudioContext + alsaContext *alsaContext + + ready chan struct{} + err atomicError + + mux *mux.Mux +} + +func newContext(sampleRate int, channelCount int, format mux.Format, bufferSizeInBytes int) (*context, chan struct{}, error) { + ctx := &context{ + ready: make(chan struct{}), + mux: mux.New(sampleRate, channelCount, format), + } + + go func() { + defer close(ctx.ready) + + pc, err0 := newPulseAudioContext(sampleRate, channelCount, ctx.mux, bufferSizeInBytes) + if err0 == nil { + ctx.pulseAudioContext = pc + return + } + + ac, err1 := newALSAContext(sampleRate, channelCount, ctx.mux, bufferSizeInBytes) + if err1 == nil { + ctx.alsaContext = ac + return + } + + ctx.err.TryStore(fmt.Errorf("oto: initialization failed: PulseAudio: %v, ALSA: %v", err0, err1)) + }() + + return ctx, ctx.ready, nil +} + +func (c *context) Suspend() error { + <-c.ready + if c.pulseAudioContext != nil { + return c.pulseAudioContext.Suspend() + } + if c.alsaContext != nil { + return c.alsaContext.Suspend() + } + return nil +} + +func (c *context) Resume() error { + <-c.ready + if c.pulseAudioContext != nil { + return c.pulseAudioContext.Resume() + } + if c.alsaContext != nil { + return c.alsaContext.Resume() + } + return nil +} + +func (c *context) Err() error { + if err := c.err.Load(); err != nil { + return err + } + + select { + case <-c.ready: + default: + return nil + } + + if c.pulseAudioContext != nil { + return c.pulseAudioContext.Err() + } + if c.alsaContext != nil { + return c.alsaContext.Err() + } + return nil +} diff --git a/driver_linux_nocgo.go b/driver_linux_nocgo.go new file mode 100644 index 0000000..3f5b7be --- /dev/null +++ b/driver_linux_nocgo.go @@ -0,0 +1,81 @@ +// Copyright 2026 The Oto Authors +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +//go:build linux && !android && !cgo + +package oto + +import "github.com/ebitengine/oto/v3/internal/mux" + +type context struct { + pulseAudioContext *pulseAudioContext + + ready chan struct{} + err atomicError + + mux *mux.Mux +} + +func newContext(sampleRate int, channelCount int, format mux.Format, bufferSizeInBytes int) (*context, chan struct{}, error) { + ctx := &context{ + ready: make(chan struct{}), + mux: mux.New(sampleRate, channelCount, format), + } + + go func() { + defer close(ctx.ready) + + pc, err := newPulseAudioContext(sampleRate, channelCount, ctx.mux, bufferSizeInBytes) + if err != nil { + ctx.err.TryStore(err) + return + } + ctx.pulseAudioContext = pc + }() + + return ctx, ctx.ready, nil +} + +func (c *context) Suspend() error { + <-c.ready + if c.pulseAudioContext != nil { + return c.pulseAudioContext.Suspend() + } + return nil +} + +func (c *context) Resume() error { + <-c.ready + if c.pulseAudioContext != nil { + return c.pulseAudioContext.Resume() + } + return nil +} + +func (c *context) Err() error { + if err := c.err.Load(); err != nil { + return err + } + + select { + case <-c.ready: + default: + return nil + } + + if c.pulseAudioContext != nil { + return c.pulseAudioContext.Err() + } + return nil +} diff --git a/driver_pulseaudio_linux.go b/driver_pulseaudio_linux.go new file mode 100644 index 0000000..3dd227c --- /dev/null +++ b/driver_pulseaudio_linux.go @@ -0,0 +1,137 @@ +// Copyright 2026 The Oto Authors +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +//go:build linux && !android + +package oto + +import ( + "fmt" + "sync" + + "github.com/ebitengine/oto/v3/internal/mux" + "github.com/jfreymuth/pulse" +) + +type pulseAudioContext struct { + client *pulse.Client + stream *pulse.PlaybackStream + + suspended bool + cond *sync.Cond + + mux *mux.Mux + err atomicError +} + +func newPulseAudioContext(sampleRate int, channelCount int, mux *mux.Mux, bufferSizeInBytes int) (*pulseAudioContext, error) { + c := &pulseAudioContext{ + cond: sync.NewCond(&sync.Mutex{}), + mux: mux, + } + + client, err := pulse.NewClient(pulse.ClientApplicationName("Oto")) + if err != nil { + return nil, fmt.Errorf("oto: PulseAudio client initialization failed: %w", err) + } + c.client = client + + options := []pulse.PlaybackOption{ + pulse.PlaybackMediaName("Oto"), + } + switch channelCount { + case 1: + options = append(options, pulse.PlaybackMono) + case 2: + options = append(options, pulse.PlaybackStereo) + default: + client.Close() + return nil, fmt.Errorf("oto: PulseAudio backend supports only mono or stereo output: %d", channelCount) + } + options = append(options, pulse.PlaybackSampleRate(sampleRate)) + if bufferSizeInBytes != 0 { + latency := float64(bufferSizeInBytes) / float64(sampleRate*channelCount*4) + if latency > 0 { + options = append(options, pulse.PlaybackLatency(latency)) + } + } + + stream, err := client.NewPlayback(pulse.Float32Reader(c.read), options...) + if err != nil { + client.Close() + return nil, fmt.Errorf("oto: PulseAudio playback initialization failed: %w", err) + } + c.stream = stream + c.stream.Start() + + return c, nil +} + +func (c *pulseAudioContext) read(buf []float32) (int, error) { + c.cond.L.Lock() + defer c.cond.L.Unlock() + + for c.suspended && c.err.Load() == nil { + c.cond.Wait() + } + if err := c.err.Load(); err != nil { + return 0, err + } + + c.mux.ReadFloat32s(buf) + return len(buf), nil +} + +func (c *pulseAudioContext) Suspend() error { + c.cond.L.Lock() + defer c.cond.L.Unlock() + + if err := c.err.Load(); err != nil { + return err + } + if err := c.stream.Error(); err != nil { + return fmt.Errorf("oto: PulseAudio error: %w", err) + } + + c.suspended = true + c.stream.Pause() + return nil +} + +func (c *pulseAudioContext) Resume() error { + c.cond.L.Lock() + defer c.cond.L.Unlock() + + if err := c.err.Load(); err != nil { + return err + } + if err := c.stream.Error(); err != nil { + return fmt.Errorf("oto: PulseAudio error: %w", err) + } + + c.suspended = false + c.stream.Resume() + c.cond.Broadcast() + return nil +} + +func (c *pulseAudioContext) Err() error { + if err := c.err.Load(); err != nil { + return err + } + if err := c.stream.Error(); err != nil { + return fmt.Errorf("oto: PulseAudio error: %w", err) + } + return nil +} diff --git a/driver_unix.go b/driver_unix.go index 5b40195..06a0e4c 100644 --- a/driver_unix.go +++ b/driver_unix.go @@ -12,7 +12,7 @@ // See the License for the specific language governing permissions and // limitations under the License. -//go:build !android && !darwin && !js && !windows && !nintendosdk && !playstation5 +//go:build !android && !darwin && !js && !linux && !windows && !nintendosdk && !playstation5 package oto diff --git a/go.mod b/go.mod index 61a707a..8697dab 100644 --- a/go.mod +++ b/go.mod @@ -4,5 +4,6 @@ go 1.24.0 require ( github.com/ebitengine/purego v0.10.0 + github.com/jfreymuth/pulse v0.1.2-0.20241102120944-4ffb35054b53 golang.org/x/sys v0.41.0 ) diff --git a/go.sum b/go.sum index dfb249f..a77ff33 100644 --- a/go.sum +++ b/go.sum @@ -1,4 +1,6 @@ github.com/ebitengine/purego v0.10.0 h1:QIw4xfpWT6GWTzaW5XEKy3HXoqrJGx1ijYHzTF0/ISU= github.com/ebitengine/purego v0.10.0/go.mod h1:iIjxzd6CiRiOG0UyXP+V1+jWqUXVjPKLAI0mRfJZTmQ= +github.com/jfreymuth/pulse v0.1.2-0.20241102120944-4ffb35054b53 h1:bwsfDCV1qoqA3ooZfP6zvNr5RCjYRxItKODBiJzOQOc= +github.com/jfreymuth/pulse v0.1.2-0.20241102120944-4ffb35054b53/go.mod h1:cpYspI6YljhkUf1WLXLLDmeaaPFc3CnGLjDZf9dZ4no= golang.org/x/sys v0.41.0 h1:Ivj+2Cp/ylzLiEU89QhWblYnOE9zerudt9Ftecq2C6k= golang.org/x/sys v0.41.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= From 7b4d8e44b171daa6586f71c4aef7f9a7ca79e4f7 Mon Sep 17 00:00:00 2001 From: Andrew Montgomery Date: Sat, 28 Mar 2026 14:13:24 +0000 Subject: [PATCH 02/14] feat: alsa first when cgo enabled, support client app name --- README.md | 9 ++++--- context.go | 6 ++++- driver_android.go | 2 +- driver_console.go | 2 +- driver_darwin.go | 2 +- driver_js.go | 2 +- driver_linux.go | 17 +++++++----- driver_linux_cgo_test.go | 54 ++++++++++++++++++++++++++++++++++++++ driver_linux_nocgo.go | 6 +++-- driver_linux_nocgo_test.go | 47 +++++++++++++++++++++++++++++++++ driver_pulseaudio_linux.go | 10 ++++--- driver_unix.go | 2 +- driver_windows.go | 2 +- 13 files changed, 139 insertions(+), 22 deletions(-) create mode 100644 driver_linux_cgo_test.go create mode 100644 driver_linux_nocgo_test.go diff --git a/README.md b/README.md index f535916..c2ec3a6 100644 --- a/README.md +++ b/README.md @@ -22,7 +22,7 @@ A low-level library to play sound. - Windows (no Cgo required!) - macOS (no Cgo required!) -- Linux (PulseAudio, no Cgo required with `CGO_ENABLED=0`) +- Linux (ALSA by default with Cgo, PulseAudio with `CGO_ENABLED=0`) - FreeBSD - OpenBSD - Android @@ -54,11 +54,12 @@ Add them to "Linked Frameworks and Libraries" on your Xcode project. ### Linux -Oto prefers PulseAudio on Linux via the pure-Go package `github.com/jfreymuth/pulse`. -This backend does not require Cgo or PulseAudio development headers. +Oto uses ALSA by default on Linux when built with Cgo enabled. +It falls back to PulseAudio via the pure-Go package `github.com/jfreymuth/pulse` if ALSA initialization fails. +The PulseAudio backend does not require Cgo or PulseAudio development headers. If you build with `CGO_ENABLED=0`, Oto uses the PulseAudio backend only. -If you build with Cgo enabled, Oto also compiles an ALSA fallback backend. +If you build with Cgo enabled, Oto tries ALSA first and then falls back to PulseAudio. For Cgo-enabled builds, ALSA development headers are required. On Ubuntu or Debian, run this command: diff --git a/context.go b/context.go index c6c9931..2c05fb7 100644 --- a/context.go +++ b/context.go @@ -73,6 +73,10 @@ type NewContextOptions struct { // Too big buffer size can increase the latency time. // On the other hand, too small buffer size can cause glitch noises due to buffer shortage. BufferSize time.Duration + + // ClientApplicationName specifies the name of the client application. + // It is used for PulseAudio's volume control UI and so on. + ClientApplicationName string } // NewContext creates a new context with given options. @@ -97,7 +101,7 @@ func NewContext(options *NewContextOptions) (*Context, chan struct{}, error) { bufferSizeInBytes = int(int64(options.BufferSize) * int64(bytesPerSecond) / int64(time.Second)) bufferSizeInBytes = bufferSizeInBytes / bytesPerSample * bytesPerSample } - ctx, ready, err := newContext(options.SampleRate, options.ChannelCount, mux.Format(options.Format), bufferSizeInBytes) + ctx, ready, err := newContext(options.SampleRate, options.ChannelCount, mux.Format(options.Format), bufferSizeInBytes, options.ClientApplicationName) if err != nil { return nil, nil, err } diff --git a/driver_android.go b/driver_android.go index 52e33f5..e722046 100644 --- a/driver_android.go +++ b/driver_android.go @@ -23,7 +23,7 @@ type context struct { mux *mux.Mux } -func newContext(sampleRate int, channelCount int, format mux.Format, bufferSizeInBytes int) (*context, chan struct{}, error) { +func newContext(sampleRate int, channelCount int, format mux.Format, bufferSizeInBytes int, _ string) (*context, chan struct{}, error) { ready := make(chan struct{}) close(ready) diff --git a/driver_console.go b/driver_console.go index 3372766..76adb04 100644 --- a/driver_console.go +++ b/driver_console.go @@ -46,7 +46,7 @@ type context struct { var theContext *context -func newContext(sampleRate int, channelCount int, format mux.Format, bufferSizeInBytes int) (*context, chan struct{}, error) { +func newContext(sampleRate int, channelCount int, format mux.Format, bufferSizeInBytes int, _ string) (*context, chan struct{}, error) { ready := make(chan struct{}) close(ready) diff --git a/driver_darwin.go b/driver_darwin.go index bd1f575..4cc2552 100644 --- a/driver_darwin.go +++ b/driver_darwin.go @@ -89,7 +89,7 @@ type context struct { var theContext *context -func newContext(sampleRate int, channelCount int, format mux.Format, bufferSizeInBytes int) (*context, chan struct{}, error) { +func newContext(sampleRate int, channelCount int, format mux.Format, bufferSizeInBytes int, _ string) (*context, chan struct{}, error) { // defaultOneBufferSizeInBytes is the default buffer size in bytes. // // 12288 seems necessary at least on iPod touch (7th) and MacBook Pro 2020. diff --git a/driver_js.go b/driver_js.go index e1888d7..14163a8 100644 --- a/driver_js.go +++ b/driver_js.go @@ -33,7 +33,7 @@ type context struct { mux *mux.Mux } -func newContext(sampleRate int, channelCount int, format mux.Format, bufferSizeInBytes int) (*context, chan struct{}, error) { +func newContext(sampleRate int, channelCount int, format mux.Format, bufferSizeInBytes int, _ string) (*context, chan struct{}, error) { ready := make(chan struct{}) class := js.Global().Get("AudioContext") diff --git a/driver_linux.go b/driver_linux.go index b84bf10..11ed8e6 100644 --- a/driver_linux.go +++ b/driver_linux.go @@ -22,6 +22,11 @@ import ( "github.com/ebitengine/oto/v3/internal/mux" ) +var ( + newPulseAudioContextFunc = newPulseAudioContext + newALSAContextFunc = newALSAContext +) + type context struct { pulseAudioContext *pulseAudioContext alsaContext *alsaContext @@ -32,7 +37,7 @@ type context struct { mux *mux.Mux } -func newContext(sampleRate int, channelCount int, format mux.Format, bufferSizeInBytes int) (*context, chan struct{}, error) { +func newContext(sampleRate int, channelCount int, format mux.Format, bufferSizeInBytes int, clientApplicationName string) (*context, chan struct{}, error) { ctx := &context{ ready: make(chan struct{}), mux: mux.New(sampleRate, channelCount, format), @@ -41,19 +46,19 @@ func newContext(sampleRate int, channelCount int, format mux.Format, bufferSizeI go func() { defer close(ctx.ready) - pc, err0 := newPulseAudioContext(sampleRate, channelCount, ctx.mux, bufferSizeInBytes) + ac, err0 := newALSAContextFunc(sampleRate, channelCount, ctx.mux, bufferSizeInBytes) if err0 == nil { - ctx.pulseAudioContext = pc + ctx.alsaContext = ac return } - ac, err1 := newALSAContext(sampleRate, channelCount, ctx.mux, bufferSizeInBytes) + pc, err1 := newPulseAudioContextFunc(sampleRate, channelCount, ctx.mux, bufferSizeInBytes, clientApplicationName) if err1 == nil { - ctx.alsaContext = ac + ctx.pulseAudioContext = pc return } - ctx.err.TryStore(fmt.Errorf("oto: initialization failed: PulseAudio: %v, ALSA: %v", err0, err1)) + ctx.err.TryStore(fmt.Errorf("oto: initialization failed: ALSA: %v, PulseAudio: %v", err0, err1)) }() return ctx, ctx.ready, nil diff --git a/driver_linux_cgo_test.go b/driver_linux_cgo_test.go new file mode 100644 index 0000000..db419e6 --- /dev/null +++ b/driver_linux_cgo_test.go @@ -0,0 +1,54 @@ +// Copyright 2026 The Oto Authors +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +//go:build linux && !android && cgo + +package oto + +import ( + "errors" + "reflect" + "testing" + + "github.com/ebitengine/oto/v3/internal/mux" +) + +func TestNewContextPrefersALSAOnLinuxWithCgo(t *testing.T) { + originalALSA := newALSAContextFunc + originalPulseAudio := newPulseAudioContextFunc + t.Cleanup(func() { + newALSAContextFunc = originalALSA + newPulseAudioContextFunc = originalPulseAudio + }) + + var order []string + newALSAContextFunc = func(sampleRate int, channelCount int, mux *mux.Mux, bufferSizeInBytes int) (*alsaContext, error) { + order = append(order, "alsa") + return nil, errors.New("alsa failed") + } + newPulseAudioContextFunc = func(sampleRate int, channelCount int, mux *mux.Mux, bufferSizeInBytes int) (*pulseAudioContext, error) { + order = append(order, "pulse") + return &pulseAudioContext{}, nil + } + + _, ready, err := newContext(48000, 2, mux.FormatFloat32LE, 0) + if err != nil { + t.Fatalf("newContext failed: %v", err) + } + <-ready + + if got, want := order, []string{"alsa", "pulse"}; !reflect.DeepEqual(got, want) { + t.Fatalf("backend selection order = %v, want %v", got, want) + } +} diff --git a/driver_linux_nocgo.go b/driver_linux_nocgo.go index 3f5b7be..85f4e73 100644 --- a/driver_linux_nocgo.go +++ b/driver_linux_nocgo.go @@ -18,6 +18,8 @@ package oto import "github.com/ebitengine/oto/v3/internal/mux" +var newPulseAudioContextFunc = newPulseAudioContext + type context struct { pulseAudioContext *pulseAudioContext @@ -27,7 +29,7 @@ type context struct { mux *mux.Mux } -func newContext(sampleRate int, channelCount int, format mux.Format, bufferSizeInBytes int) (*context, chan struct{}, error) { +func newContext(sampleRate int, channelCount int, format mux.Format, bufferSizeInBytes int, clientApplicationName string) (*context, chan struct{}, error) { ctx := &context{ ready: make(chan struct{}), mux: mux.New(sampleRate, channelCount, format), @@ -36,7 +38,7 @@ func newContext(sampleRate int, channelCount int, format mux.Format, bufferSizeI go func() { defer close(ctx.ready) - pc, err := newPulseAudioContext(sampleRate, channelCount, ctx.mux, bufferSizeInBytes) + pc, err := newPulseAudioContextFunc(sampleRate, channelCount, ctx.mux, bufferSizeInBytes, clientApplicationName) if err != nil { ctx.err.TryStore(err) return diff --git a/driver_linux_nocgo_test.go b/driver_linux_nocgo_test.go new file mode 100644 index 0000000..b3799b7 --- /dev/null +++ b/driver_linux_nocgo_test.go @@ -0,0 +1,47 @@ +// Copyright 2026 The Oto Authors +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +//go:build linux && !android && !cgo + +package oto + +import ( + "reflect" + "testing" + + "github.com/ebitengine/oto/v3/internal/mux" +) + +func TestNewContextUsesPulseAudioOnlyOnLinuxWithoutCgo(t *testing.T) { + originalPulseAudio := newPulseAudioContextFunc + t.Cleanup(func() { + newPulseAudioContextFunc = originalPulseAudio + }) + + var order []string + newPulseAudioContextFunc = func(sampleRate int, channelCount int, mux *mux.Mux, bufferSizeInBytes int, clientApplicationName string) (*pulseAudioContext, error) { + order = append(order, "pulse") + return &pulseAudioContext{}, nil + } + + _, ready, err := newContext(48000, 2, mux.FormatFloat32LE, 0, "Oto") + if err != nil { + t.Fatalf("newContext failed: %v", err) + } + <-ready + + if got, want := order, []string{"pulse"}; !reflect.DeepEqual(got, want) { + t.Fatalf("backend selection order = %v, want %v", got, want) + } +} diff --git a/driver_pulseaudio_linux.go b/driver_pulseaudio_linux.go index 3dd227c..1b439d0 100644 --- a/driver_pulseaudio_linux.go +++ b/driver_pulseaudio_linux.go @@ -35,20 +35,24 @@ type pulseAudioContext struct { err atomicError } -func newPulseAudioContext(sampleRate int, channelCount int, mux *mux.Mux, bufferSizeInBytes int) (*pulseAudioContext, error) { +func newPulseAudioContext(sampleRate int, channelCount int, mux *mux.Mux, bufferSizeInBytes int, clientApplicationName string) (*pulseAudioContext, error) { c := &pulseAudioContext{ cond: sync.NewCond(&sync.Mutex{}), mux: mux, } - client, err := pulse.NewClient(pulse.ClientApplicationName("Oto")) + if clientApplicationName == "" { + clientApplicationName = "Oto" + } + + client, err := pulse.NewClient(pulse.ClientApplicationName(clientApplicationName)) if err != nil { return nil, fmt.Errorf("oto: PulseAudio client initialization failed: %w", err) } c.client = client options := []pulse.PlaybackOption{ - pulse.PlaybackMediaName("Oto"), + pulse.PlaybackMediaName(clientApplicationName), } switch channelCount { case 1: diff --git a/driver_unix.go b/driver_unix.go index 06a0e4c..0aa3569 100644 --- a/driver_unix.go +++ b/driver_unix.go @@ -106,7 +106,7 @@ func deviceCandidates() []string { return devices } -func newContext(sampleRate int, channelCount int, format mux.Format, bufferSizeInBytes int) (*context, chan struct{}, error) { +func newContext(sampleRate int, channelCount int, format mux.Format, bufferSizeInBytes int, _ string) (*context, chan struct{}, error) { c := &context{ channelCount: channelCount, cond: sync.NewCond(&sync.Mutex{}), diff --git a/driver_windows.go b/driver_windows.go index 1a5abe1..0f27216 100644 --- a/driver_windows.go +++ b/driver_windows.go @@ -38,7 +38,7 @@ type context struct { err atomicError } -func newContext(sampleRate int, channelCount int, format mux.Format, bufferSizeInBytes int) (*context, chan struct{}, error) { +func newContext(sampleRate int, channelCount int, format mux.Format, bufferSizeInBytes int, _ string) (*context, chan struct{}, error) { ctx := &context{ sampleRate: sampleRate, channelCount: channelCount, From 052ceda619b033bdeb7520296df3ed65d3444c3f Mon Sep 17 00:00:00 2001 From: Andrew Montgomery Date: Sat, 28 Mar 2026 16:56:41 +0000 Subject: [PATCH 03/14] refactor: remove ALSA, enable pulseaudio on all supported platforms --- README.md | 38 +-- driver_alsa_linux.go | 257 ----------------- driver_darwin.go | 92 +++--- driver_linux.go | 107 ------- driver_linux_cgo_test.go | 54 ---- driver_linux_nocgo.go | 83 ------ driver_linux_nocgo_test.go | 47 ---- ...ulseaudio_linux.go => driver_pulseaudio.go | 66 ++++- driver_unix.go | 263 +----------------- driver_windows.go | 28 +- 10 files changed, 166 insertions(+), 869 deletions(-) delete mode 100644 driver_alsa_linux.go delete mode 100644 driver_linux.go delete mode 100644 driver_linux_cgo_test.go delete mode 100644 driver_linux_nocgo.go delete mode 100644 driver_linux_nocgo_test.go rename driver_pulseaudio_linux.go => driver_pulseaudio.go (68%) diff --git a/README.md b/README.md index c2ec3a6..5450238 100644 --- a/README.md +++ b/README.md @@ -20,9 +20,9 @@ A low-level library to play sound. ## Platforms -- Windows (no Cgo required!) -- macOS (no Cgo required!) -- Linux (ALSA by default with Cgo, PulseAudio with `CGO_ENABLED=0`) +- Windows (no Cgo required; PulseAudio fallback available) +- macOS (no Cgo required; PulseAudio fallback available) +- Linux (PulseAudio) - FreeBSD - OpenBSD - Android @@ -37,7 +37,7 @@ On some platforms you will need a C/C++ compiler in your path that Go can use. - iOS: On newer macOS versions type `clang` on your terminal and a dialog with installation instructions will appear if you don't have it - If you get an error with clang use xcode instead `xcode-select --install` -- Linux and other Unix systems with Cgo enabled: Should be installed by default, but if not try [GCC](https://gcc.gnu.org/) or [Clang](https://releases.llvm.org/download.html) +- BSD and console targets may still need a working C/C++ toolchain; if not installed, try [GCC](https://gcc.gnu.org/) or [Clang](https://releases.llvm.org/download.html) ### macOS @@ -54,30 +54,18 @@ Add them to "Linked Frameworks and Libraries" on your Xcode project. ### Linux -Oto uses ALSA by default on Linux when built with Cgo enabled. -It falls back to PulseAudio via the pure-Go package `github.com/jfreymuth/pulse` if ALSA initialization fails. -The PulseAudio backend does not require Cgo or PulseAudio development headers. - -If you build with `CGO_ENABLED=0`, Oto uses the PulseAudio backend only. -If you build with Cgo enabled, Oto tries ALSA first and then falls back to PulseAudio. - -For Cgo-enabled builds, ALSA development headers are required. On Ubuntu or Debian, run this command: - -```sh -apt install libasound2-dev -``` - -On RedHat-based linux distributions, run: - -```sh -dnf install alsa-lib-devel -``` - -In most cases this command must be run by root user or through `sudo` command. +Oto uses PulseAudio on Linux via the pure-Go package `github.com/jfreymuth/pulse`. +This backend does not require Cgo or PulseAudio development headers, but it does require +access to a PulseAudio-compatible server. ### FreeBSD, OpenBSD -BSD systems are not tested well. If ALSA works, Oto should work. +BSD systems are not tested well. Oto uses the same PulseAudio backend there. If the server +is not discoverable automatically, set `PULSE_SERVER`. + +On macOS and Windows, Oto still prefers the native backend first and falls back to PulseAudio +if native initialization fails. On Windows, set `PULSE_SERVER` when you want to target a +PulseAudio server explicitly. ## Usage diff --git a/driver_alsa_linux.go b/driver_alsa_linux.go deleted file mode 100644 index f9f239c..0000000 --- a/driver_alsa_linux.go +++ /dev/null @@ -1,257 +0,0 @@ -// Copyright 2026 The Oto Authors -// -// Licensed under the Apache License, Version 2.0 (the "License"); -// you may not use this file except in compliance with the License. -// You may obtain a copy of the License at -// -// http://www.apache.org/licenses/LICENSE-2.0 -// -// Unless required by applicable law or agreed to in writing, software -// distributed under the License is distributed on an "AS IS" BASIS, -// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -// See the License for the specific language governing permissions and -// limitations under the License. - -//go:build linux && !android && cgo - -package oto - -// #cgo pkg-config: alsa -// -// #include -import "C" - -import ( - "fmt" - "strings" - "sync" - "unsafe" - - "github.com/ebitengine/oto/v3/internal/mux" -) - -type alsaContext struct { - channelCount int - - suspended bool - - handle *C.snd_pcm_t - - cond *sync.Cond - - mux *mux.Mux - err atomicError -} - -func alsaError(name string, err C.int) error { - return fmt.Errorf("oto: ALSA error at %s: %s", name, C.GoString(C.snd_strerror(err))) -} - -func deviceCandidates() []string { - const getAllDevices = -1 - - cPCMInterfaceName := C.CString("pcm") - defer C.free(unsafe.Pointer(cPCMInterfaceName)) - - var hints *unsafe.Pointer - err := C.snd_device_name_hint(getAllDevices, cPCMInterfaceName, &hints) - if err != 0 { - return []string{"default", "plug:default"} - } - defer C.snd_device_name_free_hint(hints) - - var devices []string - - cIoHintName := C.CString("IOID") - defer C.free(unsafe.Pointer(cIoHintName)) - cNameHintName := C.CString("NAME") - defer C.free(unsafe.Pointer(cNameHintName)) - - for it := hints; *it != nil; it = (*unsafe.Pointer)(unsafe.Pointer(uintptr(unsafe.Pointer(it)) + unsafe.Sizeof(uintptr(0)))) { - io := C.snd_device_name_get_hint(*it, cIoHintName) - defer func() { - if io != nil { - C.free(unsafe.Pointer(io)) - } - }() - if C.GoString(io) == "Input" { - continue - } - - name := C.snd_device_name_get_hint(*it, cNameHintName) - defer func() { - if name != nil { - C.free(unsafe.Pointer(name)) - } - }() - if name == nil { - continue - } - goName := C.GoString(name) - if goName == "null" { - continue - } - if goName == "default" { - continue - } - devices = append(devices, goName) - } - - devices = append([]string{"default", "plug:default"}, devices...) - - return devices -} - -func newALSAContext(sampleRate int, channelCount int, mux *mux.Mux, bufferSizeInBytes int) (*alsaContext, error) { - c := &alsaContext{ - channelCount: channelCount, - cond: sync.NewCond(&sync.Mutex{}), - mux: mux, - } - - type openError struct { - device string - err C.int - } - var openErrs []openError - var found bool - - for _, name := range deviceCandidates() { - cname := C.CString(name) - defer C.free(unsafe.Pointer(cname)) - if err := C.snd_pcm_open(&c.handle, cname, C.SND_PCM_STREAM_PLAYBACK, 0); err < 0 { - openErrs = append(openErrs, openError{ - device: name, - err: err, - }) - continue - } - found = true - break - } - if !found { - var msgs []string - for _, e := range openErrs { - msgs = append(msgs, fmt.Sprintf("%q: %s", e.device, C.GoString(C.snd_strerror(e.err)))) - } - return nil, fmt.Errorf("oto: ALSA error at snd_pcm_open: %s", strings.Join(msgs, ", ")) - } - - const periods = 2 - var periodSize C.snd_pcm_uframes_t - if bufferSizeInBytes != 0 { - periodSize = C.snd_pcm_uframes_t(bufferSizeInBytes / (channelCount * 4 * periods)) - } else { - periodSize = C.snd_pcm_uframes_t(1024) - } - bufferSize := periodSize * periods - if err := c.alsaPcmHwParams(sampleRate, channelCount, &bufferSize, &periodSize); err != nil { - return nil, err - } - - go func() { - buf32 := make([]float32, int(periodSize)*channelCount) - for { - if !c.readAndWrite(buf32) { - return - } - } - }() - - return c, nil -} - -func (c *alsaContext) alsaPcmHwParams(sampleRate, channelCount int, bufferSize, periodSize *C.snd_pcm_uframes_t) error { - var params *C.snd_pcm_hw_params_t - C.snd_pcm_hw_params_malloc(¶ms) - defer C.free(unsafe.Pointer(params)) - - if err := C.snd_pcm_hw_params_any(c.handle, params); err < 0 { - return alsaError("snd_pcm_hw_params_any", err) - } - if err := C.snd_pcm_hw_params_set_access(c.handle, params, C.SND_PCM_ACCESS_RW_INTERLEAVED); err < 0 { - return alsaError("snd_pcm_hw_params_set_access", err) - } - if err := C.snd_pcm_hw_params_set_format(c.handle, params, C.SND_PCM_FORMAT_FLOAT_LE); err < 0 { - return alsaError("snd_pcm_hw_params_set_format", err) - } - if err := C.snd_pcm_hw_params_set_channels(c.handle, params, C.unsigned(channelCount)); err < 0 { - return alsaError("snd_pcm_hw_params_set_channels", err) - } - if err := C.snd_pcm_hw_params_set_rate_resample(c.handle, params, 1); err < 0 { - return alsaError("snd_pcm_hw_params_set_rate_resample", err) - } - sr := C.unsigned(sampleRate) - if err := C.snd_pcm_hw_params_set_rate_near(c.handle, params, &sr, nil); err < 0 { - return alsaError("snd_pcm_hw_params_set_rate_near", err) - } - if err := C.snd_pcm_hw_params_set_buffer_size_near(c.handle, params, bufferSize); err < 0 { - return alsaError("snd_pcm_hw_params_set_buffer_size_near", err) - } - if err := C.snd_pcm_hw_params_set_period_size_near(c.handle, params, periodSize, nil); err < 0 { - return alsaError("snd_pcm_hw_params_set_period_size_near", err) - } - if err := C.snd_pcm_hw_params(c.handle, params); err < 0 { - return alsaError("snd_pcm_hw_params", err) - } - return nil -} - -func (c *alsaContext) readAndWrite(buf32 []float32) bool { - c.cond.L.Lock() - defer c.cond.L.Unlock() - - for c.suspended && c.err.Load() == nil { - c.cond.Wait() - } - if err := c.err.Load(); err != nil { - return false - } - - c.mux.ReadFloat32s(buf32) - - for len(buf32) > 0 { - n := C.snd_pcm_writei(c.handle, unsafe.Pointer(&buf32[0]), C.snd_pcm_uframes_t(len(buf32)/c.channelCount)) - if n < 0 { - n = C.long(C.snd_pcm_recover(c.handle, C.int(n), 1)) - } - if n < 0 { - c.err.TryStore(alsaError("snd_pcm_writei or snd_pcm_recover", C.int(n))) - return false - } - buf32 = buf32[int(n)*c.channelCount:] - } - return true -} - -func (c *alsaContext) Suspend() error { - c.cond.L.Lock() - defer c.cond.L.Unlock() - - if err := c.err.Load(); err != nil { - return err - } - - c.suspended = true - return nil -} - -func (c *alsaContext) Resume() error { - c.cond.L.Lock() - defer c.cond.L.Unlock() - - if err := c.err.Load(); err != nil { - return err - } - - c.suspended = false - c.cond.Signal() - return nil -} - -func (c *alsaContext) Err() error { - if err := c.err.Load(); err != nil { - return err - } - return nil -} diff --git a/driver_darwin.go b/driver_darwin.go index 4cc2552..68f151f 100644 --- a/driver_darwin.go +++ b/driver_darwin.go @@ -80,16 +80,18 @@ type context struct { toPause bool toResume bool - mux *mux.Mux - err atomicError + mux *mux.Mux + pulseAudioContext *pulseAudioContext + err atomicError } // TODO: Convert the error code correctly. // See https://stackoverflow.com/questions/2196869/how-do-you-convert-an-iphone-osstatus-code-to-something-useful var theContext *context +var newPulseAudioContextFunc pulseAudioContextFactory = newPulseAudioContext -func newContext(sampleRate int, channelCount int, format mux.Format, bufferSizeInBytes int, _ string) (*context, chan struct{}, error) { +func newContext(sampleRate int, channelCount int, format mux.Format, bufferSizeInBytes int, clientApplicationName string) (*context, chan struct{}, error) { // defaultOneBufferSizeInBytes is the default buffer size in bytes. // // 12288 seems necessary at least on iPod touch (7th) and MacBook Pro 2020. @@ -115,51 +117,58 @@ func newContext(sampleRate int, channelCount int, format mux.Format, bufferSizeI } theContext = c - if err := initializeAPI(); err != nil { - return nil, nil, err - } - go func() { - runtime.LockOSThread() - defer runtime.UnlockOSThread() - - var readyClosed bool - defer func() { - if !readyClosed { - close(ready) - } - }() - - q, bs, err := newAudioQueue(sampleRate, channelCount, oneBufferSizeInBytes) - if err != nil { - c.err.TryStore(err) + started, err := c.startAudioQueueContext(sampleRate, channelCount, oneBufferSizeInBytes, ready) + if started { return } - c.audioQueue = q - c.unqueuedBuffers = bs - - var retryCount int - try: - if osstatus := _AudioQueueStart(c.audioQueue, nil); osstatus != noErr { - if osstatus == avAudioSessionErrorCodeCannotStartPlaying && retryCount < 100 { - // TODO: use sleepTime() after investigating when this error happens. - time.Sleep(10 * time.Millisecond) - retryCount++ - goto try - } - c.err.TryStore(fmt.Errorf("oto: AudioQueueStart failed at newContext: %d", osstatus)) + + pc, pulseErr := newPulseAudioContextFunc(sampleRate, channelCount, c.mux, bufferSizeInBytes, clientApplicationName) + if pulseErr == nil { + c.pulseAudioContext = pc + close(ready) return } + c.err.TryStore(fmt.Errorf("oto: initialization failed: AudioQueue: %v, PulseAudio: %v", err, pulseErr)) close(ready) - readyClosed = true - - c.loop() }() return c, ready, nil } +func (c *context) startAudioQueueContext(sampleRate, channelCount, oneBufferSizeInBytes int, ready chan struct{}) (bool, error) { + if err := initializeAPI(); err != nil { + return false, err + } + + runtime.LockOSThread() + defer runtime.UnlockOSThread() + + q, bs, err := newAudioQueue(sampleRate, channelCount, oneBufferSizeInBytes) + if err != nil { + return false, err + } + c.audioQueue = q + c.unqueuedBuffers = bs + + var retryCount int +try: + if osstatus := _AudioQueueStart(c.audioQueue, nil); osstatus != noErr { + if osstatus == avAudioSessionErrorCodeCannotStartPlaying && retryCount < 100 { + // TODO: use sleepTime() after investigating when this error happens. + time.Sleep(10 * time.Millisecond) + retryCount++ + goto try + } + return false, fmt.Errorf("oto: AudioQueueStart failed at newContext: %d", osstatus) + } + + close(ready) + c.loop() + return true, nil +} + func (c *context) wait() bool { c.cond.L.Lock() defer c.cond.L.Unlock() @@ -217,6 +226,10 @@ func (c *context) appendBuffer(buf32 []float32) { } func (c *context) Suspend() error { + if c.pulseAudioContext != nil { + return c.pulseAudioContext.Suspend() + } + c.cond.L.Lock() defer c.cond.L.Unlock() @@ -230,6 +243,10 @@ func (c *context) Suspend() error { } func (c *context) Resume() error { + if c.pulseAudioContext != nil { + return c.pulseAudioContext.Resume() + } + c.cond.L.Lock() defer c.cond.L.Unlock() @@ -275,6 +292,9 @@ func (c *context) Err() error { if err := c.err.Load(); err != nil { return err.(error) } + if c.pulseAudioContext != nil { + return c.pulseAudioContext.Err() + } return nil } diff --git a/driver_linux.go b/driver_linux.go deleted file mode 100644 index 11ed8e6..0000000 --- a/driver_linux.go +++ /dev/null @@ -1,107 +0,0 @@ -// Copyright 2026 The Oto Authors -// -// Licensed under the Apache License, Version 2.0 (the "License"); -// you may not use this file except in compliance with the License. -// You may obtain a copy of the License at -// -// http://www.apache.org/licenses/LICENSE-2.0 -// -// Unless required by applicable law or agreed to in writing, software -// distributed under the License is distributed on an "AS IS" BASIS, -// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -// See the License for the specific language governing permissions and -// limitations under the License. - -//go:build linux && !android && cgo - -package oto - -import ( - "fmt" - - "github.com/ebitengine/oto/v3/internal/mux" -) - -var ( - newPulseAudioContextFunc = newPulseAudioContext - newALSAContextFunc = newALSAContext -) - -type context struct { - pulseAudioContext *pulseAudioContext - alsaContext *alsaContext - - ready chan struct{} - err atomicError - - mux *mux.Mux -} - -func newContext(sampleRate int, channelCount int, format mux.Format, bufferSizeInBytes int, clientApplicationName string) (*context, chan struct{}, error) { - ctx := &context{ - ready: make(chan struct{}), - mux: mux.New(sampleRate, channelCount, format), - } - - go func() { - defer close(ctx.ready) - - ac, err0 := newALSAContextFunc(sampleRate, channelCount, ctx.mux, bufferSizeInBytes) - if err0 == nil { - ctx.alsaContext = ac - return - } - - pc, err1 := newPulseAudioContextFunc(sampleRate, channelCount, ctx.mux, bufferSizeInBytes, clientApplicationName) - if err1 == nil { - ctx.pulseAudioContext = pc - return - } - - ctx.err.TryStore(fmt.Errorf("oto: initialization failed: ALSA: %v, PulseAudio: %v", err0, err1)) - }() - - return ctx, ctx.ready, nil -} - -func (c *context) Suspend() error { - <-c.ready - if c.pulseAudioContext != nil { - return c.pulseAudioContext.Suspend() - } - if c.alsaContext != nil { - return c.alsaContext.Suspend() - } - return nil -} - -func (c *context) Resume() error { - <-c.ready - if c.pulseAudioContext != nil { - return c.pulseAudioContext.Resume() - } - if c.alsaContext != nil { - return c.alsaContext.Resume() - } - return nil -} - -func (c *context) Err() error { - if err := c.err.Load(); err != nil { - return err - } - - select { - case <-c.ready: - default: - return nil - } - - if c.pulseAudioContext != nil { - return c.pulseAudioContext.Err() - } - if c.alsaContext != nil { - return c.alsaContext.Err() - } - return nil -} diff --git a/driver_linux_cgo_test.go b/driver_linux_cgo_test.go deleted file mode 100644 index db419e6..0000000 --- a/driver_linux_cgo_test.go +++ /dev/null @@ -1,54 +0,0 @@ -// Copyright 2026 The Oto Authors -// -// Licensed under the Apache License, Version 2.0 (the "License"); -// you may not use this file except in compliance with the License. -// You may obtain a copy of the License at -// -// http://www.apache.org/licenses/LICENSE-2.0 -// -// Unless required by applicable law or agreed to in writing, software -// distributed under the License is distributed on an "AS IS" BASIS, -// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -// See the License for the specific language governing permissions and -// limitations under the License. - -//go:build linux && !android && cgo - -package oto - -import ( - "errors" - "reflect" - "testing" - - "github.com/ebitengine/oto/v3/internal/mux" -) - -func TestNewContextPrefersALSAOnLinuxWithCgo(t *testing.T) { - originalALSA := newALSAContextFunc - originalPulseAudio := newPulseAudioContextFunc - t.Cleanup(func() { - newALSAContextFunc = originalALSA - newPulseAudioContextFunc = originalPulseAudio - }) - - var order []string - newALSAContextFunc = func(sampleRate int, channelCount int, mux *mux.Mux, bufferSizeInBytes int) (*alsaContext, error) { - order = append(order, "alsa") - return nil, errors.New("alsa failed") - } - newPulseAudioContextFunc = func(sampleRate int, channelCount int, mux *mux.Mux, bufferSizeInBytes int) (*pulseAudioContext, error) { - order = append(order, "pulse") - return &pulseAudioContext{}, nil - } - - _, ready, err := newContext(48000, 2, mux.FormatFloat32LE, 0) - if err != nil { - t.Fatalf("newContext failed: %v", err) - } - <-ready - - if got, want := order, []string{"alsa", "pulse"}; !reflect.DeepEqual(got, want) { - t.Fatalf("backend selection order = %v, want %v", got, want) - } -} diff --git a/driver_linux_nocgo.go b/driver_linux_nocgo.go deleted file mode 100644 index 85f4e73..0000000 --- a/driver_linux_nocgo.go +++ /dev/null @@ -1,83 +0,0 @@ -// Copyright 2026 The Oto Authors -// -// Licensed under the Apache License, Version 2.0 (the "License"); -// you may not use this file except in compliance with the License. -// You may obtain a copy of the License at -// -// http://www.apache.org/licenses/LICENSE-2.0 -// -// Unless required by applicable law or agreed to in writing, software -// distributed under the License is distributed on an "AS IS" BASIS, -// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -// See the License for the specific language governing permissions and -// limitations under the License. - -//go:build linux && !android && !cgo - -package oto - -import "github.com/ebitengine/oto/v3/internal/mux" - -var newPulseAudioContextFunc = newPulseAudioContext - -type context struct { - pulseAudioContext *pulseAudioContext - - ready chan struct{} - err atomicError - - mux *mux.Mux -} - -func newContext(sampleRate int, channelCount int, format mux.Format, bufferSizeInBytes int, clientApplicationName string) (*context, chan struct{}, error) { - ctx := &context{ - ready: make(chan struct{}), - mux: mux.New(sampleRate, channelCount, format), - } - - go func() { - defer close(ctx.ready) - - pc, err := newPulseAudioContextFunc(sampleRate, channelCount, ctx.mux, bufferSizeInBytes, clientApplicationName) - if err != nil { - ctx.err.TryStore(err) - return - } - ctx.pulseAudioContext = pc - }() - - return ctx, ctx.ready, nil -} - -func (c *context) Suspend() error { - <-c.ready - if c.pulseAudioContext != nil { - return c.pulseAudioContext.Suspend() - } - return nil -} - -func (c *context) Resume() error { - <-c.ready - if c.pulseAudioContext != nil { - return c.pulseAudioContext.Resume() - } - return nil -} - -func (c *context) Err() error { - if err := c.err.Load(); err != nil { - return err - } - - select { - case <-c.ready: - default: - return nil - } - - if c.pulseAudioContext != nil { - return c.pulseAudioContext.Err() - } - return nil -} diff --git a/driver_linux_nocgo_test.go b/driver_linux_nocgo_test.go deleted file mode 100644 index b3799b7..0000000 --- a/driver_linux_nocgo_test.go +++ /dev/null @@ -1,47 +0,0 @@ -// Copyright 2026 The Oto Authors -// -// Licensed under the Apache License, Version 2.0 (the "License"); -// you may not use this file except in compliance with the License. -// You may obtain a copy of the License at -// -// http://www.apache.org/licenses/LICENSE-2.0 -// -// Unless required by applicable law or agreed to in writing, software -// distributed under the License is distributed on an "AS IS" BASIS, -// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -// See the License for the specific language governing permissions and -// limitations under the License. - -//go:build linux && !android && !cgo - -package oto - -import ( - "reflect" - "testing" - - "github.com/ebitengine/oto/v3/internal/mux" -) - -func TestNewContextUsesPulseAudioOnlyOnLinuxWithoutCgo(t *testing.T) { - originalPulseAudio := newPulseAudioContextFunc - t.Cleanup(func() { - newPulseAudioContextFunc = originalPulseAudio - }) - - var order []string - newPulseAudioContextFunc = func(sampleRate int, channelCount int, mux *mux.Mux, bufferSizeInBytes int, clientApplicationName string) (*pulseAudioContext, error) { - order = append(order, "pulse") - return &pulseAudioContext{}, nil - } - - _, ready, err := newContext(48000, 2, mux.FormatFloat32LE, 0, "Oto") - if err != nil { - t.Fatalf("newContext failed: %v", err) - } - <-ready - - if got, want := order, []string{"pulse"}; !reflect.DeepEqual(got, want) { - t.Fatalf("backend selection order = %v, want %v", got, want) - } -} diff --git a/driver_pulseaudio_linux.go b/driver_pulseaudio.go similarity index 68% rename from driver_pulseaudio_linux.go rename to driver_pulseaudio.go index 1b439d0..24531c6 100644 --- a/driver_pulseaudio_linux.go +++ b/driver_pulseaudio.go @@ -12,7 +12,7 @@ // See the License for the specific language governing permissions and // limitations under the License. -//go:build linux && !android +//go:build (linux && !android) || darwin || windows || (!android && !darwin && !js && !linux && !windows && !nintendosdk && !playstation5) package oto @@ -24,6 +24,8 @@ import ( "github.com/jfreymuth/pulse" ) +type pulseAudioContextFactory func(sampleRate int, channelCount int, mux *mux.Mux, bufferSizeInBytes int, clientApplicationName string) (*pulseAudioContext, error) + type pulseAudioContext struct { client *pulse.Client stream *pulse.PlaybackStream @@ -139,3 +141,65 @@ func (c *pulseAudioContext) Err() error { } return nil } + +type pulseOnlyContext struct { + pulseAudioContext *pulseAudioContext + + ready chan struct{} + err atomicError + + mux *mux.Mux +} + +func newPulseOnlyContext(factory pulseAudioContextFactory, sampleRate int, channelCount int, format mux.Format, bufferSizeInBytes int, clientApplicationName string) (*pulseOnlyContext, chan struct{}, error) { + ctx := &pulseOnlyContext{ + ready: make(chan struct{}), + mux: mux.New(sampleRate, channelCount, format), + } + + go func() { + defer close(ctx.ready) + + pc, err := factory(sampleRate, channelCount, ctx.mux, bufferSizeInBytes, clientApplicationName) + if err != nil { + ctx.err.TryStore(err) + return + } + ctx.pulseAudioContext = pc + }() + + return ctx, ctx.ready, nil +} + +func (c *pulseOnlyContext) Suspend() error { + <-c.ready + if c.pulseAudioContext != nil { + return c.pulseAudioContext.Suspend() + } + return nil +} + +func (c *pulseOnlyContext) Resume() error { + <-c.ready + if c.pulseAudioContext != nil { + return c.pulseAudioContext.Resume() + } + return nil +} + +func (c *pulseOnlyContext) Err() error { + if err := c.err.Load(); err != nil { + return err + } + + select { + case <-c.ready: + default: + return nil + } + + if c.pulseAudioContext != nil { + return c.pulseAudioContext.Err() + } + return nil +} diff --git a/driver_unix.go b/driver_unix.go index 0aa3569..1eab499 100644 --- a/driver_unix.go +++ b/driver_unix.go @@ -12,267 +12,22 @@ // See the License for the specific language governing permissions and // limitations under the License. -//go:build !android && !darwin && !js && !linux && !windows && !nintendosdk && !playstation5 +//go:build (linux && !android) || (!android && !darwin && !js && !linux && !windows && !nintendosdk && !playstation5) package oto -// #cgo pkg-config: alsa -// -// #include -import "C" - -import ( - "fmt" - "strings" - "sync" - "unsafe" +import "github.com/ebitengine/oto/v3/internal/mux" - "github.com/ebitengine/oto/v3/internal/mux" -) +var newPulseAudioContextFunc pulseAudioContextFactory = newPulseAudioContext type context struct { - channelCount int - - suspended bool - - handle *C.snd_pcm_t - - cond *sync.Cond - - mux *mux.Mux - err atomicError - - ready chan struct{} -} - -var theContext *context - -func alsaError(name string, err C.int) error { - return fmt.Errorf("oto: ALSA error at %s: %s", name, C.GoString(C.snd_strerror(err))) -} - -func deviceCandidates() []string { - const getAllDevices = -1 - - cPCMInterfaceName := C.CString("pcm") - defer C.free(unsafe.Pointer(cPCMInterfaceName)) - - var hints *unsafe.Pointer - err := C.snd_device_name_hint(getAllDevices, cPCMInterfaceName, &hints) - if err != 0 { - return []string{"default", "plug:default"} - } - defer C.snd_device_name_free_hint(hints) - - var devices []string - - cIoHintName := C.CString("IOID") - defer C.free(unsafe.Pointer(cIoHintName)) - cNameHintName := C.CString("NAME") - defer C.free(unsafe.Pointer(cNameHintName)) - - for it := hints; *it != nil; it = (*unsafe.Pointer)(unsafe.Pointer(uintptr(unsafe.Pointer(it)) + unsafe.Sizeof(uintptr(0)))) { - io := C.snd_device_name_get_hint(*it, cIoHintName) - defer func() { - if io != nil { - C.free(unsafe.Pointer(io)) - } - }() - if C.GoString(io) == "Input" { - continue - } - - name := C.snd_device_name_get_hint(*it, cNameHintName) - defer func() { - if name != nil { - C.free(unsafe.Pointer(name)) - } - }() - if name == nil { - continue - } - goName := C.GoString(name) - if goName == "null" { - continue - } - if goName == "default" { - continue - } - devices = append(devices, goName) - } - - devices = append([]string{"default", "plug:default"}, devices...) - - return devices -} - -func newContext(sampleRate int, channelCount int, format mux.Format, bufferSizeInBytes int, _ string) (*context, chan struct{}, error) { - c := &context{ - channelCount: channelCount, - cond: sync.NewCond(&sync.Mutex{}), - mux: mux.New(sampleRate, channelCount, format), - ready: make(chan struct{}), - } - theContext = c - - go func() { - defer close(c.ready) - - // Open a default ALSA audio device for blocking stream playback - type openError struct { - device string - err C.int - } - var openErrs []openError - var found bool - - for _, name := range deviceCandidates() { - cname := C.CString(name) - defer C.free(unsafe.Pointer(cname)) - if err := C.snd_pcm_open(&c.handle, cname, C.SND_PCM_STREAM_PLAYBACK, 0); err < 0 { - openErrs = append(openErrs, openError{ - device: name, - err: err, - }) - continue - } - found = true - break - } - if !found { - var msgs []string - for _, e := range openErrs { - msgs = append(msgs, fmt.Sprintf("%q: %s", e.device, C.GoString(C.snd_strerror(e.err)))) - } - c.err.TryStore(fmt.Errorf("oto: ALSA error at snd_pcm_open: %s", strings.Join(msgs, ", "))) - return - } - - // TODO: Should snd_pcm_hw_params_set_periods be called explicitly? - const periods = 2 - var periodSize C.snd_pcm_uframes_t - if bufferSizeInBytes != 0 { - periodSize = C.snd_pcm_uframes_t(bufferSizeInBytes / (channelCount * 4 * periods)) - } else { - periodSize = C.snd_pcm_uframes_t(1024) - } - bufferSize := periodSize * periods - if err := c.alsaPcmHwParams(sampleRate, channelCount, &bufferSize, &periodSize); err != nil { - c.err.TryStore(err) - return - } - - go func() { - buf32 := make([]float32, int(periodSize)*channelCount) - for { - if !c.readAndWrite(buf32) { - return - } - } - }() - }() - - return c, c.ready, nil -} - -func (c *context) alsaPcmHwParams(sampleRate, channelCount int, bufferSize, periodSize *C.snd_pcm_uframes_t) error { - var params *C.snd_pcm_hw_params_t - C.snd_pcm_hw_params_malloc(¶ms) - defer C.free(unsafe.Pointer(params)) - - if err := C.snd_pcm_hw_params_any(c.handle, params); err < 0 { - return alsaError("snd_pcm_hw_params_any", err) - } - if err := C.snd_pcm_hw_params_set_access(c.handle, params, C.SND_PCM_ACCESS_RW_INTERLEAVED); err < 0 { - return alsaError("snd_pcm_hw_params_set_access", err) - } - if err := C.snd_pcm_hw_params_set_format(c.handle, params, C.SND_PCM_FORMAT_FLOAT_LE); err < 0 { - return alsaError("snd_pcm_hw_params_set_format", err) - } - if err := C.snd_pcm_hw_params_set_channels(c.handle, params, C.unsigned(channelCount)); err < 0 { - return alsaError("snd_pcm_hw_params_set_channels", err) - } - if err := C.snd_pcm_hw_params_set_rate_resample(c.handle, params, 1); err < 0 { - return alsaError("snd_pcm_hw_params_set_rate_resample", err) - } - sr := C.unsigned(sampleRate) - if err := C.snd_pcm_hw_params_set_rate_near(c.handle, params, &sr, nil); err < 0 { - return alsaError("snd_pcm_hw_params_set_rate_near", err) - } - if err := C.snd_pcm_hw_params_set_buffer_size_near(c.handle, params, bufferSize); err < 0 { - return alsaError("snd_pcm_hw_params_set_buffer_size_near", err) - } - if err := C.snd_pcm_hw_params_set_period_size_near(c.handle, params, periodSize, nil); err < 0 { - return alsaError("snd_pcm_hw_params_set_period_size_near", err) - } - if err := C.snd_pcm_hw_params(c.handle, params); err < 0 { - return alsaError("snd_pcm_hw_params", err) - } - return nil -} - -func (c *context) readAndWrite(buf32 []float32) bool { - c.cond.L.Lock() - defer c.cond.L.Unlock() - - for c.suspended && c.err.Load() == nil { - c.cond.Wait() - } - if c.err.Load() != nil { - return false - } - - c.mux.ReadFloat32s(buf32) - - for len(buf32) > 0 { - n := C.snd_pcm_writei(c.handle, unsafe.Pointer(&buf32[0]), C.snd_pcm_uframes_t(len(buf32)/c.channelCount)) - if n < 0 { - n = C.long(C.snd_pcm_recover(c.handle, C.int(n), 1)) - } - if n < 0 { - c.err.TryStore(alsaError("snd_pcm_writei or snd_pcm_recover", C.int(n))) - return false - } - buf32 = buf32[int(n)*c.channelCount:] - } - return true -} - -func (c *context) Suspend() error { - <-c.ready - - c.cond.L.Lock() - defer c.cond.L.Unlock() - - if err := c.err.Load(); err != nil { - return err.(error) - } - - c.suspended = true - - // Do not use snd_pcm_pause as not all devices support this. - // 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). - return nil -} - -func (c *context) Resume() error { - <-c.ready - - c.cond.L.Lock() - defer c.cond.L.Unlock() - - if err := c.err.Load(); err != nil { - return err.(error) - } - - c.suspended = false - c.cond.Signal() - return nil + *pulseOnlyContext } -func (c *context) Err() error { - if err := c.err.Load(); err != nil { - return err.(error) +func newContext(sampleRate int, channelCount int, format mux.Format, bufferSizeInBytes int, clientApplicationName string) (*context, chan struct{}, error) { + ctx, ready, err := newPulseOnlyContext(newPulseAudioContextFunc, sampleRate, channelCount, format, bufferSizeInBytes, clientApplicationName) + if err != nil { + return nil, nil, err } - return nil + return &context{pulseOnlyContext: ctx}, ready, nil } diff --git a/driver_windows.go b/driver_windows.go index 0f27216..6786558 100644 --- a/driver_windows.go +++ b/driver_windows.go @@ -24,21 +24,24 @@ import ( var errDeviceNotFound = errors.New("oto: device not found") +var newPulseAudioContextFunc pulseAudioContextFactory = newPulseAudioContext + type context struct { sampleRate int channelCount int mux *mux.Mux - wasapiContext *wasapiContext - winmmContext *winmmContext - nullContext *nullContext + wasapiContext *wasapiContext + winmmContext *winmmContext + pulseAudioContext *pulseAudioContext + nullContext *nullContext ready chan struct{} err atomicError } -func newContext(sampleRate int, channelCount int, format mux.Format, bufferSizeInBytes int, _ string) (*context, chan struct{}, error) { +func newContext(sampleRate int, channelCount int, format mux.Format, bufferSizeInBytes int, clientApplicationName string) (*context, chan struct{}, error) { ctx := &context{ sampleRate: sampleRate, channelCount: channelCount, @@ -62,12 +65,18 @@ func newContext(sampleRate int, channelCount int, format mux.Format, bufferSizeI return } + pc, err2 := newPulseAudioContextFunc(sampleRate, channelCount, ctx.mux, bufferSizeInBytes, clientApplicationName) + if err2 == nil { + ctx.pulseAudioContext = pc + return + } + if errors.Is(err0, errDeviceNotFound) && errors.Is(err1, errDeviceNotFound) { ctx.nullContext = newNullContext(sampleRate, channelCount, ctx.mux) return } - ctx.err.TryStore(fmt.Errorf("oto: initialization failed: WASAPI: %v, WinMM: %v", err0, err1)) + ctx.err.TryStore(fmt.Errorf("oto: initialization failed: WASAPI: %v, WinMM: %v, PulseAudio: %v", err0, err1, err2)) }() return ctx, ctx.ready, nil @@ -81,6 +90,9 @@ func (c *context) Suspend() error { if c.winmmContext != nil { return c.winmmContext.Suspend() } + if c.pulseAudioContext != nil { + return c.pulseAudioContext.Suspend() + } if c.nullContext != nil { return c.nullContext.Suspend() } @@ -95,6 +107,9 @@ func (c *context) Resume() error { if c.winmmContext != nil { return c.winmmContext.Resume() } + if c.pulseAudioContext != nil { + return c.pulseAudioContext.Resume() + } if c.nullContext != nil { return c.nullContext.Resume() } @@ -118,6 +133,9 @@ func (c *context) Err() error { if c.winmmContext != nil { return c.winmmContext.Err() } + if c.pulseAudioContext != nil { + return c.pulseAudioContext.Err() + } if c.nullContext != nil { return c.nullContext.Err() } From fc50f493dfcf732b62e5566e4738718a8be7c63f Mon Sep 17 00:00:00 2001 From: Andrew Montgomery Date: Sat, 28 Mar 2026 17:27:25 +0000 Subject: [PATCH 04/14] refactor: backout unnecessary platform support, tidy ups --- .github/workflows/test.yml | 6 +- README.md | 6 +- driver_darwin.go | 26 +---- driver_pulseaudio.go | 205 ------------------------------------- driver_unix.go | 192 +++++++++++++++++++++++++++++++++- driver_windows.go | 26 +---- 6 files changed, 204 insertions(+), 257 deletions(-) delete mode 100644 driver_pulseaudio.go diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 25e4cff..3817816 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -42,12 +42,16 @@ jobs: - name: go build run: | go build -v ./... - env CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -v ./... + # Compile without optimization to check potential stack overflow. # The option '-gcflags=all=-N -l' is often used at Visual Studio Code. # See also https://go.googlesource.com/vscode-go/+/HEAD/docs/debugging.md#launch and the issue hajimehoshi/ebiten#2120. go build "-gcflags=all=-N -l" -v ./... + # Check cross-compiling Linux binaries. + env GOOS=linux GOARCH=amd64 go build -v ./... + env GOOS=linux GOARCH=arm64 go build -v ./... + # Check cross-compiling WebAssembly. # Unfortunately it is difficult to test Oto on browsers since this requires user interactions. env GOOS=js GOARCH=wasm go build -v ./... diff --git a/README.md b/README.md index 5450238..2116b7a 100644 --- a/README.md +++ b/README.md @@ -20,9 +20,9 @@ A low-level library to play sound. ## Platforms -- Windows (no Cgo required; PulseAudio fallback available) -- macOS (no Cgo required; PulseAudio fallback available) -- Linux (PulseAudio) +- Windows (no Cgo required) +- macOS (no Cgo required) +- Linux (no Cgo required - PulseAudio) - FreeBSD - OpenBSD - Android diff --git a/driver_darwin.go b/driver_darwin.go index 68f151f..d95f757 100644 --- a/driver_darwin.go +++ b/driver_darwin.go @@ -80,16 +80,14 @@ type context struct { toPause bool toResume bool - mux *mux.Mux - pulseAudioContext *pulseAudioContext - err atomicError + mux *mux.Mux + err atomicError } // TODO: Convert the error code correctly. // See https://stackoverflow.com/questions/2196869/how-do-you-convert-an-iphone-osstatus-code-to-something-useful var theContext *context -var newPulseAudioContextFunc pulseAudioContextFactory = newPulseAudioContext func newContext(sampleRate int, channelCount int, format mux.Format, bufferSizeInBytes int, clientApplicationName string) (*context, chan struct{}, error) { // defaultOneBufferSizeInBytes is the default buffer size in bytes. @@ -123,14 +121,7 @@ func newContext(sampleRate int, channelCount int, format mux.Format, bufferSizeI return } - pc, pulseErr := newPulseAudioContextFunc(sampleRate, channelCount, c.mux, bufferSizeInBytes, clientApplicationName) - if pulseErr == nil { - c.pulseAudioContext = pc - close(ready) - return - } - - c.err.TryStore(fmt.Errorf("oto: initialization failed: AudioQueue: %v, PulseAudio: %v", err, pulseErr)) + c.err.TryStore(fmt.Errorf("oto: initialization failed: AudioQueue: %v", err)) close(ready) }() @@ -226,10 +217,6 @@ func (c *context) appendBuffer(buf32 []float32) { } func (c *context) Suspend() error { - if c.pulseAudioContext != nil { - return c.pulseAudioContext.Suspend() - } - c.cond.L.Lock() defer c.cond.L.Unlock() @@ -243,10 +230,6 @@ func (c *context) Suspend() error { } func (c *context) Resume() error { - if c.pulseAudioContext != nil { - return c.pulseAudioContext.Resume() - } - c.cond.L.Lock() defer c.cond.L.Unlock() @@ -292,9 +275,6 @@ func (c *context) Err() error { if err := c.err.Load(); err != nil { return err.(error) } - if c.pulseAudioContext != nil { - return c.pulseAudioContext.Err() - } return nil } diff --git a/driver_pulseaudio.go b/driver_pulseaudio.go deleted file mode 100644 index 24531c6..0000000 --- a/driver_pulseaudio.go +++ /dev/null @@ -1,205 +0,0 @@ -// Copyright 2026 The Oto Authors -// -// Licensed under the Apache License, Version 2.0 (the "License"); -// you may not use this file except in compliance with the License. -// You may obtain a copy of the License at -// -// http://www.apache.org/licenses/LICENSE-2.0 -// -// Unless required by applicable law or agreed to in writing, software -// distributed under the License is distributed on an "AS IS" BASIS, -// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -// See the License for the specific language governing permissions and -// limitations under the License. - -//go:build (linux && !android) || darwin || windows || (!android && !darwin && !js && !linux && !windows && !nintendosdk && !playstation5) - -package oto - -import ( - "fmt" - "sync" - - "github.com/ebitengine/oto/v3/internal/mux" - "github.com/jfreymuth/pulse" -) - -type pulseAudioContextFactory func(sampleRate int, channelCount int, mux *mux.Mux, bufferSizeInBytes int, clientApplicationName string) (*pulseAudioContext, error) - -type pulseAudioContext struct { - client *pulse.Client - stream *pulse.PlaybackStream - - suspended bool - cond *sync.Cond - - mux *mux.Mux - err atomicError -} - -func newPulseAudioContext(sampleRate int, channelCount int, mux *mux.Mux, bufferSizeInBytes int, clientApplicationName string) (*pulseAudioContext, error) { - c := &pulseAudioContext{ - cond: sync.NewCond(&sync.Mutex{}), - mux: mux, - } - - if clientApplicationName == "" { - clientApplicationName = "Oto" - } - - client, err := pulse.NewClient(pulse.ClientApplicationName(clientApplicationName)) - if err != nil { - return nil, fmt.Errorf("oto: PulseAudio client initialization failed: %w", err) - } - c.client = client - - options := []pulse.PlaybackOption{ - pulse.PlaybackMediaName(clientApplicationName), - } - switch channelCount { - case 1: - options = append(options, pulse.PlaybackMono) - case 2: - options = append(options, pulse.PlaybackStereo) - default: - client.Close() - return nil, fmt.Errorf("oto: PulseAudio backend supports only mono or stereo output: %d", channelCount) - } - options = append(options, pulse.PlaybackSampleRate(sampleRate)) - if bufferSizeInBytes != 0 { - latency := float64(bufferSizeInBytes) / float64(sampleRate*channelCount*4) - if latency > 0 { - options = append(options, pulse.PlaybackLatency(latency)) - } - } - - stream, err := client.NewPlayback(pulse.Float32Reader(c.read), options...) - if err != nil { - client.Close() - return nil, fmt.Errorf("oto: PulseAudio playback initialization failed: %w", err) - } - c.stream = stream - c.stream.Start() - - return c, nil -} - -func (c *pulseAudioContext) read(buf []float32) (int, error) { - c.cond.L.Lock() - defer c.cond.L.Unlock() - - for c.suspended && c.err.Load() == nil { - c.cond.Wait() - } - if err := c.err.Load(); err != nil { - return 0, err - } - - c.mux.ReadFloat32s(buf) - return len(buf), nil -} - -func (c *pulseAudioContext) Suspend() error { - c.cond.L.Lock() - defer c.cond.L.Unlock() - - if err := c.err.Load(); err != nil { - return err - } - if err := c.stream.Error(); err != nil { - return fmt.Errorf("oto: PulseAudio error: %w", err) - } - - c.suspended = true - c.stream.Pause() - return nil -} - -func (c *pulseAudioContext) Resume() error { - c.cond.L.Lock() - defer c.cond.L.Unlock() - - if err := c.err.Load(); err != nil { - return err - } - if err := c.stream.Error(); err != nil { - return fmt.Errorf("oto: PulseAudio error: %w", err) - } - - c.suspended = false - c.stream.Resume() - c.cond.Broadcast() - return nil -} - -func (c *pulseAudioContext) Err() error { - if err := c.err.Load(); err != nil { - return err - } - if err := c.stream.Error(); err != nil { - return fmt.Errorf("oto: PulseAudio error: %w", err) - } - return nil -} - -type pulseOnlyContext struct { - pulseAudioContext *pulseAudioContext - - ready chan struct{} - err atomicError - - mux *mux.Mux -} - -func newPulseOnlyContext(factory pulseAudioContextFactory, sampleRate int, channelCount int, format mux.Format, bufferSizeInBytes int, clientApplicationName string) (*pulseOnlyContext, chan struct{}, error) { - ctx := &pulseOnlyContext{ - ready: make(chan struct{}), - mux: mux.New(sampleRate, channelCount, format), - } - - go func() { - defer close(ctx.ready) - - pc, err := factory(sampleRate, channelCount, ctx.mux, bufferSizeInBytes, clientApplicationName) - if err != nil { - ctx.err.TryStore(err) - return - } - ctx.pulseAudioContext = pc - }() - - return ctx, ctx.ready, nil -} - -func (c *pulseOnlyContext) Suspend() error { - <-c.ready - if c.pulseAudioContext != nil { - return c.pulseAudioContext.Suspend() - } - return nil -} - -func (c *pulseOnlyContext) Resume() error { - <-c.ready - if c.pulseAudioContext != nil { - return c.pulseAudioContext.Resume() - } - return nil -} - -func (c *pulseOnlyContext) Err() error { - if err := c.err.Load(); err != nil { - return err - } - - select { - case <-c.ready: - default: - return nil - } - - if c.pulseAudioContext != nil { - return c.pulseAudioContext.Err() - } - return nil -} diff --git a/driver_unix.go b/driver_unix.go index 1eab499..dcc1bed 100644 --- a/driver_unix.go +++ b/driver_unix.go @@ -1,4 +1,4 @@ -// Copyright 2021 The Oto Authors +// Copyright 2026 The Oto Authors // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. @@ -12,11 +12,17 @@ // See the License for the specific language governing permissions and // limitations under the License. -//go:build (linux && !android) || (!android && !darwin && !js && !linux && !windows && !nintendosdk && !playstation5) +//go:build !android && !darwin && !js && !windows && !nintendosdk && !playstation5 package oto -import "github.com/ebitengine/oto/v3/internal/mux" +import ( + "fmt" + "sync" + + "github.com/ebitengine/oto/v3/internal/mux" + "github.com/jfreymuth/pulse" +) var newPulseAudioContextFunc pulseAudioContextFactory = newPulseAudioContext @@ -31,3 +37,183 @@ func newContext(sampleRate int, channelCount int, format mux.Format, bufferSizeI } return &context{pulseOnlyContext: ctx}, ready, nil } + +type pulseAudioContextFactory func(sampleRate int, channelCount int, mux *mux.Mux, bufferSizeInBytes int, clientApplicationName string) (*pulseAudioContext, error) + +type pulseAudioContext struct { + client *pulse.Client + stream *pulse.PlaybackStream + + suspended bool + cond *sync.Cond + + mux *mux.Mux + err atomicError +} + +func newPulseAudioContext(sampleRate int, channelCount int, mux *mux.Mux, bufferSizeInBytes int, clientApplicationName string) (*pulseAudioContext, error) { + c := &pulseAudioContext{ + cond: sync.NewCond(&sync.Mutex{}), + mux: mux, + } + + if clientApplicationName == "" { + clientApplicationName = "Oto" + } + + client, err := pulse.NewClient(pulse.ClientApplicationName(clientApplicationName)) + if err != nil { + return nil, fmt.Errorf("oto: PulseAudio client initialization failed: %w", err) + } + c.client = client + + options := []pulse.PlaybackOption{ + pulse.PlaybackMediaName(clientApplicationName), + } + switch channelCount { + case 1: + options = append(options, pulse.PlaybackMono) + case 2: + options = append(options, pulse.PlaybackStereo) + default: + client.Close() + return nil, fmt.Errorf("oto: PulseAudio backend supports only mono or stereo output: %d", channelCount) + } + options = append(options, pulse.PlaybackSampleRate(sampleRate)) + if bufferSizeInBytes != 0 { + latency := float64(bufferSizeInBytes) / float64(sampleRate*channelCount*4) + if latency > 0 { + options = append(options, pulse.PlaybackLatency(latency)) + } + } + + stream, err := client.NewPlayback(pulse.Float32Reader(c.read), options...) + if err != nil { + client.Close() + return nil, fmt.Errorf("oto: PulseAudio playback initialization failed: %w", err) + } + c.stream = stream + c.stream.Start() + + return c, nil +} + +func (c *pulseAudioContext) read(buf []float32) (int, error) { + c.cond.L.Lock() + defer c.cond.L.Unlock() + + for c.suspended && c.err.Load() == nil { + c.cond.Wait() + } + if err := c.err.Load(); err != nil { + return 0, err + } + + c.mux.ReadFloat32s(buf) + return len(buf), nil +} + +func (c *pulseAudioContext) Suspend() error { + c.cond.L.Lock() + defer c.cond.L.Unlock() + + if err := c.err.Load(); err != nil { + return err + } + if err := c.stream.Error(); err != nil { + return fmt.Errorf("oto: PulseAudio error: %w", err) + } + + c.suspended = true + c.stream.Pause() + return nil +} + +func (c *pulseAudioContext) Resume() error { + c.cond.L.Lock() + defer c.cond.L.Unlock() + + if err := c.err.Load(); err != nil { + return err + } + if err := c.stream.Error(); err != nil { + return fmt.Errorf("oto: PulseAudio error: %w", err) + } + + c.suspended = false + c.stream.Resume() + c.cond.Broadcast() + return nil +} + +func (c *pulseAudioContext) Err() error { + if err := c.err.Load(); err != nil { + return err + } + if err := c.stream.Error(); err != nil { + return fmt.Errorf("oto: PulseAudio error: %w", err) + } + return nil +} + +type pulseOnlyContext struct { + pulseAudioContext *pulseAudioContext + + ready chan struct{} + err atomicError + + mux *mux.Mux +} + +func newPulseOnlyContext(factory pulseAudioContextFactory, sampleRate int, channelCount int, format mux.Format, bufferSizeInBytes int, clientApplicationName string) (*pulseOnlyContext, chan struct{}, error) { + ctx := &pulseOnlyContext{ + ready: make(chan struct{}), + mux: mux.New(sampleRate, channelCount, format), + } + + go func() { + defer close(ctx.ready) + + pc, err := factory(sampleRate, channelCount, ctx.mux, bufferSizeInBytes, clientApplicationName) + if err != nil { + ctx.err.TryStore(err) + return + } + ctx.pulseAudioContext = pc + }() + + return ctx, ctx.ready, nil +} + +func (c *pulseOnlyContext) Suspend() error { + <-c.ready + if c.pulseAudioContext != nil { + return c.pulseAudioContext.Suspend() + } + return nil +} + +func (c *pulseOnlyContext) Resume() error { + <-c.ready + if c.pulseAudioContext != nil { + return c.pulseAudioContext.Resume() + } + return nil +} + +func (c *pulseOnlyContext) Err() error { + if err := c.err.Load(); err != nil { + return err + } + + select { + case <-c.ready: + default: + return nil + } + + if c.pulseAudioContext != nil { + return c.pulseAudioContext.Err() + } + return nil +} diff --git a/driver_windows.go b/driver_windows.go index 6786558..896c959 100644 --- a/driver_windows.go +++ b/driver_windows.go @@ -24,18 +24,15 @@ import ( var errDeviceNotFound = errors.New("oto: device not found") -var newPulseAudioContextFunc pulseAudioContextFactory = newPulseAudioContext - type context struct { sampleRate int channelCount int mux *mux.Mux - wasapiContext *wasapiContext - winmmContext *winmmContext - pulseAudioContext *pulseAudioContext - nullContext *nullContext + wasapiContext *wasapiContext + winmmContext *winmmContext + nullContext *nullContext ready chan struct{} err atomicError @@ -65,18 +62,12 @@ func newContext(sampleRate int, channelCount int, format mux.Format, bufferSizeI return } - pc, err2 := newPulseAudioContextFunc(sampleRate, channelCount, ctx.mux, bufferSizeInBytes, clientApplicationName) - if err2 == nil { - ctx.pulseAudioContext = pc - return - } - if errors.Is(err0, errDeviceNotFound) && errors.Is(err1, errDeviceNotFound) { ctx.nullContext = newNullContext(sampleRate, channelCount, ctx.mux) return } - ctx.err.TryStore(fmt.Errorf("oto: initialization failed: WASAPI: %v, WinMM: %v, PulseAudio: %v", err0, err1, err2)) + ctx.err.TryStore(fmt.Errorf("oto: initialization failed: WASAPI: %v, WinMM: %v", err0, err1)) }() return ctx, ctx.ready, nil @@ -90,9 +81,6 @@ func (c *context) Suspend() error { if c.winmmContext != nil { return c.winmmContext.Suspend() } - if c.pulseAudioContext != nil { - return c.pulseAudioContext.Suspend() - } if c.nullContext != nil { return c.nullContext.Suspend() } @@ -107,9 +95,6 @@ func (c *context) Resume() error { if c.winmmContext != nil { return c.winmmContext.Resume() } - if c.pulseAudioContext != nil { - return c.pulseAudioContext.Resume() - } if c.nullContext != nil { return c.nullContext.Resume() } @@ -133,9 +118,6 @@ func (c *context) Err() error { if c.winmmContext != nil { return c.winmmContext.Err() } - if c.pulseAudioContext != nil { - return c.pulseAudioContext.Err() - } if c.nullContext != nil { return c.nullContext.Err() } From b54fe77f2db422d366908bf4be53640a1eb8bf8a Mon Sep 17 00:00:00 2001 From: Andrew Montgomery Date: Sat, 28 Mar 2026 18:41:40 +0000 Subject: [PATCH 05/14] cicd: reorder build tests --- .github/workflows/test.yml | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 3817816..740bdfd 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -47,10 +47,6 @@ jobs: # The option '-gcflags=all=-N -l' is often used at Visual Studio Code. # See also https://go.googlesource.com/vscode-go/+/HEAD/docs/debugging.md#launch and the issue hajimehoshi/ebiten#2120. go build "-gcflags=all=-N -l" -v ./... - - # Check cross-compiling Linux binaries. - env GOOS=linux GOARCH=amd64 go build -v ./... - env GOOS=linux GOARCH=arm64 go build -v ./... # Check cross-compiling WebAssembly. # Unfortunately it is difficult to test Oto on browsers since this requires user interactions. @@ -66,6 +62,9 @@ jobs: env GOOS=darwin GOARCH=amd64 go build -v ./... env GOOS=darwin GOARCH=arm64 go build -v ./... + # Check cross-compiling Linux binaries. + env GOOS=linux GOARCH=amd64 go build -v ./... + env GOOS=linux GOARCH=arm64 go build -v ./... - name: go mod vendor run: | mkdir /tmp/vendoring From cee36c26d782b24570c1b1d7c4190fbb276b4589 Mon Sep 17 00:00:00 2001 From: Andrew Montgomery Date: Sun, 29 Mar 2026 09:20:11 +0100 Subject: [PATCH 06/14] fix: address pr feedback --- .github/workflows/test.yml | 3 +-- README.md | 10 +++------- context.go | 6 +++--- driver_unix.go | 13 ++++++++++--- 4 files changed, 17 insertions(+), 15 deletions(-) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 740bdfd..62c0927 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -42,12 +42,11 @@ jobs: - name: go build run: | go build -v ./... - # Compile without optimization to check potential stack overflow. # The option '-gcflags=all=-N -l' is often used at Visual Studio Code. # See also https://go.googlesource.com/vscode-go/+/HEAD/docs/debugging.md#launch and the issue hajimehoshi/ebiten#2120. go build "-gcflags=all=-N -l" -v ./... - + # Check cross-compiling WebAssembly. # Unfortunately it is difficult to test Oto on browsers since this requires user interactions. env GOOS=js GOARCH=wasm go build -v ./... diff --git a/README.md b/README.md index 2116b7a..b67038c 100644 --- a/README.md +++ b/README.md @@ -22,9 +22,9 @@ A low-level library to play sound. - Windows (no Cgo required) - macOS (no Cgo required) -- Linux (no Cgo required - PulseAudio) -- FreeBSD -- OpenBSD +- Linux (no Cgo required) +- FreeBSD (no Cgo required) +- OpenBSD (no Cgo required) - Android - iOS - WebAssembly @@ -63,10 +63,6 @@ access to a PulseAudio-compatible server. BSD systems are not tested well. Oto uses the same PulseAudio backend there. If the server is not discoverable automatically, set `PULSE_SERVER`. -On macOS and Windows, Oto still prefers the native backend first and falls back to PulseAudio -if native initialization fails. On Windows, set `PULSE_SERVER` when you want to target a -PulseAudio server explicitly. - ## Usage The two main components of Oto are a `Context` and `Players`. The context handles interactions with diff --git a/context.go b/context.go index 2c05fb7..0974bb8 100644 --- a/context.go +++ b/context.go @@ -74,9 +74,9 @@ type NewContextOptions struct { // On the other hand, too small buffer size can cause glitch noises due to buffer shortage. BufferSize time.Duration - // ClientApplicationName specifies the name of the client application. + // ApplicationName specifies the name of the client application. // It is used for PulseAudio's volume control UI and so on. - ClientApplicationName string + ApplicationName string } // NewContext creates a new context with given options. @@ -101,7 +101,7 @@ func NewContext(options *NewContextOptions) (*Context, chan struct{}, error) { bufferSizeInBytes = int(int64(options.BufferSize) * int64(bytesPerSecond) / int64(time.Second)) bufferSizeInBytes = bufferSizeInBytes / bytesPerSample * bytesPerSample } - ctx, ready, err := newContext(options.SampleRate, options.ChannelCount, mux.Format(options.Format), bufferSizeInBytes, options.ClientApplicationName) + ctx, ready, err := newContext(options.SampleRate, options.ChannelCount, mux.Format(options.Format), bufferSizeInBytes, options.ApplicationName) if err != nil { return nil, nil, err } diff --git a/driver_unix.go b/driver_unix.go index dcc1bed..02268ac 100644 --- a/driver_unix.go +++ b/driver_unix.go @@ -1,4 +1,4 @@ -// Copyright 2026 The Oto Authors +// Copyright 2021 The Oto Authors // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. @@ -18,10 +18,13 @@ package oto import ( "fmt" + "os" + "path/filepath" "sync" - "github.com/ebitengine/oto/v3/internal/mux" "github.com/jfreymuth/pulse" + + "github.com/ebitengine/oto/v3/internal/mux" ) var newPulseAudioContextFunc pulseAudioContextFactory = newPulseAudioContext @@ -58,7 +61,11 @@ func newPulseAudioContext(sampleRate int, channelCount int, mux *mux.Mux, buffer } if clientApplicationName == "" { - clientApplicationName = "Oto" + if name, _ := os.Executable(); name != "" { + clientApplicationName = filepath.Base(name) + } else { + clientApplicationName = "Oto" + } } client, err := pulse.NewClient(pulse.ClientApplicationName(clientApplicationName)) From bb1c4fd3106b39e6774b7741cefd1ed0e5b566ce Mon Sep 17 00:00:00 2001 From: Andrew Montgomery Date: Sun, 29 Mar 2026 10:28:12 +0100 Subject: [PATCH 07/14] fix: readme updates, backout left overs from unneccessary pulse changes --- README.md | 5 ++-- driver_darwin.go | 72 +++++++++++++++++++++++------------------------ driver_unix.go | 12 ++++---- driver_windows.go | 2 +- 4 files changed, 46 insertions(+), 45 deletions(-) diff --git a/README.md b/README.md index b67038c..d2f7758 100644 --- a/README.md +++ b/README.md @@ -37,7 +37,7 @@ On some platforms you will need a C/C++ compiler in your path that Go can use. - iOS: On newer macOS versions type `clang` on your terminal and a dialog with installation instructions will appear if you don't have it - If you get an error with clang use xcode instead `xcode-select --install` -- BSD and console targets may still need a working C/C++ toolchain; if not installed, try [GCC](https://gcc.gnu.org/) or [Clang](https://releases.llvm.org/download.html) +- Console targets may still need a working C/C++ toolchain; if not installed, try [GCC](https://gcc.gnu.org/) or [Clang](https://releases.llvm.org/download.html) ### macOS @@ -213,7 +213,8 @@ This works because players implement a `Player` interface and a `BufferSizeSette ## Crosscompiling -Crosscompiling to macOS or Windows is as easy as setting `GOOS=darwin` or `GOOS=windows`, respectively. +Crosscompiling to macOS, Windows, Linux or BSD is as easy as setting `GOOS=darwin`, `GOOS=windows`, +`GOOS=linux` or `GOOS=freebsd` (or your particular BSD flavor) respectively. To crosscompile for other platforms, make sure the libraries for the target architecture are installed, and set `CGO_ENABLED=1` as Go disables [Cgo](https://golang.org/cmd/cgo/#hdr-Using_cgo_with_the_go_command) on crosscompiles by default. diff --git a/driver_darwin.go b/driver_darwin.go index d95f757..4cc2552 100644 --- a/driver_darwin.go +++ b/driver_darwin.go @@ -89,7 +89,7 @@ type context struct { var theContext *context -func newContext(sampleRate int, channelCount int, format mux.Format, bufferSizeInBytes int, clientApplicationName string) (*context, chan struct{}, error) { +func newContext(sampleRate int, channelCount int, format mux.Format, bufferSizeInBytes int, _ string) (*context, chan struct{}, error) { // defaultOneBufferSizeInBytes is the default buffer size in bytes. // // 12288 seems necessary at least on iPod touch (7th) and MacBook Pro 2020. @@ -115,51 +115,51 @@ func newContext(sampleRate int, channelCount int, format mux.Format, bufferSizeI } theContext = c + if err := initializeAPI(); err != nil { + return nil, nil, err + } + go func() { - started, err := c.startAudioQueueContext(sampleRate, channelCount, oneBufferSizeInBytes, ready) - if started { + runtime.LockOSThread() + defer runtime.UnlockOSThread() + + var readyClosed bool + defer func() { + if !readyClosed { + close(ready) + } + }() + + q, bs, err := newAudioQueue(sampleRate, channelCount, oneBufferSizeInBytes) + if err != nil { + c.err.TryStore(err) + return + } + c.audioQueue = q + c.unqueuedBuffers = bs + + var retryCount int + try: + if osstatus := _AudioQueueStart(c.audioQueue, nil); osstatus != noErr { + if osstatus == avAudioSessionErrorCodeCannotStartPlaying && retryCount < 100 { + // TODO: use sleepTime() after investigating when this error happens. + time.Sleep(10 * time.Millisecond) + retryCount++ + goto try + } + c.err.TryStore(fmt.Errorf("oto: AudioQueueStart failed at newContext: %d", osstatus)) return } - c.err.TryStore(fmt.Errorf("oto: initialization failed: AudioQueue: %v", err)) close(ready) + readyClosed = true + + c.loop() }() return c, ready, nil } -func (c *context) startAudioQueueContext(sampleRate, channelCount, oneBufferSizeInBytes int, ready chan struct{}) (bool, error) { - if err := initializeAPI(); err != nil { - return false, err - } - - runtime.LockOSThread() - defer runtime.UnlockOSThread() - - q, bs, err := newAudioQueue(sampleRate, channelCount, oneBufferSizeInBytes) - if err != nil { - return false, err - } - c.audioQueue = q - c.unqueuedBuffers = bs - - var retryCount int -try: - if osstatus := _AudioQueueStart(c.audioQueue, nil); osstatus != noErr { - if osstatus == avAudioSessionErrorCodeCannotStartPlaying && retryCount < 100 { - // TODO: use sleepTime() after investigating when this error happens. - time.Sleep(10 * time.Millisecond) - retryCount++ - goto try - } - return false, fmt.Errorf("oto: AudioQueueStart failed at newContext: %d", osstatus) - } - - close(ready) - c.loop() - return true, nil -} - func (c *context) wait() bool { c.cond.L.Lock() defer c.cond.L.Unlock() diff --git a/driver_unix.go b/driver_unix.go index 02268ac..27ae9a1 100644 --- a/driver_unix.go +++ b/driver_unix.go @@ -54,28 +54,28 @@ type pulseAudioContext struct { err atomicError } -func newPulseAudioContext(sampleRate int, channelCount int, mux *mux.Mux, bufferSizeInBytes int, clientApplicationName string) (*pulseAudioContext, error) { +func newPulseAudioContext(sampleRate int, channelCount int, mux *mux.Mux, bufferSizeInBytes int, applicationName string) (*pulseAudioContext, error) { c := &pulseAudioContext{ cond: sync.NewCond(&sync.Mutex{}), mux: mux, } - if clientApplicationName == "" { + if applicationName == "" { if name, _ := os.Executable(); name != "" { - clientApplicationName = filepath.Base(name) + applicationName = filepath.Base(name) } else { - clientApplicationName = "Oto" + applicationName = "Oto" } } - client, err := pulse.NewClient(pulse.ClientApplicationName(clientApplicationName)) + client, err := pulse.NewClient(pulse.ClientApplicationName(applicationName)) if err != nil { return nil, fmt.Errorf("oto: PulseAudio client initialization failed: %w", err) } c.client = client options := []pulse.PlaybackOption{ - pulse.PlaybackMediaName(clientApplicationName), + pulse.PlaybackMediaName(applicationName), } switch channelCount { case 1: diff --git a/driver_windows.go b/driver_windows.go index 896c959..0f27216 100644 --- a/driver_windows.go +++ b/driver_windows.go @@ -38,7 +38,7 @@ type context struct { err atomicError } -func newContext(sampleRate int, channelCount int, format mux.Format, bufferSizeInBytes int, clientApplicationName string) (*context, chan struct{}, error) { +func newContext(sampleRate int, channelCount int, format mux.Format, bufferSizeInBytes int, _ string) (*context, chan struct{}, error) { ctx := &context{ sampleRate: sampleRate, channelCount: channelCount, From 2ec1dd15a3b7494b0a1f057140a352238a35136f Mon Sep 17 00:00:00 2001 From: Andrew Montgomery Date: Sun, 29 Mar 2026 10:30:28 +0100 Subject: [PATCH 08/14] chore: pin pulse pkg to v0.1.1 latest tag --- go.mod | 2 +- go.sum | 2 ++ 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/go.mod b/go.mod index 8697dab..0a74965 100644 --- a/go.mod +++ b/go.mod @@ -4,6 +4,6 @@ go 1.24.0 require ( github.com/ebitengine/purego v0.10.0 - github.com/jfreymuth/pulse v0.1.2-0.20241102120944-4ffb35054b53 + github.com/jfreymuth/pulse v0.1.1 golang.org/x/sys v0.41.0 ) diff --git a/go.sum b/go.sum index a77ff33..beb68fd 100644 --- a/go.sum +++ b/go.sum @@ -1,5 +1,7 @@ github.com/ebitengine/purego v0.10.0 h1:QIw4xfpWT6GWTzaW5XEKy3HXoqrJGx1ijYHzTF0/ISU= github.com/ebitengine/purego v0.10.0/go.mod h1:iIjxzd6CiRiOG0UyXP+V1+jWqUXVjPKLAI0mRfJZTmQ= +github.com/jfreymuth/pulse v0.1.1 h1:9WLNBNCijmtZ14ZJpatgJPu/NjwAl3TIKItSFnTh+9A= +github.com/jfreymuth/pulse v0.1.1/go.mod h1:cpYspI6YljhkUf1WLXLLDmeaaPFc3CnGLjDZf9dZ4no= github.com/jfreymuth/pulse v0.1.2-0.20241102120944-4ffb35054b53 h1:bwsfDCV1qoqA3ooZfP6zvNr5RCjYRxItKODBiJzOQOc= github.com/jfreymuth/pulse v0.1.2-0.20241102120944-4ffb35054b53/go.mod h1:cpYspI6YljhkUf1WLXLLDmeaaPFc3CnGLjDZf9dZ4no= golang.org/x/sys v0.41.0 h1:Ivj+2Cp/ylzLiEU89QhWblYnOE9zerudt9Ftecq2C6k= From 91d4ed51a451c5a0a9dc83dc9b9ad8842dea5de2 Mon Sep 17 00:00:00 2001 From: Andrew Montgomery Date: Sun, 29 Mar 2026 10:59:56 +0100 Subject: [PATCH 09/14] chore: go mod tidy --- go.sum | 2 -- 1 file changed, 2 deletions(-) diff --git a/go.sum b/go.sum index beb68fd..6e5b8f1 100644 --- a/go.sum +++ b/go.sum @@ -2,7 +2,5 @@ github.com/ebitengine/purego v0.10.0 h1:QIw4xfpWT6GWTzaW5XEKy3HXoqrJGx1ijYHzTF0/ github.com/ebitengine/purego v0.10.0/go.mod h1:iIjxzd6CiRiOG0UyXP+V1+jWqUXVjPKLAI0mRfJZTmQ= github.com/jfreymuth/pulse v0.1.1 h1:9WLNBNCijmtZ14ZJpatgJPu/NjwAl3TIKItSFnTh+9A= github.com/jfreymuth/pulse v0.1.1/go.mod h1:cpYspI6YljhkUf1WLXLLDmeaaPFc3CnGLjDZf9dZ4no= -github.com/jfreymuth/pulse v0.1.2-0.20241102120944-4ffb35054b53 h1:bwsfDCV1qoqA3ooZfP6zvNr5RCjYRxItKODBiJzOQOc= -github.com/jfreymuth/pulse v0.1.2-0.20241102120944-4ffb35054b53/go.mod h1:cpYspI6YljhkUf1WLXLLDmeaaPFc3CnGLjDZf9dZ4no= golang.org/x/sys v0.41.0 h1:Ivj+2Cp/ylzLiEU89QhWblYnOE9zerudt9Ftecq2C6k= golang.org/x/sys v0.41.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= From 6e46d9d5e798212cef24ddf272f81552b5b59a6c Mon Sep 17 00:00:00 2001 From: Andrew Montgomery Date: Sun, 29 Mar 2026 13:03:45 +0100 Subject: [PATCH 10/14] refactor: defer tidy up, less ambiguity about bsd.linux pulse support/options --- README.md | 15 +++++---------- driver_unix.go | 10 ++++++---- 2 files changed, 11 insertions(+), 14 deletions(-) diff --git a/README.md b/README.md index d2f7758..b1be7f0 100644 --- a/README.md +++ b/README.md @@ -10,8 +10,7 @@ A low-level library to play sound. - [Prerequisite](#prerequisite) - [macOS](#macos) - [iOS](#ios) - - [Linux](#linux) - - [FreeBSD, OpenBSD](#freebsd-openbsd) + - [Linux, FreeBSD, OpenBSD](#linux-freebsd-openbsd) - [Usage](#usage) - [Playing sounds from memory](#playing-sounds-from-memory) - [Playing sounds by file streaming](#playing-sounds-by-file-streaming) @@ -52,16 +51,12 @@ Oto requires these frameworks: Add them to "Linked Frameworks and Libraries" on your Xcode project. -### Linux +### Linux, FreeBSD, OpenBSD -Oto uses PulseAudio on Linux via the pure-Go package `github.com/jfreymuth/pulse`. -This backend does not require Cgo or PulseAudio development headers, but it does require -access to a PulseAudio-compatible server. +Oto uses PulseAudio on Linux and BSD systems via the pure-Go package `github.com/jfreymuth/pulse`, +though BSD systems are not tested well. -### FreeBSD, OpenBSD - -BSD systems are not tested well. Oto uses the same PulseAudio backend there. If the server -is not discoverable automatically, set `PULSE_SERVER`. +If the PulseAudio server is not discoverable automatically, set `PULSE_SERVER`. ## Usage diff --git a/driver_unix.go b/driver_unix.go index 27ae9a1..94613d4 100644 --- a/driver_unix.go +++ b/driver_unix.go @@ -73,6 +73,11 @@ func newPulseAudioContext(sampleRate int, channelCount int, mux *mux.Mux, buffer return nil, fmt.Errorf("oto: PulseAudio client initialization failed: %w", err) } c.client = client + defer func() { + if client != nil && err != nil { + client.Close() + } + }() options := []pulse.PlaybackOption{ pulse.PlaybackMediaName(applicationName), @@ -83,7 +88,6 @@ func newPulseAudioContext(sampleRate int, channelCount int, mux *mux.Mux, buffer case 2: options = append(options, pulse.PlaybackStereo) default: - client.Close() return nil, fmt.Errorf("oto: PulseAudio backend supports only mono or stereo output: %d", channelCount) } options = append(options, pulse.PlaybackSampleRate(sampleRate)) @@ -94,12 +98,10 @@ func newPulseAudioContext(sampleRate int, channelCount int, mux *mux.Mux, buffer } } - stream, err := client.NewPlayback(pulse.Float32Reader(c.read), options...) + c.stream, err = client.NewPlayback(pulse.Float32Reader(c.read), options...) if err != nil { - client.Close() return nil, fmt.Errorf("oto: PulseAudio playback initialization failed: %w", err) } - c.stream = stream c.stream.Start() return c, nil From 96e9168af3d4ed8794743a80872c087b931b0114 Mon Sep 17 00:00:00 2001 From: Andrew Montgomery Date: Sun, 29 Mar 2026 19:27:26 +0100 Subject: [PATCH 11/14] fix: bad defer close, wasm cgo unneeded notice --- README.md | 2 +- driver_unix.go | 19 +++++++++---------- 2 files changed, 10 insertions(+), 11 deletions(-) diff --git a/README.md b/README.md index b1be7f0..5958cc7 100644 --- a/README.md +++ b/README.md @@ -26,7 +26,7 @@ A low-level library to play sound. - OpenBSD (no Cgo required) - Android - iOS -- WebAssembly +- WebAssembly (no Cgo required) - Nintendo Switch - Xbox diff --git a/driver_unix.go b/driver_unix.go index 94613d4..3d054c5 100644 --- a/driver_unix.go +++ b/driver_unix.go @@ -54,11 +54,16 @@ type pulseAudioContext struct { err atomicError } -func newPulseAudioContext(sampleRate int, channelCount int, mux *mux.Mux, bufferSizeInBytes int, applicationName string) (*pulseAudioContext, error) { - c := &pulseAudioContext{ +func newPulseAudioContext(sampleRate int, channelCount int, mux *mux.Mux, bufferSizeInBytes int, applicationName string) (c *pulseAudioContext, err error) { + c = &pulseAudioContext{ cond: sync.NewCond(&sync.Mutex{}), mux: mux, } + defer func() { + if c.client != nil && err != nil { + c.client.Close() + } + }() if applicationName == "" { if name, _ := os.Executable(); name != "" { @@ -68,16 +73,10 @@ func newPulseAudioContext(sampleRate int, channelCount int, mux *mux.Mux, buffer } } - client, err := pulse.NewClient(pulse.ClientApplicationName(applicationName)) + c.client, err = pulse.NewClient(pulse.ClientApplicationName(applicationName)) if err != nil { return nil, fmt.Errorf("oto: PulseAudio client initialization failed: %w", err) } - c.client = client - defer func() { - if client != nil && err != nil { - client.Close() - } - }() options := []pulse.PlaybackOption{ pulse.PlaybackMediaName(applicationName), @@ -98,7 +97,7 @@ func newPulseAudioContext(sampleRate int, channelCount int, mux *mux.Mux, buffer } } - c.stream, err = client.NewPlayback(pulse.Float32Reader(c.read), options...) + c.stream, err = c.client.NewPlayback(pulse.Float32Reader(c.read), options...) if err != nil { return nil, fmt.Errorf("oto: PulseAudio playback initialization failed: %w", err) } From 401fd83a05626950b3177c4493cd3cb9d35ccbda Mon Sep 17 00:00:00 2001 From: Andrew Montgomery Date: Tue, 31 Mar 2026 14:03:24 +0100 Subject: [PATCH 12/14] chore: rename c to client --- driver_unix.go | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/driver_unix.go b/driver_unix.go index 3d054c5..c4c9b7f 100644 --- a/driver_unix.go +++ b/driver_unix.go @@ -54,14 +54,14 @@ type pulseAudioContext struct { err atomicError } -func newPulseAudioContext(sampleRate int, channelCount int, mux *mux.Mux, bufferSizeInBytes int, applicationName string) (c *pulseAudioContext, err error) { - c = &pulseAudioContext{ +func newPulseAudioContext(sampleRate int, channelCount int, mux *mux.Mux, bufferSizeInBytes int, applicationName string) (client *pulseAudioContext, err error) { + client = &pulseAudioContext{ cond: sync.NewCond(&sync.Mutex{}), mux: mux, } defer func() { - if c.client != nil && err != nil { - c.client.Close() + if client.client != nil && err != nil { + client.client.Close() } }() @@ -73,7 +73,7 @@ func newPulseAudioContext(sampleRate int, channelCount int, mux *mux.Mux, buffer } } - c.client, err = pulse.NewClient(pulse.ClientApplicationName(applicationName)) + client.client, err = pulse.NewClient(pulse.ClientApplicationName(applicationName)) if err != nil { return nil, fmt.Errorf("oto: PulseAudio client initialization failed: %w", err) } @@ -97,13 +97,13 @@ func newPulseAudioContext(sampleRate int, channelCount int, mux *mux.Mux, buffer } } - c.stream, err = c.client.NewPlayback(pulse.Float32Reader(c.read), options...) + client.stream, err = client.client.NewPlayback(pulse.Float32Reader(client.read), options...) if err != nil { return nil, fmt.Errorf("oto: PulseAudio playback initialization failed: %w", err) } - c.stream.Start() + client.stream.Start() - return c, nil + return client, nil } func (c *pulseAudioContext) read(buf []float32) (int, error) { From 1fcf39201f1e7757d02609fe2b6068507ce585d2 Mon Sep 17 00:00:00 2001 From: Andrew Montgomery Date: Tue, 31 Mar 2026 18:36:20 +0100 Subject: [PATCH 13/14] refactor: simplify pulse audio context --- driver_unix.go | 114 +++++++++---------------------------------------- 1 file changed, 21 insertions(+), 93 deletions(-) diff --git a/driver_unix.go b/driver_unix.go index c4c9b7f..283606f 100644 --- a/driver_unix.go +++ b/driver_unix.go @@ -27,40 +27,30 @@ import ( "github.com/ebitengine/oto/v3/internal/mux" ) -var newPulseAudioContextFunc pulseAudioContextFactory = newPulseAudioContext - type context struct { - *pulseOnlyContext -} - -func newContext(sampleRate int, channelCount int, format mux.Format, bufferSizeInBytes int, clientApplicationName string) (*context, chan struct{}, error) { - ctx, ready, err := newPulseOnlyContext(newPulseAudioContextFunc, sampleRate, channelCount, format, bufferSizeInBytes, clientApplicationName) - if err != nil { - return nil, nil, err - } - return &context{pulseOnlyContext: ctx}, ready, nil -} - -type pulseAudioContextFactory func(sampleRate int, channelCount int, mux *mux.Mux, bufferSizeInBytes int, clientApplicationName string) (*pulseAudioContext, error) - -type pulseAudioContext struct { client *pulse.Client stream *pulse.PlaybackStream suspended bool cond *sync.Cond - mux *mux.Mux - err atomicError + mux *mux.Mux + ready chan struct{} + err atomicError } -func newPulseAudioContext(sampleRate int, channelCount int, mux *mux.Mux, bufferSizeInBytes int, applicationName string) (client *pulseAudioContext, err error) { - client = &pulseAudioContext{ - cond: sync.NewCond(&sync.Mutex{}), - mux: mux, +func newContext(sampleRate int, channelCount int, format mux.Format, bufferSizeInBytes int, applicationName string) (client *context, ready chan struct{}, err error) { + client = &context{ + cond: sync.NewCond(&sync.Mutex{}), + ready: make(chan struct{}), + mux: mux.New(sampleRate, channelCount, format), } defer func() { - if client.client != nil && err != nil { + if client.client == nil { + return + } + close(client.ready) + if err != nil { client.client.Close() } }() @@ -75,7 +65,7 @@ func newPulseAudioContext(sampleRate int, channelCount int, mux *mux.Mux, buffer client.client, err = pulse.NewClient(pulse.ClientApplicationName(applicationName)) if err != nil { - return nil, fmt.Errorf("oto: PulseAudio client initialization failed: %w", err) + return nil, client.ready, fmt.Errorf("oto: PulseAudio client initialization failed: %w", err) } options := []pulse.PlaybackOption{ @@ -87,7 +77,7 @@ func newPulseAudioContext(sampleRate int, channelCount int, mux *mux.Mux, buffer case 2: options = append(options, pulse.PlaybackStereo) default: - return nil, fmt.Errorf("oto: PulseAudio backend supports only mono or stereo output: %d", channelCount) + return nil, client.ready, fmt.Errorf("oto: PulseAudio backend supports only mono or stereo output: %d", channelCount) } options = append(options, pulse.PlaybackSampleRate(sampleRate)) if bufferSizeInBytes != 0 { @@ -99,14 +89,14 @@ func newPulseAudioContext(sampleRate int, channelCount int, mux *mux.Mux, buffer client.stream, err = client.client.NewPlayback(pulse.Float32Reader(client.read), options...) if err != nil { - return nil, fmt.Errorf("oto: PulseAudio playback initialization failed: %w", err) + return nil, client.ready, fmt.Errorf("oto: PulseAudio playback initialization failed: %w", err) } client.stream.Start() - return client, nil + return client, client.ready, nil } -func (c *pulseAudioContext) read(buf []float32) (int, error) { +func (c *context) read(buf []float32) (int, error) { c.cond.L.Lock() defer c.cond.L.Unlock() @@ -121,7 +111,7 @@ func (c *pulseAudioContext) read(buf []float32) (int, error) { return len(buf), nil } -func (c *pulseAudioContext) Suspend() error { +func (c *context) Suspend() error { c.cond.L.Lock() defer c.cond.L.Unlock() @@ -137,7 +127,7 @@ func (c *pulseAudioContext) Suspend() error { return nil } -func (c *pulseAudioContext) Resume() error { +func (c *context) Resume() error { c.cond.L.Lock() defer c.cond.L.Unlock() @@ -154,7 +144,7 @@ func (c *pulseAudioContext) Resume() error { return nil } -func (c *pulseAudioContext) Err() error { +func (c *context) Err() error { if err := c.err.Load(); err != nil { return err } @@ -163,65 +153,3 @@ func (c *pulseAudioContext) Err() error { } return nil } - -type pulseOnlyContext struct { - pulseAudioContext *pulseAudioContext - - ready chan struct{} - err atomicError - - mux *mux.Mux -} - -func newPulseOnlyContext(factory pulseAudioContextFactory, sampleRate int, channelCount int, format mux.Format, bufferSizeInBytes int, clientApplicationName string) (*pulseOnlyContext, chan struct{}, error) { - ctx := &pulseOnlyContext{ - ready: make(chan struct{}), - mux: mux.New(sampleRate, channelCount, format), - } - - go func() { - defer close(ctx.ready) - - pc, err := factory(sampleRate, channelCount, ctx.mux, bufferSizeInBytes, clientApplicationName) - if err != nil { - ctx.err.TryStore(err) - return - } - ctx.pulseAudioContext = pc - }() - - return ctx, ctx.ready, nil -} - -func (c *pulseOnlyContext) Suspend() error { - <-c.ready - if c.pulseAudioContext != nil { - return c.pulseAudioContext.Suspend() - } - return nil -} - -func (c *pulseOnlyContext) Resume() error { - <-c.ready - if c.pulseAudioContext != nil { - return c.pulseAudioContext.Resume() - } - return nil -} - -func (c *pulseOnlyContext) Err() error { - if err := c.err.Load(); err != nil { - return err - } - - select { - case <-c.ready: - default: - return nil - } - - if c.pulseAudioContext != nil { - return c.pulseAudioContext.Err() - } - return nil -} From c0c74e55eb79a12af342c70a944374a785731ebc Mon Sep 17 00:00:00 2001 From: Andrew Montgomery Date: Wed, 1 Apr 2026 06:58:26 +0100 Subject: [PATCH 14/14] refactor: simpler ready chan, signal instead of broadcast --- driver_unix.go | 28 ++++++++++++---------------- 1 file changed, 12 insertions(+), 16 deletions(-) diff --git a/driver_unix.go b/driver_unix.go index 283606f..3d7a25e 100644 --- a/driver_unix.go +++ b/driver_unix.go @@ -34,23 +34,19 @@ type context struct { suspended bool cond *sync.Cond - mux *mux.Mux - ready chan struct{} - err atomicError + mux *mux.Mux + err atomicError } func newContext(sampleRate int, channelCount int, format mux.Format, bufferSizeInBytes int, applicationName string) (client *context, ready chan struct{}, err error) { client = &context{ - cond: sync.NewCond(&sync.Mutex{}), - ready: make(chan struct{}), - mux: mux.New(sampleRate, channelCount, format), + cond: sync.NewCond(&sync.Mutex{}), + mux: mux.New(sampleRate, channelCount, format), } + ready = make(chan struct{}) + close(ready) defer func() { - if client.client == nil { - return - } - close(client.ready) - if err != nil { + if client.client != nil && err != nil { client.client.Close() } }() @@ -65,7 +61,7 @@ func newContext(sampleRate int, channelCount int, format mux.Format, bufferSizeI client.client, err = pulse.NewClient(pulse.ClientApplicationName(applicationName)) if err != nil { - return nil, client.ready, fmt.Errorf("oto: PulseAudio client initialization failed: %w", err) + return nil, ready, fmt.Errorf("oto: PulseAudio client initialization failed: %w", err) } options := []pulse.PlaybackOption{ @@ -77,7 +73,7 @@ func newContext(sampleRate int, channelCount int, format mux.Format, bufferSizeI case 2: options = append(options, pulse.PlaybackStereo) default: - return nil, client.ready, fmt.Errorf("oto: PulseAudio backend supports only mono or stereo output: %d", channelCount) + return nil, ready, fmt.Errorf("oto: PulseAudio backend supports only mono or stereo output: %d", channelCount) } options = append(options, pulse.PlaybackSampleRate(sampleRate)) if bufferSizeInBytes != 0 { @@ -89,11 +85,11 @@ func newContext(sampleRate int, channelCount int, format mux.Format, bufferSizeI client.stream, err = client.client.NewPlayback(pulse.Float32Reader(client.read), options...) if err != nil { - return nil, client.ready, fmt.Errorf("oto: PulseAudio playback initialization failed: %w", err) + return nil, ready, fmt.Errorf("oto: PulseAudio playback initialization failed: %w", err) } client.stream.Start() - return client, client.ready, nil + return client, ready, nil } func (c *context) read(buf []float32) (int, error) { @@ -140,7 +136,7 @@ func (c *context) Resume() error { c.suspended = false c.stream.Resume() - c.cond.Broadcast() + c.cond.Signal() return nil }