Compare commits
5 Commits
41f95cdee3
...
4f47d69aaa
Author | SHA1 | Date |
---|---|---|
|
4f47d69aaa | |
|
f095bb14e5 | |
|
93e0407eac | |
|
c9593fe6cc | |
|
cbc164c7a3 |
|
@ -22,3 +22,4 @@ backend/tts_generated_dialogs/
|
||||||
|
|
||||||
# Node.js dependencies
|
# Node.js dependencies
|
||||||
node_modules/
|
node_modules/
|
||||||
|
.aider*
|
||||||
|
|
|
@ -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
|
||||||
|
```
|
|
@ -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)
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -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,
|
||||||
)
|
)
|
||||||
|
|
|
@ -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()
|
|
@ -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",
|
||||||
|
|
|
@ -0,0 +1,2 @@
|
||||||
|
# yaml-language-server: $schema=https://raw.githubusercontent.com/antinomyhq/forge/refs/heads/main/forge.schema.json
|
||||||
|
model: qwen/qwen3-coder
|
|
@ -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;
|
||||||
|
|
|
@ -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]);
|
||||||
};
|
};
|
||||||
|
|
|
@ -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
|
||||||
|
|
Loading…
Reference in New Issue