File size: 11,668 Bytes
c41750d
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
<!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),
            // The pinned server caches transcript text by audio hash alone.
            // A fresh load prevents a prior transcript for the same WAV from
            // silently conditioning this request.
            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>