Merge pull request #1217 from debpalash/feat/audiobook-cast-authoring

feat(audiobook): cast/voice mapping (fixes multi-voice) + markup toolbar + stats + validation (#1217)
This commit is contained in:
Palash Debnath
2026-07-20 14:07:22 -07:00
committed by GitHub
41 changed files with 1503 additions and 59 deletions
+1
View File
@@ -28,6 +28,7 @@ The bundled TTS model package (`pyproject.toml`) is versioned independently.
### Added
- Audiobook tab: a Cast panel maps each `[voice:NAME]` in the script to a profile so multi-voice renders correctly (it previously fell back to a single voice), plus a markup insert toolbar, live stats (chapters · words · est. runtime), and pre-flight validation for unknown voices and empty chapters (#1217)
- Audiobook tab: a **Stop** button that truly cancels a running generation (not just the UI) and live per-chapter progress — a bar, elapsed + ETA, and each chapter's status (rendering / done / cached / failed); finished chapters stay cached so Create again resumes. `Cmd/Ctrl+Enter` starts a render (#1216)
- Settings → Permissions + wizard System Check: live mic/Accessibility grant state, per-OS guidance, Open Settings deep-links; dictation pre-flights the mic grant (#1175)
- `parakeet-mlx` engine: Parakeet TDT v3 on Apple Silicon — 25 EU languages, word timestamps, ~2 GB, opt-in from Settings → Models, never auto-downloads (#1175)
+94 -9
View File
@@ -226,6 +226,9 @@ class AudiobookRequest(ExpressiveMixin):
metadata: dict | None = None
# Optional pronunciation lexicon {word: respelling} applied before synthesis.
lexicon: dict | None = None
# Optional cast map {[voice:NAME] → profile id} for multi-voice books (#1217).
# Absent/empty reproduces today's exact render + cache keys.
voice_map: dict[str, str] | None = None
def _resolve_voice(profile_id: str | None) -> dict:
@@ -268,6 +271,55 @@ def _resolve_voice(profile_id: str | None) -> dict:
return out
def _voice_profile_exists(profile_id: str | None) -> bool:
"""True iff ``profile_id`` names a real voice profile (#1217).
Used to distinguish an exact profile id (a UUID someone passed as a span
voice) from a bare ``[voice:NAME]`` name that has no cast mapping the
former resolves as-is, the latter falls back to the book default instead of
silently missing and dropping to the engine default."""
if not profile_id:
return False
from core.db import db_conn
with db_conn() as conn:
row = conn.execute(
"SELECT 1 FROM voice_profiles WHERE id=? LIMIT 1", (profile_id,)
).fetchone()
return row is not None
def _map_span_voice(
voice_id: str | None, default_voice: str | None, voice_map: dict | None
) -> str | None:
"""Translate a span's voice token to the profile id to synthesize with (#1217).
A span's ``voice_id`` is whatever the longform parser captured from
``[voice:NAME]`` the raw human NAME, never a profile id. Resolve it:
* ``None``/empty (a run with no ``[voice:]``) ``default_voice``.
* a NAME present in ``voice_map`` its mapped profile id. THIS is the
multi-voice cast fix: before it, a NAME was handed straight to
``_resolve_voice`` as if it were a profile id, always missed (profile
ids are UUIDs), and every ``[voice:]`` silently rendered in the engine
default so ``[voice:Mara]``/``[voice:Cole]`` sounded identical.
* an unmapped token that IS a real profile id (someone passed an exact id)
itself, unchanged (exact-id back-compat, e.g. Stories spans).
* an unmapped token that is NOT a real profile id (a NAME with no cast
entry) ``default_voice`` (fixes the silent-default bug for unmapped
names: no longer treated as a literal id).
"""
if not voice_id:
return default_voice
if voice_map:
mapped = voice_map.get(voice_id)
if mapped:
return mapped
if _voice_profile_exists(voice_id):
return voice_id
return default_voice
def _resolve_default_language(language: str | None, default_voice: str | None) -> str | None:
"""Pick the language to thread into the longform synth callable.
@@ -411,6 +463,7 @@ def _build_synth(
default_voice: str | None,
language: str | None = None,
opts: ExpressiveOptions | None = None,
voice_map: dict | None = None,
) -> dict:
"""Describe how to synthesize for the active TTS engine.
@@ -432,9 +485,16 @@ def _build_synth(
opts = opts or ExpressiveOptions()
cache: dict = {}
token_cache: dict = {}
def resolve(voice_id):
key = voice_id or default_voice
# Translate the span token ([voice:NAME] / exact id / None) to a profile
# id first (#1217) — the cast fix lives here, not in the parser, so the
# parser stays a pure text→plan and exact ids keep working. Cache the
# translation so a book of hundreds of same-name spans does one DB check.
if voice_id not in token_cache:
token_cache[voice_id] = _map_span_voice(voice_id, default_voice, voice_map)
key = token_cache[voice_id]
if key not in cache:
cache[key] = _resolve_voice(key)
return cache[key]
@@ -466,6 +526,7 @@ async def _prepare_synth(
default_voice: str | None,
language: str | None = None,
opts: ExpressiveOptions | None = None,
voice_map: dict | None = None,
):
"""Resolve :func:`_build_synth` into ``(synth, sample_rate, resolve,
engine_id)`` awaiting the OmniVoice model load when needed. Shared by the
@@ -473,7 +534,7 @@ async def _prepare_synth(
chunk so a non-English clone holds its language (#505 B2). ``opts`` (#1208)
carries the expressive knobs; a default instance reproduces today exactly."""
opts = opts or ExpressiveOptions()
info = _build_synth(default_voice, language=language, opts=opts)
info = _build_synth(default_voice, language=language, opts=opts, voice_map=voice_map)
resolve, engine_id = info["resolve"], info["engine_id"]
if info["mode"] == "omnivoice":
lang = info["language"]
@@ -501,7 +562,7 @@ async def _prepare_synth(
def _render_chapter_cached(chapter, synth, sr, engine_id, resolve, cache_dir, lexicon=None,
language=None, opts=None):
language=None, opts=None, voice_map=None):
"""Render one chapter, content-addressed so a re-run reuses it (resume).
Returns ``(wav_path, duration_s, was_cached, seg_stats)``. Two cache
@@ -534,7 +595,7 @@ def _render_chapter_cached(chapter, synth, sr, engine_id, resolve, cache_dir, le
import wave
from services.audio_io import atomic_save_wav
from services.audiobook import ExpressiveOptions, Span
from services.audiobook import ExpressiveOptions, Span, voice_map_signature
from services.longform_render import SegmentCache, chapter_cache_key
from services.pronunciation import normalize_lexicon
from services.text_normalization import normalize_for_tts
@@ -567,7 +628,18 @@ def _render_chapter_cached(chapter, synth, sr, engine_id, resolve, cache_dir, le
expr_sig = opts.cache_signature()
if expr_sig:
sig["\x00expressive"] = expr_sig
# Fold the #1217 voice map into BOTH cache layers, exactly like the
# expressive signature: remapping a [voice:NAME] must re-render, while an
# empty/absent map keeps the key byte-identical to pre-#1217 (existing books
# never re-render). The resolved voice_sigs above already reflect a mapping
# when synthesis actually resolves it, but folding the raw map in makes the
# invalidation robust even where resolution is short-circuited/stubbed.
vmap_sig = voice_map_signature(voice_map)
if vmap_sig:
sig["\x00voicemap"] = vmap_sig
seg_extra_sig = f"{lex_sig}\x00{expr_sig}" if expr_sig else lex_sig
if vmap_sig:
seg_extra_sig = f"{seg_extra_sig}\x00{vmap_sig}"
if will_mark():
# Provenance-marked chapters cache under their own key (#1169): a
# chapter WAV rendered while watermarking was off/unavailable —
@@ -615,6 +687,9 @@ class AudiobookPreviewRequest(ExpressiveMixin):
default_voice: str | None = None
language: str | None = None # None/"Auto" → profile language, else autodetect
lexicon: dict | None = None
# Cast map {[voice:NAME] → profile id} — MUST match the full render's so a
# preview warms exactly the cache slot the render reuses (#1217).
voice_map: dict[str, str] | None = None
@router.post("/audiobook/preview")
@@ -643,11 +718,12 @@ async def audiobook_preview(req: AudiobookPreviewRequest) -> dict:
req.default_voice,
language=resolved_lang,
opts=opts,
voice_map=req.voice_map,
)
loop = asyncio.get_running_loop()
wav_path, dur, was_cached, _seg_stats = await loop.run_in_executor(
_gpu_pool, _render_chapter_cached, chapter, synth, sr, engine_id, resolve, cache_dir,
req.lexicon, resolved_lang, opts,
req.lexicon, resolved_lang, opts, req.voice_map,
)
return {
"output": os.path.relpath(wav_path, OUTPUTS_DIR), # served via /audio
@@ -669,6 +745,7 @@ async def _render_longform_sse(
metadata: dict | None = None,
lexicon: dict | None = None,
opts: ExpressiveOptions | None = None,
voice_map: dict | None = None,
job_type: str = "audiobook",
job_id: str | None = None,
resume: bool = False,
@@ -721,6 +798,9 @@ async def _render_longform_sse(
# #1208: persist the expressive knobs so a resumed render is
# byte-consistent with the interrupted one (same cache keys).
"expressive": opts.to_manifest(),
# #1217: persist the cast map so a resumed render resolves and
# caches every [voice:NAME] identically to the interrupted one.
"voice_map": voice_map,
},
))
except Exception: # resume durability is an enhancement; never block the render
@@ -761,7 +841,7 @@ async def _render_longform_sse(
try:
resolved_lang = _resolve_default_language(language, default_voice)
synth, sr, resolve, engine_id = await _prepare_synth(
default_voice, language=resolved_lang, opts=opts
default_voice, language=resolved_lang, opts=opts, voice_map=voice_map
)
total = len(plan.chapters)
@@ -796,7 +876,7 @@ async def _render_longform_sse(
wav_path, dur, was_cached, seg_stats = await loop.run_in_executor(
_gpu_pool, _render_chapter_cached,
chapter, synth, sr, engine_id, resolve, cache_dir, lexicon,
resolved_lang, opts,
resolved_lang, opts, voice_map,
)
except Exception: # isolate a bad chapter — keep going
logger.warning("[%s] chapter %d (%s) failed to render",
@@ -920,7 +1000,8 @@ async def audiobook_synthesize(req: AudiobookRequest, request: Request = None):
plan, default_voice=req.default_voice, language=req.language,
fmt=req.format, bitrate=req.bitrate,
loudness=req.loudness, cover_path=req.cover_path, metadata=req.metadata,
lexicon=req.lexicon, opts=_expressive_opts(req), job_type="audiobook",
lexicon=req.lexicon, opts=_expressive_opts(req), voice_map=req.voice_map,
job_type="audiobook",
is_disconnected=request.is_disconnected if request is not None else None,
),
media_type="text/event-stream",
@@ -951,6 +1032,8 @@ class LongformRenderRequest(ExpressiveMixin):
cover_path: str | None = None
metadata: dict | None = None
lexicon: dict | None = None
# Cast map {[voice:NAME] → profile id} (#1217); absent/empty = today's render.
voice_map: dict[str, str] | None = None
@router.post("/longform/render")
@@ -978,7 +1061,8 @@ async def longform_render(req: LongformRenderRequest, request: Request = None):
plan, default_voice=req.default_voice, language=req.language,
fmt=req.format, bitrate=req.bitrate,
loudness=req.loudness, cover_path=req.cover_path, metadata=req.metadata,
lexicon=req.lexicon, opts=_expressive_opts(req), job_type="story",
lexicon=req.lexicon, opts=_expressive_opts(req), voice_map=req.voice_map,
job_type="story",
is_disconnected=request.is_disconnected if request is not None else None,
),
media_type="text/event-stream",
@@ -1072,6 +1156,7 @@ async def resume_longform(job_id: str, request: Request = None):
loudness=p.get("loudness"), cover_path=p.get("cover_path"),
metadata=p.get("metadata"), lexicon=p.get("lexicon"),
opts=ExpressiveOptions.from_manifest(p.get("expressive")),
voice_map=p.get("voice_map"),
job_type=entry["job_type"],
is_disconnected=request.is_disconnected if request is not None else None,
),
+14
View File
@@ -156,6 +156,20 @@ class ExpressiveOptions:
)
def voice_map_signature(voice_map: Optional[dict]) -> str:
"""Deterministic cache-key fragment for a name→profile voice map (#1217).
A longform render can carry a ``voice_map`` (``[voice:NAME]`` profile id);
remapping a name must re-render, so the map is folded into every cache key
exactly like :meth:`ExpressiveOptions.cache_signature`. Empty for
``None``/``{}`` (so an absent map keeps today's byte-identical keys and
existing books never re-render); else canonical JSON over string keys."""
if not voice_map:
return ""
return json.dumps({str(k): v for k, v in voice_map.items()},
sort_keys=True, ensure_ascii=False)
@dataclass
class Span:
"""One contiguous run of text in a single voice, plus trailing silence.
+6
View File
@@ -63,6 +63,8 @@ export async function audiobookPreviewChapter(
default_voice?: string | null;
language?: string | null;
lexicon?: Record<string, string> | null;
// Cast map {[voice:NAME] → profile id} — must match the render's (#1217).
voice_map?: Record<string, string> | null;
} & ExpressiveRequestFields,
): Promise<AudiobookPreview> {
const res = await apiFetch('/audiobook/preview', {
@@ -96,6 +98,9 @@ export interface AudiobookGenerateBody extends ExpressiveRequestFields {
cover_path?: string | null;
metadata?: AudiobookMetadata | null;
lexicon?: Record<string, string> | null;
// Multi-voice cast map {[voice:NAME] → profile id} (#1217). Absent/empty
// reproduces today's single-voice render + cache keys.
voice_map?: Record<string, string> | null;
}
/**
@@ -153,6 +158,7 @@ export interface LongformRenderBody extends ExpressiveRequestFields {
loudness?: 'off' | 'acx' | 'podcast' | null;
cover_path?: string | null;
metadata?: AudiobookMetadata | null;
voice_map?: Record<string, string> | null;
}
/**
@@ -0,0 +1,60 @@
import React from 'react';
import VoiceSelector from '../VoiceSelector';
/**
* Cast / voice mapping (#1217) the multi-voice fix's UI surface. Lists each
* DISTINCT `[voice:NAME]` name found in the script and lets the user map it to a
* voice profile. The map is persisted in the store (`voiceCast`) and sent to the
* backend as `voice_map` so `[voice:Mara]` actually renders in Mara's voice
* instead of silently falling back to the engine default.
*
* Only names actually present in the script are shown; an unmapped name reads as
* "uses Default voice".
*
* @param {Function} t i18n
* @param {string[]} castNames distinct [voice:NAME] names in the script
* @param {Record<string,string>} voiceCast name profile id
* @param {(name:string, profileId:string|null)=>void} setVoiceCast
* @param {Array} profiles voice profiles for the selector
*/
export default function CastPanel({
t,
castNames = [],
voiceCast = {},
setVoiceCast,
profiles = [],
}) {
if (!castNames.length) {
return (
<p className="muted text-[var(--text-sm)] text-fg-muted m-0">{t('audiobook.cast_empty')}</p>
);
}
return (
<div className="flex flex-col gap-[10px]">
<p className="muted text-[0.72rem] leading-[1.5] m-0 text-fg-muted">
{t('audiobook.cast_hint')}
</p>
{castNames.map((name) => {
const mapped = voiceCast[name] || '';
return (
<div key={name} className="flex flex-col gap-[4px]">
<div className="flex items-center justify-between gap-[8px]">
<code className="text-[0.72rem] text-fg break-all">[voice:{name}]</code>
{!mapped && (
<span className="muted text-[0.68rem] text-fg-muted whitespace-nowrap">
{t('audiobook.cast_uses_default')}
</span>
)}
</div>
<VoiceSelector
value={mapped}
onChange={(v) => setVoiceCast(name, v || null)}
profiles={profiles}
defaultLabel={t('audiobook.engine_default')}
/>
</div>
);
})}
</div>
);
}
@@ -33,14 +33,27 @@ export default function GenerationProgress({ t, chapters = [], assembling = fals
).length;
const pct = total > 0 ? Math.round((completed / total) * 100) : 0;
// Elapsed off a mount-time ref; a 1 s tick re-renders so the clock ticks.
// Elapsed off a mount-time ref; a 1 s tick re-renders so the clock ticks
// but only while work remains. Once every chapter is terminal and nothing is
// assembling, freeze the clock at the finish time and stop the interval, so a
// still-mounted panel doesn't keep counting past the real elapsed. (The parent
// also unmounts this on cancel/fail, which clears the interval either way.)
const terminal = total > 0 && completed === total && !assembling;
const startRef = useRef(performance.now());
const frozenRef = useRef(null);
const [, setTick] = useState(0);
useEffect(() => {
if (terminal) {
if (frozenRef.current == null) frozenRef.current = performance.now();
return undefined;
}
frozenRef.current = null; // resumed (e.g. a re-render that adds chapters)
const id = setInterval(() => setTick((n) => n + 1), 1000);
return () => clearInterval(id);
}, []);
const elapsed = (performance.now() - startRef.current) / 1000;
}, [terminal]);
const elapsed =
((terminal ? (frozenRef.current ?? performance.now()) : performance.now()) - startRef.current) /
1000;
// ETA = average time per completed chapter × chapters remaining. Only once at
// least one chapter has finished and some remain (else it's meaningless).
const eta =
@@ -0,0 +1,137 @@
import React, { useState } from 'react';
import { ChevronDown, Smile } from 'lucide-react';
import { TAGS } from '../../utils/constants';
// Compact chrome pill button same visual language as the clone Insert menu.
const PILL =
'inline-flex items-center gap-[4px] border border-transparent bg-[var(--chrome-bg)] text-[var(--chrome-fg-muted)] px-[8px] py-[3px] rounded-[var(--chrome-radius-pill)] [font-family:var(--chrome-font-mono)] text-[0.66rem] whitespace-nowrap cursor-pointer transition-colors duration-[120ms] hover:bg-[var(--chrome-hover-bg)] hover:text-[var(--chrome-fg)] focus-visible:[outline:2px_solid_var(--chrome-accent)] focus-visible:[outline-offset:1px]';
const TAG_BTN =
'border border-transparent bg-transparent text-[var(--chrome-fg-muted)] px-[9px] py-[3px] rounded-[var(--chrome-radius-pill)] [font-family:var(--chrome-font-mono)] font-medium text-[0.66rem] whitespace-nowrap cursor-pointer transition-colors duration-[120ms] hover:bg-[var(--chrome-hover-bg)] hover:text-[var(--chrome-fg)]';
/**
* Insert markup at the script cursor (#1217). Buttons drop `[pause 500ms]`,
* `[voice:NAME]`, and paired `[slow]…[/slow]` / `[fast]…[/fast]` /
* `[emphasis]…[/emphasis]` / `[spell]…[/spell]` (wrapping the selection when
* there is one), plus a reaction-tags menu from the shared TAGS list.
*
* Uses `setRangeText` where available so the native undo stack is preserved
* (falls back to a controlled-value splice, e.g. under jsdom), matching the
* clone ScriptPanel's cursor-insert approach — React's controlled value
* tolerates it because we immediately sync state to the element's new value.
*/
export default function MarkupToolbar({ t, textareaRef, text, setText }) {
const [reactionsOpen, setReactionsOpen] = useState(false);
const focusCaret = (from, to) =>
setTimeout(() => {
const el = textareaRef.current;
if (!el) return;
el.focus();
el.setSelectionRange(from, to);
}, 0);
// Splice `snippet` in place of [start,end); prefer setRangeText (undo-safe).
const splice = (snippet, start, end) => {
const el = textareaRef.current;
if (!el) return;
if (typeof el.setRangeText === 'function') {
el.setRangeText(snippet, start, end, 'end');
setText(el.value);
} else {
setText(text.slice(0, start) + snippet + text.slice(end));
}
};
const insert = (snippet) => {
const el = textareaRef.current;
if (!el) return;
const start = el.selectionStart ?? text.length;
const end = el.selectionEnd ?? start;
splice(snippet, start, end);
focusCaret(start + snippet.length, start + snippet.length);
};
// Wrap the current selection (or drop an empty pair, caret between the tags).
const wrap = (open, close) => {
const el = textareaRef.current;
if (!el) return;
const start = el.selectionStart ?? text.length;
const end = el.selectionEnd ?? start;
const selected = (el.value ?? text).slice(start, end);
splice(`${open}${selected}${close}`, start, end);
const caret = selected
? start + open.length + selected.length + close.length
: start + open.length;
focusCaret(caret, caret);
};
const insertVoice = () => {
const el = textareaRef.current;
if (!el) return;
const start = el.selectionStart ?? text.length;
const end = el.selectionEnd ?? start;
splice('[voice:NAME]', start, end);
// Select the NAME placeholder so the user can type over it immediately.
const nameStart = start + '[voice:'.length;
focusCaret(nameStart, nameStart + 'NAME'.length);
};
return (
<div
className="flex flex-wrap items-center gap-[6px] relative"
role="toolbar"
aria-label={t('audiobook.markup_toolbar')}
>
<button type="button" className={PILL} onClick={() => insert('[pause 500ms]')}>
{t('audiobook.insert_pause')}
</button>
<button type="button" className={PILL} onClick={insertVoice}>
{t('audiobook.insert_voice')}
</button>
<button type="button" className={PILL} onClick={() => wrap('[slow]', '[/slow]')}>
{t('audiobook.insert_slow')}
</button>
<button type="button" className={PILL} onClick={() => wrap('[fast]', '[/fast]')}>
{t('audiobook.insert_fast')}
</button>
<button type="button" className={PILL} onClick={() => wrap('[emphasis]', '[/emphasis]')}>
{t('audiobook.insert_emphasis')}
</button>
<button type="button" className={PILL} onClick={() => wrap('[spell]', '[/spell]')}>
{t('audiobook.insert_spell')}
</button>
<button
type="button"
className={PILL}
onClick={() => setReactionsOpen((o) => !o)}
aria-expanded={reactionsOpen}
>
<Smile size={11} /> {t('audiobook.insert_reactions')} <ChevronDown size={10} />
</button>
{reactionsOpen && (
<>
<div className="fixed inset-0 z-[19]" onClick={() => setReactionsOpen(false)} />
<div
className="absolute left-0 top-[calc(100%+6px)] z-20 flex flex-wrap gap-1 max-w-[min(360px,calc(100vw-16px))] max-h-[min(280px,calc(100vh-120px))] overflow-y-auto overscroll-contain p-2 bg-[var(--chrome-bg)] border border-transparent rounded-[10px] shadow-[0_8px_24px_rgba(0,0,0,0.45)]"
role="menu"
>
{TAGS.map((tag) => (
<button
key={tag}
type="button"
className={TAG_BTN}
role="menuitem"
onClick={() => {
insert(tag);
setReactionsOpen(false);
}}
>
{tag}
</button>
))}
</div>
</>
)}
</div>
);
}
@@ -0,0 +1,21 @@
import React, { useMemo } from 'react';
import { scriptStats, formatRuntimeClock } from '../../utils/audiobookScript';
/**
* Live script stats (#1217) "N chapters · ~M words · ~H:MM est. runtime".
* Memoized, updates as the user types. Pure `scriptStats` does the counting; the
* runtime is a clock string (H:MM 1h, else M:SS) at ~155 wpm narration pace.
*/
export default function StatsBar({ t, text }) {
const { chapters, words, runtimeSec } = useMemo(() => scriptStats(text), [text]);
if (!text || !text.trim()) return null;
return (
<p className="muted text-[0.72rem] text-fg-muted m-0" aria-live="polite">
{t('audiobook.stats', {
chapters,
words: words.toLocaleString(),
runtime: formatRuntimeClock(runtimeSec),
})}
</p>
);
}
@@ -0,0 +1,49 @@
import React from 'react';
import { AlertTriangle, X } from 'lucide-react';
/**
* Pre-flight validation warnings (#1217) a compact, dismissible, NON-blocking
* list shown near the actions. It never blocks Create (the user may know
* better). Each warning is a `{ type, ... }` from `validateScript`:
* unknown_voice · empty_chapter · unknown_tag.
*/
export default function ValidationWarnings({ t, warnings = [], onDismiss }) {
if (!warnings.length) return null;
const message = (w) => {
if (w.type === 'unknown_voice') return t('audiobook.warn_unknown_voice', { name: w.name });
if (w.type === 'empty_chapter')
return t('audiobook.warn_empty_chapter', { title: w.title || t('audiobook.untitled') });
if (w.type === 'unknown_tag') return t('audiobook.warn_unknown_tag', { tag: w.tag });
return '';
};
return (
<div
className="flex flex-col gap-[6px] p-[10px] rounded-[8px] [border:1px_solid_rgba(250,189,47,0.35)] bg-[rgba(250,189,47,0.07)]"
role="status"
>
<div className="flex items-center justify-between gap-[8px]">
<div className="flex items-center gap-[6px] text-[0.72rem] font-semibold text-fg">
<AlertTriangle size={13} /> {t('audiobook.warnings_title')}
</div>
<button
type="button"
className="border-0 bg-transparent text-[var(--color-fg-muted)] cursor-pointer p-1 rounded-[4px] hover:bg-white/[0.08] hover:text-[var(--color-fg)]"
onClick={onDismiss}
aria-label={t('audiobook.dismiss')}
title={t('audiobook.dismiss')}
>
<X size={13} />
</button>
</div>
<ul className="list-disc pl-[18px] m-0 flex flex-col gap-[3px]">
{warnings.map((w, i) => (
<li key={i} className="text-[0.7rem] leading-[1.45] text-fg-muted">
{message(w)}
</li>
))}
</ul>
</div>
);
}
+35
View File
@@ -0,0 +1,35 @@
import { useEffect, useRef, useState } from 'react';
import { useAppStore } from '../store';
/**
* Pronunciation-lexicon editor state for the Audiobook tab (extracted from
* AudiobookTab so the page stays under the max-lines lint, #1217). Rows stay
* LOCAL (half-typed rows aren't junk-persisted); the filtered {word say} dict
* flushes to the store so it survives a reload, and hydrates back on mount.
*/
export function useAudiobookLexicon() {
const setLexiconStore = useAppStore((s) => s.setLexicon);
const storeLexicon = useAppStore((s) => s.lexicon);
const [lex, setLex] = useState([]); // [{ word, say }]
const lexHydrated = useRef(false);
useEffect(() => {
if (lexHydrated.current) return;
lexHydrated.current = true;
const rows = Object.entries(storeLexicon || {}).map(([word, say]) => ({ word, say }));
if (rows.length) setLex(rows);
}, [storeLexicon]);
const lexDict = () =>
Object.fromEntries(
lex.filter((r) => r.word.trim() && r.say.trim()).map((r) => [r.word.trim(), r.say.trim()]),
);
// Flush the filtered dict to the store whenever rows change (after hydration).
useEffect(() => {
if (!lexHydrated.current) return;
setLexiconStore(lexDict());
}, [lex]); // eslint-disable-line react-hooks/exhaustive-deps
const setLexRow = (i, k) => (e) =>
setLex((rows) => rows.map((r, j) => (j === i ? { ...r, [k]: e.target.value } : r)));
const addLexRow = () => setLex((rows) => [...rows, { word: '', say: '' }]);
const removeLexRow = (i) => setLex((rows) => rows.filter((_, j) => j !== i));
return { lex, lexDict, setLexRow, addLexRow, removeLexRow };
}
+19
View File
@@ -2072,6 +2072,25 @@
"export_error": "تعذر تصدير القاموس."
},
"audiobook": {
"cast": "طاقم الأصوات",
"cast_hint": "عيّن صوتًا لكل [voice:NAME] في النص.",
"cast_empty": "أضف وسوم [voice:NAME] إلى النص لاستخدام أصوات متعددة.",
"cast_uses_default": "يستخدم الصوت الافتراضي",
"markup_toolbar": "إدراج ترميز",
"insert_pause": "إيقاف مؤقت",
"insert_voice": "صوت",
"insert_slow": "بطيء",
"insert_fast": "سريع",
"insert_emphasis": "تأكيد",
"insert_spell": "تهجئة",
"insert_reactions": "تفاعلات",
"stats": "{{chapters}} فصول · {{words}} كلمة · {{runtime}} مدة تقديرية",
"warnings_title": "قبل الإنشاء",
"warn_unknown_voice": "صوت غير معروف ‘{{name}}’ — سيتم استخدام الافتراضي",
"warn_empty_chapter": "الفصل “{{title}}” لا يحتوي على نص منطوق",
"warn_unknown_tag": "وسم غير معروف {{tag}} — سيُقرأ بصوت عالٍ",
"untitled": "بدون عنوان",
"dismiss": "تجاهل",
"output": "الإخراج",
"title": "كتاب مسموع",
"subtitle": "قم بتحويل البرنامج النصي المحدد بفصل إلى كتاب صوتي m4b مقسم.",
+19
View File
@@ -2072,6 +2072,25 @@
"export_error": "Das Wörterbuch konnte nicht exportiert werden."
},
"audiobook": {
"cast": "Besetzung",
"cast_hint": "Weisen Sie jedem [voice:NAME] in Ihrem Skript eine Stimme zu.",
"cast_empty": "Fügen Sie [voice:NAME]-Tags zu Ihrem Skript hinzu, um mehrere Stimmen zu besetzen.",
"cast_uses_default": "verwendet die Standardstimme",
"markup_toolbar": "Markup einfügen",
"insert_pause": "Pause",
"insert_voice": "Stimme",
"insert_slow": "Langsam",
"insert_fast": "Schnell",
"insert_emphasis": "Betonung",
"insert_spell": "Buchstabieren",
"insert_reactions": "Reaktionen",
"stats": "{{chapters}} Kapitel · {{words}} Wörter · {{runtime}} geschätzte Laufzeit",
"warnings_title": "Vor dem Erstellen",
"warn_unknown_voice": "Unbekannte Stimme „{{name}}“ die Standardstimme wird verwendet",
"warn_empty_chapter": "Kapitel „{{title}}“ enthält keinen gesprochenen Text",
"warn_unknown_tag": "Unbekanntes Tag {{tag}} es wird vorgelesen",
"untitled": "Ohne Titel",
"dismiss": "Verwerfen",
"output": "Ausgabe",
"title": "Hörbuch",
"subtitle": "Verwandeln Sie ein durch Kapitel getrenntes Skript in ein in Kapitel unterteiltes m4b-Hörbuch.",
+19
View File
@@ -155,6 +155,25 @@
"flip_rail": "Flip rail side"
},
"audiobook": {
"cast": "Cast",
"cast_hint": "Map each [voice:NAME] in your script to a voice.",
"cast_empty": "Add [voice:NAME] tags to your script to cast multiple voices.",
"cast_uses_default": "uses Default voice",
"markup_toolbar": "Insert markup",
"insert_pause": "Pause",
"insert_voice": "Voice",
"insert_slow": "Slow",
"insert_fast": "Fast",
"insert_emphasis": "Emphasis",
"insert_spell": "Spell",
"insert_reactions": "Reactions",
"stats": "{{chapters}} chapters · {{words}} words · {{runtime}} est. runtime",
"warnings_title": "Before you create",
"warn_unknown_voice": "Unknown voice {{name}} — will use the default",
"warn_empty_chapter": "Chapter “{{title}}” has no spoken text",
"warn_unknown_tag": "Unrecognized tag {{tag}} — it will be read aloud",
"untitled": "Untitled",
"dismiss": "Dismiss",
"output": "Output",
"title": "Audiobook",
"subtitle": "Turn a chapter-delimited script into a chapterized m4b audiobook.",
+19
View File
@@ -2072,6 +2072,25 @@
"export_error": "No se pudo exportar el diccionario."
},
"audiobook": {
"cast": "Reparto",
"cast_hint": "Asigna una voz a cada [voice:NAME] de tu guion.",
"cast_empty": "Añade etiquetas [voice:NAME] a tu guion para asignar varias voces.",
"cast_uses_default": "usa la voz predeterminada",
"markup_toolbar": "Insertar marcado",
"insert_pause": "Pausa",
"insert_voice": "Voz",
"insert_slow": "Lento",
"insert_fast": "Rápido",
"insert_emphasis": "Énfasis",
"insert_spell": "Deletrear",
"insert_reactions": "Reacciones",
"stats": "{{chapters}} capítulos · {{words}} palabras · {{runtime}} de duración est.",
"warnings_title": "Antes de crear",
"warn_unknown_voice": "Voz desconocida «{{name}}»: se usará la predeterminada",
"warn_empty_chapter": "El capítulo «{{title}}» no tiene texto hablado",
"warn_unknown_tag": "Etiqueta no reconocida {{tag}}: se leerá en voz alta",
"untitled": "Sin título",
"dismiss": "Descartar",
"output": "Salida",
"title": "Audiolibro",
"subtitle": "Convierta un guión delimitado por capítulos en un audiolibro m4b dividido en capítulos.",
+19
View File
@@ -2072,6 +2072,25 @@
"export_error": "Impossible d'exporter le dictionnaire."
},
"audiobook": {
"cast": "Distribution",
"cast_hint": "Associez une voix à chaque [voice:NAME] de votre script.",
"cast_empty": "Ajoutez des balises [voice:NAME] à votre script pour attribuer plusieurs voix.",
"cast_uses_default": "utilise la voix par défaut",
"markup_toolbar": "Insérer un balisage",
"insert_pause": "Pause",
"insert_voice": "Voix",
"insert_slow": "Lent",
"insert_fast": "Rapide",
"insert_emphasis": "Emphase",
"insert_spell": "Épeler",
"insert_reactions": "Réactions",
"stats": "{{chapters}} chapitres · {{words}} mots · {{runtime}} de durée est.",
"warnings_title": "Avant de créer",
"warn_unknown_voice": "Voix inconnue « {{name}} » — la voix par défaut sera utilisée",
"warn_empty_chapter": "Le chapitre « {{title}} » ne contient aucun texte parlé",
"warn_unknown_tag": "Balise non reconnue {{tag}} — elle sera lue à voix haute",
"untitled": "Sans titre",
"dismiss": "Ignorer",
"output": "Sortie",
"title": "Livre audio",
"subtitle": "Transformez un script délimité par des chapitres en un livre audio m4b chapitré.",
+19
View File
@@ -2072,6 +2072,25 @@
"export_error": "शब्दकोश निर्यात नहीं हो सका।"
},
"audiobook": {
"cast": "कास्ट",
"cast_hint": "अपनी स्क्रिप्ट में हर [voice:NAME] को एक आवाज़ से मैप करें।",
"cast_empty": "कई आवाज़ें देने के लिए अपनी स्क्रिप्ट में [voice:NAME] टैग जोड़ें।",
"cast_uses_default": "डिफ़ॉल्ट आवाज़ का उपयोग करता है",
"markup_toolbar": "मार्कअप डालें",
"insert_pause": "विराम",
"insert_voice": "आवाज़",
"insert_slow": "धीमा",
"insert_fast": "तेज़",
"insert_emphasis": "ज़ोर",
"insert_spell": "वर्तनी",
"insert_reactions": "प्रतिक्रियाएँ",
"stats": "{{chapters}} अध्याय · {{words}} शब्द · {{runtime}} अनुमानित अवधि",
"warnings_title": "बनाने से पहले",
"warn_unknown_voice": "अज्ञात आवाज़ ‘{{name}}’ — डिफ़ॉल्ट का उपयोग होगा",
"warn_empty_chapter": "अध्याय “{{title}}” में बोलने योग्य कोई पाठ नहीं है",
"warn_unknown_tag": "अपरिचित टैग {{tag}} — इसे ज़ोर से पढ़ा जाएगा",
"untitled": "शीर्षकहीन",
"dismiss": "खारिज करें",
"output": "आउटपुट",
"title": "ऑडियोबुक",
"subtitle": "एक अध्याय-सीमांकित स्क्रिप्ट को एक अध्यायबद्ध m4b ऑडियोबुक में बदलें।",
+19
View File
@@ -2072,6 +2072,25 @@
"export_error": "Kamus tidak dapat diekspor."
},
"audiobook": {
"cast": "Pemeran",
"cast_hint": "Petakan setiap [voice:NAME] di skrip Anda ke sebuah suara.",
"cast_empty": "Tambahkan tag [voice:NAME] ke skrip untuk memakai beberapa suara.",
"cast_uses_default": "memakai suara default",
"markup_toolbar": "Sisipkan markup",
"insert_pause": "Jeda",
"insert_voice": "Suara",
"insert_slow": "Lambat",
"insert_fast": "Cepat",
"insert_emphasis": "Penekanan",
"insert_spell": "Eja",
"insert_reactions": "Reaksi",
"stats": "{{chapters}} bab · {{words}} kata · {{runtime}} perkiraan durasi",
"warnings_title": "Sebelum membuat",
"warn_unknown_voice": "Suara tidak dikenal {{name}} — akan memakai default",
"warn_empty_chapter": "Bab “{{title}}” tidak memiliki teks untuk dibacakan",
"warn_unknown_tag": "Tag tidak dikenal {{tag}} — akan dibacakan",
"untitled": "Tanpa judul",
"dismiss": "Tutup",
"output": "Keluaran",
"title": "Buku Audio",
"subtitle": "Ubah skrip yang dibatasi bab menjadi buku audio m4b yang diberi bab.",
+19
View File
@@ -2072,6 +2072,25 @@
"export_error": "Impossibile esportare il dizionario."
},
"audiobook": {
"cast": "Cast",
"cast_hint": "Assegna una voce a ogni [voice:NAME] del tuo copione.",
"cast_empty": "Aggiungi i tag [voice:NAME] al copione per assegnare più voci.",
"cast_uses_default": "usa la voce predefinita",
"markup_toolbar": "Inserisci markup",
"insert_pause": "Pausa",
"insert_voice": "Voce",
"insert_slow": "Lento",
"insert_fast": "Veloce",
"insert_emphasis": "Enfasi",
"insert_spell": "Compita",
"insert_reactions": "Reazioni",
"stats": "{{chapters}} capitoli · {{words}} parole · {{runtime}} durata stim.",
"warnings_title": "Prima di creare",
"warn_unknown_voice": "Voce sconosciuta «{{name}}» — verrà usata quella predefinita",
"warn_empty_chapter": "Il capitolo «{{title}}» non contiene testo parlato",
"warn_unknown_tag": "Tag non riconosciuto {{tag}} — verrà letto ad alta voce",
"untitled": "Senza titolo",
"dismiss": "Ignora",
"output": "Uscita",
"title": "Audiolibro",
"subtitle": "Trasforma uno script delimitato da capitoli in un audiolibro m4b suddiviso in capitoli.",
+19
View File
@@ -2072,6 +2072,25 @@
"export_error": "辞書をエクスポートできませんでした。"
},
"audiobook": {
"cast": "キャスト",
"cast_hint": "スクリプト内の各 [voice:NAME] に声を割り当てます。",
"cast_empty": "複数の声を割り当てるには、スクリプトに [voice:NAME] タグを追加します。",
"cast_uses_default": "デフォルトの声を使用",
"markup_toolbar": "マークアップを挿入",
"insert_pause": "ポーズ",
"insert_voice": "声",
"insert_slow": "ゆっくり",
"insert_fast": "速く",
"insert_emphasis": "強調",
"insert_spell": "スペル",
"insert_reactions": "リアクション",
"stats": "{{chapters}} 章 · 約 {{words}} 語 · 推定 {{runtime}}",
"warnings_title": "作成する前に",
"warn_unknown_voice": "不明な声 ‘{{name}}’ — デフォルトを使用します",
"warn_empty_chapter": "章「{{title}}」に読み上げるテキストがありません",
"warn_unknown_tag": "不明なタグ {{tag}} — そのまま読み上げられます",
"untitled": "無題",
"dismiss": "閉じる",
"output": "出力",
"title": "オーディオブック",
"subtitle": "チャプター区切りのスクリプトをチャプター化された m4b オーディオブックに変換します。",
+19
View File
@@ -2072,6 +2072,25 @@
"export_error": "사전을 내보낼 수 없습니다."
},
"audiobook": {
"cast": "캐스트",
"cast_hint": "스크립트의 각 [voice:NAME]에 음성을 지정하세요.",
"cast_empty": "여러 음성을 지정하려면 스크립트에 [voice:NAME] 태그를 추가하세요.",
"cast_uses_default": "기본 음성 사용",
"markup_toolbar": "마크업 삽입",
"insert_pause": "일시정지",
"insert_voice": "음성",
"insert_slow": "느리게",
"insert_fast": "빠르게",
"insert_emphasis": "강조",
"insert_spell": "철자",
"insert_reactions": "반응",
"stats": "{{chapters}}개 챕터 · 약 {{words}}단어 · 예상 {{runtime}}",
"warnings_title": "만들기 전에",
"warn_unknown_voice": "알 수 없는 음성 ‘{{name}}’ — 기본값을 사용합니다",
"warn_empty_chapter": "챕터 “{{title}}”에 읽을 텍스트가 없습니다",
"warn_unknown_tag": "인식할 수 없는 태그 {{tag}} — 그대로 읽힙니다",
"untitled": "제목 없음",
"dismiss": "닫기",
"output": "출력",
"title": "Audiobook",
"subtitle": "장으로 구분된 스크립트를 장으로 구분된 m4b 오디오북으로 바꿔보세요.",
+19
View File
@@ -2072,6 +2072,25 @@
"export_error": "Kan het woordenboek niet exporteren."
},
"audiobook": {
"cast": "Cast",
"cast_hint": "Koppel elke [voice:NAME] in je script aan een stem.",
"cast_empty": "Voeg [voice:NAME]-tags aan je script toe om meerdere stemmen te casten.",
"cast_uses_default": "gebruikt de standaardstem",
"markup_toolbar": "Markup invoegen",
"insert_pause": "Pauze",
"insert_voice": "Stem",
"insert_slow": "Langzaam",
"insert_fast": "Snel",
"insert_emphasis": "Nadruk",
"insert_spell": "Spellen",
"insert_reactions": "Reacties",
"stats": "{{chapters}} hoofdstukken · {{words}} woorden · {{runtime}} geschatte speelduur",
"warnings_title": "Voordat je aanmaakt",
"warn_unknown_voice": "Onbekende stem {{name}} — de standaardstem wordt gebruikt",
"warn_empty_chapter": "Hoofdstuk “{{title}}” bevat geen gesproken tekst",
"warn_unknown_tag": "Onbekende tag {{tag}} — deze wordt voorgelezen",
"untitled": "Naamloos",
"dismiss": "Sluiten",
"output": "Uitvoer",
"title": "Audioboek",
"subtitle": "Verander een door hoofdstukken gescheiden script in een m4b-audioboek in hoofdstukken.",
+19
View File
@@ -2072,6 +2072,25 @@
"export_error": "Nie udało się wyeksportować słownika."
},
"audiobook": {
"cast": "Obsada",
"cast_hint": "Przypisz głos do każdego [voice:NAME] w scenariuszu.",
"cast_empty": "Dodaj znaczniki [voice:NAME] do scenariusza, aby przypisać wiele głosów.",
"cast_uses_default": "używa głosu domyślnego",
"markup_toolbar": "Wstaw znacznik",
"insert_pause": "Pauza",
"insert_voice": "Głos",
"insert_slow": "Wolno",
"insert_fast": "Szybko",
"insert_emphasis": "Nacisk",
"insert_spell": "Przeliteruj",
"insert_reactions": "Reakcje",
"stats": "{{chapters}} rozdz. · {{words}} słów · {{runtime}} szac. czasu",
"warnings_title": "Przed utworzeniem",
"warn_unknown_voice": "Nieznany głos „{{name}}” — zostanie użyty domyślny",
"warn_empty_chapter": "Rozdział „{{title}}” nie zawiera tekstu do przeczytania",
"warn_unknown_tag": "Nierozpoznany znacznik {{tag}} — zostanie odczytany na głos",
"untitled": "Bez tytułu",
"dismiss": "Odrzuć",
"output": "Wyjście",
"title": "Książka audio",
"subtitle": "Zamień skrypt podzielony na rozdziały w audiobook m4b podzielony na rozdziały.",
+19
View File
@@ -2072,6 +2072,25 @@
"export_error": "Não foi possível exportar o dicionário."
},
"audiobook": {
"cast": "Elenco",
"cast_hint": "Atribua uma voz a cada [voice:NAME] do seu roteiro.",
"cast_empty": "Adicione tags [voice:NAME] ao roteiro para usar várias vozes.",
"cast_uses_default": "usa a voz padrão",
"markup_toolbar": "Inserir marcação",
"insert_pause": "Pausa",
"insert_voice": "Voz",
"insert_slow": "Lento",
"insert_fast": "Rápido",
"insert_emphasis": "Ênfase",
"insert_spell": "Soletrar",
"insert_reactions": "Reações",
"stats": "{{chapters}} capítulos · {{words}} palavras · {{runtime}} de duração est.",
"warnings_title": "Antes de criar",
"warn_unknown_voice": "Voz desconhecida «{{name}}» — a padrão será usada",
"warn_empty_chapter": "O capítulo «{{title}}» não tem texto falado",
"warn_unknown_tag": "Tag não reconhecida {{tag}} — será lida em voz alta",
"untitled": "Sem título",
"dismiss": "Dispensar",
"output": "Saída",
"title": "Audiolivro",
"subtitle": "Transforme um script delimitado por capítulos em um audiolivro m4b com capítulos.",
+19
View File
@@ -2072,6 +2072,25 @@
"export_error": "Не удалось экспортировать словарь."
},
"audiobook": {
"cast": "Актёры",
"cast_hint": "Назначьте голос каждому [voice:NAME] в сценарии.",
"cast_empty": "Добавьте теги [voice:NAME] в сценарий, чтобы назначить несколько голосов.",
"cast_uses_default": "использует голос по умолчанию",
"markup_toolbar": "Вставить разметку",
"insert_pause": "Пауза",
"insert_voice": "Голос",
"insert_slow": "Медленно",
"insert_fast": "Быстро",
"insert_emphasis": "Акцент",
"insert_spell": "По буквам",
"insert_reactions": "Реакции",
"stats": "{{chapters}} глав · {{words}} слов · {{runtime}} прибл. длительность",
"warnings_title": "Перед созданием",
"warn_unknown_voice": "Неизвестный голос «{{name}}» — будет использован голос по умолчанию",
"warn_empty_chapter": "В главе «{{title}}» нет произносимого текста",
"warn_unknown_tag": "Нераспознанный тег {{tag}} — он будет прочитан вслух",
"untitled": "Без названия",
"dismiss": "Закрыть",
"output": "Вывод",
"title": "Аудиокнига",
"subtitle": "Превратите сценарий с разделением глав в аудиокнигу m4b с разделением на главы.",
+19
View File
@@ -2072,6 +2072,25 @@
"export_error": "Kunde inte exportera ordboken."
},
"audiobook": {
"cast": "Rollbesättning",
"cast_hint": "Koppla varje [voice:NAME] i ditt manus till en röst.",
"cast_empty": "Lägg till [voice:NAME]-taggar i manuset för att använda flera röster.",
"cast_uses_default": "använder standardrösten",
"markup_toolbar": "Infoga markering",
"insert_pause": "Paus",
"insert_voice": "Röst",
"insert_slow": "Långsam",
"insert_fast": "Snabb",
"insert_emphasis": "Betoning",
"insert_spell": "Stava",
"insert_reactions": "Reaktioner",
"stats": "{{chapters}} kapitel · {{words}} ord · {{runtime}} uppskattad speltid",
"warnings_title": "Innan du skapar",
"warn_unknown_voice": "Okänd röst {{name}} — standardrösten används",
"warn_empty_chapter": "Kapitlet ”{{title}}” saknar uppläst text",
"warn_unknown_tag": "Okänd tagg {{tag}} — den läses upp",
"untitled": "Namnlös",
"dismiss": "Avfärda",
"output": "Utdata",
"title": "Ljudbok",
"subtitle": "Förvandla ett kapitelavgränsat manus till en kapitelinställd m4b-ljudbok.",
+19
View File
@@ -2072,6 +2072,25 @@
"export_error": "ไม่สามารถส่งออกพจนานุกรมได้"
},
"audiobook": {
"cast": "นักพากย์",
"cast_hint": "กำหนดเสียงให้กับแต่ละ [voice:NAME] ในสคริปต์ของคุณ",
"cast_empty": "เพิ่มแท็ก [voice:NAME] ลงในสคริปต์เพื่อใช้หลายเสียง",
"cast_uses_default": "ใช้เสียงเริ่มต้น",
"markup_toolbar": "แทรกมาร์กอัป",
"insert_pause": "หยุดชั่วคราว",
"insert_voice": "เสียง",
"insert_slow": "ช้า",
"insert_fast": "เร็ว",
"insert_emphasis": "เน้นเสียง",
"insert_spell": "สะกด",
"insert_reactions": "ปฏิกิริยา",
"stats": "{{chapters}} บท · {{words}} คำ · {{runtime}} เวลาโดยประมาณ",
"warnings_title": "ก่อนสร้าง",
"warn_unknown_voice": "เสียงที่ไม่รู้จัก ‘{{name}}’ — จะใช้เสียงเริ่มต้น",
"warn_empty_chapter": "บท “{{title}}” ไม่มีข้อความสำหรับอ่าน",
"warn_unknown_tag": "แท็กที่ไม่รู้จัก {{tag}} — จะถูกอ่านออกเสียง",
"untitled": "ไม่มีชื่อ",
"dismiss": "ปิด",
"output": "เอาต์พุต",
"title": "หนังสือเสียง",
"subtitle": "เปลี่ยนสคริปต์ที่คั่นด้วยบทให้เป็นหนังสือเสียงแบบแบ่งบท m4b",
+19
View File
@@ -2072,6 +2072,25 @@
"export_error": "Sözlük dışa aktarılamadı."
},
"audiobook": {
"cast": "Kadro",
"cast_hint": "Senaryonuzdaki her [voice:NAME] için bir ses atayın.",
"cast_empty": "Birden çok ses atamak için senaryonuza [voice:NAME] etiketleri ekleyin.",
"cast_uses_default": "varsayılan sesi kullanır",
"markup_toolbar": "İşaretleme ekle",
"insert_pause": "Duraklat",
"insert_voice": "Ses",
"insert_slow": "Yavaş",
"insert_fast": "Hızlı",
"insert_emphasis": "Vurgu",
"insert_spell": "Hecele",
"insert_reactions": "Tepkiler",
"stats": "{{chapters}} bölüm · {{words}} kelime · {{runtime}} tahmini süre",
"warnings_title": "Oluşturmadan önce",
"warn_unknown_voice": "Bilinmeyen ses {{name}} — varsayılan kullanılacak",
"warn_empty_chapter": "“{{title}}” bölümünde seslendirilecek metin yok",
"warn_unknown_tag": "Tanınmayan etiket {{tag}} — sesli okunacak",
"untitled": "Başlıksız",
"dismiss": "Kapat",
"output": "Çıktı",
"title": "Sesli kitap",
"subtitle": "Bölümle ayrılmış bir komut dosyasını bölümlere ayrılmış bir m4b sesli kitaba dönüştürün.",
+19
View File
@@ -2072,6 +2072,25 @@
"export_error": "Не вдалося експортувати словник."
},
"audiobook": {
"cast": "Актори",
"cast_hint": "Призначте голос кожному [voice:NAME] у сценарії.",
"cast_empty": "Додайте теги [voice:NAME] до сценарію, щоб призначити кілька голосів.",
"cast_uses_default": "використовує голос за замовчуванням",
"markup_toolbar": "Вставити розмітку",
"insert_pause": "Пауза",
"insert_voice": "Голос",
"insert_slow": "Повільно",
"insert_fast": "Швидко",
"insert_emphasis": "Акцент",
"insert_spell": "По літерах",
"insert_reactions": "Реакції",
"stats": "{{chapters}} розділів · {{words}} слів · {{runtime}} приблизна тривалість",
"warnings_title": "Перед створенням",
"warn_unknown_voice": "Невідомий голос «{{name}}» — буде використано голос за замовчуванням",
"warn_empty_chapter": "У розділі «{{title}}» немає тексту для озвучення",
"warn_unknown_tag": "Нерозпізнаний тег {{tag}} — його буде прочитано вголос",
"untitled": "Без назви",
"dismiss": "Закрити",
"output": "Вивід",
"title": "Аудіокнига",
"subtitle": "Перетворіть розділений розділами сценарій на розділену на розділи аудіокнигу m4b.",
+19
View File
@@ -2072,6 +2072,25 @@
"export_error": "Không thể xuất từ điển."
},
"audiobook": {
"cast": "Dàn giọng",
"cast_hint": "Gán một giọng cho từng [voice:NAME] trong kịch bản của bạn.",
"cast_empty": "Thêm thẻ [voice:NAME] vào kịch bản để dùng nhiều giọng.",
"cast_uses_default": "dùng giọng mặc định",
"markup_toolbar": "Chèn đánh dấu",
"insert_pause": "Tạm dừng",
"insert_voice": "Giọng",
"insert_slow": "Chậm",
"insert_fast": "Nhanh",
"insert_emphasis": "Nhấn mạnh",
"insert_spell": "Đánh vần",
"insert_reactions": "Phản ứng",
"stats": "{{chapters}} chương · {{words}} từ · {{runtime}} thời lượng ước tính",
"warnings_title": "Trước khi tạo",
"warn_unknown_voice": "Giọng không xác định {{name}} — sẽ dùng giọng mặc định",
"warn_empty_chapter": "Chương “{{title}}” không có nội dung để đọc",
"warn_unknown_tag": "Thẻ không nhận dạng {{tag}} — sẽ được đọc to",
"untitled": "Không có tiêu đề",
"dismiss": "Bỏ qua",
"output": "Đầu ra",
"title": "Sách nói",
"subtitle": "Biến tập lệnh được phân cách theo chương thành sách nói m4b được chia theo chương.",
+19
View File
@@ -2079,6 +2079,25 @@
"export_error": "无法导出词典。"
},
"audiobook": {
"cast": "配音角色",
"cast_hint": "将脚本中的每个 [voice:NAME] 映射到一个声音。",
"cast_empty": "在脚本中添加 [voice:NAME] 标记即可分配多个声音。",
"cast_uses_default": "使用默认声音",
"markup_toolbar": "插入标记",
"insert_pause": "停顿",
"insert_voice": "声音",
"insert_slow": "慢速",
"insert_fast": "快速",
"insert_emphasis": "强调",
"insert_spell": "拼读",
"insert_reactions": "反应音",
"stats": "{{chapters}} 章 · 约 {{words}} 字 · 约 {{runtime}} 预计时长",
"warnings_title": "创建前",
"warn_unknown_voice": "未知声音 ‘{{name}} — 将使用默认声音",
"warn_empty_chapter": "章节“{{title}}”没有可朗读的文本",
"warn_unknown_tag": "无法识别的标记 {{tag}} — 将被朗读出来",
"untitled": "无标题",
"dismiss": "关闭",
"output": "输出",
"title": "有声读物",
"subtitle": "将章节分隔的脚本转换为章节化的 m4b 有声读物。",
+19
View File
@@ -2072,6 +2072,25 @@
"export_error": "無法匯出字典。"
},
"audiobook": {
"cast": "配音角色",
"cast_hint": "將腳本中的每個 [voice:NAME] 對應到一個聲音。",
"cast_empty": "在腳本中加入 [voice:NAME] 標記即可指定多個聲音。",
"cast_uses_default": "使用預設聲音",
"markup_toolbar": "插入標記",
"insert_pause": "停頓",
"insert_voice": "聲音",
"insert_slow": "慢速",
"insert_fast": "快速",
"insert_emphasis": "強調",
"insert_spell": "拼讀",
"insert_reactions": "反應音",
"stats": "{{chapters}} 章 · 約 {{words}} 字 · 約 {{runtime}} 預計時長",
"warnings_title": "建立前",
"warn_unknown_voice": "未知聲音 ‘{{name}} — 將使用預設聲音",
"warn_empty_chapter": "章節「{{title}}」沒有可朗讀的文字",
"warn_unknown_tag": "無法辨識的標記 {{tag}} — 將被朗讀出來",
"untitled": "無標題",
"dismiss": "關閉",
"output": "輸出",
"title": "有聲書",
"subtitle": "將章節分隔的腳本轉換為章節化的 m4b 有聲書。",
+100 -44
View File
@@ -1,4 +1,4 @@
import React, { useCallback, useEffect, useRef, useState } from 'react';
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { useTranslation } from 'react-i18next';
import {
BookMarked,
@@ -10,6 +10,7 @@ import {
SpellCheck,
Square,
Upload,
Users,
} from 'lucide-react';
import {
@@ -32,6 +33,12 @@ import LexiconEditor from '../components/audiobook/LexiconEditor';
import GenerationProgress from '../components/audiobook/GenerationProgress';
import PlanList from '../components/audiobook/PlanList';
import AudiobookResult from '../components/audiobook/AudiobookResult';
import CastPanel from '../components/audiobook/CastPanel';
import MarkupToolbar from '../components/audiobook/MarkupToolbar';
import StatsBar from '../components/audiobook/StatsBar';
import ValidationWarnings from '../components/audiobook/ValidationWarnings';
import { useAudiobookLexicon } from '../hooks/useAudiobookLexicon';
import { parseCastNames, validateScript } from '../utils/audiobookScript';
import { SAMPLE_AUDIOBOOK_SCRIPT } from '../data/sampleAudiobook';
import ALL_LANGUAGES from '../languages.json';
import { POPULAR_LANGS } from '../utils/constants';
@@ -40,6 +47,10 @@ import { buttonVariants } from '@/components/ui/button.tsx';
// Chrome-mono uppercase form label (was the scoped `.audiobook-tab .field-label`
// rule; `.field-label` has no global styling, so it's reproduced as utilities).
// Stable empty-cast fallback: a literal `?? {}` mints a new object every render,
// which defeats the useMemos keyed on voiceCast (they'd recompute every render).
const EMPTY_CAST = Object.freeze({});
const FIELD_LABEL =
'[font-family:var(--chrome-font-mono)] [font-size:var(--chrome-label-size)] font-semibold [letter-spacing:var(--chrome-label-track)] uppercase [color:var(--chrome-fg-muted)]';
@@ -60,9 +71,11 @@ export default function AudiobookTab({ profiles = [] }) {
const defaultVoice = useAppStore((s) => s.defaultVoice) ?? ''; // select coerces null''
const setOutputPrefs = useAppStore((s) => s.setOutputPrefs);
const setProjectMeta = useAppStore((s) => s.setProjectMeta);
const setLexiconStore = useAppStore((s) => s.setLexicon);
const storeLexicon = useAppStore((s) => s.lexicon);
const setDefaultVoice = (v) => setOutputPrefs({ defaultVoice: v || null });
// Multi-voice cast map (#1217): [voice:NAME] profile id, store-backed so a
// book's voice assignments survive a tab switch / reload.
const voiceCast = useAppStore((s) => s.voiceCast) ?? EMPTY_CAST;
const setVoiceCast = useAppStore((s) => s.setVoiceCast);
// Language pick + expressive overrides (#1208) store-backed so a book's
// tuning survives a tab switch / reload (same persistence as the lexicon).
const language = useAppStore((s) => s.language) ?? 'Auto';
@@ -141,30 +154,27 @@ export default function AudiobookTab({ profiles = [] }) {
const [coverFile, setCoverFile] = useState(null);
const [coverPreview, setCoverPreview] = useState('');
// Pronunciation lexicon: editable {word respelling} rows. Rows stay LOCAL
// (half-typed rows aren't junk-persisted); the filtered dict flushes to the
// store so it survives a reload, and hydrates back into rows on mount.
const [lex, setLex] = useState([]); // [{ word, say }]
const lexHydrated = useRef(false);
useEffect(() => {
if (lexHydrated.current) return;
lexHydrated.current = true;
const rows = Object.entries(storeLexicon || {}).map(([word, say]) => ({ word, say }));
if (rows.length) setLex(rows);
}, [storeLexicon]);
const lexDict = () =>
Object.fromEntries(
lex.filter((r) => r.word.trim() && r.say.trim()).map((r) => [r.word.trim(), r.say.trim()]),
);
// Flush the filtered dict to the store whenever rows change (after hydration).
useEffect(() => {
if (!lexHydrated.current) return;
setLexiconStore(lexDict());
}, [lex]); // eslint-disable-line react-hooks/exhaustive-deps
const setLexRow = (i, k) => (e) =>
setLex((rows) => rows.map((r, j) => (j === i ? { ...r, [k]: e.target.value } : r)));
const addLexRow = () => setLex((rows) => [...rows, { word: '', say: '' }]);
const removeLexRow = (i) => setLex((rows) => rows.filter((_, j) => j !== i));
// Pronunciation lexicon: editable {word respelling} rows (extracted to a
// hook so this page stays under the max-lines lint, #1217).
const { lex, lexDict, setLexRow, addLexRow, removeLexRow } = useAudiobookLexicon();
// Cast + validation derive purely from the script (#1217). castNames drives
// the Cast panel; voiceMap is the minimal nameprofile map actually present in
// the script (stray store mappings are excluded so the cache key stays stable
// and an absent map keeps today's render). Warnings are non-blocking hints.
const textareaRef = useRef(null);
const [warningsDismissed, setWarningsDismissed] = useState(false);
const castNames = useMemo(() => parseCastNames(text), [text]);
const voiceMap = useMemo(() => {
const m = {};
for (const name of castNames) if (voiceCast[name]) m[name] = voiceCast[name];
return m;
}, [castNames, voiceCast]);
const voiceMapArg = Object.keys(voiceMap).length ? voiceMap : null;
const warnings = useMemo(() => {
const mappedNames = Object.keys(voiceCast).filter((n) => voiceCast[n]);
return validateScript(text, { mappedNames, profileIds: profiles.map((p) => p.id) });
}, [text, voiceCast, profiles]);
const onCoverPick = useCallback((e) => {
const f = e.target.files?.[0];
@@ -241,6 +251,9 @@ export default function AudiobookTab({ profiles = [] }) {
chapter_index: i,
default_voice: defaultVoice || null,
lexicon: Object.keys(lexicon).length ? lexicon : null,
// Cast map MUST match the full render's so a preview warms the exact
// cache slot the render reuses (preview/render parity, #1217).
voice_map: voiceMapArg,
// Same expressive fields as the full render so a preview warms the
// exact cache slot the render reuses (preview/render parity, #1208).
...overridesToRequest(overrides, language),
@@ -251,7 +264,7 @@ export default function AudiobookTab({ profiles = [] }) {
setError(e?.message || String(e));
}
},
[text, defaultVoice, lex, overrides, language],
[text, defaultVoice, lex, overrides, language, voiceMapArg],
);
const onCreate = useCallback(async () => {
@@ -284,6 +297,9 @@ export default function AudiobookTab({ profiles = [] }) {
cover_path,
metadata: Object.keys(metadata).length ? metadata : null,
lexicon: Object.keys(lexicon).length ? lexicon : null,
// Multi-voice cast map (#1217): [voice:NAME] profile id. Absent when
// empty, so a single-voice book stays byte-identical to before.
voice_map: voiceMapArg,
// language pick + expressive/quality overrides + cache opt-out (#1208).
// Only non-default values are emitted, so an untouched panel keeps the
// request byte-identical to before.
@@ -352,7 +368,18 @@ export default function AudiobookTab({ profiles = [] }) {
setAssembling(false);
abortControllerRef.current = null;
}
}, [text, defaultVoice, format, loudness, coverFile, meta, lex, overrides, language]);
}, [
text,
defaultVoice,
format,
loudness,
coverFile,
meta,
lex,
overrides,
language,
voiceMapArg,
]);
// Stop = abort the fetch (cancels the request backend disconnect) AND flip
// the isAborted flag the stream consumer polls, so the read loop releases too.
@@ -391,17 +418,16 @@ export default function AudiobookTab({ profiles = [] }) {
</p>
</div>
<div className="audiobook-tab__actions flex flex-wrap items-center gap-[8px]">
{/* All four use the one shadcn button layout (icon in the leading slot,
text in a leading-none span) so the row lines up. Import is a <label>
(it wraps the file input) styled the same way the old inline
flex/gap override is what made it ragged. */}
<label
className={buttonVariants({ variant: 'subtle', size: 'omniMd' })}
style={{
cursor: busy ? 'default' : 'pointer',
display: 'inline-flex',
alignItems: 'center',
gap: 6,
}}
style={{ cursor: busy ? 'default' : 'pointer' }}
>
{importing ? <Loader size={14} className="spin" /> : <Upload size={14} />}{' '}
{t('audiobook.import')}
{importing ? <Loader size={14} className="spin" /> : <Upload size={14} />}
<span className="leading-none">{t('audiobook.import')}</span>
<input
type="file"
accept=".txt,.md,.epub,.pdf"
@@ -415,16 +441,16 @@ export default function AudiobookTab({ profiles = [] }) {
onClick={loadSample}
disabled={busy}
title={t('audiobook.load_sample_hint')}
leading={<BookOpen size={14} />}
>
<BookOpen size={14} /> {t('audiobook.load_sample')}
{t('audiobook.load_sample')}
</Button>
<Button variant="subtle" onClick={onPreview} disabled={!canRun}>
{planLoading ? <Loader size={14} className="spin" /> : null}{' '}
<Button variant="subtle" onClick={onPreview} disabled={!canRun} loading={planLoading}>
{t('audiobook.preview_plan')}
</Button>
{generating ? (
<Button variant="danger" onClick={onStop}>
<Square size={14} /> {t('audiobook.stop')}
<Button variant="danger" onClick={onStop} leading={<Square size={14} />}>
{t('audiobook.stop')}
</Button>
) : (
<Button variant="primary" onClick={onCreate} disabled={!canRun}>
@@ -438,15 +464,22 @@ export default function AudiobookTab({ profiles = [] }) {
{/* Left: script editor fills the height */}
<div className="audiobook-tab__script flex flex-col min-h-0 gap-[6px]">
<label className={FIELD_LABEL}>{t('audiobook.script')}</label>
<MarkupToolbar t={t} textareaRef={textareaRef} text={text} setText={setText} />
<textarea
ref={textareaRef}
className="input-base"
value={text}
onChange={(e) => setText(e.target.value)}
onChange={(e) => {
setText(e.target.value);
if (warningsDismissed) setWarningsDismissed(false);
}}
onKeyDown={onScriptKeyDown}
placeholder={t('audiobook.script_placeholder')}
aria-label={t('audiobook.script')}
/>
{!text.trim() && (
{text.trim() ? (
<StatsBar t={t} text={text} />
) : (
<p className="muted text-[var(--text-sm)] text-fg-muted m-0">
{t('audiobook.empty_hint')}
</p>
@@ -476,6 +509,21 @@ export default function AudiobookTab({ profiles = [] }) {
/>
</div>
{/* Cast one row per distinct [voice:NAME] in the script (#1217).
Open by default so the multi-voice mapping is discoverable the
moment a script uses [voice:]; hidden entirely when it doesn't. */}
{castNames.length > 0 && (
<Section title={t('audiobook.cast')} icon={<Users size={13} />} defaultOpen>
<CastPanel
t={t}
castNames={castNames}
voiceCast={voiceCast}
setVoiceCast={setVoiceCast}
profiles={profiles}
/>
</Section>
)}
<AudiobookOverrides
t={t}
overrides={overrides}
@@ -545,6 +593,14 @@ export default function AudiobookTab({ profiles = [] }) {
</p>
</Section>
{!warningsDismissed && !generating && (
<ValidationWarnings
t={t}
warnings={warnings}
onDismiss={() => setWarningsDismissed(true)}
/>
)}
{error && (
<div className="error-banner" role="alert">
{error}
+2
View File
@@ -149,6 +149,7 @@ export const useAppStore = create<AppStore>()(
script: s.script,
meta: s.meta,
lexicon: s.lexicon,
voiceCast: s.voiceCast,
coverRef: s.coverRef,
outputFormat: s.outputFormat,
loudness: s.loudness,
@@ -204,6 +205,7 @@ export const useAppStore = create<AppStore>()(
outputFormat: 'm4b',
loudness: 'off',
defaultVoice: null,
voiceCast: {},
updatedAt: 0,
...sp,
}));
+19
View File
@@ -105,6 +105,9 @@ interface LongformProject {
defaultVoice: string | null;
language: string;
overrides: LongformOverrides;
// Audiobook multi-voice cast (#1217): [voice:NAME] → profile id. Empty for a
// single-voice book; an absent field on an old record default-fills to {}.
voiceCast: Record<string, string>;
updatedAt: number;
}
@@ -129,6 +132,9 @@ export interface LongformSlice {
// Expressive/quality overrides + cache opt-out (#1208). Persisted like the
// lexicon so a book's tuning survives a tab switch / reload.
overrides: LongformOverrides;
// Audiobook cast map (#1217): [voice:NAME] → profile id. Persisted like the
// lexicon so a book's voice assignments survive a tab switch / reload.
voiceCast: Record<string, string>;
// Last finished render's server filename (#1139): the Audiobook tab's
// player + Download link used to live in component useState, so a finished
// book's export affordance evaporated on the first tab switch and users
@@ -149,6 +155,7 @@ export interface LongformSlice {
setScript: (script: string) => void;
setProjectMeta: (patch: Partial<LongformMeta>) => void; // merge (I1)
setLexicon: (lexicon: Record<string, string>) => void; // replace (I3)
setVoiceCast: (name: string, profileId: string | null) => void; // merge/remove
setOutputPrefs: (patch: {
outputFormat?: 'm4b' | 'mp3';
loudness?: 'off' | 'acx' | 'podcast';
@@ -183,6 +190,7 @@ export const SLICE_DEFAULTS = {
defaultVoice: null as string | null,
language: 'Auto' as string,
overrides: DEFAULT_OVERRIDES as LongformOverrides,
voiceCast: {} as Record<string, string>,
lastOutput: '' as string,
projectMode: 'stories' as LongformMode,
} as const;
@@ -215,6 +223,7 @@ export const createLongformSlice: StateCreator<LongformSlice, [], [], LongformSl
meta: { ...SLICE_DEFAULTS.meta },
lexicon: { ...SLICE_DEFAULTS.lexicon },
overrides: { ...SLICE_DEFAULTS.overrides },
voiceCast: { ...SLICE_DEFAULTS.voiceCast },
setStoryTracks: (storyTracks) => set({ storyTracks }),
setCast: (cast) => set({ cast }),
@@ -233,6 +242,13 @@ export const createLongformSlice: StateCreator<LongformSlice, [], [], LongformSl
setScript: (script) => set({ script }),
setProjectMeta: (patch) => set((s) => ({ meta: { ...s.meta, ...patch } })), // I1 merge
setLexicon: (lexicon) => set({ lexicon: { ...lexicon } }), // I3 replace
setVoiceCast: (name, profileId) =>
set((s) => {
const next = { ...s.voiceCast };
if (profileId) next[name] = profileId;
else delete next[name]; // clearing a mapping removes it (→ default voice)
return { voiceCast: next };
}),
setOutputPrefs: (patch) =>
set((s) => ({
// I2 merge
@@ -275,6 +291,7 @@ export const createLongformSlice: StateCreator<LongformSlice, [], [], LongformSl
defaultVoice: s.defaultVoice,
language: s.language,
overrides: { ...s.overrides },
voiceCast: { ...s.voiceCast },
updatedAt: ts,
};
const exists = s.storyProjects.some((p) => p.id === id);
@@ -301,6 +318,7 @@ export const createLongformSlice: StateCreator<LongformSlice, [], [], LongformSl
defaultVoice: p.defaultVoice ?? SLICE_DEFAULTS.defaultVoice,
language: p.language ?? SLICE_DEFAULTS.language,
overrides: { ...SLICE_DEFAULTS.overrides, ...p.overrides },
voiceCast: { ...(p.voiceCast ?? SLICE_DEFAULTS.voiceCast) },
// A render belongs to the project it was made in — loading another
// project must not present A's finished file as B's output (#1139).
lastOutput: SLICE_DEFAULTS.lastOutput,
@@ -317,6 +335,7 @@ export const createLongformSlice: StateCreator<LongformSlice, [], [], LongformSl
meta: { ...SLICE_DEFAULTS.meta },
lexicon: { ...SLICE_DEFAULTS.lexicon },
overrides: { ...SLICE_DEFAULTS.overrides },
voiceCast: { ...SLICE_DEFAULTS.voiceCast },
projectMode: mode === 'audiobook' ? 'audiobook' : 'stories',
currentProjectId: null,
}),
@@ -0,0 +1,132 @@
import React, { useRef, useState } from 'react';
import { describe, it, expect, vi } from 'vitest';
import { render, screen, fireEvent } from '@testing-library/react';
// A trivial t() components take t as a prop, so no i18n provider is needed.
const t = (k, o) =>
o ? Object.entries(o).reduce((s, [kk, v]) => s.replace(`{{${kk}}}`, v), k) : k;
// Mock the shared VoiceSelector to a plain <select> so we can drive onChange.
vi.mock('../components/VoiceSelector', () => ({
default: ({ value, onChange }) => (
<select data-testid="voice-selector" value={value} onChange={(e) => onChange(e.target.value)}>
<option value="">default</option>
<option value="pid-1">Voice 1</option>
</select>
),
}));
import CastPanel from '../components/audiobook/CastPanel';
import MarkupToolbar from '../components/audiobook/MarkupToolbar';
import StatsBar from '../components/audiobook/StatsBar';
import ValidationWarnings from '../components/audiobook/ValidationWarnings';
describe('CastPanel', () => {
it('lists the scripts distinct voices and maps one', () => {
const setVoiceCast = vi.fn();
render(
<CastPanel
t={t}
castNames={['Mara', 'Cole']}
voiceCast={{}}
setVoiceCast={setVoiceCast}
profiles={[]}
/>,
);
expect(screen.getByText('[voice:Mara]')).toBeInTheDocument();
expect(screen.getByText('[voice:Cole]')).toBeInTheDocument();
// Both unmapped both show the "uses Default voice" hint.
expect(screen.getAllByText('audiobook.cast_uses_default')).toHaveLength(2);
fireEvent.change(screen.getAllByTestId('voice-selector')[0], {
target: { value: 'pid-1' },
});
expect(setVoiceCast).toHaveBeenCalledWith('Mara', 'pid-1');
});
it('shows an empty hint when the script has no voice tags', () => {
render(<CastPanel t={t} castNames={[]} voiceCast={{}} setVoiceCast={vi.fn()} profiles={[]} />);
expect(screen.getByText('audiobook.cast_empty')).toBeInTheDocument();
});
});
function ToolbarHarness({ initial = 'hello world' }) {
const [text, setText] = useState(initial);
const ref = useRef(null);
return (
<div>
<MarkupToolbar t={t} textareaRef={ref} text={text} setText={setText} />
<textarea
ref={ref}
aria-label="script"
value={text}
onChange={(e) => setText(e.target.value)}
/>
</div>
);
}
describe('MarkupToolbar', () => {
it('inserts a token at the cursor', () => {
render(<ToolbarHarness initial="hello world" />);
const ta = screen.getByLabelText('script');
ta.focus();
ta.setSelectionRange(5, 5); // caret right after "hello"
fireEvent.click(screen.getByText('audiobook.insert_pause'));
expect(ta.value).toBe('hello[pause 500ms] world');
});
it('wraps the current selection with a paired tag', () => {
render(<ToolbarHarness initial="hello world" />);
const ta = screen.getByLabelText('script');
ta.focus();
ta.setSelectionRange(0, 11); // select the whole thing
fireEvent.click(screen.getByText('audiobook.insert_slow'));
expect(ta.value).toBe('[slow]hello world[/slow]');
});
it('inserts a [voice:NAME] placeholder', () => {
render(<ToolbarHarness initial="" />);
const ta = screen.getByLabelText('script');
ta.focus();
ta.setSelectionRange(0, 0);
fireEvent.click(screen.getByText('audiobook.insert_voice'));
expect(ta.value).toBe('[voice:NAME]');
});
});
describe('StatsBar', () => {
it('computes chapters/words and passes them to t()', () => {
const spy = vi.fn(() => 'STATS');
render(<StatsBar t={spy} text={'# One\nHello world here now.'} />);
expect(spy).toHaveBeenCalledWith('audiobook.stats', {
chapters: 1,
words: '4',
runtime: expect.any(String),
});
});
it('renders nothing for an empty script', () => {
const { container } = render(<StatsBar t={t} text={' '} />);
expect(container).toBeEmptyDOMElement();
});
});
describe('ValidationWarnings', () => {
it('renders warnings and dismisses', () => {
const onDismiss = vi.fn();
render(
<ValidationWarnings
t={t}
warnings={[{ type: 'unknown_voice', name: 'Mara' }]}
onDismiss={onDismiss}
/>,
);
expect(screen.getByText(/warn_unknown_voice/)).toBeInTheDocument();
fireEvent.click(screen.getByLabelText('audiobook.dismiss'));
expect(onDismiss).toHaveBeenCalled();
});
it('renders nothing when there are no warnings', () => {
const { container } = render(<ValidationWarnings t={t} warnings={[]} onDismiss={vi.fn()} />);
expect(container).toBeEmptyDOMElement();
});
});
+70
View File
@@ -0,0 +1,70 @@
import { describe, it, expect } from 'vitest';
import {
parseCastNames,
scriptStats,
formatRuntimeClock,
validateScript,
AUDIOBOOK_WPM,
} from '../utils/audiobookScript';
describe('parseCastNames', () => {
it('returns distinct [voice:NAME] names in first-seen order', () => {
const script =
'# One\n[voice:Narrator] hi [voice:Mara] hey [voice:Narrator] again [voice:Cole] yo';
expect(parseCastNames(script)).toEqual(['Narrator', 'Mara', 'Cole']);
});
it('skips the empty [voice:] reset and trims names', () => {
expect(parseCastNames('[voice:] plain [voice: Mara ] x')).toEqual(['Mara']);
});
it('is empty for a script with no voice tags', () => {
expect(parseCastNames('# Chapter\nJust narration.')).toEqual([]);
expect(parseCastNames('')).toEqual([]);
});
});
describe('scriptStats', () => {
it('counts H1 chapters, spoken words (markup stripped), and runtime', () => {
const script =
'# One\n[voice:Mara] Hello world here. [pause 500ms]\n# Two\nFour more spoken words.';
const { chapters, words, runtimeSec } = scriptStats(script);
expect(chapters).toBe(2);
// "Hello world here" (3) + "Four more spoken words" (4) = 7 — markup excluded.
expect(words).toBe(7);
expect(runtimeSec).toBeCloseTo((7 / AUDIOBOOK_WPM) * 60, 5);
});
it('treats a title-less script as one chapter', () => {
expect(scriptStats('just some words').chapters).toBe(1);
});
});
describe('formatRuntimeClock', () => {
it('formats <1h as M:SS and ≥1h as H:MM', () => {
expect(formatRuntimeClock(45)).toBe('0:45');
expect(formatRuntimeClock(125)).toBe('2:05');
expect(formatRuntimeClock(3720)).toBe('1:02');
});
});
describe('validateScript', () => {
it('flags an unknown voice and clears once it is mapped', () => {
const script = '# One\n[voice:Mara] hello there';
const unmapped = validateScript(script, { mappedNames: [], profileIds: [] });
expect(unmapped).toEqual([{ type: 'unknown_voice', name: 'Mara' }]);
// Mapping Mara clears the warning…
expect(validateScript(script, { mappedNames: ['Mara'], profileIds: [] })).toEqual([]);
// …and an exact profile-id match also clears it.
expect(validateScript(script, { mappedNames: [], profileIds: ['Mara'] })).toEqual([]);
});
it('flags empty chapters and unrecognized tags', () => {
const script = '# Empty\n\n# Full\nSome [wobble] words [pause 1s] and [slow]slow[/slow].';
const warns = validateScript(script, {});
expect(warns).toContainEqual({ type: 'empty_chapter', title: 'Empty' });
expect(warns).toContainEqual({ type: 'unknown_tag', tag: '[wobble]' });
// Known grammar (pause / SSML / voice / reactions) must NOT warn.
expect(warns.some((w) => w.type === 'unknown_tag' && w.tag !== '[wobble]')).toBe(false);
});
it('does not flag known reaction tags', () => {
const warns = validateScript('# C\nHa [laughter] ha.', {});
expect(warns.filter((w) => w.type === 'unknown_tag')).toEqual([]);
});
});
+142
View File
@@ -0,0 +1,142 @@
/**
* Audiobook script helpers (#1217) pure, testable textdata functions for the
* Cast panel, live stats bar, and pre-flight validation. No React, no i18n: the
* UI supplies labels; these only compute.
*
* The bracket-token regexes here MIRROR the canonical grammar in
* `longformParser.js` / `backend/services/longform_parser.py` (voice + pause +
* SSML-lite). They exist for name extraction / stats / warnings only the
* golden-corpus parser stays the single grammar source of truth for rendering.
*/
import { TAGS } from './constants';
// [voice:NAME] — content excludes BOTH brackets (mirrors _VOICE_RE). Global.
const VOICE_RE = /\[voice:([^\][]*)\]/g;
// H1 chapter heading (mirrors _HEADING_RE): `# <non-space>…`, multiline.
const HEADING_RE = /^[ \t]*#[ \t]+(\S.*)$/gm;
// Any bracket token — used to strip markup before the word count and to
// enumerate tokens for validation. Non-greedy, no nested brackets.
const BRACKET_RE = /\[[^\][]*\]/g;
// Recognized (non-voice) bracket tokens, so validation can flag the rest.
const PAUSE_TOKEN_RE = /^\[\s*pause(?:\s+\d+(?:\.\d+)?(?:\s*(?:ms|s))?)?\s*\]$/i;
const SSML_TOKEN_RE = /^\[\/?(?:slow|fast|emphasis|spell)\]$/i;
const REACTION_TOKENS = new Set(TAGS.map((s) => s.toLowerCase()));
/** ~ words a listener hears per minute at an audiobook narration pace. */
export const AUDIOBOOK_WPM = 155;
/**
* Distinct `[voice:NAME]` names present in the script, in first-seen order.
* Empty `[voice:]` (reset-to-default) is skipped. Names are trimmed.
*/
export function parseCastNames(text) {
if (!text) return [];
const seen = new Set();
const names = [];
const re = new RegExp(VOICE_RE.source, VOICE_RE.flags);
let m;
while ((m = re.exec(text)) !== null) {
const name = (m[1] || '').trim();
if (re.lastIndex === m.index) re.lastIndex++; // zero-width guard
if (!name || seen.has(name)) continue;
seen.add(name);
names.push(name);
}
return names;
}
/** Text with every bracket markup token removed (for a spoken-word count). */
function stripMarkup(text) {
return (text || '').replace(BRACKET_RE, ' ');
}
/**
* Live script stats: chapters (`# ` H1 count, 1 so a title-less script still
* reads as one chapter), spoken word count (whitespace split, markup stripped),
* and an estimated runtime in seconds at {@link AUDIOBOOK_WPM}.
*/
export function scriptStats(text) {
const norm = (text || '').replace(/\r\n?/g, '\n');
const headings = norm.match(new RegExp(HEADING_RE.source, HEADING_RE.flags)) || [];
// Spoken words only: drop `# heading` lines (titles aren't narrated as body,
// mirroring the parser) AND bracket markup, then whitespace-split.
const spoken = stripMarkup(norm.replace(new RegExp(HEADING_RE.source, HEADING_RE.flags), ' '));
const words = spoken.split(/\s+/).filter(Boolean).length;
const chapters = Math.max(1, headings.length);
const runtimeSec = words > 0 ? (words / AUDIOBOOK_WPM) * 60 : 0;
return { chapters, words, runtimeSec };
}
/** Format seconds as a clock string: `H:MM` (≥1h) or `M:SS` (<1h). Pure. */
export function formatRuntimeClock(sec) {
const total = Math.max(0, Math.round(sec));
const h = Math.floor(total / 3600);
const m = Math.floor((total % 3600) / 60);
const s = total % 60;
if (h > 0) return `${h}:${String(m).padStart(2, '0')}`;
return `${m}:${String(s).padStart(2, '0')}`;
}
/**
* Pre-flight warnings for a script (non-blocking hints). Returns an array of
* `{ type, ... }`:
* - `unknown_voice` `{ name }` a `[voice:NAME]` neither mapped in the cast
* nor an exact profile id will use the default voice.
* - `empty_chapter` `{ title }` a `# heading` with no spoken body.
* - `unknown_tag` `{ tag }` a bracket token outside the known grammar
* (voice/pause/SSML-lite/reactions) read aloud literally.
*
* @param {string} text
* @param {object} opts
* @param {Set<string>|string[]} [opts.mappedNames] cast names with a voice mapped
* @param {Set<string>|string[]} [opts.profileIds] known profile ids (exact match)
*/
export function validateScript(text, { mappedNames = [], profileIds = [] } = {}) {
const warnings = [];
const norm = (text || '').replace(/\r\n?/g, '\n');
const mapped = mappedNames instanceof Set ? mappedNames : new Set(mappedNames);
const ids = profileIds instanceof Set ? profileIds : new Set(profileIds);
// 1. Unknown voices — a name with no cast mapping and no exact profile match.
for (const name of parseCastNames(norm)) {
if (!mapped.has(name) && !ids.has(name)) warnings.push({ type: 'unknown_voice', name });
}
// 2. Empty chapters — a `# heading` whose body has no spoken text. Split on
// headings (mirroring the parser) and check each body for any word.
const heads = [];
const hre = new RegExp(HEADING_RE.source, HEADING_RE.flags);
let hm;
while ((hm = hre.exec(norm)) !== null) {
heads.push({ index: hm.index, end: hm.index + hm[0].length, title: (hm[1] || '').trim() });
if (hre.lastIndex === hm.index) hre.lastIndex++;
}
for (let i = 0; i < heads.length; i++) {
const bodyEnd = i + 1 < heads.length ? heads[i + 1].index : norm.length;
const body = norm.slice(heads[i].end, bodyEnd);
if (!stripMarkup(body).trim()) warnings.push({ type: 'empty_chapter', title: heads[i].title });
}
// 3. Unrecognized bracket tokens — anything outside the known grammar.
const seenTags = new Set();
const bre = new RegExp(BRACKET_RE.source, BRACKET_RE.flags);
let bm;
while ((bm = bre.exec(norm)) !== null) {
const tok = bm[0];
if (bre.lastIndex === bm.index) bre.lastIndex++;
const lower = tok.toLowerCase();
const known =
VOICE_RE.test(tok) ||
PAUSE_TOKEN_RE.test(tok) ||
SSML_TOKEN_RE.test(tok) ||
REACTION_TOKENS.has(lower);
VOICE_RE.lastIndex = 0; // test() advances a /g regex — reset it
if (!known && !seenTags.has(lower)) {
seenTags.add(lower);
warnings.push({ type: 'unknown_tag', tag: tok });
}
}
return warnings;
}
+1 -1
View File
@@ -27,7 +27,7 @@ def _resolve(_voice_id):
def _stub_build_synth():
def _factory(default_voice=None, language=None, opts=None):
def _factory(default_voice=None, language=None, opts=None, voice_map=None):
def synth(text, voice_id, speed=None):
return torch.zeros(2400) # 0.1s @ 24k, 1-D float32
return {"mode": "generic", "resolve": _resolve, "engine_id": "stub",
+203
View File
@@ -0,0 +1,203 @@
"""Audiobook multi-voice cast mapping (#1217).
The headline fix: ``[voice:NAME]`` used to be handed to ``_resolve_voice`` as if
NAME were a profile id it never matched (profile ids are UUIDs), so every
``[voice:]`` silently rendered in the engine default and a multi-voice book was
mono-voiced. A book now carries a ``voice_map`` (NAME profile id); this suite
pins the resolution, the silent-default fix for unmapped names, exact-id
back-compat, and the CRITICAL cache-signature guard (remapping must re-render;
an absent map must keep today's byte-identical keys).
Engine + DB boundary stubbed throughout no model loads, no GPU, no ffmpeg.
"""
import os
os.environ.setdefault("OMNIVOICE_MODEL", "test")
os.environ.setdefault("OMNIVOICE_DISABLE_FILE_LOG", "1")
import torch
from services.audiobook import (
Chapter,
ExpressiveOptions,
Span,
voice_map_signature,
)
_PID = "11111111-2222-3333-4444-555555555555"
# ── voice_map_signature: the CRITICAL TRAP guard ────────────────────────────
def test_absent_or_empty_map_has_empty_signature():
# An absent/empty map must be byte-identical to pre-#1217: no signature, so
# no perturbation of any cache key — existing books never re-render.
assert voice_map_signature(None) == ""
assert voice_map_signature({}) == ""
def test_map_produces_a_signature_that_changes_with_content():
a = voice_map_signature({"Mara": _PID})
b = voice_map_signature({"Mara": "other-pid"})
c = voice_map_signature({"Mara": _PID, "Cole": "pid2"})
assert a and b and c
assert len({a, b, c}) == 3
# Order-independent (canonical JSON) — same map, same key.
assert voice_map_signature({"Cole": "pid2", "Mara": _PID}) == c
# ── name → profile resolution through the resolve closure ───────────────────
def _generic_synth_recording(monkeypatch):
"""A generic backend + a recording ``_resolve_voice`` — returns (build, seen)
where ``seen`` collects every profile id resolution was asked for."""
import api.routers.audiobook as ab
import services.tts_backend as tb
from services.tts_backend import TTSBackend
class _Fake(TTSBackend):
id = "fake-cast-engine"
display_name = "Fake Cast Engine (test)"
gpu_compat = ("cpu",)
@property
def sample_rate(self):
return 24000
@property
def supported_languages(self):
return ["multi"]
@classmethod
def is_available(cls):
return True, "ready"
def generate(self, text, **kw):
return torch.zeros(1, 2400)
monkeypatch.setattr(tb, "active_backend_id", lambda: "fake-cast-engine")
monkeypatch.setattr(tb, "get_backend_class", lambda _id: _Fake)
seen = []
def fake_resolve(pid):
seen.append(pid)
return {"ref_audio": None, "ref_text": None, "instruct": None, "seed": None}
monkeypatch.setattr(ab, "_resolve_voice", fake_resolve)
return ab, seen
def test_named_voice_resolves_through_the_map(monkeypatch):
# [voice:Mara] with voice_map={"Mara": <pid>} must resolve to that profile.
ab, seen = _generic_synth_recording(monkeypatch)
info = ab._build_synth("default-pid", voice_map={"Mara": _PID})
info["synth"]("hello", "Mara")
assert seen == [_PID]
def test_unmapped_name_falls_back_to_default_voice(monkeypatch):
# Without a map, a bare NAME is NOT a real profile id → must fall back to
# default_voice (the silent-default bug fix), NOT be treated as a literal id.
ab, seen = _generic_synth_recording(monkeypatch)
monkeypatch.setattr(ab, "_voice_profile_exists", lambda _pid: False)
info = ab._build_synth("default-pid", voice_map=None)
info["synth"]("hello", "Mara")
assert seen == ["default-pid"]
def test_exact_profile_id_still_resolves_as_itself(monkeypatch):
# An unmapped token that IS a real profile id (someone passed an exact id,
# e.g. a Stories span) must resolve unchanged — exact-id back-compat.
ab, seen = _generic_synth_recording(monkeypatch)
monkeypatch.setattr(ab, "_voice_profile_exists", lambda pid: pid == _PID)
info = ab._build_synth("default-pid", voice_map=None)
info["synth"]("hello", _PID)
assert seen == [_PID]
def test_none_voice_uses_default_without_a_db_probe(monkeypatch):
# A run with no [voice:] (None) resolves to default_voice and must never hit
# the profile-existence DB probe.
ab, seen = _generic_synth_recording(monkeypatch)
def _boom(_pid): # would fire only if None wrongly reached the probe
raise AssertionError("None must not probe the DB")
monkeypatch.setattr(ab, "_voice_profile_exists", _boom)
info = ab._build_synth("default-pid", voice_map={"Mara": _PID})
info["synth"]("hello", None)
assert seen == ["default-pid"]
# ── chapter / segment / preview cache keys absorb the voice map ─────────────
_RESOLVE = lambda _vid: { # noqa: E731
"ref_audio": None, "ref_text": None, "instruct": None, "seed": None,
}
def _render_key(tmp_path, voice_map):
"""Render a one-span [voice:Mara] chapter with a stub synth (no models);
return the content-addressed chapter cache key (the WAV basename)."""
from api.routers.audiobook import _render_chapter_cached
ch = Chapter(title="C", spans=[Span(voice_id="Mara", text="hello", pause_ms_after=0)])
synth = lambda text, vid, speed=None: torch.zeros(2400) # noqa: E731
wav_path, *_ = _render_chapter_cached(
ch, synth, 24000, "eng", _RESOLVE, str(tmp_path), None, None,
ExpressiveOptions(), voice_map,
)
return os.path.basename(wav_path)
def test_chapter_cache_key_changes_when_the_map_changes(tmp_path):
base = _render_key(tmp_path, None) # no map
empty = _render_key(tmp_path, {}) # empty map == no map
a = _render_key(tmp_path, {"Mara": _PID}) # mapped one way
b = _render_key(tmp_path, {"Mara": "other-pid"}) # remapped
assert empty == base, "an absent/empty map must keep today's cache key"
assert a != base, "adding a mapping must re-render"
assert b != a, "remapping a voice must re-render"
def test_segment_extra_sig_absorbs_the_voice_map():
# The inner segment cache keys on ``extra_sig`` — the same string the chapter
# render folds the voice-map signature into. Two otherwise-identical segments
# that differ only by mapping must land on distinct segment keys, and an
# empty map must leave the key byte-identical to the no-map derivation.
from services.longform_render import segment_cache_key
def seg_extra(voice_map):
vmap = voice_map_signature(voice_map)
return f"\x00{vmap}" if vmap else ""
def key(voice_map):
return segment_cache_key("hello", sample_rate=24000, engine_id="eng",
voice_id="Mara", extra_sig=seg_extra(voice_map))
base = key(None)
assert key({}) == base # empty map == today's key
assert key({"Mara": _PID}) != base # a mapping re-renders
assert key({"Mara": _PID}) != key({"Mara": "other"}) # remap re-renders
# ── render / preview parity ─────────────────────────────────────────────────
def test_preview_and_render_requests_carry_and_key_on_the_same_map(tmp_path):
from api.routers.audiobook import AudiobookPreviewRequest, AudiobookRequest
vm = {"Mara": _PID, "Cole": "pid2"}
r = AudiobookRequest(text="# A\n[voice:Mara] hi", voice_map=vm)
p = AudiobookPreviewRequest(text="# A\n[voice:Mara] hi", voice_map=vm)
assert r.voice_map == p.voice_map == vm
# …and therefore the same chapter cache slot (preview warms what render reuses).
assert _render_key(tmp_path, r.voice_map) == _render_key(tmp_path, p.voice_map)
def test_default_request_omits_the_map(tmp_path):
from api.routers.audiobook import AudiobookRequest
# An untouched request has no map → today's exact key (no re-render).
assert AudiobookRequest(text="# A\nhi").voice_map is None
assert _render_key(tmp_path, None) == _render_key(tmp_path, {})
+1 -1
View File
@@ -39,7 +39,7 @@ def _resolve(_voice_id):
def _stub_build_synth(*, fail_on=None):
"""Return a drop-in for `audiobook._build_synth` whose `synth` emits 0.1s of
silence per span. `fail_on(text)` raise, to exercise per-chapter faults."""
def _factory(default_voice=None, language=None, opts=None):
def _factory(default_voice=None, language=None, opts=None, voice_map=None):
def synth(text, voice_id, speed=None):
if fail_on is not None and fail_on(text):
raise RuntimeError("stub synth deliberately failed")
+1 -1
View File
@@ -349,7 +349,7 @@ def test_audiobook_preview_route_output_is_watermarked(tmp_path, monkeypatch, ma
outdir.mkdir()
monkeypatch.setattr(cfg, "OUTPUTS_DIR", str(outdir))
async def _fake_prepare(default_voice, language=None, opts=None):
async def _fake_prepare(default_voice, language=None, opts=None, voice_map=None):
return _fake_synth, SR, _resolve, "eng"
monkeypatch.setattr(ab, "_prepare_synth", _fake_prepare)