@huyooo/ai-chat-frontend-vue 0.2.11 → 0.2.13

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.
@@ -0,0 +1,531 @@
1
+ /**
2
+ * 语音输入 Composable
3
+ *
4
+ * 处理浏览器端录音和与后端 ASR 服务的通信
5
+ *
6
+ * @example
7
+ * ```vue
8
+ * <script setup>
9
+ * import { useVoiceInput } from '../composables/useVoiceInput';
10
+ *
11
+ * const adapter = inject('chatAdapter');
12
+ * const { isRecording, currentText, start, stop, cancel } = useVoiceInput(adapter);
13
+ * </script>
14
+ *
15
+ * <template>
16
+ * <button @mousedown="start" @mouseup="stop">
17
+ * {{ isRecording ? '松开发送' : '按住说话' }}
18
+ * </button>
19
+ * <div v-if="currentText">{{ currentText }}</div>
20
+ * </template>
21
+ * ```
22
+ */
23
+
24
+ import { ref, onUnmounted, computed, onMounted, type Ref } from 'vue';
25
+ import type { ChatAdapter, AsrResultData } from '@huyooo/ai-chat-bridge-electron/renderer';
26
+
27
+ // ============ 类型定义 ============
28
+
29
+ /** 语音输入状态 */
30
+ export type VoiceInputStatus = 'idle' | 'connecting' | 'recording' | 'processing' | 'error';
31
+
32
+ /** 语音输入配置 */
33
+ export interface VoiceInputConfig {
34
+ /** 采样率(默认 16000) */
35
+ sampleRate?: number;
36
+ /** 发送间隔(毫秒,默认 200) */
37
+ sendInterval?: number;
38
+ /** 启用标点 */
39
+ enablePunc?: boolean;
40
+ /** 启用 ITN */
41
+ enableItn?: boolean;
42
+ }
43
+
44
+ /** useVoiceInput 返回值 */
45
+ export interface UseVoiceInputReturn {
46
+ /** 当前状态 */
47
+ status: Ref<VoiceInputStatus>;
48
+ /** 是否正在录音 */
49
+ isRecording: Ref<boolean>;
50
+ /** 当前识别文本 */
51
+ currentText: Ref<string>;
52
+ /** 最终识别文本 */
53
+ finalText: Ref<string>;
54
+ /** 错误信息 */
55
+ error: Ref<string | null>;
56
+ /** 开始录音 */
57
+ start: () => Promise<void>;
58
+ /** 停止录音并获取结果 */
59
+ stop: () => Promise<string>;
60
+ /** 取消录音 */
61
+ cancel: () => void;
62
+ }
63
+
64
+ // ============ 音频处理工具 ============
65
+
66
+ /**
67
+ * 将 Float32Array 转换为 Int16Array(PCM 16bit)
68
+ */
69
+ function float32ToInt16(float32Array: Float32Array): Int16Array {
70
+ const int16Array = new Int16Array(float32Array.length);
71
+ for (let i = 0; i < float32Array.length; i++) {
72
+ // 限制范围 [-1, 1]
73
+ const s = Math.max(-1, Math.min(1, float32Array[i]));
74
+ // 转换为 16bit
75
+ int16Array[i] = s < 0 ? s * 0x8000 : s * 0x7fff;
76
+ }
77
+ return int16Array;
78
+ }
79
+
80
+ /**
81
+ * 重采样音频(从浏览器采样率到目标采样率)
82
+ */
83
+ function resample(
84
+ audioData: Float32Array,
85
+ fromSampleRate: number,
86
+ toSampleRate: number
87
+ ): Float32Array {
88
+ if (fromSampleRate === toSampleRate) {
89
+ return audioData;
90
+ }
91
+
92
+ const ratio = fromSampleRate / toSampleRate;
93
+ const newLength = Math.round(audioData.length / ratio);
94
+ const result = new Float32Array(newLength);
95
+
96
+ for (let i = 0; i < newLength; i++) {
97
+ const srcIndex = i * ratio;
98
+ const srcIndexFloor = Math.floor(srcIndex);
99
+ const srcIndexCeil = Math.min(srcIndexFloor + 1, audioData.length - 1);
100
+ const t = srcIndex - srcIndexFloor;
101
+
102
+ // 线性插值
103
+ result[i] = audioData[srcIndexFloor] * (1 - t) + audioData[srcIndexCeil] * t;
104
+ }
105
+
106
+ return result;
107
+ }
108
+
109
+ /**
110
+ * 创建 AudioWorklet 采集节点(避免使用已弃用的 ScriptProcessorNode)
111
+ * - source -> worklet -> gain(0) -> destination(静音,避免回放)
112
+ * - worklet 内部按 chunkSize 聚合后通过 port 发给主线程
113
+ */
114
+ async function setupAudioWorkletCapture(opts: {
115
+ audioContext: AudioContext;
116
+ source: MediaStreamAudioSourceNode;
117
+ chunkSize: number;
118
+ onChunk: (chunk: Float32Array) => void;
119
+ }): Promise<{
120
+ cleanup: () => void;
121
+ }> {
122
+ const { audioContext, source, chunkSize, onChunk } = opts;
123
+
124
+ // 运行时注入 worklet(无需额外打包配置)
125
+ const workletCode = `
126
+ class PcmCaptureProcessor extends AudioWorkletProcessor {
127
+ constructor(options) {
128
+ super();
129
+ const size = (options && options.processorOptions && options.processorOptions.chunkSize) || 4096;
130
+ this._chunkSize = size;
131
+ this._buf = new Float32Array(size);
132
+ this._offset = 0;
133
+ }
134
+ process(inputs, outputs) {
135
+ const input = inputs && inputs[0] && inputs[0][0];
136
+ const output = outputs && outputs[0] && outputs[0][0];
137
+ if (!input) return true;
138
+ // 可选直通到输出(由 gain=0 静音)
139
+ if (output) output.set(input);
140
+
141
+ let i = 0;
142
+ while (i < input.length) {
143
+ const remain = this._chunkSize - this._offset;
144
+ const take = Math.min(remain, input.length - i);
145
+ this._buf.set(input.subarray(i, i + take), this._offset);
146
+ this._offset += take;
147
+ i += take;
148
+ if (this._offset >= this._chunkSize) {
149
+ const out = this._buf;
150
+ this.port.postMessage(out, [out.buffer]);
151
+ this._buf = new Float32Array(this._chunkSize);
152
+ this._offset = 0;
153
+ }
154
+ }
155
+ return true;
156
+ }
157
+ }
158
+ registerProcessor('pcm-capture', PcmCaptureProcessor);
159
+ `;
160
+
161
+ const blob = new Blob([workletCode], { type: 'text/javascript' });
162
+ const url = URL.createObjectURL(blob);
163
+
164
+ await audioContext.audioWorklet.addModule(url);
165
+ URL.revokeObjectURL(url);
166
+
167
+ const workletNode = new AudioWorkletNode(audioContext, 'pcm-capture', {
168
+ numberOfInputs: 1,
169
+ numberOfOutputs: 1,
170
+ channelCount: 1,
171
+ processorOptions: { chunkSize },
172
+ });
173
+
174
+ const silentGain = audioContext.createGain();
175
+ silentGain.gain.value = 0;
176
+
177
+ const onMessage = (event: MessageEvent) => {
178
+ const data = event.data;
179
+ if (data instanceof Float32Array) {
180
+ onChunk(data);
181
+ return;
182
+ }
183
+ // 兼容:可能仅传了 ArrayBuffer
184
+ if (data instanceof ArrayBuffer) {
185
+ onChunk(new Float32Array(data));
186
+ }
187
+ };
188
+
189
+ workletNode.port.addEventListener('message', onMessage);
190
+ workletNode.port.start();
191
+
192
+ source.connect(workletNode);
193
+ workletNode.connect(silentGain);
194
+ silentGain.connect(audioContext.destination);
195
+
196
+ return {
197
+ cleanup: () => {
198
+ try {
199
+ workletNode.port.removeEventListener('message', onMessage);
200
+ } catch {
201
+ // ignore
202
+ }
203
+ try {
204
+ workletNode.disconnect();
205
+ } catch {
206
+ // ignore
207
+ }
208
+ try {
209
+ silentGain.disconnect();
210
+ } catch {
211
+ // ignore
212
+ }
213
+ },
214
+ };
215
+ }
216
+
217
+ // ============ Composable ============
218
+
219
+ // 全局预热标志:确保只预热一次(避免多个组件实例重复预热)
220
+ let asrWarmupDone = false;
221
+
222
+ /**
223
+ * 语音输入 Composable
224
+ */
225
+ export function useVoiceInput(
226
+ adapter: ChatAdapter | undefined,
227
+ config: VoiceInputConfig = {}
228
+ ): UseVoiceInputReturn {
229
+ const {
230
+ sampleRate = 16000,
231
+ sendInterval = 200,
232
+ enablePunc = true,
233
+ enableItn = true,
234
+ } = config;
235
+
236
+ // 自动预热 ASR 连接(仅首次调用,延迟执行,避免阻塞初始化)
237
+ if (adapter && !asrWarmupDone && typeof adapter.asrWarmup === 'function') {
238
+ asrWarmupDone = true;
239
+ // 延迟 800ms 预热,避免与首屏渲染竞争资源
240
+ setTimeout(() => {
241
+ adapter.asrWarmup?.().catch(() => {
242
+ // 静默失败,不影响功能
243
+ });
244
+ }, 800);
245
+ }
246
+
247
+ // 状态
248
+ const status = ref<VoiceInputStatus>('idle');
249
+ const currentText = ref('');
250
+ const finalText = ref('');
251
+ const error = ref<string | null>(null);
252
+
253
+ // 内部状态
254
+ let mediaStream: MediaStream | null = null;
255
+ let audioContext: AudioContext | null = null;
256
+ let workletCleanup: (() => void) | null = null;
257
+ let source: MediaStreamAudioSourceNode | null = null;
258
+ let audioBuffer: Float32Array[] = [];
259
+ let sendTimer: ReturnType<typeof setInterval> | null = null;
260
+ let cleanupFns: Array<() => void> = [];
261
+
262
+ // 计算属性:UI 维度的“正在录音”,包含 connecting,避免用户误以为没点到
263
+ const isRecording = computed(() => status.value === 'connecting' || status.value === 'recording');
264
+
265
+ /**
266
+ * 清理资源
267
+ */
268
+ function cleanup() {
269
+ // 清理定时器
270
+ if (sendTimer) {
271
+ clearInterval(sendTimer);
272
+ sendTimer = null;
273
+ }
274
+
275
+ // 清理音频处理
276
+ if (workletCleanup) {
277
+ workletCleanup();
278
+ workletCleanup = null;
279
+ }
280
+ if (source) {
281
+ source.disconnect();
282
+ source = null;
283
+ }
284
+ if (audioContext) {
285
+ audioContext.close().catch(() => {});
286
+ audioContext = null;
287
+ }
288
+
289
+ // 停止媒体流
290
+ if (mediaStream) {
291
+ mediaStream.getTracks().forEach(track => track.stop());
292
+ mediaStream = null;
293
+ }
294
+
295
+ // 清理事件监听
296
+ cleanupFns.forEach(fn => fn());
297
+ cleanupFns = [];
298
+
299
+ // 清空缓冲区
300
+ audioBuffer = [];
301
+ }
302
+
303
+ /**
304
+ * 发送累积的音频数据
305
+ */
306
+ async function sendAudioChunk() {
307
+ if (!adapter?.asrSendAudio || audioBuffer.length === 0) return;
308
+
309
+ // 合并所有缓冲的音频数据
310
+ const totalLength = audioBuffer.reduce((sum, arr) => sum + arr.length, 0);
311
+ const merged = new Float32Array(totalLength);
312
+ let offset = 0;
313
+ for (const chunk of audioBuffer) {
314
+ merged.set(chunk, offset);
315
+ offset += chunk.length;
316
+ }
317
+ audioBuffer = [];
318
+
319
+ // 转换为 PCM 16bit
320
+ const pcmData = float32ToInt16(merged);
321
+
322
+ // 发送到后端
323
+ // 直接发送底层 buffer(IPC 会进行序列化/拷贝;这里不做额外拷贝)
324
+ const result = await adapter.asrSendAudio(pcmData.buffer);
325
+ if (!result.success) {
326
+ console.error('[VoiceInput] 发送音频失败:', result.error);
327
+ }
328
+ }
329
+
330
+ /**
331
+ * 开始录音
332
+ */
333
+ async function start(): Promise<void> {
334
+ // 防抖:连接中/录音中不重复 start,避免多次点击造成状态错乱
335
+ if (status.value === 'connecting' || status.value === 'recording') {
336
+ return;
337
+ }
338
+ if (!adapter) {
339
+ error.value = 'Adapter 未初始化';
340
+ status.value = 'error';
341
+ return;
342
+ }
343
+
344
+ if (!adapter.asrStart) {
345
+ error.value = '语音识别功能不可用';
346
+ status.value = 'error';
347
+ return;
348
+ }
349
+
350
+ // 重置状态
351
+ error.value = null;
352
+ currentText.value = '';
353
+ finalText.value = '';
354
+ status.value = 'connecting';
355
+
356
+ try {
357
+ // 请求麦克风权限
358
+ mediaStream = await navigator.mediaDevices.getUserMedia({
359
+ audio: {
360
+ channelCount: 1,
361
+ sampleRate: { ideal: sampleRate },
362
+ echoCancellation: true,
363
+ noiseSuppression: true,
364
+ },
365
+ });
366
+
367
+ // 创建音频上下文
368
+ audioContext = new AudioContext({ sampleRate });
369
+ const actualSampleRate = audioContext.sampleRate;
370
+
371
+ // 注册 ASR 事件监听
372
+ if (adapter.onAsrResult) {
373
+ const cleanup = adapter.onAsrResult((data: { result: AsrResultData; isLast: boolean }) => {
374
+ const text = data.result.result?.text || '';
375
+ currentText.value = text;
376
+
377
+ if (data.isLast) {
378
+ finalText.value = text;
379
+ }
380
+ });
381
+ cleanupFns.push(cleanup);
382
+ }
383
+
384
+ if (adapter.onAsrError) {
385
+ const cleanup = adapter.onAsrError((err: { message: string }) => {
386
+ console.error('[VoiceInput] ASR 错误:', err.message);
387
+ error.value = err.message;
388
+ status.value = 'error';
389
+ });
390
+ cleanupFns.push(cleanup);
391
+ }
392
+
393
+ if (adapter.onAsrClosed) {
394
+ const cleanup = adapter.onAsrClosed(() => {
395
+ console.log('[VoiceInput] ASR 连接关闭');
396
+ if (status.value === 'recording') {
397
+ status.value = 'idle';
398
+ }
399
+ });
400
+ cleanupFns.push(cleanup);
401
+ }
402
+
403
+ // 启动 ASR 会话
404
+ const startResult = await adapter.asrStart({
405
+ format: 'pcm',
406
+ sampleRate,
407
+ enablePunc,
408
+ enableItn,
409
+ showUtterances: true,
410
+ });
411
+
412
+ if (!startResult.success) {
413
+ throw new Error(startResult.error || 'ASR 启动失败');
414
+ }
415
+
416
+ // 创建音频处理节点(连接成功后再开始接收语音,降低复杂度)
417
+ source = audioContext.createMediaStreamSource(mediaStream);
418
+ if (typeof AudioWorkletNode === 'undefined' || !audioContext.audioWorklet) {
419
+ throw new Error('当前环境不支持 AudioWorkletNode(无法启动语音录制)');
420
+ }
421
+ const { cleanup: off } = await setupAudioWorkletCapture({
422
+ audioContext,
423
+ source,
424
+ chunkSize: 4096,
425
+ onChunk: (chunk) => {
426
+ if (status.value !== 'recording') return;
427
+ const resampled = resample(chunk, actualSampleRate, sampleRate);
428
+ audioBuffer.push(new Float32Array(resampled));
429
+ },
430
+ });
431
+ workletCleanup = off;
432
+
433
+ // 启动定时发送
434
+ sendTimer = setInterval(sendAudioChunk, sendInterval);
435
+
436
+ status.value = 'recording';
437
+
438
+ } catch (err) {
439
+ console.error('[VoiceInput] 启动失败:', err);
440
+ error.value = err instanceof Error ? err.message : String(err);
441
+ status.value = 'error';
442
+ cleanup();
443
+ }
444
+ }
445
+
446
+ /**
447
+ * 停止录音并获取结果
448
+ */
449
+ async function stop(): Promise<string> {
450
+ // 连接中点击停止:按取消处理,保证状态立刻回到 idle
451
+ if (status.value === 'connecting') {
452
+ cancel();
453
+ return '';
454
+ }
455
+ if (status.value !== 'recording') {
456
+ return finalText.value || currentText.value;
457
+ }
458
+
459
+ status.value = 'processing';
460
+
461
+ try {
462
+ // 发送剩余的音频数据
463
+ await sendAudioChunk();
464
+
465
+ // 发送结束信号
466
+ if (adapter?.asrFinish) {
467
+ await adapter.asrFinish();
468
+ }
469
+
470
+ // 等待最终结果(最多等待 3 秒)
471
+ await new Promise<void>((resolve) => {
472
+ let timeout: ReturnType<typeof setTimeout>;
473
+
474
+ const checkResult = () => {
475
+ if (finalText.value) {
476
+ clearTimeout(timeout);
477
+ resolve();
478
+ }
479
+ };
480
+
481
+ // 监听最终结果
482
+ const interval = setInterval(checkResult, 100);
483
+
484
+ timeout = setTimeout(() => {
485
+ clearInterval(interval);
486
+ resolve();
487
+ }, 3000);
488
+ });
489
+
490
+ } catch (err) {
491
+ console.error('[VoiceInput] 停止失败:', err);
492
+ error.value = err instanceof Error ? err.message : String(err);
493
+ } finally {
494
+ cleanup();
495
+ status.value = 'idle';
496
+ }
497
+
498
+ return finalText.value || currentText.value;
499
+ }
500
+
501
+ /**
502
+ * 取消录音
503
+ */
504
+ function cancel() {
505
+ if (adapter?.asrStop) {
506
+ adapter.asrStop().catch(() => {});
507
+ }
508
+ cleanup();
509
+ currentText.value = '';
510
+ finalText.value = '';
511
+ error.value = null;
512
+ status.value = 'idle';
513
+ }
514
+
515
+ // 组件卸载时清理
516
+ onUnmounted(() => {
517
+ cancel();
518
+ });
519
+
520
+ return {
521
+ status,
522
+ isRecording,
523
+ currentText,
524
+ finalText,
525
+ error,
526
+ start,
527
+ stop,
528
+ cancel,
529
+ };
530
+ }
531
+
@@ -0,0 +1,94 @@
1
+ /**
2
+ * 将语音识别(useVoiceInput)封装成“写入输入框”的高内聚控制层
3
+ * - 处理 prefix(追加模式)
4
+ * - 处理实时同步到 inputText
5
+ * - 处理 Enter:录音中 Enter 停止/取消语音(不发送)
6
+ * - 输出按钮状态/禁用逻辑(ChatInput 只负责渲染)
7
+ */
8
+
9
+ import { computed, nextTick, ref, type Ref, watch } from 'vue';
10
+ import type { ChatAdapter } from '../adapter';
11
+ import { useVoiceInput } from './useVoiceInput';
12
+
13
+ export function useVoiceToTextInput(opts: {
14
+ adapter: ChatAdapter | undefined;
15
+ inputText: Ref<string>;
16
+ hasImages: Ref<boolean>;
17
+ isLoading: Ref<boolean>;
18
+ focusInput?: () => void;
19
+ adjustTextareaHeight?: () => void;
20
+ }) {
21
+ const { adapter, inputText, hasImages, isLoading, focusInput, adjustTextareaHeight } = opts;
22
+
23
+ const voiceInput = useVoiceInput(adapter);
24
+ const voiceInputPrefix = ref('');
25
+
26
+ const voiceStatus = voiceInput.status;
27
+ const isVoiceActive = computed(() => voiceStatus.value === 'connecting' || voiceStatus.value === 'recording');
28
+
29
+ // 实时同步语音识别文本到输入框(追加模式)
30
+ watch(voiceInput.currentText, (text) => {
31
+ if (!isVoiceActive.value) return;
32
+ const prefix = voiceInputPrefix.value;
33
+ inputText.value = prefix ? `${prefix} ${text}` : text;
34
+ nextTick(() => adjustTextareaHeight?.());
35
+ });
36
+
37
+ async function toggleVoice() {
38
+ if (isLoading.value) return;
39
+
40
+ // connecting 再点一次:取消并恢复录音前内容
41
+ if (voiceStatus.value === 'connecting') {
42
+ voiceInput.cancel();
43
+ inputText.value = voiceInputPrefix.value;
44
+ voiceInputPrefix.value = '';
45
+ nextTick(() => {
46
+ adjustTextareaHeight?.();
47
+ focusInput?.();
48
+ });
49
+ return;
50
+ }
51
+
52
+ if (voiceStatus.value === 'recording') {
53
+ await voiceInput.stop();
54
+ voiceInputPrefix.value = '';
55
+ nextTick(() => {
56
+ adjustTextareaHeight?.();
57
+ focusInput?.();
58
+ });
59
+ return;
60
+ }
61
+
62
+ // idle -> start
63
+ voiceInputPrefix.value = inputText.value?.trim() || '';
64
+ await voiceInput.start();
65
+ }
66
+
67
+ // 语音中:禁用发送(避免与停止录音冲突)
68
+ const sendDisabled = computed(() => {
69
+ if (isLoading.value) return false; // 允许停止生成
70
+ if (isVoiceActive.value) return true;
71
+ return !inputText.value.trim() && !hasImages.value;
72
+ });
73
+
74
+ function handleKeydownForVoice(e: KeyboardEvent): boolean {
75
+ if (!isVoiceActive.value) return false;
76
+ if (e.key === 'Enter' && !e.shiftKey) {
77
+ e.preventDefault();
78
+ void toggleVoice();
79
+ return true;
80
+ }
81
+ return false;
82
+ }
83
+
84
+ return {
85
+ voiceInput,
86
+ voiceStatus,
87
+ isVoiceActive,
88
+ toggleVoice,
89
+ sendDisabled,
90
+ handleKeydownForVoice,
91
+ };
92
+ }
93
+
94
+