Аудиофункции уже работают в Windows, но при переносе тестов iOS на облачный Mac обнаруживается проблема: при обработке сигнала целиком фильтр работает правильно, а при подаче блоками на стыках появляются выбросы. Не спешите менять громкость или коэффициенты. Сначала стоит проверить, сохраняется ли история отсчётов между блоками и полностью ли выводится хвост фильтра при завершении потока. Ниже мы создадим скалярную реализацию на чистом Swift — эталон, с которым можно будет сравнивать результаты последующих оптимизаций.
Сначала зафиксируйте контракт входа и выхода
В статье рассматривается одноканальный каузальный FIR-фильтр для входного сигнала конечной длины. Договоримся, что h[0] умножается на текущий входной отсчёт, а h[1] — на предыдущий. До начала потока вся история заполнена нулями. Во время обработки каждому входному отсчёту соответствует один выходной, а после завершения входа подаются нули, чтобы получить хвост фильтра.
Для непустого входа из 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 хвост всё равно выводится полностью.
Входные данные также должны включать нулевой сигнал, чередование положительных и отрицательных значений, постоянный сигнал и импульс. Длину выхода необходимо проверять отдельным утверждением до сравнения значений. Одного сравнения через zip недостаточно: оно игнорирует оставшуюся часть более длинного массива.
Различайте завершение потока и временное отсутствие данных
Пустой блок означает лишь то, что в этот раз отсчётов нет, а не то, что поток закончился. Вызывайте finish() только после подтверждения завершения со стороны источника. Если после короткой паузы поступают новые данные, история должна сохраняться. При тестировании повторного воспроизведения создавайте новый экземпляр и проверяйте, что в новый поток не попадает хвост предыдущего.
Для многоканального звука каждому каналу нужна отдельная история. Если передать чередующиеся отсчёты левого и правого каналов прямо в одноканальный фильтр, он воспримет их как соседние по времени отсчёты одного сигнала. Это приведёт к взаимному проникновению каналов; изменением допуска такую проблему не исправить.
Проверки перед подключением оптимизированной реализации
При переходе производственной реализации на кольцевой буфер или векторные вычисления сохраните этот скалярный эталон. Используйте одинаковые входные данные, коэффициенты и правила завершения, сравнивая длину выхода и погрешность каждого отсчёта. После изменения порядка суммирования чисел с плавающей точкой побитовое совпадение не требуется, но правила абсолютной и относительной погрешности нужно определить заранее, а не произвольно ослаблять после сбоя теста.
Для колбэка реального времени отдельно проверьте динамические выделения памяти, блокировки и соблюдение временного бюджета обработки. Тесты из этой статьи не подтверждают пригодность для работы в реальном времени и не оценивают, соответствует ли частотная характеристика фильтра требованиям продукта. После успешного запуска тестов на хосте повторите их в тестовом таргете iOS, а на физическом устройстве проверьте формат входных данных, соответствие каналов и поведение при непрерывном воспроизведении.
При архивировании результатов сохраняйте идентификатор коммита, версию набора инструментов, коэффициенты, последовательность блоков и место сбоя. Только если каждую аномалию на стыке можно воспроизвести на фиксированном входе, получится отличить ошибку состояния алгоритма от ошибки преобразования формата или проблемы в реальном тракте воспроизведения.
Часто задаваемые вопросы
Почему независимая фильтрация блоков создаёт разрывы?
Выход FIR зависит от предыдущих входных отсчётов. Повторная инициализация теряет историю и фактически подставляет нули перед каждой границей. Храните отдельное непрерывное состояние для каждого потока.
Сколько отсчётов должно быть после завершения потока?
Для непустого входа длиной N и фильтра с M коэффициентами полная линейная свёртка содержит N+M−1 отсчётов. Интерфейс с выходом длиной N должен явно указывать, что хвост отбрасывается.
Арендуйте выделенный Mac для сборки приложений.
Выберите ближайший узел, затем проверьте, сколько памяти и места на диске требуется проекту, и подберите период оплаты. В вашем распоряжении будет отдельный физический Mac, а не виртуальная машина. Актуальную доступность можно проверить в панели управления.