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

2238 lines
94 KiB
Python

import argparse
import importlib.util
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 load_phase_fit_module():
spec = importlib.util.spec_from_file_location("localcast_phase_fit", ROOT / "audio_field" / "phase_fit.py")
module = importlib.util.module_from_spec(spec)
assert spec.loader is not None
sys.modules[spec.name] = module
spec.loader.exec_module(module)
return module
_phase_fit = load_phase_fit_module()
IterativeFrequencyPhaseMapper = _phase_fit.IterativeFrequencyPhaseMapper
SmoothPhaseField = _phase_fit.SmoothPhaseField
estimate_phase_delay = _phase_fit.estimate_phase_delay
def load_room_suppression_module():
spec = importlib.util.spec_from_file_location("localcast_room_suppression", ROOT / "audio_field" / "room_suppression.py")
module = importlib.util.module_from_spec(spec)
assert spec.loader is not None
sys.modules[spec.name] = module
spec.loader.exec_module(module)
return module
_room_suppression = load_room_suppression_module()
RoomSuppressionConfig = _room_suppression.RoomSuppressionConfig
suppress_room_field = _room_suppression.suppress_room_field
def load_program_reference_module():
spec = importlib.util.spec_from_file_location("localcast_program_reference", ROOT / "audio_field" / "program_reference.py")
module = importlib.util.module_from_spec(spec)
assert spec.loader is not None
sys.modules[spec.name] = module
spec.loader.exec_module(module)
return module
_program_reference = load_program_reference_module()
suppress_program_reference = _program_reference.suppress_program_reference
def load_source_events_module():
spec = importlib.util.spec_from_file_location("localcast_source_events", ROOT / "audio_field" / "source_events.py")
module = importlib.util.module_from_spec(spec)
assert spec.loader is not None
sys.modules[spec.name] = module
spec.loader.exec_module(module)
return module
_source_events = load_source_events_module()
analyze_source_field = _source_events.analyze_source_field
def load_cultcache_audio_module():
spec = importlib.util.spec_from_file_location("localcast_cultcache_audio", ROOT / "audio_field" / "cultcache_audio.py")
module = importlib.util.module_from_spec(spec)
assert spec.loader is not None
sys.modules[spec.name] = module
spec.loader.exec_module(module)
return module
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 cmd_record_probe_train(args):
profile = load_profile(args.profile)
sd = sounddevice_module()
out = args.output or run_dir("audio-probe-train")
out.mkdir(parents=True, exist_ok=True)
(out / "sources").mkdir(exist_ok=True)
input_rate = int(args.input_rate or profile["sampleRate"])
output_device = match_device(sd, profile["outputDevice"], "output")
output_rate = int(args.output_rate or output_device["default_samplerate"] or profile["sampleRate"])
playback, chirp, events = make_probe_train(
profile,
output_rate,
seconds=float(args.seconds),
chirp_seconds=float(args.chirp_seconds),
interval_seconds=float(args.interval_seconds),
channels=int(profile["outputDevice"]["channels"]),
start_padding_seconds=float(args.start_padding_seconds),
chirps_per_second=int(args.chirps_per_second),
bands=parse_probe_bands(args.probe_band),
level_db_offset=float(args.probe_level_offset_db),
)
wavfile.write(out / "probe-train-played.wav", output_rate, playback.astype(np.float32))
wavfile.write(out / "probe-chirplet.wav", output_rate, chirp.astype(np.float32))
frames = int(input_rate * float(args.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}
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
streams = []
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),
)
)
loopback_file = None
try:
for stream in streams:
stream.start()
loopback = record_loopback(
query=args.loopback_query,
seconds=float(args.seconds),
sample_rate=int(args.loopback_rate or output_rate),
channels=args.loopback_channels,
play_data=playback,
play_rate=output_rate,
play_device=output_device["index"],
)
loopback_file = "ground_truth_loopback.wav"
wavfile.write(out / loopback_file, int(args.loopback_rate or output_rate), loopback.astype(np.float32))
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,
}
)
write_json(
out / "manifest.json",
{
"kind": "distributed-audio-probe-train",
"created_utc": datetime.now(timezone.utc).isoformat(),
"profile": str(args.profile),
"sampleRate": int(profile["sampleRate"]),
"inputSampleRate": input_rate,
"stimulus": "probe-chirplet.wav",
"playback": {
"played": True,
"file": "probe-train-played.wav",
"outputDevice": summarize_device(output_device),
"outputSampleRate": output_rate,
"seconds": float(args.seconds),
},
"reference": {
"kind": "wasapi-loopback",
"file": loopback_file,
"sampleRate": int(args.loopback_rate or output_rate),
"query": args.loopback_query,
},
"probeTrain": {
"chirpSeconds": float(args.chirp_seconds),
"intervalSeconds": float(args.interval_seconds),
"startPaddingSeconds": float(args.start_padding_seconds),
"events": events,
},
"sources": sources,
},
)
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 make_probe_train(
profile,
sample_rate,
*,
seconds,
chirp_seconds,
interval_seconds,
channels,
start_padding_seconds=1.0,
chirps_per_second=1,
bands=None,
level_db_offset=-18.0,
):
chirp_profile = dict(profile)
chirp_profile["sampleRate"] = int(sample_rate)
chirp_profile["calibration"] = dict(profile["calibration"])
chirp_profile["calibration"]["sweepSeconds"] = float(chirp_seconds)
chirp = make_sweep(chirp_profile) * db_to_amp(float(level_db_offset))
total_frames = int(float(seconds) * sample_rate)
train = np.zeros((total_frames, int(channels)), dtype=np.float32)
events = []
rng = np.random.default_rng(1337)
base_interval = 1.0 / max(1.0, float(chirps_per_second))
effective_interval = min(float(interval_seconds), base_interval)
time_s = float(start_padding_seconds)
index = 0
speaker_channels = [int(speaker["channel"]) for speaker in sorted(profile["speakers"], key=lambda item: int(item["channel"]))]
if not speaker_channels:
raise SystemExit("Probe train needs at least one speaker in the profile")
while time_s + float(chirp_seconds) <= float(seconds):
channel = speaker_channels[index % len(speaker_channels)]
if bands:
band = bands[index % len(bands)]
chirp_profile["calibration"]["sweepStartHz"] = float(band[0])
chirp_profile["calibration"]["sweepEndHz"] = float(band[1])
chirp = make_sweep(chirp_profile) * db_to_amp(float(level_db_offset))
jitter = 0.15 * effective_interval * float(rng.uniform(-1.0, 1.0))
event_time = max(float(start_padding_seconds), time_s + jitter)
start = int(round(time_s * sample_rate))
start = int(round(event_time * sample_rate))
end = min(total_frames, start + len(chirp))
if 0 <= channel < int(channels) and end > start:
train[start:end, channel] += chirp[: end - start]
speaker = next((item for item in profile["speakers"] if int(item["channel"]) == channel), {})
events.append(
{
"eventIndex": index,
"speakerId": speaker.get("id", f"speaker_channel_{channel}"),
"speakerChannel": channel,
"scheduledStartSeconds": event_time,
"scheduledStartSample": start,
"chirpSeconds": float(chirp_seconds),
"sweepStartHz": float(chirp_profile["calibration"].get("sweepStartHz")),
"sweepEndHz": float(chirp_profile["calibration"].get("sweepEndHz")),
}
)
index += 1
time_s += effective_interval
peak = float(np.max(np.abs(train))) if train.size else 0.0
if peak > 0.95:
train *= 0.95 / peak
return train, chirp, events
def parse_probe_bands(values):
if not values:
return None
bands = []
for value in values:
if ":" not in value:
raise SystemExit(f"Probe band must be start:end Hz, got {value!r}")
start, end = value.split(":", 1)
bands.append((float(start), float(end)))
return bands
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"}
response_corrections = {}
response_report = []
if args.compensate_response:
response_corrections, response_report = estimate_response_corrections(
profile,
run,
manifest,
analysis_by_mic,
max_boost_db=float(args.max_response_boost_db),
max_cut_db=float(args.max_response_cut_db),
smoothing_bins=int(args.response_smoothing_bins),
)
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)
if mic["id"] in response_corrections:
aligned = apply_frequency_response(aligned, response_corrections[mic["id"]])
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,
"responseCompensated": mic["id"] in response_corrections,
}
)
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}
if response_report:
result["responseCompensation"] = response_report
write_json(run / "response-compensation.json", {"sources": response_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 cmd_analyze_probe_train(args):
profile = load_profile(args.profile)
run = args.run
manifest = read_json(run / "manifest.json")
train = manifest.get("probeTrain") or {}
events = train.get("events") or []
if not events:
raise SystemExit("Probe-train manifest has no events")
reference_info = manifest.get("reference") or {}
reference_file = run / reference_info.get("file", "")
if not reference_file.exists():
raise SystemExit(f"Loopback reference missing: {reference_file}")
field_rate = int(profile["sampleRate"])
stim_rate, chirp = wavfile.read(run / manifest["stimulus"])
chirp = resample_if_needed(mono(as_float_matrix(chirp)), int(stim_rate), field_rate)
ref_rate, reference = wavfile.read(reference_file)
reference = resample_if_needed(mono(as_float_matrix(reference)), int(ref_rate), field_rate)
event_search = int(float(args.loopback_search_ms) * field_rate / 1000.0)
mic_search = int(float(args.mic_search_ms) * field_rate / 1000.0)
phase_frequencies = [float(value) for value in args.phase_frequency]
loopback_events = []
for event in events:
event_chirp = make_event_chirp(profile, field_rate, event)
scheduled = int(round(float(event["scheduledStartSeconds"]) * field_rate))
detected, peak, polarity = detect_chirp_near(reference, event_chirp, scheduled, event_search)
loopback_score = normalized_chirp_score(reference, event_chirp, detected)
loopback_events.append(
{
**event,
"loopbackStartSample": int(detected),
"loopbackStartSeconds": detected / field_rate,
"loopbackScore": loopback_score,
"loopbackPeak": float(abs(peak)),
"loopbackPolarity": int(polarity),
"usable": bool(loopback_score >= float(args.min_loopback_score)),
}
)
results = {
"created_utc": datetime.now(timezone.utc).isoformat(),
"sourceRun": str(run),
"sampleRate": field_rate,
"speedOfSoundMetersPerSecond": float(args.speed_of_sound),
"events": loopback_events,
"sources": [],
"notes": "Delay-to-distance values still include device latency unless solved against multiple speakers and clock offsets.",
}
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)
observations = []
phase_field = SmoothPhaseField(smoothing=float(args.phase_smoothing), max_step_samples=float(args.max_phase_step_samples))
mapper = IterativeFrequencyPhaseMapper(
phase_frequencies,
field_rate,
learning_rate=float(args.mapping_learning_rate),
max_phase_step_radians=float(args.max_mapping_phase_step),
)
for event in loopback_events:
if not event.get("usable"):
continue
event_chirp = make_event_chirp(profile, field_rate, event)
detected, peak, polarity = detect_chirp_near(mic, event_chirp, int(event["loopbackStartSample"]), mic_search)
delay = detected - int(event["loopbackStartSample"])
score = normalized_chirp_score(mic, event_chirp, detected)
if score < float(args.min_mic_score):
continue
loopback_segment = extract_window(reference, int(event["loopbackStartSample"]), len(event_chirp))
mic_segment = extract_window(mic, detected, len(event_chirp))
phase_estimate = estimate_phase_delay(
loopback_segment,
mic_segment,
field_rate,
phase_frequencies,
max_abs_delay_ms=float(args.max_phase_delta_ms),
)
confidence = min(1.0, max(0.0, score))
smooth_phase_delta = phase_field.update(source["micId"], phase_estimate.delay_samples, confidence)
mapping_phase = mapper.update(source["micId"], phase_estimate, confidence)
observations.append(
{
"eventIndex": event["eventIndex"],
"speakerId": event["speakerId"],
"speakerChannel": event["speakerChannel"],
"startSample": int(detected),
"delaySamplesFromLoopback": int(delay),
"phaseDeltaSamples": phase_estimate.delay_samples,
"phaseDeltaMs": phase_estimate.delay_ms,
"smoothedPhaseDeltaSamples": smooth_phase_delta,
"phaseFitErrorRadians": phase_estimate.fit_error_radians,
"phaseRefinedDelaySamplesFromLoopback": float(delay) + phase_estimate.delay_samples,
"phaseBands": [
{
"frequencyHz": band.frequency_hz,
"phaseDeltaRadians": band.phase_delta_radians,
"coherence": band.coherence,
}
for band in phase_estimate.bands
],
"delayMsFromLoopback": 1000.0 * delay / field_rate,
"distanceEquivalentMeters": float(args.speed_of_sound) * delay / field_rate,
"score": score,
"peak": float(abs(peak)),
"polarity": int(polarity),
}
)
results["sources"].append(
{
"micId": source["micId"],
"fieldChannel": source.get("fieldChannel"),
"observations": observations,
"summaryBySpeaker": summarize_probe_observations(observations, field_rate, float(args.speed_of_sound)),
"learnedPhaseFrequencyMapping": [
{"frequencyHz": frequency, "phaseCorrectionRadians": float(phase)}
for frequency, phase in zip(phase_frequencies, mapper.correction_for(source["micId"]))
],
"smoothedPhaseField": phase_field.state(),
}
)
write_json(run / "probe-train-analysis.json", results)
print(json.dumps(results, indent=2))
def detect_chirp_near(samples, chirp, center_sample, radius_samples):
start = max(0, int(center_sample) - int(radius_samples))
end = min(len(samples), int(center_sample) + int(radius_samples) + len(chirp))
segment = samples[start:end]
if len(segment) < max(8, len(chirp) // 4):
return int(center_sample), 0.0, 1
delay, peak, polarity = estimate_delay(segment, chirp)
return start + delay, peak, polarity
def make_event_chirp(profile, sample_rate, event):
clone = dict(profile)
clone["sampleRate"] = int(sample_rate)
clone["calibration"] = dict(profile["calibration"])
clone["calibration"]["sweepSeconds"] = float(event.get("chirpSeconds", clone["calibration"].get("sweepSeconds", 0.03)))
if "sweepStartHz" in event:
clone["calibration"]["sweepStartHz"] = float(event["sweepStartHz"])
if "sweepEndHz" in event:
clone["calibration"]["sweepEndHz"] = float(event["sweepEndHz"])
return make_sweep(clone)
def normalized_chirp_score(samples, chirp, start_sample):
segment = extract_window(samples, int(start_sample), len(chirp))
denom = float(np.linalg.norm(segment) * np.linalg.norm(chirp))
if denom <= 1e-12:
return 0.0
return float(abs(np.dot(segment.astype(np.float32), chirp.astype(np.float32))) / denom)
def summarize_probe_observations(observations, sample_rate, speed_of_sound):
by_speaker = {}
for obs in observations:
by_speaker.setdefault(obs["speakerId"], []).append(obs)
summaries = []
for speaker_id, rows in sorted(by_speaker.items()):
delays = np.asarray([row["delaySamplesFromLoopback"] for row in rows], dtype=np.float64)
refined = np.asarray([row.get("phaseRefinedDelaySamplesFromLoopback", row["delaySamplesFromLoopback"]) for row in rows], dtype=np.float64)
scores = np.asarray([row["score"] for row in rows], dtype=np.float64)
event_indexes = np.asarray([row["eventIndex"] for row in rows], dtype=np.float64)
median = float(np.median(delays))
refined_median = float(np.median(refined))
mad = float(np.median(np.abs(delays - median)))
refined_mad = float(np.median(np.abs(refined - refined_median)))
slope = 0.0
if len(delays) >= 2 and float(np.ptp(event_indexes)) > 0.0:
slope = float(np.polyfit(event_indexes, refined, 1)[0])
summaries.append(
{
"speakerId": speaker_id,
"observations": int(len(rows)),
"medianDelaySamples": median,
"medianDelayMs": 1000.0 * median / sample_rate,
"madSamples": mad,
"madMs": 1000.0 * mad / sample_rate,
"distanceEquivalentMeters": speed_of_sound * median / sample_rate,
"phaseRefinedMedianDelaySamples": refined_median,
"phaseRefinedMedianDelayMs": 1000.0 * refined_median / sample_rate,
"phaseRefinedMadSamples": refined_mad,
"phaseRefinedMadMs": 1000.0 * refined_mad / sample_rate,
"phaseRefinedDistanceEquivalentMeters": speed_of_sound * refined_median / sample_rate,
"meanScore": float(np.mean(scores)) if len(scores) else 0.0,
"delaySlopeSamplesPerEvent": slope,
}
)
return summaries
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 estimate_response_corrections(profile, run, manifest, analysis_by_mic, *, max_boost_db, max_cut_db, smoothing_bins):
field_rate = int(profile["sampleRate"])
reference_id = profile.get("clockModel", {}).get("referenceMicId") or profile.get("calibration", {}).get("referenceMicId")
source_by_mic = {item.get("micId"): item for item in manifest.get("sources", [])}
stimulus_rate, stimulus = wavfile.read(run / manifest["stimulus"])
stimulus = resample_if_needed(mono(as_float_matrix(stimulus)), int(stimulus_rate), field_rate)
fft_size = int(2 ** math.ceil(math.log2(max(2048, len(stimulus)))))
responses = {}
for mic in sorted(profile["microphones"], key=mic_channel):
mic_id = mic["id"]
source = source_by_mic.get(mic_id)
analysis = analysis_by_mic.get(mic_id)
file_name = (source or {}).get("file") or (source or {}).get("expectedFile")
if not source or not analysis or not file_name or not (run / file_name).exists():
continue
rec_rate, data = wavfile.read(run / file_name)
data = resample_if_needed(mono(as_float_matrix(data)), int(rec_rate), field_rate)
delay_seconds = float(analysis.get("chirpletDelaySamples", analysis["sweepStartSample"])) / float(analysis["sampleRate"])
start = int(round(delay_seconds * field_rate))
segment = extract_window(data, start, len(stimulus))
if rms(segment) <= 1e-6:
continue
window = signal.windows.hann(len(stimulus), sym=False).astype(np.float32)
spectrum = np.fft.rfft(segment * window, n=fft_size)
responses[mic_id] = np.maximum(np.abs(spectrum), 1e-8).astype(np.float32)
reference = responses.get(reference_id)
if reference is None:
return {}, [{"status": "disabled", "reason": f"reference response unavailable: {reference_id}"}]
corrections = {}
report = []
for mic in sorted(profile["microphones"], key=mic_channel):
mic_id = mic["id"]
response = responses.get(mic_id)
if response is None:
report.append({"micId": mic_id, "status": "missing-calibration"})
continue
gain = reference / np.maximum(response, 1e-8)
gain = normalize_gain_at_frequency(gain, field_rate, 1000.0)
gain = smooth_log_gain(gain, smoothing_bins)
gain = np.clip(gain, db_to_amp(-abs(max_cut_db)), db_to_amp(abs(max_boost_db))).astype(np.float32)
corrections[mic_id] = gain
report.append(
{
"micId": mic_id,
"status": "ok",
"fftBins": int(gain.size),
"minGainDb": amp_to_db(float(np.min(gain))),
"maxGainDb": amp_to_db(float(np.max(gain))),
}
)
return corrections, report
def extract_window(data, start, length):
out = np.zeros(length, dtype=np.float32)
src_start = max(0, start)
dst_start = max(0, -start)
count = min(length - dst_start, len(data) - src_start)
if count > 0:
out[dst_start : dst_start + count] = data[src_start : src_start + count]
return out
def rms(x):
return float(np.sqrt(np.mean(np.asarray(x, dtype=np.float32) ** 2))) if len(x) else 0.0
def normalize_gain_at_frequency(gain, sample_rate, frequency):
index = int(round(float(frequency) * (len(gain) - 1) * 2.0 / float(sample_rate)))
index = max(1, min(len(gain) - 1, index))
return gain / max(float(gain[index]), 1e-8)
def smooth_log_gain(gain, bins):
if bins <= 1:
return gain.astype(np.float32)
bins = min(int(bins), len(gain))
kernel = np.ones(bins, dtype=np.float32) / float(bins)
log_gain = np.log(np.maximum(gain, 1e-8))
return np.exp(np.convolve(log_gain, kernel, mode="same")).astype(np.float32)
def apply_frequency_response(samples, gain):
fft_size = max(len(samples), (len(gain) - 1) * 2)
spectrum = np.fft.rfft(samples.astype(np.float32), n=fft_size)
if len(gain) != len(spectrum):
x_old = np.linspace(0.0, 1.0, len(gain))
x_new = np.linspace(0.0, 1.0, len(spectrum))
gain = np.interp(x_new, x_old, gain).astype(np.float32)
corrected = np.fft.irfft(spectrum * gain, n=fft_size)
return corrected[: len(samples)].astype(np.float32)
def amp_to_db(value):
return 20.0 * math.log10(max(float(value), 1e-12))
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_suppress_room(args):
profile = load_profile(args.profile)
sample_rate, data = wavfile.read(args.input)
field = as_float_matrix(data)
anchors = infer_anchor_channels(profile) if not args.anchor_channel else [int(value) for value in args.anchor_channel]
witnesses = infer_witness_channels(profile, anchors) if not args.witness_channel else [int(value) for value in args.witness_channel]
config = RoomSuppressionConfig(
block_size=int(args.block_size),
hop_size=int(args.hop_size),
transient_ratio=float(args.transient_ratio),
max_witness_attenuation_db=float(args.max_witness_attenuation_db),
anchor_transient_attenuation_db=float(args.anchor_transient_attenuation_db),
room_subtraction=float(args.room_subtraction),
envelope_floor=float(args.envelope_floor),
)
cleaned, report = suppress_room_field(field, anchors, witnesses, config)
output = args.output or args.input.with_name(args.input.stem + "-room-suppressed.wav")
peak = float(np.max(np.abs(cleaned))) if cleaned.size else 0.0
if args.limit_peak and peak > float(args.limit_peak):
cleaned = cleaned * (float(args.limit_peak) / peak)
wavfile.write(output, int(sample_rate), cleaned.astype(np.float32))
result = {
"output": str(output),
"sampleRate": int(sample_rate),
"shape": list(cleaned.shape),
"anchors": anchors,
"witnesses": witnesses,
"transientBlocks": report.transient_blocks,
"meanWitnessGain": report.mean_witness_gain,
"meanAnchorGain": report.mean_anchor_gain,
"peakBeforeLimit": peak,
}
report_path = args.report or output.with_suffix(".room-suppression.json")
write_json(report_path, result)
print(json.dumps(result, indent=2))
def cmd_suppress_reference(args):
profile = load_profile(args.profile)
sample_rate, data = wavfile.read(args.input)
field = as_float_matrix(data)
ref_rate, reference = wavfile.read(args.reference)
reference = mono(as_float_matrix(reference))
reference = resample_if_needed(reference, int(ref_rate), int(sample_rate))
channels = [int(value) for value in args.channel] if args.channel else list(range(field.shape[1]))
cleaned, reports = suppress_program_reference(
field,
reference,
int(sample_rate),
channels,
nperseg=int(args.window_size),
noverlap=int(args.overlap),
regularization=float(args.regularization),
subtraction_strength=float(args.subtraction_strength),
)
output = args.output or args.input.with_name(args.input.stem + "-program-suppressed.wav")
wavfile.write(output, int(sample_rate), cleaned.astype(np.float32))
result = {
"output": str(output),
"reference": str(args.reference),
"sampleRate": int(sample_rate),
"shape": list(cleaned.shape),
"channels": [
{
"channel": report.channel,
"inputRms": report.input_rms,
"predictedRms": report.predicted_rms,
"outputRms": report.output_rms,
"reductionDb": report.reduction_db,
"phaseMapping": [
{"frequencyHz": frequency, "phaseRadians": phase, "magnitude": magnitude}
for frequency, phase, magnitude in report.phase_mapping
],
}
for report in reports
],
"notes": "Known program/reference bleed is transformed, mapped into each mic channel, subtracted, and exported as phase/frequency evidence for runtime fitting.",
}
report_path = args.report or output.with_suffix(".program-reference.json")
write_json(report_path, result)
print(json.dumps(result, indent=2))
def cmd_analyze_source_events(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']}")
field = as_float_matrix(data)
anchors = infer_anchor_channels(profile) if not args.anchor_channel else [int(value) for value in args.anchor_channel]
witnesses = infer_witness_channels(profile, anchors) if not args.witness_channel else [int(value) for value in args.witness_channel]
mic_positions = {
mic_channel(mic): tuple(float(value) for value in mic["positionMeters"])
for mic in profile["microphones"]
if len(mic.get("positionMeters", [])) == 3
}
events, focus = analyze_source_field(
field,
int(sample_rate),
mic_positions,
anchors,
witnesses,
block_size=int(args.block_size),
hop_size=int(args.hop_size),
transient_ratio=float(args.transient_ratio),
min_event_energy=float(args.min_event_energy),
onset_ratio=float(args.onset_ratio),
min_event_spacing_samples=int(args.min_event_spacing_samples),
)
result = {
"input": str(args.input),
"sampleRate": int(sample_rate),
"anchors": anchors,
"witnesses": witnesses,
"eventCount": len(events),
"events": [
{
"eventId": event.event_id,
"startSample": event.start_sample,
"durationSamples": event.duration_samples,
"timeSeconds": event.start_sample / float(sample_rate),
"positionMeters": list(event.position_m),
"direction": list(event.direction_m),
"energy": event.energy,
"confidence": event.confidence,
"kind": event.kind,
}
for event in events
],
"voiceFocus": [
{
"startSample": item.start_sample,
"durationSamples": item.duration_samples,
"anchorWeights": {str(channel): weight for channel, weight in item.anchor_weights.items()},
"witnessEnergy": item.witness_energy,
"anchorEnergy": item.anchor_energy,
"noiseRatio": item.noise_ratio,
}
for item in focus
],
"notes": "Dialogue anchors are the protected voice bed; witness-dominant transients are emitted as estimated volumetric events for the renderer.",
}
output = args.output or args.input.with_suffix(".source-events.json")
write_json(output, result)
if args.cache:
cultcache_audio = load_cultcache_audio_module()
start_sample = int(result["voiceFocus"][0]["startSample"]) if result["voiceFocus"] else 0
frame_count = int(field.shape[0])
cultcache_audio.put_live_audio_source_events(
args.cache,
cultcache_audio.make_audio_source_events(
frame_id=int(time.monotonic_ns() % (2**31)),
sample_rate=int(sample_rate),
start_sample=start_sample,
frame_count=frame_count,
audio_time_ns=time.monotonic_ns(),
events=result["events"][: int(args.max_cache_events)],
voice_focus=result["voiceFocus"][-int(args.max_cache_focus) :],
),
)
print(json.dumps(result, indent=2))
def infer_anchor_channels(profile):
anchors = []
for mic in profile["microphones"]:
role = str(mic.get("role", ""))
if "dialogue-anchor" in role or int(mic.get("qualityPriority", 0)) >= 100:
anchors.append(mic_channel(mic))
return sorted(set(anchors))
def infer_witness_channels(profile, anchors):
anchor_set = set(int(channel) for channel in anchors)
return [mic_channel(mic) for mic in sorted(profile["microphones"], key=mic_channel) if mic_channel(mic) not in anchor_set]
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-probe-train", help="Record local mics while emitting repeated left/right chirplets with loopback ground truth.")
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=12.0)
p.add_argument("--input-rate", type=int)
p.add_argument("--output-rate", type=int)
p.add_argument("--chirp-seconds", type=float, default=0.35)
p.add_argument("--interval-seconds", type=float, default=1.0)
p.add_argument("--chirps-per-second", type=int, default=1)
p.add_argument("--probe-band", action="append", help="Layer probe texture bands as start:end Hz, repeatable.")
p.add_argument("--probe-level-offset-db", type=float, default=-18.0)
p.add_argument("--start-padding-seconds", type=float, default=1.0)
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_probe_train)
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.add_argument("--compensate-response", action="store_true")
p.add_argument("--max-response-boost-db", type=float, default=9.0)
p.add_argument("--max-response-cut-db", type=float, default=12.0)
p.add_argument("--response-smoothing-bins", type=int, default=31)
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("analyze-probe-train", help="Analyze repeated chirplet probes against output loopback for delay/jitter/drift evidence.")
p.add_argument("--profile", type=Path, default=ROOT / "config" / "audio-field.example.json")
p.add_argument("--run", type=Path, required=True)
p.add_argument("--loopback-search-ms", type=float, default=80.0)
p.add_argument("--mic-search-ms", type=float, default=250.0)
p.add_argument("--min-loopback-score", type=float, default=0.12)
p.add_argument("--min-mic-score", type=float, default=0.08)
p.add_argument("--speed-of-sound", type=float, default=343.0)
p.add_argument("--phase-frequency", type=float, action="append", default=[250.0, 500.0, 1000.0, 2000.0, 4000.0, 8000.0, 12000.0])
p.add_argument("--max-phase-delta-ms", type=float, default=3.0)
p.add_argument("--phase-smoothing", type=float, default=0.25)
p.add_argument("--max-phase-step-samples", type=float, default=32.0)
p.add_argument("--mapping-learning-rate", type=float, default=0.2)
p.add_argument("--max-mapping-phase-step", type=float, default=0.2)
p.set_defaults(func=cmd_analyze_probe_train)
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)
p = sub.add_parser("suppress-room", help="Suppress room/transient witness energy before FOA encoding.")
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)
p.add_argument("--report", type=Path)
p.add_argument("--anchor-channel", type=int, action="append")
p.add_argument("--witness-channel", type=int, action="append")
p.add_argument("--block-size", type=int, default=1024)
p.add_argument("--hop-size", type=int, default=512)
p.add_argument("--transient-ratio", type=float, default=2.5)
p.add_argument("--max-witness-attenuation-db", type=float, default=-18.0)
p.add_argument("--anchor-transient-attenuation-db", type=float, default=-6.0)
p.add_argument("--room-subtraction", type=float, default=0.15)
p.add_argument("--envelope-floor", type=float, default=1e-5)
p.add_argument("--limit-peak", type=float, default=0.98)
p.set_defaults(func=cmd_suppress_room)
p = sub.add_parser("suppress-reference", help="Subtract known speaker/program reference bleed from an aligned field and export phase mapping.")
p.add_argument("--profile", type=Path, default=ROOT / "config" / "audio-field.example.json")
p.add_argument("--input", type=Path, required=True)
p.add_argument("--reference", type=Path, required=True)
p.add_argument("--output", type=Path)
p.add_argument("--report", type=Path)
p.add_argument("--channel", type=int, action="append")
p.add_argument("--window-size", type=int, default=2048)
p.add_argument("--overlap", type=int, default=1536)
p.add_argument("--regularization", type=float, default=1e-6)
p.add_argument("--subtraction-strength", type=float, default=0.85)
p.set_defaults(func=cmd_suppress_reference)
p = sub.add_parser("analyze-source-events", help="Estimate dialogue focus and witness-dominant volumetric transient events from an aligned field.")
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)
p.add_argument("--cache", type=Path)
p.add_argument("--anchor-channel", type=int, action="append")
p.add_argument("--witness-channel", type=int, action="append")
p.add_argument("--block-size", type=int, default=1024)
p.add_argument("--hop-size", type=int, default=512)
p.add_argument("--transient-ratio", type=float, default=2.5)
p.add_argument("--min-event-energy", type=float, default=1e-6)
p.add_argument("--onset-ratio", type=float, default=1.8)
p.add_argument("--min-event-spacing-samples", type=int, default=2048)
p.add_argument("--max-cache-events", type=int, default=128)
p.add_argument("--max-cache-focus", type=int, default=64)
p.set_defaults(func=cmd_analyze_source_events)
args = parser.parse_args()
args.func(args)
if __name__ == "__main__":
main()