Skip to content

Instantly share code, notes, and snippets.

@tannerwelsh
Last active July 10, 2017 17:21
Show Gist options
  • Save tannerwelsh/f16aac788a5560ba39aae86b5ac98ac2 to your computer and use it in GitHub Desktop.
Save tannerwelsh/f16aac788a5560ba39aae86b5ac98ac2 to your computer and use it in GitHub Desktop.
Piano Tutor

Piano Tutor

Use Ruby + a MIDI keyboard for a programmable piano exercise tool.

Usage:

$ ruby practice.rb <exercise>-<key>[,<key>, ...] [<exercise>, ...]

Example:

$ ruby practice.rb major_scale-C3,D2

Available exercises:

  • major_scale
  • major_third
  • major_third_first_inversion
  • major_third_second_inversion
  • minor_third
require './note.rb'
class PianoTutor
class Exercise
attr_reader :base_note
def initialize(note)
@base_note = Note.new(note)
end
def self.build(key)
case key
when Note::NOTE_PATTERN
new(key)
when 'all'
Note::NOTE_SEQUENCE.map { |n| new(n) }
when 'random'
new(Note::NOTE_SEQUENCE.sample.to_s + Note::OCTAVES.sample.to_s)
when 'allrandom'
Note::NOTE_SEQUENCE.map { |n| new(n) }.shuffle
end
end
def self.list
ObjectSpace.each_object(Class)
.select { |klass| klass < self }
.map { |ex_class| ex_class.to_s.demodulize.underscore }
.sort
end
def message
"#{base_note.verbose} #{self.class.to_s.demodulize.titleize.humanize.downcase}"
end
def sequence
@sequence ||= [ intervals.map { |offset| base_note.code + offset } ]
end
def intervals
raise NotImplementedError
end
def note_count
sequence.size
end
def current_note
sequence[index]
end
def complete?
index >= note_count
end
def correct?(note)
if current_note.is_a? Array
freeze_index!
note_buffer << note
if note_buffer.size < current_note.size
return true
else
correct = note_buffer.sort == current_note.sort
unfreeze_index!
note_buffer.clear
return correct
end
end
note == current_note
end
def reset
@index = 0
end
def advance!
@index += 1 unless frozen?
end
private
def index
@index ||= 0
end
def note_buffer
@note_buffer ||= []
end
def freeze_index!
@frozen = true
end
def unfreeze_index!
@frozen = false
end
def frozen?
@frozen ||= false
end
end
#
# Exercises
#
class MajorScale < Exercise
def intervals
[0, 2, 4, 5, 7, 9, 11, 12]
end
def sequence
@sequence ||= intervals.map { |offset| base_note.code + offset }
end
end
class MajorThird < Exercise
def intervals
[0, 4, 7]
end
end
class MinorThird < Exercise
def intervals
[0, 3, 7]
end
end
class MajorThirdFirstInversion < Exercise
def intervals
[4, 7, 12]
end
end
class MajorThirdSecondInversion < Exercise
def intervals
[7, 12, 16]
end
end
end
source 'https://rubygems.org'
gem 'unimidi'
gem 'midi-message'
gem 'activesupport', '~> 4.2.3'
require 'unimidi'
require 'midi-message'
module MIDI
module StatusByte
TIMING_CLOCK = 248
ACTIVE_SENSING = 254
end
class InputStream
END_OF_INPUT = [96, 36] # C7 + C2 as a custom end-of-file code
attr_reader :input, :buffer
def initialize
@input = UniMIDI::Input.first
@buffer = []
end
def read(opts = {})
loop do
input.gets.each do |raw|
data, timestamp = raw[:data], raw[:timestamp]
next if data.first.to_i == MIDI::StatusByte::TIMING_CLOCK \
|| data.first.to_i == MIDI::StatusByte::ACTIVE_SENSING
if data.last == 0
next unless opts[:include_off_notes]
msg = MIDIMessage::NoteOff.new(*data[0..1])
else
msg = MIDIMessage::NoteOn.new(*data[0..1])
end
puts "[#{msg.note}] #{msg.name}" if opts[:debug]
buffer << msg
if buffer.count > 2 && buffer[-2..-1].map(&:note) == END_OF_INPUT
buffer.pop(2)
break
end
yield msg if block_given?
end
end
input.close
buffer
end
def self.read(opts = {}, &blk)
new.read(opts, &blk)
end
end
class Output
class << self
def device
@device ||= UniMIDI::Output.first
end
def play(*notes)
notes.each do |note|
device.puts(0x90, note, 100)
sleep(0.2)
device.puts(0x80, note, 100)
end
end
end
end
end
class PianoTutor
class Note
NOTE_SEQUENCE = %w[ C C# D Eb E F F# G Ab A Bb B ]
NOTE_PATTERN = /([A-G][b#]?)(\d?)/
OCTAVES = (2..6).to_a # my keyboard only goes from 2 to 6
C1_CODE = 12
attr_reader :key, :octave
def initialize(note)
@key, @octave = note.match(NOTE_PATTERN)[1..2]
@octave = (@octave.empty? ? 4 : @octave.to_i)
end
def code
@code ||= C1_CODE + NOTE_SEQUENCE.index(key) + (octave * 12)
end
def verbose
return "#{key[0]} sharp" if key[1] == '#'
return "#{key[0]} flat" if key[1] == 'b'
key
end
end
end
require 'active_support'
require 'active_support/core_ext/string'
require './midi'
require './exercise'
class PianoTutor
def self.speak(message)
puts message
system "say -v Alex '#{message}'"
end
def self.practice(exercises)
exercises.each do |exercise|
speak exercise.message
MIDI::InputStream.read(debug: true) do |note_message|
if exercise.correct?(note_message.note)
exercise.advance!
if exercise.complete?
speak 'good!'
break
end
else
speak 'no. try again.'
puts "expected: #{exercise.current_note}"
exercise.reset
end
end
end
end
def self.exercises(selection = nil)
if selection
exercises = []
selection.each do |ex|
exercise, args = ex.split('-')
exercises += args.split(',').map { |arg| [exercise, arg] }
end
else
exercises = Exercise.list
end
exercises.map do |ex, arg|
ex_class = "#{self}::#{ex.camelize}".constantize
ex_class.build(arg)
end.flatten.shuffle
end
end
require './piano_tutor.rb'
if ARGV.count == 0
puts ''
puts $PROGRAM_NAME
puts ''
puts 'Practice piano exercises.'
puts ''
puts 'Usage:'
puts '$ ' + $PROGRAM_NAME + ' <exercise>-<key>[,<key>, ...] [<exercise>, ...]'
puts ''
puts 'Example:'
puts '$ ' + $PROGRAM_NAME + ' major_scale-C3,D2'
puts ''
puts 'Available exercises:'
puts PianoTutor::Exercise.list.map { |ex| '- ' + ex }
puts ''
exit
end
PianoTutor.practice(PianoTutor.exercises(ARGV))
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment