DEV Community

Cover image for Build a Full-Stack Music Station with OpenRouter, Amazon Bedrock, and Nuxt
Erik Hanchett for AWS

Posted on

Build a Full-Stack Music Station with OpenRouter, Amazon Bedrock, and Nuxt

Have you ever been coding and then gotten into that flow state? You know where hours pass by , and it feels to you it's only ben a few minutes? Me too. One thing that really helps me get into that state is music. So I create my own music Lo-Fi server called compile and chill.

As a part of this project, I created three radio stations. Each station can generate a 16:9 scene with Amazon Bedrock, compose an instrumental loop with ElevenLabs, and turn an illustration into a six-second video through OpenRouter. Generated files live in private Amazon S3 storage and return to the browser through the Nuxt server.

I also added a Stream Deck API interface!

This tutorial shows how to build this radio station from start to finish.

The complete source code is available in the Compile & Chill repository.

Watch the full video on YouTube.

Prerequisites

You need the following tools for the complete build:

  • Node.js 22.19 or newer. The locked Nuxt 4.5.2 release requires Node 22.19+, 24.11+, or 26+.
  • npm 10 or newer.
  • An AWS account and a configured AWS Command Line Interface (AWS CLI) profile.
  • The AWS Serverless Application Model (AWS SAM) CLI for the private storage stack.
  • Access to Stability AI Stable Image Ultra through Amazon Bedrock in us-west-2.
  • An ElevenLabs API key for music generation.
  • An OpenRouter API key for animated scenes.

The provider credentials are optional. Without them, the UI, bundled scene, station switching, player, and Focus Block timer still work.

The identity running the app needs bedrock:InvokeModel plus bucket-scoped permissions for s3:GetObject, s3:PutObject, s3:DeleteObject, s3:DeleteObjectVersion, and s3:ListBucketVersions. Use a role or profile scoped to the station bucket rather than an administrator identity.

For this project I included infrastructure as code with SAM to help setup the AWS parts. It's also included in the repo.

Steps

1. Run the station without credentials

Pull down the repo and get started!

git clone https://github.com/ErikCH/compile-and-chill.git
cd compile-and-chill
npm ci
cp .env.example .env
npm run dev
Enter fullscreen mode Exit fullscreen mode

Open http://127.0.0.1:8231. You should see the station UI with the bundled placeholder scene.

The environment file separates each feature:

ELEVENLABS_API_KEY=
BEDROCK_IMAGE_REGION=us-west-2
BEDROCK_IMAGE_MODEL_ID=stability.stable-image-ultra-v1:1
AWS_PROFILE=default
STATION_S3_BUCKET=
STATION_S3_REGION=us-west-2
OPENROUTER_API_KEY=
STATION_BIND_HOST=127.0.0.1
NUXT_CONTROL_TOKEN=
Enter fullscreen mode Exit fullscreen mode

Do not add NUXT_PUBLIC_ to these names. Nuxt exposes public runtime configuration to browser code.

Keep STATION_BIND_HOST on loopback unless you need remote control. For Stream Deck access from another machine, bind to a private VPN interface address, never 0.0.0.0 or a public IP, and set a long random NUXT_CONTROL_TOKEN. The server refuses a non-loopback binding without that token.

2. Put provider calls behind Nuxt server routes

The browser should call your application. Compile & Chill keeps provider credentials in Nuxt runtimeConfig and places provider code under server/api/ and server/utils/.

Architecture diagram showing the browser calling Nuxt server routes, which connect to Amazon Bedrock, ElevenLabs, OpenRouter, and private Amazon S3 storage

The relevant section of nuxt.config.ts looks like this:

export default defineNuxtConfig({
  runtimeConfig: {
    elevenLabsApiKey: process.env.ELEVENLABS_API_KEY || '',
    imageGenerationRegion: process.env.BEDROCK_IMAGE_REGION || 'us-west-2',
    imageGenerationModel:
      process.env.BEDROCK_IMAGE_MODEL_ID || 'stability.stable-image-ultra-v1:1',
    stationStorageBucket: process.env.STATION_S3_BUCKET || '',
    stationStorageRegion: process.env.STATION_S3_REGION || 'us-west-2',
    openRouterApiKey: process.env.OPENROUTER_API_KEY || '',
  },
})
Enter fullscreen mode Exit fullscreen mode

This boundary gives you one place to validate requests, clamp paid parameters, redact signed URLs, and translate provider errors into useful HTTP responses.

You can inspect the complete routes in server/api.

3. Generate a scene with Amazon Bedrock

Authenticate with your AWS profile first. This example uses AWS IAM Identity Center:

aws sso login --profile your-profile
Enter fullscreen mode Exit fullscreen mode

Add the profile and model settings to .env:

AWS_PROFILE=your-profile
BEDROCK_IMAGE_REGION=us-west-2
BEDROCK_IMAGE_MODEL_ID=stability.stable-image-ultra-v1:1
Enter fullscreen mode Exit fullscreen mode

The image route validates the station mode, visual style, developer presentation, and optional seed. It then calls generateStationScene() in server/utils/station-generation.ts.

The provider payload is small:

const payload = {
  prompt: buildScenePrompt(mode, direction, {
    visualStyle,
    developer,
    diversitySeed: seed,
  }),
  // Shortened here. The source contains the complete negative prompt.
  negative_prompt: [
    'logos, readable text, watermark, rear view, motion blur, extra fingers',
    visualStyle === 'illustrated'
      ? 'photograph, live action, photorealistic skin, 3D render'
      : 'cartoon, anime, cel shading, flat illustration',
  ].join(', '),
  mode: 'text-to-image',
  aspect_ratio: '16:9',
  output_format: 'png',
  seed,
}

const response = await client.send(
  new InvokeModelCommand({
    modelId: config.imageGenerationModel,
    contentType: 'application/json',
    accept: 'application/json',
    body: JSON.stringify(payload),
  }),
)
Enter fullscreen mode Exit fullscreen mode

The server normalizes the seed before constructing the payload. This matters because Number(undefined) becomes NaN, and JSON serializes NaN as null. Stable Image Ultra expects an integer.

The prompt also ties visual style to output type. Static scenes use a realistic style. Animated scenes use a clearly illustrated 2D style so the source is visibly non-photorealistic. I added that distinction after a provider's person-likeness classifier refused one of the illustrated anchors.

Checkpoint: restart the app, open Visual settings, choose Realistic static, and generate one scene. If the model is unavailable in the configured region, the route returns the provider error and model ID instead of silently switching models.

4. Add looping music with ElevenLabs

The UI sends a 60-second duration, and the server should reject or normalize nonnumeric input before applying its 10-to-60-second bounds. This hardened version avoids sending null to the provider if another client calls the route with an invalid value:

const requestedDuration = Number(input.durationSeconds ?? 60)
const durationSeconds = Number.isFinite(requestedDuration)
  ? Math.min(60, Math.max(10, requestedDuration))
  : 60

const response = await fetch(
  'https://api.elevenlabs.io/v1/music?output_format=mp3_44100_128',
  {
    method: 'POST',
    headers: {
      'Content-Type': 'application/json',
      'xi-api-key': config.elevenLabsApiKey,
    },
    body: JSON.stringify({
      prompt: buildMusicPrompt(input.mode, input.direction),
      music_length_ms: durationSeconds * 1000,
      model_id: 'music_v2',
      generation_mode: 'loop',
      force_instrumental: true,
      sign_with_c2pa: true,
    }),
  },
)
Enter fullscreen mode Exit fullscreen mode

generation_mode: 'loop' is one part of making the repeat sound natural. The prompt also asks for no intro pickup, ending cadence, or fade-out. A track written like a normal song sounds broken when it jumps from the ending back to the first beat.

Compile & Chill keeps one generated track per station mode. Switching from Deep Work to Rainy Debug and back reuses the existing track instead of making another paid request.

Checkpoint: add ELEVENLABS_API_KEY to .env, restart the server, and generate one 60-second track. Switch modes and confirm that returning to the original mode reuses its audio.

5. Deploy the private media library

The repository includes an AWS SAM template managed by AWS CloudFormation. Validate and deploy it:

sam validate \
  --template-file infra/compile-and-chill-private-station.yaml \
  --region us-west-2

sam deploy \
  --region us-west-2 \
  --stack-name compile-and-chill-private-station \
  --template-file infra/compile-and-chill-private-station.yaml \
  --no-confirm-changeset \
  --no-fail-on-empty-changeset
Enter fullscreen mode Exit fullscreen mode

Read the media bucket output:

aws cloudformation describe-stacks \
  --region us-west-2 \
  --stack-name compile-and-chill-private-station \
  --query "Stacks[0].Outputs[?OutputKey=='StationMediaBucketName'].OutputValue" \
  --output text
Enter fullscreen mode Exit fullscreen mode

Copy that value into .env:

STATION_S3_BUCKET=YOUR_STACK_OUTPUT
STATION_S3_REGION=us-west-2
Enter fullscreen mode Exit fullscreen mode

The stack creates two versioned buckets. One stores generated station media. The other stores AWS CloudTrail data-event logs encrypted with AWS Key Management Service (AWS KMS). An Amazon CloudWatch alarm watches delete requests against the media bucket.

The application assigns each browser profile a random station ID in an HttpOnly, SameSite cookie. Objects use this layout:

stations/<station-id>/manifest.json
stations/<station-id>/<mode>/scene.png
stations/<station-id>/<mode>/scene.mp4
stations/<station-id>/<mode>/music.mp3
Enter fullscreen mode Exit fullscreen mode

The browser receives same-origin URLs such as /api/library/assets/deepWork/scene. The route reads the matching S3 object into server memory and returns it. The current implementation is a proxy, not a streaming pass-through, so account for memory when increasing the 50 MiB video limit.

The storage implementation is in server/utils/station-library.ts.

Checkpoint: regenerate a scene or track, reload the page in the same browser profile, and confirm that the media returns. Open a private browsing window and confirm that it starts with a separate station.

6. Turn one illustration into a seamless video loop

Animated mode generates a fresh illustrated anchor with Amazon Bedrock, saves it to S3, and creates a 300-second presigned read URL. OpenRouter needs that temporary URL because its video provider must download the input image.

The trick is to submit the same anchor as both frame constraints:

body: JSON.stringify({
  model: modelId,
  duration: 6,
  resolution: '720p',
  aspect_ratio: '16:9',
  generate_audio: false,
  prompt: [
    'Animate this 2D cartoon illustration of a fictional character.',
    'Use a locked-off camera and keep the illustrated composition unchanged.',
    'The provided illustration is both the first and last frame.',
    'Allow only tiny two-hand typing motion and one gentle blink.',
    'No zoom, pan, cut, identity change, new objects, or geometry changes.',
    'Create a silent seamless six-second ambient loop.',
  ].join(' '),
  frame_images: [
    {
      type: 'image_url',
      image_url: { url: sourceUrl },
      frame_type: 'first_frame',
    },
    {
      type: 'image_url',
      image_url: { url: sourceUrl },
      frame_type: 'last_frame',
    },
  ],
})
Enter fullscreen mode Exit fullscreen mode

Seven-step animation pipeline showing an Amazon Bedrock anchor saved to Amazon S3, signed for five minutes, used as both video endpoints, polled, downloaded, and saved

The model can move away from the anchor, but the matching frame constraints request a return to the same composition at the end. The prompt limits movement to a small typing motion and one blink, with no camera movement, reframing, cuts, new objects, or identity changes.

The app discovers compatible video models at runtime. A candidate must support six seconds, 720p, first_frame, and last_frame. It prefers Seedance 2.0 Fast, then checks a short fallback list.

The fallback policy is intentionally narrow:

  • HTTP 402 stops because credits are unavailable.
  • HTTP 429 stops because the account is rate limited.
  • An input-image moderation refusal may try the next compatible model because no video job was created.
  • Other failures stop rather than risk submitting and billing a duplicate job.

See the complete policy in server/utils/openrouter-video.ts.

Video generation takes minutes rather than seconds, and provider timing and prices change. Treat the request as a paid background job even if the first version runs inside one long HTTP request.

7. Report real progress during a long request

A spinner cannot tell the user whether a three-minute provider job is moving or stuck. Compile & Chill keeps a process-local job registry with six phases:

creating anchor
preparing source
submitting video
rendering loop
downloading loop
saving privately
Enter fullscreen mode Exit fullscreen mode

The browser creates a job ID, starts the generation request, and polls the Nuxt status endpoint every 750 milliseconds. Nuxt polls the provider every 15 seconds, up to 48 times. These are separate loops.

The registry also rejects a second active job for the same station with HTTP 409. That protects the user from duplicate clicks, but it is not a distributed queue. A process restart loses status, and multiple Nuxt instances would each have their own registry. For a multi-instance deployment, move job state and concurrency control to a shared data store and worker queue.

8. Validate the build

Run the same checks used for the source project:

npm test
npm run typecheck
npm run build
Enter fullscreen mode Exit fullscreen mode

The current repository has 26 passing tests plus a clean type check and production build.

Then test one provider at a time:

  1. Start with no credentials and verify the UI.
  2. Add Amazon Bedrock and generate one static scene.
  3. Add ElevenLabs and generate one track.
  4. Deploy the storage stack and verify reload persistence.
  5. Add OpenRouter last, acknowledge the paid operation, and generate one animated loop.

This order keeps failures small. If the video path fails, you already know that image generation, storage, and browser identity work independently.

Cleanup

The cleanup steps remove data permanently. Download anything you want to keep before continuing. The buckets and KMS key use retention policies, so they can remain after stack deletion and may continue to incur charges.

First, use Delete saved station inside each browser profile whose generated media should be removed. This purges object versions and delete markers under that profile's station prefix.

Before deleting the stack, record the retained resource names:

MEDIA_BUCKET=$(aws cloudformation describe-stacks \
  --region us-west-2 \
  --stack-name compile-and-chill-private-station \
  --query "Stacks[0].Outputs[?OutputKey=='StationMediaBucketName'].OutputValue" \
  --output text)

AUDIT_BUCKET=$(aws cloudformation describe-stacks \
  --region us-west-2 \
  --stack-name compile-and-chill-private-station \
  --query "Stacks[0].Outputs[?OutputKey=='CloudTrailLogBucketName'].OutputValue" \
  --output text)

KMS_KEY_ID=$(aws kms describe-key \
  --region us-west-2 \
  --key-id alias/compile-and-chill-cloudtrail \
  --query KeyMetadata.KeyId \
  --output text)
Enter fullscreen mode Exit fullscreen mode

Delete the non-retained stack resources:

sam delete \
  --stack-name compile-and-chill-private-station \
  --region us-west-2
Enter fullscreen mode Exit fullscreen mode

The media bucket, audit bucket, and KMS key are retained by design. Empty all versions and delete markers from both versioned buckets in the S3 console, then delete the buckets:

aws s3api delete-bucket --bucket "$MEDIA_BUCKET" --region us-west-2
aws s3api delete-bucket --bucket "$AUDIT_BUCKET" --region us-west-2
Enter fullscreen mode Exit fullscreen mode

Schedule the retained KMS key for deletion after confirming that you no longer need the encrypted audit logs:

aws kms schedule-key-deletion \
  --region us-west-2 \
  --key-id "$KMS_KEY_ID" \
  --pending-window-in-days 7
Enter fullscreen mode Exit fullscreen mode

KMS key deletion has a waiting period. After deletion, data encrypted only by that key cannot be recovered.

For local cleanup:

rm -rf node_modules .nuxt .output
rm .env
Enter fullscreen mode Exit fullscreen mode

Finale

This has been a very fun project. Let me know if you try it out!

You can explore every route, prompt, infrastructure resource, and test in the Compile & Chill source code. For another Nuxt project that keeps AI actions behind explicit user approval, read How to Build an AI Agent That Asks Permission First. Leave a comment on what you think! Thank!

Top comments (1)

Collapse
 
erikch profile image
Erik Hanchett AWS

What do you think?