diff --git a/cmd/soundtouch-service/main.go b/cmd/soundtouch-service/main.go index eafb8a4..09e413a 100644 --- a/cmd/soundtouch-service/main.go +++ b/cmd/soundtouch-service/main.go @@ -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()) diff --git a/cmd/soundtouch-service/testdata/router_routes.txt b/cmd/soundtouch-service/testdata/router_routes.txt index 1cb90bf..f2dc4f8 100644 --- a/cmd/soundtouch-service/testdata/router_routes.txt +++ b/cmd/soundtouch-service/testdata/router_routes.txt @@ -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 diff --git a/pkg/service/ding/ding.go b/pkg/service/ding/ding.go new file mode 100644 index 0000000..2fec754 --- /dev/null +++ b/pkg/service/ding/ding.go @@ -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)) +} diff --git a/pkg/service/ding/ding_test.go b/pkg/service/ding/ding_test.go new file mode 100644 index 0000000..bcc2f3f --- /dev/null +++ b/pkg/service/ding/ding_test.go @@ -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 + } + } +} diff --git a/pkg/service/handlers/handlers_ding.go b/pkg/service/handlers/handlers_ding.go new file mode 100644 index 0000000..f5c3d8f --- /dev/null +++ b/pkg/service/handlers/handlers_ding.go @@ -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 +} diff --git a/pkg/service/handlers/handlers_ding_test.go b/pkg/service/handlers/handlers_ding_test.go new file mode 100644 index 0000000..fed91ca --- /dev/null +++ b/pkg/service/handlers/handlers_ding_test.go @@ -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() +} diff --git a/pkg/service/handlers/static/media/aftertouch-ding.wav b/pkg/service/handlers/static/media/aftertouch-ding.wav deleted file mode 100644 index 933db98..0000000 Binary files a/pkg/service/handlers/static/media/aftertouch-ding.wav and /dev/null differ diff --git a/scripts/gen-aftertouch-ding/main.go b/scripts/gen-aftertouch-ding/main.go index 471e295..63f1e52 100644 --- a/scripts/gen-aftertouch-ding/main.go +++ b/scripts/gen-aftertouch-ding/main.go @@ -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) -}