Build a Local AI Music Generator

Generate instrumental music from text prompts using Meta MusicGen on your GPU.

Introduction

30 Second Summary

AI music generators like Suno can create full songs from a text description. They're powerful, but they run on someone else's servers with usage limits and subscription fees.

In this project, you will build your own AI music generator that runs entirely on your local GPU. You'll create a web app where you type a description of the music you want and get a generated instrumental track you can play and download.

What You'll Build

A local web application powered by Meta's MusicGen model that generates instrumental music from text prompts, served through a FastAPI backend and a dark-themed browser interface.

By the end of this project, you'll have:

  • A FastAPI backend that loads MusicGen-small on your GPU and generates audio from text prompts with no usage limits or API costs.
  • A Suno-inspired web frontend with prompt input, generation controls, genre preset buttons, and an audio player.
  • A generation history panel that stores your past creations with playback and download options.
  • Secret Mission: Connect a local Ollama model to enhance your short prompts into detailed music descriptions for higher-quality results.

Are there any prerequisites?

You'll need an NVIDIA GPU with at least 8 GB of VRAM, Python installed, and Cursor IDE ready to go. Basic familiarity with running terminal commands is helpful but not required.

Set Up the Project Environment

Before you can generate music with AI, your machine needs the right software stack. The core engine is PyTorch running on your NVIDIA GPU, which accelerates the heavy computation behind audio generation.

In this step, you'll install PyTorch with GPU acceleration, set up the project folder with its dependencies, and confirm that your GPU can actually run Meta's MusicGen model.

In this step, get ready to:
  • Install PyTorch with CUDA support and verify GPU access.
  • Create the project folder and install dependencies.
  • Download the MusicGen model and confirm GPU inference works.
Install PyTorch with CUDA support

PyTorch is the deep learning framework that MusicGen runs on. The install command varies depending on which CUDA version your GPU driver supports, so you'll grab the correct command from the official PyTorch website.

  • In Cursor, open the integrated terminal by pressing Ctrl+` (backtick key, same shortcut on macOS and Windows).
  • Visit the PyTorch Get Started page in your browser.
  • In the install configurator, select your operating system.
  • Select Pip as the package manager.
  • Select Python as the language.
  • Select a CUDA version (CUDA 12.6 is a safe default for most modern GPUs).
  • Copy the generated command and run it in your Cursor terminal.

What is CUDA?

CUDA is NVIDIA's toolkit that lets software run computations directly on your GPU instead of the CPU. PyTorch binaries ship with their own CUDA runtime bundled inside, so you do not need to install a separate CUDA toolkit. You only need NVIDIA drivers already installed on your machine.

The install may take a few minutes since PyTorch is a large package.

  • After the install finishes, verify GPU access by running this command:
python -c "import torch; print(torch.cuda.is_available())"

✔️ I see True

Your GPU is detected and ready for PyTorch. Move on to the next substep.

ⓧ I see False

This means PyTorch installed a CPU-only binary instead of the CUDA-enabled version. This is the most common cause on Windows.

  • Uninstall the current PyTorch installation by running:
pip uninstall torch -y
  • Go back to the PyTorch Get Started page and double-check that you selected a CUDA compute platform (not CPU).
  • Copy and re-run the exact command shown on the page.
  • Run the verification command again to confirm it now prints True.

If it still shows False, check that your NVIDIA drivers are up to date. You can verify your driver is working by running nvidia-smi in your terminal.

Create the project folder and install dependencies

Now that PyTorch is working with your GPU, you'll create the project folder and install the remaining libraries MusicGen needs.

  • Move to your Desktop by running this command:
cd ~/Desktop
  • Create your project folder and move into it by running these commands:
mkdir mini-suno
cd mini-suno
  • Create the static directory that will hold your frontend files later:
mkdir static
  • Confirm the folder structure by running:
ls

You should see static listed in the output.

  • Create the requirements.txt file that lists your project's dependencies. In Cursor, click File in the top menu bar.
  • Click Open Folder.
  • Select the mini-suno folder on your Desktop, then click Open.

Why open the folder in Cursor?

Opening the project folder makes Cursor aware of all your files. The integrated terminal also automatically sets its working directory to the project root, so all commands you run will target the right location.

  • In the Explorer sidebar, hover over the MINI-SUNO heading and click the New File icon.
  • Name the file requirements.txt and press Enter.
  • Paste the following content into requirements.txt:
torch
transformers>=4.31.0
scipy
fastapi
uvicorn[standard]

What are these dependencies?

  • transformers is the Hugging Face library that provides the MusicGen model and processor.
  • scipy handles writing the generated audio data to WAV file format.
  • fastapi is a modern Python web framework for building your music generation API.
  • uvicorn[standard] is the server that runs your FastAPI application.
  • Save requirements.txt by pressing Cmd+S (macOS) or Ctrl+S (Windows).
  • Open the integrated terminal by pressing Ctrl+` and install the dependencies by running:
pip install -r requirements.txt

This installs transformers, scipy, fastapi, and uvicorn. PyTorch is already installed from the previous substep so pip will skip it.

Seeing install errors?

  • Make sure your terminal is inside the mini-suno folder. Run pwd (macOS/Linux) or cd (Windows) to check your current directory.
  • If you see a permissions error, try running pip install --user -r requirements.txt instead.

Still stuck? Help me fix a pip install error for my Mini-Suno project dependencies

✔️ Awesome, I've got everything!

Great. Double check you have saved requirements.txt and that the install completed without errors.

ⓧ I'd like to double check the full code

Here is the full requirements.txt file for comparison:

torch
transformers>=4.31.0
scipy
fastapi
uvicorn[standard]
Download and test the MusicGen model

The MusicGen-small model is about 2 GB. The first time you use it, Hugging Face downloads and caches it on your machine. After that, future runs load instantly from the cache.

You'll run a quick test script that loads the model onto your GPU and generates 2 seconds of audio to confirm everything works end-to-end.

  • In the Explorer sidebar, click the New File icon and name the file test_model.py.
  • Paste the following test script into test_model.py:
import torch
from transformers import AutoProcessor, MusicgenForConditionalGeneration

print("Loading MusicGen-small model...")
processor = AutoProcessor.from_pretrained("facebook/musicgen-small")
model = MusicgenForConditionalGeneration.from_pretrained("facebook/musicgen-small")
model = model.to("cuda")
print(f"Model loaded on: {model.device}")

# Generate 2 seconds of audio (50 tokens per second)
inputs = processor(
    text=["happy acoustic guitar melody"],
    padding=True,
    return_tensors="pt",
).to("cuda")

with torch.no_grad():
    audio_values = model.generate(**inputs, max_new_tokens=100)

print(f"Generated audio shape: {audio_values.shape}")
print("GPU inference working! Model is ready.")

What does this script do?

  • AutoProcessor and MusicgenForConditionalGeneration are the two components that turn a text prompt into audio. The processor tokenizes your text, and the model generates audio tokens from those.
  • model.to("cuda") moves the model weights onto your GPU for fast inference.
  • max_new_tokens=100 generates approximately 2 seconds of audio because MusicGen produces about 50 tokens per second.
  • Save test_model.py and run it in the terminal:
python test_model.py

First run takes a few minutes

The first time you run this, Hugging Face downloads the MusicGen-small model (~2 GB). Subsequent runs will load the model from cache in seconds.

When the script finishes, you should see output similar to this:

Loading MusicGen-small model...
Model loaded on: cuda:0
Generated audio shape: torch.Size([1, 1, 6400])
GPU inference working! Model is ready.

Seeing an out of memory error?

  • MusicGen-small needs about 5 GB of GPU VRAM. Close other applications using your GPU (games, other model servers, video editors) and try again.
  • If you have less than 8 GB of VRAM total, the model may not fit alongside your operating system's GPU usage. Check your available VRAM with nvidia-smi in the terminal.

Need more help? Help me fix a CUDA out of memory error when loading MusicGen-small

Your environment is locked and loaded. Next up, you'll build the FastAPI backend that turns text prompts into full music tracks on demand.

Build the MusicGen API Backend

You have your environment ready and MusicGen downloaded. Now you need a way to actually use the model.

In this step, you will build a FastAPI backend that loads MusicGen once at startup and exposes it through a REST API endpoint. Any request to that endpoint sends a text prompt to the model and gets back a WAV audio file.

In this step, get ready to:
  • Create a FastAPI application that loads MusicGen on your GPU at startup.
  • Build a POST endpoint that generates music from text prompts.
  • Add a health check endpoint and test the API with curl.
Create the FastAPI app and load MusicGen at startup

Your backend needs to load the MusicGen model into GPU memory when the server starts. Loading a model takes time, so you only want to do it once and reuse it for every request.

  • In Cursor, create a new file called app.py in the root of your project folder (the same level as requirements.txt).
  • Add the following imports, app initialization, and model loading code to app.py:
import io
import uuid
import torch
import scipy.io.wavfile
import numpy as np
from fastapi import FastAPI
from fastapi.staticfiles import StaticFiles
from fastapi.responses import Response
from pydantic import BaseModel

# Create the FastAPI application
app = FastAPI(title="Mini-Suno")

# Load MusicGen model into GPU memory at startup
print("Loading MusicGen model... (this may take a minute on first run)")
from transformers import AutoProcessor, MusicgenForConditionalGeneration

processor = AutoProcessor.from_pretrained("facebook/musicgen-small")
model = MusicgenForConditionalGeneration.from_pretrained("facebook/musicgen-small")
model = model.to("cuda")
print("Model loaded on GPU!")

What does this code do?

This block handles everything that happens once when the server starts:

  • app = FastAPI(title="Mini-Suno") creates the web application.
  • AutoProcessor handles tokenizing your text prompts into a format the model understands.
  • MusicgenForConditionalGeneration is the actual MusicGen model that generates audio.
  • model.to("cuda") moves the model onto your GPU for fast inference.

Why load the model at the top level?

Loading a model from disk into GPU memory takes 10-30 seconds. If you loaded it inside the endpoint function, every single request would wait that long.

By loading at the top level, the model loads once when the server starts. Every request after that reuses the already-loaded model and only takes seconds to generate.

  • Save app.py.

You will not see a visible change yet. The next chunk adds the generation endpoint that uses this model.

Seeing a red underline on an import?

Cursor may show warnings for transformers or scipy imports if it has not detected your Python environment. This does not affect running the server. The imports will resolve when uvicorn loads the file.

Help me fix import warnings in Cursor

Build the music generation endpoint

The generation endpoint needs to accept a text prompt along with control parameters like duration and temperature. You will define a request schema and then implement the generation logic that converts text into a WAV audio file.

  • Below the model loading code in app.py, add the request schema that defines what parameters your endpoint accepts:
# Define the shape of incoming generation requests
class GenerateRequest(BaseModel):
    prompt: str
    duration: float = 8.0
    temperature: float = 1.0
    top_k: int = 250

What are these parameters?

  • prompt is the text description of the music (required, no default).
  • duration controls how many seconds of audio to generate (default 8 seconds).
  • temperature controls randomness. Higher values produce more creative results. Lower values are more predictable.
  • top_k limits the model to choosing from the top 250 most likely next tokens at each step.
  • Now add the /generate endpoint below the GenerateRequest class:
@app.post("/generate")
async def generate_music(request: GenerateRequest):
    # MusicGen uses ~50 tokens per second of audio
    tokens_per_second = 50
    max_new_tokens = int(request.duration * tokens_per_second)

    # Tokenize the text prompt and send to GPU
    inputs = processor(
        text=[request.prompt],
        padding=True,
        return_tensors="pt",
    ).to("cuda")

    # Generate audio with no gradient tracking (inference only)
    with torch.no_grad():
        audio_values = model.generate(
            **inputs,
            max_new_tokens=max_new_tokens,
            do_sample=True,
            temperature=request.temperature,
            top_k=request.top_k,
        )

    # Convert the raw tensor to a WAV file in memory
    sampling_rate = model.config.audio_encoder.sampling_rate
    audio_data = audio_values[0, 0].cpu().numpy()

    buffer = io.BytesIO()
    scipy.io.wavfile.write(buffer, rate=sampling_rate, data=audio_data)
    buffer.seek(0)

    # Return the WAV file as a downloadable response
    filename = f"generated_{uuid.uuid4().hex[:8]}.wav"
    return Response(
        content=buffer.read(),
        media_type="audio/wav",
        headers={"Content-Disposition": f"attachment; filename={filename}"},
    )

How does the generation logic work?

The endpoint follows a three-stage pipeline:

  • Tokenize: the processor converts your text prompt into numerical tokens the model understands, then sends them to the GPU.
  • Generate: the model produces raw audio data as a tensor. do_sample=True enables sampling for more creative output. torch.no_grad() disables gradient calculation to save memory during inference.
  • Encode: the raw audio tensor is moved back to CPU, converted to a NumPy array, and written as a WAV file using scipy. The response streams this WAV file back to the caller.
  • Save app.py.

You will not see a change yet. The next section adds a health check and tests the full API.

Add a health check and test the API

Before connecting a frontend, you want to confirm the server starts and the model loads correctly. A health check endpoint gives you a quick way to verify this from the terminal.

  • Add the following code at the bottom of app.py, below the /generate endpoint:
# Health check endpoint for quick server verification
@app.get("/health")
async def health_check():
    return {
        "status": "ok",
        "model": "facebook/musicgen-small",
        "device": str(model.device),
        "gpu_name": torch.cuda.get_device_name(0) if torch.cuda.is_available() else "N/A",
    }

# Serve the static frontend files (must come after route definitions)
app.mount("/", StaticFiles(directory="static", html=True), name="static")

Why does the static mount come last?

The StaticFiles mount at / catches all requests that do not match a defined route. If it came before your /generate and /health routes, it would intercept those API requests and try to serve them as files.

  • Save app.py.

✔️ Awesome, I've got everything!

Great. Make sure you have saved app.py before moving on.

ⓧ I'd like to double check the full code

Here is the complete app.py file so far:

import io
import uuid
import torch
import scipy.io.wavfile
import numpy as np
from fastapi import FastAPI
from fastapi.staticfiles import StaticFiles
from fastapi.responses import Response
from pydantic import BaseModel

app = FastAPI(title="Mini-Suno")

print("Loading MusicGen model... (this may take a minute on first run)")
from transformers import AutoProcessor, MusicgenForConditionalGeneration

processor = AutoProcessor.from_pretrained("facebook/musicgen-small")
model = MusicgenForConditionalGeneration.from_pretrained("facebook/musicgen-small")
model = model.to("cuda")
print("Model loaded on GPU!")


class GenerateRequest(BaseModel):
    prompt: str
    duration: float = 8.0
    temperature: float = 1.0
    top_k: int = 250


@app.post("/generate")
async def generate_music(request: GenerateRequest):
    tokens_per_second = 50
    max_new_tokens = int(request.duration * tokens_per_second)

    inputs = processor(
        text=[request.prompt],
        padding=True,
        return_tensors="pt",
    ).to("cuda")

    with torch.no_grad():
        audio_values = model.generate(
            **inputs,
            max_new_tokens=max_new_tokens,
            do_sample=True,
            temperature=request.temperature,
            top_k=request.top_k,
        )

    sampling_rate = model.config.audio_encoder.sampling_rate
    audio_data = audio_values[0, 0].cpu().numpy()

    buffer = io.BytesIO()
    scipy.io.wavfile.write(buffer, rate=sampling_rate, data=audio_data)
    buffer.seek(0)

    filename = f"generated_{uuid.uuid4().hex[:8]}.wav"
    return Response(
        content=buffer.read(),
        media_type="audio/wav",
        headers={"Content-Disposition": f"attachment; filename={filename}"},
    )


@app.get("/health")
async def health_check():
    return {
        "status": "ok",
        "model": "facebook/musicgen-small",
        "device": str(model.device),
        "gpu_name": torch.cuda.get_device_name(0) if torch.cuda.is_available() else "N/A",
    }


app.mount("/", StaticFiles(directory="static", html=True), name="static")

Now start the server so you can test it.

  • Open Cursor's terminal and start the server by running:
python -m uvicorn app:app --reload

You should see the model loading messages followed by a line like Uvicorn running on http://127.0.0.1:8000.

What does this command do?

  • python -m uvicorn runs the Uvicorn ASGI server.
  • app:app tells Uvicorn to look in app.py for the object called app.
  • --reload makes the server restart automatically when you save code changes.
  • Open a second terminal in Cursor by clicking the + icon in the terminal panel.
  • Test the health check endpoint by running:
curl http://localhost:8000/health

You should see a JSON response containing your GPU name and a status of ok.

Server not starting?

  • If you see a ModuleNotFoundError, make sure you activated the same Python environment where you installed your dependencies in Step 1.
  • If you see an error about the static directory, confirm the static/ folder exists in your project root (you created it in Step 1).
  • If the model fails to load on CUDA, check that torch.cuda.is_available() returns True in a Python shell. Your NVIDIA drivers may need updating.

Help me debug my server startup issue

Your API backend is live and responding. Next up, you will build the web frontend that gives you a Suno-inspired interface for typing prompts and playing the generated audio.

Create the Web Frontend

Your backend API is ready to generate music from text prompts. Now you need a way to actually interact with it.

In this step, you'll build a web frontend that sends prompts to your FastAPI backend and plays back the generated audio. The design takes inspiration from Suno's dark, modern interface.

In this step, get ready to:
  • Build the HTML structure with a prompt textarea, generate button, and audio player.
  • Style it with a dark theme using accent gradients and card-based layout.
  • Wire up JavaScript to POST prompts to the API and play the returned audio.
Build the HTML structure

Every web app starts with its HTML skeleton. You need three key elements: a textarea for the music prompt, a button to trigger generation, and an audio player to hear the result.

  • In Cursor's Explorer sidebar, right-click the static folder and select New File.
  • Name the file index.html.
  • Paste the following HTML structure into the file:
<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>Mini-Suno | AI Music Generator</title>
    <style>
    </style>
</head>
<body>
    <div class="container">
        <h1>Mini-Suno</h1>
        <p class="subtitle">AI Music Generator - Powered by MusicGen on your local GPU</p>
        <div class="card">
            <textarea id="prompt" placeholder="Describe the music you want..."></textarea>
            <button class="generate-btn" id="generate-btn" onclick="generateMusic()">Generate Music</button>
            <div class="spinner" id="spinner">Generating music... this takes 20-30 seconds</div>
            <div class="audio-result" id="audio-result">
                <audio id="audio-player" controls></audio>
                <a class="download-link" id="download-link" download="generated.wav">Download WAV</a>
            </div>
        </div>
    </div>
    <script>
    </script>
</body>
</html>

What does this HTML do?

  • The textarea#prompt is where the learner types their music description.
  • The button.generate-btn calls generateMusic() when clicked.
  • The div.spinner shows a loading message while the model generates audio (hidden by default).
  • The div.audio-result contains an audio player and download link. It stays hidden until generation completes.
  • Save static/index.html.
Style it with a dark Suno-inspired theme

Suno uses a dark interface with purple-pink gradient accents. You'll replicate that feel with CSS custom properties and a card-based layout.

  • In static/index.html, find the empty <style> tag in the <head> section.
  • Paste the following CSS rules between the opening and closing <style> tags:
        * { margin: 0; padding: 0; box-sizing: border-box; }
        body {
            font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
            background: #0a0a0f;
            color: #e0e0e0;
            min-height: 100vh;
            padding: 2rem;
        }
        .container { max-width: 800px; margin: 0 auto; }
        h1 {
            font-size: 2rem;
            margin-bottom: 0.5rem;
            background: linear-gradient(135deg, #8b5cf6, #ec4899);
            -webkit-background-clip: text;
            -webkit-text-fill-color: transparent;
        }
        .subtitle { color: #888; margin-bottom: 2rem; }
        .card {
            background: #1a1a2e;
            border-radius: 12px;
            padding: 1.5rem;
            margin-bottom: 1.5rem;
            border: 1px solid #2a2a4a;
        }
        textarea {
            width: 100%;
            height: 80px;
            background: #0f0f1a;
            border: 1px solid #3a3a5a;
            border-radius: 8px;
            color: #e0e0e0;
            padding: 12px;
            font-size: 1rem;
            resize: vertical;
            font-family: inherit;
        }
        textarea:focus { outline: none; border-color: #8b5cf6; }
        .generate-btn {
            width: 100%;
            padding: 14px;
            border-radius: 8px;
            border: none;
            background: linear-gradient(135deg, #8b5cf6, #ec4899);
            color: white;
            font-size: 1.1rem;
            font-weight: 600;
            cursor: pointer;
            margin-top: 1rem;
            transition: opacity 0.2s;
        }
        .generate-btn:hover { opacity: 0.9; }
        .generate-btn:disabled { opacity: 0.5; cursor: not-allowed; }
        .spinner {
            display: none;
            text-align: center;
            padding: 1rem;
            color: #8b5cf6;
        }
        .spinner.active { display: block; }
        .audio-result {
            margin-top: 1rem;
            padding: 1rem;
            background: #0f0f1a;
            border-radius: 8px;
            display: none;
        }
        .audio-result.visible { display: block; }
        .audio-result audio { width: 100%; margin: 0.5rem 0; }
        .download-link {
            color: #8b5cf6;
            text-decoration: none;
            font-size: 0.9rem;
        }
        .download-link:hover { text-decoration: underline; }

What's happening in this CSS?

The background #0a0a0f creates a near-black base. Cards use #1a1a2e for subtle contrast against the page.

The gradient from #8b5cf6 (purple) to #ec4899 (pink) is used on the heading text and the generate button. This mirrors Suno's signature accent colors.

The spinner and audio-result elements are hidden by default (display: none). JavaScript toggles them visible by adding the .active and .visible classes.

  • Save static/index.html.
Wire up the JavaScript to call your API

The JavaScript needs to grab the prompt text, POST it to your /generate endpoint, and pipe the returned audio blob into the browser's audio player.

  • In static/index.html, find the empty <script> tag at the bottom of the file.
  • Paste the following JavaScript between the opening and closing <script> tags:
        async function generateMusic() {
            const prompt = document.getElementById('prompt').value.trim();
            if (!prompt) { alert('Please enter a prompt'); return; }
            const btn = document.getElementById('generate-btn');
            const spinner = document.getElementById('spinner');
            const result = document.getElementById('audio-result');
            btn.disabled = true;
            spinner.classList.add('active');
            result.classList.remove('visible');
            try {
                const response = await fetch('/generate', {
                    method: 'POST',
                    headers: { 'Content-Type': 'application/json' },
                    body: JSON.stringify({ prompt: prompt, duration: 8.0, temperature: 1.0, top_k: 250 })
                });
                if (!response.ok) throw new Error('Generation failed');
                const blob = await response.blob();
                const url = URL.createObjectURL(blob);
                document.getElementById('audio-player').src = url;
                result.classList.add('visible');
                document.getElementById('download-link').href = url;
            } catch (error) {
                alert('Error generating music: ' + error.message);
            } finally {
                btn.disabled = false;
                spinner.classList.remove('active');
            }
        }

What does this JavaScript do?

  • It disables the button and shows the spinner while the model works (20-30 seconds on most GPUs).
  • It sends a POST request to /generate with the prompt, a default duration of 8 seconds, temperature of 1.0, and top_k of 250.
  • The response comes back as a raw WAV blob. URL.createObjectURL(blob) turns it into a playable URL for the audio element.
  • The same URL is set on the download link so the learner can save the file.
  • Save static/index.html.
  • Start the server by running this command in your terminal:
python -m uvicorn app:app --reload
  • Open your browser and navigate to http://localhost:8000.

You should see a dark page with the Mini-Suno heading in a purple-pink gradient, a prompt textarea, and a Generate Music button.

  • Type lo-fi chill beats with soft piano into the textarea and click Generate Music.

After 20-30 seconds, an audio player appears below the button with your generated track. You can play it directly in the browser or click the Download WAV link.

Page not loading or showing a blank screen?

  • Make sure the server is running. You should see Uvicorn running on http://127.0.0.1:8000 in the terminal.
  • Check that your file is saved at static/index.html (not in the project root). The StaticFiles mount in app.py looks for files inside the static/ directory.
  • If the generate button does nothing, open your browser's DevTools console (F12) and look for error messages.

Help me debug my Mini-Suno frontend

✔️ Awesome, I've got everything!

Great. Double check you've saved static/index.html and your server is still running.

ⓧ I'd like to double check the full code

<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>Mini-Suno | AI Music Generator</title>
    <style>
        * { margin: 0; padding: 0; box-sizing: border-box; }
        body {
            font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
            background: #0a0a0f;
            color: #e0e0e0;
            min-height: 100vh;
            padding: 2rem;
        }
        .container { max-width: 800px; margin: 0 auto; }
        h1 {
            font-size: 2rem;
            margin-bottom: 0.5rem;
            background: linear-gradient(135deg, #8b5cf6, #ec4899);
            -webkit-background-clip: text;
            -webkit-text-fill-color: transparent;
        }
        .subtitle { color: #888; margin-bottom: 2rem; }
        .card {
            background: #1a1a2e;
            border-radius: 12px;
            padding: 1.5rem;
            margin-bottom: 1.5rem;
            border: 1px solid #2a2a4a;
        }
        textarea {
            width: 100%;
            height: 80px;
            background: #0f0f1a;
            border: 1px solid #3a3a5a;
            border-radius: 8px;
            color: #e0e0e0;
            padding: 12px;
            font-size: 1rem;
            resize: vertical;
            font-family: inherit;
        }
        textarea:focus { outline: none; border-color: #8b5cf6; }
        .generate-btn {
            width: 100%;
            padding: 14px;
            border-radius: 8px;
            border: none;
            background: linear-gradient(135deg, #8b5cf6, #ec4899);
            color: white;
            font-size: 1.1rem;
            font-weight: 600;
            cursor: pointer;
            margin-top: 1rem;
            transition: opacity 0.2s;
        }
        .generate-btn:hover { opacity: 0.9; }
        .generate-btn:disabled { opacity: 0.5; cursor: not-allowed; }
        .spinner {
            display: none;
            text-align: center;
            padding: 1rem;
            color: #8b5cf6;
        }
        .spinner.active { display: block; }
        .audio-result {
            margin-top: 1rem;
            padding: 1rem;
            background: #0f0f1a;
            border-radius: 8px;
            display: none;
        }
        .audio-result.visible { display: block; }
        .audio-result audio { width: 100%; margin: 0.5rem 0; }
        .download-link {
            color: #8b5cf6;
            text-decoration: none;
            font-size: 0.9rem;
        }
        .download-link:hover { text-decoration: underline; }
    </style>
</head>
<body>
    <div class="container">
        <h1>Mini-Suno</h1>
        <p class="subtitle">AI Music Generator - Powered by MusicGen on your local GPU</p>
        <div class="card">
            <textarea id="prompt" placeholder="Describe the music you want..."></textarea>
            <button class="generate-btn" id="generate-btn" onclick="generateMusic()">Generate Music</button>
            <div class="spinner" id="spinner">Generating music... this takes 20-30 seconds</div>
            <div class="audio-result" id="audio-result">
                <audio id="audio-player" controls></audio>
                <a class="download-link" id="download-link" download="generated.wav">Download WAV</a>
            </div>
        </div>
    </div>
    <script>
        async function generateMusic() {
            const prompt = document.getElementById('prompt').value.trim();
            if (!prompt) { alert('Please enter a prompt'); return; }
            const btn = document.getElementById('generate-btn');
            const spinner = document.getElementById('spinner');
            const result = document.getElementById('audio-result');
            btn.disabled = true;
            spinner.classList.add('active');
            result.classList.remove('visible');
            try {
                const response = await fetch('/generate', {
                    method: 'POST',
                    headers: { 'Content-Type': 'application/json' },
                    body: JSON.stringify({ prompt: prompt, duration: 8.0, temperature: 1.0, top_k: 250 })
                });
                if (!response.ok) throw new Error('Generation failed');
                const blob = await response.blob();
                const url = URL.createObjectURL(blob);
                document.getElementById('audio-player').src = url;
                result.classList.add('visible');
                document.getElementById('download-link').href = url;
            } catch (error) {
                alert('Error generating music: ' + error.message);
            } finally {
                btn.disabled = false;
                spinner.classList.remove('active');
            }
        }
    </script>
</body>
</html>

Your frontend is live and generating music. Next up, you'll add sliders for duration and temperature, plus genre preset buttons for quick prompt ideas.

Add Generation Controls and Genre Presets

Your music generator works, but right now every generation uses the same hardcoded duration and temperature. That means you have to manually edit the prompt every time you want a different style or length.

In this step, you'll add interactive sliders that let you dial in exactly how long and how creative each generation should be. You'll also add genre preset buttons that fill in detailed prompts with a single click.

In this step, get ready to:
  • Add duration and temperature sliders with live value displays.
  • Create genre preset buttons that pre-fill the prompt textarea.
  • Wire up the JavaScript so sliders and presets feed into the generation request.
Add duration and temperature sliders

Duration controls how many seconds of audio MusicGen produces. Temperature controls how "creative" the model is. A low temperature produces predictable, safe output. A high temperature produces more surprising and varied results.

  • In static/index.html, find the closing textarea:focus rule inside your <style> block:
textarea:focus { outline: none; border-color: #8b5cf6; }
  • Add the following CSS directly below that rule to style the slider controls:
.controls { display: flex; gap: 1.5rem; margin: 1rem 0; flex-wrap: wrap; }
.control-group { flex: 1; min-width: 200px; }
.control-group label { display: block; font-size: 0.85rem; color: #aaa; margin-bottom: 4px; }
input[type="range"] { width: 100%; accent-color: #8b5cf6; }
.control-value { font-size: 0.8rem; color: #8b5cf6; }

What does this CSS do?

The .controls container uses flexbox to place the two sliders side by side. Each .control-group has a minimum width of 200px, so the sliders stack vertically on narrow screens.

The accent-color property on the range input changes the slider thumb and track to match your purple theme.

  • Now add the HTML for the sliders. Find the <textarea> element in the <body> section:
<textarea id="prompt" placeholder="Describe the music you want..."></textarea>
  • Update the placeholder text to include an example, then add the controls div directly below the textarea (but above the generate button). The updated textarea and new controls should look like this:
<textarea id="prompt" placeholder="Describe the music you want... e.g. 'lo-fi chill beats with soft piano and rain ambience'"></textarea>

<div class="controls">
    <div class="control-group">
        <label>Duration: <span class="control-value" id="duration-val">8s</span></label>
        <input type="range" id="duration" min="5" max="30" value="8" step="1">
    </div>
    <div class="control-group">
        <label>Temperature: <span class="control-value" id="temp-val">1.0</span></label>
        <input type="range" id="temperature" min="0.5" max="1.5" value="1.0" step="0.1">
    </div>
</div>

What do these sliders control?

  • Duration (5-30 seconds): MusicGen generates approximately 50 tokens per second of audio. A longer duration means more tokens and a longer wait.
  • Temperature (0.5-1.5): Lower values produce safer, more predictable melodies. Higher values introduce more variation and surprise.
  • The <span> elements with IDs duration-val and temp-val display the current slider value. You'll wire these up with JavaScript shortly.
  • Save static/index.html and refresh your browser at http://localhost:8000.
  • You should see two purple-accented sliders below the textarea, labeled "Duration: 8s" and "Temperature: 1.0".

Don't see the sliders?

  • Make sure the new .controls CSS rules are placed after textarea:focus and before .generate-btn in your stylesheet.
  • Check that the controls div HTML sits between the <textarea> and the <button class="generate-btn"> elements.

Still stuck? Help me debug why my sliders aren't showing up in the Mini-Suno UI.

Create genre preset buttons

Writing detailed prompts from scratch every time is tedious. Genre preset buttons give you one-click access to curated prompts that describe specific instruments, tempo, and production style.

  • In the <style> block, add the following CSS directly below the .control-value rule you just added:
.presets { display: flex; gap: 0.5rem; flex-wrap: wrap; margin: 1rem 0; }
.preset-btn {
    padding: 6px 14px;
    border-radius: 20px;
    border: 1px solid #3a3a5a;
    background: transparent;
    color: #ccc;
    cursor: pointer;
    font-size: 0.85rem;
    transition: all 0.2s;
}
.preset-btn:hover { border-color: #8b5cf6; color: #8b5cf6; }
  • Now add the preset buttons HTML. Place this div between the updated <textarea> and the <div class="controls"> you just added:
<div class="presets">
    <button class="preset-btn" data-prompt="lo-fi chill hip hop beats with soft piano, vinyl crackle, and mellow bass">Lo-fi</button>
    <button class="preset-btn" data-prompt="energetic EDM with punchy synth bass, driving four-on-the-floor beat, and soaring lead melody">EDM</button>
    <button class="preset-btn" data-prompt="smooth jazz trio with upright bass, brushed drums, and warm electric piano improvisation">Jazz</button>
    <button class="preset-btn" data-prompt="classical orchestral piece with sweeping strings, gentle woodwinds, and building crescendo">Classical</button>
    <button class="preset-btn" data-prompt="hard rock with distorted electric guitar riffs, heavy drums, and groovy bass line">Rock</button>
    <button class="preset-btn" data-prompt="ambient electronic soundscape with ethereal pads, gentle arpeggios, and spacious reverb">Ambient</button>
</div>

Why detailed prompts in the presets?

MusicGen responds better to specific descriptions than vague genres. "Lo-fi" alone produces generic results. But "lo-fi chill hip hop beats with soft piano, vinyl crackle, and mellow bass" tells the model exactly which instruments and textures to produce.

Each data-prompt attribute stores the full description. When clicked, JavaScript reads this attribute and fills the textarea.

  • Save static/index.html and refresh your browser.
  • You should see six pill-shaped buttons (Lo-fi, EDM, Jazz, Classical, Rock, Ambient) between the textarea and the sliders. They highlight purple on hover.

Buttons not appearing or misaligned?

  • Confirm the .presets CSS is placed after .control-value and before .generate-btn in your stylesheet.
  • Make sure the presets div is placed after the <textarea> and before the <div class="controls">.

Need help? Help me fix my genre preset buttons layout in the Mini-Suno UI.

Wire up the JavaScript for controls and presets

The sliders and buttons are visible now, but they don't do anything yet. You need JavaScript to update the displayed values as sliders move, fill the textarea when presets are clicked, and pass the actual slider values to the API.

  • Find the existing <script> block at the bottom of static/index.html and replace its entire contents with the following:
const durationSlider = document.getElementById('duration');
const tempSlider = document.getElementById('temperature');
const durationVal = document.getElementById('duration-val');
const tempVal = document.getElementById('temp-val');

// Update displayed values as sliders move
durationSlider.addEventListener('input', () => { durationVal.textContent = durationSlider.value + 's'; });
tempSlider.addEventListener('input', () => { tempVal.textContent = parseFloat(tempSlider.value).toFixed(1); });

// Fill textarea when a preset button is clicked
document.querySelectorAll('.preset-btn').forEach(btn => {
    btn.addEventListener('click', () => { document.getElementById('prompt').value = btn.dataset.prompt; });
});

async function generateMusic() {
    const prompt = document.getElementById('prompt').value.trim();
    if (!prompt) { alert('Please enter a prompt'); return; }
    const btn = document.getElementById('generate-btn');
    const spinner = document.getElementById('spinner');
    const result = document.getElementById('audio-result');
    btn.disabled = true;
    spinner.classList.add('active');
    result.classList.remove('visible');
    try {
        const response = await fetch('/generate', {
            method: 'POST',
            headers: { 'Content-Type': 'application/json' },
            body: JSON.stringify({ prompt: prompt, duration: parseFloat(durationSlider.value), temperature: parseFloat(tempSlider.value), top_k: 250 })
        });
        if (!response.ok) throw new Error('Generation failed');
        const blob = await response.blob();
        const url = URL.createObjectURL(blob);
        document.getElementById('audio-player').src = url;
        result.classList.add('visible');
        document.getElementById('download-link').href = url;
    } catch (error) {
        alert('Error generating music: ' + error.message);
    } finally {
        btn.disabled = false;
        spinner.classList.remove('active');
    }
}

What changed in this script?

  • Slider references: durationSlider and tempSlider grab the range inputs so their values can be read at generation time.
  • Live value display: The input event listeners update the <span> text as the learner drags each slider.
  • Preset click handler: btn.dataset.prompt reads the data-prompt attribute and sets it as the textarea value.
  • Dynamic request body: Instead of hardcoded duration: 8.0, the POST now sends parseFloat(durationSlider.value) and parseFloat(tempSlider.value) so the backend uses whatever the sliders are set to.
  • Loading state: The button disables and the spinner appears during the 20-30 second generation wait, then both reset when the request completes.
  • Save static/index.html and refresh your browser.
  • Drag the Duration slider. The label should update live (e.g. "Duration: 15s").
  • Click the Lo-fi preset button. The textarea should fill with the detailed lo-fi prompt.
  • Click Generate Music. The button should grey out and the spinner text should appear while the model processes.

Sliders not updating or presets not filling?

  • Open your browser's DevTools console (F12) and check for JavaScript errors. A typo in an element ID like duration-val vs durationVal will cause a null reference.
  • Make sure the entire old <script> content was replaced, not appended to. Having two generateMusic() definitions will cause unexpected behavior.
  • Verify the slider HTML uses id="duration" and id="temperature" exactly. These must match the getElementById calls in the script.

Still not working? Help me debug my slider and preset JavaScript in Mini-Suno.

✔️ Awesome, I've got everything!

Great. Double check you've saved static/index.html and confirm your sliders, presets, and generate button all respond correctly.

ⓧ I'd like to double check the full code

Here is the complete static/index.html file as it should look at the end of this step:

<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>Mini-Suno | AI Music Generator</title>
    <style>
        * { margin: 0; padding: 0; box-sizing: border-box; }
        body {
            font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
            background: #0a0a0f;
            color: #e0e0e0;
            min-height: 100vh;
            padding: 2rem;
        }
        .container { max-width: 800px; margin: 0 auto; }
        h1 {
            font-size: 2rem;
            margin-bottom: 0.5rem;
            background: linear-gradient(135deg, #8b5cf6, #ec4899);
            -webkit-background-clip: text;
            -webkit-text-fill-color: transparent;
        }
        .subtitle { color: #888; margin-bottom: 2rem; }
        .card {
            background: #1a1a2e;
            border-radius: 12px;
            padding: 1.5rem;
            margin-bottom: 1.5rem;
            border: 1px solid #2a2a4a;
        }
        textarea {
            width: 100%;
            height: 80px;
            background: #0f0f1a;
            border: 1px solid #3a3a5a;
            border-radius: 8px;
            color: #e0e0e0;
            padding: 12px;
            font-size: 1rem;
            resize: vertical;
            font-family: inherit;
        }
        textarea:focus { outline: none; border-color: #8b5cf6; }
        .controls { display: flex; gap: 1.5rem; margin: 1rem 0; flex-wrap: wrap; }
        .control-group { flex: 1; min-width: 200px; }
        .control-group label { display: block; font-size: 0.85rem; color: #aaa; margin-bottom: 4px; }
        input[type="range"] { width: 100%; accent-color: #8b5cf6; }
        .control-value { font-size: 0.8rem; color: #8b5cf6; }
        .presets { display: flex; gap: 0.5rem; flex-wrap: wrap; margin: 1rem 0; }
        .preset-btn {
            padding: 6px 14px;
            border-radius: 20px;
            border: 1px solid #3a3a5a;
            background: transparent;
            color: #ccc;
            cursor: pointer;
            font-size: 0.85rem;
            transition: all 0.2s;
        }
        .preset-btn:hover { border-color: #8b5cf6; color: #8b5cf6; }
        .generate-btn {
            width: 100%;
            padding: 14px;
            border-radius: 8px;
            border: none;
            background: linear-gradient(135deg, #8b5cf6, #ec4899);
            color: white;
            font-size: 1.1rem;
            font-weight: 600;
            cursor: pointer;
            margin-top: 1rem;
            transition: opacity 0.2s;
        }
        .generate-btn:hover { opacity: 0.9; }
        .generate-btn:disabled { opacity: 0.5; cursor: not-allowed; }
        .spinner { display: none; text-align: center; padding: 1rem; color: #8b5cf6; }
        .spinner.active { display: block; }
        .audio-result { margin-top: 1rem; padding: 1rem; background: #0f0f1a; border-radius: 8px; display: none; }
        .audio-result.visible { display: block; }
        .audio-result audio { width: 100%; margin: 0.5rem 0; }
        .download-link { color: #8b5cf6; text-decoration: none; font-size: 0.9rem; }
        .download-link:hover { text-decoration: underline; }
    </style>
</head>
<body>
    <div class="container">
        <h1>Mini-Suno</h1>
        <p class="subtitle">AI Music Generator - Powered by MusicGen on your local GPU</p>
        <div class="card">
            <textarea id="prompt" placeholder="Describe the music you want... e.g. 'lo-fi chill beats with soft piano and rain ambience'"></textarea>
            <div class="presets">
                <button class="preset-btn" data-prompt="lo-fi chill hip hop beats with soft piano, vinyl crackle, and mellow bass">Lo-fi</button>
                <button class="preset-btn" data-prompt="energetic EDM with punchy synth bass, driving four-on-the-floor beat, and soaring lead melody">EDM</button>
                <button class="preset-btn" data-prompt="smooth jazz trio with upright bass, brushed drums, and warm electric piano improvisation">Jazz</button>
                <button class="preset-btn" data-prompt="classical orchestral piece with sweeping strings, gentle woodwinds, and building crescendo">Classical</button>
                <button class="preset-btn" data-prompt="hard rock with distorted electric guitar riffs, heavy drums, and groovy bass line">Rock</button>
                <button class="preset-btn" data-prompt="ambient electronic soundscape with ethereal pads, gentle arpeggios, and spacious reverb">Ambient</button>
            </div>
            <div class="controls">
                <div class="control-group">
                    <label>Duration: <span class="control-value" id="duration-val">8s</span></label>
                    <input type="range" id="duration" min="5" max="30" value="8" step="1">
                </div>
                <div class="control-group">
                    <label>Temperature: <span class="control-value" id="temp-val">1.0</span></label>
                    <input type="range" id="temperature" min="0.5" max="1.5" value="1.0" step="0.1">
                </div>
            </div>
            <button class="generate-btn" id="generate-btn" onclick="generateMusic()">Generate Music</button>
            <div class="spinner" id="spinner">Generating music... this takes 20-30 seconds</div>
            <div class="audio-result" id="audio-result">
                <audio id="audio-player" controls></audio>
                <a class="download-link" id="download-link" download="generated.wav">Download WAV</a>
            </div>
        </div>
    </div>
    <script>
        const durationSlider = document.getElementById('duration');
        const tempSlider = document.getElementById('temperature');
        const durationVal = document.getElementById('duration-val');
        const tempVal = document.getElementById('temp-val');
        durationSlider.addEventListener('input', () => { durationVal.textContent = durationSlider.value + 's'; });
        tempSlider.addEventListener('input', () => { tempVal.textContent = parseFloat(tempSlider.value).toFixed(1); });
        document.querySelectorAll('.preset-btn').forEach(btn => {
            btn.addEventListener('click', () => { document.getElementById('prompt').value = btn.dataset.prompt; });
        });
        async function generateMusic() {
            const prompt = document.getElementById('prompt').value.trim();
            if (!prompt) { alert('Please enter a prompt'); return; }
            const btn = document.getElementById('generate-btn');
            const spinner = document.getElementById('spinner');
            const result = document.getElementById('audio-result');
            btn.disabled = true;
            spinner.classList.add('active');
            result.classList.remove('visible');
            try {
                const response = await fetch('/generate', {
                    method: 'POST',
                    headers: { 'Content-Type': 'application/json' },
                    body: JSON.stringify({ prompt: prompt, duration: parseFloat(durationSlider.value), temperature: parseFloat(tempSlider.value), top_k: 250 })
                });
                if (!response.ok) throw new Error('Generation failed');
                const blob = await response.blob();
                const url = URL.createObjectURL(blob);
                document.getElementById('audio-player').src = url;
                result.classList.add('visible');
                document.getElementById('download-link').href = url;
            } catch (error) {
                alert('Error generating music: ' + error.message);
            } finally {
                btn.disabled = false;
                spinner.classList.remove('active');
            }
        }
    </script>
</body>
</html>

Your generator now has real creative controls. You can pick a genre with one click, dial in the perfect duration, and adjust how adventurous the model gets. Next up, you'll add a history panel so you can compare and revisit your past generations.

Build the Generation History Panel

Your music generator works, but every time you generate a new track, the previous one disappears. There's no way to compare different prompts or revisit something you liked.

In this step, you'll add a history panel that stores each generation and displays it as a playable card. You'll be able to compare past creations, re-download favorites, and clear the list when you're done experimenting.

In this step, get ready to:
  • Add a history panel with CSS styling below the generator card.
  • Display each generation as a card with prompt text, a mini audio player, and download link.
  • Implement a clear history button and limit storage to the 10 most recent generations.
Add the history panel HTML and styles

The history panel is a second card that appears below the generator. It starts hidden and only shows up once you have at least one generation stored.

  • In static/index.html, find the closing </style> tag. Add these history-related styles just before it:
        .history-title { font-size: 1.2rem; margin-bottom: 1rem; color: #ccc; }
        .history-item { background: #0f0f1a; border-radius: 8px; padding: 1rem; margin-bottom: 0.75rem; border: 1px solid #2a2a4a; }
        .history-item .prompt-text { font-size: 0.85rem; color: #aaa; margin-bottom: 0.5rem; }
        .history-item audio { width: 100%; }
        .history-item .meta { display: flex; justify-content: space-between; align-items: center; margin-top: 0.5rem; }
        .history-item .timestamp { font-size: 0.75rem; color: #666; }
        .clear-btn { padding: 6px 12px; border-radius: 6px; border: 1px solid #3a3a5a; background: transparent; color: #888; cursor: pointer; font-size: 0.8rem; }
        .clear-btn:hover { border-color: #ef4444; color: #ef4444; }

What do these styles do?

  • .history-item styles each past generation as a dark card with a subtle border, matching the app's overall theme.
  • .meta uses flexbox to place the timestamp on the left and the download link on the right.
  • .clear-btn turns red on hover to signal a destructive action.
  • Now add the history panel HTML. Find the closing </div> that closes the first .card div (the one containing your generator UI). Add this new card directly below it, still inside the .container div:
        <div class="card" id="history-card" style="display:none;">
            <div style="display:flex; justify-content:space-between; align-items:center;">
                <h2 class="history-title">History</h2>
                <button class="clear-btn" onclick="clearHistory()">Clear All</button>
            </div>
            <div id="history-list"></div>
        </div>

What does this HTML do?

  • The card starts with style="display:none;" so it's completely hidden until the first generation completes.
  • The header row uses inline flexbox to push the "History" title to the left and the "Clear All" button to the right.
  • The empty history-list div is where JavaScript will inject history item cards dynamically.
  • Save static/index.html.
  • Refresh your browser at http://localhost:8000. You should see no visible change yet because the history card is hidden by default.

Seeing a broken layout?

Make sure the new <div class="card" id="history-card"> is placed after the closing </div> of the first card, not inside it. Both cards should be siblings inside the .container div.

Help me fix my history panel layout.

Display history items as playable cards

With the HTML skeleton in place, you need JavaScript that stores each generation and renders it as a card with the prompt, an audio player, and a download link.

  • In the <script> section of static/index.html, find the line that says async function generateMusic() {. Add the following code directly above it:
        let history = [];
  • Now scroll to the bottom of the <script> section, after the closing } of the generateMusic function. Add the addToHistory and renderHistory functions by pasting this code:
        function addToHistory(prompt, audioUrl) {
            const item = { prompt: prompt, audioUrl: audioUrl, timestamp: new Date().toLocaleTimeString() };
            history.unshift(item);
            if (history.length > 10) history.pop();
            renderHistory();
        }
        function renderHistory() {
            const card = document.getElementById('history-card');
            const list = document.getElementById('history-list');
            if (history.length === 0) { card.style.display = 'none'; return; }
            card.style.display = 'block';
            list.innerHTML = history.map(item => `
                <div class="history-item">
                    <div class="prompt-text">${item.prompt}</div>
                    <audio controls src="${item.audioUrl}"></audio>
                    <div class="meta">
                        <span class="timestamp">${item.timestamp}</span>
                        <a class="download-link" href="${item.audioUrl}" download="generated.wav">Download</a>
                    </div>
                </div>
            `).join('');
        }

What do these functions do?

  • addToHistory creates an object with the prompt, audio URL, and a human-readable timestamp. It prepends the item to the front of the array using unshift so the newest generation always appears first.
  • renderHistory toggles the history card's visibility. When there are items, it maps each one into an HTML card with the prompt text, a full-width audio player, and a meta row containing the timestamp and download link.
  • The if (history.length > 10) history.pop() line caps the list at 10 items by removing the oldest entry whenever a new one pushes past the limit.
  • Now wire up the history by adding an addToHistory call inside generateMusic. Find this line inside the try block:
                document.getElementById('download-link').href = url;
  • Add this line directly below it:
                addToHistory(prompt, url);
  • Save static/index.html and refresh your browser.
  • Generate a track. After the audio finishes loading, you should see a "History" section appear below the generator with your prompt text, a mini audio player, a timestamp, and a download link.

History panel not appearing after generation?

Make sure the addToHistory(prompt, url); call is inside the try block, after document.getElementById('download-link').href = url; and before the } catch line. If it's placed outside the try block, the url variable will be undefined.

Help me debug my history panel.

Implement the clear history button

The "Clear All" button in the history header already references a clearHistory() function. You just need to define it.

  • At the very end of the <script> section, after the renderHistory function, add the clearHistory function by pasting this code:
        function clearHistory() { history = []; renderHistory(); }

What does this function do?

clearHistory resets the history array to empty, then calls renderHistory() which detects zero items and hides the entire card. One click wipes the slate clean.

  • Save static/index.html and refresh your browser.
  • Generate a couple of tracks, then click the **Clear All** button in the history section. The history panel should disappear completely.

Clear button not working?

Open your browser's DevTools console (press **F12**). If you see an error like "clearHistory is not defined", make sure the function is defined in the global scope inside the <script> tag, not nested inside another function.

Help me fix my clear history button.

✔️ Awesome, I've got everything!

Great. Double check you've saved static/index.html and your history panel is working.

ⓧ I'd like to double check the full code

Here is the complete static/index.html file at this point:

<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>Mini-Suno | AI Music Generator</title>
    <style>
        * { margin: 0; padding: 0; box-sizing: border-box; }
        body {
            font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
            background: #0a0a0f;
            color: #e0e0e0;
            min-height: 100vh;
            padding: 2rem;
        }
        .container { max-width: 800px; margin: 0 auto; }
        h1 {
            font-size: 2rem;
            margin-bottom: 0.5rem;
            background: linear-gradient(135deg, #8b5cf6, #ec4899);
            -webkit-background-clip: text;
            -webkit-text-fill-color: transparent;
        }
        .subtitle { color: #888; margin-bottom: 2rem; }
        .card {
            background: #1a1a2e;
            border-radius: 12px;
            padding: 1.5rem;
            margin-bottom: 1.5rem;
            border: 1px solid #2a2a4a;
        }
        textarea {
            width: 100%;
            height: 80px;
            background: #0f0f1a;
            border: 1px solid #3a3a5a;
            border-radius: 8px;
            color: #e0e0e0;
            padding: 12px;
            font-size: 1rem;
            resize: vertical;
            font-family: inherit;
        }
        textarea:focus { outline: none; border-color: #8b5cf6; }
        .controls { display: flex; gap: 1.5rem; margin: 1rem 0; flex-wrap: wrap; }
        .control-group { flex: 1; min-width: 200px; }
        .control-group label { display: block; font-size: 0.85rem; color: #aaa; margin-bottom: 4px; }
        input[type="range"] { width: 100%; accent-color: #8b5cf6; }
        .control-value { font-size: 0.8rem; color: #8b5cf6; }
        .presets { display: flex; gap: 0.5rem; flex-wrap: wrap; margin: 1rem 0; }
        .preset-btn {
            padding: 6px 14px;
            border-radius: 20px;
            border: 1px solid #3a3a5a;
            background: transparent;
            color: #ccc;
            cursor: pointer;
            font-size: 0.85rem;
            transition: all 0.2s;
        }
        .preset-btn:hover { border-color: #8b5cf6; color: #8b5cf6; }
        .generate-btn {
            width: 100%;
            padding: 14px;
            border-radius: 8px;
            border: none;
            background: linear-gradient(135deg, #8b5cf6, #ec4899);
            color: white;
            font-size: 1.1rem;
            font-weight: 600;
            cursor: pointer;
            margin-top: 1rem;
            transition: opacity 0.2s;
        }
        .generate-btn:hover { opacity: 0.9; }
        .generate-btn:disabled { opacity: 0.5; cursor: not-allowed; }
        .spinner { display: none; text-align: center; padding: 1rem; color: #8b5cf6; }
        .spinner.active { display: block; }
        .audio-result { margin-top: 1rem; padding: 1rem; background: #0f0f1a; border-radius: 8px; display: none; }
        .audio-result.visible { display: block; }
        .audio-result audio { width: 100%; margin: 0.5rem 0; }
        .download-link { color: #8b5cf6; text-decoration: none; font-size: 0.9rem; }
        .download-link:hover { text-decoration: underline; }
        .history-title { font-size: 1.2rem; margin-bottom: 1rem; color: #ccc; }
        .history-item { background: #0f0f1a; border-radius: 8px; padding: 1rem; margin-bottom: 0.75rem; border: 1px solid #2a2a4a; }
        .history-item .prompt-text { font-size: 0.85rem; color: #aaa; margin-bottom: 0.5rem; }
        .history-item audio { width: 100%; }
        .history-item .meta { display: flex; justify-content: space-between; align-items: center; margin-top: 0.5rem; }
        .history-item .timestamp { font-size: 0.75rem; color: #666; }
        .clear-btn { padding: 6px 12px; border-radius: 6px; border: 1px solid #3a3a5a; background: transparent; color: #888; cursor: pointer; font-size: 0.8rem; }
        .clear-btn:hover { border-color: #ef4444; color: #ef4444; }
    </style>
</head>
<body>
    <div class="container">
        <h1>Mini-Suno</h1>
        <p class="subtitle">AI Music Generator - Powered by MusicGen on your local GPU</p>
        <div class="card">
            <textarea id="prompt" placeholder="Describe the music you want... e.g. 'lo-fi chill beats with soft piano and rain ambience'"></textarea>
            <div class="presets">
                <button class="preset-btn" data-prompt="lo-fi chill hip hop beats with soft piano, vinyl crackle, and mellow bass">Lo-fi</button>
                <button class="preset-btn" data-prompt="energetic EDM with punchy synth bass, driving four-on-the-floor beat, and soaring lead melody">EDM</button>
                <button class="preset-btn" data-prompt="smooth jazz trio with upright bass, brushed drums, and warm electric piano improvisation">Jazz</button>
                <button class="preset-btn" data-prompt="classical orchestral piece with sweeping strings, gentle woodwinds, and building crescendo">Classical</button>
                <button class="preset-btn" data-prompt="hard rock with distorted electric guitar riffs, heavy drums, and groovy bass line">Rock</button>
                <button class="preset-btn" data-prompt="ambient electronic soundscape with ethereal pads, gentle arpeggios, and spacious reverb">Ambient</button>
            </div>
            <div class="controls">
                <div class="control-group">
                    <label>Duration: <span class="control-value" id="duration-val">8s</span></label>
                    <input type="range" id="duration" min="5" max="30" value="8" step="1">
                </div>
                <div class="control-group">
                    <label>Temperature: <span class="control-value" id="temp-val">1.0</span></label>
                    <input type="range" id="temperature" min="0.5" max="1.5" value="1.0" step="0.1">
                </div>
            </div>
            <button class="generate-btn" id="generate-btn" onclick="generateMusic()">Generate Music</button>
            <div class="spinner" id="spinner">Generating music... this takes 20-30 seconds</div>
            <div class="audio-result" id="audio-result">
                <audio id="audio-player" controls></audio>
                <a class="download-link" id="download-link" download="generated.wav">Download WAV</a>
            </div>
        </div>
        <div class="card" id="history-card" style="display:none;">
            <div style="display:flex; justify-content:space-between; align-items:center;">
                <h2 class="history-title">History</h2>
                <button class="clear-btn" onclick="clearHistory()">Clear All</button>
            </div>
            <div id="history-list"></div>
        </div>
    </div>
    <script>
        const durationSlider = document.getElementById('duration');
        const tempSlider = document.getElementById('temperature');
        const durationVal = document.getElementById('duration-val');
        const tempVal = document.getElementById('temp-val');
        durationSlider.addEventListener('input', () => { durationVal.textContent = durationSlider.value + 's'; });
        tempSlider.addEventListener('input', () => { tempVal.textContent = parseFloat(tempSlider.value).toFixed(1); });
        document.querySelectorAll('.preset-btn').forEach(btn => {
            btn.addEventListener('click', () => { document.getElementById('prompt').value = btn.dataset.prompt; });
        });
        let history = [];
        async function generateMusic() {
            const prompt = document.getElementById('prompt').value.trim();
            if (!prompt) { alert('Please enter a prompt'); return; }
            const btn = document.getElementById('generate-btn');
            const spinner = document.getElementById('spinner');
            const result = document.getElementById('audio-result');
            btn.disabled = true;
            spinner.classList.add('active');
            result.classList.remove('visible');
            try {
                const response = await fetch('/generate', {
                    method: 'POST',
                    headers: { 'Content-Type': 'application/json' },
                    body: JSON.stringify({ prompt: prompt, duration: parseFloat(durationSlider.value), temperature: parseFloat(tempSlider.value), top_k: 250 })
                });
                if (!response.ok) throw new Error('Generation failed');
                const blob = await response.blob();
                const url = URL.createObjectURL(blob);
                document.getElementById('audio-player').src = url;
                result.classList.add('visible');
                document.getElementById('download-link').href = url;
                addToHistory(prompt, url);
            } catch (error) {
                alert('Error generating music: ' + error.message);
            } finally {
                btn.disabled = false;
                spinner.classList.remove('active');
            }
        }
        function addToHistory(prompt, audioUrl) {
            const item = { prompt: prompt, audioUrl: audioUrl, timestamp: new Date().toLocaleTimeString() };
            history.unshift(item);
            if (history.length > 10) history.pop();
            renderHistory();
        }
        function renderHistory() {
            const card = document.getElementById('history-card');
            const list = document.getElementById('history-list');
            if (history.length === 0) { card.style.display = 'none'; return; }
            card.style.display = 'block';
            list.innerHTML = history.map(item => `
                <div class="history-item">
                    <div class="prompt-text">${item.prompt}</div>
                    <audio controls src="${item.audioUrl}"></audio>
                    <div class="meta">
                        <span class="timestamp">${item.timestamp}</span>
                        <a class="download-link" href="${item.audioUrl}" download="generated.wav">Download</a>
                    </div>
                </div>
            `).join('');
        }
        function clearHistory() { history = []; renderHistory(); }
    </script>
</body>
</html>

Your Mini-Suno app is now fully functional. You can generate tracks, compare past creations in the history panel, download any of them, and clear the list when you want a fresh start. The entire app runs on your local GPU with zero API costs or usage limits.

Secret mission

Ollama Prompt Enhancer

Your music generator works, but short prompts like "chill beats" produce generic results. What if you could use a second AI model to expand those short descriptions into rich, detailed music production prompts before feeding them to MusicGen? In this secret mission, you'll connect a local Ollama LLM as a prompt enhancer that transforms simple ideas into studio-quality descriptions.

Clean Up Your Resources

Clean Up Your Resources

This project runs entirely on your local machine. There are no cloud resources, no running services, and no recurring costs.

Decide whether to keep your resources for future music generation, or free up disk space by removing the downloaded model files.

Resources you used:

  • MusicGen-small model files cached in your Hugging Face cache directory (~2GB disk space).
  • Project folder containing app.py, requirements.txt, and static/index.html.

Keep everything running

No action needed. Choose this if you plan to generate more music later.

  • The MusicGen model stays cached on disk. It won't re-download next time you run the app.
  • To start your app again later, open a terminal in your project folder and run:
python -m uvicorn app:app --reload
  • Your app will be available at http://localhost:8000 as before.

Pause - I'll come back to this later

Stop the running server to free up GPU memory. Your files and model cache stay on disk for next time.

  • In the terminal where your server is running, press Ctrl+C to stop the uvicorn process.
  • That's it. Your project files and cached model remain on disk until you're ready to come back.

Delete - I don't want to use this again

Remove all project files and the downloaded model to reclaim disk space.

  • In the terminal where your server is running, press Ctrl+C to stop it.
  • Delete the MusicGen model cache by running the command for your operating system:

macOS

rm -rf ~/.cache/huggingface/hub/models--facebook--musicgen-small

Windows

rmdir /s /q %USERPROFILE%\.cache\huggingface\hub\models--facebook--musicgen-small
  • Delete your project folder by running:

macOS

cd ~/Desktop
rm -rf mini-suno

Windows

cd %USERPROFILE%\Desktop
rmdir /s /q mini-suno
  • Optionally, uninstall the Python packages you installed for this project by running:
pip uninstall torch transformers scipy fastapi uvicorn httpx -y

Nice Work!

Nice Work!

You just built a fully functional AI music generator that runs entirely on your local GPU. No API costs, no usage limits, no sending your creative ideas to a third-party server.

You've learned how to:

  • Build a working AI music generator powered by Meta's MusicGen model running locally on your GPU with zero ongoing costs.
  • Create a FastAPI backend that loads a model once at startup and exposes a generation endpoint with configurable duration, temperature, and genre preset parameters.
  • Design a Suno-inspired web interface with prompt input, audio playback, download functionality, and a generation history panel to revisit past creations.
  • Secret Mission: Connected Ollama as a prompt enhancer to produce richer, more detailed music descriptions before generation.

Ready to quiz yourself?