aboutsummaryrefslogtreecommitdiff
path: root/lib/project/src/voiceforge/pipeline.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/pipeline.py
parent39b0f2bbed74f6487a41b82501ae3c6799e4b5c4 (diff)
downloadproducer-84dd2d068317998f6fb59400c534ef5be6b51b53.tar.gz
slop rewriteHEADmain
Diffstat (limited to 'lib/project/src/voiceforge/pipeline.py')
-rw-r--r--lib/project/src/voiceforge/pipeline.py199
1 files changed, 199 insertions, 0 deletions
diff --git a/lib/project/src/voiceforge/pipeline.py b/lib/project/src/voiceforge/pipeline.py
new file mode 100644
index 0000000..a143dcf
--- /dev/null
+++ b/lib/project/src/voiceforge/pipeline.py
@@ -0,0 +1,199 @@
+"""Offline mono voice mastering with float intermediates and atomic outputs."""
+from dataclasses import asdict
+import fcntl
+import json
+import math
+import os
+from pathlib import Path
+import tempfile
+
+from . import __version__
+from .audio import ffmpeg_run, loudness, measure
+from .config import Settings
+from .setup import ensure_ffmpeg
+
+
+def filters(settings: Settings) -> list[str]:
+ s = settings
+ result = []
+ if s.expansion:
+ result.append(f"agate=threshold={10 ** (s.expansion_threshold_db / 20)}:ratio={s.expansion_ratio}:range={10 ** (-s.expansion_range_db / 20)}:attack=10:release=250:detection=rms")
+ if s.eq:
+ result += [f"equalizer=f=140:t=q:w=0.7:g={s.warmth_db}",
+ f"equalizer=f=300:t=q:w=0.8:g={s.mud_db}",
+ f"equalizer=f=3500:t=q:w=0.7:g={s.presence_db}"]
+ if s.compression:
+ result.append(f"acompressor=threshold={10 ** (s.compressor_threshold_db / 20)}:ratio={s.compressor_ratio}:attack={s.compressor_attack_ms}:release={s.compressor_release_ms}:knee=4:makeup=1:detection=rms")
+ if s.deess:
+ result.append(f"deesser=i={s.deess_intensity}:m={s.deess_amount}:f=0.5")
+ if s.lowpass:
+ result.append(f"lowpass=f={s.lowpass_hz}:p=2")
+ return result or ["anull"]
+
+
+def targets_for(destination: Path, settings: Settings) -> list[Path]:
+ """Every path a successful run publishes: master WAV, report, optional MP3."""
+ targets = [destination, destination.with_suffix(".report.json")]
+ if settings.mp3:
+ targets.append(destination.with_suffix(".mp3"))
+ return targets
+
+
+def check_target(source: Path, target: Path, overwrite: bool) -> None:
+ """Preflight one published path; the original recording is never touched."""
+ if target.resolve() == source or (target.exists() and os.path.samefile(source, target)):
+ raise ValueError("Refusing to overwrite the original recording")
+ # lexists also rejects dangling symlinks, which publication would only hit late.
+ if os.path.lexists(target) and not overwrite:
+ raise FileExistsError(f"Output exists: {target}; use --overwrite to replace outputs")
+ if target.exists() and not target.is_file():
+ raise ValueError(f"Output is not a regular file: {target}")
+
+
+def publish(source: Path, target: Path, overwrite: bool):
+ if overwrite:
+ os.replace(source, target)
+ else:
+ # Link is atomic and fails if another process created the destination.
+ os.link(source, target)
+ source.unlink()
+
+
+def process(source: Path, destination: Path, settings: Settings, progress,
+ overwrite=False, provenance: dict | None = None) -> dict:
+ source, destination = source.resolve(), destination.absolute()
+ if not source.is_file() or source.suffix.lower() != ".wav":
+ raise ValueError(f"Input must be an existing WAV file: {source}")
+ if destination.suffix.lower() != ".wav":
+ raise ValueError("Master output must have a .wav extension")
+ report_path = destination.with_suffix(".report.json")
+ for target in targets_for(destination, settings):
+ check_target(source, target, overwrite)
+ ffmpeg = ensure_ffmpeg()
+ original = measure(source, progress, "Analyzing original")
+ if original["sample_peak_dbfs"] < -100:
+ raise ValueError("Recording is silent or too quiet to master safely")
+ channel = settings.channel
+ if original["channels"] == 2 and channel == "auto":
+ raise ValueError("Stereo input: select --channel left, right, or mix explicitly. Auto downmix could cancel your voice.")
+ if original["channels"] == 1 and channel == "right":
+ raise ValueError("Cannot select the right channel of a mono recording")
+ duration = original["duration_seconds"]
+ warnings = []
+ if original["near_full_scale_samples"]:
+ warnings.append("Input has near-full-scale samples: inspect for clipping; lost peaks cannot be reliably restored.")
+ if max(abs(x) for x in original["dc_offset"]) > 0.01:
+ warnings.append("Significant input DC offset detected; enable highpass filtering to remove it.")
+ destination.parent.mkdir(parents=True, exist_ok=True)
+ with tempfile.TemporaryDirectory(prefix=".voiceforge-", dir=destination.parent) as temporary:
+ work = Path(temporary)
+ prepared, cleaned, shaped = [work / name for name in ("prepared.wav", "cleaned.wav", "shaped.wav")]
+ input_filters = []
+ if original["channels"] == 2:
+ input_filters.append({"left": "pan=mono|c0=c0", "right": "pan=mono|c0=c1",
+ "mix": "pan=mono|c0=0.5*c0+0.5*c1"}[channel])
+ if settings.highpass:
+ input_filters.append(f"highpass=f={settings.highpass_hz}:p=2")
+ if settings.hum_hz:
+ input_filters += [f"equalizer=f={settings.hum_hz * n}:t=q:w=25:g=-18" for n in (1, 2, 3)]
+ ffmpeg_run(ffmpeg, ["-i", str(source), "-map", "0:a:0", "-af",
+ ",".join(input_filters or ["anull"]), "-ar", "48000", "-ac", "1",
+ "-c:a", "pcm_f32le", "-rf64", "auto", str(prepared)], "Preparing mono 48 kHz audio", duration, progress)
+ if settings.denoiser == "deepfilter" and settings.denoise_strength > 0:
+ from .ai import denoise
+ denoise(prepared, cleaned, settings.device, settings.denoise_strength, progress)
+ elif settings.denoiser == "fft" and settings.denoise_strength > 0:
+ ffmpeg_run(ffmpeg, ["-i", str(prepared), "-af",
+ f"afftdn=nr={max(0.01, settings.fft_reduction_db * settings.denoise_strength)}:tn=1",
+ "-c:a", "pcm_f32le", "-rf64", "auto", str(cleaned)], "FFT noise reduction", duration, progress)
+ else:
+ cleaned = prepared
+ clean_stats = measure(cleaned, progress, "Measuring cleaned voice")
+ if clean_stats["sample_peak_dbfs"] < -100:
+ raise ValueError("The selected audio is silent or too quiet to master safely; "
+ "check --channel, the mix, and cleanup settings")
+ gain = 0.0
+ if settings.leveling:
+ speech_db = clean_stats["speech_level_estimate_dbfs"]
+ if speech_db <= -110:
+ warnings.append("Speech level estimate is unreliable; leveling skipped. "
+ "Inspect levels or rely on normalization.")
+ else:
+ desired = settings.level_target_db - speech_db
+ gain = max(-settings.max_gain_db, min(settings.max_gain_db, desired))
+ if abs(desired) > settings.max_gain_db:
+ warnings.append("Input level correction reached its configured gain limit.")
+ chain = ([f"volume={gain}dB"] if gain else []) + filters(settings)
+ ffmpeg_run(ffmpeg, ["-i", str(cleaned), "-af", ",".join(chain),
+ "-c:a", "pcm_f32le", "-rf64", "auto", str(shaped)], "EQ, dynamics and de-essing", duration, progress)
+ before = loudness(ffmpeg, shaped, settings, duration, progress, "Loudness analysis (pass 1)")
+ if settings.normalize and any(before.get(k) is None for k in ("input_i", "input_tp", "input_lra", "input_thresh", "target_offset")):
+ raise ValueError("Cannot normalize: insufficient measurable audio after processing")
+ final_filters = []
+ if settings.normalize:
+ final_filters.append(f"loudnorm=I={settings.target_lufs}:TP={settings.true_peak_db}:LRA={settings.loudness_range}:"
+ f"measured_I={before['input_i']}:measured_TP={before['input_tp']}:measured_LRA={before['input_lra']}:"
+ f"measured_thresh={before['input_thresh']}:offset={before['target_offset']}:linear=true:print_format=json")
+ elif settings.limiter:
+ final_filters += ["aresample=192000", f"alimiter=limit={10 ** (settings.true_peak_db / 20)}:level=false:latency=true"]
+ final_filters.append(f"aresample={settings.sample_rate}:output_sample_bits=24:dither_method=triangular")
+ master = work / "master.wav"
+ render_log = ffmpeg_run(ffmpeg, ["-i", str(shaped), "-af", ",".join(final_filters),
+ "-ar", str(settings.sample_rate), "-c:a", "pcm_s24le", "-rf64", "auto", str(master)],
+ "Mastering (pass 2)", duration, progress)
+ # Bounded disk use: drop consumed intermediates instead of waiting for
+ # the temporary directory teardown at the end of the run.
+ shaped.unlink(missing_ok=True)
+ for intermediate in {prepared, cleaned}:
+ intermediate.unlink(missing_ok=True)
+ normalization_mode = "disabled"
+ if settings.normalize:
+ start, end = render_log.rfind("{"), render_log.rfind("}")
+ if start < 0 or end < start:
+ raise RuntimeError("FFmpeg did not report the applied normalization mode")
+ normalization_mode = json.loads(render_log[start:end + 1])["normalization_type"]
+ final = loudness(ffmpeg, master, settings, duration, progress, "Verifying exported master")
+ final_stats = measure(master, progress, "Checking exported samples")
+ if abs(final_stats["duration_seconds"] - duration) > 0.02:
+ raise RuntimeError("Output duration differs from input by more than 20 ms")
+ if (final_stats["channels"] != 1 or final_stats["sample_rate"] != settings.sample_rate
+ or final_stats["subtype"] != "PCM_24"):
+ raise RuntimeError("Exported master is not the expected mono PCM_24 WAV")
+ if settings.normalize and (final["input_i"] is None or abs(final["input_i"] - settings.target_lufs) > 0.5):
+ warnings.append("Master misses the loudness target by more than 0.5 LU; inspect the report before publishing.")
+ if settings.limiter and final["input_tp"] is not None and final["input_tp"] > settings.true_peak_db + 0.1:
+ warnings.append("Master exceeds the true-peak target by more than 0.1 dB; inspect before publishing.")
+ if final_stats["near_full_scale_samples"]:
+ warnings.append("Export has near-full-scale samples. Enable limiting or lower gains.")
+ mp3_stats = None
+ if settings.mp3:
+ mp3 = work / "delivery.mp3"
+ ffmpeg_run(ffmpeg, ["-i", str(master), "-c:a", "libmp3lame", "-b:a",
+ f"{settings.mp3_bitrate}k", str(mp3)], "Encoding MP3", duration, progress)
+ mp3_stats = loudness(ffmpeg, mp3, settings, duration, progress, "Verifying decoded MP3")
+ if mp3_stats["input_tp"] is not None and mp3_stats["input_tp"] > settings.true_peak_db + 0.1:
+ warnings.append("MP3 encoding increased true peak beyond the configured ceiling; use more headroom.")
+ report = {"voiceforge_version": __version__, "source": str(source),
+ "output": str(destination), "settings": asdict(settings), "input": original,
+ "cleaned": clean_stats, "level_correction_db": gain,
+ "pre_master_loudness": before, "master_loudness": final,
+ "master": final_stats, "mp3_loudness": mp3_stats,
+ "normalization_mode": normalization_mode,
+ "warnings": warnings}
+ if provenance:
+ report["provenance"] = provenance
+ staged_report = work / "report.json"
+ staged_report.write_text(json.dumps(report, indent=2, allow_nan=False) + "\n")
+ # Publish only after all processing and verification succeeded. The
+ # directory lock serializes publication so concurrent jobs targeting
+ # the same output directory cannot interleave WAV/report/MP3 sets.
+ publication_fd = os.open(destination.parent, os.O_RDONLY)
+ try:
+ fcntl.flock(publication_fd, fcntl.LOCK_EX)
+ if settings.mp3:
+ publish(mp3, destination.with_suffix(".mp3"), overwrite)
+ publish(staged_report, report_path, overwrite)
+ publish(master, destination, overwrite)
+ finally:
+ os.close(publication_fd)
+ return report