cyclotone 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: 91d2536faa825537d9915d48c14f5b9a9471c2e7ce64b4b0d4bc980ac71d4d3f
4
+ data.tar.gz: 8578d2fb2015ee4378b4e6e7fb5a54318215dbfa9f54b0ce58910e7af9b037fe
5
+ SHA512:
6
+ metadata.gz: f70777e67583edd53c9f7b6e1e57781d46c4389179b66b267e507dc42dc9773ad3f9c776383ec1561ca999efda6d02bd497695385ea92bf4152e35ffe13beeaf
7
+ data.tar.gz: cb19eff22ebecf4652bda6bac5c4d6655bbaa849db10e20e431b7d96795be02b855a15b3c4ab114efcbf65c0c26da34e11814cc97e57bbee797d1260ddafd921
data/LICENSE.txt ADDED
@@ -0,0 +1,21 @@
1
+ The MIT License (MIT)
2
+
3
+ Copyright (c) 2026 Yudai Takada
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
13
+ all 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
21
+ THE SOFTWARE.
data/README.md ADDED
@@ -0,0 +1,121 @@
1
+ # Cyclotone
2
+
3
+ Cyclotone is a Ruby gem for pattern-based live coding. It combines exact rational time, immutable event patterns, a compact mini-notation, and runtime tools for driving OSC or MIDI-based performance workflows.
4
+
5
+ ## Highlights
6
+
7
+ - Exact timing with `Cyclotone::TimeSpan`
8
+ - Immutable `Cyclotone::Event` values and composable `Cyclotone::Pattern` queries
9
+ - Mini-notation parsing and compilation, including Euclidean rhythms
10
+ - Pattern transforms for time, concatenation, accumulation, alteration, condition, and sample operations
11
+ - Control factories, oscillators, and harmony helpers for building musical data
12
+ - Scheduler, stream transitions, and a DSL for slot-based live performance
13
+ - OSC, MIDI, and MIDI file backends
14
+
15
+ ## Installation
16
+
17
+ Cyclotone requires Ruby 3.1 or newer.
18
+
19
+ Add it to your Gemfile:
20
+
21
+ ```bash
22
+ bundle add cyclotone
23
+ ```
24
+
25
+ Or install it directly:
26
+
27
+ ```bash
28
+ gem install cyclotone
29
+ ```
30
+
31
+ ## Quick Start
32
+
33
+ ### Query a pattern directly
34
+
35
+ ```ruby
36
+ require "cyclotone"
37
+
38
+ beat = Cyclotone::Pattern.mn("bd [sd sd] hh cp")
39
+ accented = Cyclotone::Controls.s(beat).gain(0.9)
40
+
41
+ events = accented.query_cycle(0)
42
+
43
+ events.map { |event| [event.whole.to_s, event.part.to_s, event.value] }
44
+ # => [
45
+ # ["[0, 1/4)", "[0, 1/4)", {:s=>"bd", :gain=>0.9}],
46
+ # ...
47
+ # ]
48
+ ```
49
+
50
+ ### Use the live coding DSL
51
+
52
+ ```ruby
53
+ require "cyclotone"
54
+ include Cyclotone::DSL
55
+
56
+ setcps Rational(9, 16)
57
+
58
+ d1 s("bd sd:3 [~ bd] sd").gain(0.8)
59
+ d2 note("0 2 4 7").scale(:minor, root: "c4").s("superpiano")
60
+ d3 s("hh*8").every(4) { |pattern| pattern.fast(2) }.sometimes { |pattern| pattern.degrade }
61
+ ```
62
+
63
+ ## Run It Locally
64
+
65
+ ### REPL
66
+
67
+ Start an interactive session with the DSL preloaded:
68
+
69
+ ```bash
70
+ bundle exec bin/cyclotone
71
+ ```
72
+
73
+ ### Local MIDI file output
74
+
75
+ Generate a MIDI file without needing a live OSC target:
76
+
77
+ ```bash
78
+ bundle exec ruby examples/midi_output.rb
79
+ bundle exec ruby examples/chill_midi_output.rb
80
+ ```
81
+
82
+ These write `tmp/cyclotone_demo.mid` and `tmp/cyclotone_chill.mid`, which you can import into a DAW or any MIDI-capable player.
83
+
84
+ ### OSC / SuperDirt examples
85
+
86
+ If you already have SuperCollider and SuperDirt running, try one of the example scripts:
87
+
88
+ ```bash
89
+ bundle exec ruby examples/basic_beat.rb
90
+ bundle exec ruby examples/euclidean_rhythms.rb
91
+ bundle exec ruby examples/live_coding_session.rb
92
+ ```
93
+
94
+ The OSC examples read `CYCLOTONE_OSC_HOST` and `CYCLOTONE_OSC_PORT` when you need to override the default target.
95
+
96
+ ## Development
97
+
98
+ Install dependencies, run the test suite, and build the gem:
99
+
100
+ ```bash
101
+ bundle install
102
+ bundle exec rspec
103
+ bundle exec rake yard
104
+ gem build cyclotone.gemspec
105
+ ```
106
+
107
+ For local manual experiments, install the gem into your current Ruby environment:
108
+
109
+ ```bash
110
+ bundle exec rake install
111
+ ```
112
+
113
+ YARD output is written to `doc/yard`.
114
+
115
+ ## Contributing
116
+
117
+ Bug reports and pull requests are welcome at https://github.com/ydah/cyclotone.
118
+
119
+ ## License
120
+
121
+ Cyclotone is available as open source under the terms of the [MIT License](https://opensource.org/licenses/MIT).
data/Rakefile ADDED
@@ -0,0 +1,12 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "bundler/gem_tasks"
4
+ require "rspec/core/rake_task"
5
+ require "yard"
6
+
7
+ RSpec::Core::RakeTask.new(:spec)
8
+
9
+ desc "Generate YARD documentation"
10
+ YARD::Rake::YardocTask.new(:yard)
11
+
12
+ task default: :spec
data/cyclotone.gemspec ADDED
@@ -0,0 +1,29 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "lib/cyclotone/version"
4
+
5
+ Gem::Specification.new do |spec|
6
+ spec.name = "cyclotone"
7
+ spec.version = Cyclotone::VERSION
8
+ spec.authors = ["Yudai Takada"]
9
+ spec.email = ["t.yudai92@gmail.com"]
10
+
11
+ spec.summary = "Pattern-based live coding primitives for Ruby."
12
+ spec.description = "Cyclotone provides rational-time spans, immutable pattern events, and composable pattern primitives for building live coding music tools in Ruby."
13
+ spec.homepage = "https://github.com/ydah/cyclotone"
14
+ spec.license = "MIT"
15
+ spec.required_ruby_version = ">= 3.1"
16
+
17
+ spec.metadata["source_code_uri"] = spec.homepage
18
+ spec.metadata["changelog_uri"] = "#{spec.homepage}/releases"
19
+ spec.metadata["rubygems_mfa_required"] = "true"
20
+
21
+ spec.files = Dir.chdir(__dir__) do
22
+ Dir.glob(%w[lib/**/* exe/* README* LICENSE* Rakefile *.gemspec], File::FNM_DOTMATCH).select do |path|
23
+ File.file?(path)
24
+ end
25
+ end
26
+ spec.bindir = "exe"
27
+ spec.executables = spec.files.grep(%r{\Aexe/}) { |f| File.basename(f) }
28
+ spec.require_paths = ["lib"]
29
+ end
data/exe/cyclotone ADDED
@@ -0,0 +1,12 @@
1
+ #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
3
+
4
+ require "irb"
5
+ require "cyclotone"
6
+
7
+ TOPLEVEL_BINDING.receiver.extend(Cyclotone::DSL)
8
+
9
+ puts "Cyclotone REPL"
10
+ puts "DSL loaded: d1..d16, s, gain, setcps, hush"
11
+
12
+ IRB.start
@@ -0,0 +1,114 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "midi_message_support"
4
+
5
+ begin
6
+ require "unimidi"
7
+ rescue LoadError
8
+ end
9
+
10
+ module Cyclotone
11
+ module Backends
12
+ class MIDIBackend
13
+ include MIDIMessageSupport
14
+
15
+ attr_reader :channel
16
+
17
+ def initialize(device_name: nil, channel: 0, output: nil, schedule: false)
18
+ @channel = channel.to_i
19
+ @output = output || detect_output(device_name)
20
+ @schedule = schedule
21
+ end
22
+
23
+ class << self
24
+ def available_outputs
25
+ return [] unless defined?(UniMIDI)
26
+
27
+ UniMIDI::Output.all
28
+ rescue StandardError
29
+ []
30
+ end
31
+ end
32
+
33
+ def send_event(event, at: Time.now.to_f, **_options)
34
+ if @schedule
35
+ schedule_messages(messages_for(event), at: at)
36
+ else
37
+ messages_for(event).each { |message| emit(message.merge(at: at)) }
38
+ end
39
+ rescue StandardError => error
40
+ raise ConnectionError, error.message
41
+ end
42
+
43
+ private
44
+
45
+ def emit(message)
46
+ bytes = bytes_for(message)
47
+
48
+ if @output.respond_to?(:call)
49
+ @output.call(message)
50
+ elsif midi_device?(@output)
51
+ send_to_device(@output, bytes)
52
+ elsif @output.respond_to?(:puts)
53
+ @output.puts(message)
54
+ end
55
+ end
56
+
57
+ def send_to_device(device, bytes)
58
+ if device.respond_to?(:open)
59
+ device.open do |port|
60
+ (port || device).puts(bytes)
61
+ end
62
+ else
63
+ device.puts(bytes)
64
+ end
65
+ end
66
+
67
+ def midi_device?(output)
68
+ output.respond_to?(:puts) && (output.respond_to?(:open) || !output.is_a?(IO))
69
+ end
70
+
71
+ def bytes_for(message)
72
+ channel = normalize_channel(message[:channel] || channel)
73
+
74
+ case message[:type]
75
+ when :note_on
76
+ [0x90 | channel, normalize_data_byte(message[:note]), normalize_velocity(message[:velocity])]
77
+ when :note_off
78
+ [0x80 | channel, normalize_data_byte(message[:note]), normalize_velocity(message[:velocity])]
79
+ when :cc
80
+ [0xB0 | channel, normalize_data_byte(message[:controller]), normalize_controller_value(message[:value])]
81
+ else
82
+ []
83
+ end
84
+ end
85
+
86
+ def schedule_messages(messages, at:)
87
+ Thread.new do
88
+ sleep([at - Time.now.to_f, 0].max)
89
+
90
+ messages.each do |message|
91
+ delay = message[:delay].to_f
92
+
93
+ if delay.positive?
94
+ Thread.new do
95
+ sleep(delay)
96
+ emit(message.merge(at: at + delay))
97
+ end
98
+ else
99
+ emit(message.merge(at: at))
100
+ end
101
+ end
102
+ end
103
+ end
104
+
105
+ def detect_output(device_name)
106
+ devices = self.class.available_outputs
107
+ return nil if devices.empty?
108
+ return devices.first if device_name.nil?
109
+
110
+ devices.find { |device| device.name == device_name }
111
+ end
112
+ end
113
+ end
114
+ end
@@ -0,0 +1,178 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "fileutils"
4
+
5
+ module Cyclotone
6
+ module Backends
7
+ class MIDIFileBackend
8
+ include MIDIMessageSupport
9
+
10
+ DEFAULT_BPM = 120
11
+ DEFAULT_PPQN = 480
12
+ DEFAULT_TRACK_NAME = "Cyclotone"
13
+
14
+ attr_reader :path, :channel, :ppqn, :bpm
15
+
16
+ def initialize(path:, bpm: DEFAULT_BPM, ppqn: DEFAULT_PPQN, channel: 0, track_name: DEFAULT_TRACK_NAME)
17
+ @path = path
18
+ @bpm = bpm.to_f
19
+ @ppqn = ppqn.to_i
20
+ @channel = channel.to_i
21
+ @track_name = track_name.to_s
22
+ @messages = []
23
+ @origin_time = nil
24
+ end
25
+
26
+ def begin_capture(at:)
27
+ @origin_time = at.to_f
28
+ self
29
+ end
30
+
31
+ def clear
32
+ @messages.clear
33
+ @origin_time = nil
34
+ self
35
+ end
36
+
37
+ def send_event(event, at: Time.now.to_f, **_options)
38
+ capture_time = at.to_f
39
+ @origin_time ||= capture_time
40
+
41
+ messages_for(event).each do |message|
42
+ @messages << normalize_message(message, capture_time)
43
+ end
44
+
45
+ self
46
+ rescue StandardError => error
47
+ raise ConnectionError, error.message
48
+ end
49
+
50
+ def write!
51
+ FileUtils.mkdir_p(File.dirname(path))
52
+ File.binwrite(path, midi_file_data)
53
+ path
54
+ rescue StandardError => error
55
+ raise ConnectionError, error.message
56
+ end
57
+
58
+ def midi_file_data
59
+ header_chunk + track_chunk(track_data)
60
+ end
61
+
62
+ private
63
+
64
+ def normalize_message(message, capture_time)
65
+ timestamp = capture_time + message.fetch(:delay, 0).to_f
66
+ message.reject { |key, _| key == :delay }.merge(at: timestamp)
67
+ end
68
+
69
+ def header_chunk
70
+ "MThd".b << [6, 0, 1, ppqn].pack("Nnnn")
71
+ end
72
+
73
+ def track_chunk(data)
74
+ "MTrk".b << [data.bytesize].pack("N") << data
75
+ end
76
+
77
+ def track_data
78
+ previous_tick = 0
79
+ body = +"".b
80
+
81
+ track_events.each do |track_event|
82
+ delta = track_event[:tick] - previous_tick
83
+ body << encode_variable_length(delta)
84
+ body << track_event[:data]
85
+ previous_tick = track_event[:tick]
86
+ end
87
+
88
+ body
89
+ end
90
+
91
+ def track_events
92
+ events = [
93
+ { tick: 0, priority: 0, data: tempo_event },
94
+ { tick: 0, priority: 1, data: track_name_event }
95
+ ]
96
+
97
+ events.concat(@messages.map { |message| channel_track_event(message) })
98
+
99
+ end_tick = events.map { |event| event[:tick] }.max || 0
100
+ events << { tick: end_tick, priority: 99, data: end_of_track_event }
101
+ events.sort_by { |event| [event[:tick], event[:priority]] }
102
+ end
103
+
104
+ def channel_track_event(message)
105
+ tick = seconds_to_ticks(message[:at].to_f - origin_time)
106
+
107
+ {
108
+ tick: tick,
109
+ priority: event_priority(message[:type]),
110
+ data: channel_event_data(message)
111
+ }
112
+ end
113
+
114
+ def event_priority(type)
115
+ case type
116
+ when :note_off then 0
117
+ when :cc then 1
118
+ else 2
119
+ end
120
+ end
121
+
122
+ def origin_time
123
+ @origin_time || 0.0
124
+ end
125
+
126
+ def seconds_to_ticks(seconds)
127
+ beats = [seconds.to_f, 0.0].max * bpm / 60.0
128
+ (beats * ppqn).round
129
+ end
130
+
131
+ def channel_event_data(message)
132
+ channel = message[:channel].to_i.clamp(0, 15)
133
+
134
+ case message[:type]
135
+ when :note_on
136
+ [0x90 | channel, message[:note], message[:velocity]].pack("C3")
137
+ when :note_off
138
+ [0x80 | channel, message[:note], message[:velocity]].pack("C3")
139
+ when :cc
140
+ [0xB0 | channel, message[:controller], message[:value]].pack("C3")
141
+ else
142
+ raise ArgumentError, "unsupported MIDI message type: #{message[:type]}"
143
+ end
144
+ end
145
+
146
+ def tempo_event
147
+ microseconds = (60_000_000 / bpm).round.clamp(1, 0xFF_FF_FF)
148
+ "\xFF\x51\x03".b << [
149
+ (microseconds >> 16) & 0xFF,
150
+ (microseconds >> 8) & 0xFF,
151
+ microseconds & 0xFF
152
+ ].pack("C3")
153
+ end
154
+
155
+ def track_name_event
156
+ name = @track_name.dup.force_encoding(Encoding::ASCII_8BIT)
157
+ "\xFF\x03".b << encode_variable_length(name.bytesize) << name
158
+ end
159
+
160
+ def end_of_track_event
161
+ "\xFF\x2F\x00".b
162
+ end
163
+
164
+ def encode_variable_length(value)
165
+ number = value.to_i
166
+ bytes = [number & 0x7F]
167
+ number >>= 7
168
+
169
+ while number.positive?
170
+ bytes.unshift((number & 0x7F) | 0x80)
171
+ number >>= 7
172
+ end
173
+
174
+ bytes.pack("C*")
175
+ end
176
+ end
177
+ end
178
+ end
@@ -0,0 +1,80 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Cyclotone
4
+ module Backends
5
+ module MIDIMessageSupport
6
+ def messages_for(event)
7
+ values = event.value.is_a?(Hash) ? event.value : { note: event.value }
8
+ return control_change_messages(values) if values.key?(:cc)
9
+
10
+ note = values[:note]
11
+ return [] if note.nil?
12
+
13
+ active_channel = normalize_channel(values[:channel] || channel)
14
+ sustain = [extract_sustain(values, event), 0.0].max
15
+
16
+ [
17
+ {
18
+ type: :note_on,
19
+ channel: active_channel,
20
+ note: normalize_data_byte(note),
21
+ velocity: normalize_velocity(values[:velocity] || values[:gain] || 1.0)
22
+ },
23
+ {
24
+ type: :note_off,
25
+ channel: active_channel,
26
+ note: normalize_data_byte(note),
27
+ velocity: 0,
28
+ delay: sustain
29
+ }
30
+ ]
31
+ end
32
+
33
+ private
34
+
35
+ def control_change_messages(values)
36
+ cc_values = values[:cc].is_a?(Hash) ? values[:cc] : {}
37
+ active_channel = normalize_channel(values[:channel] || channel)
38
+
39
+ cc_values.map do |controller, amount|
40
+ {
41
+ type: :cc,
42
+ channel: active_channel,
43
+ controller: normalize_data_byte(controller),
44
+ value: normalize_controller_value(amount)
45
+ }
46
+ end
47
+ end
48
+
49
+ def extract_sustain(values, event)
50
+ sustain = values[:sustain]
51
+ sustain = event.duration if sustain.nil?
52
+ sustain ||= 1
53
+ sustain.to_f
54
+ end
55
+
56
+ def normalize_velocity(value)
57
+ normalize_7bit_value(value)
58
+ end
59
+
60
+ def normalize_controller_value(value)
61
+ normalize_7bit_value(value)
62
+ end
63
+
64
+ def normalize_7bit_value(value)
65
+ numeric = value.to_f
66
+ return numeric.round.clamp(0, 127) if numeric > 1.0
67
+
68
+ (numeric * 127).round.clamp(0, 127)
69
+ end
70
+
71
+ def normalize_channel(value)
72
+ value.to_i.clamp(0, 15)
73
+ end
74
+
75
+ def normalize_data_byte(value)
76
+ value.to_i.clamp(0, 127)
77
+ end
78
+ end
79
+ end
80
+ end
@@ -0,0 +1,117 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "socket"
4
+
5
+ module Cyclotone
6
+ module Backends
7
+ class OSCBackend
8
+ attr_reader :host, :port
9
+
10
+ def initialize(host: "127.0.0.1", port: 57_120, socket: nil, socket_factory: nil, retries: 1)
11
+ @host = host
12
+ @port = port
13
+ @socket_factory = socket_factory || proc { UDPSocket.new }
14
+ @retries = retries.to_i
15
+ @socket = socket || build_socket
16
+ rescue StandardError => error
17
+ raise ConnectionError, error.message
18
+ end
19
+
20
+ def payload_for(event, at:, cps: nil)
21
+ values = event.value.is_a?(Hash) ? event.value : { value: event.value }
22
+
23
+ [
24
+ "when", at.to_f,
25
+ "onset", absolute_onset(event, at),
26
+ "offset", absolute_offset(event, at, cps)
27
+ ].compact + flatten_hash(values)
28
+ end
29
+
30
+ def build_message(event, at:, cps: nil)
31
+ encode_message("/dirt/play", payload_for(event, at: at, cps: cps))
32
+ end
33
+
34
+ def send_event(event, at: Time.now.to_f, cps: nil)
35
+ with_retry do
36
+ @socket.send(build_message(event, at: at, cps: cps), 0, host, port)
37
+ end
38
+ rescue StandardError => error
39
+ raise ConnectionError, error.message
40
+ end
41
+
42
+ private
43
+
44
+ def with_retry
45
+ attempts_remaining = @retries
46
+
47
+ begin
48
+ yield
49
+ rescue StandardError
50
+ raise if attempts_remaining <= 0
51
+
52
+ attempts_remaining -= 1
53
+ reconnect!
54
+ retry
55
+ end
56
+ end
57
+
58
+ def reconnect!
59
+ @socket.close if @socket.respond_to?(:close)
60
+ @socket = build_socket
61
+ end
62
+
63
+ def build_socket
64
+ @socket_factory.call
65
+ end
66
+
67
+ def flatten_hash(hash)
68
+ hash.each_with_object([]) do |(key, value), payload|
69
+ payload << key.to_s
70
+ payload << value
71
+ end
72
+ end
73
+
74
+ def absolute_onset(event, at)
75
+ return nil unless event.onset
76
+
77
+ at.to_f
78
+ end
79
+
80
+ def absolute_offset(event, at, cps)
81
+ return nil unless event.offset
82
+ return event.offset.to_f if cps.nil? || event.duration.nil?
83
+
84
+ at.to_f + (event.duration.to_f / cps.to_f)
85
+ end
86
+
87
+ def encode_message(address, arguments)
88
+ type_tags = arguments.map do |argument|
89
+ case argument
90
+ when Integer then "i"
91
+ when Float then "f"
92
+ else "s"
93
+ end
94
+ end.join
95
+
96
+ padded(address) + padded(",#{type_tags}") + arguments.map { |argument| encode_argument(argument) }.join
97
+ end
98
+
99
+ def encode_argument(argument)
100
+ case argument
101
+ when Integer
102
+ [argument].pack("N")
103
+ when Float
104
+ [argument].pack("g")
105
+ else
106
+ padded(argument.to_s)
107
+ end
108
+ end
109
+
110
+ def padded(string)
111
+ bytes = "#{string}\0"
112
+ padding = (4 - (bytes.bytesize % 4)) % 4
113
+ bytes + ("\0" * padding)
114
+ end
115
+ end
116
+ end
117
+ end