aboutsummaryrefslogtreecommitdiff
path: root/lib/project/src/voiceforge/pipeline.py
diff options
context:
space:
mode:
authorhistoria <historiavg@proton.me>2026-09-11 17:41:33 -0400
committerhistoria <historiavg@proton.me>2026-09-11 17:41:33 -0400
commit077c82d9614328ff267632298da6c88009313337 (patch)
tree412f9e2d60c320e0c014eda45e76e36205b526ed /lib/project/src/voiceforge/pipeline.py
parent84dd2d068317998f6fb59400c534ef5be6b51b53 (diff)
downloadproducer-077c82d9614328ff267632298da6c88009313337.tar.gz
fix: consistently rename to producer
Diffstat (limited to 'lib/project/src/voiceforge/pipeline.py')
-rw-r--r--lib/project/src/voiceforge/pipeline.py199
1 files changed, 0 insertions, 199 deletions
diff --git a/lib/project/src/voiceforge/pipeline.py b/lib/project/src/voiceforge/pipeline.py
deleted file mode 100644
index a143dcf..0000000
--- a/lib/project/src/voiceforge/pipeline.py
+++ /dev/null
@@ -1,199 +0,0 @@
-"""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