Add dialog script save/load functionality and CLAUDE.md
- Implement save/load buttons in dialog editor interface - Add JSONL export/import for dialog scripts with validation - Include timestamp-based filenames for saved scripts - Add comprehensive error handling and user confirmations - Create CLAUDE.md with development guidance and architecture overview 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
parent
d3ff6e5241
commit
d8eb2492d7
|
@ -0,0 +1,102 @@
|
|||
# CLAUDE.md
|
||||
|
||||
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
|
||||
|
||||
## Common Development Commands
|
||||
|
||||
### Backend (FastAPI)
|
||||
|
||||
```bash
|
||||
# Install backend dependencies (run from project root)
|
||||
pip install -r backend/requirements.txt
|
||||
|
||||
# Run backend development server (run from project root)
|
||||
uvicorn backend.app.main:app --reload --host 0.0.0.0 --port 8000
|
||||
|
||||
# Run backend tests
|
||||
python backend/run_api_test.py
|
||||
|
||||
# Backend API accessible at http://127.0.0.1:8000
|
||||
# API docs at http://127.0.0.1:8000/docs
|
||||
```
|
||||
|
||||
### Frontend Testing
|
||||
|
||||
```bash
|
||||
# Run frontend tests
|
||||
npm test
|
||||
```
|
||||
|
||||
### Alternative Interfaces
|
||||
|
||||
```bash
|
||||
# Run Gradio interface (standalone TTS app)
|
||||
python gradio_app.py
|
||||
```
|
||||
|
||||
## Architecture Overview
|
||||
|
||||
This is a full-stack TTS (Text-to-Speech) application with three interfaces:
|
||||
|
||||
1. **Modern web frontend** (vanilla JS) - Interactive dialog editor at `frontend/`
|
||||
2. **FastAPI backend** - REST API at `backend/`
|
||||
3. **Gradio interface** - Alternative UI in `gradio_app.py`
|
||||
|
||||
### Frontend-Backend Communication
|
||||
|
||||
- **Frontend**: Vanilla JS (ES6 modules) serving on port 8001
|
||||
- **Backend**: FastAPI serving on port 8000
|
||||
- **API Base**: `http://localhost:8000/api`
|
||||
- **CORS**: Configured for frontend communication
|
||||
- **File Serving**: Generated audio served via `/generated_audio/` endpoint
|
||||
|
||||
### Key API Endpoints
|
||||
|
||||
- `/api/speakers/` - Speaker CRUD operations
|
||||
- `/api/dialog/generate/` - Full dialog generation
|
||||
- `/api/dialog/generate_line/` - Single line generation
|
||||
- `/generated_audio/` - Static audio file serving
|
||||
|
||||
### Backend Service Architecture
|
||||
|
||||
Located in `backend/app/services/`:
|
||||
|
||||
- **TTSService**: Chatterbox TTS model lifecycle management
|
||||
- **SpeakerManagementService**: Speaker data and sample management
|
||||
- **DialogProcessorService**: Dialog script to audio processing
|
||||
- **AudioManipulationService**: Audio concatenation and ZIP creation
|
||||
|
||||
### Frontend Architecture
|
||||
|
||||
- **Modular design**: `api.js` (API layer) + `app.js` (app logic)
|
||||
- **No framework**: Modern vanilla JavaScript with ES6+ features
|
||||
- **Interactive editor**: Table-based dialog creation with drag-drop reordering
|
||||
|
||||
### Data Flow
|
||||
|
||||
1. User creates dialog in frontend table editor
|
||||
2. Frontend sends dialog items to `/api/dialog/generate/`
|
||||
3. Backend processes speech/silence items via services
|
||||
4. TTS generates audio, segments concatenated
|
||||
5. ZIP archive created with all outputs
|
||||
6. Frontend receives URLs for playback/download
|
||||
|
||||
### Speaker Configuration
|
||||
|
||||
- **Location**: `speaker_data/speakers.yaml` and `speaker_data/speaker_samples/`
|
||||
- **Format**: YAML config referencing WAV audio samples
|
||||
- **Management**: Both API endpoints and file-based configuration
|
||||
|
||||
### Output Organization
|
||||
|
||||
- `dialog_output/` - Generated dialog files
|
||||
- `single_output/` - Single utterance outputs
|
||||
- `tts_outputs/` - Raw TTS generation files
|
||||
- Generated ZIPs contain organized file structure
|
||||
|
||||
## Development Setup Notes
|
||||
|
||||
- Python virtual environment expected at project root (`.venv`)
|
||||
- Backend commands run from project root, not `backend/` directory
|
||||
- Frontend served separately (typically port 8001)
|
||||
- Speaker samples must be WAV format in `speaker_data/speaker_samples/`
|
|
@ -43,6 +43,11 @@
|
|||
<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>
|
||||
<div class="dialog-controls form-row">
|
||||
<button id="save-script-btn">Save Script</button>
|
||||
<input type="file" id="load-script-input" accept=".jsonl" style="display: none;">
|
||||
<button id="load-script-btn">Load Script</button>
|
||||
</div>
|
||||
<button id="generate-dialog-btn">Generate Dialog</button>
|
||||
</section>
|
||||
</div>
|
||||
|
|
|
@ -125,6 +125,9 @@ function initializeDialogEditor() {
|
|||
const addSilenceLineBtn = document.getElementById('add-silence-line-btn');
|
||||
const outputBaseNameInput = document.getElementById('output-base-name');
|
||||
const generateDialogBtn = document.getElementById('generate-dialog-btn');
|
||||
const saveScriptBtn = document.getElementById('save-script-btn');
|
||||
const loadScriptBtn = document.getElementById('load-script-btn');
|
||||
const loadScriptInput = document.getElementById('load-script-input');
|
||||
|
||||
// Results Display Elements
|
||||
const generationLogPre = document.getElementById('generation-log-content'); // Corrected ID
|
||||
|
@ -507,6 +510,176 @@ function initializeDialogEditor() {
|
|||
});
|
||||
}
|
||||
|
||||
// --- Save/Load Script Functionality ---
|
||||
function saveDialogScript() {
|
||||
if (dialogItems.length === 0) {
|
||||
alert('No dialog items to save. Please add some speech or silence lines first.');
|
||||
return;
|
||||
}
|
||||
|
||||
// Filter out UI-specific fields and create clean data for export
|
||||
const exportData = dialogItems.map(item => {
|
||||
const cleanItem = {
|
||||
type: item.type
|
||||
};
|
||||
|
||||
if (item.type === 'speech') {
|
||||
cleanItem.speaker_id = item.speaker_id;
|
||||
cleanItem.text = item.text;
|
||||
} else if (item.type === 'silence') {
|
||||
cleanItem.duration = item.duration;
|
||||
}
|
||||
|
||||
return cleanItem;
|
||||
});
|
||||
|
||||
// Convert to JSONL format (one JSON object per line)
|
||||
const jsonlContent = exportData.map(item => JSON.stringify(item)).join('\n');
|
||||
|
||||
// Create and download file
|
||||
const blob = new Blob([jsonlContent], { type: 'application/jsonl' });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const link = document.createElement('a');
|
||||
|
||||
// Generate filename with timestamp
|
||||
const timestamp = new Date().toISOString().replace(/[:.]/g, '-').slice(0, 19);
|
||||
const filename = `dialog_script_${timestamp}.jsonl`;
|
||||
|
||||
link.href = url;
|
||||
link.download = filename;
|
||||
link.style.display = 'none';
|
||||
document.body.appendChild(link);
|
||||
link.click();
|
||||
document.body.removeChild(link);
|
||||
URL.revokeObjectURL(url);
|
||||
|
||||
console.log(`Dialog script saved as ${filename}`);
|
||||
}
|
||||
|
||||
function loadDialogScript(file) {
|
||||
if (!file) {
|
||||
alert('Please select a file to load.');
|
||||
return;
|
||||
}
|
||||
|
||||
const reader = new FileReader();
|
||||
reader.onload = function(e) {
|
||||
try {
|
||||
const content = e.target.result;
|
||||
const lines = content.trim().split('\n');
|
||||
const loadedItems = [];
|
||||
|
||||
for (let i = 0; i < lines.length; i++) {
|
||||
const line = lines[i].trim();
|
||||
if (!line) continue; // Skip empty lines
|
||||
|
||||
try {
|
||||
const item = JSON.parse(line);
|
||||
const validatedItem = validateDialogItem(item, i + 1);
|
||||
if (validatedItem) {
|
||||
loadedItems.push(normalizeDialogItem(validatedItem));
|
||||
}
|
||||
} catch (parseError) {
|
||||
console.error(`Error parsing line ${i + 1}:`, parseError);
|
||||
alert(`Error parsing line ${i + 1}: ${parseError.message}`);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
if (loadedItems.length === 0) {
|
||||
alert('No valid dialog items found in the file.');
|
||||
return;
|
||||
}
|
||||
|
||||
// Confirm replacement if existing items
|
||||
if (dialogItems.length > 0) {
|
||||
const confirmed = confirm(
|
||||
`This will replace your current dialog (${dialogItems.length} items) with the loaded script (${loadedItems.length} items). Continue?`
|
||||
);
|
||||
if (!confirmed) return;
|
||||
}
|
||||
|
||||
// Replace current dialog
|
||||
dialogItems.splice(0, dialogItems.length, ...loadedItems);
|
||||
renderDialogItems();
|
||||
|
||||
console.log(`Loaded ${loadedItems.length} dialog items from script`);
|
||||
alert(`Successfully loaded ${loadedItems.length} dialog items.`);
|
||||
|
||||
} catch (error) {
|
||||
console.error('Error loading dialog script:', error);
|
||||
alert(`Error loading dialog script: ${error.message}`);
|
||||
}
|
||||
};
|
||||
|
||||
reader.onerror = function() {
|
||||
alert('Error reading file. Please try again.');
|
||||
};
|
||||
|
||||
reader.readAsText(file);
|
||||
}
|
||||
|
||||
function validateDialogItem(item, lineNumber) {
|
||||
if (!item || typeof item !== 'object') {
|
||||
throw new Error(`Line ${lineNumber}: Invalid item format`);
|
||||
}
|
||||
|
||||
if (!item.type || !['speech', 'silence'].includes(item.type)) {
|
||||
throw new Error(`Line ${lineNumber}: Invalid or missing type. Must be 'speech' or 'silence'`);
|
||||
}
|
||||
|
||||
if (item.type === 'speech') {
|
||||
if (!item.speaker_id || typeof item.speaker_id !== 'string') {
|
||||
throw new Error(`Line ${lineNumber}: Speech items must have a valid speaker_id`);
|
||||
}
|
||||
if (!item.text || typeof item.text !== 'string') {
|
||||
throw new Error(`Line ${lineNumber}: Speech items must have text`);
|
||||
}
|
||||
|
||||
// Check if speaker exists in available speakers
|
||||
const speakerExists = availableSpeakersCache.some(speaker => speaker.id === item.speaker_id);
|
||||
if (availableSpeakersCache.length > 0 && !speakerExists) {
|
||||
console.warn(`Line ${lineNumber}: Speaker '${item.speaker_id}' not found in available speakers`);
|
||||
// Don't throw error, just warn - speaker might be added later
|
||||
}
|
||||
|
||||
return {
|
||||
type: 'speech',
|
||||
speaker_id: item.speaker_id,
|
||||
text: item.text
|
||||
};
|
||||
} else if (item.type === 'silence') {
|
||||
if (typeof item.duration !== 'number' || item.duration <= 0) {
|
||||
throw new Error(`Line ${lineNumber}: Silence items must have a positive duration number`);
|
||||
}
|
||||
|
||||
return {
|
||||
type: 'silence',
|
||||
duration: item.duration
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// Event handlers for save/load
|
||||
if (saveScriptBtn) {
|
||||
saveScriptBtn.addEventListener('click', saveDialogScript);
|
||||
}
|
||||
|
||||
if (loadScriptBtn && loadScriptInput) {
|
||||
loadScriptBtn.addEventListener('click', () => {
|
||||
loadScriptInput.click();
|
||||
});
|
||||
|
||||
loadScriptInput.addEventListener('change', (e) => {
|
||||
const file = e.target.files[0];
|
||||
if (file) {
|
||||
loadDialogScript(file);
|
||||
// Reset input so same file can be loaded again
|
||||
e.target.value = '';
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
console.log('Dialog Editor Initialized');
|
||||
renderDialogItems(); // Initial render (empty)
|
||||
}
|
||||
|
|
Loading…
Reference in New Issue