aboutsummaryrefslogtreecommitdiff
path: root/lib/project/src
diff options
context:
space:
mode:
Diffstat (limited to 'lib/project/src')
-rw-r--r--lib/project/src/producer/gui_schema.py172
-rw-r--r--lib/project/src/producer/web/app.js248
-rw-r--r--lib/project/src/producer/web/index.html5
-rw-r--r--lib/project/src/producer/web/style.css154
4 files changed, 361 insertions, 218 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)
- }
}