Mimir/audio_field/active_probe.py
2026-05-19 02:10:08 +01:00

278 lines
10 KiB
Python

from __future__ import annotations
from dataclasses import dataclass
import json
import math
from pathlib import Path
import time
import numpy as np
from scipy import signal
from scipy.io import wavfile
from audio_field.phase_meaning import PhaseFieldMeaning
from audio_field.probe_optimizer import ActiveProbeOptimizer, ProbeRequest
from audio_field.runtime_sync import SyncState
@dataclass(frozen=True)
class EmittedProbe:
request: ProbeRequest
sample_rate: int
start_hz: float
end_hz: float
duration_seconds: float
channel: int
path: Path
emitted_monotonic_ns: int
class ActiveConfidenceMaintainer:
"""Turns low phase-field confidence into bounded active chirplet probes."""
def __init__(
self,
optimizer: ActiveProbeOptimizer,
*,
sample_rate: int,
output_dir: Path,
duration_seconds: float = 0.03,
start_hz: float = 1800.0,
end_hz: float = 9000.0,
channels: int = 2,
channel: int = 0,
pattern: str = "single",
harmonic_root_hz: float = 440.0,
harmonic_voices: int = 24,
max_artifacts: int = 512,
manifest_max_bytes: int = 1_000_000,
eligible_source_ids: set[str] | frozenset[str] | None = None,
):
self.optimizer = optimizer
self.sample_rate = int(sample_rate)
self.output_dir = Path(output_dir)
self.duration_seconds = float(duration_seconds)
self.start_hz = float(start_hz)
self.end_hz = float(end_hz)
self.channels = int(channels)
self.channel = int(channel)
self.pattern = str(pattern)
self.harmonic_root_hz = float(harmonic_root_hz)
self.harmonic_voices = int(harmonic_voices)
self.max_artifacts = int(max_artifacts)
self.manifest_max_bytes = int(manifest_max_bytes)
self.eligible_source_ids = None if eligible_source_ids is None else frozenset(str(source_id) for source_id in eligible_source_ids)
if self.channels <= 0:
raise ValueError("channels must be positive")
if self.channel < 0 or self.channel >= self.channels:
raise ValueError("channel must be inside output channel count")
if self.max_artifacts <= 0:
raise ValueError("max_artifacts must be positive")
self.output_dir.mkdir(parents=True, exist_ok=True)
def update(self, meaning: PhaseFieldMeaning, reference_block: np.ndarray, *, force_masked: bool = False) -> EmittedProbe | None:
states = sync_states_from_phase_meaning(meaning)
if self.eligible_source_ids is not None:
states = {source_id: state for source_id, state in states.items() if source_id in self.eligible_source_ids}
if not states:
return None
masked = force_masked or is_masked_window(reference_block)
request = self.optimizer.choose_probe(meaning.frame_id, states, masked_window=masked)
if request is None:
return None
waveform = make_probe_waveform(
self.sample_rate,
duration_seconds=self.duration_seconds,
start_hz=self.start_hz,
end_hz=self.end_hz,
level_dbfs=request.level_dbfs,
channels=self.channels,
channel=self.channel,
pattern=self.pattern,
harmonic_root_hz=self.harmonic_root_hz,
harmonic_voices=self.harmonic_voices,
)
slot = meaning.frame_id % self.max_artifacts
path = self.output_dir / f"probe-slot-{slot:04d}-{request.source_id}.wav"
wavfile.write(path, self.sample_rate, waveform.astype(np.float32))
emitted = EmittedProbe(
request=request,
sample_rate=self.sample_rate,
start_hz=self.start_hz,
end_hz=self.end_hz,
duration_seconds=self.duration_seconds,
channel=self.channel,
path=path,
emitted_monotonic_ns=time.monotonic_ns(),
)
append_probe_manifest(
self.output_dir / "active-probes.jsonl",
emitted,
max_bytes=self.manifest_max_bytes,
)
return emitted
def sync_states_from_phase_meaning(meaning: PhaseFieldMeaning) -> dict[str, SyncState]:
return {
source.source_id: SyncState(
delay_samples=source.smoothed_delay_samples,
phase_radians=0.0,
rate_scale=1.0,
confidence=source.confidence,
last_frame_index=meaning.frame_id,
)
for source in meaning.sources
}
def is_masked_window(reference_block: np.ndarray, *, min_rms_dbfs: float = -42.0, crest_factor_limit: float = 8.0) -> bool:
samples = np.asarray(reference_block, dtype=np.float32).reshape(-1)
if samples.size == 0:
return False
rms = float(np.sqrt(np.mean(samples.astype(np.float64) ** 2)))
if rms <= db_to_amp(min_rms_dbfs):
return False
peak = float(np.max(np.abs(samples)))
crest = peak / max(rms, 1e-12)
return crest <= float(crest_factor_limit)
def make_probe_chirplet(
sample_rate: int,
*,
duration_seconds: float,
start_hz: float,
end_hz: float,
level_dbfs: float,
channels: int = 2,
channel: int = 0,
) -> np.ndarray:
frames = max(8, int(round(float(duration_seconds) * int(sample_rate))))
t = np.arange(frames, dtype=np.float64) / float(sample_rate)
chirp = signal.chirp(t, f0=float(start_hz), f1=float(end_hz), t1=float(duration_seconds), method="linear")
taper = signal.windows.tukey(frames, alpha=0.5)
chirp = chirp * taper
peak = float(np.max(np.abs(chirp)))
if peak > 0.0:
chirp = chirp / peak
chirp = chirp * db_to_amp(level_dbfs)
out = np.zeros((frames, int(channels)), dtype=np.float32)
out[:, int(channel)] = chirp.astype(np.float32)
return out
def make_probe_waveform(
sample_rate: int,
*,
duration_seconds: float,
start_hz: float,
end_hz: float,
level_dbfs: float,
channels: int = 2,
channel: int = 0,
pattern: str = "single",
harmonic_root_hz: float = 440.0,
harmonic_voices: int = 24,
) -> np.ndarray:
if pattern == "single":
return make_probe_chirplet(
sample_rate,
duration_seconds=duration_seconds,
start_hz=start_hz,
end_hz=end_hz,
level_dbfs=level_dbfs,
channels=channels,
channel=channel,
)
if pattern == "harmonic-dense":
return make_harmonic_probe_texture(
sample_rate,
duration_seconds=duration_seconds,
start_hz=start_hz,
end_hz=end_hz,
level_dbfs=level_dbfs,
channels=channels,
channel=channel,
root_hz=harmonic_root_hz,
voices=harmonic_voices,
)
raise ValueError(f"unknown probe pattern: {pattern}")
def make_harmonic_probe_texture(
sample_rate: int,
*,
duration_seconds: float,
start_hz: float,
end_hz: float,
level_dbfs: float,
channels: int = 2,
channel: int = 0,
root_hz: float = 440.0,
voices: int = 24,
) -> np.ndarray:
frames = max(8, int(round(float(duration_seconds) * int(sample_rate))))
texture = np.zeros(frames, dtype=np.float64)
harmonics = harmonic_frequencies(float(root_hz), float(start_hz), float(end_hz))
if not harmonics:
harmonics = list(np.linspace(float(start_hz), float(end_hz), max(1, int(voices))))
span_hz = max(40.0, min(420.0, (float(end_hz) - float(start_hz)) / max(4.0, float(voices))))
voice_count = max(1, int(voices))
for index in range(voice_count):
center = harmonics[index % len(harmonics)]
direction = -1.0 if index % 2 else 1.0
f0 = clamp(center - direction * span_hz * 0.5, float(start_hz), float(end_hz))
f1 = clamp(center + direction * span_hz * 0.5, float(start_hz), float(end_hz))
voice_frames = max(8, min(frames, int(round(frames * 0.38))))
offset = int(((index * 0.61803398875) % 1.0) * max(1, frames - voice_frames))
t = np.arange(voice_frames, dtype=np.float64) / float(sample_rate)
chirp = signal.chirp(t, f0=f0, f1=f1, t1=voice_frames / float(sample_rate), method="linear")
chirp *= signal.windows.tukey(voice_frames, alpha=0.7)
chirp *= 0.55 + 0.45 * ((index % 5) / 4.0)
texture[offset : offset + voice_frames] += chirp
peak = float(np.max(np.abs(texture)))
if peak > 0.0:
texture = texture / peak
texture *= db_to_amp(level_dbfs)
out = np.zeros((frames, int(channels)), dtype=np.float32)
out[:, int(channel)] = texture.astype(np.float32)
return out
def harmonic_frequencies(root_hz: float, start_hz: float, end_hz: float) -> list[float]:
if root_hz <= 0.0:
raise ValueError("root_hz must be positive")
first = max(1, int(math.ceil(start_hz / root_hz)))
last = int(math.floor(end_hz / root_hz))
return [root_hz * harmonic for harmonic in range(first, last + 1)]
def clamp(value: float, low: float, high: float) -> float:
return max(low, min(high, value))
def db_to_amp(db: float) -> float:
return float(math.pow(10.0, float(db) / 20.0))
def append_probe_manifest(path: Path, emitted: EmittedProbe, *, max_bytes: int = 1_000_000) -> None:
row = {
"frameIndex": emitted.request.frame_index,
"sourceId": emitted.request.source_id,
"reason": emitted.request.reason,
"levelDbfs": emitted.request.level_dbfs,
"urgency": emitted.request.urgency,
"sampleRate": emitted.sample_rate,
"startHz": emitted.start_hz,
"endHz": emitted.end_hz,
"durationSeconds": emitted.duration_seconds,
"channel": emitted.channel,
"path": str(emitted.path),
"emittedMonotonicNs": emitted.emitted_monotonic_ns,
}
if path.exists() and path.stat().st_size > int(max_bytes):
previous = path.with_name(f"{path.stem}.previous{path.suffix}")
path.replace(previous)
with path.open("a", encoding="utf-8") as handle:
handle.write(json.dumps(row, sort_keys=True) + "\n")