mirror of
https://github.com/gesellix/Bose-SoundTouch.git
synced 2026-08-18 08:36:13 +00:00
refactor(ding): synthesise on demand instead of vendoring the WAV
Move the ding renderer into pkg/service/ding so it can run both
at request time (from the new HandleDing handler) and offline
(from the existing scripts/gen-aftertouch-ding CLI, now a thin
wrapper around the same package).
- GET /media/aftertouch-ding.wav synthesises on first call,
caches the default-options bytes via sync.Once, and accepts
query-string overrides for every knob (pitch-{high,mid,low},
chirp-ms, gap-ms, attack-ms, release-ms, sample-rate, peak).
Invalid / out-of-range values silently fall back to defaults.
- Embedded WAV is gone from VCS — no 52 KB binary in the
repo, and tweaking the sound is now a query-param away rather
than a regenerate-and-commit cycle.
- Health-tab playback_test check is unchanged: the URL it
references (/media/aftertouch-ding.wav) keeps the same shape,
the handler just produces the bytes dynamically now.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.7
parent
6356ca588b
commit
147a69d1c3
@@ -933,6 +933,7 @@ func setupRouter(server *handlers.Server, stockholmHandler *stockholm.Handler) *
|
||||
server.HandleWeb()(w, r)
|
||||
})
|
||||
|
||||
r.Get("/media/aftertouch-ding.wav", server.HandleDing)
|
||||
r.Get("/media/*", server.HandleMedia())
|
||||
r.Get("/bmx-icons/*", server.HandleBmxIcons())
|
||||
r.Get("/ced/*", server.HandleCedStatic())
|
||||
|
||||
@@ -41,6 +41,7 @@ GET /docs/* handlers.(
|
||||
GET /favicon.ico setupRouter
|
||||
GET /health handlers.(*Server).HandleHealth-fm
|
||||
GET /media/* handlers.(*Server).HandleMedia
|
||||
GET /media/aftertouch-ding.wav handlers.(*Server).HandleDing-fm
|
||||
GET /mgmt/accounts/ handlers.(*Server).HandleMgmtListAccounts-fm
|
||||
GET /mgmt/accounts/{accountId} handlers.(*Server).HandleMgmtAccountDetails-fm
|
||||
GET /mgmt/accounts/{accountId}/speakers handlers.(*Server).HandleMgmtListSpeakers-fm
|
||||
|
||||
@@ -0,0 +1,312 @@
|
||||
// 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.
|
||||
}
|
||||
|
||||
// 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,
|
||||
}
|
||||
}
|
||||
|
||||
// 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 = 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
|
||||
}
|
||||
|
||||
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)
|
||||
|
||||
normalise(left, right, opts.Peak)
|
||||
|
||||
var buf bytes.Buffer
|
||||
|
||||
_ = writeWAV(&buf, left, right, opts.SampleRate)
|
||||
|
||||
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
|
||||
)
|
||||
|
||||
func writeWAV(w io.Writer, left, right []float64, sampleRate int) 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, uint32(sampleRate)); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
byteRate := uint32(sampleRate * wavChannels * (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))
|
||||
}
|
||||
@@ -0,0 +1,119 @@
|
||||
package ding
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/binary"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestRender_ProducesWAVHeader(t *testing.T) {
|
||||
data := Render(DefaultOptions())
|
||||
if len(data) < 44 {
|
||||
t.Fatalf("expected at least 44 bytes (WAV header), got %d", len(data))
|
||||
}
|
||||
|
||||
if !bytes.HasPrefix(data, []byte("RIFF")) {
|
||||
t.Errorf("expected RIFF prefix")
|
||||
}
|
||||
|
||||
if !bytes.Equal(data[8:12], []byte("WAVE")) {
|
||||
t.Errorf("expected WAVE format marker")
|
||||
}
|
||||
|
||||
if !bytes.Equal(data[12:16], []byte("fmt ")) {
|
||||
t.Errorf("expected fmt chunk")
|
||||
}
|
||||
|
||||
// PCM format
|
||||
if pcm := binary.LittleEndian.Uint16(data[20:22]); pcm != 1 {
|
||||
t.Errorf("expected PCM (1), got %d", pcm)
|
||||
}
|
||||
|
||||
// Channels
|
||||
if ch := binary.LittleEndian.Uint16(data[22:24]); ch != 2 {
|
||||
t.Errorf("expected stereo (2), got %d", ch)
|
||||
}
|
||||
|
||||
// Sample rate
|
||||
if sr := binary.LittleEndian.Uint32(data[24:28]); sr != 22050 {
|
||||
t.Errorf("expected default sample rate 22050, got %d", sr)
|
||||
}
|
||||
|
||||
// Bits per sample
|
||||
if bps := binary.LittleEndian.Uint16(data[34:36]); bps != 16 {
|
||||
t.Errorf("expected 16 bits/sample, got %d", bps)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRender_DefaultSizeApproximately52KB(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 {
|
||||
t.Errorf("expected ~%d bytes, got %d", wantData, got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRender_OverridePitchAffectsContent(t *testing.T) {
|
||||
a := Render(DefaultOptions())
|
||||
b := Render(Options{PitchHigh: 1200}.WithDefaults())
|
||||
|
||||
if bytes.Equal(a, b) {
|
||||
t.Errorf("expected different content for different PitchHigh values")
|
||||
}
|
||||
|
||||
// Same length (envelope doesn't change).
|
||||
if len(a) != len(b) {
|
||||
t.Errorf("expected same byte length: %d vs %d", len(a), len(b))
|
||||
}
|
||||
}
|
||||
|
||||
func TestRender_OverrideSampleRateChangesByteRate(t *testing.T) {
|
||||
data := Render(Options{SampleRate: 44100}.WithDefaults())
|
||||
if sr := binary.LittleEndian.Uint32(data[24:28]); sr != 44100 {
|
||||
t.Errorf("expected 44100, got %d", sr)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWithDefaults_FillsZeroFields(t *testing.T) {
|
||||
got := Options{PitchHigh: 1000}.WithDefaults()
|
||||
if got.PitchHigh != 1000 {
|
||||
t.Errorf("override should be preserved, got %f", got.PitchHigh)
|
||||
}
|
||||
|
||||
if got.SampleRate != 22050 {
|
||||
t.Errorf("expected default SampleRate, got %d", got.SampleRate)
|
||||
}
|
||||
|
||||
if got.PitchMid <= 0 {
|
||||
t.Errorf("expected PitchMid filled from default, got %f", got.PitchMid)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWithDefaults_ClampsInvalidPeak(t *testing.T) {
|
||||
got := Options{Peak: 2.0}.WithDefaults()
|
||||
if got.Peak != DefaultOptions().Peak {
|
||||
t.Errorf("expected default Peak for invalid input, got %f", got.Peak)
|
||||
}
|
||||
|
||||
got = Options{Peak: -0.5}.WithDefaults()
|
||||
if got.Peak != DefaultOptions().Peak {
|
||||
t.Errorf("expected default Peak for negative input, got %f", got.Peak)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRender_SamplesDontClip(t *testing.T) {
|
||||
data := Render(DefaultOptions())
|
||||
|
||||
// Walk every sample, ensure no value is at the 16-bit extreme
|
||||
// (which would indicate clipping). Header is 44 bytes.
|
||||
for i := 44; i+1 < len(data); i += 2 {
|
||||
s := int16(binary.LittleEndian.Uint16(data[i : i+2]))
|
||||
if s == 32767 || s == -32768 {
|
||||
t.Errorf("sample at offset %d hit clipping (%d)", i, s)
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,152 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"strconv"
|
||||
"sync"
|
||||
|
||||
"github.com/gesellix/bose-soundtouch/pkg/service/ding"
|
||||
)
|
||||
|
||||
// dingDefaultCache holds the rendered bytes for the default
|
||||
// option set. Computed once on first request; subsequent default
|
||||
// requests are served from cache without re-synthesising.
|
||||
var dingDefaultCache struct {
|
||||
once sync.Once
|
||||
data []byte
|
||||
}
|
||||
|
||||
// HandleDing serves the AfterTouch "ding" signature audio.
|
||||
// Defaults are used when no query parameters are supplied;
|
||||
// callers can override any of the rendering knobs:
|
||||
//
|
||||
// pitch-high, pitch-mid, pitch-low (Hz, float)
|
||||
// chirp-ms, gap-ms, attack-ms, release-ms (milliseconds, int)
|
||||
// sample-rate (Hz, int)
|
||||
// peak (0..1, float)
|
||||
//
|
||||
// Unrecognised parameters and out-of-range values fall back to
|
||||
// defaults silently — this is a "play around with it" endpoint,
|
||||
// not a strict API.
|
||||
func (s *Server) HandleDing(w http.ResponseWriter, r *http.Request) {
|
||||
opts, isDefault := parseDingOptions(r)
|
||||
|
||||
var data []byte
|
||||
|
||||
if isDefault {
|
||||
dingDefaultCache.once.Do(func() {
|
||||
dingDefaultCache.data = ding.Render(ding.DefaultOptions())
|
||||
})
|
||||
data = dingDefaultCache.data
|
||||
} else {
|
||||
data = ding.Render(opts)
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "audio/wav")
|
||||
w.Header().Set("Content-Length", strconv.Itoa(len(data)))
|
||||
w.Header().Set("Cache-Control", "public, max-age=300")
|
||||
_, _ = w.Write(data)
|
||||
}
|
||||
|
||||
// parseDingOptions reads the supported query knobs and returns
|
||||
// the resulting Options. isDefault is true when no overrides
|
||||
// were supplied — lets the caller serve from cache.
|
||||
func parseDingOptions(r *http.Request) (ding.Options, bool) {
|
||||
q := r.URL.Query()
|
||||
if len(q) == 0 {
|
||||
return ding.DefaultOptions(), true
|
||||
}
|
||||
|
||||
opts := ding.Options{}
|
||||
touched := false
|
||||
|
||||
if v, ok := floatParam(q.Get("pitch-high")); ok {
|
||||
opts.PitchHigh = v
|
||||
touched = true
|
||||
}
|
||||
|
||||
if v, ok := floatParam(q.Get("pitch-mid")); ok {
|
||||
opts.PitchMid = v
|
||||
touched = true
|
||||
}
|
||||
|
||||
if v, ok := floatParam(q.Get("pitch-low")); ok {
|
||||
opts.PitchLow = v
|
||||
touched = true
|
||||
}
|
||||
|
||||
if v, ok := millisecondsParam(q.Get("chirp-ms")); ok {
|
||||
opts.ChirpDuration = v
|
||||
touched = true
|
||||
}
|
||||
|
||||
if v, ok := millisecondsParam(q.Get("gap-ms")); ok {
|
||||
opts.GapDuration = v
|
||||
touched = true
|
||||
}
|
||||
|
||||
if v, ok := millisecondsParam(q.Get("attack-ms")); ok {
|
||||
opts.AttackDuration = v
|
||||
touched = true
|
||||
}
|
||||
|
||||
if v, ok := millisecondsParam(q.Get("release-ms")); ok {
|
||||
opts.ReleaseDuration = v
|
||||
touched = true
|
||||
}
|
||||
|
||||
if v, ok := intParam(q.Get("sample-rate")); ok {
|
||||
opts.SampleRate = v
|
||||
touched = true
|
||||
}
|
||||
|
||||
if v, ok := floatParam(q.Get("peak")); ok {
|
||||
opts.Peak = v
|
||||
touched = true
|
||||
}
|
||||
|
||||
if !touched {
|
||||
return ding.DefaultOptions(), true
|
||||
}
|
||||
|
||||
return opts.WithDefaults(), false
|
||||
}
|
||||
|
||||
func floatParam(raw string) (float64, bool) {
|
||||
if raw == "" {
|
||||
return 0, false
|
||||
}
|
||||
|
||||
v, err := strconv.ParseFloat(raw, 64)
|
||||
if err != nil || v <= 0 {
|
||||
return 0, false
|
||||
}
|
||||
|
||||
return v, true
|
||||
}
|
||||
|
||||
func millisecondsParam(raw string) (float64, bool) {
|
||||
if raw == "" {
|
||||
return 0, false
|
||||
}
|
||||
|
||||
v, err := strconv.Atoi(raw)
|
||||
if err != nil || v <= 0 {
|
||||
return 0, false
|
||||
}
|
||||
|
||||
return float64(v) / 1000.0, true
|
||||
}
|
||||
|
||||
func intParam(raw string) (int, bool) {
|
||||
if raw == "" {
|
||||
return 0, false
|
||||
}
|
||||
|
||||
v, err := strconv.Atoi(raw)
|
||||
if err != nil || v <= 0 {
|
||||
return 0, false
|
||||
}
|
||||
|
||||
return v, true
|
||||
}
|
||||
@@ -0,0 +1,128 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/binary"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"sync"
|
||||
"testing"
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
)
|
||||
|
||||
func newDingTestServer(t *testing.T) *httptest.Server {
|
||||
t.Helper()
|
||||
|
||||
_, server := setupRouter("http://localhost:8001", nil)
|
||||
|
||||
r := chi.NewRouter()
|
||||
r.Get("/media/aftertouch-ding.wav", server.HandleDing)
|
||||
|
||||
ts := httptest.NewServer(r)
|
||||
t.Cleanup(ts.Close)
|
||||
|
||||
return ts
|
||||
}
|
||||
|
||||
func TestHandleDing_DefaultIsValidWAV(t *testing.T) {
|
||||
ts := newDingTestServer(t)
|
||||
|
||||
res, err := http.Get(ts.URL + "/media/aftertouch-ding.wav")
|
||||
if err != nil {
|
||||
t.Fatalf("GET: %v", err)
|
||||
}
|
||||
defer res.Body.Close()
|
||||
|
||||
if res.StatusCode != 200 {
|
||||
t.Fatalf("expected 200, got %d", res.StatusCode)
|
||||
}
|
||||
|
||||
if ct := res.Header.Get("Content-Type"); ct != "audio/wav" {
|
||||
t.Errorf("expected audio/wav, got %q", ct)
|
||||
}
|
||||
|
||||
body := readAll(t, res)
|
||||
if !bytes.HasPrefix(body, []byte("RIFF")) {
|
||||
t.Errorf("expected RIFF prefix")
|
||||
}
|
||||
|
||||
if !bytes.Equal(body[8:12], []byte("WAVE")) {
|
||||
t.Errorf("expected WAVE format marker")
|
||||
}
|
||||
|
||||
if sr := binary.LittleEndian.Uint32(body[24:28]); sr != 22050 {
|
||||
t.Errorf("expected default sample rate 22050, got %d", sr)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleDing_OverrideSampleRate(t *testing.T) {
|
||||
ts := newDingTestServer(t)
|
||||
|
||||
res, err := http.Get(ts.URL + "/media/aftertouch-ding.wav?sample-rate=44100")
|
||||
if err != nil {
|
||||
t.Fatalf("GET: %v", err)
|
||||
}
|
||||
defer res.Body.Close()
|
||||
|
||||
body := readAll(t, res)
|
||||
if sr := binary.LittleEndian.Uint32(body[24:28]); sr != 44100 {
|
||||
t.Errorf("expected sample rate 44100, got %d", sr)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleDing_InvalidParamFallsBackToDefault(t *testing.T) {
|
||||
ts := newDingTestServer(t)
|
||||
|
||||
res, err := http.Get(ts.URL + "/media/aftertouch-ding.wav?sample-rate=notanumber&pitch-high=-50")
|
||||
if err != nil {
|
||||
t.Fatalf("GET: %v", err)
|
||||
}
|
||||
defer res.Body.Close()
|
||||
|
||||
if res.StatusCode != 200 {
|
||||
t.Errorf("expected 200 even with bad params, got %d", res.StatusCode)
|
||||
}
|
||||
|
||||
body := readAll(t, res)
|
||||
if sr := binary.LittleEndian.Uint32(body[24:28]); sr != 22050 {
|
||||
t.Errorf("invalid sample-rate should fall back to default, got %d", sr)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleDing_DefaultIsCached(t *testing.T) {
|
||||
// Reset the cache by simulating a fresh process.
|
||||
dingDefaultCache.once = sync.Once{}
|
||||
dingDefaultCache.data = nil
|
||||
|
||||
ts := newDingTestServer(t)
|
||||
|
||||
resA, err := http.Get(ts.URL + "/media/aftertouch-ding.wav")
|
||||
if err != nil {
|
||||
t.Fatalf("first GET: %v", err)
|
||||
}
|
||||
a := readAll(t, resA)
|
||||
|
||||
resB, err := http.Get(ts.URL + "/media/aftertouch-ding.wav")
|
||||
if err != nil {
|
||||
t.Fatalf("second GET: %v", err)
|
||||
}
|
||||
b := readAll(t, resB)
|
||||
|
||||
if !bytes.Equal(a, b) {
|
||||
t.Errorf("expected cached default to be byte-identical across requests")
|
||||
}
|
||||
}
|
||||
|
||||
func readAll(t *testing.T, res *http.Response) []byte {
|
||||
t.Helper()
|
||||
|
||||
defer res.Body.Close()
|
||||
|
||||
body := new(bytes.Buffer)
|
||||
if _, err := body.ReadFrom(res.Body); err != nil {
|
||||
t.Fatalf("read body: %v", err)
|
||||
}
|
||||
|
||||
return body.Bytes()
|
||||
}
|
||||
Binary file not shown.
@@ -1,273 +1,65 @@
|
||||
// Generator for the AfterTouch "ding" sound — a two-chirp signature
|
||||
// derived from the braille letters S and T (which the AfterTouch
|
||||
// logo overlays).
|
||||
//
|
||||
// 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 map to stereo channels:
|
||||
// left column (1,2,3) → left channel
|
||||
// right column (4,5,6) → right channel
|
||||
//
|
||||
// Rows map to pitch:
|
||||
// top row (1,4) → A5 (880 Hz)
|
||||
// mid row (2,5) → E5 (659.25 Hz)
|
||||
// bottom row (3,6) → A4 (440 Hz)
|
||||
//
|
||||
// So:
|
||||
//
|
||||
// S (dots 2,3,4): L = E5+A4, R = A5
|
||||
// T (dots 2,3,4,5): L = E5+A4, R = A5+E5 (S with an extra voice on the right)
|
||||
//
|
||||
// Total clip ≈ 600 ms: chirp(S) ~250 ms, gap ~100 ms, chirp(T) ~250 ms.
|
||||
// Each chirp has a short attack and decay envelope to avoid clicks.
|
||||
// Offline generator for the AfterTouch "ding" WAV. Thin CLI
|
||||
// wrapper around pkg/service/ding so the same renderer used at
|
||||
// runtime (HandleDing on GET /media/aftertouch-ding.wav) can
|
||||
// also be invoked from the shell — handy for previewing parameter
|
||||
// tweaks or producing a one-off file for sharing.
|
||||
//
|
||||
// Run:
|
||||
//
|
||||
// go run ./scripts/gen-aftertouch-ding > pkg/service/handlers/static/media/aftertouch-ding.wav
|
||||
// go run ./scripts/gen-aftertouch-ding > ding.wav
|
||||
// go run ./scripts/gen-aftertouch-ding -o ding.wav
|
||||
// go run ./scripts/gen-aftertouch-ding -pitch-high 1200 -o ding.wav
|
||||
//
|
||||
// Or pass -o to write directly:
|
||||
//
|
||||
// go run ./scripts/gen-aftertouch-ding -o pkg/service/handlers/static/media/aftertouch-ding.wav
|
||||
// All flags fall back to defaults defined in pkg/service/ding;
|
||||
// pass only the knobs you want to override.
|
||||
package main
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/binary"
|
||||
"flag"
|
||||
"fmt"
|
||||
"io"
|
||||
"math"
|
||||
"os"
|
||||
)
|
||||
|
||||
const (
|
||||
sampleRate = 22050
|
||||
channels = 2
|
||||
bitsPer = 16
|
||||
)
|
||||
|
||||
// Pitches (Hz).
|
||||
const (
|
||||
pitchHigh = 880.00 // A5 (top row)
|
||||
pitchMid = 659.2551 // E5 (mid row)
|
||||
pitchLow = 440.00 // A4 (bottom row)
|
||||
)
|
||||
|
||||
// A "voice" is a single sine tone routed to one stereo channel.
|
||||
type voice struct {
|
||||
freq float64
|
||||
channel int // 0 = left, 1 = right
|
||||
}
|
||||
|
||||
// Active voices per braille letter, derived from the dot mapping above.
|
||||
var (
|
||||
voicesS = []voice{
|
||||
{freq: pitchMid, channel: 0}, // dot 2: left-mid
|
||||
{freq: pitchLow, channel: 0}, // dot 3: left-bottom
|
||||
{freq: pitchHigh, channel: 1}, // dot 4: right-top
|
||||
}
|
||||
voicesT = []voice{
|
||||
{freq: pitchMid, channel: 0}, // dot 2: left-mid
|
||||
{freq: pitchLow, channel: 0}, // dot 3: left-bottom
|
||||
{freq: pitchHigh, channel: 1}, // dot 4: right-top
|
||||
{freq: pitchMid, channel: 1}, // dot 5: right-mid
|
||||
}
|
||||
"github.com/gesellix/bose-soundtouch/pkg/service/ding"
|
||||
)
|
||||
|
||||
func main() {
|
||||
var outPath string
|
||||
flag.StringVar(&outPath, "o", "", "output WAV path; default stdout")
|
||||
flag.Parse()
|
||||
|
||||
var (
|
||||
chirpDur = 0.25 // seconds
|
||||
gapDur = 0.10
|
||||
attack = 0.020 // fade-in, avoids click
|
||||
release = 0.060 // fade-out, avoids tail click
|
||||
outPath string
|
||||
opts ding.Options
|
||||
)
|
||||
|
||||
chirpN := int(math.Round(float64(sampleRate) * chirpDur))
|
||||
gapN := int(math.Round(float64(sampleRate) * gapDur))
|
||||
flag.StringVar(&outPath, "o", "", "output WAV path; default stdout")
|
||||
|
||||
// Allocate exactly: two chirps + one gap. Doing this from the
|
||||
// rendered sample counts (instead of re-computing from seconds)
|
||||
// avoids a rounding off-by-one between the two paths.
|
||||
samplesPerChannel := chirpN*2 + gapN
|
||||
left := make([]float64, samplesPerChannel)
|
||||
right := make([]float64, samplesPerChannel)
|
||||
flag.IntVar(&opts.SampleRate, "sample-rate", 0, "Hz; 0 → default 22050")
|
||||
flag.Float64Var(&opts.PitchHigh, "pitch-high", 0, "Hz, top row; 0 → default 880 (A5)")
|
||||
flag.Float64Var(&opts.PitchMid, "pitch-mid", 0, "Hz, mid row; 0 → default 659.25 (E5)")
|
||||
flag.Float64Var(&opts.PitchLow, "pitch-low", 0, "Hz, bottom row; 0 → default 440 (A4)")
|
||||
flag.Float64Var(&opts.ChirpDuration, "chirp-sec", 0, "chirp duration; 0 → default 0.25")
|
||||
flag.Float64Var(&opts.GapDuration, "gap-sec", 0, "between-chirp gap; 0 → default 0.10")
|
||||
flag.Float64Var(&opts.AttackDuration, "attack-sec", 0, "fade-in; 0 → default 0.020")
|
||||
flag.Float64Var(&opts.ReleaseDuration, "release-sec", 0, "fade-out; 0 → default 0.060")
|
||||
flag.Float64Var(&opts.Peak, "peak", 0, "0..1 headroom; 0 → default 0.85")
|
||||
|
||||
renderChirp(left, right, 0, chirpN, voicesS, attack, release)
|
||||
renderChirp(left, right, chirpN+gapN, chirpN, voicesT, attack, release)
|
||||
flag.Parse()
|
||||
|
||||
normalise(left, right, 0.85) // headroom below 1.0 to avoid clipping
|
||||
|
||||
var buf bytes.Buffer
|
||||
if err := writeWAV(&buf, left, right); err != nil {
|
||||
fail("encode: %v", err)
|
||||
}
|
||||
data := ding.Render(opts)
|
||||
|
||||
var w io.Writer = os.Stdout
|
||||
|
||||
if outPath != "" {
|
||||
f, err := os.Create(outPath)
|
||||
if err != nil {
|
||||
fail("create %s: %v", outPath, err)
|
||||
fmt.Fprintf(os.Stderr, "gen-aftertouch-ding: create %s: %v\n", outPath, err)
|
||||
os.Exit(1)
|
||||
}
|
||||
defer f.Close()
|
||||
|
||||
defer func() { _ = f.Close() }()
|
||||
w = f
|
||||
}
|
||||
|
||||
if _, err := w.Write(buf.Bytes()); err != nil {
|
||||
fail("write: %v", err)
|
||||
if _, err := w.Write(data); err != nil {
|
||||
fmt.Fprintf(os.Stderr, "gen-aftertouch-ding: write: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
}
|
||||
|
||||
// renderChirp writes one chirp into the L/R buffers starting at offset.
|
||||
// The envelope is a trapezoid: linear attack, flat sustain, linear release.
|
||||
func renderChirp(left, right []float64, offset, length int, voices []voice, attackSec, releaseSec float64) {
|
||||
attackN := int(math.Round(float64(sampleRate) * attackSec))
|
||||
releaseN := int(math.Round(float64(sampleRate) * releaseSec))
|
||||
|
||||
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).
|
||||
// This keeps the chord-sum from 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
|
||||
}
|
||||
}
|
||||
|
||||
func writeWAV(w io.Writer, left, right []float64) error {
|
||||
if len(left) != len(right) {
|
||||
return fmt.Errorf("channel length mismatch: %d vs %d", len(left), len(right))
|
||||
}
|
||||
|
||||
samples := len(left)
|
||||
dataBytes := samples * channels * (bitsPer / 8)
|
||||
totalRIFFSize := 4 + (8 + 16) + (8 + dataBytes) // "WAVE" + fmt chunk + data chunk
|
||||
|
||||
// RIFF header
|
||||
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
|
||||
}
|
||||
|
||||
// fmt chunk
|
||||
if _, err := w.Write([]byte("fmt ")); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := binary.Write(w, binary.LittleEndian, uint32(16)); err != nil { // PCM fmt chunk size
|
||||
return err
|
||||
}
|
||||
if err := binary.Write(w, binary.LittleEndian, uint16(1)); err != nil { // PCM
|
||||
return err
|
||||
}
|
||||
if err := binary.Write(w, binary.LittleEndian, uint16(channels)); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := binary.Write(w, binary.LittleEndian, uint32(sampleRate)); err != nil {
|
||||
return err
|
||||
}
|
||||
byteRate := uint32(sampleRate * channels * (bitsPer / 8))
|
||||
if err := binary.Write(w, binary.LittleEndian, byteRate); err != nil {
|
||||
return err
|
||||
}
|
||||
blockAlign := uint16(channels * (bitsPer / 8))
|
||||
if err := binary.Write(w, binary.LittleEndian, blockAlign); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := binary.Write(w, binary.LittleEndian, uint16(bitsPer)); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// data chunk
|
||||
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++ {
|
||||
l := floatToInt16(left[i])
|
||||
r := floatToInt16(right[i])
|
||||
if err := binary.Write(w, binary.LittleEndian, l); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := binary.Write(w, binary.LittleEndian, r); 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))
|
||||
}
|
||||
|
||||
func fail(format string, args ...any) {
|
||||
fmt.Fprintf(os.Stderr, "gen-aftertouch-ding: "+format+"\n", args...)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user