diff options
| author | historia <historiavg@proton.me> | 2026-09-12 15:25:06 -0400 |
|---|---|---|
| committer | historia <historiavg@proton.me> | 2026-09-12 15:25:06 -0400 |
| commit | 464edbc4fd658728f06d3a7d98340aa28ff3e3ec (patch) | |
| tree | a182f62ef0365f2c2143f88436de78a03d752367 | |
| parent | 1bbe59f78be9a8355bb0bee2bc8ab14ca42ac71c (diff) | |
| download | producer-464edbc4fd658728f06d3a7d98340aa28ff3e3ec.tar.gz | |
feat: slop web gui
25 files changed, 4241 insertions, 46 deletions
@@ -1,13 +1,15 @@ # Producer -Local speech cleanup and mastering for voice recordings. Noise removal uses `DeepFilterNet3`. Filtering, dynamics and loudness normalization are `FFmpeg`. +Local speech cleanup and mastering for voice recordings. Noise removal uses +ClearVoice or DeepFilterNet3; speech-aware pause silencing uses Silero VAD. +Filtering, dynamics and loudness normalization use FFmpeg. Audio stays local. `producer.sh` bootstraps its own managed Python environments and downloads dependencies and models automatically. ## Requirements -- glibc +- glibc >= 2.28 for AI denoising and speech detection - `uv` (or Python 3) - NVidia driver and toolkit (optional) @@ -15,7 +17,7 @@ dependencies and models automatically. ```bash git clone https://git.historia.vg/git/producer -./producer.sh setup +./producer.sh setup --denoiser clearvoice ./producer.sh recording.wav ``` @@ -47,12 +49,18 @@ LUFS with a -1.5 dBTP true-peak ceiling**. ./producer.sh recording.wav --denoiser fft ./producer.sh recording.wav --no-denoise -# Use the ClearVoice MossFormer2_SE_48K denoiser instead of DeepFilterNet3 -# (larger download; preserves word attacks noticeably better at high strength) +# Compare ClearVoice MossFormer2_SE_48K with DeepFilterNet3 +# (larger download; speech preservation depends on the recording) ./producer.sh recording.wav --denoiser clearvoice ./producer.sh setup --denoiser clearvoice --device cpu -# Shorten long gaps between takes (speech is never attenuated) +# Silence pauses without changing the timing (enabled in config.toml) +./producer.sh recording.wav --pause-silence --no-trim-silence + +# Bypass pause silencing for an A/B comparison +./producer.sh recording.wav --no-pause-silence --output-dir comparison + +# Optionally shorten long gaps between takes (a separate pacing edit) ./producer.sh recording.wav --trim-silence --max-gap-s 1.5 --gap-pad-s 0.6 # LibriVox-style delivery file: 44.1 kHz, 128 kbps CBR, -19 LUFS, mono @@ -63,6 +71,134 @@ LUFS with a -1.5 dBTP true-peak ceiling**. Edit default settings in `config.toml` +## Optional local browser GUI + +```bash +./producer.sh gui --root /path/to/recordings +# Optional alternate loopback port: +./producer.sh gui --root /path/to/recordings --port 8766 +``` + +The browser studio guides you through **Choose recording → Try a short passage +→ Compare sound → Export full recording**. Use a current desktop browser +(Chromium tested); JavaScript is required. No Node/npm build, CDN, telemetry, +or cloud audio service is used. The launcher installs the optional +FastAPI/uvicorn extra only when `gui` is requested. Existing CLI commands do +not need it. Direct package installs can use `producer[gui]` and need system +FFmpeg or the `bundled-ffmpeg` extra for processing. + +1. Browse folders beneath your chosen root and select a mono WAV. The overview + is a streaming scan reduced to 2,048 peak bins, not a full recording loaded + into the browser. You can enter times before scanning finishes. +2. Click or drag the waveform, or enter exact `HH:MM:SS.sss` start/end times + (seconds also work). Zoom enlarges the same coarse overview; exact times + are best for fine selection on multi-hour recordings. +3. Start with noise cleanup, quiet pauses, tone and even volume. Expand **All + settings & advanced controls** for every other setting and its units/help. + Disabled effects retain their knob values. Nothing renders per keystroke, + and no dependent effect is silently enabled. Click **Render sound comparison**. +4. Play/loop the passage and switch A/B without losing your place. Optional + matching attenuates the louder snippet for fair listening. File loudness + and peak readings are separate from monitoring gain, not quality scores + or full-export measurements. Changes pause playback and mark older renders; + late jobs never replace the visible comparison after you edit the selection. +5. Choose an existing output folder, review the frozen current settings and + confirm **Export full recording**. This processes the entire original even + if you selected only a few seconds. Completed local paths appear in the + export panel; find the files with your operating system's file manager. + +Open the printed `http://127.0.0.1:.../#token=...` URL on the same machine and +keep it private. The server binds only to loopback, rejects untrusted +Host/Origin headers, and requires the session credential for every API read +or mutation. Do not expose it through a proxy or port-forwarding service. +`--root` bounds browsing and output directories, including resolved symlinks; +its default is the current working directory. There are no recording uploads +or arbitrary host-file download endpoints. Stop the server with Ctrl-C. + +**Containers/remote shells:** loopback belongs to the machine/network namespace +running Producer. A browser on the host cannot normally reach a container's +`127.0.0.1`; publishing a container port does not change this loopback-only +binding. The simplest supported setup is to run `./producer.sh gui` directly +on the machine with your browser and recordings. This development container +has no GUI. Do not change the bind to `0.0.0.0`, disable the Host/Origin checks, +or expose the token through a proxy to work around access. No insecure +container-host access mode is provided. + +Settings use the same strict resolver and configuration precedence as the CLI. +The initial controls include your launch TOML and CLI overrides. **Restore +launch settings** resets edits. **Apply profile & launch defaults** resets +through that precedence too: explicit launch values still win. Changing only +the profile name keeps your other explicit knob values. TOML import instead +loads standalone settings (package defaults/profile plus the imported TOML). + +Save up to 20 named settings presets and 12 immutable comparison snapshots per +root in this browser. Snapshots retain the source identity, excerpt times and +requested settings, never audio. **Restore & render** explicitly recreates a +comparison; a changed source file must be re-selected. Download/import small +TOML presets to move settings between browsers. Local browser storage can be +cleared or disabled and is specific to the loopback address/port; use TOML for +backup. Session credentials are never saved in presets or comparison snapshots. + +Waveform scans and jobs run in the background, one at a time, with up to three +waiting. Activity shows per-stage progress, frozen snapshots and cancellation. +Cancellation is cooperative: a model download may take up to 60 seconds to +reach a safe callback, and files already published can remain. There is no +total recording-duration cap; typical 1–4 hour and longer recordings still need +sufficient processing time and temporary disk space. Inputs must be readable +mono WAVs; stereo needs an explicit downmix outside Producer first. + +First AI noise cleanup or pause silencing use downloads dependencies/models; +processing stays local afterwards. For a quick no-model trial, launch with +`./producer.sh gui --root /path/to/recordings --no-denoise --no-pause-silence`. +These explicit flags establish initial controls, not hidden permanent bypasses. +Reload retains the credential in the same tab and reconnects to recent jobs, +but does not automatically restore/play old audio. If Producer restarts, open +its newly printed private URL. If a connection drops during an export request, +check Activity before submitting another export; the first may have succeeded. + +Sound-comparison previews select up to 120 seconds anywhere in a recording, +process with up to 10 seconds of context on each side, then crop to the selected +interval. They are **approximate, not exact export previews**: chapter-level +normalization and inference context differ. Pacing edits are not previewed; +comparisons preserve timing even when export gap trimming is selected. +Normalization and other effects are never forced on for comparison. Optional +loudness matching is playback-only attenuation, not a change to export settings. + +Exports process the whole original with the requested settings into a new, +non-clobbering `producer-export-*` folder in the chosen output directory: +`master.wav`, optional `master.mp3`, and `logs/master.report.json`. The API +returns persistent local paths, **not browser blobs of multi-hour exports**. +The browser only fetches bounded preview snippets. TOML presets can be imported +as text or downloaded; no preset or source recording is rewritten by the API. +The server retains at most eight repeated-result cache entries and 128 MiB of +artifacts; expired previews must be rendered again. Job history is limited to +24 entries and is lost when the server stops, while completed output files +persist. Browser preview WAVs are bounded to about 11.6 MB per side (120 seconds +of mono PCM16 at 48 kHz). Full exports never become browser Blobs. These are +generic WAV/MP3 delivery files, not delivery-platform compliance certification. + +### GUI development checks (optional) + +The application itself needs no browser automation or Node dependencies. With +an external test environment containing `producer[gui,test]`, use: + +```bash +python -m pytest lib/project/tests -q +node --check lib/project/src/producer/web/app.js +node --test lib/project/tests/gui_state.test.mjs +# Optional real headless browser checks, no AI models: +python -m pip install playwright +python -m playwright install chromium +PRODUCER_BROWSER_TESTS=1 python -m pytest lib/project/tests/test_gui_browser*.py -q +``` + +Playwright may need OS-specific browser shared libraries in the test container; +these are not application runtime dependencies. Browser checks generate a +short test WAV, exercise real scan/preview/export and cancellation, and mock a +bounded four-hour overview. They do not claim a four-hour AI processing run. +Screenshots default to `/tmp/producer-browser-smoke`; set +`PRODUCER_BROWSER_ARTIFACTS` to keep them elsewhere. + ## Profiles | Profile | Sound | @@ -72,13 +208,51 @@ Edit default settings in `config.toml` | `radio` | Stronger compression, more warmth/presence, tighter range | | `cleanup-only` | Denoise + highpass only; no EQ, dynamics or normalization | -AI denoisers soften word attacks to some degree; `clearvoice` -(MossFormer2_SE_48K) was measured to leave onsets essentially untouched, -while `deepfilter` ducks them a couple of percent even at reduced strength. -Denoisers also never reach true digital silence: a noise floor remains in -blank sections. `--trim-silence` addresses both properly - long gaps are -shortened to `--gap-pad-s` of untouched room tone with a sample-exact cut, -so speech is never gated or attenuated. +## Speech starts and silent pauses + +Denoising and pause silencing are different jobs. Pushing a denoiser harder +can damage quiet consonants without eliminating all room tone. Compression +and normalization then raise the remaining noise. The shipped `config.toml` +uses ClearVoice at 0.85 strength, speech-aware pause silencing, and **no gap +trimming**. Bare package defaults leave pause silencing off (opt in with +`--pause-silence`). + +Pause silencing detects speech on the **pre-denoise** audio using pinned +Silero VAD v5.1 ONNX weights. First use downloads a separate CPU runtime and +~2.3 MB model; it needs no GPU or cloud service. After EQ/dynamics, gaps +outside protected speech fade to zero before loudness normalization. This +keeps the timeline unchanged unless you separately enable `--trim-silence`. +32-bit export uses no dither so silent cores can remain digital zero; +resampling/filter tails may extend slightly into their edges. MP3 need not +retain sample-exact silence near speech boundaries. + +Defaults protect 120 ms before and after detections, use 10 ms fades, and +require an additional 80 ms silent core. Shorter gaps are left alone rather +than risking chopped words. Controls: + +- `--pause-threshold 0.25`: lower = preserve more possible speech. +- `--pause-pre-ms 180`: protect more of quiet sentence starts. +- `--pause-post-ms 200`: keep longer word tails and decay. +- `--pause-min-ms 40`: allow shorter silent cores after margins and fades. +- `--no-pause-silence`: bypass this stage; no detector download. Combine with + `--no-denoise` to avoid all AI model/runtime downloads. + +The detector can miss whispers, breaths, or unusual speech. If it detects +**no speech**, cleanup fails open with a warning instead of muting the file. +Other detector failures stop processing, not silently switch to a level gate. +Reports in `logs/` include the attenuated intervals on the original, +pre-trim timeline for inspection. With VAD enabled, optional trimming only +shortens near-zero cores; without it, legacy `gap_threshold_db` trimming can +mistake quiet speech for silence. + +This stage does **not** restore consonants a denoiser has already removed. +Compare denoisers and try `--denoise-strength 0.65` if attacks remain soft; +a dry/wet blend trades some noise reduction for speech preservation. +A low, consistent room tone can sound more natural than dead silence. +Professional quality requires listening for artifacts, breaths, sibilance, +clipping and reverb, not just checking a meter. LUFS targets here are not +ACX RMS/noise-floor certification; no one-click chain guarantees every +recording meets a delivery specification. Test the difference on your own audio before committing: @@ -103,5 +277,6 @@ Test the difference on your own audio before committing: [0BSD](LICENSE). -Upstream components (DeepFilterNet, PyTorch, FFmpeg, uv) -carry their own licenses +Upstream components (ClearVoice, DeepFilterNet, Silero VAD, ONNX Runtime, +PyTorch, FFmpeg, uv) carry their own licenses. Silero VAD is MIT-licensed: +https://github.com/snakers4/silero-vad/tree/v5.1 diff --git a/config.toml b/config.toml index 75e0e3b..a6777e3 100644 --- a/config.toml +++ b/config.toml @@ -5,9 +5,9 @@ # with --profile radio. Remove settings you want a different profile to supply. profile = "narrator" # natural, narrator, radio, cleanup-only -denoiser = "clearvoice" # clearvoice (MossFormer2_SE_48K, best attack preservation), deepfilter; fft is explicit non-AI fallback; none +denoiser = "clearvoice" # MossFormer2_SE_48K; compare with deepfilter on your voice; fft or none also available device = "auto" # auto, cpu, cuda; explicit cuda fails if unavailable -denoise_strength = 1.0 # 0..1; AI dry/wet blend; zero skips denoising and AI setup +denoise_strength = 0.85 # 0..1; a little dry signal can preserve consonants, but also retains noise fft_reduction_db = 8.0 # 0.01..30; FFT reduction is scaled by denoise_strength highpass = true @@ -26,11 +26,21 @@ expansion_threshold_db = -50.0 # -90..-20 expansion_ratio = 1.5 # 1..4 expansion_range_db = 12.0 # 0..40 -# Static gap trimming: gaps longer than max_gap_s are shortened to gap_pad_s -# (total silence kept, split at the gap edges). Speech is copied through -# unattenuated - no gate, so word attacks are never touched. Use this instead -# of relying on the denoiser to erase room tone in blank sections. -trim_silence = true +# Silence pauses without shortening them. Silero VAD analyzes PRE-denoise +# speech; protected word margins and smooth fades reduce clipped consonants. +# First use installs a small CPU detector and downloads hash-checked weights. +# VAD can miss whispers/breaths; audition the result, not just the meter. +pause_silence = true +pause_threshold = 0.35 # 0.1..0.9; LOWER keeps more possible speech +pause_pre_ms = 120.0 # 30..500; keep this much before detected speech +pause_post_ms = 120.0 # 30..1000; preserve word endings and decay +pause_fade_ms = 10.0 # 2..100; fades outside protected speech +pause_min_ms = 80.0 # 20..1000; minimum zero core, in addition to margins/fades + +# Optional pacing EDIT, not noise removal. Long gaps are shortened to +# gap_pad_s. With pause_silence enabled, only near-zero cores are eligible; +# gap_threshold_db is used only when pause_silence is disabled. +trim_silence = false max_gap_s = 1.5 # 0.5..10; gaps longer than this are shortened gap_pad_s = 0.6 # 0.1..3; total silence kept around each trimmed gap (< max_gap_s) gap_threshold_db = -50.0 # -90..-20; blocks below this count as silence diff --git a/lib/project/pyproject.toml b/lib/project/pyproject.toml index 6c42cc9..029cc91 100644 --- a/lib/project/pyproject.toml +++ b/lib/project/pyproject.toml @@ -11,7 +11,9 @@ requires-python = ">=3.11" dependencies = ["rich==14.1.0", "numpy>=1.26.4,<3", "soundfile==0.13.1"] [project.optional-dependencies] -test = ["pytest>=8,<10"] +test = ["pytest>=8,<10", "httpx>=0.27,<1"] +# Installed only for the optional local browser GUI. +gui = ["fastapi>=0.115,<1", "uvicorn>=0.30,<1"] # Preferred by producer.sh; falls back to a system FFmpeg where the wheel # does not exist (for example musl systems). bundled-ffmpeg = ["imageio-ffmpeg==0.6.0"] diff --git a/lib/project/src/producer/cli.py b/lib/project/src/producer/cli.py index 8b34e7c..982e656 100644 --- a/lib/project/src/producer/cli.py +++ b/lib/project/src/producer/cli.py @@ -24,7 +24,7 @@ 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"): + for name in ("process", "preview", "config", "gui"): cmd = sub.add_parser(name) cmd.add_argument("--config", type=Path, help="TOML configuration file") cmd.add_argument("--profile", choices=PROFILES, default=None) @@ -41,6 +41,9 @@ def parser(): cmd.add_argument("--no-denoise", dest="denoiser", action="store_const", const="none") if name == "config": cmd.add_argument("--json", action="store_true") + elif name == "gui": + cmd.add_argument("--root", type=Path, default=Path.cwd(), help="Allowed local file root (default: current directory)") + cmd.add_argument("--port", type=int, default=8765, help="Loopback HTTP port (default: 8765)") else: cmd.add_argument("inputs", type=Path, nargs="+" if name == "process" else 1) if name in ("process", "preview"): @@ -61,7 +64,7 @@ def parser(): return app -def settings_for(args, profile=None): +def settings_overrides(args): 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: @@ -74,7 +77,11 @@ def settings_for(args, profile=None): except tomllib.TOMLDecodeError: parsed = value overrides[key.strip()] = parsed - return resolve(args.config, profile or args.profile, overrides) + return overrides + + +def settings_for(args, profile=None): + return resolve(args.config, profile or args.profile, settings_overrides(args)) def require_input(source: Path) -> None: @@ -84,7 +91,7 @@ def require_input(source: Path) -> None: # The 'process' keyword is optional: any first argument that is not a known # command (or a global option) is treated as the start of a process run. -COMMANDS = ("process", "preview", "config", "doctor", "setup") +COMMANDS = ("process", "preview", "config", "doctor", "setup", "gui") GLOBAL_OPTIONS = ("-h", "--help", "--version") @@ -113,6 +120,11 @@ def main(): console.print(f"AI ready: {python}", markup=False) return settings = settings_for(args) + if args.command == "gui": + from .gui import serve + serve(args.root, args.port, config=args.config, profile=args.profile, + overrides=settings_overrides(args)) + return if args.command == "config": print(json.dumps(asdict(settings), indent=2) if args.json else toml(settings), end="\n" if args.json else "") return diff --git a/lib/project/src/producer/config.py b/lib/project/src/producer/config.py index 347504a..34c907b 100644 --- a/lib/project/src/producer/config.py +++ b/lib/project/src/producer/config.py @@ -24,6 +24,12 @@ class Settings: expansion_threshold_db: float = -50.0 expansion_ratio: float = 1.5 expansion_range_db: float = 12.0 + pause_silence: bool = False + pause_threshold: float = 0.35 + pause_pre_ms: float = 120.0 + pause_post_ms: float = 120.0 + pause_fade_ms: float = 10.0 + pause_min_ms: float = 80.0 trim_silence: bool = False max_gap_s: float = 1.5 gap_pad_s: float = 0.6 @@ -68,6 +74,9 @@ RANGES = { "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), + "pause_threshold": (0.1, 0.9), "pause_pre_ms": (30, 500), + "pause_post_ms": (30, 1000), "pause_fade_ms": (2, 100), + "pause_min_ms": (20, 1000), "max_gap_s": (0.5, 10), "gap_pad_s": (0.1, 3), "gap_threshold_db": (-90, -20), "mud_db": (-12, 12), "presence_db": (-12, 12), "compressor_threshold_db": (-50, -5), "compressor_ratio": (1, 10), diff --git a/lib/project/src/producer/gui.py b/lib/project/src/producer/gui.py new file mode 100644 index 0000000..f1fa298 --- /dev/null +++ b/lib/project/src/producer/gui.py @@ -0,0 +1,264 @@ +"""Optional loopback-only web API. Importing the CLI never imports web dependencies.""" +from contextlib import asynccontextmanager +from dataclasses import asdict +import math +from pathlib import Path +import secrets +import socket +import tomllib + +from .config import resolve, toml +from .gui_jobs import Busy, Jobs, input_info +from .gui_schema import schema + +BODY_LIMIT = 65536 + + +class Boundary: + """Authenticate even reads, reject cross-origin/rebinding, and bound JSON bodies. + + The token arrives via URL fragment (never sent to HTTP logs), then a Bearer + header on every API request. No cookies, query credentials, or CORS bypass. + """ + def __init__(self, app, authority, token): + self.app, self.authority, self.token = app, authority, token + + async def __call__(self, scope, receive, send): + if scope["type"] != "http": + return await self.app(scope, receive, send) + from starlette.responses import JSONResponse + headers = scope.get("headers", []) + def values(name): + return [value.decode("latin1") for key, value in headers if key == name] + status, detail = None, None + if values(b"host") != [self.authority]: + status, detail = 403, "Untrusted Host" + elif values(b"origin") not in ([], [f"http://{self.authority}"]): + status, detail = 403, "Untrusted Origin" + elif any(value not in ("same-origin", "none") for value in values(b"sec-fetch-site")): + status, detail = 403, "Cross-site request denied" + elif scope["path"].startswith("/api/"): + auth = values(b"authorization") + if len(auth) != 1 or not secrets.compare_digest(auth[0].encode("latin1"), f"Bearer {self.token}".encode("ascii")): + status, detail = 401, "Open the local URL printed by Producer to authenticate" + async def secure_send(message): + if message["type"] == "http.response.start": + message.setdefault("headers", []).extend([ + (b"cache-control", b"no-store"), (b"referrer-policy", b"no-referrer"), + (b"x-content-type-options", b"nosniff"), (b"x-frame-options", b"DENY"), + (b"content-security-policy", b"default-src 'self'; script-src 'self'; style-src 'self'; img-src 'self' data:; media-src 'self' blob:; connect-src 'self'; object-src 'none'; base-uri 'none'; frame-ancestors 'none'; form-action 'none'"), + ]) + await send(message) + if status: + return await JSONResponse({"detail": detail}, status_code=status)(scope, receive, secure_send) + # Pre-read only the bounded API payload. This also handles chunked bodies. + if scope["path"].startswith("/api/"): + body = bytearray() + while True: + message = await receive() + if message["type"] == "http.disconnect": + return + body.extend(message.get("body", b"")) + if len(body) > BODY_LIMIT: + return await JSONResponse({"detail": "Request body exceeds 64 KiB"}, status_code=413)(scope, receive, secure_send) + if not message.get("more_body", False): + break + sent = False + original_receive = receive + async def bounded_receive(): + nonlocal sent + if not sent: + sent = True + return {"type": "http.request", "body": bytes(body), "more_body": False} + return await original_receive() + receive = bounded_receive + return await self.app(scope, receive, secure_send) + + +def rooted(root, value): + if not isinstance(value, str) or not value or "\x00" in value or Path(value).is_absolute(): + raise ValueError("Use a relative path inside the selected root") + path = (root / value).resolve(strict=True) + if not path.is_relative_to(root): + raise ValueError("Path escapes the selected root (including symlinks)") + return path + + +def object_body(value, allowed): + if not isinstance(value, dict): + raise ValueError("Expected a JSON object") + if value.keys() - allowed: + raise ValueError("Unknown request field(s): " + ", ".join(sorted(value.keys() - allowed))) + return value + + +def create_app(root, port, token, *, config=None, profile=None, overrides=None): + # These imports intentionally exist only behind the optional gui command. + from fastapi import FastAPI, Request + from fastapi.responses import FileResponse, JSONResponse, Response + from fastapi.staticfiles import StaticFiles + root = Path(root).resolve(strict=True) + if not root.is_dir(): + raise ValueError("GUI root must be an existing directory") + base = tomllib.loads(config.read_text()) if config else {} + if profile is not None: + base["profile"] = profile + base.update(overrides or {}) + initial = asdict(resolve(overrides=base)) + + def configured(values): + if not isinstance(values, dict): + raise ValueError("settings must be an object") + return resolve(overrides=base | values) + + def path_for(value): + return rooted(root, value) + + @asynccontextmanager + async def lifespan(app): + app.state.jobs = Jobs(root, path_for) + try: + yield + finally: + app.state.jobs.close() + + app = FastAPI(docs_url=None, redoc_url=None, openapi_url=None, lifespan=lifespan) + app.add_middleware(Boundary, authority=f"127.0.0.1:{port}", token=token) + + @app.exception_handler(ValueError) + async def invalid(request, error): + return JSONResponse({"detail": str(error)}, status_code=429 if isinstance(error, Busy) else 400) + + @app.exception_handler(OSError) + async def inaccessible(request, error): + return JSONResponse({"detail": str(error)}, status_code=400) + + @app.exception_handler(KeyError) + async def missing(request, error): + return JSONResponse({"detail": "Unknown or expired job/result, or missing required field"}, status_code=404) + + @app.get("/api/schema") + def settings_schema(): + return schema(initial) | {"root": str(root), "api_version": 1} + + @app.post("/api/resolve") + async def resolve_settings(request: Request): + body = object_body(await request.json(), {"settings"}) + return {"settings": asdict(configured(body.get("settings", {})))} + + @app.post("/api/presets/import") + async def import_preset(request: Request): + body = object_body(await request.json(), {"toml"}) + if not isinstance(body.get("toml"), str): + raise ValueError("toml must be text") + # Import is standalone: defaults/profile + TOML, not the launch config. + return {"settings": asdict(resolve(overrides=tomllib.loads(body["toml"])))} + + @app.post("/api/presets/download") + async def download_preset(request: Request): + body = object_body(await request.json(), {"settings"}) + return Response(toml(configured(body.get("settings", {}))), media_type="application/toml", + headers={"Content-Disposition": 'attachment; filename="producer-preset.toml"'}) + + @app.get("/api/files") + def files(path: str = ".", offset: int = 0, limit: int = 100): + if offset < 0 or not 1 <= limit <= 200: + raise ValueError("offset must be nonnegative and limit must be 1..200") + directory = path_for(path) + if not directory.is_dir(): + raise ValueError("Choose a directory") + entries, skipped, more = [], 0, False + # Deliberately unsorted: do not materialize arbitrarily large directories. + for entry in directory.iterdir(): + try: + resolved = path_for(str(entry.relative_to(root))) + is_dir = resolved.is_dir() + if not is_dir and not (resolved.is_file() and resolved.suffix.lower() == ".wav"): + continue + except (OSError, ValueError): + continue + if skipped < offset: + skipped += 1 + continue + if len(entries) == limit: + more = True + break + entries.append({"name": entry.name, "path": str(entry.relative_to(root)), + "kind": "directory" if is_dir else "wav"}) + relative = directory.relative_to(root) + return {"path": str(relative), "parent": None if directory == root else str(relative.parent), + "entries": entries, "next_offset": offset + len(entries) if more else None} + + @app.get("/api/input") + def inspect_input(path: str): + return input_info(path_for(path)) | {"path": path} + + @app.get("/api/jobs") + def list_jobs(): + return {"jobs": app.state.jobs.list()} + + @app.get("/api/jobs/{job_id}") + def get_job(job_id: str): + return app.state.jobs.get(job_id) + + @app.post("/api/jobs/{job_id}/cancel") + def cancel_job(job_id: str): + return app.state.jobs.cancel(job_id) + + @app.post("/api/jobs", status_code=202) + async def submit_job(request: Request): + body = object_body(await request.json(), {"kind", "path", "input_id", "settings", "start", "duration", "output_dir"}) + kind = body.get("kind") + if kind not in ("scan", "preview", "export"): + raise ValueError("kind must be scan, preview or export") + info = input_info(path_for(body.get("path"))) + if body.get("input_id") != info["input_id"]: + raise ValueError("Input changed; select and scan it again") + snapshot = {"path": body["path"], "input_id": info["input_id"]} + if kind != "scan": + snapshot["settings"] = asdict(configured(body.get("settings", {}))) + if kind == "preview": + start, duration = body.get("start"), body.get("duration") + if (type(start) not in (int, float) or type(duration) not in (int, float) + or not math.isfinite(start) or not math.isfinite(duration) + or start < 0 or not 0 < duration <= 120 or start + duration > info["duration"]): + raise ValueError("Preview must be within the recording: start >= 0, duration > 0 and <= 120 seconds") + snapshot.update(start=start, duration=duration) + if kind == "export": + output_dir = body.get("output_dir", ".") + if not path_for(output_dir).is_dir(): + raise ValueError("Choose an existing output directory") + snapshot["output_dir"] = output_dir + return app.state.jobs.submit(kind, snapshot) + + @app.get("/api/results/{result_id}/{kind}.wav") + def audio(result_id: str, kind: str): + return Response(app.state.jobs.media(result_id, kind), media_type="audio/wav") + + assets = Path(__file__).with_name("web") + @app.get("/") + def index(): + return FileResponse(assets / "index.html") + app.mount("/assets", StaticFiles(directory=assets), name="assets") + return app + + +def serve(root, port=8765, *, config=None, profile=None, overrides=None): + if type(port) is not int or not 1 <= port <= 65535: + raise ValueError("port must be between 1 and 65535") + try: + import uvicorn + import fastapi # noqa: F401 -- useful dependency diagnostic before binding + except ImportError as error: + raise RuntimeError("GUI dependencies are optional. Use './producer.sh gui' or install 'producer[gui]'.") from error + token = secrets.token_urlsafe(32) + app = create_app(root, port, token, config=config, profile=profile, overrides=overrides) + # Pre-bind so a busy port fails before printing a misleading authenticated URL. + with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as sock: + sock.bind(("127.0.0.1", port)) + sock.listen(128) + print(f"Producer local GUI: http://127.0.0.1:{port}/#token={token}", flush=True) + print(f"Allowed files: {Path(root).resolve()} (keep this URL private)", flush=True) + server = uvicorn.Server(uvicorn.Config(app, host="127.0.0.1", port=port, + access_log=False, proxy_headers=False)) + server.run(sockets=[sock]) diff --git a/lib/project/src/producer/gui_jobs.py b/lib/project/src/producer/gui_jobs.py new file mode 100644 index 0000000..705b0dd --- /dev/null +++ b/lib/project/src/producer/gui_jobs.py @@ -0,0 +1,327 @@ +"""Single-worker GUI jobs and bounded, stat-keyed sound-comparison results. + +No full-recording arrays: waveform buffers are 65536 frames plus 2048 bins. +Only bounded PCM16 preview snippets are returned to the browser. +""" +from collections import OrderedDict, deque +from copy import deepcopy +from dataclasses import asdict, replace +import hashlib +import json +from pathlib import Path +import shutil +import tempfile +import threading +import time +import uuid + +import numpy as np +import soundfile as sf + +from .audio import ffmpeg_run, loudness +from .config import Settings +from .pipeline import process, targets_for +from .setup import ensure_ffmpeg + +MAX_PENDING = 3 +MAX_HISTORY = 24 +CACHE_ENTRIES = 8 +CACHE_BYTES = 128 * 1024 * 1024 +PREVIEW_SECONDS = 120 +CONTEXT_SECONDS = 10 +WAVEFORM_BINS = 2048 + + +class Cancelled(Exception): + pass + + +class Busy(ValueError): + pass + + +def identity(path): + stat = path.stat() + return hashlib.sha256(json.dumps([str(path), stat.st_dev, stat.st_ino, stat.st_size, + stat.st_mtime_ns, stat.st_ctime_ns]).encode()).hexdigest() + + +def input_info(path): + if not path.is_file() or path.suffix.lower() != ".wav": + raise ValueError("Choose an existing WAV file. Other codecs are not supported as inputs.") + try: + info = sf.info(path) + except (RuntimeError, sf.LibsndfileError) as error: + raise ValueError(f"Unsupported or unreadable WAV: {error}") from error + if info.channels == 2: + raise ValueError("Stereo input: downmix to mono first. Automatic downmix could cancel your voice.") + if info.channels != 1: + raise ValueError("Only mono voice recordings can be processed.") + if not info.frames: + raise ValueError("The recording is empty") + return {"input_id": identity(path), "frames": info.frames, "duration": info.duration, + "sample_rate": info.samplerate, "channels": info.channels, "subtype": info.subtype} + + +def waveform(path, progress): + """Fixed-size min/max summary; bin edges use integer frame coordinates.""" + with sf.SoundFile(path) as source: + frames = len(source) + low = np.full(WAVEFORM_BINS, np.inf) + high = np.full(WAVEFORM_BINS, -np.inf) + position = 0 + progress("Scanning waveform", 0, frames) + for audio in source.blocks(blocksize=65536, dtype="float32", always_2d=True): + if not np.isfinite(audio).all(): + raise ValueError("Recording contains non-finite samples") + indices = np.minimum((np.arange(len(audio), dtype=np.int64) + position) + * WAVEFORM_BINS // frames, WAVEFORM_BINS - 1) + np.minimum.at(low, indices, audio.min(axis=1)) + np.maximum.at(high, indices, audio.max(axis=1)) + position += len(audio) + progress("Scanning waveform", position, frames) + low[~np.isfinite(low)] = 0 + high[~np.isfinite(high)] = 0 + return {"duration": frames / source.samplerate, "frames": frames, + "sample_rate": source.samplerate, "bins": WAVEFORM_BINS, + "min": low.tolist(), "max": high.tolist()} + + +def copy_range(source, target, start, duration, progress, subtype="FLOAT"): + """Seek directly to any location; copy a bounded interval in bounded blocks.""" + with sf.SoundFile(source) as src: + first = round(start * src.samplerate) + count = round(duration * src.samplerate) + if first < 0 or first + count > len(src) + 1: + raise ValueError("Selection extends beyond the recording") + count = min(count, len(src) - first) + src.seek(first) + with sf.SoundFile(target, "w", samplerate=src.samplerate, channels=src.channels, + format="WAV", subtype=subtype) as dst: + copied = 0 + while copied < count: + progress("Extracting selected audio", copied, count) + block = src.read(min(65536, count - copied), dtype="float32", always_2d=True) + if not len(block): + raise ValueError("Recording changed while reading") + if not np.isfinite(block).all(): + raise ValueError("Recording contains non-finite samples") + dst.write(block) + copied += len(block) + progress("Extracting selected audio", count, count) + + +def monitoring(original, processed): + """Attenuation-only equal-loudness comparison, separate from rendered audio.""" + levels = [item.get("input_i") for item in (original, processed)] + if any(value is None for value in levels): + return {"available": False, "original_gain_db": 0, "processed_gain_db": 0, + "reason": "Not enough measurable audio for integrated loudness matching."} + target = min(levels) + return {"available": True, "target_lufs": target, + "original_gain_db": target - levels[0], "processed_gain_db": target - levels[1], + "reason": "Playback-only attenuation to the quieter snippet; never changes export settings."} + + +def render_preview(source, work, snapshot, progress): + requested = Settings(**snapshot["settings"]) + # The sound comparison preserves timing. Pacing edits are export-only. + effective = replace(requested, trim_silence=False, mp3=False) + start, duration = snapshot["start"], snapshot["duration"] + context_start = max(0, start - CONTEXT_SECONDS) + context_end = min(sf.info(source).duration, start + duration + CONTEXT_SECONDS) + with tempfile.TemporaryDirectory(prefix="context-", dir=work) as name: + temporary = Path(name) + excerpt, master = temporary / "excerpt.wav", temporary / "master.wav" + copy_range(source, excerpt, context_start, context_end - context_start, progress) + report = process(excerpt, master, effective, progress) + original_float, processed_float = temporary / "original.wav", temporary / "processed.wav" + copy_range(source, original_float, start, duration, progress) + copy_range(master, processed_float, start - context_start, duration, progress) + ffmpeg = ensure_ffmpeg() + levels = [] + for kind, path in (("original", original_float), ("processed", processed_float)): + # PCM16 mono 48k is broadly browser-playable and bounded at ~11.6 MB. + ffmpeg_run(ffmpeg, ["-i", str(path), "-ar", "48000", "-c:a", "pcm_s16le", + str(work / f"{kind}.wav")], "Preparing browser audio", duration, progress) + levels.append(loudness(ffmpeg, work / f"{kind}.wav", effective, duration, progress)) + return {"requested_settings": asdict(requested), "effective_settings": asdict(effective), + "start": start, "duration": duration, + "context_start": context_start, "context_end": context_end, + "approximate": True, + "notice": "Approximate sound comparison: chapter normalization and inference context differ. Pacing edits are not previewed; this comparison preserves timing.", + "warnings": report["warnings"], "normalization_mode": report["normalization_mode"], + "original_loudness": levels[0], "processed_loudness": levels[1], + "monitoring": monitoring(*levels)} + + +class Jobs: + """Owned by the app lifespan. All public state is copied under the lock.""" + def __init__(self, root, resolve_path): + self.root = root + self.resolve_path = resolve_path + self.temporary = tempfile.TemporaryDirectory(prefix="producer-gui-") + self.work = Path(self.temporary.name) + self.condition = threading.Condition(threading.RLock()) + self.jobs = OrderedDict() + self.pending = deque() + self.cache = OrderedDict() + self.closed = False + self.thread = threading.Thread(target=self._worker, name="producer-gui-jobs", daemon=True) + self.thread.start() + + def _check_input(self, snapshot): + source = self.resolve_path(snapshot["path"]) + if identity(source) != snapshot["input_id"]: + raise ValueError("Input changed; select and scan it again") + return source + + def submit(self, kind, snapshot): + snapshot = deepcopy(snapshot) + with self.condition: + if self.closed: + raise Busy("Server is shutting down") + if len(self.pending) >= MAX_PENDING: + raise Busy("Queue is full; wait for a job or cancel a queued job") + self._check_input(snapshot) + job_id = uuid.uuid4().hex + # Repeated results only. Export is intentionally never cached. + key = hashlib.sha256(json.dumps([kind, snapshot], sort_keys=True).encode()).hexdigest() + job = {"id": job_id, "kind": kind, "snapshot": snapshot, "status": "queued", + "progress": {"stage": "Queued", "completed": None, "total": None}, + "created": time.time(), "result": None, "error": None, + "cancel_requested": False, "cached": False} + self.jobs[job_id] = job + self.pending.append((job_id, key)) + self._prune_jobs() + self.condition.notify_all() + return deepcopy(job) + + def _prune_jobs(self): + for job_id, job in list(self.jobs.items()): + if len(self.jobs) <= MAX_HISTORY: + break + if job["status"] in ("succeeded", "failed", "cancelled"): + del self.jobs[job_id] + + def list(self): + with self.condition: + return deepcopy(list(self.jobs.values())) + + def get(self, job_id): + with self.condition: + return deepcopy(self.jobs[job_id]) + + def cancel(self, job_id): + with self.condition: + job = self.jobs[job_id] + if job["status"] in ("queued", "running"): + job["cancel_requested"] = True + if job["status"] == "queued": + self.pending = deque(item for item in self.pending if item[0] != job_id) + job["status"] = "cancelled" + return deepcopy(job) + + def _progress(self, job, stage, completed, total): + with self.condition: + if job["cancel_requested"] or self.closed: + raise Cancelled() + job["progress"] = {"stage": stage, "completed": completed, "total": total} + + def _evict(self): + while len(self.cache) > CACHE_ENTRIES or sum(item["bytes"] for item in self.cache.values()) > CACHE_BYTES: + _, item = self.cache.popitem(last=False) + shutil.rmtree(item["directory"], ignore_errors=True) + + def media(self, result_id, kind): + if kind not in ("original", "processed"): + raise KeyError(kind) + with self.condition: + item = next((item for item in self.cache.values() if item["id"] == result_id), None) + if item is None: + raise KeyError(result_id) + self._check_input(item["snapshot"]) + # Bounded snippets, read under lock so eviction cannot race delivery. + return (item["directory"] / f"{kind}.wav").read_bytes() + + def _worker(self): + while True: + with self.condition: + self.condition.wait_for(lambda: self.pending or self.closed) + if self.closed: + return + job_id, key = self.pending.popleft() + job = self.jobs[job_id] + job["status"] = "running" + directory = None + try: + snapshot = job["snapshot"] + progress = lambda stage, completed, total: self._progress(job, stage, completed, total) + progress("Starting", None, None) + source = self._check_input(snapshot) + with self.condition: + cached = self.cache.get(key) + if cached is not None: + self.cache.move_to_end(key) + job.update(status="succeeded", result=deepcopy(cached["result"]), cached=True) + continue + directory = Path(tempfile.mkdtemp(dir=self.work)) + if job["kind"] == "scan": + result = waveform(source, progress) + elif job["kind"] == "preview": + result = render_preview(source, directory, snapshot, progress) + else: + parent = self.resolve_path(snapshot["output_dir"]) + if not parent.is_dir(): + raise ValueError("Choose an existing output directory") + # A private, new delivery folder prevents audio AND report clobbering. + delivery = Path(tempfile.mkdtemp(prefix="producer-export-", dir=parent)) + try: + target = delivery / "master.wav" + settings = Settings(**snapshot["settings"]) + report = process(source, target, settings, progress, + provenance={"gui_snapshot": snapshot}) + result = {"paths": [str(path.relative_to(self.root)) for path in targets_for(target, settings)], + "settings": asdict(settings), "warnings": report["warnings"], + "duration": report["master"]["duration_seconds"], + "notice": "Pacing edits changed duration and were not auditioned in the sound comparison." if settings.trim_silence else "Full-file export completed."} + except BaseException: + # If cancellation occurs at publication, preserve any completed audio. + if not (delivery / "master.wav").exists() and not (delivery / "master.mp3").exists(): + shutil.rmtree(delivery, ignore_errors=True) + raise + self._check_input(snapshot) + with self.condition: + if job["kind"] != "export": + progress("Complete", 1, 1) + result_id = uuid.uuid4().hex + result.update(result_id=result_id, input_id=snapshot["input_id"]) + if job["kind"] == "preview": + result["audio"] = {kind: f"/api/results/{result_id}/{kind}.wav" for kind in ("original", "processed")} + self.cache[key] = {"id": result_id, "snapshot": snapshot, "result": result, + "directory": directory, + "bytes": sum(p.stat().st_size for p in directory.rglob("*") if p.is_file())} + directory = None + self._evict() + job.update(status="succeeded", result=deepcopy(result)) + except Cancelled: + with self.condition: + job["status"] = "cancelled" + except Exception as error: + with self.condition: + job.update(status="failed", error=str(error)[-8000:]) + finally: + if directory is not None: + shutil.rmtree(directory, ignore_errors=True) + with self.condition: + self._prune_jobs() + + def close(self): + with self.condition: + self.closed = True + for job_id in list(self.jobs): + self.cancel(job_id) + self.condition.notify_all() + # Existing subprocess runners terminate/reap groups when callbacks raise. + self.thread.join() + self.temporary.cleanup() diff --git a/lib/project/src/producer/gui_schema.py b/lib/project/src/producer/gui_schema.py new file mode 100644 index 0000000..d72b6ea --- /dev/null +++ b/lib/project/src/producer/gui_schema.py @@ -0,0 +1,89 @@ +"""Presentation metadata for the strict, shared Settings resolver.""" +from dataclasses import asdict + +from .config import PROFILES, RANGES, Settings + +# Keep DSP validation in config.resolve; these labels explain the existing knobs. +GROUPS = { + "General": { + "profile": "Starting sound profile. File and explicit settings override profile values.", + "device": "AI device: auto selects an available backend; CPU needs no GPU.", + }, + "Noise removal": { + "denoiser": "DeepFilterNet or ClearVoice require local model downloads; FFT is lightweight; none bypasses denoising.", + "denoise_strength": "Dry/wet denoising blend. Lower values may preserve consonants better.", + "fft_reduction_db": "Maximum FFT noise reduction, scaled by denoise strength.", + }, + "Filters": { + "highpass": "Remove low-frequency rumble.", "highpass_hz": "High-pass cutoff in Hz.", + "lowpass": "Remove high-frequency content.", "lowpass_hz": "Low-pass cutoff in Hz, below output Nyquist.", + "hum_hz": "Mains hum notch frequency; 0 bypasses, 50/60 also filter harmonics.", + }, + "Level and expansion": { + "leveling": "Apply constant gain using an estimated speech level.", + "level_target_db": "Speech-level target in dBFS before dynamics.", + "max_gain_db": "Maximum absolute leveling adjustment in dB.", + "expansion": "Attenuate low-level sound with a downward expander; can affect quiet words.", + "expansion_threshold_db": "Expander threshold in dBFS.", + "expansion_ratio": "Expansion ratio; 1 means no expansion.", + "expansion_range_db": "Maximum expansion attenuation in dB.", + }, + "Speech-aware pauses": { + "pause_silence": "Silence non-speech pauses without changing timing. Downloads a local speech detector.", + "pause_threshold": "Speech probability threshold. Lower values protect more possible speech.", + "pause_pre_ms": "Protected milliseconds before detected speech.", + "pause_post_ms": "Protected milliseconds after detected speech.", + "pause_fade_ms": "Fade length in milliseconds outside protected speech.", + "pause_min_ms": "Minimum silent core in milliseconds after margins and fades.", + }, + "Pacing (export only)": { + "trim_silence": "Shorten long gaps on export. NOT auditioned in sound-comparison previews; changes duration.", + "max_gap_s": "Minimum gap length in seconds eligible for shortening.", + "gap_pad_s": "Total retained gap in seconds; must be smaller than maximum gap.", + "gap_threshold_db": "Legacy gap threshold in dBFS when speech-aware pauses are off; can mistake quiet words for silence.", + }, + "Tone": { + "eq": "Enable warmth, mud and presence equalizers.", + "warmth_db": "140 Hz warmth gain in dB.", "mud_db": "300 Hz mud-band gain in dB.", + "presence_db": "3500 Hz presence gain in dB.", + }, + "Dynamics": { + "compression": "Reduce dynamic range with a compressor.", + "compressor_threshold_db": "Compression threshold in dBFS.", + "compressor_ratio": "Compression ratio; 1 means no compression.", + "compressor_attack_ms": "Compressor attack in milliseconds; fast attacks can soften speech starts.", + "compressor_release_ms": "Compressor recovery in milliseconds.", + "deess": "Reduce sibilance.", "deess_intensity": "Sibilance detector intensity.", + "deess_amount": "Amount of de-essing.", + }, + "Loudness and delivery": { + "normalize": "Two-pass integrated loudness normalization, including true-peak limiting. Preview context differs from a chapter.", + "target_lufs": "Integrated loudness target in LUFS, not an ACX/Audible compliance guarantee.", + "loudness_range": "Loudness range target in LU.", + "limiter": "Limit peaks; must remain enabled when normalization is enabled.", + "true_peak_db": "True-peak ceiling in dBTP; lossy MP3 can overshoot.", + "sample_rate": "Mono 32-bit PCM WAV export sample rate in Hz.", + "mp3": "Also export a constant-bitrate MP3 alongside the WAV.", + "mp3_bitrate": "MP3 bitrate in kb/s.", + }, +} +CHOICES = {"profile": list(PROFILES), "denoiser": ["deepfilter", "clearvoice", "fft", "none"], + "device": ["auto", "cpu", "cuda"], "hum_hz": [0, 50, 60], + "sample_rate": [44100, 48000], "mp3_bitrate": [128, 160, 192, 224, 256, 320]} + + +def schema(initial): + metadata = {key: (group, help_text) for group, items in GROUPS.items() for key, help_text in items.items()} + defaults = asdict(Settings()) + return {"settings": initial, "package_defaults": defaults, "profiles": PROFILES, + "fields": [{"name": key, "type": type(value).__name__, "default": value, + "group": metadata[key][0], "help": metadata[key][1], + "range": RANGES.get(key), "choices": CHOICES.get(key)} + for key, value in defaults.items()], + "constraints": ["lowpass_hz must be below sample_rate / 2 when lowpass is enabled", + "gap_pad_s must be smaller than max_gap_s when trim_silence is enabled", + "normalize requires limiter"], + "limits": {"preview_seconds": 120, "context_seconds_each_side": 10, + "waveform_bins": 2048, "pending_jobs": 3, "job_history": 24, + "cache_entries": 8, "cache_bytes": 134217728}, + "preview_notice": "Approximate sound comparison, not an exact export preview. Chapter normalization and model context differ. Pacing edits are not previewed; this comparison preserves timing."} diff --git a/lib/project/src/producer/pauses.py b/lib/project/src/producer/pauses.py new file mode 100644 index 0000000..e2b5e78 --- /dev/null +++ b/lib/project/src/producer/pauses.py @@ -0,0 +1,177 @@ +"""Speech-aware, duration-preserving pause silencing with offline lookahead. + +Speech decisions use the pre-denoise recording, not its potentially damaged +onsets. Only gaps outside padded speech are attenuated. Audio is streamed; +VAD metadata grows by ~31 probabilities/second, not by audio sample count. +""" +import hashlib +import json +import math +import os +from pathlib import Path +import shutil +import tempfile +import time +import urllib.request + +import numpy as np +import soundfile as sf + +from .audio import ffmpeg_run +from .setup import data_dir, ensure_uv, run_process, tool_env + +MODEL_URL = "https://raw.githubusercontent.com/snakers4/silero-vad/v5.1/src/silero_vad/data/silero_vad.onnx" +MODEL_SHA256 = "2623a2953f6ff3d2c1e61740c6cdb7168133479b267dfef114a4a3cc5bdd788f" + + +def ensure_vad(progress): + """Small, separate CPU runtime; lock setup and verify pinned model bytes.""" + from .ai import _boot_lock, _interpreter_is_local, _require_supported_platform + + _require_supported_platform("Silero VAD") + root = data_dir() + root.mkdir(parents=True, exist_ok=True) + environment = root / "vad-onnx-1.20.1-py311-v1" + python = environment / "bin/python" + marker = environment / ".producer-ready" + model = root / "models/silero-vad-v5.1.onnx" + with _boot_lock(root, progress, time.monotonic()): + if not (marker.is_file() and python.is_file() and _interpreter_is_local(python, root)): + if environment.exists(): + shutil.rmtree(environment) + uv, env = ensure_uv(progress), tool_env() + run_process([uv, "venv", "--python", "3.11", "--managed-python", + "--relocatable", str(environment)], "Preparing speech detector", progress, env=env) + run_process([uv, "pip", "install", "--python", str(python), + "--only-binary", ":all:", "--index-url", "https://pypi.org/simple", + "onnxruntime==1.20.1", "numpy==1.26.4", "soundfile==0.13.1"], + "Installing speech detector", progress, env=env) + run_process([str(python), "-I", "-c", "import onnxruntime, numpy, soundfile"], + "Checking speech detector runtime", progress, env=env) + marker.touch() + if not model.is_file() or hashlib.sha256(model.read_bytes()).hexdigest() != MODEL_SHA256: + progress("Downloading Silero VAD v5.1", 0, None) + model.parent.mkdir(parents=True, exist_ok=True) + # Bound the download and publish only a hash-verified model. + with urllib.request.urlopen(MODEL_URL, timeout=60) as response: + payload = response.read(4 * 1024 * 1024) + if hashlib.sha256(payload).hexdigest() != MODEL_SHA256: + raise RuntimeError("Silero VAD model checksum mismatch") + fd, name = tempfile.mkstemp(prefix=".silero-", dir=model.parent) + try: + with os.fdopen(fd, "wb") as output: + output.write(payload) + os.replace(name, model) + finally: + Path(name).unlink(missing_ok=True) + return python, model + + +def speech_regions(probabilities, frames, sr, threshold): + """Hysteresis; keep even short detections rather than discarding short words. + + Times refer to 512-sample VAD windows at 16 kHz. Returning no regions is + explicitly handled as fail-open by the caller, never 'mute everything'. + """ + expected = math.ceil(math.ceil(frames * 16000 / sr) / 512) + if len(probabilities) != expected: + raise RuntimeError("Speech detector timeline does not match the recording") + result, start = [], None + for index, probability in enumerate(probabilities): + if not isinstance(probability, (int, float)) or not math.isfinite(probability) or not 0 <= probability <= 1: + raise RuntimeError("Invalid speech probability") + position = min(frames, round(index * 512 * sr / 16000)) + if start is None and probability >= threshold: + start = position + elif start is not None and probability < max(0.01, threshold - 0.15): + result.append((start, position)) + start = None + if start is not None: + result.append((start, frames)) + return result + + +def pause_regions(speech, frames, sr, settings): + """Complement of padded speech, excluding gaps too small for safe fades.""" + pre = round(settings.pause_pre_ms * sr / 1000) + post = round(settings.pause_post_ms * sr / 1000) + fade = max(1, round(settings.pause_fade_ms * sr / 1000)) + minimum = round(settings.pause_min_ms * sr / 1000) + 2 * fade + pauses, cursor = [], 0 + for start, end in speech: + a, b = max(0, start - pre), min(frames, end + post) + if a - cursor >= minimum: + pauses.append((cursor, a)) + cursor = max(cursor, b) + if frames - cursor >= minimum: + pauses.append((cursor, frames)) + return pauses + + +def detect_pauses(source, work, ffmpeg, settings, speech_level, progress): + python, model = ensure_vad(progress) + info = sf.info(source) + detector_input, result = work / "vad.wav", work / "vad.json" + # Detection-only gain helps quiet recordings; never alters the master. + gain = max(-18.0, min(18.0, -23.0 - speech_level)) + ffmpeg_run(ffmpeg, ["-i", str(source), "-af", f"volume={gain}dB", "-ar", "16000", + "-ac", "1", "-c:a", "pcm_f32le", "-rf64", "auto", str(detector_input)], + "Preparing speech detection", info.duration, progress) + run_process([str(python), "-I", str(Path(__file__).with_name("vad_worker.py")), + "--source", str(detector_input), "--target", str(result), "--model", str(model)], + "Detecting speech", progress, env=tool_env()) + values = json.loads(result.read_text()) + speech = speech_regions(values, info.frames, info.samplerate, settings.pause_threshold) + detector_input.unlink() + result.unlink() + # A failed detector must not silently erase an entire recording. + pauses = pause_regions(speech, info.frames, info.samplerate, settings) if speech else [] + return pauses, {"detector": "silero-vad-v5.1", "speech_regions": len(speech), + "detection_gain_db": gain, "pauses_silenced": len(pauses), + "attenuated_seconds": sum(b - a for a, b in pauses) / info.samplerate, + "intervals_seconds": [[a / info.samplerate, b / info.samplerate] for a, b in pauses]} + + +def silence_pauses(source, target, pauses, fade_ms, progress): + """Raised-cosine fades outside speech; exactly zero gain in pause cores. + + Input and output must be distinct staging files. Common gain envelopes + retain samples outside pauses exactly, with no time removal or delay. + """ + if source.resolve() == target.resolve(): + raise ValueError("Pause silencing requires distinct input and output") + with sf.SoundFile(source) as src: + fade = max(1, round(fade_ms * src.samplerate / 1000)) + frames = len(src) + cursor = 0 + for a, b in pauses: + if not cursor <= a < b <= frames or b - a < 2 * fade: + raise ValueError("Invalid pause interval") + cursor = b + output_format = "RF64" if frames * src.channels * 4 > 0xFFFFFFFF - 4096 else "WAV" + with sf.SoundFile(target, "w", samplerate=src.samplerate, channels=src.channels, + format=output_format, subtype="FLOAT") as dst: + position, index = 0, 0 + progress("Silencing pauses", 0, frames) + for audio in src.blocks(blocksize=65536, dtype="float32", always_2d=True): + if not np.isfinite(audio).all(): + raise ValueError("Input contains non-finite audio samples") + end = position + len(audio) + while index < len(pauses) and pauses[index][1] <= position: + index += 1 + current = index + while current < len(pauses) and pauses[current][0] < end: + a, b = pauses[current] + left, right = max(a, position), min(b, end) + samples = np.arange(left, right) + # File boundaries have no adjacent speech to fade from/to: + # do not bring room noise back in the last milliseconds. + left_distance = samples - a if a else np.full(len(samples), fade) + right_distance = b - 1 - samples if b < frames else np.full(len(samples), fade) + distance = np.minimum(left_distance, right_distance) + gain = 0.5 * (1 + np.cos(np.pi * np.minimum(distance / fade, 1))) + audio[left - position:right - position] *= gain[:, None] + current += 1 + dst.write(audio) + position = end + progress("Silencing pauses", position, frames) diff --git a/lib/project/src/producer/pipeline.py b/lib/project/src/producer/pipeline.py index d6c0cf1..31cb9f5 100644 --- a/lib/project/src/producer/pipeline.py +++ b/lib/project/src/producer/pipeline.py @@ -103,6 +103,17 @@ def process(source: Path, destination: Path, settings: Settings, progress, 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) + pauses, pause_report = [], {"enabled": settings.pause_silence} + if settings.pause_silence: + from .pauses import detect_pauses + pauses, details = detect_pauses(prepared, work, ffmpeg, settings, + original["speech_level_estimate_dbfs"], progress) + pause_report.update(details) + if not details["speech_regions"]: + warnings.append("Speech detector found no speech; pause silencing and gap trimming skipped " + "to avoid erasing the recording. Inspect the input or lower pause_threshold.") + elif not pauses: + warnings.append("No sufficiently long non-speech pauses detected; pause silencing changed nothing.") if settings.denoiser in {"deepfilter", "clearvoice"} and settings.denoise_strength > 0: from .ai import denoise denoise(prepared, cleaned, settings.device, settings.denoise_strength, progress, @@ -113,20 +124,16 @@ def process(source: Path, destination: Path, settings: Settings, progress, "-c:a", "pcm_f32le", "-rf64", "auto", str(cleaned)], "FFT noise reduction", duration, progress) else: cleaned = prepared - # Static gap trimming shortens long silences after cleanup; speech is - # copied through, so it stays independent of the denoiser backend. - trimmed = work / "trimmed.wav" + # Keep the original timeline through dynamics when using the speech + # map. Without VAD, preserve the legacy pre-leveling trim threshold. gaps_trimmed, silence_removed = 0, 0.0 - if settings.trim_silence: + if settings.trim_silence and not settings.pause_silence: from .audio import trim_gaps + trimmed = work / "trimmed.wav" gaps_trimmed, silence_removed = trim_gaps(cleaned, trimmed, - settings.gap_threshold_db, settings.max_gap_s, settings.gap_pad_s, - progress) + settings.gap_threshold_db, settings.max_gap_s, settings.gap_pad_s, progress) cleaned.unlink(missing_ok=True) cleaned = trimmed - if settings.trim_silence and gaps_trimmed == 0: - warnings.append("Gap trimming found no silences below the configured " - "threshold; nothing was trimmed. Check gap_threshold_db.") trimmed_duration = duration - silence_removed clean_stats = measure(cleaned, progress, "Measuring cleaned voice") if clean_stats["sample_peak_dbfs"] < -100: @@ -147,6 +154,24 @@ def process(source: Path, destination: Path, settings: Settings, progress, ffmpeg_run(ffmpeg, ["-i", str(cleaned), "-af", ",".join(chain), "-c:a", "pcm_f32le", "-rf64", "auto", str(shaped)], "EQ, dynamics and de-essing", trimmed_duration, progress) + if pauses: + from .pauses import silence_pauses + silenced = work / "silenced.wav" + silence_pauses(shaped, silenced, pauses, settings.pause_fade_ms, progress) + shaped.unlink() + shaped = silenced + if settings.trim_silence and settings.pause_silence and pause_report["speech_regions"]: + from .audio import trim_gaps + trimmed = work / "trimmed.wav" + # Trim only effectively-zero cores, not weak speech below an + # arbitrary dB threshold. gap_threshold_db is legacy trim-only. + gaps_trimmed, silence_removed = trim_gaps(shaped, trimmed, + -120.0, settings.max_gap_s, settings.gap_pad_s, progress) + shaped.unlink() + shaped = trimmed + if settings.trim_silence and gaps_trimmed == 0: + warnings.append("Gap trimming found no eligible long silences; nothing was trimmed.") + trimmed_duration = duration - silence_removed before = loudness(ffmpeg, shaped, settings, trimmed_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") @@ -157,7 +182,9 @@ def process(source: Path, destination: Path, settings: Settings, progress, 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") + # At 32-bit PCM, dither has no practical audible benefit here and + # would turn intentional digital silence back into a noise floor. + final_filters.append(f"aresample={settings.sample_rate}:output_sample_bits=32:dither_method=none") 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)], @@ -197,6 +224,7 @@ def process(source: Path, destination: Path, settings: Settings, progress, report = {"producer_version": __version__, "source": str(source), "output": str(destination), "settings": asdict(settings), "input": original, "cleaned": clean_stats, "level_correction_db": gain, + "pause_cleanup": pause_report, "gaps_trimmed": gaps_trimmed, "silence_removed_seconds": round(silence_removed, 3), "pre_master_loudness": before, "master_loudness": final, "master": final_stats, "mp3_loudness": mp3_stats, diff --git a/lib/project/src/producer/vad_worker.py b/lib/project/src/producer/vad_worker.py new file mode 100644 index 0000000..5935937 --- /dev/null +++ b/lib/project/src/producer/vad_worker.py @@ -0,0 +1,55 @@ +"""Isolated CPU Silero VAD v5.1 ONNX inference (no Torch or audio uploads).""" +import argparse +import json +from pathlib import Path + + +def probabilities(source, model, progress=lambda *_: None): + import numpy as np + import onnxruntime as ort + import soundfile as sf + + options = ort.SessionOptions() + options.inter_op_num_threads = options.intra_op_num_threads = 1 + session = ort.InferenceSession(str(model), sess_options=options, + providers=["CPUExecutionProvider"]) + state = np.zeros((2, 1, 128), dtype="float32") + context = np.zeros((1, 64), dtype="float32") + result = [] + with sf.SoundFile(source) as audio: + if audio.samplerate != 16000 or audio.channels != 1 or not len(audio): + raise ValueError("VAD input must be nonempty mono 16 kHz audio") + for chunk in audio.blocks(blocksize=512, dtype="float32"): + if not np.isfinite(chunk).all(): + raise ValueError("VAD input contains non-finite samples") + chunk = np.pad(chunk, (0, 512 - len(chunk)))[None, :] + window = np.concatenate((context, chunk), axis=1) + output, state = session.run(None, {"input": window, "state": state, + "sr": np.array(16000, dtype="int64")}) + probability = float(output.item()) + if not np.isfinite(probability) or not 0 <= probability <= 1: + raise RuntimeError("VAD returned an invalid probability") + result.append(probability) + context = window[:, -64:] + if len(result) % 32 == 0: + progress("Detecting speech (CPU)", audio.tell(), len(audio)) + progress("Detecting speech (CPU)", len(audio), len(audio)) + return result + + +def main(): + parser = argparse.ArgumentParser() + parser.add_argument("--source", type=Path, required=True) + parser.add_argument("--model", type=Path, required=True) + parser.add_argument("--target", type=Path, required=True) + args = parser.parse_args() + + def progress(*event): + print("PRODUCER_PROGRESS " + json.dumps(event), flush=True) + + values = probabilities(args.source, args.model, progress) + args.target.write_text(json.dumps(values, allow_nan=False)) + + +if __name__ == "__main__": + main() diff --git a/lib/project/src/producer/web/app.js b/lib/project/src/producer/web/app.js new file mode 100644 index 0000000..bdf62c6 --- /dev/null +++ b/lib/project/src/producer/web/app.js @@ -0,0 +1,685 @@ +import {parseTime, formatTime, selection, stable, matches, monitoringGain, absolutePath} from './state.js'; + +const $ = id => document.getElementById(id); +const copy = value => structuredClone(value); +const terminal = job => ['succeeded', 'failed', 'cancelled'].includes(job.status); +const state = { + schema: null, input: null, version: 0, inputEpoch: 0, folderEpoch: 0, + folder: '.', parent: null, next: null, wave: null, view: [0, 0], + jobs: new Map(), intents: new Map(), handled: new Set(), cards: new Map(), + preview: null, mediaEpoch: 0, mediaAbort: null, urls: [], side: 'original', + context: null, gains: {}, playbackEpoch: 0, export: null, expired: false, storageKey: '', + presets: [], comparisons: [], exportsShown: new Set() +}; +let token = new URLSearchParams(location.hash.slice(1)).get('token'); +// Fragments never reach the server. Keep the credential only in this tab, not presets. +if (location.hash) history.replaceState(null, '', location.pathname + location.search); +try { + if (token) sessionStorage.setItem('producer-token', token); + else token = sessionStorage.getItem('producer-token'); +} catch { /* A private browser may disable storage; the current tab still works. */ } + +function node(tag, text, className) { + const element = document.createElement(tag); + if (text !== undefined) element.textContent = String(text); + if (className) element.className = className; + return element; +} +function error(error) { + if (error?.name === 'AbortError') return; + let message = error instanceof Error ? error.message : String(error); + if (token) message = message.split(token).join('[credential hidden]'); + $('error-text').textContent = message; + $('error').hidden = false; +} +function action(id, callback) { + $(id).addEventListener('click', async () => { + const button = $(id); + button.disabled = true; + try { await callback(); } catch (e) { error(e); } + finally { button.disabled = false; updateAvailability(); } + }); +} +async function api(path, body, responseType = 'json', signal) { + if (!path.startsWith('/api/')) throw new Error('Invalid local API path.'); + const response = await fetch(path, { + method: body === undefined ? 'GET' : 'POST', + headers: {Authorization: `Bearer ${token || ''}`, ...(body === undefined ? {} : {'Content-Type': 'application/json'})}, + ...(body === undefined ? {} : {body: JSON.stringify(body)}), signal, cache: 'no-store', credentials: 'omit' + }); + if (!response.ok) { + if (response.status === 401) { + state.expired = true; + state.mediaEpoch++; state.mediaAbort?.abort(); + state.export = null; $('export-dialog').close(); + $('workspace').disabled = true; + pause(); + try { sessionStorage.removeItem('producer-token'); } catch { /* Storage unavailable. */ } + $('connection').textContent = 'Session expired. Open the new private URL printed in your Producer terminal. Saved presets remain in this browser.'; + throw new Error('This session is missing or expired. Reopen the exact private launch URL (127.0.0.1, not localhost).'); + } + const data = await response.json().catch(() => ({})); + let detail = typeof data.detail === 'string' ? data.detail : JSON.stringify(data.detail || 'Request failed'); + if (detail === 'Normalization includes true-peak limiting; use --no-normalize to bypass it') detail = 'Overall loudness normalization requires the peak limiter. Enable “Limit peaks” in advanced controls, or turn “Set overall loudness” off.'; + throw new Error(response.status === 404 ? `${detail}. Cached comparisons expire; restore the snapshot and render again.` : detail); + } + return responseType === 'blob' ? response.blob() : response.json(); +} + +const labels = { + profile: 'Starting profile', device: 'AI processing device', denoiser: 'Noise cleanup method', denoise_strength: 'Cleanup strength · 0–1', + fft_reduction_db: 'FFT reduction · dB', highpass: 'Remove low rumble', highpass_hz: 'Rumble cutoff · Hz', lowpass: 'Soften high frequencies', lowpass_hz: 'High-frequency cutoff · Hz', hum_hz: 'Mains hum · Hz (0 = off)', + leveling: 'Even speech level', level_target_db: 'Speech level target · dBFS', max_gain_db: 'Maximum gain change · dB', expansion: 'Reduce low-level room sound', expansion_threshold_db: 'Expansion threshold · dBFS', expansion_ratio: 'Expansion ratio', expansion_range_db: 'Maximum attenuation · dB', + pause_silence: 'Silence non-speech pauses', pause_threshold: 'Speech detection threshold · 0–1', pause_pre_ms: 'Protect word starts · ms', pause_post_ms: 'Protect word endings · ms', pause_fade_ms: 'Pause fade · ms', pause_min_ms: 'Minimum silent core · ms', + trim_silence: 'Shorten long gaps on export', max_gap_s: 'Gap eligible for shortening · s', gap_pad_s: 'Gap to keep · s', gap_threshold_db: 'Legacy silence threshold · dBFS', + eq: 'Shape vocal tone', warmth_db: 'Warmth · dB', mud_db: 'Low-mid fullness · dB', presence_db: 'Clarity / presence · dB', + compression: 'Smooth loud and quiet phrases', compressor_threshold_db: 'Compression threshold · dBFS', compressor_ratio: 'Compression ratio', compressor_attack_ms: 'Attack · ms', compressor_release_ms: 'Recovery · ms', deess: 'Soften sharp S sounds', deess_intensity: 'S-sound sensitivity · 0–1', deess_amount: 'S-sound reduction · 0–1', + normalize: 'Set overall loudness', target_lufs: 'Loudness target · LUFS', loudness_range: 'Loudness range · LU', limiter: 'Limit peaks', true_peak_db: 'Peak ceiling · dBTP', sample_rate: 'WAV sample rate · Hz', mp3: 'Also make an MP3', mp3_bitrate: 'MP3 bitrate · kb/s' +}; +const friendlyHelp = { + denoiser: 'None leaves noise untouched. FFT is light but can sound metallic. AI needs a first-use local download.', + pause_silence: 'Keeps timing; silences detected non-speech. Can lose breaths or whispers. Uses a local speech detector.', + eq: 'Switch tone shaping on or off without losing the detailed EQ values below.', + warmth_db: 'More can sound fuller; too much can sound boomy.', presence_db: 'More can clarify consonants, but also sharpen S sounds.', + compression: 'Brings phrases closer together; too much can sound squeezed or bring up room noise.', + normalize: 'Sets whole-recording loudness on export. Requires the peak limiter; it is not enabled for you.', + target_lufs: 'More negative is quieter. This is an export target, not your listening volume.', + profile: 'Changing this name alone keeps all explicit values. Use Apply profile to reset from launch configuration.' +}; +const primary = [ + ['Noise cleanup', 'Keep the words, lose some room noise. Stronger is not always better.', ['denoiser', 'denoise_strength']], + ['Quiet pauses', 'A little room tone can feel natural. Protect quiet words before chasing silence.', ['pause_silence']], + ['Tone', 'Keep your voice familiar. Listen for fullness and clear, unforced consonants.', ['eq', 'warmth_db', 'presence_db']], + ['Even volume', 'Make phrases easier to follow, without flattening the performance.', ['compression', 'normalize']] +]; +const delivery = ['sample_rate', 'mp3', 'mp3_bitrate', 'trim_silence']; +function settingControl(field) { + const wrapper = node('div', undefined, 'setting'); + const label = node('label', labels[field.name] || field.name); + const input = document.createElement(field.choices ? 'select' : 'input'); + input.id = `setting-${field.name}`; + input.dataset.setting = field.name; + label.htmlFor = input.id; + if (field.choices) { + field.choices.forEach(value => { + const option = node('option', value); + option.value = value; + input.append(option); + }); + } else if (field.type === 'bool') { + input.type = 'checkbox'; label.className = 'check'; + } else { + input.type = field.type === 'str' ? 'text' : 'number'; + input.required = true; + if (field.type !== 'str') { + input.step = field.type === 'int' ? '1' : 'any'; + if (field.range) [input.min, input.max] = field.range; + } + } + const help = node('p', friendlyHelp[field.name] || field.help); + help.id = `help-${field.name}`; + input.setAttribute('aria-describedby', help.id); + if (field.type === 'bool') { label.prepend(input); wrapper.append(label); } + else wrapper.append(label, input); + wrapper.append(help); + if (field.name === 'profile') { + const apply = node('button', 'Apply profile & launch defaults'); + apply.type = 'button'; apply.id = 'apply-profile'; wrapper.append(apply); + } + input.addEventListener('input', changed); + return wrapper; +} +function buildSettings() { + const used = new Set(); + const fields = new Map(state.schema.fields.map(field => [field.name, field])); + for (const [title, help, names] of primary) { + const card = node('div', undefined, 'sound-card'); + card.append(node('h3', title), node('p', help)); + for (const name of names) { card.append(settingControl(fields.get(name))); used.add(name); } + $('primary-settings').append(card); + } + for (const name of delivery) { $('delivery-settings').append(settingControl(fields.get(name))); used.add(name); } + const groups = new Map(); + for (const field of state.schema.fields) { + if (used.has(field.name)) continue; + if (!groups.has(field.group)) { + const group = node('div', undefined, 'settings-group'), grid = node('div', undefined, 'settings-grid'); + group.append(node('h3', field.group), grid); $('settings-groups').append(group); groups.set(field.group, grid); + } + groups.get(field.group).append(settingControl(field)); + } + $('constraints').textContent = 'Validation rules: ' + state.schema.constraints.join('; ') + '.'; + setSettings(state.schema.settings); + action('apply-profile', async () => { + if (!confirm('Replace all controls with this profile resolved through your launch configuration? Explicit launch settings still take precedence.')) return; + const version = state.version; + const data = await api('/api/resolve', {settings: {profile: $('setting-profile').value}}); + if (version !== state.version) throw new Error('Controls changed while loading the profile. Try again.'); + setSettings(data.settings); changed(); + }); +} +function setSettings(settings) { + for (const field of state.schema.fields) { + const input = $(`setting-${field.name}`); + if (field.type === 'bool') input.checked = settings[field.name]; + else input.value = settings[field.name]; + } +} +function readSettings(report = true) { + const settings = {}; + for (const field of state.schema.fields) { + const input = $(`setting-${field.name}`); + if (!input.checkValidity()) { + if (report) { + if (input.closest('#advanced-settings')) $('advanced-settings').open = true; + input.reportValidity(); + } + throw new Error(`Check ${labels[field.name] || field.name}: use a valid value within its range.`); + } + settings[field.name] = field.type === 'bool' ? input.checked : ['int', 'float'].includes(field.type) ? Number(input.value) : input.value; + } + return settings; +} +function currentSnapshot(report = false) { + if (!state.input) throw new Error('Choose a recording first.'); + return {path: state.input.path, input_id: state.input.input_id, settings: readSettings(report), + ...selection($('selection-start').value, $('selection-end').value, state.input.duration)}; +} +function previewMatches() { + try { return state.preview && matches(state.preview.snapshot, currentSnapshot()); } catch { return false; } +} +function changed() { + state.version++; + state.mediaEpoch++; + state.mediaAbort?.abort(); + pause(); + updateComparisonStatus(); + updateSelectionNote(); + drawWave(); +} +function updateAvailability() { + for (const id of ['render-preview', 'prepare-export', 'rescan', 'zoom-selection', 'zoom-full']) $(id).disabled = !state.input; + $('save-comparison').disabled = !state.preview; + $('folder-up').disabled = state.parent === null; +} +function updateComparisonStatus() { + const current = previewMatches(); + $('comparison-status').classList.toggle('stale', Boolean(state.preview && !current)); + $('comparison-status').textContent = !state.preview ? 'Render a passage to listen here.' : current ? + 'Ready · this comparison matches the current recording, passage and controls.' : + 'Controls or passage changed since preview. Playback below is the labelled saved render, NOT the current controls. Render again to hear your changes.'; +} +function updateSelectionNote() { + if (!state.input) return; + try { + const value = selection($('selection-start').value, $('selection-end').value, state.input.duration); + $('selection-note').textContent = `${Number(value.duration.toFixed(3))} seconds selected · full export remains ${formatTime(state.input.duration)}.`; + } catch (e) { $('selection-note').textContent = e.message; } +} + +async function browse(path = '.', offset = 0) { + const epoch = ++state.folderEpoch; + $('files-status').textContent = 'Reading local folder…'; + const data = await api(`/api/files?path=${encodeURIComponent(path)}&offset=${offset}&limit=100`); + if (epoch !== state.folderEpoch) return; + state.folder = data.path; state.parent = data.parent; state.next = data.next_offset; + $('folder-path').textContent = data.path; + $('folder-up').disabled = data.parent === null; + if (!offset) $('files').replaceChildren(); + for (const entry of data.entries) { + const li = node('li'), button = node('button'); button.type = 'button'; + button.append(node('span', entry.kind === 'directory' ? 'FOLDER' : 'WAV', 'file-kind'), document.createTextNode(entry.name)); + button.addEventListener('click', () => (entry.kind === 'directory' ? browse(entry.path) : chooseInput(entry.path)).catch(error)); + li.append(button); $('files').append(li); + } + $('files-more').hidden = data.next_offset === null; + $('files-status').textContent = data.entries.length ? 'Only folders and WAVs are shown. Listing order follows the filesystem.' : 'No more folders or WAV recordings here.'; +} +async function chooseInput(path) { + const epoch = ++state.inputEpoch; + state.input = null; state.wave = null; state.view = [0, 0]; + changed(); updateAvailability(); + $('input-name').textContent = 'Opening ' + path; + $('input-info').textContent = 'Reading recording metadata…'; + $('wave-status').textContent = 'Waiting for recording…'; + let data; + try { data = await api(`/api/input?path=${encodeURIComponent(path)}`); } + catch (e) { + if (epoch !== state.inputEpoch) return null; + $('input-name').textContent = 'No recording selected'; + $('input-info').textContent = 'Could not open this WAV. Choose another file or fix the issue below.'; + $('wave-status').textContent = 'Select a readable mono WAV.'; + throw e; + } + if (epoch !== state.inputEpoch) return null; + state.input = data; state.view = [0, data.duration]; + $('input-name').textContent = data.path; + $('input-info').textContent = `${formatTime(data.duration)} · mono · ${data.sample_rate.toLocaleString()} Hz · ${data.subtype}`; + $('selection-start').value = '00:00:00'; + $('selection-end').value = formatTime(Math.min(30, data.duration)); + // Rounding time display must not put the end beyond a fractional final frame. + if (parseTime($('selection-end').value) > data.duration) $('selection-end').value = String(data.duration); + changed(); updateAvailability(); + $('file-browser').open = false; + $('wave-status').textContent = 'Scanning a fixed-size overview… You can already enter exact times.'; + await submit('scan', {path: data.path, input_id: data.input_id}, {inputEpoch: epoch}); + return epoch === state.inputEpoch ? data : null; +} +function drawWave() { + const canvas = $('waveform'), width = Math.max(1, Math.round(canvas.clientWidth)); + const ratio = window.devicePixelRatio || 1; + canvas.width = Math.round(width * ratio); canvas.height = Math.round(180 * ratio); + const ctx = canvas.getContext('2d'); ctx.scale(ratio, ratio); + const [start, end] = state.view, span = end - start; + $('wave-start').textContent = formatTime(start); $('wave-end').textContent = formatTime(end); + ctx.strokeStyle = '#c5d4c7'; ctx.beginPath(); ctx.moveTo(0, 90); ctx.lineTo(width, 90); ctx.stroke(); + if (!span) return; + const wave = state.wave; + if (wave) { + ctx.strokeStyle = '#527c60'; ctx.beginPath(); + for (let x = 0; x < width; x++) { + const first = Math.max(0, Math.floor((start + x / width * span) / wave.duration * wave.bins)); + const last = Math.min(wave.bins - 1, Math.floor((start + (x + 1) / width * span) / wave.duration * wave.bins)); + let low = 0, high = 0; + for (let i = first; i <= last; i++) { low = Math.min(low, wave.min[i]); high = Math.max(high, wave.max[i]); } + ctx.moveTo(x + .5, 90 - Math.min(1, high) * 78); ctx.lineTo(x + .5, 90 - Math.max(-1, low) * 78); + } + ctx.stroke(); + } + try { + const value = selection($('selection-start').value, $('selection-end').value, state.input.duration); + const x = (value.start - start) / span * width, right = (value.start + value.duration - start) / span * width; + ctx.fillStyle = '#bd85332b'; ctx.fillRect(x, 0, right - x, 180); + ctx.strokeStyle = '#a56a18'; ctx.lineWidth = 2; ctx.strokeRect(x, 1, Math.max(2, right - x), 178); + } catch { /* Exact-time validation is shown beside the controls. */ } +} +function setSelection(start, end) { + const total = state.input.duration; + start = Math.max(0, Math.min(start, total - Math.min(.001, total))); + end = Math.min(total, start + 120, Math.max(start + Math.min(.001, total), end)); + $('selection-start').value = formatTime(Math.floor(start * 1000) / 1000); + $('selection-end').value = end === total ? String(total) : formatTime(Math.floor(end * 1000) / 1000); + changed(); +} +let drag = null; +function waveTime(event) { + const box = $('waveform').getBoundingClientRect(); + return state.view[0] + Math.max(0, Math.min(1, (event.clientX - box.left) / box.width)) * (state.view[1] - state.view[0]); +} +$('waveform').addEventListener('pointerdown', event => { + if (!state.input || state.expired) return; + drag = {time: waveTime(event), x: event.clientX}; + $('waveform').setPointerCapture(event.pointerId); +}); +$('waveform').addEventListener('pointermove', event => { + if (!drag || !state.input || Math.abs(event.clientX - drag.x) < 3) return; + const time = waveTime(event); + setSelection(Math.min(time, drag.time), Math.max(time, drag.time)); +}); +$('waveform').addEventListener('pointerup', event => { + if (!drag || !state.input) return; + const time = waveTime(event); + if (Math.abs(event.clientX - drag.x) < 3) { + const start = Math.min(time, Math.max(0, state.input.duration - 30)); setSelection(start, Math.min(start + 30, state.input.duration)); + } else setSelection(Math.min(time, drag.time), Math.max(time, drag.time)); + drag = null; +}); +$('waveform').addEventListener('pointercancel', () => { drag = null; }); +new ResizeObserver(drawWave).observe($('waveform')); + +async function submit(kind, snapshot, intent = {}) { + const job = await api('/api/jobs', {kind, ...snapshot}); + state.handled.delete(job.id); + state.intents.set(job.id, intent); state.jobs.set(job.id, job); renderJob(job); + return job; +} +function settingsDetails(snapshot) { + const details = node('details'); details.append(node('summary', 'Submitted snapshot'), node('pre', JSON.stringify(snapshot, null, 2))); return details; +} +function renderJob(job) { + let card = state.cards.get(job.id); + if (!card) { + const li = node('li', undefined, 'job'), heading = node('div', undefined, 'job-heading'); + const title = node('strong', `${job.kind.toUpperCase()} · ${job.snapshot.path}`, 'job-title'); + const cancel = node('button', 'Cancel'); cancel.type = 'button'; + cancel.addEventListener('click', async () => { + cancel.disabled = true; + try { const data = await api(`/api/jobs/${job.id}/cancel`, {}); state.jobs.set(job.id, data); renderJob(data); } + catch (e) { cancel.disabled = false; error(e); } + }); + heading.append(title, cancel); + const status = node('p'), progress = node('progress'); + progress.setAttribute('aria-label', `${job.kind} current-stage progress`); + const restore = node('button', 'Restore snapshot & render again'); restore.type = 'button'; restore.hidden = true; + restore.addEventListener('click', () => restoreComparison({snapshot: job.snapshot}).catch(error)); + const loadWave = node('button', 'Use overview for this recording'); loadWave.type = 'button'; loadWave.hidden = true; + loadWave.addEventListener('click', () => { + const latest = state.jobs.get(job.id); + if (state.input?.input_id !== latest.snapshot.input_id || state.input?.path !== latest.snapshot.path) return error(new Error('Re-select this recording first.')); + state.wave = latest.result; $('wave-status').textContent = 'Overview ready · 2,048 peak bins, no full-file audio loaded.'; drawWave(); + }); + li.append(heading, status, progress, restore, loadWave, settingsDetails(job.snapshot)); + $('jobs').prepend(li); card = {li, status, progress, cancel, restore, loadWave}; state.cards.set(job.id, card); + } + const cancelling = job.cancel_requested && !terminal(job); + card.status.textContent = `${cancelling ? 'Cancelling…' : job.status}${job.cached ? ' · cached result' : ''} · ${job.progress.stage || ''}${job.error ? ' · ' + job.error : ''}`; + card.status.classList.toggle('failed', job.status === 'failed'); + card.cancel.hidden = terminal(job); card.cancel.disabled = cancelling; + card.progress.hidden = terminal(job); + if (job.progress.total > 0 && job.progress.completed !== null) { + card.progress.max = job.progress.total; card.progress.value = job.progress.completed; + } else card.progress.removeAttribute('value'); + card.restore.hidden = job.kind !== 'preview' || !terminal(job); + card.loadWave.hidden = job.kind !== 'scan' || job.status !== 'succeeded'; +} +async function handleCompleted(job) { + if (!terminal(job) || state.handled.has(job.id)) return; + state.handled.add(job.id); + const intent = state.intents.get(job.id); state.intents.delete(job.id); + if (job.status !== 'succeeded') { + if (intent && job.status === 'failed') error(new Error(`${job.kind} failed: ${job.error || 'See Activity for details.'}`)); + if (job.kind === 'scan' && intent?.inputEpoch === state.inputEpoch) $('wave-status').textContent = 'Overview not completed. Re-select & scan to retry; exact times remain available.'; + return; + } + if (job.kind === 'export') { showExport(job); return; } + if (job.kind === 'scan' && intent?.inputEpoch === state.inputEpoch && state.input?.input_id === job.snapshot.input_id) { + state.wave = job.result; $('wave-status').textContent = 'Overview ready · 2,048 peak bins, no full-file audio loaded.'; drawWave(); + } + if (job.kind === 'preview') { + let current; try { current = currentSnapshot(); } catch { /* No valid visible selection. */ } + if (intent?.version === state.version && matches(job.snapshot, current)) await loadPreview(job, intent.version); + else if (intent) state.cards.get(job.id).status.textContent += ' · Older snapshot; not loaded because the controls or passage changed.'; + } +} +async function poll() { + if (state.expired) return; + try { + // Only reconcile objects unchanged since this request began. A delayed + // list must not erase a later submission or undo a cancellation response. + const before = new Map(state.jobs); + const {jobs} = await api('/api/jobs'); + $('connection').textContent = 'Connected to your local studio. Audio stays on this machine.'; + const ids = new Set(jobs.map(job => job.id)); + for (const [id, card] of state.cards) if (!ids.has(id) && before.has(id) && state.jobs.get(id) === before.get(id)) { card.li.remove(); state.cards.delete(id); state.jobs.delete(id); state.handled.delete(id); state.intents.delete(id); state.exportsShown.delete(id); } + for (const job of jobs) { + const previous = state.jobs.get(job.id); + if (previous && previous !== before.get(job.id)) continue; + state.jobs.set(job.id, job); + if (!previous || stable(previous) !== stable(job) || !state.cards.has(job.id)) renderJob(job); + // Audio requests run independently of polling, guarded again at publication. + handleCompleted(job).catch(error); + } + const active = [...state.jobs.values()].filter(job => !terminal(job)); + $('jobs-status').textContent = active.length ? `${active.length} job(s) active or waiting. ${active.find(job => job.status === 'running')?.progress.stage || 'Waiting for worker'}.` : `${state.jobs.size} recent job(s) · nothing running.`; + } catch (e) { + if (!state.expired) $('connection').textContent = 'Connection interrupted. Retrying… Keep the Producer terminal running; jobs may still be active. Do not resubmit an export until you check Activity.'; + else error(e); + } finally { if (!state.expired) setTimeout(poll, 750); } +} + +function pause() { + state.playbackEpoch++; + $('audio-original').pause(); $('audio-processed').pause(); $('play').textContent = 'Play'; +} +function clearMedia() { + pause(); + for (const side of ['original', 'processed']) { const audio = $(`audio-${side}`); audio.removeAttribute('src'); audio.load(); } + for (const url of state.urls) URL.revokeObjectURL(url); + state.urls = []; +} +async function loadPreview(job, version) { + const epoch = ++state.mediaEpoch; + state.mediaAbort?.abort(); const controller = new AbortController(); state.mediaAbort = controller; + $('comparison-status').textContent = 'Loading bounded comparison audio…'; + const paths = ['original', 'processed'].map(side => job.result.audio[side]); + if (paths.some(path => !/^\/api\/results\/[a-f0-9]+\/(original|processed)\.wav$/.test(path))) { + updateComparisonStatus(); throw new Error('Invalid preview media path.'); + } + let blobs; + try { blobs = await Promise.all(paths.map(path => api(path, undefined, 'blob', controller.signal))); } + catch (e) { if (epoch === state.mediaEpoch) updateComparisonStatus(); throw e; } + if (epoch !== state.mediaEpoch || version !== state.version || !matches(job.snapshot, currentSnapshot())) return; + clearMedia(); + state.preview = copy(job); state.side = 'original'; + state.urls = blobs.map(blob => URL.createObjectURL(blob)); + ['original', 'processed'].forEach((side, i) => { const audio = $(`audio-${side}`); audio.src = state.urls[i]; audio.loop = $('loop').checked; audio.load(); }); + $('player').disabled = false; + $('match').checked = false; + $('match').disabled = !job.result.monitoring.available || !(window.AudioContext || window.webkitAudioContext); + $('playback-seek').max = job.snapshot.duration; $('playback-seek').value = 0; + $('comparison-source').textContent = `${job.snapshot.path} · ${formatTime(job.snapshot.start)}–${formatTime(job.snapshot.start + job.snapshot.duration)} · render ${job.id.slice(0, 8)} · ${new Date(job.created * 1000).toLocaleTimeString()}`; + $('preview-warnings').textContent = (job.result.warnings || []).join(' '); + $('preview-snapshot').hidden = false; + $('preview-settings').textContent = JSON.stringify({requested: job.snapshot.settings, preview_only_differences: {trim_silence: false, mp3: false}, normalization_mode: job.result.normalization_mode}, null, 2); + updateAB(); updateGains(); updatePlayback(); updateComparisonStatus(); updateAvailability(); renderMeters(job.result); +} +function renderMeters(result) { + const meters = $('meters'); meters.replaceChildren(); meters.hidden = false; + const number = value => Number.isFinite(value) ? value.toFixed(1) : 'Unavailable'; + for (const [side, title] of [['original', 'Original snippet'], ['processed', 'Processed snippet']]) { + const data = result[`${side}_loudness`], meter = node('div', undefined, 'meter'); + meter.append(node('strong', title + ' · measured file level'), node('p', number(data.input_i) + ' LUFS', 'level'), + node('p', number(data.input_tp) + ' dBTP · measured true peak'), node('p', 'Before monitoring gain; not a full-export measurement.')); + meters.append(meter); + } +} +function ensureAudioContext() { + if (!state.context) { + const AudioContext = window.AudioContext || window.webkitAudioContext; + if (!AudioContext) return; + state.context = new AudioContext(); + for (const side of ['original', 'processed']) { + const source = state.context.createMediaElementSource($(`audio-${side}`)); + const gain = state.context.createGain(); source.connect(gain).connect(state.context.destination); state.gains[side] = gain; + } + } + return state.context.resume(); +} +function updateGains() { + const monitoring = state.preview?.result.monitoring; + if (!monitoring) return; + const enabled = $('match').checked && monitoring.available; + for (const side of ['original', 'processed']) if (state.gains[side]) state.gains[side].gain.value = monitoringGain(monitoring[`${side}_gain_db`], enabled); + $('monitoring-note').textContent = enabled ? `Monitoring only: Original ${monitoring.original_gain_db.toFixed(1)} dB; processed ${monitoring.processed_gain_db.toFixed(1)} dB. Attenuation only; export settings and file meters are unchanged.` : + `Matching off. ${monitoring.available ? 'Hear the rendered file levels. Matching attenuates to the quieter snippet and never changes exports.' : monitoring.reason} ${$('match').disabled && monitoring.available ? 'This browser does not support matched monitoring.' : ''}`; +} +function updateAB() { + $('listen-original').setAttribute('aria-pressed', state.side === 'original'); + $('listen-processed').setAttribute('aria-pressed', state.side === 'processed'); +} +function updatePlayback() { + const audio = $(`audio-${state.side}`); + $('play').textContent = audio.paused ? 'Play' : 'Pause'; + $('playback-seek').value = audio.currentTime || 0; + $('playback-time').textContent = `${formatTime(audio.currentTime || 0)} / ${formatTime(state.preview?.snapshot.duration || 0)} · ${state.side === 'original' ? 'A · Original' : 'B · Processed'}`; +} +async function switchSide(side) { + if (!state.preview || state.side === side) return; + const old = $(`audio-${state.side}`), next = $(`audio-${side}`), position = old.currentTime, playing = !old.paused; + pause(); state.side = side; next.currentTime = position; updateAB(); updatePlayback(); + if (playing) await next.play(); +} +for (const side of ['original', 'processed']) { + const audio = $(`audio-${side}`); + for (const event of ['timeupdate', 'play', 'pause', 'ended', 'loadedmetadata']) audio.addEventListener(event, () => { if (state.side === side) updatePlayback(); }); + audio.addEventListener('error', () => { if (audio.getAttribute('src')) error(new Error('Browser audio playback failed. Render again, or try a current desktop browser.')); }); +} + +function loadSaved() { + try { + const saved = JSON.parse(localStorage.getItem(state.storageKey) || '{}'); + state.presets = Array.isArray(saved.presets) ? saved.presets.slice(0, 20) : []; + state.comparisons = Array.isArray(saved.comparisons) ? saved.comparisons.slice(0, 12) : []; + } catch { error(new Error('Browser storage is unavailable or unreadable. You can still work and download TOML presets.')); } + renderSaved(); +} +function persist() { + try { localStorage.setItem(state.storageKey, JSON.stringify({presets: state.presets, comparisons: state.comparisons})); } + catch { throw new Error('Could not save in browser storage. Use Download settings TOML for portable presets.'); } +} +function renderSaved() { + for (const [key, target] of [['presets', 'presets'], ['comparisons', 'comparisons']]) { + $(target).replaceChildren(); + for (const entry of state[key]) { + const li = node('li'), title = node('div', entry.name, 'saved-title'); + if (key === 'comparisons') title.append(node('div', `${entry.snapshot.path} · ${formatTime(entry.snapshot.start)} · ${entry.snapshot.duration}s`, 'small')); + const restore = node('button', key === 'presets' ? 'Load settings' : 'Restore & render'); restore.type = 'button'; + restore.addEventListener('click', async () => { + restore.disabled = true; + try { + if (key === 'comparisons') await restoreComparison(entry); + else { + const version = state.version; + const data = await api('/api/resolve', {settings: entry.settings}); + if (version !== state.version) throw new Error('Controls changed while loading. Try again.'); + setSettings(data.settings); changed(); + } + } catch (e) { error(e); } finally { restore.disabled = false; } + }); + const remove = node('button', 'Delete'); remove.type = 'button'; remove.setAttribute('aria-label', `Delete ${entry.name}`); + remove.addEventListener('click', () => { + const previous = state[key]; state[key] = previous.filter(item => item !== entry); + try { persist(); renderSaved(); } catch (e) { state[key] = previous; error(e); } + }); + li.append(title, restore, remove); $(target).append(li); + } + } +} +async function restoreComparison(entry) { + const snapshot = copy(entry.snapshot), version = state.version; + const [info, resolved] = await Promise.all([api(`/api/input?path=${encodeURIComponent(snapshot.path)}`), api('/api/resolve', {settings: snapshot.settings})]); + if (version !== state.version) throw new Error('Controls changed while restoring. Try again.'); + if (info.input_id !== snapshot.input_id) throw new Error('This source file changed since the snapshot. Re-select it and make a new comparison.'); + state.inputEpoch++; state.input = info; state.wave = null; state.view = [0, info.duration]; + $('input-name').textContent = info.path; $('input-info').textContent = `${formatTime(info.duration)} · mono · ${info.sample_rate.toLocaleString()} Hz · ${info.subtype}`; + setSettings(resolved.settings); + $('selection-start').value = String(snapshot.start); $('selection-end').value = String(snapshot.start + snapshot.duration); + changed(); updateAvailability(); + $('wave-status').textContent = 'Scanning restored recording…'; + const restoredVersion = state.version; + await submit('scan', {path: info.path, input_id: info.input_id}, {inputEpoch: state.inputEpoch}); + // A later user edit while submitting the scan must not trigger an unintended preview. + const restored = {...snapshot, settings: resolved.settings}; + if (restoredVersion === state.version && matches(restored, currentSnapshot())) await submit('preview', restored, {version: restoredVersion}); +} +async function savePreset() { + const name = $('preset-name').value.trim(); + if (!name) throw new Error('Name your preset first.'); + if (state.presets.length >= 20) throw new Error('Delete an old preset first (20 maximum).'); + const settings = readSettings(); + const data = await api('/api/resolve', {settings}); + const entry = {name, settings: copy(data.settings)}; + state.presets.push(entry); + try { persist(); } catch (e) { state.presets.pop(); throw e; } + renderSaved(); $('preset-name').value = ''; +} +function saveComparison() { + const name = $('comparison-name').value.trim(); + if (!name || !state.preview) throw new Error('Render a comparison and give it a name first.'); + if (state.comparisons.length >= 12) throw new Error('Delete an old comparison first (12 maximum).'); + state.comparisons.push({name, snapshot: copy(state.preview.snapshot)}); + try { persist(); } catch (e) { state.comparisons.pop(); throw e; } + renderSaved(); $('comparison-name').value = ''; +} +function download(blob) { + const url = URL.createObjectURL(blob), link = node('a'); link.href = url; link.download = 'producer-preset.toml'; + document.body.append(link); link.click(); link.remove(); setTimeout(() => URL.revokeObjectURL(url), 30000); +} +async function prepareExport() { + if (!state.input) throw new Error('Choose a recording first.'); + const version = state.version, input = copy(state.input), settings = readSettings(), output_dir = $('output-dir').value.trim(); + const data = await api('/api/resolve', {settings}); + if (version !== state.version) throw new Error('Controls changed while reviewing export. Review again.'); + state.export = {kind: 'export', path: input.path, input_id: input.input_id, settings: copy(data.settings), output_dir}; + $('export-summary').textContent = `${input.path} · whole original ${formatTime(input.duration)}. Destination: ${absolutePath(state.schema.root, output_dir)}/producer-export-…/master.wav${data.settings.mp3 ? ' + master.mp3' : ''} and logs/master.report.json. These are the CURRENT controls, not necessarily the last comparison.`; + $('export-pacing').textContent = data.settings.trim_silence ? 'Pacing edits are ON. Long gaps will be shortened and duration will change. These timing edits were NOT auditioned in the sound comparison.' : 'Pacing edits are OFF. Sound comparison is still approximate: full-recording normalization and inference context can differ.'; + $('export-settings').textContent = JSON.stringify(state.export, null, 2); + $('export-dialog').showModal(); +} +function showExport(job) { + if (state.exportsShown.has(job.id)) return; + state.exportsShown.add(job.id); + const result = node('div', undefined, 'export-result'); + result.append(node('h3', 'Full recording exported'), node('p', `${job.snapshot.path} · job ${job.id.slice(0, 8)} · ${new Date(job.created * 1000).toLocaleString()}. Settings are frozen in the submitted snapshot below.`), node('p', 'Find these persistent files on this machine. No browser download is needed:')); + const paths = node('ul'); + for (const path of job.result.paths) { const li = node('li'); li.append(node('code', absolutePath(state.schema.root, path))); paths.append(li); } + result.append(paths, node('p', [...(job.result.warnings || []), job.result.notice].filter(Boolean).join(' ')), settingsDetails(job.snapshot)); + $('export-results').prepend(result); + while ($('export-results').children.length > 24) $('export-results').lastElementChild.remove(); +} + +// Explicit actions only; settings input events merely invalidate the current render. +action('dismiss-error', () => { $('error').hidden = true; }); +action('folder-up', () => browse(state.parent || '.')); +action('folder-refresh', () => browse(state.folder)); +action('files-more', () => browse(state.folder, state.next)); +action('use-output-folder', () => { $('output-dir').value = state.folder; }); +action('rescan', () => chooseInput(state.input.path)); +action('zoom-selection', () => { + const value = selection($('selection-start').value, $('selection-end').value, state.input.duration); + const padding = Math.max(5, value.duration / 2); + state.view = [Math.max(0, value.start - padding), Math.min(state.input.duration, value.start + value.duration + padding)]; drawWave(); +}); +action('zoom-full', () => { state.view = [0, state.input.duration]; drawWave(); }); +for (const id of ['selection-start', 'selection-end']) $(id).addEventListener('input', changed); +action('reset-launch', () => { if (confirm('Replace all controls with the settings resolved when Producer launched?')) { setSettings(state.schema.settings); changed(); } }); +action('render-preview', async () => { + const snapshot = currentSnapshot(true), version = state.version; + pause(); await submit('preview', snapshot, {version}); + $('jobs-panel').scrollIntoView({block: 'nearest'}); +}); +action('listen-original', () => switchSide('original')); +action('listen-processed', () => switchSide('processed')); +action('play', async () => { + const audio = $(`audio-${state.side}`); + if (!audio.paused) pause(); + else { + const epoch = ++state.playbackEpoch, side = state.side; + await ensureAudioContext(); + if (epoch !== state.playbackEpoch || side !== state.side || state.expired) return; + updateGains(); await audio.play(); + } +}); +$('loop').addEventListener('change', () => { for (const side of ['original', 'processed']) $(`audio-${side}`).loop = $('loop').checked; }); +$('match').addEventListener('change', async () => { try { await ensureAudioContext(); updateGains(); } catch (e) { $('match').checked = false; updateGains(); error(e); } }); +$('playback-seek').addEventListener('input', () => { $(`audio-${state.side}`).currentTime = Number($('playback-seek').value); updatePlayback(); }); +action('save-preset', savePreset); +action('download-preset', async () => download(await api('/api/presets/download', {settings: readSettings()}, 'blob'))); +$('import-preset').addEventListener('change', async () => { + const file = $('import-preset').files[0], version = state.version; + try { + if (!file) return; + if (file.size > 60000) throw new Error('Preset is too large. Choose a small TOML file (under 60 KB).'); + const toml = await file.text(); + if (new TextEncoder().encode(JSON.stringify({toml})).length > 65536) throw new Error('Encoded preset exceeds the 64 KiB API limit.'); + const data = await api('/api/presets/import', {toml}); + if (version !== state.version) throw new Error('Controls changed during import. Import again.'); + setSettings(data.settings); changed(); + } catch (e) { error(e); } finally { $('import-preset').value = ''; } +}); +action('save-comparison', saveComparison); +action('prepare-export', prepareExport); +action('cancel-export', () => { state.export = null; $('export-dialog').close(); }); +$('export-dialog').addEventListener('cancel', () => { state.export = null; }); +action('confirm-export', async () => { + const snapshot = state.export; + if (!snapshot) return; + // Clear the confirmation even on network ambiguity; never blindly retry exports. + state.export = null; $('export-dialog').close(); + await submit('export', snapshot); + $('jobs-panel').scrollIntoView({block: 'nearest'}); +}); +window.addEventListener('beforeunload', () => { state.mediaAbort?.abort(); clearMedia(); }); + +async function init() { + if (!token) throw new Error('Open the private URL printed by ./producer.sh gui on this machine. No credential was found in this tab.'); + state.schema = await api('/api/schema'); + if (state.schema.api_version !== 1) throw new Error('This browser interface needs API version 1. Restart Producer with matching packaged assets.'); + $('root-path').textContent = state.schema.root; + state.storageKey = 'producer-studio-v1:' + state.schema.root; + buildSettings(); loadSaved(); + $('workspace').disabled = false; + await browse(); + poll(); +} +init().catch(e => { $('connection').textContent = 'Studio could not connect. Check the message below and reopen the printed local URL.'; error(e); }); diff --git a/lib/project/src/producer/web/index.html b/lib/project/src/producer/web/index.html new file mode 100644 index 0000000..9808e2e --- /dev/null +++ b/lib/project/src/producer/web/index.html @@ -0,0 +1,73 @@ +<!doctype html> +<html lang="en"> +<head> + <meta charset="utf-8"> + <meta name="viewport" content="width=device-width, initial-scale=1"> + <meta name="referrer" content="no-referrer"> + <title>Producer · Local voice studio</title> + <link rel="icon" href="data:,"> + <link rel="stylesheet" href="/assets/style.css"> + <script type="module" src="/assets/app.js"></script> +</head> +<body> +<header class="masthead"><div><span class="brand-mark" aria-hidden="true">p.</span><strong>Producer</strong><span class="tagline">A little care for your voice.</span></div><span class="local-badge">● Local audio · private by design</span></header> +<main> + <div class="intro"><p class="eyebrow">YOUR RECORDING, REFINED</p><h1>Make it sound like you.<br><span>Just easier to listen to.</span></h1><p>Try a small passage. Listen for the difference. Keep what works.</p></div> + <nav class="steps" aria-label="Studio steps"><a href="#choose">1 <span>Choose recording</span></a><a href="#try">2 <span>Try a short passage</span></a><a href="#compare">3 <span>Compare sound</span></a><a href="#export">4 <span>Export full recording</span></a></nav> + <div id="connection" role="status">Connecting to your local Producer…</div> + <div id="error" class="error" role="alert" hidden><span id="error-text"></span><button id="dismiss-error" type="button">Dismiss</button></div> + <noscript>This local studio needs JavaScript. Enable it and reopen the private URL printed by Producer.</noscript> + <fieldset id="workspace" disabled> + <legend class="sr-only">Local voice studio</legend> + <section id="choose" class="panel"> + <div class="section-heading"><span class="step-number">01</span><div><h2>Choose your recording</h2><p>Browse existing mono WAV files. Nothing is uploaded or overwritten.</p></div></div> + <details id="file-browser" open><summary>Browse local recordings</summary> + <p class="small">Allowed folder: <code id="root-path"></code></p> + <div class="toolbar"><button id="folder-up" type="button">↑ Parent folder</button><button id="folder-refresh" type="button">Refresh</button><code id="folder-path">.</code><button id="use-output-folder" type="button">Use this folder for exports</button></div> + <ul id="files" class="file-list" aria-label="Folders and WAV recordings"></ul><button id="files-more" type="button" hidden>Show more files</button><p id="files-status" class="small" role="status"></p> + </details> + <div class="recording"><div><span class="eyebrow">SELECTED RECORDING</span><h3 id="input-name">No recording yet</h3><p id="input-info">Choose a WAV above to begin.</p></div><button id="rescan" type="button" disabled>Re-select & scan</button></div> + </section> + <section id="try" class="panel"> + <div class="section-heading"><span class="step-number">02</span><div><h2>Try a short passage</h2><p>Include a quiet phrase, a breath and a pause. Listen for words that sound softened or cut off.</p></div></div> + <div class="toolbar"><strong>Recording overview</strong><span id="wave-status" class="small">Select a recording first.</span><button id="zoom-selection" type="button" disabled>Zoom around passage</button><button id="zoom-full" type="button" disabled>Whole recording</button></div> + <canvas id="waveform" width="1200" height="180" role="img" aria-label="Recording waveform. Click or drag to select a passage, or use the exact time fields below."></canvas> + <div class="wave-labels"><span id="wave-start">00:00:00</span><span id="wave-end">00:00:00</span></div> + <p class="small">Click for a 30-second passage, or drag up to 120 seconds. Zoom enlarges the same coarse overview; use exact times for fine adjustments.</p> + <div class="selection-fields"><label>Start · HH:MM:SS.sss<input id="selection-start" type="text" inputmode="decimal" value="00:00:00" spellcheck="false" aria-describedby="selection-note"></label><label>End · HH:MM:SS.sss<input id="selection-end" type="text" inputmode="decimal" value="00:00:30" spellcheck="false" aria-describedby="selection-note"></label><p id="selection-note" role="status">Up to 120 seconds. You can also enter seconds directly.</p></div> + <div class="settings-heading"><div><h3>Shape the sound</h3><p>Small changes are often enough. Controls never render automatically.</p></div><button id="reset-launch" type="button">Restore launch settings</button></div> + <div id="primary-settings" class="primary-settings"></div> + <details id="advanced-settings"><summary>All settings & advanced controls <span class="small">Fine-tune without hidden overrides</span></summary><p class="small">Turning an effect off keeps its values for later. Constraints are checked when you render, save or export; dependent effects are never silently switched on.</p><div id="settings-groups"></div><p id="constraints" class="small"></p></details> + <details id="presets-panel"><summary>Settings presets <span class="small">Save a sound, not audio</span></summary> + <div class="toolbar"><label>Preset name<input id="preset-name" type="text" maxlength="80" placeholder="Gentle narration"></label><button id="save-preset" type="button">Save settings locally</button><button id="download-preset" type="button">Download settings TOML</button><label class="file-import">Import TOML<input id="import-preset" type="file" accept=".toml,text/plain,application/toml"></label></div><ul id="presets" class="saved-list"></ul><p class="small">Up to 20 named presets in this browser, for this root folder. TOML is portable. Imports replace controls with standalone preset settings, not hidden launch overrides.</p> + </details> + <div class="action-row"><button id="render-preview" class="primary" type="button" disabled>Render sound comparison <span aria-hidden="true">→</span></button><span class="small">Only this passage + up to 10 seconds of context per side.<br>First AI use downloads local models; audio never leaves this machine.</span></div> + </section> + <section id="compare" class="panel"> + <div class="section-heading"><span class="step-number">03</span><div><h2>Compare the sound, not the volume</h2><p>Switch between the same moment. Start at a comfortable listening volume.</p></div></div> + <p class="notice">Approximate sound comparison, not an exact export preview. Chapter normalization and inference context differ. Pacing edits are not previewed; this comparison preserves timing.</p> + <p id="comparison-status" class="comparison-status" role="status">Render a passage to listen here.</p> + <p id="comparison-source" class="small"></p> + <fieldset id="player" disabled><legend class="sr-only">Passage playback</legend> + <div class="player-buttons"><div class="ab-switch" role="group" aria-label="Listen to"><button id="listen-original" type="button" aria-pressed="true">A · Original</button><button id="listen-processed" type="button" aria-pressed="false">B · Processed</button></div><button id="play" class="primary" type="button">Play</button><label class="check"><input id="loop" type="checkbox" checked>Loop passage</label><label class="check"><input id="match" type="checkbox">Match listening loudness</label></div> + <label class="seek-label">Passage playback position<input id="playback-seek" type="range" min="0" max="30" step="0.01" value="0"></label><p id="playback-time" class="small">00:00:00 / 00:00:00</p><p id="monitoring-note" class="small">Matching only attenuates playback. It never changes your saved settings or export.</p> + </fieldset> + <audio id="audio-original" preload="metadata"></audio><audio id="audio-processed" preload="metadata"></audio> + <div id="meters" class="meters" hidden></div><p id="preview-warnings" class="small"></p> + <details id="preview-snapshot" hidden><summary>Exact requested settings & preview differences</summary><p>Gap trimming and MP3 encoding are disabled only for this timing-preserving WAV comparison. Full export uses the requested settings below.</p><pre id="preview-settings"></pre></details> + <div class="toolbar"><label>Name this comparison<input id="comparison-name" type="text" maxlength="80" placeholder="Less noise, softer touch"></label><button id="save-comparison" type="button" disabled>Save comparison snapshot</button></div><ul id="comparisons" class="saved-list"></ul><p class="small">Up to 12 immutable snapshots saved in this browser (source identity, times and requested settings; no audio). Restore & render to listen again. Cached audio can expire; changed source files must be re-selected.</p> + </section> + <section id="export" class="panel"> + <div class="section-heading"><span class="step-number">04</span><div><h2>Export the full recording</h2><p>Happy with the sound? Apply the current controls to the whole original, not just your passage.</p></div></div> + <div id="delivery-settings" class="delivery-settings"></div> + <label class="output-label">Existing output folder · relative to allowed folder<input id="output-dir" type="text" value="." spellcheck="false"></label><p class="small">Use “Use this folder for exports” in the local browser above, or enter an existing relative folder. Each export gets a fresh private folder. Originals stay untouched.</p> + <div class="action-row"><button id="prepare-export" class="primary" type="button" disabled>Review full-recording export →</button><span class="small">WAV + optional MP3 and a processing report.<br>No delivery-platform certification or quality score.</span></div> + <div id="export-results" aria-label="Completed exports"></div> + </section> + <section class="panel jobs-panel" id="jobs-panel"><div class="section-heading"><div><h2>Activity</h2><p>One job at a time · up to three waiting · progress is per stage, not overall.</p></div></div><p id="jobs-status" role="status">No work submitted yet.</p><p class="small">Cancellation waits for a safe progress point. A model download can take up to 60 seconds to respond. Files already published may remain.</p><ul id="jobs" class="jobs-list"></ul></section> + </fieldset> + <footer>Made for listening, not chasing a score. Local processing · no telemetry · no cloud audio.<br>Leave this server running while you work. Stop it in the terminal with Ctrl-C.</footer> +</main> +<dialog id="export-dialog" aria-labelledby="export-title"><h2 id="export-title">Export the whole original?</h2><p id="export-summary"></p><p id="export-pacing" class="notice"></p><p>These files are saved on this machine, not downloaded into your browser. A new folder prevents overwriting earlier exports. Monitoring gains are never included.</p><details><summary>Frozen export settings</summary><pre id="export-settings"></pre></details><div class="action-row"><button id="confirm-export" class="primary" type="button">Export full recording</button><button id="cancel-export" type="button">Go back</button></div></dialog> +</body> +</html> diff --git a/lib/project/src/producer/web/state.js b/lib/project/src/producer/web/state.js new file mode 100644 index 0000000..8c1f9a6 --- /dev/null +++ b/lib/project/src/producer/web/state.js @@ -0,0 +1,36 @@ +// Pure helpers shared by the studio and its small Node test suite. +export function parseTime(text) { + const value = text.trim(); + if (/^\d+(?:\.\d+)?$/.test(value)) return Number(value); + const parts = /^(\d+):([0-5]\d):([0-5]\d(?:\.\d+)?)$/.exec(value); + if (!parts) throw new Error('Use HH:MM:SS.sss (for example 01:23:45), or seconds.'); + return Number(parts[1]) * 3600 + Number(parts[2]) * 60 + Number(parts[3]); +} +export function formatTime(seconds) { + const ms = Math.max(0, Math.round(seconds * 1000)); + const h = Math.floor(ms / 3600000), m = Math.floor(ms / 60000) % 60; + const s = Math.floor(ms / 1000) % 60, fraction = ms % 1000; + return [h, m, s].map(n => String(n).padStart(2, '0')).join(':') + + (fraction ? '.' + String(fraction).padStart(3, '0') : ''); +} +export function selection(startText, endText, duration) { + const start = parseTime(startText), end = parseTime(endText); + if (!Number.isFinite(start) || !Number.isFinite(end) || start < 0 || end <= start || end > duration || end - start > 120) + throw new Error('Choose a passage inside the recording, longer than 0 and no more than 120 seconds.'); + return {start, duration: end - start}; +} +export function stable(value) { + if (Array.isArray(value)) return '[' + value.map(stable).join(',') + ']'; + if (value && typeof value === 'object') return '{' + Object.keys(value).sort().map(k => JSON.stringify(k) + ':' + stable(value[k])).join(',') + '}'; + return JSON.stringify(value); +} +export function matches(snapshot, current) { + return Boolean(current && snapshot.path === current.path && snapshot.input_id === current.input_id && + snapshot.start === current.start && snapshot.duration === current.duration && stable(snapshot.settings) === stable(current.settings)); +} +export function monitoringGain(db, enabled) { + return enabled && Number.isFinite(db) ? 10 ** (Math.min(0, db) / 20) : 1; +} +export function absolutePath(root, relative) { + return root.replace(/\/$/, '') + '/' + relative.replace(/^\.\//, ''); +} diff --git a/lib/project/src/producer/web/style.css b/lib/project/src/producer/web/style.css new file mode 100644 index 0000000..935b51c --- /dev/null +++ b/lib/project/src/producer/web/style.css @@ -0,0 +1,710 @@ +:root { + --paper:#f6f7f2; + --ink:#25382e; + --muted:#59685e; + --green:#315c46; + --mint:#eaf1e9; + --line:#d9e1d6; + --focus:#bb7925 +} +* { + box-sizing:border-box +} +html { + scroll-behavior:smooth; + scroll-padding-top:24px +} +body { + margin:0; + background:var(--paper); + color:var(--ink); + font:16px/1.55 system-ui,-apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif +} +button,input,select { + font:inherit +} +button { + cursor:pointer; + border:1px solid var(--line); + border-radius:8px; + background:#fff; + padding:10px 15px; + color:var(--ink); + font-weight:600; + line-height:1.3 +} +button:hover:not(:disabled) { + border-color:var(--green); + background:var(--mint) +} +button:disabled { + opacity:.5; + cursor:not-allowed +} +button.primary { + background:var(--green); + color:#fff; + border-color:var(--green) +} +button.primary:hover:not(:disabled) { + background:#214637 +} +button:focus-visible,a:focus-visible,summary:focus-visible,input:focus-visible,select:focus-visible { + outline:3px solid var(--focus); + outline-offset:3px +} +input:not([type=checkbox]):not([type=range]),select { + display:block; + width:100%; + min-height:43px; + padding:8px 10px; + border:1px solid #aebbb0; + border-radius:6px; + background:#fff; + color:var(--ink) +} +input[type=checkbox] { + width:18px; + height:18px; + accent-color:var(--green); + flex-shrink:0 +} +input[type=range] { + accent-color:var(--green); + width:100% +} +input:invalid { + border-color:#9f3c27 +} +label { + font-size:.9rem; + font-weight:600 +} +h1,h2,h3,p { + margin-top:0 +} +h1 { + font-size:clamp(2rem,4.5vw,3.55rem); + letter-spacing:-.055em; + line-height:1.13; + font-weight:650; + margin-bottom:20px +} +h1 span { + color:var(--muted) +} +h2 { + font-size:1.5rem; + letter-spacing:-.035em; + margin-bottom:5px +} +h3 { + font-size:1.05rem; + margin-bottom:7px +} +p { + margin-bottom:14px +} +code,pre { + font-family:ui-monospace,SFMono-Regular,Consolas,monospace; + font-size:.82rem; + overflow-wrap:anywhere +} +pre { + white-space:pre-wrap; + max-height:320px; + overflow:auto; + padding:14px; + background:var(--paper); + border:1px solid var(--line); + border-radius:6px +} +fieldset { + border:0; + margin:0; + padding:0; + min-width:0 +} +a { + color:var(--green) +} +.masthead { + display:flex; + justify-content:space-between; + align-items:center; + padding:23px 5%; + border-bottom:1px solid var(--line); + gap:15px +} +.masthead>div { + display:flex; + align-items:center; + gap:13px +} +.brand-mark { + background:var(--green); + color:#fff; + width:35px; + height:35px; + border-radius:10px; + text-align:center; + font-size:1.6rem; + line-height:1.1; + font-weight:700 +} +.tagline { + color:var(--muted); + font-size:.85rem; + margin-left:18px +} +.local-badge { + font-size:.78rem; + color:var(--green); + background:var(--mint); + padding:6px 12px; + border-radius:99px +} +main { + max-width:1192px; + margin:0 auto; + padding:44px 32px +} +.intro p:last-child { + color:var(--muted) +} +.eyebrow { + font-size:.69rem; + letter-spacing:.14em; + font-weight:750; + color:var(--green); + margin-bottom:14px +} +.steps { + display:grid; + grid-template-columns:repeat(4,1fr); + gap:12px; + margin:28px 0 +} +.steps a { + text-decoration:none; + display:flex; + align-items:center; + gap:10px; + border-top:2px solid var(--green); + padding:12px 0; + font-weight:650; + font-size:.85rem +} +.steps a:first-letter { + font-weight:bold +} +.panel { + padding:28px; + margin:22px 0; + background:#fff; + border:1px solid var(--line); + border-radius:15px; + box-shadow:0 3px 14px #263a2610; + scroll-margin-top:22px +} +.section-heading { + display:flex; + gap:17px; + align-items:flex-start; + margin-bottom:18px +} +.section-heading p,.settings-heading p { + color:var(--muted); + font-size:.9rem; + margin:0 +} +.step-number { + display:grid; + place-items:center; + background:var(--mint); + color:var(--green); + min-width:38px; + height:38px; + border-radius:50%; + font-size:.75rem; + font-weight:700 +} +.small { + font-size:.81rem; + color:var(--muted) +} +.toolbar { + display:flex; + gap:12px; + align-items:center; + flex-wrap:wrap; + margin:16px 0 +} +.toolbar label { + min-width:190px +} +.toolbar>code { + flex:1 +} +.toolbar button { + font-size:.8rem +} +.toolbar>strong { + margin-right:auto +} +.file-list,.saved-list,.jobs-list { + list-style:none; + padding:0; + margin:12px 0 +} +.file-list { + max-height:290px; + overflow:auto; + border-top:1px solid var(--line) +} +.file-list li { + border-bottom:1px solid var(--line) +} +.file-list button { + border:0; + border-radius:0; + width:100%; + text-align:left; + font-weight:500; + padding:10px; + overflow-wrap:anywhere +} +.file-kind { + display:inline-block; + color:var(--muted); + font-size:.7rem; + min-width:65px; + font-weight:700 +} +.recording { + display:flex; + justify-content:space-between; + gap:15px; + align-items:center; + padding:18px; + background:var(--mint); + border-radius:9px; + margin-top:20px +} +.recording h3 { + overflow-wrap:anywhere +} +.recording .eyebrow { + font-size:.63rem +} +.recording p { + margin:0; + font-size:.83rem; + color:var(--muted) +} +canvas { + display:block; + width:100%; + height:180px; + background:#f0f4f0; + border:1px solid var(--line); + border-radius:8px; + touch-action:none; + cursor:crosshair +} +.wave-labels { + display:flex; + justify-content:space-between; + font-size:.75rem; + font-variant-numeric:tabular-nums; + color:var(--muted); + margin:6px 0 12px +} +.selection-fields { + display:flex; + align-items:center; + gap:18px; + flex-wrap:wrap +} +.selection-fields input { + font-variant-numeric:tabular-nums +} +.selection-fields p { + flex:1; + font-size:.8rem; + min-width:150px; + color:var(--muted); + margin:20px 0 0 +} +.settings-heading { + display:flex; + justify-content:space-between; + align-items:center; + gap:16px; + margin:28px 0 18px +} +.settings-heading button { + font-size:.8rem +} +.primary-settings { + display:grid; + grid-template-columns:repeat(4,1fr); + gap:12px; + margin:16px 0 22px +} +.sound-card { + background:var(--paper); + border:1px solid var(--line); + border-radius:10px; + padding:16px +} +.sound-card h3 { + color:var(--green) +} +.sound-card>p { + font-size:.78rem; + min-height:72px; + color:var(--muted) +} +.setting { + margin:13px 0 +} +.setting label { + display:block; + margin-bottom:5px +} +.setting label.check,.check { + display:flex; + align-items:center; + gap:8px +} +.setting p { + font-size:.75rem; + color:var(--muted); + margin:6px 0 0 +} +.setting-name { + display:block; + font-size:.68rem; + font-weight:400; + color:var(--muted); + letter-spacing:.01em +} +.settings-grid { + display:grid; + grid-template-columns:repeat(3,1fr); + gap:8px 24px +} +.settings-group { + border-top:1px solid var(--line); + padding-top:18px; + margin-top:20px +} +.settings-group h3 { + color:var(--green) +} +details { + margin-top:17px +} +summary { + cursor:pointer; + font-weight:650; + padding:10px 0; + color:var(--green) +} +summary .small { + font-weight:400; + margin-left:10px +} +.file-import input { + font-size:.75rem; + max-width:250px +} +.action-row { + display:flex; + align-items:center; + flex-wrap:wrap; + gap:18px; + margin-top:24px +} +.notice { + border-left:3px solid #b37b25; + background:#fcf5e7; + color:#6d4a19; + border-radius:0 6px 6px 0; + padding:13px 16px; + font-size:.85rem +} +.comparison-status { + font-weight:650; + font-size:.9rem +} +.comparison-status.stale { + color:#945117 +} +.player-buttons { + display:flex; + align-items:center; + gap:18px; + flex-wrap:wrap; + margin:20px 0 +} +.ab-switch { + display:flex; + border:1px solid var(--line); + border-radius:9px; + overflow:hidden +} +.ab-switch button { + border:0; + border-radius:0; + padding:13px 20px +} +.ab-switch button[aria-pressed=true] { + background:var(--green); + color:#fff +} +.seek-label { + font-size:.8rem; + display:block +} +.meters { + display:grid; + grid-template-columns:1fr 1fr; + gap:14px; + margin:20px 0 +} +.meter { + border:1px solid var(--line); + border-radius:9px; + padding:16px; + background:var(--paper) +} +.meter strong { + display:block; + font-size:.85rem +} +.meter p { + margin:7px 0 0; + font-size:.8rem +} +.meter .level { + font-size:1.5rem; + font-variant-numeric:tabular-nums; + letter-spacing:-.04em; + color:var(--green) +} +.saved-list li,.job { + border-top:1px solid var(--line); + padding:14px 0 +} +.saved-list li { + display:flex; + align-items:center; + gap:10px; + flex-wrap:wrap +} +.saved-list .saved-title { + flex:1; + min-width:180px; + overflow-wrap:anywhere +} +.saved-list button { + font-size:.78rem +} +.delivery-settings { + display:grid; + grid-template-columns:repeat(4,1fr); + gap:20px +} +.output-label { + display:block; + max-width:640px; + margin-top:16px +} +.job-heading { + display:flex; + justify-content:space-between; + align-items:center; + gap:12px; + font-size:.86rem +} +.job-title { + overflow-wrap:anywhere +} +.job p { + margin:6px 0; + font-size:.78rem; + color:var(--muted) +} +.job button { + font-size:.75rem +} +.job progress { + width:100%; + height:10px; + accent-color:var(--green) +} +.job .failed { + color:#9f3c27 +} +.export-result { + border:1px solid var(--line); + border-left:4px solid var(--green); + padding:18px; + margin-top:20px; + border-radius:8px +} +.export-result ul { + padding-left:20px +} +.export-result li { + margin:7px 0 +} +.export-result p { + font-size:.85rem +} +.error { + display:flex; + align-items:center; + gap:20px; + justify-content:space-between; + background:#fcebe5; + border:1px solid #cc8875; + border-radius:8px; + padding:16px; + position:sticky; + top:8px; + z-index:5; + font-size:.85rem; + overflow-wrap:anywhere +} +#connection { + font-size:.82rem; + color:var(--muted) +} +dialog { + max-width:650px; + max-height:90vh; + overflow:auto; + border:1px solid var(--line); + border-radius:15px; + padding:28px; + color:var(--ink); + background:#fff +} +dialog::backdrop { + background:#172e23aa +} +footer { + text-align:center; + color:var(--muted); + font-size:.77rem; + padding:30px 0 10px +} +.sr-only { + position:absolute; + width:1px; + height:1px; + overflow:hidden; + clip:rect(0,0,0,0); + white-space:nowrap +} +[hidden] { + display:none!important +} +@media(max-width:950px) { + .primary-settings { + grid-template-columns:repeat(2,1fr) + } + .sound-card>p { + min-height:0 + } + .settings-grid { + grid-template-columns:repeat(2,1fr) + } + .delivery-settings { + grid-template-columns:repeat(2,1fr) + } + .tagline { + display:none + } +} +@media(max-width:600px) { + main { + padding:25px 14px + } + .masthead { + padding:16px; + flex-wrap:wrap + } + .local-badge { + font-size:.68rem + } + .panel { + padding:18px + } + .steps { + grid-template-columns:repeat(2,1fr); + gap:4px 15px + } + .steps a { + font-size:.77rem + } + .primary-settings,.settings-grid { + grid-template-columns:1fr + } + .selection-fields { + align-items:stretch + } + .selection-fields label { + width:100% + } + .selection-fields p { + margin:0 + } + .recording,.settings-heading { + align-items:flex-start; + flex-direction:column + } + .meters { + grid-template-columns:1fr + } + .toolbar { + gap:9px + } + .toolbar>code { + flex-basis:100% + } + .player-buttons { + gap:13px + } + .player-buttons .check { + font-size:.82rem + } + .delivery-settings { + gap:12px + } + h2 { + font-size:1.25rem + } + .section-heading { + gap:10px + } + summary .small { + display:block; + margin:3px 0 0 + } + .action-row .primary { + width:100% + } + dialog { + padding:20px; + width:calc(100% - 24px) + } +} +@media(prefers-reduced-motion:reduce) { + html { + scroll-behavior:auto + } +} diff --git a/lib/project/tests/gui_state.test.mjs b/lib/project/tests/gui_state.test.mjs new file mode 100644 index 0000000..10775eb --- /dev/null +++ b/lib/project/tests/gui_state.test.mjs @@ -0,0 +1,31 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import {parseTime, formatTime, selection, stable, matches, monitoringGain, absolutePath} from '../src/producer/web/state.js'; + +test('exact excerpt times work beyond four hours without wrapping', () => { + assert.equal(parseTime('05:12:03.125'), 18723.125); + assert.equal(formatTime(18723.125), '05:12:03.125'); + assert.equal(parseTime('14400'), 14400); + assert.equal(formatTime(59.9999), '00:01:00'); + for (const value of ['1:60:00', '-1', 'Infinity', '1e3', '', '00:00:60']) assert.throws(() => parseTime(value)); + assert.deepEqual(selection('03:59:30', '04:00:00', 14400), {start: 14370, duration: 30}); + for (const [start, end] of [['0', '121'], ['2', '1'], ['0', '0'], ['14399', '14401']]) assert.throws(() => selection(start, end, 14400)); +}); + +test('preview identity includes source, exact selection and every requested setting', () => { + const snapshot = {path: 'take.wav', input_id: 'identity', start: 10, duration: 30, settings: {mp3: true, trim_silence: true, denoiser: 'none'}}; + assert.ok(matches(snapshot, structuredClone(snapshot))); + assert.ok(matches(snapshot, {...snapshot, settings: {denoiser: 'none', trim_silence: true, mp3: true}})); + for (const change of [{path: 'other.wav'}, {input_id: 'changed'}, {start: 11}, {duration: 29}, {settings: {...snapshot.settings, trim_silence: false}}]) assert.equal(matches(snapshot, {...snapshot, ...change}), false); + assert.equal(matches(snapshot, null), false); + assert.equal(stable({b: 2, a: 1}), stable({a: 1, b: 2})); +}); + +test('monitoring is attenuation only and independent of export settings', () => { + assert.equal(monitoringGain(-6, false), 1); + assert.equal(monitoringGain(6, true), 1); + assert.equal(monitoringGain(NaN, true), 1); + assert.ok(Math.abs(monitoringGain(-6, true) - 0.501187) < 0.000001); + assert.equal(absolutePath('/recordings', 'delivery/master.wav'), '/recordings/delivery/master.wav'); + assert.equal(absolutePath('/', './master.wav'), '/master.wav'); +}); diff --git a/lib/project/tests/test_gui.py b/lib/project/tests/test_gui.py new file mode 100644 index 0000000..c9e5285 --- /dev/null +++ b/lib/project/tests/test_gui.py @@ -0,0 +1,260 @@ +"""Optional GUI API, origin/root boundary, snapshots, and real sound previews.""" +from dataclasses import asdict +import hashlib +from pathlib import Path +import struct +import time + +import numpy as np +import pytest +import soundfile as sf + +pytest.importorskip("fastapi") +from fastapi.testclient import TestClient + +from producer.config import Settings +from producer.gui import create_app +from producer import gui_jobs + +TOKEN = "test-session-credential" +AUTH = {"Authorization": f"Bearer {TOKEN}"} +URL = "http://127.0.0.1:8765" + + +@pytest.fixture +def client(tmp_path): + with TestClient(create_app(tmp_path, 8765, TOKEN), base_url=URL, headers=AUTH) as client: + yield client + + +def wait(client, job_id): + deadline = time.monotonic() + 30 + while time.monotonic() < deadline: + job = client.get(f"/api/jobs/{job_id}").json() + if job["status"] in ("succeeded", "failed", "cancelled"): + return job + time.sleep(0.02) + pytest.fail("Job failed to terminate") + + +def submit(client, path, kind, **kwargs): + info = client.get("/api/input", params={"path": path.name}) + assert info.status_code == 200, info.text + response = client.post("/api/jobs", json={"kind": kind, "path": path.name, + "input_id": info.json()["input_id"], **kwargs}) + assert response.status_code == 202, response.text + return response.json() + + +def test_security_credentials_origins_hosts_and_headers(tmp_path): + with TestClient(create_app(tmp_path, 8765, TOKEN), base_url=URL) as client: + assert client.get("/").status_code == 200 + assert client.get("/api/schema").status_code == 401 + assert client.get(f"/api/schema?token={TOKEN}").status_code == 401 + assert client.get("/api/schema", headers={"Cookie": f"token={TOKEN}"}).status_code == 401 + for headers in [AUTH | {"Host": "evil.example:8765"}, + AUTH | {"Host": "localhost:8765"}, + AUTH | {"Origin": "https://evil.example"}, + AUTH | {"Origin": "null"}, + AUTH | {"Origin": "http://127.0.0.1:9999"}, + AUTH | {"Sec-Fetch-Site": "cross-site"}]: + assert client.get("/api/schema", headers=headers).status_code == 403 + assert client.post("/api/jobs", json={}, headers=headers).status_code == 403 + response = client.get("/api/schema", headers=AUTH | {"Origin": URL}) + assert response.status_code == 200 + assert response.headers["Cache-Control"] == "no-store" + assert response.headers["Referrer-Policy"] == "no-referrer" + assert "frame-ancestors 'none'" in response.headers["Content-Security-Policy"] + assert "Access-Control-Allow-Origin" not in response.headers + assert TOKEN not in response.text + assert client.get("/openapi.json").status_code == 404 + assert client.post("/api/resolve", content=b"x" * 65537, headers=AUTH).status_code == 413 + assert client.post("/api/resolve", content="{", headers=AUTH).status_code == 400 + + +def test_root_browser_symlinks_traversal_and_no_arbitrary_download(client, tmp_path, wav): + source = wav() + outside = tmp_path.parent / "outside.wav" + outside.write_bytes(b"not accessible") + (tmp_path / "escape.wav").symlink_to(outside) + (tmp_path / "escape-dir").symlink_to(tmp_path.parent, target_is_directory=True) + (tmp_path / "inside.wav").symlink_to(source) + (tmp_path / "note.txt").write_text("not exposed") + (tmp_path / "subdir").mkdir() + listing = client.get("/api/files").json() + assert listing["parent"] is None + assert {item["name"] for item in listing["entries"]} == {"input.wav", "inside.wav", "subdir"} + for path in ("../outside.wav", str(outside), "escape.wav", "escape-dir", "subdir/../../outside.wav"): + assert client.get("/api/input", params={"path": path}).status_code == 400 + assert client.get("/api/files", params={"path": ".."}).status_code == 400 + assert client.get("/api/files", params={"limit": 201}).status_code == 400 + assert len(client.get("/api/files", params={"limit": 1}).json()["entries"]) == 1 + assert client.get("/api/files", params={"limit": 1}).json()["next_offset"] == 1 + assert client.get("/assets/../gui.py").status_code == 404 + assert client.get("/assets/%2e%2e/gui.py").status_code == 404 + assert client.get("/api/results/missing/original.wav").status_code == 404 + + +def test_schema_complete_strict_resolution_and_preset_roundtrip(client, bypass): + schema = client.get("/api/schema").json() + assert {item["name"] for item in schema["fields"]} == set(asdict(Settings())) + assert all(item["group"] and item["help"] for item in schema["fields"]) + assert schema["settings"] == asdict(Settings()) + for value in ({"unknown": 1}, {"normalize": "false"}, {"target_lufs": -100}, + {"normalize": True, "limiter": False}, {"sample_rate": 1234}): + assert client.post("/api/resolve", json={"settings": value}).status_code == 400 + response = client.post("/api/presets/download", json={"settings": asdict(bypass)}) + assert response.status_code == 200 + assert "attachment" in response.headers["Content-Disposition"] + imported = client.post("/api/presets/import", json={"toml": response.text}) + assert imported.json()["settings"] == asdict(bypass) + assert client.post("/api/presets/import", json={"toml": "no_such_setting = 2"}).status_code == 400 + assert client.post("/api/presets/import", json={"toml": "["}).status_code == 400 + + +def test_config_precedence_and_startup_snapshot(tmp_path): + config = tmp_path / "config.toml" + config.write_text('profile = "narrator"\ntarget_lufs = -25\n') + app = create_app(tmp_path, 8765, TOKEN, config=config, profile="radio", overrides={"target_lufs": -22}) + config.write_text("this file changed after startup") + with TestClient(app, base_url=URL, headers=AUTH) as client: + settings = client.get("/api/schema").json()["settings"] + assert settings["profile"] == "radio" and settings["compressor_ratio"] == 3.5 + assert settings["target_lufs"] == -22 + resolved = client.post("/api/resolve", json={"settings": {"target_lufs": -18, "profile": "natural"}}) + assert resolved.json()["settings"]["target_lufs"] == -18 + assert resolved.json()["settings"]["compressor_ratio"] == 2 + + +def test_scan_fixed_bins_cache_and_stale_identity(client, wav, monkeypatch): + source = wav() + original = source.read_bytes() + first = wait(client, submit(client, source, "scan")["id"]) + assert first["status"] == "succeeded", first + result = first["result"] + assert len(result["min"]) == len(result["max"]) == 2048 + assert max(result["max"]) == pytest.approx(0.15, abs=1e-6) + monkeypatch.setattr(gui_jobs, "waveform", lambda *args: pytest.fail("Expected repeated-result cache")) + second = wait(client, submit(client, source, "scan")["id"]) + assert second["cached"] and second["result"] == result + assert source.read_bytes() == original + old_identity = first["snapshot"]["input_id"] + with source.open("ab") as handle: + handle.write(b"changed") + response = client.post("/api/jobs", json={"kind": "scan", "path": source.name, "input_id": old_identity}) + assert response.status_code == 400 + assert "changed" in response.text + + +def test_real_preview_context_exact_crop_bypass_and_export_trim(client, wav, bypass, ffmpeg): + t = np.arange(48000 * 8) / 48000 + samples = 0.15 * np.sin(2 * np.pi * 440 * t) + source = wav(samples) + before = hashlib.sha256(source.read_bytes()).hexdigest() + requested = asdict(bypass) | {"trim_silence": True, "mp3": True} + queued = submit(client, source, "preview", settings=requested, start=2, duration=3) + preview = wait(client, queued["id"]) + assert preview["status"] == "succeeded", preview + result = preview["result"] + assert preview["snapshot"]["settings"] == requested + assert result["requested_settings"] == requested + assert result["effective_settings"] == requested | {"trim_silence": False, "mp3": False} + assert result["normalization_mode"] == "disabled" + assert result["context_start"] == 0 and result["context_end"] == 8 + assert result["approximate"] is True + assert "Pacing edits are not previewed" in result["notice"] + import io + originals = client.get(result["audio"]["original"]) + processed = client.get(result["audio"]["processed"]) + for response in (originals, processed): + assert response.status_code == 200 + info = sf.info(io.BytesIO(response.content)) + assert info.frames == 3 * 48000 + assert info.subtype == "PCM_16" + original_audio, _ = sf.read(io.BytesIO(originals.content)) + processed_audio, _ = sf.read(io.BytesIO(processed.content)) + np.testing.assert_allclose(original_audio, processed_audio, atol=1 / 32768) + np.testing.assert_allclose(original_audio, samples[2 * 48000:5 * 48000], atol=1 / 32768) + assert result["monitoring"]["available"] + assert result["monitoring"]["original_gain_db"] <= 0 + assert result["monitoring"]["processed_gain_db"] <= 0 + repeated = wait(client, submit(client, source, "preview", settings=requested, start=2, duration=3)["id"]) + assert repeated["cached"] and repeated["result"]["result_id"] == result["result_id"] + changed = wait(client, submit(client, source, "preview", settings=requested | {"highpass": True}, start=2, duration=3)["id"]) + assert not changed["cached"] and changed["result"]["result_id"] != result["result_id"] + for _ in range(2): + exported = wait(client, submit(client, source, "export", settings=requested)["id"]) + assert exported["status"] == "succeeded", exported + assert exported["result"]["settings"] == requested + assert len(exported["result"]["paths"]) == 3 + assert "not auditioned" in exported["result"]["notice"] + for path in exported["result"]["paths"]: + assert (source.parent / path).is_file() + assert len(list(source.parent.glob("producer-export-*"))) == 2 + assert hashlib.sha256(source.read_bytes()).hexdigest() == before + # A cached audio URL cannot read stale results after the recording changes. + with source.open("ab") as handle: + handle.write(b"changed") + assert client.get(result["audio"]["original"]).status_code == 400 + + +def test_preview_bounds_settings_errors_and_unsupported_input(client, wav): + source = wav() + for start, duration in ((-1, 1), (0, 121), (0, 0), (2, 2), (True, 1)): + info = client.get("/api/input", params={"path": source.name}).json() + response = client.post("/api/jobs", json={"kind": "preview", "path": source.name, + "input_id": info["input_id"], "start": start, "duration": duration}) + assert response.status_code == 400 + stereo = wav(np.zeros((20, 2)), name="stereo.wav") + assert "Stereo" in client.get("/api/input", params={"path": stereo.name}).json()["detail"] + surround = wav(np.zeros((20, 3)), name="surround.wav") + assert client.get("/api/input", params={"path": surround.name}).status_code == 400 + source.write_bytes(b"not a wav") + response = client.get("/api/input", params={"path": source.name}) + assert response.status_code == 400 and "Unsupported" in response.text + + +def test_no_total_duration_cap_and_seek_to_end_of_four_hour_input(tmp_path, progress): + # Sparse mono PCM16 WAV. No real recordings touched and no huge allocation. + source = tmp_path / "four-hours.wav" + sr, seconds = 8000, 4 * 60 * 60 + data_bytes = sr * seconds * 2 + header = (b"RIFF" + struct.pack("<I", 36 + data_bytes) + b"WAVEfmt " + + struct.pack("<IHHIIHH", 16, 1, 1, sr, sr * 2, 2, 16) + + b"data" + struct.pack("<I", data_bytes)) + with source.open("wb") as handle: + handle.write(header) + handle.truncate(44 + data_bytes) + assert gui_jobs.input_info(source)["duration"] == seconds + target = tmp_path / "end.wav" + gui_jobs.copy_range(source, target, seconds - 2, 2, progress) + assert sf.info(target).frames == 2 * sr + + +def test_waveform_buffers_bounded_and_scan_cancellable(wav, monkeypatch): + source = wav() + actual = sf.SoundFile + reads = [] + class CheckedFile: + def __init__(self, *args, **kwargs): + self.source = actual(*args, **kwargs) + def __enter__(self): + return self + def __exit__(self, *args): + self.source.close() + def __len__(self): + return len(self.source) + def __getattr__(self, name): + return getattr(self.source, name) + def blocks(self, **kwargs): + assert kwargs["blocksize"] <= 65536 + for block in self.source.blocks(**kwargs): + reads.append(len(block)) + yield block + monkeypatch.setattr(gui_jobs.sf, "SoundFile", CheckedFile) + def cancel(stage, completed, total): + if completed: + raise gui_jobs.Cancelled() + with pytest.raises(gui_jobs.Cancelled): + gui_jobs.waveform(source, cancel) + assert reads == [65536] diff --git a/lib/project/tests/test_gui_assets.py b/lib/project/tests/test_gui_assets.py new file mode 100644 index 0000000..b7f949a --- /dev/null +++ b/lib/project/tests/test_gui_assets.py @@ -0,0 +1,46 @@ +"""Packaged, CSP-compatible frontend assets do not require a bundler or CDN.""" +from html.parser import HTMLParser + +import pytest + +pytest.importorskip("fastapi") +from fastapi.testclient import TestClient + +from producer.gui import create_app + + +class AssetReferences(HTMLParser): + def __init__(self): + super().__init__() + self.scripts = [] + self.stylesheets = [] + self.inline_attributes = [] + + def handle_starttag(self, tag, attrs): + attrs = dict(attrs) + self.inline_attributes.extend(name for name in attrs if name == "style" or name.startswith("on")) + if tag == "script": + self.scripts.append(attrs.get("src")) + if tag == "link" and attrs.get("rel") == "stylesheet": + self.stylesheets.append(attrs.get("href")) + + +def test_gui_packaged_external_assets_and_guided_workflow(tmp_path): + with TestClient(create_app(tmp_path, 8765, "test-token"), base_url="http://127.0.0.1:8765") as client: + page = client.get("/") + assert page.status_code == 200 + parser = AssetReferences() + parser.feed(page.text) + assert parser.scripts == ["/assets/app.js"] + assert parser.stylesheets == ["/assets/style.css"] + assert not parser.inline_attributes + assert "unsafe-inline" not in page.headers["Content-Security-Policy"] + assert "Approximate sound comparison, not an exact export preview." in page.text + for name in ("Choose your recording", "Try a short passage", "Compare the sound", "Export the full recording"): + assert name in page.text + for path in parser.scripts + parser.stylesheets + ["/assets/state.js"]: + response = client.get(path) + assert response.status_code == 200, path + assert response.headers["Cache-Control"] == "no-store" + assert len(response.content) > 100 + assert client.get("/api/schema").status_code == 401 diff --git a/lib/project/tests/test_gui_browser.py b/lib/project/tests/test_gui_browser.py new file mode 100644 index 0000000..1e38964 --- /dev/null +++ b/lib/project/tests/test_gui_browser.py @@ -0,0 +1,257 @@ +"""Opt-in real Chromium/loopback smoke; no AI models or recording uploads. + +Install Playwright externally, then run: +PRODUCER_BROWSER_TESTS=1 python -m pytest tests/test_gui_browser.py -q +Screenshots go to PRODUCER_BROWSER_ARTIFACTS (default /tmp/producer-browser-smoke). +The long overview case uses mocked fixed-size scan metadata, not 4-hour AI inference. +""" +from dataclasses import asdict +import hashlib +import json +import os +import re +from pathlib import Path +import socket +import threading +import time + +import numpy as np +import pytest + +pytestmark = pytest.mark.skipif(os.environ.get("PRODUCER_BROWSER_TESTS") != "1", reason="Opt-in Playwright browser smoke") + + +@pytest.fixture +def studio(tmp_path, wav, bypass, ffmpeg, monkeypatch): + pytest.importorskip("playwright.sync_api") + import uvicorn + from producer.gui import create_app + from producer import gui_jobs + + source = wav(0.15 * np.sin(2 * np.pi * 440 * np.arange(48000 * 8) / 48000), name="Narration.wav") + wav(np.zeros((100, 2)), name="Stereo.wav") + (tmp_path / "delivery").mkdir() + (tmp_path / "subfolder").mkdir() + control = {"hold": False, "started": threading.Event(), "release": threading.Event()} + actual = gui_jobs.render_preview + + def controlled_preview(*args): + if control["hold"]: + control["started"].set() + assert control["release"].wait(15), "Browser test did not release held preview" + control["hold"] = False + return actual(*args) + + monkeypatch.setattr(gui_jobs, "render_preview", controlled_preview) + token = "browser-test-private-credential" + sock = socket.socket() + sock.bind(("127.0.0.1", 0)) + sock.listen(128) + port = sock.getsockname()[1] + app = create_app(tmp_path, port, token, overrides=asdict(bypass)) + server = uvicorn.Server(uvicorn.Config(app, log_level="error", access_log=False, proxy_headers=False)) + thread = threading.Thread(target=server.run, kwargs={"sockets": [sock]}, daemon=True) + thread.start() + deadline = time.monotonic() + 10 + while not server.started and time.monotonic() < deadline: + time.sleep(0.02) + assert server.started + try: + yield {"url": f"http://127.0.0.1:{port}", "token": token, "root": tmp_path, "source": source, "control": control} + finally: + control["release"].set() + server.should_exit = True + thread.join(15) + sock.close() + assert not thread.is_alive() + + +def test_gui_browser_real_workflow(studio): + from playwright.sync_api import sync_playwright, expect + + screenshots = Path(os.environ.get("PRODUCER_BROWSER_ARTIFACTS", "/tmp/producer-browser-smoke")) + screenshots.mkdir(parents=True, exist_ok=True) + before = hashlib.sha256(studio["source"].read_bytes()).hexdigest() + with sync_playwright() as playwright: + browser = playwright.chromium.launch(headless=True, args=["--no-sandbox"]) + context = browser.new_context(viewport={"width": 1360, "height": 1000}, accept_downloads=True) + page = context.new_page() + errors, console_errors, media_requests = [], [], [] + page.on("pageerror", lambda exc: errors.append(str(exc))) + page.on("console", lambda msg: console_errors.append(msg.text) if msg.type == "error" else None) + page.on("request", lambda req: media_requests.append(req.url) if "/api/results/" in req.url else None) + page.goto(studio["url"] + "/#token=" + studio["token"]) + expect(page.locator("#workspace")).not_to_have_attribute("disabled", "") + assert "#" not in page.url + assert studio["token"] not in page.locator("body").inner_text() + assert page.locator("[data-setting]").count() == 47 + expect(page.locator("#setting-denoiser")).to_have_value("none") + expect(page.locator("#folder-up")).to_be_disabled() + # Unsupported input errors are human-readable, not raw HTML. + page.get_by_role("button", name=re.compile(r"WAV\s*Stereo.wav")).click() + expect(page.locator("#error")).to_be_visible() + expect(page.locator("#error-text")).to_contain_text("Stereo") + page.locator("#dismiss-error").click() + page.get_by_role("button", name=re.compile(r"WAV\s*Narration.wav")).click() + expect(page.locator("#wave-status")).to_contain_text("2,048", timeout=15000) + expect(page.locator("#input-info")).to_contain_text("00:00:08") + # Both drag selection and keyboard-accessible exact inputs. + page.locator("#waveform").scroll_into_view_if_needed() + box = page.locator("#waveform").bounding_box() + page.mouse.move(box["x"] + box["width"] * .25, box["y"] + 80) + page.mouse.down() + page.mouse.move(box["x"] + box["width"] * .65, box["y"] + 80, steps=5) + page.mouse.up() + assert page.locator("#selection-start").input_value() != "00:00:00" + page.locator("#selection-start").fill("00:00:02") + page.locator("#selection-end").fill("00:00:05") + page.locator("#zoom-selection").click() + page.locator("#zoom-full").click() + # Explicit render, requested pacing+MP3 preserved in snapshot but bypassed internally. + page.locator("#setting-trim_silence").check() + page.locator("#setting-mp3").check() + page.locator("#render-preview").click() + expect(page.locator("#comparison-status")).to_contain_text("Ready", timeout=30000) + assert len(media_requests) == 2 + page.locator("#compare").scroll_into_view_if_needed() + page.locator("#play").click() + expect(page.locator("#play")).to_have_text("Pause") + page.wait_for_timeout(350) + position = page.locator("#audio-original").evaluate("audio => audio.currentTime") + page.locator("#listen-processed").click() + expect(page.locator("#listen-processed")).to_have_attribute("aria-pressed", "true") + assert page.locator("#audio-processed").evaluate("audio => audio.currentTime") >= position - .15 + assert page.locator("#audio-original").evaluate("audio => audio.paused") + assert page.locator("#audio-processed").evaluate("audio => audio.loop") + page.locator("#match").check() + expect(page.locator("#monitoring-note")).to_contain_text("Monitoring only") + page.locator("#play").click() + expect(page.locator("#meters")).to_contain_text("not a full-export measurement") + page.locator("#comparison-name").fill("Gentle <snapshot>") + page.locator("#save-comparison").click() + expect(page.locator("#comparisons")).to_contain_text("Gentle <snapshot>") + assert page.locator("#comparisons snapshot").count() == 0 + page.locator("#presets-panel summary").click() + page.locator("#preset-name").fill("My sound") + page.locator("#save-preset").click() + expect(page.locator("#presets")).to_contain_text("My sound") + with page.expect_download() as downloaded: + page.locator("#download-preset").click() + toml_path = screenshots / "roundtrip-preset.toml" + downloaded.value.save_as(toml_path) + assert "trim_silence = true" in toml_path.read_text() + assert studio["token"] not in toml_path.read_text() + page.locator("#setting-denoise_strength").fill("0.4") + expect(page.locator("#comparison-status")).to_contain_text("changed since preview") + page.locator("#import-preset").set_input_files(toml_path) + expect(page.locator("#setting-denoise_strength")).to_have_value("0.85") + # The saved copy cannot change when controls are edited later. + page.locator("#setting-denoise_strength").fill("0.5") + saved = page.evaluate("JSON.parse(localStorage.getItem(Object.keys(localStorage).find(k => k.startsWith('producer-studio-v1:'))))") + assert saved["comparisons"][0]["snapshot"]["settings"]["denoise_strength"] == .85 + assert saved["presets"][0]["settings"]["denoise_strength"] == .85 + assert studio["token"] not in json.dumps(saved) + # Delay real preview completion. Editing after submission must never publish it as current. + studio["control"]["hold"] = True + page.locator("#render-preview").click() + assert studio["control"]["started"].wait(10) + old_source = page.locator("#comparison-source").inner_text() + page.locator("#setting-denoise_strength").fill("0.6") + studio["control"]["release"].set() + expect(page.locator("#jobs")).to_contain_text("Older snapshot; not loaded", timeout=30000) + assert page.locator("#comparison-source").inner_text() == old_source + assert len(media_requests) == 2 + # Restore is an explicit render and restores the original immutable requested settings. + page.locator("#comparisons button", has_text="Restore & render").click() + expect(page.locator("#setting-denoise_strength")).to_have_value("0.85") + expect(page.locator("#comparison-status")).to_contain_text("Ready", timeout=30000) + # Cross-field errors do not silently enable the limiter. + page.locator("#setting-normalize").check() + page.locator("#render-preview").click() + expect(page.locator("#error-text")).to_contain_text("limiter") + assert not page.locator("#setting-limiter").is_checked() + page.locator("#dismiss-error").click() + page.locator("#setting-normalize").uncheck() + page.locator("#output-dir").fill("delivery") + preview_requests = len(media_requests) + page.locator("#prepare-export").click() + expect(page.locator("#export-dialog")).to_be_visible() + expect(page.locator("#export-pacing")).to_contain_text("NOT auditioned") + expect(page.locator("#export-summary")).to_contain_text("whole original 00:00:08") + page.locator("#confirm-export").click() + expect(page.locator("#export-results")).to_contain_text("Full recording exported", timeout=30000) + outputs = list((studio["root"] / "delivery").glob("producer-export-*")) + assert len(outputs) == 1 + assert (outputs[0] / "master.wav").is_file() and (outputs[0] / "master.mp3").is_file() + assert (outputs[0] / "logs/master.report.json").is_file() + import soundfile as sf + assert sf.info(outputs[0] / "master.wav").duration == pytest.approx(8, abs=.01) + assert len(media_requests) == preview_requests, "Full exports must not be fetched into browser blobs" + page.locator("#prepare-export").click() + page.locator("#confirm-export").click() + expect(page.locator("#export-results .export-result")).to_have_count(2, timeout=30000) + assert len(list((studio["root"] / "delivery").glob("producer-export-*"))) == 2 + assert hashlib.sha256(studio["source"].read_bytes()).hexdigest() == before + page.locator("#compare").scroll_into_view_if_needed() + page.screenshot(path=str(screenshots / "desktop-comparison.png"), full_page=True) + page.locator("#compare").screenshot(path=str(screenshots / "comparison-panel.png")) + # Reload retains per-tab authorization and local snapshots, not misleading current audio. + page.reload() + expect(page.locator("#workspace")).not_to_have_attribute("disabled", "") + expect(page.locator("#comparisons")).to_contain_text("Gentle <snapshot>") + expect(page.locator("#play")).to_be_disabled() + expect(page.locator("#export-results .export-result")).to_have_count(2, timeout=15000) + page.set_viewport_size({"width": 390, "height": 844}) + page.screenshot(path=str(screenshots / "mobile-studio.png"), full_page=True) + page.evaluate("window.scrollTo(0, 0)") + page.screenshot(path=str(screenshots / "mobile-entry.png")) + assert page.evaluate("document.documentElement.scrollWidth <= innerWidth") + # A fresh tab without the private fragment cannot read API data. + unauth = context.new_page() + unauth.goto(studio["url"]) + expect(unauth.locator("#error-text")).to_contain_text("private URL") + expect(unauth.locator("#workspace")).to_have_attribute("disabled", "") + unauth.close() + # Simulate server restart credential expiry and verify recovery guidance. + page.route("**/api/jobs", lambda route: route.fulfill(status=401, json={"detail": "expired"})) + expect(page.locator("#workspace")).to_have_attribute("disabled", "", timeout=10000) + expect(page.locator("#connection")).to_contain_text("Session expired") + assert errors == [] + # Expected HTTP errors for stereo, invalid constraint and the expiry test are allowed. + assert not [msg for msg in console_errors if "Content Security Policy" in msg or "Refused" in msg] + browser.close() + + +def test_gui_browser_long_overview(studio): + from playwright.sync_api import sync_playwright, expect + + with sync_playwright() as playwright: + browser = playwright.chromium.launch(headless=True, args=["--no-sandbox"]) + page = browser.new_page(viewport={"width": 1200, "height": 900}) + requests, errors = [], [] + page.on("request", lambda request: requests.append(request.url)) + page.on("pageerror", lambda exc: errors.append(str(exc))) + page.goto(studio["url"] + "/#token=" + studio["token"]) + expect(page.locator("#workspace")).not_to_have_attribute("disabled", "") + # Four-hour metadata plus fixed-size peaks: browser never receives full recording samples. + info = {"path": "Narration.wav", "input_id": "long-identity", "frames": 14400 * 8000, + "duration": 14400, "sample_rate": 8000, "channels": 1, "subtype": "PCM_16"} + result = {"input_id": "long-identity", "duration": 14400, "frames": info["frames"], "sample_rate": 8000, + "bins": 2048, "min": [-.3] * 2048, "max": [.4] * 2048} + job = {"id": "mock-scan", "kind": "scan", "snapshot": {"path": "Narration.wav", "input_id": "long-identity"}, + "status": "succeeded", "progress": {"stage": "Done", "completed": 1, "total": 1}, + "created": time.time(), "result": result, "error": None, "cancel_requested": False, "cached": False} + page.route("**/api/input?*", lambda route: route.fulfill(json=info)) + page.route("**/api/jobs", lambda route: route.fulfill(json=job if route.request.method == "POST" else {"jobs": [job]})) + page.get_by_role("button", name=re.compile(r"WAV\s*Narration.wav")).click() + expect(page.locator("#wave-status")).to_contain_text("2,048", timeout=10000) + expect(page.locator("#wave-end")).to_have_text("04:00:00") + page.locator("#selection-start").fill("03:59:30") + page.locator("#selection-end").fill("04:00:00") + expect(page.locator("#selection-note")).to_contain_text("30 seconds selected") + page.locator("#zoom-selection").click() + expect(page.locator("#wave-start")).to_have_text("03:59:15") + assert len(json.dumps(result)) < 50000 + assert not [url for url in requests if "/api/results/" in url] + assert errors == [] + browser.close() diff --git a/lib/project/tests/test_gui_browser_races.py b/lib/project/tests/test_gui_browser_races.py new file mode 100644 index 0000000..fab60ba --- /dev/null +++ b/lib/project/tests/test_gui_browser_races.py @@ -0,0 +1,210 @@ +"""Additional opt-in browser race/cancellation coverage; uses the real API fixture.""" +import os +import re + +import pytest + +from test_gui_browser import studio # noqa: F401 -- shared real loopback fixture + +pytestmark = pytest.mark.skipif(os.environ.get("PRODUCER_BROWSER_TESTS") != "1", reason="Opt-in Playwright browser smoke") + + +def test_gui_browser_cancellation_is_not_pretend(studio): + from playwright.sync_api import sync_playwright, expect + + with sync_playwright() as playwright: + browser = playwright.chromium.launch(headless=True, args=["--no-sandbox"]) + page = browser.new_page() + page.goto(studio["url"] + "/#token=" + studio["token"]) + page.get_by_role("button", name=re.compile(r"WAV\s*Narration.wav")).click() + expect(page.locator("#wave-status")).to_contain_text("2,048", timeout=10000) + studio["control"]["hold"] = True + page.locator("#render-preview").click() + assert studio["control"]["started"].wait(10) + expect(page.locator("#jobs .job").filter(has_text="running")).to_have_count(1, timeout=10000) + page.locator("#setting-denoise_strength").fill("0.4") + page.locator("#render-preview").click() + queued = page.locator("#jobs .job").filter(has_text="queued") + expect(queued).to_have_count(1) + queued.get_by_role("button", name="Cancel", exact=True).click() + expect(page.locator("#jobs .job").filter(has_text="cancelled")).to_have_count(1) + running = page.locator("#jobs .job").filter(has_text="running") + running.get_by_role("button", name="Cancel", exact=True).click() + expect(page.locator("#jobs")).to_contain_text("Cancelling…") + assert page.locator("#jobs .job").filter(has_text="cancelled").count() == 1 + studio["control"]["release"].set() + expect(page.locator("#jobs .job").filter(has_text="cancelled")).to_have_count(2, timeout=10000) + expect(page.locator("#play")).to_be_disabled() + browser.close() + + +def test_gui_browser_late_audio_is_aborted_and_urls_are_released(studio): + from playwright.sync_api import sync_playwright, expect + + with sync_playwright() as playwright: + browser = playwright.chromium.launch(headless=True, args=["--no-sandbox"]) + page = browser.new_page() + page.add_init_script("""window.objectURLs = {created: [], revoked: []}; + const create = URL.createObjectURL.bind(URL), revoke = URL.revokeObjectURL.bind(URL); + URL.createObjectURL = blob => { const url = create(blob); objectURLs.created.push(url); return url; }; + URL.revokeObjectURL = url => { objectURLs.revoked.push(url); revoke(url); };""") + page.goto(studio["url"] + "/#token=" + studio["token"]) + page.get_by_role("button", name=re.compile(r"WAV\s*Narration.wav")).click() + expect(page.locator("#wave-status")).to_contain_text("2,048", timeout=10000) + page.locator("#selection-start").fill("1") + page.locator("#selection-end").fill("4") + page.locator("#render-preview").click() + expect(page.locator("#comparison-status")).to_contain_text("Ready", timeout=30000) + first_source = page.locator("#comparison-source").inner_text() + first_urls = page.evaluate("objectURLs.created.slice()") + assert len(first_urls) == 2 + held = [] + + def hold(route): + response = route.fetch() + held.append((route, response)) + + page.route("**/api/results/**", hold) + page.locator("#selection-start").fill("2") + page.locator("#render-preview").click() + for _ in range(200): + if len(held) == 2: + break + page.wait_for_timeout(50) + assert len(held) == 2 + expect(page.locator("#comparison-status")).to_contain_text("Loading") + page.locator("#selection-start").fill("3") + for route, response in held: + route.fulfill(response=response) + page.wait_for_timeout(500) + assert page.locator("#comparison-source").inner_text() == first_source + expect(page.locator("#comparison-status")).to_contain_text("changed since preview") + assert page.evaluate("objectURLs.created.length") == 2 + page.unroute("**/api/results/**", hold) + page.locator("#render-preview").click() + expect(page.locator("#comparison-status")).to_contain_text("Ready", timeout=30000) + assert page.evaluate("objectURLs.created.length") == 4 + assert page.evaluate("objectURLs.revoked") == first_urls + browser.close() + + +def test_gui_browser_nonzero_monitoring_gain_is_playback_only(studio): + from playwright.sync_api import sync_playwright, expect + + with sync_playwright() as playwright: + browser = playwright.chromium.launch(headless=True, args=["--no-sandbox"]) + page = browser.new_page() + page.add_init_script("""window.monitorGains = []; + const createGain = AudioContext.prototype.createGain; + AudioContext.prototype.createGain = function() { + const gain = createGain.call(this); monitorGains.push(gain); return gain; + };""") + page.goto(studio["url"] + "/#token=" + studio["token"]) + page.get_by_role("button", name=re.compile(r"WAV\s*Narration.wav")).click() + expect(page.locator("#wave-status")).to_contain_text("2,048", timeout=10000) + page.locator("#advanced-settings summary").click() + # Real FFmpeg leveling makes a measurable difference without any AI runtime. + page.locator("#setting-leveling").check() + page.locator("#setting-level_target_db").fill("-12") + page.locator("#render-preview").click() + expect(page.locator("#comparison-status")).to_contain_text("Ready", timeout=30000) + requested = page.locator("#preview-settings").text_content() + meters = page.locator("#meters").inner_text() + page.locator("#match").check() + expect(page.locator("#monitoring-note")).to_contain_text("Monitoring only") + gains = page.evaluate("monitorGains.map(node => node.gain.value)") + assert len(gains) == 2 + assert all(0 < gain <= 1 for gain in gains) + assert min(gains) < .9, "A nonzero measured loudness difference should attenuate one playback side" + assert page.locator("#preview-settings").text_content() == requested + assert page.locator("#meters").inner_text() == meters + page.locator("#prepare-export").click() + expect(page.locator("#export-dialog")).to_be_visible() + frozen_export = page.locator("#export-settings").text_content() + assert '"level_target_db": -12' in frozen_export + assert "original_gain_db" not in frozen_export and "processed_gain_db" not in frozen_export + assert "monitoring" not in frozen_export + page.locator("#cancel-export").click() + page.locator("#match").uncheck() + assert page.evaluate("monitorGains.map(node => node.gain.value)") == [1, 1] + browser.close() + + +@pytest.mark.parametrize("kind", ["scan", "preview"]) +def test_delayed_job_list_preserves_new_submission_intent(studio, kind): + from playwright.sync_api import sync_playwright, expect + + with sync_playwright() as playwright: + browser = playwright.chromium.launch(headless=True, args=["--no-sandbox"]) + page = browser.new_page() + page.goto(studio["url"] + "/#token=" + studio["token"]) + if kind == "preview": + page.get_by_role("button", name=re.compile(r"WAV\s*Narration.wav")).click() + expect(page.locator("#wave-status")).to_contain_text("2,048", timeout=10000) + held = [] + + def hold_one_list(route): + if route.request.method == "GET" and not held: + held.append((route, route.fetch())) + else: + route.continue_() + + page.route("**/api/jobs", hold_one_list) + for _ in range(100): + if held: + break + page.wait_for_timeout(50) + assert held, "Expected an in-flight list captured before the new submission" + with page.expect_response(lambda response: response.request.method == "POST" + and response.url.endswith("/api/jobs")) as posted: + if kind == "scan": + page.get_by_role("button", name=re.compile(r"WAV\s*Narration.wav")).click() + else: + page.locator("#render-preview").click() + assert posted.value.status == 202 + expect(page.locator("#jobs .job").filter(has_text=kind.upper())).to_have_count(1) + route, response = held[0] + route.fulfill(response=response) + if kind == "scan": + expect(page.locator("#wave-status")).to_contain_text("2,048", timeout=10000) + else: + expect(page.locator("#comparison-status")).to_contain_text("Ready", timeout=30000) + browser.close() + + +@pytest.mark.parametrize("interruption", ["switch", "edit", "expire"]) +def test_delayed_audio_resume_does_not_start_superseded_playback(studio, interruption): + from playwright.sync_api import sync_playwright, expect + + with sync_playwright() as playwright: + browser = playwright.chromium.launch(headless=True, args=["--no-sandbox"]) + page = browser.new_page() + page.add_init_script("""window.releaseAudioResume = null; + const resume = AudioContext.prototype.resume; + AudioContext.prototype.resume = function() { + return new Promise(resolve => { + window.releaseAudioResume = () => { resume.call(this).then(resolve); }; + }); + };""") + page.goto(studio["url"] + "/#token=" + studio["token"]) + page.get_by_role("button", name=re.compile(r"WAV\s*Narration.wav")).click() + expect(page.locator("#wave-status")).to_contain_text("2,048", timeout=10000) + page.locator("#render-preview").click() + expect(page.locator("#comparison-status")).to_contain_text("Ready", timeout=30000) + page.locator("#play").click() + page.wait_for_function("typeof releaseAudioResume === 'function'") + if interruption == "switch": + page.locator("#listen-processed").click() + expect(page.locator("#listen-processed")).to_have_attribute("aria-pressed", "true") + elif interruption == "edit": + page.locator("#setting-denoise_strength").fill("0.4") + else: + page.route("**/api/jobs", lambda route: route.fulfill( + status=401, content_type="application/json", body='{"detail":"Expired"}')) + expect(page.locator("#workspace")).to_have_attribute("disabled", "", timeout=10000) + page.evaluate("releaseAudioResume()") + # Let both the resume and any erroneously scheduled play promise settle. + page.wait_for_timeout(300) + assert page.locator("#audio-original").evaluate("audio => audio.paused") + assert page.locator("#audio-processed").evaluate("audio => audio.paused") + browser.close() diff --git a/lib/project/tests/test_gui_jobs.py b/lib/project/tests/test_gui_jobs.py new file mode 100644 index 0000000..6116a72 --- /dev/null +++ b/lib/project/tests/test_gui_jobs.py @@ -0,0 +1,124 @@ +"""Bounded single-worker scheduling and cancellation through real process cleanup.""" +import os +import sys +import threading +import time + +import pytest + +from producer import gui_jobs, setup +from producer.gui import rooted + + +def terminal(jobs, job_id): + deadline = time.monotonic() + 10 + while time.monotonic() < deadline: + job = jobs.get(job_id) + if job["status"] in ("succeeded", "failed", "cancelled"): + return job + time.sleep(0.01) + pytest.fail("Job failed to finish") + + +def test_single_worker_bounded_queue_and_running_process_cancellation(wav, tmp_path, monkeypatch): + source = wav() + started = threading.Event() + pid_file = tmp_path / "child.pid" + calls = [] + def slow_scan(path, progress): + calls.append(path) + if len(calls) == 1: + started.set() + setup.run_process([sys.executable, "-c", + f"import os,time;open({str(pid_file)!r},'w').write(str(os.getpid()));time.sleep(60)"], + "Waiting for worker", progress) + return {"bins": 2048} + monkeypatch.setattr(gui_jobs, "waveform", slow_scan) + jobs = gui_jobs.Jobs(tmp_path, lambda value: rooted(tmp_path, value)) + snapshot = {"path": source.name, "input_id": gui_jobs.identity(source)} + try: + first = jobs.submit("scan", snapshot) + assert started.wait(5) + # Public snapshots cannot mutate the immutable in-flight job. + first["snapshot"]["path"] = "missing.wav" + queued = [jobs.submit("scan", snapshot) for _ in range(3)] + with pytest.raises(gui_jobs.Busy): + jobs.submit("scan", snapshot) + assert len(calls) == 1 + assert jobs.cancel(queued[0]["id"])["status"] == "cancelled" + deadline = time.monotonic() + 5 + while not pid_file.exists() and time.monotonic() < deadline: + time.sleep(0.01) + pid = int(pid_file.read_text()) + jobs.cancel(first["id"]) + assert terminal(jobs, first["id"])["status"] == "cancelled" + with pytest.raises(ProcessLookupError): + os.kill(pid, 0) + assert terminal(jobs, queued[1]["id"])["status"] == "succeeded" + assert terminal(jobs, queued[2]["id"])["cached"] + assert len(calls) == 2 + finally: + jobs.close() + assert not jobs.work.exists() + + +def test_history_and_result_cache_are_bounded_and_evicted_media_expires(wav, tmp_path, monkeypatch): + source = wav() + monkeypatch.setattr(gui_jobs, "MAX_HISTORY", 5) + monkeypatch.setattr(gui_jobs, "CACHE_ENTRIES", 2) + def preview(path, work, snapshot, progress): + (work / "original.wav").write_bytes(b"preview") + (work / "processed.wav").write_bytes(b"preview") + return {} + monkeypatch.setattr(gui_jobs, "render_preview", preview) + jobs = gui_jobs.Jobs(tmp_path, lambda value: rooted(tmp_path, value)) + snapshot = {"path": source.name, "input_id": gui_jobs.identity(source)} + try: + results = [] + for index in range(10): + job = jobs.submit("preview", snapshot | {"start": index}) + result = terminal(jobs, job["id"]) + assert result["status"] == "succeeded", result + results.append(result["result"]["result_id"]) + assert len(jobs.list()) == 5 + assert len(jobs.cache) == 2 + assert len(list(jobs.work.iterdir())) == 2 + with pytest.raises(KeyError): + jobs.media(results[0], "original") + assert jobs.media(results[-1], "original") == b"preview" + with pytest.raises(KeyError): + jobs.media(results[-1], "../original") + # Byte budget independently evicts even below the entry-count limit. + monkeypatch.setattr(gui_jobs, "CACHE_BYTES", 1) + with jobs.condition: + jobs._evict() + assert not jobs.cache + assert not list(jobs.work.iterdir()) + finally: + jobs.close() + + +def test_queued_input_replacement_is_rejected_and_shutdown_cancels_scan(wav, tmp_path, monkeypatch): + source = wav() + started = threading.Event() + def scan(path, progress): + started.set() + while True: + progress("Scanning", 1, 10) + time.sleep(0.01) + monkeypatch.setattr(gui_jobs, "waveform", scan) + jobs = gui_jobs.Jobs(tmp_path, lambda value: rooted(tmp_path, value)) + snapshot = {"path": source.name, "input_id": gui_jobs.identity(source)} + running = jobs.submit("scan", snapshot) + assert started.wait(5) + queued = jobs.submit("scan", snapshot) + with source.open("ab") as handle: + handle.write(b"replacement") + jobs.cancel(running["id"]) + assert terminal(jobs, queued["id"])["status"] == "failed" + assert "changed" in jobs.get(queued["id"])["error"] + # Submit with updated identity and exercise app shutdown cleanup. + last = jobs.submit("scan", snapshot | {"input_id": gui_jobs.identity(source)}) + jobs.close() + assert jobs.get(last["id"])["status"] == "cancelled" + assert not jobs.work.exists() diff --git a/lib/project/tests/test_gui_launcher.py b/lib/project/tests/test_gui_launcher.py new file mode 100644 index 0000000..8111c67 --- /dev/null +++ b/lib/project/tests/test_gui_launcher.py @@ -0,0 +1,113 @@ +"""Exercise bootstrap/config routing without downloading or changing the real runtime.""" +import json +import os +from pathlib import Path +import subprocess +import sys + +import pytest + +from producer import cli + +LAUNCHER = Path(__file__).resolve().parents[3] / "producer.sh" + + +def test_cli_gui_dispatch_preserves_configuration_flags(tmp_path, monkeypatch): + from producer import gui + config = tmp_path / "config.toml" + config.write_text('profile = "narrator"\n') + received = [] + monkeypatch.setattr(gui, "serve", lambda *args, **kwargs: received.append((args, kwargs))) + monkeypatch.setattr(sys, "argv", ["producer", "gui", "--root", str(tmp_path), "--port", "9001", + "--config", str(config), "--profile", "radio", "--target-lufs", "-21", "--set", "target_lufs=-18"]) + cli.main() + assert received == [((tmp_path, 9001), {"config": config, "profile": "radio", "overrides": {"target_lufs": -18}})] + + +def test_launcher_only_gui_installs_extra_and_preserves_config_injection(tmp_path): + checkout = tmp_path / "checkout" + checkout.mkdir() + launcher = checkout / "producer.sh" + launcher.write_text(LAUNCHER.read_text()) + launcher.chmod(0o755) + (checkout / "config.toml").write_text('profile = "narrator"\n') + (checkout / "lib/project").mkdir(parents=True) + tools = tmp_path / "tools" + tools.mkdir() + log, forwarded = tmp_path / "install.jsonl", tmp_path / "forwarded.json" + fake_python = (f"#!{sys.executable}\nimport json,sys\nfrom pathlib import Path\n" + f"if '-m' in sys.argv: Path({str(forwarded)!r}).write_text(json.dumps(sys.argv[1:]))\n") + uv = tools / "uv" + uv.write_text(f"#!{sys.executable}\n" + "import json,sys\nfrom pathlib import Path\n" + f"with Path({str(log)!r}).open('a') as out: out.write(json.dumps(sys.argv[1:])+'\\n')\n" + "if sys.argv[1] == 'venv':\n" + " python = Path(sys.argv[-1]) / 'bin/python'\n" + " python.parent.mkdir(parents=True, exist_ok=True)\n" + f" python.write_text({fake_python!r})\n" + " python.chmod(0o755)\n") + uv.chmod(0o755) + env = os.environ.copy() | {"PATH": f"{tools}:{os.environ['PATH']}"} + def launch(*args): + result = subprocess.run([str(launcher), *args], cwd=checkout, env=env, + capture_output=True, text=True, timeout=15) + assert result.returncode == 0, result.stderr + return json.loads(forwarded.read_text()) + args = launch("config", "--json") + assert args == ["-I", "-m", "producer", "config", f"--config={checkout / 'config.toml'}", "--json"] + calls = [json.loads(line) for line in log.read_text().splitlines()] + installs = [call for call in calls if call[0] == "pip"] + assert len(installs) == 1 and installs[0][-1].endswith("[bundled-ffmpeg]") + args = launch("gui", "--root", str(checkout), "--port", "8766") + assert args[3:5] == ["gui", f"--config={checkout / 'config.toml'}"] + calls = [json.loads(line) for line in log.read_text().splitlines()] + installs = [call for call in calls if call[0] == "pip"] + assert len(installs) == 2 and installs[1][-1].endswith("[bundled-ffmpeg,gui]") + before = log.read_text() + args = launch("gui", "--config=custom.toml") + assert args[-2:] == ["gui", "--config=custom.toml"] + launch("config") + assert log.read_text() == before # GUI/core marker reuse, no extra installs. + + +def test_cli_commands_import_without_optional_web_dependencies(): + # A fresh interpreter explicitly denies optional imports, even if installed. + code = """ +import sys +class Deny: + def find_spec(self, fullname, *args): + if fullname.split('.')[0] in ('fastapi', 'uvicorn', 'starlette'): + raise AssertionError('CLI imported optional GUI dependency: ' + fullname) +sys.meta_path.insert(0, Deny()) +from producer.cli import parser +assert parser().parse_args(['config']).command == 'config' +""" + result = subprocess.run([sys.executable, "-c", code], capture_output=True, text=True) + assert result.returncode == 0, result.stderr + + +def test_server_binds_only_loopback_and_prints_fragment_token(tmp_path, monkeypatch, capsys): + pytest.importorskip("fastapi") + uvicorn = pytest.importorskip("uvicorn") + import socket + from producer import gui + with socket.socket() as reservation: + reservation.bind(("127.0.0.1", 0)) + port = reservation.getsockname()[1] + seen = [] + class Server: + def __init__(self, config): + assert config.host == "127.0.0.1" + assert config.proxy_headers is False + assert config.access_log is False + def run(self, sockets): + seen.append(sockets[0].getsockname()) + monkeypatch.setattr(uvicorn, "Server", Server) + gui.serve(tmp_path, port) + assert seen == [("127.0.0.1", port)] + output = capsys.readouterr().out + assert f"http://127.0.0.1:{port}/#token=" in output + assert len(output.split("#token=")[1].splitlines()[0]) >= 43 + for invalid in (0, -1, 65536): + with pytest.raises(ValueError): + gui.serve(tmp_path, invalid) diff --git a/lib/project/tests/test_pauses.py b/lib/project/tests/test_pauses.py new file mode 100644 index 0000000..b67f98d --- /dev/null +++ b/lib/project/tests/test_pauses.py @@ -0,0 +1,200 @@ +import hashlib +import io +import sys +from types import SimpleNamespace + +import numpy as np +import pytest +import soundfile as sf + +from producer import pauses, pipeline +from producer.config import resolve + + +def test_hysteresis_keeps_short_words_and_closes_at_eof(): + values = [0, 0.5, 0.25, 0.1, 0, 0.8] + regions = pauses.speech_regions(values, 6 * 1536 - 7, 48000, 0.35) + assert regions == [(1536, 4608), (7680, 6 * 1536 - 7)] + + +@pytest.mark.parametrize("values", [[0.5], [0.5, float("nan")], [0.5, 1.1], [0.5, "bad"]]) +def test_invalid_detector_output_is_not_silently_accepted(values): + with pytest.raises(RuntimeError): + pauses.speech_regions(values, 3072, 48000, 0.35) + + +def test_padding_merges_close_words_but_keeps_long_gaps(): + settings = resolve() + # One millisecond per sample makes these expected boundaries easy to read. + speech = [(500, 800), (1000, 1300), (2000, 2300)] + assert pauses.pause_regions(speech, 3000, 1000, settings) == [ + (0, 380), (1420, 1880), (2420, 3000)] + # No room for padding plus fade plus the requested zero core: leave alone. + assert pauses.pause_regions([(0, 100), (400, 1000)], 1000, 1000, settings) == [] + + +def test_streamed_envelope_preserves_onsets_and_duration(wav, tmp_path, progress): + sr = 48000 + audio = np.full(3 * sr + 7, 0.001, dtype="float32") + source = wav(audio) + target = tmp_path / "silenced.wav" + # Deliberately straddle the 65536-sample processing boundary. + a, b, fade = 60000, 100000, 480 + pauses.silence_pauses(source, target, [(a, b)], 10, progress) + result, rate = sf.read(target, dtype="float32") + assert rate == sr and result.shape == audio.shape + np.testing.assert_array_equal(result[:a + 1], audio[:a + 1]) + np.testing.assert_array_equal(result[b - 1:], audio[b - 1:]) + assert np.count_nonzero(result[a + fade:b - fade]) == 0 + assert np.all(np.diff(result[a:a + fade]) <= 0) + assert np.all(np.diff(result[b - fade:b]) >= 0) + assert np.max(np.abs(np.diff(result))) < 0.00001 + + +def test_file_edges_do_not_fade_noise_back_in(wav, tmp_path, progress): + source = wav(np.full(48000, 0.001)) + target = tmp_path / "edges.wav" + pauses.silence_pauses(source, target, [(0, 12000), (36000, 48000)], 10, progress) + data = sf.read(target)[0] + assert np.count_nonzero(data[:11520]) == 0 + assert np.count_nonzero(data[36480:]) == 0 + np.testing.assert_array_equal(data[12000:36000], sf.read(source)[0][12000:36000]) + + +def test_no_intervals_is_exact_bypass(wav, tmp_path, progress): + source = wav(np.random.default_rng(0).normal(0, 0.01, 200001)) + target = tmp_path / "out.wav" + pauses.silence_pauses(source, target, [], 10, progress) + np.testing.assert_array_equal(sf.read(source)[0], sf.read(target)[0]) + with pytest.raises(ValueError, match="distinct"): + pauses.silence_pauses(source, source, [], 10, progress) + + +@pytest.mark.parametrize("rate", [44100, 48000]) +def test_pipeline_detects_before_denoise_and_exports_zero_cores( + wav, tmp_path, progress, ffmpeg, monkeypatch, rate): + sr = 48000 + t = np.arange(2 * sr) / sr + speech = 0.15 * np.sin(2 * np.pi * 220 * t) + audio = np.concatenate([speech, np.full(sr, 0.001), speech]) + source = wav(audio) + original = source.read_bytes() + events = [] + + def detect(prepared, *args): + events.append("detect") + data = sf.read(prepared)[0] + assert np.max(np.abs(data)) > 0.14 # Not the attenuated denoiser output. + return [(2 * sr + 5760, 3 * sr - 5760)], {"speech_regions": 2, "pauses_silenced": 1} + + def denoise(prepared, target, *args, **kwargs): + assert events == ["detect"] + events.append("denoise") + data, sr = sf.read(prepared) + sf.write(target, data * 0.8, sr, subtype="FLOAT") + + monkeypatch.setattr(pauses, "detect_pauses", detect) + monkeypatch.setattr("producer.ai.denoise", denoise) + settings = resolve(overrides={"pause_silence": True, "sample_rate": rate}) + target = tmp_path / "master.wav" + report = pipeline.process(source, target, settings, progress) + data, sr = sf.read(target) + assert sr == rate and len(data) == 5 * rate + # Resampling and dynamics may have tails at edges; the interior is zero, + # even after full normalization and final 32-bit encoding. + assert np.count_nonzero(data[int(2.3 * rate):int(2.7 * rate)]) == 0 + assert report["pause_cleanup"]["pauses_silenced"] == 1 + assert report["silence_removed_seconds"] == 0 + assert abs(report["master_loudness"]["input_i"] - settings.target_lufs) <= 0.5 + assert report["master_loudness"]["input_tp"] <= settings.true_peak_db + 0.1 + assert source.read_bytes() == original + + +def test_no_speech_fails_open_and_skips_requested_trim( + wav, tmp_path, bypass, progress, ffmpeg, monkeypatch): + bypass.pause_silence = bypass.trim_silence = True + monkeypatch.setattr(pauses, "detect_pauses", lambda *args: ([], {"speech_regions": 0})) + source = wav() + report = pipeline.process(source, tmp_path / "out.wav", bypass, progress) + assert report["master"]["frames"] == report["input"]["frames"] + assert any("found no speech" in warning for warning in report["warnings"]) + assert report["gaps_trimmed"] == 0 + + +def test_detector_failure_does_not_publish_or_fallback( + wav, tmp_path, bypass, progress, ffmpeg, monkeypatch): + bypass.pause_silence = True + def fail(*args): + raise RuntimeError("injected detector failure") + monkeypatch.setattr(pauses, "detect_pauses", fail) + target = tmp_path / "master.wav" + target.write_bytes(b"previous") + with pytest.raises(RuntimeError, match="injected detector"): + pipeline.process(wav(), target, bypass, progress, overwrite=True) + assert target.read_bytes() == b"previous" + assert not list(tmp_path.glob(".producer-*")) + + +def test_vad_trimming_only_removes_zero_cores(wav, tmp_path, bypass, progress, ffmpeg, monkeypatch): + sr = 48000 + bypass.pause_silence = bypass.trim_silence = True + bypass.gap_threshold_db = -20 # Would eat this entire quiet recording in legacy mode. + speech = np.full(sr, 0.005) + source = wav(np.concatenate([speech, np.full(3 * sr, 0.001), speech])) + monkeypatch.setattr(pauses, "detect_pauses", lambda *args: + ([(sr + 5760, 4 * sr - 5760)], {"speech_regions": 2})) + target = tmp_path / "out.wav" + report = pipeline.process(source, target, bypass, progress) + data = sf.read(target)[0] + np.testing.assert_allclose(data[:sr], speech, atol=1e-8) + np.testing.assert_allclose(data[-sr:], speech, atol=1e-8) + assert report["gaps_trimmed"] == 1 + assert report["silence_removed_seconds"] > 1 + + +def test_worker_uses_state_context_and_pads_partial_last_frame(wav, tmp_path, monkeypatch): + from producer.vad_worker import probabilities + calls = [] + class Session: + def __init__(self, *args, **kwargs): + assert kwargs["providers"] == ["CPUExecutionProvider"] + def run(self, _, inputs): + calls.append(inputs) + return np.array([[0.75]]), inputs["state"] + 1 + monkeypatch.setitem(sys.modules, "onnxruntime", SimpleNamespace( + SessionOptions=SimpleNamespace, InferenceSession=Session)) + audio = np.arange(600, dtype="float32") / 600 + path = tmp_path / "vad.wav" + sf.write(path, audio, 16000, subtype="FLOAT") + assert probabilities(path, tmp_path / "model") == [0.75, 0.75] + assert calls[0]["input"].shape == (1, 576) + assert np.count_nonzero(calls[0]["input"][0, :64]) == 0 + np.testing.assert_array_equal(calls[1]["input"][0, :64], audio[448:512]) + assert np.count_nonzero(calls[1]["input"][0, 152:]) == 0 + assert np.all(calls[1]["state"] == 1) + + +def test_model_download_is_verified_and_cached(tmp_path, monkeypatch, progress): + root = tmp_path / "runtime" + monkeypatch.setattr(pauses, "data_dir", lambda: root) + environment = root / "vad-onnx-1.20.1-py311-v1" + (environment / "bin").mkdir(parents=True) + (environment / "bin/python").touch() + (environment / ".producer-ready").touch() + payload = b"test model" + monkeypatch.setattr(pauses, "MODEL_SHA256", hashlib.sha256(payload).hexdigest()) + downloads = [] + def download(*args, **kwargs): + downloads.append(args) + return io.BytesIO(payload) + monkeypatch.setattr(pauses.urllib.request, "urlopen", download) + python, model = pauses.ensure_vad(progress) + assert model.read_bytes() == payload + assert pauses.ensure_vad(progress) == (python, model) + assert len(downloads) == 1 + model.write_bytes(b"corrupt cached model") + monkeypatch.setattr(pauses.urllib.request, "urlopen", lambda *a, **k: io.BytesIO(b"bad")) + with pytest.raises(RuntimeError, match="checksum mismatch"): + pauses.ensure_vad(progress) + assert model.read_bytes() == b"corrupt cached model" + assert not list(model.parent.glob(".silero-*")) diff --git a/lib/project/uv.lock b/lib/project/uv.lock index 0c22e8c..ac18e66 100644 --- a/lib/project/uv.lock +++ b/lib/project/uv.lock @@ -7,6 +7,46 @@ resolution-markers = [ ] [[package]] +name = "annotated-doc" +version = "0.0.5" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/5a/8e/38aa427ed5402449e226975b649c5dc73ccadfefeb95e6aecb8f8ea4b6b6/annotated_doc-0.0.5.tar.gz", hash = "sha256:c7e58ce09192557605d8bbd92836d7e1d520ac9580096042c0bfd197efacf1bb", size = 10758, upload-time = "2026-07-28T13:50:58.129Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/3e/30/e900b21425a860e195f32e37657aa1f7c7f2b1bfb26f03ca209b90933c06/annotated_doc-0.0.5-py3-none-any.whl", hash = "sha256:117bac03a25ede5df5440e855b32d556049ca169ead221505badf432fed4b101", size = 5302, upload-time = "2026-07-28T13:50:57.239Z" }, +] + +[[package]] +name = "annotated-types" +version = "0.8.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/5f/56/a8120250d128bed162cd73c76d45f6ef9991f3e068f62a8ee060afa3104a/annotated_types-0.8.0.tar.gz", hash = "sha256:13b2beaad985e05e2d6407ee4c4f35590b11f8d693a258a561055cac8f64cab7", size = 15893, upload-time = "2026-07-23T20:16:13.995Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/99/91/8acff4f5e50511b911bbccb72b8628a49c68ce14148cd9f6431094859a90/annotated_types-0.8.0-py3-none-any.whl", hash = "sha256:f072f4d804ea359e4eaf198b1af7a8b0943881a87f31bb764f8bf219bb9419e0", size = 13427, upload-time = "2026-07-23T20:16:12.938Z" }, +] + +[[package]] +name = "anyio" +version = "4.15.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "idna" }, + { name = "typing-extensions", marker = "python_full_version < '3.15'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/a9/d2/f4d173e22df740bc37b1db102b386ba719b66e95b0f0d751f556b387e6d2/anyio-4.15.1.tar.gz", hash = "sha256:9f28306018cbd6d329e64a36d58256edff76dd996fe423bc957326e578b82a94", size = 276966, upload-time = "2026-09-05T10:42:39.44Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/12/b8/4bd346e22b28902df4d651910f5242c28d84e4a5c2435ca5c3f797ed7e2e/anyio-4.15.1-py3-none-any.whl", hash = "sha256:6152fdbbf9a77fdec97731721bebf7c4c44f7c29b424b0065826173efc7ed101", size = 132079, upload-time = "2026-09-05T10:42:37.923Z" }, +] + +[[package]] +name = "certifi" +version = "2026.7.22" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/a3/c2/24167ea9858356b47a87a50d39908bfdb72ceeefe0041586e704e5376b3a/certifi-2026.7.22.tar.gz", hash = "sha256:741e2c3b351ddf169a738da9f2c048608ff7f2c5cc02f1ebc6b118bb090d5d55", size = 138112, upload-time = "2026-07-22T03:35:12.644Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0b/a7/71ac2cff56fec219ed242bb11b8efb69fcc4bec75db06fb7bfe35de520e6/certifi-2026.7.22-py3-none-any.whl", hash = "sha256:62f22742b58a1a33014a2b6b706588a8d7e2a88ae7bd1a6ebe8c992928483775", size = 136983, upload-time = "2026-07-22T03:35:11.276Z" }, +] + +[[package]] name = "cffi" version = "2.1.1" source = { registry = "https://pypi.org/simple" } @@ -105,6 +145,15 @@ wheels = [ ] [[package]] +name = "click" +version = "8.5.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/c7/0e/7fa0ef50764b67090eca4114772a2abf8b6148198475e54c660b97caeee6/click-8.5.0.tar.gz", hash = "sha256:ba0d2089de75ea0310e2dde03160e6ca10009947fb95a182f9b54021bb272e34", size = 382235, upload-time = "2026-08-26T13:33:14.56Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/58/50/6c0d534c5f134586a8e1ba4e330569e32f057e33372ae556463212fb4cd3/click-8.5.0-py3-none-any.whl", hash = "sha256:255bc9599cf7748b4b1a446ccc735421bd08a2ae529a8b88597d3de5664ee360", size = 125251, upload-time = "2026-08-26T13:33:12.928Z" }, +] + +[[package]] name = "colorama" version = "0.4.6" source = { registry = "https://pypi.org/simple" } @@ -114,6 +163,68 @@ wheels = [ ] [[package]] +name = "fastapi" +version = "0.141.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "annotated-doc" }, + { name = "pydantic" }, + { name = "starlette" }, + { name = "typing-extensions" }, + { name = "typing-inspection" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/8a/02/91e3416a8fdd715abb903a952a6bec7cdd8d14eed55d415fc8595524c319/fastapi-0.141.1.tar.gz", hash = "sha256:e8822fc40db1e1858054d7a949a888695bc9bdce70139178e33bd2871a453ca1", size = 425799, upload-time = "2026-07-29T17:18:05.568Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/cb/03/10388a42375ee7e4ac9b94eb2c5c569c8b5795e377e701c9ac3ad63de890/fastapi-0.141.1-py3-none-any.whl", hash = "sha256:bfb91aa2d334c61cb35ba9a116fc123b3d3df31640b801cf57a7a78ec3f603b3", size = 131954, upload-time = "2026-07-29T17:18:04.364Z" }, +] + +[[package]] +name = "h11" +version = "0.16.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/01/ee/02a2c011bdab74c6fb3c75474d40b3052059d95df7e73351460c8588d963/h11-0.16.0.tar.gz", hash = "sha256:4e35b956cf45792e4caa5885e69fba00bdbc6ffafbfa020300e549b208ee5ff1", size = 101250, upload-time = "2025-04-24T03:35:25.427Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/04/4b/29cac41a4d98d144bf5f6d33995617b185d14b22401f75ca86f384e87ff1/h11-0.16.0-py3-none-any.whl", hash = "sha256:63cf8bbe7522de3bf65932fda1d9c2772064ffb3dae62d55932da54b31cb6c86", size = 37515, upload-time = "2025-04-24T03:35:24.344Z" }, +] + +[[package]] +name = "httpcore" +version = "1.0.9" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "certifi" }, + { name = "h11" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/06/94/82699a10bca87a5556c9c59b5963f2d039dbd239f25bc2a63907a05a14cb/httpcore-1.0.9.tar.gz", hash = "sha256:6e34463af53fd2ab5d807f399a9b45ea31c3dfa2276f15a2c3f00afff6e176e8", size = 85484, upload-time = "2025-04-24T22:06:22.219Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7e/f5/f66802a942d491edb555dd61e3a9961140fd64c90bce1eafd741609d334d/httpcore-1.0.9-py3-none-any.whl", hash = "sha256:2d400746a40668fc9dec9810239072b40b4484b640a8c38fd654a024c7a1bf55", size = 78784, upload-time = "2025-04-24T22:06:20.566Z" }, +] + +[[package]] +name = "httpx" +version = "0.28.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anyio" }, + { name = "certifi" }, + { name = "httpcore" }, + { name = "idna" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/b1/df/48c586a5fe32a0f01324ee087459e112ebb7224f646c0b5023f5e79e9956/httpx-0.28.1.tar.gz", hash = "sha256:75e98c5f16b0f35b567856f597f06ff2270a374470a5c2392242528e3e3e42fc", size = 141406, upload-time = "2024-12-06T15:37:23.222Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2a/39/e50c7c3a983047577ee07d2a9e53faf5a69493943ec3f6a384bdc792deb2/httpx-0.28.1-py3-none-any.whl", hash = "sha256:d909fcccc110f8c7faf814ca82a9a4d816bc5a6dbfea25d6591d6985b8ba59ad", size = 73517, upload-time = "2024-12-06T15:37:21.509Z" }, +] + +[[package]] +name = "idna" +version = "3.19" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/5f/f7/abb373e5757eaec4b922b92f97ec8d6d7e057cf06778247604fbc4e7c3f3/idna-3.19.tar.gz", hash = "sha256:5e0811a4383b21dc5838069f801c4fb62113b7447663d2530d2bd6e77b49bf15", size = 215237, upload-time = "2026-08-18T05:14:24.27Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/57/b0/0e52c878c53f245edd3a11020f20979b3f490f245af532c7cae3027754b5/idna-3.19-py3-none-any.whl", hash = "sha256:815e7be7a7806d54abb586dc943addc79e8b2ee16915059658cbeff4b1b43bf4", size = 68550, upload-time = "2026-08-18T05:14:22.343Z" }, +] + +[[package]] name = "imageio-ffmpeg" version = "0.6.0" source = { registry = "https://pypi.org/simple" } @@ -348,19 +459,27 @@ dependencies = [ bundled-ffmpeg = [ { name = "imageio-ffmpeg" }, ] +gui = [ + { name = "fastapi" }, + { name = "uvicorn" }, +] test = [ + { name = "httpx" }, { name = "pytest" }, ] [package.metadata] requires-dist = [ + { name = "fastapi", marker = "extra == 'gui'", specifier = ">=0.115,<1" }, + { name = "httpx", marker = "extra == 'test'", specifier = ">=0.27,<1" }, { 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" }, + { name = "uvicorn", marker = "extra == 'gui'", specifier = ">=0.30,<1" }, ] -provides-extras = ["test", "bundled-ffmpeg"] +provides-extras = ["test", "gui", "bundled-ffmpeg"] [[package]] name = "pycparser" @@ -372,6 +491,123 @@ wheels = [ ] [[package]] +name = "pydantic" +version = "2.13.5" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "annotated-types" }, + { name = "pydantic-core" }, + { name = "typing-extensions" }, + { name = "typing-inspection" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/53/ef/fc4f868f4e2cee79f863883abffceff107875f569b848507319842d2a681/pydantic-2.13.5.tar.gz", hash = "sha256:51a9c5f7b2f8e636f04c6cada605d9b6a3bf1348fdf945a3d8869b19bba0ee08", size = 845750, upload-time = "2026-08-28T14:04:00.916Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/eb/47/c95ffc2009878c7aac0c5e08528022dcb885933252a88b5f170058014464/pydantic-2.13.5-py3-none-any.whl", hash = "sha256:346a034f080da3755d8e9cb5e00e8b07de1d39e4f6e2c87d8ab7cafa0b269a73", size = 472589, upload-time = "2026-08-28T14:03:59.136Z" }, +] + +[[package]] +name = "pydantic-core" +version = "2.46.5" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/af/f9/8a06bea35ef8daf588f707784c973a7046e0034c8d8cfb08828eeffb8b75/pydantic_core-2.46.5.tar.gz", hash = "sha256:10416c15b8839ecc4ef4d0885da76da6fd0f67333a0eb8aff6d93c4b8f2910fc", size = 472262, upload-time = "2026-08-28T10:01:31.677Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a2/b6/81d2d19ea0be2c03664381b59f65fa72fc7969decedae00bc2c4ad835708/pydantic_core-2.46.5-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:a1dee1b804ff4d11c663636cf15d2ea47e9f79cd56c033fb1cbf08924842a48f", size = 2074737, upload-time = "2026-08-28T09:57:57.711Z" }, + { url = "https://files.pythonhosted.org/packages/0c/18/b70da8300e292df4099684ea11b1958043580d2f50d2dc8bf7e542bdd84a/pydantic_core-2.46.5-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:d625a186a65201c23a9e3b8ed9c47e90a026e03256608cc91851c6709096844f", size = 1921751, upload-time = "2026-08-28T09:57:59.265Z" }, + { url = "https://files.pythonhosted.org/packages/e7/1a/0d590341b6ffa4b4aca83508e6b8db4761aaeacfc15a25ca3815876d4797/pydantic_core-2.46.5-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4f8507560a9284e1370bb048ed4282012fbef4e8d109875b95e884d228552061", size = 1948231, upload-time = "2026-08-28T09:58:00.678Z" }, + { url = "https://files.pythonhosted.org/packages/7d/1d/02eb35761c51f2f7b1b042d6ab4cda6600f0c8c88a2243b3f734376201e5/pydantic_core-2.46.5-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:5f93c5fe914d75fbec9a49209b00da5f08e9e467d69da2b1510c81940cfd10be", size = 2020708, upload-time = "2026-08-28T09:58:02.267Z" }, + { url = "https://files.pythonhosted.org/packages/4a/ea/f86073830e35d508cc8ddf9c3d9e6e6840fcb88d34bf726b0b4710186f27/pydantic_core-2.46.5-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:aca6c767f552b21b10f774aeac128e828eafb796adfa1b666a18bf6321453c3a", size = 2194914, upload-time = "2026-08-28T09:58:03.934Z" }, + { url = "https://files.pythonhosted.org/packages/bb/d7/fc36240d7791ce90939e51608568c33bfdae26202016f9770c229a487d86/pydantic_core-2.46.5-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:701b2e04b560eeb4bddf7a25ab8ca476176e34fdbd9a0e18196f0d12d4685f0b", size = 2235622, upload-time = "2026-08-28T09:58:05.516Z" }, + { url = "https://files.pythonhosted.org/packages/cf/bc/3fa2d76b83162820a17da7f645b28d1cba99fc8e1e5fc6517067ec450fa1/pydantic_core-2.46.5-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:49776eab08766a08dfff7012f8b422dcd7e25e43b316eedf0477c24fcfa84b7c", size = 2062091, upload-time = "2026-08-28T09:58:07.135Z" }, + { url = "https://files.pythonhosted.org/packages/ab/9a/095d557bb492c90cd8a70a6dd048bf793d433d03d86c81c11e912e4cd049/pydantic_core-2.46.5-cp311-cp311-manylinux_2_31_riscv64.whl", hash = "sha256:a2468d93d181667a7abd66e1b64bb9f76f361b0fef8faddf687456453576f5ee", size = 2089904, upload-time = "2026-08-28T09:58:08.814Z" }, + { url = "https://files.pythonhosted.org/packages/24/98/7b76b1ad10a19a617a52aaa1d80e159115af939b095e86f8e756fd52e0df/pydantic_core-2.46.5-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:53feb344243bb9510a9dec7bf3cf1b64d88a98af5dc7872a5160465f8b198c8e", size = 2132244, upload-time = "2026-08-28T09:58:10.435Z" }, + { url = "https://files.pythonhosted.org/packages/20/32/7d6ca365fadba186a0c8f85de1a701663bce81efd309d9479be58687622f/pydantic_core-2.46.5-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:cd5214352ae68f3b5e9af7768bdc5253695ee069675db3480518420b3be881f2", size = 2143901, upload-time = "2026-08-28T09:58:12.033Z" }, + { url = "https://files.pythonhosted.org/packages/f8/09/eb9a6aa57f22fd1541a9c0aa2a1f3aeef3ec65347d33e10a6da2f43e0ee9/pydantic_core-2.46.5-cp311-cp311-musllinux_1_1_armv7l.whl", hash = "sha256:9432f3598db432cb51c5b37fdbf29a60fcccc79e30d37a05022776a6bc4ab689", size = 2299425, upload-time = "2026-08-28T09:58:13.614Z" }, + { url = "https://files.pythonhosted.org/packages/8a/f9/548a5bb9d4ba8cd26e26daf48052236f6b38bb61e7b7241fbc3c995719eb/pydantic_core-2.46.5-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:8feeac04b5794e513e710af2f9c87d49f31a6dc47967bb264a1fed61a8989bec", size = 2318566, upload-time = "2026-08-28T09:58:15.199Z" }, + { url = "https://files.pythonhosted.org/packages/4a/20/06454d18834c02c406c9133f1a3b485305fd9ee984f9636c2f730bef6a9d/pydantic_core-2.46.5-cp311-cp311-win32.whl", hash = "sha256:892a881d5f68c2b9ea304b7a6c2c60d9343df578a311b0f86b94bc8f1ffe8129", size = 1954258, upload-time = "2026-08-28T09:58:16.813Z" }, + { url = "https://files.pythonhosted.org/packages/9e/c2/718b9deb4b72453b5d8c7447a3b14cb77bef36917ef5f514e0948a4096a0/pydantic_core-2.46.5-cp311-cp311-win_amd64.whl", hash = "sha256:40375c2d05acec10323e45dfe2077ac44bc74659008614af5069034e2cfc781c", size = 2041030, upload-time = "2026-08-28T09:58:18.288Z" }, + { url = "https://files.pythonhosted.org/packages/67/ea/c1d1a5b72d6e1ff7f377a4d9199f6591f095beb5b409a8a5d89f7238d939/pydantic_core-2.46.5-cp311-cp311-win_arm64.whl", hash = "sha256:28a6a556cd3b6066bea827857f9d9cce027c96f776e512f544a581f9e42161f8", size = 2009234, upload-time = "2026-08-28T09:58:19.929Z" }, + { url = "https://files.pythonhosted.org/packages/82/3f/76358795aa7a8c6d4f36e2cb828ad1c90ee118e1393a9281664f5aade9d4/pydantic_core-2.46.5-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:b9fe6fb92520e3fd61f2e49000b6911b188824f089b75973ea06d6267f0b476d", size = 2076516, upload-time = "2026-08-28T09:58:21.576Z" }, + { url = "https://files.pythonhosted.org/packages/db/50/26b091836076ce4cb2fac264186936acc069e0595772cfd02a563bc4761a/pydantic_core-2.46.5-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:a39ac25a9a2fa4072efdb429833c4a4c8009a51ff9eea3eeae131713cd27991e", size = 1922874, upload-time = "2026-08-28T09:58:23.766Z" }, + { url = "https://files.pythonhosted.org/packages/09/f0/2a8ce3849e299d44e2d2c196b6082643a3235565a735cb51db7a6261f614/pydantic_core-2.46.5-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4fdc8b93a41521988916eeaa271173fcca7fa0803d62f87675aac8dcec1c8e29", size = 1951772, upload-time = "2026-08-28T09:58:25.435Z" }, + { url = "https://files.pythonhosted.org/packages/87/46/ac0dc8bdd9e6048183a14eb127764e7ad9240021c17513074a4711b0e31e/pydantic_core-2.46.5-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:b98134087d9de723658d17a42c7d0da8d6e2ef08015dee7dc93889047315f5e4", size = 2031832, upload-time = "2026-08-28T09:58:27.102Z" }, + { url = "https://files.pythonhosted.org/packages/c4/c2/339de5bef7be36301a2231eaa52e62163742c2281f11b5f4892bc79785cd/pydantic_core-2.46.5-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:e652ab17569c94bff5475520f907b7148b8c24036a8ebbe5cf7cf7493d28579a", size = 2208645, upload-time = "2026-08-28T09:58:28.948Z" }, + { url = "https://files.pythonhosted.org/packages/7b/a0/9ff22b797724262da14427abaed4dd1d864a139693fc5e7809114376a716/pydantic_core-2.46.5-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:d925f3d9afd05a8c0fb3a1031463a8d59ebe5e2afad297e29c78be19e13b4e62", size = 2265935, upload-time = "2026-08-28T09:58:30.625Z" }, + { url = "https://files.pythonhosted.org/packages/c0/a4/eb9409ec0736e50aa70a412f16c204ed149516846912f7e6724d4c73ee53/pydantic_core-2.46.5-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0fc5be0abd4a407e200d844b404e33639a554e7bd0d448e7b9ae181be4789ac2", size = 2066284, upload-time = "2026-08-28T09:58:32.289Z" }, + { url = "https://files.pythonhosted.org/packages/c0/02/7f6156ffc926857f1c37c07d9a388682865a81830ab6a1b637082c25e399/pydantic_core-2.46.5-cp312-cp312-manylinux_2_31_riscv64.whl", hash = "sha256:816ff0a6550ffc06c098ccd2e0698600f9aa7da192a79eaa6f9af504a35db869", size = 2105889, upload-time = "2026-08-28T09:58:33.986Z" }, + { url = "https://files.pythonhosted.org/packages/92/b1/e781d357ebe09fc929f995700f1b3503e8897f1cece183ecb1300d4d67e9/pydantic_core-2.46.5-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:c7ea57fc63aa7da93a1bd2d644e6577befae10c52c4e36377635eea1056a74f5", size = 2158006, upload-time = "2026-08-28T09:58:35.647Z" }, + { url = "https://files.pythonhosted.org/packages/70/0a/644597d84ab400e50609c192120b85c9681c22d3a20461b9060a79be0a7a/pydantic_core-2.46.5-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:efd62a42486f1bda5d24cb4f63d15a3c7768375fe83d36f9417b4ad7a2fb20b3", size = 2158408, upload-time = "2026-08-28T09:58:37.38Z" }, + { url = "https://files.pythonhosted.org/packages/1e/ee/ca3b7b3a4b3769ffe9ce9432a7c9be755de9593a46d3b0d54d0409323e44/pydantic_core-2.46.5-cp312-cp312-musllinux_1_1_armv7l.whl", hash = "sha256:2bc9419666990c06d7397831f2126a1ecc3594aaa3ff7de5bf2d066802f4e07b", size = 2309609, upload-time = "2026-08-28T09:58:39.22Z" }, + { url = "https://files.pythonhosted.org/packages/ce/52/39fa1f451486019524ca685020390e7ca351832fd874530ba30c8628e6dc/pydantic_core-2.46.5-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:18a09e1e1011b462f2e32774f25859ef1223d5c2b0546a633cf56654710721e0", size = 2342618, upload-time = "2026-08-28T09:58:40.89Z" }, + { url = "https://files.pythonhosted.org/packages/81/5e/468fc630568c61dcef3cd47ad32ffbeed9af643f49208d1ea86ab4f890c4/pydantic_core-2.46.5-cp312-cp312-win32.whl", hash = "sha256:5cb482e9e84c851f4e623fe4acc1ced89168cf1fe18f7089db4548c8f5bbb65b", size = 1939475, upload-time = "2026-08-28T09:58:42.591Z" }, + { url = "https://files.pythonhosted.org/packages/cf/c9/4c19f41b84cf6b622a72fbeed7665b25d47a187d68d47d0d430c07f23268/pydantic_core-2.46.5-cp312-cp312-win_amd64.whl", hash = "sha256:5e81740c09e310f5aa5cbd3e434a01c154d4bef93241c7877b39f211d2b78ba8", size = 2043140, upload-time = "2026-08-28T09:58:44.272Z" }, + { url = "https://files.pythonhosted.org/packages/af/dd/0c1a050299147c746e5256db16d645ab5efd4f78c59937d581a0524e74a2/pydantic_core-2.46.5-cp312-cp312-win_arm64.whl", hash = "sha256:f7b0ec93a2893de856652154d73b7ba622f26fa97726487dcac373de5f4c6084", size = 1997729, upload-time = "2026-08-28T09:58:46.13Z" }, + { url = "https://files.pythonhosted.org/packages/f5/37/5abe39a8372a61d3dc3c1338fc504281c01b32fdb3169cd7187153b56d3e/pydantic_core-2.46.5-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:b7ca9034437b6022f941f4857459562ee00a560b97e7cce8a0ec5a74fc6766e0", size = 2075885, upload-time = "2026-08-28T09:58:47.856Z" }, + { url = "https://files.pythonhosted.org/packages/21/43/6323b1f8b217780454c61304bcd2b38ae4762f50754414124603ccc90bb2/pydantic_core-2.46.5-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:f332f0e72a5a0400141f830744e141bf9f97917878dbe968669e8a7fefea78ff", size = 1922768, upload-time = "2026-08-28T09:58:49.58Z" }, + { url = "https://files.pythonhosted.org/packages/0f/a3/c05ca796e1197618a774b01e596aeedfefc2f7d8c01ae3054e910b120e8a/pydantic_core-2.46.5-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:193375f3548919d3f0b60936ca113ada3e38f264f91b9b8e0508efaad57be931", size = 1951241, upload-time = "2026-08-28T09:58:51.511Z" }, + { url = "https://files.pythonhosted.org/packages/68/32/33bc39ac705c52cffc908e8389f9754fdb208aea5c69cceddf4eb3ce99af/pydantic_core-2.46.5-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:79bdfa52f843137045b2d081cc05c120ba6665d29b7559c2c47690906f39279f", size = 2031975, upload-time = "2026-08-28T09:58:53.166Z" }, + { url = "https://files.pythonhosted.org/packages/b0/70/2333e885c0f6a67bc105c5916965dac9b57f2718ee20d81d1a06a4ebdc13/pydantic_core-2.46.5-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:24922243639cbdac66c75fcb6fd6495a9cb52b213d62f9a0d16f0310b1ff8038", size = 2208542, upload-time = "2026-08-28T09:58:55.017Z" }, + { url = "https://files.pythonhosted.org/packages/f7/ea/296debfb4264207bbda5936133892e027c0a58875ad53ebd512fba8ec3a2/pydantic_core-2.46.5-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:c76fe65e607be28c7fd4d56fc3c42b1583aa058ce3408b7ad0fd540171d31f9f", size = 2264692, upload-time = "2026-08-28T09:58:56.767Z" }, + { url = "https://files.pythonhosted.org/packages/d3/f2/9e4de77a6271e07a76d2d58b11c091a979c191ed2939bf80067568b369d2/pydantic_core-2.46.5-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6f7b393a8b3da82f5c1fc0751e6d01ac6c55b93c18226a60bdfba4a724efafd1", size = 2066633, upload-time = "2026-08-28T09:58:58.531Z" }, + { url = "https://files.pythonhosted.org/packages/8d/db/f9e9d0c97445987b2084823d5c240de88087338f04fc2cfaa2df186b8049/pydantic_core-2.46.5-cp313-cp313-manylinux_2_31_riscv64.whl", hash = "sha256:7ac031912d54f3d83ef3b3eb98dfabc1608802e2202263d25957eeed40b94761", size = 2105235, upload-time = "2026-08-28T09:59:00.421Z" }, + { url = "https://files.pythonhosted.org/packages/07/c5/79169b047b3b2c3e99e04bc76372af9637e0bf6db638274fa927df96369e/pydantic_core-2.46.5-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:837b396ca3d7b74091ca623f6cbd8351bd42d670a79c2683e79fb089f06a2de5", size = 2157367, upload-time = "2026-08-28T09:59:02.442Z" }, + { url = "https://files.pythonhosted.org/packages/26/b5/ba6057afb7c291bd449f51b867f95aef2072941c4ce4e5c31d6ffd132d3b/pydantic_core-2.46.5-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:5ee239d575f80b08eca11f6e20f90c4c695de7825c67eefe6091fbf20dda648e", size = 2158420, upload-time = "2026-08-28T09:59:04.2Z" }, + { url = "https://files.pythonhosted.org/packages/6e/28/2057abecaafdc22912afa819603a51f0a62d40643b7c4871c51721fea9be/pydantic_core-2.46.5-cp313-cp313-musllinux_1_1_armv7l.whl", hash = "sha256:e80675d75ae2cd14372cb65cad5400d9347a3d3f6c13000183f22dfd027283ed", size = 2309588, upload-time = "2026-08-28T09:59:06.048Z" }, + { url = "https://files.pythonhosted.org/packages/71/9d/881156dc404e27479c4246128d73538464cab4a239bec61995e227644c30/pydantic_core-2.46.5-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:9c4b71f10dd532fb7a5cbc8f58707779e64f03a258c2bf8bfbaecfcd9970b519", size = 2341866, upload-time = "2026-08-28T09:59:08.539Z" }, + { url = "https://files.pythonhosted.org/packages/5a/38/d66f443a259f84d13babdceae568e572b0ed26da17ca5d0a649ebb110a67/pydantic_core-2.46.5-cp313-cp313-win32.whl", hash = "sha256:97bf8de4d541598c94a59344eeb988a94c08ff76b5723c41f6567ec18c7892ea", size = 1938580, upload-time = "2026-08-28T09:59:10.402Z" }, + { url = "https://files.pythonhosted.org/packages/2c/1e/1d5371213f4cc9a7ed70c0bfcc7911de22311ee99a662a56077d7292d2ac/pydantic_core-2.46.5-cp313-cp313-win_amd64.whl", hash = "sha256:15f4a94963c95accac15b7b657bb177d3ad82bb90b0d0526d9a9b85079925db5", size = 2041980, upload-time = "2026-08-28T09:59:12.396Z" }, + { url = "https://files.pythonhosted.org/packages/5a/48/4222d90b1c67568bace4dec6dca6271449c66de3595d72b6d098f5fde597/pydantic_core-2.46.5-cp313-cp313-win_arm64.whl", hash = "sha256:d22a945598fb91236b4dd793a6e42e4f3dd7740bb5aace5ebd7d4c08d13bb575", size = 1997213, upload-time = "2026-08-28T09:59:14.245Z" }, + { url = "https://files.pythonhosted.org/packages/8e/8a/14596f2a8367da50cf7cbac48169ee5d9c8e11d486a3b527082384630c72/pydantic_core-2.46.5-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:c1c43ad4339643d70ebb8124e1305a7dab423001eff58bb41a0f731adbc98355", size = 2074081, upload-time = "2026-08-28T09:59:16.141Z" }, + { url = "https://files.pythonhosted.org/packages/ae/d5/d8a4eb6d6c7f66b91dd37c576d76e9e60fba900caf5372c17bcf949febc2/pydantic_core-2.46.5-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:1a353f84de772f423b5ffb11d7ae352fbbef0f446f3c0b0af0f8236d7233606e", size = 1920497, upload-time = "2026-08-28T09:59:18.065Z" }, + { url = "https://files.pythonhosted.org/packages/8e/26/092079428f86e927e030b2c0ced87df69dbb1c875cdeaa67bf42ea2be746/pydantic_core-2.46.5-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5086029a57366b8cf81b130a43908738095c270c21a8d7f0e8bdfdb89718e2f3", size = 1952130, upload-time = "2026-08-28T09:59:20.476Z" }, + { url = "https://files.pythonhosted.org/packages/08/c3/8ec0e290a9ebaebd64047bf5fda94be835c6b1551b02437e4b76778fbcd7/pydantic_core-2.46.5-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:46c25dda9d092a06c08db76ffe0a197107904d0dfac653f7d5306bbcd6d6119c", size = 2026371, upload-time = "2026-08-28T09:59:22.227Z" }, + { url = "https://files.pythonhosted.org/packages/01/72/4fd20ad520fb8da0157f95b27a7eb05a72790ef08138e7701ac972c342ea/pydantic_core-2.46.5-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:37ea7b83c935e5b0d68c9449b82651accf78a10828b2c02b2f2d9e9496446c21", size = 2202822, upload-time = "2026-08-28T09:59:24.277Z" }, + { url = "https://files.pythonhosted.org/packages/31/b0/d16e0771206b29314f0d52198b720be21e8a99ab2bf11e3bc0d7c9cebdff/pydantic_core-2.46.5-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:e64e88d5585bea9ce95861079de72006c7fa6d3df4e3a3b65ba31eb979c15c9f", size = 2262756, upload-time = "2026-08-28T09:59:26.608Z" }, + { url = "https://files.pythonhosted.org/packages/2c/9b/59634b7ac631c63b2a37760eb6943af3e29573d6b59a4abc5e7f019d4cee/pydantic_core-2.46.5-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:54d510bac3ee52247af28ed4bb18a1e799f040ac60fd2bf5ccd4c92f1fbe786f", size = 2068352, upload-time = "2026-08-28T09:59:29.044Z" }, + { url = "https://files.pythonhosted.org/packages/08/7c/570abb1ad2155348dc754ea91be22e5aaa18eb6d69a6068f7c6f2679a6ed/pydantic_core-2.46.5-cp314-cp314-manylinux_2_31_riscv64.whl", hash = "sha256:a2a5e1d0ff29adddc9f6d6821a66302e4493f8ca898b715b6b1182c2c201ea0a", size = 2104777, upload-time = "2026-08-28T09:59:30.95Z" }, + { url = "https://files.pythonhosted.org/packages/8e/25/5bf74adc65a1ac5b7be3f6cb0bcb5433615c1598a801c19d830d84c98ded/pydantic_core-2.46.5-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:03b9666e41e35d8909852ba191a0607520f81b74eaf12ccf8737005dbb313821", size = 2156312, upload-time = "2026-08-28T09:59:32.604Z" }, + { url = "https://files.pythonhosted.org/packages/90/6a/2ef38830675e050121040618135564ed56b860b45433b02d9b4ebece46f3/pydantic_core-2.46.5-cp314-cp314-musllinux_1_1_aarch64.whl", hash = "sha256:a91c17edf6eea2402cb5457b4c89e99bc5ed1004aa34c4adf1d4258c1a5c22c2", size = 2150067, upload-time = "2026-08-28T09:59:34.453Z" }, + { url = "https://files.pythonhosted.org/packages/90/ef/a7dbb03a14a64c2a4621f989c615ed9a892535a6cad938fc27079f919d80/pydantic_core-2.46.5-cp314-cp314-musllinux_1_1_armv7l.whl", hash = "sha256:b49924c73a235e969511bf2aabdff3beebf9820931f646c80274d5d780010c47", size = 2304516, upload-time = "2026-08-28T09:59:36.194Z" }, + { url = "https://files.pythonhosted.org/packages/68/f8/6bb4c4b80e8a6fde1904c64a51c62a1d04fcdfa3ea521a66b2ddefa1d885/pydantic_core-2.46.5-cp314-cp314-musllinux_1_1_x86_64.whl", hash = "sha256:2cbd9a5eff05e51c447c34dfa4632145b26b09120cf04bd0c871e44c1a5e1c9a", size = 2335223, upload-time = "2026-08-28T09:59:37.931Z" }, + { url = "https://files.pythonhosted.org/packages/2a/80/f46b8c681195190b2c1f1c7c0a81abce60663e987613e09ef64d433dd96b/pydantic_core-2.46.5-cp314-cp314-win32.whl", hash = "sha256:2d5d76654becf5efd62c9e51c3756c67b49498b0c9a40884934c40807adbd074", size = 1934827, upload-time = "2026-08-28T09:59:39.836Z" }, + { url = "https://files.pythonhosted.org/packages/f7/3c/60674207246bc0a4009d2391b7c7251c7159f279c8d2ab8aae8ef46f3dee/pydantic_core-2.46.5-cp314-cp314-win_amd64.whl", hash = "sha256:fa10ef4112775900e7a0661068635eb67b2ab824fbde764de6e0e21982a93db0", size = 2042648, upload-time = "2026-08-28T09:59:41.792Z" }, + { url = "https://files.pythonhosted.org/packages/69/0c/117c562c7c1babdf44576b72a5e496906506c93690387ecfbca7c729ae2e/pydantic_core-2.46.5-cp314-cp314-win_arm64.whl", hash = "sha256:045ab3b6d308439e32b81cc173bba5b9018bc6ed896afd0c65b3b009b1699af5", size = 1989652, upload-time = "2026-08-28T09:59:43.702Z" }, + { url = "https://files.pythonhosted.org/packages/e8/66/9336ae58f9eb68c41d121894e52c4c89eccb07eb8f602a04ee9c3f37736a/pydantic_core-2.46.5-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:8816f3d218beb4b787de5c9759c259b8fa61f9dec42dc7811f320a33771778b7", size = 2065829, upload-time = "2026-08-28T09:59:45.364Z" }, + { url = "https://files.pythonhosted.org/packages/c5/02/bc19b47a96c2d3109760711acf22369e56bd7e405ca52f7ade164d2ead57/pydantic_core-2.46.5-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:bce57638e08ac148e5778cce7feb968307a727d66f8e2274a543d0cf0c9ad6a3", size = 1905716, upload-time = "2026-08-28T09:59:47.18Z" }, + { url = "https://files.pythonhosted.org/packages/52/a4/70b47c0509923dd98ccfed04fb3e32ea3849c82a0ff2205bb41009b43c00/pydantic_core-2.46.5-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:976e1128455aa595ea04c79ccfedff1aaeab96ee013fcc916bed120c4f0ad94f", size = 1934216, upload-time = "2026-08-28T09:59:49.241Z" }, + { url = "https://files.pythonhosted.org/packages/52/ab/aa03b65f7bb198585edf806b906c3223ecf1795543e39e23aec4cce27ad2/pydantic_core-2.46.5-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:e7b891faeedeafba41b2983e5001a81b6a915b69544c7e7570d1989ce1c36ac7", size = 2010635, upload-time = "2026-08-28T09:59:51.692Z" }, + { url = "https://files.pythonhosted.org/packages/3c/8b/0da06343f30b84ec549aafd309c6456223d5dc8bd36af504c573faad561d/pydantic_core-2.46.5-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:5f194189415698233dd1114a093a9b56e61e2c57e11b469be3b0506f46f0771c", size = 2209369, upload-time = "2026-08-28T09:59:53.582Z" }, + { url = "https://files.pythonhosted.org/packages/d6/5b/844c4defaa34a3df66eb9257087d121d70c201298b96abdf9f492fc2f1bf/pydantic_core-2.46.5-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:82a36973cf8a2ef5406f4fe2edbf8ed0c99629535d959e0b100c76a32535a111", size = 2253238, upload-time = "2026-08-28T09:59:55.484Z" }, + { url = "https://files.pythonhosted.org/packages/f4/64/a4e536cb16d7f61a7fd3120b46c577fc7fa7325992f69c4f52bc786d77d8/pydantic_core-2.46.5-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:cdbb78909f52b981d3b2d56b97328d71eb0b974c36bd77c920123a7ebb192829", size = 2065740, upload-time = "2026-08-28T09:59:58.038Z" }, + { url = "https://files.pythonhosted.org/packages/5f/75/aaa38c6bc2d085f6605b34eabdc6a8a4e0b2e61fc9c8e6e52b28e97b3125/pydantic_core-2.46.5-cp314-cp314t-manylinux_2_31_riscv64.whl", hash = "sha256:52e24eacdb536cade636aa90fb851835222becff8484b7001fdc78cb0290f2aa", size = 2087425, upload-time = "2026-08-28T09:59:59.898Z" }, + { url = "https://files.pythonhosted.org/packages/55/ae/fcab4cfc39aba3689e1d20c8b5250ad280957022c09af2ed9cd585602a5e/pydantic_core-2.46.5-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:37ae34309d7bd8c0d61ab839668058f2a7962ea1fc51d105d2db228fe0618034", size = 2139306, upload-time = "2026-08-28T10:00:03.057Z" }, + { url = "https://files.pythonhosted.org/packages/2d/f4/f1d03a4bc9d9acbc62f4d742b8a319af52f71885079868b2ff8e48a651ee/pydantic_core-2.46.5-cp314-cp314t-musllinux_1_1_aarch64.whl", hash = "sha256:0cdbada856a1c69a7624a64d3d9aefe79300bd6ef827b43a4f265010b9b55184", size = 2144589, upload-time = "2026-08-28T10:00:05.645Z" }, + { url = "https://files.pythonhosted.org/packages/83/f3/7a53bb1356de514a4cd295f25b6ac39237895620c0462d2592b76c16e114/pydantic_core-2.46.5-cp314-cp314t-musllinux_1_1_armv7l.whl", hash = "sha256:545f26c504b27c3758439a5e6d9349931f0a04f855668d5fe323c89e82300a38", size = 2288882, upload-time = "2026-08-28T10:00:07.931Z" }, + { url = "https://files.pythonhosted.org/packages/cd/94/5a81583660c175c59d49ffb09f4b3a44debeaf86a19fca664ae1cdd9ee32/pydantic_core-2.46.5-cp314-cp314t-musllinux_1_1_x86_64.whl", hash = "sha256:ff218293c9c806138dca139765e3b067621be52bcd93cdc14c7711be7ddc90a9", size = 2335210, upload-time = "2026-08-28T10:00:10.177Z" }, + { url = "https://files.pythonhosted.org/packages/5a/9f/5d685c2693b972d1a59c998586e8823712b66603aeff47ee60a4bdaafd37/pydantic_core-2.46.5-cp314-cp314t-win32.whl", hash = "sha256:97cf3eb53a8cccacf9d46686a0926186c9bfb5574f2ed66d3639d5fe117cd3a9", size = 1921180, upload-time = "2026-08-28T10:00:12.35Z" }, + { url = "https://files.pythonhosted.org/packages/70/12/5c94ee16d65a37a15f9e869f5e6256df111154491173801a4c5e800ab548/pydantic_core-2.46.5-cp314-cp314t-win_amd64.whl", hash = "sha256:d2f9fc07a8042a8f95925b35c4f04f469707c981fc33245b6ca187cf5d2dd290", size = 2020515, upload-time = "2026-08-28T10:00:14.774Z" }, + { url = "https://files.pythonhosted.org/packages/63/19/67830dda664e6bdf9285ee2e40f355d0d7d6b92aa0c42e8d217bb8d33d36/pydantic_core-2.46.5-cp314-cp314t-win_arm64.whl", hash = "sha256:acf8a67ba51f4ca9ddbd0e6b3000a65ac51ab734661778b3e7ba64d99a710f2f", size = 1989276, upload-time = "2026-08-28T10:00:16.984Z" }, + { url = "https://files.pythonhosted.org/packages/af/1e/ecca01fce348f7e8afa9572441ff6f7d1cc70d21e4859f33944d10877e1e/pydantic_core-2.46.5-graalpy311-graalpy242_311_native-macosx_10_12_x86_64.whl", hash = "sha256:c14ad3bdc85ee7f318742c457ca3968a92126d144b15721c759033bfb06296c2", size = 2075342, upload-time = "2026-08-28T10:00:51.353Z" }, + { url = "https://files.pythonhosted.org/packages/1f/4c/af80c7a8032dfc897040ad5cb772bebde529a381186499e6e29987f23f8c/pydantic_core-2.46.5-graalpy311-graalpy242_311_native-macosx_11_0_arm64.whl", hash = "sha256:0bddb4020d8f04175865ccd17eff3040874fc11fb593f424edb452653b4b947c", size = 1907219, upload-time = "2026-08-28T10:00:53.438Z" }, + { url = "https://files.pythonhosted.org/packages/be/3e/54d89e2b092e778716bf6153634ef479e955f48c261090be23aa1e0fb0b5/pydantic_core-2.46.5-graalpy311-graalpy242_311_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2471fd51c61c610e1dcf7de44d7299283661654d11264ab4802b303368d69c47", size = 1953393, upload-time = "2026-08-28T10:00:55.58Z" }, + { url = "https://files.pythonhosted.org/packages/ea/89/828ee90cda28ce17bdefaa3a6eaf74fe430e113295a10e6126beca559d6c/pydantic_core-2.46.5-graalpy311-graalpy242_311_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b10ec717381bdbfafef34607824db4c91de69ff085e4fca3b2af91b4fa17e68a", size = 2099024, upload-time = "2026-08-28T10:00:57.794Z" }, + { url = "https://files.pythonhosted.org/packages/df/dd/053c2e4303f791f3b8f8a14ab0b22008e8eb21d868c0c90b4f9be705b76a/pydantic_core-2.46.5-graalpy312-graalpy250_312_native-macosx_10_12_x86_64.whl", hash = "sha256:013d6f3483d81e02e7c328831808f336c8596ee33b4bd4026b9ffb1e960b8942", size = 2062540, upload-time = "2026-08-28T10:01:00.318Z" }, + { url = "https://files.pythonhosted.org/packages/d7/dd/a18df751a5e37dd51bfad7f68e766999125bebe68c9e1d10a493ad01bd63/pydantic_core-2.46.5-graalpy312-graalpy250_312_native-macosx_11_0_arm64.whl", hash = "sha256:e9c134bb666dd54b778b9fc0d2b50cbb7f979b9e3716f26a88c9ab3b6fc1dd0f", size = 1902040, upload-time = "2026-08-28T10:01:02.529Z" }, + { url = "https://files.pythonhosted.org/packages/b7/13/01d40f9d07ce8a779fd6e0bd8ad4fba91309500dd67b869e2e219d261a6d/pydantic_core-2.46.5-graalpy312-graalpy250_312_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:347ec774390c87326a2e4929d58d3f7e8763a104d5d35f4cd595a4c952366433", size = 1967479, upload-time = "2026-08-28T10:01:05.004Z" }, + { url = "https://files.pythonhosted.org/packages/fa/04/c81d4841331c2178b6fb09ae225425e110ed72d990c9fe556c4ec03d1013/pydantic_core-2.46.5-graalpy312-graalpy250_312_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8e24d8f05fa2d28513d94e877e9c75ad66175376209b3977f916e240e623193c", size = 2111034, upload-time = "2026-08-28T10:01:07.345Z" }, + { url = "https://files.pythonhosted.org/packages/20/21/22102e9950b3049526d20e811b95396508377d87651edd2b80d2b3d28659/pydantic_core-2.46.5-pp311-pypy311_pp73-macosx_10_12_x86_64.whl", hash = "sha256:ab4b66edffb32d9e951efb3814bd104b8367a7501b81b955cacb5726d897389f", size = 2071333, upload-time = "2026-08-28T10:01:09.636Z" }, + { url = "https://files.pythonhosted.org/packages/d8/18/87aefa427d191e6d3ab1447f1efc1cdcac86af1069239b133e8a0fd7f7c9/pydantic_core-2.46.5-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:337639ba62a11acde6ef3aeb08c8ea755f8ef1fe5e513356c0f36a2b0d7568b0", size = 1912713, upload-time = "2026-08-28T10:01:12.285Z" }, + { url = "https://files.pythonhosted.org/packages/1f/93/fd89e9ad49b1805ca94d24ce1088b7d305f05c35ffafcedb9819d03588a0/pydantic_core-2.46.5-pp311-pypy311_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:413a717a410d0c817ef5b786a059415550b3794e1d0c2abffd9efb93a3d9f7b4", size = 2090926, upload-time = "2026-08-28T10:01:15.19Z" }, + { url = "https://files.pythonhosted.org/packages/6f/45/8e59dab6acf8d35f02f0a958980074f31038968bdb2c983fcae9d1efee03/pydantic_core-2.46.5-pp311-pypy311_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:1e449def1945a462c464331254e5a44fca7c3b4f9aedf59ec2f50f8066dd8e25", size = 2131303, upload-time = "2026-08-28T10:01:17.937Z" }, + { url = "https://files.pythonhosted.org/packages/d5/a5/e1d4dc5180dd887a9522efc1f8716b8692b7606b1d3273d7862eaf66be44/pydantic_core-2.46.5-pp311-pypy311_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:a445486499897b88a7d6c310c88ed64dd37b1b59bfd7ae9107490bbb362f47d6", size = 2145128, upload-time = "2026-08-28T10:01:20.694Z" }, + { url = "https://files.pythonhosted.org/packages/c2/d7/ad493864a7fb21c0c4df98f965e2db430cb25a9d7369b5778d5016c09fd9/pydantic_core-2.46.5-pp311-pypy311_pp73-musllinux_1_1_armv7l.whl", hash = "sha256:2d330aaba8621b1edcec8ae2c4050f63b84ccf6d98723a8f212e9684713abf0e", size = 2294560, upload-time = "2026-08-28T10:01:23.495Z" }, + { url = "https://files.pythonhosted.org/packages/02/8e/b41c84c913f29973a268e6c2b5bbf13c95adb9956c126d10da11ba3b2bef/pydantic_core-2.46.5-pp311-pypy311_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:b6acfb46a814762367fb7ba0828b0a17d441b92ce249a0e007474c9072662dda", size = 2317531, upload-time = "2026-08-28T10:01:26.334Z" }, + { url = "https://files.pythonhosted.org/packages/db/1d/068464f23075f66a8f1b806935e9cd9363ee446636ea70d2c22ee8659dbf/pydantic_core-2.46.5-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:d0a24b40877af2de4950252be9d21eaf7fb07660f3c2cae1f56c6b599ada5266", size = 2140686, upload-time = "2026-08-28T10:01:28.947Z" }, +] + +[[package]] name = "pygments" version = "2.21.0" source = { registry = "https://pypi.org/simple" } @@ -428,3 +664,50 @@ wheels = [ { 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" }, ] + +[[package]] +name = "starlette" +version = "1.6.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anyio" }, + { name = "typing-extensions", marker = "python_full_version < '3.13'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/b5/b4/205b0d5241d934e8add0c38aa924c4f9fb7330834ff11e5444db964ec3f9/starlette-1.6.0.tar.gz", hash = "sha256:d4e3ac5e546444960c710297a3c9fc3f7ebae1b7e963f3d36173b49da535be9b", size = 2716969, upload-time = "2026-08-08T18:27:57.512Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c8/cb/6a6a47d5b464bd08695d254f3da6e7986cc70c9fa5d778eda57538edfe56/starlette-1.6.0-py3-none-any.whl", hash = "sha256:a86dd39d14bb45f85a3d18525215a9ef0cfd1f192ac793220e72598c90335f0c", size = 75969, upload-time = "2026-08-08T18:27:56.196Z" }, +] + +[[package]] +name = "typing-extensions" +version = "4.16.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f6/cc/6253133b5bb138fc3306cebfbda2c520f545d36b5be2c7255cc528bb45d6/typing_extensions-4.16.0.tar.gz", hash = "sha256:dc983d19a509c94dba722ee6abd33940f7c05a89e243c47e907eb4db6f1a43e5", size = 113555, upload-time = "2026-07-02T08:40:05.92Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/49/d3/b8441a820a491ddfc024b0b0cf0393375b75ea13866d9c66727e54c2fc80/typing_extensions-4.16.0-py3-none-any.whl", hash = "sha256:481caa481374e813c1b176ada14e97f1f67a4539ce9cfeb3f350d78d6370c2e8", size = 45571, upload-time = "2026-07-02T08:40:04.659Z" }, +] + +[[package]] +name = "typing-inspection" +version = "0.4.4" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/a3/26/b09b8010994eccc3c09092e6b34058f36a460eea2d4c3e8b910c695975a0/typing_inspection-0.4.4.tar.gz", hash = "sha256:547274fa6b0a561ccf549cc9524b999a578e737d015d8709d021f9d0d13bea47", size = 76928, upload-time = "2026-08-12T12:37:25.997Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/67/81/4add07e5172b7ac40d8ed5ff580409a7801a4fe26d529bdd915401dabfbe/typing_inspection-0.4.4-py3-none-any.whl", hash = "sha256:65b8397ba37ccbce054456aaccddfc91e6e3083c92824df348d96ca832f3f147", size = 14750, upload-time = "2026-08-12T12:37:24.648Z" }, +] + +[[package]] +name = "uvicorn" +version = "0.52.4" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "click" }, + { name = "h11" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/f2/0f/3f86e61397dd33bf2ccf28188c40db6a740658aeebbbf6e7dbc101a1f487/uvicorn-0.52.4.tar.gz", hash = "sha256:73acfee47a0b133c5de13d219492d62d8a31e935f4fe6e41a232451a15379f86", size = 100627, upload-time = "2026-08-19T06:27:41.821Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f1/79/4a20b54ab0491485ccd8c077db2d39187c7f12b3e15485d38a7be37c81b4/uvicorn-0.52.4-py3-none-any.whl", hash = "sha256:f86e41a149d7d05a9969337e3946a9c171c06a5d42680896daaba624aeac8da1", size = 79871, upload-time = "2026-08-19T06:27:40.36Z" }, +] diff --git a/producer.sh b/producer.sh index d2a139f..5814abc 100755 --- a/producer.sh +++ b/producer.sh @@ -8,7 +8,8 @@ # Trust boundary for downloads: official PyPI (uv, application wheels) and # Astral's managed CPython distribution. AI setup additionally trusts # download.pytorch.org, the pinned DeepFilterNet3 model host, and Hugging Face -# (ClearVoice MossFormer2_SE_48K weights). Host package-manager settings +# (ClearVoice MossFormer2_SE_48K weights), plus hash-checked Silero VAD +# weights from its pinned GitHub release. Host package-manager settings # (extra indexes, targets, constraints, config files) cannot add to or # redirect these sources. # @@ -29,8 +30,10 @@ RUNTIME="$LIB/runtime" APP="$RUNTIME/app" PROJECT="$LIB/project" READY="$RUNTIME/.producer-ready" -# Bump when the bootstrap recipe changes (python version, install steps...). -RUNTIME_VERSION="2" +GUI_READY="$RUNTIME/.producer-gui-ready" +# Bump when the bootstrap recipe or installed application contract changes. +# Version 4 adds the optional GUI command; only GUI launches install its extra. +RUNTIME_VERSION="4" usage() { cat <<'USAGE' @@ -43,6 +46,7 @@ Usage: ./producer.sh COMMAND [options], or simply: process RECORDING.wav... The same, with the explicit command keyword preview RECORDING.wav Render loudness-matched profile previews config Print resolved settings (TOML, or JSON with --json) + gui [--root PATH] [--port N] Open the optional local browser GUI (loopback only) doctor Check FFmpeg, data directory and CUDA driver setup --device cpu|cuda Install and self-test the AI backend (--denoiser clearvoice for the MossFormer2 backend) @@ -76,6 +80,9 @@ if [[ ${#ARGS[@]} -eq 1 && ( "${ARGS[0]}" == "-h" || "${ARGS[0]}" == "--help" ) exit 0 fi +WANT_GUI=0 +[[ "${ARGS[0]-}" == "gui" ]] && WANT_GUI=1 + # A config.toml next to this script is loaded automatically for every # command that accepts --config, unless one is given explicitly. Named CLI # flags and --set still override values from the file. @@ -87,7 +94,7 @@ if [[ $HAVE_CONFIG -eq 0 && -f "$ROOT/config.toml" ]]; then case "${ARGS[0]-}" in # After the command word: the CLI treats a leading non-command # argument as the implicit 'process' command and then as an input. - process|preview|config) + process|preview|config|gui) ARGS=("${ARGS[0]}" "--config=$ROOT/config.toml" "${ARGS[@]:1}") ;; # These commands take no --config (or no arguments at all). @@ -127,7 +134,9 @@ marker_current() { # a failed or interrupted rebuild leaves neither, forcing a repair. [[ -f "$READY" ]] \ && [[ "$(cat -- "$READY" 2>/dev/null)" == "$RUNTIME v$RUNTIME_VERSION" ]] \ - && [[ -x "$APP/bin/python" ]] + && [[ -x "$APP/bin/python" ]] \ + && { [[ "$WANT_GUI" -eq 0 ]] || { [[ -f "$GUI_READY" ]] \ + && [[ "$(cat -- "$GUI_READY" 2>/dev/null)" == "$RUNTIME v$RUNTIME_VERSION" ]]; }; } } bootstrap() { @@ -156,7 +165,7 @@ bootstrap() { # Invalidate the marker before any destructive work: if this bootstrap # fails or is interrupted, the next launch repairs instead of trusting # a half-built environment. - rm -f -- "$READY" "$READY.tmp" + rm -f -- "$READY" "$READY.tmp" "$GUI_READY" log "Preparing the contained runtime in lib/runtime (first run, or the directory moved)" local UV="" @@ -191,18 +200,28 @@ bootstrap() { || fail "Creating the managed Python 3.13 environment failed; check network access and free space." # The bundled FFmpeg wheel is preferred; where it does not exist (for # example musl systems), fall back to a system FFmpeg at runtime. + local bundled="$PROJECT[bundled-ffmpeg]" core="$PROJECT" + if [[ "$WANT_GUI" -eq 1 ]]; then + bundled="$PROJECT[bundled-ffmpeg,gui]" + core="$PROJECT[gui]" + fi if ! err="$("$UV" pip install --python "$APP/bin/python" --index-url https://pypi.org/simple \ - "$PROJECT[bundled-ffmpeg]" 2>&1 >/dev/null)"; then + "$bundled" 2>&1 >/dev/null)"; then log "Bundled FFmpeg wheel unavailable; installing core dependencies (a system FFmpeg is then required)." if [[ -n "$err" ]]; then sed 's/^/[producer] /' <<<"$err" >&2 fi - "$UV" pip install --python "$APP/bin/python" --index-url https://pypi.org/simple "$PROJECT" \ + "$UV" pip install --python "$APP/bin/python" --index-url https://pypi.org/simple "$core" \ || fail "Installing the application dependencies failed; check network access and free space." fi "$APP/bin/python" -I -c "import producer" >/dev/null 2>&1 \ || fail "The application environment failed verification." + if [[ "$WANT_GUI" -eq 1 ]]; then + "$APP/bin/python" -I -c "import fastapi, uvicorn" >/dev/null 2>&1 \ + || fail "The optional GUI environment failed verification." + printf '%s v%s\n' "$RUNTIME" "$RUNTIME_VERSION" > "$GUI_READY" + fi printf '%s v%s\n' "$RUNTIME" "$RUNTIME_VERSION" > "$READY.tmp" mv -f -- "$READY.tmp" "$READY" # Downgrade to the shared runtime lock for the application's lifetime. |
