mirror of
https://github.com/denizsafak/abogen.git
synced 2026-07-18 21:50:28 +02:00
feat: Enhance voice selection and settings UI with default voice option and improved layout
This commit is contained in:
+23
-1
@@ -92,6 +92,15 @@ def _build_voice_catalog() -> List[Dict[str, str]]:
|
||||
def _template_options() -> Dict[str, Any]:
|
||||
profiles = serialize_profiles()
|
||||
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 {
|
||||
"languages": LANGUAGE_DESCRIPTIONS,
|
||||
"voices": VOICES_INTERNAL,
|
||||
@@ -99,6 +108,7 @@ def _template_options() -> Dict[str, Any]:
|
||||
"supported_langs_for_subs": SUPPORTED_LANGUAGES_FOR_SUBTITLE_GENERATION,
|
||||
"output_formats": SUPPORTED_SOUND_FORMATS,
|
||||
"voice_profiles": ordered_profiles,
|
||||
"voice_profile_options": profile_options,
|
||||
"separate_formats": ["wav", "flac", "mp3", "opus"],
|
||||
"voice_catalog": _build_voice_catalog(),
|
||||
"sample_voice_texts": SAMPLE_VOICE_TEXTS,
|
||||
@@ -136,6 +146,7 @@ def _settings_defaults() -> Dict[str, Any]:
|
||||
"output_format": "wav",
|
||||
"subtitle_format": "srt",
|
||||
"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,
|
||||
"use_gpu": True,
|
||||
"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"}:
|
||||
return normalized
|
||||
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)
|
||||
|
||||
|
||||
@@ -339,7 +354,11 @@ def datetimeformat(value: float, fmt: str = "%Y-%m-%d %H:%M:%S") -> str:
|
||||
|
||||
@web_bp.get("/")
|
||||
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")
|
||||
@@ -367,6 +386,9 @@ def settings_page() -> Response | str:
|
||||
updated["save_mode"] = _normalize_setting_value(
|
||||
"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):
|
||||
updated[key] = _coerce_bool(form.get(key), False)
|
||||
updated["separate_chapters_format"] = _normalize_setting_value(
|
||||
|
||||
@@ -4,6 +4,7 @@ const initDashboard = () => {
|
||||
const voiceSelect = document.querySelector('[data-role="voice-select"]');
|
||||
const formulaField = document.querySelector('[data-role="formula-field"]');
|
||||
const formulaInput = document.querySelector('[data-role="voice-formula"]');
|
||||
const languageSelect = document.getElementById("language");
|
||||
|
||||
const sourceText = document.querySelector('[data-role="source-text"]');
|
||||
const previewEl = document.querySelector('[data-role="text-preview"]');
|
||||
@@ -11,20 +12,45 @@ const initDashboard = () => {
|
||||
const charCountEl = document.querySelector('[data-role="char-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 = () => {
|
||||
if (!profileSelect) {
|
||||
return;
|
||||
}
|
||||
const value = profileSelect.value;
|
||||
const showVoice = !value || value === "__standard";
|
||||
const showFormula = value === "__formula";
|
||||
const isStandard = !value || value === "__standard";
|
||||
const isFormula = value === "__formula";
|
||||
const isSavedProfile = Boolean(value && !isStandard && !isFormula);
|
||||
|
||||
if (voiceField) {
|
||||
voiceField.hidden = !showVoice;
|
||||
voiceField.setAttribute("aria-hidden", showVoice ? "false" : "true");
|
||||
voiceField.hidden = false;
|
||||
voiceField.setAttribute("aria-hidden", "false");
|
||||
}
|
||||
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) {
|
||||
@@ -33,9 +59,15 @@ const initDashboard = () => {
|
||||
}
|
||||
if (formulaInput) {
|
||||
formulaInput.disabled = !showFormula;
|
||||
if (!showFormula) {
|
||||
if (showFormula) {
|
||||
if (presetFormula) {
|
||||
formulaInput.value = presetFormula;
|
||||
}
|
||||
} else {
|
||||
formulaInput.value = formulaInput.value.trim();
|
||||
}
|
||||
formulaInput.dataset.state = isSavedProfile ? "locked" : "editable";
|
||||
formulaInput.readOnly = isSavedProfile;
|
||||
}
|
||||
};
|
||||
|
||||
@@ -74,6 +106,8 @@ const initDashboard = () => {
|
||||
updateVoiceControls();
|
||||
}
|
||||
|
||||
hydrateDefaultVoice();
|
||||
|
||||
if (sourceText) {
|
||||
sourceText.addEventListener("input", updatePreview);
|
||||
updatePreview();
|
||||
|
||||
+125
-14
@@ -138,6 +138,14 @@ body {
|
||||
grid-template-columns: repeat(auto-fit, minmax(320px, 1fr));
|
||||
}
|
||||
|
||||
.form-grid {
|
||||
align-items: start;
|
||||
}
|
||||
|
||||
.form-grid > .grid {
|
||||
align-items: start;
|
||||
}
|
||||
|
||||
.button {
|
||||
appearance: none;
|
||||
border: none;
|
||||
@@ -175,6 +183,20 @@ body {
|
||||
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 {
|
||||
font-weight: 500;
|
||||
color: var(--muted);
|
||||
@@ -194,6 +216,18 @@ body {
|
||||
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 select:focus,
|
||||
.field textarea:focus {
|
||||
@@ -376,19 +410,70 @@ body {
|
||||
border-radius: 18px;
|
||||
padding: 1.25rem 1.4rem;
|
||||
display: grid;
|
||||
gap: 1rem;
|
||||
gap: 1.1rem 1.4rem;
|
||||
grid-template-columns: repeat(auto-fit, minmax(260px, 1fr));
|
||||
}
|
||||
|
||||
.settings__section legend {
|
||||
font-weight: 600;
|
||||
padding: 0 0.5rem;
|
||||
color: var(--muted);
|
||||
grid-column: 1 / -1;
|
||||
}
|
||||
|
||||
.field--inline {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.5rem;
|
||||
.field--choices {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
|
||||
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 {
|
||||
@@ -600,23 +685,21 @@ progress.progress::-moz-progress-bar {
|
||||
}
|
||||
|
||||
.voice-editor__identity {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) auto;
|
||||
gap: 0.75rem 1rem;
|
||||
align-items: flex-start;
|
||||
align-items: end;
|
||||
}
|
||||
|
||||
.voice-editor__name-field {
|
||||
flex: 1 1 220px;
|
||||
min-width: 200px;
|
||||
}
|
||||
|
||||
.voice-editor__toolbar {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
flex: 0 0 auto;
|
||||
margin-left: auto;
|
||||
justify-content: flex-end;
|
||||
gap: 0.65rem;
|
||||
min-height: 2.6rem;
|
||||
}
|
||||
|
||||
@@ -1033,6 +1116,17 @@ progress.progress::-moz-progress-bar {
|
||||
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 {
|
||||
appearance: none;
|
||||
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;
|
||||
}
|
||||
|
||||
.voice-editor__identity > .icon-button,
|
||||
.voice-editor__identity > .voice-editor__toolbar {
|
||||
align-self: flex-end;
|
||||
align-self: end;
|
||||
}
|
||||
|
||||
.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 {
|
||||
0% {
|
||||
transform: rotate(0deg);
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
{% block content %}
|
||||
<section class="card">
|
||||
<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="field">
|
||||
<label for="source_file">Source file</label>
|
||||
@@ -21,9 +21,9 @@
|
||||
</div>
|
||||
<div class="field" data-conditional="standard" data-role="voice-field">
|
||||
<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 %}
|
||||
<option value="{{ voice }}">{{ voice }}</option>
|
||||
<option value="{{ voice }}" {% if settings.default_voice == voice %}selected{% endif %}>{{ voice }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
@@ -32,10 +32,10 @@
|
||||
<select id="voice_profile" name="voice_profile" data-role="voice-profile">
|
||||
<option value="__standard" selected>Standard voice</option>
|
||||
<option value="__formula">Formula</option>
|
||||
{% if options.voice_profiles %}
|
||||
{% if options.voice_profile_options %}
|
||||
<optgroup label="Saved mixes">
|
||||
{% for name, data in options.voice_profiles %}
|
||||
<option value="{{ name }}">{{ name }} ({{ data.language|upper }})</option>
|
||||
{% for profile in options.voice_profile_options %}
|
||||
<option value="{{ profile.name }}" data-language="{{ profile.language }}" data-formula="{{ profile.formula|e }}">{{ profile.name }} {% if profile.language %}({{ profile.language|upper }}){% endif %}</option>
|
||||
{% endfor %}
|
||||
</optgroup>
|
||||
{% endif %}
|
||||
@@ -64,7 +64,7 @@
|
||||
</div>
|
||||
</div>
|
||||
<div class="grid">
|
||||
<div class="field">
|
||||
<div class="field field--full">
|
||||
<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>
|
||||
</div>
|
||||
@@ -79,7 +79,7 @@
|
||||
</div>
|
||||
<pre class="text-preview__body" data-role="preview-body">Paste text to see a live preview and character count.</pre>
|
||||
</div>
|
||||
<div class="field" style="justify-content: flex-end;">
|
||||
<div class="field field--actions">
|
||||
<button type="submit" class="button">Queue conversion</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -30,6 +30,15 @@
|
||||
{% endfor %}
|
||||
</select>
|
||||
</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">
|
||||
<label for="save_mode">Save location</label>
|
||||
<select id="save_mode" name="save_mode">
|
||||
@@ -43,14 +52,27 @@
|
||||
|
||||
<fieldset class="settings__section">
|
||||
<legend>Processing</legend>
|
||||
<div class="field field--inline">
|
||||
<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="tag"><input type="checkbox" name="use_gpu" value="true" {% if settings.use_gpu %}checked{% endif %}> Use GPU (when available)</label>
|
||||
</div>
|
||||
<div class="field field--inline">
|
||||
<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="tag"><input type="checkbox" name="merge_chapters_at_end" value="true" {% if settings.merge_chapters_at_end %}checked{% endif %}> Also create merged audiobook</label>
|
||||
<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>
|
||||
<div class="field field--choices">
|
||||
<label class="toggle-pill">
|
||||
<input type="checkbox" name="replace_single_newlines" value="true" {% if settings.replace_single_newlines %}checked{% endif %}>
|
||||
<span>Replace single newlines</span>
|
||||
</label>
|
||||
<label class="toggle-pill">
|
||||
<input type="checkbox" name="use_gpu" value="true" {% if settings.use_gpu %}checked{% endif %}>
|
||||
<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 class="field">
|
||||
<label for="separate_chapters_format">Separate chapter format</label>
|
||||
|
||||
Reference in New Issue
Block a user