1
- import React , { useState , useRef } from 'react' ;
1
+ import React , { useState , useRef , useEffect } from 'react' ;
2
2
import { Button } from "@/components/ui/button" ;
3
3
import { Progress } from "@/components/ui/progress" ;
4
4
import { Label } from "@/components/ui/label" ;
5
5
import { Switch } from "@/components/ui/switch" ;
6
6
import { Select , SelectContent , SelectItem , SelectTrigger , SelectValue } from "@/components/ui/select" ;
7
7
import { Textarea } from "@/components/ui/textarea" ;
8
- import { uploadAudioFile } from '../lib/bytedanceTts' ;
8
+ import { uploadAudioFile , checkVoiceStatus , VoiceStatus , VoiceStatusResponse , VoiceInfo , listAvailableVoices } from '../lib/bytedanceTts' ;
9
9
import { logger } from '../utils/logger' ;
10
+ import { Alert , AlertDescription } from "@/components/ui/alert" ;
10
11
11
12
const ModelName = "ByteDanceRecorder" ;
12
13
@@ -29,6 +30,14 @@ export function ByteDanceRecorder({ onVoiceCloned }: ByteDanceRecorderProps) {
29
30
const [ isUploading , setIsUploading ] = useState ( false ) ;
30
31
const [ uploadProgress , setUploadProgress ] = useState ( 0 ) ;
31
32
const fileInputRef = useRef < HTMLInputElement > ( null ) ;
33
+ const [ currentVoiceId , setCurrentVoiceId ] = useState < string | null > ( null ) ;
34
+ const [ trainingStatus , setTrainingStatus ] = useState < VoiceStatusResponse | null > ( null ) ;
35
+ const [ isCheckingStatus , setIsCheckingStatus ] = useState ( false ) ;
36
+ const audioRef = useRef < HTMLAudioElement > ( null ) ;
37
+ const [ availableVoices , setAvailableVoices ] = useState < VoiceInfo [ ] > ( [ ] ) ;
38
+ const [ selectedVoiceId , setSelectedVoiceId ] = useState < string > ( '' ) ;
39
+ const [ isLoadingVoices , setIsLoadingVoices ] = useState ( false ) ;
40
+ const [ loadVoicesError , setLoadVoicesError ] = useState < string | null > ( null ) ;
32
41
33
42
// 配置选项
34
43
const [ language , setLanguage ] = useState < Language > ( 0 ) ;
@@ -37,6 +46,75 @@ export function ByteDanceRecorder({ onVoiceCloned }: ByteDanceRecorderProps) {
37
46
const [ noiseReduction , setNoiseReduction ] = useState ( true ) ;
38
47
const [ volumeNormalization , setVolumeNormalization ] = useState ( true ) ;
39
48
49
+ // 加载可用音色列表
50
+ useEffect ( ( ) => {
51
+ async function loadVoices ( ) {
52
+ try {
53
+ setIsLoadingVoices ( true ) ;
54
+ setLoadVoicesError ( null ) ;
55
+ logger . log ( 'Starting to load available voices...' , 'INFO' , ModelName ) ;
56
+
57
+ const voices = await listAvailableVoices ( ) ;
58
+ logger . log ( `Successfully loaded ${ voices . length } voices` , 'INFO' , ModelName ) ;
59
+
60
+ setAvailableVoices ( voices ) ;
61
+
62
+ // 如果有可用音色,默认选择第一个
63
+ if ( voices . length > 0 ) {
64
+ setSelectedVoiceId ( voices [ 0 ] . speakerId ) ;
65
+ logger . log ( `Selected default voice: ${ voices [ 0 ] . speakerId } ` , 'INFO' , ModelName ) ;
66
+ } else {
67
+ logger . log ( 'No voices available' , 'WARN' , ModelName ) ;
68
+ }
69
+ } catch ( error ) {
70
+ const errorMessage = error instanceof Error ? error . message : '未知错误' ;
71
+ logger . log ( `Failed to load voices: ${ errorMessage } ` , 'ERROR' , ModelName ) ;
72
+ setLoadVoicesError ( errorMessage ) ;
73
+ alert ( `加载音色列表失败: ${ errorMessage } ` ) ;
74
+ } finally {
75
+ setIsLoadingVoices ( false ) ;
76
+ }
77
+ }
78
+
79
+ loadVoices ( ) ;
80
+ } , [ ] ) ;
81
+
82
+ // 定期检查训练状态
83
+ useEffect ( ( ) => {
84
+ let intervalId : NodeJS . Timeout ;
85
+
86
+ const checkStatus = async ( ) => {
87
+ if ( ! currentVoiceId ||
88
+ ( trainingStatus ?. status !== VoiceStatus . Training &&
89
+ trainingStatus ?. status !== VoiceStatus . NotFound ) ) {
90
+ return ;
91
+ }
92
+
93
+ try {
94
+ setIsCheckingStatus ( true ) ;
95
+ const status = await checkVoiceStatus ( currentVoiceId ) ;
96
+ setTrainingStatus ( status ) ;
97
+
98
+ if ( status . status === VoiceStatus . Success || status . status === VoiceStatus . Active ) {
99
+ onVoiceCloned ( currentVoiceId ) ;
100
+ }
101
+ } catch ( error ) {
102
+ logger . log ( `Failed to check voice status: ${ error } ` , 'ERROR' , ModelName ) ;
103
+ } finally {
104
+ setIsCheckingStatus ( false ) ;
105
+ }
106
+ } ;
107
+
108
+ if ( currentVoiceId ) {
109
+ checkStatus ( ) ;
110
+ intervalId = setInterval ( checkStatus , 5000 ) ;
111
+ }
112
+
113
+ return ( ) => {
114
+ if ( intervalId ) clearInterval ( intervalId ) ;
115
+ } ;
116
+ } , [ currentVoiceId , trainingStatus ?. status ] ) ;
117
+
40
118
const handleFileSelect = async ( event : React . ChangeEvent < HTMLInputElement > ) => {
41
119
const file = event . target . files ?. [ 0 ] ;
42
120
if ( ! file ) return ;
@@ -58,24 +136,18 @@ export function ByteDanceRecorder({ onVoiceCloned }: ByteDanceRecorderProps) {
58
136
setIsUploading ( true ) ;
59
137
logger . log ( `Starting file upload: ${ file . name } ` , 'INFO' , ModelName ) ;
60
138
61
- // 模拟上传进度
62
- const progressInterval = setInterval ( ( ) => {
63
- setUploadProgress ( prev => Math . min ( prev + 10 , 90 ) ) ;
64
- } , 500 ) ;
65
-
66
- const voiceId = await uploadAudioFile ( file , {
139
+ const voiceId = await uploadAudioFile ( file , selectedVoiceId , {
67
140
language,
68
141
modelType,
69
142
textValidation,
70
143
noiseReduction,
71
144
volumeNormalization
72
145
} ) ;
73
146
74
- clearInterval ( progressInterval ) ;
75
- setUploadProgress ( 100 ) ;
147
+ setCurrentVoiceId ( voiceId ) ;
148
+ setTrainingStatus ( { status : VoiceStatus . Training } ) ;
76
149
77
150
logger . log ( `File uploaded successfully, voiceId: ${ voiceId } ` , 'INFO' , ModelName ) ;
78
- onVoiceCloned ( voiceId ) ;
79
151
80
152
} catch ( error ) {
81
153
logger . log ( `File upload failed: ${ error } ` , 'ERROR' , ModelName ) ;
@@ -89,9 +161,103 @@ export function ByteDanceRecorder({ onVoiceCloned }: ByteDanceRecorderProps) {
89
161
}
90
162
} ;
91
163
164
+ const getStatusDisplay = ( ) => {
165
+ if ( ! trainingStatus ) return null ;
166
+
167
+ const statusMessages = {
168
+ [ VoiceStatus . NotFound ] : '未找到音色' ,
169
+ [ VoiceStatus . Training ] : '正在训练中...' ,
170
+ [ VoiceStatus . Success ] : '训练成功' ,
171
+ [ VoiceStatus . Failed ] : '训练失败' ,
172
+ [ VoiceStatus . Active ] : '音色可用'
173
+ } ;
174
+
175
+ const statusColors = {
176
+ [ VoiceStatus . NotFound ] : 'bg-gray-100' ,
177
+ [ VoiceStatus . Training ] : 'bg-yellow-100' ,
178
+ [ VoiceStatus . Success ] : 'bg-green-100' ,
179
+ [ VoiceStatus . Failed ] : 'bg-red-100' ,
180
+ [ VoiceStatus . Active ] : 'bg-green-100'
181
+ } ;
182
+
183
+ const canUseVoice = trainingStatus . status === VoiceStatus . Success ||
184
+ trainingStatus . status === VoiceStatus . Active ;
185
+
186
+ return (
187
+ < Alert className = { statusColors [ trainingStatus . status ] } >
188
+ < AlertDescription className = "space-y-2" >
189
+ < div className = "flex items-center justify-between" >
190
+ < span > { statusMessages [ trainingStatus . status ] } </ span >
191
+ { trainingStatus . version && (
192
+ < span className = "text-sm text-muted-foreground" > 版本: { trainingStatus . version } </ span >
193
+ ) }
194
+ </ div >
195
+
196
+ { canUseVoice && (
197
+ < >
198
+ { trainingStatus . createTime && (
199
+ < div className = "text-sm text-muted-foreground" >
200
+ 创建时间: { new Date ( trainingStatus . createTime ) . toLocaleString ( ) }
201
+ </ div >
202
+ ) }
203
+ { trainingStatus . demoAudio && (
204
+ < div className = "mt-2" >
205
+ < Label > 试听效果</ Label >
206
+ < audio
207
+ ref = { audioRef }
208
+ src = { trainingStatus . demoAudio }
209
+ controls
210
+ className = "w-full mt-1"
211
+ />
212
+ < div className = "text-sm text-muted-foreground mt-1" >
213
+ 试听音频链接有效期为1小时
214
+ </ div >
215
+ </ div >
216
+ ) }
217
+ </ >
218
+ ) }
219
+ </ AlertDescription >
220
+ </ Alert >
221
+ ) ;
222
+ } ;
223
+
92
224
return (
93
225
< div className = "space-y-6" >
94
226
< div className = "space-y-4" >
227
+ < div className = "space-y-2" >
228
+ < Label > 选择音色</ Label >
229
+ { isLoadingVoices ? (
230
+ < div className = "text-sm text-muted-foreground" > 加载音色列表中...</ div >
231
+ ) : loadVoicesError ? (
232
+ < div className = "text-sm text-red-500" >
233
+ 加载失败: { loadVoicesError }
234
+ </ div >
235
+ ) : (
236
+ < Select
237
+ value = { selectedVoiceId }
238
+ onValueChange = { setSelectedVoiceId }
239
+ >
240
+ < SelectTrigger >
241
+ < SelectValue placeholder = "选择要使用的音色" />
242
+ </ SelectTrigger >
243
+ < SelectContent >
244
+ { availableVoices . map ( voice => (
245
+ < SelectItem
246
+ key = { voice . speakerId }
247
+ value = { voice . speakerId }
248
+ >
249
+ 音色 { voice . speakerId }
250
+ { voice . version && ` (${ voice . version } )` }
251
+ </ SelectItem >
252
+ ) ) }
253
+ </ SelectContent >
254
+ </ Select >
255
+ ) }
256
+ < div className = "text-sm text-muted-foreground" >
257
+ 选择要使用的音色ID,每个音色有其特定的声音特征
258
+ </ div >
259
+ </ div >
260
+
95
261
< input
96
262
type = "file"
97
263
ref = { fileInputRef }
@@ -101,7 +267,7 @@ export function ByteDanceRecorder({ onVoiceCloned }: ByteDanceRecorderProps) {
101
267
/>
102
268
< Button
103
269
onClick = { ( ) => fileInputRef . current ?. click ( ) }
104
- disabled = { isUploading }
270
+ disabled = { isUploading || ! selectedVoiceId }
105
271
variant = "outline"
106
272
className = "w-full"
107
273
>
@@ -188,6 +354,8 @@ export function ByteDanceRecorder({ onVoiceCloned }: ByteDanceRecorderProps) {
188
354
</ div >
189
355
</ div >
190
356
357
+ { getStatusDisplay ( ) }
358
+
191
359
< div className = "text-sm text-muted-foreground" >
192
360
注意:
193
361
< ul className = "list-disc pl-4 space-y-1" >
@@ -196,6 +364,10 @@ export function ByteDanceRecorder({ onVoiceCloned }: ByteDanceRecorderProps) {
196
364
< li > 支持mp3、wav、ogg、m4a、aac格式</ li >
197
365
< li > 复刻的音色将在7天内未使用时自动删除</ li >
198
366
< li > 使用2.0版本时,请确保音频语言与选择的语言一致</ li >
367
+ < li > 训练完成后可以试听效果</ li >
368
+ < li > 训练成功后音色ID将自动保存</ li >
369
+ < li > 每个音色ID代表一种独特的声音特征</ li >
370
+ < li > 请先选择音色ID再上传音频文件</ li >
199
371
</ ul >
200
372
</ div >
201
373
</ div >
0 commit comments