여러 시간대에 걸친 팀이 녹음 변환 코드를 인계할 때, 가장 발견하기 어려운 오류는 충돌이 아니라 끝부분의 소리가 조금 잘리는 문제인 경우가 많습니다. 클라우드 Mac에서 동일한 고정 테스트 픽스처를 실행하고, 입력 종료와 잔여 출력 처리를 먼저 확인한 다음 프레임 수를 검사하면 근무 시간대가 달라도 같은 문제를 재현할 수 있습니다. 이 글에서는 PCM 리샘플링만 검증하며, 파일 인코딩이나 마이크 녹음, 스피커 재생은 결과에 포함하지 않습니다.
먼저 변환 조건 확정하기
원본 샘플 레이트, 목표 샘플 레이트, 채널 수, 인터리브 여부, 변환 품질, primeMethod를 기록합니다. 샘플 레이트가 달라지면 프레임 수도 대개 달라집니다. 따라서 입력 프레임 수와 출력 프레임 수가 같다는 것을 성공 조건으로 삼으면 안 됩니다.
재생 길이를 유지할 때 이론적인 출력 프레임 수는 다음과 같습니다.
입력 프레임 수 × 목표 샘플 레이트 ÷ 원본 샘플 레이트
예를 들어 48000 Hz의 모노 오디오 48000 프레임을 44100 Hz로 변환하면 이론적인 출력은 44100 프레임입니다. 계산 결과가 정수가 아니면 반올림과 신호 경계에서의 변환기 동작도 고려해야 합니다. 허용 오차는 나중에 느슨하게 바꾸지 말고 테스트에 미리 명시해야 합니다.
변환기가 성공을 반환했다는 것은 해당 호출에서 오류가 발생하지 않았다는 뜻일 뿐입니다. 출력의 완전성을 보장하려면 입력 종료를 명확히 알리고 변환기 내부에 남은 출력까지 모두 가져와야 합니다.
먼저 비인터리브 Float32 모노 형식으로 변수를 줄입니다. 실제 서비스의 처리 경로에서 다른 형식을 사용한다면 이 기준 테스트를 통과한 뒤 별도 테스트 케이스를 추가합니다.
고정 신호로 변환 루프 검증하기
명령줄 개발 도구가 설치된 클라우드 Mac에서 아래 코드를 resample.swift로 저장하고 swift resample.swift를 실행합니다. 오디오 장치가 필요하지 않으며 소리도 재생하지 않습니다.
import AVFoundation
import Foundation
enum CheckError: Error {
case setup
case conversion
case stalled
}
func resample(_ input: AVAudioPCMBuffer, rate: Double) throws -> [Float] {
guard
let format = AVAudioFormat(
commonFormat: .pcmFormatFloat32,
sampleRate: rate,
channels: 1,
interleaved: false
),
let converter = AVAudioConverter(from: input.format, to: format)
else {
throw CheckError.setup
}
converter.primeMethod = .none
converter.sampleRateConverterQuality = AVAudioQuality.high.rawValue
var supplied = false
var samples: [Float] = []
for _ in 0..<1000 {
guard let output = AVAudioPCMBuffer(
pcmFormat: format,
frameCapacity: 4096
) else {
throw CheckError.setup
}
var error: NSError?
let status = converter.convert(to: output, error: &error) {
_, inputStatus in
if supplied {
inputStatus.pointee = .endOfStream
return nil
}
supplied = true
inputStatus.pointee = .haveData
return input
}
if let error = error {
throw error
}
if status == .error {
throw CheckError.conversion
}
if output.frameLength > 0 {
guard let channel = output.floatChannelData?[0] else {
throw CheckError.setup
}
samples.append(contentsOf: UnsafeBufferPointer(
start: channel,
count: Int(output.frameLength)
))
}
if status == .endOfStream {
return samples
}
}
throw CheckError.stalled
}
let sourceRate = 48000.0
let targetRate = 44100.0
let frames: AVAudioFrameCount = 48000
guard
let sourceFormat = AVAudioFormat(
commonFormat: .pcmFormatFloat32,
sampleRate: sourceRate,
channels: 1,
interleaved: false
),
let input = AVAudioPCMBuffer(
pcmFormat: sourceFormat,
frameCapacity: frames
),
let channel = input.floatChannelData?[0]
else {
throw CheckError.setup
}
input.frameLength = frames
for index in 0..<Int(frames) {
channel[index] = Float(
0.25 * sin(2 * Double.pi * 1000 * Double(index) / sourceRate)
)
}
let output = try resample(input, rate: targetRate)
let expected = Double(frames) * targetRate / sourceRate
let frameError = abs(Double(output.count) - expected)
print("frames=\(output.count), frameError=\(frameError)")
precondition(!output.isEmpty)
precondition(output.allSatisfy { $0.isFinite })
precondition(frameError <= 64)
여기서 64 프레임은 예시로 정한 허용 오차이며, 프레임워크가 보장하는 값이 아닙니다. 먼저 시스템과 도구 체인 버전을 고정한 환경에서 실제 차이를 확인한 뒤, 제품에서 허용하는 길이 오차에 맞춰 기준을 좁혀야 합니다. 44100 Hz에서 64 프레임은 약 1.45밀리초입니다.
반복 횟수 상한은 픽스처가 무한히 실행되는 것을 막기 위한 장치일 뿐, 임의 길이의 오디오를 처리하기 위한 일반적인 제한이 아닙니다. 긴 녹음은 청크 단위로 공급하고 입력 길이에 맞춰 진행 여부를 감시하도록 바꿔야 합니다.
입력 종료, 잔여 출력, 내용을 따로 검사하기
입력 콜백은 첫 호출에서 버퍼를 제공하고 이후 호출에서는 명시적으로 .endOfStream을 반환합니다. 실제 데이터가 일시적으로 도착하지 않은 경우에만 .noDataNow를 사용합니다. 일시적인 데이터 부족을 종료로 처리하면 오디오가 잘리고, 종료를 일시적인 부족으로 처리하면 변환이 끝나지 않을 수 있습니다.
출력 측에서는 호출할 때마다 frameLength가 가리키는 유효 데이터를 먼저 수집한 뒤 상태를 처리합니다. 마지막 호출의 상태가 이미 종료라고 해서 같은 호출에서 전달된 유효 프레임을 버리면 안 되며, frameCapacity까지 읽어서도 안 됩니다.
끝부분 마커로 프레임 누락 찾기
사인파는 빈 출력이나 유한하지 않은 값을 찾는 데 적합하지만, 끝부분이 짧게 잘린 문제를 드러내기는 어렵습니다. 앞부분은 무음이고 마지막 구간에만 낮은 진폭의 사인파가 들어 있는 픽스처를 추가해, 예상되는 끝부분 구간에 에너지가 존재하는지 검사합니다.
마지막 샘플이 반드시 0이 아니어야 한다고 단정하지 마세요. 필터가 경계의 파형을 바꾸기 때문입니다. 출력 길이와 끝부분 구간의 에너지를 함께 저장하면 실패 시 “프레임이 부족한 경우”와 “프레임 수는 맞지만 내용이 비어 있는 경우”를 구분할 수 있습니다.
1초 신호에 그치지 말고 경계 조건 넓히기
최소한 네 가지 독립적인 테스트 그룹을 추가합니다. 모든 값이 0인 입력은 거의 무음으로 유지되어야 하고, 매우 짧은 입력도 정상적으로 종료되어야 합니다. 프레임 수 환산 결과가 정수가 아닌 경우에는 정해 둔 허용 오차를 만족해야 하며, 반대 방향으로 변환할 때는 이론적인 프레임 수를 다시 계산해야 합니다.
빈 입력은 비즈니스 로직의 래퍼 계층에서 명시적으로 빈 결과를 반환하도록 처리합니다. 용량만 있고 유효 프레임은 없는 버퍼를 만들어 오디오 데이터인 것처럼 넘기면 안 됩니다. 잘못된 샘플 레이트도 거부하고 한 번에 받는 입력 길이를 제한해, 테스트 픽스처 자체가 메모리를 소진하지 않도록 해야 합니다.
스테레오 테스트에서는 좌우 채널에 서로 다른 주파수를 사용하고 두 출력 채널을 각각 검사합니다. 그래야 왼쪽 채널을 복사하는 구현이 테스트를 통과하지 못합니다. 형식을 바꾼 뒤에는 예제처럼 첫 번째 채널만 읽는 수집 로직을 그대로 사용하면 안 됩니다.
왕복 리샘플링에서도 샘플별 일치를 요구하면 안 됩니다. 필터링과 양자화가 샘플 값을 바꾸므로 길이, 값의 유한성, 에너지, 허용 오차를 검사해야 합니다. 실제 녹음의 청감 품질은 별도로 검증합니다.
시간대 간 인계를 재현 가능한 증거의 인계로 바꾸기
회귀 테스트를 실행할 때마다 커밋 식별자, 시스템 버전, 개발 도구 버전, 변환 매개변수, 입력 프레임 수, 출력 프레임 수, 오차를 저장합니다. 차이가 발견되면 먼저 고정 픽스처를 다시 실행한 뒤 변환 방식을 비교합니다. 곧바로 노드를 바꿔 문제를 가리지 마세요.
명령줄 픽스처는 공통 변환 로직을 검증합니다. 병합 전에는 iOS 테스트 타깃에서도 같은 테스트 모음을 실행해 대상 시스템의 동작을 확인해야 합니다. 이 픽스처만으로는 녹음 권한, 오디오 세션 인터럽트 처리, 실제 출력 경로가 올바른지 입증할 수 없습니다.
인계 결론은 “어떤 입력과 매개변수 조합에서, 어떤 상태일 때 실패했는지”로 작성하고 최소 재현 픽스처를 첨부합니다. 그러면 다음 근무자가 넘겨받는 것은 “오디오가 짧게 들린다”는 말이 아니라 직접 실행할 수 있는 증거가 됩니다.
자주 묻는 질문
noDataNow는 입력이 끝났다는 뜻인가요?
아닙니다. 지금은 데이터가 없지만 나중에 공급할 수 있다는 뜻입니다. 입력이 모두 끝나면 endOfStream을 반환하고 출력도 endOfStream이 될 때까지 변환 결과를 읽어야 합니다.
리샘플링 전후 프레임 수가 같아야 하나요?
아닙니다. 입력 프레임 수에 출력 샘플레이트와 입력 샘플레이트의 비율을 곱한 값이 기준입니다. primeMethod와 변환 품질을 고정하고 명시한 오차 범위로 판정하세요.
다음 작업에 맞는 클라우드 Mac 선택
팀이 주로 위치한 지역을 기준으로 리전을 선택하고, Mac mini 전체를 단독으로 사용하세요. 브라우저 원격 데스크톱을 이용하려면 최신 브라우저와 안정적인 네트워크가 필요합니다. 주문 전에 구성, 결제 주기, 실제 이용 가능 여부를 확인하세요.