From 077c82d9614328ff267632298da6c88009313337 Mon Sep 17 00:00:00 2001 From: historia Date: Fri, 11 Sep 2026 17:41:33 -0400 Subject: fix: consistently rename to producer --- lib/project/examples/voice.toml | 2 +- lib/project/pyproject.toml | 8 +- lib/project/src/producer/__init__.py | 3 + lib/project/src/producer/__main__.py | 3 + lib/project/src/producer/ai.py | 157 ++++++++++++ lib/project/src/producer/ai_worker.py | 161 ++++++++++++ lib/project/src/producer/audio.py | 128 ++++++++++ lib/project/src/producer/cli.py | 193 ++++++++++++++ lib/project/src/producer/config.py | 118 +++++++++ lib/project/src/producer/pipeline.py | 199 +++++++++++++++ lib/project/src/producer/progress.py | 40 +++ lib/project/src/producer/setup.py | 203 +++++++++++++++ lib/project/src/voiceforge/__init__.py | 3 - lib/project/src/voiceforge/__main__.py | 3 - lib/project/src/voiceforge/ai.py | 157 ------------ lib/project/src/voiceforge/ai_worker.py | 161 ------------ lib/project/src/voiceforge/audio.py | 128 ---------- lib/project/src/voiceforge/cli.py | 193 -------------- lib/project/src/voiceforge/config.py | 118 --------- lib/project/src/voiceforge/pipeline.py | 199 --------------- lib/project/src/voiceforge/progress.py | 40 --- lib/project/src/voiceforge/setup.py | 203 --------------- lib/project/tests/conftest.py | 4 +- lib/project/tests/test_ai.py | 24 +- lib/project/tests/test_cli.py | 6 +- lib/project/tests/test_config.py | 12 +- lib/project/tests/test_install.py | 38 +-- lib/project/tests/test_pipeline.py | 18 +- lib/project/uv.lock | 430 ++++++++++++++++++++++++++++++++ 29 files changed, 1691 insertions(+), 1261 deletions(-) create mode 100644 lib/project/src/producer/__init__.py create mode 100644 lib/project/src/producer/__main__.py create mode 100644 lib/project/src/producer/ai.py create mode 100644 lib/project/src/producer/ai_worker.py create mode 100644 lib/project/src/producer/audio.py create mode 100644 lib/project/src/producer/cli.py create mode 100644 lib/project/src/producer/config.py create mode 100644 lib/project/src/producer/pipeline.py create mode 100644 lib/project/src/producer/progress.py create mode 100644 lib/project/src/producer/setup.py delete mode 100644 lib/project/src/voiceforge/__init__.py delete mode 100644 lib/project/src/voiceforge/__main__.py delete mode 100644 lib/project/src/voiceforge/ai.py delete mode 100644 lib/project/src/voiceforge/ai_worker.py delete mode 100644 lib/project/src/voiceforge/audio.py delete mode 100644 lib/project/src/voiceforge/cli.py delete mode 100644 lib/project/src/voiceforge/config.py delete mode 100644 lib/project/src/voiceforge/pipeline.py delete mode 100644 lib/project/src/voiceforge/progress.py delete mode 100644 lib/project/src/voiceforge/setup.py create mode 100644 lib/project/uv.lock (limited to 'lib') diff --git a/lib/project/examples/voice.toml b/lib/project/examples/voice.toml index 6a5251a..0905815 100644 --- a/lib/project/examples/voice.toml +++ b/lib/project/examples/voice.toml @@ -50,7 +50,7 @@ loudness_range = 9.0 # 1..20 LU limiter = true true_peak_db = -1.5 # -9..-0.5 dBTP; MP3 can overshoot, inspect report warnings -# Final exports are always mono, 24-bit PCM WAV, with an optional MP3 sidecar. +# Final exports are always mono, 32-bit PCM WAV, with an optional MP3 sidecar. sample_rate = 48000 # 44100 or 48000; intermediate processing is 48 kHz float mp3 = false mp3_bitrate = 192 # 128, 160, 192, 224, 256 or 320 kb/s diff --git a/lib/project/pyproject.toml b/lib/project/pyproject.toml index 082b944..625ee23 100644 --- a/lib/project/pyproject.toml +++ b/lib/project/pyproject.toml @@ -3,8 +3,8 @@ requires = ["hatchling==1.27.0"] build-backend = "hatchling.build" [project] -name = "voiceforge" -version = "0.2.0" +name = "producer" +version = "0.3.0" description = "Local, configurable speech cleanup and podcast mastering" license = "0BSD" requires-python = ">=3.11" @@ -17,13 +17,13 @@ test = ["pytest>=8,<10"] bundled-ffmpeg = ["imageio-ffmpeg==0.6.0"] [project.scripts] -voiceforge = "voiceforge.cli:main" +producer = "producer.cli:main" [tool.pytest.ini_options] testpaths = ["tests"] [tool.hatch.build.targets.wheel] -packages = ["src/voiceforge"] +packages = ["src/producer"] [tool.hatch.build.targets.sdist] include = ["/src", "/tests", "/examples", "/pyproject.toml"] diff --git a/lib/project/src/producer/__init__.py b/lib/project/src/producer/__init__.py new file mode 100644 index 0000000..0bfffec --- /dev/null +++ b/lib/project/src/producer/__init__.py @@ -0,0 +1,3 @@ +"""Producer: local speech cleanup and mastering.""" + +__version__ = "0.3.0" diff --git a/lib/project/src/producer/__main__.py b/lib/project/src/producer/__main__.py new file mode 100644 index 0000000..4e28416 --- /dev/null +++ b/lib/project/src/producer/__main__.py @@ -0,0 +1,3 @@ +from .cli import main + +main() diff --git a/lib/project/src/producer/ai.py b/lib/project/src/producer/ai.py new file mode 100644 index 0000000..7598222 --- /dev/null +++ b/lib/project/src/producer/ai.py @@ -0,0 +1,157 @@ +"""Automatic, isolated DeepFilterNet3 inference (Linux, Python 3.11 worker). + +Public progress callback: (stage: str, completed: float | None, total: float | None). +Known totals are audio frames or download bytes (identified by stage); +unknown totals use elapsed seconds as completed. +First use downloads Python, wheels, and upstream model weights into the +Producer data directory (PRODUCER_HOME prefix or the XDG data location). +""" +from __future__ import annotations + +import ctypes +import fcntl +import math +import os +from pathlib import Path +import platform +import shutil +import time + +from .setup import Progress, data_dir, ensure_uv, run_process, tool_env + + +def _cuda_available() -> bool: + # Probe the driver without importing Torch or installing CUDA in the host env. + if os.environ.get("CUDA_VISIBLE_DEVICES") in {"", "-1"}: + return False + try: + driver = ctypes.CDLL("libcuda.so.1") + count = ctypes.c_int() + return driver.cuInit(0) == 0 and driver.cuDeviceGetCount(ctypes.byref(count)) == 0 and count.value > 0 + except (OSError, AttributeError): + return False + + +def _flavor(device: str) -> str: + if device not in {"auto", "cpu", "cuda"}: + raise ValueError("device must be 'auto', 'cpu', or 'cuda'") + if device == "cuda" or (device == "auto" and _cuda_available()): + if platform.machine() not in {"x86_64", "AMD64"}: + raise RuntimeError("The pinned CUDA 12.1 backend requires Linux x86_64.") + return "cu121" + return "cpu" + + +def _worker_env() -> dict[str, str]: + # Same isolation as tool_env(); kept as a named seam for worker launches. + return tool_env() + + +def _interpreter_is_local(python: Path, root: Path) -> bool: + """Whether the environment's interpreter belongs to this prefix. + + An installation duplicated by copying (while the original remained) keeps + interpreter links that resolve into the original directory. Such an + environment must be rebuilt, or the copy would silently depend on files + outside this prefix and break when the original is removed. + """ + try: + return python.resolve().is_relative_to(root) + except OSError: + return False + + +def ensure_ai(device: str = "auto", progress: Progress | None = None, + verify: bool = False) -> Path: + """Install/validate the backend and model; return its Python executable. + + CPU and CUDA environments are separate. Explicit CUDA never falls back; + auto uses Torch's availability check in the selected worker. Downloads trust + PyPI, download.pytorch.org, Astral's Python distribution, and upstream DF. + verify forces a real self-test even when the environment is marked ready + ('setup' uses this); ordinary processing trusts the marker so a ready + environment does not pay for a second model load per run. + """ + flavor = _flavor(device) + try: + libc = os.confstr("CS_GNU_LIBC_VERSION") or "" + except (ValueError, OSError): + libc = "" + if platform.system() != "Linux" or not libc.startswith("glibc ") or tuple( + int(part) for part in libc.split()[1].split(".")[:2] + ) < (2, 28): + raise RuntimeError("The pinned DeepFilterNet3 backend requires Linux with glibc >= 2.28 " + "(for example Ubuntu 22.04+ or Debian 12+). " + "Alpine/musl is not supported by its prebuilt wheels.") + root = data_dir() + root.mkdir(parents=True, exist_ok=True) + environment = root / f"ai-df-0.5.6-torch-2.5.1-{flavor}-py311-v1" + python = environment / "bin/python" + marker = environment / ".producer-ready" + started = time.monotonic() + # Serialize bootstrap/model downloads, including calls from separate CLIs. + with (root / "ai.lock").open("a") as lock: + while True: + try: + fcntl.flock(lock, fcntl.LOCK_EX | fcntl.LOCK_NB) + break + except BlockingIOError: + if progress: + progress("Waiting for AI setup", time.monotonic() - started, None) + time.sleep(0.25) + ready = (marker.is_file() and python.is_file() + and _interpreter_is_local(python, root)) + if not ready: + # A moved, copied, or half-installed environment cannot be + # repaired in place: interpreter links reference absolute paths. + # Rebuild it; the shared wheel caches make this fast, usually + # offline. + if environment.exists(): + shutil.rmtree(environment) + uv = ensure_uv(progress) + env = _worker_env() + run_process([uv, "venv", "--python", "3.11", "--managed-python", + "--relocatable", "--allow-existing", str(environment)], + "Preparing AI Python 3.11", progress, env=env) + run_process([uv, "pip", "install", "--python", str(python), + "--index-url", f"https://download.pytorch.org/whl/{flavor}", + "torch==2.5.1", "torchaudio==2.5.1"], + f"Installing PyTorch ({flavor})", progress, env=env) + run_process([uv, "pip", "install", "--python", str(python), + "--only-binary", ":all:", + "--index-url", "https://pypi.org/simple", "deepfilternet==0.5.6", + "numpy==1.26.4", "soundfile==0.12.1"], + "Installing DeepFilterNet3", progress, env=env) + # Check a freshly built environment (and any explicit setup) with a + # real model load and inference; this also downloads the weights. + if verify or not ready: + run_process([str(python), "-I", str(Path(__file__).with_name("ai_worker.py")), + "--device", device, "--model-dir", str(root / "models/df3-0.5.6"), + "--check"], "Checking DeepFilterNet3", progress, + env=_worker_env()) + marker.touch() + return python + + +def denoise(source: Path, target: Path, device: str = "auto", strength: float = 1.0, + progress: Progress | None = None) -> None: + """Denoise mono 48 kHz WAV to float WAV, preserving the exact sample count. + + strength is a dry/wet mix in [0, 1]. The worker publishes output atomically; + errors/cancellation leave an existing target untouched. Chunked inference is + bounded-memory, not bit-identical to inference over an entire recording. + """ + if not math.isfinite(strength) or not 0 <= strength <= 1: + raise ValueError("strength must be finite and between 0 and 1") + source, target = Path(source).resolve(), Path(target).resolve() + if source == target: + raise ValueError("source and target must be different files") + if not source.is_file(): + raise FileNotFoundError(source) + if not target.parent.is_dir(): + raise FileNotFoundError(target.parent) + python = ensure_ai(device, progress) + run_process([str(python), "-I", str(Path(__file__).with_name("ai_worker.py")), + "--device", device, "--source", str(source), "--target", str(target), + "--model-dir", str(data_dir() / "models/df3-0.5.6"), + "--strength", str(strength)], "Denoising", progress, env=_worker_env()) diff --git a/lib/project/src/producer/ai_worker.py b/lib/project/src/producer/ai_worker.py new file mode 100644 index 0000000..a1a197b --- /dev/null +++ b/lib/project/src/producer/ai_worker.py @@ -0,0 +1,161 @@ +"""Private script executed only by the isolated backend Python.""" +from __future__ import annotations + +import argparse +import hashlib +import json +import os +from pathlib import Path +import signal +import tempfile +import urllib.request +import zipfile + + +def report(stage: str, completed: float | None = None, total: float | None = None) -> None: + print("PRODUCER_PROGRESS " + json.dumps([stage, completed, total]), flush=True) + + +def ensure_model(directory: Path) -> Path: + """Install a release-pinned, hash-checked archive atomically under setup lock.""" + model = directory / "DeepFilterNet3" + if (model / "config.ini").is_file() and (model / "checkpoints/model_120.ckpt.best").is_file(): + return model + if directory.exists(): + raise RuntimeError(f"Incomplete model directory: {directory}. Remove it and retry AI setup.") + directory.parent.mkdir(parents=True, exist_ok=True) + url = "https://raw.githubusercontent.com/Rikorose/DeepFilterNet/v0.5.6/models/DeepFilterNet3.zip" + expected = "49c52edc8947ae1f9bf50d81530beaf3a2c3245aeaf34b6f31ff535cd22284d2" + # Release archive size is fixed alongside its hash, even without Content-Length. + expected_bytes = 7986207 + downloaded = 0 + report("Downloading DeepFilterNet3 model (bytes)", 0, expected_bytes) + with tempfile.TemporaryDirectory(prefix=".df3-", dir=directory.parent) as staging: + archive = Path(staging) / "model.zip" + digest = hashlib.sha256() + with urllib.request.urlopen(url, timeout=60) as response, archive.open("wb") as output: + while chunk := response.read(256 * 1024): + downloaded += len(chunk) + if downloaded > expected_bytes: + raise RuntimeError("DeepFilterNet3 model download exceeds the expected size") + digest.update(chunk) + output.write(chunk) + report("Downloading DeepFilterNet3 model (bytes)", downloaded, expected_bytes) + if downloaded != expected_bytes or digest.hexdigest() != expected: + raise RuntimeError("DeepFilterNet3 model checksum mismatch; refusing to load weights") + report("Verifying and extracting DeepFilterNet3 model") + unpacked = Path(staging) / "unpacked" + with zipfile.ZipFile(archive) as bundle: + for member in bundle.infolist(): + destination = (unpacked / member.filename).resolve() + if not destination.is_relative_to(unpacked.resolve()): + raise RuntimeError("Unsafe path in model archive") + bundle.extractall(unpacked) + os.replace(unpacked, directory) + return model + + +def main() -> None: + parser = argparse.ArgumentParser() + parser.add_argument("--device", choices=("auto", "cpu", "cuda"), required=True) + parser.add_argument("--check", action="store_true") + parser.add_argument("--model-dir", type=Path, required=True) + parser.add_argument("--source", type=Path) + parser.add_argument("--target", type=Path) + parser.add_argument("--strength", type=float, default=1.0) + args = parser.parse_args() + + # SIGTERM from the parent takes the same cleanup path as Ctrl-C. + def terminate(signum, frame): + raise KeyboardInterrupt + + signal.signal(signal.SIGTERM, terminate) + + import numpy as np + import soundfile as sf + import torch + + torch.set_num_threads(min(4, os.cpu_count() or 1)) + available = torch.cuda.is_available() + if args.device == "cuda" and not available: + raise RuntimeError("CUDA was explicitly requested, but PyTorch cannot use it. " + "Check the NVIDIA driver and CUDA_VISIBLE_DEVICES, or select cpu.") + device = "cuda" if args.device != "cpu" and available else "cpu" + # df.utils.get_device() consults DEVICE on every call, including model creation. + os.environ["DEVICE"] = device + from df.enhance import enhance, init_df + from df.model import ModelParams + from libdf import DF + + model_path = ensure_model(args.model_dir) + report(f"Loading DeepFilterNet3 ({device})") + model, state, _ = init_df(str(model_path), log_level="ERROR", log_file=None) + if state.sr() != 48000: + raise RuntimeError("DeepFilterNet3 model must use 48 kHz") + if args.check: + # Exercise both model execution and the compiled libdf/NumPy ABI. + sample = enhance(model, state, torch.zeros(1, 4800), pad=True) + if sample.shape != (1, 4800) or not torch.isfinite(sample).all(): + raise RuntimeError("DeepFilterNet3 self-test failed") + report(f"DeepFilterNet3 ready ({device})", 1, 1) + return + if args.source is None or args.target is None: + parser.error("--source and --target are required unless --check is used") + if not np.isfinite(args.strength) or not 0 <= args.strength <= 1: + parser.error("--strength must be in [0, 1]") + + temporary = None + try: + with sf.SoundFile(args.source) as source: + if source.samplerate != 48000 or source.channels != 1 or source.format not in {"WAV", "WAVEX", "RF64"}: + raise ValueError("AI input must be a mono 48 kHz WAV; convert it before denoising") + frames = len(source) + fd, name = tempfile.mkstemp(prefix=".producer-ai-", suffix=".wav", dir=args.target.parent) + os.close(fd) + temporary = Path(name) + # RF64 avoids RIFF's 4 GiB limit on very long recordings. + output_format = "RF64" if frames * 4 > 0xFFFFFFFF - 4096 else "WAV" + with sf.SoundFile(temporary, "w", samplerate=48000, channels=1, + format=output_format, subtype="FLOAT") as target: + block, context, overlap = 480000, 96000, 2400 + previous = None + params = ModelParams() + report(f"Denoising ({device})", 0, frames) + for start in range(0, frames, block): + end = min(start + block, frames) + left, right = max(0, start - context), min(frames, end + overlap + context) + source.seek(left) + audio = source.read(right - left, dtype="float32") + if not np.isfinite(audio).all(): + raise ValueError("Input contains non-finite audio samples") + if args.strength: + # Fresh STFT state per window; context warms normalization and + # recurrent layers. Pad to a whole hop before delay compensation. + state = DF(sr=params.sr, fft_size=params.fft_size, + hop_size=params.hop_size, nb_bands=params.nb_erb, + min_nb_erb_freqs=params.min_nb_freqs) + padded = np.pad(audio, (0, (-len(audio)) % params.hop_size)) + wet = enhance(model, state, torch.from_numpy(padded).unsqueeze(0), + pad=True).squeeze(0).numpy()[:len(audio)] + if len(wet) != len(audio) or not np.isfinite(wet).all(): + raise RuntimeError("DeepFilterNet3 returned invalid audio") + audio = audio * (1 - args.strength) + wet * args.strength + kept = audio[start - left:min(frames, end + overlap) - left].copy() + if previous is not None and args.strength: + n = len(previous) + fade = np.linspace(0, 1, n, dtype=np.float32) + kept[:n] = previous * (1 - fade) + kept[:n] * fade + target.write(kept[:end - start]) + previous = kept[end - start:].copy() + report(f"Denoising ({device})", end, frames) + if target.tell() != frames: + raise RuntimeError("Output frame count does not match input") + os.replace(temporary, args.target) + temporary = None + finally: + if temporary is not None: + temporary.unlink(missing_ok=True) + + +if __name__ == "__main__": + main() diff --git a/lib/project/src/producer/audio.py b/lib/project/src/producer/audio.py new file mode 100644 index 0000000..7fee0c2 --- /dev/null +++ b/lib/project/src/producer/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")} diff --git a/lib/project/src/producer/cli.py b/lib/project/src/producer/cli.py new file mode 100644 index 0000000..52cd1bd --- /dev/null +++ b/lib/project/src/producer/cli.py @@ -0,0 +1,193 @@ +"""Command-line entry point; configuration errors fail before model downloads.""" +import argparse +from dataclasses import asdict, fields +import json +import math +import os +from pathlib import Path +import subprocess +import sys +import tempfile +import tomllib + +from rich.console import Console + +from . import __version__ +from .audio import ffmpeg_run, loudness, measure +from .config import PROFILES, Settings, resolve, toml +from .pipeline import check_target, process, targets_for +from .progress import Display +from .setup import data_dir, ensure_ffmpeg + + +def parser(): + app = argparse.ArgumentParser(prog="producer", description="Local speech cleanup and podcast mastering. Originals are never overwritten.") + app.add_argument("--version", action="version", version=__version__) + sub = app.add_subparsers(dest="command", required=True) + for name in ("process", "preview", "config", "analyze"): + cmd = sub.add_parser(name) + cmd.add_argument("--config", type=Path, help="TOML configuration file") + cmd.add_argument("--profile", choices=PROFILES, default=None) + cmd.add_argument("--set", action="append", default=[], metavar="KEY=VALUE", help="Override any setting using TOML value syntax") + for field in fields(Settings): + if field.name == "profile": + continue + default = getattr(Settings(), field.name) + option = "--" + field.name.replace("_", "-") + if isinstance(default, bool): + cmd.add_argument(option, action=argparse.BooleanOptionalAction, default=None) + else: + cmd.add_argument(option, type=type(default), default=None) + cmd.add_argument("--no-denoise", dest="denoiser", action="store_const", const="none") + if name == "config": + cmd.add_argument("--json", action="store_true") + else: + cmd.add_argument("inputs", type=Path, nargs="+" if name == "process" else 1) + if name in ("process", "preview"): + cmd.add_argument("--output-dir", type=Path, default=None, + help="Directory for generated outputs (default: beside each input)") + cmd.add_argument("--overwrite", action="store_true", help="Replace generated outputs, never input files") + if name == "process": + cmd.add_argument("-o", "--output", type=Path, help="Explicit WAV destination (one input only)") + if name == "preview": + cmd.add_argument("--start", type=float, default=0) + cmd.add_argument("--duration", type=float, default=30) + cmd.add_argument("--profiles", nargs="+", choices=PROFILES, default=["natural", "narrator", "radio"]) + sub.add_parser("doctor", help="Check tools and driver without downloading AI") + setup = sub.add_parser("setup", help="Download and self-test the isolated AI backend") + setup.add_argument("--device", choices=("auto", "cpu", "cuda"), default="auto") + return app + + +def settings_for(args, profile=None): + overrides = {field.name: getattr(args, field.name) for field in fields(Settings) + if field.name != "profile" and getattr(args, field.name, None) is not None} + for item in args.set: + key, sep, value = item.partition("=") + if not sep: + raise ValueError("--set expects KEY=VALUE") + # Strings can be supplied without TOML quotes for CLI convenience. + try: + parsed = tomllib.loads("value = " + value)["value"] + except tomllib.TOMLDecodeError: + parsed = value + overrides[key.strip()] = parsed + return resolve(args.config, profile or args.profile, overrides) + + +def require_input(source: Path) -> None: + if not source.is_file() or source.suffix.lower() != ".wav": + raise ValueError(f"Input must be an existing WAV file: {source}") + + +def main(): + app = parser() + args = app.parse_args() + console = Console(stderr=True) + try: + if args.command == "doctor": + from .ai import _cuda_available + ffmpeg = ensure_ffmpeg() + version = subprocess.run([ffmpeg, "-version"], capture_output=True, text=True, check=True).stdout.splitlines()[0] + console.print(version, markup=False) + console.print(f"FFmpeg: {ffmpeg}\nData: {data_dir()}\nCUDA driver available: {_cuda_available()}\nAI is installed and self-tested by './producer.sh setup'.", markup=False) + return + if args.command == "setup": + from .ai import ensure_ai + with Display() as display: + ensure_ffmpeg() + python = ensure_ai(args.device, display.update, verify=True) + console.print(f"AI ready: {python}", markup=False) + return + settings = settings_for(args) + if args.command == "config": + print(json.dumps(asdict(settings), indent=2) if args.json else toml(settings), end="\n" if args.json else "") + return + with Display() as display: + if args.command == "analyze": + stats = measure(args.inputs[0], display.update) + stats["loudness"] = loudness(ensure_ffmpeg(), args.inputs[0], settings, stats["duration_seconds"], display.update) + print(json.dumps(stats, indent=2, allow_nan=False)) + return + if args.command == "process": + if args.output and len(args.inputs) != 1: + raise ValueError("--output requires exactly one input") + if args.output and args.output_dir: + raise ValueError("--output and --output-dir cannot be combined") + # Without an explicit destination, outputs are written beside + # each input; --output-dir keeps every result in one directory. + outputs = [args.output or (args.output_dir or p.parent) / f"{p.stem}.{settings.profile}.wav" + for p in args.inputs] + # Preflight the whole plan so a predictable conflict cannot + # fail a batch after earlier inputs have already been published. + sources = {p.resolve() for p in args.inputs} + claimed: set[Path] = set() + for source, output in zip(args.inputs, outputs): + require_input(source) + targets = targets_for(output.absolute(), settings) + resolved = {target.resolve() for target in targets} + if len(resolved) < len(targets) or resolved & claimed: + raise ValueError("Inputs have duplicate output names; process them separately with --output") + claimed |= resolved + if resolved & sources or any( + target.exists() and os.path.samefile(source, target) for target in targets): + raise ValueError("An output would overwrite an input recording") + for target in targets: + check_target(source, target, args.overwrite) + for source, output in zip(args.inputs, outputs): + report = process(source, output, settings, display.update, args.overwrite) + console.print(f"Written: {output} | {report['master_loudness']['input_i']} LUFS | {report['master_loudness']['input_tp']} dBTP", markup=False) + for warning in report["warnings"]: + console.print(f"Warning: {warning}", markup=False) + return + if not math.isfinite(args.start) or args.start < 0 or not 0 < args.duration <= 300: + raise ValueError("Preview start must be >= 0 and duration must be in (0, 300] seconds") + if len(set(args.profiles)) != len(args.profiles): + raise ValueError("Preview profiles must be unique") + if any(item.partition("=")[0].strip() == "profile" for item in args.set): + raise ValueError("Preview profiles come from --profiles; remove the profile override from --set") + source = args.inputs[0] + require_input(source) + original = source.resolve() + output_dir = args.output_dir or source.parent + # Preflight every preview destination before extracting the excerpt. + claimed: set[Path] = set() + for profile in args.profiles: + output = output_dir / f"{source.stem}.{profile}.preview.wav" + if output.resolve() == original: + raise ValueError("Preview output would overwrite the original") + targets = [output, output.with_suffix(".report.json")] + resolved = {target.resolve() for target in targets} + if len(resolved) < len(targets) or resolved & claimed: + raise ValueError("Preview outputs have duplicate names") + claimed |= resolved + for target in targets: + check_target(original, target, args.overwrite) + output_dir.mkdir(parents=True, exist_ok=True) + with tempfile.TemporaryDirectory(prefix=".preview-", dir=output_dir) as tmp: + excerpt = Path(tmp) / "excerpt.wav" + ffmpeg_run(ensure_ffmpeg(), ["-ss", str(args.start), "-i", str(original), "-t", str(args.duration), + "-map", "0:a:0", "-c:a", "pcm_f32le", str(excerpt)], "Extracting preview", args.duration, display.update) + for profile in args.profiles: + configured = settings_for(args, profile) + # Equal loudness prevents a louder preset winning an unfair A/B. + configured.normalize = True + configured.limiter = True + output = output_dir / f"{source.stem}.{profile}.preview.wav" + report = process(excerpt, output, configured, display.update, args.overwrite, + provenance={"original": str(original), "preview_start_seconds": args.start, + "requested_duration_seconds": args.duration, + "normalization_forced": True}) + console.print(f"Preview: {output} | {report['master_loudness']['input_i']} LUFS | {report['master_loudness']['input_tp']} dBTP", markup=False) + for warning in report["warnings"]: + console.print(f"Warning: {warning}", markup=False) + except KeyboardInterrupt: + console.print("Cancelled; temporary files removed. Completed outputs may remain.") + raise SystemExit(130) + except (ValueError, OSError, RuntimeError, subprocess.SubprocessError) as error: + console.print(f"Error: {error}", markup=False) + raise SystemExit(1) + + +if __name__ == "__main__": + main() diff --git a/lib/project/src/producer/config.py b/lib/project/src/producer/config.py new file mode 100644 index 0000000..ef0cd49 --- /dev/null +++ b/lib/project/src/producer/config.py @@ -0,0 +1,118 @@ +"""Flat, strict TOML configuration; CLI overrides are applied last.""" +from dataclasses import asdict, dataclass, fields +import math +from pathlib import Path +import tomllib + + +@dataclass +class Settings: + profile: str = "natural" + denoiser: str = "deepfilter" + device: str = "auto" + denoise_strength: float = 0.85 + fft_reduction_db: float = 8.0 + channel: str = "auto" + highpass: bool = True + highpass_hz: float = 70.0 + lowpass: bool = False + lowpass_hz: float = 16000.0 + hum_hz: int = 0 + leveling: bool = True + level_target_db: float = -23.0 + max_gain_db: float = 18.0 + expansion: bool = False + expansion_threshold_db: float = -50.0 + expansion_ratio: float = 1.5 + expansion_range_db: float = 12.0 + eq: bool = True + warmth_db: float = 0.5 + mud_db: float = -1.5 + presence_db: float = 1.0 + compression: bool = True + compressor_threshold_db: float = -21.0 + compressor_ratio: float = 2.0 + compressor_attack_ms: float = 15.0 + compressor_release_ms: float = 150.0 + deess: bool = True + deess_intensity: float = 0.15 + deess_amount: float = 0.4 + normalize: bool = True + target_lufs: float = -19.0 + loudness_range: float = 9.0 + limiter: bool = True + true_peak_db: float = -1.5 + sample_rate: int = 48000 + mp3: bool = False + mp3_bitrate: int = 192 + + +PROFILES = { + "natural": {}, + "narrator": {"compressor_ratio": 1.6, "compressor_attack_ms": 25.0, + "presence_db": 0.5, "warmth_db": 1.0, "loudness_range": 11.0}, + "radio": {"compressor_ratio": 3.5, "compressor_threshold_db": -24.0, + "compressor_attack_ms": 8.0, "compressor_release_ms": 100.0, + "warmth_db": 2.0, "mud_db": -2.0, "presence_db": 2.0, + "deess_intensity": 0.25, "loudness_range": 6.0}, + "cleanup-only": {"leveling": False, "eq": False, "compression": False, + "deess": False, "normalize": False, "limiter": False}, +} + +RANGES = { + "denoise_strength": (0, 1), "fft_reduction_db": (0.01, 30), + "highpass_hz": (20, 300), "lowpass_hz": (4000, 22000), + "level_target_db": (-40, -12), "max_gain_db": (0, 30), + "expansion_threshold_db": (-90, -20), "expansion_ratio": (1, 4), + "expansion_range_db": (0, 40), "warmth_db": (-12, 12), + "mud_db": (-12, 12), "presence_db": (-12, 12), + "compressor_threshold_db": (-50, -5), "compressor_ratio": (1, 10), + "compressor_attack_ms": (0.1, 200), "compressor_release_ms": (10, 2000), + "deess_intensity": (0, 1), "deess_amount": (0, 1), + "target_lufs": (-30, -12), "loudness_range": (1, 20), + "true_peak_db": (-9, -0.5), +} + + +def resolve(path: Path | None = None, profile: str | None = None, + overrides: dict | None = None) -> Settings: + values = tomllib.loads(path.read_text()) if path else {} + chosen = (overrides or {}).get("profile", profile or values.get("profile", "natural")) + if not isinstance(chosen, str) or chosen not in PROFILES: + raise ValueError(f"Unknown profile {chosen!r}; choose {', '.join(PROFILES)}") + merged = asdict(Settings()) | PROFILES[chosen] | values | {"profile": chosen} + merged.update(overrides or {}) + defaults = asdict(Settings()) + unknown = merged.keys() - defaults.keys() + if unknown: + raise ValueError(f"Unknown setting(s): {', '.join(sorted(unknown))}") + for key, value in merged.items(): + expected = type(defaults[key]) + if expected is float: + if type(value) not in (int, float) or not math.isfinite(value): + raise ValueError(f"{key} must be a finite number") + elif type(value) is not expected: + raise ValueError(f"{key} must be {expected.__name__}") + if key in RANGES and not RANGES[key][0] <= value <= RANGES[key][1]: + raise ValueError(f"{key} must be between {RANGES[key][0]} and {RANGES[key][1]}") + for key, options in { + "denoiser": ("deepfilter", "fft", "none"), "device": ("auto", "cpu", "cuda"), + "channel": ("auto", "left", "right", "mix"), "hum_hz": (0, 50, 60), + "sample_rate": (44100, 48000), "mp3_bitrate": (128, 160, 192, 224, 256, 320), + }.items(): + if merged[key] not in options: + raise ValueError(f"{key} must be one of {options}") + if merged["lowpass"] and merged["lowpass_hz"] >= merged["sample_rate"] / 2: + raise ValueError("lowpass_hz must be below the output Nyquist frequency") + if merged["normalize"] and not merged["limiter"]: + raise ValueError("Normalization includes true-peak limiting; use --no-normalize to bypass it") + return Settings(**merged) + + +def toml(settings: Settings) -> str: + lines = ["# Producer configuration. CLI flags override these values."] + for field in fields(settings): + value = getattr(settings, field.name) + text = str(value).lower() if isinstance(value, bool) else repr(value) + lines.append(f"{field.name} = {text}") + return "\n".join(lines) + "\n" diff --git a/lib/project/src/producer/pipeline.py b/lib/project/src/producer/pipeline.py new file mode 100644 index 0000000..8fb5484 --- /dev/null +++ b/lib/project/src/producer/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=".producer-", 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=32: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_s32le", "-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_32"): + raise RuntimeError("Exported master is not the expected mono PCM_32 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 = {"producer_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 diff --git a/lib/project/src/producer/progress.py b/lib/project/src/producer/progress.py new file mode 100644 index 0000000..a8c5476 --- /dev/null +++ b/lib/project/src/producer/progress.py @@ -0,0 +1,40 @@ +"""A refresh thread keeps elapsed time moving even during native processing.""" +import time + +from rich.console import Console +from rich.progress import (BarColumn, Progress, SpinnerColumn, TaskProgressColumn, + TextColumn, TimeElapsedColumn, TimeRemainingColumn) + + +class Display: + def __init__(self): + self.console = Console(stderr=True) + self.progress = Progress(SpinnerColumn(), TextColumn("{task.description}"), + BarColumn(), TaskProgressColumn(), TimeElapsedColumn(), + TimeRemainingColumn(), console=self.console, + refresh_per_second=5, disable=not self.console.is_terminal) + self.task = None + self.stage = "" + self.started = time.monotonic() + self.last_print = 0.0 + + def __enter__(self): + self.progress.start() + return self + + def __exit__(self, *args): + self.progress.stop() + + def update(self, stage, completed=None, total=None): + changed = stage != self.stage + if changed: + if self.task is not None: + self.progress.remove_task(self.task) + self.task = self.progress.add_task(stage, total=total) + self.stage = stage + self.progress.update(self.task, completed=completed if total else 0, total=total) + now = time.monotonic() + if not self.console.is_terminal and (changed or now - self.last_print >= 5): + fraction = f" {min(100, 100 * (completed or 0) / total):.0f}%" if total else "" + self.console.print(f"[{now - self.started:7.1f}s] {stage}{fraction}", markup=False) + self.last_print = now diff --git a/lib/project/src/producer/setup.py b/lib/project/src/producer/setup.py new file mode 100644 index 0000000..e1a8330 --- /dev/null +++ b/lib/project/src/producer/setup.py @@ -0,0 +1,203 @@ +"""User-local tools. No sudo and no changes to the application's interpreter.""" +from __future__ import annotations + +from collections import deque +from collections.abc import Callable +import json +import os +from pathlib import Path +import selectors +import shutil +import signal +import subprocess +import sys +import time + +Progress = Callable[[str, float | None, float | None], None] + + +def data_dir() -> Path: + """The Producer data/prefix directory. + + PRODUCER_HOME wins (the single-directory launcher exports it), then + XDG_DATA_HOME/producer, then ~/.local/share/producer. A relative + PRODUCER_HOME resolves against the current directory. + """ + home = os.environ.get("PRODUCER_HOME") + if home: + return Path(home).absolute() + root = Path(os.environ.get("XDG_DATA_HOME", str(Path.home() / ".local/share"))) + if not root.is_absolute(): + root = Path.home() / ".local/share" + return root / "producer" + + +def tool_env() -> dict[str, str]: + """Sanitized environment for bootstrap, setup, and worker subprocesses. + + Host Python settings are removed so a virtualenv or sitecustomize on the + machine cannot leak into managed environments. Installation-target, + index, and constraint overrides are dropped and package-manager + configuration files disabled, so packages can only come from the + explicitly passed trusted indexes and land inside the data directory; + every cache (pip, uv, managed Python, XDG, temporary files) stays there + too. + """ + env = os.environ.copy() + for name in ("PYTHONPATH", "PYTHONHOME", "VIRTUAL_ENV", + "PIP_TARGET", "PIP_PREFIX", "PIP_USER", "PIP_FIND_LINKS", + "PIP_INDEX_URL", "PIP_EXTRA_INDEX_URL", "PIP_CONSTRAINT", + "UV_INDEX_URL", "UV_DEFAULT_INDEX", "UV_EXTRA_INDEX_URL", + "UV_INDEX", "UV_FIND_LINKS", "UV_CONSTRAINT"): + env.pop(name, None) + env["PYTHONNOUSERSITE"] = "1" + env["PIP_CONFIG_FILE"] = os.devnull + env["UV_NO_CONFIG"] = "1" + cache = data_dir() / "cache" + env["XDG_CACHE_HOME"] = str(cache) + env["PIP_CACHE_DIR"] = str(cache / "pip") + env["UV_CACHE_DIR"] = str(cache / "uv") + env["UV_PYTHON_INSTALL_DIR"] = str(data_dir() / "uv/python") + tmp = data_dir() / "tmp" + try: + tmp.mkdir(parents=True, exist_ok=True) + env["TMPDIR"] = str(tmp) + except OSError: + pass # Best effort: never break subprocesses over temporary-file containment. + return env + + +def terminate_group(process: subprocess.Popen) -> None: + """SIGTERM the child's whole process group, escalate to SIGKILL, and reap. + + The group is signalled even when the leader has already exited, because + descendants can outlive it while still holding the output pipe open. + A race between exit and signalling is tolerated. + """ + for sig in (signal.SIGTERM, signal.SIGKILL): + try: + os.killpg(process.pid, sig) + except ProcessLookupError: + pass + try: + process.wait(timeout=5) + except subprocess.TimeoutExpired: + continue + try: + os.killpg(process.pid, 0) + except ProcessLookupError: + return + process.wait() + + +def run_process(args: list[str], stage: str, progress: Progress | None = None, + *, env: dict[str, str] | None = None) -> None: + """Drain output without blocking; send heartbeat callbacks every 0.25s. + + Worker JSON records carry frame/byte counts. Other processes report elapsed seconds + as completed with total=None. Only the last 64 KiB of diagnostics are retained. + Callback exceptions (including cancellation) terminate the process group. + """ + started = time.monotonic() + current_stage, completed, total = stage, None, None + diagnostics: deque[bytes] = deque(maxlen=16) + pending = b"" + if progress: + progress(stage, None, None) + with subprocess.Popen(args, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, + env=env, start_new_session=True) as process: + assert process.stdout is not None + os.set_blocking(process.stdout.fileno(), False) + try: + with selectors.DefaultSelector() as selector: + selector.register(process.stdout, selectors.EVENT_READ) + while selector.get_map() or process.poll() is None: + for key, _ in selector.select(timeout=0.25): + chunk = os.read(key.fd, 4096) + if not chunk: + selector.unregister(key.fileobj) + continue + diagnostics.append(chunk) + pending += chunk + while b"\n" in pending: + line, pending = pending.split(b"\n", 1) + if line.startswith(b"PRODUCER_PROGRESS "): + event = json.loads(line[len(b"PRODUCER_PROGRESS "):]) + current_stage, completed, total = event + if len(pending) > 65536: + pending = pending[-4096:] + if progress: + progress(current_stage, completed if total is not None else + time.monotonic() - started, total) + code = process.wait() + if code: + detail = b"".join(diagnostics).decode("utf-8", errors="replace") + raise RuntimeError(f"{stage} failed (exit {code}):\n{detail}") + except BaseException: + terminate_group(process) + raise + + +def _uv_supports_relocatable(uv: str) -> bool: + """Whether this uv understands the venv flags AI setup relies on. + + A host uv can be older than the pinned one; capability is checked + directly instead of trusting the version string. + """ + try: + result = subprocess.run([uv, "venv", "--help"], capture_output=True, + text=True, check=True) + except (OSError, subprocess.SubprocessError): + return False + return "--relocatable" in result.stdout + result.stderr + + +def ensure_uv(progress: Progress | None = None) -> str: + """Return a uv that supports the flags AI setup relies on. + + The launcher's contained bootstrap uv is preferred; a host uv is trusted + only if it actually supports --relocatable. Otherwise uv==0.8.17 is + installed from PyPI (trust PyPI's distribution, not a downloaded shell + script). + """ + contained = data_dir() / "bootstrap/bin/uv" + if contained.is_file() and os.access(contained, os.X_OK): + return str(contained) + found = shutil.which("uv") + if found and _uv_supports_relocatable(found): + return found + root = contained.parent.parent + root.parent.mkdir(parents=True, exist_ok=True) + python = sys.executable or shutil.which("python3") + env = tool_env() + run_process([python, "-m", "venv", str(root)], "Creating uv bootstrap", + progress, env=env) + run_process([str(root / "bin/python"), "-m", "pip", "install", + "--index-url", "https://pypi.org/simple", "uv==0.8.17"], + "Installing uv from PyPI", progress, env=env) + if not (contained.is_file() and os.access(contained, os.X_OK)): + raise RuntimeError("Installing uv from PyPI did not produce a usable " + "uv; check for host pip settings (such as " + "PIP_TARGET) that redirect installations outside " + "the data directory.") + return str(contained) + + +def ensure_ffmpeg() -> str: + """Return the bundled FFmpeg, or a system one where the bundle is + absent, not ffprobe. + + The launcher installs imageio-ffmpeg via the bundled-ffmpeg extra; direct + package installs should declare it as a dependency. This helper never + runs pip in the current interpreter. + """ + try: + import imageio_ffmpeg + return imageio_ffmpeg.get_ffmpeg_exe() + except (ImportError, RuntimeError) as error: + found = shutil.which("ffmpeg") + if found: + return found + raise RuntimeError("FFmpeg is unavailable. Rebuild the contained " + "runtime with './producer.sh --rebuild', or " + "install a system FFmpeg.") from error diff --git a/lib/project/src/voiceforge/__init__.py b/lib/project/src/voiceforge/__init__.py deleted file mode 100644 index 1d8663d..0000000 --- a/lib/project/src/voiceforge/__init__.py +++ /dev/null @@ -1,3 +0,0 @@ -"""VoiceForge: local speech cleanup and mastering.""" - -__version__ = "0.2.0" diff --git a/lib/project/src/voiceforge/__main__.py b/lib/project/src/voiceforge/__main__.py deleted file mode 100644 index 4e28416..0000000 --- a/lib/project/src/voiceforge/__main__.py +++ /dev/null @@ -1,3 +0,0 @@ -from .cli import main - -main() diff --git a/lib/project/src/voiceforge/ai.py b/lib/project/src/voiceforge/ai.py deleted file mode 100644 index 493be65..0000000 --- a/lib/project/src/voiceforge/ai.py +++ /dev/null @@ -1,157 +0,0 @@ -"""Automatic, isolated DeepFilterNet3 inference (Linux, Python 3.11 worker). - -Public progress callback: (stage: str, completed: float | None, total: float | None). -Known totals are audio frames or download bytes (identified by stage); -unknown totals use elapsed seconds as completed. -First use downloads Python, wheels, and upstream model weights into the -VoiceForge data directory (VOICEFORGE_HOME prefix or the XDG data location). -""" -from __future__ import annotations - -import ctypes -import fcntl -import math -import os -from pathlib import Path -import platform -import shutil -import time - -from .setup import Progress, data_dir, ensure_uv, run_process, tool_env - - -def _cuda_available() -> bool: - # Probe the driver without importing Torch or installing CUDA in the host env. - if os.environ.get("CUDA_VISIBLE_DEVICES") in {"", "-1"}: - return False - try: - driver = ctypes.CDLL("libcuda.so.1") - count = ctypes.c_int() - return driver.cuInit(0) == 0 and driver.cuDeviceGetCount(ctypes.byref(count)) == 0 and count.value > 0 - except (OSError, AttributeError): - return False - - -def _flavor(device: str) -> str: - if device not in {"auto", "cpu", "cuda"}: - raise ValueError("device must be 'auto', 'cpu', or 'cuda'") - if device == "cuda" or (device == "auto" and _cuda_available()): - if platform.machine() not in {"x86_64", "AMD64"}: - raise RuntimeError("The pinned CUDA 12.1 backend requires Linux x86_64.") - return "cu121" - return "cpu" - - -def _worker_env() -> dict[str, str]: - # Same isolation as tool_env(); kept as a named seam for worker launches. - return tool_env() - - -def _interpreter_is_local(python: Path, root: Path) -> bool: - """Whether the environment's interpreter belongs to this prefix. - - An installation duplicated by copying (while the original remained) keeps - interpreter links that resolve into the original directory. Such an - environment must be rebuilt, or the copy would silently depend on files - outside this prefix and break when the original is removed. - """ - try: - return python.resolve().is_relative_to(root) - except OSError: - return False - - -def ensure_ai(device: str = "auto", progress: Progress | None = None, - verify: bool = False) -> Path: - """Install/validate the backend and model; return its Python executable. - - CPU and CUDA environments are separate. Explicit CUDA never falls back; - auto uses Torch's availability check in the selected worker. Downloads trust - PyPI, download.pytorch.org, Astral's Python distribution, and upstream DF. - verify forces a real self-test even when the environment is marked ready - ('setup' uses this); ordinary processing trusts the marker so a ready - environment does not pay for a second model load per run. - """ - flavor = _flavor(device) - try: - libc = os.confstr("CS_GNU_LIBC_VERSION") or "" - except (ValueError, OSError): - libc = "" - if platform.system() != "Linux" or not libc.startswith("glibc ") or tuple( - int(part) for part in libc.split()[1].split(".")[:2] - ) < (2, 28): - raise RuntimeError("The pinned DeepFilterNet3 backend requires Linux with glibc >= 2.28 " - "(for example Ubuntu 22.04+ or Debian 12+). " - "Alpine/musl is not supported by its prebuilt wheels.") - root = data_dir() - root.mkdir(parents=True, exist_ok=True) - environment = root / f"ai-df-0.5.6-torch-2.5.1-{flavor}-py311-v1" - python = environment / "bin/python" - marker = environment / ".voiceforge-ready" - started = time.monotonic() - # Serialize bootstrap/model downloads, including calls from separate CLIs. - with (root / "ai.lock").open("a") as lock: - while True: - try: - fcntl.flock(lock, fcntl.LOCK_EX | fcntl.LOCK_NB) - break - except BlockingIOError: - if progress: - progress("Waiting for AI setup", time.monotonic() - started, None) - time.sleep(0.25) - ready = (marker.is_file() and python.is_file() - and _interpreter_is_local(python, root)) - if not ready: - # A moved, copied, or half-installed environment cannot be - # repaired in place: interpreter links reference absolute paths. - # Rebuild it; the shared wheel caches make this fast, usually - # offline. - if environment.exists(): - shutil.rmtree(environment) - uv = ensure_uv(progress) - env = _worker_env() - run_process([uv, "venv", "--python", "3.11", "--managed-python", - "--relocatable", "--allow-existing", str(environment)], - "Preparing AI Python 3.11", progress, env=env) - run_process([uv, "pip", "install", "--python", str(python), - "--index-url", f"https://download.pytorch.org/whl/{flavor}", - "torch==2.5.1", "torchaudio==2.5.1"], - f"Installing PyTorch ({flavor})", progress, env=env) - run_process([uv, "pip", "install", "--python", str(python), - "--only-binary", ":all:", - "--index-url", "https://pypi.org/simple", "deepfilternet==0.5.6", - "numpy==1.26.4", "soundfile==0.12.1"], - "Installing DeepFilterNet3", progress, env=env) - # Check a freshly built environment (and any explicit setup) with a - # real model load and inference; this also downloads the weights. - if verify or not ready: - run_process([str(python), "-I", str(Path(__file__).with_name("ai_worker.py")), - "--device", device, "--model-dir", str(root / "models/df3-0.5.6"), - "--check"], "Checking DeepFilterNet3", progress, - env=_worker_env()) - marker.touch() - return python - - -def denoise(source: Path, target: Path, device: str = "auto", strength: float = 1.0, - progress: Progress | None = None) -> None: - """Denoise mono 48 kHz WAV to float WAV, preserving the exact sample count. - - strength is a dry/wet mix in [0, 1]. The worker publishes output atomically; - errors/cancellation leave an existing target untouched. Chunked inference is - bounded-memory, not bit-identical to inference over an entire recording. - """ - if not math.isfinite(strength) or not 0 <= strength <= 1: - raise ValueError("strength must be finite and between 0 and 1") - source, target = Path(source).resolve(), Path(target).resolve() - if source == target: - raise ValueError("source and target must be different files") - if not source.is_file(): - raise FileNotFoundError(source) - if not target.parent.is_dir(): - raise FileNotFoundError(target.parent) - python = ensure_ai(device, progress) - run_process([str(python), "-I", str(Path(__file__).with_name("ai_worker.py")), - "--device", device, "--source", str(source), "--target", str(target), - "--model-dir", str(data_dir() / "models/df3-0.5.6"), - "--strength", str(strength)], "Denoising", progress, env=_worker_env()) diff --git a/lib/project/src/voiceforge/ai_worker.py b/lib/project/src/voiceforge/ai_worker.py deleted file mode 100644 index 99ec9f5..0000000 --- a/lib/project/src/voiceforge/ai_worker.py +++ /dev/null @@ -1,161 +0,0 @@ -"""Private script executed only by the isolated backend Python.""" -from __future__ import annotations - -import argparse -import hashlib -import json -import os -from pathlib import Path -import signal -import tempfile -import urllib.request -import zipfile - - -def report(stage: str, completed: float | None = None, total: float | None = None) -> None: - print("VOICEFORGE_PROGRESS " + json.dumps([stage, completed, total]), flush=True) - - -def ensure_model(directory: Path) -> Path: - """Install a release-pinned, hash-checked archive atomically under setup lock.""" - model = directory / "DeepFilterNet3" - if (model / "config.ini").is_file() and (model / "checkpoints/model_120.ckpt.best").is_file(): - return model - if directory.exists(): - raise RuntimeError(f"Incomplete model directory: {directory}. Remove it and retry AI setup.") - directory.parent.mkdir(parents=True, exist_ok=True) - url = "https://raw.githubusercontent.com/Rikorose/DeepFilterNet/v0.5.6/models/DeepFilterNet3.zip" - expected = "49c52edc8947ae1f9bf50d81530beaf3a2c3245aeaf34b6f31ff535cd22284d2" - # Release archive size is fixed alongside its hash, even without Content-Length. - expected_bytes = 7986207 - downloaded = 0 - report("Downloading DeepFilterNet3 model (bytes)", 0, expected_bytes) - with tempfile.TemporaryDirectory(prefix=".df3-", dir=directory.parent) as staging: - archive = Path(staging) / "model.zip" - digest = hashlib.sha256() - with urllib.request.urlopen(url, timeout=60) as response, archive.open("wb") as output: - while chunk := response.read(256 * 1024): - downloaded += len(chunk) - if downloaded > expected_bytes: - raise RuntimeError("DeepFilterNet3 model download exceeds the expected size") - digest.update(chunk) - output.write(chunk) - report("Downloading DeepFilterNet3 model (bytes)", downloaded, expected_bytes) - if downloaded != expected_bytes or digest.hexdigest() != expected: - raise RuntimeError("DeepFilterNet3 model checksum mismatch; refusing to load weights") - report("Verifying and extracting DeepFilterNet3 model") - unpacked = Path(staging) / "unpacked" - with zipfile.ZipFile(archive) as bundle: - for member in bundle.infolist(): - destination = (unpacked / member.filename).resolve() - if not destination.is_relative_to(unpacked.resolve()): - raise RuntimeError("Unsafe path in model archive") - bundle.extractall(unpacked) - os.replace(unpacked, directory) - return model - - -def main() -> None: - parser = argparse.ArgumentParser() - parser.add_argument("--device", choices=("auto", "cpu", "cuda"), required=True) - parser.add_argument("--check", action="store_true") - parser.add_argument("--model-dir", type=Path, required=True) - parser.add_argument("--source", type=Path) - parser.add_argument("--target", type=Path) - parser.add_argument("--strength", type=float, default=1.0) - args = parser.parse_args() - - # SIGTERM from the parent takes the same cleanup path as Ctrl-C. - def terminate(signum, frame): - raise KeyboardInterrupt - - signal.signal(signal.SIGTERM, terminate) - - import numpy as np - import soundfile as sf - import torch - - torch.set_num_threads(min(4, os.cpu_count() or 1)) - available = torch.cuda.is_available() - if args.device == "cuda" and not available: - raise RuntimeError("CUDA was explicitly requested, but PyTorch cannot use it. " - "Check the NVIDIA driver and CUDA_VISIBLE_DEVICES, or select cpu.") - device = "cuda" if args.device != "cpu" and available else "cpu" - # df.utils.get_device() consults DEVICE on every call, including model creation. - os.environ["DEVICE"] = device - from df.enhance import enhance, init_df - from df.model import ModelParams - from libdf import DF - - model_path = ensure_model(args.model_dir) - report(f"Loading DeepFilterNet3 ({device})") - model, state, _ = init_df(str(model_path), log_level="ERROR", log_file=None) - if state.sr() != 48000: - raise RuntimeError("DeepFilterNet3 model must use 48 kHz") - if args.check: - # Exercise both model execution and the compiled libdf/NumPy ABI. - sample = enhance(model, state, torch.zeros(1, 4800), pad=True) - if sample.shape != (1, 4800) or not torch.isfinite(sample).all(): - raise RuntimeError("DeepFilterNet3 self-test failed") - report(f"DeepFilterNet3 ready ({device})", 1, 1) - return - if args.source is None or args.target is None: - parser.error("--source and --target are required unless --check is used") - if not np.isfinite(args.strength) or not 0 <= args.strength <= 1: - parser.error("--strength must be in [0, 1]") - - temporary = None - try: - with sf.SoundFile(args.source) as source: - if source.samplerate != 48000 or source.channels != 1 or source.format not in {"WAV", "WAVEX", "RF64"}: - raise ValueError("AI input must be a mono 48 kHz WAV; convert it before denoising") - frames = len(source) - fd, name = tempfile.mkstemp(prefix=".voiceforge-ai-", suffix=".wav", dir=args.target.parent) - os.close(fd) - temporary = Path(name) - # RF64 avoids RIFF's 4 GiB limit on very long recordings. - output_format = "RF64" if frames * 4 > 0xFFFFFFFF - 4096 else "WAV" - with sf.SoundFile(temporary, "w", samplerate=48000, channels=1, - format=output_format, subtype="FLOAT") as target: - block, context, overlap = 480000, 96000, 2400 - previous = None - params = ModelParams() - report(f"Denoising ({device})", 0, frames) - for start in range(0, frames, block): - end = min(start + block, frames) - left, right = max(0, start - context), min(frames, end + overlap + context) - source.seek(left) - audio = source.read(right - left, dtype="float32") - if not np.isfinite(audio).all(): - raise ValueError("Input contains non-finite audio samples") - if args.strength: - # Fresh STFT state per window; context warms normalization and - # recurrent layers. Pad to a whole hop before delay compensation. - state = DF(sr=params.sr, fft_size=params.fft_size, - hop_size=params.hop_size, nb_bands=params.nb_erb, - min_nb_erb_freqs=params.min_nb_freqs) - padded = np.pad(audio, (0, (-len(audio)) % params.hop_size)) - wet = enhance(model, state, torch.from_numpy(padded).unsqueeze(0), - pad=True).squeeze(0).numpy()[:len(audio)] - if len(wet) != len(audio) or not np.isfinite(wet).all(): - raise RuntimeError("DeepFilterNet3 returned invalid audio") - audio = audio * (1 - args.strength) + wet * args.strength - kept = audio[start - left:min(frames, end + overlap) - left].copy() - if previous is not None and args.strength: - n = len(previous) - fade = np.linspace(0, 1, n, dtype=np.float32) - kept[:n] = previous * (1 - fade) + kept[:n] * fade - target.write(kept[:end - start]) - previous = kept[end - start:].copy() - report(f"Denoising ({device})", end, frames) - if target.tell() != frames: - raise RuntimeError("Output frame count does not match input") - os.replace(temporary, args.target) - temporary = None - finally: - if temporary is not None: - temporary.unlink(missing_ok=True) - - -if __name__ == "__main__": - main() diff --git a/lib/project/src/voiceforge/audio.py b/lib/project/src/voiceforge/audio.py deleted file mode 100644 index 7fee0c2..0000000 --- a/lib/project/src/voiceforge/audio.py +++ /dev/null @@ -1,128 +0,0 @@ -"""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")} diff --git a/lib/project/src/voiceforge/cli.py b/lib/project/src/voiceforge/cli.py deleted file mode 100644 index 52cd1bd..0000000 --- a/lib/project/src/voiceforge/cli.py +++ /dev/null @@ -1,193 +0,0 @@ -"""Command-line entry point; configuration errors fail before model downloads.""" -import argparse -from dataclasses import asdict, fields -import json -import math -import os -from pathlib import Path -import subprocess -import sys -import tempfile -import tomllib - -from rich.console import Console - -from . import __version__ -from .audio import ffmpeg_run, loudness, measure -from .config import PROFILES, Settings, resolve, toml -from .pipeline import check_target, process, targets_for -from .progress import Display -from .setup import data_dir, ensure_ffmpeg - - -def parser(): - app = argparse.ArgumentParser(prog="producer", description="Local speech cleanup and podcast mastering. Originals are never overwritten.") - app.add_argument("--version", action="version", version=__version__) - sub = app.add_subparsers(dest="command", required=True) - for name in ("process", "preview", "config", "analyze"): - cmd = sub.add_parser(name) - cmd.add_argument("--config", type=Path, help="TOML configuration file") - cmd.add_argument("--profile", choices=PROFILES, default=None) - cmd.add_argument("--set", action="append", default=[], metavar="KEY=VALUE", help="Override any setting using TOML value syntax") - for field in fields(Settings): - if field.name == "profile": - continue - default = getattr(Settings(), field.name) - option = "--" + field.name.replace("_", "-") - if isinstance(default, bool): - cmd.add_argument(option, action=argparse.BooleanOptionalAction, default=None) - else: - cmd.add_argument(option, type=type(default), default=None) - cmd.add_argument("--no-denoise", dest="denoiser", action="store_const", const="none") - if name == "config": - cmd.add_argument("--json", action="store_true") - else: - cmd.add_argument("inputs", type=Path, nargs="+" if name == "process" else 1) - if name in ("process", "preview"): - cmd.add_argument("--output-dir", type=Path, default=None, - help="Directory for generated outputs (default: beside each input)") - cmd.add_argument("--overwrite", action="store_true", help="Replace generated outputs, never input files") - if name == "process": - cmd.add_argument("-o", "--output", type=Path, help="Explicit WAV destination (one input only)") - if name == "preview": - cmd.add_argument("--start", type=float, default=0) - cmd.add_argument("--duration", type=float, default=30) - cmd.add_argument("--profiles", nargs="+", choices=PROFILES, default=["natural", "narrator", "radio"]) - sub.add_parser("doctor", help="Check tools and driver without downloading AI") - setup = sub.add_parser("setup", help="Download and self-test the isolated AI backend") - setup.add_argument("--device", choices=("auto", "cpu", "cuda"), default="auto") - return app - - -def settings_for(args, profile=None): - overrides = {field.name: getattr(args, field.name) for field in fields(Settings) - if field.name != "profile" and getattr(args, field.name, None) is not None} - for item in args.set: - key, sep, value = item.partition("=") - if not sep: - raise ValueError("--set expects KEY=VALUE") - # Strings can be supplied without TOML quotes for CLI convenience. - try: - parsed = tomllib.loads("value = " + value)["value"] - except tomllib.TOMLDecodeError: - parsed = value - overrides[key.strip()] = parsed - return resolve(args.config, profile or args.profile, overrides) - - -def require_input(source: Path) -> None: - if not source.is_file() or source.suffix.lower() != ".wav": - raise ValueError(f"Input must be an existing WAV file: {source}") - - -def main(): - app = parser() - args = app.parse_args() - console = Console(stderr=True) - try: - if args.command == "doctor": - from .ai import _cuda_available - ffmpeg = ensure_ffmpeg() - version = subprocess.run([ffmpeg, "-version"], capture_output=True, text=True, check=True).stdout.splitlines()[0] - console.print(version, markup=False) - console.print(f"FFmpeg: {ffmpeg}\nData: {data_dir()}\nCUDA driver available: {_cuda_available()}\nAI is installed and self-tested by './producer.sh setup'.", markup=False) - return - if args.command == "setup": - from .ai import ensure_ai - with Display() as display: - ensure_ffmpeg() - python = ensure_ai(args.device, display.update, verify=True) - console.print(f"AI ready: {python}", markup=False) - return - settings = settings_for(args) - if args.command == "config": - print(json.dumps(asdict(settings), indent=2) if args.json else toml(settings), end="\n" if args.json else "") - return - with Display() as display: - if args.command == "analyze": - stats = measure(args.inputs[0], display.update) - stats["loudness"] = loudness(ensure_ffmpeg(), args.inputs[0], settings, stats["duration_seconds"], display.update) - print(json.dumps(stats, indent=2, allow_nan=False)) - return - if args.command == "process": - if args.output and len(args.inputs) != 1: - raise ValueError("--output requires exactly one input") - if args.output and args.output_dir: - raise ValueError("--output and --output-dir cannot be combined") - # Without an explicit destination, outputs are written beside - # each input; --output-dir keeps every result in one directory. - outputs = [args.output or (args.output_dir or p.parent) / f"{p.stem}.{settings.profile}.wav" - for p in args.inputs] - # Preflight the whole plan so a predictable conflict cannot - # fail a batch after earlier inputs have already been published. - sources = {p.resolve() for p in args.inputs} - claimed: set[Path] = set() - for source, output in zip(args.inputs, outputs): - require_input(source) - targets = targets_for(output.absolute(), settings) - resolved = {target.resolve() for target in targets} - if len(resolved) < len(targets) or resolved & claimed: - raise ValueError("Inputs have duplicate output names; process them separately with --output") - claimed |= resolved - if resolved & sources or any( - target.exists() and os.path.samefile(source, target) for target in targets): - raise ValueError("An output would overwrite an input recording") - for target in targets: - check_target(source, target, args.overwrite) - for source, output in zip(args.inputs, outputs): - report = process(source, output, settings, display.update, args.overwrite) - console.print(f"Written: {output} | {report['master_loudness']['input_i']} LUFS | {report['master_loudness']['input_tp']} dBTP", markup=False) - for warning in report["warnings"]: - console.print(f"Warning: {warning}", markup=False) - return - if not math.isfinite(args.start) or args.start < 0 or not 0 < args.duration <= 300: - raise ValueError("Preview start must be >= 0 and duration must be in (0, 300] seconds") - if len(set(args.profiles)) != len(args.profiles): - raise ValueError("Preview profiles must be unique") - if any(item.partition("=")[0].strip() == "profile" for item in args.set): - raise ValueError("Preview profiles come from --profiles; remove the profile override from --set") - source = args.inputs[0] - require_input(source) - original = source.resolve() - output_dir = args.output_dir or source.parent - # Preflight every preview destination before extracting the excerpt. - claimed: set[Path] = set() - for profile in args.profiles: - output = output_dir / f"{source.stem}.{profile}.preview.wav" - if output.resolve() == original: - raise ValueError("Preview output would overwrite the original") - targets = [output, output.with_suffix(".report.json")] - resolved = {target.resolve() for target in targets} - if len(resolved) < len(targets) or resolved & claimed: - raise ValueError("Preview outputs have duplicate names") - claimed |= resolved - for target in targets: - check_target(original, target, args.overwrite) - output_dir.mkdir(parents=True, exist_ok=True) - with tempfile.TemporaryDirectory(prefix=".preview-", dir=output_dir) as tmp: - excerpt = Path(tmp) / "excerpt.wav" - ffmpeg_run(ensure_ffmpeg(), ["-ss", str(args.start), "-i", str(original), "-t", str(args.duration), - "-map", "0:a:0", "-c:a", "pcm_f32le", str(excerpt)], "Extracting preview", args.duration, display.update) - for profile in args.profiles: - configured = settings_for(args, profile) - # Equal loudness prevents a louder preset winning an unfair A/B. - configured.normalize = True - configured.limiter = True - output = output_dir / f"{source.stem}.{profile}.preview.wav" - report = process(excerpt, output, configured, display.update, args.overwrite, - provenance={"original": str(original), "preview_start_seconds": args.start, - "requested_duration_seconds": args.duration, - "normalization_forced": True}) - console.print(f"Preview: {output} | {report['master_loudness']['input_i']} LUFS | {report['master_loudness']['input_tp']} dBTP", markup=False) - for warning in report["warnings"]: - console.print(f"Warning: {warning}", markup=False) - except KeyboardInterrupt: - console.print("Cancelled; temporary files removed. Completed outputs may remain.") - raise SystemExit(130) - except (ValueError, OSError, RuntimeError, subprocess.SubprocessError) as error: - console.print(f"Error: {error}", markup=False) - raise SystemExit(1) - - -if __name__ == "__main__": - main() diff --git a/lib/project/src/voiceforge/config.py b/lib/project/src/voiceforge/config.py deleted file mode 100644 index 9cbd59d..0000000 --- a/lib/project/src/voiceforge/config.py +++ /dev/null @@ -1,118 +0,0 @@ -"""Flat, strict TOML configuration; CLI overrides are applied last.""" -from dataclasses import asdict, dataclass, fields -import math -from pathlib import Path -import tomllib - - -@dataclass -class Settings: - profile: str = "natural" - denoiser: str = "deepfilter" - device: str = "auto" - denoise_strength: float = 0.85 - fft_reduction_db: float = 8.0 - channel: str = "auto" - highpass: bool = True - highpass_hz: float = 70.0 - lowpass: bool = False - lowpass_hz: float = 16000.0 - hum_hz: int = 0 - leveling: bool = True - level_target_db: float = -23.0 - max_gain_db: float = 18.0 - expansion: bool = False - expansion_threshold_db: float = -50.0 - expansion_ratio: float = 1.5 - expansion_range_db: float = 12.0 - eq: bool = True - warmth_db: float = 0.5 - mud_db: float = -1.5 - presence_db: float = 1.0 - compression: bool = True - compressor_threshold_db: float = -21.0 - compressor_ratio: float = 2.0 - compressor_attack_ms: float = 15.0 - compressor_release_ms: float = 150.0 - deess: bool = True - deess_intensity: float = 0.15 - deess_amount: float = 0.4 - normalize: bool = True - target_lufs: float = -19.0 - loudness_range: float = 9.0 - limiter: bool = True - true_peak_db: float = -1.5 - sample_rate: int = 48000 - mp3: bool = False - mp3_bitrate: int = 192 - - -PROFILES = { - "natural": {}, - "narrator": {"compressor_ratio": 1.6, "compressor_attack_ms": 25.0, - "presence_db": 0.5, "warmth_db": 1.0, "loudness_range": 11.0}, - "radio": {"compressor_ratio": 3.5, "compressor_threshold_db": -24.0, - "compressor_attack_ms": 8.0, "compressor_release_ms": 100.0, - "warmth_db": 2.0, "mud_db": -2.0, "presence_db": 2.0, - "deess_intensity": 0.25, "loudness_range": 6.0}, - "cleanup-only": {"leveling": False, "eq": False, "compression": False, - "deess": False, "normalize": False, "limiter": False}, -} - -RANGES = { - "denoise_strength": (0, 1), "fft_reduction_db": (0.01, 30), - "highpass_hz": (20, 300), "lowpass_hz": (4000, 22000), - "level_target_db": (-40, -12), "max_gain_db": (0, 30), - "expansion_threshold_db": (-90, -20), "expansion_ratio": (1, 4), - "expansion_range_db": (0, 40), "warmth_db": (-12, 12), - "mud_db": (-12, 12), "presence_db": (-12, 12), - "compressor_threshold_db": (-50, -5), "compressor_ratio": (1, 10), - "compressor_attack_ms": (0.1, 200), "compressor_release_ms": (10, 2000), - "deess_intensity": (0, 1), "deess_amount": (0, 1), - "target_lufs": (-30, -12), "loudness_range": (1, 20), - "true_peak_db": (-9, -0.5), -} - - -def resolve(path: Path | None = None, profile: str | None = None, - overrides: dict | None = None) -> Settings: - values = tomllib.loads(path.read_text()) if path else {} - chosen = (overrides or {}).get("profile", profile or values.get("profile", "natural")) - if not isinstance(chosen, str) or chosen not in PROFILES: - raise ValueError(f"Unknown profile {chosen!r}; choose {', '.join(PROFILES)}") - merged = asdict(Settings()) | PROFILES[chosen] | values | {"profile": chosen} - merged.update(overrides or {}) - defaults = asdict(Settings()) - unknown = merged.keys() - defaults.keys() - if unknown: - raise ValueError(f"Unknown setting(s): {', '.join(sorted(unknown))}") - for key, value in merged.items(): - expected = type(defaults[key]) - if expected is float: - if type(value) not in (int, float) or not math.isfinite(value): - raise ValueError(f"{key} must be a finite number") - elif type(value) is not expected: - raise ValueError(f"{key} must be {expected.__name__}") - if key in RANGES and not RANGES[key][0] <= value <= RANGES[key][1]: - raise ValueError(f"{key} must be between {RANGES[key][0]} and {RANGES[key][1]}") - for key, options in { - "denoiser": ("deepfilter", "fft", "none"), "device": ("auto", "cpu", "cuda"), - "channel": ("auto", "left", "right", "mix"), "hum_hz": (0, 50, 60), - "sample_rate": (44100, 48000), "mp3_bitrate": (128, 160, 192, 224, 256, 320), - }.items(): - if merged[key] not in options: - raise ValueError(f"{key} must be one of {options}") - if merged["lowpass"] and merged["lowpass_hz"] >= merged["sample_rate"] / 2: - raise ValueError("lowpass_hz must be below the output Nyquist frequency") - if merged["normalize"] and not merged["limiter"]: - raise ValueError("Normalization includes true-peak limiting; use --no-normalize to bypass it") - return Settings(**merged) - - -def toml(settings: Settings) -> str: - lines = ["# VoiceForge configuration. CLI flags override these values."] - for field in fields(settings): - value = getattr(settings, field.name) - text = str(value).lower() if isinstance(value, bool) else repr(value) - lines.append(f"{field.name} = {text}") - return "\n".join(lines) + "\n" 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 diff --git a/lib/project/src/voiceforge/progress.py b/lib/project/src/voiceforge/progress.py deleted file mode 100644 index a8c5476..0000000 --- a/lib/project/src/voiceforge/progress.py +++ /dev/null @@ -1,40 +0,0 @@ -"""A refresh thread keeps elapsed time moving even during native processing.""" -import time - -from rich.console import Console -from rich.progress import (BarColumn, Progress, SpinnerColumn, TaskProgressColumn, - TextColumn, TimeElapsedColumn, TimeRemainingColumn) - - -class Display: - def __init__(self): - self.console = Console(stderr=True) - self.progress = Progress(SpinnerColumn(), TextColumn("{task.description}"), - BarColumn(), TaskProgressColumn(), TimeElapsedColumn(), - TimeRemainingColumn(), console=self.console, - refresh_per_second=5, disable=not self.console.is_terminal) - self.task = None - self.stage = "" - self.started = time.monotonic() - self.last_print = 0.0 - - def __enter__(self): - self.progress.start() - return self - - def __exit__(self, *args): - self.progress.stop() - - def update(self, stage, completed=None, total=None): - changed = stage != self.stage - if changed: - if self.task is not None: - self.progress.remove_task(self.task) - self.task = self.progress.add_task(stage, total=total) - self.stage = stage - self.progress.update(self.task, completed=completed if total else 0, total=total) - now = time.monotonic() - if not self.console.is_terminal and (changed or now - self.last_print >= 5): - fraction = f" {min(100, 100 * (completed or 0) / total):.0f}%" if total else "" - self.console.print(f"[{now - self.started:7.1f}s] {stage}{fraction}", markup=False) - self.last_print = now diff --git a/lib/project/src/voiceforge/setup.py b/lib/project/src/voiceforge/setup.py deleted file mode 100644 index 9afeb44..0000000 --- a/lib/project/src/voiceforge/setup.py +++ /dev/null @@ -1,203 +0,0 @@ -"""User-local tools. No sudo and no changes to the application's interpreter.""" -from __future__ import annotations - -from collections import deque -from collections.abc import Callable -import json -import os -from pathlib import Path -import selectors -import shutil -import signal -import subprocess -import sys -import time - -Progress = Callable[[str, float | None, float | None], None] - - -def data_dir() -> Path: - """The VoiceForge data/prefix directory. - - VOICEFORGE_HOME wins (the single-directory launcher exports it), then - XDG_DATA_HOME/voiceforge, then ~/.local/share/voiceforge. A relative - VOICEFORGE_HOME resolves against the current directory. - """ - home = os.environ.get("VOICEFORGE_HOME") - if home: - return Path(home).absolute() - root = Path(os.environ.get("XDG_DATA_HOME", str(Path.home() / ".local/share"))) - if not root.is_absolute(): - root = Path.home() / ".local/share" - return root / "voiceforge" - - -def tool_env() -> dict[str, str]: - """Sanitized environment for bootstrap, setup, and worker subprocesses. - - Host Python settings are removed so a virtualenv or sitecustomize on the - machine cannot leak into managed environments. Installation-target, - index, and constraint overrides are dropped and package-manager - configuration files disabled, so packages can only come from the - explicitly passed trusted indexes and land inside the data directory; - every cache (pip, uv, managed Python, XDG, temporary files) stays there - too. - """ - env = os.environ.copy() - for name in ("PYTHONPATH", "PYTHONHOME", "VIRTUAL_ENV", - "PIP_TARGET", "PIP_PREFIX", "PIP_USER", "PIP_FIND_LINKS", - "PIP_INDEX_URL", "PIP_EXTRA_INDEX_URL", "PIP_CONSTRAINT", - "UV_INDEX_URL", "UV_DEFAULT_INDEX", "UV_EXTRA_INDEX_URL", - "UV_INDEX", "UV_FIND_LINKS", "UV_CONSTRAINT"): - env.pop(name, None) - env["PYTHONNOUSERSITE"] = "1" - env["PIP_CONFIG_FILE"] = os.devnull - env["UV_NO_CONFIG"] = "1" - cache = data_dir() / "cache" - env["XDG_CACHE_HOME"] = str(cache) - env["PIP_CACHE_DIR"] = str(cache / "pip") - env["UV_CACHE_DIR"] = str(cache / "uv") - env["UV_PYTHON_INSTALL_DIR"] = str(data_dir() / "uv/python") - tmp = data_dir() / "tmp" - try: - tmp.mkdir(parents=True, exist_ok=True) - env["TMPDIR"] = str(tmp) - except OSError: - pass # Best effort: never break subprocesses over temporary-file containment. - return env - - -def terminate_group(process: subprocess.Popen) -> None: - """SIGTERM the child's whole process group, escalate to SIGKILL, and reap. - - The group is signalled even when the leader has already exited, because - descendants can outlive it while still holding the output pipe open. - A race between exit and signalling is tolerated. - """ - for sig in (signal.SIGTERM, signal.SIGKILL): - try: - os.killpg(process.pid, sig) - except ProcessLookupError: - pass - try: - process.wait(timeout=5) - except subprocess.TimeoutExpired: - continue - try: - os.killpg(process.pid, 0) - except ProcessLookupError: - return - process.wait() - - -def run_process(args: list[str], stage: str, progress: Progress | None = None, - *, env: dict[str, str] | None = None) -> None: - """Drain output without blocking; send heartbeat callbacks every 0.25s. - - Worker JSON records carry frame/byte counts. Other processes report elapsed seconds - as completed with total=None. Only the last 64 KiB of diagnostics are retained. - Callback exceptions (including cancellation) terminate the process group. - """ - started = time.monotonic() - current_stage, completed, total = stage, None, None - diagnostics: deque[bytes] = deque(maxlen=16) - pending = b"" - if progress: - progress(stage, None, None) - with subprocess.Popen(args, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, - env=env, start_new_session=True) as process: - assert process.stdout is not None - os.set_blocking(process.stdout.fileno(), False) - try: - with selectors.DefaultSelector() as selector: - selector.register(process.stdout, selectors.EVENT_READ) - while selector.get_map() or process.poll() is None: - for key, _ in selector.select(timeout=0.25): - chunk = os.read(key.fd, 4096) - if not chunk: - selector.unregister(key.fileobj) - continue - diagnostics.append(chunk) - pending += chunk - while b"\n" in pending: - line, pending = pending.split(b"\n", 1) - if line.startswith(b"VOICEFORGE_PROGRESS "): - event = json.loads(line[len(b"VOICEFORGE_PROGRESS "):]) - current_stage, completed, total = event - if len(pending) > 65536: - pending = pending[-4096:] - if progress: - progress(current_stage, completed if total is not None else - time.monotonic() - started, total) - code = process.wait() - if code: - detail = b"".join(diagnostics).decode("utf-8", errors="replace") - raise RuntimeError(f"{stage} failed (exit {code}):\n{detail}") - except BaseException: - terminate_group(process) - raise - - -def _uv_supports_relocatable(uv: str) -> bool: - """Whether this uv understands the venv flags AI setup relies on. - - A host uv can be older than the pinned one; capability is checked - directly instead of trusting the version string. - """ - try: - result = subprocess.run([uv, "venv", "--help"], capture_output=True, - text=True, check=True) - except (OSError, subprocess.SubprocessError): - return False - return "--relocatable" in result.stdout + result.stderr - - -def ensure_uv(progress: Progress | None = None) -> str: - """Return a uv that supports the flags AI setup relies on. - - The launcher's contained bootstrap uv is preferred; a host uv is trusted - only if it actually supports --relocatable. Otherwise uv==0.8.17 is - installed from PyPI (trust PyPI's distribution, not a downloaded shell - script). - """ - contained = data_dir() / "bootstrap/bin/uv" - if contained.is_file() and os.access(contained, os.X_OK): - return str(contained) - found = shutil.which("uv") - if found and _uv_supports_relocatable(found): - return found - root = contained.parent.parent - root.parent.mkdir(parents=True, exist_ok=True) - python = sys.executable or shutil.which("python3") - env = tool_env() - run_process([python, "-m", "venv", str(root)], "Creating uv bootstrap", - progress, env=env) - run_process([str(root / "bin/python"), "-m", "pip", "install", - "--index-url", "https://pypi.org/simple", "uv==0.8.17"], - "Installing uv from PyPI", progress, env=env) - if not (contained.is_file() and os.access(contained, os.X_OK)): - raise RuntimeError("Installing uv from PyPI did not produce a usable " - "uv; check for host pip settings (such as " - "PIP_TARGET) that redirect installations outside " - "the data directory.") - return str(contained) - - -def ensure_ffmpeg() -> str: - """Return the bundled FFmpeg, or a system one where the bundle is - absent, not ffprobe. - - The launcher installs imageio-ffmpeg via the bundled-ffmpeg extra; direct - package installs should declare it as a dependency. This helper never - runs pip in the current interpreter. - """ - try: - import imageio_ffmpeg - return imageio_ffmpeg.get_ffmpeg_exe() - except (ImportError, RuntimeError) as error: - found = shutil.which("ffmpeg") - if found: - return found - raise RuntimeError("FFmpeg is unavailable. Rebuild the contained " - "runtime with './producer.sh --rebuild', or " - "install a system FFmpeg.") from error diff --git a/lib/project/tests/conftest.py b/lib/project/tests/conftest.py index 7c75358..0155384 100644 --- a/lib/project/tests/conftest.py +++ b/lib/project/tests/conftest.py @@ -8,8 +8,8 @@ import soundfile as sf # Support running the suite before the project is installed. sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "src")) -from voiceforge.config import resolve -from voiceforge.setup import ensure_ffmpeg +from producer.config import resolve +from producer.setup import ensure_ffmpeg @pytest.fixture diff --git a/lib/project/tests/test_ai.py b/lib/project/tests/test_ai.py index e8c5872..8d08e5f 100644 --- a/lib/project/tests/test_ai.py +++ b/lib/project/tests/test_ai.py @@ -14,7 +14,7 @@ import numpy as np import pytest import soundfile as sf -from voiceforge import ai, ai_worker, setup +from producer import ai, ai_worker, setup MODEL_BYTES = 7986207 @@ -108,7 +108,7 @@ def test_subprocess_heartbeat_does_not_block_on_silence_or_closed_output(close_o def test_subprocess_parses_split_byte_progress_records(): events = [] - record = "VOICEFORGE_PROGRESS " + json.dumps(["Download (bytes)", 123, 456]) + "\n" + record = "PRODUCER_PROGRESS " + json.dumps(["Download (bytes)", 123, 456]) + "\n" code = f"import os,time; os.write(1,{record[:12].encode()!r}); time.sleep(.3); os.write(1,{record[12:].encode()!r})" setup.run_process([sys.executable, "-c", code], "Download", lambda *event: events.append(event)) assert events[-1] == ("Download (bytes)", 123, 456) @@ -203,14 +203,14 @@ def _ready_environment(root, flavor="cpu"): environment = root / f"ai-df-0.5.6-torch-2.5.1-{flavor}-py311-v1" (environment / "bin").mkdir(parents=True) (environment / "bin/python").write_text("#!/bin/sh\n") - (environment / ".voiceforge-ready").touch() + (environment / ".producer-ready").touch() return environment def test_ready_environment_skips_the_check_worker(tmp_path, monkeypatch): root = tmp_path / "prefix" environment = _ready_environment(root) - monkeypatch.setenv("VOICEFORGE_HOME", str(root)) + monkeypatch.setenv("PRODUCER_HOME", str(root)) monkeypatch.setattr(ai.os, "confstr", lambda *_: "glibc 2.39") executed = [] monkeypatch.setattr(ai, "run_process", lambda args, *rest, **kwargs: executed.append(args)) @@ -222,7 +222,7 @@ def test_ready_environment_skips_the_check_worker(tmp_path, monkeypatch): def test_setup_verifies_even_a_ready_environment(tmp_path, monkeypatch): root = tmp_path / "prefix" _ready_environment(root) - monkeypatch.setenv("VOICEFORGE_HOME", str(root)) + monkeypatch.setenv("PRODUCER_HOME", str(root)) monkeypatch.setattr(ai.os, "confstr", lambda *_: "glibc 2.39") executed = [] monkeypatch.setattr(ai, "run_process", lambda args, *rest, **kwargs: executed.append(args)) @@ -238,7 +238,7 @@ def test_broken_environment_is_rebuilt_and_self_tested(tmp_path, monkeypatch): (environment / "bin/python").symlink_to("/nonexistent/python3.11") leftover = environment / "leftover.txt" leftover.write_text("from the previous location") - monkeypatch.setenv("VOICEFORGE_HOME", str(root)) + monkeypatch.setenv("PRODUCER_HOME", str(root)) monkeypatch.setattr(ai.os, "confstr", lambda *_: "glibc 2.39") stages, commands = [], [] @@ -247,7 +247,7 @@ def test_broken_environment_is_rebuilt_and_self_tested(tmp_path, monkeypatch): stages.append(stage) if stage == "Checking DeepFilterNet3": environment.mkdir(parents=True, exist_ok=True) - (environment / ".voiceforge-ready").touch() + (environment / ".producer-ready").touch() monkeypatch.setattr(ai, "run_process", fake_run) monkeypatch.setattr(ai, "ensure_uv", lambda progress=None: "uv") @@ -256,7 +256,7 @@ def test_broken_environment_is_rebuilt_and_self_tested(tmp_path, monkeypatch): assert stages == ["Preparing AI Python 3.11", "Installing PyTorch (cpu)", "Installing DeepFilterNet3", "Checking DeepFilterNet3"] assert commands[0][-1] == str(environment) - assert (environment / ".voiceforge-ready").is_file() + assert (environment / ".producer-ready").is_file() def test_copied_environment_is_rebuilt_not_trusted(tmp_path, monkeypatch): @@ -268,7 +268,7 @@ def test_copied_environment_is_rebuilt_not_trusted(tmp_path, monkeypatch): original = _ready_environment(tmp_path / "other") (environment / "bin/python").unlink() (environment / "bin/python").symlink_to(original / "bin/python") - monkeypatch.setenv("VOICEFORGE_HOME", str(root)) + monkeypatch.setenv("PRODUCER_HOME", str(root)) monkeypatch.setattr(ai.os, "confstr", lambda *_: "glibc 2.39") stages = [] @@ -276,7 +276,7 @@ def test_copied_environment_is_rebuilt_not_trusted(tmp_path, monkeypatch): stages.append(stage) if stage == "Checking DeepFilterNet3": environment.mkdir(parents=True, exist_ok=True) - (environment / ".voiceforge-ready").touch() + (environment / ".producer-ready").touch() monkeypatch.setattr(ai, "run_process", fake_run) monkeypatch.setattr(ai, "ensure_uv", lambda progress=None: "uv") @@ -351,7 +351,7 @@ def test_mocked_worker_exact_length_mix_and_bounded_windows(tmp_path, wav, mocke assert calls == [] assert max(calls, default=0) <= 674400 assert progress.call_args.args == ("Denoising (cpu)", frames, frames) - assert not list(tmp_path.glob(".voiceforge-ai-*")) + assert not list(tmp_path.glob(".producer-ai-*")) def test_worker_failure_preserves_existing_target(tmp_path, wav, mocked_worker): @@ -363,4 +363,4 @@ def test_worker_failure_preserves_existing_target(tmp_path, wav, mocked_worker): with pytest.raises(RuntimeError, match="inference failed"): run(source, target, 0.85) assert target.read_bytes() == b"existing output" - assert not list(tmp_path.glob(".voiceforge-ai-*")) + assert not list(tmp_path.glob(".producer-ai-*")) diff --git a/lib/project/tests/test_cli.py b/lib/project/tests/test_cli.py index a5437b7..300b3a8 100644 --- a/lib/project/tests/test_cli.py +++ b/lib/project/tests/test_cli.py @@ -6,13 +6,13 @@ import numpy as np import pytest import soundfile as sf -from voiceforge import cli, pipeline +from producer import cli, pipeline @pytest.fixture def invoke(monkeypatch): def run(*args): - monkeypatch.setattr(sys, "argv", ["voiceforge", *map(str, args)]) + monkeypatch.setattr(sys, "argv", ["producer", *map(str, args)]) return cli.main() return run @@ -54,7 +54,7 @@ def test_preview_real_audio_preserves_original_and_reports_provenance( info = sf.info(target) assert info.duration == pytest.approx(5, abs=0.02) assert info.channels == 1 - assert info.subtype == "PCM_24" + assert info.subtype == "PCM_32" assert report["output"] == str(target) assert report["provenance"] == { "original": str(source.resolve()), "preview_start_seconds": 1, diff --git a/lib/project/tests/test_config.py b/lib/project/tests/test_config.py index 8144fc8..d3321d5 100644 --- a/lib/project/tests/test_config.py +++ b/lib/project/tests/test_config.py @@ -2,9 +2,9 @@ from dataclasses import asdict, fields import pytest -from voiceforge.cli import main, parser, settings_for -from voiceforge.config import PROFILES, RANGES, Settings, resolve, toml -from voiceforge.pipeline import filters +from producer.cli import main, parser, settings_for +from producer.config import PROFILES, RANGES, Settings, resolve, toml +from producer.pipeline import filters @pytest.mark.parametrize("profile", PROFILES) @@ -97,9 +97,9 @@ def test_invalid_cli_configuration_fails_before_processing(monkeypatch, capsys): def unexpected(*args, **kwargs): pytest.fail("Invalid configuration must not reach processing or tool setup") - monkeypatch.setattr("voiceforge.cli.process", unexpected) - monkeypatch.setattr("voiceforge.cli.ensure_ffmpeg", unexpected) - monkeypatch.setattr("sys.argv", ["voiceforge", "process", "missing.wav", "--target-lufs", "0"]) + monkeypatch.setattr("producer.cli.process", unexpected) + monkeypatch.setattr("producer.cli.ensure_ffmpeg", unexpected) + monkeypatch.setattr("sys.argv", ["producer", "process", "missing.wav", "--target-lufs", "0"]) with pytest.raises(SystemExit) as error: main() assert error.value.code == 1 diff --git a/lib/project/tests/test_install.py b/lib/project/tests/test_install.py index f1dfcf2..a137cf1 100644 --- a/lib/project/tests/test_install.py +++ b/lib/project/tests/test_install.py @@ -7,7 +7,7 @@ import types import pytest -from voiceforge import setup +from producer import setup ROOT = Path(__file__).resolve().parents[3] LAUNCHER = ROOT / "producer.sh" @@ -16,7 +16,7 @@ LAUNCHER = ROOT / "producer.sh" @pytest.fixture def shell_env(tmp_path): env = os.environ.copy() - env.pop("VOICEFORGE_HOME", None) + env.pop("PRODUCER_HOME", None) env.pop("XDG_DATA_HOME", None) env["HOME"] = str(tmp_path / "home") return env @@ -25,33 +25,33 @@ def shell_env(tmp_path): # --- data_dir resolution --------------------------------------------------- def test_data_dir_defaults_and_precedence(tmp_path, monkeypatch): - monkeypatch.delenv("VOICEFORGE_HOME", raising=False) + monkeypatch.delenv("PRODUCER_HOME", raising=False) monkeypatch.delenv("XDG_DATA_HOME", raising=False) monkeypatch.setenv("HOME", str(tmp_path)) - assert setup.data_dir() == tmp_path / ".local/share/voiceforge" + assert setup.data_dir() == tmp_path / ".local/share/producer" monkeypatch.setenv("XDG_DATA_HOME", str(tmp_path / "xdg")) - assert setup.data_dir() == tmp_path / "xdg/voiceforge" - monkeypatch.setenv("VOICEFORGE_HOME", str(tmp_path / "prefix")) + assert setup.data_dir() == tmp_path / "xdg/producer" + monkeypatch.setenv("PRODUCER_HOME", str(tmp_path / "prefix")) assert setup.data_dir() == tmp_path / "prefix" -def test_relative_voiceforge_home_resolves_against_cwd(tmp_path, monkeypatch): - monkeypatch.setenv("VOICEFORGE_HOME", "voiceforge-home") +def test_relative_producer_home_resolves_against_cwd(tmp_path, monkeypatch): + monkeypatch.setenv("PRODUCER_HOME", "producer-home") monkeypatch.chdir(tmp_path) - assert setup.data_dir() == tmp_path / "voiceforge-home" + assert setup.data_dir() == tmp_path / "producer-home" def test_relative_xdg_data_home_falls_back_to_home(tmp_path, monkeypatch): - monkeypatch.delenv("VOICEFORGE_HOME", raising=False) + monkeypatch.delenv("PRODUCER_HOME", raising=False) monkeypatch.setenv("HOME", str(tmp_path)) monkeypatch.setenv("XDG_DATA_HOME", "relative/path") - assert setup.data_dir() == tmp_path / ".local/share/voiceforge" + assert setup.data_dir() == tmp_path / ".local/share/producer" # --- contained tool environment -------------------------------------------- def test_tool_env_is_sanitized_and_contained(tmp_path, monkeypatch): - monkeypatch.setenv("VOICEFORGE_HOME", str(tmp_path / "prefix")) + monkeypatch.setenv("PRODUCER_HOME", str(tmp_path / "prefix")) monkeypatch.setenv("PYTHONPATH", "/host/site-packages") monkeypatch.setenv("PYTHONHOME", "/host/python") monkeypatch.setenv("VIRTUAL_ENV", "/host/venv") @@ -89,21 +89,21 @@ def test_tool_env_is_sanitized_and_contained(tmp_path, monkeypatch): def test_tool_env_tolerates_an_uncreatable_data_dir(monkeypatch): - monkeypatch.setenv("VOICEFORGE_HOME", "/vf-prefix-unwritable") + monkeypatch.setenv("PRODUCER_HOME", "/vf-prefix-unwritable") env = setup.tool_env() assert env["PIP_CACHE_DIR"] == "/vf-prefix-unwritable/cache/pip" assert "TMPDIR" not in env def test_tool_env_leaves_process_environment_untouched(tmp_path, monkeypatch): - monkeypatch.setenv("VOICEFORGE_HOME", str(tmp_path / "prefix")) + monkeypatch.setenv("PRODUCER_HOME", str(tmp_path / "prefix")) monkeypatch.setenv("PYTHONPATH", "/host/site-packages") setup.tool_env() assert os.environ["PYTHONPATH"] == "/host/site-packages" def test_ensure_uv_bootstraps_into_data_dir_with_contained_pip_cache(tmp_path, monkeypatch): - monkeypatch.setenv("VOICEFORGE_HOME", str(tmp_path / "prefix")) + monkeypatch.setenv("PRODUCER_HOME", str(tmp_path / "prefix")) monkeypatch.setattr(setup.shutil, "which", lambda name: None) calls = [] uv_binary = tmp_path / "prefix/bootstrap/bin/uv" @@ -130,7 +130,7 @@ def test_ensure_uv_bootstraps_into_data_dir_with_contained_pip_cache(tmp_path, m def test_ensure_uv_reuses_contained_bootstrap(tmp_path, monkeypatch): - monkeypatch.setenv("VOICEFORGE_HOME", str(tmp_path / "prefix")) + monkeypatch.setenv("PRODUCER_HOME", str(tmp_path / "prefix")) uv_binary = tmp_path / "prefix/bootstrap/bin/uv" uv_binary.parent.mkdir(parents=True) uv_binary.write_text("#!/bin/sh\n") @@ -140,7 +140,7 @@ def test_ensure_uv_reuses_contained_bootstrap(tmp_path, monkeypatch): def test_ensure_uv_prefers_the_contained_bootstrap_over_a_host_uv(tmp_path, monkeypatch): - monkeypatch.setenv("VOICEFORGE_HOME", str(tmp_path / "prefix")) + monkeypatch.setenv("PRODUCER_HOME", str(tmp_path / "prefix")) uv_binary = tmp_path / "prefix/bootstrap/bin/uv" uv_binary.parent.mkdir(parents=True) uv_binary.write_text("#!/bin/sh\n") @@ -150,7 +150,7 @@ def test_ensure_uv_prefers_the_contained_bootstrap_over_a_host_uv(tmp_path, monk def test_ensure_uv_ignores_a_host_uv_without_required_capability(tmp_path, monkeypatch): - monkeypatch.setenv("VOICEFORGE_HOME", str(tmp_path / "prefix")) + monkeypatch.setenv("PRODUCER_HOME", str(tmp_path / "prefix")) monkeypatch.setattr(setup.shutil, "which", lambda name: "/host/bin/uv") monkeypatch.setattr(setup, "_uv_supports_relocatable", lambda uv: False) stages = [] @@ -169,7 +169,7 @@ def test_ensure_uv_ignores_a_host_uv_without_required_capability(tmp_path, monke def test_ensure_uv_fails_when_bootstrap_does_not_produce_uv(tmp_path, monkeypatch): - monkeypatch.setenv("VOICEFORGE_HOME", str(tmp_path / "prefix")) + monkeypatch.setenv("PRODUCER_HOME", str(tmp_path / "prefix")) monkeypatch.setattr(setup.shutil, "which", lambda name: None) monkeypatch.setattr(setup, "run_process", lambda *args, **kwargs: None) with pytest.raises(RuntimeError, match="did not produce a usable uv"): diff --git a/lib/project/tests/test_pipeline.py b/lib/project/tests/test_pipeline.py index 0fa1c52..3f6000d 100644 --- a/lib/project/tests/test_pipeline.py +++ b/lib/project/tests/test_pipeline.py @@ -4,9 +4,9 @@ import numpy as np import pytest import soundfile as sf -from voiceforge import pipeline -from voiceforge.audio import loudness, measure -from voiceforge.config import resolve +from producer import pipeline +from producer.audio import loudness, measure +from producer.config import resolve @pytest.mark.parametrize("kind", ["missing", "input-extension", "output-extension"]) @@ -103,7 +103,7 @@ def test_explicit_stereo_selection_preserves_signal_and_duration(wav, tmp_path, np.testing.assert_allclose(actual, expected, atol=5e-7, rtol=0) assert report["normalization_mode"] == "disabled" assert report["level_correction_db"] == 0 - assert sf.info(target).subtype == "PCM_24" + assert sf.info(target).subtype == "PCM_32" @pytest.mark.parametrize("rate", [44100, 48000]) @@ -116,7 +116,7 @@ def test_normalization_peak_ceiling_and_report(wav, tmp_path, progress, ffmpeg, stats = sf.info(target) assert stats.channels == 1 assert stats.samplerate == rate - assert stats.subtype == "PCM_24" + assert stats.subtype == "PCM_32" assert abs(stats.duration - 3) <= 0.02 verified = loudness(ffmpeg, target, settings, stats.duration, progress) assert verified["input_i"] == pytest.approx(settings.target_lufs, abs=0.5) @@ -125,7 +125,7 @@ def test_normalization_peak_ceiling_and_report(wav, tmp_path, progress, ffmpeg, assert report["master"]["sample_peak_dbfs"] <= settings.true_peak_db + 0.1 assert json.loads(target.with_suffix(".report.json").read_text()) == report assert source.read_bytes() == original - assert not list(target.parent.glob(".voiceforge-*")) + assert not list(target.parent.glob(".producer-*")) def test_limiter_without_normalization(wav, tmp_path, bypass, progress, ffmpeg): @@ -183,7 +183,7 @@ def test_antiphase_mix_is_rejected_after_selection(wav, tmp_path, bypass, progre pipeline.process(source, target, bypass, progress) assert not target.exists() assert not target.with_suffix(".report.json").exists() - assert not list(tmp_path.glob(".voiceforge-*")) + assert not list(tmp_path.glob(".producer-*")) def test_silent_selected_channel_is_rejected_after_selection(wav, tmp_path, bypass, progress, ffmpeg): @@ -201,7 +201,7 @@ def test_zero_denoise_strength_skips_ai(wav, tmp_path, bypass, progress, ffmpeg, def unexpected(*args, **kwargs): pytest.fail("Zero denoise strength must not invoke AI") - monkeypatch.setattr("voiceforge.ai.denoise", unexpected) + monkeypatch.setattr("producer.ai.denoise", unexpected) bypass.denoiser = "deepfilter" bypass.denoise_strength = 0 report = pipeline.process(wav(), tmp_path / "master.wav", bypass, progress) @@ -244,7 +244,7 @@ def test_render_failure_preserves_existing_outputs_and_removes_temporary_files(w with pytest.raises(RuntimeError, match="injected encoder failure"): pipeline.process(source, target, bypass, progress, overwrite=True) assert all(path.read_bytes() == b"previous publication" for path in outputs) - assert not list(tmp_path.glob(".voiceforge-*")) + assert not list(tmp_path.glob(".producer-*")) @pytest.mark.parametrize("overwrite", [False, True]) diff --git a/lib/project/uv.lock b/lib/project/uv.lock new file mode 100644 index 0000000..0c22e8c --- /dev/null +++ b/lib/project/uv.lock @@ -0,0 +1,430 @@ +version = 1 +revision = 3 +requires-python = ">=3.11" +resolution-markers = [ + "python_full_version >= '3.12'", + "python_full_version < '3.12'", +] + +[[package]] +name = "cffi" +version = "2.1.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pycparser", marker = "implementation_name != 'PyPy'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/9e/ef/008a1939e372c06329a3fce4279c02f328488f3526744906eeec3da7ad5f/cffi-2.1.1.tar.gz", hash = "sha256:dd31f52ea1086513bb9df30f8fcee9b8918323ae067a3d5b78bc826a000712be", size = 530807, upload-time = "2026-08-03T21:21:18.939Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/70/d2/16d99a0c4948febc0ebd133a13b2f688ff7f8cb04da971e1128872ce0c03/cffi-2.1.1-cp311-cp311-macosx_10_15_x86_64.whl", hash = "sha256:c8d2c9fd1f2d16f780d15127abb050d13d1a76c03a4bd87d7e4980e45e511e12", size = 183838, upload-time = "2026-08-03T21:19:29.637Z" }, + { url = "https://files.pythonhosted.org/packages/cd/95/31b535a9f0220ae9f357de4a08d57ce89cb417653c2fd9f075f50822a388/cffi-2.1.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:398aff33cee2767e3e781d2554c54bd0dff386bb437581e0d8011fde1a942ec1", size = 184168, upload-time = "2026-08-03T21:19:30.764Z" }, + { url = "https://files.pythonhosted.org/packages/ad/5a/4707a0dc1f203f5dde5a907b0d4e3c25d71120241048bd5bc6f1bb9d4e71/cffi-2.1.1-cp311-cp311-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:154852545011f779917b11c78db2358d095da62a9a172b78ad0a583ee5adc0d0", size = 211805, upload-time = "2026-08-03T21:19:31.867Z" }, + { url = "https://files.pythonhosted.org/packages/ad/66/c19feabb28485b6e0bbaaafa90837a1ef5d302e90f2178bd33f17a49879b/cffi-2.1.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:3311ed60d36f83378794e1009ac6258bafbf81f7888b4caa7b35a521e3f95813", size = 218716, upload-time = "2026-08-03T21:19:32.896Z" }, + { url = "https://files.pythonhosted.org/packages/a7/92/500760486c8baab49a7a8a58ba7fc3355ec3974b454b8a09e528efde9e1d/cffi-2.1.1-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:6e192623c49c94421616a5778fba35cf0d5a8d000650c1967ef4448ee5cdd990", size = 205569, upload-time = "2026-08-03T21:19:34.142Z" }, + { url = "https://files.pythonhosted.org/packages/a5/a7/a67c733254d6e7373f7822f8082d8d6beade791e0cf12a7611f376fa61c7/cffi-2.1.1-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:a6e721d4b0e45d5b65e87534470e67b18dcd092c83f68fba09f152b9cbc061af", size = 204907, upload-time = "2026-08-03T21:19:35.174Z" }, + { url = "https://files.pythonhosted.org/packages/f7/a4/4399daaf8f7dfee9d7c3327fdb0426ee041cc63edc358b93911ceb2bfc7a/cffi-2.1.1-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:34e261f78cb6ceaaa36f42f2613f4380d94d9c759a9c73c769ee6e0247364632", size = 217807, upload-time = "2026-08-03T21:19:36.286Z" }, + { url = "https://files.pythonhosted.org/packages/28/f7/dabe6da2466ecbd82dc62e7342dc6b1065dad990c06f00f0ede9ebf2a0ed/cffi-2.1.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:7225e4514edb64eb6740324353e0da0711954fd8d7da4576755b1c6e09b697cd", size = 221252, upload-time = "2026-08-03T21:19:37.416Z" }, + { url = "https://files.pythonhosted.org/packages/ce/87/616202d8e51342c07d2534c510111c4cc37201775ce8f60802c9335d1edd/cffi-2.1.1-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:df913725b79db7bcf03448f36b7bf8815363417d5b58deecf9305e3e30f0f21a", size = 214214, upload-time = "2026-08-03T21:19:38.507Z" }, + { url = "https://files.pythonhosted.org/packages/b4/c6/ab025d75d2c26c19b087c0124e75ee31cb65032f4fe345d356d8c507ab97/cffi-2.1.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:f5cfbc5fe74540d335175b656c725d74d90e3730c626d92575eea35029d9afaa", size = 219408, upload-time = "2026-08-03T21:19:39.809Z" }, + { url = "https://files.pythonhosted.org/packages/db/e2/7e8109f65445bdc673a7b54f02c677de462db75674220fd1335efc8eb598/cffi-2.1.1-cp311-cp311-win32.whl", hash = "sha256:f8ec5e643a9a937f64e1999eb9f75d072263751912dc5cd06d3c85f8f44be7c3", size = 174470, upload-time = "2026-08-03T21:19:41.246Z" }, + { url = "https://files.pythonhosted.org/packages/73/c0/77ba02423c2f7d7091143c45cd49e0e6575c4c1967394bb542bd923a9b74/cffi-2.1.1-cp311-cp311-win_amd64.whl", hash = "sha256:42f6930c31dc7f50732c9ae793c2786c7b6b044195967bbdde40bb9be81c4cc0", size = 185096, upload-time = "2026-08-03T21:19:42.615Z" }, + { url = "https://files.pythonhosted.org/packages/7c/47/9f1f85f9672ceda4984dc6c4f8824e8558992a2972c3d3c81fb8eb28d4ba/cffi-2.1.1-cp311-cp311-win_arm64.whl", hash = "sha256:c7659f22557c5a0bc4855cd635f55edec690cc008a40768527762cb9fb263455", size = 179941, upload-time = "2026-08-03T21:19:43.747Z" }, + { url = "https://files.pythonhosted.org/packages/10/69/43965eccfdead3b9220015fd1320e117be8c6ed01a62ffab76eeb752f5d5/cffi-2.1.1-cp312-cp312-macosx_10_15_x86_64.whl", hash = "sha256:c8c69575568085ba0b1b10c0249d779a214aea6f6522e949a0fc9fb0fcb449d0", size = 184821, upload-time = "2026-08-03T21:19:44.887Z" }, + { url = "https://files.pythonhosted.org/packages/54/7d/16e5a096677b5e313ca80cd5e5170efa3ea44624a82bb111925522da64b1/cffi-2.1.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:f81b3b8f3d4e343550fa4baa0e479bba9f2d29ce9c2e9b51d1ce1718d7442fcf", size = 184719, upload-time = "2026-08-03T21:19:46.129Z" }, + { url = "https://files.pythonhosted.org/packages/56/e6/8941622732edec876dd17d0453dce07317ae96db34f2ec1436c9d3785986/cffi-2.1.1-cp312-cp312-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:811bd1e21d32de12efca32393a0ab3f5133b54fce9bd44b8bd77ab07da14bf6a", size = 214799, upload-time = "2026-08-03T21:19:47.218Z" }, + { url = "https://files.pythonhosted.org/packages/44/de/f98430906df1545ffde0d543dd124a7a439bc2cd32b36b9c53f805df7333/cffi-2.1.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:68e62fe11f30d5ca8289242866f0a5291402d8529ca2178ab8afc5c9694ae890", size = 222389, upload-time = "2026-08-03T21:19:48.331Z" }, + { url = "https://files.pythonhosted.org/packages/6a/5b/717f1526b9957b34456313c31645c5b82b8fb5c3fe9e4752999be7128bfc/cffi-2.1.1-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:4a7c934f7360e8cd64fe9efadcbd10c7c6364f531e432b9a4bf5ccbc9e0e8b50", size = 210249, upload-time = "2026-08-03T21:19:49.543Z" }, + { url = "https://files.pythonhosted.org/packages/64/b3/f8aa4f3e34986c7e4ec45072d1b1b9dd295b6b18007b45518d79726dd725/cffi-2.1.1-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:3143d81e29e1e20a9ce10901ec369012947876596f75a222235965f2b7ae832e", size = 208775, upload-time = "2026-08-03T21:19:50.918Z" }, + { url = "https://files.pythonhosted.org/packages/b1/db/dceb9dd5b231e1da801793f8acc9f3c52a7e1afe40bb1aae37e02b0faad5/cffi-2.1.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:c1453022f490d2459a11819d83ad1d586e9ff65a12ac3e705ffebd46d3685dcf", size = 221822, upload-time = "2026-08-03T21:19:52.054Z" }, + { url = "https://files.pythonhosted.org/packages/a0/d2/6cd24ae3be000a634109c247d1475d62e5616d0dc78c82770942ec384248/cffi-2.1.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:208f941bb9d18e768138677f0a6d2ce01f590df56043dda1df1535ac57c88517", size = 225232, upload-time = "2026-08-03T21:19:53.109Z" }, + { url = "https://files.pythonhosted.org/packages/cb/52/3fa190537004dd7f0ab860a6dc7c0175b8667f68d1e618a46f5498d30250/cffi-2.1.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:210019b6c7cf07f081b4c54635c8cf744377001350e29cc0f81c4377b4797735", size = 223597, upload-time = "2026-08-03T21:19:54.515Z" }, + { url = "https://files.pythonhosted.org/packages/80/fb/0bb75b7039588c074b37ae99f40d9bfddf990ecb2fbc346ebccd2e56b9be/cffi-2.1.1-cp312-cp312-win32.whl", hash = "sha256:046bfc24911b37851ee1b51aab8bffe713d89c68c6a057b09484ce9fd5f69b4e", size = 175292, upload-time = "2026-08-03T21:19:55.566Z" }, + { url = "https://files.pythonhosted.org/packages/d9/79/615cc094e2fb508cade7de88d3b4f6c4ec2bab695c97bce9153dc65aadf5/cffi-2.1.1-cp312-cp312-win_amd64.whl", hash = "sha256:f53e442b08449d42821fa4a4fba000095af9f62742a500f978a9f557ec44339a", size = 185919, upload-time = "2026-08-03T21:19:56.89Z" }, + { url = "https://files.pythonhosted.org/packages/70/c6/d0ea84713fe46b243a436a18fcd47d639732747e21635c8a27191b06dc30/cffi-2.1.1-cp312-cp312-win_arm64.whl", hash = "sha256:7bde5e4cc5c10140859842b9d383af292b22639a4dffb725314baf45968cef80", size = 180093, upload-time = "2026-08-03T21:19:58.155Z" }, + { url = "https://files.pythonhosted.org/packages/9d/f4/035513d4117049066b4779dc3b7c0c0fdad175fa13731c9f4003f1cd1478/cffi-2.1.1-cp313-cp313-ios_13_0_arm64_iphoneos.whl", hash = "sha256:b5bdfd1c873d4e093aabc0ca84c4ca6dbc4f752afb5c86f146d9742580c9da2e", size = 194248, upload-time = "2026-08-03T21:19:59.399Z" }, + { url = "https://files.pythonhosted.org/packages/76/af/2aeb4dbb5fc41a04161ae9ff1518de7cec08e164f44a8ce6a4cf7fd2cd1d/cffi-2.1.1-cp313-cp313-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:31348097ff5bbe827ccc41795d4dd099d9f0625e7def00ee653c137a490c2a6c", size = 196908, upload-time = "2026-08-03T21:20:00.746Z" }, + { url = "https://files.pythonhosted.org/packages/a7/46/2e5fdde8555706dd98139a910ca11be02809f3f605ce956f655d0214e100/cffi-2.1.1-cp313-cp313-macosx_10_15_x86_64.whl", hash = "sha256:9d2055050ea716bd38b7f7f1579c275386646b4894c155a3e2f3cd62ed41b7c6", size = 184805, upload-time = "2026-08-03T21:20:02.02Z" }, + { url = "https://files.pythonhosted.org/packages/55/41/4c7042f317b9217502988f0873af87e16ad606dc20f84e546e3e6ce9764c/cffi-2.1.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:19ee6127ee34de7d83ce3d371ebc5ed91addbdcc39f9ab15ce4eb35a4e534971", size = 184764, upload-time = "2026-08-03T21:20:03.141Z" }, + { url = "https://files.pythonhosted.org/packages/43/1f/1c3d90d91811c8f86ced9ed637956c54bfe5b79ca98fe976d7f8c8979f6b/cffi-2.1.1-cp313-cp313-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:6a8dddef476fab96d066d578fc88526767b836ab5ab21754e1d5bf3879c31c7c", size = 214722, upload-time = "2026-08-03T21:20:04.377Z" }, + { url = "https://files.pythonhosted.org/packages/37/6f/3b5ce4c3b2192d250f04908f2bfd91ef34552ec8f7716a5d4abdb8d67bb2/cffi-2.1.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:f16c709686a78c727bbbf059f92b0bf41c6fc60deec706d2dc19f529175a6125", size = 222369, upload-time = "2026-08-03T21:20:05.544Z" }, + { url = "https://files.pythonhosted.org/packages/02/10/4b3c75dde3d9663c9e02ba05c2668b954f671d4bbe346413ca8c696b295a/cffi-2.1.1-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:fcd22650c908d7b7da162bbfaab594a1227a15d1643a98c68b122ac642fa2264", size = 210175, upload-time = "2026-08-03T21:20:06.75Z" }, + { url = "https://files.pythonhosted.org/packages/df/62/14f74b9543e605d17701dc797b815958b8bb70b7624ce1b832ddad48ed6c/cffi-2.1.1-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:aa9511c62d14da7aacc9b4bf51f3f697a621e83b2d6919008243c3aad168eea3", size = 208670, upload-time = "2026-08-03T21:20:08.04Z" }, + { url = "https://files.pythonhosted.org/packages/95/95/86342356ff5953b3fb06f7ef7c5bee212d45e770abc7218d451b9148313c/cffi-2.1.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:a931079504ecc49efed7744c476a5c343a92fabf66dec2db95edb1b2fdc770e2", size = 221824, upload-time = "2026-08-03T21:20:09.274Z" }, + { url = "https://files.pythonhosted.org/packages/eb/ff/7b3429ff53aafe931ed8a5fc69f481bbef7ba6de87ddcbb63d08f483f613/cffi-2.1.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:a2d7755bef5a12ed488f4ef1f1b69ee9191d7396083b755a5d2295f6edb4768b", size = 225148, upload-time = "2026-08-03T21:20:10.7Z" }, + { url = "https://files.pythonhosted.org/packages/34/34/a95870b9221e09cf4f2ce3178b1a210abdfe63a1bd357da940418d7b8d15/cffi-2.1.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:e0bcb7e0f677f543555d2adff3bf19c05f66cdb4796e5ff602442ab2fe3c4ef7", size = 223564, upload-time = "2026-08-03T21:20:12.165Z" }, + { url = "https://files.pythonhosted.org/packages/70/ea/839b50531021a647fb5e929f72cf97bc1ff702b5472166164b5b6e76b851/cffi-2.1.1-cp313-cp313-win32.whl", hash = "sha256:334644fbac4eff73d985a17a91226df55d0f394160c4cfb880e084c8f7161cac", size = 175263, upload-time = "2026-08-03T21:20:13.559Z" }, + { url = "https://files.pythonhosted.org/packages/60/a6/8b149b2c3f2e11aaa1618ef64500b45f50f22c57a977a4dff1aff1f91042/cffi-2.1.1-cp313-cp313-win_amd64.whl", hash = "sha256:1aa5645c30469b09530c4ebca77ebf8f17618293c58f8549cb1a543a50236e7d", size = 185688, upload-time = "2026-08-03T21:20:14.69Z" }, + { url = "https://files.pythonhosted.org/packages/01/9a/11f687cb39d6a3504060d5242f04f48c735afb4d3d533958a20594890cb2/cffi-2.1.1-cp313-cp313-win_arm64.whl", hash = "sha256:63bbfd5ded17c4840ac07cd8f1c21ba9d9708141f840b324f422f41b207e3973", size = 180078, upload-time = "2026-08-03T21:20:15.917Z" }, + { url = "https://files.pythonhosted.org/packages/d3/7b/d6bbf82b8b96e7391438898c42f5bd96dd02030fd5b64937d248220003e2/cffi-2.1.1-cp314-cp314-ios_13_0_arm64_iphoneos.whl", hash = "sha256:7dbb61fe3a7699468030f71bbe5f8a0e326a151daa91beb11a6fc1f980c55e1c", size = 194064, upload-time = "2026-08-03T21:20:17.148Z" }, + { url = "https://files.pythonhosted.org/packages/94/e6/bcc91b283be94735e268487a054004f0aa19947b6348fa367db53230abc8/cffi-2.1.1-cp314-cp314-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:f24fb43132a4c6b4cb4eb029492919b2db645be6808d738f244fd146c03c32cb", size = 196720, upload-time = "2026-08-03T21:20:18.268Z" }, + { url = "https://files.pythonhosted.org/packages/d9/99/c4b0c17cacdc9c3b8f280026286a9826d6a208c0f047591a3c3ce99b91fd/cffi-2.1.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:d28630f5854ab07ab1fd4aba756de52326c82e6be15d414b12793f1975048b54", size = 184964, upload-time = "2026-08-03T21:20:19.708Z" }, + { url = "https://files.pythonhosted.org/packages/b3/a9/9db617d05d7367c1ad0ab00b3aa6e6f9281edd689b4ee9ea0e5a84e89c97/cffi-2.1.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:661c298b4821edebead0c91edd2b00374d67ad7c5a1f7a91d4442633b79d6a72", size = 184962, upload-time = "2026-08-03T21:20:20.833Z" }, + { url = "https://files.pythonhosted.org/packages/67/b8/b42132ca113dc567d37684437b46ca1dafc885902b02a110a02d5b511857/cffi-2.1.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:58acb8ab8e295e6c5ea12f888cbb13cf21511ef2a3303a23f4325c29d17fe5c1", size = 222328, upload-time = "2026-08-03T21:20:22.118Z" }, + { url = "https://files.pythonhosted.org/packages/80/10/c5c0cbf0a657aecf59ef511409734230bf556f05a0d6c9eed7aa5c0a0166/cffi-2.1.1-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:456a61fa52d579ebf9df2e9552ead5129855dbaff6c1e5a9b1bc408809bdc062", size = 209985, upload-time = "2026-08-03T21:20:23.401Z" }, + { url = "https://files.pythonhosted.org/packages/d5/6c/bfa0b87b03b9238148beca990292843c9396ba069b54496596594173de7b/cffi-2.1.1-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:a4f00aa42f75d6e4595e8866e748cc1705adc0cddfeb2ca86d0d03993d63ba03", size = 208530, upload-time = "2026-08-03T21:20:24.628Z" }, + { url = "https://files.pythonhosted.org/packages/e9/02/4e7d553a7ac4b4238b38b3c1b80d486e9d4436f8d2acbf87a0997fe3f402/cffi-2.1.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:b0431303acaea1089ad4b3e9ce4e6518193def1118d4073ca848635ee4ea2e96", size = 221525, upload-time = "2026-08-03T21:20:25.758Z" }, + { url = "https://files.pythonhosted.org/packages/82/1d/a4aaf9babd75acb4d5f223bff71533bee748dd770a382619a798960ee9ba/cffi-2.1.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:64faea20f4e2613363a1a9b9c7dd73058f3ecd00133a511e72ad7c511658f527", size = 225053, upload-time = "2026-08-03T21:20:26.985Z" }, + { url = "https://files.pythonhosted.org/packages/81/10/5dc0e7bdd18e22107054288283380fc97a06ae3f1656a106908d666a3c88/cffi-2.1.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:5c58fe613dc5e5336357eff555824a314d8e43282600435c8d1cb6a7a2fedd13", size = 223213, upload-time = "2026-08-03T21:20:28.277Z" }, + { url = "https://files.pythonhosted.org/packages/0b/e9/d0061c364cde06ee43168a0d076ac1da512cbc380d44767b844ba34fe2b6/cffi-2.1.1-cp314-cp314-win32.whl", hash = "sha256:1a18a57b58cfb21fc28d72e876acf10eaed67a1ed96226f92af4df681d571c4c", size = 177682, upload-time = "2026-08-03T21:20:44.288Z" }, + { url = "https://files.pythonhosted.org/packages/a7/06/1c3e01e3ba14c39f6d10bfbac52753b7e22259e38088e5cfe1d704918690/cffi-2.1.1-cp314-cp314-win_amd64.whl", hash = "sha256:3222ba5d678f80a030e6afbcc33dc1ae5cb45facabb61cee2c7016b8432fde48", size = 187949, upload-time = "2026-08-03T21:20:45.623Z" }, + { url = "https://files.pythonhosted.org/packages/87/5b/da4e39efe18eeb89cf580ea9cfc66b6a7c3eadb808fc0cc1d3a295cb5a5d/cffi-2.1.1-cp314-cp314-win_arm64.whl", hash = "sha256:ab36d55f9ed2d067327667c2fea18dda018eb628dd6347aa01dda6cf1f5d3836", size = 182947, upload-time = "2026-08-03T21:20:46.955Z" }, + { url = "https://files.pythonhosted.org/packages/23/59/40338bf421c5accea1d45158170c87006ef1cd371b05c077e76476949728/cffi-2.1.1-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:7750c6449dff7864bb9bb27ddfb0267756189201a3afc911d82b3caacd70dfc3", size = 188504, upload-time = "2026-08-03T21:20:29.495Z" }, + { url = "https://files.pythonhosted.org/packages/7d/47/5ecf1023850036e674c77ec4de86182d309ae344e39e7cba984b7df5d647/cffi-2.1.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:0beceaabe56af686895136a2de78db54ecd8e4046b236b8fd6d6cb61389e9bf2", size = 188259, upload-time = "2026-08-03T21:20:31.291Z" }, + { url = "https://files.pythonhosted.org/packages/2a/9c/92934c3bea9f785b23eba304538c0b4d37a2a96d2431eb3a1bc87a11aa19/cffi-2.1.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:49cbc70e6542d4ccccb936558d1064a8012541e78f821f955cff24e357776c94", size = 223864, upload-time = "2026-08-03T21:20:32.571Z" }, + { url = "https://files.pythonhosted.org/packages/4d/45/ba4c93527bc38616a8bd36488acb69a2212d60486794f0c1f318949bbb76/cffi-2.1.1-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:e2d65b31f36619cda3999b78b2aa9632e76b78448e7a56fc4240824200e7c4fc", size = 211538, upload-time = "2026-08-03T21:20:33.808Z" }, + { url = "https://files.pythonhosted.org/packages/80/e9/b6ef565e452acb932fb0cb5443f44a78efbd1233e566f02b5a83855e9115/cffi-2.1.1-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:28907ab9bfb6aa13184cfc17c6b8e1023c5ab6fd7076d8c20a35e59fe04f8f29", size = 210688, upload-time = "2026-08-03T21:20:34.974Z" }, + { url = "https://files.pythonhosted.org/packages/9a/95/eff5f0cee78d2eabc7eebffec40d3fc1876b5f3c95582e018bb4b99601f2/cffi-2.1.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:51b31d1c98274844cfd7838ce00bfc27c7423a4dc00fc0772fc3331c2cc90676", size = 223803, upload-time = "2026-08-03T21:20:36.564Z" }, + { url = "https://files.pythonhosted.org/packages/fa/01/579d39fb8bef00a335a23d83757b44feb24cd6345a2c451b64cb67b9c362/cffi-2.1.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:5e7cecbaadb83884793e05828cee59b210b24583b9c7425d0ba6a754fe22eb4e", size = 226763, upload-time = "2026-08-03T21:20:37.816Z" }, + { url = "https://files.pythonhosted.org/packages/8d/b0/0b44f47c60b01b57b6e2bbd92343f13a85a1d93bc46ccf6e47e244acd99c/cffi-2.1.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:25792eac27877609e7bb06d42ff88278a6624fff2ba9bbb523c09616b117e80f", size = 225688, upload-time = "2026-08-03T21:20:38.959Z" }, + { url = "https://files.pythonhosted.org/packages/eb/d2/3b7176cb570a1d3e27faf67b72f591af508036e0d8b2be2ef9af9e8c84bb/cffi-2.1.1-cp314-cp314t-win32.whl", hash = "sha256:8ef53b2de9bcb9197d31854256575d59dbac0cba72ac627bb291ef5eceb74be4", size = 182868, upload-time = "2026-08-03T21:20:40.388Z" }, + { url = "https://files.pythonhosted.org/packages/56/78/31f00c1bcd97c9bbf55f1bfdf5bc809a5de8887473e90bb9960dca825e80/cffi-2.1.1-cp314-cp314t-win_amd64.whl", hash = "sha256:616f097f2fe415bc92a247f02e11f634e1f9e9a83d327e3c915c15089c87869e", size = 194104, upload-time = "2026-08-03T21:20:41.725Z" }, + { url = "https://files.pythonhosted.org/packages/7b/1b/58496f2ed0a35de575250c02a43ab3cc2c04d494a88fed31c1cabc0fd176/cffi-2.1.1-cp314-cp314t-win_arm64.whl", hash = "sha256:ad2c86c495b899d862ea0f4b42891b8713a3bd45dd4105c7fd51c2a72f39f3a5", size = 186402, upload-time = "2026-08-03T21:20:43.042Z" }, + { url = "https://files.pythonhosted.org/packages/c1/8f/9ebe220eab48a093d1a5a5e339ab0dc7316eef3bb04d63c42f0251b61f50/cffi-2.1.1-cp315-cp315-ios_13_0_arm64_iphoneos.whl", hash = "sha256:dddad92b554513a31f272570678ba307fb9f618f05e3d4a5eacafff9eae03e1d", size = 194043, upload-time = "2026-08-03T21:20:48.179Z" }, + { url = "https://files.pythonhosted.org/packages/ff/69/844bad3ece306c4782c2ecb93597035b6690d48704b803914c199da1e8b3/cffi-2.1.1-cp315-cp315-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:da0e573f9f97159390c89d9f1a9e41908b66d408cc5b58d08cf3847d844c531b", size = 196737, upload-time = "2026-08-03T21:20:49.457Z" }, + { url = "https://files.pythonhosted.org/packages/1b/8a/af668013284634733f02d683458a0728739c7d6ddb5e14cb0c20832266fe/cffi-2.1.1-cp315-cp315-macosx_10_15_x86_64.whl", hash = "sha256:fb92203a88b3d3053034db775110081c49d28be6551923805e039924093761e4", size = 184933, upload-time = "2026-08-03T21:20:50.639Z" }, + { url = "https://files.pythonhosted.org/packages/0c/75/2f5207ff6d1a613133b23a5203cc0c2a628313b5eb3974d7956ae3c57950/cffi-2.1.1-cp315-cp315-macosx_11_0_arm64.whl", hash = "sha256:2ae64be792b8966f2c69538199728b290e34726562896df1e5dc8ffd8d8188e8", size = 185002, upload-time = "2026-08-03T21:20:52.173Z" }, + { url = "https://files.pythonhosted.org/packages/e2/31/9e1313b0a6e30e91b3b3d3fff51ae99c857c07738e3afcce1f7334e1b7ab/cffi-2.1.1-cp315-cp315-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:507a24c282e0f42f8ed737cf048572cbf580468da5555764a8331735e9c736b6", size = 222271, upload-time = "2026-08-03T21:20:53.462Z" }, + { url = "https://files.pythonhosted.org/packages/50/e3/f6234a833e6e08c7007003074723c406559eecf9b48dfc97471e5a8eb7a0/cffi-2.1.1-cp315-cp315-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:246fa40ce8645a614ff682e0b70f37134e460eaf93a775e0cbe3cca585a67a80", size = 209919, upload-time = "2026-08-03T21:20:54.783Z" }, + { url = "https://files.pythonhosted.org/packages/0d/fc/5f74e293fced6edb51af3a46c4ccf6c23c9943774ecb375ddbd522c76add/cffi-2.1.1-cp315-cp315-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:471cee653ae88de62096552e6d24ccb4a5adb8c8c9f10b5054d0122c15bf2779", size = 208529, upload-time = "2026-08-03T21:20:56.066Z" }, + { url = "https://files.pythonhosted.org/packages/44/16/29e6d01b388bef055ecd6ca8244b3f4d336bd09e92d5d892187b9601084e/cffi-2.1.1-cp315-cp315-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:aeae0e330c9f6acd681f647d46cefd30c29f93e3392882e792e82080c9691399", size = 221630, upload-time = "2026-08-03T21:20:57.336Z" }, + { url = "https://files.pythonhosted.org/packages/a4/18/fa7f1f6857d5eb88a4ca99ffcbfb7c387a287ccc154c64a73e86314745d7/cffi-2.1.1-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:42a494cee34437f05546455144f2b5d9ac09b1face62bcfce597d2e521066688", size = 225134, upload-time = "2026-08-03T21:20:58.675Z" }, + { url = "https://files.pythonhosted.org/packages/e0/9f/e8e3dfa04a1b4c241f8c91faacad872b4d4efd051d49764ad4e2fd4b9fea/cffi-2.1.1-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:cc572dace3f60ef98d7b12ff411d20f5362feb31a0439eab0085bbfd349982d7", size = 223197, upload-time = "2026-08-03T21:20:59.968Z" }, + { url = "https://files.pythonhosted.org/packages/f8/7e/8debeb04f1ab9fe2a6963964cd6f1aaf7192627b83926586a6a4e089c9fa/cffi-2.1.1-cp315-cp315-win32.whl", hash = "sha256:4f42141fc14250de6dde5ee7ea4432be017252d91f19c5ad043c084cea629cac", size = 177683, upload-time = "2026-08-03T21:21:14.901Z" }, + { url = "https://files.pythonhosted.org/packages/e0/31/5158704cc474ab65c1647932e88be78dc0873f47130e253be38bcaf13d01/cffi-2.1.1-cp315-cp315-win_amd64.whl", hash = "sha256:e6e8cff14d6fb0be70a09c0bdc58096f501952d04624ebf867e0e56da2df8960", size = 187897, upload-time = "2026-08-03T21:21:16.108Z" }, + { url = "https://files.pythonhosted.org/packages/cc/4b/b3a2da8570c704ffc0f9762cdc3ec0f02c8573798e0b5cf7f11c82bbb70f/cffi-2.1.1-cp315-cp315-win_arm64.whl", hash = "sha256:27350daa11d4f10c540e6e89dada4c54feb7256ad03e9a4dc075ebad7ba360d1", size = 182935, upload-time = "2026-08-03T21:21:17.271Z" }, + { url = "https://files.pythonhosted.org/packages/d0/ef/5443574510a1207e6f6bc38ba6e1f1de36cb48fef07b2728bb896a21f430/cffi-2.1.1-cp315-cp315t-macosx_10_15_x86_64.whl", hash = "sha256:c26608d2222fb1e94487e4a387d85f13eb55d5ed725cb25a0c589ac4ee60e7bc", size = 188464, upload-time = "2026-08-03T21:21:01.163Z" }, + { url = "https://files.pythonhosted.org/packages/7e/ae/a56fa8c4686ad50e148fcbc8d3ae0d03915ff5c30d795058988c24118cef/cffi-2.1.1-cp315-cp315t-macosx_11_0_arm64.whl", hash = "sha256:4be96343e422f2dfcd12ab5c9f5aebe03f82f737c6bffeca6830b3875cb44aab", size = 188262, upload-time = "2026-08-03T21:21:02.382Z" }, + { url = "https://files.pythonhosted.org/packages/53/b2/6187f46f2912276a3ae284076109cc5c8680482f11f766ccf26db4a86427/cffi-2.1.1-cp315-cp315t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:937c0052c05a31ca1daf18de3158eed4dbfcb9cc107adbea227728d647be701e", size = 223779, upload-time = "2026-08-03T21:21:03.553Z" }, + { url = "https://files.pythonhosted.org/packages/8a/f6/c3ad28bd19f77047a03084424fbd4cbe997303267c14423737324be0385d/cffi-2.1.1-cp315-cp315t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:df423d40ee8654634421812bc3b196da3f9bd7d32929da813f8394c4348a5358", size = 211520, upload-time = "2026-08-03T21:21:04.863Z" }, + { url = "https://files.pythonhosted.org/packages/a0/cd/ccac9013a5bd9fd764de118674ab9c805b5ca10c19270d90ee273f8b2240/cffi-2.1.1-cp315-cp315t-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:a730a083190634c65cca36ba5f489531576ebd79bcd5c8e172130f6453127231", size = 210673, upload-time = "2026-08-03T21:21:06.223Z" }, + { url = "https://files.pythonhosted.org/packages/52/86/2976131c639aead931c5bee5aba67e4b09fbeb8018b6f282f70803f923a7/cffi-2.1.1-cp315-cp315t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:363e05fa78e15116c3c32c210ee36884fd6b9afa6d440e47112c3bd511d64cb6", size = 223835, upload-time = "2026-08-03T21:21:07.539Z" }, + { url = "https://files.pythonhosted.org/packages/ac/0c/33a7aeab2f9c76918c52e084beb39c570db3588133412929e8ec06fab90b/cffi-2.1.1-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:770de9db11e84213beec501cfcaa013b019820ca881e03344dea5844f7876d94", size = 226705, upload-time = "2026-08-03T21:21:08.774Z" }, + { url = "https://files.pythonhosted.org/packages/e3/26/2cde30fdde421130bfc18f70395731a6e6b2053c6a1978a5258ff04e72fa/cffi-2.1.1-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:7da0c5eff80f0197f3b3d1232ec5a682a9325f4ae9016a78f5f5ca35f9ced1f5", size = 225539, upload-time = "2026-08-03T21:21:09.911Z" }, + { url = "https://files.pythonhosted.org/packages/6d/cd/a361394c94b2129d604bb846f624a8e88255a3ee33129c434a00d715e64f/cffi-2.1.1-cp315-cp315t-win32.whl", hash = "sha256:06c72bb76605a4b0cd0aad6930b69d4baf7dd5d806cfc409b824191099700e66", size = 182707, upload-time = "2026-08-03T21:21:11.226Z" }, + { url = "https://files.pythonhosted.org/packages/9b/b5/ba2b299993c26577d529b6ae29841f9e15b9fcf004d65f423f4fcf94ade9/cffi-2.1.1-cp315-cp315t-win_amd64.whl", hash = "sha256:d9c275eaacd24aa73f94ffd6de08fc3f932424d8b6c376f4bed7cde376fe7bc3", size = 193772, upload-time = "2026-08-03T21:21:12.39Z" }, + { url = "https://files.pythonhosted.org/packages/aa/29/35e016098c814cd93de9cd320c66b5bfba14dc6ecedd3cb518fa7c408c69/cffi-2.1.1-cp315-cp315t-win_arm64.whl", hash = "sha256:d18e5ac0f2f03f4f518d3e23db0f0cad7faa1da8620e9c09461d443bbf6e6692", size = 186360, upload-time = "2026-08-03T21:21:13.636Z" }, +] + +[[package]] +name = "colorama" +version = "0.4.6" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697, upload-time = "2022-10-25T02:36:22.414Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" }, +] + +[[package]] +name = "imageio-ffmpeg" +version = "0.6.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/44/bd/c3343c721f2a1b0c9fc71c1aebf1966a3b7f08c2eea8ed5437a2865611d6/imageio_ffmpeg-0.6.0.tar.gz", hash = "sha256:e2556bed8e005564a9f925bb7afa4002d82770d6b08825078b7697ab88ba1755", size = 25210, upload-time = "2025-01-16T21:34:32.747Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/da/58/87ef68ac83f4c7690961bce288fd8e382bc5f1513860fc7f90a9c1c1c6bf/imageio_ffmpeg-0.6.0-py3-none-macosx_10_9_intel.macosx_10_9_x86_64.whl", hash = "sha256:9d2baaf867088508d4a3458e61eeb30e945c4ad8016025545f66c4b5aaef0a61", size = 24932969, upload-time = "2025-01-16T21:34:20.464Z" }, + { url = "https://files.pythonhosted.org/packages/40/5c/f3d8a657d362cc93b81aab8feda487317da5b5d31c0e1fdfd5e986e55d17/imageio_ffmpeg-0.6.0-py3-none-macosx_11_0_arm64.whl", hash = "sha256:b1ae3173414b5fc5f538a726c4e48ea97edc0d2cdc11f103afee655c463fa742", size = 21113891, upload-time = "2025-01-16T21:34:00.277Z" }, + { url = "https://files.pythonhosted.org/packages/33/e7/1925bfbc563c39c1d2e82501d8372734a5c725e53ac3b31b4c2d081e895b/imageio_ffmpeg-0.6.0-py3-none-manylinux2014_aarch64.whl", hash = "sha256:1d47bebd83d2c5fc770720d211855f208af8a596c82d17730aa51e815cdee6dc", size = 25632706, upload-time = "2025-01-16T21:33:53.475Z" }, + { url = "https://files.pythonhosted.org/packages/a0/2d/43c8522a2038e9d0e7dbdf3a61195ecc31ca576fb1527a528c877e87d973/imageio_ffmpeg-0.6.0-py3-none-manylinux2014_x86_64.whl", hash = "sha256:c7e46fcec401dd990405049d2e2f475e2b397779df2519b544b8aab515195282", size = 29498237, upload-time = "2025-01-16T21:34:13.726Z" }, + { url = "https://files.pythonhosted.org/packages/a0/13/59da54728351883c3c1d9fca1710ab8eee82c7beba585df8f25ca925f08f/imageio_ffmpeg-0.6.0-py3-none-win32.whl", hash = "sha256:196faa79366b4a82f95c0f4053191d2013f4714a715780f0ad2a68ff37483cc2", size = 19652251, upload-time = "2025-01-16T21:34:06.812Z" }, + { url = "https://files.pythonhosted.org/packages/2c/c6/fa760e12a2483469e2bf5058c5faff664acf66cadb4df2ad6205b016a73d/imageio_ffmpeg-0.6.0-py3-none-win_amd64.whl", hash = "sha256:02fa47c83703c37df6bfe4896aab339013f62bf02c5ebf2dce6da56af04ffc0a", size = 31246824, upload-time = "2025-01-16T21:34:28.6Z" }, +] + +[[package]] +name = "iniconfig" +version = "2.3.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/72/34/14ca021ce8e5dfedc35312d08ba8bf51fdd999c576889fc2c24cb97f4f10/iniconfig-2.3.0.tar.gz", hash = "sha256:c76315c77db068650d49c5b56314774a7804df16fee4402c1f19d6d15d8c4730", size = 20503, upload-time = "2025-10-18T21:55:43.219Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12", size = 7484, upload-time = "2025-10-18T21:55:41.639Z" }, +] + +[[package]] +name = "markdown-it-py" +version = "4.2.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "mdurl" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/06/ff/7841249c247aa650a76b9ee4bbaeae59370dc8bfd2f6c01f3630c35eb134/markdown_it_py-4.2.0.tar.gz", hash = "sha256:04a21681d6fbb623de53f6f364d352309d4094dd4194040a10fd51833e418d49", size = 82454, upload-time = "2026-05-07T12:08:28.36Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b3/81/4da04ced5a082363ecfa159c010d200ecbd959ae410c10c0264a38cac0f5/markdown_it_py-4.2.0-py3-none-any.whl", hash = "sha256:9f7ebbcd14fe59494226453aed97c1070d83f8d24b6fc3a3bcf9a38092641c4a", size = 91687, upload-time = "2026-05-07T12:08:27.182Z" }, +] + +[[package]] +name = "mdurl" +version = "0.1.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d6/54/cfe61301667036ec958cb99bd3efefba235e65cdeb9c84d24a8293ba1d90/mdurl-0.1.2.tar.gz", hash = "sha256:bb413d29f5eea38f31dd4754dd7377d4465116fb207585f97bf925588687c1ba", size = 8729, upload-time = "2022-08-14T12:40:10.846Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b3/38/89ba8ad64ae25be8de66a6d463314cf1eb366222074cfda9ee839c56a4b4/mdurl-0.1.2-py3-none-any.whl", hash = "sha256:84008a41e51615a49fc9966191ff91509e3c40b939176e643fd50a5c2196b8f8", size = 9979, upload-time = "2022-08-14T12:40:09.779Z" }, +] + +[[package]] +name = "numpy" +version = "2.4.6" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version < '3.12'", +] +sdist = { url = "https://files.pythonhosted.org/packages/d0/ad/fed0499ce6a338d2a03ebae59cd15093910c8875328855781952abf6c2fe/numpy-2.4.6.tar.gz", hash = "sha256:f3a3570c4a2a16746ac2c31a7c7c7b0c186b95ce902e33db6f28094ed7387dda", size = 20735807, upload-time = "2026-05-18T23:37:14.07Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b3/49/ec46835a70be8fa6446c495126ac84fdb28cb2558e1620ffb87a10c8b64c/numpy-2.4.6-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:0280e0356c0829a18d9de1cb7eee50ec22ca639878d7240307ca0943d73cd2c4", size = 16969194, upload-time = "2026-05-18T23:33:13.503Z" }, + { url = "https://files.pythonhosted.org/packages/0e/0d/f5957185c0ee2f3e12f78715aa9e3b353fd83633316c8532b38faa37e3f6/numpy-2.4.6-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:110f8b71aacb688ec69062bb7f6938a0f8acb01b7c1c4beb453c65b6d234584d", size = 14964111, upload-time = "2026-05-18T23:33:17.795Z" }, + { url = "https://files.pythonhosted.org/packages/ad/40/40a40ee0ddf7ceb782c49af278894b686e586d65d8c1889c8b5da01a3d7d/numpy-2.4.6-cp311-cp311-macosx_14_0_arm64.whl", hash = "sha256:4cfe66903cc32a9921a6733d96b19bb6abf310397581bbad89c228f5abaf0ee8", size = 5469159, upload-time = "2026-05-18T23:33:20.654Z" }, + { url = "https://files.pythonhosted.org/packages/63/13/f9a8046535cb21deae82f8d03de9617e08882d274fad2539630761888228/numpy-2.4.6-cp311-cp311-macosx_14_0_x86_64.whl", hash = "sha256:8155154c7c691289fe18f510b5d4657c68c67989f293f0535a91360392ff6538", size = 6798936, upload-time = "2026-05-18T23:33:22.987Z" }, + { url = "https://files.pythonhosted.org/packages/33/a8/6fa8c1a345a8c85dbb21932c447bee07c30a2c2a3f31e369c0a84b300147/numpy-2.4.6-cp311-cp311-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0ab0a9c4ffb1a6d95ef519fe4247dba8eb6b18ad93999f76b7f657039acabd47", size = 15966692, upload-time = "2026-05-18T23:33:26.62Z" }, + { url = "https://files.pythonhosted.org/packages/02/03/74fe2a4cb3817d94d86402f2506554130a2f01414e299b5a843e5a8a957f/numpy-2.4.6-cp311-cp311-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:89cd468399cfd2504718f0ba50e410dca55a170b61a02ad92bb18c8a65186e93", size = 16918164, upload-time = "2026-05-18T23:33:29.955Z" }, + { url = "https://files.pythonhosted.org/packages/c5/80/3615be3313f7e7696609bc194b9f0101da809df79e859bdb84e0cd043f46/numpy-2.4.6-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:c2d37ab77531417474168eb79d6d80b14f821a966818505d03013d0833edb7a8", size = 17322877, upload-time = "2026-05-18T23:33:34.724Z" }, + { url = "https://files.pythonhosted.org/packages/ca/ac/a691e0fe2675e370d0e08ff905adc49a1c8830e8cae03efe4477e92cd55d/numpy-2.4.6-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:f407cb6b8e9d6d8c626bc73c945db1706035af8fd632295547bf1c9e46d092d6", size = 18651487, upload-time = "2026-05-18T23:33:38.217Z" }, + { url = "https://files.pythonhosted.org/packages/15/a7/9bc1cd626d7bf6869bfedf27b91b6ab5dd607758bf8e959d6fa80c6a59cb/numpy-2.4.6-cp311-cp311-win32.whl", hash = "sha256:ddea102b48f9e339f3948bf22040944184627a30fdf7f858667673b9c5f033c8", size = 6233945, upload-time = "2026-05-18T23:33:41.331Z" }, + { url = "https://files.pythonhosted.org/packages/c5/31/7fc6239c12bce7e931463251cca4426c465e1876ba3cc785402ef4dd8f4e/numpy-2.4.6-cp311-cp311-win_amd64.whl", hash = "sha256:1e254a00cdf42b1e4d5b3d68d33af63268d41340d8885df2ab6470f2e1500147", size = 12608406, upload-time = "2026-05-18T23:33:44.131Z" }, + { url = "https://files.pythonhosted.org/packages/27/83/140f85a466595a16382996a1bf06b2b54bcd597488921b0c9daaeeda72af/numpy-2.4.6-cp311-cp311-win_arm64.whl", hash = "sha256:ed9749eef4cbd126da3dc1d6bcb3a57f5eb7ac6a6484146bdbf743f552dfc577", size = 10479528, upload-time = "2026-05-18T23:33:50.725Z" }, + { url = "https://files.pythonhosted.org/packages/95/2a/3d7b5ac8aac24feaf9ad7ed58f45b0bbc06d37e4338ae84c9f2298b570f9/numpy-2.4.6-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:001fbb8e08d942dd57599e781f2472269ee7f2755fae407b4f67b2f0b17da3f1", size = 16689119, upload-time = "2026-05-18T23:33:54.065Z" }, + { url = "https://files.pythonhosted.org/packages/ea/12/92c4c131527599e8288d6918e888d88726f84d805d784b771f32408aeaef/numpy-2.4.6-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:ebfb099f8dcf083deef3ac1ca4c1503f387cf76296fcb3816b66f5ecb5f54fdb", size = 14699246, upload-time = "2026-05-18T23:33:57.621Z" }, + { url = "https://files.pythonhosted.org/packages/ad/fe/c0a6b7b2ca128a8fb228575147073b660656734b8ebe4d76c8fd748dcc79/numpy-2.4.6-cp312-cp312-macosx_14_0_arm64.whl", hash = "sha256:3213d622a0283a39a93d188f3cf72b26862df52fbb4ca3697f51705016523d41", size = 5204410, upload-time = "2026-05-18T23:34:00.302Z" }, + { url = "https://files.pythonhosted.org/packages/f3/d4/9770d14ba719432bb90a421bfd443872ed0f70f7264b64bec12ea363d5fd/numpy-2.4.6-cp312-cp312-macosx_14_0_x86_64.whl", hash = "sha256:357cc07a6d7b0b182ff02249616a03742827ebb1277546b5c7cd7f7620a45698", size = 6551240, upload-time = "2026-05-18T23:34:02.852Z" }, + { url = "https://files.pythonhosted.org/packages/c9/c6/50a46a6205feba2343f1d6d17438107c5dc491ed1c736e6ea68689fd906b/numpy-2.4.6-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5f9fb9157b4ce2971008323afe46053787b526ef624fea915b261468a8421a0f", size = 15671012, upload-time = "2026-05-18T23:34:05.485Z" }, + { url = "https://files.pythonhosted.org/packages/99/60/14115e6364fa676c5397c2ad3004e527e9aa487abf5d0706ec81bbd08529/numpy-2.4.6-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:90f9849678c75fe7afa2d348ac842c168b0a4d3d61919687216dfc547976d853", size = 16645538, upload-time = "2026-05-18T23:34:09.265Z" }, + { url = "https://files.pythonhosted.org/packages/ae/c5/693cbe59e57db94d2231fa519ca3978dc9e19da5a8f088588f5c6e947ff2/numpy-2.4.6-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:c1a2af6c6ef86344a6b0db6b97834208bf598db514f2b155042439b62605601a", size = 17020706, upload-time = "2026-05-18T23:34:13.053Z" }, + { url = "https://files.pythonhosted.org/packages/ef/fc/85b7c4eff9b4966ade25c2273cf7e7012e92366c032058653934b37de044/numpy-2.4.6-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:e5805d5a22fd19c8ccff10a9561f9df94436b0545619ea579db2d3c35294bce2", size = 18368541, upload-time = "2026-05-18T23:34:17.024Z" }, + { url = "https://files.pythonhosted.org/packages/f6/81/e1b27545deedce7f4a0b348618c6b62d74e36a4dc9ccd42f3eb2f85eee32/numpy-2.4.6-cp312-cp312-win32.whl", hash = "sha256:e3eeb0aabd6bd5ce64faae67e9935203a6991b4bc2a485a767fbafb2c5125f45", size = 5962825, upload-time = "2026-05-18T23:34:20.3Z" }, + { url = "https://files.pythonhosted.org/packages/ab/ca/feab00bd44aa5fe1ad2c18f08b4d3bb92e26484b0b1d1443897809ed528c/numpy-2.4.6-cp312-cp312-win_amd64.whl", hash = "sha256:d8e8286dd7cea7895157318d1b91cdacac64c479f3cbc8dce548331728484751", size = 12321687, upload-time = "2026-05-18T23:34:23.095Z" }, + { url = "https://files.pythonhosted.org/packages/63/cf/5a6d34850a39d1093558564f77ee8e8e0bee5061151b8f05a55711001ec7/numpy-2.4.6-cp312-cp312-win_arm64.whl", hash = "sha256:4081eb135ac24158bd51cdfbef16f1c64df7063b1143f24731387137c092bec8", size = 10221482, upload-time = "2026-05-18T23:34:25.876Z" }, + { url = "https://files.pythonhosted.org/packages/fb/82/bdab26d7438c6791ca31b7c024ca37c1eab8b726ba236129005cd4a06e45/numpy-2.4.6-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:511dbaf848decaaaf4b4ca48032619fb3138710c4bf7da7617765edad1ef96b0", size = 16684648, upload-time = "2026-05-18T23:34:29.41Z" }, + { url = "https://files.pythonhosted.org/packages/1b/30/a80189bcc7f5e4258b3fbc3968d909d1756f54d023299ecc39ad6fdb9ef8/numpy-2.4.6-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:bf162abab1c1a736333192707cef898e735a5ca00f38f27eeedf44b39d9e85eb", size = 14693902, upload-time = "2026-05-18T23:34:33.013Z" }, + { url = "https://files.pythonhosted.org/packages/97/12/70b5d0d7c15e1ebb8a6a84a8caa1d19e181d84fb58bb6d70aca29099dec1/numpy-2.4.6-cp313-cp313-macosx_14_0_arm64.whl", hash = "sha256:043191bfa8eab18c776647b62723ac9dddece59743b13f49b2016094129c2b3f", size = 5198992, upload-time = "2026-05-18T23:34:36.132Z" }, + { url = "https://files.pythonhosted.org/packages/ba/8c/ebd2a8f8a83541f8d38cc5667e8c2b69cecfd30da6e45693e8158857d44b/numpy-2.4.6-cp313-cp313-macosx_14_0_x86_64.whl", hash = "sha256:6180d8b35af935aed8ece3a85e0a43f87393ae0ac87c8d2c8bd2c993f7270ef3", size = 6546944, upload-time = "2026-05-18T23:34:38.484Z" }, + { url = "https://files.pythonhosted.org/packages/bb/c5/7b863a97a91671a0338f4253bd3b5a3d3852f0692dae91711c9f4a10e787/numpy-2.4.6-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:72fbe16c6fac95aedf5937fa873445cec2110be35d8a4e9433d7501fd98dae6b", size = 15669392, upload-time = "2026-05-18T23:34:41.257Z" }, + { url = "https://files.pythonhosted.org/packages/a5/9d/3584b9984ca4c047aea75214ce1a4c4c73d849bd71b604264b7f5653f8a8/numpy-2.4.6-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a7830bab239b79cda9c08c2da014761cafb48da6150e1da17ac06283f43b6089", size = 16633220, upload-time = "2026-05-18T23:34:45.075Z" }, + { url = "https://files.pythonhosted.org/packages/05/ae/7c67fba23bd98caec7c99261f3a16072ade14813486b0282cb29846de832/numpy-2.4.6-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:ef4aea96ce4d3b074422cb4f2f64e216bf9e213004bb58ecfdf50ea02ea8eb9a", size = 17020800, upload-time = "2026-05-18T23:34:49.065Z" }, + { url = "https://files.pythonhosted.org/packages/d9/5d/3b6725cb31d983c5e66916f5d36f6d7e5521129e4c4404d64f918292a5b6/numpy-2.4.6-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:dfa20cc6ca228e6b155b11da03825975ce66aea520985dbbddf0f2a5a495c605", size = 18357600, upload-time = "2026-05-18T23:34:52.709Z" }, + { url = "https://files.pythonhosted.org/packages/f7/da/2ccc6c2fe8898dee01d90c75c5f5f914a23daf99e3e0f59516a08760c8b5/numpy-2.4.6-cp313-cp313-win32.whl", hash = "sha256:56b39e5e0622a09a25bf5baf62f4bcf0cb8a41ae6e2819cf49bbc5a74c083f91", size = 5961134, upload-time = "2026-05-18T23:34:55.618Z" }, + { url = "https://files.pythonhosted.org/packages/b5/cd/9cc4dc876fb065d5c220aae4d5e14826b2715331bb7618ce1fb07a679d99/numpy-2.4.6-cp313-cp313-win_amd64.whl", hash = "sha256:c4fc99836233ea196540b17ab0983aff60ed07941751930f5f4d05bc3b3b7359", size = 12318598, upload-time = "2026-05-18T23:34:58.928Z" }, + { url = "https://files.pythonhosted.org/packages/39/1e/c0bcba1f8694116485fe28fd1be698c278fcda4141c5b0e53a2aed8b12a8/numpy-2.4.6-cp313-cp313-win_arm64.whl", hash = "sha256:a7c711e21628b52034bb5ab8d1bce291f752fcc5e92accc615778acee1ff4778", size = 10222272, upload-time = "2026-05-18T23:35:02.167Z" }, + { url = "https://files.pythonhosted.org/packages/63/6d/cc5619247c8f4204e507f5883528372e4ac4bb189e579fb859a12e480b1f/numpy-2.4.6-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:112b06a867b235ef466ed3508ddf0238050df9c727cafb5301ac385b899189a1", size = 14821197, upload-time = "2026-05-18T23:35:05.468Z" }, + { url = "https://files.pythonhosted.org/packages/00/58/f1c39161c87d9e9bed660f1ed4bafc0e403d5ec9650b6dd77aead07d489b/numpy-2.4.6-cp313-cp313t-macosx_14_0_arm64.whl", hash = "sha256:eaf7fa2de5c0be8ae6ff8e9bea2ccd725e980541244521d8d4b5f3354a27babe", size = 5326287, upload-time = "2026-05-18T23:35:08.693Z" }, + { url = "https://files.pythonhosted.org/packages/af/57/3917ab0fd97f271a8694513581b8a36c655f111c446852c302f04ccdb6fc/numpy-2.4.6-cp313-cp313t-macosx_14_0_x86_64.whl", hash = "sha256:7265a2f3d436e54ef9f2b52b5c937e6be778781bd97a590319d7348f1c1ca997", size = 6646763, upload-time = "2026-05-18T23:35:11.459Z" }, + { url = "https://files.pythonhosted.org/packages/eb/0f/037e64c494b67581ae18193d770adef354c41f3f2c8ebf865602d949bf8f/numpy-2.4.6-cp313-cp313t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f74a575920ab21fe304421a3fc28793d82e299cae9eccb37084e9fc7f3617c20", size = 15728070, upload-time = "2026-05-18T23:35:14.79Z" }, + { url = "https://files.pythonhosted.org/packages/21/a6/5d2bae9c9542eb4df16dc9c46dc79c186e9bad53805dfa5399a6023c6db0/numpy-2.4.6-cp313-cp313t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ede83e07a75dd06bc501566c1eca2afc0d61677c1472ac9ad93fdee6e638a48d", size = 16681752, upload-time = "2026-05-18T23:35:18.836Z" }, + { url = "https://files.pythonhosted.org/packages/92/14/23d1dfb410ae362cd59ce53e936b1513d545eb40db3949ced632e19a459e/numpy-2.4.6-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:68bb27509ac1b9a3443094260f6326150663b06abe40b73a2f81160623da5b67", size = 17086024, upload-time = "2026-05-18T23:35:22.52Z" }, + { url = "https://files.pythonhosted.org/packages/4b/6e/23595a2c642cdf3bc567877064bdd7f91c8b0038a4453cf2daf7248eafe9/numpy-2.4.6-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:a0df0043bdb289bde1f62da130d20df23d58b45429f752bc7a8fc5325a225ecd", size = 18403398, upload-time = "2026-05-18T23:35:26.398Z" }, + { url = "https://files.pythonhosted.org/packages/8a/90/0ac3bc947217e66dec77e7cbc6a1979d1af70b6461b82f620d3bccd5e4c8/numpy-2.4.6-cp313-cp313t-win32.whl", hash = "sha256:29a287e0cf63ff528da061de6b9f64a4618da591ca1046aafc54062e40ca7eab", size = 6084971, upload-time = "2026-05-18T23:35:29.387Z" }, + { url = "https://files.pythonhosted.org/packages/77/71/5673e351671a1d2bd6063b91b44f70c0affea7d1516fa7a6572941ba4aa1/numpy-2.4.6-cp313-cp313t-win_amd64.whl", hash = "sha256:25c692919ac5a01f170a3bfcd62d745b24fd095c353d50812637d6fcab442e75", size = 12458532, upload-time = "2026-05-18T23:35:32.175Z" }, + { url = "https://files.pythonhosted.org/packages/3f/88/19d3503c5046e688f049274b27a3ef3d771152fa80d3ba3d01a3dff61abe/numpy-2.4.6-cp313-cp313t-win_arm64.whl", hash = "sha256:1e978ec1e8bd0e0e4de6bb75de9d30cbb74db6b6a2bb727618613703ca0167dd", size = 10291881, upload-time = "2026-05-18T23:35:35.465Z" }, + { url = "https://files.pythonhosted.org/packages/f8/91/3ab2044d05fd16d343c5ac2e69b127f1b2854040dd20b193257c78028bd3/numpy-2.4.6-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:06ca2f61ec4385a07a6977c55ba998a4466c123642b4a32694d3128fce18c079", size = 16683458, upload-time = "2026-05-18T23:35:38.353Z" }, + { url = "https://files.pythonhosted.org/packages/8e/62/764ce66fa4147ae6d73071a3abf804ffe606f174618697c571acdf26a7c9/numpy-2.4.6-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:38efbc8de75c7a0fc1ac190162d892787f3f47b57cc291231aafee36b80982b7", size = 14704559, upload-time = "2026-05-18T23:35:42.14Z" }, + { url = "https://files.pythonhosted.org/packages/60/61/23f27c172f022e04025b7dc2367f4d63c1a398120607ec896228649a6f48/numpy-2.4.6-cp314-cp314-macosx_14_0_arm64.whl", hash = "sha256:d581b735e177fdcdce6fed8e7e8880a3fb6ee4e3653a3ac6af01c6f4c03effc5", size = 5209716, upload-time = "2026-05-18T23:35:45.377Z" }, + { url = "https://files.pythonhosted.org/packages/03/71/21cf70dc6ea3e3acb95fc53a265b2fc248b981f0194ceb5b475271b8809d/numpy-2.4.6-cp314-cp314-macosx_14_0_x86_64.whl", hash = "sha256:0a041d3d761dc3c35cc56ce0351506a02bcbc25f7b169f652435141a17db9096", size = 6543947, upload-time = "2026-05-18T23:35:47.926Z" }, + { url = "https://files.pythonhosted.org/packages/d5/91/64288395ee1799bd2e0b04a305dce9666da90c961e1f3fe982a05ee1c036/numpy-2.4.6-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:40fdc1ae7125e518ea98e53e69a4ebc27e1fd50510c47b7ea130cf21e5e1d42b", size = 15685197, upload-time = "2026-05-18T23:35:50.863Z" }, + { url = "https://files.pythonhosted.org/packages/f3/eb/ebffaa97dc55502df69584a8f0dcf07f69a3e0b3e2323670a2722db9aa39/numpy-2.4.6-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a2c306dea656c12c68f51f4cea133cbe78ca7435eb28c735eac1d3ebe73be6e8", size = 16638245, upload-time = "2026-05-18T23:35:54.752Z" }, + { url = "https://files.pythonhosted.org/packages/b8/0b/54f9da33128d7e350fab89c7455902eeae70349ee52bddb448dc4a576f45/numpy-2.4.6-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:33111801a01c12a8a1e3721f0a9232f8cfc8ae2c6b7098167e6f623c6073f402", size = 17036587, upload-time = "2026-05-18T23:35:58.355Z" }, + { url = "https://files.pythonhosted.org/packages/b6/f0/fdebc1052db1cc37c64beb22072d67cd6d1c71adca1299f53dec2b5e20d3/numpy-2.4.6-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:ae506e6902902557576a26ff33eda8695e7ecb3cb36c3b573a0765dee114ebdb", size = 18363226, upload-time = "2026-05-18T23:36:02.845Z" }, + { url = "https://files.pythonhosted.org/packages/aa/b4/298628d98c72b57e57f7165ae6a481a1deaf6f3c28262a6e4c739c275930/numpy-2.4.6-cp314-cp314-win32.whl", hash = "sha256:aaf159caa35993cb1f56fb9b8e4610d35758e7ca005412eb1daa856a78c9c4b1", size = 6010196, upload-time = "2026-05-18T23:36:05.92Z" }, + { url = "https://files.pythonhosted.org/packages/df/ac/46de6dda46478f7942f839e094970be2d4a861e005c4b3bf07c92e291a09/numpy-2.4.6-cp314-cp314-win_amd64.whl", hash = "sha256:b507f5c4c1d508876d1819b6bf9a49d365b96320b5d4993426b33a23ca4b8261", size = 12450334, upload-time = "2026-05-18T23:36:09.107Z" }, + { url = "https://files.pythonhosted.org/packages/78/92/b8b798ac784102c0da830d2257d59358e3d3d90d1e2b3f2575dad976c5cf/numpy-2.4.6-cp314-cp314-win_arm64.whl", hash = "sha256:6f41ae150c4e32db4f3310cdaf64b1593a03dbabe29eec77fc9b50fe64061df6", size = 10495678, upload-time = "2026-05-18T23:36:12.766Z" }, + { url = "https://files.pythonhosted.org/packages/30/34/ec28d1aa8115971537c01469ab2011ee96827930f0a124de1000cc2a7ed7/numpy-2.4.6-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:ece3d2cfe132e7d51f44a832b303895e6f2d499c5e74dfbdb06ee246147a304a", size = 14823672, upload-time = "2026-05-18T23:36:16.473Z" }, + { url = "https://files.pythonhosted.org/packages/16/bd/f6d1fede4e54e8042a7ff97bb495510f3c220f94bcd9e8b228e87c92cc0d/numpy-2.4.6-cp314-cp314t-macosx_14_0_arm64.whl", hash = "sha256:e3e5193ef5a3dc73bceee50f7fdc2c90dbb76c42df8d8fae3d1067a583df579e", size = 5328731, upload-time = "2026-05-18T23:36:19.767Z" }, + { url = "https://files.pythonhosted.org/packages/f4/f0/e105b9e2fd728a9910103884decd6951d9dd73896b914a98d9a231de02ee/numpy-2.4.6-cp314-cp314t-macosx_14_0_x86_64.whl", hash = "sha256:17f9ade344e7d9b464a084d69bcf18fc691cb1db67c62ed80820bf4926d78f0e", size = 6649805, upload-time = "2026-05-18T23:36:22.266Z" }, + { url = "https://files.pythonhosted.org/packages/82/dd/1206a7ca6ab15e3f02069707ca96222e202af681bb73756da7527f3cb837/numpy-2.4.6-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9cd5ffd25db4e7ba6a375693b3fc0fc1791ec636c17db3720da19bde7180ec43", size = 15730496, upload-time = "2026-05-18T23:36:25.713Z" }, + { url = "https://files.pythonhosted.org/packages/51/e7/38d3ea825dcab85a591734decb2f6c67caa7c8367d374df1a1c3842f9b07/numpy-2.4.6-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7d92c3819208a60205a12a245c91ad70cb0a85336659b19b834205573ac8456e", size = 16679616, upload-time = "2026-05-18T23:36:29.652Z" }, + { url = "https://files.pythonhosted.org/packages/93/b7/caabfdf53edf663e0b4eb74d7d405d83baef09eb5e83bcd32d601d72b93e/numpy-2.4.6-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:e85b752a1e912b70eaad4fafbd4d1238007ab221de2009b9a2f5ae7461239895", size = 17085145, upload-time = "2026-05-18T23:36:33.449Z" }, + { url = "https://files.pythonhosted.org/packages/f9/45/68d7c33a6bcf3e5aa3bdbd57a367e6f615286dfd6482f97e8ffeb734306e/numpy-2.4.6-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:29cb7f67d10b479ff07c17d33e39f78c07f71c40ef30d63c153d340e96cd3fb4", size = 18403813, upload-time = "2026-05-18T23:36:37.369Z" }, + { url = "https://files.pythonhosted.org/packages/9c/50/0753655aa844c99cd9e018aacf76f130f1bd81d881bb74bc0aef5d73a8ba/numpy-2.4.6-cp314-cp314t-win32.whl", hash = "sha256:260a5d70215b61ab4fadf5c7baacd64821842975eea312125ed3c39a6391b063", size = 6156982, upload-time = "2026-05-18T23:36:40.817Z" }, + { url = "https://files.pythonhosted.org/packages/b2/d4/7c67becf668f973cb490cec3e98dfd799d866f9c989a54d355672cfa0db6/numpy-2.4.6-cp314-cp314t-win_amd64.whl", hash = "sha256:81a1cca95ed5bb92aa8b10dd2cdc9a0d3853a50fad926c28b5d7e8ea54389627", size = 12638908, upload-time = "2026-05-18T23:36:43.996Z" }, + { url = "https://files.pythonhosted.org/packages/43/bb/e1c71a4295b1b1d1393d50dbb4f2a36283c6859d9d3892e84f00ec5a91d5/numpy-2.4.6-cp314-cp314t-win_arm64.whl", hash = "sha256:0c9136e14ed34a9e343a31c533d78a9813a69a3148332bce5e9821cb2f996e66", size = 10565867, upload-time = "2026-05-18T23:36:47.114Z" }, + { url = "https://files.pythonhosted.org/packages/de/12/b422cc84439adc0d00de605bf4a308890ae5c26f2c71fbd73e5d08fbb0dd/numpy-2.4.6-pp311-pypy311_pp73-macosx_10_15_x86_64.whl", hash = "sha256:55cced7c52e981362f708ad635198e97a752dfba412cc03c23bbf3bd8d5cd662", size = 16847511, upload-time = "2026-05-18T23:36:50.673Z" }, + { url = "https://files.pythonhosted.org/packages/44/53/f481bef68011740f8849418d82db07230e825013f31f4eef5ba5b805316a/numpy-2.4.6-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:d6da64deb6b8ed903e7560180a92f2d804ee1ba5eeb849ac2748b8c1aba1f6d7", size = 14889064, upload-time = "2026-05-18T23:36:53.879Z" }, + { url = "https://files.pythonhosted.org/packages/7f/57/42ed575c10ced8af951d426bc4e1f8aff16fd851db33f067036215a7f860/numpy-2.4.6-pp311-pypy311_pp73-macosx_14_0_arm64.whl", hash = "sha256:68a5124b13fa6cc2086764a20005d30bc0548146f7f5322f02fce212ca14317f", size = 5394157, upload-time = "2026-05-18T23:36:57.194Z" }, + { url = "https://files.pythonhosted.org/packages/6a/ef/f66cc724fcc36c1e364c67f51ae9146090b8b584f27d58b97fdae3edd737/numpy-2.4.6-pp311-pypy311_pp73-macosx_14_0_x86_64.whl", hash = "sha256:948424b06129ce883307e8cff868c31396d8dc7630a59c61d70d98dbe70f222c", size = 6708728, upload-time = "2026-05-18T23:36:59.575Z" }, + { url = "https://files.pythonhosted.org/packages/1a/9c/c531f2293b91265d8b48e9b329f54fdd7ffae73cb4134ea10cca4237e9cc/numpy-2.4.6-pp311-pypy311_pp73-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5dbbdb29840ca3d91ee0fece42fc29278886d908280bfec0a5846c6f901a3eb0", size = 15798374, upload-time = "2026-05-18T23:37:02.674Z" }, + { url = "https://files.pythonhosted.org/packages/1a/b0/413077f6b1153ed3cba361401c6783bbad6114804a000cc22eb71c13e190/numpy-2.4.6-pp311-pypy311_pp73-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8ad03c0965fb3c692200e74d458ca28c1dbb4ce96f9a479a8aa041ad5fabca02", size = 16747286, upload-time = "2026-05-18T23:37:06.327Z" }, + { url = "https://files.pythonhosted.org/packages/15/ce/e5ec180bc41812edcd8daeb8639d205622c0e8c02259d8ab25a0201b3c2a/numpy-2.4.6-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:2803abfebfc990042cd494d8ce2d5f82e9d847af6d35ec486923aa19dbad5e73", size = 12504263, upload-time = "2026-05-18T23:37:09.715Z" }, +] + +[[package]] +name = "numpy" +version = "2.5.3" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version >= '3.12'", +] +sdist = { url = "https://files.pythonhosted.org/packages/13/01/11703282db468b85f6f7b8c7f22d058de5970d5c7e60a3a8aaa313c3de36/numpy-2.5.3.tar.gz", hash = "sha256:df2d5874ff183595a4ba404edd04f6bd9b5505c1d7708573f6a6c17489a67563", size = 20791231, upload-time = "2026-09-06T16:27:47.073Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d6/50/8fdbb16af64895706a45f06a4068e29db732ec180f3c1375f14123359138/numpy-2.5.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:cb189f09db39283b26bfd061ec16189e14f71c6755207f72a0f7540867afe5b9", size = 16994982, upload-time = "2026-09-06T16:24:29.244Z" }, + { url = "https://files.pythonhosted.org/packages/60/39/789131c1188c078dcb3a1692e72e1e050c68b88ffe72c9ccaac9bcd7a9cd/numpy-2.5.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:f59a878c33d6b88122d80d239bb3b845d58708750b0cb06a09aebb9b18ec696c", size = 12009327, upload-time = "2026-09-06T16:24:32.491Z" }, + { url = "https://files.pythonhosted.org/packages/9c/59/a312e95696e5f601914dd8b6dd844692ba61670807417e24b68e337b5c70/numpy-2.5.3-cp312-cp312-macosx_14_0_arm64.whl", hash = "sha256:a72f874bc9e10e4b8f80426fb49716d5141f64442a0c8418065093ec8017fbb0", size = 5445405, upload-time = "2026-09-06T16:24:35.071Z" }, + { url = "https://files.pythonhosted.org/packages/30/d0/5623a1707ed4fe16e3909fe3cf5ee3da004ae677ad23d83bbf3adf1a6faf/numpy-2.5.3-cp312-cp312-macosx_14_0_x86_64.whl", hash = "sha256:fc36dc566135b5eceec4cf89758fcb719266a019ef07dae1754ae7c9f617ef3e", size = 6783213, upload-time = "2026-09-06T16:24:37.253Z" }, + { url = "https://files.pythonhosted.org/packages/f1/32/84146fc020ad3c25f805f70ab60da46fe3c540a21369754a7e4369754b6f/numpy-2.5.3-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:76c2c1e6bfa5c84adc6434dfbf013aa92096a7985221762c8f11fedfd20fff58", size = 15687872, upload-time = "2026-09-06T16:24:39.751Z" }, + { url = "https://files.pythonhosted.org/packages/65/af/aa78d1a88805456e212b65461354cd943197fb9acecc4c90fd12295123a3/numpy-2.5.3-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b7e18c623bb5c95acb3b3328861272816ba199fb531921c5d6d0b675f1fde9e3", size = 16717410, upload-time = "2026-09-06T16:24:42.745Z" }, + { url = "https://files.pythonhosted.org/packages/3b/24/faa79d865e69a97ba17473b23a1b74094b2259c03e820c70297293b9ea49/numpy-2.5.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:4f8929ee6c96bfbd7b4ed2032e0c03af86fe1826740ab61ddabf9072d06e57ff", size = 17040975, upload-time = "2026-09-06T16:24:45.961Z" }, + { url = "https://files.pythonhosted.org/packages/62/4a/8877e629445a7176297dffcaf9c485faa96a95d81728a62521ad55bd4c0f/numpy-2.5.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:b5d93cf48f687479941d12b69c873ad2cc76bbd487f0091c2200636497f34034", size = 18476479, upload-time = "2026-09-06T16:24:49.35Z" }, + { url = "https://files.pythonhosted.org/packages/c8/db/35e1c2d38b04cbd5b731f9d71495e055e813197669d22b612f11748d2ff9/numpy-2.5.3-cp312-cp312-win32.whl", hash = "sha256:bf63afbe037eb5d2fe87fbcc7778e61da53ebaf21d938a4515aa73b62532a5d4", size = 6133378, upload-time = "2026-09-06T16:24:51.915Z" }, + { url = "https://files.pythonhosted.org/packages/3c/a1/accf6d4f0c80c5d9ba9735d6b1550e444180599f34dec69ca01360f717ad/numpy-2.5.3-cp312-cp312-win_amd64.whl", hash = "sha256:0a59a421a32580a009e8a1751345bf829631b990dc1794b80514ab722b435def", size = 12567828, upload-time = "2026-09-06T16:24:54.255Z" }, + { url = "https://files.pythonhosted.org/packages/22/43/1764aff32e4652526ae2f71fa8b3efd8d25c8a3d6926914454e47138ed1e/numpy-2.5.3-cp312-cp312-win_arm64.whl", hash = "sha256:ccb32e0525d29e8b0572eb84c9a57af0e7a4e615726927506f55063c62414034", size = 10485432, upload-time = "2026-09-06T16:24:57.278Z" }, + { url = "https://files.pythonhosted.org/packages/79/e5/8fb89cd46d14e35699d13bf943a5f5f441ecee8667120a1f6105ab89e349/numpy-2.5.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:66a78fe4556c60aceda5916f9eacd638b18e9e681016ec302dcb4682d6d4d034", size = 16991061, upload-time = "2026-09-06T16:25:00.411Z" }, + { url = "https://files.pythonhosted.org/packages/2f/06/9dc9e48b5e5e941c8b10350c5ff2d721da42a20517d911d15544246775ff/numpy-2.5.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:92f30e89b8ee0ecf363033576c422b2f58fed6a80bed0aa48dff6d14c654663e", size = 12003676, upload-time = "2026-09-06T16:25:03.475Z" }, + { url = "https://files.pythonhosted.org/packages/ab/2a/98282aa5b8f58b1157d440bb6282eed47e3632a5de53a714fbab17e659fe/numpy-2.5.3-cp313-cp313-macosx_14_0_arm64.whl", hash = "sha256:f9a2353b37a1a9e78fd82b27ad7e2a32a2d036604d18f02b05e3136c62ca3b09", size = 5439695, upload-time = "2026-09-06T16:25:05.978Z" }, + { url = "https://files.pythonhosted.org/packages/a1/f9/b6533d777be9d6ffd29dc1be0867e563e6e8cc9a220ff1b716adc317f060/numpy-2.5.3-cp313-cp313-macosx_14_0_x86_64.whl", hash = "sha256:ccbc4665079665c3cf3bab4db9f6b095370cd6437d66be549b6c2a1fd19e1958", size = 6779395, upload-time = "2026-09-06T16:25:08.599Z" }, + { url = "https://files.pythonhosted.org/packages/73/85/735720d04ec197c5dcfacdfc9922667c7f1f5f496a279b7ba4d7c74c4cc7/numpy-2.5.3-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c76d5dde9f445058f83d0c02af00557a4db91de9a9a57c0df87d1535001d654b", size = 15681750, upload-time = "2026-09-06T16:25:11.173Z" }, + { url = "https://files.pythonhosted.org/packages/3a/1b/3b16a9bc514a440a7a0883684111dcb1ef1aee960af2ca95da8fc775f124/numpy-2.5.3-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a5fa86b80fd24bcd1aff83ad23be44ea323de3f787be8f8b15d4a65621e25321", size = 16708577, upload-time = "2026-09-06T16:25:14.171Z" }, + { url = "https://files.pythonhosted.org/packages/69/c4/386f397831b07328b639c96c5b62719346cf4baf07c68d927239752b1534/numpy-2.5.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:bd4cb9ad3c7889b9b3fe0a9a9fb5d2ed26f9879bff2608d9f01aed147a20d231", size = 17042047, upload-time = "2026-09-06T16:25:17.582Z" }, + { url = "https://files.pythonhosted.org/packages/5f/3e/a700ecbf36e85ae8328fd3b0e12eeddc22ed6358a64cb2bd913e0d195d65/numpy-2.5.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:1302b90c0e52281681b2975adfe8a860cb7b12216a27b4b0b4207c44bf7bccf0", size = 18465724, upload-time = "2026-09-06T16:25:20.949Z" }, + { url = "https://files.pythonhosted.org/packages/41/ee/38e785e88a4045f6ad1d1f2808dcdfafdca48c760260c0587bf171e29fc9/numpy-2.5.3-cp313-cp313-win32.whl", hash = "sha256:1c80eabb4035ecf4ca9cd49cde8a9fdd69a729e63e6474887d1523ade7aa277f", size = 6129003, upload-time = "2026-09-06T16:25:23.664Z" }, + { url = "https://files.pythonhosted.org/packages/f3/ec/100f2b1794ede74a9b3d7ec6b9736927f56713414c1dfe19ab6c383494bf/numpy-2.5.3-cp313-cp313-win_amd64.whl", hash = "sha256:71cad2b2a7451ab79d8f5e71b453485b6775963d5cf794179144a7463fe6e8ec", size = 12560965, upload-time = "2026-09-06T16:25:26.602Z" }, + { url = "https://files.pythonhosted.org/packages/80/b1/7dc825ca94c12acebbce4c37caa5e198695eb31424bc579679f32b1bb49d/numpy-2.5.3-cp313-cp313-win_arm64.whl", hash = "sha256:8e4dd766076855b5ff7ea52fa5f07ce26286726e0f8bff446b7739d02e6ea204", size = 10482343, upload-time = "2026-09-06T16:25:29.772Z" }, + { url = "https://files.pythonhosted.org/packages/70/78/cf416f15dc29375a229d9dfebf8db6e313f291580b39fa1a568b6052bb07/numpy-2.5.3-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:350ba9783ce969cf9f7ce6e6a9a58e1a6e2a19ca025b7ee448c4db727706212a", size = 16998686, upload-time = "2026-09-06T16:25:33.171Z" }, + { url = "https://files.pythonhosted.org/packages/9e/59/abcc2d8def4fd60eec7d87f92d27c13448ffd9ab14339bcc63a0d7a2fdea/numpy-2.5.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:012e66aca395d795496446e52aeeb5866312a5d4d3f27da270e5a0b43f70dc5c", size = 12013862, upload-time = "2026-09-06T16:25:36.748Z" }, + { url = "https://files.pythonhosted.org/packages/94/75/4640d2d6e4b64a049e48425a82728a41ef4adb61332d2cba68055774878b/numpy-2.5.3-cp314-cp314-macosx_14_0_arm64.whl", hash = "sha256:adc1ada2662f8a5f960b8a10d9986897e7499ef07e06d4cfe7197f8cce923c07", size = 5449793, upload-time = "2026-09-06T16:25:39.476Z" }, + { url = "https://files.pythonhosted.org/packages/96/cd/625b57ae33d4ca560f32cc0b47b4a5922146d9beb998ddf773900d440a73/numpy-2.5.3-cp314-cp314-macosx_14_0_x86_64.whl", hash = "sha256:54a115e5a73b8fc44f0cebef486365a1894b5c9760685d4558b72b7c3eb846e0", size = 6785176, upload-time = "2026-09-06T16:25:42.069Z" }, + { url = "https://files.pythonhosted.org/packages/9c/72/12918652e7912ef9751e8694c88820fcd1908e0618cb23f5f3caa6004b7b/numpy-2.5.3-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:be5a8381859b6da607c84f4f7d6847725f1cf1853ef8a2c9e115b7d58bef47dc", size = 15703377, upload-time = "2026-09-06T16:25:45.135Z" }, + { url = "https://files.pythonhosted.org/packages/45/8f/9beacf79ca7c650688ad0baa80931adb988fe6e6e5d5903c23cc3dbd70eb/numpy-2.5.3-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b0521d0f4aebb6e06189451025fa17a913287b13c03d5fe05c017333b654ea5b", size = 16711928, upload-time = "2026-09-06T16:25:48.461Z" }, + { url = "https://files.pythonhosted.org/packages/09/8d/41d0a56e1ac4c87495c897a211b1368691b7237aadabec8b3b8f3a74d48f/numpy-2.5.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:9deb49575e5b0b94ed72c8a64ec4d033381adc27e9060ae842971f697ba96104", size = 17059507, upload-time = "2026-09-06T16:25:51.873Z" }, + { url = "https://files.pythonhosted.org/packages/08/1e/0dfbc5cc251d54e2af790f254d24ec38637fa97ec7d5d11de7ffed787098/numpy-2.5.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:b00eefbcf0f292945c4b4dec2ae845389ef5bcdcd596e6e4328051db5b5ba694", size = 18471002, upload-time = "2026-09-06T16:25:55.233Z" }, + { url = "https://files.pythonhosted.org/packages/b5/2c/dfa40f6991f8185c8c30ffd023dfcbb11888e823cfab9557b920f3bb7bed/numpy-2.5.3-cp314-cp314-win32.whl", hash = "sha256:c2381f82999704f818e2c987a865050e285ec3621262c66d40f5a96c8f899f8e", size = 6180485, upload-time = "2026-09-06T16:25:58.157Z" }, + { url = "https://files.pythonhosted.org/packages/a4/73/d2c08231e4fde7e415501fd02c715d96e98599b2d8384445933944152984/numpy-2.5.3-cp314-cp314-win_amd64.whl", hash = "sha256:2c25dfa72943e4336ddb6b0ee4277b47a0c85bede0807530ec68103bf58e2c10", size = 12698179, upload-time = "2026-09-06T16:26:00.789Z" }, + { url = "https://files.pythonhosted.org/packages/5c/e9/dcdcc9b95cf5f49815055573aee1b11cfbf5299f38a180e437ded050810f/numpy-2.5.3-cp314-cp314-win_arm64.whl", hash = "sha256:15aa985ac73a8db02db7663381aa109510449d3819d37206caed27b33a65a8a6", size = 10769383, upload-time = "2026-09-06T16:26:04.011Z" }, + { url = "https://files.pythonhosted.org/packages/49/c4/af8bc08a7ef4e1529a7c0cf24969accce316b783999802089a581ec99272/numpy-2.5.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:ac7bb1c52d445bd4f8f7f97fefe6abc3a084dc4d63df50d79b17fa2b78e89297", size = 12132668, upload-time = "2026-09-06T16:26:07.138Z" }, + { url = "https://files.pythonhosted.org/packages/c5/ae/0f15eb56d4ec5e13c1f7ff04ff407f997d1acbadb45d3e1f2e2645a8f43c/numpy-2.5.3-cp314-cp314t-macosx_14_0_arm64.whl", hash = "sha256:e6ab667ba76450084eb64013762c438ea76d9d29cc676dcd6c2e9892ba37f841", size = 5568580, upload-time = "2026-09-06T16:26:09.828Z" }, + { url = "https://files.pythonhosted.org/packages/23/fb/c72a8f25d4b6e96c354e7ab45ace3b27dc11e5d6a13b6c7d0cd6b08bf112/numpy-2.5.3-cp314-cp314t-macosx_14_0_x86_64.whl", hash = "sha256:f7fabeb6cea87d65f3b926de33d03fb016cfdc29314c90974383b5582ae72891", size = 6882634, upload-time = "2026-09-06T16:26:12.524Z" }, + { url = "https://files.pythonhosted.org/packages/07/a9/968c90ed2ab15060c338e8137f1215b5a60756ae07328e0a60d1c6734df4/numpy-2.5.3-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1fb6f8fb9ff0b3a69f52c66ce397b0246583e9f28616231b0e32ca49259a5fa6", size = 15748923, upload-time = "2026-09-06T16:26:15.092Z" }, + { url = "https://files.pythonhosted.org/packages/59/08/9df04103947b95e3b6b1f2ed1a70521f325647a31b82da6a2aae3a485508/numpy-2.5.3-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:93e1f5447e2b1e479d7bd74701e84746b86450cff1fc368b132d195e2b8f8211", size = 16746748, upload-time = "2026-09-06T16:26:18.43Z" }, + { url = "https://files.pythonhosted.org/packages/41/a0/14c8d5fe5b53a334aabb653deb391c0fef49558f491880ea300ed6785224/numpy-2.5.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:c00abe94c1a69d75d827dcf1c025b25c8a45d230b3bcd77a9020883a1b047653", size = 17111561, upload-time = "2026-09-06T16:26:22.113Z" }, + { url = "https://files.pythonhosted.org/packages/c4/a6/d7e96e42f01522e154c32489640f16dfc4f6181d165d05fc3bec8c2c4999/numpy-2.5.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:536f963710a4e63934d80ac0dc4f478804a83e9a84b6828018f25d09953ada33", size = 18513945, upload-time = "2026-09-06T16:26:25.401Z" }, + { url = "https://files.pythonhosted.org/packages/25/39/3453afb7119d0449ef11c886874120ff180e2c337760e0e2d88f70f1a945/numpy-2.5.3-cp314-cp314t-win32.whl", hash = "sha256:4c8a6d2ebce6305fd82fbefca827775437147052a976ee7c94b36a0c1b52ac6c", size = 6335421, upload-time = "2026-09-06T16:26:28.175Z" }, + { url = "https://files.pythonhosted.org/packages/99/01/22815d2b19a1a746b1d45205cffebb3fe511a18acb75fba6c88491fc9894/numpy-2.5.3-cp314-cp314t-win_amd64.whl", hash = "sha256:9a37475425b431b4d060f23b4f52cd2f3aef6bc7c654bd760adf0040eec9d435", size = 12896420, upload-time = "2026-09-06T16:26:31.265Z" }, + { url = "https://files.pythonhosted.org/packages/fa/ee/a7cbba67eeaff038dc29ca8b98a88396c8b0cc9c89d4924f4a27a5c9150b/numpy-2.5.3-cp314-cp314t-win_arm64.whl", hash = "sha256:2d8240cb4c16fd831074aa2b2cf9fc54664d826341d61c372245b96a74a49a9a", size = 10857177, upload-time = "2026-09-06T16:26:34.167Z" }, + { url = "https://files.pythonhosted.org/packages/45/56/78194492883ff5eec90423fe56a3a44b154da047d88a6307f629713c584f/numpy-2.5.3-cp315-cp315-macosx_10_15_x86_64.whl", hash = "sha256:a6391fafaba97500887132cd582abc6e19452b1ac775a47caa7b24490e152058", size = 16996531, upload-time = "2026-09-06T16:26:37.287Z" }, + { url = "https://files.pythonhosted.org/packages/11/39/dd55c0af90bbab564b09ae3b0aa60ec5c02b900fa4f1ba23440525c8b32d/numpy-2.5.3-cp315-cp315-macosx_11_0_arm64.whl", hash = "sha256:09d5a423c71ad5feb5625844ad58050e35df43871004b52ac9c0ad44a56775be", size = 12012569, upload-time = "2026-09-06T16:26:40.707Z" }, + { url = "https://files.pythonhosted.org/packages/b6/51/04f67d32e4862b281b1cb84ceeaed3421189a84fb6fb51a391cd6d5009f7/numpy-2.5.3-cp315-cp315-macosx_14_0_arm64.whl", hash = "sha256:f9579f383d1bf9df80081e72760e84960a7fd4f88cf0c9e535a8597c9bb646f5", size = 5448498, upload-time = "2026-09-06T16:26:43.435Z" }, + { url = "https://files.pythonhosted.org/packages/a3/c9/25b4dc0dd1344ec26c7319e84fd4e9809d2b5628f4e12decd618036e5178/numpy-2.5.3-cp315-cp315-macosx_14_0_x86_64.whl", hash = "sha256:86bff898a431c0fb71f7610b75726e75a54d47b37edc9d537f48de63bb3c0b90", size = 6783026, upload-time = "2026-09-06T16:26:46.374Z" }, + { url = "https://files.pythonhosted.org/packages/fc/c7/29285be1e5232a6e7ee3268a33c85843f5a8ee93350c6465cddd66ebbf76/numpy-2.5.3-cp315-cp315-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1f3ed25271581281f2fccb1adcedfcde4c07362eec69189b50baf6f90e3ae159", size = 15697322, upload-time = "2026-09-06T16:26:49.415Z" }, + { url = "https://files.pythonhosted.org/packages/55/49/bbad5335fb4996a16881f853ff3e0ba582f01720e55c89b1c06b8fc42a90/numpy-2.5.3-cp315-cp315-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ffdc76bfcae6b255dff75202c5e7feaf95b40246bc0a17944facc1fecf9f79ab", size = 16708995, upload-time = "2026-09-06T16:26:53.127Z" }, + { url = "https://files.pythonhosted.org/packages/ef/e9/1df35483760b04a65ea44669f89dc64f30e5aca098b48ceb8b1310b0e0fe/numpy-2.5.3-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:116f96cadd935c6122e9228d676fe7ede19e741f5c8bb1c3cddbe0c51ccebea2", size = 17052508, upload-time = "2026-09-06T16:26:56.464Z" }, + { url = "https://files.pythonhosted.org/packages/b8/99/66e54da8265cc8be8a7382bf96edce17aaa2837d6f484432025932a3caa5/numpy-2.5.3-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:09ffa5d903faeaa5c4dd05009cf81c8bab9f2cb37c548b8d39b65b4cfa7c97f7", size = 18468224, upload-time = "2026-09-06T16:26:59.966Z" }, + { url = "https://files.pythonhosted.org/packages/01/bc/b5e90a91c115168d793dfd2ad9c69c438c2fe7a13a437e770bc5b078e732/numpy-2.5.3-cp315-cp315-win32.whl", hash = "sha256:e01c918ac3d48e18a927cf7b14a26a3e29ff2bdf2eacb976da0aecd6a43ed034", size = 6179919, upload-time = "2026-09-06T16:27:03.166Z" }, + { url = "https://files.pythonhosted.org/packages/37/ea/780748fd3985109075514ef8fc64cd25f943e40dde13a6d59141eb268fc8/numpy-2.5.3-cp315-cp315-win_amd64.whl", hash = "sha256:e931e4f499e0dc7ef29d269a8e5b35dd722e5d14be07df6240166ea7c6532fae", size = 12697656, upload-time = "2026-09-06T16:27:06.153Z" }, + { url = "https://files.pythonhosted.org/packages/b3/16/407be69a2a87c8cab64d95975a8977a426a29e138f07e276ec258f0fe4e5/numpy-2.5.3-cp315-cp315-win_arm64.whl", hash = "sha256:26e15e4aecd8617dfbaecb37d223e365d7b39411fba20454be2670a96aa74cb5", size = 10767601, upload-time = "2026-09-06T16:27:09.297Z" }, + { url = "https://files.pythonhosted.org/packages/44/bf/a97ffb01e41d50a32a9177aef942a4d0e389a3daf451d04e5f38ef6afb87/numpy-2.5.3-cp315-cp315t-macosx_10_15_x86_64.whl", hash = "sha256:6cef4bb1706dfec49243c05d921eefb4e190d41e2528b30d8035ea1f36b4c24a", size = 17090092, upload-time = "2026-09-06T16:27:12.907Z" }, + { url = "https://files.pythonhosted.org/packages/d1/24/136c02f2c2af9a067a84d0c3aa10c99012c0476fa5066732fa4a4202557d/numpy-2.5.3-cp315-cp315t-macosx_11_0_arm64.whl", hash = "sha256:d1c89973648c85069c5046ad460f7b8a00218b29a2e42359ac8cc63e9ab94832", size = 12129429, upload-time = "2026-09-06T16:27:16.089Z" }, + { url = "https://files.pythonhosted.org/packages/fe/6c/b47582d6597789bf946d5efbeb6b9e56fd8bcbd5efc6fbf51dbe1ea31eb3/numpy-2.5.3-cp315-cp315t-macosx_14_0_arm64.whl", hash = "sha256:214045a5bf00113a146ab9ee9730c44501af6723cdf1f6830932f7b5ef2e7af0", size = 5565452, upload-time = "2026-09-06T16:27:19.868Z" }, + { url = "https://files.pythonhosted.org/packages/be/b4/ef3cc6da73774202d4deae16bb321fd8298a4e0561e3539f8c4be237d916/numpy-2.5.3-cp315-cp315t-macosx_14_0_x86_64.whl", hash = "sha256:8617bbfae4486cf99c9f899966699428d19da931d06ca94ad3da986c76e15997", size = 6876736, upload-time = "2026-09-06T16:27:22.232Z" }, + { url = "https://files.pythonhosted.org/packages/9e/24/e3813329498596cb842703dcacac1741612ed9fb9c4e6a3e0c7e2ebbc597/numpy-2.5.3-cp315-cp315t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:595d020938c84e320bcf40ad71089e108eac0d377cd018e14a8c094f39e98d85", size = 15745777, upload-time = "2026-09-06T16:27:25.181Z" }, + { url = "https://files.pythonhosted.org/packages/4a/9e/4e7a07fd0776dc2210cdacf2010be8665194d094defc10c419d7dea794cc/numpy-2.5.3-cp315-cp315t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6f24021b9f22bc6301c37b196974a92c1c18dccedb6fef3dd252e95f2d6adbe4", size = 16746949, upload-time = "2026-09-06T16:27:28.576Z" }, + { url = "https://files.pythonhosted.org/packages/91/db/01674c0e20335057813a00c2ebd546ed25bff9ed7914f9bced00f8c55d94/numpy-2.5.3-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:71b39d9f935b6ec0f8753e3e2afb51e3efba6f2e05b68b32a40754d24bcd4a3c", size = 17108994, upload-time = "2026-09-06T16:27:31.946Z" }, + { url = "https://files.pythonhosted.org/packages/45/7a/584c5e71f8d378e57cac0b033891ed65c683ef90573ba4854e8c28203db0/numpy-2.5.3-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:6b05c171afb3aa07adbd20abc00aea86fe375beb0fdb9ef780ec5b7f63bab1c0", size = 18512266, upload-time = "2026-09-06T16:27:35.196Z" }, + { url = "https://files.pythonhosted.org/packages/a1/d2/4e1014173aa3c55e6a756e0e567290743a6ab33a288460374d7ef6bcd239/numpy-2.5.3-cp315-cp315t-win32.whl", hash = "sha256:f54660b0eb6b0b9f36e7fe1cdfdff472028dd0d14acd9b9b65098efbad059469", size = 6330292, upload-time = "2026-09-06T16:27:38.149Z" }, + { url = "https://files.pythonhosted.org/packages/6c/b0/ff5658a58199b7bcaad87bf260eef6713d9d42cca4e028f935b4fc5fbac6/numpy-2.5.3-cp315-cp315t-win_amd64.whl", hash = "sha256:1aad64d99730d013cfc6debafed22783b4fc5a7f4b8bc744d2d8cf7dcc880551", size = 12884918, upload-time = "2026-09-06T16:27:40.965Z" }, + { url = "https://files.pythonhosted.org/packages/fb/0b/b12a2df5d1b774bd9007a6fdff9381145b6223d37f11afc9c37ab0efd9a1/numpy-2.5.3-cp315-cp315t-win_arm64.whl", hash = "sha256:befa1ae5bd6030b3f512b43ff3fa5290bbed6b84411a44244b14adf835f5b89d", size = 10850807, upload-time = "2026-09-06T16:27:43.868Z" }, +] + +[[package]] +name = "packaging" +version = "26.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/7d/fa/3944b40b07da9ce895c0e6303a5ab7d53da063554f534556b134a54d6093/packaging-26.3.tar.gz", hash = "sha256:94edc256424af38762eb31306eed28beb9f0efc50a8837492c9d6fd6004aed79", size = 313412, upload-time = "2026-08-04T18:15:28.737Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/63/34/ba1c580383c9eada3711951fef0795c80b829a078d72188184bcab9dd527/packaging-26.3-py3-none-any.whl", hash = "sha256:d7193f7c8e4e93f444fde0262bf90af30e16fa0ad0ad44cb553c87339b23cd1c", size = 129956, upload-time = "2026-08-04T18:15:27.159Z" }, +] + +[[package]] +name = "pluggy" +version = "1.6.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f9/e2/3e91f31a7d2b083fe6ef3fa267035b518369d9511ffab804f839851d2779/pluggy-1.6.0.tar.gz", hash = "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3", size = 69412, upload-time = "2025-05-15T12:30:07.975Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" }, +] + +[[package]] +name = "producer" +version = "0.3.0" +source = { editable = "." } +dependencies = [ + { name = "numpy", version = "2.4.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.12'" }, + { name = "numpy", version = "2.5.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.12'" }, + { name = "rich" }, + { name = "soundfile" }, +] + +[package.optional-dependencies] +bundled-ffmpeg = [ + { name = "imageio-ffmpeg" }, +] +test = [ + { name = "pytest" }, +] + +[package.metadata] +requires-dist = [ + { name = "imageio-ffmpeg", marker = "extra == 'bundled-ffmpeg'", specifier = "==0.6.0" }, + { name = "numpy", specifier = ">=1.26.4,<3" }, + { name = "pytest", marker = "extra == 'test'", specifier = ">=8,<10" }, + { name = "rich", specifier = "==14.1.0" }, + { name = "soundfile", specifier = "==0.13.1" }, +] +provides-extras = ["test", "bundled-ffmpeg"] + +[[package]] +name = "pycparser" +version = "3.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/1b/7d/92392ff7815c21062bea51aa7b87d45576f649f16458d78b7cf94b9ab2e6/pycparser-3.0.tar.gz", hash = "sha256:600f49d217304a5902ac3c37e1281c9fe94e4d0489de643a9504c5cdfdfc6b29", size = 103492, upload-time = "2026-01-21T14:26:51.89Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0c/c3/44f3fbbfa403ea2a7c779186dc20772604442dde72947e7d01069cbe98e3/pycparser-3.0-py3-none-any.whl", hash = "sha256:b727414169a36b7d524c1c3e31839a521725078d7b2ff038656844266160a992", size = 48172, upload-time = "2026-01-21T14:26:50.693Z" }, +] + +[[package]] +name = "pygments" +version = "2.21.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/49/2e/ced460408999b33da6b31b0021b0f37d329e202d4169aeb164493778f25b/pygments-2.21.0.tar.gz", hash = "sha256:610ca751c9bc2492b38eb9a38a7fbc93edbbb2d7182edaf34e66ae493dee5c8c", size = 5005329, upload-time = "2026-08-17T08:02:48.824Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/71/46/17f022dd3e953bf20a04a028a21ec746d942f8d2af30fa0f124fa0e6a684/pygments-2.21.0-py3-none-any.whl", hash = "sha256:2363c69b61c4a97c838da3b130dcd6468f4848992b21a82f2a63ec34377137d9", size = 1250147, upload-time = "2026-08-17T08:02:44.912Z" }, +] + +[[package]] +name = "pytest" +version = "9.1.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, + { name = "iniconfig" }, + { name = "packaging" }, + { name = "pluggy" }, + { name = "pygments" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/e4/47/b9efed96c114afcfa3c9d3fe98a76a1d14c74a9e266d397cf6eb64be5e01/pytest-9.1.1.tar.gz", hash = "sha256:1088fbde8f2b49d95a549a195707afa7a76a3ce9bcadc26b6d71f0ffda5fe313", size = 1636369, upload-time = "2026-06-19T10:58:32.857Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/24/25/1de2678b631f5a49215c6c96fff41ba892b0a34df68d6d80292b1b48aa7f/pytest-9.1.1-py3-none-any.whl", hash = "sha256:37a86b45efb9a47a61a36449063e8e18d0cab3161329fc099eb21783169c4f0c", size = 386536, upload-time = "2026-06-19T10:58:31.347Z" }, +] + +[[package]] +name = "rich" +version = "14.1.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "markdown-it-py" }, + { name = "pygments" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/fe/75/af448d8e52bf1d8fa6a9d089ca6c07ff4453d86c65c145d0a300bb073b9b/rich-14.1.0.tar.gz", hash = "sha256:e497a48b844b0320d45007cdebfeaeed8db2a4f4bcf49f15e455cfc4af11eaa8", size = 224441, upload-time = "2025-07-25T07:32:58.125Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e3/30/3c4d035596d3cf444529e0b2953ad0466f6049528a879d27534700580395/rich-14.1.0-py3-none-any.whl", hash = "sha256:536f5f1785986d6dbdea3c75205c473f970777b4a0d6c6dd1b696aa05a3fa04f", size = 243368, upload-time = "2025-07-25T07:32:56.73Z" }, +] + +[[package]] +name = "soundfile" +version = "0.13.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "cffi" }, + { name = "numpy", version = "2.4.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.12'" }, + { name = "numpy", version = "2.5.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.12'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/e1/41/9b873a8c055582859b239be17902a85339bec6a30ad162f98c9b0288a2cc/soundfile-0.13.1.tar.gz", hash = "sha256:b2c68dab1e30297317080a5b43df57e302584c49e2942defdde0acccc53f0e5b", size = 46156, upload-time = "2025-01-25T09:17:04.831Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/64/28/e2a36573ccbcf3d57c00626a21fe51989380636e821b341d36ccca0c1c3a/soundfile-0.13.1-py2.py3-none-any.whl", hash = "sha256:a23c717560da2cf4c7b5ae1142514e0fd82d6bbd9dfc93a50423447142f2c445", size = 25751, upload-time = "2025-01-25T09:16:44.235Z" }, + { url = "https://files.pythonhosted.org/packages/ea/ab/73e97a5b3cc46bba7ff8650a1504348fa1863a6f9d57d7001c6b67c5f20e/soundfile-0.13.1-py2.py3-none-macosx_10_9_x86_64.whl", hash = "sha256:82dc664d19831933fe59adad199bf3945ad06d84bc111a5b4c0d3089a5b9ec33", size = 1142250, upload-time = "2025-01-25T09:16:47.583Z" }, + { url = "https://files.pythonhosted.org/packages/a0/e5/58fd1a8d7b26fc113af244f966ee3aecf03cb9293cb935daaddc1e455e18/soundfile-0.13.1-py2.py3-none-macosx_11_0_arm64.whl", hash = "sha256:743f12c12c4054921e15736c6be09ac26b3b3d603aef6fd69f9dde68748f2593", size = 1101406, upload-time = "2025-01-25T09:16:49.662Z" }, + { url = "https://files.pythonhosted.org/packages/58/ae/c0e4a53d77cf6e9a04179535766b3321b0b9ced5f70522e4caf9329f0046/soundfile-0.13.1-py2.py3-none-manylinux_2_28_aarch64.whl", hash = "sha256:9c9e855f5a4d06ce4213f31918653ab7de0c5a8d8107cd2427e44b42df547deb", size = 1235729, upload-time = "2025-01-25T09:16:53.018Z" }, + { url = "https://files.pythonhosted.org/packages/57/5e/70bdd9579b35003a489fc850b5047beeda26328053ebadc1fb60f320f7db/soundfile-0.13.1-py2.py3-none-manylinux_2_28_x86_64.whl", hash = "sha256:03267c4e493315294834a0870f31dbb3b28a95561b80b134f0bd3cf2d5f0e618", size = 1313646, upload-time = "2025-01-25T09:16:54.872Z" }, + { url = "https://files.pythonhosted.org/packages/fe/df/8c11dc4dfceda14e3003bb81a0d0edcaaf0796dd7b4f826ea3e532146bba/soundfile-0.13.1-py2.py3-none-win32.whl", hash = "sha256:c734564fab7c5ddf8e9be5bf70bab68042cd17e9c214c06e365e20d64f9a69d5", size = 899881, upload-time = "2025-01-25T09:16:56.663Z" }, + { url = "https://files.pythonhosted.org/packages/14/e9/6b761de83277f2f02ded7e7ea6f07828ec78e4b229b80e4ca55dd205b9dc/soundfile-0.13.1-py2.py3-none-win_amd64.whl", hash = "sha256:1e70a05a0626524a69e9f0f4dd2ec174b4e9567f4d8b6c11d38b5c289be36ee9", size = 1019162, upload-time = "2025-01-25T09:16:59.573Z" }, +] -- cgit v1.2.3