#!/usr/bin/env node /** * Synthesize the built-in chime that ships with this plugin. * * A short, soft two-note bell (a rising perfect fourth) with an exponential * decay: loud enough to notice from another room, gentle enough not to startle * after the twentieth task of the day. Written from scratch so the repository * carries no third-party audio and the sample is reproducible: * * node scripts/make-default-chime.mjs * * Output: `assets/default-chime.wav` — 16-bit PCM mono, 22.05 kHz, ~0.9 s (~40 KB). * * @module dsh-turn-chime/scripts/make-default-chime */ import { mkdirSync, writeFileSync } from 'node:fs' import { dirname, join, resolve } from 'node:path' import { fileURLToPath } from 'node:url' const repo = resolve(dirname(fileURLToPath(import.meta.url)), '..') const output = join(repo, 'assets', 'default-chime.wav') /** Sample rate: 22.05 kHz keeps the file small and is plenty for a bell. */ const RATE = 22_050 /** The two notes, in seconds from the start: E6 then A6 (a rising fourth). */ const NOTES = [ { at: 0, hz: 1318.51, gain: 0.55, decay: 3.4 }, { at: 0.16, hz: 1760, gain: 1, decay: 2.6 }, ] /** Length of the rendered sample, in seconds. */ const DURATION = 0.9 /** * Render the chime as mono float samples in [-1, 1]. * * Each note is a small additive stack (fundamental + two inharmonic partials) * so it reads as a bell rather than a beep, and the whole buffer gets a short * fade-in/out to keep the first and last sample from clicking. * @returns the rendered samples. */ function render() { const total = Math.round(RATE * DURATION) const samples = new Float64Array(total) for (const note of NOTES) { // Partials: fundamental, a quiet octave-plus-fifth shimmer, and a soft // metallic partial. The higher partials decay faster, which is what makes a // struck-bell envelope sound natural. const partials = [ { ratio: 1, gain: 1, decay: note.decay }, { ratio: 2.76, gain: 0.22, decay: note.decay * 1.7 }, { ratio: 5.4, gain: 0.07, decay: note.decay * 2.4 }, ] const start = Math.round(note.at * RATE) for (let index = start; index < total; index += 1) { const t = (index - start) / RATE let value = 0 for (const partial of partials) { value += partial.gain * Math.exp(-partial.decay * t) * Math.sin(2 * Math.PI * note.hz * partial.ratio * t) } samples[index] += value * note.gain } } // Normalize to a fixed headroom rather than to the peak, so the mix is stable // if the notes are ever retuned. let peak = 0 for (const value of samples) peak = Math.max(peak, Math.abs(value)) const scale = peak === 0 ? 0 : 0.85 / peak const fade = Math.round(RATE * 0.006) for (let index = 0; index < total; index += 1) { let value = samples[index] * scale if (index < fade) value *= index / fade const tail = total - 1 - index if (tail < fade) value *= tail / fade samples[index] = value } return samples } /** * Wrap float samples in a 16-bit PCM WAV container. * @param samples - mono samples in [-1, 1]. * @returns the complete file bytes. */ function toWav(samples) { const dataBytes = samples.length * 2 const buffer = Buffer.alloc(44 + dataBytes) buffer.write('RIFF', 0, 'ascii') buffer.writeUInt32LE(36 + dataBytes, 4) buffer.write('WAVE', 8, 'ascii') buffer.write('fmt ', 12, 'ascii') buffer.writeUInt32LE(16, 16) // PCM header size buffer.writeUInt16LE(1, 20) // format: PCM buffer.writeUInt16LE(1, 22) // channels: mono buffer.writeUInt32LE(RATE, 24) buffer.writeUInt32LE(RATE * 2, 28) // byte rate buffer.writeUInt16LE(2, 32) // block align buffer.writeUInt16LE(16, 34) // bits per sample buffer.write('data', 36, 'ascii') buffer.writeUInt32LE(dataBytes, 40) samples.forEach((value, index) => { const clamped = Math.max(-1, Math.min(1, value)) buffer.writeInt16LE(Math.round(clamped * 32767), 44 + index * 2) }) return buffer } const wav = toWav(render()) mkdirSync(dirname(output), { recursive: true }) writeFileSync(output, wav) console.log(`wrote ${output} (${(wav.length / 1024).toFixed(1)} KB, ${(DURATION * 1000) | 0} ms, ${RATE} Hz mono)`)