Spaces:
Running
Running
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'); | |
} | |
}; | |