La fonctionnalité audio est au point sous Windows, mais lors des tests iOS sur un Mac dans le cloud, un problème apparaît : le filtrage du signal en une seule passe fonctionne, tandis que le traitement par blocs produit des pics aux jonctions. Avant de modifier le volume ou les coefficients, vérifiez que l’historique des échantillons est conservé entre les blocs et que la fin du traitement restitue bien toute la traîne du filtre. Nous allons établir une référence scalaire en Swift pur, afin de disposer d’une base de comparaison pour les optimisations à venir.
Définir d’abord le contrat d’entrée-sortie
Nous traitons ici un FIR causal, mono, appliqué à un signal de longueur finie. Par convention, h[0] multiplie l’échantillon courant et h[1] l’échantillon précédent. Tout l’historique antérieur au début du flux est nul. Pendant le traitement, chaque échantillon d’entrée produit un échantillon de sortie ; une fois le flux terminé, des zéros sont injectés pour restituer la traîne.
Pour une entrée non vide de N échantillons et M coefficients, la sortie complète doit compter N+M−1 échantillons. Un flux d’entrée vide renvoie un tableau vide ; les appels de fin répétés ne doivent pas produire de traîne supplémentaire ; aucune entrée ne peut être ajoutée après la clôture. Si l’application n’a besoin que de N échantillons de sortie, elle doit adopter explicitement un contrat de sortie tronquée, plutôt que de considérer les échantillons manquants comme un détail d’implémentation.
Les blocs délimitent le transport des données, pas le signal. Il ne faut ni vider le filtre à la fin de chaque bloc, ni effacer l’historique au début du suivant.
Choisissez des coefficients asymétriques pour les tests : une inversion de leur ordre ne doit pas pouvoir passer inaperçue. Ne prenez pas comme premier critère de justesse l’impression subjective de lissage à l’écoute.
Créer un paquet de test minimal sur le Mac dans le cloud
Vérifiez qu’une chaîne d’outils de développement utilisable est sélectionnée, puis créez un répertoire indépendant :
xcrun swift --version
mkdir FIRContract
cd FIRContract
swift package init --type library --name FIRContract
Placez l’implémentation suivante dans Sources/FIRContract/FIRContract.swift. Elle repose sur des décalages dans un tableau, et son coût de calcul augmente avec le nombre de coefficients. Son rôle est de fournir une référence lisible pour vérifier la justesse des résultats, pas d’être intégrée telle quelle à un callback audio temps réel.
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
}
}
Les valeurs non finies sont refusées, aussi bien pour les coefficients que pour les échantillons, afin qu’une entrée invalide ne contamine pas plusieurs sorties successives. Les préconditions expriment ici un contrat interne au programme. Pour des fichiers audio externes, la validation doit avoir lieu avant l’appel et renvoyer une erreur que l’application peut gérer : l’arrêt du processus n’est pas une façon de traiter des données corrompues.
Vérifier l’ordre des coefficients et l’historique entre blocs avec un vecteur connu
Remplacez le contenu du fichier de test généré par le code ci-dessous. Avec l’entrée [2,4,6] et les coefficients [0.5,0.25], le calcul manuel donne la sortie complète [1,2.5,4,1.5]. Le deuxième échantillon de sortie doit inclure à la fois la contribution de l’échantillon courant et celle de l’échantillon précédent.
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)
}
}
Exécutez swift test à la racine du paquet. Vérifiez d’abord le vecteur connu, puis l’équivalence entre les découpages : si vous comparez uniquement les résultats de deux découpages, les deux chemins peuvent partager la même erreur. L’impulsion unité, quant à elle, révèle directement les problèmes d’ordre des coefficients et d’historique initial.
Faire des frontières de blocs des cas de non-régression
Énumérer les découpages plutôt qu’attendre le retour des parasites
Pour une entrée fixe assez courte, testez chaque position possible d’une coupure unique, y compris au début et à la fin. Créez un nouveau filtre pour chaque découpage, puis comparez le résultat à celui obtenu avec l’entrée complète. Ajoutez un découpage échantillon par échantillon, des blocs de tailles alternées et des blocs plus courts que le nombre de coefficients. Vérifiez tout particulièrement que la traîne reste complète lorsque M est supérieur à N.
Les entrées doivent aussi couvrir un signal entièrement nul, une alternance de valeurs positives et négatives, un signal constant et une impulsion. La longueur de sortie doit faire l’objet d’une assertion distincte avant la comparaison des valeurs : se limiter à zip masquerait les éléments restants du tableau le plus long.
Distinguer la fin du flux d’une absence temporaire de données
Un bloc vide signifie seulement qu’aucun échantillon n’est disponible pour cet appel, pas que le flux est terminé. N’appelez finish() que lorsque l’amont confirme la fin du flux ; si l’entrée reprend après une courte pause, l’historique doit avoir été conservé. Lors d’un test de relecture, créez une nouvelle instance et vérifiez que le nouveau flux ne récupère pas la traîne du précédent.
En multicanal, chaque canal doit conserver son propre historique. Envoyer directement des échantillons gauche et droit entrelacés dans un filtre mono revient à traiter les deux canaux comme des échantillons consécutifs dans le temps, ce qui provoque de la diaphonie. Aucun ajustement de la tolérance ne peut résoudre ce problème.
Points de contrôle avant d’intégrer une implémentation optimisée
Lorsque vous remplacez l’implémentation de production par un tampon circulaire ou un calcul vectorisé, conservez cette référence scalaire. Utilisez les mêmes entrées, les mêmes coefficients et la même stratégie de fin de flux pour comparer la longueur de sortie et les erreurs point par point. Un changement de l’ordre d’accumulation en virgule flottante n’impose pas une identité bit à bit, mais les critères d’erreur absolue et relative doivent être fixés à l’avance, et non assouplis arbitrairement après un échec.
Dans le callback temps réel, contrôlez séparément les allocations dynamiques, les verrous et le budget de temps de traitement. Les tests présentés ici ne démontrent pas les performances temps réel et ne permettent pas non plus de juger si la réponse en fréquence du filtre répond aux besoins du produit. Une fois les tests sur la machine hôte réussis, relancez-les dans la cible de test iOS, puis vérifiez sur un appareil réel le format d’entrée, l’affectation des canaux et le comportement en lecture continue.
Lors de l’archivage des tests, conservez l’identifiant du commit, la version de la chaîne d’outils, les coefficients, la séquence de blocs et l’emplacement de l’échec. Chaque anomalie à une jonction doit pouvoir être reproduite à partir d’une entrée fixe : c’est ainsi que vous pourrez distinguer une erreur d’état de l’algorithme, une erreur de conversion de format et un problème dans la chaîne de lecture effective.
Questions fréquentes
Pourquoi filtrer chaque bloc séparément crée-t-il des raccords incorrects ?
La sortie d’un FIR dépend des échantillons précédents. Réinitialiser le filtre à chaque bloc efface cet historique et revient à insérer des zéros avant chaque frontière. Conservez un état continu distinct pour chaque flux.
Combien d’échantillons doit produire un flux terminé ?
Pour une entrée non vide de N échantillons et M coefficients, la convolution linéaire complète produit N+M−1 échantillons après vidange. Une interface limitée à N sorties doit préciser que la fin du filtre est tronquée.
Louez un Mac dédié pour vos builds.
Choisissez le nœud le plus proche, puis vérifiez la mémoire, le stockage et la durée de facturation requis par votre projet. Vous disposez d’une machine physique entière, et non d’une machine virtuelle. Son état réel est indiqué en temps réel dans la console.