diff --git a/abogen/web/conversion_runner.py b/abogen/web/conversion_runner.py index 1aac1cd..113690b 100644 --- a/abogen/web/conversion_runner.py +++ b/abogen/web/conversion_runner.py @@ -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) diff --git a/abogen/web/routes.py b/abogen/web/routes.py index c48c412..513e318 100644 --- a/abogen/web/routes.py +++ b/abogen/web/routes.py @@ -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, diff --git a/abogen/web/static/dashboard.js b/abogen/web/static/dashboard.js new file mode 100644 index 0000000..a9fce26 --- /dev/null +++ b/abogen/web/static/dashboard.js @@ -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(); +} diff --git a/abogen/web/static/styles.css b/abogen/web/static/styles.css index 62b3123..ed60614 100644 --- a/abogen/web/static/styles.css +++ b/abogen/web/static/styles.css @@ -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)); diff --git a/abogen/web/static/voices.js b/abogen/web/static/voices.js index 352a77b..4880d63 100644 --- a/abogen/web/static/voices.js +++ b/abogen/web/static/voices.js @@ -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 ${label} 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 ${filters.join(" · ")}.`; + } 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), ); diff --git a/abogen/web/templates/base.html b/abogen/web/templates/base.html index c4fbfe7..bc8a09f 100644 --- a/abogen/web/templates/base.html +++ b/abogen/web/templates/base.html @@ -16,8 +16,11 @@ Audiobooks for humans, not desktops
diff --git a/abogen/web/templates/index.html b/abogen/web/templates/index.html index 2168edb..5ff4372 100644 --- a/abogen/web/templates/index.html +++ b/abogen/web/templates/index.html @@ -10,7 +10,6 @@
-

You can also paste text below.

@@ -20,9 +19,9 @@ {% endfor %}
-
+
- {% for voice in options.voices %} {% endfor %} @@ -30,18 +29,22 @@
- + + + {% if options.voice_profiles %} + + {% for name, data in options.voice_profiles %} + + {% endfor %} + + {% endif %} -

Manage mixes in the voice mixer.

+

Manage mixes in the voice mixer.

-
+
@@ -59,63 +62,22 @@ {% endfor %}
-
- - -
-
- - -
-
- - -
-
- - -
-
- Advanced chapter & project options -
- - - -
-
- - -
-
- - -
-
- - -
-
- + +
+
+
+

Preview

+
+ 0 characters + · + 0 words +
+
+
Paste text to see a live preview and character count.
@@ -123,8 +85,9 @@
+{% endblock %} -
- {% include "partials/jobs.html" %} -
+{% block scripts %} + {{ super() }} + {% endblock %} diff --git a/abogen/web/templates/queue.html b/abogen/web/templates/queue.html new file mode 100644 index 0000000..cebde96 --- /dev/null +++ b/abogen/web/templates/queue.html @@ -0,0 +1,9 @@ +{% extends "base.html" %} + +{% block title %}abogen · Queue{% endblock %} + +{% block content %} +
+ {% include "partials/jobs.html" %} +
+{% endblock %} diff --git a/abogen/web/templates/settings.html b/abogen/web/templates/settings.html new file mode 100644 index 0000000..703099f --- /dev/null +++ b/abogen/web/templates/settings.html @@ -0,0 +1,78 @@ +{% extends "base.html" %} + +{% block title %}abogen · Settings{% endblock %} + +{% block content %} +
+

Application settings

+

Settings apply to new jobs you queue from the dashboard.

+ + {% if saved %} +
Settings saved successfully.
+ {% endif %} + +
+
+ Output defaults +
+ + +
+
+ + +
+
+ + +

Default output: {{ default_output_dir }}

+
+
+ +
+ Processing +
+ + +
+
+ + + +
+
+ + +
+
+ + +
+
+ + +
+
+ +
+ +
+
+
+{% endblock %} diff --git a/abogen/web/templates/voices.html b/abogen/web/templates/voices.html index 8fb86bc..392f2ab 100644 --- a/abogen/web/templates/voices.html +++ b/abogen/web/templates/voices.html @@ -55,13 +55,19 @@

Drag into the mixer or click Add.

- - + +
+ +
+ + +
+