Files
Tobias GesellchenandClaude Sonnet 4.6 04f7388051 fix(health): skip fetchHealth re-render for non-resolving quick fixes
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>
2026-05-27 21:20:58 +02:00

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))
}