|
import { useEffect, useState } from 'react'; |
|
import { AudioPlayer } from './AudioPlayer'; |
|
import { Podcast, PodcastTurn } from '../utils/types'; |
|
import { parse } from 'yaml'; |
|
import { |
|
generateAudio, |
|
joinAudio, |
|
loadWavAndDecode, |
|
pickRand, |
|
} from '../utils/utils'; |
|
|
|
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: 'most natural', value: 1.1 }, |
|
{ name: 'a bit fast', value: 1.2 }, |
|
{ name: 'fast!', value: 1.3 }, |
|
{ name: 'fast AF', value: 1.4 }, |
|
]; |
|
|
|
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 }; |
|
}; |
|
|
|
export const PodcastGenerator = ({ |
|
genratedScript, |
|
setBusy, |
|
busy, |
|
}: { |
|
genratedScript: string; |
|
setBusy: (busy: boolean) => void; |
|
busy: boolean; |
|
}) => { |
|
const [wav, setWav] = useState<AudioBuffer | 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.1'); |
|
|
|
const setRandSpeaker = () => { |
|
const { s1, s2 } = getRandomSpeakerPair(); |
|
setSpeaker1(s1); |
|
setSpeaker2(s2); |
|
}; |
|
useEffect(setRandSpeaker, []); |
|
|
|
useEffect(() => { |
|
setScript(genratedScript); |
|
}, [genratedScript]); |
|
|
|
const generatePodcast = async () => { |
|
setWav(null); |
|
setBusy(true); |
|
try { |
|
const podcast: Podcast = parse(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); |
|
let outputWav: AudioBuffer; |
|
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; |
|
} else { |
|
const lastStep = steps[i - 1]; |
|
outputWav = joinAudio( |
|
outputWav!, |
|
step.audioBuffer, |
|
lastStep.turn.nextGapMilisecs / 1000 |
|
); |
|
} |
|
setNumStepsDone(i + 1); |
|
} |
|
setWav(outputWav! ?? null); |
|
} catch (e) { |
|
console.error(e); |
|
alert(`Error: ${(e as any).message}`); |
|
setWav(null); |
|
} |
|
setBusy(false); |
|
setNumStepsDone(0); |
|
setNumSteps(0); |
|
}; |
|
|
|
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> |
|
<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> |
|
</div> |
|
|
|
<div className="grid grid-cols-2 gap-4"> |
|
<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> |
|
|
|
<button |
|
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} /> |
|
</div> |
|
</div> |
|
)} |
|
</> |
|
); |
|
}; |
|
|