iOS 스트리밍 FIR 검증: 블록 간 상태와 꼬리 출력
Windows에서 오디오 기능을 완성한 뒤 클라우드 Mac으로 옮겨 iOS 테스트를 실행하면, 전체 입력을 한 번에 필터링할 때는 정상이지만 블록으로 나누어 처리할 때는 경계에서 스파이크가 발생할 수 있습니다. 이때 볼륨을 조정하거나 계수를 바꾸기 전에, 이전 샘플 이력이 블록 사이에서 유지되는지와 스트림 종료 시 필터의 꼬리 출력이 빠지지 않는지부터 확인해야 합니다. 여기서는 순수 Swift 스칼라 구현을 기준으로 삼아, 이후 최적화 결과를 비교할 수 있도록 합니다.
먼저 입력과 출력의 계약 정의하기
이 글에서는 유한 길이의 모노 입력을 처리하는 인과적 FIR 필터를 다룹니다. h[0]은 현재 입력에, h[1]은 직전 입력에 곱하는 것으로 정의합니다. 스트림 시작 전의 이력은 모두 0이며, 처리 중에는 입력 샘플마다 출력 샘플을 하나씩 생성합니다. 입력이 끝나면 0을 추가로 넣어 꼬리 출력을 생성합니다.
비어 있지 않은 입력 N개와 계수 M개에 대해 전체 출력 길이는 N+M−1이어야 합니다. 빈 입력 스트림은 빈 배열을 반환해야 하며, 종료를 반복 호출해도 꼬리 출력을 다시 생성해서는 안 됩니다. 종료 후에는 입력을 추가할 수 없습니다. 서비스에서 출력 N개만 필요하다면 출력을 잘라내는 계약을 명시적으로 채택해야 합니다. 빠진 샘플을 단순한 구현 세부 사항으로 취급해서는 안 됩니다.
블록은 데이터를 전달하기 위한 경계일 뿐, 신호의 경계가 아닙니다. 블록이 끝날 때마다 필터를 플러시하거나 다음 블록이 시작될 때 이력을 초기화해서는 안 됩니다.
테스트 계수에는 비대칭 배열을 사용합니다. 그래야 계수 순서를 거꾸로 적용한 구현이 테스트를 통과하는 일을 막을 수 있습니다. 소리가 부드러워졌다는 주관적인 청감부터 정확성의 판단 기준으로 삼지 마세요.
클라우드 Mac에 최소 테스트 패키지 만들기
사용 가능한 개발 도구 체인이 선택되어 있는지 확인한 뒤 별도 디렉터리를 만듭니다.
xcrun swift --version
mkdir FIRContract
cd FIRContract
swift package init --type library --name FIRContract
다음 구현을 Sources/FIRContract/FIRContract.swift에 작성합니다. 이 구현은 배열 요소를 이동하며, 계수 개수가 늘수록 연산량도 증가합니다. 읽기 쉬운 정확성 검증용 기준 구현이 목적이므로 실시간 오디오 콜백에 그대로 넣기에는 적합하지 않습니다.
struct FIR {
private let taps: [Double]
private var history: [Double]
private var hasInput = false
private var ended = false
init(taps: [Double]) {
precondition(!taps.isEmpty)
precondition(taps.allSatisfy { $0.isFinite })
self.taps = taps
self.history = Array(repeating: 0, count: taps.count)
}
mutating func process(_ input: [Double]) -> [Double] {
precondition(!ended)
return input.map { sample in
precondition(sample.isFinite)
hasInput = true
history.insert(sample, at: 0)
history.removeLast()
return zip(taps, history).reduce(0.0) {
$0 + $1.0 * $1.1
}
}
}
mutating func finish() -> [Double] {
guard !ended else { return [] }
let tail = hasInput
? process(Array(repeating: 0, count: taps.count - 1))
: []
ended = true
return tail
}
}
계수와 샘플 모두 유한하지 않은 값은 허용하지 않습니다. 잘못된 입력 하나가 이후 여러 출력에 영향을 주는 것을 막기 위해서입니다. 여기서는 사전 조건으로 프로그램 내부 계약을 표현합니다. 외부 오디오 파일을 받는 경우에는 호출 전에 검증하고, 처리 가능한 오류를 반환해야 합니다. 손상된 데이터를 프로세스 종료에 의존해 처리해서는 안 됩니다.
고정 벡터로 계수 방향과 블록 간 이력 확인하기
생성된 테스트 파일의 내용을 다음 코드로 교체합니다. 입력이 [2,4,6]이고 계수가 [0.5,0.25]이면 손으로 계산한 전체 결과는 [1,2.5,4,1.5]입니다. 두 번째 출력에는 현재 입력값과 직전 샘플의 기여분이 모두 포함되어야 합니다.
import XCTest
@testable import FIRContract
final class FIRContractTests: XCTestCase {
private func render(_ chunks: [[Double]]) -> [Double] {
var filter = FIR(taps: [0.5, 0.25])
var output: [Double] = []
for chunk in chunks {
output += filter.process(chunk)
}
output += filter.finish()
XCTAssertTrue(filter.finish().isEmpty)
return output
}
private func check(_ actual: [Double], _ expected: [Double]) {
XCTAssertEqual(actual.count, expected.count)
for (a, e) in zip(actual, expected) {
XCTAssertEqual(a, e, accuracy: 1e-12)
}
}
func testKnownVectorAndChunkLayouts() {
let expected = [1.0, 2.5, 4.0, 1.5]
check(render([[2, 4, 6]]), expected)
check(render([[2], [4], [6]]), expected)
check(render([[], [2, 4], [], [6], []]), expected)
check(render([[2], [4, 6]]), expected)
}
func testImpulseAndEmptyStream() {
check(render([[1], [0, 0]]), [0.5, 0.25, 0, 0])
check(render([[], []]), [])
}
func testSingleTapHasNoTail() {
var filter = FIR(taps: [2])
check(filter.process([1, -1]), [2, -2])
XCTAssertTrue(filter.finish().isEmpty)
}
}
패키지 루트 디렉터리에서 swift test를 실행합니다. 먼저 결과를 알고 있는 벡터로 검증한 다음, 블록 분할 방식이 달라도 결과가 같은지 확인합니다. 두 가지 분할 결과만 비교하면 두 처리 경로가 같은 오류를 공유하고 있어도 놓칠 수 있습니다. 단위 임펄스는 계수 방향과 초기 이력의 문제를 직접 드러냅니다.
블록 경계를 회귀 테스트 케이스로 만들기
잡음이 다시 나기를 기다리지 말고 모든 분할 위치 검사하기
짧은 고정 입력을 대상으로, 시작과 끝을 포함해 한 번 나눌 수 있는 모든 위치를 검사합니다. 각 분할마다 필터를 새로 만들고 전체 입력을 한 번에 처리한 결과와 비교합니다. 샘플 하나씩 나누는 방식, 블록 크기를 번갈아 바꾸는 방식, 블록 길이가 계수 개수보다 짧은 경우도 추가합니다. 특히 M이 N보다 클 때도 꼬리 출력이 온전히 생성되는지 확인합니다.
입력에는 모두 0인 신호, 양수와 음수가 번갈아 나오는 신호, 상수 신호, 임펄스도 포함해야 합니다. 출력 길이는 먼저 별도의 단언문으로 검증해야 합니다. zip으로 값만 비교하면 더 긴 배열의 나머지 부분은 무시되므로 충분하지 않습니다.
스트림 종료와 일시적인 데이터 부재 구분하기
빈 블록은 이번에 샘플이 없다는 뜻일 뿐, 스트림이 끝났다는 뜻이 아닙니다. 상위 입력 소스에서 종료를 확정했을 때만 finish()를 호출해야 합니다. 잠시 멈췄다가 입력이 다시 들어오더라도 이력은 유지되어야 합니다. 다시 재생하는 상황을 테스트할 때는 새 인스턴스를 만들고, 새 스트림에 이전 스트림의 꼬리 출력이 섞이지 않는지 검증합니다.
다채널 오디오는 채널마다 이력을 독립적으로 유지해야 합니다. 좌우 채널이 인터리브된 데이터를 모노 필터에 그대로 넣으면, 두 채널의 샘플을 시간상 연속된 샘플로 처리해 누화가 발생합니다. 이는 허용 오차를 조정해서 해결할 수 있는 문제가 아닙니다.
최적화 구현을 적용하기 전 검증 체크리스트
프로덕션 구현을 링 버퍼나 벡터화 연산으로 교체하더라도 이 스칼라 참조 구현은 유지합니다. 입력, 계수, 종료 정책을 동일하게 맞추고 출력 길이와 각 샘플의 오차를 비교합니다. 부동소수점 누산 순서가 달라지면 비트 단위로 일치할 필요는 없지만, 절대 오차와 상대 오차의 판정 규칙은 미리 고정해야 합니다. 테스트가 실패한 뒤 임의로 허용 범위를 넓혀서는 안 됩니다.
실시간 콜백에서는 동적 할당, 잠금, 처리 시간 예산도 별도로 확인해야 합니다. 이 글의 테스트는 실시간 성능을 입증하지 않으며, 필터의 주파수 응답이 제품 요구 사항에 맞는지도 평가하지 않습니다. 호스트에서 테스트를 통과한 뒤에도 iOS 테스트 타깃에서 다시 실행하고, 실제 기기로 입력 형식, 채널 매핑, 연속 재생 동작을 확인해야 합니다.
테스트를 보관할 때는 커밋 식별자, 도구 체인 버전, 계수, 블록 분할 시퀀스, 실패 위치를 함께 남깁니다. 블록 경계에서 발생한 이상을 매번 고정 입력으로 재현할 수 있어야 알고리즘의 상태 오류, 형식 변환 오류, 실제 재생 경로의 문제를 구분할 수 있습니다.
자주 묻는 질문
오디오 블록을 각각 필터링하면 왜 경계 문제가 생기나요?
FIR 출력은 이전 입력 샘플에 의존합니다. 블록마다 초기화하면 이력이 사라져 각 경계 앞에 영을 넣은 것처럼 됩니다. 스트림마다 연속 상태를 유지하고 새 스트림에서만 다시 생성해야 합니다.
입력이 N개이면 최종 출력은 몇 개여야 하나요?
비어 있지 않은 N개 입력과 M개 계수의 완전한 선형 합성곱은 꼬리까지 출력하면 N+M−1개입니다. N개만 반환하는 인터페이스라면 꼬리를 자른다는 계약을 명시해야 합니다.
빌드 작업에 맞춰 전용 Mac을 대여하세요.
가까운 노드를 선택한 다음, 프로젝트에 필요한 메모리와 저장 공간, 이용 기간을 확인하세요. 가상 머신이 아닌 물리 머신 한 대를 단독으로 사용하며, 실제 이용 가능 여부는 콘솔에서 실시간으로 확인할 수 있습니다.