AcTePuKc commited on
Commit
6a5850b
·
verified ·
1 Parent(s): da4e284

Upload 2 files

Browse files

For AudioPlayer.tsx:
Refactor: Improved AudioPlayer with dynamic filename and simplified download.
Added an optional filename prop to allow custom download filenames (used for timestamped names).
Reused the blobUrl for both audio playback and download, eliminating redundant code.
Corrected the useEffect cleanup to only revoke the necessary URL.

For PodcastGenerator.tsx:
Feat: Integrated denoising, MP3/WAV downloads, and controlled blog upload.
Combined original and suggested edits for a complete podcast generation component.
Added wavBlob and mp3Blob states for WAV and MP3 download functionality.
Implemented download links for both WAV and MP3 formats, using timestamped filenames.
Integrated optional denoising using the audio-denoiser library (requires denoiseAudioBuffer implementation in utils.ts).
Added an uploadToBlog checkbox to explicitly control blog uploads, enhancing user privacy and control. Upload only happens if isBlogMode, the checkbox is checked, a token is provided, and a blog URL is entered.
Improved error handling during the upload process.
Cleaned up state management for a more consistent UI.

Untested by me - as I don't have local react server.

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
+ };