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>
This commit is contained in:
Tobias Gesellchen
2026-05-27 21:20:58 +02:00
co-authored by Claude Sonnet 4.6
parent 118e3fc4a0
commit db33f7f22e
3 changed files with 88 additions and 14 deletions
+47 -9
View File
@@ -52,21 +52,33 @@ type Options struct {
ReleaseDuration float64 // seconds of fade-out per chirp. Default 0.060.
Peak float64 // final-mix headroom; 0 < Peak <= 1.0. Default 0.85.
// Repeat is the total number of times the complete ding is played.
// Speakers need a moment to start buffering after receiving a
// ContentItem, so the first repetition may be missed; later ones
// will be heard. Default 3.
Repeat int
// RepeatGapDuration is the silence inserted between successive
// repetitions, in seconds. Default 0.40.
RepeatGapDuration float64
}
// DefaultOptions returns the canonical option set used by the
// runtime handler when no overrides are supplied.
func DefaultOptions() Options {
return Options{
SampleRate: 22050,
PitchHigh: 880.00,
PitchMid: 659.2551,
PitchLow: 440.00,
ChirpDuration: 0.25,
GapDuration: 0.10,
AttackDuration: 0.020,
ReleaseDuration: 0.060,
Peak: 0.85,
SampleRate: 22050,
PitchHigh: 880.00,
PitchMid: 659.2551,
PitchLow: 440.00,
ChirpDuration: 0.25,
GapDuration: 0.10,
AttackDuration: 0.020,
ReleaseDuration: 0.060,
Peak: 0.85,
Repeat: 3,
RepeatGapDuration: 0.40,
}
}
@@ -115,6 +127,14 @@ func (o Options) WithDefaults() Options {
o.Peak = d.Peak
}
if o.Repeat <= 0 {
o.Repeat = d.Repeat
}
if o.RepeatGapDuration <= 0 {
o.RepeatGapDuration = d.RepeatGapDuration
}
return o
}
@@ -147,6 +167,24 @@ func Render(opts Options) []byte {
renderChirp(left, right, 0, chirpN, attackN, releaseN, voicesS, opts.SampleRate)
renderChirp(left, right, chirpN+gapN, chirpN, attackN, releaseN, voicesT, opts.SampleRate)
// Repeat: append silence + a copy of the base audio for each
// additional repetition. Speakers need a moment to start buffering
// after receiving a ContentItem; repeating ensures at least one
// instance is audible even if the first is missed.
if opts.Repeat > 1 {
repeatGapN := int(math.Round(float64(opts.SampleRate) * opts.RepeatGapDuration))
baseLeft := append([]float64{}, left...)
baseRight := append([]float64{}, right...)
silence := make([]float64, repeatGapN)
for i := 1; i < opts.Repeat; i++ {
left = append(left, silence...)
right = append(right, silence...)
left = append(left, baseLeft...)
right = append(right, baseRight...)
}
}
normalise(left, right, opts.Peak)
var buf bytes.Buffer
+14 -5
View File
@@ -45,13 +45,13 @@ func TestRender_ProducesWAVHeader(t *testing.T) {
}
}
func TestRender_DefaultSizeApproximately52KB(t *testing.T) {
func TestRender_DefaultSizeApproximately229KB(t *testing.T) {
data := Render(DefaultOptions())
// Default: 22050 Hz * 2 channels * 2 bytes * 0.6 s = 52920 data
// + ~44 byte header.
const wantData = 22050 * 2 * 2 * 60 / 100 // 0.6 seconds, integer math
if got := len(data); got < wantData || got > wantData+200 {
// 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)
}
}
@@ -112,6 +112,15 @@ func TestRender_HugeSampleRateDoesNotTruncateOrPanic(t *testing.T) {
}
}
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 {
+27
View File
@@ -35,6 +35,8 @@ var dingDefaultCache struct {
// release-ms int milliseconds; default 60
// sample-rate Hz, int; default 22050
// peak 0..1 float; default 0.85
// repeat int 1..10; default 3
// repeat-gap-ms int milliseconds; default 400
//
// The default option set is rendered once via sync.Once and the
// resulting bytes are reused across subsequent default requests —
@@ -124,6 +126,16 @@ func parseDingOptions(r *http.Request) (ding.Options, bool) {
touched = true
}
if v, ok := repeatParam(q.Get("repeat")); ok {
opts.Repeat = v
touched = true
}
if v, ok := millisecondsParam(q.Get("repeat-gap-ms")); ok {
opts.RepeatGapDuration = v
touched = true
}
if !touched {
return ding.DefaultOptions(), true
}
@@ -167,6 +179,21 @@ const (
dingMaxSampleRate = 192000
)
// repeatParam parses the "repeat" query knob (integer, 110).
// Values outside that range silently fall back to the default.
func repeatParam(raw string) (int, bool) {
if raw == "" {
return 0, false
}
v, err := strconv.Atoi(raw)
if err != nil || v < 1 || v > 10 {
return 0, false
}
return v, true
}
func sampleRateParam(raw string) (int, bool) {
if raw == "" {
return 0, false