ホームページ開発ツール>Xojo / Real Studio Trial and Error・CocoaのDeclareでMIDIを鳴らす・録音する

 Xojo / Real Studio Trial and Error

CocoaのDeclareでMIDIを鳴らす・録音する

目次
 はじめに

 以下は、Xojo Cocoa 64bitビルドについての話題です。

 MIDI演奏時のサウンドを録音できないか、調べてみました。

 なお検証には、Xojo 2017 Release 1.1を用いています。(Mac mini 2018 + macOS 10.14.6 Mojave)


 方針

 当初は、「現在発している音をキャプチャーする標準的なAPI」があるのかと思って探したのですが、見つかりませんでした。
 ではどうするのかと思ったら、「仮想オーディオデバイス」なるものを使う、というのが作法のようでした。
 検索すると、SoundFlowerというのがポピュラー、とのことだったので、インストールしてみました。
(注:最近のOSでは、セキュリティの関係でインストールに失敗してしまう(こちらではそうだった)が、サイトの対処法通りにすればOK。)

 参考サイト(1):Releases · mattingalls/Soundflower · GitHub

 使い方は様々なサイトに解説がありますが、例えば以下を参考にして、試しにiTunesで音楽を鳴らし、QuickTime Playerで録音してみたところ、できました。

 参考サイト(2):Mac内で鳴っている音を録音する方法 | Macガレージ

 どういう仕組みなんだろうと、ソースも公開されているので眺めたりもしてみましたが、どうも出力IOAudioStreamを入力IOAudioStreamに渡しているような感じがしました。(デバドラはよく分からないので、的外れかも。)

 で、仕組みはともかく(汗)、入力は確保できたので、あとはマイク等からの録音と同じ考え方でいけそうです。
 ということで、まずはAVAudioRecorderを使うものを試してみたところ、うまくいきました。
(注:以下のサイトはiOS向けのため、AVAudioSessionの設定があるが、macOSにはないので不要。)

 参考サイト(3):iOS 音声録音(AVAudioRecorder) | Professional Programmer

 他にAVCaptureSessionを使う方法もあって、こちらも試してうまくいきましたが、今回はよりシンプルなAVAudioRecorderでいくことにします。
(注:AVCaptureSession実験時のソースコードはこちら。)

 さて、これで録音はできるようになったのですが、一つ困ったことが。
 それは、AVAudioEngineを使うと、サウンドの出力を内蔵スピーカーにしても、音が出ないことです。
 ちなみに、AVAudioSequencerやAVAudioPlayerから直接発音する分には問題ありません。

 印象としては、AVAudioEngineの出口のところで流れが止まっている感じがしたので、調べていくうちにChannelMapが引っ掛かりました。
 内蔵スピーカー選択時のoutput(注1)の(audioUnitの)ChannelMapを調べると、デバイスが2個(左右あるのでチャンネルとしては計4個)で、デフォルトではいずれも無効になっていました。
(注1:通常はmixerがあればoutputを省略できるので前回は使用しなかったが、ChannelMapはoutputにしかないので、今回は追加している。)

 参考サイト(4):macos - Accessing multiple audio hardware outputs/channels using AVFoundation and Swift - Stack Overflow
 参考サイト(5):Multi Route Audio | objective-audio

 実験して調べると、最初のチャンネル2個がSoundFlowerに割り当てられ、次のチャンネル2個が内蔵スピーカーに割り当てられていました。
 なので、これを全て有効にすることで、発音だけでなく、内蔵スピーカーのままで録音することもできるようになりました。

 録音時のフォーマットは、融通の効くリニアPCMとします。拡張子は参考サイト(3)他ではcafとしているのでそれに倣いましたが、wavでもOKのようです。
 使い勝手が悪い場合は、ファイルコンバータまたは以下のコマンドで変換することとします。

 参考サイト(6):OS X のコマンドラインでCAFファイルをM4Aファイルに変換する方法 - Qiita

 以上を踏まえ、(残りの)仕様は以下の通りとしました。
 参考サイト(7):音声出力デバイスの一覧を取得する - PPixyが駄文でレポート


 Xojoでの実装
【ソースコードのコピー&ペーストについて】
・ソースコード(グレー背景部分の全文)をコピーし、指定のオブジェクトにペーストすると、(新規作成して名前等を個別にコピー&ペーストしなくても)復元されます。
・ペーストはオブジェクトに行って下さい。オブジェクト内のEvent Handlers/Methods/Properties等にペーストしても、うまくいかない場合があります。
・それでもペーストできない場合は、各項目のカッコ内を適用して下さい。
  1. 前回プロジェクトをベースとする(64bitビルド)
  2. Window1に、BevelButton(Name:BevelButton3)を追加
  3. 以下をBevelButton3にペースト(できなければ、Sub - Endの間をActionイベントに記述)
    Sub Action() Handles Action
      RecStart()
      PlayStart()
      
      BevelButton1.Value=true  // 再生ボタンも押下状態に
    End Sub
    
  4. BevelButton2のActionイベントを、以下に置き替え
    Sub Action() Handles Action
      PlayStop()
      
      // 録音と再生ボタンを戻す
      BevelButton1.Value=false
      BevelButton3.Value=false
    End Sub
    
  5. 以下をWindow1にペースト
    Protected Function FindSoundFlower() as Boolean
      Dim err, device As Int32
      Dim size As UInt32
      Dim propAddr As AudioObjectPropertyAddress
      
      propAddr.mScope    = &h676c6f62 // kAudioObjectPropertyScopeGlobal = 'glob'
      propAddr.mElement  = 0
      propAddr.mSelector = &h64657623 // kAudioHardwarePropertyDevices = 'dev#'
      
      // 全デバイスのサイズを取得
      Declare Function AudioObjectGetPropertyDataSize Lib "CoreAudio" (inObjectID As UInt32, byref inAddress As AudioObjectPropertyAddress, inQualifierDataSize As UInt32, inQualifierData As Ptr, byRef outDataSize As UInt32) As Int32
      err = AudioObjectGetPropertyDataSize(1, propAddr, 0, nil, size)
      if err<>0 then msgBox "AudioObjectGetPropertyDataSize 1 "+Str(err)
      Dim deviceIDPtr As MemoryBlock = new MemoryBlock(size)
      // 全デバイスを取得
      Declare Function AudioObjectGetPropertyData Lib "CoreAudio" (inObjectID As UInt32, byref inAddress As AudioObjectPropertyAddress, inQualifierDataSize As UInt32, inQualifierData As Ptr, byRef ioDataSize As UInt32, outData As Ptr) As Int32
      err = AudioObjectGetPropertyData(1, propAddr, 0, nil, size, deviceIDPtr)
      if err<>0 then msgBox "AudioObjectGetPropertyData 1 "+Str(err)
      Dim count As UInt32 = size / 4  // 取得したデバイスの数
      
      // 各デバイス
      for i As Integer = 0 to count-1
        
        device = deviceIDPtr.Int32Value(i*4)  // 個々のデバイス
        
        propAddr.mSelector = &h73746d23 // kAudioDevicePropertyStreams = 'stm#'
        propAddr.mScope    = &h696e7074 // kAudioObjectPropertyScopeInput = 'inpt' <入力デバイスに限定
        
        // ストリームの数を取得
        err = AudioObjectGetPropertyDataSize(device, propAddr, 0, nil, size)
        if err<>0 then msgBox "AudioObjectGetPropertyDataSize 2 "+Str(err)
        Dim streamCount As UInt32 = size / 4
        if streamCount = 0 then  // ストリームを持たないデバイスは対象外なので次へ
          continue
        end if
        
        propAddr.mScope = &h676c6f62 // kAudioObjectPropertyScopeGlobal = 'glob'
        
        // デバイスの名前のサイズを取得
        Dim deviceName As CFStringRef
        propAddr.mSelector = &h6c6e616d // kAudioObjectPropertyName = 'lnam'
        err = AudioObjectGetPropertyDataSize(device, propAddr, 0, nil, size)
        if err<>0 then msgBox "AudioObjectGetPropertyDataSize 2 "+Str(err)
        // デバイスの名前を取得
        Declare Function AudioObjectGetPropertyData Lib "CoreAudio" (inObjectID As UInt32, byref inAddress As AudioObjectPropertyAddress, inQualifierDataSize As UInt32, inQualifierData As Ptr, byRef ioDataSize As UInt32, byRef outData As CFStringRef) As Int32
        err = AudioObjectGetPropertyData(device, propAddr, 0, nil, size, deviceName)
        if err<>0 then msgBox "AudioObjectGetPropertyData 2 "+Str(err)
        
        // Soundflowerがインストールされていたらtrueを返す
        if InstrB(deviceName, "Soundflower")>0 then
          return true
        end if
        
      next
      
      msgBox "Soundflowerがインストールされていないので、録音はできません。"
      return false
    End Function
    
  6. 以下をWindow1にペースト
    Protected Sub InitAVAudioRecorder(f As FolderItem)
      Dim error As Ptr
      
      // 文字列を指定してクラスオブジェクトを取得する。最初に一回宣言しておけばよい。
      Declare Function NSClassFromString Lib "Cocoa" (aClassName As CFStringRef) As Ptr
      
      Declare Function numberWithDouble Lib "Cocoa" Selector "numberWithDouble:" (receiver As Ptr, val As CGFloat) As Ptr
      Declare Function numberWithInt Lib "Cocoa" Selector "numberWithInt:" (receiver As Ptr, val As Integer) As Ptr
      Declare Function numberWithBool Lib "Cocoa" Selector "numberWithBool:" (receiver As Ptr, val As Boolean) As Ptr
      Declare Sub setValueForKey Lib "Cocoa" Selector "setValue:forKey:" (receiver As Ptr, obj As Ptr, key As CFStringRef)
      
      // 出力ファイルパスをURL形式に変換
      Dim url As Ptr = NSClassFromString("NSURL")  // クラスメソッドなので、まずNSURLクラスを取得
      Declare Function fileURLWithPath Lib "Cocoa" Selector "fileURLWithPath:" (receiver As Ptr, path As CFStringRef) As Ptr
      url = fileURLWithPath(url, f.NativePath)
      
      // フォーマットをリニアPCMとしてsettings生成
      Dim dic As Ptr = NSClassFromString("NSMutableDictionary")  // クラスメソッドなので、まずNSMutableDictionaryクラスを取得
      Declare Function getDictionary Lib "Cocoa" Selector "dictionary" (receiver As Ptr) As Ptr  // Return NSMutableDictionary*
      dic=getDictionary(dic)
      Dim numb As Ptr
      numb = numberWithInt(NSClassFromString("NSNumber"), &h6c70636d)  // kAudioFormatLinearPCM = 'lpcm'
      setValueForKey(dic, numb,"AVFormatIDKey")
      numb = numberWithDouble(NSClassFromString("NSNumber"), 44100.0)  // numberWithFloatではダメ
      setValueForKey(dic, numb,"AVSampleRateKey")
      numb = numberWithInt(NSClassFromString("NSNumber"), 2)
      setValueForKey(dic, numb,"AVNumberOfChannelsKey")
      numb = numberWithInt(NSClassFromString("NSNumber"), 16)
      setValueForKey(dic, numb,"AVLinearPCMBitDepthKey")
      numb = numberWithBool(NSClassFromString("NSNumber"), false)
      setValueForKey(dic, numb,"AVLinearPCMIsBigEndianKey")
      numb = numberWithBool(NSClassFromString("NSNumber"), false)
      setValueForKey(dic, numb,"AVLinearPCMIsFloatKey")
      
      // 初期化
      pRecorder = NSClassFromString("AVAudioRecorder")
      Declare Function alloc Lib "Cocoa" selector "alloc" (class_id As Ptr) As Ptr
      Declare Function initWithURL Lib "Cocoa" selector "initWithURL:settings:error:" (obj_id As Ptr, url As Ptr, sett As Ptr, byRef err As Ptr) As Ptr
      pRecorder = initWithURL(alloc(pRecorder), url, dic, error)
      if error<>nil then
        msgBox"AVAudioRecorder Initialize Error."
        return
      end if
    End Sub
    
    (注:NSNumberの実数型への変換では、64bitビルドではnumberWithDouble、32bitビルドではnumberWithFloatを用いる。)
  7. 以下をWindow1にペースト
    Protected Sub InitChannelMap()
      // SoundFlowerがインストールされていると、環境設定>サウンドで内蔵スピーカー指定時、
      // AVAudioEngineのoutputが2系統に増え、最初の系統(2チャンネル)がSoundFlower、次の系統(2チャンネル)が内蔵スピーカーに割り当てられるようだ。
      // なので、両方に出力されるよう、設定する
      // ちなみに、デフォルトでは全チャンネルに-1がセットされているので、発音も録音もできない。
      
      // 出力チャンネルの指定
      pChannelMap = new MemoryBlock(2*2*4)
      pChannelMap.Int32Value(0)  =0  // SoundFlower2ch Lに送られる
      pChannelMap.Int32Value(1*4)=1  // SoundFlower2ch Rに送られる
      pChannelMap.Int32Value(2*4)=0  // 内蔵スピーカー Lに送られる
      pChannelMap.Int32Value(3*4)=1  // 内蔵スピーカー Rに送られる
    End Sub
    
  8. Window1のInitWinを、以下に置き替え
    Protected Sub InitWin()
      Dim f As FolderItem
      
      // SoundFlowerの有無を検出
      pEnableRec=FindSoundFlower()
      if pEnableRec then
        
        // ChannelMap初期化
        InitChannelMap()
        
        // AVAudioRecorder初期化
        f=SpecialFolder.Desktop.Child("test.caf")
        InitAVAudioRecorder(f)
        
      end if
      
      // MIDIチャンネル
      f=GetFolderItem("").Child("MIDI").Child("120 Rock 10 8ths Basic.mid")
      AddChannel(f)
      
      f=GetFolderItem("").Child("MIDI").Child("s-bass_1.mid")
      AddChannel(f)
      
      f=GetFolderItem("").Child("MIDI").Child("s-gt_1.mid")
      AddChannel(f)
      
      f=GetFolderItem("").Child("MIDI").Child("s-piano_1.mid")
      AddChannel(f)
    End Sub
    
  9. 以下をWindow1にペースト
    Protected Sub RecStart()
      // 録音開始
      if pEnableRec then
        Declare Sub record Lib "Cocoa" selector "record" (class_id As Ptr)
        record(pRecorder)
        pIsRec=true  // 録音中フラグ.オン
      end if
    End Sub
    
  10. Window1のPlayStopを、以下に置き替え
    Protected Sub PlayStop()
      // 全チャンネルを停止状態にセット
      for i As Integer = 0 to pChannel.Ubound
        pChannel(i).MusicStop()
      next
      
      // 録音停止
      if pEnableRec and pIsRec then
        Declare Sub stop Lib "Cocoa" selector "stop" (class_id As Ptr)
        stop(pRecorder)
        pIsRec=false
      end if
      
      // 最小値を送った後にタイマー停止
      DrawIndicator(-120,-120)
      Timer1.Mode=0
    End Sub
    
  11. 以下をWindow1にペースト(できなければプロパティに、名前:pChannelMap、データ型:MemoryBlock、を追加)
    Public Property pChannelMap as MemoryBlock
    
  12. 以下をWindow1にペースト(できなければプロパティに、名前:pEnableRec、データ型:Boolean、を追加)
    Public Property pEnableRec as Boolean
    
  13. 以下をWindow1にペースト(できなければプロパティに、名前:pIsRec、データ型:Boolean、を追加)
    Protected Property pIsRec as Boolean
    
  14. 以下をWindow1にペースト(できなければプロパティに、名前:pRecorder、データ型:Ptr、を追加)
    Protected Property pRecorder as Ptr
    
  15. 以下をWindow1にペースト
    Protected Structure AudioObjectPropertyAddress
      mSelector As UInt32
      mScope As UInt32
      mElement As UInt32
    End Structure
    
  16. ContainerControl1のCreateAudioEngineを、以下に置き替え
    Protected Function CreateAudioEngine() as Ptr
      Dim error As Ptr
      
      // 文字列を指定してクラスオブジェクトを取得する。最初に一回宣言しておけばよい。
      Declare Function NSClassFromString Lib "Cocoa" (aClassName As CFStringRef) As Ptr
      
      Declare Function alloc Lib "Cocoa" selector "alloc" (class_id As Ptr) As Ptr
      Declare Function init Lib "Cocoa" selector "init" (obj_id As Ptr) As Ptr
      
      // エンジン生成
      Dim engine As Ptr = NSClassFromString("AVAudioEngine")
      engine = init(alloc(engine))
      
      // ノード設定オプションをMusicDeviceとしてセット
      Dim cd As AudioComponentDescription
      cd.componentManufacturer = &h6170706c // kAudioUnitManufacturer_Apple = 'appl'
      cd.componentFlags = 0
      cd.componentFlagsMask = 0
      cd.componentType = &h61756d75  // kAudioUnitType_MusicDevice = 'aumu'
      cd.componentSubType = &h646c7320  // kAudioUnitSubType_DLSSynth = 'dls '
      
      // MIDIデバイス生成
      pInst = NSClassFromString("AVAudioUnitMIDIInstrument")
      Declare Function initWithAudioComponentDescription Lib "Cocoa" selector "initWithAudioComponentDescription:" (obj_id As Ptr, desc As AudioComponentDescription) As Ptr
      pInst = initWithAudioComponentDescription(alloc(pInst), cd)
      
      // エフェクター生成
      pDelay = NSClassFromString("AVAudioUnitDelay")
      pDelay = init(alloc(pDelay))
      pReverb = NSClassFromString("AVAudioUnitReverb")
      pReverb = init(alloc(pReverb))
      
      // MIDIデバイスとエフェクターをエンジンに接続
      Declare Sub attachNode Lib "Cocoa" selector "attachNode:" (class_id As Ptr, node As Ptr)
      attachNode(engine, pInst)
      attachNode(engine, pDelay)
      attachNode(engine, pReverb)
      
      // メインミキサーノードを取得
      Declare Function mainMixerNode Lib "Cocoa" selector "mainMixerNode" (class_id As Ptr) As Ptr
      pMixer = mainMixerNode(engine)
      
      // アウトプットノードを取得(通常の発音時はなくてもいいが、ChannelMapはアウトプットにしか設定できないため)
      Declare Function outputNode Lib "Cocoa" selector "outputNode" (class_id As Ptr) As Ptr
      Dim output As Ptr = outputNode(engine)
      
      // mixerからformatを取得
      Declare Function outputFormatForBus Lib "Cocoa" selector "outputFormatForBus:" (class_id As Ptr, idx As UInteger) As Ptr
      Dim format As Ptr = outputFormatForBus(pMixer, 0)
      
      // ノードの接続順を指定
      Declare Sub connecttoformat Lib "Cocoa" selector "connect:to:format:" (class_id As Ptr, src As Ptr, dest As Ptr, format As Ptr)
      connecttoformat(engine, pInst, pDelay, format)
      connecttoformat(engine, pDelay, pReverb, format)
      connecttoformat(engine, pReverb, pMixer, format)
      connecttoformat(engine, pMixer, output, format)
      
      // 録音が有効ならChannelMapをセット
      if Window1.pEnableRec then
        
        // アウトプットノードのaudioUnitを取得
        Declare Function audioUnit Lib "Cocoa" selector "audioUnit" (class_id As Ptr) As ComponentInstanceRecord
        Dim unit As ComponentInstanceRecord = audioUnit(output)
        
        // アウトプットノードのaudioUnitにChannelMapをセット
        Declare Function AudioUnitSetProperty Lib "AudioUnit" (inUnit As ComponentInstanceRecord, inID As UInt32, inScope As UInt32, inElement As UInt32, inData As Ptr, inDataSize As UInt32) As Integer
        Dim result As Int32 = AudioUnitSetProperty(unit, 2002, 0, 1, Window1.pChannelMap, Window1.pChannelMap.Size)  // 2002 = kAudioOutputUnitProperty_ChannelMap
        if result<>0 then msgBox "SetChannelMap 3 "+Str(result)
        
      end if
      
      // エンジン実行
      Declare Sub startAndReturnError Lib "Cocoa" selector "startAndReturnError:" (class_id As Ptr, byRef err As Ptr)
      startAndReturnError(engine, error)
      
      // エンジンを返す
      return engine
    End Function
    
 実行してみたところ、演奏中のサウンドを録音できることを確認しました。
S Shot1


(注:上掲スクリーンショット設定で録音後、afconvertでm4aに変換しています。)

 おわりに

 仮想オーディオデバイスのインストールという一手間は増えますが、録音できるようになるのは便利です。
 また、AVAudioEngineを使うと、録音の度にサウンドの設定を変えなくて済むので、マイク入力や音楽(音声)ソースの録音時もAVAudioEngineを使う、という手はありかもしれません。

 あと、今回は切りのいいところで録音を止める機能はつけていませんが、テンポと小節数から時間を計算できるので、タイマーを使う等して一定時間経過後に録音停止してやればよさそうです。


 お世話になったサイト

 貴重な情報をご提供頂いている皆様に、お礼申し上げます。(以下、順不同)

 参考サイト(1):Releases · mattingalls/Soundflower · GitHub
 参考サイト(2):Mac内で鳴っている音を録音する方法 | Macガレージ
 参考サイト(3):iOS 音声録音(AVAudioRecorder) | Professional Programmer
 参考サイト(4):macos - Accessing multiple audio hardware outputs/channels using AVFoundation and Swift - Stack Overflow
 参考サイト(5):Multi Route Audio | objective-audio
 参考サイト(6):OS X のコマンドラインでCAFファイルをM4Aファイルに変換する方法 - Qiita
 参考サイト(7):音声出力デバイスの一覧を取得する - PPixyが駄文でレポート


 更新履歴

 2020.07.01 新規作成


[Home]  [MacSoft]  [Donation]  [History]  [Privacy Policy]  [Affiliate Policy]