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

228 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,
AudioData,
} from '../types/identify-face'
2025-12-28 13:49:45 +08:00
// @ts-ignore
import { VoiceService } from '@/api/voice'
2026-01-27 01:39:08 +08:00
import { DEFAULT_VOICE_PROVIDER } from '@/config/voiceConfig'
/**
* Hook
2025-12-28 13:49:45 +08:00
*
*/
2025-12-28 13:49:45 +08:00
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
2025-12-28 13:49:45 +08:00
const hasVideo = true // 语音生成不依赖视频状态
return !!(hasText && hasVoice && hasVideo && !audioState.value.generating)
})
/**
2025-12-28 13:49:45 +08:00
*
*/
const suggestedMaxChars = computed(() => {
2025-12-28 13:49:45 +08:00
// 默认为 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,
2026-01-27 01:39:08 +08:00
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)
}
})
}
/**
2025-12-28 13:49:45 +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) {
audioState.value.validationPassed = false
return false
}
2025-12-28 13:49:45 +08:00
const faceDurationMs = faceEndTime - faceStartTime
const audioDuration = audioState.value.durationMs
2025-12-28 13:49:45 +08:00
const overlapStart = faceStartTime
const overlapEnd = Math.min(faceEndTime, faceStartTime + audioDuration)
const overlapDuration = Math.max(0, overlapEnd - overlapStart)
2025-12-28 13:49:45 +08:00
const isValid = overlapDuration >= minOverlapMs
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)}`
)
} 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
}