feat: Enhance voice selection and settings UI with default voice option and improved layout

This commit is contained in:
JB
2025-10-06 09:20:33 -07:00
parent 1b907be322
commit 5497697741
5 changed files with 226 additions and 37 deletions
+23 -1
View File
@@ -92,6 +92,15 @@ def _build_voice_catalog() -> List[Dict[str, str]]:
def _template_options() -> Dict[str, Any]: def _template_options() -> Dict[str, Any]:
profiles = serialize_profiles() profiles = serialize_profiles()
ordered_profiles = sorted(profiles.items()) ordered_profiles = sorted(profiles.items())
profile_options = []
for name, entry in ordered_profiles:
profile_options.append(
{
"name": name,
"language": (entry or {}).get("language", ""),
"formula": _formula_from_profile(entry or {}) or "",
}
)
return { return {
"languages": LANGUAGE_DESCRIPTIONS, "languages": LANGUAGE_DESCRIPTIONS,
"voices": VOICES_INTERNAL, "voices": VOICES_INTERNAL,
@@ -99,6 +108,7 @@ def _template_options() -> Dict[str, Any]:
"supported_langs_for_subs": SUPPORTED_LANGUAGES_FOR_SUBTITLE_GENERATION, "supported_langs_for_subs": SUPPORTED_LANGUAGES_FOR_SUBTITLE_GENERATION,
"output_formats": SUPPORTED_SOUND_FORMATS, "output_formats": SUPPORTED_SOUND_FORMATS,
"voice_profiles": ordered_profiles, "voice_profiles": ordered_profiles,
"voice_profile_options": profile_options,
"separate_formats": ["wav", "flac", "mp3", "opus"], "separate_formats": ["wav", "flac", "mp3", "opus"],
"voice_catalog": _build_voice_catalog(), "voice_catalog": _build_voice_catalog(),
"sample_voice_texts": SAMPLE_VOICE_TEXTS, "sample_voice_texts": SAMPLE_VOICE_TEXTS,
@@ -136,6 +146,7 @@ def _settings_defaults() -> Dict[str, Any]:
"output_format": "wav", "output_format": "wav",
"subtitle_format": "srt", "subtitle_format": "srt",
"save_mode": "default_output" if _has_output_override() else "save_next_to_input", "save_mode": "default_output" if _has_output_override() else "save_next_to_input",
"default_voice": VOICES_INTERNAL[0] if VOICES_INTERNAL else "",
"replace_single_newlines": False, "replace_single_newlines": False,
"use_gpu": True, "use_gpu": True,
"save_chapters_separately": False, "save_chapters_separately": False,
@@ -201,6 +212,10 @@ def _normalize_setting_value(key: str, value: Any, defaults: Dict[str, Any]) ->
if normalized in {"wav", "flac", "mp3", "opus"}: if normalized in {"wav", "flac", "mp3", "opus"}:
return normalized return normalized
return defaults[key] return defaults[key]
if key == "default_voice":
if isinstance(value, str) and value in VOICES_INTERNAL:
return value
return defaults[key]
return value if value is not None else defaults.get(key) return value if value is not None else defaults.get(key)
@@ -339,7 +354,11 @@ def datetimeformat(value: float, fmt: str = "%Y-%m-%d %H:%M:%S") -> str:
@web_bp.get("/") @web_bp.get("/")
def index() -> str: def index() -> str:
return render_template("index.html", options=_template_options()) return render_template(
"index.html",
options=_template_options(),
settings=_load_settings(),
)
@web_bp.get("/queue") @web_bp.get("/queue")
@@ -367,6 +386,9 @@ def settings_page() -> Response | str:
updated["save_mode"] = _normalize_setting_value( updated["save_mode"] = _normalize_setting_value(
"save_mode", form.get("save_mode"), defaults "save_mode", form.get("save_mode"), defaults
) )
updated["default_voice"] = _normalize_setting_value(
"default_voice", form.get("default_voice"), defaults
)
for key in sorted(BOOLEAN_SETTINGS): for key in sorted(BOOLEAN_SETTINGS):
updated[key] = _coerce_bool(form.get(key), False) updated[key] = _coerce_bool(form.get(key), False)
updated["separate_chapters_format"] = _normalize_setting_value( updated["separate_chapters_format"] = _normalize_setting_value(
+40 -6
View File
@@ -4,6 +4,7 @@ const initDashboard = () => {
const voiceSelect = document.querySelector('[data-role="voice-select"]'); const voiceSelect = document.querySelector('[data-role="voice-select"]');
const formulaField = document.querySelector('[data-role="formula-field"]'); const formulaField = document.querySelector('[data-role="formula-field"]');
const formulaInput = document.querySelector('[data-role="voice-formula"]'); const formulaInput = document.querySelector('[data-role="voice-formula"]');
const languageSelect = document.getElementById("language");
const sourceText = document.querySelector('[data-role="source-text"]'); const sourceText = document.querySelector('[data-role="source-text"]');
const previewEl = document.querySelector('[data-role="text-preview"]'); const previewEl = document.querySelector('[data-role="text-preview"]');
@@ -11,20 +12,45 @@ const initDashboard = () => {
const charCountEl = document.querySelector('[data-role="char-count"]'); const charCountEl = document.querySelector('[data-role="char-count"]');
const wordCountEl = document.querySelector('[data-role="word-count"]'); const wordCountEl = document.querySelector('[data-role="word-count"]');
const hydrateDefaultVoice = () => {
if (!voiceSelect) return;
const defaultVoice = voiceSelect.dataset.default;
if (!defaultVoice) return;
const option = voiceSelect.querySelector(`option[value="${defaultVoice}"]`);
if (option) {
voiceSelect.value = defaultVoice;
}
};
const updateVoiceControls = () => { const updateVoiceControls = () => {
if (!profileSelect) { if (!profileSelect) {
return; return;
} }
const value = profileSelect.value; const value = profileSelect.value;
const showVoice = !value || value === "__standard"; const isStandard = !value || value === "__standard";
const showFormula = value === "__formula"; const isFormula = value === "__formula";
const isSavedProfile = Boolean(value && !isStandard && !isFormula);
if (voiceField) { if (voiceField) {
voiceField.hidden = !showVoice; voiceField.hidden = false;
voiceField.setAttribute("aria-hidden", showVoice ? "false" : "true"); voiceField.setAttribute("aria-hidden", "false");
} }
if (voiceSelect) { if (voiceSelect) {
voiceSelect.disabled = !showVoice; voiceSelect.disabled = !isStandard;
voiceSelect.dataset.state = isStandard ? "editable" : "locked";
}
let showFormula = isFormula || isSavedProfile;
let presetFormula = "";
if (isSavedProfile) {
const option = profileSelect.selectedOptions[0];
if (option) {
presetFormula = option.dataset.formula || "";
const profileLang = option.dataset.language || "";
if (profileLang && languageSelect) {
languageSelect.value = profileLang;
}
}
} }
if (formulaField) { if (formulaField) {
@@ -33,9 +59,15 @@ const initDashboard = () => {
} }
if (formulaInput) { if (formulaInput) {
formulaInput.disabled = !showFormula; formulaInput.disabled = !showFormula;
if (!showFormula) { if (showFormula) {
if (presetFormula) {
formulaInput.value = presetFormula;
}
} else {
formulaInput.value = formulaInput.value.trim(); formulaInput.value = formulaInput.value.trim();
} }
formulaInput.dataset.state = isSavedProfile ? "locked" : "editable";
formulaInput.readOnly = isSavedProfile;
} }
}; };
@@ -74,6 +106,8 @@ const initDashboard = () => {
updateVoiceControls(); updateVoiceControls();
} }
hydrateDefaultVoice();
if (sourceText) { if (sourceText) {
sourceText.addEventListener("input", updatePreview); sourceText.addEventListener("input", updatePreview);
updatePreview(); updatePreview();
+125 -14
View File
@@ -138,6 +138,14 @@ body {
grid-template-columns: repeat(auto-fit, minmax(320px, 1fr)); grid-template-columns: repeat(auto-fit, minmax(320px, 1fr));
} }
.form-grid {
align-items: start;
}
.form-grid > .grid {
align-items: start;
}
.button { .button {
appearance: none; appearance: none;
border: none; border: none;
@@ -175,6 +183,20 @@ body {
gap: 0.4rem; gap: 0.4rem;
} }
.field--full {
max-width: none;
}
.field--full textarea {
min-height: 260px;
}
.field--actions {
display: flex;
justify-content: flex-end;
align-items: center;
}
.field label { .field label {
font-weight: 500; font-weight: 500;
color: var(--muted); color: var(--muted);
@@ -194,6 +216,18 @@ body {
transition: border 0.2s ease, box-shadow 0.2s ease; transition: border 0.2s ease, box-shadow 0.2s ease;
} }
.field input[type="text"],
.field input[type="file"],
.field input[type="number"],
.field select {
max-width: 420px;
width: min(100%, 420px);
}
.field input[type="number"] {
max-width: 200px;
}
.field input:focus, .field input:focus,
.field select:focus, .field select:focus,
.field textarea:focus { .field textarea:focus {
@@ -376,19 +410,70 @@ body {
border-radius: 18px; border-radius: 18px;
padding: 1.25rem 1.4rem; padding: 1.25rem 1.4rem;
display: grid; display: grid;
gap: 1rem; gap: 1.1rem 1.4rem;
grid-template-columns: repeat(auto-fit, minmax(260px, 1fr));
} }
.settings__section legend { .settings__section legend {
font-weight: 600; font-weight: 600;
padding: 0 0.5rem; padding: 0 0.5rem;
color: var(--muted); color: var(--muted);
grid-column: 1 / -1;
} }
.field--inline { .field--choices {
display: flex; display: grid;
flex-wrap: wrap; grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
gap: 0.5rem; gap: 0.85rem;
}
.toggle-pill {
position: relative;
display: inline-flex;
align-items: center;
gap: 0.65rem;
cursor: pointer;
}
.toggle-pill input {
position: absolute;
opacity: 0;
inset: 0;
cursor: pointer;
}
.toggle-pill span {
display: inline-flex;
align-items: center;
justify-content: flex-start;
width: 100%;
padding: 0.65rem 0.95rem;
border-radius: 16px;
border: 1px solid rgba(148, 163, 184, 0.25);
background: rgba(15, 23, 42, 0.45);
color: var(--muted);
transition: border 0.2s ease, background 0.2s ease, color 0.2s ease, box-shadow 0.2s ease;
}
.toggle-pill:hover span {
border-color: var(--accent);
color: var(--accent);
}
.toggle-pill input:focus-visible + span {
box-shadow: 0 0 0 4px rgba(56, 189, 248, 0.18);
}
.toggle-pill input:checked + span {
background: rgba(56, 189, 248, 0.15);
border-color: rgba(56, 189, 248, 0.4);
color: var(--accent);
}
.hint {
margin: 0;
font-size: 0.8rem;
color: var(--muted);
} }
.settings__actions { .settings__actions {
@@ -600,23 +685,21 @@ progress.progress::-moz-progress-bar {
} }
.voice-editor__identity { .voice-editor__identity {
display: flex; display: grid;
flex-wrap: wrap; grid-template-columns: minmax(0, 1fr) auto;
gap: 0.75rem 1rem; gap: 0.75rem 1rem;
align-items: flex-start; align-items: end;
} }
.voice-editor__name-field { .voice-editor__name-field {
flex: 1 1 220px;
min-width: 200px; min-width: 200px;
} }
.voice-editor__toolbar { .voice-editor__toolbar {
display: inline-flex; display: inline-flex;
align-items: center; align-items: center;
gap: 0.5rem; justify-content: flex-end;
flex: 0 0 auto; gap: 0.65rem;
margin-left: auto;
min-height: 2.6rem; min-height: 2.6rem;
} }
@@ -1033,6 +1116,17 @@ progress.progress::-moz-progress-bar {
pointer-events: none; pointer-events: none;
} }
[data-state="locked"] {
cursor: not-allowed;
}
select[data-state="locked"],
input[data-state="locked"] {
background: rgba(15, 23, 42, 0.35);
color: rgba(148, 163, 184, 0.8);
border-color: rgba(148, 163, 184, 0.2);
}
.icon-button { .icon-button {
appearance: none; appearance: none;
border-radius: 16px; border-radius: 16px;
@@ -1047,9 +1141,8 @@ progress.progress::-moz-progress-bar {
transition: color 0.2s ease, border 0.2s ease, background 0.2s ease, box-shadow 0.2s ease; transition: color 0.2s ease, border 0.2s ease, background 0.2s ease, box-shadow 0.2s ease;
} }
.voice-editor__identity > .icon-button,
.voice-editor__identity > .voice-editor__toolbar { .voice-editor__identity > .voice-editor__toolbar {
align-self: flex-end; align-self: end;
} }
.icon-button svg { .icon-button svg {
@@ -1178,6 +1271,24 @@ progress.progress::-moz-progress-bar {
} }
} }
@media (max-width: 720px) {
.voice-editor__identity {
grid-template-columns: 1fr;
}
.voice-editor__toolbar {
justify-content: flex-start;
}
.settings__section {
grid-template-columns: 1fr;
}
.field--choices {
grid-template-columns: 1fr;
}
}
@keyframes spin { @keyframes spin {
0% { 0% {
transform: rotate(0deg); transform: rotate(0deg);
+8 -8
View File
@@ -5,7 +5,7 @@
{% block content %} {% block content %}
<section class="card"> <section class="card">
<h1 class="card__title">Create a new audiobook</h1> <h1 class="card__title">Create a new audiobook</h1>
<form action="{{ url_for('web.enqueue_job') }}" method="post" enctype="multipart/form-data" class="grid grid--two"> <form action="{{ url_for('web.enqueue_job') }}" method="post" enctype="multipart/form-data" class="grid grid--two form-grid">
<div class="grid"> <div class="grid">
<div class="field"> <div class="field">
<label for="source_file">Source file</label> <label for="source_file">Source file</label>
@@ -21,9 +21,9 @@
</div> </div>
<div class="field" data-conditional="standard" data-role="voice-field"> <div class="field" data-conditional="standard" data-role="voice-field">
<label for="voice">Voice</label> <label for="voice">Voice</label>
<select id="voice" name="voice" data-role="voice-select"> <select id="voice" name="voice" data-role="voice-select" data-default="{{ settings.default_voice }}">
{% for voice in options.voices %} {% for voice in options.voices %}
<option value="{{ voice }}">{{ voice }}</option> <option value="{{ voice }}" {% if settings.default_voice == voice %}selected{% endif %}>{{ voice }}</option>
{% endfor %} {% endfor %}
</select> </select>
</div> </div>
@@ -32,10 +32,10 @@
<select id="voice_profile" name="voice_profile" data-role="voice-profile"> <select id="voice_profile" name="voice_profile" data-role="voice-profile">
<option value="__standard" selected>Standard voice</option> <option value="__standard" selected>Standard voice</option>
<option value="__formula">Formula</option> <option value="__formula">Formula</option>
{% if options.voice_profiles %} {% if options.voice_profile_options %}
<optgroup label="Saved mixes"> <optgroup label="Saved mixes">
{% for name, data in options.voice_profiles %} {% for profile in options.voice_profile_options %}
<option value="{{ name }}">{{ name }} ({{ data.language|upper }})</option> <option value="{{ profile.name }}" data-language="{{ profile.language }}" data-formula="{{ profile.formula|e }}">{{ profile.name }} {% if profile.language %}({{ profile.language|upper }}){% endif %}</option>
{% endfor %} {% endfor %}
</optgroup> </optgroup>
{% endif %} {% endif %}
@@ -64,7 +64,7 @@
</div> </div>
</div> </div>
<div class="grid"> <div class="grid">
<div class="field"> <div class="field field--full">
<label for="source_text">Or paste text directly</label> <label for="source_text">Or paste text directly</label>
<textarea id="source_text" name="source_text" rows="12" placeholder="Drop some text here if you don't have a file handy..." data-role="source-text"></textarea> <textarea id="source_text" name="source_text" rows="12" placeholder="Drop some text here if you don't have a file handy..." data-role="source-text"></textarea>
</div> </div>
@@ -79,7 +79,7 @@
</div> </div>
<pre class="text-preview__body" data-role="preview-body">Paste text to see a live preview and character count.</pre> <pre class="text-preview__body" data-role="preview-body">Paste text to see a live preview and character count.</pre>
</div> </div>
<div class="field" style="justify-content: flex-end;"> <div class="field field--actions">
<button type="submit" class="button">Queue conversion</button> <button type="submit" class="button">Queue conversion</button>
</div> </div>
</div> </div>
+30 -8
View File
@@ -30,6 +30,15 @@
{% endfor %} {% endfor %}
</select> </select>
</div> </div>
<div class="field">
<label for="default_voice">Default voice</label>
<select id="default_voice" name="default_voice">
{% for voice in options.voices %}
<option value="{{ voice }}" {% if settings.default_voice == voice %}selected{% endif %}>{{ voice }}</option>
{% endfor %}
</select>
<p class="hint">Used when “Standard voice” is selected on the dashboard.</p>
</div>
<div class="field"> <div class="field">
<label for="save_mode">Save location</label> <label for="save_mode">Save location</label>
<select id="save_mode" name="save_mode"> <select id="save_mode" name="save_mode">
@@ -43,14 +52,27 @@
<fieldset class="settings__section"> <fieldset class="settings__section">
<legend>Processing</legend> <legend>Processing</legend>
<div class="field field--inline"> <div class="field field--choices">
<label class="tag"><input type="checkbox" name="replace_single_newlines" value="true" {% if settings.replace_single_newlines %}checked{% endif %}> Replace single newlines</label> <label class="toggle-pill">
<label class="tag"><input type="checkbox" name="use_gpu" value="true" {% if settings.use_gpu %}checked{% endif %}> Use GPU (when available)</label> <input type="checkbox" name="replace_single_newlines" value="true" {% if settings.replace_single_newlines %}checked{% endif %}>
</div> <span>Replace single newlines</span>
<div class="field field--inline"> </label>
<label class="tag"><input type="checkbox" name="save_chapters_separately" value="true" {% if settings.save_chapters_separately %}checked{% endif %}> Save each chapter separately</label> <label class="toggle-pill">
<label class="tag"><input type="checkbox" name="merge_chapters_at_end" value="true" {% if settings.merge_chapters_at_end %}checked{% endif %}> Also create merged audiobook</label> <input type="checkbox" name="use_gpu" value="true" {% if settings.use_gpu %}checked{% endif %}>
<label class="tag"><input type="checkbox" name="save_as_project" value="true" {% if settings.save_as_project %}checked{% endif %}> Save as project with metadata</label> <span>Use GPU (when available)</span>
</label>
<label class="toggle-pill">
<input type="checkbox" name="save_chapters_separately" value="true" {% if settings.save_chapters_separately %}checked{% endif %}>
<span>Save each chapter separately</span>
</label>
<label class="toggle-pill">
<input type="checkbox" name="merge_chapters_at_end" value="true" {% if settings.merge_chapters_at_end %}checked{% endif %}>
<span>Also create merged audiobook</span>
</label>
<label class="toggle-pill">
<input type="checkbox" name="save_as_project" value="true" {% if settings.save_as_project %}checked{% endif %}>
<span>Save as project with metadata</span>
</label>
</div> </div>
<div class="field"> <div class="field">
<label for="separate_chapters_format">Separate chapter format</label> <label for="separate_chapters_format">Separate chapter format</label>