AcTePuKc's picture
Upload 2 files
6a5850b verified
raw
history blame
15.2 kB
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';
// taken from https://freesound.org/people/artxmp1/sounds/660540
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: 'πŸ‡ΊπŸ‡Έ 🚺 Nicole 🎧', value: 'af_nicole' },
{ 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); // Control blog upload
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('');
// Blog mode check moved inside the upload section
let outputWav: AudioBuffer;
try {
const podcast = parseYAML(script);
const { speakerNames, turns } = podcast;
for (const turn of turns) {
// normalize it
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);
}
// Denoise if enabled
if (enableDenoise) {
outputWav = await denoiseAudioBuffer(outputWav!);
}
setWav(outputWav! ?? null);
// Create WAV blob
const wavArrayBuffer = outputWav.getChannelData(0).buffer;
setWavBlob(new Blob([wavArrayBuffer], { type: 'audio/wav' }));
// Convert to MP3 and create MP3 blob
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);
// Maybe upload to blog
if (isBlogMode && uploadToBlog && outputWav && blogFilePushToken) {
if (!blogURL) {
alert('Please enter a blog slug');
return; // Stop if no blog URL
}
const repoId = 'ngxson/hf-blog-podcast';
const blogSlug = blogURL.split('/blog/').pop() ?? '_noname';
const filename = `${blogSlug}.mp3`; //Use Consistent name for blog
setBlogCmtOutput(`Uploading '${filename}' ...`);
try {
await uploadFileToHub(
mp3Data, // Use mp3 data from conversion
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>
)}
</>
);
};
// copy text to clipboard
export const copyStr = (textToCopy: string) => {
// Navigator clipboard api needs a secure context (https)
if (navigator.clipboard && window.isSecureContext) {
navigator.clipboard.writeText(textToCopy);
} else {
// Use the 'out of viewport hidden text area' trick
const textArea = document.createElement('textarea');
textArea.value = textToCopy;
// Move textarea out of the viewport so it's not visible
textArea.style.position = 'absolute';
textArea.style.left = '-999999px';
document.body.prepend(textArea);
textArea.select();
document.execCommand('copy');
}
};