diff options
| author | historia <historiavg@proton.me> | 2026-09-07 06:47:47 -0400 |
|---|---|---|
| committer | historia <historiavg@proton.me> | 2026-09-07 06:47:47 -0400 |
| commit | 84dd2d068317998f6fb59400c534ef5be6b51b53 (patch) | |
| tree | 025293e9d9229e02960374771ae522d9de2628ce /lib/project/src/voiceforge/audio.py | |
| parent | 39b0f2bbed74f6487a41b82501ae3c6799e4b5c4 (diff) | |
| download | producer-84dd2d068317998f6fb59400c534ef5be6b51b53.tar.gz | |
Diffstat (limited to 'lib/project/src/voiceforge/audio.py')
| -rw-r--r-- | lib/project/src/voiceforge/audio.py | 128 |
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")} |
