Slide 1

Slide 1 text

No content

Slide 2

Slide 2 text

How to make the Groovebox

Slide 3

Slide 3 text

Yuya Fujiwara a.k.a asonas ✦ I'm Software Engineer ✦ and Monaural Speaker. ✦ Work at IVRy Stereo Speaker Monaural Speaker

Slide 4

Slide 4 text

How to make the Groovebox

Slide 5

Slide 5 text

Groovebox?

Slide 6

Slide 6 text

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.

Slide 7

Slide 7 text

Groovebox Synthesizer Sequencer E ff ector Synthesizer Synthesizer Sampler Mixer

Slide 8

Slide 8 text

No content

Slide 9

Slide 9 text

How to make the Groovebox Why Ruby?

Slide 10

Slide 10 text

No content

Slide 11

Slide 11 text

No content

Slide 12

Slide 12 text

No content

Slide 13

Slide 13 text

Sometimes, making music means wrestling with the gear fi rst.

Slide 14

Slide 14 text

Luckily, I'm a software engineer—so I can write code to fi gure things out.

Slide 15

Slide 15 text

github.com/asonas/ groovebox-ruby

Slide 16

Slide 16 text

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

Slide 17

Slide 17 text

Groovebox Synthesizer Sequencer E ff ector Synthesizer Synthesizer Sampler Mixer

Slide 18

Slide 18 text

Synthesizer Oscilator (VCO) Filter (VCF) Envelope Generator Ampli fi er (VCA)

Slide 19

Slide 19 text

Oscilator Filter Ampli fi er Envelope Generator

Slide 20

Slide 20 text

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

Slide 21

Slide 21 text

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

Slide 22

Slide 22 text

Oscilator Generate waveform Di ff erent waveforms have di ff erent tones Type of Waveform Sine Sawtooth Triangle Square

Slide 23

Slide 23 text

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

Slide 24

Slide 24 text

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)

Slide 25

Slide 25 text

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

Slide 26

Slide 26 text

@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

Slide 27

Slide 27 text

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

Slide 28

Slide 28 text

Demo

Slide 29

Slide 29 text

Envelope Generator (ADSR) 0 time[msec] Amplitude(Volume) Attack Decay Release Sustain Note On Note O f

Slide 30

Slide 30 text

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)

Slide 31

Slide 31 text

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)

Slide 32

Slide 32 text

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)

Slide 33

Slide 33 text

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)

Slide 34

Slide 34 text

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)

Slide 35

Slide 35 text

Demo

Slide 36

Slide 36 text

Slide 37

Slide 37 text

Demo 2

Slide 38

Slide 38 text

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

Slide 39

Slide 39 text

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.

Slide 40

Slide 40 text

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] └─ ...

Slide 41

Slide 41 text

Groovebox Sequencer .mid

Slide 42

Slide 42 text

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

Slide 43

Slide 43 text

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)

Slide 44

Slide 44 text

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

Slide 45

Slide 45 text

#!/usr/bin/env ruby require 'drb/drb' require_relative '../lib/sequencer' DRb.start_service groovebox = DRbObject.new_with_uri('druby://localhost:8786') sequencer = Sequencer.new(groovebox, midi_file_path) sequencer.run

Slide 46

Slide 46 text

@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

Slide 47

Slide 47 text

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

Slide 48

Slide 48 text

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.

Slide 49

Slide 49 text

Demo

Slide 50

Slide 50 text

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

Slide 51

Slide 51 text

Appendix

Slide 52

Slide 52 text

Filter Low Pass Filter/High Pass Filter Lets {Low,High} frequencies through; cuts {High,Low}. Resonance Band Pass Filter Notch Filter etcetc...

Slide 53

Slide 53 text

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

Slide 54

Slide 54 text

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

Slide 55

Slide 55 text

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

Slide 56

Slide 56 text

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