Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
122 changes: 105 additions & 17 deletions app/integrated_app/model_manager_core/load.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,13 +3,82 @@
包含 PersonaWarmupService / warmup_persona_cache、load_voxcpm2 /
_do_load_voxcpm2_internal / load_indextts2、PreloadService / preload_model /
get_preload_status。
【边界】不做卸载(unload.py);不做引擎切换(switch.py)。
【边界】不做引擎切换(switch.py)。卸载动作仍由 unload.py 承担:#84 起
load_voxcpm2 / load_indextts2 在预检前经 _unload_loaded_engines_for_load
委托 unload_model() 清场卸载驻留引擎(switch_engine 热待机路径以
``auto_unload=False`` 跳过,保持双引擎常驻语义)。
"""

from . import state as _state
from .state import *


def _unload_loaded_engines_for_load(target_engine: str) -> str | None:
"""加载前清场:卸载已驻留引擎并等待显存回收(#84)。

WHY: routes 的 load 端点把 voxcpm2 / indextts2 / indextts20 直接指到
专用加载器(routes/model.py 的 ``load_fn`` 分派),这条路径此前没有
switch_engine 的卸载阶段,预检又是「当前空闲 vs 需求」的裸比较——
12GB 级显卡上从 UI 直切引擎必然 503 INSUFFICIENT_VRAM(issue #84 的
三段证据:无 [引擎切换] VRAM 检查日志、绕过 M-R1 记账、加载器不卸载)。

与 switch_engine 的关系:传统切换路径已先 ``unload_model()``,此时本
函数检测不到驻留引擎、是幂等空操作;热待机路径必须由调用方传
``auto_unload=False`` 跳过(见 switch.py 调用点),否则「双引擎常驻」
的快切语义会被破坏。

锁约定:专用加载器进入本函数时已持有 ``_model_lock``(RLock),
``unload_model()`` 内部的 ``with _model_lock`` 依赖同线程可重入,
与 switch_engine 阶段④的既有用法一致(见 state.py RLock WHY 注释)。

Args:
target_engine: 即将加载的引擎名(用于日志与语义标注)。

Returns:
str | None: 本次被卸载的驻留引擎名;没有驻留引擎时返回 ``None``。
"""
from ..gpu_backend import GPUBackend, GPUBackendManager
from ..model_registry import ENGINE_VRAM_REQUIREMENTS
from .unload import unload_model

resident: str | None = registry.current_engine
has_generic = any(inst is not None for inst in registry.get_all_engine_instances().values())
if registry.voxcpm_model is None and registry.indextts2_engine is None and not has_generic:
return None

logger.info(f"[模型加载] 检测到已驻留引擎 {resident or '未知'},先卸载再加载 {target_engine}")
gpu_device: Any = _state.get_gpu_device()
backend: GPUBackend = GPUBackendManager.detect_backend()
baseline_bytes: int = 0
expected_release_bytes: int = int(ENGINE_VRAM_REQUIREMENTS.get(resident or "", 0.0) * 1024**3)
if backend != GPUBackend.CPU and gpu_device is not None:
with contextlib.suppress(Exception):
baseline_bytes = GPUBackendManager.memory_allocated(gpu_device)

# RLock 同线程重入:见 state.py WHY 注释与 switch_engine 阶段④先例
unload_model()

if backend != GPUBackend.CPU and gpu_device is not None:
# 延迟导入避免循环依赖:switch.py 在模块级导入本模块(load.py)
from .switch import _wait_vram_freed

with contextlib.suppress(Exception):
GPUBackendManager.empty_cache()
if _wait_vram_freed(
gpu_device,
baseline_allocated=baseline_bytes,
expected_release_bytes=expected_release_bytes,
):
logger.info("[模型加载] 旧引擎显存已按预期回收")
else:
logger.warning(
"[模型加载] 卸载后显存回收未达预期(期望约 "
f"{expected_release_bytes / 1024**3:.2f}GB);若随后加载失败,"
"请排查 [模型卸载] 日志中的强引用告警"
)
return resident


def get_persona_cache_stats() -> dict[str, Any]:
"""获取当前 Persona 嵌入缓存的统计信息。

Expand Down Expand Up @@ -448,23 +517,16 @@ def load_voxcpm2(
# 模型加载开始时重置显存泄漏检测基线,避免加载期间显存上升导致误报
get_health_monitor().reset_vram_baseline()
try:
# Unload current engine if any
old_model: Any = registry.voxcpm_model
old_asr: Any = registry.voxcpm_asr
registry.voxcpm_model = None
registry.voxcpm_asr = None
if old_model is not None:
del old_model
if old_asr is not None:
del old_asr
gc.collect()
# #84: 统一清场——原先这里只手工摘 voxcpm_model / voxcpm_asr 两个槽位
# (漏掉 enhancer、引擎门面实例与 persona 缓存,也完全不清 IndexTTS 与
# 通用引擎),现在统一走 _unload_loaded_engines_for_load 委托
# unload_model() 全量卸载并核验显存回收;无驻留引擎时是快速空操作。
_unload_loaded_engines_for_load(EngineName.VOXCPM2.value)
time.sleep(_LOAD_RETRY_AFTER_UNLOAD_SECONDS)

from ..gpu_backend import GPUBackend, GPUBackendManager

backend: GPUBackend = GPUBackendManager.detect_backend()
if backend != GPUBackend.CPU:
with contextlib.suppress(Exception):
GPUBackendManager.empty_cache()
time.sleep(_LOAD_RETRY_AFTER_UNLOAD_SECONDS)

gpu_device: Any = _state.get_gpu_device()

Expand All @@ -487,12 +549,17 @@ def load_voxcpm2(
def load_indextts2(
progress_callback: Callable[..., None] | None = None,
version: str = "2.5",
*,
auto_unload: bool = True,
) -> Generator[tuple[str, None, None, None], None, None]:
"""加载 IndexTTS 引擎(2.5 / 2.0 双版本共用,生成器进度事件流)。

Args:
progress_callback: 预留回调参数(保持签名兼容;默认 ``None``)。
version: ``"2.5"``(默认)或 ``"2.0"``。决定权重目录、注册名与提示文案。
auto_unload: 加载前是否卸载已驻留引擎(#84)。routes 直连时保持默认
``True``;switch_engine 热待机路径必须传 ``False``,否则
「双引擎常驻」的快切语义被破坏。

Yields:
tuple[str, None, None, None]: ``(status_text, None, None, None)`` 四元组。
Expand All @@ -515,6 +582,14 @@ def load_indextts2(
if not os.path.exists(model_path):
raise FileNotFoundError(f"{label} 模型文件不存在: {model_path}\n请运行: python {download_script} 下载模型")

# #84: routes 直连本加载器时没有 switch_engine 的卸载阶段。若已有引擎
# 驻留,先全量卸载并等待显存回收,否则下面的预检是「当前空闲 vs 需求」
# 的裸比较——12GB 级显卡上从 UI 直切引擎必然 503。热待机路径由
# switch_engine 显式传 auto_unload=False 跳过(语义是双引擎常驻)。
unloaded_engine: str | None = None
if auto_unload:
unloaded_engine = _unload_loaded_engines_for_load(engine_name)

# Step 1: VRAM/RAM check
from ..model_registry import estimate_engine_vram_need_gb

Expand Down Expand Up @@ -551,7 +626,14 @@ def load_indextts2(
"③ 确认已启用 bf16(fp32 需要约两倍显存);"
"④ 最后才调低 config.yaml 的 models.vram_safety_margin_gb"
"(会增加推理期显存溢出风险)。"
"已加载的引擎将自动回滚,不会丢失当前可用状态。"
+ (
# #84: 走到这里说明清场卸载已经做过、回收等待也已结束,
# 当前 free 就是卸载后的真实余量——报错必须说清这一点,
# 否则用户会误以为「没卸载旧引擎」(#84 之前的误判路径)。
f"本次已自动卸载驻留引擎 {unloaded_engine} 并等待回收,当前可用即卸载后的真实余量。"
if unloaded_engine
else "已加载的引擎将自动回滚,不会丢失当前可用状态。"
)
)
except InsufficientVRAMError:
raise
Expand Down Expand Up @@ -666,15 +748,21 @@ def _idx_warmup_progress(msg: str) -> None:

def load_indextts20(
progress_callback: Callable[..., None] | None = None,
*,
auto_unload: bool = True,
) -> Generator[tuple[str, None, None, None], None, None]:
"""加载 IndexTTS 2.0 引擎(复用 ``load_indextts2``,version="2.0")。

与 2.5 共用同一推理代码包与引擎槽位(互斥),仅权重目录与入口类不同。

Args:
auto_unload: 透传给 :func:`load_indextts2`(#84)。switch_engine 的
热待机路径必须传 ``False``,routes 直连保持默认 ``True``。

Yields:
同 :func:`load_indextts2` 的 ``(status_text, None, None, None)`` 四元组。
"""
yield from load_indextts2(progress_callback=progress_callback, version="2.0")
yield from load_indextts2(progress_callback=progress_callback, version="2.0", auto_unload=auto_unload)


# ====================================================================
Expand Down
6 changes: 4 additions & 2 deletions app/integrated_app/model_manager_core/switch.py
Original file line number Diff line number Diff line change
Expand Up @@ -536,9 +536,11 @@ def _forward(loader: Any) -> Any:
if engine_name == EngineName.VOXCPM2.value:
yield from _forward(_load_voxcpm2_engine(gpu_device, backend))
elif engine_name == EngineName.INDEXTTS2.value:
yield from _forward(load_indextts2())
# #84: 热待机必须跳过加载前清场(语义就是双引擎常驻);传统
# 路径已先 unload_model(),传 True 只是幂等空操作。
yield from _forward(load_indextts2(auto_unload=not hot_standby))
elif engine_name == EngineName.INDEXTTS20.value:
yield from _forward(load_indextts20())
yield from _forward(load_indextts20(auto_unload=not hot_standby))
else:
# 通用新式引擎(声明式注册)
yield from _forward(_load_generic_engine(engine_name))
Expand Down
35 changes: 28 additions & 7 deletions app/integrated_app/templates/tabs/history.html
Original file line number Diff line number Diff line change
Expand Up @@ -388,6 +388,24 @@
return div.innerHTML;
}

// #99: 动作按钮接线辅助——数据经闭包传递,不再拼进 onclick 属性。
// (原先把文件名只做 JS 引号转义就拼进 innerHTML 的 onclick,
// 文件名含双引号即可闭合属性注入 HTML;改用 createElement +
// addEventListener 后数据不再进入任何 HTML/属性上下文。)
function _historyIconBtn(title, colorStyle, svgHtml, onClick) {
var btn = document.createElement('button');
btn.className = 'btn-icon';
btn.title = title;
if (colorStyle) btn.style.cssText = colorStyle;
btn.innerHTML = svgHtml; // 纯静态字面量,无任何外部输入
btn.addEventListener('click', onClick);
return btn;
}
var _SVG_HISTORY_PLAY = '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" aria-hidden="true"><polygon points="5 3 19 12 5 21 5 3"/></svg>';
var _SVG_HISTORY_DOWNLOAD = '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="7 10 12 15 17 10"/><line x1="12" y1="15" x2="12" y2="3"/></svg>';
var _SVG_HISTORY_HIDE = '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M17.94 17.94A10.07 10.07 0 0 1 12 20c-7 0-11-8-11-8a18.45 18.45 0 0 1 5.06-5.94M9.9 4.24A9.12 9.12 0 0 1 12 4c7 0 11 8 11 8a18.5 18.5 0 0 1-2.16 3.19m-6.72-1.07a3 3 0 1 1-4.24-4.24"/><line x1="1" y1="1" x2="23" y2="23"/></svg>';
var _SVG_HISTORY_DELETE = '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" aria-hidden="true"><polyline points="3 6 5 6 21 6"/><path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"/></svg>';

// 根据 filename 推断引擎
function inferEngine(filename) {
if (!filename) return 'voxcpm2';
Expand Down Expand Up @@ -453,13 +471,16 @@
var tdActions = document.createElement('td');
tdActions.setAttribute('data-label', '{{ "actions"|t(lang, default="操作") }}');
tdActions.style.textAlign = 'right';
var escName = String(r[1]).replace(/'/g, "\\'");
tdActions.innerHTML = '<div style="display:inline-flex;gap:4px">' +
'<button class="btn-icon" title="{{ "play"|t(lang) }}" onclick="playHistoryAudio(\'' + escName + '\', this)"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" aria-hidden="true"><polygon points="5 3 19 12 5 21 5 3"/></svg></button>' +
'<button class="btn-icon" title="{{ "download"|t(lang, default="下载") }}" onclick="downloadHistoryAudio(\'' + escName + '\')"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="7 10 12 15 17 10"/><line x1="12" y1="15" x2="12" y2="3"/></svg></button>' +
'<button class="btn-icon" title="{{ "hide"|t(lang, default="隐藏") }}" onclick="hideHistoryRecord(' + r[0] + ')"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M17.94 17.94A10.07 10.07 0 0 1 12 20c-7 0-11-8-11-8a18.45 18.45 0 0 1 5.06-5.94M9.9 4.24A9.12 9.12 0 0 1 12 4c7 0 11 8 11 8a18.5 18.5 0 0 1-2.16 3.19m-6.72-1.07a3 3 0 1 1-4.24-4.24"/><line x1="1" y1="1" x2="23" y2="23"/></svg></button>' +
'<button class="btn-icon" title="{{ "delete"|t(lang) }}" style="color:var(--red)" onclick="deleteHistoryAudio(' + r[0] + ', \'' + escName + '\', this)"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" aria-hidden="true"><polyline points="3 6 5 6 21 6"/><path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"/></svg></button>' +
'</div>';
// #99: r[1] 是历史库里的音频文件名(用户可控),原先只做 JS 引号
// 转义就拼进 onclick 属性——含双引号的文件名即可闭合属性注入 HTML。
// 改为事件闭包传参,名称不再进入任何 HTML/属性上下文。
var actionsWrap = document.createElement('div');
actionsWrap.style.cssText = 'display:inline-flex;gap:4px';
actionsWrap.appendChild(_historyIconBtn('{{ "play"|t(lang) }}', '', _SVG_HISTORY_PLAY, function(e) { playHistoryAudio(String(r[1]), e.currentTarget); }));
actionsWrap.appendChild(_historyIconBtn('{{ "download"|t(lang, default="下载") }}', '', _SVG_HISTORY_DOWNLOAD, function() { downloadHistoryAudio(String(r[1])); }));
actionsWrap.appendChild(_historyIconBtn('{{ "hide"|t(lang, default="隐藏") }}', '', _SVG_HISTORY_HIDE, function() { hideHistoryRecord(r[0]); }));
actionsWrap.appendChild(_historyIconBtn('{{ "delete"|t(lang) }}', 'color:var(--red)', _SVG_HISTORY_DELETE, function(e) { deleteHistoryAudio(r[0], String(r[1]), e.currentTarget); }));
tdActions.appendChild(actionsWrap);

tr.appendChild(tdCheck);
tr.appendChild(tdName);
Expand Down
Loading
Loading