@@ -108,12 +108,79 @@ const Status = styled.div`
108
108
gap: 8px;
109
109
` ;
110
110
111
+ const ButtonGroup = styled . div `
112
+ display: flex;
113
+ gap: 1rem;
114
+ justify-content: center;
115
+ ` ;
116
+
117
+ const Select = styled . select `
118
+ width: 100%;
119
+ padding: 0.8rem;
120
+ border-radius: 8px;
121
+ background: #2a2a2a;
122
+ color: white;
123
+ border: 1px solid #444;
124
+ margin-bottom: 1rem;
125
+
126
+ &:focus {
127
+ outline: none;
128
+ border-color: #4CAF50;
129
+ }
130
+ ` ;
131
+
132
+ const InputGroup = styled . div `
133
+ display: flex;
134
+ gap: 1rem;
135
+ margin-bottom: 1rem;
136
+ ` ;
137
+
138
+ const RangeInput = styled . input `
139
+ width: 100%;
140
+ background: #2a2a2a;
141
+ -webkit-appearance: none;
142
+ height: 8px;
143
+ border-radius: 4px;
144
+ margin: 10px 0;
145
+
146
+ &::-webkit-slider-thumb {
147
+ -webkit-appearance: none;
148
+ width: 20px;
149
+ height: 20px;
150
+ background: #4CAF50;
151
+ border-radius: 50%;
152
+ cursor: pointer;
153
+ }
154
+ ` ;
155
+
156
+ const Label = styled . label `
157
+ color: #888;
158
+ margin-bottom: 0.5rem;
159
+ display: block;
160
+ ` ;
161
+
162
+ const VOICE_OPTIONS = [
163
+ { id : 'af_bella' , name : 'Bella' , language : 'en-us' , gender : 'Female' } ,
164
+ { id : 'af_nicole' , name : 'Nicole' , language : 'en-us' , gender : 'Female' } ,
165
+ { id : 'af_sarah' , name : 'Sarah' , language : 'en-us' , gender : 'Female' } ,
166
+ { id : 'af_sky' , name : 'Sky' , language : 'en-us' , gender : 'Female' } ,
167
+ { id : 'am_adam' , name : 'Adam' , language : 'en-us' , gender : 'Male' } ,
168
+ { id : 'am_michael' , name : 'Michael' , language : 'en-us' , gender : 'Male' } ,
169
+ { id : 'bf_emma' , name : 'Emma' , language : 'en-gb' , gender : 'Female' } ,
170
+ { id : 'bf_isabella' , name : 'Isabella' , language : 'en-gb' , gender : 'Female' } ,
171
+ { id : 'bm_george' , name : 'George' , language : 'en-gb' , gender : 'Male' } ,
172
+ { id : 'bm_lewis' , name : 'Lewis' , language : 'en-gb' , gender : 'Male' } ,
173
+ ] ;
174
+
111
175
function App ( ) {
112
176
const [ text , setText ] = useState ( '' ) ;
113
177
const [ status , setStatus ] = useState ( '' ) ;
114
178
const [ isLoading , setIsLoading ] = useState ( false ) ;
115
179
const [ ttsAI ] = useState ( new BrowserAI ( ) ) ;
116
180
const [ isModelLoaded , setIsModelLoaded ] = useState ( false ) ;
181
+ const [ audioBlob , setAudioBlob ] = useState < Blob | null > ( null ) ;
182
+ const [ selectedVoice , setSelectedVoice ] = useState ( 'af_bella' ) ;
183
+ const [ speed , setSpeed ] = useState ( 1.0 ) ;
117
184
118
185
const loadModel = async ( ) => {
119
186
try {
@@ -145,6 +212,7 @@ function App() {
145
212
if ( audioData ) {
146
213
// Create a blob with WAV MIME type
147
214
const blob = new Blob ( [ audioData ] , { type : 'audio/wav' } ) ;
215
+ setAudioBlob ( blob ) ; // Store the blob for download
148
216
const audioUrl = URL . createObjectURL ( blob ) ;
149
217
150
218
// Create and play audio element
@@ -173,6 +241,19 @@ function App() {
173
241
}
174
242
} ;
175
243
244
+ const downloadAudio = ( ) => {
245
+ if ( audioBlob ) {
246
+ const url = URL . createObjectURL ( audioBlob ) ;
247
+ const a = document . createElement ( 'a' ) ;
248
+ a . href = url ;
249
+ a . download = 'generated-speech.wav' ;
250
+ document . body . appendChild ( a ) ;
251
+ a . click ( ) ;
252
+ document . body . removeChild ( a ) ;
253
+ URL . revokeObjectURL ( url ) ;
254
+ }
255
+ } ;
256
+
176
257
return (
177
258
< >
178
259
< Banner >
@@ -197,23 +278,62 @@ function App() {
197
278
</ ButtonContent >
198
279
</ Button >
199
280
281
+ < InputGroup >
282
+ < div style = { { flex : 1 } } >
283
+ < Label > Voice</ Label >
284
+ < Select
285
+ value = { selectedVoice }
286
+ onChange = { ( e ) => setSelectedVoice ( e . target . value ) }
287
+ disabled = { ! isModelLoaded || isLoading }
288
+ >
289
+ { VOICE_OPTIONS . map ( voice => (
290
+ < option key = { voice . id } value = { voice . id } >
291
+ { voice . name } ({ voice . language } , { voice . gender } )
292
+ </ option >
293
+ ) ) }
294
+ </ Select >
295
+ </ div >
296
+ < div style = { { flex : 1 } } >
297
+ < Label > Speed: { speed . toFixed ( 1 ) } x</ Label >
298
+ < RangeInput
299
+ type = "range"
300
+ min = "0.2"
301
+ max = "2"
302
+ step = "0.1"
303
+ value = { speed }
304
+ onChange = { ( e ) => setSpeed ( parseFloat ( e . target . value ) ) }
305
+ disabled = { ! isModelLoaded || isLoading }
306
+ />
307
+ </ div >
308
+ </ InputGroup >
309
+
200
310
< TextArea
201
311
value = { text }
202
312
onChange = { ( e ) => setText ( e . target . value ) }
203
313
placeholder = "Enter text to convert to speech..."
204
314
disabled = { ! isModelLoaded || isLoading }
205
315
/>
206
316
207
- < Button
208
- onClick = { speak }
209
- disabled = { ! isModelLoaded || isLoading || ! text . trim ( ) }
210
- isLoading = { isLoading && isModelLoaded }
211
- >
212
- < ButtonContent >
213
- { ( isLoading && isModelLoaded ) && < Spinner /> }
214
- { isLoading ? 'Processing...' : 'Speak' }
215
- </ ButtonContent >
216
- </ Button >
317
+ < ButtonGroup >
318
+ < Button
319
+ onClick = { speak }
320
+ disabled = { ! isModelLoaded || isLoading || ! text . trim ( ) }
321
+ isLoading = { isLoading && isModelLoaded }
322
+ >
323
+ < ButtonContent >
324
+ { ( isLoading && isModelLoaded ) && < Spinner /> }
325
+ { isLoading ? 'Processing...' : 'Speak' }
326
+ </ ButtonContent >
327
+ </ Button >
328
+
329
+ { audioBlob && (
330
+ < Button onClick = { downloadAudio } >
331
+ < ButtonContent >
332
+ Download Audio
333
+ </ ButtonContent >
334
+ </ Button >
335
+ ) }
336
+ </ ButtonGroup >
217
337
218
338
{ ( status || isLoading ) && (
219
339
< Status >
0 commit comments