PPM control for NDF fractional control

This commit is contained in:
Chris Frankland-Wright 2025-10-28 16:24:44 +00:00
parent 2e8bc9ac5e
commit 2295a29d75
11 changed files with 532 additions and 20 deletions

View file

@ -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 <Arduino.h>
#include <Audio.h>
#include "analyze_ltc.h"
// —— Configuration ——
const float FORCE_FPS = 0.0f; // 0 → autodetect
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
// freerun
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 1second worth of frames:
int nominal = (currentFps>29.5f) ? 30 : int(currentFps+0.5f);
long dayFrames= 24L*3600L*nominal;
freeAbsFrame = (freeAbsFrame + nominal) % dayFrames;
// reset freerun 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 freerun 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;
}
}
}

View file

@ -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<String>,
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<AppState>) -> 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<AppState>) -> 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,
}
}

View file

@ -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<dyn std::error
s.push_str("# Default nudge in milliseconds for adjtimex control.\n");
s.push_str(&format!("defaultNudgeMs: {}\n\n", config.default_nudge_ms));
s.push_str("# Automatically apply frequency correction (PPM) when a fractional non-drop\n");
s.push_str("# LTC source (e.g., 23.976 or 29.97 NDF) is detected for a sustained period.\n");
s.push_str(&format!("autoFractionalPpmEnabled: {}\n\n", config.auto_fractional_ppm_enabled));
s.push_str("# Target PPM for fractional non-drop LTC. ~1000 ppm matches 1001/1000.\n");
s.push_str(&format!("fractionalPpmTarget: {}\n\n", config.fractional_ppm_target));
s.push_str("# Time-turning offsets. All values are added to the incoming LTC time.\n");
s.push_str("# These can be positive or negative.\n");
s.push_str("timeturnerOffset:\n");
@ -104,6 +123,8 @@ pub fn save_config(path: &str, config: &Config) -> Result<(), Box<dyn std::error
Ok(())
}
fn default_fractional_ppm_target() -> i64 { 1000 }
pub fn watch_config(path: &str) -> Arc<Mutex<Config>> {
let initial_config = Config::load(&PathBuf::from(path));
let config = Arc::new(Mutex::new(initial_config));

View file

@ -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<RateMode> = None;
let mut mode_since: Option<Instant> = 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));
}
});

View file

@ -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<i64> {
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<LtcFrame>,
pub lock_count: u32,
@ -62,6 +89,8 @@ pub struct LtcState {
pub ewma_clock_delta: Option<f64>,
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

View file

@ -41,27 +41,54 @@ pub fn ntp_service_toggle(start: bool) {
pub fn calculate_target_time(frame: &LtcFrame, config: &Config) -> DateTime<Local> {
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<Loca
+ ChronoDuration::hours(offset.hours)
+ ChronoDuration::minutes(offset.minutes)
+ ChronoDuration::seconds(offset.seconds);
// Frame offset needs to be converted to milliseconds
let frame_offset_ms_ratio = Ratio::new(offset.frames * 1000, 1) / frame.frame_rate;
// Frame offset needs to be converted using the same effective rate used for display
let frame_offset_rate = if is_fractional && !frame.is_drop_frame {
Ratio::new(nominal_fps, 1)
} else {
frame.frame_rate.clone()
};
let frame_offset_ms_ratio = Ratio::new(offset.frames * 1000, 1) / frame_offset_rate;
let frame_offset_ms = frame_offset_ms_ratio.round().to_integer();
dt_local + ChronoDuration::milliseconds(frame_offset_ms + offset.milliseconds)
}
@ -146,6 +178,34 @@ pub fn nudge_clock(microseconds: i64) -> 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")]
{

View file

@ -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;

View file

@ -95,11 +95,22 @@
</div>
</div>
</div>
<div class="control-group">
<input type="checkbox" id="auto-ppm-enabled" name="auto-ppm-enabled" style="vertical-align: middle;">
<label for="auto-ppm-enabled" style="vertical-align: middle;">Enable Fractional NDF Discipline (auto PPM)</label>
<label for="ppm-target" style="margin-left: 10px;">Target PPM:</label>
<input type="number" id="ppm-target" style="width: 80px;" step="1">
</div>
<div class="control-group">
<button id="save-config">Save Timeturner Config</button>
<button id="manual-sync">Send Manual Sync</button>
<span id="sync-message"></span>
</div>
<div class="control-group">
<input type="checkbox" id="ppm-applied" disabled>
<label for="ppm-applied">PPM Applied</label>
<span id="fractional-ndf-badge" style="display:none; font-weight:bold; color:#28a745; margin-left: 10px;">Fractional NDF discipline active</span>
</div>
<div class="control-group" style="display: none;">
<label>Nudge Clock (ms):</label>
<button id="nudge-down">-</button>

View file

@ -95,11 +95,22 @@
</div>
</div>
</div>
<div class="control-group">
<input type="checkbox" id="auto-ppm-enabled" name="auto-ppm-enabled" style="vertical-align: middle;">
<label for="auto-ppm-enabled" style="vertical-align: middle;">Enable Fractional NDF Discipline (auto PPM)</label>
<label for="ppm-target" style="margin-left: 10px;">Target PPM:</label>
<input type="number" id="ppm-target" style="width: 80px;" step="1">
</div>
<div class="control-group">
<button id="save-config">Save Config</button>
<button id="manual-sync">Manual Sync</button>
<span id="sync-message"></span>
</div>
<div class="control-group">
<input type="checkbox" id="ppm-applied" disabled>
<label for="ppm-applied">PPM Applied</label>
<span id="fractional-ndf-badge" style="display:none; font-weight:bold; color:#28a745; margin-left: 10px;">Fractional NDF discipline active</span>
</div>
<div class="control-group">
<label>Nudge Clock (ms):</label>
<button id="nudge-down">-</button>

View file

@ -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,

View file

@ -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).');