// The hit log and its export as a cymsim configuration. import type { Exciter, SimulationConfig } from "./config/schema.ts"; import { cloneConfig, configToYaml } from "./config/yaml.ts"; export interface LoggedHit { id: number; time: number; // audio-context seconds at release (filled in when the worklet reports it) r: number; phi: number; velocity: number; exciter: Exciter; source: string; // key or "click" peakForce?: number; contactTime?: number; rebound?: number; } export interface ExportOptions { leadIn: number; // seconds before the first hit tail: number; // seconds after the last hit prefix: string; } /** The base configuration with the recorded hits, times shifted so the first hit is at leadIn. */ export function exportConfig(base: SimulationConfig, hits: LoggedHit[], opts: ExportOptions): SimulationConfig { const cfg = cloneConfig(base); const sorted = hits.filter((h) => h.time >= 0).slice().sort((a, b) => a.time - b.time); const t0 = sorted.length ? sorted[0].time : 0; cfg.hits = sorted.map((h) => ({ time: round(h.time - t0 + opts.leadIn, 5), r: round(h.r, 5), phi: round(h.phi, 5), velocity: round(h.velocity, 3), exciter: structuredClone(h.exciter), })); const last = cfg.hits.length ? cfg.hits[cfg.hits.length - 1].time : 0; cfg.numerics.duration = round(last + opts.tail, 3); cfg.output.prefix = opts.prefix; return cfg; } export function exportYaml(base: SimulationConfig, hits: LoggedHit[], opts: ExportOptions): string { const header = `# Recorded in cymplay (linear modal playback) on ${new Date().toISOString()}.\n` + `# ${hits.length} hit(s); render the full nonlinear model with: cymsim simulate \n`; return header + configToYaml(exportConfig(base, hits, opts)); } function round(x: number, digits: number): number { const f = Math.pow(10, digits); return Math.round(x * f) / f; } /** 16-bit PCM mono WAV of a float signal, peak-normalised to 0.9 full scale when ``normalize``. */ export function encodeWav(samples: Float32Array, sampleRate: number, normalize = true): Blob { let peak = 0; for (const s of samples) peak = Math.max(peak, Math.abs(s)); const scale = normalize ? (peak > 0 ? 0.9 / peak : 1) : 0.05; const buf = new ArrayBuffer(44 + 2 * samples.length); const v = new DataView(buf); const str = (o: number, s: string) => { for (let i = 0; i < s.length; i++) v.setUint8(o + i, s.charCodeAt(i)); }; str(0, "RIFF"); v.setUint32(4, 36 + 2 * samples.length, true); str(8, "WAVE"); str(12, "fmt "); v.setUint32(16, 16, true); v.setUint16(20, 1, true); v.setUint16(22, 1, true); v.setUint32(24, sampleRate, true); v.setUint32(28, sampleRate * 2, true); v.setUint16(32, 2, true); v.setUint16(34, 16, true); str(36, "data"); v.setUint32(40, 2 * samples.length, true); for (let i = 0; i < samples.length; i++) { const x = Math.max(-1, Math.min(1, samples[i] * scale)); v.setInt16(44 + 2 * i, Math.round(x * 32767), true); } return new Blob([buf], { type: "audio/wav" }); }