週末急に思い立ってiPadでMIDIピアノの入力に反応するアプリを作ってみようと思ったのですが、Core MIDI周りの情報はObjective-C止まりでアップデートされていないものが多く、ましてSwiftとなると英語でもほとんど情報がないので結構困りました(そもそもAppleがMIDI関連の情報発信についてやる気ない)。ニーズが果たしてどれほどあるのかわかりませんが、一応メモとして残しておきます。
また、今回作成したバイナリはCatalyst経由でmacOS上でも動いたので、macの場合もSwiftを使う限りは同じやり方で行けると思います。
目標
MIDIキーボードから入力されたイベントに従ってiOSアプリ上で絵を出す。
iPadからMIDIピアノを鳴らすなどの送信系は今回扱っていません。
流れ
Swiftと言ってもCore MIDIをつつく以上、大きな流れはObjective-Cの場合と変わりません。下記のサイトは10年以上前のものですが、大いに参考にさせていただきました。
やることをまとめると、
- MIDIデバイスの一覧を取得
- 使用したいデバイスに対するMIDIClientを作成
- MIDIClientで使うMIDIInputPort(データの受け口)を作成
- 使用したいデバイスのMIDIEndpointと、MIDIInputPortを接続
- 飛んでくるMIDIメッセージの処理
という感じです。
1. MIDIデバイスの一覧を取得
import CoreMIDI
import os.log
var numberOfSources = 0
var sourceName = [String]()
func findMIDISources() {
sourceName.removeAll()
numberOfSources = MIDIGetNumberOfSources()
os_log("%i Device(s) found", numberOfSources)
for i in 0 ..< numberOfSources {
let src = MIDIGetSource(i)
var cfStr: Unmanaged<CFString>?
let err = MIDIObjectGetStringProperty(src, kMIDIPropertyName, &cfStr)
if err == noErr {
if let str = cfStr?.takeRetainedValue() as String? {
sourceName.append(str)
os_log("Device #%i: %s", i, str)
}
}
}
}
まず、MIDIGetNumberOfSources()
関数を使用して、接続されているMIDIデバイス数を取得します。次にループ内でMIDIGetSource()
を呼び、インデックス番号に対応したMIDIデバイスを取得します。
このデバイスに対してMIDIObjectGetStringProperty()
を呼んであげることでデバイスの名称が取得できます。このデバイス名称は次のMIDIClientを作成する際に必要となります。
注意点はCore MIDIはCore Foundationを使用するC言語ベースのフレームワークなので、文字列はString
ではなくCFString
であり、文字列の取得はCFString
のポインタを渡す形で行う点です。取得されたCFString
は自動でメモリ管理されないため、takeRetainedValue()
を呼んだ上でSwiftの文字列に変換します。
#2. MIDIClientを作成
let name = NSString(string: sourceName[index])
var client = MIDIClientRef()
var err = MIDIClientCreateWithBlock(name, &client, onMIDIStatusChanged)
if err != noErr {
os_log(.error, "Failed to create client")
return
}
os_log("MIDIClient created")
func onMIDIStatusChanged(message: UnsafePointer<MIDINotification>) {
os_log("MIDI Status changed!")
}
次に取得したデバイス名から、MIDIClientを作成します。この際、ステータスが変化した際のコールバックを指定するのですが、これまでCore MIDIはMIDIClientCreate()
という関数しかなく、コールバックもCの関数ポインタ形式で非常に面倒くさかったのですが、iOS 9.0からMIDIClientCreateWithBlock()
という関数が追加され、コールバックにSwiftのクロージャを指定できるようになったのでめちゃめちゃ分かりやすくなりました。Core MIDI全然アップデートねえじゃねえかとか言ってすみません。ここではonMIDIStatusChanged()
という関数を別に定義して、それを指定しています。
3. MIDIInputPortを作成
let portName = NSString("inputPort")
var port = MIDIPortRef()
err = MIDIInputPortCreateWithBlock(client, portName, &port, onMIDIMessageReceived)
if err != noErr {
os_log("Failed to create input port")
return
}
os_log("MIDIInputPort created")
func onMIDIMessageReceived(message: UnsafePointer<MIDIPacketList>, srcConnRefCon: UnsafeMutableRawPointer?) {
os_log("MIDI Message Received")
}
MIDIInputPortの作成も、MIDIInputPortCreateWithBlock()
関数ができたのでそちらを使います。実際にMIDIメッセージを受信した際に呼ばれるのはここで指定したコールバックです。(MIDIメッセージの解析はまた後ほどで行います)
4. MIDIEndpointとMIDIInputPortを接続
let src = MIDIGetSource(index) // MIDIEndpointRef
err = MIDIPortConnectSource(port, src, nil)
if err != noErr {
os_log("Failed to connect MIDIEndpoint")
return
}
os_log("MIDIEndpoint connected to InputPort")
あとはそのままの流れで、最初に取得したMIDIEndpointと作成したMIDIInputPortを接続すればOKです。
5. MIDIメッセージの処理
最後にコールバックの中で受け取ったMIDIメッセージを解析し、内容に合わせた処理を行います。
func onMIDIMessageReceived(message: UnsafePointer<MIDIPacketList>, srcConnRefCon: UnsafeMutableRawPointer?) {
let packetList: MIDIPacketList = message.pointee
let n = packetList.numPackets
//os_log("%i MIDI Message(s) Received", n)
var packet = packetList.packet
for _ in 0 ..< n {
// Handle MIDIPacket
let mes: UInt8 = packet.data.0 & 0xF0
let ch: UInt8 = packet.data.0 & 0x0F
if mes == 0x90 && packet.data.2 != 0 {
// Note On
os_log("Note ON")
let noteNo = packet.data.1
let velocity = packet.data.2
DispatchQueue.main.async {
self.delegate?.noteOn(ch: ch, note: noteNo, vel: velocity)
}
} else if (mes == 0x80 || mes == 0x90) {
// Note Off
os_log("Note OFF")
let noteNo = packet.data.1
let velocity = packet.data.2
DispatchQueue.main.async {
self.delegate?.noteOff(ch: ch, note: noteNo, vel: velocity)
}
}
let packetPtr = MIDIPacketNext(&packet)
packet = packetPtr.pointee
}
}
MIDIメッセージはMIDIPacketList
というデータ型にまとめて入っており、MIDIPacketNext()
を呼んでやることで次のデータへのポインタが返ってくるようになっています。Appleのページによれば以下のような使い方が正しいそうです(そもそもSwiftじゃないですが...)。
MIDIPacket *packet = &packetList->packet[0];
for (int i = 0; i < packetList->numPackets; ++i) {
// ...
packet = MIDIPacketNext (packet);
}
なんかこれだと最後にMIDIPacketNext()
が一回余分に呼ばれてる感じがしますが、Appleがいいと言うのでたぶんいいのでしょう。
データの中身は.data.[0-255]
でアクセスできます。最初の1バイトが0x9nの場合ノートオン、0x8nの場合はノートオフで、nはチャンネル番号を表します。またノートオン/オフの場合は続く2バイトでノートナンバー(音高)とベロシティ(強さ)が取得できるので、これらの情報を使って音の高さ、強さに合わせた処理を書けばよいでしょう。プロトコルを定義しておいてdelegateを設定できるようにしておくと楽かもしれません。
- [参考] MIDIの学習
また、このコールバックですがメインスレッドとは別に呼ばれるっぽいので、UIやグラフィックに変更を加える際は、DispatchQueueなどを介してメインスレッドで実行されるように書いてあげるのが安全だと思われます。
まとめ
これらの処理をクラスにまとめると以下のような感じになります。
import Foundation
import CoreMIDI
import os.log
protocol MIDIManagerDelegate {
func noteOn(ch: UInt8, note: UInt8, vel: UInt8)
func noteOff(ch: UInt8, note: UInt8, vel: UInt8)
}
class MIDIManager {
var numberOfSources = 0
var sourceName = [String]()
var delegate: MIDIManagerDelegate?
init() {
findMIDISources()
}
func findMIDISources() {
sourceName.removeAll()
numberOfSources = MIDIGetNumberOfSources()
os_log("%i Device(s) found", numberOfSources)
for i in 0 ..< numberOfSources {
let src = MIDIGetSource(i)
var cfStr: Unmanaged<CFString>?
let err = MIDIObjectGetStringProperty(src, kMIDIPropertyName, &cfStr)
if err == noErr {
if let str = cfStr?.takeRetainedValue() as String? {
sourceName.append(str)
os_log("Device #%i: %s", i, str)
}
}
}
}
func connectMIDIClient(_ index: Int) {
if 0 <= index && index < sourceName.count {
// Create MIDI Client
let name = NSString(string: sourceName[index])
var client = MIDIClientRef()
var err = MIDIClientCreateWithBlock(name, &client, onMIDIStatusChanged)
if err != noErr {
os_log(.error, "Failed to create client")
return
}
os_log("MIDIClient created")
// Create MIDI Input Port
let portName = NSString("inputPort")
var port = MIDIPortRef()
err = MIDIInputPortCreateWithBlock(client, portName, &port, onMIDIMessageReceived)
if err != noErr {
os_log("Failed to create input port")
return
}
os_log("MIDIInputPort created")
// Connect MIDIEndpoint to MIDIInputPort
let src = MIDIGetSource(index)
err = MIDIPortConnectSource(port, src, nil)
if err != noErr {
os_log("Failed to connect MIDIEndpoint")
return
}
os_log("MIDIEndpoint connected to InputPort")
}
}
func onMIDIStatusChanged(message: UnsafePointer<MIDINotification>) {
os_log("MIDI Status changed!")
}
func onMIDIMessageReceived(message: UnsafePointer<MIDIPacketList>, srcConnRefCon: UnsafeMutableRawPointer?) {
let packetList: MIDIPacketList = message.pointee
let n = packetList.numPackets
//os_log("%i MIDI Message(s) Received", n)
var packet = packetList.packet
for _ in 0 ..< n {
// Handle MIDIPacket
let mes: UInt8 = packet.data.0 & 0xF0
let ch: UInt8 = packet.data.0 & 0x0F
if mes == 0x90 && packet.data.2 != 0 {
// Note On
os_log("Note ON")
let noteNo = packet.data.1
let velocity = packet.data.2
DispatchQueue.main.async {
self.delegate?.noteOn(ch: ch, note: noteNo, vel: velocity)
}
} else if (mes == 0x80 || mes == 0x90) {
// Note Off
os_log("Note OFF")
let noteNo = packet.data.1
let velocity = packet.data.2
DispatchQueue.main.async {
self.delegate?.noteOff(ch: ch, note: noteNo, vel: velocity)
}
}
let packetPtr = MIDIPacketNext(&packet)
packet = packetPtr.pointee
}
}
}
このクラスを使う側(ViewController)の処理としてはこんな感じです。
class MyViewController: UIViewController, MIDIManagerDelegate {
private var midi: MIDIManager?
override func viewDidLoad() {
super.viewDidLoad()
midi = MIDIManager()
if 0 < midi!.numberOfSources {
midi!.connectMIDIClient(0)
midi!.delegate = self
}
}
func noteOn(ch: UInt8, note: UInt8, vel: UInt8) {
// ノートオン時の処理
}
func noteOff(ch: UInt8, note: UInt8, vel: UInt8) {
// ノートオフ時の処理
}
不十分なところ多々あると思いますがご容赦ください!
AppleはMIDIのサポートちゃんと続けていってくれるのだろうか...。