@openav/cli 0.0.0-stage → 1.0.41
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE.md +12 -0
- package/README.md +1208 -2
- package/bin/dscode.exe +12 -0
- package/bin/stt/requirements.txt +20 -0
- package/bin/stt/stt_server.py +544 -0
- package/bin/tts/requirements.txt +21 -0
- package/bin/tts/tts_server.py +404 -0
- package/cli-wrapper.cjs +109 -0
- package/install.cjs +331 -0
- package/llms.txt +115 -0
- package/package.json +56 -4
- package/scripts/figure/compose.py +363 -0
- package/scripts/figure/replay-shots.mjs +205 -0
- package/web/dist/assets/AssistantsView-dhdYVIy0.js +1 -0
- package/web/dist/assets/AutomationView-D1dU5ZV7.js +1 -0
- package/web/dist/assets/ChannelConfigView-CEPVZntU.js +8 -0
- package/web/dist/assets/ChartsView-DdN-beHW.js +1 -0
- package/web/dist/assets/ConnectorsView-CseCss9C.js +1 -0
- package/web/dist/assets/FileTreeView-Dnc3-gRF.js +1 -0
- package/web/dist/assets/HookConfigView-DA2h4HdJ.js +1 -0
- package/web/dist/assets/InputArea-DAgOOjOc.js +14 -0
- package/web/dist/assets/KaTeX_AMS-Regular-BQhdFMY1.woff2 +0 -0
- package/web/dist/assets/KaTeX_Caligraphic-Bold-Dq_IR9rO.woff2 +0 -0
- package/web/dist/assets/KaTeX_Caligraphic-Regular-Di6jR-x-.woff2 +0 -0
- package/web/dist/assets/KaTeX_Fraktur-Bold-CL6g_b3V.woff2 +0 -0
- package/web/dist/assets/KaTeX_Fraktur-Regular-CTYiF6lA.woff2 +0 -0
- package/web/dist/assets/KaTeX_Main-Bold-Cx986IdX.woff2 +0 -0
- package/web/dist/assets/KaTeX_Main-BoldItalic-DxDJ3AOS.woff2 +0 -0
- package/web/dist/assets/KaTeX_Main-Italic-NWA7e6Wa.woff2 +0 -0
- package/web/dist/assets/KaTeX_Main-Regular-B22Nviop.woff2 +0 -0
- package/web/dist/assets/KaTeX_Math-BoldItalic-CZnvNsCZ.woff2 +0 -0
- package/web/dist/assets/KaTeX_Math-Italic-t53AETM-.woff2 +0 -0
- package/web/dist/assets/KaTeX_SansSerif-Bold-D1sUS0GD.woff2 +0 -0
- package/web/dist/assets/KaTeX_SansSerif-Italic-C3H0VqGB.woff2 +0 -0
- package/web/dist/assets/KaTeX_SansSerif-Regular-DDBCnlJ7.woff2 +0 -0
- package/web/dist/assets/KaTeX_Script-Regular-D3wIWfF6.woff2 +0 -0
- package/web/dist/assets/KaTeX_Size1-Regular-mCD8mA8B.woff2 +0 -0
- package/web/dist/assets/KaTeX_Size2-Regular-Dy4dx90m.woff2 +0 -0
- package/web/dist/assets/KaTeX_Size4-Regular-Dl5lxZxV.woff2 +0 -0
- package/web/dist/assets/KaTeX_Typewriter-Regular-CO6r4hn1.woff2 +0 -0
- package/web/dist/assets/MemoryView-D6OUBi4-.js +1 -0
- package/web/dist/assets/MessageBubble-CCTRbIMZ.css +1 -0
- package/web/dist/assets/MessageBubble-CV16DZMG.js +453 -0
- package/web/dist/assets/ModelsView-BuqD9vWl.js +1 -0
- package/web/dist/assets/PluginsView-CIziDHFJ.js +2 -0
- package/web/dist/assets/ProjectsView-c6faWy32.js +1 -0
- package/web/dist/assets/SessionGrid-BZJHcklh.js +4 -0
- package/web/dist/assets/SessionGrid-Bk5CAXEn.css +1 -0
- package/web/dist/assets/SettingsModal-BqdGEGW-.js +4 -0
- package/web/dist/assets/SkillsView-CB6CKkmb.js +1 -0
- package/web/dist/assets/TemplatesTab-B8efVwVZ.js +1 -0
- package/web/dist/assets/ThreadsView-CDoM0aNb.js +2 -0
- package/web/dist/assets/ToolsView-BG-xsWsO.js +1 -0
- package/web/dist/assets/abnfDiagram-WEYOIA3C-DGUPHfRO.js +1 -0
- package/web/dist/assets/agentIconSet-Btsb_SC4.js +1 -0
- package/web/dist/assets/api-CCt9DRQm.js +1 -0
- package/web/dist/assets/arc-Y1ARJVZ4.js +1 -0
- package/web/dist/assets/architecture-WOLXFQ4H-BUERx_-B.js +1 -0
- package/web/dist/assets/architectureDiagram-ZHVYKT53-BPsQL7O5.js +36 -0
- package/web/dist/assets/array-BifhSqXX.js +1 -0
- package/web/dist/assets/blockDiagram-RJAE7QA3-pB7EXp6p.js +140 -0
- package/web/dist/assets/c4Diagram-XDPPPE54-BnPeCq_L.js +38 -0
- package/web/dist/assets/channel-CSrLW66f.js +1 -0
- package/web/dist/assets/channelMeta-BGQXQxgj.js +1 -0
- package/web/dist/assets/chunk-2E7WHVMD-xX282MrC.js +88 -0
- package/web/dist/assets/chunk-2Q5K7J3B-C1jixKkw.js +1 -0
- package/web/dist/assets/chunk-4EMNKR2T-BAQDLVqt.js +1 -0
- package/web/dist/assets/chunk-55XCU76K-BwAE3Eqq.js +1 -0
- package/web/dist/assets/chunk-5VM5RSS4-ZNzvKenW.js +15 -0
- package/web/dist/assets/chunk-7S6XSTFO-DwwaClMo.js +2 -0
- package/web/dist/assets/chunk-AQDTSKZB-DHG5Km_p.js +72 -0
- package/web/dist/assets/chunk-DSNLSHJZ-a6-B52h-.js +129 -0
- package/web/dist/assets/chunk-DUW6YSOI-Dgx8z5s3.js +1 -0
- package/web/dist/assets/chunk-HAGFOTED-DZMcAj3o.js +62 -0
- package/web/dist/assets/chunk-HNE4Q36P-gl_4xI6B.js +1 -0
- package/web/dist/assets/chunk-J5ZVWO5B-oDCd-gWO.js +1 -0
- package/web/dist/assets/chunk-JWPE2WC7-DVXcaiue.js +1 -0
- package/web/dist/assets/chunk-NGNAAXSQ-Bw6tHVuc.js +136 -0
- package/web/dist/assets/chunk-NQOKRVIF-B8Q-YmhL.js +213 -0
- package/web/dist/assets/chunk-UHW7PFBP-isP5lYMB.js +1 -0
- package/web/dist/assets/chunk-VQ5W62QI-Bn68syIc.js +1 -0
- package/web/dist/assets/chunk-WQQL4CDK-CdZpmEYA.js +10 -0
- package/web/dist/assets/chunk-XXDRQBXY-CnbRkih_.js +1 -0
- package/web/dist/assets/chunk-Y2CYZVJY-DsF7k-Jl.js +1 -0
- package/web/dist/assets/chunk-YZO67HFG-DX9GhJkR.js +1 -0
- package/web/dist/assets/chunk-ZAUX2LTX-Ki22UlYp.js +1 -0
- package/web/dist/assets/chunk-ZCHMQSOR-BVj0Zx6L.js +2 -0
- package/web/dist/assets/classDiagram-v2-4MYWR3UA-C7PV1veb.js +217 -0
- package/web/dist/assets/cn-rx4uumOJ.js +1 -0
- package/web/dist/assets/common-BHXgEvgk.js +192 -0
- package/web/dist/assets/cose-bilkent-JH36ORCC-DHFtpmij.js +1 -0
- package/web/dist/assets/cynefin-EF2NZ3EQ-1rI2T0cc.js +1 -0
- package/web/dist/assets/cynefinDiagram-WF24UDRP-C8O_v8xU.js +62 -0
- package/web/dist/assets/cytoscape.esm-BY277UBX.js +321 -0
- package/web/dist/assets/dagre-NK4GIZVX-BWGoHIUd.js +4 -0
- package/web/dist/assets/defaultLocale-BFoDCU3G.js +1 -0
- package/web/dist/assets/diagram-CBK3MWTC-kf9v35RN.js +24 -0
- package/web/dist/assets/diagram-I5XIIV2D-Ro-ig56_.js +24 -0
- package/web/dist/assets/diagram-ODIL2UCW-CtGga01G.js +30 -0
- package/web/dist/assets/diagram-PFBEJUXE-C14lcR-6.js +3 -0
- package/web/dist/assets/diagram-RJA3I4VV-BBbqaSKU.js +41 -0
- package/web/dist/assets/diagram-TYUNFSNR-BynvjQsr.js +200 -0
- package/web/dist/assets/dist-CumnGVmJ.js +1 -0
- package/web/dist/assets/ebnfDiagram-V4P3TTPT-BdqUCZnk.js +1 -0
- package/web/dist/assets/elk-QONTBCTQ-CYCP_9Jy.js +27 -0
- package/web/dist/assets/erDiagram-KYQIGQAA-C3L55l_F.js +99 -0
- package/web/dist/assets/eventmodeling-K75KTNOO-CTQdA_1v.js +1 -0
- package/web/dist/assets/flowDiagram-IUGLLWBU-COLKOzBg.js +1 -0
- package/web/dist/assets/ganttDiagram-R7GPNTJY-Be4Qk8Hx.js +292 -0
- package/web/dist/assets/gitGraph-VSP46ZUC-B59HXvj1.js +1 -0
- package/web/dist/assets/gitGraphDiagram-PDYL7XHY-C2A0VA6q.js +106 -0
- package/web/dist/assets/graphlib-DS17s2tU.js +1 -0
- package/web/dist/assets/icons-BvYhTENi.js +1 -0
- package/web/dist/assets/index-BNI6CNsY.css +1 -0
- package/web/dist/assets/index-BX-d2OBW.js +130 -0
- package/web/dist/assets/info-OHQRW6UA-DWlkKvmi.js +1 -0
- package/web/dist/assets/infoDiagram-MVDRKAX5-B8e6dTjN.js +2 -0
- package/web/dist/assets/init-C-OQMol4.js +1 -0
- package/web/dist/assets/ishikawaDiagram-HPEHV2GE-udXPYi9H.js +70 -0
- package/web/dist/assets/journeyDiagram-BOSHW7O7-DTyDkf5d.js +139 -0
- package/web/dist/assets/kanban-definition-FNSA4HC5-BXgV20qe.js +89 -0
- package/web/dist/assets/katex-B940_AAO.js +257 -0
- package/web/dist/assets/line-DVabuQAu.js +1 -0
- package/web/dist/assets/linear-gLkGxN9m.js +1 -0
- package/web/dist/assets/markdown-DAQZeY5V.js +14 -0
- package/web/dist/assets/mermaid-parser.core-BrXnuEs7.js +7 -0
- package/web/dist/assets/mermaid.core-BGbz3k1k.js +44 -0
- package/web/dist/assets/mindmap-definition-6OY57UG3-DbtP-bmk.js +98 -0
- package/web/dist/assets/ordinal-BDEzSJ7C.js +1 -0
- package/web/dist/assets/packet-JDAUHWVQ-iW8w3JF5.js +1 -0
- package/web/dist/assets/panels-DYiQiVWh.js +1 -0
- package/web/dist/assets/path-fybaL0A-.js +1 -0
- package/web/dist/assets/pegDiagram-ECIUJNBX-rBWJbaq4.js +1 -0
- package/web/dist/assets/pie-XZMESJXO-KHXEq-Yx.js +1 -0
- package/web/dist/assets/pieDiagram-GNMPY74T-DsW7yHm0.js +39 -0
- package/web/dist/assets/preload-helper-BLdHbfdN.js +1 -0
- package/web/dist/assets/profileChange-xrd7jkii.js +1 -0
- package/web/dist/assets/quadrantDiagram-D57K4MC3-DF_pwNhM.js +7 -0
- package/web/dist/assets/radar-ABXABTNO-BkAUJ_Zd.js +1 -0
- package/web/dist/assets/railroad-I3PHUGI6-BdnWRdGt.js +1 -0
- package/web/dist/assets/railroad-abnf-LNEFI6M7-C8K36-8b.js +1 -0
- package/web/dist/assets/railroad-ebnf-DWYD2UWJ-BlS25T3f.js +1 -0
- package/web/dist/assets/railroad-peg-JR7BVNR7-CBKGDi75.js +1 -0
- package/web/dist/assets/railroadDiagram-Y72NRBL7-DmrlMIvv.js +1 -0
- package/web/dist/assets/react-vendor-BlngCquv.js +19 -0
- package/web/dist/assets/requirementDiagram-EQYSW5RA-cvUq9H3I.js +84 -0
- package/web/dist/assets/rolldown-runtime-COiEfhsA.js +1 -0
- package/web/dist/assets/rough.esm-Dy-Kn_BL.js +1 -0
- package/web/dist/assets/sankeyDiagram-SY24JHO5-BdxXAbER.js +40 -0
- package/web/dist/assets/sequenceDiagram-BLEHNHQU-Druumi0k.js +169 -0
- package/web/dist/assets/settingsStore-RLUAWgIk.js +1 -0
- package/web/dist/assets/sizeCapture-INFHLROL-3uHXrFqA.js +1 -0
- package/web/dist/assets/src-49LUQhkG.js +1 -0
- package/web/dist/assets/src-BWF7jMDv.js +1 -0
- package/web/dist/assets/state-_ZfNVAsd.js +1 -0
- package/web/dist/assets/stateDiagram-v2-A6IESKWZ-Jgh6qlrr.js +291 -0
- package/web/dist/assets/sttLanguages-aqoVC79g.js +2 -0
- package/web/dist/assets/swimlanes-B3ENRKFQ-4xKumvXQ.js +1 -0
- package/web/dist/assets/swimlanesDiagram-AYQRV4AK-bHcLKL3d.js +8 -0
- package/web/dist/assets/timeline-definition-JXYKGLGB-BYKhga9x.js +120 -0
- package/web/dist/assets/treeView-D4JQ5SDB-6-eTVUuH.js +1 -0
- package/web/dist/assets/treemap-SAJKECNS-Dxl8yCLE.js +1 -0
- package/web/dist/assets/ttsSettings-DHo3OsOq.js +15 -0
- package/web/dist/assets/types-JssxRFJn.js +1 -0
- package/web/dist/assets/useTranslation-BGmCDuQI.js +1 -0
- package/web/dist/assets/usecaseDiagram-WPOLBNNX-BaQ8nAv9.js +328 -0
- package/web/dist/assets/vennDiagram-Q3Y3IDCW-DkPvfmfT.js +34 -0
- package/web/dist/assets/wardley-7MLQ67FV-CZtI22aa.js +1 -0
- package/web/dist/assets/wardleyDiagram-IVAIDKWA-Dgi2OWQ4.js +78 -0
- package/web/dist/assets/websocket-C9e3h7VU.js +1 -0
- package/web/dist/assets/whatsNewStore-Bf_7lW8o.js +1 -0
- package/web/dist/assets/workspaceSemantics-RaIRer76.js +3 -0
- package/web/dist/assets/xychartDiagram-4URNHDU4-Ch96dep-.js +7 -0
- package/web/dist/favicon.svg +19 -0
- package/web/dist/fonts/OFL-Inter.txt +93 -0
- package/web/dist/fonts/inter-latin-400-normal.woff2 +0 -0
- package/web/dist/fonts/inter-latin-600-normal.woff2 +0 -0
- package/web/dist/icons/icon-1024.png +0 -0
- package/web/dist/icons/icon-180.png +0 -0
- package/web/dist/icons/icon-192.png +0 -0
- package/web/dist/icons/icon-512.png +0 -0
- package/web/dist/icons/icon-maskable-512.png +0 -0
- package/web/dist/index.html +90 -0
- package/web/dist/logo.svg +26 -0
- package/web/dist/manifest.webmanifest +16 -0
package/bin/dscode.exe
ADDED
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
#!/bin/sh
|
|
2
|
+
echo "Error: dscode native binary not installed." >&2
|
|
3
|
+
echo "" >&2
|
|
4
|
+
echo "Either postinstall did not run (--ignore-scripts, some pnpm configs)" >&2
|
|
5
|
+
echo "or the platform-native optional dependency was not downloaded" >&2
|
|
6
|
+
echo "(--omit=optional)." >&2
|
|
7
|
+
echo "" >&2
|
|
8
|
+
echo "Run the postinstall manually (adjust path for local vs global install):" >&2
|
|
9
|
+
echo " node node_modules/@deepseekcode/cli/install.cjs" >&2
|
|
10
|
+
echo "" >&2
|
|
11
|
+
echo "Or reinstall without --ignore-scripts / --omit=optional." >&2
|
|
12
|
+
exit 1
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
# DeepSeekCode 语音转文字(STT)依赖 — faster-whisper(唯一引擎)
|
|
2
|
+
#
|
|
3
|
+
# 安装(任选其一):
|
|
4
|
+
# pip install -r packages/stt/requirements.txt
|
|
5
|
+
# 或:pip install faster-whisper
|
|
6
|
+
#
|
|
7
|
+
# 说明:
|
|
8
|
+
# - 不需要 torch / funasr / modelscope:识别引擎只有 faster-whisper
|
|
9
|
+
# (ctranslate2 后端,CPU int8 即可用),中文与英语走同一套依赖,
|
|
10
|
+
# 区别只在模型档位(en → base.en,其余 → 多语言 base)。
|
|
11
|
+
# - Python 3.10+(stt_server.py 用了 PEP 604 的 `X | None` 注解),推荐 3.10~3.12。
|
|
12
|
+
|
|
13
|
+
# 音频容器解码:PyAV —— OGG/Opus、MP3、M4A 等平台原生格式
|
|
14
|
+
# 给 stt_server.decode_with_av 用;WAV / 裸 f32 走标准库,不需要它。
|
|
15
|
+
# **不需要系统 ffmpeg**:PyAV 的 wheel 自带 FFmpeg 库(av.library_versions 可验),
|
|
16
|
+
# 这正是选 PyAV 而不是 subprocess 调 ffmpeg 的全部理由。
|
|
17
|
+
av>=11
|
|
18
|
+
|
|
19
|
+
# 识别引擎:faster-whisper(whisper.cpp 的 Python 绑定,CPU 上快且省内存)
|
|
20
|
+
faster-whisper>=1.0.0
|
|
@@ -0,0 +1,544 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""DeepSeekCode 本地语音转文字(STT)服务。
|
|
3
|
+
|
|
4
|
+
参考 OpenWorker 的 stt/ 侧车设计(Rust + whisper.cpp),改为 Python 实现。
|
|
5
|
+
**唯一的识别引擎是 faster-whisper**,按语言选两个模型档位:
|
|
6
|
+
|
|
7
|
+
- 英语(en)→ faster-whisper 英语专用模型(base.en,CPU 上快且省内存)
|
|
8
|
+
- 其他语言(zh/ja/ko/fr/de/es/ru/pt/it…)→ faster-whisper 多语言模型
|
|
9
|
+
(默认 base,可用 DSC_STT_WHISPER_MULTI_MODEL 调整,如 small)
|
|
10
|
+
|
|
11
|
+
多语言档的语言码「未命中」时改由模型自动识别(详见 transcribe_whisper 的说明):
|
|
12
|
+
语言码不在多语言模型的语言表里、或带提示这一趟识别结果为空,都会退回 language=None
|
|
13
|
+
让 whisper 自己判定,避免「选错语言 → 静默出垃圾/空结果」。
|
|
14
|
+
|
|
15
|
+
WAV / 裸 f32 用标准库自己解(http.server / json / base64 / wave / struct),
|
|
16
|
+
其余平台原生容器(OGG/Opus、MP3、M4A…)交给 **PyAV**(`av`,按需 import)。
|
|
17
|
+
选 PyAV 而不是调 `ffmpeg` 可执行文件:PyAV 的 wheel 里自带 FFmpeg 库,
|
|
18
|
+
所以「本机有没有 ffmpeg」不再是这条链路的先决条件(Windows 上尤其关键)。
|
|
19
|
+
后端包同样按需懒加载:`/status` 不触发任何模型下载;`/transcribe` 首次调用时会
|
|
20
|
+
自动下载模型(faster-whisper 走 HuggingFace),耗时取决于网络。
|
|
21
|
+
|
|
22
|
+
接口(仅监听 127.0.0.1):
|
|
23
|
+
GET /health → {"status": "ok"}
|
|
24
|
+
POST /transcribe → {"language": "<ISO-639-1 小写两位码>",
|
|
25
|
+
"format": "wav"|"f32"|"auto"|"ogg"|"mp3"|…,
|
|
26
|
+
"audio": "<base64>"} → {"text": "..."}
|
|
27
|
+
|
|
28
|
+
`format` 语义:
|
|
29
|
+
- "wav"/"f32" —— 老两条路,向后兼容(浏览器录音直传);
|
|
30
|
+
- "auto" —— 按魔数嗅探:RIFF/WAVE 走内置解析器,其余交通用解码器。
|
|
31
|
+
频道入站(IM 语音)用这个,调用方不必认容器;
|
|
32
|
+
- 具体容器名 —— 已知容器(见 `_AV_FORMATS`)直接走通用解码器。
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
环境变量:
|
|
36
|
+
DSC_STT_PORT 监听端口(默认 0 = 系统分配;父进程通常指定 --port)
|
|
37
|
+
DSC_STT_WHISPER_MODEL faster-whisper 英语模型名(默认 base.en)
|
|
38
|
+
DSC_STT_WHISPER_MULTI_MODEL faster-whisper 多语言模型名(默认 base)
|
|
39
|
+
DSC_STT_DEVICE auto|cuda|cpu(默认 auto)
|
|
40
|
+
|
|
41
|
+
模型下载源(由 Node 侧按 config.yaml settings.stt.* 注入;本文件不读它们,
|
|
42
|
+
而是 huggingface_hub 在 **import 期**自行读取 —— 因此改端点
|
|
43
|
+
必须重启本进程才生效,Node 侧 restartService() 负责这件事):
|
|
44
|
+
HF_ENDPOINT HuggingFace 端点(默认 https://huggingface.co,国内可设
|
|
45
|
+
https://hf-mirror.com)—— faster-whisper 的模型权重走这里
|
|
46
|
+
"""
|
|
47
|
+
|
|
48
|
+
import base64
|
|
49
|
+
import importlib
|
|
50
|
+
import io
|
|
51
|
+
import json
|
|
52
|
+
import os
|
|
53
|
+
import struct
|
|
54
|
+
import sys
|
|
55
|
+
import threading
|
|
56
|
+
import time
|
|
57
|
+
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
|
58
|
+
from pathlib import Path
|
|
59
|
+
|
|
60
|
+
DEFAULT_MODELS_DIR = str(Path.home() / ".agent" / "stt-models")
|
|
61
|
+
TARGET_RATE = 16_000 # whisper 要求 16k 采样率
|
|
62
|
+
|
|
63
|
+
_backends_lock = threading.Lock()
|
|
64
|
+
_loaded = {"en": None, "multi": None} # 缓存已加载的模型对象
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def log(msg: str) -> None:
|
|
68
|
+
print(f"[stt] {msg}", file=sys.stderr, flush=True)
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
# ─────────────────────────── 音频解码 ───────────────────────────
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def _resample_mono(input_samples: list[float], source_rate: int) -> list[float]:
|
|
75
|
+
"""线性插值重采样到 16k(与 OpenWorker stt 的 Rust 实现同思路)。"""
|
|
76
|
+
if source_rate == TARGET_RATE:
|
|
77
|
+
return list(input_samples)
|
|
78
|
+
output_len = int(len(input_samples) * TARGET_RATE / source_rate)
|
|
79
|
+
ratio = source_rate / TARGET_RATE
|
|
80
|
+
out = []
|
|
81
|
+
for i in range(output_len):
|
|
82
|
+
pos = i * ratio
|
|
83
|
+
left = int(pos)
|
|
84
|
+
right = min(left + 1, len(input_samples) - 1)
|
|
85
|
+
frac = pos - left
|
|
86
|
+
out.append(input_samples[left] * (1.0 - frac) + input_samples[right] * frac)
|
|
87
|
+
return out
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def decode_wav(data: bytes) -> tuple[list[float], int]:
|
|
91
|
+
"""解析 WAV(RIFF)→ 单声道 float32 样本 + 采样率。支持 PCM16 / IEEE float32。"""
|
|
92
|
+
if data[0:4] != b"RIFF" or data[8:12] != b"WAVE":
|
|
93
|
+
raise ValueError("Not a WAV file (RIFF/WAVE header missing)")
|
|
94
|
+
pos = 12
|
|
95
|
+
channels = 1
|
|
96
|
+
rate = TARGET_RATE
|
|
97
|
+
bits = 16
|
|
98
|
+
fmt_code = 1
|
|
99
|
+
while pos + 8 <= len(data):
|
|
100
|
+
chunk_id = data[pos:pos + 4]
|
|
101
|
+
chunk_size = struct.unpack("<I", data[pos + 4:pos + 8])[0]
|
|
102
|
+
if chunk_id == b"fmt ":
|
|
103
|
+
fmt_code, channels, rate = struct.unpack("<HHI", data[pos + 8:pos + 16])
|
|
104
|
+
bits = struct.unpack("<H", data[pos + 22:pos + 24])[0] if pos + 24 <= len(data) else 16
|
|
105
|
+
elif chunk_id == b"data":
|
|
106
|
+
audio = data[pos + 8:pos + 8 + chunk_size]
|
|
107
|
+
samples: list[float] = []
|
|
108
|
+
if fmt_code == 3 and bits == 32: # IEEE float32
|
|
109
|
+
n = len(audio) // 4
|
|
110
|
+
for i in range(0, n * 4, 4):
|
|
111
|
+
samples.append(struct.unpack("<f", audio[i:i + 4])[0])
|
|
112
|
+
elif bits == 16: # PCM16
|
|
113
|
+
n = len(audio) // 2
|
|
114
|
+
for i in range(0, n * 2, 2):
|
|
115
|
+
samples.append(struct.unpack("<h", audio[i:i + 2])[0] / 32768.0)
|
|
116
|
+
elif bits == 8: # PCM8 unsigned
|
|
117
|
+
samples = [(b - 128) / 128.0 for b in audio]
|
|
118
|
+
else:
|
|
119
|
+
raise ValueError(f"Unsupported WAV bit depth: {bits}")
|
|
120
|
+
if channels > 1:
|
|
121
|
+
mono = []
|
|
122
|
+
for i in range(0, len(samples) - channels + 1, channels):
|
|
123
|
+
mono.append(sum(samples[i:i + channels]) / channels)
|
|
124
|
+
samples = mono
|
|
125
|
+
return samples, rate
|
|
126
|
+
pos += 8 + chunk_size + (chunk_size % 2)
|
|
127
|
+
raise ValueError("WAV data chunk not found")
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
# ────────────────────── 通用解码(平台容器:OGG/Opus、MP3…) ──────────────────────
|
|
131
|
+
|
|
132
|
+
# 能直接交给通用解码器的容器名(`format` 字段的取值)。键即调用方可传的字符串。
|
|
133
|
+
_AV_FORMATS = frozenset({
|
|
134
|
+
"ogg", "opus", "oga", "mp3", "m4a", "mp4", "aac", "webm", "flac", "wma", "amr", "silk",
|
|
135
|
+
})
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
def _sniff_needs_av(raw: bytes) -> bool:
|
|
139
|
+
"""魔数嗅探:只有明确的 WAV / 裸 f32 才走内置解析器,其余一律交给 PyAV。
|
|
140
|
+
|
|
141
|
+
刻意**默认偏严**(白名单而非黑名单):认不出的字节流宁可交给 PyAV 报错,
|
|
142
|
+
也不要拿内置 WAV 解析器去猜 —— 后者对非 RIFF 输入只会抛一句难懂的
|
|
143
|
+
"WAV data chunk not found",掩盖真正的「容器不受支持」。
|
|
144
|
+
"""
|
|
145
|
+
if len(raw) < 12:
|
|
146
|
+
return False # 交给 decode_wav 报它自己的错
|
|
147
|
+
return raw[0:4] != b"RIFF" or raw[8:12] != b"WAVE"
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
def decode_with_av(raw: bytes) -> tuple[list[float], int]:
|
|
151
|
+
"""用 PyAV 解任意容器 → 单声道 float32 + 采样率。
|
|
152
|
+
|
|
153
|
+
PyAV 的 wheel 自带 FFmpeg 库,所以不需要系统 `ffmpeg`(这是选它而不是
|
|
154
|
+
subprocess 调 ffmpeg 的全部理由)。解码到 16k **单声道**由 av 的
|
|
155
|
+
AudioResampler 完成,避免自己再写一遍重采样。
|
|
156
|
+
"""
|
|
157
|
+
try:
|
|
158
|
+
import av # 懒加载:没装 av 时只有这条路径失败,wav/f32 老路径不受影响
|
|
159
|
+
except ImportError as exc: # pragma: no cover - 依赖缺失路径
|
|
160
|
+
raise ValueError(
|
|
161
|
+
"Audio container decoding requires PyAV. "
|
|
162
|
+
"Install it into the STT venv: pip install av"
|
|
163
|
+
) from exc
|
|
164
|
+
|
|
165
|
+
try:
|
|
166
|
+
with av.open(io.BytesIO(raw), mode="r") as container:
|
|
167
|
+
stream = next((s for s in container.streams if s.type == "audio"), None)
|
|
168
|
+
if stream is None:
|
|
169
|
+
raise ValueError("No audio stream found in payload")
|
|
170
|
+
|
|
171
|
+
resampler = av.AudioResampler(format="s16", layout="mono", rate=TARGET_RATE)
|
|
172
|
+
chunks: list[bytes] = []
|
|
173
|
+
for frame in container.decode(stream):
|
|
174
|
+
# resample() 可能返回 0..N 个帧(含缓冲中的尾巴)
|
|
175
|
+
for out in resampler.resample(frame):
|
|
176
|
+
chunks.append(out.to_ndarray().tobytes())
|
|
177
|
+
# flush:重采样器内部可能还压着尾巴,不冲会静默丢最后几十毫秒
|
|
178
|
+
for out in resampler.resample(None):
|
|
179
|
+
chunks.append(out.to_ndarray().tobytes())
|
|
180
|
+
except ValueError:
|
|
181
|
+
raise
|
|
182
|
+
except Exception as exc:
|
|
183
|
+
raise ValueError(f"Could not decode audio container: {exc}") from exc
|
|
184
|
+
|
|
185
|
+
if not chunks:
|
|
186
|
+
raise ValueError("Decoded audio is empty (no samples produced)")
|
|
187
|
+
return _pcm16_to_f32(b"".join(chunks)), TARGET_RATE
|
|
188
|
+
|
|
189
|
+
|
|
190
|
+
def _pcm16_to_f32(pcm: bytes) -> list[float]:
|
|
191
|
+
"""PCM16 little-endian → [-1, 1) float32 列表。用 array 而非逐样本 struct,快一个量级。"""
|
|
192
|
+
import array
|
|
193
|
+
|
|
194
|
+
arr = array.array("h")
|
|
195
|
+
arr.frombytes(pcm[: len(pcm) - (len(pcm) % 2)])
|
|
196
|
+
return [v / 32768.0 for v in arr]
|
|
197
|
+
|
|
198
|
+
|
|
199
|
+
def decode_audio(body: dict) -> tuple[list[float], int]:
|
|
200
|
+
"""按 format 解码请求里的音频 → 单声道 float32 + 采样率。"""
|
|
201
|
+
raw = base64.b64decode(body.get("audio") or "", validate=False)
|
|
202
|
+
fmt = (body.get("format") or "wav").lower()
|
|
203
|
+
if fmt == "f32":
|
|
204
|
+
n = len(raw) // 4
|
|
205
|
+
samples = [struct.unpack("<f", raw[i:i + 4])[0] for i in range(0, n * 4)]
|
|
206
|
+
return samples, TARGET_RATE
|
|
207
|
+
if fmt == "wav":
|
|
208
|
+
return decode_wav(raw)
|
|
209
|
+
if fmt == "auto":
|
|
210
|
+
# 调用方(IM 适配器)拿到什么就传什么,由这里决定用哪个解码器
|
|
211
|
+
return decode_wav(raw) if not _sniff_needs_av(raw) else decode_with_av(raw)
|
|
212
|
+
if fmt in _AV_FORMATS:
|
|
213
|
+
return decode_with_av(raw)
|
|
214
|
+
raise ValueError(
|
|
215
|
+
f"Unsupported audio format: {fmt} "
|
|
216
|
+
f"(supported: wav, f32, auto, {', '.join(sorted(_AV_FORMATS))})"
|
|
217
|
+
)
|
|
218
|
+
|
|
219
|
+
|
|
220
|
+
# ─────────────────────────── 后端:whisper(英语 + 多语言) ───────────────────────────
|
|
221
|
+
|
|
222
|
+
# whisper 两种模型档位:en=英语专用(更快更准),multi=多语言(覆盖其余语言)
|
|
223
|
+
_WHISPER_MODEL_ENV = {"en": "DSC_STT_WHISPER_MODEL", "multi": "DSC_STT_WHISPER_MULTI_MODEL"}
|
|
224
|
+
_WHISPER_MODEL_DEFAULT = {"en": "base.en", "multi": "base"}
|
|
225
|
+
|
|
226
|
+
# 多语言模型语言表的探测结果缓存:set = 已知语言码集合;空 set = 探测过但取不到,
|
|
227
|
+
# 调用方据此跳过预检(不做「未知即未命中」的误判)。
|
|
228
|
+
_MULTI_LANGUAGE_CODES: set[str] = set()
|
|
229
|
+
_MULTI_LANGUAGE_CODES_PROBED = False
|
|
230
|
+
|
|
231
|
+
# 「带语言提示这一趟白跑了」的判定阈值(秒):VAD 之后剩下的语音短于它,
|
|
232
|
+
# 空结果更可能是静音而不是语言选错,此时不重试、不浪费第二次推理。
|
|
233
|
+
_EMPTY_RETRY_MIN_VAD_SEC = 1.0
|
|
234
|
+
|
|
235
|
+
|
|
236
|
+
def _multi_language_codes() -> set[str] | None:
|
|
237
|
+
"""多语言模型支持的语言码集合;取不到返回 None(= 未知,调用方跳过预检)。
|
|
238
|
+
|
|
239
|
+
取自 faster-whisper 自带的语言表(模块/属性名随版本有差异,逐一尝试);
|
|
240
|
+
取不到时宁可放弃预检交给模型自身判定,也不要因版本差异把正常语言误判成未命中。
|
|
241
|
+
"""
|
|
242
|
+
global _MULTI_LANGUAGE_CODES, _MULTI_LANGUAGE_CODES_PROBED
|
|
243
|
+
if _MULTI_LANGUAGE_CODES_PROBED:
|
|
244
|
+
return _MULTI_LANGUAGE_CODES or None
|
|
245
|
+
table = None
|
|
246
|
+
for module_name, attr in (("faster_whisper.tokenizer", "_LANGUAGES"), ("whisper.tokenizer", "LANGUAGES")):
|
|
247
|
+
try:
|
|
248
|
+
module = importlib.import_module(module_name)
|
|
249
|
+
except Exception: # noqa: BLE001 — 探测失败即放弃预检
|
|
250
|
+
continue
|
|
251
|
+
candidate = getattr(module, attr, None)
|
|
252
|
+
if isinstance(candidate, dict) and candidate:
|
|
253
|
+
table = candidate
|
|
254
|
+
break
|
|
255
|
+
_MULTI_LANGUAGE_CODES_PROBED = True
|
|
256
|
+
_MULTI_LANGUAGE_CODES = {str(code).lower() for code in table} if table else set()
|
|
257
|
+
if not _MULTI_LANGUAGE_CODES:
|
|
258
|
+
log("multilingual language table unavailable — the model decides the language itself")
|
|
259
|
+
return _MULTI_LANGUAGE_CODES or None
|
|
260
|
+
|
|
261
|
+
|
|
262
|
+
def _whisper_loaded(kind: str) -> object | None:
|
|
263
|
+
with _backends_lock:
|
|
264
|
+
return _loaded[kind]
|
|
265
|
+
|
|
266
|
+
|
|
267
|
+
def _whisper_load(kind: str) -> object:
|
|
268
|
+
with _backends_lock:
|
|
269
|
+
if _loaded[kind] is not None:
|
|
270
|
+
return _loaded[kind]
|
|
271
|
+
try:
|
|
272
|
+
from faster_whisper import WhisperModel # noqa: PLC0415
|
|
273
|
+
except ImportError as exc:
|
|
274
|
+
raise RuntimeError(
|
|
275
|
+
"Whisper STT backend (faster-whisper) is not installed. Run:\n"
|
|
276
|
+
" pip install faster-whisper\n"
|
|
277
|
+
"The DeepSeekCode server needs it for voice input."
|
|
278
|
+
) from exc
|
|
279
|
+
model_name = os.environ.get(_WHISPER_MODEL_ENV[kind], _WHISPER_MODEL_DEFAULT[kind])
|
|
280
|
+
device = detect_device()
|
|
281
|
+
compute = "float16" if device == "cuda" else "int8"
|
|
282
|
+
log(f"loading faster-whisper[{kind}] model '{model_name}' (device={device}, compute={compute})…")
|
|
283
|
+
model = WhisperModel(model_name, device=device, compute_type=compute)
|
|
284
|
+
_loaded[kind] = model
|
|
285
|
+
log(f"faster-whisper[{kind}] model ready")
|
|
286
|
+
return model
|
|
287
|
+
|
|
288
|
+
|
|
289
|
+
def transcribe_whisper(samples_f32: list[float], language: str, kind: str) -> tuple[str, str | None]:
|
|
290
|
+
"""whisper 转写。kind 决定用英语专用还是多语言模型;language 为 ISO-639-1 码。
|
|
291
|
+
|
|
292
|
+
返回 `(文本, 自动识别出的语言码)`:第二个元素仅在「语言提示未命中、改由模型自动
|
|
293
|
+
识别」时非空,供调用方回报实际使用的语言。
|
|
294
|
+
|
|
295
|
+
语言提示策略:默认把 language 交给模型(跳过自动识别 —— 短音频的自动语言判定
|
|
296
|
+
很不可靠)。**只有多语言档**会在「未命中」时退回 language=None 自动识别,三种触发:
|
|
297
|
+
|
|
298
|
+
1. 语言码不在多语言模型的语言表里 —— 硬传下去 faster-whisper 会直接抛错,
|
|
299
|
+
用户拿到 500 而不是结果(Node 侧有白名单,此路径主要保护直接调本服务/
|
|
300
|
+
未来扩充语言清单的场景);
|
|
301
|
+
2. 带提示这一趟识别结果为空,且 VAD 之后确实还剩 ≥1s 语音(选错语言的典型
|
|
302
|
+
表现就是这种「有声音但没结果」)—— 让模型自己判定一次;
|
|
303
|
+
3. 模型拒收该语言码(版本差异兜底),重试时改用自动识别。
|
|
304
|
+
|
|
305
|
+
⚠️ 不为「有结果但不对」而重试:whisper 在显式 language 下不给语言概率,
|
|
306
|
+
拿不到「对不对」的信号,重试只会把耗时翻倍。第二条的「空结果」是唯一可靠的
|
|
307
|
+
可用信号,且已用 VAD 时长把「纯静音」排除在外(静音不重试)。
|
|
308
|
+
"""
|
|
309
|
+
model = _whisper_load(kind)
|
|
310
|
+
import numpy as np # noqa: PLC0415
|
|
311
|
+
|
|
312
|
+
audio = np.asarray(samples_f32, dtype=np.float32)
|
|
313
|
+
|
|
314
|
+
def run(hint: str | None):
|
|
315
|
+
segments, info = model.transcribe(audio, language=hint, beam_size=5, vad_filter=True)
|
|
316
|
+
# segments 可能是惰性生成器:解码异常会在迭代期抛出,故连消费一起包在调用方
|
|
317
|
+
text = " ".join(s.text.strip() for s in segments).strip()
|
|
318
|
+
return text, info
|
|
319
|
+
|
|
320
|
+
hint: str | None = language
|
|
321
|
+
if kind == "multi":
|
|
322
|
+
codes = _multi_language_codes()
|
|
323
|
+
if codes is not None and language not in codes:
|
|
324
|
+
log(f"language {language!r} not in the multilingual model's language table — using auto-detect")
|
|
325
|
+
hint = None
|
|
326
|
+
try:
|
|
327
|
+
text, info = run(hint)
|
|
328
|
+
except Exception as exc: # noqa: BLE001 — 语言码把整趟转写打死不值得,退回自动识别
|
|
329
|
+
if hint is None:
|
|
330
|
+
raise
|
|
331
|
+
log(f"model rejected language {hint!r} ({exc}) — retrying with auto-detect")
|
|
332
|
+
text, info = run(None)
|
|
333
|
+
hint = None
|
|
334
|
+
|
|
335
|
+
if hint is not None and kind == "multi" and not text:
|
|
336
|
+
vad_sec = getattr(info, "duration_after_vad", None)
|
|
337
|
+
if vad_sec is None:
|
|
338
|
+
vad_sec = getattr(info, "duration", 0.0) or 0.0
|
|
339
|
+
if float(vad_sec) >= _EMPTY_RETRY_MIN_VAD_SEC:
|
|
340
|
+
log(f"empty result for language {hint!r} ({float(vad_sec):.1f}s after VAD) — retrying with auto-detect")
|
|
341
|
+
text, info = run(None)
|
|
342
|
+
hint = None
|
|
343
|
+
|
|
344
|
+
if hint is not None:
|
|
345
|
+
return text, None
|
|
346
|
+
detected = getattr(info, "language", None)
|
|
347
|
+
return text, (str(detected) if detected else None)
|
|
348
|
+
|
|
349
|
+
|
|
350
|
+
# ─────────────────────────── 设备检测 / 状态 ───────────────────────────
|
|
351
|
+
|
|
352
|
+
def detect_device() -> str:
|
|
353
|
+
env = os.environ.get("DSC_STT_DEVICE", "auto").lower()
|
|
354
|
+
if env in ("cuda", "gpu"):
|
|
355
|
+
return "cuda"
|
|
356
|
+
if env == "cpu":
|
|
357
|
+
return "cpu"
|
|
358
|
+
# torch 不是本服务的依赖(faster-whisper 走 ctranslate2);装了就用它探 CUDA,
|
|
359
|
+
# 没装一律 cpu。想显式用 GPU 请设 DSC_STT_DEVICE=cuda。
|
|
360
|
+
try:
|
|
361
|
+
import torch # noqa: PLC0415
|
|
362
|
+
|
|
363
|
+
return "cuda" if torch.cuda.is_available() else "cpu"
|
|
364
|
+
except ImportError:
|
|
365
|
+
return "cpu"
|
|
366
|
+
|
|
367
|
+
|
|
368
|
+
def _backend_status() -> dict:
|
|
369
|
+
def probe(name: str) -> dict:
|
|
370
|
+
loaded = _loaded.get(name) is not None
|
|
371
|
+
# 捕获所有异常而非仅 ImportError:Windows 上 ctranslate2 DLL 加载失败等
|
|
372
|
+
# 场景需要把具体原因带给前端,否则"pip 装完了却仍显示未安装"无从排查。
|
|
373
|
+
error = None
|
|
374
|
+
try:
|
|
375
|
+
import faster_whisper # noqa: PLC0415, F401
|
|
376
|
+
|
|
377
|
+
installed = True
|
|
378
|
+
except Exception as exc: # noqa: BLE001 — 探测需吞掉一切导入期异常
|
|
379
|
+
installed = False
|
|
380
|
+
error = f"{type(exc).__name__}: {exc}"
|
|
381
|
+
model = os.environ.get(_WHISPER_MODEL_ENV[name], _WHISPER_MODEL_DEFAULT[name])
|
|
382
|
+
return {
|
|
383
|
+
"installed": installed,
|
|
384
|
+
"loaded": loaded,
|
|
385
|
+
"error": error,
|
|
386
|
+
"model": model,
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
return {"en": probe("en"), "multi": probe("multi")}
|
|
390
|
+
|
|
391
|
+
|
|
392
|
+
# ─────────────────────────── HTTP 服务 ───────────────────────────
|
|
393
|
+
|
|
394
|
+
class SttHandler(BaseHTTPRequestHandler):
|
|
395
|
+
protocol_version = "HTTP/1.1"
|
|
396
|
+
|
|
397
|
+
def log_message(self, fmt: str, *args): # 走自己的 stderr 日志,避免重复刷屏
|
|
398
|
+
log(f"{self.command} {self.path} — {fmt % args}")
|
|
399
|
+
|
|
400
|
+
def _send(self, status: int, payload: dict) -> None:
|
|
401
|
+
data = json.dumps(payload).encode("utf-8")
|
|
402
|
+
self.send_response(status)
|
|
403
|
+
self.send_header("Content-Type", "application/json; charset=utf-8")
|
|
404
|
+
self.send_header("Content-Length", str(len(data)))
|
|
405
|
+
self.send_header("Access-Control-Allow-Origin", "*")
|
|
406
|
+
self.end_headers()
|
|
407
|
+
self.wfile.write(data)
|
|
408
|
+
|
|
409
|
+
def do_GET(self) -> None: # noqa: N802
|
|
410
|
+
if self.path.split("?")[0] == "/health":
|
|
411
|
+
return self._send(200, {"status": "ok"})
|
|
412
|
+
if self.path.split("?")[0] == "/status":
|
|
413
|
+
return self._send(200, {
|
|
414
|
+
"service": "ok",
|
|
415
|
+
"python": sys.version.split()[0],
|
|
416
|
+
"models_dir": os.environ.get("DSC_STT_MODELS_DIR", DEFAULT_MODELS_DIR),
|
|
417
|
+
"device": detect_device(),
|
|
418
|
+
"backends": _backend_status(),
|
|
419
|
+
})
|
|
420
|
+
self._send(404, {"error": "Not found"})
|
|
421
|
+
|
|
422
|
+
def do_POST(self) -> None: # noqa: N802
|
|
423
|
+
if self.path.split("?")[0] != "/transcribe":
|
|
424
|
+
return self._send(404, {"error": "Not found"})
|
|
425
|
+
try:
|
|
426
|
+
length = int(self.headers.get("Content-Length") or 0)
|
|
427
|
+
raw = self.rfile.read(length) if length else b"{}"
|
|
428
|
+
body = json.loads(raw.decode("utf-8") or "{}")
|
|
429
|
+
except Exception as exc: # noqa: BLE001
|
|
430
|
+
return self._send(400, {"error": f"Bad request body: {exc}"})
|
|
431
|
+
lang = str(body.get("language") or "en").lower()
|
|
432
|
+
# 接受任意 ISO-639-1 两位小写码:en→英语专用 whisper,
|
|
433
|
+
# 其余(含 zh)→多语言 whisper(模型支持约 99 种语言)。非两位字母码才拒绝。
|
|
434
|
+
if not (len(lang) == 2 and lang.isalpha() and lang.isascii()):
|
|
435
|
+
return self._send(400, {"error": f"Unsupported language: {lang} (use an ISO-639-1 code like 'en' / 'zh' / 'ja')"})
|
|
436
|
+
if not body.get("audio"):
|
|
437
|
+
return self._send(400, {"error": "Missing 'audio' (base64-encoded audio)"})
|
|
438
|
+
|
|
439
|
+
try:
|
|
440
|
+
samples, rate = decode_audio(body)
|
|
441
|
+
samples = _resample_mono(samples, rate)
|
|
442
|
+
except Exception as exc: # noqa: BLE001
|
|
443
|
+
return self._send(400, {"error": f"Could not decode audio: {exc}"})
|
|
444
|
+
|
|
445
|
+
# 少于 0.25 秒视为空白录音(对齐 OpenWorker 行为),直接返回空串
|
|
446
|
+
if len(samples) < TARGET_RATE // 4:
|
|
447
|
+
return self._send(200, {"text": "", "language": lang})
|
|
448
|
+
|
|
449
|
+
effective_lang = lang
|
|
450
|
+
try:
|
|
451
|
+
if lang == "en":
|
|
452
|
+
text, auto_lang = transcribe_whisper(samples, "en", "en")
|
|
453
|
+
backend = "faster-whisper"
|
|
454
|
+
effective_lang = auto_lang or lang
|
|
455
|
+
else:
|
|
456
|
+
text, auto_lang = transcribe_whisper(samples, lang, "multi")
|
|
457
|
+
backend = "faster-whisper-multi"
|
|
458
|
+
# 未命中 → 自动识别:回报实际使用的语言,便于调用方对齐/排查
|
|
459
|
+
effective_lang = auto_lang or lang
|
|
460
|
+
except Exception as exc: # noqa: BLE001
|
|
461
|
+
log(f"transcribe failed: {exc}")
|
|
462
|
+
return self._send(500, {"error": f"Transcription failed: {exc}"})
|
|
463
|
+
|
|
464
|
+
if effective_lang != lang:
|
|
465
|
+
log(f"transcribed {lang} (auto-detected as {effective_lang}): {text[:60]!r}")
|
|
466
|
+
else:
|
|
467
|
+
log(f"transcribed {lang}: {text[:60]!r}")
|
|
468
|
+
return self._send(200, {"text": text, "language": effective_lang, "backend": backend})
|
|
469
|
+
|
|
470
|
+
|
|
471
|
+
def _arg_value(name: str) -> str | None:
|
|
472
|
+
if name in sys.argv:
|
|
473
|
+
return sys.argv[sys.argv.index(name) + 1]
|
|
474
|
+
return None
|
|
475
|
+
|
|
476
|
+
|
|
477
|
+
def parent_alive(pid: int) -> bool:
|
|
478
|
+
"""父进程是否还在。
|
|
479
|
+
|
|
480
|
+
**不要**用 `os.kill(pid, 0)` 探活:Windows 上它并非「发 0 号信号」,CPython 会走
|
|
481
|
+
`OpenProcess(PROCESS_ALL_ACCESS)` + `TerminateProcess`(文档:除 CTRL_C_EVENT /
|
|
482
|
+
CTRL_BREAK_EVENT 外的 sig 会「unconditionally killed by the TerminateProcess API」),
|
|
483
|
+
而且 `PROCESS_ALL_ACCESS` 在子进程带 `CREATE_NO_WINDOW`(Node 侧 `windowsHide: true`,
|
|
484
|
+
见 `stt.ts:249`)时**会失败并抛 OSError** → 看门狗误判「父进程已死」→ 服务约 5 秒后
|
|
485
|
+
自杀,表现为第一个请求连接被重置、之后一律 ConnectionRefused。
|
|
486
|
+
实测见 `.agent/tts-probe/shape_probe.py`(S1 产品 stdio 形态)。
|
|
487
|
+
`packages/tts/tts_server.py` 有同一份修复,两处改动请保持同步。
|
|
488
|
+
"""
|
|
489
|
+
if os.name == "nt":
|
|
490
|
+
import ctypes
|
|
491
|
+
|
|
492
|
+
kernel32 = ctypes.WinDLL("kernel32", use_last_error=True)
|
|
493
|
+
# 只申请「查询」权限,不做任何写/终止操作
|
|
494
|
+
handle = kernel32.OpenProcess(0x1000, False, pid)
|
|
495
|
+
if not handle:
|
|
496
|
+
return False
|
|
497
|
+
try:
|
|
498
|
+
code = ctypes.c_ulong()
|
|
499
|
+
if not kernel32.GetExitCodeProcess(handle, ctypes.byref(code)):
|
|
500
|
+
return False
|
|
501
|
+
return code.value == 259 # STILL_ACTIVE
|
|
502
|
+
finally:
|
|
503
|
+
kernel32.CloseHandle(handle)
|
|
504
|
+
try:
|
|
505
|
+
os.kill(pid, 0)
|
|
506
|
+
return True
|
|
507
|
+
except (OSError, ValueError):
|
|
508
|
+
return False
|
|
509
|
+
|
|
510
|
+
|
|
511
|
+
def main() -> None:
|
|
512
|
+
models_dir = os.environ.get("DSC_STT_MODELS_DIR", DEFAULT_MODELS_DIR)
|
|
513
|
+
# 模型缓存集中到 models_dir(与 Node 侧 spawn 时注入的 HF_HOME 一致)
|
|
514
|
+
os.environ.setdefault("HF_HOME", str(Path(models_dir) / "huggingface"))
|
|
515
|
+
Path(models_dir).mkdir(parents=True, exist_ok=True)
|
|
516
|
+
|
|
517
|
+
port = int(_arg_value("--port") or os.environ.get("DSC_STT_PORT") or "0")
|
|
518
|
+
raw_parent = _arg_value("--parent-pid")
|
|
519
|
+
parent_pid = int(raw_parent) if raw_parent and raw_parent.isdigit() else None
|
|
520
|
+
|
|
521
|
+
server = ThreadingHTTPServer(("127.0.0.1", port), SttHandler)
|
|
522
|
+
server.daemon_threads = True
|
|
523
|
+
log(f"listening on 127.0.0.1:{server.server_address[1]} (models_dir={models_dir})")
|
|
524
|
+
|
|
525
|
+
def watchdog() -> None:
|
|
526
|
+
"""父进程(Node server)退出后自动退出,避免残留孤儿进程(Windows 上尤为必要)。"""
|
|
527
|
+
while True:
|
|
528
|
+
time.sleep(5)
|
|
529
|
+
if not parent_alive(parent_pid):
|
|
530
|
+
log(f"parent pid {parent_pid} gone, exiting")
|
|
531
|
+
server.shutdown()
|
|
532
|
+
return
|
|
533
|
+
|
|
534
|
+
if parent_pid is not None:
|
|
535
|
+
threading.Thread(target=watchdog, daemon=True).start()
|
|
536
|
+
|
|
537
|
+
try:
|
|
538
|
+
server.serve_forever()
|
|
539
|
+
except KeyboardInterrupt:
|
|
540
|
+
pass
|
|
541
|
+
|
|
542
|
+
|
|
543
|
+
if __name__ == "__main__":
|
|
544
|
+
main()
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
# DeepSeekCode 语音合成(TTS)依赖 — Kokoro-82M(ONNX,CPU 可跑)
|
|
2
|
+
#
|
|
3
|
+
# 安装:
|
|
4
|
+
# pip install -r packages/tts/requirements.txt
|
|
5
|
+
#
|
|
6
|
+
# 说明:
|
|
7
|
+
# - **不含 torch**:kokoro-onnx 走 onnxruntime,CPU 即可,依赖闭包约 40MB。
|
|
8
|
+
# - espeakng-loader 会连 espeak-ng 及其数据一起装上,用户**不需要**手动装 espeak-ng
|
|
9
|
+
# (这原本是 Windows 上最劝退的一步)。
|
|
10
|
+
# - misaki[zh] 是**中文可懂度的硬依赖**,不是可选优化:Kokoro 的中文音色是用 misaki 的
|
|
11
|
+
# 拼音 IPA(带调号)训练的,而 kokoro-onnx 自带的 espeak `cmn` 路径丢声母、无音调,
|
|
12
|
+
# 中文会听不懂且**不报错**。详见 docs/architecture-design/PERSONAL_REMOTE_AGENT_PLAN.md
|
|
13
|
+
# gotcha 11。misaki[zh] 只引入 pypinyin / jieba / cn2an / ordered-set / regex,无 torch。
|
|
14
|
+
# - Python 3.10+(用了 PEP 604 的 `X | None` 注解)。
|
|
15
|
+
# - 权重(model_uint8f16.onnx + voices-v1.0.bin)不由 pip 安装,由 Node 侧
|
|
16
|
+
# `packages/server/src/ttsDownload.ts` 下载到 ~/.agent/tts-models。
|
|
17
|
+
|
|
18
|
+
kokoro-onnx>=0.4.0
|
|
19
|
+
onnxruntime>=1.17.0
|
|
20
|
+
numpy>=1.24.0
|
|
21
|
+
misaki[zh]>=0.9.4
|