本地服务型引擎强制契约(ASR / MT / TTS 通用)
如果你的插件要启动本地推理服务(
llama-server/tts-server/ 自研 HTTP 子进程), 必须遵守以下契约。违反它们会直接变成用户可见的 BUG (典型抱怨:「我没开任何音轨,怎么只是切到设置页就在加载模型?」「端口被占用,服务起不来」)。
0) 强烈推荐:继承标准基类(零样板代码)
从 TransBox v0.2.0 起,框架在 transbox.plugins 提供了专为本地模型引擎打造的标准基类:
BaseLocalServicePlugin: 本地服务通用基类BaseLocalASRProvider: 本地语音识别引擎基类BaseLocalMTProvider: 本地机器翻译引擎基类BaseLocalTTSProvider: 本地语音合成引擎基类
继承这些基类,你无需手动编写任何设备切换事件、线程重启或生命周期模板代码,开箱即得:
- 自动惰性启动:
initialize()绝不提前拉起服务;ensure_loaded()或执行推理时自动按需启动。 - 计算设备自动规范化:自动处理
cuda、cuda13、vulkan、rocm、cpu、auto的映射与 GPU 检测。 - 运行中设备热切与事件派发:改配置时若服务在跑,自动派发
device.switching(前端置灰并转圈)、在后台线程重启服务、就绪后派发device.ready;若服务未运行,仅记账不吃显存。 - 活跃音轨热重载:若当前模型正在为已启用的音轨服务,宿主自动热重载音轨流水线,用户体验无缝。
- 残留端口清理:提供
self.kill_port_conflict(port)自动终结旧孤儿进程。 - 显存释放与优雅退出:宿主
shutdown()时自动调用stop_service()释放子进程和显存。
示例:极简第三方 ASR 插件开发
from typing import Optional, Callable
import numpy as np
from transbox.plugins import BaseLocalASRProvider, Transcript
from transbox.plugins.manifest import ModManifest
class MyCustomLocalASR(BaseLocalASRProvider):
def __init__(self, manifest: ModManifest) -> None:
super().__init__(manifest)
self._port = 18090 # 建议指定不冲突的端口
# 1. 启动子进程逻辑(只有真正需要推理时才会被调用)
def start_service(self, device: Optional[str] = None) -> None:
backend = self.normalize_device(device) # 自动规范化为 cuda13 / vulkan / cpu
self.kill_port_conflict(self._port) # 清理孤儿端口占用
# 启动你的子进程,存入 self._proc
self._proc = ...
# 2. 停止子进程并释放显存
def stop_service(self) -> None:
# super().stop_service() 会自动 terminate/kill self._proc
super().stop_service()
# 3. 推理入口
def transcribe(
self,
audio: np.ndarray,
sample_rate: int = 16000,
language: str = "auto",
**kwargs,
) -> Transcript:
self.ensure_loaded() # 确保服务已就绪
# 发送请求并返回结果
return Transcript(text="...")示例 2:第三方本地 TTS 插件开发(基于 BaseLocalTTSProvider 与 audio.cpp)
from typing import Optional, Tuple
from pathlib import Path
import io, wave, requests
import numpy as np
from transbox.plugins import BaseLocalTTSProvider
from transbox.plugins.manifest import ModManifest
class MyCustomLocalTTS(BaseLocalTTSProvider):
def __init__(self, manifest: ModManifest) -> None:
super().__init__(manifest)
self._port = 18320
# 1. 启动子进程(只有真正需要推理时才会被调用)
def start_service(self, device: Optional[str] = None) -> None:
backend = self.normalize_device(device) # cuda13 / vulkan / cpu
self.kill_port_conflict(self._port)
exe = Path(self.host.runtime_dir("audiocpp_runtime")) / "audiocpp_server.exe"
model_path = Path(self.host.model_dir("my_tts_model")) / "model.gguf"
# 启动 audio.cpp 服务(内置 OpenAI 兼容 /v1/audio/speech 接口)
cmd = [str(exe), "--model", str(model_path), "--port", str(self._port), "--backend", backend]
# self._proc = subprocess.Popen(...)
# 2. 合成入口
def synthesize(
self,
text: str,
voice_id: str = "",
voice: Optional[str] = None,
language: str = "zh",
speed: float = 1.0,
) -> Tuple[np.ndarray, int]:
self.ensure_loaded()
resp = requests.post(
f"http://127.0.0.1:{self._port}/v1/audio/speech",
json={"input": text, "voice": voice or voice_id, "speed": speed, "response_format": "wav"},
timeout=30.0,
)
resp.raise_for_status()
with io.BytesIO(resp.content) as buf, wave.open(buf, "rb") as wf:
sr = wf.getframerate()
samples = np.frombuffer(wf.readframes(wf.getnframes()), dtype=np.int16).astype(np.float32) / 32767.0
return samples, sr1) 惰性启动:initialize() 不得启动服务
| 钩子 | 应该做什么 | 禁止做什么 |
|---|---|---|
initialize(config) | 记配置、解析模型目录、检查权重是否在磁盘、上报状态 | 启动子进程 / 加载权重 / 阻塞等待就绪 |
ensure_loaded() | 真正要使用时(宿主在音轨开启、流水线启动、点测试连接时调用)拉起服务并等就绪 | 无(可以阻塞到就绪:宿主已在后台线程调用) |
check_ready() | 判断「能不能用」:运行时存在 + 权重齐备(+ Key 已配) | 把「服务未启动」判成未就绪(宿主会因此拒绝开启音轨) |
list_voices() 等只读方法 | 返回已有缓存 / 空表 | 拉起服务 |
为什么:宿主的只读查询(设置页状态、能力探测、音色列表、模型卡检查)不会创建实例; 但只要有任何路径创建了实例(前置插件 / 宿主引擎解析 / 插件动作),initialize() 就会跑。 若它顺手启动服务,用户「只是打开设置页或切到该引擎页签」就会白等数秒并吃掉数 GB 显存。
其余入口自行兜底:transcribe() / translate() / synthesize() / stream_tts() / register_voice() / remove_voice() 入口处应确保服务已就绪(已就绪则零开销直接返回), 这样宿主不先调 ensure_loaded() 也能工作。
def initialize(self, config=None) -> None:
super().initialize(config)
self._resolve_model_dir() # 只做检查 / 记账
self._ensure_running = False
def ensure_loaded(self, progress_callback=None) -> None:
"""宿主在"真正要用"时调;外部插件的 initialize 跑在后台线程,这里先等它收敛。"""
self.wait_async_init()
self._start_server_if_needed()
def _start_server_if_needed(self) -> None:
if self._health_ok(): # 幂等:已就绪直接返回
return
self._ensure_server(...)
def check_ready(self) -> tuple[bool, str, str]:
if not self._weights_present():
return False, "model_not_ready", "模型未就绪,请在设置页下载"
return True, "", "" # 服务没起也算「可用」
def synthesize(self, text, **kw):
self._start_server_if_needed() # 请求入口兜底
...2) 服务端口:可配置 + 被占用自动顺延
{
"key": "port",
"label": "服务端口",
"type": "number",
"default": 18080,
"description": "本地服务监听端口。默认取高位不常用端口段;被别的程序占用时会自动顺延。"
}- 默认不要用 8080 / 8081 / 8082:实测常被 Steam
steamwebhelper、各类开发工具占用, 用户会看到「服务起不来」。 - 解析顺序建议:配置值 → 被别的程序占用则在自己的候选段内顺延(如 18080~18084) → 复用本插件自己在监听的残留服务。
- 每个插件用互不重叠的端口段(如 ASR 18080~18084 / MT 18090~18094 / TTS 18100~18104), 避免插件之间抢端口。
- 非数字 / 越界(1024~65535)→ 回落默认值并留日志(不要静默用非法值)。
- 起服务前先探测占用;被外人占用时给出明确错误(含
netstat -ano | findstr :<port>指引), 不要静默失败。 - 改端口属于「服务行为变化」:只有服务正在运行才重启;没跑就只记录,下次启动生效。
3) 计算设备(device)
{ "key": "device", "label": "计算设备", "type": "select",
"choices": ["auto", "vulkan", "cpu"], "default": "auto" }choices只能声明你的运行时真实支持的后端。 (只带 Vulkan+CPU 的llama.cpp运行时就不要给cuda,否则用户选了就崩。 标准标识与宿主自动美化见 config_schema § 计算设备。)- 设备变更 → 只有服务在跑才重启;没跑就只记录并上报事件,下次启动生效。
4) 状态事件(宿主会展示成提示 / toast)
self.emit_event("device.switching", {"device": dev, "reason": f"计算设备变更为 {dev}"})
# ……重启 / 等就绪……
self.emit_event("device.ready", {"device": dev})宿主优先展示 reason(缺省时回落为「正在切换:计算设备变更为 X」), 并在此期间把设置页设备行标为「切换中」、禁用下拉。
5) 前置运行时:用 host.runtime_dir() 定位(0.1.x 才是 plugin_dir())
0.2.0 起运行库统一落在 data/local-runtime/runtimes/,不再有承载插件。 取用方式:
from pathlib import Path
rt = self.host.runtime_dir("llama_runtime") # 未就绪返回 ""
exe = Path(rt) / "llama-server.exe" if rt else None并在 mod.json 声明 requires_runtime;大体积原生运行库不要打进包。
定位兄弟插件(非运行库)才用
host.plugin_dir("<插件 id>"): 工坊安装的目录名 = 插件 id(data/workshop/<id>),不是 zip 里的文件夹名。 0.1.x 时代运行库也是插件携带的,所以老文档里会看到host.plugin_dir("workshop.common_llama") / "llama" / ...——0.2.0 已没有这些承载插件, 那份写法一律返回空路径。
6) 子进程生命周期
shutdown() 必须 kill 子进程(宿主在退出、点「释放显存」、禁用插件、改配置时都会调它), 否则会留下占端口与显存的孤儿进程。
def shutdown(self) -> None:
self._stop_server() # terminate → wait(timeout) → kill 兜底
super().shutdown()停止时留一条日志(已停止 llama-server PID=…):宿主「释放显存」会如实回报你到底停没停。
归属校验:记住自己起的 PID;句柄失效(被外部杀掉)时按端口核对归属再清残留, 不要把外部残留服务当"自己人"复用(否则界面显示 cpu、实际仍跑 vulkan 参数)。
并发安全:用一个 RLock 串行化「异步 initialize 线程」与「宿主线程」对服务的操作。 两者并发会把 self._proc 置 None,导致轮询解引用崩溃并冒到宿主 Bridge() 构造(实测复现)。
7) 状态留在服务端的,必须自己落盘
本地推理服务的"注册表"往往只在内存里(重启就没了)。如果你的服务支持运行时注册 音色 / 自定义项,请把原始素材(如参考音频 + ref_text)存到插件私有数据目录, 并在服务就绪后自动补注册。
否则用户会抱怨「每次重启都要重新配置一遍」 (实测 Qwen3-TTS 的 tts-server:注册后重启 → GET /v1/audio/voices 返回空)。
只读方法(list_voices() 等)在服务未启动时应返回本地保存的内容,而不是拉起服务。
自测清单
- [ ] 打开设置页 / 切到本插件 Tab → 任务管理器里没有任何
llama-server/tts-server - [ ] 开启音轨后才启动服务;界面显示「已就绪」时服务确实在监听端口
- [ ] 改「服务端口」为
1808x/ 改「计算设备」为 cpu → 服务按新参数重启,界面有「切换中」提示 - [ ] 服务没跑时改端口/设备 → 没有任何进程被拉起
- [ ] 把插件目录改名 / 缺前置插件 → 卡片显示「需要前置插件」,原因是人话
- [ ] 点「释放显存」→ 日志有
已停止 <server> PID=…,界面回报该引擎已停 - [ ] 退出应用后没有孤儿进程占端口 / 占显存
参考实现
| 目录 | 说明 |
|---|---|
extensions/community_qwen3_asr/ | llama.cpp 服务型(惰性 + 端口顺延) |
extensions/community_hy_mt_7b/ | llama.cpp 服务型 |
extensions/community_qwen3_tts/ | tts-server + 音色持久化 + 变体(完整范例,用内置 tts_server_runtime) |
