Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
26 commits
Select commit Hold shift + click to select a range
21b6826
Core: calibrator analysis and calibration pattern
LeeO86 Nov 8, 2026
6d752d9
Phone analyzer: calibrator take (camera and external display)
LeeO86 Nov 8, 2026
2df391f
iPhone generator: calibration pattern mode and sender calibration in …
LeeO86 Nov 8, 2026
9ddbe93
Container generator: AVSYNC_PATTERN=calibration
LeeO86 Nov 8, 2026
6f485ac
App: calibrator wizard (Settings > With calibrator)
LeeO86 Nov 8, 2026
e94b39f
Docs: calibrator wizard, calibration pattern, contract app side
LeeO86 Nov 8, 2026
520975f
Docs: native contract for the calibrator take and calibration mode
LeeO86 Nov 8, 2026
aea384b
Calibrator take: Cancel during the exposure lock no longer starts it;…
LeeO86 Nov 8, 2026
811926e
iOS: type displayCalibration as [String: Any] for Swift
LeeO86 Nov 8, 2026
0369bd9
iPhone generator: Stop clears the whole raster and shows "Stopped"
LeeO86 Nov 8, 2026
16b7a5a
Analysers subtract the sender calibration from the AVS4 QR
LeeO86 Nov 8, 2026
574d151
App: raw value and sender calibration, clear both, Title as default n…
LeeO86 Nov 8, 2026
0516c69
Docs: analysers subtract the sender calibration, clear both, Title as…
LeeO86 Nov 8, 2026
6fbcdba
Android: generator bridge to the shared core and picture renderer
LeeO86 Nov 8, 2026
c1126cf
Android: fullscreen generator, calibration mode and image picker
LeeO86 Nov 8, 2026
405fbc9
Android: calibrator take in the camera view
LeeO86 Nov 8, 2026
1607084
App: calibrator wizard on Android, generator title placeholder
LeeO86 Nov 8, 2026
4690637
Android: generator tests (audio placement, layout, bridge on a device)
LeeO86 Nov 8, 2026
5ec6532
Docs: Android generator and calibrator take
LeeO86 Nov 8, 2026
0fe96a5
Merge feat/calibrator-wizard (analysers subtract the sender calibrati…
LeeO86 Nov 8, 2026
104caab
docs/calibrator.md: BOOT button and 1 nF filter row from #69
LeeO86 Nov 8, 2026
651ac8b
Merge feat/calibrator-wizard (calibrator.md wording)
LeeO86 Nov 8, 2026
c7deebe
Android camera view: live poll goes on between calibrator reports, as…
LeeO86 Nov 8, 2026
0f04ea9
Phone generator: refresh the clock while the test signal runs (slewed)
LeeO86 Nov 8, 2026
b0a6085
Phone generator: switch a device-UTC signal to the first clock check
LeeO86 Nov 9, 2026
54d90b9
Merge main (#71 squashed) into feat/generator-clock-refresh
LeeO86 Nov 9, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 15 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
20 changes: 20 additions & 0 deletions core/include/avsync/phone_generator.hpp
Original file line number Diff line number Diff line change
@@ -1,6 +1,9 @@
#pragma once
#include "avsync/avsync.hpp"
#include "avsync/optical_code.hpp"
#include <mutex>
#include <utility>
#include <vector>

namespace avsync {
// A scheduled source, not an independent measurement of display/DAC emission.
Expand All @@ -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<std::pair<uint64_t, int64_t>> 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
33 changes: 32 additions & 1 deletion core/src/phone_generator.cpp
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
#include "avsync/phone_generator.hpp"
#include <algorithm>
#include <cmath>
#include <cstdlib>
#include <stdexcept>
#include <utility>

Expand All @@ -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;
}
Expand All @@ -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<std::mutex> 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<std::mutex> 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<int64_t>(1, std::llround(1e9 / rate_ * clock_slew_ppm / 1e6));
slew_.push_back({from, current});
slew_.push_back({from + uint64_t(std::max<int64_t>(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
74 changes: 69 additions & 5 deletions docs/phone-clock.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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.
Expand Down
7 changes: 6 additions & 1 deletion docs/phone-generator.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
16 changes: 9 additions & 7 deletions mobile-app/App.js
Original file line number Diff line number Diff line change
Expand Up @@ -13,16 +13,14 @@ 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';
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() {
Expand Down Expand Up @@ -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 () => {
Expand All @@ -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);
Expand Down Expand Up @@ -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 }); }
Expand Down
3 changes: 2 additions & 1 deletion mobile-app/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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))
Expand Down
12 changes: 12 additions & 0 deletions mobile-app/modules/avsync/android/src/main/cpp/generator_jni.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -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)
{
Expand Down
Loading
Loading