Files
Tobias GesellchenandClaude Sonnet 4.6 db33f7f22e feat(ding): repeat ding 3× by default to survive speaker startup delay
Speakers need a moment to start buffering after receiving a ContentItem;
the first ~2 s of audio is often missed. Repeating the ding 3 times with
0.4 s gaps between each ensures at least one repetition is audible.

- Add Repeat (default 3) and RepeatGapDuration (default 0.40 s) to Options
- Render() appends silence + base audio for each extra repetition
- WithDefaults() fills zero values for the new fields
- Handler exposes ?repeat= (1–10) and ?repeat-gap-ms= query knobs
- Update TestRender_DefaultSizeApproximately52KB → ~229 KB (2.6 s)
- Add TestRender_RepeatProducesLongerAudio

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-27 21:20:58 +02:00

164 lines
4.7 KiB
Go

package ding
import (
"bytes"
"encoding/binary"
"testing"
)
func TestRender_ProducesWAVHeader(t *testing.T) {
data := Render(DefaultOptions())
if len(data) < 44 {
t.Fatalf("expected at least 44 bytes (WAV header), got %d", len(data))
}
if !bytes.HasPrefix(data, []byte("RIFF")) {
t.Errorf("expected RIFF prefix")
}
if !bytes.Equal(data[8:12], []byte("WAVE")) {
t.Errorf("expected WAVE format marker")
}
if !bytes.Equal(data[12:16], []byte("fmt ")) {
t.Errorf("expected fmt chunk")
}
// PCM format
if pcm := binary.LittleEndian.Uint16(data[20:22]); pcm != 1 {
t.Errorf("expected PCM (1), got %d", pcm)
}
// Channels
if ch := binary.LittleEndian.Uint16(data[22:24]); ch != 2 {
t.Errorf("expected stereo (2), got %d", ch)
}
// Sample rate
if sr := binary.LittleEndian.Uint32(data[24:28]); sr != 22050 {
t.Errorf("expected default sample rate 22050, got %d", sr)
}
// Bits per sample
if bps := binary.LittleEndian.Uint16(data[34:36]); bps != 16 {
t.Errorf("expected 16 bits/sample, got %d", bps)
}
}
func TestRender_DefaultSizeApproximately229KB(t *testing.T) {
data := Render(DefaultOptions())
// Default: 3 repetitions of 0.6 s + 2 gaps of 0.4 s = 2.6 s total.
// 22050 Hz * 2 ch * 2 bytes * 2.6 s ≈ 229320 data bytes + 44 byte header.
const wantData = 22050 * 2 * 2 * 260 / 100 // 2.6 seconds, integer math
if got := len(data); got < wantData || got > wantData+500 {
t.Errorf("expected ~%d bytes, got %d", wantData, got)
}
}
func TestRender_OverridePitchAffectsContent(t *testing.T) {
a := Render(DefaultOptions())
b := Render(Options{PitchHigh: 1200}.WithDefaults())
if bytes.Equal(a, b) {
t.Errorf("expected different content for different PitchHigh values")
}
// Same length (envelope doesn't change).
if len(a) != len(b) {
t.Errorf("expected same byte length: %d vs %d", len(a), len(b))
}
}
func TestRender_OverrideSampleRateChangesByteRate(t *testing.T) {
data := Render(Options{SampleRate: 44100}.WithDefaults())
if sr := binary.LittleEndian.Uint32(data[24:28]); sr != 44100 {
t.Errorf("expected 44100, got %d", sr)
}
}
func TestSafeSampleRate_ClampsOutOfRange(t *testing.T) {
cases := []struct {
in int
want uint32
}{
{22050, 22050},
{44100, 44100},
{192000, 192000},
{0, 22050}, // zero → default
{-1, 22050}, // negative → default
{200000, 22050}, // above max → default
{1 << 31, 22050}, // way beyond uint32 → default (the original CodeQL concern)
{1 << 33, 22050}, // wraps to a different value on int→uint32; default protects us
{int(maxSampleRate) + 1, 22050},
}
for _, c := range cases {
if got := safeSampleRate(c.in); got != c.want {
t.Errorf("safeSampleRate(%d) = %d, want %d", c.in, got, c.want)
}
}
}
func TestRender_HugeSampleRateDoesNotTruncateOrPanic(t *testing.T) {
// Regression for the int→uint32 truncation CodeQL flagged.
// A caller (test, future SDK user) bypassing the handler's
// sampleRateParam guard with an int well above uint32 used
// to silently wrap. The defensive clamp now substitutes the
// default sample rate instead.
data := Render(Options{SampleRate: 1 << 33}.WithDefaults())
if sr := binary.LittleEndian.Uint32(data[24:28]); sr != uint32(DefaultOptions().SampleRate) {
t.Errorf("expected clamp to default sample rate, got %d", sr)
}
}
func TestRender_RepeatProducesLongerAudio(t *testing.T) {
once := Render(Options{Repeat: 1}.WithDefaults())
thrice := Render(Options{Repeat: 3}.WithDefaults())
if len(thrice) <= len(once) {
t.Errorf("expected Repeat:3 to produce more bytes than Repeat:1: %d vs %d", len(thrice), len(once))
}
}
func TestWithDefaults_FillsZeroFields(t *testing.T) {
got := Options{PitchHigh: 1000}.WithDefaults()
if got.PitchHigh != 1000 {
t.Errorf("override should be preserved, got %f", got.PitchHigh)
}
if got.SampleRate != 22050 {
t.Errorf("expected default SampleRate, got %d", got.SampleRate)
}
if got.PitchMid <= 0 {
t.Errorf("expected PitchMid filled from default, got %f", got.PitchMid)
}
}
func TestWithDefaults_ClampsInvalidPeak(t *testing.T) {
got := Options{Peak: 2.0}.WithDefaults()
if got.Peak != DefaultOptions().Peak {
t.Errorf("expected default Peak for invalid input, got %f", got.Peak)
}
got = Options{Peak: -0.5}.WithDefaults()
if got.Peak != DefaultOptions().Peak {
t.Errorf("expected default Peak for negative input, got %f", got.Peak)
}
}
func TestRender_SamplesDontClip(t *testing.T) {
data := Render(DefaultOptions())
// Walk every sample, ensure no value is at the 16-bit extreme
// (which would indicate clipping). Header is 44 bytes.
for i := 44; i+1 < len(data); i += 2 {
s := int16(binary.LittleEndian.Uint16(data[i : i+2]))
if s == 32767 || s == -32768 {
t.Errorf("sample at offset %d hit clipping (%d)", i, s)
return
}
}
}