​关于 M4A 文件的随机访问

作者: haodongyuan

文章介绍了M4A文件的大概结构,详细解读了其中的Sample Table Box,并结合图例,详细讲解了如何使用它来完成M4A文件的随机访问。

本文属原创作品,转载请保留出处!

一、MP4文件结构简介

在讲解M4A的随机访问之前,我们先来大概了解一下MP4文件结构,以及MP4和M4A的关系。

整个MP4文件由若干个box组成,box可以嵌套。每个box包含自己的大小和类型等信息,之后就是包含的内容,box也可以作为其内容,形成嵌套,如下图所示:

图片来源

类似面向对象编程语言,box也有“继承”的概念,所有box都继承于Box类,其结构如下:

class Box {
    uint8 size;
    uint8 type;
    if (size == 1) {
        uint64 largeSize;
    } else {
        // 一直到文件末尾
    }
    if (boxtype == 'uuid') {
        uint8 [16] usertype;
    }
}

其中,size就是这个box的大小,包含所有字段(包括size自己)和它包含的box。type就是此box的类型,必须由四个英文字母表示。

有了这两个值,就可以快速定位到某个box了。

另外一个常见的box是FullBox,stbl里面的box都继承于此类,其结构如下:

class FullBox extends Box {
    uint8 version;
    bit[24] flags;
}

MP4规范中描述了非常多的box,不过最常用的到的其实只有这些:

图片来源:"MP4文件格式的解析,以及MP4文件的分割算法"

M4A与MP4的区别

M4A可以理解为只包含音频的MP4,最初由Apple提出。

具体到Sample Table Box里面区别,由于所有音频帧都是同步帧,因此M4A没有stss。至于有没有elst,还没有找到任何规范说明M4A是否存在elst,但是从faad解码库的源码里面找不到任何elst相关逻辑,所以本文将不讨论elst。

二、什么是 Sample Table Box

现在进入主题:在MP4中,如何进行随机访问。

在MP4中,一个轨道一定并且只会存在一个Sample Table Box,简写为stbl。它的官方定义如下:它包含一个轨道中所有媒体采样的时间-数据索引。说人话,它的主要功能就是:将时间转换成对应采样在文件中的位置。

这对流媒体播放是至关重要的。比如说,在流媒体播放中,如果用户seek(既拖动进度条)到了1:50处,如果1:50的数据还没有被缓冲,就需要我们马上从这里开始缓冲。

那么问题来了:如何知道1:50对应的数据在文件中哪个位置呢?

一个简单的方法就是用平均码率来计算:

offset = bitrate * time

如果歌曲是恒定码率(CBR),并且头不大的话,用这个方法计算offset,再加上一些补偿,也是可行的。

如果想更精确地计算offset,就必须使用Sample Table Box,既stbl。

三、如何使用 Sample Table Box

stbl里面包含很多box,有必需,也有可选的。这里对必需的进行详细讲解,可选的只做简单介绍。

首先来看一下如何找到stbl,以及它包含哪些子box:

图片来源:"MP4文件elst研究"

然后,我用伪代码描述一下完整的流程:

long seek(long timeMs, long timeScale) {
    long actualTimeMs;

    uint8 time = (timeMs/1000) * timeScale;    // <--- [1]

    if (trak 包含 elst) {
        time += elst.get(time);    // <--- [2]
    }

    // time对应的sample
    uint8 sample = stts.get(time);    // <--- [3]

    if (stbl 包含 stss) {
        // 获取离sample最近的同步帧
        sample = stss.get(sample);    // <--- [4]
    } else {
        // 没有stss,说明stts中的每一帧都是同步帧
    }

    // sample所在的chunk
    uint8 chunk; 
    // 该chunk中第一个sample的序号
    uint8 firstSampleInChunk;

    [chunk, firstSampleInChunk] = stsc.get(sample);    // <--- [5]

    // 该chunk在文件中的位置
    uint8 chunkOffset; 

    if (stbl 包含 stco) {
        chunkOffset = stco.get(chunk);    // <--- [6]
    } else if (stbl 包含 co64) {
        chunkOffset = co64.get(chunk);    // <--- [6]
    } else {
        throw "这个文件头不正确!"
    }

    // sample在其所在chunk内的偏移
    uint8 sampleOffsetInChunk = stsz.get(sample, firstSampleInChunk);    // <--- [7]

    // 两个offset相加就是sample在文件中的位置了
    return chunkOffset + sampleOffsetInChunk;    // <--- [8]
}

其中,必需的box有:stts、stsc、stco或co64其中一个、stsz,一共四个。可选的box有:elst、stss。

接下来,我们来看下上面伪代码中各个操作的意义。

1、时间单位转换

MP4内部的使用的时间单位不是秒、毫秒等物理意义上的时间单位,要经过以下转换:

time = realTimeInSeconds * timeScale

其中,timeScale的含义是:一秒内流过多少个时间单位,对于音频,就是每秒采样率,对于视频,就是每秒帧率。

2、时间偏移

如果trak中存在elst,事情就有些复杂了,它的出现,说明MP4中的某条轨道的时间戳有偏移,比如视频比音频慢10s,或者某一帧画面停留一段时间等等。

这里不做详解,有兴趣的话,可以参考:link和link,使用方法可以参考ffmpeg的代码,见mov.c的mov_build_index方法。

3、获取时刻对应的sample:stts (Decoding Time to Sample Box)

这个box保存了sample序号和对应的播放时间信息。其中,播放时间通过差值的方式进行保存,以减少box的大小。

它的结构如下:

class stts extends FullBox {
    uint8 entry_count;
    uint8[entry_count] sample_count;
    uint8[entry_count] sample_delta;
}

一个entry就像这样:

Sample Count

Sample-delta

14

10

说明这个entry包含14个sample。每个sample的时间相差10个时间单位。

如果整个stts只有这一个entry,那么就很容易计算出:

  • 当time < 14 * 10 时,sample = time / 10
  • 当time >= 14 * 10 时,sample = 14(因为总共只有14个sample)

以此类推即可得出任意entry时的算法。

4、获取同步sample:stss (Sync sample box)

这是一个可选的box,如果stbl中不存在此box,说明每一个sample都是同步的。否则就要通过此box查找同步sample。

每一个音频sample都是同步sample,所以M4A不会存在stss。

查找方法很简单,用二分法查找即可。

5、获取chunk序号及内部偏移:stsc (Sample to Chunk Box)

在继续之前,有必要先来介绍一下,在MP4中,媒体数据是如何保存的。

所有chunk位于mdat中,每个chunk大小可以不一样,其中包含的每个sample也可以有不同大小。

一个chunk中包含一个轨道的若干个连续sample。不同轨道的chunk交错存放。

如下图所示,chunk1包含轨道1的4个连续sample,chunk2包含轨道2的4个连续采样。

stsc用于查询sample所在的chunk。它的结构是这样的:

class stsc extends FullBox {
    uint8 entry_count;
    uint8[entry_count] first_chunk;
    uint8[entry_count] samples_per_chunk;
    uint8[entry_count] sample_description_index;
}

举个例子,比如stsc包含两个entry:

first_chunk

samples_per_chunk

sample_description_index

1

10

不关心

50

20

不关心

说明第1个到第49个chunk,每个chunk都包含10个sample,第50个以及之后的chunk,每个chunk包含20个sample。如下图所示:

这样就可以计算出sample所在的chunk了,比如sample 490位于chunk 49第一个sample,sample 499位于chunk 49最末,sample 500刚好位于chunk 50的第一个sample。如下图:

得到chunk序号之后,用stco或者co64,可以计算出该chunk在文件中的位置。co64其实就是stco的64位版本,使用方式是一样的,两者只能同时存在一个。

6、计算chunk偏移:stco

这个box包含每个chunk相对文件开头的偏移,结构如下:

class stco extends FullBox {
    uint8 entry_count;
    uint8[entry_count] chunk_offset;
}

使用方法很简单,用chunk的序号去chunk_offset数组里面取就行。如下图:

注意,上面得到chunk的序号是从1开始的,去数组里面取的时候注意减一。

7、计算chunk内部偏移:stsz

这个box包含sample的大小信息。它的结构如下:

class stsz extends FullBox {
    uint8 sample_size;
    uint8 sample_count;
    uint8[sample_count] entry_size;
}

sample大小不一定是固定的,如果是固定的,sample_size就不为0;否则,每个sample的大小保存在entry_size数组里面。

比如有这样一个stsz box,它的sample_size为0,entry_size中记录每个sample在所属chunk内部的偏移:

在第5步(stsc的使用)中,我们获取到了sample所属chunk的序号,以及chunk第一个sample的序号,通过stsz,就可以获得该sample在所处chunk内部的偏移。

比如要计算sample 497的内部偏移,需要从497所属chunk的第一个sample(在这里是490)开始,将偏移累加起来:

看到这里,你是否会想到:既然stsz包含了所有sample的大小,仅通过sample大小就可以计算出对应的偏移,不再需要计算chunk偏移了。 但不要忘了:chunk是按照不同轨道交错排列的,而且即便只有一个轨道,每个chunk自身头部的大小也不能忽略。

8、计算最终偏移

chunk的偏移加上sample在当前chunk内的偏移,就是sample的完整偏移了。如下图:

四、如何解析stbl

box的解析比较简单,读取前8个字节,其中前4个字节为box大小,后4个为类型,知道类型后,按照类型定义的字段按序读取即可。

其中有两点需要注意:

  1. 将byte[]转换成int时,使用大端序
  2. 解析多个数组时,要“交错”地解析,比如stts,应该这样解析:for (uint8 i = 0; i < entry_count; i++) { sample_count[i] = readInt(); sample_delta[i] = readInt(); }

五、总结

相对于Flac珍惜每一个bit的办事风格,MP4还是比较慷慨的,所以解析起来比较方便。

而且,经过观察,MP4的关键sample间隔仅在0.02s ~ 0.04s,作为比较,flac的seektable则是平均10s一个关键sample。

至于STBL所占大小,我观察了几个文件,所占空间很小:

文件大小(KB)

时长(S)

STBL大小(KB)

1319

209

19

1887

193

6.6

3550

333

61

由于文章只关注M4A的随机访问,MP4中可见的elst、stss,ctts等等box就没有解析了,如果对这方面有兴趣,可以参考MP4的规范以及网上资料。

六、参考资料

  1. ISO/IEC 14496-12 (内容很多,其实只看Appendix A就好了,对MP4文件做了一个大致的介绍,此外,第11、12页是其中最常用的)
  2. MP4文件格式的解析,以及MP4文件的分割算法
  3. MP4文件elst研究

原创声明,本文系作者授权云+社区-专栏发表,未经许可,不得转载。

如有侵权,请联系 yunjia_community@tencent.com 删除。

编辑于

我来说两句

0 条评论
登录 后参与评论

相关文章

来自专栏FreeBuf

关于CPU漏洞Spectre的详细分析

一 前言 阿尔法实验室研究人员通过结合POC对整个漏洞原理流程还有漏洞细节做了进一步更详细的技术分析。 在本文中将详细分析POC中每个环节的关键点和漏洞的所有细...

2107
来自专栏自动化测试实战

HTML第五课——css盒子模型

1094
来自专栏cnblogs

CSS3新特性应用之用户体验

一、光标 新增加not-allowed光标,不允许访问 隐藏光标,在触模应用上很有用,css2.1需要一个透明的图片来实现,而css3直接用cursor:non...

1838
来自专栏猿人谷

对缓存的思考【续】——编写高速缓存友好代码

开篇 上一篇博文对缓存的思考——提高命中率详细介绍了高速缓存的组织结构,并通过实例说详细明了cpu从高速缓存中取数据的过程,对于缓存的工作机制应该有了清晰的认识...

19410
来自专栏*坤的Blog

hdu1045

1646
来自专栏Ldpe2G的个人博客

自定义scala 字符串插值

1034
来自专栏程序员互动联盟

【深度解析第一讲】大小写字母如何转换?

有网友提出怎么转换英文字母的大小写,这个也是编程中非常常见的需求,这个问题其实很简单,很多有点基础的朋友都会解决,下面我给出三种常用的方法给初学者参考。 ? 方...

2848
来自专栏瓜大三哥

Verilog代码设计风格

1.信号命名规则 信号命名规则在团队开发中占据着重要地位,统一、有序的命名能大幅减少设计人员之间的冗余工作,还可便于团队成员代码的查错和验证。比较著名的信号命名...

1887
来自专栏向治洪

html5 jqueryrotate插件实现旋转动画

CSS3 提供了多种变形效果,比如矩阵变形、位移、缩放、旋转和倾斜等等,让页面更加生动活泼有趣,不再一动不动。然后 IE10 以下版本的浏览器不支持 CSS3...

1726
来自专栏一名合格java开发的自我修养

storm 1.0版本滑动窗口的实现及原理

滑动窗口在监控和统计应用的场景比较广泛,比如每隔一段时间(10s)统计最近30s的请求量或者异常次数,根据请求或者异常次数采取相应措施。在storm1.0版本之...

713

扫码关注云+社区