1// The simplest system with the cymbal's kind of nonlinearity: one oscillator
2// with a hardening (Duffing) spring, u'' = -w0^2 (1 + b u^2) u - 2 s u'.
3// Struck softly it rings at its linear pitch; struck hard it starts sharp
4// and glides down as the amplitude decays. The figure integrates it, plots
5// the waveform and the instantaneous frequency from zero crossings, and can
6// play the result.
8import { useMemo, useState } from 'react';
9import { useCanvas, drawFrame, PLOT_COLORS } from '../../components/plots/canvasUtils';
10import { Figure, FigControls, FigSlider, usePlayer } from '../figparts';
12const HEIGHT = 240;
13const SR = 24000;
14const DUR = 1.4;
15const F0 = 380;
16const BETA = 5.0;
17const SIGMA = 3.5;
19function simulate(amp: number) {
20 const n = Math.round(SR * DUR);
21 const y = new Float32Array(n);
22 const w0 = 2 * Math.PI * F0;
23 const dt = 1 / SR;
24 let u = amp;
25 let v = 0;
26 const acc = (uu: number, vv: number) => -w0 * w0 * (1 + BETA * uu * uu) * uu - 2 * SIGMA * vv;
27 for (let k = 0; k < n; k++) {
28 y[k] = u;
29 // RK4
30 const k1u = v, k1v = acc(u, v);
31 const k2u = v + 0.5 * dt * k1v, k2v = acc(u + 0.5 * dt * k1u, v + 0.5 * dt * k1v);
32 const k3u = v + 0.5 * dt * k2v, k3v = acc(u + 0.5 * dt * k2u, v + 0.5 * dt * k2v);
33 const k4u = v + dt * k3v, k4v = acc(u + dt * k3u, v + dt * k3v);
34 u += (dt / 6) * (k1u + 2 * k2u + 2 * k3u + k4u);
35 v += (dt / 6) * (k1v + 2 * k2v + 2 * k3v + k4v);
36 }
37 // instantaneous frequency from upward zero crossings
38 const tf: number[] = [];
39 const ff: number[] = [];
40 let prevT = -1;
41 for (let k = 1; k < n; k++) {
42 if (y[k - 1] < 0 && y[k] >= 0) {
43 const t = (k - y[k] / (y[k] - y[k - 1])) / SR;
44 if (prevT >= 0) {
45 tf.push((t + prevT) / 2);
46 ff.push(1 / (t - prevT));
47 }
48 prevT = t;
49 }
50 }
51 return { y, tf, ff };
52}
54export default function PitchGlideFig() {
55 const [amp, setAmp] = useState(0.85);
56 const play = usePlayer();
57 const sim = useMemo(() => simulate(amp), [amp]);
59 const ref = useCanvas(
60 (ctx, w, h) => {
61 drawFrame(ctx, w, h);
62 const padL = 44;
63 const padR = 52;
64 const padT = 10;
65 const padB = 26;
66 const pw = w - padL - padR;
67 const ph = h - padT - padB;
68 const { y, tf, ff } = sim;
70 // waveform, min-max per pixel column
71 ctx.strokeStyle = PLOT_COLORS.trace;
72 ctx.globalAlpha = 0.85;
73 ctx.beginPath();
74 const n = y.length;
75 for (let px = 0; px < pw; px++) {
76 const i0 = Math.floor((px / pw) * n);
77 const i1 = Math.floor(((px + 1) / pw) * n);
78 let mn = Infinity, mx = -Infinity;
79 for (let i = i0; i < Math.max(i1, i0 + 1); i++) {
80 if (y[i] < mn) mn = y[i];
81 if (y[i] > mx) mx = y[i];
82 }
83 const ymid = padT + ph / 2;
84 ctx.moveTo(padL + px, ymid - (mx / 1.05) * (ph / 2));
85 ctx.lineTo(padL + px, ymid - (mn / 1.05) * (ph / 2));
86 }
87 ctx.stroke();
88 ctx.globalAlpha = 1;
90 // frequency track on the right axis, 300..900 Hz
91 const fMin = 300, fMax = 900;
92 const fy = (f: number) => padT + ph - ((f - fMin) / (fMax - fMin)) * ph;
93 ctx.strokeStyle = PLOT_COLORS.grid;
94 ctx.fillStyle = PLOT_COLORS.axis;
95 ctx.font = '10px system-ui';
96 ctx.textAlign = 'left';
97 for (let f = fMin; f <= fMax; f += 150) {
98 ctx.beginPath();
99 ctx.moveTo(padL, fy(f));
100 ctx.lineTo(w - padR, fy(f));
101 ctx.stroke();
102 ctx.fillText(`${f} Hz`, w - padR + 5, fy(f) + 3);
103 }
104 ctx.strokeStyle = PLOT_COLORS.accent;
105 ctx.lineWidth = 2;
106 ctx.beginPath();
107 for (let i = 0; i < tf.length; i++) {
108 const x = padL + (tf[i] / DUR) * pw;
109 const yy = fy(Math.min(fMax, Math.max(fMin, ff[i])));
110 if (i === 0) ctx.moveTo(x, yy);
111 else ctx.lineTo(x, yy);
112 }
113 ctx.stroke();
114 ctx.lineWidth = 1;
116 // linear pitch reference
117 ctx.strokeStyle = 'rgba(232, 163, 61, 0.4)';
118 ctx.setLineDash([4, 4]);
119 ctx.beginPath();
120 ctx.moveTo(padL, fy(F0));
121 ctx.lineTo(w - padR, fy(F0));
122 ctx.stroke();
123 ctx.setLineDash([]);
124 ctx.fillStyle = PLOT_COLORS.accent;
125 ctx.fillText(`linear pitch ${F0} Hz`, padL + 4, fy(F0) - 5);
127 // time axis
128 ctx.fillStyle = PLOT_COLORS.axis;
129 ctx.textAlign = 'center';
130 for (let t = 0.25; t <= DUR; t += 0.25) {
131 ctx.fillText(`${t.toFixed(2)}`, padL + (t / DUR) * pw, h - 8);
132 }
133 ctx.textAlign = 'left';
134 ctx.fillText('time (s)', 4, h - 8);
135 ctx.fillStyle = PLOT_COLORS.trace;
136 ctx.fillText('waveform', padL + 4, padT + 12);
137 const fpk = ff.length > 0 ? Math.max(...ff.slice(0, 12)) : F0;
138 ctx.fillStyle = PLOT_COLORS.accent;
139 ctx.fillText(`starts near ${fpk.toFixed(0)} Hz`, padL + 80, padT + 12);
140 },
141 [sim],
142 HEIGHT,
143 );
145 return (
146 <Figure
147 caption={
148 <>
149 A single oscillator with a hardening spring (a Duffing oscillator): its restoring
150 force stiffens with displacement, so its frequency rises with amplitude. Struck
151 hard, it starts sharp and glides down toward its linear pitch as the ring decays,
152 which is the pitch glide heard in a struck cymbal or gong. Each mode of the shell
153 sees a hardening of exactly this kind through the membrane stretching, with the
154 strength growing as the squared displacement measured in plate thicknesses.
155 </>
156 }
157 >
158 <canvas ref={ref} />
159 <FigControls>
160 <FigSlider
161 label="strike strength"
162 value={amp}
163 min={0.02}
164 max={1}
165 step={0.01}
166 format={(v) => v.toFixed(2)}
167 onChange={setAmp}
168 />
169 <button
170 onClick={() => {
171 const out = new Float32Array(sim.y.length);
172 let pk = 0;
173 for (let i = 0; i < out.length; i++) pk = Math.max(pk, Math.abs(sim.y[i]));
174 for (let i = 0; i < out.length; i++) out[i] = (0.5 * sim.y[i]) / (pk || 1);
175 play(out, SR);
176 }}
177 >
178 ▶ Play this tone
179 </button>
180 </FigControls>
181 </Figure>
182 );
183}