feat: Add gender filter to voice mixer and enhance UI for better user experience

feat: Implement dynamic settings page with form handling and default values
feat: Create a queue page to display ongoing jobs with auto-refresh
feat: Revamp dashboard with live text preview and character/word count
fix: Update navigation links in base template for active state indication
This commit is contained in:
JB
2025-10-06 06:59:16 -07:00
parent 2e402f6b5b
commit 01209f6878
10 changed files with 624 additions and 119 deletions
+3
View File
@@ -18,6 +18,7 @@ from abogen.utils import (
calculate_text_length,
create_process,
get_user_cache_path,
get_user_output_path,
load_config,
load_numpy_kpipeline,
)
@@ -215,6 +216,8 @@ def _prepare_output_dir(job: Job) -> Path:
directory = job.stored_path.parent
elif job.save_mode == "Choose output folder" and job.output_folder:
directory = Path(job.output_folder)
elif job.save_mode == "Use default save location":
directory = Path(get_user_output_path())
else:
directory = default_output
directory.mkdir(parents=True, exist_ok=True)
+208 -34
View File
@@ -3,6 +3,7 @@ from __future__ import annotations
import io
import json
import mimetypes
import os
import threading
import uuid
from pathlib import Path
@@ -32,7 +33,14 @@ from abogen.constants import (
SUPPORTED_SOUND_FORMATS,
VOICES_INTERNAL,
)
from abogen.utils import calculate_text_length, clean_text, load_config, load_numpy_kpipeline
from abogen.utils import (
calculate_text_length,
clean_text,
get_user_output_path,
load_config,
load_numpy_kpipeline,
save_config,
)
from abogen.voice_profiles import (
delete_profile,
duplicate_profile,
@@ -98,6 +106,113 @@ def _template_options() -> Dict[str, Any]:
}
SAVE_MODE_LABELS = {
"save_next_to_input": "Save next to input file",
"save_to_desktop": "Save to Desktop",
"choose_output_folder": "Choose output folder",
"default_output": "Use default save location",
}
LEGACY_SAVE_MODE_MAP = {label: key for key, label in SAVE_MODE_LABELS.items()}
BOOLEAN_SETTINGS = {
"replace_single_newlines",
"use_gpu",
"save_chapters_separately",
"merge_chapters_at_end",
"save_as_project",
}
FLOAT_SETTINGS = {"silence_between_chapters"}
INT_SETTINGS = {"max_subtitle_words"}
def _has_output_override() -> bool:
return bool(os.environ.get("ABOGEN_OUTPUT_DIR") or os.environ.get("ABOGEN_OUTPUT_ROOT"))
def _settings_defaults() -> Dict[str, Any]:
return {
"output_format": "wav",
"subtitle_format": "srt",
"save_mode": "default_output" if _has_output_override() else "save_next_to_input",
"replace_single_newlines": False,
"use_gpu": True,
"save_chapters_separately": False,
"merge_chapters_at_end": True,
"save_as_project": False,
"separate_chapters_format": "wav",
"silence_between_chapters": 2.0,
"max_subtitle_words": 50,
}
def _coerce_bool(value: Any, default: bool) -> bool:
if isinstance(value, bool):
return value
if isinstance(value, str):
return value.lower() in {"true", "1", "yes", "on"}
if value is None:
return default
return bool(value)
def _coerce_float(value: Any, default: float) -> float:
try:
return max(0.0, float(value))
except (TypeError, ValueError):
return default
def _coerce_int(value: Any, default: int, *, minimum: int = 1, maximum: int = 200) -> int:
try:
parsed = int(value)
except (TypeError, ValueError):
return default
return max(minimum, min(parsed, maximum))
def _normalize_save_mode(value: Any, default: str) -> str:
if isinstance(value, str):
if value in SAVE_MODE_LABELS:
return value
if value in LEGACY_SAVE_MODE_MAP:
return LEGACY_SAVE_MODE_MAP[value]
return default
def _normalize_setting_value(key: str, value: Any, defaults: Dict[str, Any]) -> Any:
if key in BOOLEAN_SETTINGS:
return _coerce_bool(value, defaults[key])
if key in FLOAT_SETTINGS:
return _coerce_float(value, defaults[key])
if key in INT_SETTINGS:
return _coerce_int(value, defaults[key])
if key == "save_mode":
return _normalize_save_mode(value, defaults[key])
if key == "output_format":
return value if value in SUPPORTED_SOUND_FORMATS else defaults[key]
if key == "subtitle_format":
valid = {item[0] for item in SUBTITLE_FORMATS}
return value if value in valid else defaults[key]
if key == "separate_chapters_format":
if isinstance(value, str):
normalized = value.lower()
if normalized in {"wav", "flac", "mp3", "opus"}:
return normalized
return defaults[key]
return value if value is not None else defaults.get(key)
def _load_settings() -> Dict[str, Any]:
defaults = _settings_defaults()
cfg = load_config() or {}
settings: Dict[str, Any] = {}
for key, default in defaults.items():
raw_value = cfg.get(key, default)
settings[key] = _normalize_setting_value(key, raw_value, defaults)
return settings
def _formula_from_profile(entry: Dict[str, Any]) -> Optional[str]:
voices = entry.get("voices") or []
if not voices:
@@ -224,13 +339,62 @@ def datetimeformat(value: float, fmt: str = "%Y-%m-%d %H:%M:%S") -> str:
@web_bp.get("/")
def index() -> str:
service = _service()
jobs = service.list_jobs()
return render_template(
"index.html",
jobs=jobs,
options=_template_options(),
return render_template("index.html", options=_template_options())
@web_bp.get("/queue")
def queue_page() -> str:
jobs = _service().list_jobs()
return render_template("queue.html", jobs=jobs)
@web_bp.route("/settings", methods=["GET", "POST"])
def settings_page() -> Response | str:
options = _template_options()
current_settings = _load_settings()
if request.method == "POST":
form = request.form
defaults = _settings_defaults()
updated: Dict[str, Any] = {}
updated["output_format"] = _normalize_setting_value(
"output_format", form.get("output_format"), defaults
)
updated["subtitle_format"] = _normalize_setting_value(
"subtitle_format", form.get("subtitle_format"), defaults
)
updated["save_mode"] = _normalize_setting_value(
"save_mode", form.get("save_mode"), defaults
)
for key in sorted(BOOLEAN_SETTINGS):
updated[key] = _coerce_bool(form.get(key), False)
updated["separate_chapters_format"] = _normalize_setting_value(
"separate_chapters_format", form.get("separate_chapters_format"), defaults
)
updated["silence_between_chapters"] = _coerce_float(
form.get("silence_between_chapters"), defaults["silence_between_chapters"]
)
updated["max_subtitle_words"] = _coerce_int(
form.get("max_subtitle_words"), defaults["max_subtitle_words"]
)
cfg = load_config() or {}
cfg.update(updated)
save_config(cfg)
return redirect(url_for("web.settings_page", saved="1"))
save_locations = [
{"value": key, "label": label} for key, label in SAVE_MODE_LABELS.items()
]
context = {
"options": options,
"settings": current_settings,
"save_locations": save_locations,
"default_output_dir": get_user_output_path(),
"saved": request.args.get("saved") == "1",
}
return render_template("settings.html", **context)
@web_bp.get("/voices")
@@ -361,7 +525,11 @@ def api_preview_voice_mix() -> Response:
language = (payload.get("language") or "a").strip() or "a"
text = (payload.get("text") or "").strip()
speed = float(payload.get("speed", 1.0) or 1.0)
max_seconds = float(payload.get("max_seconds", 12.0) or 12.0)
try:
requested_preview = float(payload.get("max_seconds", 60.0) or 60.0)
except (TypeError, ValueError):
requested_preview = 60.0
max_seconds = max(1.0, min(60.0, requested_preview))
profile_name = (payload.get("profile") or payload.get("profile_name") or "").strip()
formula = (payload.get("formula") or "").strip()
@@ -398,9 +566,12 @@ def api_preview_voice_mix() -> Response:
if not text:
text = SAMPLE_VOICE_TEXTS.get(language, SAMPLE_VOICE_TEXTS.get("a", "This is a sample of the selected voice."))
cfg = load_config()
use_gpu_cfg = bool(cfg.get("use_gpu", True))
use_gpu = use_gpu_cfg if payload.get("use_gpu") is None else bool(payload.get("use_gpu"))
settings = _load_settings()
use_gpu_default = settings.get("use_gpu", True)
if "use_gpu" in payload:
use_gpu = _coerce_bool(payload.get("use_gpu"), use_gpu_default)
else:
use_gpu = use_gpu_default
device = "cpu"
if use_gpu:
try:
@@ -506,11 +677,23 @@ def enqueue_job() -> Response:
total_chars = calculate_text_length(clean_text(text_input))
profiles = load_profiles()
settings = _load_settings()
language = request.form.get("language", "a")
base_voice = request.form.get("voice", "af_alloy")
profile_name = request.form.get("voice_profile", "").strip()
custom_formula = request.form.get("voice_formula", "").strip()
profile_selection = (request.form.get("voice_profile") or "__standard").strip()
custom_formula_raw = request.form.get("voice_formula", "").strip()
if profile_selection in {"__standard", ""}:
profile_name = ""
custom_formula = ""
elif profile_selection == "__formula":
profile_name = ""
custom_formula = custom_formula_raw
else:
profile_name = profile_selection
custom_formula = ""
voice, language, selected_profile = _resolve_voice_choice(
language,
base_voice,
@@ -520,27 +703,18 @@ def enqueue_job() -> Response:
)
speed = float(request.form.get("speed", "1.0"))
subtitle_mode = request.form.get("subtitle_mode", "Disabled")
output_format = request.form.get("output_format", "wav")
subtitle_format = request.form.get("subtitle_format", "srt")
save_mode = request.form.get("save_mode", "Save next to input file")
replace_single_newlines = request.form.get("replace_single_newlines") in {"true", "on", "1"}
use_gpu = request.form.get("use_gpu") in {"true", "on", "1"}
save_chapters_separately = request.form.get("save_chapters_separately") in {"true", "on", "1"}
merge_chapters_at_end = request.form.get("merge_chapters_at_end") in {"true", "on", "1"}
if not save_chapters_separately:
merge_chapters_at_end = True
save_as_project = request.form.get("save_as_project") in {"true", "on", "1"}
separate_chapters_format = request.form.get("separate_chapters_format", "wav").lower()
try:
silence_between_chapters = float(request.form.get("silence_between_chapters", "2.0") or 0.0)
except ValueError:
silence_between_chapters = 2.0
silence_between_chapters = max(0.0, silence_between_chapters)
try:
max_subtitle_words = int(request.form.get("max_subtitle_words", "50") or 50)
except ValueError:
max_subtitle_words = 50
max_subtitle_words = max(1, min(max_subtitle_words, 200))
output_format = settings["output_format"]
subtitle_format = settings["subtitle_format"]
save_mode_key = settings["save_mode"]
save_mode = SAVE_MODE_LABELS.get(save_mode_key, SAVE_MODE_LABELS["save_next_to_input"])
replace_single_newlines = settings["replace_single_newlines"]
use_gpu = settings["use_gpu"]
save_chapters_separately = settings["save_chapters_separately"]
merge_chapters_at_end = settings["merge_chapters_at_end"] or not save_chapters_separately
save_as_project = settings["save_as_project"]
separate_chapters_format = settings["separate_chapters_format"]
silence_between_chapters = settings["silence_between_chapters"]
max_subtitle_words = settings["max_subtitle_words"]
job = service.enqueue(
original_filename=original_name,
+87
View File
@@ -0,0 +1,87 @@
const initDashboard = () => {
const profileSelect = document.querySelector('[data-role="voice-profile"]');
const voiceField = document.querySelector('[data-role="voice-field"]');
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 sourceText = document.querySelector('[data-role="source-text"]');
const previewEl = document.querySelector('[data-role="text-preview"]');
const previewBody = document.querySelector('[data-role="preview-body"]');
const charCountEl = document.querySelector('[data-role="char-count"]');
const wordCountEl = document.querySelector('[data-role="word-count"]');
const updateVoiceControls = () => {
if (!profileSelect) {
return;
}
const value = profileSelect.value;
const showVoice = !value || value === "__standard";
const showFormula = value === "__formula";
if (voiceField) {
voiceField.hidden = !showVoice;
voiceField.setAttribute("aria-hidden", showVoice ? "false" : "true");
}
if (voiceSelect) {
voiceSelect.disabled = !showVoice;
}
if (formulaField) {
formulaField.hidden = !showFormula;
formulaField.setAttribute("aria-hidden", showFormula ? "false" : "true");
}
if (formulaInput) {
formulaInput.disabled = !showFormula;
if (!showFormula) {
formulaInput.value = formulaInput.value.trim();
}
}
};
const updatePreview = () => {
if (!sourceText || !previewBody || !charCountEl || !wordCountEl) {
return;
}
const raw = sourceText.value || "";
const trimmed = raw.trim();
const charCount = raw.length;
const wordCount = trimmed ? trimmed.split(/\s+/).length : 0;
const charLabel = `${charCount.toLocaleString()} ${charCount === 1 ? "character" : "characters"}`;
const wordLabel = `${wordCount.toLocaleString()} ${wordCount === 1 ? "word" : "words"}`;
charCountEl.textContent = charLabel;
wordCountEl.textContent = wordLabel;
if (!trimmed) {
previewBody.textContent = "Paste text to see a live preview and character count.";
if (previewEl) {
previewEl.setAttribute("data-state", "empty");
}
return;
}
const snippet = trimmed.length > 1200 ? `${trimmed.slice(0, 1200)}` : trimmed;
previewBody.textContent = snippet;
if (previewEl) {
previewEl.setAttribute("data-state", "ready");
}
};
if (profileSelect) {
profileSelect.addEventListener("change", updateVoiceControls);
updateVoiceControls();
}
if (sourceText) {
sourceText.addEventListener("input", updatePreview);
updatePreview();
}
};
if (document.readyState === "loading") {
document.addEventListener("DOMContentLoaded", initDashboard, { once: true });
} else {
initDashboard();
}
+137 -1
View File
@@ -73,6 +73,13 @@ body {
background: rgba(56, 189, 248, 0.1);
}
.top-actions .btn.is-active {
border-color: var(--accent);
background: rgba(56, 189, 248, 0.18);
color: #fff;
box-shadow: 0 0 0 4px rgba(56, 189, 248, 0.12);
}
.main {
flex: 1;
padding: 2.5rem 3rem 3.5rem;
@@ -341,11 +348,54 @@ body {
gap: 1rem;
}
.alert {
margin: 1rem 0;
padding: 0.75rem 1rem;
border-radius: 14px;
font-size: 0.95rem;
}
.alert--success {
background: rgba(52, 211, 153, 0.12);
border: 1px solid rgba(52, 211, 153, 0.35);
color: var(--success);
}
.button--danger {
background: linear-gradient(135deg, var(--danger), #ef4444);
box-shadow: 0 12px 30px rgba(239, 68, 68, 0.2);
}
.settings__form {
display: grid;
gap: 1.75rem;
}
.settings__section {
border: 1px solid rgba(148, 163, 184, 0.2);
border-radius: 18px;
padding: 1.25rem 1.4rem;
display: grid;
gap: 1rem;
}
.settings__section legend {
font-weight: 600;
padding: 0 0.5rem;
color: var(--muted);
}
.field--inline {
display: flex;
flex-wrap: wrap;
gap: 0.5rem;
}
.settings__actions {
display: flex;
justify-content: flex-end;
}
.pill-grid {
display: flex;
flex-wrap: wrap;
@@ -361,6 +411,51 @@ body {
background: rgba(15, 23, 42, 0.4);
}
.text-preview {
border: 1px solid rgba(148, 163, 184, 0.2);
border-radius: 18px;
background: rgba(15, 23, 42, 0.35);
padding: 1.1rem 1.25rem;
display: grid;
gap: 0.85rem;
min-height: 220px;
}
.text-preview__header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 0.75rem;
}
.text-preview__header h2 {
margin: 0;
font-size: 1rem;
font-weight: 600;
}
.text-preview__meta {
display: inline-flex;
align-items: center;
gap: 0.35rem;
font-size: 0.85rem;
color: var(--muted);
}
.text-preview__body {
margin: 0;
white-space: pre-wrap;
word-break: break-word;
font-family: inherit;
font-size: 0.95rem;
line-height: 1.5;
color: var(--text);
}
.text-preview[data-state="empty"] .text-preview__body {
color: var(--muted);
}
progress.progress {
width: 100%;
height: 0.6rem;
@@ -548,7 +643,7 @@ progress.progress::-moz-progress-bar {
min-width: 180px;
}
.voice-filter label {
.voice-filter__label {
font-size: 0.75rem;
font-weight: 600;
letter-spacing: 0.04em;
@@ -556,6 +651,13 @@ progress.progress::-moz-progress-bar {
color: var(--muted);
}
.voice-filter__controls {
display: flex;
align-items: center;
gap: 0.75rem;
flex-wrap: wrap;
}
.voice-filter select {
border-radius: 12px;
border: 1px solid rgba(148, 163, 184, 0.2);
@@ -563,6 +665,8 @@ progress.progress::-moz-progress-bar {
padding: 0.55rem 0.75rem;
color: var(--text);
font-size: 0.95rem;
min-width: 0;
flex: 1 1 200px;
}
.voice-filter select:focus {
@@ -571,6 +675,38 @@ progress.progress::-moz-progress-bar {
box-shadow: 0 0 0 3px rgba(56, 189, 248, 0.15);
}
.voice-gender-filter {
display: inline-flex;
align-items: center;
gap: 0.35rem;
flex: 0 0 auto;
}
.voice-gender-filter__button {
border: 1px solid rgba(148, 163, 184, 0.3);
background: rgba(15, 23, 42, 0.45);
color: var(--muted);
font-size: 1.05rem;
line-height: 1;
width: 2.25rem;
height: 2.25rem;
border-radius: 999px;
cursor: pointer;
transition: border 0.2s ease, background 0.2s ease, color 0.2s ease;
}
.voice-gender-filter__button:hover {
border-color: var(--accent);
color: var(--accent);
}
.voice-gender-filter__button[aria-pressed="true"] {
background: rgba(56, 189, 248, 0.16);
border-color: var(--accent);
color: #fff;
box-shadow: 0 0 0 3px rgba(56, 189, 248, 0.15);
}
.voice-available__list {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(220px, 1fr));
+52 -6
View File
@@ -31,6 +31,7 @@ const setupVoiceMixer = () => {
const dropzoneEl = app.querySelector('[data-role="dropzone"]');
const emptyStateEl = app.querySelector('[data-role="mix-empty"]');
const voiceFilterSelect = app.querySelector('[data-role="voice-filter"]');
const genderFilterEl = app.querySelector('[data-role="gender-filter"]');
if (!profileListEl || !availableListEl || !selectedListEl) {
return;
@@ -57,6 +58,7 @@ const setupVoiceMixer = () => {
voices: new Map(),
},
languageFilter: voiceFilterSelect ? voiceFilterSelect.value : "",
genderFilter: "",
};
let statusTimeout = null;
@@ -155,6 +157,16 @@ const setupVoiceMixer = () => {
});
};
const updateGenderFilterButtons = () => {
if (!genderFilterEl) return;
const buttons = genderFilterEl.querySelectorAll("[data-value]");
buttons.forEach((button) => {
const value = button.getAttribute("data-value") || "";
const pressed = value === state.genderFilter;
button.setAttribute("aria-pressed", pressed ? "true" : "false");
});
};
const setSliderFocus = (voiceId) => {
const control = selectedControls.get(voiceId);
if (control?.slider) {
@@ -250,18 +262,30 @@ const setupVoiceMixer = () => {
const filteredVoices = sortedVoices.filter((voice) => {
const languageCode = voice.language || voice.id?.charAt(0) || "a";
return !state.languageFilter || state.languageFilter === languageCode;
const languageMatch = !state.languageFilter || state.languageFilter === languageCode;
const genderCode = (voice.gender || "").charAt(0).toLowerCase();
const genderMatch = !state.genderFilter || state.genderFilter === genderCode;
return languageMatch && genderMatch;
});
if (!filteredVoices.length) {
const empty = document.createElement("p");
empty.className = "voice-available__empty";
const label = state.languageFilter
? voiceLanguageLabel(state.languageFilter) || state.languageFilter.toUpperCase()
: "All voices";
empty.innerHTML = `No voices for <strong>${label}</strong> yet.`;
const filters = [];
if (state.languageFilter) {
filters.push(voiceLanguageLabel(state.languageFilter) || state.languageFilter.toUpperCase());
}
if (state.genderFilter) {
filters.push(state.genderFilter === "f" ? "♀ Female" : "♂ Male");
}
if (filters.length) {
empty.innerHTML = `No voices match <strong>${filters.join(" · ")}</strong>.`;
} else {
empty.textContent = "No voices available.";
}
availableListEl.appendChild(empty);
updateAvailableState();
updateGenderFilterButtons();
return;
}
@@ -330,6 +354,7 @@ const setupVoiceMixer = () => {
});
updateAvailableState();
updateGenderFilterButtons();
availableListEl.scrollTop = 0;
};
@@ -727,6 +752,18 @@ const setupVoiceMixer = () => {
});
}
if (genderFilterEl) {
genderFilterEl.addEventListener("click", (event) => {
const target = event.target;
if (!(target instanceof HTMLButtonElement)) return;
const value = (target.getAttribute("data-value") || "").toLowerCase();
if (!value) return;
state.genderFilter = state.genderFilter === value ? "" : value;
renderAvailableVoices();
});
updateGenderFilterButtons();
}
if (nameInput) {
nameInput.addEventListener("input", () => {
state.draft.name = nameInput.value;
@@ -789,7 +826,16 @@ const setupVoiceMixer = () => {
});
});
dropzoneEl.addEventListener("click", () => {
dropzoneEl.addEventListener("click", (event) => {
if (!(event.target instanceof HTMLElement)) {
return;
}
if (event.target.closest(".mix-voice")) {
return;
}
if (event.target.closest(".mix-slider")) {
return;
}
const firstInactive = Array.from(availableCards.entries()).find(
([voiceId]) => !state.draft.voices.has(voiceId),
);
+5 -2
View File
@@ -16,8 +16,11 @@
<span class="brand__tagline">Audiobooks for humans, not desktops</span>
</div>
<nav class="top-actions">
<a href="{{ url_for('web.index') }}" class="btn">Dashboard</a>
<a href="{{ url_for('web.voice_profiles_page') }}" class="btn">Voice mixer</a>
{% set endpoint = request.endpoint or '' %}
<a href="{{ url_for('web.index') }}" class="btn{% if endpoint == 'web.index' %} is-active{% endif %}">Dashboard</a>
<a href="{{ url_for('web.voice_profiles_page') }}" class="btn{% if endpoint == 'web.voice_profiles_page' %} is-active{% endif %}">Voice Mixer</a>
<a href="{{ url_for('web.queue_page') }}" class="btn{% if endpoint in ['web.queue_page', 'web.job_detail'] %} is-active{% endif %}">Queue</a>
<a href="{{ url_for('web.settings_page') }}" class="btn{% if endpoint == 'web.settings_page' %} is-active{% endif %}">Settings</a>
</nav>
</header>
<main class="main">
+27 -64
View File
@@ -10,7 +10,6 @@
<div class="field">
<label for="source_file">Source file</label>
<input type="file" id="source_file" name="source_file" accept=".txt,.pdf,.epub,.md,.markdown">
<p class="tag">You can also paste text below.</p>
</div>
<div class="field">
<label for="language">Language</label>
@@ -20,9 +19,9 @@
{% endfor %}
</select>
</div>
<div class="field">
<div class="field" data-conditional="standard" data-role="voice-field">
<label for="voice">Voice</label>
<select id="voice" name="voice">
<select id="voice" name="voice" data-role="voice-select">
{% for voice in options.voices %}
<option value="{{ voice }}">{{ voice }}</option>
{% endfor %}
@@ -30,18 +29,22 @@
</div>
<div class="field">
<label for="voice_profile">Voice profile</label>
<select id="voice_profile" name="voice_profile">
<option value="">Use selected voice</option>
<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 %}
<optgroup label="Saved mixes">
{% for name, data in options.voice_profiles %}
<option value="{{ name }}">{{ name }} ({{ data.language|upper }})</option>
{% endfor %}
</optgroup>
{% endif %}
</select>
<p class="tag">Manage mixes in the <a href="{{ url_for('web.voice_profiles_page') }}">voice mixer</a>.</p>
</div>
<div class="field">
<div class="field" data-conditional="formula" data-role="formula-field" hidden>
<label for="voice_formula">Custom voice formula</label>
<input type="text" id="voice_formula" name="voice_formula" placeholder="af_nova*0.4+am_liam*0.6">
<p class="tag">Overrides the dropdown/profile when provided.</p>
<input type="text" id="voice_formula" name="voice_formula" placeholder="af_nova*0.4+am_liam*0.6" data-role="voice-formula">
</div>
<div class="field">
<label for="speed">Speed <span class="tag" id="speed_value">1.00×</span></label>
@@ -59,63 +62,22 @@
{% endfor %}
</select>
</div>
<div class="field">
<label for="output_format">Audio format</label>
<select id="output_format" name="output_format">
{% for fmt in options.output_formats %}
<option value="{{ fmt }}">{{ fmt }}</option>
{% endfor %}
</select>
</div>
<div class="field">
<label for="subtitle_format">Subtitle file format</label>
<select id="subtitle_format" name="subtitle_format">
{% for value, text in options.subtitle_formats %}
<option value="{{ value }}">{{ text }}</option>
{% endfor %}
</select>
</div>
<div class="field">
<label for="save_mode">Save location</label>
<select id="save_mode" name="save_mode">
<option>Save next to input file</option>
<option>Save to Desktop</option>
<option>Choose output folder</option>
</select>
</div>
<div class="split">
<label class="tag"><input type="checkbox" name="replace_single_newlines" value="true"> Replace single newlines</label>
<label class="tag"><input type="checkbox" name="use_gpu" value="true" checked> Use GPU (when available)</label>
</div>
<details class="field">
<summary>Advanced chapter &amp; project options</summary>
<div class="field inline">
<label class="tag"><input type="checkbox" name="save_chapters_separately" value="true"> Save each chapter separately</label>
<label class="tag"><input type="checkbox" name="merge_chapters_at_end" value="true" checked> Also create merged audiobook</label>
<label class="tag"><input type="checkbox" name="save_as_project" value="true"> Save in project folder with metadata</label>
</div>
<div class="field">
<label for="separate_chapters_format">Separate chapter format</label>
<select id="separate_chapters_format" name="separate_chapters_format">
{% for fmt in options.separate_formats %}
<option value="{{ fmt }}">{{ fmt|upper }}</option>
{% endfor %}
</select>
</div>
<div class="field">
<label for="silence_between_chapters">Silence between chapters (seconds)</label>
<input type="number" step="0.5" min="0" value="2.0" id="silence_between_chapters" name="silence_between_chapters">
</div>
<div class="field">
<label for="max_subtitle_words">Max words per subtitle entry</label>
<input type="number" min="1" max="100" value="50" id="max_subtitle_words" name="max_subtitle_words">
</div>
</details>
</div>
<div class="grid">
<div class="field">
<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..."></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 class="text-preview" data-role="text-preview" aria-live="polite">
<div class="text-preview__header">
<h2>Preview</h2>
<div class="text-preview__meta">
<span data-role="char-count">0 characters</span>
<span>·</span>
<span data-role="word-count">0 words</span>
</div>
</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;">
<button type="submit" class="button">Queue conversion</button>
@@ -123,8 +85,9 @@
</div>
</form>
</section>
{% endblock %}
<section class="card" id="jobs-panel" hx-get="{{ url_for('web.jobs_partial') }}" hx-trigger="load, every 3s" hx-target="#jobs-panel" hx-swap="innerHTML">
{% include "partials/jobs.html" %}
</section>
{% block scripts %}
{{ super() }}
<script type="module" src="{{ url_for('static', filename='dashboard.js') }}"></script>
{% endblock %}
+9
View File
@@ -0,0 +1,9 @@
{% extends "base.html" %}
{% block title %}abogen · Queue{% endblock %}
{% block content %}
<section class="card" id="jobs-panel" hx-get="{{ url_for('web.jobs_partial') }}" hx-trigger="load, every 3s" hx-target="#jobs-panel" hx-swap="innerHTML">
{% include "partials/jobs.html" %}
</section>
{% endblock %}
+78
View File
@@ -0,0 +1,78 @@
{% extends "base.html" %}
{% block title %}abogen · Settings{% endblock %}
{% block content %}
<section class="card settings">
<h1 class="card__title">Application settings</h1>
<p class="tag">Settings apply to new jobs you queue from the dashboard.</p>
{% if saved %}
<div class="alert alert--success">Settings saved successfully.</div>
{% endif %}
<form action="{{ url_for('web.settings_page') }}" method="post" class="settings__form">
<fieldset class="settings__section">
<legend>Output defaults</legend>
<div class="field">
<label for="output_format">Audio format</label>
<select id="output_format" name="output_format">
{% for fmt in options.output_formats %}
<option value="{{ fmt }}" {% if settings.output_format == fmt %}selected{% endif %}>{{ fmt }}</option>
{% endfor %}
</select>
</div>
<div class="field">
<label for="subtitle_format">Subtitle file format</label>
<select id="subtitle_format" name="subtitle_format">
{% for value, text in options.subtitle_formats %}
<option value="{{ value }}" {% if settings.subtitle_format == value %}selected{% endif %}>{{ text }}</option>
{% endfor %}
</select>
</div>
<div class="field">
<label for="save_mode">Save location</label>
<select id="save_mode" name="save_mode">
{% for location in save_locations %}
<option value="{{ location.value }}" {% if settings.save_mode == location.value %}selected{% endif %}>{{ location.label }}</option>
{% endfor %}
</select>
<p class="tag">Default output: <code>{{ default_output_dir }}</code></p>
</div>
</fieldset>
<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>
<div class="field">
<label for="separate_chapters_format">Separate chapter format</label>
<select id="separate_chapters_format" name="separate_chapters_format">
{% for fmt in options.separate_formats %}
<option value="{{ fmt }}" {% if settings.separate_chapters_format == fmt %}selected{% endif %}>{{ fmt|upper }}</option>
{% endfor %}
</select>
</div>
<div class="field">
<label for="silence_between_chapters">Silence between chapters (seconds)</label>
<input type="number" step="0.5" min="0" id="silence_between_chapters" name="silence_between_chapters" value="{{ settings.silence_between_chapters }}">
</div>
<div class="field">
<label for="max_subtitle_words">Max words per subtitle entry</label>
<input type="number" min="1" max="200" id="max_subtitle_words" name="max_subtitle_words" value="{{ settings.max_subtitle_words }}">
</div>
</fieldset>
<div class="settings__actions">
<button type="submit" class="button">Save settings</button>
</div>
</form>
</section>
{% endblock %}
+7 -1
View File
@@ -55,13 +55,19 @@
<p class="tag">Drag into the mixer or click Add.</p>
</div>
<div class="voice-filter">
<label for="voice-language-filter">Language</label>
<label class="voice-filter__label" for="voice-language-filter">Language</label>
<div class="voice-filter__controls">
<select id="voice-language-filter" data-role="voice-filter">
<option value="">All languages</option>
{% for key, label in options.languages.items() %}
<option value="{{ key }}">{{ label }} ({{ key|upper }})</option>
{% endfor %}
</select>
<div class="voice-gender-filter" data-role="gender-filter" aria-label="Filter by gender">
<button type="button" class="voice-gender-filter__button" data-value="f" aria-pressed="false" title="Show female voices"></button>
<button type="button" class="voice-gender-filter__button" data-value="m" aria-pressed="false" title="Show male voices"></button>
</div>
</div>
</div>
</header>
<div class="voice-available__list" data-role="available-voices"></div>