From 0281a1cc62433be7682827caada42cf60e2df2d1 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Mon, 18 May 2026 16:45:01 +0100 Subject: [PATCH] Add remote Focusrite calibration capture --- docs/audio-field.md | 8 ++++ scripts/audio_field.py | 106 +++++++++++++++++++++++++++++++++++++++++ state/evidence.jsonl | 1 + 3 files changed, 115 insertions(+) diff --git a/docs/audio-field.md b/docs/audio-field.md index c4982c0..c2ac89e 100644 --- a/docs/audio-field.md +++ b/docs/audio-field.md @@ -126,6 +126,14 @@ After dropping the neighbor Focusrite WAV into the run's `sources/` folder, esti .\.venv\Scripts\python.exe .\scripts\audio_field.py assemble-aligned --profile .\config\audio-field.json --run .\calibration\runs\ ``` +Record the neighbor Focusrite shotgun directly into an existing distributed run over SSH/SFTP: + +```powershell +.\.venv\Scripts\python.exe .\scripts\audio_field.py record-remote-focusrite --profile .\config\audio-field.json --run .\calibration\runs\ --seconds 10 --sample-rate 48000 +``` + +This records `Analogue 1 + 2 (Focusrite USB Audio)` on `192.168.1.84` with remote FFmpeg, pulls it back as `sources/mic_focusrite_neighbor.wav`, and updates the run manifest. Use this for calibration imports; the existing OBS SRT source is still the live monitoring/streaming path. + `analyze-distributed` now does a deadline-grade chirplet refinement by default: it uses the coarse sweep matched-filter peak as the initial time-of-arrival, then searches a small fractional-delay and chirp-rate neighborhood with chirplet atoms. Tune the local search when needed: ```powershell diff --git a/scripts/audio_field.py b/scripts/audio_field.py index e10b17a..b268f85 100644 --- a/scripts/audio_field.py +++ b/scripts/audio_field.py @@ -1,10 +1,12 @@ import argparse import json import math +import subprocess import sys import time from datetime import datetime, timezone from pathlib import Path +from tempfile import NamedTemporaryFile import numpy as np from scipy.io import wavfile @@ -38,6 +40,14 @@ def write_json(path: Path, data) -> None: path.write_text(json.dumps(data, indent=2), encoding="utf-8") +def run_checked(command, *, label: str) -> subprocess.CompletedProcess: + completed = subprocess.run(command, text=True, capture_output=True) + if completed.returncode != 0: + details = "\n".join(part for part in [completed.stdout, completed.stderr] if part) + raise SystemExit(f"{label} failed with exit {completed.returncode}\n{details}") + return completed + + def load_profile(path: Path): profile = read_json(path) validate_profile_shape(profile) @@ -538,6 +548,87 @@ def cmd_record_local_calibration(args): print(out) +def find_profile_mic(profile, mic_id: str): + for mic in profile["microphones"]: + if mic["id"] == mic_id: + return mic + raise SystemExit(f"No microphone {mic_id!r} exists in profile") + + +def upsert_manifest_source(run: Path, source: dict) -> None: + manifest_path = run / "manifest.json" + manifest = read_json(manifest_path) + sources = [item for item in manifest.get("sources", []) if item.get("micId") != source["micId"]] + sources.append(source) + sources.sort(key=lambda item: int(item.get("fieldChannel", 999))) + manifest["sources"] = sources + write_json(manifest_path, manifest) + + +def sftp_get(ssh_target: str, remote_file: str, local_file: Path) -> None: + remote_sftp_path = "/C:/" + remote_file.replace("\\", "/").split("C:/", 1)[-1] + batch = f'get "{remote_sftp_path}" "{local_file}"\n' + with NamedTemporaryFile("w", encoding="ascii", delete=False, suffix=".sftp") as fh: + fh.write(batch) + batch_path = fh.name + try: + run_checked(["sftp", "-b", batch_path, ssh_target], label="SFTP pull") + finally: + Path(batch_path).unlink(missing_ok=True) + + +def cmd_record_remote_focusrite(args): + profile = load_profile(args.profile) + run = args.run + if not (run / "manifest.json").exists(): + raise SystemExit(f"Run manifest does not exist: {run / 'manifest.json'}") + mic = find_profile_mic(profile, args.mic_id) + device_name = args.device or args.dshow_device or "Analogue 1 + 2 (Focusrite USB Audio)" + sample_rate = int(args.sample_rate or profile.get("capturePolicy", {}).get("focusriteCalibrationSampleRate") or profile["sampleRate"]) + seconds = float(args.seconds) + remote_dir = args.remote_dir.rstrip("\\/") + remote_file = f"{remote_dir}\\{args.mic_id}-{utc_stamp().replace(':', '').replace('.', '')}.wav" + local_file = run / "sources" / f"{args.mic_id}.wav" + local_file.parent.mkdir(parents=True, exist_ok=True) + + ffmpeg = args.ffmpeg + remote_cmd = ( + f'cmd /c if not exist "{remote_dir}" mkdir "{remote_dir}" && ' + f'"{ffmpeg}" -y -hide_banner -nostdin -loglevel info ' + f'-f dshow -t {seconds:.3f} -i audio="{device_name}" ' + f'-vn -ac {int(args.channels)} -ar {sample_rate} -c:a pcm_f32le "{remote_file}"' + ) + if args.dry_run: + print(remote_cmd) + return + + print(f"Recording {args.mic_id} on {args.ssh_target} for {seconds:.1f}s...") + run_checked(["ssh", args.ssh_target, remote_cmd], label="Remote Focusrite capture") + sftp_get(args.ssh_target, remote_file, local_file) + if not local_file.exists() or local_file.stat().st_size == 0: + raise SystemExit(f"Remote capture did not produce a usable local file: {local_file}") + + upsert_manifest_source( + run, + { + "micId": mic["id"], + "fieldChannel": mic_channel(mic), + "machine": mic.get("machine"), + "clockDomain": mic.get("clockDomain"), + "file": f"sources/{mic['id']}.wav", + "sampleRate": sample_rate, + "capture": { + "kind": "ssh-ffmpeg-dshow", + "sshTarget": args.ssh_target, + "remoteFile": remote_file, + "device": device_name, + "channels": int(args.channels), + }, + }, + ) + print(local_file) + + def record_loopback(query, seconds, sample_rate, channels=2, play_data=None, play_rate=None, play_device=None): try: import soundcard as sc @@ -1196,6 +1287,21 @@ def main(): p.add_argument("--loopback-channels", type=int, default=2) p.set_defaults(func=cmd_record_local_calibration) + p = sub.add_parser("record-remote-focusrite", help="Record the neighbor Focusrite to a run source WAV over SSH/SFTP.") + p.add_argument("--profile", type=Path, default=ROOT / "config" / "audio-field.example.json") + p.add_argument("--run", type=Path, required=True) + p.add_argument("--mic-id", default="mic_focusrite_neighbor") + p.add_argument("--ssh-target", default="madman's lullaby@192.168.1.84") + p.add_argument("--ffmpeg", default=r"C:\Users\Madman's Lullaby\AppData\Local\Microsoft\WinGet\Links\ffmpeg.exe") + p.add_argument("--remote-dir", default=r"C:\Meta\LocalCastBridge\calibration\remote-captures") + p.add_argument("--device", default="Analogue 1 + 2 (Focusrite USB Audio)") + p.add_argument("--dshow-device", help=argparse.SUPPRESS) + p.add_argument("--seconds", type=float, default=8.0) + p.add_argument("--sample-rate", type=int, default=48000) + p.add_argument("--channels", type=int, default=1) + p.add_argument("--dry-run", action="store_true") + p.set_defaults(func=cmd_record_remote_focusrite) + p = sub.add_parser("play-record", help="Play each speaker calibration sweep while recording all microphones.") p.add_argument("--profile", type=Path, default=ROOT / "config" / "audio-field.example.json") p.add_argument("--output", type=Path) diff --git a/state/evidence.jsonl b/state/evidence.jsonl index afba4f9..b9e3fd0 100644 --- a/state/evidence.jsonl +++ b/state/evidence.jsonl @@ -38,3 +38,4 @@ {"ts": "2026-05-18T15:23:38+00:00", "type": "decision", "status": "ok", "note": "Active sync probes are now an explicit optimizer responsibility: when passive chirplet telemetry leaves any source below confidence threshold, the system may emit bounded extra chirplets in masked windows, maximizing confidence gain per audible intrusion while respecting spacing and level limits."} {"ts": "2026-05-18T15:35:24+00:00", "type": "verification", "status": "partial", "note": "Passive sync test using Scarlett output loopback as ground-truth content: 20s music run with normalized 4s/1s window correlation produced local Focusrite median delay 20115.5 samples/419.07 ms with 17 sample/0.35 ms MAD; PS Eye left 23848 samples/496.83 ms with 17 sample/0.35 ms MAD; Kiyo-left and PS Eye-right were noisier at about 3.9-4.2 ms MAD. Soundcard WASAPI loopback reported discontinuities, so loopback clock quality remains suspect."} {"ts": "2026-05-18T15:38:07+00:00", "type": "verification", "status": "ok", "note": "OBS puppet pass installed win-spout, enabled local obs-websocket without auth, created Spout2 Capture source LocalCastBridge Point Cloud, and verified a nonblack OBS screenshot from the live Spout sender. Zyphos sample/research shifted the deadline sink from point sprites toward compact anisotropic screen brush packets."} +{"ts": "2026-05-18T15:44:14+00:00", "type": "verification", "status": "ok", "note": "Remote Focusrite shotgun capture path verified: SSH to 192.168.1.84 runs remote FFmpeg against Analogue 1 + 2 (Focusrite USB Audio), SFTP pulls a mono 48 kHz float WAV into a distributed calibration run, and the manifest records mic_focusrite_neighbor as field channel 5."}