Skip to content

Commit dc9998c

Browse files
committed
fix: review suggestion
1 parent f0a4ca2 commit dc9998c

5 files changed

Lines changed: 117 additions & 47 deletions

File tree

docs/demos/sender/voice-input.vue

Lines changed: 4 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -22,24 +22,22 @@ const voiceMode = ref<'append' | 'replace'>('append')
2222
{{
2323
voiceMode === 'append'
2424
? '追加模式:每次语音识别结果会追加到输入框末尾,适合混合输入'
25-
: '替换模式:在录音期间使用最新识别结果直接替换整个输入框内容'
25+
: '替换模式:在录音期间使用识别结果持续替换整个输入框内容'
2626
}}
2727
</div>
2828
<tr-sender
2929
:key="voiceMode"
3030
mode="multiple"
3131
:placeholder="
32-
voiceMode === 'append'
33-
? '可以打字或点击麦克风说话,语音内容会追加...'
34-
: '点击麦克风连续说话,输入框内容会被语音结果持续替换...'
32+
voiceMode === 'append' ? '可以打字或点击麦克风说话,语音内容会追加...' : '点击麦克风说话,输入框内容持续替换...'
3533
"
3634
>
3735
<template #footer-right>
3836
<VoiceButton
3937
:speech-config="
4038
voiceMode === 'append'
41-
? { autoReplace: false, interimResults: true }
42-
: { autoReplace: true, continuous: true, interimResults: true }
39+
? { autoReplace: false, continuous: true, interimResults: true }
40+
: { autoReplace: true, interimResults: true }
4341
"
4442
/>
4543
</template>

docs/src/components/sender.md

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
---
1+
---
22
outline: [1, 3]
33
---
44

@@ -159,6 +159,12 @@ TrSender.Suggestion.configure({ items: suggestions, filterFn: customFilter })
159159

160160
<demo vue="../../demos/sender/voice-input.vue" title="基础语音输入" description="使用浏览器内置语音识别,展示追加写入和整框替换两种体验。" />
161161

162+
:::tip 替换模式说明
163+
`speechConfig.autoReplace``true` 时,输入框会被当前录音结果整框替换。
164+
165+
如果同时开启 `speechConfig.continuous`,替换进去的是“当前录音会话的累计识别结果”,也就是后续说出的内容会和前面已确认的内容一起更新,而不是仅保留最后一句。
166+
:::
167+
162168
:::tip lang 语言说明
163169
`lang` 用于指定语音识别语言,建议显式传入,并与页面的 `html lang` 保持一致,避免页面语言和浏览器环境语言不一致时出现识别偏差。
164170

packages/components/src/sender-actions/voice-button/index.vue

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import { useSpeechHandler } from './useSpeechHandler'
55
import ActionButton from '../action-button/index.vue'
66
import { IconVoice, IconRecordingWave } from '@opentiny/tiny-robot-svgs'
77
import type { VoiceButtonProps, VoiceButtonEmits } from './index.type'
8+
import type { SpeechHookOptions } from './speech.types'
89
910
const props = withDefaults(defineProps<VoiceButtonProps>(), {
1011
tooltipPlacement: 'top',
@@ -59,8 +60,7 @@ const mergeCommittedTranscript = (transcript: string) => {
5960
return committedTranscript.value
6061
}
6162
62-
// 语音配置 - 使用普通对象而不是 computed,避免每次都创建新对象
63-
const speechOptions = {
63+
const getSpeechOptions = (): SpeechHookOptions => ({
6464
...props.speechConfig,
6565
onStart: () => {
6666
resetSpeechSession()
@@ -91,10 +91,10 @@ const speechOptions = {
9191
resetSpeechSession()
9292
emit('speech-error', error)
9393
},
94-
}
94+
})
9595
9696
// 使用语音 Hook
97-
const { speechState, start, stop } = useSpeechHandler(speechOptions)
97+
const { speechState, start, stop } = useSpeechHandler(getSpeechOptions)
9898
9999
// 处理点击
100100
const handleClick = async () => {

packages/components/src/sender-actions/voice-button/useSpeechHandler.ts

Lines changed: 99 additions & 33 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
1-
import { reactive, onUnmounted, ref } from 'vue'
1+
import { reactive, onUnmounted, shallowRef, toValue, watch } from 'vue'
2+
import type { MaybeRefOrGetter } from 'vue'
23
import type {
34
SpeechHookOptions,
45
SpeechHandlerResult,
@@ -16,9 +17,10 @@ import { WebSpeechHandler } from './webSpeechHandler'
1617
* @param options 语音识别配置
1718
* @returns 语音识别控制器
1819
*/
19-
export function useSpeechHandler(options: SpeechHookOptions): SpeechHandlerResult {
20-
// 使用 ref 存储 options,确保能获取最新值
21-
const optionsRef = ref(options)
20+
export function useSpeechHandler(options: MaybeRefOrGetter<SpeechHookOptions>): SpeechHandlerResult {
21+
const handlerRef = shallowRef<SpeechHandler | null>(null)
22+
const pendingRestart = shallowRef(false)
23+
const suppressEndCallback = shallowRef(false)
2224

2325
// 语音识别状态
2426
const speechState = reactive<SpeechState>({
@@ -27,86 +29,150 @@ export function useSpeechHandler(options: SpeechHookOptions): SpeechHandlerResul
2729
error: undefined,
2830
})
2931

30-
// 创建回调函数集合 - 使用函数形式,每次调用时获取最新的 options
32+
const resolveOptions = () => toValue(options)
33+
34+
const updateSupportState = () => {
35+
const currentOptions = resolveOptions()
36+
speechState.isSupported = currentOptions.customHandler
37+
? currentOptions.customHandler.isSupported()
38+
: WebSpeechHandler.isSupported()
39+
}
40+
41+
const createHandler = (currentOptions: SpeechHookOptions): SpeechHandler | null => {
42+
if (currentOptions.customHandler) {
43+
return currentOptions.customHandler
44+
}
45+
46+
if (!WebSpeechHandler.isSupported()) {
47+
return null
48+
}
49+
50+
return new WebSpeechHandler(currentOptions)
51+
}
52+
53+
// 创建回调函数集合 - 每次调用时都获取最新的 options
3154
const callbacks: SpeechCallbacks = {
3255
onStart: () => {
3356
speechState.isRecording = true
3457
speechState.error = undefined
35-
optionsRef.value.onStart?.()
58+
resolveOptions().onStart?.()
3659
},
3760
onInterim: (transcript: string) => {
38-
optionsRef.value.onInterim?.(transcript)
61+
resolveOptions().onInterim?.(transcript)
3962
},
4063
onFinal: (transcript: string) => {
41-
optionsRef.value.onFinal?.(transcript)
64+
resolveOptions().onFinal?.(transcript)
4265
},
4366
onEnd: (transcript?: string) => {
67+
const shouldEmitEnd = !suppressEndCallback.value
68+
const shouldRestart = pendingRestart.value
69+
70+
suppressEndCallback.value = false
71+
pendingRestart.value = false
72+
handlerRef.value = null
73+
4474
if (speechState.isRecording) {
4575
speechState.isRecording = false
46-
optionsRef.value.onEnd?.(transcript)
76+
}
77+
78+
if (shouldEmitEnd) {
79+
resolveOptions().onEnd?.(transcript)
80+
}
81+
82+
updateSupportState()
83+
84+
if (shouldRestart) {
85+
start()
4786
}
4887
},
4988
onError: (error: Error) => {
5089
speechState.error = error
5190
speechState.isRecording = false
52-
optionsRef.value.onError?.(error)
91+
pendingRestart.value = false
92+
suppressEndCallback.value = false
93+
handlerRef.value = null
94+
resolveOptions().onError?.(error)
95+
updateSupportState()
5396
},
5497
}
5598

56-
// 检查是否支持(对于内置 Handler,提前检查避免无效创建)
57-
const isBuiltinSupported = WebSpeechHandler.isSupported()
58-
speechState.isSupported = options.customHandler ? options.customHandler.isSupported() : isBuiltinSupported
59-
60-
// 选择语音处理器:如果提供了 customHandler,直接使用;否则在支持的情况下创建 WebSpeechHandler
61-
const handler: SpeechHandler | null =
62-
options.customHandler ?? (isBuiltinSupported ? new WebSpeechHandler(options) : null)
99+
watch(
100+
() => resolveOptions().customHandler,
101+
() => {
102+
if (!speechState.isRecording) {
103+
handlerRef.value = null
104+
}
105+
updateSupportState()
106+
},
107+
{ immediate: true },
108+
)
63109

64110
// 开始录音
65111
const start = () => {
66-
if (!speechState.isSupported || !handler) {
112+
const currentOptions = resolveOptions()
113+
114+
updateSupportState()
115+
116+
if (!speechState.isSupported) {
67117
const error = new Error('语音识别不受支持')
68118
speechState.error = error
69-
optionsRef.value.onError?.(error)
119+
currentOptions.onError?.(error)
70120
return
71121
}
72122

73-
// 如果正在录音,先停止再重新开始
123+
// 如果正在录音,等待当前会话自然结束后再重启
74124
if (speechState.isRecording) {
75-
handler.stop()
76-
speechState.isRecording = false
77-
// 短暂延迟后重新开始
78-
setTimeout(() => {
79-
handler.start(callbacks)
80-
}, 200)
125+
pendingRestart.value = true
126+
handlerRef.value?.stop()
81127
return
82128
}
83129

130+
const nextHandler = createHandler(currentOptions)
131+
132+
if (!nextHandler || !nextHandler.isSupported()) {
133+
const error = new Error('语音识别不受支持')
134+
speechState.error = error
135+
currentOptions.onError?.(error)
136+
updateSupportState()
137+
return
138+
}
139+
140+
handlerRef.value = nextHandler
141+
pendingRestart.value = false
142+
suppressEndCallback.value = false
143+
84144
try {
85-
handler.start(callbacks)
145+
nextHandler.start(callbacks)
86146
} catch (error) {
87147
speechState.error = error instanceof Error ? error : new Error('启动失败')
88-
optionsRef.value.onError?.(speechState.error)
148+
handlerRef.value = null
149+
currentOptions.onError?.(speechState.error)
89150
}
90151
}
91152

92153
// 停止录音
93154
const stop = () => {
94-
if (!speechState.isRecording || !handler) {
155+
if (!speechState.isRecording || !handlerRef.value) {
95156
return
96157
}
97158

98-
handler.stop()
99-
callbacks.onEnd()
159+
pendingRestart.value = false
160+
suppressEndCallback.value = false
161+
handlerRef.value.stop()
100162
}
101163

102164
// 组件卸载时清理资源
103165
onUnmounted(() => {
104166
// 如果正在录音,先停止
105-
if (speechState.isRecording && handler) {
106-
handler.stop()
167+
if (speechState.isRecording && handlerRef.value) {
168+
pendingRestart.value = false
169+
suppressEndCallback.value = true
170+
handlerRef.value.stop()
107171
// 卸载时不触发 onEnd 回调,避免不必要的副作用
108172
speechState.isRecording = false
109173
}
174+
175+
handlerRef.value = null
110176
})
111177

112178
return {

packages/components/src/sender-actions/voice-button/webSpeechHandler.ts

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -88,6 +88,7 @@ export class WebSpeechHandler implements SpeechHandler {
8888

8989
this.recognition.onend = () => {
9090
callbacks.onEnd(this.finalizedTranscript || undefined)
91+
this.cleanup()
9192
this.resetSessionTranscript()
9293
}
9394

@@ -148,12 +149,11 @@ export class WebSpeechHandler implements SpeechHandler {
148149
stop(): void {
149150
if (!this.recognition) return
150151

151-
this.cleanup()
152-
this.resetSessionTranscript()
153-
154152
try {
155153
this.recognition.stop()
156154
} catch (error) {
155+
this.cleanup()
156+
this.resetSessionTranscript()
157157
console.warn('停止语音识别时发生错误:', error)
158158
}
159159
}

0 commit comments

Comments
 (0)