Files
sionrui/frontend/app/web-gold/src/views/kling/hooks/useVoiceGeneration.ts

229 lines
6.0 KiB
TypeScript
Raw Normal View History

/**
* @fileoverview useVoiceGeneration Hook -
* @author Claude Code
*/
import { ref, computed } from 'vue'
import { message } from 'ant-design-vue'
import type {
UseVoiceGeneration,
AudioState,
VoiceMeta,
IdentifyState,
AudioData,
} from '../types/identify-face'
import { VoiceService } from '@/api/voice'
/**
* Hook
* @param identifyState Hook
* @param faceDuration
*/
export function useVoiceGeneration(
identifyState: IdentifyState,
faceDuration: number
): 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 = identifyState.identified
return !!(hasText && hasVoice && hasVideo && !audioState.value.generating)
})
/**
*
*/
const suggestedMaxChars = computed(() => {
const durationSec = faceDuration / 1000
const adjustedRate = speechRate.value || 1.0
return Math.floor(durationSec * 3.5 * adjustedRate)
})
// ==================== 核心方法 ====================
/**
*
*/
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,
}
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 = (): boolean => {
if (!identifyState.identified || faceDuration <= 0) {
audioState.value.validationPassed = false
return false
}
const faceStart = identifyState.faceStartTime
const faceEnd = identifyState.faceEndTime
const faceDurationMs = faceEnd - faceStart
const audioDuration = audioState.value.durationMs
const overlapStart = faceStart
const overlapEnd = Math.min(faceEnd, faceStart + audioDuration)
const overlapDuration = Math.max(0, overlapEnd - overlapStart)
const isValid = overlapDuration >= 2000
audioState.value.validationPassed = isValid
if (!isValid) {
const overlapSec = (overlapDuration / 1000).toFixed(1)
message.warning(
`音频时长(${(audioDuration/1000).toFixed(1)}秒)与人脸区间(${(faceDurationMs/1000).toFixed(1)}秒)不匹配,重合部分仅${overlapSec}至少需要2秒`
)
} 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
}