2026-05-02 13:26:57 +08:00
|
|
|
import { ref, computed, onUnmounted } from 'vue'
|
|
|
|
|
|
|
|
|
|
export interface SpeechOptions {
|
|
|
|
|
rate?: number // 语速 0.1-10,默认 1
|
|
|
|
|
pitch?: number // 音调 0-2,默认 1
|
|
|
|
|
volume?: number // 音量 0-1,默认 1
|
|
|
|
|
voice?: SpeechSynthesisVoice | null
|
|
|
|
|
lang?: string // 语言 'zh-CN', 'en-US' 等
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export interface SpeechState {
|
|
|
|
|
isPlaying: boolean
|
|
|
|
|
isPaused: boolean
|
|
|
|
|
currentMessageId: string | null
|
|
|
|
|
progress: number // 当前进度(字符数)
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-07 10:34:58 +08:00
|
|
|
interface SpeechQueueItem {
|
|
|
|
|
messageId: string
|
|
|
|
|
content: string
|
|
|
|
|
options: SpeechOptions
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-02 13:26:57 +08:00
|
|
|
/**
|
|
|
|
|
* Web Speech API 语音播放 Composable
|
|
|
|
|
*/
|
|
|
|
|
export function useSpeech() {
|
|
|
|
|
const synth = window.speechSynthesis
|
|
|
|
|
const availableVoices = ref<SpeechSynthesisVoice[]>([])
|
|
|
|
|
const state = ref<SpeechState>({
|
|
|
|
|
isPlaying: false,
|
|
|
|
|
isPaused: false,
|
|
|
|
|
currentMessageId: null,
|
|
|
|
|
progress: 0,
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
let utterance: SpeechSynthesisUtterance | null = null
|
2026-05-07 10:34:58 +08:00
|
|
|
let playbackToken = 0
|
|
|
|
|
const speechQueue: SpeechQueueItem[] = []
|
2026-05-02 13:26:57 +08:00
|
|
|
|
|
|
|
|
// 加载可用语音列表
|
|
|
|
|
function loadVoices() {
|
|
|
|
|
availableVoices.value = synth.getVoices()
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// 浏览器会在语音列表变化时触发 voiceschanged 事件
|
|
|
|
|
synth.addEventListener('voiceschanged', loadVoices)
|
|
|
|
|
loadVoices() // 初始加载
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* 从文本中提取纯文本内容,过滤代码块、thinking 标签等
|
|
|
|
|
*/
|
|
|
|
|
function extractReadableText(content: string): string {
|
|
|
|
|
if (!content) return ''
|
|
|
|
|
|
|
|
|
|
let text = content
|
|
|
|
|
|
|
|
|
|
// 移除 thinking 标签内容
|
|
|
|
|
text = text.replace(/<thinking[^>]*>[\s\S]*?<\/thinking>/gi, '')
|
|
|
|
|
text = text.replace(/<thinking[^>]*>[\s\S]*/gi, '')
|
|
|
|
|
|
|
|
|
|
// 移除代码块
|
|
|
|
|
text = text.replace(/```[\s\S]*?```/g, '')
|
|
|
|
|
text = text.replace(/`[^`]+`/g, '')
|
|
|
|
|
|
|
|
|
|
// 移除 HTML 标签
|
|
|
|
|
text = text.replace(/<[^>]+>/g, '')
|
|
|
|
|
|
2026-05-03 22:10:40 +08:00
|
|
|
// 只保留:字母、数字、空格、常用标点、中文
|
|
|
|
|
// 保留的标点:。!?;,,。!?;:、""''()【】《》
|
|
|
|
|
// 移除:*# 等特殊符号、表情符号、emoji 等
|
|
|
|
|
text = text.replace(/[^\p{L}\p{N}\s。!?;,,。!?;:、""''()【】《》\n一-鿿㐀-䶿]/gu, '')
|
|
|
|
|
|
2026-05-02 13:26:57 +08:00
|
|
|
// 移除多余的空白
|
|
|
|
|
text = text.replace(/\s+/g, ' ').trim()
|
|
|
|
|
|
|
|
|
|
return text
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* 检查浏览器是否支持 Web Speech API
|
|
|
|
|
*/
|
|
|
|
|
const isSupported = computed(() => {
|
|
|
|
|
return 'speechSynthesis' in window && 'SpeechSynthesisUtterance' in window
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* 获取默认语音(优先选择中文)
|
|
|
|
|
*/
|
|
|
|
|
function getDefaultVoice(): SpeechSynthesisVoice | null {
|
|
|
|
|
const voices = availableVoices.value
|
|
|
|
|
if (voices.length === 0) return null
|
|
|
|
|
|
|
|
|
|
// 优先选择中文语音
|
|
|
|
|
const zhVoice = voices.find(v => v.lang.startsWith('zh'))
|
|
|
|
|
if (zhVoice) return zhVoice
|
|
|
|
|
|
|
|
|
|
// 其次选择英文语音
|
|
|
|
|
const enVoice = voices.find(v => v.lang.startsWith('en'))
|
|
|
|
|
if (enVoice) return enVoice
|
|
|
|
|
|
|
|
|
|
// 默认第一个
|
|
|
|
|
return voices[0]
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* 获取所有可用语音(用于调试)
|
|
|
|
|
*/
|
|
|
|
|
function getAllVoices(): SpeechSynthesisVoice[] {
|
|
|
|
|
return availableVoices.value
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* 停止当前播放
|
|
|
|
|
*/
|
2026-05-07 10:34:58 +08:00
|
|
|
function stop(clearQueue = true) {
|
|
|
|
|
playbackToken += 1
|
|
|
|
|
if (clearQueue) {
|
|
|
|
|
speechQueue.length = 0
|
|
|
|
|
}
|
|
|
|
|
if (synth.speaking || synth.pending || synth.paused) {
|
2026-05-02 13:26:57 +08:00
|
|
|
synth.cancel()
|
|
|
|
|
}
|
|
|
|
|
if (utterance) {
|
|
|
|
|
utterance = null
|
|
|
|
|
}
|
|
|
|
|
state.value = {
|
|
|
|
|
isPlaying: false,
|
|
|
|
|
isPaused: false,
|
|
|
|
|
currentMessageId: null,
|
|
|
|
|
progress: 0,
|
|
|
|
|
}
|
2026-05-07 10:34:58 +08:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function speak(messageId: string, text: string, options: SpeechOptions = {}) {
|
|
|
|
|
const token = ++playbackToken
|
|
|
|
|
|
|
|
|
|
utterance = new SpeechSynthesisUtterance(text)
|
|
|
|
|
const activeUtterance = utterance
|
|
|
|
|
const activeText = text
|
|
|
|
|
|
|
|
|
|
// 设置语音参数
|
|
|
|
|
utterance.rate = options.rate ?? 1
|
|
|
|
|
utterance.pitch = options.pitch ?? 1
|
|
|
|
|
utterance.volume = options.volume ?? 1
|
|
|
|
|
utterance.voice = options.voice ?? getDefaultVoice()
|
|
|
|
|
|
|
|
|
|
console.log('[useSpeech] Selected voice:', utterance.voice?.name, utterance.voice?.lang)
|
|
|
|
|
|
|
|
|
|
if (options.lang) {
|
|
|
|
|
utterance.lang = options.lang
|
|
|
|
|
} else if (utterance.voice) {
|
|
|
|
|
utterance.lang = utterance.voice.lang
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// 事件监听
|
|
|
|
|
utterance.onstart = () => {
|
|
|
|
|
if (token !== playbackToken || utterance !== activeUtterance) return
|
|
|
|
|
console.log('[useSpeech] onstart fired')
|
|
|
|
|
state.value.isPlaying = true
|
|
|
|
|
state.value.isPaused = false
|
|
|
|
|
state.value.currentMessageId = messageId
|
|
|
|
|
state.value.progress = 0
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
utterance.onboundary = (event) => {
|
|
|
|
|
if (token !== playbackToken || utterance !== activeUtterance) return
|
|
|
|
|
if (event.name === 'word') {
|
|
|
|
|
state.value.progress = event.charIndex
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
utterance.onend = () => {
|
|
|
|
|
if (token !== playbackToken || utterance !== activeUtterance) return
|
|
|
|
|
console.log('[useSpeech] onend fired')
|
|
|
|
|
state.value.isPlaying = false
|
|
|
|
|
state.value.isPaused = false
|
|
|
|
|
state.value.currentMessageId = null
|
|
|
|
|
state.value.progress = activeText.length
|
|
|
|
|
utterance = null
|
|
|
|
|
if (speechQueue.length > 0) {
|
|
|
|
|
window.setTimeout(playNextQueuedSpeech, 0)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
utterance.onerror = (event) => {
|
|
|
|
|
if (token !== playbackToken || utterance !== activeUtterance) return
|
|
|
|
|
console.error('[useSpeech] Speech synthesis error:', event.error)
|
|
|
|
|
state.value.isPlaying = false
|
|
|
|
|
state.value.isPaused = false
|
|
|
|
|
state.value.currentMessageId = null
|
|
|
|
|
utterance = null
|
|
|
|
|
if (speechQueue.length > 0) {
|
|
|
|
|
window.setTimeout(playNextQueuedSpeech, 0)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// 开始播放
|
|
|
|
|
console.log('[useSpeech] Calling synth.speak()')
|
|
|
|
|
synth.speak(utterance)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function playNextQueuedSpeech() {
|
|
|
|
|
if (state.value.isPlaying || state.value.isPaused || synth.speaking || synth.pending) return
|
|
|
|
|
const next = speechQueue.shift()
|
|
|
|
|
if (!next) return
|
|
|
|
|
|
|
|
|
|
const text = extractReadableText(next.content)
|
|
|
|
|
if (!text) {
|
|
|
|
|
window.setTimeout(playNextQueuedSpeech, 0)
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
console.log('[useSpeech] Playing queued text:', text.substring(0, 50) + '...')
|
|
|
|
|
speak(next.messageId, text, next.options)
|
2026-05-02 13:26:57 +08:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* 播放文本
|
|
|
|
|
*/
|
|
|
|
|
function play(messageId: string, content: string, options: SpeechOptions = {}) {
|
|
|
|
|
if (!isSupported.value) {
|
|
|
|
|
console.warn('[useSpeech] Speech synthesis not supported')
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
console.log('[useSpeech] play called:', messageId)
|
|
|
|
|
|
|
|
|
|
// 如果正在播放其他消息,先停止
|
|
|
|
|
if (state.value.currentMessageId && state.value.currentMessageId !== messageId) {
|
|
|
|
|
stop()
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// 如果已经在播放这条消息,暂停/恢复
|
|
|
|
|
if (state.value.currentMessageId === messageId) {
|
|
|
|
|
if (state.value.isPaused) {
|
|
|
|
|
resume()
|
|
|
|
|
} else if (state.value.isPlaying) {
|
|
|
|
|
pause()
|
|
|
|
|
}
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// 提取可读文本
|
|
|
|
|
const text = extractReadableText(content)
|
|
|
|
|
if (!text) {
|
|
|
|
|
console.warn('[useSpeech] No readable text found')
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
console.log('[useSpeech] Playing text:', text.substring(0, 50) + '...')
|
|
|
|
|
|
|
|
|
|
// 停止当前播放
|
|
|
|
|
stop()
|
2026-05-07 10:34:58 +08:00
|
|
|
speak(messageId, text, options)
|
|
|
|
|
}
|
2026-05-02 13:26:57 +08:00
|
|
|
|
2026-05-07 10:34:58 +08:00
|
|
|
/**
|
|
|
|
|
* 自动播放入队:不打断当前语音,按完成顺序依次播放。
|
|
|
|
|
*/
|
|
|
|
|
function enqueue(messageId: string, content: string, options: SpeechOptions = {}) {
|
|
|
|
|
if (!isSupported.value) {
|
|
|
|
|
console.warn('[useSpeech] Speech synthesis not supported')
|
|
|
|
|
return
|
2026-05-02 13:26:57 +08:00
|
|
|
}
|
2026-05-07 10:34:58 +08:00
|
|
|
if (!extractReadableText(content)) {
|
|
|
|
|
console.warn('[useSpeech] No readable text found')
|
|
|
|
|
return
|
2026-05-02 13:26:57 +08:00
|
|
|
}
|
2026-05-07 10:34:58 +08:00
|
|
|
speechQueue.push({ messageId, content, options })
|
|
|
|
|
playNextQueuedSpeech()
|
2026-05-02 13:26:57 +08:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* 暂停播放
|
|
|
|
|
*/
|
|
|
|
|
function pause() {
|
|
|
|
|
if (synth.speaking && !state.value.isPaused) {
|
|
|
|
|
synth.pause()
|
|
|
|
|
state.value.isPaused = true
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* 恢复播放
|
|
|
|
|
*/
|
|
|
|
|
function resume() {
|
|
|
|
|
if (state.value.isPaused) {
|
|
|
|
|
synth.resume()
|
|
|
|
|
state.value.isPaused = false
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* 切换播放/暂停
|
|
|
|
|
*/
|
|
|
|
|
function toggle(messageId: string, content: string, options: SpeechOptions = {}) {
|
|
|
|
|
if (state.value.currentMessageId === messageId && state.value.isPlaying) {
|
|
|
|
|
if (state.value.isPaused) {
|
|
|
|
|
resume()
|
|
|
|
|
} else {
|
|
|
|
|
pause()
|
|
|
|
|
}
|
|
|
|
|
} else {
|
|
|
|
|
play(messageId, content, options)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// 清理
|
|
|
|
|
onUnmounted(() => {
|
|
|
|
|
stop()
|
|
|
|
|
synth.removeEventListener('voiceschanged', loadVoices)
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
return {
|
|
|
|
|
// 状态
|
|
|
|
|
isSupported,
|
|
|
|
|
availableVoices,
|
|
|
|
|
isPlaying: computed(() => state.value.isPlaying),
|
|
|
|
|
isPaused: computed(() => state.value.isPaused),
|
|
|
|
|
currentMessageId: computed(() => state.value.currentMessageId),
|
|
|
|
|
progress: computed(() => state.value.progress),
|
|
|
|
|
|
|
|
|
|
// 方法
|
|
|
|
|
play,
|
|
|
|
|
pause,
|
|
|
|
|
resume,
|
|
|
|
|
stop,
|
|
|
|
|
toggle,
|
2026-05-07 10:34:58 +08:00
|
|
|
enqueue,
|
2026-05-02 13:26:57 +08:00
|
|
|
getDefaultVoice,
|
|
|
|
|
getAllVoices,
|
|
|
|
|
extractReadableText,
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// 单例模式,全局共享一个语音实例
|
|
|
|
|
let globalSpeech: ReturnType<typeof useSpeech> | null = null
|
|
|
|
|
|
|
|
|
|
export function useGlobalSpeech() {
|
|
|
|
|
if (!globalSpeech) {
|
|
|
|
|
globalSpeech = useSpeech()
|
|
|
|
|
}
|
|
|
|
|
return globalSpeech
|
|
|
|
|
}
|