static_delay_ms auto-measurement — Implementation Plan
static_delay_ms auto-measurement — Implementation Plan
For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (
- [ ]) syntax for tracking.
Goal: Auto-measure output latency via AudioTrack.getTimestamp() deltas and populate static_delay_ms on the server in the pre-playback window, so multi-room sync works without manual per-device calibration.
Architecture: New OutputLatencyEstimator (pure Kotlin, in shared module) performs ring-buffered sampling of write-time vs DAC-time deltas, computes a 20-sample mean with 2 s timeout fallback. SendspinTimeFilter.staticDelayMicros is split into autoMeasuredDelayMicros + userSyncOffsetMicros with a computed sum; the existing staticDelayMs getter is preserved. SyncAudioPlayer owns the estimator, feeds it events from its existing write + getTimestamp paths, and the WAITING_FOR_START → PLAYING gate gains a measurement-complete clause. L-3 torn-read on the Kalman offset field is fixed alongside.
Tech Stack: Kotlin, Android Gradle Plugin, KMP shared module, JUnit 4 (existing pattern: shared/src/androidHostTest/kotlin), :app:testDebugUnitTest + :shared:testAndroidHostTest.
Spec: docs/superpowers/specs/2026-04-21-static-delay-auto-measurement-design.md
Worktree: C:/CodeProjects/SendspinDroid-static-delay-auto-measurement, branch task/static-delay-auto-measurement, based on origin/main at 5a258a7.
File Structure
Create:
android/shared/src/commonMain/kotlin/com/sendspindroid/sendspin/latency/OutputLatencyEstimator.kt— estimator with ring buffer, status enum, callback. Pure Kotlin, no Android deps.android/shared/src/commonMain/kotlin/com/sendspindroid/sendspin/latency/StaticDelaySource.kt— small enum (kept in its own file because it’s referenced by both the filter and the estimator).android/shared/src/androidHostTest/kotlin/com/sendspindroid/sendspin/latency/OutputLatencyEstimatorTest.kt— comprehensive unit tests.
Modify:
android/shared/src/commonMain/kotlin/com/sendspindroid/sendspin/SendspinTimeFilter.kt— splitstaticDelayMicrosinto two fields; fix L-3 torn-read onoffset; add explicit typed setters.android/shared/src/androidHostTest/kotlin/com/sendspindroid/sendspin/SendspinTimeFilterTest.kt(if it exists; otherwise new) — add additive-sum and torn-read regression coverage.android/app/src/main/java/com/sendspindroid/sendspin/protocol/SendSpinProtocolHandler.kt— call sites that wrotetimeFilter.staticDelayMs = ...now callsetServerSyncOffsetMs(...); add publicsendClientStateSnapshot()wrapper.android/app/src/main/java/com/sendspindroid/sendspin/SendSpinClient.kt— forwardsendClientStateSnapshot()soSyncAudioPlayercan reach it.android/app/src/main/java/com/sendspindroid/sendspin/SyncAudioPlayer.kt— construct + feed the estimator, add measurement-complete clause to theWAITING_FOR_START → PLAYINGgate.android/app/src/main/java/com/sendspindroid/playback/PlaybackService.kt— three new stats bundle keys; route two existing slider write sites to the newsetUserSyncOffsetMssetter.
Task 1: Scaffold StaticDelaySource enum + OutputLatencyEstimator skeleton
Files:
- Create:
android/shared/src/commonMain/kotlin/com/sendspindroid/sendspin/latency/StaticDelaySource.kt - Create:
android/shared/src/commonMain/kotlin/com/sendspindroid/sendspin/latency/OutputLatencyEstimator.kt -
Create:
android/shared/src/androidHostTest/kotlin/com/sendspindroid/sendspin/latency/OutputLatencyEstimatorTest.kt - Step 1.1: Write the failing test for initial status
// OutputLatencyEstimatorTest.kt
package com.sendspindroid.sendspin.latency
import org.junit.Assert.assertEquals
import org.junit.Test
class OutputLatencyEstimatorTest {
@Test
fun `starts in Idle status before start() is called`() {
val est = OutputLatencyEstimator(nowNs = { 0L })
assertEquals(OutputLatencyEstimator.Status.Idle, est.status)
}
}
- Step 1.2: Run the test to verify it fails
cd "C:/CodeProjects/SendspinDroid-static-delay-auto-measurement/android"
./gradlew :shared:testAndroidHostTest --tests "com.sendspindroid.sendspin.latency.OutputLatencyEstimatorTest"
Expected: FAIL with “unresolved reference: OutputLatencyEstimator”.
- Step 1.3: Create the enum
// StaticDelaySource.kt
package com.sendspindroid.sendspin.latency
/**
* Source that most recently wrote the effective static delay.
*
* - [NONE]: no source has written; effective delay is 0
* - [AUTO]: [OutputLatencyEstimator] converged successfully
* - [USER]: user's settings slider
* - [SERVER]: server-pushed `client/sync_offset`
*/
enum class StaticDelaySource { NONE, AUTO, USER, SERVER }
- Step 1.4: Create the estimator skeleton
// OutputLatencyEstimator.kt
package com.sendspindroid.sendspin.latency
/**
* Measures device output latency (time from AudioTrack.write() to sound
* leaving the DAC) by cross-referencing write timestamps against DAC
* timestamp callbacks.
*
* Pure Kotlin, no Android dependencies. Takes write events in via
* [recordWrite] and DAC timestamp events in via [recordDacTimestamp];
* emits a single [Result] via the callback when the session converges
* or times out.
*
* @param nowNs monotonic clock source (System.nanoTime in production,
* a mock in tests).
*/
class OutputLatencyEstimator(
private val nowNs: () -> Long,
) {
enum class Status { Idle, Measuring, Converged, TimedOut, Cancelled }
sealed class Result {
data class Converged(val latencyMicros: Long, val sampleCount: Int) : Result()
data class TimedOut(val sampleCount: Int) : Result()
}
@Volatile var status: Status = Status.Idle
private set
fun start(onResult: (Result) -> Unit) {
TODO("Task 2")
}
fun cancel() {
TODO("Task 7")
}
fun recordWrite(framesWritten: Long, writeTimeNs: Long) {
TODO("Task 2")
}
fun recordDacTimestamp(framePosition: Long, dacTimeNs: Long) {
TODO("Task 3")
}
}
- Step 1.5: Run the test to verify it passes
./gradlew :shared:testAndroidHostTest --tests "com.sendspindroid.sendspin.latency.OutputLatencyEstimatorTest"
Expected: PASS.
- Step 1.6: Commit
git add android/shared/src/commonMain/kotlin/com/sendspindroid/sendspin/latency/ android/shared/src/androidHostTest/kotlin/com/sendspindroid/sendspin/latency/
git commit -m "feat(latency): scaffold OutputLatencyEstimator skeleton"
Task 2: Ring buffer for writes + start()
Files:
- Modify:
android/shared/src/commonMain/kotlin/com/sendspindroid/sendspin/latency/OutputLatencyEstimator.kt -
Modify:
android/shared/src/androidHostTest/kotlin/com/sendspindroid/sendspin/latency/OutputLatencyEstimatorTest.kt - Step 2.1: Write the failing test for start() + write recording
Add this test to OutputLatencyEstimatorTest.kt:
@Test
fun `start() transitions to Measuring and accepts writes`() {
val est = OutputLatencyEstimator(nowNs = { 0L })
est.start(onResult = {})
assertEquals(OutputLatencyEstimator.Status.Measuring, est.status)
// Recording writes does not change status on its own.
est.recordWrite(framesWritten = 960, writeTimeNs = 1_000_000L)
assertEquals(OutputLatencyEstimator.Status.Measuring, est.status)
}
- Step 2.2: Run the test to verify it fails
./gradlew :shared:testAndroidHostTest --tests "com.sendspindroid.sendspin.latency.OutputLatencyEstimatorTest.start transitions to Measuring and accepts writes"
Expected: FAIL with NotImplementedError.
- Step 2.3: Implement start() + internal ring buffer
Replace the OutputLatencyEstimator class body with:
class OutputLatencyEstimator(
private val nowNs: () -> Long,
private val ringCapacity: Int = DEFAULT_RING_CAPACITY,
) {
companion object {
const val DEFAULT_RING_CAPACITY = 64
}
enum class Status { Idle, Measuring, Converged, TimedOut, Cancelled }
sealed class Result {
data class Converged(val latencyMicros: Long, val sampleCount: Int) : Result()
data class TimedOut(val sampleCount: Int) : Result()
}
// Ring buffer entry: (framesWritten cumulative, writeTimeNs)
private data class WriteEntry(val framesWritten: Long, val writeTimeNs: Long)
@Volatile var status: Status = Status.Idle
private set
private val lock = Any()
private var onResult: ((Result) -> Unit)? = null
private val ring = ArrayDeque<WriteEntry>(DEFAULT_RING_CAPACITY)
fun start(onResult: (Result) -> Unit) {
synchronized(lock) {
if (status != Status.Idle) return
this.onResult = onResult
ring.clear()
status = Status.Measuring
}
}
fun cancel() {
TODO("Task 7")
}
fun recordWrite(framesWritten: Long, writeTimeNs: Long) {
synchronized(lock) {
if (status != Status.Measuring) return
if (ring.size >= ringCapacity) ring.removeFirst()
ring.addLast(WriteEntry(framesWritten, writeTimeNs))
}
}
fun recordDacTimestamp(framePosition: Long, dacTimeNs: Long) {
TODO("Task 3")
}
}
- Step 2.4: Run the test to verify it passes
./gradlew :shared:testAndroidHostTest --tests "com.sendspindroid.sendspin.latency.OutputLatencyEstimatorTest"
Expected: PASS (both tests).
- Step 2.5: Commit
git add android/shared/src/commonMain/kotlin/com/sendspindroid/sendspin/latency/OutputLatencyEstimator.kt android/shared/src/androidHostTest/kotlin/com/sendspindroid/sendspin/latency/OutputLatencyEstimatorTest.kt
git commit -m "feat(latency): ring buffer + start() in OutputLatencyEstimator"
Task 3: Sample evaluation on recordDacTimestamp
Files:
- Modify:
android/shared/src/commonMain/kotlin/com/sendspindroid/sendspin/latency/OutputLatencyEstimator.kt -
Modify:
android/shared/src/androidHostTest/kotlin/com/sendspindroid/sendspin/latency/OutputLatencyEstimatorTest.kt - Step 3.1: Write the failing tests
Add to OutputLatencyEstimatorTest.kt:
@Test
fun `recordDacTimestamp produces a sample when writeTime for frame is in ring`() {
var captured: OutputLatencyEstimator.Result? = null
val est = OutputLatencyEstimator(nowNs = { 0L })
est.start { captured = it }
// Write 10 entries: framesWritten advances by 960 each, writeTimeNs by 20ms each.
repeat(10) { i ->
est.recordWrite(framesWritten = (i + 1) * 960L, writeTimeNs = i * 20_000_000L)
}
// DAC reports it's at frame 5760 (== 6 writes' worth). Look up should find
// the write at framesWritten=5760 with writeTimeNs=5*20_000_000=100_000_000.
// If dacTimeNs is 180_000_000, latency = 80_000_000 ns = 80ms = 80000us.
// We can't observe the sample directly yet (no accumulator test), but we
// can assert no result has been emitted (1 sample is not enough to converge).
est.recordDacTimestamp(framePosition = 5760L, dacTimeNs = 180_000_000L)
assertEquals(null, captured)
assertEquals(OutputLatencyEstimator.Status.Measuring, est.status)
}
@Test
fun `recordDacTimestamp drops samples when frame is before ring buffer start`() {
val est = OutputLatencyEstimator(nowNs = { 0L }, ringCapacity = 4)
est.start {}
// Fill then overflow the ring so frame 960 is evicted.
repeat(6) { i ->
est.recordWrite(framesWritten = (i + 1) * 960L, writeTimeNs = i * 20_000_000L)
}
// Ring now contains frames 2880, 3840, 4800, 5760 (oldest to newest).
// Asking about frame 960 should be dropped (no throw, no crash, no sample).
est.recordDacTimestamp(framePosition = 960L, dacTimeNs = 100_000_000L)
// Implicit assertion: no exception thrown.
}
- Step 3.2: Run the tests to verify they fail
./gradlew :shared:testAndroidHostTest --tests "com.sendspindroid.sendspin.latency.OutputLatencyEstimatorTest"
Expected: FAIL on the new tests with NotImplementedError.
- Step 3.3: Implement recordDacTimestamp + sample accumulation
In OutputLatencyEstimator.kt, add fields after ring:
private val samples = ArrayDeque<Long>() // latency values in nanoseconds
private var rejectedSamples = 0
Replace the recordDacTimestamp stub with:
fun recordDacTimestamp(framePosition: Long, dacTimeNs: Long) {
synchronized(lock) {
if (status != Status.Measuring) return
val writeTimeNs = lookupWriteTime(framePosition) ?: run {
rejectedSamples++
return
}
val latencyNs = dacTimeNs - writeTimeNs
if (latencyNs <= 0 || latencyNs > MAX_REASONABLE_LATENCY_NS) {
rejectedSamples++
return
}
samples.addLast(latencyNs)
// Convergence trigger lands in Task 4.
}
}
/**
* Linear scan for the write entry whose `framesWritten` is >= the query
* frame — i.e., the earliest write that contains the requested frame.
* Returns its `writeTimeNs`, or null if the frame is older than the
* oldest entry in the ring.
*/
private fun lookupWriteTime(framePosition: Long): Long? {
for (entry in ring) {
if (entry.framesWritten >= framePosition) return entry.writeTimeNs
}
return null
}
Add to the companion object:
// Reject latency samples outside [0, 1_000 ms]. Negative = measurement
// bug, > 1 s = pathological device or Bluetooth routing. Don't poison
// the mean with these.
const val MAX_REASONABLE_LATENCY_NS = 1_000_000_000L // 1 second
- Step 3.4: Run the tests to verify they pass
./gradlew :shared:testAndroidHostTest --tests "com.sendspindroid.sendspin.latency.OutputLatencyEstimatorTest"
Expected: PASS (all four tests so far).
- Step 3.5: Commit
git add android/shared/src/commonMain/kotlin/com/sendspindroid/sendspin/latency/OutputLatencyEstimator.kt android/shared/src/androidHostTest/kotlin/com/sendspindroid/sendspin/latency/OutputLatencyEstimatorTest.kt
git commit -m "feat(latency): recordDacTimestamp sample evaluation + frame lookup"
Task 4: Convergence on 20 samples
Files:
- Modify:
android/shared/src/commonMain/kotlin/com/sendspindroid/sendspin/latency/OutputLatencyEstimator.kt -
Modify:
android/shared/src/androidHostTest/kotlin/com/sendspindroid/sendspin/latency/OutputLatencyEstimatorTest.kt - Step 4.1: Write the failing test
Add to OutputLatencyEstimatorTest.kt:
@Test
fun `converges on exactly 20 accepted samples with arithmetic mean`() {
var captured: OutputLatencyEstimator.Result? = null
val est = OutputLatencyEstimator(nowNs = { 0L })
est.start { captured = it }
// Seed the ring with 25 writes so every DAC frame has a lookup.
repeat(25) { i ->
est.recordWrite(framesWritten = (i + 1) * 960L, writeTimeNs = i * 20_000_000L)
}
// Emit 20 DAC samples. For sample i, DAC reports frame (i+1)*960, DAC time
// is (write time for that frame) + latency_ns. Vary latency so the mean
// is testable: use 80ms for all 20 → mean is 80ms → 80000us.
val latencyNs = 80_000_000L
for (i in 0 until 20) {
val frame = (i + 1) * 960L
val writeTimeNs = i * 20_000_000L
est.recordDacTimestamp(framePosition = frame, dacTimeNs = writeTimeNs + latencyNs)
}
val result = captured
assertNotNull("should have converged after 20 samples", result)
result as OutputLatencyEstimator.Result.Converged
assertEquals(80_000L, result.latencyMicros)
assertEquals(20, result.sampleCount)
assertEquals(OutputLatencyEstimator.Status.Converged, est.status)
}
Also add at top of test file (imports):
import org.junit.Assert.assertNotNull
- Step 4.2: Run the test to verify it fails
./gradlew :shared:testAndroidHostTest --tests "com.sendspindroid.sendspin.latency.OutputLatencyEstimatorTest.converges on exactly 20 accepted samples with arithmetic mean"
Expected: FAIL — captured is still null because we never fire the callback.
- Step 4.3: Implement convergence
In OutputLatencyEstimator.kt, add to the companion object:
const val CONVERGENCE_SAMPLE_COUNT = 20
In recordDacTimestamp, replace the comment // Convergence trigger lands in Task 4. with:
if (samples.size >= CONVERGENCE_SAMPLE_COUNT) {
val sum = samples.sum()
val meanNs = sum / samples.size
val result = Result.Converged(
latencyMicros = meanNs / 1_000,
sampleCount = samples.size,
)
status = Status.Converged
val cb = onResult
onResult = null
cb?.invoke(result)
}
- Step 4.4: Run the test to verify it passes
./gradlew :shared:testAndroidHostTest --tests "com.sendspindroid.sendspin.latency.OutputLatencyEstimatorTest"
Expected: PASS (all five tests).
- Step 4.5: Commit
git add android/shared/src/commonMain/kotlin/com/sendspindroid/sendspin/latency/OutputLatencyEstimator.kt android/shared/src/androidHostTest/kotlin/com/sendspindroid/sendspin/latency/OutputLatencyEstimatorTest.kt
git commit -m "feat(latency): convergence on 20-sample mean"
Task 5: Sample rejection — negative, over-cap, dropped lookups don’t count
Files:
-
Modify:
android/shared/src/androidHostTest/kotlin/com/sendspindroid/sendspin/latency/OutputLatencyEstimatorTest.kt -
Step 5.1: Write the failing tests
Add to OutputLatencyEstimatorTest.kt:
@Test
fun `rejects negative latency and still converges on 20 real samples`() {
var captured: OutputLatencyEstimator.Result? = null
val est = OutputLatencyEstimator(nowNs = { 0L })
est.start { captured = it }
// 30 writes so every frame resolves.
repeat(30) { i ->
est.recordWrite(framesWritten = (i + 1) * 960L, writeTimeNs = i * 20_000_000L)
}
// First 5 "samples" have negative latency (DAC time before write time) --
// must be rejected and NOT count toward the 20.
for (i in 0 until 5) {
est.recordDacTimestamp(framePosition = (i + 1) * 960L, dacTimeNs = i * 20_000_000L - 1_000L)
}
// Then 20 clean samples at 50ms.
for (i in 0 until 20) {
val frame = (i + 6) * 960L
val writeTimeNs = (i + 5) * 20_000_000L
est.recordDacTimestamp(frame, writeTimeNs + 50_000_000L)
}
val result = captured as? OutputLatencyEstimator.Result.Converged
assertNotNull(result)
assertEquals(50_000L, result!!.latencyMicros)
assertEquals(20, result.sampleCount)
}
@Test
fun `rejects latency above 1000ms cap`() {
var captured: OutputLatencyEstimator.Result? = null
val est = OutputLatencyEstimator(nowNs = { 0L })
est.start { captured = it }
repeat(30) { i ->
est.recordWrite(framesWritten = (i + 1) * 960L, writeTimeNs = i * 20_000_000L)
}
// 10 "samples" with 2-second latency -- rejected.
for (i in 0 until 10) {
est.recordDacTimestamp(
framePosition = (i + 1) * 960L,
dacTimeNs = i * 20_000_000L + 2_000_000_000L,
)
}
// No result yet.
assertEquals(null, captured)
assertEquals(OutputLatencyEstimator.Status.Measuring, est.status)
}
- Step 5.2: Run the tests to verify they pass (rejection logic already exists)
./gradlew :shared:testAndroidHostTest --tests "com.sendspindroid.sendspin.latency.OutputLatencyEstimatorTest"
Expected: PASS. The rejection logic was added in Task 3; these tests just lock that behavior down.
- Step 5.3: Commit
git add android/shared/src/androidHostTest/kotlin/com/sendspindroid/sendspin/latency/OutputLatencyEstimatorTest.kt
git commit -m "test(latency): pin sample-rejection behavior"
Task 6: Timeout mechanism
Files:
- Modify:
android/shared/src/commonMain/kotlin/com/sendspindroid/sendspin/latency/OutputLatencyEstimator.kt -
Modify:
android/shared/src/androidHostTest/kotlin/com/sendspindroid/sendspin/latency/OutputLatencyEstimatorTest.kt - Step 6.1: Write the failing test
Add to OutputLatencyEstimatorTest.kt:
@Test
fun `times out after 2 seconds with partial sample count`() {
var now = 0L
var captured: OutputLatencyEstimator.Result? = null
val est = OutputLatencyEstimator(nowNs = { now })
est.start { captured = it }
// Feed 10 clean samples over the first 200 ms.
repeat(15) { i ->
est.recordWrite(framesWritten = (i + 1) * 960L, writeTimeNs = i * 20_000_000L)
}
for (i in 0 until 10) {
est.recordDacTimestamp((i + 1) * 960L, i * 20_000_000L + 50_000_000L)
}
assertEquals(null, captured)
// Advance the clock 2.1 s forward and tick.
now = 2_100_000_000L
est.tick()
val result = captured as? OutputLatencyEstimator.Result.TimedOut
assertNotNull("should have timed out", result)
assertEquals(10, result!!.sampleCount)
assertEquals(OutputLatencyEstimator.Status.TimedOut, est.status)
}
- Step 6.2: Run the test to verify it fails
./gradlew :shared:testAndroidHostTest --tests "com.sendspindroid.sendspin.latency.OutputLatencyEstimatorTest.times out after 2 seconds with partial sample count"
Expected: FAIL — est.tick() does not exist.
- Step 6.3: Implement timeout + tick()
In OutputLatencyEstimator.kt, add to the companion object:
const val TIMEOUT_NS = 2_000_000_000L // 2 seconds
Add field next to rejectedSamples:
private var startNs: Long = 0L
In start(), set the start time right before status = Status.Measuring:
startNs = nowNs()
Add a new method tick() that callers invoke periodically (e.g., from the audio thread’s existing polling loop):
/**
* Check the timeout clock. Call this periodically from any thread that
* also calls [recordWrite] / [recordDacTimestamp] (so the same lock
* serializes state). When the timeout has elapsed and the session has
* not yet converged, fires [Result.TimedOut].
*/
fun tick() {
synchronized(lock) {
if (status != Status.Measuring) return
if (nowNs() - startNs < TIMEOUT_NS) return
val result = Result.TimedOut(sampleCount = samples.size)
status = Status.TimedOut
val cb = onResult
onResult = null
cb?.invoke(result)
}
}
- Step 6.4: Run the test to verify it passes
./gradlew :shared:testAndroidHostTest --tests "com.sendspindroid.sendspin.latency.OutputLatencyEstimatorTest"
Expected: PASS (all tests).
- Step 6.5: Commit
git add android/shared/src/commonMain/kotlin/com/sendspindroid/sendspin/latency/OutputLatencyEstimator.kt android/shared/src/androidHostTest/kotlin/com/sendspindroid/sendspin/latency/OutputLatencyEstimatorTest.kt
git commit -m "feat(latency): 2s timeout via tick()"
Task 7: Cancel during active session
Files:
- Modify:
android/shared/src/commonMain/kotlin/com/sendspindroid/sendspin/latency/OutputLatencyEstimator.kt -
Modify:
android/shared/src/androidHostTest/kotlin/com/sendspindroid/sendspin/latency/OutputLatencyEstimatorTest.kt - Step 7.1: Write the failing test
@Test
fun `cancel() stops firing callbacks even if convergence would have happened`() {
var callbackCount = 0
val est = OutputLatencyEstimator(nowNs = { 0L })
est.start { callbackCount++ }
est.cancel()
assertEquals(OutputLatencyEstimator.Status.Cancelled, est.status)
// Feed enough samples that it would normally converge.
repeat(30) { i ->
est.recordWrite(framesWritten = (i + 1) * 960L, writeTimeNs = i * 20_000_000L)
}
for (i in 0 until 20) {
val frame = (i + 1) * 960L
est.recordDacTimestamp(frame, i * 20_000_000L + 50_000_000L)
}
assertEquals(0, callbackCount)
assertEquals(OutputLatencyEstimator.Status.Cancelled, est.status)
}
- Step 7.2: Run the test to verify it fails
./gradlew :shared:testAndroidHostTest --tests "com.sendspindroid.sendspin.latency.OutputLatencyEstimatorTest.cancel\$\$ stops firing callbacks"
Expected: FAIL with NotImplementedError on cancel().
- Step 7.3: Implement cancel()
Replace the cancel() stub:
fun cancel() {
synchronized(lock) {
if (status != Status.Measuring) return
status = Status.Cancelled
onResult = null
}
}
- Step 7.4: Run the test to verify it passes
./gradlew :shared:testAndroidHostTest --tests "com.sendspindroid.sendspin.latency.OutputLatencyEstimatorTest"
Expected: PASS.
- Step 7.5: Commit
git add android/shared/src/commonMain/kotlin/com/sendspindroid/sendspin/latency/OutputLatencyEstimator.kt android/shared/src/androidHostTest/kotlin/com/sendspindroid/sendspin/latency/OutputLatencyEstimatorTest.kt
git commit -m "feat(latency): cancel() suppresses late callbacks"
Task 8: Split staticDelayMicros in SendspinTimeFilter
Files:
- Modify:
android/shared/src/commonMain/kotlin/com/sendspindroid/sendspin/SendspinTimeFilter.kt
Context: Today staticDelayMicros at SendspinTimeFilter.kt:306 is a single @Volatile Long updated by three separate call paths (user slider, server sync_offset, settings initial apply). This task splits it into two independent fields with explicit per-source setters. Existing callers get compatibility setters in Task 10; this task only changes SendspinTimeFilter itself.
- Step 8.1: Create SendspinTimeFilterTest if it does not exist
Check whether the test file exists:
ls android/shared/src/androidHostTest/kotlin/com/sendspindroid/sendspin/SendspinTimeFilterTest.kt 2>/dev/null && echo EXISTS || echo MISSING
If MISSING, create the file:
// android/shared/src/androidHostTest/kotlin/com/sendspindroid/sendspin/SendspinTimeFilterTest.kt
package com.sendspindroid.sendspin
import com.sendspindroid.sendspin.latency.StaticDelaySource
import org.junit.Assert.assertEquals
import org.junit.Test
class SendspinTimeFilterTest {
// Tests added by the static-delay-auto-measurement plan.
}
- Step 8.2: Write the failing tests for field split
Add to SendspinTimeFilterTest.kt:
@Test
fun `staticDelayMs returns sum of auto-measured and user sync offset`() {
val f = SendspinTimeFilter()
f.setUserSyncOffsetMs(30.0)
f.setAutoMeasuredDelayMicros(50_000L, StaticDelaySource.AUTO)
assertEquals(80.0, f.staticDelayMs, 0.0001)
}
@Test
fun `user and auto-measured writes do not clobber each other`() {
val f = SendspinTimeFilter()
f.setAutoMeasuredDelayMicros(100_000L, StaticDelaySource.AUTO)
f.setUserSyncOffsetMs(25.0)
assertEquals(125.0, f.staticDelayMs, 0.0001)
assertEquals(StaticDelaySource.USER, f.staticDelaySource) // Most recent writer
f.setAutoMeasuredDelayMicros(0L, StaticDelaySource.NONE)
assertEquals(25.0, f.staticDelayMs, 0.0001)
}
@Test
fun `server sync_offset writes route to user field with SERVER source`() {
val f = SendspinTimeFilter()
f.setServerSyncOffsetMs(-40.0)
assertEquals(-40.0, f.staticDelayMs, 0.0001)
assertEquals(StaticDelaySource.SERVER, f.staticDelaySource)
}
- Step 8.3: Run the tests to verify they fail
./gradlew :shared:testAndroidHostTest --tests "com.sendspindroid.sendspin.SendspinTimeFilterTest"
Expected: FAIL (methods not defined).
- Step 8.4: Refactor SendspinTimeFilter fields
In SendspinTimeFilter.kt:
Add import at the top:
import com.sendspindroid.sendspin.latency.StaticDelaySource
Replace the block at lines 303-306 (the @Volatile private var staticDelayMicros: Long = 0 and its comment):
// Static delay = auto-measured output latency + user sync offset.
// Each source is tracked separately so auto-measurement and user
// corrections don't clobber each other. [staticDelayMs] returns the sum.
// @Volatile fields: read by audio thread (serverToClient), written from
// UI/main or estimator threads.
@Volatile private var autoMeasuredDelayMicros: Long = 0
@Volatile private var userSyncOffsetMicros: Long = 0
@Volatile var staticDelaySource: StaticDelaySource = StaticDelaySource.NONE
private set
Replace the existing staticDelayMs get/set property (lines ~397-401):
/**
* Effective static delay in milliseconds. Sum of the auto-measured
* hardware latency and the user's sync-offset correction. Both
* components may be written independently by their respective setters.
*
* Positive = delay playback (plays later), Negative = advance (plays earlier).
*/
val staticDelayMs: Double
get() = (autoMeasuredDelayMicros + userSyncOffsetMicros) / 1000.0
/**
* Raw auto-measured component (microseconds).
*/
val autoMeasuredDelayMs: Double
get() = autoMeasuredDelayMicros / 1000.0
/**
* Raw user sync-offset component (milliseconds).
*/
val userSyncOffsetMs: Double
get() = userSyncOffsetMicros / 1000.0
/**
* Write the auto-measured hardware output latency. Called by
* [OutputLatencyEstimator] when measurement converges (source=AUTO)
* or times out (source=NONE).
*/
fun setAutoMeasuredDelayMicros(micros: Long, source: StaticDelaySource) {
autoMeasuredDelayMicros = micros
staticDelaySource = source
}
/**
* Write the user's manual sync-offset correction (milliseconds).
* Called by the settings slider's broadcast path.
*/
fun setUserSyncOffsetMs(ms: Double) {
userSyncOffsetMicros = (ms * 1000).toLong()
staticDelaySource = StaticDelaySource.USER
}
/**
* Write a server-pushed sync-offset (from `client/sync_offset`).
* Goes into the same field as the user slider because both are
* semantically "corrections on top of the measured hardware latency".
*/
fun setServerSyncOffsetMs(ms: Double) {
userSyncOffsetMicros = (ms * 1000).toLong()
staticDelaySource = StaticDelaySource.SERVER
}
- Step 8.5: Run the tests to verify they pass
./gradlew :shared:testAndroidHostTest --tests "com.sendspindroid.sendspin.SendspinTimeFilterTest"
Expected: PASS.
- Step 8.6: Commit
git add android/shared/src/commonMain/kotlin/com/sendspindroid/sendspin/SendspinTimeFilter.kt android/shared/src/androidHostTest/kotlin/com/sendspindroid/sendspin/SendspinTimeFilterTest.kt
git commit -m "refactor(timefilter): split staticDelayMicros into auto + user fields"
Task 9: Fix L-3 torn-read on Kalman offset field
Files:
- Modify:
android/shared/src/commonMain/kotlin/com/sendspindroid/sendspin/SendspinTimeFilter.kt
Context: @Volatile private var offset: Double at line 274 is theoretically torn-readable on 32-bit JVMs. Switch to AtomicLong storage with Double.toRawBits/Double.fromBits bit-cast. The Kalman update paths already acquire lock for the covariance matrix; readers access offset unlocked on the audio thread.
- Step 9.1: Write the failing torn-read test
Add to SendspinTimeFilterTest.kt:
@Test
fun `concurrent writer-reader stress on offset does not tear`() {
val f = SendspinTimeFilter()
val writer = Thread {
for (i in 0 until 10_000) {
f.addMeasurement(serverTimeMicros = i.toLong() * 1_000L, clientTimeMicros = i.toLong() * 1_000L, rttMicros = 1_000L)
}
}
val reader = Thread {
for (i in 0 until 10_000) {
val now = i.toLong() * 1_000L
val v = f.serverToClient(now)
// A torn read would yield NaN or an impossible magnitude.
// Accept any finite long as non-torn.
require(v in Long.MIN_VALUE..Long.MAX_VALUE)
}
}
writer.start()
reader.start()
writer.join()
reader.join()
}
- Step 9.2: Run the test to verify it passes (torn reads are nondeterministic)
./gradlew :shared:testAndroidHostTest --tests "com.sendspindroid.sendspin.SendspinTimeFilterTest.concurrent writer-reader"
Expected: PASS on modern ARM64 (the test exists to lock in regression coverage after the fix — it is unlikely to fail before the fix on 64-bit hardware, but would catch a regression on 32-bit). The test’s primary value is protecting the AtomicLong representation across refactors.
- Step 9.3: Replace the
@Volatile Doublewith atomic Long bit-cast
In SendspinTimeFilter.kt:
Add import:
import java.util.concurrent.atomic.AtomicLong
Replace line 274 (@Volatile private var offset: Double = 0.0):
// offset is stored as AtomicLong (bit-cast from Double via toRawBits /
// fromBits) so reads on 32-bit JVMs are atomic. The covariance matrix
// (p00, p01, p10, p11) is still guarded by [lock] on writes. Readers
// on the audio thread (serverToClient, clientToServer) read offset
// lock-free via [offsetDouble].
private val offsetBits = AtomicLong(0L)
private var offset: Double
get() = Double.fromBits(offsetBits.get())
set(value) { offsetBits.set(value.toRawBits()) }
- Step 9.4: Run all existing filter tests to verify no regression
./gradlew :shared:testAndroidHostTest --tests "com.sendspindroid.sendspin.*"
Expected: PASS (including the pre-existing filter tests and the new torn-read test).
- Step 9.5: Commit
git add android/shared/src/commonMain/kotlin/com/sendspindroid/sendspin/SendspinTimeFilter.kt android/shared/src/androidHostTest/kotlin/com/sendspindroid/sendspin/SendspinTimeFilterTest.kt
git commit -m "fix(timefilter): atomic Long storage for Kalman offset (L-3)"
Task 10: Migrate existing staticDelayMs writers to explicit setters
Files:
- Modify:
android/app/src/main/java/com/sendspindroid/sendspin/protocol/SendSpinProtocolHandler.kt - Modify:
android/app/src/main/java/com/sendspindroid/playback/PlaybackService.kt
Context: The old staticDelayMs setter was removed in Task 8. Four call sites wrote to it:
SendSpinProtocolHandler.kt:468— serverclient/sync_offsethandlerPlaybackService.kt:231—syncOffsetReceiverbroadcast handlerPlaybackService.kt:2936—applySyncOffsetFromSettingsPlaybackService.kt:2949—updateSyncOffset
All four need to use the new typed setters.
- Step 10.1: Update SendSpinProtocolHandler
In SendSpinProtocolHandler.kt, at the line that currently reads getTimeFilter().staticDelayMs = clampedOffset (around line 468):
getTimeFilter().setServerSyncOffsetMs(clampedOffset)
- Step 10.2: Update PlaybackService broadcast receiver
In PlaybackService.kt, at line ~231:
Before:
timeFilter.staticDelayMs = offsetMs.toDouble()
After:
timeFilter.setUserSyncOffsetMs(offsetMs.toDouble())
- Step 10.3: Update applySyncOffsetFromSettings (line ~2936)
Before:
timeFilter.staticDelayMs = offsetMs.toDouble()
After:
timeFilter.setUserSyncOffsetMs(offsetMs.toDouble())
- Step 10.4: Update updateSyncOffset (line ~2949)
Before:
timeFilter.staticDelayMs = offsetMs.toDouble()
After:
timeFilter.setUserSyncOffsetMs(offsetMs.toDouble())
- Step 10.5: Build and run protocol + playback tests
./gradlew assembleDebug
./gradlew :app:testDebugUnitTest --tests "com.sendspindroid.sendspin.protocol.*" --tests "com.sendspindroid.playback.*"
Expected: PASS. The behavior is unchanged (all four writers still ultimately populate userSyncOffsetMicros); this just uses the new typed API.
- Step 10.6: Commit
git add android/app/src/main/java/com/sendspindroid/sendspin/protocol/SendSpinProtocolHandler.kt android/app/src/main/java/com/sendspindroid/playback/PlaybackService.kt
git commit -m "refactor: route existing static-delay writers to explicit setters"
Task 11: Add sendClientStateSnapshot on protocol handler + client
Files:
- Modify:
android/app/src/main/java/com/sendspindroid/sendspin/protocol/SendSpinProtocolHandler.kt -
Modify:
android/app/src/main/java/com/sendspindroid/sendspin/SendSpinClient.kt - Step 11.1: Expose sendPlayerStateUpdate as sendClientStateSnapshot
In SendSpinProtocolHandler.kt, right after the existing protected fun sendPlayerStateUpdate() block (around line 206):
/**
* Public hook for code outside the protocol handler (e.g.
* [OutputLatencyEstimator] via [SyncAudioPlayer]) to push a fresh
* `client/state` to the server, for example after auto-measured
* `static_delay_ms` converges.
*/
fun sendClientStateSnapshot() {
if (!handshakeComplete) return
sendPlayerStateUpdate()
}
- Step 11.2: Verify SendSpinClient exposes it (inherited)
Because SendSpinClient extends SendSpinProtocolHandler, sendClientStateSnapshot() is already callable on a SendSpinClient instance. No change required in SendSpinClient.kt.
- Step 11.3: Build
./gradlew assembleDebug
Expected: PASS.
- Step 11.4: Commit
git add android/app/src/main/java/com/sendspindroid/sendspin/protocol/SendSpinProtocolHandler.kt
git commit -m "feat(protocol): sendClientStateSnapshot public hook"
Task 12: Wire OutputLatencyEstimator into SyncAudioPlayer
Files:
- Modify:
android/app/src/main/java/com/sendspindroid/sendspin/SyncAudioPlayer.kt
Context: SyncAudioPlayer owns the AudioTrack lifetime and already performs write + getTimestamp() operations. The estimator hooks into both. The result callback writes to the SendspinTimeFilter and requests a client/state push via a lambda injected at construction (to avoid SyncAudioPlayer depending directly on SendSpinClient).
- Step 12.1: Add an injection point for the “request client/state push” callback
In SyncAudioPlayer.kt, find the primary constructor and add a new parameter:
Before:
class SyncAudioPlayer(
private val timeFilter: SendspinTimeFilter,
private val sampleRate: Int,
private val channels: Int,
private val bitDepth: Int,
private val maxQueueSamples: Long = 0L,
) {
After:
class SyncAudioPlayer(
private val timeFilter: SendspinTimeFilter,
private val sampleRate: Int,
private val channels: Int,
private val bitDepth: Int,
private val maxQueueSamples: Long = 0L,
private val requestClientStateSnapshot: () -> Unit = {},
) {
- Step 12.2: Update PlaybackService construction site
In PlaybackService.kt, find the SyncAudioPlayer(...) construction (around line ~1315 from earlier exploration) and add the callback:
syncAudioPlayer = SyncAudioPlayer(
timeFilter = timeFilter,
sampleRate = sampleRate,
channels = channels,
bitDepth = bitDepth,
maxQueueSamples = maxSamples,
requestClientStateSnapshot = {
sendSpinClient?.sendClientStateSnapshot()
},
).apply {
- Step 12.3: Add estimator field + start it when a new AudioTrack is created
Near the audioTrack field declaration in SyncAudioPlayer.kt, add:
private val latencyEstimator = com.sendspindroid.sendspin.latency.OutputLatencyEstimator(
nowNs = { System.nanoTime() },
)
In the method that creates the AudioTrack (look for AudioTrack.Builder() usage around line ~524), immediately after the track is successfully built, start the estimator:
latencyEstimator.start { result ->
when (result) {
is com.sendspindroid.sendspin.latency.OutputLatencyEstimator.Result.Converged -> {
timeFilter.setAutoMeasuredDelayMicros(
result.latencyMicros,
com.sendspindroid.sendspin.latency.StaticDelaySource.AUTO,
)
AppLog.Audio.i("[delay-cal] converged: ${result.latencyMicros}us from ${result.sampleCount} samples")
}
is com.sendspindroid.sendspin.latency.OutputLatencyEstimator.Result.TimedOut -> {
timeFilter.setAutoMeasuredDelayMicros(
0L,
// Don't overwrite a user slider with NONE if one is in place;
// the filter's staticDelaySource will reflect USER or NONE
// based on whether a user write happened after this one.
com.sendspindroid.sendspin.latency.StaticDelaySource.NONE,
)
AppLog.Audio.w("[delay-cal] timed out with ${result.sampleCount} samples; falling back to 0")
}
}
requestClientStateSnapshot()
}
- Step 12.4: Locate the write loop and cumulative-frames counter
Find the AudioTrack.write call site and the field tracking cumulative frames. These names depend on the current file content; locate them with:
grep -n "audioTrack.write\|framesWritten\|totalFrames" android/app/src/main/java/com/sendspindroid/sendspin/SyncAudioPlayer.kt
Note the variable name used for cumulative frames written (examples from past versions: totalFramesWritten, framesWritten). Call this variable F_CUMULATIVE for the rest of this task.
- Step 12.5: Feed recordWrite on each AudioTrack.write()
Immediately before the audioTrack.write(...) call, capture writeTimeNs = System.nanoTime(). Immediately after, once F_CUMULATIVE has been updated with the just-written frames, call:
latencyEstimator.recordWrite(F_CUMULATIVE, writeTimeNs)
The exact insertion point: the write-time capture goes right before val written = audioTrack.write(...), and the recordWrite call goes in the block that handles a successful write (the same place where F_CUMULATIVE is already updated). Do not add a new counter — reuse what’s already there. If no such counter exists, that is a red flag — stop and ask, because frame-position lookup in the ring buffer requires a cumulative count that matches what AudioTrack.getTimestamp() returns.
- Step 12.6: Feed recordDacTimestamp on each successful getTimestamp()
Locate the AudioTimestamp poll site:
grep -n "AudioTimestamp\|getTimestamp(" android/app/src/main/java/com/sendspindroid/sendspin/SyncAudioPlayer.kt
After a successful audioTrack.getTimestamp(ts) call (the overload returns Boolean), add:
if (success) {
latencyEstimator.recordDacTimestamp(ts.framePosition, ts.nanoTime)
}
latencyEstimator.tick()
The tick() call runs on every poll regardless of success, so the timeout clock advances even if getTimestamp is failing persistently.
- Step 12.7: Cancel the estimator when AudioTrack is released
Find the audioTrack?.release() call in release() (around line 980). Immediately before it:
latencyEstimator.cancel()
- Step 12.8: Build
./gradlew assembleDebug
Expected: PASS.
- Step 12.9: Run the existing SyncAudioPlayer tests
./gradlew :app:testDebugUnitTest --tests "com.sendspindroid.sendspin.SyncAudioPlayerTest" --tests "com.sendspindroid.playback.*"
Expected: PASS. The new estimator is default-off when not started, and the construction path now starts it — but on the unit test bench there’s no real AudioTrack, so the tests must still pass. If they fail, the construction site wiring was too aggressive — the fix is to only start the estimator when a non-null AudioTrack was actually built (not on mock/failure paths).
- Step 12.10: Commit
git add android/app/src/main/java/com/sendspindroid/sendspin/SyncAudioPlayer.kt android/app/src/main/java/com/sendspindroid/playback/PlaybackService.kt
git commit -m "feat(audio): wire OutputLatencyEstimator into SyncAudioPlayer"
Task 13: Gate WAITING_FOR_START → PLAYING on measurement complete
Files:
- Modify:
android/app/src/main/java/com/sendspindroid/sendspin/SyncAudioPlayer.kt
Context: handleStartGatingDacAware at SyncAudioPlayer.kt:1458 returns true to mean “keep waiting.” The new gate clause: if measurement is still in Measuring status, return true regardless of DAC alignment.
- Step 13.1: Add measurement-complete check at the top of handleStartGatingDacAware
In SyncAudioPlayer.kt, at the very start of handleStartGatingDacAware (around line 1459, right after the opening brace):
private fun handleStartGatingDacAware(track: AudioTrack): Boolean {
// Measurement-complete clause: don't transition to PLAYING until
// the latency estimator has converged or timed out. If we don't
// wait here, an unusually-early server-scheduled start could make
// us enter PLAYING with staticDelay=0, then change it mid-stream
// once measurement finishes -- causing a one-time sync jump / click.
if (latencyEstimator.status == com.sendspindroid.sendspin.latency.OutputLatencyEstimator.Status.Measuring) {
return true // keep waiting
}
val nowMicros = System.nanoTime() / 1000
// ... (rest of existing body unchanged)
- Step 13.2: Build
./gradlew assembleDebug
Expected: PASS.
- Step 13.3: Run playback tests
./gradlew :app:testDebugUnitTest --tests "com.sendspindroid.sendspin.*" --tests "com.sendspindroid.playback.*"
Expected: PASS. Existing tests may not exercise the gate directly; they should still pass because the estimator’s default state is Idle (gate does not block) and if the test constructs a SyncAudioPlayer and starts it, the estimator goes to Measuring — if a test then simulates DAC alignment without providing DAC timestamps, the gate now blocks it. That’s a legitimate test failure if it happens, and the fix is to update the test to either call estimator.tick() with an advanced clock to force timeout or to inject a different requestClientStateSnapshot callback. Investigate each failing test individually.
- Step 13.4: Commit
git add android/app/src/main/java/com/sendspindroid/sendspin/SyncAudioPlayer.kt
git commit -m "feat(audio): hold WAITING_FOR_START until measurement completes"
Task 14: Stats bundle keys in PlaybackService
Files:
- Modify:
android/app/src/main/java/com/sendspindroid/playback/PlaybackService.kt
Context: The stats bundle at PlaybackService.kt:2898 already exposes static_delay_ms. Adding auto_measured_delay_ms, user_sync_offset_ms, and static_delay_source lets the stats UI show the breakdown without new UI.
- Step 14.1: Add the three new keys
In PlaybackService.kt, immediately after the existing bundle.putDouble("static_delay_ms", timeFilter.staticDelayMs) line (around line 2898):
bundle.putDouble("auto_measured_delay_ms", timeFilter.autoMeasuredDelayMs)
bundle.putDouble("user_sync_offset_ms", timeFilter.userSyncOffsetMs)
bundle.putString("static_delay_source", timeFilter.staticDelaySource.name)
- Step 14.2: Build
./gradlew assembleDebug
Expected: PASS.
- Step 14.3: Commit
git add android/app/src/main/java/com/sendspindroid/playback/PlaybackService.kt
git commit -m "feat(stats): expose auto-measured and user-sync-offset breakdown"
Task 15: Final build, full test run, open PR
Files:
-
None (verification only)
-
Step 15.1: Full build
./gradlew assembleDebug
Expected: BUILD SUCCESSFUL.
- Step 15.2: Full unit-test run, both modules
./gradlew :app:testDebugUnitTest
./gradlew :shared:testAndroidHostTest
Expected: PASS.
- Step 15.3: Review the combined diff
git log --oneline origin/main..HEAD
git diff --stat origin/main..HEAD
Expected: ~11 commits, modifications to SendspinTimeFilter, two new files under sendspin/latency/, and the wiring across SyncAudioPlayer, PlaybackService, SendSpinProtocolHandler. No changes to SendSpinClient.kt (it inherits sendClientStateSnapshot for free).
- Step 15.4: Push and open PR
git push -u origin task/static-delay-auto-measurement
gh pr create --base main --title "feat: auto-measure static_delay_ms for multi-room sync (H-2 + L-3)" --body "$(cat <<'EOF'
## Summary
Adds automatic measurement of device output latency, populating `static_delay_ms` without manual per-device calibration. Addresses audit findings H-2 (primary) and L-3 (folded in). This is Group 5 of the architecture-audit rollup; Groups 1-4 and 6 have landed (#139, #140, #141, #142, #143).
Full design at `docs/superpowers/specs/2026-04-21-static-delay-auto-measurement-design.md`.
Implementation plan at `docs/superpowers/plans/2026-04-21-static-delay-auto-measurement.md`.
## Changes
- New `OutputLatencyEstimator` (pure Kotlin, shared module) with ring-buffered sampling, 20-sample fixed-window mean, 2 s timeout, drop-sample rejection (`<0 ms` and `>1000 ms`).
- `SendspinTimeFilter.staticDelayMicros` split into two fields: `autoMeasuredDelayMicros` (written by estimator) and `userSyncOffsetMicros` (written by slider / server). Existing `staticDelayMs` getter returns the sum. Explicit typed setters replace the old catch-all setter.
- L-3 fix: Kalman `offset` field migrated to `AtomicLong` + `Double.toRawBits/fromBits` to eliminate torn-read risk on 32-bit JVMs.
- `SyncAudioPlayer` owns the estimator lifetime, feeds it from existing write and `getTimestamp()` paths, and the `WAITING_FOR_START -> PLAYING` gate now holds until measurement completes.
- New `SendSpinProtocolHandler.sendClientStateSnapshot()` public wrapper so the estimator can push an updated `client/state` before first audible chunk.
- Stats bundle gains `auto_measured_delay_ms`, `user_sync_offset_ms`, `static_delay_source`.
## Verified
- [x] `./gradlew assembleDebug`
- [x] `:app:testDebugUnitTest` (all existing tests + new wiring checks)
- [x] `:shared:testAndroidHostTest` (full `OutputLatencyEstimatorTest`, new `SendspinTimeFilterTest` additions)
## Test plan
- [ ] CI build + tests pass
- [ ] On-device: connect fresh, check logcat `[delay-cal]` for convergence log; check stats sheet shows non-zero `auto_measured_delay_ms` and `static_delay_source: AUTO`
- [ ] On-device: user slider still works independently; stats show `user_sync_offset_ms` changing, source flipping to `USER`
- [ ] Multi-room (if available): drift between two devices is noticeably better than pre-PR baseline
EOF
)"
- Step 15.5: Update tracking table in conversation
Report the PR URL to the user and mark Group 5 complete in the audit rollup.
Deferred / out of scope
- M-4 and L-5 are tracked as deferred tasks (#15 and #16 in the session task list); not part of this PR.
- H-1 code side (AAudio/Oboe migration) remains a future design spec of its own.
- Multi-device automated sync test is explicitly excluded — validation is manual per the spec.
Risks
- Test-construction of
SyncAudioPlayeractivates the estimator. Existing tests may exercise gate behavior in unintended ways. Task 12.8 / 13.3 include checkpoints for investigating. - Estimator starts before the AudioTrack is actually playing silence. If the first
getTimestamp()returns before anyrecordWritehas populated the ring buffer, samples are dropped harmlessly — but persistentgetTimestamp()success with empty ring would lead to timeout. Not observed in practice because the write loop feeds the ring before the first poll. - Late result after AudioTrack release.
cancel()inrelease()prevents this; Task 7 tested it.