ホームページ>開発ツール>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が駄文でレポート
- 仮想オーディオデバイスはSoundFlower、出力は内蔵スピーカーに固定。(必要ならカスタマイズして下さい。)
- 録音ファイルの保存場所は、デスクトップに固定。(必要ならカスタマイズして下さい。)
- はじめにSoundFlowerを検出して、見つかった場合に録音可能とする。(検出方法は参考サイト(7)をベースにさせて頂きました。)
Xojoでの実装
【ソースコードのコピー&ペーストについて】
・ソースコード(グレー背景部分の全文)をコピーし、指定のオブジェクトにペーストすると、(新規作成して名前等を個別にコピー&ペーストしなくても)復元されます。
・ペーストはオブジェクトに行って下さい。オブジェクト内のEvent Handlers/Methods/Properties等にペーストしても、うまくいかない場合があります。
・それでもペーストできない場合は、各項目のカッコ内を適用して下さい。
実行してみたところ、演奏中のサウンドを録音できることを確認しました。
- 前回プロジェクトをベースとする(64bitビルド)
- Window1に、BevelButton(Name:BevelButton3)を追加
- 以下をBevelButton3にペースト(できなければ、Sub - Endの間をActionイベントに記述)
Sub Action() Handles Action RecStart() PlayStart() BevelButton1.Value=true // 再生ボタンも押下状態に End Sub
- BevelButton2のActionイベントを、以下に置き替え
Sub Action() Handles Action PlayStop() // 録音と再生ボタンを戻す BevelButton1.Value=false BevelButton3.Value=false End Sub
- 以下を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
- 以下をWindow1にペースト
(注:NSNumberの実数型への変換では、64bitビルドではnumberWithDouble、32bitビルドではnumberWithFloatを用いる。)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
- 以下を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
- 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
- 以下を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
- 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
- 以下をWindow1にペースト(できなければプロパティに、名前:pChannelMap、データ型:MemoryBlock、を追加)
Public Property pChannelMap as MemoryBlock
- 以下をWindow1にペースト(できなければプロパティに、名前:pEnableRec、データ型:Boolean、を追加)
Public Property pEnableRec as Boolean
- 以下をWindow1にペースト(できなければプロパティに、名前:pIsRec、データ型:Boolean、を追加)
Protected Property pIsRec as Boolean
- 以下をWindow1にペースト(できなければプロパティに、名前:pRecorder、データ型:Ptr、を追加)
Protected Property pRecorder as Ptr
- 以下をWindow1にペースト
Protected Structure AudioObjectPropertyAddress mSelector As UInt32 mScope As UInt32 mElement As UInt32 End Structure
- 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
(注:上掲スクリーンショット設定で録音後、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]