From db33f7f22ea6c7c1c75651355aeef8e9e8ef0905 Mon Sep 17 00:00:00 2001 From: Tobias Gesellchen Date: Wed, 27 May 2026 20:22:05 +0200 Subject: [PATCH] =?UTF-8?q?feat(ding):=20repeat=20ding=203=C3=97=20by=20de?= =?UTF-8?q?fault=20to=20survive=20speaker=20startup=20delay?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- pkg/service/ding/ding.go | 56 ++++++++++++++++++++++----- pkg/service/ding/ding_test.go | 19 ++++++--- pkg/service/handlers/handlers_ding.go | 27 +++++++++++++ 3 files changed, 88 insertions(+), 14 deletions(-) diff --git a/pkg/service/ding/ding.go b/pkg/service/ding/ding.go index 7a7dbaf..50697c6 100644 --- a/pkg/service/ding/ding.go +++ b/pkg/service/ding/ding.go @@ -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 diff --git a/pkg/service/ding/ding_test.go b/pkg/service/ding/ding_test.go index 5f60d45..b328845 100644 --- a/pkg/service/ding/ding_test.go +++ b/pkg/service/ding/ding_test.go @@ -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 { diff --git a/pkg/service/handlers/handlers_ding.go b/pkg/service/handlers/handlers_ding.go index 2f1c092..aa0dbba 100644 --- a/pkg/service/handlers/handlers_ding.go +++ b/pkg/service/handlers/handlers_ding.go @@ -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, 1–10). +// 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