Personalized bedtime stories for kids.
Snoozy generates a fresh, age-appropriate bedtime story tailored to a child's name, chosen world, and mood ("vibe"), then narrates it with text-to-speech. With voice cloning, the story can be read in the voice of someone the child loves.
A child hearing their own mom or dad reading a personalized story when the parent isn't present isn't a TTS feature.
snoozy/
snoozy-android/ # Expo (React Native) mobile app — primary codebase
backend/ # Node.js / Express API server
ios/ # Legacy Swift app (inactive)
docs/ # Architecture, system design, product notes
- The parent signs in and sets up a child profile (name, age, pronouns, voice).
- They pick a world and a vibe for tonight's story.
- The app calls the backend to generate the story text (LLM).
- The app calls the backend to generate narration audio (TTS), which is cached and streamed back.
- The story is saved locally and plays back with audio controls; finished stories live in the library.
HomeScreen → WorldPicker → VibePicker → Generating (story → audio → save) → StoryPlayer → StoryEnd
An Expo / React Native app (TypeScript).
Stack highlights
- Navigation: no React Navigation — a single Zustand field
currentScreendrives conditionalAnimated.Viewrendering inApp.tsx. - State: one global Zustand store (
src/stores/storyStore.ts) owns screens, child details, saved stories, audio playback, and the generation pipeline. - Auth: Clerk (
@clerk/clerk-expo) —SignedIn/SignedOutwrappers; the JWT is attached to every backend call. - Storage:
expo-file-systemfor audio + story JSON;AsyncStoragefor the child profile and avatar. - Design tokens:
src/config/tokens.ts— useSpacing.*,Radii.*,Fonts.*,Colors.*.
Commands
cd snoozy-android
npx expo start # start the Metro dev server
npx expo run:android # build & run on Android device/emulator
npx expo run:ios # build & run on the iOS simulator
npx tsc --noEmit # TypeScript type-checkThere is no lint script or test suite configured for the app.
See snoozy-android/CLAUDE.md for full architecture and styling constraints (e.g. no shadows, Nunito font only, consistent Spacing.* tokens).
An Express API that proxies the LLM and TTS providers and protects the keys.
Endpoints (all under /api, JWT-protected via Clerk requireAuth()):
POST /api/generate-story— Azure OpenAI chat → returns{ title, storyText }.POST /api/generate-audio— ElevenLabs or Fish Audio TTS → returns an MP3 buffer.GET /health— liveness check.
Notable details
- TTS provider is selected per-request by the client via the
providerfield (elevenlabs|fishaudio). - Story text is pre-processed in
src/utils/ttsPreprocessor.js(strips markdown, injects SSML break tags per vibe). - ElevenLabs output is loudness-normalized via
src/utils/audioNormalizer.js. - In-memory audio cache keyed by SHA-256 of
(processedText + voiceId), capped at 200 entries / 2h TTL — swap for Redis above ~500 stories/day. - Per-user rate limits: 15 stories/day and 30 audio requests/day.
- ElevenLabs voice resolution order: explicit
voiceId→ region default (REGION_VOICE_DEFAULTS) → global fallback (Rachel).
Commands
cd backend
npm install
npm run dev # node --watch (auto-restart on save)
npm start # production startConfiguration
Copy backend/.env.example to backend/.env and fill in the keys:
| Variable | Purpose |
|---|---|
AZURE_OPENAI_API_KEY / AZURE_OPENAI_ENDPOINT / AZURE_OPENAI_CHAT_DEPLOYMENT |
Story generation (required) |
AZURE_OPENAI_TTS_DEPLOYMENT |
Azure TTS deployment |
FISH_API_KEY / FISH_AUDIO_VOICE_ID |
Fish Audio TTS + voice cloning |
ELEVENLABS_API_KEY / ELEVENLABS_VOICE_ID |
ElevenLabs TTS |
CLERK_SECRET_KEY |
Clerk auth (backend secret key) |
STRIPE_SECRET_KEY |
Stripe payments |
RAZORPAY_KEY_ID / RAZORPAY_KEY_SECRET |
Razorpay payments (India) |
Config is loaded from .env via src/config.js. The service deploys on Render (render.yaml, rootDir: backend).
More detail lives in docs/:
Proprietary — all rights reserved.