| <!doctype html> |
| <html lang="en"> |
| <head> |
| <meta charset="utf-8"> |
| <meta name="viewport" content="width=device-width, initial-scale=1"> |
| <title>S2-Pro Quantized TTS</title> |
| <style> |
| :root { |
| color-scheme: dark; |
| font-family: Inter, ui-sans-serif, system-ui, sans-serif; |
| background: #0b1014; |
| color: #edf3f6; |
| } |
| * { box-sizing: border-box; } |
| body { |
| margin: 0; |
| min-height: 100vh; |
| background: |
| radial-gradient(circle at 15% 10%, #18384a 0, transparent 34rem), |
| linear-gradient(145deg, #080c10, #111b21); |
| } |
| main { width: min(920px, calc(100% - 32px)); margin: 0 auto; padding: 48px 0 72px; } |
| header { margin-bottom: 28px; } |
| .eyebrow { color: #68d6c0; font-size: 0.78rem; font-weight: 800; letter-spacing: 0.15em; text-transform: uppercase; } |
| h1 { margin: 8px 0; font-size: clamp(2rem, 6vw, 4rem); line-height: 1; letter-spacing: -0.05em; } |
| header p { max-width: 700px; color: #aab9c2; line-height: 1.65; } |
| .card { background: rgba(18, 28, 34, 0.9); border: 1px solid #2b3d47; border-radius: 18px; padding: 24px; box-shadow: 0 22px 70px #0008; } |
| .grid { display: grid; grid-template-columns: 1fr 1fr; gap: 18px; } |
| .full { grid-column: 1 / -1; } |
| label { display: block; margin-bottom: 8px; color: #c9d5da; font-size: 0.88rem; font-weight: 700; } |
| textarea, input, select { |
| width: 100%; border: 1px solid #354b56; border-radius: 11px; padding: 12px 13px; |
| background: #0a1217; color: #f4fafc; font: inherit; outline: none; |
| } |
| textarea { min-height: 108px; resize: vertical; line-height: 1.5; } |
| textarea:focus, input:focus, select:focus { border-color: #68d6c0; box-shadow: 0 0 0 3px #68d6c022; } |
| .hint { margin: 7px 0 0; color: #82939d; font-size: 0.79rem; line-height: 1.45; } |
| .actions { display: flex; align-items: center; gap: 14px; margin-top: 22px; flex-wrap: wrap; } |
| button { |
| appearance: none; border: 0; border-radius: 999px; padding: 12px 22px; |
| color: #06100e; background: #68d6c0; font: inherit; font-weight: 850; cursor: pointer; |
| } |
| button:hover { background: #8be6d4; } |
| button:disabled { opacity: 0.45; cursor: wait; } |
| #status { color: #9fb0b9; font-size: 0.88rem; } |
| #status.error { color: #ff9f9f; } |
| .result { display: none; margin-top: 22px; padding-top: 20px; border-top: 1px solid #2b3d47; } |
| audio { width: 100%; } |
| .download { display: inline-block; margin-top: 12px; color: #68d6c0; font-weight: 700; } |
| .badge { display: inline-flex; align-items: center; gap: 7px; padding: 6px 10px; border: 1px solid #2b3d47; border-radius: 999px; color: #aab9c2; font-size: 0.76rem; } |
| .dot { width: 8px; height: 8px; border-radius: 50%; background: #e2b34a; } |
| .dot.ok { background: #68d6c0; box-shadow: 0 0 12px #68d6c0; } |
| footer { margin-top: 22px; color: #6f8089; font-size: 0.76rem; line-height: 1.5; } |
| @media (max-width: 680px) { .grid { grid-template-columns: 1fr; } .full { grid-column: auto; } main { padding-top: 28px; } } |
| </style> |
| </head> |
| <body> |
| <main> |
| <header> |
| <div class="eyebrow">V1 · English balanced release</div> |
| <h1 id="model-title">Fish Audio S2-Pro NVFP4 Balanced</h1> |
| <p id="model-description">Loading the active checkpoint information…</p> |
| <div class="badge"><span id="health-dot" class="dot"></span><span id="health-text">Checking service…</span></div> |
| </header> |
|
|
| <section class="card"> |
| <form id="tts-form"> |
| <div class="grid"> |
| <div class="full"> |
| <label for="text">Text to speak</label> |
| <textarea id="text" required>A clear voice can make even a short message feel personal and memorable.</textarea> |
| <p class="hint">The base S2-Pro model can sound flatter than highly expressive TTS systems. Inline instructions may help some prompts, but V1 does not claim to solve that inherited limitation.</p> |
| </div> |
| <div> |
| <label for="reference-audio">Zero-shot reference audio</label> |
| <input id="reference-audio" type="file" accept="audio/*" required> |
| <p class="hint">Use a clean, consented 10–30 second sample with one speaker and little background noise.</p> |
| </div> |
| <div> |
| <label for="reference-text">Exact reference transcript</label> |
| <textarea id="reference-text" required placeholder="Transcribe the reference audio verbatim."></textarea> |
| <p class="hint">The transcript is required for reliable zero-shot conditioning.</p> |
| </div> |
| <div> |
| <label for="seed">Seed (optional)</label> |
| <input id="seed" type="number" placeholder="Random when blank"> |
| </div> |
| <div> |
| <label for="sampling-preset">Voice-clone sampling</label> |
| <select id="sampling-preset"> |
| <option value="natural" selected>Natural (recommended)</option> |
| <option value="api">Original API default</option> |
| <option value="stable">Stable identity</option> |
| <option value="expressive">Higher randomness (not an emotion tag)</option> |
| </select> |
| <p class="hint">Use the same seed to A/B sampling. For emotional delivery, put explicit [tag] instructions in the text.</p> |
| </div> |
| <div> |
| <label for="temperature">Temperature</label> |
| <input id="temperature" type="number" min="0.1" max="1" step="0.05" value="0.9"> |
| </div> |
| <div> |
| <label for="top-p">Top-p</label> |
| <input id="top-p" type="number" min="0.1" max="1" step="0.05" value="0.9"> |
| </div> |
| </div> |
| <div class="actions"> |
| <button id="generate" type="submit">Generate speech</button> |
| <span id="status">Ready.</span> |
| </div> |
| </form> |
| <div id="result" class="result"> |
| <audio id="player" controls></audio> |
| <a id="download" class="download" download="s2-pro-tts.wav">Download WAV</a> |
| </div> |
| </section> |
| <footer><strong>Built with Fish Audio.</strong> Use only voices you have permission to clone. This derivative is under the Fish Audio Research License; commercial use requires a separate written license from Fish Audio.</footer> |
| </main> |
|
|
| <script> |
| const form = document.querySelector('#tts-form'); |
| const button = document.querySelector('#generate'); |
| const status = document.querySelector('#status'); |
| const result = document.querySelector('#result'); |
| const player = document.querySelector('#player'); |
| const download = document.querySelector('#download'); |
| const samplingPreset = document.querySelector('#sampling-preset'); |
| const temperature = document.querySelector('#temperature'); |
| const topP = document.querySelector('#top-p'); |
| let currentUrl = null; |
| |
| const samplingProfiles = { |
| natural: { temperature: 0.9, topP: 0.9 }, |
| api: { temperature: 0.8, topP: 0.8 }, |
| stable: { temperature: 0.7, topP: 0.7 }, |
| expressive: { temperature: 0.95, topP: 0.95 } |
| }; |
| |
| samplingPreset.addEventListener('change', () => { |
| const profile = samplingProfiles[samplingPreset.value]; |
| temperature.value = profile.temperature; |
| topP.value = profile.topP; |
| }); |
| |
| function toBase64(buffer) { |
| const bytes = new Uint8Array(buffer); |
| const chunkSize = 32768; |
| let binary = ''; |
| for (let offset = 0; offset < bytes.length; offset += chunkSize) { |
| binary += String.fromCharCode(...bytes.subarray(offset, offset + chunkSize)); |
| } |
| return btoa(binary); |
| } |
| |
| function setStatus(message, isError = false) { |
| status.textContent = message; |
| status.classList.toggle('error', isError); |
| } |
| |
| async function checkHealth() { |
| try { |
| const response = await fetch('/v1/health'); |
| if (!response.ok) throw new Error(`HTTP ${response.status}`); |
| const modelResponse = await fetch('/v1/model'); |
| if (!modelResponse.ok) throw new Error(`Model HTTP ${modelResponse.status}`); |
| const model = await modelResponse.json(); |
| const isNvfp4 = model.policy === 'w4a16_gate_up_middle30_mxfp8_rest'; |
| const isBf16 = model.policy === 'bf16_control'; |
| document.querySelector('#model-title').textContent = model.model; |
| document.querySelector('#model-description').textContent = `${model.quantization}. Checkpoint: ${model.checkpoint}`; |
| document.querySelector('#health-dot').classList.add('ok'); |
| document.querySelector('#health-text').textContent = isNvfp4 ? 'Checkpointed NVFP4 service online' : (isBf16 ? 'Untouched BF16 base online' : 'MXFP8 service online'); |
| if (isNvfp4) { |
| samplingProfiles.natural = { temperature: 1.0, topP: 0.85 }; |
| temperature.value = 1.0; |
| topP.value = 0.85; |
| } |
| } catch (error) { |
| document.querySelector('#health-text').textContent = 'Service unavailable'; |
| } |
| } |
| |
| form.addEventListener('submit', async (event) => { |
| event.preventDefault(); |
| const audio = document.querySelector('#reference-audio').files[0]; |
| const referenceText = document.querySelector('#reference-text').value.trim(); |
| if (!audio || !referenceText) { |
| setStatus('Reference audio and its exact transcript are required.', true); |
| return; |
| } |
| button.disabled = true; |
| result.style.display = 'none'; |
| setStatus('Encoding reference and generating… first use may take longer.'); |
| try { |
| const seedValue = document.querySelector('#seed').value; |
| const payload = { |
| text: document.querySelector('#text').value, |
| references: [{ audio: toBase64(await audio.arrayBuffer()), text: referenceText }], |
| reference_id: null, |
| format: 'wav', |
| streaming: false, |
| normalize: false, |
| max_new_tokens: 2048, |
| chunk_length: 1000, |
| top_p: Number(topP.value), |
| repetition_penalty: 1.05, |
| temperature: Number(temperature.value), |
| seed: seedValue === '' ? null : Number(seedValue), |
| |
| |
| |
| use_memory_cache: 'off' |
| }; |
| const headers = { 'Content-Type': 'application/json', 'Accept': 'audio/wav' }; |
| const response = await fetch('/v1/tts', { method: 'POST', headers, body: JSON.stringify(payload) }); |
| if (!response.ok) throw new Error((await response.text()) || `HTTP ${response.status}`); |
| const blob = await response.blob(); |
| if (currentUrl) URL.revokeObjectURL(currentUrl); |
| currentUrl = URL.createObjectURL(blob); |
| player.src = currentUrl; |
| download.href = currentUrl; |
| result.style.display = 'block'; |
| setStatus(`Complete — ${(blob.size / 1024).toFixed(0)} KiB WAV.`); |
| } catch (error) { |
| setStatus(`Generation failed: ${error.message}`, true); |
| } finally { |
| button.disabled = false; |
| } |
| }); |
| |
| checkHealth(); |
| </script> |
| </body> |
| </html> |
|
|