MidiDevices.kt
src/jvmMain/kotlin/afield/audio/synth/MidiDevices.kt · 458 lines
@file:afield.runtime.completion.CompletionCategory("Audio")
package afield.audio.synth
import java.io.File
import java.util.concurrent.CopyOnWriteArrayList
import javax.sound.midi.MidiDevice
import javax.sound.midi.MidiMessage
import javax.sound.midi.MidiSystem
import javax.sound.midi.Receiver
import javax.sound.midi.Sequence
import javax.sound.midi.ShortMessage
import javax.sound.midi.Synthesizer
import javax.sound.sampled.AudioFileFormat
import javax.sound.sampled.AudioFormat
import javax.sound.sampled.AudioInputStream
import javax.sound.sampled.AudioSystem
import kotlin.math.abs
Live MIDI device I/O, and offline rendering of a MidiClip to a WAV through the JDK's own software synthesizer.
MidiDevices enumerates what the JVM can see and opens a MidiOutput or MidiInput on it;
MidiClip.playTo sends a clip to a device by name fragment. MidiClip.renderWav and
renderToWav take the offline route instead, which needs no hardware at all.
Everything here has to survive a machine with no MIDI hardware, so every entry point either returns null or an empty list, or throws a message that names what was missing — midiRenderUnavailableReason is that check on its own, for asking before committing to a render. Nothing fakes a device.
data class MidiDeviceHandle
One MIDI device the JVM can see.
canReceive and canTransmit are the useful distinction, and they are not symmetric with the
names: a device Field can send to reports receivers, and a device Field can listen to reports
transmitters. macOS surfaces its IAC bus as two separate MidiDevice.Info entries with the
same name, one of each direction, so name alone does not identify a device.
data class MidiDeviceHandle(
val name: String,
val vendor: String,
val description: String,
val version: String,
/** True if Field can send messages to this device (it exposes receivers). */
val canReceive: Boolean,
/** True if Field can read messages from this device (it exposes transmitters). */
val canTransmit: Boolean,
/** The JDK implementation class, e.g. `SoftSynthesizer`, `MidiOutDevice`, `RealTimeSequencer`. */
val implementation: String,
internal val info: MidiDevice.Info
) {
val isSoftware: Boolean
True for the JDK's bundled software synthesizer and sequencer, rather than real hardware.
val isSoftware: Boolean
get() = implementation == "SoftSynthesizer" || implementation == "RealTimeSequencer"
override fun toString(): String
override fun toString(): String = buildString {
append(name)
append(" [")
append(if (canReceive) "out" else "")
append(if (canReceive && canTransmit) "/" else "")
append(if (canTransmit) "in" else "")
append("] ")
append(vendor)
append(" (")
append(implementation)
append(")")
}
}
object MidiDevices
Enumeration and opening of live MIDI devices.
object MidiDevices {
fun list(): List<MidiDeviceHandle>
Every device MidiSystem reports, in its own order.
A device whose info cannot be opened for inspection is skipped rather than thrown, because one broken driver should not hide the rest of the list.
fun list(): List<MidiDeviceHandle> = MidiSystem.getMidiDeviceInfo().mapNotNull { info ->
try {
val device = MidiSystem.getMidiDevice(info)
MidiDeviceHandle(
name = info.name,
vendor = info.vendor,
description = info.description,
version = info.version,
// maxReceivers/-Transmitters return -1 for "unlimited", 0 for "none".
canReceive = device.maxReceivers != 0,
canTransmit = device.maxTransmitters != 0,
implementation = device.javaClass.simpleName,
info = info
)
} catch (_: Throwable) {
null
}
}
fun outputs(): List<MidiDeviceHandle> = list().filter { it.canReceive }
Devices Field can send to.
fun inputs(): List<MidiDeviceHandle> = list().filter { it.canTransmit }
Devices Field can listen to.
fun hardware(): List<MidiDeviceHandle> = list().filterNot { it.isSoftware }
Devices that are real ports rather than the JDK's built-in synthesizer and sequencer.
On this machine at the time of writing that is macOS's IAC "Bus 1", in both directions. On a machine with nothing plugged in it is empty, which is the case every device test here has to skip on.
fun describe(): String
A one-line-per-device report, for a box output or a test's skip reason.
fun describe(): String {
val all = list()
if (all.isEmpty()) return "no MIDI devices at all"
return all.joinToString("\n") { " $it" }
}
fun findOutput(nameFragment: String): MidiDeviceHandle?
The first output whose name contains nameFragment, case-insensitively.
fun findOutput(nameFragment: String): MidiDeviceHandle? =
outputs().firstOrNull { it.name.contains(nameFragment, ignoreCase = true) }
fun findInput(nameFragment: String): MidiDeviceHandle?
The first input whose name contains nameFragment, case-insensitively.
fun findInput(nameFragment: String): MidiDeviceHandle? =
inputs().firstOrNull { it.name.contains(nameFragment, ignoreCase = true) }
fun openOutput(handle: MidiDeviceHandle): MidiOutput
Open handle for output. The caller owns the result and must close it.
fun openOutput(handle: MidiDeviceHandle): MidiOutput {
require(handle.canReceive) { "${handle.name} has no receivers, so it cannot be opened for output" }
val device = MidiSystem.getMidiDevice(handle.info)
device.open()
return MidiOutput(handle, device, device.receiver)
}
fun openInput(handle: MidiDeviceHandle): MidiInput
Open handle for input. The caller owns the result and must close it — but see MidiInput.
fun openInput(handle: MidiDeviceHandle): MidiInput {
require(handle.canTransmit) { "${handle.name} has no transmitters, so it cannot be opened for input" }
val device = MidiSystem.getMidiDevice(handle.info)
device.open()
return MidiInput(handle, device)
}
}
class MidiOutput internal constructor
An open MIDI output. Send notes with noteOn/noteOff, or a whole clip with play.
class MidiOutput internal constructor(
val handle: MidiDeviceHandle,
private val device: MidiDevice,
private val receiver: Receiver
) : AutoCloseable {
fun noteOn(pitch: Int, velocity: Int = 100, channel: Int = 0)
fun noteOn(pitch: Int, velocity: Int = 100, channel: Int = 0) {
receiver.send(ShortMessage(ShortMessage.NOTE_ON, channel.coerceIn(0, 15), pitch.coerceIn(0, 127), velocity.coerceIn(0, 127)), -1)
}
fun noteOff(pitch: Int, channel: Int = 0)
fun noteOff(pitch: Int, channel: Int = 0) {
receiver.send(ShortMessage(ShortMessage.NOTE_OFF, channel.coerceIn(0, 15), pitch.coerceIn(0, 127), 0), -1)
}
fun controlChange(controller: Int, value: Int, channel: Int = 0)
fun controlChange(controller: Int, value: Int, channel: Int = 0) {
receiver.send(ShortMessage(ShortMessage.CONTROL_CHANGE, channel.coerceIn(0, 15), controller.coerceIn(0, 127), value.coerceIn(0, 127)), -1)
}
fun programChange(program: Int, channel: Int = 0)
fun programChange(program: Int, channel: Int = 0) {
receiver.send(ShortMessage(ShortMessage.PROGRAM_CHANGE, channel.coerceIn(0, 15), program.coerceIn(0, 127), 0), -1)
}
fun send(message: javax.sound.midi.MidiMessage)
Send any Java Sound message as-is (SysEx, MIDI Time Code, ...).
fun send(message: javax.sound.midi.MidiMessage) {
receiver.send(message, -1)
}
fun allNotesOff()
Send an all-notes-off on every channel. Call this before closing, or a note hangs.
fun allNotesOff() {
for (channel in 0..15) {
receiver.send(ShortMessage(ShortMessage.CONTROL_CHANGE, channel, 123, 0), -1)
}
}
fun play(clip: MidiClip)
Play clip to this output in real time, blocking until it finishes.
This is a plain sleep-and-send loop rather than a Sequencer, because a Sequencer needs to
be wired to a device and torn down, and for a short clip the added machinery buys nothing.
It is therefore not sample-accurate and not suitable as a musical clock — MidiClip.play is
for auditioning, and anything that needs real timing should drive a Sequencer directly.
fun play(clip: MidiClip) {
data class Ev(val seconds: Double, val on: Boolean, val note: MidiNote)
val events = clip.tracks.flatMap { it.notes }
.flatMap { listOf(Ev(clip.beatsToSeconds(it.startBeats), true, it), Ev(clip.beatsToSeconds(it.endBeats), false, it)) }
.sortedBy { it.seconds }
val startedAt = System.nanoTime()
for (event in events) {
val targetNanos = startedAt + (event.seconds * 1_000_000_000L).toLong()
val waitNanos = targetNanos - System.nanoTime()
if (waitNanos > 0) Thread.sleep(waitNanos / 1_000_000L, (waitNanos % 1_000_000L).toInt())
if (event.on) noteOn(event.note.pitch, event.note.velocity, event.note.channel)
else noteOff(event.note.pitch, event.note.channel)
}
}
override fun close()
override fun close() {
try {
allNotesOff()
} catch (_: Throwable) {
// Closing matters more than a clean release.
}
runCatching { receiver.close() }
runCatching { device.close() }
}
}
class MidiInput internal constructor
An open MIDI input, usable as a Field control source. Poll lastValue for a controller, or
install a callback with onMessage.
Operational hazard, measured rather than assumed. Opening a CoreMIDI input device spawns a
non-daemon thread named Java Sound MidiInDevice Thread, and that thread survives both
Transmitter.close() and MidiDevice.close(). A JVM that has ever opened a MIDI input will
therefore not exit naturally — I confirmed this by listing non-daemon threads after closing both,
and the process still had to be killed after a 20-second wait. An output-only device closes
cleanly and the JVM exits normally.
The consequence for this codebase: a jvm-main smoke test must not open a MIDI input, because
smoke tests are required to exit naturally. MidiDeviceSmokeTest enumerates and opens an
output only, and the loopback check that does open an input is a jvmTest (where Gradle's test
JVM is torn down for us) guarded to skip when no hardware port exists.
class MidiInput internal constructor(
val handle: MidiDeviceHandle,
private val device: MidiDevice
) : AutoCloseable {
private val transmitter = device.transmitter
private val listeners = CopyOnWriteArrayList<(MidiMessage, Long) -> Unit>()
val controllers: MutableMap<Pair<Int, Int>, Int> = java.util.concurrent.ConcurrentHashMap()
Latest value seen per (channel, controller), 0..127.
val lastNoteOn: MutableMap<Int, Pair<Int, Int>> = java.util.concurrent.ConcurrentHashMap()
Latest note-on seen per channel, as (pitch, velocity).
var messageCount: Long
Count of messages seen since opening. Useful for asserting that a device really spoke.
@Volatile
var messageCount: Long = 0L
private set
init
init {
transmitter.receiver = object : Receiver {
override fun send(message: MidiMessage, timeStamp: Long) {
messageCount++
if (message is ShortMessage) {
when (message.command) {
ShortMessage.CONTROL_CHANGE ->
controllers[message.channel to message.data1] = message.data2
ShortMessage.NOTE_ON ->
if (message.data2 > 0) lastNoteOn[message.channel] = message.data1 to message.data2
}
}
listeners.forEach { runCatching { it(message, timeStamp) } }
}
override fun close() {}
}
}
fun knob(controller: Int, channel: Int = 0, default: Double = 0.0): Double
The current value of controller on channel, normalised to 0..1 — the shape a knob on a
controller driving a box property wants. Returns default until the controller has moved
once, because MIDI controllers are stateless and report nothing until touched.
fun knob(controller: Int, channel: Int = 0, default: Double = 0.0): Double {
val raw = controllers[channel to controller] ?: return default
return raw / 127.0
}
fun raw(controller: Int, channel: Int = 0): Int? = controllers[channel to controller]
Raw 0..127 value of controller on channel, or null if untouched.
fun onMessage(block: (MidiMessage, Long) -> Unit) {
Add a message callback. Called on the Java Sound MIDI thread, so keep it short.
fun onMessage(block: (MidiMessage, Long) -> Unit) {
listeners += block
}
override fun close()
override fun close() {
listeners.clear()
runCatching { transmitter.close() }
runCatching { device.close() }
// The `Java Sound MidiInDevice Thread` outlives this call. See the class doc.
}
}
data class MidiRenderResult
Result of renderToWav.
data class MidiRenderResult(
val file: File,
val seconds: Double,
val sampleRate: Int,
/** Peak absolute sample amplitude, 0.0..1.0. A silent render is 0.0. */
val peakAmplitude: Double,
/** Root-mean-square amplitude, 0.0..1.0. */
val rmsAmplitude: Double,
val soundbankName: String?
) {
val isSilent: Boolean get() = peakAmplitude <= 0.0
fun midiRenderUnavailableReason(): String?
Whether this JVM can render MIDI to audio offline, and if not, why not.
Returns null when rendering is available, or a human-readable reason when it is not. Callers
should treat a non-null result as a skip reason, not a failure: an offline render needs
com.sun.media.sound.AudioSynthesizer, which is the JDK's internal interface behind
MidiSystem.getSynthesizer(). java.desktop does not export that package, so it is reachable
only when the JVM was started with
--add-exports=java.desktop/com.sun.media.sound=ALL-UNNAMED.
This is checked at run time rather than assumed, because whether the flag is present depends on
how the JVM was launched, and Field's requiredRunJvmArgs() does not currently pass it.
fun midiRenderUnavailableReason(): String? {
val synthClass = try {
Class.forName("com.sun.media.sound.AudioSynthesizer")
} catch (t: Throwable) {
return "com.sun.media.sound.AudioSynthesizer is not present in this JVM (${t.javaClass.simpleName})"
}
val synth = try {
MidiSystem.getSynthesizer()
} catch (t: Throwable) {
return "MidiSystem.getSynthesizer() failed: ${t.message ?: t.javaClass.simpleName}"
}
if (!synthClass.isInstance(synth)) {
return "the default synthesizer is ${synth.javaClass.name}, which is not an AudioSynthesizer"
}
return try {
// Resolving the method is not enough — the access check happens on invoke, and that is the
// check that actually fails without the --add-exports, with an IllegalAccessException.
val probe = synthClass.getMethod("openStream", AudioFormat::class.java, Map::class.java)
val stream = probe.invoke(synth, WAV_FORMAT, null) as AudioInputStream
stream.close()
synth.close()
null
} catch (t: Throwable) {
val cause = t.cause ?: t
"openStream is not accessible: ${cause.javaClass.simpleName}. " +
"Relaunch with --add-exports=java.desktop/com.sun.media.sound=ALL-UNNAMED"
}
}
private val WAV_FORMAT = AudioFormat(44100f, 16, 2, true, false)
fun renderToWav
Render clip to a WAV at file using the JDK's Gervill software synthesizer, offline and faster
than real time. Returns the measured result, or throws with a stated reason if rendering is
unavailable — check midiRenderUnavailableReason first if you want to skip instead.
The returned MidiRenderResult carries the measured peak and RMS amplitude of what was actually written, because "the file exists" and "the file has audio in it" are different claims and file size cannot distinguish them: a WAV of pure silence is exactly as large as a loud one.
body — 79 lines, from line 365
fun renderToWav(
clip: MidiClip,
file: File,
tailSeconds: Double = 1.0,
program: Int? = null
): MidiRenderResult {
midiRenderUnavailableReason()?.let { error("cannot render MIDI to audio: $it") }
val synthClass = Class.forName("com.sun.media.sound.AudioSynthesizer")
val synth: Synthesizer = MidiSystem.getSynthesizer()
val openStream = synthClass.getMethod("openStream", AudioFormat::class.java, Map::class.java)
val stream = openStream.invoke(synth, WAV_FORMAT, null) as AudioInputStream
val soundbank = runCatching { synth.defaultSoundbank?.name }.getOrNull()
try {
val receiver = synth.receiver
// Load the sequence into the synth's own event queue with real timestamps, in microseconds.
// Sending with timestamp -1 would fire everything immediately and the render would be a
// single chord.
clip.tracks.forEach { track ->
val channel = track.notes.firstOrNull()?.channel ?: 0
(program ?: track.program)?.let {
receiver.send(ShortMessage(ShortMessage.PROGRAM_CHANGE, channel, it.coerceIn(0, 127), 0), 0L)
}
track.notes.forEach { note ->
val onMicros = (clip.beatsToSeconds(note.startBeats) * 1_000_000L).toLong()
val offMicros = (clip.beatsToSeconds(note.endBeats) * 1_000_000L).toLong()
receiver.send(
ShortMessage(ShortMessage.NOTE_ON, note.channel, note.pitch, note.velocity),
onMicros
)
receiver.send(ShortMessage(ShortMessage.NOTE_OFF, note.channel, note.pitch, 0), offMicros)
}
}
val seconds = clip.durationSeconds + tailSeconds
val frames = (seconds * WAV_FORMAT.sampleRate).toLong()
val byteCount = frames * WAV_FORMAT.frameSize
file.absoluteFile.parentFile?.mkdirs()
val bytes = ByteArray(byteCount.toInt())
var read = 0
while (read < bytes.size) {
val n = stream.read(bytes, read, bytes.size - read)
if (n < 0) break
read += n
}
AudioSystem.write(
AudioInputStream(bytes.inputStream(), WAV_FORMAT, (read / WAV_FORMAT.frameSize).toLong()),
AudioFileFormat.Type.WAVE,
file
)
var peak = 0
var sumSquares = 0.0
var samples = 0L
var i = 0
while (i + 1 < read) {
val sample = ((bytes[i + 1].toInt() shl 8) or (bytes[i].toInt() and 0xff)).toShort().toInt()
peak = maxOf(peak, abs(sample))
sumSquares += sample.toDouble() * sample
samples++
i += 2
}
return MidiRenderResult(
file = file,
seconds = read.toDouble() / WAV_FORMAT.frameSize / WAV_FORMAT.sampleRate,
sampleRate = WAV_FORMAT.sampleRate.toInt(),
peakAmplitude = peak / 32768.0,
rmsAmplitude = if (samples == 0L) 0.0 else Math.sqrt(sumSquares / samples) / 32768.0,
soundbankName = soundbank
)
} finally {
runCatching { stream.close() }
runCatching { synth.close() }
}
}
fun MidiClip.renderWav(file: File, tailSeconds: Double = 1.0, program: Int? = null): MidiRenderResult
Render this clip to a WAV. See renderToWav.
fun MidiClip.renderWav(file: File, tailSeconds: Double = 1.0, program: Int? = null): MidiRenderResult =
renderToWav(this, file, tailSeconds, program)
fun MidiClip.playTo(nameFragment: String)
Play this clip to a named MIDI output, opening and closing it around the play.
fun MidiClip.playTo(nameFragment: String) {
val handle = MidiDevices.findOutput(nameFragment)
?: error("no MIDI output matching \"$nameFragment\". Available:\n${MidiDevices.describe()}")
MidiDevices.openOutput(handle).use { it.play(this) }
}
fun MidiClip.asSequence(): Sequence = toSequence()
This clip as a javax.sound.midi.Sequence, for callers that want to drive a Sequencer.