Repository navigation
Conversation
c43e32d to
cf783d0
Compare
|
Resolve the conflicts. |
OutputLatency returns how long the sound the context has read from its players takes to be heard: what the fifo queues, and what the stream holds, as its timestamp says. On Android the timestamp counts a Bluetooth headset's own delay where the headset reports it. LoopRead measures it every 100 ms. Other platforms report nothing yet. An application that shows the sound it plays, as a spectrum or a beat, needs it to show what is heard rather than what was read: over Bluetooth earbuds the difference is a quarter of a second. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
OutputLatency is declared in context.go and calls the driver's context, as Suspend, Resume and Err do. Android reports the latency, and the other drivers report none for now; each is where a later change adds its platform. outputlatency_android.go and outputlatency_other.go are gone. TestOutputLatency asks the test's context: a latency reported is zero or more, and one not reported is zero. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
cf783d0 to
e478e38
Compare
|
Rebased onto 91d77a3, now that #306 is in. The conflicts were all in Reply written by Claude (Anthropic), an AI agent, and posted at Marcus's request. |
|
Cannot we implement the same things for other platforms? It's ok to focus on Android in this PR, but at least we should leave TODO comments |
Each driver that reports no latency yet has a TODO naming what it would use: PulseAudio's latency query and snd_pcm_delay on Linux, IAudioClock and waveOutGetPosition on Windows, the audio queue's time and the device's latency on macOS and iOS, and the AudioContext's latencies on the web. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
hajimehoshi
left a comment
There was a problem hiding this comment.
Apart from the shape of the API, I found some issues in the Android measurement. Most of this code would carry over to the player-level design, so they apply either way.
This review was drafted with Claude (Claude Code).
| // stream_latency_frames_ is how many frames the stream playing holds that are | ||
| // still to be heard, as its timestamp last said, or -1 while it has not said. | ||
| // LoopRead measures it every kLatencyEvery. | ||
| std::atomic<bool> buffers_ready_{false}; |
There was a problem hiding this comment.
This is not needed. Only LoopRead stores a non-negative stream_latency_frames_, and it starts after fifo_ is made. If Latency checks stream_latency_frames_ first, a value of 0 or more already means fifo_ is ready.
| } | ||
| ConfigureRefillLocked(); | ||
| // The stream is new, and has not said how long it takes yet. | ||
| stream_latency_frames_.store(-1); |
There was a problem hiding this comment.
This is the only place the value is reset. After onErrorAfterClose, a failed restart, or Pause, the last value is still reported as valid, plus a fifo that has filled up because nothing drains it. Could it be reset wherever stream_ is dropped or leaves kRunning?
| if (stream < 0) { | ||
| return -1; | ||
| } | ||
| return stream + fifo_->getFullFramesAvailable(); |
There was a problem hiding this comment.
stream was measured up to 100 ms ago, and the fifo count is from now. Each jumps by a burst at every callback, in opposite directions (Oboe's documentation says an output stream's latency "will increase abruptly when you write data to it"), so the sum is smooth only when both are sampled together. Otherwise it can be off by up to a burst, which is tens of ms over Bluetooth. How about recording, at each measurement, the fifo's write counter and when that frame will be heard, and computing the latency from that pair here?
| measured = now; | ||
| // The stream is reached under mutex_, which Pause and Resume hold only | ||
| // briefly; a measurement is skipped rather than waited for. | ||
| std::unique_lock<std::mutex> lock{mutex_, std::try_to_lock}; |
There was a problem hiding this comment.
This holds mutex_ across calculateLatencyMillis, which queries AAudio. The comment in StartLocked says nothing under mutex_ may wait on a device, as Pause and Resume take it on the UI thread. try_lock keeps this thread from waiting for others, but not others from waiting for it, and the fifo is not refilled meanwhile either. How about copying stream_ and a generation counter under the lock, querying the copy after releasing it, and storing the result only if the generation is unchanged?
| // briefly; a measurement is skipped rather than waited for. | ||
| std::unique_lock<std::mutex> lock{mutex_, std::try_to_lock}; | ||
| if (lock.owns_lock() && stream_ && state_ == State::kRunning) { | ||
| if (auto ms = stream_->calculateLatencyMillis(); ms) { |
There was a problem hiding this comment.
Oboe implements calculateLatencyMillis only for AAudio. OpenSL ES returns ErrorUnimplemented, and has no getTimestamp either. AudioApiForSdk uses OpenSL ES below Android 11, and StartOrDeferLocked falls back to it when AAudio refuses the configuration, so nothing is ever reported on those devices. Could we estimate it there, e.g. from the stream's buffer size plus the fifo? Otherwise the documentation should say so.
| std::unique_lock<std::mutex> lock{mutex_, std::try_to_lock}; | ||
| if (lock.owns_lock() && stream_ && state_ == State::kRunning) { | ||
| if (auto ms = stream_->calculateLatencyMillis(); ms) { | ||
| stream_latency_frames_.store( |
There was a problem hiding this comment.
calculateLatencyMillis is the time the next frame will be heard minus now, with no clamping, so it can be negative, e.g. around an underrun. Latency treats any negative value as not reported yet, so the result flips to unavailable for at least 100 ms. Clamping it to 0 here would avoid that.
|
Thank you for working on this. Before going into the details, I'd like to settle the shape of the API. Context or playerThe delay after the mux is shared by all the players, so measuring it per context in each driver makes sense. But apps need it per player: which part of this player's data is being heard now. Ebitengine's
Only the mux can get these right, so how about this?
Then Ebitengine only has to call the new method instead of I'd rather not make UnitBytes, like NameWith this, Just a suggestion, nothing decided: if a context-level value is made public, This comment was drafted with Claude (Claude Code). |
…tency The delay after the mux is shared by the players, but an app needs it per player: which of this player's data is heard now, also after Pause, right after Play, and at the end of the source. - The mux counts the samples it mixes, and each player records the spans of the mix its data went into, while they may be unheard. - Each driver reports, through Mux.SetDelayFunc, how many of the frames the mux has mixed are not heard yet. Android does; the others have a TODO naming what they would use. - Player.UnplayedSize returns the bytes read from the source that are not heard yet: what BufferedSize returns, and what was sent on and not played. Where the driver reports nothing, it returns what BufferedSize does. Seek and Reset forget what was sent. - Context.OutputLatency is gone. On Android, LoopRead measures every 100 ms when the frame written to the fifo next will be heard, and Delay counts on from that pair, so the fifo and the stream are sampled together. The stream is asked outside mutex_, and a measurement is kept only if the stream is unchanged and no callback ran meanwhile. It is forgotten wherever the stream is dropped, paused or replaced, clamped at zero, and estimated from the stream's buffer on OpenSL ES, which has no timestamps. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The UnplayedSize tests run in testing/synctest bubbles and wait for the mux's loop with synctest.Wait, so their results depend on no machine's scheduler. Their cleanup lets the loop see Stop after the moment it sleeps once a source has ended. sentSpan's literal has a field a line. Doc comments say what each thing does: how spans merge is said where they merge, and ForgetDelayLocked's says only what it forgets. MeasureDelay says why a few tries are enough. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
|
Thanks, this is a better shape. 3d1f768 reworks the PR along your outline, and 8913cd7 runs its tests in virtual time:
The six points from your review of the Android code are all in: no The new tests in One thing we haven't explained: once, during the phone test, right after a seek near the end of a track, the app's visuals went silent, as if the music had stopped, while the music played on, and they stayed silent across track changes until the app restarted. The app draws from a history of about 5.5 seconds of the mix, at the position it computes as heard, so this fits Reply written by Claude (Anthropic), an AI agent, and posted at Marcus's request. |
This change and its description were written by Claude, an AI agent, working with @marrasen on gunim, a GUI framework that plays its sound through oto. Marcus tested it on his phone.
What issue is this addressing?
Updates #311
What type of issue is this addressing?
feature
What this PR does | solves
An application that draws the sound it plays needs to know which part of a player's data is being heard now.
BufferedSizecounts only what the player still holds, but after the mux the sound takes a while longer to reach the listener: about 250 ms over Bluetooth earbuds. This adds one method:So the part of the source being heard now is the bytes read from it less
UnplayedSize, afterPause, right afterPlay, and at the end of the source alike.The mux
Muxcounts the samplesReadFloat32smixes.Mux.SetDelayFunc, how many of the frames the mux has mixed are not heard yet. Where it reports nothing,UnplayedSizereturns whatBufferedSizedoes.SeekandResetforget the spans, as their data is from the old position.Android
The binding reports the delay. Every 100 ms,
LoopReadmeasures when the frame written to the fifo next will be heard, fromcalculateLatencyMillisand the frames queued, andDelaycounts on from that pair, so the fifo and the stream are sampled together.mutex_. Its pointer and a generation counter are copied under the lock, and the result is kept only if the generation is unchanged and no callback ran meanwhile.The other drivers report nothing yet. Each has a TODO beside its
mux.New, naming what it would use.Context.OutputLatency, from the first version of this PR, is gone.Testing
internal/mux/unplayed_test.godrives the mux as a driver does, with a delay it controls: no delay reported, steady play, right afterPlay, afterPause, at the end of the source, and afterSeek. The tests run intesting/synctestbubbles and wait for the mux's loop withsynctest.Wait, so their results depend on no machine's scheduler. They pass with-race -cpu=1,4.On a Pixel 8 Pro (Android 16) with Sony WF-1000XM4 earbuds, in a music player built on gunim, Marcus checked the spectrum and the animations against the sound: in step during play, after pausing and resuming (from the lock screen too), across a change of track, and with the earbuds taken out and put back.
On the Android emulator, a stress app played through gunim and this change for 15 minutes, 2,300 random actions: seeks, mostly near the end of a track, the next track queued, pauses, the speaker suspended and resumed, its buffer resized, bursts of garbage, and the screen and the app's foreground changed from outside. What it computed as heard never fell further behind than the buffer and the device's delay, 610 ms, and no measurement failed.
The branch is based on
main, and the workflow passed on it in my fork, on all nine jobs: https://github.com/marrasen/oto/actions/runs/38035884350🤖 Generated with Claude Code