Music Maker with Save Load Clear & Slider | PyShine

PyShine Music Maker – The Ultimate Step Sequencer Tutorial

Build a Visual Music Sequencer with Pygame, JSON Save/Load, Clear, Slider, and Real-Time Waveform Visualization

This comprehensive tutorial will help you build a feature-packed music maker app using Python and Pygame. You’ll not only learn to synthesize musical notes using NumPy but also understand threading, grid-based sequencing, waveform visualization, and dynamic user controls such as buttons and sliders.


Table of Contents

  1. Overview
  2. Setting Up Pygame
  3. Creating Synthetic Piano Notes
  4. Sequencer Pattern & Grid Layout
  5. Buttons: Save, Load, Clear
  6. Slider for Loop Control
  7. Waveform Visualization
  8. Playback Thread
  9. Main Loop & Event Handling
  10. Running the Application
  11. Key Learnings
  12. Further Ideas

Overview

The PyShine Music Maker combines interactive graphics and synthesized audio, offering an accessible introduction to digital sound processing and event-driven programming. It’s an interactive step sequencer that lets you visually create patterns, loop them, and hear your creations immediately. The program includes:

  • A 7-note synth (C–B) generated mathematically.
  • Grid-based pattern editing with 32 steps.
  • Save/Load features using JSON for persistence.
  • A Clear All button to reset instantly.
  • A loop slider to adjust playback range dynamically.
  • Waveform visualization for real-time feedback.

This tutorial is perfect for anyone learning how graphics, sound, and time-based logic interact in Python. You’ll gain insights into digital signal processing, event handling, and multi-threaded programming.

Each section is fully expanded, so by the end, you’ll have a working DAW-style sequencer and a deep understanding of how everything connects.

Dont worry, if you dont know about DAW. What is this DAW? Digital Audio Workstation (DAW) is a software platform for recording, editing, mixing, and producing audio digitally. Examples include Ableton Live, FL Studio, Logic Pro, and Cubase. Sequencer is a tool or module inside the DAW (or standalone) that lets you arrange musical events (notes, beats, or samples) over time in a grid or timeline format.

So a DAW-style sequencer mimics the way professional music software arranges musical notes and beats

  • Horizontal axis = time/steps (when notes play)
  • Vertical axis = pitch/note (which note plays)

Typically includes looping, step triggering, and visual feedback for composing music.


Setting Up Pygame

We start by setting up the core components. Pygame handles both graphics and audio, while NumPy handles waveform generation.

import pygame, sys, time, numpy as np, json, os, threading

pygame.init()
pygame.mixer.init(frequency=44100)

The pygame.mixer module is responsible for audio playback. The frequency 44100 Hz corresponds to CD-quality audio. After initializing the libraries, we define the display:

W, H = 480, 750
S = pygame.display.set_mode((W, H))
pygame.display.set_caption("PyShine Music Maker (Save/Load + Clear + Slider)")
font = pygame.font.SysFont(None, 26)
clock = pygame.time.Clock()
  • W and H define the window size.
  • font creates text for labels.
  • clock controls frame rate and smooth rendering.

We use a window size of 480×750 so that we have ample vertical space for the waveform, buttons, and grid. You’ll redraw everything every frame for smooth interaction.

💡 Tip: Redrawing the whole UI per frame may seem wasteful, but it’s standard in Pygame’s immediate mode rendering system and ensures crisp, consistent visuals.

Try running the above code — you should see a black window open with a title. That’s your base canvas for all future drawings.


Creating Synthetic Piano Notes

Music is vibration — and vibration is math. A musical note is simply a sine wave oscillating at a specific frequency. By summing multiple harmonics, we get richer sounds. Let’s create a function to generate synthetic piano tones.

fs = 44100  # samples per second
duration = 0.5  # seconds per note

def make_note(freq):
    t = np.linspace(0, duration, int(fs * duration), False)
    env = np.exp(-4.5 * t)
    wave = (0.6*np.sin(2*np.pi*freq*t) + 0.3*np.sin(2*np.pi*freq*2*t) + 0.1*np.sin(2*np.pi*freq*3*t)) * env
    audio = (wave * 32767).astype(np.int16)
    stereo = np.column_stack((audio, audio))
    return pygame.sndarray.make_sound(stereo), wave
  • np.linspace creates evenly spaced samples across the note’s duration.
  • env adds a natural decay envelope (simulating how a note fades).
  • The three sine terms form the base and its second and third harmonics.

Next, generate seven notes:

notes = {'C':261.63,'D':293.63,'E':329.63,'F':349.23,'G':392.00,'A':440.00,'B':493.88}
sounds, wave_data = {}, {}
for n, f in notes.items():
    snd, wave = make_note(f)
    sounds[n] = snd
    wave_data[n] = wave

Each sound object can be played instantly with sounds[n].play(), while wave_data[n] stores the corresponding waveform for visualization. Try printing one waveform — it’s an array of thousands of amplitude samples.

By synthesizing tones mathematically, you’re effectively writing your own instrument.


Sequencer Pattern & Grid Layout

A sequencer is a visual representation of musical timing. Here, rows are notes and columns are beats (steps). Each cell can be toggled on/off to represent a note being played.

Creating a Pattern Structure

We’ll define 32 steps and a default melody pattern.

steps = 32

def default_pattern():
    pat = {n:[0]*steps for n in notes}
    melody = [('C',0),('C',4),('G',8),('G',12),('A',16),('A',20),('G',24)]
    for n,p in melody: pat[n][p]=1
    return pat

pattern = default_pattern()
names = list(pattern.keys())

Each note has an array of length 32 containing 0s and 1s. A 1 means that note should play on that step.

Grid Geometry

margin_left = 80
grid_top = 320
cell_w = (W - margin_left - 40 - (steps-1)*3) / steps
cell_h = 50
spacing = 3

We calculate dynamic dimensions to ensure a uniform layout.

Drawing the Grid

def draw_grid():
    y = grid_top
    for n in names:
        x = margin_left
        for i in range(steps):
            color = (0,255,0) if pattern[n][i] else (60,60,60)
            rect = pygame.Rect(x, y, cell_w, cell_h)
            pygame.draw.rect(S, color, rect)
            x += cell_w + spacing
        label = font.render(n, True, (255,255,255))
        S.blit(label, (20, y+15))
        y += cell_h + spacing

Every redraw shows current pattern states. Toggling cells simply flips between 0 and 1.

💡 Pro Tip: This data-driven grid mirrors how MIDI clip editors work in professional DAWs.


Buttons: Save, Load, Clear

We’ll now add Save, Load, and Clear buttons for control. These features make your sequencer persistent and user-friendly.

Creating Buttons

btn_w, btn_h = 120, 50
save_btn = pygame.Rect(20, 40, btn_w, btn_h)
load_btn = pygame.Rect(180, 40, btn_w, btn_h)
clear_btn = pygame.Rect(340, 40, btn_w, btn_h)

Each pygame.Rect defines the clickable area for a button.

Drawing Buttons

def draw_button(rect, text, color=(50,50,50)):
    pygame.draw.rect(S, color, rect, border_radius=6)
    label = font.render(text, True, (255,255,255))
    S.blit(label, (rect.x + rect.w//4, rect.y + rect.h//3))

Handling Events

if save_btn.collidepoint(mouse_pos):
    save_pattern()
elif load_btn.collidepoint(mouse_pos):
    load_pattern()
elif clear_btn.collidepoint(mouse_pos):
    pattern = {n:[0]*steps for n in notes}

Save and Load Logic

def save_pattern():
    with open('pattern.json', 'w') as f:
        json.dump(pattern, f)

def load_pattern():
    global pattern
    if os.path.exists('pattern.json'):
        with open('pattern.json', 'r') as f:
            pattern = json.load(f)

Why Use JSON?

It’s lightweight, readable, and editable. You can even open your melody file in a text editor and modify it manually.

The Clear button resets all notes, making quick restarts effortless.

This section establishes the basic file management and editing functions that make your app feel professional and reusable.


Slider for Loop Control

The loop slider determines how many steps are played before restarting. This allows you to fine-tune short loops during composition.

Define Geometry

slider_x = 80
slider_y = 260
slider_w = W - slider_x - 40
slider_h = 20
slider_val = 31
slider_dragging = False

Drawing

def draw_slider(x, y, w, h, val):
    pygame.draw.rect(S, (150,150,150), (x,y,w,h), border_radius=4)
    handle_x = x + int(val / (steps - 1) * w)
    pygame.draw.circle(S, (255,0,0), (handle_x, y + h // 2), 10)
    label = font.render(f"Loop end: {val + 1}", True, (255,255,255))
    S.blit(label, (x, y - 30))
    return handle_x

Event Handling

if event.type == pygame.MOUSEBUTTONDOWN and slider_y <= event.pos[1] <= slider_y + slider_h:
    slider_dragging = True
if event.type == pygame.MOUSEBUTTONUP:
    slider_dragging = False
if event.type == pygame.MOUSEMOTION and slider_dragging:
    rel_x = max(0, min(event.pos[0] - slider_x, slider_w))
    slider_val = int((rel_x / slider_w) * (steps - 1))

This simple code lets you dynamically shorten or extend the loop range visually.


Waveform Visualization

The waveform visually represents sound energy across time. By showing the amplitude of the audio signal, users gain a deeper understanding of how sound behaves.

wave_buffer = np.zeros(W)
current_frame_wave = np.zeros(64)

def draw_waveform():
    S.fill((0,0,0), rect=pygame.Rect(0, 120, W, 120))
    mid_y = 180
    buf = wave_buffer[-W:]
    for x in range(1, W):
        y1 = mid_y - int(buf[x-1] * 100)
        y2 = mid_y - int(buf[x] * 100)
        pygame.draw.line(S, (0,255,0), (x-1, y1), (x, y2))
    label = font.render("Waveform", True, (255,255,255))
    S.blit(label, (20, 100))

Each new note updates the wave_buffer, shifting data like an oscilloscope trace.

wave_buffer = np.roll(wave_buffer, -1)
wave_buffer[-1] = current_frame_wave[0]

You can extend this with note-specific colors or FFT-based frequency visualizations for advanced analysis.


Playback Thread

We separate playback from UI rendering for responsiveness. Threading allows smooth visuals while maintaining precise timing.

def playback_loop():
    global step, wave_buffer, current_frame_wave
    while True:
        if playing and time.time() - last >= beat_time:
            active_notes = [n for n in notes if pattern[n][step]]
            if active_notes:
                for n in active_notes:
                    sounds[n].play()
                    wave_buffer = np.concatenate((wave_buffer[-W//2:], wave_data[n][:W//2]))
            step = 0 if step >= slider_val else step + 1
            last = time.time()
        time.sleep(0.01)

threading.Thread(target=playback_loop, daemon=True).start()

This ensures the app continues to draw and respond even as notes play precisely in rhythm.


Main Loop & Event Handling

The main loop manages all user input, drawing, and updates. It listens for mouse clicks to toggle notes, adjust sliders, or click buttons.

playing = True
beat_time = 0.3
step, last = 0, time.time()

while True:
    for event in pygame.event.get():
        if event.type == pygame.QUIT:
            pygame.quit(); sys.exit()
        if event.type == pygame.MOUSEBUTTONDOWN:
            mouse_pos = pygame.mouse.get_pos()
            # handle buttons and slider

    S.fill((20,20,20))
    draw_button(save_btn, "SAVE")
    draw_button(load_btn, "LOAD")
    draw_button(clear_btn, "CLEAR")
    draw_slider(slider_x, slider_y, slider_w, slider_h, slider_val)
    draw_waveform()
    draw_grid()

    pygame.display.flip()
    clock.tick(60)

This loop ties together all the UI elements into one cohesive, reactive system.


A sample Jingle bells Melody json file

jingle.json

jingle bells {"C": [0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0], "D": [0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, 1, 0, 1, 0, 0, 0, 0, 0, 0], "E": [1, 1, 1, 0, 1, 1, 1, 0, 1, 0, 0, 0, 1, 0, 0, 0, 0, 0, 1, 1, 0, 1, 0, 0, 1, 0, 0, 0, 0, 0, 0, 0], "F": [0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, 1, 1, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0], "G": [0, 0, 0, 0, 0, 0, 0, 0, 0, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, 0, 0, 0, 0, 0], "A": [0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0]}

Complete Code

pyshine_music_maker

import pygame, sys, time, numpy as np, json, os, threading

pygame.init()
pygame.mixer.init(frequency=44100)

# Settings 
W, H = 480, 750
S = pygame.display.set_mode((W, H))
pygame.display.set_caption("PyShine Music Maker (Save/Load + Clear + Slider)")
font = pygame.font.SysFont(None, 26)
clock = pygame.time.Clock()

# Synthetic piano 
fs = 44100
waveform_height = 150
waveform_buffer_len = 1024 * 1
duration = 0.5

def make_note(freq):
    t = np.linspace(0, duration, int(fs*duration), False)
    env = np.exp(-4.5 * t)
    wave = (0.6*np.sin(2*np.pi*freq*t) +
            0.3*np.sin(2*np.pi*freq*2*t) +
            0.1*np.sin(2*np.pi*freq*3*t)) * env
    audio = (wave*32767).astype(np.int16)
    return pygame.sndarray.make_sound(np.column_stack((audio,audio))), wave

notes = {'C':261.63,'D':293.66,'E':329.63,'F':349.23,
         'G':392.00,'A':440.00,'B':493.88}

sounds = {}
wave_data = {}
for n,f in notes.items():
    snd, wave = make_note(f)
    sounds[n] = snd
    wave_data[n] = wave

# Pattern 
steps = 32
def default_pattern():
    pat = {n:[0]*steps for n in notes}
    melody = [
        ('C',0),('C',1),('G',4),('G',5),
        ('A',8),('A',9),('G',12),
        ('F',16),('F',17),('E',20),('E',21),
        ('D',24),('D',25),('C',28)
    ]
    for n,p in melody: pat[n][p]=1
    return pat

pattern = default_pattern()
names = list(pattern.keys())

# Layout 
margin_left = 80
waveform_top = 120
grid_top = waveform_top + waveform_height + 50
grid_width = W - margin_left - 40
spacing = 3
cell_w = (grid_width - spacing*(steps-1)) / steps
cell_h = 50

# Buttons 
btn_w = 120  # reduced width
btn_h = 50
save_btn = pygame.Rect(20, 40, btn_w, btn_h)
load_btn = pygame.Rect(180, 40, btn_w, btn_h)
clear_btn = pygame.Rect(340, 40, btn_w, btn_h)

# Slider 
slider_x = margin_left
slider_y = grid_top - 40
slider_w = grid_width
slider_h = 20
slider_val = steps - 1  # return cell id default at last step
slider_dragging = False

def draw_slider(x, y, w, h, val):
    pygame.draw.rect(S, (150, 150, 150), (x, y, w, h))
    handle_x = x + int(val / (steps - 1) * w)
    pygame.draw.circle(S, (255, 0, 0), (handle_x, y + h // 2), 10)
    return handle_x

# Playback 
step = 0
bpm = 180
playing = True
beat_time = 60/bpm
last = time.time()

# Rolling waveform 
wave_buffer = np.zeros(waveform_buffer_len)
current_frame_wave = np.zeros(64)

# Draw waveform 
def draw_waveform():
    S.fill((0,0,0), rect=pygame.Rect(0,waveform_top,W,waveform_height))
    mid_y = waveform_top + waveform_height//2
    buf = wave_buffer[-W:] if len(wave_buffer) >= W else np.pad(wave_buffer, (W-len(wave_buffer),0))
    for x in range(1, W):
        y1 = mid_y - int(buf[x-1]*waveform_height/2)
        y2 = mid_y - int(buf[x]*waveform_height/2)
        pygame.draw.line(S, (0,255,0), (x-1,y1), (x,y2))
    pygame.draw.line(S, (0,200,0), (0, mid_y), (W, mid_y), 2)


# Playback thread 
def playback_loop():
    global step, last, wave_buffer, current_frame_wave
    while True:
        if playing and time.time() - last >= beat_time:
            step_wave = np.zeros(len(next(iter(wave_data.values()))))
            active = False
            for name in names:
                if pattern.get(name,[0]*steps)[step]:
                    sounds[name].play()
                    step_wave += wave_data[name]
                    active = True
            if active:
                step_wave = step_wave / np.max(np.abs(step_wave))
            else:
                step_wave = np.zeros_like(step_wave)
            current_frame_wave = np.interp(np.linspace(0,len(step_wave)-1,64),
                                           np.arange(len(step_wave)), step_wave)
            # Increment step
            step += 1
            # If step exceeds slider_val, loop back to start (0) or slider start
            if step > slider_val:
                step = 0  # you can also set to slider_start if you want
            last = time.time()
        wave_buffer = np.roll(wave_buffer, -1)
        wave_buffer[-1] = current_frame_wave[0]
        current_frame_wave = np.roll(current_frame_wave, -1)
        time.sleep(0.005)

threading.Thread(target=playback_loop, daemon=True).start()

# File picker overlay with blinking cursor 
def file_picker_overlay(for_save=False):
    files = [f for f in os.listdir('.') if f.endswith('.json')]
    picker_open = True
    selected = None
    input_text = "new_file.json" if for_save else ""
    typing_active = for_save
    cursor_visible = True
    cursor_timer = time.time()

    while picker_open:
        S.fill((40,40,40))
        title = "Enter filename to SAVE:" if for_save else "Select file to LOAD:"
        S.blit(font.render(title, True, (255,255,255)), (20,20))

        file_rects = []
        for i, fname in enumerate(files):
            rect = pygame.Rect(40, 60 + i*50, 400, 40)
            color = (180,100,50) if for_save else (100,180,100)
            pygame.draw.rect(S, color, rect, border_radius=5)
            S.blit(font.render(fname, True, (0,0,0)), (rect.x+10, rect.y+8))
            file_rects.append((fname, rect))

        if for_save:
            input_rect = pygame.Rect(40, 60 + len(files)*50, 400, 40)
            pygame.draw.rect(S, (200,200,200), input_rect, border_radius=5)
            S.blit(font.render(input_text, True, (0,0,0)), (input_rect.x+5, input_rect.y+8))
            if time.time() - cursor_timer > 0.5:
                cursor_visible = not cursor_visible
                cursor_timer = time.time()
            if cursor_visible and typing_active:
                cursor_x = input_rect.x + 5 + font.size(input_text)[0]
                cursor_y = input_rect.y + 5
                pygame.draw.line(S, (0,0,0), (cursor_x, cursor_y), (cursor_x, cursor_y + input_rect.height - 10), 2)

        pygame.display.flip()

        for e in pygame.event.get():
            if e.type == pygame.QUIT:
                pygame.quit(); sys.exit()
            elif e.type == pygame.MOUSEBUTTONDOWN:
                mx,my = e.pos
                for fname, rect in file_rects:
                    if rect.collidepoint(mx,my) and not for_save:
                        selected = fname
                        picker_open = False
                if for_save and input_rect.collidepoint(mx,my):
                    typing_active = True
                else:
                    typing_active = False
            elif e.type == pygame.KEYDOWN and typing_active:
                if e.key == pygame.K_BACKSPACE:
                    input_text = input_text[:-1]
                elif e.key == pygame.K_RETURN:
                    selected = input_text
                    picker_open = False
                else:
                    input_text += e.unicode

    return selected

# Main loop 
while True:
    S.fill((25,25,25))

    # Buttons
    pygame.draw.rect(S, (70,130,180), save_btn, border_radius=10)
    pygame.draw.rect(S, (100,180,100), load_btn, border_radius=10)
    pygame.draw.rect(S, (200,100,100), clear_btn, border_radius=10)
    S.blit(font.render("SAVE", True, (255,255,255)), (save_btn.x+30, save_btn.y+15))
    S.blit(font.render("LOAD", True, (255,255,255)), (load_btn.x+30, load_btn.y+15))
    S.blit(font.render("CLEAR", True, (255,255,255)), (clear_btn.x+30, clear_btn.y+15))

    # Slider
    slider_handle = draw_slider(slider_x, slider_y, slider_w, slider_h, slider_val)

    # Waveform
    draw_waveform()

    # Grid
    for r,name in enumerate(names):
        y = grid_top + r*(cell_h+10)
        S.blit(font.render(name, True, (255,255,255)), (20, y+cell_h/3))
        for c in range(steps):
            x = margin_left + c*(cell_w+spacing)
            rect = pygame.Rect(x, y, cell_w, cell_h)
            color = (0,200,200) if pattern.get(name,[0]*steps)[c] else (60,60,60)
            if c == step: pygame.draw.rect(S, (0,255,0), rect, 3)
            pygame.draw.rect(S, color, rect, border_radius=3)

    pygame.display.flip()

    # Events 
    for e in pygame.event.get():
        if e.type == pygame.QUIT:
            pygame.quit(); sys.exit()
        elif e.type == pygame.MOUSEBUTTONDOWN:
            mx,my = e.pos
            if save_btn.collidepoint(mx,my):
                target_file = file_picker_overlay(for_save=True)
                if target_file:
                    if not target_file.endswith(".json"):
                        target_file += ".json"
                    with open(target_file,"w") as f:
                        json.dump(pattern,f)
                    print("Saved to:", target_file)
            elif load_btn.collidepoint(mx,my):
                chosen_file = file_picker_overlay(for_save=False)
                if chosen_file:
                    with open(chosen_file,"r") as f:
                        data = f.read().strip()
                        loaded_pattern = json.loads(data) if data else {}
                        pattern = {n: loaded_pattern.get(n,[0]*steps) for n in notes}
                    print("Loaded:", chosen_file)
            elif clear_btn.collidepoint(mx,my):
                pattern = {n:[0]*steps for n in notes}
                print("Cleared all cells")
            elif pygame.Rect(slider_x, slider_y, slider_w, slider_h).collidepoint(mx,my):
                slider_dragging = True
            else:
                # Toggle grid cells
                for r,name in enumerate(names):
                    y = grid_top + r*(cell_h+10)
                    for c in range(steps):
                        x = margin_left + c*(cell_w+spacing)
                        if pygame.Rect(x,y,cell_w,cell_h).collidepoint(mx,my):
                            pattern[name][c] ^= 1
        elif e.type == pygame.MOUSEBUTTONUP:
            slider_dragging = False
        elif e.type == pygame.MOUSEMOTION and slider_dragging:
            mx,_ = e.pos
            # Clamp and convert to cell id
            slider_val = int((mx - slider_x) / slider_w * (steps - 1))
            slider_val = max(0, min(steps-1, slider_val))

    clock.tick(60)


Running the Application

Install dependencies and run:

pip install pygame numpy
python pyshine_music_maker.py

Click cells to activate notes, save and load patterns, clear the grid, and adjust the loop length with the slider.


Key Learnings

  • Audio synthesis using NumPy sine waves and harmonics.
  • Threaded design for real-time interactivity.
  • Dynamic GUI layout in Pygame.
  • JSON-based pattern persistence.
  • Visualization of digital waveforms.

Further Ideas

  • Add more octaves and instruments.
  • Implement a tempo (BPM) slider.
  • Add real-time recording or export to WAV/MIDI.
  • Use color-coded notes by frequency.
  • Animate transitions between steps for visual rhythm tracking.

This in-depth guide transforms your simple code into a mini music production environment — blending coding, sound design, and interactive graphics beautifully.