21
14

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 3 years have passed since last update.

SwiftでCore MIDI

Last updated at Posted at 2020-05-17

Untitled.mov.gif

週末急に思い立ってiPadでMIDIピアノの入力に反応するアプリを作ってみようと思ったのですが、Core MIDI周りの情報はObjective-C止まりでアップデートされていないものが多く、ましてSwiftとなると英語でもほとんど情報がないので結構困りました(そもそもAppleがMIDI関連の情報発信についてやる気ない)。ニーズが果たしてどれほどあるのかわかりませんが、一応メモとして残しておきます。

また、今回作成したバイナリはCatalyst経由でmacOS上でも動いたので、macの場合もSwiftを使う限りは同じやり方で行けると思います。

目標

MIDIキーボードから入力されたイベントに従ってiOSアプリ上で絵を出す。
iPadからMIDIピアノを鳴らすなどの送信系は今回扱っていません。

流れ

Swiftと言ってもCore MIDIをつつく以上、大きな流れはObjective-Cの場合と変わりません。下記のサイトは10年以上前のものですが、大いに参考にさせていただきました。

やることをまとめると、

  1. MIDIデバイスの一覧を取得
  2. 使用したいデバイスに対するMIDIClientを作成
  3. MIDIClientで使うMIDIInputPort(データの受け口)を作成
  4. 使用したいデバイスのMIDIEndpointと、MIDIInputPortを接続
  5. 飛んでくるMIDIメッセージの処理

という感じです。

midi.png

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を設定できるようにしておくと楽かもしれません。

また、このコールバックですがメインスレッドとは別に呼ばれるっぽいので、UIやグラフィックに変更を加える際は、DispatchQueueなどを介してメインスレッドで実行されるように書いてあげるのが安全だと思われます。

まとめ

これらの処理をクラスにまとめると以下のような感じになります。

MIDIManager.swift
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)の処理としてはこんな感じです。

MyViewController.swift
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のサポートちゃんと続けていってくれるのだろうか...。

21
14
2

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
21
14

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?