Mimir/scripts/audio_field.py
2026-05-18 16:45:01 +01:00

1368 lines
56 KiB
Python

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
from scipy import signal
ROOT = Path(__file__).resolve().parents[1]
RUNS = ROOT / "calibration" / "runs"
def utc_stamp() -> str:
return datetime.now(timezone.utc).strftime("%Y%m%dT%H%M%S.%fZ")
def run_dir(kind: str) -> Path:
for suffix in ["", *[f"-{i}" for i in range(1, 100)]]:
path = RUNS / f"{utc_stamp()}-{kind}{suffix}"
try:
path.mkdir(parents=True, exist_ok=False)
return path
except FileExistsError:
continue
raise RuntimeError(f"Could not create unique audio run directory for {kind}")
def read_json(path: Path):
return json.loads(path.read_text(encoding="utf-8"))
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)
return profile
def validate_profile_shape(profile) -> None:
errors = []
if int(profile.get("sampleRate", 0)) <= 0:
errors.append("sampleRate must be a positive integer")
capture_mode = profile.get("captureMode", "shared-input-device")
if capture_mode not in ("shared-input-device", "distributed-clocks"):
errors.append("captureMode must be 'shared-input-device' or 'distributed-clocks'")
if capture_mode == "distributed-clocks":
clock_model = profile.get("clockModel", {})
if clock_model.get("requiresAlignmentBeforeEncoding") is not True:
errors.append("distributed-clocks profiles must set clockModel.requiresAlignmentBeforeEncoding=true")
capture_policy = profile.get("capturePolicy", {})
if int(capture_policy.get("fieldSampleRate", profile.get("sampleRate", 0))) != int(profile.get("sampleRate", 0)):
errors.append("capturePolicy.fieldSampleRate must match sampleRate for the aligned field")
bus = profile.get("ambisonicBus", {})
if bus.get("order") != 1 or bus.get("channelOrder") != "ACN" or bus.get("normalization") != "SN3D":
errors.append("ambisonicBus must declare first-order ACN/SN3D for this tool")
if bus.get("channels") != ["W", "Y", "Z", "X"]:
errors.append("ambisonicBus.channels must be ['W', 'Y', 'Z', 'X']")
mics = profile.get("microphones", [])
speakers = profile.get("speakers", [])
if len(mics) != 6:
errors.append("profile must declare exactly 6 microphones")
if len(speakers) != 2:
errors.append("profile must declare exactly 2 speakers")
machine_ids = {machine.get("id") for machine in profile.get("machines", [])}
if capture_mode == "distributed-clocks" and not machine_ids:
errors.append("distributed-clocks profiles must declare machines")
seen_mic_channels = set()
seen_clock_domains = set()
for mic in mics:
channel = mic_channel(mic)
if not isinstance(channel, int) or channel < 0:
errors.append(f"microphone {mic.get('id', '<missing>')} has invalid fieldChannel/channel")
if channel in seen_mic_channels:
errors.append(f"duplicate microphone field channel {channel}")
seen_mic_channels.add(channel)
if capture_mode == "distributed-clocks":
if mic.get("machine") not in machine_ids:
errors.append(f"microphone {mic.get('id', '<missing>')} references unknown machine")
if not isinstance(mic.get("device"), dict):
errors.append(f"microphone {mic.get('id', '<missing>')} needs device query/hostApi/channel")
clock_domain = mic.get("clockDomain")
if not clock_domain:
errors.append(f"microphone {mic.get('id', '<missing>')} needs clockDomain")
if clock_domain in seen_clock_domains:
errors.append(f"duplicate microphone clockDomain {clock_domain}")
seen_clock_domains.add(clock_domain)
if len(mic.get("positionMeters", [])) != 3:
errors.append(f"microphone {mic.get('id', '<missing>')} needs positionMeters [x,y,z]")
orientation = mic.get("orientationDeg", {})
if "azimuth" not in orientation or "elevation" not in orientation:
errors.append(f"microphone {mic.get('id', '<missing>')} needs orientationDeg azimuth/elevation")
if mic.get("polarity", 1) not in (-1, 1):
errors.append(f"microphone {mic.get('id', '<missing>')} polarity must be 1 or -1")
if "qualityPriority" in mic and not isinstance(mic.get("qualityPriority"), int):
errors.append(f"microphone {mic.get('id', '<missing>')} qualityPriority must be an integer")
seen_speaker_channels = set()
for speaker in speakers:
channel = speaker.get("channel")
if not isinstance(channel, int) or channel < 0:
errors.append(f"speaker {speaker.get('id', '<missing>')} has invalid channel")
if channel in seen_speaker_channels:
errors.append(f"duplicate speaker channel {channel}")
seen_speaker_channels.add(channel)
if len(speaker.get("positionMeters", [])) != 3:
errors.append(f"speaker {speaker.get('id', '<missing>')} needs positionMeters [x,y,z]")
if speaker.get("polarity", 1) not in (-1, 1):
errors.append(f"speaker {speaker.get('id', '<missing>')} polarity must be 1 or -1")
input_channels = int(profile.get("inputDevice", {}).get("channels", 0))
output_channels = int(profile.get("outputDevice", {}).get("channels", 0))
if capture_mode == "shared-input-device" and input_channels < 6:
errors.append("inputDevice.channels must be at least 6")
if output_channels < 2:
errors.append("outputDevice.channels must be at least 2")
if errors:
raise SystemExit("Invalid audio profile:\n- " + "\n- ".join(errors))
def mic_channel(mic) -> int:
return int(mic.get("fieldChannel", mic.get("channel", -1)))
def sounddevice_module():
try:
import sounddevice as sd
except Exception as exc:
raise SystemExit(f"sounddevice is unavailable: {exc!r}") from exc
return sd
def match_device(sd, spec, direction: str):
hostapis = sd.query_hostapis()
devices = sd.query_devices()
query = (spec.get("query") or "").lower()
hostapi_name = (spec.get("hostApi") or "").lower()
needed_channels = int(spec.get("channels") or 0)
matches = []
for device in devices:
hostapi = hostapis[device["hostapi"]]["name"]
channel_key = "max_input_channels" if direction == "input" else "max_output_channels"
if device[channel_key] < needed_channels:
continue
if query and query not in device["name"].lower():
continue
if hostapi_name and hostapi_name not in hostapi.lower():
continue
matches.append({**device, "hostapi_name": hostapi})
if not matches:
raise SystemExit(f"No {direction} device matches {spec!r}")
return matches[0]
def find_devices(sd, spec, direction: str):
hostapis = sd.query_hostapis()
devices = sd.query_devices()
query = (spec.get("query") or "").lower()
hostapi_name = (spec.get("hostApi") or "").lower()
needed_channels = int(spec.get("channels") or 1)
matches = []
for device in devices:
hostapi = hostapis[device["hostapi"]]["name"]
channel_key = "max_input_channels" if direction == "input" else "max_output_channels"
if device[channel_key] < needed_channels:
continue
if query and query not in device["name"].lower():
continue
if hostapi_name and hostapi_name not in hostapi.lower():
continue
matches.append({**device, "hostapi_name": hostapi})
return matches
def match_indexed_device(sd, spec, direction: str):
matches = find_devices(sd, spec, direction)
match_index = int(spec.get("matchIndex", 0))
if match_index < 0 or match_index >= len(matches):
raise SystemExit(f"No {direction} device match {match_index} for {spec!r}; found {len(matches)}")
return matches[match_index]
def cmd_devices(args):
sd = sounddevice_module()
hostapis = sd.query_hostapis()
rows = []
for dev in sd.query_devices():
rows.append(
{
"index": dev["index"],
"name": dev["name"],
"hostapi": hostapis[dev["hostapi"]]["name"],
"inputs": dev["max_input_channels"],
"outputs": dev["max_output_channels"],
"default_samplerate": dev["default_samplerate"],
}
)
print(json.dumps(rows, indent=2))
def cmd_probe_rates(args):
sd = sounddevice_module()
spec = {
"query": args.device_query,
"hostApi": args.hostapi,
"channels": args.channels,
}
matches = find_devices(sd, spec, args.direction)
results = []
for device in matches:
rate_results = []
for rate in sorted(set(args.rate or [48000, 96000])):
try:
if args.direction == "input":
sd.check_input_settings(
device=device["index"],
channels=args.channels,
samplerate=rate,
dtype=args.dtype,
)
else:
sd.check_output_settings(
device=device["index"],
channels=args.channels,
samplerate=rate,
dtype=args.dtype,
)
rate_results.append({"sampleRate": rate, "ok": True})
except Exception as exc:
rate_results.append({"sampleRate": rate, "ok": False, "error": repr(exc)})
results.append({"device": summarize_device(device), "rates": rate_results})
print(json.dumps({"query": spec, "direction": args.direction, "dtype": args.dtype, "results": results}, indent=2))
def cmd_validate(args):
profile = load_profile(args.profile)
result = {
"profile": str(args.profile),
"sampleRate": profile["sampleRate"],
"captureMode": profile.get("captureMode", "shared-input-device"),
"capturePolicy": profile.get("capturePolicy"),
"microphones": [mic["id"] for mic in profile["microphones"]],
"speakers": [speaker["id"] for speaker in profile["speakers"]],
"ambisonicBus": profile["ambisonicBus"],
"deviceCheck": None,
}
if args.check_devices:
sd = sounddevice_module()
output_device = match_device(sd, profile["outputDevice"], "output")
if profile.get("captureMode", "shared-input-device") == "shared-input-device":
input_device = match_device(sd, profile["inputDevice"], "input")
result["deviceCheck"] = {
"input": summarize_device(input_device),
"output": summarize_device(output_device),
}
else:
result["deviceCheck"] = distributed_device_check(sd, profile, output_device)
print(json.dumps(result, indent=2))
def distributed_device_check(sd, profile, output_device):
local_machine = profile.get("localMachine", "local")
checks = []
for mic in profile["microphones"]:
if mic.get("machine") != local_machine:
checks.append(
{
"micId": mic["id"],
"machine": mic.get("machine"),
"status": "remote-unchecked",
"device": mic.get("device"),
}
)
continue
matches = find_devices(sd, mic.get("device", {}), "input")
checks.append(
{
"micId": mic["id"],
"machine": mic.get("machine"),
"clockDomain": mic.get("clockDomain"),
"status": "ok" if matches else "missing",
"matches": [summarize_device(match) for match in matches],
}
)
return {
"distributedInputs": checks,
"output": summarize_device(output_device),
"alignmentRequiredBeforeFoa": bool(profile.get("clockModel", {}).get("requiresAlignmentBeforeEncoding")),
}
def summarize_device(device):
return {
"index": device["index"],
"name": device["name"],
"hostapi": device["hostapi_name"],
"inputs": device["max_input_channels"],
"outputs": device["max_output_channels"],
"default_samplerate": device["default_samplerate"],
}
def db_to_amp(db: float) -> float:
return 10.0 ** (db / 20.0)
def make_sweep(profile):
calibration = profile["calibration"]
sample_rate = int(profile["sampleRate"])
seconds = float(calibration.get("sweepSeconds", 3.0))
t = np.arange(int(sample_rate * seconds), dtype=np.float64) / sample_rate
sweep = signal.chirp(
t,
f0=float(calibration.get("sweepStartHz", 80.0)),
f1=float(calibration.get("sweepEndHz", 18000.0)),
t1=seconds,
method="logarithmic",
)
fade_len = max(1, int(sample_rate * 0.02))
fade = np.sin(np.linspace(0.0, math.pi / 2.0, fade_len)) ** 2
sweep[:fade_len] *= fade
sweep[-fade_len:] *= fade[::-1]
sweep *= db_to_amp(float(calibration.get("levelDbfs", -18.0)))
return sweep.astype(np.float32)
def cmd_make_stimulus(args):
profile = load_profile(args.profile)
out = args.output or run_dir("audio-stimulus")
out.mkdir(parents=True, exist_ok=True)
sweep = make_sweep(profile)
wavfile.write(out / "calibration-sweep.wav", int(profile["sampleRate"]), sweep)
write_json(
out / "manifest.json",
{
"created_utc": datetime.now(timezone.utc).isoformat(),
"profile": str(args.profile),
"sampleRate": int(profile["sampleRate"]),
"sweep": profile["calibration"],
"file": "calibration-sweep.wav",
},
)
print(out)
def cmd_init_run(args):
profile = load_profile(args.profile)
out = args.output or run_dir("audio-distributed")
out.mkdir(parents=True, exist_ok=True)
(out / "sources").mkdir(exist_ok=True)
sweep = make_sweep(profile)
wavfile.write(out / "calibration-sweep.wav", int(profile["sampleRate"]), sweep)
sources = []
for mic in sorted(profile["microphones"], key=mic_channel):
sources.append(
{
"micId": mic["id"],
"fieldChannel": mic_channel(mic),
"machine": mic.get("machine"),
"clockDomain": mic.get("clockDomain"),
"expectedFile": f"sources/{mic['id']}.wav",
"role": mic.get("role"),
}
)
write_json(
out / "manifest.json",
{
"kind": "distributed-audio-calibration",
"created_utc": datetime.now(timezone.utc).isoformat(),
"profile": str(args.profile),
"sampleRate": int(profile["sampleRate"]),
"stimulus": "calibration-sweep.wav",
"sources": sources,
"notes": "Drop one mono WAV per mic at expectedFile, or use record-local-calibration for local mics.",
},
)
print(out)
def cmd_record_local_calibration(args):
profile = load_profile(args.profile)
sd = sounddevice_module()
out = args.output or run_dir("audio-local-calibration")
out.mkdir(parents=True, exist_ok=True)
(out / "sources").mkdir(exist_ok=True)
input_rate = int(args.input_rate or profile["sampleRate"])
seconds = float(args.seconds)
frames = int(input_rate * seconds)
local_mics = [mic for mic in profile["microphones"] if mic.get("machine") == args.machine]
grouped = local_device_groups(sd, local_mics)
captures = {
key: np.zeros((frames, group["channels"]), dtype=np.float32)
for key, group in grouped.items()
if key != "_missing"
}
positions = {key: 0 for key in captures}
streams = []
def make_callback(key):
def callback(indata, frame_count, time_info, status):
pos = positions[key]
end = min(pos + frame_count, frames)
if end > pos:
captures[key][pos:end, :] = indata[: end - pos, :]
positions[key] = end
return callback
for key, group in grouped.items():
if key == "_missing":
continue
streams.append(
sd.InputStream(
device=group["device"]["index"],
channels=group["channels"],
samplerate=input_rate,
dtype="float32",
callback=make_callback(key),
)
)
stimulus_file = None
loopback_file = None
loopback_rate = None
output_device = None
output_rate = None
if args.play_sweep:
output_device = match_device(sd, profile["outputDevice"], "output")
output_rate = int(args.output_rate or output_device["default_samplerate"] or profile["sampleRate"])
sweep = make_sweep_at_rate(profile, output_rate)
playback = np.zeros((len(sweep), int(profile["outputDevice"]["channels"])), dtype=np.float32)
speaker_channel = int(args.speaker_channel)
playback[:, speaker_channel] = sweep
stimulus_file = "calibration-sweep-played.wav"
wavfile.write(out / stimulus_file, output_rate, sweep)
try:
for stream in streams:
stream.start()
if args.record_loopback:
loopback_rate = int(args.loopback_rate or input_rate)
loopback = record_loopback(
query=args.loopback_query,
seconds=seconds,
sample_rate=loopback_rate,
channels=args.loopback_channels,
play_data=playback if args.play_sweep else None,
play_rate=output_rate,
play_device=output_device["index"] if output_device else None,
)
loopback_file = "ground_truth_loopback.wav"
wavfile.write(out / loopback_file, loopback_rate, loopback.astype(np.float32))
elif args.play_sweep:
sd.play(playback, samplerate=output_rate, device=output_device["index"], blocking=True)
remaining = max(0.0, seconds - len(playback) / output_rate)
time.sleep(remaining)
else:
time.sleep(seconds)
finally:
for stream in streams:
try:
stream.stop()
stream.close()
except Exception:
pass
sources = []
for missing in grouped.get("_missing", []):
sources.append(
{
"micId": missing["micId"],
"fieldChannel": missing["fieldChannel"],
"machine": args.machine,
"status": "missing-device",
"error": missing["error"],
}
)
for mic in sorted(local_mics, key=mic_channel):
if any(missing["micId"] == mic["id"] for missing in grouped.get("_missing", [])):
continue
group_key = device_group_key(grouped, mic)
data = captures[group_key][:, int(mic.get("device", {}).get("channel", 0))]
file_name = f"sources/{mic['id']}.wav"
wavfile.write(out / file_name, input_rate, data.astype(np.float32))
sources.append(
{
"micId": mic["id"],
"fieldChannel": mic_channel(mic),
"machine": mic.get("machine"),
"clockDomain": mic.get("clockDomain"),
"file": file_name,
"sampleRate": input_rate,
}
)
if stimulus_file is None:
sweep = make_sweep(profile)
stimulus_file = "calibration-sweep-reference.wav"
wavfile.write(out / stimulus_file, int(profile["sampleRate"]), sweep)
write_json(
out / "manifest.json",
{
"kind": "distributed-audio-calibration",
"created_utc": datetime.now(timezone.utc).isoformat(),
"profile": str(args.profile),
"sampleRate": int(profile["sampleRate"]),
"inputSampleRate": input_rate,
"stimulus": stimulus_file,
"sources": sources,
"playback": {
"played": bool(args.play_sweep),
"speakerChannel": int(args.speaker_channel) if args.play_sweep else None,
"outputDevice": summarize_device(output_device) if output_device else None,
"outputSampleRate": output_rate,
},
"reference": {
"kind": "wasapi-loopback",
"file": loopback_file,
"sampleRate": loopback_rate,
"query": args.loopback_query if loopback_file else None,
},
},
)
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
except Exception as exc:
raise SystemExit(f"soundcard loopback capture unavailable: {exc!r}") from exc
matches = [
mic
for mic in sc.all_microphones(include_loopback=True)
if "loopback" in repr(mic).lower() and query.lower() in mic.name.lower()
]
if not matches:
raise SystemExit(f"No loopback device matching {query!r}")
loopback = matches[0]
frame_count = int(seconds * sample_rate)
if play_data is not None:
sd = sounddevice_module()
sd.play(play_data, samplerate=play_rate, device=play_device, blocking=False)
with loopback.recorder(samplerate=sample_rate, channels=channels) as recorder:
data = recorder.record(numframes=frame_count)
if play_data is not None:
try:
sounddevice_module().stop()
except Exception:
pass
return np.asarray(data, dtype=np.float32)
def make_sweep_at_rate(profile, sample_rate):
clone = dict(profile)
clone["sampleRate"] = int(sample_rate)
return make_sweep(clone)
def local_device_groups(sd, mics):
groups = {"_missing": []}
for mic in mics:
try:
device = match_indexed_device(sd, mic.get("device", {}), "input")
except SystemExit as exc:
groups["_missing"].append(
{
"micId": mic["id"],
"fieldChannel": mic_channel(mic),
"error": str(exc),
}
)
continue
channel = int(mic.get("device", {}).get("channel", 0))
key = str(device["index"])
if key not in groups:
groups[key] = {"device": device, "mics": [], "channels": channel + 1}
groups[key]["mics"].append(mic)
groups[key]["channels"] = max(groups[key]["channels"], channel + 1)
return groups
def device_group_key(groups, mic):
for key, group in groups.items():
if key == "_missing":
continue
if any(item["id"] == mic["id"] for item in group["mics"]):
return key
raise KeyError(mic["id"])
def cmd_play_record(args):
profile = load_profile(args.profile)
if profile.get("captureMode", "shared-input-device") == "distributed-clocks":
raise SystemExit(
"play-record only works for shared-input-device profiles. "
"For distributed clocks, play the shared calibration stimulus and record each mic source "
"with its own capture process, then align the takes before FOA encoding."
)
sd = sounddevice_module()
input_device = match_device(sd, profile["inputDevice"], "input")
output_device = match_device(sd, profile["outputDevice"], "output")
sample_rate = int(profile["sampleRate"])
input_channels = int(profile["inputDevice"]["channels"])
output_channels = int(profile["outputDevice"]["channels"])
sweep = make_sweep(profile)
tail = int(sample_rate * float(profile["calibration"].get("recordTailSeconds", 1.0)))
silence = np.zeros(tail, dtype=np.float32)
out = args.output or run_dir("audio-calibration")
out.mkdir(parents=True, exist_ok=True)
records = []
for speaker in profile["speakers"]:
playback = np.zeros((len(sweep) + tail, output_channels), dtype=np.float32)
playback[: len(sweep), int(speaker["channel"])] = sweep
recorded = sd.playrec(
playback,
samplerate=sample_rate,
channels=input_channels,
dtype="float32",
device=(input_device["index"], output_device["index"]),
blocking=True,
)
stem = f"{speaker['id']}-return.wav"
wavfile.write(out / stem, sample_rate, recorded.astype(np.float32))
records.append({"speakerId": speaker["id"], "file": stem})
wavfile.write(out / "calibration-sweep.wav", sample_rate, sweep)
write_json(
out / "manifest.json",
{
"created_utc": datetime.now(timezone.utc).isoformat(),
"profile": str(args.profile),
"inputDevice": summarize_device(input_device),
"outputDevice": summarize_device(output_device),
"records": records,
"stimulus": "calibration-sweep.wav",
"unusedSilenceSamples": int(len(silence)),
},
)
print(out)
def cmd_analyze_calibration(args):
profile = load_profile(args.profile)
run = args.run
manifest = read_json(run / "manifest.json")
sample_rate, sweep = wavfile.read(run / manifest["stimulus"])
sweep = as_float_matrix(sweep).reshape(-1)
output = {
"created_utc": datetime.now(timezone.utc).isoformat(),
"sourceRun": str(run),
"sampleRate": int(sample_rate),
"paths": [],
}
for record in manifest["records"]:
rec_rate, recorded = wavfile.read(run / record["file"])
if rec_rate != sample_rate:
raise SystemExit(f"Sample-rate mismatch in {record['file']}: {rec_rate} != {sample_rate}")
recorded = as_float_matrix(recorded)
for mic in profile["microphones"]:
channel = mic_channel(mic)
if channel >= recorded.shape[1]:
continue
delay, peak, polarity = estimate_delay(recorded[:, channel], sweep)
output["paths"].append(
{
"speakerId": record["speakerId"],
"micId": mic["id"],
"delaySamples": int(delay),
"delayMs": 1000.0 * delay / sample_rate,
"peak": float(abs(peak)),
"polarity": int(polarity),
}
)
write_json(run / "analysis.json", output)
print(json.dumps(output, indent=2))
def cmd_analyze_distributed(args):
profile = load_profile(args.profile)
run = args.run
manifest = read_json(run / "manifest.json")
stim_rate, stimulus = wavfile.read(run / manifest["stimulus"])
stimulus = as_float_matrix(stimulus).reshape(-1)
results = {
"created_utc": datetime.now(timezone.utc).isoformat(),
"sourceRun": str(run),
"referenceMicId": args.reference_mic or profile.get("clockModel", {}).get("referenceMicId"),
"sources": [],
}
detections = {}
for source in manifest.get("sources", []):
file_name = source.get("file") or source.get("expectedFile")
if not file_name or not (run / file_name).exists():
results["sources"].append({"micId": source.get("micId"), "status": "missing", "file": file_name})
continue
rec_rate, recorded = wavfile.read(run / file_name)
recorded = as_float_matrix(recorded).reshape(-1)
stim_for_rate = resample_if_needed(stimulus, int(stim_rate), int(rec_rate))
delay, peak, polarity = estimate_delay(recorded, stim_for_rate)
refined = None
if args.chirplet_refine:
refined = refine_chirplet_delay(
recorded,
stim_for_rate,
delay,
search_samples=args.search_samples,
fractional_steps=args.fractional_steps,
rate_ppm=args.rate_ppm,
)
rms = float(np.sqrt(np.mean(recorded * recorded))) if recorded.size else 0.0
item = {
"micId": source["micId"],
"fieldChannel": source.get("fieldChannel"),
"status": "ok",
"file": file_name,
"sampleRate": int(rec_rate),
"sweepStartSample": int(delay),
"sweepStartMs": 1000.0 * delay / rec_rate,
"peak": float(abs(peak)),
"polarity": int(polarity),
"rms": rms,
}
if refined:
item["chirpletDelaySamples"] = refined["delaySamples"]
item["chirpletDelayMs"] = 1000.0 * refined["delaySamples"] / rec_rate
item["chirpletScore"] = refined["score"]
item["chirpletRateScale"] = refined["rateScale"]
results["sources"].append(item)
detections[source["micId"]] = item
reference = detections.get(results["referenceMicId"])
if reference:
ref_delay = reference.get("chirpletDelaySamples", reference["sweepStartSample"])
ref_time = ref_delay / reference["sampleRate"]
for item in results["sources"]:
if item.get("status") != "ok":
continue
item_delay = item.get("chirpletDelaySamples", item["sweepStartSample"])
item_time = item_delay / item["sampleRate"]
item["relativeDelaySeconds"] = item_time - ref_time
item["relativeDelaySamplesAtFieldRate"] = int(round((item_time - ref_time) * int(profile["sampleRate"])))
write_json(run / "distributed-analysis.json", results)
print(json.dumps(results, indent=2))
def cmd_assemble_aligned(args):
profile = load_profile(args.profile)
run = args.run
manifest = read_json(run / "manifest.json")
analysis_path = args.analysis or run / "distributed-analysis.json"
analysis = read_json(analysis_path) if analysis_path.exists() else {"sources": []}
field_rate = int(profile["sampleRate"])
seconds = args.seconds
source_by_mic = {item.get("micId"): item for item in manifest.get("sources", [])}
analysis_by_mic = {item.get("micId"): item for item in analysis.get("sources", []) if item.get("status") == "ok"}
channels = []
report = []
max_len = 0
for mic in sorted(profile["microphones"], key=mic_channel):
source = source_by_mic.get(mic["id"])
if not source:
channels.append(None)
report.append({"micId": mic["id"], "status": "missing-source-entry"})
continue
file_name = source.get("file") or source.get("expectedFile")
if not file_name or not (run / file_name).exists():
channels.append(None)
report.append({"micId": mic["id"], "status": "missing-file", "file": file_name})
continue
rec_rate, data = wavfile.read(run / file_name)
mono = as_float_matrix(data).reshape(-1)
mono = resample_if_needed(mono, int(rec_rate), field_rate)
delay = int(mic.get("delaySamples", 0))
if mic["id"] in analysis_by_mic:
delay += int(analysis_by_mic[mic["id"]].get("relativeDelaySamplesAtFieldRate", 0))
aligned = apply_integer_delay(mono, -delay)
aligned *= db_to_amp(float(mic.get("gainDb", 0.0))) * int(mic.get("polarity", 1))
channels.append(aligned)
max_len = max(max_len, len(aligned))
report.append({"micId": mic["id"], "status": "ok", "file": file_name, "appliedDelaySamples": delay})
if seconds:
frame_count = int(float(seconds) * field_rate)
else:
frame_count = max_len
field = np.zeros((frame_count, len(channels)), dtype=np.float32)
for index, channel in enumerate(channels):
if channel is None:
continue
count = min(frame_count, len(channel))
field[:count, index] = channel[:count]
output = args.output or run / "field-aligned.wav"
wavfile.write(output, field_rate, field)
result = {"output": str(output), "sampleRate": field_rate, "shape": list(field.shape), "sources": report}
write_json(run / "aligned-field-manifest.json", result)
print(json.dumps(result, indent=2))
def cmd_analyze_reference_sync(args):
profile = load_profile(args.profile)
run = args.run
manifest = read_json(run / "manifest.json")
reference_info = manifest.get("reference") or {}
reference_file = args.reference or (run / reference_info.get("file", ""))
if not reference_file.exists():
raise SystemExit(f"Reference loopback WAV not found: {reference_file}")
ref_rate, reference = wavfile.read(reference_file)
reference = mono(as_float_matrix(reference))
reference = resample_if_needed(reference, int(ref_rate), int(profile["sampleRate"]))
field_rate = int(profile["sampleRate"])
window = int(args.window_seconds * field_rate)
hop = int(args.hop_seconds * field_rate)
max_lag = int(args.max_lag_ms * field_rate / 1000.0)
results = {
"created_utc": datetime.now(timezone.utc).isoformat(),
"sourceRun": str(run),
"reference": str(reference_file),
"sampleRate": field_rate,
"windowSeconds": args.window_seconds,
"hopSeconds": args.hop_seconds,
"sources": [],
}
for source in manifest.get("sources", []):
file_name = source.get("file")
if not file_name or not (run / file_name).exists():
continue
rec_rate, data = wavfile.read(run / file_name)
mic = resample_if_needed(mono(as_float_matrix(data)), int(rec_rate), field_rate)
delays = []
scores = []
for start in range(0, min(len(reference), len(mic)) - window + 1, hop):
ref_win = reference[start : start + window]
mic_win = mic[start : start + window]
if float(np.std(ref_win)) < args.min_reference_std or float(np.std(mic_win)) < args.min_mic_std:
continue
if args.method == "gcc-phat":
delay, score = gcc_phat_delay(mic_win, ref_win, max_lag=max_lag)
else:
delay, score = normalized_delay(mic_win, ref_win, max_lag=max_lag)
if score < args.min_score or abs(delay) < int(args.min_abs_lag_ms * field_rate / 1000.0):
continue
delays.append(delay)
scores.append(score)
if not delays:
continue
delays = np.asarray(delays, dtype=np.float64)
scores = np.asarray(scores, dtype=np.float64)
median = float(np.median(delays))
mad = float(np.median(np.abs(delays - median)))
results["sources"].append(
{
"micId": source["micId"],
"fieldChannel": source.get("fieldChannel"),
"windows": int(len(delays)),
"medianDelaySamples": median,
"medianDelayMs": 1000.0 * median / field_rate,
"madSamples": mad,
"madMs": 1000.0 * mad / field_rate,
"meanScore": float(np.mean(scores)),
"maxScore": float(np.max(scores)),
"method": args.method,
}
)
write_json(run / "reference-sync-analysis.json", results)
print(json.dumps(results, indent=2))
def mono(matrix):
if matrix.ndim == 1 or matrix.shape[1] == 1:
return matrix.reshape(-1).astype(np.float32)
return np.mean(matrix, axis=1).astype(np.float32)
def gcc_phat_delay(signal_a, signal_b, max_lag):
a = np.asarray(signal_a, dtype=np.float32)
b = np.asarray(signal_b, dtype=np.float32)
a = a - float(np.mean(a))
b = b - float(np.mean(b))
n = 1
while n < len(a) + len(b):
n *= 2
A = np.fft.rfft(a, n=n)
B = np.fft.rfft(b, n=n)
R = A * np.conj(B)
denom = np.abs(R)
R = np.divide(R, denom, out=np.zeros_like(R), where=denom > 1e-12)
corr = np.fft.irfft(R, n=n)
corr = np.concatenate((corr[-max_lag:], corr[: max_lag + 1]))
index = int(np.argmax(np.abs(corr)))
lag = index - max_lag
score = float(abs(corr[index]))
return lag, score
def normalized_delay(signal_a, signal_b, max_lag):
a = np.asarray(signal_a, dtype=np.float32)
b = np.asarray(signal_b, dtype=np.float32)
a = (a - float(np.mean(a))) / (float(np.std(a)) + 1e-9)
b = (b - float(np.mean(b))) / (float(np.std(b)) + 1e-9)
corr = signal.correlate(a, b, mode="full", method="fft")
mid = len(b) - 1
segment = corr[mid - max_lag : mid + max_lag + 1]
index = int(np.argmax(np.abs(segment)))
lag = index - max_lag
score = float(abs(segment[index]) / max(1, len(a)))
return lag, score
def as_float_matrix(data):
arr = np.asarray(data)
if arr.ndim == 1:
arr = arr[:, None]
if np.issubdtype(arr.dtype, np.integer):
arr = arr.astype(np.float32) / np.iinfo(arr.dtype).max
else:
arr = arr.astype(np.float32)
return arr
def resample_if_needed(samples, source_rate, target_rate):
if int(source_rate) == int(target_rate):
return samples.astype(np.float32)
gcd = math.gcd(int(source_rate), int(target_rate))
up = int(target_rate) // gcd
down = int(source_rate) // gcd
return signal.resample_poly(samples.astype(np.float32), up, down).astype(np.float32)
def estimate_delay(recorded, sweep):
corr = signal.fftconvolve(recorded.astype(np.float32), sweep[::-1].astype(np.float32), mode="full")
peak_index = int(np.argmax(np.abs(corr)))
delay = peak_index - (len(sweep) - 1)
peak = float(corr[peak_index])
polarity = 1 if peak >= 0 else -1
return delay, peak, polarity
def refine_chirplet_delay(recorded, sweep, coarse_delay, search_samples=3, fractional_steps=8, rate_ppm=150):
recorded = np.asarray(recorded, dtype=np.float32).reshape(-1)
sweep = np.asarray(sweep, dtype=np.float32).reshape(-1)
if recorded.size == 0 or sweep.size == 0:
return None
rate_scales = [1.0]
if rate_ppm:
delta = float(rate_ppm) / 1_000_000.0
rate_scales = [1.0 - delta, 1.0, 1.0 + delta]
best = None
start = int(round(coarse_delay)) - int(search_samples)
stop = int(round(coarse_delay)) + int(search_samples)
fractions = np.arange(max(1, int(fractional_steps)), dtype=np.float64) / max(1, int(fractional_steps))
for rate_scale in rate_scales:
atom = chirplet_atom(sweep, rate_scale)
atom_norm = float(np.linalg.norm(atom))
if atom_norm <= 0:
continue
for integer_delay in range(start, stop + 1):
for frac in fractions:
delay = float(integer_delay) + float(frac)
segment = sample_fractional_window(recorded, delay, len(atom))
segment_norm = float(np.linalg.norm(segment))
if segment_norm <= 0:
continue
score_signed = float(np.dot(segment, atom) / (segment_norm * atom_norm))
score = abs(score_signed)
if best is None or score > best["score"]:
best = {
"delaySamples": delay,
"score": score,
"signedScore": score_signed,
"rateScale": rate_scale,
}
return best
def chirplet_atom(sweep, rate_scale):
if rate_scale == 1.0:
atom = sweep.astype(np.float32)
else:
x = np.arange(len(sweep), dtype=np.float64) * rate_scale
atom = np.interp(x, np.arange(len(sweep), dtype=np.float64), sweep, left=0.0, right=0.0).astype(np.float32)
atom = atom - float(np.mean(atom))
norm = float(np.linalg.norm(atom))
return atom / norm if norm > 0 else atom
def sample_fractional_window(samples, start, frame_count):
x = start + np.arange(frame_count, dtype=np.float64)
return np.interp(x, np.arange(len(samples), dtype=np.float64), samples, left=0.0, right=0.0).astype(np.float32)
def cmd_record_field(args):
profile = load_profile(args.profile)
if profile.get("captureMode", "shared-input-device") == "distributed-clocks":
raise SystemExit(
"record-field only works for shared-input-device profiles. "
"Distributed camera/Focusrite microphones must be captured per clock domain, "
"aligned/resampled into a six-channel WAV, then passed to encode-foa."
)
sd = sounddevice_module()
input_device = match_device(sd, profile["inputDevice"], "input")
sample_rate = int(profile["sampleRate"])
channels = int(profile["inputDevice"]["channels"])
frames = int(float(args.seconds) * sample_rate)
out = args.output or run_dir("audio-field-record")
out.mkdir(parents=True, exist_ok=True)
rec = sd.rec(
frames,
samplerate=sample_rate,
channels=channels,
dtype="float32",
device=input_device["index"],
blocking=True,
)
wavfile.write(out / "field-raw.wav", sample_rate, rec.astype(np.float32))
write_json(
out / "manifest.json",
{
"created_utc": datetime.now(timezone.utc).isoformat(),
"profile": str(args.profile),
"inputDevice": summarize_device(input_device),
"seconds": float(args.seconds),
"file": "field-raw.wav",
},
)
print(out)
def cmd_encode_foa(args):
profile = load_profile(args.profile)
sample_rate, data = wavfile.read(args.input)
if int(sample_rate) != int(profile["sampleRate"]):
raise SystemExit(f"Input sample-rate {sample_rate} does not match profile {profile['sampleRate']}")
data = as_float_matrix(data)
foa = encode_foa(profile, data)
peak = float(np.max(np.abs(foa))) if foa.size else 0.0
if peak > 0.98:
foa = foa / peak * 0.98
wavfile.write(args.output, sample_rate, foa.astype(np.float32))
print(json.dumps({"output": str(args.output), "sampleRate": int(sample_rate), "channels": ["W", "Y", "Z", "X"], "peakBeforeLimit": peak}, indent=2))
def cmd_sync_plan(args):
profile = load_profile(args.profile)
groups = {}
for mic in profile["microphones"]:
groups.setdefault(mic.get("clockDomain", "shared"), []).append(mic["id"])
anchors = sorted(
[
{
"micId": mic["id"],
"label": mic.get("label"),
"role": mic.get("role"),
"qualityPriority": mic.get("qualityPriority", 0),
"machine": mic.get("machine"),
"clockDomain": mic.get("clockDomain"),
"attachedMic": mic.get("attachedMic"),
"preferredSampleRates": mic.get("device", {}).get("preferredSampleRates"),
}
for mic in profile["microphones"]
],
key=lambda item: item["qualityPriority"],
reverse=True,
)
plan = {
"profile": str(args.profile),
"captureMode": profile.get("captureMode", "shared-input-device"),
"capturePolicy": profile.get("capturePolicy", {}),
"clockModel": profile.get("clockModel", {}),
"referenceMicId": profile.get("clockModel", {}).get("referenceMicId") or profile.get("calibration", {}).get("referenceMicId"),
"priorityMics": anchors,
"clockDomains": [{"id": key, "microphones": value} for key, value in sorted(groups.items())],
"requiredBeforeFoa": [
"drive each Focusrite through the best available native/exclusive driver path and capture lossless float/PCM for calibration",
"capture each clock domain with source timestamps and nominal sample rate",
"estimate initial delay from speaker calibration pulse/sweep against the reference mic",
"estimate sampling-rate offset over time against the reference domain",
"resample each non-reference stream into the reference timeline",
"write one aligned six-channel WAV ordered by microphone fieldChannel",
"run encode-foa only on that aligned WAV",
],
}
print(json.dumps(plan, indent=2))
def encode_foa(profile, data):
frames = data.shape[0]
corrected = []
weights = []
for mic in sorted(profile["microphones"], key=mic_channel):
channel = mic_channel(mic)
if channel >= data.shape[1]:
raise SystemExit(f"Input has {data.shape[1]} channels, missing microphone channel {channel}")
x = data[:, channel].copy()
x = apply_integer_delay(x, int(mic.get("delaySamples", 0)))
x *= db_to_amp(float(mic.get("gainDb", 0.0)))
x *= int(mic.get("polarity", 1))
corrected.append(x)
weights.append(foa_weights(mic))
mic_matrix = np.stack(corrected, axis=1)
weight_matrix = np.stack(weights, axis=0)
foa = mic_matrix @ weight_matrix
return foa / max(1, len(corrected))
def apply_integer_delay(x, delay):
if delay == 0:
return x
y = np.zeros_like(x)
if delay > 0:
y[delay:] = x[:-delay]
else:
y[:delay] = x[-delay:]
return y
def foa_weights(mic):
orientation = mic["orientationDeg"]
az = math.radians(float(orientation["azimuth"]))
el = math.radians(float(orientation["elevation"]))
ce = math.cos(el)
# ACN/SN3D first order: W, Y, Z, X.
return np.array(
[
1.0 / math.sqrt(2.0),
math.sin(az) * ce,
math.sin(el),
math.cos(az) * ce,
],
dtype=np.float32,
)
def main():
parser = argparse.ArgumentParser(description="Build and calibrate the LocalCastBridge six-mic Ambisonic audio field.")
sub = parser.add_subparsers(required=True)
p = sub.add_parser("devices", help="List PortAudio devices as JSON.")
p.set_defaults(func=cmd_devices)
p = sub.add_parser("probe-rates", help="Check whether matching devices accept selected sample rates.")
p.add_argument("--device-query", required=True)
p.add_argument("--hostapi", default="")
p.add_argument("--direction", choices=["input", "output"], default="input")
p.add_argument("--channels", type=int, default=1)
p.add_argument("--dtype", default="float32")
p.add_argument("--rate", type=int, action="append")
p.set_defaults(func=cmd_probe_rates)
p = sub.add_parser("validate", help="Validate an audio field profile.")
p.add_argument("--profile", type=Path, default=ROOT / "config" / "audio-field.example.json")
p.add_argument("--check-devices", action="store_true")
p.set_defaults(func=cmd_validate)
p = sub.add_parser("make-stimulus", help="Generate the calibration sweep WAV.")
p.add_argument("--profile", type=Path, default=ROOT / "config" / "audio-field.example.json")
p.add_argument("--output", type=Path)
p.set_defaults(func=cmd_make_stimulus)
p = sub.add_parser("init-run", help="Create a distributed calibration run folder and manifest.")
p.add_argument("--profile", type=Path, default=ROOT / "config" / "audio-field.example.json")
p.add_argument("--output", type=Path)
p.set_defaults(func=cmd_init_run)
p = sub.add_parser("record-local-calibration", help="Record local distributed mic sources, optionally while playing one speaker sweep.")
p.add_argument("--profile", type=Path, default=ROOT / "config" / "audio-field.example.json")
p.add_argument("--output", type=Path)
p.add_argument("--machine", default="local")
p.add_argument("--seconds", type=float, default=8.0)
p.add_argument("--input-rate", type=int)
p.add_argument("--play-sweep", action="store_true")
p.add_argument("--speaker-channel", type=int, default=0)
p.add_argument("--output-rate", type=int)
p.add_argument("--record-loopback", action="store_true")
p.add_argument("--loopback-query", default="Scarlett")
p.add_argument("--loopback-rate", type=int)
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)
p.set_defaults(func=cmd_play_record)
p = sub.add_parser("sync-plan", help="Summarize clock domains and the required alignment path before FOA encoding.")
p.add_argument("--profile", type=Path, default=ROOT / "config" / "audio-field.example.json")
p.set_defaults(func=cmd_sync_plan)
p = sub.add_parser("analyze-calibration", help="Estimate speaker-to-mic delay/gain/polarity from a calibration run.")
p.add_argument("--profile", type=Path, default=ROOT / "config" / "audio-field.example.json")
p.add_argument("--run", type=Path, required=True)
p.set_defaults(func=cmd_analyze_calibration)
p = sub.add_parser("analyze-distributed", help="Detect calibration sweep arrival in one WAV per mic and estimate relative delays.")
p.add_argument("--profile", type=Path, default=ROOT / "config" / "audio-field.example.json")
p.add_argument("--run", type=Path, required=True)
p.add_argument("--reference-mic")
p.add_argument("--chirplet-refine", action=argparse.BooleanOptionalAction, default=True)
p.add_argument("--search-samples", type=int, default=3)
p.add_argument("--fractional-steps", type=int, default=8)
p.add_argument("--rate-ppm", type=int, default=150)
p.set_defaults(func=cmd_analyze_distributed)
p = sub.add_parser("assemble-aligned", help="Assemble a distributed run into one aligned six-channel WAV.")
p.add_argument("--profile", type=Path, default=ROOT / "config" / "audio-field.example.json")
p.add_argument("--run", type=Path, required=True)
p.add_argument("--analysis", type=Path)
p.add_argument("--output", type=Path)
p.add_argument("--seconds", type=float)
p.set_defaults(func=cmd_assemble_aligned)
p = sub.add_parser("analyze-reference-sync", help="Estimate live mic delay stability against captured output loopback ground truth.")
p.add_argument("--profile", type=Path, default=ROOT / "config" / "audio-field.example.json")
p.add_argument("--run", type=Path, required=True)
p.add_argument("--reference", type=Path)
p.add_argument("--window-seconds", type=float, default=2.0)
p.add_argument("--hop-seconds", type=float, default=1.0)
p.add_argument("--max-lag-ms", type=float, default=500.0)
p.add_argument("--method", choices=["normalized", "gcc-phat"], default="normalized")
p.add_argument("--min-score", type=float, default=0.08)
p.add_argument("--min-reference-std", type=float, default=1e-4)
p.add_argument("--min-mic-std", type=float, default=1e-5)
p.add_argument("--min-abs-lag-ms", type=float, default=0.5)
p.set_defaults(func=cmd_analyze_reference_sync)
p = sub.add_parser("record-field", help="Record the raw synchronized six-channel microphone field.")
p.add_argument("--profile", type=Path, default=ROOT / "config" / "audio-field.example.json")
p.add_argument("--seconds", type=float, default=10.0)
p.add_argument("--output", type=Path)
p.set_defaults(func=cmd_record_field)
p = sub.add_parser("encode-foa", help="Encode a calibrated six-channel WAV into first-order AmbiX B-format.")
p.add_argument("--profile", type=Path, default=ROOT / "config" / "audio-field.example.json")
p.add_argument("--input", type=Path, required=True)
p.add_argument("--output", type=Path, required=True)
p.set_defaults(func=cmd_encode_foa)
args = parser.parse_args()
args.func(args)
if __name__ == "__main__":
main()