mirror of
https://github.com/storytold/artcraft.git
synced 2026-11-10 20:11:49 +00:00
Merge pull request #200 from storytold/feature/audio-sidepanel-v2
Feature/audio sidepanel v2
This commit is contained in:
17 files changed
+813
-150
No files matched your search
+4
-1
@@ -11,4 +11,7 @@ export const inferTts = `${apiHost}/tts/inference`;
|
||||
export const listTts = `${apiHost}/tts/list`;
|
||||
|
||||
// User Media
|
||||
export const listMediaByUser = (username:string) => `${apiHost}/v1/media_files/list/user/${username}`;
|
||||
export const listMediaByUser = (username:string) => `${apiHost}/v1/media_files/list/user/${username}`;
|
||||
|
||||
// Any Media
|
||||
export const getMediaFileByToken = (fileToken:string) => `${apiHost}/v1/media_files/file/${fileToken}`;
|
||||
@@ -0,0 +1,18 @@
|
||||
import { twMerge } from "tailwind-merge"
|
||||
|
||||
export const Pill = ({
|
||||
className,
|
||||
children,
|
||||
}:{
|
||||
className?: string,
|
||||
children : React.ReactNode
|
||||
})=>{
|
||||
return (
|
||||
<div className={twMerge(
|
||||
"rounded-md text-xs font-bold bg-brand-primary py-0.5 px-1.5 w-fit",
|
||||
className
|
||||
)}>
|
||||
{children}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -12,6 +12,7 @@ import { ListDropdown } from "./ListDropdown"
|
||||
import { ListSearchDropdown } from "./ListSearchDropdown"
|
||||
import { LoadingBar } from "./LoadingBar";
|
||||
import { LoadingDots, LoadingDotsBricks, LoadingDotsTyping } from "./LoadingDots";
|
||||
import { Pill } from "./Pill";
|
||||
|
||||
export {
|
||||
Button,
|
||||
@@ -30,4 +31,5 @@ export {
|
||||
LoadingDots,
|
||||
LoadingDotsBricks,
|
||||
LoadingDotsTyping,
|
||||
Pill
|
||||
};
|
||||
@@ -1,41 +1,29 @@
|
||||
import { useCallback, useContext, useEffect, useState } from "react";
|
||||
import { twMerge } from "tailwind-merge";
|
||||
import { useSignals, useSignalEffect } from "@preact/signals-react/runtime";
|
||||
import { faCirclePlus, faArrowsRotate } from "@fortawesome/pro-solid-svg-icons";
|
||||
|
||||
import { AppUiContext } from "~/contexts/AppUiContext";
|
||||
import { AuthenticationContext } from "~/contexts/Authentication";
|
||||
import { APPUI_ACTION_TYPES } from "~/reducers";
|
||||
import { audioFilter, audioItems } from "~/pages/PageEnigma/store";
|
||||
import { AssetFilterOption } from "~/pages/PageEnigma/models";
|
||||
|
||||
import { Button, ButtonIcon } from "~/components";
|
||||
|
||||
import { MediaItem, AssetType } from "~/pages/PageEnigma/models";
|
||||
import { ItemElements } from "~/pages/PageEnigma/comps/SidePanelTabs/itemTabs/ItemElements";
|
||||
import { audioItemsFromServer } from "~/pages/PageEnigma/store/mediaFromServer";
|
||||
import { ListAudioByUser } from "./listAudioByUser";
|
||||
import { ListAudioByUser } from "./utilities";
|
||||
import { inferenceJobs } from "~/pages/PageEnigma/store/inferenceJobs";
|
||||
import { JobState } from "~/hooks/useInferenceJobManager/useInferenceJobManager";
|
||||
|
||||
import { PageLibrary } from "./pageLibrary";
|
||||
import { PageTTS } from "./pageTTS";
|
||||
import { AudioTabPages } from "./types";
|
||||
|
||||
export const AudioTabComponent = () => {
|
||||
useSignals();
|
||||
const [ state, setState ] = useState({
|
||||
firstLoad: false,
|
||||
fetchingUserAudio: false,
|
||||
page: AudioTabPages.LIBRARY,
|
||||
});
|
||||
const { authState } = useContext(AuthenticationContext);
|
||||
const [, dispatchAppUiState] = useContext(AppUiContext);
|
||||
const handleOpenTtsDialogue = ()=>{
|
||||
dispatchAppUiState({
|
||||
type: APPUI_ACTION_TYPES.OPEN_DIALOGUE_TTS
|
||||
})
|
||||
}
|
||||
const allAudioItems = [...audioItems.value, ...audioItemsFromServer.value];
|
||||
|
||||
const handleListAudioByUser = useCallback((username:string)=>{
|
||||
const handleListAudioByUser = useCallback((username:string, sessionToken:string)=>{
|
||||
setState((curr)=>({...curr, fetchingUserAudio:true}));
|
||||
ListAudioByUser(username).then((res:any[])=>{
|
||||
ListAudioByUser(username, sessionToken).then((res:any[])=>{
|
||||
setState((curr)=>({...curr, fetchingUserAudio:false}));
|
||||
audioItemsFromServer.value = res.map(item=>{
|
||||
const morphedItem:MediaItem = {
|
||||
@@ -44,6 +32,8 @@ export const AudioTabComponent = () => {
|
||||
media_id: item.token,
|
||||
object_uuid: item.token,
|
||||
name: item.maybe_title || item.origin.maybe_model.title,
|
||||
description: item.maybe_text_transcript,
|
||||
publicBucketPath: item.public_bucket_path,
|
||||
length: 25,
|
||||
thumbnail: "/resources/placeholders/audio_placeholder.png",
|
||||
isMine: true,
|
||||
@@ -55,9 +45,9 @@ export const AudioTabComponent = () => {
|
||||
}, []);
|
||||
|
||||
useEffect(()=>{
|
||||
if ( authState.userInfo ){
|
||||
if (authState.userInfo && authState.sessionToken){
|
||||
if(state.firstLoad === false && audioItemsFromServer.value.length === 0){
|
||||
handleListAudioByUser(authState.userInfo.username);
|
||||
handleListAudioByUser(authState.userInfo.username, authState.sessionToken);
|
||||
setState((curr)=>({...curr, firstLoad:true}));
|
||||
}
|
||||
}
|
||||
@@ -82,80 +72,30 @@ export const AudioTabComponent = () => {
|
||||
return foundItemOfJob !== undefined;
|
||||
}
|
||||
});
|
||||
if(found === undefined){
|
||||
handleListAudioByUser(authState.userInfo.username);
|
||||
if(found === undefined && authState.sessionToken){
|
||||
handleListAudioByUser(authState.userInfo.username, authState.sessionToken);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="w-full overflow-x-auto">
|
||||
<div className="mb-4 mt-4 flex justify-start items-center gap-2 px-4">
|
||||
<button
|
||||
className={twMerge(
|
||||
"filter-tab",
|
||||
audioFilter.value === AssetFilterOption.ALL ? "active" : "",
|
||||
"disabled",
|
||||
)}
|
||||
onClick={() => (audioFilter.value = AssetFilterOption.ALL)}
|
||||
>
|
||||
All
|
||||
</button>
|
||||
<button
|
||||
className={twMerge(
|
||||
"filter-tab",
|
||||
audioFilter.value === AssetFilterOption.MINE ? "active" : "",
|
||||
"disabled",
|
||||
)}
|
||||
onClick={() => (audioFilter.value = AssetFilterOption.MINE)}
|
||||
disabled={!allAudioItems.some((item) => item.isMine)}
|
||||
>
|
||||
My Audios
|
||||
</button>
|
||||
<button
|
||||
className={twMerge(
|
||||
"filter-tab",
|
||||
audioFilter.value === AssetFilterOption.BOOKMARKED
|
||||
? "active"
|
||||
: "",
|
||||
"disabled",
|
||||
)}
|
||||
onClick={() => (audioFilter.value = AssetFilterOption.BOOKMARKED)}
|
||||
disabled={!allAudioItems.some((item) => item.isBookmarked)}
|
||||
>
|
||||
Bookmarked
|
||||
</button>
|
||||
<ButtonIcon
|
||||
className={twMerge(
|
||||
"absolute right-0 mr-4",
|
||||
// state.fetchingUserAudio ? "animate-spin" : "",
|
||||
)}
|
||||
icon={faArrowsRotate}
|
||||
onClick={()=>{
|
||||
if(authState.userInfo?.username){
|
||||
handleListAudioByUser(authState.userInfo?.username);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="w-full px-4">
|
||||
<Button
|
||||
icon={faCirclePlus}
|
||||
variant="action"
|
||||
className="w-full py-3 text-sm font-medium"
|
||||
onClick={handleOpenTtsDialogue}
|
||||
>
|
||||
Generate Audio
|
||||
</Button>
|
||||
</div>
|
||||
<div className="h-full w-full overflow-y-auto px-4 pt-4">
|
||||
<ItemElements
|
||||
items={allAudioItems}
|
||||
assetFilter={audioFilter.value}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
const changePage = (newPage:AudioTabPages)=>{
|
||||
setState((curr)=>({
|
||||
...curr,
|
||||
page: newPage
|
||||
}))
|
||||
}
|
||||
if(state.page === AudioTabPages.LIBRARY){
|
||||
return <PageLibrary changePage={changePage}/>
|
||||
}else if(state.page === AudioTabPages.TTS && authState.sessionToken){
|
||||
return(
|
||||
<PageTTS
|
||||
changePage={changePage}
|
||||
sessionToken={authState.sessionToken}
|
||||
/>
|
||||
);
|
||||
}else{
|
||||
return(
|
||||
<p>Unknown Error</p>
|
||||
)
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,54 @@
|
||||
import { useCallback, useEffect, useState, useRef } from 'react';
|
||||
import WaveSurfer from 'wavesurfer.js';
|
||||
import { faCirclePlay, faCirclePause, } from "@fortawesome/pro-solid-svg-icons"
|
||||
import { ButtonIcon } from '~/components';
|
||||
|
||||
export const WaveformPlayer = ({
|
||||
audio
|
||||
}:{
|
||||
audio: string
|
||||
}) => {
|
||||
const waveSurferRef = useRef<WaveSurfer | undefined>(undefined);
|
||||
const [isPlaying, toggleIsPlaying] = useState(false);
|
||||
|
||||
const containerRef = useCallback((node:HTMLDivElement)=>{
|
||||
if(node){
|
||||
const waveSurfer = WaveSurfer.create({
|
||||
container: node,
|
||||
barWidth: 2,
|
||||
height: 30,
|
||||
cursorWidth: 0,
|
||||
});
|
||||
waveSurfer.load(audio);
|
||||
waveSurfer.on('ready', () => {
|
||||
waveSurferRef.current = waveSurfer;
|
||||
});
|
||||
waveSurfer.on('play', () => {
|
||||
toggleIsPlaying(true);
|
||||
});
|
||||
waveSurfer.on('pause', () => {
|
||||
toggleIsPlaying(false);
|
||||
});
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(()=>{
|
||||
return()=>{
|
||||
console.log("OK?");
|
||||
waveSurferRef.current?.destroy();
|
||||
}
|
||||
},[]);
|
||||
|
||||
return (
|
||||
<div className="flex py-1 gap-2">
|
||||
<ButtonIcon
|
||||
icon={isPlaying ? faCirclePause : faCirclePlay}
|
||||
size="2x"
|
||||
onClick={() => {
|
||||
waveSurferRef.current?.playPause();
|
||||
}}
|
||||
/>
|
||||
<div ref={containerRef} className="w-full h-10"/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,99 @@
|
||||
import { useCallback, useContext, useEffect } from "react";
|
||||
import { useSignals } from "@preact/signals-react/runtime";
|
||||
|
||||
import { WaveformPlayer } from "./WaveformPlayer";
|
||||
import { MediaItem } from "~/pages/PageEnigma/models";
|
||||
import { TrackContext } from "~/pages/PageEnigma/contexts/TrackContext/TrackContext";
|
||||
import {
|
||||
canDrop,
|
||||
currPosition,
|
||||
dragItem,
|
||||
initPosition,
|
||||
} from "~/pages/PageEnigma/store";
|
||||
|
||||
import { H4, H6, Pill } from "~/components";
|
||||
|
||||
function getGcsUrl(bucketRelativePath: string | undefined | null): string {
|
||||
let bucket = "vocodes-public";
|
||||
let path = bucketRelativePath;
|
||||
if (path !== undefined && path !== null && !path.startsWith("/")) {
|
||||
path = "/" + path;
|
||||
}
|
||||
return `https://storage.googleapis.com/${bucket}${path}`;
|
||||
}
|
||||
|
||||
interface Props {
|
||||
item: MediaItem;
|
||||
}
|
||||
|
||||
export const AudioItemElement = ({ item }: Props) => {
|
||||
useSignals();
|
||||
const { startDrag, endDrag } = useContext(TrackContext);
|
||||
|
||||
const { initX, initY } = initPosition.value;
|
||||
|
||||
useEffect(() => {
|
||||
const onPointerUp = () => {
|
||||
if (dragItem.value) {
|
||||
endDrag();
|
||||
}
|
||||
};
|
||||
|
||||
const onMouseMove = (event: MouseEvent) => {
|
||||
if (dragItem.value) {
|
||||
event.stopPropagation();
|
||||
event.preventDefault();
|
||||
const deltaX = event.pageX - initX;
|
||||
const deltaY = event.pageY - initY;
|
||||
currPosition.value = { currX: initX + deltaX, currY: initY + deltaY };
|
||||
}
|
||||
};
|
||||
|
||||
window.addEventListener("pointerup", onPointerUp);
|
||||
window.addEventListener("pointermove", onMouseMove);
|
||||
|
||||
return () => {
|
||||
window.removeEventListener("pointerup", onPointerUp);
|
||||
window.removeEventListener("pointermove", onMouseMove);
|
||||
};
|
||||
}, [startDrag, endDrag, initX, initY]);
|
||||
|
||||
const onPointerDown = useCallback(
|
||||
(event: React.PointerEvent<HTMLDivElement>) => {
|
||||
if (event.button === 0) {
|
||||
startDrag(item);
|
||||
currPosition.value = {
|
||||
currX: event.pageX,
|
||||
currY: event.pageY,
|
||||
};
|
||||
initPosition.value = {
|
||||
initX: event.pageX,
|
||||
initY: event.pageY,
|
||||
};
|
||||
canDrop.value = false;
|
||||
}
|
||||
},
|
||||
[item, startDrag],
|
||||
);
|
||||
|
||||
return (
|
||||
<div
|
||||
className="relative w-full cursor-pointer rounded-lg transition-all duration-200"
|
||||
onPointerDown={onPointerDown}
|
||||
>
|
||||
<div
|
||||
className="flex flex-col p-2 gap-1 w-full rounded-lg bg-assets-background"
|
||||
>
|
||||
<div className="flex justify-between pb-1">
|
||||
<Pill>Audio</Pill>
|
||||
</div>
|
||||
|
||||
{item.publicBucketPath && <WaveformPlayer audio={getGcsUrl(item.publicBucketPath)} />}
|
||||
|
||||
|
||||
<H4 className="text-overflow-ellipsis">{item.name}</H4>
|
||||
{item.description && <H6 className="text-overflow-ellipsis">{item.description}</H6>}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,28 @@
|
||||
import { AudioItemElement } from "./audioItemElement";
|
||||
import { AssetFilterOption, MediaItem } from "~/pages/PageEnigma/models";
|
||||
|
||||
interface Props {
|
||||
items: MediaItem[];
|
||||
assetFilter: AssetFilterOption;
|
||||
}
|
||||
|
||||
export const AudioItemElements = ({ items, assetFilter }: Props) => {
|
||||
|
||||
const displayItems = items.filter((item) => {
|
||||
if (assetFilter === AssetFilterOption.ALL) {
|
||||
return true;
|
||||
}
|
||||
if (assetFilter === AssetFilterOption.MINE) {
|
||||
return item.isMine;
|
||||
}
|
||||
return item.isBookmarked;
|
||||
});
|
||||
|
||||
return (
|
||||
<div className={`grid grid-cols-1 gap-3`}>
|
||||
{displayItems.map((item) => (
|
||||
<AudioItemElement key={item.media_id} item={item} />
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -1,55 +0,0 @@
|
||||
import { listMediaByUser } from "~/api";
|
||||
import {
|
||||
MediaFileType,
|
||||
MediaFileClass,
|
||||
MediaFileSubtype,
|
||||
UserDetailsLight,
|
||||
WeightCategory,
|
||||
WeightType
|
||||
} from "./types";
|
||||
|
||||
|
||||
export interface MediaFile {
|
||||
token: string;
|
||||
media_type: MediaFileType;
|
||||
media_class: MediaFileClass | null;
|
||||
maybe_media_subtype: MediaFileSubtype | null;
|
||||
public_bucket_path: string;
|
||||
maybe_engine_extension: string | null;
|
||||
maybe_batch_token: string;
|
||||
maybe_title: string | null;
|
||||
maybe_original_filename: string | null;
|
||||
maybe_creator_user: UserDetailsLight | null;
|
||||
maybe_prompt_token: string | null;
|
||||
creator_set_visibility: string;
|
||||
created_at: Date;
|
||||
updated_at: Date;
|
||||
maybe_model_weight_info: {
|
||||
title: string;
|
||||
weight_token: string;
|
||||
weight_category: WeightCategory;
|
||||
weight_type: WeightType;
|
||||
maybe_weight_creator: UserDetailsLight;
|
||||
maybe_cover_image_public_bucket_path: string;
|
||||
};
|
||||
}
|
||||
|
||||
export const ListAudioByUser = async(username:string) => {
|
||||
return await fetch(listMediaByUser(username),{
|
||||
method: 'GET',
|
||||
headers: {
|
||||
"Accept": "application/json",
|
||||
},
|
||||
// credentials: 'include'
|
||||
})
|
||||
.then(res => res.json())
|
||||
.then(res => {
|
||||
if(res.success && res.results){
|
||||
return res.results.filter((item:MediaFile)=>item['media_type']==='audio');
|
||||
}else{
|
||||
Promise.reject();
|
||||
}
|
||||
})
|
||||
.catch(e => ({ success : false }));
|
||||
}
|
||||
|
||||
@@ -0,0 +1,79 @@
|
||||
import { faCirclePlus } from "@fortawesome/pro-solid-svg-icons";
|
||||
import { twMerge } from "tailwind-merge";
|
||||
import { useSignals } from "@preact/signals-react/runtime";
|
||||
import { audioFilter, audioItems } from "~/pages/PageEnigma/store";
|
||||
import { AssetFilterOption } from "~/pages/PageEnigma/models";
|
||||
import { audioItemsFromServer } from "~/pages/PageEnigma/store/mediaFromServer";
|
||||
|
||||
import { Button } from "~/components";
|
||||
import { AudioItemElements } from "./audioItemElements";
|
||||
import { AudioTabPages } from "./types";
|
||||
|
||||
export const PageLibrary = ({
|
||||
changePage
|
||||
}:{
|
||||
changePage: (newPage:AudioTabPages) => void
|
||||
})=>{
|
||||
useSignals();
|
||||
const allAudioItems = [...audioItems.value, ...audioItemsFromServer.value];
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="w-full overflow-x-auto">
|
||||
<div className="mb-4 mt-4 flex justify-start items-center gap-2 px-4">
|
||||
<button
|
||||
className={twMerge(
|
||||
"filter-tab",
|
||||
audioFilter.value === AssetFilterOption.ALL ? "active" : "",
|
||||
"disabled",
|
||||
)}
|
||||
onClick={() => (audioFilter.value = AssetFilterOption.ALL)}
|
||||
>
|
||||
All
|
||||
</button>
|
||||
<button
|
||||
className={twMerge(
|
||||
"filter-tab",
|
||||
audioFilter.value === AssetFilterOption.MINE ? "active" : "",
|
||||
"disabled",
|
||||
)}
|
||||
onClick={() => (audioFilter.value = AssetFilterOption.MINE)}
|
||||
disabled={!allAudioItems.some((item) => item.isMine)}
|
||||
>
|
||||
My Audios
|
||||
</button>
|
||||
<button
|
||||
className={twMerge(
|
||||
"filter-tab",
|
||||
audioFilter.value === AssetFilterOption.BOOKMARKED
|
||||
? "active"
|
||||
: "",
|
||||
"disabled",
|
||||
)}
|
||||
onClick={() => (audioFilter.value = AssetFilterOption.BOOKMARKED)}
|
||||
disabled={!allAudioItems.some((item) => item.isBookmarked)}
|
||||
>
|
||||
Bookmarked
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="w-full px-4">
|
||||
<Button
|
||||
icon={faCirclePlus}
|
||||
variant="action"
|
||||
className="w-full py-3 text-sm font-medium"
|
||||
onClick={()=>changePage(AudioTabPages.TTS)}
|
||||
>
|
||||
Generate Audio
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="h-full w-full overflow-y-auto px-4 pt-4">
|
||||
<AudioItemElements
|
||||
items={allAudioItems}
|
||||
assetFilter={audioFilter.value}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,233 @@
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { useSignalEffect } from "@preact/signals-react/runtime";
|
||||
import { v4 as uuidv4 } from "uuid";
|
||||
|
||||
import {
|
||||
faChevronLeft,
|
||||
faBrainCircuit,
|
||||
} from "@fortawesome/pro-solid-svg-icons";
|
||||
|
||||
import {
|
||||
Button, ButtonIcon, H2, Label,
|
||||
LoadingDotsTyping,
|
||||
ListSearchDropdown,
|
||||
Textarea
|
||||
} from "~/components";
|
||||
|
||||
import { TtsModelListItem, GenerateTtsAudioResponse } from "~/pages/PageEnigma/models/tts";
|
||||
|
||||
import { addInferenceJob, inferenceJobs} from "../../../store/inferenceJobs";
|
||||
import { JobState } from "~/hooks/useInferenceJobManager/useInferenceJobManager";
|
||||
import { ListTtsModels, GenerateTtsAudio, GetMediaFileByToken } from "./utilities";
|
||||
import { AudioTabPages } from "./types";
|
||||
import { AudioItemElement } from "./audioItemElement";
|
||||
import { MediaItem, AssetType } from "~/pages/PageEnigma/models";
|
||||
|
||||
type TtsState = {
|
||||
voice: TtsModelListItem | undefined;
|
||||
text: string;
|
||||
hasEnqueued :boolean;
|
||||
inferenceToken?: string;
|
||||
inferenceJobType?: string;
|
||||
hasTtsResult: boolean;
|
||||
}
|
||||
const initialState:TtsState =
|
||||
{
|
||||
voice:undefined,
|
||||
text:"",
|
||||
hasEnqueued:false,
|
||||
inferenceJobType: undefined,
|
||||
inferenceToken: undefined,
|
||||
hasTtsResult:false,
|
||||
};
|
||||
|
||||
export const PageTTS =({
|
||||
changePage,
|
||||
sessionToken,
|
||||
}:{
|
||||
changePage: (newPage:AudioTabPages) => void;
|
||||
sessionToken: string;
|
||||
})=>{
|
||||
const [ttsState, setTtsState] = useState<TtsState>(initialState);
|
||||
|
||||
const [ttsResultFile, setTtsResultFile] = useState<MediaItem|undefined>();
|
||||
|
||||
useSignalEffect(()=>{
|
||||
console.log(inferenceJobs.value);
|
||||
if(ttsState.hasEnqueued && ttsState.inferenceToken){
|
||||
const found = inferenceJobs.value.find((job)=>job.job_id===ttsState.inferenceToken);
|
||||
console.log(`finding: ${ttsState.inferenceToken}`);
|
||||
console.log(found);
|
||||
if(found?.job_status === JobState.COMPLETE_SUCCESS){
|
||||
setTtsState((curr)=>({
|
||||
...curr,
|
||||
hasTtsResult: true,
|
||||
}))
|
||||
GetMediaFileByToken(found.result.entity_token, sessionToken)
|
||||
.then(res=>{
|
||||
console.log(res);
|
||||
const morphedItem:MediaItem = {
|
||||
version: 1,
|
||||
type: AssetType.AUDIO,
|
||||
media_id: res.media_file.token,
|
||||
object_uuid: res.media_file.token,
|
||||
name: ttsState.voice?.title || '',
|
||||
description: ttsState.text,
|
||||
publicBucketPath: res.media_file.public_bucket_path,
|
||||
length: 25,
|
||||
thumbnail: "/resources/placeholders/audio_placeholder.png",
|
||||
isMine: true,
|
||||
// isBookmarked?: boolean;
|
||||
}
|
||||
setTtsResultFile(morphedItem);
|
||||
})
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
const [ttsModels, setTtsModels] = useState<Array<TtsModelListItem>>([]);
|
||||
|
||||
const listModels = useCallback(async () => {
|
||||
const ttsModelsLoaded = ttsModels.length > 0;
|
||||
if (ttsModelsLoaded) {
|
||||
return; // Already queried.
|
||||
}
|
||||
const models = await ListTtsModels(sessionToken);
|
||||
if (models) {
|
||||
setTtsModels(models);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
listModels();
|
||||
}, [listModels]);
|
||||
|
||||
useEffect(()=> {
|
||||
if(ttsState.hasEnqueued && ttsState.inferenceToken && ttsState.inferenceJobType){
|
||||
console.log(`tts has Enqueued`);
|
||||
addInferenceJob({
|
||||
version:1,
|
||||
job_id: ttsState.inferenceToken,
|
||||
job_type: ttsState.inferenceJobType,
|
||||
job_status: JobState.PENDING,
|
||||
})
|
||||
}
|
||||
},[ttsState]);
|
||||
|
||||
const requestTts = useCallback( (sessionToken:string)=>{
|
||||
const modelToken = ttsState.voice ? ttsState.voice.model_token : undefined;
|
||||
|
||||
if(modelToken){
|
||||
setTtsState((curr)=>({
|
||||
...curr,
|
||||
hasEnqueued: true,
|
||||
inferenceToken: undefined,
|
||||
inferenceJobType: undefined,
|
||||
hasAudio: false,
|
||||
result: undefined,
|
||||
}));
|
||||
|
||||
const request = {
|
||||
uuid_idempotency_token: uuidv4(),
|
||||
tts_model_token: modelToken,
|
||||
inference_text: ttsState.text,
|
||||
};
|
||||
|
||||
GenerateTtsAudio(request, sessionToken).then((
|
||||
res:GenerateTtsAudioResponse)=>{
|
||||
if(res && res.inference_job_token){
|
||||
setTtsState((curr)=>({
|
||||
...curr,
|
||||
inferenceToken: res.inference_job_token,
|
||||
inferenceJobType: res.inference_job_token_type
|
||||
}));
|
||||
}
|
||||
});
|
||||
}else{
|
||||
console.log("no voice model selected");
|
||||
}
|
||||
},[ttsState]);
|
||||
|
||||
const handleTextInput = (
|
||||
e: React.ChangeEvent<HTMLTextAreaElement>
|
||||
)=>{
|
||||
setTtsState((curr)=>({
|
||||
...curr,
|
||||
text: e.target.value,
|
||||
}));
|
||||
};
|
||||
|
||||
const handleOnSelect = (val:string)=>{
|
||||
console.log( val);
|
||||
const voiceModel = ttsModels.find((item)=>{
|
||||
if (item.title === val) return item
|
||||
})
|
||||
setTtsState((curr)=>({
|
||||
...curr,
|
||||
voice: voiceModel,
|
||||
}));
|
||||
}
|
||||
|
||||
return(
|
||||
<div className="flex flex-col p-4">
|
||||
<div className="flex items-center">
|
||||
<ButtonIcon
|
||||
icon={faChevronLeft}
|
||||
onClick={()=>changePage(AudioTabPages.LIBRARY)}
|
||||
/>
|
||||
<H2>Generate TTS</H2>
|
||||
</div>
|
||||
<Label className="mb-1">Select a Voice</Label>
|
||||
{ttsModels.length > 0 && <ListSearchDropdown
|
||||
list={ttsModels}
|
||||
listDisplayKey="title"
|
||||
onSelect={handleOnSelect}
|
||||
/> }
|
||||
<div className="flex w-full justify-between mt-4">
|
||||
<Label>What would you like to say?</Label>
|
||||
<div className="flex gap-2 items-center">
|
||||
</div>
|
||||
</div>
|
||||
<Textarea
|
||||
placeholder="Enter what you want the voice to say here."
|
||||
value={ttsState.text}
|
||||
onChange={handleTextInput}
|
||||
/>
|
||||
<div className="mt-6 flex gap-2">
|
||||
<div className="w-full h-12">
|
||||
{!ttsState.hasTtsResult && !ttsState.hasEnqueued &&
|
||||
<Button
|
||||
className="w-36 h-full text-xl "
|
||||
variant={ttsState.hasTtsResult ? "secondary" : "primary" }
|
||||
disabled={ttsState.text === ""}
|
||||
icon={faBrainCircuit}
|
||||
onClick={()=>requestTts(sessionToken)}
|
||||
>
|
||||
Generate
|
||||
</Button>
|
||||
}
|
||||
{!ttsState.hasTtsResult && ttsState.hasEnqueued &&
|
||||
<LoadingDotsTyping className="bg-brand-secondary-500 rounded-lg"/>
|
||||
}
|
||||
{ ttsResultFile &&
|
||||
<AudioItemElement item={ttsResultFile}/>
|
||||
}
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
<div className="mt-6 flex justify-between gap-2">
|
||||
{ ttsState.hasEnqueued &&
|
||||
<Button
|
||||
type="button"
|
||||
disabled={!ttsState.hasTtsResult}
|
||||
onClick={()=>requestTts(sessionToken)}
|
||||
icon={faBrainCircuit}
|
||||
>
|
||||
Generate Another
|
||||
</Button>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -122,3 +122,39 @@ export enum WeightType {
|
||||
VALL_E = "vall_e",
|
||||
NONE = "none",
|
||||
}
|
||||
|
||||
export enum AudioTabPages {
|
||||
LIBRARY = 'library',
|
||||
TTS = 'tts',
|
||||
}
|
||||
|
||||
|
||||
export interface MediaFile {
|
||||
token: string;
|
||||
media_type: MediaFileType;
|
||||
media_class: MediaFileClass | null;
|
||||
maybe_media_subtype: MediaFileSubtype | null;
|
||||
public_bucket_path: string;
|
||||
maybe_engine_extension: string | null;
|
||||
maybe_batch_token: string;
|
||||
maybe_title: string | null;
|
||||
maybe_original_filename: string | null;
|
||||
maybe_creator_user: UserDetailsLight | null;
|
||||
maybe_prompt_token: string | null;
|
||||
creator_set_visibility: string;
|
||||
created_at: Date;
|
||||
updated_at: Date;
|
||||
maybe_model_weight_info: {
|
||||
title: string;
|
||||
weight_token: string;
|
||||
weight_category: WeightCategory;
|
||||
weight_type: WeightType;
|
||||
maybe_weight_creator: UserDetailsLight;
|
||||
maybe_cover_image_public_bucket_path: string;
|
||||
};
|
||||
}
|
||||
|
||||
export interface GetMediaFileResponse {
|
||||
success: boolean;
|
||||
media_file?: MediaFile;
|
||||
}
|
||||
@@ -0,0 +1,138 @@
|
||||
import { listTts, inferTts, listMediaByUser, getMediaFileByToken } from '~/api';
|
||||
import { MediaFile, GetMediaFileResponse } from './types';
|
||||
import {
|
||||
TtsModelListItem,
|
||||
TtsModelListResponsePayload,
|
||||
GenerateTtsAudioErrorType,
|
||||
StatusLike,
|
||||
GenerateTtsAudioRequest,
|
||||
GenerateTtsAudioResponse,
|
||||
} from '~/pages/PageEnigma/models/tts';
|
||||
|
||||
|
||||
|
||||
export const ListAudioByUser = async(username:string, sessionToken: string) => {
|
||||
return await fetch(listMediaByUser(username),{
|
||||
method: 'GET',
|
||||
headers: {
|
||||
"Accept": "application/json",
|
||||
'session': sessionToken,
|
||||
},
|
||||
// credentials: 'include'
|
||||
})
|
||||
.then(res => res.json())
|
||||
.then(res => {
|
||||
if(res.success && res.results){
|
||||
return res.results.filter((item:MediaFile)=>item['media_type']==='audio');
|
||||
}else{
|
||||
Promise.reject();
|
||||
}
|
||||
})
|
||||
.catch(e => ({ success : false }));
|
||||
}
|
||||
|
||||
export async function ListTtsModels(sessionToken:string) : Promise<Array<TtsModelListItem>| undefined> {
|
||||
return await fetch(listTts, {
|
||||
method: 'GET',
|
||||
headers: {
|
||||
'Accept': 'application/json',
|
||||
'session': sessionToken,
|
||||
},
|
||||
// credentials: 'include',
|
||||
})
|
||||
.then(res => res.json())
|
||||
.then(res => {
|
||||
const response : TtsModelListResponsePayload = res;
|
||||
if (!response.success) {
|
||||
return;
|
||||
}
|
||||
return response?.models;
|
||||
})
|
||||
.catch(e => {
|
||||
return undefined;
|
||||
});
|
||||
}
|
||||
|
||||
export function maybeMapError(statuslike: StatusLike) : GenerateTtsAudioErrorType | undefined {
|
||||
switch (statuslike.status) {
|
||||
case 400:
|
||||
return GenerateTtsAudioErrorType.BadRequest;
|
||||
case 404:
|
||||
return GenerateTtsAudioErrorType.NotFound;
|
||||
case 429:
|
||||
return GenerateTtsAudioErrorType.TooManyRequests;
|
||||
case 500:
|
||||
return GenerateTtsAudioErrorType.ServerError;
|
||||
}
|
||||
}
|
||||
|
||||
// export function GenerateTtsAudioIsError(response: GenerateTtsAudioResponse): response is GenerateTtsAudioError {
|
||||
// return !('inference_job_token' in response);
|
||||
// }
|
||||
|
||||
export async function GenerateTtsAudio(request: GenerateTtsAudioRequest, sessionToken:string) : Promise<GenerateTtsAudioResponse>
|
||||
{
|
||||
return await fetch(inferTts, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Accept': 'application/json',
|
||||
'Content-Type': 'application/json',
|
||||
'session': sessionToken,
|
||||
},
|
||||
// credentials: 'include',
|
||||
body: JSON.stringify(request),
|
||||
})
|
||||
.then(res => res.json())
|
||||
.then(res => {
|
||||
if (!('inference_job_token' in res)) {
|
||||
return { error: GenerateTtsAudioErrorType.UnknownError };
|
||||
}
|
||||
const ret:GenerateTtsAudioResponse = {
|
||||
success: true,
|
||||
inference_job_token: res.inference_job_token,
|
||||
inference_job_token_type: res.inference_job_token_type,
|
||||
}
|
||||
return ret;
|
||||
})
|
||||
.catch(e => {
|
||||
let maybeError = maybeMapError(e);
|
||||
if (maybeError !== undefined) {
|
||||
return { error: maybeError };
|
||||
}
|
||||
return { error: GenerateTtsAudioErrorType.UnknownError };
|
||||
});
|
||||
|
||||
};
|
||||
|
||||
export async function GetMediaFileByToken (fileToken: string, sessionToken: string) : Promise<GetMediaResponse>
|
||||
{
|
||||
return await fetch(getMediaFileByToken(fileToken), {
|
||||
method: "GET",
|
||||
headers: {
|
||||
'Accept': 'application/json',
|
||||
'Content-Type': 'application/json',
|
||||
'session': sessionToken,
|
||||
},
|
||||
})
|
||||
.then(res => res.json())
|
||||
.then(res => {
|
||||
const response: GetMediaFileResponse = res;
|
||||
|
||||
if (response && response.success && response.media_file) {
|
||||
// NB: Timestamps aren't converted to Date objects on their own!
|
||||
response.media_file.created_at = new Date(
|
||||
response.media_file.created_at
|
||||
);
|
||||
response.media_file.updated_at = new Date(
|
||||
response.media_file.updated_at
|
||||
);
|
||||
return response;
|
||||
} else {
|
||||
return { success: false };
|
||||
}
|
||||
})
|
||||
.catch(e => {
|
||||
return { success: false };
|
||||
});
|
||||
;
|
||||
}
|
||||
@@ -4,6 +4,8 @@ export interface MediaItem {
|
||||
media_id: string;
|
||||
object_uuid?: string;
|
||||
name: string;
|
||||
description?: string;
|
||||
publicBucketPath?: string;
|
||||
length?: number;
|
||||
thumbnail?: string;
|
||||
isMine?: boolean;
|
||||
|
||||
@@ -0,0 +1,77 @@
|
||||
/*
|
||||
* Types for Listing TTS Models
|
||||
*/
|
||||
|
||||
export interface TtsModelListResponsePayload {
|
||||
success: boolean,
|
||||
models: Array<TtsModelListItem>,
|
||||
}
|
||||
|
||||
export interface UserRatings {
|
||||
positive_count: number,
|
||||
negative_count: number,
|
||||
// Total count does not take into account "neutral" ratings.
|
||||
total_count: number,
|
||||
}
|
||||
|
||||
export interface TtsModelListItem {
|
||||
model_token: string,
|
||||
tts_model_type: string,
|
||||
creator_user_token: string,
|
||||
creator_username: string,
|
||||
creator_display_name: string,
|
||||
creator_gravatar_hash: string,
|
||||
ietf_language_tag: string,
|
||||
ietf_primary_language_subtag: string,
|
||||
updatable_slug: string,
|
||||
title: string,
|
||||
is_front_page_featured: boolean,
|
||||
is_twitch_featured: boolean,
|
||||
user_ratings: UserRatings,
|
||||
category_tokens: string[],
|
||||
created_at: string,
|
||||
updated_at: string,
|
||||
}
|
||||
|
||||
/*
|
||||
* Types for Making a TTS Generation Request
|
||||
*/
|
||||
export interface GenerateTtsAudioRequest {
|
||||
uuid_idempotency_token: string,
|
||||
tts_model_token: string,
|
||||
inference_text: string,
|
||||
// TODO(2022-03): TEMPORARY
|
||||
is_storyteller_demo?: boolean,
|
||||
}
|
||||
|
||||
export interface GenerateTtsAudioResponse {
|
||||
success?: boolean,
|
||||
inference_job_token?: string,
|
||||
// Which queue to poll
|
||||
inference_job_token_type?: string,
|
||||
error?: GenerateTtsAudioErrorType,
|
||||
}
|
||||
|
||||
export enum GenerateTtsAudioErrorType {
|
||||
BadRequest,
|
||||
NotFound,
|
||||
TooManyRequests,
|
||||
ServerError,
|
||||
UnknownError,
|
||||
}
|
||||
|
||||
export interface EndpointSuccessResponse {
|
||||
success: boolean,
|
||||
inference_job_token: string,
|
||||
inference_job_token_type?: string,
|
||||
}
|
||||
|
||||
export interface EndpointErrorResponse {
|
||||
success: boolean,
|
||||
}
|
||||
|
||||
export type EndpointResponse = EndpointSuccessResponse | EndpointErrorResponse;
|
||||
|
||||
export interface StatusLike {
|
||||
status: number,
|
||||
}
|
||||
@@ -455,6 +455,7 @@ export const audioItems = signal<MediaItem[]>([
|
||||
length: 25,
|
||||
name: "Talk (Demo Sounds)",
|
||||
thumbnail: "/resources/placeholders/audio_placeholder.png",
|
||||
publicBucketPath: "/media/j/a/r/r/3/jarr3asge6t0x048wdzcehzjd2nh4ep7/fakeyou_jarr3asge6t0x048wdzcehzjd2nh4ep7.wav",
|
||||
},
|
||||
{
|
||||
version: 1,
|
||||
@@ -463,6 +464,7 @@ export const audioItems = signal<MediaItem[]>([
|
||||
length: 25,
|
||||
name: "NCS Song",
|
||||
thumbnail: "/resources/placeholders/audio_placeholder.png",
|
||||
publicBucketPath: "/media/s/j/t/0/6/sjt06a8y2qrdqe574nry02bpd3bt01ma/upload_sjt06a8y2qrdqe574nry02bpd3bt01ma.wav",
|
||||
},
|
||||
]);
|
||||
|
||||
|
||||
Generated
+6
@@ -32,6 +32,7 @@
|
||||
"terser": "^5.30.3",
|
||||
"three": "^0.162.0",
|
||||
"uuid": "^9.0.1",
|
||||
"wavesurfer.js": "^7.7.11",
|
||||
"webm-writer": "^1.0.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
@@ -13181,6 +13182,11 @@
|
||||
"fsevents": "~2.3.2"
|
||||
}
|
||||
},
|
||||
"node_modules/wavesurfer.js": {
|
||||
"version": "7.7.11",
|
||||
"resolved": "https://registry.npmjs.org/wavesurfer.js/-/wavesurfer.js-7.7.11.tgz",
|
||||
"integrity": "sha512-27otJlDBcUSbMh/YkxEZN0yXTHINQNYSOl5aJplYlIaHw2u3BrABUzOmUZV3dKIy8udOLJXS/67y5pOIzgAg+w=="
|
||||
},
|
||||
"node_modules/wcwidth": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/wcwidth/-/wcwidth-1.0.1.tgz",
|
||||
|
||||
@@ -39,6 +39,7 @@
|
||||
"terser": "^5.30.3",
|
||||
"three": "^0.162.0",
|
||||
"uuid": "^9.0.1",
|
||||
"wavesurfer.js": "^7.7.11",
|
||||
"webm-writer": "^1.0.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
|
||||
Reference in new issue
Block a user