Compare commits

..

5 Commits

11 changed files with 680 additions and 47 deletions

1
.gitignore vendored
View File

@ -22,3 +22,4 @@ backend/tts_generated_dialogs/
# Node.js dependencies
node_modules/
.aider*

204
.note/unload_model_plan.md Normal file
View File

@ -0,0 +1,204 @@
# Unload Model on Idle: Implementation Plan
## Goals
- Automatically unload large TTS model(s) when idle to reduce RAM/VRAM usage.
- Lazy-load on demand without breaking API semantics.
- Configurable timeout and safety controls.
## Requirements
- Config-driven idle timeout and poll interval.
- Thread-/async-safe across concurrent requests.
- No unload while an inference is in progress.
- Clear logs and metrics for load/unload events.
## Configuration
File: `backend/app/config.py`
- Add:
- `MODEL_IDLE_TIMEOUT_SECONDS: int = 900` (0 disables eviction)
- `MODEL_IDLE_CHECK_INTERVAL_SECONDS: int = 60`
- `MODEL_EVICTION_ENABLED: bool = True`
- Bind to env: `MODEL_IDLE_TIMEOUT_SECONDS`, `MODEL_IDLE_CHECK_INTERVAL_SECONDS`, `MODEL_EVICTION_ENABLED`.
## Design
### ModelManager (Singleton)
File: `backend/app/services/model_manager.py` (new)
- Responsibilities:
- Manage lifecycle (load/unload) of the TTS model/pipeline.
- Provide `get()` that returns a ready model (lazy-load if needed) and updates `last_used`.
- Track active request count to block eviction while > 0.
- Internals:
- `self._model` (or components), `self._last_used: float`, `self._active: int`.
- Locks: `asyncio.Lock` for load/unload; `asyncio.Lock` or `asyncio.Semaphore` for counters.
- Optional CUDA cleanup: `torch.cuda.empty_cache()` after unload.
- API:
- `async def get(self) -> Model`: ensures loaded; bumps `last_used`.
- `async def load(self)`: idempotent; guarded by lock.
- `async def unload(self)`: only when `self._active == 0`; clears refs and caches.
- `def touch(self)`: update `last_used`.
- Context helper: `async def using(self)`: async context manager incrementing/decrementing `active` safely.
### Idle Reaper Task
Registration: FastAPI startup (e.g., in `backend/app/main.py`)
- Background task loop every `MODEL_IDLE_CHECK_INTERVAL_SECONDS`:
- If eviction enabled and timeout > 0 and model is loaded and `active == 0` and `now - last_used >= timeout`, call `unload()`.
- Handle cancellation on shutdown.
### API Integration
- Replace direct model access in endpoints with:
```python
manager = ModelManager.instance()
async with manager.using():
model = await manager.get()
# perform inference
```
- Optionally call `manager.touch()` at request start for non-inference paths that still need the model resident.
## Pseudocode
```python
# services/model_manager.py
import time, asyncio
from typing import Optional
from .config import settings
class ModelManager:
_instance: Optional["ModelManager"] = None
def __init__(self):
self._model = None
self._last_used = time.time()
self._active = 0
self._lock = asyncio.Lock()
self._counter_lock = asyncio.Lock()
@classmethod
def instance(cls):
if not cls._instance:
cls._instance = cls()
return cls._instance
async def load(self):
async with self._lock:
if self._model is not None:
return
# ... load model/pipeline here ...
self._model = await load_pipeline()
self._last_used = time.time()
async def unload(self):
async with self._lock:
if self._model is None:
return
if self._active > 0:
return # safety: do not unload while in use
# ... free resources ...
self._model = None
try:
import torch
torch.cuda.empty_cache()
except Exception:
pass
async def get(self):
if self._model is None:
await self.load()
self._last_used = time.time()
return self._model
async def _inc(self):
async with self._counter_lock:
self._active += 1
async def _dec(self):
async with self._counter_lock:
self._active = max(0, self._active - 1)
self._last_used = time.time()
def last_used(self):
return self._last_used
def is_loaded(self):
return self._model is not None
def active(self):
return self._active
def using(self):
manager = self
class _Ctx:
async def __aenter__(self):
await manager._inc()
return manager
async def __aexit__(self, exc_type, exc, tb):
await manager._dec()
return _Ctx()
# main.py (startup)
@app.on_event("startup")
async def start_reaper():
async def reaper():
while True:
try:
await asyncio.sleep(settings.MODEL_IDLE_CHECK_INTERVAL_SECONDS)
if not settings.MODEL_EVICTION_ENABLED:
continue
timeout = settings.MODEL_IDLE_TIMEOUT_SECONDS
if timeout <= 0:
continue
m = ModelManager.instance()
if m.is_loaded() and m.active() == 0 and (time.time() - m.last_used()) >= timeout:
await m.unload()
except asyncio.CancelledError:
break
except Exception as e:
logger.exception("Idle reaper error: %s", e)
app.state._model_reaper_task = asyncio.create_task(reaper())
@app.on_event("shutdown")
async def stop_reaper():
task = getattr(app.state, "_model_reaper_task", None)
if task:
task.cancel()
with contextlib.suppress(Exception):
await task
```
```
## Observability
- Logs: model load/unload, reaper decisions, active count.
- Metrics (optional): counters and gauges (load events, active, residency time).
## Safety & Edge Cases
- Avoid unload when `active > 0`.
- Guard multiple loads/unloads with lock.
- Multi-worker servers: each worker manages its own model.
- Cold-start latency: document expected additional latency for first request after idle unload.
## Testing
- Unit tests for `ModelManager`: load/unload idempotency, counter behavior.
- Simulated reaper triggering with short timeouts.
- Endpoint tests: concurrency (N simultaneous inferences), ensure no unload mid-flight.
## Rollout Plan
1. Introduce config + Manager (no reaper), switch endpoints to `using()`.
2. Enable reaper with long timeout in staging; observe logs/metrics.
3. Tune timeout; enable in production.
## Tasks Checklist
- [ ] Add config flags and defaults in `backend/app/config.py`.
- [ ] Create `backend/app/services/model_manager.py`.
- [ ] Register startup/shutdown reaper in app init (`backend/app/main.py`).
- [ ] Refactor endpoints to use `ModelManager.instance().using()` and `get()`.
- [ ] Add logs and optional metrics.
- [ ] Add unit/integration tests.
- [ ] Update README/ops docs.
## Alternatives Considered
- Gunicorn/uvicorn worker preloading with external idle supervisor: more complexity, less portability.
- OS-level cgroup memory pressure eviction: opaque and risky for correctness.
## Configuration Examples
```
MODEL_EVICTION_ENABLED=true
MODEL_IDLE_TIMEOUT_SECONDS=900
MODEL_IDLE_CHECK_INTERVAL_SECONDS=60
```

View File

@ -67,6 +67,14 @@ if CORS_ORIGINS != ["*"] and _frontend_host and _frontend_port:
# Device configuration
DEVICE = os.getenv("DEVICE", "auto")
# Model idle eviction configuration
# Enable/disable idle-based model eviction
MODEL_EVICTION_ENABLED = os.getenv("MODEL_EVICTION_ENABLED", "true").lower() == "true"
# Unload model after this many seconds of inactivity (0 disables eviction)
MODEL_IDLE_TIMEOUT_SECONDS = int(os.getenv("MODEL_IDLE_TIMEOUT_SECONDS", "900"))
# How often the reaper checks for idleness
MODEL_IDLE_CHECK_INTERVAL_SECONDS = int(os.getenv("MODEL_IDLE_CHECK_INTERVAL_SECONDS", "60"))
# Ensure directories exist
SPEAKER_SAMPLES_DIR.mkdir(parents=True, exist_ok=True)
TTS_TEMP_OUTPUT_DIR.mkdir(parents=True, exist_ok=True)

View File

@ -2,6 +2,10 @@ from fastapi import FastAPI
from fastapi.staticfiles import StaticFiles
from fastapi.middleware.cors import CORSMiddleware
from pathlib import Path
import asyncio
import contextlib
import logging
import time
from app.routers import speakers, dialog # Import the routers
from app import config
@ -38,3 +42,47 @@ config.DIALOG_GENERATED_DIR.mkdir(parents=True, exist_ok=True)
app.mount("/generated_audio", StaticFiles(directory=config.DIALOG_GENERATED_DIR), name="generated_audio")
# Further endpoints for speakers, dialog generation, etc., will be added here.
# --- Background task: idle model reaper ---
logger = logging.getLogger("app.model_reaper")
@app.on_event("startup")
async def _start_model_reaper():
from app.services.model_manager import ModelManager
async def reaper():
while True:
try:
await asyncio.sleep(config.MODEL_IDLE_CHECK_INTERVAL_SECONDS)
if not getattr(config, "MODEL_EVICTION_ENABLED", True):
continue
timeout = getattr(config, "MODEL_IDLE_TIMEOUT_SECONDS", 0)
if timeout <= 0:
continue
m = ModelManager.instance()
if m.is_loaded() and m.active() == 0 and (time.time() - m.last_used()) >= timeout:
logger.info("Idle timeout reached (%.0fs). Unloading model...", timeout)
await m.unload()
except asyncio.CancelledError:
break
except Exception:
logger.exception("Model reaper encountered an error")
# Log eviction configuration at startup
logger.info(
"Model Eviction -> enabled: %s | idle_timeout: %ss | check_interval: %ss",
getattr(config, "MODEL_EVICTION_ENABLED", True),
getattr(config, "MODEL_IDLE_TIMEOUT_SECONDS", 0),
getattr(config, "MODEL_IDLE_CHECK_INTERVAL_SECONDS", 60),
)
app.state._model_reaper_task = asyncio.create_task(reaper())
@app.on_event("shutdown")
async def _stop_model_reaper():
task = getattr(app.state, "_model_reaper_task", None)
if task:
task.cancel()
with contextlib.suppress(Exception):
await task

View File

@ -9,6 +9,8 @@ from app.services.speaker_service import SpeakerManagementService
from app.services.dialog_processor_service import DialogProcessorService
from app.services.audio_manipulation_service import AudioManipulationService
from app import config
from typing import AsyncIterator
from app.services.model_manager import ModelManager
router = APIRouter()
@ -16,9 +18,12 @@ router = APIRouter()
# These can be more sophisticated with a proper DI container or FastAPI's Depends system if services had complex init.
# For now, direct instantiation or simple Depends is fine.
def get_tts_service():
# Consider making device configurable
return TTSService(device="mps")
async def get_tts_service() -> AsyncIterator[TTSService]:
"""Dependency that holds a usage token for the duration of the request."""
manager = ModelManager.instance()
async with manager.using():
service = await manager.get_service()
yield service
def get_speaker_management_service():
return SpeakerManagementService()
@ -32,7 +37,7 @@ def get_dialog_processor_service(
def get_audio_manipulation_service():
return AudioManipulationService()
# --- Helper function to manage TTS model loading/unloading ---
# --- Helper imports ---
from app.models.dialog_models import SpeechItem, SilenceItem
from app.services.tts_service import TTSService
@ -128,19 +133,7 @@ async def generate_line(
detail=error_detail
)
async def manage_tts_model_lifecycle(tts_service: TTSService, task_function, *args, **kwargs):
"""Loads TTS model, executes task, then unloads model."""
try:
print("API: Loading TTS model...")
tts_service.load_model()
return await task_function(*args, **kwargs)
except Exception as e:
# Log or handle specific exceptions if needed before re-raising
print(f"API: Error during TTS model lifecycle or task execution: {e}")
raise
finally:
print("API: Unloading TTS model...")
tts_service.unload_model()
# Removed per-request load/unload in favor of ModelManager idle eviction.
async def process_dialog_flow(
request: DialogRequest,
@ -274,12 +267,10 @@ async def generate_dialog_endpoint(
- Concatenates all audio segments into a single file.
- Creates a ZIP archive of all individual segments and the concatenated file.
"""
# Wrap the core processing logic with model loading/unloading
return await manage_tts_model_lifecycle(
tts_service,
process_dialog_flow,
request=request,
dialog_processor=dialog_processor,
# Execute core processing; ModelManager dependency keeps the model marked "in use".
return await process_dialog_flow(
request=request,
dialog_processor=dialog_processor,
audio_manipulator=audio_manipulator,
background_tasks=background_tasks
background_tasks=background_tasks,
)

View File

@ -0,0 +1,170 @@
import asyncio
import time
import logging
from typing import Optional
import gc
import os
_proc = None
try:
import psutil # type: ignore
_proc = psutil.Process(os.getpid())
except Exception:
psutil = None # type: ignore
def _rss_mb() -> float:
"""Return current process RSS in MB, or -1.0 if unavailable."""
global _proc
try:
if _proc is None and psutil is not None:
_proc = psutil.Process(os.getpid())
if _proc is not None:
return _proc.memory_info().rss / (1024 * 1024)
except Exception:
return -1.0
return -1.0
try:
import torch # Optional; used for cache cleanup metrics
except Exception: # pragma: no cover - torch may not be present in some envs
torch = None # type: ignore
from app import config
from app.services.tts_service import TTSService
logger = logging.getLogger(__name__)
class ModelManager:
_instance: Optional["ModelManager"] = None
def __init__(self):
self._service: Optional[TTSService] = None
self._last_used: float = time.time()
self._active: int = 0
self._lock = asyncio.Lock()
self._counter_lock = asyncio.Lock()
@classmethod
def instance(cls) -> "ModelManager":
if not cls._instance:
cls._instance = cls()
return cls._instance
async def _ensure_service(self) -> None:
if self._service is None:
# Use configured device, default is handled by TTSService itself
device = getattr(config, "DEVICE", "auto")
# TTSService presently expects explicit device like "mps"/"cpu"/"cuda"; map "auto" to "mps" on Mac otherwise cpu
if device == "auto":
try:
import torch
if hasattr(torch.backends, "mps") and torch.backends.mps.is_available():
device = "mps"
elif torch.cuda.is_available():
device = "cuda"
else:
device = "cpu"
except Exception:
device = "cpu"
self._service = TTSService(device=device)
async def load(self) -> None:
async with self._lock:
await self._ensure_service()
if self._service and self._service.model is None:
before_mb = _rss_mb()
logger.info(
"Loading TTS model (device=%s)... (rss_before=%.1f MB)",
self._service.device,
before_mb,
)
self._service.load_model()
after_mb = _rss_mb()
if after_mb >= 0 and before_mb >= 0:
logger.info(
"TTS model loaded (rss_after=%.1f MB, delta=%.1f MB)",
after_mb,
after_mb - before_mb,
)
self._last_used = time.time()
async def unload(self) -> None:
async with self._lock:
if not self._service:
return
if self._active > 0:
logger.debug("Skip unload: %d active operations", self._active)
return
if self._service.model is not None:
before_mb = _rss_mb()
logger.info(
"Unloading idle TTS model... (rss_before=%.1f MB, active=%d)",
before_mb,
self._active,
)
self._service.unload_model()
# Drop the service instance as well to release any lingering refs
self._service = None
# Force GC and attempt allocator cache cleanup
try:
gc.collect()
finally:
if torch is not None:
try:
if hasattr(torch, "cuda") and torch.cuda.is_available():
torch.cuda.empty_cache()
except Exception:
logger.debug("cuda.empty_cache() failed", exc_info=True)
try:
# MPS empty_cache may exist depending on torch version
mps = getattr(torch, "mps", None)
if mps is not None and hasattr(mps, "empty_cache"):
mps.empty_cache()
except Exception:
logger.debug("mps.empty_cache() failed", exc_info=True)
after_mb = _rss_mb()
if after_mb >= 0 and before_mb >= 0:
logger.info(
"Idle unload complete (rss_after=%.1f MB, delta=%.1f MB)",
after_mb,
after_mb - before_mb,
)
self._last_used = time.time()
async def get_service(self) -> TTSService:
if not self._service or self._service.model is None:
await self.load()
self._last_used = time.time()
return self._service # type: ignore[return-value]
async def _inc(self) -> None:
async with self._counter_lock:
self._active += 1
async def _dec(self) -> None:
async with self._counter_lock:
self._active = max(0, self._active - 1)
self._last_used = time.time()
def last_used(self) -> float:
return self._last_used
def is_loaded(self) -> bool:
return bool(self._service and self._service.model is not None)
def active(self) -> int:
return self._active
def using(self):
manager = self
class _Ctx:
async def __aenter__(self):
await manager._inc()
return manager
async def __aexit__(self, exc_type, exc, tb):
await manager._dec()
return _Ctx()

View File

@ -14,6 +14,14 @@ if __name__ == "__main__":
print(f"CORS Origins: {config.CORS_ORIGINS}")
print(f"Project Root: {config.PROJECT_ROOT}")
print(f"Device: {config.DEVICE}")
# Idle eviction settings
print(
"Model Eviction -> enabled: {} | idle_timeout: {}s | check_interval: {}s".format(
getattr(config, "MODEL_EVICTION_ENABLED", True),
getattr(config, "MODEL_IDLE_TIMEOUT_SECONDS", 0),
getattr(config, "MODEL_IDLE_CHECK_INTERVAL_SECONDS", 60),
)
)
uvicorn.run(
"app.main:app",

2
forge.yaml Normal file
View File

@ -0,0 +1,2 @@
# yaml-language-server: $schema=https://raw.githubusercontent.com/antinomyhq/forge/refs/heads/main/forge.schema.json
model: qwen/qwen3-coder

View File

@ -55,7 +55,7 @@ body {
}
.container {
max-width: 1100px;
max-width: 1280px;
margin: 0 auto;
padding: 0 18px;
}
@ -142,11 +142,11 @@ main {
text-align: center;
}
/* Make the Actions (4th) column narrower */
/* Actions (4th) column sizing */
#dialog-items-table th:nth-child(4), #dialog-items-table td:nth-child(4) {
width: 110px;
min-width: 90px;
max-width: 130px;
width: 200px;
min-width: 180px;
max-width: 280px;
text-align: left;
padding-left: 0;
padding-right: 0;
@ -186,8 +186,22 @@ main {
#dialog-items-table td.actions {
text-align: left;
min-width: 110px;
white-space: nowrap;
min-width: 200px;
white-space: normal; /* allow wrapping so we don't see ellipsis */
overflow: visible; /* override table cell default from global rule */
text-overflow: clip; /* no ellipsis */
}
/* Allow wrapping of action buttons on smaller screens */
@media (max-width: 900px) {
#dialog-items-table th:nth-child(4), #dialog-items-table td:nth-child(4) {
width: auto;
min-width: 160px;
max-width: none;
}
#dialog-items-table td.actions {
white-space: normal;
}
}
/* Collapsible log details */
@ -346,7 +360,7 @@ button {
margin-right: 10px;
}
.generate-line-btn, .play-line-btn {
.generate-line-btn, .play-line-btn, .stop-line-btn {
background: var(--bg-blue-light);
color: var(--text-blue);
border: 1.5px solid var(--border-blue);
@ -363,7 +377,7 @@ button {
vertical-align: middle;
}
.generate-line-btn:disabled, .play-line-btn:disabled {
.generate-line-btn:disabled, .play-line-btn:disabled, .stop-line-btn:disabled {
opacity: 0.45;
cursor: not-allowed;
}
@ -374,7 +388,7 @@ button {
border-color: var(--warning-border);
}
.generate-line-btn:hover, .play-line-btn:hover {
.generate-line-btn:hover, .play-line-btn:hover, .stop-line-btn:hover {
background: var(--bg-blue-lighter);
color: var(--text-blue-darker);
border-color: var(--text-blue);
@ -451,7 +465,7 @@ footer {
/* Inline Notification */
.notice {
max-width: 1100px;
max-width: 1280px;
margin: 16px auto 0;
padding: 12px 16px;
border-radius: 6px;

View File

@ -1,6 +1,11 @@
import { getSpeakers, addSpeaker, deleteSpeaker, generateDialog } from './api.js';
import { API_BASE_URL, API_BASE_URL_FOR_FILES } from './config.js';
// Shared per-line audio playback state to prevent overlapping playback
let currentLineAudio = null;
let currentLinePlayBtn = null;
let currentLineStopBtn = null;
// --- Global Inline Notification Helpers --- //
const noticeEl = document.getElementById('global-notice');
const noticeContentEl = document.getElementById('global-notice-content');
@ -214,6 +219,48 @@ async function initializeDialogEditor() {
// Continue without speakers - they'll be loaded when needed
}
// --- LocalStorage persistence helpers ---
const LS_KEY = 'dialogEditor.items.v1';
function saveDialogToLocalStorage() {
try {
const exportData = dialogItems.map(item => {
const obj = { type: item.type };
if (item.type === 'speech') {
obj.speaker_id = item.speaker_id;
obj.text = item.text;
if (item.exaggeration !== undefined) obj.exaggeration = item.exaggeration;
if (item.cfg_weight !== undefined) obj.cfg_weight = item.cfg_weight;
if (item.temperature !== undefined) obj.temperature = item.temperature;
if (item.audioUrl) obj.audioUrl = item.audioUrl; // keep existing audio reference if present
} else if (item.type === 'silence') {
obj.duration = item.duration;
}
return obj;
});
localStorage.setItem(LS_KEY, JSON.stringify({ items: exportData }));
} catch (e) {
console.warn('Failed to save dialog to localStorage:', e);
}
}
function loadDialogFromLocalStorage() {
try {
const raw = localStorage.getItem(LS_KEY);
if (!raw) return;
const parsed = JSON.parse(raw);
if (!parsed || !Array.isArray(parsed.items)) return;
const loaded = parsed.items.map(normalizeDialogItem);
dialogItems.splice(0, dialogItems.length, ...loaded);
console.log(`Restored ${loaded.length} dialog items from localStorage`);
} catch (e) {
console.warn('Failed to load dialog from localStorage:', e);
}
}
// Attempt to restore saved dialog before first render
loadDialogFromLocalStorage();
// Function to render the current dialogItems array to the DOM as table rows
function renderDialogItems() {
if (!dialogItemsContainer) return;
@ -246,6 +293,8 @@ async function initializeDialogEditor() {
});
speakerSelect.onchange = (e) => {
dialogItems[index].speaker_id = e.target.value;
// Persist change
saveDialogToLocalStorage();
};
speakerTd.appendChild(speakerSelect);
} else {
@ -305,6 +354,8 @@ async function initializeDialogEditor() {
if (!isNaN(val) && val > 0) dialogItems[index].duration = val;
dialogItems[index].audioUrl = null;
}
// Persist changes before re-render
saveDialogToLocalStorage();
renderDialogItems();
}
};
@ -323,6 +374,7 @@ async function initializeDialogEditor() {
upBtn.onclick = () => {
if (index > 0) {
[dialogItems[index - 1], dialogItems[index]] = [dialogItems[index], dialogItems[index - 1]];
saveDialogToLocalStorage();
renderDialogItems();
}
};
@ -337,6 +389,7 @@ async function initializeDialogEditor() {
downBtn.onclick = () => {
if (index < dialogItems.length - 1) {
[dialogItems[index], dialogItems[index + 1]] = [dialogItems[index + 1], dialogItems[index]];
saveDialogToLocalStorage();
renderDialogItems();
}
};
@ -350,6 +403,7 @@ async function initializeDialogEditor() {
removeBtn.title = 'Remove';
removeBtn.onclick = () => {
dialogItems.splice(index, 1);
saveDialogToLocalStorage();
renderDialogItems();
};
actionsTd.appendChild(removeBtn);
@ -376,6 +430,8 @@ async function initializeDialogEditor() {
if (result && result.audio_url) {
dialogItems[index].audioUrl = result.audio_url;
console.log('Set audioUrl to:', result.audio_url);
// Persist newly generated audio reference
saveDialogToLocalStorage();
} else {
console.error('Invalid result structure:', result);
throw new Error('Invalid response: missing audio_url');
@ -392,19 +448,107 @@ async function initializeDialogEditor() {
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();
const playPauseBtn = document.createElement('button');
playPauseBtn.innerHTML = '⏵';
playPauseBtn.title = item.audioUrl ? 'Play' : 'No audio generated yet';
playPauseBtn.className = 'play-line-btn';
playPauseBtn.disabled = !item.audioUrl;
const stopBtn = document.createElement('button');
stopBtn.innerHTML = '⏹';
stopBtn.title = 'Stop';
stopBtn.className = 'stop-line-btn';
stopBtn.disabled = !item.audioUrl;
const setBtnStatesForPlaying = () => {
try {
playPauseBtn.innerHTML = '⏸';
playPauseBtn.title = 'Pause';
stopBtn.disabled = false;
} catch (e) { /* detached */ }
};
actionsTd.appendChild(playBtn);
const setBtnStatesForPausedOrStopped = () => {
try {
playPauseBtn.innerHTML = '⏵';
playPauseBtn.title = 'Play';
} catch (e) { /* detached */ }
};
const stopCurrent = () => {
if (currentLineAudio) {
try { currentLineAudio.pause(); currentLineAudio.currentTime = 0; } catch (e) { /* noop */ }
}
if (currentLinePlayBtn) {
try { currentLinePlayBtn.innerHTML = '⏵'; currentLinePlayBtn.title = 'Play'; } catch (e) { /* detached */ }
}
if (currentLineStopBtn) {
try { currentLineStopBtn.disabled = true; } catch (e) { /* detached */ }
}
currentLineAudio = null;
currentLinePlayBtn = null;
currentLineStopBtn = null;
};
playPauseBtn.onclick = () => {
if (!item.audioUrl) return;
const audioUrl = item.audioUrl.startsWith('http') ? item.audioUrl : `${API_BASE_URL_FOR_FILES}${item.audioUrl}`;
// If controlling the same line
if (currentLineAudio && currentLinePlayBtn === playPauseBtn) {
if (currentLineAudio.paused) {
// Resume
currentLineAudio.play().then(() => setBtnStatesForPlaying()).catch(err => {
console.error('Audio resume failed:', err);
showNotice('Could not resume audio.', 'error', { timeout: 2000 });
});
} else {
// Pause
try { currentLineAudio.pause(); } catch (e) { /* noop */ }
setBtnStatesForPausedOrStopped();
}
return;
}
// Switching to a different line: stop previous
if (currentLineAudio) {
stopCurrent();
}
// Start new audio
const audio = new window.Audio(audioUrl);
currentLineAudio = audio;
currentLinePlayBtn = playPauseBtn;
currentLineStopBtn = stopBtn;
const clearState = () => {
if (currentLineAudio === audio) {
setBtnStatesForPausedOrStopped();
try { stopBtn.disabled = true; } catch (e) { /* detached */ }
currentLineAudio = null;
currentLinePlayBtn = null;
currentLineStopBtn = null;
}
};
audio.addEventListener('ended', clearState, { once: true });
audio.addEventListener('error', clearState, { once: true });
audio.play().then(() => setBtnStatesForPlaying()).catch(err => {
console.error('Audio play failed:', err);
clearState();
showNotice('Could not play audio.', 'error', { timeout: 2000 });
});
};
stopBtn.onclick = () => {
// Only acts if this line is the active one
if (currentLineAudio && currentLinePlayBtn === playPauseBtn) {
stopCurrent();
}
};
actionsTd.appendChild(playPauseBtn);
actionsTd.appendChild(stopBtn);
// --- NEW: Settings button for speech items ---
if (item.type === 'speech') {
@ -485,6 +629,7 @@ async function initializeDialogEditor() {
return;
}
dialogItems.push(normalizeDialogItem({ type: 'speech', speaker_id: speakerId, text: text }));
saveDialogToLocalStorage();
renderDialogItems();
clearTempInputArea();
};
@ -527,6 +672,7 @@ async function initializeDialogEditor() {
return;
}
dialogItems.push(normalizeDialogItem({ type: 'silence', duration: duration }));
saveDialogToLocalStorage();
renderDialogItems();
clearTempInputArea();
};
@ -726,6 +872,8 @@ async function initializeDialogEditor() {
// Replace current dialog
dialogItems.splice(0, dialogItems.length, ...loadedItems);
// Persist loaded script
saveDialogToLocalStorage();
renderDialogItems();
console.log(`Loaded ${loadedItems.length} dialog items from script`);
@ -805,6 +953,40 @@ async function initializeDialogEditor() {
});
}
// --- Clear Dialog Button ---
// Prefer an existing button with id if present; otherwise, create and insert beside Save/Load.
let clearDialogBtn = document.getElementById('clear-dialog-btn');
if (!clearDialogBtn) {
clearDialogBtn = document.createElement('button');
clearDialogBtn.id = 'clear-dialog-btn';
clearDialogBtn.textContent = 'Clear Dialog';
// Insert next to Save/Load if possible
const saveLoadContainer = saveScriptBtn ? saveScriptBtn.parentElement : null;
if (saveLoadContainer) {
saveLoadContainer.appendChild(clearDialogBtn);
} else {
// Fallback: append near the add buttons container
const addBtnsContainer = addSpeechLineBtn ? addSpeechLineBtn.parentElement : null;
if (addBtnsContainer) addBtnsContainer.appendChild(clearDialogBtn);
}
}
clearDialogBtn.addEventListener('click', async () => {
if (dialogItems.length === 0) {
showNotice('Dialog is already empty.', 'info', { timeout: 2500 });
return;
}
const ok = await confirmAction(`This will remove ${dialogItems.length} dialog item(s). Continue?`);
if (!ok) return;
// Clear any transient input UI
if (typeof clearTempInputArea === 'function') clearTempInputArea();
// Clear state and persistence
dialogItems.splice(0, dialogItems.length);
try { localStorage.removeItem(LS_KEY); } catch (e) { /* ignore */ }
renderDialogItems();
showNotice('Dialog cleared.', 'success', { timeout: 2500 });
});
console.log('Dialog Editor Initialized');
renderDialogItems(); // Initial render (empty)
@ -851,6 +1033,8 @@ async function initializeDialogEditor() {
dialogItems[index].audioUrl = null;
closeModal();
// Persist settings change
saveDialogToLocalStorage();
renderDialogItems(); // Re-render to reflect changes
console.log('TTS settings updated for item:', dialogItems[index]);
};

View File

@ -31,3 +31,6 @@ dd3552d9-f4e8-49ed-9892-f9e67afcf23c:
3d3e85db-3d67-4488-94b2-ffc189fbb287:
name: RCB
sample_path: speaker_samples/3d3e85db-3d67-4488-94b2-ffc189fbb287.wav
f754cf35-892c-49b6-822a-f2e37246623b:
name: Jim
sample_path: speaker_samples/f754cf35-892c-49b6-822a-f2e37246623b.wav