• 本文核心参考 Xiph官网Vorbis文档《Vorbis 规格 I》# 注释编码 和 《Vorbis 注释字段 & 标头 规范》
• 本文涉及外网文档,无统一中文翻译。因此使用作者翻译,水平有限,感谢谅解。
• 本文有一定专业性,读者应具备 基础计算机科学知识 或 基本编程能力。
【原作者:神武竹 • 未经允许,禁止转载】
「前言」
紧随Xiph基金会和Ogg容器,Vorbis格式来了。Vorbis技术上很先进,不要小瞧这2002更新至2020的“老技术”,无论难度、设计上都远超MP3。中网上vorbis是无人详解的,直接给你扔英文的毛用没有;我的中文参考资料只有21世纪初的几篇博士论文(豆丁网),致敬前辈!
Vorbis的算法比较复杂,看看官网文档有多长?料定Vorbis不是暑假可完成的了(上一篇Ogg的java代码也怕是补不上了)。暂定把Vorbis拆成几个小文章和一篇主文章,慢慢写。作者是学生党,预计8月中旬开学,勿念!
2022.8.9
【原作者:神武竹 • 未经允许,禁止转载】
「正文」
Vorbis注释【Vorbis Comment】
是Vorbis格式用来标记音频信息的数据。根据Ogg容器的限制,Vorbis除初始标头外,还有两个辅助标头:注释标头【Comment Header】& 设置标头【Setup Header】。类似MP3的ID3标签,注释标头标记音乐的作者、演奏者、时长、版权等等信息。Vorbis注释应该只包含简短的信息,不应该储存 无关的元数据 或 过长的段落。
Vorbis注释最开始设计给Vorbis音频使用,但因为简单实用、拓展性好,被陆续装在了FLAC、Opus、Speex音频和Theroa视频格式里,依然叫Vorbis注释
。Vorbis注释在Ogg容器里总是单独成包(通常是第二个),作为辅助标头(见《Ogg格式》);即使没有实际内容(向量数为0),也必须存在。
Vorbis注释包由一个供应商字符串【vendor string】
和若干注释向量【comment vector】
组成。“供应商字符串”在前,提供了编解码器的信息,包括 源组织名(公司、集团、基金会)、编解码器名称、时间戳等等。例如官方库 libvorbis 的 VENDOR_STRING 为Xiph.Org libVorbis I 20200704 (Reducing Environment)
。供应商字符串由编解码器自行添加,用户不需要控制。
每个注释向量都是一组键值对,或者叫注释字段
【comment field】。很像UNIX环境变量,注释字段结构是 字段名称=值
【field name = value】。字段名称只能包括ASCII字符(简单起见,绝非歧视非英语国家)的0x20(空格)~ 0x7D( } ),而且不能有0x4D(‘=’因为等号用来分隔字段名称和值)。参考图:
而且字段名称不区分大小写,也就是 A - Z 等同于 a - z,在libvorbis中,全部作大写处理。等号后是字段值,必须使用UTF-8编码,没有特殊限制。
字段名称=值
构成了一个注释向量
(实际上就是一个字符串)。 每个向量总长度不能超过 231-1 (有符号int的最大值)字节。向量总数也不能超过 231-1 个。向量的数量可以为0,但是供应商字符串必须存在(其长度也可以为0)。其实这么大的数压根用不完,Vorbis注释的大小约等于无限制。
字段名不是唯一互斥的,多个向量可以使用同一字段名。比如一首曲子有三个作曲家,可以使用三个向量,每个向量的字段名都是artists
。
理论上,字段名称
没有任何限制,你可以随意拼一个来,也就是随意定义注释字段(正如 XML 可以随意定义标签)。但这容易导致注释混乱,例如artist
和composer
都是作曲者
,用哪一个呢?再例如artist
可以是演奏者
也可以是作曲家
,究竟用哪一个呢?因此,Vorbis定义了一系列标准字段名
【standard field names】,作为官方参考标准;非强制性,爱用不用,自定义非标准字段名
也可以;但要求上下文清晰,不得滥用,不得污染公共命名空间。谁闲的发慌去自己定义字段名
标准字段名表如下:
标准字段名 | 含义/用途 |
---|---|
TITLE | 音乐作品名称 |
VERSION | 音乐版本 |
ALBUM | 专辑 |
TRACKNUMBER | 曲目编号(专辑里的第几首) |
ARTIST | 艺术家。音乐中指作曲者,比如《命运交响曲》作者是贝多芬;有声读物中指文章的作者 |
PERFORMER | 表演者。音乐中指演奏者(个人/乐团);有声读物中是阅读者;如果艺术家和表演者是同一个人,那么省略此字段 |
COPYRIGHT | 版权归属 |
LICENSE | 许可证。如“保留所有权利”,也可以填许可证的URL地址 |
ORGANIZATION | 组织。如作曲者的签约公司等等 |
DESCRIPTION | 简短描述 |
GENRE | 音乐流派。比如民歌、摇滚、爵士 |
DATE | 录制日期。建议使用ISO 8601标准,即 YYYY-MM-DD |
LOCATION | 录制地点。建议使用WGS84标准,即 纬度;经度[;海拔] |
CONTACT | 原作者或组织联系方式。电子邮件地址、网址、电话等 |
ISRC | ISRC编号。即国际标准音像制品编码 |
实际编码时,因为向量之间没有分隔符,必须编码每个向量的长度。每个向量编码时,先将其长度作为一个有符号int整数写入,再写入向量字符串。有符号int叫做向量长度
,按照Little Endian顺序。因为供应商字符串必须存在,单独解码;向量可以放在循环里解码,所以在供应商字符串后有一个有符号int向量总数
。
Java代码实操
package com.mcsw.media.vorbis;
import java.nio.charset.StandardCharsets;
import java.util.*;
import java.io.*;
import javax.sound.sampled.*;
import com.mcsw.media.ogg.*;
import com.mcsw.io.Bits;
/**
* @author MCSW
* @version 1.0
*/
public final class VorbisCodec {
/* 省略若干内容 */
/**
* 此方法从字节数组中读取单个向量并返回一个String数组。
* @param src Vorbis注释包源数据的字节数组
* @param offset 源数组的偏移量
* @return 一个String数组,长度为2,0的位置是键,1的位置是值。
* @exception IndexOutOfBoundsException 读取向量时超出数组长度
* @exception CommentException 注释向量格式问题
*/
private static String[] readVector(byte[] src, int offset) {
// Bits.getInt(byte[] src, int offset)是我写的方法,将数组从偏移量开始的四个连续字节按Little Endian组成int值。
//读取四个字节并组成int,然后读取int个字节,按照UTF-8转为字符串,再在第一个‘=’处分割字符串
String[] field = new String(src, offset + 4, Bits.getInt(src, offset), StandardCharsets.UTF_8).split("=", 2);
// 没有 ‘=’ 抛出自定义的异常
if (field.length != 2)
// CommentException是自定义的
throw new CommentException("char = not found");
// 检查字段名称有无非法字符
for (; src[offset] != '='; offset ++) {
if (src[offset] < 0x20 || src[offset] > 0x7D)
throw new CommentException("Invaild Character in Vector");
}
// 字段名称大写,返回
field[0] = field[0].toUpperCase(Locale.US);
return field;
}
/**
* 此方法从字节数组中读取所有向量并返回不可变的Map对象。
*
* @param src Vorbis注释包源数据的字节数组
* @return 一个不可变的Map键值对,键类型为String,值类型为Object,实际上只能为String或String[];必定含有“vendor”键。
* @exception IndexOutOfBoundsException 读取向量时超出数组长度
* @exception CommentException 注释向量格式问题
*/
public static Map<String, Object> readVectors(byte[] src) {
// 键值对数组,源数组偏移量
String[] keys;
String[] values;
int offset = 4;
//读取供应商字符串 存储String用的Object
String str = new String(src, offset, Bits.getInt(src), StandardCharsets.UTF_8);
// Bits.getInt(byte[]) = Bits.getInt(byte[], 0)
offset += str.length();
// 循环、辅助专用工具数——i!在这里是向量总数。
int i = Bits.getInt(src, offset);
keys = new String[i];
values = new String[i];
// 节点数组
String[] node;
// 循环读取向量
for (i = 0; i < keys.length; i ++) {
node = readVector(src, offset);
// readVector确认Key字符串一定是ASCII,所以直接使用String.length();Value字符串可能是UTF-8,所以只能先转换再加长度(效率较低)
offset += 4 + node[1].getBytes(StandardCharsets.UTF_8).length + node[0].length();
keys[i] = node[0];
values[i] = node[1];
}
// 存储String用的Object 和 节点集合
Object value = str;
List<Map.Entry<String, Object>> buf = new ArrayList<>(1);
// 添加供应商字符串
buf.add(Map.entry("vendor", value));
// 查重 值集合;node转空数组
List<String> list = new ArrayList<>();
node = new String[0];
// 查重
for (i --; i > 0; i --) {
if (keys[i] == null)
continue;
str = keys[i];
// 查找所有键相同的节点
for (offset = 0; offset < i; offset ++) {
if (str.equals(keys[offset])) {
// 清除相同的键,留下值添加到集合
list.add(values[offset]);
keys[offset] = null;
}
}
//是否有重合
if (!list.isEmpty()) {
// 有,value是集合转化为的数组,清空集合
list.add(values[i]);
value = list.toArray(node);
list.clear();
} else
value = values[i];
buf.add(Map.entry(str, value));
}
return Map.ofEntries(buf.toArray(new Map.Entry[0]));
}
}
Q:为什么Map的值用Object?不仅不好写,而且代码安全性差,难道就是为了同时放String和String[]?
A:javax.sound.sampled是Java官方标准数字音频包,每个音频文件(或者流、URL)都有一个AudioFileFormat对象;AudioFileFormat有一个成员变量就是一个不可变的Map<String, Object>,用来存储音频信息。此处为了兼容java官方库考虑。
Q:使用“vendor”作为供应商的键,不怕冲突?
A:根据上文,字段名称无视大小写,在官方libvorbis库中统一大写处理;在上面readVector方法中将注释名称全部大写再返回;而“vendor”是小写的,不可能冲突。
【原作者:神武竹 • 未经允许,禁止转载】