271 lines
11 KiB
Python
271 lines
11 KiB
Python
import argparse
|
|
from pathlib import Path
|
|
import sys
|
|
import time
|
|
|
|
import numpy as np
|
|
from scipy.io import wavfile
|
|
|
|
|
|
ROOT = Path(__file__).resolve().parents[1]
|
|
if str(ROOT) not in sys.path:
|
|
sys.path.insert(0, str(ROOT))
|
|
|
|
from audio_field.cultcache_audio import make_audio_phase_field, put_live_audio_phase_field # noqa: E402
|
|
from audio_field.active_probe import ActiveConfidenceMaintainer # noqa: E402
|
|
from audio_field.phase_meaning import LivePhaseMeaningExtractor # noqa: E402
|
|
from audio_field.probe_optimizer import ActiveProbeOptimizer, ProbePolicy # noqa: E402
|
|
|
|
|
|
def read_float_wav(path: Path) -> tuple[int, np.ndarray]:
|
|
rate, data = wavfile.read(path)
|
|
samples = data.astype(np.float32)
|
|
if np.issubdtype(data.dtype, np.integer):
|
|
samples /= np.iinfo(data.dtype).max
|
|
if samples.ndim == 1:
|
|
samples = samples[:, None]
|
|
return int(rate), samples
|
|
|
|
|
|
def write_phase_runtime_status(
|
|
path: Path,
|
|
*,
|
|
field: Path,
|
|
reference: Path,
|
|
frame_id: int,
|
|
global_confidence: float,
|
|
needs_active_probe: bool,
|
|
emitted_probe,
|
|
maintain_confidence: bool,
|
|
play_probes: bool,
|
|
) -> None:
|
|
import json
|
|
|
|
path.parent.mkdir(parents=True, exist_ok=True)
|
|
path.write_text(
|
|
json.dumps(
|
|
{
|
|
"input_mode": "wav-replay",
|
|
"field": str(field),
|
|
"reference": str(reference),
|
|
"frame_id": int(frame_id),
|
|
"global_confidence": float(global_confidence),
|
|
"needs_active_probe": bool(needs_active_probe),
|
|
"maintain_confidence": bool(maintain_confidence),
|
|
"play_probes": bool(play_probes),
|
|
"probe_feedback_mode": "open-loop-playback" if play_probes else "dry-run",
|
|
"closed_loop_probe_capture": False,
|
|
"last_probe": None
|
|
if emitted_probe is None
|
|
else {
|
|
"source_id": emitted_probe.request.source_id,
|
|
"reason": emitted_probe.request.reason,
|
|
"urgency": emitted_probe.request.urgency,
|
|
"path": str(emitted_probe.path),
|
|
"emitted_monotonic_ns": emitted_probe.emitted_monotonic_ns,
|
|
},
|
|
"updated_monotonic_ns": time.monotonic_ns(),
|
|
},
|
|
indent=2,
|
|
sort_keys=True,
|
|
),
|
|
encoding="utf-8",
|
|
)
|
|
|
|
|
|
def parse_frequencies(values: list[str]) -> list[float]:
|
|
out: list[float] = []
|
|
for value in values:
|
|
out.extend(float(part) for part in value.split(",") if part.strip())
|
|
return out
|
|
|
|
|
|
def make_source_ids(count: int, explicit: list[str]) -> tuple[str, ...]:
|
|
if explicit:
|
|
if len(explicit) != count:
|
|
raise SystemExit(f"expected {count} --source-id values, got {len(explicit)}")
|
|
return tuple(explicit)
|
|
return tuple(f"channel-{index}" for index in range(count))
|
|
|
|
|
|
def source_dicts(meaning) -> list[dict]:
|
|
rows = []
|
|
for source in meaning.sources:
|
|
rows.append(
|
|
{
|
|
"sourceId": source.source_id,
|
|
"channel": source.channel,
|
|
"delaySamples": source.smoothed_delay_samples,
|
|
"delayMs": source.delay_ms,
|
|
"distanceDeltaMeters": source.distance_delta_m,
|
|
"coherence": source.coherence,
|
|
"fitErrorRadians": source.fit_error_radians,
|
|
"confidence": source.confidence,
|
|
"referenceBleed": source.reference_bleed,
|
|
"suppressionWeight": source.suppression_weight,
|
|
"correctionEnergy": source.correction_energy,
|
|
}
|
|
)
|
|
return rows
|
|
|
|
|
|
def main() -> None:
|
|
parser = argparse.ArgumentParser(description="Publish live extracted phase-field meaning from reference and aligned mic WAVs.")
|
|
parser.add_argument("--field", type=Path, required=True, help="Aligned frames-by-channel mic field WAV.")
|
|
parser.add_argument("--reference", type=Path, required=True, help="Known program/loopback reference WAV.")
|
|
parser.add_argument("--cache", type=Path, default=ROOT / "calibration" / "runs" / "audio-phase-field.msgpack")
|
|
parser.add_argument("--status", type=Path, default=ROOT / "calibration" / "runs" / "audio-phase-field-status.json")
|
|
parser.add_argument("--source-id", action="append", default=[])
|
|
parser.add_argument("--reference-id", default="program-reference")
|
|
parser.add_argument("--frequency", action="append", default=["250,500,1000,2000,4000,8000,12000"])
|
|
parser.add_argument("--chunk-frames", type=int, default=4096)
|
|
parser.add_argument("--hop-frames", type=int, default=2048)
|
|
parser.add_argument("--duration", type=float)
|
|
parser.add_argument("--loop", action="store_true")
|
|
parser.add_argument("--realtime", action="store_true")
|
|
parser.add_argument("--maintain-confidence", action="store_true")
|
|
parser.add_argument("--target-confidence", type=float, default=0.65)
|
|
parser.add_argument("--trigger-confidence", type=float, default=0.35)
|
|
parser.add_argument("--min-probe-interval-frames", type=int, default=45)
|
|
parser.add_argument("--probe-level-dbfs", type=float, default=-18.0)
|
|
parser.add_argument("--probe-output-dir", type=Path, default=ROOT / "calibration" / "runs" / "active-probes")
|
|
parser.add_argument("--probe-seconds", type=float, default=0.03)
|
|
parser.add_argument("--probe-band", default="1800:9000")
|
|
parser.add_argument("--ultrasonic-probes", action="store_true", help="Use a near-ultrasonic probe band below Nyquist.")
|
|
parser.add_argument("--probe-pattern", choices=["single", "harmonic-dense"], default="single")
|
|
parser.add_argument("--cram-harmonic-probes", action="store_true", help="Shortcut for dense near-ultrasonic harmonic probes.")
|
|
parser.add_argument("--harmonic-root-hz", type=float, default=440.0)
|
|
parser.add_argument("--harmonic-voices", type=int, default=36)
|
|
parser.add_argument("--probe-channel", type=int, default=0)
|
|
parser.add_argument("--probe-output-channels", type=int, default=2)
|
|
parser.add_argument("--probe-unmasked", action="store_true", help="Allow probes even when the reference block is not masking them.")
|
|
parser.add_argument("--play-probes", action="store_true", help="Play scheduled probes through the default output device.")
|
|
args = parser.parse_args()
|
|
|
|
sample_rate, field = read_float_wav(args.field)
|
|
reference_rate, reference = read_float_wav(args.reference)
|
|
if reference_rate != sample_rate:
|
|
raise SystemExit(f"reference sample rate {reference_rate} does not match field sample rate {sample_rate}")
|
|
reference_mono = reference[:, 0]
|
|
source_ids = make_source_ids(field.shape[1], args.source_id)
|
|
extractor = LivePhaseMeaningExtractor(
|
|
source_ids,
|
|
sample_rate,
|
|
parse_frequencies(args.frequency),
|
|
reference_id=args.reference_id,
|
|
)
|
|
maintainer = None
|
|
if args.maintain_confidence:
|
|
use_ultrasonic = args.ultrasonic_probes or args.cram_harmonic_probes
|
|
probe_pattern = "harmonic-dense" if args.cram_harmonic_probes else args.probe_pattern
|
|
band_start, band_end = ultrasonic_probe_band(sample_rate) if use_ultrasonic else parse_probe_band(args.probe_band)
|
|
maintainer = ActiveConfidenceMaintainer(
|
|
ActiveProbeOptimizer(
|
|
ProbePolicy(
|
|
target_confidence=args.target_confidence,
|
|
trigger_confidence=args.trigger_confidence,
|
|
min_interval_frames=args.min_probe_interval_frames,
|
|
max_probe_level_dbfs=args.probe_level_dbfs,
|
|
prefer_masked_windows=not args.probe_unmasked,
|
|
)
|
|
),
|
|
sample_rate=sample_rate,
|
|
output_dir=args.probe_output_dir,
|
|
duration_seconds=args.probe_seconds,
|
|
start_hz=band_start,
|
|
end_hz=band_end,
|
|
channels=args.probe_output_channels,
|
|
channel=args.probe_channel,
|
|
pattern=probe_pattern,
|
|
harmonic_root_hz=args.harmonic_root_hz,
|
|
harmonic_voices=args.harmonic_voices,
|
|
)
|
|
|
|
start_monotonic = time.monotonic()
|
|
audio_origin_ns = time.monotonic_ns()
|
|
frame_id = 0
|
|
cursor = 0
|
|
max_samples = min(len(reference_mono), len(field))
|
|
last_status = 0.0
|
|
while cursor + args.chunk_frames <= max_samples:
|
|
if args.duration is not None and time.monotonic() - start_monotonic >= args.duration:
|
|
break
|
|
block_ref = reference_mono[cursor : cursor + args.chunk_frames]
|
|
block_field = field[cursor : cursor + args.chunk_frames]
|
|
audio_time_ns = audio_origin_ns + int(cursor * 1_000_000_000 / sample_rate)
|
|
meaning = extractor.update(
|
|
block_ref,
|
|
block_field,
|
|
frame_id=frame_id,
|
|
start_sample=cursor,
|
|
audio_time_ns=audio_time_ns,
|
|
)
|
|
put_live_audio_phase_field(
|
|
args.cache,
|
|
make_audio_phase_field(
|
|
frame_id=meaning.frame_id,
|
|
sample_rate=meaning.sample_rate,
|
|
start_sample=meaning.start_sample,
|
|
frame_count=meaning.frame_count,
|
|
audio_time_ns=meaning.audio_time_ns,
|
|
reference_id=meaning.reference_id,
|
|
sources=source_dicts(meaning),
|
|
global_confidence=meaning.global_confidence,
|
|
needs_active_probe=meaning.needs_active_probe,
|
|
active_probe_reason=meaning.active_probe_reason,
|
|
),
|
|
)
|
|
emitted = None
|
|
if maintainer is not None:
|
|
emitted = maintainer.update(meaning, block_ref, force_masked=args.probe_unmasked)
|
|
if emitted is not None and args.play_probes:
|
|
play_probe_file(emitted.path)
|
|
if time.monotonic() - last_status >= 1.0:
|
|
write_phase_runtime_status(
|
|
args.status,
|
|
field=args.field,
|
|
reference=args.reference,
|
|
frame_id=meaning.frame_id,
|
|
global_confidence=meaning.global_confidence,
|
|
needs_active_probe=meaning.needs_active_probe,
|
|
emitted_probe=emitted,
|
|
maintain_confidence=args.maintain_confidence,
|
|
play_probes=args.play_probes,
|
|
)
|
|
last_status = time.monotonic()
|
|
frame_id += 1
|
|
cursor += args.hop_frames
|
|
if cursor + args.chunk_frames > max_samples and args.loop:
|
|
cursor = 0
|
|
if args.realtime:
|
|
target_time = start_monotonic + cursor / sample_rate
|
|
sleep_for = target_time - time.monotonic()
|
|
if sleep_for > 0:
|
|
time.sleep(sleep_for)
|
|
|
|
|
|
def parse_probe_band(value: str) -> tuple[float, float]:
|
|
parts = value.split(":")
|
|
if len(parts) != 2:
|
|
raise SystemExit(f"probe band must be start:end Hz, got {value!r}")
|
|
return float(parts[0]), float(parts[1])
|
|
|
|
|
|
def ultrasonic_probe_band(sample_rate: int) -> tuple[float, float]:
|
|
nyquist = 0.5 * float(sample_rate)
|
|
end_hz = min(22_000.0, nyquist - 1200.0)
|
|
start_hz = min(18_500.0, end_hz - 2500.0)
|
|
if end_hz <= 16_000.0 or start_hz <= 0.0:
|
|
raise SystemExit(f"sample rate {sample_rate} leaves no useful ultrasonic probe band")
|
|
return start_hz, end_hz
|
|
|
|
|
|
def play_probe_file(path: Path) -> None:
|
|
import sounddevice as sd
|
|
|
|
rate, data = wavfile.read(path)
|
|
sd.play(data.astype(np.float32), int(rate), blocking=True)
|
|
|
|
|
|
if __name__ == "__main__":
|
|
main()
|