diff options
Diffstat (limited to 'lib/project/src')
| -rw-r--r-- | lib/project/src/producer/config.py | 20 | ||||
| -rw-r--r-- | lib/project/src/producer/gui.py | 17 | ||||
| -rw-r--r-- | lib/project/src/producer/gui_schema.py | 30 | ||||
| -rw-r--r-- | lib/project/src/producer/web/app.js | 43 | ||||
| -rw-r--r-- | lib/project/src/producer/web/index.html | 1 | ||||
| -rw-r--r-- | lib/project/src/producer/web/style.css | 35 |
6 files changed, 116 insertions, 30 deletions
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 @@ <header><strong>Producer</strong><span class="small">local voice cleanup & mastering</span><span id="connection">Connecting…</span></header> <main> <div id="error" class="error" role="alert" hidden><span id="error-text"></span><button id="dismiss-error" type="button">Dismiss</button></div> + <div id="toast" class="toast" role="status" hidden><div><strong id="toast-title"></strong><ul id="toast-list"></ul></div></div> <noscript>This tool needs JavaScript. Reload this page after starting Producer.</noscript> <fieldset id="workspace" disabled> <legend class="sr-only">Producer studio</legend> 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; |
