Skip to content

Commit 5c8d824

Browse files
committed
Add aiff/flac and ogg opus support
extend media probing and strict validation to include aif/aiff/aifc and flac, with new parsers/codecs and facade coverage. enhance ogg parsing to recognize opus identification packets alongside vorbis and report codec-specific bitrate behavior. improve probe routing with lightweight header-prefix sniffing before full reads to avoid unnecessary decoding for unsupported inputs. expand mp4 signature detection for m4a-family brands and add tests for m4a, flac, mp3 vbr/mono paths, ogg opus, and wav profiles.
1 parent 4d3402b commit 5c8d824

File tree

18 files changed

+955
-67
lines changed

18 files changed

+955
-67
lines changed

CHANGELOG.md

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,26 @@ All notable changes to this project will be documented in this file.
55
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
66
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
77

8+
## [1.0.3] - 2026-03-05
9+
10+
### Added
11+
- Added OGG Opus identification/probing support alongside Vorbis in [`OggParser`](src/main/java/me/tamkungz/codecmedia/internal/audio/ogg/OggParser.java).
12+
- Added AIFF/AIF/AIFC probing support with new parser/codec in [`AiffParser`](src/main/java/me/tamkungz/codecmedia/internal/audio/aiff/AiffParser.java) and [`AiffCodec`](src/main/java/me/tamkungz/codecmedia/internal/audio/aiff/AiffCodec.java).
13+
- Added FLAC probing support with STREAMINFO parsing in [`FlacParser`](src/main/java/me/tamkungz/codecmedia/internal/audio/flac/FlacParser.java) and [`FlacCodec`](src/main/java/me/tamkungz/codecmedia/internal/audio/flac/FlacCodec.java).
14+
- Added parser test coverage for MP3 VBR and mono channel-mode paths in [`Mp3ParserTest`](src/test/java/me/tamkungz/codecmedia/internal/audio/mp3/Mp3ParserTest.java).
15+
- Added parser test coverage for OGG Opus identification in [`OggParserTest`](src/test/java/me/tamkungz/codecmedia/internal/audio/ogg/OggParserTest.java).
16+
- Added WAV parser synthetic profile tests (mono/stereo, sample-rate, bit-depth combinations) in [`WavParserTest`](src/test/java/me/tamkungz/codecmedia/internal/audio/wav/WavParserTest.java).
17+
- Added parser test coverage for AIFF probing in [`AiffParserTest`](src/test/java/me/tamkungz/codecmedia/internal/audio/aiff/AiffParserTest.java).
18+
- Added facade test coverage for `.m4a` probe/strict-validate flows in [`CodecMediaFacadeTest`](src/test/java/me/tamkungz/codecmedia/CodecMediaFacadeTest.java).
19+
- Added parser/facade test coverage for `.flac` in [`FlacParserTest`](src/test/java/me/tamkungz/codecmedia/internal/audio/flac/FlacParserTest.java) and [`CodecMediaFacadeTest`](src/test/java/me/tamkungz/codecmedia/CodecMediaFacadeTest.java).
20+
21+
### Changed
22+
- Improved probe routing in [`StubCodecMediaEngine`](src/main/java/me/tamkungz/codecmedia/internal/StubCodecMediaEngine.java) to perform lightweight prefix-based type sniffing before full-file decode, reducing unnecessary full reads for unsupported/unknown inputs.
23+
- Extended probe and strict validation routing in [`StubCodecMediaEngine`](src/main/java/me/tamkungz/codecmedia/internal/StubCodecMediaEngine.java) to include AIFF/AIF/AIFC.
24+
- Extended probe and strict validation routing in [`StubCodecMediaEngine`](src/main/java/me/tamkungz/codecmedia/internal/StubCodecMediaEngine.java) to include FLAC.
25+
- Expanded MP4 signature acceptance for M4A family brands in [`Mp4Parser.isLikelyMp4()`](src/main/java/me/tamkungz/codecmedia/internal/video/mp4/Mp4Parser.java).
26+
- Updated feature notes in [`README.md`](README.md) to reflect OGG Vorbis/Opus probing support and prefix-sniff probe behavior.
27+
828
## [1.0.2] - 2026-03-02
929

1030
### Added

README.md

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,8 +13,11 @@ CodecMedia is a Java library for media probing, validation, metadata sidecar per
1313
- Media engine facade via `CodecMedia.createDefault()`
1414
- Probing support for:
1515
- MP3
16-
- OGG/Vorbis
16+
- OGG/Vorbis/Opus
1717
- WAV (RIFF/WAVE)
18+
- AIFF/AIF/AIFC (COMM-based parsing)
19+
- M4A (MP4 audio profile)
20+
- FLAC (STREAMINFO parsing)
1821
- PNG
1922
- JPEG
2023
- WebP
@@ -45,6 +48,7 @@ CodecMedia is a Java library for media probing, validation, metadata sidecar per
4548
## Notes and Limitations
4649

4750
- Current probing focuses on **technical media info** (mime/type/streams/basic tags).
51+
- Probe routing now performs a lightweight header-prefix sniff before full decode to reduce unnecessary full-file reads for clearly unsupported/unknown inputs.
4852
- `readMetadata` currently uses sidecar metadata persistence; it is **not** a full embedded tag extractor (for example ID3 album art/APIC).
4953
- Audio-to-audio conversion is not implemented yet for real transcode cases (for example `mp3 -> ogg`).
5054
- The only temporary audio conversion path is a stub `wav <-> pcm` route.

pom.xml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@
77

88
<groupId>me.tamkungz.codecmedia</groupId>
99
<artifactId>codecmedia</artifactId>
10-
<version>1.0.2</version>
10+
<version>1.0.3</version>
1111
<packaging>jar</packaging>
1212

1313
<name>CodecMedia</name>

src/main/java/me/tamkungz/codecmedia/internal/StubCodecMediaEngine.java

Lines changed: 132 additions & 40 deletions
Large diffs are not rendered by default.
Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
package me.tamkungz.codecmedia.internal.audio.aiff;
2+
3+
import java.io.IOException;
4+
import java.nio.file.Files;
5+
import java.nio.file.Path;
6+
7+
import me.tamkungz.codecmedia.CodecMediaException;
8+
9+
public final class AiffCodec {
10+
11+
private AiffCodec() {
12+
}
13+
14+
public static AiffProbeInfo decode(Path input) throws CodecMediaException {
15+
try {
16+
byte[] bytes = Files.readAllBytes(input);
17+
return decode(bytes, input);
18+
} catch (IOException e) {
19+
throw new CodecMediaException("Failed to decode AIFF: " + input, e);
20+
}
21+
}
22+
23+
public static AiffProbeInfo decode(byte[] bytes, Path sourceRef) throws CodecMediaException {
24+
AiffProbeInfo info = AiffParser.parse(bytes);
25+
validateDecodedProbe(info, sourceRef);
26+
return info;
27+
}
28+
29+
private static void validateDecodedProbe(AiffProbeInfo info, Path input) throws CodecMediaException {
30+
if (info.sampleRate() <= 0 || info.channels() <= 0 || info.bitrateKbps() <= 0) {
31+
throw new CodecMediaException("Decoded AIFF has invalid stream values: " + input);
32+
}
33+
}
34+
}
35+
Lines changed: 132 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,132 @@
1+
package me.tamkungz.codecmedia.internal.audio.aiff;
2+
3+
import me.tamkungz.codecmedia.CodecMediaException;
4+
import me.tamkungz.codecmedia.internal.audio.BitrateMode;
5+
6+
public final class AiffParser {
7+
8+
private AiffParser() {
9+
}
10+
11+
public static AiffProbeInfo parse(byte[] bytes) throws CodecMediaException {
12+
if (!isLikelyAiff(bytes)) {
13+
throw new CodecMediaException("Not an AIFF file");
14+
}
15+
16+
int offset = 12;
17+
Integer channels = null;
18+
Integer bitsPerSample = null;
19+
Integer sampleRate = null;
20+
Long frameCount = null;
21+
22+
while (offset + 8 <= bytes.length) {
23+
String chunkId = readAscii(bytes, offset, 4);
24+
int chunkSize = readBeInt(bytes, offset + 4);
25+
if (chunkSize < 0) {
26+
throw new CodecMediaException("Invalid AIFF chunk size: " + chunkSize);
27+
}
28+
29+
int chunkDataStart = offset + 8;
30+
if (chunkDataStart + chunkSize > bytes.length) {
31+
throw new CodecMediaException("AIFF chunk exceeds file bounds: " + chunkId);
32+
}
33+
34+
if ("COMM".equals(chunkId)) {
35+
if (chunkSize < 18) {
36+
throw new CodecMediaException("AIFF COMM chunk too small");
37+
}
38+
channels = readBeShort(bytes, chunkDataStart);
39+
frameCount = readBeUInt32(bytes, chunkDataStart + 2);
40+
bitsPerSample = readBeShort(bytes, chunkDataStart + 6);
41+
sampleRate = decodeExtended80ToIntHz(bytes, chunkDataStart + 8);
42+
}
43+
44+
int padded = (chunkSize % 2 == 0) ? chunkSize : chunkSize + 1;
45+
offset = chunkDataStart + padded;
46+
}
47+
48+
if (channels == null || bitsPerSample == null || sampleRate == null || frameCount == null) {
49+
throw new CodecMediaException("AIFF missing required COMM chunk fields");
50+
}
51+
if (channels <= 0 || bitsPerSample <= 0 || sampleRate <= 0 || frameCount < 0) {
52+
throw new CodecMediaException("Invalid AIFF format values");
53+
}
54+
55+
long durationMillis = (frameCount * 1000L) / sampleRate;
56+
long byteRate = (long) sampleRate * channels * bitsPerSample / 8L;
57+
int bitrateKbps = (int) ((byteRate * 8L) / 1000L);
58+
59+
return new AiffProbeInfo(durationMillis, bitrateKbps, sampleRate, channels, BitrateMode.CBR);
60+
}
61+
62+
public static boolean isLikelyAiff(byte[] bytes) {
63+
return bytes != null
64+
&& bytes.length >= 12
65+
&& bytes[0] == 'F'
66+
&& bytes[1] == 'O'
67+
&& bytes[2] == 'R'
68+
&& bytes[3] == 'M'
69+
&& bytes[8] == 'A'
70+
&& bytes[9] == 'I'
71+
&& bytes[10] == 'F'
72+
&& (bytes[11] == 'F' || bytes[11] == 'C');
73+
}
74+
75+
private static int decodeExtended80ToIntHz(byte[] bytes, int offset) throws CodecMediaException {
76+
if (offset + 10 > bytes.length) {
77+
throw new CodecMediaException("Unexpected end of AIFF data");
78+
}
79+
80+
int exp = ((bytes[offset] & 0x7F) << 8) | (bytes[offset + 1] & 0xFF);
81+
long mantissa = 0;
82+
for (int i = 0; i < 8; i++) {
83+
mantissa = (mantissa << 8) | (bytes[offset + 2 + i] & 0xFFL);
84+
}
85+
86+
if (exp == 0 || mantissa == 0) {
87+
return 0;
88+
}
89+
90+
int shift = exp - 16383 - 63;
91+
long value;
92+
if (shift >= 0) {
93+
value = mantissa << Math.min(shift, 30);
94+
} else {
95+
value = mantissa >>> Math.min(-shift, 63);
96+
}
97+
98+
if (value <= 0 || value > Integer.MAX_VALUE) {
99+
throw new CodecMediaException("Unsupported AIFF sample rate encoding");
100+
}
101+
return (int) value;
102+
}
103+
104+
private static String readAscii(byte[] bytes, int offset, int len) throws CodecMediaException {
105+
if (offset + len > bytes.length) {
106+
throw new CodecMediaException("Unexpected end of AIFF data");
107+
}
108+
return new String(bytes, offset, len, java.nio.charset.StandardCharsets.US_ASCII);
109+
}
110+
111+
private static int readBeShort(byte[] bytes, int offset) throws CodecMediaException {
112+
if (offset + 2 > bytes.length) {
113+
throw new CodecMediaException("Unexpected end of AIFF data");
114+
}
115+
return ((bytes[offset] & 0xFF) << 8) | (bytes[offset + 1] & 0xFF);
116+
}
117+
118+
private static int readBeInt(byte[] bytes, int offset) throws CodecMediaException {
119+
if (offset + 4 > bytes.length) {
120+
throw new CodecMediaException("Unexpected end of AIFF data");
121+
}
122+
return ((bytes[offset] & 0xFF) << 24)
123+
| ((bytes[offset + 1] & 0xFF) << 16)
124+
| ((bytes[offset + 2] & 0xFF) << 8)
125+
| (bytes[offset + 3] & 0xFF);
126+
}
127+
128+
private static long readBeUInt32(byte[] bytes, int offset) throws CodecMediaException {
129+
return readBeInt(bytes, offset) & 0xFFFFFFFFL;
130+
}
131+
}
132+
Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
package me.tamkungz.codecmedia.internal.audio.aiff;
2+
3+
import me.tamkungz.codecmedia.internal.audio.BitrateMode;
4+
5+
public record AiffProbeInfo(
6+
long durationMillis,
7+
int bitrateKbps,
8+
int sampleRate,
9+
int channels,
10+
BitrateMode bitrateMode
11+
) {
12+
}
13+
Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
package me.tamkungz.codecmedia.internal.audio.flac;
2+
3+
import java.io.IOException;
4+
import java.nio.file.Files;
5+
import java.nio.file.Path;
6+
7+
import me.tamkungz.codecmedia.CodecMediaException;
8+
9+
public final class FlacCodec {
10+
11+
private FlacCodec() {
12+
}
13+
14+
public static FlacProbeInfo decode(Path input) throws CodecMediaException {
15+
try {
16+
byte[] bytes = Files.readAllBytes(input);
17+
return decode(bytes, input);
18+
} catch (IOException e) {
19+
throw new CodecMediaException("Failed to decode FLAC: " + input, e);
20+
}
21+
}
22+
23+
public static FlacProbeInfo decode(byte[] bytes, Path sourceRef) throws CodecMediaException {
24+
FlacProbeInfo info = FlacParser.parse(bytes);
25+
validateDecodedProbe(info, sourceRef);
26+
return info;
27+
}
28+
29+
private static void validateDecodedProbe(FlacProbeInfo info, Path input) throws CodecMediaException {
30+
if (info.sampleRate() <= 0 || info.channels() <= 0 || info.bitsPerSample() <= 0) {
31+
throw new CodecMediaException("Decoded FLAC has invalid stream values: " + input);
32+
}
33+
}
34+
}
35+
Lines changed: 91 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,91 @@
1+
package me.tamkungz.codecmedia.internal.audio.flac;
2+
3+
import me.tamkungz.codecmedia.CodecMediaException;
4+
import me.tamkungz.codecmedia.internal.audio.BitrateMode;
5+
6+
public final class FlacParser {
7+
8+
private FlacParser() {
9+
}
10+
11+
public static FlacProbeInfo parse(byte[] bytes) throws CodecMediaException {
12+
if (!isLikelyFlac(bytes)) {
13+
throw new CodecMediaException("Not a FLAC file");
14+
}
15+
16+
int offset = 4; // skip fLaC marker
17+
boolean streamInfoFound = false;
18+
int sampleRate = 0;
19+
int channels = 0;
20+
int bitsPerSample = 0;
21+
long totalSamples = 0;
22+
23+
while (offset + 4 <= bytes.length) {
24+
int header = bytes[offset] & 0xFF;
25+
boolean last = (header & 0x80) != 0;
26+
int blockType = header & 0x7F;
27+
int length = ((bytes[offset + 1] & 0xFF) << 16)
28+
| ((bytes[offset + 2] & 0xFF) << 8)
29+
| (bytes[offset + 3] & 0xFF);
30+
offset += 4;
31+
32+
if (length < 0 || offset + length > bytes.length) {
33+
throw new CodecMediaException("Invalid FLAC metadata block length");
34+
}
35+
36+
if (blockType == 0) { // STREAMINFO
37+
if (length < 34) {
38+
throw new CodecMediaException("Invalid FLAC STREAMINFO block");
39+
}
40+
long packed = readUInt64BE(bytes, offset + 10);
41+
sampleRate = (int) ((packed >>> 44) & 0xFFFFF);
42+
channels = (int) (((packed >>> 41) & 0x7) + 1);
43+
bitsPerSample = (int) (((packed >>> 36) & 0x1F) + 1);
44+
totalSamples = packed & 0xFFFFFFFFFL;
45+
streamInfoFound = true;
46+
}
47+
48+
offset += length;
49+
if (last) {
50+
break;
51+
}
52+
}
53+
54+
if (!streamInfoFound || sampleRate <= 0 || channels <= 0 || bitsPerSample <= 0) {
55+
throw new CodecMediaException("FLAC STREAMINFO is missing or invalid");
56+
}
57+
58+
long durationMillis = totalSamples > 0 ? (totalSamples * 1000L) / sampleRate : 0;
59+
int avgBitrateKbps = durationMillis > 0
60+
? (int) ((((long) bytes.length * 8L) * 1000L) / durationMillis / 1000L)
61+
: 0;
62+
int pcmEquivalentKbps = (int) (((long) sampleRate * channels * bitsPerSample) / 1000L);
63+
int bitrateKbps = avgBitrateKbps > 0 ? avgBitrateKbps : pcmEquivalentKbps;
64+
65+
return new FlacProbeInfo("flac", sampleRate, channels, bitsPerSample, bitrateKbps, BitrateMode.VBR, durationMillis);
66+
}
67+
68+
public static boolean isLikelyFlac(byte[] bytes) {
69+
return bytes != null
70+
&& bytes.length >= 4
71+
&& bytes[0] == 'f'
72+
&& bytes[1] == 'L'
73+
&& bytes[2] == 'a'
74+
&& bytes[3] == 'C';
75+
}
76+
77+
private static long readUInt64BE(byte[] bytes, int offset) throws CodecMediaException {
78+
if (offset + 8 > bytes.length) {
79+
throw new CodecMediaException("Unexpected end of FLAC data");
80+
}
81+
return ((long) (bytes[offset] & 0xFF) << 56)
82+
| ((long) (bytes[offset + 1] & 0xFF) << 48)
83+
| ((long) (bytes[offset + 2] & 0xFF) << 40)
84+
| ((long) (bytes[offset + 3] & 0xFF) << 32)
85+
| ((long) (bytes[offset + 4] & 0xFF) << 24)
86+
| ((long) (bytes[offset + 5] & 0xFF) << 16)
87+
| ((long) (bytes[offset + 6] & 0xFF) << 8)
88+
| ((long) (bytes[offset + 7] & 0xFF));
89+
}
90+
}
91+
Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
package me.tamkungz.codecmedia.internal.audio.flac;
2+
3+
import me.tamkungz.codecmedia.internal.audio.BitrateMode;
4+
5+
public record FlacProbeInfo(
6+
String codec,
7+
int sampleRate,
8+
int channels,
9+
int bitsPerSample,
10+
int bitrateKbps,
11+
BitrateMode bitrateMode,
12+
long durationMillis
13+
) {
14+
}
15+

0 commit comments

Comments
 (0)