Upload 2 files
Browse filesFor 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.
@@ -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 |
-
|
9 |
-
|
10 |
-
|
11 |
-
|
12 |
-
|
13 |
-
|
14 |
-
|
15 |
-
|
16 |
-
|
17 |
-
|
18 |
-
|
19 |
-
|
20 |
-
|
21 |
-
|
22 |
-
|
23 |
-
|
24 |
-
|
25 |
-
|
26 |
-
|
27 |
-
|
28 |
-
|
29 |
-
|
30 |
-
<
|
31 |
-
|
32 |
-
|
33 |
-
|
34 |
-
|
35 |
-
|
36 |
-
|
37 |
-
|
38 |
-
|
39 |
-
|
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 |
+
};
|
|
|
|
|
|
|
|
@@ -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 |
-
|
16 |
-
|
17 |
-
|
18 |
-
|
19 |
-
import
|
20 |
-
|
21 |
-
|
22 |
-
|
23 |
-
|
24 |
-
|
25 |
-
|
26 |
-
|
27 |
-
|
28 |
-
{ name: 'slow', value: 0.
|
29 |
-
{ name: '
|
30 |
-
{ name: '
|
31 |
-
{ name: '
|
32 |
-
{ name: '
|
33 |
-
{ name: 'fast
|
34 |
-
{ name: 'fast
|
35 |
-
|
36 |
-
|
37 |
-
|
38 |
-
|
39 |
-
{ name: '🇺🇸 🚺
|
40 |
-
|
41 |
-
{ name: '🇺🇸 🚺
|
42 |
-
{ name: '🇺🇸 🚺
|
43 |
-
{ name: '🇺🇸 🚺
|
44 |
-
{ name: '🇺🇸 🚺
|
45 |
-
{ name: '🇺🇸 🚺
|
46 |
-
{ name: '🇺🇸 🚺
|
47 |
-
{ name: '🇺🇸 🚺
|
48 |
-
{ name: '🇺🇸 🚺
|
49 |
-
{ name: '🇺🇸
|
50 |
-
{ name: '🇺🇸 🚹
|
51 |
-
{ name: '🇺🇸 🚹
|
52 |
-
{ name: '🇺🇸 🚹
|
53 |
-
{ name: '🇺🇸 🚹
|
54 |
-
{ name: '🇺🇸 🚹
|
55 |
-
{ name: '🇺🇸 🚹
|
56 |
-
{ name: '🇺🇸 🚹
|
57 |
-
{ name: '🇺🇸 🚹
|
58 |
-
{ name: '
|
59 |
-
{ name: '🇬🇧 🚺
|
60 |
-
{ name: '🇬🇧 🚺
|
61 |
-
{ name: '🇬🇧 🚺
|
62 |
-
{ name: '🇬🇧
|
63 |
-
{ name: '🇬🇧 🚹
|
64 |
-
{ name: '🇬🇧 🚹
|
65 |
-
{ name: '🇬🇧 🚹
|
66 |
-
|
67 |
-
|
68 |
-
|
69 |
-
|
70 |
-
const
|
71 |
-
const
|
72 |
-
|
73 |
-
|
74 |
-
|
75 |
-
|
76 |
-
|
77 |
-
|
78 |
-
};
|
79 |
-
|
80 |
-
|
81 |
-
|
82 |
-
|
83 |
-
|
84 |
-
|
85 |
-
|
86 |
-
|
87 |
-
|
88 |
-
|
89 |
-
}
|
90 |
-
|
91 |
-
|
92 |
-
|
93 |
-
|
94 |
-
|
95 |
-
|
96 |
-
|
97 |
-
|
98 |
-
|
99 |
-
setBusy
|
100 |
-
|
101 |
-
|
102 |
-
|
103 |
-
|
104 |
-
|
105 |
-
|
106 |
-
|
107 |
-
|
108 |
-
const [
|
109 |
-
|
110 |
-
|
111 |
-
|
112 |
-
const [
|
113 |
-
|
114 |
-
);
|
115 |
-
const [
|
116 |
-
|
117 |
-
|
118 |
-
|
119 |
-
|
120 |
-
|
121 |
-
|
122 |
-
|
123 |
-
|
124 |
-
|
125 |
-
|
126 |
-
|
127 |
-
|
128 |
-
|
129 |
-
|
130 |
-
|
131 |
-
|
132 |
-
|
133 |
-
|
134 |
-
|
135 |
-
|
136 |
-
|
137 |
-
|
138 |
-
|
139 |
-
|
140 |
-
|
141 |
-
|
142 |
-
|
143 |
-
|
144 |
-
|
145 |
-
|
146 |
-
|
147 |
-
|
148 |
-
|
149 |
-
|
150 |
-
|
151 |
-
|
152 |
-
|
153 |
-
|
154 |
-
|
155 |
-
|
156 |
-
|
157 |
-
for (
|
158 |
-
|
159 |
-
|
160 |
-
|
161 |
-
|
162 |
-
|
163 |
-
|
164 |
-
|
165 |
-
|
166 |
-
|
167 |
-
|
168 |
-
|
169 |
-
|
170 |
-
|
171 |
-
|
172 |
-
|
173 |
-
|
174 |
-
|
175 |
-
|
176 |
-
|
177 |
-
|
178 |
-
|
179 |
-
|
180 |
-
|
181 |
-
|
182 |
-
|
183 |
-
|
184 |
-
|
185 |
-
|
186 |
-
|
187 |
-
|
188 |
-
|
189 |
-
|
190 |
-
|
191 |
-
|
192 |
-
|
193 |
-
|
194 |
-
|
195 |
-
|
196 |
-
|
197 |
-
|
198 |
-
|
199 |
-
|
200 |
-
|
201 |
-
|
202 |
-
|
203 |
-
|
204 |
-
|
205 |
-
|
206 |
-
|
207 |
-
|
208 |
-
|
209 |
-
|
210 |
-
|
211 |
-
|
212 |
-
|
213 |
-
|
214 |
-
|
215 |
-
|
216 |
-
|
217 |
-
|
218 |
-
|
219 |
-
|
220 |
-
|
221 |
-
|
222 |
-
|
223 |
-
|
224 |
-
|
225 |
-
|
226 |
-
|
227 |
-
|
228 |
-
|
229 |
-
|
230 |
-
|
231 |
-
|
232 |
-
|
233 |
-
|
234 |
-
|
235 |
-
|
236 |
-
|
237 |
-
|
238 |
-
|
239 |
-
|
240 |
-
|
241 |
-
|
242 |
-
|
243 |
-
|
244 |
-
|
245 |
-
|
246 |
-
|
247 |
-
|
248 |
-
|
249 |
-
|
250 |
-
|
251 |
-
|
252 |
-
|
253 |
-
|
254 |
-
|
255 |
-
|
256 |
-
|
257 |
-
|
258 |
-
|
259 |
-
|
260 |
-
|
261 |
-
|
262 |
-
|
263 |
-
|
264 |
-
className="
|
265 |
-
value={
|
266 |
-
onChange={(e) =>
|
267 |
-
|
268 |
-
|
269 |
-
|
270 |
-
|
271 |
-
|
272 |
-
|
273 |
-
|
274 |
-
|
275 |
-
|
276 |
-
|
277 |
-
|
278 |
-
|
279 |
-
|
280 |
-
|
281 |
-
|
282 |
-
|
283 |
-
|
284 |
-
|
285 |
-
|
286 |
-
|
287 |
-
|
288 |
-
|
289 |
-
|
290 |
-
|
291 |
-
|
292 |
-
|
293 |
-
|
294 |
-
|
295 |
-
|
296 |
-
|
297 |
-
|
298 |
-
|
299 |
-
|
300 |
-
|
301 |
-
|
302 |
-
|
303 |
-
|
304 |
-
|
305 |
-
|
306 |
-
|
307 |
-
|
308 |
-
|
309 |
-
|
310 |
-
|
311 |
-
|
312 |
-
|
313 |
-
|
314 |
-
|
315 |
-
|
316 |
-
|
317 |
-
|
318 |
-
|
319 |
-
|
320 |
-
|
321 |
-
|
322 |
-
|
323 |
-
<
|
324 |
-
|
325 |
-
|
326 |
-
|
327 |
-
|
328 |
-
|
329 |
-
|
330 |
-
|
331 |
-
|
332 |
-
|
333 |
-
|
334 |
-
|
335 |
-
|
336 |
-
|
337 |
-
|
338 |
-
|
339 |
-
|
340 |
-
|
341 |
-
|
342 |
-
|
343 |
-
|
344 |
-
|
345 |
-
|
346 |
-
|
347 |
-
|
348 |
-
|
349 |
-
|
350 |
-
|
351 |
-
|
352 |
-
|
353 |
-
|
354 |
-
|
355 |
-
|
356 |
-
|
357 |
-
|
358 |
-
|
359 |
-
|
360 |
-
|
361 |
-
|
362 |
-
|
363 |
-
|
364 |
-
|
365 |
-
|
366 |
-
|
367 |
-
|
368 |
-
|
369 |
-
|
370 |
-
|
371 |
-
|
372 |
-
|
373 |
-
|
374 |
-
|
375 |
-
|
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 |
+
};
|