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, calculate_text_length,
create_process, create_process,
get_user_cache_path, get_user_cache_path,
get_user_output_path,
load_config, load_config,
load_numpy_kpipeline, load_numpy_kpipeline,
) )
@@ -215,6 +216,8 @@ def _prepare_output_dir(job: Job) -> Path:
directory = job.stored_path.parent directory = job.stored_path.parent
elif job.save_mode == "Choose output folder" and job.output_folder: elif job.save_mode == "Choose output folder" and job.output_folder:
directory = Path(job.output_folder) directory = Path(job.output_folder)
elif job.save_mode == "Use default save location":
directory = Path(get_user_output_path())
else: else:
directory = default_output directory = default_output
directory.mkdir(parents=True, exist_ok=True) directory.mkdir(parents=True, exist_ok=True)
+209 -35
View File
@@ -3,6 +3,7 @@ from __future__ import annotations
import io import io
import json import json
import mimetypes import mimetypes
import os
import threading import threading
import uuid import uuid
from pathlib import Path from pathlib import Path
@@ -32,7 +33,14 @@ from abogen.constants import (
SUPPORTED_SOUND_FORMATS, SUPPORTED_SOUND_FORMATS,
VOICES_INTERNAL, 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 ( from abogen.voice_profiles import (
delete_profile, delete_profile,
duplicate_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]: def _formula_from_profile(entry: Dict[str, Any]) -> Optional[str]:
voices = entry.get("voices") or [] voices = entry.get("voices") or []
if not voices: if not voices:
@@ -224,13 +339,62 @@ 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:
service = _service() return render_template("index.html", options=_template_options())
jobs = service.list_jobs()
return render_template(
"index.html", @web_bp.get("/queue")
jobs=jobs, def queue_page() -> str:
options=_template_options(), 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") @web_bp.get("/voices")
@@ -361,7 +525,11 @@ def api_preview_voice_mix() -> Response:
language = (payload.get("language") or "a").strip() or "a" language = (payload.get("language") or "a").strip() or "a"
text = (payload.get("text") or "").strip() text = (payload.get("text") or "").strip()
speed = float(payload.get("speed", 1.0) or 1.0) 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() profile_name = (payload.get("profile") or payload.get("profile_name") or "").strip()
formula = (payload.get("formula") or "").strip() formula = (payload.get("formula") or "").strip()
@@ -398,9 +566,12 @@ def api_preview_voice_mix() -> Response:
if not text: if not text:
text = SAMPLE_VOICE_TEXTS.get(language, SAMPLE_VOICE_TEXTS.get("a", "This is a sample of the selected voice.")) text = SAMPLE_VOICE_TEXTS.get(language, SAMPLE_VOICE_TEXTS.get("a", "This is a sample of the selected voice."))
cfg = load_config() settings = _load_settings()
use_gpu_cfg = bool(cfg.get("use_gpu", True)) use_gpu_default = settings.get("use_gpu", True)
use_gpu = use_gpu_cfg if payload.get("use_gpu") is None else bool(payload.get("use_gpu")) if "use_gpu" in payload:
use_gpu = _coerce_bool(payload.get("use_gpu"), use_gpu_default)
else:
use_gpu = use_gpu_default
device = "cpu" device = "cpu"
if use_gpu: if use_gpu:
try: try:
@@ -506,11 +677,23 @@ def enqueue_job() -> Response:
total_chars = calculate_text_length(clean_text(text_input)) total_chars = calculate_text_length(clean_text(text_input))
profiles = load_profiles() profiles = load_profiles()
settings = _load_settings()
language = request.form.get("language", "a") language = request.form.get("language", "a")
base_voice = request.form.get("voice", "af_alloy") base_voice = request.form.get("voice", "af_alloy")
profile_name = request.form.get("voice_profile", "").strip() profile_selection = (request.form.get("voice_profile") or "__standard").strip()
custom_formula = request.form.get("voice_formula", "").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( voice, language, selected_profile = _resolve_voice_choice(
language, language,
base_voice, base_voice,
@@ -520,27 +703,18 @@ def enqueue_job() -> Response:
) )
speed = float(request.form.get("speed", "1.0")) speed = float(request.form.get("speed", "1.0"))
subtitle_mode = request.form.get("subtitle_mode", "Disabled") subtitle_mode = request.form.get("subtitle_mode", "Disabled")
output_format = request.form.get("output_format", "wav") output_format = settings["output_format"]
subtitle_format = request.form.get("subtitle_format", "srt") subtitle_format = settings["subtitle_format"]
save_mode = request.form.get("save_mode", "Save next to input file") save_mode_key = settings["save_mode"]
replace_single_newlines = request.form.get("replace_single_newlines") in {"true", "on", "1"} save_mode = SAVE_MODE_LABELS.get(save_mode_key, SAVE_MODE_LABELS["save_next_to_input"])
use_gpu = request.form.get("use_gpu") in {"true", "on", "1"} replace_single_newlines = settings["replace_single_newlines"]
save_chapters_separately = request.form.get("save_chapters_separately") in {"true", "on", "1"} use_gpu = settings["use_gpu"]
merge_chapters_at_end = request.form.get("merge_chapters_at_end") in {"true", "on", "1"} save_chapters_separately = settings["save_chapters_separately"]
if not save_chapters_separately: merge_chapters_at_end = settings["merge_chapters_at_end"] or not save_chapters_separately
merge_chapters_at_end = True save_as_project = settings["save_as_project"]
save_as_project = request.form.get("save_as_project") in {"true", "on", "1"} separate_chapters_format = settings["separate_chapters_format"]
separate_chapters_format = request.form.get("separate_chapters_format", "wav").lower() silence_between_chapters = settings["silence_between_chapters"]
try: max_subtitle_words = settings["max_subtitle_words"]
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))
job = service.enqueue( job = service.enqueue(
original_filename=original_name, 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); 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 { .main {
flex: 1; flex: 1;
padding: 2.5rem 3rem 3.5rem; padding: 2.5rem 3rem 3.5rem;
@@ -341,11 +348,54 @@ body {
gap: 1rem; 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 { .button--danger {
background: linear-gradient(135deg, var(--danger), #ef4444); background: linear-gradient(135deg, var(--danger), #ef4444);
box-shadow: 0 12px 30px rgba(239, 68, 68, 0.2); 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 { .pill-grid {
display: flex; display: flex;
flex-wrap: wrap; flex-wrap: wrap;
@@ -361,6 +411,51 @@ body {
background: rgba(15, 23, 42, 0.4); 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 { progress.progress {
width: 100%; width: 100%;
height: 0.6rem; height: 0.6rem;
@@ -548,7 +643,7 @@ progress.progress::-moz-progress-bar {
min-width: 180px; min-width: 180px;
} }
.voice-filter label { .voice-filter__label {
font-size: 0.75rem; font-size: 0.75rem;
font-weight: 600; font-weight: 600;
letter-spacing: 0.04em; letter-spacing: 0.04em;
@@ -556,6 +651,13 @@ progress.progress::-moz-progress-bar {
color: var(--muted); color: var(--muted);
} }
.voice-filter__controls {
display: flex;
align-items: center;
gap: 0.75rem;
flex-wrap: wrap;
}
.voice-filter select { .voice-filter select {
border-radius: 12px; border-radius: 12px;
border: 1px solid rgba(148, 163, 184, 0.2); border: 1px solid rgba(148, 163, 184, 0.2);
@@ -563,6 +665,8 @@ progress.progress::-moz-progress-bar {
padding: 0.55rem 0.75rem; padding: 0.55rem 0.75rem;
color: var(--text); color: var(--text);
font-size: 0.95rem; font-size: 0.95rem;
min-width: 0;
flex: 1 1 200px;
} }
.voice-filter select:focus { .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); 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 { .voice-available__list {
display: grid; display: grid;
grid-template-columns: repeat(auto-fill, minmax(220px, 1fr)); 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 dropzoneEl = app.querySelector('[data-role="dropzone"]');
const emptyStateEl = app.querySelector('[data-role="mix-empty"]'); const emptyStateEl = app.querySelector('[data-role="mix-empty"]');
const voiceFilterSelect = app.querySelector('[data-role="voice-filter"]'); const voiceFilterSelect = app.querySelector('[data-role="voice-filter"]');
const genderFilterEl = app.querySelector('[data-role="gender-filter"]');
if (!profileListEl || !availableListEl || !selectedListEl) { if (!profileListEl || !availableListEl || !selectedListEl) {
return; return;
@@ -57,6 +58,7 @@ const setupVoiceMixer = () => {
voices: new Map(), voices: new Map(),
}, },
languageFilter: voiceFilterSelect ? voiceFilterSelect.value : "", languageFilter: voiceFilterSelect ? voiceFilterSelect.value : "",
genderFilter: "",
}; };
let statusTimeout = null; 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 setSliderFocus = (voiceId) => {
const control = selectedControls.get(voiceId); const control = selectedControls.get(voiceId);
if (control?.slider) { if (control?.slider) {
@@ -250,18 +262,30 @@ const setupVoiceMixer = () => {
const filteredVoices = sortedVoices.filter((voice) => { const filteredVoices = sortedVoices.filter((voice) => {
const languageCode = voice.language || voice.id?.charAt(0) || "a"; 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) { if (!filteredVoices.length) {
const empty = document.createElement("p"); const empty = document.createElement("p");
empty.className = "voice-available__empty"; empty.className = "voice-available__empty";
const label = state.languageFilter const filters = [];
? voiceLanguageLabel(state.languageFilter) || state.languageFilter.toUpperCase() if (state.languageFilter) {
: "All voices"; filters.push(voiceLanguageLabel(state.languageFilter) || state.languageFilter.toUpperCase());
empty.innerHTML = `No voices for <strong>${label}</strong> yet.`; }
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); availableListEl.appendChild(empty);
updateAvailableState(); updateAvailableState();
updateGenderFilterButtons();
return; return;
} }
@@ -330,6 +354,7 @@ const setupVoiceMixer = () => {
}); });
updateAvailableState(); updateAvailableState();
updateGenderFilterButtons();
availableListEl.scrollTop = 0; 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) { if (nameInput) {
nameInput.addEventListener("input", () => { nameInput.addEventListener("input", () => {
state.draft.name = nameInput.value; 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( const firstInactive = Array.from(availableCards.entries()).find(
([voiceId]) => !state.draft.voices.has(voiceId), ([voiceId]) => !state.draft.voices.has(voiceId),
); );
+5 -2
View File
@@ -16,8 +16,11 @@
<span class="brand__tagline">Audiobooks for humans, not desktops</span> <span class="brand__tagline">Audiobooks for humans, not desktops</span>
</div> </div>
<nav class="top-actions"> <nav class="top-actions">
<a href="{{ url_for('web.index') }}" class="btn">Dashboard</a> {% set endpoint = request.endpoint or '' %}
<a href="{{ url_for('web.voice_profiles_page') }}" class="btn">Voice mixer</a> <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> </nav>
</header> </header>
<main class="main"> <main class="main">
+31 -68
View File
@@ -10,7 +10,6 @@
<div class="field"> <div class="field">
<label for="source_file">Source file</label> <label for="source_file">Source file</label>
<input type="file" id="source_file" name="source_file" accept=".txt,.pdf,.epub,.md,.markdown"> <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>
<div class="field"> <div class="field">
<label for="language">Language</label> <label for="language">Language</label>
@@ -20,9 +19,9 @@
{% endfor %} {% endfor %}
</select> </select>
</div> </div>
<div class="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"> <select id="voice" name="voice" data-role="voice-select">
{% for voice in options.voices %} {% for voice in options.voices %}
<option value="{{ voice }}">{{ voice }}</option> <option value="{{ voice }}">{{ voice }}</option>
{% endfor %} {% endfor %}
@@ -30,18 +29,22 @@
</div> </div>
<div class="field"> <div class="field">
<label for="voice_profile">Voice profile</label> <label for="voice_profile">Voice profile</label>
<select id="voice_profile" name="voice_profile"> <select id="voice_profile" name="voice_profile" data-role="voice-profile">
<option value="">Use selected voice</option> <option value="__standard" selected>Standard voice</option>
{% for name, data in options.voice_profiles %} <option value="__formula">Formula</option>
<option value="{{ name }}">{{ name }} ({{ data.language|upper }})</option> {% if options.voice_profiles %}
{% endfor %} <optgroup label="Saved mixes">
{% for name, data in options.voice_profiles %}
<option value="{{ name }}">{{ name }} ({{ data.language|upper }})</option>
{% endfor %}
</optgroup>
{% endif %}
</select> </select>
<p class="tag">Manage mixes in the <a href="{{ url_for('web.voice_profiles_page') }}">voice mixer</a>.</p> <p class="tag">Manage mixes in the <a href="{{ url_for('web.voice_profiles_page') }}">voice mixer</a>.</p>
</div> </div>
<div class="field"> <div class="field" data-conditional="formula" data-role="formula-field" hidden>
<label for="voice_formula">Custom voice formula</label> <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"> <input type="text" id="voice_formula" name="voice_formula" placeholder="af_nova*0.4+am_liam*0.6" data-role="voice-formula">
<p class="tag">Overrides the dropdown/profile when provided.</p>
</div> </div>
<div class="field"> <div class="field">
<label for="speed">Speed <span class="tag" id="speed_value">1.00×</span></label> <label for="speed">Speed <span class="tag" id="speed_value">1.00×</span></label>
@@ -59,63 +62,22 @@
{% endfor %} {% endfor %}
</select> </select>
</div> </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>
<div class="grid"> <div class="grid">
<div class="field"> <div class="field">
<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..."></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>
<div class="field" style="justify-content: flex-end;"> <div class="field" style="justify-content: flex-end;">
<button type="submit" class="button">Queue conversion</button> <button type="submit" class="button">Queue conversion</button>
@@ -123,8 +85,9 @@
</div> </div>
</form> </form>
</section> </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"> {% block scripts %}
{% include "partials/jobs.html" %} {{ super() }}
</section> <script type="module" src="{{ url_for('static', filename='dashboard.js') }}"></script>
{% endblock %} {% 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 %}
+13 -7
View File
@@ -55,13 +55,19 @@
<p class="tag">Drag into the mixer or click Add.</p> <p class="tag">Drag into the mixer or click Add.</p>
</div> </div>
<div class="voice-filter"> <div class="voice-filter">
<label for="voice-language-filter">Language</label> <label class="voice-filter__label" for="voice-language-filter">Language</label>
<select id="voice-language-filter" data-role="voice-filter"> <div class="voice-filter__controls">
<option value="">All languages</option> <select id="voice-language-filter" data-role="voice-filter">
{% for key, label in options.languages.items() %} <option value="">All languages</option>
<option value="{{ key }}">{{ label }} ({{ key|upper }})</option> {% for key, label in options.languages.items() %}
{% endfor %} <option value="{{ key }}">{{ label }} ({{ key|upper }})</option>
</select> {% 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> </div>
</header> </header>
<div class="voice-available__list" data-role="available-voices"></div> <div class="voice-available__list" data-role="available-voices"></div>