본문으로 건너뛰기
Version: 6.0

커스텀 오디오 디바이스

기본적으로 PlanetKit은 통화 시작 시 마이크나 스피커 장치를 자동으로 감지하여 제어하며, 마이크에서 캡처한 오디오를 통화에서 사용하여 상대방에게 전달하고, 수신한 오디오 데이터를 스피커로 재생합니다. 하지만 애플리케이션의 요구사항에 따라 이 구조를 변경해야 할 필요가 있을 수 있습니다. 예를 들어, 사용자의 오디오 대신 파일의 오디오를 전송하거나, 수신한 오디오 데이터를 스피커가 아닌 파일로 저장하는 경우입니다.

이러한 요구를 충족할 수 있도록 PlanetKit은 원하는 오디오 데이터를 직접 공급할 수 있는 커스텀 오디오 소스(custom audio source)와 수신한 오디오 데이터를 직접 처리할 수 있는 커스텀 오디오 싱크(custom audio sink) 기능을 제공합니다. 이 기능을 통해 개발자가 원하는 오디오 데이터를 직접 PlanetKit 모듈에 제공하여 상대방에게 전달하거나, 상대방으로부터 수신한 오디오 데이터를 직접 제어할 수 있습니다.

Note

커스텀 오디오 소스 사용 시 PlanetKit은 마이크 장치를 제어하지 않으며, 커스텀 오디오 싱크 사용 시에는 스피커 장치를 제어하지 않습니다.

지원 통화 유형최소 SDK 버전
1대1 통화, 그룹 통화(컨퍼런스)PlanetKit 6.0

활용 사례

커스텀 오디오 디바이스의 주요 활용 사례는 다음과 같습니다.

커스텀 오디오 소스

  • 음성 파일을 오디오 소스로 사용
  • 외부 오디오 소스(라이브 방송 중계나 웹 스트리밍) 연동
  • 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 설정이 필요한 경우
    • 조건
      • 아래의 조건 중 하나라도 해당하는 경우
        • 커스텀 오디오 소스에서 실제 마이크 장치가 아닌 다른 입력(예: wav 파일, HTTP URL 등)을 사용하는 경우
        • 커스텀 오디오 싱크에서 실제 스피커 장치가 아닌 다른 출력(예: 파일 레코딩)을 사용하는 경우
    • 필요 설정
      • 커스텀 오디오 디바이스 사용 시작 후 setAcousticEchoCanceller(mode: .disabled) 호출
      • 커스텀 오디오 디바이스 사용 종료 후 setAcousticEchoCanceller(mode: .intensityRecommended) 호출
  • Case 2: 애플리케이션 AEC 레퍼런스 설정이 필요한 경우
    • 조건
      • PlanetKit에서 전달된 스피커 데이터를 커스텀 오디오 싱크에서 변조하여 출력하는 경우
        • 단, 마이크 입력이 실제 마이크 장치일 경우에만 해당(입력이 wav 파일 등 실제 에코가 입력될 수 있는 장치가 아닐 경우엔 해당 없음)
    • 필요 설정
      • 커스텀 오디오 싱크 사용 시작 후 startUserAcousticEchoCancellerReference() 호출
      • putUserAcousticEchoCancellerReference()로 스피커에 출력하고자 하는 음원을 제공
      • 커스텀 오디오 싱크 사용 종료 후 stopUserAcousticEchoCancellerReference() 호출

Case 1: VQE 제어의 AEC 설정이 필요한 경우

커스텀 오디오 소스 또는 커스텀 오디오 싱크를 이용해 실제 마이크와 스피커가 아닌 다른 입력 또는 출력을 사용한다면, 통화 세션 클래스인 PlanetKitCallPlanetKitConference에서 제공하는 VQE 기능 중 하나인 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 레퍼런스 설정이 필요한 경우

통화 세션 클래스인 PlanetKitCallPlanetKitConference에서 제공하는 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