@fe-free/ai 4.1.26 → 4.1.28
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 +18 -0
- package/package.json +4 -4
- package/src/ai.stories.tsx +30 -1
- package/src/index.ts +1 -1
- package/src/m_sender/actions.tsx +6 -6
- package/src/m_sender/m_sender.stories.tsx +122 -16
- package/src/messages/message_actions.tsx +25 -4
- package/src/voice/index.ts +178 -23
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,23 @@
|
|
|
1
1
|
# @fe-free/ai
|
|
2
2
|
|
|
3
|
+
## 4.1.28
|
|
4
|
+
|
|
5
|
+
### Patch Changes
|
|
6
|
+
|
|
7
|
+
- feat: ai
|
|
8
|
+
- @fe-free/core@4.1.28
|
|
9
|
+
- @fe-free/icons@4.1.28
|
|
10
|
+
- @fe-free/tool@4.1.28
|
|
11
|
+
|
|
12
|
+
## 4.1.27
|
|
13
|
+
|
|
14
|
+
### Patch Changes
|
|
15
|
+
|
|
16
|
+
- feat: ai voice
|
|
17
|
+
- @fe-free/core@4.1.27
|
|
18
|
+
- @fe-free/icons@4.1.27
|
|
19
|
+
- @fe-free/tool@4.1.27
|
|
20
|
+
|
|
3
21
|
## 4.1.26
|
|
4
22
|
|
|
5
23
|
### Patch Changes
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@fe-free/ai",
|
|
3
|
-
"version": "4.1.
|
|
3
|
+
"version": "4.1.28",
|
|
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.28"
|
|
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.28",
|
|
33
|
+
"@fe-free/tool": "4.1.28"
|
|
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';
|
package/src/m_sender/actions.tsx
CHANGED
|
@@ -12,7 +12,7 @@ function Actions(
|
|
|
12
12
|
setType: (type: 'input' | 'record') => void;
|
|
13
13
|
},
|
|
14
14
|
) {
|
|
15
|
-
const {
|
|
15
|
+
const { loading, onSubmit, value, onChange, setType, allowSpeech } = props;
|
|
16
16
|
|
|
17
17
|
const isLoading = loading;
|
|
18
18
|
|
|
@@ -33,10 +33,10 @@ function Actions(
|
|
|
33
33
|
// reset
|
|
34
34
|
onChange?.({});
|
|
35
35
|
|
|
36
|
-
// focus
|
|
37
|
-
refText.current?.focus();
|
|
36
|
+
// 移动端 不 focus
|
|
37
|
+
// refText.current?.focus();
|
|
38
38
|
}
|
|
39
|
-
}, [isLoading, value, onSubmit, onChange
|
|
39
|
+
}, [isLoading, value, onSubmit, onChange]);
|
|
40
40
|
|
|
41
41
|
return (
|
|
42
42
|
<div className="mr-1 flex items-center gap-2">
|
|
@@ -44,7 +44,7 @@ function Actions(
|
|
|
44
44
|
<Button
|
|
45
45
|
type="primary"
|
|
46
46
|
shape="circle"
|
|
47
|
-
icon={<Icons component={IconRecord} />}
|
|
47
|
+
icon={<Icons component={IconRecord} className="!text-lg" />}
|
|
48
48
|
onClick={() => {
|
|
49
49
|
setType('record');
|
|
50
50
|
}}
|
|
@@ -53,7 +53,7 @@ function Actions(
|
|
|
53
53
|
<Button
|
|
54
54
|
type="primary"
|
|
55
55
|
shape="circle"
|
|
56
|
-
icon={<Icons component={SendIcon} />}
|
|
56
|
+
icon={<Icons component={SendIcon} className="!text-lg" />}
|
|
57
57
|
loading={isLoading}
|
|
58
58
|
onClick={handleSubmit}
|
|
59
59
|
/>
|
|
@@ -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,31 +61,23 @@ 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);
|
|
66
|
-
|
|
67
64
|
const handleSubmit = (value: MSenderValue) => {
|
|
68
65
|
console.log('handleSubmit', value);
|
|
69
66
|
};
|
|
70
67
|
|
|
71
68
|
const handleRecordStart = useCallback(async () => {
|
|
72
|
-
//
|
|
73
|
-
setRecordVoice('这是录音的文本');
|
|
74
|
-
|
|
75
|
-
return;
|
|
69
|
+
// fake
|
|
76
70
|
}, []);
|
|
77
71
|
|
|
78
72
|
const handleRecordEnd = useCallback(
|
|
79
|
-
async (isSend
|
|
73
|
+
async (isSend) => {
|
|
80
74
|
console.log('handleRecordEnd isSend', isSend);
|
|
81
|
-
if (isSend) {
|
|
82
|
-
await sleep(1000);
|
|
83
|
-
const recordResult = recordVoice;
|
|
84
75
|
|
|
85
|
-
|
|
76
|
+
if (isSend) {
|
|
77
|
+
handleSubmit({ ...(props.value || {}), text: '假设这是识别的文字' });
|
|
86
78
|
}
|
|
87
79
|
},
|
|
88
|
-
[props.value
|
|
80
|
+
[props.value],
|
|
89
81
|
);
|
|
90
82
|
|
|
91
83
|
return (
|
|
@@ -122,4 +114,118 @@ export const AllowSpeech: Story = {
|
|
|
122
114
|
},
|
|
123
115
|
};
|
|
124
116
|
|
|
117
|
+
export const AllowSpeechPCM: Story = {
|
|
118
|
+
render: (props) => {
|
|
119
|
+
const { message } = App.useApp();
|
|
120
|
+
|
|
121
|
+
const { start: startRecord, stop: stopRecord } = useMemo(() => {
|
|
122
|
+
return getRecordAudioOfPCM();
|
|
123
|
+
}, []);
|
|
124
|
+
|
|
125
|
+
const handleSubmit = (value: MSenderValue) => {
|
|
126
|
+
console.log('handleSubmit', value);
|
|
127
|
+
};
|
|
128
|
+
|
|
129
|
+
const handleRecordStart = useCallback(async () => {
|
|
130
|
+
// 假设这是录音的文本
|
|
131
|
+
try {
|
|
132
|
+
await startRecord({
|
|
133
|
+
onAudio: (data) => {
|
|
134
|
+
console.log('onAudio', data);
|
|
135
|
+
},
|
|
136
|
+
onError: (err) => {
|
|
137
|
+
message.error(err.message);
|
|
138
|
+
},
|
|
139
|
+
});
|
|
140
|
+
} catch (err) {
|
|
141
|
+
console.error(err);
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
return;
|
|
145
|
+
}, []);
|
|
146
|
+
|
|
147
|
+
const handleRecordEnd = useCallback(
|
|
148
|
+
async (isSend) => {
|
|
149
|
+
console.log('handleRecordEnd isSend', isSend);
|
|
150
|
+
|
|
151
|
+
const voiceData = await stopRecord();
|
|
152
|
+
console.log('voiceData', voiceData);
|
|
153
|
+
|
|
154
|
+
if (isSend) {
|
|
155
|
+
handleSubmit({ ...(props.value || {}), text: '假设这是识别的文字' });
|
|
156
|
+
}
|
|
157
|
+
},
|
|
158
|
+
[props.value, stopRecord],
|
|
159
|
+
);
|
|
160
|
+
|
|
161
|
+
return (
|
|
162
|
+
<Component
|
|
163
|
+
{...props}
|
|
164
|
+
defaultType="record"
|
|
165
|
+
allowSpeech={{
|
|
166
|
+
onRecordStart: handleRecordStart,
|
|
167
|
+
onRecordEnd: handleRecordEnd,
|
|
168
|
+
}}
|
|
169
|
+
/>
|
|
170
|
+
);
|
|
171
|
+
},
|
|
172
|
+
};
|
|
173
|
+
|
|
174
|
+
export const AllowSpeechBlob: Story = {
|
|
175
|
+
render: (props) => {
|
|
176
|
+
const { message } = App.useApp();
|
|
177
|
+
|
|
178
|
+
const { start: startRecord, stop: stopRecord } = useMemo(() => {
|
|
179
|
+
return getRecordAudioOfBlob();
|
|
180
|
+
}, []);
|
|
181
|
+
|
|
182
|
+
const handleSubmit = (value: MSenderValue) => {
|
|
183
|
+
console.log('handleSubmit', value);
|
|
184
|
+
};
|
|
185
|
+
|
|
186
|
+
const handleRecordStart = useCallback(async () => {
|
|
187
|
+
// 假设这是录音的文本
|
|
188
|
+
try {
|
|
189
|
+
await startRecord({
|
|
190
|
+
onAudio: (data) => {
|
|
191
|
+
console.log('onAudio', data);
|
|
192
|
+
},
|
|
193
|
+
onError: (err) => {
|
|
194
|
+
message.error(err.message);
|
|
195
|
+
},
|
|
196
|
+
});
|
|
197
|
+
} catch (err) {
|
|
198
|
+
console.error(err);
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
return;
|
|
202
|
+
}, []);
|
|
203
|
+
|
|
204
|
+
const handleRecordEnd = useCallback(
|
|
205
|
+
async (isSend) => {
|
|
206
|
+
console.log('handleRecordEnd isSend', isSend);
|
|
207
|
+
|
|
208
|
+
const voiceData = await stopRecord();
|
|
209
|
+
console.log('voiceData', voiceData);
|
|
210
|
+
|
|
211
|
+
if (isSend) {
|
|
212
|
+
handleSubmit({ ...(props.value || {}), text: '假设这是识别的文字' });
|
|
213
|
+
}
|
|
214
|
+
},
|
|
215
|
+
[props.value, stopRecord],
|
|
216
|
+
);
|
|
217
|
+
|
|
218
|
+
return (
|
|
219
|
+
<Component
|
|
220
|
+
{...props}
|
|
221
|
+
defaultType="record"
|
|
222
|
+
allowSpeech={{
|
|
223
|
+
onRecordStart: handleRecordStart,
|
|
224
|
+
onRecordEnd: handleRecordEnd,
|
|
225
|
+
}}
|
|
226
|
+
/>
|
|
227
|
+
);
|
|
228
|
+
},
|
|
229
|
+
};
|
|
230
|
+
|
|
125
231
|
export default meta;
|
|
@@ -8,9 +8,18 @@ import {
|
|
|
8
8
|
LikeOutlined,
|
|
9
9
|
} from '@fe-free/icons';
|
|
10
10
|
import { App, Button, Tooltip } from 'antd';
|
|
11
|
+
import classNames from 'classnames';
|
|
11
12
|
import { useCallback, useEffect, useState } from 'react';
|
|
12
13
|
|
|
13
|
-
function MessageActionOfCopy({
|
|
14
|
+
function MessageActionOfCopy({
|
|
15
|
+
value,
|
|
16
|
+
onCopied,
|
|
17
|
+
className,
|
|
18
|
+
}: {
|
|
19
|
+
value: string;
|
|
20
|
+
onCopied?: () => void;
|
|
21
|
+
className?: string;
|
|
22
|
+
}) {
|
|
14
23
|
const [active, setActive] = useState(false);
|
|
15
24
|
const { message } = App.useApp();
|
|
16
25
|
|
|
@@ -22,7 +31,11 @@ function MessageActionOfCopy({ value, onCopied }: { value: string; onCopied?: ()
|
|
|
22
31
|
|
|
23
32
|
return (
|
|
24
33
|
<Tooltip title="复制">
|
|
25
|
-
<Copy
|
|
34
|
+
<Copy
|
|
35
|
+
value={value}
|
|
36
|
+
className={classNames('cursor-pointer text-03', className)}
|
|
37
|
+
onCopied={handleCopied}
|
|
38
|
+
>
|
|
26
39
|
<Button
|
|
27
40
|
type="text"
|
|
28
41
|
size="small"
|
|
@@ -37,9 +50,11 @@ function MessageActionOfCopy({ value, onCopied }: { value: string; onCopied?: ()
|
|
|
37
50
|
function MessageActionOfLike({
|
|
38
51
|
active: propsActive,
|
|
39
52
|
onClick,
|
|
53
|
+
className,
|
|
40
54
|
}: {
|
|
41
55
|
active?: boolean;
|
|
42
56
|
onClick?: (active: boolean) => Promise<void>;
|
|
57
|
+
className?: string;
|
|
43
58
|
}) {
|
|
44
59
|
const { message } = App.useApp();
|
|
45
60
|
const [active, setActive] = useState(propsActive || false);
|
|
@@ -60,7 +75,7 @@ function MessageActionOfLike({
|
|
|
60
75
|
type="text"
|
|
61
76
|
onClick={handleClick}
|
|
62
77
|
size="small"
|
|
63
|
-
className=
|
|
78
|
+
className={classNames('text-03', className)}
|
|
64
79
|
icon={active ? <LikeFilled /> : <LikeOutlined />}
|
|
65
80
|
/>
|
|
66
81
|
</Tooltip>
|
|
@@ -70,9 +85,11 @@ function MessageActionOfLike({
|
|
|
70
85
|
function MessageActionOfDislike({
|
|
71
86
|
active: propsActive,
|
|
72
87
|
onClick,
|
|
88
|
+
className,
|
|
73
89
|
}: {
|
|
74
90
|
active?: boolean;
|
|
75
91
|
onClick?: (active: boolean) => Promise<void>;
|
|
92
|
+
className?: string;
|
|
76
93
|
}) {
|
|
77
94
|
const [active, setActive] = useState(propsActive || false);
|
|
78
95
|
const { message } = App.useApp();
|
|
@@ -93,7 +110,7 @@ function MessageActionOfDislike({
|
|
|
93
110
|
type="text"
|
|
94
111
|
onClick={handleClick}
|
|
95
112
|
size="small"
|
|
96
|
-
className=
|
|
113
|
+
className={classNames('text-03', className)}
|
|
97
114
|
icon={active ? <DislikeFilled /> : <DislikeOutlined />}
|
|
98
115
|
/>
|
|
99
116
|
</Tooltip>
|
|
@@ -103,9 +120,11 @@ function MessageActionOfDislike({
|
|
|
103
120
|
function MessageActionOfLinkAndDislike({
|
|
104
121
|
value: propsValue,
|
|
105
122
|
onChange,
|
|
123
|
+
className,
|
|
106
124
|
}: {
|
|
107
125
|
value?: -1 | 0 | 1;
|
|
108
126
|
onChange?: (value: -1 | 0 | 1) => void;
|
|
127
|
+
className?: string;
|
|
109
128
|
}) {
|
|
110
129
|
const [value, setValue] = useState<(-1 | 0 | 1) | undefined>(propsValue);
|
|
111
130
|
|
|
@@ -122,6 +141,7 @@ function MessageActionOfLinkAndDislike({
|
|
|
122
141
|
await Promise.resolve(onChange?.(newValue));
|
|
123
142
|
setValue(newValue);
|
|
124
143
|
}}
|
|
144
|
+
className={className}
|
|
125
145
|
/>
|
|
126
146
|
<MessageActionOfDislike
|
|
127
147
|
active={value === -1}
|
|
@@ -130,6 +150,7 @@ function MessageActionOfLinkAndDislike({
|
|
|
130
150
|
await Promise.resolve(onChange?.(newValue));
|
|
131
151
|
setValue(newValue);
|
|
132
152
|
}}
|
|
153
|
+
className={className}
|
|
133
154
|
/>
|
|
134
155
|
</>
|
|
135
156
|
);
|
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 };
|