diff --git a/.gitignore b/.gitignore index 21748d3..1237fed 100644 --- a/.gitignore +++ b/.gitignore @@ -22,3 +22,4 @@ backend/tts_generated_dialogs/ # Node.js dependencies node_modules/ +.aider* diff --git a/backend/app/services/model_manager.py b/backend/app/services/model_manager.py index c071271..b2d268a 100644 --- a/backend/app/services/model_manager.py +++ b/backend/app/services/model_manager.py @@ -2,6 +2,32 @@ 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 @@ -47,8 +73,20 @@ class ModelManager: async with self._lock: await self._ensure_service() if self._service and self._service.model is None: - logger.info("Loading TTS model (device=%s)...", self._service.device) + 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: @@ -59,8 +97,39 @@ class ModelManager: logger.debug("Skip unload: %d active operations", self._active) return if self._service.model is not None: - logger.info("Unloading idle TTS model...") + 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: diff --git a/forge.yaml b/forge.yaml new file mode 100644 index 0000000..85ccf2a --- /dev/null +++ b/forge.yaml @@ -0,0 +1,2 @@ +# yaml-language-server: $schema=https://raw.githubusercontent.com/antinomyhq/forge/refs/heads/main/forge.schema.json +model: qwen/qwen3-coder diff --git a/frontend/css/style.css b/frontend/css/style.css index 1ac08b2..3c4477a 100644 --- a/frontend/css/style.css +++ b/frontend/css/style.css @@ -55,7 +55,7 @@ body { } .container { - max-width: 1100px; + max-width: 1280px; margin: 0 auto; padding: 0 18px; } @@ -465,7 +465,7 @@ footer { /* Inline Notification */ .notice { - max-width: 1100px; + max-width: 1280px; margin: 16px auto 0; padding: 12px 16px; border-radius: 6px; diff --git a/frontend/js/app.js b/frontend/js/app.js index 1b75096..086d1ca 100644 --- a/frontend/js/app.js +++ b/frontend/js/app.js @@ -219,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; @@ -251,6 +293,8 @@ async function initializeDialogEditor() { }); speakerSelect.onchange = (e) => { dialogItems[index].speaker_id = e.target.value; + // Persist change + saveDialogToLocalStorage(); }; speakerTd.appendChild(speakerSelect); } else { @@ -310,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(); } }; @@ -328,6 +374,7 @@ async function initializeDialogEditor() { upBtn.onclick = () => { if (index > 0) { [dialogItems[index - 1], dialogItems[index]] = [dialogItems[index], dialogItems[index - 1]]; + saveDialogToLocalStorage(); renderDialogItems(); } }; @@ -342,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(); } }; @@ -355,6 +403,7 @@ async function initializeDialogEditor() { removeBtn.title = 'Remove'; removeBtn.onclick = () => { dialogItems.splice(index, 1); + saveDialogToLocalStorage(); renderDialogItems(); }; actionsTd.appendChild(removeBtn); @@ -381,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'); @@ -578,6 +629,7 @@ async function initializeDialogEditor() { return; } dialogItems.push(normalizeDialogItem({ type: 'speech', speaker_id: speakerId, text: text })); + saveDialogToLocalStorage(); renderDialogItems(); clearTempInputArea(); }; @@ -620,6 +672,7 @@ async function initializeDialogEditor() { return; } dialogItems.push(normalizeDialogItem({ type: 'silence', duration: duration })); + saveDialogToLocalStorage(); renderDialogItems(); clearTempInputArea(); }; @@ -819,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`); @@ -898,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) @@ -944,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]); }; diff --git a/speaker_data/speakers.yaml b/speaker_data/speakers.yaml index 285f093..f845683 100644 --- a/speaker_data/speakers.yaml +++ b/speaker_data/speakers.yaml @@ -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