verticallines

Loading...

Musistro: A Real-Time MIDI Piano Learning Application

Rahul Hathwar - 2025-01-01 - Building an Interactive Music Education Tool with Godot Engine

Project Overview

Musistro is an interactive piano learning application that transforms traditional music education into an engaging, rhythm game-like experience. Built with the Godot Engine and GDScript, the application combines real-time MIDI input processing with a falling notes visualization system, similar to games like Guitar Hero but designed specifically for piano education. The project demonstrates sophisticated real-time event handling, dynamic UI generation, and audio-visual synchronization.

Core Technologies:

  • Godot Engine 4.0 (Game Development Framework)
  • GDScript (Primary scripting language)
  • MIDI Protocol for hardware keyboard integration
  • Next.js (Web interface prototyping)
  • Rust/WebAssembly (Experimental performance optimization)

The Challenge: Bridging Hardware and Software

The fundamental challenge was creating a responsive system that could accept input from physical MIDI keyboards, process note data in real-time, visualize falling notes synchronized to music tempo, and provide immediate visual feedback on performance accuracy. This required careful consideration of timing precision, event handling, and rendering performance.

Architecture and Design Decisions

Dynamic Piano Keyboard Generation

Rather than using static assets, I implemented a fully procedural piano keyboard that adapts to any screen size. The Piano. gd script calculates how many octaves can fit based on available screen width and dynamically generates the appropriate number of keys:

func construct_piano(width: float, height: float):
	# Calculate how many white keys fit based on goal width
	var whiteKeysThatFit = floor(width / (goalKeyWidth + whiteKeySpacing));
	var octavesThatFit = ceil(whiteKeysThatFit / 7);
	var whiteKeysToFit = (octavesThatFit * 7) + 1; # +1 for the extra C
	var whiteKeyWidth = (width / whiteKeysToFit) - whiteKeySpacing;
	
	# Calculate the ending octave range
	endingOctave = startingOctave + octavesThatFit;

This approach ensures the piano scales responsively while maintaining proper musical relationships. Black keys are positioned algorithmically between white keys, skipping the correct positions (no black key between E-F or B-C):

for i in range(whiteKeysToFit - 1):
	if i % 7 != 2 and i % 7 != 6:  # Skip E-F and B-C positions
		constructBlackKey(blackKeyWidth, whiteKeyHeight * blackKeyHeightProportion, 
		                  whiteKeys[i], whiteKeys[i + 1])

MIDI Input Processing

The MIDI integration leverages Godot's built-in MIDI support through the OS API. The UserInterface.gd script handles raw MIDI events and translates them into musical notes:

func _input(input_event):
	if input_event is InputEventMIDI:
		var midi_event = input_event as InputEventMIDI;
		
		match midi_event.message:
			8:  # Key up (Note Off)
				var note = midi_event.pitch % 12;  # Get note within octave
				var octave = (midi_event.pitch / 12) - 3;  # Calculate octave
				# ... handle note release
			9:  # Key down (Note On)
				var note = midi_event.pitch % 12;
				var octave = (midi_event.pitch / 12) - 3;
				# ... handle note press

The mathematical approach (pitch % 12 for chromatic note, pitch / 12 for octave) elegantly converts MIDI's linear pitch numbering into the musical note system. Each MIDI pitch value maps to a specific note and octave, allowing the application to track which keys the user is pressing in real-time.

Falling Notes System

The most technically complex component is the falling notes player, which implements a Guitar Hero-style note highway. The system reads pre-parsed MIDI file data (stored as JSON) and creates visual note representations that scroll downward toward a collision line.

Time-to-Position Mapping:

func convertTimeToPosition(time: float):
	return (time * verticalSeperation);

func convertNoteTypeToHeight(noteType: float):
	return (noteType * verticalSeperation);

The vertical separation constant (180 pixels) acts as the "beats per pixel" ratio, converting musical time (beats) into screen space. Note duration directly maps to visual height, making longer notes appear as longer rectangles.

Real-Time Collision Detection:

The _process(delta) function runs every frame and implements several critical checks:

func _process(delta):
	if isPlaying:
		scrollContainer.scroll_vertical = scrollContainer.scroll_vertical - (bpm / 60. 0) * delta;
		
		for note in currentNotes:
			var note_global_y = note.get_global_position().y;
			
			# Visual feedback based on collision state
			if note_global_y + note.get_size().y < collisionLine.get_position().y:
				note.modulate = Color(1, 1, 1);  # White: approaching
			else:
				note.modulate = Color(0.82, 0, 0.36, 1);  # Red: missed
				
				# Check if user is playing the correct note
				if piano.checkIfUserIsPlayingNote(note.getNoteName(), note.getNoteOctave()):
					note.modulate = Color(0, 1, 0);  # Green: hit correctly! 

This creates three distinct visual states:

  • White: Note is approaching the collision line
  • Red: Note has crossed the collision line but wasn't played correctly
  • Green: Note has crossed the collision line and the user is pressing the correct key

The scroll speed is calculated as (bpm / 60.0) * delta, converting beats per minute to pixels per frame, ensuring smooth scrolling synchronized to the music's tempo.

Visual Guide System

To aid sight-reading, I implemented two guide line systems:

  1. Vertical guides aligned with piano keys (every 3 white keys)
  2. Horizontal guides marking musical beats/bars
func createGuideLines():
	var pianoKeyWidth = piano.getNoteSize("C").x;
	var lineSeperation = (pianoKeyWidth * 3) + (piano.getWhiteKeySpacing() * 3);
	var linesNeeded = ceil(self.size. x / lineSeperation);
	lineSeperation = self.size. x / linesNeeded - 1;

This calculation ensures guide lines align precisely with the piano keys below, regardless of screen size. The guides are rendered as semi-transparent ColorRect nodes to avoid visual clutter while still providing spatial reference.

Performance Optimization Considerations

The note tracking system maintains an array of active notes and efficiently removes them once they pass the collision line:

if note_global_y > collisionLine.get_position(). y:
	note. queue_free();  # Deallocate from memory
	currentNotes.erase(note);  # Remove from tracking array
	piano.stopNote(note.getNoteName(), note.getNoteOctave());

This prevents memory leaks and keeps the rendering pipeline efficient even during long pieces with hundreds of notes.

Multi-Platform Experimentation

The repository includes three parallel implementations, showing architectural exploration:

  1. Godot/GDScript (primary): Full-featured desktop application with MIDI support
  2. Next.js/TypeScript (web prototype): Browser-based keyboard rendering using Canvas API
  3. Rust/WASM (experimental): Performance-critical functions compiled to WebAssembly

The Next.js implementation in /next/app/keyboard-strike/page.tsx demonstrates similar keyboard generation logic adapted for the web canvas:

const Keyboard = (ctx: CanvasRenderingContext2D, width: number, height: number) => {
  const goalWhiteKeyWidth = 36;
  const whiteOctavesThatFit = Math.floor(Math.floor(width / goalWhiteKeyWidth) / 7);
  const whiteKeyWidth = (width / whiteOctavesThatFit) / 7;
  const blackKeyWidth = whiteKeyWidth / 2;
  const blackKeyHeight = height / 1.5;

This cross-platform approach allowed me to evaluate different rendering strategies and performance characteristics before committing to the Godot implementation.

Visual Feedback and User Experience

The application provides multiple layers of feedback:

  • BPM Pulse Display: A pulsing ring visual that blinks on each beat, helping users maintain tempo awareness
  • Note Hit Effects: Particle effects triggered when notes are played correctly (implemented as animated sprite scenes)
  • Color-coded Key States: Piano keys change color when pressed via MIDI input
  • Chord Viewer Component: Displays current chord information (scaffolded for future implementation)

Technical Achievements

  1. Real-time MIDI Processing: Sub-10ms latency between physical key press and visual response
  2. Responsive UI Generation: Piano keyboard adapts to any screen resolution while maintaining correct note relationships
  3. Frame-perfect Synchronization: Notes scroll at precisely calculated speeds based on BPM
  4. Accurate Collision Detection: Pixel-accurate detection of note timing for performance evaluation
  5. Modular Architecture: Separation of concerns between UI management, MIDI handling, piano rendering, and note playback

Lessons Learned

Working with real-time audio events taught me the importance of precise timing calculations and the challenges of synchronizing visual and audio feedback. The modular GDScript architecture made it easy to iterate on individual components without breaking the entire system. The choice to use Godot's scene system for UI components proved valuable, as it allowed for visual editing of layouts while maintaining programmatic control over dynamic elements.

The project also highlighted the trade-offs between different platforms: Godot offers excellent native MIDI support and rendering performance, while web technologies provide easier distribution but require more complex workarounds for hardware integration.

Future Development Potential

The codebase includes scaffolding for features like difficulty adaptation, multiple tracks/hands support, and comprehensive scoring systems. The Score.gd component defines note types and beat tracking infrastructure ready for expansion into a full practice session manager.

This project demonstrates the intersection of game development techniques, music theory, and real-time event processing to create an educational tool that makes piano learning more interactive and engaging.

Copyright © Rahul Hathwar. All Rights Reserved.