An interactive violin practice website for a young student and their parents, designed to make daily practice easy and fun. It's a single-tenant template: fork it and deploy your own instance for your family (see Deploy your own).
String Steps turns the teacher's weekly practice list into a checklist the student can work through on their own, marking each item done.
Each track can require one or more of the following tasks:
- Sing with track — sing along to the audio
- Practice on violin with track — play along to the audio
- Practice on violin without track — play independently
Each task has a repeat count, and any task can be a focus drill — scoped to a specific passage (e.g. bars 5–8) so the student knows exactly which part to work on. Some tracks need just one task; others combine several.
- Audio — a recording to sing or play along with.
- Video — a teacher demo of new notes; supports pause, rewind, and step-through so the student can learn at their own pace.
- Reference — a named exercise with no media (e.g. A Major Scale), used for play-without-track drills and the dice roller.
Some exercises (e.g., A Major Scale) use a dice roll to pick the rhythm. A built-in dice roller is available when needed.
A configurable checklist to run through before playing, defined declaratively in data/checklist-items.json. The teacher adjusts it periodically based on the student's progress.
Each entry is:
| field | meaning |
|---|---|
id |
stable key; also the /api/img/<id> and checklist:blobs key |
description |
the reminder text shown under the icon |
category |
violin-hand | bow-hand | other — groups items into a labeled row |
emoji |
shown when the item has no image (see below) |
image |
seed path; the live image is served from Vercel Blob via /api/img/<id> |
The image is optional. At render time the app looks up the item's id in the
checklist:blobs Redis map (the /api/img/<id> route resolves it from Blob). If
there's an uploaded image, it renders the photo; if not, it renders the item's
emoji instead. So a fresh deployment with no images shows a friendly emoji
checklist out of the box, and you can add a photo per item later by uploading it
(scripts/migrate-checklist-images.mjs) without touching this file's structure.
Current example (the committed demo seed):
- 🐭 Three mouse holes on the bow hand
- 🐻 Baby bear gesture on the violin hand
- ☝️ First finger down at the edge of the tape
- 🤗 Two hugger fingers on the bow
- 🐶 Space for a puppy in the bow hold
The Calendar tab shows each day at a glance — how much of that day's plan was completed, plus class days and sick days — and lets the student open any day to practice it.
The Plans tab is where the week's practice is built: create a plan, add tracks with their tasks (sing / play / focus drills / dice), set the Get Ready checklist, and choose the date it becomes active. Past plans are frozen so practice history stays intact.
While practicing, the student can add a short note to any track (how it went, what was tricky). Notes are saved per track per day and feed the Teacher view.
The Teacher tab gives the teacher a read-only summary of recent practice — the tracks worked on and any notes — covering the stretch since the last class day.
By default a deployment shows a single student — no account switcher, no "Practicing as …" banner.
Optionally, the roster can include a linked account — e.g. a parent who practices too. To enable it, add a second entry to the students:list array in Redis with "kind": "linked" and "linkedTo": "<primary id>", for example:
{ "id": "parent", "name": "Mama Bear", "emoji": "🐻", "kind": "linked", "linkedTo": "mozart-x" }Once a linked account exists, a name chip appears in the tab bar to switch between accounts; each keeps its own practice history. The linked account works through the same plan (with a "Practicing as …" banner) but doesn't see the Teacher tab or track notes — those stay with the student.
The four tabs form a loop around each lesson:
- Plans — during or after a lesson, the parent builds that week's plan from the teacher's feedback. A new plan starts from the current one: swap out tracks, add or adjust tasks, and pick the date it becomes active.
- Practice — each day the student opens that day's practice, runs the checklist, works through each track's tasks, marks them done, and adds track notes.
- Calendar — shows the month's practice at a glance; page back to review past months.
- Teacher — at the next lesson the teacher reviews what was practiced and the notes since last time, and gives the feedback that shapes the next plan — back to step 1.
Requires Node 20.9+ (the floor declared by Next.js 16).
npm install
cp .env.example .env.local # then set APP_PASSCODE to any value for local use
npm run dev # http://localhost:3000Open http://localhost:3000 and log in with your APP_PASSCODE.
Locally, APP_PASSCODE is the only variable you need — set it to anything and leave the rest of .env.local blank. With no Redis, the app reads and writes the committed data/*.json seed files (it ships a demo plan + a CC-BY-SA play-along track, so a fresh clone works immediately). With no Blob, the checklist shows its emoji fallback and there's simply no uploaded media to play. Add Redis/Blob locally only if you want to develop against them — see Environment variables for what each one does.
Checks:
npm test # vitest
npm run lint # eslint
npm run typecheck # tsc --noEmit.claude/settings.json is Claude Code project config — it enables the Superpowers plugin used while developing this app. It has no effect on the running app; ignore or delete it if you don't use Claude Code.
All configured in .env.local for local dev, and in the Vercel project settings for a deployment (see .env.example):
| Variable | Local dev | Deployment | Purpose |
|---|---|---|---|
APP_PASSCODE |
required | required | Shared passcode that gates the whole app — it errors on boot if unset. |
KV_REST_API_URL + KV_REST_API_TOKEN |
optional | required | Upstash Redis storage (plans, schedule, tracks, practice log, people). Optional locally — falls back to the data/*.json files. Required in production: Vercel's filesystem is read-only at runtime, so without Redis any save (marking tasks done, notes, plan edits) fails. |
BLOB_READ_WRITE_TOKEN |
optional | optional | Vercel Blob — serves and stores media (audio, video, checklist images). Without it: emoji-checklist fallback and no media. |
UPLOAD_TOKEN |
optional | optional | Bearer token for the video-upload routes (/api/tracks*). Only needed if you add videos via a Shortcut. |
In short: local dev needs only APP_PASSCODE; a real deployment needs APP_PASSCODE and an Upstash Redis store, plus Vercel Blob if you want media.
String Steps is single-tenant — each deployment is one family's instance. To run your own:
-
Fork this repo (or clone and push it to your own GitHub).
-
Create a Vercel project from the repo. It's a Next.js app, so the defaults work.
-
Add an Upstash Redis store (required) — via the Vercel Marketplace (Storage → Upstash) or at upstash.com. Put its REST URL and token into the Vercel project's environment variables as
KV_REST_API_URLandKV_REST_API_TOKEN. (Required because Vercel's runtime filesystem is read-only — all writes go to Redis.) -
Add a Vercel Blob store (optional, for media) — Vercel dashboard → Storage → Blob. Put its read-write token into
BLOB_READ_WRITE_TOKEN. Skip it and the app runs without media (emoji checklist, no audio/video). -
Set the env vars in Vercel — in your project on vercel.com, open Settings → Environment Variables and add the Redis/Blob values from steps 3–4 plus (see .env.example):
APP_PASSCODE— the shared password that gates the whole app.UPLOAD_TOKEN— a long random string for the video-upload routes (generate withopenssl rand -hex 32; only needed if you use the upload flow).
Re-deploy after changing environment variables so they take effect.
-
Deploy. On first load you'll hit the passcode gate; after logging in, the app boots from the committed demo seed, so it works right away — one sample plan with a play-along track and the emoji checklist.
-
Make it yours. Edit the live data in the Upstash console — the student/teacher roster (
students:list,teacher:info), plans (plans:list), schedule (schedule:map), and tracks (tracks:list) — and add your own media (see below). The committeddata/*.jsonfiles only seed empty Redis keys, so changing them later never overwrites a populated production store.
De-identification. Real names live only in your production Redis. The repo ships fictional defaults — Mozart X / Mama Bear / Mr. G in data/students.json and data/teacher.json — so nothing personal is committed.
Teacher-demo videos live in Vercel Blob and are registered in Redis; the app serves them behind the passcode gate via /api/v/<id>. The easiest way to add one from an iPhone is a small iOS Shortcut you build yourself — there's no shared link, since it has to embed your deployment URL and upload token. If you'd rather not build a Shortcut, use the ffmpeg recipe below.
First set an UPLOAD_TOKEN env var on your deployment — a long random string. Generate one with openssl rand -hex 32 (or node -e "console.log(require('crypto').randomBytes(32).toString('hex'))"). The two upload routes authenticate with Authorization: Bearer <UPLOAD_TOKEN>. Then, in the Shortcuts app, create a Shortcut that receives video from the share sheet and runs these actions (store your own values in the two Text actions at the top):
- Text → your deployment URL (e.g.
https://your-app.vercel.app) → Set VariableSERVER_URL. - Text → your
UPLOAD_TOKEN→ Set VariableUPLOAD_TOKEN. - Ask for Input ("Track name?") → Set Variable
NAME. - Encode Media → size 540p (960×540), H.264 (see the note below).
- Get Contents of URL →
POST {SERVER_URL}/api/tracks/upload-url, headerAuthorization: Bearer {UPLOAD_TOKEN}, JSON body{ "name": NAME }. Returns{ id, uploadUrl, clientToken, uploadHeaders, blobUrl }. - Get Contents of URL →
PUT {uploadUrl}with the encoded video as the file body, the returneduploadHeaders, plusAuthorization: Bearer {clientToken}. Uploads straight to Vercel Blob. - Get Contents of URL →
POST {SERVER_URL}/api/tracks, headerAuthorization: Bearer {UPLOAD_TOKEN}, JSON body{ "id": id, "name": NAME, "blobUrl": blobUrl }. Registers the track in Redis. - Show Notification → "Uploaded {NAME}".
The exact request/response shapes are the source of truth in src/app/api/tracks/upload-url/route.ts and src/app/api/tracks/route.ts.
Encode at 540p, not 720p. iOS's hardware encoder runs at a high bitrate regardless of size — 720p produced ~11 Mbps files that buffered; 540p roughly halves it at the same 16:9 framing. iOS
Encode Mediaalso can't write a faststart MP4, so this only mitigates the problem — runscripts/reencode-videos.mjsafterward to fully normalize (see below).
- In Photos, open the demo clip → tap Share → choose your Shortcut.
- Type a track name (e.g. May Song - Bread - Teacher Demo) when prompted.
- Wait for the upload notification. The new track appears in
/planson next page load.
Encode a streaming-friendly MP4, then upload it to your Blob store and add a matching entry to tracks:list in Redis:
ffmpeg -i input.mov \
-vcodec libx264 -crf 28 -preset medium -vf "scale=-2:720" \
-acodec aac -b:a 128k \
-movflags +faststart \
output.mp4Knobs: bump -crf (e.g. 30–32) for smaller files at lower quality, drop -vf "scale=-2:720" to keep the source resolution, or change -2:720 to -2:480 for an even smaller file.
iPhone uploads can land at a high bitrate or without faststart, which makes them buffer instead of streaming cleanly (iOS's encoder can't fix either). scripts/reencode-videos.mjs audits the videos already in your deployment and re-encodes any that aren't streaming-ready:
# Audit only — lists every video and flags any that aren't streaming-ready
node --env-file=.env.local scripts/reencode-videos.mjs
# Fix — re-encode just the flagged ones and swap them on Blob
node --env-file=.env.local scripts/reencode-videos.mjs --executeIt flags any video that isn't faststart or exceeds 2.5 Mbps, re-encodes those to 720p / faststart / ~1.2 Mbps (no upscaling), uploads to a fresh Blob path, repoints Redis, and deletes the old file. Requires ffmpeg/ffprobe installed and the KV_REST_API_* + BLOB_READ_WRITE_TOKEN env vars (i.e. your .env.local). Safe to run anytime — it only touches non-conforming videos.