diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index ed81710..62c0927 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -61,6 +61,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 diff --git a/README.md b/README.md index 6d0baaa..5958cc7 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) @@ -20,14 +19,14 @@ A low-level library to play sound. ## Platforms -- Windows (no Cgo required!) -- macOS (no Cgo required!) -- Linux -- FreeBSD -- OpenBSD +- Windows (no Cgo required) +- macOS (no Cgo required) +- Linux (no Cgo required) +- FreeBSD (no Cgo required) +- OpenBSD (no Cgo required) - Android - iOS -- WebAssembly +- WebAssembly (no Cgo required) - Nintendo Switch - Xbox @@ -37,7 +36,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) +- 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 @@ -52,25 +51,12 @@ Oto requires these frameworks: Add them to "Linked Frameworks and Libraries" on your Xcode project. -### Linux +### Linux, FreeBSD, OpenBSD -ALSA is required. On Ubuntu or Debian, run this command: +Oto uses PulseAudio on Linux and BSD systems via the pure-Go package `github.com/jfreymuth/pulse`, +though BSD systems are not tested well. -```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. - -### FreeBSD, OpenBSD - -BSD systems are not tested well. If ALSA works, Oto should work. +If the PulseAudio server is not discoverable automatically, set `PULSE_SERVER`. ## Usage @@ -222,7 +208,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/context.go b/context.go index c6c9931..0974bb8 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 + + // ApplicationName specifies the name of the client application. + // It is used for PulseAudio's volume control UI and so on. + ApplicationName 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.ApplicationName) 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_unix.go b/driver_unix.go index 5b40195..3d7a25e 100644 --- a/driver_unix.go +++ b/driver_unix.go @@ -16,263 +16,136 @@ package oto -// #cgo pkg-config: alsa -// -// #include -import "C" - import ( "fmt" - "strings" + "os" + "path/filepath" "sync" - "unsafe" + + "github.com/jfreymuth/pulse" "github.com/ebitengine/oto/v3/internal/mux" ) type context struct { - channelCount int + client *pulse.Client + stream *pulse.PlaybackStream suspended bool - - handle *C.snd_pcm_t - - cond *sync.Cond + 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"} +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{}), + mux: mux.New(sampleRate, channelCount, format), } - 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 + ready = make(chan struct{}) + close(ready) + defer func() { + if client.client != nil && err != nil { + client.client.Close() } + }() - 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 + if applicationName == "" { + if name, _ := os.Executable(); name != "" { + applicationName = filepath.Base(name) + } else { + applicationName = "Oto" } - devices = append(devices, goName) } - devices = append([]string{"default", "plug:default"}, devices...) - - return devices -} - -func newContext(sampleRate int, channelCount int, format mux.Format, bufferSizeInBytes int) (*context, chan struct{}, error) { - c := &context{ - channelCount: channelCount, - cond: sync.NewCond(&sync.Mutex{}), - mux: mux.New(sampleRate, channelCount, format), - ready: make(chan struct{}), + client.client, err = pulse.NewClient(pulse.ClientApplicationName(applicationName)) + if err != nil { + return nil, ready, fmt.Errorf("oto: PulseAudio client initialization failed: %w", err) } - 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) + options := []pulse.PlaybackOption{ + pulse.PlaybackMediaName(applicationName), } - 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) + switch channelCount { + case 1: + options = append(options, pulse.PlaybackMono) + case 2: + options = append(options, pulse.PlaybackStereo) + default: + return nil, ready, fmt.Errorf("oto: PulseAudio backend supports only mono or stereo output: %d", channelCount) } - 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) + 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)) + } } - if err := C.snd_pcm_hw_params(c.handle, params); err < 0 { - return alsaError("snd_pcm_hw_params", err) + + client.stream, err = client.client.NewPlayback(pulse.Float32Reader(client.read), options...) + if err != nil { + return nil, ready, fmt.Errorf("oto: PulseAudio playback initialization failed: %w", err) } - return nil + client.stream.Start() + + return client, ready, nil } -func (c *context) readAndWrite(buf32 []float32) bool { +func (c *context) 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 c.err.Load() != nil { - return false + if err := c.err.Load(); err != nil { + return 0, err } - 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 + c.mux.ReadFloat32s(buf) + return len(buf), nil } 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) + return err + } + if err := c.stream.Error(); err != nil { + return fmt.Errorf("oto: PulseAudio error: %w", err) } 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). + c.stream.Pause() 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) + 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.Signal() return nil } func (c *context) Err() error { if err := c.err.Load(); err != nil { - return err.(error) + return err + } + if err := c.stream.Error(); err != nil { + return fmt.Errorf("oto: PulseAudio error: %w", err) } return nil } 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, diff --git a/go.mod b/go.mod index 61a707a..0a74965 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.1 golang.org/x/sys v0.41.0 ) diff --git a/go.sum b/go.sum index dfb249f..6e5b8f1 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.1 h1:9WLNBNCijmtZ14ZJpatgJPu/NjwAl3TIKItSFnTh+9A= +github.com/jfreymuth/pulse v0.1.1/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=