/** * @fileoverview useVoiceGeneration Hook - 语音生成逻辑封装 * @author Claude Code */ import { ref, computed } from 'vue' import { message } from 'ant-design-vue' import type { UseVoiceGeneration, AudioState, VoiceMeta, AudioData, } from '../types/identify-face' // @ts-ignore import { VoiceService } from '@/api/voice' import { DEFAULT_VOICE_PROVIDER } from '@/config/voiceConfig' /** * 语音生成 Hook * 独立管理所有状态,不依赖外部状态 */ export function useVoiceGeneration(): UseVoiceGeneration { // ==================== 响应式状态 ==================== const ttsText = ref('') const speechRate = ref(1.0) const selectedVoiceMeta = ref(null) const audioState = ref({ generated: null, durationMs: 0, validationPassed: false, generating: false, }) // ==================== 计算属性 ==================== /** * 是否可以生成配音 */ const canGenerateAudio = computed(() => { const hasText = ttsText.value.trim() const hasVoice = selectedVoiceMeta.value const hasVideo = true // 语音生成不依赖视频状态 return !!(hasText && hasVoice && hasVideo && !audioState.value.generating) }) /** * 建议的最大字符数(需要从外部传入) */ const suggestedMaxChars = computed(() => { // 默认为 4000,需要从外部设置 return 4000 }) // ==================== 核心方法 ==================== /** * 生成配音 */ const generateAudio = async (): Promise => { const voice = selectedVoiceMeta.value if (!voice) { message.warning('请选择音色') return } if (!ttsText.value.trim()) { message.warning('请输入文案内容') return } audioState.value.generating = true try { const params = { inputText: ttsText.value, voiceConfigId: voice.rawId || extractIdFromString(voice.id), speechRate: speechRate.value || 1.0, audioFormat: 'mp3' as const, providerType: DEFAULT_VOICE_PROVIDER, } const res = await VoiceService.synthesize(params) if (res.code === 0) { const audioData = res.data as AudioData if (!audioData.audioBase64) { throw new Error('未收到音频数据,无法进行时长解析') } audioState.value.generated = audioData try { // 解析音频时长 audioState.value.durationMs = await parseAudioDuration(audioData.audioBase64) // 验证音频时长 validateAudioDuration() message.success('配音生成成功!') } catch (error) { message.error('音频解析失败,请重新生成配音') audioState.value.durationMs = 0 audioState.value.generated = null audioState.value.validationPassed = false } } else { throw new Error(res.msg || '配音生成失败') } } catch (error: any) { message.error(error.message || '配音生成失败') } finally { audioState.value.generating = false } } /** * 解析音频时长 */ const parseAudioDuration = async (base64Data: string): Promise => { return new Promise((resolve, reject) => { try { const base64 = base64Data.includes(',') ? base64Data.split(',')[1] : base64Data const binaryString = window.atob(base64) const bytes = new Uint8Array(binaryString.length) for (let i = 0; i < binaryString.length; i++) { bytes[i] = binaryString.charCodeAt(i) } const blob = new Blob([bytes], { type: 'audio/mp3' }) const audio = new Audio() const objectUrl = URL.createObjectURL(blob) audio.addEventListener('loadedmetadata', () => { URL.revokeObjectURL(objectUrl) const durationMs = Math.round(audio.duration * 1000) resolve(durationMs) }) audio.addEventListener('error', (error) => { URL.revokeObjectURL(objectUrl) reject(error) }) audio.src = objectUrl audio.load() } catch (error) { reject(error) } }) } /** * 验证音频与人脸区间的重合时长(外部调用时传入校验参数) */ const validateAudioDuration = ( faceStartTime: number = 0, faceEndTime: number = 0, minOverlapMs: number = 2000 ): boolean => { if (faceStartTime <= 0 || faceEndTime <= 0) { audioState.value.validationPassed = false return false } const faceDurationMs = faceEndTime - faceStartTime const audioDuration = audioState.value.durationMs const overlapStart = faceStartTime const overlapEnd = Math.min(faceEndTime, faceStartTime + audioDuration) const overlapDuration = Math.max(0, overlapEnd - overlapStart) const isValid = overlapDuration >= minOverlapMs audioState.value.validationPassed = isValid if (!isValid) { const overlapSec = (overlapDuration / 1000).toFixed(1) message.warning( `音频时长(${(audioDuration/1000).toFixed(1)}秒)与人脸区间(${(faceDurationMs/1000).toFixed(1)}秒)不匹配,重合部分仅${overlapSec}秒,至少需要${(minOverlapMs/1000)}秒` ) } else { message.success('时长校验通过!') } return isValid } /** * 重置音频状态 */ const resetAudioState = (): void => { audioState.value.generated = null audioState.value.durationMs = 0 audioState.value.validationPassed = false audioState.value.generating = false } return { // 响应式状态 ttsText, speechRate, selectedVoiceMeta, audioState, // 计算属性 canGenerateAudio, suggestedMaxChars, // 方法 generateAudio, parseAudioDuration, validateAudioDuration, resetAudioState, } } /** * 从字符串中提取ID */ function extractIdFromString(str: string): string { // 尝试从各种格式中提取ID const match = str.match(/[\w-]+$/) return match ? match[0] : str }