Summary
On Linux, the ALSA backend hardcodes the PCM format request to SND_PCM_FORMAT_FLOAT_LE with no fallback. On a device that doesn't support float formats, snd_pcm_hw_params_set_format fails — but that failure happens inside a background goroutine and is never surfaced through NewContext's synchronous error return. readyChan closes regardless of success or failure (defer close(c.ready)), so every caller waiting on <-readyChan proceeds as if setup succeeded. Playback then silently does nothing: the PCM handle is opened but hw_params is never actually committed, so it never leaves ALSA's OPEN state.
Root cause (traced in driver_unix.go, v3.4.0)
alsaPcmHwParams (called from the background goroutine spawned in newContext):
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)
}
This is unconditional — there's no attempt to check what formats the device actually supports (which snd_pcm_hw_params_any + HW_REFINE already tells you at that point) and no fallback to another format (e.g. S32_LE, S16_LE).
When this fails, the error is stored in c.err and the goroutine returns early — but:
go func() {
defer close(c.ready)
...
if err := c.alsaPcmHwParams(...); err != nil {
c.err.TryStore(err)
return // <-- c.ready still closes via the defer above
}
...
}()
return c, c.ready, nil // <-- this synchronous error is always nil for async failures
newContext's synchronous error return value only covers errors before the goroutine is spawned. Everything inside the goroutine — including snd_pcm_hw_params_set_format failing — is invisible to a caller doing the normal:
ctx, ready, err := oto.NewContext(opts)
if err != nil { ... } // never true for this failure
<-ready // returns immediately, failure or not
player := ctx.NewPlayer(...)
player.Play() // silently does nothing
The only way to discover the failure afterward is to call ctx.Err() — but nothing in the public API or docs indicates callers must poll that after <-ready, and libraries built on oto (e.g. gopxl/beep's speaker package) don't.
Reproduction
Hardware: FiiO K7 USB DAC (hw:5,0 on Linux/ALSA). Its supported format mask, per snd_pcm_hw_params_any:
FORMAT: S16_LE S32_LE DSD_U32_BE
No float format at all. Confirmed independently of oto:
$ aplay -D hw:5,0 -f FLOAT_LE -r 48000 -c 2 test.wav
Warning: format is changed to S16_LE
aplay's own convenience layer detects the mismatch and substitutes a supported format automatically. oto's snd_pcm_hw_params_set_format call has no equivalent fallback — it just fails.
Confirmed via strace -f -v -e trace=ioctl against a real oto-based app (cliamp, using gopxl/beep/v2) targeting this device: the trace shows two SNDRV_PCM_IOCTL_HW_REFINE calls (the initial hw_params_any probe, then narrowing ACCESS to RW_INTERLEAVED) and then nothing else, ever — no further ioctls, no SNDRV_PCM_IOCTL_HW_PARAMS commit. /proc/asound/cardN/pcmXpX/subX/status stays at state: OPEN / hw_params: no setup indefinitely. The app reports "playing" the whole time since it has no way to know otherwise.
Suggested fix
Either (or both):
- Try a small ordered list of candidate formats (e.g.
FLOAT_LE, S32_LE, S16_LE) in alsaPcmHwParams, falling through to the next on -EINVAL, similar to how aplay/most ALSA apps handle this.
- Don't close
c.ready until the outcome (success or failure) can be distinguished by the caller — e.g. document that ctx.Err() must be checked after <-readyChan returns, or better, make NewContext return the async setup error synchronously by waiting for the goroutine's first setup phase before returning (only the long-running read/write loop needs to stay async).
Environment
- oto v3.4.0 (via
gopxl/beep/v2 v2.1.1, as used by bjarneo/cliamp v1.63.2)
- Arch Linux, kernel with
snd-usb-audio, ALSA via alsa-lib
- Device: FiiO K7 USB DAC,
hw:5,0
Summary
On Linux, the ALSA backend hardcodes the PCM format request to
SND_PCM_FORMAT_FLOAT_LEwith no fallback. On a device that doesn't support float formats,snd_pcm_hw_params_set_formatfails — but that failure happens inside a background goroutine and is never surfaced throughNewContext's synchronous error return.readyChancloses regardless of success or failure (defer close(c.ready)), so every caller waiting on<-readyChanproceeds as if setup succeeded. Playback then silently does nothing: the PCM handle is opened buthw_paramsis never actually committed, so it never leaves ALSA'sOPENstate.Root cause (traced in
driver_unix.go, v3.4.0)alsaPcmHwParams(called from the background goroutine spawned innewContext):This is unconditional — there's no attempt to check what formats the device actually supports (which
snd_pcm_hw_params_any+HW_REFINEalready tells you at that point) and no fallback to another format (e.g.S32_LE,S16_LE).When this fails, the error is stored in
c.errand the goroutine returns early — but:newContext's synchronouserrorreturn value only covers errors before the goroutine is spawned. Everything inside the goroutine — includingsnd_pcm_hw_params_set_formatfailing — is invisible to a caller doing the normal:The only way to discover the failure afterward is to call
ctx.Err()— but nothing in the public API or docs indicates callers must poll that after<-ready, and libraries built on oto (e.g.gopxl/beep'sspeakerpackage) don't.Reproduction
Hardware: FiiO K7 USB DAC (
hw:5,0on Linux/ALSA). Its supported format mask, persnd_pcm_hw_params_any:No float format at all. Confirmed independently of oto:
aplay's own convenience layer detects the mismatch and substitutes a supported format automatically. oto'ssnd_pcm_hw_params_set_formatcall has no equivalent fallback — it just fails.Confirmed via
strace -f -v -e trace=ioctlagainst a real oto-based app (cliamp, usinggopxl/beep/v2) targeting this device: the trace shows twoSNDRV_PCM_IOCTL_HW_REFINEcalls (the initialhw_params_anyprobe, then narrowingACCESStoRW_INTERLEAVED) and then nothing else, ever — no further ioctls, noSNDRV_PCM_IOCTL_HW_PARAMScommit./proc/asound/cardN/pcmXpX/subX/statusstays atstate: OPEN/hw_params: no setupindefinitely. The app reports "playing" the whole time since it has no way to know otherwise.Suggested fix
Either (or both):
FLOAT_LE,S32_LE,S16_LE) inalsaPcmHwParams, falling through to the next on-EINVAL, similar to howaplay/most ALSA apps handle this.c.readyuntil the outcome (success or failure) can be distinguished by the caller — e.g. document thatctx.Err()must be checked after<-readyChanreturns, or better, makeNewContextreturn the async setup error synchronously by waiting for the goroutine's first setup phase before returning (only the long-running read/write loop needs to stay async).Environment
gopxl/beep/v2v2.1.1, as used bybjarneo/cliampv1.63.2)snd-usb-audio, ALSA viaalsa-libhw:5,0