Upgrade to Pro — share decks privately, control downloads, hide ads and more …

How to make the Groovebox

How to make the Groovebox

w/ RubyKaigi 2025

あそなす

April 28, 2025
Tweet

More Decks by あそなす

Other Decks in How-to & DIY

Transcript

  1. Yuya Fujiwara a.k.a asonas ✦ I'm Software Engineer ✦ and

    Monaural Speaker. ✦ Work at IVRy Stereo Speaker Monaural Speaker
  2. What is Groovebox? Create Music/Sound with just that one device.

    An electronic instrument that has the features of a Synthesizer and Sampler, E ff ector, Sequencer.
  3. asonas/groovebox-ruby Not rubygems (yet) A small toolkit of modular components

    Mix & match them to build YOUR groovebox Tweak the parameters until the VIBE is just right
  4. Ampli fi er(1) class VCA < FFI::PortAudio::Stream include FFI::PortAudio def

    initialize(generator, sample_rate, buffer_size) @generator = generator @buffer_size = buffer_size output_params = API::PaStreamParameters.new output_params[:device] = API.Pa_GetDefaultOutputDevice output_params[:channelCount] = 2 output_params[:sampleFormat] = API::Float32 output_params[:suggestedLatency] = API.Pa_GetDeviceInfo(output_params[:device])[:defaultHighOutputLatency] output_params[:hostApiSpecificStreamInfo] = nil super() open(nil, output_params, sample_rate, buffer_size) start end
  5. Ampli fi er(2) def process(input, output, frame_count, time_info, status_flags, user_data)

    samples = @generator.generate(frame_count) output.write_array_of_float(samples) :paContinue end
  6. Oscilator Generate waveform Di ff erent waveforms have di ff

    erent tones Type of Waveform Sine Sawtooth Triangle Square
  7. Oscilator # Sine Math.sin(phase) # Sawtooth (phase % (2 *

    Math::PI)) / Math::PI - 1.0 # Triangle 2.0 * (2.0 * ((phase / (2.0 * Math::PI)) - 0.5).abs) - 1.0 # Square (phase % (2.0 * Math::PI)) < Math::PI ? 0.5 : -0.5
  8. Generate waveform def generate(buffer_size) return Array.new(buffer_size, 0.0) if @active_notes.empty? samples

    = Array.new(buffer_size, 0.0) start_sample_index = @global_sample_count active_note_count = 0 @active_notes.each_value do |note| wave = @oscillator.generate_wave(note, buffer_size) wave.each_with_index do |sample_val, idx| current_sample_index = start_sample_index + idx env_val = @envelope.apply_envelope(note, current_sample_index, @sample_rate)
  9. samples = Array.new(buffer_size, 0.0) start_sample_index = @global_sample_count active_note_count = 0

    @active_notes.each_value do |note| wave = @oscillator.generate_wave(note, buffer_size) wave.each_with_index do |sample, idx| current_sample_index = start_sample_index + idx env = @envelope.apply_envelope( note, current_sample_index, @sample_rate) wave[idx] = sample * env end samples = samples.zip(wave).map { |s1, s2| s1 + s2 } has_sound = false wave.each do |sample| Generate waveform
  10. @active_notes.each_value do |note| wave = @oscillator.generate_wave(note, buffer_size) wave.each_with_index do |sample,

    idx| current_sample_index = start_sample_index + idx env = @envelope.apply_envelope( note, current_sample_index, @sample_rate) wave[idx] = sample * env end samples = samples.zip(wave).map { |s1, s2| s1 + s2 } has_sound = false wave.each do |sample| if sample != 0.0 has_sound = true break end end active_note_count += 1 if has_sound Generate waveform
  11. has_sound = true break end end active_note_count += 1 if

    has_sound end master_gain = 5.0 if active_note_count > 1 master_gain *= (1.0 / Math.sqrt(active_note_count)) end samples.map! { |sample| sample * master_gain } @global_sample_count += buffer_size cleanup_inactive_notes(buffer_size) samples end Generate waveform
  12. if note.note_off_sample_index.nil? # Note On if current_time < @attack #

    Attack current_time / @attack elsif current_time < (@attack + @decay) # Decay 1.0 - ((current_time - @attack) / @decay) * (1.0 - @sustain) else # Sustain @sustain end else # Release release_start_offset = note.note_off_sample_index - note.note_on_sample_index release_sample_offset = sample_index - note.note_off_sample_index\ release_time = release_sample_offset.to_f / sample_rate # Calculate Release timing volume_at_release_start = sustain_level_at_release(note, release_start_offset, sample_rate) envelope_val = volume_at_release_start * (1.0 - (release_time / @release)) envelope_val.negative? ? 0.0 : envelope_val end.clamp(0.0, 1.0)
  13. if note.note_off_sample_index.nil? # Note On if current_time < @attack #

    Attack current_time / @attack elsif current_time < (@attack + @decay) # Decay 1.0 - ((current_time - @attack) / @decay) * (1.0 - @sustain) else # Sustain @sustain end else # Release release_start_offset = note.note_off_sample_index - note.note_on_sample_index release_sample_offset = sample_index - note.note_off_sample_index\ release_time = release_sample_offset.to_f / sample_rate # Calculate Release timing volume_at_release_start = sustain_level_at_release(note, release_start_offset, sample_rate) envelope_val = volume_at_release_start * (1.0 - (release_time / @release)) envelope_val.negative? ? 0.0 : envelope_val end.clamp(0.0, 1.0)
  14. if note.note_off_sample_index.nil? # Note On if current_time < @attack #

    Attack current_time / @attack elsif current_time < (@attack + @decay) # Decay 1.0 - ((current_time - @attack) / @decay) * (1.0 - @sustain) else # Sustain @sustain end else # Release release_start_offset = note.note_off_sample_index - note.note_on_sample_index release_sample_offset = sample_index - note.note_off_sample_index\ release_time = release_sample_offset.to_f / sample_rate # Calculate Release timing volume_at_release_start = sustain_level_at_release(note, release_start_offset, sample_rate) envelope_val = volume_at_release_start * (1.0 - (release_time / @release)) envelope_val.negative? ? 0.0 : envelope_val end.clamp(0.0, 1.0)
  15. if note.note_off_sample_index.nil? # Note On if current_time < @attack #

    Attack current_time / @attack elsif current_time < (@attack + @decay) # Decay 1.0 - ((current_time - @attack) / @decay) * (1.0 - @sustain) else # Sustain @sustain end else # Release release_start_offset = note.note_off_sample_index - note.note_on_sample_index release_sample_offset = sample_index - note.note_off_sample_index\ release_time = release_sample_offset.to_f / sample_rate # Calculate Release timing volume_at_release_start = sustain_level_at_release(note, release_start_offset, sample_rate) envelope_val = volume_at_release_start * (1.0 - (release_time / @release)) envelope_val.negative? ? 0.0 : envelope_val end.clamp(0.0, 1.0)
  16. if note.note_off_sample_index.nil? # Note On if current_time < @attack #

    Attack current_time / @attack elsif current_time < (@attack + @decay) # Decay 1.0 - ((current_time - @attack) / @decay) * (1.0 - @sustain) else # Sustain @sustain end else # Release release_start_offset = note.note_off_sample_index - note.note_on_sample_index release_sample_offset = sample_index - note.note_off_sample_index\ release_time = release_sample_offset.to_f / sample_rate # Calculate Release timing volume_at_release_start = sustain_level_at_release(note, release_start_offset, sample_rate) envelope_val = volume_at_release_start * (1.0 - (release_time / @release)) envelope_val.negative? ? 0.0 : envelope_val end.clamp(0.0, 1.0)
  17. Sequencer A sequencer is a device or software that arranges

    musical events (notes, rhythms, patterns) on a timeline and plays them automatically. Typical events include: Note On / Note O ff Step-based note arrangement Timing managed by tempo (BPM) Explore a sequencer built with Ruby, integrated with a synthesizer
  18. Sequencer Implementation Overview Key features of our Ruby-based sequencer: Multi-track

    step sequencing Support for drums and synthesizers Import MIDI fi le (.mid) Integration with a Groovebox using dRuby Runs on a terminal-based UI, providing a simple and intuitive work fl ow.
  19. Sequencer Data Structure Sequencer ├─ Track 1: Synth │ ├─

    Step 1: [active, note=C4, vel=100] │ ├─ Step 2: [inactive] │ └─ ... └─ Track 2: Drum ├─ Step 1: [active, midi_note=36, vel=127] ├─ Step 2: [inactive] └─ ...
  20. Groovebox Sequencer .mid 1.1.1 1.2.1 1.3.1 1.4.1 Kick: | K

    - - - K - - - K - - - K - - - | Snare: | - - - - S - - - - - - - S - - - | Hi-hat: | H - H - H - H - H - H - H - H - | 1.1.3. 1.2.3 1.3.3 1.4.3
  21. Groovebox Sequencer .mid 1.1.1 1.2.1 1.3.1 1.4.1 Kick: | K

    - - - K - - - K - - - K - - - | Snare: | - - - - S - - - - - - - S - - - | Hi-hat: | H - H - H - H - H - H - H - H - | 1.1.3 1.2.3 1.3.3 1.4.3 K, H H K, S, H . . . . . . . . . note_on(note, velocity)
  22. FFI::PortAudio::API.Pa_Initialize groovebox = Groovebox.new synthesizer = Synthesizer.new groovebox.add_instrument synthesizer bass

    = Presets::Bass.new groovebox.add_instrument bass VCA.new(groovebox, SAMPLE_RATE, BUFFER_SIZE) DRb.start_service('druby://localhost:8786', groovebox) DRb.thread.join
  23. @playing = true step_interval = 60.0 / @bpm / 4

    play_position = 0 while @playing @tracks.each do |track| instrument_index = track[:instrument_index] @groovebox.change_sequencer_channel(instrument_index) if track[:midi_note] step = track[:steps][play_position] if step.active midi_note = track[:midi_note] velocity = step.velocity || 100 @groovebox.sequencer_note_on(midi_note, velocity) end end end sleep step_interval play_position = (play_position + 1) % @steps_per_track end
  24. require 'io/console' require 'ffi-portaudio' require 'drb/drb' require 'midilib' SAMPLE_RATE =

    44100 BUFFER_SIZE = 128 require_relative "groovebox" require_relative "drum_rack" require_relative "synthesizer" require_relative "note" require_relative "vca" require_relative "step" require_relative "presets/bass" require_relative "presets/kick" require_relative "presets/snare" require_relative "presets/hihat_closed" require_relative "presets/piano" require_relative "sidechain" class Sequencer attr_reader :tracks, :steps_per_track DEFAULT_NOTES = ["C3", "C#3", "D3", "D#3", "E3", "F3", "F#3", "G3", "G#3", "A3", "A#3", "B3", "C4", "C#4", "D4", "D#4", "E4", "F4", "F#4", "G4", "G#4", "A4", "A#4", "B4",] NOTE_TO_MIDI = {} DEFAULT_NOTES.each_with_index do |note_name, idx| NOTE_TO_MIDI[note_name] = 48 + idx end def initialize(groovebox = nil, mid_file_path = nil) @groovebox = groovebox @current_position = 0 @current_track = 0 @current_voice = 0 # ݱࡏฤूதͷ੠෦ʢϙϦϑΥχʔͷΠϯσοΫεʣ @steps_per_track = 32 @tracks = [] @playing = false @bpm = 120 initialize_tracks if mid_file_path puts "Loading MIDI file: #{mid_file_path}" load_midi_file(mid_file_path) end end def initialize_tracks return if @groovebox.nil? @instruments = @groovebox.instruments @instruments.each_with_index do |instrument, idx| track_name = "Track #{idx}" if instrument.respond_to?(:pad_notes) # DrumRackͷ৔߹͸ैདྷ௨Γ instrument.pad_notes.sort.each do |pad_note| track = Array.new(@steps_per_track) { Step.new } @tracks << { name: "Drum #{pad_note}", instrument_index: idx, midi_note: pad_note, steps: track, polyphony: 1, # υϥϜ͸ৗʹ୯Ի voices: [0], # ੠෦ΠϯσοΫε } end else # γϯηαΠβʔͷ৔߹͸ϙϦϑΥχʔରԠ polyphony = @groovebox.get_polyphony(idx) voices = (0...polyphony).to_a # ֤੠෦ʢϙϦϑΥχʔʣʹରԠ͢Δεςοϓ഑ྻΛੜ੒ poly_steps = voices.map { Array.new(@steps_per_track) { Step.new } } @tracks << { name: track_name, instrument_index: idx, midi_note: nil, steps: poly_steps, polyphony: polyphony, voices: voices, } end end if @tracks.empty? @tracks = [ { name: "Default Track", instrument_index: 0, midi_note: nil, steps: [Array.new(@steps_per_track) { Step.new }], polyphony: 1, voices: [0], }, ] end end def load_midi_file(midi_file_path) seq = MIDI::Sequence.new File.open(midi_file_path, 'rb') do |file| seq.read(file) end puts "Loaded #{seq.tracks.size} tracks" # τϥοΫͷॳظԽʢϙϦϑΥχʔରԠʣ @tracks.each do |track_info| if track_info[:midi_note] # υϥϜτϥοΫͷ৔߹ track_info[:steps].each do |step| step.active = false step.note = nil step.velocity = nil end else # γϯηαΠβʔτϥοΫͷ৔߹ʢ഑ྻͷ഑ྻʣ track_info[:steps].each do |voice_steps| voice_steps.each do |step| step.active = false step.note = nil step.velocity = nil end end end end # Find BPM from tempo events seq.tracks.each do |track| tempo_events = track.events.select { |e| e.kind_of?(MIDI::Tempo) } if tempo_events.any? tempo_event = tempo_events.first @bpm = 60_000_000 / tempo_event.tempo break end end # See if we have any drum tracks to map to synth_tracks = @tracks.select { |t| t[:midi_note].nil? } drum_tracks = @tracks.select { |t| t[:midi_note] } # Skip the first track (usually just tempo/timing info) seq.tracks.each_with_index do |midi_track, track_index| next if track_index == 0 puts "MIDI Track #{track_index}: #{midi_track.name}" # Find all note-on events with velocity > 0 note_on_events = midi_track.events.select do |event| Sequencer is HARD
  25. Note: MIDI Clock MIDI sequencing usually requires high-precision timing (PPQN:

    Pulses Per Quarter Note). Standard sleep functions (like Ruby's sleep) can introduce noticeable timing errors, especially at higher tempos. Professional MIDI equipment and DAWs typically use specialized timing mechanisms or high-resolution timers provided by OS APIs. Currently, my sequencer implementation does not yet provide MIDI clock output or high-accuracy event scheduling.
  26. Conclusion Implement a Synthesizer and Sequencer in Ruby. They run

    independently with each other via dRuby. Through this implementation, I learned more about electronic musical instruments. Ask the Speaker Today's Afternoon Break at Sponsor booth
  27. Filter Low Pass Filter/High Pass Filter Lets {Low,High} frequencies through;

    cuts {High,Low}. Resonance Band Pass Filter Notch Filter etcetc...
  28. Filter - LPF def update_low_pass_alpha rc = 1.0 / (2.0

    * Math::PI * @low_pass_cutoff) @low_pass_alpha = rc / (rc + 1.0 / @sample_rate) # α = RC / (RC + T) end R C V V IN OUT
  29. Filter - LPF def low_pass(input) # snip if @resonance >

    0.01 # snip else output = @low_pass_alpha * input + (1 - @low_pass_alpha) * @low_pass_prev_output @low_pass_prev_input = input @low_pass_prev_output = output output end end
  30. Filter - HPF def update_high_pass_alpha rc = 1.0 / (2.0

    * Math::PI * @high_pass_cutoff) @high_pass_alpha = rc / (rc + 1.0 / @sample_rate) # α = RC / (RC + T) end C V V IN OUT R
  31. Filter - HPF def high_pass(input) # snip output = (1

    - @high_pass_alpha) * (@high_pass_prev_output + input - @high_pass_prev_input) @high_pass_prev_input = input @high_pass_prev_output = output output end