diff --git a/.github/workflows/product-site.yml b/.github/workflows/product-site.yml index 8256de757..93bda3250 100644 --- a/.github/workflows/product-site.yml +++ b/.github/workflows/product-site.yml @@ -90,6 +90,7 @@ jobs: tests/test_training_docs_contract.py tests/test_python_api_docs_contract.py tests/test_streaming_vad_docs.py + tests/test_kws_docs.py tests/test_service_docs_contract.py tests/test_generated_api_docs.py tests/test_api_signatures.py diff --git a/docs/README.md b/docs/README.md index 7b0d9ec93..e4412aa08 100644 --- a/docs/README.md +++ b/docs/README.md @@ -29,6 +29,7 @@ | --- | --- | | Compare CPU, GPU, realtime and API runtimes | [Deployment matrix](deployment_matrix.md) / [中文](deployment_matrix_zh.md) | | Handle streaming speech boundaries and finalization | [Streaming VAD](streaming_vad.md) / [中文](streaming_vad_zh.md) | +| Detect keywords and manage utterance-end KWS results | [Keyword spotting](keyword_spotting.md) / [中文](keyword_spotting_zh.md) | | Transcribe and diarize with third-party MOSS | [MOSS](moss_transcribe_diarize.md) / [中文](moss_transcribe_diarize_zh.md) | | Accelerate with the FunASR vLLM split engine | [vLLM](vllm_guide.md) / [中文](vllm_guide_zh.md) | | Evaluate native vLLM serving | [Validation record](vllm_native_funasr_validation.md) | diff --git a/docs/index.rst b/docs/index.rst index e22567dfa..3e00f16b6 100644 --- a/docs/index.rst +++ b/docs/index.rst @@ -42,6 +42,8 @@ entry to these guides. Source Markdown remains in this repository. streaming_vad streaming_vad_zh + keyword_spotting + keyword_spotting_zh deployment_matrix deployment_matrix_zh ./moss_transcribe_diarize.md diff --git a/docs/keyword_spotting.md b/docs/keyword_spotting.md new file mode 100644 index 000000000..75440457d --- /dev/null +++ b/docs/keyword_spotting.md @@ -0,0 +1,179 @@ +[简体中文](keyword_spotting_zh.md) | English + +# Keyword Spotting + +Detect a configured keyword in one utterance with a KWS checkpoint. This task +does not produce a general transcription, speaker identity, or wake-word +timestamps. **The streaming SANM Python interface returns results at utterance +end, not wake events for every incoming packet.** + +## Choose the Matching Path + +| Task | Guide | +| --- | --- | +| Detect keywords in a complete recording | [FSMN KWS examples](../examples/industrial_data_pretraining/fsmn_kws) | +| Feed packets, then detect at utterance end | SANM recipe below | +| Transcribe general speech continuously | [Streaming ASR SDK](python_api.md#streaming-cache-lifecycle) | +| Locate speech activity | [Streaming VAD](streaming_vad.md) | + +FSMN uses its own checkpoint tokenizer and vocabulary, not streaming SANM cache +settings. The SANM recipe below uses the online checkpoint and `小云小云`. +ASR text/hotwords are not a KWS detector; VAD finds speech boundaries, not keywords. + +Changing `keywords` configures decoder candidates; it does not train a model +for any arbitrary phrase or language. Use checkpoint-supported tokens, inspect +its model card and license, and evaluate each intended keyword on your audio. +Use a nonempty string, with ASCII commas separating multiple keywords, not a +Python list or the ASR `hotword` parameter. Detection is not a list of every +keyword occurrence. The offline FSMN example uses +`iic/speech_charctc_kws_phone-xiaoyun`, not an invented `fsmn-kws` alias. +The [Model Zoo](../model_zoo/readme.md) and individual training recipes remain +the source for other architectures; their interfaces are not interchangeable. + +## Pin a Source Version + +This recipe requires the fixes merged in [#3655](https://github.com/modelscope/FunASR/pull/3655) +and [#3656](https://github.com/modelscope/FunASR/pull/3656). **PyPI `funasr==1.4.14` +does not include them.** After the [environment checks](installation/installation.md), +install the exact tested source in your isolated environment: + +```sh +python -m pip uninstall -y funasr +python -m pip install "funasr @ git+https://github.com/modelscope/FunASR.git@403555289a6d4f79f5c4a48e5beb00f521c5e172" +``` + +The uninstall step replaces an already installed package with the same version +number; a Git URL or `--upgrade` alone may leave that older package installed. +Run these commands in the isolated environment, not a shared production worker. + +A source checkout may still report package version `1.4.14`; record the Git +commit and actual imported module path as well as the package version. A later release must explicitly include +both fixes before substituting its wheel. This guide is not a new PyPI release. + +Prepare a complete local snapshot of +`iic/speech_sanm_kws_phone-xiaoyun-commands-online`, including configuration, +tokenizer, frontend/CMVN files and weights. Record the resolved revision or a +file checksum manifest; a moving `master` label is not an immutable revision. +Use a nonempty mono 16 kHz WAV. No download occurs inside the example. +For external arrays, use normalized one-dimensional `float32` samples, not +integer PCM. Resample the complete signal before splitting it; independent +per-packet resampling is not covered by the continuity guarantee. + +## Run a File or Ordered Packets + +The program takes `model_dir`, `audio`, and optional `--mode file` (default: +`stream`). Stream mode reads a complete WAV and simulates 960-sample packets; +it is not a microphone client and holds the file in memory. `detect_stream` is +an application helper, not a new SDK method. + +```python +import argparse +from pathlib import Path +import soundfile as sf +from funasr import AutoModel + + +def detect_stream(model, speech, sample_rate, packet_samples=960): + if packet_samples <= 0 or len(speech) == 0: + raise ValueError("Audio length and packet size must be positive") + cache = {} + final_results = [] + for start in range(0, len(speech), packet_samples): + end = min(start + packet_samples, len(speech)) + final_results = model.generate( + input=speech[start:end], fs=sample_rate, cache=cache, + chunk_size=[4, 8, 4], batch_size=1, is_final=end == len(speech), + ) + return final_results + + +parser = argparse.ArgumentParser() +parser.add_argument("model_dir") +parser.add_argument("audio") +parser.add_argument("--mode", choices=["file", "stream"], default="stream") +args = parser.parse_args() +model_dir = Path(args.model_dir).expanduser().resolve(strict=True) +if not model_dir.is_dir(): + raise ValueError("Expected a complete local SANM KWS model directory") +speech, sample_rate = sf.read(args.audio, dtype="float32") +if sample_rate != 16000 or speech.ndim != 1 or len(speech) == 0: + raise ValueError("Expected nonempty mono 16 kHz audio") + +model = AutoModel( + model=str(model_dir), keywords="小云小云", device="cpu", ncpu=1, + chunk_size=[4, 8, 4], encoder_chunk_look_back=0, + decoder_chunk_look_back=0, disable_update=True, trust_remote_code=False, +) +if args.mode == "file": + results = model.generate( + input=args.audio, fs=sample_rate, cache={}, chunk_size=[4, 8, 4], + batch_size=1, is_final=True, + ) +else: + results = detect_stream(model, speech, sample_rate) +for item in results: + print(item["text"]) +``` + +## Read Results and Manage Sessions + +- Nonfinal public `AutoModel.generate` calls return `[]`. This means no final + result yet, not a rejected keyword. Final results contain `key` and `text`; + the text is `detected ` or `rejected`. The score is not a + calibrated probability or a universal operating threshold. No time interval + or speaker identity is returned. + If the entire utterance has no valid feature frames, its final result can + also be `[]`; absence of a result is not evidence of rejection. An empty EOS + still decodes output accumulated from earlier packets. +- `chunk_size=[4, 8, 4]` specifies left/current/right **frontend feature frames**, + not milliseconds or caller packet lengths. The example's 960 samples equal + 60 ms at 16 kHz; this is an input packet duration, not measured wake latency. + Keep model, keyword, chunk settings and sample rate fixed within a session. +- Share the same dictionary only across ordered calls for one utterance. Use + `batch_size=1` and a fresh cache for another recording or cancelled session. + Separate caches alone do not make concurrent calls on one `AutoModel` + thread-safe; serialize calls or isolate model workers. +- The last nonempty packet in this example carries `is_final=True`, even for + exact packet multiples. With the pinned SANM fixes, a single empty **array** + final call after nonfinal audio also flushes pending state. Do not resend + consumed audio, add a second EOS after finalization, or assume other models + share this behavior. File/URL inputs are whole utterances and finalize + automatically; do not use repeated file paths as streaming packets. +- Finalization resets this model's cache fields in place; they need not become + an empty dictionary. Discard session state at the application boundary anyway. + Encoded output accumulates until EOS: impose explicit utterance limits, not + an unbounded always-on session. Choosing VAD or a timeout for those limits is + application policy and changes which audio the detector sees. +- Omitting `output_dir` now returns results without creating a result writer. + If output files are wanted, explicitly configure a writable `output_dir`; + result files are optional and may append across calls. They are not required + for inference and are not an event delivery system. + +## Evidence and Deployment Limits + +The source fix was checked on CPU with the official checkpoint, one official +positive sample and synthetic silence. Under one thread and frontend `dither=0`, +the complete file, complete array, 960-sample packets and irregular packets +with empty EOS produced identical final encoder frames for the positive sample. +The baseline and fixed version both detected the keyword; silence was rejected. +This is a continuity regression check, **not** an accuracy improvement, a +natural-negative false-accept benchmark, or a microphone/GPU validation. +The example leaves the checkpoint frontend configuration intact; bitwise +comparisons require the same deterministic settings used in that check. + +The Python example does not establish KWS support in the transcription HTTP +server, native vLLM, llama.cpp or the C++ WebSocket protocol. Check the separate +[deployment matrix](deployment_matrix.md) and a runtime's model contract before +building a service. Do not route KWS result strings as OpenAI transcription text +or advertise immediate wake events that this implementation does not emit. + +For a bug report, retain the original audio, model manifest, source commit, +packet lengths/order, finalization flags and result strings. Include natural +negative recordings and your false-accept/false-reject methodology before +claiming deployment accuracy. Do not post secrets or private audio publicly. + +Source contracts: [streaming implementation](../funasr/models/sanm_kws_streaming/model.py), +[optional output tests](../tests/test_kws_optional_output.py), +[stream continuity tests](../tests/test_kws_streaming_continuity.py), and +[runnable guide tests](../tests/test_kws_docs.py). Guide tests execute the +published entry point with a recording SDK double; they do not load weights. diff --git a/docs/keyword_spotting_zh.md b/docs/keyword_spotting_zh.md new file mode 100644 index 000000000..35288a008 --- /dev/null +++ b/docs/keyword_spotting_zh.md @@ -0,0 +1,156 @@ +简体中文 | [English](keyword_spotting.md) + +# 关键词检测 + +使用 KWS 检查点判断一句音频中是否出现配置的关键词。本任务不输出通用转写、 +说话人身份或唤醒词时间戳。**当前 SANM 流式 Python 接口在一句音频结束时返回结果, +不是每收到一个音频包就产生唤醒事件。** + +## 选择对应路径 + +| 任务 | 指南 | +| --- | --- | +| 检测完整录音中的关键词 | [FSMN KWS 示例](../examples/industrial_data_pretraining/fsmn_kws) | +| 按顺序输入音频包,句末检测 | 下方 SANM 示例 | +| 连续转写一般语音 | [流式 ASR SDK](python_api_zh.md#流式-cache-生命周期) | +| 定位语音活动 | [流式 VAD](streaming_vad_zh.md) | + +FSMN 使用对应检查点的 tokenizer 和关键词词表,不要套用流式 SANM 的缓存参数。 +下方 SANM 示例使用在线检查点和关键词 `小云小云`。 +ASR 文本及热词不是 KWS 检测器;VAD 检测语音边界,不判断关键词是否出现。 + +修改 `keywords` 是配置解码候选,不是为任意短语或语言训练新模型。 +应使用检查点支持的 token,阅读模型卡与许可证,并用目标音频评测每个关键词。 +参数使用非空字符串,多个关键词以英文逗号分隔,不是 Python 列表或 ASR 的 +`hotword` 参数。结果不保证列出所有关键词的每次出现。离线 FSMN 示例使用 +`iic/speech_charctc_kws_phone-xiaoyun`,不要自行改成不存在的 `fsmn-kws` 别名。 +其他架构请查阅 [Model Zoo](../model_zoo/readme_zh.md) 和对应训练示例,不要假定接口通用。 + +## 固定源码版本 + +此示例要求包含 [#3655](https://github.com/modelscope/FunASR/pull/3655) 和 +[#3656](https://github.com/modelscope/FunASR/pull/3656) 的修复。 +**PyPI `funasr==1.4.14` 尚未包含这些修复。** 完成 +[环境检查](installation/installation_zh.md) 后,在隔离环境中安装已测试的确切源码: + +```sh +python -m pip uninstall -y funasr +python -m pip install "funasr @ git+https://github.com/modelscope/FunASR.git@403555289a6d4f79f5c4a48e5beb00f521c5e172" +``` + +先卸载是为了替换已安装的同版本旧包;只指定 Git 地址或增加 `--upgrade` +仍可能保留旧包。请在隔离环境运行,不要直接修改共享生产工作进程的环境。 + +源码安装仍可能显示包版本 `1.4.14`,因此必须同时记录 Git commit 和实际导入模块路径。 +只有明确包含这两项修复的后续发布才能用对应 wheel 替代。本指南不代表发布了新 PyPI 包。 + +准备 `iic/speech_sanm_kws_phone-xiaoyun-commands-online` 的完整本地快照, +包括配置、tokenizer、前端/CMVN 文件和权重。记录解析后的版本或文件校验清单; +可变的 `master` 标签不等于不可变版本。音频使用非空、单声道、16 kHz WAV。 +示例内部不下载模型。 +外部数组应为归一化的一维 `float32` 波形,而非整数 PCM。 +先对完整信号统一采样率再切包;逐包独立重采样不在连续性保证范围内。 + +## 运行文件或顺序音频包 + +程序接收 `model_dir`、`audio` 和可选的 `--mode file`,默认模式为 `stream`。 +流式模式先读取完整 WAV,再模拟每包 960 个采样点;不是麦克风采集程序, +整段文件仍保留在内存中。`detect_stream` 是应用侧辅助函数,不是新的 SDK 方法。 + +```python +import argparse +from pathlib import Path +import soundfile as sf +from funasr import AutoModel + + +def detect_stream(model, speech, sample_rate, packet_samples=960): + if packet_samples <= 0 or len(speech) == 0: + raise ValueError("Audio length and packet size must be positive") + cache = {} + final_results = [] + for start in range(0, len(speech), packet_samples): + end = min(start + packet_samples, len(speech)) + final_results = model.generate( + input=speech[start:end], fs=sample_rate, cache=cache, + chunk_size=[4, 8, 4], batch_size=1, is_final=end == len(speech), + ) + return final_results + + +parser = argparse.ArgumentParser() +parser.add_argument("model_dir") +parser.add_argument("audio") +parser.add_argument("--mode", choices=["file", "stream"], default="stream") +args = parser.parse_args() +model_dir = Path(args.model_dir).expanduser().resolve(strict=True) +if not model_dir.is_dir(): + raise ValueError("Expected a complete local SANM KWS model directory") +speech, sample_rate = sf.read(args.audio, dtype="float32") +if sample_rate != 16000 or speech.ndim != 1 or len(speech) == 0: + raise ValueError("Expected nonempty mono 16 kHz audio") + +model = AutoModel( + model=str(model_dir), keywords="小云小云", device="cpu", ncpu=1, + chunk_size=[4, 8, 4], encoder_chunk_look_back=0, + decoder_chunk_look_back=0, disable_update=True, trust_remote_code=False, +) +if args.mode == "file": + results = model.generate( + input=args.audio, fs=sample_rate, cache={}, chunk_size=[4, 8, 4], + batch_size=1, is_final=True, + ) +else: + results = detect_stream(model, speech, sample_rate) +for item in results: + print(item["text"]) +``` + +## 结果与会话管理 + +- 非末包调用公开的 `AutoModel.generate` 返回 `[]`,含义是尚无最终结果, + 不是关键词被拒绝。最终结果包含 `key` 和 `text`,文本为 + `detected ` 或 `rejected`。分数不是校准后的概率或通用阈值, + 结果中没有关键词时间区间或说话人身份。 + 若整句音频都没有有效特征帧,最终结果也可能为 `[]`;没有结果不等于检测拒绝。 + 空 EOS 仍会解码此前音频包已经累计的输出。 +- `chunk_size=[4, 8, 4]` 指左侧、当前、右侧的**前端特征帧数**, + 不是毫秒或调用者的音频包长。16 kHz 下 960 个采样点等于 60 ms; + 这是输入包时长,不是实测唤醒延迟。会话内固定模型、关键词、分块设置和采样率。 +- 同一句音频的有序调用共用同一个字典,使用 `batch_size=1`。 + 新录音或取消会话后创建新缓存。独立缓存不代表同一个 `AutoModel` 可以线程安全地 + 并发调用;应串行调用或隔离模型工作进程。 +- 本例最后一个非空包携带 `is_final=True`,整包倍数长度也一样。 + 固定版本的 SANM 修复也支持:先输入非末包音频,再用一次空**数组**末包刷新状态。 + 不要重发已消费音频,不要在终结后追加第二次 EOS,也不要将此行为推广到其他模型。 + 文件/URL 输入会按完整一句自动终结,不能重复传文件路径来模拟连续音频包。 +- 终结会原地重置模型缓存字段,不保证字典变空。应用侧仍应在会话边界丢弃状态。 + 编码输出会一直积累到 EOS,必须明确限制句长,不能无限保持常开会话。 + 使用 VAD 或超时来划分句子是应用策略,会改变检测器接收到的音频。 +- 现在省略 `output_dir` 可直接返回结果,不创建结果写入器。如需文件, + 显式配置可写的 `output_dir`;结果文件是可选项,多次调用可能追加写入。 + 文件不是推理前提,也不是唤醒事件分发系统。 + +## 验证证据与部署边界 + +源码修复已在 CPU 上使用官方检查点、一条官方正样本和合成静音验证。 +在单线程、前端 `dither=0` 的条件下,完整文件、完整数组、960 点分包、 +不规则分包加空 EOS 的正样本最终编码帧一致。修复前后均检出了关键词,静音均被拒绝。 +这只是连续性回归验证,**不是**准确率提升、自然负样本误唤醒率评测, +也不是麦克风或 GPU 验证。上方示例保留检查点前端配置;逐位一致性比较 +需要使用该验证记录中的相同确定性设置。 + +此 Python 示例不能证明转写 HTTP 服务、原生 vLLM、llama.cpp 或 C++ WebSocket +协议已支持 KWS。构建服务前查阅独立的[部署矩阵](deployment_matrix_zh.md)与 +对应运行时模型契约,不要把 KWS 结果字符串当成 OpenAI 转写文本, +也不要宣传实现尚未输出的即时唤醒事件。 + +报告问题时保留原音频、模型文件清单、源码 commit、分包长度与顺序、末包标志和 +结果字符串。宣称部署准确率前,应包含自然负样本、误唤醒与漏检评测方法。 +不要公开密钥或私人音频。 + +源码契约:[流式实现](../funasr/models/sanm_kws_streaming/model.py)、 +[可选输出测试](../tests/test_kws_optional_output.py)、 +[连续性测试](../tests/test_kws_streaming_continuity.py)、 +[指南可运行测试](../tests/test_kws_docs.py)。指南测试使用记录调用的 SDK 替身 +执行完整示例入口,不加载模型权重。 diff --git a/tests/test_kws_docs.py b/tests/test_kws_docs.py new file mode 100644 index 000000000..837fdf359 --- /dev/null +++ b/tests/test_kws_docs.py @@ -0,0 +1,126 @@ +"""Run both published KWS examples with a recording SDK double, not weights.""" + +import ast +import shlex +import sys +import types +from pathlib import Path + +import pytest +from bs4 import BeautifulSoup +from markdown import markdown + +ROOT = Path(__file__).resolve().parents[1] + + +def test_bilingual_guides_exist(): + for name in ("keyword_spotting.md", "keyword_spotting_zh.md"): + assert (ROOT / "docs" / name).is_file() + + +@pytest.mark.parametrize("name", ["keyword_spotting.md", "keyword_spotting_zh.md"]) +def test_pinned_install_replaces_an_existing_same_version(name): + soup = BeautifulSoup(markdown((ROOT / "docs" / name).read_text(), extensions=["fenced_code"]), "html.parser") + commands = [shlex.split(line) for line in soup.select_one("code.language-sh").get_text().splitlines() if line.strip()] + assert commands == [ + ["python", "-m", "pip", "uninstall", "-y", "funasr"], + ["python", "-m", "pip", "install", "funasr @ git+https://github.com/modelscope/FunASR.git@403555289a6d4f79f5c4a48e5beb00f521c5e172"], + ] + + +class Audio: + ndim = 1 + + def __init__(self, length, start=0): + self.length = length + self.start = start + + def __len__(self): + return self.length + + def __getitem__(self, item): + start, end, step = item.indices(self.length) + assert step == 1 + return Audio(end - start, self.start + start) + + +@pytest.fixture(params=["keyword_spotting.md", "keyword_spotting_zh.md"]) +def guide(request): + path = ROOT / "docs" / request.param + assert path.is_file(), f"Missing runnable guide: {path}" + soup = BeautifulSoup(markdown(path.read_text(), extensions=["fenced_code"]), "html.parser") + blocks = soup.select("code.language-python") + assert len(blocks) == 1 + code = blocks[0].get_text() + ast.parse(code) + return compile(code, str(path), "exec") + + +def run_guide(guide, monkeypatch, tmp_path, length=1921, rate=16000, mode="stream", ndim=1): + calls, constructors = [], [] + speech = Audio(length) + speech.ndim = ndim + model_dir = tmp_path / "checkpoint" + model_dir.mkdir(exist_ok=True) + + class Model: + def __init__(self, **kwargs): + constructors.append(kwargs) + + def generate(self, **kwargs): + calls.append(kwargs) + # Simulate SDK mutation of the very same caller-owned dictionary. + kwargs["cache"]["calls"] = kwargs["cache"].get("calls", 0) + 1 + if kwargs["is_final"]: + return [{"key": "demo", "text": "detected 小云小云 0.900"}] + return [] + + monkeypatch.setitem(sys.modules, "funasr", types.SimpleNamespace(AutoModel=Model)) + monkeypatch.setitem(sys.modules, "soundfile", types.SimpleNamespace(read=lambda *a, **k: (speech, rate))) + monkeypatch.setattr(sys, "argv", ["kws.py", str(model_dir), "positive.wav", "--mode", mode]) + scope = {"__name__": "__main__"} + exec(guide, scope) + return scope, calls, constructors + + +@pytest.mark.parametrize("length,sizes", [(1, [1]), (960, [960]), (961, [960, 1]), (1920, [960, 960])]) +def test_full_stream_entry_point(guide, monkeypatch, tmp_path, capsys, length, sizes): + _, calls, constructors = run_guide(guide, monkeypatch, tmp_path, length=length) + assert [len(call["input"]) for call in calls] == sizes + assert [call["input"].start for call in calls] == list(range(0, length, 960)) + assert [call["is_final"] for call in calls] == [False] * (len(sizes) - 1) + [True] + assert all(call["cache"] is calls[0]["cache"] for call in calls) + assert all(call["chunk_size"] == [4, 8, 4] and call["batch_size"] == 1 and call["fs"] == 16000 for call in calls) + assert constructors[0]["keywords"] == "小云小云" + assert constructors[0]["device"] == "cpu" + assert constructors[0]["trust_remote_code"] is False + assert "output_dir" not in constructors[0] + assert "detected 小云小云" in capsys.readouterr().out + + +def test_file_mode_is_one_complete_utterance(guide, monkeypatch, tmp_path): + _, calls, _ = run_guide(guide, monkeypatch, tmp_path, mode="file") + assert len(calls) == 1 and calls[0]["input"] == "positive.wav" + assert calls[0]["is_final"] is True + + +@pytest.mark.parametrize("length,rate,ndim", [(0, 16000, 1), (960, 8000, 1), (960, 16000, 2)]) +def test_invalid_audio_is_rejected(guide, monkeypatch, tmp_path, length, rate, ndim): + with pytest.raises(ValueError, match="nonempty mono 16 kHz"): + run_guide(guide, monkeypatch, tmp_path, length=length, rate=rate, ndim=ndim) + + +def test_second_session_gets_a_new_cache(guide, monkeypatch, tmp_path): + scope, calls, _ = run_guide(guide, monkeypatch, tmp_path) + first_cache = calls[0]["cache"] + first_count = len(calls) + scope["detect_stream"](scope["model"], Audio(960), 16000) + assert len(calls) == first_count + 1 + assert calls[-1]["cache"] is not first_cache + + +def test_invalid_packet_size_rejected(guide, monkeypatch, tmp_path): + scope, _, _ = run_guide(guide, monkeypatch, tmp_path) + for size in (0, -1): + with pytest.raises(ValueError, match="positive"): + scope["detect_stream"](scope["model"], Audio(960), 16000, packet_samples=size) diff --git a/web-pages/product-site/data/documentation.json b/web-pages/product-site/data/documentation.json index cee4f7abe..e3ad24e2a 100644 --- a/web-pages/product-site/data/documentation.json +++ b/web-pages/product-site/data/documentation.json @@ -18,6 +18,7 @@ {"slug": "moss-transcribe-diarize", "group": "models", "zh": "MOSS 转写与说话人分离", "en": "MOSS transcription & diarization", "source_zh": "docs/moss_transcribe_diarize_zh.md", "source_en": "docs/moss_transcribe_diarize.md"}, {"slug": "model-zoo", "group": "models", "zh": "Model Zoo", "en": "Model Zoo", "source_zh": "model_zoo/readme_zh.md", "source_en": "model_zoo/readme.md"}, {"slug": "streaming-vad", "group": "models", "zh": "流式语音活动检测", "en": "Streaming voice activity detection", "source_zh": "docs/streaming_vad_zh.md", "source_en": "docs/streaming_vad.md"}, + {"slug": "keyword-spotting", "group": "models", "zh": "关键词检测", "en": "Keyword spotting", "source_zh": "docs/keyword_spotting_zh.md", "source_en": "docs/keyword_spotting.md"}, {"slug": "training", "group": "train", "zh": "微调与评测", "en": "Fine-tuning & evaluation", "source_zh": "docs/training_zh.md", "source_en": "docs/training.md"}, {"slug": "model-registration", "group": "train", "zh": "自定义模型注册", "en": "Custom model registration", "source_zh": "docs/model_registration_zh.md", "source_en": "docs/model_registration.md"}, {"slug": "docker", "group": "deploy", "zh": "容器环境与镜像选择", "en": "Containers & image selection", "source_zh": "docs/installation/docker_zh.md", "source_en": "docs/installation/docker.md"}, diff --git a/web-pages/product-site/tests/browser/kws.spec.ts b/web-pages/product-site/tests/browser/kws.spec.ts new file mode 100644 index 000000000..e7bf130e4 --- /dev/null +++ b/web-pages/product-site/tests/browser/kws.spec.ts @@ -0,0 +1,28 @@ +import { expect, test } from '@playwright/test'; + +for (const width of [390, 1440]) { + for (const language of ['zh', 'en']) { + test(`KWS guide search and runnable code: ${language} ${width}px`, async ({ page, context }, testInfo) => { + const prefix = language === 'en' ? '/en' : ''; + const query = language === 'en' ? 'Keyword spotting' : '关键词检测'; + const errors: string[] = []; + page.on('pageerror', error => errors.push(error.message)); + await context.grantPermissions(['clipboard-read', 'clipboard-write']); + await page.setViewportSize({ width, height: 900 }); + await page.goto(`${prefix}/docs/`); + await page.locator('[data-doc-search] input').fill(query); + await page.locator(`.search-results a[href="${prefix}/docs/keyword-spotting.html"]`).click(); + await expect(page.locator('.docs-title h1')).toHaveText(query); + expect(await page.evaluate(() => document.documentElement.scrollWidth - innerWidth)).toBeLessThanOrEqual(1); + await page.screenshot({ path: testInfo.outputPath('initial.png') }); + const block = page.locator('.docs-article pre').filter({ hasText: 'def detect_stream' }); + await expect(block).toHaveCount(1); + const expected = await block.locator('code').innerText(); + await block.locator('button').click(); + expect((await page.evaluate(() => navigator.clipboard.readText())).trim()).toBe(expected.trim()); + await page.screenshot({ path: testInfo.outputPath('code.png') }); + expect(await page.evaluate(() => document.documentElement.scrollWidth - innerWidth)).toBeLessThanOrEqual(1); + expect(errors).toEqual([]); + }); + } +}