From b87f976d91518bba4bf0a9b4d5c3ef26d69527ec Mon Sep 17 00:00:00 2001 From: guko Date: Tue, 25 Aug 2026 16:50:55 +0800 Subject: [PATCH 1/2] Make the pcmdump settle adjustable, so SETTLE_MS can be measured; versionCode 13 SETTLE_MS is the pause the calibrator takes after writing a latency, before it trusts the audio it records. It was never measured. Answering that costs a rig session at ~7 minutes per calibration, per candidate value. dumpCapturePcm already does the essential part - it commands a known offset (SWEEP_PROBE_MS), settles on its own hardcoded 7 s, and dumps the ref+mic PCM - so exposing that settle turns each data point into a ~40 s dump analysed offline. Nine dumps took seven minutes where nine calibrations would have taken an hour, and the commanded offset is ground truth: recover it and the settle was long enough. Debug-gated like the rest of the dbg hooks, and the default is the production value, so nothing changes unless --ei settle is passed. Kept rather than reverted because the question it exists to answer is still open: full-capture lag recovery turned out to be insensitive to the settle (0 ms recovers the commanded offset as well as 7000 ms), and the split-half gate that could discriminate is noise-dominated on this rig even at 75 %. Re-running at better SNR needs this hook. --- app/build.gradle.kts | 2 +- .../java/tech/capullo/quantumcast/MainActivity.kt | 3 +++ .../capullo/quantumcast/player/PlaybackService.kt | 12 ++++++++++-- .../capullo/quantumcast/viewmodel/RadioViewModel.kt | 2 +- 4 files changed, 15 insertions(+), 4 deletions(-) diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 0355152..35b3385 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -17,7 +17,7 @@ android { applicationId = "tech.capullo.quantumcast" minSdk = 26 targetSdk = 36 - versionCode = 12 + versionCode = 13 versionName = "1.0" } diff --git a/app/src/main/java/tech/capullo/quantumcast/MainActivity.kt b/app/src/main/java/tech/capullo/quantumcast/MainActivity.kt index 54b3a21..31ba8c9 100755 --- a/app/src/main/java/tech/capullo/quantumcast/MainActivity.kt +++ b/app/src/main/java/tech/capullo/quantumcast/MainActivity.kt @@ -118,9 +118,12 @@ class MainActivity : ComponentActivity() { // /sdcard/Android/data//files/, so estimator questions can be answered offline // instead of costing a rig session. --ez probe true separates the arrivals first. // am start ... --es dbg pcmdump [--ez probe true] [--es target ] + // --ei settle overrides the post-probe settle, so SETTLE_MS can be measured + // from dumps instead of from 7-minute calibrations. "pcmdump" -> vm.dumpCapturePcm( intent.getStringExtra("target"), intent.getBooleanExtra("probe", true), + intent.getIntExtra("settle", 7_000).toLong(), ) // Fix the snapserver base port (--ei port N; omit or -1 to auto-pick, 0 to disable). // Applies on the next broadcast start. am start ... --es dbg fixport --ei port 34000 diff --git a/app/src/main/java/tech/capullo/quantumcast/player/PlaybackService.kt b/app/src/main/java/tech/capullo/quantumcast/player/PlaybackService.kt index c077623..939a989 100755 --- a/app/src/main/java/tech/capullo/quantumcast/player/PlaybackService.kt +++ b/app/src/main/java/tech/capullo/quantumcast/player/PlaybackService.kt @@ -421,8 +421,14 @@ class PlaybackService : Service() { * * [probe] optionally offsets one client's latency first, so the dump can capture the SEPARATED * geometry the balance actually harvests from rather than the overlapped baseline. + * + * [settleMs] is the pause between commanding that offset and recording — the same quantity + * SyncCalibrator spends as SETTLE_MS, exposed here so it can be MEASURED instead of assumed. + * A dump costs ~40 s against a calibration's ~7 min, and the commanded offset (SWEEP_PROBE_MS) + * is ground truth: recover it and the settle was long enough. Debug-only path; the default is + * the production value. */ - fun dumpCapturePcm(targetName: String?, probe: Boolean) { + fun dumpCapturePcm(targetName: String?, probe: Boolean, settleMs: Long = 7_000L) { if (calibrationJob?.isActive == true) return val control = snapcastControl val connected = _state.value.snapcastGroups.flatMap { it.clients }.filter { it.connected } @@ -454,7 +460,8 @@ class PlaybackService : Service() { Log.w(TAG_CAL, "pcmdump: could not journal — dumping unprobed to stay recoverable") } else { control!!.sendSetLatency(target.id, baseLatency - SWEEP_PROBE_MS) - delay(7_000L) + Log.i(TAG_CAL, "pcmdump: settle ${settleMs}ms after probing $SWEEP_PROBE_MS ms") + delay(settleMs) } } val cap = mic.record(12_000) @@ -498,6 +505,7 @@ class PlaybackService : Service() { appendLine("lagFormula=(index-pre)*1000/fs") appendLine("probed=$probing") appendLine("probeMs=${if (probing) SWEEP_PROBE_MS else 0}") + appendLine("settleMs=${if (probing) settleMs else 0}") appendLine("probeTarget=${if (probing) target?.config?.name else ""}") appendLine( "gains=" + connected.joinToString(",") { diff --git a/app/src/main/java/tech/capullo/quantumcast/viewmodel/RadioViewModel.kt b/app/src/main/java/tech/capullo/quantumcast/viewmodel/RadioViewModel.kt index d2fb593..96d3960 100755 --- a/app/src/main/java/tech/capullo/quantumcast/viewmodel/RadioViewModel.kt +++ b/app/src/main/java/tech/capullo/quantumcast/viewmodel/RadioViewModel.kt @@ -240,7 +240,7 @@ class RadioViewModel @Inject constructor( fun measureLevelSweep(target: String?) = playbackService?.measureLevelSweep(target) /** Dump one capture's decimated reference+mic PCM to external files, for offline estimator work. */ - fun dumpCapturePcm(target: String?, probe: Boolean) = playbackService?.dumpCapturePcm(target, probe) + fun dumpCapturePcm(target: String?, probe: Boolean, settleMs: Long = 7_000L) = playbackService?.dumpCapturePcm(target, probe, settleMs) /** Revert the volumes the last calibration balance overwrote. */ fun undoBalancedVolumes() = playbackService?.undoBalancedVolumes() From e8c3006d205ac2b7fec62799285287a65f61ba9f Mon Sep 17 00:00:00 2001 From: guko Date: Tue, 25 Aug 2026 17:29:26 +0800 Subject: [PATCH 2/2] Let pcmdump shorten the capture too; versionCode 14 --ei capture alongside the existing --ei settle. Debug-gated, default unchanged. The hypothesis was that the production 12 s capture hides a too-short settle by averaging: the audio in flight when a latency is written is ~1.15 s, only ~10 % of the window, so shrinking the window to 2 s should make the contamination dominate and move the peak. Rig-tested at 2 s, alternating settle 7000/0/7000/0 so drift could not pose as an effect. It made no difference - all four recovered the commanded 380 ms offset within ~4 ms, and the absolute drift was -20.5 to -0.1 ms with no grouping by settle. The hypothesis was wrong, and the reason is worth recording: the probe separates one client RELATIVE to the other, both arrivals land in the same capture against the same reference, so audio still in flight delays both together and cancels in the spacing. That quantity is insensitive to the settle by construction, not by averaging, and no capture length changes it. Kept because the knob is what established that, and because the level path - which is NOT differential, and which SETTLE_MS also precedes - still needs testing. --- app/build.gradle.kts | 2 +- .../tech/capullo/quantumcast/MainActivity.kt | 5 ++++- .../quantumcast/player/PlaybackService.kt | 17 +++++++++++++++-- .../quantumcast/viewmodel/RadioViewModel.kt | 7 ++++++- 4 files changed, 26 insertions(+), 5 deletions(-) diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 35b3385..1a30579 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -17,7 +17,7 @@ android { applicationId = "tech.capullo.quantumcast" minSdk = 26 targetSdk = 36 - versionCode = 13 + versionCode = 14 versionName = "1.0" } diff --git a/app/src/main/java/tech/capullo/quantumcast/MainActivity.kt b/app/src/main/java/tech/capullo/quantumcast/MainActivity.kt index 31ba8c9..703a805 100755 --- a/app/src/main/java/tech/capullo/quantumcast/MainActivity.kt +++ b/app/src/main/java/tech/capullo/quantumcast/MainActivity.kt @@ -119,11 +119,14 @@ class MainActivity : ComponentActivity() { // instead of costing a rig session. --ez probe true separates the arrivals first. // am start ... --es dbg pcmdump [--ez probe true] [--es target ] // --ei settle overrides the post-probe settle, so SETTLE_MS can be measured - // from dumps instead of from 7-minute calibrations. + // from dumps instead of from 7-minute calibrations. --ei capture shortens the + // recording, which is what makes a too-short settle visible: at 12 s the contamination + // is ~10 % of the window and averages away. "pcmdump" -> vm.dumpCapturePcm( intent.getStringExtra("target"), intent.getBooleanExtra("probe", true), intent.getIntExtra("settle", 7_000).toLong(), + intent.getIntExtra("capture", 12_000), ) // Fix the snapserver base port (--ei port N; omit or -1 to auto-pick, 0 to disable). // Applies on the next broadcast start. am start ... --es dbg fixport --ei port 34000 diff --git a/app/src/main/java/tech/capullo/quantumcast/player/PlaybackService.kt b/app/src/main/java/tech/capullo/quantumcast/player/PlaybackService.kt index 939a989..e162c8e 100755 --- a/app/src/main/java/tech/capullo/quantumcast/player/PlaybackService.kt +++ b/app/src/main/java/tech/capullo/quantumcast/player/PlaybackService.kt @@ -427,8 +427,20 @@ class PlaybackService : Service() { * A dump costs ~40 s against a calibration's ~7 min, and the commanded offset (SWEEP_PROBE_MS) * is ground truth: recover it and the settle was long enough. Debug-only path; the default is * the production value. + * + * [captureMs] shortens the recording for the same reason. At the production 12 s, the ~1.15 s + * of audio still in flight when a latency is written is only ~10 % of the window, so the + * correlation averages it away and the measurement cannot tell a settle of 0 from one of 7000 + * — which is exactly what the first run of this experiment found. Shrink the window and the + * contamination dominates instead: at 2 s it is more than half, so a too-short settle has to + * move the peak. That is what makes the instrument able to discriminate at all. */ - fun dumpCapturePcm(targetName: String?, probe: Boolean, settleMs: Long = 7_000L) { + fun dumpCapturePcm( + targetName: String?, + probe: Boolean, + settleMs: Long = 7_000L, + captureMs: Int = 12_000, + ) { if (calibrationJob?.isActive == true) return val control = snapcastControl val connected = _state.value.snapcastGroups.flatMap { it.clients }.filter { it.connected } @@ -464,7 +476,7 @@ class PlaybackService : Service() { delay(settleMs) } } - val cap = mic.record(12_000) + val cap = mic.record(captureMs) ?: run { Log.w(TAG_CAL, "pcmdump: capture failed") return@launch @@ -506,6 +518,7 @@ class PlaybackService : Service() { appendLine("probed=$probing") appendLine("probeMs=${if (probing) SWEEP_PROBE_MS else 0}") appendLine("settleMs=${if (probing) settleMs else 0}") + appendLine("captureMs=$captureMs") appendLine("probeTarget=${if (probing) target?.config?.name else ""}") appendLine( "gains=" + connected.joinToString(",") { diff --git a/app/src/main/java/tech/capullo/quantumcast/viewmodel/RadioViewModel.kt b/app/src/main/java/tech/capullo/quantumcast/viewmodel/RadioViewModel.kt index 96d3960..5953b25 100755 --- a/app/src/main/java/tech/capullo/quantumcast/viewmodel/RadioViewModel.kt +++ b/app/src/main/java/tech/capullo/quantumcast/viewmodel/RadioViewModel.kt @@ -240,7 +240,12 @@ class RadioViewModel @Inject constructor( fun measureLevelSweep(target: String?) = playbackService?.measureLevelSweep(target) /** Dump one capture's decimated reference+mic PCM to external files, for offline estimator work. */ - fun dumpCapturePcm(target: String?, probe: Boolean, settleMs: Long = 7_000L) = playbackService?.dumpCapturePcm(target, probe, settleMs) + fun dumpCapturePcm( + target: String?, + probe: Boolean, + settleMs: Long = 7_000L, + captureMs: Int = 12_000, + ) = playbackService?.dumpCapturePcm(target, probe, settleMs, captureMs) /** Revert the volumes the last calibration balance overwrote. */ fun undoBalancedVolumes() = playbackService?.undoBalancedVolumes()