本文にスキップする
Version: 6.1

カスタムオーディオデバイス

デフォルトでは、PlanetKitは通話開始時にマイクやスピーカーデバイスを自動的に検出して制御し、マイクでキャプチャーしたオーディオを通話で使用して相手に転送し、受信したオーディオデータをスピーカーで再生します。ただし、アプリケーションの要求事項によっては、この構造を変更する必要があります。たとえば、ユーザーのオーディオの代わりにファイルのオーディオを転送したり、受信したオーディオデータをスピーカー以外のファイルとして保存したりする場合です。

このような要求を満たすために、PlanetKitは希望するオーディオデータを直接供給できるカスタムオーディオソース(custom audio source)機能と、受信したオーディオデータを直接処理できるカスタムオーディオシンク(custom audio sink)機能を提供します。この機能により、開発者が希望するオーディオデータをPlanetKitモジュールに提供して相手に転送したり、相手から受信したオーディオデータを直接制御したりできます。

Note

PlanetKitは、カスタムオーディオソースを使用時にはマイクデバイスを制御せず、カスタムオーディオシンクを使用時にはスピーカーデバイスを制御しません。

通話タイプSDKの最低バージョン
1対1通話、グループ通話(カンファレンス)PlanetKit 6.0

活用事例

カスタムオーディオデバイスの主な活用例は次のとおりです。

カスタムオーディオソース

  • 音声ファイルをオーディオソースとして使用
  • 外部オーディオソース(ライブ配信中継やWebストリーミング)連動
  • TTS音声をオーディオソースとして使用
  • マイクデバイスをPlanetKitで制御せずにアプリケーションから直接制御

カスタムオーディオシンク

  • スピーカーデバイスを有効にせずに通話オーディオをファイルとして録音
  • 外部デバイスへのオーディオルーティング
  • ネットワークオーディオストリーミング
  • スピーカーデバイスをPlanetKitで制御せずにアプリケーションから直接制御

APIの概要

PlanetKitでは、カスタムオーディオデバイスを実装するためのopenクラスを次のように提供します。

  • カスタムオーディオソース:PlanetKitCustomMic
  • カスタムオーディオシンク:PlanetKitCustomSpeaker

カスタムオーディオデバイスを実装し、PlanetKitAudioManagerを通じて次のように設定または解除できます。

// Set custom audio source/sink
PlanetKitAudioManager.shared.setCustomMic(customMic)
PlanetKitAudioManager.shared.setCustomSpeaker(customSpeaker)

// Reset to default devices
PlanetKitAudioManager.shared.resetCustomMicToDefaultMic()
PlanetKitAudioManager.shared.resetCustomSpeakerToDefaultSpeaker()

実装手順

例1:カスタムオーディオソース - オーディオファイルの再生

カスタムオーディオソース機能を実装する手順は、次のとおりです。

カスタムオーディオソースクラスを実装する

PlanetKitCustomMicを相続するクラスを作ってメソッドを実装します。

import PlanetKit
import AVFoundation

class AudioFileMicrophone: PlanetKitCustomMic {
private var audioFile: AVAudioFile?
private var audioTimer: DispatchSourceTimer?

func playFile(url: URL) {
do {
audioFile = try AVAudioFile(forReading: url)
startSendingAudio()
} catch {
print("Failed to load audio file: \(error)")
}
}

private func startSendingAudio() {
// Send audio every 20ms
let queue = DispatchQueue(label: "audio.playback", qos: .userInteractive)

audioTimer = DispatchSource.makeTimerSource(flags: [.strict], queue: queue)
audioTimer?.setEventHandler { [weak self] in
if let audioBuffer = self?.readNextAudioFrame() {
self?.sendAudio(audioBuffer: audioBuffer)
}
}
audioTimer?.schedule(deadline: .now(), repeating: .milliseconds(20), leeway: .milliseconds(2))
audioTimer?.resume()
}

private func readNextAudioFrame() -> PlanetKitAudioBuffer? {
guard let audioFile = audioFile else { return nil }

// Generate 20ms worth of frames (960 frames at 48kHz)
let frameCount: UInt32 = 960
let sampleRate: UInt32 = 48000
let bufferSize = frameCount * 4 // Float32 = 4 bytes per sample

// Allocate memory and read audio data
let buffer = UnsafeMutableRawPointer.allocate(
byteCount: Int(bufferSize),
alignment: MemoryLayout<Float>.alignment
)

// Ensure buffer is always deallocated, even if error occurs
defer {
buffer.deallocate()
}

// Read actual data from file (simplified here)
// Logic to copy PCM data from AVAudioFile to buffer
...

return PlanetKitAudioBuffer(
frameCount: frameCount,
sampleRate: sampleRate,
buffer: buffer,
bufferSize: bufferSize
)
}

func stopPlayback() {
audioTimer?.cancel()
audioTimer = nil
}

deinit {
audioTimer?.cancel()
audioFile = nil
print("AudioFileMicrophone cleaned up")
}
}

カスタムオーディオソースを使用する

カスタムオーディオソースを使用するには、実装したカスタムオーディオソースのオブジェクトを引数としてPlanetKitAudioManagersetCustomMic()を呼び出します。

// Usage
let customMic = AudioFileMicrophone()
PlanetKitAudioManager.shared.setCustomMic(customMic)

// Start file playback
customMic.playFile(url: audioFileURL)

カスタムオーディオソースをマイクに戻しておく

カスタムオーディオソースをマイクに戻しておくには、PlanetKitAudioManagerresetCustomMicToDefaultMic()を呼び出します。

// Cleanup after call ends
customMic.stopPlayback()
PlanetKitAudioManager.shared.resetCustomMicToDefaultMic()

例2:カスタムオーディオシンク - オーディオ録音

カスタムオーディオシンク機能を実装する手順は、次のとおりです。

カスタムオーディオシンククラスを実装する

PlanetKitCustomSpeakerを相続するクラスを作ってメソッドを実装します。

import PlanetKit
import AVFoundation

class AudioFileRecorder: PlanetKitCustomSpeaker {
private var audioFile: AVAudioFile?
private var isRecording = false
private var audioTimer: Timer?

func startRecording(to url: URL) {
do {
// Setup recording file
let format = AVAudioFormat(
standardFormatWithSampleRate: 48000,
channels: 1
)!

audioFile = try AVAudioFile(
forWriting: url,
settings: format.settings
)

isRecording = true

// Start periodic audio data collection
let queue = DispatchQueue(label: "audio.recording", qos: .userInteractive)

audioTimer = DispatchSource.makeTimerSource(flags: [.strict], queue: queue)
audioTimer?.setEventHandler { [weak self] in
self?.handleReceivedAudio()
}
audioTimer?.schedule(deadline: .now(), repeating: .milliseconds(20), leeway: .milliseconds(2))
audioTimer?.resume()

print("Recording started: \(url)")

} catch {
print("Failed to create recording file: \(error)")
}
}

private func handleReceivedAudio() {
// Get audio data from PlanetKit
if let pcmBuffer = playAudio() {
// Save to file if recording
if isRecording, let audioFile = audioFile {
do {
try audioFile.write(from: pcmBuffer)
} catch {
print("Failed to write file: \(error)")
}
}

// Process or forward to system audio
print("Audio received: \(pcmBuffer.frameCount) frames")
}
}

private func playAudio() -> AVAudioPCMBuffer? {
let frameCnt: UInt32 = 960
let channels: UInt32 = 1
let sampleRate: UInt32 = 48000

guard let format = AVAudioFormat(commonFormat: .pcmFormatFloat32, sampleRate: Double(sampleRate), channels: channels, interleaved: false) else {
return nil
}

let sampleBufferLen = UInt32(MemoryLayout<Float>.size) * frameCnt

guard let pcmBuffer = AVAudioPCMBuffer(pcmFormat: format, frameCapacity: frameCnt) else {
return nil
}

if let floatChannelData = pcmBuffer.floatChannelData {
var sampleBufferOutLen: Int32 = 0

floatChannelData[0].withMemoryRebound(to: Float.self, capacity: Int(pcmBuffer.frameCapacity)) { ptr -> Void in
let audioBuffer = PlanetKitAudioBuffer(frameCount: frameCnt, sampleRate: sampleRate, buffer: ptr, bufferSize: sampleBufferLen)

// Get audio data from peer
sampleBufferOutLen = playAudio(audioBuffer: audioBuffer)
}
pcmBuffer.frameLength = AVAudioFrameCount(sampleBufferOutLen / Int32(MemoryLayout<Float>.size))
}

return pcmBuffer
}

func stopRecording() {
isRecording = false
audioTimer?.cancel()
audioTimer = nil
audioFile = nil
print("Recording stopped")
}

deinit {
audioTimer?.cancel()
audioFile = nil
print("AudioFileRecorder cleaned up")
}
}

カスタムオーディオシンクを使用する

カスタムオーディオシンクを使用するには、実装したカスタムオーディオシンクのオブジェクトを引数としてPlanetKitAudioManagersetCustomSpeaker()を呼び出します。

// Usage
let recorder = AudioFileRecorder()
PlanetKitAudioManager.shared.setCustomSpeaker(recorder)

// Start recording - everything is handled internally
let documentsPath = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask)[0]
let recordingURL = documentsPath.appendingPathComponent("call_recording.wav")
recorder.startRecording(to: recordingURL)

カスタムオーディオシンクをスピーカーに戻しておく

カスタムオーディオシンクをスピーカーに戻しておくには、PlanetKitAudioManagerresetCustomSpeakerToDefaultSpeaker()を呼び出します。

// Cleanup after call ends
recorder.stopRecording()
PlanetKitAudioManager.shared.resetCustomSpeakerToDefaultSpeaker()

主な考慮事項

オーディオフォーマット

  • サンプルレート:48kHz推奨
  • チャンネル:モノ(1チャンネル)
  • サンプルタイプ:Float32(signedFloat32

パフォーマンスの最適化

  • パフォーマンスのためにメモリの割り当てと解除を最小限に抑える必要があります。
  • オーディオ処理は必ずバックグラウンドスレッドで行ってください。

カスタムオーディオデバイス使用時のAEC設定

AEC(acoustic echo cancellation)は、スピーカーから出力された音がマイクに再入力されて発生するエコーを除去する技術です。PlanetKitが提供するAEC関連の機能には、VQE制御のAECアプリケーションAECリファレンスがあります。一般的には、AECに関連して別途の設定は必要ありませんが、カスタムオーディオデバイスを利用する場合は、状況に応じてAEC関連の設定を直接実行する必要があります。

AEC関連設定が必要な条件

アプリケーションの実装が以下の条件に該当する場合は、AEC関連の設定を直接実行する必要があります。

  • Case 1:VQE制御のAEC設定が必要な場合
    • 条件
      • 以下の条件のいずれかに該当する場合
        • カスタムオーディオソースから**VPIOを有効にせず**実際のマイクデバイス以外の入力(例:WAVファイル、HTTP URLなど)を使用する場合
        • カスタムオーディオシンクから**VPIOを有効にせず**実際のスピーカーデバイス以外の入力(例:ファイルレコーディング)を使用する場合
    • 必要設定
      • カスタムオーディオデバイスの使用開始後にsetAcousticEchoCanceller(mode: .disabled)呼び出し
      • カスタムオーディオデバイスの使用終了後にsetAcousticEchoCanceller(mode: .intensityRecommended)呼び出し
  • Case 2:アプリケーションAECリファレンス設定が必要な場合
    • 条件
      • PlanetKitから引き渡されたスピーカーデータをカスタムオーディオシンクで**VPIOを有効にせず**変調して出力する場合
        • ただし、マイク入力が実際のマイクデバイスの場合にのみ該当(入力がWAVファイルなど実際のエコーが入力できるデバイスでない場合は該当なし)
    • 必要設定
      • カスタムオーディオシンクの使用開始後にstartUserAcousticEchoCancellerReference()呼び出し
      • putUserAcousticEchoCancellerReference()でスピーカーに出力したい音源を提供
      • カスタムオーディオシンクの使用終了後にstopUserAcousticEchoCancellerReference()呼び出し

Case 1:VQE制御のAEC設定が必要な場合

カスタムオーディオソースまたはカスタムオーディオシンクを利用して実際のマイクとスピーカー以外の入力または出力を使用している場合は、通話セッションクラスであるPlanetKitCallおよびPlanetKitConferenceで提供されるVQE機能の1つであるAECの使用を中止して、通話品質を向上させることができます。PlanetKitSendVoiceProcessorsetAcousticEchoCanceller()を呼び出してAECモードを設定できます。

カスタムオーディオソースクラスを実装する

カスタムオーディオソースを利用してオーディオファイルを入力として使用するためのクラスを実装します。

  • 通話に接続された後、オーディオデータを送信するために定期的にsendAudio()を呼び出します。
import AVFoundation
import PlanetKit

class AudioFileMicrophone: PlanetKitCustomMic {
private var audioFile: AVAudioFile?
private var audioTimer: DispatchSourceTimer?

func playFile(url: URL) {
do {
audioFile = try AVAudioFile(forReading: url)
startSendingAudio()
} catch {
print("Failed to load audio file: \(error)")
}
}

private func startSendingAudio() {
// Send audio every 20ms
let queue = DispatchQueue(label: "audio.playback", qos: .userInteractive)

audioTimer = DispatchSource.makeTimerSource(flags: [.strict], queue: queue)
audioTimer?.setEventHandler { [weak self] in
if let audioBuffer = self?.readNextAudioFrame() {
self?.sendAudio(audioBuffer: audioBuffer)
}
}
audioTimer?.schedule(deadline: .now(), repeating: .milliseconds(20), leeway: .milliseconds(2))
audioTimer?.resume()
}

private func readNextAudioFrame() -> PlanetKitAudioBuffer? {
guard let audioFile = audioFile else { return nil }

// Generate 20ms worth of frames (960 frames at 48kHz)
let frameCount: UInt32 = 960
let sampleRate: UInt32 = 48000
let bufferSize = frameCount * 4 // Float32 = 4 bytes per sample

// Allocate memory and read audio data
let buffer = UnsafeMutableRawPointer.allocate(
byteCount: Int(bufferSize),
alignment: MemoryLayout<Float>.alignment
)

// Ensure buffer is always deallocated, even if error occurs
defer {
buffer.deallocate()
}

// Read actual data from file (simplified here)
// Logic to copy PCM data from AVAudioFile to buffer
...

return PlanetKitAudioBuffer(
frameCount: frameCount,
sampleRate: sampleRate,
buffer: buffer,
bufferSize: bufferSize
)
}

func stopPlayback() {
audioTimer?.cancel()
audioTimer = nil
}

deinit {
audioTimer?.cancel()
audioFile = nil
print("AudioFileMicrophone cleaned up")
}
}

カスタムオーディオソースを使用する

カスタムオーディオソースを使用するには、実装したカスタムオーディオソースのオブジェクトを引数としてPlanetKitAudioManagersetCustomMic()を呼び出し、disabledを引数としてPlanetKitSendVoiceProcessorクラスのsetAcousticEchoCanceller()を呼び出してAECを無効に設定します。

let conference: PlanetKitConference

func enableCustomMic() {
let customMic = AudioFileMicrophone()
PlanetKitAudioManager.shared.setCustomMic(customMic)

// Start file playback
customMic.playFile(url: audioFileURL)

// Disable AEC
conference.sendVoiceProcessor.setAcousticEchoCanceller(mode: .disabled) { success in
// Add logging or UI code here
}
...
}

カスタムオーディオソースをマイクに戻しておく

カスタムオーディオソースをマイクに戻しておくには、PlanetKitAudioManagerresetCustomMicToDefaultMic()を呼び出し、intensityRecommendedを引数としてPlanetKitSendVoiceProcessorクラスのsetAcousticEchoCanceller()を呼び出してAECを有効に設定します。

func disableCustomMic() {
customMic.stopPlayback()
PlanetKitAudioManager.shared.resetCustomMicToDefaultMic()

// Enable AEC by default
conference.sendVoiceProcessor.setAcousticEchoCanceller(mode: .intensityRecommended) { success in
// Add logging or UI code here
}
...
}

Case 2:アプリケーションAECリファレンス設定が必要な場合

通話セッションクラスであるPlanetKitCallおよびPlanetKitConferenceで提供されるstartUserAcousticEchoCancellerReference()stopUserAcousticEchoCancellerReference()を利用して、AECリファレンスデータを使用するかどうかを設定できます。AECリファレンスデータを有効にした後、通話セッションクラスのputUserAcousticEchoCancellerReference()を利用して変更されたオーディオデータを提供します。

カスタムオーディオシンククラスを実装する

カスタムオーディオシンクを利用してオーディオ出力データを変更するためのクラスを実装します。

  • 通話に接続された後、受信されたオーディオデータを取得するために定期的にplayAudio()を呼び出します。
  • オーディオデータを加工し、これを引数としてputUserAcousticEchoCancellerReference()を呼び出します。
import AVFoundation
import PlanetKit

class CustomMixingSpeaker: PlanetKitCustomSpeaker {

/// Audio engine for audio processing using AVAudioEngine
private let audioEngine = AVAudioEngine()

...

/// Timer for periodic audio processing
private var sourceTimer: DispatchSourceTimer?

private weak var conference: PlanetKitConference?

init(url: URL, conference: PlanetKitConference) throws {
self.conference = conference
super.init()
try setupAudioEngine()
try loadBackgroundMusic(from: url)
}

func start() {
sourceTimer = DispatchSource.makeTimerSource(flags: [.strict], queue: .global(qos: .userInteractive))
sourceTimer?.setEventHandler {
self.processMixedAudio()
}
sourceTimer?.schedule(deadline: .now(), repeating: .milliseconds(sampleMilliSeconds), leeway: .milliseconds(2))
sourceTimer?.resume()
}

func stop() {
sourceTimer?.cancel()
sourceTimer = nil
}

private var sampleBitrate: UInt32 { 48000 }
private var sampleMilliSeconds: Int { 50 }
private var sampleFrame: UInt32 { sampleBitrate / (1000 / UInt32(sampleMilliSeconds)) }

private func processMixedAudio() {
let frameCnt: UInt32 = sampleFrame
let channels: UInt32 = 1
let sampleRate: UInt32 = sampleBitrate

guard let format = AVAudioFormat(commonFormat: .pcmFormatFloat32, sampleRate: Double(sampleRate), channels: channels, interleaved: false) else {
return
}

let sampleBufferLen = UInt32(MemoryLayout<Float>.size) * frameCnt

guard let pcmBuffer = AVAudioPCMBuffer(pcmFormat: format, frameCapacity: frameCnt) else {
return
}

if let floatChannelData = pcmBuffer.floatChannelData {
var sampleBufferOutLen: Int32 = 0

floatChannelData[0].withMemoryRebound(to: Float.self, capacity: Int(pcmBuffer.frameCapacity)) { ptr -> Void in
let audioBuffer = PlanetKitAudioBuffer(frameCount: frameCnt, sampleRate: sampleRate, buffer: ptr, bufferSize: sampleBufferLen)

// Get audio data from peer
sampleBufferOutLen = playAudio(audioBuffer: audioBuffer)

// Mix background music with audio data
mixAudioWithBackground(audioBuffer: audioBuffer)

// Provides audio data for AEC reference
conference?.putUserAcousticEchoCancellerReference(frameCnt: frameCnt,
channels: channels,
sampleRate: sampleRate,
sampleType: .signedFloat32,
outData: audioBuffer.buffer,
outDataLen: audioBuffer.bufferSize)
}
pcmBuffer.frameLength = AVAudioFrameCount(sampleBufferOutLen / Int32(MemoryLayout<Float>.size))
}

// Output to actual audio device
playToOutputDevice(buffer: pcmBuffer)
}

private func mixAudioWithBackground(audioBuffer: PlanetKitAudioBuffer) {
...
}

private func playToOutputDevice(buffer: AVAudioPCMBuffer?) {
...
}
}

カスタムオーディオシンクを使用する

カスタムオーディオシンクを使用するには、実装したカスタムオーディオシンクのオブジェクトを引数として、PlanetKitAudioManagersetCustomSpeaker()を呼び出し、アプリケーションAECリファレンスの使用を開始するために通話セッションクラスのstartUserAcousticEchoCancellerReference()を呼び出します。

let conference: PlanetKitConference
var customSpeaker: CustomMixingSpeaker?

func enableCustomMixingSpeaker() {
let backgroundMusicURL = Bundle.main.url(forResource: "background", withExtension: "mp3")!
do {
customSpeaker = try CustomMixingSpeaker(url: backgroundMusicURL, conference: conference)
customSpeaker?.start()

PlanetKitAudioManager.shared.setCustomSpeaker(customSpeaker)
} catch {
print("Custom speaker setup failed: \(error)")
}

conference?.startUserAcousticEchoCancellerReference() { success in
print("Custom speaker AEC reference enabled \(success)")
}
...
}

カスタムオーディオシンクをスピーカーに戻しておく

カスタムオーディオシンクをスピーカーに戻しておくには、PlanetKitAudioManagerresetCustomSpeakerToDefaultSpeaker()を呼び出し、アプリケーションAECリファレンスの使用を中止するために通話セッションクラスのstopUserAcousticEchoCancellerReference()を呼び出します。

func disableCustomMixingSpeaker() {
customSpeaker?.stop()
PlanetKitAudioManager.shared.resetCustomSpeakerToDefaultSpeaker()

conference?.stopUserAcousticEchoCancellerReference { success in
print("Custom speaker AEC reference disabled \(success)")
}
...
}

関連API