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.js dependencies
node_modules/ 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 configuration
DEVICE = os.getenv("DEVICE", "auto") 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 # Ensure directories exist
SPEAKER_SAMPLES_DIR.mkdir(parents=True, exist_ok=True) SPEAKER_SAMPLES_DIR.mkdir(parents=True, exist_ok=True)
TTS_TEMP_OUTPUT_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.staticfiles import StaticFiles
from fastapi.middleware.cors import CORSMiddleware from fastapi.middleware.cors import CORSMiddleware
from pathlib import Path from pathlib import Path
import asyncio
import contextlib
import logging
import time
from app.routers import speakers, dialog # Import the routers from app.routers import speakers, dialog # Import the routers
from app import config 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") app.mount("/generated_audio", StaticFiles(directory=config.DIALOG_GENERATED_DIR), name="generated_audio")
# Further endpoints for speakers, dialog generation, etc., will be added here. # 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.dialog_processor_service import DialogProcessorService
from app.services.audio_manipulation_service import AudioManipulationService from app.services.audio_manipulation_service import AudioManipulationService
from app import config from app import config
from typing import AsyncIterator
from app.services.model_manager import ModelManager
router = APIRouter() 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. # 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. # For now, direct instantiation or simple Depends is fine.
def get_tts_service(): async def get_tts_service() -> AsyncIterator[TTSService]:
# Consider making device configurable """Dependency that holds a usage token for the duration of the request."""
return TTSService(device="mps") manager = ModelManager.instance()
async with manager.using():
service = await manager.get_service()
yield service
def get_speaker_management_service(): def get_speaker_management_service():
return SpeakerManagementService() return SpeakerManagementService()
@ -32,7 +37,7 @@ def get_dialog_processor_service(
def get_audio_manipulation_service(): def get_audio_manipulation_service():
return AudioManipulationService() return AudioManipulationService()
# --- Helper function to manage TTS model loading/unloading --- # --- Helper imports ---
from app.models.dialog_models import SpeechItem, SilenceItem from app.models.dialog_models import SpeechItem, SilenceItem
from app.services.tts_service import TTSService from app.services.tts_service import TTSService
@ -128,19 +133,7 @@ async def generate_line(
detail=error_detail detail=error_detail
) )
async def manage_tts_model_lifecycle(tts_service: TTSService, task_function, *args, **kwargs): # Removed per-request load/unload in favor of ModelManager idle eviction.
"""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()
async def process_dialog_flow( async def process_dialog_flow(
request: DialogRequest, request: DialogRequest,
@ -274,12 +267,10 @@ async def generate_dialog_endpoint(
- Concatenates all audio segments into a single file. - Concatenates all audio segments into a single file.
- Creates a ZIP archive of all individual segments and the concatenated file. - Creates a ZIP archive of all individual segments and the concatenated file.
""" """
# Wrap the core processing logic with model loading/unloading # Execute core processing; ModelManager dependency keeps the model marked "in use".
return await manage_tts_model_lifecycle( return await process_dialog_flow(
tts_service, request=request,
process_dialog_flow, dialog_processor=dialog_processor,
request=request,
dialog_processor=dialog_processor,
audio_manipulator=audio_manipulator, 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"CORS Origins: {config.CORS_ORIGINS}")
print(f"Project Root: {config.PROJECT_ROOT}") print(f"Project Root: {config.PROJECT_ROOT}")
print(f"Device: {config.DEVICE}") 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( uvicorn.run(
"app.main:app", "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 { .container {
max-width: 1100px; max-width: 1280px;
margin: 0 auto; margin: 0 auto;
padding: 0 18px; padding: 0 18px;
} }
@ -142,11 +142,11 @@ main {
text-align: center; 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) { #dialog-items-table th:nth-child(4), #dialog-items-table td:nth-child(4) {
width: 110px; width: 200px;
min-width: 90px; min-width: 180px;
max-width: 130px; max-width: 280px;
text-align: left; text-align: left;
padding-left: 0; padding-left: 0;
padding-right: 0; padding-right: 0;
@ -186,8 +186,22 @@ main {
#dialog-items-table td.actions { #dialog-items-table td.actions {
text-align: left; text-align: left;
min-width: 110px; min-width: 200px;
white-space: nowrap; 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 */ /* Collapsible log details */
@ -346,7 +360,7 @@ button {
margin-right: 10px; margin-right: 10px;
} }
.generate-line-btn, .play-line-btn { .generate-line-btn, .play-line-btn, .stop-line-btn {
background: var(--bg-blue-light); background: var(--bg-blue-light);
color: var(--text-blue); color: var(--text-blue);
border: 1.5px solid var(--border-blue); border: 1.5px solid var(--border-blue);
@ -363,7 +377,7 @@ button {
vertical-align: middle; 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; opacity: 0.45;
cursor: not-allowed; cursor: not-allowed;
} }
@ -374,7 +388,7 @@ button {
border-color: var(--warning-border); 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); background: var(--bg-blue-lighter);
color: var(--text-blue-darker); color: var(--text-blue-darker);
border-color: var(--text-blue); border-color: var(--text-blue);
@ -451,7 +465,7 @@ footer {
/* Inline Notification */ /* Inline Notification */
.notice { .notice {
max-width: 1100px; max-width: 1280px;
margin: 16px auto 0; margin: 16px auto 0;
padding: 12px 16px; padding: 12px 16px;
border-radius: 6px; border-radius: 6px;

View File

@ -1,6 +1,11 @@
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';
// Shared per-line audio playback state to prevent overlapping playback
let currentLineAudio = null;
let currentLinePlayBtn = null;
let currentLineStopBtn = null;
// --- Global Inline Notification Helpers --- // // --- Global Inline Notification Helpers --- //
const noticeEl = document.getElementById('global-notice'); const noticeEl = document.getElementById('global-notice');
const noticeContentEl = document.getElementById('global-notice-content'); const noticeContentEl = document.getElementById('global-notice-content');
@ -214,6 +219,48 @@ async function initializeDialogEditor() {
// Continue without speakers - they'll be loaded when needed // 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 to render the current dialogItems array to the DOM as table rows
function renderDialogItems() { function renderDialogItems() {
if (!dialogItemsContainer) return; if (!dialogItemsContainer) return;
@ -246,6 +293,8 @@ async function initializeDialogEditor() {
}); });
speakerSelect.onchange = (e) => { speakerSelect.onchange = (e) => {
dialogItems[index].speaker_id = e.target.value; dialogItems[index].speaker_id = e.target.value;
// Persist change
saveDialogToLocalStorage();
}; };
speakerTd.appendChild(speakerSelect); speakerTd.appendChild(speakerSelect);
} else { } else {
@ -305,6 +354,8 @@ async function initializeDialogEditor() {
if (!isNaN(val) && val > 0) dialogItems[index].duration = val; if (!isNaN(val) && val > 0) dialogItems[index].duration = val;
dialogItems[index].audioUrl = null; dialogItems[index].audioUrl = null;
} }
// Persist changes before re-render
saveDialogToLocalStorage();
renderDialogItems(); renderDialogItems();
} }
}; };
@ -323,6 +374,7 @@ async function initializeDialogEditor() {
upBtn.onclick = () => { upBtn.onclick = () => {
if (index > 0) { if (index > 0) {
[dialogItems[index - 1], dialogItems[index]] = [dialogItems[index], dialogItems[index - 1]]; [dialogItems[index - 1], dialogItems[index]] = [dialogItems[index], dialogItems[index - 1]];
saveDialogToLocalStorage();
renderDialogItems(); renderDialogItems();
} }
}; };
@ -337,6 +389,7 @@ async function initializeDialogEditor() {
downBtn.onclick = () => { downBtn.onclick = () => {
if (index < dialogItems.length - 1) { if (index < dialogItems.length - 1) {
[dialogItems[index], dialogItems[index + 1]] = [dialogItems[index + 1], dialogItems[index]]; [dialogItems[index], dialogItems[index + 1]] = [dialogItems[index + 1], dialogItems[index]];
saveDialogToLocalStorage();
renderDialogItems(); renderDialogItems();
} }
}; };
@ -350,6 +403,7 @@ async function initializeDialogEditor() {
removeBtn.title = 'Remove'; removeBtn.title = 'Remove';
removeBtn.onclick = () => { removeBtn.onclick = () => {
dialogItems.splice(index, 1); dialogItems.splice(index, 1);
saveDialogToLocalStorage();
renderDialogItems(); renderDialogItems();
}; };
actionsTd.appendChild(removeBtn); actionsTd.appendChild(removeBtn);
@ -376,6 +430,8 @@ async function initializeDialogEditor() {
if (result && result.audio_url) { if (result && result.audio_url) {
dialogItems[index].audioUrl = result.audio_url; dialogItems[index].audioUrl = result.audio_url;
console.log('Set audioUrl to:', result.audio_url); console.log('Set audioUrl to:', result.audio_url);
// Persist newly generated audio reference
saveDialogToLocalStorage();
} else { } else {
console.error('Invalid result structure:', result); console.error('Invalid result structure:', result);
throw new Error('Invalid response: missing audio_url'); throw new Error('Invalid response: missing audio_url');
@ -392,19 +448,107 @@ async function initializeDialogEditor() {
actionsTd.appendChild(generateBtn); actionsTd.appendChild(generateBtn);
// --- NEW: Per-line Play button --- // --- NEW: Per-line Play button ---
const playBtn = document.createElement('button'); const playPauseBtn = document.createElement('button');
playBtn.innerHTML = '⏵'; playPauseBtn.innerHTML = '⏵';
playBtn.title = item.audioUrl ? 'Play generated audio' : 'No audio generated yet'; playPauseBtn.title = item.audioUrl ? 'Play' : 'No audio generated yet';
playBtn.className = 'play-line-btn'; playPauseBtn.className = 'play-line-btn';
playBtn.disabled = !item.audioUrl; playPauseBtn.disabled = !item.audioUrl;
playBtn.onclick = () => {
if (!item.audioUrl) return; const stopBtn = document.createElement('button');
let audioUrl = item.audioUrl.startsWith('http') ? item.audioUrl : `${API_BASE_URL_FOR_FILES}${item.audioUrl}`; stopBtn.innerHTML = '⏹';
// Use a shared audio element or create one per play stopBtn.title = 'Stop';
let audio = new window.Audio(audioUrl); stopBtn.className = 'stop-line-btn';
audio.play(); 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 --- // --- NEW: Settings button for speech items ---
if (item.type === 'speech') { if (item.type === 'speech') {
@ -485,6 +629,7 @@ async function initializeDialogEditor() {
return; return;
} }
dialogItems.push(normalizeDialogItem({ type: 'speech', speaker_id: speakerId, text: text })); dialogItems.push(normalizeDialogItem({ type: 'speech', speaker_id: speakerId, text: text }));
saveDialogToLocalStorage();
renderDialogItems(); renderDialogItems();
clearTempInputArea(); clearTempInputArea();
}; };
@ -527,6 +672,7 @@ async function initializeDialogEditor() {
return; return;
} }
dialogItems.push(normalizeDialogItem({ type: 'silence', duration: duration })); dialogItems.push(normalizeDialogItem({ type: 'silence', duration: duration }));
saveDialogToLocalStorage();
renderDialogItems(); renderDialogItems();
clearTempInputArea(); clearTempInputArea();
}; };
@ -726,6 +872,8 @@ async function initializeDialogEditor() {
// Replace current dialog // Replace current dialog
dialogItems.splice(0, dialogItems.length, ...loadedItems); dialogItems.splice(0, dialogItems.length, ...loadedItems);
// Persist loaded script
saveDialogToLocalStorage();
renderDialogItems(); renderDialogItems();
console.log(`Loaded ${loadedItems.length} dialog items from script`); 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'); console.log('Dialog Editor Initialized');
renderDialogItems(); // Initial render (empty) renderDialogItems(); // Initial render (empty)
@ -851,6 +1033,8 @@ async function initializeDialogEditor() {
dialogItems[index].audioUrl = null; dialogItems[index].audioUrl = null;
closeModal(); closeModal();
// Persist settings change
saveDialogToLocalStorage();
renderDialogItems(); // Re-render to reflect changes renderDialogItems(); // Re-render to reflect changes
console.log('TTS settings updated for item:', dialogItems[index]); 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: 3d3e85db-3d67-4488-94b2-ffc189fbb287:
name: RCB name: RCB
sample_path: speaker_samples/3d3e85db-3d67-4488-94b2-ffc189fbb287.wav 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