Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 19 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,25 @@ because it turns other people's test suites red.

### Breaking

- **WAV files asking for 24 or 32 bit audio have slightly different bytes.** A
steady tone repeats, so it is now worked out once for a single cycle and read
back for the rest of the file instead of being calculated again for every
sample.

Reading it back is not quite identical to calculating it - the two answers
differ far below the step between one sample value and the next. At the
default 16 bit depth that difference never reaches the file, and 16 bit WAVs
are byte for byte what they were. At 24 and 32 bits it does reach the file, in
roughly one sample in twenty thousand.

Nothing audible changes, and neither the size nor the structure of the file
changes. If you record hashes of 24 or 32 bit WAVs, they will not match.

This is what makes producing a WAV cheaper. Measured on a 256 MB file, runs
interleaved: **2.0 times less processor time and 1.7 times less wall clock.**
`silence`, `noise` and `sweep` are untouched - a sweep never repeats, so there
is nothing to read back.

- **ZIP, TAR.GZ and WAV files have different bytes.** The padding these three
formats write is now drawn from the random generator eight bytes at a time
instead of one byte at a time. For a ZIP or a TAR.GZ that padding is almost
Expand Down
127 changes: 127 additions & 0 deletions internal/format/wav/signal.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,127 @@
package wav

import (
// D11 promises the same bytes from the same seed, so a deliberate,
// reproducible generator is the product rather than a weakness. Nothing
// here ever makes a secret.
// nosemgrep: go.lang.security.audit.crypto.math_random.math-random-used
"math"
"math/rand/v2"

"github.com/donislawdev/TestingFilesGenerator/internal/core"
)

// What the sound IS, kept apart from how it reaches the disk.
//
// The split is by job rather than by size. wav.go works out how many frames fit
// in the requested number of bytes and writes them out. This file answers the
// one question that sits underneath: what is the value of frame n. Those change
// for different reasons - a new kind of content touches only this file, and a
// change to the padding chunk touches only the other one.

// sampler yields the value of one frame, in the range -1 to 1.
type sampler struct {
content string
base float64
rate int
rng *rand.Rand

// tone holds one full cycle of the steady tone, empty for everything else.
tone []float64
at int
}

// newSampler prepares whatever the chosen content needs before the first frame.
func newSampler(m memo) *sampler {
s := &sampler{
content: m.content,
// A fixed pitch, nudged by the seed so two seeds sound different.
base: 220.0 + float64(m.seed%880),
rate: m.rate,
rng: core.NewRand(m.seed),
}
// The condition mirrors the switch in next rather than naming the tone, so
// a fifth kind of content added one day lands on the same branch in both
// places instead of reading past the end of an empty table.
switch m.content {
case "silence", "noise", "sweep":
default:
s.tone = tonePeriod(m.rate, s.base, m.frames)
}
return s
}

// next returns the value of one frame and moves the sampler on.
func (s *sampler) next(frame int64) float64 {
switch s.content {
case "silence":
return 0
case "noise":
return s.rng.Float64()*2 - 1
case "sweep":
// A sweep changes pitch as it goes, so it never repeats and there is
// nothing to read back.
t := float64(frame) / float64(s.rate)
return math.Sin(2 * math.Pi * (s.base + s.base*t) * t)
default:
v := s.tone[s.at]
if s.at++; s.at == len(s.tone) {
s.at = 0
}
return v
}
}

// tonePeriod works out one full cycle of the steady tone, so the rest of the
// file can read it back instead of asking for the same sine again.
//
// Measured 2026-09-06 on a 256 MB file: the sine was 542 ms of a 1495 ms run,
// and every call after the first cycle was recomputing a number the file
// already held. Reading it back instead costs 2.0 times less processor time
// and 1.7 times less wall clock, ranges disjoint on both.
//
// The tone at frame f is sin(2*pi*base*f/rate). Adding P to f moves that angle
// by 2*pi*base*P/rate, which is a whole number of turns exactly when
// P = rate/gcd(base, rate) - and base is a whole number of hertz, so that
// divides cleanly. The seed picks a pitch between 220 and 1099 Hz, which at the
// default rate makes the cycle anything from 42 frames to 44100.
//
// The table is never longer than the file needs, so a one kilobyte WAV does not
// reserve a table for sound it will never make. That clamp is about memory
// only. It is NOT what keeps short files identical - most of them wrap anyway,
// since 374 of the 880 pitches have a cycle shorter than an eight thousand
// frame file.
//
// D11: reading the table back is not bit for bit what recomputing gives, and
// the reason is rounding rather than mathematics. float64(frame)/float64(rate)
// and float64(frame % period)/float64(rate) are different arguments, and
// math.Sin reduces a large one differently. So the tone is now periodic by
// definition rather than periodic to within the last bit of a mantissa.
//
// Whether that reaches the file depends on the bit depth, because the gap is
// far smaller than the step between two neighbouring sample values. Measured
// 2026-09-06 rather than argued: at sixteen bits NOTHING moved across forty
// combinations of seed, size, rate and channel count, and at 24 and 32 bits 25
// of 30 combinations did. That is a measurement and not a proof - a sample
// sitting exactly on a rounding boundary would flip at any depth.
func tonePeriod(rate int, base float64, frames int64) []float64 {
period := int64(rate) / gcd(int64(base), int64(rate))
if frames < period {
period = frames
}
table := make([]float64, period)
for i := range table {
table[i] = math.Sin(2 * math.Pi * base * (float64(i) / float64(rate)))
}
return table
}

func gcd(a, b int64) int64 {
for b != 0 {
a, b = b, a%b
}
if a < 0 {
return -a
}
return a
}
18 changes: 2 additions & 16 deletions internal/format/wav/wav.go
Original file line number Diff line number Diff line change
Expand Up @@ -365,11 +365,8 @@ func (m memo) writeSamples(ctx context.Context, w io.Writer) error {
return nil
}
bytesPerSample := m.bits / 8
rng := core.NewRand(m.seed)
buf := make([]byte, 0, writeChunk+16)

// A fixed pitch, nudged by the seed so two seeds sound different.
base := 220.0 + float64(m.seed%880)
signal := newSampler(m)

var written int64
for frame := int64(0); frame < m.frames; frame++ {
Expand All @@ -381,18 +378,7 @@ func (m memo) writeSamples(ctx context.Context, w io.Writer) error {
}
}

t := float64(frame) / float64(m.rate)
var v float64
switch m.content {
case "silence":
v = 0
case "noise":
v = rng.Float64()*2 - 1
case "sweep":
v = math.Sin(2 * math.Pi * (base + base*t) * t)
default:
v = math.Sin(2 * math.Pi * base * t)
}
v := signal.next(frame)

for c := 0; c < m.channels; c++ {
buf = appendSample(buf, v, bytesPerSample)
Expand Down
24 changes: 24 additions & 0 deletions internal/guard/generatorbytes_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -202,6 +202,30 @@ func goldenCases() map[string]engine.Target {
// constant moved 33 of the 54 cases and not one of them was a WAV.
"wav_with_the_padding_chunk": {ID: "g", Format: "wav", Sizes: engine.Uniform(1, 102400), Label: true},

// Both sides of the tone table of 2026-09-06, which works one cycle of
// the sine out and then reads it back rather than asking again.
//
// Reading it back is not bit for bit what recomputing gives, but at
// sixteen bits the difference lands far below the step between one
// sample value and the next, so the file comes out identical. This case
// is here to keep it that way, at a length where the tone has wrapped
// many times over. Measured, not proved: forty combinations of seed,
// size, rate and channel count, none of which moved a byte.
"wav_past_one_cycle": {ID: "g", Format: "wav", Sizes: engine.Uniform(1, 1048576), Label: true},

// At thirty two bits the step between sample values is small enough to
// show the difference, so this file DID change and is the only pinned
// witness to it. No golden case set bit_depth at all before this one.
//
// The size is two megabytes because smaller ones do not witness
// anything, and the first version of this case picked one of those.
// Measured at the pinned seed, where the cycle is 14700 frames:
// 512 KiB differs in 0 bytes, 1 MiB in 38, 2 MiB in 250, 4 MiB in 1048.
// A case sitting at 512 KiB would have been green whatever the tone
// table did, and would have looked like coverage.
"wav_32bit_past_one_cycle": {ID: "g", Format: "wav", Sizes: engine.Uniform(1, 2097152), Label: true,
Properties: map[string]string{"bit_depth": "32"}},

"zip_16kib": {ID: "g", Format: "zip", Sizes: engine.Uniform(1, 16384), Label: true},
"md_8kib": {ID: "g", Format: "md", Sizes: engine.Uniform(1, 8192), Label: true},
"log_8kib": {ID: "g", Format: "log", Sizes: engine.Uniform(1, 8192), Label: true},
Expand Down
18 changes: 18 additions & 0 deletions internal/guard/testdata/generator-golden.json
Original file line number Diff line number Diff line change
Expand Up @@ -204,6 +204,16 @@
"sha256": "55f453ce6cc46a6d856d62825327169683283b22b98b0a4e06effe3a3aaafffe",
"measured_on": "2026-09-06"
},
"wav_past_one_cycle": {
"bytes": 1048576,
"sha256": "fff46cab5eb2db05221acf54d1e8e6e5c65c2323be3268e0e296eff7a16e6ecc",
"measured_on": "2026-09-06"
},
"wav_32bit_past_one_cycle": {
"bytes": 2097152,
"sha256": "2e637d6ef2e3229ed66b39f2258944738c4e7552ee00f42058268672d93b7f4a",
"measured_on": "2026-09-06"
},
"webp_64kib": {
"bytes": 65536,
"sha256": "de9034bd6315f0a51fd8fe1b99adfa5719597f7c3d7cdaba1d3b8624754bf89e",
Expand Down Expand Up @@ -334,6 +344,14 @@
"zip_past_the_comment_limit",
"zip_with_three_pdfs"
]
},
{
"on": "2026-09-06",
"why": "The steady tone is worked out for one cycle and then read back instead of being recomputed, which is 2.03x less CPU and 1.70x less wall clock on a 256 MB file, ranges disjoint on both. Reading the table back is not bit for bit what recomputing gives, because float64(frame)/float64(rate) rounds differently from float64(frame%period)/float64(rate) - but the gap is far below the step between sample values at sixteen bits. Measured: at the default depth NOTHING moves (40 combinations of seed, size, rate and channels), and at 24 and 32 bits it does (25 of 30 combinations). Both new cases exist because no case pinned either side: everything else is shorter than one cycle, and no golden case set bit_depth at all. wav_past_one_cycle pins that the default depth stays as it was and is NOT a repin. wav_32bit_past_one_cycle is the only witness to the change, at two megabytes because 512 KiB differs in zero bytes and would have looked like coverage.",
"files": [
"wav_32bit_past_one_cycle",
"wav_past_one_cycle"
]
}
]
}
Loading