【原作者:神武竹 • 未经允许,禁止转载】

「前言」

•请区分Ogg与OGG

* 本文Ogg为一种开源多媒体容器格式,由Xiph.Org基金会开发支持,常见于音视频文件和网络传输。

* Oracle Golden Gate(OGG)一种基于日志的结构化数据复制软件,由Orcale甲骨文公司开发支持。

经典混淆:

8abf007303234e729f3efe99cca03e22.png

72d78e211d7f4b38b23fbd9b509da4c5.png

(仅指出错误,没有针对性。上两篇文章虽有纰漏,也不乏参考价值。重点:Ogg不是一种压缩格式)

• 本文主要参考文献【Xiph.Org基金会官网Ogg规格文档】

• 辅助资料:2006浙江大学 冯炯硕士论文《Ogg/Vorbis解码器设计实现》

( 代码实践性很强,只有少量纰漏,致敬前辈!)

• 代码示例均为Java语言。其他语言程序员可跳过示例,了解原理即可。(还没写)

• 本文技术性较强,读者应有 基础计算机知识 和 编程基础 。凡重要、晦涩、存疑处都加【】注释。

• 本文涉及英文资料,无官方中文翻译,因而使用作者翻译,感谢指正。凡重要或易混淆的译名也加【】注释。

        Ogg是Xiph.Org基金会开发的开源流媒体容器格式。适用于Opus、Vorbis、Theora、Kate、PCM等多种音视频编码格式。

        中网有很多Ogg技术文章。但都有通病:

一是翻译混乱,Xiph官方无中文支持,导致一些文章的翻译冗余或用机翻,更有甚者上来就甩你一篇英文;二是胡乱删减,官方英文文档极为详细,有些文章省略过多细节,连“逻辑流”“物理流”的含义都不解释,你压根找不到真正解释清楚了“多路复用”的文章;三是没有实践,要么不上代码空谈连篇,要么直接套其它代码库;四是反复“转载”,层层降值。

        本人第二篇技术性文章,一方面 希望写一篇 可供参考 的文章;另一方面承接第一篇Xiph基金会,提高文章的专业性,顺便练练文笔~

       图片均源自网络,侵删 |

       感谢阅读与指正

【原作者:神武竹 • 未经允许,禁止转载】


「概述」

        Ogg是一种流媒体容器格式。提供了对流式【连续的按时间排列的数据】数据进行 分割、存储、排序、按时间顺序查找以及寻错保护【数据丢失或错误时进行调整】 的框架,也可以将多个流混合,即多路复用,形成视频或其他复杂文件。

        Ogg 是容器【container】,是个箱子,类似微软的Wav格式。Ogg文件理论上可以装任何数据,而不仅仅是音视频,甚至可以存 pdf 或 jpg (只要有对应的解码器)。Ogg容器和内部存储的数据 / 元数据【metadata,描述数据的数据】本质上是分离的,好比 箱子跟箱子里放的东西 无关,称之为解耦。Ogg可直接作容器,也能作另一个更复杂(可能非线性)容器的组分。

        Ogg同时指Ogg多媒体项目(原名Squish),该项目包括Ogg、Vorbis、Theora(Opus不属于Ogg项目)。参考作者第一篇文章【Xiph基金会】

4365d6b20ddc4b768318b650b445039c.png

【原作者:神武竹 • 未经允许,禁止转载】


「设计特点」

        简易。实现容易,无论是分页、分段还是多路复用;不需要重新打包源数据;字节对齐【你就理解为以字节为单位】;编解码复杂度持平;容器结构固定。

        流式。Ogg主要用于流媒体数据【流式传输的多媒体数据,适用网络传输即时播放】传输 / 存储 / 播放。编解码器 能且只能 一次性、不回头地编解码整个Ogg流,不需要也不能 花时间查找数据或使用多余的缓冲区。Ogg适用于面向流的TCP、本地文件;UDP、RTP(面向数据报)不宜搭配Ogg。

        线性Ogg 摒弃了索引的设计【!!!】,理由是它对流媒体是多余的,Ogg拒绝非线性访问,允许但强烈不推荐 优化编码(如二次编码,分两次扫描数据)或 交互解码。

       绝对颗粒位置【absolute granule position】的设计,具体见 基本概念-页。相当于绝对时间戳【单纯指时间标记】。不同类型数据的颗粒位置有不同的意义,编解码器可以自行定义、使用,非常灵活;在复合流中又可以互相转化,使音频、视频以及字幕/弹幕能统一装在容器里,进行播放和跳转。(而不是把音频、视频分两块)

        空间高效<未必>。每在65307Bytes约64kB以下,通常一页4~6kB,其中容器数据只有27 ~ 282字节,空间开销0.4 ~ 0.7%。

        捕获/定位【capture,以下用“定位”】。每页大小 < 64kB,所以最多读取128kB(130613Bytes)就能定位Ogg流,因而可以从流的任何一处开始解码。

        查找【seek 。能相对简单地进行“低精度”或“高精度”查找。参见 高级-查找。

        多路复用【Muxtiplex,简称mux】。将多个基本流交错成复合流,使音频、视频、弹幕/字幕 等混合在一起 ,可以同时解码播放。见 高级-多路复用。

        完全解耦。容器与 数据/元数据 是分离的;容器和编解码器也是分离的;对复合流而言,不同基本流间的关系跟容器本身更是毫无关系的。Ogg只是个箱子,里面装的什么、怎么用、什么关系 跟箱子本身没有一毛钱关系,Ogg只负责把数据按你定好的规则放进去、存好、取出来。所以Ogg可以存放 各种各样 的数据。当然解耦有一定弊端,见 高级-Skeleton。


「彩蛋」

        很多人误认为Ogg是音频格式。实际上Ogg容器存储的Vorbis文件才是一种音频格式,Ogg还可以存储FLAC、Opus、PCM音频,以及Theora、Dirac、Daala视频、Kate、XSPF、JSPF文本。只不过最初只有Vorbis装在Ogg里,合称Ogg Vorbis,简称Ogg。后来虽有了其他格式,大家都叫习惯了;于是2007年Xiph将错就错,规定只有Ogg Vorbis文件的拓展名为“.ogg”,而视频换成“.ogv”、音效换成“.oga”等。

        Ogg目前规格版本还是0,只更新了一个可选的Skeleton【见Skeleton】来辅助解码,可选索引还在草案中。(其实Xiph就是懒)收回这句话,Xiph2012年前后提出了Ogg 2 -- TransOgg,针对传输和安全性对Ogg进行了改进。主要改进有:强制性元数据流Skeleton,最大系带值由255降至252,可选的索引Ogg Index,强制编解码器信息写入Skeleton,元数据增强等等。然而和Ogg Index一样,尚在草案中(10年了喂!)。参见【XiphWiki TransOgg】

        Ogg的名字源于上世纪世界第三款网络游戏 Netrek 里的梗:“Ogging”,不能写成“OGG”,但老有人写错。

        梗百科:Netrek 是一款开源网络游戏,也是世界上第一款网络团队游戏。玩家作为一艘星舰舰长与敌舰干架。游戏分两队,通过消灭敌人而占领所有行星即胜利。

742182e8a23647709b98a0afa9de8a8b.png Netrek 游戏截图

        其中玩家编号是    <队伍首字母> + 十六进制数字(0~9 a~f)   。而观众(观察模式的玩家) 或者 人机 是    <队伍首字母> + g ~ z字母    。

        1990年11月某日,卡内基梅隆大学计算机室里三个玩家Jay Hui,Byron Sinor,Steve Russel正在Netrek开黑。三人都是游戏新手,菜。

        服务器腐竹Terence(就他把游戏带进学校)黑进游戏伪装成人机,编号为“Og”(Orions猎户队)。Terence可是大佬级别,他决定给三个菜鸟“上上课”。他秀出一套高阶操作:自杀性袭击 ---- 即开局 不管资源不发育 直接全买武器 跟正在发育的对面硬刚。

        于是,“机器人” Og 大杀四方,血洗三军,给Steve吓坏了。安静的计算机室里,Steve突然暴喝:“Help!It's 'O' 'g'  !”( “救命!Og来了”,念两个字母)然后继续喊“Hellllllllpppp!It's Og!It's Ogggggggg!”( “救命啊啊啊啊,是Og!”念成“奥格”)。

        Ogg于是乎成了梗,游戏里代指这种自杀性袭击战术“Ogging”;Ogg在生活中可以表示“不考虑退路、强行地做”。

        Ogg 始于1993年Monty开发的大项目,项目目标是开发压缩多媒体格式。其中音频压缩的软件叫做”Squish”。“squish”直译“挤压”引申为“压缩”。Squish网页公布后,Monty得知“Squish”这个名字已经被一家邮递公司使用,为避免侵权,在基友Mike建议下,改成“OggSquish”。

        为什么叫Ogg呢?Monty构思Squish的90年代,美国PC的CPU几乎都是Intel i386芯片,i486才刚刚推出。但Monty开发的压缩算法非常复杂。

        “啊哦,他们(用户)至少需要一个i486芯片” “如果用Squish软件播音乐,半个计算单元都闲不下来。” (monty)这当然是一种“不留退路”,也就是Ogging了。

        后来,Ogg 变成容器格式的名字,而Squish变成了Ogg编解码器的名字(已淘汰)。总之,Ogg不是人名,也不能写成”OGG”。

       至于它的徽标…呃…

76a28da569714b9da5fd542aaf5dfdf5.pngd46c74e7014c4098b8d7148e2f0c437d.gif早期OggSquish徽标

你看清了吗 ?徽标取材于北欧神话“雷神战蛇”的故事。但这 分辨率 和 配色 真是一言难尽(Squish与Ogg已经无关了;所以封面没用这张图)。寓意嘛……官网说……

       “蛇的身子很像正弦曲线,雷神索尔(Thor)举起雷神之锤(Mjollnir)要 砸扁(compress,双关“压缩”)长的像 周期信号(知道傅立叶变换的话你就懂了)耶梦加得(Jörmungandr蛇名)…… 是不是有感觉了?”

       【此时无声胜有声】

       


「基本概念」

<Bitstream>

       指流式数据。常译“位流”,“比特流”或“数据流”,下文统一“比特流”。

<逻辑流>

        Logic Bitstream。“逻辑意义上的数据流”,指编码器创建的比特流。逻辑流的基本单位是包。

        就是分完包的原始数据。逻辑流中的数据应该是同一类型的:比如一首歌(音频)可作一个逻辑流,一部无声电影(视频)可以当一个逻辑流,一篇文章(文本)也能作逻辑流。但一首MV(音频 + 视频)最好分成两个逻辑流;带弹幕的动画(音频 + 视频 + 文本)应分为三个逻辑流。

       逻辑流就是Ogg容器要装的数据。

6d9686486b414228b15d3afac692b01c.png

逻辑流 由(通常)无框架信息的包 组成

<包>

        Packet 或译“数据包”。原始数据像一块蛋糕,被切成若干块,即包(怎么切蛋糕不是Ogg的事)。包的大小理论上从0到无限字节(可以为0!!),不需要框架信息(也就是说把包连起来还是原始数据,相当于蛋糕切了还是蛋糕),框架信息由页提供。不需要,但可以有。

       会写代码的可能更容易理解:包就是一个任意大小的byte数组,是从源数据上分割下来的。0长度的包叫空包【nil】

       虽然包可以随便分,但更推荐能直接解码的相对独立的数据。一个包可以对应一个视频帧、一个音频帧;虽然大部分包只有50 ~ 200Bytes大小(一帧),但单个包大小在 0.5 ~ 1 KB 时效率最高,还可以直接用于UDP传输(因为UDP数据报限制1024字节内)。(明面上“无限制”,结果还有这么多“建议”,这波上屋抽梯)

       逻辑流第一个包叫BOS包【Beginning Of Stream流的开头】,最后一个包叫EOS包【End Of Stream流的末尾】

<物理流>

        Physical Bitstream。“物理意义上的流”,指Ogg编码器输出的比特流。物理流的基本单位是页。

        物理流要么是基本流,要么是复合流,但必须是 单个 由页组成的流。物理流相当于装完数据后的整个容器,比如一个“test.ogg”文件就是一个物理流。

        复合流中的每个基本流都有一个唯一 流序列号【见 基本概念-页】,标记在页上,按序列号即可把混合的页重新组装成基本流。【见 高级-多路复用】

<基本流>

         Elementary Stream 或 Elementary Physical Bitstream。单个逻辑流分页后形成的新流叫做基本流。它可以单独作物理流,也可以多路复用后变成复合流。

         Logical Ogg Bitstream。“逻辑Ogg流”。根据官网文档,这个单词与“基本流”应该同义。这导致很多文章混用 逻辑流 和 基本流 两个概念。区别可能在于:“基本流” 强调 “一个物理流 / 复合流的一部分”,而“逻辑Ogg流” 强调 “构成复合流的一部分”。

         一个基本流最少只有一页(BOS页)。

<复合流>

         Multiplexed Bitstream【 Multiplexed Physical Bitstream,或“复合物理流”/“混合物理流”;mixed streams,或译“混合流”;又有multiplexed streams,强调“多路复用的流”。以上统一“复合流”】。由多个基本流 多路复用 构成。见 高级-多路复用。

<链>

        Chain【或“链条”】

        已知  :

        1 逻辑流 分页 ->  1 基本流      |

        N 基本流 混合 -> 1 复合流      |

        1 基本流 / 复合流 -> 1 物理流 |

        而多个物理流串联【Chaining 意译】成一个新流,即链【chain】。链中的每个物理流叫做节。【link,直译“链条”,强调链中的一环,意译为“节”】

        注意,节与节不能混合、交错,只能串联。前一节没有结束,后一节不能开始。此时每个基本流的流序列号在整个链内都必须是唯一的。链中的每一节都是独立的物理流。如图是两个物理流组成的链:

d266cd4083084d70894762d4e2c2a1bc.png

方块-页    同颜色-同一基本流   右下角数字-页号

initial-BOS页   term-EOS页

        两个节都是复合流。前一节 有 红/蓝/绿 三个逻辑流,后一节有 黄/紫 两个基本流。只有红蓝绿三个基本流的最后一页都出现之后,即三个流都结束之后,黄/紫 基本流才能开始。

<段>

         Segment【或“片”】。要把包封装成页,因为包大小不固定且没有框架信息,所以先把每个包分成更小的数据单元:段。段本质上还是个byte数组,长度在0 ~ 255 之间,段的长度称为 系带值【lacing value】。参见 基础编解码-数据包分割。

复习一下:

|     逻辑流 分页 ->         基本流  

 |    基本流/复合流 ->      物理流  

 |   基本流 多路复用 ->   复合流

*****************敲黑板*****************


<页>

         Page。Ogg物理流的基本单元。页与页是相互独立的。页构成了整个容器,页的结构 和 页与页 / 页与流 的关系是Ogg规格的核心。所有容器功能都集成在页中。页的大小不固定。

页头【Page Header】

        页头是页的功能结构,包含了全部容器信息。页头字段结构如图:

6f9157b73995421bb35c2efb0cf0bfd9.png

图片来自维基百科

字节1~4(对应图中0 ~ 3)

定位域【capture pattern,或“捕获模式”】

      “定位Ogg流”指找到Ogg流中某一页的精确位置。Ogg流中页与页首尾相连,故只要找到了一页的准确位置,就可以顺着找到后面所有的页。(但不能直接找前面的页)。

       定位域就是页的标志,四个字节固定为4F 67 67 53 ,ASCII “OggS”,即“Ogg Stream”,注意大小写。

       解析Ogg文件时,定位域可以当幻数【Magic Number,又称“魔数”,在数据首部设置的固定字节,用于标识文件类型】;而在网络传输时,定位域使程序可以在流的任意一处开始解析。见 基础编解码-定位。

字节5

流结构版本【stream_structure_version】:

        即Ogg容器的版本,目前只能为0。

字节6

类型标记【header type flag】:

          标记此页在流中的上下文 关系,主要是和前一页的关系。这个字节(8位)是按位 取值的。目前只使用后3位,剩下的留未来使用。

     (从右向左)

        第一位:Continue位。如果为0,此页以一个新包开始,是新页【Fresh】;如果为1,则此页数据 接着 上一页的最后一个未完成的包,即此页是上一页的续页【Continued】。当然,续页也可能只包含这一个包而且还没续完,则下一页还是续页。

        第二位:BOS位。如果为1,表示此页是其所属基本流的第一页【Beginning Of Stream】。对应它的页号必须为0。见 基础编解码 - 填页头。

        第三位:EOS位。如果为1,表示此页是其所属基本流的最后一页【End Of Stream】。一个基本流理论上可以只有一页,此页同时是BOS和EOS页。

        BOS页是流的第一页,所以一定是新页(前面一定没有其他页)。即如果第二位为1,则第一位一定为0。综上,类型标记有6种有效值:000、001、010、101、100、110。十进制:0 1 2 4 5 6。

字节7~14

绝对颗粒位置:

         8 字节的有符号整数。下面长篇大论解释:

        在流中实现跳转(seek)和多路复用,都需要有时间戳。根据时间标记,程序便可以精确跳转到流的某一处开始解码;根据时间标记,编码器才能把 计时单位不同 的  视频/音频/字幕  按时间顺序排列交错【见 高级-多路复用】。

        但我们又说,Ogg容器和数据是完全分离的:想知道总时间,难道要从头开始解码?

        为了实现 解耦 + 时间戳 ,Ogg采用了绝对颗粒位置的设计。Ogg要求颗粒位置必须是绝对时间戳【如总样本数/总帧数】,故称“绝对颗粒位置”。注意! “总时间 / 绝对时间” 是指从 <流开始> 到 <这个包> 为止 的时间,所以某个包的 绝对时间 =  前面所有包的时间加上自己的时间 之和。

        颗粒位置只是个数字,它的单位由数据类型决定:对于音频,单位是 个(PCM样本)【关于PCM编码,可参见《PCM数据格式》】;对于视频,单位一般是 帧 ,也可以直接以毫秒为单位。颗粒位置可以和实际时间相互转换,例如音频总时间为 样本数➗样本率,视频总时间是 帧数➗帧率。颗粒位置如何转换,取决于编解码器,与容器无关,不需要修改Ogg的代码,符合开闭原则【修改关闭,拓展开放】。

        颗粒位置的意义完全由编解码器解释,所以除了绝对时间之外,还可以在颗粒位置里附加其他数据(但保证颗粒位置仍然按非递减顺序排列),从而添加新功能。Theora(视频)就是这么做的:

        视频帧分为关键帧和中间帧,中间帧必须依赖关键帧才能解码。所以视频跳转时,必须跳转到关键帧处;但Ogg本身没有标记关键帧的设计。这时候,颗粒位置的灵活性就显现出来了。

        Thoera把这8字节分成 6B<总帧数> + 2B<偏移量>。关键帧 K 的<总帧数> = 包括自己在内的所有帧的总数,中间帧 C 的 <总帧数> 等于 所依赖的关键帧K 的 <总帧数> ,<偏移量>表示C与K的距离(单位帧 也就是包),K 的<偏移量>为0。

        这样,一 保证颗粒位置仍然非递减排序,二 把关键帧位置巧妙嵌入Ogg容器中。跳转时可以根据偏移量往前找到关键帧。

        由于单位不同,不同的颗粒位置可以转换为同一时间:比如 视频 640 帧 =  音频 12800 个样本 =  16 秒。跳转/多路复用Mux 时需要把它们转换成同一单位(建议 秒/毫秒 )。

        烧脑吗?回归原题。

        每个包都有自己的<时间>和<总时间>,每个包都必须有自己的绝对颗粒位置。包的<总时间> = 上一个包的<总时间> + 本包的<时间>。一个包的<时间>可以为0。见 基础编解码-填页头。下文提到颗粒位置时,请一定注意<总时间>和<时间>的区别。

        页的 绝对颗粒位置 是本页最后一个 <已完成的包> 的颗粒位置,如果最后一个包还没完成(下一页是续页),就取倒数第二个数据包的颗粒位置。如果整整一页只有一个包而且还没完成,颗粒位置取 补码 -1页的颗粒位置可以为-1,包不可以。

字节15~18

流序列号【stream serial number】:

         页所属逻辑流的序列号。标识不同的逻辑流,仅在多路复用Mux时有用。可以取 -1 外的任何值(-1表示空流)。

字节19~22

页号【page sequence no】:

         页在所属逻辑流中的编号,表示这是逻辑流中的第几页,从0开始。用于检查页是否丢失。

字节23~26

CRC校验和【page checksum】:

        页的校验码【check code,加在数据末尾检查漏误的一串数字】,采用32位CRC算法。CRC是一种优秀的检错码,想学习自行搜索,可参考这里【年代早 无图 细节满满】,精通英语的大佬看这里【专业性极强】。

       如果你已经懂了CRC,请注意:Ogg的CRC采用直接算法(不反转),寄存器初始值为0,余数为0。因为CRC码在页头,计算时先把 CRC 的4个字节全填0,算出校验和 再填到这4个字节里,校验时同理。

字节27

段数【page segments】:

         此页中段的数量,也是段表的长度,大小范围0 ~ 255,一页最多有255段。如果为0,说明此页不含数据,为空页。

字节28 ~ 27 + N

段表【segment table】:

         段的长度表 / 系带值表。表的长度由 段数 决定。段表后面就是原始数据一大串字节。段表中每个字节 都表示数据中一个 段 的长度,即系带值,大小0~255。

        例如,某页段表有四个字节:255 255 255 173

        那么,紧跟段表后面就是按顺序排列的 255、255、255、173 字节的四段。

        由上可算出:一页最大大小是 27 + 255 + 255 * 255  = 65307 B,略小于64KiB(64*1024 = 65536)。最小27字节,即只有页头没有数据,这种页叫“空页”【nil page】,通常充当EOS页。不建议用太大的页:因为一旦数据出错,这么大一页都要丢弃,血亏!页的最佳大小是4 ~ 6KiB。

        注意:上文所有整数的字节按低字节序【Little Endian又译“低位编址”】,即低位字节(字节 不是 位 !)在前,高位字节在后。比如  0x 00 00 25 9a  四个字节,输出时变成 9a 25 00 00


「基础编解码」

        这一节讲述了最基础的Ogg编解码过程,官方libogg 库实现了基础编解码功能,参考gitlab-libogg库。

        解码显然比编码简单。搞懂编码,解码就通了。这里只涉及 单个逻辑流 <—> 基本流的过程,对于 多个基本流 <—> 复合流 的情况,见 高级-多路复用。

壹:数据包分割 【segmentation分段】

        Ogg要存储的数据就是一堆包,大小无限制,但页的大小是有限制的。第一步,将包分成更小的段,使之能组装成页;而段 又要能重组成 包。所以分系带值是有套路的:

        把一个包从前向后,每255字节分为一段。如果分到最后,不够255字节,就把剩下的分成一段;如果刚好255字节,为了区分边界,再加一个长度为0的段。这样系带值 < 255字节的一定是一个包的最后一段。

       例如,一个753字节的包 + 一个255字节的包  会被分成:

       255  255  243  255  0          共 5 段

       分段的设计使 过大/过小 的数据包都不会严重浪费空间。当然最好还是在255 ~ 510字节效率最高。(< 255字节的较小包浪费严重,> 510字节的较大包大约耗费0.5%)。

       段重组成包也简单。每个系带值< 255 的段都是一个包的末尾,把它以及它前面所有255字节的段合起来即可:

       255   255   243       255   0       67

                             ^                  ^        ^

                       753B包       255B包   67B包

贰:数据包分页【pagination】

       现在我们有了一大堆段。接下来组装成页,我们且不管页头其他部分,先把段和段表装起来。

       包的大小不确定,所分出来的 段的数量 也是不确定的。大数据包一页装不下  ;小数据包一页只装一个 又太浪费了;如果一页只装整数个包,页的大小就很可能太参差不齐。综上,我们不能按包来分页,而应该用更小的单位:段。

       按段组装页,就意味着数据包可以跨页。一个包可能前几段在上一页,后几段在下一页;特大包可能跨好几页;小包可能都塞在一页里。如此一来,页大小便可以把控在4 - 6KiB 左右。

       一个包看成一篇文章,页就看作书页。文章有长有短:可能一篇长文占几页;也可能几篇短文放一页;文章还可以跨页;但每页的大小都差不多。(Monty就是这么比喻的,于是起名为“页”)如图:

       

6a542e6a0c874d75be9fe07d61b8dca9.png

数据包分页(这里没有模拟超大数据包)

        但是,Ogg建议尽量避免跨页的包,因为这变相增加了解码复杂度(特别是增大空间开销);在libogg的函数中,默认情况 每一页要满足(1)至少有4个包 (2)大小超过4096字节 (3)最后一个包不跨页 才能输出。这是一个对调用者隐藏的低级策略。只有4096这个阈值可手动调整;若上述三个条件一直不满足,页会一直装到满才输出!说实话,这个实现真摆烂。不过官方文档也说:编码器可以有更高级的策略,libogg只是个模板。

叁:填页头【封装】

        我们已经把每个段的系带值写进了段表。我们是按逻辑流的顺序分包,按包的顺序分段,按段的顺序分页,那么页已经是有序的。只要加上页头 ----

        页头可不简单。

        定位域、版本是固定的;页号按顺序标(从0开始);流序列号找个随机数就行;CRC校验码的话本文不提。只有 类型标识 和 绝对颗粒位置 要费脑筋了。

        BOS页是流的第一页,Ogg要求 BOS页只能有一个包 -- 初始标头【initial header】 ,只用于标识,不含具体数据,通过它必须能得到整个逻辑流的数据类型,从而确定对应解码器(尤其是确定颗粒位置的转换方式);而且必须能确定流的连续性 (见 高级-缓冲)。所以我们回到第二步分页的时候,把初始标头单独装进首页里,BOS位为 1。

        同时,Ogg规定:逻辑流可以有辅助标头【auxiliary header】,辅助标头可以由若干个包组成,用来提供额外信息或者为后续解码提供初始化参数(例如Vorbis的注释标头和设置标头)。辅助标头必须紧跟初始标头,所以辅助标头的页必须紧跟BOS页,且最后一个包必须恰好在最后一页完成(此操作称为刷新页【Flush page】),真正的数据从新页开始。于是我们又回到第二步,调整辅助标头的页。

        EOS页是流的最后一页,EOS位为 1。一个基本流可以只有一页(奇怪的规范,只有BOS页就只有初始标头;只有标识没有数据有什么意义?),它的BOS  EOS位都为 1。

        如果这一页以一个新包开始,那么Continue位为0;反之为1。这个设计只在查找操作时,正常解码时只要看前一页的最后一段系带值,是255说明数据包还没结束,下一页为续页;反之下一页是新页。Continue位增强了流的检错功能,同时提升了查找操作的效率。【见 高级-查找】

        因为逻辑流通常是多媒体数据,一定是按时间顺序排列的;所以包的绝对颗粒位置必须按照非递减【后 >= 前】排列,相应地,页的颗粒位置也必然非递减排列( -1 除外)。包的时间可以为0,所以颗粒位置可能 = 前一个包,同理,页的颗粒位置可能 = 前一页。页的颗粒位置是页中最后一个<已完成的包>(包的最后一段在页里,就说包在此页上完成)的颗粒位置,如果没有完成的包就填 -1。总之,后一页的颗粒位置要么为-1,要么大于等于前一页。

        诶不对,如果一页里有多个包,前几个包的颗粒位置不就没了吗?确实,颗粒位置的精度会下降。但Ogg认为只要控制好页的大小即可控制误差在容忍范围内。这一点在 高级-查找 里还要重提。

        上面两段文字其实是Ogg对数据的基本要求,我开头说 “Ogg里可以存任何数据” 不完全对:(闲的发慌的)你还需要把数据 合理地拆成包 并加入 初始标头(+ 辅助标头),还要定义一个非递减的颗粒时间出来。编解码器像你这样将逻辑流完美封装到Ogg流中的过程称作 Ogg 映射【Ogg Mapping】或 编解码器映射【Codec's Mapping】

        Ogg 映射包括:

1. 将逻辑流分成整数个包;

2. 定义颗粒位置如何转换,颗粒位置必须按非递减排序,必须确保可以用 上一个包的颗粒位置 算出 此包的颗粒位置。

3. 提供初始标头(和辅助标头)放在流开头;

4. 按前文编码。

        到此为止,我们把一个逻辑流 完美地 封装成了一个基本流。接下来,我们来看看解码。

肆:解码问题

        解码当然就是编码的逆过程:读取页,根据段表合成包,然后输出逻辑流。此外多了几步:检查页头、CRC校验等,判断是否有数据丢失。

        》》 页 丢失/错误 怎么办?

        如果丟页,你可以选择 跳过/报错 。一般而言,网络传输时常忽略错误,可能的话要求重传数据;文件解析时 常抛出异常;页头错误建议忽略,但CRC校验错误、段表错误是不应忽略的(说明数据异常),这时又要选择 跳过/报错。

        跳页的方法就是重新定位。如果异常页的 上一页有未完成的包 / 下一页有续包,都要丢弃。丢失的数据 跳过 / 用黑屏、静音等糊弄过去,如果丢失太多,那还是报错吧。

        最恶心的情况:因为段表错误,无法正确读取异常页的数据,也就找不到下一页。这种严重错误十有八九可以被CRC校验出来(这时你已经读完错误的页了)。这就只能回移指针到页头段表处,用重新定位下一页。

        》》关于 捕获/定位

        定位Ogg流时,因为单页最大65307字节,读取65307B后还没找到定位域,可以直接报错 “非法的Ogg流”。

        查找 和 定位 操作在libogg库里都叫seek函数(该死的官方),为了区分我在自己的代码里用capture( )方法指代定位。capture有两种实现:一种是调用capture后指针就位于页头;一种是capture( )返回下一页到指针的偏移量。个人偏爱第一种:不需要把指针移回去。

        如果你已经完全掌握,可以用自己擅长的语言写一个 Ogg 基础解析库了。需要小试牛刀的话,这里有【Vorbis测试音频】,下载后缀.ogg的文件,测试你的程序吧。

        不满足的往下看。

【原作者:神武竹 • 未经允许,禁止转载】


「高级」

       这一节讲解在 编解码单个逻辑流 的基础上,Ogg容器的高级功能。Xiph官方liboggz 库在libogg库的基础上实现了这些功能,代码参考gitlab-liboggz

>> 多路复用

        我也不知道我在前文写了几个“见多路复用”了,Xiph用了整整一篇文档描述它,但千万不要把它想高级了:Ogg追求简单!

        多路复用【Multiplex,或译“多路传输”】,以下简称Mux。指把多个 基本流 按时间顺序混合、交错成为 复合流 的过程。Mux使得视频、音频、字幕、弹幕等可以被混合在同一个传输流中,解码时同时播放。

         Mux时,交错的是页,所以页本身是保持不变的。把页想象成扑克牌,几副 花色不同的牌 混在一起,但牌还是牌。要想把混合的牌还原,只要挨个看牌,把同一花色(流序列号)的牌按数字顺序(颗粒位置)放一起即可。Mux 和 Demux 多路分解【Demultiplex,即multiplex的逆过程,或译“多路分配”】对于计算来说很简单。如图:

        f72dc08d0b9e4cf29867d4ee888a3b6d.png

基本流的Mux   “OggS”表示页    同色表示同一基本流

         综上所述,页是Mux/Demux的最小单位。所以我们要搞懂的,就是不同流的页怎么个交错法儿。

         回到上面的那张图:

d561e21319e84bb99e2a4deca51e4f8a.png

       我们可以看到 :红、蓝、绿 三个基本流的BOS页(图中 initial)被一同放在复合流的最前面,其后的页无规律地排列,三个流的EOS页(图中 term)也不在一起。因此:

       MUX一:基本流的BOS页一同放在复合流最前面。BOS页之间不能插入非BOS页,但顺序无要求。

        MUX二:基本流的EOS页不需要放在一起。因为基本流没有必要同时结束。

       BOS页有初始标头包,标记逻辑流的数据类型。解码时,只需读完所有BOS页,就可推断此流是否是复合流,以及复合流有几个基本流 ,每个流的数据类型和流序列号,从而使解码器调用对应的编解码器。

       

        如果在上图中,去掉其他页,只看红色的页,从左到右是红0、红1、红2、红3 ……红n。基本流的页依然保持原始先后顺序。

       MUX三:同一基本流的页,彼此之间保持原先后顺序。

       逻辑流还可能有辅助标头。

       MUX四:BOS页全部结束后,所有基本流的辅助标头(如果有)的页紧随最后一个BOS页后出现,互相交错无顺序要求。辅助标头页之间不能出现非辅助标头页。辅助标头页全部结束后数据页开始。

        初始标头和辅助标头只提供编解码信息和其他信息,而非原始数据,所以它们的颗粒位置理应为0(非硬性要求,保持非递减即可)。

       MUX五:数据页根据颗粒位置,按时间顺序交错。这是说,把每页的颗粒位置转换为同一单位的绝对时间,按时间顺序依次插入。例如两个流混合:(字母表示页,大小写表示流,数字表示页的颗粒位置)

       视频流|帧率  60帧/秒       |A 0   B 24   C 33   D 60   E 84

       音频流|样本率  44K个/秒 |a 0   b 8800   c 16500  d 26400  e 39600

        把他们的颗粒位置转换为统一单位毫秒:

       A 0ms    B  400ms    C 550ms    D 1000ms    E 1400ms

       a 0ms     b  200ms    c 375ms     d  600ms     e  900ms

       按时间排序:0 - 0 - 200 - 375 - 400 - 550 - 600 - 900 - 1000 - 1400

       混合结果:A a b B c C d e D E

        此外,时间相同的可以任意插入,比如A a亦可以是a A。(我推荐视频帧在音频帧前面,毕竟解码复杂度不一样)颗粒位置为-1的页的最好紧跟同流的前一页,免得解码器用缓冲等。

       如果你需要复合流来测试程序的话,抱歉找不到较小的ogv视频(目前只有Theora视频是复合流),官方提供较小的英文宣传片是《Digital Show & Tell》。包含一个复合流,由十个基本流构成(已知包括视频、音频、字幕、索引)。下载目录在这里,最小的是360P分辨率、117MB大小。

        (2024更新)Xiph基金会的wiki中有专用于Theora的测试短片,大小只有几MB!网址-》https://wiki.xiph.org/TheoraTestsuite

>> 查找

        查找【seek】即跳转功能,转到某个时间重新开始解析,直观感受就是 视频/音乐 跳转到某一秒开始播放。也就是随机访问【Random Access根据具体位置访问数据的任意一部分】。

        查找分为低精度【粗精度】和高精度【细精度】。

        低精度查找:估算大致字节数,将指针向 前/后 移动一段距离,然后重新定位,检查页头的颗粒位置是否和目标位置对应,若相差较大,重复上述过程直至基本符合。(太tm简单粗暴了,翻译时愣是半天没看懂,以为很高级)

        高精度查找:低精度 + 二分查找算法。如果追求更高精度,还可以按包查找。按包查找时,只能使用同一逻辑流的页。(liboggz库没有实现按包查找)

        可见seek的本质就是:乱猜 + 硬找。不骗你,liboggz库里的seek( )主要调用的函数就叫 oggz_seek_guess() ……

        下面解答常见问题:

> FAQ

        Q :我找到了相邻的两页,前一页的颗粒位置与目标时间误差 -700,后一页误差为 +500 ,是不是该选误差小的那一页?

        A :官方文档认为总是应该找前一页,主要是保险起见。我认为视情况而定,连续流【见缓冲】可以找后一页(误差为0也应该用下一页);非连续流找前一页(误差为0可太完美啦);如果是复合流只能找前一页,参见下文。

        Q :如何在复合流里查找?

        A :因为多路复用是通过转换颗粒位置为统一时间单位,按时间顺序混合,查找时显然用不着多路分解。官方解法:

        忽略复用,通过转换颗粒位置为同一单位,正常「查找」到目标页,然后向前扫描,直到复合流中每个基本流都出现了至少一页。然后跳转到最前面(时间最早)的页(如果某页有依赖页(如视频的关键帧)该页的颗粒位置 = 依赖页,但这改变了颗粒位置的读取方式,意味着编解码器要改变Ogg容器代码才能实现,官方承认了这种耦合)。

        由于部分非连续流的页间隙较大,Ogg允许设置一个最大扫描页数。(我觉得直接不管非连续流更好)

        Q:Thoera是视频格式,需要关键帧,如何查找?

        A:好问!: )  目标时间转换为帧数。调用liboggz(或者自己写的代码)seek,如果恰好找到最后是关键帧的页,直接跳转到关键帧处;只找到中间帧则取颗粒位置前6个字节(关键帧的<总帧数>)作为目标再seek,恰好碰到关键帧同上;否则,切换为按包查找,向后搜索找到关键帧。统共需要「查找」两次,即双重查找【double seek】。

        官方:你也可以简单粗暴地查找中间帧,代价是必须等到下一个关键帧才能继续播放。令人惊讶的是,好多人都选择简单粗暴...

        Q:颗粒位置为-1的页怎么办?

        A:无视。

        补充:对于Ogg解码器而言,最理想的数据源是本地Ogg文件;可靠的流媒体传输(基于TCP的Http协议等)次之,注意:网络传输Ogg时不会发送EOS页,且EOS页一定是Nil页;不稳定可靠的流媒体传输(基于UDP的RTP协议等)又次之;无反馈传输(如卫星广播)最次,因为服务端只管发送,不管数据丢失,客户端也不能与服务端通讯。网络传输不一定能够实现seek功能,而本地Ogg文件肯定可以seek。

> 二分查找

        高精度查找使用插值二分查找算法,其实精度并不比低精度高,但是平均速度略胜一筹。

        1:确定流的两端B和E

        2:(指针)跳转到B和E的中间位置

        3:定位,读取页的颗粒位置。

        4:switch

                case 颗粒位置 == -1,  读取下一页;  回到第3步

                case 颗粒位置 < 目标,E = 当前坐标;回到第2步

                case 颗粒位置 > 目标,B = 当前坐标;回到第2步

                case 颗粒位置 == 目标,结束。

        当然算法也可以进一步改进:更高级的算法,即 计算页平均大小 和 页平均颗粒位置 得到平均比特率,就能更加精准地猜测位置,然后再二分查找。

>> 缓冲

连续性

       Ogg将逻辑流分为连续【Continuous】流非连续【Discontinuous】流 两类。

       像音频、视频这样,时间上连续不断的是连续流。连续流的数据必须是不间断的,只要流尚未结束,一定能读取到更多的数据。

       像 字幕/弹幕 这样,时间上不连续的是非连续流。它们有以下特征:

1,不规则性。有时有,有时没有。

2,离散性。弹幕/字幕 是分散、彼此独立的,不像音视频数据一样前后相连。

3,随机性。尤其是弹幕,完全取决于用户操作。

        总之,它们本身不是流式数据,但必须作为流才能放在Ogg容器里,就是非连续流。

        补充:虽然连续流和非连续流组成的复合流中可以直接seek操作,但是如果按包查找,不能使用非连续流的页。

绝对颗粒位置

        前面我们说:页的绝对颗粒位置 是 该页上最后一个已完成的包 的颗粒位置。现在要自我否定一下:这种“末时间【end-time】”是针对连续流的;对于非连续流,页的颗粒位置是 <本页第一个开始的包的之前所有包的总时间>,而不是<总时间> + <此包的时间>,这种颗粒位置叫“初时间【start-time】”。一个包的初时间 = 上一个包的末时间。如果一页没有开始的包(非连续流几乎不可能使用续页,一般不会有这种情况),颗粒位置用 -1。

        多路复用时,忽略 末时间/初时间 的差异,直接按时间顺序混合即可。

        Xiph官网关于缓冲的内容充满歧义,均以下划线标出。如果你对任何一处有异议,一定要自己去看官方Ogg文档(如果你搞懂了,务必评论区指出本人谬误)。

        节选自原文《Ogg多路复用》-缓冲【buffering】

Ogg缓冲基于一个简单的前提:在解码期间,不允许任何活跃连续流要求得到【stave for。 <编解码器 要求得到 数据>不合理,应为stave(缺乏)之误】数据;缓冲继续进行,直到【proceed ahead until.proceed为“继续进行” ahead可作“提前/向前”,个人倾向“向前”】物理流中的所有连续流一伸手就有准备好的数据以供解码。

非连续流的数据可能会经常出现,例如,在大多数字幕系统中,特定字幕的时间无法确定。因此,缓冲系统应该 到非连续数据来的时候【take ... as it comes还加了引号,意译“随遇而安”,但这里应是直译】 才进行处理,而不是提前(在可能是无限长的时间里)寻找将来的非连续数据。因此缓冲时,不连续流会被忽略;当连续流的页得到正确处理时,非连续流的页只会“掉出”【fall out,什么意思?】流。

缓冲需求 【buffer requirement】对于已编码的流不需要显式声明或管理流;解码器只需读取尽可能多的数据,保证所有连续流类型(也确保不连续流数据及时到达)无间隙 and no more/further【是“连续流数据没有更多的”还是“解码器不再进一步行动(即’罢了‘)”】,从而使缓冲对于给定流是隐式最优的【什么?】。因为所有数据类型的页在流中都被绝对计时信息【指绝对颗粒位置】标记,因此流间同步计时总是显式保持,而无需显式声明的缓冲区。

        这一段很晦涩,官方libogg库里也没有相应的实现。《Ogg比特流概述》的缓冲文段跟《Ogg多路复用》的基本一致,几个单词不同。大致解释一下即可:

        解码复合流时,缓冲区由连续流使用,非连续流没有缓冲区;解码器一旦读取到非连续流数据时,立刻自行处理;缓冲应是隐式的,对调用者透明。

>> Skeleton

        待续


「实现细节」

         本节不参考Ogg文档,而是根据 官方代码库 及 个人经验 提出 Ogg编解码器实现 的建议。这不是Ogg规格的要求。

>> Mux表

        mux/demux时,每个逻辑流(基本流)都有对应的序列号。为了更方便的mux和demux,liboggz库实现了一个表【table】。表是给调用者使用的,它把 流序列号 - 数据 以 键值对【key-value】的形式存成一张表,这里的数据可以是页(demux用)、包(mux用)、基本流。liboggz中 表用两个OggVector(Vector向量,自动扩容的集合)实现,显然,哈希表的效率更高。

        如果要实现更简单的API,可以把多路复用的过程也对调用者隐藏,这时候流表就可以派上用场了。

>> Mux层

(待续)

【原作者:神武竹 • 未经允许,禁止转载】


「其它」

>> MIME

       Ogg根据其存储的数据分三种:音频、视频、其它(拓展)。

       拓展Ogg文件是指:首先必须是多路复用Ogg流;其次必须包含Skeleton。当程序解码ogx文件时,能解码的流就解码,不能解码的直接忽略。拓展Ogg文件的主要作用是混合数据,例如把多首音乐组成的一首专辑 多路复用 成一个ogx文件。没有官方拓展名的文件也可以加上Skeleton后存储为.ogx文件,比如Ogg Kate。(ogx绝对不是指应用程序)

        与之对应,音频Ogg文件的MIME类型是audio/ogg(Mac文件类型码OggV);视频Ogg文件是video/ogg(Mac码OggA);拓展是application/ogg(Mac码OggX)。视频Ogg文件拓展名为.ogv;音频Ogg文件拓展名有.ogg(Vorbis)、.opus(Opus)、.spx(Speex)、.oga(通常指FLAC)、.ogm(已淘汰的MNG);其它Ogg文件拓展名为.ogx。参见【XiphWiki MIME和拓展名】

        Ogg Skeleton可以添加在任何一类Ogg文件中,在音频、视频文件中可有可无,但在其它文件中必须存在(因为ogx必然是复杂大文件)。

>> 初始标头规范

        上文:必须能够通过初始标头确定编解码器。所以每个初始标头肯定存在幻数 以确定数据类型。Xiph明确了部分数据类型的初始标头的幻数和版本字段(如果有)。一般来说幻数的长度是8字节(年代早的Vorbis和Theora是7字节,不属于Xiph的Dirac 和 原本不属于Xiph的FLAC是5字节),具体规范本文章不便列出,参见MIME类型和编解码器


「不足」

        作者没有资格来评论Ogg,我不是它的开发者,也还没有亲自测试过。但我应把对Ogg的指责列出来,留给读者评判。

        对Ogg最刺骨的指责莫过于 2010年3月3日,FFmpeg程序员Mans Rullgard在个人博客上发表的《Ogg异议》了。这篇文章从头到脚地痛骂Ogg,最后直言“烂是对Ogg唯一合适的描述”。此前,他还有一篇文章指责Ogg的颗粒位置设计。这两篇文章引起轩然大波(以至于中网居然有此新闻)。

        看看这篇文章的评论区,“虚伪的自由软件倡导者,暗地操纵互联网控制权” “我们为什么要为了开源选择Ogg这样的东西” “以前坚决反对索引,现在又加,呵,真香” “Matroska也开源,不比它好?”“Xiph压根不解决问题,而是高呼专利专利专利”。我觉得咱们的网络环境也不是那么……

        这惹恼了Monty,Monty于4月27日发表了一篇文章“逐字逐句”地反驳(无考)。

        摘录Mans反对的理由如下:

1 浪费空间。32位的流序列号 、 64 位的颗粒位置、 32位页号 简直“不可理喻”,你用得了那么多吗?不用网络传输,强制的32位CRC有用吗?Ogg开销高达1%,MP4才0.05%!(简单的计算可以得出开销至少是0.4%,但官方称平均开销0.25 ~ 0.5%)

2 随机访问。Ogg反对索引,怎么在网络中定位?seek的效率您考虑了么?一个10GB的文件你要二分操作50次!Ogg根本就不是流媒体用的东西!

3 这“映射”麻烦的要死,真的考虑了通用性吗?哪里比得上Matroska的简洁?Ogg里能装什么?还不是你自己那几个破玩意(指Vorbis和Thoera)

4 多路复用是什么玩意?你知道延迟有多大吗?页这么大,活该延迟高。

5 颗粒位置。你设计这么麻烦给谁看?还转换?为什么是结束时间,开始时间不更容易播放吗?Theora的颗粒位置还更复杂!

6 复杂性。把数据包拆成碎片,要耗多少内存?物理流串联成链,这设计根本不会有人用。

        对于以上指责,Monty一一驳斥。说到底Ogg是用来存储文件和流媒体传输的,使得Mans的一些论点显然不成立(比如“Ogg为什么不能用UDP传输”)。Ogg当然不是完美的,单位Mans后来直接针对Xiph基金会进行道德谴责和引战,显然是在给自己挖坑。


「没了」

• 写作不易,感谢你的支持与指正。所有图片来源于Xiph官方或维基百科。

• 此篇仓促之中,尚有未完之处,除标注“待续”外,实例代码亦未添加。本人学生党,精力着实有限,偶执闲笔,玩笑之作,不得已随缘更新,烦请谅解。

<更新时间2022-8-22>

【原作者:神武竹 • 未经允许,禁止转载】

Logo

权威|前沿|技术|干货|国内首个API全生命周期开发者社区

更多推荐