musa-dsl 0.30.2 → 0.40.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/.gitignore +3 -1
- data/.version +6 -0
- data/.yardopts +7 -0
- data/README.md +227 -6
- data/docs/README.md +83 -0
- data/docs/api-reference.md +86 -0
- data/docs/getting-started/quick-start.md +93 -0
- data/docs/getting-started/tutorial.md +58 -0
- data/docs/subsystems/core-extensions.md +316 -0
- data/docs/subsystems/datasets.md +465 -0
- data/docs/subsystems/generative.md +290 -0
- data/docs/subsystems/matrix.md +63 -0
- data/docs/subsystems/midi.md +123 -0
- data/docs/subsystems/music.md +233 -0
- data/docs/subsystems/musicxml-builder.md +264 -0
- data/docs/subsystems/neumas.md +71 -0
- data/docs/subsystems/repl.md +135 -0
- data/docs/subsystems/sequencer.md +98 -0
- data/docs/subsystems/series.md +302 -0
- data/docs/subsystems/transcription.md +152 -0
- data/docs/subsystems/transport.md +177 -0
- data/lib/musa-dsl/core-ext/array-explode-ranges.rb +68 -0
- data/lib/musa-dsl/core-ext/arrayfy.rb +110 -0
- data/lib/musa-dsl/core-ext/attribute-builder.rb +91 -30
- data/lib/musa-dsl/core-ext/deep-copy.rb +125 -2
- data/lib/musa-dsl/core-ext/dynamic-proxy.rb +78 -0
- data/lib/musa-dsl/core-ext/extension.rb +53 -0
- data/lib/musa-dsl/core-ext/hashify.rb +162 -1
- data/lib/musa-dsl/core-ext/inspect-nice.rb +154 -0
- data/lib/musa-dsl/core-ext/smart-proc-binder.rb +117 -0
- data/lib/musa-dsl/core-ext/with.rb +114 -0
- data/lib/musa-dsl/datasets/dataset.rb +109 -0
- data/lib/musa-dsl/datasets/delta-d.rb +78 -0
- data/lib/musa-dsl/datasets/e.rb +186 -2
- data/lib/musa-dsl/datasets/gdv.rb +279 -2
- data/lib/musa-dsl/datasets/gdvd.rb +201 -0
- data/lib/musa-dsl/datasets/helper.rb +75 -0
- data/lib/musa-dsl/datasets/p.rb +177 -2
- data/lib/musa-dsl/datasets/packed-v.rb +91 -0
- data/lib/musa-dsl/datasets/pdv.rb +136 -1
- data/lib/musa-dsl/datasets/ps.rb +134 -4
- data/lib/musa-dsl/datasets/score/queriable.rb +143 -1
- data/lib/musa-dsl/datasets/score/render.rb +105 -1
- data/lib/musa-dsl/datasets/score/to-mxml/process-pdv.rb +138 -1
- data/lib/musa-dsl/datasets/score/to-mxml/process-ps.rb +111 -0
- data/lib/musa-dsl/datasets/score/to-mxml/process-time.rb +200 -1
- data/lib/musa-dsl/datasets/score/to-mxml/to-mxml.rb +145 -1
- data/lib/musa-dsl/datasets/score.rb +279 -0
- data/lib/musa-dsl/datasets/v.rb +88 -0
- data/lib/musa-dsl/generative/darwin.rb +180 -1
- data/lib/musa-dsl/generative/generative-grammar.rb +359 -0
- data/lib/musa-dsl/generative/markov.rb +133 -3
- data/lib/musa-dsl/generative/rules.rb +258 -4
- data/lib/musa-dsl/generative/variatio.rb +217 -2
- data/lib/musa-dsl/logger/logger.rb +267 -2
- data/lib/musa-dsl/matrix/matrix.rb +256 -10
- data/lib/musa-dsl/midi/midi-recorder.rb +108 -1
- data/lib/musa-dsl/midi/midi-voices.rb +265 -4
- data/lib/musa-dsl/music/chord-definition.rb +233 -1
- data/lib/musa-dsl/music/chord-definitions.rb +33 -6
- data/lib/musa-dsl/music/chords.rb +308 -2
- data/lib/musa-dsl/music/equally-tempered-12-tone-scale-system.rb +315 -0
- data/lib/musa-dsl/music/scales.rb +957 -40
- data/lib/musa-dsl/musicxml/builder/attributes.rb +483 -3
- data/lib/musa-dsl/musicxml/builder/backup-forward.rb +166 -1
- data/lib/musa-dsl/musicxml/builder/direction.rb +243 -0
- data/lib/musa-dsl/musicxml/builder/helper.rb +240 -0
- data/lib/musa-dsl/musicxml/builder/measure.rb +284 -0
- data/lib/musa-dsl/musicxml/builder/note-complexities.rb +324 -8
- data/lib/musa-dsl/musicxml/builder/note.rb +285 -0
- data/lib/musa-dsl/musicxml/builder/part-group.rb +108 -1
- data/lib/musa-dsl/musicxml/builder/part.rb +139 -0
- data/lib/musa-dsl/musicxml/builder/pitched-note.rb +124 -0
- data/lib/musa-dsl/musicxml/builder/rest.rb +93 -0
- data/lib/musa-dsl/musicxml/builder/score-partwise.rb +276 -0
- data/lib/musa-dsl/musicxml/builder/typed-text.rb +62 -1
- data/lib/musa-dsl/musicxml/builder/unpitched-note.rb +83 -0
- data/lib/musa-dsl/neumalang/neumalang.rb +675 -0
- data/lib/musa-dsl/neumas/array-to-neumas.rb +149 -0
- data/lib/musa-dsl/neumas/neuma-decoder.rb +253 -0
- data/lib/musa-dsl/neumas/neuma-gdv-decoder.rb +142 -2
- data/lib/musa-dsl/neumas/neuma-gdvd-decoder.rb +82 -0
- data/lib/musa-dsl/neumas/neumas.rb +67 -0
- data/lib/musa-dsl/neumas/string-to-neumas.rb +233 -1
- data/lib/musa-dsl/repl/repl.rb +550 -0
- data/lib/musa-dsl/sequencer/base-sequencer-implementation-every.rb +118 -2
- data/lib/musa-dsl/sequencer/base-sequencer-implementation-move.rb +149 -2
- data/lib/musa-dsl/sequencer/base-sequencer-implementation-play-helper.rb +296 -0
- data/lib/musa-dsl/sequencer/base-sequencer-implementation-play-timed.rb +88 -2
- data/lib/musa-dsl/sequencer/base-sequencer-implementation-play.rb +161 -0
- data/lib/musa-dsl/sequencer/base-sequencer-implementation.rb +263 -0
- data/lib/musa-dsl/sequencer/base-sequencer-tick-based.rb +173 -1
- data/lib/musa-dsl/sequencer/base-sequencer-tickless-based.rb +177 -0
- data/lib/musa-dsl/sequencer/base-sequencer.rb +710 -10
- data/lib/musa-dsl/sequencer/sequencer-dsl.rb +210 -0
- data/lib/musa-dsl/sequencer/timeslots.rb +79 -0
- data/lib/musa-dsl/series/array-to-serie.rb +37 -1
- data/lib/musa-dsl/series/base-series.rb +843 -5
- data/lib/musa-dsl/series/buffer-serie.rb +48 -0
- data/lib/musa-dsl/series/hash-or-array-serie-splitter.rb +41 -0
- data/lib/musa-dsl/series/main-serie-constructors.rb +398 -2
- data/lib/musa-dsl/series/main-serie-operations.rb +538 -16
- data/lib/musa-dsl/series/proxy-serie.rb +67 -0
- data/lib/musa-dsl/series/quantizer-serie.rb +45 -7
- data/lib/musa-dsl/series/queue-serie.rb +65 -0
- data/lib/musa-dsl/series/series-composer.rb +701 -0
- data/lib/musa-dsl/series/timed-serie.rb +473 -28
- data/lib/musa-dsl/transcription/from-gdv-to-midi.rb +404 -1
- data/lib/musa-dsl/transcription/from-gdv-to-musicxml.rb +118 -0
- data/lib/musa-dsl/transcription/from-gdv.rb +84 -1
- data/lib/musa-dsl/transcription/transcription.rb +265 -0
- data/lib/musa-dsl/transport/clock.rb +125 -0
- data/lib/musa-dsl/transport/dummy-clock.rb +89 -2
- data/lib/musa-dsl/transport/external-tick-clock.rb +91 -0
- data/lib/musa-dsl/transport/input-midi-clock.rb +133 -1
- data/lib/musa-dsl/transport/timer-clock.rb +183 -1
- data/lib/musa-dsl/transport/timer.rb +83 -0
- data/lib/musa-dsl/transport/transport.rb +318 -0
- data/lib/musa-dsl/version.rb +1 -1
- data/lib/musa-dsl.rb +132 -25
- data/musa-dsl.gemspec +12 -10
- metadata +87 -8
|
@@ -4,7 +4,81 @@ require_relative 'clock'
|
|
|
4
4
|
|
|
5
5
|
module Musa
|
|
6
6
|
module Clock
|
|
7
|
+
# Clock synchronized to external MIDI Clock messages.
|
|
8
|
+
#
|
|
9
|
+
# InputMidiClock receives MIDI Clock, Start, Stop, Continue, and Song Position
|
|
10
|
+
# messages from an external source (typically a DAW or hardware sequencer) and
|
|
11
|
+
# generates ticks synchronized to that source.
|
|
12
|
+
#
|
|
13
|
+
# ## Activation Model
|
|
14
|
+
#
|
|
15
|
+
# **IMPORTANT**: InputMidiClock requires external MIDI activation. After calling
|
|
16
|
+
# `transport.start` (which blocks), the clock waits for MIDI "Start" (0xFA)
|
|
17
|
+
# message from the external source to begin generating ticks.
|
|
18
|
+
#
|
|
19
|
+
# This activation model is appropriate for:
|
|
20
|
+
#
|
|
21
|
+
# - **DAW synchronization**: DAW controls start/stop via MIDI Clock
|
|
22
|
+
# - **Hardware sequencer sync**: External device controls timing
|
|
23
|
+
# - **Multi-device setups**: One master device controls all slaves
|
|
24
|
+
#
|
|
25
|
+
# ## MIDI Clock Protocol
|
|
26
|
+
#
|
|
27
|
+
# - **Clock (0xF8)**: Sent 24 times per quarter note (generates ticks when started)
|
|
28
|
+
# - **Start (0xFA)**: Begin playing from start (activates tick generation)
|
|
29
|
+
# - **Stop (0xFC)**: Stop playing (halts tick generation)
|
|
30
|
+
# - **Continue (0xFB)**: Resume from current position
|
|
31
|
+
# - **Song Position Pointer (0xF2)**: Jump to specific position
|
|
32
|
+
#
|
|
33
|
+
# ## Features
|
|
34
|
+
#
|
|
35
|
+
# - Automatic synchronization to external MIDI Clock
|
|
36
|
+
# - Position changes via Song Position Pointer
|
|
37
|
+
# - Start/Stop/Continue handling
|
|
38
|
+
# - Performance monitoring (time_table for tick processing times)
|
|
39
|
+
# - Graceful handling of missing input (waits until assigned)
|
|
40
|
+
#
|
|
41
|
+
# ## Special Sequences
|
|
42
|
+
#
|
|
43
|
+
# The clock handles the common sequence: Stop + Song Position + Continue
|
|
44
|
+
# as a position change while running, avoiding unnecessary stop/start cycles.
|
|
45
|
+
#
|
|
46
|
+
# ## Performance Monitoring
|
|
47
|
+
#
|
|
48
|
+
# The time_table tracks processing time per tick in milliseconds, useful
|
|
49
|
+
# for detecting performance issues.
|
|
50
|
+
#
|
|
51
|
+
# @example Basic setup with DAW synchronization
|
|
52
|
+
# input = MIDICommunications::Input.all.first
|
|
53
|
+
# clock = InputMidiClock.new(input, logger: logger)
|
|
54
|
+
# transport = Transport.new(clock)
|
|
55
|
+
#
|
|
56
|
+
# # Start transport (blocks waiting for MIDI Start message)
|
|
57
|
+
# transport.start # Waits until DAW sends MIDI Start (0xFA)
|
|
58
|
+
#
|
|
59
|
+
# @example Dynamic input assignment
|
|
60
|
+
# clock = InputMidiClock.new # No input yet
|
|
61
|
+
# transport = Transport.new(clock)
|
|
62
|
+
# transport.start # Waits for input to be assigned
|
|
63
|
+
#
|
|
64
|
+
# # Later:
|
|
65
|
+
# clock.input = MIDICommunications::Input.all.first
|
|
66
|
+
#
|
|
67
|
+
# @example Checking performance
|
|
68
|
+
# clock.time_table # => [0 => 1543, 1 => 234, 2 => 12, ...]
|
|
69
|
+
# # Shows histogram: X ms took Y ticks
|
|
70
|
+
#
|
|
71
|
+
# @see Transport Connects clock to sequencer
|
|
72
|
+
# @see MIDICommunications::Input MIDI input ports
|
|
73
|
+
# @see TimerClock For internal timing without MIDI
|
|
74
|
+
# @see DummyClock For automatic activation (testing/batch)
|
|
7
75
|
class InputMidiClock < Clock
|
|
76
|
+
# Creates a new MIDI Clock synchronized clock.
|
|
77
|
+
#
|
|
78
|
+
# @param input [MIDICommunications::Input, nil] MIDI input port.
|
|
79
|
+
# Can be nil; clock will wait for assignment.
|
|
80
|
+
# @param logger [Logger, nil] logger for messages
|
|
81
|
+
# @param do_log [Boolean, nil] enable debug logging
|
|
8
82
|
def initialize(input = nil, logger: nil, do_log: nil)
|
|
9
83
|
do_log ||= false
|
|
10
84
|
|
|
@@ -25,25 +99,64 @@ module Musa
|
|
|
25
99
|
@midi_parser = MIDIParser.new
|
|
26
100
|
end
|
|
27
101
|
|
|
102
|
+
# Current MIDI input port.
|
|
103
|
+
#
|
|
104
|
+
# @return [MIDICommunications::Input, nil] the input port
|
|
28
105
|
attr_reader :input
|
|
106
|
+
|
|
107
|
+
# Performance timing histogram.
|
|
108
|
+
#
|
|
109
|
+
# Maps processing time in milliseconds to tick count.
|
|
110
|
+
#
|
|
111
|
+
# @return [Array<Integer>] histogram indexed by milliseconds
|
|
112
|
+
#
|
|
113
|
+
# @example
|
|
114
|
+
# time_table[5] # => 123 (123 ticks took 5ms)
|
|
29
115
|
attr_reader :time_table
|
|
30
116
|
|
|
117
|
+
# Assigns a MIDI input port.
|
|
118
|
+
#
|
|
119
|
+
# If the clock is waiting for input (sleeping), wakes it up.
|
|
120
|
+
#
|
|
121
|
+
# @param input_midi_port [MIDICommunications::Input] MIDI input port
|
|
122
|
+
# @return [MIDICommunications::Input] the assigned input
|
|
31
123
|
def input=(input_midi_port)
|
|
32
124
|
@input = input_midi_port
|
|
33
125
|
@waiting_for_input&.wakeup
|
|
34
126
|
end
|
|
35
127
|
|
|
128
|
+
# Runs the MIDI Clock processing loop.
|
|
129
|
+
#
|
|
130
|
+
# This method blocks and processes incoming MIDI messages, generating ticks
|
|
131
|
+
# in response to MIDI Clock messages. If no input is assigned, it waits
|
|
132
|
+
# until one is assigned via {#input=}.
|
|
133
|
+
#
|
|
134
|
+
# ## Message Handling
|
|
135
|
+
#
|
|
136
|
+
# - **Clock**: Yields (generates tick) if started
|
|
137
|
+
# - **Start**: Triggers on_start callbacks
|
|
138
|
+
# - **Stop**: Triggers on_stop callbacks
|
|
139
|
+
# - **Continue**: Resumes (typically after Stop)
|
|
140
|
+
# - **Song Position**: Triggers on_change_position
|
|
141
|
+
#
|
|
142
|
+
# @yield Called once per MIDI Clock message (24 ppqn)
|
|
143
|
+
# @return [void]
|
|
144
|
+
#
|
|
145
|
+
# @note This method blocks until {#terminate} is called
|
|
146
|
+
# @note Waits if no input assigned
|
|
36
147
|
def run
|
|
37
148
|
@run = true
|
|
38
149
|
|
|
39
150
|
while @run
|
|
40
151
|
if @input
|
|
152
|
+
# Read raw MIDI messages from input port
|
|
41
153
|
raw_messages = @input.gets
|
|
42
154
|
else
|
|
155
|
+
# No input assigned yet - wait for assignment
|
|
43
156
|
@logger.warn('InputMidiClock') { 'Waiting for clock input MIDI port' }
|
|
44
157
|
|
|
45
158
|
@waiting_for_input = Thread.current
|
|
46
|
-
sleep
|
|
159
|
+
sleep # Wait until input= wakes us
|
|
47
160
|
@waiting_for_input = nil
|
|
48
161
|
|
|
49
162
|
if @input
|
|
@@ -53,6 +166,7 @@ module Musa
|
|
|
53
166
|
end
|
|
54
167
|
end
|
|
55
168
|
|
|
169
|
+
# Parse raw MIDI bytes into message objects
|
|
56
170
|
messages = []
|
|
57
171
|
stop_index = nil
|
|
58
172
|
|
|
@@ -104,12 +218,20 @@ module Musa
|
|
|
104
218
|
end
|
|
105
219
|
end
|
|
106
220
|
|
|
221
|
+
# Terminates the MIDI Clock processing loop.
|
|
222
|
+
#
|
|
223
|
+
# @return [void]
|
|
107
224
|
def terminate
|
|
108
225
|
@run = false
|
|
109
226
|
end
|
|
110
227
|
|
|
111
228
|
private
|
|
112
229
|
|
|
230
|
+
# Processes MIDI Start message.
|
|
231
|
+
#
|
|
232
|
+
# Calls registered on_start callbacks and marks as started.
|
|
233
|
+
#
|
|
234
|
+
# @api private
|
|
113
235
|
def process_start
|
|
114
236
|
@logger.debug('InputMidiClock') { 'processing Start...' }
|
|
115
237
|
|
|
@@ -119,6 +241,16 @@ module Musa
|
|
|
119
241
|
@logger.debug('InputMidiClock') { 'processing Start... done' }
|
|
120
242
|
end
|
|
121
243
|
|
|
244
|
+
# Processes individual MIDI Clock protocol messages.
|
|
245
|
+
#
|
|
246
|
+
# Handles Start, Stop, Continue, Clock, and Song Position Pointer messages.
|
|
247
|
+
# For Clock messages, yields and tracks processing time.
|
|
248
|
+
#
|
|
249
|
+
# @param m [MIDIEvents::Event] parsed MIDI message
|
|
250
|
+
# @yield Called for Clock messages if started
|
|
251
|
+
# @return [void]
|
|
252
|
+
#
|
|
253
|
+
# @api private
|
|
122
254
|
def process_message(m)
|
|
123
255
|
case m.name
|
|
124
256
|
when 'Start'
|
|
@@ -2,7 +2,92 @@ require_relative 'clock'
|
|
|
2
2
|
|
|
3
3
|
module Musa
|
|
4
4
|
module Clock
|
|
5
|
+
# Internal timer-based clock for standalone operation.
|
|
6
|
+
#
|
|
7
|
+
# TimerClock uses a high-precision Timer to generate ticks at a configurable
|
|
8
|
+
# rate. Unlike DummyClock, TimerClock requires external activation and is
|
|
9
|
+
# designed for scenarios where timing control comes from outside (e.g., live
|
|
10
|
+
# coding clients, interactive systems).
|
|
11
|
+
#
|
|
12
|
+
# ## Activation Model
|
|
13
|
+
#
|
|
14
|
+
# **IMPORTANT**: TimerClock starts in a paused state. After calling
|
|
15
|
+
# `transport.start` (which blocks), you must call `clock.start()` from
|
|
16
|
+
# another thread to begin generating ticks.
|
|
17
|
+
#
|
|
18
|
+
# This activation model is appropriate for:
|
|
19
|
+
#
|
|
20
|
+
# - **Live coding**: Client controls when to start/stop
|
|
21
|
+
# - **Interactive systems**: External controller manages playback
|
|
22
|
+
# - **Testing with control**: Precise control over when ticks begin
|
|
23
|
+
#
|
|
24
|
+
# ## Configuration Methods
|
|
25
|
+
#
|
|
26
|
+
# The clock can be configured in three equivalent ways:
|
|
27
|
+
#
|
|
28
|
+
# 1. **BPM + ticks_per_beat**: Musical tempo-based (most common)
|
|
29
|
+
# 2. **Period**: Direct tick period in seconds
|
|
30
|
+
# 3. **Any combination**: Changes one parameter, others auto-calculate
|
|
31
|
+
#
|
|
32
|
+
# ## Relationship Between Parameters
|
|
33
|
+
#
|
|
34
|
+
# period = 60 / (bpm * ticks_per_beat)
|
|
35
|
+
#
|
|
36
|
+
# Example: 120 BPM, 24 ticks/beat → period = 60/(120*24) = 0.02083s
|
|
37
|
+
#
|
|
38
|
+
# ## States
|
|
39
|
+
#
|
|
40
|
+
# - **Not started**: Clock created but not running
|
|
41
|
+
# - **Started**: Clock running, generating ticks
|
|
42
|
+
# - **Paused**: Clock started but temporarily stopped
|
|
43
|
+
#
|
|
44
|
+
# @example Complete setup with external activation (live coding pattern)
|
|
45
|
+
# clock = TimerClock.new(bpm: 120, ticks_per_beat: 24)
|
|
46
|
+
# transport = Transport.new(clock, beats_per_bar: 4)
|
|
47
|
+
#
|
|
48
|
+
# # Schedule events
|
|
49
|
+
# transport.sequencer.at(4) { transport.stop }
|
|
50
|
+
#
|
|
51
|
+
# # Start transport in background (it blocks)
|
|
52
|
+
# thread = Thread.new { transport.start }
|
|
53
|
+
# sleep 0.1 # Let transport initialize
|
|
54
|
+
#
|
|
55
|
+
# # Activate clock from external control (e.g., live coding client)
|
|
56
|
+
# clock.start # Now ticks begin generating
|
|
57
|
+
#
|
|
58
|
+
# thread.join # Wait for completion
|
|
59
|
+
#
|
|
60
|
+
# @example With timing correction
|
|
61
|
+
# # Correction compensates for system-specific timing offsets
|
|
62
|
+
# clock = TimerClock.new(bpm: 140, correction: -0.001)
|
|
63
|
+
#
|
|
64
|
+
# @example Dynamic tempo changes
|
|
65
|
+
# clock = TimerClock.new(bpm: 120)
|
|
66
|
+
# # ... later, while running:
|
|
67
|
+
# clock.bpm = 140 # Tempo change takes effect immediately
|
|
68
|
+
#
|
|
69
|
+
# @see Timer Internal precision timer
|
|
70
|
+
# @see Transport Connects clock to sequencer
|
|
71
|
+
# @see DummyClock For automatic activation (testing/batch)
|
|
5
72
|
class TimerClock < Clock
|
|
73
|
+
# Creates a new timer-based clock.
|
|
74
|
+
#
|
|
75
|
+
# At least one timing parameter must be provided (period, bpm, or ticks_per_beat).
|
|
76
|
+
# Missing parameters use defaults: bpm=120, ticks_per_beat=24.
|
|
77
|
+
#
|
|
78
|
+
# @param period [Numeric, nil] tick period in seconds (direct specification)
|
|
79
|
+
# @param ticks_per_beat [Numeric, nil] number of ticks per beat (default: 24)
|
|
80
|
+
# @param bpm [Numeric, nil] beats per minute (default: 120)
|
|
81
|
+
# @param correction [Numeric, nil] timing correction in seconds (for calibration)
|
|
82
|
+
# @param delayed_ticks_error [Numeric, nil] threshold for error-level logging
|
|
83
|
+
# @param logger [Logger, nil] logger for warnings/errors
|
|
84
|
+
# @param do_log [Boolean, nil] enable timing logs
|
|
85
|
+
#
|
|
86
|
+
# @example
|
|
87
|
+
# # All equivalent for 120 BPM, 24 ticks/beat:
|
|
88
|
+
# TimerClock.new(bpm: 120, ticks_per_beat: 24)
|
|
89
|
+
# TimerClock.new(bpm: 120) # ticks_per_beat defaults to 24
|
|
90
|
+
# TimerClock.new(period: 0.02083, ticks_per_beat: 24)
|
|
6
91
|
def initialize(period = nil, ticks_per_beat: nil, bpm: nil, correction: nil, delayed_ticks_error: nil, logger: nil, do_log: nil)
|
|
7
92
|
do_log ||= false
|
|
8
93
|
|
|
@@ -10,10 +95,12 @@ module Musa
|
|
|
10
95
|
|
|
11
96
|
@correction = correction
|
|
12
97
|
|
|
98
|
+
# Set parameters in any combination
|
|
13
99
|
self.period = period if period
|
|
14
100
|
self.ticks_per_beat = ticks_per_beat if ticks_per_beat
|
|
15
101
|
self.bpm = bpm if bpm
|
|
16
102
|
|
|
103
|
+
# Apply defaults
|
|
17
104
|
self.bpm ||= 120
|
|
18
105
|
self.ticks_per_beat ||= 24
|
|
19
106
|
|
|
@@ -25,34 +112,87 @@ module Musa
|
|
|
25
112
|
@do_log = do_log
|
|
26
113
|
end
|
|
27
114
|
|
|
28
|
-
|
|
115
|
+
# Current tick period in seconds.
|
|
116
|
+
#
|
|
117
|
+
# @return [Rational] seconds between ticks
|
|
118
|
+
attr_reader :period
|
|
29
119
|
|
|
120
|
+
# Number of ticks per beat.
|
|
121
|
+
#
|
|
122
|
+
# @return [Rational] ticks per beat (typically 24 or 96)
|
|
123
|
+
attr_reader :ticks_per_beat
|
|
124
|
+
|
|
125
|
+
# Current tempo in beats per minute.
|
|
126
|
+
#
|
|
127
|
+
# @return [Rational] BPM
|
|
128
|
+
attr_reader :bpm
|
|
129
|
+
|
|
130
|
+
# Sets the tick period in seconds and recalculates BPM.
|
|
131
|
+
#
|
|
132
|
+
# @param period_in_seconds [Numeric] new period in seconds
|
|
133
|
+
# @return [Rational] the rationalized period
|
|
134
|
+
#
|
|
135
|
+
# @note If clock is running, change takes effect immediately via @timer.period
|
|
30
136
|
def period=(period_in_seconds)
|
|
31
137
|
@period = period_in_seconds.rationalize
|
|
32
138
|
@bpm = 60r / (@period * @ticks_per_beat) if @period && @ticks_per_beat
|
|
33
139
|
@timer.period = @period if @timer
|
|
34
140
|
end
|
|
35
141
|
|
|
142
|
+
# Sets ticks per beat and recalculates period.
|
|
143
|
+
#
|
|
144
|
+
# @param ticks [Numeric] new ticks per beat
|
|
145
|
+
# @return [Rational] the rationalized ticks_per_beat
|
|
146
|
+
#
|
|
147
|
+
# @note Common values: 24 (standard), 96 (high resolution)
|
|
148
|
+
# @note If clock is running, change takes effect immediately
|
|
36
149
|
def ticks_per_beat=(ticks)
|
|
37
150
|
@ticks_per_beat = ticks.rationalize
|
|
38
151
|
@period = 60r / (@bpm * @ticks_per_beat) if @bpm && @ticks_per_beat
|
|
39
152
|
@timer.period = @period if @timer && @period
|
|
40
153
|
end
|
|
41
154
|
|
|
155
|
+
# Sets tempo in BPM and recalculates period.
|
|
156
|
+
#
|
|
157
|
+
# @param bpm [Numeric] new tempo in beats per minute
|
|
158
|
+
# @return [Rational] the rationalized BPM
|
|
159
|
+
#
|
|
160
|
+
# @note If clock is running, tempo change takes effect immediately
|
|
161
|
+
# @example Tempo automation
|
|
162
|
+
# clock.bpm = 120
|
|
163
|
+
# sleep 10
|
|
164
|
+
# clock.bpm = 140 # Speed up!
|
|
42
165
|
def bpm=(bpm)
|
|
43
166
|
@bpm = bpm.rationalize
|
|
44
167
|
@period = 60r / (@bpm * @ticks_per_beat) if @bpm && @ticks_per_beat
|
|
45
168
|
@timer.period = @period if @timer && @period
|
|
46
169
|
end
|
|
47
170
|
|
|
171
|
+
# Checks if the clock has been started.
|
|
172
|
+
#
|
|
173
|
+
# @return [Boolean] true if started (even if currently paused)
|
|
48
174
|
def started?
|
|
49
175
|
@started
|
|
50
176
|
end
|
|
51
177
|
|
|
178
|
+
# Checks if the clock is paused.
|
|
179
|
+
#
|
|
180
|
+
# @return [Boolean] true if paused
|
|
52
181
|
def paused?
|
|
53
182
|
@paused
|
|
54
183
|
end
|
|
55
184
|
|
|
185
|
+
# Starts the clock's run loop.
|
|
186
|
+
#
|
|
187
|
+
# This method blocks and runs the timer loop, yielding for each tick.
|
|
188
|
+
# The clock starts in a paused state and must be explicitly started
|
|
189
|
+
# via {#start}.
|
|
190
|
+
#
|
|
191
|
+
# @yield Called once per tick
|
|
192
|
+
# @return [void]
|
|
193
|
+
#
|
|
194
|
+
# @note This method blocks until {#terminate} is called
|
|
195
|
+
# @note Clock begins paused; call {#start} to begin ticking
|
|
56
196
|
def run
|
|
57
197
|
@run = true
|
|
58
198
|
|
|
@@ -70,6 +210,15 @@ module Musa
|
|
|
70
210
|
end
|
|
71
211
|
end
|
|
72
212
|
|
|
213
|
+
# Starts the clock from paused state.
|
|
214
|
+
#
|
|
215
|
+
# Triggers @on_start callbacks and begins generating ticks.
|
|
216
|
+
# Has no effect if already started.
|
|
217
|
+
#
|
|
218
|
+
# @return [void]
|
|
219
|
+
#
|
|
220
|
+
# @note Must call {#run} first to start the run loop
|
|
221
|
+
# @note Calls registered on_start callbacks
|
|
73
222
|
def start
|
|
74
223
|
unless @started
|
|
75
224
|
@on_start.each(&:call)
|
|
@@ -79,6 +228,15 @@ module Musa
|
|
|
79
228
|
end
|
|
80
229
|
end
|
|
81
230
|
|
|
231
|
+
# Stops the clock and resets to initial state.
|
|
232
|
+
#
|
|
233
|
+
# Triggers @on_stop callbacks and marks clock as not started.
|
|
234
|
+
# Has no effect if not currently started.
|
|
235
|
+
#
|
|
236
|
+
# @return [void]
|
|
237
|
+
#
|
|
238
|
+
# @note Calls registered on_stop callbacks
|
|
239
|
+
# @note Different from {#pause}: stop resets to initial state
|
|
82
240
|
def stop
|
|
83
241
|
if @started
|
|
84
242
|
@timer.stop
|
|
@@ -88,6 +246,15 @@ module Musa
|
|
|
88
246
|
end
|
|
89
247
|
end
|
|
90
248
|
|
|
249
|
+
# Pauses the clock without stopping it.
|
|
250
|
+
#
|
|
251
|
+
# Ticks stop but clock remains in started state. Use {#continue}
|
|
252
|
+
# to resume.
|
|
253
|
+
#
|
|
254
|
+
# @return [void]
|
|
255
|
+
#
|
|
256
|
+
# @note No effect if not started or already paused
|
|
257
|
+
# @see #continue
|
|
91
258
|
def pause
|
|
92
259
|
if @started && !@paused
|
|
93
260
|
@timer.stop
|
|
@@ -95,6 +262,14 @@ module Musa
|
|
|
95
262
|
end
|
|
96
263
|
end
|
|
97
264
|
|
|
265
|
+
# Resumes the clock from paused state.
|
|
266
|
+
#
|
|
267
|
+
# Continues generating ticks. Has no effect if not paused.
|
|
268
|
+
#
|
|
269
|
+
# @return [void]
|
|
270
|
+
#
|
|
271
|
+
# @note No effect if not started or not paused
|
|
272
|
+
# @see #pause
|
|
98
273
|
def continue
|
|
99
274
|
if @started && @paused
|
|
100
275
|
@paused = false
|
|
@@ -102,6 +277,13 @@ module Musa
|
|
|
102
277
|
end
|
|
103
278
|
end
|
|
104
279
|
|
|
280
|
+
# Terminates the clock's run loop.
|
|
281
|
+
#
|
|
282
|
+
# Causes {#run} to exit. This is the clean shutdown mechanism.
|
|
283
|
+
#
|
|
284
|
+
# @return [void]
|
|
285
|
+
#
|
|
286
|
+
# @note After calling this, {#run} will exit
|
|
105
287
|
def terminate
|
|
106
288
|
@run = false
|
|
107
289
|
end
|
|
@@ -1,8 +1,54 @@
|
|
|
1
1
|
module Musa
|
|
2
2
|
module Clock
|
|
3
|
+
# High-precision timer for generating regular ticks.
|
|
4
|
+
#
|
|
5
|
+
# Timer uses Ruby's monotonic clock (Process::CLOCK_MONOTONIC) for drift-free
|
|
6
|
+
# timing. It compensates for processing delays and reports when the system
|
|
7
|
+
# cannot keep up with the requested tick rate.
|
|
8
|
+
#
|
|
9
|
+
# ## Precision Features
|
|
10
|
+
#
|
|
11
|
+
# - **Monotonic clock**: Immune to system time changes
|
|
12
|
+
# - **Drift compensation**: Calculates exact next tick time
|
|
13
|
+
# - **Overload detection**: Reports delayed ticks when processing is slow
|
|
14
|
+
# - **Correction parameter**: Fine-tune timing for specific systems
|
|
15
|
+
#
|
|
16
|
+
# ## Usage Pattern
|
|
17
|
+
#
|
|
18
|
+
# Timer is typically used internally by TimerClock, not directly. It runs
|
|
19
|
+
# in a loop, yielding for each tick and managing precise sleep intervals.
|
|
20
|
+
#
|
|
21
|
+
# ## Timing Algorithm
|
|
22
|
+
#
|
|
23
|
+
# 1. Record next expected tick time
|
|
24
|
+
# 2. Yield (caller processes tick)
|
|
25
|
+
# 3. Add period to next_moment
|
|
26
|
+
# 4. Calculate sleep time = (next_moment + correction) - current_time
|
|
27
|
+
# 5. Sleep if positive, warn if negative (delayed)
|
|
28
|
+
#
|
|
29
|
+
# @example Internal use by TimerClock
|
|
30
|
+
# timer = Timer.new(0.02083, logger: logger) # ~48 ticks/second
|
|
31
|
+
# timer.run { sequencer.tick }
|
|
32
|
+
#
|
|
33
|
+
# @see TimerClock Uses Timer internally
|
|
3
34
|
class Timer
|
|
35
|
+
# The period between ticks in seconds.
|
|
36
|
+
#
|
|
37
|
+
# @return [Rational] tick period in seconds
|
|
4
38
|
attr_accessor :period
|
|
5
39
|
|
|
40
|
+
# Creates a new precision timer.
|
|
41
|
+
#
|
|
42
|
+
# @param tick_period_in_seconds [Numeric] time between ticks in seconds
|
|
43
|
+
# @param correction [Numeric, nil] timing correction offset in seconds (for calibration)
|
|
44
|
+
# @param stop [Boolean, nil] initial stopped state (true = paused)
|
|
45
|
+
# @param delayed_ticks_error [Numeric, nil] threshold for error-level logging (default: 1.0 tick)
|
|
46
|
+
# @param logger [Logger, nil] logger for timing warnings/errors
|
|
47
|
+
# @param do_log [Boolean, nil] enable logging
|
|
48
|
+
#
|
|
49
|
+
# @example 120 BPM, 24 ticks per beat
|
|
50
|
+
# period = 60.0 / (120 * 24) # 0.02083 seconds
|
|
51
|
+
# timer = Timer.new(period, logger: logger)
|
|
6
52
|
def initialize(tick_period_in_seconds, correction: nil, stop: nil, delayed_ticks_error: nil, logger: nil, do_log: nil)
|
|
7
53
|
@period = tick_period_in_seconds.rationalize
|
|
8
54
|
@correction = (correction || 0r).rationalize
|
|
@@ -13,6 +59,21 @@ module Musa
|
|
|
13
59
|
@do_log = do_log
|
|
14
60
|
end
|
|
15
61
|
|
|
62
|
+
# Runs the timer loop, yielding for each tick.
|
|
63
|
+
#
|
|
64
|
+
# This method blocks and runs indefinitely until stopped. For each tick:
|
|
65
|
+
# 1. Yields to caller if not stopped
|
|
66
|
+
# 2. Calculates next tick time
|
|
67
|
+
# 3. Sleeps precisely until next tick
|
|
68
|
+
# 4. Logs warnings if timing cannot be maintained
|
|
69
|
+
#
|
|
70
|
+
# When stopped (@stop = true), the thread sleeps until {#continue} is called.
|
|
71
|
+
#
|
|
72
|
+
# @yield Called once per tick for processing
|
|
73
|
+
# @return [void]
|
|
74
|
+
#
|
|
75
|
+
# @note This method blocks the current thread
|
|
76
|
+
# @note Uses monotonic clock for drift-free timing
|
|
16
77
|
def run
|
|
17
78
|
@thread = Thread.current
|
|
18
79
|
|
|
@@ -20,11 +81,14 @@ module Musa
|
|
|
20
81
|
|
|
21
82
|
loop do
|
|
22
83
|
unless @stop
|
|
84
|
+
# Process the tick
|
|
23
85
|
yield
|
|
24
86
|
|
|
87
|
+
# Calculate next tick moment (compensates for processing time)
|
|
25
88
|
@next_moment += @period
|
|
26
89
|
to_sleep = (@next_moment + @correction) - Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
27
90
|
|
|
91
|
+
# Log timing issues if enabled
|
|
28
92
|
if @do_log && to_sleep.negative? & @logger
|
|
29
93
|
tick_errors = -to_sleep / @period
|
|
30
94
|
if tick_errors >= @delayed_ticks_error
|
|
@@ -34,17 +98,36 @@ module Musa
|
|
|
34
98
|
end
|
|
35
99
|
end
|
|
36
100
|
|
|
101
|
+
# Sleep precisely until next tick (if not already late)
|
|
37
102
|
sleep to_sleep if to_sleep > 0.0
|
|
38
103
|
end
|
|
39
104
|
|
|
105
|
+
# When stopped, sleep thread until continue is called
|
|
40
106
|
sleep if @stop
|
|
41
107
|
end
|
|
42
108
|
end
|
|
43
109
|
|
|
110
|
+
# Pauses the timer without terminating the loop.
|
|
111
|
+
#
|
|
112
|
+
# The timer thread sleeps until {#continue} is called. Ticks are not
|
|
113
|
+
# generated while stopped.
|
|
114
|
+
#
|
|
115
|
+
# @return [void]
|
|
116
|
+
#
|
|
117
|
+
# @see #continue
|
|
44
118
|
def stop
|
|
45
119
|
@stop = true
|
|
46
120
|
end
|
|
47
121
|
|
|
122
|
+
# Resumes the timer after being stopped.
|
|
123
|
+
#
|
|
124
|
+
# Resets the next tick moment to avoid a burst of catchup ticks,
|
|
125
|
+
# then wakes the timer thread.
|
|
126
|
+
#
|
|
127
|
+
# @return [void]
|
|
128
|
+
#
|
|
129
|
+
# @note Resets timing baseline to prevent tick accumulation
|
|
130
|
+
# @see #stop
|
|
48
131
|
def continue
|
|
49
132
|
@stop = false
|
|
50
133
|
@next_moment = Process.clock_gettime(Process::CLOCK_MONOTONIC)
|