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 +7 -0
- data/LICENSE.txt +21 -0
- data/README.md +121 -0
- data/Rakefile +12 -0
- data/cyclotone.gemspec +29 -0
- data/exe/cyclotone +12 -0
- data/lib/cyclotone/backends/midi_backend.rb +114 -0
- data/lib/cyclotone/backends/midi_file_backend.rb +178 -0
- data/lib/cyclotone/backends/midi_message_support.rb +80 -0
- data/lib/cyclotone/backends/osc_backend.rb +117 -0
- data/lib/cyclotone/controls.rb +142 -0
- data/lib/cyclotone/dsl.rb +141 -0
- data/lib/cyclotone/errors.rb +27 -0
- data/lib/cyclotone/euclidean.rb +65 -0
- data/lib/cyclotone/event.rb +65 -0
- data/lib/cyclotone/harmony.rb +159 -0
- data/lib/cyclotone/mini_notation/ast.rb +199 -0
- data/lib/cyclotone/mini_notation/compiler.rb +115 -0
- data/lib/cyclotone/mini_notation/parser.rb +350 -0
- data/lib/cyclotone/oscillators.rb +131 -0
- data/lib/cyclotone/pattern.rb +361 -0
- data/lib/cyclotone/scheduler.rb +168 -0
- data/lib/cyclotone/state.rb +49 -0
- data/lib/cyclotone/stream.rb +185 -0
- data/lib/cyclotone/support/deterministic.rb +42 -0
- data/lib/cyclotone/time_span.rb +99 -0
- data/lib/cyclotone/transforms/accumulation.rb +45 -0
- data/lib/cyclotone/transforms/alteration.rb +173 -0
- data/lib/cyclotone/transforms/concatenation.rb +15 -0
- data/lib/cyclotone/transforms/condition.rb +63 -0
- data/lib/cyclotone/transforms/sample.rb +82 -0
- data/lib/cyclotone/transforms/time.rb +93 -0
- data/lib/cyclotone/transition.rb +204 -0
- data/lib/cyclotone/version.rb +5 -0
- data/lib/cyclotone.rb +32 -0
- metadata +79 -0
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
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,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
|