From a5bca8573e7781f71aba9575acd99f1a7e82cbe4 Mon Sep 17 00:00:00 2001 From: historia Date: Sat, 12 Sep 2026 18:45:33 -0400 Subject: feat: gui notifications when changing sound profile --- lib/project/src/producer/config.py | 20 +++++++-------- lib/project/src/producer/gui.py | 17 +++++++++++-- lib/project/src/producer/gui_schema.py | 30 +++++++++++------------ lib/project/src/producer/web/app.js | 43 ++++++++++++++++++++++++++++++--- lib/project/src/producer/web/index.html | 1 + lib/project/src/producer/web/style.css | 35 +++++++++++++++++++++++++++ lib/project/tests/test_gui.py | 31 ++++++++++++++++++++++++ lib/project/tests/test_gui_browser.py | 1 + 8 files changed, 148 insertions(+), 30 deletions(-) (limited to 'lib') diff --git a/lib/project/src/producer/config.py b/lib/project/src/producer/config.py index 34c907b..1d18054 100644 --- a/lib/project/src/producer/config.py +++ b/lib/project/src/producer/config.py @@ -24,16 +24,6 @@ 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 - gap_threshold_db: float = -50.0 eq: bool = True warmth_db: float = 0.5 mud_db: float = -1.5 @@ -54,6 +44,16 @@ class Settings: sample_rate: int = 48000 mp3: bool = False mp3_bitrate: int = 192 + 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 + gap_threshold_db: float = -50.0 PROFILES = { diff --git a/lib/project/src/producer/gui.py b/lib/project/src/producer/gui.py index 09be6bd..00ea229 100644 --- a/lib/project/src/producer/gui.py +++ b/lib/project/src/producer/gui.py @@ -154,8 +154,16 @@ def create_app(port, *, config=None, profile=None, overrides=None, output_dir=No @app.post("/api/resolve") async def resolve_settings(request: Request): - body = object_body(await request.json(), {"settings"}) - return {"settings": asdict(configured(body.get("settings", {})))} + body = object_body(await request.json(), {"settings", "standalone"}) + values = body.get("settings", {}) + if not isinstance(values, dict): + raise ValueError("settings must be an object") + if body.get("standalone"): + # Like a preset import: package defaults plus the profile's own + # values, not the launch config, whose explicit values would + # otherwise override most profile differences. + return {"settings": asdict(resolve(overrides={"profile": values.get("profile", "natural")}))} + return {"settings": asdict(configured(values))} @app.post("/api/presets/import") async def import_preset(request: Request): @@ -257,6 +265,11 @@ def serve(port=8765, *, config=None, profile=None, overrides=None): app = create_app(port, config=config, profile=profile, overrides=overrides) # Pre-bind so a busy port fails before printing a misleading URL. with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as sock: + # Without SO_REUSEADDR a restart within a minute of a shutdown fails + # with 'Address already in use': connections the server closed first + # linger in TIME_WAIT while holding the local port. uvicorn sets this + # flag only when it binds itself, not for pre-bound sockets. + sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) sock.bind(("127.0.0.1", port)) sock.listen(128) print(f"Producer local GUI: http://127.0.0.1:{port}/", flush=True) diff --git a/lib/project/src/producer/gui_schema.py b/lib/project/src/producer/gui_schema.py index c27ba89..9c3ee2b 100644 --- a/lib/project/src/producer/gui_schema.py +++ b/lib/project/src/producer/gui_schema.py @@ -7,7 +7,7 @@ from .config import PROFILES, RANGES, Settings # control does; dependents are greyed out in the UI. GROUPS = { "General": { - "profile": "Starting sound profile (natural, narrator, radio, cleanup-only); a profile only fills unset values, explicit settings always win.", + "profile": "Sound profile (natural, narrator, radio, cleanup-only). Applying it overwrites all controls on this page with the profile's values.", "device": "Where AI noise cleanup runs: auto picks a GPU if available, otherwise CPU. Only relevant when the denoiser is deepfilter or clearvoice.", }, "Noise removal": { @@ -31,20 +31,6 @@ GROUPS = { "expansion_ratio": "Expansion ratio; 1 means no expansion.", "expansion_range_db": "Maximum expansion attenuation in dB.", }, - "Pause silencing (VAD)": { - "pause_silence": "Uses a local Silero speech detector (VAD) to zero out non-speech pauses (breaths, gaps) without moving anything in time. Can lose breaths or whispers. First use downloads the detector.", - "pause_threshold": "Speech probability threshold; lower values protect more possible speech from being silenced.", - "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, avoiding clicks.", - "pause_min_ms": "Minimum silent core in milliseconds after margins and fades.", - }, - "Gap shortening (export only)": { - "trim_silence": "Shortens gaps longer than 'Gap eligible' down to 'Gap to keep' on the full export only.", - "max_gap_s": "Minimum gap length in seconds eligible for shortening.", - "gap_pad_s": "Total retained gap in seconds; must be smaller than the maximum gap.", - "gap_threshold_db": "Legacy gap threshold in dBFS used only when pause silencing is off; it can mistake quiet words for silence.", - }, "Equalizer": { "eq": "Three fixed equalizer bands at 140 Hz, 300 Hz and 3500 Hz.", "warmth_db": "140 Hz gain in dB; positive adds low-end fullness, negative thins it.", @@ -71,6 +57,20 @@ GROUPS = { "mp3": "Also encode a constant-bitrate MP3 alongside the WAV.", "mp3_bitrate": "MP3 bitrate in kb/s.", }, + "Pause silencing (VAD)": { + "pause_silence": "Uses a local Silero speech detector (VAD) to zero out non-speech pauses (breaths, gaps) without moving anything in time. Can lose breaths or whispers. First use downloads the detector.", + "pause_threshold": "Speech probability threshold; lower values protect more possible speech from being silenced.", + "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, avoiding clicks.", + "pause_min_ms": "Minimum silent core in milliseconds after margins and fades.", + }, + "Gap shortening (export only)": { + "trim_silence": "Shortens gaps longer than 'Gap eligible' down to 'Gap to keep' on the full export only.", + "max_gap_s": "Minimum gap length in seconds eligible for shortening.", + "gap_pad_s": "Total retained gap in seconds; must be smaller than the maximum gap.", + "gap_threshold_db": "Legacy gap threshold in dBFS used only when pause silencing is off; it can mistake quiet words for silence.", + }, } CHOICES = {"profile": list(PROFILES), "denoiser": ["deepfilter", "clearvoice", "fft", "none"], "device": ["auto", "cpu", "cuda"], "hum_hz": [0, 50, 60], diff --git a/lib/project/src/producer/web/app.js b/lib/project/src/producer/web/app.js index dad702c..faa8ea5 100644 --- a/lib/project/src/producer/web/app.js +++ b/lib/project/src/producer/web/app.js @@ -24,6 +24,20 @@ function error(error) { $('error-text').textContent = message; $('error').hidden = false; } +let toastTimer; +function notify(title, lines = []) { + $('toast-title').textContent = title; + const list = $('toast-list'); + list.replaceChildren(...lines.map(line => node('li', line))); + list.hidden = !lines.length; + const toast = $('toast'); + toast.hidden = false; + toast.classList.remove('fade-out'); + clearTimeout(toastTimer); + const visible = 8000, fade = visible * 0.25; + toastTimer = setTimeout(() => toast.classList.add('fade-out'), visible - fade); + toastTimer = setTimeout(() => { toast.hidden = true; toast.classList.remove('fade-out'); }, visible); +} function action(id, callback) { $(id).addEventListener('click', async () => { const button = $(id); @@ -49,7 +63,7 @@ async function api(path, body, responseType = 'json', signal) { } const labels = { - profile: 'Starting profile', device: 'AI processing device', denoiser: 'Denoiser', denoise_strength: 'Denoise strength · 0–1', + profile: 'Sound profile', device: 'AI processing device', denoiser: 'Denoiser', denoise_strength: 'Denoise strength · 0–1', fft_reduction_db: 'FFT reduction · dB', highpass: 'High-pass filter', highpass_hz: 'High-pass cutoff · Hz', lowpass: 'Low-pass filter', lowpass_hz: 'Low-pass cutoff · Hz', hum_hz: 'Mains hum · Hz (0 = off)', leveling: 'Leveling', level_target_db: 'Speech level target · dBFS', max_gain_db: 'Maximum gain change · dB', expansion: 'Expander', expansion_threshold_db: 'Expander threshold · dBFS', expansion_ratio: 'Expansion ratio', expansion_range_db: 'Maximum attenuation · dB', pause_silence: 'Pause silencing (VAD)', 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', @@ -141,13 +155,30 @@ function buildSettings() { $('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}}); + const before = rawValues(); + const data = await api('/api/resolve', {settings: {profile: $('setting-profile').value}, standalone: true}); if (version !== state.version) throw new Error('Controls changed while loading the profile. Try again.'); setSettings(data.settings); changed(); + const differences = state.schema.fields + .filter(field => field.name !== 'profile' && before[field.name] !== data.settings[field.name]) + .map(field => `${labels[field.name] || field.name}: ${shown(before[field.name])} → ${shown(data.settings[field.name])}`); + notify(`Sound profile “${data.settings.profile}” applied.`, differences); }); } +function rawValues() { + const settings = {}; + for (const field of state.schema.fields) { + const input = $(`setting-${field.name}`); + settings[field.name] = field.type === 'bool' ? input.checked : + ['int', 'float'].includes(field.type) ? Number(input.value) : input.value; + } + return settings; +} +function shown(value) { + if (typeof value === 'number' && Number.isNaN(value)) return 'unset'; + return typeof value === 'boolean' ? (value ? 'on' : 'off') : String(value); +} function setSettings(settings) { for (const field of state.schema.fields) { const input = $(`setting-${field.name}`); @@ -604,6 +635,12 @@ function showExport(job) { } // Explicit actions only; settings input events merely invalidate the current render. +function hideToast() { + clearTimeout(toastTimer); + $('toast').hidden = true; + $('toast').classList.remove('fade-out'); +} +$('toast').addEventListener('click', hideToast); action('dismiss-error', () => { $('error').hidden = true; }); action('rescan', () => selectUpload(state.input)); action('zoom-selection', () => { diff --git a/lib/project/src/producer/web/index.html b/lib/project/src/producer/web/index.html index 1113c8a..88f8767 100644 --- a/lib/project/src/producer/web/index.html +++ b/lib/project/src/producer/web/index.html @@ -13,6 +13,7 @@
Producerlocal voice cleanup & masteringConnecting…
+
Producer studio diff --git a/lib/project/src/producer/web/style.css b/lib/project/src/producer/web/style.css index 5ffd5e4..c5b9c78 100644 --- a/lib/project/src/producer/web/style.css +++ b/lib/project/src/producer/web/style.css @@ -502,6 +502,41 @@ summary .small { overflow-wrap:anywhere; margin-bottom:12px } +.toast { + display:flex; + align-items:flex-start; + gap:20px; + justify-content:space-between; + background:var(--mint); + border:1px solid var(--green); + border-radius:8px; + padding:14px; + position:fixed; + right:16px; + bottom:16px; + z-index:20; + max-width:min(420px, calc(100vw - 32px)); + font-size:.84rem; + overflow-wrap:anywhere; + box-shadow:0 4px 14px rgba(37, 56, 46, .18); + cursor:pointer; + opacity:1; + transition:opacity 2s ease +} +.toast.fade-out { + opacity:0; + pointer-events:none +} +.toast strong { + display:block +} +.toast ul { + margin:6px 0 0; + padding-left:18px +} +.toast li::marker { + color:var(--muted) +} dialog { max-width:640px; max-height:90vh; diff --git a/lib/project/tests/test_gui.py b/lib/project/tests/test_gui.py index e490e99..2f17549 100644 --- a/lib/project/tests/test_gui.py +++ b/lib/project/tests/test_gui.py @@ -1,5 +1,6 @@ """Optional GUI API, origin boundary, uploads, snapshots, and real sound previews.""" from dataclasses import asdict +import socket import hashlib from pathlib import Path import struct @@ -13,6 +14,7 @@ pytest.importorskip("fastapi") from fastapi.testclient import TestClient from producer.config import Settings +from producer import gui as gui_module from producer.gui import create_app from producer import gui_jobs @@ -280,3 +282,32 @@ def test_waveform_buffers_bounded_and_scan_cancellable(wav, monkeypatch): with pytest.raises(gui_jobs.Cancelled): gui_jobs.waveform(source, cancel) assert reads == [65536] + + +def test_serve_listener_allows_quick_restart(monkeypatch): + """serve() must set SO_REUSEADDR: without it a restart right after Ctrl+C + fails while connections the server closed first sit in TIME_WAIT.""" + captured = {} + + class FakeServer: + def __init__(self, config): + pass + def run(self, sockets): + captured["sockets"] = sockets + (sock,) = sockets + # Sample the state while serve() still holds the socket open. + captured["reuseaddr"] = sock.getsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR) + captured["address"] = sock.getsockname() + + class FakeConfig: + def __init__(self, *args, **kwargs): + pass + + class FakeUvicorn: + Server, Config = FakeServer, FakeConfig + + monkeypatch.setitem(__import__("sys").modules, "uvicorn", FakeUvicorn) + monkeypatch.setattr(gui_module, "create_app", lambda *a, **k: object()) + gui_module.serve(18765) + assert captured["reuseaddr"] == 1 + assert captured["address"] == ("127.0.0.1", 18765) diff --git a/lib/project/tests/test_gui_browser.py b/lib/project/tests/test_gui_browser.py index 385de44..efc6e55 100644 --- a/lib/project/tests/test_gui_browser.py +++ b/lib/project/tests/test_gui_browser.py @@ -8,6 +8,7 @@ The long overview case uses mocked fixed-size scan metadata, not 4-hour AI infer from dataclasses import asdict import hashlib import json +import os from pathlib import Path import socket import threading -- cgit v1.2.3