mirror of
https://github.com/gesellix/Bose-SoundTouch.git
synced 2026-08-18 16:46:17 +00:00
Add a refresh policy to the fix registry so the UI can avoid the
unnecessary "Loading…" flash when a quick fix does not change any
check state.
- Registry stores fixEntry{fn, refresh} instead of bare FixFunc.
- RegisterFix (existing callers) keeps refresh=true: resolved
findings disappear from the list after the fix runs.
- New RegisterFixNoRefresh sets refresh=false: used for persistent
operator affordances whose success leaves the finding unchanged.
- RunFix now returns (string, bool, error); the bool propagates to
the healthFixResponse JSON as "refresh".
- play_ding registered via RegisterFixNoRefresh — pressing it never
resolves the finding, so no re-fetch is needed.
- runQuickFix in script.js gates setTimeout(fetchHealth, 400) on
data.refresh !== false; absent or true keeps the existing behaviour.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
379 lines
9.8 KiB
Go
379 lines
9.8 KiB
Go
// Package ding renders the AfterTouch "ding" signature sound — a
|
|
// two-chirp tone derived from the braille letters S and T that
|
|
// make up the AfterTouch logo. Used as the Health-tab test
|
|
// playback target: pushed to a speaker as a custom-radio
|
|
// ContentItem so operators can confirm a freshly migrated speaker
|
|
// emits audio without depending on TuneIn or any external service.
|
|
//
|
|
// Mapping:
|
|
//
|
|
// Braille S = ⠎ = dots 2, 3, 4
|
|
// Braille T = ⠞ = dots 2, 3, 4, 5
|
|
//
|
|
// Dot positions in the 6-dot grid:
|
|
// 1 4
|
|
// 2 5
|
|
// 3 6
|
|
//
|
|
// Columns → stereo channels (left=1,2,3 / right=4,5,6).
|
|
// Rows → pitches: top=PitchHigh, mid=PitchMid, bottom=PitchLow.
|
|
//
|
|
// So S (dots 2,3,4) renders as L=PitchMid+PitchLow, R=PitchHigh,
|
|
// and T (dots 2,3,4,5) adds R=PitchMid on top of S.
|
|
//
|
|
// Render(opts) returns a self-contained 16-bit stereo PCM WAV.
|
|
// Default options produce a ~600 ms / 52 KB clip; callers can
|
|
// override any subset and let the rest fall back to defaults
|
|
// (see DefaultOptions).
|
|
package ding
|
|
|
|
import (
|
|
"bytes"
|
|
"encoding/binary"
|
|
"fmt"
|
|
"io"
|
|
"math"
|
|
)
|
|
|
|
// Options controls the synthesis. A zero-valued Options struct
|
|
// is *not* usable directly; the WithDefaults method fills in
|
|
// sensible numbers for unset fields so callers can supply only
|
|
// the parameters they want to override.
|
|
type Options struct {
|
|
SampleRate int // Hz. Default 22050.
|
|
|
|
PitchHigh float64 // Hz, top row (A5=880).
|
|
PitchMid float64 // Hz, middle row (E5=659.2551).
|
|
PitchLow float64 // Hz, bottom row (A4=440).
|
|
|
|
ChirpDuration float64 // seconds per chirp. Default 0.25.
|
|
GapDuration float64 // seconds between chirps. Default 0.10.
|
|
AttackDuration float64 // seconds of fade-in per chirp. Default 0.020.
|
|
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,
|
|
Repeat: 3,
|
|
RepeatGapDuration: 0.40,
|
|
}
|
|
}
|
|
|
|
// WithDefaults returns a copy of o with any zero-valued fields
|
|
// filled in from DefaultOptions. Lets callers write
|
|
//
|
|
// ding.Options{PitchHigh: 1000}.WithDefaults()
|
|
//
|
|
// instead of restating every field.
|
|
func (o Options) WithDefaults() Options {
|
|
d := DefaultOptions()
|
|
|
|
if o.SampleRate <= 0 || o.SampleRate > int(maxSampleRate) {
|
|
o.SampleRate = d.SampleRate
|
|
}
|
|
|
|
if o.PitchHigh <= 0 {
|
|
o.PitchHigh = d.PitchHigh
|
|
}
|
|
|
|
if o.PitchMid <= 0 {
|
|
o.PitchMid = d.PitchMid
|
|
}
|
|
|
|
if o.PitchLow <= 0 {
|
|
o.PitchLow = d.PitchLow
|
|
}
|
|
|
|
if o.ChirpDuration <= 0 {
|
|
o.ChirpDuration = d.ChirpDuration
|
|
}
|
|
|
|
if o.GapDuration <= 0 {
|
|
o.GapDuration = d.GapDuration
|
|
}
|
|
|
|
if o.AttackDuration <= 0 {
|
|
o.AttackDuration = d.AttackDuration
|
|
}
|
|
|
|
if o.ReleaseDuration <= 0 {
|
|
o.ReleaseDuration = d.ReleaseDuration
|
|
}
|
|
|
|
if o.Peak <= 0 || o.Peak > 1.0 {
|
|
o.Peak = d.Peak
|
|
}
|
|
|
|
if o.Repeat <= 0 {
|
|
o.Repeat = d.Repeat
|
|
}
|
|
|
|
if o.RepeatGapDuration <= 0 {
|
|
o.RepeatGapDuration = d.RepeatGapDuration
|
|
}
|
|
|
|
return o
|
|
}
|
|
|
|
// Render synthesises the ding using opts (after defaulting) and
|
|
// returns a self-contained 16-bit PCM WAV file.
|
|
func Render(opts Options) []byte {
|
|
opts = opts.WithDefaults()
|
|
|
|
voicesS := []voice{
|
|
{freq: opts.PitchMid, channel: 0},
|
|
{freq: opts.PitchLow, channel: 0},
|
|
{freq: opts.PitchHigh, channel: 1},
|
|
}
|
|
voicesT := []voice{
|
|
{freq: opts.PitchMid, channel: 0},
|
|
{freq: opts.PitchLow, channel: 0},
|
|
{freq: opts.PitchHigh, channel: 1},
|
|
{freq: opts.PitchMid, channel: 1},
|
|
}
|
|
|
|
chirpN := int(math.Round(float64(opts.SampleRate) * opts.ChirpDuration))
|
|
gapN := int(math.Round(float64(opts.SampleRate) * opts.GapDuration))
|
|
attackN := int(math.Round(float64(opts.SampleRate) * opts.AttackDuration))
|
|
releaseN := int(math.Round(float64(opts.SampleRate) * opts.ReleaseDuration))
|
|
|
|
samplesPerChannel := chirpN*2 + gapN
|
|
left := make([]float64, samplesPerChannel)
|
|
right := make([]float64, samplesPerChannel)
|
|
|
|
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
|
|
|
|
// Defensive bound check: clamp before the conversion to
|
|
// uint32 so even a buggy caller (or one that bypassed the
|
|
// handler-side bound check on the query param) can't trigger
|
|
// integer truncation in the WAV header fields.
|
|
sampleRate32 := safeSampleRate(opts.SampleRate)
|
|
|
|
_ = writeWAV(&buf, left, right, sampleRate32)
|
|
|
|
return buf.Bytes()
|
|
}
|
|
|
|
type voice struct {
|
|
freq float64
|
|
channel int
|
|
}
|
|
|
|
// renderChirp synthesises one chirp into the L/R buffers
|
|
// starting at offset, with a trapezoidal attack/sustain/release
|
|
// envelope.
|
|
func renderChirp(left, right []float64, offset, length, attackN, releaseN int, voices []voice, sampleRate int) {
|
|
if attackN+releaseN > length {
|
|
attackN = length / 3
|
|
releaseN = length / 3
|
|
}
|
|
|
|
for i := 0; i < length; i++ {
|
|
t := float64(i) / float64(sampleRate)
|
|
env := 1.0
|
|
|
|
switch {
|
|
case i < attackN:
|
|
env = float64(i) / float64(attackN)
|
|
case i >= length-releaseN:
|
|
remaining := length - i
|
|
env = float64(remaining) / float64(releaseN)
|
|
}
|
|
|
|
for _, v := range voices {
|
|
sample := math.Sin(2*math.Pi*v.freq*t) * env
|
|
if v.channel == 0 {
|
|
left[offset+i] += sample
|
|
} else {
|
|
right[offset+i] += sample
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// normalise scales L/R so the peak absolute value equals `peak`
|
|
// (≤ 1.0). Keeps the chord sum below clipping without hardcoding
|
|
// voice counts.
|
|
func normalise(left, right []float64, peak float64) {
|
|
maxVal := 0.0
|
|
|
|
for i := range left {
|
|
if v := math.Abs(left[i]); v > maxVal {
|
|
maxVal = v
|
|
}
|
|
|
|
if v := math.Abs(right[i]); v > maxVal {
|
|
maxVal = v
|
|
}
|
|
}
|
|
|
|
if maxVal == 0 {
|
|
return
|
|
}
|
|
|
|
scale := peak / maxVal
|
|
for i := range left {
|
|
left[i] *= scale
|
|
right[i] *= scale
|
|
}
|
|
}
|
|
|
|
const (
|
|
wavChannels = 2
|
|
wavBitsPer = 16
|
|
)
|
|
|
|
// maxSampleRate is the largest sample rate writeWAV will accept
|
|
// before clamping. Generous enough to allow studio-quality 192
|
|
// kHz; well below the uint32 ceiling the WAV header field can
|
|
// represent, and far below anything the byte-rate multiplication
|
|
// downstream could overflow.
|
|
const maxSampleRate uint32 = 192_000
|
|
|
|
// safeSampleRate converts the operator-supplied int sample rate
|
|
// into the uint32 the WAV header needs, clamping anything
|
|
// out-of-range to the default. Defence-in-depth: the
|
|
// handler-side sampleRateParam already rejects unreasonable
|
|
// inputs, but Render is exported so other callers (tests,
|
|
// scripts) could pass anything.
|
|
func safeSampleRate(in int) uint32 {
|
|
if in <= 0 || in > int(maxSampleRate) {
|
|
return uint32(DefaultOptions().SampleRate)
|
|
}
|
|
|
|
return uint32(in)
|
|
}
|
|
|
|
func writeWAV(w io.Writer, left, right []float64, sampleRate uint32) error {
|
|
if len(left) != len(right) {
|
|
return fmt.Errorf("channel length mismatch: %d vs %d", len(left), len(right))
|
|
}
|
|
|
|
samples := len(left)
|
|
dataBytes := samples * wavChannels * (wavBitsPer / 8)
|
|
totalRIFFSize := 4 + (8 + 16) + (8 + dataBytes)
|
|
|
|
if _, err := w.Write([]byte("RIFF")); err != nil {
|
|
return err
|
|
}
|
|
|
|
if err := binary.Write(w, binary.LittleEndian, uint32(totalRIFFSize)); err != nil {
|
|
return err
|
|
}
|
|
|
|
if _, err := w.Write([]byte("WAVE")); err != nil {
|
|
return err
|
|
}
|
|
|
|
if _, err := w.Write([]byte("fmt ")); err != nil {
|
|
return err
|
|
}
|
|
|
|
if err := binary.Write(w, binary.LittleEndian, uint32(16)); err != nil {
|
|
return err
|
|
}
|
|
|
|
if err := binary.Write(w, binary.LittleEndian, uint16(1)); err != nil { // PCM
|
|
return err
|
|
}
|
|
|
|
if err := binary.Write(w, binary.LittleEndian, uint16(wavChannels)); err != nil {
|
|
return err
|
|
}
|
|
|
|
if err := binary.Write(w, binary.LittleEndian, sampleRate); err != nil {
|
|
return err
|
|
}
|
|
|
|
byteRate := sampleRate * uint32(wavChannels) * uint32(wavBitsPer/8)
|
|
if err := binary.Write(w, binary.LittleEndian, byteRate); err != nil {
|
|
return err
|
|
}
|
|
|
|
blockAlign := uint16(wavChannels * (wavBitsPer / 8))
|
|
if err := binary.Write(w, binary.LittleEndian, blockAlign); err != nil {
|
|
return err
|
|
}
|
|
|
|
if err := binary.Write(w, binary.LittleEndian, uint16(wavBitsPer)); err != nil {
|
|
return err
|
|
}
|
|
|
|
if _, err := w.Write([]byte("data")); err != nil {
|
|
return err
|
|
}
|
|
|
|
if err := binary.Write(w, binary.LittleEndian, uint32(dataBytes)); err != nil {
|
|
return err
|
|
}
|
|
|
|
for i := 0; i < samples; i++ {
|
|
if err := binary.Write(w, binary.LittleEndian, floatToInt16(left[i])); err != nil {
|
|
return err
|
|
}
|
|
|
|
if err := binary.Write(w, binary.LittleEndian, floatToInt16(right[i])); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
func floatToInt16(v float64) int16 {
|
|
if v > 1.0 {
|
|
v = 1.0
|
|
} else if v < -1.0 {
|
|
v = -1.0
|
|
}
|
|
|
|
return int16(math.Round(v * 32767))
|
|
}
|