Cast: card/list views, sort & filter, online voice picker, "Hear a line" sample button, AI character notes, import auto-save. Platform: WCAG 2.1 AA accessibility pass; German UI translation + language picker; installable PWA with offline shell; GZip + content-visibility virtualization + lazy images + Rehearser PCM memory cap (mobile stability); Playwright suite (desktop + iPhone); opt-in minified bundle build. Fixes: screenplay parser false characters; Fish-Speech inline-tag tones; narrator/voice pickers list full library; clone GUI rework; fish.audio import dedup; voice-ID rename; bulk-delete modal. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
293 lines
14 KiB
JavaScript
293 lines
14 KiB
JavaScript
// -- Get voices ---------------------------------------------------------------
|
|
const DEFAULT_VOICE_SOURCE_URLS = [
|
|
'https://aiartes.com/voiceai',
|
|
'https://sample-files.com/downloads/audio/wav/voice-sample.wav',
|
|
'https://freesound.org/people/Scott%20Simpson/',
|
|
'https://lanceblairvo.com/raw-voiceover-samples/',
|
|
'https://github.com/yaph/tts-samples/tree/main/mp3',
|
|
'https://github.com/jim-schwoebel/voice_datasets',
|
|
];
|
|
const VOICE_SOURCE_STORAGE_KEY = 'ttsvc-getvoices-sources';
|
|
let _voiceSourcePayload = null;
|
|
let _voiceSourceItems = [];
|
|
|
|
function sourceTextareaValueFromDefaults() {
|
|
return DEFAULT_VOICE_SOURCE_URLS.join('\n');
|
|
}
|
|
|
|
function initGetVoiceSourcesEditor() {
|
|
const box = $('getvoices-sources');
|
|
if (!box || box.dataset.ready) return;
|
|
box.value = localStorage.getItem(VOICE_SOURCE_STORAGE_KEY) || sourceTextareaValueFromDefaults();
|
|
box.dataset.ready = '1';
|
|
box.addEventListener('input', () => {
|
|
localStorage.setItem(VOICE_SOURCE_STORAGE_KEY, box.value);
|
|
_voiceSourcePayload = null;
|
|
$('getvoices-status').textContent = 'Source list changed.';
|
|
});
|
|
}
|
|
|
|
function getEditableVoiceSourceUrls() {
|
|
initGetVoiceSourcesEditor();
|
|
return ($('getvoices-sources')?.value || '')
|
|
.split(/\r?\n/)
|
|
.map(line => line.trim())
|
|
.filter(line => line && !line.startsWith('#'));
|
|
}
|
|
|
|
function getVoiceSourceItems() {
|
|
const sources = (_voiceSourcePayload && _voiceSourcePayload.sources) || [];
|
|
return sources.flatMap(src => (src.items || []).map(item => ({...item, _sourceName: src.name, _sourceHomepage: src.homepage})));
|
|
}
|
|
|
|
function voiceSourceSearchText(item) {
|
|
return [item.name, item.kind, item.category, item.language, item.gender, item.description, item.source, item._sourceName].join(' ').toLowerCase();
|
|
}
|
|
|
|
function setOptions(selectId, values, allLabel) {
|
|
const sel = $(selectId);
|
|
if (!sel) return;
|
|
const current = sel.value || 'all';
|
|
sel.innerHTML = `<option value="all">${escHtml(allLabel)}</option>` + values.map(value => `<option value="${escHtml(value)}">${escHtml(value)}</option>`).join('');
|
|
sel.value = values.includes(current) ? current : 'all';
|
|
}
|
|
|
|
function renderGetVoices() {
|
|
initGetVoiceSourcesEditor();
|
|
const list = $('getvoices-list');
|
|
const summary = $('getvoices-summary');
|
|
if (!list || !summary) return;
|
|
const payload = _voiceSourcePayload || {sources:[], total:0, direct_audio:0, errors:[]};
|
|
const sources = payload.sources || [];
|
|
const sourceFilter = $('getvoices-source-filter')?.value || 'all';
|
|
const languageFilter = $('getvoices-language-filter')?.value || 'all';
|
|
const genderFilter = $('getvoices-gender-filter')?.value || 'all';
|
|
const filetypeFilter = $('getvoices-filetype-filter')?.value || 'all';
|
|
const q = ($('getvoices-search')?.value || '').trim().toLowerCase();
|
|
const directOnly = !!$('getvoices-direct-only')?.checked;
|
|
_voiceSourceItems = getVoiceSourceItems();
|
|
|
|
summary.innerHTML = [
|
|
[`${payload.total || 0}`, 'Items found'],
|
|
[`${payload.direct_audio || 0}`, 'Direct audio'],
|
|
[`${sources.length}`, 'Sources OK'],
|
|
[`${(payload.errors || []).length}`, 'Errors'],
|
|
].map(([value, label]) => `<div class="insight"><strong>${escHtml(value)}</strong><span>${escHtml(label)}</span></div>`).join('');
|
|
|
|
setOptions('getvoices-source-filter', sources.map(src => src.id).filter(Boolean), 'Source: all');
|
|
const sourceSelect = $('getvoices-source-filter');
|
|
if (sourceSelect) {
|
|
[...sourceSelect.options].forEach(option => {
|
|
if (option.value === 'all') return;
|
|
const src = sources.find(s => s.id === option.value);
|
|
if (src) option.textContent = src.name || src.id;
|
|
});
|
|
}
|
|
const languages = [...new Set(_voiceSourceItems.map(item => item.language || 'Unknown'))].sort((a, b) => a.localeCompare(b));
|
|
const genders = [...new Set(_voiceSourceItems.map(item => item.gender || 'Unknown'))].sort((a, b) => a.localeCompare(b));
|
|
const filetypes = [...new Set(_voiceSourceItems.map(item => (item.file_type || (item.direct_audio ? 'audio' : 'page')).toUpperCase()))].sort((a, b) => a.localeCompare(b));
|
|
setOptions('getvoices-language-filter', languages, 'Language: all');
|
|
setOptions('getvoices-gender-filter', genders, 'Sex: all');
|
|
setOptions('getvoices-filetype-filter', filetypes, 'Filetype: all');
|
|
|
|
let items = _voiceSourceItems.filter(item => {
|
|
if (sourceFilter !== 'all' && item.source_id !== sourceFilter) return false;
|
|
if (languageFilter !== 'all' && (item.language || 'Unknown') !== languageFilter) return false;
|
|
if (genderFilter !== 'all' && (item.gender || 'Unknown') !== genderFilter) return false;
|
|
const itemFiletype = (item.file_type || (item.direct_audio ? 'audio' : 'page')).toUpperCase();
|
|
if (filetypeFilter !== 'all' && itemFiletype !== filetypeFilter) return false;
|
|
if (directOnly && !item.direct_audio) return false;
|
|
if (q && !voiceSourceSearchText(item).includes(q)) return false;
|
|
return true;
|
|
});
|
|
const shown = items.slice(0, 240);
|
|
const more = items.length - shown.length;
|
|
if (!shown.length) {
|
|
const errorText = (payload.errors || []).map(e => `${e.source}: ${e.detail}`).join(' | ');
|
|
list.innerHTML = `<div class="card"><p class="note">No matching sources found.${errorText ? ' Source errors: ' + escHtml(errorText) : ''}</p></div>`;
|
|
return;
|
|
}
|
|
list.innerHTML = shown.map(item => {
|
|
const thumb = item.image_url ? `<img class="voice-source-thumb" src="${escHtml(item.image_url)}" alt="" loading="lazy" decoding="async">` : `<div class="voice-source-thumb"></div>`;
|
|
const audio = item.audio_url ? `<audio controls preload="none" src="${escHtml(item.audio_url)}"></audio>` : '';
|
|
const audioLink = item.audio_url ? `<a class="btn-secondary" href="${escHtml(item.audio_url)}" target="_blank" rel="noopener">Open audio</a>` : '';
|
|
const canGetVoice = !!(item.import_url || item.audio_url);
|
|
const importUrl = item.import_url || item.audio_url;
|
|
const getVoice = canGetVoice ? `<button class="btn-primary get-source-voice" data-url="${escHtml(importUrl)}" data-name="${escHtml(item.name || '')}" data-image="${escHtml(item.image_url || '')}" data-language="${escHtml(item.language || '')}" data-gender="${escHtml(item.gender || '')}" data-kind="${escHtml(item.kind || '')}" data-description="${escHtml(item.description || '')}" data-page="${escHtml(item.page_url || item._sourceHomepage || '')}">Import this voice</button>` : '';
|
|
const type = item.file_type ? `<span class="voice-source-pill">${escHtml(String(item.file_type).toUpperCase())}</span>` : '';
|
|
const language = item.language ? `<span class="voice-source-pill">${escHtml(item.language)}</span>` : '';
|
|
const gender = item.gender ? `<span class="voice-source-pill">${escHtml(item.gender)}</span>` : '';
|
|
return `<div class="voice-source-card">
|
|
<div class="voice-source-head">
|
|
${thumb}
|
|
<div class="voice-source-title">
|
|
<strong title="${escHtml(item.name || '')}">${escHtml(item.name || 'Untitled')}</strong>
|
|
<span>${escHtml(item._sourceName || item.source || '')}${item.category ? ' · ' + escHtml(item.category) : ''}</span>
|
|
</div>
|
|
</div>
|
|
<div class="voice-source-desc">${escHtml(item.kind || '')}${item.description ? ' - ' + escHtml(item.description) : ''}</div>
|
|
${audio}
|
|
<div class="voice-source-actions">
|
|
${type}${language}${gender}
|
|
${audioLink}
|
|
<a class="btn-secondary" href="${escHtml(item.page_url || item._sourceHomepage || '#')}" target="_blank" rel="noopener">Source page</a>
|
|
<button class="btn-secondary copy-source-url" data-url="${escHtml(item.audio_url || item.page_url || '')}">Copy URL</button>
|
|
</div>
|
|
${getVoice ? `<div class="vs-import-footer">${getVoice}</div>` : ''}
|
|
</div>`;
|
|
}).join('') + (more > 0 ? `<div class="card"><p class="note">${more} more matches. Narrow the search or filters to see them.</p></div>` : '');
|
|
}
|
|
|
|
async function loadGetVoices(force = false) {
|
|
initGetVoiceSourcesEditor();
|
|
if (_voiceSourcePayload && !force) { renderGetVoices(); return; }
|
|
const urls = getEditableVoiceSourceUrls();
|
|
const list = $('getvoices-list');
|
|
if (!urls.length) {
|
|
if (list) list.innerHTML = '<div class="card"><p class="note">Add at least one source URL, then scrape again.</p></div>';
|
|
$('getvoices-status').textContent = 'No sources.';
|
|
return;
|
|
}
|
|
if (list) list.innerHTML = loadingMarkup('Scraping voice sources', `Fetching ${urls.length} source${urls.length === 1 ? '' : 's'} from the editable list.`, 6);
|
|
$('getvoices-status').textContent = 'Scraping...';
|
|
$('getvoices-refresh-btn').disabled = true;
|
|
try {
|
|
const r = await fetch('/api/voice-sources', {
|
|
method: 'POST',
|
|
headers: {'Content-Type': 'application/json'},
|
|
body: JSON.stringify({urls}),
|
|
});
|
|
if (!r.ok) {
|
|
const text = await r.text().catch(() => '');
|
|
let message = r.statusText || `HTTP ${r.status}`;
|
|
try { message = JSON.parse(text).detail || message; } catch (_) { if (text) message = text.slice(0, 160); }
|
|
throw new Error(message);
|
|
}
|
|
_voiceSourcePayload = await r.json();
|
|
renderGetVoices();
|
|
const errors = (_voiceSourcePayload.errors || []).length;
|
|
$('getvoices-status').textContent = `${_voiceSourcePayload.total || 0} found${errors ? `, ${errors} source errors` : ''}`;
|
|
} catch(e) {
|
|
if (list) list.innerHTML = `<div class="card"><p style="color:var(--red)">Scrape failed: ${escHtml(e.message)}</p><p class="note">Check that the TTS Voice Creator backend is restarted and that at least one source URL is reachable.</p></div>`;
|
|
$('getvoices-status').textContent = 'Scrape failed';
|
|
toast('Voice source scrape failed: ' + e.message, 'error');
|
|
} finally {
|
|
$('getvoices-refresh-btn').disabled = false;
|
|
}
|
|
}
|
|
|
|
['getvoices-search', 'getvoices-source-filter', 'getvoices-language-filter', 'getvoices-gender-filter', 'getvoices-filetype-filter', 'getvoices-direct-only'].forEach(id => {
|
|
const el = $(id);
|
|
if (el) el.addEventListener(id === 'getvoices-search' ? 'input' : 'change', renderGetVoices);
|
|
});
|
|
$('getvoices-refresh-btn')?.addEventListener('click', () => loadGetVoices(true));
|
|
$('getvoices-reset-sources-btn')?.addEventListener('click', () => {
|
|
const box = $('getvoices-sources');
|
|
if (!box) return;
|
|
box.value = sourceTextareaValueFromDefaults();
|
|
localStorage.setItem(VOICE_SOURCE_STORAGE_KEY, box.value);
|
|
_voiceSourcePayload = null;
|
|
renderGetVoices();
|
|
$('getvoices-status').textContent = 'Source list reset.';
|
|
});
|
|
const SOURCE_LANGUAGE_CODES = {
|
|
english:'EN', german:'DE', deutsch:'DE', french:'FR', spanish:'ES', japanese:'JA', korean:'KO',
|
|
italian:'IT', portuguese:'PT', russian:'RU', arabic:'AR', polish:'PL', dutch:'NL', swedish:'SV',
|
|
turkish:'TR', hindi:'HI', chinese:'ZH'
|
|
};
|
|
function sourceLanguageCode(language) {
|
|
const raw = String(language || '').trim();
|
|
if (/^[A-Z]{2}$/.test(raw)) return raw;
|
|
return SOURCE_LANGUAGE_CODES[raw.toLowerCase()] || 'EN';
|
|
}
|
|
function sourceGenderCode(gender, name = '') {
|
|
const text = `${gender || ''} ${name || ''}`.toLowerCase();
|
|
if (/female|woman|girl|\bf\b/.test(text)) return 'F';
|
|
if (/male|man|boy|\bm\b/.test(text)) return 'M';
|
|
return 'N';
|
|
}
|
|
function suggestedVoiceIdFromSourceName(name, language = 'EN', gender = 'N') {
|
|
const base = String(name || 'SourceVoice')
|
|
.normalize('NFKD')
|
|
.replace(/[\u0300-\u036f]/g, '')
|
|
.replace(/[^A-Za-z0-9]+/g, '_')
|
|
.replace(/^_+|_+$/g, '')
|
|
.slice(0, 48) || 'SourceVoice';
|
|
return `${language || 'EN'}_${gender || 'N'}_${base}`;
|
|
}
|
|
|
|
function setLibAddSourcePreview(meta = {}) {
|
|
const sourceBox = document.querySelector('.lib-add-source-box');
|
|
if (!sourceBox) return;
|
|
let preview = $('lib-add-source-preview');
|
|
if (!preview) {
|
|
preview = document.createElement('div');
|
|
preview.id = 'lib-add-source-preview';
|
|
preview.className = 'lib-add-source-preview';
|
|
sourceBox.appendChild(preview);
|
|
}
|
|
if (!meta.name && !meta.imageUrl) {
|
|
preview.classList.remove('open');
|
|
preview.innerHTML = '';
|
|
return;
|
|
}
|
|
const image = meta.imageUrl ? `<img src="${escHtml(meta.imageUrl)}" alt="">` : '<div class="voice-source-thumb"></div>';
|
|
preview.innerHTML = `${image}<div style="min-width:0"><strong>${escHtml(meta.name || 'Source voice')}</strong><span>${escHtml([meta.language, meta.gender, meta.kind].filter(Boolean).join(' · ') || 'Source metadata will be saved with the voice')}</span></div>`;
|
|
preview.classList.add('open');
|
|
}
|
|
|
|
async function getSourceVoiceInLibrary(meta) {
|
|
if (!meta.url) { toast('This source has no direct audio URL', 'error'); return; }
|
|
const lang = sourceLanguageCode(meta.language);
|
|
const gender = sourceGenderCode(meta.gender, meta.name);
|
|
const voiceId = suggestedVoiceIdFromSourceName(meta.name, lang, gender);
|
|
toast(`Importing ${meta.name || 'voice'}…`, 'info');
|
|
try {
|
|
const r = await fetch('/api/quick-import-voice', {
|
|
method: 'POST', headers: {'Content-Type': 'application/json'},
|
|
body: JSON.stringify({audio_url: meta.url, voice_id: voiceId, transcript: ''})
|
|
});
|
|
if (!r.ok) { const e = await r.json().catch(()=>({})); throw new Error(e.detail || r.statusText); }
|
|
const d = await r.json();
|
|
// Tag the imported voice with its source (host of the source page) + a picture if any
|
|
let srcTag = 'online';
|
|
try { if (meta.pageUrl) srcTag = new URL(meta.pageUrl).hostname.replace(/^www\./, '').split('.')[0] || 'online'; } catch (_) {}
|
|
if (typeof saveMeta === 'function') {
|
|
await saveMeta(d.voice_id, {
|
|
name: meta.name || d.voice_id, tag: srcTag, group: srcTag, origin: 'cloned',
|
|
gender: (gender || '').toUpperCase(), note: (meta.description || '').slice(0, 180),
|
|
}).catch(() => {});
|
|
}
|
|
if (meta.imageUrl) {
|
|
await fetch('/api/voice/picture-url', {
|
|
method: 'POST', headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ voice_id: d.voice_id, image_url: meta.imageUrl }),
|
|
}).catch(() => {});
|
|
}
|
|
_pendingSelectId = d.voice_id;
|
|
navTo('s-voices');
|
|
_libraryLoadPromise = null;
|
|
await loadVoiceLibrary();
|
|
toast(`Saved as ${d.voice_id}`, 'success');
|
|
} catch(e) {
|
|
toast('Import failed: ' + e.message, 'error');
|
|
}
|
|
}
|
|
|
|
$('getvoices-list')?.addEventListener('click', async e => {
|
|
const getBtn = e.target.closest('.get-source-voice');
|
|
if (getBtn) {
|
|
await getSourceVoiceInLibrary({
|
|
url: getBtn.dataset.url || '', name: getBtn.dataset.name || '', imageUrl: getBtn.dataset.image || '',
|
|
language: getBtn.dataset.language || '', gender: getBtn.dataset.gender || '', kind: getBtn.dataset.kind || '',
|
|
description: getBtn.dataset.description || '', pageUrl: getBtn.dataset.page || ''
|
|
});
|
|
return;
|
|
}
|
|
const btn = e.target.closest('.copy-source-url');
|
|
if (!btn) return;
|
|
await copyText(btn.dataset.url || '');
|
|
toast('Source URL copied', 'success');
|
|
});
|
|
|