2025-12-28 00:19:17 +08:00
|
|
|
|
/**
|
|
|
|
|
|
* @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'
|
2025-12-28 13:49:45 +08:00
|
|
|
|
// @ts-ignore
|
2025-12-28 00:19:17 +08:00
|
|
|
|
import { VoiceService } from '@/api/voice'
|
2026-01-27 01:39:08 +08:00
|
|
|
|
import { DEFAULT_VOICE_PROVIDER } from '@/config/voiceConfig'
|
2025-12-28 00:19:17 +08:00
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
|
* 语音生成 Hook
|
2025-12-28 13:49:45 +08:00
|
|
|
|
* 独立管理所有状态,不依赖外部状态
|
2025-12-28 00:19:17 +08:00
|
|
|
|
*/
|
2025-12-28 13:49:45 +08:00
|
|
|
|
export function useVoiceGeneration(): UseVoiceGeneration {
|
2025-12-28 00:19:17 +08:00
|
|
|
|
// ==================== 响应式状态 ====================
|
|
|
|
|
|
|
|
|
|
|
|
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
|
2025-12-28 13:49:45 +08:00
|
|
|
|
const hasVideo = true // 语音生成不依赖视频状态
|
2025-12-28 00:19:17 +08:00
|
|
|
|
return !!(hasText && hasVoice && hasVideo && !audioState.value.generating)
|
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
|
|
/**
|
2025-12-28 13:49:45 +08:00
|
|
|
|
* 建议的最大字符数(需要从外部传入)
|
2025-12-28 00:19:17 +08:00
|
|
|
|
*/
|
|
|
|
|
|
const suggestedMaxChars = computed(() => {
|
2025-12-28 13:49:45 +08:00
|
|
|
|
// 默认为 4000,需要从外部设置
|
|
|
|
|
|
return 4000
|
2025-12-28 00:19:17 +08:00
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
|
|
// ==================== 核心方法 ====================
|
|
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
|
* 生成配音
|
|
|
|
|
|
*/
|
|
|
|
|
|
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,
|
2026-01-27 01:39:08 +08:00
|
|
|
|
providerType: DEFAULT_VOICE_PROVIDER,
|
2025-12-28 00:19:17 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
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)
|
|
|
|
|
|
}
|
|
|
|
|
|
})
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/**
|
2025-12-28 13:49:45 +08:00
|
|
|
|
* 验证音频与人脸区间的重合时长(外部调用时传入校验参数)
|
2025-12-28 00:19:17 +08:00
|
|
|
|
*/
|
2025-12-28 13:49:45 +08:00
|
|
|
|
const validateAudioDuration = (
|
|
|
|
|
|
faceStartTime: number = 0,
|
|
|
|
|
|
faceEndTime: number = 0,
|
|
|
|
|
|
minOverlapMs: number = 2000
|
|
|
|
|
|
): boolean => {
|
|
|
|
|
|
if (faceStartTime <= 0 || faceEndTime <= 0) {
|
2025-12-28 00:19:17 +08:00
|
|
|
|
audioState.value.validationPassed = false
|
|
|
|
|
|
return false
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2025-12-28 13:49:45 +08:00
|
|
|
|
const faceDurationMs = faceEndTime - faceStartTime
|
2025-12-28 00:19:17 +08:00
|
|
|
|
const audioDuration = audioState.value.durationMs
|
|
|
|
|
|
|
2025-12-28 13:49:45 +08:00
|
|
|
|
const overlapStart = faceStartTime
|
|
|
|
|
|
const overlapEnd = Math.min(faceEndTime, faceStartTime + audioDuration)
|
2025-12-28 00:19:17 +08:00
|
|
|
|
const overlapDuration = Math.max(0, overlapEnd - overlapStart)
|
|
|
|
|
|
|
2025-12-28 13:49:45 +08:00
|
|
|
|
const isValid = overlapDuration >= minOverlapMs
|
2025-12-28 00:19:17 +08:00
|
|
|
|
|
|
|
|
|
|
audioState.value.validationPassed = isValid
|
|
|
|
|
|
|
|
|
|
|
|
if (!isValid) {
|
|
|
|
|
|
const overlapSec = (overlapDuration / 1000).toFixed(1)
|
|
|
|
|
|
message.warning(
|
2025-12-28 13:49:45 +08:00
|
|
|
|
`音频时长(${(audioDuration/1000).toFixed(1)}秒)与人脸区间(${(faceDurationMs/1000).toFixed(1)}秒)不匹配,重合部分仅${overlapSec}秒,至少需要${(minOverlapMs/1000)}秒`
|
2025-12-28 00:19:17 +08:00
|
|
|
|
)
|
|
|
|
|
|
} 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
|
|
|
|
|
|
}
|