Build Fish Speech web studio

This commit is contained in:
2026-10-29 16:29:24 -04:00
commit a269bda060
23 changed files with 4688 additions and 0 deletions
+51
View File
@@ -0,0 +1,51 @@
import { expect, test } from '@playwright/test'
test('shows the generation workspace and handles an empty library', async ({ page }) => {
await page.goto('/')
await expect(page.getByRole('heading', { name: 'Generated audio' })).toBeVisible()
await expect(page.getByText('Your next favorite take starts here.')).toBeVisible()
await expect(page.getByRole('tab', { name: /record/i })).toBeVisible()
})
test('records a browser microphone sample and offers a preview', async ({ page }) => {
await page.addInitScript(() => {
Object.defineProperty(navigator, 'mediaDevices', {
configurable: true,
value: { getUserMedia: async () => ({ getTracks: () => [{ stop() {} }] }) },
})
class TestMediaRecorder {
state = 'inactive'
mimeType = 'audio/webm'
ondataavailable: ((event: { data: Blob }) => void) | null = null
onstop: (() => void) | null = null
static isTypeSupported() { return true }
constructor(_: unknown) {}
start() { this.state = 'recording' }
stop() {
this.state = 'inactive'
this.ondataavailable?.({ data: new Blob(['fake audio'], { type: this.mimeType }) })
this.onstop?.()
}
}
Object.defineProperty(window, 'MediaRecorder', { configurable: true, value: TestMediaRecorder })
})
await page.goto('/')
await page.getByRole('tab', { name: /record/i }).click()
await page.getByRole('button', { name: /start recording/i }).click()
await expect(page.getByText('Recording in progress')).toBeVisible()
await page.waitForTimeout(700)
await page.getByRole('button', { name: /stop recording/i }).click()
await expect(page.getByLabel('Recording preview')).toBeVisible()
})
test('cancels a queued generation from the library', async ({ page }) => {
const job = { id: 'job-1', text: 'A queued sample', reference_id: 'ref-1', status: 'queued', error: null, created_at: '2026-10-27T12:00:00Z', updated_at: '2026-10-27T12:00:00Z', audio_url: null }
await page.route('**/api/references', (route) => route.fulfill({ json: [] }))
await page.route('**/api/generations*', (route) => route.fulfill({ json: { items: [job], page: 1, page_size: 50, total: 1, pages: 1 } }))
await page.route('**/api/generations/job-1/cancel', async (route) => route.fulfill({ json: { ...job, status: 'cancelled' } }))
await page.goto('/')
await expect(page.getByText('A queued sample')).toBeVisible()
await page.getByRole('button', { name: /cancel generation/i }).click()
await expect(page.getByText('CANCELLED', { exact: true })).toBeVisible()
})
+15
View File
@@ -0,0 +1,15 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="theme-color" content="#f3f0e9" />
<meta name="description" content="A private workspace for Fish Speech voice references and generated audio." />
<title>Fish Speech Studio</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>
+3129
View File
File diff suppressed because it is too large Load Diff
+30
View File
@@ -0,0 +1,30 @@
{
"name": "fish-speech-studio-ui",
"private": true,
"version": "1.0.0",
"type": "module",
"scripts": {
"dev": "vite --host 0.0.0.0",
"build": "tsc -b && vite build",
"test": "vitest run",
"test:e2e": "playwright test"
},
"dependencies": {
"react": "^18.3.1",
"react-dom": "^18.3.1"
},
"devDependencies": {
"@playwright/test": "^1.51.0",
"@testing-library/jest-dom": "^6.6.3",
"@testing-library/react": "^16.2.0",
"@testing-library/user-event": "^14.6.1",
"@types/react": "^18.3.18",
"@types/react-dom": "^18.3.5",
"@vitejs/plugin-react": "^4.3.4",
"jsdom": "^26.0.0",
"typescript": "~5.7.2",
"vite": "^6.1.0",
"vitest": "^3.0.5"
}
}
+8
View File
@@ -0,0 +1,8 @@
import { defineConfig } from '@playwright/test'
export default defineConfig({
testDir: './e2e',
use: { baseURL: process.env.PLAYWRIGHT_BASE_URL || 'http://127.0.0.1:8000', permissions: ['microphone'], launchOptions: { args: ['--use-fake-device-for-media-stream', '--use-fake-ui-for-media-stream'] } },
webServer: { command: 'cd .. && python -m uvicorn app.main:app --port 8000', url: 'http://127.0.0.1:8000/api/health', reuseExistingServer: !process.env.CI, timeout: 30_000, env: { DATA_DIR: './data' } },
})
+72
View File
@@ -0,0 +1,72 @@
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import App from './App'
vi.mock('./api', () => ({
getReferences: vi.fn().mockResolvedValue([{ id: 'r1', name: 'Studio voice', transcript: 'Hello there.', created_at: '2026-10-27T14:00:00Z', audio_url: '/ref.wav' }]),
getGenerations: vi.fn().mockResolvedValue({ items: [], page: 1, page_size: 50, total: 0, pages: 0 }),
getGeneration: vi.fn(),
getTtsEndpoints: vi.fn().mockResolvedValue({ items: [], active_url: 'http://host.docker.internal:8080/v1/tts' }),
testTtsEndpoint: vi.fn().mockResolvedValue({ ok: true, url: 'http://192.168.1.42:8080/v1/tts', message: 'Fish Speech server is reachable and exposes the /v1/tts endpoint.' }),
saveTtsEndpoint: vi.fn().mockResolvedValue({ id: 'tts-1', url: 'http://192.168.1.42:8080/v1/tts', created_at: '2026-10-28T14:00:00Z', active: true }),
activateTtsEndpoint: vi.fn(),
saveReference: vi.fn().mockResolvedValue({ id: 'r2', name: 'New voice', transcript: 'sample words', created_at: '2026-10-27T14:00:00Z', audio_url: '/ref2.wav' }),
createGeneration: vi.fn().mockResolvedValue({ id: 'g1', status: 'queued' }),
cancelGeneration: vi.fn(),
}))
describe('Fish Speech Studio', () => {
beforeEach(() => vi.clearAllMocks())
it('loads saved references and lets the user submit text to generate', async () => {
const api = await import('./api')
render(<App />)
expect(await screen.findByRole('button', { name: /studio voice/i })).toBeInTheDocument()
expect(screen.getByLabelText('Play reference audio: Studio voice')).toHaveAttribute('src', '/ref.wav')
fireEvent.change(screen.getByLabelText('TEXT TO GENERATE'), { target: { value: 'Good morning.' } })
fireEvent.click(screen.getByRole('button', { name: /generate audio/i }))
await waitFor(() => expect(api.createGeneration).toHaveBeenCalledWith('Good morning.', 'r1'))
})
it('requires a transcript before saving an uploaded reference', async () => {
render(<App />)
fireEvent.click(await screen.findByRole('button', { name: /save reference/i }))
expect(await screen.findByText('Enter the words spoken in the reference recording.')).toBeInTheDocument()
})
it('offers upload and recording reference modes', async () => {
render(<App />)
expect(await screen.findByLabelText('Reference WAV file')).toBeInTheDocument()
fireEvent.click(screen.getByRole('tab', { name: /record/i }))
expect(screen.getByRole('button', { name: /start recording/i })).toBeInTheDocument()
})
it('lets a user cancel an active job', async () => {
const api = await import('./api')
vi.mocked(api.getGenerations).mockResolvedValueOnce({
items: [{ id: 'g-running', text: 'A running take', reference_id: 'r1', status: 'running', error: null, created_at: '2026-10-27T14:01:00Z', updated_at: '2026-10-27T14:01:00Z', audio_url: null }],
page: 1, page_size: 50, total: 1, pages: 1,
})
vi.mocked(api.cancelGeneration).mockResolvedValue({
id: 'g-running', text: 'A running take', reference_id: 'r1', status: 'cancelled', error: null, created_at: '2026-10-27T14:01:00Z', updated_at: '2026-10-27T14:02:00Z', audio_url: null,
})
render(<App />)
fireEvent.click(await screen.findByRole('button', { name: /cancel generation: a running take/i }))
await waitFor(() => expect(api.cancelGeneration).toHaveBeenCalledWith('g-running'))
expect(await screen.findByText('CANCELLED')).toBeInTheDocument()
})
it('tests a TTS URL before enabling save and use', async () => {
const api = await import('./api')
render(<App />)
const input = await screen.findByLabelText('Fish Speech API URL')
fireEvent.change(input, { target: { value: 'http://192.168.1.42:8080' } })
expect(screen.getByRole('button', { name: /save & use/i })).toBeDisabled()
fireEvent.click(screen.getByRole('button', { name: /test connection/i }))
await waitFor(() => expect(api.testTtsEndpoint).toHaveBeenCalledWith('http://192.168.1.42:8080'))
expect(await screen.findByText('Fish Speech server is reachable and exposes the /v1/tts endpoint.')).toBeInTheDocument()
fireEvent.click(screen.getByRole('button', { name: /save & use/i }))
await waitFor(() => expect(api.saveTtsEndpoint).toHaveBeenCalledWith('http://192.168.1.42:8080/v1/tts'))
})
})
+331
View File
@@ -0,0 +1,331 @@
import { useCallback, useEffect, useRef, useState } from 'react'
import { activateTtsEndpoint, cancelGeneration, createGeneration, getGeneration, getGenerations, getReferences, getTtsEndpoints, saveReference, saveTtsEndpoint, testTtsEndpoint } from './api'
import type { Generation, GenerationPage, Reference, TtsEndpoint } from './types'
function Icon({ name, size = 18 }: { name: string; size?: number }) {
const common = { width: size, height: size, viewBox: '0 0 24 24', fill: 'none', stroke: 'currentColor', strokeWidth: 1.8, strokeLinecap: 'round' as const, strokeLinejoin: 'round' as const }
const paths: Record<string, React.ReactNode> = {
wave: <><path d="M2 10v4M6 6v12M10 3v18M14 8v8M18 5v14M22 10v4" /></>,
mic: <><rect x="9" y="2" width="6" height="12" rx="3"/><path d="M5 10v2a7 7 0 0 0 14 0v-2M12 19v3m-4 0h8"/></>,
upload: <><path d="M12 16V4m-4 4 4-4 4 4"/><path d="M4 16v4h16v-4"/></>,
play: <path d="m9 6 10 6-10 6z" fill="currentColor" stroke="none"/>,
stop: <><rect x="6" y="6" width="12" height="12" rx="2" fill="currentColor" stroke="none"/></>,
arrow: <><path d="M5 12h14m-6-6 6 6-6 6"/></>,
plus: <><path d="M12 5v14M5 12h14"/></>,
spark: <><path d="m12 3 1.9 5.8L20 11l-6.1 2.2L12 19l-1.9-5.8L4 11l6.1-2.2L12 3Z"/><path d="m19 14 1 3 3 1-3 1-1 3-1-3-3-1 3-1 1-3Z"/></>,
cancel: <><circle cx="12" cy="12" r="9"/><path d="m9 9 6 6m0-6-6 6"/></>,
chevron: <><path d="m9 18 6-6-6-6"/></>,
}
return <svg {...common} aria-hidden="true">{paths[name]}</svg>
}
function formatDate(value: string) {
return new Intl.DateTimeFormat(undefined, { month: 'short', day: 'numeric', hour: 'numeric', minute: '2-digit' }).format(new Date(value))
}
function App() {
const [references, setReferences] = useState<Reference[]>([])
const [selectedReference, setSelectedReference] = useState('')
const [text, setText] = useState('')
const [page, setPage] = useState(1)
const [history, setHistory] = useState<GenerationPage | null>(null)
const [notice, setNotice] = useState('')
const [error, setError] = useState('')
const [savingReference, setSavingReference] = useState(false)
const [generating, setGenerating] = useState(false)
const [mode, setMode] = useState<'upload' | 'record'>('upload')
const [file, setFile] = useState<File | null>(null)
const [transcript, setTranscript] = useState('')
const [referenceName, setReferenceName] = useState('')
const [recordedBlob, setRecordedBlob] = useState<Blob | null>(null)
const [recording, setRecording] = useState(false)
const [recordError, setRecordError] = useState('')
const [recordSeconds, setRecordSeconds] = useState(0)
const [recordPreviewUrl, setRecordPreviewUrl] = useState('')
const [ttsEndpoints, setTtsEndpoints] = useState<TtsEndpoint[]>([])
const [ttsUrl, setTtsUrl] = useState('')
const [testedTtsUrl, setTestedTtsUrl] = useState('')
const [ttsTestState, setTtsTestState] = useState<'idle' | 'testing' | 'ok' | 'error'>('idle')
const [ttsTestMessage, setTtsTestMessage] = useState('')
const [savingTtsUrl, setSavingTtsUrl] = useState(false)
const recorder = useRef<MediaRecorder | null>(null)
const stream = useRef<MediaStream | null>(null)
const chunks = useRef<Blob[]>([])
const refreshReferences = useCallback(async () => {
const next = await getReferences()
setReferences(next)
setSelectedReference((current) => current || next[0]?.id || '')
}, [])
const refreshHistory = useCallback(async (requestedPage = page) => {
const result = await getGenerations(requestedPage)
setHistory(result)
if (result.pages > 0 && requestedPage > result.pages) {
setPage(result.pages)
return
}
}, [page])
useEffect(() => {
getReferences().then((next) => {
setReferences(next)
setSelectedReference(next[0]?.id || '')
}).catch((err: Error) => setError(err.message))
getGenerations(1).then(setHistory).catch((err: Error) => setError(err.message))
getTtsEndpoints().then((result) => {
setTtsEndpoints(result.items)
setTtsUrl(result.active_url)
}).catch((err: Error) => setTtsTestMessage(err.message))
}, [])
useEffect(() => {
const active = history?.items.filter((job) => job.status === 'queued' || job.status === 'running') ?? []
if (!active.length) return
const timer = window.setInterval(async () => {
try {
const updates = await Promise.all(active.map((job) => getGeneration(job.id)))
setHistory((current) => current ? ({ ...current, items: current.items.map((job) => updates.find((u) => u.id === job.id) ?? job) }) : current)
} catch (err) { setError((err as Error).message) }
}, 1800)
return () => window.clearInterval(timer)
}, [history])
useEffect(() => {
if (!recording) return
const timer = window.setInterval(() => setRecordSeconds((seconds) => seconds + 1), 1000)
return () => window.clearInterval(timer)
}, [recording])
useEffect(() => {
if (!recordedBlob) { setRecordPreviewUrl(''); return }
const url = URL.createObjectURL(recordedBlob)
setRecordPreviewUrl(url)
return () => URL.revokeObjectURL(url)
}, [recordedBlob])
useEffect(() => () => {
stream.current?.getTracks().forEach((track) => track.stop())
}, [])
const beginRecording = async () => {
setRecordError('')
setRecordedBlob(null)
setRecordSeconds(0)
if (!navigator.mediaDevices?.getUserMedia || !window.MediaRecorder) {
setRecordError('This browser does not support microphone recording. Try uploading a WAV file instead.')
return
}
try {
const mediaStream = await navigator.mediaDevices.getUserMedia({ audio: true })
stream.current = mediaStream
const preferred = ['audio/webm;codecs=opus', 'audio/webm', 'audio/ogg;codecs=opus'].find((type) => MediaRecorder.isTypeSupported(type))
const activeRecorder = new MediaRecorder(mediaStream, preferred ? { mimeType: preferred } : undefined)
recorder.current = activeRecorder
chunks.current = []
activeRecorder.ondataavailable = (event) => { if (event.data.size) chunks.current.push(event.data) }
activeRecorder.onstop = () => {
setRecordedBlob(new Blob(chunks.current, { type: activeRecorder.mimeType || 'audio/webm' }))
mediaStream.getTracks().forEach((track) => track.stop())
}
activeRecorder.start()
setRecording(true)
} catch (err) {
setRecordError(err instanceof DOMException && err.name === 'NotAllowedError'
? 'Microphone access was denied. Allow microphone access in your browser, or upload a WAV file.'
: 'Could not start the microphone. Check that one is connected and available.')
}
}
const stopRecording = () => {
if (recorder.current?.state === 'recording') recorder.current.stop()
setRecording(false)
}
const saveReferenceFromForm = async (event: React.FormEvent) => {
event.preventDefault()
setError('')
if (!transcript.trim()) { setError('Enter the words spoken in the reference recording.'); return }
const source = mode === 'upload' ? file : recordedBlob
if (!source) { setError(mode === 'upload' ? 'Choose a WAV file first.' : 'Record a reference before saving.'); return }
setSavingReference(true)
try {
const filename = mode === 'upload' ? (file?.name || 'reference.wav') : `recording.${recordedBlob?.type.includes('ogg') ? 'ogg' : 'webm'}`
const created = await saveReference(source, filename, transcript.trim(), referenceName.trim(), mode === 'record')
await refreshReferences()
setSelectedReference(created.id)
setFile(null); setRecordedBlob(null); setTranscript(''); setReferenceName('')
setNotice('Reference saved and ready to use.')
} catch (err) { setError((err as Error).message) }
finally { setSavingReference(false) }
}
const generate = async (event: React.FormEvent) => {
event.preventDefault()
if (!text.trim() || !selectedReference) return
setError(''); setGenerating(true)
try {
await createGeneration(text.trim(), selectedReference)
setText('')
setPage(1)
await refreshHistory(1)
setNotice('Generation added to the queue.')
} catch (err) { setError((err as Error).message) }
finally { setGenerating(false) }
}
const cancel = async (job: Generation) => {
try {
const updated = await cancelGeneration(job.id)
setHistory((current) => current ? { ...current, items: current.items.map((item) => item.id === job.id ? updated : item) } : current)
setNotice('Generation cancelled. Fish Speech may finish its current GPU work in the background.')
} catch (err) { setError((err as Error).message) }
}
const testTtsHost = async () => {
setTtsTestState('testing')
setTtsTestMessage('Checking the server and its /v1/tts API…')
setTestedTtsUrl('')
try {
const result = await testTtsEndpoint(ttsUrl)
setTtsUrl(result.url)
setTestedTtsUrl(result.url)
setTtsTestState('ok')
setTtsTestMessage(result.message)
} catch (err) {
setTtsTestState('error')
setTtsTestMessage((err as Error).message)
}
}
const saveAndUseTtsHost = async () => {
if (!testedTtsUrl || testedTtsUrl !== ttsUrl) return
setSavingTtsUrl(true)
try {
const saved = await saveTtsEndpoint(ttsUrl)
setTtsEndpoints((current) => [saved, ...current.filter((item) => item.url !== saved.url)])
setTtsUrl(saved.url)
setTestedTtsUrl(saved.url)
setTtsTestState('ok')
setTtsTestMessage('Connection saved and now active for new generations.')
} catch (err) {
setTtsTestState('error')
setTtsTestMessage((err as Error).message)
} finally { setSavingTtsUrl(false) }
}
const useSavedTtsHost = async (endpoint: TtsEndpoint) => {
setSavingTtsUrl(true)
try {
const active = await activateTtsEndpoint(endpoint.id)
setTtsEndpoints((current) => current.map((item) => ({ ...item, active: item.id === active.id })))
setTtsUrl(active.url)
setTestedTtsUrl(active.url)
setTtsTestState('ok')
setTtsTestMessage('Saved connection is now active for new generations.')
} catch (err) {
setTtsTestState('error')
setTtsTestMessage((err as Error).message)
} finally { setSavingTtsUrl(false) }
}
const activeCount = history?.items.filter((job) => job.status === 'queued' || job.status === 'running').length ?? 0
const selected = references.find((item) => item.id === selectedReference)
const savedTtsEndpoint = ttsEndpoints.find((endpoint) => endpoint.url === ttsUrl)
return <main className="app-shell">
<header className="topbar">
<a className="brand" href="#top" aria-label="Fish Speech Studio home"><span className="brand-mark"><Icon name="wave" size={20}/></span><span>fish<span className="brand-light">speech</span><small>LOCAL STUDIO</small></span></a>
<div className="topbar-right"><span className="server-indicator"><i /> FISH SPEECH SERVER</span><span className="avatar">FS</span></div>
</header>
<div className="content" id="top">
<section className="intro">
<div><div className="eyebrow">VOICE WORKSPACE <span>•</span> S2 PRO</div><h1>Make something <em>heard.</em></h1><p>Shape a voice, give it words, and keep every take close at hand.</p></div>
<div className="intro-wave" aria-hidden="true">▁ ▃ ▅ ▂ ▆ ▃ ▇ ▂ ▅ ▄ ▆ ▂ ▄ ▇ ▃ ▅</div>
</section>
<section className="connection-bar" aria-label="Fish Speech connection">
<div className="connection-title"><span className="connection-icon"><Icon name="wave" size={17}/></span><div><strong>TTS HOST</strong><small>{ttsEndpoints.find((endpoint) => endpoint.active)?.url === ttsUrl ? 'SAVED CONNECTION' : 'CONFIGURE SERVER'}</small></div></div>
<div className="connection-control"><label className="visually-hidden" htmlFor="tts-host">Fish Speech API URL</label><input id="tts-host" list="saved-tts-hosts" value={ttsUrl} onChange={(event) => { setTtsUrl(event.target.value); setTestedTtsUrl(''); setTtsTestState('idle'); setTtsTestMessage('') }} placeholder="http://192.168.1.42:8080/v1/tts"/><datalist id="saved-tts-hosts">{ttsEndpoints.map((endpoint) => <option key={endpoint.id} value={endpoint.url}/>)}</datalist></div>
<button className="connection-button test-connection" onClick={testTtsHost} disabled={!ttsUrl.trim() || ttsTestState === 'testing' || savingTtsUrl}>{ttsTestState === 'testing' ? <><span className="spinner small"/> Testing</> : 'Test connection'}</button>
{savedTtsEndpoint ? <button className="connection-button use-connection" onClick={() => useSavedTtsHost(savedTtsEndpoint)} disabled={savedTtsEndpoint.active || savingTtsUrl}>{savedTtsEndpoint.active ? 'Active' : 'Use saved'}</button> : <button className="connection-button save-connection" onClick={saveAndUseTtsHost} disabled={ttsTestState !== 'ok' || testedTtsUrl !== ttsUrl || savingTtsUrl}>{savingTtsUrl ? 'Saving…' : 'Save & use'}</button>}
<div className={`connection-feedback connection-${ttsTestState}`} role={ttsTestState === 'error' ? 'alert' : 'status'}>{ttsTestState === 'ok' && <span className="connection-ok-dot"/>}{ttsTestMessage || 'Choose a saved host or enter a Fish Speech API URL.'}</div>
</section>
{(error || notice) && <div className={`toast ${error ? 'toast-error' : ''}`} role={error ? 'alert' : 'status'}>{error || notice}<button onClick={() => { setError(''); setNotice('') }} aria-label="Dismiss">×</button></div>}
<section className="work-grid">
<article className="panel reference-panel">
<div className="panel-head"><div><div className="step-label">01 <span>VOICE SOURCE</span></div><h2>Reference audio</h2></div><span className="count-pill">{references.length} SAVED</span></div>
<div className="reference-list">
{references.length ? references.map((reference) => <div key={reference.id} className={`reference-option ${selectedReference === reference.id ? 'is-selected' : ''}`}>
<button type="button" className="reference-select" aria-pressed={selectedReference === reference.id} onClick={() => setSelectedReference(reference.id)}>
<span className="ref-icon"><Icon name="wave" size={17}/></span><span className="ref-copy"><strong>{reference.name}</strong><small>{reference.transcript}</small></span>{selectedReference === reference.id && <span className="selected-mark">✓</span>}
</button>
<audio controls preload="none" src={reference.audio_url} aria-label={`Play reference audio: ${reference.name}`}/>
</div>) : <div className="empty-reference"><span className="empty-wave"><Icon name="wave" size={20}/></span><strong>No voices saved yet</strong><span>Upload a WAV or record a sample to start.</span></div>}
</div>
<div className="new-reference">
<div className="subhead"><span><Icon name="plus" size={15}/> ADD A REFERENCE</span><span className="subhead-line"/></div>
<div className="segmented" role="tablist" aria-label="Reference source">
<button role="tab" aria-selected={mode === 'upload'} className={mode === 'upload' ? 'active' : ''} onClick={() => { setMode('upload'); setError('') }}><Icon name="upload" size={15}/> Upload WAV</button>
<button role="tab" aria-selected={mode === 'record'} className={mode === 'record' ? 'active' : ''} onClick={() => { setMode('record'); setError('') }}><Icon name="mic" size={15}/> Record</button>
</div>
<form onSubmit={saveReferenceFromForm} className="reference-form">
{mode === 'upload' ? <label className="dropzone">
<input aria-label="Reference WAV file" type="file" accept="audio/wav,.wav" onChange={(event) => setFile(event.target.files?.[0] ?? null)}/>
<span className="drop-icon"><Icon name="upload" size={19}/></span><span><strong>{file?.name ?? 'Choose a WAV file'}</strong><small>{file ? `${(file.size / 1024 / 1024).toFixed(1)} MB · WAV` : 'Drop it here or browse · max 25 MB'}</small></span>
</label> : <div className={`recorder-box ${recording ? 'recording' : ''}`}>
<button type="button" className={`record-button ${recording ? 'recording' : ''}`} onClick={recording ? stopRecording : beginRecording} aria-label={recording ? 'Stop recording' : 'Start recording'}>{recording ? <Icon name="stop" size={16}/> : <Icon name="mic" size={17}/>}</button>
<div className="recorder-copy"><strong>{recording ? 'Recording in progress' : recordedBlob ? 'Recording ready to preview' : 'Record a short voice sample'}</strong><small>{recording ? `00:${String(recordSeconds).padStart(2, '0')} · select stop when finished` : 'Allow microphone access when prompted'}</small></div><span className={`recording-dot ${recording ? 'live' : ''}`}/>
{recordedBlob && <audio className="record-preview" controls src={recordPreviewUrl} aria-label="Recording preview"/>}
{recordError && <div className="record-error" role="alert">{recordError}</div>}
</div>}
<label className="field-label" htmlFor="reference-name">REFERENCE NAME <span>OPTIONAL</span></label>
<input id="reference-name" className="text-input" placeholder="e.g. Warm narrator" value={referenceName} onChange={(event) => setReferenceName(event.target.value)}/>
<label className="field-label" htmlFor="transcript">WHAT IS SAID IN THE AUDIO <span>REQUIRED</span></label>
<textarea id="transcript" className="text-input transcript-input" placeholder="Type the exact words spoken in your reference…" value={transcript} onChange={(event) => setTranscript(event.target.value)} rows={3}/>
<button type="submit" className="secondary-button" disabled={savingReference || recording}>{savingReference ? <><span className="spinner small"/> Saving reference</> : 'Save reference'}<Icon name="arrow" size={16}/></button>
</form>
</div>
</article>
<article className="panel generate-panel">
<div className="panel-head"><div><div className="step-label">02 <span>NEW GENERATION</span></div><h2>Give it something to say</h2></div><span className="model-tag"><span/> S2 PRO</span></div>
<form onSubmit={generate} className="generate-form">
<label className="field-label" htmlFor="voice-select">SPEAKING VOICE</label>
<div className="select-wrap"><select id="voice-select" value={selectedReference} onChange={(event) => setSelectedReference(event.target.value)} disabled={!references.length}>
{!references.length && <option value="">Save a reference to begin</option>}
{references.map((reference) => <option value={reference.id} key={reference.id}>{reference.name}</option>)}
</select><Icon name="chevron" size={16}/></div>
{selected && <div className="selected-transcript"><span>REFERENCE TRANSCRIPT</span><p>“{selected.transcript}”</p></div>}
<div className="text-entry-head"><label className="field-label" htmlFor="generation-text">TEXT TO GENERATE</label><span>{text.length.toLocaleString()} / 10,000</span></div>
<textarea id="generation-text" className="text-input generation-input" placeholder="Write or paste the words you want spoken…" value={text} onChange={(event) => setText(event.target.value)} maxLength={10000}/>
<div className="generate-footer"><span className="format-note"><span className="format-icon">MP3</span> Compressed audio · saved automatically</span><button className="primary-button" type="submit" disabled={!text.trim() || !selectedReference || generating}>{generating ? <><span className="spinner"/> Adding…</> : <><Icon name="spark" size={17}/> Generate audio</>}</button></div>
</form>
<div className="generate-hint"><span className="hint-light"/><span>{activeCount ? `${activeCount} ${activeCount === 1 ? 'generation' : 'generations'} in progress or queued` : 'Your audio will appear in the library below'}</span></div>
</article>
</section>
<section className="library-section">
<div className="library-head"><div><div className="step-label">03 <span>YOUR LIBRARY</span></div><h2>Generated audio <span className="total-count">{history?.total ?? 0}</span></h2></div><div className="sort-label"><span className="sort-dot"/> NEWEST FIRST</div></div>
{!history?.items.length ? <div className="empty-library"><div className="empty-library-mark"><Icon name="wave" size={23}/></div><strong>Your next favorite take starts here.</strong><span>Generated clips will be saved here, ready to replay.</span></div> : <>
<div className="audio-list">{history.items.map((job, index) => <article className="audio-row" key={job.id}>
<span className="row-index">{String((page - 1) * 50 + index + 1).padStart(2, '0')}</span>
<div className="audio-meta"><div className="audio-title">{job.text}</div><div className="audio-subtitle"><span>{formatDate(job.created_at)}</span><span className="meta-divider">·</span><span>{references.find((item) => item.id === job.reference_id)?.name ?? 'Reference voice'}</span></div>{job.error && <div className="job-error">{job.error}</div>}</div>
<div className={`status-chip status-${job.status}`}><span className="status-indicator"/>{job.status === 'running' ? 'GENERATING' : job.status.toUpperCase()}</div>
{job.audio_url ? <audio controls preload="none" src={job.audio_url} aria-label={`Play generated audio: ${job.text}`}/> : <div className="audio-placeholder">{job.status === 'running' || job.status === 'queued' ? <><span className="spinner small"/> {job.status === 'queued' ? 'Waiting' : 'Working'}</> : '—'}</div>}
{(job.status === 'queued' || job.status === 'running') && <button className="cancel-button" aria-label={`Cancel generation: ${job.text}`} onClick={() => cancel(job)}><Icon name="cancel" size={17}/></button>}
</article>)}</div>
<div className="pagination"><span>Showing <strong>{(page - 1) * 50 + 1}–{Math.min(page * 50, history.total)}</strong> of <strong>{history.total}</strong></span><div className="page-controls"><button aria-label="Previous page" disabled={page <= 1} onClick={() => { const next = page - 1; setPage(next); refreshHistory(next).catch((err) => setError(err.message)) }}>←</button><span>PAGE <strong>{page}</strong> OF <strong>{Math.max(1, history.pages)}</strong></span><button aria-label="Next page" disabled={page >= history.pages} onClick={() => { const next = page + 1; setPage(next); refreshHistory(next).catch((err) => setError(err.message)) }}>→</button></div></div>
</>}
</section>
<footer className="footer"><span>FISH SPEECH STUDIO <i>·</i> PRIVATE VOICE WORKSPACE</span><span>YOUR FILES STAY ON YOUR SERVER</span></footer>
</div>
</main>
}
export default App
+67
View File
@@ -0,0 +1,67 @@
import type { Generation, GenerationPage, Reference, TtsEndpoint, TtsEndpointList } from './types'
async function request<T>(url: string, init?: RequestInit): Promise<T> {
const response = await fetch(url, init)
if (!response.ok) {
let message = `Request failed (${response.status})`
try {
const body = await response.json()
message = body.detail || message
} catch { /* keep status message */ }
throw new Error(message)
}
return response.json() as Promise<T>
}
export async function getReferences(): Promise<Reference[]> {
return request('/api/references')
}
export async function saveReference(file: File | Blob, filename: string, transcript: string, name: string, recording = false): Promise<Reference> {
const form = new FormData()
form.append('file', file, filename)
form.append('transcript', transcript)
form.append('name', name)
form.append('recording', String(recording))
return request('/api/references', { method: 'POST', body: form })
}
export async function createGeneration(text: string, reference_id: string): Promise<Generation> {
return request('/api/generations', {
method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ text, reference_id }),
})
}
export async function getGeneration(id: string): Promise<Generation> {
return request(`/api/generations/${id}`)
}
export async function cancelGeneration(id: string): Promise<Generation> {
return request(`/api/generations/${id}/cancel`, { method: 'POST' })
}
export async function getGenerations(page: number): Promise<GenerationPage> {
return request(`/api/generations?page=${page}&page_size=50`)
}
export async function getTtsEndpoints(): Promise<TtsEndpointList> {
return request('/api/tts-endpoints')
}
export async function testTtsEndpoint(url: string): Promise<{ ok: boolean; url: string; message: string }> {
return request('/api/tts-endpoints/test', {
method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ url }),
})
}
export async function saveTtsEndpoint(url: string): Promise<TtsEndpoint> {
return request('/api/tts-endpoints', {
method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ url }),
})
}
export async function activateTtsEndpoint(id: string): Promise<TtsEndpoint> {
return request(`/api/tts-endpoints/${id}/activate`, { method: 'POST' })
}
+9
View File
@@ -0,0 +1,9 @@
import React from 'react'
import ReactDOM from 'react-dom/client'
import App from './App'
import './styles.css'
ReactDOM.createRoot(document.getElementById('root')!).render(
<React.StrictMode><App /></React.StrictMode>,
)
File diff suppressed because one or more lines are too long
+6
View File
@@ -0,0 +1,6 @@
import '@testing-library/jest-dom/vitest'
import { cleanup } from '@testing-library/react'
import { afterEach } from 'vitest'
afterEach(() => cleanup())
+39
View File
@@ -0,0 +1,39 @@
export type Reference = {
id: string
name: string
transcript: string
created_at: string
audio_url: string
}
export type Generation = {
id: string
text: string
reference_id: string
status: 'queued' | 'running' | 'completed' | 'failed' | 'cancelled'
error: string | null
created_at: string
updated_at: string
audio_url: string | null
}
export type GenerationPage = {
items: Generation[]
page: number
page_size: number
total: number
pages: number
}
export type TtsEndpoint = {
id: string
url: string
created_at: string
active: boolean
}
export type TtsEndpointList = {
items: TtsEndpoint[]
active_url: string
}
+21
View File
@@ -0,0 +1,21 @@
{
"compilerOptions": {
"target": "ES2020",
"useDefineForClassFields": true,
"lib": ["ES2020", "DOM", "DOM.Iterable"],
"module": "ESNext",
"skipLibCheck": true,
"moduleResolution": "Bundler",
"allowImportingTsExtensions": true,
"resolveJsonModule": true,
"isolatedModules": true,
"noEmit": true,
"jsx": "react-jsx",
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noFallthroughCasesInSwitch": true
},
"include": ["src", "vite.config.ts"]
}
+9
View File
@@ -0,0 +1,9 @@
import { defineConfig } from 'vitest/config'
import react from '@vitejs/plugin-react'
export default defineConfig({
plugins: [react()],
server: { proxy: { '/api': 'http://localhost:8000' } },
test: { environment: 'jsdom', setupFiles: './src/test-setup.ts', include: ['src/**/*.test.{ts,tsx}'] },
})