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:
@@ -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)
|
||||
|
||||
@@ -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,
|
||||
),
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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 مقسم.",
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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é.",
|
||||
|
||||
@@ -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 ऑडियोबुक में बदलें।",
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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 オーディオブックに変換します。",
|
||||
|
||||
@@ -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 오디오북으로 바꿔보세요.",
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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 с разделением на главы.",
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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 有声读物。",
|
||||
|
||||
@@ -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 有聲書。",
|
||||
|
||||
@@ -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 name→profile 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}
|
||||
|
||||
@@ -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,
|
||||
}));
|
||||
|
||||
@@ -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 script’s 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();
|
||||
});
|
||||
});
|
||||
@@ -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([]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,142 @@
|
||||
/**
|
||||
* Audiobook script helpers (#1217) — pure, testable text→data 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;
|
||||
}
|
||||
@@ -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",
|
||||
|
||||
@@ -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, {})
|
||||
@@ -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")
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user