228 lines
6.0 KiB
TypeScript
228 lines
6.0 KiB
TypeScript
/**
|
||
* @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<string>('')
|
||
const speechRate = ref<number>(1.0)
|
||
const selectedVoiceMeta = ref<VoiceMeta | null>(null)
|
||
const audioState = ref<AudioState>({
|
||
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<void> => {
|
||
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<number> => {
|
||
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
|
||
}
|