Adds a collapsible "🎲 Seed Finder" panel to the voice inspector pane.
Select a voice, expand the panel, set a seed range (default 1–15), and
click Run to generate audio samples with different seeds.
Each result row has Play and "Use this seed" buttons. "Use this seed"
saves the chosen seed to the faster-qwen3-tts voices.json via the new
/api/tts-voice-seed proxy endpoint (which calls /voice-seed on the TTS
server). The saved row gets a ✓ marker.
Test sentence auto-selects DE/EN mixed or EN-only based on voice name.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
214 lines
8.8 KiB
JavaScript
214 lines
8.8 KiB
JavaScript
// ── Seed Finder — find the best RNG seed for a voice ─────────────────────────
|
|
//
|
|
// Appended to the voice inspector body by voice-inspector.js.
|
|
// Generates audio samples for a voice across a range of seeds so the user
|
|
// can listen and pick the most natural-sounding one, then save it to the
|
|
// TTS server's voices.json with one click.
|
|
|
|
const SEED_FINDER_TEXT_DE = (
|
|
'Die 3.500 neuen High-End Geräte für das Server-Update benötigen eine ' +
|
|
'außergewöhnlich starke Kühlung und regelmäßige Maßnahmen, um die Performance ' +
|
|
'bei großer Last zu gewährleisten.'
|
|
);
|
|
const SEED_FINDER_TEXT_EN = (
|
|
'The system administrator successfully configured the customized Docker stacks ' +
|
|
'and benchmarked the inference engines at exactly 8:45 AM.'
|
|
);
|
|
const SEED_FINDER_TEXT_MIXED = SEED_FINDER_TEXT_DE + ' - ' + SEED_FINDER_TEXT_EN;
|
|
|
|
function _seedFinderDefaultText(voiceId) {
|
|
const lc = (voiceId || '').toLowerCase();
|
|
if (lc.startsWith('de_')) return SEED_FINDER_TEXT_MIXED;
|
|
if (lc.startsWith('en_') || lc.startsWith('gb_')) return SEED_FINDER_TEXT_EN;
|
|
return SEED_FINDER_TEXT_MIXED;
|
|
}
|
|
|
|
// Called from voice-inspector.js after the persona panel is appended.
|
|
function attachSeedFinder(voiceId, body) {
|
|
const panel = document.createElement('div');
|
|
panel.className = 'opt-group seed-finder-group';
|
|
panel.innerHTML = `
|
|
<div class="opt-group-title">
|
|
<span class="opt-chevron"></span>
|
|
<span class="opt-title-text">🎲 Seed Finder</span>
|
|
<span class="opt-group-meta">Find the most natural-sounding voice</span>
|
|
</div>
|
|
<div class="opt-group-subtitle">
|
|
Generates audio for each seed so you can listen and pick the best one.
|
|
Click "Use this seed" to save it to the TTS server.
|
|
</div>
|
|
<div class="opt-group-body seed-finder-body">
|
|
<div class="seed-finder-config">
|
|
<div class="seed-finder-row">
|
|
<label class="seed-finder-label">Test sentence</label>
|
|
<textarea class="seed-finder-text" rows="3" spellcheck="false"></textarea>
|
|
</div>
|
|
<div class="seed-finder-row seed-finder-params">
|
|
<div class="seed-finder-field">
|
|
<label>Seeds: from</label>
|
|
<input class="seed-finder-from" type="number" min="0" max="9999" value="1" step="1">
|
|
<label>to</label>
|
|
<input class="seed-finder-to" type="number" min="1" max="9999" value="15" step="1">
|
|
</div>
|
|
<div class="seed-finder-field">
|
|
<label>Backend</label>
|
|
<select class="seed-finder-backend">
|
|
<option value="voice_clone" selected>Voice Clone</option>
|
|
<option value="streaming">Streaming</option>
|
|
</select>
|
|
</div>
|
|
</div>
|
|
<div class="seed-finder-actions">
|
|
<button class="btn-primary seed-finder-run-btn">▶ Run Seed Finder</button>
|
|
<button class="btn-secondary seed-finder-cancel-btn" style="display:none">✕ Cancel</button>
|
|
<span class="seed-finder-status"></span>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="seed-finder-progress" style="display:none">
|
|
<div class="seed-finder-progress-head">
|
|
<span class="seed-finder-progress-label">Generating…</span>
|
|
<span class="seed-finder-progress-count">0 / 0</span>
|
|
</div>
|
|
<div class="benchmark-progress-track">
|
|
<div class="seed-finder-bar" style="width:0%"></div>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="seed-finder-results"></div>
|
|
</div>
|
|
`;
|
|
|
|
body.appendChild(panel);
|
|
|
|
// Toggle open/closed
|
|
panel.querySelector('.opt-group-title').addEventListener('click', () => {
|
|
panel.classList.toggle('open');
|
|
});
|
|
|
|
const textEl = panel.querySelector('.seed-finder-text');
|
|
const fromEl = panel.querySelector('.seed-finder-from');
|
|
const toEl = panel.querySelector('.seed-finder-to');
|
|
const backendEl = panel.querySelector('.seed-finder-backend');
|
|
const runBtn = panel.querySelector('.seed-finder-run-btn');
|
|
const cancelBtn = panel.querySelector('.seed-finder-cancel-btn');
|
|
const statusEl = panel.querySelector('.seed-finder-status');
|
|
const progressEl = panel.querySelector('.seed-finder-progress');
|
|
const progLabel = panel.querySelector('.seed-finder-progress-label');
|
|
const progCount = panel.querySelector('.seed-finder-progress-count');
|
|
const progBar = panel.querySelector('.seed-finder-bar');
|
|
const resultsEl = panel.querySelector('.seed-finder-results');
|
|
|
|
textEl.value = _seedFinderDefaultText(voiceId);
|
|
|
|
let _cancelled = false;
|
|
let _currentAudio = null;
|
|
|
|
function _playBlob(blob) {
|
|
if (_currentAudio) { _currentAudio.pause(); _currentAudio = null; }
|
|
const url = URL.createObjectURL(blob);
|
|
const audio = new Audio(url);
|
|
_currentAudio = audio;
|
|
audio.play().catch(() => {});
|
|
audio.addEventListener('ended', () => { URL.revokeObjectURL(url); _currentAudio = null; });
|
|
}
|
|
|
|
function _addResultRow(seed, blob, durationSec, err, activeSeedSet) {
|
|
const row = document.createElement('div');
|
|
row.className = 'seed-finder-result-row' + (err ? ' seed-finder-result-error' : '');
|
|
row.dataset.seed = seed;
|
|
|
|
if (err) {
|
|
row.innerHTML = `
|
|
<span class="sfr-seed">Seed ${seed}</span>
|
|
<span class="sfr-error">Failed: ${escHtml(String(err))}</span>
|
|
`;
|
|
} else {
|
|
const durStr = durationSec ? `${durationSec.toFixed(1)}s` : '';
|
|
row.innerHTML = `
|
|
<span class="sfr-seed">Seed ${seed}</span>
|
|
<span class="sfr-dur">${durStr}</span>
|
|
<button class="btn-secondary sfr-play-btn"><span class="mdi mdi-play"></span> Play</button>
|
|
<button class="btn-primary sfr-use-btn">★ Use seed ${seed}</button>
|
|
<span class="sfr-saved"></span>
|
|
`;
|
|
row.querySelector('.sfr-play-btn').addEventListener('click', () => _playBlob(blob));
|
|
row.querySelector('.sfr-use-btn').addEventListener('click', async () => {
|
|
// Stop any playback
|
|
if (_currentAudio) { _currentAudio.pause(); _currentAudio = null; }
|
|
try {
|
|
const resp = await fetch('/api/tts-voice-seed', {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ voice: voiceId, seed }),
|
|
});
|
|
if (!resp.ok) {
|
|
const e = await resp.json().catch(() => ({}));
|
|
throw new Error(e.detail || resp.statusText);
|
|
}
|
|
// Clear all other "saved" markers
|
|
resultsEl.querySelectorAll('.sfr-saved').forEach(el => el.textContent = '');
|
|
resultsEl.querySelectorAll('.seed-finder-result-row').forEach(r => r.classList.remove('sfr-active'));
|
|
row.classList.add('sfr-active');
|
|
row.querySelector('.sfr-saved').textContent = '✓ Saved';
|
|
if (typeof toast === 'function') toast(`Seed ${seed} saved for ${voiceId}`, 'success');
|
|
} catch (e) {
|
|
if (typeof toast === 'function') toast('Save failed: ' + e.message, 'error');
|
|
}
|
|
});
|
|
}
|
|
|
|
resultsEl.appendChild(row);
|
|
}
|
|
|
|
async function _runFinder() {
|
|
const text = textEl.value.trim();
|
|
if (!text) { statusEl.textContent = 'Enter a test sentence.'; return; }
|
|
|
|
const seedFrom = Math.max(0, parseInt(fromEl.value, 10) || 1);
|
|
const seedTo = Math.max(seedFrom, parseInt(toEl.value, 10) || 15);
|
|
const backend = backendEl.value;
|
|
const seeds = [];
|
|
for (let s = seedFrom; s <= seedTo; s++) seeds.push(s);
|
|
const total = seeds.length;
|
|
|
|
_cancelled = false;
|
|
resultsEl.innerHTML = '';
|
|
progressEl.style.display = '';
|
|
runBtn.style.display = 'none';
|
|
cancelBtn.style.display = '';
|
|
statusEl.textContent = '';
|
|
progBar.style.width = '0%';
|
|
progCount.textContent = `0 / ${total}`;
|
|
progLabel.textContent = 'Generating…';
|
|
|
|
for (let i = 0; i < seeds.length; i++) {
|
|
if (_cancelled) break;
|
|
const seed = seeds[i];
|
|
progLabel.textContent = `Seed ${seed}…`;
|
|
progCount.textContent = `${i} / ${total}`;
|
|
progBar.style.width = `${Math.round(i / total * 100)}%`;
|
|
|
|
try {
|
|
const blob = await fetchTtsPreviewBlob(
|
|
voiceId, text, 'wav', '', backend, false, { seed }
|
|
);
|
|
// Estimate duration from WAV byte length: (bytes - 44 header) / (24000 * 2)
|
|
const durSec = Math.max(0, (blob.size - 44) / (24000 * 2));
|
|
_addResultRow(seed, blob, durSec, null, false);
|
|
} catch (e) {
|
|
_addResultRow(seed, null, 0, e.message || String(e), false);
|
|
}
|
|
}
|
|
|
|
progBar.style.width = '100%';
|
|
progCount.textContent = `${seeds.length} / ${total}`;
|
|
progLabel.textContent = _cancelled ? 'Cancelled.' : 'Done — listen and pick your seed.';
|
|
runBtn.style.display = '';
|
|
cancelBtn.style.display = 'none';
|
|
}
|
|
|
|
runBtn.addEventListener('click', _runFinder);
|
|
cancelBtn.addEventListener('click', () => { _cancelled = true; });
|
|
}
|