diff options
| author | historia <historiavg@proton.me> | 2026-09-11 17:41:33 -0400 |
|---|---|---|
| committer | historia <historiavg@proton.me> | 2026-09-11 17:41:33 -0400 |
| commit | 077c82d9614328ff267632298da6c88009313337 (patch) | |
| tree | 412f9e2d60c320e0c014eda45e76e36205b526ed /lib/project/src/voiceforge/pipeline.py | |
| parent | 84dd2d068317998f6fb59400c534ef5be6b51b53 (diff) | |
| download | producer-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.py | 199 |
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 |
