纯 MoonBit 实现的音频标签元数据读写库(ID3v2 / Vorbis Comment / APEv2 / MP4 ilst),不依赖任何 C 库
moon add cp15695540483/moontagimport {
"cp15695540483/moontag",
}| 容器 | 格式 | 读 | 写 |
|---|---|---|---|
| MP3 | ID3v2.3 / ID3v2.4 | ✅ 全部帧遍历 + 文本帧解码 | ✅ 文本帧改写/新增/删除 |
| MP3 | ID3v2 帧级标志位(压缩/分组/DLI/unsync) | ✅ 按规范处理,压缩帧用 zlib 解压 | ✅ 清压缩位与 unsync 位、补回组号字节、重算 DLI |
| MP3 | APIC 封面 | ✅ MIME/类型/描述/图片数据 | ✅ 嵌入/替换/删除 |
| MP3 | APEv2(尾部) | ✅ 条目 + 二进制标志 | ✅ 改写(不叠加) |
| FLAC | VORBIS_COMMENT | ✅ vendor + 全部条目 | ✅ 改写(含 padding 复用) |
| FLAC | 元数据块链 | ✅ 遍历(含保留类型块) | ✅ 原样保留 |
| MP4/M4A | ilst | ✅ 全部条目 + 封面 | ✅ 改写(保留 mdat 与其它盒、修正 chunk 偏移) |
| Ogg | Vorbis Comment(Vorbis/Opus) | ✅ vendor + 全部条目 | ✅ 改写(音频页原样保留、PCM 零损失) |
| 输入 | 识别依据 | 标签来源 |
|---|---|---|
| MP3 | ID3 前缀或 MPEG 帧同步 | ID3v2(TIT2/TPE1/…);缺失字段回退到文件尾部 APEv2(Title/Artist/…) |
| FLAC | fLaC 魔数 | VORBIS_COMMENT |
| M4A / MP4 | 第 4..8 字节 ftyp | moov/udta/meta/ilst |
| Ogg Vorbis | OggS 页头魔数 | comment packet(\x03vorbis) |
| Ogg Opus | OggS 页头魔数 | comment packet(OpusTags) |
| 标志 | v2.3 | v2.4 | 本实现 |
|---|---|---|---|
| 压缩 | $80 + 帧头后 4 字节普通大端 u32「解压后尺寸」 | $08 + 必须同时设 DLI $01(4 字节 synchsafe) | 读取时用 zlib(RFC 1950)解压;写回时按未压缩帧写出并清压缩位 |
| 分组 | $20 + 1 字节组号 | $40 + 1 字节组号 | 组号字节读时保留(Frame::group)、写时原样补回 |
| 未同步 | 作用于整个标签体 | $02,作用于帧载荷 | 读取时先还原 unsync 再解压;写回时清位 |
| 数据长度指示 | — | $01,4 字节 synchsafe | 保留该位,值按新的载荷长度重算 |
| 加密 | $40 | $04 | 明确报 Unsupported:加密方法不在规范内定义,需外部注册表(见「明确不做」) |
| 路径 | 做法 | 取舍 |
|---|---|---|
| ① FFI 包装 TagLib 等 C++ 库 | 直接绑定 | ❌ 依赖系统库,wasm-gc 后端不可用;跨语言边界要复制数据结构 |
| ② 移植 GPL/LGPL 参考实现 | 逐行翻译 | ❌ 许可传染(TagLib LGPL/MPL、mutagen GPL);且 C++ 的 RAII 与异常模型无法直译 |
| ③ 纯 MoonBit 按公开规范原创实现 | 读规范 + 第三方对拍 | ✅ 选此路径。依据是 ID3v2.3/2.4 非正式标准(id3.org,许可允许衍生)、RFC 9639(FLAC)、ISO/IEC 14496-12 公开派生文档(mp4ra.org) |
///|
fn example(data : Bytes) -> Unit raise TagError {
let info = read_track_info(data.exact_view())
println("格式: \{info.format.name()}")
match info.title {
Some(t) => println("标题: \{t}")
None => ()
}
println("有封面: \{info.has_cover}")
}///|
fn read_example(data : Bytes) -> Unit raise TagError {
let tag = parse(data.exact_view())
println("版本 \{tag.major}, 帧数 \{tag.frames.length()}")
match tag.text("TIT2") {
Some(title) => println("标题: \{title}")
None => ()
}
match tag.picture() {
Some(pic) => println("封面: \{pic.mime}, \{pic.data.length()} 字节")
None => ()
}
}///|
fn write_example(data : Bytes) -> Bytes raise TagError {
set_fields(
data.exact_view(),
[field("TIT2", "新标题"), field("TPE1", "新艺术家")],
WriteOptions::default(),
)
}///|
fn flac_example(data : Bytes) -> Bytes raise TagError {
let vc = match read_vorbis_comment(data.exact_view()) {
Some(c) => c
None => empty_vorbis_comment("moontag")
}
let vc = vc.set_key("TITLE", "新标题").set_key("ALBUM", "新专辑")
set_vorbis_comment(data.exact_view(), vc)
}///|
fn mp4_example(data : Bytes) -> Bytes raise TagError {
set_mp4_tags(data.exact_view(), [
mp4_text_tag("©nam", "新标题"),
mp4_text_tag("©alb", "新专辑"),
])
}# 统一视图(自动识别 MP3 / FLAC / M4A)
moon run cmd/tag -- show song.mp3
# 原始视图(打印全部帧/条目)
moon run cmd/tag -- dump song.m4a
# 改字段(自动按格式分派;空值表示删除)
moon run cmd/tag -- set song.mp3 "TIT2=新标题" "TPE1=艺术家"
moon run cmd/tag -- set song.flac "TITLE=新标题"
moon run cmd/tag -- set song.m4a "©nam=新标题"
# 封面(ID3v2)
moon run cmd/tag -- cover song.mp3 cover.png # 嵌入
moon run cmd/tag -- apic song.mp3 out.png # 提取
moon run cmd/tag -- apics song.mp3 # 列出全部
# 指定格式的子命令
moon run cmd/tag -- flac show song.flac
moon run cmd/tag -- ape show song.mp3
moon run cmd/tag -- mp4 show song.m4amoon test --target wasm-gc
moon test --target js| 判据 | 变异后变红的用例数 |
|---|---|
| v2.4 帧尺寸 synchsafe 语义 | 1 |
| v2.3 必须用 UTF-16 而非 UTF-8 | 1 |
| APIC 描述终止符随编码变化 | 1 |
| FLAC padding 复用 | 1 |
| APEv2 标签起点(含 footer 32 字节) | 7 |
| MP4 meta 的 FullBox +4 偏移 | 7 |
| MP4 data 盒 type 在 flags 最低字节 | 3 |
| 压缩帧解压(取消 inflate) | 6 |
| v2.4 附加头顺序(组号在 DLI 之前) | 1 |
| v2.3 解压后尺寸按大端 u32 读(非 synchsafe) | 1 |
| 写回时补回分组字节 | 2 |
| 写回时重算 DLI 值 | 1 |
| 写路径吞掉解析错误(产出双标签) | 1 |
| 功能 | 验证工具 | 结果 |
|---|---|---|
| ID3v2 读写 | ffmpeg 7.1 | 读回的 title/artist/album 与预期一致 |
| 音频无损 | ffmpeg -f s16le | 改写前后裸 PCM 的 SHA-256 完全相同 |
| APIC 提取 | ffmpeg 嵌入的封面 | 提取结果与原始 PNG SHA-256 相同 |
| APIC 写入 | ffmpeg -i | 识别为 (attached pic),尺寸正确 |
| FLAC 读写 | metaflac 1.5.0 | --list / --export-tags-to 逐字段一致 |
| FLAC 完整性 | flac -t | ok |
| APEv2 双向 | 独立 Python 解析器 | 互读结果一致 |
| MP4 ilst 读写 | ffmpeg -i | title/album/date 读回一致,音频时长不变 |
| ID3v2 帧级压缩(v2.4) | ffmpeg 7.1 | 双方都读出被压缩的 artist;改写后 PCM 逐字节相同 |
| ID3v2 帧级压缩(v2.3) | ffmpeg 7.1 | ffmpeg 静默丢掉该帧(本库可读)——用于划清能力边界,不是缺陷对照 |
| 依赖 | 许可证 | 用途 |
|---|---|---|
| moonbitlang/x | Apache-2.0 | 文件读写(CLI)、编码工具 |
| Milky2018/moon_yazi | Apache-2.0 | RFC 1950/1951 zlib/DEFLATE,用于解压 ID3v2 压缩帧(该包自述为 yazi 的移植,仓库 moonbit-community/moon_yazi) |
pub suberror TagError {
Truncated(offset~ : Int, need~ : Int, actual~ : Int)
NotTagged(format~ : String)
BadHeader(reason~ : String)
BadFrameHeader(reason~ : String)
FrameOutOfBounds(frame_id~ : String, size~ : Int, available~ : Int)
BadFrameData(frame_id~ : String, reason~ : String)
Unsupported(what~ : String)
UnsupportedVersion(version~ : Int)
}pub enum AudioFormat {
Mp3
Flac
Mp4
Ogg
Unknown
}pub struct FlacBlock {
code : Int
offset : Int
length : Int
is_last : Bool
}pub enum FlacBlockType {
StreamInfo
Padding
Application
SeekTable
VorbisComment
CueSheet
Picture
Reserved
Invalid
}pub struct Frame {
id : String
data : Bytes
status_flags : Int
format_flags : Int
group : Int?
}pub struct Mp4Box {
kind : String
offset : Int
length : Int
start : Int
}pub struct OggCommentLoc {
payload : Bytes
end_offset : Int
serial : Int
is_opus : Bool
}pub struct OggPage {
offset : Int
n_segments : Int
total_len : Int
body_offset : Int
is_bos : Bool
is_eos : Bool
is_continued : Bool
serial : Int
seq : Int
data : Bytes
}pub struct Picture {
mime : String
picture_type : PictureType
raw_type : Int
description : String
data : Bytes
}pub(all) enum PictureType {
Other
FileIcon
OtherIcon
FrontCover
BackCover
LeafletPage
Media
LeadArtist
Artist
Conductor
Band
Composer
Lyricist
RecordingLocation
DuringRecording
DuringPerformance
MovieScreenCapture
BrightColouredFish
Illustration
BandLogotype
PublisherLogotype
}pub enum TextEncoding {
Latin1
Utf16Bom
Utf16Be
Utf8
}pub struct TrackInfo {
format : AudioFormat
title : String?
artist : String?
album : String?
track : String?
date : String?
genre : String?
comment : String?
has_cover : Bool
}fn VorbisComment::set_key(self : VorbisComment, key : String, value : String) -> VorbisComment raise TagErrorpub struct WriteOptions {
padding : Int
default_major : Int
}fn make_page(serial : Int, seq : Int, header_type : Int, granule : Int64, payload : Bytes) -> Bytesfn make_page_multi(serial : Int, seq : Int, header_type : Int, granule : Int64, packets : Array[Bytes]) -> Bytesfn ogg_crc32(data : Bytes) -> UIntfn picture(mime : String, picture_type : PictureType, description : String, data : Bytes) -> Picturefn set_fields(data : BytesView, fields : Array[TagField], options : WriteOptions) -> Bytes raise TagErrorfn set_picture(data : BytesView, picture : Picture?, options : WriteOptions) -> Bytes raise TagErrorfn split_strings(data : BytesView, encoding : TextEncoding, start : Int, end : Int) -> Array[String]fn unsynchronise(data : BytesView) -> BytesInstall
Download zip纯 MoonBit 实现的音频标签元数据读写库(ID3v2 / Vorbis Comment / APEv2 / MP4 ilst),不依赖任何 C 库