カスタムオーディオデバイス
デフォルトでは、PlanetKitは通話開始時にマイクやスピーカーデバイスを自動的に検出して制御し、マイクでキャプチャーしたオーディオを通話で使用して相手に転送し、受信したオーディオデータをスピーカーで再生します。ただし、アプリケーションの要求事項によっては、この構造を変更する必要があります。たとえば、ユーザーのオーディオの代わりにファイルのオーディオを転送したり、受信したオーディオデータをスピーカー以外のファイルとして保存したりする場合です。
このような要求を満たすために、PlanetKitは希望するオーディオデータを直接供給できるカスタムオーディオソース(custom audio source)機能と、受信したオーディオデータを直接処理できるカスタムオーディオシンク(custom audio sink)機能を提供します。この機能により、開発者が希望するオーディオデータをPlanetKitモジュールに提供して相手に転送したり、相手から受信したオーディオデータを直接制御したりできます。
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")
}
}
カスタムオーディオソースを使用する
カスタムオーディオソースを使用するには、実装したカスタムオーディオソースのオブジェクトを引数としてPlanetKitAudioManagerのsetCustomMic()を呼び出します。
// Usage
let customMic = AudioFileMicrophone()
PlanetKitAudioManager.shared.setCustomMic(customMic)
// Start file playback
customMic.playFile(url: audioFileURL)
カスタムオーディオソースをマイクに戻しておく
カスタムオーディオソースをマイクに戻しておくには、PlanetKitAudioManagerのresetCustomMicToDefaultMic()を呼び出します。
// 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")
}
}
カスタムオーディオシンクを使用する
カスタムオーディオシンクを使用するには、実装したカスタムオーディオシンクのオブジェクトを引数としてPlanetKitAudioManagerのsetCustomSpeaker()を呼び出します。
// 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)
カスタムオーディオシンクをスピーカーに戻しておく
カスタムオーディオシンクをスピーカーに戻しておくには、PlanetKitAudioManagerのresetCustomSpeakerToDefaultSpeaker()を呼び出します。
// 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設定が必要な場合
- 条件
- 以下の条件のいずれかに該当する場合
- カスタムオーディオソースからの実際のマイクデバイス以外の入力(例:WAVファイル、HTTP URLなど)を使用する場合
- カスタムオーディオシンクからの実際のスピーカーデバイス以外の入力(例:ファイルレコーディング)を使用する場合
- 以下の条件のいずれかに該当する場合
- 必要設定
- カスタムオーディオデバイスの使用開始後に
setAcousticEchoCanceller(mode: .disabled)呼び出し - カスタムオーディオデバイスの使用終了後に
setAcousticEchoCanceller(mode: .intensityRecommended)呼び出し
- カスタムオーディオデバイスの使用開始後に
- 条件
- Case 2:アプリケーションAECリファレンス設定が必要な場合
- 条件
- PlanetKitから引き渡されたスピーカーデータをカスタムオーディオシンクで変調して出力する場合
- ただし、マイク入力が実際のマイクデバイスの場合にのみ該当(入力がWAVファイルなど実際のエコーが入力できるデバイスでない場合は該当なし)
- PlanetKitから引き渡されたスピーカーデータをカスタムオーディオシンクで変調して出力する場合
- 必要設定
- カスタムオーディオシンクの使用開始後に
startUserAcousticEchoCancellerReference()呼び出し putUserAcousticEchoCancellerReference()でスピーカーに出力したい音源を提供- カスタムオーディオシンクの使用終了後に
stopUserAcousticEchoCancellerReference()呼び出し
- カスタムオーディオシンクの使用開始後に
- 条件
Case 1:VQE制御のAEC設定が必要な場合
カスタムオーディオソースまたはカスタムオーディオシンクを利用して実際のマイクとスピーカー以外の入力または出力を使用している場合は、通話セッションクラスであるPlanetKitCallおよびPlanetKitConferenceで提供されるVQE機能の1つであるAECの使用を中止して、通話品質を向上させることができます。PlanetKitSendVoiceProcessorのsetAcousticEchoCanceller()を呼び出して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")
}
}
カスタムオーディオソースを使用する
カスタムオーディオソースを使用するには、実装したカスタムオーディオソースのオブジェクトを引数としてPlanetKitAudioManagerのsetCustomMic()を呼び出し、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
}
...
}
カスタムオーディオソースをマイクに戻しておく
カスタムオーディオソースをマイクに戻しておくには、PlanetKitAudioManagerのresetCustomMicToDefaultMic()を呼び出し、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?) {
...
}
}
カスタムオーディオシンクを使用する
カスタムオーディオシンクを使用するには、実装したカスタムオーディオシンクのオブジェクトを引数として、PlanetKitAudioManagerのsetCustomSpeaker()を呼び出し、アプリケーション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)")
}
...
}
カスタムオーディオシンクをスピーカーに戻しておく
カスタムオーディオシンクをスピーカーに戻しておくには、PlanetKitAudioManagerのresetCustomSpeakerToDefaultSpeaker()を呼び出し、アプリケーションAECリファレンスの使用を中止するために通話セッションクラスのstopUserAcousticEchoCancellerReference()を呼び出します。
func disableCustomMixingSpeaker() {
customSpeaker?.stop()
PlanetKitAudioManager.shared.resetCustomSpeakerToDefaultSpeaker()
conference?.stopUserAcousticEchoCancellerReference { success in
print("Custom speaker AEC reference disabled \(success)")
}
...
}