Merge pull request #416 from storytold/bflat-fy-model-weight

polymorphic model weights view page
This commit is contained in:
BFlat authored and GitHub committed 2023-12-13 08:12:16 +07:00
commit 3f144a1b52
32 files changed
+1737 -81

No files matched your search

+1
View File
@@ -43,6 +43,7 @@
/voice-conversion* /index.html 200
/w2l* /index.html 200
/voice-designer* /index.html 200
/weight* /index.html 200
# Test paths
/dev-tts* /index.html 200
@@ -0,0 +1,7 @@
export enum WeightCategory {
TTS = "text_to_speech",
VOCODER = "vocoder",
VC = "voice_conversion",
ZS = "zero_shot",
SD = "image_generation",
}
@@ -0,0 +1,10 @@
export enum WeightType {
TT2 = "tt2",
HIFIGAN_TT2 = "hifigan_tt2",
RVCv2 = "rvc_v2",
SD_15 = "sd_1.5",
SDXL = "sdxl",
SVC = "so_vits_svc",
LORA = "loRA",
VALL_E = "vall_e",
}
@@ -0,0 +1,28 @@
// import MakeRequest from "../MakeRequest";
import { UserDetailsLight } from "../_common/UserDetailsLight";
import { WeightCategory } from "../_common/enums/WeightCategory";
import { WeightType } from "../_common/enums/WeightType";
export interface Weight {
weight_token: string;
weight_type: WeightType;
weight_category: WeightCategory;
maybe_creator_user: UserDetailsLight | null;
creator_set_visibility: string;
created_at: Date;
updated_at: Date;
title: string;
description_markdown: string;
}
// export interface GetMediaRequest {}
// export interface GetMediaResponse {
// success: boolean,
// media_file?: MediaFile,
// }
// export const GetMedia = MakeRequest<string, GetMediaRequest, GetMediaResponse>({
// method: "GET",
// routingFunction: (mediaFileToken: string) => `/v1/media_files/file/${mediaFileToken}`,
// });
@@ -39,17 +39,25 @@ function AccordionItem({
) as AccordionContextType;
const isOpen = openItems.includes(title);
const [contentHeight, setContentHeight] = useState<number | undefined>(
isOpen ? undefined : 0
const [contentHeight, setContentHeight] = useState<number>(
defaultOpen ? 0 : 0
);
const contentRef = useRef<HTMLDivElement>(null);
useEffect(() => {
// Update content height whenever the accordion item is opened or closed
if (contentRef.current) {
setContentHeight(isOpen ? contentRef.current.scrollHeight : 0);
}
}, [isOpen]);
useEffect(() => {
// Adjust height when the content changes, only if the item is open
if (isOpen && contentRef.current) {
setContentHeight(contentRef.current.scrollHeight);
}
}, [children, isOpen]);
const heightProps = useSpring({
height: isOpen ? `${contentHeight}px` : "0px",
config: { tension: 300, friction: 25, clamp: true },
@@ -89,24 +97,22 @@ function AccordionItem({
}
function Accordion({ children }: AccordionProps) {
const [openItems, setOpenItems] = useState<string[]>([]);
// Extract titles of items that should be initially open
const defaultOpenTitles = React.Children.toArray(children)
.filter((child: any) => child.props.defaultOpen)
.map((child: any) => child.props.title);
// Initialize openItems with the titles of items that should be open
const [openItems, setOpenItems] = useState<string[]>(defaultOpenTitles);
const toggleItem = (title: string) => {
if (openItems.includes(title)) {
setOpenItems(openItems.filter((item) => item !== title));
setOpenItems(openItems.filter(item => item !== title));
} else {
setOpenItems([...openItems, title]);
}
};
// Initialize openItems based on defaultOpen props of AccordionItem children
useEffect(() => {
const defaultOpenItems = React.Children.toArray(children)
.filter((child: any) => child.props.defaultOpen)
.map((child: any) => child.props.title);
setOpenItems(defaultOpenItems);
}, [children]);
return (
<AccordionContext.Provider value={{ openItems, toggleItem }}>
<div className="d-flex flex-column gap-3">{children}</div>
@@ -2,7 +2,6 @@
border-radius: 0.5rem !important;
font-weight: 400;
font-size: 14px;
margin-bottom: 1rem !important;
padding: 0.75rem 0.75rem !important;
&.alert-dismissible {
@@ -1,8 +1,9 @@
@import "scss/colours.scss";
.fy-badge {
display: flex;
font-weight: 600;
border-radius: 0.35rem;
border-radius: 0.5rem;
font-size: 13px;
padding: 4px 8px;
transition: 0.2s;
@@ -11,8 +12,8 @@
-webkit-user-select: none;
-ms-user-select: none;
user-select: none;
height: 100%;
margin-bottom: 10px;
padding: 4px 8px;
&.shadow {
box-shadow: 0 0 12px rgba($bg, 0.4);
@@ -46,7 +46,7 @@ export default function AudioCard({ data, type }: AudioCardProps) {
<>
<div className="mb-3">
<div className="d-flex align-items-center">
<div className="flex-grow-1">
<div className="d-flex flex-grow-1">
<Badge label="Audio" color="teal" />
</div>
</div>
@@ -71,7 +71,7 @@ export default function AudioCard({ data, type }: AudioCardProps) {
<>
<div>
<div className="d-flex align-items-center">
<div className="flex-grow-1">
<div className="d-flex flex-grow-1">
<Badge label="RVC" color="orange" />
</div>
<div onClick={handleInnerClick}>
@@ -46,7 +46,7 @@ export default function ImageCard({ data, type }: ImageCardProps) {
<div className="card-img-gradient" />
<div className="d-flex align-items-center">
<div className="flex-grow-1">
<div className="d-flex flex-grow-1">
<Badge label="Image" color="ultramarine" overlay={true} />
</div>
</div>
@@ -78,7 +78,7 @@ export default function ImageCard({ data, type }: ImageCardProps) {
<div className="card-img-gradient" />
<div className="d-flex align-items-center">
<div className="flex-grow-1">
<div className="d-flex flex-grow-1">
<Badge label="LORA" color="pink" overlay={true} />
</div>
<div onClick={handleInnerClick}>
@@ -42,7 +42,7 @@ export default function VideoCard({ data, type }: VideoCardProps) {
<div className="card-img-gradient" />
<div className="d-flex align-items-center">
<div className="flex-grow-1">
<div className="d-flex flex-grow-1">
<Badge label="Video" color="purple" overlay={true} />
</div>
</div>
@@ -77,7 +77,7 @@ export default function VideoCard({ data, type }: VideoCardProps) {
<div className="card-img-gradient" />
<div className="d-flex align-items-center">
<div className="flex-grow-1">
<div className="d-flex flex-grow-1">
<Badge label="Video" color="purple" overlay={true} />
</div>
</div>
@@ -3,6 +3,7 @@
.favorite-button {
display: flex;
align-items: center;
justify-content: center;
background-color: rgba(lighten($secondary, 5%), 0.9);
color: inherit;
border: none;
@@ -13,6 +14,7 @@
font-size: 16px;
transition: 0.2s;
padding: 3px 8px;
min-width: 56px;
border-radius: 0.5rem;
&.shadow {
@@ -46,10 +48,14 @@
);
}
}
&.large {
padding: 6px 10px;
}
}
.favorite-button .icon-default {
color: $white;
color: map-get($badge-colors, "yellow");
opacity: 0.7;
transition: 0.2s;
}
@@ -64,9 +70,10 @@
}
.favorite-number {
color: $white;
font-size: 14px;
font-weight: 400 !important;
opacity: 0.8;
opacity: 0.9;
transition: 0.2s;
}
@@ -5,12 +5,14 @@ import { faBookmark as faBookmarkOutline } from "@fortawesome/pro-regular-svg-ic
import Tippy from "@tippyjs/react";
import "tippy.js/dist/tippy.css";
import "./FavoriteButton.scss";
import useShortenNumber from "hooks/useShortenNumber";
interface FavoriteButtonProps {
initialToggled?: boolean;
onToggle: (toggled: boolean) => Promise<void>;
favoriteCount?: number;
favoriteCount: number;
overlay?: boolean;
large?: boolean;
}
export default function FavoriteButton({
@@ -18,6 +20,7 @@ export default function FavoriteButton({
onToggle,
favoriteCount,
overlay,
large,
}: FavoriteButtonProps) {
const [isToggled, setIsToggled] = useState(initialToggled);
const [isLoading, setIsLoading] = useState(false);
@@ -37,7 +40,8 @@ export default function FavoriteButton({
const buttonClass = isToggled ? "favorite-button toggled" : "favorite-button";
const buttonShadow = overlay ? "shadow" : "";
const iconClass = isToggled ? "icon-toggled" : "icon-default";
const toolTip = isToggled ? "Unfavorite" : "Favorite";
const toolTip = isToggled ? "Unbookmark" : "Bookmark";
let favoriteCountShort = useShortenNumber(favoriteCount);
return (
<div className="d-flex gap-2" onClick={handleClick}>
@@ -52,13 +56,15 @@ export default function FavoriteButton({
<button
onClick={handleClick}
disabled={isLoading}
className={`${buttonClass} ${buttonShadow}`}
className={`${buttonClass} ${buttonShadow} ${large ? "large" : ""}`}
>
<FontAwesomeIcon
icon={isToggled ? faBookmark : faBookmarkOutline}
className={`${iconClass} me-2`}
/>
{favoriteCount && <p className="favorite-number">{favoriteCount}</p>}
{favoriteCount && (
<p className="favorite-number">{favoriteCountShort}</p>
)}
</button>
</Tippy>
</div>
@@ -3,6 +3,7 @@
.like-button {
display: flex;
align-items: center;
justify-content: center;
background-color: rgba(lighten($secondary, 5%), 0.9);
color: inherit;
border: none;
@@ -40,13 +41,17 @@
background-color: rgba(#593a48, 0.9);
&:hover {
background-color: darken(rgba(#593a48, 0.9), 2%);
background-color: lighten(rgba(#593a48, 0.9), 10%);
}
}
&.large {
padding: 6px 10px;
}
}
.like-button .icon-default {
color: $white;
color: $primary;
opacity: 0.7;
transition: 0.2s;
}
@@ -61,9 +66,10 @@
}
.like-number {
color: $white;
font-size: 14px;
font-weight: 400 !important;
opacity: 0.8;
opacity: 0.9;
transition: 0.2s;
}
@@ -5,12 +5,14 @@ import { faHeart as faHeartOutline } from "@fortawesome/pro-regular-svg-icons";
import Tippy from "@tippyjs/react";
import "tippy.js/dist/tippy.css";
import "./LikeButton.scss";
import useShortenNumber from "hooks/useShortenNumber";
interface LikeButtonProps {
initialToggled?: boolean;
onToggle: (toggled: boolean) => Promise<void>;
likeCount?: number;
likeCount: number;
overlay?: boolean;
large?: boolean;
}
export default function LikeButton({
@@ -18,6 +20,7 @@ export default function LikeButton({
onToggle,
likeCount,
overlay,
large,
}: LikeButtonProps) {
const [isToggled, setIsToggled] = useState(initialToggled);
const [isLoading, setIsLoading] = useState(false);
@@ -38,6 +41,7 @@ export default function LikeButton({
const buttonShadow = overlay ? "shadow" : "";
const iconClass = isToggled ? "icon-toggled" : "icon-default";
const toolTip = isToggled ? "Unlike" : "Like";
let likeCountShort = useShortenNumber(likeCount);
return (
<div className="d-flex gap-2" onClick={handleClick}>
@@ -53,13 +57,13 @@ export default function LikeButton({
<button
onClick={handleClick}
disabled={isLoading}
className={`${buttonClass} ${buttonShadow}`}
className={`${buttonClass} ${buttonShadow} ${large ? "large" : ""}`}
>
<FontAwesomeIcon
icon={isToggled ? faHeart : faHeartOutline}
className={`${iconClass} me-2`}
/>
{likeCount && <p className="like-number">{likeCount}</p>}
{likeCount && <p className="like-number">{likeCountShort}</p>}
</button>
</Tippy>
</div>
@@ -0,0 +1,45 @@
@import "scss/colours.scss";
.modal-backdrop {
position: fixed;
top: 0;
left: 0;
background-color: rgba(darken($panel, 8%), 0.7) !important;
display: flex;
justify-content: center;
align-items: center;
}
.modal {
z-index: 1050;
display: block;
}
.modal-width-auto {
max-width: fit-content;
margin: auto;
}
.modal-content {
flex: 0 1 auto;
min-width: 0;
background-color: $panel;
border-color: lighten($panel, 8%) !important;
border: 1px solid;
border-radius: 0.5rem;
box-shadow: 0px 9px 33px 1px rgba(19, 19, 27, 0.8);
-webkit-box-shadow: 0px 9px 33px 1px rgba(19, 19, 27, 0.8);
-moz-box-shadow: 0px 9px 33px 1px rgba(19, 19, 27, 0.8);
}
.modal-header {
border-bottom: 1px solid lighten($panel, 8%);
}
.modal-header .btn-close {
filter: brightness(0) invert(1);
}
.modal-footer {
border-top: 1px solid lighten($panel, 8%);
}
@@ -1,10 +1,9 @@
import React, { useRef, useEffect } from "react";
import React, { useEffect } from "react";
import Button from "../Button";
import { faTrashAlt } from "@fortawesome/pro-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { useSpring, a } from "@react-spring/web";
import { IconDefinition } from "@fortawesome/fontawesome-svg-core";
import "./Modal.scss";
interface ModalProps {
show: boolean;
@@ -13,19 +12,47 @@ interface ModalProps {
onConfirm?: (e: React.MouseEvent<HTMLElement>) => any;
title: string;
content: React.ReactNode;
icon?: IconDefinition;
autoWidth?: boolean;
showButtons?: boolean;
}
const Modal: React.FC<ModalProps> = ({ show, handleClose, onCancel: cancelEvent, onConfirm: confirmEvent, title, content }) => {
const modalRef = useRef<HTMLDivElement>(null);
const Modal: React.FC<ModalProps> = ({
show,
handleClose,
onCancel: cancelEvent,
onConfirm: confirmEvent,
title,
content,
icon,
autoWidth,
showButtons = true,
}) => {
const fadeIn = useSpring({
opacity: show ? 1 : 0,
config: { duration: 80, easing: (t) => t },
config: { duration: 80, easing: t => t },
onRest: () => {
if (!show) handleClose();
},
});
useEffect(() => {
const handleClickOutside = (event: MouseEvent) => {
const modalContent = document.querySelector(".modal-content");
if (modalContent && !modalContent.contains(event.target as Node)) {
handleClose();
}
};
if (show) {
document.addEventListener("mousedown", handleClickOutside);
}
return () => {
document.removeEventListener("mousedown", handleClickOutside);
};
}, [show, handleClose]);
const onCancel = (e: React.MouseEvent<HTMLElement>) => {
if (cancelEvent) cancelEvent(e);
handleClose();
@@ -36,40 +63,22 @@ const Modal: React.FC<ModalProps> = ({ show, handleClose, onCancel: cancelEvent,
handleClose();
};
useEffect(() => {
const handleClickOutside = (event: MouseEvent) => {
if (
modalRef.current &&
!modalRef.current.contains(event.target as Node)
) {
handleClose();
}
};
document.addEventListener("mousedown", handleClickOutside);
return () => {
document.removeEventListener("mousedown", handleClickOutside);
};
}, [handleClose]);
if (!show) {
return null;
}
return (
<a.div style={fadeIn} className="modal-backdrop">
<div
ref={modalRef}
className="modal"
role="dialog"
style={{ display: "block" }}
>
<div className="modal-dialog modal-dialog-centered">
<div className="modal" role="dialog">
<div
className={`modal-dialog modal-dialog-centered ${
autoWidth && "modal-width-auto"
}`}
>
<div className="modal-content">
<div className="modal-header">
<h5 className="modal-title">
<FontAwesomeIcon icon={faTrashAlt} className="me-3" />
{icon && <FontAwesomeIcon icon={icon} className="me-3" />}
{title}
</h5>
<button
@@ -80,14 +89,14 @@ const Modal: React.FC<ModalProps> = ({ show, handleClose, onCancel: cancelEvent,
></button>
</div>
<div className="modal-body">{content}</div>
<div className="modal-footer">
<Button
variant="secondary"
label="Cancel"
onClick={onCancel}
/>
<Button variant="danger" label="Delete" onClick={onConfirm} />
</div>
{showButtons && (
<div className="modal-footer">
<Button variant="secondary" label="Cancel" onClick={onCancel} />
{onConfirm && (
<Button variant="danger" label="Delete" onClick={onConfirm} />
)}
</div>
)}
</div>
</div>
</div>
@@ -0,0 +1,4 @@
.rating-stars {
font-size: 16px;
color: #e88b00;
}
@@ -0,0 +1,46 @@
import React from "react";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { faStar, faStarHalfAlt } from "@fortawesome/pro-solid-svg-icons";
import { faStar as faStarOutline } from "@fortawesome/pro-regular-svg-icons";
import "./RatingStars.scss";
type StarRatingProps = {
rating: number;
};
const RatingStars: React.FC<StarRatingProps> = ({ rating }) => {
const roundToNearestHalf = (num: number) => Math.round(num * 2) / 2;
const roundedRating = roundToNearestHalf(rating);
const fullStars = Math.floor(roundedRating);
const hasHalfStar = roundedRating % 1 !== 0;
const stars = [];
for (let i = 0; i < fullStars; i++) {
stars.push(<FontAwesomeIcon key={`full-${i}`} icon={faStar} />);
}
if (hasHalfStar) {
stars.push(<FontAwesomeIcon key="half" icon={faStarHalfAlt} />);
}
while (stars.length < 5) {
stars.push(
<FontAwesomeIcon
key={`empty-${stars.length}`}
icon={faStarOutline}
className="empty-star"
/>
);
}
return (
<div className="d-flex gap-2 p-1 align-items-center rating-stars">
<div className="d-flex">{stars}</div>
<p className="fw-medium">{rating}</p>
</div>
);
};
export default RatingStars;
@@ -0,0 +1 @@
export { default } from "./RatingStars";
@@ -0,0 +1,75 @@
@import "scss/colours.scss";
.social-button {
display: flex;
flex-direction: column;
align-items: center;
margin: 0;
padding: 0;
background: none;
border: none;
}
.social-button-icon {
font-size: 32px !important;
height: 52px;
width: 52px;
display: flex;
align-items: center;
justify-content: center;
border-radius: 0.5rem;
border: 0px solid;
margin-bottom: 10px;
transition: 0.2s;
&:hover {
opacity: 0.8;
}
&.bg-facebook {
color: $white;
background-color: #4267b2;
}
&.bg-reddit {
color: $white;
background-color: #ff5700;
}
&.bg-reddit {
color: $white;
background-color: #ff5700;
}
&.bg-whatsapp {
color: $white;
background-color: #25d366;
}
&.bg-x {
color: $white;
background-color: #1da1f2;
}
&.bg-email {
color: $white;
background-color: #858585;
}
}
.social-button-text {
font-size: 12px;
font-weight: 600;
}
@media (min-width: 768px) {
.social-button-icon {
height: 62px;
width: 62px;
}
.social-button-text {
font-size: 13px;
font-weight: 600;
}
}
@@ -0,0 +1,76 @@
import {
faFacebookF,
faRedditAlien,
faWhatsapp,
faTwitter,
} from "@fortawesome/free-brands-svg-icons";
import { faXmark, faEnvelope } from "@fortawesome/pro-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import React from "react";
import "./SocialButton.scss";
interface SocialButtonProps {
social: "x" | "whatsapp" | "facebook" | "reddit" | "email";
shareUrl: string;
shareText: string;
}
export default function SocialButton({
social,
shareUrl,
shareText,
}: SocialButtonProps) {
const socialIcons = {
x: faTwitter,
whatsapp: faWhatsapp,
facebook: faFacebookF,
reddit: faRedditAlien,
email: faEnvelope,
};
let socialIcon = socialIcons[social] || faXmark;
const getShareLink = (social: string, url: string, text: string) => {
switch (social) {
case "x":
return `https://twitter.com/intent/tweet?url=${encodeURIComponent(
url
)}&text=${encodeURIComponent(text)}`;
case "facebook":
return `https://www.facebook.com/sharer/sharer.php?u=${encodeURIComponent(
url
)}`;
case "whatsapp":
return `https://wa.me/?text=${encodeURIComponent(
text
)} ${encodeURIComponent(url)}`;
case "reddit":
return `https://www.reddit.com/submit?url=${encodeURIComponent(
url
)}&title=${encodeURIComponent(text)}`;
case "email":
return `mailto:?subject=${encodeURIComponent(
text
)}&body=${encodeURIComponent(url)}`;
default:
return "#";
}
};
const handleClick = () => {
const shareLink = getShareLink(social, shareUrl, shareText);
window.open(shareLink, "_blank");
};
return (
<button className="social-button" onClick={handleClick}>
<div className={`social-button-icon bg-${social}`}>
<FontAwesomeIcon icon={socialIcon} />
</div>
<p className="social-button-text">
{social.charAt(0).toUpperCase() + social.slice(1)}
</p>
</button>
);
}
@@ -0,0 +1 @@
export { default } from "./SocialButton";
@@ -31,7 +31,8 @@ const Footer: React.FC<FooterProps> = ({ children, padding }) => {
// Panel Interface
interface PanelProps {
children: ReactNode;
divider?: boolean;
dividerHeader?: boolean;
dividerFooter?: boolean;
clear?: boolean;
className?: string;
}
@@ -41,7 +42,7 @@ const SplitPanel: React.FC<PanelProps> & {
Header: typeof Header;
Body: typeof Body;
Footer: typeof Footer;
} = ({ children, divider, clear, className }) => {
} = ({ children, dividerHeader, dividerFooter, clear, className }) => {
let header: ReactNode, body: ReactNode, footer: ReactNode;
const panelClassName = `${clear ? "panel-clear" : "panel"} ${
@@ -67,9 +68,9 @@ const SplitPanel: React.FC<PanelProps> & {
return (
<div className={panelClassName}>
{header}
{divider && <hr className="m-0" />}
{dividerHeader && <hr className="m-0" />}
{body}
{divider && <hr className="m-0" />}
{dividerFooter && <hr className="m-0" />}
{footer}
</div>
);
@@ -0,0 +1,83 @@
import React, { useState } from "react";
import { useSpring, a } from "@react-spring/web";
import "./Tabs.scss";
import { IconDefinition } from "@fortawesome/fontawesome-svg-core";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
interface NonRouteTabProps {
label: string;
icon?: IconDefinition;
isActive: boolean;
onClick: () => void;
}
interface NonRouteTabsProps {
tabs: {
label: string;
content: React.ReactNode;
icon?: IconDefinition;
padding?: boolean;
}[];
}
function NonRouteTab({ label, icon, isActive, onClick }: NonRouteTabProps) {
return (
<li className="nav-item" onClick={onClick}>
<div className={`nav-link fs-6 px-3 px-lg-4 ${isActive ? "active" : ""}`}>
{icon && <FontAwesomeIcon icon={icon} className="me-2" />}
{label}
</div>
</li>
);
}
function NonRouteTabs({ tabs }: NonRouteTabsProps) {
const [activeTab, setActiveTab] = useState(tabs[0].label);
const [fade, setFade] = useSpring(() => ({
opacity: 1,
from: { opacity: 0 },
config: { duration: 300 },
}));
const handleTabClick = (tabLabel: string) => {
if (tabLabel === activeTab) {
return;
}
setFade({ opacity: 0 });
setTimeout(() => {
setActiveTab(tabLabel);
setFade({ opacity: 1 });
}, 50);
};
return (
<nav>
<ul className="nav nav-tabs">
{tabs.map(tab => (
<NonRouteTab
key={tab.label}
label={tab.label}
icon={tab.icon}
isActive={tab.label === activeTab}
onClick={() => handleTabClick(tab.label)}
/>
))}
</ul>
{tabs.map(tab => {
if (tab.label === activeTab) {
return (
<a.div key={tab.label} style={fade}>
<div className={tab.padding ? "p-3 py-4 p-md-4" : ""}>
{tab.content}
</div>
</a.div>
);
}
return null;
})}
</nav>
);
}
export default NonRouteTabs;
@@ -11,6 +11,7 @@
font-weight: 500;
white-space: nowrap;
transition: 0.2s;
cursor: pointer;
&:focus {
border-bottom: 4px solid transparent;
@@ -94,10 +94,10 @@ export default function PageHeader({
if (!panel) {
return (
<div className="py-3 py-lg-4">
<div className="py-3">
<Panel clear={true}>
{(back || showBackButton) && (
<div className="d-flex mb-2 mb-lg-3">
<div className="d-flex my-2 my-lg-3">
<BackButton
label={back ? back.label : backbuttonLabel}
to={back ? back.to : backbuttonTo}
@@ -43,6 +43,10 @@ styles with important tags:
}
}
.page-title {
font-size: 2.5rem;
}
.btn-link {
color: $primary;
text-decoration: none;
@@ -98,6 +98,7 @@ import { PasswordResetVerificationPage } from "./pages/password_reset/PasswordRe
import { NewProfilePage } from "./pages/profile/profile_view/NewProfilePage";
import { ProfilePage } from "./pages/profile/profile_view/ProfilePage";
import { ModerationJobControlPage } from "./pages/moderation/job_control/ModerationJobControlPage";
import WeightPage from "./pages/weight/WeightPage";
interface Props {
sessionWrapper: SessionWrapper;
@@ -356,6 +357,24 @@ class PageContainer extends React.Component<
<MediaPage sessionWrapper={this.props.sessionWrapper} />
</Route>
{/* <Route path="/weight/:weight_token/edit">
<WeightEditPage />
</Route> */}
<Route path="/weight/:weight_token">
<WeightPage
sessionWrapper={this.props.sessionWrapper}
sessionSubscriptionsWrapper={
this.props.sessionSubscriptionsWrapper
}
inferenceJobs={this.props.inferenceJobs}
enqueueInferenceJob={this.props.enqueueInferenceJob}
inferenceJobsByCategory={this.props.inferenceJobsByCategory}
ttsInferenceJobs={this.props.ttsInferenceJobs}
enqueueTtsJob={this.props.enqueueTtsJob}
/>
</Route>
<Route path="/dev-tts">
<TtsModelSearchPage sessionWrapper={this.props.sessionWrapper} />
</Route>
@@ -628,7 +647,8 @@ class PageContainer extends React.Component<
<FaceAnimator
{...{
enqueueInferenceJob: this.props.enqueueInferenceJob,
sessionSubscriptionsWrapper: this.props.sessionSubscriptionsWrapper,
sessionSubscriptionsWrapper:
this.props.sessionSubscriptionsWrapper,
inferenceJobs: this.props.inferenceJobs,
inferenceJobsByCategory: this.props.inferenceJobsByCategory,
}}
@@ -0,0 +1,557 @@
import React, { useCallback, useEffect, useState } from "react";
import { Link, useParams } from "react-router-dom";
import { SessionWrapper } from "@storyteller/components/src/session/SessionWrapper";
import { Weight } from "@storyteller/components/src/api/weights/GetWeight";
import Container from "components/common/Container";
import Panel from "components/common/Panel";
import PageHeader from "components/layout/PageHeader";
import Skeleton from "components/common/Skeleton";
import Button from "components/common/Button";
import {
faCircleExclamation,
faLink,
faShare,
} from "@fortawesome/pro-solid-svg-icons";
import Accordion from "components/common/Accordion";
import DataTable from "components/common/DataTable";
import { Gravatar } from "@storyteller/components/src/elements/Gravatar";
import useTimeAgo from "hooks/useTimeAgo";
import { CommentComponent } from "v2/view/_common/comments/CommentComponent";
import { WeightType } from "@storyteller/components/src/api/_common/enums/WeightType";
import { WeightCategory } from "@storyteller/components/src/api/_common/enums/WeightCategory";
import { SessionSubscriptionsWrapper } from "@storyteller/components/src/session/SessionSubscriptionsWrapper";
import {
FrontendInferenceJobType,
InferenceJob,
} from "@storyteller/components/src/jobs/InferenceJob";
import { TtsInferenceJob } from "@storyteller/components/src/jobs/TtsInferenceJobs";
import Badge from "components/common/Badge";
import FavoriteButton from "components/common/FavoriteButton";
import LikeButton from "components/common/LikeButton";
import VdInferencePanel from "./inference_panels/VdInferencePanel";
import VcInferencePanel from "./inference_panels/VcInferencePanel";
import TtsInferencePanel from "./inference_panels/TtsInferencePanel";
import Modal from "components/common/Modal";
import SocialButton from "components/common/SocialButton";
import Input from "components/common/Input";
interface WeightProps {
sessionWrapper: SessionWrapper;
sessionSubscriptionsWrapper: SessionSubscriptionsWrapper;
inferenceJobs: Array<InferenceJob>;
ttsInferenceJobs: Array<TtsInferenceJob>;
enqueueInferenceJob: (
jobToken: string,
frontendInferenceJobType: FrontendInferenceJobType
) => void;
inferenceJobsByCategory: Map<FrontendInferenceJobType, Array<InferenceJob>>;
enqueueTtsJob: (jobToken: string) => void;
}
export default function WeightPage({
sessionWrapper,
sessionSubscriptionsWrapper,
inferenceJobs,
ttsInferenceJobs,
enqueueInferenceJob,
enqueueTtsJob,
inferenceJobsByCategory,
}: WeightProps) {
const { weight_token } = useParams<{ weight_token: string }>();
const [weight, setWeight] = useState<Weight | undefined | null>(null);
const [isLoading, setIsLoading] = useState<boolean>(true);
const [error, setError] = useState<boolean>(false);
const timeUpdated = useTimeAgo(weight?.updated_at.toISOString() || "");
const [isShareModalOpen, setIsShareModalOpen] = useState(false);
const [buttonLabel, setButtonLabel] = useState("Copy");
const getWeight = useCallback(async (weightToken: string) => {
// Dummy data
const dummyData = {
weight_token: "TM:xke15gz3v8pv",
title: "Harry Potter (Daniel Radcliffe)",
created_at: new Date(),
updated_at: new Date(),
weight_type: WeightType.HIFIGAN_TT2,
weight_category: WeightCategory.TTS,
maybe_creator_user: {
user_token: "test",
username: "test",
display_name: "Test",
gravatar_hash: "test",
default_avatar: {
image_index: 1,
color_index: 2,
},
},
creator_set_visibility: "Public",
description_markdown:
"Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat.",
};
// Simulate an API call delay
await new Promise(resolve => setTimeout(resolve, 1000));
if (dummyData) {
setWeight(dummyData);
setIsLoading(false);
} else {
setError(true);
setIsLoading(false);
}
}, []);
useEffect(() => {
getWeight(weight_token);
}, [weight_token, getWeight]);
function renderWeightComponent(weight: Weight) {
switch (weight.weight_category) {
case WeightCategory.TTS:
return (
<TtsInferencePanel
inferenceJobs={inferenceJobs}
sessionSubscriptionsWrapper={sessionSubscriptionsWrapper}
enqueueInferenceJob={enqueueInferenceJob}
inferenceJobsByCategory={inferenceJobsByCategory}
ttsInferenceJobs={ttsInferenceJobs}
enqueueTtsJob={enqueueTtsJob}
voiceToken={weight.weight_token}
/>
);
case WeightCategory.VC:
return (
<VcInferencePanel
sessionSubscriptionsWrapper={sessionSubscriptionsWrapper}
enqueueInferenceJob={enqueueInferenceJob}
inferenceJobs={inferenceJobs}
inferenceJobsByCategory={inferenceJobsByCategory}
voiceToken={weight.weight_token}
/>
);
case WeightCategory.ZS:
return (
<VdInferencePanel
inferenceJobs={inferenceJobs}
sessionSubscriptionsWrapper={sessionSubscriptionsWrapper}
enqueueInferenceJob={enqueueInferenceJob}
inferenceJobsByCategory={inferenceJobsByCategory}
ttsInferenceJobs={ttsInferenceJobs}
voiceToken={weight.weight_token}
/>
);
default:
return null;
}
}
//Loading state
if (isLoading)
return (
<>
<Container type="padded" className="pt-4 pt-lg-5">
<div className="row g-4">
<div className="col-12 col-xl-8">
<h1 className="mb-0">
<Skeleton />
</h1>
<div className="panel p-3 py-4 p-md-4 mt-4 d-none d-xl-block">
<h4 className="fw-semibold mb-3">
<Skeleton type="short" />
</h4>
<h1>
<Skeleton />
</h1>
</div>
</div>
<div className="col-12 col-xl-4 d-flex flex-column gap-2">
<h1 className="mb-0">
<Skeleton type="medium" />
</h1>
<h1 className="mb-0">
<Skeleton />
</h1>
<h1 className="mb-0">
<Skeleton />
</h1>
</div>
</div>
</Container>
<div className="d-xl-none mt-4">
<Panel padding>
<h4 className="fw-semibold mb-3">
<Skeleton type="short" />
</h4>
<h1 className="mb-0">
<Skeleton />
</h1>
</Panel>
</div>
</>
);
//Error state
if (error || !weight)
return (
<Container type="panel">
<PageHeader
titleIcon={faCircleExclamation}
title="Media not found"
subText="This media does not exist or is private."
panel={true}
extension={
<div className="d-flex">
<Button label="Back to homepage" to="/" className="d-flex" />
</div>
}
/>
</Container>
);
const weightTypeMap: Record<
WeightType,
{ weightType: string; weightTagColor: string }
> = {
[WeightType.TT2]: {
weightType: "Tacotron 2",
weightTagColor: "ultramarine",
},
[WeightType.HIFIGAN_TT2]: {
weightType: "HiFi-GAN Tacotron 2",
weightTagColor: "blue",
},
[WeightType.VALL_E]: { weightType: "VALL-E", weightTagColor: "purple" },
[WeightType.LORA]: { weightType: "LoRA", weightTagColor: "pink" },
[WeightType.RVCv2]: {
weightType: "RVCv2",
weightTagColor: "orange",
},
[WeightType.SD_15]: {
weightType: "Stable Diffusion 1.5",
weightTagColor: "lime",
},
[WeightType.SDXL]: {
weightType: "Stable Diffusion XL",
weightTagColor: "green",
},
[WeightType.SVC]: {
weightType: "SVC",
weightTagColor: "aqua",
},
};
let { weightType, weightTagColor } = weightTypeMap[weight.weight_type] || {
weightType: "",
weightTagColor: "",
};
const weightCategoryMap: Record<WeightCategory, { weightCategory: string }> =
{
[WeightCategory.TTS]: { weightCategory: "Text to Speech" },
[WeightCategory.VC]: { weightCategory: "Voice to Voice" },
[WeightCategory.SD]: { weightCategory: "Stable Diffusion" },
[WeightCategory.ZS]: { weightCategory: "Voice Designer" },
[WeightCategory.VOCODER]: { weightCategory: "Vocoder" },
};
let { weightCategory } = weightCategoryMap[weight.weight_category] || {
weightCategory: "",
};
const voiceDetails = [
{ property: "Type", value: weightType },
{ property: "Category", value: weightCategory },
{
property: "Visibility",
value: weight.creator_set_visibility.toString(),
},
{ property: "Created at", value: weight.created_at.toString() },
{ property: "Updated at", value: weight.updated_at.toString() },
];
const imageDetails = [
{ property: "Type", value: weightType },
{ property: "Category", value: weightCategory },
{
property: "Visibility",
value: weight.creator_set_visibility.toString(),
},
{ property: "Created at", value: weight.created_at.toString() },
{ property: "Updated at", value: weight.updated_at.toString() },
//more to add for image/stable diffusion details
];
let weightDetails = undefined;
switch (weight.weight_category) {
case WeightCategory.TTS:
weightDetails = <DataTable data={voiceDetails} />;
break;
case WeightCategory.VC:
weightDetails = <DataTable data={voiceDetails} />;
break;
case WeightCategory.ZS:
weightDetails = <DataTable data={voiceDetails} />;
break;
case WeightCategory.SD:
weightDetails = <DataTable data={imageDetails} />;
break;
default:
}
let modMediaDetails = undefined;
//dummy content
const modDetails = [
{ property: "Model creator is banned", value: "good standing" },
{
property: "Result creator is banned (if user)",
value: "good standing",
},
{
property: "Result creator IP address",
value: "0.0.0.0",
},
{
property: "Mod deleted at (UTC)",
value: "not deleted",
},
{
property: "Result creator deleted at (UTC)",
value: "not deleted",
},
];
if (sessionWrapper.canBanUsers()) {
modMediaDetails = (
<Accordion.Item title="Moderator Details" defaultOpen={false}>
<DataTable data={modDetails} />
</Accordion.Item>
);
}
const handleBookmark = async (data: any) => {
console.log(
`The item is now ${data.isLiked ? "Bookmarked" : "Not Bookmarked"}.`
);
};
const subtitleDivider = <span className="opacity-25 fs-5 fw-light">|</span>;
const openShareModal = () => {
setIsShareModalOpen(true);
};
const closeShareModal = () => {
setIsShareModalOpen(false);
};
const handleCopyLink = () => {
navigator.clipboard.writeText(shareUrl);
setButtonLabel("Copied!");
setTimeout(() => setButtonLabel("Copy"), 1000);
};
const shareUrl = `https://fakeyou.com/weight/${weight.weight_token}`;
const shareText = `Use FakeYou to generate speech as ${
weight.title || "your favorite characters"
}!`;
return (
<div>
<Container type="panel" className="mb-5">
<PageHeader
title={
<div className="d-flex gap-2 align-items-center flex-wrap">
<span className="mb-1">{weight.title}</span>
</div>
}
subText={
<div className="d-flex gap-3 flex-wrap align-items-center">
<div className="d-flex gap-2 align-items-center flex-wrap">
<div>
<Badge label={weightType} color={weightTagColor} />
</div>
{subtitleDivider}
<p>{weightCategory}</p>
{subtitleDivider}
<div className="d-flex align-items-center gap-2">
<LikeButton
likeCount={1200}
onToggle={handleBookmark}
large={true}
/>
<FavoriteButton
favoriteCount={100}
onToggle={handleBookmark}
large={true}
/>
</div>
</div>
</div>
}
/>
<div className="row g-4">
<div className="col-12 col-xl-8 d-flex flex-column gap-3">
<div className="media-wrapper">{renderWeightComponent(weight)}</div>
{weight.description_markdown !== "" && (
<Panel padding={true}>
<h4 className="fw-semibold mb-3">Description</h4>
<p>{weight.description_markdown}</p>
</Panel>
)}
<div className="panel p-3 py-4 p-md-4 d-none d-xl-block">
<h4 className="fw-semibold mb-3">Comments</h4>
<CommentComponent
entityType="user"
entityToken={weight.weight_token}
sessionWrapper={sessionWrapper}
/>
</div>
</div>
<div className="col-12 col-xl-4">
<div className="panel panel-clear d-flex flex-column gap-3">
<div className="d-flex gap-2 flex-wrap">
<Button
variant="secondary"
icon={faShare}
label="Share"
className="flex-grow-1"
onClick={openShareModal}
/>
{/* Share and Create Buttons */}
{/* <div className="d-flex gap-2">
<Button
square={true}
variant="secondary"
icon={faCirclePlay}
onClick={() => {}}
tooltip="Create"
/>
<Button
square={true}
variant="secondary"
icon={faShare}
onClick={() => {}}
tooltip="Share"
/>
</div> */}
</div>
<Panel className="rounded">
<div className="d-flex gap-2 p-3">
<Gravatar
size={48}
username={weight.maybe_creator_user?.display_name}
avatarIndex={
weight.maybe_creator_user?.default_avatar.image_index
}
backgroundIndex={
weight.maybe_creator_user?.default_avatar.color_index
}
/>
<div className="d-flex flex-column">
<Link
className="fw-medium"
to={`/profile/${weight.maybe_creator_user?.display_name}`}
>
{weight.maybe_creator_user?.display_name}
</Link>
<p className="fs-7">Updated: {timeUpdated}</p>
</div>
</div>
</Panel>
<Accordion>
<Accordion.Item title="Weight Details" defaultOpen={true}>
{weightDetails}
</Accordion.Item>
{modMediaDetails}
</Accordion>
<div className="d-flex gap-2">
<Button full={true} variant="secondary" label="Edit Weight" />
<Button full={true} variant="danger" label="Delete Weight" />
</div>
</div>
</div>
</div>
</Container>
<div className="d-xl-none my-4">
<Container type="panel">
<Panel padding={true}>
<h4 className="fw-semibold mb-3">Comments</h4>
<CommentComponent
entityType="user"
entityToken={weight.weight_token}
sessionWrapper={sessionWrapper}
/>
</Panel>
</Container>
</div>
{/* Share Modal */}
<Modal
show={isShareModalOpen}
handleClose={closeShareModal}
title="Share"
autoWidth={true}
showButtons={false}
content={
<div className="d-flex flex-column gap-4">
<div className="d-flex gap-3">
<SocialButton
social="x"
shareUrl={shareUrl}
shareText={shareText}
/>
<SocialButton
social="whatsapp"
shareUrl={shareUrl}
shareText={shareText}
/>
<SocialButton
social="facebook"
shareUrl={shareUrl}
shareText={shareText}
/>
<SocialButton
social="reddit"
shareUrl={shareUrl}
shareText={shareText}
/>
<SocialButton
social="email"
shareUrl={shareUrl}
shareText={shareText}
/>
</div>
<div className="d-flex gap-2">
<div className="flex-grow-1">
<Input type="text" value={shareUrl} readOnly />
</div>
<Button
icon={faLink}
label={buttonLabel}
onClick={handleCopyLink}
variant="primary"
/>
</div>
</div>
}
/>
</div>
);
}
@@ -0,0 +1,216 @@
import React, { useState } from "react";
import { faDeleteLeft } from "@fortawesome/pro-solid-svg-icons";
import Panel from "components/common/Panel/Panel";
import { SessionSubscriptionsWrapper } from "@storyteller/components/src/session/SessionSubscriptionsWrapper";
import TextArea from "components/common/TextArea";
import { Button } from "components/common";
import {
FrontendInferenceJobType,
InferenceJob,
} from "@storyteller/components/src/jobs/InferenceJob";
import { TtsInferenceJob } from "@storyteller/components/src/jobs/TtsInferenceJobs";
import { faVolumeUp } from "@fortawesome/free-solid-svg-icons";
import { v4 as uuidv4 } from "uuid";
import Accordion from "components/common/Accordion";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import {
GenerateTtsAudio,
GenerateTtsAudioErrorType,
GenerateTtsAudioIsError,
GenerateTtsAudioIsOk,
} from "@storyteller/components/src/api/tts/GenerateTtsAudio";
import { Analytics } from "common/Analytics";
import { Link } from "react-router-dom";
import { SessionTtsInferenceResultList } from "v2/view/_common/SessionTtsInferenceResultsList";
interface TtsInferencePanelProps {
sessionSubscriptionsWrapper: SessionSubscriptionsWrapper;
inferenceJobs: Array<InferenceJob>;
ttsInferenceJobs: Array<TtsInferenceJob>;
enqueueInferenceJob: (
jobToken: string,
frontendInferenceJobType: FrontendInferenceJobType
) => void;
inferenceJobsByCategory: Map<FrontendInferenceJobType, Array<InferenceJob>>;
voiceToken: string;
enqueueTtsJob: (jobToken: string) => void;
}
export default function TtsInferencePanel({
inferenceJobs,
sessionSubscriptionsWrapper,
ttsInferenceJobs,
enqueueInferenceJob,
enqueueTtsJob,
inferenceJobsByCategory,
voiceToken,
}: TtsInferencePanelProps) {
const [textBuffer, setTextBuffer] = useState("");
const [isEnqueuing, setIsEnqueuing] = useState(false);
const [maybeTtsError, setMaybeTtsError] = useState<
GenerateTtsAudioErrorType | undefined
>(undefined);
const [isAudioLimitAlertVisible, setAudioLimitAlertVisible] = useState(false);
const handleChangeText = (ev: React.FormEvent<HTMLTextAreaElement>) => {
const textValue = (ev.target as HTMLTextAreaElement).value;
setTextBuffer(textValue);
setAudioLimitAlertVisible(textValue.length > 100);
};
const handleClearText = () => {
setTextBuffer("");
};
const handleEnqueueTts = async (ev: React.FormEvent<HTMLButtonElement>) => {
ev.preventDefault();
if (!textBuffer) {
return false;
}
setIsEnqueuing(true);
const modelToken = voiceToken;
const request = {
uuid_idempotency_token: uuidv4(),
tts_model_token: modelToken,
inference_text: textBuffer,
};
const response = await GenerateTtsAudio(request);
Analytics.ttsGenerate(modelToken, textBuffer.length);
if (GenerateTtsAudioIsOk(response)) {
setMaybeTtsError(undefined);
if (response.inference_job_token_type === "generic") {
enqueueInferenceJob(
response.inference_job_token,
FrontendInferenceJobType.TextToSpeech
);
} else {
enqueueTtsJob(response.inference_job_token);
}
} else if (GenerateTtsAudioIsError(response)) {
setMaybeTtsError(response.error);
}
setIsEnqueuing(false);
return false;
};
let maybeError = <></>;
if (!!maybeTtsError) {
let hasMessage = false;
let message = <></>;
switch (maybeTtsError) {
case GenerateTtsAudioErrorType.TooManyRequests:
hasMessage = true;
message = <>Error: Too many requests. Please try again in a bit.</>;
break;
case GenerateTtsAudioErrorType.ServerError |
GenerateTtsAudioErrorType.BadRequest |
GenerateTtsAudioErrorType.NotFound:
break;
}
if (hasMessage) {
maybeError = (
<div
className="alert alert-primary alert-dismissible fade show m-0"
role="alert"
>
<button
className="btn-close"
onClick={() => setMaybeTtsError(undefined)}
data-bs-dismiss="alert"
aria-label="Close"
></button>
{message}
</div>
);
}
}
let audioLimitAlert = <></>;
if (
isAudioLimitAlertVisible &&
!sessionSubscriptionsWrapper.hasPaidFeatures()
) {
audioLimitAlert = (
<>
<div className="alert alert-warning fs-7 mb-0">
<span className="fw-semibold">
<u>Note:</u> Non-premium is limited to 12 seconds of audio.{" "}
<Link className="fw-semibold" to="/pricing">
Upgrade now
</Link>
.
</span>
</div>
</>
);
}
return (
<Panel padding={true}>
<form>
<div className="d-flex flex-column gap-3">
<h4 className="fw-semibold">
<FontAwesomeIcon icon={faVolumeUp} className="me-3" />
Generate TTS
</h4>
<TextArea
placeholder="Enter the text you want your character to say here..."
value={textBuffer}
onChange={handleChangeText}
rows={6}
/>
{audioLimitAlert}
</div>
<div className="d-flex gap-2 justify-content-end mt-3">
<Button
icon={faDeleteLeft}
label="Clear"
variant="danger"
onClick={handleClearText}
disabled={textBuffer.length === 0}
/>
<Button
icon={faVolumeUp}
label="Speak"
onClick={handleEnqueueTts}
isLoading={isEnqueuing}
disabled={textBuffer.length === 0}
/>
</div>
</form>
{inferenceJobs[0] && (
<div className="mt-4">
<Accordion>
<Accordion.Item title="Session TTS Results" defaultOpen={true}>
<div className="p-3">
<SessionTtsInferenceResultList
inferenceJobs={
inferenceJobsByCategory.get(
FrontendInferenceJobType.TextToSpeech
)!
}
ttsInferenceJobs={ttsInferenceJobs}
sessionSubscriptionsWrapper={sessionSubscriptionsWrapper}
/>
</div>
</Accordion.Item>
</Accordion>
{maybeError}
</div>
)}
</Panel>
);
}
@@ -0,0 +1,311 @@
import React, { useState } from "react";
import { faRightLeft } from "@fortawesome/pro-solid-svg-icons";
import { SessionSubscriptionsWrapper } from "@storyteller/components/src/session/SessionSubscriptionsWrapper";
import { Button } from "components/common";
import {
FrontendInferenceJobType,
InferenceJob,
} from "@storyteller/components/src/jobs/InferenceJob";
import { v4 as uuidv4 } from "uuid";
import Accordion from "components/common/Accordion";
import SplitPanel from "components/common/SplitPanel";
import NonRouteTabs from "components/common/Tabs/NonRouteTabs";
import { SessionVoiceConversionResultsList } from "v2/view/_common/SessionVoiceConversionResultsList";
import {
EnqueueVoiceConversion,
EnqueueVoiceConversionFrequencyMethod,
EnqueueVoiceConversionIsSuccess,
EnqueueVoiceConversionRequest,
} from "@storyteller/components/src/api/voice_conversion/EnqueueVoiceConversion";
import { Analytics } from "common/Analytics";
import UploadComponent from "../../vc/vc_model_list/components/UploadComponent";
import PitchEstimateMethodComponent from "../../vc/vc_model_list/components/PitchEstimateMethodComponent";
import PitchShiftComponent from "../../vc/vc_model_list/components/PitchShiftComponent";
import RecordComponent from "../../vc/vc_model_list/components/RecordComponent";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
interface VcInferencePanelProps {
sessionSubscriptionsWrapper: SessionSubscriptionsWrapper;
enqueueInferenceJob: (
jobToken: string,
frontendInferenceJobType: FrontendInferenceJobType
) => void;
inferenceJobs: Array<InferenceJob>;
inferenceJobsByCategory: Map<FrontendInferenceJobType, Array<InferenceJob>>;
voiceToken: string;
}
export default function VcInferencePanel({
sessionSubscriptionsWrapper,
enqueueInferenceJob,
inferenceJobsByCategory,
inferenceJobs,
voiceToken,
}: VcInferencePanelProps) {
const [convertLoading, setConvertLoading] = useState(false);
const [canConvert, setCanConvert] = useState(false);
const [mediaUploadToken, setMediaUploadToken] = useState<string | undefined>(
undefined
);
const [convertIdempotencyToken, setConvertIdempotencyToken] =
useState(uuidv4());
const [autoConvertF0, setAutoConvertF0] = useState(false);
const [maybeF0MethodOverride, setMaybeF0MethodOverride] = useState<
EnqueueVoiceConversionFrequencyMethod | undefined
>(undefined);
const [semitones, setSemitones] = useState(0);
// NB: Something of a UI hack here.
// The 3rd party microphone component doesn't let you clear it, so we emulate form clearing
// with this variable.
const [formIsCleared, setFormIsCleared] = useState(false);
const changeConvertIdempotencyToken = () => {
setConvertIdempotencyToken(uuidv4());
};
const handleVoiceConversion = async () => {
if (mediaUploadToken === undefined) {
return;
}
setConvertLoading(true);
let request: EnqueueVoiceConversionRequest = {
uuid_idempotency_token: convertIdempotencyToken,
voice_conversion_model_token: voiceToken,
source_media_upload_token: mediaUploadToken,
};
if (semitones !== 0) {
request.transpose = semitones;
}
if (maybeF0MethodOverride !== undefined) {
request.override_f0_method = maybeF0MethodOverride;
}
if (autoConvertF0) {
request.auto_predict_f0 = true;
}
Analytics.voiceConversionGenerate(voiceToken);
let result = await EnqueueVoiceConversion(request);
if (EnqueueVoiceConversionIsSuccess(result)) {
enqueueInferenceJob(
result.inference_job_token,
FrontendInferenceJobType.VoiceConversion
);
}
setConvertLoading(false);
};
const handleFormSubmit = async (ev: React.FormEvent<HTMLFormElement>) => {
ev.preventDefault();
};
const handlePitchChange = (value: any) => {
setSemitones(value);
changeConvertIdempotencyToken();
};
const handlePitchMethodChange = (value: any) => {
setMaybeF0MethodOverride(value);
changeConvertIdempotencyToken();
};
const handleAutoF0Change = (ev: React.FormEvent<HTMLInputElement>) => {
const value = (ev.target as HTMLInputElement).checked;
setAutoConvertF0(value);
changeConvertIdempotencyToken();
};
const speakButtonClass = convertLoading
? "btn btn-primary disabled"
: "btn btn-primary";
const enableConvertButton = canConvert && mediaUploadToken !== undefined;
const vcTabs = [
{
label: "Upload",
content: (
<div>
<div className="d-flex flex-column gap-4 h-100">
<div>
<label className="sub-title">Upload Audio File</label>
<div className="d-flex flex-column gap-3 upload-component">
<UploadComponent
setMediaUploadToken={setMediaUploadToken}
formIsCleared={formIsCleared}
setFormIsCleared={setFormIsCleared}
setCanConvert={setCanConvert}
changeConvertIdempotencyToken={changeConvertIdempotencyToken}
/>
</div>
</div>
<div>
<label className="sub-title">Pitch Control</label>
<div className="d-flex flex-column gap-3">
<div>
<PitchEstimateMethodComponent
pitchMethod={maybeF0MethodOverride}
onMethodChange={handlePitchMethodChange}
/>
</div>
<div>
<PitchShiftComponent
min={-36}
max={36}
step={1}
value={semitones}
onPitchChange={handlePitchChange}
/>
</div>
<div className="form-check">
<input
id="autoF0Checkbox"
className="form-check-input"
type="checkbox"
checked={autoConvertF0}
onChange={handleAutoF0Change}
/>
<label className="form-check-label" htmlFor="autoF0Checkbox">
Auto F0 (off for singing, on for speech)
</label>
</div>
</div>
</div>
<div className="d-flex gap-3 justify-content-end">
<Button
className={speakButtonClass}
onClick={handleVoiceConversion}
type="submit"
disabled={!enableConvertButton}
isLoading={convertLoading}
icon={faRightLeft}
label="Convert Audio"
></Button>
</div>
</div>
</div>
),
padding: true,
},
{
label: "Record",
content: (
<div>
<div className="d-flex flex-column gap-4 h-100">
<div>
<label className="sub-title">Record Audio</label>
<div className="d-flex flex-column gap-3 upload-component">
<RecordComponent
setMediaUploadToken={setMediaUploadToken}
formIsCleared={formIsCleared}
setFormIsCleared={setFormIsCleared}
setCanConvert={setCanConvert}
changeConvertIdempotencyToken={changeConvertIdempotencyToken}
/>
</div>
</div>
<div>
<label className="sub-title">Pitch Control</label>
<div className="d-flex flex-column gap-3">
<div>
<PitchEstimateMethodComponent
pitchMethod={maybeF0MethodOverride}
onMethodChange={handlePitchMethodChange}
/>
</div>
<div>
<PitchShiftComponent
min={-36}
max={36}
step={1}
value={semitones}
onPitchChange={handlePitchChange}
/>
</div>
<div className="form-check">
<input
id="autoF0CheckboxMic"
className="form-check-input"
type="checkbox"
checked={autoConvertF0}
onChange={handleAutoF0Change}
/>
<label
className="form-check-label"
htmlFor="autoF0CheckboxMic"
>
Auto F0 (off for singing, on for speech)
</label>
</div>
</div>
</div>
<div className="d-flex gap-3 justify-content-end">
<Button
className={speakButtonClass}
onClick={handleVoiceConversion}
type="submit"
disabled={!enableConvertButton}
isLoading={convertLoading}
icon={faRightLeft}
label="Convert Audio"
></Button>
</div>
</div>
</div>
),
padding: true,
},
];
return (
<SplitPanel>
<SplitPanel.Header padding={true}>
<h4 className="fw-semibold mb-0">
<FontAwesomeIcon icon={faRightLeft} className="me-3" />
Generate Voice Conversion
</h4>
</SplitPanel.Header>
<SplitPanel.Body>
<form onSubmit={handleFormSubmit}>
<NonRouteTabs tabs={vcTabs} />
</form>
</SplitPanel.Body>
{inferenceJobs[0] && (
<SplitPanel.Footer padding={true}>
<Accordion>
<Accordion.Item title="Session V2V Results" defaultOpen={true}>
<div className="p-3">
<SessionVoiceConversionResultsList
inferenceJobs={
inferenceJobsByCategory.get(
FrontendInferenceJobType.VoiceConversion
)!
}
sessionSubscriptionsWrapper={sessionSubscriptionsWrapper}
/>
</div>
</Accordion.Item>
</Accordion>
</SplitPanel.Footer>
)}
</SplitPanel>
);
}
@@ -0,0 +1,131 @@
import React, { useState } from "react";
import { faDeleteLeft } from "@fortawesome/pro-solid-svg-icons";
import Panel from "components/common/Panel/Panel";
import { SessionSubscriptionsWrapper } from "@storyteller/components/src/session/SessionSubscriptionsWrapper";
import TextArea from "components/common/TextArea";
import { Button } from "components/common";
import {
FrontendInferenceJobType,
InferenceJob,
} from "@storyteller/components/src/jobs/InferenceJob";
import { TtsInferenceJob } from "@storyteller/components/src/jobs/TtsInferenceJobs";
import { faVolumeUp } from "@fortawesome/free-solid-svg-icons";
import useVoiceRequests from "../../voice_designer/useVoiceRequests";
import { v4 as uuidv4 } from "uuid";
import Accordion from "components/common/Accordion";
import { SessionVoiceDesignerInferenceResultsList } from "v2/view/_common/SessionVoiceDesignerInferenceResultsList";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
interface VdInferencePanelProps {
sessionSubscriptionsWrapper: SessionSubscriptionsWrapper;
inferenceJobs: Array<InferenceJob>;
ttsInferenceJobs: Array<TtsInferenceJob>;
enqueueInferenceJob: (
jobToken: string,
frontendInferenceJobType: FrontendInferenceJobType
) => void;
inferenceJobsByCategory: Map<FrontendInferenceJobType, Array<InferenceJob>>;
voiceToken: string;
}
export default function VdInferencePanel({
inferenceJobs,
sessionSubscriptionsWrapper,
ttsInferenceJobs,
enqueueInferenceJob,
inferenceJobsByCategory,
voiceToken,
}: VdInferencePanelProps) {
const [textBuffer, setTextBuffer] = useState("");
const { inference } = useVoiceRequests({});
const [isEnqueuing, setIsEnqueuing] = useState(false);
const handleEnqueueTts = () => {
setIsEnqueuing(true);
inference
.enqueue("", {
uuid_idempotency_token: uuidv4(),
text: textBuffer,
voice_token: voiceToken,
})
.then((res: any) => {
if (res && res.success) {
enqueueInferenceJob(
res.inference_job_token,
FrontendInferenceJobType.VoiceDesignerTts
);
}
})
.catch(error => {
console.error("Error enqueuing TTS:", error);
})
.finally(() => {
setIsEnqueuing(false);
});
};
const handleChangeText = (ev: React.FormEvent<HTMLTextAreaElement>) => {
const textValue = (ev.target as HTMLTextAreaElement).value;
setTextBuffer(textValue);
};
const handleClearText = () => {
setTextBuffer("");
};
return (
<Panel padding={true}>
<form>
<div className="d-flex flex-column gap-3">
<h4 className="fw-semibold">
<FontAwesomeIcon icon={faVolumeUp} className="me-3" />
Generate TTS
</h4>
<TextArea
placeholder="Enter the text you want your character to say here..."
value={textBuffer}
onChange={handleChangeText}
rows={6}
/>
</div>
<div className="d-flex gap-2 justify-content-end mt-3">
<Button
icon={faDeleteLeft}
label="Clear"
variant="danger"
onClick={handleClearText}
disabled={textBuffer.length === 0}
/>
<Button
icon={faVolumeUp}
label="Speak"
onClick={handleEnqueueTts}
isLoading={isEnqueuing}
disabled={textBuffer.length === 0}
/>
</div>
</form>
{inferenceJobs[0] && (
<div className="mt-4">
<Accordion>
<Accordion.Item title="Session TTS Results" defaultOpen={true}>
<div className="p-3">
<SessionVoiceDesignerInferenceResultsList
inferenceJobs={
inferenceJobsByCategory.get(
FrontendInferenceJobType.VoiceDesignerTts
)!
}
ttsInferenceJobs={ttsInferenceJobs}
sessionSubscriptionsWrapper={sessionSubscriptionsWrapper}
/>
</div>
</Accordion.Item>
</Accordion>
</div>
)}
</Panel>
);
}