ngxson's picture
ngxson HF staff
filter better voices
9eb519e
import { useEffect, useRef, useState } from 'react';
import { AudioPlayer } from './AudioPlayer';
import { Podcast } from '../utils/types';
import { parse } from 'yaml';
import {
audioBufferToMp3,
isBlogMode,
pickRand,
uploadFileToHub,
} from '../utils/utils';
import { getBlogComment } from '../utils/prompts';
import { pipelineGeneratePodcast } from '../utils/pipeline';
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
);
}
};
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 [outTitle, setOutTitle] = useState<string>('');
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 [isAddIntroMusic, setIsAddIntroMusic] = useState<boolean>(false);
const [isAddNoise, setIsAddNoise] = useState<boolean>(true);
const refInput = useRef<HTMLTextAreaElement | null>(null);
const [blogFilePushToken, setBlogFilePushToken] = useState<string>(
localStorage.getItem('blogFilePushToken') || ''
);
const [blogCmtOutput, setBlogCmtOutput] = useState<string>('');
useEffect(() => {
localStorage.setItem('blogFilePushToken', blogFilePushToken);
}, [blogFilePushToken]);
const setRandSpeaker = () => {
const { s1, s2 } = getRandomSpeakerPair();
setSpeaker1(s1);
setSpeaker2(s2);
};
useEffect(setRandSpeaker, []);
useEffect(() => {
setScript(genratedScript);
setTimeout(() => {
// auto scroll
if (refInput.current) {
refInput.current.scrollTop = refInput.current.scrollHeight;
}
}, 10);
}, [genratedScript]);
const generatePodcast = async () => {
setWav(null);
setBusy(true);
setBlogCmtOutput('');
if (isBlogMode && !blogURL) {
alert('Please enter a blog slug');
setBusy(false);
return;
}
let outputWav: AudioBuffer;
try {
const podcast = parseYAML(script);
setOutTitle(podcast.title ?? 'Untitled podcast');
outputWav = await pipelineGeneratePodcast(
{
podcast,
speaker1,
speaker2,
speed: parseFloat(speed),
isAddIntroMusic,
isAddNoise,
},
(done: number, total: number) => {
setNumStepsDone(done);
setNumSteps(total);
}
);
setWav(outputWav! ?? null);
} catch (e) {
console.error(e);
alert(`Error: ${(e as any).message}`);
setWav(null);
}
setBusy(false);
setNumStepsDone(0);
setNumSteps(0);
// maybe upload
if (isBlogMode && outputWav!) {
const repoId = 'ngxson/hf-blog-podcast';
const blogSlug = blogURL.split('/blog/').pop() ?? '_noname';
const filename = `${blogSlug}.mp3`;
setBlogCmtOutput(`Uploading '${filename}' ...`);
await uploadFileToHub(
audioBufferToMp3(outputWav),
filename,
repoId,
blogFilePushToken
);
setBlogCmtOutput(getBlogComment(filename));
}
};
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)}
/>
</>
)}
<textarea
ref={refInput}
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 (πŸ‘ is better)</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 (πŸ‘ is better)</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={isAddIntroMusic}
onChange={(e) => setIsAddIntroMusic(e.target.checked)}
disabled={isGenerating || busy}
/>
Add intro music (to make it feels like radio)
</div>
<div className="flex items-center gap-2">
<input
type="checkbox"
className="checkbox"
checked={isAddNoise}
onChange={(e) => setIsAddNoise(e.target.checked)}
disabled={isGenerating || busy}
/>
Add small background noise (to make it more realistic)
</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} title={outTitle} />
{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');
}
};