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,222 @@
1
+ # Testing this gem on Windows without MIDI hardware
2
+
3
+ *Written 2026-09-06. Section 1 is the part with a shelf life: it describes
4
+ software that was in preview at the time, which Microsoft's release notes said
5
+ was headed for Windows 11 25H2 and later during the last week of November 2026.
6
+ If that has happened, most of section 1 collapses into "nothing to install" and
7
+ the rest still applies.*
8
+
9
+ This is **the procedure the library was measured with**, not a promise that it
10
+ works on another machine. All of it ran once, on one particular installation.
11
+ Where something was checked, it says so; where it was not, it says that too.
12
+
13
+ The machine was:
14
+
15
+ | | |
16
+ |---|---|
17
+ | Windows | 11 25H2, build **26200.9278** |
18
+ | CPU | ARM64 — a VMware VM on Apple silicon |
19
+ | Ruby | 3.4.10 `x64-mingw-ucrt`, **emulated** under Prism (no ARM64 Ruby present) |
20
+ | ffi | 1.17.4 `x64-mingw-ucrt`, precompiled binary |
21
+ | MIDI hardware | **none** — no USB device passed through to the guest |
22
+
23
+ That last row is why this document exists: with no hardware, a loopback pair is
24
+ the only way to exercise input and output at the same time.
25
+
26
+ Nothing measured on this setup says anything about **timing**. Emulation inside
27
+ a virtual machine; latency and jitter need real hardware and a native Ruby.
28
+
29
+ ---
30
+
31
+ ## 1. What has to be installed
32
+
33
+ Windows 11 25H2 ships **Windows MIDI Services** in the box: the `midisrv`
34
+ service (`C:\Windows\System32\midisrv.exe`) and the `wdmaud2.drv` shim,
35
+ registered as `midi1` under
36
+ `HKLM\SOFTWARE\Microsoft\Windows NT\CurrentVersion\Drivers32`. That shim is what
37
+ makes endpoints from the new stack **visible to WinMM** without this gem doing
38
+ anything special, and therefore what makes this setup able to test a gem that
39
+ speaks only WinMM.
40
+
41
+ What is *not* in the box are the MIDI 1.0 loopback transport and the tools. Those
42
+ come from the [`microsoft/MIDI`] repository, release `inbox-dev-preview-6`:
43
+
44
+ - `Windows.MIDI.Services.Basic.MIDI.1.0.Loopback.Preview.1.0.18-preview.3-arm64.exe`
45
+ - `Windows.MIDI.Services.Tools.0.99.64-devpreview.6-arm64.exe`
46
+
47
+ There are `-x64` variants of both. The measuring machine used the **native ARM64**
48
+ ones (PE header `0xAA64`) even though the Ruby was emulated x64: the gem talks to
49
+ `winmm.dll`, and Windows handles the crossing.
50
+
51
+ The Network and Bluetooth transports from the same release are not needed.
52
+
53
+ [`microsoft/MIDI`]: https://github.com/microsoft/MIDI/releases
54
+
55
+ ### Warnings that are not optional
56
+
57
+ - **These are unsigned preview binaries.** The release notes require Windows
58
+ **Developer Mode**, and are explicit: *"There are almost certainly bugs and
59
+ missing/incomplete features."* This is not production software.
60
+ - **Do not redistribute them.** The release's permitted-use table forbids it. A
61
+ repository should link to the release and never host the installers.
62
+ - Minimum system version declared: **Windows 11 25H2**.
63
+ - Downloaded through a browser they carry Mark of the Web and SmartScreen will
64
+ object; `Unblock-File` clears it. Fetched with `Invoke-WebRequest` they do not.
65
+
66
+ ## 2. Installing
67
+
68
+ Both installers are **WiX bundles**, so they accept `/quiet`, `/norestart` and
69
+ `/log`. Both need **elevation**, and Developer Mode writes to `HKLM`, so it is
70
+ worth doing the whole thing in one elevated script and getting one UAC prompt.
71
+
72
+ The release notes fix the order: **loopback transport first, tools second.**
73
+
74
+ ```powershell
75
+ # --- 1. Developer Mode (the key may not exist)
76
+ $k = 'HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\AppModelUnlock'
77
+ if (-not (Test-Path $k)) { New-Item $k -Force | Out-Null }
78
+ Set-ItemProperty $k -Name AllowDevelopmentWithoutDevLicense -Value 1 -Type DWord
79
+ Set-ItemProperty $k -Name AllowAllTrustedApps -Value 1 -Type DWord
80
+
81
+ # --- 2. installers, in this order
82
+ Start-Process -Wait -FilePath '<...>Basic.MIDI.1.0.Loopback.Preview...-arm64.exe' `
83
+ -ArgumentList '/quiet','/norestart','/log','loopback.log'
84
+ Start-Process -Wait -FilePath '<...>Tools.0.99.64-devpreview.6-arm64.exe' `
85
+ -ArgumentList '/quiet','/norestart','/log','tools.log'
86
+ ```
87
+
88
+ **Both return exit `3010`**, which is `ERROR_SUCCESS_REBOOT_REQUIRED`: installed
89
+ correctly, reboot pending. The measuring machine **was not rebooted**, and every
90
+ measurement was taken that way. If something behaves oddly, that reboot is the
91
+ first suspect.
92
+
93
+ To check the transport registered:
94
+
95
+ ```powershell
96
+ Get-ChildItem 'HKLM:\SOFTWARE\Microsoft\Windows MIDI Services\Transport Plugins'
97
+ # Midi2BasicLoopbackMidiTransport should appear, with Enabled = 1
98
+ ```
99
+
100
+ ### `midi.exe` and the PATH
101
+
102
+ The console lands at:
103
+
104
+ ```
105
+ C:\Program Files\Windows MIDI Services\Tools\Console\midi.exe
106
+ ```
107
+
108
+ The installer **does** add it to the machine PATH, but **a shell opened before
109
+ installing still has the old environment** and will say `midi` does not exist. In
110
+ a fresh shell, `midi` is enough; in the installing shell, use the full path. This
111
+ detail cost time and produced one wrong report before it was noticed.
112
+
113
+ ## 3. Creating the loopback pair
114
+
115
+ ```powershell
116
+ $midi = 'C:\Program Files\Windows MIDI Services\Tools\Console\midi.exe'
117
+
118
+ & $midi basic-loopback create --name "Reloj Bitwig ñ prueba de longitud"
119
+ & $midi basic-loopback list
120
+ ```
121
+
122
+ Options for `create`:
123
+
124
+ | option | what it does |
125
+ |---|---|
126
+ | `-n`, `--name` | the endpoint's name |
127
+ | `-u`, `--unique-identifier` | an identifier of your own |
128
+ | `-s`, `--save-to-config` | **persists it in the system configuration** |
129
+
130
+ **Without `--save-to-config` a loopback is temporary**: it disappears when
131
+ `midisrv` or the machine restarts, and nothing is written to disk. Every
132
+ measurement was taken that way, deliberately. `--save-to-config` does modify the
133
+ user's system configuration — ask before using it.
134
+
135
+ A `create` returns an **Association Id**, a GUID. Keep it: it is the only thing
136
+ that will remove the loopback again.
137
+
138
+ ### Checking through WinMM, which is what the gem sees
139
+
140
+ ```
141
+ ruby -Ilib examples/list_ports.rb
142
+ ```
143
+
144
+ One loopback shows up as **one input and one output with the same name, byte for
145
+ byte**. With two loopbacks created, enumeration gives 2 inputs and 3 outputs —
146
+ the third being the *Microsoft GS Wavetable Synth*, which is always there.
147
+
148
+ ## 4. The name-collision recipe
149
+
150
+ WinMM stores **31 characters** of a name (`MAXPNAMELEN` is 32, less the NUL) and
151
+ drops the rest **silently**. To reproduce the case where two distinct ports are
152
+ indistinguishable, create two loopbacks whose **first 31 characters match**:
153
+
154
+ ```powershell
155
+ & $midi basic-loopback create --name "Reloj Bitwig ñ prueba de longitud"
156
+ & $midi basic-loopback create --name "Reloj Bitwig ñ prueba de longitud DOS"
157
+ ```
158
+
159
+ The first is 33 characters, the second 37; both truncate to
160
+ `Reloj Bitwig ñ prueba de longit`. In `MIDIINCAPSW`/`MIDIOUTCAPSW` they come back
161
+ **identical field by field**: same name, same `wMid` (1), same `wPid` (25 for
162
+ inputs, 26 for outputs), same `vDriverVersion`, same `wTechnology`. Only the
163
+ index separates them.
164
+
165
+ If you change the names, keep the character counts working — the recipe depends
166
+ on one being longer than 31 and both agreeing up to that point.
167
+
168
+ The truncation happens **upstream of WinMM**: `midi enumerate
169
+ midi-services-endpoints` shows the service already holding the 31-character name.
170
+ Whether the console or the service truncates was not determined; for the gem it
171
+ makes no difference.
172
+
173
+ The `ñ` survives intact (`U+00F1` in the UTF-16LE dump), which is what validates
174
+ the **W** entry points against a genuinely non-ASCII character.
175
+
176
+ ## 5. Removing
177
+
178
+ ```powershell
179
+ & $midi basic-loopback remove --association-id "{bfba125e-3aca-446c-9b94-692a70153034}"
180
+ ```
181
+
182
+ **The braces around the GUID are required.** `list` prints the id without them;
183
+ they have to be added.
184
+
185
+ Afterwards, check the machine is back where it started — with `list`, and better
186
+ still by enumerating through WinMM: with no loopbacks there should be **0 inputs
187
+ and 1 output**.
188
+
189
+ ## 6. What this setup can test, and what it cannot
190
+
191
+ Exercised with it, and working: enumeration with colliding names, short messages
192
+ in all three lengths (3, 2 and 1 bytes), accumulation in `gets`, System Exclusive
193
+ both ways at 200, 3000 and 5000 bytes, buffer recycling, closing and reopening,
194
+ and the propagation of an error from opening a port.
195
+
196
+ **Not testable with this setup**, and therefore still untested:
197
+
198
+ - **`SYSEX_TIMEOUT` / `Output#wait_until_sent`.** The only real output device is
199
+ the *GS Wavetable Synth*, a software synthesiser that marks `MHDR_DONE` in
200
+ under a millisecond because there is no wire. This needs a physical interface.
201
+ - **`RESET_TIMEOUT` / `Input#await_returned_buffers`.** It never came close:
202
+ closes took between 4 and 21 ms against a one-second bound. The
203
+ device-went-away path needs something to unplug.
204
+ - **Renumbering in `Device.enumerate`.** Needs a port to actually appear or go.
205
+ - **`MM_MIM_LONGERROR`.** None could be provoked, not even with a System
206
+ Exclusive message larger than the entire buffer queue. The code handling it is
207
+ unexercised — its presence has been shown harmless, which is not the same
208
+ thing.
209
+ - **That an input and an output of the same device share a name.** Established
210
+ for a loopback, where they are literally the same device. Not for a real USB
211
+ interface.
212
+ - **Any measurement of time.**
213
+
214
+ Those are the items a session with real hardware should close.
215
+
216
+ ## 7. One finding from here worth not forgetting
217
+
218
+ Multi-client access **follows the endpoint, not the API**. Two WinMM clients on
219
+ the same BLOOP input both work and **both receive every message**. The *GS
220
+ Wavetable Synth*, which belongs to the classic `wdmaud` stack, refuses the second
221
+ open with *"The specified device is already in use."* So on one machine, through
222
+ one API, shareable ports and exclusive ports coexist.
data/examples/input.rb ADDED
@@ -0,0 +1,27 @@
1
+ #!/usr/bin/env ruby
2
+ $:.unshift(File.join('..', 'lib'))
3
+
4
+ require 'midi-communications-windows'
5
+
6
+ # This program selects the first midi input and sends an inspection of the first
7
+ # 10 messages it receives to standard out
8
+ #
9
+ # gets blocks until something arrives, so this loop needs no delay of its own
10
+
11
+ num_messages = 10
12
+
13
+ MIDICommunicationsWindows::Input.first.open do |input|
14
+ puts "Using input: #{input.id}, #{input.name}"
15
+
16
+ puts 'send some MIDI to your input now...'
17
+
18
+ received = 0
19
+ while received < num_messages
20
+ input.gets.each do |message|
21
+ puts message.inspect
22
+ received += 1
23
+ end
24
+ end
25
+
26
+ puts 'finished'
27
+ end
@@ -0,0 +1,17 @@
1
+ #!/usr/bin/env ruby
2
+ $:.unshift(File.join('..', 'lib'))
3
+
4
+ require 'midi-communications-windows'
5
+
6
+ # This lists every MIDI port WinMM offers, in both directions.
7
+ #
8
+ # The ids are indexes within a direction, so input 0 and output 0 are different
9
+ # ports. Names come back truncated to 31 characters, which is all WinMM stores.
10
+
11
+ %i[input output].each do |direction|
12
+ puts "#{direction}s:"
13
+
14
+ MIDICommunicationsWindows::Device.all_by_type[direction].each do |port|
15
+ puts " #{port.id}: #{port.name}"
16
+ end
17
+ end
@@ -0,0 +1,22 @@
1
+ #!/usr/bin/env ruby
2
+ $:.unshift(File.join('..', 'lib'))
3
+
4
+ require 'midi-communications-windows'
5
+
6
+ # This program selects the first midi output and sends some arpeggiated chords to it
7
+ #
8
+ # examples/list_ports.rb will list your midi ports
9
+
10
+ notes = [36, 40, 43] # C E G
11
+ octaves = 5
12
+ duration = 0.1
13
+
14
+ MIDICommunicationsWindows::Output.first.open do |output|
15
+ (0..((octaves - 1) * 12)).step(12) do |oct|
16
+ notes.each do |note|
17
+ output.puts(0x90, note + oct, 100) # note on
18
+ sleep(duration) # wait
19
+ output.puts(0x80, note + oct, 100) # note off
20
+ end
21
+ end
22
+ end
@@ -0,0 +1,15 @@
1
+ #!/usr/bin/env ruby
2
+ $:.unshift(File.join('..', 'lib'))
3
+
4
+ require 'midi-communications-windows'
5
+
6
+ # This example outputs a raw sysex message to the first output port
7
+ # there will not be any output to the console
8
+ #
9
+ # The call does not return until the device reports the message sent, which at
10
+ # MIDI's 31250 baud takes about 320 microseconds per byte
11
+
12
+ output = MIDICommunicationsWindows::Output.first
13
+ sysex_msg = [0xF0, 0x41, 0x10, 0x42, 0x12, 0x40, 0x00, 0x7F, 0x00, 0x41, 0xF7]
14
+
15
+ output.open { |port| port.puts(sysex_msg) }
@@ -0,0 +1,340 @@
1
+ module MIDICommunicationsWindows
2
+ # Raised when a WinMM call reports a failure.
3
+ #
4
+ # WinMM reports errors as `MMRESULT` codes rather than by any out-of-band
5
+ # mechanism, so every call is checked and a failure becomes an exception at
6
+ # the point of the call, carrying the text WinMM itself gives for the code.
7
+ #
8
+ # @api public
9
+ class Error < StandardError
10
+ # @!attribute [r] code
11
+ # @return [Integer, nil] the `MMRESULT` WinMM returned, or nil for a
12
+ # failure WinMM did not report as a code — a call that never completed,
13
+ # for instance
14
+ # @!attribute [r] operation
15
+ # @return [Symbol] the WinMM function that failed, e.g. `:midiInOpen`
16
+ attr_reader :code, :operation
17
+
18
+ # @param operation [Symbol] the WinMM function that failed
19
+ # @param code [Integer, nil] the `MMRESULT` code, if there was one
20
+ # @param text [String, nil] a description of the failure
21
+ # @api private
22
+ def initialize(operation, code, text = nil)
23
+ @operation = operation
24
+ @code = code
25
+
26
+ super(build_message(operation, code, text))
27
+ end
28
+
29
+ private
30
+
31
+ # @api private
32
+ def build_message(operation, code, text)
33
+ return "#{operation}: #{text}" unless text.nil? || text.empty?
34
+
35
+ code.nil? ? "#{operation} failed" : "#{operation} failed with MMRESULT #{code}"
36
+ end
37
+ end
38
+
39
+ # Low-level FFI bindings to the Windows Multimedia (WinMM) MIDI API.
40
+ #
41
+ # This module binds `winmm.dll` directly. Most users should use the
42
+ # higher-level {Input}, {Output} and {Device} instead.
43
+ #
44
+ # ## Why the bindings look the way they do
45
+ #
46
+ # **Pointer-sized types are never `:ulong`.** Windows is LLP64: `long` stays
47
+ # 32 bits on 64-bit Windows while `HANDLE`, `DWORD_PTR` and `UINT_PTR` are
48
+ # pointer-sized. Measured from inside FFI on Windows 11 x64:
49
+ # `FFI::Pointer.size` is 8 and `FFI.type_size(:ulong)` is 4. Declaring a
50
+ # handle as `:ulong` truncates it — silently, because the low half of a
51
+ # handle often looks plausible. Every pointer-sized parameter here is
52
+ # `:uintptr_t`.
53
+ #
54
+ # **The `W` variants, never the `A` ones.** `midiInGetDevCapsA` returns the
55
+ # port name in the machine's ANSI code page, which mangles any name that is
56
+ # not ASCII. A user-created loopback called "Reloj Bitwig" is a realistic
57
+ # port name, so the wide-character functions are the only correct choice.
58
+ #
59
+ # **`dwCallback` is `:uintptr_t`, not a callback type.** WinMM overloads that
60
+ # parameter by the flags in `dwFlags`: it holds a function pointer under
61
+ # `CALLBACK_FUNCTION`, a thread id under `CALLBACK_THREAD`, a window handle
62
+ # under `CALLBACK_WINDOW`, and nothing under `CALLBACK_NULL`. Typing it as a
63
+ # callback would make the binding lie about three of those four.
64
+ #
65
+ # These bindings were written from Microsoft's Win32 documentation. They are
66
+ # not derived from any existing Ruby binding.
67
+ #
68
+ # @api private
69
+ module API
70
+ extend FFI::Library
71
+
72
+ # user32 and kernel32 come in for the thread message queue, which is how
73
+ # input is delivered; see {Input}. FFI searches all three for each function.
74
+ ffi_lib 'winmm', 'user32', 'kernel32'
75
+ # Ignored on x64, where there is a single calling convention; required on
76
+ # 32-bit Windows, where WinMM is stdcall.
77
+ ffi_convention :stdcall
78
+
79
+ typedef :uint8, :BYTE
80
+ typedef :uint16, :WORD
81
+ typedef :uint32, :DWORD
82
+ typedef :uint32, :MMRESULT
83
+ typedef :uintptr_t, :DWORD_PTR
84
+ typedef :uintptr_t, :UINT_PTR
85
+ # Handles are opaque and pointer-sized. See the note above on LLP64.
86
+ typedef :uintptr_t, :HMIDIIN
87
+ typedef :uintptr_t, :HMIDIOUT
88
+
89
+ # Length of a port name in `MIDIINCAPSW`/`MIDIOUTCAPSW`, in characters,
90
+ # including the terminating NUL. This is the origin of WinMM's 31-character
91
+ # limit on port names.
92
+ MAXPNAMELEN = 32
93
+
94
+ # `MMRESULT` value meaning success.
95
+ MMSYSERR_NOERROR = 0
96
+
97
+ # Characters reserved for an error description. WinMM truncates to fit.
98
+ MAX_ERROR_TEXT_LENGTH = 256
99
+
100
+ # How WinMM should notify a client of input. Passed in `dwFlags`; decides
101
+ # how `dwCallback` is interpreted.
102
+ CALLBACK_NULL = 0x0000_0000
103
+ CALLBACK_WINDOW = 0x0001_0000
104
+ CALLBACK_THREAD = 0x0002_0000
105
+ CALLBACK_FUNCTION = 0x0003_0000
106
+
107
+ # Notification messages delivered for an input device.
108
+ MM_MIM_OPEN = 0x3C1
109
+ MM_MIM_CLOSE = 0x3C2
110
+ MM_MIM_DATA = 0x3C3
111
+ MM_MIM_LONGDATA = 0x3C4
112
+ MM_MIM_ERROR = 0x3C5
113
+ MM_MIM_LONGERROR = 0x3C6
114
+
115
+ # Notification messages delivered for an output device.
116
+ MM_MOM_OPEN = 0x3C7
117
+ MM_MOM_CLOSE = 0x3C8
118
+ MM_MOM_DONE = 0x3C9
119
+
120
+ # `MIDIHDR#dwFlags` bits.
121
+ MHDR_DONE = 0x0000_0001
122
+ MHDR_PREPARED = 0x0000_0002
123
+ MHDR_INQUEUE = 0x0000_0004
124
+
125
+ # Thread message queue.
126
+ WM_QUIT = 0x0012
127
+ PM_NOREMOVE = 0x0000
128
+
129
+ # Windows posts messages of its own to the same queue WinMM uses. Measured:
130
+ # a `WM_USER` with both parameters zero follows every WinMM notification,
131
+ # including those after the port opens and closes. Nothing here emits them
132
+ # and nothing here knows what does, so the reader dispatches on the message
133
+ # id and ignores everything it did not ask for.
134
+ WM_USER = 0x0400
135
+
136
+ # Capabilities of a MIDI input device, as `midiInGetDevCapsW` fills it.
137
+ #
138
+ # `szPname` is an array of UTF-16 code units, not of bytes: the field is
139
+ # declared as `:uint16` so that reading it cannot split a character in half.
140
+ class MIDIInCaps < FFI::Struct
141
+ layout :wMid, :uint16,
142
+ :wPid, :uint16,
143
+ :vDriverVersion, :uint32,
144
+ :szPname, [:uint16, MAXPNAMELEN],
145
+ :dwSupport, :uint32
146
+ end
147
+
148
+ # Capabilities of a MIDI output device, as `midiOutGetDevCapsW` fills it.
149
+ class MIDIOutCaps < FFI::Struct
150
+ layout :wMid, :uint16,
151
+ :wPid, :uint16,
152
+ :vDriverVersion, :uint32,
153
+ :szPname, [:uint16, MAXPNAMELEN],
154
+ :wTechnology, :uint16,
155
+ :wVoices, :uint16,
156
+ :wNotes, :uint16,
157
+ :wChannelMask, :uint16,
158
+ :dwSupport, :uint32
159
+ end
160
+
161
+ # Both capability structures have a size fixed by the Win32 headers, and
162
+ # WinMM rejects a call whose `cbSize` disagrees with what it expects. A
163
+ # mismatch here means the layout above is wrong, and every field read from
164
+ # it afterwards would be garbage that still looks like data — so it is
165
+ # checked once, at load, rather than discovered as a puzzling device name
166
+ # much later.
167
+ #
168
+ # Verified against Windows 11 25H2 (build 26200), winmm.dll 10.0.26100.
169
+ raise "MIDIINCAPSW is #{MIDIInCaps.size} bytes, expected 76" unless MIDIInCaps.size == 76
170
+ raise "MIDIOUTCAPSW is #{MIDIOutCaps.size} bytes, expected 84" unless MIDIOutCaps.size == 84
171
+
172
+ # A buffer handed to WinMM, used for System Exclusive in both directions.
173
+ #
174
+ # `dwReserved` really is an array of eight pointer-sized words. Declaring it
175
+ # as a single word makes the structure too short, and WinMM writes past the
176
+ # end of it. This layout measures 120 bytes on 64-bit Windows, which is what
177
+ # WinMM there expects.
178
+ class MIDIHdr < FFI::Struct
179
+ layout :lpData, :pointer,
180
+ :dwBufferLength, :uint32,
181
+ :dwBytesRecorded, :uint32,
182
+ :dwUser, :uintptr_t,
183
+ :dwFlags, :uint32,
184
+ :lpNext, :pointer,
185
+ :reserved, :uintptr_t,
186
+ :dwOffset, :uint32,
187
+ :dwReserved, [:uintptr_t, 8]
188
+ end
189
+
190
+ # Measured against winmm on 64-bit Windows 11. Checked only there, because
191
+ # the layout's size follows the pointer size and 32-bit Windows produces a
192
+ # different, equally correct, number.
193
+ if FFI::Pointer.size == 8 && MIDIHdr.size != 120
194
+ raise "MIDIHDR is #{MIDIHdr.size} bytes on 64-bit Windows, expected 120"
195
+ end
196
+
197
+ # Enumeration. These touch no device and can fire no callback, so they are
198
+ # the only calls here that keep the GVL.
199
+ attach_function :midiInGetNumDevs, [], :uint32
200
+ attach_function :midiOutGetNumDevs, [], :uint32
201
+ attach_function :midiInGetDevCapsW, %i[UINT_PTR pointer uint32], :MMRESULT
202
+ attach_function :midiOutGetDevCapsW, %i[UINT_PTR pointer uint32], :MMRESULT
203
+
204
+ # EVERY CALL BELOW RELEASES THE GVL, and that is not an optimisation.
205
+ #
206
+ # Measured on Windows 11 25H2 against a system loopback: with an input open
207
+ # and a Ruby callback attached to it, a `midiOutShortMsg` that holds the GVL
208
+ # deadlocks against the driver thread waiting for that same GVL to enter the
209
+ # callback. The shim eventually gives up, the send returns MMSYSERR_ERROR,
210
+ # and — this is the part that matters — the message is then delivered
211
+ # TWICE, a second or two later. Three sends produced five, four and five
212
+ # deliveries across three runs. System Exclusive behaved the same way: 400
213
+ # bytes received for a 200-byte message.
214
+ #
215
+ # Two controls place the cause: sending with no input open succeeds, and so
216
+ # does sending with an input open under CALLBACK_NULL. It is not the send,
217
+ # and it is not having an input open. It is a Ruby callback waiting for the
218
+ # GVL held by the thread that is sending.
219
+ #
220
+ # The failure is quiet. Nothing raises; the port simply emits notes nobody
221
+ # asked for. So the rule is structural rather than case by case: anything
222
+ # that can reach a device is declared blocking, and a function added here
223
+ # later should be too.
224
+ attach_function :midiInOpen, %i[pointer uint32 DWORD_PTR DWORD_PTR DWORD], :MMRESULT, blocking: true
225
+ attach_function :midiInClose, [:HMIDIIN], :MMRESULT, blocking: true
226
+ attach_function :midiInStart, [:HMIDIIN], :MMRESULT, blocking: true
227
+ attach_function :midiInStop, [:HMIDIIN], :MMRESULT, blocking: true
228
+ attach_function :midiInReset, [:HMIDIIN], :MMRESULT, blocking: true
229
+
230
+ attach_function :midiOutOpen, %i[pointer uint32 DWORD_PTR DWORD_PTR DWORD], :MMRESULT, blocking: true
231
+ attach_function :midiOutClose, [:HMIDIOUT], :MMRESULT, blocking: true
232
+ attach_function :midiOutReset, [:HMIDIOUT], :MMRESULT, blocking: true
233
+
234
+ # Sending
235
+ attach_function :midiOutShortMsg, %i[HMIDIOUT DWORD], :MMRESULT, blocking: true
236
+ attach_function :midiOutLongMsg, %i[HMIDIOUT pointer uint32], :MMRESULT, blocking: true
237
+ attach_function :midiOutPrepareHeader, %i[HMIDIOUT pointer uint32], :MMRESULT, blocking: true
238
+ attach_function :midiOutUnprepareHeader, %i[HMIDIOUT pointer uint32], :MMRESULT, blocking: true
239
+
240
+ # Receiving System Exclusive
241
+ attach_function :midiInPrepareHeader, %i[HMIDIIN pointer uint32], :MMRESULT, blocking: true
242
+ attach_function :midiInUnprepareHeader, %i[HMIDIIN pointer uint32], :MMRESULT, blocking: true
243
+ attach_function :midiInAddBuffer, %i[HMIDIIN pointer uint32], :MMRESULT, blocking: true
244
+
245
+ # Diagnostics
246
+ attach_function :midiInGetErrorTextW, %i[MMRESULT pointer uint32], :MMRESULT
247
+ attach_function :midiOutGetErrorTextW, %i[MMRESULT pointer uint32], :MMRESULT
248
+
249
+ # A message waiting in a thread's queue.
250
+ #
251
+ # `POINT pt` is spelled out as two fields because nothing here reads it and
252
+ # a nested structure would earn its keep only in a mouse handler. The size
253
+ # is 48 bytes on 64-bit Windows, which is what the queue functions expect.
254
+ class MSG < FFI::Struct
255
+ layout :hwnd, :pointer,
256
+ :message, :uint32,
257
+ :wParam, :uintptr_t,
258
+ # LPARAM is a signed LONG_PTR in the headers. It is read unsigned
259
+ # here because every value this library takes from it is either a
260
+ # packed MIDI word or a pointer, and a high address arriving as a
261
+ # negative number would not survive being turned back into one.
262
+ :lParam, :uintptr_t,
263
+ :time, :uint32,
264
+ :pt_x, :int32,
265
+ :pt_y, :int32
266
+ end
267
+
268
+ raise "MSG is #{MSG.size} bytes on 64-bit Windows, expected 48" if FFI::Pointer.size == 8 && MSG.size != 48
269
+
270
+ # The thread message queue.
271
+ #
272
+ # `GetMessageW` blocks, so it releases the GVL like everything else that can
273
+ # wait. It returns an int and not a boolean: above zero for a message, zero
274
+ # for `WM_QUIT`, and -1 for an error — which is why `BOOL` is bound as `:int`
275
+ # throughout. FFI's `:bool` is one byte and would read only the low half of
276
+ # a `BOOL` that Windows defines as a four-byte int.
277
+ attach_function :GetCurrentThreadId, [], :uint32
278
+ attach_function :GetMessageW, %i[pointer pointer uint32 uint32], :int, blocking: true
279
+ attach_function :PeekMessageW, %i[pointer pointer uint32 uint32 uint32], :int
280
+ attach_function :PostThreadMessageW, %i[uint32 uint32 uintptr_t uintptr_t], :int
281
+
282
+ module_function
283
+
284
+ # Raises unless the call succeeded.
285
+ #
286
+ # @param code [Integer] the `MMRESULT` a WinMM call returned
287
+ # @param operation [Symbol] the function that returned it, for the message
288
+ # @param direction [Symbol] `:input` or `:output`, to pick the error table
289
+ # @return [void]
290
+ # @raise [Error] when `code` is anything but `MMSYSERR_NOERROR`
291
+ def check!(code, operation, direction)
292
+ return if code == MMSYSERR_NOERROR
293
+
294
+ raise Error.new(operation, code, error_text(code, direction))
295
+ end
296
+
297
+ # WinMM's own description of an error code.
298
+ #
299
+ # The two error tables are not the same, which is why the direction has to
300
+ # be known: the same numeric code can mean different things for input and
301
+ # for output.
302
+ #
303
+ # @param code [Integer] an `MMRESULT`
304
+ # @param direction [Symbol] `:input` or `:output`
305
+ # @return [String, nil] the description, or nil if WinMM has none
306
+ def error_text(code, direction)
307
+ buffer = FFI::MemoryPointer.new(:uint16, MAX_ERROR_TEXT_LENGTH)
308
+
309
+ result = case direction
310
+ when :input then midiInGetErrorTextW(code, buffer, MAX_ERROR_TEXT_LENGTH)
311
+ when :output then midiOutGetErrorTextW(code, buffer, MAX_ERROR_TEXT_LENGTH)
312
+ end
313
+
314
+ read_wide_string(buffer, MAX_ERROR_TEXT_LENGTH) if result == MMSYSERR_NOERROR
315
+ end
316
+
317
+ # Reads a NUL-terminated UTF-16LE string out of native memory.
318
+ #
319
+ # Reading is done in code units rather than bytes on purpose: a byte-wise
320
+ # search for a NUL pair would stop in the middle of a pair such as
321
+ # `'A' 'Ā'` (`41 00 00 01`) and return a truncated name.
322
+ #
323
+ # A name that does not decode is repaired rather than raised on: a port
324
+ # whose name arrives mangled is still a port the caller may want to open,
325
+ # and losing the whole enumeration over one bad character would be worse
326
+ # than showing a replacement character.
327
+ #
328
+ # @param pointer [FFI::Pointer] memory holding the string
329
+ # @param max_characters [Integer] capacity of the field, in characters
330
+ # @return [String] the decoded string, in UTF-8
331
+ def read_wide_string(pointer, max_characters)
332
+ units = pointer.read_array_of_uint16(max_characters)
333
+ units = units.take_while { |unit| !unit.zero? }
334
+
335
+ units.pack('S<*')
336
+ .force_encoding(Encoding::UTF_16LE)
337
+ .encode(Encoding::UTF_8, invalid: :replace, undef: :replace)
338
+ end
339
+ end
340
+ end