|
import { useEffect, useState } from 'react';
|
|
import { AudioPlayer } from './AudioPlayer';
|
|
import { Podcast, PodcastTurn } from '../utils/types';
|
|
import { parse } from 'yaml';
|
|
import {
|
|
addNoise,
|
|
addSilence,
|
|
audioBufferToMp3,
|
|
generateAudio,
|
|
isBlogMode,
|
|
joinAudio,
|
|
loadWavAndDecode,
|
|
pickRand,
|
|
uploadFileToHub,
|
|
denoiseAudioBuffer,
|
|
} from '../utils/utils';
|
|
|
|
|
|
import openingSoundSrc from '../opening-sound.wav';
|
|
import { getBlogComment } from '../utils/prompts';
|
|
|
|
interface GenerationStep {
|
|
turn: PodcastTurn;
|
|
audioBuffer?: AudioBuffer;
|
|
}
|
|
|
|
const SPEEDS = [
|
|
{ name: 'slow AF', value: 0.8 },
|
|
{ name: 'slow', value: 0.9 },
|
|
{ name: 'a bit slow', value: 1.0 },
|
|
{ name: 'natural', value: 1.1 },
|
|
{ name: 'most natural', value: 1.2 },
|
|
{ name: 'a bit fast', value: 1.3 },
|
|
{ name: 'fast!', value: 1.4 },
|
|
{ name: 'fast AF', value: 1.5 },
|
|
];
|
|
|
|
const SPEAKERS = [
|
|
{ name: 'πΊπΈ πΊ Heart β€οΈ', value: 'af_heart' },
|
|
{ name: 'πΊπΈ πΊ Bella π₯', value: 'af_bella' },
|
|
|
|
{ name: 'πΊπΈ πΊ Aoede', value: 'af_aoede' },
|
|
{ name: 'πΊπΈ πΊ Kore', value: 'af_kore' },
|
|
{ name: 'πΊπΈ πΊ Sarah', value: 'af_sarah' },
|
|
{ name: 'πΊπΈ πΊ Nova', value: 'af_nova' },
|
|
{ name: 'πΊπΈ πΊ Sky', value: 'af_sky' },
|
|
{ name: 'πΊπΈ πΊ Alloy', value: 'af_alloy' },
|
|
{ name: 'πΊπΈ πΊ Jessica', value: 'af_jessica' },
|
|
{ name: 'πΊπΈ πΊ River', value: 'af_river' },
|
|
{ name: 'πΊπΈ πΉ Michael', value: 'am_michael' },
|
|
{ name: 'πΊπΈ πΉ Fenrir', value: 'am_fenrir' },
|
|
{ name: 'πΊπΈ πΉ Puck', value: 'am_puck' },
|
|
{ name: 'πΊπΈ πΉ Echo', value: 'am_echo' },
|
|
{ name: 'πΊπΈ πΉ Eric', value: 'am_eric' },
|
|
{ name: 'πΊπΈ πΉ Liam', value: 'am_liam' },
|
|
{ name: 'πΊπΈ πΉ Onyx', value: 'am_onyx' },
|
|
{ name: 'πΊπΈ πΉ Santa', value: 'am_santa' },
|
|
{ name: 'πΊπΈ πΉ Adam', value: 'am_adam' },
|
|
{ name: 'π¬π§ πΊ Emma', value: 'bf_emma' },
|
|
{ name: 'π¬π§ πΊ Isabella', value: 'bf_isabella' },
|
|
{ name: 'π¬π§ πΊ Alice', value: 'bf_alice' },
|
|
{ name: 'π¬π§ πΊ Lily', value: 'bf_lily' },
|
|
{ name: 'π¬π§ πΉ George', value: 'bm_george' },
|
|
{ name: 'π¬π§ πΉ Fable', value: 'bm_fable' },
|
|
{ name: 'π¬π§ πΉ Lewis', value: 'bm_lewis' },
|
|
{ name: 'π¬π§ πΉ Daniel', value: 'bm_daniel' },
|
|
];
|
|
|
|
const getRandomSpeakerPair = (): { s1: string; s2: string } => {
|
|
const s1Gender = Math.random() > 0.5 ? 'πΊ' : 'πΉ';
|
|
const s2Gender = s1Gender === 'πΊ' ? 'πΉ' : 'πΊ';
|
|
const s1 = pickRand(
|
|
SPEAKERS.filter((s) => s.name.includes(s1Gender) && s.name.includes('πΊπΈ'))
|
|
).value;
|
|
const s2 = pickRand(
|
|
SPEAKERS.filter((s) => s.name.includes(s2Gender) && s.name.includes('πΊπΈ'))
|
|
).value;
|
|
return { s1, s2 };
|
|
};
|
|
|
|
const parseYAML = (yaml: string): Podcast => {
|
|
try {
|
|
return parse(yaml);
|
|
} catch (e) {
|
|
console.error(e);
|
|
throw new Error(
|
|
'invalid YAML, please re-generate the script: ' + (e as any).message
|
|
);
|
|
}
|
|
};
|
|
|
|
const getTimestampedFilename = (ext: string) => {
|
|
return `podcast_${new Date().toISOString().replace(/:/g, "-").split(".")[0]}.${ext}`;
|
|
};
|
|
|
|
|
|
export const PodcastGenerator = ({
|
|
genratedScript,
|
|
setBusy,
|
|
blogURL,
|
|
busy,
|
|
}: {
|
|
genratedScript: string;
|
|
blogURL: string;
|
|
setBusy: (busy: boolean) => void;
|
|
busy: boolean;
|
|
}) => {
|
|
const [wav, setWav] = useState<AudioBuffer | null>(null);
|
|
const [wavBlob, setWavBlob] = useState<Blob | null>(null);
|
|
const [mp3Blob, setMp3Blob] = useState<Blob | null>(null);
|
|
const [numSteps, setNumSteps] = useState<number>(0);
|
|
const [numStepsDone, setNumStepsDone] = useState<number>(0);
|
|
|
|
const [script, setScript] = useState<string>('');
|
|
const [speaker1, setSpeaker1] = useState<string>('');
|
|
const [speaker2, setSpeaker2] = useState<string>('');
|
|
const [speed, setSpeed] = useState<string>('1.2');
|
|
const [addIntroMusic, setAddIntroMusic] = useState<boolean>(false);
|
|
const [enableDenoise, setEnableDenoise] = useState<boolean>(false);
|
|
|
|
|
|
const [blogFilePushToken, setBlogFilePushToken] = useState<string>(
|
|
localStorage.getItem('blogFilePushToken') || ''
|
|
);
|
|
const [blogCmtOutput, setBlogCmtOutput] = useState<string('');
|
|
const [uploadToBlog, setUploadToBlog] = useState<boolean>(false);
|
|
|
|
|
|
useEffect(() => {
|
|
localStorage.setItem('blogFilePushToken', blogFilePushToken);
|
|
}, [blogFilePushToken]);
|
|
|
|
const setRandSpeaker = () => {
|
|
const { s1, s2 } = getRandomSpeakerPair();
|
|
setSpeaker1(s1);
|
|
setSpeaker2(s2);
|
|
};
|
|
useEffect(setRandSpeaker, []);
|
|
|
|
useEffect(() => {
|
|
setScript(genratedScript);
|
|
}, [genratedScript]);
|
|
|
|
const generatePodcast = async () => {
|
|
setWav(null);
|
|
setWavBlob(null);
|
|
setMp3Blob(null);
|
|
setBusy(true);
|
|
setBlogCmtOutput('');
|
|
|
|
|
|
|
|
let outputWav: AudioBuffer;
|
|
try {
|
|
const podcast = parseYAML(script);
|
|
const { speakerNames, turns } = podcast;
|
|
for (const turn of turns) {
|
|
|
|
turn.nextGapMilisecs =
|
|
Math.max(-600, Math.min(300, turn.nextGapMilisecs)) - 100;
|
|
turn.text = turn.text
|
|
.trim()
|
|
.replace(/β/g, "'")
|
|
.replace(/β/g, '"')
|
|
.replace(/β/g, '"');
|
|
}
|
|
const steps: GenerationStep[] = turns.map((turn) => ({ turn }));
|
|
setNumSteps(steps.length);
|
|
setNumStepsDone(0);
|
|
for (let i = 0; i < steps.length; i++) {
|
|
const step = steps[i];
|
|
const speakerIdx = speakerNames.indexOf(
|
|
step.turn.speakerName as string
|
|
) as 1 | 0;
|
|
const speakerVoice = speakerIdx === 0 ? speaker1 : speaker2;
|
|
const url = await generateAudio(
|
|
step.turn.text,
|
|
speakerVoice,
|
|
parseFloat(speed)
|
|
);
|
|
step.audioBuffer = await loadWavAndDecode(url);
|
|
if (i === 0) {
|
|
outputWav = step.audioBuffer;
|
|
if (addIntroMusic) {
|
|
const openingSound = await loadWavAndDecode(openingSoundSrc);
|
|
outputWav = joinAudio(openingSound, outputWav!, -2000);
|
|
} else {
|
|
outputWav = addSilence(outputWav!, true, 200);
|
|
}
|
|
} else {
|
|
const lastStep = steps[i - 1];
|
|
outputWav = joinAudio(
|
|
outputWav!,
|
|
step.audioBuffer,
|
|
lastStep.turn.nextGapMilisecs
|
|
);
|
|
}
|
|
setNumStepsDone(i + 1);
|
|
}
|
|
|
|
|
|
if (enableDenoise) {
|
|
outputWav = await denoiseAudioBuffer(outputWav!);
|
|
}
|
|
|
|
setWav(outputWav! ?? null);
|
|
|
|
const wavArrayBuffer = outputWav.getChannelData(0).buffer;
|
|
setWavBlob(new Blob([wavArrayBuffer], { type: 'audio/wav' }));
|
|
|
|
|
|
const mp3Data = await audioBufferToMp3(outputWav);
|
|
setMp3Blob(new Blob([mp3Data], { type: 'audio/mp3' }));
|
|
|
|
} catch (e) {
|
|
console.error(e);
|
|
alert(`Error: ${(e as any).message}`);
|
|
setWav(null);
|
|
setWavBlob(null);
|
|
setMp3Blob(null);
|
|
}
|
|
setBusy(false);
|
|
setNumStepsDone(0);
|
|
setNumSteps(0);
|
|
|
|
|
|
if (isBlogMode && uploadToBlog && outputWav && blogFilePushToken) {
|
|
if (!blogURL) {
|
|
alert('Please enter a blog slug');
|
|
return;
|
|
}
|
|
const repoId = 'ngxson/hf-blog-podcast';
|
|
const blogSlug = blogURL.split('/blog/').pop() ?? '_noname';
|
|
const filename = `${blogSlug}.mp3`;
|
|
setBlogCmtOutput(`Uploading '${filename}' ...`);
|
|
try {
|
|
await uploadFileToHub(
|
|
mp3Data,
|
|
filename,
|
|
repoId,
|
|
blogFilePushToken
|
|
);
|
|
setBlogCmtOutput(getBlogComment(filename));
|
|
} catch (uploadError) {
|
|
console.error("Upload failed:", uploadError);
|
|
setBlogCmtOutput(`Upload failed: ${(uploadError as any).message}`);
|
|
}
|
|
}
|
|
};
|
|
|
|
const isGenerating = numSteps > 0;
|
|
|
|
return (
|
|
<>
|
|
<div className="card bg-base-100 w-full shadow-xl">
|
|
<div className="card-body">
|
|
<h2 className="card-title">Step 2: Script (YAML format)</h2>
|
|
|
|
{isBlogMode && (
|
|
<>
|
|
<input
|
|
type="password"
|
|
placeholder="Repo push HF_TOKEN"
|
|
className="input input-bordered w-full"
|
|
value={blogFilePushToken}
|
|
onChange={(e) => setBlogFilePushToken(e.target.value)}
|
|
/>
|
|
<div className="flex items-center gap-2">
|
|
<input
|
|
type="checkbox"
|
|
className="checkbox"
|
|
checked={uploadToBlog}
|
|
onChange={(e) => setUploadToBlog(e.target.checked)}
|
|
disabled={isGenerating || busy}
|
|
/>
|
|
Upload to Blog
|
|
</div>
|
|
</>
|
|
)}
|
|
|
|
<textarea
|
|
className="textarea textarea-bordered w-full h-72 p-2"
|
|
placeholder="Type your script here..."
|
|
value={script}
|
|
onChange={(e) => setScript(e.target.value)}
|
|
></textarea>
|
|
|
|
<div className="grid grid-cols-2 gap-4">
|
|
<label className="form-control w-full">
|
|
<div className="label">
|
|
<span className="label-text">Speaker 1</span>
|
|
</div>
|
|
<select
|
|
className="select select-bordered"
|
|
value={speaker1}
|
|
onChange={(e) => setSpeaker1(e.target.value)}
|
|
>
|
|
{SPEAKERS.map((s) => (
|
|
<option key={s.value} value={s.value}>
|
|
{s.name}
|
|
</option>
|
|
))}
|
|
</select>
|
|
</label>
|
|
|
|
<label className="form-control w-full">
|
|
<div className="label">
|
|
<span className="label-text">Speaker 2</span>
|
|
</div>
|
|
<select
|
|
className="select select-bordered"
|
|
value={speaker2}
|
|
onChange={(e) => setSpeaker2(e.target.value)}
|
|
>
|
|
{SPEAKERS.map((s) => (
|
|
<option key={s.value} value={s.value}>
|
|
{s.name}
|
|
</option>
|
|
))}
|
|
</select>
|
|
</label>
|
|
|
|
<button className="btn" onClick={setRandSpeaker}>
|
|
Randomize speakers
|
|
</button>
|
|
|
|
<label className="form-control w-full">
|
|
<select
|
|
className="select select-bordered"
|
|
value={speed.toString()}
|
|
onChange={(e) => setSpeed(e.target.value)}
|
|
>
|
|
{SPEEDS.map((s) => (
|
|
<option key={s.value} value={s.value.toString()}>
|
|
Speed: {s.name} ({s.value})
|
|
</option>
|
|
))}
|
|
</select>
|
|
</label>
|
|
|
|
<div className="flex items-center gap-2">
|
|
<input
|
|
type="checkbox"
|
|
className="checkbox"
|
|
checked={addIntroMusic}
|
|
onChange={(e) => setAddIntroMusic(e.target.checked)}
|
|
disabled={isGenerating || busy}
|
|
/>
|
|
Add intro music
|
|
</div>
|
|
|
|
<div className="flex items-center gap-2">
|
|
<input
|
|
type="checkbox"
|
|
className="checkbox"
|
|
checked={enableDenoise}
|
|
onChange={(e) => setEnableDenoise(e.target.checked)}
|
|
disabled={isGenerating || busy}
|
|
/>
|
|
Enable Noise Reduction
|
|
</div>
|
|
</div>
|
|
|
|
<button
|
|
id="btn-generate-podcast"
|
|
className="btn btn-primary mt-2"
|
|
onClick={generatePodcast}
|
|
disabled={busy || !script || isGenerating}
|
|
>
|
|
{isGenerating ? (
|
|
<>
|
|
<span className="loading loading-spinner loading-sm"></span>
|
|
Generating ({numStepsDone}/{numSteps})...
|
|
</>
|
|
) : (
|
|
'Generate podcast'
|
|
)}
|
|
</button>
|
|
|
|
{isGenerating && (
|
|
<progress
|
|
className="progress progress-primary mt-2"
|
|
value={numStepsDone}
|
|
max={numSteps}
|
|
></progress>
|
|
)}
|
|
</div>
|
|
</div>
|
|
|
|
{wav && (
|
|
<div className="card bg-base-100 w-full shadow-xl">
|
|
<div className="card-body">
|
|
<h2 className="card-title">Step 3: Listen to your podcast</h2>
|
|
<AudioPlayer audioBuffer={wav} />
|
|
{wavBlob && (
|
|
<a
|
|
className="btn btn-sm btn-primary ml-2"
|
|
href={URL.createObjectURL(wavBlob)}
|
|
download={getTimestampedFilename('wav')}
|
|
>
|
|
Download WAV
|
|
</a>
|
|
)}
|
|
|
|
{mp3Blob && (
|
|
<a
|
|
className="btn btn-sm btn-primary ml-2"
|
|
href={URL.createObjectURL(mp3Blob)}
|
|
download={getTimestampedFilename('mp3')}
|
|
>
|
|
Download MP3
|
|
</a>
|
|
)}
|
|
|
|
{isBlogMode && (
|
|
<div>
|
|
-------------------
|
|
<br />
|
|
<h2>Comment to be posted:</h2>
|
|
<pre className="p-2 bg-base-200 rounded-md my-2 whitespace-pre-wrap break-words">
|
|
{blogCmtOutput}
|
|
</pre>
|
|
<button
|
|
className="btn btn-sm btn-secondary"
|
|
onClick={() => copyStr(blogCmtOutput)}
|
|
>
|
|
Copy comment
|
|
</button>
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
)}
|
|
</>
|
|
);
|
|
};
|
|
|
|
|
|
export const copyStr = (textToCopy: string) => {
|
|
|
|
if (navigator.clipboard && window.isSecureContext) {
|
|
navigator.clipboard.writeText(textToCopy);
|
|
} else {
|
|
|
|
const textArea = document.createElement('textarea');
|
|
textArea.value = textToCopy;
|
|
|
|
textArea.style.position = 'absolute';
|
|
textArea.style.left = '-999999px';
|
|
document.body.prepend(textArea);
|
|
textArea.select();
|
|
document.execCommand('copy');
|
|
}
|
|
}; |