├── .gitignore ├── .rubocop.yml ├── .ruby-version ├── Gemfile ├── Gemfile.lock ├── LICENSE ├── README.md ├── Rakefile ├── bin └── sequencer ├── lib ├── drum_rack.rb ├── envelope.rb ├── groovebox.rb ├── note.rb ├── oscillator.rb ├── presets │ ├── bass.rb │ ├── clap.rb │ ├── cowbell.rb │ ├── hihat.rb │ ├── hihat_closed.rb │ ├── kick.rb │ ├── moog_lead.rb │ ├── piano.rb │ └── snare.rb ├── sequencer.rb ├── sidechain.rb ├── step.rb ├── synthesizer.rb ├── vca.rb └── vcf.rb ├── main.rb ├── midi.rb ├── midi_config.yml ├── test ├── presets │ ├── frequency_test.rb │ └── moog_lead_test.rb └── support │ ├── audio_analyzer.rb │ └── test_vca.rb └── visualizer.html /.gitignore: -------------------------------------------------------------------------------- 1 | tmp/ 2 | .rubocop-* 3 | *.mid 4 | -------------------------------------------------------------------------------- /.rubocop.yml: -------------------------------------------------------------------------------- 1 | inherit_from: https://raw.githubusercontent.com/cookpad/styleguide/refs/heads/master/.rubocop.yml 2 | 3 | Layout/SpaceAroundOperators: 4 | EnforcedStyleForExponentOperator: space 5 | -------------------------------------------------------------------------------- /.ruby-version: -------------------------------------------------------------------------------- 1 | 3.4.1 2 | -------------------------------------------------------------------------------- /Gemfile: -------------------------------------------------------------------------------- 1 | source "https://rubygems.org" 2 | 3 | gem "ffi-portaudio" 4 | gem "midilib" 5 | gem "ruby_wasm" 6 | gem "rubocop" 7 | gem "unimidi" 8 | 9 | gem "wavefile", "~> 1.1" 10 | gem "minitest", "~> 5.18" 11 | -------------------------------------------------------------------------------- /Gemfile.lock: -------------------------------------------------------------------------------- 1 | GEM 2 | remote: https://rubygems.org/ 3 | specs: 4 | alsa-rawmidi (0.4.0) 5 | ffi (~> 1.15, >= 1.15.5) 6 | ast (2.4.2) 7 | ffi (1.17.1) 8 | ffi (1.17.1-arm64-darwin) 9 | ffi-coremidi (0.5.1) 10 | ffi (~> 1.15, >= 1.15.5) 11 | ffi-portaudio (0.1.3) 12 | ffi 13 | json (2.9.1) 14 | language_server-protocol (3.17.0.4) 15 | midi-jruby (0.2.0) 16 | midi-winmm (0.1.10) 17 | ffi (>= 1.0) 18 | midilib (4.0.2) 19 | minitest (5.25.5) 20 | parallel (1.26.3) 21 | parser (3.3.7.0) 22 | ast (~> 2.4.1) 23 | racc 24 | racc (1.8.1) 25 | rainbow (3.1.1) 26 | regexp_parser (2.10.0) 27 | rubocop (1.71.2) 28 | json (~> 2.3) 29 | language_server-protocol (>= 3.17.0) 30 | parallel (~> 1.10) 31 | parser (>= 3.3.0.2) 32 | rainbow (>= 2.2.2, < 4.0) 33 | regexp_parser (>= 2.9.3, < 3.0) 34 | rubocop-ast (>= 1.38.0, < 2.0) 35 | ruby-progressbar (~> 1.7) 36 | unicode-display_width (>= 2.4.0, < 4.0) 37 | rubocop-ast (1.38.0) 38 | parser (>= 3.3.1.0) 39 | ruby-progressbar (1.13.0) 40 | ruby_wasm (2.7.1) 41 | ruby_wasm (2.7.1-arm64-darwin) 42 | unicode-display_width (3.1.4) 43 | unicode-emoji (~> 4.0, >= 4.0.4) 44 | unicode-emoji (4.0.4) 45 | unimidi (0.5.1) 46 | alsa-rawmidi (~> 0.4, >= 0.4.0) 47 | ffi-coremidi (~> 0.5, >= 0.5.1) 48 | midi-jruby (~> 0.2, >= 0.2.0) 49 | midi-winmm (~> 0.1, >= 0.1.10) 50 | wavefile (1.1.2) 51 | 52 | PLATFORMS 53 | arm64-darwin-24 54 | ruby 55 | 56 | DEPENDENCIES 57 | ffi-portaudio 58 | midilib 59 | minitest (~> 5.18) 60 | rubocop 61 | ruby_wasm 62 | unimidi 63 | wavefile (~> 1.1) 64 | 65 | BUNDLED WITH 66 | 2.5.23 67 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2024 asonas 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # groovebox-ruby 2 | 3 | ## Requirements 4 | 5 | - portaudio 6 | - unimidi 7 | 8 | ``` 9 | $ brew install portaudio 10 | $ bundle install 11 | ``` 12 | 13 | ## Usage 14 | 15 | ``` 16 | $ ruby sequencer.rb 17 | ``` 18 | 19 | ``` 20 | $ ruby main.rb 21 | ``` 22 | 23 | ### Demo 24 | 25 | sequencer mode 26 | 27 | https://www.youtube.com/watch?v=zeH4NeFvS7Y 28 | 29 | synth mode 30 | 31 | https://www.youtube.com/watch?v=dy30IP6FijA 32 | -------------------------------------------------------------------------------- /Rakefile: -------------------------------------------------------------------------------- 1 | require 'rake/testtask' 2 | 3 | desc 'Run all tests' 4 | task :test do 5 | $LOAD_PATH.unshift('lib') 6 | Dir.glob('./test/**/*_test.rb').each { |file| require file } 7 | end 8 | 9 | namespace :test do 10 | desc 'Run MoogLead tests' 11 | task :moog_lead do 12 | ruby 'test/presets/moog_lead_test.rb' 13 | end 14 | 15 | desc 'Run frequency analysis tests' 16 | task :frequency do 17 | ruby 'test/presets/frequency_test.rb' 18 | end 19 | end 20 | 21 | task default: :test 22 | -------------------------------------------------------------------------------- /bin/sequencer: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | 3 | require 'drb/drb' 4 | 5 | require_relative '../lib/sequencer' 6 | require_relative '../lib/note' 7 | 8 | midi_file_path = ARGV[0] 9 | 10 | DRb.start_service 11 | puts "Connecting to Groovebox..." 12 | 13 | begin 14 | groovebox = DRbObject.new_with_uri('druby://localhost:8786') 15 | 16 | test_note = Note.new.set_by_name("C4") 17 | groovebox.change_channel(0) 18 | groovebox.note_on(test_note.midi_note, 100) 19 | puts "Playing test note: C4 (MIDI: #{test_note.midi_note})." 20 | sleep 0.5 21 | groovebox.note_off(test_note.midi_note) 22 | puts "Test note stopped." 23 | puts "Connection test successful. Connected to Groovebox." 24 | 25 | rescue DRb::DRbConnError => e 26 | puts "Connection error: #{e.message}" 27 | puts "Please ensure the Groovebox main process (e.g., main.rb) is running." 28 | exit(1) 29 | rescue => e 30 | puts "An unexpected error occurred: #{e.message}" 31 | exit(1) 32 | end 33 | 34 | puts "Successfully connected to Groovebox DRb server at druby://localhost:8786" 35 | 36 | sequencer = Sequencer.new(groovebox, midi_file_path) 37 | 38 | sequencer.run 39 | DRb.thread.join 40 | -------------------------------------------------------------------------------- /lib/drum_rack.rb: -------------------------------------------------------------------------------- 1 | # 規定のmidi_noteから4x4のドラムパッドを生成する 2 | # basa_midi_noteが68で4x8のデバイスの場合は以下のようにアサインされる 3 | # 92 93 94 95 4 | # 84 85 86 87 5 | # 76 77 78 79 6 | # 68 69 70 71 7 | class DrumRack 8 | MAX_TRACKS = 16 9 | 10 | def initialize(base_midi_node) 11 | @tracks = {} 12 | @track_volumes = {} 13 | @base_midi_node = base_midi_node 14 | end 15 | 16 | def add_track(synth, pad_offset = 0, volume = 1.0) 17 | midi_note = @base_midi_node + pad_offset 18 | @tracks[midi_note] = synth 19 | @track_volumes[midi_note] = volume 20 | end 21 | 22 | def set_track_volume(midi_note, volume) 23 | @track_volumes[midi_note] = volume if @tracks.key?(midi_note) 24 | end 25 | 26 | def note_on(midi_note, velocity) 27 | if @tracks[midi_note] 28 | @tracks[midi_note].note_on(midi_note, velocity) 29 | puts "Note On: #{midi_note}, velocity=#{velocity}" 30 | end 31 | end 32 | 33 | def note_off(midi_note) 34 | if @tracks[midi_note] 35 | @tracks[midi_note].note_off(midi_note) 36 | puts "Note Off: #{midi_note}" 37 | end 38 | end 39 | 40 | def generate(buffer_size) 41 | mixed_samples = Array.new(buffer_size, 0.0) 42 | active_tracks = 0 43 | 44 | @tracks.each do |midi_note, synth| 45 | samples = synth.generate(buffer_size) 46 | next if samples.all? { |sample| sample.zero? } 47 | 48 | # トラックごとの音量を適用 49 | volume = @track_volumes[midi_note] || 1.0 50 | samples.map! { |sample| sample * volume } 51 | 52 | mixed_samples = mixed_samples.zip(samples).map { |a, b| a + b } 53 | active_tracks += 1 54 | end 55 | 56 | # 複数のトラックがアクティブな場合は、平方根スケーリングを使用して 57 | # より自然な音量調整を行う(単純な割り算よりも音が小さくなりすぎない) 58 | if active_tracks > 1 59 | gain_adjustment = 1.0 / Math.sqrt(active_tracks) 60 | mixed_samples.map! { |sample| sample * gain_adjustment } 61 | end 62 | 63 | mixed_samples 64 | end 65 | 66 | def pad_notes 67 | @tracks.keys 68 | end 69 | end 70 | -------------------------------------------------------------------------------- /lib/envelope.rb: -------------------------------------------------------------------------------- 1 | class Envelope 2 | attr_accessor :attack, :decay, :sustain, :release 3 | 4 | def initialize(attack: 0.01, decay: 0.1, sustain: 0.7, release: 0.5) 5 | @attack = attack 6 | @decay = decay 7 | @sustain = sustain 8 | @release = release 9 | end 10 | 11 | # サンプル単位で経過秒を計算する 12 | # @param note [Note] ノートオン/オフの情報 13 | # @param sample_index [Integer] 現在のサンプル番号 14 | # @param sample_rate [Integer] サンプリングレート 15 | # @return [Float] 0.0〜1.0の範囲のエンベロープ値 16 | def apply_envelope(note, sample_index, sample_rate) 17 | # Note.on / off 時刻(サンプル番号)がnilなら鳴っていないか無音扱い 18 | return 0.0 if note.note_on_sample_index.nil? 19 | 20 | current_sample_offset = sample_index - note.note_on_sample_index 21 | # まだノートオンしていない未来のサンプルを参照してしまったら0 22 | return 0.0 if current_sample_offset < 0 23 | 24 | current_time = current_sample_offset.to_f / sample_rate 25 | 26 | if note.note_off_sample_index.nil? 27 | # ノートオン〜 28 | if current_time < @attack 29 | # Attack 30 | current_time / @attack 31 | elsif current_time < (@attack + @decay) 32 | # Decay 33 | 1.0 - ((current_time - @attack) / @decay) * (1.0 - @sustain) 34 | else 35 | # Sustain 36 | @sustain 37 | end 38 | else 39 | # Release中 40 | release_start_offset = note.note_off_sample_index - note.note_on_sample_index 41 | # Releaseを開始してから何サンプル経ったか 42 | release_sample_offset = sample_index - note.note_off_sample_index 43 | return 0.0 if release_sample_offset < 0 # まだノートオフしてない時点なら sustain値を適用 44 | 45 | release_time = release_sample_offset.to_f / sample_rate 46 | 47 | # リリース計算 48 | volume_at_release_start = sustain_level_at_release(note, release_start_offset, sample_rate) 49 | envelope_val = volume_at_release_start * (1.0 - (release_time / @release)) 50 | envelope_val.negative? ? 0.0 : envelope_val 51 | end.clamp(0.0, 1.0) 52 | end 53 | 54 | 55 | # 指定された時間におけるエンベロープの値を計算する 56 | # @param current_time [Float] ノートオンからの経過時間(秒) 57 | # @param time_since_note_off [Float, nil] ノートオフからの経過時間(秒)、nilの場合はノートオフされていない 58 | # @return [Float] 0.0〜1.0の範囲のエンベロープ値 59 | def at(current_time, time_since_note_off = nil) 60 | # ノートオンからの経過時間を計算 61 | time_since_note_on = current_time 62 | 63 | # ノートがオフされていない場合 64 | if time_since_note_off.nil? 65 | # Attach 66 | if time_since_note_on < @attack 67 | return time_since_note_on / @attack 68 | end 69 | 70 | # Decay 71 | if time_since_note_on < (@attack + @decay) 72 | decay_progress = (time_since_note_on - @attack) / @decay 73 | return 1.0 - ((1.0 - @sustain) * decay_progress) 74 | end 75 | 76 | # Sustain 77 | return @sustain 78 | else 79 | # Release 80 | # リリース開始時のレベルを計算 81 | release_start_level = 82 | if time_since_note_on < @attack 83 | time_since_note_on / @attack 84 | elsif time_since_note_on < (@attack + @decay) 85 | decay_progress = (time_since_note_on - @attack) / @decay 86 | 1.0 - ((1.0 - @sustain) * decay_progress) 87 | else 88 | @sustain 89 | end 90 | 91 | # Releaseの進行度に応じて値を減衰 92 | if time_since_note_off < @release 93 | return release_start_level * (1.0 - (time_since_note_off / @release)) 94 | else 95 | return 0.0 # リリース終了後は0 96 | end 97 | end 98 | end 99 | 100 | private 101 | 102 | # リリース開始時点(= note_off_sample_index) でのエンベロープレベルを求める 103 | def sustain_level_at_release(note, release_start_offset, sample_rate) 104 | # release_start_offset: ノートオンからノートオフまでのサンプル数 105 | release_start_time = release_start_offset.to_f / sample_rate 106 | 107 | if release_start_time < @attack 108 | # Attack途中でオフになった 109 | release_start_time / @attack 110 | elsif release_start_time < (@attack + @decay) 111 | # Decay途中でオフになった 112 | 1.0 - ((release_start_time - @attack) / @decay) * (1.0 - @sustain) 113 | else 114 | # Sustain状態でオフ 115 | @sustain 116 | end 117 | end 118 | end 119 | -------------------------------------------------------------------------------- /lib/groovebox.rb: -------------------------------------------------------------------------------- 1 | class Groovebox 2 | attr_reader :instruments 3 | def initialize 4 | @instruments = [] 5 | @current_channel = 0 6 | @sequencer_channel = 0 7 | @sidechain_connections = {} 8 | end 9 | 10 | def add_instrument(instrument) 11 | @instruments.push instrument 12 | end 13 | 14 | def change_channel(channel) 15 | @current_channel = channel 16 | end 17 | 18 | def change_sequencer_channel(channel) 19 | @sequencer_channel = channel 20 | end 21 | 22 | def current_instrument 23 | @instruments[@current_channel] 24 | end 25 | 26 | def sequencer_instrument 27 | @instruments[@sequencer_channel] 28 | end 29 | 30 | def current_instrument_index 31 | @current_channel 32 | end 33 | 34 | def get_instrument(index) 35 | @instruments[index] 36 | end 37 | 38 | def note_on(midi_note, velocity) 39 | current_instrument.note_on(midi_note, velocity) 40 | end 41 | 42 | def note_off(midi_note) 43 | current_instrument.note_off(midi_note) 44 | end 45 | 46 | def sequencer_note_on(midi_note, velocity) 47 | sequencer_instrument.note_on(midi_note, velocity) 48 | end 49 | 50 | def sequencer_note_off(midi_note) 51 | sequencer_instrument.note_off(midi_note) 52 | end 53 | 54 | # サイドチェイン接続を設定 55 | # @param trigger_index [Integer] トリガーとなるインストゥルメントのインデックス 56 | # @param target_index [Integer] 効果を受けるインストゥルメントのインデックス 57 | # @param options [Hash] サイドチェインのパラメータ 58 | def setup_sidechain(trigger_index, target_index, options = {}) 59 | return if trigger_index >= @instruments.length || target_index >= @instruments.length 60 | 61 | sidechain = Sidechain.new( 62 | threshold: options[:threshold] || 0.3, 63 | ratio: options[:ratio] || 4.0, 64 | attack: options[:attack] || 0.001, 65 | release: options[:release] || 0.2 66 | ) 67 | 68 | @sidechain_connections[target_index] = { 69 | trigger: trigger_index, 70 | processor: sidechain, 71 | } 72 | end 73 | 74 | def generate(frame_count) 75 | # 各インストゥルメントの生の出力を保存 76 | raw_outputs = [] 77 | @instruments.each do |instrument| 78 | raw_outputs << instrument.generate(frame_count) 79 | end 80 | 81 | processed_outputs = raw_outputs.dup 82 | @sidechain_connections.each do |target_idx, connection| 83 | trigger_idx = connection[:trigger] 84 | sidechain = connection[:processor] 85 | 86 | processed_outputs[target_idx] = sidechain.process( 87 | raw_outputs[trigger_idx], 88 | raw_outputs[target_idx], 89 | SAMPLE_RATE 90 | ) 91 | end 92 | 93 | mixed_samples = Array.new(frame_count, 0.0) 94 | active_instruments = 0 95 | 96 | processed_outputs.each do |samples| 97 | next if samples.all? { |sample| sample.zero? } 98 | 99 | mixed_samples = mixed_samples.zip(samples).map { |a, b| a + b } 100 | active_instruments += 1 101 | end 102 | 103 | if active_instruments > 1 104 | gain_adjustment = 1.0 / Math.sqrt(active_instruments) 105 | mixed_samples.map! { |sample| sample * gain_adjustment } 106 | end 107 | 108 | mixed_samples 109 | end 110 | end 111 | -------------------------------------------------------------------------------- /lib/note.rb: -------------------------------------------------------------------------------- 1 | class Note 2 | BASE_FREQUENCY = 440.0 3 | NOTE_NAMES = %w[C C# D D# E F F# G G# A A# B].freeze 4 | 5 | attr_accessor :phase, :note_on_sample_index, :note_off_sample_index, :midi_note, :velocity 6 | attr_accessor :custom_envelope, :filter_cutoff 7 | 8 | def initialize 9 | @semitone = 0 10 | @midi_note = 69 # A4 11 | @phase = 0.0 12 | @note_on_sample_index = nil 13 | @note_off_sample_index = nil 14 | @velocity = 1.0 15 | @custom_envelope = nil 16 | @filter_cutoff = nil 17 | end 18 | 19 | def frequency 20 | BASE_FREQUENCY * (2 ** (@semitone / 12.0)) 21 | end 22 | 23 | def set_by_name(name) 24 | return self if name.nil? || name.empty? 25 | 26 | begin 27 | note_name = name.gsub(/[0-9]/, '') # 数字を削除してノート名を取得 28 | octave = name.gsub(/[^0-9]/, '').to_i # 数字のみを取得して音高を取得 29 | 30 | note_index = NOTE_NAMES.index(note_name) 31 | return self if note_index.nil? # 無効なノート名の場合は変更しない 32 | 33 | @semitone = (octave - 4) * 12 + note_index - 9 34 | @midi_note = @semitone + 69 35 | 36 | @midi_note = @midi_note.clamp(0, 127) 37 | rescue => e 38 | puts "ノート設定エラー: #{e.message} (入力: #{name})" 39 | end 40 | 41 | self 42 | end 43 | 44 | # MIDIノート番号で示される音高を、A4(=69番)を基準に 45 | # 「何半音ずれているか」を計算し、@semitone に格納する。 46 | # 例えば、MIDIノート60はC4で、これを渡すと 47 | # 60 - 69 = -9 48 | # となり、440Hzを基準に -9 半音分だけ低い周波数になる。 49 | # 50 | # @param [Integer] midi_note MIDIノート番号(0〜127) 51 | # @return [Note] self 52 | def set_by_midi(midi_note) 53 | @midi_note = midi_note 54 | @semitone = midi_note - 69 55 | self 56 | end 57 | 58 | def name 59 | NOTE_NAMES[(@semitone + 9) % 12] 60 | end 61 | 62 | def octave 63 | 4 + ((@semitone + 9) / 12) 64 | end 65 | 66 | def display 67 | "#{name}#{octave} (#{frequency.round(2)} Hz)" 68 | end 69 | end 70 | -------------------------------------------------------------------------------- /lib/oscillator.rb: -------------------------------------------------------------------------------- 1 | class Oscillator 2 | attr_accessor :waveform, :harmonics 3 | 4 | def initialize(waveform, sample_rate) 5 | @waveform = waveform 6 | @sample_rate = sample_rate 7 | @harmonics = [ 8 | # [ 倍音比, 振幅比 ] 9 | [1.0, 1.0], # 基本波 (1倍音) 10 | # [2.0, 0.5], # 第2倍音 11 | # [3.0, 0.3], # 第3倍音 12 | ] 13 | end 14 | 15 | def generate_wave(note, buffer_size) 16 | delta = 2.0 * Math::PI * note.frequency / @sample_rate 17 | 18 | Array.new(buffer_size) do 19 | # 各倍音ごとに合成 20 | sample_sum = 0.0 21 | @harmonics.each do |(harmonic_ratio, amplitude_ratio)| 22 | # 各倍音の周波数を n倍して波形を生成 23 | harmonic_phase = note.phase * harmonic_ratio 24 | 25 | # 波形選択 (waveform) は基本波形を使うが、倍音にも同じ波形を適用する 26 | partial_sample = 27 | case @waveform 28 | when :sine 29 | Math.sin(harmonic_phase) 30 | when :sawtooth 31 | 2.0 * (harmonic_phase / (2.0 * Math::PI) - (harmonic_phase / (2.0 * Math::PI)).floor) - 1.0 32 | when :triangle 33 | 2.0 * (2.0 * ((harmonic_phase / (2.0 * Math::PI)) - 0.5).abs) - 1.0 34 | when :pulse 35 | (harmonic_phase % (2.0 * Math::PI)) < Math::PI ? 1.0 : -1.0 36 | when :square 37 | (harmonic_phase % (2.0 * Math::PI)) < Math::PI ? 0.5 : -0.5 38 | else 39 | 0.0 40 | end 41 | 42 | sample_sum += partial_sample * amplitude_ratio 43 | end 44 | 45 | # 全倍音の合計をメインのsampleとして扱う 46 | note.phase += delta 47 | note.phase -= 2.0 * Math::PI if note.phase > 2.0 * Math::PI 48 | 49 | sample_sum 50 | end 51 | end 52 | end 53 | -------------------------------------------------------------------------------- /lib/presets/bass.rb: -------------------------------------------------------------------------------- 1 | module Presets 2 | class Bass < Synthesizer 3 | attr_accessor :bass_note 4 | attr_accessor :filter_cutoff, :filter_resonance 5 | 6 | def initialize(sample_rate = 44100, amplitude = 1000) 7 | super(sample_rate, amplitude) 8 | 9 | @envelope.attack = 0.01 10 | @envelope.decay = 0.2 11 | @envelope.sustain = 0.4 12 | @envelope.release = 0.3 13 | 14 | @oscillator.waveform = :sawtooth 15 | 16 | @oscillator.harmonics = [ 17 | [1.0, 1.0], # base wave 18 | [2.0, 0.3], # 2nd harmonic 19 | [0.5, 0.5], # 1 octave down, 50% volume 20 | ] 21 | 22 | @vcf.low_pass_cutoff = 800.0 23 | @vcf.high_pass_cutoff = 30.0 24 | 25 | @filter_cutoff = 800.0 26 | @filter_resonance = 0.3 27 | 28 | @bass_note = Note.new.set_by_midi(36) # C1 29 | end 30 | 31 | # @param midi_note [Integer] 32 | # @param velocity [Integer] 33 | def note_on(midi_note, velocity) 34 | new_note = Note.new 35 | semitone_diff = midi_note - @bass_note.midi_note 36 | new_note.set_by_midi(semitone_diff) 37 | 38 | new_note.phase = 0.0 39 | new_note.note_on_sample_index = @global_sample_count 40 | 41 | new_note.velocity = velocity / 127.0 if velocity 42 | 43 | @active_notes[midi_note] = new_note 44 | 45 | note_name = new_note.name + new_note.octave.to_s 46 | puts "bass note_on: midi_note=#{midi_note} (#{note_name}), velocity=#{velocity}, frequency=#{new_note.frequency.round(2)}Hz" 47 | end 48 | 49 | def generate(buffer_size) 50 | return Array.new(buffer_size, 0.0) if @active_notes.empty? 51 | 52 | samples = Array.new(buffer_size, 0.0) 53 | 54 | start_sample_index = @global_sample_count 55 | active_note_count = 0 56 | 57 | @active_notes.each_value do |note| 58 | wave = @oscillator.generate_wave(note, buffer_size) 59 | 60 | wave.each_with_index do |sample_val, idx| 61 | current_sample_index = start_sample_index + idx 62 | env_val = @envelope.apply_envelope(note, current_sample_index, @sample_rate) 63 | 64 | wave[idx] = sample_val * env_val 65 | end 66 | 67 | @vcf.resonance = @filter_resonance 68 | wave = @vcf.process(wave, :low_pass) 69 | 70 | samples = samples.zip(wave).map { |s1, s2| s1 + s2 } 71 | has_sound = false 72 | wave.each do |sample| 73 | if sample != 0.0 74 | has_sound = true 75 | break 76 | end 77 | end 78 | active_note_count += 1 if has_sound 79 | end 80 | 81 | master_gain = 1.0 82 | if active_note_count > 1 83 | master_gain *= (1.0 / Math.sqrt(active_note_count)) 84 | end 85 | samples.map! { |sample| sample * master_gain } 86 | 87 | @global_sample_count += buffer_size 88 | 89 | cleanup_inactive_notes(buffer_size) 90 | 91 | samples 92 | end 93 | 94 | private 95 | 96 | def cleanup_inactive_notes(buffer_size) 97 | @active_notes.delete_if do |note_id, note| 98 | if note.note_off_sample_index 99 | final_env = @envelope.apply_envelope(note, @global_sample_count - 1, @sample_rate) 100 | final_env <= 0.0 101 | else 102 | false 103 | end 104 | end 105 | end 106 | end 107 | end 108 | -------------------------------------------------------------------------------- /lib/presets/clap.rb: -------------------------------------------------------------------------------- 1 | module Presets 2 | class Clap < Synthesizer 3 | attr_accessor :base_note 4 | 5 | def initialize 6 | super 7 | 8 | # 808クラップはバンドパスフィルターをかけたノイズ 9 | @oscillator.waveform = :noise 10 | 11 | # クラップ特有の複数の短いパルス 12 | @envelope.attack = 0.0 13 | @envelope.decay = 0.02 14 | @envelope.sustain = 0.0 15 | @envelope.release = 0.05 # リバーブ的な尾部のために少し長めに 16 | 17 | # バンドパスフィルター設定 18 | @vcf.low_pass_cutoff = 2000.0 # サンプルに合わせて調整 19 | @vcf.high_pass_cutoff = 500.0 # サンプルに合わせて調整 20 | 21 | # 基準となるMIDIノート 22 | @base_note = Note.new.set_by_midi(39) # D#2 23 | end 24 | 25 | def note_on(midi_note, velocity) 26 | new_note = Note.new 27 | # クラップは固定周波数を使用 28 | new_note.set_by_midi(@base_note.midi_note) 29 | new_note.phase = 0.0 30 | new_note.note_on_sample_index = @global_sample_count 31 | 32 | @active_notes[midi_note] = new_note 33 | puts "clap note_on: #{midi_note}, velocity=#{velocity}" 34 | end 35 | 36 | def note_off(midi_note) 37 | super(midi_note) 38 | end 39 | 40 | def generate(buffer_size) 41 | start_sample_index = @global_sample_count 42 | samples = Array.new(buffer_size, 0.0) 43 | 44 | return samples if @active_notes.empty? 45 | 46 | @active_notes.each_value do |note| 47 | note_on_index = note.note_on_sample_index 48 | time_since_note_on = (start_sample_index - note_on_index) / @sample_rate.to_f 49 | 50 | time_since_note_off = nil 51 | if note.note_off_sample_index 52 | time_since_note_off = (start_sample_index - note.note_off_sample_index) / @sample_rate.to_f 53 | end 54 | 55 | # 808クラップの特徴的な複数パルス - サンプルに合わせて調整 56 | pulse_times = [0.0, 0.01, 0.02] # 0ms, 10ms, 20ms にパルス 57 | pulse_amplitudes = [1.0, 0.7, 0.5] # 各パルスの強さ 58 | 59 | # 減衰定数 60 | short_decay = 0.015 # 短いエンベロープの減衰定数(約15ms) 61 | long_decay = 0.05 # 長い「リバーブ」エンベロープの減衰定数(約50ms) 62 | 63 | # ノイズ波形を生成 64 | noise_wave = Array.new(buffer_size) { rand * 2.0 - 1.0 } 65 | 66 | # 複数パルスを適用した波形を生成 67 | pulse_wave = Array.new(buffer_size, 0.0) 68 | 69 | buffer_size.times do |i| 70 | current_time = time_since_note_on + (i / @sample_rate.to_f) 71 | 72 | # 複数パルスのエンベロープを重ね合わせ 73 | env = 0.0 74 | 75 | # ノートオフ後は処理しない 76 | if time_since_note_off.nil? || time_since_note_off < 0 77 | pulse_times.each_with_index do |pt, idx| 78 | if current_time >= pt && current_time < pt + 0.03 # 各パルス約30msの長さで減衰 79 | tau = current_time - pt 80 | # 短いパルスは指数関数的に減衰 81 | env += pulse_amplitudes[idx] * Math.exp(-tau / short_decay) 82 | end 83 | end 84 | 85 | # リバーブ的な長めの減衰(100ms程度まで) 86 | if current_time < 0.1 87 | env += 0.3 * Math.exp(-current_time / long_decay) 88 | end 89 | else 90 | # ノートオフ後の処理 91 | # リリース時間に応じて全体の音量を下げる 92 | if time_since_note_off < @envelope.release 93 | release_factor = 1.0 - (time_since_note_off / @envelope.release) 94 | 95 | pulse_times.each_with_index do |pt, idx| 96 | if current_time >= pt && current_time < pt + 0.03 97 | tau = current_time - pt 98 | env += pulse_amplitudes[idx] * Math.exp(-tau / short_decay) * release_factor 99 | end 100 | end 101 | 102 | if current_time < 0.1 103 | env += 0.3 * Math.exp(-current_time / long_decay) * release_factor 104 | end 105 | end 106 | end 107 | 108 | # 振幅は最大1.0にクリップ 109 | env = env > 1.0 ? 1.0 : env 110 | 111 | pulse_wave[i] = noise_wave[i] * env 112 | end 113 | 114 | # 各ノートの波形を足し合わせる 115 | samples = samples.zip(pulse_wave).map { |s1, s2| s1 + s2 } 116 | end 117 | 118 | # バンドパスフィルタ処理(ローパスとハイパスの両方を適用) 119 | filtered_samples = @vcf.process(samples, :low_pass) 120 | filtered_samples = @vcf.process(filtered_samples, :high_pass) 121 | 122 | # 全体のゲイン調整 123 | master_gain = 12.0 124 | filtered_samples.map! { |sample| sample * master_gain } 125 | 126 | @global_sample_count += buffer_size 127 | cleanup_inactive_notes(buffer_size) 128 | 129 | filtered_samples 130 | end 131 | 132 | private 133 | 134 | def cleanup_inactive_notes(buffer_size) 135 | @active_notes.delete_if do |_, note| 136 | if note.note_off_sample_index 137 | final_env = @envelope.apply_envelope(note, @global_sample_count - 1, @sample_rate) 138 | final_env <= 0.0 139 | else 140 | false 141 | end 142 | end 143 | end 144 | end 145 | end 146 | -------------------------------------------------------------------------------- /lib/presets/cowbell.rb: -------------------------------------------------------------------------------- 1 | module Presets 2 | class Cowbell < DrumSynthBase 3 | def initialize(sample_rate = 44100, amplitude = 1000, base_midi_note = 56, tune = 0) 4 | super(sample_rate, amplitude, base_midi_note, tune) 5 | 6 | # 808カウベルは2つの矩形波で構成 7 | @oscillator.waveform = :square 8 | 9 | # カウベル特有のエンベロープ 10 | @envelope.attack = 0.0 11 | @envelope.decay = 0.4 12 | @envelope.sustain = 0.0 13 | @envelope.release = 0.1 14 | end 15 | 16 | def generate(buffer_size) 17 | start_sample_index = @global_sample_count 18 | samples = Array.new(buffer_size, 0.0) 19 | 20 | return samples if @active_notes.empty? 21 | 22 | @active_notes.each do |midi_note, note| 23 | note_on_index = note.note_on_sample_index 24 | time_since_note_on = (start_sample_index - note_on_index) / @sample_rate.to_f 25 | 26 | time_since_note_off = nil 27 | if note.note_off_sample_index 28 | time_since_note_off = (start_sample_index - note.note_off_sample_index) / @sample_rate.to_f 29 | end 30 | 31 | # 808カウベルの2つの周波数(完全5度) 32 | freq1 = note.frequency 33 | freq2 = note.frequency * 1.5 # 完全5度上 34 | 35 | buffer_size.times do |i| 36 | current_time = time_since_note_on + (i / @sample_rate.to_f) 37 | 38 | # 2つの矩形波 39 | square1 = (Math.sin(2.0 * Math::PI * freq1 * current_time) > 0) ? 1.0 : -1.0 40 | square2 = (Math.sin(2.0 * Math::PI * freq2 * current_time) > 0) ? 1.0 : -1.0 41 | 42 | # 混合(比率は808カウベルに近くなるように調整) 43 | combined = (square1 * 0.6) + (square2 * 0.4) 44 | 45 | # エンベロープ適用 46 | env_value = @envelope.at(current_time, time_since_note_off) 47 | 48 | value = combined * env_value * @amplitude * @velocity 49 | samples[i] += value 50 | end 51 | end 52 | 53 | # 軽いバンドパス処理 54 | filtered_samples = @vcf.process(samples, :low_pass) 55 | 56 | cleanup_inactive_notes(buffer_size) 57 | 58 | master_gain = 0.6 59 | filtered_samples.map! { |sample| sample * master_gain } 60 | 61 | @global_sample_count += buffer_size 62 | 63 | filtered_samples 64 | end 65 | 66 | private 67 | 68 | def cleanup_inactive_notes(buffer_size) 69 | current_time = @global_sample_count / @sample_rate.to_f 70 | buffer_duration = buffer_size / @sample_rate.to_f 71 | 72 | @active_notes.delete_if do |_, note| 73 | if note.note_off_sample_index 74 | time_since_note_off = current_time - (note.note_off_sample_index / @sample_rate.to_f) 75 | time_since_note_off > @envelope.release + buffer_duration 76 | else 77 | false 78 | end 79 | end 80 | end 81 | end 82 | end 83 | -------------------------------------------------------------------------------- /lib/presets/hihat.rb: -------------------------------------------------------------------------------- 1 | # presets/hihat.rb 2 | module Presets 3 | class HiHat < Synthesizer 4 | attr_accessor :base_note 5 | 6 | def initialize(sample_rate = 44100, amplitude = 1000) 7 | super(sample_rate, amplitude) 8 | 9 | @envelope.attack = 0 10 | @envelope.decay = 0.04 11 | @envelope.sustain = 0.0 12 | @envelope.release = 0.01 13 | 14 | # ============== Oscillator設定 ============== 15 | # 金属っぽいスクエア系 16 | @oscillator.waveform = :square 17 | # いくつか倍音をちょっとズラして入れると金属らしさが増す 18 | @oscillator.harmonics = [ 19 | [1.0, 1.0], # 基本(周波数そのまま) 20 | [2.0, 0.6], # 2倍音 21 | [3.1, 0.4], # 3.1倍音 22 | [4.2, 0.3], # 4.2倍音 23 | [5.4, 0.2], # 5.4倍音 24 | ] 25 | 26 | 27 | # ============== 基本周波数 ============== 28 | # ドラム系なのでハイハットは音階を使わず固定でOK 29 | # 適当な高めのMIDIノート値をあてて周波数だけ利用します 30 | @base_note = Note.new.set_by_midi(89) # A#5(=932Hzあたり)など 31 | 32 | # ============== フィルタ設定 ============== 33 | # 808ハイハットっぽくするなら、バンドパス気味にしたり 34 | # 下をそこそこ切って上も少し切ると “シャリ” としやすいです 35 | # ※ここでは実験的に設定。お好みで変更してください。 36 | @vcf.high_pass_cutoff = 500.0 # 低域を削ってシャリ感を出す 37 | @vcf.low_pass_cutoff = 10000.0 # 高域を適度に残す 38 | end 39 | 40 | # ハイハットは固定周波数で鳴らすので、midi_noteは無視 41 | def note_on(midi_note, velocity) 42 | new_note = Note.new 43 | new_note.set_by_midi(@base_note.midi_note) 44 | new_note.phase = 0.0 45 | new_note.note_on_sample_index = @global_sample_count 46 | 47 | @active_notes[midi_note] = new_note 48 | 49 | puts "hihat note_on: #{midi_note}, velocity=#{velocity}" 50 | end 51 | 52 | def generate(buffer_size) 53 | return Array.new(buffer_size, 0.0) if @active_notes.empty? 54 | 55 | start_sample_index = @global_sample_count 56 | samples = Array.new(buffer_size, 0.0) 57 | 58 | @active_notes.each_value do |note| 59 | # ========== オシレーター(複数倍音スクエア波) ========== 60 | wave_body = @oscillator.generate_wave(note, buffer_size) 61 | 62 | # ========== ノイズ(ホワイトノイズ) ========== 63 | wave_noise = Array.new(buffer_size) { (rand * 2.0) - 1.0 } 64 | # フィルタをかける (不要ならコメントアウト) 65 | wave_noise.map!.with_index do |val, i| 66 | @vcf.apply(val) 67 | end 68 | 69 | # ========== ボディ + ノイズ 合成 ========== 70 | body_amp = 0.3 # スクエア波の割合(小さめに設定) 71 | noise_amp = 0.7 # ノイズの割合(大きめに設定) 72 | combined_wave = wave_body.zip(wave_noise).map do |b_val, n_val| 73 | (body_amp * b_val) + (noise_amp * n_val) 74 | end 75 | 76 | # ========== エンベロープ適用 ========== 77 | combined_wave.each_with_index do |sample_val, idx| 78 | current_sample_index = start_sample_index + idx 79 | env_val = @envelope.apply_envelope(note, current_sample_index, @sample_rate) 80 | combined_wave[idx] = sample_val * env_val 81 | end 82 | 83 | # ========== ノート同士の合成 (複数発音) ========== 84 | samples = samples.zip(combined_wave).map { |s1, s2| s1 + s2 } 85 | end 86 | 87 | # 全体ゲイン 88 | master_gain = 5.0 89 | samples.map! { |sample| sample * master_gain } 90 | 91 | @global_sample_count += buffer_size 92 | cleanup_inactive_notes(buffer_size) 93 | 94 | samples 95 | end 96 | end 97 | end 98 | -------------------------------------------------------------------------------- /lib/presets/hihat_closed.rb: -------------------------------------------------------------------------------- 1 | module Presets 2 | class HihatClosed < Synthesizer 3 | def initialize(sample_rate = 44100, amplitude = 1000) 4 | super(sample_rate, amplitude) 5 | 6 | @oscillator.waveform = :square 7 | 8 | # Short envelope for a "tick" sound. 9 | @envelope.attack = 0.0 10 | @envelope.decay = 0.04 11 | @envelope.sustain = 0.0 12 | @envelope.release = 0.03 13 | 14 | # Higher cutoff frequency for hi-hat. 15 | @vcf.high_pass_cutoff = 5500.0 16 | 17 | @base_midi_note = 96 18 | end 19 | 20 | def note_on(midi_note, velocity) 21 | # Apply base MIDI note. 22 | actual_note = @base_midi_note 23 | 24 | new_note = Note.new 25 | new_note.set_by_midi(actual_note) 26 | new_note.phase = 0.0 27 | new_note.note_on_sample_index = @global_sample_count 28 | 29 | @active_notes[midi_note] = new_note 30 | end 31 | 32 | def note_off(midi_note) 33 | if @active_notes[midi_note] 34 | @active_notes[midi_note].note_off_sample_index = @global_sample_count 35 | end 36 | end 37 | 38 | def generate(buffer_size) 39 | start_sample_index = @global_sample_count 40 | samples = Array.new(buffer_size, 0.0) 41 | 42 | return samples if @active_notes.empty? 43 | 44 | @active_notes.each do |midi_note, note| 45 | note_on_index = note.note_on_sample_index 46 | time_since_note_on = (start_sample_index - note_on_index) / @sample_rate.to_f 47 | 48 | time_since_note_off = nil 49 | if note.note_off_sample_index 50 | time_since_note_off = (start_sample_index - note.note_off_sample_index) / @sample_rate.to_f 51 | end 52 | 53 | # Frequency ratios for the six oscillators of the 808 hi-hat (metallic harmonics). 54 | ratios = [1.0, 1.4, 1.7, 2.0, 2.5, 3.0] 55 | 56 | buffer_size.times do |i| 57 | current_time = time_since_note_on + (i / @sample_rate.to_f) 58 | 59 | # make a noise 60 | noise = 0.0 61 | ratios.each do |ratio| 62 | # Slightly detune to add thickness. 63 | detune = rand(-0.01..0.01) 64 | freq = note.frequency * ratio * (1.0 + detune) 65 | 66 | # Combine two phase-shifted square waves. 67 | square1 = (Math.sin(2.0 * Math::PI * freq * current_time) > 0) ? 1.0 : -1.0 68 | square2 = (Math.sin(2.0 * Math::PI * freq * (current_time + 0.5)) > 0) ? 1.0 : -1.0 69 | 70 | noise += (square1 + square2) * 0.5 71 | end 72 | 73 | noise /= ratios.length 74 | 75 | # Also add random noise. 76 | white_noise = rand(-0.3..0.3) # Lower the noise level. 77 | noise = (noise * 0.8) + (white_noise * 0.2) # Lower the white noise ratio. 78 | 79 | # Apply envelope. 80 | env_value = @envelope.at(current_time, time_since_note_off) 81 | 82 | # Pass through the high-pass filter (using VCF class). 83 | value = noise * env_value * @amplitude * 0.8 # Also slightly lower the overall volume. 84 | 85 | # Add to the sample. 86 | samples[i] += value 87 | end 88 | end 89 | 90 | samples = @vcf.process(samples, :high_pass) 91 | 92 | cleanup_inactive_notes(buffer_size) 93 | 94 | master_gain = 1.0 95 | samples.map! { |sample| sample * master_gain } 96 | 97 | @global_sample_count += buffer_size 98 | 99 | samples 100 | end 101 | 102 | private 103 | 104 | def cleanup_inactive_notes(buffer_size) 105 | current_time = @global_sample_count / @sample_rate.to_f 106 | buffer_duration = buffer_size / @sample_rate.to_f 107 | 108 | @active_notes.delete_if do |_, note| 109 | if note.note_off_sample_index 110 | time_since_note_off = current_time - (note.note_off_sample_index / @sample_rate.to_f) 111 | time_since_note_off > @envelope.release + buffer_duration 112 | else 113 | false 114 | end 115 | end 116 | end 117 | end 118 | end 119 | -------------------------------------------------------------------------------- /lib/presets/kick.rb: -------------------------------------------------------------------------------- 1 | module Presets 2 | class Kick < Synthesizer 3 | attr_accessor :base_note 4 | def initialize(sample_rate = 44100, amplitude = 1000) 5 | super(sample_rate, amplitude) 6 | 7 | @envelope.attack = 0.001 8 | @envelope.decay = 0.25 9 | @envelope.sustain = 0.0 10 | @envelope.release = 0.2 11 | 12 | @oscillator.waveform = :sine 13 | 14 | @base_note = Note.new.set_by_midi(36) #C1 15 | end 16 | 17 | def note_on(midi_note, velocity) 18 | new_note = Note.new 19 | 20 | # Since it's a drum kick, the pitch is always fixed. Ignore midi_note. 21 | new_note.set_by_midi(@base_note.midi_note) 22 | new_note.phase = 0.0 23 | new_note.note_on_sample_index = @global_sample_count 24 | 25 | 26 | @active_notes[midi_note] = new_note 27 | puts "kick note_on: #{midi_note}, velocity=#{velocity}" 28 | end 29 | 30 | def note_off(midi_note) 31 | super(midi_note) 32 | end 33 | 34 | # Implement pitch bend. 35 | def generate(buffer_size) 36 | start_sample_index = @global_sample_count 37 | samples = Array.new(buffer_size, 0.0) 38 | 39 | @active_notes.each_value do |note| 40 | pitch_env_duration = 0.05 # Drops rapidly around 50ms. 41 | pitch_ratio = 20.0 # Relative ratio when the pitch is highest. 42 | 43 | wave = @oscillator.generate_wave(note, buffer_size) 44 | 45 | wave.each_with_index do |sample_val, idx| 46 | current_sample_index = start_sample_index + idx 47 | time_sec = (current_sample_index - note.note_on_sample_index).to_f / @sample_rate 48 | 49 | # Pitch envelope: Until time_sec reaches pitch_env_duration, 50 | # assume it exponentially approaches 1.0 from pitch_ratio. 51 | if time_sec < pitch_env_duration 52 | t = time_sec / pitch_env_duration 53 | # Calculation: starts at pitch_ratio times -> approaches 1.0 times at the end. 54 | current_pitch_multiplier = 1.0 + (pitch_ratio - 1.0) * (1.0 - t) 55 | else 56 | current_pitch_multiplier = 1.0 57 | end 58 | 59 | # Apply pitch correction. 60 | sample_val *= current_pitch_multiplier 61 | 62 | # Apply Envelope. 63 | env_val = @envelope.apply_envelope(note, current_sample_index, @sample_rate) 64 | wave[idx] = sample_val * env_val 65 | end 66 | 67 | # Add the waveforms of each note together. 68 | samples = samples.zip(wave).map { |s1, s2| s1 + s2 } 69 | end 70 | 71 | master_gain = 1.0 72 | samples.map! { |sample| sample * master_gain } 73 | 74 | @global_sample_count += buffer_size 75 | cleanup_inactive_notes(buffer_size) 76 | 77 | samples 78 | end 79 | end 80 | end 81 | -------------------------------------------------------------------------------- /lib/presets/moog_lead.rb: -------------------------------------------------------------------------------- 1 | module Presets 2 | class MoogLead < Synthesizer 3 | # Moogフィルターのレゾナンス値(フィルター特有の鋭さ) 4 | attr_accessor :filter_resonance, :filter_cutoff, :filter_envelope_amount 5 | 6 | def initialize(sample_rate = 44100, amplitude = 1000) 7 | super(sample_rate, amplitude) 8 | 9 | # Moogシンセらしいエンベロープ設定 10 | # アタックはやや緩やかに、サステインは高めに設定 11 | @envelope.attack = 0.05 # 中程度のアタック - リードらしい立ち上がり 12 | @envelope.decay = 0.1 # 比較的速い減衰 13 | @envelope.sustain = 0.7 # 高めのサステインレベル - リード楽器向け 14 | @envelope.release = 0.2 # 適度なリリース 15 | 16 | # Moogシンセサイザーらしい倍音豊かな波形設定 17 | # 主にノコギリ波をベースに設定 18 | @oscillator.waveform = :sawtooth # Moogの特徴的な音色に合わせてノコギリ波を選択 19 | 20 | # 倍音を追加して豊かな音にする 21 | @oscillator.harmonics = [ 22 | [1.0, 1.0], # 基本波 23 | [2.0, 0.5], # 2倍音 (半分の音量) 24 | [3.0, 0.3], # 3倍音 (30%の音量) 25 | [4.0, 0.2], # 4倍音 (20%の音量) 26 | ] 27 | 28 | # フィルタ設定 - Moogらしいローパスフィルタの特性 29 | # 特徴的な温かみのある音色を作るためのフィルター設定 30 | @vcf.low_pass_cutoff = 1200.0 # やや低めのカットオフで温かみのある音に 31 | @vcf.high_pass_cutoff = 60.0 # 極低域のみをカット 32 | 33 | # Moog特有のフィルター設定用のパラメーター 34 | @filter_resonance = 0.8 # レゾナンス(0.0-1.0)- 特徴的なピークを作る 35 | @filter_cutoff = 1200.0 # 基本カットオフ周波数 36 | @filter_envelope_amount = 0.6 # フィルターエンベロープのかかり具合 37 | @filter_attack = 0.1 # フィルターエンベロープのアタック 38 | @filter_decay = 0.2 # フィルターエンベロープのディケイ 39 | 40 | # デチューン(複数オシレーターの微妙なずれ)設定 41 | @detune_amount = 0.005 # デチューン量 (5 cents程度) 42 | end 43 | 44 | def note_on(midi_note, velocity) 45 | new_note = Note.new 46 | new_note.set_by_midi(midi_note) 47 | new_note.phase = 0.0 48 | new_note.note_on_sample_index = @global_sample_count 49 | 50 | # ベロシティに応じた音量設定 51 | new_note.velocity = velocity.to_f / 127.0 52 | 53 | # ベロシティによってフィルターカットオフも変化させる 54 | # 強く弾くとブライトな音に、弱く弾くと丸い音に 55 | velocity_filter_mod = (velocity.to_f / 127.0) * 1500.0 # 最大1500Hz上昇 56 | @vcf.low_pass_cutoff = @filter_cutoff + velocity_filter_mod 57 | 58 | @active_notes[midi_note] = new_note 59 | puts "Moog lead note_on: #{midi_note}, velocity=#{velocity}, filter cutoff=#{@vcf.low_pass_cutoff}" 60 | end 61 | 62 | def generate(buffer_size) 63 | return Array.new(buffer_size, 0.0) if @active_notes.empty? 64 | 65 | # 個々の発音を合成する先 66 | samples = Array.new(buffer_size, 0.0) 67 | 68 | start_sample_index = @global_sample_count 69 | active_note_count = 0 70 | 71 | @active_notes.each_value do |note| 72 | # 基本オシレーターで波形生成 73 | wave1 = @oscillator.generate_wave(note, buffer_size) 74 | 75 | # デチューンした2つ目のオシレーター用に一時的なノートを作成 76 | # Noteクラスには frequency= がないので、別インスタンスで生成する 77 | detuned_note = Note.new 78 | detuned_note.set_by_midi(note.midi_note) 79 | # semitoneを直接調整してデチューン効果を出す(frequency=の代わり) 80 | detuned_semitone = note.midi_note - 69 + (@detune_amount * 100 / 100.0) 81 | detuned_note.instance_variable_set(:@semitone, detuned_semitone) 82 | detuned_note.phase = note.phase 83 | 84 | # デチューンしたノートで波形を生成 85 | wave2 = @oscillator.generate_wave(detuned_note, buffer_size) 86 | 87 | # 2つの波形をミックス 88 | wave = wave1.zip(wave2).map { |s1, s2| (s1 + s2) * 0.5 } 89 | 90 | wave.each_with_index do |sample_val, idx| 91 | current_sample_index = start_sample_index + idx 92 | 93 | # エンベロープを適用 94 | env_val = @envelope.apply_envelope(note, current_sample_index, @sample_rate) 95 | # velocity が nil の場合に備えて、デフォルト値を使う 96 | velocity_value = note.velocity.nil? ? 1.0 : note.velocity 97 | 98 | # サンプル値にエンベロープとベロシティを適用 99 | wave[idx] = sample_val * env_val * velocity_value 100 | end 101 | 102 | # フィルタ処理を適用(基本的なローパスフィルター) 103 | wave = @vcf.process(wave, :low_pass) 104 | 105 | # 各ノートの波形を足し合わせる 106 | samples = samples.zip(wave).map { |s1, s2| s1 + s2 } 107 | 108 | # 音が出ているノートをカウント 109 | has_sound = false 110 | wave.each do |sample| 111 | if sample != 0.0 112 | has_sound = true 113 | break 114 | end 115 | end 116 | active_note_count += 1 if has_sound 117 | end 118 | 119 | # 複数音発音時の音量調整 120 | master_gain = 5.0 121 | if active_note_count > 1 122 | master_gain *= (1.0 / Math.sqrt(active_note_count)) 123 | end 124 | 125 | # 最終的な音量調整(アンプリチュード適用) 126 | samples.map! { |sample| sample * master_gain * @amplitude } 127 | 128 | @global_sample_count += buffer_size 129 | cleanup_inactive_notes(buffer_size) 130 | 131 | samples 132 | end 133 | 134 | private 135 | 136 | def cleanup_inactive_notes(buffer_size) 137 | @active_notes.delete_if do |note_id, note| 138 | if note.note_off_sample_index 139 | final_env = @envelope.apply_envelope(note, @global_sample_count - 1, @sample_rate) 140 | final_env <= 0.0 141 | else 142 | false 143 | end 144 | end 145 | end 146 | end 147 | end 148 | -------------------------------------------------------------------------------- /lib/presets/piano.rb: -------------------------------------------------------------------------------- 1 | module Presets 2 | class Piano < Synthesizer 3 | attr_accessor :base_note 4 | attr_accessor :filter_cutoff, :filter_resonance 5 | attr_accessor :brightness, :hardness 6 | attr_accessor :oscillators, :oscillator_mix 7 | 8 | def initialize(sample_rate = 44100, amplitude = 1000) 9 | super(sample_rate, amplitude) 10 | 11 | # Piano-like ADSR envelope settings. 12 | # Fast attack, medium decay, low sustain, long release. 13 | @envelope.attack = 0.001 # Very fast attack. 14 | @envelope.decay = 0.8 # Relatively long decay. 15 | @envelope.sustain = 0.2 # Low sustain level. 16 | @envelope.release = 0.5 # Long release. 17 | 18 | # Main oscillator (inherited from parent class) settings. 19 | @oscillator.waveform = :triangle 20 | 21 | # Reproduce the harmonic structure of the main oscillator. 22 | @oscillator.harmonics = [ 23 | [1.0, 1.0], # Fundamental (100%). 24 | [2.0, 0.5], # 2nd harmonic (50%). 25 | [3.0, 0.3], # 3rd harmonic (30%). 26 | [4.0, 0.2], # 4th harmonic (20%). 27 | [5.0, 0.1], # 5th harmonic (10%). 28 | [6.0, 0.05], # 6th harmonic (5%). 29 | [7.0, 0.025], # 7th harmonic (2.5%). 30 | ] 31 | 32 | # Create additional oscillators (for sound thickness). 33 | @oscillators = [] 34 | 35 | # Oscillator 1: Slightly different waveform characteristics and slightly detuned. 36 | osc1 = Oscillator.new(:sine, sample_rate) 37 | osc1.harmonics = [ 38 | [1.0, 0.8], # Fundamental (80%). 39 | [2.0, 0.4], # 2nd harmonic (40%). 40 | [3.0, 0.2], # 3rd harmonic (20%). 41 | ] 42 | @oscillators << osc1 43 | 44 | # Oscillator 2: Soft-sounding pulse wave (detuned). 45 | osc2 = Oscillator.new(:pulse, sample_rate) 46 | osc2.harmonics = [ 47 | [1.0, 0.3], # Fundamental (30% - subtle). 48 | [2.0, 0.1], # 2nd harmonic (10%). 49 | ] 50 | @oscillators << osc2 51 | 52 | # Oscillator mix ratio [Main, Oscillator 1, Oscillator 2]. 53 | # Note: If the sum exceeds 1.0, the volume might become too high. 54 | @oscillator_mix = [0.6, 0.25, 0.15] 55 | 56 | # Detune values (in cents - 100 cents = 1 semitone). 57 | @detune_cents = [0, 5, -7] # Main, Oscillator 1, Oscillator 2. 58 | 59 | # Filter settings. 60 | @vcf.low_pass_cutoff = 5000.0 # Higher cutoff. 61 | @vcf.high_pass_cutoff = 100.0 # Allows some low frequencies through. 62 | 63 | # Accessible parameters. 64 | @filter_cutoff = 5000.0 65 | @filter_resonance = 0.1 # Subtle resonance. 66 | 67 | # Piano tone adjustment parameters. 68 | @brightness = 0.7 # Brightness (0.0-1.0). 69 | @hardness = 0.5 # Hardness (0.0-1.0). 70 | 71 | # Base note (C3 = Middle C). 72 | @base_note = Note.new.set_by_midi(24) 73 | end 74 | 75 | # @param midi_note [Integer] 76 | # @param velocity [Integer] 77 | # @param midi_note [Integer] 78 | # @param velocity [Integer] 79 | def note_on(midi_note, velocity) 80 | new_note = Note.new 81 | semitone_diff = midi_note - @base_note.midi_note 82 | new_note.set_by_midi(semitone_diff) 83 | 84 | new_note.phase = 0.0 85 | new_note.note_on_sample_index = @global_sample_count 86 | 87 | # Reproduce the behavior where the timbre changes based on velocity (strength). 88 | vel_normalized = velocity / 127.0 89 | new_note.velocity = vel_normalized 90 | 91 | # Dynamically adjust the envelope and timbre according to velocity. 92 | # The harder you play, the faster the attack and the longer the decay. 93 | dynamic_attack = @envelope.attack * (1.0 - (vel_normalized * 0.3)) 94 | dynamic_decay = @envelope.decay * (1.0 + (vel_normalized * 0.5)) 95 | 96 | # Create and adjust a copy of the envelope for each note. 97 | new_note.custom_envelope = @envelope.dup 98 | new_note.custom_envelope.attack = dynamic_attack 99 | new_note.custom_envelope.decay = dynamic_decay 100 | 101 | # Adjust filter cutoff according to velocity. 102 | # The harder you play, the brighter the sound. 103 | new_note.filter_cutoff = @filter_cutoff * (1.0 + (vel_normalized * @brightness)) 104 | 105 | @active_notes[midi_note] = new_note 106 | 107 | note_name = new_note.name + new_note.octave.to_s 108 | puts "piano note_on: midi_note=#{midi_note} (#{note_name}), velocity=#{velocity}, frequency=#{new_note.frequency.round(2)}Hz" 109 | end 110 | 111 | def generate(buffer_size) 112 | return Array.new(buffer_size, 0.0) if @active_notes.empty? 113 | 114 | samples = Array.new(buffer_size, 0.0) 115 | 116 | start_sample_index = @global_sample_count 117 | active_note_count = 0 118 | 119 | @active_notes.each_value do |note| 120 | # Generate waveform for the main oscillator. 121 | main_wave = @oscillator.generate_wave(note, buffer_size) 122 | 123 | # Generate waveforms for additional oscillators. 124 | additional_waves = [] 125 | 126 | @oscillators.each_with_index do |osc, idx| 127 | # Create a temporary note for detuning. 128 | detuned_note = note.dup 129 | 130 | # Apply detuning (convert cent value to pitch ratio). 131 | if @detune_cents[idx + 1] != 0 132 | cent_ratio = 2.0 ** (@detune_cents[idx + 1] / 1200.0) 133 | detuned_freq = note.frequency * cent_ratio 134 | 135 | # Adjust only the frequency (phase, etc., remain the same as the original note). 136 | class << detuned_note 137 | attr_accessor :detuned_frequency 138 | alias_method :original_frequency, :frequency 139 | 140 | def frequency 141 | @detuned_frequency || original_frequency 142 | end 143 | end 144 | 145 | detuned_note.detuned_frequency = detuned_freq 146 | end 147 | 148 | # Generate waveform with the detuned note. 149 | additional_waves << osc.generate_wave(detuned_note, buffer_size) 150 | end 151 | 152 | # Apply envelope to all waveforms. 153 | all_waves = [main_wave] + additional_waves 154 | 155 | all_waves.each_with_index do |wave, wave_idx| 156 | wave.each_with_index do |sample_val, idx| 157 | current_sample_index = start_sample_index + idx 158 | env_val = 159 | if note.custom_envelope 160 | note.custom_envelope.apply_envelope(note, current_sample_index, @sample_rate) 161 | else 162 | @envelope.apply_envelope(note, current_sample_index, @sample_rate) 163 | end 164 | 165 | # Apply the appropriate mix ratio. 166 | wave[idx] = sample_val * env_val * @oscillator_mix[wave_idx] 167 | end 168 | end 169 | 170 | # Combine all waveforms. 171 | combined_wave = Array.new(buffer_size, 0.0) 172 | all_waves.each do |wave| 173 | combined_wave = combined_wave.zip(wave).map { |s1, s2| s1 + s2 } 174 | end 175 | 176 | # Apply note-specific filter cutoff. 177 | @vcf.resonance = @filter_resonance 178 | filter_cutoff = note.filter_cutoff || @filter_cutoff 179 | @vcf.low_pass_cutoff = filter_cutoff 180 | combined_wave = @vcf.process(combined_wave, :low_pass) 181 | 182 | # For high-range keys, additional filtering to emphasize high frequencies. 183 | if note.midi_note > 80 # If in the high range. 184 | high_emphasis = 1.0 + ((note.midi_note - 80) * 0.02) # Emphasize more as the pitch gets higher. 185 | combined_wave.map! { |s| s * high_emphasis } 186 | end 187 | 188 | # For low-range keys, emphasize low frequencies. 189 | if note.midi_note < 48 190 | low_emphasis = 1.0 + ((48 - note.midi_note) * 0.03) # Emphasize more as the pitch gets lower. 191 | combined_wave.map! { |s| s * low_emphasis } 192 | end 193 | 194 | samples = samples.zip(combined_wave).map { |s1, s2| s1 + s2 } 195 | has_sound = combined_wave.any? { |sample| sample != 0.0 } 196 | active_note_count += 1 if has_sound 197 | end 198 | 199 | master_gain = 1.0 # Master gain adjustment (reduce volume when multiple notes are playing) 200 | if active_note_count > 1 201 | # If multiple notes are playing, reduce the gain. 202 | master_gain *= (1.0 / Math.sqrt(active_note_count)) 203 | end 204 | samples.map! { |sample| sample * master_gain } 205 | 206 | @global_sample_count += buffer_size 207 | 208 | cleanup_inactive_notes(buffer_size) 209 | 210 | samples 211 | end 212 | 213 | private 214 | 215 | def cleanup_inactive_notes(buffer_size) 216 | @active_notes.delete_if do |note_id, note| 217 | if note.note_off_sample_index 218 | env = note.custom_envelope || @envelope 219 | final_env = env.apply_envelope(note, @global_sample_count - 1, @sample_rate) 220 | final_env <= 0.0 221 | else 222 | false 223 | end 224 | end 225 | end 226 | end 227 | end 228 | -------------------------------------------------------------------------------- /lib/presets/snare.rb: -------------------------------------------------------------------------------- 1 | # presets/snare.rb 2 | module Presets 3 | class Snare < Synthesizer 4 | attr_accessor :base_note 5 | 6 | def initialize(sample_rate = 44100, amplitude = 1000) 7 | super(sample_rate, amplitude) 8 | 9 | # 808 Snare-like envelope. 10 | @envelope.attack = 0.0001 # Almost instantaneous attack. 11 | @envelope.decay = 0.2 # Body decays relatively quickly. 12 | @envelope.sustain = 0.0 # Sustain is 0 (like percussion, sounds and decays at once). 13 | @envelope.release = 0.05 # Release is also short. 14 | 15 | # Set the body oscillator to sine wave. 16 | @oscillator.waveform = :sine 17 | 18 | # 808 snare is typically around 180Hz. 19 | # MIDI note 54 (F#3) is approx. 185Hz, so fix it here. 20 | @base_note = Note.new.set_by_midi(54) 21 | 22 | # Example filter initial settings (adjust/disable as desired). 23 | # @vcf.high_pass_cutoff = 600.0 # e.g., if you want to cut some low frequencies of the noise. 24 | # @vcf.low_pass_cutoff = 8000.0 # Keep some high frequencies. 25 | end 26 | 27 | # Similar to Kick, it's a drum sound, so ignore the MIDI note value itself and play a fixed frequency. 28 | def note_on(midi_note, velocity) 29 | new_note = Note.new 30 | new_note.set_by_midi(@base_note.midi_note) 31 | new_note.phase = 0.0 32 | new_note.note_on_sample_index = @global_sample_count 33 | 34 | @active_notes[midi_note] = new_note 35 | 36 | puts "snare note_on: #{midi_note}, velocity=#{velocity}" 37 | end 38 | 39 | # If no changes are needed, note_off can be the same as the parent class. 40 | def note_off(midi_note) 41 | super(midi_note) 42 | end 43 | 44 | def generate(buffer_size) 45 | return Array.new(buffer_size, 0.0) if @active_notes.empty? 46 | 47 | start_sample_index = @global_sample_count 48 | samples = Array.new(buffer_size, 0.0) 49 | 50 | @active_notes.each_value do |note| 51 | # --- Body part (sine wave) --- 52 | wave_body = @oscillator.generate_wave(note, buffer_size) 53 | 54 | # --- Noise part (white noise) --- 55 | wave_noise = Array.new(buffer_size) { (rand * 2.0) - 1.0 } 56 | 57 | # Apply VCF (filter) here if desired. 58 | # wave_noise.map!.with_index do |val, i| 59 | # @vcf.apply(val) 60 | # end 61 | 62 | # Body/noise volume balance (adjust as needed). 63 | body_amp = 0.7 64 | noise_amp = 0.4 65 | 66 | # Combine body + noise. 67 | combined_wave = wave_body.zip(wave_noise).map do |b_val, n_val| 68 | (body_amp * b_val) + (noise_amp * n_val) 69 | end 70 | 71 | # Apply envelope. 72 | combined_wave.each_with_index do |sample_val, idx| 73 | current_sample_index = start_sample_index + idx 74 | env_val = @envelope.apply_envelope(note, current_sample_index, @sample_rate) 75 | 76 | combined_wave[idx] = sample_val * env_val 77 | end 78 | 79 | # Combine with other notes (polyphony). 80 | samples = samples.zip(combined_wave).map { |s1, s2| s1 + s2 } 81 | end 82 | 83 | # Overall gain. 84 | master_gain = 1.0 85 | samples.map! { |sample| sample * master_gain } 86 | 87 | @global_sample_count += buffer_size 88 | cleanup_inactive_notes(buffer_size) 89 | 90 | samples 91 | end 92 | end 93 | end 94 | -------------------------------------------------------------------------------- /lib/sequencer.rb: -------------------------------------------------------------------------------- 1 | require 'io/console' 2 | require 'ffi-portaudio' 3 | require 'drb/drb' 4 | require 'midilib' 5 | 6 | SAMPLE_RATE = 44100 7 | BUFFER_SIZE = 128 8 | 9 | 10 | require_relative "groovebox" 11 | require_relative "drum_rack" 12 | require_relative "synthesizer" 13 | require_relative "note" 14 | require_relative "vca" 15 | require_relative "step" 16 | 17 | require_relative "presets/bass" 18 | require_relative "presets/kick" 19 | require_relative "presets/snare" 20 | require_relative "presets/hihat_closed" 21 | require_relative "presets/piano" 22 | require_relative "sidechain" 23 | 24 | class Sequencer 25 | attr_reader :tracks, :steps_per_track 26 | 27 | DEFAULT_NOTES = ["C3", "C#3", "D3", "D#3", "E3", "F3", "F#3", "G3", "G#3", "A3", "A#3", "B3", 28 | "C4", "C#4", "D4", "D#4", "E4", "F4", "F#4", "G4", "G#4", "A4", "A#4", "B4",] 29 | 30 | NOTE_TO_MIDI = {} 31 | DEFAULT_NOTES.each_with_index do |note_name, idx| 32 | NOTE_TO_MIDI[note_name] = 48 + idx 33 | end 34 | 35 | def initialize(groovebox = nil, mid_file_path = nil) 36 | @groovebox = groovebox 37 | @current_position = 0 38 | @current_track = 0 39 | @steps_per_track = 32 40 | @tracks = [] 41 | @playing = false 42 | @bpm = 120 43 | 44 | initialize_tracks 45 | 46 | if mid_file_path 47 | puts "Loading MIDI file: #{mid_file_path}" 48 | load_midi_file(mid_file_path) 49 | end 50 | end 51 | 52 | def initialize_tracks 53 | return if @groovebox.nil? 54 | 55 | @instruments = @groovebox.instruments 56 | 57 | @instruments.each_with_index do |instrument, idx| 58 | track_name = "Track #{idx}" 59 | if instrument.respond_to?(:pad_notes) 60 | instrument.pad_notes.sort.each do |pad_note| 61 | track = Array.new(@steps_per_track) { Step.new } 62 | @tracks << { name: "Drum #{pad_note}", instrument_index: idx, midi_note: pad_note, steps: track } 63 | end 64 | else 65 | track = Array.new(@steps_per_track) { Step.new } 66 | @tracks << { name: track_name, instrument_index: idx, midi_note: nil, steps: track } 67 | end 68 | end 69 | 70 | if @tracks.empty? 71 | @tracks = [ 72 | { 73 | name: "Default Track", 74 | instrument_index: 0, 75 | midi_note: nil, 76 | steps: Array.new(@steps_per_track) { Step.new }, 77 | }, 78 | ] 79 | end 80 | end 81 | 82 | def load_midi_file(midi_file_path) 83 | seq = MIDI::Sequence.new 84 | 85 | File.open(midi_file_path, 'rb') do |file| 86 | seq.read(file) 87 | end 88 | 89 | puts "Loaded #{seq.tracks.size} tracks" 90 | 91 | @tracks.each do |track_info| 92 | track_info[:steps].each do |step| 93 | step.active = false 94 | step.note = nil 95 | step.velocity = nil 96 | end 97 | end 98 | 99 | # Find BPM from tempo events 100 | seq.tracks.each do |track| 101 | tempo_events = track.events.select { |e| e.kind_of?(MIDI::Tempo) } 102 | if tempo_events.any? 103 | tempo_event = tempo_events.first 104 | @bpm = 60_000_000 / tempo_event.tempo 105 | break 106 | end 107 | end 108 | 109 | # See if we have any drum tracks to map to 110 | synth_tracks = @tracks.select { |t| t[:midi_note].nil? } 111 | drum_tracks = @tracks.select { |t| t[:midi_note] } 112 | 113 | # Skip the first track (usually just tempo/timing info) 114 | seq.tracks.each_with_index do |midi_track, track_index| 115 | next if track_index == 0 116 | 117 | puts "MIDI Track #{track_index}: #{midi_track.name}" 118 | 119 | # Find all note-on events with velocity > 0 120 | note_on_events = midi_track.events.select do |event| 121 | event.kind_of?(MIDI::NoteOn) && event.velocity > 0 122 | end 123 | 124 | # Skip tracks with no notes 125 | next if note_on_events.empty? 126 | 127 | # Detect if this is a drum track (MIDI channel 10) 128 | channel = note_on_events.first.channel if note_on_events.first.respond_to?(:channel) 129 | is_drum_track = channel == 9 130 | 131 | # Count occurrences of each note 132 | note_counts = Hash.new(0) 133 | note_on_events.each { |event| note_counts[event.note] += 1 } 134 | 135 | puts " Note distribution: #{note_counts.inspect}" 136 | puts " Channel: #{channel}, Drum track: #{is_drum_track}" 137 | 138 | ticks_per_step = seq.ppqn / 4.0 # Ticks per 16th note 139 | 140 | if is_drum_track 141 | # For drum tracks, find the corresponding drum track for each note 142 | note_counts.each do |note_num, count| 143 | # Find drum track matching this note 144 | matching_drum_track = drum_tracks.find { |dt| dt[:midi_note].to_i == note_num } 145 | 146 | # Skip if no matching drum track found 147 | next unless matching_drum_track 148 | 149 | puts " Assigning drum note #{note_num} to track #{matching_drum_track[:name]}" 150 | 151 | # Extract events only for this drum note 152 | drum_events = note_on_events.select { |event| event.note == note_num } 153 | 154 | # Set steps 155 | drum_events.each do |event| 156 | step_index = (event.time_from_start / ticks_per_step).to_i 157 | next if step_index >= @steps_per_track 158 | 159 | puts " Setting drum note at step #{step_index + 1}" 160 | 161 | matching_drum_track[:steps][step_index].active = true 162 | matching_drum_track[:steps][step_index].note = note_num.to_s 163 | matching_drum_track[:steps][step_index].velocity = event.velocity 164 | end 165 | end 166 | else 167 | # For synth tracks, use the next available synth track 168 | target_track = synth_tracks.first 169 | synth_tracks.shift # Remove the used track 170 | 171 | # Skip if no synth tracks available 172 | next unless target_track 173 | 174 | puts " Assigning synth notes to track #{target_track[:name]}" 175 | 176 | # Set steps 177 | note_on_events.each do |event| 178 | step_index = (event.time_from_start / ticks_per_step).to_i 179 | next if step_index >= @steps_per_track 180 | 181 | # Convert note number to note name 182 | note_obj = Note.new.set_by_midi(event.note) 183 | note_name = "#{note_obj.name}#{note_obj.octave}" 184 | 185 | puts " Setting note #{note_name} at step #{step_index + 1}" 186 | 187 | target_step = target_track[:steps][step_index] 188 | target_step.active = true 189 | target_step.note = note_name 190 | target_step.velocity = event.velocity 191 | end 192 | end 193 | end 194 | 195 | # Initialize after loading from MIDI file 196 | @current_position = 0 197 | end 198 | 199 | def toggle_step(track_index, step_index) 200 | track = @tracks[track_index][:steps] 201 | step = track[step_index] 202 | 203 | step.active = !step.active 204 | 205 | # Set default note if needed for active synth track steps 206 | if step.active && @tracks[track_index][:midi_note].nil? && step.note.nil? 207 | step.note = "C4" 208 | end 209 | end 210 | 211 | # Transpose note up by one semitone 212 | def transpose_note_up(step) 213 | return unless step && step.active && step.note 214 | 215 | note = Note.new.set_by_name(step.note) 216 | 217 | new_midi_note = note.midi_note + 1 218 | return if new_midi_note > 127 # Check maximum value 219 | 220 | new_note = Note.new.set_by_midi(new_midi_note) 221 | 222 | step.note = "#{new_note.name}#{new_note.octave}" 223 | end 224 | 225 | # Transpose note down by one semitone 226 | def transpose_note_down(step) 227 | return unless step && step.active && step.note 228 | 229 | note = Note.new.set_by_name(step.note) 230 | 231 | new_midi_note = note.midi_note - 1 232 | return if new_midi_note < 0 # Check minimum value 233 | 234 | new_note = Note.new.set_by_midi(new_midi_note) 235 | 236 | step.note = "#{new_note.name}#{new_note.octave}" 237 | end 238 | 239 | # Transpose note up by one octave 240 | def transpose_octave_up(step) 241 | return unless step && step.active && step.note 242 | 243 | note = Note.new.set_by_name(step.note) 244 | 245 | new_midi_note = note.midi_note + 12 246 | return if new_midi_note > 127 # Check maximum value 247 | 248 | new_note = Note.new.set_by_midi(new_midi_note) 249 | 250 | step.note = "#{new_note.name}#{new_note.octave}" 251 | end 252 | 253 | # Transpose note down by one octave 254 | def transpose_octave_down(step) 255 | return unless step && step.active && step.note 256 | 257 | note = Note.new.set_by_name(step.note) 258 | 259 | new_midi_note = note.midi_note - 12 260 | return if new_midi_note < 0 # Check minimum value 261 | 262 | new_note = Note.new.set_by_midi(new_midi_note) 263 | 264 | step.note = "#{new_note.name}#{new_note.octave}" 265 | end 266 | 267 | def display 268 | system('clear') 269 | puts "Groovebox Sequencer" + (@playing ? " (Playing)" : "") 270 | puts "===================" 271 | 272 | # ステップの番号を表示 273 | print " " 274 | puts (1..@steps_per_track).map { |n| n.to_s.rjust(4) }.join 275 | 276 | # 各トラックのステップを表示 277 | @tracks.each_with_index do |track_info, track_idx| 278 | track_name = track_info[:name][0..8] 279 | 280 | if track_idx == @current_track 281 | print "→ #{track_name.ljust(9)} " 282 | else 283 | print " #{track_name.ljust(9)} " 284 | end 285 | 286 | # トラックのステップを表示 287 | steps = track_info[:steps] 288 | puts steps.map.with_index { |step, step_idx| 289 | # ドラムトラックとシンセトラックで表示を変える 290 | is_drum = track_info[:midi_note] != nil 291 | 292 | step_display = if is_drum 293 | # ドラムの場合はノート表示ではなく「xx」と表示 294 | step.active ? "xx" : "__" 295 | else 296 | # シンセの場合はノート名を表示 297 | note_display = step.note && step.active ? step.note[0..1] : "__" 298 | note_display 299 | end 300 | 301 | if track_idx == @current_track && step_idx == @current_position 302 | if step.active 303 | "[#{step_display}]" 304 | else 305 | "[__]" 306 | end 307 | else 308 | step.active ? " #{step_display} " : " __ " 309 | end 310 | }.join 311 | end 312 | 313 | puts "\nCommands:" 314 | puts " Space: Play/Stop" 315 | puts " Arrow keys: Move cursor" 316 | puts " Enter: Toggle step" 317 | puts " s: Save as MIDI file (format: yyyy-mm-dd-hh-mm-ss.mid)" 318 | puts " Ctrl+C: Exit" 319 | end 320 | 321 | def play_sequence 322 | return unless @groovebox 323 | 324 | if @playing 325 | @playing = false 326 | 327 | # 停止時にすべてのアクティブなノートをオフにする 328 | all_notes_off 329 | return 330 | end 331 | 332 | @playing = true 333 | puts "BPM: #{@bpm}" 334 | 335 | Thread.new do 336 | step_interval = 60.0 / @bpm / 4 337 | play_position = 0 338 | 339 | while @playing 340 | # 各トラックの現在位置のステップをチェック 341 | @tracks.each do |track_info| 342 | step = track_info[:steps][play_position] 343 | 344 | if step.active 345 | instrument_index = track_info[:instrument_index] 346 | 347 | if track_info[:midi_note].nil? 348 | begin 349 | # ノート文字列からMIDIノート番号に変換 350 | note_obj = Note.new.set_by_name(step.note) 351 | velocity = step.velocity || 100 352 | 353 | @groovebox.change_sequencer_channel(instrument_index) 354 | 355 | # Grooveboxに直接note_onを呼び出す 356 | @groovebox.sequencer_note_on(note_obj.midi_note, velocity) 357 | rescue => e 358 | puts "error synth: #{e.message}" 359 | end 360 | else 361 | # DrumRack 362 | begin 363 | midi_note = track_info[:midi_note] 364 | velocity = step.velocity || 100 365 | 366 | # チャンネルを変更 367 | @groovebox.change_sequencer_channel(instrument_index) 368 | 369 | # Grooveboxに特定のチャンネルを設定してからnote_onを呼び出す 370 | @groovebox.sequencer_note_on(midi_note, velocity) 371 | rescue => e 372 | puts "error drum: #{e.message}" 373 | end 374 | end 375 | end 376 | end 377 | 378 | # 少し待ってノートをオフにする 379 | sleep step_interval * 0.8 380 | 381 | # ノートをオフにする 382 | @tracks.each do |track_info| 383 | step = track_info[:steps][play_position] 384 | if step.active 385 | instrument_index = track_info[:instrument_index] 386 | 387 | if track_info[:midi_note].nil? 388 | # Synthesizer 389 | # TODO: track_infoからは分かりづらいのでclassで判断できるようにする 390 | begin 391 | note_obj = Note.new.set_by_name(step.note) 392 | @groovebox.change_sequencer_channel(instrument_index) 393 | @groovebox.sequencer_note_off(note_obj.midi_note) 394 | rescue => e 395 | puts "error synth: #{e.message}" 396 | end 397 | else 398 | # DrumRack 399 | begin 400 | midi_note = track_info[:midi_note] 401 | @groovebox.change_sequencer_channel(instrument_index) 402 | @groovebox.sequencer_note_off(midi_note) 403 | rescue => e 404 | puts "error drum: #{e.message}" 405 | end 406 | end 407 | end 408 | end 409 | 410 | # 残りのステップ時間を待つ 411 | sleep step_interval * 0.2 412 | 413 | # 次のステップへ 414 | play_position = (play_position + 1) % @steps_per_track 415 | end 416 | end 417 | end 418 | 419 | def all_notes_off 420 | return unless @groovebox 421 | 422 | # 各トラックに対して処理 423 | @tracks.each do |track_info| 424 | instrument_index = track_info[:instrument_index] 425 | @groovebox.change_sequencer_channel(instrument_index) 426 | 427 | if track_info[:midi_note].nil? 428 | # シンセサイザーやベースなどの場合 429 | # すべてのMIDIノート(0-127)に対してnote_offを送信 430 | (0..127).each do |midi_note| 431 | @groovebox.sequencer_note_off(midi_note) 432 | end 433 | else 434 | # DrumRackのパッドの場合 435 | midi_note = track_info[:midi_note] 436 | @groovebox.sequencer_note_off(midi_note) 437 | end 438 | end 439 | 440 | puts "all notes off" 441 | end 442 | 443 | def save_to_midi_file 444 | # MIDIシーケンスの作成 445 | seq = MIDI::Sequence.new 446 | # テンポの設定 447 | seq.ppqn = 480 # Pulses Per Quarter Note (四分音符あたりのティック数) 448 | 449 | # テンポトラックの作成 450 | tempo_track = MIDI::Track.new(seq) 451 | seq.tracks << tempo_track 452 | tempo_track.name = "Tempo Track" 453 | 454 | # テンポイベントの追加 455 | tempo_event = MIDI::Tempo.new(tempo_track) 456 | tempo_event.tempo = 60_000_000 / @bpm # マイクロ秒/四分音符でテンポを指定 457 | tempo_track.events << tempo_event 458 | tempo_track.events << MIDI::MetaEvent.new(MIDI::META_TRACK_END, nil, 0) 459 | 460 | # デバッグ情報 461 | puts "Saving steps:" 462 | 463 | # シンセトラックとドラムトラックを分けずに処理 464 | @tracks.each_with_index do |track_info, track_idx| 465 | # アクティブなステップがなければ、このトラックをスキップ 466 | active_steps = track_info[:steps].select(&:active) 467 | next if active_steps.empty? 468 | 469 | # トラック情報の出力 470 | track_type = track_info[:midi_note].nil? ? "Synth" : "Drum" 471 | puts "Track #{track_idx}: #{track_info[:name]} (#{track_type})" 472 | 473 | # MIDIトラック作成 474 | midi_track = MIDI::Track.new(seq) 475 | seq.tracks << midi_track 476 | midi_track.name = track_info[:name] 477 | 478 | # チャンネル設定 479 | channel = track_info[:midi_note].nil? ? 0 : 9 # シンセなら0、ドラムなら9(チャンネル10) 480 | 481 | # プログラムチェンジ設定 482 | prog_num = track_info[:instrument_index] % 128 483 | prog_chg = MIDI::ProgramChange.new(channel, prog_num) 484 | midi_track.events << prog_chg 485 | 486 | # ステップデータの追加 487 | ticks_per_step = seq.ppqn / 4.0 # 16分音符あたりのティック数 488 | 489 | last_time = 0 # 前回のタイムスタンプを記録 490 | 491 | # アクティブなステップだけを処理するためにソート 492 | active_step_indices = [] 493 | track_info[:steps].each_with_index do |step, idx| 494 | active_step_indices << idx if step.active 495 | end 496 | 497 | # ステップインデックスでソートして処理 498 | active_step_indices.sort.each do |step_idx| 499 | step = track_info[:steps][step_idx] 500 | 501 | # ノートが有効か確認 502 | next unless step.note 503 | 504 | # ノートの開始位置(ティック単位) 505 | start_time = (step_idx * ticks_per_step).to_i 506 | 507 | # デルタタイム(前回のイベントからの経過時間) 508 | delta_time = start_time - last_time 509 | last_time = start_time 510 | 511 | # ノートの長さ(ティック単位) - 16分音符の長さ 512 | duration = (ticks_per_step * 0.95).to_i 513 | 514 | # MIDI番号への変換 515 | midi_note = nil 516 | if track_info[:midi_note].nil? 517 | # シンセサイザーの場合、ノート名からMIDIノート番号に変換 518 | begin 519 | note_obj = Note.new.set_by_name(step.note) 520 | midi_note = note_obj.midi_note 521 | puts " Step #{step_idx + 1}: Note #{step.note} (MIDI: #{midi_note})" 522 | rescue => e 523 | puts "Warning: Failed to convert note '#{step.note}': #{e.message}" 524 | next 525 | end 526 | else 527 | # DrumRackの場合、midi_noteを使用 528 | midi_note = track_info[:midi_note].to_i 529 | puts " Step #{step_idx + 1}: Drum pad #{midi_note}" 530 | end 531 | 532 | # ベロシティ 533 | velocity = step.velocity || 100 534 | 535 | # ノートオンイベント 536 | note_on = MIDI::NoteOn.new(channel, midi_note, velocity) 537 | note_on.time_from_start = start_time 538 | midi_track.events << note_on 539 | 540 | # ノートオフイベント 541 | note_off = MIDI::NoteOff.new(channel, midi_note, 0) 542 | note_off.time_from_start = start_time + duration 543 | midi_track.events << note_off 544 | end 545 | 546 | # トラックの終了イベント 547 | midi_track.events << MIDI::MetaEvent.new(MIDI::META_TRACK_END, nil, 0) 548 | end 549 | 550 | # 現在の日時を使ってファイル名を生成 551 | timestamp = Time.now.strftime("%Y-%m-%d-%H-%M-%S") 552 | filename = "#{timestamp}.mid" 553 | 554 | # イベントをソート 555 | seq.tracks.each do |track| 556 | track.recalc_delta_from_times 557 | end 558 | 559 | # MIDIファイルを保存 560 | File.open(filename, 'wb') do |file| 561 | seq.write(file) 562 | end 563 | 564 | puts "MIDI file saved: #{filename}" 565 | sleep 1 # Short pause to display message 566 | true 567 | end 568 | 569 | def run 570 | return puts "Groovebox is not connected" unless @groovebox 571 | 572 | # Initialize if tracks are empty 573 | initialize_tracks if @tracks.empty? 574 | 575 | loop do 576 | display 577 | key = STDIN.getch 578 | 579 | # 現在のステップを取得 (編集モードがないので常に必要) 580 | current_step = @tracks[@current_track][:steps][@current_position] if @current_track < @tracks.size 581 | 582 | case key 583 | when "\e" # エスケープキーまたは特殊キー 584 | # エスケープキーは何もしない (以前は編集モード終了) 585 | 586 | # 矢印キーの場合 587 | next_key = STDIN.getch 588 | if next_key == "[" 589 | case STDIN.getch 590 | when "D" # 左矢印 591 | @current_position -= 1 if @current_position > 0 592 | when "C" # 右矢印 593 | @current_position += 1 if @current_position < @steps_per_track - 1 594 | when "A" # 上矢印 595 | @current_track -= 1 if @current_track > 0 596 | when "B" # 下矢印 597 | @current_track += 1 if @current_track < @tracks.size - 1 598 | end 599 | end 600 | when "\r" # Enter 601 | toggle_step(@current_track, @current_position) 602 | when " " # スペース 603 | play_sequence 604 | when "s", "S" # MIDI保存 605 | save_to_midi_file 606 | when "\u0003" # Ctrl+C 607 | @playing = false # Stop if playing 608 | all_notes_off # Turn off all notes 609 | puts "exiting..." 610 | break 611 | end 612 | end 613 | end 614 | end 615 | -------------------------------------------------------------------------------- /lib/sidechain.rb: -------------------------------------------------------------------------------- 1 | class Sidechain 2 | attr_accessor :threshold, :ratio, :attack, :release 3 | 4 | def initialize(threshold: 0.5, ratio: 4.0, attack: 0.001, release: 0.2) 5 | @threshold = threshold # トリガーがこの値を超えたら処理開始 6 | @ratio = ratio # 圧縮比 7 | @attack = attack # 圧縮が始まるまでの時間(秒) 8 | @release = release # 圧縮が解除されるまでの時間(秒) 9 | @envelope = 1.0 # 現在の圧縮エンベロープ値 10 | @sample_rate = 44100 # デフォルトのサンプルレート 11 | end 12 | 13 | # トリガー信号とターゲット信号を受け取り、サイドチェイン処理を適用 14 | def process(trigger_samples, target_samples, sample_rate = 44100) 15 | @sample_rate = sample_rate 16 | buffer_size = [trigger_samples.length, target_samples.length].min 17 | 18 | processed_samples = target_samples.dup 19 | 20 | buffer_size.times do |i| 21 | # トリガー信号の強さを検出 22 | trigger_level = trigger_samples[i].abs 23 | 24 | # しきい値を超えたら圧縮を開始 25 | if trigger_level > @threshold 26 | # アタック時間に基づいて圧縮を適用 27 | @envelope -= @attack * 10 # アタック速度調整 28 | @envelope = 0.0 if @envelope < 0.0 29 | else 30 | # リリース時間に基づいて圧縮を解除 31 | @envelope += @release / (@sample_rate * 0.1) # リリース速度調整 32 | @envelope = 1.0 if @envelope > 1.0 33 | end 34 | 35 | # ターゲット信号にエンベロープを適用 36 | processed_samples[i] *= @envelope 37 | end 38 | 39 | processed_samples 40 | end 41 | end 42 | -------------------------------------------------------------------------------- /lib/step.rb: -------------------------------------------------------------------------------- 1 | class Step 2 | attr_accessor :active, :note, :velocity 3 | 4 | def initialize(active: false, note: "C4", velocity: 127) 5 | @active = active 6 | @note = note 7 | @velocity = velocity 8 | end 9 | end 10 | -------------------------------------------------------------------------------- /lib/synthesizer.rb: -------------------------------------------------------------------------------- 1 | require_relative "vcf" 2 | require_relative "oscillator" 3 | require_relative "envelope" 4 | require_relative "note" 5 | 6 | class Synthesizer 7 | attr_accessor :active, :envelope 8 | attr_reader :vcf 9 | 10 | def initialize(sample_rate = 44100, amplitude = 1000) 11 | @sample_rate = sample_rate 12 | @amplitude = amplitude 13 | @active_notes = {} 14 | @oscillator = Oscillator.new(:sawtooth, sample_rate) 15 | @vcf = VCF.new(sample_rate) 16 | @envelope = Envelope.new 17 | @global_sample_count = 0 18 | end 19 | 20 | def note_on(midi_note, velocity) 21 | new_note = Note.new 22 | new_note.set_by_midi(midi_note) 23 | new_note.phase = 0.0 24 | 25 | new_note.note_on_sample_index = @global_sample_count 26 | 27 | @active_notes[midi_note] = new_note 28 | end 29 | 30 | def note_off(midi_note) 31 | if @active_notes[midi_note] 32 | @active_notes[midi_note].note_off_sample_index = @global_sample_count 33 | end 34 | end 35 | 36 | def generate(buffer_size) 37 | return Array.new(buffer_size, 0.0) if @active_notes.empty? 38 | 39 | # 個々の発音を合成する先 40 | samples = Array.new(buffer_size, 0.0) 41 | 42 | start_sample_index = @global_sample_count 43 | active_note_count = 0 44 | 45 | @active_notes.each_value do |note| 46 | wave = @oscillator.generate_wave(note, buffer_size) 47 | 48 | wave.each_with_index do |sample_val, idx| 49 | current_sample_index = start_sample_index + idx 50 | env_val = @envelope.apply_envelope(note, current_sample_index, @sample_rate) 51 | 52 | wave[idx] = sample_val * env_val 53 | end 54 | 55 | # 各ノートの波形を足し合わせるだけ 56 | samples = samples.zip(wave).map { |s1, s2| s1 + s2 } 57 | has_sound = false 58 | wave.each do |sample| 59 | if sample != 0.0 60 | has_sound = true 61 | break 62 | end 63 | end 64 | active_note_count += 1 if has_sound 65 | end 66 | 67 | # 複数のノートがアクティブな場合は、平方根スケーリングを使用して 68 | # より自然な音量調整を行う 69 | master_gain = 5.0 70 | if active_note_count > 1 71 | master_gain *= (1.0 / Math.sqrt(active_note_count)) 72 | end 73 | samples.map! { |sample| sample * master_gain } 74 | 75 | @global_sample_count += buffer_size 76 | 77 | cleanup_inactive_notes(buffer_size) 78 | 79 | samples 80 | end 81 | 82 | private 83 | 84 | def cleanup_inactive_notes(buffer_size) 85 | @active_notes.delete_if do |note_id, note| 86 | if note.note_off_sample_index 87 | final_env = @envelope.apply_envelope(note, @global_sample_count - 1, @sample_rate) 88 | final_env <= 0.0 89 | else 90 | false 91 | end 92 | end 93 | end 94 | end 95 | -------------------------------------------------------------------------------- /lib/vca.rb: -------------------------------------------------------------------------------- 1 | class VCA < FFI::PortAudio::Stream 2 | include FFI::PortAudio 3 | 4 | def initialize(generator, sample_rate, buffer_size) 5 | @generator = generator 6 | @buffer_size = buffer_size 7 | 8 | output_params = API::PaStreamParameters.new 9 | output_params[:device] = API.Pa_GetDefaultOutputDevice 10 | output_params[:channelCount] = 1 11 | output_params[:sampleFormat] = API::Float32 12 | output_params[:suggestedLatency] = API.Pa_GetDeviceInfo(output_params[:device])[:defaultHighOutputLatency] 13 | output_params[:hostApiSpecificStreamInfo] = nil 14 | 15 | super() 16 | open(nil, output_params, sample_rate, buffer_size) 17 | start 18 | end 19 | 20 | def process(input, output, frame_count, time_info, status_flags, user_data) 21 | samples = @generator.generate(frame_count) 22 | output.write_array_of_float(samples) 23 | :paContinue 24 | end 25 | end 26 | -------------------------------------------------------------------------------- /lib/vcf.rb: -------------------------------------------------------------------------------- 1 | class VCF 2 | attr_accessor :low_pass_cutoff, :high_pass_cutoff, :resonance 3 | 4 | def initialize(sample_rate) 5 | @sample_rate = sample_rate 6 | @low_pass_cutoff = 1000.0 7 | @high_pass_cutoff = 100.0 8 | @resonance = 0.0 9 | reset_filters 10 | end 11 | 12 | def apply(input) 13 | low_pass(high_pass(input)) 14 | end 15 | 16 | def low_pass_cutoff=(new_frequency) 17 | @low_pass_cutoff = [[new_frequency, 20.0].max, @sample_rate / 2.0].min 18 | update_low_pass_alpha 19 | end 20 | 21 | def high_pass_cutoff=(new_frequency) 22 | @high_pass_cutoff = [[new_frequency, 20.0].max, @sample_rate / 2.0].min 23 | update_high_pass_alpha 24 | end 25 | 26 | def resonance=(new_resonance) 27 | @resonance = [[new_resonance, 0.0].max, 0.99].min # 発振防止のため上限を0.99とする 28 | # resonanceを更新したらフィルターを更新 29 | update_low_pass_filters 30 | end 31 | 32 | # サンプル配列に対してフィルタ処理を行う 33 | # @param samples [Array] 処理する波形サンプルの配列 34 | # @param filter_type [Symbol] 適用するフィルターの種類 (:low_pass, :high_pass, :band_pass, :all) 35 | # @return [Array] フィルター適用後のサンプル配列 36 | def process(samples, filter_type = :all) 37 | case filter_type 38 | when :low_pass 39 | # 低域通過フィルターのみ適用 40 | processed_samples = samples.map { |sample| low_pass(sample) } 41 | when :high_pass 42 | # 高域通過フィルターのみ適用 43 | processed_samples = samples.map { |sample| high_pass(sample) } 44 | when :band_pass 45 | # バンドパスフィルター(低域と高域の両方を適用) 46 | processed_samples = samples.map { |sample| low_pass(high_pass(sample)) } 47 | when :all 48 | # すべてのフィルターを適用(既存のapplyメソッドと同等) 49 | processed_samples = samples.map { |sample| apply(sample) } 50 | else 51 | # 不明なフィルタータイプの場合は元のサンプルを返す 52 | return samples 53 | end 54 | 55 | # フィルター適用後のサンプルを返す 56 | processed_samples 57 | end 58 | 59 | private 60 | 61 | def reset_filters 62 | @low_pass_prev_output = 0.0 63 | @low_pass_prev_input = 0.0 64 | @high_pass_prev_input = 0.0 65 | @high_pass_prev_output = 0.0 66 | update_low_pass_alpha 67 | update_high_pass_alpha 68 | update_low_pass_filters 69 | end 70 | 71 | def update_low_pass_alpha 72 | rc = 1.0 / (2.0 * Math::PI * @low_pass_cutoff) 73 | @low_pass_alpha = rc / (rc + 1.0 / @sample_rate) 74 | end 75 | 76 | def update_high_pass_alpha 77 | rc = 1.0 / (2.0 * Math::PI * @high_pass_cutoff) 78 | @high_pass_alpha = rc / (rc + 1.0 / @sample_rate) 79 | end 80 | 81 | def update_low_pass_filters 82 | @feedback = @resonance * 3.8 # 3.8はフィードバック係数。マジックナンバー 83 | end 84 | 85 | def low_pass(input) 86 | return @low_pass_prev_output if input.nil? 87 | 88 | input = input.clamp(-10.0, 10.0) 89 | 90 | # レゾナンスが設定されている場合はフィードバックする 91 | if @resonance > 0.01 92 | feedback_value = @low_pass_prev_output * @feedback 93 | 94 | # NaNチェック - フィードバック値が無効な場合はフィードバックを適用しない 95 | feedback_value = 0.0 if feedback_value.nan? || feedback_value.infinite? 96 | 97 | # 入力からフィードバックを減算 98 | filtered_input = input - feedback_value 99 | 100 | # フィルター係数を適用 101 | output = @low_pass_alpha * filtered_input + (1 - @low_pass_alpha) * @low_pass_prev_output 102 | 103 | @low_pass_prev_input = input 104 | @low_pass_prev_output = output 105 | 106 | # クリッピング防止 107 | output.clamp(-3.0, 3.0) 108 | else 109 | output = @low_pass_alpha * input + (1 - @low_pass_alpha) * @low_pass_prev_output 110 | @low_pass_prev_input = input 111 | @low_pass_prev_output = output 112 | output 113 | end 114 | end 115 | 116 | def high_pass(input) 117 | return @high_pass_prev_output if input.nil? 118 | 119 | input = input.clamp(-10.0, 10.0) 120 | 121 | output = (1 - @high_pass_alpha) * (@high_pass_prev_output + input - @high_pass_prev_input) 122 | @high_pass_prev_input = input 123 | @high_pass_prev_output = output 124 | output 125 | end 126 | end 127 | -------------------------------------------------------------------------------- /main.rb: -------------------------------------------------------------------------------- 1 | require 'drb/drb' 2 | require 'ffi-portaudio' 3 | require 'unimidi' 4 | require 'yaml' 5 | 6 | SAMPLE_RATE = 44100 7 | BUFFER_SIZE = 128 8 | AMPLITUDE = 1000 9 | BPM = 120 10 | STEPS = 16 11 | 12 | require_relative "lib/groovebox" 13 | require_relative "lib/drum_rack" 14 | require_relative "lib/synthesizer" 15 | require_relative "lib/note" 16 | require_relative "lib/vca" 17 | require_relative "lib/step" 18 | 19 | require_relative "lib/presets/bass" 20 | require_relative "lib/presets/kick" 21 | require_relative "lib/presets/snare" 22 | require_relative "lib/presets/hihat_closed" 23 | require_relative "lib/presets/piano" 24 | require_relative "lib/sidechain" 25 | 26 | def select_midi_device 27 | available_inputs = UniMIDI::Input.all 28 | available_inputs.each do |input| 29 | puts "MIDI Device: #{input.name} (index: #{available_inputs.index(input)})" 30 | end 31 | 32 | # Ableton Moveを自動検出して繋ぐ 33 | auto_selected_index = available_inputs.find_index { |input| input.name.include?('Ableton Move') } 34 | 35 | if auto_selected_index 36 | return auto_selected_index 37 | else 38 | selected_index = gets.chomp.to_i 39 | 40 | if selected_index < 0 || selected_index >= available_inputs.size 41 | return 0 42 | end 43 | 44 | return selected_index 45 | end 46 | end 47 | 48 | def handle_midi_signals(groovebox, config) 49 | # MIDIデバイスのインデックスを動的に選択 50 | midi_device_index = select_midi_device 51 | 52 | midi_input = UniMIDI::Input.use(midi_device_index) 53 | midi_output = UniMIDI::Output.use(midi_device_index) 54 | puts "MIDI Device: #{midi_input.name} (index: #{midi_device_index}) has been connected." 55 | 56 | white_keys = [0, 2, 4, 5, 7, 9, 11] # C, D, E, F, G, A, B 57 | drum_pad_range = (68..95).to_a 58 | 59 | # アクティブなノートを追跡するためのハッシュ 60 | active_notes = {} 61 | # 現在のインスツルメントのインデックスを記録 62 | current_instrument_index = groovebox.current_instrument_index 63 | 64 | loop do 65 | if groovebox.current_instrument.is_a?(DrumRack) 66 | drum_pad_range.each do |midi_note| 67 | if groovebox.current_instrument.pad_notes.include?(midi_note) 68 | midi_output.puts(0x90, midi_note, 120) 69 | else 70 | midi_output.puts(0x80, midi_note, 0) 71 | end 72 | end 73 | elsif groovebox.current_instrument.is_a?(Synthesizer) 74 | # 白鍵のパッドを光らせる 75 | (config['keyboard']['note_range']['start']..config['keyboard']['note_range']['end']).each do |midi_note| 76 | if white_keys.include?(midi_note % 12) 77 | midi_output.puts(0x90, midi_note, 120) 78 | else 79 | midi_output.puts(0x80, midi_note, 0) 80 | end 81 | end 82 | end 83 | 84 | if current_instrument_index != groovebox.current_instrument_index 85 | # すべてのアクティブなノートをオフにする 86 | active_notes.each do |midi_note, instrument_index| 87 | puts "音階変更: 前の音をオフにします - note=#{midi_note}" 88 | groovebox.get_instrument(instrument_index).note_off(midi_note) 89 | end 90 | # アクティブノートをクリア 91 | active_notes = {} 92 | # 現在のインスツルメントインデックスを更新 93 | current_instrument_index = groovebox.current_instrument_index 94 | end 95 | 96 | midi_input.gets.each do |message| 97 | data = message[:data] 98 | # TODO: 254を受信出来なくなったらエラーを出して終了させる 99 | next if data[0] == 254 # Active Sensing を無視 100 | 101 | status_byte = data[0] 102 | midi_note = data[1] 103 | velocity = data[2] 104 | 105 | # Ignore spurious Note On messages from Ableton Move pads used for CC. 106 | skip_mini_note = [0,1,2,3,4,5,6,7,8,9,10,11] 107 | 108 | case status_byte & 0xF0 109 | when 0x90 # Note On (channel 0 => 0x90, channel 1 => 0x91, etc.) 110 | next if skip_mini_note.include?(midi_note) 111 | groovebox.current_instrument.note_on(midi_note, velocity) 112 | 113 | # アクティブノートとして記録 114 | active_notes[midi_note] = groovebox.current_instrument_index 115 | puts "Note On: ch=#{status_byte & 0x0F}, midi_note=#{midi_note}, velocity=#{velocity}" 116 | 117 | when 0x80 # Note Off 118 | next if skip_mini_note.include?(midi_note) 119 | groovebox.current_instrument.note_off(midi_note) 120 | 121 | # アクティブノートから削除 122 | active_notes.delete(midi_note) 123 | puts "Note Off: ch=#{status_byte & 0x0F}, midi_note=#{midi_note}" 124 | when 0xB0 # Control Change (channel 0=>0xB0,1=>0xB1, etc.) 125 | control = midi_note 126 | value = velocity 127 | 128 | # TODO: リアルタイムで音作りをするよりも、コード上で音作りをする方が簡単になったので将来的に消す 129 | # LPF / HPF 130 | # cutoff_change = value == 127 ? 10 : -10 131 | # if control == 71 132 | # synthesizer.vcf.low_pass_cutoff += cutoff_change 133 | # puts "VCF Low Pass Cutoff: #{synthesizer.vcf.low_pass_cutoff.round(2)} Hz" 134 | # elsif control == 72 135 | # synthesizer.vcf.high_pass_cutoff += cutoff_change 136 | # puts "VCF High Pass Cutoff: #{synthesizer.vcf.high_pass_cutoff.round(2)} Hz" 137 | # end 138 | 139 | # ADSR 140 | # Attack: 0.00~2.00 141 | # Decay: 0.00~5.00 142 | # Sustain: 0.00~1.00 143 | # Release: 0.00~10.00 144 | # case control 145 | # when 73 # Attack 146 | # new_value = value == 127 ? -0.01 : 0.01 147 | # groovebox.current_instrument.envelope.attack = 148 | # (groovebox.current_instrument.envelope.attack + new_value).clamp(0.00, 2.00) 149 | # puts "Attack: #{groovebox.current_instrument.envelope.attack.round(2)}" 150 | # when 74 # Decay 151 | # new_value = value == 127 ? 0.01 : -0.01 152 | # groovebox.current_instrument.envelope.decay = groovebox.current_instrument.envelope.decay.clamp(0.00, 5.00) 153 | # puts "Decay: #{groovebox.current_instrument.envelope.decay.round(2)}" 154 | # when 75 # Sustain 155 | # new_value = value == 127 ? 0.01 : -0.01 156 | # groovebox.current_instrument.envelope.sustain = groovebox.current_instrument.envelope.sustain.clamp(0.00, 1.00) 157 | # puts "Sustain: #{groovebox.current_instrument.envelope.sustain.round(2)}" 158 | # when 76 # Release 159 | # new_value = value == 127 ? 0.01 : -0.01 160 | # groovebox.current_instrument.envelope.release = groovebox.current_instrument.envelope.release.clamp(0.00, 10.00) 161 | # puts "Release: #{groovebox.current_instrument.envelope.release.round(2)}" 162 | # end 163 | 164 | # サイドチェインパラメータの調整 165 | # case control 166 | # when 77 # サイドチェインのThreshold 167 | # if groovebox.instance_variable_defined?(:@sidechain_connections) && !groovebox.instance_variable_get(:@sidechain_connections).empty? 168 | # # シンセサイザーに適用されているサイドチェイン 169 | # sidechain_connection = groovebox.instance_variable_get(:@sidechain_connections)[0] 170 | # if sidechain_connection 171 | # sidechain = sidechain_connection[:processor] 172 | # new_value = value == 127 ? -0.01 : 0.01 173 | # sidechain.threshold = (sidechain.threshold + new_value).clamp(0.01, 0.9) 174 | # puts "サイドチェイン Threshold: #{sidechain.threshold.round(2)}" 175 | # end 176 | # end 177 | # when 78 # サイドチェインのRelease 178 | # if groovebox.instance_variable_defined?(:@sidechain_connections) && !groovebox.instance_variable_get(:@sidechain_connections).empty? 179 | # # シンセサイザーに適用されているサイドチェイン 180 | # sidechain_connection = groovebox.instance_variable_get(:@sidechain_connections)[0] 181 | # if sidechain_connection 182 | # sidechain = sidechain_connection[:processor] 183 | # new_value = value == 127 ? -0.01 : 0.01 184 | # sidechain.release = (sidechain.release + new_value).clamp(0.01, 1.0) 185 | # puts "サイドチェイン Release: #{sidechain.release.round(2)}" 186 | # end 187 | # end 188 | # end 189 | # TODO: ここまでが音作りの変更にまつわる実装 190 | 191 | case control 192 | when 40 193 | active_notes.each do |note, _| 194 | groovebox.current_instrument.note_off(note) 195 | end 196 | active_notes = {} 197 | groovebox.change_channel(3) 198 | when 41 199 | active_notes.each do |note, _| 200 | groovebox.current_instrument.note_off(note) 201 | end 202 | active_notes = {} 203 | groovebox.change_channel(2) 204 | when 42 205 | active_notes.each do |note, _| 206 | groovebox.current_instrument.note_off(note) 207 | end 208 | active_notes = {} 209 | groovebox.change_channel(1) 210 | when 43 211 | active_notes.each do |note, _| 212 | groovebox.current_instrument.note_off(note) 213 | end 214 | active_notes = {} 215 | groovebox.change_channel(0) 216 | end 217 | end 218 | end 219 | end 220 | end 221 | 222 | FFI::PortAudio::API.Pa_Initialize 223 | 224 | begin 225 | config = YAML.load_file('midi_config.yml') 226 | 227 | groovebox = Groovebox.new 228 | 229 | piano = Presets::Piano.new 230 | groovebox.add_instrument piano 231 | 232 | synthesizer = Synthesizer.new(SAMPLE_RATE, AMPLITUDE) 233 | groovebox.add_instrument synthesizer 234 | 235 | bass = Presets::Bass.new 236 | groovebox.add_instrument bass 237 | 238 | kick = Presets::Kick.new 239 | drum_rack = DrumRack.new(68) 240 | drum_rack.add_track(kick, 0, 1.2) 241 | 242 | snare = Presets::Snare.new 243 | drum_rack.add_track(snare, 1, 1.0) 244 | 245 | hihat_closed = Presets::HihatClosed.new 246 | drum_rack.add_track(hihat_closed, 2, 0.5) 247 | 248 | groovebox.add_instrument drum_rack 249 | 250 | # サイドチェインの設定: キック(ドラムラック)をトリガーとして、シンセサイザーの音量を制御 251 | groovebox.setup_sidechain(1, 0, { 252 | threshold: 0.2, 253 | attack: 0.001, 254 | release: 0.2, 255 | ratio: 8.0, 256 | }) 257 | 258 | # ベースシンセ用のサイドチェインの設定 259 | groovebox.setup_sidechain(2, 0, { 260 | threshold: 0.2, 261 | attack: 0.001, 262 | release: 0.2, 263 | ratio: 8.0, 264 | }) 265 | 266 | stream = VCA.new(groovebox, SAMPLE_RATE, BUFFER_SIZE) 267 | 268 | DRb.start_service('druby://localhost:8786', groovebox) 269 | puts "Groovebox DRb server running at druby://localhost:8786" 270 | 271 | Thread.new do 272 | handle_midi_signals(groovebox, config) 273 | end 274 | 275 | DRb.thread.join 276 | ensure 277 | stream&.close 278 | FFI::PortAudio::API.Pa_Terminate 279 | end 280 | -------------------------------------------------------------------------------- /midi.rb: -------------------------------------------------------------------------------- 1 | require 'unimidi' 2 | 3 | def list_midi_devices 4 | puts "Available MIDI Input Devices:" 5 | UniMIDI::Input.list.each_with_index do |input, index| 6 | puts "#{index}) #{input.inspect}" 7 | end 8 | end 9 | 10 | def select_midi_device 11 | list_midi_devices 12 | print "Select a MIDI device by number: " 13 | $stdout.flush 14 | device_index = gets.chomp.to_i 15 | 16 | begin 17 | input = UniMIDI::Input.use(device_index) 18 | puts "Selected MIDI device: #{input.inspect}" 19 | input 20 | rescue 21 | puts "Invalid selection. Exiting." 22 | exit 23 | end 24 | end 25 | 26 | def monitor_midi_signals(input) 27 | puts "Listening for MIDI signals from #{input.inspect}..." 28 | puts "Press Ctrl+C to exit." 29 | 30 | loop do 31 | messages = input.gets 32 | messages.each do |message| 33 | timestamp = message[:timestamp] 34 | data = message[:data] 35 | 36 | # Active Sensing (254) を無視 37 | next if data[0] == 254 38 | 39 | puts "[#{timestamp}] #{data.inspect}" 40 | end 41 | rescue Interrupt 42 | puts "\nExiting MIDI monitor." 43 | exit 44 | end 45 | end 46 | 47 | begin 48 | input = select_midi_device 49 | monitor_midi_signals(input) 50 | rescue Interrupt 51 | puts "\nExiting program." 52 | end 53 | -------------------------------------------------------------------------------- /midi_config.yml: -------------------------------------------------------------------------------- 1 | midi_device: 2 | index: 2 3 | name: "Ableton Push 2" 4 | 5 | keyboard: 6 | note_range: 7 | start: 68 8 | end: 99 9 | 10 | switches: 11 | 16: "sine" 12 | 17: "sawtooth" 13 | 18: "triangle" 14 | 19: "pulse" 15 | 20: "square" 16 | 17 | start: 85 18 | stop: 86 19 | -------------------------------------------------------------------------------- /test/presets/frequency_test.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'minitest/autorun' 4 | require 'fileutils' 5 | require_relative '../../lib/synthesizer' 6 | require_relative '../support/audio_analyzer' 7 | require_relative '../support/test_vca' 8 | require_relative '../../lib/presets/acid_bass' 9 | require_relative '../../lib/presets/moog_lead' 10 | 11 | # シンセサイザーの周波数テスト 12 | class FrequencyTest < Minitest::Test 13 | SAMPLE_RATE = 44100 14 | BUFFER_SIZE = 128 15 | AMPLITUDE = 1000 16 | TEST_DURATION = 0.5 # 秒 17 | 18 | def setup 19 | # テスト用の出力ディレクトリを作成 20 | @output_dir = 'tmp/test_audio' 21 | FileUtils.mkdir_p(@output_dir) unless Dir.exist?(@output_dir) 22 | end 23 | 24 | def test_acid_bass_single_note_frequency 25 | # MIDIノート設定 26 | midi_note = 60 # C4 (ド) 27 | 28 | # Acid Bassの設定 29 | synth = Presets::AcidBass.new(SAMPLE_RATE, AMPLITUDE) 30 | 31 | # 音声ファイルのパス 32 | output_file = File.join(@output_dir, 'acid_bass_frequency_test.wav') 33 | 34 | # 音声生成 35 | test_vca = TestVCA.new(synth, SAMPLE_RATE, BUFFER_SIZE, output_file) 36 | synth.note_on(midi_note, 100) 37 | test_vca.generate_samples(BUFFER_SIZE, TEST_DURATION) 38 | synth.note_off(midi_note) 39 | test_vca.generate_samples(BUFFER_SIZE, 0.2) # リリース部分 40 | test_vca.save_to_file(:wav) 41 | 42 | # 周波数分析 43 | analyzer = TestSupport::AudioAnalyzer.new(output_file) 44 | 45 | # 期待されるMIDIノートの周波数が存在するか確認 46 | has_expected_freq = analyzer.has_frequency_for_midi_note?(midi_note) 47 | 48 | assert has_expected_freq, "生成された音声にMIDIノート#{midi_note}の周波数が含まれていません" 49 | end 50 | 51 | def test_acid_bass_sequence 52 | # MIDIノートシーケンス 53 | midi_notes = [36, 48, 60, 72] # C2, C3, C4, C5(オクターブ上昇) 54 | 55 | # Acid Bassの設定 56 | synth = Presets::AcidBass.new(SAMPLE_RATE, AMPLITUDE) 57 | 58 | # 音声ファイルのパス 59 | output_file = File.join(@output_dir, 'acid_bass_sequence_test.wav') 60 | 61 | # 音声生成 62 | test_vca = TestVCA.new(synth, SAMPLE_RATE, BUFFER_SIZE, output_file) 63 | 64 | # シーケンスを生成 65 | midi_notes.each do |note| 66 | synth.note_on(note, 100) 67 | test_vca.generate_samples(BUFFER_SIZE, 0.3) 68 | synth.note_off(note) 69 | test_vca.generate_samples(BUFFER_SIZE, 0.1) # ノート間のギャップ 70 | end 71 | 72 | test_vca.save_to_file(:wav) 73 | 74 | # 音声ファイルを分割して各部分を分析 75 | # 簡略化のため、各ノートが0.4秒の長さを持つと仮定 76 | total_samples = (SAMPLE_RATE * 0.4 * midi_notes.length).to_i 77 | samples_per_note = (SAMPLE_RATE * 0.4).to_i 78 | 79 | # 各ノートの時間位置を計算 80 | note_positions = midi_notes.each_with_index.map do |note, index| 81 | [note, index * 0.4] # [MIDIノート, 開始時間(秒)] 82 | end 83 | 84 | # 簡易版:音声全体で全てのノートの周波数が含まれているか確認 85 | analyzer = TestSupport::AudioAnalyzer.new(output_file) 86 | 87 | # 各ノートに対して個別にテスト 88 | results = analyzer.check_midi_notes(midi_notes) 89 | 90 | # すべてのノートに対応する周波数が少なくとも1つは検出されるべき 91 | midi_notes.each do |note| 92 | assert results[note], "MIDIノート#{note}の周波数が検出されませんでした" 93 | end 94 | end 95 | 96 | def test_moog_lead_frequency 97 | # MIDIノート設定 98 | midi_note = 60 # C4 (ド) 99 | 100 | # Moog Leadの設定 101 | synth = Presets::MoogLead.new(SAMPLE_RATE, AMPLITUDE) 102 | 103 | # 音声ファイルのパス 104 | output_file = File.join(@output_dir, 'moog_lead_frequency_test.wav') 105 | 106 | # 音声生成 107 | test_vca = TestVCA.new(synth, SAMPLE_RATE, BUFFER_SIZE, output_file) 108 | synth.note_on(midi_note, 100) 109 | test_vca.generate_samples(BUFFER_SIZE, TEST_DURATION) 110 | synth.note_off(midi_note) 111 | test_vca.generate_samples(BUFFER_SIZE, 0.2) # リリース部分 112 | test_vca.save_to_file(:wav) 113 | 114 | # 周波数分析 115 | analyzer = TestSupport::AudioAnalyzer.new(output_file) 116 | 117 | # 期待されるMIDIノートの周波数が存在するか確認 118 | has_expected_freq = analyzer.has_frequency_for_midi_note?(midi_note) 119 | 120 | assert has_expected_freq, "生成された音声にMIDIノート#{midi_note}の周波数が含まれていません" 121 | end 122 | 123 | def test_c_major_scale 124 | # Cメジャースケール(C4からC5まで) 125 | scale_notes = [60, 62, 64, 65, 67, 69, 71, 72] # C, D, E, F, G, A, B, C 126 | 127 | # Acid Bassで音階を生成 128 | synth = Presets::AcidBass.new(SAMPLE_RATE, AMPLITUDE) 129 | 130 | # 音声ファイルのパス 131 | output_file = File.join(@output_dir, 'c_major_scale_frequency_test.wav') 132 | 133 | # 音声生成 134 | test_vca = TestVCA.new(synth, SAMPLE_RATE, BUFFER_SIZE, output_file) 135 | 136 | # スケールを生成 137 | scale_notes.each do |note| 138 | synth.note_on(note, 100) 139 | test_vca.generate_samples(BUFFER_SIZE, 0.3) 140 | synth.note_off(note) 141 | test_vca.generate_samples(BUFFER_SIZE, 0.1) # ノート間のギャップ 142 | end 143 | 144 | test_vca.save_to_file(:wav) 145 | 146 | # 音声分析 147 | analyzer = TestSupport::AudioAnalyzer.new(output_file) 148 | 149 | # 各ノートに対して個別にテスト 150 | results = analyzer.check_midi_notes(scale_notes) 151 | 152 | # すべてのノートが検出されるべき 153 | scale_notes.each do |note| 154 | assert results[note], "Cメジャースケールの音階 MIDIノート#{note}が検出されませんでした" 155 | end 156 | end 157 | end 158 | -------------------------------------------------------------------------------- /test/presets/moog_lead_test.rb: -------------------------------------------------------------------------------- 1 | require 'minitest/autorun' 2 | require 'fileutils' 3 | require 'wavefile' 4 | require_relative '../support/test_vca' 5 | require_relative '../../lib/synthesizer' 6 | require_relative '../../lib/presets/moog_lead' 7 | 8 | FileUtils.mkdir_p('tmp/test_audio') 9 | 10 | class MoogLeadTest < Minitest::Test 11 | SAMPLE_RATE = 44100 12 | BUFFER_SIZE = 128 13 | AMPLITUDE = 1000 14 | 15 | def setup 16 | @moog_lead = Presets::MoogLead.new(SAMPLE_RATE, AMPLITUDE) 17 | @test_vca = TestVCA.new(@moog_lead, SAMPLE_RATE, BUFFER_SIZE, 'tmp/test_audio/moog_lead_test.wav') 18 | end 19 | 20 | def test_single_note_generation 21 | # C4(ド)の音を鳴らす(MIDIノート60) 22 | midi_note = 60 23 | velocity = 100 24 | 25 | # ノートをオンにする 26 | @moog_lead.note_on(midi_note, velocity) 27 | 28 | # 1秒間の音声を生成 29 | @test_vca.generate_samples(BUFFER_SIZE, 1.0) 30 | 31 | # ノートをオフにする 32 | @moog_lead.note_off(midi_note) 33 | 34 | # リリース部分も含めてさらに0.5秒生成 35 | @test_vca.generate_samples(BUFFER_SIZE, 0.5) 36 | 37 | output_file = @test_vca.save_to_file 38 | 39 | # ファイルが存在することを確認 40 | assert File.exist?(output_file), "Audio file was not generated" 41 | 42 | # バッファの内容を検証 43 | buffer = @test_vca.get_buffer 44 | 45 | # バッファが空でないことを確認 46 | refute_empty buffer, "Generated audio buffer is empty" 47 | 48 | # サンプル値が適切な範囲内にあることを確認(クリッピングがないか) 49 | max_amplitude = buffer.map(&:abs).max 50 | assert max_amplitude > 0, "No audio signal was generated" 51 | assert max_amplitude <= 32768, "Audio signal is clipping" 52 | 53 | # 一部のサンプルでゼロでない値があることを確認(無音でないか) 54 | non_zero_samples = buffer.reject { |sample| sample == 0 } 55 | assert non_zero_samples.length > 0, "Generated audio is silent" 56 | 57 | puts "Test successful: Audio file generated: #{output_file}" 58 | end 59 | 60 | def test_c_major_scale 61 | # テスト用に別のVCAを用意 (クリアなバッファで開始) 62 | scale_output_file = 'tmp/test_audio/c_major_scale.wav' 63 | scale_vca = TestVCA.new(@moog_lead, SAMPLE_RATE, BUFFER_SIZE, scale_output_file) 64 | 65 | # Cメジャースケールの各音を順番に鳴らして保存 66 | scale_notes = [60, 62, 64, 65, 67, 69, 71, 72] # C4, D4, E4, F4, G4, A4, B4, C5 67 | 68 | # 各ノートを0.5秒間鳴らす 69 | scale_notes.each do |midi_note| 70 | @moog_lead.note_on(midi_note, 100) 71 | # 音を鳴らす (0.3秒) 72 | scale_vca.generate_samples(BUFFER_SIZE, 0.3) 73 | # ノートオフ 74 | @moog_lead.note_off(midi_note) 75 | # リリース (0.2秒) 76 | scale_vca.generate_samples(BUFFER_SIZE, 0.2) 77 | end 78 | 79 | # スケール全体をファイルに保存 80 | output_file = scale_vca.save_to_file(:wav) 81 | 82 | assert File.exist?(output_file), "Scale audio file was not generated" 83 | 84 | # バッファの長さが期待通りであることを確認 85 | # 1音あたり0.5秒、8音で約4秒 = 44100 * 4 = 176400サンプル程度を期待 86 | expected_min_length = (SAMPLE_RATE * 0.5 * scale_notes.length * 0.9).to_i # 少し余裕を持たせる 87 | assert scale_vca.get_buffer.length >= expected_min_length, 88 | "Buffer length is shorter than expected (#{scale_vca.get_buffer.length} < #{expected_min_length})" 89 | 90 | puts "Test successful: C major scale audio file generated: #{output_file}" 91 | end 92 | 93 | # 異なるパラメータでの音色テスト 94 | def test_different_parameters 95 | params_output_file = 'tmp/test_audio/moog_lead_params_test.wav' 96 | params_vca = TestVCA.new(@moog_lead, SAMPLE_RATE, BUFFER_SIZE, params_output_file) 97 | 98 | @moog_lead.envelope.attack = 0.2 99 | @moog_lead.envelope.decay = 0.3 100 | @moog_lead.envelope.sustain = 0.5 101 | @moog_lead.envelope.release = 0.5 102 | 103 | @moog_lead.filter_cutoff = 2000.0 104 | @moog_lead.filter_resonance = 0.6 105 | 106 | midi_note = 60 # C4 107 | @moog_lead.note_on(midi_note, 100) 108 | params_vca.generate_samples(BUFFER_SIZE, 1.0) 109 | @moog_lead.note_off(midi_note) 110 | params_vca.generate_samples(BUFFER_SIZE, 0.7) 111 | 112 | output_file = params_vca.save_to_file(:wav) 113 | 114 | assert File.exist?(output_file), "Audio file with different parameters was not generated" 115 | 116 | puts "Test successful: Audio file with different parameters generated: #{output_file}" 117 | end 118 | end 119 | 120 | if __FILE__ == $PROGRAM_NAME 121 | Minitest.run 122 | end 123 | -------------------------------------------------------------------------------- /test/support/audio_analyzer.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'wavefile' 4 | require 'minitest/autorun' 5 | 6 | module TestSupport 7 | # 音声ファイルの周波数分析を行うクラス 8 | class AudioAnalyzer 9 | # デバッグモード(ログ出力の有無) 10 | DEBUG = false 11 | 12 | # MIDIノート番号から周波数を計算する定数 13 | MIDI_NOTE_TO_FREQ = {} 14 | (0..127).each do |midi_note| 15 | MIDI_NOTE_TO_FREQ[midi_note] = 440.0 * (2.0 ** ((midi_note - 69) / 12.0)) 16 | end 17 | 18 | def initialize(wav_file_path) 19 | @wav_file_path = wav_file_path 20 | @sample_rate = nil 21 | @samples = nil 22 | load_samples 23 | end 24 | 25 | # WAVファイルからサンプルを読み込む 26 | def load_samples 27 | reader = WaveFile::Reader.new(@wav_file_path) 28 | @sample_rate = reader.format.sample_rate 29 | 30 | # サンプルを配列に読み込む 31 | buffer_size = 4096 32 | buffer = reader.read(buffer_size) 33 | @samples = [] 34 | 35 | # チャンネルが複数ある場合は最初のチャンネルだけを使用 36 | until buffer.samples.empty? 37 | if buffer.samples[0].is_a?(Array) 38 | # ステレオの場合は左チャンネルを使用 39 | @samples.concat(buffer.samples.map { |s| s[0] }) 40 | else 41 | @samples.concat(buffer.samples) 42 | end 43 | 44 | break if @samples.length >= buffer_size * 4 # 解析のための十分なサンプルを取得 45 | buffer = reader.read(buffer_size) 46 | end 47 | 48 | reader.close 49 | end 50 | 51 | # 単純なDFT(離散フーリエ変換)を実装 52 | def analyze_frequencies(start_freq = 20, end_freq = 1000, resolution = 1.0) 53 | results = {} 54 | return results if @samples.nil? || @samples.empty? 55 | 56 | # 解析する周波数範囲を設定 57 | frequencies = [] 58 | freq = start_freq 59 | while freq <= end_freq 60 | frequencies << freq 61 | freq += resolution 62 | end 63 | 64 | # 各周波数に対してDFTを実行 65 | frequencies.each do |frequency| 66 | # 周波数に対応する角速度(ラジアン/サンプル) 67 | omega = 2.0 * Math::PI * frequency / @sample_rate 68 | 69 | # 実部と虚部に分けて計算 70 | real_sum = 0.0 71 | imag_sum = 0.0 72 | 73 | @samples.each_with_index do |sample, n| 74 | real_sum += sample * Math.cos(omega * n) 75 | imag_sum -= sample * Math.sin(omega * n) 76 | end 77 | 78 | # 振幅を計算 79 | amplitude = Math.sqrt(real_sum * real_sum + imag_sum * imag_sum) / @samples.length 80 | results[frequency] = amplitude 81 | end 82 | 83 | results 84 | end 85 | 86 | # 主要な周波数のピークを検出 87 | def detect_frequency_peaks(threshold = 0.01, max_peaks = 5) 88 | frequencies = analyze_frequencies() 89 | return [] if frequencies.empty? 90 | 91 | # 振幅でソート 92 | sorted_freqs = frequencies.sort_by { |_, amplitude| -amplitude } 93 | 94 | # しきい値以上の振幅を持つ周波数を抽出 95 | peaks = sorted_freqs.select { |_, amplitude| amplitude >= threshold }.take(max_peaks) 96 | 97 | # 周波数のみを返す 98 | peaks.map { |frequency, _| frequency } 99 | end 100 | 101 | # 特定のMIDIノートに対応する周波数が含まれているかをチェック 102 | def has_frequency_for_midi_note?(midi_note, tolerance_percent = 5.0) 103 | return false if @samples.nil? || @samples.empty? 104 | 105 | expected_freq = MIDI_NOTE_TO_FREQ[midi_note] 106 | tolerance = expected_freq * (tolerance_percent / 100.0) 107 | 108 | # 指定された範囲の周波数を集中的に分析 109 | min_freq = expected_freq - tolerance 110 | max_freq = expected_freq + tolerance 111 | 112 | # 高分解能でこの範囲を分析 113 | resolution = tolerance / 10.0 114 | frequencies = analyze_frequencies(min_freq, max_freq, resolution) 115 | 116 | # 最大振幅の周波数を見つける 117 | max_amplitude = 0.0 118 | max_freq = nil 119 | 120 | frequencies.each do |freq, amplitude| 121 | if amplitude > max_amplitude 122 | max_amplitude = amplitude 123 | max_freq = freq 124 | end 125 | end 126 | 127 | return false if max_freq.nil? 128 | 129 | # 期待される周波数との差を計算 130 | difference = (max_freq - expected_freq).abs 131 | 132 | # 周波数の差が許容範囲内であるかをチェック 133 | difference <= tolerance 134 | end 135 | 136 | # 複数のMIDIノートが音声に含まれているかをチェック 137 | def check_midi_notes(midi_notes, tolerance_percent = 5.0) 138 | results = {} 139 | 140 | midi_notes.each do |note| 141 | results[note] = has_frequency_for_midi_note?(note, tolerance_percent) 142 | end 143 | 144 | results 145 | end 146 | end 147 | end 148 | -------------------------------------------------------------------------------- /test/support/test_vca.rb: -------------------------------------------------------------------------------- 1 | require 'wavefile' 2 | 3 | class TestVCA 4 | def initialize(generator, sample_rate, buffer_size, output_path = 'tmp/test_output.wav') 5 | @generator = generator 6 | @buffer_size = buffer_size 7 | @sample_rate = sample_rate 8 | @output_path = output_path 9 | @buffer = [] 10 | 11 | output_dir = File.dirname(@output_path) 12 | Dir.mkdir(output_dir) unless Dir.exist?(output_dir) 13 | end 14 | 15 | def process(frame_count) 16 | samples = @generator.generate(frame_count) 17 | @buffer.concat(samples) 18 | :paContinue 19 | end 20 | 21 | def generate_samples(frames, note_duration = 1.0) 22 | total_frames = (note_duration * @sample_rate).to_i 23 | frames_processed = 0 24 | 25 | while frames_processed < total_frames 26 | frames_to_process = [frames, total_frames - frames_processed].min 27 | process(frames_to_process) 28 | frames_processed += frames_to_process 29 | end 30 | 31 | @buffer 32 | end 33 | 34 | # 内部バッファの内容を音声ファイルとして保存 35 | def save_to_file(format = :wav) 36 | case format 37 | when :wav 38 | save_to_wav 39 | else 40 | raise "Unsupported format: #{format}" 41 | end 42 | end 43 | 44 | def save_to_wav 45 | buffer = @buffer.map { |sample| sample / 32768.0 } # 正規化 (-1.0 ~ 1.0 の範囲に) 46 | 47 | WaveFile::Writer.new(@output_path, WaveFile::Format.new(:mono, :float, @sample_rate)) do |writer| 48 | # バッファを適切なサイズのチャンクに分割して書き込む 49 | buffer.each_slice(@buffer_size) do |chunk| 50 | writer.write(WaveFile::Buffer.new(chunk, WaveFile::Format.new(:mono, :float, @sample_rate))) 51 | end 52 | end 53 | 54 | puts "Audio saved to #{@output_path}" 55 | @output_path 56 | end 57 | 58 | def clear_buffer 59 | @buffer = [] 60 | end 61 | 62 | def get_buffer 63 | @buffer 64 | end 65 | end 66 | -------------------------------------------------------------------------------- /visualizer.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | Audio Waveform Visualizer 7 | 29 | 30 | 31 |
32 | 33 | 34 |
35 | 36 | 37 | 176 | 177 | 178 | --------------------------------------------------------------------------------