| 1 | import assert from "node:assert/strict"; |
| 2 | import { test } from "node:test"; |
| 3 | |
| 4 | import { SOUND_CUES } from "@g1t/contracts/sounds"; |
| 5 | |
| 6 | import { PEAK, cueLength, cueSpec, envelope, renderCue, volumeGain } from "./sound-synth.ts"; |
| 7 | |
| 8 | const RATE = 48_000; |
| 9 | |
| 10 | test("every cue in both sets is short, starts and ends in silence, and never clips", () => { |
| 11 | for (const set of ["soft", "bright"] as const) { |
| 12 | for (const cue of [...SOUND_CUES, "call"] as const) { |
| 13 | const spec = cueSpec(set, cue); |
| 14 | const length = cueLength(spec); |
| 15 | assert.ok(length >= 40 && length <= 260, `${set} ${cue} lasts ${length} ms`); |
| 16 | const samples = renderCue(spec, RATE); |
| 17 | assert.equal(samples.length, Math.ceil((length / 1000) * RATE)); |
| 18 | // No click: the first and last millisecond are quiet. |
| 19 | const ms = RATE / 1000; |
| 20 | for (let i = 0; i < ms; i++) { |
| 21 | assert.ok(Math.abs(samples[i]!) < 0.15, `${set} ${cue} clicks on at sample ${i}: ${samples[i]}`); |
| 22 | assert.ok(Math.abs(samples[samples.length - 1 - i]!) < 0.05, `${set} ${cue} clicks off`); |
| 23 | } |
| 24 | assert.ok(Math.abs(samples[0]!) < 1e-6); |
| 25 | assert.ok(Math.abs(samples[samples.length - 1]!) < 1e-3); |
| 26 | let peak = 0; |
| 27 | let energy = 0; |
| 28 | for (const s of samples) { |
| 29 | peak = Math.max(peak, Math.abs(s)); |
| 30 | energy += s * s; |
| 31 | } |
| 32 | assert.ok(Math.abs(peak - PEAK) < 1e-6, `${set} ${cue} peaks at ${peak}`); |
| 33 | assert.ok(energy > 1, `${set} ${cue} is audible`); |
| 34 | } |
| 35 | } |
| 36 | }); |
| 37 | |
| 38 | test("the cues are told apart: different figures, and Bright is higher and shorter than Soft", () => { |
| 39 | const soft = [...SOUND_CUES].map((cue) => cueSpec("soft", cue)); |
| 40 | const figures = new Set(soft.map((spec) => spec.notes.map((n) => `${n.freq.toFixed(0)}@${n.at}+${n.dur}`).join(" "))); |
| 41 | assert.equal(figures.size, soft.length, "no two Soft cues are the same figure"); |
| 42 | for (const cue of SOUND_CUES) { |
| 43 | const s = cueSpec("soft", cue); |
| 44 | const b = cueSpec("bright", cue); |
| 45 | assert.equal(b.notes.length, s.notes.length, "the same roles"); |
| 46 | for (const [i, note] of b.notes.entries()) { |
| 47 | assert.ok(note.freq > s.notes[i]!.freq, `${cue}: Bright is higher`); |
| 48 | assert.ok(note.dur < s.notes[i]!.dur, `${cue}: Bright is shorter`); |
| 49 | } |
| 50 | assert.ok(cueLength(b) < cueLength(s)); |
| 51 | } |
| 52 | // The tick as you send is the quietest and shortest. |
| 53 | assert.ok(cueLength(cueSpec("soft", "sent")) < 80); |
| 54 | }); |
| 55 | |
| 56 | test("the envelope rises from nothing, falls, and lands on exactly nothing", () => { |
| 57 | assert.equal(envelope(-1, 100), 0); |
| 58 | assert.equal(envelope(0, 100), 0); |
| 59 | assert.ok(envelope(3, 100) > 0 && envelope(3, 100) < envelope(6, 100)); |
| 60 | assert.ok(envelope(50, 100) < envelope(10, 100), "it decays"); |
| 61 | assert.ok(envelope(99.9, 100) < 0.01); |
| 62 | assert.equal(envelope(100, 100), 0); |
| 63 | }); |
| 64 | |
| 65 | test("volume is a curve: half way reads as half as loud, not a third", () => { |
| 66 | assert.equal(volumeGain(0), 0); |
| 67 | assert.equal(volumeGain(100), 1); |
| 68 | assert.ok(volumeGain(50) > 0.3 && volumeGain(50) < 0.35); |
| 69 | assert.equal(volumeGain(200), 1); |
| 70 | assert.equal(volumeGain(-5), 0); |
| 71 | }); |