mirror of
https://github.com/denizsafak/abogen.git
synced 2026-07-18 21:50:28 +02:00
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:
@@ -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)
|
||||||
|
|||||||
+208
-34
@@ -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,
|
||||||
|
|||||||
@@ -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();
|
||||||
|
}
|
||||||
@@ -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));
|
||||||
|
|||||||
@@ -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),
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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">
|
||||||
|
|||||||
@@ -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>
|
||||||
|
<option value="__formula">Formula</option>
|
||||||
|
{% if options.voice_profiles %}
|
||||||
|
<optgroup label="Saved mixes">
|
||||||
{% for name, data in options.voice_profiles %}
|
{% for name, data in options.voice_profiles %}
|
||||||
<option value="{{ name }}">{{ name }} ({{ data.language|upper }})</option>
|
<option value="{{ name }}">{{ name }} ({{ data.language|upper }})</option>
|
||||||
{% endfor %}
|
{% 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 & 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 %}
|
||||||
|
|||||||
@@ -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 %}
|
||||||
@@ -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 %}
|
||||||
@@ -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>
|
||||||
|
<div class="voice-filter__controls">
|
||||||
<select id="voice-language-filter" data-role="voice-filter">
|
<select id="voice-language-filter" data-role="voice-filter">
|
||||||
<option value="">All languages</option>
|
<option value="">All languages</option>
|
||||||
{% for key, label in options.languages.items() %}
|
{% for key, label in options.languages.items() %}
|
||||||
<option value="{{ key }}">{{ label }} ({{ key|upper }})</option>
|
<option value="{{ key }}">{{ label }} ({{ key|upper }})</option>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
</select>
|
</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>
|
||||||
|
|||||||
Reference in New Issue
Block a user