From 04b478225f67fd0892467d9810d26276ab25631d Mon Sep 17 00:00:00 2001
From: LauraGPT <18321252+LauraGPT@users.noreply.github.com>
Date: Sun, 30 Aug 2026 16:28:33 +0000
Subject: [PATCH] feat: add MOSS diarization backend
Signed-off-by: LauraGPT <18321252+LauraGPT@users.noreply.github.com>
---
README.md | 19 +++-
README_zh.md | 19 +++-
funclip/launch.py | 49 ++++------
funclip/model_selection.py | 89 +++++++++++++++++++
funclip/utils/trans_utils.py | 9 +-
funclip/videoclipper.py | 13 ++-
requirements.txt | 2 +-
tests/test_funasr_requirement.py | 30 ++++++-
tests/test_model_selection.py | 67 +++++++++++++-
tests/test_recognition_result_compat.py | 113 +++++++++++++++++++++++-
10 files changed, 363 insertions(+), 47 deletions(-)
create mode 100644 funclip/model_selection.py
diff --git a/README.md b/README.md
index 24f2b67..993665d 100644
--- a/README.md
+++ b/README.md
@@ -34,6 +34,7 @@
## What's New🚀
+- 2026/08/30 FunClip adds the third-party [OpenMOSS/MOSS-Transcribe-Diarize](https://github.com/OpenMOSS/MOSS-Transcribe-Diarize) model as an opt-in `moss` path. It provides long-form ASR, speaker identity, and segment timestamps without external `vad_model` or `spk_model`, through FunASR's vLLM adapter. OpenMOSS owns and maintains the model; FunClip only integrates its published contract.
- 2026/08/03 [FunClip v2.1.1](https://github.com/modelscope/FunClip/releases/tag/v2.1.1) fixes fresh Gradio 4 installations by constraining the incompatible Starlette 1.x runtime, keeps `--listen` container startup private unless `--share` is explicitly requested, makes transcript matching case-insensitive, and adds MiniMax M2.7 provider routes.
- 2026/07/24 [FunClip v2.1.0](https://github.com/modelscope/FunClip/releases/tag/v2.1.0) is the first versioned GitHub release. It packages the current Fun-ASR-Nano, SenseVoice, Paraformer, and LLM-assisted clipping application as checksum-protected source archives for a stable rollback point.
- 2026/05/20 FunClip now supports [Fun-ASR-Nano](https://huggingface.co/FunAudioLLM/Fun-ASR-Nano-2512) and [SenseVoice](https://huggingface.co/FunAudioLLM/SenseVoiceSmall) models. The `fun-asr-nano` option loads the flagship Fun-ASR-Nano-2512 checkpoint for Mandarin, English, Japanese, 7 Chinese dialect groups, and 26 regional accents; it does not load the separate 31-language Fun-ASR-MLT-Nano-2512 checkpoint. SenseVoice adds emotion recognition and audio event detection. Run `python funclip/launch.py -m fun-asr-nano` or `-m sensevoice` to try. For precise text-based clipping, use Paraformer because the released Nano checkpoint does not provide reliable character-level timestamps.
@@ -80,7 +81,7 @@ For a versioned snapshot, download [FunClip-2.1.1.tar.gz](https://github.com/mod
FunClip v2.1.1 supports Gradio 4 with `starlette<1.0`. Existing installations should run `pip install -U -r requirements.txt` before restarting. Container users can pass `--listen` to bind all interfaces; a public Gradio sharing tunnel is created only when `--share` is also supplied.
-FunClip's Fun-ASR-Nano, SenseVoice, and subtitle compatibility paths require `funasr>=1.3.29`. This release returns every SenseVoice VAD region through `sentence_info` when token timestamps are unavailable, so clipping and subtitle clients receive segment boundaries instead of an empty timeline. It also includes the real-time final-text and short-tail fixes from 1.3.28. If you installed FunClip before this requirement was updated, run `pip install -U "funasr>=1.3.29"` before starting the Gradio service. [Release notes](https://github.com/modelscope/FunASR/releases/tag/v1.3.29) · [PyPI](https://pypi.org/project/funasr/1.3.29/)
+FunClip's current model and subtitle compatibility paths require `funasr>=1.4.9`. This includes the MOSS vLLM adapter, long-audio generation controls, normalized `sentence_info` speaker segments, and the earlier SenseVoice and realtime fixes. If you installed FunClip before this requirement was updated, run `pip install -U "funasr>=1.4.9"` before starting the Gradio service. [Release notes](https://github.com/modelscope/FunASR/releases/tag/v1.4.9) · [PyPI](https://pypi.org/project/funasr/1.4.9/)
### imagemagick install (Optional)
@@ -119,6 +120,7 @@ python funclip/launch.py
# '-m fun-asr-nano' for the flagship Fun-ASR-Nano model (Mandarin, English,
# Japanese, 7 Chinese dialect groups, and 26 regional accents)
# '-m sensevoice' for SenseVoice model (multilingual ASR + emotion + audio event detection)
+# '--model moss' for OpenMOSS long-form ASR + speaker identity + timestamps
# '-l en' for English audio recognize
# '-p xxx' for setting port number
# '-s True' for establishing service for public accessing
@@ -131,8 +133,23 @@ python funclip/launch.py
| Default Chinese video clipping with Paraformer | `python funclip/launch.py` |
| High-accuracy transcription with the flagship Fun-ASR-Nano checkpoint (use Paraformer for precise text-based clipping) | `python funclip/launch.py -m fun-asr-nano` |
| Multilingual ASR with emotion and audio event tags | `python funclip/launch.py -m sensevoice` |
+| MOSS through a local vLLM transcription service | `python funclip/launch.py --model moss --moss-backend vllm` |
| English video clipping with the Paraformer English model | `python funclip/launch.py -l en` |
+#### MOSS-Transcribe-Diarize backend
+
+[MOSS-Transcribe-Diarize](https://github.com/OpenMOSS/MOSS-Transcribe-Diarize) is a third-party OpenMOSS model, not a FunASR or FunClip model. FunClip pins the published Hugging Face object `OpenMOSS-Team/MOSS-Transcribe-Diarize` at revision `e8681d68e7042738ffca8ac8212bc8fcb1131ab8`. Start and verify the vLLM service using the [bilingual production guide](https://www.funasr.com/en/deploy/moss-transcribe-diarize.html), then run:
+
+```shell
+# vLLM is the default MOSS backend and defaults to http://127.0.0.1:8898/v1
+python funclip/launch.py --model moss --moss-backend vllm
+
+# Optional bearer credentials are read from the environment, not the command line
+MOSS_API_KEY=replace-me python funclip/launch.py --model moss
+```
+
+MOSS performs segmentation and speaker diarization end to end. Do not attach an external `vad_model` or `spk_model`, because chunking would break global speaker identity. Its timestamps are segment-level: SRT, speaker clipping (`spkS01`, `spkS02`, ...), and LLM timestamp clipping are supported, while precise arbitrary text clipping still requires Paraformer's token timestamps. FunClip currently exposes the vLLM path because it is compatible with the standard Transformers 4.x environment and has an end-to-end tested OpenAI transcription contract.
+
If you only need offline speech transcription on CPU or edge devices and do not need FunClip's video clipping UI, use the FunASR llama.cpp / GGUF runtime instead: [funasr.com/llama-cpp](https://www.funasr.com/llama-cpp.html) · [Fun-ASR-Nano-GGUF](https://huggingface.co/FunAudioLLM/Fun-ASR-Nano-GGUF) · [SenseVoiceSmall-GGUF](https://huggingface.co/FunAudioLLM/SenseVoiceSmall-GGUF).
then visit ```localhost:7860``` you will get a Gradio service like below and you can use FunClip following the steps:
diff --git a/README_zh.md b/README_zh.md
index 03db364..5c10ef5 100644
--- a/README_zh.md
+++ b/README_zh.md
@@ -36,6 +36,7 @@
## 近期更新🚀
+- 2026/08/30 FunClip 新增第三方 [OpenMOSS/MOSS-Transcribe-Diarize](https://github.com/OpenMOSS/MOSS-Transcribe-Diarize) 可选路径。它通过 FunASR 的 vLLM 适配器提供长音频 ASR、说话人身份和分段时间戳,不需要外部 `vad_model` 或 `spk_model`。模型归 OpenMOSS 所有并由其维护,FunClip 只集成公开接口。
- 2026/08/03 [FunClip v2.1.1](https://github.com/modelscope/FunClip/releases/tag/v2.1.1) 修复 Gradio 4 新安装环境与 Starlette 1.x 的不兼容问题;容器使用 `--listen` 时不会自动创建公网分享链接;文本匹配改为大小写不敏感,并新增 MiniMax M2.7 模型路由。
- 2026/07/24 [FunClip v2.1.0](https://github.com/modelscope/FunClip/releases/tag/v2.1.0) 是首个带版本号的 GitHub Release,将当前支持 Fun-ASR-Nano、SenseVoice、Paraformer 与大模型智能剪辑的应用打包为带 SHA-256 校验的源码归档,提供稳定的下载与回退节点。
- 2026/05/20 FunClip 现在支持 [Fun-ASR-Nano](https://huggingface.co/FunAudioLLM/Fun-ASR-Nano-2512) 与 [SenseVoice](https://huggingface.co/FunAudioLLM/SenseVoiceSmall) 模型。`fun-asr-nano` 选项加载旗舰版 Fun-ASR-Nano-2512,支持普通话、英语、日语、7 类中文方言和 26 种地域口音;该选项不会加载独立的 31 语种 Fun-ASR-MLT-Nano-2512。SenseVoice 支持多语种识别,并额外输出情绪识别与音频事件检测标签。可通过 `python funclip/launch.py -m fun-asr-nano` 或 `python funclip/launch.py -m sensevoice` 启动体验。需要精确按文本裁剪时请使用 Paraformer,因为当前发布的 Nano checkpoint 不提供可靠的字符级时间戳。
@@ -81,7 +82,7 @@ pip install -r ./requirements.txt
FunClip v2.1.1 在 Gradio 4 环境中要求 `starlette<1.0`。已有安装请在重启前执行 `pip install -U -r requirements.txt`。容器用户可用 `--listen` 监听全部网卡;只有同时显式传入 `--share` 才会创建 Gradio 公网分享链接。
-FunClip 的 Fun-ASR-Nano、SenseVoice 与字幕兼容路径需要 `funasr>=1.3.29`。当 SenseVoice 没有 token 时间戳时,该版本会通过 `sentence_info` 返回每个 VAD 语音区域,让智能剪辑与字幕客户端获得分段边界,而不再收到空时间线;同时包含 1.3.28 的实时最终文本和短尾语音修复。如果你之前已经安装过 FunClip,请先执行 `pip install -U "funasr>=1.3.29"`,再启动 Gradio 服务。[发布说明](https://github.com/modelscope/FunASR/releases/tag/v1.3.29) · [PyPI](https://pypi.org/project/funasr/1.3.29/)
+FunClip 当前模型与字幕兼容路径需要 `funasr>=1.4.9`,其中包括 MOSS 的 vLLM 适配器、长音频生成上限、归一化的 `sentence_info` 说话人分段,以及此前的 SenseVoice 和实时修复。如果你之前已经安装过 FunClip,请先执行 `pip install -U "funasr>=1.4.9"`,再启动 Gradio 服务。[发布说明](https://github.com/modelscope/FunASR/releases/tag/v1.4.9) · [PyPI](https://pypi.org/project/funasr/1.4.9/)
### 安装imagemagick(可选)
@@ -119,6 +120,7 @@ python funclip/launch.py
# '-m fun-asr-nano' 使用旗舰版 Fun-ASR-Nano(普通话、英语、日语、
# 7 类中文方言和 26 种地域口音)
# '-m sensevoice' 使用 SenseVoice 模型(多语种 ASR + 情绪识别 + 音频事件检测)
+# '--model moss' 使用 OpenMOSS 长音频 ASR + 说话人身份 + 时间戳
# '-l en' for English audio recognize
# '-p xxx' for setting port number
# '-s True' for establishing service for public accessing
@@ -130,8 +132,23 @@ python funclip/launch.py
| 默认中文视频裁剪,使用 Paraformer | `python funclip/launch.py` |
| 使用旗舰版 Fun-ASR-Nano 进行高精度转写(精确按文本裁剪请使用 Paraformer) | `python funclip/launch.py -m fun-asr-nano` |
| 使用 SenseVoice 进行多语种识别、情绪识别和音频事件检测 | `python funclip/launch.py -m sensevoice` |
+| 通过本地 vLLM 转写服务使用 MOSS | `python funclip/launch.py --model moss --moss-backend vllm` |
| 使用 Paraformer 英文模型裁剪英文视频 | `python funclip/launch.py -l en` |
+#### MOSS-Transcribe-Diarize 后端
+
+[MOSS-Transcribe-Diarize](https://github.com/OpenMOSS/MOSS-Transcribe-Diarize) 是 OpenMOSS 维护的第三方模型,不属于 FunASR 或 FunClip。FunClip 固定使用 Hugging Face 模型 `OpenMOSS-Team/MOSS-Transcribe-Diarize` 的 revision `e8681d68e7042738ffca8ac8212bc8fcb1131ab8`。先按[双语生产部署指南](https://www.funasr.com/en/deploy/moss-transcribe-diarize.html)启动并验证 vLLM 服务,再运行:
+
+```shell
+# vLLM 是默认后端,默认地址为 http://127.0.0.1:8898/v1
+python funclip/launch.py --model moss --moss-backend vllm
+
+# 可选凭据只从环境变量读取,不放入命令行参数
+MOSS_API_KEY=replace-me python funclip/launch.py --model moss
+```
+
+MOSS 端到端完成分段与说话人识别,不要再配置外部 `vad_model` 或 `spk_model`,否则切块会破坏全局说话人身份。它提供段级时间戳,适合生成 SRT、按说话人(`spkS01`、`spkS02` 等)剪辑以及 LLM 按时间剪辑;任意文本的精确剪辑仍应使用带 token 时间戳的 Paraformer。FunClip 当前只开放 vLLM 路径,因为它兼容标准 Transformers 4.x 环境,并且已经通过 OpenAI 转写接口的端到端测试。
+
如果你只需要在 CPU 或边缘设备上离线转写语音,而不需要 FunClip 的视频剪辑界面,请优先使用 FunASR llama.cpp / GGUF 运行时:[funasr.com/llama-cpp](https://www.funasr.com/llama-cpp.html) · [Fun-ASR-Nano-GGUF](https://huggingface.co/FunAudioLLM/Fun-ASR-Nano-GGUF) · [SenseVoiceSmall-GGUF](https://huggingface.co/FunAudioLLM/SenseVoiceSmall-GGUF)。
随后在浏览器中访问```localhost:7860```即可看到如下图所示的界面,按如下步骤即可进行视频剪辑
diff --git a/funclip/launch.py b/funclip/launch.py
index 21d0734..cfca30d 100644
--- a/funclip/launch.py
+++ b/funclip/launch.py
@@ -12,6 +12,7 @@
import gradio as gr
from funasr import AutoModel
from videoclipper import VideoClipper
+from model_selection import create_asr_model as _create_asr_model
from llm.openai_api import openai_call
from llm.qwen_api import call_qwen_model
from llm.g4f_openai_api import g4f_openai_call
@@ -22,48 +23,34 @@
from launch_config import build_launch_kwargs
-def create_asr_model(model_name, lang, auto_model_cls=AutoModel):
- if model_name == "fun-asr-nano":
- return auto_model_cls(
- model="FunAudioLLM/Fun-ASR-Nano-2512",
- trust_remote_code=True,
- remote_code="./model.py",
- vad_model="fsmn-vad",
- vad_kwargs={"max_single_segment_time": 30000},
- spk_model="cam++",
- hub="hf",
- )
- if model_name == "sensevoice":
- return auto_model_cls(
- model="iic/SenseVoiceSmall",
- vad_model="fsmn-vad",
- vad_kwargs={"max_single_segment_time": 30000},
- spk_model="cam++",
- )
-
- paraformer_model = (
- "iic/speech_seaco_paraformer_large_asr_nat-zh-cn-16k-common-vocab8404-pytorch"
- if lang == "zh"
- else "iic/speech_paraformer_asr-en-16k-vocab4199-pytorch"
- )
- return auto_model_cls(
- model=paraformer_model,
- vad_model="damo/speech_fsmn_vad_zh-cn-16k-common-pytorch",
- punc_model="damo/punc_ct-transformer_zh-cn-common-vocab272727-pytorch",
- spk_model="damo/speech_campplus_sv_zh-cn_16k-common",
+def create_asr_model(model_name, lang, auto_model_cls=AutoModel, **kwargs):
+ return _create_asr_model(
+ model_name, lang, auto_model_cls=auto_model_cls, **kwargs
)
if __name__ == "__main__":
parser = argparse.ArgumentParser(description='argparse testing')
parser.add_argument('--lang', '-l', type=str, default = "zh", help="language mode; selects the Paraformer checkpoint but does not override --model")
- parser.add_argument('--model', '-m', type=str, default="paraformer", choices=["paraformer", "fun-asr-nano", "sensevoice"], help="ASR model: paraformer, fun-asr-nano, or sensevoice (takes precedence over --lang)")
+ parser.add_argument('--model', '-m', type=str, default="paraformer", choices=["paraformer", "fun-asr-nano", "sensevoice", "moss"], help="ASR model: paraformer, fun-asr-nano, sensevoice, or moss (takes precedence over --lang)")
+ parser.add_argument('--moss-backend', choices=["vllm"], default="vllm", help="MOSS runtime backed by an existing vLLM transcription service")
+ parser.add_argument('--moss-base-url', default="http://127.0.0.1:8898/v1", help="OpenAI-compatible base URL for the MOSS vLLM service")
+ parser.add_argument('--moss-api-key-env', default="MOSS_API_KEY", help="environment variable containing the optional MOSS service API key")
+ parser.add_argument('--moss-max-tokens', type=int, default=8192, help="MOSS generation limit for long recordings")
parser.add_argument('--share', '-s', action='store_true', help="if to establish gradio share link")
parser.add_argument('--port', '-p', type=int, default=7860, help='port number')
parser.add_argument('--listen', action='store_true', help="if to listen to all hosts")
args = parser.parse_args()
- funasr_model = create_asr_model(args.model, args.lang)
+ moss_api_key = os.environ.get(args.moss_api_key_env) if args.moss_api_key_env else None
+ funasr_model = create_asr_model(
+ args.model,
+ args.lang,
+ moss_backend=args.moss_backend,
+ moss_base_url=args.moss_base_url,
+ moss_api_key=moss_api_key,
+ moss_max_tokens=args.moss_max_tokens,
+ )
audio_clipper = VideoClipper(funasr_model)
audio_clipper.lang = args.lang
diff --git a/funclip/model_selection.py b/funclip/model_selection.py
new file mode 100644
index 0000000..bcecf29
--- /dev/null
+++ b/funclip/model_selection.py
@@ -0,0 +1,89 @@
+MOSS_MODEL = "OpenMOSS-Team/MOSS-Transcribe-Diarize"
+MOSS_MODEL_REVISION = "e8681d68e7042738ffca8ac8212bc8fcb1131ab8"
+MOSS_DEFAULT_BASE_URL = "http://127.0.0.1:8898/v1"
+
+
+def _moss_model_kwargs(
+ backend="vllm",
+ base_url=MOSS_DEFAULT_BASE_URL,
+ api_key=None,
+ max_tokens=8192,
+):
+ backend = str(backend).lower()
+ if backend != "vllm":
+ raise ValueError(f"unsupported MOSS backend: {backend}")
+
+ max_tokens = int(max_tokens)
+ if max_tokens <= 0:
+ raise ValueError("MOSS generation token limit must be positive")
+
+ kwargs = {
+ "model": MOSS_MODEL,
+ "model_revision": MOSS_MODEL_REVISION,
+ "backend": backend,
+ "disable_update": True,
+ }
+ base_url = str(base_url or "").rstrip("/")
+ if not base_url:
+ raise ValueError("--moss-base-url is required for remote MOSS backends")
+
+ kwargs.update(
+ {
+ "vllm_base_url": base_url,
+ "vllm_model": "moss-transcribe-diarize",
+ "vllm_response_format": "json",
+ "max_completion_tokens": max_tokens,
+ }
+ )
+ if api_key:
+ kwargs["vllm_api_key"] = api_key
+ return kwargs
+
+
+def create_asr_model(
+ model_name,
+ lang,
+ auto_model_cls,
+ moss_backend="vllm",
+ moss_base_url=MOSS_DEFAULT_BASE_URL,
+ moss_api_key=None,
+ moss_max_tokens=8192,
+):
+ if model_name == "moss":
+ return auto_model_cls(
+ **_moss_model_kwargs(
+ backend=moss_backend,
+ base_url=moss_base_url,
+ api_key=moss_api_key,
+ max_tokens=moss_max_tokens,
+ )
+ )
+ if model_name == "fun-asr-nano":
+ return auto_model_cls(
+ model="FunAudioLLM/Fun-ASR-Nano-2512",
+ trust_remote_code=True,
+ remote_code="./model.py",
+ vad_model="fsmn-vad",
+ vad_kwargs={"max_single_segment_time": 30000},
+ spk_model="cam++",
+ hub="hf",
+ )
+ if model_name == "sensevoice":
+ return auto_model_cls(
+ model="iic/SenseVoiceSmall",
+ vad_model="fsmn-vad",
+ vad_kwargs={"max_single_segment_time": 30000},
+ spk_model="cam++",
+ )
+
+ paraformer_model = (
+ "iic/speech_seaco_paraformer_large_asr_nat-zh-cn-16k-common-vocab8404-pytorch"
+ if lang == "zh"
+ else "iic/speech_paraformer_asr-en-16k-vocab4199-pytorch"
+ )
+ return auto_model_cls(
+ model=paraformer_model,
+ vad_model="damo/speech_fsmn_vad_zh-cn-16k-common-pytorch",
+ punc_model="damo/punc_ct-transformer_zh-cn-common-vocab272727-pytorch",
+ spk_model="damo/speech_campplus_sv_zh-cn_16k-common",
+ )
diff --git a/funclip/utils/trans_utils.py b/funclip/utils/trans_utils.py
index 118eabb..3fd2dd4 100644
--- a/funclip/utils/trans_utils.py
+++ b/funclip/utils/trans_utils.py
@@ -33,6 +33,8 @@ def proc(raw_text, timestamp, dest_text, lang='zh'):
ld = len(dest_text.split())
normalized_raw_text = raw_text.translate(ASCII_LOWER_TABLE)
normalized_dest_text = dest_text.translate(ASCII_LOWER_TABLE)
+ if not normalized_dest_text or not timestamp:
+ return []
mi, ts = [], []
offset = 0
while True:
@@ -43,8 +45,11 @@ def proc(raw_text, timestamp, dest_text, lang='zh'):
if fi == -1:
break
offset = fi + len(normalized_dest_text)
+ end_index = ti + ld - 1
+ if ti >= len(timestamp) or end_index >= len(timestamp):
+ continue
mi.append(fi)
- ts.append([timestamp[ti][0]*16, timestamp[ti+ld-1][1]*16])
+ ts.append([timestamp[ti][0]*16, timestamp[end_index][1]*16])
return ts
@@ -54,7 +59,7 @@ def proc_spk(dest_spk, sd_sentences):
d_start = d['timestamp'][0][0]
d_end = d['timestamp'][-1][1]
spkid=dest_spk[3:]
- if str(d['spk']) == spkid and d_end-d_start>999:
+ if str(d['spk']) == spkid and d_end > d_start:
ts.append([d_start*16, d_end*16])
return ts
diff --git a/funclip/videoclipper.py b/funclip/videoclipper.py
index e3c3be0..fd6007f 100644
--- a/funclip/videoclipper.py
+++ b/funclip/videoclipper.py
@@ -25,6 +25,8 @@
MAX_SUBTITLE_DURATION_MS = 8000
MAX_SUBTITLE_TOKENS = 30
SENSEVOICE_TAG_RE = re.compile(r"<\|[^|>]+\|>")
+MOSS_SEGMENT_MARKER_RE = re.compile(r"\[\d+(?:\.\d+)?\]\[S\d+\]")
+MOSS_FINAL_TIMESTAMP_RE = re.compile(r"\[\d+(?:\.\d+)?\]\s*$")
def _is_valid_timestamp(timestamp):
@@ -93,9 +95,14 @@ def _normalize_recognition_result(result):
text = _clean_recognition_text(
result.get("text") or result.get("text_tn") or result.get("raw_text") or ""
)
- raw_text = _clean_recognition_text(
- result.get("raw_text") or result.get("text_tn") or text
- )
+ raw_value = result.get("raw_text") or result.get("text_tn") or text
+ raw_text = _clean_recognition_text(raw_value)
+ if isinstance(raw_value, str) and MOSS_SEGMENT_MARKER_RE.search(raw_value):
+ if not MOSS_FINAL_TIMESTAMP_RE.search(raw_value):
+ raise RuntimeError(
+ "truncated MOSS transcript: increase --moss-max-tokens and retry"
+ )
+ raw_text = text
timestamp = result.get("timestamp") or result.get("timestamps") or []
sentence_info = []
diff --git a/requirements.txt b/requirements.txt
index d479796..ee6b781 100644
--- a/requirements.txt
+++ b/requirements.txt
@@ -1,7 +1,7 @@
librosa
soundfile
scikit-learn>=1.3.2
-funasr>=1.3.29
+funasr>=1.4.9
transformers>=4.32.0,<5.0
huggingface_hub>=0.19.3,<1.0
moviepy==1.0.3
diff --git a/tests/test_funasr_requirement.py b/tests/test_funasr_requirement.py
index d7959c8..93a2058 100644
--- a/tests/test_funasr_requirement.py
+++ b/tests/test_funasr_requirement.py
@@ -1,27 +1,49 @@
from pathlib import Path
-
ROOT = Path(__file__).resolve().parents[1]
def test_funasr_minimum_version_matches_current_model_paths():
requirements = (ROOT / "requirements.txt").read_text()
- assert "funasr>=1.3.29" in requirements
+ assert "funasr>=1.4.9" in requirements
assert "funasr>=1.1.2" not in requirements
assert "funasr>=1.3.26" not in requirements
assert "funasr>=1.3.27" not in requirements
assert "funasr>=1.3.28" not in requirements
+ assert "funasr>=1.3.29" not in requirements
def test_readmes_explain_upgrade_for_existing_installs():
for readme in ["README.md", "README_zh.md"]:
text = (ROOT / readme).read_text()
- assert 'pip install -U "funasr>=1.3.29"' in text
+ assert 'pip install -U "funasr>=1.4.9"' in text
assert "funasr>=1.3.26" not in text
assert "funasr>=1.3.27" not in text
assert "funasr>=1.3.28" not in text
+ assert "funasr>=1.3.29" not in text
assert "sentence_info" in text
- assert "https://github.com/modelscope/FunASR/releases/tag/v1.3.29" in text
+ assert "https://github.com/modelscope/FunASR/releases/tag/v1.4.9" in text
+
+
+def test_readmes_document_moss_ownership_backends_and_timestamp_boundary():
+ required = [
+ "OpenMOSS/MOSS-Transcribe-Diarize",
+ "OpenMOSS-Team/MOSS-Transcribe-Diarize",
+ "--model moss",
+ "--moss-backend vllm",
+ "MOSS_API_KEY",
+ "e8681d68e7042738ffca8ac8212bc8fcb1131ab8",
+ "https://www.funasr.com/en/deploy/moss-transcribe-diarize.html",
+ ]
+ for readme in ["README.md", "README_zh.md"]:
+ text = (ROOT / readme).read_text()
+ for item in required:
+ assert item in text
+ assert "vad_model" in text
+ assert "spk_model" in text
+ assert "Paraformer" in text
+ assert "--moss-backend sglang" not in text
+ assert "--moss-backend hf" not in text
def test_readmes_route_edge_asr_users_to_gguf_runtime():
diff --git a/tests/test_model_selection.py b/tests/test_model_selection.py
index 86a494b..38bb9e0 100644
--- a/tests/test_model_selection.py
+++ b/tests/test_model_selection.py
@@ -2,11 +2,10 @@
import unittest
from pathlib import Path
-
ROOT = Path(__file__).resolve().parents[1]
sys.path.insert(0, str(ROOT / "funclip"))
-from launch import create_asr_model
+from model_selection import create_asr_model
class RecordingAutoModel:
@@ -15,6 +14,70 @@ def __init__(self, **kwargs):
class TestModelSelection(unittest.TestCase):
+ def test_moss_defaults_to_local_vllm_service(self):
+ model = create_asr_model(
+ "moss", "zh", auto_model_cls=RecordingAutoModel
+ )
+
+ self.assertEqual(model.kwargs["backend"], "vllm")
+ self.assertEqual(model.kwargs["vllm_base_url"], "http://127.0.0.1:8898/v1")
+ self.assertEqual(model.kwargs["vllm_model"], "moss-transcribe-diarize")
+ self.assertEqual(model.kwargs["vllm_response_format"], "json")
+ self.assertEqual(model.kwargs["max_completion_tokens"], 8192)
+ self.assertNotIn("vad_model", model.kwargs)
+ self.assertNotIn("spk_model", model.kwargs)
+
+ def test_moss_vllm_uses_supported_json_response(self):
+ model = create_asr_model(
+ "moss",
+ "en",
+ auto_model_cls=RecordingAutoModel,
+ moss_backend="vllm",
+ moss_base_url="http://127.0.0.1:8898/v1",
+ moss_api_key="secret",
+ moss_max_tokens=12288,
+ )
+
+ self.assertEqual(model.kwargs["backend"], "vllm")
+ self.assertEqual(model.kwargs["vllm_base_url"], "http://127.0.0.1:8898/v1")
+ self.assertEqual(model.kwargs["vllm_model"], "moss-transcribe-diarize")
+ self.assertEqual(model.kwargs["vllm_response_format"], "json")
+ self.assertEqual(model.kwargs["vllm_api_key"], "secret")
+ self.assertEqual(model.kwargs["max_completion_tokens"], 12288)
+ self.assertNotIn("vad_model", model.kwargs)
+ self.assertNotIn("spk_model", model.kwargs)
+
+ def test_moss_remote_backend_requires_base_url(self):
+ with self.assertRaisesRegex(ValueError, "--moss-base-url is required"):
+ create_asr_model(
+ "moss",
+ "zh",
+ auto_model_cls=RecordingAutoModel,
+ moss_backend="vllm",
+ moss_base_url="",
+ )
+
+ def test_moss_rejects_backends_not_packaged_by_funasr_1_4_9(self):
+ for backend in ("hf", "sglang"):
+ with self.subTest(backend=backend), self.assertRaisesRegex(
+ ValueError, "unsupported MOSS backend"
+ ):
+ create_asr_model(
+ "moss",
+ "zh",
+ auto_model_cls=RecordingAutoModel,
+ moss_backend=backend,
+ )
+
+ def test_moss_rejects_unknown_backend_before_model_loading(self):
+ with self.assertRaisesRegex(ValueError, "unsupported MOSS backend"):
+ create_asr_model(
+ "moss",
+ "zh",
+ auto_model_cls=RecordingAutoModel,
+ moss_backend="other",
+ )
+
def test_fun_asr_nano_is_selected_independently_of_language(self):
model = create_asr_model(
"fun-asr-nano", "en", auto_model_cls=RecordingAutoModel
diff --git a/tests/test_recognition_result_compat.py b/tests/test_recognition_result_compat.py
index 8880355..a81ae81 100644
--- a/tests/test_recognition_result_compat.py
+++ b/tests/test_recognition_result_compat.py
@@ -19,11 +19,11 @@ def generate(self, *args, **kwargs):
class TestRecognitionResultCompat(unittest.TestCase):
- def _recognize(self, result):
+ def _recognize(self, result, sd_switch="no"):
clipper = VideoClipper(DummyASRModel(result))
clipper.lang = "zh"
audio = (16000, np.zeros(16000, dtype=np.float32))
- return clipper.recog(audio)
+ return clipper.recog(audio, sd_switch=sd_switch)
def test_fun_asr_nano_result_without_sentence_info_or_raw_text_still_builds_state(self):
text, srt, state = self._recognize(
@@ -98,6 +98,115 @@ def test_long_token_level_sentence_is_split_into_subtitle_chunks(self):
self.assertIn("00:00:00,000 --> 00:00:08,000", srt)
self.assertIn("00:00:08,000 --> 00:00:12,000", srt)
+ def test_moss_result_preserves_speaker_segments_without_raw_markup(self):
+ clipper = VideoClipper(
+ DummyASRModel(
+ {
+ "key": "utt",
+ "text": "Hello there General Kenobi",
+ "raw_text": (
+ "[0.00][S01]Hello there[1.20]"
+ "[1.25][S02]General Kenobi[2.80]"
+ ),
+ "timestamp": [[0, 1200], [1250, 2800]],
+ "sentence_info": [
+ {
+ "start": 0,
+ "end": 1200,
+ "text": "Hello there",
+ "spk": "S01",
+ "timestamp": [[0, 1200]],
+ },
+ {
+ "start": 1250,
+ "end": 2800,
+ "text": "General Kenobi",
+ "spk": "S02",
+ "timestamp": [[1250, 2800]],
+ },
+ ],
+ }
+ )
+ )
+ clipper.lang = "en"
+ audio = (16000, np.zeros(48000, dtype=np.float32))
+
+ text, srt, state = clipper.recog(audio, sd_switch="Yes")
+
+ self.assertEqual(text, "Hello there General Kenobi")
+ self.assertEqual(state["recog_res_raw"], "Hello there General Kenobi")
+ self.assertNotIn("[S01]", srt)
+ self.assertIn("spkS01", srt)
+ self.assertIn("spkS02", srt)
+ self.assertEqual(
+ [item["spk"] for item in state["sd_sentences"]], ["S01", "S02"]
+ )
+
+ (_, clipped), message, _ = clipper.clip(
+ "", 0, 0, state, dest_spk="spkS02"
+ )
+ self.assertEqual(len(clipped), 24800)
+ self.assertIn("1 periods found", message)
+
+ (_, unchanged), message, _ = clipper.clip(
+ "General Kenobi", 0, 0, state
+ )
+ self.assertEqual(len(unchanged), len(audio[1]))
+ self.assertIn("No period found", message)
+
+ def test_moss_speaker_clipping_keeps_short_turns(self):
+ text, srt, state = self._recognize(
+ {
+ "key": "utt",
+ "text": "Yes",
+ "raw_text": "[1.00][S02]Yes[1.40]",
+ "timestamp": [[1000, 1400]],
+ "sentence_info": [
+ {
+ "start": 1000,
+ "end": 1400,
+ "text": "Yes",
+ "spk": "S02",
+ "timestamp": [[1000, 1400]],
+ }
+ ],
+ },
+ sd_switch="Yes",
+ )
+
+ state["audio_input"] = (16000, np.zeros(32000, dtype=np.float32))
+ clipper = VideoClipper(DummyASRModel({}))
+ (_, clipped), message, _ = clipper.clip(
+ "", 0, 0, state, dest_spk="spkS02"
+ )
+ self.assertEqual(text, "Yes")
+ self.assertIn("spkS02", srt)
+ self.assertEqual(len(clipped), 6400)
+ self.assertIn("1 periods found", message)
+
+ def test_moss_truncated_final_segment_fails_instead_of_silently_dropping_it(self):
+ with self.assertRaisesRegex(RuntimeError, "truncated MOSS transcript"):
+ self._recognize(
+ {
+ "key": "utt",
+ "text": "Complete turn",
+ "raw_text": (
+ "[0.00][S01]Complete turn[1.00]"
+ "[1.10][S02]This final turn has no end timestamp"
+ ),
+ "timestamp": [[0, 1000]],
+ "sentence_info": [
+ {
+ "start": 0,
+ "end": 1000,
+ "text": "Complete turn",
+ "spk": "S01",
+ "timestamp": [[0, 1000]],
+ }
+ ],
+ }
+ )
+
if __name__ == "__main__":
unittest.main()