cyclotone 0.1.0 → 1.0.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 +4 -4
- data/README.md +8 -74
- data/Rakefile +37 -1
- data/cyclotone.gemspec +16 -3
- data/exe/cyclotone +12 -0
- data/lib/cyclotone/backends/midi_backend.rb +106 -21
- data/lib/cyclotone/backends/midi_file_backend.rb +182 -24
- data/lib/cyclotone/backends/midi_message_support.rb +111 -28
- data/lib/cyclotone/backends/null_backend.rb +33 -0
- data/lib/cyclotone/backends/osc_backend.rb +105 -17
- data/lib/cyclotone/controls.rb +64 -16
- data/lib/cyclotone/dsl.rb +5 -5
- data/lib/cyclotone/errors.rb +8 -3
- data/lib/cyclotone/event.rb +38 -3
- data/lib/cyclotone/harmony.rb +62 -8
- data/lib/cyclotone/mini_notation/ast.rb +85 -5
- data/lib/cyclotone/mini_notation/compiler.rb +18 -10
- data/lib/cyclotone/mini_notation/parser.rb +168 -34
- data/lib/cyclotone/oscillators.rb +130 -28
- data/lib/cyclotone/pattern.rb +211 -36
- data/lib/cyclotone/scheduler.rb +179 -40
- data/lib/cyclotone/state.rb +0 -1
- data/lib/cyclotone/stream.rb +91 -45
- data/lib/cyclotone/support/deterministic.rb +37 -1
- data/lib/cyclotone/time_span.rb +29 -7
- data/lib/cyclotone/transforms/accumulation.rb +28 -5
- data/lib/cyclotone/transforms/alteration.rb +82 -18
- data/lib/cyclotone/transforms/condition.rb +15 -3
- data/lib/cyclotone/transforms/sample.rb +33 -9
- data/lib/cyclotone/transforms/time.rb +24 -5
- data/lib/cyclotone/transition.rb +54 -42
- data/lib/cyclotone/version.rb +1 -1
- data/lib/cyclotone.rb +1 -0
- data/sig/cyclotone.rbs +99 -0
- metadata +4 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 82b8193d66aa307661065cf4c0ed8679daac648ca0dd70b8d63a115631583e68
|
|
4
|
+
data.tar.gz: 5c08359b0afc01849bdfb0ca5e59ba494d20fa9a8fdb2ef90d805777a197731a
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: accd31e54752dced23a33f8a5fae49363390b93f4b8da3541b61ed6493be6ac88c327bc60eaf9628a12c2c38f6b26917d27ff9c9e653b747a9c4721c9256bec9
|
|
7
|
+
data.tar.gz: 1b55740aac157aecce5a9acd4702d72305d1a781522c9d2d3f432027dde807dd1ccc0dbc55e48cd5327c7d73b6958010adbc90d75610e9283d3aea5fae425ef9
|
data/README.md
CHANGED
|
@@ -1,54 +1,22 @@
|
|
|
1
1
|
# Cyclotone
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
Pattern-based live coding primitives for Ruby. Cyclotone provides rational-time spans, immutable pattern events, a compact mini-notation, and OSC/MIDI backends for music workflows.
|
|
4
4
|
|
|
5
|
-
##
|
|
5
|
+
## Requirements
|
|
6
6
|
|
|
7
|
-
-
|
|
8
|
-
-
|
|
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
|
|
7
|
+
- Ruby 3.1 or newer
|
|
8
|
+
- `unimidi` is optional and only needed for direct MIDI device output
|
|
14
9
|
|
|
15
10
|
## Installation
|
|
16
11
|
|
|
17
|
-
Cyclotone requires Ruby 3.1 or newer.
|
|
18
|
-
|
|
19
|
-
Add it to your Gemfile:
|
|
20
|
-
|
|
21
12
|
```bash
|
|
22
13
|
bundle add cyclotone
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
Or install it directly:
|
|
26
|
-
|
|
27
|
-
```bash
|
|
14
|
+
# or
|
|
28
15
|
gem install cyclotone
|
|
29
16
|
```
|
|
30
17
|
|
|
31
18
|
## Quick Start
|
|
32
19
|
|
|
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
20
|
```ruby
|
|
53
21
|
require "cyclotone"
|
|
54
22
|
include Cyclotone::DSL
|
|
@@ -60,58 +28,24 @@ d2 note("0 2 4 7").scale(:minor, root: "c4").s("superpiano")
|
|
|
60
28
|
d3 s("hh*8").every(4) { |pattern| pattern.fast(2) }.sometimes { |pattern| pattern.degrade }
|
|
61
29
|
```
|
|
62
30
|
|
|
63
|
-
##
|
|
64
|
-
|
|
65
|
-
### REPL
|
|
66
|
-
|
|
67
|
-
Start an interactive session with the DSL preloaded:
|
|
31
|
+
## Local Usage
|
|
68
32
|
|
|
69
33
|
```bash
|
|
70
34
|
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
35
|
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
36
|
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
37
|
```
|
|
93
38
|
|
|
94
|
-
|
|
39
|
+
`examples/midi_output.rb` writes `tmp/cyclotone_demo.mid`. OSC examples require a running SuperCollider/SuperDirt target and can use `CYCLOTONE_OSC_HOST` and `CYCLOTONE_OSC_PORT`.
|
|
95
40
|
|
|
96
41
|
## Development
|
|
97
42
|
|
|
98
|
-
Install dependencies, run the test suite, and build the gem:
|
|
99
|
-
|
|
100
43
|
```bash
|
|
101
44
|
bundle install
|
|
102
|
-
bundle exec
|
|
103
|
-
bundle exec rake yard
|
|
45
|
+
bundle exec rake
|
|
104
46
|
gem build cyclotone.gemspec
|
|
105
47
|
```
|
|
106
48
|
|
|
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
49
|
## Contributing
|
|
116
50
|
|
|
117
51
|
Bug reports and pull requests are welcome at https://github.com/ydah/cyclotone.
|
data/Rakefile
CHANGED
|
@@ -6,7 +6,43 @@ require "yard"
|
|
|
6
6
|
|
|
7
7
|
RSpec::Core::RakeTask.new(:spec)
|
|
8
8
|
|
|
9
|
+
desc "Run RuboCop"
|
|
10
|
+
task :rubocop do
|
|
11
|
+
require "rubocop/rake_task"
|
|
12
|
+
RuboCop::RakeTask.new(:rubocop_run)
|
|
13
|
+
Rake::Task[:rubocop_run].invoke
|
|
14
|
+
rescue LoadError
|
|
15
|
+
warn "rubocop is not available; run bundle install to enable linting"
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
desc "Validate RBS signatures"
|
|
19
|
+
task :rbs do
|
|
20
|
+
sh "rbs", "validate", "sig/cyclotone.rbs"
|
|
21
|
+
rescue Errno::ENOENT
|
|
22
|
+
warn "rbs is not available; run bundle install to enable signature validation"
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
desc "Syntax-check examples without external OSC/MIDI services"
|
|
26
|
+
task :examples do
|
|
27
|
+
Dir.glob("examples/*.rb").each do |path|
|
|
28
|
+
sh Gem.ruby, "-c", path
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
desc "Run focused mutation tests for core music primitives"
|
|
33
|
+
task :mutation do
|
|
34
|
+
sh "bundle", "exec", "mutant", "run",
|
|
35
|
+
"--include", "lib",
|
|
36
|
+
"--require", "cyclotone",
|
|
37
|
+
"--usage", "opensource",
|
|
38
|
+
"--integration", "rspec",
|
|
39
|
+
"--fail-fast",
|
|
40
|
+
"Cyclotone::TimeSpan#duration",
|
|
41
|
+
"Cyclotone::TimeSpan#midpoint",
|
|
42
|
+
"Cyclotone::TimeSpan#intersection"
|
|
43
|
+
end
|
|
44
|
+
|
|
9
45
|
desc "Generate YARD documentation"
|
|
10
46
|
YARD::Rake::YardocTask.new(:yard)
|
|
11
47
|
|
|
12
|
-
task default:
|
|
48
|
+
task default: %i[spec examples]
|
data/cyclotone.gemspec
CHANGED
|
@@ -9,7 +9,8 @@ Gem::Specification.new do |spec|
|
|
|
9
9
|
spec.email = ["t.yudai92@gmail.com"]
|
|
10
10
|
|
|
11
11
|
spec.summary = "Pattern-based live coding primitives for Ruby."
|
|
12
|
-
spec.description = "Cyclotone provides rational-time spans, immutable pattern events,
|
|
12
|
+
spec.description = "Cyclotone provides rational-time spans, immutable pattern events, " \
|
|
13
|
+
"and composable pattern primitives for building live coding music tools in Ruby."
|
|
13
14
|
spec.homepage = "https://github.com/ydah/cyclotone"
|
|
14
15
|
spec.license = "MIT"
|
|
15
16
|
spec.required_ruby_version = ">= 3.1"
|
|
@@ -17,10 +18,22 @@ Gem::Specification.new do |spec|
|
|
|
17
18
|
spec.metadata["source_code_uri"] = spec.homepage
|
|
18
19
|
spec.metadata["changelog_uri"] = "#{spec.homepage}/releases"
|
|
19
20
|
spec.metadata["rubygems_mfa_required"] = "true"
|
|
21
|
+
spec.metadata["optional_dependencies"] = "unimidi"
|
|
20
22
|
|
|
21
23
|
spec.files = Dir.chdir(__dir__) do
|
|
22
|
-
|
|
23
|
-
|
|
24
|
+
tracked = begin
|
|
25
|
+
`git ls-files -z`.split("\x0")
|
|
26
|
+
rescue StandardError
|
|
27
|
+
[]
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
candidates = if tracked.empty?
|
|
31
|
+
Dir.glob(%w[lib/**/* exe/* README* LICENSE* Rakefile *.gemspec], File::FNM_DOTMATCH)
|
|
32
|
+
else
|
|
33
|
+
tracked
|
|
34
|
+
end
|
|
35
|
+
candidates.select do |path|
|
|
36
|
+
File.file?(path) && path.match?(%r{\A(?:lib/|sig/|exe/|README|LICENSE|Rakefile|[^/]+\.gemspec\z)})
|
|
24
37
|
end
|
|
25
38
|
end
|
|
26
39
|
spec.bindir = "exe"
|
data/exe/cyclotone
CHANGED
|
@@ -5,6 +5,18 @@ require "irb"
|
|
|
5
5
|
require "cyclotone"
|
|
6
6
|
|
|
7
7
|
TOPLEVEL_BINDING.receiver.extend(Cyclotone::DSL)
|
|
8
|
+
stream = Cyclotone::Stream.instance
|
|
9
|
+
|
|
10
|
+
at_exit do
|
|
11
|
+
stream.stop
|
|
12
|
+
stream.scheduler.backend.close if stream.scheduler.backend.respond_to?(:close)
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
Signal.trap("INT") do
|
|
16
|
+
stream.hush
|
|
17
|
+
stream.scheduler.backend.panic if stream.scheduler.backend.respond_to?(:panic)
|
|
18
|
+
exit(130)
|
|
19
|
+
end
|
|
8
20
|
|
|
9
21
|
puts "Cyclotone REPL"
|
|
10
22
|
puts "DSL loaded: d1..d16, s, gain, setcps, hush"
|
|
@@ -2,27 +2,44 @@
|
|
|
2
2
|
|
|
3
3
|
require_relative "midi_message_support"
|
|
4
4
|
|
|
5
|
-
begin
|
|
6
|
-
require "unimidi"
|
|
7
|
-
rescue LoadError
|
|
8
|
-
end
|
|
9
|
-
|
|
10
5
|
module Cyclotone
|
|
11
6
|
module Backends
|
|
12
7
|
class MIDIBackend
|
|
13
8
|
include MIDIMessageSupport
|
|
14
9
|
|
|
10
|
+
UNIMIDI_AVAILABLE = begin
|
|
11
|
+
require "unimidi"
|
|
12
|
+
true
|
|
13
|
+
rescue LoadError
|
|
14
|
+
false
|
|
15
|
+
end
|
|
16
|
+
|
|
15
17
|
attr_reader :channel
|
|
16
18
|
|
|
17
|
-
def initialize(
|
|
19
|
+
def initialize(
|
|
20
|
+
device_name: nil,
|
|
21
|
+
channel: 0,
|
|
22
|
+
output: nil,
|
|
23
|
+
schedule: false,
|
|
24
|
+
strict_output: false,
|
|
25
|
+
unsupported_controls: :ignore,
|
|
26
|
+
fractional_notes: :floor
|
|
27
|
+
)
|
|
18
28
|
@channel = channel.to_i
|
|
19
29
|
@output = output || detect_output(device_name)
|
|
20
30
|
@schedule = schedule
|
|
31
|
+
@strict_output = strict_output
|
|
32
|
+
@unsupported_controls = normalize_unsupported_control_policy(unsupported_controls)
|
|
33
|
+
@fractional_notes = normalize_fractional_note_policy(fractional_notes)
|
|
34
|
+
@queue_mutex = Mutex.new
|
|
35
|
+
@queue_cv = ConditionVariable.new
|
|
36
|
+
@scheduled_messages = []
|
|
37
|
+
@closed = false
|
|
21
38
|
end
|
|
22
39
|
|
|
23
40
|
class << self
|
|
24
41
|
def available_outputs
|
|
25
|
-
return [] unless defined?(UniMIDI)
|
|
42
|
+
return [] unless UNIMIDI_AVAILABLE || defined?(UniMIDI::Output)
|
|
26
43
|
|
|
27
44
|
UniMIDI::Output.all
|
|
28
45
|
rescue StandardError
|
|
@@ -30,18 +47,55 @@ module Cyclotone
|
|
|
30
47
|
end
|
|
31
48
|
end
|
|
32
49
|
|
|
33
|
-
def send_event(event, at: Time.now.to_f, **_options)
|
|
50
|
+
def send_event(event, at: Time.now.to_f, cps: nil, **_options)
|
|
51
|
+
ensure_output!
|
|
52
|
+
|
|
34
53
|
if @schedule
|
|
35
|
-
schedule_messages(messages_for(event), at: at)
|
|
54
|
+
schedule_messages(messages_for(event, cps: cps), at: at)
|
|
36
55
|
else
|
|
37
|
-
messages_for(event).each { |message| emit(message.merge(at: at)) }
|
|
56
|
+
messages_for(event, cps: cps).each { |message| emit(message.merge(at: at)) }
|
|
38
57
|
end
|
|
39
58
|
rescue StandardError => error
|
|
40
59
|
raise ConnectionError, error.message
|
|
41
60
|
end
|
|
42
61
|
|
|
62
|
+
def flush
|
|
63
|
+
@queue_mutex.synchronize do
|
|
64
|
+
@scheduled_messages.clear
|
|
65
|
+
@queue_cv.signal
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
self
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
def close
|
|
72
|
+
thread = nil
|
|
73
|
+
@queue_mutex.synchronize do
|
|
74
|
+
@closed = true
|
|
75
|
+
@scheduled_messages.clear
|
|
76
|
+
@queue_cv.broadcast
|
|
77
|
+
thread = @scheduler_thread
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
thread&.join(0.5)
|
|
81
|
+
self
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
def panic
|
|
85
|
+
(0..15).each do |panic_channel|
|
|
86
|
+
emit(type: :cc, channel: panic_channel, controller: 123, value: 0, at: Time.now.to_f)
|
|
87
|
+
emit(type: :cc, channel: panic_channel, controller: 120, value: 0, at: Time.now.to_f)
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
self
|
|
91
|
+
end
|
|
92
|
+
|
|
43
93
|
private
|
|
44
94
|
|
|
95
|
+
def ensure_output!
|
|
96
|
+
raise ConnectionError, "MIDI output is not available" if @strict_output && @output.nil?
|
|
97
|
+
end
|
|
98
|
+
|
|
45
99
|
def emit(message)
|
|
46
100
|
bytes = bytes_for(message)
|
|
47
101
|
|
|
@@ -84,20 +138,51 @@ module Cyclotone
|
|
|
84
138
|
end
|
|
85
139
|
|
|
86
140
|
def schedule_messages(messages, at:)
|
|
87
|
-
|
|
88
|
-
sleep([at - Time.now.to_f, 0].max)
|
|
141
|
+
ensure_scheduler_worker
|
|
89
142
|
|
|
143
|
+
@queue_mutex.synchronize do
|
|
90
144
|
messages.each do |message|
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
145
|
+
scheduled_time = at + message.fetch(:delay, 0).to_f
|
|
146
|
+
@scheduled_messages << message.except(:delay).merge(at: scheduled_time)
|
|
147
|
+
end
|
|
148
|
+
|
|
149
|
+
@scheduled_messages.sort_by! { |message| message[:at] }
|
|
150
|
+
@queue_cv.signal
|
|
151
|
+
end
|
|
152
|
+
end
|
|
153
|
+
|
|
154
|
+
def ensure_scheduler_worker
|
|
155
|
+
@queue_mutex.synchronize do
|
|
156
|
+
return if @scheduler_thread&.alive?
|
|
157
|
+
|
|
158
|
+
@closed = false
|
|
159
|
+
@scheduler_thread = Thread.new { scheduler_loop }
|
|
160
|
+
end
|
|
161
|
+
end
|
|
162
|
+
|
|
163
|
+
def scheduler_loop
|
|
164
|
+
loop do
|
|
165
|
+
message = next_scheduled_message
|
|
166
|
+
return unless message
|
|
167
|
+
|
|
168
|
+
emit(message)
|
|
169
|
+
end
|
|
170
|
+
end
|
|
171
|
+
|
|
172
|
+
def next_scheduled_message
|
|
173
|
+
@queue_mutex.synchronize do
|
|
174
|
+
loop do
|
|
175
|
+
return nil if @closed
|
|
176
|
+
|
|
177
|
+
if @scheduled_messages.empty?
|
|
178
|
+
@queue_cv.wait(@queue_mutex)
|
|
179
|
+
next
|
|
100
180
|
end
|
|
181
|
+
|
|
182
|
+
wait_time = @scheduled_messages.first[:at] - Time.now.to_f
|
|
183
|
+
return @scheduled_messages.shift unless wait_time.positive?
|
|
184
|
+
|
|
185
|
+
@queue_cv.wait(@queue_mutex, wait_time)
|
|
101
186
|
end
|
|
102
187
|
end
|
|
103
188
|
end
|