@fe-free/ai 4.1.26 → 4.1.27
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/CHANGELOG.md +9 -0
- package/package.json +4 -4
- package/src/ai.stories.tsx +30 -1
- package/src/index.ts +1 -1
- package/src/m_sender/m_sender.stories.tsx +86 -12
- package/src/voice/index.ts +178 -23
package/CHANGELOG.md
CHANGED
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@fe-free/ai",
|
|
3
|
-
"version": "4.1.
|
|
3
|
+
"version": "4.1.27",
|
|
4
4
|
"description": "",
|
|
5
5
|
"main": "./src/index.ts",
|
|
6
6
|
"author": "",
|
|
@@ -19,7 +19,7 @@
|
|
|
19
19
|
"lodash-es": "^4.17.21",
|
|
20
20
|
"uuid": "^13.0.0",
|
|
21
21
|
"zustand": "^4.5.7",
|
|
22
|
-
"@fe-free/core": "4.1.
|
|
22
|
+
"@fe-free/core": "4.1.27"
|
|
23
23
|
},
|
|
24
24
|
"peerDependencies": {
|
|
25
25
|
"antd": "^5.27.1",
|
|
@@ -29,8 +29,8 @@
|
|
|
29
29
|
"i18next-icu": "^2.4.1",
|
|
30
30
|
"react": "^19.2.0",
|
|
31
31
|
"react-i18next": "^16.4.0",
|
|
32
|
-
"@fe-free/icons": "4.1.
|
|
33
|
-
"@fe-free/tool": "4.1.
|
|
32
|
+
"@fe-free/icons": "4.1.27",
|
|
33
|
+
"@fe-free/tool": "4.1.27"
|
|
34
34
|
},
|
|
35
35
|
"scripts": {
|
|
36
36
|
"test": "echo \"Error: no test specified\" && exit 1",
|
package/src/ai.stories.tsx
CHANGED
|
@@ -5,6 +5,7 @@ import {
|
|
|
5
5
|
EnumChatMessageStatus,
|
|
6
6
|
EnumChatMessageType,
|
|
7
7
|
generateUUID,
|
|
8
|
+
getRecordAudioOfPCM,
|
|
8
9
|
Markdown,
|
|
9
10
|
MessageActions,
|
|
10
11
|
Messages,
|
|
@@ -12,7 +13,7 @@ import {
|
|
|
12
13
|
} from '@fe-free/ai';
|
|
13
14
|
import { sleep } from '@fe-free/tool';
|
|
14
15
|
import type { Meta } from '@storybook/react-vite';
|
|
15
|
-
import { Button, Divider } from 'antd';
|
|
16
|
+
import { App, Button, Divider } from 'antd';
|
|
16
17
|
import { set } from 'lodash-es';
|
|
17
18
|
import { useCallback, useEffect, useMemo } from 'react';
|
|
18
19
|
|
|
@@ -54,6 +55,8 @@ function Component() {
|
|
|
54
55
|
const updateMessage = useChatStore((state) => state.updateMessage);
|
|
55
56
|
const { chatStatus } = useChatStoreComputed();
|
|
56
57
|
|
|
58
|
+
const { message } = App.useApp();
|
|
59
|
+
|
|
57
60
|
// init from cache
|
|
58
61
|
useEffect(() => {
|
|
59
62
|
const cacheMessages = localStorage.getItem('chatMessages');
|
|
@@ -103,6 +106,10 @@ function Component() {
|
|
|
103
106
|
[addMessage, updateMessage],
|
|
104
107
|
);
|
|
105
108
|
|
|
109
|
+
const { start: startRecord, stop: stopRecord } = useMemo(() => {
|
|
110
|
+
return getRecordAudioOfPCM();
|
|
111
|
+
}, []);
|
|
112
|
+
|
|
106
113
|
return (
|
|
107
114
|
<div>
|
|
108
115
|
<div>
|
|
@@ -139,6 +146,28 @@ function Component() {
|
|
|
139
146
|
onChange={(v) => setSenderValue(v)}
|
|
140
147
|
loading={loading}
|
|
141
148
|
onSubmit={handleSubmit}
|
|
149
|
+
allowSpeech={{
|
|
150
|
+
onRecordStart: async () => {
|
|
151
|
+
console.log('onRecordStart');
|
|
152
|
+
try {
|
|
153
|
+
await startRecord({
|
|
154
|
+
onAudio: (data) => {
|
|
155
|
+
console.log('onAudio', data);
|
|
156
|
+
},
|
|
157
|
+
onError: (err) => {
|
|
158
|
+
message.error(err.message);
|
|
159
|
+
},
|
|
160
|
+
});
|
|
161
|
+
} catch (err) {
|
|
162
|
+
console.error(err);
|
|
163
|
+
}
|
|
164
|
+
},
|
|
165
|
+
onRecordEnd: async (isSend) => {
|
|
166
|
+
console.log('onRecordEnd', isSend);
|
|
167
|
+
const voiceData = await stopRecord();
|
|
168
|
+
console.log('voiceData', voiceData);
|
|
169
|
+
},
|
|
170
|
+
}}
|
|
142
171
|
/>
|
|
143
172
|
</div>
|
|
144
173
|
}
|
package/src/index.ts
CHANGED
|
@@ -15,4 +15,4 @@ export { EnumChatMessageStatus, EnumChatMessageType } from './store/types';
|
|
|
15
15
|
export type { ChatMessage, ChatMessageOfAI, ChatMessageOfUser } from './store/types';
|
|
16
16
|
export { fetchStream } from './stream';
|
|
17
17
|
export { Tip } from './tip';
|
|
18
|
-
export {
|
|
18
|
+
export { getRecordAudioOfBlob, getRecordAudioOfPCM } from './voice';
|
|
@@ -1,7 +1,7 @@
|
|
|
1
|
-
import { MSender } from '@fe-free/ai';
|
|
2
|
-
import { sleep } from '@fe-free/tool';
|
|
1
|
+
import { getRecordAudioOfBlob, getRecordAudioOfPCM, MSender } from '@fe-free/ai';
|
|
3
2
|
import type { Meta, StoryObj } from '@storybook/react-vite';
|
|
4
|
-
import {
|
|
3
|
+
import { App } from 'antd';
|
|
4
|
+
import { useCallback, useMemo, useState } from 'react';
|
|
5
5
|
import type { MSenderProps, MSenderValue } from './types';
|
|
6
6
|
|
|
7
7
|
const meta: Meta<typeof MSender> = {
|
|
@@ -61,8 +61,11 @@ export const Loading: Story = {
|
|
|
61
61
|
|
|
62
62
|
export const AllowSpeech: Story = {
|
|
63
63
|
render: (props) => {
|
|
64
|
-
|
|
65
|
-
|
|
64
|
+
const { message } = App.useApp();
|
|
65
|
+
|
|
66
|
+
const { start: startRecord, stop: stopRecord } = useMemo(() => {
|
|
67
|
+
return getRecordAudioOfPCM();
|
|
68
|
+
}, []);
|
|
66
69
|
|
|
67
70
|
const handleSubmit = (value: MSenderValue) => {
|
|
68
71
|
console.log('handleSubmit', value);
|
|
@@ -70,22 +73,34 @@ export const AllowSpeech: Story = {
|
|
|
70
73
|
|
|
71
74
|
const handleRecordStart = useCallback(async () => {
|
|
72
75
|
// 假设这是录音的文本
|
|
73
|
-
|
|
76
|
+
try {
|
|
77
|
+
await startRecord({
|
|
78
|
+
onAudio: (data) => {
|
|
79
|
+
console.log('onAudio', data);
|
|
80
|
+
},
|
|
81
|
+
onError: (err) => {
|
|
82
|
+
message.error(err.message);
|
|
83
|
+
},
|
|
84
|
+
});
|
|
85
|
+
} catch (err) {
|
|
86
|
+
console.error(err);
|
|
87
|
+
}
|
|
74
88
|
|
|
75
89
|
return;
|
|
76
90
|
}, []);
|
|
77
91
|
|
|
78
92
|
const handleRecordEnd = useCallback(
|
|
79
|
-
async (isSend
|
|
93
|
+
async (isSend) => {
|
|
80
94
|
console.log('handleRecordEnd isSend', isSend);
|
|
81
|
-
if (isSend) {
|
|
82
|
-
await sleep(1000);
|
|
83
|
-
const recordResult = recordVoice;
|
|
84
95
|
|
|
85
|
-
|
|
96
|
+
const voiceData = await stopRecord();
|
|
97
|
+
console.log('voiceData', voiceData);
|
|
98
|
+
|
|
99
|
+
if (isSend) {
|
|
100
|
+
handleSubmit({ ...(props.value || {}), text: '假设这是识别的文字' });
|
|
86
101
|
}
|
|
87
102
|
},
|
|
88
|
-
[props.value,
|
|
103
|
+
[props.value, stopRecord],
|
|
89
104
|
);
|
|
90
105
|
|
|
91
106
|
return (
|
|
@@ -122,4 +137,63 @@ export const AllowSpeech: Story = {
|
|
|
122
137
|
},
|
|
123
138
|
};
|
|
124
139
|
|
|
140
|
+
export const AllowSpeech2: Story = {
|
|
141
|
+
render: (props) => {
|
|
142
|
+
const { message } = App.useApp();
|
|
143
|
+
|
|
144
|
+
const { start: startRecord, stop: stopRecord } = useMemo(() => {
|
|
145
|
+
return getRecordAudioOfBlob();
|
|
146
|
+
}, []);
|
|
147
|
+
|
|
148
|
+
const handleSubmit = (value: MSenderValue) => {
|
|
149
|
+
console.log('handleSubmit', value);
|
|
150
|
+
};
|
|
151
|
+
|
|
152
|
+
const handleRecordStart = useCallback(async () => {
|
|
153
|
+
// 假设这是录音的文本
|
|
154
|
+
try {
|
|
155
|
+
await startRecord({
|
|
156
|
+
onAudio: (data) => {
|
|
157
|
+
console.log('onAudio', data);
|
|
158
|
+
},
|
|
159
|
+
onError: (err) => {
|
|
160
|
+
message.error(err.message);
|
|
161
|
+
},
|
|
162
|
+
});
|
|
163
|
+
} catch (err) {
|
|
164
|
+
console.error(err);
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
return;
|
|
168
|
+
}, []);
|
|
169
|
+
|
|
170
|
+
const handleRecordEnd = useCallback(
|
|
171
|
+
async (isSend) => {
|
|
172
|
+
console.log('handleRecordEnd isSend', isSend);
|
|
173
|
+
|
|
174
|
+
const voiceData = await stopRecord();
|
|
175
|
+
console.log('voiceData', voiceData);
|
|
176
|
+
|
|
177
|
+
if (isSend) {
|
|
178
|
+
handleSubmit({ ...(props.value || {}), text: '假设这是识别的文字' });
|
|
179
|
+
}
|
|
180
|
+
},
|
|
181
|
+
[props.value, stopRecord],
|
|
182
|
+
);
|
|
183
|
+
|
|
184
|
+
return (
|
|
185
|
+
<div className="flex flex-col gap-10">
|
|
186
|
+
<Component
|
|
187
|
+
{...props}
|
|
188
|
+
defaultType="record"
|
|
189
|
+
allowSpeech={{
|
|
190
|
+
onRecordStart: handleRecordStart,
|
|
191
|
+
onRecordEnd: handleRecordEnd,
|
|
192
|
+
}}
|
|
193
|
+
/>
|
|
194
|
+
</div>
|
|
195
|
+
);
|
|
196
|
+
},
|
|
197
|
+
};
|
|
198
|
+
|
|
125
199
|
export default meta;
|
package/src/voice/index.ts
CHANGED
|
@@ -1,33 +1,188 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
// ScriptProcessorNode(4096 是稳定 buffer)
|
|
7
|
-
const processorNode = audioContext.createScriptProcessor(4096, 1, 1);
|
|
8
|
-
|
|
9
|
-
processorNode.onaudioprocess = function (event) {
|
|
10
|
-
const float32Data = event.inputBuffer.getChannelData(0); // float32
|
|
11
|
-
|
|
12
|
-
// === 转成 Int16 PCM ===
|
|
13
|
-
const pcm16 = new Int16Array(float32Data.length);
|
|
14
|
-
for (let i = 0; i < float32Data.length; i++) {
|
|
15
|
-
const s = Math.max(-1, Math.min(1, float32Data[i]));
|
|
16
|
-
pcm16[i] = s < 0 ? s * 0x8000 : s * 0x7fff;
|
|
17
|
-
}
|
|
1
|
+
function getRecordAudioOfPCM() {
|
|
2
|
+
let processorNode: ScriptProcessorNode | null = null;
|
|
3
|
+
let sourceNode: MediaStreamAudioSourceNode | null = null;
|
|
4
|
+
let audioContext: AudioContext | null = null;
|
|
5
|
+
let micStream: MediaStream | null = null;
|
|
18
6
|
|
|
19
|
-
|
|
20
|
-
|
|
7
|
+
let data: ArrayBufferLike[] = [];
|
|
8
|
+
|
|
9
|
+
async function start({
|
|
10
|
+
onAudio,
|
|
11
|
+
onError,
|
|
12
|
+
}: {
|
|
13
|
+
onAudio: (data: ArrayBufferLike) => void;
|
|
14
|
+
onError?: (error: Error) => void;
|
|
15
|
+
}): Promise<void> {
|
|
16
|
+
try {
|
|
17
|
+
// --- 初始化音频 ---
|
|
18
|
+
micStream = await navigator.mediaDevices.getUserMedia({ audio: true });
|
|
19
|
+
audioContext = new AudioContext({ sampleRate: 16000 });
|
|
20
|
+
sourceNode = audioContext.createMediaStreamSource(micStream);
|
|
21
|
+
// ScriptProcessorNode(4096 是稳定 buffer)
|
|
22
|
+
processorNode = audioContext.createScriptProcessor(4096, 1, 1);
|
|
23
|
+
|
|
24
|
+
data = [];
|
|
25
|
+
|
|
26
|
+
processorNode.onaudioprocess = function (event) {
|
|
27
|
+
const float32Data = event.inputBuffer.getChannelData(0); // float32
|
|
28
|
+
|
|
29
|
+
// === 转成 Int16 PCM ===
|
|
30
|
+
const pcm16 = new Int16Array(float32Data.length);
|
|
31
|
+
for (let i = 0; i < float32Data.length; i++) {
|
|
32
|
+
const s = Math.max(-1, Math.min(1, float32Data[i]));
|
|
33
|
+
pcm16[i] = s < 0 ? s * 0x8000 : s * 0x7fff;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
data.push(pcm16.buffer);
|
|
37
|
+
|
|
38
|
+
onAudio(pcm16.buffer);
|
|
39
|
+
};
|
|
21
40
|
|
|
22
|
-
|
|
23
|
-
|
|
41
|
+
sourceNode.connect(processorNode);
|
|
42
|
+
processorNode.connect(audioContext.destination);
|
|
43
|
+
} catch (err) {
|
|
44
|
+
if (err instanceof DOMException && err.name === 'NotAllowedError') {
|
|
45
|
+
onError?.(new Error('请允许麦克风权限'));
|
|
46
|
+
} else if (err instanceof DOMException && err.name === 'NotFoundError') {
|
|
47
|
+
onError?.(new Error('未找到麦克风设备'));
|
|
48
|
+
} else if (err instanceof DOMException && err.name === 'NotReadableError') {
|
|
49
|
+
onError?.(new Error('麦克风被其他应用占用'));
|
|
50
|
+
} else {
|
|
51
|
+
onError?.(new Error('启动录音失败'));
|
|
52
|
+
}
|
|
24
53
|
|
|
25
|
-
|
|
54
|
+
throw err;
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
async function stop(): Promise<{ data: ArrayBufferLike[] }> {
|
|
26
59
|
if (processorNode) processorNode.disconnect();
|
|
27
60
|
if (sourceNode) sourceNode.disconnect();
|
|
28
61
|
if (audioContext) audioContext.close();
|
|
29
62
|
if (micStream) micStream.getTracks().forEach((track) => track.stop());
|
|
63
|
+
|
|
64
|
+
const result = data;
|
|
65
|
+
data = [];
|
|
66
|
+
|
|
67
|
+
return { data: result };
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
return {
|
|
71
|
+
start,
|
|
72
|
+
stop,
|
|
73
|
+
};
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function getRecordAudioOfBlob() {
|
|
77
|
+
let mediaRecorder: MediaRecorder | null = null;
|
|
78
|
+
let micStream: MediaStream | null = null;
|
|
79
|
+
let chunks: Blob[] = [];
|
|
80
|
+
|
|
81
|
+
async function start({
|
|
82
|
+
onAudio,
|
|
83
|
+
onError,
|
|
84
|
+
mimeType = 'audio/webm',
|
|
85
|
+
}: {
|
|
86
|
+
onAudio?: (blob: Blob) => void;
|
|
87
|
+
onError?: (error: Error) => void;
|
|
88
|
+
mimeType?: string;
|
|
89
|
+
}): Promise<void> {
|
|
90
|
+
try {
|
|
91
|
+
// 获取麦克风权限
|
|
92
|
+
micStream = await navigator.mediaDevices.getUserMedia({ audio: true });
|
|
93
|
+
|
|
94
|
+
// 检查浏览器是否支持指定的 MIME 类型
|
|
95
|
+
let finalMimeType = mimeType;
|
|
96
|
+
if (!MediaRecorder.isTypeSupported(mimeType)) {
|
|
97
|
+
// 如果不支持,尝试使用默认类型
|
|
98
|
+
finalMimeType = '';
|
|
99
|
+
console.warn(`不支持的 MIME 类型: ${mimeType},使用默认类型`);
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
// 创建 MediaRecorder 实例
|
|
103
|
+
mediaRecorder = new MediaRecorder(micStream, {
|
|
104
|
+
mimeType: finalMimeType || undefined,
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
chunks = [];
|
|
108
|
+
|
|
109
|
+
// 监听数据可用事件
|
|
110
|
+
mediaRecorder.ondataavailable = (event) => {
|
|
111
|
+
if (event.data && event.data.size > 0) {
|
|
112
|
+
chunks.push(event.data);
|
|
113
|
+
onAudio?.(event.data);
|
|
114
|
+
}
|
|
115
|
+
};
|
|
116
|
+
|
|
117
|
+
// 监听错误事件
|
|
118
|
+
mediaRecorder.onerror = () => {
|
|
119
|
+
const error = new Error('MediaRecorder 录音错误');
|
|
120
|
+
onError?.(error);
|
|
121
|
+
};
|
|
122
|
+
|
|
123
|
+
// 开始录音
|
|
124
|
+
mediaRecorder.start(100); // 每 100ms 触发一次 dataavailable 事件
|
|
125
|
+
} catch (err) {
|
|
126
|
+
if (err instanceof DOMException && err.name === 'NotAllowedError') {
|
|
127
|
+
onError?.(new Error('请允许麦克风权限'));
|
|
128
|
+
} else if (err instanceof DOMException && err.name === 'NotFoundError') {
|
|
129
|
+
onError?.(new Error('未找到麦克风设备'));
|
|
130
|
+
} else if (err instanceof DOMException && err.name === 'NotReadableError') {
|
|
131
|
+
onError?.(new Error('麦克风被其他应用占用'));
|
|
132
|
+
} else {
|
|
133
|
+
onError?.(new Error('启动录音失败'));
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
throw err;
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
async function stop(): Promise<{ data: Blob; base64: string }> {
|
|
141
|
+
return new Promise((resolve, reject) => {
|
|
142
|
+
if (!mediaRecorder) {
|
|
143
|
+
reject(new Error('MediaRecorder 未初始化'));
|
|
144
|
+
return;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
function doStop() {
|
|
148
|
+
const blob = new Blob(chunks, { type: mediaRecorder?.mimeType || 'audio/webm' });
|
|
149
|
+
chunks = [];
|
|
150
|
+
|
|
151
|
+
// 停止所有轨道
|
|
152
|
+
if (micStream) {
|
|
153
|
+
micStream.getTracks().forEach((track) => track.stop());
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
// 将 Blob 转换为 base64
|
|
157
|
+
const reader = new FileReader();
|
|
158
|
+
reader.onloadend = () => {
|
|
159
|
+
const base64String = reader.result as string;
|
|
160
|
+
resolve({ data: blob, base64: base64String });
|
|
161
|
+
};
|
|
162
|
+
reader.onerror = () => {
|
|
163
|
+
reject(new Error('转换为 base64 失败'));
|
|
164
|
+
};
|
|
165
|
+
reader.readAsDataURL(blob);
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
// 监听停止事件
|
|
169
|
+
mediaRecorder.onstop = () => {
|
|
170
|
+
doStop();
|
|
171
|
+
};
|
|
172
|
+
|
|
173
|
+
// 如果正在录音,则停止
|
|
174
|
+
if (mediaRecorder.state === 'recording') {
|
|
175
|
+
mediaRecorder.stop();
|
|
176
|
+
} else {
|
|
177
|
+
doStop();
|
|
178
|
+
}
|
|
179
|
+
});
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
return {
|
|
183
|
+
start,
|
|
184
|
+
stop,
|
|
30
185
|
};
|
|
31
186
|
}
|
|
32
187
|
|
|
33
|
-
export {
|
|
188
|
+
export { getRecordAudioOfBlob, getRecordAudioOfPCM };
|