fix(#1196): 修复试听播放失败状态不一致 + 音频切换竞态 + 列表key统一为voice_id
CI/CD Pipeline / Build Staging API Image (pull_request) Has been skipped
CI/CD Pipeline / Build Staging Worker Image (pull_request) Has been skipped
CI/CD Pipeline / Build Staging Web Image (pull_request) Has been skipped
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (pull_request) Has been skipped
CI/CD Pipeline / Staging E2E Tests (pull_request) Has been skipped
CI/CD Pipeline / ACR Image Cleanup (pull_request) Has been skipped
CI/CD Pipeline / Staging API Integration Tests (pull_request) Has been skipped
CI/CD Pipeline / Check if frontend-only change (pull_request) Has been cancelled
CI/CD Pipeline / Validate - Code Quality (pull_request) Has been cancelled
CI/CD Pipeline / Validate - Type Check (mypy) (pull_request) Has been cancelled
CI/CD Pipeline / Validate - Migration (alembic) (pull_request) Has been cancelled
CI/CD Pipeline / Unit Tests (pull_request) Has been cancelled
CI/CD Pipeline / Integration Tests (pull_request) Has been cancelled
CI/CD Pipeline / Frontend Lint (pull_request) Has been cancelled
CI/CD Pipeline / Frontend Unit Tests (pull_request) Has been cancelled
CI/CD Pipeline / PR Build API Image (pull_request) Has been cancelled
CI/CD Pipeline / PR Build Web Image (pull_request) Has been cancelled
CI/CD Pipeline / PR Build Worker Image (pull_request) Has been cancelled
CI/CD Pipeline / Build Production API Image (pull_request) Has been cancelled
CI/CD Pipeline / Build Production Web Image (pull_request) Has been cancelled
CI/CD Pipeline / Build Production Worker Image (pull_request) Has been cancelled
CI/CD Pipeline / Deploy Production (pull_request) Has been cancelled
CI/CD Pipeline / Production Browser E2E (pull_request) Has been cancelled
CI/CD Pipeline / Canary Release to Production (pull_request) Has been cancelled
CI/CD Pipeline / CI Gate (pull_request) Has been cancelled
AI Code Review / AI Code Review (pull_request) Has been cancelled
PR Automation / Auto Approve on CI Green (pull_request) Has been cancelled
PR Automation / Auto Merge on CI Green + Approved (pull_request) Has been cancelled
Preview Deploy / Deploy Preview Environment (pull_request) Has been cancelled
CI/CD Pipeline / Build Staging API Image (pull_request) Has been skipped
CI/CD Pipeline / Build Staging Worker Image (pull_request) Has been skipped
CI/CD Pipeline / Build Staging Web Image (pull_request) Has been skipped
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (pull_request) Has been skipped
CI/CD Pipeline / Staging E2E Tests (pull_request) Has been skipped
CI/CD Pipeline / ACR Image Cleanup (pull_request) Has been skipped
CI/CD Pipeline / Staging API Integration Tests (pull_request) Has been skipped
CI/CD Pipeline / Check if frontend-only change (pull_request) Has been cancelled
CI/CD Pipeline / Validate - Code Quality (pull_request) Has been cancelled
CI/CD Pipeline / Validate - Type Check (mypy) (pull_request) Has been cancelled
CI/CD Pipeline / Validate - Migration (alembic) (pull_request) Has been cancelled
CI/CD Pipeline / Unit Tests (pull_request) Has been cancelled
CI/CD Pipeline / Integration Tests (pull_request) Has been cancelled
CI/CD Pipeline / Frontend Lint (pull_request) Has been cancelled
CI/CD Pipeline / Frontend Unit Tests (pull_request) Has been cancelled
CI/CD Pipeline / PR Build API Image (pull_request) Has been cancelled
CI/CD Pipeline / PR Build Web Image (pull_request) Has been cancelled
CI/CD Pipeline / PR Build Worker Image (pull_request) Has been cancelled
CI/CD Pipeline / Build Production API Image (pull_request) Has been cancelled
CI/CD Pipeline / Build Production Web Image (pull_request) Has been cancelled
CI/CD Pipeline / Build Production Worker Image (pull_request) Has been cancelled
CI/CD Pipeline / Deploy Production (pull_request) Has been cancelled
CI/CD Pipeline / Production Browser E2E (pull_request) Has been cancelled
CI/CD Pipeline / Canary Release to Production (pull_request) Has been cancelled
CI/CD Pipeline / CI Gate (pull_request) Has been cancelled
AI Code Review / AI Code Review (pull_request) Has been cancelled
PR Automation / Auto Approve on CI Green (pull_request) Has been cancelled
PR Automation / Auto Merge on CI Green + Approved (pull_request) Has been cancelled
Preview Deploy / Deploy Preview Environment (pull_request) Has been cancelled
This commit is contained in:
@@ -1,203 +1,227 @@
|
||||
import { useState, useCallback, useEffect, useRef, type ChangeEvent } from "react"
|
||||
import { message } from "antd"
|
||||
import type { TtsConfig, TtsMode } from "../../../types"
|
||||
import { DEFAULT_TTS_CONFIG } from "../../../types"
|
||||
import { previewTts } from "@/api/tts"
|
||||
import { fetchVoices, type UnifiedVoiceItem } from "@/api/voices"
|
||||
import {
|
||||
useState,
|
||||
useCallback,
|
||||
useEffect,
|
||||
useRef,
|
||||
type ChangeEvent,
|
||||
} from "react";
|
||||
import { message } from "antd";
|
||||
import type { TtsConfig, TtsMode } from "../../../types";
|
||||
import { DEFAULT_TTS_CONFIG } from "../../../types";
|
||||
import { previewTts } from "@/api/tts";
|
||||
import { fetchVoices, type UnifiedVoiceItem } from "@/api/voices";
|
||||
|
||||
interface UseTtsPanelOptions {
|
||||
open: boolean
|
||||
config: TtsConfig
|
||||
onChange: (config: TtsConfig) => void
|
||||
onClose: () => void
|
||||
open: boolean;
|
||||
config: TtsConfig;
|
||||
onChange: (config: TtsConfig) => void;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
export const useTtsPanel = ({ open, config, onChange, onClose }: UseTtsPanelOptions) => {
|
||||
export const useTtsPanel = ({
|
||||
open,
|
||||
config,
|
||||
onChange,
|
||||
onClose,
|
||||
}: UseTtsPanelOptions) => {
|
||||
/* ── 音色列表(统一音色:预设 + 克隆) ── */
|
||||
const [presetVoices, setPresetVoices] = useState<UnifiedVoiceItem[]>([])
|
||||
const [cloneVoices, setCloneVoices] = useState<UnifiedVoiceItem[]>([])
|
||||
const [voicesLoading, setVoicesLoading] = useState(false)
|
||||
const [presetVoices, setPresetVoices] = useState<UnifiedVoiceItem[]>([]);
|
||||
const [cloneVoices, setCloneVoices] = useState<UnifiedVoiceItem[]>([]);
|
||||
const [voicesLoading, setVoicesLoading] = useState(false);
|
||||
|
||||
/* ── 试听状态 ── */
|
||||
const [previewLoading, setPreviewLoading] = useState(false)
|
||||
const audioRef = useRef<HTMLAudioElement | null>(null)
|
||||
const mountedRef = useRef(true)
|
||||
const [previewLoading, setPreviewLoading] = useState(false);
|
||||
const audioRef = useRef<HTMLAudioElement | null>(null);
|
||||
const mountedRef = useRef(true);
|
||||
|
||||
/* ── 加载音色列表 + 重置挂载状态 ── */
|
||||
useEffect(() => {
|
||||
if (!open) return
|
||||
mountedRef.current = true
|
||||
let active = true
|
||||
setVoicesLoading(true)
|
||||
if (!open) return;
|
||||
mountedRef.current = true;
|
||||
let active = true;
|
||||
setVoicesLoading(true);
|
||||
fetchVoices({ status: "ready", limit: 100 })
|
||||
.then((res) => {
|
||||
if (!active) return
|
||||
const presets = res.items.filter((v) => v.type === "preset")
|
||||
const clones = res.items.filter((v) => v.type === "clone")
|
||||
setPresetVoices(presets)
|
||||
setCloneVoices(clones)
|
||||
if (!active) return;
|
||||
const presets = res.items.filter((v) => v.type === "preset");
|
||||
const clones = res.items.filter((v) => v.type === "clone");
|
||||
setPresetVoices(presets);
|
||||
setCloneVoices(clones);
|
||||
// 如果当前没有选中音色,默认选第一个预设
|
||||
if (!config.voice_id && presets.length > 0) {
|
||||
onChange({ ...config, voice_id: presets[0].voice_id })
|
||||
onChange({ ...config, voice_id: presets[0].voice_id });
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
if (active) message.error("加载音色列表失败")
|
||||
if (active) message.error("加载音色列表失败");
|
||||
})
|
||||
.finally(() => {
|
||||
if (active) setVoicesLoading(false)
|
||||
})
|
||||
if (active) setVoicesLoading(false);
|
||||
});
|
||||
return () => {
|
||||
active = false
|
||||
}
|
||||
active = false;
|
||||
};
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [open])
|
||||
}, [open]);
|
||||
|
||||
/* ── 组件卸载时清理音频资源 ── */
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
audioRef.current?.pause()
|
||||
audioRef.current = null
|
||||
mountedRef.current = false
|
||||
}
|
||||
}, [])
|
||||
audioRef.current?.pause();
|
||||
audioRef.current = null;
|
||||
mountedRef.current = false;
|
||||
};
|
||||
}, []);
|
||||
|
||||
/* ── 切换配音模式 ── */
|
||||
const handleModeChange = useCallback(
|
||||
(mode: TtsMode) => {
|
||||
onChange({ ...config, mode })
|
||||
onChange({ ...config, mode });
|
||||
},
|
||||
[config, onChange],
|
||||
)
|
||||
);
|
||||
|
||||
/* ── 文本输入 ── */
|
||||
const handleTextChange = useCallback(
|
||||
(e: ChangeEvent<HTMLTextAreaElement>) => {
|
||||
const text = e.target.value.slice(0, 5000)
|
||||
onChange({ ...config, text })
|
||||
const text = e.target.value.slice(0, 5000);
|
||||
onChange({ ...config, text });
|
||||
},
|
||||
[config, onChange],
|
||||
)
|
||||
);
|
||||
|
||||
/* ── 选择音色 ── */
|
||||
const handleVoiceSelect = useCallback(
|
||||
(voiceId: string) => {
|
||||
onChange({ ...config, voice_id: voiceId })
|
||||
onChange({ ...config, voice_id: voiceId });
|
||||
},
|
||||
[config, onChange],
|
||||
)
|
||||
);
|
||||
|
||||
/* ── 语速 ── */
|
||||
const handleSpeedChange = useCallback(
|
||||
(speed: number) => {
|
||||
onChange({ ...config, speed })
|
||||
onChange({ ...config, speed });
|
||||
},
|
||||
[config, onChange],
|
||||
)
|
||||
);
|
||||
|
||||
/* ── 语调 ── */
|
||||
const handlePitchChange = useCallback(
|
||||
(pitch: number) => {
|
||||
onChange({ ...config, pitch })
|
||||
onChange({ ...config, pitch });
|
||||
},
|
||||
[config, onChange],
|
||||
)
|
||||
);
|
||||
|
||||
/* ── 音量 ── */
|
||||
const handleVolumeChange = useCallback(
|
||||
(volume: number) => {
|
||||
onChange({ ...config, volume })
|
||||
onChange({ ...config, volume });
|
||||
},
|
||||
[config, onChange],
|
||||
)
|
||||
);
|
||||
|
||||
/* ── 字幕联动 ── */
|
||||
const handleSubtitleSyncToggle = useCallback(() => {
|
||||
onChange({ ...config, subtitle_sync: !config.subtitle_sync })
|
||||
}, [config, onChange])
|
||||
onChange({ ...config, subtitle_sync: !config.subtitle_sync });
|
||||
}, [config, onChange]);
|
||||
|
||||
/* ── 试听 ── */
|
||||
const handlePreview = useCallback(async () => {
|
||||
if (!config.text.trim()) {
|
||||
message.warning("请先输入合成文本")
|
||||
return
|
||||
message.warning("请先输入合成文本");
|
||||
return;
|
||||
}
|
||||
if (!config.voice_id) {
|
||||
message.warning("请先选择音色")
|
||||
return
|
||||
message.warning("请先选择音色");
|
||||
return;
|
||||
}
|
||||
setPreviewLoading(true)
|
||||
setPreviewLoading(true);
|
||||
try {
|
||||
const res = await previewTts({
|
||||
text: config.text.slice(0, 200),
|
||||
voice_id: config.voice_id,
|
||||
speed: config.speed,
|
||||
pitch: config.pitch,
|
||||
})
|
||||
if (!mountedRef.current) return
|
||||
audioRef.current?.pause()
|
||||
const audio = new Audio(res.audio_url)
|
||||
audioRef.current = audio
|
||||
});
|
||||
if (!mountedRef.current) return;
|
||||
audioRef.current?.pause();
|
||||
const audio = new Audio(res.audio_url);
|
||||
audioRef.current = audio;
|
||||
audio
|
||||
.play()
|
||||
.then(() => {
|
||||
if (mountedRef.current) message.success("试听播放中")
|
||||
if (mountedRef.current) message.success("试听播放中");
|
||||
})
|
||||
.catch(() => {
|
||||
if (mountedRef.current) message.error("播放失败")
|
||||
})
|
||||
if (mountedRef.current) message.error("播放失败");
|
||||
});
|
||||
audio.onended = () => {
|
||||
if (mountedRef.current) audioRef.current = null
|
||||
}
|
||||
if (mountedRef.current) audioRef.current = null;
|
||||
};
|
||||
} catch {
|
||||
if (mountedRef.current) message.error("试听生成失败")
|
||||
if (mountedRef.current) message.error("试听生成失败");
|
||||
} finally {
|
||||
if (mountedRef.current) setPreviewLoading(false)
|
||||
if (mountedRef.current) setPreviewLoading(false);
|
||||
}
|
||||
}, [config])
|
||||
}, [config]);
|
||||
|
||||
/* ── 单音色示例试听 ── */
|
||||
const [playingVoiceId, setPlayingVoiceId] = useState<string | null>(null)
|
||||
const [playingVoiceId, setPlayingVoiceId] = useState<string | null>(null);
|
||||
const handlePlayVoiceSample = useCallback(
|
||||
(voiceId: string, previewUrl: string | null) => {
|
||||
if (playingVoiceId === voiceId) {
|
||||
audioRef.current?.pause()
|
||||
audioRef.current = null
|
||||
setPlayingVoiceId(null)
|
||||
return
|
||||
audioRef.current?.pause();
|
||||
audioRef.current = null;
|
||||
setPlayingVoiceId(null);
|
||||
return;
|
||||
}
|
||||
// 暂停上一个音频并清除事件监听,避免竞态
|
||||
if (audioRef.current) {
|
||||
audioRef.current.onended = null;
|
||||
audioRef.current.pause();
|
||||
}
|
||||
audioRef.current?.pause()
|
||||
if (!previewUrl) {
|
||||
message.warning("该音色暂无示例音频")
|
||||
return
|
||||
message.warning("该音色暂无示例音频");
|
||||
return;
|
||||
}
|
||||
const audio = new Audio(previewUrl)
|
||||
audioRef.current = audio
|
||||
audio.play().catch(() => {
|
||||
message.error("播放失败")
|
||||
})
|
||||
const audio = new Audio(previewUrl);
|
||||
audioRef.current = audio;
|
||||
audio
|
||||
.play()
|
||||
.then(() => {
|
||||
if (mountedRef.current) setPlayingVoiceId(voiceId);
|
||||
})
|
||||
.catch(() => {
|
||||
if (mountedRef.current) {
|
||||
message.error("播放失败");
|
||||
audioRef.current = null;
|
||||
}
|
||||
});
|
||||
audio.onended = () => {
|
||||
setPlayingVoiceId(null)
|
||||
audioRef.current = null
|
||||
}
|
||||
setPlayingVoiceId(voiceId)
|
||||
if (mountedRef.current) {
|
||||
setPlayingVoiceId(null);
|
||||
audioRef.current = null;
|
||||
}
|
||||
};
|
||||
},
|
||||
[playingVoiceId],
|
||||
)
|
||||
);
|
||||
|
||||
/* ── 重置 ── */
|
||||
const handleReset = useCallback(() => {
|
||||
onChange({ ...DEFAULT_TTS_CONFIG })
|
||||
}, [onChange])
|
||||
onChange({ ...DEFAULT_TTS_CONFIG });
|
||||
}, [onChange]);
|
||||
|
||||
/* ── 关闭时停止音频 ── */
|
||||
const handleClose = useCallback(() => {
|
||||
mountedRef.current = false
|
||||
audioRef.current?.pause()
|
||||
audioRef.current = null
|
||||
setPlayingVoiceId(null)
|
||||
onClose()
|
||||
}, [onClose])
|
||||
mountedRef.current = false;
|
||||
audioRef.current?.pause();
|
||||
audioRef.current = null;
|
||||
setPlayingVoiceId(null);
|
||||
onClose();
|
||||
}, [onClose]);
|
||||
|
||||
return {
|
||||
presetVoices,
|
||||
@@ -216,5 +240,5 @@ export const useTtsPanel = ({ open, config, onChange, onClose }: UseTtsPanelOpti
|
||||
handlePreview,
|
||||
handleReset,
|
||||
handleClose,
|
||||
}
|
||||
}
|
||||
};
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user