mirror of
https://github.com/denizsafak/abogen.git
synced 2026-07-18 13:40:27 +02:00
Add number normalization and enhance UI for voice preview
- Implemented number conversion to words for grouped numbers in text normalization. - Added configuration options for number conversion in ApostropheConfig. - Updated the normalize_apostrophes function to include number normalization. - Enhanced the dashboard UI with new sections for manuscript and narrator defaults. - Improved voice preview functionality with better handling of audio playback and status updates. - Refactored HTML templates for cleaner structure and added new fields for speaker settings. - Updated CSS styles for improved layout and responsiveness. - Added tests to ensure correct spelling of grouped numbers in the normalization process. - Included num2words as a new dependency in pyproject.toml.
This commit is contained in:
@@ -3,6 +3,10 @@ import re
|
|||||||
import unicodedata
|
import unicodedata
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
from typing import Callable, Iterable, List, Optional, Sequence, Tuple
|
from typing import Callable, Iterable, List, Optional, Sequence, Tuple
|
||||||
|
try: # pragma: no cover - optional dependency guard
|
||||||
|
from num2words import num2words
|
||||||
|
except Exception: # pragma: no cover - graceful degradation
|
||||||
|
num2words = None # type: ignore
|
||||||
|
|
||||||
# ---------- Configuration Dataclass ----------
|
# ---------- Configuration Dataclass ----------
|
||||||
|
|
||||||
@@ -24,6 +28,8 @@ class ApostropheConfig:
|
|||||||
joiner: str = "" # Replacement used when collapsing internal apostrophes
|
joiner: str = "" # Replacement used when collapsing internal apostrophes
|
||||||
lowercase_for_matching: bool = True # Normalize to lower for rule matching (not output)
|
lowercase_for_matching: bool = True # Normalize to lower for rule matching (not output)
|
||||||
protect_cultural_names: bool = True # Always keep O'Brien, D'Angelo, etc.
|
protect_cultural_names: bool = True # Always keep O'Brien, D'Angelo, etc.
|
||||||
|
convert_numbers: bool = True # Convert grouped numbers such as 12,500 to words
|
||||||
|
number_lang: str = "en" # num2words language code
|
||||||
|
|
||||||
# ---------- Dictionaries / Patterns ----------
|
# ---------- Dictionaries / Patterns ----------
|
||||||
|
|
||||||
@@ -80,6 +86,7 @@ CONTRACTIONS_EXACT = {
|
|||||||
}
|
}
|
||||||
|
|
||||||
# For ambiguous 'd and 's we handle separately
|
# For ambiguous 'd and 's we handle separately
|
||||||
|
_NUMBER_WITH_GROUP_RE = re.compile(r"(?<![\w\d])(-?\d{1,3}(?:,\d{3})+)(?![\w\d])")
|
||||||
AMBIGUOUS_D_BASES = {"i","you","he","she","we","they"}
|
AMBIGUOUS_D_BASES = {"i","you","he","she","we","they"}
|
||||||
AMBIGUOUS_S_BASES = {"it","that","what","where","who","when","how","there","here"}
|
AMBIGUOUS_S_BASES = {"it","that","what","where","who","when","how","there","here"}
|
||||||
|
|
||||||
@@ -528,6 +535,7 @@ def normalize_apostrophes(text: str, cfg: ApostropheConfig | None = None) -> Tup
|
|||||||
cfg = ApostropheConfig()
|
cfg = ApostropheConfig()
|
||||||
|
|
||||||
text = normalize_unicode_apostrophes(text)
|
text = normalize_unicode_apostrophes(text)
|
||||||
|
text = _normalize_grouped_numbers(text, cfg)
|
||||||
tokens = tokenize(text)
|
tokens = tokenize(text)
|
||||||
|
|
||||||
results = []
|
results = []
|
||||||
@@ -542,6 +550,41 @@ def normalize_apostrophes(text: str, cfg: ApostropheConfig | None = None) -> Tup
|
|||||||
normalized_text = _cleanup_spacing(" ".join(filtered))
|
normalized_text = _cleanup_spacing(" ".join(filtered))
|
||||||
return normalized_text, results
|
return normalized_text, results
|
||||||
|
|
||||||
|
def _normalize_grouped_numbers(text: str, cfg: ApostropheConfig) -> str:
|
||||||
|
if not text or not cfg.convert_numbers:
|
||||||
|
return text
|
||||||
|
|
||||||
|
def _replace(match: re.Match[str]) -> str:
|
||||||
|
token = match.group(1)
|
||||||
|
cleaned = token.replace(",", "")
|
||||||
|
if not cleaned:
|
||||||
|
return token
|
||||||
|
negative = cleaned.startswith("-")
|
||||||
|
cleaned_digits = cleaned[1:] if negative else cleaned
|
||||||
|
|
||||||
|
if not cleaned_digits.isdigit():
|
||||||
|
return cleaned_digits if not negative else f"-{cleaned_digits}"
|
||||||
|
|
||||||
|
if num2words is None:
|
||||||
|
return ("-" if negative else "") + cleaned_digits
|
||||||
|
|
||||||
|
try:
|
||||||
|
value = int(cleaned)
|
||||||
|
except ValueError:
|
||||||
|
return cleaned
|
||||||
|
|
||||||
|
language = (cfg.number_lang or "en").strip() or "en"
|
||||||
|
try:
|
||||||
|
words = num2words(abs(value), lang=language)
|
||||||
|
except Exception: # pragma: no cover - unsupported locale
|
||||||
|
return str(value)
|
||||||
|
|
||||||
|
if value < 0:
|
||||||
|
words = f"minus {words}"
|
||||||
|
return words
|
||||||
|
|
||||||
|
return _NUMBER_WITH_GROUP_RE.sub(_replace, text)
|
||||||
|
|
||||||
# ---------- Optional phoneme hint post-processing ----------
|
# ---------- Optional phoneme hint post-processing ----------
|
||||||
|
|
||||||
def apply_phoneme_hints(text: str, iz_marker="‹IZ›") -> str:
|
def apply_phoneme_hints(text: str, iz_marker="‹IZ›") -> str:
|
||||||
@@ -567,6 +610,7 @@ def normalize_for_pipeline(text: str, *, config: Optional[ApostropheConfig] = No
|
|||||||
normalized, _details = normalize_apostrophes(text, cfg)
|
normalized, _details = normalize_apostrophes(text, cfg)
|
||||||
normalized = expand_titles_and_suffixes(normalized)
|
normalized = expand_titles_and_suffixes(normalized)
|
||||||
normalized = ensure_terminal_punctuation(normalized)
|
normalized = ensure_terminal_punctuation(normalized)
|
||||||
|
normalized = _normalize_grouped_numbers(normalized, cfg)
|
||||||
if cfg.add_phoneme_hints:
|
if cfg.add_phoneme_hints:
|
||||||
normalized = apply_phoneme_hints(normalized, iz_marker=cfg.sibilant_iz_marker)
|
normalized = apply_phoneme_hints(normalized, iz_marker=cfg.sibilant_iz_marker)
|
||||||
return normalized
|
return normalized
|
||||||
|
|||||||
@@ -1508,13 +1508,15 @@ def index() -> str:
|
|||||||
"index.html",
|
"index.html",
|
||||||
options=_template_options(),
|
options=_template_options(),
|
||||||
settings=_load_settings(),
|
settings=_load_settings(),
|
||||||
jobs_panel=_render_jobs_panel(),
|
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@web_bp.get("/queue")
|
@web_bp.get("/queue")
|
||||||
def queue_page() -> ResponseReturnValue:
|
def queue_page() -> ResponseReturnValue:
|
||||||
return redirect(url_for("web.index", _anchor="queue"))
|
return render_template(
|
||||||
|
"queue.html",
|
||||||
|
jobs_panel=_render_jobs_panel(),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
@web_bp.route("/settings", methods=["GET", "POST"])
|
@web_bp.route("/settings", methods=["GET", "POST"])
|
||||||
|
|||||||
@@ -8,15 +8,35 @@ const initDashboard = () => {
|
|||||||
const defaultReaderHint = readerHint?.textContent || "";
|
const defaultReaderHint = readerHint?.textContent || "";
|
||||||
const scope = uploadModal || document;
|
const scope = uploadModal || document;
|
||||||
|
|
||||||
|
const parseJSONScript = (id) => {
|
||||||
|
const element = document.getElementById(id);
|
||||||
|
if (!element) return null;
|
||||||
|
try {
|
||||||
|
const raw = element.textContent || "";
|
||||||
|
return raw ? JSON.parse(raw) : null;
|
||||||
|
} catch (error) {
|
||||||
|
console.warn(`Failed to parse JSON script: ${id}`, error);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const profileSelect = scope.querySelector('[data-role="voice-profile"]');
|
const profileSelect = scope.querySelector('[data-role="voice-profile"]');
|
||||||
const voiceField = scope.querySelector('[data-role="voice-field"]');
|
const voiceField = scope.querySelector('[data-role="voice-field"]');
|
||||||
const voiceSelect = scope.querySelector('[data-role="voice-select"]');
|
const voiceSelect = scope.querySelector('[data-role="voice-select"]');
|
||||||
const formulaField = scope.querySelector('[data-role="formula-field"]');
|
const formulaField = scope.querySelector('[data-role="formula-field"]');
|
||||||
const formulaInput = scope.querySelector('[data-role="voice-formula"]');
|
const formulaInput = scope.querySelector('[data-role="voice-formula"]');
|
||||||
const languageSelect = uploadModal?.querySelector("#language") || document.getElementById("language");
|
const languageSelect = uploadModal?.querySelector("#language") || document.getElementById("language");
|
||||||
|
const speedInput = uploadModal?.querySelector('#speed') || document.getElementById('speed');
|
||||||
|
const previewButton = scope.querySelector('[data-role="voice-preview-button"]');
|
||||||
|
const previewStatus = scope.querySelector('[data-role="voice-preview-status"]');
|
||||||
|
const previewAudio = scope.querySelector('[data-role="voice-preview-audio"]');
|
||||||
|
const sampleVoiceTexts = parseJSONScript('voice-sample-texts') || {};
|
||||||
|
|
||||||
let lastTrigger = null;
|
let lastTrigger = null;
|
||||||
let readerTrigger = null;
|
let readerTrigger = null;
|
||||||
|
let previewAbortController = null;
|
||||||
|
let previewObjectUrl = null;
|
||||||
|
let suppressPauseStatus = false;
|
||||||
|
|
||||||
const openUploadModal = (trigger) => {
|
const openUploadModal = (trigger) => {
|
||||||
if (!uploadModal) return;
|
if (!uploadModal) return;
|
||||||
@@ -131,6 +151,215 @@ const initDashboard = () => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const resolveSampleText = (language) => {
|
||||||
|
const fallback = typeof sampleVoiceTexts === "object" && sampleVoiceTexts?.a
|
||||||
|
? sampleVoiceTexts.a
|
||||||
|
: "This is a sample of the selected voice.";
|
||||||
|
if (!language || typeof sampleVoiceTexts !== "object" || !sampleVoiceTexts) {
|
||||||
|
return fallback;
|
||||||
|
}
|
||||||
|
const normalizedKey = language.toLowerCase();
|
||||||
|
if (typeof sampleVoiceTexts[normalizedKey] === "string" && sampleVoiceTexts[normalizedKey].trim()) {
|
||||||
|
return sampleVoiceTexts[normalizedKey];
|
||||||
|
}
|
||||||
|
const baseKey = normalizedKey.split(/[_.-]/)[0];
|
||||||
|
if (baseKey && typeof sampleVoiceTexts[baseKey] === "string" && sampleVoiceTexts[baseKey].trim()) {
|
||||||
|
return sampleVoiceTexts[baseKey];
|
||||||
|
}
|
||||||
|
return fallback;
|
||||||
|
};
|
||||||
|
|
||||||
|
const getSelectedLanguage = () => {
|
||||||
|
const value = languageSelect?.value || "a";
|
||||||
|
return (value || "a").trim() || "a";
|
||||||
|
};
|
||||||
|
|
||||||
|
const getSelectedSpeed = () => {
|
||||||
|
const raw = speedInput?.value || "1";
|
||||||
|
const parsed = Number.parseFloat(raw);
|
||||||
|
return Number.isFinite(parsed) ? parsed : 1;
|
||||||
|
};
|
||||||
|
|
||||||
|
const cancelPreviewRequest = () => {
|
||||||
|
if (!previewAbortController) return;
|
||||||
|
previewAbortController.abort();
|
||||||
|
previewAbortController = null;
|
||||||
|
};
|
||||||
|
|
||||||
|
const stopPreviewAudio = () => {
|
||||||
|
if (previewAudio) {
|
||||||
|
suppressPauseStatus = true;
|
||||||
|
try {
|
||||||
|
previewAudio.pause();
|
||||||
|
} catch (error) {
|
||||||
|
// Ignore pause errors
|
||||||
|
}
|
||||||
|
previewAudio.removeAttribute("src");
|
||||||
|
previewAudio.load();
|
||||||
|
previewAudio.hidden = true;
|
||||||
|
suppressPauseStatus = false;
|
||||||
|
}
|
||||||
|
if (previewObjectUrl) {
|
||||||
|
URL.revokeObjectURL(previewObjectUrl);
|
||||||
|
previewObjectUrl = null;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const setPreviewStatus = (message, state = "") => {
|
||||||
|
if (!previewStatus) return;
|
||||||
|
if (!message) {
|
||||||
|
previewStatus.textContent = "";
|
||||||
|
previewStatus.hidden = true;
|
||||||
|
previewStatus.removeAttribute("data-state");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
previewStatus.textContent = message;
|
||||||
|
previewStatus.hidden = false;
|
||||||
|
if (state) {
|
||||||
|
previewStatus.dataset.state = state;
|
||||||
|
} else {
|
||||||
|
previewStatus.removeAttribute("data-state");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const setPreviewLoading = (isLoading) => {
|
||||||
|
if (!previewButton) return;
|
||||||
|
previewButton.disabled = isLoading;
|
||||||
|
if (isLoading) {
|
||||||
|
previewButton.dataset.loading = "true";
|
||||||
|
} else {
|
||||||
|
previewButton.removeAttribute("data-loading");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const buildPreviewRequest = () => {
|
||||||
|
const language = getSelectedLanguage();
|
||||||
|
const speed = getSelectedSpeed();
|
||||||
|
const basePayload = {
|
||||||
|
language,
|
||||||
|
speed,
|
||||||
|
max_seconds: 8,
|
||||||
|
text: resolveSampleText(language),
|
||||||
|
};
|
||||||
|
|
||||||
|
const profileValue = profileSelect?.value || "__standard";
|
||||||
|
|
||||||
|
if (profileValue && profileValue !== "__standard") {
|
||||||
|
if (profileValue === "__formula") {
|
||||||
|
const formulaValue = (formulaInput?.value || "").trim();
|
||||||
|
if (!formulaValue) {
|
||||||
|
return { error: "Enter a custom voice formula to preview." };
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
endpoint: "/api/voice-profiles/preview",
|
||||||
|
payload: { ...basePayload, formula: formulaValue },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
endpoint: "/api/voice-profiles/preview",
|
||||||
|
payload: { ...basePayload, profile: profileValue },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const selectedVoice = (voiceSelect?.value || voiceSelect?.dataset.default || "").trim();
|
||||||
|
if (!selectedVoice) {
|
||||||
|
return { error: "Select a narrator voice to preview." };
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
endpoint: "/api/speaker-preview",
|
||||||
|
payload: { ...basePayload, voice: selectedVoice },
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
const resetPreview = () => {
|
||||||
|
cancelPreviewRequest();
|
||||||
|
stopPreviewAudio();
|
||||||
|
setPreviewStatus("", "");
|
||||||
|
};
|
||||||
|
|
||||||
|
if (previewAudio) {
|
||||||
|
previewAudio.addEventListener("ended", () => {
|
||||||
|
setPreviewStatus("Preview finished", "info");
|
||||||
|
});
|
||||||
|
previewAudio.addEventListener("pause", () => {
|
||||||
|
if (suppressPauseStatus || previewAudio.ended || previewAudio.currentTime === 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setPreviewStatus("Preview paused", "info");
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleVoicePreview = async () => {
|
||||||
|
if (!previewButton) return;
|
||||||
|
const request = buildPreviewRequest();
|
||||||
|
if (!request) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (request.error) {
|
||||||
|
setPreviewStatus(request.error, "error");
|
||||||
|
cancelPreviewRequest();
|
||||||
|
stopPreviewAudio();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
cancelPreviewRequest();
|
||||||
|
stopPreviewAudio();
|
||||||
|
previewAbortController = new AbortController();
|
||||||
|
setPreviewLoading(true);
|
||||||
|
setPreviewStatus("Generating preview…", "loading");
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch(request.endpoint, {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify(request.payload),
|
||||||
|
signal: previewAbortController.signal,
|
||||||
|
});
|
||||||
|
if (!response.ok) {
|
||||||
|
const message = await response.text();
|
||||||
|
throw new Error(message || `Preview failed (status ${response.status})`);
|
||||||
|
}
|
||||||
|
const blob = await response.blob();
|
||||||
|
previewObjectUrl = URL.createObjectURL(blob);
|
||||||
|
if (previewAudio) {
|
||||||
|
previewAudio.src = previewObjectUrl;
|
||||||
|
previewAudio.hidden = false;
|
||||||
|
try {
|
||||||
|
await previewAudio.play();
|
||||||
|
setPreviewStatus("Preview playing", "success");
|
||||||
|
} catch (error) {
|
||||||
|
setPreviewStatus("Preview ready. Press play to listen.", "success");
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
setPreviewStatus("Preview ready.", "success");
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
if (error.name === "AbortError") {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
console.error("Voice preview failed", error);
|
||||||
|
setPreviewStatus(error.message || "Preview failed", "error");
|
||||||
|
stopPreviewAudio();
|
||||||
|
} finally {
|
||||||
|
setPreviewLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (previewButton) {
|
||||||
|
previewButton.addEventListener("click", (event) => {
|
||||||
|
event.preventDefault();
|
||||||
|
handleVoicePreview();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
[voiceSelect, profileSelect, formulaInput, languageSelect, speedInput].forEach((input) => {
|
||||||
|
if (!input) return;
|
||||||
|
const eventName = input === formulaInput ? "input" : "change";
|
||||||
|
input.addEventListener(eventName, () => {
|
||||||
|
resetPreview();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
const hydrateDefaultVoice = () => {
|
const hydrateDefaultVoice = () => {
|
||||||
if (!voiceSelect) return;
|
if (!voiceSelect) return;
|
||||||
const defaultVoice = voiceSelect.dataset.default;
|
const defaultVoice = voiceSelect.dataset.default;
|
||||||
@@ -232,6 +461,11 @@ const initDashboard = () => {
|
|||||||
} else {
|
} else {
|
||||||
hydrateDefaultVoice();
|
hydrateDefaultVoice();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
window.addEventListener("beforeunload", () => {
|
||||||
|
cancelPreviewRequest();
|
||||||
|
stopPreviewAudio();
|
||||||
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
if (document.readyState === "loading") {
|
if (document.readyState === "loading") {
|
||||||
|
|||||||
@@ -609,12 +609,122 @@ body {
|
|||||||
outline-offset: 2px;
|
outline-offset: 2px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.upload-form__sections {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-section {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 1.25rem;
|
||||||
|
padding: 1.5rem;
|
||||||
|
border-radius: 24px;
|
||||||
|
border: 1px solid rgba(148, 163, 184, 0.18);
|
||||||
|
background: rgba(15, 23, 42, 0.45);
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-section__title {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 1.1rem;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--text);
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-section__layout {
|
||||||
|
display: grid;
|
||||||
|
gap: 1.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-section__layout--split {
|
||||||
|
grid-template-columns: repeat(auto-fit, minmax(280px, 1fr));
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-section__group {
|
||||||
|
display: grid;
|
||||||
|
gap: 1.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.field-grid {
|
||||||
|
display: grid;
|
||||||
|
gap: 1.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.field-grid--two {
|
||||||
|
grid-template-columns: repeat(auto-fit, minmax(240px, 1fr));
|
||||||
|
}
|
||||||
|
|
||||||
|
.field-grid--two > .field {
|
||||||
|
max-width: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.field-grid--two input[type="text"],
|
||||||
|
.field-grid--two input[type="number"],
|
||||||
|
.field-grid--two input[type="file"],
|
||||||
|
.field-grid--two input[type="range"],
|
||||||
|
.field-grid--two select,
|
||||||
|
.field-grid--two textarea {
|
||||||
|
max-width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.field--stack {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.65rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.field--with-action .field__label-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.field__label-row .field__label,
|
||||||
|
.field__label-row label {
|
||||||
|
font-weight: 500;
|
||||||
|
color: var(--muted);
|
||||||
|
font-size: 0.9rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.field__status {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
color: var(--muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.field__status[data-state="loading"] {
|
||||||
|
color: var(--accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.field__status[data-state="error"] {
|
||||||
|
color: var(--danger);
|
||||||
|
}
|
||||||
|
|
||||||
|
.field__status[data-state="success"] {
|
||||||
|
color: var(--success);
|
||||||
|
}
|
||||||
|
|
||||||
|
.voice-preview__audio {
|
||||||
|
width: min(100%, 360px);
|
||||||
|
margin-top: 0.75rem;
|
||||||
|
border-radius: 14px;
|
||||||
|
background: rgba(15, 23, 42, 0.4);
|
||||||
|
}
|
||||||
|
|
||||||
.field {
|
.field {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: 0.4rem;
|
gap: 0.4rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.field__caption {
|
||||||
|
font-weight: 500;
|
||||||
|
color: var(--muted);
|
||||||
|
font-size: 0.9rem;
|
||||||
|
}
|
||||||
|
|
||||||
.field[hidden],
|
.field[hidden],
|
||||||
.field[data-state="hidden"] {
|
.field[data-state="hidden"] {
|
||||||
display: none !important;
|
display: none !important;
|
||||||
@@ -1418,9 +1528,45 @@ body {
|
|||||||
gap: 2rem;
|
gap: 2rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.prepare-step {
|
||||||
|
display: grid;
|
||||||
|
gap: 2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.prepare-step__header {
|
||||||
|
display: grid;
|
||||||
|
gap: 0.5rem;
|
||||||
|
margin-bottom: 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
.chapter-grid {
|
.chapter-grid {
|
||||||
display: grid;
|
display: grid;
|
||||||
gap: 1.25rem;
|
gap: 1.25rem;
|
||||||
|
margin-top: 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.prepare-options {
|
||||||
|
display: grid;
|
||||||
|
gap: 1.5rem;
|
||||||
|
margin-top: 1.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (min-width: 880px) {
|
||||||
|
.prepare-options {
|
||||||
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
|
}
|
||||||
|
.prepare-options .field {
|
||||||
|
max-width: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.prepare-step__actions {
|
||||||
|
margin-top: 0;
|
||||||
|
padding-top: 1.5rem;
|
||||||
|
padding-bottom: 0.5rem;
|
||||||
|
display: flex;
|
||||||
|
justify-content: flex-end;
|
||||||
|
border-top: 1px solid rgba(148, 163, 184, 0.22);
|
||||||
}
|
}
|
||||||
|
|
||||||
.chapter-card {
|
.chapter-card {
|
||||||
|
|||||||
@@ -20,7 +20,7 @@
|
|||||||
<a href="{{ url_for('web.index') }}" class="btn{% if endpoint == 'web.index' %} is-active{% endif %}">Dashboard</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.voice_profiles_page') }}" class="btn{% if endpoint == 'web.voice_profiles_page' %} is-active{% endif %}">Voice Mixer</a>
|
||||||
<a href="{{ url_for('web.speaker_configs_page') }}" class="btn{% if endpoint == 'web.speaker_configs_page' %} is-active{% endif %}">Speakers</a>
|
<a href="{{ url_for('web.speaker_configs_page') }}" class="btn{% if endpoint == 'web.speaker_configs_page' %} is-active{% endif %}">Speakers</a>
|
||||||
<a href="{{ url_for('web.index', _anchor='queue') }}" class="btn{% if endpoint in ['web.queue_page', 'web.job_detail'] %} is-active{% endif %}">Queue</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>
|
<a href="{{ url_for('web.settings_page') }}" class="btn{% if endpoint == 'web.settings_page' %} is-active{% endif %}">Settings</a>
|
||||||
</nav>
|
</nav>
|
||||||
</header>
|
</header>
|
||||||
|
|||||||
@@ -22,6 +22,7 @@
|
|||||||
<p class="card__subtitle">Kick off a fresh conversion with your manuscript or pasted text. You can fine-tune chapters and speakers in the next steps.</p>
|
<p class="card__subtitle">Kick off a fresh conversion with your manuscript or pasted text. You can fine-tune chapters and speakers in the next steps.</p>
|
||||||
<div class="card__actions">
|
<div class="card__actions">
|
||||||
<button type="button" class="button" data-role="open-upload-modal">Open upload & settings</button>
|
<button type="button" class="button" data-role="open-upload-modal">Open upload & settings</button>
|
||||||
|
<a class="button button--ghost" href="{{ url_for('web.queue_page') }}">View queue</a>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
@@ -37,10 +38,13 @@
|
|||||||
<button type="button" class="icon-button" data-role="upload-modal-close" aria-label="Close upload settings">✕</button>
|
<button type="button" class="icon-button" data-role="upload-modal-close" aria-label="Close upload settings">✕</button>
|
||||||
</header>
|
</header>
|
||||||
<form action="{{ url_for('web.enqueue_job') }}" method="post" enctype="multipart/form-data" class="upload-form">
|
<form action="{{ url_for('web.enqueue_job') }}" method="post" enctype="multipart/form-data" class="upload-form">
|
||||||
<div class="modal__body">
|
<div class="modal__body upload-form__sections">
|
||||||
<div class="grid grid--two form-grid">
|
<section class="form-section">
|
||||||
|
<h3 class="form-section__title">Manuscript & subtitles</h3>
|
||||||
|
<div class="form-section__layout">
|
||||||
|
<div class="field-grid field-grid--two">
|
||||||
<div class="field field--file">
|
<div class="field field--file">
|
||||||
<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">
|
||||||
</div>
|
</div>
|
||||||
<div class="field">
|
<div class="field">
|
||||||
@@ -51,11 +55,41 @@
|
|||||||
{% endfor %}
|
{% endfor %}
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="field-grid field-grid--two">
|
||||||
<div class="field">
|
<div class="field">
|
||||||
<label for="voice_profile">Voice Profile</label>
|
<label for="subtitle_mode">Subtitle mode</label>
|
||||||
|
<select id="subtitle_mode" name="subtitle_mode">
|
||||||
|
<option value="Disabled">Disabled</option>
|
||||||
|
<option value="Sentence">Sentence</option>
|
||||||
|
<option value="Sentence + Comma">Sentence + Comma</option>
|
||||||
|
<option value="Sentence + Highlighting">Sentence + Highlighting</option>
|
||||||
|
{% for i in range(1, 11) %}
|
||||||
|
<option value="{{ i }} {% if i == 1 %}word{% else %}words{% endif %}">{{ i }} {% if i == 1 %}word{% else %}words{% endif %}</option>
|
||||||
|
{% endfor %}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="field field--stack">
|
||||||
|
<span class="field__caption">Additional outputs</span>
|
||||||
|
<label class="toggle-pill">
|
||||||
|
<input type="checkbox" name="generate_epub3" value="true" {% if settings.generate_epub3 %}checked{% endif %}>
|
||||||
|
<span>Generate EPUB 3 (experimental)</span>
|
||||||
|
</label>
|
||||||
|
<p class="hint">Creates a synchronized EPUB alongside audio output.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="form-section">
|
||||||
|
<h3 class="form-section__title">Narrator defaults</h3>
|
||||||
|
<div class="form-section__layout form-section__layout--split">
|
||||||
|
<div class="form-section__group">
|
||||||
|
<div class="field">
|
||||||
|
<label for="voice_profile">Voice profile</label>
|
||||||
<select id="voice_profile" name="voice_profile" data-role="voice-profile">
|
<select id="voice_profile" name="voice_profile" data-role="voice-profile">
|
||||||
<option value="__standard" {% if not options.voice_profile_options %}selected{% endif %}>Standard Voice</option>
|
<option value="__standard" {% if not options.voice_profile_options %}selected{% endif %}>Standard voice</option>
|
||||||
<option value="__formula">Custom Voice Formula</option>
|
<option value="__formula">Custom voice formula</option>
|
||||||
{% if options.voice_profile_options %}
|
{% if options.voice_profile_options %}
|
||||||
<optgroup label="Saved mixes">
|
<optgroup label="Saved mixes">
|
||||||
{% for profile in options.voice_profile_options %}
|
{% for profile in options.voice_profile_options %}
|
||||||
@@ -74,10 +108,32 @@
|
|||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
<div class="field" data-conditional="formula" data-role="formula-field" hidden aria-hidden="true">
|
<div class="field" data-conditional="formula" data-role="formula-field" hidden aria-hidden="true">
|
||||||
<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" data-role="voice-formula">
|
<input type="text" id="voice_formula" name="voice_formula" placeholder="af_nova*0.4+am_liam*0.6" data-role="voice-formula">
|
||||||
</div>
|
</div>
|
||||||
<div class="field">
|
</div>
|
||||||
|
<div class="form-section__group">
|
||||||
|
<div class="field field--slider">
|
||||||
|
<label for="speed">Speed <span class="tag" id="speed_value">1.00×</span></label>
|
||||||
|
<input type="range" id="speed" name="speed" min="0.6" max="1.4" step="0.05" value="1.0" oninput="document.getElementById('speed_value').textContent = Number.parseFloat(this.value).toFixed(2) + '×';">
|
||||||
|
</div>
|
||||||
|
<div class="field field--with-action field--preview" data-role="voice-preview">
|
||||||
|
<div class="field__label-row">
|
||||||
|
<span class="field__label">Preview</span>
|
||||||
|
<button type="button" class="button button--ghost button--small" data-role="voice-preview-button">Preview voice</button>
|
||||||
|
</div>
|
||||||
|
<p class="hint field__status" data-role="voice-preview-status" aria-live="polite" hidden></p>
|
||||||
|
<audio class="voice-preview__audio" data-role="voice-preview-audio" controls preload="none" hidden></audio>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="form-section">
|
||||||
|
<h3 class="form-section__title">Speakers & casting</h3>
|
||||||
|
<div class="form-section__layout">
|
||||||
|
<div class="field-grid field-grid--two">
|
||||||
|
<div class="field field--stack">
|
||||||
<label for="speaker_config">Speaker preset</label>
|
<label for="speaker_config">Speaker preset</label>
|
||||||
<select id="speaker_config" name="speaker_config">
|
<select id="speaker_config" name="speaker_config">
|
||||||
<option value="">None</option>
|
<option value="">None</option>
|
||||||
@@ -85,32 +141,7 @@
|
|||||||
<option value="{{ config.name }}">{{ config.name }} · {{ config.speakers|length }} speaker{% if config.speakers|length != 1 %}s{% endif %}</option>
|
<option value="{{ config.name }}">{{ config.name }} · {{ config.speakers|length }} speaker{% if config.speakers|length != 1 %}s{% endif %}</option>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
</select>
|
</select>
|
||||||
<p class="hint">Optional: reuse a saved roster to keep character voices consistent.</p>
|
<p class="hint">Reuse a saved roster to keep character voices consistent.</p>
|
||||||
</div>
|
|
||||||
<div class="field">
|
|
||||||
<label for="speed">Speed <span class="tag" id="speed_value">1.00×</span></label>
|
|
||||||
<input type="range" id="speed" name="speed" min="0.6" max="1.4" step="0.05" value="1.0" oninput="document.getElementById('speed_value').textContent = Number.parseFloat(this.value).toFixed(2) + '×';">
|
|
||||||
</div>
|
|
||||||
<div class="field">
|
|
||||||
<label for="subtitle_mode">Subtitle mode</label>
|
|
||||||
<select id="subtitle_mode" name="subtitle_mode">
|
|
||||||
<option value="Disabled">Disabled</option>
|
|
||||||
<option value="Sentence">Sentence</option>
|
|
||||||
<option value="Sentence + Comma">Sentence + Comma</option>
|
|
||||||
<option value="Sentence + Highlighting">Sentence + Highlighting</option>
|
|
||||||
{% for i in range(1, 11) %}
|
|
||||||
<option value="{{ i }} {% if i == 1 %}word{% else %}words{% endif %}">{{ i }} {% if i == 1 %}word{% else %}words{% endif %}</option>
|
|
||||||
{% endfor %}
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
<div class="field">
|
|
||||||
<label for="chunk_level">Chunk granularity</label>
|
|
||||||
<select id="chunk_level" name="chunk_level">
|
|
||||||
{% for option in options.chunk_levels %}
|
|
||||||
<option value="{{ option.value }}" {% if settings.chunk_level == option.value %}selected{% endif %}>{{ option.label }}</option>
|
|
||||||
{% endfor %}
|
|
||||||
</select>
|
|
||||||
<p class="hint">Controls how chapters are split into TTS-ready chunks.</p>
|
|
||||||
</div>
|
</div>
|
||||||
<div class="field">
|
<div class="field">
|
||||||
<label for="speaker_mode">Speaker mode</label>
|
<label for="speaker_mode">Speaker mode</label>
|
||||||
@@ -120,14 +151,30 @@
|
|||||||
{% endfor %}
|
{% endfor %}
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="field-grid field-grid--two">
|
||||||
<div class="field">
|
<div class="field">
|
||||||
<label class="toggle-pill">
|
<label for="chunk_level">Chunk granularity</label>
|
||||||
<input type="checkbox" name="generate_epub3" value="true" {% if settings.generate_epub3 %}checked{% endif %}>
|
<select id="chunk_level" name="chunk_level">
|
||||||
<span>Generate EPUB 3 (experimental)</span>
|
{% for option in options.chunk_levels %}
|
||||||
</label>
|
<option value="{{ option.value }}" {% if settings.chunk_level == option.value %}selected{% endif %}>{{ option.label }}</option>
|
||||||
<p class="hint">Creates a synchronized EPUB alongside audio output.</p>
|
{% endfor %}
|
||||||
|
</select>
|
||||||
|
<p class="hint">Paragraphs work well for long-form narration; sentences give finer subtitle sync.</p>
|
||||||
|
</div>
|
||||||
|
<div class="field">
|
||||||
|
<label for="speaker_analysis_threshold">Speaker analysis minimum mentions</label>
|
||||||
|
<input type="number" min="1" max="25" id="speaker_analysis_threshold" name="speaker_analysis_threshold" value="{{ settings.speaker_analysis_threshold }}">
|
||||||
|
<p class="hint">Speakers appearing less often fall back to the narrator voice.</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="field">
|
||||||
|
<label for="chapter_intro_delay">Pause after chapter titles (seconds)</label>
|
||||||
|
<input type="number" step="0.1" min="0" id="chapter_intro_delay" name="chapter_intro_delay" value="{{ '%.2f'|format(settings.chapter_intro_delay) }}">
|
||||||
|
<p class="hint">Set to 0 to disable the pause after speaking each chapter title.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
</div>
|
</div>
|
||||||
<footer class="modal__footer">
|
<footer class="modal__footer">
|
||||||
<button type="button" class="button button--ghost" data-role="upload-modal-close">Cancel</button>
|
<button type="button" class="button button--ghost" data-role="upload-modal-close">Cancel</button>
|
||||||
@@ -157,18 +204,10 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<section class="card" id="queue">
|
|
||||||
<div id="jobs-panel"
|
|
||||||
hx-get="{{ url_for('web.jobs_partial') }}"
|
|
||||||
hx-trigger="load, every 3s"
|
|
||||||
hx-target="#jobs-panel"
|
|
||||||
hx-swap="innerHTML">
|
|
||||||
{{ jobs_panel|safe }}
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|
||||||
{% block scripts %}
|
{% block scripts %}
|
||||||
{{ super() }}
|
{{ super() }}
|
||||||
|
<script id="voice-sample-texts" type="application/json">{{ options.sample_voice_texts | tojson }}</script>
|
||||||
<script type="module" src="{{ url_for('static', filename='dashboard.js') }}"></script>
|
<script type="module" src="{{ url_for('static', filename='dashboard.js') }}"></script>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|||||||
@@ -13,76 +13,21 @@
|
|||||||
|
|
||||||
<form action="{{ url_for('web.settings_page') }}" method="post" class="settings__form">
|
<form action="{{ url_for('web.settings_page') }}" method="post" class="settings__form">
|
||||||
<fieldset class="settings__section">
|
<fieldset class="settings__section">
|
||||||
<legend>Output Defaults</legend>
|
<legend>Narration Defaults</legend>
|
||||||
<div class="field">
|
<div class="field">
|
||||||
<label for="output_format">Audio Format</label>
|
<label for="default_voice">Narrator Voice</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="default_voice">Default Voice</label>
|
|
||||||
<select id="default_voice" name="default_voice">
|
<select id="default_voice" name="default_voice">
|
||||||
{% for voice in options.voices %}
|
{% for voice in options.voices %}
|
||||||
<option value="{{ voice }}" {% if settings.default_voice == voice %}selected{% endif %}>{{ voice }}</option>
|
<option value="{{ voice }}" {% if settings.default_voice == voice %}selected{% endif %}>{{ voice }}</option>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
</select>
|
</select>
|
||||||
<p class="hint">Used when “Standard voice” is selected on the dashboard.</p>
|
<p class="hint">Used whenever “Standard voice” is selected for a new job.</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="field">
|
<div class="field">
|
||||||
<label for="save_mode">Save Location</label>
|
<label for="speaker_mode_default">Speaker Mode</label>
|
||||||
<select id="save_mode" name="save_mode">
|
<select id="speaker_mode_default" name="speaker_mode">
|
||||||
{% for location in save_locations %}
|
{% for option in options.speaker_modes %}
|
||||||
<option value="{{ location.value }}" {% if settings.save_mode == location.value %}selected{% endif %}>{{ location.label }}</option>
|
<option value="{{ option.value }}" {% if settings.speaker_mode == option.value %}selected{% endif %}>{{ option.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--choices">
|
|
||||||
<label class="toggle-pill">
|
|
||||||
<input type="checkbox" name="replace_single_newlines" value="true" {% if settings.replace_single_newlines %}checked{% endif %}>
|
|
||||||
<span>Replace Single Newlines</span>
|
|
||||||
</label>
|
|
||||||
<label class="toggle-pill">
|
|
||||||
<input type="checkbox" name="use_gpu" value="true" {% if settings.use_gpu %}checked{% endif %}>
|
|
||||||
<span>Use GPU (When Available)</span>
|
|
||||||
</label>
|
|
||||||
<label class="toggle-pill">
|
|
||||||
<input type="checkbox" name="save_chapters_separately" value="true" {% if settings.save_chapters_separately %}checked{% endif %}>
|
|
||||||
<span>Save Each Chapter Separately</span>
|
|
||||||
</label>
|
|
||||||
<label class="toggle-pill">
|
|
||||||
<input type="checkbox" name="merge_chapters_at_end" value="true" {% if settings.merge_chapters_at_end %}checked{% endif %}>
|
|
||||||
<span>Also Create Merged Audiobook</span>
|
|
||||||
</label>
|
|
||||||
<label class="toggle-pill">
|
|
||||||
<input type="checkbox" name="save_as_project" value="true" {% if settings.save_as_project %}checked{% endif %}>
|
|
||||||
<span>Save as Project With Metadata</span>
|
|
||||||
</label>
|
|
||||||
<label class="toggle-pill">
|
|
||||||
<input type="checkbox" name="generate_epub3" value="true" {% if settings.generate_epub3 %}checked{% endif %}>
|
|
||||||
<span>Generate EPUB 3 (experimental)</span>
|
|
||||||
</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 %}
|
{% endfor %}
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
@@ -95,12 +40,14 @@
|
|||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
<div class="field">
|
<div class="field">
|
||||||
<label for="speaker_mode_default">Speaker Mode</label>
|
<label for="speaker_analysis_threshold">Speaker Analysis Minimum Mentions</label>
|
||||||
<select id="speaker_mode_default" name="speaker_mode">
|
<input type="number" min="1" max="25" id="speaker_analysis_threshold" name="speaker_analysis_threshold" value="{{ settings.speaker_analysis_threshold }}">
|
||||||
{% for option in options.speaker_modes %}
|
<p class="hint">Speakers detected fewer times fall back to the narrator voice.</p>
|
||||||
<option value="{{ option.value }}" {% if settings.speaker_mode == option.value %}selected{% endif %}>{{ option.label }}</option>
|
</div>
|
||||||
{% endfor %}
|
<div class="field">
|
||||||
</select>
|
<label for="speaker_pronunciation_sentence">Speaker Pronunciation Preview</label>
|
||||||
|
<input type="text" id="speaker_pronunciation_sentence" name="speaker_pronunciation_sentence" value="{{ settings.speaker_pronunciation_sentence }}" placeholder="This is {{ '{{name}}' }} speaking.">
|
||||||
|
<p class="hint">Include <code>{{ '{{name}}' }}</code> where the speaker name should be inserted.</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="field">
|
<div class="field">
|
||||||
<label for="speaker_random_languages">Randomizer Languages</label>
|
<label for="speaker_random_languages">Randomizer Languages</label>
|
||||||
@@ -110,17 +57,50 @@
|
|||||||
<option value="{{ code }}" {% if code in selected_languages %}selected{% endif %}>{{ label }} ({{ code|upper }})</option>
|
<option value="{{ code }}" {% if code in selected_languages %}selected{% endif %}>{{ label }} ({{ code|upper }})</option>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
</select>
|
</select>
|
||||||
<p class="hint">Limits random voice selection for speakers marked as random. Leave unused to allow any language.</p>
|
<p class="hint">Limits random voice selection for speakers marked as random. Leave empty to allow any language.</p>
|
||||||
|
</div>
|
||||||
|
</fieldset>
|
||||||
|
|
||||||
|
<fieldset class="settings__section">
|
||||||
|
<legend>Audio & Delivery</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>
|
||||||
<div class="field">
|
<div class="field">
|
||||||
<label for="speaker_analysis_threshold">Speaker Analysis Minimum Mentions</label>
|
<label for="save_mode">Save Location</label>
|
||||||
<input type="number" min="1" max="25" id="speaker_analysis_threshold" name="speaker_analysis_threshold" value="{{ settings.speaker_analysis_threshold }}">
|
<select id="save_mode" name="save_mode">
|
||||||
<p class="hint">Speakers detected fewer times than this fallback to the narrator voice.</p>
|
{% 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>
|
</div>
|
||||||
<div class="field">
|
<div class="field">
|
||||||
<label for="speaker_pronunciation_sentence">Speaker Pronunciation Preview</label>
|
<label for="separate_chapters_format">Separate Chapter Format</label>
|
||||||
<input type="text" id="speaker_pronunciation_sentence" name="speaker_pronunciation_sentence" value="{{ settings.speaker_pronunciation_sentence }}" placeholder="This is {{ '{{name}}' }} speaking.">
|
<select id="separate_chapters_format" name="separate_chapters_format">
|
||||||
<p class="hint">Sentence template used when previewing name pronunciation. Include <code>{{ '{{name}}' }}</code> where the speaker name should be inserted.</p>
|
{% 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 field--choices">
|
||||||
|
<label class="toggle-pill">
|
||||||
|
<input type="checkbox" name="save_chapters_separately" value="true" {% if settings.save_chapters_separately %}checked{% endif %}>
|
||||||
|
<span>Save Each Chapter Separately</span>
|
||||||
|
</label>
|
||||||
|
<label class="toggle-pill">
|
||||||
|
<input type="checkbox" name="merge_chapters_at_end" value="true" {% if settings.merge_chapters_at_end %}checked{% endif %}>
|
||||||
|
<span>Also Create Merged Audiobook</span>
|
||||||
|
</label>
|
||||||
|
<label class="toggle-pill">
|
||||||
|
<input type="checkbox" name="save_as_project" value="true" {% if settings.save_as_project %}checked{% endif %}>
|
||||||
|
<span>Save as Project With Metadata</span>
|
||||||
|
</label>
|
||||||
</div>
|
</div>
|
||||||
<div class="field">
|
<div class="field">
|
||||||
<label for="silence_between_chapters">Silence Between Chapters (Seconds)</label>
|
<label for="silence_between_chapters">Silence Between Chapters (Seconds)</label>
|
||||||
@@ -131,10 +111,42 @@
|
|||||||
<input type="number" step="0.1" min="0" id="chapter_intro_delay" name="chapter_intro_delay" value="{{ '%.2f'|format(settings.chapter_intro_delay) }}">
|
<input type="number" step="0.1" min="0" id="chapter_intro_delay" name="chapter_intro_delay" value="{{ '%.2f'|format(settings.chapter_intro_delay) }}">
|
||||||
<p class="hint">Inserted between the spoken chapter title and the chapter content. Set to 0 to disable.</p>
|
<p class="hint">Inserted between the spoken chapter title and the chapter content. Set to 0 to disable.</p>
|
||||||
</div>
|
</div>
|
||||||
|
</fieldset>
|
||||||
|
|
||||||
|
<fieldset class="settings__section">
|
||||||
|
<legend>Subtitles & Text</legend>
|
||||||
|
<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">
|
<div class="field">
|
||||||
<label for="max_subtitle_words">Max Words Per Subtitle Entry</label>
|
<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 }}">
|
<input type="number" min="1" max="200" id="max_subtitle_words" name="max_subtitle_words" value="{{ settings.max_subtitle_words }}">
|
||||||
</div>
|
</div>
|
||||||
|
<div class="field field--choices">
|
||||||
|
<label class="toggle-pill">
|
||||||
|
<input type="checkbox" name="replace_single_newlines" value="true" {% if settings.replace_single_newlines %}checked{% endif %}>
|
||||||
|
<span>Replace Single Newlines</span>
|
||||||
|
</label>
|
||||||
|
<label class="toggle-pill">
|
||||||
|
<input type="checkbox" name="generate_epub3" value="true" {% if settings.generate_epub3 %}checked{% endif %}>
|
||||||
|
<span>Generate EPUB 3 (experimental)</span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</fieldset>
|
||||||
|
|
||||||
|
<fieldset class="settings__section">
|
||||||
|
<legend>Performance</legend>
|
||||||
|
<div class="field field--choices">
|
||||||
|
<label class="toggle-pill">
|
||||||
|
<input type="checkbox" name="use_gpu" value="true" {% if settings.use_gpu %}checked{% endif %}>
|
||||||
|
<span>Use GPU (when available)</span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
</fieldset>
|
</fieldset>
|
||||||
|
|
||||||
<div class="settings__actions">
|
<div class="settings__actions">
|
||||||
|
|||||||
+2
-1
@@ -30,7 +30,8 @@ dependencies = [
|
|||||||
"Markdown>=3.9",
|
"Markdown>=3.9",
|
||||||
"Flask>=3.0.3",
|
"Flask>=3.0.3",
|
||||||
"numpy>=1.24.0",
|
"numpy>=1.24.0",
|
||||||
"gpustat>=1.1.1"
|
"gpustat>=1.1.1",
|
||||||
|
"num2words>=0.5.13"
|
||||||
]
|
]
|
||||||
|
|
||||||
classifiers = [
|
classifiers = [
|
||||||
|
|||||||
@@ -63,3 +63,8 @@ def test_normalize_roman_titles_preserves_separators() -> None:
|
|||||||
assert normalized[0] == " 4. The Trial"
|
assert normalized[0] == " 4. The Trial"
|
||||||
assert normalized[1] == "5 - The Verdict"
|
assert normalized[1] == "5 - The Verdict"
|
||||||
assert normalized[2].startswith("6\nAftermath")
|
assert normalized[2].startswith("6\nAftermath")
|
||||||
|
|
||||||
|
|
||||||
|
def test_grouped_numbers_are_spelled_out() -> None:
|
||||||
|
normalized = _normalize_for_pipeline("The vault holds 35,000 credits")
|
||||||
|
assert "thirty-five thousand" in normalized.lower()
|
||||||
|
|||||||
Reference in New Issue
Block a user