Spaces:
Running
Running
Commit
β’
c1e4aec
1
Parent(s):
fecef05
work in progress
Browse files- README.md +2 -2
- package-lock.json +28 -0
- package.json +1 -0
- src/app/interface/generate/index.tsx +79 -52
- src/app/layout.tsx +2 -2
- src/app/server/actions/{generateStory.ts β generateStoryLines.ts} +10 -14
- src/lib/useAudio.ts +64 -38
- src/types.ts +5 -1
README.md
CHANGED
@@ -1,5 +1,5 @@
|
|
1 |
---
|
2 |
-
title: AI Bedtime Story
|
3 |
emoji: π
|
4 |
colorFrom: yellow
|
5 |
colorTo: gray
|
@@ -8,6 +8,6 @@ pinned: true
|
|
8 |
app_port: 3000
|
9 |
---
|
10 |
|
11 |
-
# AI Bedtime Story
|
12 |
|
13 |
(To be continued)
|
|
|
1 |
---
|
2 |
+
title: AI Bedtime Story ποΈ
|
3 |
emoji: π
|
4 |
colorFrom: yellow
|
5 |
colorTo: gray
|
|
|
8 |
app_port: 3000
|
9 |
---
|
10 |
|
11 |
+
# π AI Bedtime Story ποΈ
|
12 |
|
13 |
(To be continued)
|
package-lock.json
CHANGED
@@ -50,6 +50,7 @@
|
|
50 |
"react-virtualized-auto-sizer": "^1.0.20",
|
51 |
"replicate": "^0.17.0",
|
52 |
"sbd": "^1.0.19",
|
|
|
53 |
"sharp": "^0.32.5",
|
54 |
"styled-components": "^6.0.7",
|
55 |
"tailwind-merge": "^1.13.2",
|
@@ -1691,6 +1692,11 @@
|
|
1691 |
"tslib": "^2.4.0"
|
1692 |
}
|
1693 |
},
|
|
|
|
|
|
|
|
|
|
|
1694 |
"node_modules/@tsconfig/node10": {
|
1695 |
"version": "1.0.9",
|
1696 |
"resolved": "https://registry.npmjs.org/@tsconfig/node10/-/node10-1.0.9.tgz",
|
@@ -2248,6 +2254,11 @@
|
|
2248 |
"readable-stream": "^3.4.0"
|
2249 |
}
|
2250 |
},
|
|
|
|
|
|
|
|
|
|
|
2251 |
"node_modules/brace-expansion": {
|
2252 |
"version": "1.1.11",
|
2253 |
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.11.tgz",
|
@@ -5815,6 +5826,15 @@
|
|
5815 |
"node": ">=10"
|
5816 |
}
|
5817 |
},
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
5818 |
"node_modules/set-function-length": {
|
5819 |
"version": "1.1.1",
|
5820 |
"resolved": "https://registry.npmjs.org/set-function-length/-/set-function-length-1.1.1.tgz",
|
@@ -6084,6 +6104,14 @@
|
|
6084 |
"url": "https://github.com/sponsors/sindresorhus"
|
6085 |
}
|
6086 |
},
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
6087 |
"node_modules/styled-components": {
|
6088 |
"version": "6.1.1",
|
6089 |
"resolved": "https://registry.npmjs.org/styled-components/-/styled-components-6.1.1.tgz",
|
|
|
50 |
"react-virtualized-auto-sizer": "^1.0.20",
|
51 |
"replicate": "^0.17.0",
|
52 |
"sbd": "^1.0.19",
|
53 |
+
"sentence-splitter": "^4.3.0",
|
54 |
"sharp": "^0.32.5",
|
55 |
"styled-components": "^6.0.7",
|
56 |
"tailwind-merge": "^1.13.2",
|
|
|
1692 |
"tslib": "^2.4.0"
|
1693 |
}
|
1694 |
},
|
1695 |
+
"node_modules/@textlint/ast-node-types": {
|
1696 |
+
"version": "13.4.0",
|
1697 |
+
"resolved": "https://registry.npmjs.org/@textlint/ast-node-types/-/ast-node-types-13.4.0.tgz",
|
1698 |
+
"integrity": "sha512-roVeLjnf8UPntFICb1uEwE2dccC8V/T5N1x7eBxkT3VDmSQkyfIAuGtlpwyH0wNKEwJmjO/2gSm2fCjW5K/rbA=="
|
1699 |
+
},
|
1700 |
"node_modules/@tsconfig/node10": {
|
1701 |
"version": "1.0.9",
|
1702 |
"resolved": "https://registry.npmjs.org/@tsconfig/node10/-/node10-1.0.9.tgz",
|
|
|
2254 |
"readable-stream": "^3.4.0"
|
2255 |
}
|
2256 |
},
|
2257 |
+
"node_modules/boundary": {
|
2258 |
+
"version": "2.0.0",
|
2259 |
+
"resolved": "https://registry.npmjs.org/boundary/-/boundary-2.0.0.tgz",
|
2260 |
+
"integrity": "sha512-rJKn5ooC9u8q13IMCrW0RSp31pxBCHE3y9V/tp3TdWSLf8Em3p6Di4NBpfzbJge9YjjFEsD0RtFEjtvHL5VyEA=="
|
2261 |
+
},
|
2262 |
"node_modules/brace-expansion": {
|
2263 |
"version": "1.1.11",
|
2264 |
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.11.tgz",
|
|
|
5826 |
"node": ">=10"
|
5827 |
}
|
5828 |
},
|
5829 |
+
"node_modules/sentence-splitter": {
|
5830 |
+
"version": "4.3.0",
|
5831 |
+
"resolved": "https://registry.npmjs.org/sentence-splitter/-/sentence-splitter-4.3.0.tgz",
|
5832 |
+
"integrity": "sha512-srJOMqv7JeEmsbVa/N64ULey2N6/OuZzeKWn2Zrj0DiTBlU930JGr/rKKlKQRigzXtLMOtl32/Gm5G3HW8/ULA==",
|
5833 |
+
"dependencies": {
|
5834 |
+
"@textlint/ast-node-types": "^13.2.0",
|
5835 |
+
"structured-source": "^4.0.0"
|
5836 |
+
}
|
5837 |
+
},
|
5838 |
"node_modules/set-function-length": {
|
5839 |
"version": "1.1.1",
|
5840 |
"resolved": "https://registry.npmjs.org/set-function-length/-/set-function-length-1.1.1.tgz",
|
|
|
6104 |
"url": "https://github.com/sponsors/sindresorhus"
|
6105 |
}
|
6106 |
},
|
6107 |
+
"node_modules/structured-source": {
|
6108 |
+
"version": "4.0.0",
|
6109 |
+
"resolved": "https://registry.npmjs.org/structured-source/-/structured-source-4.0.0.tgz",
|
6110 |
+
"integrity": "sha512-qGzRFNJDjFieQkl/sVOI2dUjHKRyL9dAJi2gCPGJLbJHBIkyOHxjuocpIEfbLioX+qSJpvbYdT49/YCdMznKxA==",
|
6111 |
+
"dependencies": {
|
6112 |
+
"boundary": "^2.0.0"
|
6113 |
+
}
|
6114 |
+
},
|
6115 |
"node_modules/styled-components": {
|
6116 |
"version": "6.1.1",
|
6117 |
"resolved": "https://registry.npmjs.org/styled-components/-/styled-components-6.1.1.tgz",
|
package.json
CHANGED
@@ -51,6 +51,7 @@
|
|
51 |
"react-virtualized-auto-sizer": "^1.0.20",
|
52 |
"replicate": "^0.17.0",
|
53 |
"sbd": "^1.0.19",
|
|
|
54 |
"sharp": "^0.32.5",
|
55 |
"styled-components": "^6.0.7",
|
56 |
"tailwind-merge": "^1.13.2",
|
|
|
51 |
"react-virtualized-auto-sizer": "^1.0.20",
|
52 |
"replicate": "^0.17.0",
|
53 |
"sbd": "^1.0.19",
|
54 |
+
"sentence-splitter": "^4.3.0",
|
55 |
"sharp": "^0.32.5",
|
56 |
"styled-components": "^6.0.7",
|
57 |
"tailwind-merge": "^1.13.2",
|
src/app/interface/generate/index.tsx
CHANGED
@@ -3,21 +3,19 @@
|
|
3 |
import { useEffect, useRef, useState, useTransition } from "react"
|
4 |
import { useSpring, animated } from "@react-spring/web"
|
5 |
import { usePathname, useRouter, useSearchParams } from "next/navigation"
|
|
|
6 |
|
7 |
import { useToast } from "@/components/ui/use-toast"
|
8 |
import { cn } from "@/lib/utils"
|
9 |
import { headingFont } from "@/app/interface/fonts"
|
10 |
import { useCharacterLimit } from "@/lib/useCharacterLimit"
|
11 |
-
import {
|
12 |
-
import {
|
13 |
-
import { HotshotImageInferenceSize, Post, SDXLModel, Story, TTSVoice } from "@/types"
|
14 |
-
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip"
|
15 |
import { TooltipProvider } from "@radix-ui/react-tooltip"
|
16 |
-
|
17 |
import { useCountdown } from "@/lib/useCountdown"
|
|
|
18 |
|
19 |
import { Countdown } from "../countdown"
|
20 |
-
import { useAudio } from "@/lib/useAudio"
|
21 |
|
22 |
type Stage = "generate" | "finished"
|
23 |
|
@@ -38,32 +36,54 @@ export function Generate() {
|
|
38 |
const [runs, setRuns] = useState(0)
|
39 |
const runsRef = useRef(0)
|
40 |
|
41 |
-
const
|
42 |
-
const
|
43 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
44 |
|
45 |
const [stage, setStage] = useState<Stage>("generate")
|
46 |
|
47 |
const { toast } = useToast()
|
48 |
|
|
|
|
|
|
|
|
|
|
|
|
|
49 |
const [typedStoryText, setTypedStoryText] = useState("")
|
50 |
const [typedStoryCharacterIndex, setTypedStoryCharacterIndex] = useState(0)
|
51 |
|
52 |
-
const audio = useAudio()
|
53 |
-
|
54 |
useEffect(() => {
|
55 |
if (storyText && typedStoryCharacterIndex < storyText.length) {
|
56 |
setTimeout(() => {
|
57 |
setTypedStoryText(typedStoryText + story.text[typedStoryCharacterIndex])
|
58 |
setTypedStoryCharacterIndex(typedStoryCharacterIndex + 1)
|
|
|
59 |
}, 40)
|
60 |
}
|
61 |
}, [storyText, typedStoryCharacterIndex])
|
|
|
62 |
|
63 |
const { progressPercent, remainingTimeInSec } = useCountdown({
|
64 |
isActive: isLocked,
|
65 |
timerId: runs, // everytime we change this, the timer will reset
|
66 |
-
durationInSec: /*stage === "interpolate" ? 30 :*/
|
67 |
onEnd: () => {}
|
68 |
})
|
69 |
|
@@ -108,28 +128,17 @@ export function Generate() {
|
|
108 |
const search = current.toString()
|
109 |
router.push(`${pathname}${search ? `?${search}` : ""}`)
|
110 |
|
111 |
-
let story: Story = {
|
112 |
-
text: "",
|
113 |
-
audio: ""
|
114 |
-
}
|
115 |
-
|
116 |
const voice: TTSVoice = "CloΓ©e"
|
117 |
|
118 |
setRuns(runsRef.current + 1)
|
119 |
|
120 |
try {
|
121 |
// console.log("starting transition, calling generateAnimation")
|
122 |
-
|
123 |
-
|
124 |
-
console.log("generated story:", story)
|
125 |
-
|
126 |
-
if (!story) {
|
127 |
-
throw new Error("invalid story")
|
128 |
-
}
|
129 |
|
130 |
-
(
|
131 |
|
132 |
-
|
133 |
|
134 |
} catch (err) {
|
135 |
|
@@ -171,21 +180,28 @@ export function Generate() {
|
|
171 |
|
172 |
|
173 |
useEffect(() => {
|
174 |
-
|
175 |
-
|
176 |
-
|
177 |
-
|
|
|
178 |
|
179 |
-
|
180 |
-
|
181 |
-
|
182 |
-
|
|
|
|
|
|
|
|
|
|
|
183 |
}
|
|
|
184 |
|
185 |
return () => {
|
186 |
audio() // stop
|
187 |
}
|
188 |
-
}, [
|
189 |
|
190 |
return (
|
191 |
<div
|
@@ -212,9 +228,10 @@ export function Generate() {
|
|
212 |
<div
|
213 |
className={cn(
|
214 |
`flex flex-col`,
|
215 |
-
`flex-grow
|
216 |
-
`
|
217 |
-
`
|
|
|
218 |
`items-center`,
|
219 |
`space-y-6 md:space-y-8 lg:space-y-12 xl:space-y-14`,
|
220 |
`px-3 py-6 md:px-6 md:py-12 xl:px-8 xl:py-14`,
|
@@ -252,14 +269,14 @@ export function Generate() {
|
|
252 |
`w-full`,
|
253 |
`input input-bordered rounded-full`,
|
254 |
`transition-all duration-300 ease-in-out`,
|
|
|
255 |
`placeholder:text-gray-400`,
|
256 |
`disabled:bg-gray-500 disabled:text-yellow-300 disabled:border-transparent`,
|
257 |
isLocked
|
258 |
-
? `bg-
|
259 |
-
: `bg-white/10 text-yellow-400 selection:bg-yellow-200`,
|
260 |
`text-left`,
|
261 |
-
`text-
|
262 |
-
`selection:bg-yellow-200 selection:text-yellow-200`
|
263 |
)}
|
264 |
value={promptDraft}
|
265 |
onChange={e => setPromptDraft(e.target.value)}
|
@@ -276,7 +293,7 @@ export function Generate() {
|
|
276 |
`flex flew-row ml-[-64px] items-center`,
|
277 |
`transition-all duration-300 ease-in-out`,
|
278 |
`text-base`,
|
279 |
-
`bg-yellow-200`,
|
280 |
`rounded-full`,
|
281 |
`text-right`,
|
282 |
`p-1`,
|
@@ -289,7 +306,7 @@ export function Generate() {
|
|
289 |
<span>{nbCharsLimits}</span>
|
290 |
</div>
|
291 |
</div>
|
292 |
-
<div className="flex flex-row w-
|
293 |
<animated.button
|
294 |
style={{
|
295 |
textShadow: "0px 0px 1px #000000ab",
|
@@ -298,15 +315,16 @@ export function Generate() {
|
|
298 |
onMouseEnter={() => setOverSubmitButton(true)}
|
299 |
onMouseLeave={() => setOverSubmitButton(false)}
|
300 |
className={cn(
|
301 |
-
`px-
|
302 |
`rounded-full`,
|
303 |
`transition-all duration-300 ease-in-out`,
|
|
|
304 |
isLocked
|
305 |
-
? `bg-orange-
|
306 |
-
: `bg-yellow-
|
307 |
`text-center`,
|
308 |
`w-full`,
|
309 |
-
`text-2xl
|
310 |
`border`,
|
311 |
headingFont.className,
|
312 |
// `transition-all duration-300`,
|
@@ -342,7 +360,9 @@ export function Generate() {
|
|
342 |
`items-center`,
|
343 |
`space-y-6 md:space-y-8 lg:space-y-12 xl:space-y-14`,
|
344 |
`px-3 py-6 md:px-6 md:py-12 xl:px-8 xl:py-14`,
|
345 |
-
|
|
|
|
|
346 |
)}>
|
347 |
{assetUrl ? <div
|
348 |
className={cn(
|
@@ -366,16 +386,23 @@ export function Generate() {
|
|
366 |
`items-center justify-between`
|
367 |
)}>
|
368 |
<div className={cn(
|
369 |
-
`flex flex-
|
370 |
)}>
|
371 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
372 |
</div>
|
373 |
</div>
|
374 |
</div>
|
375 |
|
376 |
</div>
|
377 |
|
378 |
-
|
379 |
</TooltipProvider>
|
380 |
</div>
|
381 |
)
|
|
|
3 |
import { useEffect, useRef, useState, useTransition } from "react"
|
4 |
import { useSpring, animated } from "@react-spring/web"
|
5 |
import { usePathname, useRouter, useSearchParams } from "next/navigation"
|
6 |
+
import { split } from "sentence-splitter"
|
7 |
|
8 |
import { useToast } from "@/components/ui/use-toast"
|
9 |
import { cn } from "@/lib/utils"
|
10 |
import { headingFont } from "@/app/interface/fonts"
|
11 |
import { useCharacterLimit } from "@/lib/useCharacterLimit"
|
12 |
+
import { generateStoryLines } from "@/app/server/actions/generateStoryLines"
|
13 |
+
import { Story, StoryLine, TTSVoice } from "@/types"
|
|
|
|
|
14 |
import { TooltipProvider } from "@radix-ui/react-tooltip"
|
|
|
15 |
import { useCountdown } from "@/lib/useCountdown"
|
16 |
+
import { useAudio } from "@/lib/useAudio"
|
17 |
|
18 |
import { Countdown } from "../countdown"
|
|
|
19 |
|
20 |
type Stage = "generate" | "finished"
|
21 |
|
|
|
36 |
const [runs, setRuns] = useState(0)
|
37 |
const runsRef = useRef(0)
|
38 |
|
39 |
+
const currentLineIndexRef = useRef(0)
|
40 |
+
const [currentLineIndex, setCurrentLineIndex] = useState(0)
|
41 |
+
|
42 |
+
useEffect(() => {
|
43 |
+
currentLineIndexRef.current = currentLineIndex
|
44 |
+
}, [currentLineIndex])
|
45 |
+
|
46 |
+
const [storyLines, setStoryLines] = useState<StoryLine[]>([])
|
47 |
+
|
48 |
+
// computing those is cheap
|
49 |
+
const wholeStory = storyLines.map(line => line.text).join("\n")
|
50 |
+
const currentLine = storyLines.at(currentLineIndex)
|
51 |
+
const currentLineText = currentLine?.text || ""
|
52 |
+
const currentLineAudio = currentLine?.audio || ""
|
53 |
+
|
54 |
+
// reset the whole player when story changes
|
55 |
+
useEffect(() => {
|
56 |
+
setCurrentLineIndex(0)
|
57 |
+
}, [wholeStory])
|
58 |
|
59 |
const [stage, setStage] = useState<Stage>("generate")
|
60 |
|
61 |
const { toast } = useToast()
|
62 |
|
63 |
+
const audio = useAudio()
|
64 |
+
|
65 |
+
/*
|
66 |
+
// to simulate a "typing" effect
|
67 |
+
however.. we don't need this as we already have an audio player!
|
68 |
+
|
69 |
const [typedStoryText, setTypedStoryText] = useState("")
|
70 |
const [typedStoryCharacterIndex, setTypedStoryCharacterIndex] = useState(0)
|
71 |
|
|
|
|
|
72 |
useEffect(() => {
|
73 |
if (storyText && typedStoryCharacterIndex < storyText.length) {
|
74 |
setTimeout(() => {
|
75 |
setTypedStoryText(typedStoryText + story.text[typedStoryCharacterIndex])
|
76 |
setTypedStoryCharacterIndex(typedStoryCharacterIndex + 1)
|
77 |
+
console.log("boom")
|
78 |
}, 40)
|
79 |
}
|
80 |
}, [storyText, typedStoryCharacterIndex])
|
81 |
+
*/
|
82 |
|
83 |
const { progressPercent, remainingTimeInSec } = useCountdown({
|
84 |
isActive: isLocked,
|
85 |
timerId: runs, // everytime we change this, the timer will reset
|
86 |
+
durationInSec: /*stage === "interpolate" ? 30 :*/ 35, // it usually takes 40 seconds, but there might be lag
|
87 |
onEnd: () => {}
|
88 |
})
|
89 |
|
|
|
128 |
const search = current.toString()
|
129 |
router.push(`${pathname}${search ? `?${search}` : ""}`)
|
130 |
|
|
|
|
|
|
|
|
|
|
|
131 |
const voice: TTSVoice = "CloΓ©e"
|
132 |
|
133 |
setRuns(runsRef.current + 1)
|
134 |
|
135 |
try {
|
136 |
// console.log("starting transition, calling generateAnimation")
|
137 |
+
const newStoryLines = await generateStoryLines(promptDraft, voice)
|
|
|
|
|
|
|
|
|
|
|
|
|
138 |
|
139 |
+
console.log(`generated ${newStoryLines.length} story lines`)
|
140 |
|
141 |
+
setStoryLines(newStoryLines)
|
142 |
|
143 |
} catch (err) {
|
144 |
|
|
|
180 |
|
181 |
|
182 |
useEffect(() => {
|
183 |
+
const fn = async () => {
|
184 |
+
if (!currentLineAudio) {
|
185 |
+
return
|
186 |
+
}
|
187 |
+
console.log("story audio changed!")
|
188 |
|
189 |
+
try {
|
190 |
+
console.log("playing audio!")
|
191 |
+
await audio(currentLineAudio) // play
|
192 |
+
console.log("audio has ended, I think? let's go next!")
|
193 |
+
setCurrentLineIndex(currentLineIndexRef.current += 1)
|
194 |
+
// TODO change the line
|
195 |
+
} catch (err) {
|
196 |
+
console.error(err)
|
197 |
+
}
|
198 |
}
|
199 |
+
fn()
|
200 |
|
201 |
return () => {
|
202 |
audio() // stop
|
203 |
}
|
204 |
+
}, [currentLineAudio])
|
205 |
|
206 |
return (
|
207 |
<div
|
|
|
228 |
<div
|
229 |
className={cn(
|
230 |
`flex flex-col`,
|
231 |
+
`flex-grow`,
|
232 |
+
// `rounded-2xl md:rounded-3xl`,
|
233 |
+
// `backdrop-blur-md bg-gray-800/30`,
|
234 |
+
// `border-2 border-white/10`,
|
235 |
`items-center`,
|
236 |
`space-y-6 md:space-y-8 lg:space-y-12 xl:space-y-14`,
|
237 |
`px-3 py-6 md:px-6 md:py-12 xl:px-8 xl:py-14`,
|
|
|
269 |
`w-full`,
|
270 |
`input input-bordered rounded-full`,
|
271 |
`transition-all duration-300 ease-in-out`,
|
272 |
+
`backdrop-blur-md `,
|
273 |
`placeholder:text-gray-400`,
|
274 |
`disabled:bg-gray-500 disabled:text-yellow-300 disabled:border-transparent`,
|
275 |
isLocked
|
276 |
+
? `bg-white/10 text-yellow-400/60 selection:bg-yellow-200/60 selection:text-yellow-200/60 border-transparent`
|
277 |
+
: `bg-white/10 text-yellow-400/100 selection:bg-yellow-200/100 selection:text-yellow-200/100`,
|
278 |
`text-left`,
|
279 |
+
`text-2xl leading-10 px-6 h-16 pt-1`,
|
|
|
280 |
)}
|
281 |
value={promptDraft}
|
282 |
onChange={e => setPromptDraft(e.target.value)}
|
|
|
293 |
`flex flew-row ml-[-64px] items-center`,
|
294 |
`transition-all duration-300 ease-in-out`,
|
295 |
`text-base`,
|
296 |
+
// `bg-yellow-200`,
|
297 |
`rounded-full`,
|
298 |
`text-right`,
|
299 |
`p-1`,
|
|
|
306 |
<span>{nbCharsLimits}</span>
|
307 |
</div>
|
308 |
</div>
|
309 |
+
<div className="flex flex-row w-44">
|
310 |
<animated.button
|
311 |
style={{
|
312 |
textShadow: "0px 0px 1px #000000ab",
|
|
|
315 |
onMouseEnter={() => setOverSubmitButton(true)}
|
316 |
onMouseLeave={() => setOverSubmitButton(false)}
|
317 |
className={cn(
|
318 |
+
`px-4 h-16`,
|
319 |
`rounded-full`,
|
320 |
`transition-all duration-300 ease-in-out`,
|
321 |
+
`backdrop-blur-sm`,
|
322 |
isLocked
|
323 |
+
? `bg-orange-200/50 text-sky-50/80 border-yellow-600/10`
|
324 |
+
: `bg-yellow-400/70 text-sky-50 border-yellow-800/20 hover:bg-yellow-400/80`,
|
325 |
`text-center`,
|
326 |
`w-full`,
|
327 |
+
`text-2xl `,
|
328 |
`border`,
|
329 |
headingFont.className,
|
330 |
// `transition-all duration-300`,
|
|
|
360 |
`items-center`,
|
361 |
`space-y-6 md:space-y-8 lg:space-y-12 xl:space-y-14`,
|
362 |
`px-3 py-6 md:px-6 md:py-12 xl:px-8 xl:py-14`,
|
363 |
+
storyLines.length
|
364 |
+
? 'scale-100'
|
365 |
+
: 'scale-0'
|
366 |
)}>
|
367 |
{assetUrl ? <div
|
368 |
className={cn(
|
|
|
386 |
`items-center justify-between`
|
387 |
)}>
|
388 |
<div className={cn(
|
389 |
+
`flex flex-col flex-grow w-full space-y-2 text-2xl text-blue-200/90`
|
390 |
)}>
|
391 |
+
{storyLines.map((line, i) =>
|
392 |
+
<div
|
393 |
+
key={`${line.text}_${i}`}
|
394 |
+
|
395 |
+
// TODO change a color if we have progressed at the current index (i)
|
396 |
+
className={cn()}
|
397 |
+
>{
|
398 |
+
line.text
|
399 |
+
}</div>)}
|
400 |
</div>
|
401 |
</div>
|
402 |
</div>
|
403 |
|
404 |
</div>
|
405 |
|
|
|
406 |
</TooltipProvider>
|
407 |
</div>
|
408 |
)
|
src/app/layout.tsx
CHANGED
@@ -8,8 +8,8 @@ import './globals.css'
|
|
8 |
const inter = Inter({ subsets: ['latin'] })
|
9 |
|
10 |
export const metadata: Metadata = {
|
11 |
-
title: 'AI Bedtime Story
|
12 |
-
description: 'AI Bedtime Story
|
13 |
}
|
14 |
|
15 |
export default function RootLayout({
|
|
|
8 |
const inter = Inter({ subsets: ['latin'] })
|
9 |
|
10 |
export const metadata: Metadata = {
|
11 |
+
title: 'π AI Bedtime Story ποΈ',
|
12 |
+
description: 'π AI Bedtime Story ποΈ',
|
13 |
}
|
14 |
|
15 |
export default function RootLayout({
|
src/app/server/actions/{generateStory.ts β generateStoryLines.ts}
RENAMED
@@ -1,11 +1,11 @@
|
|
1 |
"use server"
|
2 |
|
3 |
-
import { Story, TTSVoice } from "@/types"
|
4 |
|
5 |
const instance = `${process.env.AI_BEDTIME_STORY_API_GRADIO_URL || ""}`
|
6 |
const secretToken = `${process.env.AI_BEDTIME_STORY_API_SECRET_TOKEN || ""}`
|
7 |
|
8 |
-
export async function
|
9 |
if (!prompt?.length) {
|
10 |
throw new Error(`prompt is too short!`)
|
11 |
}
|
@@ -34,22 +34,18 @@ export async function generateStory(prompt: string, voice: TTSVoice): Promise<St
|
|
34 |
// next: { revalidate: 1 }
|
35 |
})
|
36 |
|
37 |
-
console.log("res:", res)
|
38 |
-
const rawJson = await res.json()
|
39 |
-
console.log("rawJson:", rawJson)
|
40 |
-
const data = rawJson.data as Story[]
|
41 |
-
console.log("data:", data)
|
42 |
-
|
43 |
-
const story = data?.[0] || { text: "", audio: "" }
|
44 |
|
45 |
-
|
|
|
46 |
|
47 |
-
|
48 |
-
if (res.status !== 200 || !story?.text || !story?.audio) {
|
49 |
|
50 |
-
|
51 |
throw new Error('Failed to fetch data')
|
52 |
}
|
53 |
|
54 |
-
return
|
|
|
|
|
|
|
55 |
}
|
|
|
1 |
"use server"
|
2 |
|
3 |
+
import { Story, StoryLine, TTSVoice } from "@/types"
|
4 |
|
5 |
const instance = `${process.env.AI_BEDTIME_STORY_API_GRADIO_URL || ""}`
|
6 |
const secretToken = `${process.env.AI_BEDTIME_STORY_API_SECRET_TOKEN || ""}`
|
7 |
|
8 |
+
export async function generateStoryLines(prompt: string, voice: TTSVoice): Promise<StoryLine[]> {
|
9 |
if (!prompt?.length) {
|
10 |
throw new Error(`prompt is too short!`)
|
11 |
}
|
|
|
34 |
// next: { revalidate: 1 }
|
35 |
})
|
36 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
37 |
|
38 |
+
const rawJson = await res.json()
|
39 |
+
const data = rawJson.data as StoryLine[][]
|
40 |
|
41 |
+
const stories = data?.[0] || []
|
|
|
42 |
|
43 |
+
if (res.status !== 200) {
|
44 |
throw new Error('Failed to fetch data')
|
45 |
}
|
46 |
|
47 |
+
return stories.map(line => ({
|
48 |
+
text: line.text.replaceAll(" .", ".").replaceAll(" ?", "?").replaceAll(" !", "!").trim(),
|
49 |
+
audio: line.audio
|
50 |
+
}))
|
51 |
}
|
src/lib/useAudio.ts
CHANGED
@@ -1,54 +1,80 @@
|
|
1 |
-
import { useCallback, useEffect, useRef } from
|
2 |
|
3 |
-
/**
|
4 |
-
* Custom React hook to play a Base64 WAV file.
|
5 |
-
*/
|
6 |
export function useAudio() {
|
7 |
-
|
8 |
-
|
9 |
-
|
10 |
-
|
11 |
-
|
12 |
-
if (audioRef.current) {
|
13 |
-
audioRef.current.pause();
|
14 |
-
audioRef.current.src = ''; // Release the object URL to avoid memory leaks
|
15 |
-
audioRef.current = null;
|
16 |
-
}
|
17 |
}, []);
|
18 |
|
19 |
-
//
|
|
|
|
|
|
|
|
|
|
|
20 |
const playAudio = useCallback(
|
21 |
-
(base64Data?: string) => {
|
22 |
-
|
23 |
-
|
24 |
-
|
25 |
-
|
26 |
-
|
27 |
-
|
28 |
-
// Clean up any existing audio first
|
29 |
-
stopAndCleanupAudio();
|
30 |
-
|
31 |
-
// Create a new Audio object and start playing
|
32 |
-
audioRef.current = new Audio(base64wav);
|
33 |
-
audioRef.current.play().catch((e) => {
|
34 |
-
console.error('Failed to play the audio:', e);
|
35 |
-
});
|
36 |
-
} else {
|
37 |
-
// If no base64 data provided, stop the audio
|
38 |
-
stopAndCleanupAudio();
|
39 |
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
40 |
},
|
41 |
-
[
|
42 |
);
|
43 |
|
44 |
// Effect to handle cleanup on component unmount
|
45 |
useEffect(() => {
|
46 |
return () => {
|
47 |
-
|
48 |
};
|
49 |
-
}, [
|
50 |
|
51 |
// Return the playAudio function from the hook
|
52 |
return playAudio;
|
53 |
-
}
|
54 |
-
|
|
|
1 |
+
import { useCallback, useEffect, useRef } from 'react';
|
2 |
|
|
|
|
|
|
|
3 |
export function useAudio() {
|
4 |
+
const audioContextRef = useRef<AudioContext | null>(null);
|
5 |
+
|
6 |
+
const stopAudio = useCallback(() => {
|
7 |
+
audioContextRef.current?.close();
|
8 |
+
audioContextRef.current = null;
|
|
|
|
|
|
|
|
|
|
|
9 |
}, []);
|
10 |
|
11 |
+
// Helper function to handle conversion from Base64 to an ArrayBuffer
|
12 |
+
async function base64ToArrayBuffer(base64: string): Promise<ArrayBuffer> {
|
13 |
+
const response = await fetch(base64);
|
14 |
+
return response.arrayBuffer();
|
15 |
+
}
|
16 |
+
|
17 |
const playAudio = useCallback(
|
18 |
+
async (base64Data?: string) => {
|
19 |
+
stopAudio(); // Stop any playing audio first
|
20 |
+
|
21 |
+
// If no base64 data provided, we don't attempt to play any audio
|
22 |
+
if (!base64Data) {
|
23 |
+
return;
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
24 |
}
|
25 |
+
|
26 |
+
// Initialize AudioContext
|
27 |
+
const audioContext = new AudioContext();
|
28 |
+
audioContextRef.current = audioContext;
|
29 |
+
|
30 |
+
// Format Base64 string if necessary and get ArrayBuffer
|
31 |
+
const formattedBase64 =
|
32 |
+
base64Data.startsWith('data:audio/wav') || base64Data.startsWith('data:audio/wav;base64,')
|
33 |
+
? base64Data
|
34 |
+
: `data:audio/wav;base64,${base64Data}`;
|
35 |
+
|
36 |
+
console.log(`formattedBase64: ${formattedBase64.slice(0, 50)} (len: ${formattedBase64.length})`);
|
37 |
+
|
38 |
+
const arrayBuffer = await base64ToArrayBuffer(formattedBase64);
|
39 |
+
|
40 |
+
return new Promise((resolve, reject) => {
|
41 |
+
// Decode the audio data and play
|
42 |
+
audioContext.decodeAudioData(arrayBuffer, (audioBuffer) => {
|
43 |
+
// Create a source node and gain node
|
44 |
+
const source = audioContext.createBufferSource();
|
45 |
+
const gainNode = audioContext.createGain();
|
46 |
+
|
47 |
+
// Set buffer and gain
|
48 |
+
source.buffer = audioBuffer;
|
49 |
+
gainNode.gain.value = 1.0;
|
50 |
+
|
51 |
+
// Connect nodes
|
52 |
+
source.connect(gainNode);
|
53 |
+
gainNode.connect(audioContext.destination);
|
54 |
+
|
55 |
+
// Start playback and handle finishing
|
56 |
+
source.start();
|
57 |
+
|
58 |
+
source.onended = () => {
|
59 |
+
stopAudio();
|
60 |
+
resolve(true);
|
61 |
+
};
|
62 |
+
}, (error) => {
|
63 |
+
console.error('Error decoding audio data:', error);
|
64 |
+
reject(error);
|
65 |
+
});
|
66 |
+
})
|
67 |
},
|
68 |
+
[stopAudio]
|
69 |
);
|
70 |
|
71 |
// Effect to handle cleanup on component unmount
|
72 |
useEffect(() => {
|
73 |
return () => {
|
74 |
+
stopAudio();
|
75 |
};
|
76 |
+
}, [stopAudio]);
|
77 |
|
78 |
// Return the playAudio function from the hook
|
79 |
return playAudio;
|
80 |
+
}
|
|
src/types.ts
CHANGED
@@ -197,7 +197,11 @@ export type QualityOption = {
|
|
197 |
label: string
|
198 |
}
|
199 |
|
200 |
-
export type
|
201 |
text: string
|
202 |
audio: string // in base64
|
|
|
|
|
|
|
|
|
203 |
}
|
|
|
197 |
label: string
|
198 |
}
|
199 |
|
200 |
+
export type StoryLine = {
|
201 |
text: string
|
202 |
audio: string // in base64
|
203 |
+
}
|
204 |
+
|
205 |
+
export type Story = {
|
206 |
+
lines: StoryLine[]
|
207 |
}
|