From c69719cf4a467539db609a9c299c20898ad1e346 Mon Sep 17 00:00:00 2001 From: buxuku Date: Mon, 10 Aug 2026 11:11:19 +0800 Subject: [PATCH] fix(sherpa): sanitize Qwen ASR JSON control characters --- extraResources/sherpa/README.md | 6 +- extraResources/sherpa/vendor/json-result.js | 74 +++++++++++++++++++ .../sherpa/vendor/non-streaming-asr.js | 5 +- package.json | 1 + scripts/test-sherpa-json-result.cjs | 26 +++++++ 5 files changed, 108 insertions(+), 4 deletions(-) create mode 100644 extraResources/sherpa/vendor/json-result.js create mode 100644 scripts/test-sherpa-json-result.cjs diff --git a/extraResources/sherpa/README.md b/extraResources/sherpa/README.md index f0c5664d..47c83b68 100644 --- a/extraResources/sherpa/README.md +++ b/extraResources/sherpa/README.md @@ -3,8 +3,10 @@ 封装 JS 复制自 npm `sherpa-onnx-node@1.13.2`(Apache-2.0)。 - `vendor/addon.js` 已替换为自定义加载器:从环境变量 `SHERPA_ONNX_LIB_DIR` - 用 `process.dlopen` 加载 `sherpa-onnx.node`(其余 `vendor/*.js` 原样保留, - 它们都经 `require('./addon.js')` 取原生模块)。 + 用 `process.dlopen` 加载 `sherpa-onnx.node`;vendor 文件都经 + `require('./addon.js')` 取原生模块。 +- `vendor/json-result.js` 由 `non-streaming-asr.js` 使用,处理原生识别结果中 + 的未转义控制字符;其余 `vendor/*.js` 原样保留。 - `vendor/addon-static-import.js` 在自定义加载器下不再被引用(保留以便升级对照)。 原生库**不在此处**,按需下载到 `userData/sherpa-onnx/current/` diff --git a/extraResources/sherpa/vendor/json-result.js b/extraResources/sherpa/vendor/json-result.js new file mode 100644 index 00000000..4d334b22 --- /dev/null +++ b/extraResources/sherpa/vendor/json-result.js @@ -0,0 +1,74 @@ +'use strict'; + +// Native sherpa results are JSON strings. Some Qwen outputs may contain raw +// C0 control characters inside the transcript text, which JSON.parse rejects. +function sanitizeJsonControlCharacters(jsonStr) { + let sanitized = ''; + let inString = false; + let escaped = false; + + for (const char of String(jsonStr)) { + const code = char.charCodeAt(0); + + if (inString) { + if (escaped) { + sanitized += char; + escaped = false; + continue; + } + if (char === '\\') { + sanitized += char; + escaped = true; + continue; + } + if (char === '"') { + sanitized += char; + inString = false; + continue; + } + if (code <= 0x1f) { + sanitized += escapeControlCharacter(char); + continue; + } + sanitized += char; + continue; + } + + if (char === '"') { + inString = true; + sanitized += char; + } else if (code > 0x1f || char === '\t' || char === '\n' || char === '\r') { + // Tabs, line feeds, and carriage returns are valid JSON whitespace + // outside strings. Other C0 controls are never valid JSON tokens. + sanitized += char; + } + } + + return sanitized; +} + +function escapeControlCharacter(char) { + switch (char) { + case '\b': + return '\\b'; + case '\f': + return '\\f'; + case '\n': + return '\\n'; + case '\r': + return '\\r'; + case '\t': + return '\\t'; + default: + return `\\u${char.charCodeAt(0).toString(16).padStart(4, '0')}`; + } +} + +function parseJsonResult(jsonStr) { + return JSON.parse(sanitizeJsonControlCharacters(jsonStr)); +} + +module.exports = { + parseJsonResult, + sanitizeJsonControlCharacters, +}; diff --git a/extraResources/sherpa/vendor/non-streaming-asr.js b/extraResources/sherpa/vendor/non-streaming-asr.js index 44ae0f4c..e29b31cd 100644 --- a/extraResources/sherpa/vendor/non-streaming-asr.js +++ b/extraResources/sherpa/vendor/non-streaming-asr.js @@ -8,6 +8,7 @@ */ const addon = require('./addon.js'); +const { parseJsonResult } = require('./json-result.js'); /** * Internal symbol to mark async-created recognizers. @@ -127,7 +128,7 @@ class OfflineRecognizer { this.handle, stream.handle, ); - return JSON.parse(jsonStr); + return parseJsonResult(jsonStr); } /** @@ -137,7 +138,7 @@ class OfflineRecognizer { */ getResult(stream) { const jsonStr = addon.getOfflineStreamResultAsJson(stream.handle); - return JSON.parse(jsonStr); + return parseJsonResult(jsonStr); } } diff --git a/package.json b/package.json index 34938a60..98b8cf74 100644 --- a/package.json +++ b/package.json @@ -38,6 +38,7 @@ "test:custom-languages": "tsc scripts/test-custom-languages.ts --outDir node_modules/.cache/custom-language-tests --module commonjs --moduleResolution node --target es2019 --esModuleInterop --skipLibCheck && node node_modules/.cache/custom-language-tests/scripts/test-custom-languages.js", "test:refine": "tsc scripts/test-refine-units.ts --outDir node_modules/.cache/refine-tests --module commonjs --moduleResolution node --target es2019 --esModuleInterop --skipLibCheck --resolveJsonModule && node node_modules/.cache/refine-tests/scripts/test-refine-units.js", "test:speaker-diarization": "node scripts/test-speaker-diarization-config.cjs && tsc scripts/test-speaker-diarization.ts --outDir node_modules/.cache/speaker-diarization-tests --module commonjs --moduleResolution node --target es2019 --esModuleInterop --skipLibCheck && node node_modules/.cache/speaker-diarization-tests/scripts/test-speaker-diarization.js", + "test:sherpa-json": "node scripts/test-sherpa-json-result.cjs", "test:proofread-speakers": "tsc scripts/test-proofread-speakers.ts --outDir node_modules/.cache/proofread-speaker-tests --module commonjs --moduleResolution node --target es2019 --esModuleInterop --skipLibCheck && node node_modules/.cache/proofread-speaker-tests/scripts/test-proofread-speakers.js", "test:manuscript": "tsc scripts/test-manuscript-matching.ts --outDir node_modules/.cache/manuscript-tests --module commonjs --moduleResolution node --target es2022 --esModuleInterop --skipLibCheck --resolveJsonModule && node node_modules/.cache/manuscript-tests/scripts/test-manuscript-matching.js", "longgap:gen": "tsc scripts/longgap/gen-audio.ts --outDir node_modules/.cache/longgap --module commonjs --moduleResolution node --target es2019 --esModuleInterop --skipLibCheck --resolveJsonModule && node node_modules/.cache/longgap/scripts/longgap/gen-audio.js", diff --git a/scripts/test-sherpa-json-result.cjs b/scripts/test-sherpa-json-result.cjs new file mode 100644 index 00000000..feeb1c76 --- /dev/null +++ b/scripts/test-sherpa-json-result.cjs @@ -0,0 +1,26 @@ +'use strict'; + +const assert = require('assert'); +const { + parseJsonResult, + sanitizeJsonControlCharacters, +} = require('../extraResources/sherpa/vendor/json-result.js'); + +const rawResult = '{"text":"first\nsecond\t\u0000end","tokens":["first"]}'; +assert.deepStrictEqual(parseJsonResult(rawResult), { + text: 'first\nsecond\t\u0000end', + tokens: ['first'], +}); + +const escapedResult = String.raw`{"text":"first\nsecond\t"}`; +assert.deepStrictEqual(parseJsonResult(escapedResult), { + text: 'first\nsecond\t', +}); + +assert.strictEqual(parseJsonResult('{"text":"ok"}\u0000\n').text, 'ok'); +assert.strictEqual( + sanitizeJsonControlCharacters('{"text":"a\u000bb"}'), + '{"text":"a\\u000bb"}', +); + +console.log('sherpa JSON result tests: 4 passed');