@yh-ui/hooks 0.1.12 → 0.1.16

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.
@@ -44,6 +44,37 @@ export const qwenParser = (raw) => {
44
44
  }
45
45
  return text || null;
46
46
  };
47
+ export const claudeParser = (raw) => {
48
+ const lines = raw.split("\n");
49
+ let text = "";
50
+ for (const line of lines) {
51
+ if (!line.startsWith("data: ")) continue;
52
+ const data = line.slice(6).trim();
53
+ try {
54
+ const json = JSON.parse(data);
55
+ if (json?.type === "content_block_delta" && json?.delta?.text) {
56
+ text += json.delta.text;
57
+ }
58
+ } catch {
59
+ }
60
+ }
61
+ return text || null;
62
+ };
63
+ export const geminiParser = (raw) => {
64
+ const lines = raw.split("\n");
65
+ let text = "";
66
+ for (const line of lines) {
67
+ const content = line.startsWith("data: ") ? line.slice(6).trim() : line.trim();
68
+ if (!content) continue;
69
+ try {
70
+ const json = JSON.parse(content);
71
+ const part = json?.candidates?.[0]?.content?.parts?.[0]?.text;
72
+ if (part) text += part;
73
+ } catch {
74
+ }
75
+ }
76
+ return text || null;
77
+ };
47
78
  export const plainTextParser = (raw) => raw || null;
48
79
  class TypewriterThrottle {
49
80
  queue = [];
@@ -167,6 +198,13 @@ export function useAiStream(options) {
167
198
  fetchStream,
168
199
  stop,
169
200
  // 暴露解析器供测试/自定义使用
170
- parsers: { openaiParser, ernieParser, qwenParser, plainTextParser }
201
+ parsers: {
202
+ openaiParser,
203
+ ernieParser,
204
+ qwenParser,
205
+ claudeParser,
206
+ geminiParser,
207
+ plainTextParser
208
+ }
171
209
  };
172
210
  }
@@ -0,0 +1,218 @@
1
+ "use strict";
2
+
3
+ Object.defineProperty(exports, "__esModule", {
4
+ value: true
5
+ });
6
+ exports.useAiVoice = useAiVoice;
7
+ var _vue = require("vue");
8
+ function useAiVoice(options = {}) {
9
+ const {
10
+ language = "zh-CN",
11
+ interimResults = true,
12
+ continuous = false,
13
+ vad = true,
14
+ vadThreshold = 2e3,
15
+ volumeThreshold = 0.05,
16
+ waveCount = 20,
17
+ useSTT = true
18
+ } = options;
19
+ const isRecording = (0, _vue.ref)(false);
20
+ const transcript = (0, _vue.ref)("");
21
+ const interimTranscript = (0, _vue.ref)("");
22
+ const volume = (0, _vue.ref)(0);
23
+ const amplitudes = (0, _vue.ref)(new Array(waveCount).fill(5));
24
+ const audioBlob = (0, _vue.ref)(null);
25
+ const recognition = (0, _vue.shallowRef)(null);
26
+ const audioContext = (0, _vue.shallowRef)(null);
27
+ const analyser = (0, _vue.shallowRef)(null);
28
+ const stream = (0, _vue.shallowRef)(null);
29
+ const mediaRecorder = (0, _vue.shallowRef)(null);
30
+ let chunks = [];
31
+ let animationId = null;
32
+ let silenceStart = null;
33
+ const _window = typeof window !== "undefined" ? window : null;
34
+ const SpeechRecognition = _window?.SpeechRecognition || _window?.webkitSpeechRecognition;
35
+ const sttSupported = !!SpeechRecognition;
36
+ const initMediaRecorder = mediaStream => {
37
+ chunks = [];
38
+ const recorder = new MediaRecorder(mediaStream);
39
+ recorder.ondataavailable = e => {
40
+ if (e.data.size > 0) chunks.push(e.data);
41
+ };
42
+ recorder.onstop = () => {
43
+ audioBlob.value = new Blob(chunks, {
44
+ type: "audio/webm"
45
+ });
46
+ if (isRecording.value === false && chunks.length > 0) {
47
+ options.onStop?.(transcript.value, audioBlob.value);
48
+ }
49
+ };
50
+ mediaRecorder.value = recorder;
51
+ };
52
+ const initRecognition = () => {
53
+ if (!sttSupported || !useSTT) return;
54
+ const recognitionInstance = new SpeechRecognition();
55
+ recognitionInstance.lang = language;
56
+ recognitionInstance.interimResults = interimResults;
57
+ recognitionInstance.continuous = continuous;
58
+ recognitionInstance.onresult = event => {
59
+ let currentInterim = "";
60
+ for (let i = event.resultIndex; i < event.results.length; ++i) {
61
+ if (event.results[i].isFinal) {
62
+ transcript.value += event.results[i][0].transcript;
63
+ options.onResult?.(transcript.value);
64
+ } else {
65
+ currentInterim += event.results[i][0].transcript;
66
+ }
67
+ }
68
+ interimTranscript.value = currentInterim;
69
+ options.onPartialResult?.(currentInterim);
70
+ };
71
+ recognitionInstance.onerror = event => {
72
+ if (event.error !== "no-speech" && event.error !== "aborted") {
73
+ options.onError?.(event);
74
+ }
75
+ };
76
+ recognition.value = recognitionInstance;
77
+ };
78
+ const initAudioAnalyzer = async mediaStream => {
79
+ try {
80
+ const AudioCtx = window.AudioContext || window.webkitAudioContext;
81
+ audioContext.value = new AudioCtx();
82
+ if (audioContext.value.state === "suspended") {
83
+ await audioContext.value.resume();
84
+ }
85
+ analyser.value = audioContext.value.createAnalyser();
86
+ analyser.value.fftSize = 256;
87
+ const source = audioContext.value.createMediaStreamSource(mediaStream);
88
+ source.connect(analyser.value);
89
+ const bufferLength = analyser.value.frequencyBinCount;
90
+ const dataArray = new Uint8Array(bufferLength);
91
+ const process = () => {
92
+ if (!isRecording.value) {
93
+ amplitudes.value = new Array(waveCount).fill(5);
94
+ volume.value = 0;
95
+ return;
96
+ }
97
+ animationId = requestAnimationFrame(process);
98
+ analyser.value.getByteFrequencyData(dataArray);
99
+ let total = 0;
100
+ for (let i = 0; i < bufferLength; i++) total += dataArray[i];
101
+ const avg = total / bufferLength;
102
+ volume.value = Math.min(100, avg / 128 * 100);
103
+ const step = Math.floor(bufferLength / waveCount);
104
+ const newAmps = [];
105
+ for (let i = 0; i < waveCount; i++) {
106
+ const val = dataArray[i * step];
107
+ newAmps.push(6 + val / 255 * 34);
108
+ }
109
+ amplitudes.value = newAmps;
110
+ if (vad) {
111
+ const normalizedVol = avg / 255;
112
+ if (normalizedVol < volumeThreshold) {
113
+ if (silenceStart === null) silenceStart = Date.now();else if (Date.now() - silenceStart > vadThreshold) {
114
+ stop();
115
+ }
116
+ } else {
117
+ silenceStart = null;
118
+ }
119
+ }
120
+ };
121
+ process();
122
+ } catch (err) {
123
+ options.onError?.(err);
124
+ }
125
+ };
126
+ const start = async () => {
127
+ if (isRecording.value) return;
128
+ try {
129
+ transcript.value = "";
130
+ interimTranscript.value = "";
131
+ audioBlob.value = null;
132
+ silenceStart = null;
133
+ stream.value = await navigator.mediaDevices.getUserMedia({
134
+ audio: true
135
+ });
136
+ isRecording.value = true;
137
+ initMediaRecorder(stream.value);
138
+ initRecognition();
139
+ await initAudioAnalyzer(stream.value);
140
+ mediaRecorder.value?.start(1e3);
141
+ recognition.value?.start();
142
+ options.onStart?.();
143
+ } catch (err) {
144
+ isRecording.value = false;
145
+ if (stream.value) {
146
+ stream.value.getTracks().forEach(t => t.stop());
147
+ stream.value = null;
148
+ }
149
+ console.error("[yh-ui/hooks] useAiVoice start failed:", err);
150
+ options.onError?.(err);
151
+ }
152
+ };
153
+ const stop = () => {
154
+ if (!isRecording.value) return;
155
+ isRecording.value = false;
156
+ if (stream.value) {
157
+ stream.value.getTracks().forEach(track => track.stop());
158
+ stream.value = null;
159
+ }
160
+ if (recognition.value) {
161
+ try {
162
+ recognition.value.stop();
163
+ } catch {}
164
+ }
165
+ if (mediaRecorder.value && mediaRecorder.value.state !== "inactive") {
166
+ try {
167
+ mediaRecorder.value.stop();
168
+ } catch {}
169
+ }
170
+ cleanup();
171
+ };
172
+ const cancel = () => {
173
+ if (!isRecording.value) return;
174
+ isRecording.value = false;
175
+ if (stream.value) {
176
+ stream.value.getTracks().forEach(track => track.stop());
177
+ stream.value = null;
178
+ }
179
+ if (recognition.value) {
180
+ try {
181
+ recognition.value.abort();
182
+ } catch {}
183
+ }
184
+ if (mediaRecorder.value && mediaRecorder.value.state !== "inactive") {
185
+ try {
186
+ mediaRecorder.value.stop();
187
+ } catch {}
188
+ }
189
+ cleanup();
190
+ };
191
+ const cleanup = () => {
192
+ if (animationId) {
193
+ cancelAnimationFrame(animationId);
194
+ animationId = null;
195
+ }
196
+ if (audioContext.value && audioContext.value.state !== "closed") {
197
+ audioContext.value.close().catch(_err => {});
198
+ audioContext.value = null;
199
+ }
200
+ amplitudes.value = new Array(waveCount).fill(5);
201
+ volume.value = 0;
202
+ };
203
+ (0, _vue.onUnmounted)(() => {
204
+ if (isRecording.value) stop();else cleanup();
205
+ });
206
+ return {
207
+ isRecording,
208
+ transcript,
209
+ interimTranscript,
210
+ amplitudes,
211
+ volume,
212
+ audioBlob,
213
+ start,
214
+ stop,
215
+ cancel,
216
+ sttSupported
217
+ };
218
+ }
@@ -0,0 +1,83 @@
1
+ export interface UseAiVoiceOptions {
2
+ /**
3
+ * 语言代码 (用于 SpeechRecognition)
4
+ * @default 'zh-CN'
5
+ */
6
+ language?: string;
7
+ /**
8
+ * 是否需要临时结果(在说话过程中实时返回)
9
+ * @default true
10
+ */
11
+ interimResults?: boolean;
12
+ /**
13
+ * 是否连续识别
14
+ * @default false
15
+ */
16
+ continuous?: boolean;
17
+ /**
18
+ * 智能静音检测(VAD)
19
+ * 开启后,当检测到长时间无声会自动停止录音
20
+ * @default true
21
+ */
22
+ vad?: boolean;
23
+ /**
24
+ * 静音检测阈值 (ms)
25
+ * @default 2000
26
+ */
27
+ vadThreshold?: number;
28
+ /**
29
+ * 音量变化敏感度 (0-1)
30
+ * @default 0.05
31
+ */
32
+ volumeThreshold?: number;
33
+ /**
34
+ * 返回波形柱的数量(对应 AiVoiceTrigger 的 amplitudes)
35
+ * @default 20
36
+ */
37
+ waveCount?: number;
38
+ /**
39
+ * 是否在开始时自动执行浏览器语音识别 (SpeechRecognition)
40
+ * 如果关闭,则只进行物理音频录制
41
+ * @default true
42
+ */
43
+ useSTT?: boolean;
44
+ /** 回调事件 */
45
+ onStart?: () => void;
46
+ /** 停止回调,包含最终转写文本和录音文件 Blob */
47
+ onStop?: (transcript: string, blob: Blob | null) => void;
48
+ onResult?: (transcript: string) => void;
49
+ onPartialResult?: (transcript: string) => void;
50
+ onError?: (error: unknown) => void;
51
+ }
52
+ export interface UseAiVoiceReturn {
53
+ /** 是否正在录音 */
54
+ isRecording: import('vue').Ref<boolean>;
55
+ /** 最终转写文本 */
56
+ transcript: import('vue').Ref<string>;
57
+ /** 过程中的临时文本 */
58
+ interimTranscript: import('vue').Ref<string>;
59
+ /** 实时波形数据 */
60
+ amplitudes: import('vue').Ref<number[]>;
61
+ /** 实时音量 (0-100) */
62
+ volume: import('vue').Ref<number>;
63
+ /** 录音文件的 Blob */
64
+ audioBlob: import('vue').Ref<Blob | null>;
65
+ /** 开始录音 */
66
+ start: () => Promise<void>;
67
+ /** 停止录音 */
68
+ stop: () => void;
69
+ /** 取消并放弃当前结果 */
70
+ cancel: () => void;
71
+ /** 浏览器是否支持 SpeechRecognition (用于显示警告) */
72
+ sttSupported: boolean;
73
+ }
74
+ /**
75
+ * useAiVoice - 专业级 AI 语音交互 Hook
76
+ *
77
+ * 核心能力:
78
+ * 1. 【音频录制】:通过 MediaRecorder 真实录制音频并导出 Blob 文件。
79
+ * 2. 【视觉分析】:通过 Web Audio API 实时输出驱动 AiVoiceTrigger 的波形数组。
80
+ * 3. 【智能 VAD】:多维检测静音状态,支持自动停顿结束。
81
+ * 4. 【语音转写】:内置 Web Speech API 实时转写及临时结果反馈。
82
+ */
83
+ export declare function useAiVoice(options?: UseAiVoiceOptions): UseAiVoiceReturn;
@@ -0,0 +1,215 @@
1
+ import { ref, onUnmounted, shallowRef } from "vue";
2
+ export function useAiVoice(options = {}) {
3
+ const {
4
+ language = "zh-CN",
5
+ interimResults = true,
6
+ continuous = false,
7
+ vad = true,
8
+ vadThreshold = 2e3,
9
+ volumeThreshold = 0.05,
10
+ waveCount = 20,
11
+ useSTT = true
12
+ } = options;
13
+ const isRecording = ref(false);
14
+ const transcript = ref("");
15
+ const interimTranscript = ref("");
16
+ const volume = ref(0);
17
+ const amplitudes = ref(new Array(waveCount).fill(5));
18
+ const audioBlob = ref(null);
19
+ const recognition = shallowRef(null);
20
+ const audioContext = shallowRef(null);
21
+ const analyser = shallowRef(null);
22
+ const stream = shallowRef(null);
23
+ const mediaRecorder = shallowRef(null);
24
+ let chunks = [];
25
+ let animationId = null;
26
+ let silenceStart = null;
27
+ const _window = typeof window !== "undefined" ? window : null;
28
+ const SpeechRecognition = _window?.SpeechRecognition || _window?.webkitSpeechRecognition;
29
+ const sttSupported = !!SpeechRecognition;
30
+ const initMediaRecorder = (mediaStream) => {
31
+ chunks = [];
32
+ const recorder = new MediaRecorder(mediaStream);
33
+ recorder.ondataavailable = (e) => {
34
+ if (e.data.size > 0) chunks.push(e.data);
35
+ };
36
+ recorder.onstop = () => {
37
+ audioBlob.value = new Blob(chunks, { type: "audio/webm" });
38
+ if (isRecording.value === false && chunks.length > 0) {
39
+ options.onStop?.(transcript.value, audioBlob.value);
40
+ }
41
+ };
42
+ mediaRecorder.value = recorder;
43
+ };
44
+ const initRecognition = () => {
45
+ if (!sttSupported || !useSTT) return;
46
+ const recognitionInstance = new SpeechRecognition();
47
+ recognitionInstance.lang = language;
48
+ recognitionInstance.interimResults = interimResults;
49
+ recognitionInstance.continuous = continuous;
50
+ recognitionInstance.onresult = (event) => {
51
+ let currentInterim = "";
52
+ for (let i = event.resultIndex; i < event.results.length; ++i) {
53
+ if (event.results[i].isFinal) {
54
+ transcript.value += event.results[i][0].transcript;
55
+ options.onResult?.(transcript.value);
56
+ } else {
57
+ currentInterim += event.results[i][0].transcript;
58
+ }
59
+ }
60
+ interimTranscript.value = currentInterim;
61
+ options.onPartialResult?.(currentInterim);
62
+ };
63
+ recognitionInstance.onerror = (event) => {
64
+ if (event.error !== "no-speech" && event.error !== "aborted") {
65
+ options.onError?.(event);
66
+ }
67
+ };
68
+ recognition.value = recognitionInstance;
69
+ };
70
+ const initAudioAnalyzer = async (mediaStream) => {
71
+ try {
72
+ const AudioCtx = window.AudioContext || window.webkitAudioContext;
73
+ audioContext.value = new AudioCtx();
74
+ if (audioContext.value.state === "suspended") {
75
+ await audioContext.value.resume();
76
+ }
77
+ analyser.value = audioContext.value.createAnalyser();
78
+ analyser.value.fftSize = 256;
79
+ const source = audioContext.value.createMediaStreamSource(mediaStream);
80
+ source.connect(analyser.value);
81
+ const bufferLength = analyser.value.frequencyBinCount;
82
+ const dataArray = new Uint8Array(bufferLength);
83
+ const process = () => {
84
+ if (!isRecording.value) {
85
+ amplitudes.value = new Array(waveCount).fill(5);
86
+ volume.value = 0;
87
+ return;
88
+ }
89
+ animationId = requestAnimationFrame(process);
90
+ analyser.value.getByteFrequencyData(dataArray);
91
+ let total = 0;
92
+ for (let i = 0; i < bufferLength; i++) total += dataArray[i];
93
+ const avg = total / bufferLength;
94
+ volume.value = Math.min(100, avg / 128 * 100);
95
+ const step = Math.floor(bufferLength / waveCount);
96
+ const newAmps = [];
97
+ for (let i = 0; i < waveCount; i++) {
98
+ const val = dataArray[i * step];
99
+ newAmps.push(6 + val / 255 * 34);
100
+ }
101
+ amplitudes.value = newAmps;
102
+ if (vad) {
103
+ const normalizedVol = avg / 255;
104
+ if (normalizedVol < volumeThreshold) {
105
+ if (silenceStart === null) silenceStart = Date.now();
106
+ else if (Date.now() - silenceStart > vadThreshold) {
107
+ stop();
108
+ }
109
+ } else {
110
+ silenceStart = null;
111
+ }
112
+ }
113
+ };
114
+ process();
115
+ } catch (err) {
116
+ options.onError?.(err);
117
+ }
118
+ };
119
+ const start = async () => {
120
+ if (isRecording.value) return;
121
+ try {
122
+ transcript.value = "";
123
+ interimTranscript.value = "";
124
+ audioBlob.value = null;
125
+ silenceStart = null;
126
+ stream.value = await navigator.mediaDevices.getUserMedia({ audio: true });
127
+ isRecording.value = true;
128
+ initMediaRecorder(stream.value);
129
+ initRecognition();
130
+ await initAudioAnalyzer(stream.value);
131
+ mediaRecorder.value?.start(1e3);
132
+ recognition.value?.start();
133
+ options.onStart?.();
134
+ } catch (err) {
135
+ isRecording.value = false;
136
+ if (stream.value) {
137
+ stream.value.getTracks().forEach((t) => t.stop());
138
+ stream.value = null;
139
+ }
140
+ console.error("[yh-ui/hooks] useAiVoice start failed:", err);
141
+ options.onError?.(err);
142
+ }
143
+ };
144
+ const stop = () => {
145
+ if (!isRecording.value) return;
146
+ isRecording.value = false;
147
+ if (stream.value) {
148
+ stream.value.getTracks().forEach((track) => track.stop());
149
+ stream.value = null;
150
+ }
151
+ if (recognition.value) {
152
+ try {
153
+ recognition.value.stop();
154
+ } catch {
155
+ }
156
+ }
157
+ if (mediaRecorder.value && mediaRecorder.value.state !== "inactive") {
158
+ try {
159
+ mediaRecorder.value.stop();
160
+ } catch {
161
+ }
162
+ }
163
+ cleanup();
164
+ };
165
+ const cancel = () => {
166
+ if (!isRecording.value) return;
167
+ isRecording.value = false;
168
+ if (stream.value) {
169
+ stream.value.getTracks().forEach((track) => track.stop());
170
+ stream.value = null;
171
+ }
172
+ if (recognition.value) {
173
+ try {
174
+ recognition.value.abort();
175
+ } catch {
176
+ }
177
+ }
178
+ if (mediaRecorder.value && mediaRecorder.value.state !== "inactive") {
179
+ try {
180
+ mediaRecorder.value.stop();
181
+ } catch {
182
+ }
183
+ }
184
+ cleanup();
185
+ };
186
+ const cleanup = () => {
187
+ if (animationId) {
188
+ cancelAnimationFrame(animationId);
189
+ animationId = null;
190
+ }
191
+ if (audioContext.value && audioContext.value.state !== "closed") {
192
+ audioContext.value.close().catch((_err) => {
193
+ });
194
+ audioContext.value = null;
195
+ }
196
+ amplitudes.value = new Array(waveCount).fill(5);
197
+ volume.value = 0;
198
+ };
199
+ onUnmounted(() => {
200
+ if (isRecording.value) stop();
201
+ else cleanup();
202
+ });
203
+ return {
204
+ isRecording,
205
+ transcript,
206
+ interimTranscript,
207
+ amplitudes,
208
+ volume,
209
+ audioBlob,
210
+ start,
211
+ stop,
212
+ cancel,
213
+ sttSupported
214
+ };
215
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@yh-ui/hooks",
3
- "version": "0.1.12",
3
+ "version": "0.1.16",
4
4
  "description": "YH-UI composition hooks",
5
5
  "type": "module",
6
6
  "sideEffects": false,
@@ -24,8 +24,8 @@
24
24
  ],
25
25
  "dependencies": {
26
26
  "dayjs": "^1.11.19",
27
- "@yh-ui/locale": "0.1.12",
28
- "@yh-ui/utils": "0.1.12"
27
+ "@yh-ui/locale": "0.1.16",
28
+ "@yh-ui/utils": "0.1.16"
29
29
  },
30
30
  "devDependencies": {
31
31
  "vue": "^3.5.27",