diff --git a/.dockerignore b/.dockerignore index f6870a6..f532124 100644 --- a/.dockerignore +++ b/.dockerignore @@ -7,4 +7,3 @@ __pycache__ data docs tests - diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 113256c..9716a80 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -2,9 +2,9 @@ name: CI on: push: - branches: [main] + branches: [main, dev] pull_request: - branches: [main] + branches: [main, dev] jobs: python: diff --git a/.gitignore b/.gitignore index b664e3b..d33a3c4 100644 --- a/.gitignore +++ b/.gitignore @@ -193,9 +193,9 @@ cython_debug/ .abstra/ # Visual Studio Code -# Visual Studio Code specific template is maintained in a separate VisualStudioCode.gitignore +# Visual Studio Code specific template is maintained in a separate VisualStudioCode.gitignore # that can be found at https://github.com/github/gitignore/blob/main/Global/VisualStudioCode.gitignore -# and can be added to the global gitignore or merged into this file. However, if you prefer, +# and can be added to the global gitignore or merged into this file. However, if you prefer, # you could uncomment the following to ignore the entire vscode folder # .vscode/ # Temporary file for partial code execution diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml new file mode 100644 index 0000000..bb1b2e9 --- /dev/null +++ b/.pre-commit-config.yaml @@ -0,0 +1,38 @@ +repos: + - repo: https://github.com/pre-commit/pre-commit-hooks + rev: v4.6.0 + hooks: + - id: trailing-whitespace + - id: end-of-file-fixer + - id: check-yaml + - id: check-added-large-files + + - repo: https://github.com/astral-sh/ruff-pre-commit + rev: v0.11.10 + hooks: + - id: ruff + args: [ --fix ] + - id: ruff-format + + - repo: local + hooks: + - id: frontend-prettier + name: Frontend Prettier + entry: npm --prefix frontend run format + language: system + files: ^frontend/.*\.([jt]sx?|css|json)$ + pass_filenames: false + + - id: frontend-eslint + name: Frontend ESLint + entry: npm --prefix frontend run lint + language: system + files: ^frontend/.*\.([jt]sx?)$ + pass_filenames: false + + - id: mypy + name: mypy + entry: uv run mypy src/awaaz + language: system + files: ^src/awaaz/.*\.py$ + pass_filenames: false 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/docs/architecture.md b/docs/architecture.md index 79140a3..b385f28 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -20,4 +20,3 @@ input above configured backend limit. Job states: `queued`, `running`, `paused`, `failed`, `cancelled`, `completed`. Chunk states: `pending`, `processing`, `failed`, `completed`. Worker startup resets abandoned `processing` chunks to `pending`. Atomic file replacement prevents partial WAV checkpoints. - diff --git a/frontend/.dockerignore b/frontend/.dockerignore index 7227482..0e75fe5 100644 --- a/frontend/.dockerignore +++ b/frontend/.dockerignore @@ -1,4 +1,3 @@ node_modules dist coverage - diff --git a/frontend/nginx.conf.template b/frontend/nginx.conf.template index d487a0d..1a8f1e0 100644 --- a/frontend/nginx.conf.template +++ b/frontend/nginx.conf.template @@ -50,4 +50,3 @@ server { try_files $uri $uri/ /index.html; } } - 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}} +