aud_midi_android 0.1.0
aud_midi_android: ^0.1.0 copied to clipboard
Android backend for aud_midi: android.media.midi via JNI, AMidi via FFI
aud_midi_android #
The Android backend of aud_midi: android.media.midi through jnigen
bindings and a small Java shim, AMidi through FFI for sending.
Part of the aud_midi family, see aud_midi.
Goals #
- USB, Bluetooth LE and virtual MIDI devices as ports
- MIDI 1.0 byte-stream devices and, from API 33, Universal MIDI Packets
- Android timestamps on input, AMidi with timestamps on output
- Java shim for the abstract callback classes, no MethodChannel
- The app's own virtual devices:
MidiDeviceService,MidiUmpDeviceService NsdManageradvertising and a multicast lock for network sessions- BLE peripheral through BluetoothGattServer
State #
AndroidMidiBackend (name android) implements MidiBackend of
aud_midi_core:
- Ports: every port of every device the MIDI service knows: USB,
Bluetooth and the virtual devices of other apps; from API 33 both
transports (
getDevicesForTransport). Android's input port, data into the device, is an output of the app and vice versa. Ids areandroid:<device id>:in|out:<port number>; Android assigns a new device id on re-plug, thefingerprintfinds the candidates. Byte-stream devices speak MIDI 1.0; UMP devices take their protocol fromgetDefaultProtocoland exchange UMP words, big-endian in Android's byte arrays. - Receiving: the shim's
AudMidiReceivercopies every chunk with its timestamp into a buffer of 64 KiB per port and signals the isolate once; the isolate drains all chunks with one JNI call. A full buffer drops the newest chunk and counts it (queueOverflow). The timestamps,System.nanoTime()=CLOCK_MONOTONIC, become package times throughMidiClockMapper, measured again every 10 s and on hotplug. - Sending: AMidi
sendWithTimestampfrom API 29,MidiInputPort.sendbelow or when AMidi refuses a device (nativeErrordiagnostic). Chunks have at most 1012 bytes and never split a UMP. Outputs to USB and Bluetooth devices reportscheduledSendandcancelPending: the MIDI service holds the data until they are due and drops them on a flush. A virtual device gets the timestamp with the data and decides itself, so the engine schedules for it. - Hotplug:
AudMidiDeviceCallbackper transport → added, removed and changed ports; the open ports of a removed device are closed. virtualPorts(static): the ports of the app's ownAudMidiDeviceServiceandAudMidiUmpDeviceService(or subclasses) from the manifest, listed withisOwn.create(spec)hands out a declared port, named like the spec or the first free one; Android creates none at runtime.bluetooth: Bluetooth LE scan with the BLE-MIDI service UUID filter,connectthroughopenBluetoothDevice; the peripheral stays open untildisconnect. Permissions per API level; a missing one throwsMidiPermissionDeniedand shows incapabilities.missingPermissions.networkis null: Android has no network session. The umbrella composes the AppleMIDI session of aud_midi_network withMidiAndroidServiceAdvertiser(NsdManager); mDNS browsing needs aMidiAndroidMulticastLock.- Isolates: package:jni loads classes through the application class loader
its plugin captured and attaches threads on demand, so the backend runs
in any isolate. The application context comes from the shim's content
provider, or from the
contextparameter.
Verified on the Android emulator (API 36, arm64) with the example app in a background isolate, debug and release build (R8):
| Check | Result |
|---|---|
| Enumeration | MIDI 1.0 and UMP loopbacks, own MIDI 1.0 and UMP devices with isOwn |
| MIDI 1.0 loopback through AMidi, 3018 bytes with a 3000-byte SysEx | bytes and order unchanged, timestamps back within 0–2 µs |
| Packet due in 200 ms | timestamp equals the due time; arrives at once, a virtual device does not schedule |
| UMP loopback through AMidi, 607 words with 300 MIDI 2.0 notes | words and order unchanged |
| Latency from the Java receiver to Dart | 0.15–2.5 ms release, 7–12 ms debug |
| Own virtual devices, MIDI 1.0 and UMP | data in both directions, the other client sending through MidiInputPort and AMidi |
System.nanoTime() stamp against the CLOCK_MONOTONIC reader |
arrival 46–169 µs after the stamp: one clock |
cancelPending, MidiInputPort.flush |
no error |
| Bluetooth LE scan | starts and stops, finds nothing on the emulator |
NsdManager, multicast lock |
_apple-midi._udp registered and unregistered; lock held |
MidiEngine over AndroidMidiBackend |
typed messages unchanged; a note scheduled in software 1.5–1.9 ms after due; a MIDI 1.0 Note On to a MIDI 2.0 port arrives as MIDI 2.0 |
| Release build | R8 keeps all shim classes; same report |
Not verified: USB devices and real BLE-MIDI peripherals (no hardware),
API levels below 33 (no UMP) and below 29 (sending only through
MidiInputPort; that path ran on API 36 as the client of the own
device), hotplug of real devices. Their logic is tested with fakes.
Findings that shape the behaviour:
- Android cuts data longer than 1015 bytes into packets, inside UMP words too; the backend splits UMP data itself, between packets.
- A
MidiDeviceServicereceives the timestamp but nothing schedules for it; only USB and Bluetooth devices sit behind the scheduler of the MIDI service. - The
ServiceInfoof a virtual device lies under the hidden property keyservice_info; the shim reads it to recognise the app's own devices. - package:jni finds the shim classes from background isolates without extra setup; no class loader workaround is needed.
Installation #
dart pub add aud_midi_android
The package is an Android plugin without a Flutter dependency: Flutter
builds the Java shim into the app, R8 keeps it through the shim's
consumer-rules.pro. minSdk 24; AMidi is used from API 29, UMP devices
appear from API 33.
The app declares in AndroidManifest.xml:
<uses-feature android:name="android.software.midi" android:required="true" />
<!-- Bluetooth LE MIDI -->
<uses-feature android:name="android.hardware.bluetooth_le" android:required="false" />
<uses-permission android:name="android.permission.BLUETOOTH_SCAN"
android:usesPermissionFlags="neverForLocation" />
<uses-permission android:name="android.permission.BLUETOOTH_CONNECT" />
<uses-permission android:name="android.permission.BLUETOOTH" android:maxSdkVersion="30" />
<uses-permission android:name="android.permission.BLUETOOTH_ADMIN" android:maxSdkVersion="30" />
<uses-permission android:name="android.permission.ACCESS_FINE_LOCATION" android:maxSdkVersion="30" />
<!-- Network sessions -->
<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.CHANGE_WIFI_MULTICAST_STATE" />
<uses-permission android:name="android.permission.ACCESS_WIFI_STATE" />
The app requests the runtime permissions itself: BLUETOOTH_SCAN and
BLUETOOTH_CONNECT from API 31, ACCESS_FINE_LOCATION below.
A virtual device of the app, inside <application>:
<service
android:name="com.audanika.aud_midi_android.AudMidiDeviceService"
android:permission="android.permission.BIND_MIDI_DEVICE_SERVICE"
android:exported="true">
<intent-filter>
<action android:name="android.media.midi.MidiDeviceService" />
</intent-filter>
<meta-data
android:name="android.media.midi.MidiDeviceService"
android:resource="@xml/midi_device_info" />
</service>
with res/xml/midi_device_info.xml:
<devices>
<device manufacturer="My company" product="My app">
<input-port name="My app in" />
<output-port name="My app out" />
</device>
</devices>
A UMP device (API 33) uses AudMidiUmpDeviceService with the action
android.media.midi.MidiUmpDeviceService, a <property> instead of
<meta-data> and <port name="…" /> entries. Several devices need one
subclass each.
Documentation #
- The plan of the aud_midi family
- Example app
- Guides
Code Examples #
import 'package:aud_midi_android/aud_midi_android.dart';
import 'package:aud_midi_core/aud_midi_core.dart';
import 'package:aud_midi_standard/aud_midi_standard.dart';
// Runs in an isolate of an Android app, e.g. the MIDI isolate.
Future<void> main() async {
final engine = MidiEngine(backend: AndroidMidiBackend());
await engine.open();
for (final port in engine.outputs) {
final output = await engine.openOutput(port.id);
await output.send(const MidiNoteOn(channel: 0, note: 60, velocity: 100));
}
await engine.close();
final registration = await MidiAndroidServiceAdvertiser().register(
name: 'My session',
type: '_apple-midi._udp',
port: 5004,
);
await registration.unregister();
}
Regenerating the bindings #
The bindings are generated files in lib/src/**/*.g.dart. After a change
of the Java shim, jnigen.yaml or ffigen.yaml:
# jnigen: android.media.midi, Bluetooth, NsdManager, WifiManager, the shim
export JAVA_HOME="/Applications/Android Studio.app/Contents/jbr/Contents/Home"
export ANDROID_SDK_ROOT="$HOME/Library/Android/sdk"
dart run jnigen:setup # once: builds the API summarizer
dart run jnigen --config jnigen.yaml
# ffigen: AMidi from the NDK
NDK="$ANDROID_SDK_ROOT/ndk/28.2.13676358"
SYSROOT="$NDK/toolchains/llvm/prebuilt/darwin-x86_64/sysroot"
dart run ffigen --config ffigen.yaml \
--compiler-opts "--target=aarch64-linux-android33 --sysroot=$SYSROOT"
Both use the Dart of the Flutter SDK (package:jni needs it) and produce the same files again.
How It Works #
- Dart talks to Android only through
MidiAndroidPlatform. Its JNI implementation and the AMidi glue are thin and run on Android only; everything else is plain Dart, tested on the development machine with fakes. - Java threads never wait for Dart: the shim's callbacks are listeners that post to the isolate's port and return.
- Receiving: MIDI thread →
AudMidiReceiver.onSendcopies → one signal → the isolate drains the buffer → decode → port packets with times. - Sending: package time →
CLOCK_MONOTONICthrough the clock mapper → AMidi orMidiInputPortwith that timestamp.
Contributing #
See doc/guides/develop-guide.md.