chatterbox-ui/frontend/js/app.js

526 lines
23 KiB
JavaScript

import { getSpeakers, addSpeaker, deleteSpeaker, generateDialog } from './api.js';
const API_BASE_URL = 'http://localhost:8000'; // Assuming backend runs here
// This should match the base URL from which FastAPI serves static files
// If your main app is at http://localhost:8000, and static files are served from /generated_audio relative to that,
// then this should be http://localhost:8000. The backend will return paths like /generated_audio/...
const API_BASE_URL_FOR_FILES = 'http://localhost:8000';
document.addEventListener('DOMContentLoaded', () => {
console.log('DOM fully loaded and parsed');
initializeSpeakerManagement();
initializeDialogEditor(); // Placeholder for now
initializeResultsDisplay(); // Placeholder for now
});
// --- Speaker Management --- //
const speakerListUL = document.getElementById('speaker-list');
const addSpeakerForm = document.getElementById('add-speaker-form');
function initializeSpeakerManagement() {
loadSpeakers();
if (addSpeakerForm) {
addSpeakerForm.addEventListener('submit', async (event) => {
event.preventDefault();
const formData = new FormData(addSpeakerForm);
const speakerName = formData.get('name');
const audioFile = formData.get('audio_file');
if (!speakerName || !audioFile || audioFile.size === 0) {
alert('Please provide a speaker name and an audio file.');
return;
}
try {
const newSpeaker = await addSpeaker(formData);
alert(`Speaker added: ${newSpeaker.name} (ID: ${newSpeaker.id})`);
addSpeakerForm.reset();
loadSpeakers(); // Refresh speaker list
} catch (error) {
console.error('Failed to add speaker:', error);
alert('Error adding speaker: ' + error.message);
}
});
}
}
async function loadSpeakers() {
if (!speakerListUL) return;
try {
const speakers = await getSpeakers();
speakerListUL.innerHTML = ''; // Clear existing list
if (speakers.length === 0) {
const listItem = document.createElement('li');
listItem.textContent = 'No speakers available.';
speakerListUL.appendChild(listItem);
return;
}
speakers.forEach(speaker => {
const listItem = document.createElement('li');
// Create a container for the speaker name and delete button
const container = document.createElement('div');
container.style.display = 'flex';
container.style.justifyContent = 'space-between';
container.style.alignItems = 'center';
container.style.width = '100%';
// Add speaker name
const nameSpan = document.createElement('span');
nameSpan.textContent = speaker.name;
container.appendChild(nameSpan);
// Add delete button
const deleteBtn = document.createElement('button');
deleteBtn.textContent = 'Delete';
deleteBtn.classList.add('delete-speaker-btn');
deleteBtn.onclick = () => handleDeleteSpeaker(speaker.id);
container.appendChild(deleteBtn);
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) {
if (!speakerId) {
alert('Cannot delete speaker: Speaker ID is missing.');
return;
}
if (!confirm(`Are you sure you want to delete speaker ${speakerId}?`)) return;
try {
await deleteSpeaker(speakerId);
alert(`Speaker ${speakerId} deleted successfully.`);
loadSpeakers(); // Refresh speaker list
} 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) {
return {
...item,
audioUrl: item.audioUrl || null,
isGenerating: item.isGenerating || false,
error: item.error || null
};
}
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');
// 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
// 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 = '';
const input = document.createElement('input');
input.className = 'dialog-edit-input';
if (item.type === 'speech') {
input.type = 'text';
input.value = item.text;
input.size = Math.max(16, item.text.length);
} else {
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') {
input.blur();
} else if (e.key === 'Escape') {
renderDialogItems();
}
};
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 = '&times;'; // 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;
const result = await generateLine(payload);
dialogItems[index].audioUrl = result.audio_url;
} catch (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);
// 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 addButton = document.createElement('button');
addButton.textContent = 'Add Speech';
addButton.onclick = () => {
const speakerId = speakerSelect.value;
const text = textInput.value.trim();
if (!speakerId || !text) {
alert('Please select a speaker and enter text.');
return;
}
dialogItems.push(normalizeDialogItem({ type: 'speech', speaker_id: speakerId, text: text }));
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(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}`);
}
});
}
console.log('Dialog Editor Initialized');
renderDialogItems(); // Initial render (empty)
}
// --- 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');
}