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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 91d2536faa825537d9915d48c14f5b9a9471c2e7ce64b4b0d4bc980ac71d4d3f
4
- data.tar.gz: 8578d2fb2015ee4378b4e6e7fb5a54318215dbfa9f54b0ce58910e7af9b037fe
3
+ metadata.gz: 82b8193d66aa307661065cf4c0ed8679daac648ca0dd70b8d63a115631583e68
4
+ data.tar.gz: 5c08359b0afc01849bdfb0ca5e59ba494d20fa9a8fdb2ef90d805777a197731a
5
5
  SHA512:
6
- metadata.gz: f70777e67583edd53c9f7b6e1e57781d46c4389179b66b267e507dc42dc9773ad3f9c776383ec1561ca999efda6d02bd497695385ea92bf4152e35ffe13beeaf
7
- data.tar.gz: cb19eff22ebecf4652bda6bac5c4d6655bbaa849db10e20e431b7d96795be02b855a15b3c4ab114efcbf65c0c26da34e11814cc97e57bbee797d1260ddafd921
6
+ metadata.gz: accd31e54752dced23a33f8a5fae49363390b93f4b8da3541b61ed6493be6ac88c327bc60eaf9628a12c2c38f6b26917d27ff9c9e653b747a9c4721c9256bec9
7
+ data.tar.gz: 1b55740aac157aecce5a9acd4702d72305d1a781522c9d2d3f432027dde807dd1ccc0dbc55e48cd5327c7d73b6958010adbc90d75610e9283d3aea5fae425ef9
data/README.md CHANGED
@@ -1,54 +1,22 @@
1
1
  # Cyclotone
2
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.
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
- ## Highlights
5
+ ## Requirements
6
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
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
- ## Run It Locally
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
- The OSC examples read `CYCLOTONE_OSC_HOST` and `CYCLOTONE_OSC_PORT` when you need to override the default target.
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 rspec
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: :spec
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, and composable pattern primitives for building live coding music tools in Ruby."
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
- Dir.glob(%w[lib/**/* exe/* README* LICENSE* Rakefile *.gemspec], File::FNM_DOTMATCH).select do |path|
23
- File.file?(path)
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(device_name: nil, channel: 0, output: nil, schedule: false)
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
- Thread.new do
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
- 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))
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