diff options
| author | historia <historiavg@proton.me> | 2026-09-12 20:06:45 -0400 |
|---|---|---|
| committer | historia <historiavg@proton.me> | 2026-09-12 20:06:45 -0400 |
| commit | 0cd53b67793e74e8be0201b90711cb51b7186d6e (patch) | |
| tree | 2fa603145daa36b63d55fad43251237212c08054 /lib | |
| parent | a5bca8573e7781f71aba9575acd99f1a7e82cbe4 (diff) | |
| download | producer-0cd53b67793e74e8be0201b90711cb51b7186d6e.tar.gz | |
feat: single column web gui layout
Diffstat (limited to 'lib')
| -rw-r--r-- | lib/project/src/producer/gui_schema.py | 172 | ||||
| -rw-r--r-- | lib/project/src/producer/web/app.js | 248 | ||||
| -rw-r--r-- | lib/project/src/producer/web/index.html | 5 | ||||
| -rw-r--r-- | lib/project/src/producer/web/style.css | 154 | ||||
| -rw-r--r-- | lib/project/tests/test_gui_browser.py | 18 |
5 files changed, 369 insertions, 228 deletions
diff --git a/lib/project/src/producer/gui_schema.py b/lib/project/src/producer/gui_schema.py index 9c3ee2b..49bb133 100644 --- a/lib/project/src/producer/gui_schema.py +++ b/lib/project/src/producer/gui_schema.py @@ -3,74 +3,104 @@ from dataclasses import asdict from .config import PROFILES, RANGES, Settings -# Keep DSP validation in config.resolve. Each help briefly explains what the -# control does; dependents are greyed out in the UI. +# Keep DSP validation in config.resolve. The GUI renders every module as a +# card whose master switch doubles as the card title; dependent fields are +# hidden entirely while their module is off. "master" names the on/off control +# (checkbox, or select whose "off" value disables the module); modules without +# a master are always on. Fields may also carry a "when" rule (WHEN below) +# that hides them for particular values of another control. +MODULES = [ + {"title": "Sound profile", "master": None, "fields": ("profile",)}, + {"title": "Noise removal", "master": "denoiser", "off": "none", + "fields": ("device", "denoise_strength", "fft_reduction_db")}, + {"title": "High-pass filter", "master": "highpass", "fields": ("highpass_hz",)}, + {"title": "Low-pass filter", "master": "lowpass", "fields": ("lowpass_hz",)}, + {"title": "Mains hum removal", "master": "hum_hz", "off": 0, "fields": ()}, + {"title": "Equalizer", "master": "eq", "fields": ("warmth_db", "mud_db", "presence_db")}, + {"title": "Leveling", "master": "leveling", "fields": ("level_target_db", "max_gain_db")}, + {"title": "Expander", "master": "expansion", + "fields": ("expansion_threshold_db", "expansion_ratio", "expansion_range_db")}, + {"title": "Compressor", "master": "compression", + "fields": ("compressor_threshold_db", "compressor_ratio", + "compressor_attack_ms", "compressor_release_ms")}, + {"title": "De-esser", "master": "deess", "fields": ("deess_intensity", "deess_amount")}, + {"title": "Loudness normalization", "master": "normalize", + "fields": ("target_lufs", "loudness_range")}, + {"title": "Limiter", "master": "limiter", "fields": ("true_peak_db",)}, + {"title": "Pause silencing", "master": "pause_silence", + "fields": ("pause_threshold", "pause_pre_ms", "pause_post_ms", "pause_fade_ms", "pause_min_ms")}, + {"title": "Gap shortening", "master": "trim_silence", + "fields": ("max_gap_s", "gap_pad_s", "gap_threshold_db")}, + {"title": "Export format", "master": None, "fields": ("sample_rate", "mp3", "mp3_bitrate")}, +] +# The cards render in this order as one untitled grid: "" renders the +# modules without any group heading between them. +SECTIONS = [ + {"title": "", + "modules": ("Sound profile", "Noise removal", "High-pass filter", "Low-pass filter", + "Mains hum removal", "Equalizer", "Leveling", "Expander", "Compressor", + "De-esser", "Loudness normalization", "Limiter", "Pause silencing", + "Gap shortening", "Export format")}, +] +# Extra visibility rules beyond the owning module's switch: hide the field +# unless the named control holds one of the listed values (or, for "equals", +# matches the boolean exactly). +WHEN = { + "device": {"field": "denoiser", "one_of": ("deepfilter", "clearvoice")}, + "fft_reduction_db": {"field": "denoiser", "one_of": ("fft",)}, + "mp3_bitrate": {"field": "mp3", "equals": True}, +} + +# Each help briefly explains what the control does; hidden helpers keep the +# page concise. Help strings must stay unique (they are tested). GROUPS = { - "General": { - "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": { - "denoiser": "Removes steady background noise (fans, hiss, hum). deepfilter and clearvoice are AI models (first use downloads one); fft is a light fallback that can sound metallic; none disables noise removal.", - "denoise_strength": "How much of the detected noise the AI model removes, 0 (keep) to 1 (remove). Inactive unless an AI method is chosen.", - "fft_reduction_db": "Maximum noise reduction for the fft method only, in dB, further scaled by cleanup strength (effective reduction = this × strength).", - }, - "Filters": { - "highpass": "High-pass filter: cuts everything below the cutoff to remove low rumble (traffic, handling, HVAC).", - "highpass_hz": "High-pass cutoff frequency in Hz.", - "lowpass": "Low-pass filter: cuts everything above the cutoff to soften hiss and sibilance harshness.", - "lowpass_hz": "Low-pass cutoff frequency in Hz, below the output Nyquist.", - "hum_hz": "Notch filter at the mains-hum frequency (50 or 60 Hz) plus its harmonics. Choose 0 to disable hum removal; 50/60 are active.", - }, - "Level and expansion": { - "leveling": "Leveling: applies one constant gain so speech sits near the target level.", - "level_target_db": "Speech-level target in dBFS before dynamics.", - "max_gain_db": "Maximum absolute leveling adjustment in dB (0 disables movement while keeping leveling on).", - "expansion": "Downward expander: attenuates sound below the threshold (room noise between words). 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.", - }, - "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.", - "mud_db": "300 Hz gain in dB; negative reduces low-mid build-up.", - "presence_db": "3500 Hz gain in dB; positive emphasizes consonants and S sounds.", - }, - "Dynamics": { - "compression": "Compressor: brings loud and quiet phrases closer together; too much sounds squeezed or raises room noise.", - "compressor_threshold_db": "Compression threshold in dBFS.", - "compressor_ratio": "Compression ratio; 1 means no compression.", - "compressor_attack_ms": "Attack in milliseconds; fast attacks can soften speech starts.", - "compressor_release_ms": "Recovery in milliseconds.", - "deess": "De-esser: reduces sibilance (sharp S sounds).", - "deess_intensity": "S-sound detector sensitivity, 0–1.", - "deess_amount": "Amount of reduction applied to detected S sounds, 0–1.", - }, - "Loudness and delivery": { - "normalize": "Two-pass integrated loudness normalization to the target, including true-peak limiting.", - "target_lufs": "Integrated loudness target in LUFS (more negative = quieter).", - "loudness_range": "Loudness range target in LU.", - "limiter": "True-peak limiter, independent of normalization; loudness normalization requires it.", - "true_peak_db": "True-peak ceiling in dBTP; lossy MP3 can overshoot.", - "sample_rate": "Export WAV sample rate in Hz (44100 or 48000); recordings are always mono 32-bit PCM.", - "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.", - }, + "profile": "Starting point that overwrites every control on this page with a tuned set: natural, narrator, radio or cleanup-only.", + "denoiser": "Removes steady background noise (fans, hiss, hum). deepfilter and clearvoice are local AI models (first use downloads one); fft is a light fallback that can sound metallic.", + "device": "Where the AI denoiser runs: auto picks a GPU when available, otherwise CPU.", + "denoise_strength": "How much of the detected noise is removed: 0 keeps it, 1 removes it fully. Applies to the AI models and to fft.", + "fft_reduction_db": "Maximum fft noise reduction in dB; the effective reduction is this × denoise strength and cannot be 0.", + "highpass": "Cuts everything below the cutoff to remove low rumble (traffic, handling, HVAC). Uncheck to disable.", + "highpass_hz": "High-pass cutoff frequency in Hz.", + "lowpass": "Cuts everything above the cutoff to soften hiss and sibilance harshness. Uncheck to disable; the cutoff must stay below the output Nyquist frequency.", + "lowpass_hz": "Low-pass cutoff frequency in Hz, below the output Nyquist.", + "hum_hz": "Notch filter at the mains-hum frequency (50 or 60 Hz) plus its harmonics; 0 to disable hum removal.", + "leveling": "Applies one constant gain so speech sits near the target level. Uncheck to disable.", + "level_target_db": "Speech-level target in dBFS before dynamics.", + "max_gain_db": "Maximum absolute leveling adjustment in dB (0 disables movement while keeping leveling on).", + "expansion": "Downward expander: attenuates sound below the threshold (room noise between words). Can affect quiet words. Uncheck to disable.", + "expansion_threshold_db": "Expander threshold in dBFS.", + "expansion_ratio": "Expansion ratio; 1 means no expansion.", + "expansion_range_db": "Maximum expansion attenuation in dB.", + "eq": "Three fixed equalizer bands at 140 Hz, 300 Hz and 3500 Hz. Uncheck to disable.", + "warmth_db": "140 Hz gain in dB; positive adds low-end fullness, negative thins it.", + "mud_db": "300 Hz gain in dB; negative reduces low-mid build-up.", + "presence_db": "3500 Hz gain in dB; positive emphasizes consonants and S sounds.", + "compression": "Brings loud and quiet phrases closer together; too much sounds squeezed or raises room noise. Uncheck to disable.", + "compressor_threshold_db": "Compression threshold in dBFS.", + "compressor_ratio": "Compression ratio; 1 means no compression.", + "compressor_attack_ms": "Attack in milliseconds; fast attacks can soften speech starts.", + "compressor_release_ms": "Recovery in milliseconds.", + "deess": "Reduces sibilance (sharp S sounds). Uncheck to disable.", + "deess_intensity": "S-sound detector sensitivity, 0–1.", + "deess_amount": "Amount of reduction applied to detected S sounds, 0–1.", + "normalize": "Two-pass integrated loudness normalization to the target, including true-peak limiting; requires the Limiter module.", + "target_lufs": "Integrated loudness target in LUFS (more negative = quieter).", + "loudness_range": "Loudness range target in LU.", + "limiter": "True-peak limiter, independent of normalization; loudness normalization requires it. Uncheck to disable.", + "true_peak_db": "True-peak ceiling in dBTP; lossy MP3 can overshoot.", + "sample_rate": "Export WAV sample rate in Hz (44100 or 48000); recordings are always mono 32-bit PCM.", + "mp3": "Also encode a constant-bitrate MP3 alongside the WAV.", + "mp3_bitrate": "MP3 bitrate in kb/s.", + "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. Uncheck to disable.", + "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.", + "trim_silence": "Shortens gaps longer than 'Gap eligible' down to 'Gap to keep' on the full export only. Uncheck to disable.", + "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], @@ -78,13 +108,17 @@ CHOICES = {"profile": list(PROFILES), "denoiser": ["deepfilter", "clearvoice", " def schema(initial): - metadata = {key: (group, help_text) for group, items in GROUPS.items() for key, help_text in items.items()} + module_of = {name: module["title"] for module in MODULES for name in + ((module["master"],) if module["master"] else ()) + module["fields"]} 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], + "group": module_of[key], "help": GROUPS[key], "range": RANGES.get(key), "choices": CHOICES.get(key)} for key, value in defaults.items()], + "modules": [dict(module, fields=list(module["fields"])) for module in MODULES], + "sections": [dict(section, modules=list(section["modules"])) for section in SECTIONS], + "when": WHEN, "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"], diff --git a/lib/project/src/producer/web/app.js b/lib/project/src/producer/web/app.js index faa8ea5..c0bfd8a 100644 --- a/lib/project/src/producer/web/app.js +++ b/lib/project/src/producer/web/app.js @@ -8,7 +8,7 @@ const state = { uploads: [], 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, storageKey: 'producer-gui-v1', + context: null, gains: {}, playbackEpoch: 0, storageKey: 'producer-gui-v1', presets: [], comparisons: [], exportsShown: new Set() }; @@ -66,54 +66,68 @@ const labels = { 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', - trim_silence: 'Gap shortening (export only)', max_gap_s: 'Gap eligible for shortening · s', gap_pad_s: 'Gap to keep · s', gap_threshold_db: 'Legacy silence threshold · dBFS', + pause_silence: 'Pause silencing', 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: 'Gap shortening', max_gap_s: 'Gap eligible for shortening · s', gap_pad_s: 'Gap to keep · s', gap_threshold_db: 'Legacy silence threshold · dBFS', eq: 'Equalizer', warmth_db: '140 Hz gain · dB', mud_db: '300 Hz gain · dB', presence_db: '3500 Hz gain · dB', compression: 'Compressor', compressor_threshold_db: 'Compression threshold · dBFS', compressor_ratio: 'Compression ratio', compressor_attack_ms: 'Attack · ms', compressor_release_ms: 'Recovery · ms', deess: 'De-esser', deess_intensity: 'S-sound sensitivity · 0–1', deess_amount: 'S-sound reduction · 0–1', normalize: 'Loudness normalization', target_lufs: 'Loudness target · LUFS', loudness_range: 'Loudness range · LU', limiter: 'Limiter', true_peak_db: 'Peak ceiling · dBTP', sample_rate: 'WAV sample rate · Hz', mp3: 'MP3 export', mp3_bitrate: 'MP3 bitrate · kb/s' }; -// Which controls belong to which on/off switch. Values list the dependent -// fields to disable when the function returns true. -const DEPS = { - denoiser: value => ({fft_reduction_db: value !== 'fft', denoise_strength: value === 'none'}), - highpass: checked => ({highpass_hz: !checked}), - lowpass: checked => ({lowpass_hz: !checked}), - leveling: checked => ({level_target_db: !checked, max_gain_db: !checked}), - expansion: checked => ({expansion_threshold_db: !checked, expansion_ratio: !checked, expansion_range_db: !checked}), - pause_silence: checked => ({pause_threshold: !checked, pause_pre_ms: !checked, pause_post_ms: !checked, pause_fade_ms: !checked, pause_min_ms: !checked}), - trim_silence: checked => ({max_gap_s: !checked, gap_pad_s: !checked, gap_threshold_db: !checked}), - eq: checked => ({warmth_db: !checked, mud_db: !checked, presence_db: !checked}), - compression: checked => ({compressor_threshold_db: !checked, compressor_ratio: !checked, compressor_attack_ms: !checked, compressor_release_ms: !checked}), - deess: checked => ({deess_intensity: !checked, deess_amount: !checked}), - normalize: checked => ({target_lufs: !checked, loudness_range: !checked, true_peak_db: !checked}), - mp3: checked => ({mp3_bitrate: !checked}) -}; -function refreshDependencies() { - const controllerFor = {}; - for (const parent of Object.keys(DEPS)) { - const input = $(`setting-${parent}`); - if (input) controllerFor[parent] = input.type === 'checkbox' ? input.checked : input.value; - } - const controlledBy = {}; - for (const [parent, resolve] of Object.entries(DEPS)) { - if (!(parent in controllerFor)) continue; - for (const [field, off] of Object.entries(resolve(controllerFor[parent]))) if (off) controlledBy[field] = labels[parent]; +// Each module is a card whose master switch doubles as the card title; the +// master's dependents are hidden entirely while the module is off. Fields may +// additionally hide for particular values of another control (schema "when"). +const MODULE_VIEWS = new Map(), FIELD_VIEWS = new Map(); +function fieldOf(name) { return state.schema.fields.find(field => field.name === name); } +function typed(field, value) { + if (field.type === 'bool') return Boolean(value); + if (['int', 'float'].includes(field.type)) { + const number = Number(value); + return Number.isFinite(number) ? number : field.default; } - for (const field of state.schema.fields) { - const input = $(`setting-${field.name}`); - if (!input) continue; - const parent = controlledBy[field.name]; - input.disabled = Boolean(parent); - input.closest('.setting').classList.toggle('inactive', Boolean(parent)); + return String(value); +} +function settingValue(field) { + const input = $(`setting-${field.name}`); + return typed(field, input.type === 'checkbox' ? input.checked : input.value); +} +function conditionHolds(rule) { + const input = $(`setting-${rule.field}`); + if (!input) return true; + if ('one_of' in rule) return rule.one_of.includes(String(input.value)); + return settingValue(fieldOf(rule.field)) === rule.equals; +} +function masterIsOff(module) { + if (!module.master) return false; + const input = $(`setting-${module.master}`); + return module.off === undefined || module.off === null ? input.type === 'checkbox' && !input.checked : + String(input.value) === String(module.off); +} +function updateVisibility() { + for (const module of state.schema.modules) { + const view = MODULE_VIEWS.get(module.title); + if (!view) continue; + const off = masterIsOff(module); + if (module.master) { + view.card.classList.toggle('off', off); + // Select-style masters carry a checkbox: it mirrors and drives the off value. + if (view.enable) { + view.enable.checked = !off; + view.masterInput.disabled = off; + } + } + let anyVisible = false; + for (const name of module.fields) { + const wrapper = FIELD_VIEWS.get(name); + const visible = !off && (!state.schema.when[name] || conditionHolds(state.schema.when[name])); + wrapper.hidden = !visible; + anyVisible = anyVisible || visible; + } + if (module.fields.length) view.body.hidden = !anyVisible; } } -function settingControl(field) { - const wrapper = node('div', undefined, 'setting'); - const label = node('label', labels[field.name] || field.name); +function buildInput(field) { 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); @@ -121,7 +135,7 @@ function settingControl(field) { input.append(option); }); } else if (field.type === 'bool') { - input.type = 'checkbox'; label.className = 'check'; + input.type = 'checkbox'; } else { input.type = field.type === 'str' ? 'text' : 'number'; input.required = true; @@ -130,29 +144,95 @@ function settingControl(field) { if (field.range) [input.min, input.max] = field.range; } } - const help = node('p', field.help); + input.addEventListener('input', changed); + return input; +} +function attachHelp(input, field, className) { + const help = node('p', field.help, className); 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'); - apply.type = 'button'; apply.id = 'apply-profile'; wrapper.append(apply); + return help; +} +function settingControl(field) { + const wrapper = node('div', undefined, 'setting'); + const input = buildInput(field); + const label = node('label', labels[field.name] || field.name); + label.htmlFor = input.id; + if (field.type === 'bool') { label.className = 'check'; label.prepend(input); wrapper.append(label); } + else if (field.name === 'profile') { + const apply = node('button', 'Apply profile', 'danger'); + apply.type = 'button'; apply.id = 'apply-profile'; + const row = node('div', undefined, 'profile-row'); + row.append(input, apply); + wrapper.append(label, row); } - input.addEventListener('input', changed); + else wrapper.append(label, input); + wrapper.append(attachHelp(input, field)); + FIELD_VIEWS.set(field.name, wrapper); return wrapper; } -function buildSettings() { - const groups = new Map(); - for (const field of state.schema.fields) { - 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); +function buildModule(module, fields) { + const card = node('div', undefined, 'module'); + card.dataset.module = module.title; + const head = node('div', undefined, 'module-head'); + let enable = null, masterInput = null; + if (module.master) { + const field = fields.get(module.master); + const input = buildInput(field); + if (field.type === 'bool') { + const toggle = node('label', undefined, 'module-toggle'); + toggle.append(input, node('span', module.title)); + head.append(toggle); + } else if (module.off === undefined || module.off === null) { + const label = node('label', module.title); + label.htmlFor = input.id; + const row = node('div', undefined, 'module-select'); + row.append(label, input); + head.append(row); + } else { + // A checkbox sits next to the title: unchecked means exactly the + // module's "off" value (denoiser "none", hum 0 Hz). + const off = String(module.off); + enable = node('input'); + enable.type = 'checkbox'; + enable.id = `enable-${module.master}`; + enable.addEventListener('change', () => { + if (enable.checked) { + const choices = field.choices.map(String).filter(value => value !== off); + input.value = choices.includes(input.dataset.on) ? input.dataset.on : choices[0]; + } else { + input.dataset.on = input.value; + input.value = off; + } + changed(); + }); + const toggle = node('label', undefined, 'module-toggle'); + toggle.append(enable, node('span', module.title)); + const row = node('div', undefined, 'module-select'); + row.append(toggle, input); + head.append(row); + masterInput = input; } - groups.get(field.group).append(settingControl(field)); + head.append(attachHelp(input, field, 'module-help')); + } else if ((labels[module.fields[0]] || module.fields[0]) !== module.title) { + head.append(node('h3', module.title, 'module-title')); + } + const body = node('div', undefined, 'module-grid'); + for (const name of module.fields) body.append(settingControl(fields.get(name))); + card.append(head, body); + MODULE_VIEWS.set(module.title, {card, body, enable, masterInput}); + return card; +} +function buildSettings() { + const fields = new Map(state.schema.fields.map(field => [field.name, field])); + for (const section of state.schema.sections) { + const sectionEl = node('div', undefined, 'settings-section'); + if (section.title) sectionEl.append(node('h3', section.title)); + const grid = node('div', undefined, 'modules-grid'); + for (const title of section.modules) grid.append(buildModule(state.schema.modules.find(m => m.title === title), fields)); + sectionEl.append(grid); + $('settings-groups').append(sectionEl); } - $('constraints').textContent = 'Validation rules: ' + state.schema.constraints.join('; ') + '.'; setSettings(state.schema.settings); action('apply-profile', async () => { const version = state.version; @@ -168,11 +248,7 @@ function buildSettings() { } 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; - } + for (const field of state.schema.fields) settings[field.name] = settingValue(field); return settings; } function shown(value) { @@ -185,17 +261,20 @@ function setSettings(settings) { if (field.type === 'bool') input.checked = settings[field.name]; else input.value = settings[field.name]; } - refreshDependencies(); + updateVisibility(); } function readSettings(report = true) { const settings = {}; for (const field of state.schema.fields) { const input = $(`setting-${field.name}`); - if (!input.disabled && !input.checkValidity()) { + // Hidden fields belong to an inactive module; keep their value (or the + // default when the text is invalid) without interrupting the user. + if (FIELD_VIEWS.get(field.name)?.hidden) { settings[field.name] = settingValue(field); continue; } + if (!input.checkValidity()) { if (report) 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; + settings[field.name] = settingValue(field); } return settings; } @@ -212,7 +291,7 @@ function changed() { state.mediaEpoch++; state.mediaAbort?.abort(); pause(); - refreshDependencies(); + updateVisibility(); updateComparisonStatus(); updateSelectionNote(); drawWave(); @@ -371,16 +450,8 @@ function renderJob(job) { 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?.upload_id !== latest.snapshot.upload_id) 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); + li.append(heading, status, progress); + $('jobs').prepend(li); card = {li, status, progress, cancel}; 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 : ''}`; @@ -390,8 +461,6 @@ function renderJob(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; @@ -614,13 +683,10 @@ 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', upload_id: input.upload_id, input_id: input.input_id, settings: copy(data.settings), output_dir}; - const destination = output_dir || state.schema.output_dir; - $('export-summary').textContent = `${input.name} · whole original ${formatTime(input.duration)}. Destination: ${destination}/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(); + if (version !== state.version) throw new Error('Controls changed while starting the export. Try again.'); + // Export starts immediately; settings are frozen in the submitted job snapshot. + await submit('export', {kind: 'export', upload_id: input.upload_id, input_id: input.input_id, settings: copy(data.settings), output_dir}); + $('jobs-panel').scrollIntoView({block: 'nearest'}); } function showExport(job) { if (state.exportsShown.has(job.id)) return; @@ -630,8 +696,8 @@ function showExport(job) { const paths = node('ul'); for (const path of job.result.paths) { const li = node('li'); li.append(node('code', 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(); + // Only the latest export stays on display; a new export replaces the old card. + $('export-results').replaceChildren(result); } // Explicit actions only; settings input events merely invalidate the current render. @@ -695,16 +761,6 @@ $('import-preset').addEventListener('change', async () => { }); 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() { diff --git a/lib/project/src/producer/web/index.html b/lib/project/src/producer/web/index.html index 88f8767..ef1247e 100644 --- a/lib/project/src/producer/web/index.html +++ b/lib/project/src/producer/web/index.html @@ -40,9 +40,8 @@ <section id="settings" class="panel"> <div class="settings-heading"><h2>Settings</h2><button id="reset-launch" type="button">Restore launch settings</button></div> <div id="settings-groups"></div> - <p id="constraints" class="small"></p> <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> + <div class="toolbar presets-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. TOML is portable; imports replace the controls with standalone preset settings.</p> </details> <div class="action-row"><button id="render-preview" class="primary danger" type="button" disabled>Render sound comparison</button><span class="small">Processes only this passage plus up to 10 seconds of context per side; result is approximate, not an exact export preview. First AI use downloads local models; audio never leaves this machine.</span></div> @@ -71,8 +70,6 @@ <section class="panel jobs-panel" id="jobs-panel"><h2>Activity</h2><p id="jobs-status" role="status">No work submitted yet.</p><ul id="jobs" class="jobs-list"></ul></section> </fieldset> - <footer>Local processing · no telemetry · no cloud audio. Stop the server 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>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/style.css b/lib/project/src/producer/web/style.css index c5b9c78..73ba09b 100644 --- a/lib/project/src/producer/web/style.css +++ b/lib/project/src/producer/web/style.css @@ -56,7 +56,7 @@ button.danger { button.danger:hover:not(:disabled),.browse.danger:hover { background:#8f2b20 } -button:focus-visible,a:focus-visible,summary:focus-visible,input:focus-visible,select:focus-visible { +button:focus-visible,a:focus-visible,summary:focus-visible { outline:3px solid var(--focus); outline-offset:2px } @@ -312,26 +312,87 @@ canvas { color:var(--muted); margin:3px 0 0 } -.settings-grid { +/* Sections group the module cards. */ +.settings-section>h3 { + margin:16px 0 8px; + font-size:.78rem; + font-weight:700; + text-transform:uppercase; + letter-spacing:.07em; + color:var(--muted) +} +.settings-section:first-child>h3 { + margin-top:0 +} +.modules-grid { display:grid; - grid-template-columns:repeat(3,1fr); - gap:10px 22px; + grid-template-columns:1fr; + gap:12px; align-items:start } -.settings-group { - border-top:1px solid var(--line); - padding-top:10px; - margin-top:12px +/* A module card: its master switch doubles as the title. */ +.module { + border:1px solid var(--line); + border-radius:9px; + padding:10px 14px; + background:#fff; + min-width:0 } -.settings-group h3 { - color:var(--green) +.module .module-title { + margin:0 } -/* Dependent controls are visibly inactive while their effect is off. */ -.setting.inactive input,.setting.inactive select { - opacity:.45; - background:var(--paper) +.module-toggle { + display:flex; + align-items:center; + gap:7px; + font-weight:650; + font-size:.95rem; + margin:0 +} +.module-toggle input[type=checkbox] { + width:17px; + height:17px +} +.module-select { + display:flex; + align-items:center; + gap:10px +} +.module-select .module-toggle { + flex-shrink:0 +} +.module-select label { + font-weight:650; + font-size:.95rem +} +.module-select select { + flex:1; + min-width:0; + min-height:34px +} +.module-help { + font-size:.72rem; + color:var(--muted); + margin:6px 0 0 +} +.module-grid { + display:grid; + grid-template-columns:repeat(auto-fit,minmax(150px,1fr)); + gap:8px 14px; + margin-top:8px } -.setting.inactive label { +.module-grid:empty { + display:none +} +.module-grid .setting p { + margin-top:2px +} +/* Off modules are dimmed and dashed; their dependents are hidden. */ +.module.off { + border-style:dashed; + background:transparent +} +.module.off .module-toggle,.module.off .module-select label { color:var(--muted) } details { @@ -347,9 +408,35 @@ summary .small { font-weight:400; margin-left:10px } -.file-import input { - font-size:.72rem; - max-width:250px +/* Native import control, styled as a button like .browse. */ +.file-import { + display:inline-block; + min-width:0; + padding:7px 14px; + border:1px solid var(--green); + border-radius:7px; + background:var(--green); + color:#fff; + font-weight:600; + cursor:pointer; + font-size:.78rem +} +.file-import:hover { + background:#214637 +} +/* The preset name input carries a caption above it; bottom-align the + buttons so they sit centered with the input box itself. */ +.presets-toolbar { + align-items:flex-end +} +.profile-row { + display:flex; + align-items:center; + gap:8px +} +.profile-row select { + flex:1; + min-width:0 } .action-row { display:flex; @@ -537,25 +624,6 @@ summary .small { .toast li::marker { color:var(--muted) } -dialog { - max-width:640px; - max-height:90vh; - overflow:auto; - border:1px solid var(--line); - border-radius:12px; - padding:24px; - color:var(--ink); - background:#fff -} -dialog::backdrop { - background:#172e23aa -} -footer { - text-align:center; - color:var(--muted); - font-size:.74rem; - padding:18px 0 6px -} .sr-only { position:absolute; width:1px; @@ -567,11 +635,6 @@ footer { [hidden] { display:none!important } -@media(max-width:950px) { - .settings-grid { - grid-template-columns:repeat(2,1fr) - } -} @media(max-width:600px) { main { padding:12px @@ -579,9 +642,6 @@ footer { .panel { padding:14px } - .settings-grid { - grid-template-columns:1fr - } .selection-fields label { width:100% } @@ -601,8 +661,4 @@ footer { .action-row .primary { width:100% } - dialog { - padding:16px; - width:calc(100% - 24px) - } } diff --git a/lib/project/tests/test_gui_browser.py b/lib/project/tests/test_gui_browser.py index efc6e55..5edc114 100644 --- a/lib/project/tests/test_gui_browser.py +++ b/lib/project/tests/test_gui_browser.py @@ -82,16 +82,14 @@ def test_gui_browser_real_workflow(studio): assert "token" not in page.locator("body").inner_text() assert page.locator("[data-setting]").count() == 47 expect(page.locator("#setting-denoiser")).to_have_value("none") - # Dependent controls of a disabled effect are visibly inactive. - expect(page.locator("#setting-denoise_strength")).to_be_disabled() - expect(page.locator("#setting-fft_reduction_db")).to_be_disabled() - expect(page.locator("#setting-highpass_hz")).to_be_disabled() + # Dependent controls of an inactive module are hidden entirely. + expect(page.locator("#setting-denoise_strength")).to_be_hidden() + expect(page.locator("#setting-fft_reduction_db")).to_be_hidden() + expect(page.locator("#setting-highpass_hz")).to_be_hidden() expect(page.locator("#setting-highpass_hz")).to_have_value("70") - # Unsupported input errors are human-readable, not raw HTML. + # Stereo input is downmixed safely to mono and both recordings load. page.locator("#pick-files").set_input_files([studio["root"] / "Stereo.wav", studio["source"]]) - expect(page.locator("#error")).to_be_visible() - expect(page.locator("#error-text")).to_contain_text("Stereo") - page.locator("#dismiss-error").click() + expect(page.locator("#error")).not_to_be_visible() 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. @@ -109,7 +107,7 @@ def test_gui_browser_real_workflow(studio): # Explicit render, requested pacing+MP3 preserved in snapshot but bypassed internally. page.locator("#setting-trim_silence").check() page.locator("#setting-mp3").check() - expect(page.locator("#setting-mp3_bitrate")).not_to_be_disabled() + expect(page.locator("#setting-mp3_bitrate")).not_to_be_hidden() page.locator("#render-preview").click() expect(page.locator("#comparison-status")).to_contain_text("Ready", timeout=30000) assert len(media_requests) == 2 @@ -166,7 +164,7 @@ def test_gui_browser_real_workflow(studio): 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() - expect(page.locator("#setting-target_lufs")).not_to_be_disabled() + expect(page.locator("#setting-target_lufs")).not_to_be_hidden() page.locator("#render-preview").click() expect(page.locator("#error-text")).to_contain_text("limiter") assert not page.locator("#setting-limiter").is_checked() |
