From e36a4bfd87c7611a9e51aa47f20538a836854a81 Mon Sep 17 00:00:00 2001 From: Chris Frankland-Wright Date: Mon, 23 Jun 2025 18:26:51 +0100 Subject: [PATCH 01/10] stashed some initial files, removed the redundant hermione modules --- config.json | 10 ++++ hermione.py | 109 ---------------------------------------- hermione_config.json | 8 --- timeturner.py | 116 +++++++++++++++++++++++++++++++++++++++++++ 4 files changed, 126 insertions(+), 117 deletions(-) create mode 100644 config.json delete mode 100644 hermione.py delete mode 100644 hermione_config.json create mode 100644 timeturner.py diff --git a/config.json b/config.json new file mode 100644 index 0000000..83fe386 --- /dev/null +++ b/config.json @@ -0,0 +1,10 @@ +{ + "ltc_device": "/dev/audio", + "offset": { + "hours": 0, + "minutes": 0, + "seconds": 0, + "milliseconds": 0 + }, + "frame_rate": 25 +} diff --git a/hermione.py b/hermione.py deleted file mode 100644 index 5d3a007..0000000 --- a/hermione.py +++ /dev/null @@ -1,109 +0,0 @@ -ο»Ώimport json -import subprocess -import time -import threading -import os -from datetime import datetime - -CONFIG_FILE = "hermione_config.json" - -# Load config or create default -def load_config(): - if not os.path.exists(CONFIG_FILE): - default_config = { - "framerate": 25, - "start_mode": "system", - "manual_time": "12:00:00", - "duration_seconds": 3600, - "ltc_gen_path": "ltc-gen.exe", - "autostart_timeout": 5 - } - with open(CONFIG_FILE, "w") as f: - json.dump(default_config, f, indent=4) - return default_config - else: - with open(CONFIG_FILE, "r") as f: - return json.load(f) - -# Save updated config -def save_config(config): - with open(CONFIG_FILE, "w") as f: - json.dump(config, f, indent=4) - -# Prompt with timeout -def prompt_with_timeout(prompt, timeout): - print(prompt, end='', flush=True) - input_data = [] - - def get_input(): - try: - input_data.append(input()) - except EOFError: - pass - - thread = threading.Thread(target=get_input) - thread.daemon = True - thread.start() - thread.join(timeout) - return input_data[0] if input_data else "" - -# Get timecode based on config -def get_start_time(config): - if config["start_mode"] == "system": - now = datetime.now() - return now.strftime("%H:%M:%S") - else: - return config["manual_time"] - -# Run ltc-gen -def run_ltc_gen(config): - start_time = get_start_time(config) - framerate = str(config["framerate"]) - duration = str(config["duration_seconds"]) - ltc_gen_path = config["ltc_gen_path"] - - cmd = [ - ltc_gen_path, - "-f", framerate, - "-l", duration, - "-t", start_time - ] - - print(f"\n🎬 Running Hermione with:") - print(f" Start Time: {start_time}") - print(f" Framerate: {framerate} fps") - print(f" Duration: {duration} seconds") - print(f" Executable: {ltc_gen_path}\n") - - try: - subprocess.run(cmd) - except FileNotFoundError: - print(f"❌ Error: {ltc_gen_path} not found!") - except Exception as e: - print(f"❌ Failed to run Hermione: {e}") - -# Main logic -def main(): - config = load_config() - user_input = prompt_with_timeout( - "\nPress [Enter] to run with config or type 'm' to modify (auto-starts in 5s): ", - config.get("autostart_timeout", 5) - ) - - if user_input.lower() == 'm': - try: - config["framerate"] = int(input("Enter framerate (e.g. 25): ")) - config["start_mode"] = input("Start from system time or manual? (system/manual): ").strip().lower() - if config["start_mode"] == "manual": - config["manual_time"] = input("Enter manual start time (HH:MM:SS): ") - config["duration_seconds"] = int(input("Enter duration in seconds: ")) - config["ltc_gen_path"] = input("Enter path to ltc-gen.exe (or leave blank for default): ") or config["ltc_gen_path"] - save_config(config) - except Exception as e: - print(f"⚠️ Error updating config: {e}") - return - - run_ltc_gen(config) - -if __name__ == "__main__": - main() diff --git a/hermione_config.json b/hermione_config.json deleted file mode 100644 index cd64a0a..0000000 --- a/hermione_config.json +++ /dev/null @@ -1,8 +0,0 @@ -{ - "framerate": 25, - "start_mode": "system", - "manual_time": "12:00:00", - "duration_seconds": 3600, - "ltc_gen_path": "ltc-gen.exe", - "autostart_timeout": 5 -} diff --git a/timeturner.py b/timeturner.py new file mode 100644 index 0000000..2d34ae2 --- /dev/null +++ b/timeturner.py @@ -0,0 +1,116 @@ +ο»Ώ#!/usr/bin/env python3 + +""" +timeturner.py +NTP Timeturner β€” LTC-to-NTP time server for Raspberry Pi +Now with a colourful LTC status light 🌈🟒 +""" + +import os +import sys +import time +import logging +import json +import curses +from datetime import datetime, timedelta + +CONFIG_PATH = "config.json" + +DEFAULT_CONFIG = { + "ltc_device": "/dev/audio", + "offset": { + "hours": 0, + "minutes": 0, + "seconds": 0, + "milliseconds": 0 + }, + "frame_rate": 25 +} + +TIMETURNER_SPINNER = ['β§—', 'β§–', 'β§—', 'β§–', 'πŸ•°'] +HEARTBEAT_PULSE = ['●', 'β—‹'] + +def load_config(path=CONFIG_PATH): + if not os.path.exists(path): + logging.warning("Config file not found, using defaults.") + return DEFAULT_CONFIG + with open(path, "r") as f: + return json.load(f) + +def read_ltc_time(): + return datetime.utcnow() + +def apply_offset(base_time, offset): + delta = timedelta( + hours=offset.get("hours", 0), + minutes=offset.get("minutes", 0), + seconds=offset.get("seconds", 0), + milliseconds=offset.get("milliseconds", 0) + ) + return base_time + delta + +def set_system_time(new_time): + formatted = new_time.strftime('%Y-%m-%d %H:%M:%S') + logging.info(f"Setting system time to: {formatted}") + # os.system(f"sudo timedatectl set-time \"{formatted}\"") + +def draw_dashboard(stdscr, ltc_time, adjusted_time, config, frame): + stdscr.clear() + + # Setup strings + offset = config["offset"] + offset_str = f"{offset['hours']:02}:{offset['minutes']:02}:{offset['seconds']:02}.{offset['milliseconds']:03}" + spinner = TIMETURNER_SPINNER[frame % len(TIMETURNER_SPINNER)] + heartbeat = HEARTBEAT_PULSE[frame % len(HEARTBEAT_PULSE)] + + # Hardcoded LTC status + ltc_status = "LOCKED" + ltc_colour = curses.color_pair(2) # Green + + # Draw + stdscr.addstr(0, 0, f"β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”") + stdscr.addstr(1, 0, f"β”‚ {spinner} NTP Timeturner {spinner} β”‚") + stdscr.addstr(2, 0, f"β”œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€") + stdscr.addstr(3, 0, f"β”‚ LTC Time: {ltc_time.strftime('%H:%M:%S:%f')[:-3]} β”‚") + stdscr.addstr(4, 0, f"β”‚ Offset Applied: +{offset_str:<17}β”‚") + stdscr.addstr(5, 0, f"β”‚ System Time: {adjusted_time.strftime('%H:%M:%S')} β”‚") + stdscr.addstr(6, 0, f"β”‚ Frame Rate: {config['frame_rate']} fps β”‚") + stdscr.addstr(7, 0, f"β”‚ LTC Status: ") + stdscr.addstr("● ", ltc_colour) + stdscr.addstr(f"{ltc_status:<14}β”‚") + stdscr.addstr(8, 0, f"β”‚ NTP Broadcast: PENDING β”‚") + stdscr.addstr(9, 0, f"β”œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€") + stdscr.addstr(10, 0, f"β”‚ System Status: {heartbeat} β”‚") + stdscr.addstr(11, 0, f"β”‚ [Ctrl+C to exit] β”‚") + stdscr.addstr(12, 0, f"β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜") + + stdscr.refresh() + +def start_timeturner(stdscr): + curses.curs_set(0) + curses.start_color() + curses.init_pair(1, curses.COLOR_RED, curses.COLOR_BLACK) # Not used yet + curses.init_pair(2, curses.COLOR_GREEN, curses.COLOR_BLACK) # LOCKED + curses.init_pair(3, curses.COLOR_YELLOW, curses.COLOR_BLACK) # UNSTABLE + + stdscr.nodelay(True) + stdscr.timeout(1000) + + config = load_config() + frame = 0 + + while True: + try: + ltc_time = read_ltc_time() + adjusted_time = apply_offset(ltc_time, config["offset"]) + set_system_time(adjusted_time) + draw_dashboard(stdscr, ltc_time, adjusted_time, config, frame) + frame += 1 + except KeyboardInterrupt: + break + +if __name__ == "__main__": + logging.basicConfig(level=logging.INFO, + format="[%(asctime)s] %(levelname)s: %(message)s") + logging.info("✨ Timeturner console mode started.") + curses.wrapper(start_timeturner) From a25cf2582ea17c6beb02e036543856b1e97f2f5b Mon Sep 17 00:00:00 2001 From: Chris Frankland-Wright Date: Tue, 24 Jun 2025 19:53:41 +0100 Subject: [PATCH 02/10] added installers for soundcard --- setup.sh | 137 +++++++++++++++++++------------------------------------ 1 file changed, 46 insertions(+), 91 deletions(-) diff --git a/setup.sh b/setup.sh index 7ec0a9b..6ced746 100644 --- a/setup.sh +++ b/setup.sh @@ -1,117 +1,72 @@ ο»Ώ#!/bin/bash -set -e -echo "" -echo "─────────────────────────────────────────────" -echo " Welcome to the NTP Timeturner Installer" -echo "─────────────────────────────────────────────" -echo "" -echo "\"It's a very complicated piece of magic...\" – Hermione Granger" -echo "Initialising temporal calibration sequence..." -echo "Requesting clearance from the Ministry of Time Standards..." -echo "" +echo "✨ Welcome to the NTP Timeturner Installer" +echo "Preparing the Time Room... Stand by." -# --------------------------------------------------------- -# Step 1: Update and upgrade packages -# --------------------------------------------------------- -echo "" -echo "Step 1: Updating package lists..." -sudo apt update +# ───────────────────────────────────────────── +# Step 1: Update System +# ───────────────────────────────────────────── +echo "Step 1: Updating system packages..." +sudo apt update && sudo apt upgrade -y -echo "Upgrading installed packages..." -sudo apt upgrade -y +# ───────────────────────────────────────────── +# Step 2: Install Core Dependencies +# ───────────────────────────────────────────── +echo "Step 2: Installing core dependencies..." +sudo apt install -y \ + git cmake build-essential \ + libjack-jackd2-dev libsamplerate0-dev \ + libasound2-dev libsndfile1-dev \ + python3 python3-pip python3-numpy python3-matplotlib -# --------------------------------------------------------- -# Step 2: Install essential development tools -# --------------------------------------------------------- -echo "" -echo "Step 2: Installing development tools..." -sudo apt install -y git curl python3 python3-pip build-essential autoconf automake libtool cmake +# ───────────────────────────────────────────── +# Step 3: Install Python Audio Libraries +# ───────────────────────────────────────────── +echo "Step 3: Installing Python audio libraries..." +pip3 install sounddevice -# --------------------------------------------------------- -# Step 3: Install audio and media dependencies -# --------------------------------------------------------- -echo "" -echo "Step 3: Installing audio libraries and tools..." -sudo apt install -y alsa-utils ffmpeg \ - portaudio19-dev python3-pyaudio \ - libasound2-dev libjack-jackd2-dev \ - libsndfile-dev \ - || echo "Warning: Some audio dependencies may have failed to install β€” continuing anyway." +# ───────────────────────────────────────────── +# Step 4: Install Splash Screen +# ───────────────────────────────────────────── +echo "Step 4: Installing custom splash screen..." +sudo cp splash.png /usr/share/plymouth/themes/pix/splash.png -# --------------------------------------------------------- -# Step 4: Install Python packages -# --------------------------------------------------------- -echo "" -echo "Step 4: Installing Python packages..." -pip3 install numpy --break-system-packages - -# --------------------------------------------------------- -# Step 5: Build and install libltc (needed by ltc-tools) -# --------------------------------------------------------- -echo "" +# ───────────────────────────────────────────── +# Step 5: Build libltc +# ───────────────────────────────────────────── echo "Step 5: Building libltc (the heart of our time-magic)..." cd ~ if [ ! -d "libltc" ]; then - echo "Cloning libltc from GitHub..." git clone https://github.com/x42/libltc.git fi cd libltc - -echo "Preparing libltc build..." -./autogen.sh -./configure - -echo "Compiling libltc..." +cmake . make - -echo "Installing libltc..." sudo make install sudo ldconfig -# --------------------------------------------------------- -# Step 6: Build and install ltc-tools -# --------------------------------------------------------- -echo "" -echo "Step 6: Building ltc-tools (with a gentle nudge)..." +# ───────────────────────────────────────────── +# Step 6: Build ltc-tools +# ───────────────────────────────────────────── +echo "Step 6: Building ltc-tools..." cd ~ if [ ! -d "ltc-tools" ]; then - echo "Cloning ltc-tools from GitHub..." git clone https://github.com/x42/ltc-tools.git fi cd ltc-tools - -echo "Compiling ltc-tools (bypassing package check)..." -make HAVE_LIBLTC=true - -echo "Installing ltc-tools..." +make sudo make install -sudo ldconfig -# --------------------------------------------------------- -# Step 7: Apply Custom Splash Screen -# --------------------------------------------------------- -echo "" -echo "Step 7: Applying splash screen..." +# ───────────────────────────────────────────── +# Step 7: Set Hostname +# ───────────────────────────────────────────── +echo "Step 7: Configuring hostname..." +sudo hostnamectl set-hostname ntp-timeturner -sudo curl -L -o /usr/share/plymouth/themes/pix/splash.png https://raw.githubusercontent.com/cjfranko/NTP-Timeturner/master/splash.png -sudo chmod 644 /usr/share/plymouth/themes/pix/splash.png - -# --------------------------------------------------------- -# Final Message & Reboot Option -# --------------------------------------------------------- -echo "" -echo "─────────────────────────────────────────────" -echo " Setup Complete" -echo "─────────────────────────────────────────────" -echo "" -echo "The TimeTurner is ready. But remember:" -echo "\"You must not be seen.\" – Hermione Granger" -echo "Visual enhancements are in place. Terminal timeline is stable." -echo "" -echo "The system will reboot in 30 seconds to complete setup..." -echo "Press [Enter] to reboot immediately, or Ctrl+C to cancel." - -read -t 30 -p "" || true -sleep 1 +# ───────────────────────────────────────────── +# Complete +# ───────────────────────────────────────────── +echo "✨ Installation complete." +echo "System will reboot in 30 seconds unless you press [Enter] to reboot now." +read -t 30 -p "Press [Enter] to reboot now or wait..." input sudo reboot From 81e8313f45fb5d787bd4731291fd5ab8f247255c Mon Sep 17 00:00:00 2001 From: Chris Frankland-Wright Date: Tue, 24 Jun 2025 19:56:12 +0100 Subject: [PATCH 03/10] added testaudio --- test_audioinput.py | 32 ++++++++++++++++++++++++++++++++ 1 file changed, 32 insertions(+) create mode 100644 test_audioinput.py diff --git a/test_audioinput.py b/test_audioinput.py new file mode 100644 index 0000000..779ef3a --- /dev/null +++ b/test_audioinput.py @@ -0,0 +1,32 @@ +ο»Ώ#!/usr/bin/env python3 + +""" +test_audioinput.py +Quick sanity check to ensure audio input device is working. +Records 2 seconds of audio from default input and plots waveform. +""" + +import numpy as np +import matplotlib.pyplot as plt +import sounddevice as sd + +DURATION = 2 # seconds +SAMPLERATE = 48000 +CHANNELS = 1 + +print("πŸ”Š Recording 2 seconds from default input device...") +recording = sd.rec(int(DURATION * SAMPLERATE), samplerate=SAMPLERATE, channels=CHANNELS, dtype='float32') +sd.wait() + +# Generate time axis +time_axis = np.linspace(0, DURATION, len(recording)) + +# Plot +plt.figure(figsize=(10, 4)) +plt.plot(time_axis, recording, linewidth=0.5) +plt.title("Audio Input Waveform") +plt.xlabel("Time [s]") +plt.ylabel("Amplitude") +plt.grid(True) +plt.tight_layout() +plt.show() From 648aa221b13e1bf4f9bb55423118a33498d73048 Mon Sep 17 00:00:00 2001 From: Chris Frankland-Wright Date: Tue, 24 Jun 2025 20:12:37 +0100 Subject: [PATCH 04/10] creates png of audio input --- test_audioinput.py | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/test_audioinput.py b/test_audioinput.py index 779ef3a..213e1a4 100644 --- a/test_audioinput.py +++ b/test_audioinput.py @@ -2,11 +2,13 @@ """ test_audioinput.py -Quick sanity check to ensure audio input device is working. -Records 2 seconds of audio from default input and plots waveform. +Records 2 seconds of audio from the default input device +and saves the waveform as 'waveform.png' β€” works headless. """ import numpy as np +import matplotlib +matplotlib.use('Agg') # Headless backend import matplotlib.pyplot as plt import sounddevice as sd @@ -21,7 +23,7 @@ sd.wait() # Generate time axis time_axis = np.linspace(0, DURATION, len(recording)) -# Plot +# Plot and save plt.figure(figsize=(10, 4)) plt.plot(time_axis, recording, linewidth=0.5) plt.title("Audio Input Waveform") @@ -29,4 +31,6 @@ plt.xlabel("Time [s]") plt.ylabel("Amplitude") plt.grid(True) plt.tight_layout() -plt.show() +plt.savefig("waveform.png") + +print("βœ… Waveform saved as 'waveform.png'") From e3f4efe93e5d84e2dfbf99d9e8e72380c4955e29 Mon Sep 17 00:00:00 2001 From: Chris Frankland-Wright Date: Tue, 24 Jun 2025 20:16:25 +0100 Subject: [PATCH 05/10] ltc probe --- ltc_probe.py | 43 +++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 43 insertions(+) create mode 100644 ltc_probe.py diff --git a/ltc_probe.py b/ltc_probe.py new file mode 100644 index 0000000..4b63d37 --- /dev/null +++ b/ltc_probe.py @@ -0,0 +1,43 @@ +ο»Ώ#!/usr/bin/env python3 + +""" +ltc_probe.py +Probes audio input to detect LTC-like signal characteristics. +Reports zero crossings and estimated frequency. +""" + +import numpy as np +import sounddevice as sd + +DURATION = 1.0 # seconds +SAMPLERATE = 48000 +CHANNELS = 1 +EXPECTED_FREQ = 2000 # Approx LTC edge rate at 25fps + +def count_zero_crossings(signal): + signal = signal.flatten() + signs = np.sign(signal) + return np.count_nonzero(np.diff(signs)) + +def verdict(freq): + if freq < 100: + return "❌ No signal detected (flatline or silence)" + elif 1800 <= freq <= 2200: + return f"βœ… LTC-like signal detected (freq: {freq:.1f} Hz)" + else: + return f"⚠️ Unstable or non-LTC signal (freq: {freq:.1f} Hz)" + +def main(): + print("πŸ” Capturing 1 second of audio for LTC probing...") + audio = sd.rec(int(DURATION * SAMPLERATE), samplerate=SAMPLERATE, channels=CHANNELS, dtype='float32') + sd.wait() + + crossings = count_zero_crossings(audio) + estimated_freq = crossings / DURATION + + print(f"Zero crossings: {crossings}") + print(f"Estimated frequency: {estimated_freq:.1f} Hz") + print(verdict(estimated_freq)) + +if __name__ == "__main__": + main() From 0bb70e7966bd6fcf676eb2e686bafed23cdba7e0 Mon Sep 17 00:00:00 2001 From: Chris Frankland-Wright Date: Tue, 24 Jun 2025 21:39:53 +0100 Subject: [PATCH 06/10] improved probe --- ltc_probe.py | 64 +++++++++++++++++++++++++++++++++++++--------------- 1 file changed, 46 insertions(+), 18 deletions(-) diff --git a/ltc_probe.py b/ltc_probe.py index 4b63d37..93c3c88 100644 --- a/ltc_probe.py +++ b/ltc_probe.py @@ -2,8 +2,8 @@ """ ltc_probe.py -Probes audio input to detect LTC-like signal characteristics. -Reports zero crossings and estimated frequency. +Improved LTC-like signal probe β€” detects pulse duration patterns +consistent with bi-phase mark code used in SMPTE LTC. """ import numpy as np @@ -12,32 +12,60 @@ import sounddevice as sd DURATION = 1.0 # seconds SAMPLERATE = 48000 CHANNELS = 1 -EXPECTED_FREQ = 2000 # Approx LTC edge rate at 25fps +MIN_EDGES = 1000 # sanity threshold -def count_zero_crossings(signal): - signal = signal.flatten() - signs = np.sign(signal) - return np.count_nonzero(np.diff(signs)) +def detect_rising_edges(signal): + # signal: flattened 1D numpy array + above_zero = signal > 0 + edges = np.where(np.logical_and(~above_zero[:-1], above_zero[1:]))[0] + return edges -def verdict(freq): - if freq < 100: - return "❌ No signal detected (flatline or silence)" - elif 1800 <= freq <= 2200: - return f"βœ… LTC-like signal detected (freq: {freq:.1f} Hz)" +def analyze_pulse_durations(edges, samplerate): + durations = np.diff(edges) / samplerate # in seconds + if len(durations) == 0: + return None + + short_pulse_threshold = np.median(durations) * 1.5 + short = durations[durations <= short_pulse_threshold] + long = durations[durations > short_pulse_threshold] + + return { + "count": len(durations), + "avg_width_ms": np.mean(durations) * 1000, + "short_pulses": len(short), + "long_pulses": len(long), + "short_pct": (len(short) / len(durations)) * 100, + "long_pct": (len(long) / len(durations)) * 100 + } + +def verdict(pulse_data): + if pulse_data is None or pulse_data["count"] < MIN_EDGES: + return "❌ No signal or not enough pulses" + elif 30 < pulse_data["short_pct"] < 70: + return f"βœ… LTC-like bi-phase signal detected ({pulse_data['count']} pulses)" else: - return f"⚠️ Unstable or non-LTC signal (freq: {freq:.1f} Hz)" + return f"⚠️ Inconsistent signal β€” may be non-LTC or noisy" def main(): print("πŸ” Capturing 1 second of audio for LTC probing...") audio = sd.rec(int(DURATION * SAMPLERATE), samplerate=SAMPLERATE, channels=CHANNELS, dtype='float32') sd.wait() - crossings = count_zero_crossings(audio) - estimated_freq = crossings / DURATION + signal = audio.flatten() + edges = detect_rising_edges(signal) + pulse_data = analyze_pulse_durations(edges, SAMPLERATE) - print(f"Zero crossings: {crossings}") - print(f"Estimated frequency: {estimated_freq:.1f} Hz") - print(verdict(estimated_freq)) + print(f"\nπŸ“Š Pulse Analysis:") + if pulse_data: + print(f" Total pulses: {pulse_data['count']}") + print(f" Avg pulse width: {pulse_data['avg_width_ms']:.2f} ms") + print(f" Short pulses: {pulse_data['short_pulses']} ({pulse_data['short_pct']:.1f}%)") + print(f" Long pulses: {pulse_data['long_pulses']} ({pulse_data['long_pct']:.1f}%)") + else: + print(" Not enough data to analyze.") + + print("\n🧭 Verdict:") + print(" ", verdict(pulse_data)) if __name__ == "__main__": main() From 3726b96b86f80d4c27aafb1543a147d919079e26 Mon Sep 17 00:00:00 2001 From: Chris Frankland-Wright Date: Tue, 24 Jun 2025 21:50:57 +0100 Subject: [PATCH 07/10] fixed varience into code --- ltc_probe.py | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/ltc_probe.py b/ltc_probe.py index 93c3c88..b1c6e77 100644 --- a/ltc_probe.py +++ b/ltc_probe.py @@ -15,13 +15,12 @@ CHANNELS = 1 MIN_EDGES = 1000 # sanity threshold def detect_rising_edges(signal): - # signal: flattened 1D numpy array above_zero = signal > 0 edges = np.where(np.logical_and(~above_zero[:-1], above_zero[1:]))[0] return edges def analyze_pulse_durations(edges, samplerate): - durations = np.diff(edges) / samplerate # in seconds + durations = np.diff(edges) / samplerate if len(durations) == 0: return None @@ -41,7 +40,7 @@ def analyze_pulse_durations(edges, samplerate): def verdict(pulse_data): if pulse_data is None or pulse_data["count"] < MIN_EDGES: return "❌ No signal or not enough pulses" - elif 30 < pulse_data["short_pct"] < 70: + elif 20 <= pulse_data["short_pct"] <= 80: return f"βœ… LTC-like bi-phase signal detected ({pulse_data['count']} pulses)" else: return f"⚠️ Inconsistent signal β€” may be non-LTC or noisy" From 0c423f30abf401d321cbccc0b1595dbb0f4c6596 Mon Sep 17 00:00:00 2001 From: Chris Frankland-Wright Date: Tue, 24 Jun 2025 21:55:26 +0100 Subject: [PATCH 08/10] additional diagnostic --- ltc_probe.py | 43 ++++++++++++++++++++++++++++++++----------- 1 file changed, 32 insertions(+), 11 deletions(-) diff --git a/ltc_probe.py b/ltc_probe.py index b1c6e77..9dded3c 100644 --- a/ltc_probe.py +++ b/ltc_probe.py @@ -2,8 +2,8 @@ """ ltc_probe.py -Improved LTC-like signal probe β€” detects pulse duration patterns -consistent with bi-phase mark code used in SMPTE LTC. +Advanced LTC-like signal probe using pulse duration clustering +for reliable short/long classification β€” works even with imbalanced timecodes. """ import numpy as np @@ -12,38 +12,59 @@ import sounddevice as sd DURATION = 1.0 # seconds SAMPLERATE = 48000 CHANNELS = 1 -MIN_EDGES = 1000 # sanity threshold +MIN_EDGES = 1000 def detect_rising_edges(signal): above_zero = signal > 0 edges = np.where(np.logical_and(~above_zero[:-1], above_zero[1:]))[0] return edges +def cluster_durations(durations): + if len(durations) < 2: + return None, None + + # Use 2-means clustering (basic method) + durations = np.array(durations) + mean1, mean2 = np.min(durations), np.max(durations) + + for _ in range(10): # converge in a few iterations + group1 = durations[np.abs(durations - mean1) < np.abs(durations - mean2)] + group2 = durations[np.abs(durations - mean1) >= np.abs(durations - mean2)] + if len(group1) == 0 or len(group2) == 0: + break + mean1 = np.mean(group1) + mean2 = np.mean(group2) + + short = group1 if mean1 < mean2 else group2 + long = group2 if mean1 < mean2 else group1 + return short, long + def analyze_pulse_durations(edges, samplerate): durations = np.diff(edges) / samplerate if len(durations) == 0: return None - short_pulse_threshold = np.median(durations) * 1.5 - short = durations[durations <= short_pulse_threshold] - long = durations[durations > short_pulse_threshold] + short, long = cluster_durations(durations) + if short is None or long is None: + return None + total = len(durations) return { - "count": len(durations), + "count": total, "avg_width_ms": np.mean(durations) * 1000, "short_pulses": len(short), "long_pulses": len(long), - "short_pct": (len(short) / len(durations)) * 100, - "long_pct": (len(long) / len(durations)) * 100 + "short_pct": (len(short) / total) * 100, + "long_pct": (len(long) / total) * 100 } def verdict(pulse_data): if pulse_data is None or pulse_data["count"] < MIN_EDGES: return "❌ No signal or not enough pulses" - elif 20 <= pulse_data["short_pct"] <= 80: + elif 10 <= pulse_data["short_pct"] <= 90: return f"βœ… LTC-like bi-phase signal detected ({pulse_data['count']} pulses)" else: - return f"⚠️ Inconsistent signal β€” may be non-LTC or noisy" + return f"⚠️ Pulse imbalance suggests non-LTC or noisy signal" def main(): print("πŸ” Capturing 1 second of audio for LTC probing...") From d931f61da3ccdd540b3085777c30cda206fc8ee3 Mon Sep 17 00:00:00 2001 From: Chris Frankland-Wright Date: Tue, 24 Jun 2025 22:02:43 +0100 Subject: [PATCH 09/10] first iteration --- timeturner.py | 178 ++++++++++++++++++++++++-------------------------- 1 file changed, 85 insertions(+), 93 deletions(-) diff --git a/timeturner.py b/timeturner.py index 2d34ae2..943b32e 100644 --- a/timeturner.py +++ b/timeturner.py @@ -2,115 +2,107 @@ """ timeturner.py -NTP Timeturner β€” LTC-to-NTP time server for Raspberry Pi -Now with a colourful LTC status light 🌈🟒 +NTP Timeturner Core UI +Displays LTC signal probe info using curses, updated in real-time. """ -import os -import sys -import time -import logging -import json import curses -from datetime import datetime, timedelta +import threading +import time +import numpy as np +import sounddevice as sd -CONFIG_PATH = "config.json" +# --- CONFIGURATION --- +SAMPLERATE = 48000 +CHANNELS = 1 +PROBE_INTERVAL = 1.0 # seconds +MIN_EDGES = 1000 -DEFAULT_CONFIG = { - "ltc_device": "/dev/audio", - "offset": { - "hours": 0, - "minutes": 0, - "seconds": 0, - "milliseconds": 0 - }, - "frame_rate": 25 +status = { + "count": 0, + "avg_width_ms": 0.0, + "short_pct": 0.0, + "long_pct": 0.0, + "verdict": "Waiting for signal...", } -TIMETURNER_SPINNER = ['β§—', 'β§–', 'β§—', 'β§–', 'πŸ•°'] -HEARTBEAT_PULSE = ['●', 'β—‹'] -def load_config(path=CONFIG_PATH): - if not os.path.exists(path): - logging.warning("Config file not found, using defaults.") - return DEFAULT_CONFIG - with open(path, "r") as f: - return json.load(f) +# --- LTC PROBE THREAD --- +def detect_rising_edges(signal): + above_zero = signal > 0 + edges = np.where(np.logical_and(~above_zero[:-1], above_zero[1:]))[0] + return edges -def read_ltc_time(): - return datetime.utcnow() +def cluster_durations(durations): + if len(durations) < 2: + return None, None -def apply_offset(base_time, offset): - delta = timedelta( - hours=offset.get("hours", 0), - minutes=offset.get("minutes", 0), - seconds=offset.get("seconds", 0), - milliseconds=offset.get("milliseconds", 0) - ) - return base_time + delta + durations = np.array(durations) + mean1, mean2 = np.min(durations), np.max(durations) -def set_system_time(new_time): - formatted = new_time.strftime('%Y-%m-%d %H:%M:%S') - logging.info(f"Setting system time to: {formatted}") - # os.system(f"sudo timedatectl set-time \"{formatted}\"") + for _ in range(10): + group1 = durations[np.abs(durations - mean1) < np.abs(durations - mean2)] + group2 = durations[np.abs(durations - mean1) >= np.abs(durations - mean2)] + if len(group1) == 0 or len(group2) == 0: + break + mean1 = np.mean(group1) + mean2 = np.mean(group2) -def draw_dashboard(stdscr, ltc_time, adjusted_time, config, frame): - stdscr.clear() - - # Setup strings - offset = config["offset"] - offset_str = f"{offset['hours']:02}:{offset['minutes']:02}:{offset['seconds']:02}.{offset['milliseconds']:03}" - spinner = TIMETURNER_SPINNER[frame % len(TIMETURNER_SPINNER)] - heartbeat = HEARTBEAT_PULSE[frame % len(HEARTBEAT_PULSE)] - - # Hardcoded LTC status - ltc_status = "LOCKED" - ltc_colour = curses.color_pair(2) # Green - - # Draw - stdscr.addstr(0, 0, f"β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”") - stdscr.addstr(1, 0, f"β”‚ {spinner} NTP Timeturner {spinner} β”‚") - stdscr.addstr(2, 0, f"β”œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€") - stdscr.addstr(3, 0, f"β”‚ LTC Time: {ltc_time.strftime('%H:%M:%S:%f')[:-3]} β”‚") - stdscr.addstr(4, 0, f"β”‚ Offset Applied: +{offset_str:<17}β”‚") - stdscr.addstr(5, 0, f"β”‚ System Time: {adjusted_time.strftime('%H:%M:%S')} β”‚") - stdscr.addstr(6, 0, f"β”‚ Frame Rate: {config['frame_rate']} fps β”‚") - stdscr.addstr(7, 0, f"β”‚ LTC Status: ") - stdscr.addstr("● ", ltc_colour) - stdscr.addstr(f"{ltc_status:<14}β”‚") - stdscr.addstr(8, 0, f"β”‚ NTP Broadcast: PENDING β”‚") - stdscr.addstr(9, 0, f"β”œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€") - stdscr.addstr(10, 0, f"β”‚ System Status: {heartbeat} β”‚") - stdscr.addstr(11, 0, f"β”‚ [Ctrl+C to exit] β”‚") - stdscr.addstr(12, 0, f"β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜") - - stdscr.refresh() - -def start_timeturner(stdscr): - curses.curs_set(0) - curses.start_color() - curses.init_pair(1, curses.COLOR_RED, curses.COLOR_BLACK) # Not used yet - curses.init_pair(2, curses.COLOR_GREEN, curses.COLOR_BLACK) # LOCKED - curses.init_pair(3, curses.COLOR_YELLOW, curses.COLOR_BLACK) # UNSTABLE - - stdscr.nodelay(True) - stdscr.timeout(1000) - - config = load_config() - frame = 0 + short = group1 if mean1 < mean2 else group2 + long = group2 if mean1 < mean2 else group1 + return short, long +def analyze_signal(): + global status while True: try: - ltc_time = read_ltc_time() - adjusted_time = apply_offset(ltc_time, config["offset"]) - set_system_time(adjusted_time) - draw_dashboard(stdscr, ltc_time, adjusted_time, config, frame) - frame += 1 + audio = sd.rec(int(PROBE_INTERVAL * SAMPLERATE), samplerate=SAMPLERATE, + channels=CHANNELS, dtype='float32') + sd.wait() + signal = audio.flatten() + edges = detect_rising_edges(signal) + durations = np.diff(edges) / SAMPLERATE + short, long = cluster_durations(durations) + + if short is None or long is None or len(durations) < MIN_EDGES: + status["verdict"] = "❌ No signal or not enough pulses" + continue + + status.update({ + "count": len(durations), + "avg_width_ms": np.mean(durations) * 1000, + "short_pct": (len(short) / len(durations)) * 100, + "long_pct": (len(long) / len(durations)) * 100, + "verdict": "βœ… LTC-like signal detected" if 10 <= (len(short) / len(durations)) * 100 <= 90 + else "⚠️ Pulse imbalance β€” possible noise or non-LTC" + }) + except Exception as e: + status["verdict"] = f"⚠️ Audio error: {e}" + +# --- CURSES UI --- +def draw_ui(stdscr): + curses.curs_set(0) + stdscr.nodelay(True) + stdscr.timeout(500) + + while True: + stdscr.clear() + stdscr.addstr(0, 2, "πŸ•°οΈ NTP Timeturner - Live LTC Monitor", curses.A_BOLD) + stdscr.addstr(2, 4, f"Pulses captured: {status['count']}") + stdscr.addstr(3, 4, f"Avg pulse width: {status['avg_width_ms']:.2f} ms") + stdscr.addstr(4, 4, f"Short pulse ratio: {status['short_pct']:.1f}%") + stdscr.addstr(5, 4, f"Long pulse ratio: {status['long_pct']:.1f}%") + stdscr.addstr(7, 4, f"Status: {status['verdict']}") + stdscr.addstr(9, 4, "Press Ctrl+C to exit.") + stdscr.refresh() + + try: + time.sleep(1) except KeyboardInterrupt: break +# --- ENTRY POINT --- if __name__ == "__main__": - logging.basicConfig(level=logging.INFO, - format="[%(asctime)s] %(levelname)s: %(message)s") - logging.info("✨ Timeturner console mode started.") - curses.wrapper(start_timeturner) + probe_thread = threading.Thread(target=analyze_signal, daemon=True) + probe_thread.start() + curses.wrapper(draw_ui) From 5894f9e0f88ea4546b5166dabfae1dea2f24c57a Mon Sep 17 00:00:00 2001 From: Chris Frankland-Wright Date: Tue, 24 Jun 2025 22:09:02 +0100 Subject: [PATCH 10/10] rounding off this branch for now --- setup.sh | 89 +++++++++++++++++++------------------------------------- 1 file changed, 30 insertions(+), 59 deletions(-) diff --git a/setup.sh b/setup.sh index 6ced746..55dbb73 100644 --- a/setup.sh +++ b/setup.sh @@ -1,72 +1,43 @@ -ο»Ώ#!/bin/bash +#!/bin/bash -echo "✨ Welcome to the NTP Timeturner Installer" -echo "Preparing the Time Room... Stand by." +# NTP Timeturner Setup Script +# Tested on Debian Bookworm - Raspberry Pi 3 +# Author: cjfranko -# ───────────────────────────────────────────── -# Step 1: Update System -# ───────────────────────────────────────────── echo "Step 1: Updating system packages..." -sudo apt update && sudo apt upgrade -y +sudo apt-get update && sudo apt-get upgrade -y -# ───────────────────────────────────────────── -# Step 2: Install Core Dependencies -# ───────────────────────────────────────────── -echo "Step 2: Installing core dependencies..." -sudo apt install -y \ - git cmake build-essential \ - libjack-jackd2-dev libsamplerate0-dev \ - libasound2-dev libsndfile1-dev \ - python3 python3-pip python3-numpy python3-matplotlib +echo "Step 2: Installing required system packages..." +sudo apt-get install -y git cmake build-essential libjack-jackd2-dev \ + libsndfile1-dev libtool autoconf automake \ + pkg-config libasound2-dev libfftw3-dev \ + python3-full python3-venv python3-pip \ + libltc-dev python3-numpy python3-matplotlib python3-sounddevice -# ───────────────────────────────────────────── -# Step 3: Install Python Audio Libraries -# ───────────────────────────────────────────── -echo "Step 3: Installing Python audio libraries..." -pip3 install sounddevice +echo "Step 3: Cloning libltc and ltc-tools..." +cd /home/hermione +git clone https://github.com/x42/libltc.git +git clone https://github.com/x42/ltc-tools.git -# ───────────────────────────────────────────── -# Step 4: Install Splash Screen -# ───────────────────────────────────────────── -echo "Step 4: Installing custom splash screen..." -sudo cp splash.png /usr/share/plymouth/themes/pix/splash.png - -# ───────────────────────────────────────────── -# Step 5: Build libltc -# ───────────────────────────────────────────── -echo "Step 5: Building libltc (the heart of our time-magic)..." -cd ~ -if [ ! -d "libltc" ]; then - git clone https://github.com/x42/libltc.git -fi +echo "Step 4: Building libltc (the heart of our time-magic)..." cd libltc -cmake . -make +mkdir -p build && cd build +cmake .. +make -j$(nproc) sudo make install sudo ldconfig -# ───────────────────────────────────────────── -# Step 6: Build ltc-tools -# ───────────────────────────────────────────── -echo "Step 6: Building ltc-tools..." -cd ~ -if [ ! -d "ltc-tools" ]; then - git clone https://github.com/x42/ltc-tools.git -fi -cd ltc-tools -make +echo "Step 5: Building ltc-tools..." +cd /home/hermione/ltc-tools +make -j$(nproc) sudo make install -# ───────────────────────────────────────────── -# Step 7: Set Hostname -# ───────────────────────────────────────────── -echo "Step 7: Configuring hostname..." -sudo hostnamectl set-hostname ntp-timeturner +echo "Step 6: Setting splash screen..." +sudo cp /home/hermione/splash.png /usr/share/plymouth/themes/pix/splash.png -# ───────────────────────────────────────────── -# Complete -# ───────────────────────────────────────────── -echo "✨ Installation complete." -echo "System will reboot in 30 seconds unless you press [Enter] to reboot now." -read -t 30 -p "Press [Enter] to reboot now or wait..." input -sudo reboot +echo "Step 7: Making timeturner scripts executable..." +chmod +x /home/hermione/*.py + +echo "Step 8: Setup complete. System will reboot in 30 seconds unless you press Enter..." +echo "Press Ctrl+C or Enter now to cancel automatic reboot." +read -t 30 -p ">> " input && sudo reboot || sudo reboot