feat(flow): 添加语音输入节点功能

- 新增 MicrophoneNode 组件,支持语音识别与转文字功能
- 在节点类型配置中注册了 'MICRO' 类型及其映射关系
- 实现音频采集与 WebSocket 实时传输模块
- 提供 audioService 封装录音与语音识别逻辑
- 添加 NodeContentMicrophone 组件用于展示语音节点 UI 与控制按钮
- 集成 WebSocketConnectMethod 处理与语音服务端的通信
- 在侧边栏配置中增加“语音输入”节点选项
- 支持开始/停止录音及实时结果显示功能
master
钟良源 2 months ago
parent fbd60bc488
commit 45fe56c543

@ -9,6 +9,7 @@ import ImageNode from './imageNode/ImageNode';
import RestNode from './restNode/RestNode';
import SwitchNode from './switchNode/SwitchNode';
import LoopNode from './loopNode/LoopNode';
import MicrophoneNode from './microphoneNode/MicrophoneNode';
// 定义所有可用的节点类型
export const nodeTypes: NodeTypes = {
@ -21,7 +22,8 @@ export const nodeTypes: NodeTypes = {
IMAGE: ImageNode,
REST: RestNode,
SWITCH: SwitchNode,
LOOP: LoopNode
LOOP: LoopNode,
MICRO: MicrophoneNode
};
// 节点类型映射,用于创建节点时的类型查找
@ -35,7 +37,8 @@ export const nodeTypeMap: Record<string, string> = {
'image': 'IMAGE',
'rest': 'REST',
'switch': 'SWITCH',
'loop': 'LOOP'
'loop': 'LOOP',
'micro': 'MICRO'
};
// 节点显示名称映射
@ -49,7 +52,8 @@ export const nodeTypeNameMap: Record<string, string> = {
'image': '图片节点',
'rest': 'REST节点',
'switch': '条件节点',
'loop': '循环节点'
'loop': '循环节点',
'micro': '语音节点'
};
// 注册新节点类型的函数

@ -0,0 +1,43 @@
import React from 'react';
import { useStore } from '@xyflow/react';
import styles from '@/components/FlowEditor/node/style/baseOther.module.less';
import DynamicIcon from '@/components/DynamicIcon';
import NodeContentMicrophone from '@/pages/flowEditor/components/nodeContentMicrophone';
import NodeStatusIndicator, { NodeStatus } from '@/components/FlowEditor/NodeStatusIndicator';
import { useStore as useFlowStore } from '@xyflow/react';
const setIcon = () => {
return <DynamicIcon type="IconVoice" style={{ fontSize: '16px', marginRight: '5px' }} />;
};
const MicrophoneNode = ({ data, id }: { data: any; id: string }) => {
const title = data.title || '语音输入';
// 获取节点选中状态 - 适配React Flow v12 API
const isSelected = useStore((state) =>
state.nodeLookup.get(id)?.selected || false
);
// 获取节点运行状态
const nodeStatus: NodeStatus = useFlowStore((state) =>
(state.nodeLookup.get(id)?.data?.status as NodeStatus) || 'waiting'
);
// 获取运行状态可见性
const isStatusVisible = useFlowStore((state) =>
!!state.nodeLookup.get(id)?.data?.isStatusVisible
);
return (
<div className={`${styles['node-container']} ${isSelected ? styles.selected : ''}`}>
<div className={styles['node-header']} style={{ backgroundColor: '#1890ff' }}>
{setIcon()}
{title}
<NodeStatusIndicator status={nodeStatus} isVisible={isStatusVisible} />
</div>
<NodeContentMicrophone data={data} />
</div>
);
};
export default MicrophoneNode;

@ -0,0 +1,140 @@
import Recorder from 'recorder-core';
import 'recorder-core/src/engine/pcm';
import 'recorder-core/src/engine/wav';
import WebSocketConnectMethod from './wsconnecter';
let wsconnecter;
let rec;
export const audioService = setResult => {
// 判断当前访问协议匹配websocket连接
// const protocol = window.location.protocol.replace(':', '');
// const url = protocol === 'http' ? 'ws://asr.ngsk.tech:7000/' : 'wss://asr.ngsk.tech:7000/';
// const url = 'wss://asr.ngsk.tech:7001/';
const url = 'wss://medicalai.ngsk.tech:7001/api/v1/ws';
// 连接; 定义socket连接类对象与语音对象
let sampleBuf = new Int16Array();
let rec_text = '';
let offline_text = '';
let isRec = false;
// 语音识别结果; 对jsonMsg数据解析,将识别结果附加到编辑框中
function getJsonMessage(jsonMsg) {
console.log(`message: ${JSON.parse(jsonMsg.data).text}`);
const rectxt = `${JSON.parse(jsonMsg.data).text}`;
const asrmodel = JSON.parse(jsonMsg.data).mode;
const {is_final} = JSON.parse(jsonMsg.data);
if (asrmodel === '2pass-offline' || asrmodel === 'offline') {
offline_text = `${offline_text + rectxt.replace(/ +/g, '')}\n`; // handleWithTimestamp(rectxt,timestamp); //rectxt; //.replace(/ +/g,"");
rec_text = offline_text;
} else {
rec_text += rectxt; // .replace(/ +/g,"");
}
if (rec_text.length > 0) {
setResult(rec_text);
}
console.log(`offline_text: ${asrmodel},${offline_text}`);
console.log(`rec_text: ${rec_text}`);
}
// 连接状态响应
function getConnState(connState) {
if (connState === 2) {
stop();
console.log('connection error');
}
}
// 开始录音
function record() {
// 录音; 定义录音对象,wav格式
rec = Recorder({
type: 'pcm',
bitRate: 16,
sampleRate: 16000,
onProcess: recProcess
});
rec.open(function () {
rec.start();
console.log('开始');
});
}
// 识别启动、停止、清空操作
function connect() {
// 控件状态更新
wsconnecter = new WebSocketConnectMethod({
msgHandle: getJsonMessage,
stateHandle: getConnState,
url
});
// 启动连接
const ret = wsconnecter.wsStart();
// 1 is ok, 0 is error
if (ret === 1) {
isRec = true;
record();
return 1;
}
return 0;
}
function stop() {
rec_text = '';
offline_text = '';
if (sampleBuf.length > 0) {
wsconnecter.wsSend(sampleBuf);
console.log(`sampleBuf.length${sampleBuf.length}`);
sampleBuf = new Int16Array();
}
// 控件状态更新
isRec = false;
const request = {
chunk_interval: 10,
chunk_size: [5, 10, 5],
is_speaking: false,
mode: '2pass',
wav_name: 'h5'
};
wsconnecter.wsSend(JSON.stringify(request));
// wait 3s for asr result
rec.stop(
function (blob, duration) {
console.log(blob);
},
function (errMsg) {
console.log(`errMsg: ${errMsg}`);
}
);
setTimeout(function () {
console.log('call stop ws!');
wsconnecter.wsStop();
}, 1000);
}
function recProcess(buffer, powerLevel, bufferDuration, bufferSampleRate, newBufferIdx, asyncEnd) {
if (isRec === true) {
const data_48k = buffer[buffer.length - 1];
const array_48k = new Array(data_48k);
const data_16k = Recorder.SampleData(array_48k, bufferSampleRate, 16000).data;
sampleBuf = Int16Array.from([...sampleBuf, ...data_16k]);
const chunk_size = 960; // for asr chunk_size [5, 10, 5]
while (sampleBuf.length >= chunk_size) {
const sendBuf = sampleBuf.slice(0, chunk_size);
sampleBuf = sampleBuf.slice(chunk_size, sampleBuf.length);
wsconnecter.wsSend(sendBuf);
}
}
}
return {
stop,
record,
connect
};
};

@ -0,0 +1,96 @@
/**
* Copyright FunASR (https://github.com/alibaba-damo-academy/FunASR). All Rights
* Reserved. MIT License (https://opensource.org/licenses/MIT)
*/
/* 2021-2023 by zhaoming,mali aihealthx.com */
export default function WebSocketConnectMethod(config) {
//定义socket连接方法类
let speechSokt;
let connKeeperID;
let msgHandle = config.msgHandle;
let stateHandle = config.stateHandle;
this.wsStart = function () {
let Uri = config.url; //"wss://111.205.137.58:5821/wss/" //设置wss asr online接口地址 如 wss://X.X.X.X:port/wss/
if (Uri.match(/wss:\S*|ws:\S*/)) {
console.log('Uri' + Uri);
} else {
alert('请检查wss地址正确性');
return 0;
}
if ('WebSocket' in window) {
speechSokt = new WebSocket(Uri); // 定义socket连接对象
speechSokt.onopen = function (e) {
onOpen(e);
}; // 定义响应函数
speechSokt.onclose = function (e) {
console.log('onclose ws!');
//speechSokt.close();
onClose(e);
};
speechSokt.onmessage = function (e) {
onMessage(e);
};
speechSokt.onerror = function (e) {
onError(e);
};
return 1;
} else {
alert('当前浏览器不支持 WebSocket');
return 0;
}
};
// 定义停止与发送函数
this.wsStop = function () {
if (speechSokt !== undefined) {
console.log('stop ws!');
speechSokt.close();
}
};
this.wsSend = function (oneData) {
if (speechSokt === undefined) return;
if (speechSokt.readyState === 1) {
// 0:CONNECTING, 1:OPEN, 2:CLOSING, 3:CLOSED
speechSokt.send(oneData);
}
};
// SOCEKT连接中的消息与状态响应
function onOpen(e) {
// 发送json
let chunk_size = new Array(5, 10, 5);
let request = {
chunk_interval: 10,
chunk_size: chunk_size,
hotwords: '{"阿里巴巴":20,"hello world":40}',
is_speaking: true,
itn: false,
mode: '2pass',
wav_name: 'h5'
};
console.log(JSON.stringify(request));
speechSokt.send(JSON.stringify(request));
console.log('连接成功');
stateHandle(0);
}
function onClose(e) {
console.log(e);
stateHandle(1);
}
function onMessage(e) {
msgHandle(e);
}
function onError(e) {
console.log(e);
stateHandle(2);
}
}

@ -0,0 +1,47 @@
<script setup lang="ts">
import { ref } from 'vue';
import { audioService } from '@/components/audio-recognize/audio/main';
const emit = defineEmits(['recordFinish']);
const isRecording = ref(false);
const resultText = ref('');
function setResultText(value: string) {
resultText.value = value;
}
const { connect, stop } = audioService(setResultText);
function handleComplete() {
if (isRecording.value) {
console.log('用户结束语音转文字');
stop();
isRecording.value = false;
emit('recordFinish', { result: resultText.value });
}
}
function handleStartRecord() {
if (!isRecording.value) {
console.log('用户开始语音转文字');
connect();
isRecording.value = true;
}
}
</script>
<template>
<div style="margin-right: 10px">
<a-button v-if="isRecording" type="secondary" @click="handleComplete">
<template #icon>
<icon-pause />
</template>
</a-button>
<a-button v-else type="primary" @click="handleStartRecord">
<template #icon>
<icon-customer-service />
</template>
</a-button>
</div>
</template>

@ -0,0 +1,311 @@
import React, { useEffect, useState } from 'react';
import styles from '@/components/FlowEditor/node/style/baseOther.module.less';
import { Handle, Position } from '@xyflow/react';
import { formatDataType } from '@/utils/common';
import { Button } from '@arco-design/web-react';
import { audioService } from '@/components/audio-recognize/audio/main';
interface NodeContentData {
parameters?: {
dataIns?: any[];
dataOuts?: any[];
apiIns?: any[];
apiOuts?: any[];
};
showFooter?: boolean;
type?: string;
[key: string]: any;
}
// 定义通用的句柄样式
const handleStyles = {
mainSource: {
background: '#2290f6',
width: '8px',
height: '8px',
border: '2px solid #fff',
boxShadow: '0 0 4px rgba(0,0,0,0.2)'
},
mainTarget: {
background: '#2290f6',
width: '8px',
height: '8px',
border: '2px solid #fff',
boxShadow: '0 0 4px rgba(0,0,0,0.2)'
},
data: {
background: '#555',
width: '6px',
height: '6px',
border: '1px solid #fff',
boxShadow: '0 0 2px rgba(0,0,0,0.2)'
}
};
// 渲染特殊节点(开始/结束节点)的句柄
const renderSpecialNodeHandles = (isStartNode: boolean, isEndNode: boolean, dataIns: any[], dataOuts: any[], apiIns: any[], apiOuts: any[]) => {
const renderStartNodeHandles = () => {
if (!isStartNode) return null;
return (
<>
{apiOuts.map((_, index) => (
<Handle
key={`start-output-handle-${index}`}
type="source"
position={Position.Right}
id={apiOuts[index].name || `start-output-${index}`}
style={{
...handleStyles.mainSource,
top: `${35 + index * 20}px`
}}
/>
))}
{dataOuts.length > 0 && dataOuts.map((_, index) => (
<Handle
key={`output-handle-${index}`}
type="source"
position={Position.Right}
id={dataOuts[index].name || `output-${index}`}
style={{
...handleStyles.data,
top: `${70 + apiIns.length * 20 + index * 20}px`
}}
/>
))}
</>
);
};
const renderEndNodeHandles = () => {
if (!isEndNode) return null;
return (
<>
{apiIns.map((_, index) => (
<Handle
key={`end-input-handle-${index}`}
type="target"
position={Position.Left}
id={apiIns[index].name || `end-input-${index}`}
style={{
...handleStyles.mainTarget,
top: `${35 + index * 20}px`
}}
/>
))}
{dataIns.length > 0 && dataIns.map((_, index) => (
<Handle
key={`input-handle-${index}`}
type="target"
position={Position.Left}
id={dataIns[index].name || `input-${index}`}
style={{
...handleStyles.data,
top: `${70 + apiIns.length * 20 + index * 20}px`
}}
/>
))}
</>
);
};
return (
<>
{renderStartNodeHandles()}
{renderEndNodeHandles()}
</>
);
};
// 渲染普通节点的句柄
const renderRegularNodeHandles = (dataIns: any[], dataOuts: any[], apiIns: any[], apiOuts: any[]) => {
return (
<>
{apiOuts.map((_, index) => (
<Handle
key={`api-output-handle-${index}`}
type="source"
position={Position.Right}
id={apiOuts[index].name || apiOuts[index].id || `output-${index}`}
style={{
...handleStyles.mainSource,
top: `${37 + index * 22}px`
}}
/>
))}
{apiIns.map((_, index) => (
<Handle
key={`api-input-handle-${index}`}
type="target"
position={Position.Left}
id={apiIns[index].name || apiIns[index].id || `input-${index}`}
style={{
...handleStyles.mainTarget,
top: `${37 + index * 22}px`
}}
/>
))}
{/* 输入参数连接端点 */}
{dataIns.map((_, index) => (
<Handle
key={`data-input-handle-${index}`}
type="target"
position={Position.Left}
id={dataIns[index].name || dataIns[index].id || `input-${index}`}
style={{
...handleStyles.data,
top: `${65 + (apiIns.length + index) * 22}px`
}}
/>
))}
{/* 输出参数连接端点 */}
{dataOuts.map((_, index) => (
<Handle
key={`data-output-handle-${index}`}
type="source"
position={Position.Right}
id={dataOuts[index].name || dataOuts[index].id || `output-${index}`}
style={{
...handleStyles.data,
top: `${65 + (apiIns.length + index) * 22}px`
}}
/>
))}
</>
);
};
const formatTitle = (text) => {
return text === 'start' || text === 'end' ? '' : text;
};
const NodeContentMicrophone = ({ data }: { data: NodeContentData }) => {
const [isRecording, setIsRecording] = useState(false);
const [resultText, setResultText] = useState('');
const { connect, stop } = audioService(setResultText);
const apiIns = data.parameters?.apiIns || [];
const apiOuts = data.parameters?.apiOuts || [];
const dataIns = data.parameters?.dataIns || [];
const dataOuts = data.parameters?.dataOuts || [];
// 判断节点类型
const isStartNode = data.type === 'start';
const isEndNode = data.type === 'end';
const isSpecialNode = isStartNode || isEndNode;
const handleStartRecordWithCheck = () => {
setIsRecording(true);
handleStartRecord();
};
const handleCompleteWithCheck = () => {
setIsRecording(false);
handleComplete();
};
const handleStartRecord = () => {
console.log('用户开始语音转文字');
connect();
};
const handleComplete = () => {
console.log('用户结束语音转文字');
stop();
// TODO 调接口 等待后端出接口
// voiceTrigger(params);
};
useEffect(() => {
console.log('resultText:', resultText);
}, [resultText]);
return (
<>
{/*content栏-api部分*/}
<div className={styles['node-api-box']}>
<div className={styles['node-content-api']}>
{apiIns.length > 0 && (
<div className={styles['node-inputs']}>
{apiIns.map((input, index) => (
<div key={input.id || `input-${index}`} className={styles['node-input-label']}>
{formatTitle(input.desc || input.id || input.name)}
</div>
))}
</div>
)}
{apiOuts.length > 0 && (
<div className={styles['node-outputs']}>
{apiOuts.map((output, index) => (
<div key={output.id || `output-${index}`} className={styles['node-output-label']}>
{output.desc}
</div>
))}
</div>
)}
</div>
</div>
{(dataIns.length > 0 || dataOuts.length > 0) && (
<>
{/*分割*/}
<div className={styles['node-split-line']}></div>
{/*content栏-data部分*/}
<div className={styles['node-data-box']}>
<div className={styles['node-content']}>
<div className={styles['node-inputs']}>
{dataIns.map((input, index) => (
<div key={input.id || `input-${index}`} className={styles['node-input-label']}>
<span
className={styles['node-data-type']}
>{input.id || `输入${index + 1}`} {formatDataType(input.dataType)}
</span>
</div>
))}
</div>
{dataOuts.length > 0 && !isEndNode && (
<div className={styles['node-outputs']}>
{dataOuts.map((output, index) => (
<div key={output.id || `output-${index}`} className={styles['node-output-label']}>
<span
className={styles['node-data-type']}
>{formatDataType(output.dataType)} {output.id || `输出${index + 1}`}
</span>
</div>
))}
</div>
)}
</div>
</div>
</>
)}
{/*footer栏*/}
<div className={styles['node-footer']}>
{!isRecording ?
<Button shape="round" type="primary" style={{ backgroundColor: '#1890ff' }}
onClick={() => handleStartRecordWithCheck()}></Button>
:
<Button shape="round" type="primary" style={{ backgroundColor: '#c05144' }}
onClick={() => handleCompleteWithCheck()}></Button>
}
</div>
{/* 根据节点类型渲染不同的句柄 */}
{isSpecialNode
? renderSpecialNodeHandles(isStartNode, isEndNode, dataIns, dataOuts, apiIns, apiOuts)
: renderRegularNodeHandles(dataIns, dataOuts, apiIns, apiOuts)}
</>
);
}
;
export default NodeContentMicrophone;

@ -117,6 +117,7 @@ const nodeDefinitions = [
{ nodeName: '周期', nodeType: 'CYCLE', nodeGroup: 'common', icon: 'IconSchedule' },
{ nodeName: '事件接收', nodeType: 'EVENTLISTENE', nodeGroup: 'common', icon: 'IconImport' },
{ nodeName: '事件发送', nodeType: 'EVENTSEND', nodeGroup: 'common', icon: 'IconExport' },
{ nodeName: '语音输入', nodeType: 'MICRO', nodeGroup: 'common', icon: 'IconVoice' },
// { nodeName: 'JSON转字符串', nodeType: 'JSON2STR', nodeGroup: 'common', icon: 'IconCodeBlock' },
// { nodeName: '字符串转JSON', nodeType: 'STR2JSON', nodeGroup: 'common', icon: 'IconCodeSquare' },
// { nodeName: 'JSON封装', nodeType: 'JSONCONVERT', nodeGroup: 'common', icon: 'IconTranslate' },

Loading…
Cancel
Save