fix filename, add convert to mp3
Browse files- front/src/components/AudioPlayer.tsx +59 -11
- front/src/components/PodcastGenerator.tsx +3 -1
- front/src/utils/utils.ts +7 -0
- index.html +51 -9
front/src/components/AudioPlayer.tsx
CHANGED
|
@@ -1,30 +1,59 @@
|
|
| 1 |
-
import React, { useMemo, useEffect } from 'react';
|
| 2 |
-
import {
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 3 |
|
| 4 |
interface AudioPlayerProps {
|
| 5 |
audioBuffer: AudioBuffer;
|
|
|
|
| 6 |
}
|
| 7 |
|
| 8 |
-
export const AudioPlayer: React.FC<AudioPlayerProps> = ({
|
| 9 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 10 |
const blobUrl = useMemo(() => {
|
| 11 |
const wavBlob = blobFromAudioBuffer(audioBuffer);
|
| 12 |
return URL.createObjectURL(wavBlob);
|
| 13 |
}, [audioBuffer]);
|
| 14 |
|
| 15 |
-
const
|
| 16 |
const wavBlob = blobFromAudioBuffer(audioBuffer);
|
| 17 |
return URL.createObjectURL(wavBlob);
|
| 18 |
}, [audioBuffer]);
|
| 19 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 20 |
// Clean up the object URL when the component unmounts or audioBuffer changes.
|
| 21 |
useEffect(() => {
|
| 22 |
-
return () =>
|
| 23 |
-
URL.revokeObjectURL(blobUrl);
|
| 24 |
-
URL.revokeObjectURL(downloadUrl);
|
| 25 |
-
};
|
| 26 |
}, [blobUrl]);
|
| 27 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 28 |
return (
|
| 29 |
<div className="mt-4 flex items-center">
|
| 30 |
<audio controls src={blobUrl}>
|
|
@@ -33,11 +62,30 @@ export const AudioPlayer: React.FC<AudioPlayerProps> = ({ audioBuffer }) => {
|
|
| 33 |
|
| 34 |
<a
|
| 35 |
className="btn btn-sm btn-primary ml-2"
|
| 36 |
-
href={
|
| 37 |
-
download={
|
| 38 |
>
|
| 39 |
Download WAV
|
| 40 |
</a>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 41 |
</div>
|
| 42 |
);
|
| 43 |
};
|
|
|
|
| 1 |
+
import React, { useMemo, useEffect, useState } from 'react';
|
| 2 |
+
import {
|
| 3 |
+
audioBufferToMp3,
|
| 4 |
+
blobFromAudioBuffer,
|
| 5 |
+
cleanupFilename,
|
| 6 |
+
delay,
|
| 7 |
+
} from '../utils/utils';
|
| 8 |
|
| 9 |
interface AudioPlayerProps {
|
| 10 |
audioBuffer: AudioBuffer;
|
| 11 |
+
title: string;
|
| 12 |
}
|
| 13 |
|
| 14 |
+
export const AudioPlayer: React.FC<AudioPlayerProps> = ({
|
| 15 |
+
audioBuffer,
|
| 16 |
+
title,
|
| 17 |
+
}) => {
|
| 18 |
+
const [isConverting, setIsConverting] = useState(false);
|
| 19 |
+
const [mp3Buffer, setMp3Buffer] = useState<ArrayBuffer | null>(null);
|
| 20 |
+
|
| 21 |
const blobUrl = useMemo(() => {
|
| 22 |
const wavBlob = blobFromAudioBuffer(audioBuffer);
|
| 23 |
return URL.createObjectURL(wavBlob);
|
| 24 |
}, [audioBuffer]);
|
| 25 |
|
| 26 |
+
const downloadWavUrl = useMemo(() => {
|
| 27 |
const wavBlob = blobFromAudioBuffer(audioBuffer);
|
| 28 |
return URL.createObjectURL(wavBlob);
|
| 29 |
}, [audioBuffer]);
|
| 30 |
|
| 31 |
+
const downloadMp3Url = useMemo(() => {
|
| 32 |
+
if (!mp3Buffer) return '';
|
| 33 |
+
const mp3Blob = new Blob([mp3Buffer], { type: 'audio/mp3' });
|
| 34 |
+
return URL.createObjectURL(mp3Blob);
|
| 35 |
+
}, [audioBuffer]);
|
| 36 |
+
|
| 37 |
// Clean up the object URL when the component unmounts or audioBuffer changes.
|
| 38 |
useEffect(() => {
|
| 39 |
+
return () => URL.revokeObjectURL(blobUrl);
|
|
|
|
|
|
|
|
|
|
| 40 |
}, [blobUrl]);
|
| 41 |
|
| 42 |
+
useEffect(() => {
|
| 43 |
+
return () => URL.revokeObjectURL(downloadWavUrl);
|
| 44 |
+
}, [downloadWavUrl]);
|
| 45 |
+
|
| 46 |
+
useEffect(() => {
|
| 47 |
+
return () => URL.revokeObjectURL(downloadMp3Url);
|
| 48 |
+
}, [downloadMp3Url]);
|
| 49 |
+
|
| 50 |
+
const convertToMp3 = async () => {
|
| 51 |
+
setIsConverting(true);
|
| 52 |
+
await delay(10); // wait a bit for the button to be rendered
|
| 53 |
+
setMp3Buffer(audioBufferToMp3(audioBuffer));
|
| 54 |
+
setIsConverting(false);
|
| 55 |
+
};
|
| 56 |
+
|
| 57 |
return (
|
| 58 |
<div className="mt-4 flex items-center">
|
| 59 |
<audio controls src={blobUrl}>
|
|
|
|
| 62 |
|
| 63 |
<a
|
| 64 |
className="btn btn-sm btn-primary ml-2"
|
| 65 |
+
href={downloadWavUrl}
|
| 66 |
+
download={`Podcast_${cleanupFilename(title)}.wav`}
|
| 67 |
>
|
| 68 |
Download WAV
|
| 69 |
</a>
|
| 70 |
+
{mp3Buffer ? (
|
| 71 |
+
<a
|
| 72 |
+
className="btn btn-sm btn-primary ml-2"
|
| 73 |
+
href={downloadMp3Url}
|
| 74 |
+
download={`Podcast_${cleanupFilename(title)}.mp3`}
|
| 75 |
+
>
|
| 76 |
+
Download MP3
|
| 77 |
+
</a>
|
| 78 |
+
) : (
|
| 79 |
+
<button
|
| 80 |
+
className="btn btn-sm ml-2"
|
| 81 |
+
disabled={isConverting}
|
| 82 |
+
onClick={convertToMp3}
|
| 83 |
+
>
|
| 84 |
+
{isConverting
|
| 85 |
+
? 'Converting...'
|
| 86 |
+
: 'Convert to MP3 (may take ~10 seconds)'}
|
| 87 |
+
</button>
|
| 88 |
+
)}
|
| 89 |
</div>
|
| 90 |
);
|
| 91 |
};
|
front/src/components/PodcastGenerator.tsx
CHANGED
|
@@ -89,6 +89,7 @@ export const PodcastGenerator = ({
|
|
| 89 |
busy: boolean;
|
| 90 |
}) => {
|
| 91 |
const [wav, setWav] = useState<AudioBuffer | null>(null);
|
|
|
|
| 92 |
const [numSteps, setNumSteps] = useState<number>(0);
|
| 93 |
const [numStepsDone, setNumStepsDone] = useState<number>(0);
|
| 94 |
|
|
@@ -138,6 +139,7 @@ export const PodcastGenerator = ({
|
|
| 138 |
let outputWav: AudioBuffer;
|
| 139 |
try {
|
| 140 |
const podcast = parseYAML(script);
|
|
|
|
| 141 |
outputWav = await pipelineGeneratePodcast(
|
| 142 |
{
|
| 143 |
podcast,
|
|
@@ -312,7 +314,7 @@ export const PodcastGenerator = ({
|
|
| 312 |
<div className="card bg-base-100 w-full shadow-xl">
|
| 313 |
<div className="card-body">
|
| 314 |
<h2 className="card-title">Step 3: Listen to your podcast</h2>
|
| 315 |
-
<AudioPlayer audioBuffer={wav} />
|
| 316 |
|
| 317 |
{isBlogMode && (
|
| 318 |
<div>
|
|
|
|
| 89 |
busy: boolean;
|
| 90 |
}) => {
|
| 91 |
const [wav, setWav] = useState<AudioBuffer | null>(null);
|
| 92 |
+
const [outTitle, setOutTitle] = useState<string>('');
|
| 93 |
const [numSteps, setNumSteps] = useState<number>(0);
|
| 94 |
const [numStepsDone, setNumStepsDone] = useState<number>(0);
|
| 95 |
|
|
|
|
| 139 |
let outputWav: AudioBuffer;
|
| 140 |
try {
|
| 141 |
const podcast = parseYAML(script);
|
| 142 |
+
setOutTitle(podcast.title ?? 'Untitled podcast');
|
| 143 |
outputWav = await pipelineGeneratePodcast(
|
| 144 |
{
|
| 145 |
podcast,
|
|
|
|
| 314 |
<div className="card bg-base-100 w-full shadow-xl">
|
| 315 |
<div className="card-body">
|
| 316 |
<h2 className="card-title">Step 3: Listen to your podcast</h2>
|
| 317 |
+
<AudioPlayer audioBuffer={wav} title={outTitle} />
|
| 318 |
|
| 319 |
{isBlogMode && (
|
| 320 |
<div>
|
front/src/utils/utils.ts
CHANGED
|
@@ -12,6 +12,8 @@ export const isDev: boolean = import.meta.env.MODE === 'development';
|
|
| 12 |
export const testToken: string = import.meta.env.VITE_TEST_TOKEN;
|
| 13 |
export const isBlogMode: boolean = !!window.location.href.match(/blogmode/);
|
| 14 |
|
|
|
|
|
|
|
| 15 |
// return URL to the WAV file
|
| 16 |
export const generateAudio = async (
|
| 17 |
content: string,
|
|
@@ -507,3 +509,8 @@ function floatTo16BitPCM(input: Float32Array): Int16Array {
|
|
| 507 |
}
|
| 508 |
return output;
|
| 509 |
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 12 |
export const testToken: string = import.meta.env.VITE_TEST_TOKEN;
|
| 13 |
export const isBlogMode: boolean = !!window.location.href.match(/blogmode/);
|
| 14 |
|
| 15 |
+
export const delay = (ms: number) => new Promise((res) => setTimeout(res, ms));
|
| 16 |
+
|
| 17 |
// return URL to the WAV file
|
| 18 |
export const generateAudio = async (
|
| 19 |
content: string,
|
|
|
|
| 509 |
}
|
| 510 |
return output;
|
| 511 |
}
|
| 512 |
+
|
| 513 |
+
// clean up filename for saving
|
| 514 |
+
export const cleanupFilename = (name: string): string => {
|
| 515 |
+
return name.replace(/[^a-zA-Z0-9-_]/g, '_');
|
| 516 |
+
};
|
index.html
CHANGED
|
@@ -25406,6 +25406,7 @@ async function whoAmI(params) {
|
|
| 25406 |
return response;
|
| 25407 |
}
|
| 25408 |
const isBlogMode = !!window.location.href.match(/blogmode/);
|
|
|
|
| 25409 |
const generateAudio = async (content, voice, speed = 1.1) => {
|
| 25410 |
const maxRetries = 3;
|
| 25411 |
for (let i = 0; i < maxRetries; i++) {
|
|
@@ -25704,31 +25705,70 @@ function floatTo16BitPCM(input) {
|
|
| 25704 |
}
|
| 25705 |
return output;
|
| 25706 |
}
|
| 25707 |
-
const
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 25708 |
const blobUrl = reactExports.useMemo(() => {
|
| 25709 |
const wavBlob = blobFromAudioBuffer(audioBuffer);
|
| 25710 |
return URL.createObjectURL(wavBlob);
|
| 25711 |
}, [audioBuffer]);
|
| 25712 |
-
const
|
| 25713 |
const wavBlob = blobFromAudioBuffer(audioBuffer);
|
| 25714 |
return URL.createObjectURL(wavBlob);
|
| 25715 |
}, [audioBuffer]);
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 25716 |
reactExports.useEffect(() => {
|
| 25717 |
-
return () =>
|
| 25718 |
-
URL.revokeObjectURL(blobUrl);
|
| 25719 |
-
URL.revokeObjectURL(downloadUrl);
|
| 25720 |
-
};
|
| 25721 |
}, [blobUrl]);
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 25722 |
return /* @__PURE__ */ jsxRuntimeExports.jsxs("div", { className: "mt-4 flex items-center", children: [
|
| 25723 |
/* @__PURE__ */ jsxRuntimeExports.jsx("audio", { controls: true, src: blobUrl, children: "Your browser does not support the audio element." }),
|
| 25724 |
/* @__PURE__ */ jsxRuntimeExports.jsx(
|
| 25725 |
"a",
|
| 25726 |
{
|
| 25727 |
className: "btn btn-sm btn-primary ml-2",
|
| 25728 |
-
href:
|
| 25729 |
-
download:
|
| 25730 |
children: "Download WAV"
|
| 25731 |
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 25732 |
)
|
| 25733 |
] });
|
| 25734 |
};
|
|
@@ -31966,6 +32006,7 @@ const PodcastGenerator = ({
|
|
| 31966 |
busy
|
| 31967 |
}) => {
|
| 31968 |
const [wav, setWav] = reactExports.useState(null);
|
|
|
|
| 31969 |
const [numSteps, setNumSteps] = reactExports.useState(0);
|
| 31970 |
const [numStepsDone, setNumStepsDone] = reactExports.useState(0);
|
| 31971 |
const [script, setScript] = reactExports.useState("");
|
|
@@ -32008,6 +32049,7 @@ const PodcastGenerator = ({
|
|
| 32008 |
let outputWav;
|
| 32009 |
try {
|
| 32010 |
const podcast = parseYAML(script);
|
|
|
|
| 32011 |
outputWav = await pipelineGeneratePodcast(
|
| 32012 |
{
|
| 32013 |
podcast,
|
|
@@ -32165,7 +32207,7 @@ const PodcastGenerator = ({
|
|
| 32165 |
] }) }),
|
| 32166 |
wav && /* @__PURE__ */ jsxRuntimeExports.jsx("div", { className: "card bg-base-100 w-full shadow-xl", children: /* @__PURE__ */ jsxRuntimeExports.jsxs("div", { className: "card-body", children: [
|
| 32167 |
/* @__PURE__ */ jsxRuntimeExports.jsx("h2", { className: "card-title", children: "Step 3: Listen to your podcast" }),
|
| 32168 |
-
/* @__PURE__ */ jsxRuntimeExports.jsx(AudioPlayer, { audioBuffer: wav }),
|
| 32169 |
isBlogMode && /* @__PURE__ */ jsxRuntimeExports.jsxs("div", { children: [
|
| 32170 |
"-------------------",
|
| 32171 |
/* @__PURE__ */ jsxRuntimeExports.jsx("br", {}),
|
|
|
|
| 25406 |
return response;
|
| 25407 |
}
|
| 25408 |
const isBlogMode = !!window.location.href.match(/blogmode/);
|
| 25409 |
+
const delay$1 = (ms) => new Promise((res) => setTimeout(res, ms));
|
| 25410 |
const generateAudio = async (content, voice, speed = 1.1) => {
|
| 25411 |
const maxRetries = 3;
|
| 25412 |
for (let i = 0; i < maxRetries; i++) {
|
|
|
|
| 25705 |
}
|
| 25706 |
return output;
|
| 25707 |
}
|
| 25708 |
+
const cleanupFilename = (name2) => {
|
| 25709 |
+
return name2.replace(/[^a-zA-Z0-9-_]/g, "_");
|
| 25710 |
+
};
|
| 25711 |
+
const AudioPlayer = ({
|
| 25712 |
+
audioBuffer,
|
| 25713 |
+
title
|
| 25714 |
+
}) => {
|
| 25715 |
+
const [isConverting, setIsConverting] = reactExports.useState(false);
|
| 25716 |
+
const [mp3Buffer, setMp3Buffer] = reactExports.useState(null);
|
| 25717 |
const blobUrl = reactExports.useMemo(() => {
|
| 25718 |
const wavBlob = blobFromAudioBuffer(audioBuffer);
|
| 25719 |
return URL.createObjectURL(wavBlob);
|
| 25720 |
}, [audioBuffer]);
|
| 25721 |
+
const downloadWavUrl = reactExports.useMemo(() => {
|
| 25722 |
const wavBlob = blobFromAudioBuffer(audioBuffer);
|
| 25723 |
return URL.createObjectURL(wavBlob);
|
| 25724 |
}, [audioBuffer]);
|
| 25725 |
+
const downloadMp3Url = reactExports.useMemo(() => {
|
| 25726 |
+
if (!mp3Buffer) return "";
|
| 25727 |
+
const mp3Blob = new Blob([mp3Buffer], { type: "audio/mp3" });
|
| 25728 |
+
return URL.createObjectURL(mp3Blob);
|
| 25729 |
+
}, [audioBuffer]);
|
| 25730 |
reactExports.useEffect(() => {
|
| 25731 |
+
return () => URL.revokeObjectURL(blobUrl);
|
|
|
|
|
|
|
|
|
|
| 25732 |
}, [blobUrl]);
|
| 25733 |
+
reactExports.useEffect(() => {
|
| 25734 |
+
return () => URL.revokeObjectURL(downloadWavUrl);
|
| 25735 |
+
}, [downloadWavUrl]);
|
| 25736 |
+
reactExports.useEffect(() => {
|
| 25737 |
+
return () => URL.revokeObjectURL(downloadMp3Url);
|
| 25738 |
+
}, [downloadMp3Url]);
|
| 25739 |
+
const convertToMp3 = async () => {
|
| 25740 |
+
setIsConverting(true);
|
| 25741 |
+
await delay$1(10);
|
| 25742 |
+
setMp3Buffer(audioBufferToMp3(audioBuffer));
|
| 25743 |
+
setIsConverting(false);
|
| 25744 |
+
};
|
| 25745 |
return /* @__PURE__ */ jsxRuntimeExports.jsxs("div", { className: "mt-4 flex items-center", children: [
|
| 25746 |
/* @__PURE__ */ jsxRuntimeExports.jsx("audio", { controls: true, src: blobUrl, children: "Your browser does not support the audio element." }),
|
| 25747 |
/* @__PURE__ */ jsxRuntimeExports.jsx(
|
| 25748 |
"a",
|
| 25749 |
{
|
| 25750 |
className: "btn btn-sm btn-primary ml-2",
|
| 25751 |
+
href: downloadWavUrl,
|
| 25752 |
+
download: `Podcast_${cleanupFilename(title)}.wav`,
|
| 25753 |
children: "Download WAV"
|
| 25754 |
}
|
| 25755 |
+
),
|
| 25756 |
+
mp3Buffer ? /* @__PURE__ */ jsxRuntimeExports.jsx(
|
| 25757 |
+
"a",
|
| 25758 |
+
{
|
| 25759 |
+
className: "btn btn-sm btn-primary ml-2",
|
| 25760 |
+
href: downloadMp3Url,
|
| 25761 |
+
download: `Podcast_${cleanupFilename(title)}.mp3`,
|
| 25762 |
+
children: "Download MP3"
|
| 25763 |
+
}
|
| 25764 |
+
) : /* @__PURE__ */ jsxRuntimeExports.jsx(
|
| 25765 |
+
"button",
|
| 25766 |
+
{
|
| 25767 |
+
className: "btn btn-sm ml-2",
|
| 25768 |
+
disabled: isConverting,
|
| 25769 |
+
onClick: convertToMp3,
|
| 25770 |
+
children: isConverting ? "Converting..." : "Convert to MP3 (may take ~10 seconds)"
|
| 25771 |
+
}
|
| 25772 |
)
|
| 25773 |
] });
|
| 25774 |
};
|
|
|
|
| 32006 |
busy
|
| 32007 |
}) => {
|
| 32008 |
const [wav, setWav] = reactExports.useState(null);
|
| 32009 |
+
const [outTitle, setOutTitle] = reactExports.useState("");
|
| 32010 |
const [numSteps, setNumSteps] = reactExports.useState(0);
|
| 32011 |
const [numStepsDone, setNumStepsDone] = reactExports.useState(0);
|
| 32012 |
const [script, setScript] = reactExports.useState("");
|
|
|
|
| 32049 |
let outputWav;
|
| 32050 |
try {
|
| 32051 |
const podcast = parseYAML(script);
|
| 32052 |
+
setOutTitle(podcast.title ?? "Untitled podcast");
|
| 32053 |
outputWav = await pipelineGeneratePodcast(
|
| 32054 |
{
|
| 32055 |
podcast,
|
|
|
|
| 32207 |
] }) }),
|
| 32208 |
wav && /* @__PURE__ */ jsxRuntimeExports.jsx("div", { className: "card bg-base-100 w-full shadow-xl", children: /* @__PURE__ */ jsxRuntimeExports.jsxs("div", { className: "card-body", children: [
|
| 32209 |
/* @__PURE__ */ jsxRuntimeExports.jsx("h2", { className: "card-title", children: "Step 3: Listen to your podcast" }),
|
| 32210 |
+
/* @__PURE__ */ jsxRuntimeExports.jsx(AudioPlayer, { audioBuffer: wav, title: outTitle }),
|
| 32211 |
isBlogMode && /* @__PURE__ */ jsxRuntimeExports.jsxs("div", { children: [
|
| 32212 |
"-------------------",
|
| 32213 |
/* @__PURE__ */ jsxRuntimeExports.jsx("br", {}),
|