Xenova HF Staff commited on
Commit
59c3ada
·
verified ·
1 Parent(s): 47e7114

Upload application

Browse files
.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
- <head>
4
- <meta charset="utf-8" />
5
- <meta name="viewport" content="width=device-width" />
6
- <title>My static Space</title>
7
- <link rel="stylesheet" href="style.css" />
8
- </head>
9
- <body>
10
- <div class="card">
11
- <h1>Welcome to your static Space!</h1>
12
- <p>You can modify this app directly by editing <i>index.html</i> in the Files and versions tab.</p>
13
- <p>
14
- Also don't forget to check the
15
- <a href="https://huggingface.co/docs/hub/spaces" target="_blank">Spaces documentation</a>.
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
+ });