midi-communications-windows 0.0.3

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.
Files changed (57) hide show
  1. checksums.yaml +7 -0
  2. data/.gitignore +8 -0
  3. data/.version +6 -0
  4. data/.yardoc/checksums +8 -0
  5. data/.yardoc/complete +0 -0
  6. data/.yardoc/object_types +0 -0
  7. data/.yardoc/objects/root.dat +0 -0
  8. data/.yardoc/proxy_types +0 -0
  9. data/.yardopts +8 -0
  10. data/Gemfile +3 -0
  11. data/LICENSE +165 -0
  12. data/README.md +176 -0
  13. data/Rakefile +13 -0
  14. data/doc/MIDICommunicationsWindows/API/MIDIHdr.html +144 -0
  15. data/doc/MIDICommunicationsWindows/API/MIDIInCaps.html +142 -0
  16. data/doc/MIDICommunicationsWindows/API/MIDIOutCaps.html +140 -0
  17. data/doc/MIDICommunicationsWindows/API/MSG.html +143 -0
  18. data/doc/MIDICommunicationsWindows/API.html +981 -0
  19. data/doc/MIDICommunicationsWindows/Device/ClassMethods.html +365 -0
  20. data/doc/MIDICommunicationsWindows/Device/InstanceMethods.html +1096 -0
  21. data/doc/MIDICommunicationsWindows/Device.html +988 -0
  22. data/doc/MIDICommunicationsWindows/Error.html +438 -0
  23. data/doc/MIDICommunicationsWindows/Input.html +1554 -0
  24. data/doc/MIDICommunicationsWindows/Message.html +700 -0
  25. data/doc/MIDICommunicationsWindows/Output.html +1697 -0
  26. data/doc/MIDICommunicationsWindows/TypeConversion.html +369 -0
  27. data/doc/MIDICommunicationsWindows.html +210 -0
  28. data/doc/_index.html +262 -0
  29. data/doc/class_list.html +54 -0
  30. data/doc/css/common.css +1 -0
  31. data/doc/css/full_list.css +206 -0
  32. data/doc/css/style.css +1089 -0
  33. data/doc/file.README.html +240 -0
  34. data/doc/file.testing-on-windows.html +253 -0
  35. data/doc/file_list.html +64 -0
  36. data/doc/frames.html +22 -0
  37. data/doc/index.html +240 -0
  38. data/doc/js/app.js +801 -0
  39. data/doc/js/full_list.js +334 -0
  40. data/doc/js/jquery.js +4 -0
  41. data/doc/method_list.html +382 -0
  42. data/doc/top-level-namespace.html +112 -0
  43. data/docs/testing-on-windows.md +222 -0
  44. data/examples/input.rb +27 -0
  45. data/examples/list_ports.rb +17 -0
  46. data/examples/output.rb +22 -0
  47. data/examples/sysex_output.rb +15 -0
  48. data/lib/midi-communications-windows/api.rb +340 -0
  49. data/lib/midi-communications-windows/device.rb +310 -0
  50. data/lib/midi-communications-windows/input.rb +486 -0
  51. data/lib/midi-communications-windows/message.rb +133 -0
  52. data/lib/midi-communications-windows/output.rb +206 -0
  53. data/lib/midi-communications-windows/type_conversion.rb +20 -0
  54. data/lib/midi-communications-windows/version.rb +4 -0
  55. data/lib/midi-communications-windows.rb +76 -0
  56. data/midi-communications-windows.gemspec +32 -0
  57. metadata +220 -0
@@ -0,0 +1,486 @@
1
+ module MIDICommunicationsWindows
2
+ # A MIDI input port: somewhere messages arrive from.
3
+ #
4
+ # @example Read from the first input
5
+ # input = MIDICommunicationsWindows::Input.first
6
+ # input.open
7
+ # input.gets
8
+ # # => [{ data: [144, 60, 100], timestamp: 1789123456.789 }]
9
+ #
10
+ # ## `gets` blocks, and that is part of the contract
11
+ #
12
+ # {#gets} waits until at least one message has arrived and then returns
13
+ # everything that accumulated. It does not return an empty array.
14
+ #
15
+ # This is not an incidental property. `Musa::Clock::InputMidiClock` reads its
16
+ # MIDI Clock in a loop with no delay of its own, relying on `gets` to be where
17
+ # the thread waits. An implementation that returned immediately would turn
18
+ # that loop into a spin on a full core, and would do it silently — the notes
19
+ # would still play.
20
+ #
21
+ # @see Output for sending
22
+ #
23
+ # @api public
24
+ class Input
25
+ extend Device::ClassMethods
26
+ include Device::InstanceMethods
27
+
28
+ # @return [Symbol] `:input`
29
+ def self.direction
30
+ :input
31
+ end
32
+
33
+ # @param id [Integer] the port's WinMM index among inputs
34
+ # @param name [String] the port's name as reported by WinMM
35
+ # @api private
36
+ def initialize(id, name)
37
+ super
38
+
39
+ @queue = Queue.new
40
+ @sysex = []
41
+ end
42
+
43
+ # Reads the messages that have arrived.
44
+ #
45
+ # Blocks until there is at least one. See the note on the class.
46
+ #
47
+ # @return [Array<Hash>] each with `:data`, an array of numeric bytes, and
48
+ # `:timestamp`, a Float of seconds
49
+ #
50
+ # @example
51
+ # input.gets
52
+ # # => [{ data: [248], timestamp: 1789123456.789 },
53
+ # # { data: [144, 60, 100], timestamp: 1789123456.812 }]
54
+ def gets
55
+ # Queue#pop is where the thread waits, and it is the whole of the waiting
56
+ # mechanism on purpose. The obvious alternative — test whether the queue
57
+ # is empty, then sleep, and have the producer Thread#run the sleeper — has
58
+ # a window between the test and the sleep in which a message can arrive
59
+ # and its wake-up be delivered to a thread that is not sleeping yet. The
60
+ # reader then sleeps forever, and the thread it happens on is the one
61
+ # carrying the MIDI clock. Queue does the same job with no such window.
62
+ messages = [@queue.pop]
63
+ messages << @queue.pop until @queue.empty?
64
+
65
+ messages
66
+ end
67
+ alias read gets
68
+
69
+ # Reads the messages that have arrived, with their data as hex strings.
70
+ #
71
+ # @return [Array<Hash>] as {#gets}, but `:data` is a String
72
+ #
73
+ # @example
74
+ # input.gets_s
75
+ # # => [{ data: 'F8', timestamp: 1789123456.789 }]
76
+ def gets_s
77
+ gets.each do |message|
78
+ message[:data] = TypeConversion.numeric_bytes_to_hex_string(message[:data])
79
+ end
80
+ end
81
+ alias gets_bytestr gets_s
82
+
83
+ private
84
+
85
+ # Accepts one message from whichever mechanism is delivering them, waking
86
+ # whatever thread is waiting in {#gets}.
87
+ #
88
+ # Both candidate delivery mechanisms end here, which is the point: they
89
+ # differ only in how bytes reach this method.
90
+ #
91
+ # @param bytes [Array<Integer>] one complete message
92
+ # @param timestamp [Float] seconds, as `Time.now.to_f`
93
+ # @return [void]
94
+ # @api private
95
+ def enqueue(bytes, timestamp)
96
+ @queue << { data: bytes, timestamp: timestamp }
97
+ end
98
+
99
+ # Accepts a fragment of System Exclusive, emitting the message once it ends.
100
+ #
101
+ # WinMM fills a buffer at a time, so a System Exclusive message longer than
102
+ # the buffer arrives in pieces. They are collected until the 0xF7 that ends
103
+ # the message, and only then handed on: a caller asking for messages should
104
+ # get messages, not the arbitrary lengths a buffer size happened to impose.
105
+ #
106
+ # Measured: a 200-byte message through 128-byte buffers arrives as 128 then
107
+ # 72, contiguous and exact. WinMM does not require a buffer large enough for
108
+ # the whole message.
109
+ #
110
+ # Empty fragments must never reach here. Closing a port makes `midiInReset`
111
+ # hand back every buffer that was queued and unused, each reported with
112
+ # `dwBytesRecorded` of zero; passing those on would emit a stray empty
113
+ # message, or worse, terminate a partial one.
114
+ #
115
+ # @param bytes [Array<Integer>] one buffer's worth
116
+ # @param timestamp [Float] seconds
117
+ # @return [void]
118
+ # @api private
119
+ def enqueue_sysex(bytes, timestamp)
120
+ @sysex.concat(bytes)
121
+
122
+ return unless @sysex.last == 0xF7
123
+
124
+ enqueue(@sysex.dup, timestamp)
125
+ @sysex.clear
126
+ end
127
+
128
+ # Opens the port and starts delivery.
129
+ #
130
+ # @return [void]
131
+ # @raise [Error]
132
+ # @api private
133
+ def connect
134
+ start_receiving
135
+ end
136
+
137
+ # Stops delivery and closes the port.
138
+ #
139
+ # @return [void]
140
+ # @raise [Error]
141
+ # @api private
142
+ def disconnect
143
+ stop_receiving
144
+
145
+ # A System Exclusive message interrupted by the close is not going to be
146
+ # completed by anything, and leaving its bytes in the buffer would prepend
147
+ # them to the first message of the next session.
148
+ @sysex.clear
149
+ end
150
+
151
+ # ------------------------------------------------------------------
152
+ # Delivery: CALLBACK_THREAD
153
+ # ------------------------------------------------------------------
154
+ #
155
+ # WinMM offers two ways to hand a client its input, and both were measured
156
+ # working against a system loopback on Windows 11 25H2. This is the one that
157
+ # was chosen, and why.
158
+ #
159
+ # Under `CALLBACK_FUNCTION` the driver calls an FFI function pointer from a
160
+ # thread of its own. It is what RtMidi, PortMidi and midi-winmm all do. But
161
+ # a callback arriving on a thread Ruby does not own runs on a thread FFI
162
+ # supplies while the driver's thread waits inside the callback, and if
163
+ # anything else in the process is holding the GVL inside a winmm call, the
164
+ # two deadlock. What that produces is not an exception: it is duplicate
165
+ # deliveries, a second or two late, with the send reporting an error. See
166
+ # the note in {API} for the measurement and its controls. Declaring every
167
+ # winmm call `blocking: true` avoids it — but that is a rule someone has to
168
+ # keep obeying, and the failure it prevents is silent.
169
+ #
170
+ # Under `CALLBACK_THREAD` WinMM posts to the message queue of a thread this
171
+ # library owns. The driver's thread never enters Ruby, so a Ruby callback
172
+ # can never be waiting on a GVL that a sending thread holds: the failure
173
+ # above stops being something to guard against and becomes something that
174
+ # cannot happen. The reader thread owns the port and may call winmm freely,
175
+ # which is what makes {#requeue} ordinary code rather than the thing
176
+ # Microsoft's documentation prohibits inside `midiInProc`.
177
+ #
178
+ # The price is `dwParam2`, the driver's own millisecond count, which is not
179
+ # delivered this way. Nothing consumes it: `InputMidiClock` reads only
180
+ # `message[:data]`, `MIDIRecorder` stamps with the sequencer's position, and
181
+ # the macOS layer already takes `Time.now.to_f` from inside its own callback
182
+ # — arrival at Ruby, not a driver stamp. So the timestamp is taken here the
183
+ # same way, and both platforms mean the same thing by it.
184
+
185
+ # How many buffers to keep queued for System Exclusive, and how large.
186
+ #
187
+ # WinMM fills whatever it is given and splits a message across buffers, so
188
+ # the size is not a limit on message length; it only decides how often the
189
+ # reader is woken. Keeping several queued means the next fragment has
190
+ # somewhere to go while this one is being copied out.
191
+ BUFFER_COUNT = 4
192
+ BUFFER_SIZE = 1024
193
+
194
+ # How long to wait for `midiInReset` to hand the buffers back, in seconds.
195
+ RESET_TIMEOUT = 1.0
196
+
197
+ # Opens the port and starts delivering, from a thread of our own.
198
+ #
199
+ # The port is opened *inside* the reader thread because `midiInOpen` is
200
+ # given that thread's id and posts to that thread's queue. The caller waits
201
+ # here until the thread reports that it opened, so that a failure to open
202
+ # reaches whoever called {#open} instead of disappearing into a thread.
203
+ #
204
+ # @return [void]
205
+ # @raise [Error]
206
+ # @api private
207
+ def start_receiving
208
+ @returned = Queue.new
209
+ @started = Queue.new
210
+
211
+ @reader = Thread.new { receive_loop }
212
+
213
+ failure = @started.pop
214
+ raise failure if failure
215
+ end
216
+
217
+ # Stops delivery and closes the port.
218
+ #
219
+ # The order matters, and one step is easy to leave out. `midiInReset` hands
220
+ # every queued buffer back **as messages in the reader thread's queue**, so
221
+ # the thread has to keep pumping until they arrive. Posting `WM_QUIT` first
222
+ # loses them, and then `midiInUnprepareHeader` is called on buffers the
223
+ # driver has not released.
224
+ #
225
+ # A thread blocked in a `blocking: true` call cannot be interrupted by
226
+ # `Thread#raise`, so `WM_QUIT` is the only way to end the loop.
227
+ #
228
+ # @return [void]
229
+ # @raise [Error]
230
+ # @api private
231
+ def stop_receiving
232
+ API.check!(API.midiInStop(@handle), :midiInStop, :input)
233
+ API.check!(API.midiInReset(@handle), :midiInReset, :input)
234
+
235
+ await_returned_buffers
236
+
237
+ API.PostThreadMessageW(@thread_id, API::WM_QUIT, 0, 0)
238
+ @reader.join
239
+ @reader = nil
240
+
241
+ release_buffers
242
+
243
+ API.check!(API.midiInClose(@handle), :midiInClose, :input)
244
+ @handle = nil
245
+ end
246
+
247
+ # The reader thread: owns the port, and is the only thread that touches it
248
+ # while it is open.
249
+ #
250
+ # @return [void]
251
+ # @api private
252
+ def receive_loop
253
+ message = API::MSG.new
254
+
255
+ # A thread has no message queue until it asks for one, and WinMM cannot
256
+ # post to a queue that does not exist: without this, `midiInOpen` is given
257
+ # a thread id that rejects messages and the first notifications are lost.
258
+ API.PeekMessageW(message, nil, 0, 0, API::PM_NOREMOVE)
259
+
260
+ begin
261
+ open_port
262
+ prepare_buffers
263
+ API.check!(API.midiInStart(@handle), :midiInStart, :input)
264
+ rescue StandardError => e
265
+ # Whatever was opened before the failure has to be given back here:
266
+ # nobody else can, because {#open} will not mark the port enabled and
267
+ # so {#close} will never run.
268
+ abandon_port
269
+ @started << e
270
+ return
271
+ end
272
+
273
+ @started << nil
274
+
275
+ pump(message)
276
+ end
277
+
278
+ # Releases whatever an interrupted {#receive_loop} had managed to take.
279
+ #
280
+ # @return [void]
281
+ # @api private
282
+ def abandon_port
283
+ return if @handle.nil?
284
+
285
+ API.midiInReset(@handle)
286
+ @buffers&.each { |buffer| API.midiInUnprepareHeader(@handle, buffer[:header], API::MIDIHdr.size) }
287
+ API.midiInClose(@handle)
288
+
289
+ @buffers = nil
290
+ @handle = nil
291
+ end
292
+
293
+ # @return [void]
294
+ # @raise [Error]
295
+ # @api private
296
+ def open_port
297
+ @thread_id = API.GetCurrentThreadId
298
+
299
+ handle_pointer = FFI::MemoryPointer.new(:uintptr_t)
300
+
301
+ API.check!(API.midiInOpen(handle_pointer, @id, @thread_id, 0, API::CALLBACK_THREAD),
302
+ :midiInOpen, :input)
303
+
304
+ @handle = handle_pointer.read(:uintptr_t)
305
+ end
306
+
307
+ # Hands WinMM the buffers it will fill with System Exclusive.
308
+ #
309
+ # Both the header and the memory it points at are kept, because letting
310
+ # either be collected while the driver still holds a pointer to it is not a
311
+ # Ruby error but a corrupted process.
312
+ #
313
+ # @return [void]
314
+ # @raise [Error]
315
+ # @api private
316
+ def prepare_buffers
317
+ @buffers = Array.new(BUFFER_COUNT) do
318
+ memory = FFI::MemoryPointer.new(:uint8, BUFFER_SIZE)
319
+
320
+ header = API::MIDIHdr.new
321
+ header[:lpData] = memory
322
+ header[:dwBufferLength] = BUFFER_SIZE
323
+
324
+ API.check!(API.midiInPrepareHeader(@handle, header, API::MIDIHdr.size),
325
+ :midiInPrepareHeader, :input)
326
+ API.check!(API.midiInAddBuffer(@handle, header, API::MIDIHdr.size),
327
+ :midiInAddBuffer, :input)
328
+
329
+ { header: header, memory: memory }
330
+ end
331
+ end
332
+
333
+ # Reads the thread's queue until `WM_QUIT`.
334
+ #
335
+ # `GetMessageW` returns above zero for a message, zero for `WM_QUIT` and -1
336
+ # for an error, so the loop ends on anything that is not positive.
337
+ #
338
+ # Only the two MIDI notifications are acted on. The queue carries other
339
+ # traffic — see {API::WM_USER} — and treating everything that arrives as
340
+ # MIDI would read those as messages.
341
+ #
342
+ # @param message [API::MSG] reused for every read
343
+ # @return [void]
344
+ # @api private
345
+ def pump(message)
346
+ loop do
347
+ break unless API.GetMessageW(message, nil, 0, 0).positive?
348
+
349
+ begin
350
+ case message[:message]
351
+ when API::MM_MIM_DATA then deliver_short(message[:lParam])
352
+ when API::MM_MIM_LONGDATA then deliver_long(message[:lParam])
353
+ when API::MM_MIM_LONGERROR then discard_long(message[:lParam])
354
+ when API::MM_MIM_ERROR then report_invalid(message[:lParam])
355
+ end
356
+ rescue StandardError => e
357
+ # One bad message must not end the loop. A reader thread that dies
358
+ # here takes every later message with it, and leaves gets blocked
359
+ # forever on a queue nothing will ever fill again -- which looks like
360
+ # a hung program, not like an error.
361
+ warn "[midi-communications-windows] #{@name}: #{e.class}: #{e.message}"
362
+ end
363
+ end
364
+ end
365
+
366
+ # A short message: `lParam` is the message itself, packed into a word.
367
+ #
368
+ # @param word [Integer]
369
+ # @return [void]
370
+ # @api private
371
+ def deliver_short(word)
372
+ enqueue(Message.unpack(word & 0xFFFF_FFFF), Time.now.to_f)
373
+ rescue ArgumentError => e
374
+ # WinMM should never deliver a word this cannot size. If it does, the
375
+ # port keeps working: one message nobody can read is a smaller loss than
376
+ # a reader thread that dies and takes every later message with it.
377
+ warn "[midi-communications-windows] ignoring an unreadable message on #{@name}: #{e.message}"
378
+ end
379
+
380
+ # A System Exclusive fragment: `lParam` points at the buffer WinMM filled.
381
+ #
382
+ # @param header_pointer [Integer] address of a `MIDIHDR`
383
+ # @return [void]
384
+ # @api private
385
+ def deliver_long(header_pointer)
386
+ header = API::MIDIHdr.new(FFI::Pointer.new(header_pointer))
387
+ recorded = header[:dwBytesRecorded]
388
+
389
+ # An empty one is a buffer coming back from midiInReset, not a fragment.
390
+ # The two are indistinguishable except by this field, and passing one on
391
+ # would either emit a message of nothing or truncate a partial one.
392
+ if recorded.zero?
393
+ @returned << header_pointer
394
+ return
395
+ end
396
+
397
+ enqueue_sysex(header[:lpData].read_array_of_uint8(recorded), Time.now.to_f)
398
+
399
+ requeue(header)
400
+ end
401
+
402
+ # A System Exclusive buffer WinMM could not fill: the data in it is invalid.
403
+ #
404
+ # This has never been observed — sending a message larger than the whole
405
+ # buffer queue did not provoke one — and it is handled anyway because of the
406
+ # shape its failure would take rather than its likelihood. A fragment lost
407
+ # mid-message means {#enqueue_sysex} never sees the 0xF7 that ends it, so it
408
+ # would hold every later fragment too and {#gets} would wait forever. That
409
+ # reads as a hung program, not as a corrupt message.
410
+ #
411
+ # So the partial message is abandoned, loudly, and the buffer goes back into
412
+ # the queue. One message is lost, which is what actually happened.
413
+ #
414
+ # @param header_pointer [Integer] address of a `MIDIHDR`
415
+ # @return [void]
416
+ # @api private
417
+ def discard_long(header_pointer)
418
+ header = API::MIDIHdr.new(FFI::Pointer.new(header_pointer))
419
+
420
+ unless @sysex.empty?
421
+ warn "[midi-communications-windows] #{@name}: discarding #{@sysex.size} bytes of an " \
422
+ 'incomplete System Exclusive message after a device error'
423
+ @sysex.clear
424
+ end
425
+
426
+ requeue(header) unless header[:dwBytesRecorded].zero?
427
+ end
428
+
429
+ # Invalid MIDI data arrived; `lParam` holds what WinMM made of it.
430
+ #
431
+ # There is nothing to hand on and nothing to recycle, but staying silent
432
+ # about a port emitting bytes that are not MIDI helps nobody.
433
+ #
434
+ # @param word [Integer]
435
+ # @return [void]
436
+ # @api private
437
+ def report_invalid(word)
438
+ warn format('[midi-communications-windows] %s: ignoring invalid MIDI data 0x%08X', @name, word & 0xFFFF_FFFF)
439
+ end
440
+
441
+ # Gives a buffer back to WinMM so it can be filled again.
442
+ #
443
+ # This is the call that decided the delivery mechanism: it happens on the
444
+ # reader thread, inside its own message loop, which is legal here and is
445
+ # what Microsoft's documentation forbids inside `midiInProc`. The queue is
446
+ # first in, first out, so a recycled buffer goes to the back and is used
447
+ # again once the ones ahead of it have been.
448
+ #
449
+ # @param header [API::MIDIHdr]
450
+ # @return [void]
451
+ # @raise [Error]
452
+ # @api private
453
+ def requeue(header)
454
+ header[:dwBytesRecorded] = 0
455
+ header[:dwFlags] &= ~API::MHDR_DONE & 0xFFFF_FFFF
456
+
457
+ API.check!(API.midiInAddBuffer(@handle, header, API::MIDIHdr.size), :midiInAddBuffer, :input)
458
+ end
459
+
460
+ # Waits for `midiInReset` to return the queued buffers.
461
+ #
462
+ # Bounded, because closing a port must end whether or not the driver plays
463
+ # its part; a device that has been unplugged will not return anything.
464
+ #
465
+ # @return [void]
466
+ # @api private
467
+ def await_returned_buffers
468
+ deadline = Process.clock_gettime(Process::CLOCK_MONOTONIC) + RESET_TIMEOUT
469
+
470
+ while @returned.size < @buffers.size &&
471
+ Process.clock_gettime(Process::CLOCK_MONOTONIC) < deadline
472
+ sleep 0.001
473
+ end
474
+ end
475
+
476
+ # @return [void]
477
+ # @api private
478
+ def release_buffers
479
+ @buffers.each do |buffer|
480
+ API.midiInUnprepareHeader(@handle, buffer[:header], API::MIDIHdr.size)
481
+ end
482
+
483
+ @buffers = nil
484
+ end
485
+ end
486
+ end
@@ -0,0 +1,133 @@
1
+ module MIDICommunicationsWindows
2
+ # Conversion between MIDI bytes and the packed 32-bit word WinMM uses for
3
+ # short messages.
4
+ #
5
+ # WinMM does not pass short messages as byte strings. It packs them into a
6
+ # `DWORD`: the status byte in the low-order byte, the first data byte next,
7
+ # the second data byte after that, and the high-order byte unused. The same
8
+ # packing is used in both directions — as `dwMsg` for `midiOutShortMsg`, and
9
+ # as `dwParam1` in the notification a client receives for input.
10
+ #
11
+ # ## Why the length matters
12
+ #
13
+ # A packed word carries no length. Nothing distinguishes a Clock, which is one
14
+ # byte, from a Note On whose two data bytes happen to be zero: both arrive as
15
+ # a word whose upper three bytes are zero. The length has to be derived from
16
+ # the status byte, and getting it wrong is not a cosmetic problem — a Clock
17
+ # delivered as `[0xF8, 0, 0]` is not a MIDI Clock message, and a parser
18
+ # reading it will either reject it or invent two events that were never sent.
19
+ # In MusaDSL that is the difference between a piece that follows the DAW's
20
+ # tempo and one that does not start at all.
21
+ #
22
+ # ## Why this file has no FFI in it
23
+ #
24
+ # This is the only part of the gem with logic rather than plumbing, and it is
25
+ # also the part most likely to be wrong. Keeping it free of any binding to
26
+ # `winmm.dll` means it can be tested on any machine, by anyone, without
27
+ # Windows and without a MIDI port — which is what lets `test/message_test.rb`
28
+ # run anywhere.
29
+ #
30
+ # @api public
31
+ module Message
32
+ # Number of bytes in a channel message, indexed by the status byte's high
33
+ # nibble. Program Change and Channel Pressure carry one data byte; every
34
+ # other channel message carries two.
35
+ CHANNEL_MESSAGE_LENGTHS = {
36
+ 0x8 => 3, # Note Off
37
+ 0x9 => 3, # Note On
38
+ 0xA => 3, # Polyphonic Key Pressure
39
+ 0xB => 3, # Control Change
40
+ 0xC => 2, # Program Change
41
+ 0xD => 2, # Channel Pressure
42
+ 0xE => 3 # Pitch Bend Change
43
+ }.freeze
44
+
45
+ # Number of bytes in a System Common message, indexed by status byte.
46
+ # Everything from 0xF8 up is System Real Time and is always one byte, so it
47
+ # is not listed here.
48
+ SYSTEM_COMMON_MESSAGE_LENGTHS = {
49
+ 0xF1 => 2, # MIDI Time Code Quarter Frame
50
+ 0xF2 => 3, # Song Position Pointer
51
+ 0xF3 => 2, # Song Select
52
+ 0xF6 => 1 # Tune Request
53
+ }.freeze
54
+
55
+ module_function
56
+
57
+ # How many bytes the message with this status byte occupies.
58
+ #
59
+ # @param status [Integer] a MIDI status byte, 0x80..0xFF
60
+ # @return [Integer] 1, 2 or 3
61
+ # @raise [ArgumentError] if the byte is not a status byte, or begins a
62
+ # System Exclusive message, which is never delivered as a short message
63
+ #
64
+ # @example
65
+ # Message.length_of(0x90) # => 3 Note On
66
+ # Message.length_of(0xC0) # => 2 Program Change
67
+ # Message.length_of(0xF8) # => 1 Clock
68
+ def length_of(status)
69
+ raise ArgumentError, "not a status byte: #{format('0x%02X', status)}" if status < 0x80 || status > 0xFF
70
+
71
+ # System Exclusive does not travel as a short message: WinMM delivers it
72
+ # through a buffer instead, so a caller who reaches here with 0xF0 has
73
+ # confused the two paths.
74
+ raise ArgumentError, 'System Exclusive is not a short message' if status == 0xF0
75
+
76
+ return 1 if status >= 0xF8
77
+ return SYSTEM_COMMON_MESSAGE_LENGTHS.fetch(status, 1) if status >= 0xF0
78
+
79
+ CHANNEL_MESSAGE_LENGTHS.fetch(status >> 4)
80
+ end
81
+
82
+ # Unpacks the word WinMM delivers for an incoming short message.
83
+ #
84
+ # @param word [Integer] the `dwParam1` of an `MM_MIM_DATA` notification
85
+ # @return [Array<Integer>] the message's bytes, of the length its status
86
+ # byte calls for
87
+ #
88
+ # @example A Clock arrives as a word whose upper bytes are zero
89
+ # Message.unpack(0x0000_00F8) # => [0xF8]
90
+ #
91
+ # @example A Note On carries all three
92
+ # Message.unpack(0x0064_3C90) # => [0x90, 0x3C, 0x64]
93
+ def unpack(word)
94
+ bytes = [word & 0xFF, (word >> 8) & 0xFF, (word >> 16) & 0xFF]
95
+
96
+ bytes.first(length_of(bytes.first))
97
+ end
98
+
99
+ # Packs a short message into the word `midiOutShortMsg` expects.
100
+ #
101
+ # The length must be exactly what the status byte calls for. Too few bytes
102
+ # is plainly a mistake; too many is either a mistake or an attempt at
103
+ # running status, which WinMM does not accept on output. Quietly dropping
104
+ # the surplus would send something the caller did not ask for and give no
105
+ # sign of it.
106
+ #
107
+ # @param bytes [Array<Integer>] the message, status byte first
108
+ # @return [Integer] the packed word
109
+ # @raise [ArgumentError] if the message is not the length its status byte
110
+ # calls for
111
+ #
112
+ # @example
113
+ # Message.pack([0x90, 0x3C, 0x64]) # => 0x00643C90
114
+ def pack(bytes)
115
+ length = length_of(bytes.first)
116
+
117
+ unless bytes.size == length
118
+ raise ArgumentError,
119
+ "#{format('0x%02X', bytes.first)} is a #{length}-byte message, got #{bytes.size} bytes"
120
+ end
121
+
122
+ bytes[0] | ((bytes[1] || 0) << 8) | ((bytes[2] || 0) << 16)
123
+ end
124
+
125
+ # Is this the start of a System Exclusive message?
126
+ #
127
+ # @param bytes [Array<Integer>] a message, status byte first
128
+ # @return [Boolean]
129
+ def sysex?(bytes)
130
+ bytes.first == 0xF0
131
+ end
132
+ end
133
+ end