mirror of
https://github.com/gesellix/Bose-SoundTouch.git
synced 2026-08-18 16:46:17 +00:00
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:
co-authored by
Claude Sonnet 4.6
parent
118e3fc4a0
commit
db33f7f22e
@@ -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
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user