Merge pull request #1 from cjfranko/timeturner-and-curses

merging ltc probe and first iteration of timeturner
This commit is contained in:
Chris Frankland-Wright 2025-06-24 22:09:46 +01:00 committed by GitHub
commit fdaad719b0
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
7 changed files with 275 additions and 221 deletions

10
config.json Normal file
View file

@ -0,0 +1,10 @@
{
"ltc_device": "/dev/audio",
"offset": {
"hours": 0,
"minutes": 0,
"seconds": 0,
"milliseconds": 0
},
"frame_rate": 25
}

View file

@ -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()

View file

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

91
ltc_probe.py Normal file
View file

@ -0,0 +1,91 @@
#!/usr/bin/env python3
"""
ltc_probe.py
Advanced LTC-like signal probe using pulse duration clustering
for reliable short/long classification works even with imbalanced timecodes.
"""
import numpy as np
import sounddevice as sd
DURATION = 1.0 # seconds
SAMPLERATE = 48000
CHANNELS = 1
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, long = cluster_durations(durations)
if short is None or long is None:
return None
total = len(durations)
return {
"count": total,
"avg_width_ms": np.mean(durations) * 1000,
"short_pulses": len(short),
"long_pulses": len(long),
"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 10 <= pulse_data["short_pct"] <= 90:
return f"✅ LTC-like bi-phase signal detected ({pulse_data['count']} pulses)"
else:
return f"⚠️ Pulse imbalance suggests non-LTC or noisy signal"
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()
signal = audio.flatten()
edges = detect_rising_edges(signal)
pulse_data = analyze_pulse_durations(edges, SAMPLERATE)
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()

134
setup.sh
View file

@ -1,117 +1,43 @@
#!/bin/bash
set -e
#!/bin/bash
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 ""
# NTP Timeturner Setup Script
# Tested on Debian Bookworm - Raspberry Pi 3
# Author: cjfranko
# ---------------------------------------------------------
# Step 1: Update and upgrade packages
# ---------------------------------------------------------
echo ""
echo "Step 1: Updating package lists..."
sudo apt update
echo "Step 1: Updating system packages..."
sudo apt-get update && sudo apt-get upgrade -y
echo "Upgrading installed packages..."
sudo apt upgrade -y
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 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
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 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 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 ""
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
echo "Step 4: Building libltc (the heart of our time-magic)..."
cd libltc
echo "Preparing libltc build..."
./autogen.sh
./configure
echo "Compiling libltc..."
make
echo "Installing libltc..."
mkdir -p build && cd build
cmake ..
make -j$(nproc)
sudo make install
sudo ldconfig
# ---------------------------------------------------------
# Step 6: Build and install ltc-tools
# ---------------------------------------------------------
echo ""
echo "Step 6: Building ltc-tools (with a gentle nudge)..."
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..."
echo "Step 5: Building ltc-tools..."
cd /home/hermione/ltc-tools
make -j$(nproc)
sudo make install
sudo ldconfig
# ---------------------------------------------------------
# Step 7: Apply Custom Splash Screen
# ---------------------------------------------------------
echo ""
echo "Step 7: Applying splash screen..."
echo "Step 6: Setting splash screen..."
sudo cp /home/hermione/splash.png /usr/share/plymouth/themes/pix/splash.png
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
echo "Step 7: Making timeturner scripts executable..."
chmod +x /home/hermione/*.py
# ---------------------------------------------------------
# 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
sudo reboot
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

36
test_audioinput.py Normal file
View file

@ -0,0 +1,36 @@
#!/usr/bin/env python3
"""
test_audioinput.py
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
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 and save
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.savefig("waveform.png")
print("✅ Waveform saved as 'waveform.png'")

108
timeturner.py Normal file
View file

@ -0,0 +1,108 @@
#!/usr/bin/env python3
"""
timeturner.py
NTP Timeturner Core UI
Displays LTC signal probe info using curses, updated in real-time.
"""
import curses
import threading
import time
import numpy as np
import sounddevice as sd
# --- CONFIGURATION ---
SAMPLERATE = 48000
CHANNELS = 1
PROBE_INTERVAL = 1.0 # seconds
MIN_EDGES = 1000
status = {
"count": 0,
"avg_width_ms": 0.0,
"short_pct": 0.0,
"long_pct": 0.0,
"verdict": "Waiting for signal...",
}
# --- 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 cluster_durations(durations):
if len(durations) < 2:
return None, None
durations = np.array(durations)
mean1, mean2 = np.min(durations), np.max(durations)
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)
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:
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__":
probe_thread = threading.Thread(target=analyze_signal, daemon=True)
probe_thread.start()
curses.wrapper(draw_ui)