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.
- Make sure your project is created with Kotlin and Swift support.
- Add
flutter_midi_commandto yourpubspec.yaml. - Add
flutter_midi_command_bleonly 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 targets13.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 targets10.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 usesminSdkVersion(24)(example/android/app/build.gradle.kts).
- iOS: plugin package minimum is
- If BLE is enabled on iOS, add
NSBluetoothAlwaysUsageDescription(and related Bluetooth/location keys as required by your BLE flow) toInfo.plist. - If using network MIDI on iOS, add
NSLocalNetworkUsageDescription. - On Linux, make sure ALSA is installed.
- On Web, use HTTPS and a browser with Web MIDI enabled (for example Chrome/Edge).
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';
class MidiSessionController {
MidiSessionController({required this.enableBle});
final bool enableBle;
final MidiCommand midi = MidiCommand();
StreamSubscription<MidiDataReceivedEvent>? _rxSub;
StreamSubscription<String>? _setupSub;
MidiDevice? selectedDevice;
Future<void> initialize() async {
if (enableBle) {
midi.configureBleTransport(UniversalBleMidiTransport());
await midi.startBluetooth();
await midi.waitUntilBluetoothIsInitialized();
await midi.startScanningForBluetoothDevices();
} else {
midi.configureBleTransport(null);
midi.configureTransportPolicy(
const MidiTransportPolicy(
excludedTransports: {MidiTransport.ble},
),
);
}
_setupSub = midi.onMidiSetupChanged?.listen((_) async {
final devices = await midi.devices ?? const <MidiDevice>[];
if (devices.isNotEmpty && selectedDevice == null) {
selectedDevice = devices.first;
}
});
_rxSub = midi.onMidiDataReceived?.listen((event) {
_handleIncomingMessage(
event.device,
event.transport,
event.timestamp,
event.message,
);
});
}
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: () => throw StateError('No MIDI device found for "$query".'),
);
await midi.connectToDevice(device);
selectedDevice = device;
}
void sendMiddleC() {
final targetId = selectedDevice?.id;
midi.sendData(
NoteOnMessage(channel: 0, note: 60, velocity: 100).generateData(),
deviceId: targetId,
);
Future<void>.delayed(const Duration(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 is NoteOnMessage) {
// Example: route to synth engine / UI.
return;
}
if (message is CCMessage) {
// Example: map controllers to parameters.
return;
}
if (message is SysExMessage) {
// 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();
}
}connectToDevice completes when the connection is established, throws StateError on connection failure, and times out after 10 seconds by default.
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 Devicesaction for the current device snapshot - a separate
Scan BLEaction so Bluetooth discovery does not double as general device refresh
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';
final MidiMessageParser parser = MidiMessageParser();
void onPacket(MidiPacket packet) {
final messages = parser.parse(packet.data, flushPendingNrpn: false);
for (final message in messages) {
if (message is NoteOnMessage) {
print('NoteOn ch=${message.channel} note=${message.note} vel=${message.velocity}');
} else if (message is PitchBendMessage) {
print('Pitch bend ch=${message.channel} value=${message.bend}');
} else if (message is NRPN4Message) {
print('NRPN param=${message.parameter} value=${message.value}');
} else if (message is SysExMessage) {
print('SysEx bytes=${message.data.length}');
}
}
}
void onStreamClosed() {
// 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();
}For simple one-shot payloads you can also call:
final messages = MidiMessage.parse(packet.data);With native transports only:
dependencies:
flutter_midi_command: ^1.0.0With BLE support enabled:
dependencies:
flutter_midi_command: ^1.0.0
flutter_midi_command_ble: ^1.0.0If you previously relied on built-in BLE behavior, add and attach the BLE transport explicitly:
dependencies:
flutter_midi_command: ^1.0.0
flutter_midi_command_ble: ^1.0.0final midi = MidiCommand();
midi.configureBleTransport(UniversalBleMidiTransport());If you want to remove BLE entirely, omit flutter_midi_command_ble and/or call:
midi.configureBleTransport(null);For local workspace development (like this monorepo), path: dependencies are still valid and used by the example app.
- Old:
startBluetoothCentral() - New:
startBluetooth()
onBluetoothStateChanged and bluetoothState are still available.
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.
await midi.connectToDevice(device) now resolves only when connected (or throws on failure/timeout), so completion means a real connected state.
MidiDevice also exposes onConnectionStateChanged for reactive flows.
Use MidiTransportPolicy to enable/disable transports at runtime. Transport-specific calls throw StateError when that transport is disabled.
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.
For help getting started with Flutter, view our online documentation.
For help on editing plugin code, view the documentation.
This repository is now managed as a melos monorepo.
flutter_midi_command(this package): public API and transport policiespackages/flutter_midi_command_platform_interface: shared platform contractspackages/flutter_midi_command_linux: Linux host MIDI wrapperpackages/flutter_midi_command_windows: Windows host MIDI wrapperpackages/flutter_midi_command_ble: shared BLE MIDI transport usinguniversal_blepackages/flutter_midi_command_web: browser Web MIDI transport Seepackages/flutter_midi_command_web/README.mdfor web-specific runtime/permission details.
You can include/exclude transports at runtime:
final midi = MidiCommand();
midi.configureTransportPolicy(
const MidiTransportPolicy(
excludedTransports: {MidiTransport.ble},
),
);When a transport is disabled, transport-specific calls throw a StateError.
MidiDevice.type is now strongly typed as MidiDeviceType (for example MidiDeviceType.serial, MidiDeviceType.ble, MidiDeviceType.virtual).
Each MidiDevice now exposes connection state updates:
final sub = selectedDevice.onConnectionStateChanged.listen((state) {
// state is MidiConnectionState.disconnected/connecting/connected/disconnecting
});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_bleand attach it toMidiCommand:
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;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.
Pigeon definitions are tracked in pigeons/midi_api.dart and should be used as the source-of-truth for generated host/flutter messaging code.