Skip to content

Commit

Permalink
Merge pull request #21 from braheezy/fuzz-testing
Browse files Browse the repository at this point in the history
Add fuzz testing
  • Loading branch information
braheezy authored Mar 16, 2024
2 parents b9fdfe5 + f9e6279 commit 47497b1
Show file tree
Hide file tree
Showing 7 changed files with 157 additions and 5 deletions.
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -6,3 +6,4 @@ dist/
/*.qoa
/*.ogg
/*.flac
/fuzz/*.qoa
5 changes: 5 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -103,6 +103,11 @@ The check uses `cmp` to check each byte in each produced file. For an unknown re
- `check_spec.h` to check a small amount bytes for a small amount of files
- `check_spec.sh -a` to check all 150 songs and record `failures`

## Fuzz Testing
The `qoa` package has a fuzz unit test to examine the `Encode()` and `Decode()` functions.

`fuzz/create_fuzzy_files.py` generates valid QOA files with random data.

---
## Disclaimer
I have never written software that deals with audio files before. I saw a post about QOA on HackerNews and found the name amusing. There were many ports to other languages, but Go was not listed. So here we are!
Expand Down
6 changes: 3 additions & 3 deletions cmd/play.go
Original file line number Diff line number Diff line change
Expand Up @@ -70,7 +70,7 @@ func playQOA(inputFiles []string) {
}

// Decode the QOA audio data
_, qoaAudioData, err := qoa.Decode(qoaBytes)
qoaMetadata, qoaAudioData, err := qoa.Decode(qoaBytes)
if err != nil {
logger.Fatalf("Error decoding QOA data: %v", err)
}
Expand All @@ -87,9 +87,9 @@ func playQOA(inputFiles []string) {
"File",
inputFile,
"SampleRate",
"44100",
qoaMetadata.SampleRate,
"ChannelCount",
"2",
qoaMetadata.Channels,
"BufferedSize",
player.BufferedSize())
player.Play()
Expand Down
68 changes: 68 additions & 0 deletions fuzz/create_fuzzy_files.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
#!/usr/bin/env python

import struct
import random
import os

COUNT=20

def generate_random_history_weights():
return [random.randint(-32768, 32767) for _ in range(4)]

def generate_qoa_slice():
# Generate a slice with random 20 samples of audio data
return os.urandom(8) # 8 bytes for 20 samples, assuming random data is acceptable

def generate_qoa_file(samples, num_channels, samplerate):
file_header = struct.pack(">4sI", b"qoaf", samples)

samples_per_frame = 256 * 20
num_frames = (samples + samples_per_frame - 1) // samples_per_frame

frames = []
for _ in range(num_frames):
frame_samples = min(samples, samples_per_frame)
# Ensure the calculation of frame_size does not exceed 'H' limits
frame_size = 8 + num_channels * (16 + 256 * 8) # Adjusted calculation
if frame_size > 65535:
raise ValueError("Frame size exceeds allowable range for 'H' format.")

# Pack the samplerate as three separate bytes
sr_byte1 = (samplerate >> 16) & 0xFF
sr_byte2 = (samplerate >> 8) & 0xFF
sr_byte3 = samplerate & 0xFF

frame_header = struct.pack(">BBBBHH", num_channels, sr_byte1, sr_byte2, sr_byte3, frame_samples, frame_size)

lms_states = b''
for _ in range(num_channels):
history = generate_random_history_weights()
weights = generate_random_history_weights()
lms_state = struct.pack(">8h", *history, *weights)
lms_states += lms_state

slices = b''.join(generate_qoa_slice() for _ in range(256 * num_channels))

frames.append(frame_header + lms_states + slices)

qoa_content = file_header + b''.join(frames)
return qoa_content



def save_qoa_file(filename, content):
with open(filename, 'wb') as f:
f.write(content)

def main():
for _ in range(COUNT):
samples = random.randint(20, 1000) # Choose a random number of samples
num_channels = random.randint(1, 8) # Choose a random number of channels
samplerate = random.randint(1, 16777215) # Random sample rate within valid range
qoa_content = generate_qoa_file(samples, num_channels, samplerate)
filename = f"fuzz_qoa_{samples}_{num_channels}_{samplerate}.qoa"
save_qoa_file(filename, qoa_content)
print(f"Generated {filename}")

if __name__ == "__main__":
main()
4 changes: 2 additions & 2 deletions pkg/qoa/decode.go
Original file line number Diff line number Diff line change
Expand Up @@ -85,13 +85,13 @@ func (q *QOA) decodeFrame(bytes []byte, size uint, sampleData []int16, frameLen
slice := binary.BigEndian.Uint64(bytes[p:])
p += 8

scaleFactor := (slice >> 60) & 0x0F
scaleFactor := (slice >> 60) & 0xF
sliceStart := sampleIndex*channels + c
sliceEnd := uint32(clamp(int(sampleIndex)+QOASliceLen, 0, int(samples)))*channels + c

for si := sliceStart; si < sliceEnd; si += channels {
predicted := q.LMS[c].predict()
quantized := int((slice >> 57) & 0x07)
quantized := int((slice >> 57) & 0x7)
dequantized := qoaDequantTable[scaleFactor][quantized]
reconstructed := clampS16(predicted + int(dequantized))

Expand Down
3 changes: 3 additions & 0 deletions pkg/qoa/encode.go
Original file line number Diff line number Diff line change
Expand Up @@ -178,6 +178,9 @@ func (q *QOA) Encode(sampleData []int16) ([]byte, error) {
frameLen := uint32(QOAFrameLen)
for sampleIndex := uint32(0); sampleIndex < q.Samples; sampleIndex += frameLen {
frameLen = uint32(clamp(QOAFrameLen, 0, int(q.Samples-sampleIndex)))
if (sampleIndex+frameLen)*q.Channels > uint32(len(sampleData)) {
return nil, errors.New("not enough samples")
}
frameSamples := sampleData[sampleIndex*q.Channels : (sampleIndex+frameLen)*q.Channels]
frameSize := q.encodeFrame(frameSamples, frameLen, bytes[p:])
p += uint32(frameSize)
Expand Down
75 changes: 75 additions & 0 deletions pkg/qoa/qoa_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,10 @@ package qoa

import (
"bytes"
"encoding/binary"
"fmt"
"log"
"math"
"os"
"testing"

Expand Down Expand Up @@ -278,3 +280,76 @@ func TestBasicEncode(t *testing.T) {
assert.Nil(t, err, "Unexpected error")
assert.NotEmpty(t, qoaEncodedData, "Expected QOA encoded data")
}
func FuzzEncodeDecode(f *testing.F) {
// Values to fuzz with, taken from the QOA spec
MIN_CHANNELS := 1
MAX_CHANNELS := 255
MIN_SAMPLE_RATE := 1
MAX_SAMPLE_RATE := 16777215
// 1 channel, minimum slices (assuming at least 1 slice is required)
MIN_SIZE := 8 + (8 + (16 * 1) + (256 * 8 * 1))
// 1 channel, size to just exceed one frame, requiring part of a second frame
SIZE_MULTIPLE_FRAMES := 8 + 2*(8+(16*1)+(256*8*1))
SIZE_MAX_CHANNELS := 8 + (8 + (16 * 255) + (256 * 8 * 255))

f.Add(generateFuzzData(MIN_SIZE, 0x7FFF), uint32(MIN_CHANNELS), uint32(MIN_SAMPLE_RATE))
f.Add(generateFuzzData(SIZE_MULTIPLE_FRAMES, -0x8000), uint32(MIN_CHANNELS), uint32(MIN_SAMPLE_RATE))
f.Add(generateFuzzData(SIZE_MAX_CHANNELS, 0x0000), uint32(MIN_CHANNELS), uint32(MIN_SAMPLE_RATE))

f.Add(generateFuzzData(MIN_SIZE, 0x0000), uint32(MAX_CHANNELS), uint32(MAX_SAMPLE_RATE))
f.Add(generateFuzzData(SIZE_MULTIPLE_FRAMES, -0x8000), uint32(MAX_CHANNELS), uint32(MAX_SAMPLE_RATE))
f.Add(generateFuzzData(SIZE_MAX_CHANNELS, 0x0000), uint32(MAX_CHANNELS), uint32(MIN_SAMPLE_RATE))

f.Fuzz(func(t *testing.T, data []byte, channels uint32, sampleRate uint32) {
if len(data)%2 != 0 {
// Ensure data length is even, as we're converting to int16
return
}

// Convert []byte to []int16 for testing
var originalSamples []int16
for i := 0; i < len(data); i += 2 {
sample := int16(binary.BigEndian.Uint16(data[i : i+2]))
originalSamples = append(originalSamples, sample)
}

// Setup QOA struct with random but valid data
q := QOA{
Channels: channels,
SampleRate: sampleRate,
Samples: uint32(len(originalSamples)) / channels,
}

// Encode the sample data
encodedBytes, err := q.Encode(originalSamples)
if err != nil {
t.Logf("Failed to encode: %v", err)
}

// Decode the encoded bytes
decodedQOA, _, err := Decode(encodedBytes)
if err != nil {
t.Logf("Failed to decode: %v", err)
} else {

psnr := -20.0 * math.Log10(math.Sqrt(float64(q.ErrorCount)/float64(q.Samples*q.Channels))/32768.0)

// Check if decoded data is reasonable
// Is there a better way to check lossy-compressed roundtrip bytes to original?
assert.Greater(t, psnr, 30.0, "PSNR of decoded QOA bytes is bad")

// Additional checks can be added here, such as verifying other fields in the QOA struct
if decodedQOA.Channels != channels || decodedQOA.SampleRate != sampleRate || decodedQOA.Samples != uint32(len(originalSamples))/channels {
t.Errorf("Decoded QOA struct fields do not match original")
}
}
})
}

func generateFuzzData(size int, seedValue int16) []byte {
data := make([]byte, size)
for i := 0; i < size; i += 2 {
binary.BigEndian.PutUint16(data[i:i+2], uint16(seedValue))
}
return data
}

0 comments on commit 47497b1

Please sign in to comment.