diff --git a/examples/kws/README.md b/examples/kws/README.md new file mode 100644 index 0000000..9da887f --- /dev/null +++ b/examples/kws/README.md @@ -0,0 +1,38 @@ +# Open-XiaoAI x Sherpa-ONNX + +> [!CAUTION] +> 下面的教程和脚本正在开发整理中,请勿直接使用!🚨 + +> [!IMPORTANT] +> 本教程仅适用于 **小爱音箱 Pro(LX06)** 和 **Xiaomi 智能音箱 Pro(OH2P)** + +小爱音箱自定义唤醒词,基于 [sherpa-onnx](https://github.com/k2-fsa/sherpa-onnx)。 + +## 刷机 + +首先按照 [open-xiaoai](https://github.com/idootop/open-xiaoai) 里的教程将小爱音箱刷机, 然后 SSH 连接到小爱音箱。 + +## 安装脚本 + +在小爱音箱上安装启动脚本,然后重启小爱音响。 + +```shell +# 下载到 /data/init.sh 开机时自启动 +curl -L -o /data/init.sh https://gitee.com/idootop/artifacts/releases/download/open-xiaoai-kws/init.sh +``` + +## 设置唤醒词 + +在你的电脑上克隆本项目,然后修改 `my-keywords.txt` 文件里的唤醒词。注意: + +- 仅支持中文唤醒词 +- 支持多个唤醒词,每行一个 + +运行以下命令,更新 `keywords.txt` 配置文件: + +```shell +# 你可能需要先安装 uv 👉 https://github.com/astral-sh/uv +uv run keywords.py --tokens tokens.txt --output keywords.txt --text my-keywords.txt +``` + +将更新后的 `keywords.txt`(你的电脑)复制到 `/data/open-xiaoai/kws/keywords.txt`(小爱音箱),然后重启小爱音箱即可。 diff --git a/examples/kws/init.sh b/examples/kws/init.sh new file mode 100644 index 0000000..a77eb56 --- /dev/null +++ b/examples/kws/init.sh @@ -0,0 +1,55 @@ +#! /bin/sh + +set -e + +MIN_SPACE_MB=32 +DOWNLOAD_URL="https://gitee.com/idootop/artifacts/releases/download/open-xiaoai-kws/kws.tar.gz" + + +check_disk_space() { + local space_kb=$(df -k "$1" | awk 'NR==2 {print $4}') + if [ $((space_kb / 1024)) -lt "$MIN_SPACE_MB" ]; then + echo 1 + else + echo 0 + fi +} + + +BASE_DIR="/data" +if [ $(check_disk_space "$BASE_DIR") -eq 1 ]; then + BASE_DIR="/tmp" + if [ $(check_disk_space "$BASE_DIR") -eq 1 ]; then + echo "❌ 磁盘空间不足,请先清理磁盘空间(至少需要 $MIN_SPACE_MB MB 空间)" + exit 1 + fi +fi + + +WORK_DIR="$BASE_DIR/open-xiaoai" + +if [ ! -d "$WORK_DIR" ]; then + mkdir -p "$WORK_DIR" +fi + +if [ ! -f "$WORK_DIR/kws/kws" ]; then + echo "🔥 正在下载模型文件..." + curl -L -# -o "$WORK_DIR/kws.tar.gz" https://gitee.com/idootop/artifacts/releases/download/open-xiaoai-kws/kws.tar.gz + tar -xzvf "$WORK_DIR/kws.tar.gz" -C "$WORK_DIR" + rm "$WORK_DIR/kws.tar.gz" + echo "✅ 模型文件下载完毕" +fi + +echo "🔥 正在启动唤醒词识别服务..." + +"$WORK_DIR/kws/kws" \ + --model-type=zipformer2 \ + --tokens="$WORK_DIR/kws/models/tokens.txt" \ + --encoder="$WORK_DIR/kws/models/encoder.onnx" \ + --decoder="$WORK_DIR/kws/models/decoder.onnx" \ + --joiner="$WORK_DIR/kws/models/joiner.onnx" \ + --keywords-file="/data/open-xiaoai/kws/keywords.txt" \ + --provider=cpu \ + --num-threads=1 \ + --chunk-size=1024 \ + noop diff --git a/examples/kws/keywords.py b/examples/kws/keywords.py new file mode 100644 index 0000000..a46d9c5 --- /dev/null +++ b/examples/kws/keywords.py @@ -0,0 +1,163 @@ +# /// script +# requires-python = ">=3.12" +# dependencies = [ +# "pypinyin", +# ] +# [tool.uv] +# exclude-newer = "2025-05-06T00:00:00Z" +# /// + +import argparse +from pathlib import Path +from typing import List, Union +from pypinyin import pinyin +from pypinyin.contrib.tone_convert import to_initials, to_finals_tone + + +def get_args(): + parser = argparse.ArgumentParser() + parser.add_argument( + "--text", + type=str, + required=False, + default="my-keywords.txt", + help="""Path to the input texts. + + Each line in the texts contains the original phrase, it might also contain some + extra items, for example, the boosting score (startting with :), the triggering + threshold (startting with #, only used in keyword spotting task) and the original + phrase (startting with @). Note: extra items will be kept in the output. + + example input 1 (tokens_type = ppinyin): + + 小爱同学 :2.0 #0.6 @小爱同学 + 你好问问 :3.5 @你好问问 + 小艺小艺 #0.6 @小艺小艺 + + example output 1: + + x iǎo ài t óng x ué :2.0 #0.6 @小爱同学 + n ǐ h ǎo w èn w èn :3.5 @你好问问 + x iǎo y ì x iǎo y ì #0.6 @小艺小艺 + + example input 2 (tokens_type = bpe): + + HELLO WORLD :1.5 #0.4 + HI GOOGLE :2.0 #0.8 + HEY SIRI #0.35 + + example output 2: + + ▁HE LL O ▁WORLD :1.5 #0.4 + ▁HI ▁GO O G LE :2.0 #0.8 + ▁HE Y ▁S I RI #0.35 + """, + ) + + parser.add_argument( + "--tokens", + type=str, + required=False, + default="tokens.txt", + help="The path to tokens.txt.", + ) + + parser.add_argument( + "--output", + type=str, + required=False, + default="keywords.txt", + help="Path where the encoded tokens will be written to.", + ) + + return parser.parse_args() + + +def text2token( + texts: List[str], + tokens: str, + output_ids: bool = False, +): + """将文本转换为 token 列表""" + assert Path(tokens).is_file(), f"File not exists, {tokens}" + tokens_table = {} + with open(tokens, "r", encoding="utf-8") as f: + for line in f: + toks = line.strip().split() + assert len(toks) == 2, len(toks) + assert toks[0] not in tokens_table, f"Duplicate token: {toks} " + tokens_table[toks[0]] = int(toks[1]) + + texts_list: List[List[str]] = [] + for txt in texts: + res = [] + py = [x[0] for x in pinyin(txt)] + for x in py: + initial = to_initials(x, strict=False) + final = to_finals_tone(x, strict=False) + if initial == "" and final == "": + res.append(x) + else: + if initial != "": + res.append(initial) + if final != "": + res.append(final) + texts_list.append(res) + + result: List[List[Union[int, str]]] = [] + for text in texts_list: + text_list = [] + contain_oov = False + for txt in text: + if txt in tokens_table: + text_list.append(tokens_table[txt] if output_ids else txt) + else: + print( + f"Can't find token {txt} in token table, check your " + f"tokens.txt see if {txt} in it. skipping text : {text}." + ) + contain_oov = True + break + if contain_oov: + continue + else: + result.append(text_list) + return result + + +def main() -> None: + args = get_args() + + texts = [] + # extra information like boosting score (start with :), triggering threshold (start with #) + # original keyword (start with @) + extra_info = [] + with open(args.text, "r", encoding="utf8") as f: + for line in f: + extra = [] + text = [] + toks = line.strip().split() + if len(toks) == 1: + text.append(toks[0]) + extra.append('@'+toks[0]) + else: + for tok in toks: + if tok[0] == ":" or tok[0] == "#" or tok[0] == "@": + extra.append(tok) + else: + text.append(tok) + texts.append(" ".join(text)) + extra_info.append(extra) + encoded_texts = text2token( + texts, + tokens=args.tokens, + ) + with open(args.output, "w", encoding="utf8") as f: + for i, txt in enumerate(encoded_texts): + txt += extra_info[i] + f.write(" ".join(txt) + "\n") + print(f"✅ 唤醒词已保存到 {args.output}") + + +if __name__ == "__main__": + main() diff --git a/examples/kws/keywords.txt b/examples/kws/keywords.txt new file mode 100644 index 0000000..c231f0c --- /dev/null +++ b/examples/kws/keywords.txt @@ -0,0 +1,3 @@ +n ǐ h ǎo x iǎo zh ì @你好小智 +d òu b āo d òu b āo @豆包豆包 +n ǐ h ǎo sh ǎ n iū @你好傻妞 diff --git a/examples/kws/my-keywords.txt b/examples/kws/my-keywords.txt new file mode 100644 index 0000000..1e3a930 --- /dev/null +++ b/examples/kws/my-keywords.txt @@ -0,0 +1,3 @@ +你好小智 +豆包豆包 +你好傻妞 \ No newline at end of file diff --git a/examples/kws/tokens.txt b/examples/kws/tokens.txt new file mode 100644 index 0000000..5880fbf --- /dev/null +++ b/examples/kws/tokens.txt @@ -0,0 +1,227 @@ + 0 + 1 + 2 +A 3 +B 4 +S 5 +I 6 +f 7 +ù 8 +ǔ 9 +zh 10 +y 11 +ī 12 +sh 13 +ēng 14 +uè 15 +p 16 +iàn 17 +K 18 +L 19 +P 20 +R 21 +T 22 +M 23 +j 24 +W 25 +q 26 +s 27 +ān 28 +b 29 +ā 30 +l 31 +íng 32 +iǔ 33 +ch 34 +ǐ 35 +ì 36 +èr 37 +w 38 +r 39 +án 40 +én 41 +ó 42 +g 43 +uǎn 44 +c 45 +āng 46 +ǎn 47 +x 48 +iān 49 +ōng 50 +àng 51 +òng 52 +d 53 +ài 54 +iù 55 +ìng 56 +ū 57 +m 58 +àn 59 +ǎ 60 +í 61 +z 62 +k 63 +ǒu 64 +ūn 65 +uó 66 +èng 67 +òu 68 +ú 69 +éng 70 +à 71 +iào 72 +āo 73 +ào 74 +h 75 +uáng 76 +t 77 +iáo 78 +è 79 +iǎn 80 +er 81 +n 82 +èi 83 +ǐng 84 +uā 85 +ēi 86 +ǎo 87 +iú 88 +uàng 89 +uí 90 +ǜ 91 +āi 92 +óu 93 +ià 94 +ǎng 95 +īn 96 +uà 97 +uǐ 98 +ái 99 +ò 100 +ē 101 +óng 102 +uàn 103 +é 104 +ìn 105 +ùn 106 +uò 107 +ún 108 +uì 109 +uān 110 +ǐn 111 +iāo 112 +ōu 113 +uán 114 +iǎo 115 +á 116 +ér 117 +èn 118 +C 119 +D 120 +E 121 +O 122 +N 123 +Z 124 +ěi 125 +ián 126 +ēn 127 +iā 128 +ǚ 129 +áng 130 +iě 131 +ǒng 132 +éi 133 +iāng 134 +J 135 +V 136 +ín 137 +ié 138 +īng 139 +ō 140 +ěr 141 +F 142 +ué 143 +iǎng 144 +uō 145 +G 146 +ǎi 147 +ěn 148 +H 149 +en 150 +X 151 +i 152 +ǒ 153 +uǒ 154 +iè 155 +iū 156 +U 157 +iàng 158 +uāng 159 +uá 160 +Q 161 +ě 162 +e 163 +iǎ 164 +iáng 165 +Y 166 +○ 167 +iē 168 +uī 169 +ěng 170 +uài 171 +áo 172 +a 173 +ou 174 +ǔn 175 +uái 176 +uāi 177 +ióng 178 +ei 179 +ń 180 +iá 181 +iōng 182 +üè 183 +uē 184 +uǎng 185 +uǎ 186 +o 187 +uǎi 188 +uě 189 +ǘ 190 +uo 191 +iǒng 192 +ang 193 +ia 194 +u 195 +üě 196 +#0 197 +#1 198 +#2 199 +#3 200 +#4 201 +#5 202 +#6 203 +#7 204 +#8 205 +#9 206 +#10 207 +#11 208 +#12 209 +#13 210 +#14 211 +#15 212 +#16 213 +#17 214 +#18 215 +#19 216 +#20 217 +#21 218 +#22 219 +#23 220 +#24 221 +#25 222 +#26 223 +#27 224 +#28 225 +#29 226