feat/frontend-phase1 #1

Merged
stwhite merged 34 commits from feat/frontend-phase1 into main 2025-08-14 15:44:25 +00:00
3 changed files with 141 additions and 22 deletions
Showing only changes of commit 4a7c1ea6a1 - Show all commits

View File

@ -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;

View File

@ -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<Object>} 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.

View File

@ -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 {