Why a WAV file can’t be bigger than 4 GB — and what everyone does about it
A WAV is the simplest audio file there is: a list of chunks, one of which is raw samples. That simplicity is why it opens instantly at any size, why a timestamp maps to a byte offset by multiplication rather than search, and why you can draw a spectrogram of a twenty-gigabyte recording from a few megabytes of reading. It is also why the format ran out of numbers in 1991 and never quite recovered. Every size field in RIFF is 32 bits wide. This is where that ceiling actually is, the three incompatible things the industry did about it — RF64/BW64, Wave64, and lying — and the JavaScript to read all of them.
RIFF in one screen
A WAV file is a RIFF file, and RIFF is about as much format as you can define in a paragraph. A chunk is a four-character ASCII id, a 32-bit little-endian length, and that many bytes of body; if the length is odd, a single pad byte follows so the next chunk starts on an even offset. The whole file is one chunk, with the id RIFF, whose body begins with a four-character form type — WAVE for audio, AVI for video, WEBP for a picture.
52 49 46 46 24 08 10 00 57 41 56 45 66 6d 74 20 10 00 00 00 01 00 02 00 ...
└─ "RIFF" └ 1,050,148 └ "WAVE" └─ "fmt " └ 16 bytes └ PCM, 2ch
the file bytes form type the format of body …
Inside, only two chunks are load-bearing. fmt says how the samples are encoded — codec tag, channel count, sample rate, byte rate, block alignment, bit depth. data holds the samples themselves, interleaved, one frame after another with nothing between them. Everything else is description a program hung off the side: LIST/INFO tags, a bext broadcast record, cue markers with LIST/adtl labels, a fact sample count, JUNK padding left so a later edit would not have to move the audio, and whatever private chunk your DAW felt like writing.
Walking the list is a loop with no cleverness in it at all:
function* chunks(bytes, at = 12) { // 12: past "RIFF" + size + "WAVE"
while (at + 8 <= bytes.length) {
const id = String.fromCharCode(...bytes.subarray(at, at + 4));
const size = u32le(bytes, at + 4);
yield { id, offset: at, dataOffset: at + 8, size };
at += 8 + size + (size & 1); // + the pad byte, if the size is odd
}
}
data. It reads the length out of the header and adds it. That single line is why a WAV of any size opens in two reads — and it is also the line that breaks at 4 GiB.Where the ceiling actually is
A 32-bit unsigned length tops out at 4,294,967,295 bytes. Both the outer RIFF size and the data size are that field, so a WAV cannot honestly describe more than 4 GiB of anything. Divide that by the byte rate and you get the real limit, which is a duration:
| Format | Bytes per second | Runs out after |
|---|---|---|
| 44.1 kHz, 16-bit, stereo (CD) | 176,400 | 6 h 45 m |
| 48 kHz, 24-bit, stereo | 288,000 | 4 h 8 m |
| 96 kHz, 24-bit, stereo | 576,000 | 2 h 4 m |
| 48 kHz, 24-bit, 8 channels | 1,152,000 | 1 h 2 m |
| 192 kHz, 32-bit float, 32 channels | 24,576,000 | 2 m 55 s |
Three hours is not an exotic recording. It is one concert, one court session, one wildlife morning, one multitrack rehearsal — and the last row of that table is an ordinary day’s work for a location sound recordist. The format has been over its own ceiling in normal use for two decades, which is why three different answers exist and why a WAV reader that trusts the header is a WAV reader that breaks on real files.
Answer 1: RF64 / BW64 — keep the layout, add a chunk
The EBU’s answer (EBU Tech 3306, later folded into ITU-R BS.2088 as BW64) is the tidiest. The file is still a RIFF file, laid out exactly the same way, with two changes: the form id becomes RF64 (or BW64) instead of RIFF, and every 32-bit size that would overflow is written as 0xFFFFFFFF — a flag meaning “look elsewhere.” Elsewhere is a new ds64 chunk, placed immediately after the form type, carrying the real numbers as 64-bit integers.
52 46 36 34 ff ff ff ff 57 41 56 45 64 73 36 34 1c 00 00 00 ...
└─ "RF64" └ 0xFFFFFFFF └ "WAVE" └─ "ds64" └ 28 bytes
ds64 body: riffSize (u64) dataSize (u64) sampleCount (u64) tableLength (u32) ...
The elegance is that a program which does not understand RF64 still parses the container correctly — it finds fmt , it finds data, it just gets a nonsense length. Reading it properly is four lines:
function parseDs64(body) {
return {
riffSize: Number(u64le(body, 0)),
dataSize: Number(u64le(body, 8)),
sampleCount: Number(u64le(body, 16)),
};
}
The catch is that a writer has to know in advance that it might need ds64, because the chunk has to sit at a fixed place near the front. In practice recorders write a JUNK chunk of exactly the right size at the front of every file and overwrite it with a ds64 if the recording turns out to be long. If you have ever wondered why a two-second WAV from a field recorder has a 92-byte hole in it, that is the hole.
Answer 2: Wave64 — a different file that looks similar
Sony’s answer, from Sound Forge, does not try to stay compatible. Wave64 (.w64) keeps RIFF’s shape and widens everything: chunk ids become 16-byte GUIDs, chunk sizes become 64-bit, and — the detail that catches every naive implementation — a Wave64 chunk’s size includes its own 24-byte header, where a RIFF chunk’s does not. Chunks are padded to 8 bytes rather than 2.
The GUIDs are not arbitrary. Each is the familiar four-character id followed by a fixed twelve-byte suffix, so you can still read a chunk id the way you always did:
riff GUID: 72 69 66 66 2e 91 cf 11 a5 d6 28 db 04 c1 00 00 ← "riff" + its own suffix
fmt GUID: 66 6d 74 20 f3 ac d3 11 8c d1 00 c0 4f 8e db 8a ← "fmt " + the standard one
data GUID: 64 61 74 61 f3 ac d3 11 8c d1 00 c0 4f 8e db 8a ← "data" + the standard one
const id = ascii(bytes, at, 4); // same four characters as RIFF
const size = Number(u64le(bytes, at + 16)) - 24; // minus the header it counts
Wave64 is common in long-form recording — conference capture, radio logging, anything that runs for hours unattended — and essentially unknown outside it, which is why so few tools open one.
Answer 3: write a plain WAV and lie
This is the one you will actually meet. An enormous amount of software writes a normal RIFF header, streams audio until somebody stops it, and leaves the size fields in whatever state the crash, the power cut or the design left them. There are three flavours, and a reader has to handle all of them:
0xFFFFFFFF— the writer knew the file would be too big and deliberately marked the length unknown, without going to RF64.- Zero — a streaming writer emitted the header before it knew the length and never seeked back to patch it. Common when audio is piped to a socket, or when the recorder was unplugged.
- Wrapped — the nastiest, because it looks valid. The writer computed the length in 32 bits and it overflowed, so a 5 GB file claims 705 MB. A trusting reader plays the first twelve minutes and reports the rest as garbage.
Every real decoder quietly copes: if the declared size is impossible, the data chunk runs to the end of the file. What separates a good reader from a bad one is not doing that — it is saying so, because a file whose header disagrees with its body is a file the user probably wants to know about.
Reading all three
Which makes the whole 4 GB problem one function. It takes the data chunk header, the ds64 chunk if there was one, and the file length, and returns a size plus the reason it chose that size:
function resolveDataSize(dataChunk, ds64, fileSize) {
const toEof = fileSize - dataChunk.dataOffset;
const declared = dataChunk.declaredSize;
if (ds64 && ds64.dataSize > 0) // RF64/BW64: believe the u64
return { size: Math.min(ds64.dataSize, toEof), source: 'ds64' };
if (declared === 0xffffffff) // "I don't know" — read to EOF
return { size: toEof, source: 'eof', why: 'size field is 0xFFFFFFFF' };
if (declared === 0 && toEof > 0) // streaming writer never patched it
return { size: toEof, source: 'eof', why: 'size field is zero' };
if (declared > toEof) // truncated, or wrapped past 4 GiB
return { size: toEof, source: 'eof', why: 'header claims more bytes than exist' };
// The subtle one: the file is >4 GiB and the size wrapped modulo 2^32, so the
// declared length *fits* — with a whole extra 4 GiB of audio sitting after it.
if (fileSize > 0x100000000 && toEof - declared >= 0x100000000)
return { size: toEof, source: 'eof', why: 'size appears to have wrapped' };
return { size: declared, source: 'header' };
}
That last branch is the one nobody writes until they have been bitten. A 5 GB WAV whose header says 705 MB passes every other check in the function: the length is plausible, it fits inside the file, nothing overruns. The only evidence that it is wrong is that there are another four gibibytes after it and no chunk header where the next chunk should be.
The upside: no index, because none is needed
It is worth being clear about what the format buys with all this. Every other audio container needs a way to answer “where is minute 47?” MP3 has no index at all, so a player either scans the frame chain or guesses from an average bitrate. MP4 keeps a sample table in moov that can run to megabytes and, in a badly-muxed file, sits at the end. Ogg has to binary-search pages by granule position.
PCM needs none of it, because a sample frame is always the same size:
const frameSize = channels * Math.ceil(bitsPerSample / 8); // e.g. 2 * 2 = 4
const byteRate = sampleRate * frameSize; // e.g. 44100 * 4 = 176400
const offsetOf = (t) => dataOffset + Math.floor(t * byteRate / frameSize) * frameSize;
const timeOf = (o) => (o - dataOffset) / byteRate;
Seeking is a multiplication. It is exact — not to the nearest frame, to the nearest sample — it costs nothing, and it does not care how far into the file you are asking about. Reading one second of audio from five hours into a 40 GB recording is a single ranged read of 176,400 bytes at a computed offset.
Drawing a spectrogram of a 20 GB file
Which is what makes OmniViewer’s SPECTRUM tab possible in a browser. A spectrogram is a picture roughly a thousand pixels wide, and each of those columns is one windowed Fourier transform of a couple of thousand samples. The naive implementation streams the whole file to produce them. The arithmetic above means you do not have to: you compute where each column’s window is and read only that.
for (let c = 0; c < cols; c++) {
const frame = startFrame + Math.round(c * (spanFrames - fftSize) / (cols - 1));
const bytes = await file
.slice(dataOffset + frame * frameSize,
dataOffset + (frame + fftSize) * frameSize) // ~8 KB, wherever it is
.arrayBuffer();
columns.push(spectrumColumn(fft, decodeSamples(bytes, fmt), window));
}
Five hundred columns of a 2048-point transform on 16-bit stereo is 500 × 8 KB — four megabytes of reading for the whole picture, and the same four megabytes whether the file is 20 MB or 20 GB. Zooming into a moment re-reads the same amount at a finer spacing. The transform itself is a plain iterative radix-2 Cooley-Tukey, which is about sixty lines and no dependency:
// Bit-reversal permutation, then log2(n) butterfly passes over precomputed twiddles.
for (let size = 2; size <= n; size <<= 1) {
const half = size >>> 1, step = n / size;
for (let i = 0; i < n; i += size) {
for (let j = i, k = 0; j < i + half; j++, k += step) {
const l = j + half;
const tre = re[l] * cos[k] - im[l] * sin[k];
const tim = re[l] * sin[k] + im[l] * cos[k];
re[l] = re[j] - tre; im[l] = im[j] - tim;
re[j] += tre; im[j] += tim;
}
}
}
AnalyserNode only analyses audio that is playing, in real time. A spectrogram of a two-hour recording would take two hours, and a file the browser refuses to decode — 24-bit PCM in Safari, anything A-law — could not be drawn at all. Reading the samples yourself is both faster and more general.Re-tagging without re-encoding
The same layout makes metadata editing trivial in a way it is not for a compressed format. The tags live in a LIST/INFO chunk in front of data; changing them changes the length of a few hundred bytes at the front of the file. So you rebuild that region, fix the outer RIFF size, and concatenate the original file from the data header onwards, untouched:
const header = buildHeaderRegion({ // 'RIFF' + size + 'WAVE' + fmt + … + new LIST/INFO
preChunks, // every chunk that was in front of `data`
tags,
tailLength: file.size - dataHeaderOffset, // so the RIFF size comes out right
});
const out = new Blob([header.bytes, file.slice(dataHeaderOffset)]); // ← no audio is read
No sample is decoded, no encoder runs, and the audio in the output is bit-identical to the audio in the input. It is also instant on a 40 GB file, because Blob concatenation is a reference to a range of the original, not a copy of it. The one thing you must not do is stamp a fresh RIFF size onto a file whose data size you had to guess — that turns an honestly-broken file into a confidently-wrong one, which is why OmniViewer makes those files read-only and says why.
Where the specs live
The original is Microsoft and IBM’s Multimedia Programming Interface and Data Specifications 1.0 (1991), which defines RIFF, the WAVE form and the LIST/INFO tag ids. The 64-bit successor is EBU Tech 3306 (RF64), standardised as ITU-R BS.2088 (BW64). The broadcast metadata chunk is EBU Tech 3285 (Broadcast Wave Format). WAVE_FORMAT_EXTENSIBLE, which is how anything above two channels or 16 bits is supposed to be declared, is in Microsoft’s WAVEFORMATEXTENSIBLE reference. Wave64 has no public standard at all — it is documented by the people who reverse-engineered it.
OmniViewer implements all of the above in a dependency-free reader and writer that never loads a file into memory. Drop your own on the WAV viewer, or open the chunk list and the spectrogram of the sample.