|
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(() => { |
|
|
|
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); |
|
|
|
|
|
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> |
|
)} |
|
</> |
|
); |
|
}; |
|
|
|
|
|
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'); |
|
} |
|
}; |
|
|