From 3b83b63b8d21e26fca8af0fae3e5299717350b7f Mon Sep 17 00:00:00 2001 From: Igor Sheko Date: Tue, 14 Jul 2026 22:05:22 +0100 Subject: [PATCH] feat: add AVS2 audio (cavs) codec string support Parse and generate cavs codec strings (AVS2 audio): cavs. as one hex byte, e.g. cavs.00 (General Audio Coding), cavs.01 (Lossless Audio Coding). Note: in the ISOBMFF codec-string context cavs is AVS2 audio (verified against paulhiggs/codec-string), distinct from FFmpeg's cavs video decoder. Wired into codecInfoFactory via the cavs prefix. --- README.md | 4 +- src/codec/avs2/avs2-audio-info.spec.ts | 49 ++++++++++++++++++++ src/codec/avs2/avs2-audio-info.ts | 64 ++++++++++++++++++++++++++ src/codec/avs2/enums.ts | 13 ++++++ src/codec/avs2/index.ts | 3 ++ src/index.spec.ts | 9 +++- src/index.ts | 5 ++ 7 files changed, 145 insertions(+), 2 deletions(-) create mode 100644 src/codec/avs2/avs2-audio-info.spec.ts create mode 100644 src/codec/avs2/avs2-audio-info.ts create mode 100644 src/codec/avs2/enums.ts create mode 100644 src/codec/avs2/index.ts diff --git a/README.md b/README.md index 5d7bd8e..7c4cd83 100644 --- a/README.md +++ b/README.md @@ -30,6 +30,7 @@ Useful for reading codec parameters out of a `codecs=` string, checking support | L-HEVC | ✅ | ✅ | | MP4 (mp4a/mp4v — AAC, MP3, MPEG-4/2) | ✅ | ✅ | | AVS3 video/audio (avs3/av3a) | ✅ | ✅ | +| AVS2 audio (cavs) | ✅ | ✅ | | MPEG-H 3D Audio (mha1/mhm1) | ✅ | ✅ | | Parameterless (DTS, VC-1, Opus, FLAC, Vorbis, ALAC, PCM) | ✅ | ✅ | | Uncompressed (uncv/unci) | ✅ | ✅ | @@ -101,7 +102,7 @@ console.log(info.toString()); // 'avc1.640028' - `codecInfoFactory(codecString)` — dispatches by prefix (`vp08`/`vp8`, `vp09`/`vp9`, `av01`, `avc1`/`avc2`/`avc3`/`avc4`, `hev1`/`hvc1`, `vvc1`/`vvi1`, `lvc1`, `apv1`, `evc1`, `lhv1`/`lhe1`, - `mp4a`/`mp4v`, `avs3`, `av3a`, `mha1`/`mha2`/`mhm1`/`mhm2`, `uncv`/`unci`) and returns the matching + `mp4a`/`mp4v`, `avs3`, `av3a`, `cavs`, `mha1`/`mha2`/`mhm1`/`mhm2`, `uncv`/`unci`) and returns the matching info object (`Vp8Info`, `Vp9Info`, `Av1Info`, `H264Info`, `H265Info`, `H266Info`, `LcevcInfo`, `ApvInfo`, `EvcInfo`, `LhevcInfo`, `Mp4Info`, `Avs3VideoInfo`, `Avs3AudioInfo`, `MpeghInfo`, `UncvInfo`). Recognised parameterless 4CCs (DTS, VC-1, Opus, FLAC, Vorbis, ALAC, PCM) return a @@ -128,6 +129,7 @@ console.log(info.toString()); // 'avc1.640028' MPEG-4 Visual, MPEG-2 video/audio and more (`mp4a.40.2`, `mp4a.69`, `mp4v.20.9`, …). - `avs3` — namespace exporting `Avs3VideoInfo` (`avs3..`) and `Avs3AudioInfo` (`av3a.`) for the AVS3 video/audio standard. +- `avs2` — namespace exporting `Avs2AudioInfo` for AVS2 audio (`cavs.`). - `mpegh` — namespace exporting `MpeghInfo` (MPEG-H 3D Audio, `mha1`/`mha2`/`mhm1`/`mhm2` + a `profileLevelId`, e.g. `mhm1.0c`). - `simple` — namespace exporting `SimpleCodecInfo` and the `SIMPLE_CODECS` registry for diff --git a/src/codec/avs2/avs2-audio-info.spec.ts b/src/codec/avs2/avs2-audio-info.spec.ts new file mode 100644 index 0000000..d5c0d23 --- /dev/null +++ b/src/codec/avs2/avs2-audio-info.spec.ts @@ -0,0 +1,49 @@ +import {Avs2AudioInfo} from "./avs2-audio-info"; + +describe('Avs2AudioInfo', () => { + describe('parse / round-trip', () => { + it.each(['cavs.00', 'cavs.01'])('parses and round-trips %s', (str) => { + expect(Avs2AudioInfo.fromString(str).toString()).toBe(str); + }); + + it('decodes the audio codec id', () => { + const info = Avs2AudioInfo.fromString('cavs.01'); + expect(info.fourCC).toBe('cavs'); + expect(info.audioCodecId).toBe(1); + }); + }); + + describe('human-readable', () => { + it.each([ + ['cavs.00', 'General Audio Coding'], + ['cavs.01', 'Lossless Audio Coding'], + ])('%s -> %s', (str, codec) => { + expect(Avs2AudioInfo.fromString(str).toHumanReadable().codec).toBe(codec); + }); + }); + + describe('build', () => { + it('assembles a codec string from parts', () => { + const info = new Avs2AudioInfo(); + info.audioCodecId = 1; + expect(info.toString()).toBe('cavs.01'); + }); + + it('rejects out-of-range bytes', () => { + expect(() => { new Avs2AudioInfo().audioCodecId = 256; }).toThrow('audio_codec_id'); + }); + }); + + describe('invalid input', () => { + it.each(['cavs', 'cavs.00.01', 'cavs.GG'])( + 'rejects malformed string %s', + (str) => { + expect(() => Avs2AudioInfo.fromString(str)).toThrow('Invalid AVS2 audio codec string'); + }, + ); + + it('rejects an unknown 4CC', () => { + expect(() => Avs2AudioInfo.fromString('cav2.00')).toThrow('Unknown codec'); + }); + }); +}); diff --git a/src/codec/avs2/avs2-audio-info.ts b/src/codec/avs2/avs2-audio-info.ts new file mode 100644 index 0000000..514979e --- /dev/null +++ b/src/codec/avs2/avs2-audio-info.ts @@ -0,0 +1,64 @@ +import {CodecInfo} from "../codec-info"; +import {padStart} from "../pad-start"; +import {AVS2_AUDIO_FOUR_CCS, Avs2AudioFourCC, hAudioCodec} from "./enums"; + +function assertByte(value: number, name: string): number { + if (!Number.isInteger(value) || value < 0 || value > 255) { + throw new Error(`${name} must be a byte in the range 0-255`); + } + return value; +} + +// AVS2 audio codec information. Codec string: cavs. (one hex byte). +export class Avs2AudioInfo extends CodecInfo { + codecName = 'cavs'; + + private _fourCC: Avs2AudioFourCC = 'cavs'; + private _audioCodecId = 0; + + get fourCC(): Avs2AudioFourCC { + return this._fourCC; + } + + set fourCC(fourCC: Avs2AudioFourCC) { + if (!AVS2_AUDIO_FOUR_CCS.includes(fourCC)) { + throw new Error(`Invalid AVS2 audio sample entry: ${fourCC}`); + } + this._fourCC = fourCC; + } + + get audioCodecId(): number { + return this._audioCodecId; + } + + set audioCodecId(audioCodecId: number) { + this._audioCodecId = assertByte(audioCodecId, 'audio_codec_id'); + } + + static fromString(codecString: string): Avs2AudioInfo { + const parts = codecString.split('.'); + const fourCC = parts[0] as Avs2AudioFourCC; + if (!AVS2_AUDIO_FOUR_CCS.includes(fourCC)) { + throw new Error('Unknown codec'); + } + if (parts.length !== 2 || !/^[0-9a-fA-F]{1,2}$/.test(parts[1])) { + throw new Error('Invalid AVS2 audio codec string, expected cavs.'); + } + const info = new Avs2AudioInfo(); + info.fourCC = fourCC; + info.audioCodecId = parseInt(parts[1], 16); + return info; + } + + toString(): string { + return `${this._fourCC}.${padStart(this._audioCodecId.toString(16), 2, '0')}`; + } + + toHumanReadable() { + return { + fourCC: this._fourCC, + codec: hAudioCodec(this._audioCodecId), + audioCodecId: this._audioCodecId, + } as const; + } +} diff --git a/src/codec/avs2/enums.ts b/src/codec/avs2/enums.ts new file mode 100644 index 0000000..51df39b --- /dev/null +++ b/src/codec/avs2/enums.ts @@ -0,0 +1,13 @@ +// AVS2 audio (China AVS working group). Codec string: cavs. (one hex byte). +// e.g. cavs.00 (General Audio Coding), cavs.01 (Lossless Audio Coding). + +export type Avs2AudioFourCC = 'cavs'; +export const AVS2_AUDIO_FOUR_CCS: readonly Avs2AudioFourCC[] = ['cavs']; + +export function hAudioCodec(audioCodecId: number): string { + switch (audioCodecId) { + case 0x00: return 'General Audio Coding'; + case 0x01: return 'Lossless Audio Coding'; + default: return 'unknown'; + } +} diff --git a/src/codec/avs2/index.ts b/src/codec/avs2/index.ts new file mode 100644 index 0000000..9d9aa71 --- /dev/null +++ b/src/codec/avs2/index.ts @@ -0,0 +1,3 @@ +export {AVS2_AUDIO_FOUR_CCS, hAudioCodec} from './enums'; +export type {Avs2AudioFourCC} from './enums'; +export * from './avs2-audio-info'; diff --git a/src/index.spec.ts b/src/index.spec.ts index 4918547..7d47bdc 100644 --- a/src/index.spec.ts +++ b/src/index.spec.ts @@ -1,4 +1,4 @@ -import {codecInfoFactory, version, vpx, av1, h264, h265, h266, lcevc, apv, evc, lhevc, mp4, avs3, mpegh, simple, uncv} from './index'; +import {codecInfoFactory, version, vpx, av1, h264, h265, h266, lcevc, apv, evc, lhevc, mp4, avs3, mpegh, simple, uncv, avs2} from './index'; describe('codecInfoFactory', () => { it('dispatches vp8 strings to Vp8Info', () => { @@ -117,6 +117,13 @@ describe('codecInfoFactory', () => { expect(codecInfoFactory('uncv')).toBeInstanceOf(uncv.UncvInfo); }); + it('dispatches cavs strings to Avs2AudioInfo', () => { + const info = codecInfoFactory('cavs.01'); + expect(info).toBeInstanceOf(avs2.Avs2AudioInfo); + expect(info.codecName).toBe('cavs'); + expect((info as avs2.Avs2AudioInfo).audioCodecId).toBe(1); + }); + it('throws on an unknown codec', () => { expect(() => codecInfoFactory('theora')).toThrow('Unknown codec'); expect(() => codecInfoFactory('tx3g')).toThrow('Unknown codec'); diff --git a/src/index.ts b/src/index.ts index b15b909..4c652dd 100644 --- a/src/index.ts +++ b/src/index.ts @@ -12,6 +12,7 @@ import {Avs3VideoInfo, Avs3AudioInfo} from "./codec/avs3"; import {MpeghInfo} from "./codec/mpegh"; import {SimpleCodecInfo, isSimpleCodec} from "./codec/simple"; import {UncvInfo} from "./codec/uncv"; +import {Avs2AudioInfo} from "./codec/avs2"; export * as vpx from "./codec/vpx"; export * as av1 from "./codec/av1"; @@ -27,6 +28,7 @@ export * as avs3 from "./codec/avs3"; export * as mpegh from "./codec/mpegh"; export * as simple from "./codec/simple"; export * as uncv from "./codec/uncv"; +export * as avs2 from "./codec/avs2"; export * from './codec/codec-info'; export const version = '__lib_version__'; // Version will be injected on the build @@ -68,6 +70,9 @@ export const codecInfoFactory = (codecString: string) => { if (codecString.startsWith('av3a')) { return Avs3AudioInfo.fromString(codecString); } + if (codecString.startsWith('cavs')) { + return Avs2AudioInfo.fromString(codecString); + } if (codecString.startsWith('mha') || codecString.startsWith('mhm')) { return MpeghInfo.fromString(codecString); }