296 lines
11 KiB
TypeScript
296 lines
11 KiB
TypeScript
import { useEffect, useState } from "react";
|
||
import type { MouseEvent } from "react";
|
||
import { Upload, RefreshCw, Play, Pause, Pencil, Trash2, Check, X, Mic, Square, RotateCw } from "lucide-react";
|
||
|
||
interface RefAudio {
|
||
id: string;
|
||
name: string;
|
||
path: string;
|
||
ref_text: string;
|
||
duration_sec: number;
|
||
created_at: number;
|
||
}
|
||
|
||
interface RefAudioPanelProps {
|
||
refAudios: RefAudio[];
|
||
selectedRefAudio: RefAudio | null;
|
||
onSelectRefAudio: (audio: RefAudio) => void;
|
||
isUploadingRef: boolean;
|
||
uploadRefError: string | null;
|
||
onClearUploadRefError: () => void;
|
||
onUploadRefAudio: (file: File) => void;
|
||
onFetchRefAudios: () => void;
|
||
playingAudioId: string | null;
|
||
onTogglePlayPreview: (audio: RefAudio, event: MouseEvent) => void;
|
||
editingAudioId: string | null;
|
||
editName: string;
|
||
onEditNameChange: (value: string) => void;
|
||
onStartEditing: (audio: RefAudio, event: MouseEvent) => void;
|
||
onSaveEditing: (id: string, event: MouseEvent) => void;
|
||
onCancelEditing: (event: MouseEvent) => void;
|
||
onDeleteRefAudio: (id: string) => void;
|
||
onRetranscribe: (id: string) => void;
|
||
retranscribingId: string | null;
|
||
recordedBlob: Blob | null;
|
||
isRecording: boolean;
|
||
recordingTime: number;
|
||
onStartRecording: () => void;
|
||
onStopRecording: () => void;
|
||
onUseRecording: () => void;
|
||
formatRecordingTime: (seconds: number) => string;
|
||
}
|
||
|
||
const OLD_FIXED_REF_TEXT = "其实生活中有许多美好的瞬间";
|
||
|
||
export function RefAudioPanel({
|
||
refAudios,
|
||
selectedRefAudio,
|
||
onSelectRefAudio,
|
||
isUploadingRef,
|
||
uploadRefError,
|
||
onClearUploadRefError,
|
||
onUploadRefAudio,
|
||
onFetchRefAudios,
|
||
playingAudioId,
|
||
onTogglePlayPreview,
|
||
editingAudioId,
|
||
editName,
|
||
onEditNameChange,
|
||
onStartEditing,
|
||
onSaveEditing,
|
||
onCancelEditing,
|
||
onDeleteRefAudio,
|
||
onRetranscribe,
|
||
retranscribingId,
|
||
recordedBlob,
|
||
isRecording,
|
||
recordingTime,
|
||
onStartRecording,
|
||
onStopRecording,
|
||
onUseRecording,
|
||
formatRecordingTime,
|
||
}: RefAudioPanelProps) {
|
||
const [recordedUrl, setRecordedUrl] = useState<string | null>(null);
|
||
|
||
useEffect(() => {
|
||
if (!recordedBlob) {
|
||
// eslint-disable-next-line react-hooks/set-state-in-effect
|
||
setRecordedUrl(null);
|
||
return;
|
||
}
|
||
const url = URL.createObjectURL(recordedBlob);
|
||
setRecordedUrl(url);
|
||
return () => {
|
||
URL.revokeObjectURL(url);
|
||
};
|
||
}, [recordedBlob]);
|
||
|
||
const needsRetranscribe = (audio: RefAudio) =>
|
||
audio.ref_text.startsWith(OLD_FIXED_REF_TEXT);
|
||
|
||
return (
|
||
<div className="space-y-4">
|
||
<div>
|
||
<div className="flex justify-between items-center mb-2">
|
||
<span className="text-sm text-gray-300">📁 我的参考音频</span>
|
||
<div className="flex gap-2">
|
||
<input
|
||
type="file"
|
||
id="ref-audio-upload"
|
||
accept=".wav,.mp3,.m4a,.webm,.ogg,.flac,.aac"
|
||
onChange={(e) => {
|
||
const file = e.target.files?.[0];
|
||
if (file) {
|
||
onUploadRefAudio(file);
|
||
}
|
||
e.target.value = '';
|
||
}}
|
||
className="hidden"
|
||
/>
|
||
<label
|
||
htmlFor="ref-audio-upload"
|
||
className={`px-2 py-1 text-xs rounded cursor-pointer transition-all flex items-center gap-1 ${isUploadingRef
|
||
? "bg-gray-600 cursor-not-allowed text-gray-400"
|
||
: "bg-purple-600 hover:bg-purple-700 text-white"
|
||
}`}
|
||
>
|
||
<Upload className="h-3.5 w-3.5" />
|
||
上传
|
||
</label>
|
||
<button
|
||
onClick={onFetchRefAudios}
|
||
className="px-2 py-1 text-xs bg-white/10 hover:bg-white/20 rounded text-gray-300 flex items-center gap-1"
|
||
>
|
||
<RefreshCw className="h-3.5 w-3.5" />
|
||
刷新
|
||
</button>
|
||
</div>
|
||
</div>
|
||
|
||
{isUploadingRef && (
|
||
<div className="mb-2 p-2 bg-purple-500/10 rounded text-sm text-purple-300">
|
||
⏳ 上传并识别中...
|
||
</div>
|
||
)}
|
||
|
||
{uploadRefError && (
|
||
<div className="mb-2 p-2 bg-red-500/20 text-red-200 rounded text-xs flex justify-between">
|
||
<span>❌ {uploadRefError}</span>
|
||
<button onClick={onClearUploadRefError} className="text-red-300 hover:text-white">
|
||
<X className="h-3.5 w-3.5" />
|
||
</button>
|
||
</div>
|
||
)}
|
||
|
||
{refAudios.length === 0 ? (
|
||
<div className="text-center py-4 text-gray-500 text-sm">
|
||
暂无参考音频,请上传或录制
|
||
</div>
|
||
) : (
|
||
<div className="grid grid-cols-2 gap-2" style={{ contentVisibility: 'auto' }}>
|
||
{refAudios.map((audio) => (
|
||
<div
|
||
key={audio.id}
|
||
className={`p-2 rounded-lg border transition-all relative group cursor-pointer ${selectedRefAudio?.id === audio.id
|
||
? "border-purple-500 bg-purple-500/20"
|
||
: "border-white/10 bg-white/5 hover:border-white/30"
|
||
}`}
|
||
onClick={() => {
|
||
if (editingAudioId !== audio.id) {
|
||
onSelectRefAudio(audio);
|
||
}
|
||
}}
|
||
>
|
||
{editingAudioId === audio.id ? (
|
||
<div className="flex items-center gap-1" onClick={(e) => e.stopPropagation()}>
|
||
<input
|
||
type="text"
|
||
value={editName}
|
||
onChange={(e) => onEditNameChange(e.target.value)}
|
||
className="w-full bg-black/50 text-white text-xs px-1 py-0.5 rounded border border-purple-500 focus:outline-none"
|
||
autoFocus
|
||
onKeyDown={(e) => {
|
||
if (e.key === 'Enter') onSaveEditing(audio.id, e as unknown as MouseEvent);
|
||
if (e.key === 'Escape') onCancelEditing(e as unknown as MouseEvent);
|
||
}}
|
||
/>
|
||
<button onClick={(e) => onSaveEditing(audio.id, e)} className="text-green-400 hover:text-green-300 text-xs">
|
||
<Check className="h-3 w-3" />
|
||
</button>
|
||
<button onClick={(e) => onCancelEditing(e)} className="text-gray-400 hover:text-gray-300 text-xs">
|
||
<X className="h-3 w-3" />
|
||
</button>
|
||
</div>
|
||
) : (
|
||
<>
|
||
<div className="flex justify-between items-start mb-1">
|
||
<div className="text-white text-xs truncate pr-1 flex-1" title={audio.name}>
|
||
{audio.name}
|
||
</div>
|
||
<div className="flex gap-1 opacity-0 group-hover:opacity-100 transition-opacity">
|
||
<button
|
||
onClick={(e) => onTogglePlayPreview(audio, e)}
|
||
className="text-gray-400 hover:text-purple-400 text-xs"
|
||
title="试听"
|
||
>
|
||
{playingAudioId === audio.id ? (
|
||
<Pause className="h-3.5 w-3.5" />
|
||
) : (
|
||
<Play className="h-3.5 w-3.5" />
|
||
)}
|
||
</button>
|
||
<button
|
||
onClick={(e) => {
|
||
e.stopPropagation();
|
||
onRetranscribe(audio.id);
|
||
}}
|
||
disabled={retranscribingId === audio.id}
|
||
className="text-gray-400 hover:text-cyan-400 text-xs disabled:opacity-50"
|
||
title="重新识别文字"
|
||
>
|
||
<RotateCw className={`h-3.5 w-3.5 ${retranscribingId === audio.id ? 'animate-spin' : ''}`} />
|
||
</button>
|
||
<button
|
||
onClick={(e) => onStartEditing(audio, e)}
|
||
className="text-gray-400 hover:text-blue-400 text-xs"
|
||
title="重命名"
|
||
>
|
||
<Pencil className="h-3.5 w-3.5" />
|
||
</button>
|
||
<button
|
||
onClick={(e) => {
|
||
e.stopPropagation();
|
||
onDeleteRefAudio(audio.id);
|
||
}}
|
||
className="text-gray-400 hover:text-red-400 text-xs"
|
||
title="删除"
|
||
>
|
||
<Trash2 className="h-3.5 w-3.5" />
|
||
</button>
|
||
</div>
|
||
</div>
|
||
<div className="text-gray-400 text-xs">
|
||
{audio.duration_sec.toFixed(1)}s
|
||
{needsRetranscribe(audio) && (
|
||
<span className="text-yellow-500 ml-1" title="需要重新识别文字">⚠</span>
|
||
)}
|
||
</div>
|
||
</>
|
||
)}
|
||
</div>
|
||
))}
|
||
</div>
|
||
)}
|
||
</div>
|
||
|
||
<div className="border-t border-white/10 pt-4">
|
||
<span className="text-sm text-gray-300 mb-2 block">🎤 或在线录音 <span className="text-xs text-gray-500">(建议 3-10 秒,超出将自动截取)</span></span>
|
||
<div className="flex gap-2 items-center">
|
||
{!isRecording ? (
|
||
<button
|
||
onClick={onStartRecording}
|
||
className="px-4 py-2 bg-red-600 hover:bg-red-700 text-white rounded-lg text-sm font-medium transition-colors flex items-center gap-2"
|
||
>
|
||
<Mic className="h-4 w-4" />
|
||
开始录音
|
||
</button>
|
||
) : (
|
||
<button
|
||
onClick={onStopRecording}
|
||
className="px-4 py-2 bg-gray-600 hover:bg-gray-700 text-white rounded-lg text-sm font-medium transition-colors flex items-center gap-2"
|
||
>
|
||
<Square className="h-4 w-4" />
|
||
停止
|
||
</button>
|
||
)}
|
||
{isRecording && (
|
||
<span className="text-red-400 text-sm animate-pulse">
|
||
🔴 录音中 {formatRecordingTime(recordingTime)}
|
||
</span>
|
||
)}
|
||
</div>
|
||
|
||
{recordedBlob && !isRecording && (
|
||
<div className="mt-3 p-3 bg-green-500/10 border border-green-500/30 rounded-lg">
|
||
<div className="flex items-center gap-2 mb-2">
|
||
<span className="text-green-300 text-sm">✅ 录音完成 ({formatRecordingTime(recordingTime)})</span>
|
||
<audio src={recordedUrl || ''} controls className="h-8" />
|
||
</div>
|
||
<button
|
||
onClick={onUseRecording}
|
||
disabled={isUploadingRef}
|
||
className="px-3 py-1 bg-green-600 hover:bg-green-700 text-white rounded text-sm disabled:bg-gray-600"
|
||
>
|
||
使用此录音
|
||
</button>
|
||
</div>
|
||
)}
|
||
</div>
|
||
|
||
<p className="text-xs text-gray-500 mt-2 border-t border-white/10 pt-3">
|
||
上传任意语音样本(3-10秒),系统将自动识别内容并克隆声音
|
||
</p>
|
||
</div>
|
||
);
|
||
}
|