SAM, Sam's Web Site

Guitar Improv: Building the Tool I Kept Almost Finding

 
Dated
practical

I got tired of juggling YouTube, Moises and Songsterr to practise improvising, so I built my own on AWS. Here's the serverless architecture and what it actually costs.

TL;DR
A guitar improv trainer on CloudFront, Cognito, Lambda, DynamoDB and durable functions, costing under 50 cents a month idle. The only real bill is Music.ai, and a credit ledger caps that.

Four browser tabs, three subscriptions, and one serverless stack that costs less than the secrets it stores.


I have, at various points, had a YouTube backing track, Moises and Songsterr open at the same time. On a laptop. Next to a guitar I was supposed to be playing.

Sound familiar ? Good, you're my people. If it doesn't, you either play better than me or you've made peace with tab-switching, and I'm jealous of both.

Guitar, laptop and too many browser tabs

This is the story of how I stopped juggling and built the thing I'd wanted for years, on AWS, serverless, for roughly the price of drinking the dregs out of a starbucks coffee cup found sitting on a bench outside argos (cos lets face it coffee is damn expensive these days). It's mostly about the architecture and the cost, because that's the bit I can actually defend. The guitar playing is another matter.

Try it (Here's on i made eariler): improv.scarr.cloud. The demo loops need no login.

The Itch

Every tool I used did part of the job well and then stopped, or hidden behind a subscription i didnt want to pay for.

YouTube has a backing track for almost everything, some have chord maps but others... i have no idea what chords are coming next, and definitely no idea what I should be playing over them. Moises will pull the guitar out of a song and let me slow things down, which is brilliant, but it won't show me where to land on the neck when the chord changes. Songsterr is great for learning a song, less so for improvising over one.

What I actually wanted was simple to say and annoying to find:

  • A track playing
  • A fretboard showing the scale I'm in
  • The chord tones ringing on the neck as each chord arrives
  • A loop I can set on the bit I keep getting wrong
  • The option to take the lead (or Rhythm) guitar out and play over what's left

Nothing did all five. So each time I'd use one tool, hit the wall, and open another. Eventually I ran out of patience, which in my experience is the real mother of invention.

What I Built

guitar improv is a single-page app. You play along to a track and the fretboard shows the scale, rings the chord tones, and counts down to the next change. You can upload your own track and have it analysed into a chord chart and beat map, and optionally get a backing mix with the lead, rhythm or all guitar removed.

The analysis and stem separation come from Music.ai, the developer API from the Moises folks. Yes, I paid for the API of the thing I was trying to replace. I'm choosing to call that "integration" rather than "irony".

It started in late May as a single React file with a chord parser and a fretboard. It's now a proper multi-user platform (of two, me and admin me) with auth, an upload pipeline, an editor for fixing wrong chord charts, and an admin side. Claude Code did a lot of the typing. I did the arguing, which you'll see in the decisions below.

Here's what the landing page looks like. The fretboard under the chord cards is the whole point: the scale in grey, the chord tones in colour, and the notes that are in both ringed in orange. Press play and it follows the track.

The guitar improv landing page with chord cards and a fretboard The current chord (E7), the previous one, the next one and a countdown in beats. Then the fretboard doing the thinking for me.

Scroll down and there are five free loops with no account needed (blues, slow blues, classic rock, thrash and nu-metal). "Side B" is where your own MP3s go, which is invite-only for now.

Side A demo loops and the invite-only Side B Side A is the demo. Side B is the bit that needs the entire backend below.

The five demo tracks are royalty free, on purpose. I didn't want the public landing page to be the first place I got a copyright complaint. They all come from Pixabay under the Pixabay Content License, which allows this kind of use. I only stream them inside the player, never offer them as downloads, and credit each artist in the landing page footer. Each clip is trimmed to 25 to 32 seconds and cut on a bar line using the beat map from Music.ai, so the loop doesn't stumble.

The Architecture

The bit this whole blog post is about, promise its not a sales pitch !

Serverless, scale-to-zero, nothing that bills me for existing. Deployed into the existing workload account of my landing zone rather than a new account, because a new account is a new bill.

Architecture diagram: CloudFront, Cognito, API Gateway, Lambda, DynamoDB, S3 and Music.ai

The numbers on the arrows are the order things happen in. Everything the browser does goes through CloudFront (1): the app, the demo loops, the API and the audio. API calls go to /api/* with a JWT (2), uploads go straight to S3 with a presigned URL (3), which fires EventBridge (4), which starts the pipeline that talks to Music.ai (5). Audio comes back to the browser through CloudFront with signed URLs (6).

The purple box at the top is the newest addition, and it changed the shape of the diagram. The blue box at the bottom is what that addition cost me in visibility. Both get their own section below.

Same picture in Mermaid, if you'd rather paste it than admire mine:

Rendering diagram

The pieces, and why each one is there:

PieceWhat it doesWhy this and not something else
CloudFront + S3 (OAC)Serves the SPA, the demo loops, the audio and now the APIPrivate buckets, HTTPS, HSTS, price class 100 because my users (me) are in Europe and I'm cheap. On the Free flat-rate plan, so WAF and rate limiting come with it
CognitoInvite-only auth, TOTP requiredAdmin-create only, no public signup. Smallest attack surface for a beta of me and well.. me
HTTP API + JWT authorizerThe API front doorCheaper and simpler than REST API. Every route is defined explicitly, no {proxy+}, with tighter throttles on the expensive ones. Custom domain, default endpoint disabled
8 Lambdas (Node 22, ARM64)tracks, credits, uploads, editor, admin, purge, reaper, pipelineOne runtime across the lot. Coarse Lambdas per domain, not one per route, with least-privilege IAM on each
DynamoDBSingle table, on-demand, PITR onAccess patterns are known and small. Idle cost is basically zero
S3 upload + processed bucketsRaw uploads expire at 90 daysPresigned PUT straight from the browser, so no file ever passes through a Lambda
Lambda durable functionsThe Music.ai pipelineCovered below
CloudFront signed URLsAudio playbackKey in Secrets Manager. Tracks are private by default

Two decisions are worth pausing on, because they're the ones people ask about (who am i kidding, no one is asking me about them, but i'm gonna tell you anyway).

Locking the front door, for free

For a while this had no WAF, and I was fine with that. Nobody can sign up, every API route needs a JWT, and the worst a stranger could do is annoy me. My own Well-Architected review disagreed, politely, with a MEDIUM finding on the CloudFront distribution. Fair. The trouble was the price: a WAF at roughly $5 to $8 a month is more than the rest of the stack put together.

Then I found the CloudFront flat-rate plans. The Free one is $0 (or converted to British sterling at todays exchange rate... still £0) a month, includes AWS WAF, DDoS protection and IP rate limiting, and has no overage charges. The allowance is 1M requests and 100 GB a month, and blocked requests don't count against it. For a site with a handful of users (yeah, just me but still going to write this like its a real thing alright !), that's plenty.

Is a free WAF too good to be true ? Slightly. The Free tier is the cut-down feature set (IP rate limiting and geo blocking, with the bot management and CAPTCHA stuff reserved for the paid tiers), but for "stop something hammering my demo loops" it does exactly the job.

So what was actually exposed ? Not my data, every route needs a login and the buckets are private. What a stranger could do is waste my money or get in the way of my own use. Four ways, simplest first:

  • Hammering the demo loops. The five demo tracks are public, so anyone can download them as often as they like. They're cached, so each request is cheap, but nothing stopped one person (or one script) asking thousands of times and running up the bill. Now the WAF blocks any single IP that sends too many requests in a short window
  • A back door I didn't know I'd left open. API Gateway gives every API a public address by default, and using it skips CloudFront and everything I'd put in front of it. So the API now has its own domain and sits behind CloudFront, and the default address gets switched off in a second deploy (doing both at once is how you lock yourself out of your own app). Is it airtight ? No. It's a hobby app with a cost ceiling, not a bank, and if you spot a gap I'm happy to hear about it (but be gentle)
  • One speed limit shared by everything. The whole API had a single limit of 25 requests a second, so junk traffic on any route could use it all up and lock real users out. Now the routes that cost me money, uploads and stems, are limited to 1 request a second (with a burst of 3). Admin gets 5 and 10, and everything else keeps the old 25 and 50. The limits are built from the route list, so a new route can't quietly skip them
  • Finding out too late. My budget alert only emails me after the money's gone. Now there are alarms for repeated "slow down" errors (HTTP 429) on uploads and stems, five in five minutes, and for a spike in CloudFront requests, so I find out while it's happening rather than at the end of the month

There is a catch, and it's worth being straight about. The Free plan doesn't include standard access logs or WAF request logs. Those start on the $15 a month Pro plan. So I can see that traffic spiked, but not who caused it, and I can't see what the rate limiter blocked, which means I'm tuning it blind. The API is where the traffic that costs real money lives, so that's the bit I wanted to keep visibility on. The obvious answer was to log the client IP in the API Gateway access log. It can't. HTTP API access logs have no variable for a request header, and behind CloudFront the source IP is just an edge address, so the real viewer IP never shows up.

The workaround is a tiny CloudFront Function on /api/* that logs the viewer IP, method and path. No query string, no headers. The logs go to a log group with 14 day retention, because IPs are personal data and two weeks is plenty to work out what happened, and I saved a Logs Insights query for top IPs by route. The API access log also gains the JWT subject, so authenticated callers are identifiable even without an IP. On top of that sit the alarms on 429s and request counts. It's best effort rather than an audit trail (function logs can drop, and they're billed separately, in pennies), but it tells me something is wrong quickly and usually who.

For everything else (the app, the demo loops, audio) there's nothing per request. The WAF does keep a sample of recent requests and a security dashboard, which is enough to see what's being blocked, just not to go digging next week.

Is that the right trade ? For a hobby app with invite-only auth, yes. $0 with no request logs beats $0 with no protection at all. If this ever becomes more than a hobby, the move is either pay-as-you-go CloudFront with my own web ACL and logging to S3 (roughly $6 to $7 a month) or the Pro plan. Until then, free is better than not free.

Total cost is roughly nothing. It saves about $6 a month compared with the paid WAF, and the alarms and function logs add around 30 cents. The interesting part is that the security story got better and the architecture got simpler, because one front door is easier to reason about than two.

A few rules of the Free plan are worth knowing before you copy this. You get 5 cache behaviours (I use exactly 5) and managed policies only, so no custom cache or response header policies. Once you're subscribed the web ACL can't be detached, and deleting the subscription from your template quietly puts the distribution back on pay-as-you-go. Neither is a dealbreaker, but I'd rather you read it here than find out mid-deploy.

CDK, not Terraform

Yes, I know. I'm the person who says "boring and correct" and reaches for Terraform. But this workload deploys into an estate that's already CDK - because you know shits and giggles, and matching the estate beat matching my habits. It's a deliberate exception, scoped to this repo, and I'm at peace with it. Mostly.

Spoiler: More on that estate to come in a future post... maybe.... if i remember... and ever finish it.

Durable functions for the pipeline

The processing flow is: upload lands in S3, EventBridge fires, a Lambda submits a job to Music.ai, then waits. Minutes. Sometimes longer for stems. Then it polls, maps the result, saves it, and refunds a credit if anything went wrong.

That wait is the awkward bit. Lambda durable functions solve it neatly, because waitForCondition suspends the function between polls and you aren't billed compute while it sleeps:

const final = await context.waitForCondition<PollState>(
  `${prefix}poll`,
  async (state) => {
    const mai = await pollMusicAi(state.musicAiJobId);
    return { musicAiJobId: state.musicAiJobId, status: mai.status };
  },
  {
    initialState: { musicAiJobId, status: 'QUEUED' },
    waitStrategy: createWaitStrategy<PollState>({
      initialDelay: { seconds: 5 },
      maxDelay: { seconds: 30 },
      backoffRate: 1.5,
      maxAttempts: 200,
      timeoutSeconds: 60 * 60, // 1 h ceiling on the whole poll
      shouldContinuePolling: (s) => s.status === 'QUEUED' || s.status === 'STARTED',
    }),
  },
);

Was this chosen to save money ? No. At tens of jobs a month, Step Functions Standard and durable functions both cost pennies. I chose it because the orchestrator stays plain TypeScript (no ASL rewrite), the waits are free, it's CDK-native, and, honestly, I wanted to try it out. Step Functions Standard is documented as a like-for-like fallback with no data model change if I get bored or it bites me.

Deploys are CI-only through GitHub Actions and OIDC. Merge to main, it deploys. My laptop has read-only access to the environment, which has saved me from myself more than once.

The Cost

This is the bit I promised. There are two numbers, and the gap between them is the story.

What I estimated before building, for 10 to 50 users and up to about 250 jobs a month:

ItemEstimate (£/mo)Measured, 30 days to mid-Sept (US$)
S3under 1~0.005
CloudFront1 to 3~0
DynamoDB on-demandunder 1~0.0001
Lambda (ARM, incl. durable)under 1~0
HTTP APIunder 1~0
Cognito00
Secrets Manager~1~0.40
CloudWatch~1~0
Total~5 to 10under 0.50

Two honest caveats. The measured column is the idle floor, mostly just me and a handful of test tracks, so it's not a forecast for 50 users. And the currencies don't match because the spec was written in pounds and Cost Explorer speaks dollars. Take it as "the order of magnitude was right, and then some".

The most expensive thing in the stack is storing two secrets. That tells you everything about how true serverless scales down, and to be honest I could probably get away with using Parameter Store secure strings for these (one is the music.ai api key which is managed outside of AWS anyway), but i'd need to create them both out of band and to be honest i borrowed some of the CDK code from elsewhere in the environment and couldn't be arsed to rewrite it.

Under 50 cents a month. In this economy, that's not a hosting bill, that's a rounding error with a rapper's name. I've got 99 lambda's but a... that's the wrong rapper.

If your side project has a fixed monthly cost, it's a subscription to your own guilt. Fight me.

The real bill is Music.ai

Everything above is rounding error next to what Music.ai charges. It bills per workflow, and each workflow chains several jobs together (my analyse workflow does more than just chords and beats, and the stems workflow does more than a single separation). So these are my per-minute estimates for the two workflows as I've configured them, not a list price for one model:

WorkflowEstimated costCredits
Analyse (chords, beat map and friends)~$0.11 a minute of audio1
Stems (lead, rhythm or all guitar removed)~$0.28 a minute of audio1

For a typical 4 minute song, analysing it costs about $0.44 and the stems about $1.12, so $1.56 if someone wants both. That is roughly four times my entire AWS bill for the month, for a single track.

The credit ledger is priced against those numbers. Five credits a month is at most about $2.20 per user if they only analyse, or $5.60 if they only ever run stems. Multiply by 50 users and the ceiling is roughly $110 to $280 a month. That is the number that matters, and it's nowhere near the AWS line items.

So the cost control isn't in AWS at all, it's a credit ledger, which i built in just to see if i could:

  • Standard users get 5 credits a month, use-or-lose
  • Analysing a track is 1 credit, stems are 1 credit
  • Debits and refunds are transactional and per job record, so nothing can double-refund
  • A credit is only refunded if the Music.ai job fails. If the job succeeds but I can't map the result (Music.ai returns six beats a bar on shuffle feels, which my 4/4 mapper doesn't love), you keep the debit and the track drops to a review queue with the raw data

Is that harsh ? A bit. But it's stated at upload time, and it means the worst case is users × 5 credits a month, not a surprise invoice. At 50 users that's a hard ceiling of 250 credits.

What I deliberately didn't buy

  • A paid WAF. A standalone web ACL is roughly $5 to $8 a month, more than the rest of the stack combined, so I didn't buy one. The CloudFront Free plan (see above) gives me the rate limiting and DDoS protection I actually needed for nothing. If signup ever goes public, a proper WAF configuration becomes a hard gate, not a nice-to-have.
  • A GPU. I researched self-hosting stem separation on AWS Batch with g4dn Spot instances, scaling to zero, at roughly 1 to 2 cents a track. The architecture is sound. I parked it, because Music.ai still does the chord and beat analysis, so self-hosting stems would remove one dependency and leave the other. Worth revisiting if Music.ai pricing or terms change or open models get better at stem separation and beat mapping.
  • A second AWS account. Covered above. Tags and a $10 monthly budget filtered on the guitar-improv tag keep an eye on it.

Things That Bit Me

Not everything deployed cleanly. A few greatest hits:

The 403 that said "computer says no" in XML. Signed audio URLs returned a 403 with an XML body, which the Web Audio API tried to decode as audio and threw EncodingError. Tells you precisely nothing. The cause: I'd regenerated the CloudFront signing key pair and loaded the private half into Secrets Manager, but never committed the new public key, so CloudFront was checking against the old one.

Presigned URLs carry the signer's permissions. The pipeline role could write the processed audio but not read it, so the URL it presigned for Music.ai was useless. Music.ai failed the job in seconds with "Invalid input URL". The fix was one narrow grantRead. Finding it took considerably longer.

cdk synth false confidence. A Cognito client setting that's mutually exclusive with refresh-token rotation synthesised happily and failed at deploy time. The rollback then left an orphaned user pool behind, because I'd set it to retain. Synth passing tells you the template is valid, not that AWS will accept it.

Will This Become a Subscription?

Probably not. Which is a strange thing to write at the bottom of a post about a working product, but here we are.

The architecture is the easy part. It scales to a beta without me touching it. The hard parts are elsewhere:

  • Music.ai's per-job cost has to be covered by whatever I'd charge
  • Users uploading other people's music is a copyright problem, not a tech problem. As far as I know the UK has no private-copying exception, so even personal-use copies are technically infringement, and hosting someone else's uploads means terms, a takedown process and a repeat-infringer policy. I'm not a lawyer, and that's exactly why I'd need one
  • Tax, VAT and support for a paid product, when this is a hobby that currently supports itself on 40 cents

And, frankly, I'm not happy enough with it yet. I'm never happy enough with anything. If that changes, the sensible route is a small invite-only beta rather than a launch.

The Short Version

Is it perfect ? No. Does it do the five things I wanted in one tab ? Yes. Does it cost less than a coffee a year to leave running idle ? Also yes.

Plus I just wanted to see if I could build it.

Right. I've got a pentatonic box I keep landing badly in.


Spotted something I've overengineered, or a cheaper way to do any of this ? I'm always happy to be proven wrong if it means a cleaner solution.

Key takeaways
  • The whole AWS stack idles at under $0.50 a month; the most expensive line is storing two secrets.
  • The real cost is Music.ai, at roughly $1.56 for one 4-minute track, so cost control is a credit ledger, not AWS tuning.
  • CloudFront's Free flat-rate plan gives you WAF, DDoS protection and IP rate limiting for $0, minus request logs.
  • Lambda durable functions keep the Music.ai polling loop in plain TypeScript, with no billed compute while it waits.
SAM (Sam's Web Site) is a parody. Not affiliated with, endorsed by, or in any way the fault of Amazon Web Services.