1020 lines
42 KiB
JavaScript
1020 lines
42 KiB
JavaScript
import {
|
|
getSpeakers, addSpeaker, deleteSpeaker, generateDialog,
|
|
validateSpeakerData, createSpeakerData
|
|
} from './api.js';
|
|
import { API_BASE_URL, API_BASE_URL_FOR_FILES } from './config.js';
|
|
|
|
document.addEventListener('DOMContentLoaded', async () => {
|
|
console.log('DOM fully loaded and parsed');
|
|
initializeSpeakerManagement();
|
|
await initializeDialogEditor(); // Now properly awaiting the async function
|
|
initializeResultsDisplay(); // Placeholder for now
|
|
});
|
|
|
|
// --- Speaker Management --- //
|
|
const speakerListUL = document.getElementById('speaker-list');
|
|
const addSpeakerForm = document.getElementById('add-speaker-form');
|
|
const referenceTextArea = document.getElementById('reference-text');
|
|
const charCountSpan = document.getElementById('char-count');
|
|
const validationErrors = document.getElementById('validation-errors');
|
|
|
|
function initializeSpeakerManagement() {
|
|
loadSpeakers();
|
|
initializeReferenceText();
|
|
initializeValidation();
|
|
|
|
if (addSpeakerForm) {
|
|
addSpeakerForm.addEventListener('submit', async (event) => {
|
|
event.preventDefault();
|
|
|
|
// Get form data
|
|
const formData = new FormData(addSpeakerForm);
|
|
const speakerData = createSpeakerData(
|
|
formData.get('name'),
|
|
formData.get('audio_file'),
|
|
formData.get('reference_text')
|
|
);
|
|
|
|
// Validate speaker data
|
|
const validation = validateSpeakerData(speakerData);
|
|
if (!validation.isValid) {
|
|
showValidationErrors(validation.errors);
|
|
return;
|
|
}
|
|
|
|
try {
|
|
const newSpeaker = await addSpeaker(speakerData);
|
|
alert(`Speaker added: ${newSpeaker.name} for Higgs TTS`);
|
|
addSpeakerForm.reset();
|
|
hideValidationErrors();
|
|
// Clear form and reset character count
|
|
loadSpeakers(); // Refresh speaker list
|
|
} catch (error) {
|
|
console.error('Failed to add speaker:', error);
|
|
showValidationErrors({ general: error.message });
|
|
}
|
|
});
|
|
}
|
|
}
|
|
|
|
function initializeReferenceText() {
|
|
if (referenceTextArea) {
|
|
referenceTextArea.addEventListener('input', updateCharCount);
|
|
// Initialize character count
|
|
updateCharCount();
|
|
}
|
|
}
|
|
|
|
function updateCharCount() {
|
|
if (referenceTextArea && charCountSpan) {
|
|
const length = referenceTextArea.value.length;
|
|
charCountSpan.textContent = length;
|
|
|
|
// Add visual feedback for character count
|
|
if (length > 500) {
|
|
charCountSpan.style.color = 'red';
|
|
} else if (length > 400) {
|
|
charCountSpan.style.color = 'orange';
|
|
} else {
|
|
charCountSpan.style.color = '';
|
|
}
|
|
}
|
|
}
|
|
|
|
function initializeValidation() {
|
|
// Real-time validation as user types
|
|
document.getElementById('speaker-name')?.addEventListener('input', clearValidationErrors);
|
|
referenceTextArea?.addEventListener('input', clearValidationErrors);
|
|
}
|
|
|
|
function showValidationErrors(errors) {
|
|
if (!validationErrors) return;
|
|
|
|
const errorList = Object.entries(errors).map(([field, message]) =>
|
|
`<div class="error-item"><strong>${field}:</strong> ${message}</div>`
|
|
).join('');
|
|
|
|
validationErrors.innerHTML = errorList;
|
|
validationErrors.style.display = 'block';
|
|
}
|
|
|
|
function hideValidationErrors() {
|
|
if (validationErrors) {
|
|
validationErrors.style.display = 'none';
|
|
validationErrors.innerHTML = '';
|
|
}
|
|
}
|
|
|
|
function clearValidationErrors() {
|
|
hideValidationErrors();
|
|
}
|
|
|
|
function initializeFiltering() {
|
|
if (backendFilter) {
|
|
backendFilter.addEventListener('change', handleFilterChange);
|
|
}
|
|
|
|
if (showStatsBtn) {
|
|
showStatsBtn.addEventListener('click', toggleSpeakerStats);
|
|
}
|
|
}
|
|
|
|
async function handleFilterChange() {
|
|
const selectedBackend = backendFilter.value;
|
|
await loadSpeakers(selectedBackend || null);
|
|
}
|
|
|
|
async function toggleSpeakerStats() {
|
|
const statsDiv = document.getElementById('speaker-stats');
|
|
const statsContent = document.getElementById('stats-content');
|
|
|
|
if (!statsDiv || !statsContent) return;
|
|
|
|
if (statsDiv.style.display === 'none' || !statsDiv.style.display) {
|
|
try {
|
|
const stats = await getSpeakerStatistics();
|
|
displayStats(stats, statsContent);
|
|
statsDiv.style.display = 'block';
|
|
showStatsBtn.textContent = 'Hide Statistics';
|
|
} catch (error) {
|
|
console.error('Failed to load statistics:', error);
|
|
alert('Failed to load statistics: ' + error.message);
|
|
}
|
|
} else {
|
|
statsDiv.style.display = 'none';
|
|
showStatsBtn.textContent = 'Show Statistics';
|
|
}
|
|
}
|
|
|
|
function displayStats(stats, container) {
|
|
const { speaker_statistics, validation_status } = stats;
|
|
|
|
let html = `
|
|
<div class="stats-summary">
|
|
<p><strong>Total Speakers:</strong> ${speaker_statistics.total_speakers}</p>
|
|
<p><strong>Valid Speakers:</strong> ${validation_status.valid_speakers}</p>
|
|
${validation_status.invalid_speakers > 0 ?
|
|
`<p class="error"><strong>Invalid Speakers:</strong> ${validation_status.invalid_speakers}</p>` :
|
|
''
|
|
}
|
|
</div>
|
|
<div class="backend-breakdown">
|
|
<h5>Backend Distribution:</h5>
|
|
`;
|
|
|
|
for (const [backend, info] of Object.entries(speaker_statistics.backends)) {
|
|
html += `
|
|
<div class="backend-stats">
|
|
<strong>${backend.toUpperCase()}:</strong> ${info.count} speakers
|
|
<br><small>With reference text: ${info.with_reference_text} | Without: ${info.without_reference_text}</small>
|
|
</div>
|
|
`;
|
|
}
|
|
|
|
html += '</div>';
|
|
container.innerHTML = html;
|
|
}
|
|
|
|
async function loadSpeakers(backend = null) {
|
|
if (!speakerListUL) return;
|
|
try {
|
|
const speakers = await getSpeakers(backend);
|
|
speakerListUL.innerHTML = ''; // Clear existing list
|
|
|
|
if (speakers.length === 0) {
|
|
const listItem = document.createElement('li');
|
|
listItem.textContent = backend ?
|
|
`No speakers available for ${backend} backend.` :
|
|
'No speakers available.';
|
|
speakerListUL.appendChild(listItem);
|
|
return;
|
|
}
|
|
speakers.forEach(speaker => {
|
|
const listItem = document.createElement('li');
|
|
listItem.classList.add('speaker-item');
|
|
|
|
// Create speaker info container
|
|
const speakerInfo = document.createElement('div');
|
|
speakerInfo.classList.add('speaker-info');
|
|
|
|
// Speaker name and backend
|
|
const nameDiv = document.createElement('div');
|
|
nameDiv.classList.add('speaker-name');
|
|
nameDiv.innerHTML = `
|
|
<span class="name">${speaker.name}</span>
|
|
<span class="backend-badge ${speaker.tts_backend || 'chatterbox'}">${(speaker.tts_backend || 'chatterbox').toUpperCase()}</span>
|
|
`;
|
|
|
|
// Reference text preview for Higgs speakers
|
|
if (speaker.tts_backend === 'higgs' && speaker.reference_text) {
|
|
const refTextDiv = document.createElement('div');
|
|
refTextDiv.classList.add('reference-text');
|
|
const preview = speaker.reference_text.length > 60 ?
|
|
speaker.reference_text.substring(0, 60) + '...' :
|
|
speaker.reference_text;
|
|
refTextDiv.innerHTML = `<small><em>Reference:</em> "${preview}"</small>`;
|
|
nameDiv.appendChild(refTextDiv);
|
|
}
|
|
|
|
speakerInfo.appendChild(nameDiv);
|
|
|
|
// Actions
|
|
const actions = document.createElement('div');
|
|
actions.classList.add('speaker-actions');
|
|
|
|
const deleteBtn = document.createElement('button');
|
|
deleteBtn.textContent = 'Delete';
|
|
deleteBtn.classList.add('delete-speaker-btn');
|
|
deleteBtn.onclick = () => handleDeleteSpeaker(speaker.id, speaker.name);
|
|
actions.appendChild(deleteBtn);
|
|
|
|
// Main container
|
|
const container = document.createElement('div');
|
|
container.classList.add('speaker-container');
|
|
container.appendChild(speakerInfo);
|
|
container.appendChild(actions);
|
|
|
|
listItem.appendChild(container);
|
|
speakerListUL.appendChild(listItem);
|
|
});
|
|
} catch (error) {
|
|
console.error('Failed to load speakers:', error);
|
|
speakerListUL.innerHTML = '<li>Error loading speakers. See console for details.</li>';
|
|
alert('Error loading speakers: ' + error.message);
|
|
}
|
|
}
|
|
|
|
async function handleDeleteSpeaker(speakerId, speakerName = null) {
|
|
if (!speakerId) {
|
|
alert('Cannot delete speaker: Speaker ID is missing.');
|
|
return;
|
|
}
|
|
|
|
const displayName = speakerName || speakerId;
|
|
if (!confirm(`Are you sure you want to delete speaker "${displayName}"?`)) return;
|
|
|
|
try {
|
|
await deleteSpeaker(speakerId);
|
|
alert(`Speaker "${displayName}" deleted successfully.`);
|
|
|
|
// Refresh speaker list with current filter
|
|
const currentFilter = backendFilter?.value || null;
|
|
await loadSpeakers(currentFilter);
|
|
} catch (error) {
|
|
console.error(`Failed to delete speaker ${speakerId}:`, error);
|
|
alert(`Error deleting speaker: ${error.message}`);
|
|
}
|
|
}
|
|
|
|
// --- Dialog Editor --- //
|
|
let dialogItems = []; // Holds the sequence of speech/silence items
|
|
let availableSpeakersCache = []; // To populate speaker dropdown
|
|
|
|
// Utility: ensure each dialog item has audioUrl, isGenerating, error
|
|
function normalizeDialogItem(item) {
|
|
const normalized = {
|
|
...item,
|
|
audioUrl: item.audioUrl || null,
|
|
isGenerating: item.isGenerating || false,
|
|
error: item.error || null
|
|
};
|
|
|
|
// Add TTS settings for speech items with defaults (Higgs TTS parameters)
|
|
if (item.type === 'speech') {
|
|
normalized.description = item.description || null;
|
|
normalized.temperature = item.temperature ?? 0.9;
|
|
normalized.max_new_tokens = item.max_new_tokens ?? 1024;
|
|
normalized.top_p = item.top_p ?? 0.95;
|
|
normalized.top_k = item.top_k ?? 50;
|
|
}
|
|
|
|
return normalized;
|
|
}
|
|
|
|
async function initializeDialogEditor() {
|
|
const dialogItemsContainer = document.getElementById('dialog-items-container');
|
|
const addSpeechLineBtn = document.getElementById('add-speech-line-btn');
|
|
const addSilenceLineBtn = document.getElementById('add-silence-line-btn');
|
|
const outputBaseNameInput = document.getElementById('output-base-name');
|
|
const generateDialogBtn = document.getElementById('generate-dialog-btn');
|
|
const saveScriptBtn = document.getElementById('save-script-btn');
|
|
const loadScriptBtn = document.getElementById('load-script-btn');
|
|
const loadScriptInput = document.getElementById('load-script-input');
|
|
|
|
// Results Display Elements
|
|
const generationLogPre = document.getElementById('generation-log-content'); // Corrected ID
|
|
const audioPlayer = document.getElementById('concatenated-audio-player'); // Corrected ID
|
|
// audioSource will be the audioPlayer itself, no separate element by default in the HTML
|
|
const downloadZipLink = document.getElementById('zip-archive-link'); // Corrected ID
|
|
const zipArchivePlaceholder = document.getElementById('zip-archive-placeholder');
|
|
const resultsDisplaySection = document.getElementById('results-display');
|
|
|
|
let dialogItems = [];
|
|
let availableSpeakersCache = []; // Cache for speaker names and IDs
|
|
|
|
// Load speakers at startup
|
|
try {
|
|
availableSpeakersCache = await getSpeakers();
|
|
console.log(`Loaded ${availableSpeakersCache.length} speakers for dialog editor`);
|
|
} catch (error) {
|
|
console.error('Error loading speakers at startup:', error);
|
|
// Continue without speakers - they'll be loaded when needed
|
|
}
|
|
|
|
// Function to render the current dialogItems array to the DOM as table rows
|
|
function renderDialogItems() {
|
|
if (!dialogItemsContainer) return;
|
|
dialogItemsContainer.innerHTML = '';
|
|
dialogItems = dialogItems.map(normalizeDialogItem); // Ensure all fields present
|
|
dialogItems.forEach((item, index) => {
|
|
const tr = document.createElement('tr');
|
|
|
|
// Type column
|
|
const typeTd = document.createElement('td');
|
|
typeTd.classList.add('type-icon-cell');
|
|
if (item.type === 'speech') {
|
|
typeTd.innerHTML = '<span class="dialog-type-icon" title="Speech" aria-label="Speech">🗣️</span>';
|
|
} else {
|
|
typeTd.innerHTML = '<span class="dialog-type-icon" title="Silence" aria-label="Silence">🤫</span>';
|
|
}
|
|
tr.appendChild(typeTd);
|
|
|
|
// Speaker column
|
|
const speakerTd = document.createElement('td');
|
|
if (item.type === 'speech') {
|
|
const speakerSelect = document.createElement('select');
|
|
speakerSelect.className = 'dialog-speaker-select';
|
|
availableSpeakersCache.forEach(speaker => {
|
|
const option = document.createElement('option');
|
|
option.value = speaker.id;
|
|
option.textContent = speaker.name;
|
|
if (speaker.id === item.speaker_id) option.selected = true;
|
|
speakerSelect.appendChild(option);
|
|
});
|
|
speakerSelect.onchange = (e) => {
|
|
dialogItems[index].speaker_id = e.target.value;
|
|
};
|
|
speakerTd.appendChild(speakerSelect);
|
|
} else {
|
|
speakerTd.textContent = '—';
|
|
}
|
|
tr.appendChild(speakerTd);
|
|
|
|
// Text/Duration column
|
|
const textTd = document.createElement('td');
|
|
textTd.className = 'dialog-editable-cell';
|
|
if (item.type === 'speech') {
|
|
let txt = item.text.length > 60 ? item.text.substring(0, 57) + '…' : item.text;
|
|
textTd.textContent = `"${txt}"`;
|
|
textTd.title = item.text;
|
|
} else {
|
|
textTd.textContent = `${item.duration}s`;
|
|
}
|
|
// Double-click to edit
|
|
textTd.ondblclick = () => {
|
|
textTd.innerHTML = '';
|
|
let input;
|
|
if (item.type === 'speech') {
|
|
// Use textarea for speech text to enable multi-line editing
|
|
input = document.createElement('textarea');
|
|
input.className = 'dialog-edit-textarea';
|
|
input.value = item.text;
|
|
input.rows = Math.max(2, Math.ceil(item.text.length / 50)); // Auto-size based on content
|
|
} else {
|
|
// Use number input for duration
|
|
input = document.createElement('input');
|
|
input.className = 'dialog-edit-input';
|
|
input.type = 'number';
|
|
input.value = item.duration;
|
|
input.min = 0.1;
|
|
input.step = 0.1;
|
|
}
|
|
input.onblur = saveEdit;
|
|
input.onkeydown = (e) => {
|
|
if (e.key === 'Enter' && item.type === 'silence') {
|
|
// Only auto-save on Enter for duration inputs
|
|
input.blur();
|
|
} else if (e.key === 'Escape') {
|
|
renderDialogItems();
|
|
} else if (e.key === 'Enter' && e.ctrlKey && item.type === 'speech') {
|
|
// Ctrl+Enter to save for speech text
|
|
input.blur();
|
|
}
|
|
};
|
|
textTd.appendChild(input);
|
|
input.focus();
|
|
function saveEdit() {
|
|
if (item.type === 'speech') {
|
|
dialogItems[index].text = input.value;
|
|
dialogItems[index].audioUrl = null; // Invalidate audio if text edited
|
|
} else {
|
|
let val = parseFloat(input.value);
|
|
if (!isNaN(val) && val > 0) dialogItems[index].duration = val;
|
|
dialogItems[index].audioUrl = null;
|
|
}
|
|
renderDialogItems();
|
|
}
|
|
};
|
|
tr.appendChild(textTd);
|
|
|
|
// Actions column
|
|
const actionsTd = document.createElement('td');
|
|
actionsTd.classList.add('actions');
|
|
|
|
// Up button
|
|
const upBtn = document.createElement('button');
|
|
upBtn.innerHTML = '↑';
|
|
upBtn.title = 'Move up';
|
|
upBtn.className = 'move-up-btn';
|
|
upBtn.disabled = index === 0;
|
|
upBtn.onclick = () => {
|
|
if (index > 0) {
|
|
[dialogItems[index - 1], dialogItems[index]] = [dialogItems[index], dialogItems[index - 1]];
|
|
renderDialogItems();
|
|
}
|
|
};
|
|
actionsTd.appendChild(upBtn);
|
|
|
|
// Down button
|
|
const downBtn = document.createElement('button');
|
|
downBtn.innerHTML = '↓';
|
|
downBtn.title = 'Move down';
|
|
downBtn.className = 'move-down-btn';
|
|
downBtn.disabled = index === dialogItems.length - 1;
|
|
downBtn.onclick = () => {
|
|
if (index < dialogItems.length - 1) {
|
|
[dialogItems[index], dialogItems[index + 1]] = [dialogItems[index + 1], dialogItems[index]];
|
|
renderDialogItems();
|
|
}
|
|
};
|
|
actionsTd.appendChild(downBtn);
|
|
|
|
// Remove button
|
|
const removeBtn = document.createElement('button');
|
|
removeBtn.innerHTML = '×'; // Unicode multiplication sign (X)
|
|
removeBtn.classList.add('remove-dialog-item-btn', 'x-remove-btn');
|
|
removeBtn.setAttribute('aria-label', 'Remove dialog line');
|
|
removeBtn.title = 'Remove';
|
|
removeBtn.onclick = () => {
|
|
dialogItems.splice(index, 1);
|
|
renderDialogItems();
|
|
};
|
|
actionsTd.appendChild(removeBtn);
|
|
|
|
// --- NEW: Per-line Generate button ---
|
|
const generateBtn = document.createElement('button');
|
|
generateBtn.innerHTML = '⚡';
|
|
generateBtn.title = 'Generate audio for this line';
|
|
generateBtn.className = 'generate-line-btn';
|
|
generateBtn.disabled = item.isGenerating;
|
|
if (item.isGenerating) generateBtn.classList.add('loading');
|
|
generateBtn.onclick = async () => {
|
|
dialogItems[index].isGenerating = true;
|
|
dialogItems[index].error = null;
|
|
renderDialogItems();
|
|
try {
|
|
const { generateLine } = await import('./api.js');
|
|
const payload = { ...item };
|
|
// Remove fields not needed by backend
|
|
delete payload.audioUrl; delete payload.isGenerating; delete payload.error;
|
|
console.log('Sending payload:', payload);
|
|
const result = await generateLine(payload);
|
|
console.log('Received result:', result);
|
|
if (result && result.audio_url) {
|
|
dialogItems[index].audioUrl = result.audio_url;
|
|
console.log('Set audioUrl to:', result.audio_url);
|
|
} else {
|
|
console.error('Invalid result structure:', result);
|
|
throw new Error('Invalid response: missing audio_url');
|
|
}
|
|
} catch (err) {
|
|
console.error('Error in generateLine:', err);
|
|
dialogItems[index].error = err.message || 'Failed to generate audio.';
|
|
alert(dialogItems[index].error);
|
|
} finally {
|
|
dialogItems[index].isGenerating = false;
|
|
renderDialogItems();
|
|
}
|
|
};
|
|
actionsTd.appendChild(generateBtn);
|
|
|
|
// --- NEW: Per-line Play button ---
|
|
const playBtn = document.createElement('button');
|
|
playBtn.innerHTML = '⏵';
|
|
playBtn.title = item.audioUrl ? 'Play generated audio' : 'No audio generated yet';
|
|
playBtn.className = 'play-line-btn';
|
|
playBtn.disabled = !item.audioUrl;
|
|
playBtn.onclick = () => {
|
|
if (!item.audioUrl) return;
|
|
let audioUrl = item.audioUrl.startsWith('http') ? item.audioUrl : `${API_BASE_URL_FOR_FILES}${item.audioUrl}`;
|
|
// Use a shared audio element or create one per play
|
|
let audio = new window.Audio(audioUrl);
|
|
audio.play();
|
|
};
|
|
actionsTd.appendChild(playBtn);
|
|
|
|
// --- NEW: Settings button for speech items ---
|
|
if (item.type === 'speech') {
|
|
const settingsBtn = document.createElement('button');
|
|
settingsBtn.innerHTML = '⚙️';
|
|
settingsBtn.title = 'TTS Settings';
|
|
settingsBtn.className = 'settings-line-btn';
|
|
settingsBtn.onclick = () => {
|
|
showTTSSettingsModal(item, index);
|
|
};
|
|
actionsTd.appendChild(settingsBtn);
|
|
}
|
|
|
|
// Show error if present
|
|
if (item.error) {
|
|
const errorSpan = document.createElement('span');
|
|
errorSpan.className = 'line-error-msg';
|
|
errorSpan.textContent = item.error;
|
|
actionsTd.appendChild(errorSpan);
|
|
}
|
|
|
|
tr.appendChild(actionsTd);
|
|
dialogItemsContainer.appendChild(tr);
|
|
});
|
|
}
|
|
|
|
const tempInputArea = document.getElementById('temp-input-area');
|
|
|
|
function clearTempInputArea() {
|
|
if (tempInputArea) tempInputArea.innerHTML = '';
|
|
}
|
|
|
|
if (addSpeechLineBtn) {
|
|
addSpeechLineBtn.addEventListener('click', async () => {
|
|
clearTempInputArea(); // Clear any previous inputs
|
|
|
|
if (availableSpeakersCache.length === 0) {
|
|
try {
|
|
availableSpeakersCache = await getSpeakers();
|
|
} catch (error) {
|
|
alert('Could not load speakers. Please try again.');
|
|
console.error('Error fetching speakers for dialog:', error);
|
|
return;
|
|
}
|
|
}
|
|
if (availableSpeakersCache.length === 0) {
|
|
alert('No speakers available. Please add a speaker first.');
|
|
return;
|
|
}
|
|
|
|
const speakerSelectLabel = document.createElement('label');
|
|
speakerSelectLabel.textContent = 'Speaker: ';
|
|
speakerSelectLabel.htmlFor = 'temp-speaker-select';
|
|
const speakerSelect = document.createElement('select');
|
|
speakerSelect.id = 'temp-speaker-select';
|
|
availableSpeakersCache.forEach(speaker => {
|
|
const option = document.createElement('option');
|
|
option.value = speaker.id;
|
|
option.textContent = speaker.name;
|
|
speakerSelect.appendChild(option);
|
|
});
|
|
|
|
const textInputLabel = document.createElement('label');
|
|
textInputLabel.textContent = ' Text: ';
|
|
textInputLabel.htmlFor = 'temp-speech-text';
|
|
const textInput = document.createElement('textarea');
|
|
textInput.id = 'temp-speech-text';
|
|
textInput.rows = 2;
|
|
textInput.placeholder = 'Enter speech text';
|
|
|
|
const descriptionInputLabel = document.createElement('label');
|
|
descriptionInputLabel.textContent = ' Style Description: ';
|
|
descriptionInputLabel.htmlFor = 'temp-speech-description';
|
|
const descriptionInput = document.createElement('textarea');
|
|
descriptionInput.id = 'temp-speech-description';
|
|
descriptionInput.rows = 1;
|
|
descriptionInput.placeholder = 'e.g., "speaking thoughtfully", "in a whisper", "with excitement" (optional)';
|
|
|
|
const addButton = document.createElement('button');
|
|
addButton.textContent = 'Add Speech';
|
|
addButton.onclick = () => {
|
|
const speakerId = speakerSelect.value;
|
|
const text = textInput.value.trim();
|
|
const description = descriptionInput.value.trim();
|
|
if (!speakerId || !text) {
|
|
alert('Please select a speaker and enter text.');
|
|
return;
|
|
}
|
|
const speechItem = { type: 'speech', speaker_id: speakerId, text: text };
|
|
if (description) {
|
|
speechItem.description = description;
|
|
}
|
|
dialogItems.push(normalizeDialogItem(speechItem));
|
|
renderDialogItems();
|
|
clearTempInputArea();
|
|
};
|
|
|
|
const cancelButton = document.createElement('button');
|
|
cancelButton.textContent = 'Cancel';
|
|
cancelButton.onclick = clearTempInputArea;
|
|
|
|
if (tempInputArea) {
|
|
tempInputArea.appendChild(speakerSelectLabel);
|
|
tempInputArea.appendChild(speakerSelect);
|
|
tempInputArea.appendChild(textInputLabel);
|
|
tempInputArea.appendChild(textInput);
|
|
tempInputArea.appendChild(descriptionInputLabel);
|
|
tempInputArea.appendChild(descriptionInput);
|
|
tempInputArea.appendChild(addButton);
|
|
tempInputArea.appendChild(cancelButton);
|
|
}
|
|
});
|
|
}
|
|
|
|
if (addSilenceLineBtn) {
|
|
addSilenceLineBtn.addEventListener('click', () => {
|
|
clearTempInputArea(); // Clear any previous inputs
|
|
|
|
const durationInputLabel = document.createElement('label');
|
|
durationInputLabel.textContent = 'Duration (s): ';
|
|
durationInputLabel.htmlFor = 'temp-silence-duration';
|
|
const durationInput = document.createElement('input');
|
|
durationInput.type = 'number';
|
|
durationInput.id = 'temp-silence-duration';
|
|
durationInput.step = '0.1';
|
|
durationInput.min = '0.1';
|
|
durationInput.placeholder = 'e.g., 0.5';
|
|
|
|
const addButton = document.createElement('button');
|
|
addButton.textContent = 'Add Silence';
|
|
addButton.onclick = () => {
|
|
const duration = parseFloat(durationInput.value);
|
|
if (isNaN(duration) || duration <= 0) {
|
|
alert('Invalid duration. Please enter a positive number.');
|
|
return;
|
|
}
|
|
dialogItems.push(normalizeDialogItem({ type: 'silence', duration: duration }));
|
|
renderDialogItems();
|
|
clearTempInputArea();
|
|
};
|
|
|
|
const cancelButton = document.createElement('button');
|
|
cancelButton.textContent = 'Cancel';
|
|
cancelButton.onclick = clearTempInputArea;
|
|
|
|
if (tempInputArea) {
|
|
tempInputArea.appendChild(durationInputLabel);
|
|
tempInputArea.appendChild(durationInput);
|
|
tempInputArea.appendChild(addButton);
|
|
tempInputArea.appendChild(cancelButton);
|
|
}
|
|
});
|
|
}
|
|
|
|
if (generateDialogBtn && outputBaseNameInput) {
|
|
generateDialogBtn.addEventListener('click', async () => {
|
|
const outputBaseName = outputBaseNameInput.value.trim();
|
|
if (!outputBaseName) {
|
|
alert('Please enter an output base name.');
|
|
outputBaseNameInput.focus();
|
|
return;
|
|
}
|
|
if (dialogItems.length === 0) {
|
|
alert('Please add at least one speech or silence line to the dialog.');
|
|
return; // Prevent further execution if no dialog items
|
|
}
|
|
|
|
// Smart dialog-wide generation: use pre-generated audio where present
|
|
const dialogItemsToGenerate = dialogItems.map(item => {
|
|
// Only send minimal fields for items that need generation
|
|
if (item.audioUrl) {
|
|
return { ...item, use_existing_audio: true, audio_url: item.audioUrl };
|
|
} else {
|
|
// Remove frontend-only fields
|
|
const payload = { ...item };
|
|
delete payload.audioUrl; delete payload.isGenerating; delete payload.error;
|
|
return payload;
|
|
}
|
|
});
|
|
|
|
const payload = {
|
|
output_base_name: outputBaseName,
|
|
dialog_items: dialogItemsToGenerate
|
|
};
|
|
|
|
try {
|
|
console.log('Generating dialog with payload:', JSON.stringify(payload, null, 2));
|
|
const result = await generateDialog(payload);
|
|
console.log('Dialog generation successful:', result);
|
|
|
|
if (generationLogPre) generationLogPre.textContent = result.log || 'No log output.';
|
|
|
|
if (result.concatenated_audio_url && audioPlayer) { // Check audioPlayer, not audioSource
|
|
// Cache-busting: append timestamp to force reload
|
|
let audioUrl = result.concatenated_audio_url.startsWith('http') ? result.concatenated_audio_url : `${API_BASE_URL_FOR_FILES}${result.concatenated_audio_url}`;
|
|
audioUrl += (audioUrl.includes('?') ? '&' : '?') + 't=' + Date.now();
|
|
audioPlayer.src = audioUrl;
|
|
audioPlayer.load(); // Call load() after setting new source
|
|
audioPlayer.style.display = 'block';
|
|
} else {
|
|
if (audioPlayer) audioPlayer.style.display = 'none'; // Ensure it's hidden if no URL
|
|
if (generationLogPre) generationLogPre.textContent += '\nNo concatenated audio URL found.';
|
|
}
|
|
|
|
if (result.zip_archive_url && downloadZipLink) {
|
|
downloadZipLink.href = result.zip_archive_url.startsWith('http') ? result.zip_archive_url : `${API_BASE_URL_FOR_FILES}${result.zip_archive_url}`;
|
|
downloadZipLink.textContent = `Download ${outputBaseName}.zip`;
|
|
downloadZipLink.style.display = 'block';
|
|
if (zipArchivePlaceholder) zipArchivePlaceholder.style.display = 'none'; // Hide placeholder
|
|
} else {
|
|
if (downloadZipLink) downloadZipLink.style.display = 'none';
|
|
if (zipArchivePlaceholder) zipArchivePlaceholder.style.display = 'block'; // Show placeholder if no link
|
|
if (generationLogPre) generationLogPre.textContent += '\nNo ZIP archive URL found.';
|
|
}
|
|
|
|
} catch (error) {
|
|
console.error('Dialog generation failed:', error);
|
|
if (generationLogPre) generationLogPre.textContent = `Error generating dialog: ${error.message}`;
|
|
alert(`Error generating dialog: ${error.message}`);
|
|
}
|
|
});
|
|
}
|
|
|
|
// --- Save/Load Script Functionality ---
|
|
function saveDialogScript() {
|
|
if (dialogItems.length === 0) {
|
|
alert('No dialog items to save. Please add some speech or silence lines first.');
|
|
return;
|
|
}
|
|
|
|
// Filter out UI-specific fields and create clean data for export
|
|
const exportData = dialogItems.map(item => {
|
|
const cleanItem = {
|
|
type: item.type
|
|
};
|
|
|
|
if (item.type === 'speech') {
|
|
cleanItem.speaker_id = item.speaker_id;
|
|
cleanItem.text = item.text;
|
|
// Include TTS parameters if they exist (will use defaults if not present)
|
|
if (item.exaggeration !== undefined) cleanItem.exaggeration = item.exaggeration;
|
|
if (item.cfg_weight !== undefined) cleanItem.cfg_weight = item.cfg_weight;
|
|
if (item.temperature !== undefined) cleanItem.temperature = item.temperature;
|
|
} else if (item.type === 'silence') {
|
|
cleanItem.duration = item.duration;
|
|
}
|
|
|
|
return cleanItem;
|
|
});
|
|
|
|
// Convert to JSONL format (one JSON object per line)
|
|
const jsonlContent = exportData.map(item => JSON.stringify(item)).join('\n');
|
|
|
|
// Create and download file
|
|
const blob = new Blob([jsonlContent], { type: 'application/jsonl' });
|
|
const url = URL.createObjectURL(blob);
|
|
const link = document.createElement('a');
|
|
|
|
// Generate filename with timestamp
|
|
const timestamp = new Date().toISOString().replace(/[:.]/g, '-').slice(0, 19);
|
|
const filename = `dialog_script_${timestamp}.jsonl`;
|
|
|
|
link.href = url;
|
|
link.download = filename;
|
|
link.style.display = 'none';
|
|
document.body.appendChild(link);
|
|
link.click();
|
|
document.body.removeChild(link);
|
|
URL.revokeObjectURL(url);
|
|
|
|
console.log(`Dialog script saved as ${filename}`);
|
|
}
|
|
|
|
function loadDialogScript(file) {
|
|
if (!file) {
|
|
alert('Please select a file to load.');
|
|
return;
|
|
}
|
|
|
|
const reader = new FileReader();
|
|
reader.onload = async function(e) {
|
|
try {
|
|
const content = e.target.result;
|
|
const lines = content.trim().split('\n');
|
|
const loadedItems = [];
|
|
|
|
for (let i = 0; i < lines.length; i++) {
|
|
const line = lines[i].trim();
|
|
if (!line) continue; // Skip empty lines
|
|
|
|
try {
|
|
const item = JSON.parse(line);
|
|
const validatedItem = validateDialogItem(item, i + 1);
|
|
if (validatedItem) {
|
|
loadedItems.push(normalizeDialogItem(validatedItem));
|
|
}
|
|
} catch (parseError) {
|
|
console.error(`Error parsing line ${i + 1}:`, parseError);
|
|
alert(`Error parsing line ${i + 1}: ${parseError.message}`);
|
|
return;
|
|
}
|
|
}
|
|
|
|
if (loadedItems.length === 0) {
|
|
alert('No valid dialog items found in the file.');
|
|
return;
|
|
}
|
|
|
|
// Confirm replacement if existing items
|
|
if (dialogItems.length > 0) {
|
|
const confirmed = confirm(
|
|
`This will replace your current dialog (${dialogItems.length} items) with the loaded script (${loadedItems.length} items). Continue?`
|
|
);
|
|
if (!confirmed) return;
|
|
}
|
|
|
|
// Ensure speakers are loaded before rendering
|
|
if (availableSpeakersCache.length === 0) {
|
|
try {
|
|
availableSpeakersCache = await getSpeakers();
|
|
} catch (error) {
|
|
console.error('Error fetching speakers:', error);
|
|
alert('Could not load speakers. Dialog loaded but speaker names may not display correctly.');
|
|
}
|
|
}
|
|
|
|
// Replace current dialog
|
|
dialogItems.splice(0, dialogItems.length, ...loadedItems);
|
|
renderDialogItems();
|
|
|
|
console.log(`Loaded ${loadedItems.length} dialog items from script`);
|
|
alert(`Successfully loaded ${loadedItems.length} dialog items.`);
|
|
|
|
} catch (error) {
|
|
console.error('Error loading dialog script:', error);
|
|
alert(`Error loading dialog script: ${error.message}`);
|
|
}
|
|
};
|
|
|
|
reader.onerror = function() {
|
|
alert('Error reading file. Please try again.');
|
|
};
|
|
|
|
reader.readAsText(file);
|
|
}
|
|
|
|
function validateDialogItem(item, lineNumber) {
|
|
if (!item || typeof item !== 'object') {
|
|
throw new Error(`Line ${lineNumber}: Invalid item format`);
|
|
}
|
|
|
|
if (!item.type || !['speech', 'silence'].includes(item.type)) {
|
|
throw new Error(`Line ${lineNumber}: Invalid or missing type. Must be 'speech' or 'silence'`);
|
|
}
|
|
|
|
if (item.type === 'speech') {
|
|
if (!item.speaker_id || typeof item.speaker_id !== 'string') {
|
|
throw new Error(`Line ${lineNumber}: Speech items must have a valid speaker_id`);
|
|
}
|
|
if (!item.text || typeof item.text !== 'string') {
|
|
throw new Error(`Line ${lineNumber}: Speech items must have text`);
|
|
}
|
|
|
|
// Check if speaker exists in available speakers
|
|
const speakerExists = availableSpeakersCache.some(speaker => speaker.id === item.speaker_id);
|
|
if (availableSpeakersCache.length > 0 && !speakerExists) {
|
|
console.warn(`Line ${lineNumber}: Speaker '${item.speaker_id}' not found in available speakers`);
|
|
// Don't throw error, just warn - speaker might be added later
|
|
}
|
|
|
|
return {
|
|
type: 'speech',
|
|
speaker_id: item.speaker_id,
|
|
text: item.text
|
|
};
|
|
} else if (item.type === 'silence') {
|
|
if (typeof item.duration !== 'number' || item.duration <= 0) {
|
|
throw new Error(`Line ${lineNumber}: Silence items must have a positive duration number`);
|
|
}
|
|
|
|
return {
|
|
type: 'silence',
|
|
duration: item.duration
|
|
};
|
|
}
|
|
}
|
|
|
|
// Event handlers for save/load
|
|
if (saveScriptBtn) {
|
|
saveScriptBtn.addEventListener('click', saveDialogScript);
|
|
}
|
|
|
|
if (loadScriptBtn && loadScriptInput) {
|
|
loadScriptBtn.addEventListener('click', () => {
|
|
loadScriptInput.click();
|
|
});
|
|
|
|
loadScriptInput.addEventListener('change', (e) => {
|
|
const file = e.target.files[0];
|
|
if (file) {
|
|
loadDialogScript(file);
|
|
// Reset input so same file can be loaded again
|
|
e.target.value = '';
|
|
}
|
|
});
|
|
}
|
|
|
|
console.log('Dialog Editor Initialized');
|
|
renderDialogItems(); // Initial render (empty)
|
|
|
|
// Add a function to show TTS settings modal
|
|
function showTTSSettingsModal(item, index) {
|
|
const modal = document.getElementById('tts-settings-modal');
|
|
const exaggerationSlider = document.getElementById('tts-exaggeration');
|
|
const exaggerationValue = document.getElementById('tts-exaggeration-value');
|
|
const cfgWeightSlider = document.getElementById('tts-cfg-weight');
|
|
const cfgWeightValue = document.getElementById('tts-cfg-weight-value');
|
|
const temperatureSlider = document.getElementById('tts-temperature');
|
|
const temperatureValue = document.getElementById('tts-temperature-value');
|
|
const saveBtn = document.getElementById('tts-settings-save');
|
|
const cancelBtn = document.getElementById('tts-settings-cancel');
|
|
const closeBtn = document.getElementById('tts-modal-close');
|
|
|
|
// Set current values
|
|
exaggerationSlider.value = item.exaggeration || 0.5;
|
|
exaggerationValue.textContent = exaggerationSlider.value;
|
|
cfgWeightSlider.value = item.cfg_weight || 0.5;
|
|
cfgWeightValue.textContent = cfgWeightSlider.value;
|
|
temperatureSlider.value = item.temperature || 0.8;
|
|
temperatureValue.textContent = temperatureSlider.value;
|
|
|
|
// Update value displays when sliders change
|
|
const updateValueDisplay = (slider, display) => {
|
|
display.textContent = slider.value;
|
|
};
|
|
|
|
exaggerationSlider.oninput = () => updateValueDisplay(exaggerationSlider, exaggerationValue);
|
|
cfgWeightSlider.oninput = () => updateValueDisplay(cfgWeightSlider, cfgWeightValue);
|
|
temperatureSlider.oninput = () => updateValueDisplay(temperatureSlider, temperatureValue);
|
|
|
|
// Show modal
|
|
modal.style.display = 'flex';
|
|
|
|
// Save settings
|
|
const saveSettings = () => {
|
|
dialogItems[index].exaggeration = parseFloat(exaggerationSlider.value);
|
|
dialogItems[index].cfg_weight = parseFloat(cfgWeightSlider.value);
|
|
dialogItems[index].temperature = parseFloat(temperatureSlider.value);
|
|
|
|
// Clear any existing audio since settings changed
|
|
dialogItems[index].audioUrl = null;
|
|
|
|
closeModal();
|
|
renderDialogItems(); // Re-render to reflect changes
|
|
console.log('TTS settings updated for item:', dialogItems[index]);
|
|
};
|
|
|
|
// Close modal
|
|
const closeModal = () => {
|
|
modal.style.display = 'none';
|
|
// Clean up event listeners
|
|
exaggerationSlider.oninput = null;
|
|
cfgWeightSlider.oninput = null;
|
|
temperatureSlider.oninput = null;
|
|
saveBtn.onclick = null;
|
|
cancelBtn.onclick = null;
|
|
closeBtn.onclick = null;
|
|
modal.onclick = null;
|
|
};
|
|
|
|
// Event listeners
|
|
saveBtn.onclick = saveSettings;
|
|
cancelBtn.onclick = closeModal;
|
|
closeBtn.onclick = closeModal;
|
|
|
|
// Close modal when clicking outside
|
|
modal.onclick = (e) => {
|
|
if (e.target === modal) {
|
|
closeModal();
|
|
}
|
|
};
|
|
|
|
// Close modal on Escape key
|
|
const handleEscape = (e) => {
|
|
if (e.key === 'Escape') {
|
|
closeModal();
|
|
document.removeEventListener('keydown', handleEscape);
|
|
}
|
|
};
|
|
document.addEventListener('keydown', handleEscape);
|
|
}
|
|
}
|
|
|
|
// --- Results Display --- //
|
|
function initializeResultsDisplay() {
|
|
const generationLogContent = document.getElementById('generation-log-content');
|
|
const concatenatedAudioPlayer = document.getElementById('concatenated-audio-player');
|
|
const zipArchiveLink = document.getElementById('zip-archive-link');
|
|
const zipArchivePlaceholder = document.getElementById('zip-archive-placeholder');
|
|
|
|
// Functions to update these elements will be called by the generateDialog handler
|
|
// e.g., updateLog(message), setAudioSource(url), setZipLink(url)
|
|
|
|
console.log('Results Display Initialized');
|
|
}
|