diff --git a/firmware/ltc_audiohat_lock_v3.ino b/firmware/ltc_audiohat_lock_v3.ino new file mode 100644 index 0000000..008c859 --- /dev/null +++ b/firmware/ltc_audiohat_lock_v3.ino @@ -0,0 +1,264 @@ +/* Linear Timecode for Audio Library for Teensy 3.x / 4.x + Copyright (c) 2019, Frank Bösing, f.boesing (at) gmx.de + + Development of this audio library was funded by PJRC.COM, LLC by sales of + Teensy and Audio Adaptor boards. Please support PJRC's efforts to develop + open source software by purchasing Teensy or other PJRC products. + + Permission is hereby granted, free of charge, to any person obtaining a copy + of this software and associated documentation files (the "Software"), to deal + in the Software without restriction, including without limitation the rights + to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + copies of the Software, and to permit persons to whom the Software is + furnished to do so, subject to the following conditions: + + The above copyright notice, development funding notice, and this permission + notice shall be included in all copies or substantial portions of the Software. + + THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN + THE SOFTWARE. +*/ + +/* + + https://forum.pjrc.com/threads/41584-Audio-Library-for-Linear-Timecode-(LTC) + + LTC example audio at: https://www.youtube.com/watch?v=uzje8fDyrgg + + Forked by Chris Frankland-Wright 2025 for Teensy Audio Shield Input with autodetect FPS for the Fetch | Haichi + +*/ + +#include +#include +#include "analyze_ltc.h" + +// —— Configuration —— +const float FORCE_FPS = 0.0f; // 0 → auto‑detect +const int FRAME_OFFSET = 4; // compensation in frames +const unsigned long LOSS_TIMEOUT = 1000UL; // ms before we go into LOST +// BLINK_PERIOD is now the half-period (on or off time) +const unsigned long BLINK_PERIOD[3] = {100, 500, 100}; // ACTIVE, LOST, NO_LTC (base) + +AudioInputI2S i2s1; +AudioAnalyzeLTC ltc1; +AudioControlSGTL5000 sgtl5000; +AudioConnection patchCord(i2s1, 0, ltc1, 0); + +enum State { NO_LTC=0, LTC_ACTIVE, LTC_LOST }; +State ltcState = NO_LTC; +bool ledOn = false; +unsigned long lastDecode = 0; +unsigned long lastBlink = 0; +// Variables for NO_LTC double-blink pattern +int noLtcBlinkCount = 0; +unsigned long noLtcPauseTime = 600; + +// FPS detection +float currentFps = 25.0f; +float periodMs = 0; +const float SMOOTH_ALPHA = 0.05f; // Much slower smoothing to reduce noise +unsigned long lastDetectTs = 0; +// Simple consecutive counter approach +float candidateFps = 25.0f; +int consecutiveCount = 0; +const int REQUIRED_CONSECUTIVE = 5; // Need 5 consecutive same readings + +// free‑run +long freeAbsFrame = 0; +unsigned long lastFreeRun = 0; + +void setup() { + Serial.begin(115200); + AudioMemory(12); + sgtl5000.enable(); + sgtl5000.inputSelect(AUDIO_INPUT_LINEIN); + pinMode(LED_BUILTIN, OUTPUT); +} + +void loop() { + unsigned long now = millis(); + // compute framePeriod from currentFps + unsigned long framePeriod = (unsigned long)(1000.0f / currentFps + 0.5f); + + // 1) If in ACTIVE and we've gone > LOSS_TIMEOUT w/o decode, enter LOST + if (ltcState == LTC_ACTIVE && (now - lastDecode) >= LOSS_TIMEOUT) { + ltcState = LTC_LOST; + // bump freeAbsFrame by 1 second worth of frames: + int nominal = (currentFps>29.5f) ? 30 : int(currentFps+0.5f); + long dayFrames= 24L*3600L*nominal; + freeAbsFrame = (freeAbsFrame + nominal) % dayFrames; + // reset free‑run timer so we start next tick fresh + lastFreeRun = now; + } + + // 2) Handle incoming LTC frame + if (ltc1.available()) { + ltcframe_t frame = ltc1.read(); + int h = ltc1.hour(&frame), + m = ltc1.minute(&frame), + s = ltc1.second(&frame), + f = ltc1.frame(&frame); + bool isDF = ltc1.bit10(&frame); + + // — FPS detect or force — + if (FORCE_FPS > 0.0f) { + currentFps = FORCE_FPS; + } else { + if (isDF) { + // Drop-frame flag is only used for 29.97fps. + if (currentFps != 29.97f) { + currentFps = 29.97f; + consecutiveCount = 0; + } + } else { + if (lastDetectTs) { + float dt = now - lastDetectTs; + // Use an IIR filter to smooth the measured period + periodMs = (periodMs == 0) ? dt : (SMOOTH_ALPHA * dt + (1.0f - SMOOTH_ALPHA) * periodMs); + float measFps = 1000.0f / periodMs; + + // More comprehensive list of standard frame rates + const float choices[] = {23.98f, 24.0f, 25.0f, 29.97f, 30.0f}; + float bestFit = 25.0f; + float minDiff = 1e6; + + for (auto rate : choices) { + float diff = fabsf(measFps - rate); + if (diff < minDiff) { + minDiff = diff; + bestFit = rate; + } + } + + // Use wider thresholds and hysteresis based on current frame rate + float newFps = bestFit; + + // For 23.98/24fps discrimination with hysteresis + if (fabsf(bestFit - 24.0f) < 0.1f || fabsf(bestFit - 23.98f) < 0.1f) { + if (currentFps == 24.0f) { + // Currently 24fps - need much stronger evidence of 23.98 to switch + newFps = (measFps < 23.96f) ? 23.98f : 24.0f; + } else if (currentFps == 23.98f) { + // Currently 23.98fps - need evidence of 24 to switch back + newFps = (measFps > 23.99f) ? 24.0f : 23.98f; + } else { + // First detection - strongly favor 24fps unless very clearly 23.98 + newFps = (measFps < 23.965f) ? 23.98f : 24.0f; + } + // Debug output to see what's being measured + //Serial.printf("[DEBUG] measFps=%.3f, newFps=%.2f, currentFps=%.2f\r\n", + // measFps, newFps, currentFps); + } + // For 29.97/30fps discrimination with hysteresis + else if (fabsf(bestFit - 30.0f) < 0.1f || fabsf(bestFit - 29.97f) < 0.1f) { + if (currentFps == 30.0f) { + // Currently 30fps - need strong evidence of 29.97 to switch + newFps = (measFps < 29.98f) ? 29.97f : 30.0f; + } else if (currentFps == 29.97f) { + // Currently 29.97fps - need evidence of 30 to switch back + newFps = (measFps > 29.99f) ? 30.0f : 29.97f; + } else { + // First detection - strongly favor 30fps unless very clearly 29.97 + newFps = (measFps < 29.98f) ? 29.97f : 30.0f; + } + } + + // Require consecutive readings before changing frame rate + if (newFps == candidateFps) { + consecutiveCount++; + if (consecutiveCount >= REQUIRED_CONSECUTIVE && newFps != currentFps) { + currentFps = newFps; + consecutiveCount = 0; + } + } else { + candidateFps = newFps; + consecutiveCount = 1; + } + } + } + lastDetectTs = now; + } + + // — pack + offset + wrap — + int nominal = (currentFps>29.5f) ? 30 : int(currentFps+0.5f); + long dayFrames = 24L*3600L*nominal; + long absF = ((long)h*3600 + m*60 + s)*nominal + f + FRAME_OFFSET; + absF = (absF % dayFrames + dayFrames) % dayFrames; + + // — reset anchors & state — + freeAbsFrame = absF; + lastFreeRun = now; + lastDecode = now; + ltcState = LTC_ACTIVE; + + // — print LOCK — + long totSec = absF/nominal; + int outF = absF % nominal; + int outS = totSec % 60; + long totMin = totSec/60; + int outM = totMin % 60; + int outH = (totMin/60) % 24; + char sep = isDF?';':':'; + Serial.printf("[LOCK] %02d:%02d:%02d%c%02d | %.2ffps\r\n", + outH,outM,outS,sep,outF,currentFps); + + // — LED → ACTIVE immediately — + lastBlink = now; + ledOn = true; + digitalWrite(LED_BUILTIN, HIGH); + noLtcBlinkCount = 0; // Reset pattern when LTC becomes active + } + // 3) If in LOST, do free‑run printing + else if (ltcState == LTC_LOST) { + if ((now - lastFreeRun) >= framePeriod) { + freeAbsFrame = (freeAbsFrame + 1) % (24L*3600L*((int)(currentFps+0.5f))); + lastFreeRun += framePeriod; + + // — print FREE — + int nominal = (currentFps>29.5f) ? 30 : int(currentFps+0.5f); + long totSec = freeAbsFrame/nominal; + int outF = freeAbsFrame % nominal; + int outS = totSec % 60; + long totMin = totSec/60; + int outM = totMin % 60; + int outH = (totMin/60)%24; + Serial.printf("[FREE] %02d:%02d:%02d:%02d | %.2ffps\r\n", + outH,outM,outS,outF,currentFps); + } + noLtcBlinkCount = 0; // Reset pattern when LTC is lost + } + + // 4) LED heartbeat + unsigned long now_led = millis(); + if (ltcState == NO_LTC) { + unsigned long blinkInterval = BLINK_PERIOD[NO_LTC]; + if (noLtcBlinkCount < 4) { // First two blinks (on-off-on-off) + if (now_led - lastBlink >= blinkInterval) { + ledOn = !ledOn; + digitalWrite(LED_BUILTIN, ledOn); + lastBlink = now_led; + noLtcBlinkCount++; + } + } else { // Pause + if (now_led - lastBlink >= noLtcPauseTime) { + noLtcBlinkCount = 0; // Reset for next double-blink + lastBlink = now_led; + } + } + } else { // LTC_ACTIVE or LTC_LOST + // LTC_ACTIVE: 5Hz flash (100ms on/off). Period = 200ms. + // LTC_LOST: 1Hz flash (500ms on/off). Period = 1000ms. + unsigned long blinkInterval = (ltcState == LTC_ACTIVE) ? 100 : BLINK_PERIOD[LTC_LOST]; + if (now_led - lastBlink >= blinkInterval) { + ledOn = !ledOn; + digitalWrite(LED_BUILTIN, ledOn); + lastBlink = now_led; + } + } +} \ No newline at end of file diff --git a/src/api.rs b/src/api.rs index 14b0da4..f274fa7 100644 --- a/src/api.rs +++ b/src/api.rs @@ -9,7 +9,7 @@ use std::collections::VecDeque; use std::sync::{Arc, Mutex}; use crate::config::{self, Config}; -use crate::sync_logic::{self, LtcState}; +use crate::sync_logic::{self, LtcState, effective_display_rate}; use crate::system; use num_rational::Ratio; use num_traits::ToPrimitive; @@ -30,6 +30,8 @@ struct ApiStatus { ntp_active: bool, interfaces: Vec, hardware_offset_ms: i64, + fractional_ndf_discipline_active: bool, + applied_ppm: i64, } // AppState to hold shared data @@ -71,13 +73,16 @@ async fn get_status(data: web::Data) -> impl Responder { let mut delta_frames = 0; if let Some(frame) = &state.latest { let delta_ms_ratio = Ratio::new(avg_delta, 1); - let frames_ratio = delta_ms_ratio * frame.frame_rate / Ratio::new(1000, 1); + let eff_rate = effective_display_rate(frame); + let frames_ratio = delta_ms_ratio * eff_rate / Ratio::new(1000, 1); delta_frames = frames_ratio.round().to_integer(); } let sync_status = sync_logic::get_sync_status(avg_delta, &config); let jitter_status = sync_logic::get_jitter_status(state.average_jitter()); let lock_ratio = state.lock_ratio(); + let applied_ppm = state.applied_ppm; + let fractional_ndf_discipline_active = applied_ppm != 0; let ntp_active = system::ntp_service_active(); let interfaces = get_if_addrs() @@ -101,6 +106,8 @@ async fn get_status(data: web::Data) -> impl Responder { ntp_active, interfaces, hardware_offset_ms: hw_offset_ms, + fractional_ndf_discipline_active, + applied_ppm, }) } @@ -256,6 +263,7 @@ mod tests { ewma_clock_delta: Some(5.0), last_match_status: "IN SYNC".to_string(), last_match_check: Utc::now().timestamp(), + applied_ppm: 0, } } diff --git a/src/config.rs b/src/config.rs index 8669e62..1e4340f 100644 --- a/src/config.rs +++ b/src/config.rs @@ -43,6 +43,16 @@ pub struct Config { pub default_nudge_ms: i64, #[serde(default)] pub auto_sync_enabled: bool, + /// When enabled, automatically apply a frequency correction (PPM) when + /// a fractional non-drop LTC source (e.g., 23.976 or 29.97 NDF) is + /// detected continuously for a threshold period, and revert when an + /// integer/DF source is detected. + #[serde(default)] + pub auto_fractional_ppm_enabled: bool, + /// Target PPM to apply for fractional non-drop LTC sources. A value of + /// approximately +1000 ppm matches the 1001/1000 time scale. + #[serde(default = "default_fractional_ppm_target")] + pub fractional_ppm_target: i64, } fn default_nudge_ms() -> i64 { @@ -74,6 +84,8 @@ impl Default for Config { timeturner_offset: TimeturnerOffset::default(), default_nudge_ms: default_nudge_ms(), auto_sync_enabled: false, + auto_fractional_ppm_enabled: false, + fractional_ppm_target: default_fractional_ppm_target(), } } } @@ -91,6 +103,13 @@ pub fn save_config(path: &str, config: &Config) -> Result<(), Box Result<(), Box i64 { 1000 } + pub fn watch_config(path: &str) -> Arc> { let initial_config = Config::load(&PathBuf::from(path)); let config = Arc::new(Mutex::new(initial_config)); diff --git a/src/main.rs b/src/main.rs index 8006681..2a46d35 100644 --- a/src/main.rs +++ b/src/main.rs @@ -53,6 +53,13 @@ autoSyncEnabled: false # Default nudge in milliseconds for adjtimex control. defaultNudgeMs: 2 +# Automatically apply frequency correction (PPM) when a fractional non-drop +# LTC source (e.g., 23.976 or 29.97 NDF) is detected for a sustained period. +autoFractionalPpmEnabled: false + +# Target PPM for fractional non-drop LTC. ~1000 ppm matches 1001/1000. +fractionalPpmTarget: 1000 + # Time-turning offsets. All values are added to the incoming LTC time. # These can be positive or negative. timeturnerOffset: @@ -219,6 +226,15 @@ async fn main() { let sync_state = ltc_state.clone(); let sync_config = config.clone(); thread::spawn(move || { + use std::time::{Duration, Instant}; + + // Track mode and applied frequency correction + #[derive(Clone, Copy, PartialEq, Eq, Debug)] + enum RateMode { FractionalNdf, Other } + let mut current_mode: Option = None; + let mut mode_since: Option = None; + let mut applied_ppm: i64 = 0; // 0 means none + // Wait for the first LTC frame to arrive loop { if sync_state.lock().unwrap().latest.is_some() { @@ -248,10 +264,49 @@ async fn main() { // Main auto-sync loop loop { + // Decide any frequency action inside a short lock scope + enum FreqAction { Apply(i64), Revert, None } + let mut freq_action = FreqAction::None; { let state = sync_state.lock().unwrap(); let config = sync_config.lock().unwrap(); + // Determine current rate mode + let mode_now = if let Some(frame) = &state.latest { + let denom = *frame.frame_rate.denom(); + let is_fractional = denom == 1001; + if is_fractional && !frame.is_drop_frame { + RateMode::FractionalNdf + } else { + RateMode::Other + } + } else { + RateMode::Other + }; + + // Update mode timing + let now = Instant::now(); + if current_mode != Some(mode_now) { + current_mode = Some(mode_now); + mode_since = Some(now); + } + + // After 15s in a stable mode, consider applying/reverting frequency + if let (Some(m), Some(since)) = (current_mode, mode_since) { + if now.duration_since(since) >= Duration::from_secs(15) { + if m == RateMode::FractionalNdf { + if config.auto_fractional_ppm_enabled { + let target = config.fractional_ppm_target; + if applied_ppm != target { + freq_action = FreqAction::Apply(target); + } + } + } else if applied_ppm != 0 { + freq_action = FreqAction::Revert; + } + } + } + if config.auto_sync_enabled && state.latest.is_some() { let delta = state.get_ewma_clock_delta(); let frame = state.latest.as_ref().unwrap(); @@ -277,6 +332,23 @@ async fn main() { } } // locks released here + // Perform any pending frequency action and update shared state + match freq_action { + FreqAction::Apply(ppm) => { + let _ = system::set_clock_frequency_ppm(ppm); + applied_ppm = ppm; + let mut st = sync_state.lock().unwrap(); + st.applied_ppm = ppm; + } + FreqAction::Revert => { + let _ = system::set_clock_frequency_ppm(0); + applied_ppm = 0; + let mut st = sync_state.lock().unwrap(); + st.applied_ppm = 0; + } + FreqAction::None => {} + } + thread::sleep(std::time::Duration::from_secs(10)); } }); diff --git a/src/sync_logic.rs b/src/sync_logic.rs index c6a3e80..406f85e 100644 --- a/src/sync_logic.rs +++ b/src/sync_logic.rs @@ -52,6 +52,33 @@ impl LtcFrame { } } +/// Effective frame rate to use when mapping LTC to displayed wall time. +/// +/// Rules: +/// - Integer fps (24/25/30): use the exact integer rate. +/// - Fractional drop-frame (e.g., 29.97 DF): use the true fractional rate (30000/1001). +/// - Fractional non-drop (e.g., 23.976 NDF, 29.97 NDF): use nominal integer fps (24 or 30) +/// so the system wall clock matches the timecode labels exactly. +pub fn effective_display_rate(frame: &LtcFrame) -> Ratio { + let numer = *frame.frame_rate.numer(); + let denom = *frame.frame_rate.denom(); + let is_fractional = denom == 1001; + if is_fractional && !frame.is_drop_frame { + // NDF → nominal integers + match numer { + 30000 => Ratio::new(30, 1), + 24000 => Ratio::new(24, 1), + _ => { + // Fallback to nearest integer + let approx = (numer as f64) / (denom as f64); + Ratio::from_integer(approx.round() as i64) + } + } + } else { + frame.frame_rate.clone() + } +} + pub struct LtcState { pub latest: Option, pub lock_count: u32, @@ -62,6 +89,8 @@ pub struct LtcState { pub ewma_clock_delta: Option, pub last_match_status: String, pub last_match_check: i64, + /// Current applied frequency correction in PPM (Linux only). 0 means none. + pub applied_ppm: i64, } impl LtcState { @@ -74,6 +103,7 @@ impl LtcState { ewma_clock_delta: None, last_match_status: "UNKNOWN".into(), last_match_check: 0, + applied_ppm: 0, } } @@ -144,7 +174,8 @@ impl LtcState { pub fn average_frames(&self) -> i64 { if let Some(frame) = &self.latest { let jitter_ms_ratio = Ratio::new(self.average_jitter(), 1); - let frames_ratio = jitter_ms_ratio * frame.frame_rate / Ratio::new(1000, 1); + let eff_rate = effective_display_rate(frame); + let frames_ratio = jitter_ms_ratio * eff_rate / Ratio::new(1000, 1); frames_ratio.round().to_integer() } else { 0 diff --git a/src/system.rs b/src/system.rs index 8db481d..e7e7a2c 100644 --- a/src/system.rs +++ b/src/system.rs @@ -41,27 +41,54 @@ pub fn ntp_service_toggle(start: bool) { pub fn calculate_target_time(frame: &LtcFrame, config: &Config) -> DateTime { let today_local = Local::now().date_naive(); - // Total seconds from timecode components - let timecode_secs = - frame.hours as i64 * 3600 + frame.minutes as i64 * 60 + frame.seconds as i64; + // Compute milliseconds since local midnight from the LTC timecode. + // We distinguish between integer rates, fractional drop-frame, and fractional non-drop. + // Rules chosen to make the system wall clock match the LTC labels exactly: + // - Integer fps (24/25/30): treat HH:MM:SS as wall-clock seconds and frames as 1/fps fractions. + // - Fractional fps + drop-frame (e.g., 29.97 DF): HH:MM:SS corresponds to wall-clock seconds; + // frames are converted with the true fractional fps for the sub-second part. + // - Fractional fps + non-drop (e.g., 23.976 NDF, 29.97 NDF): use the nominal integer fps + // (24 or 30) for the sub-second fraction so the displayed wall time equals the LTC label. - // Timecode is always treated as wall-clock time. NDF scaling is not applied - // as the LTC source appears to be pre-compensated. - let total_duration_secs = - Ratio::new(timecode_secs, 1) + Ratio::new(frame.frames as i64, 1) / frame.frame_rate; + // Basic components + let hms_secs: i64 = frame.hours as i64 * 3600 + frame.minutes as i64 * 60 + frame.seconds as i64; + let numer = *frame.frame_rate.numer(); + let denom = *frame.frame_rate.denom(); - // Convert to milliseconds - let total_ms = (total_duration_secs * Ratio::new(1000, 1)) - .round() - .to_integer(); + // Detect fractional (…/1001) and choose nominal fps for reconstruction when needed + let is_fractional = denom == 1001; + let nominal_fps: i64 = match numer { + 30000 => 30, + 24000 => 24, + _ => { + // Fallback to nearest integer fps + let approx = (numer as f64) / (denom as f64); + approx.round() as i64 + } + }; + // total_ms since midnight + let total_ms: i64 = if is_fractional && !frame.is_drop_frame { + // NDF fractional: use nominal sub-second to mirror LTC label exactly + let secs_ratio = Ratio::new(hms_secs, 1) + + Ratio::new(frame.frames as i64, 1) / Ratio::new(nominal_fps, 1); + (secs_ratio * Ratio::new(1000, 1)).round().to_integer() + } else { + // Integer fps or fractional drop-frame: treat HH:MM:SS as wall-clock seconds + let secs_ratio = Ratio::new(hms_secs, 1) + + Ratio::new(frame.frames as i64, 1) / frame.frame_rate; + (secs_ratio * Ratio::new(1000, 1)).round().to_integer() + }; + + // Build local datetime from midnight plus computed ms let naive_midnight = today_local.and_hms_opt(0, 0, 0).unwrap(); let naive_dt = naive_midnight + ChronoDuration::milliseconds(total_ms); + // Handle possible DST transitions by preferring the first valid mapping. let mut dt_local = Local .from_local_datetime(&naive_dt) - .single() - .expect("Ambiguous or invalid local time"); + .earliest() + .unwrap_or_else(|| Local.from_local_datetime(&naive_dt).latest().expect("Ambiguous or invalid local time")); // Apply timeturner offset let offset = &config.timeturner_offset; @@ -69,8 +96,13 @@ pub fn calculate_target_time(frame: &LtcFrame, config: &Config) -> DateTime Result<(), ()> { } } +/// Set a persistent frequency correction (PPM) using adjtimex. Linux only. +pub fn set_clock_frequency_ppm(ppm: i64) -> Result<(), ()> { + #[cfg(target_os = "linux")] + { + let success = Command::new("sudo") + .arg("adjtimex") + .arg("--frequency") + .arg(ppm.to_string()) + .status() + .map(|s| s.success()) + .unwrap_or(false); + + if success { + log::info!("Set clock frequency to {} ppm", ppm); + Ok(()) + } else { + log::error!("Failed to set clock frequency via adjtimex"); + Err(()) + } + } + #[cfg(not(target_os = "linux"))] + { + let _ = ppm; + log::warn!("Frequency adjustment is only supported on Linux."); + Err(()) + } +} + pub fn set_date(date: &str) -> Result<(), ()> { #[cfg(target_os = "linux")] { diff --git a/src/ui.rs b/src/ui.rs index 5854f4a..df71971 100644 --- a/src/ui.rs +++ b/src/ui.rs @@ -19,7 +19,7 @@ use crossterm::{ }; use crate::config::Config; -use crate::sync_logic::{get_jitter_status, get_sync_status, LtcState}; +use crate::sync_logic::{get_jitter_status, get_sync_status, LtcState, effective_display_rate}; use crate::system; use get_if_addrs::get_if_addrs; use num_rational::Ratio; @@ -85,7 +85,8 @@ pub fn start_ui( cached_delta_ms = avg_delta; if let Some(frame) = &state.lock().unwrap().latest { let delta_ms_ratio = Ratio::new(avg_delta, 1); - let frames_ratio = delta_ms_ratio * frame.frame_rate / Ratio::new(1000, 1); + let eff_rate = effective_display_rate(frame); + let frames_ratio = delta_ms_ratio * eff_rate / Ratio::new(1000, 1); cached_delta_frames = frames_ratio.round().to_integer(); } else { cached_delta_frames = 0; diff --git a/static/index.html b/static/index.html index 02bb279..3847b0f 100644 --- a/static/index.html +++ b/static/index.html @@ -95,11 +95,22 @@ +
+ + + + +
+
+ + + +
+
+ + + + +
+
+ + + +
diff --git a/static/mock-data.js b/static/mock-data.js index a953e59..3f5ed24 100644 --- a/static/mock-data.js +++ b/static/mock-data.js @@ -14,6 +14,7 @@ const mockApiDataSets = { timecode_delta_frames: 0.125, jitter_status: 'GOOD', interfaces: ['192.168.1.100/24 (eth0)', '10.0.0.5/8 (wlan0)'], + fractional_ndf_discipline_active: false, }, config: { hardwareOffsetMs: 10, @@ -41,6 +42,7 @@ const mockApiDataSets = { timecode_delta_frames: 0.075, jitter_status: 'GOOD', interfaces: ['192.168.1.100/24 (eth0)'], + fractional_ndf_discipline_active: false, }, config: { hardwareOffsetMs: 10, @@ -64,6 +66,7 @@ const mockApiDataSets = { timecode_delta_frames: -12.5, jitter_status: 'AVERAGE', interfaces: ['192.168.1.100/24 (eth0)'], + fractional_ndf_discipline_active: true, }, config: { hardwareOffsetMs: 10, @@ -87,6 +90,7 @@ const mockApiDataSets = { timecode_delta_frames: 20, jitter_status: 'AVERAGE', interfaces: ['192.168.1.100/24 (eth0)'], + fractional_ndf_discipline_active: false, }, config: { hardwareOffsetMs: 10, @@ -110,6 +114,7 @@ const mockApiDataSets = { timecode_delta_frames: 93076, jitter_status: 'GOOD', interfaces: ['192.168.1.100/24 (eth0)'], + fractional_ndf_discipline_active: false, }, config: { hardwareOffsetMs: 10, @@ -133,6 +138,7 @@ const mockApiDataSets = { timecode_delta_frames: 0.25, jitter_status: 'BAD', interfaces: ['192.168.1.100/24 (eth0)'], + fractional_ndf_discipline_active: false, }, config: { hardwareOffsetMs: 10, @@ -156,6 +162,7 @@ const mockApiDataSets = { timecode_delta_frames: 0, jitter_status: 'UNKNOWN', interfaces: [], + fractional_ndf_discipline_active: false, }, config: { hardwareOffsetMs: 0, diff --git a/static/script.js b/static/script.js index 634ed33..f14c0eb 100644 --- a/static/script.js +++ b/static/script.js @@ -25,6 +25,9 @@ const hwOffsetInput = document.getElementById('hw-offset'); const autoSyncCheckbox = document.getElementById('auto-sync-enabled'); + const autoPpmCheckbox = document.getElementById('auto-ppm-enabled'); + const ppmTargetInput = document.getElementById('ppm-target'); + const ppmAppliedCheckbox = document.getElementById('ppm-applied'); const offsetInputs = { h: document.getElementById('offset-h'), m: document.getElementById('offset-m'), @@ -35,6 +38,7 @@ const saveConfigButton = document.getElementById('save-config'); const manualSyncButton = document.getElementById('manual-sync'); const syncMessage = document.getElementById('sync-message'); + const fractionalNdfBadge = document.getElementById('fractional-ndf-badge'); const nudgeDownButton = document.getElementById('nudge-down'); const nudgeUpButton = document.getElementById('nudge-up'); @@ -150,6 +154,18 @@ } else { statusElements.interfaces.textContent = 'No active interfaces found.'; } + + // Show/hide fractional NDF discipline badge + if (fractionalNdfBadge) { + if (data.fractional_ndf_discipline_active) { + fractionalNdfBadge.style.display = 'inline'; + } else { + fractionalNdfBadge.style.display = 'none'; + } + } + if (ppmAppliedCheckbox) { + ppmAppliedCheckbox.checked = !!data.fractional_ndf_discipline_active; + } } function animateClocks() { @@ -238,6 +254,8 @@ const data = mockApiDataSets[currentMockSetKey].config; hwOffsetInput.value = data.hardwareOffsetMs; autoSyncCheckbox.checked = data.autoSyncEnabled; + if (autoPpmCheckbox) autoPpmCheckbox.checked = !!data.autoFractionalPpmEnabled; + if (ppmTargetInput && typeof data.fractionalPpmTarget === 'number') ppmTargetInput.value = data.fractionalPpmTarget; offsetInputs.h.value = data.timeturnerOffset.hours; offsetInputs.m.value = data.timeturnerOffset.minutes; offsetInputs.s.value = data.timeturnerOffset.seconds; @@ -252,6 +270,8 @@ const data = await response.json(); hwOffsetInput.value = data.hardwareOffsetMs; autoSyncCheckbox.checked = data.autoSyncEnabled; + if (autoPpmCheckbox) autoPpmCheckbox.checked = !!data.autoFractionalPpmEnabled; + if (ppmTargetInput && typeof data.fractionalPpmTarget === 'number') ppmTargetInput.value = data.fractionalPpmTarget; offsetInputs.h.value = data.timeturnerOffset.hours; offsetInputs.m.value = data.timeturnerOffset.minutes; offsetInputs.s.value = data.timeturnerOffset.seconds; @@ -268,6 +288,8 @@ hardwareOffsetMs: parseInt(hwOffsetInput.value, 10) || 0, autoSyncEnabled: autoSyncCheckbox.checked, defaultNudgeMs: parseInt(nudgeValueInput.value, 10) || 0, + autoFractionalPpmEnabled: autoPpmCheckbox ? autoPpmCheckbox.checked : undefined, + fractionalPpmTarget: ppmTargetInput ? (parseInt(ppmTargetInput.value, 10) || 0) : undefined, timeturnerOffset: { hours: parseInt(offsetInputs.h.value, 10) || 0, minutes: parseInt(offsetInputs.m.value, 10) || 0, @@ -277,6 +299,10 @@ } }; + // Remove undefined keys if controls are absent + if (config.autoFractionalPpmEnabled === undefined) delete config.autoFractionalPpmEnabled; + if (config.fractionalPpmTarget === undefined) delete config.fractionalPpmTarget; + if (useMockData) { console.log('Mock save:', config); alert('Configuration saved (mock).');