Upload application
Browse files- .gitattributes +1 -0
- eslint.config.js +23 -0
- index.html +14 -17
- package.json +41 -0
- public/bubble.mp3 +0 -0
- public/hover.mp3 +0 -0
- public/music.mp3 +3 -0
- src/App.tsx +138 -0
- src/components/ErrorScreen.tsx +37 -0
- src/components/LandingScreen.tsx +49 -0
- src/components/MainApplication.tsx +523 -0
- src/components/NeoButton.tsx +47 -0
- src/components/OptionCard.tsx +88 -0
- src/components/ProgressScreen.tsx +25 -0
- src/constants.ts +31 -0
- src/hooks/useAudioPlayer.ts +86 -0
- src/hooks/useLLM.ts +86 -0
- src/hooks/useTTS.ts +75 -0
- src/index.css +88 -0
- src/main.tsx +10 -0
- src/play-worklet.js +63 -0
- src/vite-env.d.ts +1 -0
- tsconfig.app.json +29 -0
- tsconfig.json +4 -0
- tsconfig.node.json +25 -0
- vite.config.ts +14 -0
.gitattributes
CHANGED
@@ -33,3 +33,4 @@ saved_model/**/* filter=lfs diff=lfs merge=lfs -text
|
|
33 |
*.zip filter=lfs diff=lfs merge=lfs -text
|
34 |
*.zst filter=lfs diff=lfs merge=lfs -text
|
35 |
*tfevents* filter=lfs diff=lfs merge=lfs -text
|
|
|
|
33 |
*.zip filter=lfs diff=lfs merge=lfs -text
|
34 |
*.zst filter=lfs diff=lfs merge=lfs -text
|
35 |
*tfevents* filter=lfs diff=lfs merge=lfs -text
|
36 |
+
public/music.mp3 filter=lfs diff=lfs merge=lfs -text
|
eslint.config.js
ADDED
@@ -0,0 +1,23 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
import js from "@eslint/js";
|
2 |
+
import globals from "globals";
|
3 |
+
import reactHooks from "eslint-plugin-react-hooks";
|
4 |
+
import reactRefresh from "eslint-plugin-react-refresh";
|
5 |
+
import tseslint from "typescript-eslint";
|
6 |
+
import { globalIgnores } from "eslint/config";
|
7 |
+
|
8 |
+
export default tseslint.config([
|
9 |
+
globalIgnores(["dist"]),
|
10 |
+
{
|
11 |
+
files: ["**/*.{ts,tsx}"],
|
12 |
+
extends: [
|
13 |
+
js.configs.recommended,
|
14 |
+
tseslint.configs.recommended,
|
15 |
+
reactHooks.configs["recommended-latest"],
|
16 |
+
reactRefresh.configs.vite,
|
17 |
+
],
|
18 |
+
languageOptions: {
|
19 |
+
ecmaVersion: 2020,
|
20 |
+
globals: globals.browser,
|
21 |
+
},
|
22 |
+
},
|
23 |
+
]);
|
index.html
CHANGED
@@ -1,19 +1,16 @@
|
|
1 |
<!doctype html>
|
2 |
-
<html>
|
3 |
-
|
4 |
-
|
5 |
-
|
6 |
-
|
7 |
-
|
8 |
-
|
9 |
-
|
10 |
-
|
11 |
-
|
12 |
-
|
13 |
-
|
14 |
-
|
15 |
-
|
16 |
-
</p>
|
17 |
-
</div>
|
18 |
-
</body>
|
19 |
</html>
|
|
|
1 |
<!doctype html>
|
2 |
+
<html lang="en">
|
3 |
+
<head>
|
4 |
+
<meta charset="UTF-8" />
|
5 |
+
<link
|
6 |
+
rel="icon"
|
7 |
+
href="data:image/svg+xml,<svg xmlns=%22http://www.w3.org/2000/svg%22 viewBox=%220 0 100 100%22><text y=%22.9em%22 font-size=%2290%22>✨</text></svg>"
|
8 |
+
/>
|
9 |
+
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
10 |
+
<title>Bedtime Story Generator</title>
|
11 |
+
</head>
|
12 |
+
<body>
|
13 |
+
<div id="root"></div>
|
14 |
+
<script type="module" src="/src/main.tsx"></script>
|
15 |
+
</body>
|
|
|
|
|
|
|
16 |
</html>
|
package.json
ADDED
@@ -0,0 +1,41 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
{
|
2 |
+
"name": "bedtime-story-generator",
|
3 |
+
"private": true,
|
4 |
+
"version": "0.0.0",
|
5 |
+
"type": "module",
|
6 |
+
"scripts": {
|
7 |
+
"dev": "vite",
|
8 |
+
"build": "tsc -b && vite build",
|
9 |
+
"lint": "eslint .",
|
10 |
+
"preview": "vite preview"
|
11 |
+
},
|
12 |
+
"dependencies": {
|
13 |
+
"@huggingface/transformers": "^3.7.1",
|
14 |
+
"@tailwindcss/vite": "^4.1.11",
|
15 |
+
"kokoro-js": "^1.2.1",
|
16 |
+
"lucide-react": "^0.539.0",
|
17 |
+
"react": "^19.1.1",
|
18 |
+
"react-dom": "^19.1.1",
|
19 |
+
"tailwindcss": "^4.1.11"
|
20 |
+
},
|
21 |
+
"devDependencies": {
|
22 |
+
"@eslint/js": "^9.32.0",
|
23 |
+
"@types/audioworklet": "^0.0.82",
|
24 |
+
"@types/react": "^19.1.9",
|
25 |
+
"@types/react-dom": "^19.1.7",
|
26 |
+
"@vitejs/plugin-react": "^4.7.0",
|
27 |
+
"@webgpu/types": "^0.1.64",
|
28 |
+
"eslint": "^9.32.0",
|
29 |
+
"eslint-plugin-react-hooks": "^5.2.0",
|
30 |
+
"eslint-plugin-react-refresh": "^0.4.20",
|
31 |
+
"globals": "^16.3.0",
|
32 |
+
"typescript": "~5.8.3",
|
33 |
+
"typescript-eslint": "^8.39.0",
|
34 |
+
"vite": "^7.1.0"
|
35 |
+
},
|
36 |
+
"overrides": {
|
37 |
+
"kokoro-js": {
|
38 |
+
"@huggingface/transformers": "^3.7.1"
|
39 |
+
}
|
40 |
+
}
|
41 |
+
}
|
public/bubble.mp3
ADDED
Binary file (34.6 kB). View file
|
|
public/hover.mp3
ADDED
Binary file (14.8 kB). View file
|
|
public/music.mp3
ADDED
@@ -0,0 +1,3 @@
|
|
|
|
|
|
|
|
|
1 |
+
version https://git-lfs.github.com/spec/v1
|
2 |
+
oid sha256:828258c0b3b023a926aeee0a83d67f546f719e63913c1781fe8eb436e43e9dc2
|
3 |
+
size 4199653
|
src/App.tsx
ADDED
@@ -0,0 +1,138 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
import { useState, useEffect, useRef, useCallback } from "react";
|
2 |
+
import { Volume2, VolumeX } from "lucide-react";
|
3 |
+
|
4 |
+
import { useLLM } from "./hooks/useLLM";
|
5 |
+
import { useTTS } from "./hooks/useTTS";
|
6 |
+
import useAudioPlayer from "./hooks/useAudioPlayer";
|
7 |
+
|
8 |
+
import LandingScreen from "./components/LandingScreen";
|
9 |
+
import ProgressScreen from "./components/ProgressScreen";
|
10 |
+
import ErrorScreen from "./components/ErrorScreen";
|
11 |
+
|
12 |
+
import WORKLET from "./play-worklet.js?raw";
|
13 |
+
import MainApplication from "./components/MainApplication";
|
14 |
+
|
15 |
+
export default function App() {
|
16 |
+
const llm = useLLM();
|
17 |
+
const tts = useTTS();
|
18 |
+
|
19 |
+
const { initAudio, playPopSound, playHoverSound, toggleMusic, playMusic, isMusicPlaying, isAudioReady } =
|
20 |
+
useAudioPlayer();
|
21 |
+
const [appState, setAppState] = useState<"landing" | "loading" | "main" | "error">(
|
22 |
+
navigator.gpu ? "landing" : "error",
|
23 |
+
);
|
24 |
+
const [error, setError] = useState<string | null>(null);
|
25 |
+
const audioWorkletNodeRef = useRef<AudioWorkletNode | null>(null);
|
26 |
+
const audioContextRef = useRef<AudioContext | null>(null);
|
27 |
+
|
28 |
+
const audioGlobal = (globalThis as any).__AUDIO__ || {
|
29 |
+
ctx: null as AudioContext | null,
|
30 |
+
node: null as AudioWorkletNode | null,
|
31 |
+
loaded: false as boolean,
|
32 |
+
};
|
33 |
+
(globalThis as any).__AUDIO__ = audioGlobal;
|
34 |
+
|
35 |
+
const allowAutoplayRef = useRef(true);
|
36 |
+
const handleToggleMusic = useCallback(() => {
|
37 |
+
if (isMusicPlaying) allowAutoplayRef.current = false;
|
38 |
+
toggleMusic();
|
39 |
+
}, [isMusicPlaying, toggleMusic]);
|
40 |
+
|
41 |
+
const handleLoadApp = async () => {
|
42 |
+
setAppState("loading");
|
43 |
+
initAudio();
|
44 |
+
|
45 |
+
if (audioGlobal.ctx && audioGlobal.node) {
|
46 |
+
audioContextRef.current = audioGlobal.ctx;
|
47 |
+
audioWorkletNodeRef.current = audioGlobal.node;
|
48 |
+
await audioContextRef.current?.resume();
|
49 |
+
} else {
|
50 |
+
try {
|
51 |
+
const audioContext = new AudioContext({ sampleRate: 24000 });
|
52 |
+
audioContextRef.current = audioContext;
|
53 |
+
await audioContext.resume();
|
54 |
+
|
55 |
+
if (!audioGlobal.loaded) {
|
56 |
+
const blob = new Blob([WORKLET], { type: "application/javascript" });
|
57 |
+
const url = URL.createObjectURL(blob);
|
58 |
+
await audioContext.audioWorklet.addModule(url);
|
59 |
+
URL.revokeObjectURL(url);
|
60 |
+
audioGlobal.loaded = true;
|
61 |
+
}
|
62 |
+
|
63 |
+
const workletNode = new AudioWorkletNode(audioContext, "buffered-audio-worklet-processor");
|
64 |
+
workletNode.connect(audioContext.destination);
|
65 |
+
|
66 |
+
audioWorkletNodeRef.current = workletNode;
|
67 |
+
audioGlobal.ctx = audioContext;
|
68 |
+
audioGlobal.node = workletNode;
|
69 |
+
} catch {}
|
70 |
+
}
|
71 |
+
|
72 |
+
await audioContextRef.current?.resume();
|
73 |
+
llm.load();
|
74 |
+
tts.load();
|
75 |
+
};
|
76 |
+
|
77 |
+
const handleLoadingComplete = useCallback(() => {
|
78 |
+
setAppState("main");
|
79 |
+
if (allowAutoplayRef.current && !isMusicPlaying) playMusic();
|
80 |
+
}, [playMusic, isMusicPlaying]);
|
81 |
+
|
82 |
+
const handleRetry = () => {
|
83 |
+
setError(null);
|
84 |
+
handleLoadApp();
|
85 |
+
};
|
86 |
+
|
87 |
+
useEffect(() => {
|
88 |
+
if (llm.error) {
|
89 |
+
setError(`LLM Error: ${llm.error}`);
|
90 |
+
setAppState("error");
|
91 |
+
} else if (tts.error) {
|
92 |
+
setError(`TTS Error: ${tts.error}`);
|
93 |
+
setAppState("error");
|
94 |
+
} else if (llm.isReady && tts.isReady) {
|
95 |
+
handleLoadingComplete();
|
96 |
+
}
|
97 |
+
}, [llm.isReady, tts.isReady, llm.error, tts.error, handleLoadingComplete]);
|
98 |
+
|
99 |
+
useEffect(() => {
|
100 |
+
if (!navigator.gpu) {
|
101 |
+
setError("WebGPU is not supported in this browser.");
|
102 |
+
setAppState("error");
|
103 |
+
return;
|
104 |
+
}
|
105 |
+
return () => {
|
106 |
+
audioWorkletNodeRef.current?.disconnect();
|
107 |
+
};
|
108 |
+
}, []);
|
109 |
+
|
110 |
+
return (
|
111 |
+
<>
|
112 |
+
<div className="bg-pattern h-screen text-black relative overflow-hidden">
|
113 |
+
{isAudioReady && (
|
114 |
+
<button
|
115 |
+
onClick={handleToggleMusic}
|
116 |
+
className="absolute top-4 right-4 z-20 p-2 bg-white/50 border-2 border-black rounded-full shadow-[2px_2px_0px_#000] hover:bg-white/80 transition-colors"
|
117 |
+
>
|
118 |
+
{isMusicPlaying ? <Volume2 /> : <VolumeX />}
|
119 |
+
</button>
|
120 |
+
)}
|
121 |
+
<LandingScreen isVisible={appState === "landing"} onLoad={handleLoadApp} playHoverSound={playHoverSound} />
|
122 |
+
<ProgressScreen isVisible={appState === "loading"} progress={(llm.progress + tts.progress) / 2} />
|
123 |
+
<ErrorScreen isVisible={appState === "error"} error={error} onRetry={handleRetry} />
|
124 |
+
<MainApplication
|
125 |
+
isVisible={appState === "main"}
|
126 |
+
playPopSound={playPopSound}
|
127 |
+
playHoverSound={playHoverSound}
|
128 |
+
generate={llm.generate}
|
129 |
+
streamTTS={tts.stream}
|
130 |
+
isTTSReady={tts.isReady}
|
131 |
+
audioWorkletNode={audioWorkletNodeRef.current}
|
132 |
+
toggleMusic={handleToggleMusic}
|
133 |
+
isMusicPlaying={isMusicPlaying}
|
134 |
+
/>
|
135 |
+
</div>
|
136 |
+
</>
|
137 |
+
);
|
138 |
+
}
|
src/components/ErrorScreen.tsx
ADDED
@@ -0,0 +1,37 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
import type React from "react";
|
2 |
+
import { AlertTriangle } from "lucide-react";
|
3 |
+
import NeoButton from "./NeoButton";
|
4 |
+
|
5 |
+
interface ErrorScreenProps {
|
6 |
+
isVisible: boolean;
|
7 |
+
error: string | null;
|
8 |
+
onRetry: () => void;
|
9 |
+
}
|
10 |
+
|
11 |
+
const ErrorScreen: React.FC<ErrorScreenProps> = ({ isVisible, error, onRetry }) => {
|
12 |
+
const transitionClass = isVisible ? "opacity-100" : "opacity-0 -translate-y-10 pointer-events-none";
|
13 |
+
return (
|
14 |
+
<div
|
15 |
+
className={`absolute inset-0 flex flex-col items-center justify-center transition-all duration-700 ease-in-out ${transitionClass}`}
|
16 |
+
>
|
17 |
+
<div className="text-center p-4 max-w-2xl">
|
18 |
+
<h1 className="text-4xl md:text-6xl font-black tracking-tighter mb-6 animate-sway text-red-500">
|
19 |
+
<AlertTriangle className="inline-block h-16 w-16 md:h-24 md:w-24 mb-4" />
|
20 |
+
<br />
|
21 |
+
An Error Occurred
|
22 |
+
</h1>
|
23 |
+
{error && (
|
24 |
+
<div className="bg-red-100 border-2 border-red-400 text-red-700 px-4 py-3 rounded-lg relative mb-8 text-left">
|
25 |
+
<strong className="font-bold">Details:</strong>
|
26 |
+
<span className="block sm:inline ml-2">{error}</span>
|
27 |
+
</div>
|
28 |
+
)}
|
29 |
+
<NeoButton onClick={onRetry} className="text-2xl px-8 py-4" variant="secondary">
|
30 |
+
Try Again
|
31 |
+
</NeoButton>
|
32 |
+
</div>
|
33 |
+
</div>
|
34 |
+
);
|
35 |
+
};
|
36 |
+
|
37 |
+
export default ErrorScreen;
|
src/components/LandingScreen.tsx
ADDED
@@ -0,0 +1,49 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
import type React from "react";
|
2 |
+
import { Sparkles } from "lucide-react";
|
3 |
+
import NeoButton from "./NeoButton";
|
4 |
+
|
5 |
+
interface LandingScreenProps {
|
6 |
+
isVisible: boolean;
|
7 |
+
onLoad: () => void;
|
8 |
+
playHoverSound?: () => void;
|
9 |
+
}
|
10 |
+
|
11 |
+
const LandingScreen: React.FC<LandingScreenProps> = ({ isVisible, onLoad, playHoverSound }) => {
|
12 |
+
const transitionClass = isVisible ? "opacity-100" : "opacity-0 -translate-y-10 pointer-events-none";
|
13 |
+
return (
|
14 |
+
<div
|
15 |
+
className={`absolute inset-0 flex flex-col items-center justify-center transition-all duration-700 ease-in-out ${transitionClass}`}
|
16 |
+
>
|
17 |
+
<div className="text-center p-4">
|
18 |
+
<h1 className="text-6xl md:text-8xl font-black tracking-tighter mb-6 animate-sway">
|
19 |
+
✨ Bedtime Story ✨<br />
|
20 |
+
Generator
|
21 |
+
</h1>
|
22 |
+
<p className="text-2xl md:text-3xl mb-10">Craft magical tales in seconds.</p>
|
23 |
+
<NeoButton
|
24 |
+
onClick={onLoad}
|
25 |
+
className="text-2xl px-8 py-4 mb-4 animate-pulse-grow"
|
26 |
+
playHoverSound={playHoverSound}
|
27 |
+
>
|
28 |
+
<Sparkles className="mr-1" /> Start Creating
|
29 |
+
</NeoButton>
|
30 |
+
</div>
|
31 |
+
|
32 |
+
<footer className="absolute bottom-5 left-0 right-0">
|
33 |
+
<div className="flex items-center justify-center text-gray-500 text-sm">
|
34 |
+
<span>Built with </span>
|
35 |
+
<a
|
36 |
+
href="https://github.com/huggingface/transformers.js"
|
37 |
+
target="_blank"
|
38 |
+
rel="noopener noreferrer"
|
39 |
+
className="ml-1 font-semibold text-gray-600 hover:text-black transition-colors flex items-center"
|
40 |
+
>
|
41 |
+
🤗 Transformers.js
|
42 |
+
</a>
|
43 |
+
</div>
|
44 |
+
</footer>
|
45 |
+
</div>
|
46 |
+
);
|
47 |
+
};
|
48 |
+
|
49 |
+
export default LandingScreen;
|
src/components/MainApplication.tsx
ADDED
@@ -0,0 +1,523 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
import { useReducer, useState, useRef, useEffect, useCallback } from "react";
|
2 |
+
import type React from "react";
|
3 |
+
import { Dices, RefreshCw } from "lucide-react";
|
4 |
+
|
5 |
+
import type { generateFn } from "../hooks/useLLM";
|
6 |
+
import NeoButton from "./NeoButton";
|
7 |
+
import OptionCard from "./OptionCard";
|
8 |
+
import { STORY_DATA } from "../constants";
|
9 |
+
|
10 |
+
interface MainApplicationProps {
|
11 |
+
isVisible: boolean;
|
12 |
+
playPopSound: () => void;
|
13 |
+
playHoverSound: () => void;
|
14 |
+
generate: generateFn;
|
15 |
+
streamTTS: (onAudioChunk: (chunk: { audio: Float32Array; text?: string }) => void) => {
|
16 |
+
splitter: any;
|
17 |
+
ttsPromise: Promise<void>;
|
18 |
+
};
|
19 |
+
isTTSReady: boolean;
|
20 |
+
audioWorkletNode: AudioWorkletNode | null;
|
21 |
+
toggleMusic: (force?: boolean) => void;
|
22 |
+
isMusicPlaying: boolean;
|
23 |
+
}
|
24 |
+
|
25 |
+
type StoryState = {
|
26 |
+
character: string;
|
27 |
+
setting: string;
|
28 |
+
item: string;
|
29 |
+
theme: string;
|
30 |
+
length: string;
|
31 |
+
customCharacter: string;
|
32 |
+
customSetting: string;
|
33 |
+
customItem: string;
|
34 |
+
};
|
35 |
+
|
36 |
+
type StoryAction =
|
37 |
+
| { type: "SET_FIELD"; field: keyof StoryState; value: string }
|
38 |
+
| {
|
39 |
+
type: "SURPRISE_ME";
|
40 |
+
payload: Omit<StoryState, "customCharacter" | "customSetting" | "customItem">;
|
41 |
+
}
|
42 |
+
| { type: "RESET" };
|
43 |
+
|
44 |
+
const initialState: StoryState = {
|
45 |
+
character: STORY_DATA.characters[0],
|
46 |
+
setting: STORY_DATA.settings[0],
|
47 |
+
item: STORY_DATA.items[0],
|
48 |
+
theme: STORY_DATA.themes[0],
|
49 |
+
length: STORY_DATA.length[0],
|
50 |
+
customCharacter: "",
|
51 |
+
customSetting: "",
|
52 |
+
customItem: "",
|
53 |
+
};
|
54 |
+
|
55 |
+
const storyReducer = (state: StoryState, action: StoryAction): StoryState => {
|
56 |
+
switch (action.type) {
|
57 |
+
case "SET_FIELD":
|
58 |
+
return { ...state, [action.field]: action.value };
|
59 |
+
case "SURPRISE_ME":
|
60 |
+
return { ...initialState, ...action.payload };
|
61 |
+
case "RESET":
|
62 |
+
return initialState;
|
63 |
+
default:
|
64 |
+
return state;
|
65 |
+
}
|
66 |
+
};
|
67 |
+
|
68 |
+
const MainApplication: React.FC<MainApplicationProps> = ({
|
69 |
+
isVisible,
|
70 |
+
playPopSound,
|
71 |
+
playHoverSound,
|
72 |
+
generate,
|
73 |
+
streamTTS,
|
74 |
+
isTTSReady,
|
75 |
+
audioWorkletNode,
|
76 |
+
toggleMusic,
|
77 |
+
isMusicPlaying,
|
78 |
+
}) => {
|
79 |
+
const [storyState, dispatch] = useReducer(storyReducer, initialState);
|
80 |
+
const { character, setting, item, theme, length, customCharacter, customSetting, customItem } = storyState;
|
81 |
+
|
82 |
+
const [generatedStory, setGeneratedStory] = useState<string | null>(null);
|
83 |
+
const [isShuffling, setIsShuffling] = useState<boolean>(false);
|
84 |
+
const [isLoading, setIsLoading] = useState<boolean>(false);
|
85 |
+
const [isSpeaking, setIsSpeaking] = useState<boolean>(false);
|
86 |
+
const [previewText, setPreviewText] = useState<string>("");
|
87 |
+
|
88 |
+
const storyPanelRef = useRef<HTMLDivElement | null>(null);
|
89 |
+
|
90 |
+
const [ranges, setRanges] = useState<Array<{ start: number; end: number }>>([]);
|
91 |
+
const rangesRef = useRef(ranges);
|
92 |
+
useEffect(() => {
|
93 |
+
rangesRef.current = ranges;
|
94 |
+
}, [ranges]);
|
95 |
+
|
96 |
+
const pendingRef = useRef<string[]>([]);
|
97 |
+
const rawSearchStartRef = useRef(0);
|
98 |
+
const normDataRef = useRef<{ norm: string; map: number[] } | null>(null);
|
99 |
+
|
100 |
+
const [currentChunkIdx, setCurrentChunkIdx] = useState<number>(-1);
|
101 |
+
const finishedIndexRef = useRef<number>(-1);
|
102 |
+
const textContainerRef = useRef<HTMLDivElement | null>(null);
|
103 |
+
const chunkRefs = useRef<(HTMLSpanElement | null)[]>([]);
|
104 |
+
const resumeMusicOnPlaybackEndRef = useRef<boolean>(false);
|
105 |
+
const activeSplitterRef = useRef<any>(null);
|
106 |
+
|
107 |
+
const displayedText = previewText || generatedStory || "";
|
108 |
+
|
109 |
+
const buildNorm = (text: string) => {
|
110 |
+
const map: number[] = [];
|
111 |
+
let norm = "";
|
112 |
+
for (let i = 0; i < text.length; i++) {
|
113 |
+
const ch = text[i];
|
114 |
+
if (/\s/.test(ch)) continue;
|
115 |
+
map.push(i);
|
116 |
+
norm += ch;
|
117 |
+
}
|
118 |
+
return { norm, map };
|
119 |
+
};
|
120 |
+
|
121 |
+
const tryResolve = useCallback(() => {
|
122 |
+
const normData = normDataRef.current;
|
123 |
+
if (!normData) return;
|
124 |
+
|
125 |
+
const { norm, map } = normData;
|
126 |
+
const nextRanges = rangesRef.current.slice();
|
127 |
+
|
128 |
+
const normStartFromRaw = () => {
|
129 |
+
let i = 0;
|
130 |
+
while (i < map.length && map[i] < rawSearchStartRef.current) i++;
|
131 |
+
return i;
|
132 |
+
};
|
133 |
+
|
134 |
+
let changed = false;
|
135 |
+
while (pendingRef.current.length) {
|
136 |
+
const nextText = pendingRef.current[0];
|
137 |
+
const needle = nextText.replace(/\s+/g, "");
|
138 |
+
if (!needle) {
|
139 |
+
pendingRef.current.shift();
|
140 |
+
continue;
|
141 |
+
}
|
142 |
+
const startNormIdx = normStartFromRaw();
|
143 |
+
const idx = norm.indexOf(needle, startNormIdx);
|
144 |
+
if (idx === -1) break;
|
145 |
+
|
146 |
+
const startRaw = map[idx];
|
147 |
+
const endRaw = map[idx + needle.length - 1] + 1;
|
148 |
+
|
149 |
+
nextRanges.push({ start: startRaw, end: endRaw });
|
150 |
+
rawSearchStartRef.current = endRaw;
|
151 |
+
pendingRef.current.shift();
|
152 |
+
changed = true;
|
153 |
+
}
|
154 |
+
|
155 |
+
if (changed) {
|
156 |
+
setRanges(nextRanges);
|
157 |
+
setCurrentChunkIdx((prev) =>
|
158 |
+
prev === -1 ? Math.min(finishedIndexRef.current + 1, nextRanges.length - 1) : prev,
|
159 |
+
);
|
160 |
+
}
|
161 |
+
}, []);
|
162 |
+
|
163 |
+
useEffect(() => {
|
164 |
+
normDataRef.current = buildNorm(displayedText);
|
165 |
+
tryResolve();
|
166 |
+
}, [displayedText, tryResolve]);
|
167 |
+
|
168 |
+
useEffect(() => {
|
169 |
+
if (!audioWorkletNode) return;
|
170 |
+
const handler = (event: MessageEvent) => {
|
171 |
+
const data = (event as any).data;
|
172 |
+
if (!data || typeof data !== "object") return;
|
173 |
+
if (data.type === "next_chunk") {
|
174 |
+
finishedIndexRef.current += 1;
|
175 |
+
const nextIdx = finishedIndexRef.current + 1;
|
176 |
+
setCurrentChunkIdx(nextIdx < rangesRef.current.length ? nextIdx : -1);
|
177 |
+
} else if (data.type === "playback_ended") {
|
178 |
+
setIsSpeaking(false);
|
179 |
+
setCurrentChunkIdx(-1);
|
180 |
+
if (resumeMusicOnPlaybackEndRef.current) {
|
181 |
+
toggleMusic();
|
182 |
+
}
|
183 |
+
}
|
184 |
+
};
|
185 |
+
(audioWorkletNode.port as any).onmessage = handler;
|
186 |
+
return () => {
|
187 |
+
if (audioWorkletNode?.port) (audioWorkletNode.port as any).onmessage = null;
|
188 |
+
};
|
189 |
+
}, [audioWorkletNode, toggleMusic]);
|
190 |
+
|
191 |
+
useEffect(() => {
|
192 |
+
if (currentChunkIdx < 0) return;
|
193 |
+
const container = textContainerRef.current;
|
194 |
+
const el = chunkRefs.current[currentChunkIdx];
|
195 |
+
if (!container || !el) return;
|
196 |
+
const containerRect = container.getBoundingClientRect();
|
197 |
+
const elRect = el.getBoundingClientRect();
|
198 |
+
const elTopInContainer = elRect.top - containerRect.top + container.scrollTop;
|
199 |
+
const targetTop = Math.max(0, elTopInContainer - container.clientHeight * 0.3);
|
200 |
+
container.scrollTo({ top: targetTop, behavior: "smooth" });
|
201 |
+
}, [currentChunkIdx]);
|
202 |
+
|
203 |
+
const getRandomItem = (arr: string[]): string => arr[Math.floor(Math.random() * arr.length)];
|
204 |
+
|
205 |
+
const handleSurpriseMe = () => {
|
206 |
+
playPopSound();
|
207 |
+
if (isShuffling) return;
|
208 |
+
setIsShuffling(true);
|
209 |
+
dispatch({ type: "SET_FIELD", field: "customCharacter", value: "" });
|
210 |
+
dispatch({ type: "SET_FIELD", field: "customSetting", value: "" });
|
211 |
+
dispatch({ type: "SET_FIELD", field: "customItem", value: "" });
|
212 |
+
|
213 |
+
let count = 0;
|
214 |
+
const max = 15;
|
215 |
+
const interval = setInterval(() => {
|
216 |
+
dispatch({
|
217 |
+
type: "SURPRISE_ME",
|
218 |
+
payload: {
|
219 |
+
character: getRandomItem(STORY_DATA.characters),
|
220 |
+
setting: getRandomItem(STORY_DATA.settings),
|
221 |
+
item: getRandomItem(STORY_DATA.items),
|
222 |
+
theme: getRandomItem(STORY_DATA.themes),
|
223 |
+
length: getRandomItem(STORY_DATA.length),
|
224 |
+
},
|
225 |
+
});
|
226 |
+
count++;
|
227 |
+
if (count >= max) {
|
228 |
+
clearInterval(interval);
|
229 |
+
setIsShuffling(false);
|
230 |
+
}
|
231 |
+
}, 60);
|
232 |
+
};
|
233 |
+
|
234 |
+
const generateStory = async () => {
|
235 |
+
if (!audioWorkletNode) return;
|
236 |
+
playPopSound();
|
237 |
+
setIsLoading(true);
|
238 |
+
setPreviewText("");
|
239 |
+
setGeneratedStory(null);
|
240 |
+
|
241 |
+
setRanges([]);
|
242 |
+
rangesRef.current = [];
|
243 |
+
pendingRef.current = [];
|
244 |
+
rawSearchStartRef.current = 0;
|
245 |
+
setCurrentChunkIdx(-1);
|
246 |
+
finishedIndexRef.current = -1;
|
247 |
+
chunkRefs.current = [];
|
248 |
+
|
249 |
+
const wasMusicPlaying = isMusicPlaying;
|
250 |
+
resumeMusicOnPlaybackEndRef.current = wasMusicPlaying;
|
251 |
+
|
252 |
+
storyPanelRef.current?.scrollIntoView({
|
253 |
+
behavior: "smooth",
|
254 |
+
block: "start",
|
255 |
+
});
|
256 |
+
|
257 |
+
setIsSpeaking(true);
|
258 |
+
if (wasMusicPlaying) toggleMusic();
|
259 |
+
|
260 |
+
const { splitter, ttsPromise } = streamTTS(({ audio, text }) => {
|
261 |
+
audioWorkletNode.port.postMessage(audio);
|
262 |
+
if (text) {
|
263 |
+
pendingRef.current.push(text);
|
264 |
+
tryResolve();
|
265 |
+
}
|
266 |
+
});
|
267 |
+
activeSplitterRef.current = splitter;
|
268 |
+
|
269 |
+
const selectedCharacter = (customCharacter || character).trim();
|
270 |
+
const selectedSetting = (customSetting || setting).trim();
|
271 |
+
const selectedItem = (customItem || item).trim();
|
272 |
+
|
273 |
+
const lengthMap: Record<string, string> = {
|
274 |
+
Short: "100-200 word",
|
275 |
+
Medium: "200-300 word",
|
276 |
+
Long: "300-400 word",
|
277 |
+
};
|
278 |
+
|
279 |
+
const userMessage = `Write a ${lengthMap[length]} ${theme.toLowerCase()} story about ${selectedCharacter} ${selectedSetting} that ${selectedItem}.`;
|
280 |
+
|
281 |
+
try {
|
282 |
+
const llmPromise = generate(
|
283 |
+
[{ role: "user", content: userMessage }],
|
284 |
+
(token: string) => setPreviewText((prev) => prev + token),
|
285 |
+
splitter,
|
286 |
+
);
|
287 |
+
await Promise.all([llmPromise, ttsPromise]);
|
288 |
+
} catch {
|
289 |
+
setGeneratedStory(previewText || "Sorry, the story failed to generate.");
|
290 |
+
} finally {
|
291 |
+
activeSplitterRef.current = null;
|
292 |
+
setIsLoading(false);
|
293 |
+
}
|
294 |
+
};
|
295 |
+
|
296 |
+
const handleReset = () => {
|
297 |
+
playPopSound();
|
298 |
+
|
299 |
+
activeSplitterRef.current?.close?.();
|
300 |
+
activeSplitterRef.current = null;
|
301 |
+
audioWorkletNode?.port.postMessage("stop");
|
302 |
+
setIsSpeaking(false);
|
303 |
+
|
304 |
+
if (!isMusicPlaying && resumeMusicOnPlaybackEndRef.current) {
|
305 |
+
toggleMusic();
|
306 |
+
}
|
307 |
+
resumeMusicOnPlaybackEndRef.current = false;
|
308 |
+
|
309 |
+
setRanges([]);
|
310 |
+
rangesRef.current = [];
|
311 |
+
pendingRef.current = [];
|
312 |
+
rawSearchStartRef.current = 0;
|
313 |
+
setCurrentChunkIdx(-1);
|
314 |
+
finishedIndexRef.current = -1;
|
315 |
+
chunkRefs.current = [];
|
316 |
+
|
317 |
+
setGeneratedStory(null);
|
318 |
+
setPreviewText("");
|
319 |
+
|
320 |
+
dispatch({ type: "RESET" });
|
321 |
+
};
|
322 |
+
|
323 |
+
const transitionClass = isVisible ? "opacity-100" : "opacity-0 translate-y-10 pointer-events-none";
|
324 |
+
|
325 |
+
return (
|
326 |
+
<div
|
327 |
+
className={`h-screen py-16 px-8 overflow-y-scroll transition-all duration-700 ease-in-out ${isVisible ? "delay-300" : ""} ${transitionClass}`}
|
328 |
+
>
|
329 |
+
<header className="text-center mb-8">
|
330 |
+
<h1 className="text-5xl md:text-7xl font-black tracking-tighter">Let's Make a Story!</h1>
|
331 |
+
<p className="text-lg mt-2 bg-cyan-300 inline-block px-3 py-1 border-2 border-black rounded-md">
|
332 |
+
Runs completely on your device, powered by
|
333 |
+
<a
|
334 |
+
href="https://huggingface.co/onnx-community/gemma-3-270m-it-ONNX"
|
335 |
+
target="_blank"
|
336 |
+
rel="noopener noreferrer"
|
337 |
+
className="ml-1 font-semibold text-gray-700 hover:text-black transition-colors"
|
338 |
+
>
|
339 |
+
Gemma 3 270M
|
340 |
+
</a>
|
341 |
+
!
|
342 |
+
</p>
|
343 |
+
</header>
|
344 |
+
|
345 |
+
<main className="w-full max-w-4xl mx-auto space-y-6">
|
346 |
+
<OptionCard
|
347 |
+
title="1. Choose a Character"
|
348 |
+
options={STORY_DATA.characters}
|
349 |
+
selected={character}
|
350 |
+
onSelect={(val) => dispatch({ type: "SET_FIELD", field: "character", value: val })}
|
351 |
+
customValue={customCharacter}
|
352 |
+
onCustomChange={(val) =>
|
353 |
+
dispatch({
|
354 |
+
type: "SET_FIELD",
|
355 |
+
field: "customCharacter",
|
356 |
+
value: val,
|
357 |
+
})
|
358 |
+
}
|
359 |
+
isShuffling={isShuffling}
|
360 |
+
isVisible={isVisible}
|
361 |
+
playPopSound={playPopSound}
|
362 |
+
playHoverSound={playHoverSound}
|
363 |
+
/>
|
364 |
+
<OptionCard
|
365 |
+
title="2. Pick a Setting"
|
366 |
+
options={STORY_DATA.settings}
|
367 |
+
selected={setting}
|
368 |
+
onSelect={(val) => dispatch({ type: "SET_FIELD", field: "setting", value: val })}
|
369 |
+
customValue={customSetting}
|
370 |
+
onCustomChange={(val) => dispatch({ type: "SET_FIELD", field: "customSetting", value: val })}
|
371 |
+
isShuffling={isShuffling}
|
372 |
+
isVisible={isVisible}
|
373 |
+
playPopSound={playPopSound}
|
374 |
+
playHoverSound={playHoverSound}
|
375 |
+
/>
|
376 |
+
<OptionCard
|
377 |
+
title="3. Add a Twist"
|
378 |
+
options={STORY_DATA.items}
|
379 |
+
selected={item}
|
380 |
+
onSelect={(val) => dispatch({ type: "SET_FIELD", field: "item", value: val })}
|
381 |
+
customValue={customItem}
|
382 |
+
onCustomChange={(val) => dispatch({ type: "SET_FIELD", field: "customItem", value: val })}
|
383 |
+
isShuffling={isShuffling}
|
384 |
+
isVisible={isVisible}
|
385 |
+
playPopSound={playPopSound}
|
386 |
+
playHoverSound={playHoverSound}
|
387 |
+
/>
|
388 |
+
<div
|
389 |
+
className={`bg-white border-2 border-black rounded-xl p-6 shadow-[6px_6px_0px_rgba(0,0,0,0.1)] transition-opacity duration-500 ${isShuffling ? "animate-shake" : ""} ${isVisible ? "animate-slide-in" : "opacity-0"}`}
|
390 |
+
>
|
391 |
+
<h3 className="text-2xl font-bold mb-4">4. Select a Theme</h3>
|
392 |
+
<div className="flex flex-wrap gap-3">
|
393 |
+
{STORY_DATA.themes.map((t) => (
|
394 |
+
<button
|
395 |
+
key={t}
|
396 |
+
onClick={() => {
|
397 |
+
playPopSound();
|
398 |
+
dispatch({ type: "SET_FIELD", field: "theme", value: t });
|
399 |
+
}}
|
400 |
+
onMouseEnter={playHoverSound}
|
401 |
+
className={`font-bold py-3 px-6 border-2 rounded-lg transition-all duration-150 text-lg transform hover:-translate-y-0.5 active:translate-y-0 ${theme === t ? "bg-pink-400 border-black shadow-[2px_2px_0px_#000]" : "bg-gray-100 border-gray-300 hover:bg-gray-200"}`}
|
402 |
+
>
|
403 |
+
{t}
|
404 |
+
</button>
|
405 |
+
))}
|
406 |
+
</div>
|
407 |
+
</div>
|
408 |
+
<div
|
409 |
+
className={`bg-white border-2 border-black rounded-xl p-6 shadow-[6px_6px_0px_rgba(0,0,0,0.1)] transition-opacity duration-500 ${isShuffling ? "animate-shake" : ""} ${isVisible ? "animate-slide-in" : "opacity-0"}`}
|
410 |
+
>
|
411 |
+
<h3 className="text-2xl font-bold mb-4">5. Story Length</h3>
|
412 |
+
<div className="flex flex-wrap gap-3">
|
413 |
+
{STORY_DATA.length.map((l) => (
|
414 |
+
<button
|
415 |
+
key={l}
|
416 |
+
onClick={() => {
|
417 |
+
playPopSound();
|
418 |
+
dispatch({ type: "SET_FIELD", field: "length", value: l });
|
419 |
+
}}
|
420 |
+
onMouseEnter={playHoverSound}
|
421 |
+
className={`font-bold py-3 px-6 border-2 rounded-lg transition-all duration-150 text-lg transform hover:-translate-y-0.5 active:translate-y-0 ${length === l ? "bg-pink-400 border-black shadow-[2px_2px_0px_#000]" : "bg-gray-100 border-gray-300 hover:bg-gray-200"}`}
|
422 |
+
>
|
423 |
+
{l}
|
424 |
+
</button>
|
425 |
+
))}
|
426 |
+
</div>
|
427 |
+
</div>
|
428 |
+
<div className="pt-6 flex flex-col sm:flex-row items-center justify-center gap-4">
|
429 |
+
<NeoButton
|
430 |
+
onClick={generateStory}
|
431 |
+
{...{
|
432 |
+
className: "w-full sm:w-auto text-xl",
|
433 |
+
isLoading,
|
434 |
+
disabled: isShuffling || isLoading,
|
435 |
+
playHoverSound,
|
436 |
+
}}
|
437 |
+
>
|
438 |
+
Make My Story
|
439 |
+
</NeoButton>
|
440 |
+
<NeoButton
|
441 |
+
onClick={handleSurpriseMe}
|
442 |
+
{...{
|
443 |
+
variant: "accent",
|
444 |
+
className: "w-full sm:w-auto text-xl",
|
445 |
+
disabled: isShuffling || isLoading,
|
446 |
+
playHoverSound,
|
447 |
+
}}
|
448 |
+
>
|
449 |
+
<Dices size={24} />
|
450 |
+
Surprise Me!
|
451 |
+
</NeoButton>
|
452 |
+
<NeoButton
|
453 |
+
onClick={handleReset}
|
454 |
+
{...{
|
455 |
+
variant: "secondary",
|
456 |
+
className: "w-full sm:w-auto text-xl",
|
457 |
+
disabled: isLoading,
|
458 |
+
playHoverSound,
|
459 |
+
}}
|
460 |
+
>
|
461 |
+
<RefreshCw size={20} />
|
462 |
+
Reset
|
463 |
+
</NeoButton>
|
464 |
+
</div>
|
465 |
+
|
466 |
+
{/* Story Panel (covers screen height, no separate story page) */}
|
467 |
+
<div
|
468 |
+
ref={storyPanelRef}
|
469 |
+
className="mt-8 bg-white border-2 border-black rounded-xl p-8 shadow-[8px_8px_0px_#000] overflow-y-auto animate-slide-in"
|
470 |
+
>
|
471 |
+
<h3 className="text-3xl font-black mb-6 text-center">Your Story</h3>
|
472 |
+
<div
|
473 |
+
ref={textContainerRef}
|
474 |
+
className="text-2xl leading-relaxed font-serif whitespace-pre-wrap h-[400px] overflow-y-auto"
|
475 |
+
>
|
476 |
+
{(() => {
|
477 |
+
const displayed = displayedText;
|
478 |
+
if (!displayed) {
|
479 |
+
return "Click “Make My Story” to generate a bedtime story.";
|
480 |
+
}
|
481 |
+
const pieces: React.ReactNode[] = [];
|
482 |
+
let last = 0;
|
483 |
+
for (let i = 0; i < ranges.length; i++) {
|
484 |
+
const { start, end } = ranges[i];
|
485 |
+
if (start > last) {
|
486 |
+
pieces.push(<span key={`n-${i}`}>{displayed.slice(last, start)}</span>);
|
487 |
+
}
|
488 |
+
pieces.push(
|
489 |
+
<span
|
490 |
+
key={`h-${i}`}
|
491 |
+
ref={(el) => {
|
492 |
+
chunkRefs.current[i] = el;
|
493 |
+
}}
|
494 |
+
className={i === currentChunkIdx ? "bg-yellow-200" : ""}
|
495 |
+
>
|
496 |
+
{displayed.slice(start, end)}
|
497 |
+
</span>,
|
498 |
+
);
|
499 |
+
last = end;
|
500 |
+
}
|
501 |
+
if (last < displayed.length) {
|
502 |
+
pieces.push(<span key="tail">{displayed.slice(last)}</span>);
|
503 |
+
}
|
504 |
+
return pieces;
|
505 |
+
})()}
|
506 |
+
</div>
|
507 |
+
{isLoading && (
|
508 |
+
<div className="mt-4 text-center text-sm opacity-70">
|
509 |
+
{isSpeaking ? "Generating and speaking..." : "Generating..."}
|
510 |
+
</div>
|
511 |
+
)}
|
512 |
+
{generatedStory && !isLoading && isTTSReady && !isSpeaking && (
|
513 |
+
<div className="mt-4 flex justify-center">
|
514 |
+
<p className="text-sm opacity-70">Story finished.</p>
|
515 |
+
</div>
|
516 |
+
)}
|
517 |
+
</div>
|
518 |
+
</main>
|
519 |
+
</div>
|
520 |
+
);
|
521 |
+
};
|
522 |
+
|
523 |
+
export default MainApplication;
|
src/components/NeoButton.tsx
ADDED
@@ -0,0 +1,47 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
import type React from "react";
|
2 |
+
import { Loader } from "lucide-react";
|
3 |
+
|
4 |
+
interface NeoButtonProps {
|
5 |
+
children: React.ReactNode;
|
6 |
+
onClick?: (e: React.MouseEvent<HTMLButtonElement>) => void;
|
7 |
+
playPopSound?: () => void;
|
8 |
+
playHoverSound?: () => void;
|
9 |
+
className?: string;
|
10 |
+
variant?: "primary" | "secondary" | "accent";
|
11 |
+
isLoading?: boolean;
|
12 |
+
disabled?: boolean;
|
13 |
+
}
|
14 |
+
|
15 |
+
const NeoButtonComponent: React.FC<NeoButtonProps> = ({
|
16 |
+
children,
|
17 |
+
onClick,
|
18 |
+
playHoverSound,
|
19 |
+
className = "",
|
20 |
+
variant = "primary",
|
21 |
+
isLoading = false,
|
22 |
+
disabled = false,
|
23 |
+
}) => {
|
24 |
+
const colorClasses = {
|
25 |
+
primary: "bg-yellow-300 hover:bg-yellow-400 border-black",
|
26 |
+
secondary: "bg-pink-400 hover:bg-pink-500 border-black",
|
27 |
+
accent: "bg-cyan-400 hover:bg-cyan-500 border-black",
|
28 |
+
};
|
29 |
+
const primaryShine = variant === "primary" ? "animate-shine" : "";
|
30 |
+
|
31 |
+
return (
|
32 |
+
<button
|
33 |
+
onClick={onClick}
|
34 |
+
onMouseEnter={playHoverSound}
|
35 |
+
disabled={isLoading || disabled}
|
36 |
+
className={`relative font-bold py-3 px-6 border-2 rounded-lg shadow-[4px_4px_0px_#000] transition-all duration-150 transform hover:-translate-y-1 active:shadow-[1px_1px_0px_#000] active:translate-y-0 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-black disabled:opacity-70 disabled:cursor-not-allowed disabled:transform-none disabled:shadow-[4px_4px_0px_#000] overflow-hidden ${colorClasses[variant]} ${className}`}
|
37 |
+
>
|
38 |
+
<span className={`absolute top-0 left-0 w-full h-full ${primaryShine}`} />
|
39 |
+
<span className="relative z-10 flex items-center justify-center gap-3">
|
40 |
+
{isLoading && <Loader className="animate-spin" />}
|
41 |
+
{children}
|
42 |
+
</span>
|
43 |
+
</button>
|
44 |
+
);
|
45 |
+
};
|
46 |
+
|
47 |
+
export default NeoButtonComponent;
|
src/components/OptionCard.tsx
ADDED
@@ -0,0 +1,88 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
import { useState } from "react";
|
2 |
+
import type React from "react";
|
3 |
+
import { Pencil } from "lucide-react";
|
4 |
+
|
5 |
+
interface OptionCardProps {
|
6 |
+
title: string;
|
7 |
+
options: string[];
|
8 |
+
selected: string;
|
9 |
+
onSelect: (value: string) => void;
|
10 |
+
customValue: string;
|
11 |
+
onCustomChange: (value: string) => void;
|
12 |
+
isShuffling: boolean;
|
13 |
+
isVisible: boolean;
|
14 |
+
playPopSound: () => void;
|
15 |
+
playHoverSound: () => void;
|
16 |
+
}
|
17 |
+
|
18 |
+
const OptionCard: React.FC<OptionCardProps> = ({
|
19 |
+
title,
|
20 |
+
options,
|
21 |
+
selected,
|
22 |
+
onSelect,
|
23 |
+
customValue,
|
24 |
+
onCustomChange,
|
25 |
+
isShuffling,
|
26 |
+
isVisible,
|
27 |
+
playPopSound,
|
28 |
+
playHoverSound,
|
29 |
+
}) => {
|
30 |
+
const [isCustom, setIsCustom] = useState(false);
|
31 |
+
|
32 |
+
const handleSelect = (option: string) => {
|
33 |
+
playPopSound();
|
34 |
+
setIsCustom(false);
|
35 |
+
onSelect(option);
|
36 |
+
onCustomChange("");
|
37 |
+
};
|
38 |
+
const handleCustomClick = () => {
|
39 |
+
playPopSound();
|
40 |
+
setIsCustom(true);
|
41 |
+
onSelect("");
|
42 |
+
};
|
43 |
+
|
44 |
+
const shuffleClass = isShuffling ? "animate-shake" : "";
|
45 |
+
const visibilityClass = isVisible ? "animate-slide-in opacity-100" : "opacity-0 pointer-events-none";
|
46 |
+
|
47 |
+
return (
|
48 |
+
<div
|
49 |
+
className={`bg-white border-2 border-black rounded-xl p-6 shadow-[6px_6px_0px_rgba(0,0,0,0.1)] transition-opacity duration-500 ${shuffleClass} ${visibilityClass}`}
|
50 |
+
>
|
51 |
+
<h3 className="text-2xl font-bold mb-4">{title}</h3>
|
52 |
+
<div className="flex flex-wrap gap-3">
|
53 |
+
{options.map((option) => (
|
54 |
+
<button
|
55 |
+
key={option}
|
56 |
+
onClick={() => handleSelect(option)}
|
57 |
+
onMouseEnter={playHoverSound}
|
58 |
+
className={`font-semibold py-2 px-4 border-2 rounded-md transition-all duration-150 transform hover:-translate-y-0.5 active:translate-y-0 ${selected === option && !isCustom ? "bg-black text-white border-black" : "bg-gray-100 hover:bg-gray-200 border-gray-300"}`}
|
59 |
+
>
|
60 |
+
{option}
|
61 |
+
</button>
|
62 |
+
))}
|
63 |
+
<button
|
64 |
+
onClick={handleCustomClick}
|
65 |
+
onMouseEnter={playHoverSound}
|
66 |
+
className={`font-semibold py-2 px-4 border-2 rounded-md transition-all duration-150 flex items-center gap-2 transform hover:-translate-y-0.5 active:translate-y-0 ${isCustom ? "bg-black text-white border-black" : "bg-gray-100 hover:bg-gray-200 border-gray-300"}`}
|
67 |
+
>
|
68 |
+
<Pencil size={16} />
|
69 |
+
Write your own
|
70 |
+
</button>
|
71 |
+
</div>
|
72 |
+
{isCustom && (
|
73 |
+
<input
|
74 |
+
type="text"
|
75 |
+
value={customValue}
|
76 |
+
onChange={(e: React.ChangeEvent<HTMLInputElement>) => {
|
77 |
+
onCustomChange(e.target.value);
|
78 |
+
onSelect("");
|
79 |
+
}}
|
80 |
+
placeholder="Enter your own option..."
|
81 |
+
className="mt-4 w-full p-3 border-2 border-black rounded-lg focus:outline-none focus:ring-2 focus:ring-yellow-400"
|
82 |
+
/>
|
83 |
+
)}
|
84 |
+
</div>
|
85 |
+
);
|
86 |
+
};
|
87 |
+
|
88 |
+
export default OptionCard;
|
src/components/ProgressScreen.tsx
ADDED
@@ -0,0 +1,25 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
import type React from "react";
|
2 |
+
|
3 |
+
interface ProgressScreenProps {
|
4 |
+
progress: number;
|
5 |
+
isVisible: boolean;
|
6 |
+
}
|
7 |
+
|
8 |
+
const ProgressScreen: React.FC<ProgressScreenProps> = ({ progress, isVisible }) => {
|
9 |
+
const transitionClass = isVisible ? "opacity-100" : "opacity-0 pointer-events-none";
|
10 |
+
return (
|
11 |
+
<div
|
12 |
+
className={`absolute inset-0 flex flex-col items-center justify-center transition-opacity duration-500 ${transitionClass}`}
|
13 |
+
>
|
14 |
+
<div className="w-full max-w-md text-center">
|
15 |
+
<h2 className="text-3xl font-bold mb-4">Warming up the magic...</h2>
|
16 |
+
<div className="w-full bg-white border-2 border-black rounded-full p-1 shadow-[4px_4px_0px_#000]">
|
17 |
+
<div className="bg-pink-400 h-6 rounded-full" style={{ width: `${progress}%` }} />
|
18 |
+
</div>
|
19 |
+
<p className="mt-2 text-lg font-semibold">{progress.toFixed(2)}%</p>
|
20 |
+
</div>
|
21 |
+
</div>
|
22 |
+
);
|
23 |
+
};
|
24 |
+
|
25 |
+
export default ProgressScreen;
|
src/constants.ts
ADDED
@@ -0,0 +1,31 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
export const STORY_DATA = {
|
2 |
+
characters: [
|
3 |
+
"a brave squirrel",
|
4 |
+
"a shy dragon",
|
5 |
+
"a clever fox",
|
6 |
+
"a sleepy astronaut",
|
7 |
+
"a grumpy gnome",
|
8 |
+
"a cheerful princess",
|
9 |
+
"a tiny robot",
|
10 |
+
"a magical cat",
|
11 |
+
],
|
12 |
+
settings: [
|
13 |
+
"on the moon",
|
14 |
+
"in an enchanted forest",
|
15 |
+
"under the sea",
|
16 |
+
"in a castle made of clouds",
|
17 |
+
"inside a giant's boot",
|
18 |
+
"at a wizard's school",
|
19 |
+
"on a floating island",
|
20 |
+
],
|
21 |
+
items: [
|
22 |
+
"discovers a map to hidden treasure",
|
23 |
+
"finds a pair of flying shoes",
|
24 |
+
"stumbles upon an invisible cloak",
|
25 |
+
"befriends a friendly ghost",
|
26 |
+
"uncovers a secret door",
|
27 |
+
"meets a wise old man",
|
28 |
+
],
|
29 |
+
themes: ["Fairy Tale", "Silly", "Adventurous", "Magical", "Bedtime", "Funny"],
|
30 |
+
length: ["Short", "Medium", "Long"],
|
31 |
+
};
|
src/hooks/useAudioPlayer.ts
ADDED
@@ -0,0 +1,86 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
import { useState, useRef, useCallback } from "react";
|
2 |
+
|
3 |
+
type AudioKeys = "pop" | "hover" | "music";
|
4 |
+
interface AudioPlayer {
|
5 |
+
initAudio: () => boolean;
|
6 |
+
playPopSound: () => void;
|
7 |
+
playHoverSound: () => void;
|
8 |
+
toggleMusic: () => void;
|
9 |
+
playMusic: () => void;
|
10 |
+
isMusicPlaying: boolean;
|
11 |
+
isAudioReady: boolean;
|
12 |
+
}
|
13 |
+
|
14 |
+
const useAudioPlayer = (): AudioPlayer => {
|
15 |
+
const audioRefs = useRef<Record<AudioKeys, HTMLAudioElement | null>>({
|
16 |
+
pop: null,
|
17 |
+
hover: null,
|
18 |
+
music: null,
|
19 |
+
});
|
20 |
+
const [isReady, setIsReady] = useState<boolean>(false);
|
21 |
+
const [isMusicPlaying, setIsMusicPlaying] = useState<boolean>(false);
|
22 |
+
|
23 |
+
const initAudio = useCallback(() => {
|
24 |
+
if (isReady) return true;
|
25 |
+
try {
|
26 |
+
audioRefs.current.pop = new Audio("/bubble.mp3");
|
27 |
+
audioRefs.current.hover = new Audio("/hover.mp3");
|
28 |
+
audioRefs.current.music = new Audio("/music.mp3");
|
29 |
+
audioRefs.current.music.loop = true;
|
30 |
+
audioRefs.current.music.volume = 0.1;
|
31 |
+
setIsReady(true);
|
32 |
+
return true;
|
33 |
+
} catch {
|
34 |
+
return false;
|
35 |
+
}
|
36 |
+
}, [isReady]);
|
37 |
+
|
38 |
+
const playMusic = useCallback(() => {
|
39 |
+
if (isReady && !isMusicPlaying) {
|
40 |
+
audioRefs.current.music?.play().catch(() => {});
|
41 |
+
setIsMusicPlaying(true);
|
42 |
+
}
|
43 |
+
}, [isReady, isMusicPlaying]);
|
44 |
+
|
45 |
+
const toggleMusic = useCallback(
|
46 |
+
(force?: boolean) => {
|
47 |
+
if (!isReady || !audioRefs.current.music) return;
|
48 |
+
|
49 |
+
const shouldBePlaying = force === undefined ? !isMusicPlaying : force;
|
50 |
+
|
51 |
+
if (shouldBePlaying === isMusicPlaying) return;
|
52 |
+
|
53 |
+
if (shouldBePlaying) {
|
54 |
+
audioRefs.current.music?.play().catch(() => {});
|
55 |
+
} else {
|
56 |
+
audioRefs.current.music?.pause();
|
57 |
+
}
|
58 |
+
setIsMusicPlaying(shouldBePlaying);
|
59 |
+
},
|
60 |
+
[isReady, isMusicPlaying],
|
61 |
+
);
|
62 |
+
|
63 |
+
const playPopSound = useCallback(() => {
|
64 |
+
if (!isReady || !audioRefs.current.pop) return;
|
65 |
+
audioRefs.current.pop.currentTime = 0;
|
66 |
+
audioRefs.current.pop.play().catch(() => {});
|
67 |
+
}, [isReady]);
|
68 |
+
|
69 |
+
const playHoverSound = useCallback(() => {
|
70 |
+
if (!isReady || !audioRefs.current.hover) return;
|
71 |
+
audioRefs.current.hover.currentTime = 0;
|
72 |
+
audioRefs.current.hover.play().catch(() => {});
|
73 |
+
}, [isReady]);
|
74 |
+
|
75 |
+
return {
|
76 |
+
initAudio,
|
77 |
+
playPopSound,
|
78 |
+
playHoverSound,
|
79 |
+
toggleMusic,
|
80 |
+
playMusic,
|
81 |
+
isMusicPlaying,
|
82 |
+
isAudioReady: isReady,
|
83 |
+
};
|
84 |
+
};
|
85 |
+
|
86 |
+
export default useAudioPlayer;
|
src/hooks/useLLM.ts
ADDED
@@ -0,0 +1,86 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
import { useState, useCallback } from "react";
|
2 |
+
import { pipeline, TextStreamer } from "@huggingface/transformers";
|
3 |
+
import type { TextSplitterStream } from "kokoro-js";
|
4 |
+
|
5 |
+
interface LLMState {
|
6 |
+
isLoading: boolean;
|
7 |
+
isReady: boolean;
|
8 |
+
error: string | null;
|
9 |
+
progress: number;
|
10 |
+
}
|
11 |
+
|
12 |
+
type LLMGlobal = { generator: any | null };
|
13 |
+
const g = globalThis as any;
|
14 |
+
let __LLM: LLMGlobal = g.__LLM || { generator: null };
|
15 |
+
g.__LLM = __LLM;
|
16 |
+
|
17 |
+
export type generateFn = (
|
18 |
+
messages: Array<{ role: string; content: string }>,
|
19 |
+
onToken?: (token: string) => void,
|
20 |
+
splitter?: TextSplitterStream,
|
21 |
+
) => Promise<void>;
|
22 |
+
|
23 |
+
export const useLLM = () => {
|
24 |
+
const [state, setState] = useState<LLMState>({
|
25 |
+
isLoading: false,
|
26 |
+
isReady: !!__LLM.generator,
|
27 |
+
error: null,
|
28 |
+
progress: __LLM.generator ? 100 : 0,
|
29 |
+
});
|
30 |
+
|
31 |
+
const load = async () => {
|
32 |
+
if (__LLM.generator) return __LLM.generator;
|
33 |
+
setState((p) => ({ ...p, isLoading: true, error: null, progress: 0 }));
|
34 |
+
try {
|
35 |
+
const generator = await pipeline("text-generation", "onnx-community/gemma-3-270m-it-ONNX", {
|
36 |
+
dtype: "fp32",
|
37 |
+
device: "webgpu",
|
38 |
+
progress_callback: (item) => {
|
39 |
+
if (item.status === "progress" && item.file?.endsWith?.("onnx_data")) {
|
40 |
+
setState((p) => ({ ...p, progress: item.progress || 0 }));
|
41 |
+
}
|
42 |
+
},
|
43 |
+
});
|
44 |
+
__LLM.generator = generator;
|
45 |
+
setState((p) => ({
|
46 |
+
...p,
|
47 |
+
isLoading: false,
|
48 |
+
isReady: true,
|
49 |
+
progress: 100,
|
50 |
+
}));
|
51 |
+
return generator;
|
52 |
+
} catch (error) {
|
53 |
+
setState((p) => ({
|
54 |
+
...p,
|
55 |
+
isLoading: false,
|
56 |
+
error: error instanceof Error ? error.message : "Failed to load model",
|
57 |
+
}));
|
58 |
+
throw error;
|
59 |
+
}
|
60 |
+
};
|
61 |
+
|
62 |
+
const generate: generateFn = useCallback(async (messages, onToken, splitter) => {
|
63 |
+
const generator = __LLM.generator;
|
64 |
+
if (!generator) throw new Error("Model not loaded. Call load() first.");
|
65 |
+
const streamer = new TextStreamer(generator.tokenizer, {
|
66 |
+
skip_prompt: true,
|
67 |
+
skip_special_tokens: true,
|
68 |
+
callback_function: (token: string) => {
|
69 |
+
onToken?.(token);
|
70 |
+
splitter?.push(token);
|
71 |
+
},
|
72 |
+
});
|
73 |
+
await generator(messages, {
|
74 |
+
max_new_tokens: 1024,
|
75 |
+
do_sample: false,
|
76 |
+
streamer,
|
77 |
+
});
|
78 |
+
splitter?.close();
|
79 |
+
}, []);
|
80 |
+
|
81 |
+
return {
|
82 |
+
...state,
|
83 |
+
load,
|
84 |
+
generate,
|
85 |
+
};
|
86 |
+
};
|
src/hooks/useTTS.ts
ADDED
@@ -0,0 +1,75 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
import { useState, useCallback } from "react";
|
2 |
+
import { KokoroTTS, TextSplitterStream } from "kokoro-js";
|
3 |
+
|
4 |
+
interface TTSState {
|
5 |
+
isLoading: boolean;
|
6 |
+
isReady: boolean;
|
7 |
+
error: string | null;
|
8 |
+
progress: number;
|
9 |
+
}
|
10 |
+
|
11 |
+
type TTSGlobal = { model: KokoroTTS | null };
|
12 |
+
const g = globalThis as any;
|
13 |
+
let __TTS: TTSGlobal = g.__TTS || { model: null };
|
14 |
+
g.__TTS = __TTS;
|
15 |
+
|
16 |
+
export const useTTS = () => {
|
17 |
+
const [state, setState] = useState<TTSState>({
|
18 |
+
isLoading: false,
|
19 |
+
isReady: !!__TTS.model,
|
20 |
+
error: null,
|
21 |
+
progress: __TTS.model ? 100 : 0,
|
22 |
+
});
|
23 |
+
|
24 |
+
const load = async () => {
|
25 |
+
if (__TTS.model) return __TTS.model;
|
26 |
+
setState((p) => ({ ...p, isLoading: true, error: null, progress: 0 }));
|
27 |
+
try {
|
28 |
+
const tts = await KokoroTTS.from_pretrained("onnx-community/Kokoro-82M-v1.0-ONNX", {
|
29 |
+
dtype: "fp32",
|
30 |
+
device: "webgpu",
|
31 |
+
progress_callback: (item) => {
|
32 |
+
if (item.status === "progress" && item.file?.endsWith?.("onnx")) {
|
33 |
+
setState((p) => ({ ...p, progress: item.progress || 0 }));
|
34 |
+
}
|
35 |
+
},
|
36 |
+
});
|
37 |
+
__TTS.model = tts;
|
38 |
+
setState((p) => ({
|
39 |
+
...p,
|
40 |
+
isLoading: false,
|
41 |
+
isReady: true,
|
42 |
+
progress: 100,
|
43 |
+
}));
|
44 |
+
return tts;
|
45 |
+
} catch (error) {
|
46 |
+
setState((p) => ({
|
47 |
+
...p,
|
48 |
+
isLoading: false,
|
49 |
+
error: error instanceof Error ? error.message : "Failed to load TTS model",
|
50 |
+
}));
|
51 |
+
throw error;
|
52 |
+
}
|
53 |
+
};
|
54 |
+
|
55 |
+
const stream = useCallback((onAudioChunk: (chunk: { audio: Float32Array; text?: string }) => void) => {
|
56 |
+
const tts = __TTS.model as KokoroTTS | null;
|
57 |
+
if (!tts) throw new Error("TTS model not loaded. Call load() first.");
|
58 |
+
const splitter = new TextSplitterStream();
|
59 |
+
const ttsStream = tts.stream(splitter);
|
60 |
+
const ttsPromise = (async () => {
|
61 |
+
for await (const chunk of ttsStream) {
|
62 |
+
if (chunk.audio) {
|
63 |
+
onAudioChunk({ audio: chunk.audio.audio, text: chunk.text });
|
64 |
+
}
|
65 |
+
}
|
66 |
+
})();
|
67 |
+
return { splitter, ttsPromise };
|
68 |
+
}, []);
|
69 |
+
|
70 |
+
return {
|
71 |
+
...state,
|
72 |
+
load,
|
73 |
+
stream,
|
74 |
+
};
|
75 |
+
};
|
src/index.css
ADDED
@@ -0,0 +1,88 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
@import url("https://fonts.googleapis.com/css2?family=Nunito:wght@400;700;900&display=swap");
|
2 |
+
@import "tailwindcss";
|
3 |
+
|
4 |
+
body {
|
5 |
+
font-family: "Nunito", sans-serif;
|
6 |
+
}
|
7 |
+
.bg-pattern {
|
8 |
+
background-color: #fef3c7;
|
9 |
+
background-image: url("data:image/svg+xml,%3Csvg width='60' height='60' viewBox='0 0 60 60' xmlns='http://www.w3.org/2000/svg'%3E%3Cg fill='none' fill-rule='evenodd'%3E%3Cg fill='%23fbbf24' fill-opacity='0.2'%3E%3Cpath d='M36 34v-4h-2v4h-4v2h4v4h2v-4h4v-2h-4zm0-30V0h-2v4h-4v2h4v4h2V6h4V4h-4zM6 34v-4H4v4H0v2h4v4h2v-4h4v-2H6zM6 4V0H4v4H0v2h4v4h2V6h4V4H6z'/%3E%3C/g%3E%3C/g%3E%3C/svg%3E");
|
10 |
+
}
|
11 |
+
@keyframes shine {
|
12 |
+
0% {
|
13 |
+
transform: translateX(-100%) skewX(-20deg);
|
14 |
+
}
|
15 |
+
100% {
|
16 |
+
transform: translateX(200%) skewX(-20deg);
|
17 |
+
}
|
18 |
+
}
|
19 |
+
.animate-shine {
|
20 |
+
background: linear-gradient(
|
21 |
+
90deg,
|
22 |
+
rgba(255, 255, 255, 0) 0%,
|
23 |
+
rgba(255, 255, 255, 0.4) 50%,
|
24 |
+
rgba(255, 255, 255, 0) 100%
|
25 |
+
);
|
26 |
+
animation: shine 4s infinite;
|
27 |
+
}
|
28 |
+
@keyframes shake {
|
29 |
+
0%,
|
30 |
+
100% {
|
31 |
+
transform: translateX(0);
|
32 |
+
}
|
33 |
+
10%,
|
34 |
+
30%,
|
35 |
+
50%,
|
36 |
+
70%,
|
37 |
+
90% {
|
38 |
+
transform: translateX(-3px);
|
39 |
+
}
|
40 |
+
20%,
|
41 |
+
40%,
|
42 |
+
60%,
|
43 |
+
80% {
|
44 |
+
transform: translateX(3px);
|
45 |
+
}
|
46 |
+
}
|
47 |
+
.animate-shake {
|
48 |
+
animation: shake 0.3s linear infinite;
|
49 |
+
}
|
50 |
+
@keyframes slide-in {
|
51 |
+
from {
|
52 |
+
transform: translateY(20px);
|
53 |
+
opacity: 0;
|
54 |
+
}
|
55 |
+
to {
|
56 |
+
transform: translateY(0);
|
57 |
+
opacity: 1;
|
58 |
+
}
|
59 |
+
}
|
60 |
+
.animate-slide-in {
|
61 |
+
animation: slide-in 0.5s ease-out forwards;
|
62 |
+
}
|
63 |
+
@keyframes pulse-grow {
|
64 |
+
0%,
|
65 |
+
100% {
|
66 |
+
transform: scale(1);
|
67 |
+
}
|
68 |
+
50% {
|
69 |
+
transform: scale(1.05);
|
70 |
+
}
|
71 |
+
}
|
72 |
+
.animate-pulse-grow {
|
73 |
+
animation: pulse-grow 3s infinite;
|
74 |
+
}
|
75 |
+
@keyframes sway {
|
76 |
+
0% {
|
77 |
+
transform: rotate(-1deg);
|
78 |
+
}
|
79 |
+
50% {
|
80 |
+
transform: rotate(1deg);
|
81 |
+
}
|
82 |
+
100% {
|
83 |
+
transform: rotate(-1deg);
|
84 |
+
}
|
85 |
+
}
|
86 |
+
.animate-sway {
|
87 |
+
animation: sway 8s ease-in-out infinite;
|
88 |
+
}
|
src/main.tsx
ADDED
@@ -0,0 +1,10 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
import { StrictMode } from "react";
|
2 |
+
import { createRoot } from "react-dom/client";
|
3 |
+
import "./index.css";
|
4 |
+
import App from "./App.tsx";
|
5 |
+
|
6 |
+
createRoot(document.getElementById("root")!).render(
|
7 |
+
<StrictMode>
|
8 |
+
<App />
|
9 |
+
</StrictMode>,
|
10 |
+
);
|
src/play-worklet.js
ADDED
@@ -0,0 +1,63 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
class BufferedAudioWorkletProcessor extends AudioWorkletProcessor {
|
2 |
+
constructor() {
|
3 |
+
super();
|
4 |
+
this.bufferQueue = [];
|
5 |
+
this.currentChunkOffset = 0;
|
6 |
+
this.hadData = false;
|
7 |
+
|
8 |
+
this.port.onmessage = (event) => {
|
9 |
+
const data = event.data;
|
10 |
+
if (data instanceof Float32Array) {
|
11 |
+
this.hadData = true;
|
12 |
+
this.bufferQueue.push(data);
|
13 |
+
} else if (data === "stop") {
|
14 |
+
this.bufferQueue = [];
|
15 |
+
this.currentChunkOffset = 0;
|
16 |
+
}
|
17 |
+
};
|
18 |
+
}
|
19 |
+
|
20 |
+
process(inputs, outputs) {
|
21 |
+
const channel = outputs[0][0];
|
22 |
+
if (!channel) return true;
|
23 |
+
|
24 |
+
const numSamples = channel.length;
|
25 |
+
let outputIndex = 0;
|
26 |
+
|
27 |
+
if (this.hadData && this.bufferQueue.length === 0) {
|
28 |
+
this.port.postMessage({ type: "playback_ended" });
|
29 |
+
this.hadData = false;
|
30 |
+
}
|
31 |
+
|
32 |
+
while (outputIndex < numSamples) {
|
33 |
+
if (this.bufferQueue.length > 0) {
|
34 |
+
const currentChunk = this.bufferQueue[0];
|
35 |
+
const remainingSamples = currentChunk.length - this.currentChunkOffset;
|
36 |
+
const samplesToCopy = Math.min(remainingSamples, numSamples - outputIndex);
|
37 |
+
|
38 |
+
channel.set(
|
39 |
+
currentChunk.subarray(this.currentChunkOffset, this.currentChunkOffset + samplesToCopy),
|
40 |
+
outputIndex,
|
41 |
+
);
|
42 |
+
|
43 |
+
this.currentChunkOffset += samplesToCopy;
|
44 |
+
outputIndex += samplesToCopy;
|
45 |
+
|
46 |
+
// Remove the chunk if fully consumed.
|
47 |
+
if (this.currentChunkOffset >= currentChunk.length) {
|
48 |
+
// current chunk finished; advance and signal UI to move highlight
|
49 |
+
this.bufferQueue.shift();
|
50 |
+
this.currentChunkOffset = 0;
|
51 |
+
this.port.postMessage({ type: "next_chunk" });
|
52 |
+
}
|
53 |
+
} else {
|
54 |
+
// If no data is available, fill the rest of the buffer with silence.
|
55 |
+
channel.fill(0, outputIndex);
|
56 |
+
outputIndex = numSamples;
|
57 |
+
}
|
58 |
+
}
|
59 |
+
return true;
|
60 |
+
}
|
61 |
+
}
|
62 |
+
|
63 |
+
registerProcessor("buffered-audio-worklet-processor", BufferedAudioWorkletProcessor);
|
src/vite-env.d.ts
ADDED
@@ -0,0 +1 @@
|
|
|
|
|
1 |
+
/// <reference types="vite/client" />
|
tsconfig.app.json
ADDED
@@ -0,0 +1,29 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
{
|
2 |
+
"compilerOptions": {
|
3 |
+
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
|
4 |
+
"target": "ES2022",
|
5 |
+
"useDefineForClassFields": true,
|
6 |
+
"lib": ["ES2022", "DOM", "DOM.Iterable"],
|
7 |
+
"module": "ESNext",
|
8 |
+
"skipLibCheck": true,
|
9 |
+
|
10 |
+
/* Bundler mode */
|
11 |
+
"moduleResolution": "bundler",
|
12 |
+
"allowImportingTsExtensions": true,
|
13 |
+
"verbatimModuleSyntax": true,
|
14 |
+
"moduleDetection": "force",
|
15 |
+
"noEmit": true,
|
16 |
+
"jsx": "react-jsx",
|
17 |
+
|
18 |
+
/* Linting */
|
19 |
+
"strict": true,
|
20 |
+
"noUnusedLocals": true,
|
21 |
+
"noUnusedParameters": true,
|
22 |
+
"erasableSyntaxOnly": true,
|
23 |
+
"noFallthroughCasesInSwitch": true,
|
24 |
+
"noUncheckedSideEffectImports": true,
|
25 |
+
|
26 |
+
"types": ["@webgpu/types"]
|
27 |
+
},
|
28 |
+
"include": ["src"]
|
29 |
+
}
|
tsconfig.json
ADDED
@@ -0,0 +1,4 @@
|
|
|
|
|
|
|
|
|
|
|
1 |
+
{
|
2 |
+
"files": [],
|
3 |
+
"references": [{ "path": "./tsconfig.app.json" }, { "path": "./tsconfig.node.json" }]
|
4 |
+
}
|
tsconfig.node.json
ADDED
@@ -0,0 +1,25 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
{
|
2 |
+
"compilerOptions": {
|
3 |
+
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",
|
4 |
+
"target": "ES2023",
|
5 |
+
"lib": ["ES2023"],
|
6 |
+
"module": "ESNext",
|
7 |
+
"skipLibCheck": true,
|
8 |
+
|
9 |
+
/* Bundler mode */
|
10 |
+
"moduleResolution": "bundler",
|
11 |
+
"allowImportingTsExtensions": true,
|
12 |
+
"verbatimModuleSyntax": true,
|
13 |
+
"moduleDetection": "force",
|
14 |
+
"noEmit": true,
|
15 |
+
|
16 |
+
/* Linting */
|
17 |
+
"strict": true,
|
18 |
+
"noUnusedLocals": true,
|
19 |
+
"noUnusedParameters": true,
|
20 |
+
"erasableSyntaxOnly": true,
|
21 |
+
"noFallthroughCasesInSwitch": true,
|
22 |
+
"noUncheckedSideEffectImports": true
|
23 |
+
},
|
24 |
+
"include": ["vite.config.ts"]
|
25 |
+
}
|
vite.config.ts
ADDED
@@ -0,0 +1,14 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
import { defineConfig } from "vite";
|
2 |
+
import react from "@vitejs/plugin-react";
|
3 |
+
import tailwindcss from "@tailwindcss/vite";
|
4 |
+
|
5 |
+
// https://vite.dev/config/
|
6 |
+
export default defineConfig({
|
7 |
+
plugins: [react(), tailwindcss()],
|
8 |
+
|
9 |
+
resolve: {
|
10 |
+
// Only bundle a single instance of Transformers.js
|
11 |
+
// (shared by `@huggingface/transformers` and `kokoro-js`)
|
12 |
+
dedupe: ["@huggingface/transformers"],
|
13 |
+
},
|
14 |
+
});
|