首页 / 资讯中心 / 文章详情

从ADTS头解析AAC裸流:提取Profile、采样率与时长

从ADTS头解析AAC裸流:提取Profile、采样率与时长 ★ FEATURED ARTICLE
写这个标题的时候我愣了一下心里其实很清楚“ACC”大概率是“AAC”的手误——在音视频开发里这俩缩写经常被混着写需求文档里写ACC、代码里写AAC的情况我见过太多了。真正值得聊的是后面那句话在字节数据里提取AAC的profile、采样率和长度。做播放器、转封装、流媒体协议解析的同学手头一定遇到过这种尴尬拿到一段裸AAC流没容器、没标签、没任何额外说明就是一连串字节摆在面前。你得在不解码的情况下告诉上层“这段音频是什么配置、多少采样率、能播多长时间”然后才能继续做裁剪、拼接、转码或者协议转发。这篇文章就讲清楚这件事怎么干从AAC原始流里把profile、采样率、帧长和总时长准确抠出来。适合音视频入门开发者、做格式分析工具的人也适合那些刚接触ADTS头、想彻底搞懂字节布局的读者。1. 先搞清楚一件事没有容器的AAC到底是一堆什么样的字节1.1 “裸AAC流”并不是一串无结构的二进制而是帧的连续排列AACAdvanced Audio Coding编码出来的原始音频数据如果不经过MP4、FLV、TS这类容器封装通常以ADTSAudio Data Transport Stream格式存在。ADTS这个名字听起来陌生但你可以把它理解成“快递信封”每一个AAC音频帧前面都套了一个固定格式的信封信封上写了这包货的配置信息快递员解码器不需要拆开货也能知道里面是什么规格。这个信封就是ADTS头。裸AAC流比如常见的.aac文件本质上是无数个“ADTS头 AAC原始数据块”的连续拼接。这里的关键认知是AAC原始数据块本身是不携带任何参数的解码器如果直接拿到一段raw AAC data根本不知道采样率是多少、声道怎么排、帧边界在哪里。ADTS头就是弥补这个信息的这也是为什么裸流必须用ADTS承载。现在很多人直接拿MP4里抽取的AAC裸流做分析会意外发现它没有ADTS头这是正常的——MP4容器自己存了音频参数不需要每帧重复。所以当你面对的数据是“带ADTS头的裸流”时才有这篇文章说的解析需求。1.2 哪些场景逼着你手动从字节里抠参数从我的实际经历来看需要手动解析ADTS头提取这三个参数的情况主要有这么几类。第一类是裸流分析工具。这个很好理解一个.aac文件没有box头你要知道时长、要检测编码配置是否合法只能从头开始一帧一帧数。第二类是转封装或协议转发。比如在某个跨平台系统的转封装模块中输入侧直接给的是纯AAC字节流输出侧要封装成其他格式必须在不解码的情况下拿到profile和采样率去构造目标格式的元数据。这个场景下你没得选必须读ADTS头。第三类是流媒体切片和HLS拉流。TS流里的音频PES负载携带AAC数据虽然TS头里有一些描述信息但很多时候不完整你仍要回到ADTS头上找答案。第四类是裁剪和拼接工具。想从音频中间切一段出来如果不解码重编码就要靠frame_length逐帧跳过定位到目标秒数对应的帧位置这本质上也是解析ADTS。这些场景的共同特征非常明显手上只有字节流没有任何封装层元数据必须自己从ADTS头里把参数取出来。弄明白这套逻辑之后你再看那些号称“轻量级”“零解复用”的音视频工具核心往往就是这段话里的东西。1.3 解析之前需要建立的两个基础认知第一个认知ADTS头是“定格式”的但不代表帧长固定。每个ADTS头都长一样7字节或9字节但每帧的frame_length字段可以不同因为AAC编码在不同复杂度的音频片段上压缩率会变。这就是为什么你不能用“每帧固定多少字节”的思路做跳转必须一帧一帧读frame_length再跳到下一帧。第二个认知ADTS头的解析是纯位操作不是字节操作。profile、采样率索引这些字段都不是整数个字节对齐的它们有的占2位、有的占4位跨字节分布的情况非常普遍。你如果按“读一个字节得到一个参数”的直觉去写必然出错。正确姿势是先搞清楚每个字节的每一位属于哪个字段再通过位移和掩码把它们抠出来。这两个认知如果建立不起来后面写代码时会反复踩坑。我见过有人用结构体强转的方式去读ADTS头在小端字节序的机器上直接翻车这就是没想清楚“位”和“字节”的区别。2. ADTS头逐位拆解profile、采样率、帧长到底藏在哪些位2.1 7个字节的完整位布局比你想的更紧凑标准ADTS头一共7字节。假设收到的是一个无CRC的ADTS头我们从第0字节开始逐个拆。第0字节固定是0xFF它是12位同步字syncword的高8位。第1字节的高4位是同步字的低4位也就是0xF。从这里开始后面的信息全是非线性分布的。第1字节里bit3是ID字段表示MPEG版本0为MPEG-41为MPEG-2bit2和bit1是LayerAAC要求这两位的值是00bit0是protection_absent1表示没有CRC校验常见0表示有CRC校验这时头会多出2字节共9字节。第2字节开始进入参数核心区。bit7和bit6是profile占2位bit5到bit2是sampling_frequency_index占4位bit1是private_bitbit0是声道配置channel_configuration的最高位。第3字节的bit7和bit6是channel_configuration的低2位和前一个字节的bit0拼在一起构成完整的3位声道配置bit5是original_copybit4是homebit3是copyright_identification_bitbit2是copyright_identification_startbit1和bit0是frame_length的最高2位。第4字节的整整8位都用来表示frame_length的中间8位。第5字节的bit7到bit5是frame_length的最低3位bit4到bit0是buffer_fullness的高5位。第6字节bit7到bit2是buffer_fullness的低6位bit1和bit0则是number_of_raw_data_blocks_in_frame。这串描述光看文字容易绕晕我建议你在调试的时候打开二进制查看器对照上面的说明逐字节看。真正理解了之后你会发现这个7字节头的设计确实紧凑几乎没有浪费一位。字节序号位7位6位5位4位3位2位1位00syncword 高8位固定0xFF1syncword低4位固定0xFIDLayerLayerprotection_absent2profileprofilesampling_frequency_index4位private_bitchannel_config高位3channel_config低2位original_copyhomecopyright_bitcopyright_startframe_length高2位4frame_length中间8位5frame_length低3位buffer_fullness高5位6buffer_fullness低6位raw_blocks数2.2 profile字段不能拿来就用它背后还有个AOTprofile字段占2位直接映射的关系是0对应Main Profile1对应Low ComplexityLC2对应Scalable Sampling RateSSR3对应Long Term PredictionLTP。实际项目中你们遇到的AAC音频绝大多数是LC profile这是兼容性最好、应用最广泛的AAC配置。但这里有一个很多人不知道的细节ADTS头里的profile字段其实是MPEG-4 Audio Object TypeAOT的低2位。这意味着什么意味着HE-AAC这种带SBR的AACAOT5二进制是101低2位是01也就是十进制的1和AAC-LC在ADTS头里显示的profile值一模一样。所以你没法只靠ADTS头里的2位profile去区分LC和HE-AAC。这个坑在实际项目里有真实影响。如果你写的工具要对AAC流做profile识别根据profile1就断定是LC然后拿这个结论去做后续处理遇到HE-AAC流就会判断错误。解决方案是解析AudioSpecificConfig里的5位AOT字段或者从容器元数据里获取真实AOT。但如果你的场景只是判断“这流能不能解、按什么规格解”那LC和HE-AAC的区分通常不影响解码器工作很多解码器看到HE-AAC也能自己检测SBR。2.3 采样率索引表4位索引背后的真实数值sampling_frequency_index字段占4位取值范围0到15。其中0到12是有效值对应13种标准采样率13到15属于保留值需要特殊处理。索引采样率Hz备注096000常见于高码率音频188200264000348000视频制作最常见444100音乐、CD、流媒体最常见532000广播、低码率6240007220508160009120001011025118000电话语音常用12735013保留不应出现14保留显式频率需从AudioSpecificConfig读取15保留不应出现这张表建议直接写进代码里做静态数组。注意索引13、14、15这三种情况不能直接查表否则会读到数组越界或者垃圾值。第14个索引比较特殊规范里允许通过AudioSpecificConfig显式指定采样率但ADTS头里不提供所以遇到它时正确的做法是返回“采样率未知”而不是强行编一个值。2.4 frame_length字段从7到8191时长全靠它frame_length字段占13位最大值是8191。它表示的是一整帧的完整字节长度包括ADTS头本身、CRC校验如果有、以及AAC原始数据块。为什么这个字段如此重要因为它是遍历裸AAC流的“步长”。你解析完一帧后只有通过frame_length才能跳到下一帧的位置。如果这个字段读错一位后面所有的帧定位全部错乱解析结果完全不可信。至于时长计算则要涉及另一个常量一个AAC原始数据块固定对应1024个采样点。注意这是每个声道的采样点数量。所以单帧时长的公式是单帧时长 1024 / 采样率例如采样率是44100Hz一帧时长就是1024 / 44100 ≈ 0.0232秒约23.2毫秒。总时长公式则是总时长 总帧数 × 1024 / 采样率这里有一个容易混淆的概念总时长是时间维度的长度不是字节长度。有些刚接触的人会把文件字节总数当成“长度”直接算这是错的。你真正要统计的是帧数然后用帧数乘以每帧的采样点数再除以采样率得到秒数。还有一点要留意number_of_raw_data_blocks_in_frame这个字段在绝大多数文件里都是0。如果是0那么一个ADTS帧内就只有一个AAC原始数据块正好1024个采样点。如果这个字段不是0说明一个ADTS帧里包含多个原始数据块总采样点数要乘上(raw_blocks 1)。这种情况非常罕见但严谨的解析器应该把它考虑进去。3. 动手实现从单帧解析到全流统计3.1 单帧ADTS解析函数C和Python双版本我先给出C语言版本因为做底层音视频处理的人很多还是习惯用C而且位操作的表达非常直接。#include stdio.h #include stdint.h #include stddef.h static const int AAC_SAMPLE_RATES[13] { 96000, 88200, 64000, 48000, 44100, 32000, 24000, 22050, 16000, 12000, 11025, 8000, 7350 }; typedef struct { uint8_t profile; // 0Main, 1LC, 2SSR, 3LTP uint8_t sample_rate_index; uint32_t sample_rate; uint8_t channel_config; uint16_t frame_length; uint8_t has_crc; } aac_adts_params_t; int parse_adts_frame(const uint8_t* buf, aac_adts_params_t* params) { if (!buf || !params) return -1; if (buf[0] ! 0xFF || (buf[1] 0xF0) ! 0xF0) return -1; uint8_t layer (buf[1] 1) 0x03; if (layer ! 0) return -1; uint8_t protection_absent buf[1] 0x01; if (protection_absent 0) { // 有CRC的头共9字节frame_length9才是合法帧 if (params-frame_length 9) return -1; } params-profile (buf[2] 6) 0x03; params-sample_rate_index (buf[2] 2) 0x0F; params-channel_config ((buf[2] 0x01) 2) | ((buf[3] 6) 0x03); params-frame_length ((uint16_t)(buf[3] 0x03) 11) | ((uint16_t)buf[4] 3) | ((buf[5] 5) 0x07); params-has_crc (protection_absent 0); if (params-sample_rate_index 13) { params-sample_rate AAC_SAMPLE_RATES[params-sample_rate_index]; } else { params-sample_rate 0; return -2; } return 0; }Python版本写起来更简洁而且适合拿来做验证和原型工具AAC_SAMPLE_RATES [ 96000, 88200, 64000, 48000, 44100, 32000, 24000, 22050, 16000, 12000, 11025, 8000, 7350 ] def parse_adts_header(data: bytes): if len(data) 7: return None if data[0] ! 0xFF or (data[1] 0xF0) ! 0xF0: return None layer (data[1] 1) 0x03 if layer ! 0: return None protection_absent data[1] 0x01 profile (data[2] 6) 0x03 sf_index (data[2] 2) 0x0F channel_config ((data[2] 0x01) 2) | ((data[3] 6) 0x03) frame_length ((data[3] 0x03) 11) | (data[4] 3) | ((data[5] 5) 0x07) sample_rate AAC_SAMPLE_RATES[sf_index] if sf_index 13 else 0 return { profile: profile, profile_name: [Main, LC, SSR, LTP][profile], sample_rate: sample_rate, sf_index: sf_index, channel_config: channel_config, frame_length: frame_length, has_crc: not protection_absent, }C版本里有几个细节值得说。frame_length的计算用了三部分拼接buf[3]的低2位左移11位作为最高2位buf[4]整体左移3位作为中间8位buf[5]的高3位作为最低3位。这正是我前面讲的跨字节位拼接新手最容易在这里出错。还有一点我做了layer校验这一下就能把AAC和MP3的裸流区分开MP3的ADTS头layer字段是01或10不是00。3.2 全流遍历跳帧、统计帧数、计算总时长单帧解析只是第一步真正要拿到“长度”这个参数必须遍历整个流统计总帧数。遍历的核心逻辑并不复杂从一个偏移位置开始校验是否为合法ADTS头如果是就解析frame_length然后把偏移前进frame_length个字节如果不是偏移加1继续找下一个可能的帧头。def analyze_aac_stream(data: bytes): offset 0 frame_count 0 first_header None total_duration 0.0 while offset 7 len(data): if data[offset] 0xFF and (data[offset 1] 0xF0) 0xF0: layer (data[offset 1] 1) 0x03 if layer 0: flen ((data[offset 3] 0x03) 11) | \ (data[offset 4] 3) | \ ((data[offset 5] 5) 0x07) if flen 7 or offset flen len(data): break if first_header is None: first_header parse_adts_header(data[offset:offset 7]) if first_header is None or first_header[sample_rate] 0: break frame_count 1 offset flen continue offset 1 if first_header and first_header[sample_rate] 0: total_duration frame_count * 1024.0 / first_header[sample_rate] return first_header, frame_count, total_duration这套逻辑的巧妙之处在于不需要预先知道流的长度只要数据在内存里就能从头扫到尾。但它有一个潜在的效率问题如果数据流里有很多干扰字节每次只前进1字节去找syncword会很慢。优化思路是用memchr之类的函数快速跳转到下一个0xFF字节再判断它的下一个字节高4位是否为0xF。对于纯AAC裸流帧之间没有干扰数据每个frame_length都是精确的遍历效率很高。时长计算就一行frame_count * 1024.0 / sample_rate。这里我默认number_of_raw_data_blocks_in_frame为0也就是标准情况。如果你要极其严谨可以在解析时把raw_blocks也读出来然后总采样点数是frame_count * 1024 * (raw_blocks 1)但99.9%的场景用不上。3.3 防御性检查这几种情况宁可停也别瞎算写解析代码最怕的不是功能实现不了而是在数据异常时给出一个“看起来正确但实际上是错的”结果。我在实际开发中总结了几个必须处理的边界情况。第一个是采样率索引越界。前面表格里已经写了索引13、14、15不是合法采样率如果代码不做判断直接查表C语言会数组越界Python会IndexError。更危险的是有些实现会在数组越界时读到内存里的随机值导致采样率变成一个荒谬的数字。正确做法是先判断sf_index 13不满足就返回未知。第二个是frame_length小于头长度。ADTS头最少7字节有CRC时9字节。如果frame_length字段读出来比头还小那几乎可以断定这个位置不是真正的帧头而是数据里恰好出现了0xFFF开头的字节组合。这时候应该放弃这个位置继续往后搜索。第三个是frame_length超出剩余数据总量。这说明数据流可能被截断或者上一帧的定位已经错了。稳妥做法是停止遍历而不是越界读取。我之前做过一个工具就是在这个判断上没写对裁出来的音频总时长每次都差那么一两帧排查了很久才发现是在尾部少判断了一次。第四个是ID位和Layer位校验。有些AAC文件是MPEG-2版本ID1解析逻辑本身不用区分但Layer必须是0。如果Layer是1或者2说明这段数据更可能是MP3的裸流直接返回错误可以让上层快速发现格式不匹配。4. 实际排查记录最容易踩的坑和我的处理方式4.1 0xFFF不是AAC的专属暗号别一见到就激动AAC裸流里的原始数据块是经过压缩的音频数据内容完全是随机的它内部完全可能连续出现0xFF 0xF0开头、且第三字节也恰好满足profile字段合法性的序列。这意味着仅靠syncword匹配就会把音频数据误判成ADTS头。我处理这个问题的方法是多帧验证。具体做法是每解析到一个疑似帧头先假设它是真的解析出frame_length跳到下一帧位置再检查下一帧是否仍然是合法的ADTS头。如果能连续验证2到3帧都成功才认为当前起点是真的帧序列。如果在下一帧位置发现不合法就回退到原起点的下一个字节继续搜索。这个策略在实际测试中误判率很低因为连续的AAC原始数据里恰好连续多次出现合法帧头的概率几乎为零。4.2 采样率索引越界不是小事它能让整个工具崩溃有个印象特别深的排查经历某次我用一个工具解析一个来自不明来源的音频文件工具在解析到某一帧时直接崩溃报错是数组越界。一开始我以为是数据有问题后来才发现是索引没做范围判断采样率索引是14程序直接拿去查表了。更隐蔽的问题在于有些语言实现数组越界时不报错会返回相邻内存的数据。假如采样率索引是13但数组里有默认值0你拿到的采样率就是0。然后时长公式变成了“帧数×1024/0”结果就是无穷大或者一个巨大的垃圾数。你看着这个数字还觉得很奇怪但找不到原因其实根子就在一个if判断上。所以我现在写解析代码有个习惯凡是从流里读出来的索引字段先判断范围再使用宁可返回错误也不让程序带病运行。4.3 profile1到底该判断成LC还是HE-AAC前面讲过ADTS头里的profile只暴露了AOT的低2位。HE-AAC的AOT是5二进制101低2位是01也就是1和LC一样。这意味着如果你硬要根据ADTS头去区分LC和HE-AAC结论必然不靠谱。我遇到的实际场景是在做一个音频流分析工具时产品要求显示“编码档次”我就只用了ADTS头的profile字段结果测HE-AAC音频时显示的是LC用户反馈说不对。后来我在文档里说明了这个限制并补充了从AudioSpecificConfig读取AOT的路径才算真正满足需求。这里想提醒的是如果你的应用只关心能不能播、采样率是多少LC和HE-AAC的区别真的无所谓解码器自己会处理SBR。但如果你做的是专业分析工具输出要给其他系统用就必须考虑这个字段的局限性。4.4 ID3v2标签和头部额外数据会让你的帧计数从第一个字节就歪不要以为.aac文件就是从ADTS头开始的。不少工具生成的AAC裸流文件前面会附加ID3v2标签里面可能有专辑封面、歌手名这些元数据。如果你不跳过它遍历从标签数据开始第一帧的解析结果就是错的。ID3v2的头部结构是固定的10字节前3字节是“ID3”ASCII字符紧接着2字节版本号1字节标志位最后4字节是标签大小。注意这个大小字段用的是syncsafe integer编码即每个字节只使用低7位最高位强制为0。换算公式是size ((byte6 0x7F) 21) | ((byte7 0x7F) 14) | ((byte8 0x7F) 7) | (byte9 0x7F)真实数据起点在文件偏移10 size处。如果你的解析工具一上来就读0字节肯定炸。我还遇到过一种情况文件尾部有填充字节padding长度不固定遍历到尾部时frame_length超出了数据范围程序就直接break了。这个处理是对的不用为了追求完美修正而强行猜测尾部填充长度。4.5 帧长算错一位后面的所有帧全错frame_length一共13位跨3个字节分布一个位的错误就会导致跳帧位置完全错乱。我在写代码初期犯过把buf[4]整体当作frame_length低8位的错误结果解析出来的帧长只在特定情况下正确一旦帧长超过255就出现偏差。排查这类问题的技巧是拿已知数据对照。你可以先用ffmpeg或者直接从MP4里提取一段AAC ADTS流做测试素材手工解析第一帧的头把每个字节打印成二进制对照位表核对一行一行看。这个过程虽然繁琐但能帮你快速定位是哪一位拼错了。另外一个容易错的地方是拼接顺序。13位的frame_length高2位来自buf[3]的低2位中8位来自buf[4]低3位来自buf[5]的高3位。我在C代码里用了显式的位移ensure顺序不会错。Python里也同样要注意运算符优先级左移的优先级低于加法所以必须加括号。最后补一句解析ADTS头这事代码量不大但信息密度很高。说实话这类底层字节解析的核心不在于你会不会位运算而在于你愿不愿意在“看起来正常”的数据上做防御性校验。后来我又写了很多类似的数据解析模块从NALU到SEI到各种box踩坑经验如出一辙先假设它不对再证明它对这个思路比任何技巧都重要。如果你在实现过程中也碰到了奇怪的现象优先怀疑索引越界和帧长拼接顺序这两处是重灾区。
阅读完成 · 觉得有帮助?
咨询建站