`;
}
function getChunkInstruction(chunk) {
if (!chunk || typeof chunk !== 'object') return '';
if (chunk.emotion) return chunk.emotion;
const extra = chunk.voice_assignment?.extra;
if (extra && extra.instruct) return extra.instruct;
return '';
}
function escapeHtml(str) {
if (!str) return '';
return str.replace(/&/g, '&').replace(//g, '>').replace(/"/g, '"');
}
function handleLibraryChunkPlayClick(btn) {
const url = btn.getAttribute('data-audio-url');
if (!url) return;
// If this button is currently playing, stop it
if (libraryActiveAudio && libraryActivePlayButton === btn) {
stopLibraryChunkAudio();
return;
}
// Stop any other playing audio first
if (libraryActiveAudio) {
stopLibraryChunkAudio();
}
stopChunkSequence(false);
stopPreviewAudio();
// Start playing
const audio = new Audio(url);
libraryActiveAudio = audio;
libraryActivePlayButton = btn;
// Update button to show stop state
btn.textContent = '■ Stop';
btn.classList.add('playing');
audio.addEventListener('ended', () => {
resetLibraryPlayButton(btn);
libraryActiveAudio = null;
libraryActivePlayButton = null;
});
audio.addEventListener('error', (err) => {
console.error('Playback error:', err);
resetLibraryPlayButton(btn);
libraryActiveAudio = null;
libraryActivePlayButton = null;
});
audio.play().catch(err => {
console.error('Playback error:', err);
resetLibraryPlayButton(btn);
libraryActiveAudio = null;
libraryActivePlayButton = null;
});
}
function stopLibraryChunkAudio() {
if (libraryActiveAudio) {
libraryActiveAudio.pause();
libraryActiveAudio.currentTime = 0;
libraryActiveAudio = null;
}
if (libraryActivePlayButton) {
resetLibraryPlayButton(libraryActivePlayButton);
libraryActivePlayButton = null;
}
}
function resetLibraryPlayButton(btn) {
if (btn) {
btn.textContent = '▶';
btn.classList.remove('playing');
}
}
function resetChunkSequenceHighlight() {
chunkSequenceItems.forEach(({ card }) => {
card.classList.remove('is-playing-sequence');
card.classList.remove('is-playing-sequence-stopped');
});
}
function stopChunkSequence(keepHighlight = false, preserveResume = false) {
const lastIndex = chunkSequenceIndex;
if (chunkSequenceAudio) {
chunkSequenceAudio.pause();
chunkSequenceAudio.currentTime = 0;
}
chunkSequenceAudio = null;
chunkSequenceItems = [];
chunkSequenceIndex = -1;
if (keepHighlight && chunkSequenceLastCard) {
if (lastIndex >= 0) {
chunkSequenceResumeIndex = lastIndex;
}
resetChunkSequenceHighlight();
chunkSequenceLastCard.classList.add('is-playing-sequence-stopped');
chunkSequenceStartIndex = null;
} else {
resetChunkSequenceHighlight();
if (!preserveResume) {
chunkSequenceResumeIndex = null;
}
}
if (chunkSequenceButton) {
chunkSequenceButton.textContent = '▶';
chunkSequenceButton.classList.remove('playing');
}
if (chunkSequenceLabel) {
chunkSequenceLabel.textContent = 'Start Audio Review';
}
chunkSequenceButton = null;
chunkSequenceLabel = null;
}
function setChunkSequenceStartFromCard(card) {
if (!card) return;
const rawIndex = card.getAttribute('data-idx');
const index = rawIndex === null ? NaN : Number(rawIndex);
if (!Number.isNaN(index)) {
chunkSequenceStartIndex = index;
chunkSequenceResumeIndex = index;
}
}
function highlightManualChunkSelection(card) {
if (!card) return;
document.querySelectorAll('.library-chunk-card').forEach(item => {
item.classList.remove('is-playing-sequence');
item.classList.remove('is-playing-sequence-stopped');
});
card.classList.add('is-playing-sequence-stopped');
}
function startChunkSequence(items, startIndex = 0) {
if (!items.length) return;
const currentButton = chunkSequenceButton;
const currentLabel = chunkSequenceLabel;
stopChunkSequence(false, true);
chunkSequenceButton = currentButton;
chunkSequenceLabel = currentLabel;
chunkSequenceItems = items;
chunkSequenceIndex = Math.max(0, startIndex);
chunkSequenceAudio = new Audio();
const playIndex = (index) => {
if (!chunkSequenceAudio) return;
if (index >= chunkSequenceItems.length) {
stopChunkSequence();
return;
}
const item = chunkSequenceItems[index];
if (!item || !item.url) {
playIndex(index + 1);
return;
}
chunkSequenceIndex = index;
chunkSequenceResumeIndex = index;
resetChunkSequenceHighlight();
item.card.classList.add('is-playing-sequence');
chunkSequenceLastCard = item.card;
chunkSequenceAudio.src = item.url;
chunkSequenceAudio.play().catch(() => {
playIndex(index + 1);
});
};
chunkSequenceAudio.addEventListener('ended', () => {
playIndex(chunkSequenceIndex + 1);
});
chunkSequenceAudio.addEventListener('error', () => {
playIndex(chunkSequenceIndex + 1);
});
playIndex(chunkSequenceIndex);
if (chunkSequenceButton) {
chunkSequenceButton.textContent = '■';
chunkSequenceButton.classList.add('playing');
}
if (chunkSequenceLabel) {
chunkSequenceLabel.textContent = 'Stop Audio Review';
}
}
function wireChunkReviewEvents(jobId, chunks, engine) {
const body = document.getElementById('chunk-review-modal-body');
if (!body) return;
const playAllButton = body.querySelector('#chunk-review-play-all');
const playAllLabel = body.querySelector('#chunk-review-play-all-label');
if (playAllButton) {
playAllButton.addEventListener('click', () => {
if (chunkSequenceAudio) {
stopChunkSequence(true);
return;
}
const cards = Array.from(body.querySelectorAll('.library-chunk-card'));
const items = cards.map(card => {
const playButton = card.querySelector('.library-chunk-play');
return {
card,
url: playButton?.getAttribute('data-audio-url') || '',
index: Number(card.getAttribute('data-idx'))
};
}).filter(item => item.url);
let startIndex = 0;
const preferredIndex = chunkSequenceStartIndex ?? chunkSequenceResumeIndex;
if (preferredIndex !== null && preferredIndex !== undefined) {
const preferredPos = items.findIndex(item => item.index === preferredIndex);
if (preferredPos >= 0) {
startIndex = preferredPos;
} else {
const nextPos = items.findIndex(item => item.index > preferredIndex);
if (nextPos >= 0) {
startIndex = nextPos;
}
}
}
chunkSequenceButton = playAllButton;
chunkSequenceLabel = playAllLabel || null;
startChunkSequence(items, startIndex);
});
}
// Chunk card expand/collapse toggle
body.querySelectorAll('.library-chunk-summary').forEach(summary => {
summary.addEventListener('click', (e) => {
// Don't toggle if clicking the play button
if (e.target.closest('.library-chunk-play')) return;
const chunkId = summary.getAttribute('data-chunk-id');
const details = body.querySelector(`.library-chunk-details[data-chunk-id="${chunkId}"]`);
const toggle = summary.querySelector('.chunk-expand-toggle');
if (details) {
const isCollapsed = details.classList.contains('collapsed');
if (isCollapsed) {
details.classList.remove('collapsed');
if (toggle) toggle.textContent = '▼';
const card = summary.closest('.library-chunk-card');
setChunkSequenceStartFromCard(card);
highlightManualChunkSelection(card);
} else {
details.classList.add('collapsed');
if (toggle) toggle.textContent = '▶';
}
}
});
});
// Play/Stop buttons
body.querySelectorAll('.library-chunk-play').forEach(btn => {
btn.addEventListener('click', (e) => {
e.stopPropagation(); // Prevent triggering expand/collapse
const card = btn.closest('.library-chunk-card');
setChunkSequenceStartFromCard(card);
highlightManualChunkSelection(card);
handleLibraryChunkPlayClick(btn);
});
});
// Populate voice selects
populateLibraryVoiceSelects(engine);
// Regenerate buttons for individual chunks
body.querySelectorAll('.library-chunk-regen').forEach(btn => {
btn.addEventListener('click', () => {
const chunkId = btn.getAttribute('data-chunk-id');
triggerLibraryChunkRegen(jobId, chunkId, btn);
});
});
body.querySelectorAll('.library-chunk-voice-select').forEach(select => {
select.addEventListener('change', () => {
const chunkId = select.getAttribute('data-chunk-id');
if (!chunkId) return;
const card = select.closest('.library-chunk-card');
const engineSelect = card?.querySelector('.library-chunk-engine-select');
const engineOverride = engineSelect?.value
|| engineSelect?.dataset.selectedEngine
|| engineSelect?.dataset.currentEngine
|| '';
const normalizedEngine = (engineOverride || engine || '').toLowerCase().replace(/[_-]/g, '');
const value = (select.value || '').trim();
if (!value) {
delete libraryChunkVoiceOverrides[chunkId];
return;
}
if (normalizedEngine.includes('chatterbox')
|| normalizedEngine.includes('voxcpm')
|| normalizedEngine.includes('pockettts')
|| (normalizedEngine.includes('qwen3') && normalizedEngine.includes('clone'))
) {
libraryChunkVoiceOverrides[chunkId] = { audio_prompt_path: value };
} else {
const voiceData = libraryVoiceMap.get(value);
libraryChunkVoiceOverrides[chunkId] = { voice: value, lang_code: voiceData?.langCode || 'a' };
}
});
});
// Bulk speaker accordion toggle
body.querySelectorAll('.bulk-speaker-summary').forEach(summary => {
summary.addEventListener('click', (e) => {
// Don't toggle if clicking the checkbox
if (e.target.closest('.bulk-speaker-checkbox')) return;
const speaker = summary.getAttribute('data-speaker');
const details = body.querySelector(`.bulk-speaker-details[data-speaker="${speaker}"]`);
const toggle = summary.querySelector('.bulk-expand-toggle');
if (details) {
const isCollapsed = details.classList.contains('collapsed');
if (isCollapsed) {
details.classList.remove('collapsed');
if (toggle) toggle.textContent = '▼';
} else {
details.classList.add('collapsed');
if (toggle) toggle.textContent = '▶';
}
}
});
});
// Bulk speaker checkbox and voice select handlers
body.querySelectorAll('.bulk-speaker-checkbox').forEach(checkbox => {
checkbox.addEventListener('change', (e) => {
e.stopPropagation(); // Prevent triggering accordion
updateBulkRegenButtonState(checkbox);
});
});
body.querySelectorAll('.bulk-speaker-voice-select').forEach(select => {
select.addEventListener('change', () => {
const card = select.closest('.bulk-speaker-card');
const checkbox = card?.querySelector('.bulk-speaker-checkbox');
if (checkbox) updateBulkRegenButtonState(checkbox);
});
});
// Bulk regenerate buttons
body.querySelectorAll('.bulk-speaker-regen').forEach(btn => {
btn.addEventListener('click', () => {
const speaker = btn.getAttribute('data-speaker');
// Use per-speaker engine dropdown or original engine
const card = btn.closest('.bulk-speaker-card');
const engineSelect = card?.querySelector('.bulk-speaker-engine-select');
const engineOverride = engineSelect?.value || engine;
triggerBulkSpeakerRegen(jobId, speaker, chunks, engineOverride, btn);
});
});
// Individual chunk FX sliders
body.querySelectorAll('.chunk-speed-slider').forEach(slider => {
slider.addEventListener('input', () => {
const valueSpan = slider.parentElement.querySelector('.chunk-speed-value');
if (valueSpan) valueSpan.textContent = `${parseFloat(slider.value).toFixed(2)}x`;
updateChunkApplyFxButtonState(slider);
});
});
body.querySelectorAll('.chunk-pitch-slider').forEach(slider => {
slider.addEventListener('input', () => {
const valueSpan = slider.parentElement.querySelector('.chunk-pitch-value');
if (valueSpan) valueSpan.textContent = parseFloat(slider.value).toFixed(1);
updateChunkApplyFxButtonState(slider);
});
});
// Individual chunk Preview FX buttons
body.querySelectorAll('.library-chunk-preview-fx').forEach(btn => {
btn.addEventListener('click', () => {
const chunkId = btn.getAttribute('data-chunk-id');
triggerChunkPreviewFx(jobId, chunkId, btn);
});
});
// Individual chunk Apply FX buttons
body.querySelectorAll('.library-chunk-apply-fx').forEach(btn => {
btn.addEventListener('click', () => {
const chunkId = btn.getAttribute('data-chunk-id');
triggerChunkApplyFx(jobId, chunkId, btn);
});
});
// Bulk speaker FX sliders
body.querySelectorAll('.bulk-speed-slider').forEach(slider => {
slider.addEventListener('input', () => {
const valueSpan = slider.parentElement.querySelector('.bulk-speed-value');
if (valueSpan) valueSpan.textContent = `${parseFloat(slider.value).toFixed(2)}x`;
updateBulkApplyFxButtonState(slider);
});
});
body.querySelectorAll('.bulk-pitch-slider').forEach(slider => {
slider.addEventListener('input', () => {
const valueSpan = slider.parentElement.querySelector('.bulk-pitch-value');
if (valueSpan) valueSpan.textContent = parseFloat(slider.value).toFixed(1);
updateBulkApplyFxButtonState(slider);
});
});
// Bulk speaker Apply FX buttons
body.querySelectorAll('.bulk-speaker-apply-fx').forEach(btn => {
btn.addEventListener('click', () => {
const speaker = btn.getAttribute('data-speaker');
triggerBulkSpeakerApplyFx(jobId, speaker, chunks, btn);
});
});
}
async function initLibraryVoiceFilters(engine) {
const normalizedEngine = (engine || '').toLowerCase();
const usesPrompts = normalizedEngine.includes('chatterbox')
|| normalizedEngine.includes('voxcpm')
|| normalizedEngine.includes('pockettts')
|| (normalizedEngine.includes('qwen3') && normalizedEngine.includes('clone'));
if (!usesPrompts) return;
const genderFilter = document.getElementById('library-voice-filter-gender');
const languageFilter = document.getElementById('library-voice-filter-language');
if (!genderFilter || !languageFilter) return;
// Reset filter state
libraryVoiceFilters = { gender: 'all', language: 'all' };
// Fetch voice prompts to populate filter options
try {
const response = await fetch('/api/voice-prompts');
const data = await response.json();
if (data.success && data.prompts) {
const genders = new Set();
const languages = new Set();
data.prompts.forEach(p => {
if (p.gender) genders.add(p.gender);
if (p.language) languages.add(p.language);
});
// Populate gender filter
genderFilter.innerHTML = '';
[...genders].sort().forEach(g => {
const opt = document.createElement('option');
opt.value = g.toLowerCase();
opt.textContent = g;
genderFilter.appendChild(opt);
});
// Populate language filter
languageFilter.innerHTML = '';
[...languages].sort((a, b) =>
getLibraryLanguageDisplayName(a).localeCompare(getLibraryLanguageDisplayName(b))
).forEach(lang => {
const opt = document.createElement('option');
opt.value = lang;
opt.textContent = getLibraryLanguageDisplayName(lang);
languageFilter.appendChild(opt);
});
}
} catch (err) {
console.error('Failed to load voice prompts for filters:', err);
}
// Wire up filter change events
genderFilter.addEventListener('change', () => {
libraryVoiceFilters.gender = genderFilter.value;
populateLibraryVoiceSelects(engine);
});
languageFilter.addEventListener('change', () => {
libraryVoiceFilters.language = languageFilter.value;
populateLibraryVoiceSelects(engine);
});
}
async function populateLibraryVoiceSelects(engine) {
const body = document.getElementById('chunk-review-modal-body');
if (!body) return;
const chunks = chunkReviewModalData?.chunks || [];
const normalizedEngine = (engine || '').toLowerCase().replace(/[_-]/g, '');
const isChatterbox = normalizedEngine.includes('chatterbox');
const isVoxCPM = normalizedEngine.includes('voxcpm');
const isQwen = normalizedEngine.includes('qwen3');
const isQwenClone = normalizedEngine.includes('qwen3') && normalizedEngine.includes('clone');
const isPocketPreset = normalizedEngine.includes('pocketttspreset');
const isPocket = normalizedEngine.includes('pockettts') && !isPocketPreset;
const usesVoicePrompts = isChatterbox || isVoxCPM || isQwenClone || isPocket;
let voices = [];
try {
if (usesVoicePrompts) {
// Chatterbox and VoxCPM use voice prompts
const response = await fetch('/api/voice-prompts');
const data = await response.json();
if (data.success) {
voices = (data.prompts || []).map(p => ({
id: p.name, // API returns 'name' as the filename
name: p.display || p.name.replace('.wav', ''),
duration: p.duration_seconds,
gender: p.gender,
language: p.language,
transcript: p.transcript,
isPrompt: true
}));
// Apply filters
if (libraryVoiceFilters.gender && libraryVoiceFilters.gender !== 'all') {
voices = voices.filter(v =>
(v.gender || '').toLowerCase() === libraryVoiceFilters.gender.toLowerCase()
);
}
if (libraryVoiceFilters.language && libraryVoiceFilters.language !== 'all') {
voices = voices.filter(v => v.language === libraryVoiceFilters.language);
}
}
} else if (isQwen) {
// Qwen3 uses /api/qwen3/metadata for speakers
const response = await fetch('/api/qwen3/metadata');
const data = await response.json();
if (data.success && data.speakers) {
voices = data.speakers.map(speaker => ({
id: speaker,
name: speaker,
isQwen: true
}));
}
} else if (isPocketPreset) {
const response = await fetch('/api/pocket-tts/voices');
const data = await response.json();
if (data.success && data.voices) {
voices = data.voices.map(voice => ({
id: voice,
name: voice,
isPrompt: false
}));
}
} else {
// Kokoro and others use /api/voices - returns nested structure by language
const response = await fetch('/api/voices');
const data = await response.json();
if (data.success && data.voices) {
// Flatten the nested voice structure, keeping lang_code for each voice
Object.entries(data.voices).forEach(([langKey, langConfig]) => {
const langLabel = langConfig.language || langKey;
const langCode = langConfig.lang_code || 'a';
// Add built-in voices
(langConfig.voices || []).forEach(voiceName => {
voices.push({
id: voiceName,
name: `${voiceName} (${langLabel})`,
langCode: langCode,
isPrompt: false
});
});
// Add custom voices
(langConfig.custom_voices || []).forEach(cv => {
voices.push({
id: cv.code || cv.id,
name: `${cv.name || cv.code} (${langLabel}, custom)`,
langCode: langCode,
isPrompt: false
});
});
});
}
}
} catch (err) {
console.error('Failed to load voices:', err);
}
// Store voice map globally for lookup during regeneration
libraryVoiceMap = new Map();
voices.forEach(v => libraryVoiceMap.set(v.id, v));
// Helper to populate a single chunk voice select based on engine
// Minimum duration requirements per engine (in seconds)
const ENGINE_MIN_DURATION = {
'chatterbox': 5.0,
'chatterboxturbolocal': 5.0,
'chatterboxturborepl': 5.0,
'voxcpmlocal': 0, // VoxCPM accepts any duration
'pockettts': 0,
'pocketttspreset': 0,
};
function getMinDuration(engineName) {
const normalized = (engineName || '').toLowerCase().replace(/[_-]/g, '');
for (const [key, val] of Object.entries(ENGINE_MIN_DURATION)) {
if (normalized.includes(key)) return val;
}
return 0;
}
function getPromptFilters(container, genderSelector, languageSelector) {
if (!container) {
return { gender: 'all', language: 'all' };
}
const genderSelect = container.querySelector(genderSelector);
const languageSelect = container.querySelector(languageSelector);
return {
gender: genderSelect?.value || 'all',
language: languageSelect?.value || 'all'
};
}
let voicePromptCache = null;
let voicePromptRequest = null;
let qwenMetadataCache = null;
let qwenMetadataRequest = null;
let voicesCache = null;
let voicesRequest = null;
let pocketTtsVoicesCache = null;
let pocketTtsVoicesRequest = null;
async function getVoicePromptsCached() {
if (voicePromptCache) return voicePromptCache;
if (!voicePromptRequest) {
voicePromptRequest = fetch('/api/voice-prompts')
.then(response => response.json())
.then(data => {
voicePromptCache = data;
return data;
})
.catch(err => {
voicePromptRequest = null;
throw err;
});
}
return voicePromptRequest;
}
async function getQwenMetadataCached() {
if (qwenMetadataCache) return qwenMetadataCache;
if (!qwenMetadataRequest) {
qwenMetadataRequest = fetch('/api/qwen3/metadata')
.then(response => response.json())
.then(data => {
qwenMetadataCache = data;
return data;
})
.catch(err => {
qwenMetadataRequest = null;
throw err;
});
}
return qwenMetadataRequest;
}
async function getVoicesCached() {
if (voicesCache) return voicesCache;
if (!voicesRequest) {
voicesRequest = fetch('/api/voices')
.then(response => response.json())
.then(data => {
voicesCache = data;
return data;
})
.catch(err => {
voicesRequest = null;
throw err;
});
}
return voicesRequest;
}
async function getPocketTtsVoicesCached() {
if (pocketTtsVoicesCache) return pocketTtsVoicesCache;
if (!pocketTtsVoicesRequest) {
pocketTtsVoicesRequest = fetch('/api/pocket-tts/voices')
.then(response => response.json())
.then(data => {
pocketTtsVoicesCache = data;
return data;
})
.catch(err => {
pocketTtsVoicesRequest = null;
throw err;
});
}
return pocketTtsVoicesRequest;
}
async function populatePromptFilterOptions(genderSelect, languageSelect) {
if (!genderSelect || !languageSelect) return;
try {
const data = await getVoicePromptsCached();
if (!data.success || !data.prompts) return;
const genders = new Set();
const languages = new Set();
data.prompts.forEach(prompt => {
if (prompt.gender) genders.add(prompt.gender);
if (prompt.language) languages.add(prompt.language);
});
const currentGender = genderSelect.value || 'all';
const currentLanguage = languageSelect.value || 'all';
genderSelect.innerHTML = '';
[...genders].sort().forEach(gender => {
const opt = document.createElement('option');
opt.value = gender.toLowerCase();
opt.textContent = gender;
genderSelect.appendChild(opt);
});
languageSelect.innerHTML = '';
[...languages].sort((a, b) =>
getLibraryLanguageDisplayName(a).localeCompare(getLibraryLanguageDisplayName(b))
).forEach(language => {
const opt = document.createElement('option');
opt.value = language;
opt.textContent = getLibraryLanguageDisplayName(language);
languageSelect.appendChild(opt);
});
genderSelect.value = currentGender;
languageSelect.value = currentLanguage;
} catch (err) {
console.error('Failed to load prompt filter options:', err);
}
}
async function populateChunkVoiceSelect(select, chunkId, engineName, filters = null, currentLabel = '', selectedValue = '') {
const isPocketPreset = engineName.includes('pocketttspreset');
const usesPrompts = engineName.includes('chatterbox')
|| engineName.includes('voxcpm')
|| (engineName.includes('pockettts') && !isPocketPreset)
|| (engineName.includes('qwen3') && engineName.includes('clone'));
const isQwenEngine = engineName.includes('qwen3');
const minDuration = getMinDuration(engineName);
const activeFilters = filters || libraryVoiceFilters;
let chunkVoices = [];
try {
if (usesPrompts) {
const data = await getVoicePromptsCached();
if (data.success && data.prompts) {
chunkVoices = data.prompts.map(p => ({
id: p.path || p.name,
name: p.display || p.name,
duration: p.duration_seconds,
gender: p.gender,
language: p.language,
isPrompt: true
}));
}
} else if (isQwenEngine) {
const data = await getQwenMetadataCached();
if (data.success && data.speakers) {
chunkVoices = data.speakers.map(speaker => ({
id: speaker,
name: speaker,
isQwen: true
}));
}
} else if (isPocketPreset) {
const data = await getPocketTtsVoicesCached();
if (data.success && data.voices) {
chunkVoices = data.voices.map(voice => ({
id: voice,
name: voice,
isPrompt: false
}));
}
} else {
const data = await getVoicesCached();
if (data.success && data.voices) {
Object.entries(data.voices).forEach(([langKey, langConfig]) => {
const langLabel = langConfig.language || langKey;
const langCode = langConfig.lang_code || 'a';
(langConfig.voices || []).forEach(voiceName => {
chunkVoices.push({
id: voiceName,
name: `${voiceName} (${langLabel})`,
langCode: langCode,
isPrompt: false
});
});
(langConfig.custom_voices || []).forEach(cv => {
chunkVoices.push({
id: cv.code || cv.id,
name: `${cv.name || cv.code} (${langLabel}, custom)`,
langCode: langCode,
isPrompt: false
});
});
});
}
}
} catch (err) {
console.error('Failed to load voices for chunk:', err);
}
// Apply filters for prompt-based voices
if (usesPrompts) {
if (activeFilters.gender && activeFilters.gender !== 'all') {
chunkVoices = chunkVoices.filter(v =>
(v.gender || '').toLowerCase() === activeFilters.gender.toLowerCase()
);
}
if (activeFilters.language && activeFilters.language !== 'all') {
chunkVoices = chunkVoices.filter(v => v.language === activeFilters.language);
}
}
const defaultLabel = currentLabel ? `Current: ${currentLabel}` : '-- Keep current --';
select.innerHTML = ``;
chunkVoices.forEach(v => {
const opt = document.createElement('option');
opt.value = v.id;
const durationLabel = v.duration != null ? ` · ${v.duration.toFixed(1)}s` : '';
// Build label with gender and language for prompt voices
let displayName = v.name;
if (v.isPrompt && (v.gender || v.language)) {
const gender = v.gender ? ` [${v.gender.charAt(0).toUpperCase()}]` : '';
const lang = v.language ? ` ${getLibraryLanguageDisplayName(v.language)}` : '';
displayName = `${v.name} ·${gender}${lang}`;
}
opt.textContent = `${displayName}${durationLabel}`;
opt.dataset.gender = v.gender || '';
opt.dataset.language = v.language || '';
// Disable if duration is too short for this engine
if (minDuration > 0 && v.duration != null && v.duration < minDuration) {
opt.disabled = true;
opt.style.color = '#ff6b6b';
opt.textContent = `${displayName}${durationLabel} (too short)`;
}
select.appendChild(opt);
});
if (selectedValue) {
select.value = selectedValue;
if (select.value !== selectedValue) {
select.value = '';
}
}
}
// Helper to populate bulk speaker voice select based on engine
async function populateBulkVoiceSelect(select, speaker, engineName, filters = null) {
const isPocketPreset = engineName.includes('pocketttspreset');
const usesPrompts = engineName.includes('chatterbox')
|| engineName.includes('voxcpm')
|| (engineName.includes('pockettts') && !isPocketPreset)
|| (engineName.includes('qwen3') && engineName.includes('clone'));
const isQwenEngine = engineName.includes('qwen3');
const minDuration = getMinDuration(engineName);
const activeFilters = filters || libraryVoiceFilters;
let bulkVoices = [];
try {
if (usesPrompts) {
const data = await getVoicePromptsCached();
if (data.success && data.prompts) {
bulkVoices = data.prompts.map(p => ({
id: p.path || p.name,
name: p.display || p.name,
duration: p.duration_seconds,
gender: p.gender,
language: p.language,
isPrompt: true
}));
}
} else if (isQwenEngine) {
const data = await getQwenMetadataCached();
if (data.success && data.speakers) {
bulkVoices = data.speakers.map(speaker => ({
id: speaker,
name: speaker,
isQwen: true
}));
}
} else if (isPocketPreset) {
const data = await getPocketTtsVoicesCached();
if (data.success && data.voices) {
bulkVoices = data.voices.map(voice => ({
id: voice,
name: voice,
isPrompt: false
}));
}
} else {
const data = await getVoicesCached();
if (data.success && data.voices) {
Object.entries(data.voices).forEach(([langKey, langConfig]) => {
const langLabel = langConfig.language || langKey;
const langCode = langConfig.lang_code || 'a';
(langConfig.voices || []).forEach(voiceName => {
bulkVoices.push({
id: voiceName,
name: `${voiceName} (${langLabel})`,
langCode: langCode,
isPrompt: false
});
});
(langConfig.custom_voices || []).forEach(cv => {
bulkVoices.push({
id: cv.code || cv.id,
name: `${cv.name || cv.code} (${langLabel}, custom)`,
langCode: langCode,
isPrompt: false
});
});
});
}
}
} catch (err) {
console.error('Failed to load voices for bulk speaker:', err);
}
// Apply filters for prompt-based voices
if (usesPrompts) {
if (activeFilters.gender && activeFilters.gender !== 'all') {
bulkVoices = bulkVoices.filter(v =>
(v.gender || '').toLowerCase() === activeFilters.gender.toLowerCase()
);
}
if (activeFilters.language && activeFilters.language !== 'all') {
bulkVoices = bulkVoices.filter(v => v.language === activeFilters.language);
}
}
select.innerHTML = '';
bulkVoices.forEach(v => {
const opt = document.createElement('option');
opt.value = v.id;
const durationLabel = v.duration != null ? ` · ${v.duration.toFixed(1)}s` : '';
// Build label with gender and language for prompt voices
let displayName = v.name;
if (v.isPrompt && (v.gender || v.language)) {
const gender = v.gender ? ` [${v.gender.charAt(0).toUpperCase()}]` : '';
const lang = v.language ? ` ${getLibraryLanguageDisplayName(v.language)}` : '';
displayName = `${v.name} ·${gender}${lang}`;
}
opt.textContent = `${displayName}${durationLabel}`;
opt.dataset.gender = v.gender || '';
opt.dataset.language = v.language || '';
// Disable if duration is too short for this engine
if (minDuration > 0 && v.duration != null && v.duration < minDuration) {
opt.disabled = true;
opt.style.color = '#ff6b6b';
opt.textContent = `${displayName}${durationLabel} (too short)`;
}
select.appendChild(opt);
});
}
const chunkById = new Map(chunks.map(chunk => [chunk.id, chunk]));
// Populate chunk engine selects
body.querySelectorAll('.library-chunk-engine-select').forEach(select => {
const chunkId = select.getAttribute('data-chunk-id');
const chunk = chunkById.get(chunkId);
const currentEngine = chunk?.engine || engine;
const currentEngineLabel = currentEngine ? `Current: ${formatEngineName(currentEngine)}` : '-- Same engine --';
const normalizedCurrentEngine = (currentEngine || '').toLowerCase();
select.dataset.currentEngine = currentEngine || '';
select.dataset.selectedEngine = '';
select.innerHTML = `
`;
if (normalizedCurrentEngine) {
Array.from(select.options).forEach(option => {
if (option.value && normalizedCurrentEngine.includes(option.value.replace(/[_-]/g, ''))) {
option.textContent = `Current: ${option.textContent}`;
}
});
}
const regenSection = select.closest('.chunk-regen-section');
const voiceSelect = regenSection?.querySelector('.library-chunk-voice-select');
const qwen3Options = regenSection?.querySelector('.chunk-qwen3-options');
const promptFilters = regenSection?.querySelector('.chunk-prompt-filters');
const genderFilter = regenSection?.querySelector('.chunk-voice-filter-gender');
const languageFilter = regenSection?.querySelector('.chunk-voice-filter-language');
if (voiceSelect) {
const currentVoiceLabel = voiceSelect.dataset.currentVoiceLabel || '';
const voiceAssignment = chunk?.voice_assignment || {};
const override = libraryChunkVoiceOverrides[chunkId] || {};
const selectedValue = override.audio_prompt_path || override.voice
|| voiceAssignment.audio_prompt_path || voiceAssignment.voice || '';
const filters = getPromptFilters(regenSection, '.chunk-voice-filter-gender', '.chunk-voice-filter-language');
populateChunkVoiceSelect(voiceSelect, chunkId, currentEngine, filters, currentVoiceLabel, selectedValue);
}
const normalizedEngineValue = (currentEngine || '').toLowerCase();
const isQwen = normalizedEngineValue.includes('qwen3') && !normalizedEngineValue.includes('clone');
const usesPrompts = normalizedEngineValue.includes('chatterbox')
|| normalizedEngineValue.includes('voxcpm')
|| (normalizedEngineValue.includes('pockettts') && !normalizedEngineValue.includes('pocketttspreset'))
|| (normalizedEngineValue.includes('qwen3') && normalizedEngineValue.includes('clone'));
if (promptFilters) {
promptFilters.style.display = usesPrompts ? 'block' : 'none';
if (usesPrompts) {
populatePromptFilterOptions(genderFilter, languageFilter);
}
}
if (qwen3Options) {
qwen3Options.style.display = isQwen ? 'block' : 'none';
if (isQwen) {
const langSelect = qwen3Options.querySelector('.chunk-qwen3-language');
if (langSelect && langSelect.options.length <= 1) {
populateQwen3LanguageSelect(langSelect);
}
}
}
if (genderFilter) {
genderFilter.addEventListener('change', () => {
if (!voiceSelect) return;
const currentVoiceLabel = voiceSelect.dataset.currentVoiceLabel || '';
const voiceAssignment = chunk?.voice_assignment || {};
const override = libraryChunkVoiceOverrides[chunkId] || {};
const selectedValue = override.audio_prompt_path || override.voice
|| voiceAssignment.audio_prompt_path || voiceAssignment.voice || '';
const filters = getPromptFilters(regenSection, '.chunk-voice-filter-gender', '.chunk-voice-filter-language');
populateChunkVoiceSelect(voiceSelect, chunkId, currentEngine, filters, currentVoiceLabel, selectedValue);
});
}
if (languageFilter) {
languageFilter.addEventListener('change', () => {
if (!voiceSelect) return;
const currentVoiceLabel = voiceSelect.dataset.currentVoiceLabel || '';
const voiceAssignment = chunk?.voice_assignment || {};
const override = libraryChunkVoiceOverrides[chunkId] || {};
const selectedValue = override.audio_prompt_path || override.voice
|| voiceAssignment.audio_prompt_path || voiceAssignment.voice || '';
const filters = getPromptFilters(regenSection, '.chunk-voice-filter-gender', '.chunk-voice-filter-language');
populateChunkVoiceSelect(voiceSelect, chunkId, currentEngine, filters, currentVoiceLabel, selectedValue);
});
}
// When engine changes, repopulate the voice dropdown for this chunk and show/hide Qwen3 options
select.addEventListener('change', async () => {
const selectedEngine = select.value || currentEngine || engine;
select.dataset.selectedEngine = selectedEngine || '';
const regenSection = select.closest('.chunk-regen-section');
const voiceSelect = regenSection?.querySelector('.library-chunk-voice-select');
const qwen3Options = regenSection?.querySelector('.chunk-qwen3-options');
const promptFilters = regenSection?.querySelector('.chunk-prompt-filters');
const genderFilter = regenSection?.querySelector('.chunk-voice-filter-gender');
const languageFilter = regenSection?.querySelector('.chunk-voice-filter-language');
if (voiceSelect) {
const currentVoiceLabel = voiceSelect.dataset.currentVoiceLabel || '';
const voiceAssignment = chunk?.voice_assignment || {};
const override = libraryChunkVoiceOverrides[chunkId] || {};
const selectedValue = override.audio_prompt_path || override.voice
|| voiceAssignment.audio_prompt_path || voiceAssignment.voice || '';
const filters = getPromptFilters(regenSection, '.chunk-voice-filter-gender', '.chunk-voice-filter-language');
await populateChunkVoiceSelect(voiceSelect, chunkId, selectedEngine, filters, currentVoiceLabel, selectedValue);
}
// Show/hide Qwen3 options based on engine
const normalizedSelectedEngine = selectedEngine.toLowerCase();
const isQwen = normalizedSelectedEngine.includes('qwen3')
&& !normalizedSelectedEngine.includes('clone');
const usesPrompts = normalizedSelectedEngine.includes('chatterbox')
|| normalizedSelectedEngine.includes('voxcpm')
|| (normalizedSelectedEngine.includes('pockettts') && !normalizedSelectedEngine.includes('pocketttspreset'))
|| (normalizedSelectedEngine.includes('qwen3') && normalizedSelectedEngine.includes('clone'));
if (promptFilters) {
promptFilters.style.display = usesPrompts ? 'block' : 'none';
if (usesPrompts) {
await populatePromptFilterOptions(genderFilter, languageFilter);
}
}
if (qwen3Options) {
qwen3Options.style.display = isQwen ? 'block' : 'none';
// Populate language dropdown if Qwen3 selected
if (isQwen) {
const langSelect = qwen3Options.querySelector('.chunk-qwen3-language');
if (langSelect && langSelect.options.length <= 1) {
await populateQwen3LanguageSelect(langSelect);
}
}
}
});
});
// Populate bulk speaker engine selects
body.querySelectorAll('.bulk-speaker-engine-select').forEach(select => {
const speaker = select.getAttribute('data-speaker');
select.innerHTML = `
`;
// When engine changes, repopulate the voice dropdown for this speaker and show/hide Qwen3 options
select.addEventListener('change', async () => {
const selectedEngine = select.value || engine;
const regenSection = select.closest('.bulk-regen-section');
const voiceSelect = regenSection?.querySelector('.bulk-speaker-voice-select');
const qwen3Options = regenSection?.querySelector('.bulk-qwen3-options');
const promptFilters = regenSection?.querySelector('.bulk-prompt-filters');
const genderFilter = regenSection?.querySelector('.bulk-voice-filter-gender');
const languageFilter = regenSection?.querySelector('.bulk-voice-filter-language');
if (voiceSelect) {
const filters = getPromptFilters(regenSection, '.bulk-voice-filter-gender', '.bulk-voice-filter-language');
await populateBulkVoiceSelect(voiceSelect, speaker, selectedEngine, filters);
}
// Show/hide Qwen3 options based on engine
const normalizedSelectedEngine = selectedEngine.toLowerCase();
const isQwen = normalizedSelectedEngine.includes('qwen3')
&& !normalizedSelectedEngine.includes('clone');
const usesPrompts = normalizedSelectedEngine.includes('chatterbox')
|| normalizedSelectedEngine.includes('voxcpm')
|| (normalizedSelectedEngine.includes('qwen3') && normalizedSelectedEngine.includes('clone'));
if (promptFilters) {
promptFilters.style.display = usesPrompts ? 'block' : 'none';
if (usesPrompts) {
await populatePromptFilterOptions(genderFilter, languageFilter);
}
}
if (qwen3Options) {
qwen3Options.style.display = isQwen ? 'block' : 'none';
// Populate language dropdown if Qwen3 selected
if (isQwen) {
const langSelect = qwen3Options.querySelector('.bulk-qwen3-language');
if (langSelect && langSelect.options.length <= 1) {
await populateQwen3LanguageSelect(langSelect);
}
}
}
});
});
// Also populate bulk speaker voice selects
body.querySelectorAll('.bulk-speaker-voice-select').forEach(select => {
select.innerHTML = '';
});
// Initialize prompt filters and voice lists for bulk speakers
body.querySelectorAll('.bulk-speaker-card').forEach(card => {
const engineSelect = card.querySelector('.bulk-speaker-engine-select');
const voiceSelect = card.querySelector('.bulk-speaker-voice-select');
const promptFilters = card.querySelector('.bulk-prompt-filters');
const genderFilter = card.querySelector('.bulk-voice-filter-gender');
const languageFilter = card.querySelector('.bulk-voice-filter-language');
if (!engineSelect || !voiceSelect) return;
const selectedEngine = engineSelect.value || engine;
const normalizedEngine = selectedEngine.toLowerCase();
const usesPrompts = normalizedEngine.includes('chatterbox')
|| normalizedEngine.includes('voxcpm')
|| (normalizedEngine.includes('pockettts') && !normalizedEngine.includes('pocketttspreset'))
|| (normalizedEngine.includes('qwen3') && normalizedEngine.includes('clone'));
if (promptFilters) {
promptFilters.style.display = usesPrompts ? 'block' : 'none';
if (usesPrompts) {
populatePromptFilterOptions(genderFilter, languageFilter);
const filters = getPromptFilters(card, '.bulk-voice-filter-gender', '.bulk-voice-filter-language');
populateBulkVoiceSelect(voiceSelect, card.dataset.speaker || '', selectedEngine, filters);
}
}
if (genderFilter) {
genderFilter.addEventListener('change', async () => {
const currentEngine = engineSelect.value || engine;
const filters = getPromptFilters(card, '.bulk-voice-filter-gender', '.bulk-voice-filter-language');
await populateBulkVoiceSelect(voiceSelect, card.dataset.speaker || '', currentEngine, filters);
});
}
if (languageFilter) {
languageFilter.addEventListener('change', async () => {
const currentEngine = engineSelect.value || engine;
const filters = getPromptFilters(card, '.bulk-voice-filter-gender', '.bulk-voice-filter-language');
await populateBulkVoiceSelect(voiceSelect, card.dataset.speaker || '', currentEngine, filters);
});
}
});
// Store engine info for bulk regen (usesVoicePrompts covers both Chatterbox and VoxCPM)
body.dataset.usesVoicePrompts = usesVoicePrompts ? 'true' : 'false';
}
// Helper to populate Qwen3 language dropdown
async function populateQwen3LanguageSelect(select) {
try {
const response = await fetch('/api/qwen3/metadata');
const data = await response.json();
if (data.success && data.languages) {
data.languages.forEach(lang => {
const opt = document.createElement('option');
opt.value = lang;
opt.textContent = lang;
select.appendChild(opt);
});
}
} catch (err) {
console.error('Failed to load Qwen3 languages:', err);
}
}
function updateBulkRegenButtonState(checkbox) {
const card = checkbox.closest('.bulk-speaker-card');
if (!card) return;
const select = card.querySelector('.bulk-speaker-voice-select');
const btn = card.querySelector('.bulk-speaker-regen');
if (!select || !btn) return;
// Enable button only if checkbox is checked AND a voice is selected
const isChecked = checkbox.checked;
const hasVoice = select.value !== '';
btn.disabled = !(isChecked && hasVoice);
}
async function triggerBulkSpeakerRegen(jobId, speaker, chunks, engine, button) {
const body = document.getElementById('chunk-review-modal-body');
const card = button.closest('.bulk-speaker-card');
const select = card?.querySelector('.bulk-speaker-voice-select');
const voiceValue = select?.value;
if (!voiceValue) {
alert('Please select a voice first.');
return;
}
// Get all chunks for this speaker
const speakerChunks = chunks.filter(c => (c.speaker || 'default') === speaker);
if (speakerChunks.length === 0) {
alert('No chunks found for this speaker.');
return;
}
const normalizedEngine = (engine || '').toLowerCase().replace(/[_-]/g, '');
const isChatterbox = normalizedEngine.includes('chatterbox');
const isVoxCPM = normalizedEngine.includes('voxcpm');
const isQwenEngine = normalizedEngine.includes('qwen3');
const isQwenClone = normalizedEngine.includes('qwen3') && normalizedEngine.includes('clone');
const usesVoicePrompts = isChatterbox || isVoxCPM || isQwenClone;
// Build voice payload based on engine type
const voiceData = libraryVoiceMap.get(voiceValue || '');
let voicePayload;
if (usesVoicePrompts) {
const promptEntry = libraryVoiceMap.get(voiceValue);
voicePayload = {
audio_prompt_path: voiceValue,
...(promptEntry?.transcript ? { extra: { prompt_text: promptEntry.transcript } } : {})
};
} else if (isQwenEngine) {
// Get Qwen3 language and instruction from the bulk options
const qwen3Options = card?.querySelector('.bulk-qwen3-options');
const langSelect = qwen3Options?.querySelector('.bulk-qwen3-language');
const instructInput = qwen3Options?.querySelector('.bulk-qwen3-instruct');
const language = langSelect?.value || 'Auto';
let instruct = instructInput?.value?.trim() || '';
if (!instruct) {
const originalInstruction = speakerChunks
.map(chunk => getChunkInstruction(chunk))
.find(Boolean) || '';
instruct = originalInstruction;
}
voicePayload = {
voice: voiceValue,
extra: {
language: language,
...(instruct && { instruct: instruct })
}
};
} else {
voicePayload = { voice: voiceValue, lang_code: voiceData?.langCode || 'a' };
}
button.disabled = true;
button.textContent = `Regenerating ${speakerChunks.length}...`;
try {
// First restore the job to review mode
await fetch(`/api/library/${jobId}/restore-review`, { method: 'POST' });
// Regenerate each chunk for this speaker
for (const chunk of speakerChunks) {
const chunkId = chunk.id;
const chunkCard = document.querySelector(`.library-chunk-card[data-chunk-id="${chunkId}"]`);
const textarea = chunkCard?.querySelector('.library-chunk-textarea');
const text = textarea ? textarea.value.trim() : chunk.text;
if (!text) continue;
// Update the individual chunk's voice override
libraryChunkVoiceOverrides[chunkId] = { ...voicePayload };
const requestBody = {
chunk_id: chunkId,
text: text,
voice: voicePayload,
};
// Use the engine passed in from the per-speaker dropdown
if (engine) {
requestBody.engine = engine;
}
const response = await fetch(`/api/jobs/${jobId}/review/regen`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(requestBody),
});
const data = await response.json();
if (data.success) {
updateLibraryChunkStatus(chunkId, 'queued');
startLibraryChunkRegenWatcher(jobId, chunkId);
}
}
button.textContent = 'Queued!';
setTimeout(() => {
button.textContent = 'Regenerate All';
button.disabled = false;
}, 2000);
} catch (error) {
console.error('Bulk regen error:', error);
alert(error.message || 'Failed to queue bulk regeneration');
button.textContent = 'Regenerate All';
button.disabled = false;
}
}
async function triggerLibraryChunkRegen(jobId, chunkId, button) {
const card = button.closest('.library-chunk-card');
const textarea = card ? card.querySelector('.library-chunk-textarea') : null;
const text = textarea ? textarea.value.trim() : '';
if (!text) {
alert('Chunk text cannot be empty.');
return;
}
setRegenButtonBusy(button, true, '⟳ Queued...');
// Get engine override from per-chunk dropdown
const chunkEngineSelect = card?.querySelector('.library-chunk-engine-select');
const resolvedEngine = chunkEngineSelect?.value
|| chunkEngineSelect?.dataset.selectedEngine
|| chunkEngineSelect?.dataset.currentEngine
|| (chunkReviewModalData?.chunks || []).find(chunk => chunk.id === chunkId)?.engine
|| chunkReviewModalData?.engine
|| '';
// Get voice selection from per-chunk dropdown or stored override
const voiceSelect = card?.querySelector('.library-chunk-voice-select');
const voiceValue = voiceSelect?.value || '';
const storedOverride = libraryChunkVoiceOverrides[chunkId] || {};
const selectedVoiceValue = voiceValue || storedOverride.audio_prompt_path || storedOverride.voice || '';
// Build voice payload based on engine type
let voicePayload = libraryChunkVoiceOverrides[chunkId] || {};
const originalChunk = (chunkReviewModalData?.chunks || []).find(chunk => chunk.id === chunkId);
const normalizedEngine = (resolvedEngine || originalChunk?.engine || chunkReviewModalData?.engine || '')
.toLowerCase()
.replace(/[_-]/g, '');
const isChatterbox = normalizedEngine.includes('chatterbox');
const isVoxCPM = normalizedEngine.includes('voxcpm');
const isQwenEngine = normalizedEngine.includes('qwen3');
const isQwenClone = normalizedEngine.includes('qwen3') && normalizedEngine.includes('clone');
const usesVoicePrompts = isChatterbox || isVoxCPM || isQwenClone;
const voiceData = libraryVoiceMap.get(voiceValue);
if (usesVoicePrompts) {
if (selectedVoiceValue) {
const promptEntry = libraryVoiceMap.get(selectedVoiceValue);
voicePayload = {
audio_prompt_path: selectedVoiceValue,
...(promptEntry?.transcript ? { extra: { prompt_text: promptEntry.transcript } } : {})
};
}
} else if (isQwenEngine) {
// Get Qwen3 language and instruction from the chunk options
const qwen3Options = card?.querySelector('.chunk-qwen3-options');
const langSelect = qwen3Options?.querySelector('.chunk-qwen3-language');
const instructInput = qwen3Options?.querySelector('.chunk-qwen3-instruct');
const language = langSelect?.value || 'Auto';
let instruct = instructInput?.value?.trim() || '';
if (!instruct) {
instruct = getChunkInstruction(originalChunk);
}
const existingExtra = voicePayload.extra || {};
voicePayload = {
...voicePayload,
...(selectedVoiceValue ? { voice: selectedVoiceValue } : {}),
extra: {
...existingExtra,
language: language,
...(instruct && { instruct: instruct })
}
};
} else if (selectedVoiceValue) {
voicePayload = { voice: selectedVoiceValue, lang_code: voiceData?.langCode || 'a' };
}
if (Object.keys(voicePayload || {}).length > 0) {
libraryChunkVoiceOverrides[chunkId] = { ...voicePayload };
}
try {
// First restore the job to review mode if not already
await fetch(`/api/library/${jobId}/restore-review`, { method: 'POST' });
const requestBody = {
chunk_id: chunkId,
text: text,
voice: voicePayload,
engine: resolvedEngine, // Always send resolved engine
};
const response = await fetch(`/api/jobs/${jobId}/review/regen`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(requestBody),
});
const data = await response.json();
if (!data.success) {
throw new Error(data.error || 'Failed to queue regeneration');
}
// Update UI to show queued status
updateLibraryChunkStatus(chunkId, 'queued');
startLibraryChunkRegenWatcher(jobId, chunkId);
} catch (error) {
console.error('Regen error:', error);
alert(error.message || 'Failed to regenerate chunk');
setRegenButtonBusy(button, false);
}
}
function updateLibraryChunkStatus(chunkId, status) {
const card = document.querySelector(`.library-chunk-card[data-chunk-id="${chunkId}"]`);
if (!card) return;
const summary = card.querySelector('.library-chunk-summary');
if (!summary) return;
// Remove existing status badges
summary.querySelectorAll('.review-chip').forEach(el => el.remove());
let badge = '';
if (status === 'queued') {
badge = 'Queued';
} else if (status === 'running') {
badge = 'Rendering';
} else if (status === 'failed') {
badge = 'Failed';
} else if (status === 'completed') {
badge = 'Updated';
}
const regenButton = card.querySelector('.library-chunk-regen');
if (status === 'queued') {
setRegenButtonBusy(regenButton, true, '⟳ Queued...');
} else if (status === 'running') {
setRegenButtonBusy(regenButton, true, '⟳ Rendering...');
} else if (status === 'completed' || status === 'failed') {
setRegenButtonBusy(regenButton, false);
}
if (badge) {
const toggle = summary.querySelector('.chunk-expand-toggle');
if (toggle) {
toggle.insertAdjacentHTML('afterend', badge);
}
}
}
function startLibraryChunkRegenWatcher(jobId, chunkId) {
const key = `${jobId}:${chunkId}`;
if (libraryChunkRegenWatchers[key]) {
clearTimeout(libraryChunkRegenWatchers[key].timer);
}
const entry = { attempts: 0, timer: null };
libraryChunkRegenWatchers[key] = entry;
pollLibraryChunkStatus(jobId, chunkId, entry);
}
async function pollLibraryChunkStatus(jobId, chunkId, entry) {
entry.attempts++;
try {
const response = await fetch(`/api/jobs/${jobId}/chunks`);
const data = await response.json();
if (data.success) {
const chunks = data.chunks || [];
const regenTasks = data.regen_tasks || {};
const task = regenTasks[chunkId];
const status = task ? task.status : null;
updateLibraryChunkStatus(chunkId, status || 'completed');
// Update audio URL if completed
if (!status || status === 'completed' || status === 'failed') {
resetChunkRegenButton(chunkId);
const chunk = chunks.find(c => c.id === chunkId);
if (chunk && chunk.file_url) {
const card = document.querySelector(`.library-chunk-card[data-chunk-id="${chunkId}"]`);
if (card) {
const playBtn = card.querySelector('.library-chunk-play');
const cacheToken = chunk.regenerated_at || Date.now().toString();
const newUrl = `${chunk.file_url}?t=${encodeURIComponent(cacheToken)}`;
if (playBtn) {
playBtn.setAttribute('data-audio-url', newUrl);
playBtn.disabled = false;
}
// Update voice label (API returns 'voice', not 'voice_label')
const voiceLabelEl = card.querySelector('.library-chunk-voice-label');
const newVoiceLabel = chunk.voice || chunk.voice_label;
if (voiceLabelEl && newVoiceLabel) {
voiceLabelEl.textContent = newVoiceLabel;
}
}
}
delete libraryChunkRegenWatchers[`${jobId}:${chunkId}`];
return;
}
}
} catch (err) {
console.error('Poll error:', err);
}
if (entry.attempts >= LIBRARY_CHUNK_MAX_ATTEMPTS) {
delete libraryChunkRegenWatchers[`${jobId}:${chunkId}`];
return;
}
entry.timer = setTimeout(() => pollLibraryChunkStatus(jobId, chunkId, entry), LIBRARY_CHUNK_POLL_INTERVAL_MS);
}
async function recompileLibraryAudio() {
const jobId = chunkReviewModalJobId;
if (!jobId) return;
const recompileBtn = document.getElementById('chunk-review-recompile-btn');
if (recompileBtn) {
recompileBtn.disabled = true;
recompileBtn.textContent = 'Recompiling...';
}
try {
// Finish review to recompile
const response = await fetch(`/api/jobs/${jobId}/review/finish`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
});
const data = await response.json();
if (!data.success) {
throw new Error(data.error || 'Failed to recompile audio');
}
alert('Audio recompiled successfully!');
closeChunkReviewModal();
loadLibrary();
} catch (error) {
console.error('Recompile error:', error);
alert(error.message || 'Failed to recompile audio');
} finally {
if (recompileBtn) {
recompileBtn.disabled = false;
recompileBtn.textContent = 'Recompile Audio';
}
}
}
// FX button state helpers
function updateChunkApplyFxButtonState(slider) {
const card = slider.closest('.library-chunk-card');
if (!card) return;
const speedSlider = card.querySelector('.chunk-speed-slider');
const pitchSlider = card.querySelector('.chunk-pitch-slider');
const applyBtn = card.querySelector('.library-chunk-apply-fx');
const previewBtn = card.querySelector('.library-chunk-preview-fx');
const speed = parseFloat(speedSlider?.value || 1.0);
const pitch = parseFloat(pitchSlider?.value || 0);
// Enable if either value is changed from default
const hasChanges = Math.abs(speed - 1.0) > 0.01 || Math.abs(pitch) > 0.1;
if (applyBtn) applyBtn.disabled = !hasChanges;
if (previewBtn) previewBtn.disabled = !hasChanges;
}
function updateBulkApplyFxButtonState(slider) {
const card = slider.closest('.bulk-speaker-card');
if (!card) return;
const speedSlider = card.querySelector('.bulk-speed-slider');
const pitchSlider = card.querySelector('.bulk-pitch-slider');
const applyBtn = card.querySelector('.bulk-speaker-apply-fx');
if (!applyBtn) return;
const speed = parseFloat(speedSlider?.value || 1.0);
const pitch = parseFloat(pitchSlider?.value || 0);
// Enable if either value is changed from default
const hasChanges = Math.abs(speed - 1.0) > 0.01 || Math.abs(pitch) > 0.1;
applyBtn.disabled = !hasChanges;
}
async function triggerChunkPreviewFx(jobId, chunkId, button) {
const card = button.closest('.library-chunk-card');
if (!card) return;
// If already previewing, stop it
if (previewAudio && previewButton === button) {
stopPreviewAudio();
return;
}
// Stop any existing preview or regular playback
stopPreviewAudio();
stopLibraryChunkAudio();
const speedSlider = card.querySelector('.chunk-speed-slider');
const pitchSlider = card.querySelector('.chunk-pitch-slider');
const speed = parseFloat(speedSlider?.value || 1.0);
const pitch = parseFloat(pitchSlider?.value || 0);
const originalText = button.textContent;
if (!button.dataset.originalText) {
button.dataset.originalText = originalText;
}
button.textContent = '⏳ Loading...';
button.disabled = true;
try {
const response = await fetch(`/api/jobs/${jobId}/review/preview-fx`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ chunk_id: chunkId, speed, pitch }),
});
if (!response.ok) {
const data = await response.json();
throw new Error(data.error || 'Failed to preview effects');
}
// Get the audio blob and play it
const blob = await response.blob();
const audioUrl = URL.createObjectURL(blob);
previewAudio = new Audio(audioUrl);
previewButton = button;
button.textContent = '■ Stop';
button.disabled = false;
button.classList.add('previewing');
previewAudio.addEventListener('ended', () => {
stopPreviewAudio();
button.textContent = button.dataset.originalText;
button.disabled = false;
// Re-check if button should be enabled based on slider values
updateChunkApplyFxButtonState(speedSlider);
});
previewAudio.addEventListener('error', (err) => {
console.error('Preview playback error:', err);
stopPreviewAudio();
button.textContent = originalText;
button.disabled = false;
updateChunkApplyFxButtonState(speedSlider);
});
await previewAudio.play();
} catch (error) {
console.error('Preview FX error:', error);
alert(error.message || 'Failed to preview effects');
button.textContent = originalText;
button.disabled = false;
updateChunkApplyFxButtonState(speedSlider);
}
}
function stopPreviewAudio() {
if (previewAudio) {
previewAudio.pause();
previewAudio.currentTime = 0;
if (previewAudio.src && previewAudio.src.startsWith('blob:')) {
URL.revokeObjectURL(previewAudio.src);
}
previewAudio = null;
}
if (previewButton) {
previewButton.classList.remove('previewing');
previewButton.disabled = false;
previewButton.textContent = previewButton.dataset.originalText || 'Preview';
previewButton = null;
}
}
async function triggerChunkApplyFx(jobId, chunkId, button) {
const card = button.closest('.library-chunk-card');
if (!card) return;
const speedSlider = card.querySelector('.chunk-speed-slider');
const pitchSlider = card.querySelector('.chunk-pitch-slider');
const speed = parseFloat(speedSlider?.value || 1.0);
const pitch = parseFloat(pitchSlider?.value || 0);
button.disabled = true;
button.textContent = 'Applying...';
try {
// First restore the job to review mode if not already
await fetch(`/api/library/${jobId}/restore-review`, { method: 'POST' });
const response = await fetch(`/api/jobs/${jobId}/review/apply-fx`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
chunks: [{ chunk_id: chunkId, speed, pitch }]
}),
});
const data = await response.json();
if (!data.success) {
throw new Error(data.error || 'Failed to apply effects');
}
// Show success feedback
button.textContent = 'Applied!';
updateLibraryChunkStatus(chunkId, 'completed');
// Reset sliders to default
if (speedSlider) speedSlider.value = 1.0;
if (pitchSlider) pitchSlider.value = 0;
card.querySelector('.chunk-speed-value').textContent = '1.0x';
card.querySelector('.chunk-pitch-value').textContent = '0';
// Update audio player URL to bust cache
const playBtn = card.querySelector('.library-chunk-play');
if (playBtn) {
const currentUrl = playBtn.getAttribute('data-audio-url');
if (currentUrl) {
const baseUrl = currentUrl.split('?')[0];
playBtn.setAttribute('data-audio-url', `${baseUrl}?t=${Date.now()}`);
}
}
setTimeout(() => {
button.textContent = 'Apply';
button.disabled = true;
}, 1500);
} catch (error) {
console.error('Apply FX error:', error);
alert(error.message || 'Failed to apply effects');
button.textContent = 'Apply';
button.disabled = false;
}
}
async function triggerBulkSpeakerApplyFx(jobId, speaker, chunks, button) {
const card = button.closest('.bulk-speaker-card');
if (!card) return;
const speedSlider = card.querySelector('.bulk-speed-slider');
const pitchSlider = card.querySelector('.bulk-pitch-slider');
const speed = parseFloat(speedSlider?.value || 1.0);
const pitch = parseFloat(pitchSlider?.value || 0);
// Get all chunks for this speaker
const speakerChunks = chunks.filter(c => (c.speaker || 'default') === speaker);
if (speakerChunks.length === 0) {
alert('No chunks found for this speaker.');
return;
}
button.disabled = true;
button.textContent = `Applying to ${speakerChunks.length}...`;
try {
// First restore the job to review mode if not already
await fetch(`/api/library/${jobId}/restore-review`, { method: 'POST' });
const chunksFx = speakerChunks.map(c => ({
chunk_id: c.id,
speed,
pitch
}));
const response = await fetch(`/api/jobs/${jobId}/review/apply-fx`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ chunks: chunksFx }),
});
const data = await response.json();
if (!data.success) {
throw new Error(data.error || 'Failed to apply effects');
}
// Show success feedback
button.textContent = `Applied to ${data.processed}!`;
// Update status for each chunk
speakerChunks.forEach(c => {
updateLibraryChunkStatus(c.id, 'completed');
// Update audio player URL to bust cache
const chunkCard = document.querySelector(`.library-chunk-card[data-chunk-id="${c.id}"]`);
if (chunkCard) {
const playBtn = chunkCard.querySelector('.library-chunk-play');
if (playBtn) {
const currentUrl = playBtn.getAttribute('data-audio-url');
if (currentUrl) {
const baseUrl = currentUrl.split('?')[0];
playBtn.setAttribute('data-audio-url', `${baseUrl}?t=${Date.now()}`);
}
}
}
});
// Reset sliders to default
if (speedSlider) speedSlider.value = 1.0;
if (pitchSlider) pitchSlider.value = 0;
card.querySelector('.bulk-speed-value').textContent = '1.0x';
card.querySelector('.bulk-pitch-value').textContent = '0';
setTimeout(() => {
button.textContent = 'Apply FX';
button.disabled = true;
}, 2000);
} catch (error) {
console.error('Bulk apply FX error:', error);
alert(error.message || 'Failed to apply effects');
button.textContent = 'Apply FX';
button.disabled = false;
}
}
// ─── Library Alt Word Registry ───────────────────────────────────────────────
// Reuses the full AWR modal from index.html (awr-entry-overlay / awr-modal).
// When _libraryAwrJobId is set, the modal's OK button saves to the backend
// instead of adding a single entry to the main-page registry.
let _libraryAwrJobId = null;
let _libraryAwrList = [];
let _libraryAwrEditIdx = null; // index being edited, or null for new
async function openLibraryAwr(jobId) {
_libraryAwrJobId = jobId;
_libraryAwrList = [];
_libraryAwrEditIdx = null;
// Load existing replacements from backend
try {
const resp = await fetch(`/api/library/${jobId}/word-replacements`);
const data = await resp.json();
if (data.success) _libraryAwrList = data.word_replacements || [];
} catch (e) {
console.warn('Failed to load library AWR:', e);
}
// Reuse the main AWR modal — swap its title and show the registry table view
_openLibraryAwrMainModal();
}
function _openLibraryAwrMainModal() {
// We repurpose the existing awr-modal (the main registry modal, not the entry sub-modal)
// by injecting a library-specific overlay on top of it.
// Simpler: build a standalone modal using the same CSS classes as the main AWR entry modal.
let overlay = document.getElementById('lib-awr-registry-overlay');
if (!overlay) {
overlay = document.createElement('div');
overlay.id = 'lib-awr-registry-overlay';
overlay.className = 'modal-overlay hidden';
overlay.innerHTML = `
Alt Word Registry
These replacements are applied whenever audio chunks are regenerated for this job.
Use this to replace words the engine mispronounces with phonetic alternatives.
Original Word / Phrase
Replacement
No entries yet.
`;
document.body.appendChild(overlay);
document.getElementById('lib-awr-reg-close').addEventListener('click', closeLibraryAwr);
document.getElementById('lib-awr-reg-cancel').addEventListener('click', closeLibraryAwr);
overlay.addEventListener('click', e => { if (e.target === overlay) closeLibraryAwr(); });
document.getElementById('lib-awr-reg-add-btn').addEventListener('click', () => {
_libraryAwrEditIdx = null;
_openLibraryAwrEntryModal(null);
});
}
// Reassign onclick every open so it always captures the current _libraryAwrJobId
document.getElementById('lib-awr-reg-save').onclick = saveLibraryAwr;
_renderLibraryAwrRegTable();
overlay.classList.remove('hidden');
document.getElementById('lib-awr-registry-modal').classList.remove('hidden');
}
function _renderLibraryAwrRegTable() {
const tbody = document.getElementById('lib-awr-reg-tbody');
if (!tbody) return;
if (_libraryAwrList.length === 0) {
tbody.innerHTML = '