From 573510409b5cbd3e021026f2cd077948c0b7c040 Mon Sep 17 00:00:00 2001 From: Sharad Raj Date: Wed, 8 Jul 2026 19:46:47 +0530 Subject: [PATCH 1/3] Feature/voice preview (#1) * feat: add voice preview feature for audiobook generation and settings modal * test: add preview route assertion to OpenAPI contract test * style: format all files with prettier and ruff --- alembic/env.py | 1 + .../versions/9cbf7db8429b_initial_schema.py | 182 +++++++++--------- frontend/src/App.tsx | 125 ++++++++---- .../src/__tests__/GenerationForm.test.tsx | 4 +- frontend/src/__tests__/api.test.ts | 23 ++- frontend/src/api/chunking.ts | 16 +- frontend/src/api/client.ts | 19 ++ frontend/src/components/GenerationForm.tsx | 53 ++++- frontend/src/components/Modal.tsx | 8 +- frontend/src/components/SettingsModal.tsx | 85 +++++--- frontend/src/components/VoiceSelector.tsx | 78 +++++--- frontend/src/hooks/useVoicePreview.ts | 71 +++++++ frontend/src/styles.css | 126 ++++++++++-- src/awaaz/__init__.py | 1 - src/awaaz/adapters/__init__.py | 1 - src/awaaz/adapters/base.py | 1 - src/awaaz/adapters/registry.py | 1 - src/awaaz/api.py | 99 ++++++++-- src/awaaz/db.py | 3 + src/awaaz/domain/chunking.py | 1 - src/awaaz/domain/exceptions.py | 1 - src/awaaz/domain/progress.py | 1 - src/awaaz/main.py | 1 - src/awaaz/models.py | 12 +- src/awaaz/schemas.py | 1 - src/awaaz/services/__init__.py | 1 - src/awaaz/services/documents.py | 8 +- src/awaaz/services/files.py | 1 - src/awaaz/services/queue.py | 14 +- tests/test_adapters.py | 1 - tests/test_api_contract.py | 2 +- tests/test_api_preview.py | 54 ++++++ tests/test_chunking.py | 1 - tests/test_collection_names.py | 8 +- tests/test_files.py | 1 - tests/test_progress.py | 1 - tests/test_queue_recovery.py | 1 - tests/test_schema_collection_names.py | 4 +- tests/test_services.py | 16 +- tests/test_settings.py | 1 - uv.lock | 2 +- 41 files changed, 770 insertions(+), 260 deletions(-) create mode 100644 frontend/src/hooks/useVoicePreview.ts create mode 100644 tests/test_api_preview.py diff --git a/alembic/env.py b/alembic/env.py index c959246..1e52932 100644 --- a/alembic/env.py +++ b/alembic/env.py @@ -7,6 +7,7 @@ calling code may pass an existing connection via ``config.attributes[ "connection"]`` to share a single transaction. """ + import asyncio from logging.config import fileConfig diff --git a/alembic/versions/9cbf7db8429b_initial_schema.py b/alembic/versions/9cbf7db8429b_initial_schema.py index 5cdc80f..98d6624 100644 --- a/alembic/versions/9cbf7db8429b_initial_schema.py +++ b/alembic/versions/9cbf7db8429b_initial_schema.py @@ -1,10 +1,11 @@ """initial schema Revision ID: 9cbf7db8429b -Revises: +Revises: Create Date: 2026-06-27 20:22:36.367761 """ + from collections.abc import Sequence import sqlalchemy as sa @@ -13,7 +14,7 @@ from alembic import op # revision identifiers, used by Alembic. -revision: str = '9cbf7db8429b' +revision: str = "9cbf7db8429b" down_revision: str | None = None branch_labels: str | Sequence[str] | None = None depends_on: str | Sequence[str] | None = None @@ -21,105 +22,110 @@ def upgrade() -> None: # ### commands auto generated by Alembic - please adjust! ### - op.create_table('collections', - sa.Column('id', sa.String(length=36), nullable=False), - sa.Column('name', sa.String(length=255), nullable=False), - sa.Column('created_at', sa.DateTime(timezone=True), nullable=False), - sa.Column('updated_at', sa.DateTime(timezone=True), nullable=False), - sa.PrimaryKeyConstraint('id') + op.create_table( + "collections", + sa.Column("id", sa.String(length=36), nullable=False), + sa.Column("name", sa.String(length=255), nullable=False), + sa.Column("created_at", sa.DateTime(timezone=True), nullable=False), + sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False), + sa.PrimaryKeyConstraint("id"), ) - with op.batch_alter_table('collections', schema=None) as batch_op: - batch_op.create_index(batch_op.f('ix_collections_name'), ['name'], unique=True) - - op.create_table('documents', - sa.Column('id', sa.String(length=36), nullable=False), - sa.Column('title', sa.String(length=255), nullable=False), - sa.Column('source_filename', sa.String(length=255), nullable=True), - sa.Column('text', sa.Text(), nullable=False), - sa.Column('revision', sa.Integer(), nullable=False), - sa.Column('author', sa.String(length=255), nullable=True), - sa.Column('series', sa.String(length=255), nullable=True), - sa.Column('tags', sa.String(length=500), nullable=True), - sa.Column('cover_path', sa.Text(), nullable=True), - sa.Column('metadata_json', sqlite.JSON(), nullable=True), - sa.Column('word_count', sa.Integer(), nullable=False), - sa.Column('created_at', sa.DateTime(timezone=True), nullable=False), - sa.Column('updated_at', sa.DateTime(timezone=True), nullable=False), - sa.PrimaryKeyConstraint('id') + with op.batch_alter_table("collections", schema=None) as batch_op: + batch_op.create_index(batch_op.f("ix_collections_name"), ["name"], unique=True) + + op.create_table( + "documents", + sa.Column("id", sa.String(length=36), nullable=False), + sa.Column("title", sa.String(length=255), nullable=False), + sa.Column("source_filename", sa.String(length=255), nullable=True), + sa.Column("text", sa.Text(), nullable=False), + sa.Column("revision", sa.Integer(), nullable=False), + sa.Column("author", sa.String(length=255), nullable=True), + sa.Column("series", sa.String(length=255), nullable=True), + sa.Column("tags", sa.String(length=500), nullable=True), + sa.Column("cover_path", sa.Text(), nullable=True), + sa.Column("metadata_json", sqlite.JSON(), nullable=True), + sa.Column("word_count", sa.Integer(), nullable=False), + sa.Column("created_at", sa.DateTime(timezone=True), nullable=False), + sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False), + sa.PrimaryKeyConstraint("id"), ) - with op.batch_alter_table('documents', schema=None) as batch_op: - batch_op.create_index(batch_op.f('ix_documents_author'), ['author'], unique=False) - batch_op.create_index(batch_op.f('ix_documents_series'), ['series'], unique=False) - - op.create_table('document_collections', - sa.Column('document_id', sa.String(length=36), nullable=False), - sa.Column('collection_id', sa.String(length=36), nullable=False), - sa.ForeignKeyConstraint(['collection_id'], ['collections.id'], ondelete='CASCADE'), - sa.ForeignKeyConstraint(['document_id'], ['documents.id'], ondelete='CASCADE'), - sa.PrimaryKeyConstraint('document_id', 'collection_id') + with op.batch_alter_table("documents", schema=None) as batch_op: + batch_op.create_index(batch_op.f("ix_documents_author"), ["author"], unique=False) + batch_op.create_index(batch_op.f("ix_documents_series"), ["series"], unique=False) + + op.create_table( + "document_collections", + sa.Column("document_id", sa.String(length=36), nullable=False), + sa.Column("collection_id", sa.String(length=36), nullable=False), + sa.ForeignKeyConstraint(["collection_id"], ["collections.id"], ondelete="CASCADE"), + sa.ForeignKeyConstraint(["document_id"], ["documents.id"], ondelete="CASCADE"), + sa.PrimaryKeyConstraint("document_id", "collection_id"), ) - op.create_table('jobs', - sa.Column('id', sa.String(length=36), nullable=False), - sa.Column('document_id', sa.String(length=36), nullable=False), - sa.Column('document_revision', sa.Integer(), nullable=False), - sa.Column('status', sa.String(length=32), nullable=False), - sa.Column('backend', sa.String(length=32), nullable=False), - sa.Column('model', sa.String(length=255), nullable=False), - sa.Column('voice', sa.String(length=255), nullable=False), - sa.Column('speed', sa.Float(), nullable=False), - sa.Column('chunking_mode', sa.String(length=32), nullable=False), - sa.Column('character_limit', sa.Integer(), nullable=False), - sa.Column('error', sa.Text(), nullable=True), - sa.Column('output_path', sa.Text(), nullable=True), - sa.Column('created_at', sa.DateTime(timezone=True), nullable=False), - sa.Column('updated_at', sa.DateTime(timezone=True), nullable=False), - sa.ForeignKeyConstraint(['document_id'], ['documents.id'], ondelete='CASCADE'), - sa.PrimaryKeyConstraint('id') + op.create_table( + "jobs", + sa.Column("id", sa.String(length=36), nullable=False), + sa.Column("document_id", sa.String(length=36), nullable=False), + sa.Column("document_revision", sa.Integer(), nullable=False), + sa.Column("status", sa.String(length=32), nullable=False), + sa.Column("backend", sa.String(length=32), nullable=False), + sa.Column("model", sa.String(length=255), nullable=False), + sa.Column("voice", sa.String(length=255), nullable=False), + sa.Column("speed", sa.Float(), nullable=False), + sa.Column("chunking_mode", sa.String(length=32), nullable=False), + sa.Column("character_limit", sa.Integer(), nullable=False), + sa.Column("error", sa.Text(), nullable=True), + sa.Column("output_path", sa.Text(), nullable=True), + sa.Column("created_at", sa.DateTime(timezone=True), nullable=False), + sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False), + sa.ForeignKeyConstraint(["document_id"], ["documents.id"], ondelete="CASCADE"), + sa.PrimaryKeyConstraint("id"), ) - with op.batch_alter_table('jobs', schema=None) as batch_op: - batch_op.create_index(batch_op.f('ix_jobs_status'), ['status'], unique=False) - - op.create_table('chunks', - sa.Column('id', sa.String(length=36), nullable=False), - sa.Column('job_id', sa.String(length=36), nullable=False), - sa.Column('position', sa.Integer(), nullable=False), - sa.Column('text', sa.Text(), nullable=False), - sa.Column('status', sa.String(length=32), nullable=False), - sa.Column('attempts', sa.Integer(), nullable=False), - sa.Column('audio_path', sa.Text(), nullable=True), - sa.Column('error', sa.Text(), nullable=True), - sa.Column('created_at', sa.DateTime(timezone=True), nullable=False), - sa.Column('updated_at', sa.DateTime(timezone=True), nullable=False), - sa.ForeignKeyConstraint(['job_id'], ['jobs.id'], ondelete='CASCADE'), - sa.PrimaryKeyConstraint('id'), - sa.UniqueConstraint('job_id', 'position') + with op.batch_alter_table("jobs", schema=None) as batch_op: + batch_op.create_index(batch_op.f("ix_jobs_status"), ["status"], unique=False) + + op.create_table( + "chunks", + sa.Column("id", sa.String(length=36), nullable=False), + sa.Column("job_id", sa.String(length=36), nullable=False), + sa.Column("position", sa.Integer(), nullable=False), + sa.Column("text", sa.Text(), nullable=False), + sa.Column("status", sa.String(length=32), nullable=False), + sa.Column("attempts", sa.Integer(), nullable=False), + sa.Column("audio_path", sa.Text(), nullable=True), + sa.Column("error", sa.Text(), nullable=True), + sa.Column("created_at", sa.DateTime(timezone=True), nullable=False), + sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False), + sa.ForeignKeyConstraint(["job_id"], ["jobs.id"], ondelete="CASCADE"), + sa.PrimaryKeyConstraint("id"), + sa.UniqueConstraint("job_id", "position"), ) - with op.batch_alter_table('chunks', schema=None) as batch_op: - batch_op.create_index(batch_op.f('ix_chunks_job_id'), ['job_id'], unique=False) - batch_op.create_index(batch_op.f('ix_chunks_status'), ['status'], unique=False) + with op.batch_alter_table("chunks", schema=None) as batch_op: + batch_op.create_index(batch_op.f("ix_chunks_job_id"), ["job_id"], unique=False) + batch_op.create_index(batch_op.f("ix_chunks_status"), ["status"], unique=False) # ### end Alembic commands ### def downgrade() -> None: # ### commands auto generated by Alembic - please adjust! ### - with op.batch_alter_table('chunks', schema=None) as batch_op: - batch_op.drop_index(batch_op.f('ix_chunks_status')) - batch_op.drop_index(batch_op.f('ix_chunks_job_id')) + with op.batch_alter_table("chunks", schema=None) as batch_op: + batch_op.drop_index(batch_op.f("ix_chunks_status")) + batch_op.drop_index(batch_op.f("ix_chunks_job_id")) - op.drop_table('chunks') - with op.batch_alter_table('jobs', schema=None) as batch_op: - batch_op.drop_index(batch_op.f('ix_jobs_status')) + op.drop_table("chunks") + with op.batch_alter_table("jobs", schema=None) as batch_op: + batch_op.drop_index(batch_op.f("ix_jobs_status")) - op.drop_table('jobs') - op.drop_table('document_collections') - with op.batch_alter_table('documents', schema=None) as batch_op: - batch_op.drop_index(batch_op.f('ix_documents_series')) - batch_op.drop_index(batch_op.f('ix_documents_author')) + op.drop_table("jobs") + op.drop_table("document_collections") + with op.batch_alter_table("documents", schema=None) as batch_op: + batch_op.drop_index(batch_op.f("ix_documents_series")) + batch_op.drop_index(batch_op.f("ix_documents_author")) - op.drop_table('documents') - with op.batch_alter_table('collections', schema=None) as batch_op: - batch_op.drop_index(batch_op.f('ix_collections_name')) + op.drop_table("documents") + with op.batch_alter_table("collections", schema=None) as batch_op: + batch_op.drop_index(batch_op.f("ix_collections_name")) - op.drop_table('collections') + op.drop_table("collections") # ### end Alembic commands ### diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index def1dd8..f39da73 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -117,7 +117,9 @@ export default function App() { const [view, setView] = useState('library'); const [filter, setFilter] = useState(null); const [selectedCollectionId, setSelectedCollectionId] = useState(null); - const [chipCategory, setChipCategory] = useState<'all' | 'collections' | 'authors' | 'series' | 'tags'>('all'); + const [chipCategory, setChipCategory] = useState< + 'all' | 'collections' | 'authors' | 'series' | 'tags' + >('all'); const [chipFilter, setChipFilter] = useState(''); const [selectedDocId, setSelectedDocId] = useState(null); const [draft, setDraft] = useState(''); @@ -129,7 +131,9 @@ export default function App() { const [sidebarOpen, setSidebarOpen] = useState(false); const [managingCollectionId, setManagingCollectionId] = useState(null); const [creatingCollection, setCreatingCollection] = useState(false); - const [renamingCollection, setRenamingCollection] = useState<{ id: string; name: string } | null>(null); + const [renamingCollection, setRenamingCollection] = useState<{ id: string; name: string } | null>( + null, + ); const [settingsOpen, setSettingsOpen] = useState(false); const [voicePreferences, setVoicePreferences] = useState>(() => { try { @@ -266,7 +270,9 @@ export default function App() { const handleJobAction = (jobId: string, action: JobAction) => { void run(async () => { const updated = await controlJob(jobId, action); - setJobs((current) => current.map((jobItem) => (jobItem.id === updated.id ? updated : jobItem))); + setJobs((current) => + current.map((jobItem) => (jobItem.id === updated.id ? updated : jobItem)), + ); }); }; @@ -408,7 +414,13 @@ export default function App() { onClick={() => setSidebarOpen(true)} aria-label="Open menu" > -
+
handleSelectCollection(collection.id)} @@ -590,8 +612,18 @@ export default function App() { setManagingCollectionId(collection.id); }} > - Made with ♥ by{' '} - + Sharad Raj Singh Maurya @@ -706,12 +734,12 @@ export default function App() { setChipFilter(event.target.value)} - /> -
- )} + placeholder={`Filter ${chipCategory}…`} + value={chipFilter} + onChange={(event) => setChipFilter(event.target.value)} + /> +
+ )}
{chipCategory !== 'all' && (
@@ -899,7 +927,13 @@ function BookRow({ document: doc, job, onClick }: BookRowProps) { {description && {description}} {job && job.status !== 'cancelled' && (
- + {percent}% @@ -952,7 +986,13 @@ function DetailModal({
event.stopPropagation()}> @@ -1017,7 +1057,11 @@ function DetailModal({ {draft.trim() ? draft.trim().split(/\s+/).length.toLocaleString() : 0} words - {draft.split(/\n\s*\n/).filter(Boolean).length.toLocaleString()} paragraphs + {draft + .split(/\n\s*\n/) + .filter(Boolean) + .length.toLocaleString()}{' '} + paragraphs
@@ -1039,12 +1083,7 @@ function DetailModal({

Jobs for this title

{documentJobs.map((job) => ( - + ))}
@@ -1071,7 +1110,13 @@ function UploadModal({ busy, onClose, onCreateText, onUpload }: UploadModalProps

Import an EPUB/TXT file or paste raw text.

@@ -1138,7 +1183,13 @@ function CollectionCreateModal({ busy, onClose, onCreate }: CollectionCreateModa ); } -function CollectionManageModal({ collection, documents, busy, onClose, onSave }: CollectionManageModalProps) { +function CollectionManageModal({ + collection, + documents, + busy, + onClose, + onSave, +}: CollectionManageModalProps) { const [search, setSearch] = useState(''); const [selectedIds, setSelectedIds] = useState>( () => @@ -1213,9 +1264,7 @@ function CollectionManageModal({ collection, documents, busy, onClose, onSave }: onChange={() => toggle(document.id)} /> {document.title} - {document.author && ( - {document.author} - )} + {document.author && {document.author}} )) )} diff --git a/frontend/src/__tests__/GenerationForm.test.tsx b/frontend/src/__tests__/GenerationForm.test.tsx index 8352ed9..2bf4ee2 100644 --- a/frontend/src/__tests__/GenerationForm.test.tsx +++ b/frontend/src/__tests__/GenerationForm.test.tsx @@ -17,7 +17,9 @@ const backends: Backend[] = [ test('submits selected generation settings', async () => { const onSubmit = jest.fn(); - render(); + render( + , + ); await screen.findByRole('combobox', { name: 'Voice' }); diff --git a/frontend/src/__tests__/api.test.ts b/frontend/src/__tests__/api.test.ts index 50d1dea..1b45310 100644 --- a/frontend/src/__tests__/api.test.ts +++ b/frontend/src/__tests__/api.test.ts @@ -1,4 +1,4 @@ -import { ApiError, createTextDocument, listBackends } from '../api/client'; +import { ApiError, createTextDocument, listBackends, previewVoice } from '../api/client'; beforeEach(() => { globalThis.fetch = jest.fn(); @@ -32,3 +32,24 @@ test('surfaces backend problem detail', async () => { await expect(listBackends()).rejects.toEqual(new ApiError('backend unavailable', 503)); }); + +test('requests voice preview through versioned API', async () => { + const mockBlob = new Blob(['audio_data'], { type: 'audio/mpeg' }); + jest.mocked(globalThis.fetch).mockResolvedValue({ + ok: true, + status: 200, + blob: async () => mockBlob, + } as Response); + + await expect( + previewVoice('kokoro', { voice: 'af_bella', model: 'kokoro', speed: 1.0, text: 'Hello' }), + ).resolves.toBe(mockBlob); + + expect(globalThis.fetch).toHaveBeenCalledWith( + '/api/v1/backends/kokoro/preview', + expect.objectContaining({ + method: 'POST', + body: JSON.stringify({ voice: 'af_bella', model: 'kokoro', speed: 1.0, text: 'Hello' }), + }), + ); +}); diff --git a/frontend/src/api/chunking.ts b/frontend/src/api/chunking.ts index 4035dfc..e4c1552 100644 --- a/frontend/src/api/chunking.ts +++ b/frontend/src/api/chunking.ts @@ -19,9 +19,9 @@ function splitAtWords(text: string, limit: number): string[] { if (words.some((word) => word.length > limit)) { // A single word exceeds the limit; the backend raises an error here, // but for preview purposes we just count it as one chunk. - return words.filter((word) => word.length <= limit).concat( - words.filter((word) => word.length > limit).map((word) => word.slice(0, limit)), - ); + return words + .filter((word) => word.length <= limit) + .concat(words.filter((word) => word.length > limit).map((word) => word.slice(0, limit))); } const chunks: string[] = []; let current = ''; @@ -66,11 +66,17 @@ export function countChunks(text: string, mode: ChunkingMode, characterLimit: nu if (mode === 'whole') return 1; if (mode === 'line') { - return normalized.split('\n').map((line) => line.trim()).filter(Boolean).length; + return normalized + .split('\n') + .map((line) => line.trim()) + .filter(Boolean).length; } if (mode === 'paragraph') { - return normalized.split(PARAGRAPH_BOUNDARY).map((part) => part.trim()).filter(Boolean).length; + return normalized + .split(PARAGRAPH_BOUNDARY) + .map((part) => part.trim()) + .filter(Boolean).length; } if (mode === 'sentence') { diff --git a/frontend/src/api/client.ts b/frontend/src/api/client.ts index d3b0a16..9fb120c 100644 --- a/frontend/src/api/client.ts +++ b/frontend/src/api/client.ts @@ -82,6 +82,25 @@ export function listBackendVoices(name: string): Promise<{ backend: string; voic return request(`/api/v1/backends/${name}/voices`); } +export async function previewVoice( + backendName: string, + params: { voice: string; model: string; speed?: number; text?: string }, +): Promise { + const response = await fetch(`/api/v1/backends/${backendName}/preview`, { + method: 'POST', + headers: jsonHeaders, + body: JSON.stringify(params), + }); + if (!response.ok) { + const body = (await response.json().catch(() => ({}))) as { detail?: string }; + throw new ApiError( + body.detail ?? `Preview failed with status ${response.status}`, + response.status, + ); + } + return response.blob(); +} + export function createTextDocument(title: string, text: string): Promise { return request('/api/v1/documents', { method: 'POST', diff --git a/frontend/src/components/GenerationForm.tsx b/frontend/src/components/GenerationForm.tsx index 4942e01..22810a7 100644 --- a/frontend/src/components/GenerationForm.tsx +++ b/frontend/src/components/GenerationForm.tsx @@ -4,6 +4,7 @@ import { listBackendVoices } from '../api/client'; import { countChunks } from '../api/chunking'; import type { Backend, ChunkingMode, JobRequest } from '../types'; import { VoiceSelector } from './VoiceSelector'; +import { useVoicePreview } from '../hooks/useVoicePreview'; interface GenerationFormProps { backends: Backend[]; @@ -25,7 +26,13 @@ function modeDetail(value: ChunkingMode): string { return modes.find((item) => item.value === value)?.detail ?? ''; } -export function GenerationForm({ backends, disabled, onSubmit, text, voicePreferences }: GenerationFormProps) { +export function GenerationForm({ + backends, + disabled, + onSubmit, + text, + voicePreferences, +}: GenerationFormProps) { const [backendName, setBackendName] = useState(''); const [model, setModel] = useState(''); const [voice, setVoice] = useState(''); @@ -33,6 +40,9 @@ export function GenerationForm({ backends, disabled, onSubmit, text, voicePrefer const [mode, setMode] = useState('paragraph'); const [characterLimit, setCharacterLimit] = useState(1000); const [voices, setVoices] = useState([]); + const [previewText, setPreviewText] = useState(''); + + const { previewState, error: previewError, play, stop } = useVoicePreview(); const backend = useMemo( () => backends.find((item) => item.name === backendName) ?? backends[0], @@ -42,7 +52,9 @@ export function GenerationForm({ backends, disabled, onSubmit, text, voicePrefer // Character limit only applies in "character" mode. Other modes use the // backend maximum so semantic units are preserved. const limitVisible = mode === 'character'; - const effectiveLimit = limitVisible ? characterLimit : (backend?.max_characters ?? characterLimit); + const effectiveLimit = limitVisible + ? characterLimit + : (backend?.max_characters ?? characterLimit); useEffect(() => { if (!backend) return; @@ -74,10 +86,27 @@ export function GenerationForm({ backends, disabled, onSubmit, text, voicePrefer }; }, [backend]); + useEffect(() => { + stop(); + }, [backendName, model, voice, speed, stop]); + if (!backend) return

No TTS backend configured.

; const chunkCount = countChunks(text, mode, effectiveLimit); + const handlePreview = () => { + if (previewState === 'playing') { + stop(); + } else { + void play(backend.name, { + voice, + model, + speed, + text: previewText.trim() || undefined, + }); + } + }; + return (
+ {previewError && {previewError}} +