Skip to content

Phone generator: refresh the clock while the test signal runs (slewed) - #72

Merged
LeeO86 merged 26 commits into
mainfrom
feat/generator-clock-refresh
Nov 9, 2026
Merged

LeeO86 merged 26 commits into
mainfrom
feat/generator-clock-refresh

Conversation

@LeeO86

@LeeO86 LeeO86 commented Nov 8, 2026 •

Copy link
Copy Markdown
Owner

Stacked on #71 (feat/android-generator).

Problem

The phone test signal took the NTP clock check once at Start and stopped itself ("Clock reference expired") once that check was 5 minutes old, or sooner once the holdover passed the limit. So it always died about 5 minutes after the last check.

Fix (option 1: refresh while running)

  • App (App.js): while the signal runs, every new clock check is passed to it with the new native call updateGeneratorClock(options). It is the same JS call on iOS and Android, and it takes the same options as generatorClockOptions. The check cadence is now one shared rule, clockCheckDue (src/time-reference.mjs), for measuring and for the signal: a check a minute before the 5-minute age limit, or (at most every 30 s) when the holdover would pass the limit within a minute. Before, the app checked only after the limit was passed, which was too late for a running signal.
  • Core (PhoneGeneratorPlan::retime, clock_offset_ns, clock_reference_expired): this code is shared, so iOS and Android behave the same.
    • The timestamps move, not the timing. Only the TAI stamps (in the QR and in the audio packet) of events not queued yet change. Host times never move: frames stay on the display's 60 Hz grid, and the sound and picture of one event always carry the same stamp. Events already queued (up to 2.5 s ahead) keep their stamps. I chose this over moving the host times: a sub-frame shift of the picture would be rounded to the vsync grid and could put picture and sound up to one frame apart.
    • Slew rate 100 ppm: each event's stamp moves by at most 0.05 ms (2/s) or 0.1 ms (1/s) from the one before. A typical 1–5 ms difference between checks is taken up in 10–50 s. The largest accepted difference (the limit, 20 ms by default) takes 200 s, which is less than the time to the next check. To an analyser this looks like a source clock 100 ppm off, the same order as the 50 ppm holdover allowance. When QRs are missed and the analyser counts on from an older event, the error stays under 2 ms (20 s gap).
    • Too big a difference (new check further from the current stamps than the uncertainty limit): the signal stops with "New clock check is … ms off (more than the … ms limit). Tap Start for a fresh clock mapping." The next Start uses the new check. I chose stopping over slewing with a cap: a slew that long would leave the output outside the limit for minutes without saying so.
    • The signal only stops with "Clock reference expired" when no check got through in time (no network). The rule is the same as before and now lives in core.
  • Native (iOS AvsyncGeneratorController.swift, Android AvsyncGenerator.kt): updateClock checks the new reference, slews from nextAudio (the first event not queued yet), and keeps it as the current reference. The status line still says "NTP UTC", now as "NTP UTC (check 42 s ago, adjusting +0.8 ms)" (the part of the slew still to go; nothing while there is none).
  • Started before the first check (follow-up from the Mac session): pressing Start right after coming to the front, before the first NTP check finishes, used to keep the signal on device UTC for the whole run. Now the first accepted check switches it to NTP UTC, with the same 100 ppm slew from the device-UTC timestamps to the NTP ones (same core retime, iOS and Android identical). If the device clock is further from the check than the limit, it does not stop: it stays on device UTC and the status says "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, the normal rules apply (refresh, expiry, stop on a big jump).
  • What an analyser sees: the QR and the audio packet carry no device-vs-NTP mark (phone timestamps are always flagged estimated; the server reports source_reference: phone-utc-scheduled-output either way). So an analyser only sees the timestamps slew. The close result has clockApplied: true and the check in clockReference from the moment of the switch, not from the end of the slew.
  • Unchanged: test ID / source ID; the "Phone clock changed" check (it compares the phone's wall clock with the host clock and does not use the NTP check). The calibration pattern uses no clock reference, so it is not affected. The container generator uses the host/PTP clock and does not use PhoneGeneratorPlan (checked by reading the code; no change).

Tests

  • Core ctest (test_timecode.cpp), 2 new cases:
    • slew rate, refused big change, queued events unchanged, host times unchanged, same stamp for audio and video;
    • a 15-minute run with checks every ~3 min: no stop. Without checks: stops at 240.5 s, which is the rule for 8 ms + 240 s × 50 ppm.
    • start on device UTC: a check 35 ms off is refused (stays on device UTC, nothing changes); a later 12 ms check switches it, slewed at ≤ 50 µs per event, host times unchanged.
  • node --test mobile-app/src/*.test.mjs tests/web/*.test.cjs: 60 pass, including clockCheckDue, a 15-minute JS run with and without network, and the first check reaching a signal started before it.
  • Android (docker): :avsync:testDebugUnitTest (new clockCheckMapsToTheHostClock), :avsync:assembleDebugAndroidTest (new instrumented clockRefreshSlewsStampsNotHostTimes, compiled but not run), :app:assembleRelease: build successful.
  • iOS: not compiled here. Swift parse check passed; the new Swift functions also type-check against stubs of the bridge. tests/native/generator-runner.mm has new retime/expiry checks.

Needs the Mac: build, then npm run test:ios-generator and npm run test:ios-native. Changed iOS files: AVSyncGenerator.h, AVSyncGenerator.mm, AvsyncGeneratorController.swift, AvsyncModule.swift, tests/native/generator-runner.mm.

Device test

  1. Start the test signal on NTP UTC and leave it running for more than 10 min. It must not stop, and the "check … s ago" age must go back to a small number every few minutes.
  2. Point an analyser at it during that time. Latency and AV offset should show no jumps; the "±… ms" value may move a little.
  3. Turn on flight mode while it runs. It should stop with "Clock reference expired" within about 5 min (sooner with a poor check).

🤖 Generated with Claude Code

https://claude.ai/code/session_017ybuzgBaGxuB5UiaE8dRa4

LeeO86 and others added 26 commits November 8, 2026 15:30
Shared C++ for the AV Sync calibrator board (docs/calibrator.md, contract v1):
- find_sound_onsets: clicks and beeps in a recording (trailing 0.25 ms RMS,
  25 % of peak), told apart by 2.7 kHz vs 1.5 kHz energy.
- find_flash_onsets: LED flash start from row-band means, on the
  rolling-shutter row the analyzer uses; readout from the band height or,
  with full-frame exposure, fitted over the pulses in camera_calibration.
- camera_calibration: pair flashes with clicks, robust median, uncertainty.
- display_calibration: click then beep D = 100 ms later, offset = gap - D,
  both swap directions weighted equally.
- calibration pattern for all generators: halves swap every 500 ms, beep.
Unit tests on synthetic signals (noise, missing pulses, stray sound, rolling
shutter with known/unknown readout, slow screen, sensor mismatch).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017ybuzgBaGxuB5UiaE8dRa4
PhoneAnalyzer::begin_calibrator keeps 32 row-band means per frame and the
microphone, and poll_calibrator reports progress about once a second, then
the result (20 pairs or 30 s) or an error. No live report or QR scan while
it runs. iOS: beginCalibrator(mode) on the camera view; the camera take
first re-locks exposure (frame interval) at the lowest ISO, because the LED
floods the lens.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017ybuzgBaGxuB5UiaE8dRa4
…the QR

generateStyled(..., {mode: 'calibration'}) shows the calibration pattern in
the generator's own screen: the same display link, layers, audio engine and
player node as the test signal. Portrait halves left and right near the
bottom microphone, a red outline of the board, halves swap every 500 ms, a
1.5 kHz beep 100 ms after each swap (scheduled on the host clock minus the
reported output latency). The session is play-and-record on the built-in
speaker; the microphone take is analysed with the shared core
(display_calibration) and the result comes back when the screen closes.

The test signal sends a saved sender calibration in the AVS4 QR
(senderCalibration* options), only on the audio output it was measured on.
Core pattern helpers are now frame based (also 59.94 fps).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017ybuzgBaGxuB5UiaE8dRa4
Same feature as the phone: the generator can send the calibration pattern
instead of the test signal, so a phone with the calibrator board can
measure a TV/monitor chain. Left/right halves swap every 500 ms on the
grain grid (also interlaced and 59.94), a 1.5 kHz beep 100 ms after each
swap at the chirp level, a grey tick at the ends of the pattern line.
Live key, also in the Signal API (pattern) and the web form (Picture).

tests/tools/calibration_flow + tests/integration/calibration_pattern.py
read both flows, check the timing and run a simulated calibrator through
display_calibration (1080p50, 1080i25, 1080p59.94: 0.03 ms).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017ybuzgBaGxuB5UiaE8dRa4
Three steps with small animations: plug in, camera (the calibrator on the
rear camera; progress ring of 20 flashes; 'Camera vs mic' result saved as
this phone's analyser calibration, method calibrator-pulse, same record and
validity as the clap), screen (the generator's calibration pattern;
'Screen vs speaker' saved as this phone's sender calibration, method
transponder). The test signal then sends it in the AVS4 QR. A TV/monitor
showing the container's calibration pattern can be measured too (result
only, not saved). The clap calibration is unchanged. iOS only for now
(Android has no generator and no calibrator take yet).

Pure logic in src/calibrator.mjs with node tests.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017ybuzgBaGxuB5UiaE8dRa4
docs/calibrator.md (copy of PR #69's contract) gets an 'App side' section
and the list of changes from the v1 proposal (none needs a firmware change).
calibration.md: user guide 'With the calibrator'. phone-generator.md:
calibration mode and the sender calibration in the QR. SPECIFICATION:
AVSYNC_PATTERN and the calibrator in 12.3. IMPLEMENTATION_PLAN: entry with
the deviations. CHANGELOG, mobile-app README.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017ybuzgBaGxuB5UiaE8dRa4
… clap gap fill unchanged

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017ybuzgBaGxuB5UiaE8dRa4
Done once in core (Pairer::set_sender_calibration) for the phone session,
the container analyzer and the file analyzer. Events keep raw_av_offset_ms
and sender_calibration_ms; the phone reading has rawOffsetMs and
senderCalibrationMs; /api/v1/measurement has raw_offset_ms and
sender_calibration_ms; the web UI Input card shows AV sync raw. Not
calibrated: no change. The phone fixture sends a sender calibration at 2 Hz.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017ybuzgBaGxuB5UiaE8dRa4
…ickname

Measure and Results show the raw value and the sender calibration under a
corrected result; saved results keep them. The analyser calibration stays
subtracted in the app only, so nothing counts twice. Clear calibration asks
first and clears the analyser and the sender calibration. A sender's default
name is the newest Title from its QR; a set nickname wins.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017ybuzgBaGxuB5UiaE8dRa4
… nickname

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017ybuzgBaGxuB5UiaE8dRa4
PhoneGeneratorPlan, the AVS4 QR, render_picture/render_sync_foreground and the
calibration pattern functions compile into libavsync-jni as on iOS
(generator_jni.cpp mirrors AVSyncGenerator.mm). The renderer's text goes through
JNI to Android's text drawing with the bundled DejaVu Sans (GeneratorText.kt),
as iOS uses CoreText; the font and icon come from the iOS resource folder.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017ybuzgBaGxuB5UiaE8dRa4
Same behaviour and options as the iPhone generator: test picture with the AVS3
fields, sync square and scale, AVS4 QR with test ID, title and sender calibration,
stable sender ID, 'Stopped' screen, calibration pattern with the microphone.
Frames: Choreographer's expected presentation time (API 33+, else frame start
plus two periods). Sound: one AudioTrack stream written in 5 ms blocks; packets
are placed to the sample by AudioTrack.getTimestamp on System.nanoTime, the
clock Choreographer uses. Module: supportsGenerator, generate, generateStyled,
pickGeneratorImage, generatorImageInfo, like iOS.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017ybuzgBaGxuB5UiaE8dRa4
beginCalibrator('camera' | 'display') as on iOS: the camera take re-runs the
auto step and locks at the lowest ISO (or the lowest exposure compensation when
the camera has no manual exposure), then PhoneAnalyzer::begin_calibrator starts;
display mode listens only. Progress and result reports come from
poll_calibrator.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017ybuzgBaGxuB5UiaE8dRa4
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017ybuzgBaGxuB5UiaE8dRa4
…on, clear both)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017ybuzgBaGxuB5UiaE8dRa4
Same file on both branches now, so the merge has no conflict here.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017ybuzgBaGxuB5UiaE8dRa4
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017ybuzgBaGxuB5UiaE8dRa4
The test signal stopped itself about 5 minutes after the last clock check.
Now the app hands each new check to the running signal
(updateGeneratorClock, iOS and Android). Core PhoneGeneratorPlan::retime
slews the TAI stamps of events not queued yet toward the new mapping, at
most 100 ppm per event; host times never move, so frames stay on the
display grid and sound and picture of one event keep one stamp. A check
further off than the limit stops the signal with a clear message. The
expiry rule moved to core (clock_reference_expired); the app's check
cadence moved to clockCheckDue and now checks a minute before the
holdover passes the limit.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017ybuzgBaGxuB5UiaE8dRa4
Pressing Start before the first NTP check finished kept the signal on
device UTC for the whole run. Now the first accepted check switches it
to NTP UTC with the same 100 ppm timestamp slew (core retime from the
device-UTC mapping). A check further off than the limit leaves it on
device UTC without stopping and says so in the status line; a later
check within the limit can still switch it. While slewing, the status
shows the correction still to go.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017ybuzgBaGxuB5UiaE8dRa4
@LeeO86
LeeO86 changed the base branch from feat/android-generator to main November 9, 2026 10:23
@LeeO86
LeeO86 marked this pull request as ready for review November 9, 2026 10:23
@LeeO86
LeeO86 merged commit 330e218 into main Nov 9, 2026
11 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant