Add remote Focusrite calibration capture
This commit is contained in:
parent
03330f9476
commit
0281a1cc62
3 changed files with 115 additions and 0 deletions
|
|
@ -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\<run-folder>
|
||||
```
|
||||
|
||||
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\<run-folder> --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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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."}
|
||||
|
|
|
|||
Loading…
Reference in a new issue