Working minimum interface for js and api
This commit is contained in:
parent
9d1dc330ea
commit
b5db7172cf
|
@ -0,0 +1,92 @@
|
|||
/* Basic styles - to be expanded */
|
||||
body {
|
||||
font-family: sans-serif;
|
||||
line-height: 1.6;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
background-color: #f4f4f4;
|
||||
color: #333;
|
||||
}
|
||||
|
||||
header {
|
||||
background: #333;
|
||||
color: #fff;
|
||||
padding: 1rem 0;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
main {
|
||||
padding: 20px;
|
||||
max-width: 960px;
|
||||
margin: auto;
|
||||
}
|
||||
|
||||
section {
|
||||
background: #fff;
|
||||
padding: 20px;
|
||||
margin-bottom: 20px;
|
||||
border-radius: 5px;
|
||||
}
|
||||
|
||||
hr {
|
||||
margin: 20px 0;
|
||||
border: 0;
|
||||
border-top: 1px solid #eee;
|
||||
}
|
||||
|
||||
button {
|
||||
padding: 10px 15px;
|
||||
background: #333;
|
||||
color: #fff;
|
||||
border: none;
|
||||
border-radius: 5px;
|
||||
cursor: pointer;
|
||||
margin-right: 5px; /* Add some margin between buttons */
|
||||
}
|
||||
|
||||
button:hover {
|
||||
background: #555;
|
||||
}
|
||||
|
||||
input[type='text'], input[type='file'] {
|
||||
padding: 8px;
|
||||
margin-bottom: 10px;
|
||||
border: 1px solid #ddd;
|
||||
border-radius: 4px;
|
||||
width: calc(100% - 20px); /* Adjust width considering padding */
|
||||
}
|
||||
|
||||
label {
|
||||
display: block;
|
||||
margin-bottom: 5px;
|
||||
}
|
||||
|
||||
#speaker-list {
|
||||
list-style: none;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
#speaker-list li {
|
||||
padding: 5px 0;
|
||||
border-bottom: 1px dotted #eee;
|
||||
}
|
||||
|
||||
#speaker-list li:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
pre {
|
||||
background: #eee;
|
||||
padding: 10px;
|
||||
border-radius: 4px;
|
||||
white-space: pre-wrap; /* Allow wrapping */
|
||||
word-wrap: break-word; /* Break long words */
|
||||
}
|
||||
|
||||
footer {
|
||||
text-align: center;
|
||||
padding: 20px;
|
||||
background: #333;
|
||||
color: #fff;
|
||||
margin-top: 30px;
|
||||
}
|
|
@ -0,0 +1,87 @@
|
|||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Chatterbox TTS Frontend</title>
|
||||
<link rel="stylesheet" href="css/style.css">
|
||||
</head>
|
||||
<body>
|
||||
<header>
|
||||
<h1>Chatterbox TTS</h1>
|
||||
</header>
|
||||
|
||||
<main>
|
||||
<section id="speaker-management">
|
||||
<h2>Speaker Management</h2>
|
||||
<div id="speaker-list-container">
|
||||
<h3>Available Speakers</h3>
|
||||
<ul id="speaker-list">
|
||||
<!-- Speakers will be populated here by JavaScript -->
|
||||
</ul>
|
||||
</div>
|
||||
<div id="add-speaker-container">
|
||||
<h3>Add New Speaker</h3>
|
||||
<form id="add-speaker-form">
|
||||
<div>
|
||||
<label for="speaker-name">Speaker Name:</label>
|
||||
<input type="text" id="speaker-name" name="name" required>
|
||||
</div>
|
||||
<div>
|
||||
<label for="speaker-sample">Audio Sample (WAV or MP3):</label>
|
||||
<input type="file" id="speaker-sample" name="audio_file" accept=".wav,.mp3" required>
|
||||
</div>
|
||||
<button type="submit">Add Speaker</button>
|
||||
</form>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<hr>
|
||||
|
||||
<section id="dialog-editor">
|
||||
<h2>Dialog Editor</h2>
|
||||
<div id="dialog-items-container">
|
||||
<!-- Dialog items will be added here by JavaScript -->
|
||||
</div>
|
||||
<div id="temp-input-area">
|
||||
<!-- Temporary inputs for speech/silence will go here -->
|
||||
</div>
|
||||
<div class="dialog-controls">
|
||||
<button id="add-speech-line-btn">Add Speech Line</button>
|
||||
<button id="add-silence-line-btn">Add Silence Line</button>
|
||||
</div>
|
||||
<div class="dialog-controls">
|
||||
<label for="output-base-name">Output Base Name:</label>
|
||||
<input type="text" id="output-base-name" name="output-base-name" value="dialog_output" required>
|
||||
</div>
|
||||
<button id="generate-dialog-btn">Generate Dialog</button>
|
||||
</section>
|
||||
|
||||
<hr>
|
||||
|
||||
<section id="results-display">
|
||||
<h2>Results</h2>
|
||||
<div>
|
||||
<h3>Log:</h3>
|
||||
<pre id="generation-log-content">(Generation log will appear here)</pre>
|
||||
</div>
|
||||
<div>
|
||||
<h3>Concatenated Audio:</h3>
|
||||
<audio id="concatenated-audio-player" controls src=""></audio>
|
||||
</div>
|
||||
<div>
|
||||
<h3>Download Archive:</h3>
|
||||
<a id="zip-archive-link" href="#" download style="display: none;">Download ZIP</a>
|
||||
<p id="zip-archive-placeholder">(ZIP download link will appear here)</p>
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
|
||||
<footer>
|
||||
<p>© 2024 Chatterbox TTS</p>
|
||||
</footer>
|
||||
|
||||
<script src="js/api.js" type="module"></script>
|
||||
<script src="js/app.js" type="module" defer></script>
|
||||
</body>
|
||||
</html>
|
|
@ -0,0 +1,131 @@
|
|||
// frontend/js/api.js
|
||||
|
||||
const API_BASE_URL = 'http://localhost:8000/api'; // Assuming backend runs on port 8000
|
||||
|
||||
/**
|
||||
* Fetches the list of available speakers.
|
||||
* @returns {Promise<Array<Object>>} A promise that resolves to an array of speaker objects.
|
||||
* @throws {Error} If the network response is not ok.
|
||||
*/
|
||||
export async function getSpeakers() {
|
||||
const response = await fetch(`${API_BASE_URL}/speakers/`);
|
||||
if (!response.ok) {
|
||||
const errorData = await response.json().catch(() => ({ message: response.statusText }));
|
||||
throw new Error(`Failed to fetch speakers: ${errorData.detail || errorData.message || response.statusText}`);
|
||||
}
|
||||
return response.json();
|
||||
}
|
||||
|
||||
// We will add more functions here: addSpeaker, deleteSpeaker, generateDialog
|
||||
|
||||
// ... (keep API_BASE_URL and getSpeakers)
|
||||
|
||||
/**
|
||||
* Adds a new speaker.
|
||||
* @param {FormData} formData - The form data containing speaker name and audio file.
|
||||
* Example: formData.append('name', 'New Speaker');
|
||||
* formData.append('audio_sample_file', fileInput.files[0]);
|
||||
* @returns {Promise<Object>} A promise that resolves to the new speaker object.
|
||||
* @throws {Error} If the network response is not ok.
|
||||
*/
|
||||
export async function addSpeaker(formData) {
|
||||
const response = await fetch(`${API_BASE_URL}/speakers/`, {
|
||||
method: 'POST',
|
||||
body: formData, // FormData sets Content-Type to multipart/form-data automatically
|
||||
});
|
||||
if (!response.ok) {
|
||||
console.log('API_JS_ADD_SPEAKER: Entered !response.ok block. Status:', response.status, 'StatusText:', response.statusText);
|
||||
let errorPayload = { detail: `Request failed with status ${response.status}` }; // Default payload
|
||||
try {
|
||||
console.log('API_JS_ADD_SPEAKER: Attempting to parse error response as JSON...');
|
||||
errorPayload = await response.json();
|
||||
console.log('API_JS_ADD_SPEAKER: Successfully parsed error JSON:', errorPayload);
|
||||
} catch (e) {
|
||||
console.warn('API_JS_ADD_SPEAKER: Failed to parse error response as JSON. Error:', e);
|
||||
// Use statusText if JSON parsing fails
|
||||
errorPayload = { detail: response.statusText || `Request failed with status ${response.status} and no JSON body.`, parseError: e.toString() };
|
||||
}
|
||||
|
||||
console.error('--- BEGIN SERVER ERROR PAYLOAD (addSpeaker) ---');
|
||||
console.error('Status:', response.status);
|
||||
console.error('Status Text:', response.statusText);
|
||||
console.error('Parsed Payload:', errorPayload);
|
||||
console.error('--- END SERVER ERROR PAYLOAD (addSpeaker) ---');
|
||||
|
||||
let detailedMessage = "Unknown error";
|
||||
if (errorPayload && errorPayload.detail) {
|
||||
if (typeof errorPayload.detail === 'string') {
|
||||
detailedMessage = errorPayload.detail;
|
||||
} else {
|
||||
// If detail is an array (FastAPI validation errors) or object, stringify it.
|
||||
detailedMessage = JSON.stringify(errorPayload.detail);
|
||||
}
|
||||
} else if (errorPayload && errorPayload.message) {
|
||||
detailedMessage = errorPayload.message;
|
||||
} else if (response.statusText) {
|
||||
detailedMessage = response.statusText;
|
||||
} else {
|
||||
detailedMessage = `HTTP error ${response.status}`;
|
||||
}
|
||||
|
||||
console.log(`API_JS_ADD_SPEAKER: Constructed detailedMessage: "${detailedMessage}"`);
|
||||
console.log(`API_JS_ADD_SPEAKER: Throwing error with message: "Failed to add speaker: ${detailedMessage}"`);
|
||||
throw new Error(`Failed to add speaker: ${detailedMessage}`);
|
||||
}
|
||||
return response.json();
|
||||
}
|
||||
|
||||
// ... (keep API_BASE_URL, getSpeakers, addSpeaker)
|
||||
|
||||
/**
|
||||
* Deletes a speaker by their ID.
|
||||
* @param {string} speakerId - The ID of the speaker to delete.
|
||||
* @returns {Promise<Object>} A promise that resolves to the response data (e.g., success message).
|
||||
* @throws {Error} If the network response is not ok.
|
||||
*/
|
||||
export async function deleteSpeaker(speakerId) {
|
||||
const response = await fetch(`${API_BASE_URL}/speakers/${speakerId}/`, {
|
||||
method: 'DELETE',
|
||||
});
|
||||
if (!response.ok) {
|
||||
const errorData = await response.json().catch(() => ({ message: response.statusText }));
|
||||
throw new Error(`Failed to delete speaker ${speakerId}: ${errorData.detail || errorData.message || response.statusText}`);
|
||||
}
|
||||
// Handle 204 No Content specifically, as .json() would fail
|
||||
if (response.status === 204) {
|
||||
return { message: `Speaker ${speakerId} deleted successfully.` };
|
||||
}
|
||||
return response.json();
|
||||
}
|
||||
|
||||
// ... (keep API_BASE_URL, getSpeakers, addSpeaker, deleteSpeaker)
|
||||
|
||||
/**
|
||||
* Generates a dialog by sending a payload to the backend.
|
||||
* @param {Object} dialogPayload - The payload for dialog generation.
|
||||
* Example:
|
||||
* {
|
||||
* output_base_name: "my_dialog",
|
||||
* dialog_items: [
|
||||
* { type: "speech", speaker_id: "speaker1", text: "Hello world.", exaggeration: 1.0, cfg_weight: 2.0, temperature: 0.7 },
|
||||
* { type: "silence", duration_ms: 500 },
|
||||
* { type: "speech", speaker_id: "speaker2", text: "How are you?" }
|
||||
* ]
|
||||
* }
|
||||
* @returns {Promise<Object>} A promise that resolves to the dialog generation response (log, file URLs).
|
||||
* @throws {Error} If the network response is not ok.
|
||||
*/
|
||||
export async function generateDialog(dialogPayload) {
|
||||
const response = await fetch(`${API_BASE_URL}/dialog/generate/`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify(dialogPayload),
|
||||
});
|
||||
if (!response.ok) {
|
||||
const errorData = await response.json().catch(() => ({ message: response.statusText }));
|
||||
throw new Error(`Failed to generate dialog: ${errorData.detail || errorData.message || response.statusText}`);
|
||||
}
|
||||
return response.json();
|
||||
}
|
|
@ -0,0 +1,353 @@
|
|||
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');
|
||||
listItem.textContent = `${speaker.name} (ID: ${speaker.id || 'N/A'}) `;
|
||||
|
||||
const deleteBtn = document.createElement('button');
|
||||
deleteBtn.textContent = 'Delete';
|
||||
deleteBtn.classList.add('delete-speaker-btn');
|
||||
deleteBtn.onclick = () => handleDeleteSpeaker(speaker.id);
|
||||
listItem.appendChild(deleteBtn);
|
||||
|
||||
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
|
||||
|
||||
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
|
||||
function renderDialogItems() {
|
||||
if (!dialogItemsContainer) return;
|
||||
dialogItemsContainer.innerHTML = ''; // Clear existing items
|
||||
dialogItems.forEach((item, index) => {
|
||||
const li = document.createElement('li');
|
||||
li.classList.add('dialog-item');
|
||||
if (item.type === 'speech') {
|
||||
const speaker = availableSpeakersCache.find(s => s.id === item.speaker_id);
|
||||
const speakerName = speaker ? speaker.name : 'Unknown Speaker';
|
||||
li.textContent = `Speech: [${speakerName}] "${item.text.substring(0, 30)}${item.text.length > 30 ? '...' : ''}"`;
|
||||
} else if (item.type === 'silence') {
|
||||
li.textContent = `Silence: ${item.duration}s`;
|
||||
}
|
||||
|
||||
const removeBtn = document.createElement('button');
|
||||
removeBtn.textContent = 'Remove';
|
||||
removeBtn.classList.add('remove-dialog-item-btn');
|
||||
removeBtn.onclick = () => {
|
||||
dialogItems.splice(index, 1);
|
||||
renderDialogItems();
|
||||
};
|
||||
li.appendChild(removeBtn);
|
||||
dialogItemsContainer.appendChild(li);
|
||||
});
|
||||
}
|
||||
|
||||
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({ 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({ 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;
|
||||
}
|
||||
|
||||
// Clear previous results and show loading/status
|
||||
if (generationLogPre) generationLogPre.textContent = 'Generating dialog...';
|
||||
if (audioPlayer) {
|
||||
audioPlayer.style.display = 'none';
|
||||
audioPlayer.src = ''; // Clear previous audio source
|
||||
}
|
||||
if (downloadZipLink) {
|
||||
downloadZipLink.style.display = 'none';
|
||||
downloadZipLink.href = '#';
|
||||
downloadZipLink.textContent = '';
|
||||
}
|
||||
if (zipArchivePlaceholder) zipArchivePlaceholder.style.display = 'block'; // Show placeholder
|
||||
if (resultsDisplaySection) resultsDisplaySection.style.display = 'block'; // Make sure it's visible
|
||||
|
||||
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
|
||||
})
|
||||
};
|
||||
|
||||
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
|
||||
audioPlayer.src = result.concatenated_audio_url.startsWith('http') ? result.concatenated_audio_url : `${API_BASE_URL_FOR_FILES}${result.concatenated_audio_url}`;
|
||||
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');
|
||||
}
|
|
@ -0,0 +1,196 @@
|
|||
// frontend/tests/api.test.js
|
||||
|
||||
// Import the function to test (adjust path if your structure is different)
|
||||
// We might need to configure Jest or use Babel for ES module syntax if this causes issues.
|
||||
import { getSpeakers, addSpeaker, deleteSpeaker, generateDialog } from '../js/api.js';
|
||||
|
||||
// Mock the global fetch function
|
||||
global.fetch = jest.fn();
|
||||
|
||||
const API_BASE_URL = 'http://localhost:8000/api'; // Centralize for all tests
|
||||
|
||||
describe('API Client - getSpeakers', () => {
|
||||
beforeEach(() => {
|
||||
// Clear all instances and calls to constructor and all methods:
|
||||
fetch.mockClear();
|
||||
});
|
||||
|
||||
it('should fetch speakers successfully', async () => {
|
||||
const mockSpeakers = [{ id: '1', name: 'Speaker 1' }, { id: '2', name: 'Speaker 2' }];
|
||||
fetch.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
json: async () => mockSpeakers,
|
||||
});
|
||||
|
||||
const speakers = await getSpeakers();
|
||||
expect(fetch).toHaveBeenCalledTimes(1);
|
||||
expect(fetch).toHaveBeenCalledWith(`${API_BASE_URL}/speakers`);
|
||||
expect(speakers).toEqual(mockSpeakers);
|
||||
});
|
||||
|
||||
it('should throw an error if the network response is not ok', async () => {
|
||||
fetch.mockResolvedValueOnce({
|
||||
ok: false,
|
||||
statusText: 'Not Found',
|
||||
json: async () => ({ detail: 'Speakers not found' }) // Simulate FastAPI error response
|
||||
});
|
||||
|
||||
await expect(getSpeakers()).rejects.toThrow('Failed to fetch speakers: Speakers not found');
|
||||
expect(fetch).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('should throw a generic error if parsing error response fails', async () => {
|
||||
fetch.mockResolvedValueOnce({
|
||||
ok: false,
|
||||
statusText: 'Internal Server Error',
|
||||
json: async () => { throw new Error('Failed to parse error JSON'); } // Simulate error during .json()
|
||||
});
|
||||
|
||||
await expect(getSpeakers()).rejects.toThrow('Failed to fetch speakers: Internal Server Error');
|
||||
expect(fetch).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('should throw an error if fetch itself fails (network error)', async () => {
|
||||
fetch.mockRejectedValueOnce(new TypeError('Network failed'));
|
||||
|
||||
await expect(getSpeakers()).rejects.toThrow('Network failed'); // This will be the original fetch error
|
||||
expect(fetch).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('API Client - addSpeaker', () => {
|
||||
beforeEach(() => {
|
||||
fetch.mockClear();
|
||||
});
|
||||
|
||||
it('should add a speaker successfully', async () => {
|
||||
const mockFormData = new FormData(); // In a real scenario, this would have data
|
||||
mockFormData.append('name', 'Test Speaker');
|
||||
// mockFormData.append('audio_sample_file', new File([''], 'sample.wav')); // File creation in Node test needs more setup or a mock
|
||||
|
||||
const mockResponse = { id: '3', name: 'Test Speaker', message: 'Speaker added successfully' };
|
||||
fetch.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
json: async () => mockResponse,
|
||||
});
|
||||
|
||||
const result = await addSpeaker(mockFormData);
|
||||
expect(fetch).toHaveBeenCalledTimes(1);
|
||||
expect(fetch).toHaveBeenCalledWith(`${API_BASE_URL}/speakers`, {
|
||||
method: 'POST',
|
||||
body: mockFormData,
|
||||
});
|
||||
expect(result).toEqual(mockResponse);
|
||||
});
|
||||
|
||||
it('should throw an error if adding a speaker fails', async () => {
|
||||
const mockFormData = new FormData();
|
||||
fetch.mockResolvedValueOnce({
|
||||
ok: false,
|
||||
statusText: 'Bad Request',
|
||||
json: async () => ({ detail: 'Invalid speaker data' }),
|
||||
});
|
||||
|
||||
await expect(addSpeaker(mockFormData)).rejects.toThrow('Failed to add speaker: Invalid speaker data');
|
||||
expect(fetch).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('API Client - deleteSpeaker', () => {
|
||||
beforeEach(() => {
|
||||
fetch.mockClear();
|
||||
});
|
||||
|
||||
it('should delete a speaker successfully with JSON response', async () => {
|
||||
const speakerId = 'test-speaker-id-123';
|
||||
const mockResponse = { message: `Speaker ${speakerId} deleted successfully` };
|
||||
fetch.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
status: 200, // Or any 2xx status that might return JSON
|
||||
json: async () => mockResponse,
|
||||
});
|
||||
|
||||
const result = await deleteSpeaker(speakerId);
|
||||
expect(fetch).toHaveBeenCalledTimes(1);
|
||||
expect(fetch).toHaveBeenCalledWith(`${API_BASE_URL}/speakers/${speakerId}`, {
|
||||
method: 'DELETE',
|
||||
});
|
||||
expect(result).toEqual(mockResponse);
|
||||
});
|
||||
|
||||
it('should handle successful deletion with 204 No Content response', async () => {
|
||||
const speakerId = 'test-speaker-id-204';
|
||||
fetch.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
status: 204,
|
||||
statusText: 'No Content',
|
||||
// .json() is not called by the function if status is 204
|
||||
});
|
||||
|
||||
const result = await deleteSpeaker(speakerId);
|
||||
expect(fetch).toHaveBeenCalledTimes(1);
|
||||
expect(fetch).toHaveBeenCalledWith(`${API_BASE_URL}/speakers/${speakerId}`, {
|
||||
method: 'DELETE',
|
||||
});
|
||||
expect(result).toEqual({ message: `Speaker ${speakerId} deleted successfully.` });
|
||||
});
|
||||
|
||||
it('should throw an error if deleting a speaker fails (e.g., speaker not found)', async () => {
|
||||
const speakerId = 'non-existent-speaker-id';
|
||||
fetch.mockResolvedValueOnce({
|
||||
ok: false,
|
||||
status: 404,
|
||||
statusText: 'Not Found',
|
||||
json: async () => ({ detail: 'Speaker not found' }),
|
||||
});
|
||||
|
||||
await expect(deleteSpeaker(speakerId)).rejects.toThrow(`Failed to delete speaker ${speakerId}: Speaker not found`);
|
||||
expect(fetch).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('API Client - generateDialog', () => {
|
||||
beforeEach(() => {
|
||||
fetch.mockClear();
|
||||
});
|
||||
|
||||
it('should generate dialog successfully', async () => {
|
||||
const mockPayload = {
|
||||
output_base_name: "test_dialog",
|
||||
dialog_items: [
|
||||
{ type: "speech", speaker_id: "spk_1", text: "Hello.", exaggeration: 1.0, cfg_weight: 3.0, temperature: 0.5 },
|
||||
{ type: "silence", duration_ms: 250 }
|
||||
]
|
||||
};
|
||||
const mockResponse = {
|
||||
log: "Dialog generated.",
|
||||
concatenated_audio_url: "/audio/test_dialog_concatenated.wav",
|
||||
zip_archive_url: "/audio/test_dialog.zip"
|
||||
};
|
||||
fetch.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
json: async () => mockResponse,
|
||||
});
|
||||
|
||||
const result = await generateDialog(mockPayload);
|
||||
expect(fetch).toHaveBeenCalledTimes(1);
|
||||
expect(fetch).toHaveBeenCalledWith(`${API_BASE_URL}/dialog/generate`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(mockPayload),
|
||||
});
|
||||
expect(result).toEqual(mockResponse);
|
||||
});
|
||||
|
||||
it('should throw an error if dialog generation fails', async () => {
|
||||
const mockPayload = { output_base_name: "fail_dialog", dialog_items: [] }; // Example invalid payload
|
||||
fetch.mockResolvedValueOnce({
|
||||
ok: false,
|
||||
statusText: 'Bad Request',
|
||||
json: async () => ({ detail: 'Invalid dialog data' }),
|
||||
});
|
||||
|
||||
await expect(generateDialog(mockPayload)).rejects.toThrow('Failed to generate dialog: Invalid dialog data');
|
||||
expect(fetch).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
Loading…
Reference in New Issue