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:
JB
2025-10-10 16:12:51 -07:00
parent f35b35c7a9
commit be37f03109
9 changed files with 673 additions and 190 deletions
+44
View File
@@ -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
+4 -2
View File
@@ -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"])
+234
View File
@@ -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") {
+146
View File
@@ -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 {
+1 -1
View File
@@ -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>
+87 -48
View File
@@ -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 &amp; settings</button> <button type="button" class="button" data-role="open-upload-modal">Open upload &amp; 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 &amp; 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 &amp; 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 %}
+87 -75
View File
@@ -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 &amp; 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 &amp; 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
View File
@@ -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 = [
+5
View File
@@ -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()