Ein Flutter-Plugin zum Senden und Empfangen von MIDI-Nachrichten zwischen Flutter und physischen sowie virtuellen MIDI-Geräten. Umhüllt CoreMIDI und android.media.midi in einer dünnen Dart/Flutter-Schicht.
Aus öffentlichen Paketdaten erstellte Orientierung.
Ein Flutter-Plugin, das bidirektionale MIDI-Kommunikation zwischen Flutter-Apps und physischen oder virtuellen MIDI-Geräten ermöglicht. Es abstrahiert plattformspezifische MIDI-APIs – CoreMIDI auf iOS und android.media.midi auf Android – in eine einheitliche Dart-Schnittstelle und unterstützt macOS, Windows und Linux. Das Paket ermöglicht Entwicklern die nahtlose Übertragung und Empfang von MIDI-Nachrichten über Plattformen hinweg, was es ideal für Musikanwendungen mit Echtzeitsteuerung und -Feedback macht.
Anwendungsfälle
Echtzeit-Steuerelement für Musikinstrumente
MIDI-Synthesizer-Integration
Entwicklung einer Live-Auftritts-App
Digital-Audio-Workstation-(DAW)-Konnektivität
interaktive Musikspiele
Hauptmerkmale
Cross-Platform-MIDI-Unterstützung
Einheitliche Dart-API
Bidirektionale Nachrichtenverarbeitung
Unterstützung für virtuelle und Hardware-MIDI-Geräte
Plattformspezifische Einrichtung kann erforderlich sein
Keine integrierte MIDI-Geräteerkennung
Abhängig von nativen MIDI-Frameworks
Original-README
Originaler README-Schnappschuss aus dem verknüpften Quell-Repository. Aktuelle Inhalte finden Sie im Repository.
Flutter MIDI Command
A Flutter plugin for sending and receiving MIDI messages between Flutter and physical and virtual MIDI devices.
Wraps CoreMIDI/android.media.midi/ALSA/win32 in a thin Dart/Flutter layer.
Includes a built-in typed MIDI parser/generator (MidiMessageParser and MidiMessage.parse); see Message parser.
Supports
Transports
iOS
macOS
Android
Linux
Windows
Web
USB
✓
✓
✓
✓
✓
✓*
BLE
✓
✓
✓
✗
✓
✗**
Virtual
✓
✓
✓
✗
✗
✗
Network Session
✓
✓
✗
✗
✗
✗
* via browser Web MIDI API support.
** BLE MIDI on Web is not handled by flutter_midi_command_ble; Web MIDI exposure depends on browser/OS.
To install
Make sure your project is created with Kotlin and Swift support.
Add flutter_midi_command to your pubspec.yaml.
Add flutter_midi_command_ble only if you want BLE MIDI support.
Minimum platform versions in this repo:
iOS: plugin package minimum is 11.0 (packages/flutter_midi_command_darwin/ios/flutter_midi_command_darwin.podspec), while the example app currently targets 13.0 (example/ios/Podfile).
macOS: plugin package minimum is 10.13 (packages/flutter_midi_command_darwin/macos/flutter_midi_command_darwin.podspec), while the example app currently targets 10.15 (example/macos/Podfile).
Android: plugin package minimum is minSdkVersion(21) (packages/flutter_midi_command_android/android/build.gradle.kts), while the example app currently uses minSdkVersion(24) (example/android/app/build.gradle.kts).
Android BLE permissions are merged automatically when flutter_midi_command_ble is installed.
Apple usage descriptions/capabilities and packaged Windows/Linux permissions remain application-owned. See the BLE platform setup guide.
If using network MIDI on iOS, add NSLocalNetworkUsageDescription. The system local-network prompt appears the first time you call setNetworkSessionEnabled(true), not at app launch.
On Linux, make sure ALSA is installed.
On Web, use HTTPS and a browser with Web MIDI enabled (for example Chrome/Edge).
Getting Started
The snippet below shows a practical integration pattern with optional BLE, device discovery, connection, and send/receive flow.
import'dart:async';
import'package:flutter_midi_command/flutter_midi_command.dart';
import'package:flutter_midi_command/flutter_midi_command_messages.dart';
// Optional: remove this import and BLE setup if your app is native-only.import'package:flutter_midi_command_ble/flutter_midi_command_ble.dart';
classMidiSessionController{
MidiSessionController({requiredthis.enableBle});
finalbool enableBle;
finalMidiCommand midi = MidiCommand();
StreamSubscription<MidiDataReceivedEvent>? _rxSub;
StreamSubscription<MidiSetupChange>? _setupSub;
MidiDevice? selectedDevice;
Future<void> initialize() async {
if (enableBle) {
midi.configureBleTransport(UniversalBleMidiTransport());
} else {
midi.configureBleTransport(null);
midi.configureTransportPolicy(
constMidiTransportPolicy(
excludedTransports: {MidiTransport.ble},
),
);
}
// Obtain merged streams after configuring optional transports, but listen// before scanning so an early advertisement cannot be missed.
_setupSub = midi.onMidiSetupChanged?.listen((_) {
unawaited(_refreshDevices());
});
_rxSub = midi.onMidiDataReceived?.listen((event) {
_handleIncomingMessage(
event.device,
event.transport,
event.timestamp,
event.message,
);
});
if (enableBle) {
await midi.startBluetooth();
await midi.waitUntilBluetoothIsInitialized();
if (midi.bluetoothState != BluetoothState.poweredOn) {
throwStateError('Bluetooth is not powered on: ${midi.bluetoothState}');
}
await midi.startScanningForBluetoothDevices();
}
// Setup events are invalidation signals, not the initial device snapshot.await_refreshDevices();
}
Future<void> _refreshDevices() async {
final devices = await midi.devices ?? const <MidiDevice>[];
if (devices.isNotEmpty && selectedDevice == null) {
selectedDevice = devices.first;
}
}
Future<void> connectFirstMatching(String query) async {
final devices = await midi.devices ?? const <MidiDevice>[];
final q = query.toLowerCase();
final device = devices.firstWhere(
(d) => d.name.toLowerCase().contains(q),
orElse: () => throwStateError('No MIDI device found for "$query".'),
);
try {
await midi.connectToDevice(device);
} onMidiConnectionTimeoutExceptioncatch (e) {
// Timed out while waiting for a usable MIDI connection.print('Connection timed out at ${e.stage}: ${e.message}');
rethrow;
} onMidiPairingRejectedExceptioncatch (e) {
// The user rejected the OS pairing/bonding prompt, or pairing did not complete.print('Pairing rejected: ${e.message}');
rethrow;
} onMidiConnectionExceptioncatch (e) {
// Other connection-readiness failures such as service discovery,// pairing, or notification subscription.print('Connection failed at ${e.stage}: ${e.message}');
rethrow;
}
selectedDevice = device;
}
voidsendMiddleC() {
final targetId = selectedDevice?.id;
midi.sendData(
NoteOnMessage(channel: 0, note: 60, velocity: 100).generateData(),
deviceId: targetId,
);
Future<void>.delayed(constDuration(milliseconds: 200), () {
midi.sendData(
NoteOffMessage(channel: 0, note: 60, velocity: 0).generateData(),
deviceId: targetId,
);
});
}
void_handleIncomingMessage(
MidiDevice source,
MidiTransport transport,
int timestamp,
MidiMessage message,
) {
if (message isNoteOnMessage) {
// Example: route to synth engine / UI.return;
}
if (message isCCMessage) {
// Example: map controllers to parameters.return;
}
if (message isSysExMessage) {
// Example: parse manufacturer-specific payload.return;
}
// Handle other typed messages as needed (PitchBendMessage, NRPN4Message, etc).
}
Future<void> dispose() async {
await _rxSub?.cancel();
await _setupSub?.cancel();
if (enableBle) {
midi.stopScanningForBluetoothDevices();
}
midi.dispose();
}
}
BLE lifecycle
Configure the optional BLE transport once per application MIDI session, before
reading onMidiSetupChanged or onMidiDataReceived. Those getters merge the
streams that are available when they are read; a subscription created before
configureBleTransport does not later acquire the BLE stream.
startBluetooth() initializes the Bluetooth subsystem but does not start a
scan. It is idempotent, and a later call can complete without producing another
onBluetoothStateChanged event. The state stream reports transitions and does
not replay the current value to a new listener. After initialization, inspect
bluetoothState directly and explicitly call
startScanningForBluetoothDevices() when it is BluetoothState.poweredOn.
Subscribe to setup changes before starting a scan, then fetch an initial
devices snapshot after starting it. Treat every MidiSetupChange value as a
signal to refresh that snapshot; do not rely on setup events to provide initial
state or contain the changed device.
Keep MidiCommand and its BLE transport in an application/session-level owner
rather than recreating or disposing them with individual routes. Scan start and
stop are idempotent, but on affected Android devices avoiding stop/restart
cycles around a BLE connection is still important; see the known issue below.
connectToDevice completes when at least one MIDI path is ready for use, or throws. The default awaitConnectionTimeout is 30 seconds and covers the required connection-readiness flow, not just the initial radio/socket connection. For BLE MIDI this includes BLE connection, service discovery, pairing/bonding through native UI when required, and notification subscription.
On Apple platforms, a bonded peripheral may subsequently appear through CoreMIDI. That handoff is an optional, bounded background transport upgrade: direct BLE remains active until the CoreMIDI endpoint is connected, and absence or failure of the counterpart does not turn a working connection into an error. Devices whose MIDI characteristic does not require encryption therefore remain on the direct BLE path without a delayed timeout.
Connection failures are surfaced as typed MidiConnectionException subclasses where possible:
MidiConnectionTimeoutException: the readiness flow exceeded awaitConnectionTimeout.
MidiPairingRejectedException: pairing/bonding was rejected or did not complete.
MidiPairingFailedException: pairing failed before a clear rejection was reported.
MidiServiceDiscoveryException: the BLE MIDI service/characteristic was not found.
MidiNotificationSubscriptionException: BLE MIDI notifications could not be enabled.
Pass awaitConnectionTimeout: null only if you explicitly want to let the required readiness flow wait indefinitely. The optional Apple CoreMIDI handoff remains bounded.
Setup change events
Listen to onMidiSetupChanged to refresh your device list when the host MIDI topology changes. Native desktop/mobile transports monitor platform setup notifications and emit MidiSetupChange values:
MidiSetupChange.deviceAppeared: a MIDI device or logical port became available.
MidiSetupChange.deviceDisappeared: a MIDI device or logical port was removed.
MidiSetupChange.deviceStateChanged: an existing device changed name, port shape, or availability state.
MidiSetupChange.deviceConnected: this app connected to a device.
MidiSetupChange.deviceDisconnected: this app disconnected from a device, or a connected device was removed.
Android, iOS/macOS, Linux, Windows, and Web use platform notifications to wake a fresh device snapshot and emit setup events only after a real MIDI-device change is observed. BLE MIDI is scan-driven: MidiSetupChange.deviceAppeared is emitted for scan results, and connection loss is emitted as MidiSetupChange.deviceDisconnected.
On Windows, native device monitoring now keeps USB MIDI hot-plug changes in sync with devices, and multi-port WinMM endpoints are paired into full-duplex devices when matching input/output port sets can be inferred consistently.
See example/ for a complete app with:
independent transport toggles for RTP, BLE, and Virtual MIDI
a manual Refresh Devices action for the current device snapshot
a separate Scan BLE action so Bluetooth discovery does not double as general device refresh
Message parser
onMidiDataReceived already emits typed MIDI messages.
Use MidiMessageParser (or MidiMessage.parse) when you need to parse raw bytes from onMidiPacketReceived or from custom byte streams.
Keep one parser instance per input stream/device to preserve running-status and partial-message state correctly.
Supports running status.
Handles realtime bytes interleaved with channel and SysEx data.
Reassembles split packets across callback boundaries.
Recovers from malformed/incomplete byte sequences and resumes on the next valid status byte.
import'dart:typed_data';
import'package:flutter_midi_command/flutter_midi_command.dart';
import'package:flutter_midi_command/flutter_midi_command_messages.dart';
finalMidiMessageParser parser = MidiMessageParser();
voidonPacket(MidiPacket packet) {
final messages = parser.parse(packet.data, flushPendingNrpn: false);
for (final message in messages) {
if (message isNoteOnMessage) {
print('NoteOn ch=${message.channel} note=${message.note} vel=${message.velocity}');
} elseif (message isPitchBendMessage) {
print('Pitch bend ch=${message.channel} value=${message.bend}');
} elseif (message isNRPN4Message) {
print('NRPN param=${message.parameter} value=${message.value}');
} elseif (message isSysExMessage) {
print('SysEx bytes=${message.data.length}');
}
}
}
voidonStreamClosed() {
// Flush pending partial NRPN/RPN state, if any.final flushed = parser.parse(Uint8List(0), flushPendingNrpn: true);
for (final message in flushed) {
// Handle final pending message.
}
parser.reset();
}
final midi = MidiCommand();
midi.configureBleTransport(UniversalBleMidiTransport());
If you want to remove BLE entirely, omit flutter_midi_command_ble and/or call:
midi.configureBleTransport(null);
The BLE transport sizes its packets from the negotiated ATT MTU and asks for a
low-latency connection interval, both on by default. These affect Android,
Windows and Linux; on iOS and macOS the device is handed off to CoreMIDI after
connecting, so the OS owns the write path. See
the BLE package README
for the platform matrix, when to opt out, and for onBleWriteFailure, which
reports writes the platform rejected.
For local workspace development (like this monorepo), path: dependencies are still valid and used by the example app.
2) Bluetooth API rename
Old: startBluetoothCentral()
New: startBluetooth()
onBluetoothStateChanged and bluetoothState are still available.
3) MidiDevice.type changed from String to enum
Use MidiDeviceType instead of string comparisons:
if (device.type == MidiDeviceType.ble) {
// ...
}
If you still need old wire values for logging or compatibility, use device.type.wireValue.
4) Connection semantics are stricter
await midi.connectToDevice(device) now resolves only when a MIDI path is ready for use (or throws on failure/timeout), so completion means a real connected state. The default timeout is 30 seconds and is a required connection-readiness budget. For BLE MIDI it covers BLE connection, service discovery, native pairing/bonding UI if required, and notification subscription. On Apple, a bonded device can switch transparently to CoreMIDI after completion; that optional handoff does not delay or fail the usable BLE connection.
Connection failures use typed exceptions such as MidiConnectionTimeoutException and MidiPairingRejectedException, all deriving from MidiConnectionException.
MidiDevice also exposes onConnectionStateChanged for reactive flows.
5) Transport policies are first-class
Use MidiTransportPolicy to enable/disable transports at runtime. Transport-specific calls throw StateError when that transport is disabled.
6) Host-paired Bluetooth MIDI devices may be native-routed
A host-native device can report MidiDeviceType.ble while still communicating through host MIDI APIs (for example paired CoreMIDI/Android host devices). Do not assume type == ble always means Dart BLE transport is used internally.
7) Virtual device and network session APIs return futures
addVirtualDevice, removeVirtualDevice and setNetworkSessionEnabled return Future<void> instead of void. They previously discarded the platform call, so a failure such as PlatformException(AUDIOERROR, Error -2 while create MIDI virtual source) surfaced as an unhandled asynchronous error that no try/catch around the call could see.
Await them to handle failures:
try {
await midi.addVirtualDevice(name: 'My App');
} onPlatformExceptioncatch (err) {
// The virtual source was not created.
}
Existing calls that ignore the result keep compiling. Custom MidiCommandPlatform implementations must widen these three overrides from void to Future<void>.
For help getting started with Flutter, view our online
documentation.
For help on editing plugin code, view the documentation.
Known issues
Android: BLE scan returns nothing after a connect/disconnect (needs app restart)
On some Android devices, after connecting to and then disconnecting from a BLE
MIDI peripheral, a subsequent startScanningForBluetoothDevices() registers a
scanner successfully but delivers no advertisements for any device until
the app process is restarted.
This is an upstream bug in universal_ble's Android layer / the platform BLE
stack: the LE scanner is stopped and restarted around the GATT connection, and
the reused ScanCallback gets desynced from the platform's scanner registration
(BluetoothLeScanner: could not find callback wrapper). It cannot be recovered
from Dart — the plugin already makes scan start/stop idempotent to minimise the
trigger, but the wedge originates below the Dart layer.
Workarounds until the upstream fix lands:
Keep a single continuous scan while on your device picker; avoid stopping and
restarting scanning around connect/disconnect.
Persist the device id and look for a matching MidiDevice in midi.devices
when the host still exposes the bonded endpoint, then pass that object to
connectToDevice. The method does not accept a raw id; if the endpoint is no
longer present in devices, rediscovery is still required.
As a last resort, restart the app process to clear the wedged scanner.
Workspace and architecture
This repository is now managed as a melos monorepo.
Packages
flutter_midi_command (this package): public API and transport policies
packages/flutter_midi_command_linux: Linux host MIDI wrapper
packages/flutter_midi_command_windows: Windows host MIDI wrapper
packages/flutter_midi_command_ble: shared BLE MIDI transport using universal_ble
packages/flutter_midi_command_web: browser Web MIDI transport
See packages/flutter_midi_command_web/README.md for web-specific runtime/permission details.
Transport policies
You can include/exclude transports at runtime:
final midi = MidiCommand();
midi.configureTransportPolicy(
constMidiTransportPolicy(
excludedTransports: {MidiTransport.ble},
),
);
When a transport is disabled, transport-specific calls throw a StateError.
Device types
MidiDevice.type is now strongly typed as MidiDeviceType (for example MidiDeviceType.serial, MidiDeviceType.ble, MidiDeviceType.virtual).
Device connection state
Each MidiDevice now exposes connection state updates:
final sub = selectedDevice.onConnectionStateChanged.listen((state) {
// state is MidiConnectionState.disconnected/connecting/connected/disconnecting
});
Compile-time BLE include/exclude
Direct BLE scan/connect is optional at dependency level:
If you only depend on flutter_midi_command, no shared Dart BLE scanner/transport is attached.
To include shared Dart BLE discovery/connection, add flutter_midi_command_ble and attach it to MidiCommand:
import'package:flutter_midi_command/flutter_midi_command.dart';
import'package:flutter_midi_command_ble/flutter_midi_command_ble.dart';
final midi = MidiCommand();
midi.configureBleTransport(UniversalBleMidiTransport());
To disable BLE completely:
midi.configureBleTransport(null);
Note: paired Bluetooth MIDI devices exposed by host native MIDI APIs can still appear in MidiCommand().devices with MidiDeviceType.ble and connect through the native backend.
The normal BLE API remains unchanged:
await midi.startBluetooth();
await midi.startScanningForBluetoothDevices();
final state = midi.bluetoothState;
final stateStream = midi.onBluetoothStateChanged;
BLE connectToDevice completion means the BLE MIDI path is ready. On platforms without an explicit pairing API, such as Apple platforms, the transport triggers native pairing/bonding by accessing the encrypted MIDI characteristic and waits for that readiness step to finish or fail.
Architecture note
MidiCommandPlatform now only describes native host MIDI operations.
Shared BLE discovery/connection lives in MidiBleTransport, implemented in Dart (flutter_midi_command_ble).
Host-native backends may also report paired Bluetooth devices as MidiDeviceType.ble.
Web MIDI is implemented by flutter_midi_command_web using browser Web MIDI APIs.
Native API contracts with Pigeon
Pigeon definitions are tracked in pigeons/midi_api.dart and should be used as the source-of-truth for generated host/flutter messaging code.