aboutsummaryrefslogtreecommitdiff
path: root/lib/project/src/voiceforge/audio.py
diff options
context:
space:
mode:
authorhistoria <historiavg@proton.me>2026-09-07 06:47:47 -0400
committerhistoria <historiavg@proton.me>2026-09-07 06:47:47 -0400
commit84dd2d068317998f6fb59400c534ef5be6b51b53 (patch)
tree025293e9d9229e02960374771ae522d9de2628ce /lib/project/src/voiceforge/audio.py
parent39b0f2bbed74f6487a41b82501ae3c6799e4b5c4 (diff)
downloadproducer-main.tar.gz
slop rewriteHEADmain
Diffstat (limited to 'lib/project/src/voiceforge/audio.py')
-rw-r--r--lib/project/src/voiceforge/audio.py128
1 files changed, 128 insertions, 0 deletions
diff --git a/lib/project/src/voiceforge/audio.py b/lib/project/src/voiceforge/audio.py
new file mode 100644
index 0000000..7fee0c2
--- /dev/null
+++ b/lib/project/src/voiceforge/audio.py
@@ -0,0 +1,128 @@
+"""Streaming measurement and FFmpeg execution. No shell interpolation."""
+from collections import deque
+import json
+import math
+import os
+from pathlib import Path
+import selectors
+import signal
+import subprocess
+import time
+
+import numpy as np
+import soundfile as sf
+
+from .setup import terminate_group
+
+
+def ffmpeg_run(ffmpeg: str, args: list[str], stage: str, duration: float,
+ progress) -> str:
+ command = [ffmpeg, "-hide_banner", "-nostdin", "-y", "-nostats",
+ "-progress", "pipe:1"] + args
+ progress(stage, 0, duration or None)
+ diagnostics = deque(maxlen=128)
+ pending = b""
+ completed = 0.0
+ last_advance = time.monotonic()
+ with subprocess.Popen(command, stdout=subprocess.PIPE, stderr=subprocess.PIPE,
+ start_new_session=True) as process:
+ try:
+ with selectors.DefaultSelector() as selector:
+ for stream in (process.stdout, process.stderr):
+ os.set_blocking(stream.fileno(), False)
+ selector.register(stream, selectors.EVENT_READ)
+ while selector.get_map() or process.poll() is None:
+ for key, _ in selector.select(timeout=0.2):
+ data = os.read(key.fd, 8192)
+ if not data:
+ selector.unregister(key.fileobj)
+ continue
+ if key.fileobj is process.stderr:
+ diagnostics.append(data)
+ else:
+ pending += data
+ while b"\n" in pending:
+ line, pending = pending.split(b"\n", 1)
+ if line.startswith(b"out_time_us="):
+ try:
+ value = max(0.0, int(line.split(b"=", 1)[1]) / 1e6)
+ except ValueError:
+ continue
+ if value > completed:
+ last_advance = time.monotonic()
+ completed = value
+ label = stage
+ if time.monotonic() - last_advance > 60:
+ label += " (no new audio progress for 60s; still waiting)"
+ progress(label, min(completed, duration), duration or None)
+ code = process.wait()
+ output = b"".join(diagnostics).decode(errors="replace")
+ if code:
+ raise RuntimeError(f"{stage} failed (exit {code}):\n{output[-8000:]}")
+ progress(stage, duration, duration or None)
+ return output
+ except BaseException:
+ terminate_group(process)
+ raise
+
+
+def measure(path: Path, progress, stage="Analyzing audio") -> dict:
+ """100ms RMS histogram is bounded in memory, including multi-hour input."""
+ with sf.SoundFile(path) as source:
+ if source.channels not in (1, 2):
+ raise ValueError("Only mono or stereo voice recordings are supported")
+ if not len(source):
+ raise ValueError("The recording is empty")
+ peaks = np.zeros(source.channels)
+ sums = np.zeros(source.channels)
+ squares = np.zeros(source.channels)
+ clipped = 0
+ histogram = np.zeros(121, dtype=np.int64)
+ frames = 0
+ progress(stage, 0, len(source))
+ for audio in source.blocks(blocksize=max(1, source.samplerate // 10),
+ dtype="float64", always_2d=True):
+ if not np.isfinite(audio).all():
+ raise ValueError("Recording contains non-finite samples")
+ peaks = np.maximum(peaks, np.max(np.abs(audio), axis=0))
+ sums += audio.sum(axis=0)
+ squares += (audio * audio).sum(axis=0)
+ clipped += int(np.count_nonzero(np.abs(audio) >= 0.9999))
+ rms = float(np.sqrt(np.mean(audio * audio)))
+ db = 20 * math.log10(max(rms, 1e-6))
+ histogram[int(np.clip(round(db) + 120, 0, 120))] += 1
+ frames += len(audio)
+ progress(stage, frames, len(source))
+ cumulative = histogram.cumsum()
+ def percentile(p):
+ return int(np.searchsorted(cumulative, max(1, math.ceil(cumulative[-1] * p)))) - 120
+ quiet, speech = percentile(0.1), percentile(0.8)
+ if speech - quiet < 15:
+ # Pause-dominated recordings: the 80th percentile of all blocks
+ # sits in room tone, so estimate the level from the loud tail
+ # instead of mistaking sparse active speech for silence.
+ speech = max(speech, percentile(0.98))
+ noise_available = speech - quiet >= 15 and quiet > -110
+ return {"frames": frames, "duration_seconds": frames / source.samplerate,
+ "sample_rate": source.samplerate, "channels": source.channels,
+ "subtype": source.subtype,
+ "sample_peak_dbfs": float(20 * np.log10(max(float(peaks.max()), 1e-12))),
+ "channel_rms_dbfs": (20 * np.log10(np.maximum(np.sqrt(squares / frames), 1e-12))).tolist(),
+ "dc_offset": (sums / frames).tolist(), "near_full_scale_samples": clipped,
+ "speech_level_estimate_dbfs": speech,
+ "quiet_blocks_dbfs": quiet,
+ "noise_floor_estimate_dbfs": quiet if noise_available else None,
+ "noise_floor_confidence": "low (quiet-block heuristic, not speech recognition)" if noise_available else "insufficient room tone"}
+
+
+def loudness(ffmpeg, source, settings, duration, progress, stage="Measuring loudness"):
+ output = ffmpeg_run(ffmpeg, ["-i", str(source), "-map", "0:a:0", "-af",
+ f"loudnorm=I={settings.target_lufs}:TP={settings.true_peak_db}:LRA={settings.loudness_range}:print_format=json",
+ "-f", "null", "-"], stage, duration, progress)
+ start, end = output.rfind("{"), output.rfind("}")
+ if start < 0 or end < start:
+ raise RuntimeError("FFmpeg did not return loudness measurements")
+ raw = json.loads(output[start:end + 1])
+ return {key: (float(value) if math.isfinite(float(value)) else None)
+ for key, value in raw.items() if key != "normalization_type"} | {
+ "normalization_type": raw.get("normalization_type")}