Skip to content

Commit ae798ec

Browse files
committed
Implement real wav pcm conversion
replace the temporary wav/pcm byte-copy stub with a real converter that extracts raw pcm from wav data chunks and wraps pcm into a pcm wav container. route wav/pcm requests through the new converter, add preset parsing for pcm->wav output parameters (sr, ch, bits), and harden parsing with bounds checks, fmt validation, and overflow guards. update facade tests to assert re-encode behavior and wav header/stream properties, and refresh readme/changelog to reflect the new route behavior
1 parent d1ff13e commit ae798ec

6 files changed

Lines changed: 506 additions & 18 deletions

File tree

CHANGELOG.md

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,22 @@ 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.1.4] - 2026-03-16
9+
10+
### Changed
11+
- Replaced temporary WAV/PCM stub converter with production path via [`WavPcmConverter`](src/main/java/me/tamkungz/codecmedia/internal/convert/WavPcmConverter.java), including real `wav -> pcm` data-chunk extraction and `pcm -> wav` container wrapping.
12+
- Updated conversion hub wiring in [`DefaultConversionHub`](src/main/java/me/tamkungz/codecmedia/internal/convert/DefaultConversionHub.java) to route WAV/PCM through the renamed real converter.
13+
- Added preset-driven PCM->WAV parameter parsing in [`WavPcmConverter.parsePcmWavParams()`](src/main/java/me/tamkungz/codecmedia/internal/convert/WavPcmConverter.java) supporting `sr=`, `ch=`, and `bits=`.
14+
- Updated facade regression behavior in [`CodecMediaFacadeTest`](src/test/java/me/tamkungz/codecmedia/CodecMediaFacadeTest.java) to assert real re-encode behavior and preset-based output stream properties for WAV/PCM route.
15+
16+
### Fixed
17+
- Added defensive bounds checks for little-endian reads in [`WavPcmConverter.readLeInt()`](src/main/java/me/tamkungz/codecmedia/internal/convert/WavPcmConverter.java) and [`WavPcmConverter.readLeUnsignedShort()`](src/main/java/me/tamkungz/codecmedia/internal/convert/WavPcmConverter.java).
18+
- Added WAV `fmt ` validation before payload extraction in [`WavPcmConverter.extractWavDataChunk()`](src/main/java/me/tamkungz/codecmedia/internal/convert/WavPcmConverter.java), rejecting non-PCM WAV payload extraction.
19+
- Hardened chunk traversal and container construction against arithmetic overflow in [`WavPcmConverter.extractWavDataChunk()`](src/main/java/me/tamkungz/codecmedia/internal/convert/WavPcmConverter.java) and [`WavPcmConverter.wrapPcmAsWav()`](src/main/java/me/tamkungz/codecmedia/internal/convert/WavPcmConverter.java).
20+
21+
### Verified
22+
- Confirmed facade regression coverage with `mvn -Dtest=CodecMediaFacadeTest test`.
23+
824
## [1.1.3] - 2026-03-16
925

1026
### Fixed

README.md

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -43,7 +43,7 @@ CodecMedia is a Java library for media probing, validation, metadata sidecar per
4343
- In-Java extraction and conversion file operations
4444
- Image-to-image conversion in Java for: `png`, `jpg`/`jpeg`, `webp`, `bmp`, `tif`/`tiff`, `heic`/`heif`/`avif`
4545
- Playback API with dry-run support and optional desktop-open backend
46-
- Conversion hub routing with explicit unsupported routes and a stub `wav <-> pcm` path
46+
- Conversion hub routing with explicit unsupported routes and a real `wav <-> pcm` path (`WAV -> PCM` data-chunk extraction, `PCM -> WAV` wrapping)
4747

4848
## API Behavior Summary
4949

@@ -61,8 +61,11 @@ CodecMedia is a Java library for media probing, validation, metadata sidecar per
6161
- Current probing focuses on **technical media info** (mime/type/streams/basic tags).
6262
- Probe routing now performs a lightweight header-prefix sniff before full decode to reduce unnecessary full-file reads for clearly unsupported/unknown inputs.
6363
- `readMetadata` currently uses sidecar metadata persistence; it is **not** a full embedded tag extractor (for example ID3 album art/APIC).
64-
- Audio-to-audio conversion is not implemented yet for real transcode cases (for example `mp3 -> ogg`).
65-
- The only temporary audio conversion path is a stub `wav <-> pcm` route.
64+
- Audio-to-audio conversion is not implemented yet for general real transcode cases (for example `mp3 -> ogg`).
65+
- The currently implemented audio route is `wav <-> pcm`:
66+
- `wav -> pcm`: extracts raw PCM payload from WAV `data` chunk
67+
- `pcm -> wav`: wraps PCM into PCM WAV container
68+
- Optional PCM->WAV preset tuning via `ConversionOptions.preset`, for example: `sr=22050,ch=1,bits=16`
6669
- Container/unknown conversion routes are intentionally unsupported unless explicitly mapped by the conversion route resolver.
6770
- TIFF probe currently reads the **first IFD/image** only (multi-page TIFF traversal is not implemented in probe mode).
6871
- WebP probe currently reports `bitDepth` as an assumed default (`8`) for `VP8`/`VP8L`/`VP8X` unless deeper profile metadata parsing is added.

src/main/java/me/tamkungz/codecmedia/internal/convert/DefaultConversionHub.java

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@
66
public final class DefaultConversionHub implements ConversionHub {
77

88
private final MediaConverter passthroughConverter = new SameFormatCopyConverter();
9-
private final MediaConverter wavPcmStubConverter = new WavPcmStubConverter();
9+
private final MediaConverter wavPcmConverter = new WavPcmConverter();
1010
private final MediaConverter videoToAudioConverter = new UnsupportedRouteConverter(
1111
"video->audio conversion is not implemented yet (planned conversion hub path)"
1212
);
@@ -36,7 +36,7 @@ public ConversionResult convert(ConversionRequest request) throws CodecMediaExce
3636
boolean wavPcmPair = ("wav".equals(request.sourceExtension()) && "pcm".equals(request.targetExtension()))
3737
|| ("pcm".equals(request.sourceExtension()) && "wav".equals(request.targetExtension()));
3838
if (wavPcmPair) {
39-
yield wavPcmStubConverter.convert(request);
39+
yield wavPcmConverter.convert(request);
4040
}
4141
yield audioToAudioTranscodeConverter.convert(request);
4242
}
Lines changed: 238 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,238 @@
1+
package me.tamkungz.codecmedia.internal.convert;
2+
3+
import java.io.IOException;
4+
import java.nio.ByteBuffer;
5+
import java.nio.ByteOrder;
6+
import java.nio.charset.StandardCharsets;
7+
import java.nio.file.Files;
8+
import java.nio.file.Path;
9+
import java.util.Locale;
10+
11+
import me.tamkungz.codecmedia.CodecMediaException;
12+
import me.tamkungz.codecmedia.model.ConversionResult;
13+
14+
/**
15+
* WAV <-> PCM converter.
16+
* <p>
17+
* - WAV -> PCM: extracts raw PCM payload from the WAV {@code data} chunk.
18+
* - PCM -> WAV: wraps raw PCM bytes in a canonical 16-bit LE PCM WAV container
19+
* (44.1kHz, stereo).
20+
*/
21+
public final class WavPcmConverter implements MediaConverter {
22+
23+
private static final int DEFAULT_SAMPLE_RATE = 44_100;
24+
private static final short DEFAULT_CHANNELS = 2;
25+
private static final short DEFAULT_BITS_PER_SAMPLE = 16;
26+
27+
private static final String PRESET_PREFIX_SR = "sr=";
28+
private static final String PRESET_PREFIX_CHANNELS = "ch=";
29+
private static final String PRESET_PREFIX_BITS = "bits=";
30+
31+
@Override
32+
public ConversionResult convert(ConversionRequest request) throws CodecMediaException {
33+
String source = request.sourceExtension();
34+
String target = request.targetExtension();
35+
36+
boolean wavToPcm = "wav".equals(source) && "pcm".equals(target);
37+
boolean pcmToWav = "pcm".equals(source) && "wav".equals(target);
38+
if (!(wavToPcm || pcmToWav)) {
39+
throw new CodecMediaException(
40+
"audio->audio transcoding is not implemented yet (supported pair: wav<->pcm only)"
41+
);
42+
}
43+
44+
Path output = request.output();
45+
try {
46+
Path parent = output.getParent();
47+
if (parent != null) {
48+
Files.createDirectories(parent);
49+
}
50+
if (Files.exists(output) && !request.options().overwrite()) {
51+
throw new CodecMediaException("Output already exists and overwrite is disabled: " + output);
52+
}
53+
54+
if (wavToPcm) {
55+
byte[] wavBytes = Files.readAllBytes(request.input());
56+
byte[] pcmBytes = extractWavDataChunk(wavBytes);
57+
Files.write(output, pcmBytes);
58+
return new ConversionResult(output, request.targetExtension(), true);
59+
}
60+
61+
byte[] pcmBytes = Files.readAllBytes(request.input());
62+
PcmWavParams params = parsePcmWavParams(request.options().preset());
63+
byte[] wavBytes = wrapPcmAsWav(pcmBytes, params);
64+
Files.write(output, wavBytes);
65+
return new ConversionResult(output, request.targetExtension(), true);
66+
} catch (IOException e) {
67+
throw new CodecMediaException("Failed to convert file: " + request.input(), e);
68+
}
69+
}
70+
71+
private static byte[] extractWavDataChunk(byte[] wavBytes) throws CodecMediaException {
72+
if (wavBytes.length < 12) {
73+
throw new CodecMediaException("Invalid WAV: file too small");
74+
}
75+
String riff = new String(wavBytes, 0, 4, StandardCharsets.US_ASCII);
76+
String wave = new String(wavBytes, 8, 4, StandardCharsets.US_ASCII);
77+
if ((!"RIFF".equals(riff) && !"RF64".equals(riff)) || !"WAVE".equals(wave)) {
78+
throw new CodecMediaException("Invalid WAV header");
79+
}
80+
81+
int offset = 12;
82+
boolean sawFmt = false;
83+
while (offset + 8 <= wavBytes.length) {
84+
String chunkId = new String(wavBytes, offset, 4, StandardCharsets.US_ASCII);
85+
int chunkSize = readLeInt(wavBytes, offset + 4);
86+
if (chunkSize < 0) {
87+
throw new CodecMediaException("Unsupported WAV chunk size");
88+
}
89+
int dataStart = offset + 8;
90+
long dataEndLong = (long) dataStart + chunkSize;
91+
if (dataEndLong > wavBytes.length) {
92+
throw new CodecMediaException("WAV chunk exceeds file bounds: " + chunkId);
93+
}
94+
95+
if ("fmt ".equals(chunkId)) {
96+
if (chunkSize < 16) {
97+
throw new CodecMediaException("Invalid WAV fmt chunk");
98+
}
99+
int audioFormat = readLeUnsignedShort(wavBytes, dataStart);
100+
if (audioFormat != 1) {
101+
throw new CodecMediaException("Unsupported WAV format for PCM extraction: " + audioFormat);
102+
}
103+
sawFmt = true;
104+
}
105+
106+
if ("data".equals(chunkId)) {
107+
if (!sawFmt) {
108+
throw new CodecMediaException("Invalid WAV: missing fmt chunk before data");
109+
}
110+
int dataEnd = (int) dataEndLong;
111+
byte[] out = new byte[chunkSize];
112+
System.arraycopy(wavBytes, dataStart, out, 0, chunkSize);
113+
return out;
114+
}
115+
116+
long paddedSize = (chunkSize % 2 == 0) ? chunkSize : (long) chunkSize + 1L;
117+
long nextOffset = (long) dataStart + paddedSize;
118+
if (nextOffset > Integer.MAX_VALUE || nextOffset > wavBytes.length) {
119+
throw new CodecMediaException("WAV chunk offset overflow");
120+
}
121+
offset = (int) nextOffset;
122+
}
123+
124+
throw new CodecMediaException("WAV data chunk not found");
125+
}
126+
127+
private static byte[] wrapPcmAsWav(byte[] pcmBytes, PcmWavParams params) throws CodecMediaException {
128+
int dataSize = pcmBytes.length;
129+
long totalBytes = 44L + dataSize;
130+
if (totalBytes > Integer.MAX_VALUE) {
131+
throw new CodecMediaException("PCM data too large for WAV container");
132+
}
133+
134+
int sampleRate = params.sampleRate();
135+
short channels = params.channels();
136+
short bitsPerSample = params.bitsPerSample();
137+
138+
int bytesPerSample = bitsPerSample / 8;
139+
int byteRate = sampleRate * channels * bytesPerSample;
140+
short blockAlign = (short) (channels * bytesPerSample);
141+
142+
int riffChunkSize = (int) (totalBytes - 8L);
143+
ByteBuffer b = ByteBuffer.allocate((int) totalBytes).order(ByteOrder.LITTLE_ENDIAN);
144+
b.put((byte) 'R').put((byte) 'I').put((byte) 'F').put((byte) 'F');
145+
b.putInt(riffChunkSize);
146+
b.put((byte) 'W').put((byte) 'A').put((byte) 'V').put((byte) 'E');
147+
148+
b.put((byte) 'f').put((byte) 'm').put((byte) 't').put((byte) ' ');
149+
b.putInt(16);
150+
b.putShort((short) 1);
151+
b.putShort(channels);
152+
b.putInt(sampleRate);
153+
b.putInt(byteRate);
154+
b.putShort(blockAlign);
155+
b.putShort(bitsPerSample);
156+
157+
b.put((byte) 'd').put((byte) 'a').put((byte) 't').put((byte) 'a');
158+
b.putInt(dataSize);
159+
b.put(pcmBytes);
160+
return b.array();
161+
}
162+
163+
private static int readLeInt(byte[] bytes, int offset) throws CodecMediaException {
164+
if (offset < 0 || offset + 4 > bytes.length) {
165+
throw new CodecMediaException("Unexpected end of WAV data");
166+
}
167+
return (bytes[offset] & 0xFF)
168+
| ((bytes[offset + 1] & 0xFF) << 8)
169+
| ((bytes[offset + 2] & 0xFF) << 16)
170+
| ((bytes[offset + 3] & 0xFF) << 24);
171+
}
172+
173+
private static int readLeUnsignedShort(byte[] bytes, int offset) throws CodecMediaException {
174+
if (offset < 0 || offset + 2 > bytes.length) {
175+
throw new CodecMediaException("Unexpected end of WAV data");
176+
}
177+
return (bytes[offset] & 0xFF) | ((bytes[offset + 1] & 0xFF) << 8);
178+
}
179+
180+
private static PcmWavParams parsePcmWavParams(String preset) throws CodecMediaException {
181+
int sampleRate = DEFAULT_SAMPLE_RATE;
182+
short channels = DEFAULT_CHANNELS;
183+
short bitsPerSample = DEFAULT_BITS_PER_SAMPLE;
184+
185+
if (preset == null || preset.isBlank() || "balanced".equalsIgnoreCase(preset.trim())) {
186+
return new PcmWavParams(sampleRate, channels, bitsPerSample);
187+
}
188+
189+
String[] tokens = preset.toLowerCase(Locale.ROOT).split(",");
190+
for (String rawToken : tokens) {
191+
String token = rawToken.trim();
192+
if (token.isEmpty()) {
193+
continue;
194+
}
195+
if (token.startsWith(PRESET_PREFIX_SR)) {
196+
sampleRate = parseIntParam(token.substring(PRESET_PREFIX_SR.length()), "sr", 8_000, 384_000);
197+
continue;
198+
}
199+
if (token.startsWith(PRESET_PREFIX_CHANNELS)) {
200+
int parsed = parseIntParam(token.substring(PRESET_PREFIX_CHANNELS.length()), "ch", 1, 8);
201+
channels = (short) parsed;
202+
continue;
203+
}
204+
if (token.startsWith(PRESET_PREFIX_BITS)) {
205+
int parsed = parseBitsPerSample(token.substring(PRESET_PREFIX_BITS.length()));
206+
bitsPerSample = (short) parsed;
207+
continue;
208+
}
209+
throw new CodecMediaException("Unsupported preset token for pcm->wav: " + token);
210+
}
211+
212+
return new PcmWavParams(sampleRate, channels, bitsPerSample);
213+
}
214+
215+
private static int parseIntParam(String value, String name, int min, int max) throws CodecMediaException {
216+
try {
217+
int parsed = Integer.parseInt(value.trim());
218+
if (parsed < min || parsed > max) {
219+
throw new CodecMediaException(name + " out of range: " + parsed + " (" + min + "-" + max + ")");
220+
}
221+
return parsed;
222+
} catch (NumberFormatException e) {
223+
throw new CodecMediaException("Invalid integer for " + name + ": " + value, e);
224+
}
225+
}
226+
227+
private static int parseBitsPerSample(String value) throws CodecMediaException {
228+
int parsed = parseIntParam(value, "bits", 8, 32);
229+
return switch (parsed) {
230+
case 8, 16, 24, 32 -> parsed;
231+
default -> throw new CodecMediaException("Unsupported bits value in preset (allowed: 8,16,24,32)");
232+
};
233+
}
234+
235+
private record PcmWavParams(int sampleRate, short channels, short bitsPerSample) {
236+
}
237+
}
238+

0 commit comments

Comments
 (0)