index-v6.js 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272
  1. /**
  2. * dsh-voice-server — local streaming ASR for the DSH Web GUI.
  3. *
  4. * Registers the exact-path upgrade route `/api/voice/asr` on the harness
  5. * webserver. The browser client opens a WebSocket and streams 16 kHz Int16
  6. * PCM; this plugin feeds sherpa-onnx's streaming Zipformer recognizer and
  7. * replies with partial/final transcripts. Fully offline — no cloud service.
  8. *
  9. * Wire protocol (JSON frames over the socket):
  10. * client → server: binary frames = Int16LE PCM @ 16 kHz mono
  11. * { "type": "stop" } → flush the final result and close
  12. * server → client: { "type": "partial", "text" } (interim)
  13. * { "type": "final", "text" } (committed segment)
  14. * { "type": "error", "message" }
  15. */
  16. import { existsSync, appendFileSync, readFileSync, mkdtempSync, rmSync } from "node:fs";
  17. import { join } from "node:path";
  18. import { tmpdir, homedir } from "node:os";
  19. import { spawn } from "node:child_process";
  20. import { WebSocketServer } from "ws";
  21. /** Default model directory (override with $DSH_VOICE_MODELS_DIR). */
  22. const DEFAULT_MODEL_DIR = "/Users/eastudio/Documents/DSWorkSpace/dsh-voice-models/zh-en-streaming";
  23. const MODEL_DIR = process.env.DSH_VOICE_MODELS_DIR || DEFAULT_MODEL_DIR;
  24. /** Recognition hotwords (domain terms to bias the recognizer toward). */
  25. const HOTWORDS_FILE = process.env.DSH_VOICE_HOTWORDS || "/Users/eastudio/Documents/DSWorkSpace/dsh-voice-models/hotwords.txt";
  26. /** Piper TTS model (local, offline). */
  27. const PIPER_MODEL = process.env.DSH_PIPER_MODEL || join(homedir(), "piper-models", "zh_CN-huayan-medium.onnx");
  28. const PIPER_CONFIG = process.env.DSH_PIPER_CONFIG || PIPER_MODEL + ".json";
  29. /** Diagnostic log (workspace-writable) to observe activation from inside the server process. */
  30. const DEBUG_LOG = "/Users/eastudio/Documents/DSWorkSpace/dsh-voice-server-debug.log";
  31. function dbg(msg) {
  32. try { appendFileSync(DEBUG_LOG, new Date().toISOString() + " " + msg + "\n"); } catch { /* noop */ }
  33. }
  34. dbg("module evaluated");
  35. let wss = null;
  36. /** Lazy-load sherpa-onnx (native module) and build one recognizer. */
  37. let recognizerPromise = null;
  38. function loadRecognizer() {
  39. if (recognizerPromise !== null) return recognizerPromise;
  40. recognizerPromise = (async () => {
  41. const sherpa = await import("sherpa-onnx-node");
  42. const encoder = join(MODEL_DIR, "encoder-epoch-99-avg-1.onnx");
  43. const decoder = join(MODEL_DIR, "decoder-epoch-99-avg-1.onnx");
  44. const joiner = join(MODEL_DIR, "joiner-epoch-99-avg-1.onnx");
  45. const tokens = join(MODEL_DIR, "tokens.txt");
  46. const missing = [encoder, decoder, joiner, tokens].filter((p) => !existsSync(p));
  47. if (missing.length > 0) {
  48. throw new Error(`dsh-voice-server: missing model files in ${MODEL_DIR}: ${missing.join(", ")}`);
  49. }
  50. const rec = new sherpa.OnlineRecognizer({
  51. featConfig: { sampleRate: 16000, featureDim: 80 },
  52. modelConfig: {
  53. transducer: { encoder, decoder, joiner },
  54. tokens,
  55. numThreads: 2,
  56. provider: "cpu",
  57. debug: 0
  58. },
  59. enableEndpoint: true,
  60. decodingMethod: "modified_beam_search",
  61. ...(existsSync(HOTWORDS_FILE) ? { hotwordsFile: HOTWORDS_FILE, hotwordsScore: 1.5 } : {})
  62. });
  63. if (rec === null || rec === undefined) throw new Error("dsh-voice-server: sherpa-onnx failed to create the recognizer");
  64. return rec;
  65. })();
  66. recognizerPromise.catch(() => { recognizerPromise = null; }); // allow retry after failure
  67. return recognizerPromise;
  68. }
  69. function send(socket, payload) {
  70. if (socket.readyState === socket.OPEN) socket.send(JSON.stringify(payload));
  71. }
  72. /** One active connection: its online stream + accumulated text. */
  73. function createSession(rec) {
  74. const stream = rec.createStream();
  75. // Trailing silence flushes the last syllable the streaming model otherwise drops.
  76. const TAIL_SILENCE = new Float32Array(9600); // 0.6s @ 16 kHz
  77. return {
  78. stream,
  79. closed: false,
  80. endpointSent: false,
  81. feed(pcm16 /* Int16Array */) {
  82. const samples = new Float32Array(pcm16.length);
  83. for (let i = 0; i < pcm16.length; i++) samples[i] = pcm16[i] / 32768;
  84. stream.acceptWaveform({ samples, sampleRate: 16000 });
  85. while (rec.isReady(stream)) rec.decode(stream);
  86. if (!this.endpointSent && rec.isEndpoint(stream)) {
  87. // Speech ended (trailing silence detected) — signal the client once.
  88. this.endpointSent = true;
  89. return { type: "endpoint", text: (rec.getResult(stream).text || "").trim() };
  90. }
  91. const partial = (rec.getResult(stream).text || "").trim();
  92. if (partial) return { type: "partial", text: partial };
  93. return null;
  94. },
  95. finalize() {
  96. stream.acceptWaveform({ samples: TAIL_SILENCE, sampleRate: 16000 });
  97. try { stream.inputFinished(); } catch { /* noop */ }
  98. while (rec.isReady(stream)) rec.decode(stream);
  99. this.closed = true;
  100. return (rec.getResult(stream).text || "").trim();
  101. }
  102. };
  103. }
  104. function attachSession(socket) {
  105. loadRecognizer()
  106. .then((rec) => {
  107. if (socket.readyState !== socket.OPEN) return;
  108. const session = createSession(rec);
  109. socket.on("message", (data, isBinary) => {
  110. if (session.closed) return;
  111. if (isBinary) {
  112. try {
  113. const pcm16 = new Int16Array(data.buffer, data.byteOffset, data.byteLength >> 1);
  114. const ev = session.feed(pcm16);
  115. if (ev) send(socket, ev);
  116. } catch (err) {
  117. send(socket, { type: "error", message: "decode error: " + String(err && err.message || err) });
  118. }
  119. return;
  120. }
  121. let msg;
  122. try { msg = JSON.parse(data.toString()); } catch { return; }
  123. if (msg && msg.type === "stop") {
  124. const final = session.finalize();
  125. if (final.trim()) send(socket, { type: "final", text: final });
  126. session.closed = true;
  127. try { socket.close(); } catch { /* noop */ }
  128. }
  129. });
  130. socket.on("close", () => { /* stream has no explicit free in 1.x; GC reclaims */ });
  131. })
  132. .catch((err) => {
  133. dbg("recognizer error: " + String(err && err.message || err));
  134. send(socket, { type: "error", message: String(err && err.message || err) });
  135. try { socket.close(); } catch { /* noop */ }
  136. });
  137. }
  138. /**
  139. * Synthesize speech via edge-tts (online, natural) or piper (offline).
  140. * @returns {Promise<{data: Buffer, contentType: string}>}
  141. */
  142. function synthesize({ text, engine, voice, rate }) {
  143. return new Promise((resolve, reject) => {
  144. const dir = mkdtempSync(join(tmpdir(), "dsh-tts-"));
  145. const finish = (outPath, contentType) => {
  146. try {
  147. const data = readFileSync(outPath);
  148. resolve({ data, contentType });
  149. } catch (err) {
  150. reject(err);
  151. } finally {
  152. try { rmSync(dir, { recursive: true, force: true }); } catch { /* noop */ }
  153. }
  154. };
  155. let child;
  156. if (engine === "piper") {
  157. const outPath = join(dir, "out.wav");
  158. const args = ["-m", "piper", "-m", PIPER_MODEL, "-c", PIPER_CONFIG, "-f", outPath];
  159. if (rate && rate > 0) args.push("--length-scale", String(Math.max(0.4, Math.min(2, 1 / rate))));
  160. child = spawn("python3", args);
  161. let stderr = "";
  162. child.stderr.on("data", (d) => { stderr += d; });
  163. child.on("error", (err) => reject(err));
  164. child.on("close", (code) => {
  165. if (code !== 0) return reject(new Error("piper 合成失败: " + (stderr.trim() || code)));
  166. finish(outPath, "audio/wav");
  167. });
  168. child.stdin.write(text, "utf8");
  169. child.stdin.end();
  170. return;
  171. }
  172. // edge-tts (default)
  173. const outPath = join(dir, "out.mp3");
  174. const v = voice || "zh-CN-XiaoxiaoNeural";
  175. const args = ["-m", "edge_tts", "--voice", v, "--text", text, "--write-media", outPath];
  176. if (rate && rate > 0) {
  177. const pct = Math.round((rate - 1) * 100);
  178. if (pct !== 0) args.push("--rate", (pct > 0 ? "+" : "") + pct + "%");
  179. }
  180. child = spawn("python3", args);
  181. let stderr = "";
  182. child.stderr.on("data", (d) => { stderr += d; });
  183. child.on("error", (err) => reject(err));
  184. child.on("close", (code) => {
  185. if (code !== 0) return reject(new Error("edge-tts 合成失败: " + (stderr.trim() || code)));
  186. finish(outPath, "audio/mpeg");
  187. });
  188. });
  189. }
  190. /** Read a JSON request body. */
  191. async function readJson(req) {
  192. let body = "";
  193. for await (const chunk of req) body += chunk;
  194. if (!body) return {};
  195. try { return JSON.parse(body); } catch { return null; }
  196. }
  197. export const inject = ["webServer"];
  198. export function apply(ctx) {
  199. dbg("apply called");
  200. if (wss === null) wss = new WebSocketServer({ noServer: true });
  201. ctx.effect(() => {
  202. const disposeUpgrade = ctx.webServer.registerUpgrade({
  203. path: "/api/voice/asr",
  204. handler: (req, socket, head) => {
  205. wss.handleUpgrade(req, socket, head, (ws) => {
  206. attachSession(ws);
  207. });
  208. }
  209. });
  210. const disposePing = ctx.webServer.register({
  211. kind: "exact",
  212. path: "/api/voice/ping",
  213. handler: (req, res) => {
  214. res.writeHead(200, { "content-type": "application/json; charset=utf-8" });
  215. res.end(JSON.stringify({ ok: true, service: "dsh-voice-server" }));
  216. }
  217. });
  218. const disposeTts = ctx.webServer.register({
  219. kind: "exact",
  220. path: "/api/voice/tts",
  221. handler: async (req, res) => {
  222. if (req.method !== "POST") {
  223. res.writeHead(405, { "content-type": "application/json; charset=utf-8" });
  224. res.end(JSON.stringify({ error: "method not allowed" }));
  225. return;
  226. }
  227. const parsed = await readJson(req);
  228. if (parsed === null || typeof parsed.text !== "string" || !parsed.text.trim()) {
  229. res.writeHead(400, { "content-type": "application/json; charset=utf-8" });
  230. res.end(JSON.stringify({ error: "missing text" }));
  231. return;
  232. }
  233. try {
  234. const { data, contentType } = await synthesize({
  235. text: parsed.text,
  236. engine: parsed.engine === "piper" ? "piper" : "edge",
  237. voice: parsed.voice,
  238. rate: parsed.rate
  239. });
  240. res.writeHead(200, { "content-type": contentType, "content-length": data.length, "cache-control": "no-cache" });
  241. res.end(data);
  242. } catch (err) {
  243. dbg("tts error: " + String(err && err.message || err));
  244. res.writeHead(500, { "content-type": "application/json; charset=utf-8" });
  245. res.end(JSON.stringify({ error: String(err && err.message || err) }));
  246. }
  247. }
  248. });
  249. dbg("routes registered");
  250. return () => {
  251. dbg("effect disposed — routes torn down");
  252. disposeUpgrade();
  253. disposePing();
  254. disposeTts();
  255. };
  256. }, "dsh-voice-server: voice routes");
  257. }