F5-TTS product page (#720)

* f5-tts product page

* footer fix

* ai tools section on f5-tts update

* final updates
This commit is contained in:
BFlat authored and GitHub committed 2024-10-18 20:22:07 +07:00
1 parent 2a0c3b43a3
commit 0aba1957e8
17 files changed
+1208 -34

No files matched your search

@@ -0,0 +1,23 @@
import MakeRequest from "../MakeRequest";
export interface GenerateF5TtsAudioRequest {
uuid_idempotency_token: string;
source_media_file_token: string;
inference_text: string;
creator_set_visibility: string;
}
export interface GenerateF5TtsAudioResponse {
success: boolean;
inference_job_token: string;
}
export const GenerateF5TtsAudio = MakeRequest<
string,
GenerateF5TtsAudioRequest,
GenerateF5TtsAudioResponse,
{}
>({
method: "POST",
routingFunction: () => "/v1/tts/f5_inference",
});
@@ -20,6 +20,7 @@ export enum FrontendInferenceJobType {
VideoWorkflow, // workflow ?
VideoStyleTransfer, // video_filter
LivePortrait, // live_portrait
F5Tts, // f5_tts
}
export enum AllInferenceJobs {
@@ -207,6 +207,10 @@ export default function AITools() {
items = [items[0], items[1], items[2], items[3], items[4], items[5]];
}
if (currentPath.includes("/f5-tts")) {
items = [items[0], items[1], items[2], items[3], items[4], items[5]];
}
if (currentPath.includes("/style-video")) {
items = [items[1], items[2], items[8]];
}
@@ -11,6 +11,7 @@ import useCameraState, { CameraState } from "./useCameraState";
import useChanger from "./useChanger";
import useCoverImgUpload from "./useCoverImgUpload";
import useDebounce from "./useDebounce";
import useF5Store from "./useF5Store";
import useFile from "./useFile";
import useHover, { HoverValues } from "./useHover";
import useId from "./useId";
@@ -50,6 +51,7 @@ export {
useChanger,
useCoverImgUpload,
useDebounce,
useF5Store,
useFile,
useHover,
useId,
@@ -0,0 +1 @@
export { default } from "./useF5Store";
@@ -0,0 +1,45 @@
import { create } from "zustand";
interface F5StoreState {
mediaUploadToken: string | undefined;
setMediaUploadToken: (token: string | undefined) => void;
hasUploadedFile: boolean;
setHasUploadedFile: (value: boolean) => void;
hasRecordedFile: boolean;
setHasRecordedFile: (value: boolean) => void;
recordingBlobStore: Blob | undefined;
setRecordingBlobStore: (blob: Blob | undefined) => void;
isUploadDisabled: boolean;
setIsUploadDisabled: (value: boolean) => void;
file: any;
setFile: (file: any) => void;
audioLink: string | undefined;
setAudioLink: (link: string | undefined) => void;
formIsCleared: boolean;
setFormIsCleared: (cleared: boolean) => void;
text: string;
setText: (text: string) => void;
}
const useF5Store = create<F5StoreState>(set => ({
mediaUploadToken: undefined,
setMediaUploadToken: token => set({ mediaUploadToken: token }),
hasUploadedFile: false,
setHasUploadedFile: value => set({ hasUploadedFile: value }),
hasRecordedFile: false,
setHasRecordedFile: value => set({ hasRecordedFile: value }),
recordingBlobStore: undefined,
setRecordingBlobStore: blob => set({ recordingBlobStore: blob }),
isUploadDisabled: false,
setIsUploadDisabled: value => set({ isUploadDisabled: value }),
file: undefined,
setFile: file => set({ file: file }),
audioLink: undefined,
setAudioLink: link => set({ audioLink: link }),
formIsCleared: false,
setFormIsCleared: cleared => set({ formIsCleared: cleared }),
text: "",
setText: text => set({ text }),
}));
export default useF5Store;
@@ -42,6 +42,8 @@ const JobCategoryToType = (jobCategory: string) => {
return FrontendInferenceJobType.EngineComposition;
case "live_portrait":
return FrontendInferenceJobType.LivePortrait;
case "f5_tts":
return FrontendInferenceJobType.F5Tts;
default:
return FrontendInferenceJobType.Unknown;
}
@@ -1450,6 +1450,14 @@ styles with important tags:
height: 100px;
}
}
&.empty-zone-f5 {
height: 120px;
@media (max-width: 768px) {
height: 100px;
}
}
}
.upload-zone-drag {
@@ -1889,6 +1897,10 @@ textarea {
height: 511px;
}
.session-f5-section {
height: 637px;
}
.session-tts-section::-webkit-scrollbar {
width: 10px;
}
@@ -443,7 +443,7 @@ const routes = [
},
{
path: "/f5-tts",
component: lazy(() => import("./pages/voice_designer_v2/VoiceDesignerV2")),
component: lazy(() => import("./pages/f5_tts/F5TTS")),
},
{
path: "/style-video/:mediaToken?",
@@ -0,0 +1,284 @@
import React, { useEffect, useState } from "react";
import { Link } from "react-router-dom";
import { BucketConfig } from "@storyteller/components/src/api/BucketConfig";
import { JobState } from "@storyteller/components/src/jobs/JobStates";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { faClock, faHeadphonesSimple } from "@fortawesome/free-solid-svg-icons";
import { Analytics } from "../../../common/Analytics";
import { SessionTtsAudioPlayer } from "./SessionTtsAudioPlayer";
import { WebUrl } from "../../../common/WebUrl";
import {
GetPendingTtsJobCount,
GetPendingTtsJobCountIsOk,
GetPendingTtsJobCountSuccessResponse,
} from "@storyteller/components/src/api/tts/GetPendingTtsJobCount";
import {
FrontendInferenceJobType,
InferenceJob,
} from "@storyteller/components/src/jobs/InferenceJob";
import { useInferenceJobs, useLocalize, useSession } from "hooks";
import { Button } from "components/common";
import {
faArrowDownToLine,
faArrowRight,
faStars,
} from "@fortawesome/pro-solid-svg-icons";
import LoadingSpinner from "components/common/LoadingSpinner";
// TODO: This is duplicated in SessionTtsInferenceResultsList !
// Default to querying every 15 seconds, but make it configurable serverside
const DEFAULT_QUEUE_REFRESH_INTERVAL_MILLIS = 15000;
function SessionF5TtsResultsList() {
const { t } = useLocalize("SessionVoiceConversionResultsList");
const { t: t2 } = useLocalize("NewVC");
const [pendingTtsJobs, setPendingTtsJobs] =
useState<GetPendingTtsJobCountSuccessResponse>({
success: true,
pending_job_count: 0,
cache_time: new Date(0), // NB: Epoch is used for vector clock's initial state
refresh_interval_millis: DEFAULT_QUEUE_REFRESH_INTERVAL_MILLIS,
});
const { loggedInOrModal, loggedIn, sessionSubscriptions } = useSession();
const { inferenceJobsByCategory } = useInferenceJobs();
useEffect(() => {
const fetch = async () => {
const response = await GetPendingTtsJobCount();
if (GetPendingTtsJobCountIsOk(response)) {
if (
response.cache_time.getTime() > pendingTtsJobs.cache_time.getTime()
) {
setPendingTtsJobs(response);
}
}
};
// TODO: We're having an outage and need to lower this.
//const interval = setInterval(async () => fetch(), 15000);
const refreshInterval = Math.max(
DEFAULT_QUEUE_REFRESH_INTERVAL_MILLIS,
pendingTtsJobs.refresh_interval_millis
);
const interval = setInterval(async () => fetch(), refreshInterval);
fetch();
return () => clearInterval(interval);
}, [pendingTtsJobs]);
let results: Array<JSX.Element> = [];
// TODO(bt,2023-04-08): Clean this utter garbage duplication up.
// ============================= GENERIC INFERENCE =============================
inferenceJobsByCategory
.get(FrontendInferenceJobType.F5Tts)
.forEach((job: InferenceJob, index: number, array: InferenceJob[]) => {
const reverseIndex = array.length - index;
if (!job.maybeResultToken) {
let cssStyle = "alert alert-secondary mb-0";
let stateDescription = "Pending...";
let loadingSpinner = null;
let percentage = job.progressPercentage;
switch (job.jobState) {
case JobState.PENDING:
case JobState.UNKNOWN:
stateDescription =
job.maybeExtraStatusDescription == null
? t("resultsProgressPending")
: job.maybeExtraStatusDescription;
loadingSpinner = (
<LoadingSpinner
className="fs-6"
padding={false}
thin={true}
size={16}
/>
);
break;
case JobState.STARTED:
cssStyle = "alert alert-success mb-0";
stateDescription =
job.maybeExtraStatusDescription == null
? t("resultsProgressStarted")
: job.maybeExtraStatusDescription;
loadingSpinner = (
<LoadingSpinner
className="fs-6"
padding={false}
thin={true}
size={16}
/>
);
break;
case JobState.ATTEMPT_FAILED:
cssStyle = "alert alert-danger mb-0";
stateDescription = `${
(t("resultsProgressFail"), { 0: job.attemptCount || "0" })
}}`;
loadingSpinner = (
<LoadingSpinner
className="fs-6"
padding={false}
thin={true}
size={16}
/>
);
break;
case JobState.COMPLETE_FAILURE:
case JobState.DEAD:
cssStyle = "alert alert-danger mb-0";
// TODO(bt,2023-01-23): Translate when I can test it
stateDescription = t("resultsProgressDead");
break;
case JobState.COMPLETE_SUCCESS:
cssStyle = "message is-success mb-0";
// Not sure why we're here instead of other branch!
stateDescription = t("resultsProgressSuccess");
break;
}
results.push(
<div key={job.jobToken}>
<div className={`d-flex gap-2 ${cssStyle} fw-medium`.trim()}>
{loadingSpinner}
{stateDescription}
{percentage !== 0 && ` (${percentage}%)`}
</div>
</div>
);
} else {
let audioLink = new BucketConfig().getGcsUrl(
job.maybeResultPublicBucketMediaPath
);
let audioPermalink = `/media/${job.maybeResultToken}`;
let wavesurfers = <SessionTtsAudioPlayer filename={audioLink} />;
results.push(
<div key={job.jobToken}>
{/*<div className="message-header">
<p>{job.title}</p>
<button className="delete" aria-label="delete"></button>
</div>*/}
<div>
<div className="panel panel-results p-3 gap-3 d-flex flex-column">
<div>
<div className="d-flex align-items-center gap-1 mb-2">
<h6 className="mb-0 fw-semibold flex-grow-1">
Audio #{reverseIndex}
</h6>
<Button
iconFlip={true}
variant="link"
label="More details"
className="fs-7"
icon={faArrowRight}
to={audioPermalink}
/>
</div>
<p className="mb-0 fs-7 pt-1">{job.maybeRawInferenceText}</p>
</div>
<div className="d-flex gap-3 align-items-center">
{wavesurfers}
<Button
variant="action"
small={true}
square={true}
icon={faArrowDownToLine}
fontLarge={true}
to={audioPermalink}
onClick={() => {
Analytics.ttsClickResultLink();
}}
/>
</div>
{/* <div className="mt-2">
<Link
to={ttsPermalink}
onClick={() => {
Analytics.ttsClickResultLink();
}}
className="fw-semibold"
>
<FontAwesomeIcon icon={faLink} className="me-2" />
{t("resultsAudioShareDownload")}
</Link>
</div> */}
</div>
</div>
</div>
);
}
});
let noResultsSection = (
<div className="panel panel-inner text-center p-5 rounded-5 h-100">
<div className="d-flex flex-column opacity-75 h-100 justify-content-center">
<FontAwesomeIcon icon={faHeadphonesSimple} className="fs-3 mb-3" />
<h5 className="fw-semibold">{t2("sessionResults.emptyTitle")}</h5>
<p>{t2("sessionResults.emptySubtitle")}</p>
</div>
</div>
);
if (results.length === 0) {
return <>{noResultsSection}</>;
}
let upgradeNotice = <></>;
// Ask non-premium users to upgrade
if (results.length !== 0 && !sessionSubscriptions?.hasPaidFeatures()) {
if (loggedIn) {
upgradeNotice = (
<div className="d-flex flex-column gap-3 sticky-top zi-2">
<div className="alert alert-primary alert-cta mb-0">
<FontAwesomeIcon icon={faStars} className="me-2" />
{t2("sessionResults.alertNoPlan")}{" "}
<Link
to={WebUrl.pricingPageWithReferer("nowait")}
onClick={() => {
Analytics.ttsTooSlowUpgradePremium();
}}
className="alert-link fw-semibold"
>
{t2("sessionResults.alertNoPlanLink")}
</Link>
</div>
</div>
);
} else {
upgradeNotice = (
<div className="d-flex flex-column gap-3 sticky-top zi-2">
<div className="alert alert-warning alert-cta mb-0 d-flex align-items-center">
<FontAwesomeIcon icon={faClock} className="me-2" />
{t2("sessionResults.alertNonUser")}{" "}
<Button
onClick={() =>
!loggedInOrModal({
loginMessage: t2("modal.title.login"),
signupMessage: t2("modal.title.signUp"),
})
}
variant="link"
className="alert-link fw-semibold ms-1"
label={t2("sessionResults.alertNonUserLink")}
/>
</div>
</div>
);
}
}
return (
<div className="d-flex flex-column gap-3">
{upgradeNotice}
<div className="d-flex flex-column gap-3">{results}</div>
</div>
);
}
export { SessionF5TtsResultsList };
@@ -134,12 +134,10 @@ function FooterNav() {
return null;
}
const isOnF5TtsPage = location.pathname === "/f5-tts";
return (
<>
{!isOnStudioPage && (
<footer id="footer" className={isOnF5TtsPage ? "mt-0" : ""}>
<footer id="footer">
<Container type="panel" className="py-5">
<div className="row g-5 m-0">
<div className="col-12 col-lg-3 d-flex flex-column gap-4 align-items-center align-items-lg-start pb-2">
@@ -78,9 +78,9 @@ export default function VCRecordComponent(props: Props) {
} else {
// @ts-ignore
window.dataLayer.push({
"event": "upload_failure",
"page": "/voice-conversion",
"user_id": "$user_id"
event: "upload_failure",
page: "/voice-conversion",
user_id: "$user_id",
});
}
@@ -55,42 +55,32 @@
}
}
}
}
.icon {
height: 46px;
width: 46px;
padding: 20px;
background-color: $secondary;
border-radius: 50%;
display: flex;
justify-content: center;
align-items: center;
margin-bottom: 15px;
border: 3px solid rgba($white, 0.1);
}
.icon {
height: 32px;
width: 32px;
padding: 20px;
background-color: $secondary;
border-radius: 16px;
display: flex;
justify-content: center;
align-items: center;
border: 3px solid rgba($white, 0.2);
}
.faq-section {
background-color: rgba($secondary, 0.8);
border-radius: 12px;
padding: 3rem;
padding-bottom: 2rem;
margin-top: 4rem;
margin-bottom: 4rem;
max-width: 1100px !important;
h2 {
color: $white;
text-align: center;
margin-bottom: 36px;
font-weight: bold;
}
margin-bottom: 2rem;
border: 3px solid rgba($secondary, 0.8);
.faq-item {
margin-bottom: 30px;
margin-bottom: 24px;
h3 {
color: $primary;
color: lighten($primary, 5%);
font-size: 20px;
margin-bottom: 10px;
font-weight: bold;
@@ -121,3 +111,69 @@
}
}
}
.how-to-use-section {
background-color: rgba($secondary, 0.8);
border-radius: 12px;
padding-bottom: 2rem;
margin-top: 3rem;
margin-bottom: 3rem;
border: 3px solid rgba($white, 0.2);
.how-to-use-icon {
height: 24px;
width: 24px;
padding: 14px;
background-color: lighten($secondary, 10%);
color: $white;
border-radius: 12px;
display: flex;
justify-content: center;
align-items: center;
margin-bottom: 24px;
border: 3px solid lighten($secondary, 20%);
@media (max-width: 768px) {
margin-bottom: 16px;
height: 16px;
width: 16px;
padding: 8px;
border-radius: 8px;
}
}
.how-to-use-item {
margin-bottom: 30px;
h3 {
color: lighten($primary, 6%);
font-size: 20px;
margin-bottom: 12px;
font-weight: bold;
}
p {
color: rgba($white, 0.8);
font-size: 16px;
line-height: 1.6;
}
}
}
@media (max-width: 768px) {
.how-to-use-section {
padding: 20px;
padding-top: 2.5rem;
border-radius: 0px;
.how-to-use-item {
h3 {
font-size: 18px;
}
p {
font-size: 14px;
}
}
}
}
@@ -0,0 +1,314 @@
import {
faArrowDownToLine,
faFlask,
faMicrophoneAlt,
faTextSize,
faWaveformLines,
} from "@fortawesome/pro-solid-svg-icons";
import {
Badge,
Button,
Container,
Label,
Panel,
TextArea,
} from "components/common";
import React, { useContext, useState } from "react";
import useF5Store from "hooks/useF5Store";
import F5RecordComponent from "./components/F5RecordComponent";
import F5UploadComponent from "./components/F5UploadComponent";
import { SessionF5TtsResultsList } from "v2/view/_common/SessionF5TtsResultsList";
import { useInferenceJobs } from "hooks";
import { v4 as uuidv4 } from "uuid";
import { FrontendInferenceJobType } from "@storyteller/components/src/jobs/InferenceJob";
import {
GenerateF5TtsAudio,
GenerateF5TtsAudioRequest,
GenerateF5TtsAudioResponse,
} from "@storyteller/components/src/api/f5_tts/GenerateF5TtsAudio";
import "./F5TTS.scss";
import { AITools } from "components/marketing";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { Link } from "react-router-dom";
import { AppStateContext } from "components/providers/AppStateProvider";
import { usePrefixedDocumentTitle } from "common/UsePrefixedDocumentTitle";
export default function F5TTS() {
const { sessionWrapper } = useContext(AppStateContext);
const { enqueueInferenceJob } = useInferenceJobs();
const {
mediaUploadToken,
setMediaUploadToken,
hasUploadedFile,
setHasUploadedFile,
hasRecordedFile,
setHasRecordedFile,
formIsCleared,
setFormIsCleared,
setText,
text,
} = useF5Store();
// eslint-disable-next-line @typescript-eslint/no-unused-vars
const [isRecordingAudio, setIsRecordingAudio] = useState(false);
const [isGenerating, setIsGenerating] = useState(false);
usePrefixedDocumentTitle("F5-TTS Zero-Shot Voice Cloning");
const textChange = ({ target }: { target: any }) => {
setText(target.value);
};
const handleConvert = async (ev: React.FormEvent<HTMLFormElement>) => {
ev.preventDefault();
setIsGenerating(true);
if (!mediaUploadToken) return;
const request: GenerateF5TtsAudioRequest = {
uuid_idempotency_token: uuidv4(),
source_media_file_token: mediaUploadToken,
inference_text: text,
creator_set_visibility: "public",
};
try {
const response: GenerateF5TtsAudioResponse = await GenerateF5TtsAudio(
"",
request
);
if (response) {
console.log("TTS queued successfully:", response.inference_job_token);
enqueueInferenceJob(
response.inference_job_token,
FrontendInferenceJobType.F5Tts
);
setIsGenerating(false);
} else {
console.error("Error queuing TTS");
setIsGenerating(false);
}
} catch (error) {
console.error("Unexpected error:", error);
setIsGenerating(false);
}
};
return (
<>
<Container type="panel" className="mt-3 mt-lg-5">
<Panel padding={true} className="p-lg-5">
<form onSubmit={handleConvert}>
<div className="d-flex flex-column flex-lg-row gap-3 align-items-center mb-3 text-center text-lg-start">
<FontAwesomeIcon icon={faMicrophoneAlt} className="icon" />
<div>
<div className="d-flex gap-2 gap-lg-3 align-items-center justify-content-center justify-content-lg-start flex-wrap mb-1">
<h1 className="fw-bold fs-1 mb-0">F5-TTS Voice Cloning</h1>
<Badge label="Beta" icon={faFlask} color="gray" />
</div>
<p
className="opacity-75 fw-medium"
style={{ fontSize: "18px" }}
>
Instantly generate text-to-speech audio by uploading a short
audio clip of any voice you choose.
</p>
</div>
</div>
<div className="d-flex flex-column gap-3 pt-3 pt-lg-5">
<div className="row g-5">
<div className="d-flex flex-column gap-3 col-12 col-lg-6">
<div>
<Label label="Reference Audio" />
<div className="d-flex flex-column gap-3">
{!hasUploadedFile && (
<div>
<F5RecordComponent
setMediaUploadToken={setMediaUploadToken}
formIsCleared={formIsCleared}
setFormIsCleared={setFormIsCleared}
setHasRecordedFile={setHasRecordedFile}
hasRecordedFile={hasRecordedFile}
setIsRecordingAudio={setIsRecordingAudio}
/>
</div>
)}
{!hasUploadedFile && !hasRecordedFile && (
<div className="d-flex gap-3 align-items-center">
<hr className="w-100" />
<span className="opacity-75 fw-medium">or</span>
<hr className="w-100" />
</div>
)}
{!hasRecordedFile && (
<div>
<div className="upload-component">
<F5UploadComponent
setMediaUploadToken={setMediaUploadToken}
formIsCleared={formIsCleared}
setFormIsCleared={setFormIsCleared}
setHasUploadedFile={setHasUploadedFile}
/>
</div>
</div>
)}
</div>
</div>
<div className="mt-2">
<TextArea
label="Enter Text"
onChange={textChange}
value={text}
rows={10}
placeholder="Type what you want the voice to say..."
resize={false}
/>
</div>
<div className="d-flex justify-content-end">
<Button
icon={faWaveformLines}
label="Generate Speech"
type="submit"
isLoading={isGenerating}
disabled={!mediaUploadToken || !text}
/>
</div>
</div>
<div className="col-12 col-lg-6">
<div className="d-flex flex-column">
<Label label="Output" />
<div className="d-flex flex-column session-f5-section">
<SessionF5TtsResultsList />
</div>
</div>
</div>
</div>
</div>
</form>
</Panel>
</Container>
<Container type="panel">
<div className="how-to-use-section p-4">
<h2 className="fw-bold mb-5">How to Use F5-TTS</h2>
<div className="row g-5">
<div className="col-md-4 how-to-use-item">
<FontAwesomeIcon
icon={faWaveformLines}
className="how-to-use-icon"
/>
<h3>Step 1: Upload Your Audio</h3>
<p>
In the panel above, start by adding a reference audio, either
record your own voice or upload an audio file. This audio will
be used by F5-TTS to clone the voice, enabling the generation of
speech that closely resembles the reference voice. For optimal
results, ensure the audio is clear and of high quality.
</p>
</div>
<div className="col-md-4 how-to-use-item">
<FontAwesomeIcon icon={faTextSize} className="how-to-use-icon" />
<h3>Step 2: Enter Your Text</h3>
<p>
Next, input the text you wish to convert into speech. This text
will be synthesized using the voice from your reference audio,
allowing you to create personalized audio content. Ensure your
text is clear and concise for the best results.
</p>
</div>
<div className="col-md-4 how-to-use-item">
<FontAwesomeIcon
icon={faArrowDownToLine}
className="how-to-use-icon"
/>
<h3>Step 3: Generate and Save</h3>
<p>
With your audio and text prepared, click 'Generate Speech' to
activate F5-TTS and transform your text into lifelike speech.
Once the process is complete, you can listen to the synthesized
audio directly in the output panel above. If you're happy with
the result, click the download button to save the audio file and
use it in your projects!
{!sessionWrapper.isLoggedIn() && (
<span>
{" "}
If you want your results to be kept,{" "}
<Link
to="/signup?redirect=/f5-tts"
className="fw-medium text-red text-decoration-underline"
>
sign up
</Link>{" "}
and they will be saved to your account!
</span>
)}
</p>
</div>
</div>
</div>
</Container>
<Container type="panel">
<Panel padding={true} className="p-4 faq-section">
<h2 className="fw-bold mb-5">Frequently Asked Questions</h2>
<div className="row g-5">
<div className="faq-item">
<h3>What is F5-TTS?</h3>
<p>
F5-TTS is an AI-driven text-to-speech tool that transforms
written text into lifelike speech. With real-time processing,
it’s great for generating dynamic audio content, whether it's
for voice-overs, digital storytelling, or any other project that
requires high-quality spoken output.
</p>
</div>
<hr className="my-3" />
<div className="faq-item">
<h3>How does F5-TTS work?</h3>
<p>
F5-TTS leverages advanced AI techniques, like Flow Matching and
Diffusion Transformers, to turn text into speech. It skips some
of the traditional steps like phoneme alignment and duration
prediction, producing more natural-sounding audio directly from
your input text.
</p>
</div>
<hr className="my-3" />
<div className="faq-item">
<h3>What kind of audio quality can I expect from F5-TTS?</h3>
<p>
F5-TTS delivers high-quality audio with clear, natural
intonation, making it a perfect fit for professional projects
like podcasts, audiobooks, and educational content. The speech
it generates is crisp and lifelike, designed to meet the
standards of any polished audio production.
</p>
</div>
<hr className="my-3" />
<div className="faq-item">
<h3>Can F5-TTS be used for voice-over work?</h3>
<p>
Absolutely! F5-TTS is a fantastic tool for voice-over
production. Its zero-shot voice cloning feature allows you to
create different voices for various characters or narrators, and
it even supports emotional expression to add extra nuance and
depth to your audio content.
</p>
</div>
</div>
</Panel>
</Container>
<Container type="panel" className="pt-5 mt-5">
<Panel clear={true}>
<h2 className="fw-bold mb-3">Try our other AI tools</h2>
<AITools />
</Panel>
</Container>
</>
);
}
@@ -1,5 +1,5 @@
import React, { useState, useEffect, useContext } from "react";
import "./VoiceDesignerV2.scss";
import "./F5TTS.scss";
import { Button, Container, Panel } from "components/common";
import {
faArrowRight,
@@ -80,7 +80,7 @@ export default function VoiceDesignerV2(): JSX.Element {
>
<Panel padding={true}>
<div className="countdown-container">
<FontAwesomeIcon icon={faMicrophoneAlt} className="icon" />
<FontAwesomeIcon icon={faMicrophoneAlt} className="icon mb-3" />
<h1 className="fw-bold mb-1">F5-TTS Voice Cloning</h1>
<p className="description">
New zero-shot voice cloning coming soon...
@@ -0,0 +1,205 @@
import React, { useEffect, useState, useMemo } from "react";
import { v4 as uuidv4 } from "uuid";
import { useAudioRecorder } from "react-audio-voice-recorder";
import { InputVcAudioPlayer } from "v2/view/_common/InputVcAudioPlayer";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { faCircle, faFileArrowUp } from "@fortawesome/pro-solid-svg-icons";
import {
UploadAudio,
UploadAudioIsOk,
UploadAudioRequest,
} from "@storyteller/components/src/api/upload/UploadAudio";
import { faCheck, faTrash } from "@fortawesome/free-solid-svg-icons";
import { useLocalize, useF5Store } from "hooks";
import { Button } from "components/common";
interface Props {
setMediaUploadToken: (token?: string) => void;
formIsCleared: boolean;
setFormIsCleared: (cleared: boolean) => void;
setHasRecordedFile: (hasRecordedFile: boolean) => void;
hasRecordedFile: boolean;
setIsRecordingAudio: (isRecordingAudio: boolean) => void;
}
export default function VCRecordComponent(props: Props) {
const { t } = useLocalize("NewVC");
const [uploadLoading, setUploadLoading] = useState(false);
const { startRecording, stopRecording, recordingBlob, isRecording } =
useAudioRecorder();
const {
recordingBlobStore,
setRecordingBlobStore,
isUploadDisabled,
setIsUploadDisabled,
} = useF5Store();
useEffect(() => {
if (!recordingBlob) {
return;
} else setRecordingBlobStore(recordingBlob);
}, [recordingBlob, setRecordingBlobStore]);
const handleStartRecording = async () => {
startRecording();
props.setIsRecordingAudio(true);
};
const handleStopRecording = async (blob: any) => {
stopRecording();
props.setFormIsCleared(false);
setIsUploadDisabled(false);
props.setHasRecordedFile(true);
};
const handleClear = () => {
stopRecording();
props.setMediaUploadToken(undefined);
props.setFormIsCleared(true);
props.setHasRecordedFile(false);
};
const handleUpload = async () => {
const request: UploadAudioRequest = {
uuid_idempotency_token: uuidv4(),
file: recordingBlobStore,
source: "device",
};
setUploadLoading(true);
let result = await UploadAudio(request);
if (UploadAudioIsOk(result)) {
props.setMediaUploadToken(result.upload_token);
props.setFormIsCleared(false);
setIsUploadDisabled(true);
} else {
// @ts-ignore
window.dataLayer.push({
event: "upload_failure",
page: "/voice-conversion",
user_id: "$user_id",
});
}
setUploadLoading(false);
};
const enableMediaReview =
!props.formIsCleared && recordingBlobStore !== undefined;
const enableUploadButton =
!props.formIsCleared && recordingBlobStore !== undefined && !isRecording;
const speakButtonClass = isUploadDisabled
? "btn btn-uploaded w-100 disabled"
: "btn btn-primary w-100";
return (
<div className="d-flex flex-column gap-3" id="record-audio">
{isRecording ? (
<button
className="btn btn-secondary py-3"
onClick={handleStopRecording}
>
<div className="d-flex align-items-center">
<div
className="spinner-grow spinner-grow-sm text-danger me-2"
role="status"
>
<span className="visually-hidden">Recording...</span>
</div>
{t("button.stopRecord")}
</div>
</button>
) : (
<Button
icon={faCircle}
iconClassName="text-danger"
variant="secondary"
label={
props.hasRecordedFile
? t("button.rerecord")
: t("button.startRecord")
}
onClick={handleStartRecording}
className="py-3"
/>
)}
{enableMediaReview ? (
<>
<RecordedAudioComponent recordingBlob={recordingBlobStore} />
<div className="d-flex gap-3">
<button
className={speakButtonClass}
onClick={handleUpload}
type="submit"
disabled={
isUploadDisabled || uploadLoading || !enableUploadButton
}
>
{isUploadDisabled ? (
<>
<FontAwesomeIcon icon={faCheck} className="me-2" />
{t("button.recordUploaded")}
</>
) : (
<>
<FontAwesomeIcon icon={faFileArrowUp} className="me-2" />
{t("button.recordUpload")}
</>
)}
{uploadLoading && <LoadingIcon />}
</button>
<button className="btn btn-destructive w-100" onClick={handleClear}>
<FontAwesomeIcon icon={faTrash} className="me-2" />
{t("button.recordClear")}
</button>
</div>
</>
) : (
<></>
)}
</div>
);
}
interface RecorderProps {
recordingBlob: any;
}
function RecordedAudioComponent(props: RecorderProps) {
// Only generate the URL on change.
const audioLink = useMemo(() => {
if (!props.recordingBlob) {
return;
}
return URL.createObjectURL(props.recordingBlob);
}, [props.recordingBlob]);
if (!props.recordingBlob) {
return <></>;
}
return (
<div className="panel panel-inner rounded p-3">
<InputVcAudioPlayer filename={audioLink as string} />
</div>
);
}
const LoadingIcon: React.FC = () => {
return (
<>
<span
className="spinner-border spinner-border-sm ms-3"
role="status"
aria-hidden="true"
></span>
<span className="visually-hidden">Loading...</span>
</>
);
};
@@ -0,0 +1,227 @@
import {
faCheck,
faFileArrowUp,
faFileAudio,
faTrash,
} from "@fortawesome/pro-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import React, { useState } from "react";
import { FileUploader } from "react-drag-drop-files";
import { InputVcAudioPlayer } from "v2/view/_common/InputVcAudioPlayer";
import { v4 as uuidv4 } from "uuid";
import {
UploadAudio,
UploadAudioIsOk,
UploadAudioRequest,
} from "@storyteller/components/src/api/upload/UploadAudio";
import { useF5Store, useLocalize } from "hooks";
const FILE_TYPES = ["MP3", "WAV", "FLAC", "OGG"];
interface Props {
setMediaUploadToken: (token?: string) => void;
formIsCleared: boolean;
setFormIsCleared: (cleared: boolean) => void;
setHasUploadedFile: (hasUploadedFile: boolean) => void;
}
function F5UploadComponent(props: Props) {
const { t } = useLocalize("UploadComponent");
const [uploadLoading, setUploadLoading] = useState(false);
const [uploaderKey, setUploaderKey] = useState(uuidv4());
const {
isUploadDisabled,
setIsUploadDisabled,
file,
setFile,
audioLink,
setAudioLink,
} = useF5Store();
const handleChange = (file: any) => {
console.log("handle change");
setFile(file);
const audioUrl = URL.createObjectURL(file);
setAudioLink(audioUrl ?? "");
props.setFormIsCleared(false);
setIsUploadDisabled(false);
props.setHasUploadedFile(true);
};
const handleDragOver = (e: React.DragEvent<HTMLDivElement>): void => {
e.preventDefault();
e.stopPropagation();
e.currentTarget.classList.add("upload-zone-drag");
};
const handleDragLeave = (e: React.DragEvent<HTMLDivElement>): void => {
e.preventDefault();
e.stopPropagation();
e.currentTarget.classList.remove("upload-zone-drag");
};
const handleClear = () => {
setFile(null);
setAudioLink("");
setIsUploadDisabled(false);
props.setMediaUploadToken(undefined);
props.setFormIsCleared(true);
props.setHasUploadedFile(false);
setUploaderKey(uuidv4());
};
const handleUploadFile = async () => {
if (file === undefined) {
return false;
}
setUploadLoading(true);
const request: UploadAudioRequest = {
uuid_idempotency_token: uuidv4(),
file: file,
source: "file",
};
let result = await UploadAudio(request);
if (UploadAudioIsOk(result)) {
setIsUploadDisabled(true);
props.setMediaUploadToken(result.upload_token);
props.setFormIsCleared(false);
}
setUploadLoading(false);
};
const fileSize =
file && file.size >= 1024 * 1024
? (file.size / 1024 / 1024).toFixed(2) + " MB"
: file
? `${Math.floor(file.size / 1024)} KB`
: null;
const uploadBtnClass = isUploadDisabled
? "btn fw-medium btn-uploaded w-100 disabled"
: "btn fw-medium btn-primary w-100";
return (
<div className="d-flex flex-column gap-3">
<FileUploader
key={uploaderKey}
handleChange={handleChange}
name="file"
types={FILE_TYPES}
maxSize={50}
children={
<div
className={`panel panel-inner upload-zone d-flex align-items-center justify-content-center p-4 ${
!file ? "empty-zone-f5" : ""
}`}
onDragOver={handleDragOver}
onDragLeave={handleDragLeave}
>
<div className="me-3">
{file ? (
<FontAwesomeIcon icon={faFileAudio} className="upload-icon" />
) : (
<FontAwesomeIcon icon={faFileArrowUp} className="upload-icon" />
)}
</div>
<div>
<div className="pb-0">
{file ? (
<span className="filename" title={file.name}>
{file.name.slice(0, file.name.lastIndexOf("."))}
</span>
) : (
<>
<u className="fw-medium">{t("uploadFileTextUpload")}</u>{" "}
{t("uploadFileTextDrop")}
</>
)}
</div>
<div className="d-flex gap-1">
<div>
{file ? (
<p>
<span className="opacity-50">
{file && `${file.name.split(".").pop().toUpperCase()}`}{" "}
file size: {fileSize}
</span>{" "}
<u className="fw-medium opacity-100 ms-1">
{t("uploadChangeFile")}
</u>
</p>
) : (
<p className="opacity-50">
{FILE_TYPES.join(", ").toString()} supported
</p>
)}
</div>
</div>
</div>
</div>
}
/>
{file ? (
<div className="panel panel-inner rounded p-3">
<InputVcAudioPlayer filename={audioLink as string} />
</div>
) : (
<></>
)}
{file ? (
<div className="d-flex gap-3">
<button
className={uploadBtnClass}
onClick={() => {
handleUploadFile();
}}
type="submit"
disabled={uploadLoading || isUploadDisabled}
>
{isUploadDisabled ? (
<>
<FontAwesomeIcon icon={faCheck} className="me-2" />
{t("uploadButtonUploaded")}
</>
) : (
<>
<FontAwesomeIcon icon={faFileArrowUp} className="me-2" />
{t("uploadButtonUploadAudio")}
</>
)}
{uploadLoading && <LoadingIcon />}
</button>
<button
className="btn btn-destructive w-100 fw-medium"
onClick={handleClear}
>
<FontAwesomeIcon icon={faTrash} className="me-2" />
{t("uploadButtonClear")}
</button>
</div>
) : (
<></>
)}
</div>
);
}
const LoadingIcon: React.FC = () => {
return (
<>
<span
className="spinner-border spinner-border-sm ms-3"
role="status"
aria-hidden="true"
></span>
<span className="visually-hidden">Loading...</span>
</>
);
};
export default F5UploadComponent;