A full-stack AI music generation SaaS. Describe your sound in plain words and walk away with a complete song — AI-written lyrics, an original melody, and unique cover artwork — all generated in minutes.
🎵 Live Demo · Portfolio · GitHub · Twitter / X
| Light Mode | Dark Mode |
|---|---|
![]() |
![]() |
https://sonauto-three.vercel.app
Sign up for free and receive 10 credits (5 songs) immediately — no credit card required.
The app is a portfolio project. Payments run in Polar sandbox mode (test cards accepted, no real charges).
- Overview
- Features
- AI Models & Services
- Tech Stack
- Architecture
- Project Structure
- Security & Rate Limiting
- Credit & Billing System
- Installation & Local Development
- Environment Variables
- Database Setup
- Deployment
- Generation Modes
- Author
Sonauto is an end-to-end AI music generation platform built as a production-grade portfolio project. A user describes a song — a genre, a mood, a story — and the system orchestrates three AI models in sequence to produce a fully finished track: structured lyrics, a synthesized audio file, and a matching piece of cover art.
The platform is split into two independently deployable units:
web-app— A Next.js 15 application (App Router) handling auth, the UI, background job dispatch, and all user-facing features.modal-workers— A Python 3.12 Modal serverless worker that loads ACE-Step (audio generation) and SDXL-Turbo (thumbnail generation) onto GPU and exposes a single FastAPI-style endpoint that the web app calls.
The two services communicate over HTTP. The web app fires an Inngest background function which calls the Modal endpoint; Modal uploads the finished audio and thumbnail directly to Cloudflare R2 and returns the public URLs.
- Four generation modes — Simple (vibe-only), Custom Auto (AI lyrics from theme), Custom Manual (bring your own lyrics), and Instrumental (no lyrics at all)
- AI-structured lyrics — Verses, choruses, bridges, intros, and outros scaled to the chosen song duration (15 s – 3 min)
- Controllable duration — Slider from 15 to 180 seconds in 15-second steps
- Seed control — Advanced option to lock in a specific generation seed for reproducible results
- Style tags — Genre, vocal type, instruments, mood, and BPM tags are auto-generated from the user's description and fed directly into ACE-Step
- Indian subcontinent support — Ghazal, Sufi, Bollywood, Qawwali, sitar, tabla, and related genres are explicitly supported in all prompts
- Rename tracks inline
- Publish / Unpublish — toggle community visibility
- Delete with smart refund logic (see Credit System)
- Download as a high-quality WAV via time-limited, cryptographically signed R2 pre-signed URLs
- View lyrics with a one-click copy-to-clipboard action
- Persistent floating player — survives page navigation within the app
- Seek bar, volume control, play/pause
- Sidebar-aware positioning (shifts right when sidebar is open)
- Download and lyrics access from the player itself
- Published tracks are visible to all users
- Unique listen count (once per user per track)
- Like / unlike tracks
- Category-based browsing (3 AI-generated tags per song)
- Full light/dark theme with system preference detection
- Skeleton loading states on all async content
- Toast notifications for all async actions (Sonner)
- Responsive across mobile, tablet, and desktop
- Breadcrumb navigation in the protected app header
- Frosted-glass sticky headers
- Custom audio equalizer and skeleton shimmer CSS animations
| Layer | Model / Service | Purpose |
|---|---|---|
| Text | Groq — openai/gpt-oss-120b (with automatic fallback) |
Generates style tags, lyrics, song title, and 3 genre categories from the user's description. Runs in parallel where possible. |
| Audio | ACE-Step | Open-source music generation model. Takes a comma-separated style/tag prompt and structured lyrics and synthesises a full audio waveform. Runs on Modal (NVIDIA L4 GPU). |
| Artwork | SDXL-Turbo (stabilityai/sdxl-turbo) |
Generates abstract, atmospheric album cover art from the style prompt. 2-step inference for fast generation. Runs on the same Modal container as ACE-Step. |
Every generation runs four Groq calls (some in parallel). Model selection goes through a small preference-ordered fallback chain (openai/gpt-oss-120b → openai/gpt-oss-20b → llama-3.1-8b-instant) so a Groq model deprecation degrades gracefully instead of breaking generation — see web-app/lib/groq.ts.
generateTags— converts the user description into ACE-Step's comma-separated tag format ("pop, upbeat, female vocal, acoustic guitar, 120 bpm")generateLyrics— writes full, duration-scaled lyrics with[verse],[chorus],[bridge],[intro],[outro]markers (skipped for instrumental)generateTitle— produces a short, punchy 2–5 word song titleextractCategories— returns exactly 3 genre/mood category tags as JSON for community browsing
| Category | Technology |
|---|---|
| Framework | Next.js 15 — App Router, React Server Components, Server Actions |
| Language | TypeScript 5.8 |
| Styling | Tailwind CSS v4 |
| UI Components | shadcn/ui (Radix UI primitives) |
| Authentication | Better Auth (email + password, session cookies) |
| Auth UI | @daveyplate/better-auth-ui |
| Database ORM | Prisma 6 |
| Database | Neon — serverless PostgreSQL (connection pooling + direct URL) |
| Env Validation | @t3-oss/env-nextjs (T3 App Stack) |
| Background Jobs | Inngest — event-driven, retryable job queue |
| File Storage | Cloudflare R2 via AWS S3-compatible SDK |
| Payments | Polar — one-time credit packs (sandbox) |
| AI SDK | Vercel AI SDK + @ai-sdk/groq |
| State Management | Zustand (audio player global state) |
| Notifications | Sonner |
| Icons | Lucide React |
| Theming | next-themes |
| Font | Libre Franklin (Google Fonts, variable) |
| Category | Technology |
|---|---|
| Runtime | Modal — serverless GPU compute |
| Language | Python 3.12.3 |
| GPU | NVIDIA L4 (per generation) |
| Audio Model | ACE-Step (cloned from GitHub, pinned to 1bee4c9) |
| Image Model | SDXL-Turbo via Diffusers |
| Audio I/O | torchaudio + soundfile (WAV output) |
| Storage Upload | boto3 (S3-compatible Cloudflare R2) |
| API Framework | Modal's built-in FastAPI endpoint (@modal.fastapi_endpoint) |
| Model Cache | Modal persistent Volume (sonauto-models-cache) — avoids re-downloading on cold start |
| Secrets | Modal named secrets (sonauto-secret) |
User Browser
│
▼
Next.js App (Vercel)
│
├─ Better Auth ──────────────── Neon PostgreSQL (Prisma)
│
├─ /api/generate (POST)
│ │
│ ├─ Rate limit check (GenerationEvent count, rolling 24h window)
│ ├─ Atomic credit deduction (updateMany WHERE credits >= 2)
│ ├─ Song & GenerationEvent records created atomically
│ └─ inngest.send("song/generate")
│ │
│ ▼
│ Inngest Worker (background, retryable)
│ │
│ ├─ Step 1: mark song "generating"
│ ├─ Step 2: Groq — tags + lyrics + title + categories (parallel)
│ ├─ Step 3: save text content to DB
│ ├─ Step 4: step.fetch → Modal GPU endpoint
│ │ │
│ │ ├─ SDXL-Turbo → thumbnail → R2
│ │ └─ ACE-Step → audio WAV → R2
│ └─ Step 5: save URLs, mark "completed"
│
├─ Cloudflare R2 ─────────────── audio/*.wav + thumbnails/*.webp
│
└─ Polar (Sandbox) ────────────── Webhook → credit increment on order paid
The client polls for completion every 5 seconds via router.refresh() on the TracksFetcher Server Component. A 15-minute stale-song remediation pass runs on each poll tick and automatically refunds credits for jobs that never completed.
sonauto-ai-music-gen-saas/
│
├── web-app/ # Next.js 15 application
│ ├── prisma/
│ │ └── schema.prisma # Database schema
│ ├── public/
│ │ └── images/ # Hero banners, favicon
│ └── src/
│ ├── app/
│ │ ├── (auth)/ # Auth route group
│ │ │ └── auth/
│ │ │ ├── layout.tsx # Split-panel auth layout (branding + form)
│ │ │ └── [...all]/ # Better Auth UI catch-all
│ │ ├── (protected)/ # Protected route group
│ │ │ ├── layout.tsx # Sidebar + header + audio player shell
│ │ │ ├── dashboard/ # User's track library
│ │ │ ├── generate/ # Generation panel + track feed
│ │ │ ├── billing/ # Credit purchase page
│ │ │ └── account/ # Account settings
│ │ ├── api/
│ │ │ ├── generate/
│ │ │ │ └── route.ts # POST — rate limit, credit deduction, job dispatch
│ │ │ ├── auth/ # Better Auth handler
│ │ │ └── inngest/ # Inngest event handler
│ │ ├── layout.tsx # Root layout (fonts, theme, providers)
│ │ ├── page.tsx # Landing page
│ │ └── not-found.tsx # Global 404
│ ├── components/
│ │ ├── audio-player/ # Floating persistent audio player
│ │ ├── landing/ # Hero, Features, HowItWorks, Pricing, Header, Footer
│ │ ├── tracks/ # TrackGenPanel, TracksFetcher, Tracks, TrackThumbnail
│ │ ├── theme/ # ThemeProvider, ThemeToggle
│ │ ├── auth/ # Providers, SignOutClient
│ │ ├── ui/ # shadcn/ui primitives
│ │ ├── AppHeader.tsx # Sticky header with rate-limit badge
│ │ ├── AppSidebar.tsx # Navigation + credit display + upgrade CTA
│ │ ├── AppBreadcrumbs.tsx # Dynamic breadcrumbs from pathname
│ │ └── Logo.tsx
│ ├── inngest/
│ │ ├── client.ts # Inngest client
│ │ └── functions.ts # generateSong function (5-step pipeline)
│ ├── lib/
│ │ ├── constants.ts # App-wide constants (routes, plans, DAILY_GENERATION_LIMIT)
│ │ ├── groq.ts # Groq AI helpers (generateTags, generateLyrics, etc.)
│ │ ├── prompts.ts # System prompts for all Groq calls
│ │ └── utils.ts
│ ├── server/
│ │ ├── actions/
│ │ │ └── songs.ts # Server actions: rename, delete, publish, download, like
│ │ ├── better-auth/
│ │ │ ├── config.ts # Better Auth + Polar plugin config
│ │ │ ├── server.ts # getSession helper
│ │ │ └── client.ts # authClient (React)
│ │ └── db.ts # Prisma client singleton
│ ├── stores/
│ │ └── useAudioPlayerStore.ts # Zustand audio player store
│ ├── styles/
│ │ └── globals.css # Tailwind v4 theme tokens, skeleton, scrollbar
│ ├── env.js # T3 env validation schema
│ └── middleware.ts # Edge auth middleware (optimistic cookie check)
│
├── modal-workers/ # Python GPU workers
│ ├── main.py # SongGenServer: ACE-Step + SDXL-Turbo + R2 upload
│ ├── requirements.txt # boto3, pydantic, transformers, python-dotenv
│ └── .env # Local secrets for modal serve/test
│
├── package.json # Monorepo root (pnpm workspaces)
└── README.md
All new accounts must verify their email address before they can sign in. This prevents throwaway or fake emails from consuming free Modal GPU credits and Cloudflare R2 storage.
How it works:
- A user signs up with email + password.
- Better Auth immediately fires a
sendVerificationEmailcallback, which dispatches a branded HTML email via Nodemailer + Gmail SMTP (using a Google App Password — not the account password). - The email contains a signed, time-limited verification link generated by Better Auth.
- Until the link is clicked, the account exists in the database but
emailVerifiedisfalseand all sign-in attempts are rejected with a403. - On click,
emailVerifiedis set totrueand the user is auto-signed in (autoSignInAfterVerification: true). - If a user with an unverified email attempts to sign in, a fresh verification email is re-sent automatically (
sendOnSignIn: true).
Implementation notes:
- Email transport:
nodemailer@8.0.5(pinned exact version, zero transitive dependencies) over Gmail SMTP with an App Password. - No custom domain required — Gmail's SMTP relay is used directly.
- The email is sent with
void(fire-and-forget) to avoid timing attacks and keep the sign-up response fast on Vercel serverless. - The
Verificationmodel was already present in the Prisma schema — no migration was needed. - The existing
authViewPathscatch-all inapp/(auth)/auth/[...all]/page.tsxalready handles theverify-emailpath via@daveyplate/better-auth-ui. - The logo in the verification email is a PNG (
/public/images/logo-email.png, 192×192 px, displayed at 64×64).
To generate a Google App Password: Google Account → Security → 2-Step Verification → App Passwords → create one named "Sonauto".
When a user exhausts their {DAILY_GENERATION_LIMIT} daily generations, a compact amber badge (icon + "Limit") appears in the sticky app header. The badge is intentionally terse to avoid crowding the header on small screens — the full context ("You've used all N daily generations. Resets at HH:MM.") is surfaced via a shadcn/ui Tooltip on hover/focus.
How it computes the reset time:
The layout fetches the oldest generation event from an immutable GenerationEvent ledger within the rolling 24-hour window and adds 24 hours to its createdAt timestamp. This gives the exact moment the window slides enough to allow a new generation, formatted in the user's local timezone by toLocaleTimeString. Because it queries an append-only ledger rather than the user's active song list, the rate limit calculation cannot be spoofed by deleting tracks.
Router cache caveat:
Next.js maintains a client-side router cache (RSC payload cache) in browser memory. A Vercel redeploy does not flush this cache. In the unlikely event that a user navigates back to the app immediately after a redeploy, they may briefly see a stale layout where the badge is absent even though the limit has been reached. This is self-correcting: any call to router.refresh() — which is triggered by every generate attempt (success, 429, or error) — invalidates the cache and re-fetches the server layout with accurate data. No data is lost and the API-level rate-limit check (POST /api/generate) remains authoritative regardless of the UI state.
Each authenticated user is limited to 2 song generations per rolling 24-hour window.
To prevent exploitation (where a user might generate, download, delete the track, and regenerate endlessly), the rate limit is completely decoupled from the actual Song records. Instead, it checks an immutable GenerationEvent ledger. This is enforced server-side in POST /api/generate before any credits are touched:
// src/app/api/generate/route.ts
const since = new Date(Date.now() - 24 * 60 * 60 * 1000);
const todayCount = await db.generationEvent.count({
where: { userId: session.user.id, createdAt: { gte: since } },
});
if (todayCount >= DAILY_GENERATION_LIMIT) {
return NextResponse.json(
{ error: "You've reached your daily limit of 2 generations..." },
{ status: 429, headers: { "Retry-After": "86400" } },
);
}When a user successfully initiates a generation, a Song and a GenerationEvent are created atomically via a Prisma $transaction.
If a user manually cancels a queued song (which correctly refunds their 2 credits), that action does not erase the GenerationEvent. This intentionally burns one of their daily generation attempts, actively preventing malicious users from spamming and cancelling the compute queue while dodging rate limits.
The limit is configured via a single constant — DAILY_GENERATION_LIMIT in src/lib/constants.ts — so it can be updated in one place.
Credits are deducted with a WHERE credits >= 2 guard in a single updateMany call — not a read-then-write. This prevents double-spending under concurrent requests:
const deducted = await db.user.updateMany({
where: { id: session.user.id, credits: { gte: 2 } },
data: { credits: { decrement: 2 } },
});
if (deducted.count === 0)
return NextResponse.json({ error: "No credits" }, { status: 402 });The delete/failure refund policy is intentionally asymmetric:
| Song Status at Deletion / Failure | Refund |
|---|---|
queued — not yet picked up by GPU |
✅ 2 credits refunded |
generating — GPU is actively running |
❌ No refund (GPU time is consumed) |
failed — already refunded by Inngest onFailure |
❌ No double-refund |
completed — user received their track |
❌ No refund |
Songs stuck in queued or generating for more than 15 minutes (Modal's max timeout) are automatically marked failed and their credits refunded by TracksFetcher on every poll tick — without a separate cron job.
Audio downloads use cryptographically signed R2 pre-signed URLs that expire in 60 seconds. Authorization is enforced server-side: only the track owner or users viewing a published track can request a URL.
middleware.ts performs an optimistic session cookie check at the edge to redirect unauthenticated users from protected routes instantly — before any DB query is made.
The Modal endpoint requires Modal-Key and Modal-Secret headers (requires_proxy_auth=True). These are never exposed to the client.
Each song generation costs 2 credits. Credits are purchased as one-time packs via Polar (sandbox mode for this portfolio project):
| Pack | Price | Credits | Songs |
|---|---|---|---|
| Free (signup bonus) | $0 | 10 | ~5 |
| Starter Pack | $5 | 60 | ~30 |
| Producer Pack | $12 | 160 | ~80 |
| Studio Pack | $20 | 300 | ~150 |
Credits are added via a Polar webhook (onOrderPaid) that reads credits_to_add from the product metadata and increments the user's balance in the DB. Credits never expire.
| Tool | Version |
|---|---|
| Node.js | ≥ 20 |
| pnpm | 10.33.0 (see packageManager in web-app/package.json) |
| Python | 3.12.3 |
| Modal CLI | latest (pip install modal) |
| Prisma CLI | bundled via pnpm postinstall |
You will also need accounts with:
- Neon (PostgreSQL)
- Groq (free tier)
- Modal (free $30 credit)
- Cloudflare R2 (free tier)
- Polar Sandbox (free)
- Inngest (free tier, or run the dev server locally)
- Vercel (free tier, for deployment)
git clone https://github.com/KeepSerene/sonauto-ai-music-gen-saas.git
cd sonauto-ai-music-gen-saascd web-app
# Install dependencies
pnpm install
# Copy the example env file
cp .env.example .env
# → Fill in all values (see Environment Variables section below)
# Generate Prisma client and push schema to your Neon DB
pnpm db:push
# (Optional) Open Prisma Studio to inspect your database
pnpm db:studiocd ../modal-workers
# Create and activate a Python 3.12.3 virtual environment
python3.12 -m venv .venv
source .venv/bin/activate # Windows: .venv\Scripts\activate
# Install Python dependencies
pip install -r requirements.txt
pip install modal # Modal CLI
# Authenticate with Modal
modal setup
# Create the Modal named secret (add your R2 credentials in the Modal dashboard)
# Required keys: R2_ACCOUNT_ID, R2_ACCESS_KEY_ID, R2_SECRET_ACCESS_KEY,
# R2_BUCKET_NAME, R2_PUBLIC_URL
# https://modal.com/secrets → New Secret → name it "sonauto-secret"
# Serve the worker locally (for testing; gives you a temporary web URL)
modal serve main.py
# Or deploy to Modal permanently and get a stable endpoint URL
modal deploy main.py
# → Copy the endpoint URL into MODAL_API_URL in web-app/.envIn one terminal (web app):
cd web-app
pnpm dev # Starts Next.js on http://localhost:3000 with TurbopackIn a second terminal (Inngest dev server — required for background jobs):
cd web-app
npx inngest-cli@latest dev # Connects to your /api/inngest endpointTip: Set
INNGEST_DEV="1"in your.envto tell Inngest to use the local dev server instead of the cloud.
To expose your local server to Polar webhooks during development:
cd web-app
pnpm tunnel # Runs ngrok on port 3000
# → Use the ngrok URL as your Polar webhook endpoint# ── Node ──────────────────────────────────────────────────────────────────────
NODE_ENV="development"
# ── Better Auth ───────────────────────────────────────────────────────────────
# Generate with: openssl rand -base64 32
BETTER_AUTH_SECRET=""
# Base URL of your running app (no trailing slash)
BETTER_AUTH_URL="http://localhost:3000"
# ── Neon PostgreSQL (Prisma) ──────────────────────────────────────────────────
# Pooled connection (for runtime queries)
DATABASE_URL=""
# Direct connection (for Prisma migrations — no -pooler suffix)
DIRECT_URL=""
# ── Modal GPU Workers ─────────────────────────────────────────────────────────
# From: Modal Dashboard → Settings → API Tokens
MODAL_API_KEY=""
MODAL_API_SECRET=""
# Web endpoint URL from `modal serve/deploy main.py`
MODAL_API_URL=""
# ── Groq AI ───────────────────────────────────────────────────────────────────
# From: https://console.groq.com/keys
GROQ_API_KEY=""
# ── Inngest ───────────────────────────────────────────────────────────────────
INNGEST_DEV="1" # Remove in production
INNGEST_EVENT_KEY="" # Auto-added by Inngest's Vercel integration
INNGEST_SIGNING_KEY="" # Auto-added by Inngest's Vercel integration
# ── Cloudflare R2 ─────────────────────────────────────────────────────────────
R2_ACCOUNT_ID=""
R2_ACCESS_KEY_ID=""
R2_SECRET_ACCESS_KEY=""
R2_BUCKET_NAME=""
# ── Polar (Sandbox) ───────────────────────────────────────────────────────────
# From: https://sandbox.polar.sh/dashboard → Settings → API
POLAR_ACCESS_TOKEN=""
POLAR_WEBHOOK_SECRET=""
# ── Public ────────────────────────────────────────────────────────────────────
NEXT_PUBLIC_APP_URL="http://localhost:3000"
# ── Google Account ────────────────────────────────────────────────────────────────────
GMAIL_USER="your.email@gmail.com"
GMAIL_APP_PASSWORD="xxxx xxxx xxxx xxxx" # Google App Password (not your Gmail password)These are stored as a Modal named secret (sonauto-secret) in the Modal dashboard, not in a local .env file in production. For local testing with modal serve, you can use a .env:
# Cloudflare R2 — same account as the web app
R2_ACCOUNT_ID=""
R2_ACCESS_KEY_ID=""
R2_SECRET_ACCESS_KEY=""
R2_BUCKET_NAME=""
# The public R2 custom domain or dev URL (no trailing slash)
# e.g. https://pub-xxxxxxxxxxxx.r2.dev
R2_PUBLIC_URL=""Note: Add all five keys to a Modal secret named exactly
sonauto-secretat modal.com/secrets. Themain.pyworker references them by that name.
The schema is managed by Prisma and hosted on Neon. Key models:
| Model | Purpose |
|---|---|
User |
Auth identity + credit balance |
Song |
Track record (status, URLs, lyrics, prompt, mode) |
Category |
Deduplicated genre/mood tags (many-to-many with Song) |
Like |
Unique user–song like join table |
Listen |
Unique user–song listen join table |
Session / Account / Verification |
Better Auth managed tables |
# Push schema changes to the database (development)
pnpm db:push
# Generate and apply a migration (production-safe)
pnpm db:generate # creates a migration file
pnpm db:migrate # applies pending migrations (use in CI/CD)- Import the repository into Vercel and set the root directory to
web-app - Add all production environment variables (same keys as
.env, withoutINNGEST_DEV) - Install the Inngest Vercel integration — it auto-injects
INNGEST_EVENT_KEYandINNGEST_SIGNING_KEY - Deploy — Vercel's build command runs
prisma generateautomatically viapostinstall
cd modal-workers
source .venv/bin/activate
modal deploy main.py
# → Outputs a stable HTTPS endpoint URL
# → Paste this URL into MODAL_API_URL in Vercel's environment variablesThe Modal worker uses a persistent volume (sonauto-models-cache) to cache Hugging Face model weights. The first cold start downloads the models; all subsequent starts load from the volume in seconds.
| Mode | Description | Groq calls |
|---|---|---|
| Simple | User writes a free-form vibe description. AI handles style tags, lyrics, title, and categories. | generateTags + generateLyrics + generateTitle + extractCategories |
| Custom Auto | User sets a theme/style and the genres field. AI writes matching lyrics for that specific style. | Simple + Lyrics Description/Direction |
| Custom Manual | User writes their own full lyrics. AI only generates style tags, title, and categories. | generateTags + generateTitle + extractCategories |
| Instrumental | Any mode with the Instrumental toggle on. Lyrics generation is skipped entirely. | generateTags + generateTitle + extractCategories |
Dhrubajyoti Bhattacharjee
A self-taught full-stack developer focused on building production-ready applications with modern web technologies and AI integrations.
| 🌐 Portfolio | math-to-dev.vercel.app |
| 🐙 GitHub | @KeepSerene |
| dhrubajyoti-bhattacharjee-320822318 | |
| 🐦 X (Twitter) | @UsualLearner |
This project is licensed under the Apache License 2.0. See the LICENSE file for details.
Built with ♪ by KeepSerene
