diff --git a/CHANGELOG.md b/CHANGELOG.md index 9245caf..96ee94b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,21 @@ ## Mobile (not released yet) +- Test signal (iPhone and Android) no longer stops by itself about 5 minutes + after the last clock check ("Clock reference expired"). The app keeps + checking the clock while the signal runs and hands each new check to it; the + source timestamps slew to it at most 100 ppm (0.1 ms per second), never + jumping, and only for events not queued yet (core + `PhoneGeneratorPlan::retime`). It still stops when no check gets through for + too long (no network), or when a new check is further off than the + uncertainty limit. The status line shows "NTP UTC (check … s ago, + adjusting ±… ms)". A signal started before the first clock check (device + UTC) now switches to NTP UTC with the first check within the limit, slewed + the same way; one further off keeps it on device UTC (no stop, said in the + status line). Before, it stayed on device UTC for the whole run. + The app now also checks a minute before the holdover would pass the limit, + not only after. New native call `updateGeneratorClock(options)` + (`docs/phone-clock.md`). - Android test signal: Signal → Start test signal now works on Android, with the same picture, options (1 or 2 events/s, title, subtitle, image), AVS4 QR (test ID, title, sender calibration), stable sender ID and "Stopped" screen diff --git a/core/include/avsync/phone_generator.hpp b/core/include/avsync/phone_generator.hpp index 8356830..3bc41ce 100644 --- a/core/include/avsync/phone_generator.hpp +++ b/core/include/avsync/phone_generator.hpp @@ -1,6 +1,9 @@ #pragma once #include "avsync/avsync.hpp" #include "avsync/optical_code.hpp" +#include +#include +#include namespace avsync { // A scheduled source, not an independent measurement of display/DAC emission. @@ -17,12 +20,29 @@ class PhoneGeneratorPlan { uint64_t named_seq(uint64_t frame) const { return 1 + optical_named_seq(frame, period_frames()); } // AVS3: field A white at this frame (B is always the opposite). bool a_white(uint64_t frame) const; + // Clock refresh (docs/phone-clock.md). A newer clock check changes the TAI stamps of the events + // from from_seq on (the first one not scheduled yet), never their host times: frames stay on the + // display grid, and the sound and the picture of one event keep one stamp. The stamps move toward + // offset_ns (relative to the start mapping) by at most clock_slew_ppm of the event period per event. + // A change above max_step_ns is refused: false, nothing changes. + bool retime(int64_t offset_ns, uint64_t from_seq, int64_t max_step_ns); + int64_t clock_offset_ns(uint64_t seq) const; static constexpr int width = 1280, height = 720, fps = 60; + static constexpr double clock_slew_ppm = 100; private: + int64_t offset_locked(uint64_t seq) const; std::string id_; int rate_; int64_t epoch_; double host_; uint32_t source_; + // (seq, offset ns) corners of the slew, linear in between. audio() and the picture renderer may + // read stamps on another thread than retime(). + mutable std::mutex clock_; + std::vector> slew_; }; + +// The phone generator's clock reference rule, as referenceAt in mobile-app/src/time-reference.mjs: +// usable while at most 300 s old and the check's radius plus 50 ppm holdover stays within the limit. +bool clock_reference_expired(double age_ms, double radius_ms, double limit_ms); } // namespace avsync diff --git a/core/src/phone_generator.cpp b/core/src/phone_generator.cpp index 4c4bd24..1c9c770 100644 --- a/core/src/phone_generator.cpp +++ b/core/src/phone_generator.cpp @@ -1,5 +1,7 @@ #include "avsync/phone_generator.hpp" +#include #include +#include #include #include @@ -15,7 +17,7 @@ QrPayload PhoneGeneratorPlan::qr(uint64_t seq) const { if (seq == 0 || seq > 86400ULL * rate_) throw std::invalid_argument("phone source session exceeds one day"); QrPayload p; p.test_id = id_; p.mode = "LIVE"; p.rate = rate_; p.format = "720p60"; - p.k = seq; p.tk = epoch_ + int64_t(seq - 1) * (1000000000LL / rate_); + p.k = seq; p.tk = epoch_ + int64_t(seq - 1) * (1000000000LL / rate_) + clock_offset_ns(seq); p.source_id = source_; p.timecode = true; p.qr_flash = true; p.optical_code = true; p.estimated_source = true; return p; } @@ -34,4 +36,33 @@ uint64_t PhoneGeneratorPlan::frame_at(double target) const { if (!std::isfinite(target) || target < host_) return 0; return uint64_t(std::floor((target - host_) * fps + 1e-5)); } +int64_t PhoneGeneratorPlan::offset_locked(uint64_t seq) const { + if (slew_.empty() || seq <= slew_.front().first) return slew_.empty() ? 0 : slew_.front().second; + for (size_t i = 1; i < slew_.size(); ++i) { + const auto [s0, v0] = slew_[i - 1]; + const auto [s1, v1] = slew_[i]; + if (seq < s1) return v0 + std::llround(double(v1 - v0) * double(seq - s0) / double(s1 - s0)); + } + return slew_.back().second; +} +int64_t PhoneGeneratorPlan::clock_offset_ns(uint64_t seq) const { + std::lock_guard lock(clock_); + return offset_locked(seq); +} +bool PhoneGeneratorPlan::retime(int64_t offset, uint64_t from, int64_t max_step) { + if (from == 0 || max_step < 0) return false; + std::lock_guard lock(clock_); + const int64_t current = offset_locked(from), change = offset - current; + if (std::llabs(change) > max_step) return false; + // Events before from keep their stamps; a minute of older corners is enough for late renders. + while (!slew_.empty() && slew_.back().first >= from) slew_.pop_back(); + while (slew_.size() > 1 && slew_[1].first + 120 <= from) slew_.erase(slew_.begin()); + const auto step = std::max(1, std::llround(1e9 / rate_ * clock_slew_ppm / 1e6)); + slew_.push_back({from, current}); + slew_.push_back({from + uint64_t(std::max(1, (std::llabs(change) + step - 1) / step)), offset}); + return true; +} +bool clock_reference_expired(double age_ms, double radius_ms, double limit_ms) { + return !(age_ms >= 0 && age_ms <= 300000 && radius_ms + age_ms * 50 / 1e6 <= limit_ms); +} } // namespace avsync diff --git a/docs/phone-clock.md b/docs/phone-clock.md index 82b2ee6..af28892 100644 --- a/docs/phone-clock.md +++ b/docs/phone-clock.md @@ -10,8 +10,10 @@ mapping from the capture clock to UTC. Settings → Debug → NTP server selects a hostname/IP (UDP port 123), default `time.cloudflare.com`. The app checks the clock on its own when it starts or comes to the front, and again every 4 minutes (a check is valid for 5 minutes). -When the holdover allowance (below) pushes the uncertainty past the limit, it -checks again sooner, at most every 30 s. +When the holdover allowance (below) would push the uncertainty past the limit +within a minute, it checks again sooner, at most every 30 s (`clockCheckDue` +in `src/time-reference.mjs`). So a new check normally arrives before the old +one runs out, also while the test signal runs. Settings → Sync clock now repeats it at once. The iOS client makes up to four requests, one second apart, on a worker; Android (`Sntp.kt`) makes eight, 250 ms apart, with the same checks. @@ -65,10 +67,67 @@ once, independently of AV offset/calibration. A wall-clock step cannot silently shift a qualified monotonic reference. Every saved reading retains the exact clock source, mapping, age, correction, uncertainty, RTT (NTP), server and raw latency diagnostics **at acquisition**; saving/rechecking never updates an -old reading. A synchronized iOS generator uses the same mapping for scheduled -source timestamps and stops at expiry; physical speaker/display output stays +old reading. A synchronized generator (iOS and Android) uses the same mapping +for scheduled source timestamps; physical speaker/display output stays estimated and encoded as such. +## Clock refresh while the test signal runs + +The app keeps checking the clock while the test signal runs, by the same rule +as for measuring, and hands every new check to the running signal +(`updateGeneratorClock`, same JS call on iOS and Android). The signal then +moves to the new check gently instead of jumping (core +`PhoneGeneratorPlan::retime`, the same code on both phones): + +- **What moves.** Only the source timestamps (the time in the QR and in the + audio packet) of the events that are not queued yet. When the picture and + the sound of an event play does not change: frames stay on the display's + frame grid, and the sound and picture of one event always carry the same + timestamp. Events already queued (up to 2.5 s ahead) keep theirs. +- **How fast.** At most 100 ppm: each event's timestamp moves by at most + 0.05 ms (2 events/s) or 0.1 ms (1 event/s) from the one before, that is + 0.1 ms per second. A typical difference of 1–5 ms between two checks is + taken up in 10–50 s; the largest allowed (the limit, 20 ms by default) in + 200 s, before the next check is due. For an analyser this looks like a + source clock 100 ppm off for that time: far below anything it reacts to, and + the phone's own 50 ppm holdover allowance is of the same order. When the + QR is missed and an analyser counts on from an older event, the error stays + below 100 ppm × the gap (2 ms at the longest 20 s gap). +- **Too big a difference.** When a new check is further from the current + timestamps than the uncertainty limit (default 20 ms), something is wrong + with one of the two checks (network path, server). The signal does not + slew then; it stops with "New clock check is … ms off (more than the … ms + limit). Tap Start for a fresh clock mapping." Start uses the new check. +- **When it stops.** Only when no new check got through in time: the newest + check is more than 5 minutes old, or its estimate plus the 50 ppm holdover + passes the limit (no network, for example). Then it stops with "Clock + reference expired. Close and synchronize again." as before. +- **What stays.** The test ID and source ID, the "Phone clock changed" check + (it watches the phone's own wall clock against the host clock and does not + use the check), and the other stop reasons. +- **Status line.** "NTP UTC" stays, now with the age of the newest check and, + while the timestamps still slew, the part still to go: "NTP UTC (check 42 s + ago, adjusting +0.8 ms)". +- **Started before the first check.** Pressing Start right after the app comes + to the front, before its first check is done, starts the signal on device + UTC. The first accepted check then switches it to NTP UTC with the same + 100 ppm slew, from the device-UTC timestamps to the NTP ones (same code as + above, same on iOS and Android). The status line shows "NTP UTC (…, + adjusting …)" from that moment. If the device clock is further from the + check than the limit, the signal does **not** stop: it keeps running on + device UTC and says so, e.g. "device UTC (clock check +35.0 ms off, more than + the 20 ms limit)". A later check within the limit can still switch it. Once + switched it follows the rules above (refresh, expiry, stop on a big jump). +- **What an analyser sees.** The QR and the audio packet carry no + "device or NTP" mark: phone timestamps are always flagged as estimated, and + the server reports `source_reference: phone-utc-scheduled-output` either way. + So an analyser only sees the timestamps slew, never a jump or a flag change. + The result when the screen closes has `clockApplied: true` and the check in + `clockReference` as soon as the signal switched (at the switch, not when the + slew ends). +- The calibration pattern uses no clock reference; it is not affected. The container generator uses the host/PTP clock and has no + such refresh. + ## Timestamp-relative versus emission-to-capture latency Optional source emit offsets (video/audio, −1000…+1000 ms) can be copied from @@ -106,7 +165,12 @@ cannot identify all of them. See [device-to-device](device-to-device.md). ## Checks - `npm test` from `mobile-app`: clock/schema/expiry/correction/source-change, - raw diagnostics and immutable saved results; calibration context mismatch. + raw diagnostics and immutable saved results; calibration context mismatch; + the check cadence, with a 15-minute test signal run that never expires while + checks arrive and stops after the rule when they do not. +- Core `ctest` (`test_timecode.cpp`): the clock refresh slew (rate, refused + big changes, queued events unchanged, same stamp for sound and picture) and + the same 15-minute run on the generator plan. - `python3 mobile-app/tests/native/clock.py` (macOS): actual Swift packet codec and Network.framework UDP client against a fake server with known offset; rejection of unsynchronized, wrong-origin and kiss-of-death replies. diff --git a/docs/phone-generator.md b/docs/phone-generator.md index e5447ae..3f3bdf6 100644 --- a/docs/phone-generator.md +++ b/docs/phone-generator.md @@ -75,7 +75,12 @@ It prevents automatic screen lock and raises brightness while active, restoring both when stopped. Stop/Close cancel queued sound and clear the whole timing picture; a large "Stopped" shows instead. App inactivity, audio interruptions, output/configuration changes, a wall-clock jump over 100 ms, missed scheduling deadlines and the 23-hour session limit stop -the source. Restart creates a fresh clock mapping. Bluetooth and other external +the source. Restart creates a fresh clock mapping. With NTP UTC, new clock checks +reach the running signal and its timestamps slew to them (at most 100 ppm); it +only stops when no check got through for too long or a check is further off than +the limit. A signal started before the first check (device UTC) switches to NTP +UTC with the first check within the limit, slewed the same way; a check further +off leaves it on device UTC, without stopping ([phone-clock.md](phone-clock.md#clock-refresh-while-the-test-signal-runs)). Bluetooth and other external output paths have unverified delays; use the built-in speaker. ## Calibration mode diff --git a/mobile-app/App.js b/mobile-app/App.js index 20c962f..ee41a96 100644 --- a/mobile-app/App.js +++ b/mobile-app/App.js @@ -13,7 +13,7 @@ import Results from './components/Results'; import { Debug, Settings, Sheet } from './components/Settings'; import { Button, colors, small, text } from './components/ui'; import { liveView, offsetText, savedResult, formatWhen, clockText } from './src/live.mjs'; -import { clockCheckDetails, clockSettings, generatorClockOptions, referenceAt, synchronizeClock, CLOCK_MAX_AGE_MS } from './src/time-reference.mjs'; +import { clockCheckDetails, clockCheckDue, clockSettings, generatorClockOptions, referenceAt, synchronizeClock } from './src/time-reference.mjs'; import { calibrationSummary, makeCalibration, validCalibration } from './src/workflow.mjs'; import { generatorOptions } from './src/generator.mjs'; import { makePulseCalibration, makeSenderCalibration, senderCalibrationLabel, senderCalibrationOptions } from './src/calibrator.mjs'; @@ -21,8 +21,6 @@ import { screenAwakeEnabled } from './src/screen-awake.mjs'; import { addResult, liveDelta, loadSessionState, setNickname, toggleReference } from './src/results.mjs'; const NativeCamera = native?.supportsLiveCapture?.() ? requireNativeViewManager('Avsync') : null; -const RESYNC_MS = CLOCK_MAX_AGE_MS - 60000; // renew the clock reference a minute before it expires -const UNCERTAIN_RESYNC_MS = 30000; // or sooner, at most every 30 s, once the holdover passes the limit const TABS = [['measure', 'Measure', 'speedometer-outline'], ['generate', 'Signal', 'tv-outline'], ['results', 'Results', 'list-outline']]; function loadSession() { @@ -50,7 +48,7 @@ export default function App() { const [tick, setTick] = useState(0); useEffect(() => { const t = setInterval(() => setTick(value => value + 1), 500); return () => clearInterval(t); }, []); - // Clock: synchronize automatically while the app is in front, and renew before expiry. + // Clock: synchronize automatically while the app is in front, and renew before expiry (clockCheckDue). const [reference, setReference] = useState(null), [syncing, setSyncing] = useState(false), [clockError, setClockError] = useState(null); const syncBusy = useRef(false); const syncClock = async () => { @@ -62,9 +60,7 @@ export default function App() { }; useEffect(() => { if (!foreground) setReference(null); else syncClock(); }, [foreground, session.ntpServer]); useEffect(() => { - if (!foreground) return; - const age = reference ? performance.now() - reference.receivedJsMonoMs + reference.ageAtReceiptMs : Infinity; - if ((age > RESYNC_MS || (clockNow.state === 'uncertain' && age > UNCERTAIN_RESYNC_MS)) && !syncBusy.current) syncClock(); + if (foreground && clockCheckDue(reference, performance.now(), limits.maxUncertaintyMs) && !syncBusy.current) syncClock(); }, [tick]); const limits = useMemo(() => { try { return clockSettings(session); } catch { return clockSettings({}); } }, [session.ntpServer, session.clockMaxUncertaintyMs]); const clockNow = referenceAt(reference, native?.clockNow?.(), limits.maxUncertaintyMs); @@ -146,6 +142,12 @@ export default function App() { } catch (error) { setNotice(error.message); } finally { setGenerating(false); } }; + // A running test signal takes each new clock check and slews to it (docs/phone-clock.md). + useEffect(() => { + if (!generating) return; + const clock = generatorClockOptions(reference, native?.clockNow?.(), limits.maxUncertaintyMs); + if (clock.clockUnixMs) native?.updateGeneratorClock?.(clock)?.catch?.(() => {}); + }, [reference]); const pickImage = async () => { setPicking(true); try { const picked = await native.pickGeneratorImage(); if (picked) update({ generatorImageId: picked.id }); } diff --git a/mobile-app/README.md b/mobile-app/README.md index ff26a65..64bef7a 100644 --- a/mobile-app/README.md +++ b/mobile-app/README.md @@ -29,7 +29,8 @@ with its sound audible. After a few events the screen shows: - the **spread** (±, half the 10–90 % range) and the number of events in the reading (the last 8 within 10 s); - **video and audio latency** from the source time to this phone, once the - clock is synchronized (NTP, automatic, renewed every 4 minutes). + clock is synchronized (NTP, automatic, renewed every 4 minutes or sooner; + a running test signal takes the new checks too, see `docs/phone-clock.md`). When the signal drops, the last reading stays greyed for 8 s. **Save** stores it in Results. **Restart** clears the history, for example after moving the diff --git a/mobile-app/modules/avsync/android/src/androidTest/java/io/leeo86/mxl/avsync/PhoneGeneratorTest.kt b/mobile-app/modules/avsync/android/src/androidTest/java/io/leeo86/mxl/avsync/PhoneGeneratorTest.kt index 979972a..a737b93 100644 --- a/mobile-app/modules/avsync/android/src/androidTest/java/io/leeo86/mxl/avsync/PhoneGeneratorTest.kt +++ b/mobile-app/modules/avsync/android/src/androidTest/java/io/leeo86/mxl/avsync/PhoneGeneratorTest.kt @@ -47,6 +47,18 @@ class PhoneGeneratorTest { } } + @Test fun clockRefreshSlewsStampsNotHostTimes() { + // core PhoneGeneratorPlan::retime: events from 5 on move to +2 ms at 100 ppm; a 50 ms change is refused. + PhoneGenerator("phone-985274814721", 2, 1_800_000_000_000_000_000L, 100.0, "Android").use { g -> + assertTrue(g.retime(2_000_000L, 5, 20_000_000L)) + assertFalse(g.retime(50_000_000L, 5, 20_000_000L)) + assertEquals(0.0, g.clockOffsetMs(4), 0.0); assertEquals(2.0, g.clockOffsetMs(100), 1e-9) + assertEquals(149.5, g.hostTime(100), 1e-9) + } + assertTrue(PhoneGenerator.clockReferenceExpired(300_001.0, 0.0, 20.0)) + assertFalse(PhoneGenerator.clockReferenceExpired(1000.0, 5.0, 20.0)) + } + @Test fun calibrationPatternAndAnalysis() { // Period k lasts 500 ms: at 60 fps the swaps are every 30 frames, left half white in even periods. assertTrue(PhoneGenerator.calibrationLeftWhite(0, 60)); assertFalse(PhoneGenerator.calibrationLeftWhite(30, 60)) diff --git a/mobile-app/modules/avsync/android/src/main/cpp/generator_jni.cpp b/mobile-app/modules/avsync/android/src/main/cpp/generator_jni.cpp index 23ef9b1..fa430f3 100644 --- a/mobile-app/modules/avsync/android/src/main/cpp/generator_jni.cpp +++ b/mobile-app/modules/avsync/android/src/main/cpp/generator_jni.cpp @@ -184,6 +184,18 @@ JNIEXPORT jfloatArray JNICALL GENERATOR(audio)(JNIEnv* env, jclass, jlong h, jlo env->SetFloatArrayRegion(out, 0, jsize(samples.size()), samples.data()); return out; } +JNIEXPORT jboolean JNICALL GENERATOR(retime)(JNIEnv*, jclass, jlong h, jlong offsetNs, jlong fromSeq, jlong maxStepNs) +{ + return fromSeq > 0 && self(h)->plan->retime(int64_t(offsetNs), uint64_t(fromSeq), int64_t(maxStepNs)); +} +JNIEXPORT jdouble JNICALL GENERATOR(clockOffsetMs)(JNIEnv*, jclass, jlong h, jlong seq) +{ + return seq > 0 ? double(self(h)->plan->clock_offset_ns(uint64_t(seq))) / 1e6 : 0.0; +} +JNIEXPORT jboolean JNICALL GENERATOR(clockReferenceExpired)(JNIEnv*, jclass, jdouble ageMs, jdouble radiusMs, jdouble limitMs) +{ + return avsync::clock_reference_expired(ageMs, radiusMs, limitMs); +} // The test picture for this event: AVS4 QR with the shown name and the sender calibration. JNIEXPORT jintArray JNICALL GENERATOR(image)(JNIEnv* env, jclass, jlong h, jlong seq) { diff --git a/mobile-app/modules/avsync/android/src/main/java/io/leeo86/mxl/avsync/AvsyncGenerator.kt b/mobile-app/modules/avsync/android/src/main/java/io/leeo86/mxl/avsync/AvsyncGenerator.kt index fdb5f62..c59a636 100644 --- a/mobile-app/modules/avsync/android/src/main/java/io/leeo86/mxl/avsync/AvsyncGenerator.kt +++ b/mobile-app/modules/avsync/android/src/main/java/io/leeo86/mxl/avsync/AvsyncGenerator.kt @@ -72,7 +72,10 @@ class AvsyncGenerator(private val activity: Activity, private val options: Optio private var sourceID = "" private var lastStatus = 0.0 private var utcMinusHost = 0.0 + private var planUtcMinusHost = 0.0 // the mapping the plan's stamps started from + private var clockReference = options.clockReference // the newest accepted clock check (updateClock) private var usingReference = false + private var deviceNote = "" // why a signal on device UTC did not switch to a clock check private var senderCalibrationNote = "" // Calibration pattern: the microphone take. private var recorder: AudioCapture? = null @@ -190,7 +193,7 @@ class AvsyncGenerator(private val activity: Activity, private val options: Optio if (finishing) return finishing = true val result = mutableMapOf("source" to sourceID, "eventRate" to rate, "skippedDisplayFrames" to lateFrames, - "audioPresentationLatencyMs" to outputLatency * 1000, "estimated" to true, "clockReference" to options.clockReference, "clockApplied" to usingReference) + "audioPresentationLatencyMs" to outputLatency * 1000, "estimated" to true, "clockReference" to clockReference, "clockApplied" to usingReference) calibrationResult?.let { result["calibration"] = it } calibrationError?.let { result["calibrationError"] = it } stop("Closed") @@ -245,10 +248,20 @@ class AvsyncGenerator(private val activity: Activity, private val options: Optio raster.show(frame) if (now - lastStatus > 0.5) { lastStatus = now - if (referenceExpired()) return stop("Clock reference expired. Close and synchronize again.") + // Expires when no newer clock check arrived in time (updateClock), by the rule of the app. + var clockNote = "" + val r = clockReference + val anchor = r["clockMonotonicMs"]?.toDoubleOrNull(); val radius = r["clockUncertaintyMs"]?.toDoubleOrNull(); val limit = r["clockLimitMs"]?.toDoubleOrNull() + if (usingReference && anchor != null && radius != null && limit != null) { + val age = PhoneClock.nowMs() - anchor + if (PhoneGenerator.clockReferenceExpired(age, radius, limit)) return stop("Clock reference expired. Close and synchronize again.") + val remaining = plan.clockOffsetMs(Long.MAX_VALUE) - plan.clockOffsetMs(frame.sequence) + clockNote = " (check ${(age / 1000).toLong()} s ago" + + (if (abs(remaining) >= 0.05) String.format(java.util.Locale.ROOT, ", adjusting %+.1f ms", remaining) else "") + ")" + } if (abs(System.currentTimeMillis() / 1000.0 - now - utcMinusHost) > 0.1) return stop("Phone clock changed. Tap Start to create a fresh clock mapping.") outputLatency = audio.latencyNs / 1e9 - status.text = "TEST SIGNAL ACTIVE · 1200 baud · $rate/s · frame skips $lateFrames\nEstimated output timing · ${if (usingReference) "NTP UTC" else "device UTC"} · " + + status.text = "TEST SIGNAL ACTIVE · 1200 baud · $rate/s · frame skips $lateFrames\nEstimated output timing · ${if (usingReference) "NTP UTC$clockNote" else "device UTC$deviceNote"} · " + "audio lead ${Math.round(outputLatency * 1000)} ms$senderCalibrationNote" } } @@ -263,15 +276,12 @@ class AvsyncGenerator(private val activity: Activity, private val options: Optio utcMinusHost = utc - (before + after) / 2e9 var referenceUtcMinusHost = utcMinusHost usingReference = false - // The clock reference is on PhoneClock (elapsedRealtime); the host clock here is System.nanoTime. - val r = options.clockReference - val unixMs = r["clockUnixMs"]?.toDoubleOrNull(); val bootMs = r["clockMonotonicMs"]?.toDoubleOrNull() - val nowBootMs = before / 1e6 + bootMinusHost * 1000 - if (r["clockId"] == PhoneClock.identifier && unixMs != null && bootMs != null && unixMs in 1e12..4e12 && bootMs.isFinite() && - nowBootMs - bootMs >= 0 && nowBootMs - bootMs < 300_000) { - referenceUtcMinusHost = (unixMs - bootMs) / 1000 + bootMinusHost + GeneratorClock.referenceMapping(clockReference, before / 1e6 + bootMinusHost * 1000, bootMinusHost)?.let { + referenceUtcMinusHost = it usingReference = true } + planUtcMinusHost = referenceUtcMinusHost + deviceNote = "" val epochTai = ((referenceUtcMinusHost + options.taiUtc + epochHost) * 1e9).roundToLong() val next = PhoneGenerator(sourceID, rate, epochTai, epochHost, options.name.ifEmpty { "Android" }) plan = next @@ -307,14 +317,31 @@ class AvsyncGenerator(private val activity: Activity, private val options: Optio images.keys.removeAll { it + 1 < shown } } - private fun referenceExpired(): Boolean { - if (!usingReference) return false - val r = options.clockReference - val anchor = r["clockMonotonicMs"]?.toDoubleOrNull() ?: return false - val radius = r["clockUncertaintyMs"]?.toDoubleOrNull() ?: return false - val limit = r["clockLimitMs"]?.toDoubleOrNull() ?: return false - val age = PhoneClock.nowMs() - anchor - return age > 300_000 || radius + age * 50 / 1e6 > limit + // A newer clock check from the app (docs/phone-clock.md), like updateClock on iOS. Before the plan starts + // (or while stopped) it replaces the reference for the next start. While the signal runs, the stamps of the + // events not scheduled yet slew to it (core retime, 100 ppm); host times and the Test ID stay. A signal on + // device UTC switches to NTP UTC this way. A check further from the current stamps than the limit stops a + // signal on NTP UTC; one on device UTC keeps running and says so in the status line. + fun updateClock(reference: Map) { + if (calibrate || finishing) return + val bootMinusHost = (SystemClock.elapsedRealtimeNanos() - System.nanoTime()) / 1e9 + val nowBootMs = PhoneClock.nowMs() + val mapping = GeneratorClock.referenceMapping(reference, nowBootMs, bootMinusHost) ?: return + val anchor = reference["clockMonotonicMs"]?.toDoubleOrNull() ?: return + val radius = reference["clockUncertaintyMs"]?.toDoubleOrNull() ?: return + val limit = reference["clockLimitMs"]?.toDoubleOrNull() ?: return + if (PhoneGenerator.clockReferenceExpired(nowBootMs - anchor, radius, limit)) return + val plan = plan ?: run { clockReference = reference; return } + val offset = mapping - planUtcMinusHost + if (plan.retime(Math.round(offset * 1e9), nextAudio, Math.round(limit * 1e6))) { + clockReference = reference; usingReference = true; deviceNote = ""; return + } + val change = offset * 1000 - plan.clockOffsetMs(nextAudio) + if (!usingReference) { + deviceNote = String.format(java.util.Locale.ROOT, " (clock check %+.1f ms off, more than the %.0f ms limit)", change, limit); return + } + clockReference = reference + stop(String.format(java.util.Locale.ROOT, "New clock check is %+.1f ms off (more than the %.0f ms limit). Tap Start for a fresh clock mapping.", change, limit)) } // ---- Sender calibration and calibration pattern ---- @@ -491,6 +518,19 @@ class AvsyncGenerator(private val activity: Activity, private val options: Optio } } +// The clock check as a mapping for the generator (JVM tests). The check is on PhoneClock (elapsedRealtime), +// the generator's host clock is System.nanoTime: UTC minus host seconds, or null for another clock or when +// the check is not 0–300 s old at nowBootMs. +object GeneratorClock { + fun referenceMapping(r: Map, nowBootMs: Double, bootMinusHost: Double): Double? { + val unixMs = r["clockUnixMs"]?.toDoubleOrNull() ?: return null + val bootMs = r["clockMonotonicMs"]?.toDoubleOrNull() ?: return null + val age = nowBootMs - bootMs + if (r["clockId"] != PhoneClock.identifier || unixMs !in 1e12..4e12 || !bootMs.isFinite() || !(age >= 0 && age < 300_000)) return null + return (unixMs - bootMs) / 1000 + bootMinusHost + } +} + // Pure layout of the generator screens (JVM tests). object GeneratorLayout { class Fit(val rotate: Boolean, val scale: Float) diff --git a/mobile-app/modules/avsync/android/src/main/java/io/leeo86/mxl/avsync/AvsyncModule.kt b/mobile-app/modules/avsync/android/src/main/java/io/leeo86/mxl/avsync/AvsyncModule.kt index 10ba4c0..ec0d56b 100644 --- a/mobile-app/modules/avsync/android/src/main/java/io/leeo86/mxl/avsync/AvsyncModule.kt +++ b/mobile-app/modules/avsync/android/src/main/java/io/leeo86/mxl/avsync/AvsyncModule.kt @@ -47,6 +47,11 @@ class AvsyncModule : Module() { AsyncFunction("generateStyled") { rate: Int, taiUtc: Double, options: Map, promise: Promise -> presentGenerator(rate, taiUtc, options, promise) }.runOnQueue(Queues.MAIN) + // A newer clock check for the running test signal (docs/phone-clock.md); ignored when none runs. + AsyncFunction("updateGeneratorClock") { options: Map -> + generator?.updateClock(options.filterKeys { it.startsWith("clock") }) + Unit + }.runOnQueue(Queues.MAIN) Function("generatorImageInfo") { id: String -> GeneratorImageStore.info(context, id) } // The system photo picker (Android 13+) or document picker: only the chosen image is shared, no storage permission. AsyncFunction("pickGeneratorImage") { promise: Promise -> diff --git a/mobile-app/modules/avsync/android/src/main/java/io/leeo86/mxl/avsync/PhoneGenerator.kt b/mobile-app/modules/avsync/android/src/main/java/io/leeo86/mxl/avsync/PhoneGenerator.kt index 42f8282..d9624db 100644 --- a/mobile-app/modules/avsync/android/src/main/java/io/leeo86/mxl/avsync/PhoneGenerator.kt +++ b/mobile-app/modules/avsync/android/src/main/java/io/leeo86/mxl/avsync/PhoneGenerator.kt @@ -27,6 +27,10 @@ class PhoneGenerator(testId: String, rate: Int, epochTaiNs: Long, epochHostSecon } fun hostTime(sequence: Long): Double = hostTime(live(), sequence) fun audio(sequence: Long): FloatArray = audio(live(), sequence) + // Clock refresh (core PhoneGeneratorPlan::retime): stamps of events from fromSequence on slew to offsetNs; + // false (nothing changes) when that is more than maxStepNs away from the current stamps. + fun retime(offsetNs: Long, fromSequence: Long, maxStepNs: Long): Boolean = retime(live(), offsetNs, fromSequence, maxStepNs) + fun clockOffsetMs(sequence: Long): Double = clockOffsetMs(live(), sequence) fun image(sequence: Long): Bitmap = bitmap(image(live(), sequence) ?: throw IllegalStateException("Session exceeds one day.")) override fun close() { if (handle != 0L) { destroy(handle); handle = 0 } } @@ -45,6 +49,8 @@ class PhoneGenerator(testId: String, rate: Int, epochTaiNs: Long, epochHostSecon } private fun bitmap(argb: IntArray) = Bitmap.createBitmap(argb, WIDTH, HEIGHT, Bitmap.Config.ARGB_8888) + // The clock reference rule (core clock_reference_expired): older than 300 s or radius + 50 ppm holdover above the limit. + @JvmStatic external fun clockReferenceExpired(ageMs: Double, radiusMs: Double, limitMs: Double): Boolean @JvmStatic external fun testId(prefix: String, installId: String, version: String, settings: Array): String // Calibration pattern (docs/calibrator.md display mode), the same core functions as every generator. @JvmStatic external fun calibrationLeftWhite(frame: Long, fps: Int): Boolean @@ -64,5 +70,7 @@ class PhoneGenerator(testId: String, rate: Int, epochTaiNs: Long, epochHostSecon @JvmStatic private external fun hostTime(h: Long, seq: Long): Double @JvmStatic private external fun audio(h: Long, seq: Long): FloatArray @JvmStatic private external fun image(h: Long, seq: Long): IntArray? + @JvmStatic private external fun retime(h: Long, offsetNs: Long, fromSeq: Long, maxStepNs: Long): Boolean + @JvmStatic private external fun clockOffsetMs(h: Long, seq: Long): Double } } diff --git a/mobile-app/modules/avsync/android/src/test/java/io/leeo86/mxl/avsync/GeneratorTest.kt b/mobile-app/modules/avsync/android/src/test/java/io/leeo86/mxl/avsync/GeneratorTest.kt index c207114..46b3392 100644 --- a/mobile-app/modules/avsync/android/src/test/java/io/leeo86/mxl/avsync/GeneratorTest.kt +++ b/mobile-app/modules/avsync/android/src/test/java/io/leeo86/mxl/avsync/GeneratorTest.kt @@ -45,4 +45,13 @@ class GeneratorTest { // A short view: the halves fill it. assertEquals(0f, GeneratorLayout.calibration(1080f, 500f, mm).top, 0f) } + @Test fun clockCheckMapsToTheHostClock() { + // A check on PhoneClock 1 s before now: UTC minus host = (unix - boot) / 1000 + (boot - host). + val check = mapOf("clockId" to PhoneClock.identifier, "clockUnixMs" to "1800000000000", "clockMonotonicMs" to "50000") + assertEquals(1_799_999_950.0 + 2.5, GeneratorClock.referenceMapping(check, 51_000.0, 2.5)!!, 1e-6) + assertNull(GeneratorClock.referenceMapping(check, 350_000.0, 2.5)) // 300 s old + assertNull(GeneratorClock.referenceMapping(check, 49_000.0, 2.5)) // from the future + assertNull(GeneratorClock.referenceMapping(check + ("clockId" to "other"), 51_000.0, 2.5)) + assertNull(GeneratorClock.referenceMapping(check + ("clockUnixMs" to "17"), 51_000.0, 2.5)) + } } diff --git a/mobile-app/modules/avsync/ios/AVSyncGenerator.h b/mobile-app/modules/avsync/ios/AVSyncGenerator.h index 71bbedb..da5ca11 100644 --- a/mobile-app/modules/avsync/ios/AVSyncGenerator.h +++ b/mobile-app/modules/avsync/ios/AVSyncGenerator.h @@ -14,6 +14,12 @@ NS_ASSUME_NONNULL_BEGIN - (NSData *)audioForSequence:(uint64_t)sequence NS_SWIFT_NAME(audio(sequence:)); - (UIImage *)imageForSequence:(uint64_t)sequence NS_SWIFT_NAME(image(sequence:)); // AVS4 sender calibration (this phone's screen vs speaker); method none until set. +// Clock refresh (core PhoneGeneratorPlan::retime): stamps of events from `sequence` on slew to offsetNs; +// NO (nothing changes) when that is more than maxStepNs away from the current stamps. +- (BOOL)retimeOffsetNs:(int64_t)offsetNs fromSequence:(uint64_t)sequence maxStepNs:(int64_t)maxStepNs NS_SWIFT_NAME(retime(offsetNs:fromSequence:maxStepNs:)); +- (double)clockOffsetMsForSequence:(uint64_t)sequence NS_SWIFT_NAME(clockOffsetMs(sequence:)); +// The clock reference rule (core clock_reference_expired): older than 300 s or radius + 50 ppm holdover above the limit. ++ (BOOL)clockReferenceExpiredAgeMs:(double)ageMs radiusMs:(double)radiusMs limitMs:(double)limitMs NS_SWIFT_NAME(clockReferenceExpired(ageMs:radiusMs:limitMs:)); - (void)setSenderCalibrationMethod:(NSString *)method offsetMs:(double)offsetMs uncertaintyMs:(double)uncertaintyMs day:(NSInteger)day NS_SWIFT_NAME(setSenderCalibration(method:offsetMs:uncertaintyMs:day:)); // Calibration pattern (docs/calibrator.md display mode; core/include/avsync/calibrator.hpp). diff --git a/mobile-app/modules/avsync/ios/AVSyncGenerator.mm b/mobile-app/modules/avsync/ios/AVSyncGenerator.mm index a48e179..8e3cf71 100644 --- a/mobile-app/modules/avsync/ios/AVSyncGenerator.mm +++ b/mobile-app/modules/avsync/ios/AVSyncGenerator.mm @@ -108,6 +108,9 @@ - (void)setSenderCalibrationMethod:(NSString *)method offsetMs:(double)offset un if((m!="clap"&&m!="transponder"&&m!="pulse-train")||!std::isfinite(offset)||std::abs(offset)>3276||!std::isfinite(uncertainty)||uncertainty<0||uncertainty>127||day<0||day>0xFFFF)return; _calibration.method=m;_calibration.offset_ms=offset;_calibration.uncertainty_ms=uncertainty;_calibration.day=int(day); } +- (BOOL)retimeOffsetNs:(int64_t)offset fromSequence:(uint64_t)seq maxStepNs:(int64_t)maxStep { return _plan->retime(offset,seq,maxStep); } +- (double)clockOffsetMsForSequence:(uint64_t)seq { return double(_plan->clock_offset_ns(seq))/1e6; } ++ (BOOL)clockReferenceExpiredAgeMs:(double)age radiusMs:(double)radius limitMs:(double)limit { return avsync::clock_reference_expired(age,radius,limit); } + (BOOL)calibrationLeftWhiteAtFrame:(uint64_t)frame fps:(NSInteger)fps { return avsync::calibration_left_white(frame,fps,1); } + (uint64_t)calibrationSwapFrame:(uint64_t)swap fps:(NSInteger)fps { return avsync::calibration_swap_frame(swap,fps,1); } + (NSData *)calibrationBeepWithSampleRate:(double)rate { diff --git a/mobile-app/modules/avsync/ios/AvsyncGeneratorController.swift b/mobile-app/modules/avsync/ios/AvsyncGeneratorController.swift index cc88a5c..cbdd521 100644 --- a/mobile-app/modules/avsync/ios/AvsyncGeneratorController.swift +++ b/mobile-app/modules/avsync/ios/AvsyncGeneratorController.swift @@ -43,8 +43,10 @@ final class AvsyncGeneratorController: UIViewController { private var sourceID = "" private var lastStatus = 0.0 private var utcMinusHost = 0.0 - private let clockReference: [String:String] + private var planUtcMinusHost = 0.0 // the mapping the plan's stamps started from + private var clockReference: [String:String] // the newest accepted clock check (updateClock) private var usingReference = false + private var deviceNote = "" // why a signal on device UTC did not switch to a clock check private let senderCalibration: [String:String] private var senderCalibrationNote = "" // Calibration pattern: two halves, the calibrator outline, the microphone take. @@ -176,13 +178,12 @@ final class AvsyncGeneratorController: UIViewController { utcMinusHost = utc - (before + after)/2 var referenceUtcMinusHost = utcMinusHost usingReference = false - if clockReference["clockId"] == PhoneClock.identifier, - let unixMs = Double(clockReference["clockUnixMs"] ?? ""), (1e12...4e12).contains(unixMs), - let hostMs = Double(clockReference["clockMonotonicMs"] ?? ""), unixMs.isFinite, hostMs.isFinite, - (before*1000-hostMs) >= 0, before*1000-hostMs < 300_000 { - referenceUtcMinusHost = (unixMs-hostMs)/1000 + if let mapping = referenceMapping(clockReference, now: before) { + referenceUtcMinusHost = mapping usingReference = true } + planUtcMinusHost = referenceUtcMinusHost + deviceNote = "" let epochTai = Int64((referenceUtcMinusHost + taiUtc + epochHost) * 1e9) plan = AVSyncGenerator(testID: sourceID, rate: rate, epochTaiNs: epochTai, epochHost: epochHost) guard let plan else { return } @@ -201,6 +202,37 @@ final class AvsyncGeneratorController: UIViewController { canvas.layer.contents = first.cgImage try fillAhead(now: after) } + // UTC minus host seconds of a clock check on this phone's clock; nil for another clock or when not 0–300 s old. + private func referenceMapping(_ r: [String:String], now: Double) -> Double? { + guard r["clockId"] == PhoneClock.identifier, + let unixMs = Double(r["clockUnixMs"] ?? ""), (1e12...4e12).contains(unixMs), + let hostMs = Double(r["clockMonotonicMs"] ?? ""), hostMs.isFinite, + now*1000-hostMs >= 0, now*1000-hostMs < 300_000 else { return nil } + return (unixMs-hostMs)/1000 + } + // A newer clock check from the app (docs/phone-clock.md). Before the plan starts (or while stopped) + // it replaces the reference for the next start. While the signal runs, the stamps of the events not + // scheduled yet slew to it (core retime, 100 ppm); host times and the Test ID stay. A signal on + // device UTC switches to NTP UTC this way. A check further from the current stamps than the limit + // stops a signal on NTP UTC; one on device UTC keeps running and says so in the status line. + func updateClock(_ reference: [String:String]) { + let now = CACurrentMediaTime() + guard !calibrate, let mapping = referenceMapping(reference, now: now), + let anchor = Double(reference["clockMonotonicMs"] ?? ""), let radius = Double(reference["clockUncertaintyMs"] ?? ""), + let limit = Double(reference["clockLimitMs"] ?? ""), + !AVSyncGenerator.clockReferenceExpired(ageMs: now*1000-anchor, radiusMs: radius, limitMs: limit) else { return } + guard let plan else { clockReference = reference; return } + let offset = mapping - planUtcMinusHost + if plan.retime(offsetNs: Int64((offset*1e9).rounded()), fromSequence: nextAudio, maxStepNs: Int64((limit*1e6).rounded())) { + clockReference = reference; usingReference = true; deviceNote = ""; return + } + let change = offset*1000 - plan.clockOffsetMs(sequence: nextAudio) + guard usingReference else { + deviceNote = String(format: " (clock check %+.1f ms off, more than the %.0f ms limit)", change, limit); return + } + clockReference = reference + stop(String(format: "New clock check is %+.1f ms off (more than the %.0f ms limit). Tap Start for a fresh clock mapping.", change, limit)) + } // Same install, app version (with build number) and timing settings give the same ID, so a restart keeps it. // The install ID is random, kept in UserDefaults and gone after a reinstall. // Name, subtitle and image are cosmetic and stay out. Add a new timing setting to `settings`. @@ -279,15 +311,20 @@ final class AvsyncGeneratorController: UIViewController { CATransaction.commit() if now - lastStatus > 0.5 { lastStatus = now + // Expires when no newer clock check arrived in time (updateClock), by the rule of the app. + var clockNote = "" if usingReference, let anchor = Double(clockReference["clockMonotonicMs"] ?? ""), - let radius = Double(clockReference["clockUncertaintyMs"] ?? ""), let limit = Double(clockReference["clockLimitMs"] ?? ""), - (now*1000-anchor > 300_000 || radius+(now*1000-anchor)*50/1e6 > limit) { - stop("Clock reference expired. Close and synchronize again."); return + let radius = Double(clockReference["clockUncertaintyMs"] ?? ""), let limit = Double(clockReference["clockLimitMs"] ?? "") { + if AVSyncGenerator.clockReferenceExpired(ageMs: now*1000-anchor, radiusMs: radius, limitMs: limit) { + stop("Clock reference expired. Close and synchronize again."); return + } + let remaining = plan.clockOffsetMs(sequence: UInt64.max) - plan.clockOffsetMs(sequence: sequence) + clockNote = " (check \(Int(now - anchor/1000)) s ago" + (abs(remaining) >= 0.05 ? String(format: ", adjusting %+.1f ms", remaining) : "") + ")" } if abs(Date().timeIntervalSince1970 - now - utcMinusHost) > 0.1 { stop("Phone clock changed. Tap Start to create a fresh clock mapping."); return } - status.text = "TEST SIGNAL ACTIVE · 1200 baud · \(rate)/s · frame skips \(lateFrames)\nEstimated output timing · \(usingReference ? "NTP UTC" : "device UTC") · audio lead \(Int((outputLatency*1000).rounded())) ms\(senderCalibrationNote)" + status.text = "TEST SIGNAL ACTIVE · 1200 baud · \(rate)/s · frame skips \(lateFrames)\nEstimated output timing · \(usingReference ? "NTP UTC\(clockNote)" : "device UTC\(deviceNote)") · audio lead \(Int((outputLatency*1000).rounded())) ms\(senderCalibrationNote)" } } private func stop(_ message: String) { diff --git a/mobile-app/modules/avsync/ios/AvsyncModule.swift b/mobile-app/modules/avsync/ios/AvsyncModule.swift index d8c0cd9..5938e27 100644 --- a/mobile-app/modules/avsync/ios/AvsyncModule.swift +++ b/mobile-app/modules/avsync/ios/AvsyncModule.swift @@ -39,6 +39,11 @@ public class AvsyncModule: Module { AsyncFunction("generateStyled") { (rate: Int, taiUtc: Double, options: [String: String], promise: Promise) in self.presentGenerator(rate: rate, taiUtc: taiUtc, options: options, promise: promise) }.runOnQueue(.main) + // A newer clock check for the running test signal (docs/phone-clock.md); ignored when none runs. + AsyncFunction("updateGeneratorClock") { (options: [String: String]) in + guard let generator = self.generator else { return } + generator.updateClock(options.filter { $0.key.hasPrefix("clock") }) + }.runOnQueue(.main) Function("generatorImageInfo") { (id: String) -> [String: Any]? in GeneratorImageStore.info(id: id) } AsyncFunction("pickGeneratorImage") { (promise: Promise) in guard self.imagePicker == nil, let parent = self.appContext?.utilities?.currentViewController(), parent.presentedViewController == nil else { diff --git a/mobile-app/src/time-reference.mjs b/mobile-app/src/time-reference.mjs index 0f35f3a..a852653 100644 --- a/mobile-app/src/time-reference.mjs +++ b/mobile-app/src/time-reference.mjs @@ -53,6 +53,17 @@ export function referenceAt(reference, mapping, maxUncertaintyMs = 20) { sampleUncertaintyMs, holdoverUncertaintyMs, holdoverDriftPpm: CLOCK_DRIFT_PPM, unixMs }; } +// When the app runs its next clock check (in front, also while the test signal runs, which stops once +// its reference expires by referenceAt's rule): a minute before the 5 min age limit, or, at most every +// 30 s, once the holdover allowance would pass the limit within a minute. +export const CLOCK_RECHECK_MS = 30000, CLOCK_MARGIN_MS = 60000; +export function clockCheckDue(reference, jsMono = performance.now(), limitMs = 20) { + if (!reference) return true; + const age = jsMono-reference.receivedJsMonoMs+reference.ageAtReceiptMs; + if (!(age >= 0) || age > CLOCK_MAX_AGE_MS-CLOCK_MARGIN_MS) return true; + return age > CLOCK_RECHECK_MS && reference.uncertaintyMs+(age+CLOCK_MARGIN_MS)*CLOCK_DRIFT_PPM/1e6 > limitMs; +} + export function clockRadiusText(ms) { if (!Number.isFinite(ms) || ms < 0) return 'unknown'; return ms < 0.1 ? '<0.1 ms' : `±${ms.toFixed(1)} ms`; diff --git a/mobile-app/src/time-reference.test.mjs b/mobile-app/src/time-reference.test.mjs index 69b6f84..c2dd842 100644 --- a/mobile-app/src/time-reference.test.mjs +++ b/mobile-app/src/time-reference.test.mjs @@ -1,6 +1,6 @@ import test from 'node:test'; import assert from 'node:assert/strict'; -import { applyTimeReference, bindReference, clockCheckDetails, compareClockReferences, clockRadiusText, clockSettings, measurementClockLabel, generatorClockOptions, referenceAt, referenceSummary, synchronizeClock } from './time-reference.mjs'; +import { applyTimeReference, bindReference, clockCheckDetails, clockCheckDue, compareClockReferences, clockRadiusText, clockSettings, measurementClockLabel, generatorClockOptions, referenceAt, referenceSummary, synchronizeClock } from './time-reference.mjs'; const epoch = 1793450000000; const sample = {source:'ntp',server:'lab.example',referenceUnixMs:epoch,referenceMonotonicMs:1000,deviceUnixMs:epoch-100,uncertaintyMs:2,roundTripMs:2,clockId:'boot-a',timebase:'ios-host'}; const now = {monotonicMs:1100,clockId:'boot-a',timebase:'ios-host'}; @@ -129,3 +129,37 @@ test('independent sync checks compare at a common native time without treating n assert.equal(compareClockReferences({...current,uncertaintyMs:NaN},reference),null); assert.equal(compareClockReferences(current,null),null); }); + +test('clock checks come before the reference expires, so a running test signal never runs out', () => { + const at=(t,radius=2)=>bindReference({...sample,referenceMonotonicMs:t,uncertaintyMs:radius},{...now,monotonicMs:t},t); + assert.equal(clockCheckDue(null,0),true); + assert.equal(clockCheckDue(at(0),239000),false); + assert.equal(clockCheckDue(at(0),241000),true); // a minute before the 5 min limit + assert.equal(clockCheckDue(at(0,8),179000),false); + assert.equal(clockCheckDue(at(0,8),181000),true); // 8 ms + 240 s x 50 ppm reaches 20 ms + assert.equal(clockCheckDue(at(0,19),29000),false); // never more often than every 30 s + assert.equal(clockCheckDue(at(0,19),31000),true); + // 15 min of test signal: every 0.5 s the app asks clockCheckDue; a check answers 1 s later and is + // pushed to the generator (generatorClockOptions), which stops once its reference is no longer usable. + for(const network of [true,false]) { + let pushed=at(0,8),pending=null,stopped=null,pushes=0; + for(let t=0;t<=900000&&stopped===null;t+=500) { + if(pending!==null&&t>=pending){pushed=at(pending-1000,8);pending=null;assert.ok(generatorClockOptions(pushed,{...now,monotonicMs:t}).clockUnixMs);pushes++;} + if(network&&pending===null&&clockCheckDue(pushed,t)) pending=t+1000; + if(referenceAt(pushed,{...now,monotonicMs:t}).state!=='estimated') stopped=t; + } + if(network){assert.equal(stopped,null);assert.ok(pushes>=4);} + else assert.equal(stopped,240500); + } +}); + +test('a signal started before the first check gets that check as soon as it arrives', () => { + // Start right after coming to the front: no reference yet, so the signal starts on device UTC ... + assert.deepEqual(generatorClockOptions(null,now),{}); + // ... and the first accepted check is what App.js hands to it (updateGeneratorClock). + const first=bindReference(sample,now,10); + const options=generatorClockOptions(first,now); + assert.equal(options.clockUnixMs,String(epoch)); + assert.equal(options.clockLimitMs,'20'); + assert.equal(options.clockId,'boot-a'); +}); diff --git a/mobile-app/tests/native/generator-runner.mm b/mobile-app/tests/native/generator-runner.mm index 65333fd..ecd5e8b 100644 --- a/mobile-app/tests/native/generator-runner.mm +++ b/mobile-app/tests/native/generator-runner.mm @@ -166,5 +166,15 @@ int main(int argc, char **argv) { @autoreleasepool { assert(beep.length==192*sizeof(float)); assert(![[AVSyncGenerator displayCalibrationForSamples:[NSMutableData dataWithLength:48000*sizeof(float)] sampleRate:48000 minimumPairs:8][@"ok"] boolValue]); } - std::cout<<"PASS: shared color renderer, DejaVu text, custom name/subtitle/image, native sync scale, QR geometry/flash onset and 1/2-Hz independent audio.\n"; + { + // Clock refresh (core retime): stamps of events not scheduled yet slew, host times stay. + AVSyncGenerator *source=[[AVSyncGenerator alloc] initWithTestID:@"phone-native-slew" rate:2 epochTaiNs:1700000037000000000LL epochHost:100]; + assert([source retimeOffsetNs:2000000 fromSequence:5 maxStepNs:20000000]); + assert(![source retimeOffsetNs:50000000 fromSequence:5 maxStepNs:20000000]); + assert([source clockOffsetMsForSequence:4]==0 && std::abs([source clockOffsetMsForSequence:100]-2)<1e-9); + assert(std::abs([source hostTimeForSequence:100]-(100+99/2.0))<1e-9); + assert([AVSyncGenerator clockReferenceExpiredAgeMs:300001 radiusMs:0 limitMs:20]); + assert(![AVSyncGenerator clockReferenceExpiredAgeMs:1000 radiusMs:5 limitMs:20]); + } + std::cout<<"PASS: shared color renderer, DejaVu text, custom name/subtitle/image, native sync scale, QR geometry/flash onset, 1/2-Hz independent audio and clock refresh slew.\n"; } } diff --git a/tests/unit/test_timecode.cpp b/tests/unit/test_timecode.cpp index 8140619..f21ca4e 100644 --- a/tests/unit/test_timecode.cpp +++ b/tests/unit/test_timecode.cpp @@ -293,6 +293,96 @@ TEST_CASE("phone generator uses one scheduled clock for QR, flash and independen CHECK_THROWS(encode_timecode(1,epoch,0,false,true)); } +TEST_CASE("phone generator clock refresh slews stamps of unscheduled events only") { + constexpr int64_t epoch = 1792963237123456789LL, ms = 1000000; + for (int rate : {1,2}) { + const int64_t period = 1000000000LL / rate, step = 100 * period / 1000000; // 100 ppm of a period + PhoneGeneratorPlan plan("phone-slew",rate,epoch,100); + const double host = plan.host_seconds(10); + const auto before = plan.qr(9).tk; + REQUIRE(plan.retime(5*ms,10,20*ms)); + CHECK(plan.qr(9).tk == before); // already scheduled: unchanged + CHECK(plan.host_seconds(10) == host); // host times never move + CHECK(plan.frame_at(host) == uint64_t(9*60/rate)); + int64_t last = plan.qr(10).tk; + CHECK(last == epoch + 9*period); + for (uint64_t k = 11; k < 400; ++k) { // never faster than the slew rate + const auto tk = plan.qr(k).tk; + CHECK(std::llabs(tk - last - period) <= step); + last = tk; + } + CHECK(plan.clock_offset_ns(10 + uint64_t((5*ms + step - 1)/step)) == 5*ms); + CHECK(plan.clock_offset_ns(1000) == 5*ms); + // A second check while still slewing starts from where the stamps are at that event. + const auto mid = plan.clock_offset_ns(30), earlier = plan.clock_offset_ns(29); + REQUIRE(plan.retime(-1*ms,30,20*ms)); + CHECK(plan.clock_offset_ns(30) == mid); + CHECK(plan.clock_offset_ns(29) == earlier); + CHECK(plan.clock_offset_ns(2000) == -1*ms); + // More than the limit away from the current stamps: refused, nothing changes. + CHECK_FALSE(plan.retime(25*ms,2000,20*ms)); + CHECK(plan.clock_offset_ns(5000) == -1*ms); + CHECK_FALSE(plan.retime(0,0,20*ms)); + // Audio and picture of one event carry the same slewed stamp. + AudioTimecode packet; const auto qr = plan.qr(40); + REQUIRE(parse_timecode(encode_timecode(qr.source_id,qr.tk,0,true,true),packet)); + VideoObservation video; video.seq=40; REQUIRE(identify_video(video,qr)); + CHECK(video.event_time_ns == quantize_timecode_ns(qr.tk)); + } +} + +TEST_CASE("phone generator started on device UTC switches to the first clock check within the limit") { + // The plan's start mapping is device UTC (offset 0). The native side retimes with + // NTP minus device; a refusal keeps the signal on device UTC (no stop). + constexpr int64_t epoch = 1792963237123456789LL, ms = 1000000; + PhoneGeneratorPlan plan("phone-switch",2,epoch,0); + CHECK_FALSE(plan.retime(35*ms,10,20*ms)); // device clock 35 ms off: stays device UTC + CHECK(plan.clock_offset_ns(100000) == 0); + CHECK(plan.qr(500).tk == epoch + 499*500000000LL); + REQUIRE(plan.retime(12*ms,200,20*ms)); // a later check within the limit: switch + CHECK(plan.clock_offset_ns(200) == 0); + CHECK(plan.clock_offset_ns(200 + 12*ms/50000) == 12*ms); // 240 events = 120 s at 100 ppm + int64_t last = plan.qr(200).tk; + for (uint64_t k = 201; k < 500; ++k) { const auto tk = plan.qr(k).tk; CHECK(std::llabs(tk - last - 500000000LL) <= 50000); last = tk; } + CHECK(plan.host_seconds(300) == doctest::Approx(149.5)); +} + +TEST_CASE("phone generator keeps running past 5 minutes with refreshed clock checks and stops without them") { + // A 15 min run at 2 events/s: status every 0.5 s, packets 2.5 s ahead, a check + // (radius 8 ms, limit 20 ms) whenever the app's rule asks for one + // (clockCheckDue in mobile-app/src/time-reference.mjs: 240 s old, or the limit within 60 s). + constexpr int64_t epoch = 1792963237123456789LL, ms = 1000000; + const double radius = 8, limit = 20; + for (bool network : {true,false}) { + PhoneGeneratorPlan plan("phone-run",2,epoch,0); + double anchor = 0, stopped = -1; int64_t target = 0; int checks = 0; + const int64_t truth[] = {3*ms,-2*ms,4*ms,1*ms,-3*ms}; + uint64_t next = 1; + for (double now = 0; now <= 900 && stopped < 0; now += .5) { + while (plan.host_seconds(next) < now + 2.5) ++next; + const double age = (now - anchor) * 1000; + const bool due = age > 240000 || (age > 30000 && radius + (age + 60000) * 50 / 1e6 > limit); + if (network && due) { + target = truth[checks++ % 5]; + REQUIRE(plan.retime(target,next,int64_t(limit*ms))); + anchor = now; + } + if (clock_reference_expired((now - anchor) * 1000,radius,limit)) stopped = now; + } + if (network) { + CHECK(stopped < 0); + CHECK(checks >= 4); + CHECK(plan.clock_offset_ns(next + 1000) == target); + } else { + CHECK(stopped > 240); CHECK(stopped <= 240.5); // 8 ms + 240 s x 50 ppm = 20 ms + } + } + CHECK(clock_reference_expired(300001,0,20)); + CHECK_FALSE(clock_reference_expired(300000,5,20)); + CHECK(clock_reference_expired(-1,0,20)); + CHECK(clock_reference_expired(std::nan(""),0,20)); +} + TEST_CASE("phone source latency stays explicitly estimated even with receiver clock lock") { for (auto state : {"locked","unlocked","unknown"}) { ReferenceInfo ref; ref.type="tai-ptp"; ref.sync_state=state;