docs(kws): add bilingual task guides and tested session examples

Signed-off-by: zhifu gao <[email protected]>
This commit is contained in:
zhifu gao
2026-09-07 08:13:07 +00:00
parent 403555289a
commit 391174c38f
8 changed files with 494 additions and 0 deletions
+1
View File
@@ -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
+1
View File
@@ -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) |
+2
View File
@@ -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
+179
View File
@@ -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 <keyword> <score>` 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.
+156
View File
@@ -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 <keyword> <score>` 或 `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 替身
执行完整示例入口,不加载模型权重。
+126
View File
@@ -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)
@@ -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"},
@@ -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([]);
});
}
}