Added per-line generation and playback; currently regenerates when you hit 'Generate Audio'
This commit is contained in:
parent
0261b86ad2
commit
4a7c1ea6a1
|
@ -100,10 +100,12 @@ main {
|
||||||
|
|
||||||
/* Make the Actions (4th) column narrower */
|
/* Make the Actions (4th) column narrower */
|
||||||
#dialog-items-table th:nth-child(4), #dialog-items-table td:nth-child(4) {
|
#dialog-items-table th:nth-child(4), #dialog-items-table td:nth-child(4) {
|
||||||
width: 60px;
|
width: 110px;
|
||||||
min-width: 44px;
|
min-width: 90px;
|
||||||
max-width: 60px;
|
max-width: 130px;
|
||||||
text-align: center;
|
text-align: center;
|
||||||
|
padding-left: 0;
|
||||||
|
padding-right: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@ -139,7 +141,8 @@ main {
|
||||||
}
|
}
|
||||||
#dialog-items-table td.actions {
|
#dialog-items-table td.actions {
|
||||||
text-align: center;
|
text-align: center;
|
||||||
min-width: 90px;
|
min-width: 110px;
|
||||||
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Collapsible log details */
|
/* Collapsible log details */
|
||||||
|
@ -286,6 +289,37 @@ button {
|
||||||
margin-right: 10px;
|
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 {
|
button:hover, button:focus {
|
||||||
background: #357ab8;
|
background: #357ab8;
|
||||||
outline: none;
|
outline: none;
|
||||||
|
|
|
@ -100,6 +100,27 @@ export async function deleteSpeaker(speakerId) {
|
||||||
|
|
||||||
// ... (keep API_BASE_URL, getSpeakers, addSpeaker, deleteSpeaker)
|
// ... (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.
|
* Generates a dialog by sending a payload to the backend.
|
||||||
* @param {Object} dialogPayload - The payload for dialog generation.
|
* @param {Object} dialogPayload - The payload for dialog generation.
|
||||||
|
|
|
@ -109,6 +109,16 @@ async function handleDeleteSpeaker(speakerId) {
|
||||||
let dialogItems = []; // Holds the sequence of speech/silence items
|
let dialogItems = []; // Holds the sequence of speech/silence items
|
||||||
let availableSpeakersCache = []; // To populate speaker dropdown
|
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() {
|
function initializeDialogEditor() {
|
||||||
const dialogItemsContainer = document.getElementById('dialog-items-container');
|
const dialogItemsContainer = document.getElementById('dialog-items-container');
|
||||||
const addSpeechLineBtn = document.getElementById('add-speech-line-btn');
|
const addSpeechLineBtn = document.getElementById('add-speech-line-btn');
|
||||||
|
@ -131,6 +141,7 @@ function initializeDialogEditor() {
|
||||||
function renderDialogItems() {
|
function renderDialogItems() {
|
||||||
if (!dialogItemsContainer) return;
|
if (!dialogItemsContainer) return;
|
||||||
dialogItemsContainer.innerHTML = '';
|
dialogItemsContainer.innerHTML = '';
|
||||||
|
dialogItems = dialogItems.map(normalizeDialogItem); // Ensure all fields present
|
||||||
dialogItems.forEach((item, index) => {
|
dialogItems.forEach((item, index) => {
|
||||||
const tr = document.createElement('tr');
|
const tr = document.createElement('tr');
|
||||||
|
|
||||||
|
@ -203,9 +214,11 @@ function initializeDialogEditor() {
|
||||||
function saveEdit() {
|
function saveEdit() {
|
||||||
if (item.type === 'speech') {
|
if (item.type === 'speech') {
|
||||||
dialogItems[index].text = input.value;
|
dialogItems[index].text = input.value;
|
||||||
|
dialogItems[index].audioUrl = null; // Invalidate audio if text edited
|
||||||
} else {
|
} else {
|
||||||
let val = parseFloat(input.value);
|
let val = parseFloat(input.value);
|
||||||
if (!isNaN(val) && val > 0) dialogItems[index].duration = val;
|
if (!isNaN(val) && val > 0) dialogItems[index].duration = val;
|
||||||
|
dialogItems[index].audioUrl = null;
|
||||||
}
|
}
|
||||||
renderDialogItems();
|
renderDialogItems();
|
||||||
}
|
}
|
||||||
|
@ -255,8 +268,59 @@ function initializeDialogEditor() {
|
||||||
renderDialogItems();
|
renderDialogItems();
|
||||||
};
|
};
|
||||||
actionsTd.appendChild(removeBtn);
|
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);
|
dialogItemsContainer.appendChild(tr);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
@ -314,7 +378,7 @@ function initializeDialogEditor() {
|
||||||
alert('Please select a speaker and enter text.');
|
alert('Please select a speaker and enter text.');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
dialogItems.push({ type: 'speech', speaker_id: speakerId, text: text });
|
dialogItems.push(normalizeDialogItem({ type: 'speech', speaker_id: speakerId, text: text }));
|
||||||
renderDialogItems();
|
renderDialogItems();
|
||||||
clearTempInputArea();
|
clearTempInputArea();
|
||||||
};
|
};
|
||||||
|
@ -356,7 +420,7 @@ function initializeDialogEditor() {
|
||||||
alert('Invalid duration. Please enter a positive number.');
|
alert('Invalid duration. Please enter a positive number.');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
dialogItems.push({ type: 'silence', duration: duration });
|
dialogItems.push(normalizeDialogItem({ type: 'silence', duration: duration }));
|
||||||
renderDialogItems();
|
renderDialogItems();
|
||||||
clearTempInputArea();
|
clearTempInputArea();
|
||||||
};
|
};
|
||||||
|
@ -386,23 +450,23 @@ function initializeDialogEditor() {
|
||||||
alert('Please add at least one speech or silence line to the dialog.');
|
alert('Please add at least one speech or silence line to the dialog.');
|
||||||
return; // Prevent further execution if no dialog items
|
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 = {
|
const payload = {
|
||||||
output_base_name: outputBaseName,
|
output_base_name: outputBaseName,
|
||||||
dialog_items: dialogItems.map(item => {
|
dialog_items: dialogItemsToGenerate
|
||||||
// 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
|
|
||||||
})
|
|
||||||
};
|
};
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
|
Loading…
Reference in New Issue