Build Fish Speech web studio
This commit is contained in:
@@ -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()
|
||||
})
|
||||
|
||||
@@ -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>
|
||||
|
||||
Generated
+3129
File diff suppressed because it is too large
Load Diff
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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' } },
|
||||
})
|
||||
|
||||
@@ -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'))
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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' })
|
||||
}
|
||||
|
||||
@@ -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
@@ -0,0 +1,6 @@
|
||||
import '@testing-library/jest-dom/vitest'
|
||||
import { cleanup } from '@testing-library/react'
|
||||
import { afterEach } from 'vitest'
|
||||
|
||||
afterEach(() => cleanup())
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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"]
|
||||
}
|
||||
|
||||
@@ -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}'] },
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user