This document captures everything needed to port the adaptive PLL sync algorithm
from fpp-listener-sync (v2.4.0) into fpp-eavesdrop. Written to be consumed
by a fresh Claude session working in the eavesdrop repo.
c:\Users\TNash\Documents\GitHub\fpp-listener-sync- Branch:
READY_SET_GO_Timing(sync algorithm) /Rework_Captive_Redirect(v2.4.0 latest) - Key file:
www/listen/index.html— all sync JS is inline (~1180 lines, heavily commented)
c:\Users\TNash\Documents\GitHub\fpp-eavesdrop- Key file:
www/listen/listen.html— all sync JS is inline (~580 lines) - Keep: admin UI (start/stop sequences, WiFi SSID/password), version display
- Replace: entire sync algorithm JS + add debug UI
- Port the full adaptive PLL sync algorithm including debug UI
- Port the WebSocket server (
ws-sync-server.py) and Apache proxy config - Keep HTTP polling as fallback (eavesdrop already has this)
- Keep eavesdrop's admin features (sequence start/stop, WiFi settings)
- Owner-only for now, but design for future branch serving both owner + public listeners
- Deployment target: master Pi (FPP on localhost)
const POLL_MS = 250; // → will use WS 100ms + HTTP 250ms fallback
const CAL_COUNT = 6; // → will use CAL_MIN_MS=800 (time-based, not count-based)
const Kp = 0.3; // → will use adaptive Kp: BASE=0.01, SCALE=4
const MAX_ADJ = 0.05; // → same
const HARD_SEEK_S = 2.0; // → same
const RATE_EMA = 0.05; // → same
const SEEK_COOLDOWN_MS = 2000; // → same- No settle period — corrections start immediately, noisy
- No avg2s — uses instantaneous phaseErr (noisy, causes oscillation)
- No adaptive Kp — fixed Kp=0.3 (too aggressive when error is small)
- No log-compressed correction — linear
Kp * err(overshoots) - No dead zone — corrects even sub-ms jitter
- No MIN_RATE_CHANGE threshold — updates rate every tick (micro-stutters)
- No rate update interval — adjusts every 250ms tick instead of 500-1000ms
- No WebSocket transport — HTTP polling only
- No debug UI — no checkboxes, no debug box, no client log
- No server-side logging — no sync.log
- No play-ahead latency measurement
- baseRate clamp ±5% — should be ±1%
// Transport
const WS_RECONNECT_MS = 2000;
const WS_PING_INTERVAL_MS = 1000;
const HTTP_POLL_MS = 250;
// Clock offset
const RTT_HISTORY_SIZE = 5;
const OFFSET_HISTORY_SIZE = 8;
const MIN_OFFSET_SAMPLES = 3;
// PLL
const CAL_MIN_MS = 800; // minimum calibration window
const Kp_BASE = 0.01; // base proportional gain
const Kp_SCALE = 4; // adaptive scaling (Kp grows with error)
const MAX_ADJ = 0.05; // ±5% rate clamp
const DEAD_ZONE_MS = 5; // no correction below 5ms
const LOG_SCALE_MS = 100; // log compression denominator
const HARD_SEEK_S = 2.0; // hard seek threshold
const RATE_EMA = 0.05; // baseRate learning alpha
const MIN_RATE_CHANGE = 0.003; // only update rate if Δ > 0.3%
const SEEK_COOLDOWN_MS = 2000; // between hard seeks
// Settle
const SETTLE_MS = 1500; // 1.5s settle after play()
// Reporting
const REPORT_INTERVAL_MS = 1000;
const LOG_MAX = 200;IDLE: Waiting. Entry: track change, stop, pause, hard seek.
CALIBRATING: Collect (localMs, fppMs) pairs for ≥800ms with ≥6 points.
- Compute least-squares linear regression slope → baseRate
- Clamp baseRate to [0.99, 1.01] (reject garbage)
- NO snap-seek at lock (MP3 keyframe error causes overshoot)
LOCKED: Continuous adaptive correction.
- Error input:
avg2s(2-second rolling average), NOT instantaneous - Adaptive Kp:
Kp = Kp_BASE * (1 + Kp_SCALE * min(|avg2s|/200, 1)) - Log-compressed:
rate = baseRate + sign(avg2s) * Kp * log1p((|avg2s| - DEAD_ZONE) / LOG_SCALE) - Rate interval: 500ms (error>50ms) or 1000ms (error<50ms)
- MIN_RATE_CHANGE threshold: only update if Δ > 0.003
- Rate learning: EMA α=0.05 every 2+ seconds
- Hard seek if error > 2s (with cooldown), stay locked
During settle:
- No PLL corrections
- Measure play() startup latency (EWMA α=0.5, clamp [0,500ms], persist to localStorage)
- Collect calibration samples
offset = serverTs - clientSendTs - (RTT / 2)
- Sliding window of 8 samples
- Median filter → EWMA (70% old, 30% new)
- Require 3+ samples before trusting
- WS: burst 5 pings at 200ms on connect, then 1/sec
- HTTP: use request/response timing
const targetMs = pos_ms + elapsed; // where FPP should be NOW
const localSec = audio.currentTime;
const errMs = (targetMs/1000 - localSec) * 1000;
// positive = phone behind, negative = phone ahead
// 2-second rolling average (THE PLL INPUT):
errHistory2s.push({t: nowMs, err: errMs});
while (errHistory2s.length > 0 && (nowMs - errHistory2s[0].t) > 2000) errHistory2s.shift();
const avg2s = errHistory2s.reduce((a,b) => a + b.err, 0) / errHistory2s.length;const absAvg2s = Math.abs(lastAvg2s);
const Kp = Kp_BASE * (1 + Kp_SCALE * Math.min(absAvg2s / 200, 1));
// Kp ranges: 0.01 (calm) → 0.05 (aggressive)
const rateInterval = absAvg2s > 50 ? 500 : 1000;
if ((nowMs - pll.lastRateUpdateMs) >= rateInterval) {
pll.lastRateUpdateMs = nowMs;
let rate = pll.baseRate;
if (absAvg2s > DEAD_ZONE_MS) {
rate = pll.baseRate + Math.sign(lastAvg2s) * Kp * Math.log1p((absAvg2s - DEAD_ZONE_MS) / LOG_SCALE_MS);
}
rate = clamp(rate, 1 - MAX_ADJ, 1 + MAX_ADJ);
if (Math.abs(rate - audio.playbackRate) > MIN_RATE_CHANGE) {
audio.playbackRate = rate;
}
}Python3 asyncio + websockets library (apt version 10.4 on Pi).
- Polls
http://127.0.0.1/api/fppd/statusevery 100ms - Broadcasts state to all clients:
{state, base, pos_ms, mp3_url, server_ms} - Handles ping/pong for clock offset
- Handles report messages → writes to
/home/fpp/listen-sync/sync.log - Log auto-clears on TRACK event, rotates at 5MB
- Port 8080, Apache proxies
/wsto it via mod_proxy_wstunnel - websockets 10.4 (apt): handler signature is
(websocket, path), nomax_queueparam
<IfModule mod_proxy.c>
ProxyPass /ws ws://127.0.0.1:8080/
ProxyPassReverse /ws ws://127.0.0.1:8080/
</IfModule>[Unit]
Description=FPP Listener Sync WebSocket Server
After=network.target
[Service]
Type=simple
User=fpp
ExecStart=/usr/bin/python3 /home/fpp/fpp-eavesdrop/ws-sync-server.py
Restart=always
RestartSec=3
[Install]
WantedBy=multi-user.target- Debug — shows debug box with: Transport, RTT, Clock Offset, Sequence, State, Target, Local, Error, PLL State, Rate, Avg Error (2s), Avg Error (all), Effective Rate, Buffered, Last Correction, Play Latency
- Client Log — on-screen scrollable monospace log (200 lines max), with Copy/Clear/Resume buttons. Auto-freezes on song end so user can review.
- Server Log — sends reports to server via WS →
/home/fpp/listen-sync/sync.log
- "No show is currently broadcasting" (idle)
- "Now Playing: trackname" (playing)
- Enable Audio button (required for mobile autoplay policy)
YYYY-MM-DD HH:MM:SS.mmm [client_ip] EVENT fpp=X target=Y local=Z err=Nms avg2s=Nms rate=R eff=E offset=Oms
Events: TRACK, INITIAL_SEEK, START, SYNC (throttled 1/sec), CORRECTION, STOP (once per transition)
| Aspect | Listener-Sync | Eavesdrop | Port Decision |
|---|---|---|---|
| WiFi | Open (no password) | WPA2 protected | Keep eavesdrop's WPA2 |
| Admin UI | None | Start/stop sequences, WiFi config | Keep eavesdrop's admin |
| Network isolation | nftables, captive portal, .htaccess | Basic dnsmasq | Keep eavesdrop's simple approach |
| Deployment | Remote Pi | Master Pi | Keep eavesdrop's master approach |
| Audio source | /music/ symlink |
/music/ symlink |
Same |
| status.php | Has it | Has it (nearly identical) | Update to match listener-sync's |
| WebSocket | ws-sync-server.py + systemd | None | Add from listener-sync |
| Sync algorithm | Adaptive PLL (proven) | Basic PLL (oscillates) | Replace with listener-sync's |
| Debug UI | Full (3 checkboxes) | None | Add from listener-sync |
| Enable button | "Enable Audio" | Auto-unlock on tap | Use listener-sync's explicit button |
- Use avg2s as PLL error, not instantaneous — prevents oscillation. Instantaneous is ±80ms noisy.
- No snap-seek at PLL lock — MP3 keyframe granularity (~500ms) causes 250-300ms overshoot. Let PLL converge.
- baseRate clamp ±1% — wide clamp (±5%) allows garbage slopes from noisy settle data
- MIN_RATE_CHANGE threshold (0.003) — prevents micro-stutters from constant rate API calls
- Settle period (1.5s) — audio element needs time to stabilize after seek + play
- Log compression (log1p) — strong initial correction that tapers, prevents overshoot
- Adaptive Kp — aggressive when far (Kp=0.05), gentle when close (Kp=0.01)
- FPP uses
milliseconds_elapsednotseconds_played— seconds_played is whole-second only - PHP
round()notintval()— intval overflows at 2^31 on Pi 3B 32-bit PHP - Gate all logging behind checkboxes — JS execution overhead from debug UI updates affects sync
- STOP flooding — server broadcasts stop every 100ms. Guard with
wasPlayingtransition flag. - Burst pings on WS connect — 5 pings at 200ms intervals for fast initial clock calibration
- websockets 10.4 (apt) — handler needs
(websocket, path)signature, nomax_queueparam - Reentrance guard on sync() — WS and HTTP can deliver concurrently, only process latest message
www/listen/listen.html— Replace sync JS, add debug UI HTML, keep admin UI (start/stop, WiFi settings)www/listen/status.php— Ensure format matches (already close, checkmilliseconds_elapsedandround())install.sh— Add ws-sync-server.py deployment, systemd service, Apache proxy config,a2enmod proxy proxy_wstunnel
ws-sync-server.py— Copy from listener-sync, adjust paths (/home/fpp/fpp-eavesdrop/vs/home/fpp/fpp-listener-sync/)server/ws-sync-server.service— systemd unit for the WS serverconfig/apache-listener.conf— Apache proxy config for/ws
www/listen/admin.php— Sequence control + WiFi management (works fine)www/listen/version.php— Version display (works fine)www/music.php— Audio streaming with range requests (works fine)server/listener-ap.sh— WiFi AP setup (works fine)server/listener-ap.service— WiFi AP systemd service (works fine)config/listener.conf— Environment overrides (works fine)
uninstall.sh— Also remove WS server service + config
To read the complete implementation, open these files in the listener-sync repo:
- Sync algorithm + debug UI:
c:\Users\TNash\Documents\GitHub\fpp-listener-sync\www\listen\index.html(1188 lines) - WebSocket server:
c:\Users\TNash\Documents\GitHub\fpp-listener-sync\ws-sync-server.py - HTTP fallback:
c:\Users\TNash\Documents\GitHub\fpp-listener-sync\www\listen\status.php - Apache config:
c:\Users\TNash\Documents\GitHub\fpp-listener-sync\config\apache-listener.conf - Install script:
c:\Users\TNash\Documents\GitHub\fpp-listener-sync\install.sh
- 12-14 seconds to converge from ~500ms initial MP3 seek error
- 5-25ms steady-state avg2s error (imperceptible)
- No overshoot, no oscillation, no drift over long songs
- Tested on Samsung S24 Ultra, S25 Ultra, S21 (Android 15/16) and Apple devices