| import { createWavBlob, loadReferenceAudio } from './audio' |
| import { APP_CONFIG } from './config' |
| import type { WorkerRequest, WorkerResponse } from './types' |
|
|
| export function mountApp(root: HTMLElement): void { |
| root.innerHTML = ` |
| <main class="app-shell"> |
| <section class="panel"> |
| <h1>${APP_CONFIG.appTitle}</h1> |
| <p class="intro"> |
| WebGPU-only Finnish TTS test app using the uploaded ONNX assets and a fixed Finnish reference voice. |
| </p> |
| <label class="field"> |
| <span>Finnish text</span> |
| <textarea id="tts-text" rows="5">${APP_CONFIG.sampleText}</textarea> |
| </label> |
| <div class="actions"> |
| <button id="speak-button" type="button">Speak</button> |
| <span id="busy-indicator" class="muted">Idle</span> |
| </div> |
| <div class="status-box"> |
| <strong>Status</strong> |
| <pre id="status-log">Ready to load reference audio.</pre> |
| </div> |
| <div class="status-box"> |
| <strong>Output</strong> |
| <audio id="audio-player" controls></audio> |
| <p id="result-meta" class="muted">No audio generated yet.</p> |
| </div> |
| </section> |
| </main> |
| ` |
|
|
| const textArea = root.querySelector<HTMLTextAreaElement>('#tts-text') |
| const speakButton = root.querySelector<HTMLButtonElement>('#speak-button') |
| const busyIndicator = root.querySelector<HTMLSpanElement>('#busy-indicator') |
| const statusLog = root.querySelector<HTMLPreElement>('#status-log') |
| const audioPlayer = root.querySelector<HTMLAudioElement>('#audio-player') |
| const resultMeta = root.querySelector<HTMLParagraphElement>('#result-meta') |
|
|
| if (!textArea || !speakButton || !busyIndicator || !statusLog || !audioPlayer || !resultMeta) { |
| throw new Error('App UI failed to initialize.') |
| } |
|
|
| const worker = new Worker(new URL('./worker.ts', import.meta.url), { type: 'module' }) |
| let referenceAudioPromise: Promise<Float32Array> | null = null |
| let currentAudioUrl: string | null = null |
|
|
| const setBusy = (busy: boolean) => { |
| speakButton.disabled = busy |
| busyIndicator.textContent = busy ? 'Working...' : 'Idle' |
| } |
|
|
| const appendStatus = (message: string) => { |
| statusLog.textContent = `${statusLog.textContent}\n${message}`.trim() |
| } |
|
|
| const ensureReferenceAudio = async (): Promise<Float32Array> => { |
| referenceAudioPromise ??= loadReferenceAudio() |
| return referenceAudioPromise |
| } |
|
|
| worker.onmessage = (event: MessageEvent<WorkerResponse>) => { |
| if (event.data.type === 'status') { |
| appendStatus(event.data.message) |
| return |
| } |
|
|
| if (event.data.type === 'ready') { |
| appendStatus('Models loaded and worker is ready.') |
| return |
| } |
|
|
| if (event.data.type === 'error') { |
| appendStatus(`ERROR: ${event.data.message}`) |
| resultMeta.textContent = event.data.message |
| setBusy(false) |
| return |
| } |
|
|
| if (event.data.type === 'result') { |
| if (currentAudioUrl) { |
| URL.revokeObjectURL(currentAudioUrl) |
| } |
| currentAudioUrl = URL.createObjectURL( |
| createWavBlob(event.data.audio, event.data.sampleRate), |
| ) |
| audioPlayer.src = currentAudioUrl |
| resultMeta.textContent = `Generated ${event.data.speechTokenCount} speech tokens at ${event.data.sampleRate} Hz.` |
| void audioPlayer.play() |
| appendStatus('Audio generation complete.') |
| setBusy(false) |
| } |
| } |
|
|
| speakButton.addEventListener('click', async () => { |
| try { |
| setBusy(true) |
| statusLog.textContent = 'Loading reference audio...' |
| const referenceAudio = await ensureReferenceAudio() |
| appendStatus('Sending request to worker...') |
|
|
| const message: WorkerRequest = { |
| type: 'speak', |
| text: textArea.value, |
| referenceAudio: new Float32Array(referenceAudio), |
| } |
|
|
| worker.postMessage(message, [message.referenceAudio.buffer]) |
| } catch (error) { |
| appendStatus( |
| `ERROR: ${error instanceof Error ? error.message : String(error)}`, |
| ) |
| setBusy(false) |
| } |
| }) |
| } |
|
|