The speaker opens at 44100 while the device runs at 48000, so everything is resampled twice #194

Open
opened 2026-08-21 23:33:53 +00:00 by logan · 1 comment
Collaborator

Report

The app opens the speaker at 44100 Hz on every platform. The reference
device's output stream runs at 48000 Hz, so every file is resampled
twice on the way out: once by us into 44100, and once by the platform
back to 48000.

Measured on the device (TLP301, Android 14) with dumpsys audio, for
our own pid, while playing:

AudioPlaybackConfiguration piid:839 deviceId:3
  type:OpenSL ES AudioPlayer (Buffer Queue) u/pid:10191/13280
  state:started ... FormatInfo{channelMask=0x3, sampleRate=48000}

backend/player/player.go:

var speakerSampleRate = beep.SampleRate(44100)
// TODO: allow user to change buffer size and speaker sample rate.

48000 is Android's native output rate on essentially every device, and
the library's own files agree: a sample of the test library reports
SampleRate: 48000 per track, so the first resampling is often
downsampling 48k source material only for the platform to put it back.

This is deliberately not #135. That issue asked whether this was
causing audible popping; it was measured, the ring buffer records zero
underruns, and the reporter has listened on the device and confirmed
the symptom is not present. So this is an efficiency and latency
report, not a defect anyone can hear, and it should be judged on those
terms rather than as a bug fix.

Findings

  • oto gives no way to ask for the native rate. Its Android driver
    calls oboe.Play(sampleRate, ...) and the binding always calls
    builder.setSampleRate(sample_rate_) with whatever it is handed
    (internal/oboe/binding_android.cpp). Oboe would pick the device
    rate if the field were left unset, and there is no path through oto
    to leave it unset — so the number has to come from us.
  • Asking the device needs Java. The standard recipe is
    AudioManager.PROPERTY_OUTPUT_SAMPLE_RATE and
    PROPERTY_OUTPUT_FRAMES_PER_BUFFER. Our Java lives in
    build/android/.../WailsForegroundService.java, and the existing
    Go↔Java channels are application.Android.StartForegroundService
    (JSON out) and WailsBridge.emitEvent (events back) — neither is a
    synchronous "call and return a value", and InitSpeaker runs at
    startup, before the WebView could be asked instead.
  • The stream opens as OpenSL ES, not AAudio, which is worth a look
    on its own: Oboe prefers AAudio from API 27 and this device is API
    34. Requesting a non-native rate is one of the things that can cost
    the fast path, so the two may be the same finding.
  • speakerSampleRate is a package var rather than a const, which
    suggests it was always meant to be settable.

Direction

  1. Decide whether the rate is worth Java plumbing or whether an
    Android-only constant of 48000 is enough. The platform-constant
    shape is already established here — platformOwnsVolume in
    systemvolume_{android,other}.go, with the testable half in an
    untagged file — and it needs no new JNI channel.
  2. Whatever is chosen, re-measure with dumpsys audio that the stream
    opens at the device rate, and check whether it then takes AAudio
    rather than OpenSL ES.
  3. make android-logs will say if it made anything worse: #135's
    underrun counter is silent while playback is healthy and reports
    runs, calls and milliseconds of silence when it is not. Any change
    to the audio path should be judged against it
    , since that is the
    whole reason it exists.

Worth knowing: the buffer size is the other half of the same TODO
and the other half of #135's candidate 3. InitSpeaker's comment says
the speaker buffer is 200 ms; beep halves it, so Oboe gets ~100 ms, and
oto's own documentation for that option suggests raising it to "reduce
noises". It has never been tuned per platform, and a phone can afford
latency a desktop player cannot.

**Report** The app opens the speaker at 44100 Hz on every platform. The reference device's output stream runs at **48000 Hz**, so every file is resampled twice on the way out: once by us into 44100, and once by the platform back to 48000. Measured on the device (TLP301, Android 14) with `dumpsys audio`, for our own pid, while playing: ``` AudioPlaybackConfiguration piid:839 deviceId:3 type:OpenSL ES AudioPlayer (Buffer Queue) u/pid:10191/13280 state:started ... FormatInfo{channelMask=0x3, sampleRate=48000} ``` `backend/player/player.go`: ```go var speakerSampleRate = beep.SampleRate(44100) // TODO: allow user to change buffer size and speaker sample rate. ``` 48000 is Android's native output rate on essentially every device, and the library's own files agree: a sample of the test library reports `SampleRate: 48000` per track, so the first resampling is often downsampling 48k source material only for the platform to put it back. **This is deliberately not #135.** That issue asked whether this was causing audible popping; it was measured, the ring buffer records zero underruns, and the reporter has listened on the device and confirmed the symptom is not present. So this is an efficiency and latency report, not a defect anyone can hear, and it should be judged on those terms rather than as a bug fix. **Findings** - **oto gives no way to ask for the native rate.** Its Android driver calls `oboe.Play(sampleRate, ...)` and the binding always calls `builder.setSampleRate(sample_rate_)` with whatever it is handed (`internal/oboe/binding_android.cpp`). Oboe would pick the device rate if the field were left unset, and there is no path through oto to leave it unset — so the number has to come from us. - **Asking the device needs Java.** The standard recipe is `AudioManager.PROPERTY_OUTPUT_SAMPLE_RATE` and `PROPERTY_OUTPUT_FRAMES_PER_BUFFER`. Our Java lives in `build/android/.../WailsForegroundService.java`, and the existing Go↔Java channels are `application.Android.StartForegroundService` (JSON out) and `WailsBridge.emitEvent` (events back) — neither is a synchronous "call and return a value", and `InitSpeaker` runs at startup, before the WebView could be asked instead. - **The stream opens as OpenSL ES, not AAudio**, which is worth a look on its own: Oboe prefers AAudio from API 27 and this device is API 34. Requesting a non-native rate is one of the things that can cost the fast path, so the two may be the same finding. - `speakerSampleRate` is a package `var` rather than a `const`, which suggests it was always meant to be settable. **Direction** 1. Decide whether the rate is worth Java plumbing or whether an Android-only constant of 48000 is enough. The platform-constant shape is already established here — `platformOwnsVolume` in `systemvolume_{android,other}.go`, with the testable half in an untagged file — and it needs no new JNI channel. 2. Whatever is chosen, re-measure with `dumpsys audio` that the stream opens at the device rate, and check whether it then takes AAudio rather than OpenSL ES. 3. `make android-logs` will say if it made anything worse: #135's underrun counter is silent while playback is healthy and reports runs, calls and milliseconds of silence when it is not. **Any change to the audio path should be judged against it**, since that is the whole reason it exists. **Worth knowing**: the buffer size is the other half of the same `TODO` and the other half of #135's candidate 3. `InitSpeaker`'s comment says the speaker buffer is 200 ms; beep halves it, so Oboe gets ~100 ms, and oto's own documentation for that option suggests raising it to "reduce noises". It has never been tuned per platform, and a phone can afford latency a desktop player cannot.
Author
Collaborator

The "not a defect anyone can hear" framing is now contested — see
#203.

This issue asks to be judged as an efficiency and latency report rather
than a bug fix, on the grounds that "it was measured, the ring buffer
records zero underruns, and the reporter has listened on the device and
confirmed the symptom is not present". Both halves of that come from
#135, and both are narrower than they read:

  • the underrun measurement was taken with PR #191's counter, which is
    not in v0.5.0842fe47 landed after the tag, so no released
    build reports underruns at all;
  • the listening test was on a debug build, attached to USB, charging,
    screen on.

The reporter has now listened on the release build and popping is still
audible, less obvious than #135's static.

That does not make this a bug — it may still be exactly the efficiency
report it says it is. What it does is remove the reason to rule it
out
: the double resampling and the OpenSL ES path are in the layer
whose buffer is being missed, and if #203's re-measurement comes back
at zero underruns again, this becomes the leading candidate rather than
a tidy-up.

Not relabelling on that basis. #203 step 4 is what would decide it, and
it wants ears and the device.

**The "not a defect anyone can hear" framing is now contested — see #203.** This issue asks to be judged as an efficiency and latency report rather than a bug fix, on the grounds that "it was measured, the ring buffer records zero underruns, and the reporter has listened on the device and confirmed the symptom is not present". Both halves of that come from #135, and both are narrower than they read: - the underrun measurement was taken with PR #191's counter, which is **not in `v0.5.0`** — `842fe47` landed after the tag, so no released build reports underruns at all; - the listening test was on a debug build, attached to USB, charging, screen on. The reporter has now listened on the release build and popping is still audible, less obvious than #135's static. That does not make this a bug — it may still be exactly the efficiency report it says it is. What it does is remove the reason to *rule it out*: the double resampling and the OpenSL ES path are in the layer whose buffer is being missed, and if #203's re-measurement comes back at zero underruns again, this becomes the leading candidate rather than a tidy-up. Not relabelling on that basis. #203 step 4 is what would decide it, and it wants ears and the device.
Sign in to join this conversation.
1 Participants
Notifications
Due Date
No due date set.
Dependencies

No dependencies set.

Reference: yonlu/yellowjacket#194