@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 CHANGED
@@ -1,5 +1,14 @@
1
1
  # @fe-free/ai
2
2
 
3
+ ## 4.1.27
4
+
5
+ ### Patch Changes
6
+
7
+ - feat: ai voice
8
+ - @fe-free/core@4.1.27
9
+ - @fe-free/icons@4.1.27
10
+ - @fe-free/tool@4.1.27
11
+
3
12
  ## 4.1.26
4
13
 
5
14
  ### Patch Changes
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@fe-free/ai",
3
- "version": "4.1.26",
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.26"
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.26",
33
- "@fe-free/tool": "4.1.26"
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",
@@ -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 { recordAudioOfPCM } from './voice';
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 { useCallback, useState } from 'react';
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
- // 假设是字符串,实则是 buffer
65
- const [recordVoice, setRecordVoice] = useState<string | undefined>(undefined);
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
- setRecordVoice('这是录音的文本');
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: boolean) => {
93
+ async (isSend) => {
80
94
  console.log('handleRecordEnd isSend', isSend);
81
- if (isSend) {
82
- await sleep(1000);
83
- const recordResult = recordVoice;
84
95
 
85
- handleSubmit({ ...(props.value || {}), text: recordResult });
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, recordVoice],
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;
@@ -1,33 +1,188 @@
1
- async function recordAudioOfPCM({ onAudio }: { onAudio: (data: ArrayBufferLike) => void }) {
2
- // --- 初始化音频 ---
3
- const micStream = await navigator.mediaDevices.getUserMedia({ audio: true });
4
- const audioContext = new AudioContext({ sampleRate: 16000 });
5
- const sourceNode = audioContext.createMediaStreamSource(micStream);
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
- onAudio(pcm16.buffer);
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
- sourceNode.connect(processorNode);
23
- processorNode.connect(audioContext.destination);
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
- return () => {
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 { recordAudioOfPCM };
188
+ export { getRecordAudioOfBlob, getRecordAudioOfPCM };