🙌

MacOSシステム音声のキャプチャ実装:SwiftからNode.jsまでの音声ストリーム処理設計

に公開1件

こんにちは、GBase開発チームです。

1. はじめに

macOSシステム上でスピーカーオーディオストリームをキャプチャすることは技術的な課題です。なぜならNode.jsはシステムレベルのオーディオAPIに直接アクセスできないからです。本稿では、SwiftとCoreAudioに基づくソリューションを詳細に説明し、NAPI(Node-API)ブリッジ技術を通じて、システムオーディオキャプチャからNode.jsオーディオ処理までの完全な技術スタックを実現します。

2. 技術アーキテクチャ概要

┌─────────────────┐ ┌─────────────────┐ ┌─────────────────┐
│ Node.js App │ │ NAPI Binding │ │ Swift Module │
│ │◄──►│ │◄──►│ │
│ JavaScript API │ │ C++ Interface │ │ CoreAudio API │
└─────────────────┘ └─────────────────┘ └─────────────────┘
│ │
▼ ▼
┌─────────────────┐ ┌─────────────────┐
│ Audio Buffer │ │ Process Tap │
│ │ │ │
│ Base64 Data │ │ System Audio │
└─────────────────┘ └─────────────────┘

3. SwiftによるMacスピーカーオーディオストリーム取得

3.1 CoreAudio Process Tap技術

CoreAudioのProcess Tapは、macOSシステムレベルオーディオキャプチャの中核技術です。これにより、アプリケーションは元のオーディオストリームに影響を与えることなく、他のアプリケーションのオーディオ出力を「盗み聞き」できます。

3.1.1 Process Tapの作成

// Process Tapディスクリプタの作成
let tapDescription = CATapDescription(stereoGlobalTapButExcludeProcesses: [])
tapDescription.uuid = UUID()
tapDescription.muteBehavior = .unmuted
tapDescription.name = "AudioTap System Tap"
tapDescription.isPrivate = true
tapDescription.isExclusive = true

// Process Tapの作成
var tapID = AudioObjectID.unknown
let err = AudioHardwareCreateProcessTap(tapDescription, &tapID)

主要パラメータ説明:
stereoGlobalTapButExcludeProcesses: []: 全てのプロセスのステレオオーディオをキャプチャ
muteBehavior: .unmuted: 元のオーディオストリームをミュートしない
isPrivate: true: プライベートTap(他のアプリと共有しない)
isExclusive: true: 排他モード(オーディオ品質を確保)

#### 3.1.2 オーディオストリームフォーマットの取得

// オーディオストリーム記述の取得
func readAudioTapStreamBasicDescription() throws -> AudioStreamBasicDescription {
    try read(kAudioTapPropertyFormat, defaultValue: AudioStreamBasicDescription())
}
AudioStreamBasicDescription 包含内容:
mSampleRate: サンプリングレート(例: 48000Hz)
mChannelsPerFrame: チャンネル数(例: 2チャンネルステレオ)
mBitsPerChannel: ビット深度(例: 32ビット浮動小数点)
mFormatID: オーディオフォーマットID

3.2 Aggregate Device技術
Process Tapのオーディオストリームを処理するため、仮想集約デバイスを作成する必要があります:

swift
// 集約デバイス設定の作成
let tapUID = tapDescription.uuid.uuidString
let tapConfig: [String: Any] = [
    kAudioSubTapUIDKey as String: tapUID,
    kAudioSubTapDriftCompensationKey as String: true
]

let aggregateUID = UUID().uuidString
let description: [String: Any] = [
    kAudioAggregateDeviceNameKey: "AudioCap System Audio Aggregate Device",
    kAudioAggregateDeviceUIDKey: aggregateUID,
    kAudioAggregateDeviceTapListKey: [tapConfig],
    kAudioAggregateDeviceTapAutoStartKey: false,
    kAudioAggregateDeviceIsPrivateKey: true
]

// 集約デバイスの作成
var aggregateDeviceID = AudioObjectID.unknown
let err = AudioHardwareCreateAggregateDevice(description as CFDictionary, &aggregateDeviceID)

3.3 オーディオコールバック処理
AudioUnitコールバック関数によるリアルタイムオーディオデータ処理:

swift
// オーディオ処理コールバックの作成
var err = AudioDeviceCreateIOProcIDWithBlock(&deviceProcID, aggregateDeviceID, nil) { [weak self] inNow, inInputData, inInputTime, outOutputData, inOutputTime in
    guard let self, let processingBlock = self.processingBlock else { return }
    processingBlock(inNow, inInputData, inInputTime, outOutputData, inOutputTime)
}

// オーディオデバイスの起動
err = AudioDeviceStart(aggregateDeviceID, deviceProcID)

4. NAPIオーディオストリームブリッジ技術
4.1 NAPIモジュール初期化
NAPI(Node-API)はNode.jsのC APIで、ネイティブアドオンを作成するために使用されます。Swift NAPIバインディングを通じて、SwiftとNode.jsのブリッジを実現:

swift
@_cdecl("_init_callbacks")
func initCallbacks(env: OpaquePointer, exports: OpaquePointer) -> OpaquePointer? {
    return initModule(env, exports, [
        .function("configure", { try configure(options: [:]) }),
        .function("startCapture", startCapture),
        .function("stopCapture", stopCapture),
        .function("getAudioData", getAudioData)
    ])
}

4.2 オーディオデータのエンコードと転送
Swift側でオーディオデータをBase64文字列にエンコードし、言語を跨いだ転送を容易にします:

swift
private func encodeBuffer(_ buffer: AVAudioPCMBuffer) -> String {
    let audioBuffer = buffer.audioBufferList.pointee.mBuffers
    
    if let audioDataReference = audioBuffer.mData {
        let bufferData = Data(bytes: audioDataReference,
                            count: Int(audioBuffer.mDataByteSize))
        
        return bufferData.base64EncodedString()
    }
    return ""
}

func getAudioData() throws -> [String] {
    guard let recorder = manager.audioRecorder else {
        throw AudioCaptureError.invalidConfiguration
    }
    
    if let data = recorder.getAudioData() {
        return data
    } else {
        return []
    }
}

4.3 グローバル状態管理
シングルトンパターンによるオーディオキャプチャのグローバル状態管理:

swift
private final class AudioCaptureManager {
    static let shared = AudioCaptureManager()
    
    var audioTap: SystemAudioTap?
    var audioRecorder: SystemAudioRecorder?
    
    private lazy var audioFileURL: URL = {
        let filename = "system_audio_\(Int(Date.now.timeIntervalSinceReferenceDate))"
        return URL.applicationSupport.appendingPathComponent(filename, conformingTo: .wav)
    }()
}

5. Node.jsオーディオストリームトランスコード処理
5.1 JavaScript API設計
Node.js側では、低レベルの複雑さをカプセル化した簡潔なJavaScript APIを提供します:

javascript
class AudioCapture extends EventEmitter {
    constructor(options = {}) {
        super();
        
        this.config = {
            sampleRate: 48000,
            channelCount: 2,
            logPath: path.join(process.cwd(), 'audio_capture.log'),
            ...options
        };
        
        this.isRecording = false;
        this.audioBuffer = [];
        this.captureInterval = null;
        this.isConfigured = false;
    }
}

// 外部呼び出し例
const capture = new AudioCapture({
    sampleRate: 48000,
    channelCount: 2,
    logPath: './logs/audio_capture.log'
});
5.2 オーディオデータのデコード
Base64エンコードされたオーディオデータをFloat32Arrayにデコード:

javascript
_base64StringsToFloat32Array(base64Strings) {
    if (!Array.isArray(base64Strings) || base64Strings.length === 0) {
        throw new Error('無効なオーディオデータ');
    }

    const buffers = base64Strings.map(str => {
        if (typeof str !== 'string') {
            throw new Error('無効なBase64文字列');
        }
        return Buffer.from(str, 'base64');
    });
    
    const mergedBuffer = Buffer.concat(buffers);

    if (mergedBuffer.length % 4 !== 0) {
        throw new Error('データ長がFloat32の倍数ではない');
    }

    return new Float32Array(mergedBuffer.buffer);
}

5.3 オーディオフォーマット変換
Float32オーディオデータをWAVファイル保存に適したInt16形式に変換:

javascript
_float32ToInt16(float32Array) {
    const int16Array = new Int16Array(float32Array.length);
    for (let i = 0; i < float32Array.length; i++) {
        // -1.0~1.0の浮動小数点数を-32768~32767の整数に変換
        int16Array[i] = Math.max(-32768, Math.min(32767, float32Array[i] * 32767));
    }
    return int16Array;
}

5.4 WAVファイル生成
標準WAVファイルフォーマットの生成:

javascript
_createWavHeader(options) {
    const { sampleRate, numChannels, bitDepth, dataLength } = options;
    const byteRate = (sampleRate * numChannels * bitDepth) / 8;
    const blockAlign = (numChannels * bitDepth) / 8;
    const buffer = Buffer.alloc(44);

    // RIFFヘッダー
    buffer.write('RIFF', 0);
    buffer.writeUInt32LE(36 + dataLength, 4);
    buffer.write('WAVE', 8);
    
    // fmtチャンク
    buffer.write('fmt ', 12);
    buffer.writeUInt32LE(16, 16);
    buffer.writeUInt16LE(1, 20);  // PCMフォーマット
    buffer.writeUInt16LE(numChannels, 22);
    buffer.writeUInt32LE(sampleRate, 24);
    buffer.writeUInt32LE(byteRate, 28);
    buffer.writeUInt16LE(blockAlign, 32);
    buffer.writeUInt16LE(bitDepth, 34);
    
    // dataチャンク
    buffer.write('data', 36);
    buffer.writeUInt32LE(dataLength, 40);

    return buffer;
}

5.5 リアルタイムオーディオ処理
リアルタイムオーディオデータストリーム処理のサポート:

javascript
async startCapture(options = {}) {
    // オーディオキャプチャ開始
    startCapture();
    this.isRecording = true;
    this.audioBuffer = [];
    
    // 定期的なオーディオデータ収集
    this.captureInterval = setInterval(() => {
        try {
            const data = getAudioData();
            if (data && data.length > 0) {
                this.audioBuffer.push(...data);
                this.emit('data', data);  // リアルタイムデータイベント発火
            }
        } catch (error) {
            this.emit('error', new Error(`オーディオデータ取得失敗: ${error.message}`));
        }
    }, options.interval || 200);
    
    this.emit('started');
}

6. 使用例
6.1 基本使用法
javascript
const AudioCapture = require('./lib');

async function captureAudio() {
    const capture = new AudioCapture({
        sampleRate: 48000,
        channelCount: 2
    });
    
    // イベントリスニング
    capture.on('started', () => console.log('🎙️ キャプチャ開始'));
    capture.on('stopped', () => console.log('🛑 キャプチャ停止'));
    capture.on('error', (error) => console.error('❌ エラー:', error.message));
    
    try {
        // 権限確認
        await capture.ensurePermissions();
        
        // 5秒間オーディオ録音
        const filePath = await capture.record(5000, 'output.wav');
        console.log('✅ 録音完了:', filePath);
    } catch (error) {
        console.error('録音失敗:', error.message);
    }
}

6.2 高度な使用法
javascript
async function advancedCapture() {
    const capture = new AudioCapture();
    
    // オーディオキャプチャ設定
    await capture.configure({
        sampleRate: 44100,
        channelCount: 1,
        logPath: './logs/audio.log'
    });
    
    // キャプチャ開始
    await capture.startCapture({ interval: 100 });
    
    // リアルタイムオーディオデータ処理
    capture.on('data', (audioData) => {
        console.log(`📊 ${audioData.length}個のオーディオチャンクを受信`);
        // オーディオデータ処理
    });
    
    // 3秒間録音
    await new Promise(resolve => setTimeout(resolve, 3000));
    
    // キャプチャ停止
    await capture.stopCapture();
    
    // WAVファイルとして保存
    const filePath = await capture.saveToWav('advanced-output.wav');
    console.log('ファイル保存先:', filePath);
}

7. 使用最適化と注意事項
7.1 プロセス分離と安定性
オーディオキャプチャモジュール使用時は、プログラムクラッシュがメインアプリに影響を与えないようプロセス分離を推奨します:

javascript
// forkによる子プロセスでのオーディオキャプチャ
const { fork } = require('child_process');
const path = require('path');

class AudioCaptureWorker {
    constructor() {
        this.worker = null;
        this.isRunning = false;
    }
    
    startCapture(options = {}) {
        return new Promise((resolve, reject) => {
            // 子プロセス作成
            this.worker = fork(path.join(__dirname, 'audio-worker.js'), [], {
                stdio: ['pipe', 'pipe', 'pipe', 'ipc']
            });
            
            // 子プロセスメッセージ監視
            this.worker.on('message', (message) => {
                switch (message.type) {
                    case 'started':
                        this.isRunning = true;
                        resolve();
                        break;
                    case 'data':
                        this.emit('data', message.data);
                        break;
                    case 'error':
                        reject(new Error(message.error));
                        break;
                }
            });
            
            // 子プロセスエラー監視
            this.worker.on('error', (error) => {
                reject(error);
            });
            
            // 起動コマンド送信
            this.worker.send({
                type: 'start',
                options: options
            });
        });
    }
    
    stopCapture() {
        return new Promise((resolve) => {
            if (this.worker) {
                this.worker.send({ type: 'stop' });
                this.worker.on('exit', () => {
                    this.isRunning = false;
                    this.worker = null;
                    resolve();
                });
            } else {
                resolve();
            }
        });
    }
}

// audio-worker.js 子プロセスファイル
const AudioCapture = require('./lib');

const capture = new AudioCapture();

process.on('message', async (message) => {
    try {
        switch (message.type) {
            case 'start':
                await capture.startCapture(message.options);
                capture.on('data', (data) => {
                    process.send({ type: 'data', data: data });
                });
                process.send({ type: 'started' });
                break;
            case 'stop':
                await capture.stopCapture();
                process.exit(0);
                break;
        }
    } catch (error) {
        process.send({ type: 'error', error: error.message });
    }
});

7.2 リアルタイムオーディオストリーム処理最適化
リアルタイムオーディオストリーム処理が必要なシナリオでは、AudioWorkletProcessorを使用したオーディオデータ合成・処理を推奨します:

javascript
// リアルタイムオーディオ処理用AudioWorkletProcessor
class AudioCaptureProcessor extends AudioWorkletProcessor {
    constructor() {
        super();
        this.audioBuffer = [];
        this.isProcessing = false;
    }
    
    process(inputs, outputs, parameters) {
        // オーディオ入力データ取得
        const input = inputs[0];
        const output = outputs[0];
        
        if (input && input.length > 0) {
            // オーディオデータ処理
            for (let channel = 0; channel < input.length; channel++) {
                const inputChannel = input[channel];
                const outputChannel = output[channel];
                
                for (let i = 0; i < inputChannel.length; i++) {
                    // リアルタイムオーディオ処理
                    // 例: ボリューム調整、オーディオエフェクト、リアルタイム分析等
                    outputChannel[i] = this.processAudioSample(inputChannel[i]);
                }
            }
            
            // メインスレッドへオーディオデータ送信
            this.port.postMessage({
                type: 'audioData',
                data: this.encodeAudioData(input)
            });
        }
        
        return true; // 処理継続
    }
    
    processAudioSample(sample) {
        // 簡易ボリューム調整例
        return sample * 1.2; // 20%音量増加
    }
    
    encodeAudioData(input) {
        // 転送可能形式へのオーディオデータエンコード
        const encodedData = [];
        for (let channel = 0; channel < input.length; channel++) {
            encodedData.push(Array.from(input[channel]));
        }
        return encodedData;
    }
}

// AudioWorkletProcessor登録
registerProcessor('audio-capture-processor', AudioCaptureProcessor);

// メインスレッドでのAudioWorklet使用
class RealTimeAudioProcessor {
    constructor() {
        this.audioContext = null;
        this.workletNode = null;
        this.captureNode = null;
    }
    
    async initialize() {
        // オーディオコンテキスト作成
        this.audioContext = new AudioContext();
        
        // AudioWorkletモジュール読み込み
        await this.audioContext.audioWorklet.addModule('audio-capture-processor.js');
        
        // AudioWorkletノード作成
        this.workletNode = new AudioWorkletNode(this.audioContext, 'audio-capture-processor');
        
        // AudioWorkletからのメッセージ監視
        this.workletNode.port.onmessage = (event) => {
            if (event.data.type === 'audioData') {
                this.handleRealTimeAudioData(event.data.data);
            }
        };
        
        // オーディオノード接続
        this.workletNode.connect(this.audioContext.destination);
    }
    
    handleRealTimeAudioData(audioData) {
        // リアルタイムオーディオデータ処理
        console.log('リアルタイムオーディオデータ受信:', audioData);
        
        // 以下の処理が可能:
        // - オーディオ分析
        // - リアルタイム可視化
        // - オーディオエフェクト処理
        // - データ記録等
    }
    
    startProcessing() {
        if (this.audioContext) {
            this.audioContext.resume();
        }
    }
    
    stopProcessing() {
        if (this.audioContext) {
            this.audioContext.suspend();
        }
    }
}

// 使用例
async function setupRealTimeAudioProcessing() {
    const processor = new RealTimeAudioProcessor();
    await processor.initialize();
    processor.startProcessing();
    
    // オーディオキャプチャ設定
    const capture = new AudioCapture();
    await capture.startCapture({ interval: 50 });
    
    capture.on('data', (audioData) => {
        // キャプチャしたオーディオデータをAudioWorkletに渡す
        if (processor.workletNode) {
            processor.workletNode.port.postMessage({
                type: 'processAudio',
                data: audioData
            });
        }
    });
}

7.3 メモリ管理とパフォーマンス最適化
javascript
// オーディオデータバッファ管理
class AudioBufferManager {
    constructor(maxBufferSize = 1024 * 1024) { // デフォルト1MBバッファ
        this.buffer = [];
        this.maxBufferSize = maxBufferSize;
        this.currentSize = 0;
    }
    
    addAudioData(data) {
        // バッファサイズ確認
        if (this.currentSize + data.length > this.maxBufferSize) {
            // 最も古いデータを削除
            this.removeOldestData();
        }
        
        this.buffer.push(data);
        this.currentSize += data.length;
    }
    
    removeOldestData() {
        while (this.buffer.length > 0 && this.currentSize > this.maxBufferSize * 0.8) {
            const removed = this.buffer.shift();
            this.currentSize -= removed.length;
        }
    }
    
    getAudioData() {
        const data = [...this.buffer];
        this.buffer = [];
        this.currentSize = 0;
        return data;
    }
    
    clear() {
        this.buffer = [];
        this.currentSize = 0;
    }
}

// バッファマネージャー使用例
const bufferManager = new AudioBufferManager();

capture.on('data', (audioData) => {
    bufferManager.addAudioData(audioData);
});

7.4 エラー処理と回復メカニズム
javascript
// 強化されたエラー処理機構
class RobustAudioCapture extends AudioCapture {
    constructor(options = {}) {
        super(options);
        this.retryCount = 0;
        this.maxRetries = 3;
        this.retryDelay = 1000; // 1秒
    }
    
    async startCapture(options = {}) {
        try {
            await super.startCapture(options);
            this.retryCount = 0; // リトライカウンタリセット
        } catch (error) {
            if (this.retryCount < this.maxRetries) {
                this.retryCount++;
                console.log(`オーディオキャプチャ起動失敗、${this.retryDelay}ms後に再試行 (${this.retryCount}/${this.maxRetries})`);
                
                await new Promise(resolve => setTimeout(resolve, this.retryDelay));
                return this.startCapture(options);
            } else {
                throw new Error(`オーディオキャプチャ起動失敗、${this.maxRetries}回再試行しました: ${error.message}`);
            }
        }
    }
}
8. オープンソースプロジェクト: mac-audio-capture
上記技術アーキテクチャに基づき、完全なMacシステムオーディオキャプチャソリューション mac-audio-capture を開発・公開しました。

8.1 プロジェクト概要
プロジェクトURL: https://github.com/sparticleinc/mac-audio-capture

プロジェクト特徴:

🎯 システムレベルキャプチャ: CoreAudio Process Tap技術による全アプリケーションオーディオ出力キャプチャ
⚡ 高性能: SwiftネイティブコードによるC言語に近いパフォーマンス
🔧 使いやすいAPI: Promiseとイベント駆動をサポートした簡潔なJavaScriptインターフェース
🎵 リアルタイム処理: リアルタイムオーディオデータ処理とフォーマット変換をサポート
📁 マルチフォーマット出力: WAVフォーマットオーディオファイル出力をサポート
🛡️ エラー処理: 包括的なエラー処理と状態管理

8.2 クイックスタート
インストール
bash
# GitHubからインストール
npm install git+https://github.com/sparticleinc/mac-audio-capture.git

# またはリポジトリをクローンしてローカルインストール
git clone https://github.com/sparticleinc/mac-audio-capture.git
cd mac-audio-capture
npm install
基本使用法
javascript
const AudioCapture = require('mac-audio-capture');

async function captureAudio() {
    const capture = new AudioCapture({
        sampleRate: 48000,
        channelCount: 2
    });
    
    capture.on('started', () => console.log('🎙️ キャプチャ開始'));
    capture.on('stopped', () => console.log('🛑 キャプチャ停止'));
    capture.on('error', (error) => console.error('❌ エラー:', error.message));
    
    try {
        const filePath = await capture.record(5000, 'output.wav');
        console.log('✅ 録音完了:', filePath);
    } catch (error) {
        console.error('録音失敗:', error.message);
    }
}

captureAudio();

8.3 権限設定
重要: 本モジュール使用前に、手動でシステムオーディオ録音権限を有効化する必要があります:

システム環境設定 > プライバシーとセキュリティ > 画面収録とシステムオーディオ収録を開く
アプリケーションリストから対象アプリを選択
「システムオーディオ収録」権限を有効化
権限付与後、アプリケーションを再起動

8.4 詳細情報
主要技術ポイント:
CoreAudio Process Tap: システムレベルオーディオキャプチャの中核技術
Aggregate Device: 仮想オーディオデバイス管理
NAPIブリッジ: SwiftとNode.js間のクロスランゲージ通信
リアルタイムオーディオ処理: 低遅延オーディオデータストリーム処理

追加利点:
🚀 即時利用可能: ゼロから構築不要な完全ソリューション
📚 オープンソース: 完全実装コードと使用例を提供
🔧 容易な統合: 既存プロジェクトへの統合が容易な簡潔なJavaScript API

以下の詳細情報を含む完全な内容については:
完全な実装
使用例
実際のアプリケーションシナリオ
トラブルシューティングガイド

プロジェクトホームページをご覧ください:
https://github.com/sparticleinc/mac-audio-capture

Discussion

nobutakonobutako

Capturing system audio with Core Audio taps の AudioRecorder.h にコメントしてあるように Swift はオーディオIOをリアルタイムで実行するには安全ではありません。

// You implement the `AudioRecorder` class in Objective-C++ because Swift doesn't have the real-time safety required to run an audio IO proc.
@interface AudioRecorder : NSObject

FYI: Fabian Renn-Giles & Dave Rowland - Real-time 101 - part I: Investigating the real-time problem space