mirror of
https://github.com/cjfranko/NTP-Timeturner.git
synced 2025-11-08 10:22:02 +00:00
PPM control for NDF fractional control
This commit is contained in:
parent
2e8bc9ac5e
commit
2295a29d75
11 changed files with 532 additions and 20 deletions
264
firmware/ltc_audiohat_lock_v3.ino
Normal file
264
firmware/ltc_audiohat_lock_v3.ino
Normal 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 → 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
12
src/api.rs
12
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<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,
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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));
|
||||
|
|
|
|||
72
src/main.rs
72
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<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));
|
||||
}
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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")]
|
||||
{
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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).');
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue