// -- 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 = `${escHtml(allLabel)} ` + values.map(value => `${escHtml(value)} `).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]) => `
${escHtml(value)} ${escHtml(label)}
`).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 = `No matching sources found.${errorText ? ' Source errors: ' + escHtml(errorText) : ''}
`;
return;
}
list.innerHTML = shown.map(item => {
const thumb = item.image_url ? ` ` : `
`;
const audio = item.audio_url ? ` ` : '';
const audioLink = item.audio_url ? `Open audio ` : '';
const canGetVoice = !!(item.import_url || item.audio_url);
const importUrl = item.import_url || item.audio_url;
const getVoice = canGetVoice ? `Import this voice ` : '';
const type = item.file_type ? `${escHtml(String(item.file_type).toUpperCase())} ` : '';
const language = item.language ? `${escHtml(item.language)} ` : '';
const gender = item.gender ? `${escHtml(item.gender)} ` : '';
return `
${thumb}
${escHtml(item.name || 'Untitled')}
${escHtml(item._sourceName || item.source || '')}${item.category ? ' · ' + escHtml(item.category) : ''}
${escHtml(item.kind || '')}${item.description ? ' - ' + escHtml(item.description) : ''}
${audio}
${type}${language}${gender}
${audioLink}
Source page
Copy URL
${getVoice ? `` : ''}
`;
}).join('') + (more > 0 ? `${more} more matches. Narrow the search or filters to see them.
` : '');
}
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 = 'Add at least one source URL, then scrape again.
';
$('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 = `Scrape failed: ${escHtml(e.message)}
Check that the TTS Voice Creator backend is restarted and that at least one source URL is reachable.
`;
$('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 ? ` ` : '
';
preview.innerHTML = `${image}${escHtml(meta.name || 'Source voice')} ${escHtml([meta.language, meta.gender, meta.kind].filter(Boolean).join(' · ') || 'Source metadata will be saved with the voice')}
`;
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');
});