From 4a7c1ea6a17fb846cded104839ee3c1aef425980 Mon Sep 17 00:00:00 2001 From: Steve White Date: Fri, 6 Jun 2025 08:44:21 -0500 Subject: [PATCH] Added per-line generation and playback; currently regenerates when you hit 'Generate Audio' --- frontend/css/style.css | 42 +++++++++++++++-- frontend/js/api.js | 21 +++++++++ frontend/js/app.js | 100 +++++++++++++++++++++++++++++++++-------- 3 files changed, 141 insertions(+), 22 deletions(-) diff --git a/frontend/css/style.css b/frontend/css/style.css index 30f0f35..bf9d229 100644 --- a/frontend/css/style.css +++ b/frontend/css/style.css @@ -100,10 +100,12 @@ main { /* Make the Actions (4th) column narrower */ #dialog-items-table th:nth-child(4), #dialog-items-table td:nth-child(4) { - width: 60px; - min-width: 44px; - max-width: 60px; + width: 110px; + min-width: 90px; + max-width: 130px; text-align: center; + padding-left: 0; + padding-right: 0; } @@ -139,7 +141,8 @@ main { } #dialog-items-table td.actions { text-align: center; - min-width: 90px; + min-width: 110px; + white-space: nowrap; } /* Collapsible log details */ @@ -286,6 +289,37 @@ button { margin-right: 10px; } +.generate-line-btn, .play-line-btn { + background: #f3f7fa; + color: #357ab8; + border: 1.5px solid #b5c6df; + border-radius: 50%; + width: 32px; + height: 32px; + font-size: 1.25rem; + display: inline-flex; + align-items: center; + justify-content: center; + margin: 0 3px; + padding: 0; + box-shadow: 0 1px 2px rgba(44,62,80,0.06); + vertical-align: middle; +} +.generate-line-btn:disabled, .play-line-btn:disabled { + opacity: 0.45; + cursor: not-allowed; +} +.generate-line-btn.loading { + background: #f9e79f; + color: #b7950b; + border-color: #f7ca18; +} +.generate-line-btn:hover, .play-line-btn:hover { + background: #eaf1fa; + color: #205081; + border-color: #357ab8; +} + button:hover, button:focus { background: #357ab8; outline: none; diff --git a/frontend/js/api.js b/frontend/js/api.js index 9c33263..611f5ee 100644 --- a/frontend/js/api.js +++ b/frontend/js/api.js @@ -100,6 +100,27 @@ export async function deleteSpeaker(speakerId) { // ... (keep API_BASE_URL, getSpeakers, addSpeaker, deleteSpeaker) +/** + * Generates audio for a single dialog line (speech or silence). + * @param {Object} line - The dialog line object (type: 'speech' or 'silence'). + * @returns {Promise} Resolves with { audio_url } on success. + * @throws {Error} If the network response is not ok. + */ +export async function generateLine(line) { + const response = await fetch(`${API_BASE_URL}/dialog/generate_line/`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify(line), + }); + if (!response.ok) { + const errorData = await response.json().catch(() => ({ message: response.statusText })); + throw new Error(`Failed to generate line audio: ${errorData.detail || errorData.message || response.statusText}`); + } + return response.json(); +} + /** * Generates a dialog by sending a payload to the backend. * @param {Object} dialogPayload - The payload for dialog generation. diff --git a/frontend/js/app.js b/frontend/js/app.js index be9a782..a8bc536 100644 --- a/frontend/js/app.js +++ b/frontend/js/app.js @@ -109,6 +109,16 @@ async function handleDeleteSpeaker(speakerId) { 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'); @@ -131,6 +141,7 @@ function initializeDialogEditor() { function renderDialogItems() { if (!dialogItemsContainer) return; dialogItemsContainer.innerHTML = ''; + dialogItems = dialogItems.map(normalizeDialogItem); // Ensure all fields present dialogItems.forEach((item, index) => { const tr = document.createElement('tr'); @@ -203,9 +214,11 @@ function initializeDialogEditor() { 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(); } @@ -255,8 +268,59 @@ function initializeDialogEditor() { renderDialogItems(); }; actionsTd.appendChild(removeBtn); - tr.appendChild(actionsTd); + // --- 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); }); } @@ -314,7 +378,7 @@ function initializeDialogEditor() { alert('Please select a speaker and enter text.'); return; } - dialogItems.push({ type: 'speech', speaker_id: speakerId, text: text }); + dialogItems.push(normalizeDialogItem({ type: 'speech', speaker_id: speakerId, text: text })); renderDialogItems(); clearTempInputArea(); }; @@ -356,7 +420,7 @@ function initializeDialogEditor() { alert('Invalid duration. Please enter a positive number.'); return; } - dialogItems.push({ type: 'silence', duration: duration }); + dialogItems.push(normalizeDialogItem({ type: 'silence', duration: duration })); renderDialogItems(); clearTempInputArea(); }; @@ -386,23 +450,23 @@ function initializeDialogEditor() { 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: dialogItems.map(item => { - // For now, we are not collecting TTS params in the UI for speech items. - // The backend will use defaults. If we add UI for these later, they'd be included here. - if (item.type === 'speech') { - return { - type: item.type, - speaker_id: item.speaker_id, - text: item.text, - // exaggeration: item.exaggeration, // Example for future UI enhancement - // cfg_weight: item.cfg_weight, - // temperature: item.temperature - }; - } - return item; // for silence items - }) + dialog_items: dialogItemsToGenerate }; try {