feat/frontend-phase1 #1
|
@ -22,3 +22,4 @@ backend/tts_generated_dialogs/
|
|||
|
||||
# Node.js dependencies
|
||||
node_modules/
|
||||
.aider*
|
||||
|
|
|
@ -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:
|
||||
|
|
|
@ -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 {
|
||||
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;
|
||||
|
|
|
@ -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]);
|
||||
};
|
||||
|
|
|
@ -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
|
||||
|
|
Loading…
Reference in New Issue