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
- Overview
- Setting Up Pygame
- Creating Synthetic Piano Notes
- Sequencer Pattern & Grid Layout
- Buttons: Save, Load, Clear
- Slider for Loop Control
- Waveform Visualization
- Playback Thread
- Main Loop & Event Handling
- Running the Application
- Key Learnings
- 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.linspacecreates evenly spaced samples across the note’s duration.envadds 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.