Skip to content

Commit 2bcc05f

Browse files
Merge pull request #525 from spotify/cjacoby/fix-wav-data-length
io: Bound WAV lengths to physically available audio
2 parents 8b3cd65 + ba3e59c commit 2bcc05f

3 files changed

Lines changed: 741 additions & 5 deletions

File tree

‎docs/source/reference/pedalboard.io.rst‎

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,18 @@ a regular file in Python::
3535
:class:`pedalboard.io.WriteableAudioFile`, or :class:`pedalboard.io.ResampledReadableAudioFile` directly,
3636
you *probably don't need to do that* - :class:`pedalboard.io.AudioFile` has you covered.
3737

38+
.. note::
39+
For finite, seekable WAV and RF64 files containing supported PCM or 32-bit
40+
floating-point audio, overstated lengths are capped at the complete frames
41+
physically available when the file is opened, when the audio layout can be
42+
identified. Reported duration, reads, and resampling use this recovered length.
43+
44+
Only overstated audio lengths are corrected. Shorter declarations and the
45+
handling of other malformed file contents follow the existing decoder.
46+
Physically present trailing metadata may still be decoded as audio when an
47+
oversized data chunk includes it. Recovery does not cover every WAV codec or
48+
streams of unknown length, and it does not add support for growing files.
49+
3850
The following documentation lists all of the available I/O classes.
3951

4052

‎pedalboard/juce_overrides/juce_PatchedWavAudioFormat.h‎

Lines changed: 156 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -226,11 +226,45 @@ class JUCE_API PatchedWavAudioFormat : public WavAudioFormat {
226226
bool deleteStreamIfOpeningFails) override {
227227
auto streamStartPos = sourceStream->getPosition();
228228

229-
// Helper to delegate to the parent WavAudioFormat implementation
230-
auto useDefaultReader = [&]() {
231-
sourceStream->setPosition(streamStartPos);
232-
return WavAudioFormat::createReaderFor(sourceStream,
233-
deleteStreamIfOpeningFails);
229+
auto useDefaultReader = [&]() -> AudioFormatReader * {
230+
if (!sourceStream->setPosition(streamStartPos)) {
231+
if (deleteStreamIfOpeningFails)
232+
delete sourceStream;
233+
return nullptr;
234+
}
235+
const auto layout = scanWavLayout(*sourceStream, streamStartPos);
236+
// Let JUCE establish the final stream state, including native fallback
237+
// behavior for file-like objects whose seeks do not always succeed.
238+
if (!sourceStream->setPosition(streamStartPos)) {
239+
if (deleteStreamIfOpeningFails)
240+
delete sourceStream;
241+
return nullptr;
242+
}
243+
std::unique_ptr<AudioFormatReader> reader(WavAudioFormat::createReaderFor(
244+
sourceStream, deleteStreamIfOpeningFails));
245+
// The default factory can delegate Ogg-in-WAV to a compressed reader.
246+
// Only JUCE's native WAV reader has the byte stride described below.
247+
if (reader && reader->getFormatName() == getFormatName()) {
248+
// JUCE validates extensible GUIDs and normalizes legacy bit depths.
249+
// Use that validated depth for float32 eligibility, preserving the
250+
// existing float64 and unsupported-float dispatch paths.
251+
const bool canCapLength =
252+
layout.format == (uint16)WavFormatTag::PCM ||
253+
(layout.format == (uint16)WavFormatTag::IEEEFloat &&
254+
reader->bitsPerSample == 32) ||
255+
(layout.format == (uint16)WavFormatTag::Extensible &&
256+
(!reader->usesFloatingPointData || reader->bitsPerSample == 32));
257+
if (canCapLength && layout.dataOffset >= 0 &&
258+
layout.bytesPerFrame > 0) {
259+
const auto available =
260+
std::max<int64>(0, layout.totalLength - layout.dataOffset);
261+
reader->lengthInSamples = std::min(reader->lengthInSamples,
262+
available / layout.bytesPerFrame);
263+
}
264+
}
265+
// Null readers may already have deleted the stream. Callback errors stay
266+
// pending for ReadableAudioFile's existing Python exception propagation.
267+
return reader.release();
234268
};
235269

236270
// Read the RIFF header
@@ -311,6 +345,123 @@ class JUCE_API PatchedWavAudioFormat : public WavAudioFormat {
311345
}
312346

313347
private:
348+
struct WavDataLayout {
349+
int64 totalLength = -1;
350+
int64 dataOffset = -1;
351+
int64 bytesPerFrame = 0;
352+
unsigned short format = 0;
353+
unsigned int bitsPerSample = 0;
354+
};
355+
356+
// This is a best-effort lookup, not a second format validator. An unfamiliar
357+
// or incomplete layout leaves JUCE's result unchanged rather than rejecting
358+
// a file that the original reader could open.
359+
static WavDataLayout scanWavLayout(InputStream &stream, int64 start) {
360+
static_assert(JUCE_VERSION == 0x060104,
361+
"Review WAV traversal and decoder stride when updating JUCE");
362+
WavDataLayout layout;
363+
layout.totalLength = stream.getTotalLength();
364+
if (layout.totalLength < 0 || start < 0 || !stream.setPosition(start))
365+
return {};
366+
367+
uint8 header[12];
368+
if (stream.read(header, sizeof(header)) != sizeof(header) ||
369+
(int)ByteOrder::littleEndianInt(header + 8) != chunkName("WAVE"))
370+
return {};
371+
const auto container = (int)ByteOrder::littleEndianInt(header);
372+
const bool isRF64 = container == chunkName("RF64");
373+
if (!isRF64 && container != chunkName("RIFF"))
374+
return {};
375+
376+
uint64 riffSize = ByteOrder::littleEndianInt(header + 4);
377+
int64 rf64DataSize = 0;
378+
if (isRF64) {
379+
uint8 ds64[24];
380+
if (stream.read(ds64, sizeof(ds64)) != sizeof(ds64) ||
381+
(int)ByteOrder::littleEndianInt(ds64) != chunkName("ds64"))
382+
return {};
383+
const auto size = ByteOrder::littleEndianInt(ds64 + 4);
384+
riffSize = ByteOrder::littleEndianInt64(ds64 + 8);
385+
const auto dataSize = ByteOrder::littleEndianInt64(ds64 + 16);
386+
if (size < 28 || dataSize > (uint64)std::numeric_limits<int64>::max())
387+
return {};
388+
rf64DataSize = (int64)dataSize;
389+
const int64 remaining = (int64)size + (size & 1) - 16;
390+
if (remaining > layout.totalLength - stream.getPosition() ||
391+
!stream.setPosition(stream.getPosition() + remaining))
392+
return {};
393+
}
394+
if (start > std::numeric_limits<int64>::max() - 8 ||
395+
riffSize > (uint64)(std::numeric_limits<int64>::max() - start - 8))
396+
return {};
397+
const auto scanEnd =
398+
std::min(layout.totalLength, start + 8 + (int64)riffSize);
399+
400+
while (stream.getPosition() < scanEnd) {
401+
const auto chunkStart = stream.getPosition();
402+
uint8 chunk[8];
403+
// JUCE tests only the chunk start against RIFF end. The header itself
404+
// can cross an understated container boundary if physically present.
405+
if (stream.read(chunk, sizeof(chunk)) != sizeof(chunk))
406+
return {};
407+
const auto type = (int)ByteOrder::littleEndianInt(chunk);
408+
const auto length = ByteOrder::littleEndianInt(chunk + 4);
409+
const auto payload = stream.getPosition();
410+
int64 payloadLength = length;
411+
if (payload <= chunkStart)
412+
return {};
413+
414+
if (type == chunkName("fmt ")) {
415+
uint8 fmt[16];
416+
// JUCE reads these fields even when the declared fmt size is short.
417+
// Follow that layout without imposing stricter header validation.
418+
if (stream.read(fmt, sizeof(fmt)) != sizeof(fmt))
419+
return {};
420+
layout.format = ByteOrder::littleEndianShort(fmt);
421+
const auto channels =
422+
(unsigned int)(short)ByteOrder::littleEndianShort(fmt + 2);
423+
const auto sampleRate = (int32)ByteOrder::littleEndianInt(fmt + 4);
424+
const auto byteRate = (int32)ByteOrder::littleEndianInt(fmt + 8);
425+
layout.bitsPerSample =
426+
(unsigned int)(int)(short)ByteOrder::littleEndianShort(fmt + 14);
427+
// Mirror JUCE 6.1.4, including legacy bit-depth normalization. The
428+
// blockAlign field does not determine the decoder's byte stride.
429+
layout.bytesPerFrame = layout.bitsPerSample > 64 && sampleRate != 0
430+
? (int64)byteRate / sampleRate
431+
: (int)(channels * layout.bitsPerSample / 8);
432+
} else if (type == chunkName("data")) {
433+
// JUCE selects the last data chunk it visits, using its current format.
434+
// Retain the final decoding stride even if another fmt follows data.
435+
layout.dataOffset = payload;
436+
if (isRF64 && rf64DataSize > 0)
437+
payloadLength = rf64DataSize;
438+
} else if (length == 0) {
439+
// JUCE stops at zero-sized unknown chunks, but continues past metadata
440+
// it recognises. Match that distinction before selecting a later data.
441+
const int metadata[] = {
442+
chunkName("bext"), chunkName("smpl"), chunkName("inst"),
443+
chunkName("INST"), chunkName("cue "), chunkName("axml"),
444+
chunkName("LIST"), chunkName("acid"), chunkName("Trkn")};
445+
if (std::find(std::begin(metadata), std::end(metadata), type) ==
446+
std::end(metadata))
447+
break;
448+
}
449+
450+
// A short final payload (including metadata or a missing pad byte) is
451+
// accepted or rejected by JUCE, not by this lookup. Oversized audio uses
452+
// the physical tail below; an understated RIFF end never expands length.
453+
if (payloadLength > scanEnd - payload)
454+
break;
455+
const auto end = payload + payloadLength;
456+
if ((payloadLength & 1) != 0 && end == scanEnd)
457+
break;
458+
const auto next = end + (payloadLength & 1);
459+
if (!stream.setPosition(next) || stream.getPosition() != next)
460+
return {};
461+
}
462+
return layout;
463+
}
464+
314465
/**
315466
* Creates an MP3 reader for a WAV file containing MP3-compressed audio data.
316467
* Finds the data chunk and wraps it in a SubregionStream for the MP3 decoder.

0 commit comments

Comments
 (0)