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.
- checksums.yaml +7 -0
- data/.gitignore +8 -0
- data/.version +6 -0
- data/.yardoc/checksums +8 -0
- data/.yardoc/complete +0 -0
- data/.yardoc/object_types +0 -0
- data/.yardoc/objects/root.dat +0 -0
- data/.yardoc/proxy_types +0 -0
- data/.yardopts +8 -0
- data/Gemfile +3 -0
- data/LICENSE +165 -0
- data/README.md +176 -0
- data/Rakefile +13 -0
- data/doc/MIDICommunicationsWindows/API/MIDIHdr.html +144 -0
- data/doc/MIDICommunicationsWindows/API/MIDIInCaps.html +142 -0
- data/doc/MIDICommunicationsWindows/API/MIDIOutCaps.html +140 -0
- data/doc/MIDICommunicationsWindows/API/MSG.html +143 -0
- data/doc/MIDICommunicationsWindows/API.html +981 -0
- data/doc/MIDICommunicationsWindows/Device/ClassMethods.html +365 -0
- data/doc/MIDICommunicationsWindows/Device/InstanceMethods.html +1096 -0
- data/doc/MIDICommunicationsWindows/Device.html +988 -0
- data/doc/MIDICommunicationsWindows/Error.html +438 -0
- data/doc/MIDICommunicationsWindows/Input.html +1554 -0
- data/doc/MIDICommunicationsWindows/Message.html +700 -0
- data/doc/MIDICommunicationsWindows/Output.html +1697 -0
- data/doc/MIDICommunicationsWindows/TypeConversion.html +369 -0
- data/doc/MIDICommunicationsWindows.html +210 -0
- data/doc/_index.html +262 -0
- data/doc/class_list.html +54 -0
- data/doc/css/common.css +1 -0
- data/doc/css/full_list.css +206 -0
- data/doc/css/style.css +1089 -0
- data/doc/file.README.html +240 -0
- data/doc/file.testing-on-windows.html +253 -0
- data/doc/file_list.html +64 -0
- data/doc/frames.html +22 -0
- data/doc/index.html +240 -0
- data/doc/js/app.js +801 -0
- data/doc/js/full_list.js +334 -0
- data/doc/js/jquery.js +4 -0
- data/doc/method_list.html +382 -0
- data/doc/top-level-namespace.html +112 -0
- data/docs/testing-on-windows.md +222 -0
- data/examples/input.rb +27 -0
- data/examples/list_ports.rb +17 -0
- data/examples/output.rb +22 -0
- data/examples/sysex_output.rb +15 -0
- data/lib/midi-communications-windows/api.rb +340 -0
- data/lib/midi-communications-windows/device.rb +310 -0
- data/lib/midi-communications-windows/input.rb +486 -0
- data/lib/midi-communications-windows/message.rb +133 -0
- data/lib/midi-communications-windows/output.rb +206 -0
- data/lib/midi-communications-windows/type_conversion.rb +20 -0
- data/lib/midi-communications-windows/version.rb +4 -0
- data/lib/midi-communications-windows.rb +76 -0
- data/midi-communications-windows.gemspec +32 -0
- 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
|
data/examples/output.rb
ADDED
|
@@ -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
|