aboutsummaryrefslogtreecommitdiff
path: root/lib
diff options
context:
space:
mode:
authorhistoria <historiavg@proton.me>2026-09-12 15:25:06 -0400
committerhistoria <historiavg@proton.me>2026-09-12 15:25:06 -0400
commit464edbc4fd658728f06d3a7d98340aa28ff3e3ec (patch)
treea182f62ef0365f2c2143f88436de78a03d752367 /lib
parent1bbe59f78be9a8355bb0bee2bc8ab14ca42ac71c (diff)
downloadproducer-464edbc4fd658728f06d3a7d98340aa28ff3e3ec.tar.gz
feat: slop web gui
Diffstat (limited to 'lib')
-rw-r--r--lib/project/pyproject.toml4
-rw-r--r--lib/project/src/producer/cli.py20
-rw-r--r--lib/project/src/producer/config.py9
-rw-r--r--lib/project/src/producer/gui.py264
-rw-r--r--lib/project/src/producer/gui_jobs.py327
-rw-r--r--lib/project/src/producer/gui_schema.py89
-rw-r--r--lib/project/src/producer/pauses.py177
-rw-r--r--lib/project/src/producer/pipeline.py48
-rw-r--r--lib/project/src/producer/vad_worker.py55
-rw-r--r--lib/project/src/producer/web/app.js685
-rw-r--r--lib/project/src/producer/web/index.html73
-rw-r--r--lib/project/src/producer/web/state.js36
-rw-r--r--lib/project/src/producer/web/style.css710
-rw-r--r--lib/project/tests/gui_state.test.mjs31
-rw-r--r--lib/project/tests/test_gui.py260
-rw-r--r--lib/project/tests/test_gui_assets.py46
-rw-r--r--lib/project/tests/test_gui_browser.py257
-rw-r--r--lib/project/tests/test_gui_browser_races.py210
-rw-r--r--lib/project/tests/test_gui_jobs.py124
-rw-r--r--lib/project/tests/test_gui_launcher.py113
-rw-r--r--lib/project/tests/test_pauses.py200
-rw-r--r--lib/project/uv.lock285
22 files changed, 4007 insertions, 16 deletions
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" },
+]