front/src/components/AudioPlayer.tsx CHANGED
@@ -1,43 +1,39 @@
1
- import React, { useMemo, useEffect } from 'react';
2
- import { blobFromAudioBuffer } from '../utils/utils';
3
-
4
- interface AudioPlayerProps {
5
- audioBuffer: AudioBuffer;
6
- }
7
-
8
- export const AudioPlayer: React.FC<AudioPlayerProps> = ({ audioBuffer }) => {
9
- // Create a Blob URL from the audioBuffer.
10
- const blobUrl = useMemo(() => {
11
- const wavBlob = blobFromAudioBuffer(audioBuffer);
12
- return URL.createObjectURL(wavBlob);
13
- }, [audioBuffer]);
14
-
15
- const downloadUrl = useMemo(() => {
16
- const wavBlob = blobFromAudioBuffer(audioBuffer);
17
- return URL.createObjectURL(wavBlob);
18
- }, [audioBuffer]);
19
-
20
- // Clean up the object URL when the component unmounts or audioBuffer changes.
21
- useEffect(() => {
22
- return () => {
23
- URL.revokeObjectURL(blobUrl);
24
- URL.revokeObjectURL(downloadUrl);
25
- };
26
- }, [blobUrl]);
27
-
28
- return (
29
- <div className="mt-4 flex items-center">
30
- <audio controls src={blobUrl}>
31
- Your browser does not support the audio element.
32
- </audio>
33
-
34
- <a
35
- className="btn btn-sm btn-primary ml-2"
36
- href={downloadUrl}
37
- download={'podcast.wav'}
38
- >
39
- Download
40
- </a>
41
- </div>
42
- );
43
- };
 
1
+ import React, { useMemo, useEffect } from 'react';
2
+ import { blobFromAudioBuffer } from '../utils/utils';
3
+
4
+ interface AudioPlayerProps {
5
+ audioBuffer: AudioBuffer;
6
+ filename?: string; // Optional filename prop
7
+ }
8
+
9
+ export const AudioPlayer: React.FC<AudioPlayerProps> = ({ audioBuffer, filename = 'podcast.wav' }) => {
10
+ // Create a Blob URL from the audioBuffer.
11
+ const blobUrl = useMemo(() => {
12
+ const wavBlob = blobFromAudioBuffer(audioBuffer);
13
+ return URL.createObjectURL(wavBlob);
14
+ }, [audioBuffer]);
15
+
16
+
17
+ // Clean up the object URL when the component unmounts or audioBuffer changes.
18
+ useEffect(() => {
19
+ return () => {
20
+ URL.revokeObjectURL(blobUrl);
21
+ };
22
+ }, [blobUrl]);
23
+
24
+ return (
25
+ <div className="mt-4 flex items-center">
26
+ <audio controls src={blobUrl}>
27
+ Your browser does not support the audio element.
28
+ </audio>
29
+
30
+ <a
31
+ className="btn btn-sm btn-primary ml-2"
32
+ href={blobUrl} // Use the same blobUrl for download
33
+ download={filename}
34
+ >
35
+ Download
36
+ </a>
37
+ </div>
38
+ );
39
+ };
 
 
 
 
front/src/components/PodcastGenerator.tsx CHANGED
@@ -1,377 +1,454 @@
1
- import { useEffect, useState } from 'react';
2
- import { AudioPlayer } from './AudioPlayer';
3
- import { Podcast, PodcastTurn } from '../utils/types';
4
- import { parse } from 'yaml';
5
- import {
6
- addNoise,
7
- addSilence,
8
- audioBufferToMp3,
9
- generateAudio,
10
- isBlogMode,
11
- joinAudio,
12
- loadWavAndDecode,
13
- pickRand,
14
- uploadFileToHub,
15
- } from '../utils/utils';
16
-
17
- // taken from https://freesound.org/people/artxmp1/sounds/660540
18
- import openingSoundSrc from '../opening-sound.wav';
19
- import { getBlogComment } from '../utils/prompts';
20
-
21
- interface GenerationStep {
22
- turn: PodcastTurn;
23
- audioBuffer?: AudioBuffer;
24
- }
25
-
26
- const SPEEDS = [
27
- { name: 'slow AF', value: 0.8 },
28
- { name: 'slow', value: 0.9 },
29
- { name: 'a bit slow', value: 1.0 },
30
- { name: 'natural', value: 1.1 },
31
- { name: 'most natural', value: 1.2 },
32
- { name: 'a bit fast', value: 1.3 },
33
- { name: 'fast!', value: 1.4 },
34
- { name: 'fast AF', value: 1.5 },
35
- ];
36
-
37
- const SPEAKERS = [
38
- { name: '🇺🇸 🚺 Heart ❤️', value: 'af_heart' },
39
- { name: '🇺🇸 🚺 Bella 🔥', value: 'af_bella' },
40
- // { name: '🇺🇸 🚺 Nicole 🎧', value: 'af_nicole' },
41
- { name: '🇺🇸 🚺 Aoede', value: 'af_aoede' },
42
- { name: '🇺🇸 🚺 Kore', value: 'af_kore' },
43
- { name: '🇺🇸 🚺 Sarah', value: 'af_sarah' },
44
- { name: '🇺🇸 🚺 Nova', value: 'af_nova' },
45
- { name: '🇺🇸 🚺 Sky', value: 'af_sky' },
46
- { name: '🇺🇸 🚺 Alloy', value: 'af_alloy' },
47
- { name: '🇺🇸 🚺 Jessica', value: 'af_jessica' },
48
- { name: '🇺🇸 🚺 River', value: 'af_river' },
49
- { name: '🇺🇸 🚹 Michael', value: 'am_michael' },
50
- { name: '🇺🇸 🚹 Fenrir', value: 'am_fenrir' },
51
- { name: '🇺🇸 🚹 Puck', value: 'am_puck' },
52
- { name: '🇺🇸 🚹 Echo', value: 'am_echo' },
53
- { name: '🇺🇸 🚹 Eric', value: 'am_eric' },
54
- { name: '🇺🇸 🚹 Liam', value: 'am_liam' },
55
- { name: '🇺🇸 🚹 Onyx', value: 'am_onyx' },
56
- { name: '🇺🇸 🚹 Santa', value: 'am_santa' },
57
- { name: '🇺🇸 🚹 Adam', value: 'am_adam' },
58
- { name: '🇬🇧 🚺 Emma', value: 'bf_emma' },
59
- { name: '🇬🇧 🚺 Isabella', value: 'bf_isabella' },
60
- { name: '🇬🇧 🚺 Alice', value: 'bf_alice' },
61
- { name: '🇬🇧 🚺 Lily', value: 'bf_lily' },
62
- { name: '🇬🇧 🚹 George', value: 'bm_george' },
63
- { name: '🇬🇧 🚹 Fable', value: 'bm_fable' },
64
- { name: '🇬🇧 🚹 Lewis', value: 'bm_lewis' },
65
- { name: '🇬🇧 🚹 Daniel', value: 'bm_daniel' },
66
- ];
67
-
68
- const getRandomSpeakerPair = (): { s1: string; s2: string } => {
69
- const s1Gender = Math.random() > 0.5 ? '🚺' : '🚹';
70
- const s2Gender = s1Gender === '🚺' ? '🚹' : '🚺';
71
- const s1 = pickRand(
72
- SPEAKERS.filter((s) => s.name.includes(s1Gender) && s.name.includes('🇺🇸'))
73
- ).value;
74
- const s2 = pickRand(
75
- SPEAKERS.filter((s) => s.name.includes(s2Gender) && s.name.includes('🇺🇸'))
76
- ).value;
77
- return { s1, s2 };
78
- };
79
-
80
- const parseYAML = (yaml: string): Podcast => {
81
- try {
82
- return parse(yaml);
83
- } catch (e) {
84
- console.error(e);
85
- throw new Error(
86
- 'invalid YAML, please re-generate the script: ' + (e as any).message
87
- );
88
- }
89
- };
90
-
91
- export const PodcastGenerator = ({
92
- genratedScript,
93
- setBusy,
94
- blogURL,
95
- busy,
96
- }: {
97
- genratedScript: string;
98
- blogURL: string;
99
- setBusy: (busy: boolean) => void;
100
- busy: boolean;
101
- }) => {
102
- const [wav, setWav] = useState<AudioBuffer | null>(null);
103
- const [numSteps, setNumSteps] = useState<number>(0);
104
- const [numStepsDone, setNumStepsDone] = useState<number>(0);
105
-
106
- const [script, setScript] = useState<string>('');
107
- const [speaker1, setSpeaker1] = useState<string>('');
108
- const [speaker2, setSpeaker2] = useState<string>('');
109
- const [speed, setSpeed] = useState<string>('1.2');
110
- const [addIntroMusic, setAddIntroMusic] = useState<boolean>(false);
111
-
112
- const [blogFilePushToken, setBlogFilePushToken] = useState<string>(
113
- localStorage.getItem('blogFilePushToken') || ''
114
- );
115
- const [blogCmtOutput, setBlogCmtOutput] = useState<string>('');
116
- useEffect(() => {
117
- localStorage.setItem('blogFilePushToken', blogFilePushToken);
118
- }, [blogFilePushToken]);
119
-
120
- const setRandSpeaker = () => {
121
- const { s1, s2 } = getRandomSpeakerPair();
122
- setSpeaker1(s1);
123
- setSpeaker2(s2);
124
- };
125
- useEffect(setRandSpeaker, []);
126
-
127
- useEffect(() => {
128
- setScript(genratedScript);
129
- }, [genratedScript]);
130
-
131
- const generatePodcast = async () => {
132
- setWav(null);
133
- setBusy(true);
134
- setBlogCmtOutput('');
135
- if (isBlogMode && !blogURL) {
136
- alert('Please enter a blog slug');
137
- setBusy(false);
138
- return;
139
- }
140
- let outputWav: AudioBuffer;
141
- try {
142
- const podcast = parseYAML(script);
143
- const { speakerNames, turns } = podcast;
144
- for (const turn of turns) {
145
- // normalize it
146
- turn.nextGapMilisecs =
147
- Math.max(-600, Math.min(300, turn.nextGapMilisecs)) - 100;
148
- turn.text = turn.text
149
- .trim()
150
- .replace(/’/g, "'")
151
- .replace(/“/g, '"')
152
- .replace(/”/g, '"');
153
- }
154
- const steps: GenerationStep[] = turns.map((turn) => ({ turn }));
155
- setNumSteps(steps.length);
156
- setNumStepsDone(0);
157
- for (let i = 0; i < steps.length; i++) {
158
- const step = steps[i];
159
- const speakerIdx = speakerNames.indexOf(
160
- step.turn.speakerName as string
161
- ) as 1 | 0;
162
- const speakerVoice = speakerIdx === 0 ? speaker1 : speaker2;
163
- const url = await generateAudio(
164
- step.turn.text,
165
- speakerVoice,
166
- parseFloat(speed)
167
- );
168
- step.audioBuffer = await loadWavAndDecode(url);
169
- if (i === 0) {
170
- outputWav = step.audioBuffer;
171
- if (addIntroMusic) {
172
- const openingSound = await loadWavAndDecode(openingSoundSrc);
173
- outputWav = joinAudio(openingSound, outputWav!, -2000);
174
- } else {
175
- outputWav = addSilence(outputWav!, true, 200);
176
- }
177
- } else {
178
- const lastStep = steps[i - 1];
179
- outputWav = joinAudio(
180
- outputWav!,
181
- step.audioBuffer,
182
- lastStep.turn.nextGapMilisecs
183
- );
184
- }
185
- setNumStepsDone(i + 1);
186
- }
187
- outputWav = addNoise(outputWav!, 0.002);
188
- setWav(outputWav! ?? null);
189
- } catch (e) {
190
- console.error(e);
191
- alert(`Error: ${(e as any).message}`);
192
- setWav(null);
193
- }
194
- setBusy(false);
195
- setNumStepsDone(0);
196
- setNumSteps(0);
197
-
198
- // maybe upload
199
- if (isBlogMode && outputWav!) {
200
- const repoId = 'ngxson/hf-blog-podcast';
201
- const blogSlug = blogURL.split('/blog/').pop() ?? '_noname';
202
- const filename = `${blogSlug}.mp3`;
203
- setBlogCmtOutput(`Uploading '${filename}' ...`);
204
- await uploadFileToHub(
205
- audioBufferToMp3(outputWav),
206
- filename,
207
- repoId,
208
- blogFilePushToken
209
- );
210
- setBlogCmtOutput(getBlogComment(filename));
211
- }
212
- };
213
-
214
- const isGenerating = numSteps > 0;
215
-
216
- return (
217
- <>
218
- <div className="card bg-base-100 w-full shadow-xl">
219
- <div className="card-body">
220
- <h2 className="card-title">Step 2: Script (YAML format)</h2>
221
-
222
- {isBlogMode && (
223
- <>
224
- <input
225
- type="password"
226
- placeholder="Repo push HF_TOKEN"
227
- className="input input-bordered w-full"
228
- value={blogFilePushToken}
229
- onChange={(e) => setBlogFilePushToken(e.target.value)}
230
- />
231
- </>
232
- )}
233
-
234
- <textarea
235
- className="textarea textarea-bordered w-full h-72 p-2"
236
- placeholder="Type your script here..."
237
- value={script}
238
- onChange={(e) => setScript(e.target.value)}
239
- ></textarea>
240
-
241
- <div className="grid grid-cols-2 gap-4">
242
- <label className="form-control w-full">
243
- <div className="label">
244
- <span className="label-text">Speaker 1</span>
245
- </div>
246
- <select
247
- className="select select-bordered"
248
- value={speaker1}
249
- onChange={(e) => setSpeaker1(e.target.value)}
250
- >
251
- {SPEAKERS.map((s) => (
252
- <option key={s.value} value={s.value}>
253
- {s.name}
254
- </option>
255
- ))}
256
- </select>
257
- </label>
258
-
259
- <label className="form-control w-full">
260
- <div className="label">
261
- <span className="label-text">Speaker 2</span>
262
- </div>
263
- <select
264
- className="select select-bordered"
265
- value={speaker2}
266
- onChange={(e) => setSpeaker2(e.target.value)}
267
- >
268
- {SPEAKERS.map((s) => (
269
- <option key={s.value} value={s.value}>
270
- {s.name}
271
- </option>
272
- ))}
273
- </select>
274
- </label>
275
-
276
- <button className="btn" onClick={setRandSpeaker}>
277
- Randomize speakers
278
- </button>
279
-
280
- <label className="form-control w-full">
281
- <select
282
- className="select select-bordered"
283
- value={speed.toString()}
284
- onChange={(e) => setSpeed(e.target.value)}
285
- >
286
- {SPEEDS.map((s) => (
287
- <option key={s.value} value={s.value.toString()}>
288
- Speed: {s.name} ({s.value})
289
- </option>
290
- ))}
291
- </select>
292
- </label>
293
-
294
- <div className="flex items-center gap-2">
295
- <input
296
- type="checkbox"
297
- className="checkbox"
298
- checked={addIntroMusic}
299
- onChange={(e) => setAddIntroMusic(e.target.checked)}
300
- disabled={isGenerating || busy}
301
- />
302
- Add intro music
303
- </div>
304
- </div>
305
-
306
- <button
307
- id="btn-generate-podcast"
308
- className="btn btn-primary mt-2"
309
- onClick={generatePodcast}
310
- disabled={busy || !script || isGenerating}
311
- >
312
- {isGenerating ? (
313
- <>
314
- <span className="loading loading-spinner loading-sm"></span>
315
- Generating ({numStepsDone}/{numSteps})...
316
- </>
317
- ) : (
318
- 'Generate podcast'
319
- )}
320
- </button>
321
-
322
- {isGenerating && (
323
- <progress
324
- className="progress progress-primary mt-2"
325
- value={numStepsDone}
326
- max={numSteps}
327
- ></progress>
328
- )}
329
- </div>
330
- </div>
331
-
332
- {wav && (
333
- <div className="card bg-base-100 w-full shadow-xl">
334
- <div className="card-body">
335
- <h2 className="card-title">Step 3: Listen to your podcast</h2>
336
- <AudioPlayer audioBuffer={wav} />
337
-
338
- {isBlogMode && (
339
- <div>
340
- -------------------
341
- <br />
342
- <h2>Comment to be posted:</h2>
343
- <pre className="p-2 bg-base-200 rounded-md my-2 whitespace-pre-wrap break-words">
344
- {blogCmtOutput}
345
- </pre>
346
- <button
347
- className="btn btn-sm btn-secondary"
348
- onClick={() => copyStr(blogCmtOutput)}
349
- >
350
- Copy comment
351
- </button>
352
- </div>
353
- )}
354
- </div>
355
- </div>
356
- )}
357
- </>
358
- );
359
- };
360
-
361
- // copy text to clipboard
362
- export const copyStr = (textToCopy: string) => {
363
- // Navigator clipboard api needs a secure context (https)
364
- if (navigator.clipboard && window.isSecureContext) {
365
- navigator.clipboard.writeText(textToCopy);
366
- } else {
367
- // Use the 'out of viewport hidden text area' trick
368
- const textArea = document.createElement('textarea');
369
- textArea.value = textToCopy;
370
- // Move textarea out of the viewport so it's not visible
371
- textArea.style.position = 'absolute';
372
- textArea.style.left = '-999999px';
373
- document.body.prepend(textArea);
374
- textArea.select();
375
- document.execCommand('copy');
376
- }
377
- };
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { useEffect, useState } from 'react';
2
+ import { AudioPlayer } from './AudioPlayer';
3
+ import { Podcast, PodcastTurn } from '../utils/types';
4
+ import { parse } from 'yaml';
5
+ import {
6
+ addNoise,
7
+ addSilence,
8
+ audioBufferToMp3,
9
+ generateAudio,
10
+ isBlogMode,
11
+ joinAudio,
12
+ loadWavAndDecode,
13
+ pickRand,
14
+ uploadFileToHub,
15
+ denoiseAudioBuffer,
16
+ } from '../utils/utils';
17
+
18
+ // taken from https://freesound.org/people/artxmp1/sounds/660540
19
+ import openingSoundSrc from '../opening-sound.wav';
20
+ import { getBlogComment } from '../utils/prompts';
21
+
22
+ interface GenerationStep {
23
+ turn: PodcastTurn;
24
+ audioBuffer?: AudioBuffer;
25
+ }
26
+
27
+ const SPEEDS = [
28
+ { name: 'slow AF', value: 0.8 },
29
+ { name: 'slow', value: 0.9 },
30
+ { name: 'a bit slow', value: 1.0 },
31
+ { name: 'natural', value: 1.1 },
32
+ { name: 'most natural', value: 1.2 },
33
+ { name: 'a bit fast', value: 1.3 },
34
+ { name: 'fast!', value: 1.4 },
35
+ { name: 'fast AF', value: 1.5 },
36
+ ];
37
+
38
+ const SPEAKERS = [
39
+ { name: '🇺🇸 🚺 Heart ❤️', value: 'af_heart' },
40
+ { name: '🇺🇸 🚺 Bella 🔥', value: 'af_bella' },
41
+ // { name: '🇺🇸 🚺 Nicole 🎧', value: 'af_nicole' },
42
+ { name: '🇺🇸 🚺 Aoede', value: 'af_aoede' },
43
+ { name: '🇺🇸 🚺 Kore', value: 'af_kore' },
44
+ { name: '🇺🇸 🚺 Sarah', value: 'af_sarah' },
45
+ { name: '🇺🇸 🚺 Nova', value: 'af_nova' },
46
+ { name: '🇺🇸 🚺 Sky', value: 'af_sky' },
47
+ { name: '🇺🇸 🚺 Alloy', value: 'af_alloy' },
48
+ { name: '🇺🇸 🚺 Jessica', value: 'af_jessica' },
49
+ { name: '🇺🇸 🚺 River', value: 'af_river' },
50
+ { name: '🇺🇸 🚹 Michael', value: 'am_michael' },
51
+ { name: '🇺🇸 🚹 Fenrir', value: 'am_fenrir' },
52
+ { name: '🇺🇸 🚹 Puck', value: 'am_puck' },
53
+ { name: '🇺🇸 🚹 Echo', value: 'am_echo' },
54
+ { name: '🇺🇸 🚹 Eric', value: 'am_eric' },
55
+ { name: '🇺🇸 🚹 Liam', value: 'am_liam' },
56
+ { name: '🇺🇸 🚹 Onyx', value: 'am_onyx' },
57
+ { name: '🇺🇸 🚹 Santa', value: 'am_santa' },
58
+ { name: '🇺🇸 🚹 Adam', value: 'am_adam' },
59
+ { name: '🇬🇧 🚺 Emma', value: 'bf_emma' },
60
+ { name: '🇬🇧 🚺 Isabella', value: 'bf_isabella' },
61
+ { name: '🇬🇧 🚺 Alice', value: 'bf_alice' },
62
+ { name: '🇬🇧 🚺 Lily', value: 'bf_lily' },
63
+ { name: '🇬🇧 🚹 George', value: 'bm_george' },
64
+ { name: '🇬🇧 🚹 Fable', value: 'bm_fable' },
65
+ { name: '🇬🇧 🚹 Lewis', value: 'bm_lewis' },
66
+ { name: '🇬🇧 🚹 Daniel', value: 'bm_daniel' },
67
+ ];
68
+
69
+ const getRandomSpeakerPair = (): { s1: string; s2: string } => {
70
+ const s1Gender = Math.random() > 0.5 ? '🚺' : '🚹';
71
+ const s2Gender = s1Gender === '🚺' ? '🚹' : '🚺';
72
+ const s1 = pickRand(
73
+ SPEAKERS.filter((s) => s.name.includes(s1Gender) && s.name.includes('🇺🇸'))
74
+ ).value;
75
+ const s2 = pickRand(
76
+ SPEAKERS.filter((s) => s.name.includes(s2Gender) && s.name.includes('🇺🇸'))
77
+ ).value;
78
+ return { s1, s2 };
79
+ };
80
+
81
+ const parseYAML = (yaml: string): Podcast => {
82
+ try {
83
+ return parse(yaml);
84
+ } catch (e) {
85
+ console.error(e);
86
+ throw new Error(
87
+ 'invalid YAML, please re-generate the script: ' + (e as any).message
88
+ );
89
+ }
90
+ };
91
+
92
+ const getTimestampedFilename = (ext: string) => {
93
+ return `podcast_${new Date().toISOString().replace(/:/g, "-").split(".")[0]}.${ext}`;
94
+ };
95
+
96
+
97
+ export const PodcastGenerator = ({
98
+ genratedScript,
99
+ setBusy,
100
+ blogURL,
101
+ busy,
102
+ }: {
103
+ genratedScript: string;
104
+ blogURL: string;
105
+ setBusy: (busy: boolean) => void;
106
+ busy: boolean;
107
+ }) => {
108
+ const [wav, setWav] = useState<AudioBuffer | null>(null);
109
+ const [wavBlob, setWavBlob] = useState<Blob | null>(null);
110
+ const [mp3Blob, setMp3Blob] = useState<Blob | null>(null);
111
+ const [numSteps, setNumSteps] = useState<number>(0);
112
+ const [numStepsDone, setNumStepsDone] = useState<number>(0);
113
+
114
+ const [script, setScript] = useState<string>('');
115
+ const [speaker1, setSpeaker1] = useState<string>('');
116
+ const [speaker2, setSpeaker2] = useState<string>('');
117
+ const [speed, setSpeed] = useState<string>('1.2');
118
+ const [addIntroMusic, setAddIntroMusic] = useState<boolean>(false);
119
+ const [enableDenoise, setEnableDenoise] = useState<boolean>(false);
120
+
121
+
122
+ const [blogFilePushToken, setBlogFilePushToken] = useState<string>(
123
+ localStorage.getItem('blogFilePushToken') || ''
124
+ );
125
+ const [blogCmtOutput, setBlogCmtOutput] = useState<string('');
126
+ const [uploadToBlog, setUploadToBlog] = useState<boolean>(false); // Control blog upload
127
+
128
+
129
+ useEffect(() => {
130
+ localStorage.setItem('blogFilePushToken', blogFilePushToken);
131
+ }, [blogFilePushToken]);
132
+
133
+ const setRandSpeaker = () => {
134
+ const { s1, s2 } = getRandomSpeakerPair();
135
+ setSpeaker1(s1);
136
+ setSpeaker2(s2);
137
+ };
138
+ useEffect(setRandSpeaker, []);
139
+
140
+ useEffect(() => {
141
+ setScript(genratedScript);
142
+ }, [genratedScript]);
143
+
144
+ const generatePodcast = async () => {
145
+ setWav(null);
146
+ setWavBlob(null);
147
+ setMp3Blob(null);
148
+ setBusy(true);
149
+ setBlogCmtOutput('');
150
+
151
+ // Blog mode check moved inside the upload section
152
+
153
+ let outputWav: AudioBuffer;
154
+ try {
155
+ const podcast = parseYAML(script);
156
+ const { speakerNames, turns } = podcast;
157
+ for (const turn of turns) {
158
+ // normalize it
159
+ turn.nextGapMilisecs =
160
+ Math.max(-600, Math.min(300, turn.nextGapMilisecs)) - 100;
161
+ turn.text = turn.text
162
+ .trim()
163
+ .replace(/’/g, "'")
164
+ .replace(/“/g, '"')
165
+ .replace(/”/g, '"');
166
+ }
167
+ const steps: GenerationStep[] = turns.map((turn) => ({ turn }));
168
+ setNumSteps(steps.length);
169
+ setNumStepsDone(0);
170
+ for (let i = 0; i < steps.length; i++) {
171
+ const step = steps[i];
172
+ const speakerIdx = speakerNames.indexOf(
173
+ step.turn.speakerName as string
174
+ ) as 1 | 0;
175
+ const speakerVoice = speakerIdx === 0 ? speaker1 : speaker2;
176
+ const url = await generateAudio(
177
+ step.turn.text,
178
+ speakerVoice,
179
+ parseFloat(speed)
180
+ );
181
+ step.audioBuffer = await loadWavAndDecode(url);
182
+ if (i === 0) {
183
+ outputWav = step.audioBuffer;
184
+ if (addIntroMusic) {
185
+ const openingSound = await loadWavAndDecode(openingSoundSrc);
186
+ outputWav = joinAudio(openingSound, outputWav!, -2000);
187
+ } else {
188
+ outputWav = addSilence(outputWav!, true, 200);
189
+ }
190
+ } else {
191
+ const lastStep = steps[i - 1];
192
+ outputWav = joinAudio(
193
+ outputWav!,
194
+ step.audioBuffer,
195
+ lastStep.turn.nextGapMilisecs
196
+ );
197
+ }
198
+ setNumStepsDone(i + 1);
199
+ }
200
+
201
+ // Denoise if enabled
202
+ if (enableDenoise) {
203
+ outputWav = await denoiseAudioBuffer(outputWav!);
204
+ }
205
+
206
+ setWav(outputWav! ?? null);
207
+ // Create WAV blob
208
+ const wavArrayBuffer = outputWav.getChannelData(0).buffer;
209
+ setWavBlob(new Blob([wavArrayBuffer], { type: 'audio/wav' }));
210
+
211
+ // Convert to MP3 and create MP3 blob
212
+ const mp3Data = await audioBufferToMp3(outputWav);
213
+ setMp3Blob(new Blob([mp3Data], { type: 'audio/mp3' }));
214
+
215
+ } catch (e) {
216
+ console.error(e);
217
+ alert(`Error: ${(e as any).message}`);
218
+ setWav(null);
219
+ setWavBlob(null);
220
+ setMp3Blob(null);
221
+ }
222
+ setBusy(false);
223
+ setNumStepsDone(0);
224
+ setNumSteps(0);
225
+
226
+ // Maybe upload to blog
227
+ if (isBlogMode && uploadToBlog && outputWav && blogFilePushToken) {
228
+ if (!blogURL) {
229
+ alert('Please enter a blog slug');
230
+ return; // Stop if no blog URL
231
+ }
232
+ const repoId = 'ngxson/hf-blog-podcast';
233
+ const blogSlug = blogURL.split('/blog/').pop() ?? '_noname';
234
+ const filename = `${blogSlug}.mp3`; //Use Consistent name for blog
235
+ setBlogCmtOutput(`Uploading '${filename}' ...`);
236
+ try {
237
+ await uploadFileToHub(
238
+ mp3Data, // Use mp3 data from conversion
239
+ filename,
240
+ repoId,
241
+ blogFilePushToken
242
+ );
243
+ setBlogCmtOutput(getBlogComment(filename));
244
+ } catch (uploadError) {
245
+ console.error("Upload failed:", uploadError);
246
+ setBlogCmtOutput(`Upload failed: ${(uploadError as any).message}`);
247
+ }
248
+ }
249
+ };
250
+
251
+ const isGenerating = numSteps > 0;
252
+
253
+ return (
254
+ <>
255
+ <div className="card bg-base-100 w-full shadow-xl">
256
+ <div className="card-body">
257
+ <h2 className="card-title">Step 2: Script (YAML format)</h2>
258
+
259
+ {isBlogMode && (
260
+ <>
261
+ <input
262
+ type="password"
263
+ placeholder="Repo push HF_TOKEN"
264
+ className="input input-bordered w-full"
265
+ value={blogFilePushToken}
266
+ onChange={(e) => setBlogFilePushToken(e.target.value)}
267
+ />
268
+ <div className="flex items-center gap-2">
269
+ <input
270
+ type="checkbox"
271
+ className="checkbox"
272
+ checked={uploadToBlog}
273
+ onChange={(e) => setUploadToBlog(e.target.checked)}
274
+ disabled={isGenerating || busy}
275
+ />
276
+ Upload to Blog
277
+ </div>
278
+ </>
279
+ )}
280
+
281
+ <textarea
282
+ className="textarea textarea-bordered w-full h-72 p-2"
283
+ placeholder="Type your script here..."
284
+ value={script}
285
+ onChange={(e) => setScript(e.target.value)}
286
+ ></textarea>
287
+
288
+ <div className="grid grid-cols-2 gap-4">
289
+ <label className="form-control w-full">
290
+ <div className="label">
291
+ <span className="label-text">Speaker 1</span>
292
+ </div>
293
+ <select
294
+ className="select select-bordered"
295
+ value={speaker1}
296
+ onChange={(e) => setSpeaker1(e.target.value)}
297
+ >
298
+ {SPEAKERS.map((s) => (
299
+ <option key={s.value} value={s.value}>
300
+ {s.name}
301
+ </option>
302
+ ))}
303
+ </select>
304
+ </label>
305
+
306
+ <label className="form-control w-full">
307
+ <div className="label">
308
+ <span className="label-text">Speaker 2</span>
309
+ </div>
310
+ <select
311
+ className="select select-bordered"
312
+ value={speaker2}
313
+ onChange={(e) => setSpeaker2(e.target.value)}
314
+ >
315
+ {SPEAKERS.map((s) => (
316
+ <option key={s.value} value={s.value}>
317
+ {s.name}
318
+ </option>
319
+ ))}
320
+ </select>
321
+ </label>
322
+
323
+ <button className="btn" onClick={setRandSpeaker}>
324
+ Randomize speakers
325
+ </button>
326
+
327
+ <label className="form-control w-full">
328
+ <select
329
+ className="select select-bordered"
330
+ value={speed.toString()}
331
+ onChange={(e) => setSpeed(e.target.value)}
332
+ >
333
+ {SPEEDS.map((s) => (
334
+ <option key={s.value} value={s.value.toString()}>
335
+ Speed: {s.name} ({s.value})
336
+ </option>
337
+ ))}
338
+ </select>
339
+ </label>
340
+
341
+ <div className="flex items-center gap-2">
342
+ <input
343
+ type="checkbox"
344
+ className="checkbox"
345
+ checked={addIntroMusic}
346
+ onChange={(e) => setAddIntroMusic(e.target.checked)}
347
+ disabled={isGenerating || busy}
348
+ />
349
+ Add intro music
350
+ </div>
351
+
352
+ <div className="flex items-center gap-2">
353
+ <input
354
+ type="checkbox"
355
+ className="checkbox"
356
+ checked={enableDenoise}
357
+ onChange={(e) => setEnableDenoise(e.target.checked)}
358
+ disabled={isGenerating || busy}
359
+ />
360
+ Enable Noise Reduction
361
+ </div>
362
+ </div>
363
+
364
+ <button
365
+ id="btn-generate-podcast"
366
+ className="btn btn-primary mt-2"
367
+ onClick={generatePodcast}
368
+ disabled={busy || !script || isGenerating}
369
+ >
370
+ {isGenerating ? (
371
+ <>
372
+ <span className="loading loading-spinner loading-sm"></span>
373
+ Generating ({numStepsDone}/{numSteps})...
374
+ </>
375
+ ) : (
376
+ 'Generate podcast'
377
+ )}
378
+ </button>
379
+
380
+ {isGenerating && (
381
+ <progress
382
+ className="progress progress-primary mt-2"
383
+ value={numStepsDone}
384
+ max={numSteps}
385
+ ></progress>
386
+ )}
387
+ </div>
388
+ </div>
389
+
390
+ {wav && (
391
+ <div className="card bg-base-100 w-full shadow-xl">
392
+ <div className="card-body">
393
+ <h2 className="card-title">Step 3: Listen to your podcast</h2>
394
+ <AudioPlayer audioBuffer={wav} />
395
+ {wavBlob && (
396
+ <a
397
+ className="btn btn-sm btn-primary ml-2"
398
+ href={URL.createObjectURL(wavBlob)}
399
+ download={getTimestampedFilename('wav')}
400
+ >
401
+ Download WAV
402
+ </a>
403
+ )}
404
+
405
+ {mp3Blob && (
406
+ <a
407
+ className="btn btn-sm btn-primary ml-2"
408
+ href={URL.createObjectURL(mp3Blob)}
409
+ download={getTimestampedFilename('mp3')}
410
+ >
411
+ Download MP3
412
+ </a>
413
+ )}
414
+
415
+ {isBlogMode && (
416
+ <div>
417
+ -------------------
418
+ <br />
419
+ <h2>Comment to be posted:</h2>
420
+ <pre className="p-2 bg-base-200 rounded-md my-2 whitespace-pre-wrap break-words">
421
+ {blogCmtOutput}
422
+ </pre>
423
+ <button
424
+ className="btn btn-sm btn-secondary"
425
+ onClick={() => copyStr(blogCmtOutput)}
426
+ >
427
+ Copy comment
428
+ </button>
429
+ </div>
430
+ )}
431
+ </div>
432
+ </div>
433
+ )}
434
+ </>
435
+ );
436
+ };
437
+
438
+ // copy text to clipboard
439
+ export const copyStr = (textToCopy: string) => {
440
+ // Navigator clipboard api needs a secure context (https)
441
+ if (navigator.clipboard && window.isSecureContext) {
442
+ navigator.clipboard.writeText(textToCopy);
443
+ } else {
444
+ // Use the 'out of viewport hidden text area' trick
445
+ const textArea = document.createElement('textarea');
446
+ textArea.value = textToCopy;
447
+ // Move textarea out of the viewport so it's not visible
448
+ textArea.style.position = 'absolute';
449
+ textArea.style.left = '-999999px';
450
+ document.body.prepend(textArea);
451
+ textArea.select();
452
+ document.execCommand('copy');
453
+ }
454
+ };