Compare commits

..

No commits in common. "41f95cdee338c834e7617d3ebd6793e328a48d05" and "aeb0f7b63881abd333129ce5811454ae9c0655ce" have entirely different histories.

14 changed files with 97 additions and 430 deletions

View File

@ -1,138 +0,0 @@
# Frontend Review and Recommendations
Date: 2025-08-12T11:32:16-05:00
Scope: `frontend/` of `chatterbox-test` monorepo
---
## Summary
- Static vanilla JS frontend served by `frontend/start_dev_server.py` interacting with FastAPI backend under `/api`.
- Solid feature set (speaker management, dialog editor, per-line generation, full dialog generation, save/load) with robust error handling.
- Key issues: inconsistent API trailing slashes, Jest/babel-jest version/config mismatch, minor state duplication, alert/confirm UX, overly dark border color, token in `package.json` repo URL.
---
## Findings
- **Framework/structure**
- `frontend/` is static vanilla JS. Main files:
- `index.html`, `js/app.js`, `js/api.js`, `js/config.js`, `css/style.css`.
- Dev server: `frontend/start_dev_server.py` (CORS, env-based port/host).
- **API client vs backend routes (trailing slashes)**
- Frontend `frontend/js/api.js` currently uses:
- `getSpeakers()`: `${API_BASE_URL}/speakers/` (trailing).
- `addSpeaker()`: `${API_BASE_URL}/speakers/` (trailing).
- `deleteSpeaker()`: `${API_BASE_URL}/speakers/${speakerId}/` (trailing).
- `generateLine()`: `${API_BASE_URL}/dialog/generate_line`.
- `generateDialog()`: `${API_BASE_URL}/dialog/generate`.
- Backend routes:
- `backend/app/routers/speakers.py`: `GET/POST /` and `DELETE /{speaker_id}` (no trailing slash on delete when prefixed under `/api/speakers`).
- `backend/app/routers/dialog.py`: `/generate_line` and `/generate` (match frontend).
- Tests in `frontend/tests/api.test.js` expect no trailing slashes for `/speakers` and `/speakers/{id}`.
- Implication: Inconsistent trailing slashes can cause test failures and possible 404s for delete.
- **Payload schema inconsistencies**
- `generateDialog()` JSDoc shows `silence` as `{ duration_ms: 500 }` but backend expects `duration` (seconds). UI also uses `duration` seconds.
- **Form fields alignment**
- Speaker add uses `name` and `audio_file` which match backend (`Form` and `File`).
- **State management duplication in `frontend/js/app.js`**
- `dialogItems` and `availableSpeakersCache` defined at module scope and again inside `initializeDialogEditor()`, creating shadowing risk. Consolidate to a single source of truth.
- **UX considerations**
- Heavy use of `alert()`/`confirm()`. Prefer inline notifications/banners and per-row error chips (you already render `item.error`).
- Add global loading/disabled states for long actions (e.g., full dialog generation, speaker add/delete).
- **CSS theme issue**
- `--border-light` is `#1b0404` (dark red); semantically a light gray fits better and improves contrast harmony.
- **Testing/Jest/Babel config**
- Root `package.json` uses `jest@^29.7.0` with `babel-jest@^30.0.0-beta.3` (major mismatch). Align versions.
- No `jest.config.cjs` to configure `transform` via `babel-jest` for ESM modules.
- **Security**
- `package.json` `repository.url` embeds a token. Remove secrets from VCS immediately.
- **Dev scripts**
- Only `"test": "jest"` present. Add scripts to run the frontend dev server and test config explicitly.
- **Response handling consistency**
- `generateLine()` parses via `response.text()` then `JSON.parse()`. Others use `response.json()`. Standardize for consistency.
---
## Recommended Actions (Phase 1: Quick wins)
- **Normalize API paths in `frontend/js/api.js`**
- Use no trailing slashes:
- `GET/POST`: `${API_BASE_URL}/speakers`
- `DELETE`: `${API_BASE_URL}/speakers/${speakerId}`
- Keep dialog endpoints unchanged.
- **Fix JSDoc for `generateDialog()`**
- Use `silence: { duration: number }` (seconds), not `duration_ms`.
- **Refactor `frontend/js/app.js` state**
- Remove duplicate `dialogItems`/`availableSpeakersCache` declarations. Choose module-scope or function-scope, and pass references.
- **Improve UX**
- Replace `alert/confirm` with inline banners near `#results-display` and per-row error chips (extend existing `.line-error-msg`).
- Add disabled/loading states for global generate and speaker actions.
- **CSS tweak**
- Set `--border-light: #e5e7eb;` (or similar) to reflect a light border.
- **Harden tests/Jest config**
- Align versions: either Jest 29 + `babel-jest` 29, or upgrade both to 30 stable together.
- Add `jest.config.cjs` with `transform` using `babel-jest` and suitable `testEnvironment`.
- Ensure tests expect normalized API paths (recommended to change code to match tests).
- **Dev scripts**
- Add to root `package.json`:
- `"frontend:dev": "python3 frontend/start_dev_server.py"`
- `"test:frontend": "jest --config ./jest.config.cjs"`
- **Sanitize repository URL**
- Remove embedded token from `package.json`.
- **Standardize response parsing**
- Switch `generateLine()` to `response.json()` unless backend returns `text/plain`.
---
## Backend Endpoint Confirmation
- `speakers` router (`backend/app/routers/speakers.py`):
- List/Create: `GET /`, `POST /` (when mounted under `/api/speakers``/api/speakers/`).
- Delete: `DELETE /{speaker_id}` (→ `/api/speakers/{speaker_id}`), no trailing slash.
- `dialog` router (`backend/app/routers/dialog.py`):
- `POST /generate_line`, `POST /generate` (mounted under `/api/dialog`).
---
## Proposed Implementation Plan
- **Phase 1 (12 hours)**
- Normalize API paths in `api.js`.
- Fix JSDoc for `generateDialog`.
- Consolidate dialog state in `app.js`.
- Adjust `--border-light` to light gray.
- Add `jest.config.cjs`, align Jest/babel-jest versions.
- Add dev/test scripts.
- Remove token from `package.json`.
- **Phase 2 (24 hours)**
- Inline notifications and comprehensive loading/disabled states.
- **Phase 3 (optional)**
- ESLint + Prettier.
- Consider Vite migration (HMR, proxy to backend, improved DX).
---
## Notes
- Current local time captured for this review: 2025-08-12T11:32:16-05:00.
- Frontend config (`frontend/js/config.js`) supports env overrides for API base and dev server port.
- Tests (`frontend/tests/api.test.js`) currently assume endpoints without trailing slashes.

View File

@ -359,7 +359,7 @@ The API uses the following directory structure (configurable in `app/config.py`)
- **Temporary Files**: `{PROJECT_ROOT}/tts_temp_outputs/` - **Temporary Files**: `{PROJECT_ROOT}/tts_temp_outputs/`
### CORS Settings ### CORS Settings
- Allowed Origins: `http://localhost:8001`, `http://127.0.0.1:8001` (plus any `FRONTEND_HOST:FRONTEND_PORT` when using `start_servers.py`) - Allowed Origins: `http://localhost:8001`, `http://127.0.0.1:8001`
- Allowed Methods: All - Allowed Methods: All
- Allowed Headers: All - Allowed Headers: All
- Credentials: Enabled - Credentials: Enabled

View File

@ -58,7 +58,7 @@ The application uses environment variables for configuration. Three `.env` files
- `VITE_DEV_SERVER_HOST`: Frontend development server host - `VITE_DEV_SERVER_HOST`: Frontend development server host
#### CORS Configuration #### CORS Configuration
- `CORS_ORIGINS`: Comma-separated list of allowed origins. When using `start_servers.py` with the default `FRONTEND_HOST=0.0.0.0` and no explicit `CORS_ORIGINS`, CORS will allow all origins (wildcard) to simplify development. - `CORS_ORIGINS`: Comma-separated list of allowed origins
#### Device Configuration #### Device Configuration
- `DEVICE`: Device for TTS model (auto, cpu, cuda, mps) - `DEVICE`: Device for TTS model (auto, cpu, cuda, mps)
@ -101,7 +101,7 @@ CORS_ORIGINS=http://localhost:3000
### Common Issues ### Common Issues
1. **Permission Errors**: Ensure the `PROJECT_ROOT` directory is writable 1. **Permission Errors**: Ensure the `PROJECT_ROOT` directory is writable
2. **CORS Errors**: Check that your frontend URL is in `CORS_ORIGINS`. (When using `start_servers.py`, your specified `FRONTEND_HOST:FRONTEND_PORT` will be autoincluded.) 2. **CORS Errors**: Check that your frontend URL is in `CORS_ORIGINS`
3. **Model Loading Errors**: Verify `DEVICE` setting matches your hardware 3. **Model Loading Errors**: Verify `DEVICE` setting matches your hardware
4. **Path Errors**: Ensure all path variables point to existing, accessible directories 4. **Path Errors**: Ensure all path variables point to existing, accessible directories

View File

@ -149,5 +149,5 @@ The application automatically:
- **"Skipping unknown speaker"**: Configure speaker in `speaker_data/speakers.yaml` - **"Skipping unknown speaker"**: Configure speaker in `speaker_data/speakers.yaml`
- **"Sample file not found"**: Verify audio files exist in `speaker_data/speaker_samples/` - **"Sample file not found"**: Verify audio files exist in `speaker_data/speaker_samples/`
- **Memory issues**: Use model reinitialization options for long content - **Memory issues**: Use model reinitialization options for long content
- **CORS errors**: Check frontend/backend port configuration (frontend origin is auto-included when using `start_servers.py`) - **CORS errors**: Check frontend/backend port configuration
- **Import errors**: Run `python import_helper.py` to check dependencies - **Import errors**: Run `python import_helper.py` to check dependencies

View File

@ -6,34 +6,20 @@ from dotenv import load_dotenv
load_dotenv() load_dotenv()
# Project root - can be overridden by environment variable # Project root - can be overridden by environment variable
PROJECT_ROOT = Path( PROJECT_ROOT = Path(os.getenv("PROJECT_ROOT", Path(__file__).parent.parent.parent)).resolve()
os.getenv("PROJECT_ROOT", Path(__file__).parent.parent.parent)
).resolve()
# Directory paths # Directory paths
SPEAKER_DATA_BASE_DIR = Path( SPEAKER_DATA_BASE_DIR = Path(os.getenv("SPEAKER_DATA_BASE_DIR", str(PROJECT_ROOT / "speaker_data")))
os.getenv("SPEAKER_DATA_BASE_DIR", str(PROJECT_ROOT / "speaker_data")) SPEAKER_SAMPLES_DIR = Path(os.getenv("SPEAKER_SAMPLES_DIR", str(SPEAKER_DATA_BASE_DIR / "speaker_samples")))
) SPEAKERS_YAML_FILE = Path(os.getenv("SPEAKERS_YAML_FILE", str(SPEAKER_DATA_BASE_DIR / "speakers.yaml")))
SPEAKER_SAMPLES_DIR = Path(
os.getenv("SPEAKER_SAMPLES_DIR", str(SPEAKER_DATA_BASE_DIR / "speaker_samples"))
)
SPEAKERS_YAML_FILE = Path(
os.getenv("SPEAKERS_YAML_FILE", str(SPEAKER_DATA_BASE_DIR / "speakers.yaml"))
)
# TTS temporary output path (used by DialogProcessorService) # TTS temporary output path (used by DialogProcessorService)
TTS_TEMP_OUTPUT_DIR = Path( TTS_TEMP_OUTPUT_DIR = Path(os.getenv("TTS_TEMP_OUTPUT_DIR", str(PROJECT_ROOT / "tts_temp_outputs")))
os.getenv("TTS_TEMP_OUTPUT_DIR", str(PROJECT_ROOT / "tts_temp_outputs"))
)
# Final dialog output path (used by Dialog router and served by main app) # Final dialog output path (used by Dialog router and served by main app)
# These are stored within the 'backend' directory to be easily servable. # These are stored within the 'backend' directory to be easily servable.
DIALOG_OUTPUT_PARENT_DIR = PROJECT_ROOT / "backend" DIALOG_OUTPUT_PARENT_DIR = PROJECT_ROOT / "backend"
DIALOG_GENERATED_DIR = Path( DIALOG_GENERATED_DIR = Path(os.getenv("DIALOG_GENERATED_DIR", str(DIALOG_OUTPUT_PARENT_DIR / "tts_generated_dialogs")))
os.getenv(
"DIALOG_GENERATED_DIR", str(DIALOG_OUTPUT_PARENT_DIR / "tts_generated_dialogs")
)
)
# Alias for clarity and backward compatibility # Alias for clarity and backward compatibility
DIALOG_OUTPUT_DIR = DIALOG_GENERATED_DIR DIALOG_OUTPUT_DIR = DIALOG_GENERATED_DIR
@ -43,26 +29,8 @@ HOST = os.getenv("HOST", "0.0.0.0")
PORT = int(os.getenv("PORT", "8000")) PORT = int(os.getenv("PORT", "8000"))
RELOAD = os.getenv("RELOAD", "true").lower() == "true" RELOAD = os.getenv("RELOAD", "true").lower() == "true"
# CORS configuration: determine allowed origins based on env & frontend binding # CORS configuration
_cors_env = os.getenv("CORS_ORIGINS", "") CORS_ORIGINS = [origin.strip() for origin in os.getenv("CORS_ORIGINS", "http://localhost:8001,http://127.0.0.1:8001").split(",")]
_frontend_host = os.getenv("FRONTEND_HOST")
_frontend_port = os.getenv("FRONTEND_PORT")
# If the dev server is bound to 0.0.0.0 (all interfaces), allow all origins
if _frontend_host == "0.0.0.0": # dev convenience when binding wildcard
CORS_ORIGINS = ["*"]
elif _cors_env:
# parse comma-separated origins, strip whitespace
CORS_ORIGINS = [origin.strip() for origin in _cors_env.split(",") if origin.strip()]
else:
# default to allow all origins in development
CORS_ORIGINS = ["*"]
# Auto-include specific frontend origin when not using wildcard CORS
if CORS_ORIGINS != ["*"] and _frontend_host and _frontend_port:
_frontend_origin = f"http://{_frontend_host.strip()}:{_frontend_port.strip()}"
if _frontend_origin not in CORS_ORIGINS:
CORS_ORIGINS.append(_frontend_origin)
# Device configuration # Device configuration
DEVICE = os.getenv("DEVICE", "auto") DEVICE = os.getenv("DEVICE", "auto")

View File

@ -24,7 +24,7 @@
--text-blue-darker: #205081; --text-blue-darker: #205081;
/* Border Colors */ /* Border Colors */
--border-light: #e5e7eb; --border-light: #1b0404;
--border-medium: #cfd8dc; --border-medium: #cfd8dc;
--border-blue: #b5c6df; --border-blue: #b5c6df;
--border-gray: #e3e3e3; --border-gray: #e3e3e3;
@ -449,72 +449,6 @@ footer {
border-top: 3px solid var(--primary-blue); border-top: 3px solid var(--primary-blue);
} }
/* Inline Notification */
.notice {
max-width: 1100px;
margin: 16px auto 0;
padding: 12px 16px;
border-radius: 6px;
border: 1px solid var(--border-medium);
background: var(--bg-white);
color: var(--text-primary);
display: flex;
align-items: center;
gap: 12px;
box-shadow: 0 1px 2px var(--shadow-light);
}
.notice--info {
border-color: var(--border-blue);
background: var(--bg-blue-light);
}
.notice--success {
border-color: #A7F3D0;
background: #ECFDF5;
}
.notice--warning {
border-color: var(--warning-border);
background: var(--warning-bg);
}
.notice--error {
border-color: var(--error-bg-dark);
background: #FEE2E2;
}
.notice__content {
flex: 1;
}
.notice__actions {
display: flex;
gap: 8px;
}
.notice__actions button {
padding: 6px 12px;
border-radius: 4px;
border: 1px solid var(--border-medium);
background: var(--bg-white);
cursor: pointer;
}
.notice__actions .btn-primary {
background: var(--primary-blue);
color: var(--text-white);
border: none;
}
.notice__close {
background: none;
border: none;
font-size: 18px;
cursor: pointer;
color: var(--text-secondary);
}
@media (max-width: 900px) { @media (max-width: 900px) {
.panel-grid { .panel-grid {
flex-direction: column; flex-direction: column;

View File

@ -13,13 +13,6 @@
</div> </div>
</header> </header>
<!-- Global inline notification area -->
<div id="global-notice" class="notice" role="status" aria-live="polite" style="display:none;">
<div class="notice__content" id="global-notice-content"></div>
<div class="notice__actions" id="global-notice-actions"></div>
<button class="notice__close" id="global-notice-close" aria-label="Close notification">&times;</button>
</div>
<main class="container" role="main"> <main class="container" role="main">
<div class="panel-grid"> <div class="panel-grid">
<section id="dialog-editor" class="panel full-width-panel" aria-labelledby="dialog-editor-title"> <section id="dialog-editor" class="panel full-width-panel" aria-labelledby="dialog-editor-title">

View File

@ -10,7 +10,7 @@ const API_BASE_URL = API_BASE_URL_WITH_PREFIX;
* @throws {Error} If the network response is not ok. * @throws {Error} If the network response is not ok.
*/ */
export async function getSpeakers() { export async function getSpeakers() {
const response = await fetch(`${API_BASE_URL}/speakers`); const response = await fetch(`${API_BASE_URL}/speakers/`);
if (!response.ok) { if (!response.ok) {
const errorData = await response.json().catch(() => ({ message: response.statusText })); const errorData = await response.json().catch(() => ({ message: response.statusText }));
throw new Error(`Failed to fetch speakers: ${errorData.detail || errorData.message || response.statusText}`); throw new Error(`Failed to fetch speakers: ${errorData.detail || errorData.message || response.statusText}`);
@ -26,12 +26,12 @@ export async function getSpeakers() {
* Adds a new speaker. * Adds a new speaker.
* @param {FormData} formData - The form data containing speaker name and audio file. * @param {FormData} formData - The form data containing speaker name and audio file.
* Example: formData.append('name', 'New Speaker'); * Example: formData.append('name', 'New Speaker');
* formData.append('audio_file', fileInput.files[0]); * formData.append('audio_sample_file', fileInput.files[0]);
* @returns {Promise<Object>} A promise that resolves to the new speaker object. * @returns {Promise<Object>} A promise that resolves to the new speaker object.
* @throws {Error} If the network response is not ok. * @throws {Error} If the network response is not ok.
*/ */
export async function addSpeaker(formData) { export async function addSpeaker(formData) {
const response = await fetch(`${API_BASE_URL}/speakers`, { const response = await fetch(`${API_BASE_URL}/speakers/`, {
method: 'POST', method: 'POST',
body: formData, // FormData sets Content-Type to multipart/form-data automatically body: formData, // FormData sets Content-Type to multipart/form-data automatically
}); });
@ -86,7 +86,7 @@ export async function addSpeaker(formData) {
* @throws {Error} If the network response is not ok. * @throws {Error} If the network response is not ok.
*/ */
export async function deleteSpeaker(speakerId) { export async function deleteSpeaker(speakerId) {
const response = await fetch(`${API_BASE_URL}/speakers/${speakerId}`, { const response = await fetch(`${API_BASE_URL}/speakers/${speakerId}/`, {
method: 'DELETE', method: 'DELETE',
}); });
if (!response.ok) { if (!response.ok) {
@ -124,8 +124,18 @@ export async function generateLine(line) {
const errorData = await response.json().catch(() => ({ message: response.statusText })); const errorData = await response.json().catch(() => ({ message: response.statusText }));
throw new Error(`Failed to generate line audio: ${errorData.detail || errorData.message || response.statusText}`); throw new Error(`Failed to generate line audio: ${errorData.detail || errorData.message || response.statusText}`);
} }
const data = await response.json();
return data; const responseText = await response.text();
console.log('Raw response text:', responseText);
try {
const jsonData = JSON.parse(responseText);
console.log('Parsed JSON:', jsonData);
return jsonData;
} catch (parseError) {
console.error('JSON parse error:', parseError);
throw new Error(`Invalid JSON response: ${responseText}`);
}
} }
/** /**
@ -136,7 +146,7 @@ export async function generateLine(line) {
* output_base_name: "my_dialog", * output_base_name: "my_dialog",
* dialog_items: [ * dialog_items: [
* { type: "speech", speaker_id: "speaker1", text: "Hello world.", exaggeration: 1.0, cfg_weight: 2.0, temperature: 0.7 }, * { type: "speech", speaker_id: "speaker1", text: "Hello world.", exaggeration: 1.0, cfg_weight: 2.0, temperature: 0.7 },
* { type: "silence", duration: 0.5 }, * { type: "silence", duration_ms: 500 },
* { type: "speech", speaker_id: "speaker2", text: "How are you?" } * { type: "speech", speaker_id: "speaker2", text: "How are you?" }
* ] * ]
* } * }

View File

@ -1,64 +1,6 @@
import { getSpeakers, addSpeaker, deleteSpeaker, generateDialog } from './api.js'; import { getSpeakers, addSpeaker, deleteSpeaker, generateDialog } from './api.js';
import { API_BASE_URL, API_BASE_URL_FOR_FILES } from './config.js'; import { API_BASE_URL, API_BASE_URL_FOR_FILES } from './config.js';
// --- Global Inline Notification Helpers --- //
const noticeEl = document.getElementById('global-notice');
const noticeContentEl = document.getElementById('global-notice-content');
const noticeActionsEl = document.getElementById('global-notice-actions');
const noticeCloseBtn = document.getElementById('global-notice-close');
function hideNotice() {
if (!noticeEl) return;
noticeEl.style.display = 'none';
noticeEl.className = 'notice';
if (noticeContentEl) noticeContentEl.textContent = '';
if (noticeActionsEl) noticeActionsEl.innerHTML = '';
}
function showNotice(message, type = 'info', options = {}) {
if (!noticeEl || !noticeContentEl || !noticeActionsEl) {
console[type === 'error' ? 'error' : 'log']('[NOTICE]', message);
return () => {};
}
const { timeout = null, actions = [] } = options;
noticeEl.className = `notice notice--${type}`;
noticeContentEl.textContent = message;
noticeActionsEl.innerHTML = '';
actions.forEach(({ text, primary = false, onClick }) => {
const btn = document.createElement('button');
btn.textContent = text;
if (primary) btn.classList.add('btn-primary');
btn.onclick = () => {
try { onClick && onClick(); } finally { hideNotice(); }
};
noticeActionsEl.appendChild(btn);
});
if (noticeCloseBtn) noticeCloseBtn.onclick = hideNotice;
noticeEl.style.display = 'flex';
let timerId = null;
if (timeout && Number.isFinite(timeout)) {
timerId = window.setTimeout(hideNotice, timeout);
}
return () => {
if (timerId) window.clearTimeout(timerId);
hideNotice();
};
}
function confirmAction(message) {
return new Promise((resolve) => {
showNotice(message, 'warning', {
actions: [
{ text: 'Cancel', primary: false, onClick: () => resolve(false) },
{ text: 'Confirm', primary: true, onClick: () => resolve(true) },
],
});
});
}
document.addEventListener('DOMContentLoaded', async () => { document.addEventListener('DOMContentLoaded', async () => {
console.log('DOM fully loaded and parsed'); console.log('DOM fully loaded and parsed');
initializeSpeakerManagement(); initializeSpeakerManagement();
@ -81,24 +23,18 @@ function initializeSpeakerManagement() {
const audioFile = formData.get('audio_file'); const audioFile = formData.get('audio_file');
if (!speakerName || !audioFile || audioFile.size === 0) { if (!speakerName || !audioFile || audioFile.size === 0) {
showNotice('Please provide a speaker name and an audio file.', 'warning', { timeout: 4000 }); alert('Please provide a speaker name and an audio file.');
return; return;
} }
try { try {
const submitBtn = addSpeakerForm.querySelector('button[type="submit"]');
const prevText = submitBtn ? submitBtn.textContent : null;
if (submitBtn) { submitBtn.disabled = true; submitBtn.textContent = 'Adding…'; }
const newSpeaker = await addSpeaker(formData); const newSpeaker = await addSpeaker(formData);
showNotice(`Speaker added: ${newSpeaker.name} (ID: ${newSpeaker.id})`, 'success', { timeout: 3000 }); alert(`Speaker added: ${newSpeaker.name} (ID: ${newSpeaker.id})`);
addSpeakerForm.reset(); addSpeakerForm.reset();
loadSpeakers(); // Refresh speaker list loadSpeakers(); // Refresh speaker list
} catch (error) { } catch (error) {
console.error('Failed to add speaker:', error); console.error('Failed to add speaker:', error);
showNotice('Error adding speaker: ' + error.message, 'error'); alert('Error adding speaker: ' + error.message);
} finally {
const submitBtn = addSpeakerForm.querySelector('button[type="submit"]');
if (submitBtn) { submitBtn.disabled = false; submitBtn.textContent = 'Add Speaker'; }
} }
}); });
} }
@ -143,24 +79,23 @@ async function loadSpeakers() {
} catch (error) { } catch (error) {
console.error('Failed to load speakers:', error); console.error('Failed to load speakers:', error);
speakerListUL.innerHTML = '<li>Error loading speakers. See console for details.</li>'; speakerListUL.innerHTML = '<li>Error loading speakers. See console for details.</li>';
showNotice('Error loading speakers: ' + error.message, 'error'); alert('Error loading speakers: ' + error.message);
} }
} }
async function handleDeleteSpeaker(speakerId) { async function handleDeleteSpeaker(speakerId) {
if (!speakerId) { if (!speakerId) {
showNotice('Cannot delete speaker: Speaker ID is missing.', 'warning', { timeout: 4000 }); alert('Cannot delete speaker: Speaker ID is missing.');
return; return;
} }
const ok = await confirmAction(`Are you sure you want to delete speaker ${speakerId}?`); if (!confirm(`Are you sure you want to delete speaker ${speakerId}?`)) return;
if (!ok) return;
try { try {
await deleteSpeaker(speakerId); await deleteSpeaker(speakerId);
showNotice(`Speaker ${speakerId} deleted successfully.`, 'success', { timeout: 3000 }); alert(`Speaker ${speakerId} deleted successfully.`);
loadSpeakers(); // Refresh speaker list loadSpeakers(); // Refresh speaker list
} catch (error) { } catch (error) {
console.error(`Failed to delete speaker ${speakerId}:`, error); console.error(`Failed to delete speaker ${speakerId}:`, error);
showNotice(`Error deleting speaker: ${error.message}`, 'error'); alert(`Error deleting speaker: ${error.message}`);
} }
} }
@ -205,6 +140,9 @@ async function initializeDialogEditor() {
const zipArchivePlaceholder = document.getElementById('zip-archive-placeholder'); const zipArchivePlaceholder = document.getElementById('zip-archive-placeholder');
const resultsDisplaySection = document.getElementById('results-display'); const resultsDisplaySection = document.getElementById('results-display');
let dialogItems = [];
let availableSpeakersCache = []; // Cache for speaker names and IDs
// Load speakers at startup // Load speakers at startup
try { try {
availableSpeakersCache = await getSpeakers(); availableSpeakersCache = await getSpeakers();
@ -383,7 +321,7 @@ async function initializeDialogEditor() {
} catch (err) { } catch (err) {
console.error('Error in generateLine:', err); console.error('Error in generateLine:', err);
dialogItems[index].error = err.message || 'Failed to generate audio.'; dialogItems[index].error = err.message || 'Failed to generate audio.';
showNotice(dialogItems[index].error, 'error'); alert(dialogItems[index].error);
} finally { } finally {
dialogItems[index].isGenerating = false; dialogItems[index].isGenerating = false;
renderDialogItems(); renderDialogItems();
@ -445,13 +383,13 @@ async function initializeDialogEditor() {
try { try {
availableSpeakersCache = await getSpeakers(); availableSpeakersCache = await getSpeakers();
} catch (error) { } catch (error) {
showNotice('Could not load speakers. Please try again.', 'error'); alert('Could not load speakers. Please try again.');
console.error('Error fetching speakers for dialog:', error); console.error('Error fetching speakers for dialog:', error);
return; return;
} }
} }
if (availableSpeakersCache.length === 0) { if (availableSpeakersCache.length === 0) {
showNotice('No speakers available. Please add a speaker first.', 'warning', { timeout: 4000 }); alert('No speakers available. Please add a speaker first.');
return; return;
} }
@ -481,7 +419,7 @@ async function initializeDialogEditor() {
const speakerId = speakerSelect.value; const speakerId = speakerSelect.value;
const text = textInput.value.trim(); const text = textInput.value.trim();
if (!speakerId || !text) { if (!speakerId || !text) {
showNotice('Please select a speaker and enter text.', 'warning', { timeout: 4000 }); alert('Please select a speaker and enter text.');
return; return;
} }
dialogItems.push(normalizeDialogItem({ type: 'speech', speaker_id: speakerId, text: text })); dialogItems.push(normalizeDialogItem({ type: 'speech', speaker_id: speakerId, text: text }));
@ -523,7 +461,7 @@ async function initializeDialogEditor() {
addButton.onclick = () => { addButton.onclick = () => {
const duration = parseFloat(durationInput.value); const duration = parseFloat(durationInput.value);
if (isNaN(duration) || duration <= 0) { if (isNaN(duration) || duration <= 0) {
showNotice('Invalid duration. Please enter a positive number.', 'warning', { timeout: 4000 }); alert('Invalid duration. Please enter a positive number.');
return; return;
} }
dialogItems.push(normalizeDialogItem({ type: 'silence', duration: duration })); dialogItems.push(normalizeDialogItem({ type: 'silence', duration: duration }));
@ -548,18 +486,15 @@ async function initializeDialogEditor() {
generateDialogBtn.addEventListener('click', async () => { generateDialogBtn.addEventListener('click', async () => {
const outputBaseName = outputBaseNameInput.value.trim(); const outputBaseName = outputBaseNameInput.value.trim();
if (!outputBaseName) { if (!outputBaseName) {
showNotice('Please enter an output base name.', 'warning', { timeout: 4000 }); alert('Please enter an output base name.');
outputBaseNameInput.focus(); outputBaseNameInput.focus();
return; return;
} }
if (dialogItems.length === 0) { if (dialogItems.length === 0) {
showNotice('Please add at least one speech or silence line to the dialog.', 'warning', { timeout: 4000 }); 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
} }
const prevText = generateDialogBtn.textContent;
generateDialogBtn.disabled = true;
generateDialogBtn.textContent = 'Generating…';
// Smart dialog-wide generation: use pre-generated audio where present // Smart dialog-wide generation: use pre-generated audio where present
const dialogItemsToGenerate = dialogItems.map(item => { const dialogItemsToGenerate = dialogItems.map(item => {
// Only send minimal fields for items that need generation // Only send minimal fields for items that need generation
@ -611,11 +546,7 @@ async function initializeDialogEditor() {
} catch (error) { } catch (error) {
console.error('Dialog generation failed:', error); console.error('Dialog generation failed:', error);
if (generationLogPre) generationLogPre.textContent = `Error generating dialog: ${error.message}`; if (generationLogPre) generationLogPre.textContent = `Error generating dialog: ${error.message}`;
showNotice(`Error generating dialog: ${error.message}`, 'error'); alert(`Error generating dialog: ${error.message}`);
}
finally {
generateDialogBtn.disabled = false;
generateDialogBtn.textContent = prevText;
} }
}); });
} }
@ -623,7 +554,7 @@ async function initializeDialogEditor() {
// --- Save/Load Script Functionality --- // --- Save/Load Script Functionality ---
function saveDialogScript() { function saveDialogScript() {
if (dialogItems.length === 0) { if (dialogItems.length === 0) {
showNotice('No dialog items to save. Please add some speech or silence lines first.', 'warning', { timeout: 4000 }); alert('No dialog items to save. Please add some speech or silence lines first.');
return; return;
} }
@ -668,12 +599,11 @@ async function initializeDialogEditor() {
URL.revokeObjectURL(url); URL.revokeObjectURL(url);
console.log(`Dialog script saved as ${filename}`); console.log(`Dialog script saved as ${filename}`);
showNotice(`Dialog script saved as ${filename}`, 'success', { timeout: 3000 });
} }
function loadDialogScript(file) { function loadDialogScript(file) {
if (!file) { if (!file) {
showNotice('Please select a file to load.', 'warning', { timeout: 4000 }); alert('Please select a file to load.');
return; return;
} }
@ -696,19 +626,19 @@ async function initializeDialogEditor() {
} }
} catch (parseError) { } catch (parseError) {
console.error(`Error parsing line ${i + 1}:`, parseError); console.error(`Error parsing line ${i + 1}:`, parseError);
showNotice(`Error parsing line ${i + 1}: ${parseError.message}`, 'error'); alert(`Error parsing line ${i + 1}: ${parseError.message}`);
return; return;
} }
} }
if (loadedItems.length === 0) { if (loadedItems.length === 0) {
showNotice('No valid dialog items found in the file.', 'warning', { timeout: 4000 }); alert('No valid dialog items found in the file.');
return; return;
} }
// Confirm replacement if existing items // Confirm replacement if existing items
if (dialogItems.length > 0) { if (dialogItems.length > 0) {
const confirmed = await confirmAction( const confirmed = confirm(
`This will replace your current dialog (${dialogItems.length} items) with the loaded script (${loadedItems.length} items). Continue?` `This will replace your current dialog (${dialogItems.length} items) with the loaded script (${loadedItems.length} items). Continue?`
); );
if (!confirmed) return; if (!confirmed) return;
@ -720,7 +650,7 @@ async function initializeDialogEditor() {
availableSpeakersCache = await getSpeakers(); availableSpeakersCache = await getSpeakers();
} catch (error) { } catch (error) {
console.error('Error fetching speakers:', error); console.error('Error fetching speakers:', error);
showNotice('Could not load speakers. Dialog loaded but speaker names may not display correctly.', 'warning', { timeout: 5000 }); alert('Could not load speakers. Dialog loaded but speaker names may not display correctly.');
} }
} }
@ -729,16 +659,16 @@ async function initializeDialogEditor() {
renderDialogItems(); renderDialogItems();
console.log(`Loaded ${loadedItems.length} dialog items from script`); console.log(`Loaded ${loadedItems.length} dialog items from script`);
showNotice(`Successfully loaded ${loadedItems.length} dialog items.`, 'success', { timeout: 3000 }); alert(`Successfully loaded ${loadedItems.length} dialog items.`);
} catch (error) { } catch (error) {
console.error('Error loading dialog script:', error); console.error('Error loading dialog script:', error);
showNotice(`Error loading dialog script: ${error.message}`, 'error'); alert(`Error loading dialog script: ${error.message}`);
} }
}; };
reader.onerror = function() { reader.onerror = function() {
showNotice('Error reading file. Please try again.', 'error'); alert('Error reading file. Please try again.');
}; };
reader.readAsText(file); reader.readAsText(file);

View File

@ -13,15 +13,8 @@ const getEnvVar = (name, defaultValue) => {
}; };
// API Configuration // API Configuration
// Default to the same hostname as the frontend, on port 8000 (override via VITE_API_BASE_URL*) export const API_BASE_URL = getEnvVar('VITE_API_BASE_URL', 'http://localhost:8000');
const _defaultHost = (typeof window !== 'undefined' && window.location?.hostname) || 'localhost'; export const API_BASE_URL_WITH_PREFIX = getEnvVar('VITE_API_BASE_URL_WITH_PREFIX', 'http://localhost:8000/api');
const _defaultPort = getEnvVar('VITE_API_BASE_URL_PORT', '8000');
const _defaultBase = `http://${_defaultHost}:${_defaultPort}`;
export const API_BASE_URL = getEnvVar('VITE_API_BASE_URL', _defaultBase);
export const API_BASE_URL_WITH_PREFIX = getEnvVar(
'VITE_API_BASE_URL_WITH_PREFIX',
`${_defaultBase}/api`
);
// For file serving (same as API_BASE_URL since files are served from the same server) // For file serving (same as API_BASE_URL since files are served from the same server)
export const API_BASE_URL_FOR_FILES = API_BASE_URL; export const API_BASE_URL_FOR_FILES = API_BASE_URL;

View File

@ -1,9 +0,0 @@
// jest.config.cjs
module.exports = {
testEnvironment: 'node',
transform: {
'^.+\\.js$': 'babel-jest',
},
moduleFileExtensions: ['js', 'json'],
roots: ['<rootDir>/frontend/tests', '<rootDir>'],
};

View File

@ -5,13 +5,11 @@
"main": "index.js", "main": "index.js",
"type": "module", "type": "module",
"scripts": { "scripts": {
"test": "jest", "test": "jest"
"test:frontend": "jest --config ./jest.config.cjs",
"frontend:dev": "python3 frontend/start_dev_server.py"
}, },
"repository": { "repository": {
"type": "git", "type": "git",
"url": "https://gitea.r8z.us/stwhite/chatterbox-ui.git" "url": "https://oauth2:78f77aaebb8fa1cd3efbd5b738177c127f7d7d0b@gitea.r8z.us/stwhite/chatterbox-ui.git"
}, },
"keywords": [], "keywords": [],
"author": "", "author": "",
@ -19,7 +17,7 @@
"devDependencies": { "devDependencies": {
"@babel/core": "^7.27.4", "@babel/core": "^7.27.4",
"@babel/preset-env": "^7.27.2", "@babel/preset-env": "^7.27.2",
"babel-jest": "^29.7.0", "babel-jest": "^30.0.0-beta.3",
"jest": "^29.7.0" "jest": "^29.7.0"
} }
} }

View File

@ -28,6 +28,3 @@ dd3552d9-f4e8-49ed-9892-f9e67afcf23c:
2cdd6d3d-c533-44bf-a5f6-cc83bd089d32: 2cdd6d3d-c533-44bf-a5f6-cc83bd089d32:
name: Grace name: Grace
sample_path: speaker_samples/2cdd6d3d-c533-44bf-a5f6-cc83bd089d32.wav sample_path: speaker_samples/2cdd6d3d-c533-44bf-a5f6-cc83bd089d32.wav
3d3e85db-3d67-4488-94b2-ffc189fbb287:
name: RCB
sample_path: speaker_samples/3d3e85db-3d67-4488-94b2-ffc189fbb287.wav

View File

@ -14,109 +14,101 @@ from pathlib import Path
# Try to load environment variables, but don't fail if dotenv is not available # Try to load environment variables, but don't fail if dotenv is not available
try: try:
from dotenv import load_dotenv from dotenv import load_dotenv
load_dotenv() load_dotenv()
except ImportError: except ImportError:
print("python-dotenv not installed, using system environment variables only") print("python-dotenv not installed, using system environment variables only")
# Configuration # Configuration
BACKEND_PORT = int(os.getenv("BACKEND_PORT", "8000")) BACKEND_PORT = int(os.getenv('BACKEND_PORT', '8000'))
BACKEND_HOST = os.getenv("BACKEND_HOST", "0.0.0.0") BACKEND_HOST = os.getenv('BACKEND_HOST', '0.0.0.0')
# Frontend host/port (for dev server binding) FRONTEND_PORT = int(os.getenv('FRONTEND_PORT', '8001'))
FRONTEND_PORT = int(os.getenv("FRONTEND_PORT", "8001")) FRONTEND_HOST = os.getenv('FRONTEND_HOST', '127.0.0.1')
FRONTEND_HOST = os.getenv("FRONTEND_HOST", "0.0.0.0")
# Export frontend host/port so backend CORS config can pick them up automatically
os.environ["FRONTEND_HOST"] = FRONTEND_HOST
os.environ["FRONTEND_PORT"] = str(FRONTEND_PORT)
# Get project root directory # Get project root directory
PROJECT_ROOT = Path(__file__).parent.absolute() PROJECT_ROOT = Path(__file__).parent.absolute()
def run_backend(): def run_backend():
"""Run the backend FastAPI server""" """Run the backend FastAPI server"""
os.chdir(PROJECT_ROOT / "backend") os.chdir(PROJECT_ROOT / "backend")
cmd = [ cmd = [
sys.executable, sys.executable, "-m", "uvicorn",
"-m", "app.main:app",
"uvicorn", "--reload",
"app.main:app", f"--host={BACKEND_HOST}",
"--reload", f"--port={BACKEND_PORT}"
f"--host={BACKEND_HOST}",
f"--port={BACKEND_PORT}",
] ]
print(f"\n{'='*50}") print(f"\n{'='*50}")
print(f"Starting Backend Server at http://{BACKEND_HOST}:{BACKEND_PORT}") print(f"Starting Backend Server at http://{BACKEND_HOST}:{BACKEND_PORT}")
print(f"API docs available at http://{BACKEND_HOST}:{BACKEND_PORT}/docs") print(f"API docs available at http://{BACKEND_HOST}:{BACKEND_PORT}/docs")
print(f"{'='*50}\n") print(f"{'='*50}\n")
return subprocess.Popen( return subprocess.Popen(
cmd, cmd,
stdout=subprocess.PIPE, stdout=subprocess.PIPE,
stderr=subprocess.STDOUT, stderr=subprocess.STDOUT,
universal_newlines=True, universal_newlines=True,
bufsize=1, bufsize=1
) )
def run_frontend(): def run_frontend():
"""Run the frontend development server""" """Run the frontend development server"""
frontend_dir = PROJECT_ROOT / "frontend" frontend_dir = PROJECT_ROOT / "frontend"
os.chdir(frontend_dir) os.chdir(frontend_dir)
cmd = [sys.executable, "start_dev_server.py"] cmd = [sys.executable, "start_dev_server.py"]
env = os.environ.copy() env = os.environ.copy()
env["VITE_DEV_SERVER_HOST"] = FRONTEND_HOST env["VITE_DEV_SERVER_HOST"] = FRONTEND_HOST
env["VITE_DEV_SERVER_PORT"] = str(FRONTEND_PORT) env["VITE_DEV_SERVER_PORT"] = str(FRONTEND_PORT)
print(f"\n{'='*50}") print(f"\n{'='*50}")
print(f"Starting Frontend Server at http://{FRONTEND_HOST}:{FRONTEND_PORT}") print(f"Starting Frontend Server at http://{FRONTEND_HOST}:{FRONTEND_PORT}")
print(f"{'='*50}\n") print(f"{'='*50}\n")
return subprocess.Popen( return subprocess.Popen(
cmd, cmd,
env=env, env=env,
stdout=subprocess.PIPE, stdout=subprocess.PIPE,
stderr=subprocess.STDOUT, stderr=subprocess.STDOUT,
universal_newlines=True, universal_newlines=True,
bufsize=1, bufsize=1
) )
def print_process_output(process, prefix): def print_process_output(process, prefix):
"""Print process output with a prefix""" """Print process output with a prefix"""
for line in iter(process.stdout.readline, ""): for line in iter(process.stdout.readline, ''):
if not line: if not line:
break break
print(f"{prefix} | {line}", end="") print(f"{prefix} | {line}", end='')
def main(): def main():
"""Main function to start both servers""" """Main function to start both servers"""
print("\n🚀 Starting Chatterbox UI Development Environment") print("\n🚀 Starting Chatterbox UI Development Environment")
# Start the backend server # Start the backend server
backend_process = run_backend() backend_process = run_backend()
# Give the backend a moment to start # Give the backend a moment to start
time.sleep(2) time.sleep(2)
# Start the frontend server # Start the frontend server
frontend_process = run_frontend() frontend_process = run_frontend()
# Create threads to monitor and print output # Create threads to monitor and print output
backend_monitor = threading.Thread( backend_monitor = threading.Thread(
target=print_process_output, args=(backend_process, "BACKEND"), daemon=True target=print_process_output,
args=(backend_process, "BACKEND"),
daemon=True
) )
frontend_monitor = threading.Thread( frontend_monitor = threading.Thread(
target=print_process_output, args=(frontend_process, "FRONTEND"), daemon=True target=print_process_output,
args=(frontend_process, "FRONTEND"),
daemon=True
) )
backend_monitor.start() backend_monitor.start()
frontend_monitor.start() frontend_monitor.start()
# Setup signal handling for graceful shutdown # Setup signal handling for graceful shutdown
def signal_handler(sig, frame): def signal_handler(sig, frame):
print("\n\n🛑 Shutting down servers...") print("\n\n🛑 Shutting down servers...")
@ -125,16 +117,16 @@ def main():
# Threads are daemon, so they'll exit when the main thread exits # Threads are daemon, so they'll exit when the main thread exits
print("✅ Servers stopped successfully") print("✅ Servers stopped successfully")
sys.exit(0) sys.exit(0)
signal.signal(signal.SIGINT, signal_handler) signal.signal(signal.SIGINT, signal_handler)
# Print access information # Print access information
print("\n📋 Access Information:") print("\n📋 Access Information:")
print(f" • Frontend: http://{FRONTEND_HOST}:{FRONTEND_PORT}") print(f" • Frontend: http://{FRONTEND_HOST}:{FRONTEND_PORT}")
print(f" • Backend API: http://{BACKEND_HOST}:{BACKEND_PORT}/api") print(f" • Backend API: http://{BACKEND_HOST}:{BACKEND_PORT}/api")
print(f" • API Documentation: http://{BACKEND_HOST}:{BACKEND_PORT}/docs") print(f" • API Documentation: http://{BACKEND_HOST}:{BACKEND_PORT}/docs")
print("\n⚠️ Press Ctrl+C to stop both servers\n") print("\n⚠️ Press Ctrl+C to stop both servers\n")
# Keep the main process running # Keep the main process running
try: try:
while True: while True:
@ -142,6 +134,5 @@ def main():
except KeyboardInterrupt: except KeyboardInterrupt:
signal_handler(None, None) signal_handler(None, None)
if __name__ == "__main__": if __name__ == "__main__":
main() main()