Beam: strike and listening at positions along the beam (m), hammer blow of fixed physical width, so both mean the same at any N; N = 8 unchanged
6 changed files+87−34
src/beam/README.mdmodified+3−3View file
@@ -2,16 +2,16 @@
22
33 Struck beam with bending stiffness, step 3 of "Listening for chaos" (after double-pendulum and fput-chain). Work in progress: step 1 (physics, animation, sped-up note with spectrogram), step 2 (Lyapunov exponent), step 3 (mode bars and mode histories), step 4 (ghost), step 5 (λ vs E), curvature (rise h of a curved rest shape), frequency-dependent loss (τ top), Masses as resolution (refine).
44
5-Current state: N masses (2 to 64, default 8) on vertical rails, moving sideways only, with tension k, bending κ and a stretching nonlinearity k3 (below). Ends (a select next to Masses; changing it returns the beam to rest): clamped–clamped (default), pinned–pinned, or cantilever (clamped at the left, free at the right). Strike gives energy E (log slider, 10 µJ to 100 J, default 2.51 J) as velocity, on one mass alone (default mass 2) or in the shape of one normal mode. The card draws the beam from the side, at rest shape plus displacement, scaled to fit (scale given; the rest shape is a faint line), with rails as faint lines, a hatched wall for a clamp and a triangle for a pin, plus the time, energy and relative energy error. The Ghost chip (as in fput-chain; controls in `../common/ghost.ts`) adds a second beam started from the same state with every mass moved by 10^k / sqrt(N) m (total offset 10^k m, k = -12 to -1, default -10), run in lockstep without renormalization and drawn in translucent orange underneath; restarted on Strike, on "Restart ghost", when switched on or when the offset changes; a readout gives the separation (root sum of squared displacement differences, m) and the time since the ghost started. Default strike: the separation stays near 1e-10 m for ~10 s, then grows to 1.7e-8 m by 39.5 s, e^5.1, matching λ since the strike (0.13 /s × 39.5 s). A "Real time" card on the right has chips: Mode bars (on; the current harmonic energy of each mode relative to the strike, log scale over 12 decades, with the number of modes sharing the energy, 1 / sum (E_n / sum E)^2, and the mode frequency range; `../common/modebars.ts`), Mode history (off; each mode's share of the harmonic energy over the last 300 s as a heat map, mode 1 at the bottom; `LiveModeHistory` in `../common/modehistory.ts`), and charts (`../common/traces.ts`): Lyapunov (on; the largest Lyapunov exponent over the last 10 s and since the strike, last 60 s shown, the first 5 s after a strike left out of the y range) Mode energies (off; log, last 60 s, values below 1e-12 of the strike left out), Energy error (off) and Separation (log, last 60 s; offered only with the ghost on). The exponent is the two-trajectory method of `../common/lyapunov.ts` with the beam's shadow in `lyapunov.ts`: a hidden second beam 1e-8 away, displacements in units of A / sqrt(N) and momenta in units of m omega_1 A / sqrt(N), A the mode-1 amplitude at the nonlinear scale. Speed 0.1x to 20x real time; Pause or space. Parameters (m, k, κ, k3, rise h, a, τ, τ top, stretching form) fold out with derived values (mode frequencies, ratios to mode 1, the nonlinear scale, integrator step); Apply returns the beam to rest.
5+Current state: N masses (2 to 64, default 8) on vertical rails, moving sideways only, with tension k, bending κ and a stretching nonlinearity k3 (below). Ends (a select next to Masses; changing it returns the beam to rest): clamped–clamped (default), pinned–pinned, or cantilever (clamped at the left, free at the right). Strike gives energy E (log slider, 10 µJ to 100 J, default 2.51 J) as velocity, as a hammer blow at x (m from the left support, default 1.0 m, where mass 2 sits at every N; `strikeAt`) or in the shape of one normal mode. The blow is a triangular velocity bump of half-width one spacing of the 8-mass beam (0.5 m; at least one spacing), so at N = 8 on a mass only that mass moves (bit-identical to the old mass strike) and at larger N the same blow spreads over the neighbours. The card draws the beam from the side, at rest shape plus displacement, scaled to fit (scale given; the rest shape is a faint line), with rails as faint lines, a hatched wall for a clamp and a triangle for a pin, plus the time, energy and relative energy error. The Ghost chip (as in fput-chain; controls in `../common/ghost.ts`) adds a second beam started from the same state with every mass moved by 10^k / sqrt(N) m (total offset 10^k m, k = -12 to -1, default -10), run in lockstep without renormalization and drawn in translucent orange underneath; restarted on Strike, on "Restart ghost", when switched on or when the offset changes; a readout gives the separation (root sum of squared displacement differences, m) and the time since the ghost started. Default strike: the separation stays near 1e-10 m for ~10 s, then grows to 1.7e-8 m by 39.5 s, e^5.1, matching λ since the strike (0.13 /s × 39.5 s). A "Real time" card on the right has chips: Mode bars (on; the current harmonic energy of each mode relative to the strike, log scale over 12 decades, with the number of modes sharing the energy, 1 / sum (E_n / sum E)^2, and the mode frequency range; `../common/modebars.ts`), Mode history (off; each mode's share of the harmonic energy over the last 300 s as a heat map, mode 1 at the bottom; `LiveModeHistory` in `../common/modehistory.ts`), and charts (`../common/traces.ts`): Lyapunov (on; the largest Lyapunov exponent over the last 10 s and since the strike, last 60 s shown, the first 5 s after a strike left out of the y range) Mode energies (off; log, last 60 s, values below 1e-12 of the strike left out), Energy error (off) and Separation (log, last 60 s; offered only with the ghost on). The exponent is the two-trajectory method of `../common/lyapunov.ts` with the beam's shadow in `lyapunov.ts`: a hidden second beam 1e-8 away, displacements in units of A / sqrt(N) and momenta in units of m omega_1 A / sqrt(N), A the mode-1 amplitude at the nonlinear scale. Speed 0.1x to 20x real time; Pause or space. Parameters (m, k, κ, k3, rise h, a, τ, τ top, stretching form) fold out with derived values (mode frequencies, ratios to mode 1, the nonlinear scale, integrator step); Apply returns the beam to rest.
66
7-"Sped up · listen" (as in fput-chain): each Strike renders the same strike in a worker (`render-worker.ts`), sped up so the lowest mode sounds at the Pitch (5 to 1000 Hz, default 100), and plays it; the page starts muted. A damped note is the whole decay to -60 dB. Notes are anti-alias filtered (`../common/antialias.ts`, in the shared render loop): the signal is read 4 times per audio sample, low-passed by a 143-tap Kaiser FIR (passband to 0.42 fs, 20.2 kHz at 48 kHz, ripple 0.0003 dB; at least 87 dB down from 0.58 fs, so what folds lands above the passband) and decimated, centred so samples stay aligned with the diagnostics. A note shows when the top mode is above the passband (those modes are filtered out). Checked (Node, one trajectory recorded at 32x or 64x, against an FFT brick-wall reference at 20 kHz, error below 18 kHz): default N = 8 -153 dB before, -107 dB now (filter and float32 floor); clamped N = 32, 25 J -46 -> -105 dB; cantilever N = 8, 2.51 J -95 -> -111 dB; cantilever N = 16, 2.51 J -20 -> -104 dB. Cost: the 4 points per sample force at least 4 steps per sample, so the default N = 8 note now takes ~0.9 s to compute (0.3 s before; 1.49 M steps instead of 0.57 M); N = 32 9.9 s (7.9 s). Signals (`signals.ts`): velocity (default), displacement or acceleration of mass j (default 2), or the sum of accelerations (the net force of the supports). Replay, Save WAV (named e.g. beam-clamped-local-N8-mass2-2.51J-v2-100Hz.wav), spectrogram over the whole note with dashed lines at the mode pitches. Chips: Spectrogram (on; frequency axis logarithmic by default, from a round number at or below half the lowest mode pitch, with a "log f" chip to switch to linear from 0; each pixel row shows the loudest FFT bin in its band; mode lines each drawn only while at least 6 px from their neighbours: at the default note the six lowest are drawn, modes 7 and 8 are 2 px apart; on the old linear axis to 10 kHz the modes filled the bottom eighth), Modes (off; the note's mode-energy shares over the whole note, one column per 256 samples, from the worker; `ModeHistory` in `../common/modehistory.ts`), Lyapunov (on; the note's exponent over the last 10 s of physical time, from a shadow run in the worker; the first 5 s of physical time left out of the y range; the shadow runs only while this chart or λ vs E is shown, as it doubles the cost) Energy (log) and λ vs E (off; `../common/scatter.ts` as in fput-chain: one point per 256 samples, accumulated over notes, log energy axis, colored by time into the note, earlier notes faded, hover for values, Clear; not cleared when parameters change). All but λ vs E span the whole note and share one cursor (playhead while playing; hover, click to pin). The render loop is shared with fput-chain (`../common/note-render.ts`). The status line gives the render time (default note 1.0 to 1.4 s with the shadow, 0.6 s without).
7+"Sped up · listen" (as in fput-chain): each Strike renders the same strike in a worker (`render-worker.ts`), sped up so the lowest mode sounds at the Pitch (5 to 1000 Hz, default 100), and plays it; the page starts muted. A damped note is the whole decay to -60 dB. Notes are anti-alias filtered (`../common/antialias.ts`, in the shared render loop): the signal is read 4 times per audio sample, low-passed by a 143-tap Kaiser FIR (passband to 0.42 fs, 20.2 kHz at 48 kHz, ripple 0.0003 dB; at least 87 dB down from 0.58 fs, so what folds lands above the passband) and decimated, centred so samples stay aligned with the diagnostics. A note shows when the top mode is above the passband (those modes are filtered out). Checked (Node, one trajectory recorded at 32x or 64x, against an FFT brick-wall reference at 20 kHz, error below 18 kHz): default N = 8 -153 dB before, -107 dB now (filter and float32 floor); clamped N = 32, 25 J -46 -> -105 dB; cantilever N = 8, 2.51 J -95 -> -111 dB; cantilever N = 16, 2.51 J -20 -> -104 dB. Cost: the 4 points per sample force at least 4 steps per sample, so the default N = 8 note now takes ~0.9 s to compute (0.3 s before; 1.49 M steps instead of 0.57 M); N = 32 9.9 s (7.9 s). Signals (`signals.ts`): velocity (default), displacement or acceleration at x (m from the left support, default 1.0 m; linear between the two nearest masses, a support counting as 0; `signalAt`; exactly mass 2 at N = 8), or the sum of accelerations (the net force of the supports). Replay, Save WAV (named e.g. beam-clamped-local-N8-mass2-2.51J-v2-100Hz.wav), spectrogram over the whole note with dashed lines at the mode pitches. Chips: Spectrogram (on; frequency axis logarithmic by default, from a round number at or below half the lowest mode pitch, with a "log f" chip to switch to linear from 0; each pixel row shows the loudest FFT bin in its band; mode lines each drawn only while at least 6 px from their neighbours: at the default note the six lowest are drawn, modes 7 and 8 are 2 px apart; on the old linear axis to 10 kHz the modes filled the bottom eighth), Modes (off; the note's mode-energy shares over the whole note, one column per 256 samples, from the worker; `ModeHistory` in `../common/modehistory.ts`), Lyapunov (on; the note's exponent over the last 10 s of physical time, from a shadow run in the worker; the first 5 s of physical time left out of the y range; the shadow runs only while this chart or λ vs E is shown, as it doubles the cost) Energy (log) and λ vs E (off; `../common/scatter.ts` as in fput-chain: one point per 256 samples, accumulated over notes, log energy axis, colored by time into the note, earlier notes faded, hover for values, Clear; not cleared when parameters change). All but λ vs E span the whole note and share one cursor (playhead while playing; hover, click to pin). The render loop is shared with fput-chain (`../common/note-render.ts`). The status line gives the render time (default note 1.0 to 1.4 s with the shadow, 0.6 s without).
88
99 Live site: https://chaos-apps--listening-for-chaos.vault1-sites.magland.org/beam/ (part of the chaos-apps site).
1010
1111 ## Model
1212
1313 - SI units. Defaults m = 1 kg, k = 0, κ = 20 N/m, k3 = 4000 N/m^3, a = 0.5 m (drawing only), τ = 540 s, local stretching, clamped ends.
14-- Resolution: the Parameters values are for the beam with 8 masses (REF_N); Masses (2 to 64) refines the same beam (`refine` in `beam.ts`): same length, rise, drag, strike energies; with spacing ratio r = span(8) / span(N) (span N + 1 spacings between two supports, N for a cantilever) m x r, k / r, κ / r^3, k3 / r^3, a x r, and the loss dashpots / r (τ top is converted so a wave of given length decays alike at any N). N = 8 returns the parameters unchanged (checked, all ends). The derived values show the scaled constants when N is not 8. Before this (to 2026-10-09) more masses made the beam longer at fixed spacing: mode 1 fell as ~1/N^2 (0.211 Hz at N = 8, 0.015 Hz at 32), so at fixed τ the note shrank (7.9 s at N = 8, 0.6 s at 32, 100 Hz pitch). Now (clamped, local, τ 540, τ top 50): f1 0.207 / 0.211 / 0.206 / 0.202 / 0.200 Hz at N = 4 / 8 / 16 / 32 / 64; f2/f1 2.24 / 2.59 / 2.71 / 2.75 / 2.754 and f3/f1 3.37 / 4.68 / 5.20 / 5.35 / 5.39 (beam theory 2.757, 5.404); pinned 3.998, 8.986 at 64 (4, 9); cantilever 6.265, 17.53 (6.27, 17.55); mode-1 decay ~375 s, mode 3 ~122 s; nonlinear scale 176 / 167 / 161 / 158 mJ at 8 / 16 / 32 / 64; default note 7.9 / 7.7 / 7.5 s. Cost: the step falls ~1/N^2 (0.58 ms at 8, 0.087 ms at 32 for 2.51 J), so the default note (with the Lyapunov shadow) took ~1 s to compute at N = 8, 8.2 s at 16 and 55 s at 32 in the pane at step 0.03; at 0.2, 0.3 s, 1.5 s and 7.9 s. The strike and listening masses are indices, so at larger N "mass 2" is a narrower blow nearer the end.
14+- Resolution: the Parameters values are for the beam with 8 masses (REF_N); Masses (2 to 64) refines the same beam (`refine` in `beam.ts`): same length, rise, drag, strike energies; with spacing ratio r = span(8) / span(N) (span N + 1 spacings between two supports, N for a cantilever) m x r, k / r, κ / r^3, k3 / r^3, a x r, and the loss dashpots / r (τ top is converted so a wave of given length decays alike at any N). N = 8 returns the parameters unchanged (checked, all ends). The derived values show the scaled constants when N is not 8. Before this (to 2026-10-09) more masses made the beam longer at fixed spacing: mode 1 fell as ~1/N^2 (0.211 Hz at N = 8, 0.015 Hz at 32), so at fixed τ the note shrank (7.9 s at N = 8, 0.6 s at 32, 100 Hz pitch). Now (clamped, local, τ 540, τ top 50): f1 0.207 / 0.211 / 0.206 / 0.202 / 0.200 Hz at N = 4 / 8 / 16 / 32 / 64; f2/f1 2.24 / 2.59 / 2.71 / 2.75 / 2.754 and f3/f1 3.37 / 4.68 / 5.20 / 5.35 / 5.39 (beam theory 2.757, 5.404); pinned 3.998, 8.986 at 64 (4, 9); cantilever 6.265, 17.53 (6.27, 17.55); mode-1 decay ~375 s, mode 3 ~122 s; nonlinear scale 176 / 167 / 161 / 158 mJ at 8 / 16 / 32 / 64; default note 7.9 / 7.7 / 7.5 s. Cost: the step falls ~1/N^2 (0.58 ms at 8, 0.087 ms at 32 for 2.51 J), so the default note (with the Lyapunov shadow) took ~1 s to compute at N = 8, 8.2 s at 16 and 55 s at 32 in the pane at step 0.03; at 0.2, 0.3 s, 1.5 s and 7.9 s. Strike and listening are at positions (m), so they mean the same at any N. Default blow at 1.0 m, 2.51 J, clamped (mode-energy shares just after the strike, modes 1 / 2 / 3, and above mode 8; λ over 0 to 300 s with τ 540 s): N = 8 5.1 / 18.8 / 26.2 %, 0 %, λ 0.13 to 0.15 /s; N = 16 8.4 / 26.5 / 30.8 %, 2.7 %, 0.12 to 0.13; N = 32 8.4 / 26.2 / 30.6 %, 2.9 %, 0.068; N = 64 8.7 / 26.8 / 30.3 %, 2.8 %, 0.071. N = 8 differs because the bump covers a single mass there. The old blow on mass 2 (to 2026-10-09) moved up the spectrum with N: above mode 8 38 % at N = 16, 89 % at 32, 99 % at 64; λ 0.09, 0.024, 0.011.
1515 - Masses on rails: only sideways motion u_j, so every axial force is taken by the rails and tension and stretching are defined at a free end too.
1616 - Potential: springs between neighbours (and to each support), k d^2 / 2 with d = u_j - u_{j-1}; bending κ c^2 / 2 at each mass with both neighbours, c = u_{j-1} - 2 u_j + u_{j+1} (supports at u = 0). Pinned support: no extra term (ghost u_{-2} = -u_0 gives c = 0 there). Clamped: a joint at the support with ghost u_{-2} = u_0, c = 2 u_0, energy 2 κ u_0^2. Free end: no spring or joint beyond the last mass.
1717 - Stretching: "local", k3 d^4 / 4 per spring (FPUT); or "Kirchhoff", k3 S^2 / (4 nS) with S the sum of d^2 over the nS springs, which acts as one tension k3 S / nS shared by all springs. They agree when all springs are stretched alike.
src/beam/beam.tsmodified+24−4View file
@@ -442,9 +442,29 @@ export function strikeMode(P: Params, modes: Modes, n: number, E: number): State
442442 return s;
443443 }
444444
445-// Velocity on mass j (1..N) alone: a hammer blow to one mass.
446-export function strikeMass(P: Params, j: number, E: number): State {
447- const s = newState(P.N);
448- s.p[j - 1] = Math.sqrt(2 * P.m * E);
445+// Positions along the beam (m from the left support): mass j (0-based) is
446+// at (j + 1) a; the beam's length is its span in spacings times a.
447+export const massPosition = (P: Params, j: number) => (j + 1) * P.a;
448+export const beamLength = (P: Params) => (freeRight(P) ? P.N : P.N + 1) * P.a;
449+
450+// A hammer blow at x (m) of half-width w (m, at least one spacing): velocity
451+// in a triangular bump, 1 - |x_j - x| / w on the masses it covers, scaled
452+// to give energy E. The same blow at any N (refine) when w is fixed: with
453+// w the spacing at REF_N and x on a mass there, only that mass moves at REF_N.
454+export function strikeAt(P: Params, x: number, w: number, E: number): State {
455+ const s = newState(P.N), we = Math.max(w, P.a), phi = new Float64Array(P.N);
456+ let S = 0;
457+ for (let j = 0; j < P.N; j++) {
458+ phi[j] = Math.max(0, 1 - Math.abs(massPosition(P, j) - x) / we);
459+ S += phi[j] * phi[j];
460+ }
461+ if (S === 0) {
462+ // x beyond the masses' reach (at a support): the nearest mass.
463+ const j = Math.min(P.N - 1, Math.max(0, Math.round(x / P.a) - 1));
464+ phi[j] = 1;
465+ S = 1;
466+ }
467+ const scale = Math.sqrt((2 * P.m * E) / S);
468+ for (let j = 0; j < P.N; j++) s.p[j] = phi[j] * scale;
449469 return s;
450470 }
src/beam/index.htmlmodified+6−5View file
@@ -16,9 +16,10 @@
1616 <button id="strike" class="primary">Strike</button>
1717 <select id="strike-kind">
1818 <option value="mode">mode</option>
19- <option value="mass" selected>mass</option>
19+ <option value="at" selected>at</option>
2020 </select>
21- <input id="strike-index" type="number" min="1" value="2" step="1" />
21+ <input id="strike-index" type="number" min="1" value="2" step="1" hidden />
22+ <span id="strike-at"><input id="strike-x" type="number" min="0" value="1" step="0.05" /> <span class="unit">m</span></span>
2223 <button id="pause" disabled>Pause</button>
2324 <button type="button" class="help" data-help="beam" title="About the beam" aria-label="About the beam">?</button>
2425 </div>
@@ -102,7 +103,7 @@
102103 <div class="row">
103104 <button id="mute" type="button" class="mute" title="Unmute" aria-label="Unmute"></button>
104105 <label><span class="label">Signal</span> <select id="signal"></select></label>
105- <input id="sound-mass" type="number" min="1" step="1" value="2" aria-label="Mass" />
106+ <span id="sound-at"><input id="sound-x" type="number" min="0" step="0.05" value="1" aria-label="Position (m)" /> <span class="unit">m</span></span>
106107 <label><span class="label">Volume</span> <input id="volume" type="range" min="0" max="1" step="0.01" value="0.5" /></label>
107108 <button id="replay" type="button" disabled title="Play the last strike again with the current settings">Replay</button>
108109 <button id="save-wav" type="button" disabled title="Save the last note as a WAV file">Save WAV</button>
@@ -135,7 +136,7 @@
135136
136137 <template id="help-beam">
137138 <h2>The beam</h2>
138- <p>Equal masses on vertical rails, moving sideways only, shown in real time (displacements scaled to fit). Neighbours are joined by springs (tension k), and each mass resists bending (κ, on how far it sits off the line through its neighbours). The springs also stiffen as they stretch (k3): this is the nonlinearity. Strike gives one mass, or one normal mode, the energy on the slider.</p>
139+ <p>Equal masses on vertical rails, moving sideways only, shown in real time (displacements scaled to fit). Neighbours are joined by springs (tension k), and each mass resists bending (κ, on how far it sits off the line through its neighbours). The springs also stiffen as they stretch (k3): this is the nonlinearity. Strike gives the energy on the slider as a hammer blow at a point (m from the left support; a short triangular bump of velocity, one spacing of the 8-mass beam wide on each side, so the blow is the same at any number of masses), or in the shape of one normal mode.</p>
139140 <p>The ends are clamped (fixed, and held level), pinned (fixed, free to turn), or a cantilever: clamped at the left and free at the right. Bending makes the overtones inharmonic, as in a bar or a cymbal. Weak strikes ring as independent normal modes; strong ones trade energy between them. Ghost adds a second beam started a tiny distance away, drawn in orange: in chaotic motion the two separate exponentially. The space bar pauses.</p>
140141 </template>
141142 <template id="help-params">
@@ -153,7 +154,7 @@
153154 <template id="help-sound">
154155 <h2>Sped up · listen</h2>
155156 <p>A copy of the same strike, run fast enough to be heard: Pitch sets where the lowest normal mode lands, and the whole decay plays in a few seconds. While this panel is open each Strike plays a note; Replay plays the last one again with the current settings.</p>
156- <p>Save WAV saves the last note as heard (16-bit mono, normalized). Signal chooses what is heard. The charts span the whole note: the spectrogram, with dashed lines at the normal-mode pitches, the Lyapunov exponent over the last 10 s of the beam's own time, the energy, and Modes, each mode's share of the energy as a heat map (mode 1 at the bottom). λ vs E collects the exponent against the energy over notes, earlier notes faded (Clear starts over). Notes are anti-alias filtered (motion above 20 kHz at 48 kHz is removed rather than folded back); a note says when the top modes are above that and so are not heard.</p>
157+ <p>Save WAV saves the last note as heard (16-bit mono, normalized). Signal chooses what is heard, at a point along the beam (m from the left support, between masses by interpolation). The charts span the whole note: the spectrogram, with dashed lines at the normal-mode pitches, the Lyapunov exponent over the last 10 s of the beam's own time, the energy, and Modes, each mode's share of the energy as a heat map (mode 1 at the bottom). λ vs E collects the exponent against the energy over notes, earlier notes faded (Clear starts over). Notes are anti-alias filtered (motion above 20 kHz at 48 kHz is removed rather than folded back); a note says when the top modes are above that and so are not heard.</p>
157158 </template>
158159 <script type="module" src="./main.ts"></script>
159160 </body>
src/beam/main.tsmodified+32−14View file
@@ -1,7 +1,7 @@
11 import "../common/style.css";
22 import "./style.css";
33 import {
4- Modes, REF_N, Verlet, copyState, decayTimes, energy, isDamped, newState, nonlinearScale, refine, restShape, stepFor, strikeMass, strikeMode,
4+ Modes, REF_N, Verlet, beamLength, copyState, decayTimes, energy, isDamped, newState, nonlinearScale, refine, restShape, stepFor, strikeAt, strikeMode,
55 type Ends, type Params, type State, type Stretch,
66 } from "./beam";
77 import { fit, indexColor } from "../common/canvas";
@@ -33,6 +33,8 @@ const nInput = document.getElementById("n") as HTMLInputElement;
3333 const endsSelect = document.getElementById("ends") as HTMLSelectElement;
3434 const kindSelect = document.getElementById("strike-kind") as HTMLSelectElement;
3535 const indexInput = document.getElementById("strike-index") as HTMLInputElement;
36+const strikeAtEl = document.getElementById("strike-at")!;
37+const strikeXInput = document.getElementById("strike-x") as HTMLInputElement;
3638 const speedSelect = document.getElementById("speed") as HTMLSelectElement;
3739 const pauseButton = document.getElementById("pause") as HTMLButtonElement;
3840
@@ -80,15 +82,28 @@ document.addEventListener("keydown", (e) => {
8082 });
8183
8284 const strikeIndex = () => Math.min(P.N, Math.max(1, Math.round(Number(indexInput.value) || 1)));
85+// A position along the beam (m from the left support), kept on the beam.
86+const position = (el: HTMLInputElement) => Math.min(beamLength(P), Math.max(0, Number(el.value) || 0));
87+// Strike in a mode (index) or at a point (position).
88+const showStrikeKind = () => {
89+ const mode = kindSelect.value === "mode";
90+ indexInput.hidden = !mode;
91+ strikeAtEl.hidden = mode;
92+};
93+kindSelect.addEventListener("change", showStrikeKind);
94+showStrikeKind();
8395
8496 // label names the strike in saved files.
8597 let strikeLabel = "";
8698 document.getElementById("strike")!.addEventListener("click", () => {
8799 const E = strikeEnergy();
88100 indexInput.value = String(strikeIndex());
101+ strikeXInput.value = String(position(strikeXInput));
89102 const mode = kindSelect.value === "mode";
90- strikeLabel = `${mode ? "mode" : "mass"}${strikeIndex()}`;
91- s = mode ? strikeMode(P, modes, strikeIndex(), E) : strikeMass(P, strikeIndex(), E);
103+ strikeLabel = mode ? `mode${strikeIndex()}` : `x${position(strikeXInput)}m`;
104+ // The hammer's half-width is one spacing of the beam at REF_N masses, so
105+ // the blow is the same at any N (at REF_N, on a mass, only that mass moves).
106+ s = mode ? strikeMode(P, modes, strikeIndex(), E) : strikeAt(P, position(strikeXInput), base.a, E);
92107 lastStrike = copyState(s);
93108 verlet = new Verlet(P, s);
94109 E0 = energy(P, s);
@@ -257,7 +272,8 @@ const soundPanel = document.getElementById("sound") as HTMLDetailsElement;
257272 const replayButton = document.getElementById("replay") as HTMLButtonElement;
258273 const saveButton = document.getElementById("save-wav") as HTMLButtonElement;
259274 const signalSelect = document.getElementById("signal") as HTMLSelectElement;
260-const soundMass = document.getElementById("sound-mass") as HTMLInputElement;
275+const soundAt = document.getElementById("sound-at")!;
276+const soundX = document.getElementById("sound-x") as HTMLInputElement;
261277 const pitchInput = document.getElementById("pitch") as HTMLInputElement;
262278 const soundStatus = document.getElementById("sound-status")!;
263279 const soundHint = document.getElementById("sound-hint")!;
@@ -265,9 +281,9 @@ const soundClock = document.getElementById("sound-clock")!;
265281 const aliasWarning = document.getElementById("alias")!;
266282 for (const sig of SIGNALS) signalSelect.add(new Option(sig.label, sig.key));
267283 signalSelect.value = "v";
268-const showSoundMass = () => (soundMass.hidden = !signalByKey(signalSelect.value).perMass);
269-signalSelect.addEventListener("change", showSoundMass);
270-showSoundMass();
284+const showSoundAt = () => (soundAt.hidden = !signalByKey(signalSelect.value).perMass);
285+signalSelect.addEventListener("change", showSoundAt);
286+showSoundAt();
271287
272288 const player = new Player();
273289 setupMute(document.getElementById("mute") as HTMLButtonElement, player);
@@ -387,19 +403,19 @@ function renderNote() {
387403 player.setPaused(!running); // resumes the audio clock within the click
388404 showPitch(); // the sample rate is known now
389405 const sig = signalByKey(signalSelect.value);
390- const j = Math.min(P.N, Math.max(1, Math.round(Number(soundMass.value) || 1)));
391- soundMass.value = String(j);
406+ const x = position(soundX);
407+ soundX.value = String(x);
392408 const req: RenderRequest = {
393409 id: ++requestId, P, state: lastStrike, speedup: speedup(), sampleRate: ctx.sampleRate,
394- maxLength: 2 * predictedNoteLength(), signal: sig.key, j: j - 1,
410+ maxLength: 2 * predictedNoteLength(), signal: sig.key, x,
395411 // The note's Lyapunov shadow doubles the computation, so it runs only
396412 // when a display needs it (Lyapunov chart or λ vs E).
397413 lyapunov: soundTraces.shownLabels().includes("Lyapunov") || !scatterFig.hidden,
398414 };
399- noteInfo = `${sig.label}${sig.perMass ? ` ${j}` : ""}`;
415+ noteInfo = `${sig.label}${sig.perMass ? ` ${x} m` : ""}`;
400416 noteFile =
401417 `beam-${P.ends}-${P.stretch}-N${P.N}-${strikeLabel}-${energy(P, lastStrike).toPrecision(3)}J-` +
402- `${sig.key}${sig.perMass ? j : ""}-${pitch().toFixed(0)}Hz.wav`;
418+ `${sig.key}${sig.perMass ? `${x}m` : ""}-${pitch().toFixed(0)}Hz.wav`;
403419 soundStatus.textContent = "computing…";
404420 soundHint.hidden = true;
405421 player.stop();
@@ -464,8 +480,10 @@ function setParams(next: Params, N: number) {
464480 saveButton.disabled = true;
465481 note = null;
466482 player.stop();
467- soundMass.max = String(P.N);
468- soundMass.value = String(Math.min(P.N, Number(soundMass.value) || 1));
483+ for (const el of [strikeXInput, soundX]) {
484+ el.max = String(beamLength(P));
485+ el.value = String(position(el));
486+ }
469487 showPitch();
470488 showParams();
471489 }
src/beam/render-worker.tsmodified+3−3View file
@@ -5,7 +5,7 @@
55 import { Modes, Verlet, copyState, energy, isDamped, stepFor, type Params, type State } from "./beam";
66 import { renderNote, type Note } from "../common/note-render";
77 import { shadowOf } from "./lyapunov";
8-import { signalByKey } from "./signals";
8+import { signalAt, signalByKey } from "./signals";
99
1010 export interface RenderRequest {
1111 id: number;
@@ -15,7 +15,7 @@ export interface RenderRequest {
1515 sampleRate: number;
1616 maxLength: number; // s of sound, at most
1717 signal: string; // signal key
18- j: number; // mass index (0-based) for per-mass signals
18+ x: number; // m from the left support, for point signals
1919 lyapunov: boolean; // run the Lyapunov shadow
2020 }
2121
@@ -38,7 +38,7 @@ self.onmessage = (e: MessageEvent<RenderRequest>) => {
3838 energy: () => energy(P, s),
3939 stepFor: (E) => stepFor(P, modes, E),
4040 step: (h) => verlet.step(s, h),
41- signal: () => signal.value(P, s, verlet.force, r.j),
41+ signal: () => signalAt(signal, P, s, verlet.force, r.x),
4242 shadow: () => shadowOf(P, modes, s),
4343 diag: () => {
4444 modes.energies(s, Ek);
src/beam/signals.tsmodified+19−5View file
@@ -1,21 +1,23 @@
11 // Quantities that can be heard. In a single mass's motion, mode n appears
22 // with weight sqrt(E_n) / omega_n (displacement), sqrt(E_n) (velocity) or
33 // sqrt(E_n) omega_n (acceleration), times the mode shape at that mass.
4+// Point signals are read at a position x along the beam (signalAt), so the
5+// same listening point serves every N.
46
5-import type { Params, State } from "./beam";
7+import { beamLength, type Params, type State } from "./beam";
68
79 export interface Signal {
810 key: string;
911 label: string;
10- perMass: boolean; // takes a mass index j
12+ perMass: boolean; // read at a point (a mass index j; see signalAt)
1113 // Value now; F is the force on each mass at the current displacements.
1214 value: (P: Params, s: State, F: Float64Array, j: number) => number;
1315 }
1416
1517 export const SIGNALS: Signal[] = [
16- { key: "v", label: "velocity of mass", perMass: true, value: (P, s, _F, j) => s.p[j] / P.m },
17- { key: "u", label: "displacement of mass", perMass: true, value: (_P, s, _F, j) => s.u[j] },
18- { key: "a", label: "acceleration of mass", perMass: true, value: (P, _s, F, j) => F[j] / P.m },
18+ { key: "v", label: "velocity at", perMass: true, value: (P, s, _F, j) => s.p[j] / P.m },
19+ { key: "u", label: "displacement at", perMass: true, value: (_P, s, _F, j) => s.u[j] },
20+ { key: "a", label: "acceleration at", perMass: true, value: (P, _s, F, j) => F[j] / P.m },
1921 {
2022 // The forces between masses cancel in the sum, so this is the net force
2123 // of the supports on the beam (for a cantilever, the clamp's): what a
@@ -30,3 +32,15 @@ export const SIGNALS: Signal[] = [
3032 ];
3133
3234 export const signalByKey = (key: string) => SIGNALS.find((s) => s.key === key) ?? SIGNALS[0];
35+
36+// A signal at x (m from the left support), linear between the two nearest
37+// masses; a support counts as 0 (it does not move), and past the last mass
38+// of a cantilever the last mass's value holds. Exactly the mass's value when
39+// x is on a mass.
40+export function signalAt(sig: Signal, P: Params, s: State, F: Float64Array, x: number): number {
41+ if (!sig.perMass) return sig.value(P, s, F, 0);
42+ const at = (j: number) => (j < 0 ? 0 : j >= P.N ? (P.ends === "cantilever" ? sig.value(P, s, F, P.N - 1) : 0) : sig.value(P, s, F, j));
43+ const f = Math.min(Math.max(x, 0), beamLength(P)) / P.a - 1; // position in mass indices
44+ const j0 = Math.floor(f), frac = f - j0;
45+ return frac === 0 ? at(j0) : (1 - frac) * at(j0) + frac * at(j0 + 1);
46+}