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

Pure Intonation on Browser: Building a Sequence...

Pure Intonation on Browser: Building a Sequencer with Ruby

The talk in RubyKaigi 2026.
Explore pure intonation, microtonal music theory, and ruby.wasm interoperability to create a modular synthesizer and sequencer running in the browser using Web Audio API and Ruby.

Avatar for nagachika

nagachika

April 23, 2026

More Decks by nagachika

Other Decks in Technology

Transcript

  1. ˒ @nagachika ˒ CRuby committer / CRuby stable branch maintainer

    (3.4) ˒ Write "ruby trunk changes” every day ˒ ANDPAD Inc ˒ Fukuoka.rb ˒ Sound Programmer Wannabe WHO AM I
  2. ˒ @nagachika ˒ CRuby committer / CRuby stable branch maintainer

    (3.4) ˒ Write "ruby trunk changes” every day ˒ ANDPAD Inc ˒ Fukuoka.rb ˒ Sound Programmer Wannabe ← Today’s Theme WHO AM I
  3. ˒ Introduction of “puri fi ed-synth” ˒ About Music Theory

    underlying puri fi ed-synth ˒ Interoperability of ruby.wasm on browser Agenda
  4. ˒ Synthesizer + Sequencer based on … ˒ Pure Intonation

    (७ਖ਼཯) / Microtonal (ඍ෼Ի) ˒ Web Audio API + ruby.wasm ˒ Runs in the browser ˒ https://nagachika.github.io/ruby-wasm-puri fi ed-synth/ puri fi ed-synth
  5. ˒ Synthesizer ˒ Modular Synthesizer ˒ Oscillator, Filters, ADSR, Gain…

    ˒ Delay, Reverb ˒ Chord Editor ˒ Edit Chord based on Shasaf-Style Theory (I will introduce later) ˒ Sequencer ˒ Arbitrary number of tracks ˒ Mute and Volume adjustments per track ˒ Arpeggiator ˒ Rhythm pattern editor puri fi ed-synth
  6. ˒ Modern Western music is entirely based on 12-EDO (12ฏۉ཯)

    12-EDO (Equal Division of an octave) C D E F G A B Do Re Mi Fa Sol La Si C# 1 Octave = x2 [Hz] C:C# = C#:D = D:D# = D#:E = E:F =… x x C : C# = 1 :
  7. ˒ Music using intervals smaller than a standard semitone/half step.

    ˒ 31-EDO / 53-EDO (Dividing the octave into 31/53 steps) Microtonal Music cf. https://youtu.be/QUJ2oND3cdg Microtonal Keyboard: Terpstra Keyboard
  8. ˒ Harmonics based on simple integer frequency ratios (e.g., 3:2,

    5:4) ˒ Achieves crystal clear chords with zero dissonance ˒ Pure resonance without the “beats” (interference) Pure Intonation Perfect Fifth 3:2
 12-EDO: G:C = 27/12 ≈ 1.498 㱺 acoustic interference
  9. ˒ Proposed by LΛMPLIGHT †1 ˒ Rede fi ning musical

    intervals as “Dimensions” ˒ 1D: Octave, 2D: Perfect 5th (3/2), 3D: Major 3rd (5/4) ˒ Expanding into higher dimensions (4D: 7/4, 5D: 11/4) Shasaf-Style Theory †1 https://x.com/lamplight_music
  10. ˒ Chord Diagrams - Lattice - Chordnyms ˒ puri fi

    ed-synth adopt Lattice in chord editor Shasaf-Style Notation “Ahta”
  11. ˒ The Problem: Even in microtonal DTM, chords are often

    quantized to N-EDO. ˒ The Mission: I want to play chords with exact frequency ratios. ˒ The Solution: As a programmer, I can built a tool to achieve it. Motivation: Real Harmony
  12. ˒ WebAssembly port of CRuby with a WASI-compatible runtime. ˒

    Provides libraries to bridge Ruby and JavaScript objects. ruby.wasm
  13. ˒ A graph-based modular audio synthesis framework ˒ Oscillators ˒

    Filters/Effects ˒ Inputs/Outputs ˒ Accurate scheduling and timing Web Audio API Oscillator BiFilter Speaker 🔉
  14. ˒ Ruby wrapper for JavaScript AudioContext & AudioNode JavaScript AudioNodeWrapper

    Oscillator BiFilter Speaker 🔉 Ruby Oscillator BiFilter Speaker
  15. ˒ Ruby wrapper for JavaScript AudioContext & AudioNode AudioNodeWrapper class

    AudioNodeWrapper def initialize(ctx, native_node) @ctx = ctx @native_node = native_node end def connect(destination) if destination.is_a?(AudioNodeWrapper) @native_node.connect(destination.native_node) elsif destination.is_a?(JS::Object) @native_node.connect(destination) end end def disconnect(destination = nil) if destination.is_a?(AudioNodeWrapper) @native_node.disconnect(destination.native_node) elsif destination.is_a?(JS::Object) @native_node.disconnect(destination) end end end More on this later
  16. ˒ A Class in Web Audio API ˒ Automates parameters

    instead of using static constants JavaScript AudioParam Oscillator Gain Speaker 🔉 Exponential Amplitude
  17. ˒ Ruby wrapper for JavaScript AudioParam JavaScript AudioParamWrapper Ruby Gain

    Speaker 🔉 AudioParam frequency
 [Hz] Oscillator Gain Speaker AudioParam frequency
 [Hz] Oscillator
  18. ˒ JavaScript Object Proxy in ruby.wasm VM JS::Object obj =

    JS.global[:window] obj.is_a?(JS::Object) #=> true obj.class #=> ERROR: /bundle/gems/js-2.8.1/lib/js.rb:275:in `invoke_js_method': undefined method `class' for an instance of JS::Object (NoMethodError)
  19. ˒ Inherits BasicObject JS::Object JS::Object.superclass #=> BasicObject JS::Object.instance_methods(false) #=> [:typeof,

    :apply, :raise, :strictly_eql?, :==, :to_a, :to_s, :[], :[]=, :to_f, :to_i, :is_a?, :call, :nil?, :eql?, :respond_to?, :new, :hash , :await, :inspect, :method_missing]
  20. ˒ AudioNodeWrapper again JS::Object class AudioNodeWrapper def connect(destination) (…snip…) elsif

    destination.is_a?(JS::Object) @native_node.connect(destination) end end end
  21. ˒ Inherits BasicObject JS::Object JS::Object.superclass #=> BasicObject JS::Object.instance_methods(false) #=> [:typeof,

    :apply, :raise, :strictly_eql?, :==, :to_a, :to_s, :[], :[]=, :to_f, :to_i, :is_a?, :call, :nil?, :eql?, :respond_to?, :new, :hash , :await, :inspect, :method_missing]
  22. ˒ method_missing → invoke_js_method → call JS::Object def method_missing(sym, *args,

    &block) (...snip...) invoke_js_method(sym, *args, &block) end def invoke_js_method(sym, *args, &block) return self.call(sym, *args, &block) if self[sym].typeof == "function" https://github.com/ruby/ruby.wasm/blob/2.9.4/packages/gems/js/lib/js.rb
  23. ˒ JavaScript objects are wrapped in Ruby space ˒ Manual

    conversion required to get Ruby primitives ˒ Use #to_i, #to_f, #to_s, etc JS::Object num = JS.eval("42") puts num + 30 # => NoMethodError puts num.to_i + 30 # => 72
  24. ˒ vm.eval() ˒ call() ˒ vm.wrap() JavaScript → Ruby const

    ruby_obj = vm.eval(“Const1”); const result = ruby_obj.call(“meth1", vm.wrap(42));
  25. JS 㱻 Ruby trampoline module RubyTest def self.m1(obj) obj.js_func(JS::Object.wrap(self)) end

    def self.m2(obj) 42 end end class J { js_func(obj) { return obj.call("m2", vm.wrap(this)); } } const test = vm.eval("RubyTest"); const obj = new J(); const result = test.call("m1", vm.wrap(obj)); console.log(result); // => 42
  26. JS 㱻 Ruby trampoline limitation module RubyTest def self.m1(obj) obj.call(js_func(JS::Object.wrap(self))

    end def self.m2(obj) raise 'unhandled error' end end class J { js_func(obj) { return obj.call("m2", vm.wrap(this)); } } const test = vm.eval("RubyTest"); const obj = new J(); const result = test.call("m1", vm.wrap(obj)); // The fatal error occured
  27. JS 㱻 Ruby trampoline limitation Ruby APIs that may rewind

    the VM stack are prohibited under nested VM operation (rb_wasm_handle_jmp_unwind) Nested VM operation means that the call stack has sandwitched JS frames like JS -> Ruby -> JS -> Ruby caused by something like `window.rubyVM.eval("JS.global[:rubyVM].eval('Fiber.yield')")` Please check your call stack and make sure that you are **not** doing any of the following inside the nested Ruby frame: 1. Switching fibers (e.g. Fiber#resume, Fiber.yield, and Fiber#transfer) Note that `evalAsync` JS API switches fibers internally 2. Raising uncaught exceptions Please catch all exceptions inside the nested operation 3. Calling Continuation APIs
  28. ˒ Pass graph by JSON (future plan) Reduce JS 㱻

    Ruby conversion JavaScript Oscillator BiFilter Speaker 🔉 Ruby Oscillator BiFilter Speaker JSON JSON Encode Decode
  29. ˒ JavaScript → Ruby: vm.eval(), call(), vm.wrap() ˒ Ruby →

    JavaScript: JS.global JS.eval, JS::Object#call ˒ Limitation about nested call stack ˒ JS::Object ˒ JS::Object#call, JS::Object#method_missing(js.gem) ˒ Explicit conversion required with to_i, to_f, to_s Ruby 㱻 JavaScript