Validation des profils AAC via AudioSpecificConfig : Parsing du Magic Cookie en Swift
Introduction : Le problème de la vérification des profils AAC
Contexte normatif
Les profils AAC sont définis dans ISO/IEC 14496-3:2009 (MPEG-4 Audio, Part 3). Chaque profil correspond à un Audio Object Type (AOT) qui détermine les capacités de compression et les extensions utilisées :
| Audio Object Type | Profil | Extensions | Référence ISO/IEC 14496-3 |
|---|---|---|---|
| 2 | AAC-LC | Aucune | Table 1.16 |
| 5 | HE-AAC (SBR) | Spectral Band Replication | Section 4.6.18 |
| 29 | HE-AAC v2 (PS) | SBR + Parametric Stereo | Section 4.6.20 |
| 23 | AAC-LD | Low Delay | Section 4.6.3 |
| 39 | AAC-ELD | Enhanced Low Delay | Section 4.6.10 |
Références Apple :
- Technical Note TN2236: High-Efficiency Advanced Audio Coding (HE-AAC)
- Technical Note TN2237: Audio Export - Encoding AAC Audio
Comportement observé avec AVAssetWriter
Problème technique : Lors de l'encodage AAC sur macOS/iOS avec AVFoundation, il n'existe aucune API Core Audio pour vérifier programmatiquement le profil AAC réellement produit dans le fichier final.
import AVFoundation
// Configuration demandée : HE-AAC v2 à 48 kbps
let outputSettings: [String: Any] = [
AVFormatIDKey: kAudioFormatMPEG4AAC_HE_V2,
AVEncoderBitRateKey: 48_000,
AVSampleRateKey: 44100,
AVNumberOfChannelsKey: 2
]
let assetWriter = try AVAssetWriter(outputURL: outputURL, fileType: .m4a)
let writerInput = AVAssetWriterInput(
mediaType: .audio,
outputSettings: outputSettings
)
// ⚠️ Problème : Aucune API pour vérifier post-encodage :
// 1. Le profil effectivement encodé dans le fichier
// 2. Si un fallback silencieux a eu lieu (HE-AAC v2 → AAC-LC)
// 3. Si les extensions SBR/PS sont réellement présentes dans le bitstream
Conséquence pratique : Un fichier peut être étiqueté "HE-AAC v2" dans les métadonnées de conteneur mais contenir de l'AAC-LC, résultant en une qualité audio inadéquate pour le bitrate configuré.
Exemple de problème réel :
Configuration : HE-AAC v2 @ 48 kbps
Résultat attendu : Qualité acceptable (HE-AAC v2 est optimisé pour bas débits)
Résultat réel : Qualité médiocre (AAC-LC @ 48 kbps est insuffisant)
Solution : Parser l'AudioSpecificConfig
Référence normative : ISO/IEC 14496-3:2009, Section 1.6.2.1 - "AudioSpecificConfig"
L'AudioSpecificConfig (ASC) est une structure binaire contenue dans le "magic cookie" du fichier AAC. Elle définit précisément les paramètres de décodage conformément à la norme MPEG-4 Audio, et constitue la seule source de vérité sur le profil réellement encodé.
Emplacement dans le fichier MP4/M4A : L'ASC est stocké dans le Elementary Stream Descriptor (ESDS) conformément à ISO/IEC 14496-1:2004 (MPEG-4 Systems, Part 1).
Partie 1 : Structure de l'AudioSpecificConfig
Définition selon ISO/IEC 14496-3
L'AudioSpecificConfig est défini dans la section 1.6.2.1 de la norme MPEG-4 Audio :
AudioSpecificConfig() {
audioObjectType; // 5 bits
samplingFrequencyIndex; // 4 bits
if (samplingFrequencyIndex == 0xF) {
samplingFrequency; // 24 bits (explicit frequency)
}
channelConfiguration; // 4 bits
// Extensions pour profils avancés (HE-AAC, etc.)
if (audioObjectType == 5 || audioObjectType == 29) {
extensionSamplingFrequencyIndex; // 4 bits
extensionAudioObjectType; // 5 bits
}
// Informations additionnelles selon le profil
// (frame length, depends on core coder, etc.)
}
Source : ISO/IEC 14496-3:2009, Table 1.13 - "Syntax of AudioSpecificConfig()"
Mapping Audio Object Type → Profil AAC
D'après ISO/IEC 14496-3, Table 1.16 - "Audio Object Type definition" :
/// Audio Object Type selon ISO/IEC 14496-3, Table 1.16
enum AudioObjectType: UInt8 {
case aacMain = 1 // AAC Main Profile (rarement utilisé)
case aacLC = 2 // AAC Low Complexity (le plus courant)
case aacSSR = 3 // AAC Scalable Sample Rate (obsolète)
case aacLTP = 4 // AAC Long Term Prediction
case heAAC_SBR = 5 // HE-AAC (SBR) - aussi appelé AAC+
case aacScalable = 6 // AAC Scalable
case twinVQ = 7 // TwinVQ
case celp = 8 // CELP
case hxvc = 9 // HVXC
case ttsi = 12 // TTSI (Text-To-Speech Interface)
case mainSynthetic = 13 // Main Synthetic
case wavetableSynthesis = 14 // Wavetable Synthesis
case generalMIDI = 15 // General MIDI
case algorithmicSynthesis = 16 // Algorithmic Synthesis and Audio FX
case erAacLC = 17 // ER AAC LC (Error Resilient)
case erAacLTP = 19 // ER AAC LTP
case erAacScalable = 20 // ER AAC Scalable
case erTwinVQ = 21 // ER TwinVQ
case erBSAC = 22 // ER BSAC (Bit-Sliced Arithmetic Coding)
case erAacLD = 23 // ER AAC LD (Low Delay)
case erCELP = 24 // ER CELP
case erHVXC = 25 // ER HVXC
case erHILN = 26 // ER HILN
case erParametric = 27 // ER Parametric
case ssc = 28 // SSC (SinuSoidal Coding)
case heAAC_PS = 29 // HE-AAC v2 (PS) - aussi appelé AAC+ v2
case mpegSurround = 30 // MPEG Surround
case layer1 = 32 // Layer-1
case layer2 = 33 // Layer-2
case layer3 = 34 // Layer-3 (MP3)
case dsr = 35 // DSR (Direct Stream Transfer)
case als = 36 // ALS (Audio Lossless Coding)
case sls = 37 // SLS (Scalable Lossless Coding)
case sslsNonCore = 38 // SLS non-core
case aacELD = 39 // AAC ELD (Enhanced Low Delay)
case smrSimple = 40 // SMR Simple
case smrMain = 41 // SMR Main
case usacNoSBR = 42 // USAC (Unified Speech and Audio Coding) sans SBR
case saoc = 43 // SAOC (Spatial Audio Object Coding)
case ldMpegSurround = 44 // LD MPEG Surround
case usac = 45 // USAC (avec SBR)
}
extension AudioObjectType {
var profileDescription: String {
switch self {
case .aacLC: return "AAC-LC (Low Complexity)"
case .heAAC_SBR: return "HE-AAC (High Efficiency AAC with SBR)"
case .heAAC_PS: return "HE-AAC v2 (with PS and SBR)"
case .aacELD: return "AAC-ELD (Enhanced Low Delay)"
default: return "AOT \(self.rawValue)"
}
}
}
Sampling Frequency Index Table
ISO/IEC 14496-3, Table 1.18 - "Sampling Frequency Index" :
/// Table de correspondance samplingFrequencyIndex → fréquence
/// Source : ISO/IEC 14496-3:2009, Table 1.18
func samplingFrequency(from index: UInt8) -> UInt32? {
let samplingFrequencies: [UInt32] = [
96000, // 0x0
88200, // 0x1
64000, // 0x2
48000, // 0x3
44100, // 0x4
32000, // 0x5
24000, // 0x6
22050, // 0x7
16000, // 0x8
12000, // 0x9
11025, // 0xA
8000, // 0xB
7350 // 0xC
// 0xD, 0xE : reserved
// 0xF : explicit frequency (24-bit field follows)
]
guard index < samplingFrequencies.count else {
return nil
}
return samplingFrequencies[Int(index)]
}
Channel Configuration
ISO/IEC 14496-3, Table 1.19 - "Channel Configuration" :
/// Configuration des canaux selon ISO/IEC 14496-3, Table 1.19
func channelDescription(from config: UInt8) -> String {
switch config {
case 0: return "Defined in AOT Specific Config"
case 1: return "1 channel: front-center (Mono)"
case 2: return "2 channels: front-left, front-right (Stereo)"
case 3: return "3 channels: front-center, front-left, front-right"
case 4: return "4 channels: front-center, front-left, front-right, back-center"
case 5: return "5 channels: front-center, front-left, front-right, back-left, back-right"
case 6: return "6 channels: front-center, front-left, front-right, back-left, back-right, LFE"
case 7: return "8 channels: front-center, front-left, front-right, side-left, side-right, back-left, back-right, LFE"
default: return "Reserved or invalid (\(config))"
}
}
Partie 2 : Extraction du Magic Cookie avec Core Audio
Localisation du magic cookie dans un fichier audio
Apple fournit l'API CMAudioFormatDescriptionGetMagicCookie dans le framework CoreMedia pour extraire le magic cookie d'un format audio.
import AVFoundation
import CoreMedia
/// Extrait le magic cookie AAC d'un fichier audio
/// - Parameter url: URL du fichier audio (M4A, MP4, AAC, etc.)
/// - Returns: Data contenant le magic cookie ou nil si non disponible
func extractMagicCookie(from url: URL) -> Data? {
let asset = AVAsset(url: url)
// Récupérer la piste audio
guard let audioTrack = asset.tracks(withMediaType: .audio).first else {
print("❌ Aucune piste audio trouvée")
return nil
}
// Obtenir les format descriptions
guard let formatDescriptions = audioTrack.formatDescriptions as? [CMFormatDescription],
let formatDescription = formatDescriptions.first else {
print("❌ Aucun format description disponible")
return nil
}
// Extraire le magic cookie via l'API Core Media
var cookieSize: Int = 0
guard let magicCookiePointer = CMAudioFormatDescriptionGetMagicCookie(
formatDescription,
sizeOut: &cookieSize
) else {
print("❌ Pas de magic cookie (format PCM ?)")
return nil
}
// Convertir en Data Swift
let cookieData = Data(bytes: magicCookiePointer, count: cookieSize)
print("✅ Magic cookie extrait : \(cookieSize) octets")
return cookieData
}
Documentation :
- CMAudioFormatDescriptionGetMagicCookie - CoreMedia framework
- AVAssetTrack - AVFoundation framework
Format du magic cookie : ESDS vs AudioSpecificConfig direct
Le magic cookie AAC peut se présenter sous deux formes :
1. Format direct : AudioSpecificConfig seul (rare)
[Octet 0] [Octet 1] [Octet 2...]
└─────┴─────────┴──────────────> AudioSpecificConfig direct
2. Format ESDS : Elementary Stream Descriptor (courant dans MP4/M4A)
Le format ESDS est défini dans ISO/IEC 14496-1:2004, Section 8.6.6 - "Elementary Stream Descriptor"
Structure hiérarchique :
ESDS (Elementary Stream Descriptor)
│
├─ ES_Descriptor [tag 0x03]
│ ├─ ES_ID (2 bytes)
│ ├─ flags (1 byte)
│ │
│ └─ DecoderConfigDescriptor [tag 0x04]
│ ├─ objectTypeIndication (1 byte) = 0x40 pour MPEG-4 Audio
│ ├─ streamType (1 byte)
│ ├─ bufferSizeDB (3 bytes)
│ ├─ maxBitrate (4 bytes)
│ ├─ avgBitrate (4 bytes)
│ │
│ └─ DecoderSpecificInfo [tag 0x05]
│ └─ AudioSpecificConfig ← Notre cible !
│ ├─ audioObjectType (5 bits)
│ ├─ samplingFrequencyIndex (4 bits)
│ └─ channelConfiguration (4 bits)
│ └─ ... (extensions)
Problème pratique : Les encodeurs peuvent ajouter du padding ou des données supplémentaires, rendant la position exacte de l'AudioSpecificConfig variable.
Partie 3 : Parsing bit-à-bit de l'AudioSpecificConfig
Implémentation conforme ISO/IEC 14496-3
Voici l'implémentation complète du parser AudioSpecificConfig :
/// Informations extraites de l'AudioSpecificConfig
/// Conforme à ISO/IEC 14496-3:2009, Section 1.6.2.1
struct AudioSpecificConfigInfo: CustomStringConvertible {
let audioObjectType: UInt8
let samplingFrequencyIndex: UInt8
let samplingFrequency: UInt32?
let channelConfiguration: UInt8
let extensions: [String]
let rawData: Data
var profile: AACProfile {
switch audioObjectType {
case 2: return .LC
case 5: return .HE
case 29: return .HEv2
case 23: return .LD
case 39: return .ELD
default: return .unknown
}
}
var description: String {
var output = """
┌─ AudioSpecificConfig (ISO/IEC 14496-3) ─────────┐
│ Audio Object Type : \(audioObjectType) (\(profile.description))
│ Sampling Freq Index : \(samplingFrequencyIndex)
"""
if let freq = samplingFrequency {
output += "\n│ Sampling Frequency : \(freq) Hz"
}
output += """
│ Channel Config : \(channelConfiguration) (\(channelDescription(from: channelConfiguration)))
"""
if !extensions.isEmpty {
output += "\n│ Extensions : \(extensions.joined(separator: ", "))"
}
output += "\n└──────────────────────────────────────────────────┘"
return output
}
}
/// Parser AudioSpecificConfig selon ISO/IEC 14496-3, Section 1.6.2.1
func parseAudioSpecificConfig(_ data: Data) -> AudioSpecificConfigInfo? {
guard data.count >= 2 else {
print("❌ Magic cookie trop court (< 2 octets)")
return nil
}
let firstByte = data[0]
let secondByte = data[1]
// ─────────────────────────────────────────────────────────────
// Extraction des champs selon ISO/IEC 14496-3, Section 1.6.2.1
// ─────────────────────────────────────────────────────────────
// audioObjectType : bits [0-4] du premier octet (5 bits)
// Lecture : (firstByte >> 3) & 0x1F
let audioObjectType = (firstByte >> 3) & 0x1F
// samplingFrequencyIndex : bits [5-7] du premier octet + bit [0] du second (4 bits)
// Lecture : ((firstByte & 0x07) << 1) | ((secondByte >> 7) & 0x01)
let samplingFreqIndex = ((firstByte & 0x07) << 1) | ((secondByte >> 7) & 0x01)
// channelConfiguration : bits [1-4] du second octet (4 bits)
// Lecture : (secondByte >> 3) & 0x0F
let channelConfig = (secondByte >> 3) & 0x0F
// Résolution de la fréquence d'échantillonnage
let samplingFreq: UInt32?
if samplingFreqIndex == 0xF {
// Fréquence explicite sur 24 bits (rarement utilisé)
if data.count >= 6 {
let freq = (UInt32(data[2]) << 16) | (UInt32(data[3]) << 8) | UInt32(data[4])
samplingFreq = freq
} else {
samplingFreq = nil
}
} else {
// Lookup dans la table ISO/IEC 14496-3, Table 1.18
samplingFreq = samplingFrequency(from: samplingFreqIndex)
}
// Détection des extensions (SBR, PS, MPEG Surround)
let extensions = detectExtensions(
in: data,
audioObjectType: audioObjectType
)
let info = AudioSpecificConfigInfo(
audioObjectType: audioObjectType,
samplingFrequencyIndex: samplingFreqIndex,
samplingFrequency: samplingFreq,
channelConfiguration: channelConfig,
extensions: extensions,
rawData: data
)
print("✅ AudioSpecificConfig parsé avec succès")
print(info)
return info
}
enum AACProfile: String, CustomStringConvertible {
case LC = "AAC-LC"
case HE = "HE-AAC"
case HEv2 = "HE-AAC v2"
case LD = "AAC-LD"
case ELD = "AAC-ELD"
case unknown = "Unknown"
var description: String { rawValue }
}
Visualisation du parsing bit-à-bit
Prenons un exemple concret de magic cookie HE-AAC v2 :
Hex dump : 11 90 56 E5 00
Binary : 00010001 10010000 01010110 11100101 00000000
└──┬───┘ └──┬───┘
│ │
Octet 0 Octet 1
Parsing :
┌───────────────────────────────────────────────────────────┐
│ Octet 0 : 0x11 = 0b00010001 │
│ audioObjectType = [0-4] = 0b00010 = 2 (AAC-LC) │
│ samplingFreqIndex_hi = [5-7] = 0b001 │
│ │
│ Octet 1 : 0x90 = 0b10010000 │
│ samplingFreqIndex_lo = [0] = 0b1 │
│ samplingFreqIndex = 0b0011 = 3 (48000 Hz) │
│ channelConfiguration = [1-4] = 0b0010 = 2 (Stereo) │
│ frameLengthFlag = [5] = 0 │
│ dependsOnCoreCoder = [6] = 0 │
│ extensionFlag = [7] = 1 ← Extensions présentes│
└───────────────────────────────────────────────────────────┘
Note importante : Dans cet exemple, l'audioObjectType initial est 2 (AAC-LC), mais les extensions suivantes peuvent indiquer HE-AAC v2. C'est pourquoi il est crucial de parser les extensions !
Partie 4 : Détection des extensions (SBR, PS)
Extensions AAC selon ISO/IEC 14496-3
Les extensions comme SBR (Spectral Band Replication) et PS (Parametric Stereo) transforment un profil AAC-LC en HE-AAC :
- AAC-LC = Audio Object Type 2
- AAC-LC + SBR = HE-AAC (AOT 5)
- AAC-LC + SBR + PS = HE-AAC v2 (AOT 29)
Les extensions sont signalées par des sync words définis dans la norme :
| Extension | Sync Word | Référence ISO/IEC 14496-3 |
|---|---|---|
| SBR (Spectral Band Replication) | 0x2B7 |
Section 4.6.18.3 |
| PS (Parametric Stereo) | 0x548 |
Section 4.6.20.3 |
| MPEG Surround | 0x2E5 |
Section 4.6.15 |
Implémentation de la détection des extensions
/// Détecte les extensions AAC (SBR, PS, MPEG Surround)
/// selon ISO/IEC 14496-3, Sections 4.6.18 et 4.6.20
func detectExtensions(in data: Data, audioObjectType: UInt8) -> [String] {
var extensions: [String] = []
// Sync words définis dans ISO/IEC 14496-3
let sbrSyncWord: UInt16 = 0x2B7 // Section 4.6.18.3
let psSyncWord: UInt16 = 0x548 // Section 4.6.20.3
let mpegSurroundSyncWord: UInt16 = 0x2E5
// Scan du bitstream à la recherche des sync words
// Note : Les sync words peuvent apparaître n'importe où après l'ASC de base
for i in 2..<(data.count - 1) {
let syncWord = (UInt16(data[i]) << 8) | UInt16(data[i + 1])
switch syncWord {
case sbrSyncWord:
if !extensions.contains("SBR") {
extensions.append("SBR (Spectral Band Replication)")
}
case psSyncWord:
if !extensions.contains("PS") {
extensions.append("PS (Parametric Stereo)")
}
case mpegSurroundSyncWord:
if !extensions.contains("MPEG Surround") {
extensions.append("MPEG Surround")
}
default:
break
}
}
// Vérification de cohérence avec l'audioObjectType
if audioObjectType == 5 && !extensions.contains("SBR") {
print("⚠️ AOT=5 (HE-AAC) mais sync word SBR non trouvé")
}
if audioObjectType == 29 && (!extensions.contains("SBR") || !extensions.contains("PS")) {
print("⚠️ AOT=29 (HE-AAC v2) mais extensions SBR/PS non trouvées")
}
return extensions
}
Détection PS simplifiée vs complète
Limitation actuelle : La détection du Parametric Stereo peut être incomplète selon la configuration de l'encodeur.
// ⚠️ DÉTECTION SIMPLIFIÉE (utilisée dans le projet actuel)
// Cette approche vérifie uniquement le bit indicateur simplifié
func detectPS_Simplified(in data: Data) -> Bool {
// ISO/IEC 14496-3, Section 4.6.20 définit une structure complexe
// Cette implémentation utilise une heuristique simplifiée
if data.count >= 3 {
let thirdByte = data[2]
return (thirdByte & 0x80) != 0 // Bit flag simplifié
}
return false
}
// ✅ DÉTECTION COMPLÈTE (recommandée)
// Parser complet de la structure PS selon ISO/IEC 14496-3
func detectPS_Complete(in data: Data) -> Bool {
// 1. Rechercher le sync word PS (0x548)
// 2. Parser la structure PSExtension qui suit
// 3. Vérifier ps_enable flag
// 4. Extraire les paramètres PS si présents
// Implémentation complète nécessiterait un parser bitstream complet
// pour gérer les structures de taille variable
// Pour l'instant, on se fie au sync word :
for i in 2..<(data.count - 1) {
let syncWord = (UInt16(data[i]) << 8) | UInt16(data[i + 1])
if syncWord == 0x548 { // PS sync word
return true
}
}
return false
}
Référence : ISO/IEC 14496-3:2009, Section 4.6.20 - "Parametric Stereo"
Partie 5 : Extraction de l'AudioSpecificConfig depuis ESDS
Problème : Magic cookie au format ESDS
Dans les fichiers MP4/M4A, l'AudioSpecificConfig est encapsulé dans une structure ESDS (Elementary Stream Descriptor) définie par ISO/IEC 14496-1.
Structure ESDS (simplifié) :
ESDS :
[tag 0x03] ES_Descriptor
length
ES_ID (2 bytes)
flags (1 byte)
[tag 0x04] DecoderConfigDescriptor
length
objectTypeIndication (0x40 = MPEG-4 Audio)
streamType
bufferSizeDB (3 bytes)
maxBitrate (4 bytes)
avgBitrate (4 bytes)
[tag 0x05] DecoderSpecificInfo
length
AudioSpecificConfig ← NOTRE CIBLE !
Implémentation du parser ESDS
/// Extrait l'AudioSpecificConfig d'un magic cookie au format ESDS
/// Conforme à ISO/IEC 14496-1:2004, Section 8.6.6
func extractAudioSpecificConfigFromESDS(_ esdsData: Data) -> Data? {
// Tag identifiers selon ISO/IEC 14496-1
let esDescriptorTag: UInt8 = 0x03
let decoderConfigDescriptorTag: UInt8 = 0x04
let decoderSpecificInfoTag: UInt8 = 0x05
var position = 0
// Helper : Lire un descripteur ESDS avec son length field
func readDescriptor(expectedTag: UInt8) -> (data: Data, nextPosition: Int)? {
guard position < esdsData.count else { return nil }
let tag = esdsData[position]
guard tag == expectedTag else {
print("⚠️ Tag attendu: 0x\(String(format: "%02X", expectedTag)), reçu: 0x\(String(format: "%02X", tag))")
return nil
}
position += 1
// Lire le length field (format variable, high bit = continuation)
var length = 0
var bytesRead = 0
repeat {
guard position < esdsData.count else { return nil }
let byte = esdsData[position]
position += 1
bytesRead += 1
length = (length << 7) | Int(byte & 0x7F)
// Si high bit = 0, c'est le dernier octet du length
if (byte & 0x80) == 0 { break }
} while bytesRead < 4 // Max 4 octets pour le length
guard position + length <= esdsData.count else {
print("❌ Length invalide : \(length) octets, mais seulement \(esdsData.count - position) disponibles")
return nil
}
let data = esdsData[position..<(position + length)]
return (Data(data), position + length)
}
// ─────────────────────────────────────────────────────────
// Étape 1 : Parser ES_Descriptor [tag 0x03]
// ─────────────────────────────────────────────────────────
guard let esDescriptor = readDescriptor(expectedTag: esDescriptorTag) else {
print("❌ ES_Descriptor introuvable")
return tryDirectASC(esdsData)
}
// Skip ES_ID (2 bytes) + flags (1 byte)
position = esDescriptor.nextPosition + 3
// ─────────────────────────────────────────────────────────
// Étape 2 : Parser DecoderConfigDescriptor [tag 0x04]
// ─────────────────────────────────────────────────────────
guard let decoderConfig = readDescriptor(expectedTag: decoderConfigDescriptorTag) else {
print("❌ DecoderConfigDescriptor introuvable")
return tryDirectASC(esdsData)
}
// Skip objectTypeIndication (1) + streamType (1) + bufferSizeDB (3) + bitrates (8)
position = decoderConfig.nextPosition + 13
// ─────────────────────────────────────────────────────────
// Étape 3 : Parser DecoderSpecificInfo [tag 0x05]
// ─────────────────────────────────────────────────────────
guard let decoderSpecificInfo = readDescriptor(expectedTag: decoderSpecificInfoTag) else {
print("❌ DecoderSpecificInfo introuvable")
return tryDirectASC(esdsData)
}
// Le contenu du DecoderSpecificInfo EST l'AudioSpecificConfig !
let ascData = decoderSpecificInfo.data
print("✅ AudioSpecificConfig extrait de l'ESDS : \(ascData.count) octets")
return ascData
}
/// Fallback : Tenter de parser directement comme AudioSpecificConfig
private func tryDirectASC(_ data: Data) -> Data? {
print("ℹ️ Tentative de parsing direct (non-ESDS)...")
// Si les 2 premiers octets ressemblent à un ASC valide, on tente
guard data.count >= 2 else { return nil }
let firstByte = data[0]
let audioObjectType = (firstByte >> 3) & 0x1F
// Valider que l'AOT est dans une plage raisonnable (1-45)
if audioObjectType >= 1 && audioObjectType <= 45 {
print("✅ Format direct détecté (AOT=\(audioObjectType))")
return data
}
return nil
}
Parsing complet avec extraction ESDS
/// Parse un magic cookie AAC (ESDS ou direct) et retourne l'AudioSpecificConfig
func parseAACMagicCookie(_ cookieData: Data) -> AudioSpecificConfigInfo? {
print("\n┌─ Parsing Magic Cookie AAC ─────────────────────────┐")
print("│ Taille : \(cookieData.count) octets")
print("│ Hex : \(cookieData.prefix(min(16, cookieData.count)).map { String(format: "%02X", $0) }.joined(separator: " "))")
print("└────────────────────────────────────────────────────┘\n")
// Étape 1 : Extraire l'ASC de l'ESDS si nécessaire
let ascData: Data
if cookieData.count > 2 && cookieData[0] == 0x03 {
// Format ESDS détecté
print("📦 Format ESDS détecté, extraction de l'ASC...")
guard let extracted = extractAudioSpecificConfigFromESDS(cookieData) else {
print("❌ Échec de l'extraction ESDS")
return nil
}
ascData = extracted
} else {
// Format direct
print("📄 Format direct détecté")
ascData = cookieData
}
// Étape 2 : Parser l'AudioSpecificConfig
return parseAudioSpecificConfig(ascData)
}
Partie 6 : Validation du profil encodé
Cas d'usage : Vérification post-encodage
Voici un workflow complet pour valider qu'un fichier AAC a été encodé avec le profil demandé :
enum ProfileValidationResult {
case match(AACProfile, details: AudioSpecificConfigInfo)
case mismatch(requested: AACProfile, actual: AACProfile, details: AudioSpecificConfigInfo)
case cannotVerify(reason: String)
}
/// Valide que le profil AAC demandé correspond au profil réellement encodé
func validateEncodedProfile(
requestedProfile: AACProfile,
encodedFileURL: URL
) -> ProfileValidationResult {
print("\n┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓")
print("┃ Validation du profil AAC encodé ┃")
print("┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫")
print("┃ Fichier : \(encodedFileURL.lastPathComponent)")
print("┃ Profil demandé : \(requestedProfile)")
print("┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛\n")
// Étape 1 : Extraire le magic cookie
guard let cookieData = extractMagicCookie(from: encodedFileURL) else {
return .cannotVerify(reason: "Impossible d'extraire le magic cookie")
}
// Étape 2 : Parser l'AudioSpecificConfig
guard let ascInfo = parseAACMagicCookie(cookieData) else {
return .cannotVerify(reason: "Impossible de parser l'AudioSpecificConfig")
}
let actualProfile = ascInfo.profile
// Étape 3 : Comparer les profils
print("\n┌─ Résultat de la validation ─────────────────────┐")
print("│ Profil demandé : \(requestedProfile)")
print("│ Profil détecté : \(actualProfile)")
if actualProfile == requestedProfile {
print("│ ✅ MATCH - Le profil correspond !")
print("└──────────────────────────────────────────────────┘\n")
return .match(actualProfile, details: ascInfo)
} else {
print("│ ⚠️ MISMATCH - Fallback détecté !")
print("└──────────────────────────────────────────────────┘\n")
// Diagnostic : Pourquoi le fallback ?
let reason = diagnoseFallbackReason(
requested: requestedProfile,
actual: actualProfile,
ascInfo: ascInfo
)
print("📋 Raison probable : \(reason)\n")
return .mismatch(
requested: requestedProfile,
actual: actualProfile,
details: ascInfo
)
}
}
/// Diagnostic des raisons de fallback
func diagnoseFallbackReason(
requested: AACProfile,
actual: AACProfile,
ascInfo: AudioSpecificConfigInfo
) -> String {
// HE-AAC v2 → AAC-LC : L'encodeur ne supporte pas HE-AAC v2
if requested == .HEv2 && actual == .LC {
return """
AVAssetWriter ne supporte pas HE-AAC v2 de manière fiable.
Solution : Utiliser AudioToolbox avec ExtAudioFile API.
Référence : Audio Converter Services (AudioToolbox.framework)
"""
}
// HE-AAC → AAC-LC : Problème de sample rate ou configuration
if requested == .HE && actual == .LC {
if let freq = ascInfo.samplingFrequency, freq < 32000 {
return """
Fréquence d'échantillonnage trop basse pour HE-AAC (\(freq) Hz).
HE-AAC nécessite généralement ≥ 32 kHz.
"""
}
return "Encodeur ne supporte pas HE-AAC pour cette configuration."
}
return "Fallback non documenté vers \(actual)."
}
Exemple d'utilisation complète
import AVFoundation
func encodeAndValidate() async throws {
let inputURL = URL(fileURLWithPath: "/path/to/input.wav")
let outputURL = URL(fileURLWithPath: "/tmp/output.m4a")
// ─────────────────────────────────────────────────────────
// Étape 1 : Encodage avec AVAssetWriter
// ─────────────────────────────────────────────────────────
let settings: [String: Any] = [
AVFormatIDKey: kAudioFormatMPEG4AAC_HE_V2, // HE-AAC v2 demandé
AVEncoderBitRateKey: 48_000,
AVSampleRateKey: 44100,
AVNumberOfChannelsKey: 2
]
print("🎛️ Encodage avec AVAssetWriter...")
print(" Format demandé : HE-AAC v2 @ 48 kbps\n")
// [Code d'encodage AVAssetWriter omis pour clarté]
// try await encodeWithAVAssetWriter(input: inputURL, output: outputURL, settings: settings)
// ─────────────────────────────────────────────────────────
// Étape 2 : Validation du profil produit
// ─────────────────────────────────────────────────────────
let result = validateEncodedProfile(
requestedProfile: .HEv2,
encodedFileURL: outputURL
)
switch result {
case .match(let profile, let details):
print("✅ SUCCESS : Le fichier est bien encodé en \(profile)")
print(details)
case .mismatch(let requested, let actual, let details):
print("⚠️ WARNING : Fallback détecté !")
print(" Demandé : \(requested)")
print(" Produit : \(actual)")
print("\n🔧 Action recommandée :")
print(" Utiliser AudioToolbox (ExtAudioFile) au lieu d'AVAssetWriter")
print(" pour garantir le support HE-AAC v2.\n")
print(details)
case .cannotVerify(let reason):
print("❌ ERROR : Impossible de vérifier le profil")
print(" Raison : \(reason)")
}
}
Sortie typique en cas de fallback :
🎛️ Encodage avec AVAssetWriter...
Format demandé : HE-AAC v2 @ 48 kbps
┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓
┃ Validation du profil AAC encodé ┃
┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫
┃ Fichier : output.m4a
┃ Profil demandé : HE-AAC v2
┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛
✅ Magic cookie extrait : 5 octets
┌─ Parsing Magic Cookie AAC ─────────────────────────┐
│ Taille : 5 octets
│ Hex : 11 90 56 E5 00
└────────────────────────────────────────────────────┘
✅ AudioSpecificConfig parsé avec succès
┌─ AudioSpecificConfig (ISO/IEC 14496-3) ─────────┐
│ Audio Object Type : 2 (AAC-LC)
│ Sampling Freq Index : 3
│ Sampling Frequency : 48000 Hz
│ Channel Config : 2 (2 channels: front-left, front-right (Stereo))
└──────────────────────────────────────────────────┘
┌─ Résultat de la validation ─────────────────────┐
│ Profil demandé : HE-AAC v2
│ Profil détecté : AAC-LC
│ ⚠️ MISMATCH - Fallback détecté !
└──────────────────────────────────────────────────┘
📋 Raison probable : AVAssetWriter ne supporte pas HE-AAC v2 de manière fiable.
Solution : Utiliser AudioToolbox avec ExtAudioFile API.
Référence : Audio Converter Services (AudioToolbox.framework)
⚠️ WARNING : Fallback détecté !
Demandé : HE-AAC v2
Produit : AAC-LC
🔧 Action recommandée :
Utiliser AudioToolbox (ExtAudioFile) au lieu d'AVAssetWriter
pour garantir le support HE-AAC v2.
Partie 7 : Limitations et améliorations
Limitations de l'implémentation actuelle
1. Code dupliqué dans le projet
Observation : Le code de parsing du magic cookie est actuellement dupliqué dans deux fichiers :
Sources/AudioAnalyzer.swift(lignes 135-465)Sources/AudioConverter.swift(lignes 561-887)
Impact :
- ❌ Violation du principe DRY (Don't Repeat Yourself)
- ❌ Risque d'incohérence si une correction est appliquée à un seul fichier
- ❌ Double maintenance en cas de bug
Amélioration proposée : Consolider dans une classe dédiée MagicCookieAnalyzer
/// Analyseur de magic cookie AAC conforme ISO/IEC 14496-3
struct MagicCookieAnalyzer {
/// Parse un magic cookie AAC (format ESDS ou direct)
static func analyze(_ data: Data) -> AudioSpecificConfigInfo? {
return parseAACMagicCookie(data)
}
/// Extrait et analyse le magic cookie d'un fichier audio
static func analyze(fileURL: URL) -> AudioSpecificConfigInfo? {
guard let cookieData = extractMagicCookie(from: fileURL) else {
return nil
}
return analyze(cookieData)
}
/// Compare le profil détecté avec le profil demandé
static func validate(
requestedProfile: AACProfile,
fileURL: URL
) -> ProfileValidationResult {
return validateEncodedProfile(
requestedProfile: requestedProfile,
encodedFileURL: fileURL
)
}
}
2. Détection PS simplifiée
Problème : La détection du Parametric Stereo (PS) utilise une heuristique simplifiée (ligne 435 dans le code actuel) :
// ⚠️ Implémentation actuelle (simplifiée)
if cookieData.count >= 3 {
let thirdByte = cookieData[2]
return (thirdByte & 0x80) != 0 // Approximation
}
Limitation : Cette méthode peut manquer certaines configurations PS légitimes.
Solution complète : Parser la structure GASpecificConfig et PSExtension selon ISO/IEC 14496-3, Section 4.6.20.
3. Scoring heuristique pour ESDS
Problème : Le code actuel utilise un système de scoring pour trouver l'AudioSpecificConfig dans l'ESDS :
// Scoring system (lignes 294-316 du code actuel)
var score = 0
if audioObjectType == 2 { score += 10 } // AAC-LC le plus courant
if samplingFreqIndex == 4 || samplingFreqIndex == 3 { score += 5 } // 44.1/48kHz
if channelConfig == 1 || channelConfig == 2 { score += 5 } // mono/stereo
Limitations :
- Assume que les configurations courantes sont correctes
- Peut échouer sur des configurations légitimes mais inhabituelles (ex: 96 kHz, 7.1 channels)
- Aucun seuil de confiance minimum
Amélioration : Utiliser un parser ESDS strict selon ISO/IEC 14496-1 au lieu d'un scan heuristique.
4. Pas de cache des résultats
Optimisation : Parser le magic cookie à chaque appel peut être coûteux. Un cache pourrait améliorer les performances pour les analyses répétées.
class CachedMagicCookieAnalyzer {
private static var cache: [URL: AudioSpecificConfigInfo] = [:]
static func analyze(fileURL: URL, useCache: Bool = true) -> AudioSpecificConfigInfo? {
if useCache, let cached = cache[fileURL] {
return cached
}
guard let result = MagicCookieAnalyzer.analyze(fileURL: fileURL) else {
return nil
}
cache[fileURL] = result
return result
}
}
Partie 8 : Application pratique : Sélection d'engine de conversion
Cas d'usage dans audio-converter-cli
Le projet audio-converter-cli utilise cette analyse pour sélectionner intelligemment l'engine de conversion optimal :
// Sources/ConversionRouter.swift
func selectEngine(for settings: AudioSettings) -> AudioConversionEngine.Type {
// Vérifier si le profil nécessite AudioToolbox
if settings.requiresAudioToolbox {
return AudioToolboxConverterEngine.self
}
// Par défaut, utiliser AVFoundation (plus mature)
return AVFoundationConverterEngine.self
}
extension AudioSettings {
var requiresAudioToolbox: Bool {
switch format {
case .aac(let aacSettings):
// HE-AAC v2 nécessite AudioToolbox
// (AVAssetWriter produit souvent AAC-LC)
return [.HE, .HEv2].contains(aacSettings.profile)
case .flac:
// AVAssetWriter ne supporte pas FLAC
return true
default:
return false
}
}
}
Workflow complet avec validation
// Conversion avec validation automatique
func convertWithValidation(
input: URL,
output: URL,
settings: AudioSettings
) async throws {
// Étape 1 : Sélection de l'engine
let engineType = ConversionRouter.shared.selectEngine(for: settings)
print("🔧 Engine sélectionné : \(engineType.engineName)")
// Étape 2 : Conversion
let engine = engineType.init()
try await engine.convert(input: input, output: output, settings: settings)
// Étape 3 : Validation post-encodage (AAC uniquement)
if case .aac(let aacSettings) = settings.format {
let result = MagicCookieAnalyzer.validate(
requestedProfile: aacSettings.profile,
fileURL: output
)
switch result {
case .match:
print("✅ Profil AAC validé")
case .mismatch(let requested, let actual, _):
print("⚠️ Fallback détecté : \(requested) → \(actual)")
// Option : Ré-encoder avec un autre engine
if engineType == AVFoundationConverterEngine.self {
print("🔄 Ré-encodage avec AudioToolbox...")
let audioToolboxEngine = AudioToolboxConverterEngine()
try await audioToolboxEngine.convert(
input: input,
output: output,
settings: settings
)
// Re-valider
let retryResult = MagicCookieAnalyzer.validate(
requestedProfile: aacSettings.profile,
fileURL: output
)
if case .match = retryResult {
print("✅ Profil validé après ré-encodage")
}
}
case .cannotVerify(let reason):
print("⚠️ Validation impossible : \(reason)")
}
}
}
Conclusion
Pourquoi ce parsing est nécessaire
Constat technique : Core Audio sur macOS/iOS ne fournit aucune API publique pour vérifier programmatiquement le profil AAC réellement encodé dans un fichier. Les méthodes disponibles :
- CMAudioFormatDescription : Indique le format conteneur, pas le profil bitstream réel
- AVAssetTrack.formatDescriptions : Peut indiquer un profil demandé, pas le profil produit
- AudioFileGetProperty : Retourne les metadata du conteneur, pas l'analyse du bitstream
Solution unique : Parser manuellement l'AudioSpecificConfig selon ISO/IEC 14496-3.
Ce que nous avons appris
- Structure normative : L'AudioSpecificConfig est défini précisément dans ISO/IEC 14496-3, Section 1.6.2.1
- Extensions AAC : SBR et PS transforment AAC-LC en HE-AAC via des sync words spécifiques
- Format ESDS : Le magic cookie AAC est souvent encapsulé dans un Elementary Stream Descriptor (ISO/IEC 14496-1)
- Fallback silencieux : AVAssetWriter peut accepter des profils qu'il ne supporte pas et produire AAC-LC sans avertissement
- Validation nécessaire : Le seul moyen fiable de confirmer le profil est le parsing post-encodage
Applications pratiques
✅ Validation qualité : Confirmer que le profil demandé a été produit
✅ Sélection d'engine : Choisir AudioToolbox si AVFoundation ne supporte pas le profil
✅ Détection de fallback : Identifier les cas où l'encodeur a dégradé le profil
✅ Reporting : Vérifier la présence des extensions SBR/PS dans le bitstream
✅ Debugging : Comprendre pourquoi la qualité audio est insuffisante
Améliorations futures du projet
- Consolidation du code : Créer
MagicCookieAnalyzerpour éliminer la duplication - Parser ESDS complet : Remplacer le scoring heuristique par un parser strict ISO/IEC 14496-1
- Détection PS complète : Implémenter le parser
PSExtensionselon Section 4.6.20 - Tests unitaires : Valider avec des magic cookies de référence pour tous les profils AAC
- Cache des résultats : Optimiser les analyses répétées
- Support des profils étendus : Ajouter AAC-ELD, USAC, etc.
Code source complet
Le code complet de cette implémentation est disponible dans le projet audio-converter-cli :
📁 Fichiers du projet :
Sources/AudioAnalyzer.swift(lignes 135-465) - Analyse de fichiers audioSources/AudioConverter.swift(lignes 561-887) - Conversion avec validationSources/ConversionRouter.swift- Sélection d'engine basée sur le profil
🔗 Dépôt GitHub : audio-converter-cli
Note technique : Le code est actuellement dupliqué dans deux fichiers. Une issue GitHub est ouverte pour consolider l'implémentation dans un module MagicCookieAnalyzer dédié.
Ressources
Normes ISO/IEC
- ISO/IEC 14496-3:2009 - MPEG-4 Audio (définition des profils AAC, AudioSpecificConfig)
- ISO/IEC 14496-1:2004 - MPEG-4 Systems (structure ESDS)
Documentation Apple
- Technical Note TN2236: High-Efficiency Advanced Audio Coding (HE-AAC) - Support HE-AAC sur iOS/macOS
- Technical Note TN2237: Audio Export - Encoding AAC Audio - Bonnes pratiques d'encodage AAC
- Technical Note TN2258: AAC Audio - Encoder Delay and Synchronization - Gestion du delay AAC
- CMAudioFormatDescriptionGetMagicCookie - API Core Media
- Core Audio Overview - Architecture Core Audio
Ressources complémentaires
- Advanced Audio Coding (Wikipedia) - Historique et variations AAC
- MPEG-4 Part 3 (Wikipedia) - Vue d'ensemble MPEG-4 Audio
- MIT MPEG-4 Audio Overview - Documentation technique MPEG-4
À propos
Cet article est basé sur l'implémentation réelle du projet audio-converter-cli, un convertisseur audio haute performance pour macOS utilisant les frameworks Core Audio natifs.
Auteur : @bathtubsailor82
Projet : audio-converter-cli
Licence : MIT + for commercial, support, consulting or custom dev please contact me.
Article technique rédigé en 2025. Les APIs Core Audio et les normes ISO/IEC citées sont à jour à la date de publication.