Когда команды из разных часовых поясов передают друг другу код преобразования аудиозаписей, труднее всего заметить не сбой, а потерю небольшого фрагмента в конце. Если запускать один и тот же фиксированный тестовый пример на облачном Mac, сначала проверяя завершение входного потока и выгрузку оставшегося вывода, а затем число кадров, разные смены смогут воспроизвести одну и ту же проблему. Здесь проверяется только ресемплинг PCM: кодирование файлов, захват с микрофона и воспроизведение через динамики в результаты не входят.
Сначала зафиксируйте условия преобразования
Запишите исходную и целевую частоты дискретизации, число каналов, способ чередования данных, качество преобразования и primeMethod. При изменении частоты дискретизации обычно меняется и число кадров. Равенство числа входных и выходных кадров не может служить критерием успеха.
При сохранении длительности теоретическое число выходных кадров рассчитывается так:
Число входных кадров × Целевая частота дискретизации ÷ Исходная частота дискретизации
Например, преобразование монофонического сигнала из 48000 кадров с частотой 48000 Hz в 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 миллисекунды.
Ограничение числа итераций лишь защищает тест от бесконечного выполнения. Это не универсальный предел для обработки аудио произвольной длины. Длинные записи нужно подавать блоками и контролировать продвижение с учётом длины входных данных.
Проверяйте завершение, выгрузку остатка и содержимое по отдельности
При первом обращении входной callback передаёт буфер, а при последующих явно возвращает .endOfStream. Используйте .noDataNow только тогда, когда реальные данные временно недоступны. Если принять временное отсутствие данных за конец потока, аудио обрежется; если принять конец за временное отсутствие, преобразование может не завершиться.
На выходе при каждом вызове сначала собирайте действительные данные в количестве, указанном в frameLength, и только затем обрабатывайте статус. Не отбрасывайте действительные кадры, полученные вместе с финальным статусом, и не считывайте данные до границы frameCapacity.
Используйте маркер в конце сигнала для поиска потерянных кадров
Синусоидальный сигнал помогает обнаружить пустой вывод и неконечные значения, но плохо выявляет потерю короткого фрагмента в конце. Добавьте ещё один тестовый сигнал: тишина в начале и синусоида небольшой амплитуды в конце. Проверьте наличие энергии в ожидаемом конечном окне.
Не требуйте, чтобы последний отсчёт обязательно был ненулевым: фильтр меняет форму сигнала на границах. Сохраняйте и длину вывода, и энергию конечного окна, чтобы при сбое можно было отличить нехватку кадров от ситуации, когда число кадров верное, но содержимое пустое.
Проверяйте граничные случаи, а не только секундный сигнал
Добавьте как минимум четыре независимые группы тестов: полностью нулевой вход должен оставаться практически бесшумным; очень короткий вход должен корректно завершаться; длина, дающая нецелый результат пересчёта, должна укладываться в установленный допуск; для преобразования в обратном направлении нужно заново вычислять теоретическое число кадров.
Для пустого входа прикладная обёртка должна явно возвращать пустой результат. Не подменяйте аудиосигнал буфером, у которого есть ёмкость, но нет действительных кадров. Также отклоняйте недопустимые частоты дискретизации и ограничивайте длину одного входного блока, чтобы сам тест не исчерпал память.
В стереотесте используйте разные частоты для левого и правого каналов и проверяйте оба выходных канала отдельно. Это не позволит реализации, копирующей левый канал в правый, пройти проверку. После изменения формата нельзя оставлять из примера логику сбора данных, которая читает только первый канал.
При ресемплинге туда и обратно также нельзя требовать совпадения каждого отсчёта. Фильтрация и квантование меняют значения: проверяйте длительность, конечность значений, энергию и допустимую погрешность. Субъективное качество звучания реальных записей оценивайте отдельно.
Передавайте между часовыми поясами воспроизводимые доказательства
Для каждого регрессионного прогона сохраняйте идентификатор коммита, версии системы и инструментов разработчика, параметры преобразования, число входных и выходных кадров и погрешность. При расхождении сначала повторно запустите фиксированный тест, затем сравните стратегии преобразования. Не меняйте сразу узел, маскируя проблему.
Тест командной строки проверяет общую логику преобразования. Перед слиянием изменений тот же набор тестов всё равно нужно запустить в тестовом таргете iOS, чтобы подтвердить поведение на целевой системе. Этот тест не доказывает корректность разрешений на запись, обработки прерываний аудиосессии или фактического маршрута вывода.
В итогах передачи укажите, «на каком входе, с каким набором параметров и в каком состоянии произошёл сбой», и приложите минимальный тестовый пример. Тогда следующая смена получит исполняемые доказательства, а не фразу «кажется, запись стала короче».
Часто задаваемые вопросы
Означает ли noDataNow конец входных данных?
Нет. Это временное отсутствие данных. Когда вход исчерпан, верните endOfStream и продолжайте получать выходные кадры до состояния endOfStream на выходе.
Должно ли число кадров сохраняться после ресемплинга?
Нет. Ожидаемое число равно числу входных кадров, умноженному на отношение выходной частоты к входной. Проверяйте его с явно заданным допуском при фиксированных primeMethod и качестве.
Выберите облачный Mac для следующей задачи
Выберите сервер в регионе, где находится основная часть команды, и получите в полное распоряжение физический Mac mini. Для удалённого рабочего стола в браузере нужны современный браузер и стабильное подключение к интернету. Перед заказом проверьте конфигурацию, период оплаты и фактическую доступность.