Skip to content

Commit 604e57f

Browse files
committed
feat: 添加字节跳动语音复刻功能
- 实现音频文件上传和语音克隆功能 - 支持音频文件格式验证和大小限制 - 添加上传进度显示 - 优化错误处理和用户提示
1 parent aa7dc93 commit 604e57f

File tree

1 file changed

+140
-18
lines changed

1 file changed

+140
-18
lines changed

src/components/ByteDanceRecorder.tsx

+140-18
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,8 @@ import { Textarea } from "@/components/ui/textarea";
88
import { uploadAudioFile, checkVoiceStatus, VoiceStatus, VoiceStatusResponse, VoiceInfo, listAvailableVoices } from '../lib/bytedanceTts';
99
import { logger } from '../utils/logger';
1010
import { Alert, AlertDescription } from "@/components/ui/alert";
11+
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
12+
import { Mic, Square, Upload } from "lucide-react";
1113

1214
const ModelName = "ByteDanceRecorder";
1315

@@ -45,6 +47,14 @@ export function ByteDanceRecorder({ onVoiceCloned }: ByteDanceRecorderProps) {
4547
const [noiseReduction, setNoiseReduction] = useState(true);
4648
const [volumeNormalization, setVolumeNormalization] = useState(true);
4749

50+
// Add new recording related state
51+
const [isRecording, setIsRecording] = useState(false);
52+
const [duration, setDuration] = useState(0);
53+
const mediaRecorder = useRef<MediaRecorder | null>(null);
54+
const chunks = useRef<Blob[]>([]);
55+
const timerRef = useRef<number>();
56+
const maxDuration = 300; // 5 minutes in seconds
57+
4858
// 加载可用音色列表
4959
useEffect(() => {
5060
async function loadVoices() {
@@ -217,6 +227,80 @@ export function ByteDanceRecorder({ onVoiceCloned }: ByteDanceRecorderProps) {
217227
);
218228
};
219229

230+
// Add recording functions
231+
const startRecording = async () => {
232+
try {
233+
const stream = await navigator.mediaDevices.getUserMedia({ audio: true });
234+
mediaRecorder.current = new MediaRecorder(stream);
235+
236+
mediaRecorder.current.ondataavailable = (e) => {
237+
if (e.data.size > 0) {
238+
chunks.current.push(e.data);
239+
}
240+
};
241+
242+
mediaRecorder.current.onstop = async () => {
243+
const blob = new Blob(chunks.current, { type: 'audio/wav' });
244+
const file = new File([blob], `recording-${Date.now()}.wav`, { type: 'audio/wav' });
245+
246+
try {
247+
setIsUploading(true);
248+
const voiceId = await uploadAudioFile(file, selectedVoiceId, {
249+
language,
250+
modelType,
251+
textValidation,
252+
noiseReduction,
253+
volumeNormalization
254+
});
255+
256+
setCurrentVoiceId(voiceId);
257+
setTrainingStatus({ status: VoiceStatus.Training });
258+
} catch (error) {
259+
logger.log(`Recording upload failed: ${error}`, 'ERROR', ModelName);
260+
alert('上传失败,请重试');
261+
} finally {
262+
setIsUploading(false);
263+
}
264+
265+
chunks.current = [];
266+
setDuration(0);
267+
};
268+
269+
mediaRecorder.current.start();
270+
setIsRecording(true);
271+
272+
// Start timer
273+
timerRef.current = window.setInterval(() => {
274+
setDuration(prev => {
275+
if (prev >= maxDuration - 1) {
276+
stopRecording();
277+
return 0;
278+
}
279+
return prev + 1;
280+
});
281+
}, 1000);
282+
283+
} catch (error) {
284+
logger.log(`Recording failed: ${error}`, 'ERROR', ModelName);
285+
alert('无法访问麦克风');
286+
}
287+
};
288+
289+
const stopRecording = () => {
290+
if (mediaRecorder.current && isRecording) {
291+
mediaRecorder.current.stop();
292+
mediaRecorder.current.stream.getTracks().forEach(track => track.stop());
293+
clearInterval(timerRef.current);
294+
setIsRecording(false);
295+
}
296+
};
297+
298+
const formatTime = (seconds: number) => {
299+
const mins = Math.floor(seconds / 60);
300+
const secs = seconds % 60;
301+
return `${mins}:${secs.toString().padStart(2, '0')}`;
302+
};
303+
220304
return (
221305
<div className="space-y-6">
222306
<div className="space-y-4">
@@ -254,24 +338,62 @@ export function ByteDanceRecorder({ onVoiceCloned }: ByteDanceRecorderProps) {
254338
</div>
255339
</div>
256340

257-
<input
258-
type="file"
259-
ref={fileInputRef}
260-
onChange={handleFileSelect}
261-
accept=".mp3,.wav,.ogg,.m4a,.aac"
262-
className="hidden"
263-
/>
264-
<Button
265-
onClick={() => fileInputRef.current?.click()}
266-
disabled={isUploading || !selectedVoiceId}
267-
variant="outline"
268-
className="w-full"
269-
>
270-
{isUploading ? '上传中...' : '选择音频文件'}
271-
</Button>
272-
{isUploading && (
273-
<Progress value={uploadProgress} className="w-full" />
274-
)}
341+
<Tabs defaultValue="record" className="w-full">
342+
<TabsList className="grid w-full grid-cols-2">
343+
<TabsTrigger value="record">实时录音</TabsTrigger>
344+
<TabsTrigger value="upload">文件上传</TabsTrigger>
345+
</TabsList>
346+
347+
<TabsContent value="record" className="space-y-4">
348+
<div className="flex items-center gap-4">
349+
{isRecording ? (
350+
<Button
351+
variant="destructive"
352+
onClick={stopRecording}
353+
disabled={!selectedVoiceId}
354+
className="gap-2"
355+
>
356+
<Square className="h-4 w-4" />
357+
停止录音 ({formatTime(duration)})
358+
</Button>
359+
) : (
360+
<Button
361+
onClick={startRecording}
362+
disabled={!selectedVoiceId}
363+
className="gap-2"
364+
>
365+
<Mic className="h-4 w-4" />
366+
开始录音
367+
</Button>
368+
)}
369+
</div>
370+
<div className="text-sm text-muted-foreground">
371+
最长录音时间:{Math.floor(maxDuration / 60)}分钟
372+
</div>
373+
</TabsContent>
374+
375+
<TabsContent value="upload" className="space-y-4">
376+
<input
377+
type="file"
378+
ref={fileInputRef}
379+
onChange={handleFileSelect}
380+
accept=".mp3,.wav,.ogg,.m4a,.aac"
381+
className="hidden"
382+
/>
383+
<Button
384+
onClick={() => fileInputRef.current?.click()}
385+
disabled={isUploading || !selectedVoiceId}
386+
variant="outline"
387+
className="w-full gap-2"
388+
>
389+
<Upload className="h-4 w-4" />
390+
{isUploading ? '上传中...' : '选择音频文件'}
391+
</Button>
392+
{isUploading && (
393+
<Progress value={uploadProgress} className="w-full" />
394+
)}
395+
</TabsContent>
396+
</Tabs>
275397
</div>
276398

277399
<div className="space-y-4">

0 commit comments

Comments
 (0)