feat: respect AudioContextOptions.latencyHint when opening the Android output stream - #1232
DanielOrmeno wants to merge 4 commits into
Conversation
…d output stream AudioContext now accepts the Web Audio latencyHint category and the Android backend maps it to Oboe performance modes: interactive -> LowLatency (unchanged default), balanced -> None, playback -> PowerSaving. On web the hint is forwarded to the browser AudioContext; on iOS it is accepted but does not change the stream yet. Numeric hints are not supported yet. Motivation: the hard-coded LowLatency stream is granted a very small buffer (192 frames / 4 ms on an entry-level Samsung) and misses ~220 render deadlines per second when a multi-source graph renders, heard as continuous crackle. The same graph on a PerformanceMode::None stream plays clean. Measured via AudioStream::getXRunCount on device.
|
Updated the branch against current Verified locally on the updated branch:
CI hasn't run on this PR at all — the workflow runs are sitting at On next steps, I'd welcome any direction: whether the One thing we ran into downstream that may deserve its own issue: on a Samsung SM-A176U1 with a USB audio interface, the hard-coded Thanks, and no rush — just wanted to get the branch mergeable again. |
|
to be honest ideally we wanted to have also ios side implemented in this pr, we played around a little bit, but couldn't achieved any satisfying solution, that's why it was sitting idle for a while, also thank you for sharing that sometimes |
|
Makes total sense, I'm having a go at ios too, will update the PR accordingly. |
The Android half of this PR had nothing to pair with on iOS because the backend never requested a buffer duration at all: playback ran at whatever AVAudioSession defaulted to for the category. iOS is the mirror image of the Android bug — there was no way to ask for LOW latency, so 'interactive' was unimplementable. An absent hint now stays absent rather than becoming INTERACTIVE, so each backend keeps the stream it opened before the hint existed. Android reads absence as LowLatency exactly as before; iOS leaves the session untouched, because dropping every existing playback app to a 2.7 ms buffer is how issue software-mansion#1231 would arrive on iOS. An unrecognised string is treated the same way, where a browser throws a TypeError. 'interactive' asks for one render quantum and 'balanced' for four, in frames of the SESSION's rate rather than the context's — asking in seconds made 'interactive' request 256 frames on a 44.1 kHz context driving a 48 kHz output, and at low context rates asked for a buffer deeper than the default it was meant to shorten. 'playback' asks for nothing, the session default being deep already. The duration belongs to the process-wide session rather than to one stream, so live requests are reconciled by taking the shortest, and releasing the last one asks for what the session ran at beforehand. That baseline is snapped to the nearest power-of-two frame count because the session grants those and rounds a raw request down: asking for a 23 ms baseline as-is was granted 11 ms, and rounding up alone asked for 43 ms. Verified on an iPhone 17 simulator by reading back context.outputLatency: omitted 10.10 ms with no request made, 'interactive' 2.77 ms (128 frames granted) at both 48 kHz and 44.1 kHz, 'balanced' 10.77 ms (512 frames), and the deep buffer restored once the interactive context closed. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
Pushed the iOS side. Why it's shaped differently from the Android half: the iOS backend never called That changed one thing from the original PR: an omitted hint now stays omitted ( Measured on an iPhone 17 simulator via Two platform details worth knowing:
The hint→frames policy and the string parse moved into On WPT, I built the node addon and ran A few things I'd rather you decided:
Nothing has run on physical iPhone hardware — the simulator doesn't grant buffer durations the way a device does, and the wired and Bluetooth routes are unmeasured. Happy to do that before you merge. CI hasn't run on this branch yet (still |
WPT non-regression comparisonPASS — no regressions · 1 improved section(s) · overall 2716 → 2717 (+1)
Unchanged sections (27)
Baseline: Workflow run · this comment is updated on every push. |
|
ok few observations when playing around on your branch with various option for the latencies:
I think the approach is correct, need probably some adjustments f.e. ios buffer size constants, but overally respecting this implementation should provide a way for better ux of the audio |
mdydek
left a comment
There was a problem hiding this comment.
ios side introduces much more lines than android and I think it could be simplified a little bit
| std::atomic<int32_t> lastCallbackFrameCount_{0}; | ||
| std::mutex *driverMutex_; | ||
| std::weak_ptr<AudioContext> context_; | ||
| std::optional<AudioContextLatencyHint> latencyHint_; |
There was a problem hiding this comment.
I don't like the idea that in one of the player the latency hint is its state, whereas in the other it is not
| inline int preferredIOBufferFramesFor(std::optional<AudioContextLatencyHint> latencyHint) { | ||
| if (!latencyHint.has_value()) { | ||
| return 0; | ||
| } | ||
|
|
||
| switch (*latencyHint) { | ||
| case AudioContextLatencyHint::INTERACTIVE: | ||
| return RENDER_QUANTUM_SIZE; | ||
| case AudioContextLatencyHint::BALANCED: | ||
| return 4 * RENDER_QUANTUM_SIZE; | ||
| case AudioContextLatencyHint::PLAYBACK: | ||
| return 0; | ||
| } | ||
|
|
||
| return 0; | ||
| } |
There was a problem hiding this comment.
android completely do not use this function, move it to the utils in the ios side
| case AudioContextLatencyHint::BALANCED: | ||
| return 4 * RENDER_QUANTUM_SIZE; | ||
| case AudioContextLatencyHint::PLAYBACK: | ||
| return 0; |
There was a problem hiding this comment.
| case AudioContextLatencyHint::BALANCED: | |
| return 4 * RENDER_QUANTUM_SIZE; | |
| case AudioContextLatencyHint::PLAYBACK: | |
| return 0; | |
| case AudioContextLatencyHint::BALANCED: | |
| return 8 * RENDER_QUANTUM_SIZE; | |
| case AudioContextLatencyHint::PLAYBACK: | |
| return 32 * RENDER_QUANTUM_SIZE; |
my proposition for the values, 0 is treated as default (8 * RQS), we can enlarge it to 32 * RQS
| int frames = 0; | ||
| for (NSNumber *clientFrames in self.ioBufferFramesRequests.objectEnumerator) { | ||
| int clientRequest = [clientFrames intValue]; | ||
| if (frames == 0 || clientRequest < frames) { | ||
| frames = clientRequest; | ||
| } | ||
| } | ||
|
|
||
| if (frames == 0 && self.baselineIOBufferDuration <= 0.0) { | ||
| return; | ||
| } | ||
|
|
||
| double sampleRate = self.audioSession.sampleRate; | ||
| if (sampleRate <= 0.0) { | ||
| return; | ||
| } |
There was a problem hiding this comment.
looks like kind off too much work for taking min from the dictionary and few returns
Summary
Implements
AudioContextOptions.latencyHint(category form) and maps it to each backend's stream configuration, so playback-heavy apps can opt out of the underrun-prone low-latency stream on Android, and latency-sensitive apps can opt into a short buffer on iOS.Closes #1231 — measurements there: on an entry-level Samsung the hard-coded
LowLatencystream is granted 192 frames (4 ms) and misses ~210–230 render deadlines per second while an 11-source graph plays. The same graph onPerformanceMode::Noneplays clean.Mapping
latencyHintPerformanceMode)AVAudioSession.preferredIOBufferDuration)LowLatency— unchanged'interactive'LowLatency'balanced'None'playback'PowerSavingiOS is the mirror image of the Android problem: the backend never requested a buffer duration, so it ran deep and could not go low. That is why the two columns aren't symmetric, and why an omitted hint is not treated as
interactive— doing so would drop every existing iOS playback app to a ~2.7 ms buffer. An unrecognised string is likewise treated as no hint, where a browser throws aTypeError.On iOS the duration belongs to the process-wide session rather than to one stream, so live requests reconcile by taking the shortest;
balancedandplaybacktherefore lose to a concurrentinteractive. Releasing the last request asks for what the session ran at beforehand, snapped to the nearest power-of-two frame count, because the session grants those and rounds a raw request down.What changed
AudioContextLatencyHintincommon/cpp/audioapi/core/types/plus the hint→frames policy;latencyHintFromStringsits injs_enum_parserwith the other string→enum conversions.AudioPlayermaps the hint to aPerformanceModewhen opening the stream.AudioSessionManagerreconciles buffer-duration requests per client;NativeAudioPlayerholds one while rendering and releases it on stop, suspend, cleanup and every failure path.createAudioContextgains an optional second string argument, in both the module installer and the WPT binding.AudioContextOptions.latencyHint?: AudioContextLatencyCategory, forwarded to the browser on web.audio-context.mdxconstructor table plus a per-platform table.Non-breaking: every new constructor parameter is defaulted, and an omitted hint is today's behaviour on both platforms.
Out of scope, per #1231: numeric hints in seconds, which would map to
setBufferSizeInFrameson Android.Test plan
typecheck,lint:js,format:check(clang-format + ktlint),cpplint— clean.test:js— 120/120, including a suite pinning that an omitted hint reaches native as omitted, on both the native and web paths.common/cpp/test/run-tests.sh smoke— 428/428, includingAudioContextLatencyHintTestover the parse table and the frames policy. Mutation-checked: remappinginteractive, makingplaybackor an absent hint request a buffer, and changing the balanced multiple each fail the suite.build:ios(xcodebuild, iOS Simulator) — clean.audiocontextoptions.htmlrun on this branch and on pristinemain— 37/41 both, identical.context.outputLatency: omitted 10.10 ms with no request issued,interactive2.77 ms (128 frames granted, at 48 kHz and 44.1 kHz contexts),balanced10.77 ms (512 frames), deep buffer restored after close.interactivereproduces the underruns,balancedplays the 11-source session clean (AudioStream::getXRunCount()logging).🤖 Generated with Claude Code