Forward speech proxy text messages as strings
- Convert non-binary upstream Xunfei WebSocket messages to UTF-8 strings before forwarding them to browser clients. - Export and test raw WebSocket data to text conversion for speech proxy forwarding. - Log unparseable speech responses in the report editor instead of silently swallowing them. - Update report editor, progress, and testing documentation for text-message forwarding.
This commit is contained in:
@@ -89,7 +89,7 @@ AI 面板支持两种模式:
|
|||||||
- 浏览器采集麦克风音频后按实际 `AudioContext.sampleRate` 重采样为 16k、16bit、单声道 PCM,并按小帧发送音频帧。
|
- 浏览器采集麦克风音频后按实际 `AudioContext.sampleRate` 重采样为 16k、16bit、单声道 PCM,并按小帧发送音频帧。
|
||||||
- 启动前会检查浏览器是否支持 `navigator.mediaDevices.getUserMedia` 和 `AudioContext`;如果不是 `localhost` 或 HTTPS 等安全上下文,浏览器会禁止麦克风能力。Docker 演示环境可使用 `https://localhost:4443`,局域网普通 HTTP 只能通过 Chrome/Edge 演示启动参数临时标记为可信来源。
|
- 启动前会检查浏览器是否支持 `navigator.mediaDevices.getUserMedia` 和 `AudioContext`;如果不是 `localhost` 或 HTTPS 等安全上下文,浏览器会禁止麦克风能力。Docker 演示环境可使用 `https://localhost:4443`,局域网普通 HTTP 只能通过 Chrome/Edge 演示启动参数临时标记为可信来源。
|
||||||
- 后端读取 Settings API 中的 `xfSpeechConfig`,连接讯飞 IAT,上游首帧由后端补齐 `common.app_id` 和默认 `business` 参数。
|
- 后端读取 Settings API 中的 `xfSpeechConfig`,连接讯飞 IAT,上游首帧由后端补齐 `common.app_id` 和默认 `business` 参数。
|
||||||
- 识别结果由后端转发回前端,并追加到 AI 输入框。
|
- 识别结果由后端按字符串转发回前端,并追加到 AI 输入框。
|
||||||
|
|
||||||
## 保存规则
|
## 保存规则
|
||||||
|
|
||||||
|
|||||||
@@ -95,3 +95,4 @@
|
|||||||
| 2026-05-02 | 调整抽帧百分比为两位小数保序保存;自动截图按时间顺序执行,自动插入按配置顺序执行。 |
|
| 2026-05-02 | 调整抽帧百分比为两位小数保序保存;自动截图按时间顺序执行,自动插入按配置顺序执行。 |
|
||||||
| 2026-05-02 | 加固报告编辑器语音采集,保留 Web Audio 节点引用、显式恢复 AudioContext,并在无识别文本时给出提示。 |
|
| 2026-05-02 | 加固报告编辑器语音采集,保留 Web Audio 节点引用、显式恢复 AudioContext,并在无识别文本时给出提示。 |
|
||||||
| 2026-05-02 | 对齐讯飞 IAT 音频帧协议,前端按实际采样率重采样到 16k PCM、按小帧发送,并使用标准结束帧。 |
|
| 2026-05-02 | 对齐讯飞 IAT 音频帧协议,前端按实际采样率重采样到 16k PCM、按小帧发送,并使用标准结束帧。 |
|
||||||
|
| 2026-05-02 | 修复讯飞代理返回方向的消息类型,确保上游 TextMessage 以字符串转发到浏览器,避免前端静默解析失败。 |
|
||||||
|
|||||||
@@ -33,7 +33,7 @@ npm run build
|
|||||||
| 后端用户映射 | Users API 返回前端可消费的 `User` 结构,并把部门模板授权映射成 `visibleTemplates/manageableTemplates`。 |
|
| 后端用户映射 | Users API 返回前端可消费的 `User` 结构,并把部门模板授权映射成 `visibleTemplates/manageableTemplates`。 |
|
||||||
| 后端设置校验 | Settings API 使用 schema 校验抽帧、AI Provider 和语音配置。 |
|
| 后端设置校验 | Settings API 使用 schema 校验抽帧、AI Provider 和语音配置。 |
|
||||||
| 后端 AI 代理入参 | AI Proxy 使用 schema 校验 OpenAI 兼容消息和多模态内容。 |
|
| 后端 AI 代理入参 | AI Proxy 使用 schema 校验 OpenAI 兼容消息和多模态内容。 |
|
||||||
| 后端语音代理帧处理 | Speech Proxy 对首个讯飞 IAT 音频帧补齐 APPID 和默认业务参数,后续帧保持兼容。 |
|
| 后端语音代理帧处理 | Speech Proxy 对首个讯飞 IAT 音频帧补齐 APPID 和默认业务参数,后续帧保持兼容,并把上游文本消息按字符串转发给浏览器。 |
|
||||||
| 后端字段库和文件 schema | Library/Files API 校验字段库和通用文件上传 payload。 |
|
| 后端字段库和文件 schema | Library/Files API 校验字段库和通用文件上传 payload。 |
|
||||||
| 后端 HTTP 集成 | Nest HTTP 层覆盖 API prefix、登录会话、未登录保护、受保护接口 actor 传递。 |
|
| 后端 HTTP 集成 | Nest HTTP 层覆盖 API prefix、登录会话、未登录保护、受保护接口 actor 传递。 |
|
||||||
| 后端真实数据库集成 | 使用 Prisma/PostgreSQL 覆盖 Auth、Dashboard、Reports、ReportMedia、Templates、Files、HTML 清洗和审计核心服务。 |
|
| 后端真实数据库集成 | 使用 Prisma/PostgreSQL 覆盖 Auth、Dashboard、Reports、ReportMedia、Templates、Files、HTML 清洗和审计核心服务。 |
|
||||||
@@ -114,7 +114,7 @@ AI 第三方接口、讯飞语音上游 WebSocket、麦克风权限和真实视
|
|||||||
| 报告编辑器完整流程 | 部分覆盖 | 已覆盖保存修订版本、个人模板和后端草稿/完成报告 schema;模板切换、字段同步仍待补。 |
|
| 报告编辑器完整流程 | 部分覆盖 | 已覆盖保存修订版本、个人模板和后端草稿/完成报告 schema;模板切换、字段同步仍待补。 |
|
||||||
| 视频抽帧 | 待 E2E/人工 | 依赖真实视频解码和 canvas。 |
|
| 视频抽帧 | 待 E2E/人工 | 依赖真实视频解码和 canvas。 |
|
||||||
| AI 撰写 | 待集成测试 | 需要隔离外部模型服务。 |
|
| AI 撰写 | 待集成测试 | 需要隔离外部模型服务。 |
|
||||||
| 讯飞语音听写 | 部分覆盖/待集成测试 | 已覆盖前端 16k PCM 处理、后端首帧处理和 WebSocket 地址生成;完整链路仍需要 WebSocket 集成测试、麦克风权限和测试凭证。 |
|
| 讯飞语音听写 | 部分覆盖/待集成测试 | 已覆盖前端 16k PCM 处理、后端首帧处理、上游文本消息转发和 WebSocket 地址生成;完整链路仍需要 WebSocket 集成测试、麦克风权限和测试凭证。 |
|
||||||
|
|
||||||
## Playwright 说明
|
## Playwright 说明
|
||||||
|
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import type { RawData, WebSocket } from 'ws';
|
|||||||
import Ws from 'ws';
|
import Ws from 'ws';
|
||||||
import type { SafeUser } from '../auth/auth.types.js';
|
import type { SafeUser } from '../auth/auth.types.js';
|
||||||
import { SettingsService } from '../settings/settings.service.js';
|
import { SettingsService } from '../settings/settings.service.js';
|
||||||
import { prepareXfIatFrame } from './xf-frame.js';
|
import { prepareXfIatFrame, rawDataToText } from './xf-frame.js';
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class SpeechService {
|
export class SpeechService {
|
||||||
@@ -24,9 +24,9 @@ export class SpeechService {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
upstream.on('message', (data) => {
|
upstream.on('message', (data, isBinary) => {
|
||||||
if (client.readyState === Ws.OPEN) {
|
if (client.readyState === Ws.OPEN) {
|
||||||
client.send(data);
|
client.send(isBinary ? data : rawDataToText(data));
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { describe, expect, it } from 'vitest';
|
import { describe, expect, it } from 'vitest';
|
||||||
import { prepareXfIatFrame } from './xf-frame.js';
|
import { prepareXfIatFrame, rawDataToText } from './xf-frame.js';
|
||||||
|
|
||||||
describe('prepareXfIatFrame', () => {
|
describe('prepareXfIatFrame', () => {
|
||||||
it('adds server-side app id and default business options to the first frame', () => {
|
it('adds server-side app id and default business options to the first frame', () => {
|
||||||
@@ -28,4 +28,9 @@ describe('prepareXfIatFrame', () => {
|
|||||||
it('keeps non-json payloads unchanged', () => {
|
it('keeps non-json payloads unchanged', () => {
|
||||||
expect(prepareXfIatFrame('not-json', 'test-app-id')).toBe('not-json');
|
expect(prepareXfIatFrame('not-json', 'test-app-id')).toBe('not-json');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('converts websocket raw data to text for browser forwarding', () => {
|
||||||
|
expect(rawDataToText(Buffer.from('{"code":0}', 'utf8'))).toBe('{"code":0}');
|
||||||
|
expect(rawDataToText([Buffer.from('{"code"'), Buffer.from(':0}')])).toBe('{"code":0}');
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -29,7 +29,7 @@ export const prepareXfIatFrame = (data: RawData | string, appId: string) => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const rawDataToText = (data: RawData | string) => {
|
export const rawDataToText = (data: RawData | string) => {
|
||||||
if (typeof data === 'string') return data;
|
if (typeof data === 'string') return data;
|
||||||
if (Buffer.isBuffer(data)) return data.toString('utf8');
|
if (Buffer.isBuffer(data)) return data.toString('utf8');
|
||||||
if (Array.isArray(data)) return Buffer.concat(data).toString('utf8');
|
if (Array.isArray(data)) return Buffer.concat(data).toString('utf8');
|
||||||
|
|||||||
@@ -1282,7 +1282,9 @@ export default function ReportEditor() {
|
|||||||
setIsListening(false);
|
setIsListening(false);
|
||||||
stopMicrophone();
|
stopMicrophone();
|
||||||
}
|
}
|
||||||
} catch {}
|
} catch (error) {
|
||||||
|
console.warn('无法解析讯飞语音返回', error, event.data);
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
ws.onerror = () => { alert('讯飞语音连接失败,请确认已登录且超级管理员已配置语音参数'); setIsListening(false); stopMicrophone(); };
|
ws.onerror = () => { alert('讯飞语音连接失败,请确认已登录且超级管理员已配置语音参数'); setIsListening(false); stopMicrophone(); };
|
||||||
|
|||||||
Reference in New Issue
Block a user