Swift Audio Converter

Swift Audio Converter

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 :

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))"
    }
}

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 :

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

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 :

  1. CMAudioFormatDescription : Indique le format conteneur, pas le profil bitstream réel
  2. AVAssetTrack.formatDescriptions : Peut indiquer un profil demandé, pas le profil produit
  3. 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

  1. Structure normative : L'AudioSpecificConfig est défini précisément dans ISO/IEC 14496-3, Section 1.6.2.1
  2. Extensions AAC : SBR et PS transforment AAC-LC en HE-AAC via des sync words spécifiques
  3. Format ESDS : Le magic cookie AAC est souvent encapsulé dans un Elementary Stream Descriptor (ISO/IEC 14496-1)
  4. Fallback silencieux : AVAssetWriter peut accepter des profils qu'il ne supporte pas et produire AAC-LC sans avertissement
  5. 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

  1. Consolidation du code : Créer MagicCookieAnalyzer pour éliminer la duplication
  2. Parser ESDS complet : Remplacer le scoring heuristique par un parser strict ISO/IEC 14496-1
  3. Détection PS complète : Implémenter le parser PSExtension selon Section 4.6.20
  4. Tests unitaires : Valider avec des magic cookies de référence pour tous les profils AAC
  5. Cache des résultats : Optimiser les analyses répétées
  6. 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 audio
  • Sources/AudioConverter.swift (lignes 561-887) - Conversion avec validation
  • Sources/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

Documentation Apple

Ressources complémentaires


À 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.