Skip to content

本地服务型引擎强制契约(ASR / MT / TTS 通用) ​

如果你的插件要启动本地推理服务(llama-server / tts-server / 自研 HTTP 子进程), 必须遵守以下契约。违反它们会直接变成用户可见的 BUG (典型抱怨:「我没开任何音轨,怎么只是切到设置页就在加载模型?」「端口被占用,服务起不来」)。


0) 强烈推荐:继承标准基类(零样板代码) ​

从 TransBox v0.2.0 起,框架在 transbox.plugins 提供了专为本地模型引擎打造的标准基类:

  • BaseLocalServicePlugin: 本地服务通用基类
  • BaseLocalASRProvider: 本地语音识别引擎基类
  • BaseLocalMTProvider: 本地机器翻译引擎基类
  • BaseLocalTTSProvider: 本地语音合成引擎基类

继承这些基类,你无需手动编写任何设备切换事件、线程重启或生命周期模板代码,开箱即得:

  1. 自动惰性启动:initialize() 绝不提前拉起服务;ensure_loaded() 或执行推理时自动按需启动。
  2. 计算设备自动规范化:自动处理 cuda、cuda13、vulkan、rocm、cpu、auto 的映射与 GPU 检测。
  3. 运行中设备热切与事件派发:改配置时若服务在跑,自动派发 device.switching(前端置灰并转圈)、在后台线程重启服务、就绪后派发 device.ready;若服务未运行,仅记账不吃显存。
  4. 活跃音轨热重载:若当前模型正在为已启用的音轨服务,宿主自动热重载音轨流水线,用户体验无缝。
  5. 残留端口清理:提供 self.kill_port_conflict(port) 自动终结旧孤儿进程。
  6. 显存释放与优雅退出:宿主 shutdown() 时自动调用 stop_service() 释放子进程和显存。

示例:极简第三方 ASR 插件开发 ​

python
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) ​

python
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, sr

1) 惰性启动:initialize() 不得启动服务 ​

钩子应该做什么禁止做什么
initialize(config)记配置、解析模型目录、检查权重是否在磁盘、上报状态启动子进程 / 加载权重 / 阻塞等待就绪
ensure_loaded()真正要使用时(宿主在音轨开启、流水线启动、点测试连接时调用)拉起服务并等就绪无(可以阻塞到就绪:宿主已在后台线程调用)
check_ready()判断「能不能用」:运行时存在 + 权重齐备(+ Key 已配)把「服务未启动」判成未就绪(宿主会因此拒绝开启音轨)
list_voices() 等只读方法返回已有缓存 / 空表拉起服务

为什么:宿主的只读查询(设置页状态、能力探测、音色列表、模型卡检查)不会创建实例; 但只要有任何路径创建了实例(前置插件 / 宿主引擎解析 / 插件动作),initialize() 就会跑。 若它顺手启动服务,用户「只是打开设置页或切到该引擎页签」就会白等数秒并吃掉数 GB 显存。

其余入口自行兜底:transcribe() / translate() / synthesize() / stream_tts() / register_voice() / remove_voice() 入口处应确保服务已就绪(已就绪则零开销直接返回), 这样宿主不先调 ensure_loaded() 也能工作。

python
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) 服务端口:可配置 + 被占用自动顺延 ​

json
{
  "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) ​

json
{ "key": "device", "label": "计算设备", "type": "select",
  "choices": ["auto", "vulkan", "cpu"], "default": "auto" }
  • choices 只能声明你的运行时真实支持的后端。 (只带 Vulkan+CPU 的 llama.cpp 运行时就不要给 cuda,否则用户选了就崩。 标准标识与宿主自动美化见 config_schema § 计算设备。)
  • 设备变更 → 只有服务在跑才重启;没跑就只记录并上报事件,下次启动生效。

4) 状态事件(宿主会展示成提示 / toast) ​

python
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/,不再有承载插件。 取用方式:

python
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 子进程(宿主在退出、点「释放显存」、禁用插件、改配置时都会调它), 否则会留下占端口与显存的孤儿进程。

python
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)

相关 ​

TransBox - 赋能全人类无界限跨语言交流