You are right, and thanks for the pointer: .dataPlayedBack fires when the buffer has finished playing through the device, so the first callback is "first buffer fully played", not playback onset. I will report it under that name, and get the onset separately.
Onset, iOS: once the player node is running, its render clock gives the host time at which sample 0 left the node (playerTime(forNodeTime:) on lastRenderTime, extrapolated back to sample 0), plus AVAudioSession.outputLatency for the device path. Cross-check: the first .dataPlayedBack minus the first chunk's duration, which in Linnet is 50 ms, not 80: the first chunk is 720 samples shorter because the session compensates the decoder's delay (docs/streaming-contract.md). Android: AudioTrack.getTimestamp returns a (frame position, nanoTime) pair, and extrapolating to frame 0 gives the onset directly.
One correction to my previous comment, having re-read the player: the constructor activates the AVAudioSession (category playback, mode spokenAudio) and wires the engine, but engine.start() and node.play() run on the first write. So the iOS figures are "route configured, engine started for the request", not "output already open". The measurement will therefore have three columns per device: first chunk returned (what is published now), playback onset, and first buffer fully played; each in two rows: engine started on the request, and engine pre-started with a silent buffer played through the route. A15 and Snapdragon 662, numbers here and in the README.