diff --git a/CHANGELOG.md b/CHANGELOG.md index bb8424ae..387bdc67 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,19 @@ GitHub are its section here (`tools/release.sh` copies them), so add to ## Unreleased +- **Hammerwork Grand, a physically modeled piano.** Felt hammers strike up + to three detuned strings per key, which ring into a soundboard through + a moving bridge. Velocity changes the tone the way a real hammer does, + bass and treble keep their own inharmonicity and decay, and the + unisons give the piano's two-stage decay. PEDAL lifts the dampers on + held notes too. Six presets. See docs/41. + +- **Skinwork Kit, a physically modeled drum kit.** Kick, snare with + rattling wires, side stick, three toms, closed, pedal and open hats, + crash, ride and ride bell on the GM drum map. Toms and kick drop in + pitch when hit hard, cymbals bloom into a wash, and a closed hat chokes + an open one. Five presets. See docs/41. + - **Separate STOP and PLAY.** Pressing STOP again while stopped returns to the loop's start, or to the song's start with the loop off. diff --git a/bench/golden/grand.txt b/bench/golden/grand.txt new file mode 100644 index 00000000..c7acb1c3 --- /dev/null +++ b/bench/golden/grand.txt @@ -0,0 +1,6 @@ +# bench goldens for grand: case sha256(f32 L ++ f32 R) [nonharm_db thd_db] +notes 761e28dacea9ae368aa89ba9a1368ac6390492cd74a218da1de21e76d5645d44 +velocity aa6f16e703000592e2740b47d48208dd2b0bfabaed8d9081637a271bf3347218 +held 007e11ce89fde6e29f858b9de604174aae2df4552cacf9614e4ab16387b1bab8 -23.2 2.8 +high af415e8ec0017c9614e979c29044f8ff6d6ab4d51e1871286140ef345f4e61a5 -43.9 -18.5 +chord d5d017df9abba1412baa474df8d47366015f4b16bc7e0aea26801dcd9ae622a0 -0.3 4.0 diff --git a/bench/golden/kit.txt b/bench/golden/kit.txt new file mode 100644 index 00000000..f53675ba --- /dev/null +++ b/bench/golden/kit.txt @@ -0,0 +1,2 @@ +# bench goldens for kit: case sha256(f32 L ++ f32 R) [nonharm_db thd_db] +hits 7deadce04bd5313d21b102b1bc471057214b193fc9aac810353e65128ec5c598 diff --git a/docs/02-machines.md b/docs/02-machines.md index 33a10f8f..96e05ab9 100644 --- a/docs/02-machines.md +++ b/docs/02-machines.md @@ -128,6 +128,11 @@ The two modes: `note-expr!` and `control!` hook the voice lifecycle. Pools with more than one voice render voices in pairs, one per lane. +A state region holds up to 2 KB inline. A machine whose `state-size!` is +larger, up to 32 KB, gets its regions from the heap when it is created; +the piano's and the kit's mode banks do (docs/41). Params are capped at +4 KB. + The entry words and their stack effects are in docs/04 §Kernel ABI. `prepare!` runs every block, not once. Other declarations: diff --git a/docs/04-block-contract.md b/docs/04-block-contract.md index bf38b427..0e742598 100644 --- a/docs/04-block-contract.md +++ b/docs/04-block-contract.md @@ -471,6 +471,43 @@ is empty before its machine sleeps and delays correctly when it wakes. `Track.inst_quiet`, `Effect.quiet`, and samples of silence (saturating). Replacing a machine resets its counter. +## Shared state (implemented, 2026-10-16) + +A voice machine can keep one region for the whole machine, next to its +per-voice state: a body every voice acts on, such as a piano's +soundboard, a kit's shared shell or a resonator all notes ring into. The +host knows nothing of what the region holds. It allocates the bytes, +zeroes them, passes the pointer, and orders the calls. + +- `bytes shared-size!` asks for the region (up to 1 MiB, + `MAX_SHARED`). It is zeroed at load and on reset. Every entry word, + note hooks, block-prepare, control and render included, finds it at + `ctx.shared` (`ctx.shared& p@64 | body:MyBody |`). +- `"word" period shared!` names the region's render word, + `( io ctx shared params -- )`. The host renders the sounding voices + for `period` samples, then this word over the same samples, and + repeats. With a period of 1 a voice reads, each sample, what the shared + word left after the previous one, and the shared word sees everything + the voices wrote during that sample. Longer periods cost fewer calls + and delay the coupling by up to that many samples. During the call + `ctx.clock` is the step's first sample, so the word can branch on it + (an `ifte` on the clock is versioned: the arm not taken costs nothing). +- `offset voice-level!` names a voice state field that reports the + voice's level for idle skipping, for a voice heard only through the + shared word. A released voice sleeps once both its own output and that + field stay under `IDLE_FLOOR` for a block. +- Voices of a machine with a shared region render one at a time, in + voice order, never in lane pairs, so their writes into the region land + in a fixed order and renders stay bit-exact at any thread count. The + convention for writing is to add into fields the shared word clears. +- The shared word accumulates into io like a voice. It wakes on any + note-on and runs while a voice sounds; once none does, it sleeps after + a block whose own output stayed under `IDLE_FLOOR`, until the next + note-on. Its output counts as the machine's for idle skipping. + +The fixture `machines/raw_fixtures/shared_body.fy` and the test "shared +state: voices and the shared word alternate per step" show the order. + ## Why one ctx pointer and not varargs - Stable ABI surface. Adding a field at the end (in `_reserved`) diff --git a/docs/13-dsp-workbench.md b/docs/13-dsp-workbench.md index b4a9fa31..e6098b28 100644 --- a/docs/13-dsp-workbench.md +++ b/docs/13-dsp-workbench.md @@ -159,6 +159,10 @@ kernels/ 08-machines/ ``` +In the tree today, `kernels/10-physical/` holds the physical-modeling +layer: the exact modal step, the implicit striker contact and the +membrane's Bessel modes (docs/41). + Current first fixture: ``` diff --git a/docs/20-machine-reference.md b/docs/20-machine-reference.md index 69762b99..2232f313 100644 --- a/docs/20-machine-reference.md +++ b/docs/20-machine-reference.md @@ -14,9 +14,11 @@ project or preset stores (docs/19): real units for knobs, the option | [`cs80`](#cs80) | C-Beam 80 | instrument | 8 | `—` | | [`drum2`](#drum2) | DS-404 Drums | instrument | mono | `—` | | [`fm86`](#fm86) | FM-7.11 | instrument | 8 | `—` | +| [`grand`](#grand) | Hammerwork Grand | instrument | 16 | `gp-` | | [`guitar`](#guitar) | Fretwire Guitar | instrument | 6 | `gt-` | | [`jp8`](#jp8) | Juniper-8 | instrument | 8 | `jp-` | | [`juno2`](#juno2) | Ju-Know | instrument | 8 | `jn-` | +| [`kit`](#kit) | Skinwork Kit | instrument | 12 | `sk-` | | [`ms20`](#ms20) | SM-24 Mono | instrument | mono | `—` | | [`profit5`](#profit5) | Profit-5 | instrument | 8 | `p5-` | | [`rhodes`](#rhodes) | Rhodes E-Piano | instrument | 8 | `rd-` | @@ -626,6 +628,34 @@ Presets: - `slab/tubular-bells` — Slab: tubular bells. A clangorous strike that settles into a long, slightly sour hum while the key is held. - `slab/vibes` — Slab: vibraphone. A bright mallet bar with a ringing high partial, a tink on the strike and a steady motor tremolo. +## grand + +**Hammerwork Grand**, instrument, 16 voices. + +| param | section | label | range | default | notes | +|---|---|---|---|---|---| +| `gp-hard` | HAMMER | HARD | -1 … 1 | -0.6 | linear | +| `gp-felt` | HAMMER | FELT | 0 … 1 | 0.35 | linear | +| `gp-strike` | HAMMER | STRIKE | -1 … 1 | 0 | linear | +| `gp-decay` | STRING | DECAY | 0.25 … 4 | 1.5 | exp | +| `gp-hfdec` | STRING | HF DEC | 0.2 … 8 | 3 | exp | +| `gp-stiff` | STRING | STIFF | 0 … 3 | 1 | linear | +| `gp-unison` | STRING | UNISON | 0 … 6 | 1.2 | linear | +| `gp-board` | BOARD | COUPLE | 0.1 … 2 | 1 | exp | +| `gp-width` | BOARD | WIDTH | 0 … 1 | 0.7 | linear | +| `gp-release` | DAMPER | RELEASE | 0.05 … 2 | 0.25 | exp | +| `gp-pedal` | DAMPER | PEDAL | 0 … 1 | 0 | linear | +| `gp-level` | DAMPER | LEVEL | 0 … 1 | 0.7 | pow | + +Presets: + +- `bright-pop` — Hard-voiced hammers and a lively board: cuts through a band mix +- `default` — Concert grand at its own voicing: medium felt, long sustain +- `dry-studio` — Short, focused and close: dampers quick, sustain trimmed +- `felt-ballad` — Soft felt and a dark top, dampers half lifted: intimate and round +- `honky-tonk` — Unisons pulled apart and a bright hammer: the saloon upright +- `sustain-wash` — Pedal down: every note rings into the next, wide board + ## guitar **Fretwire Guitar**, instrument, 6 voices. @@ -838,6 +868,46 @@ Presets: - `wide-sunny-pad` — Wide sunny melancholic pad - `afterimage/horizon` — Slow detuned pad without sub, with two-voice stereo unison; Afterimage Express. Upper chord voicings leave space for bass. +## kit + +**Skinwork Kit**, instrument, 12 voices. +Drum notes: kick = 36, snare = 38, side = 37, lt = 41, mt = 45, ht = 48, ch = 42, ph = 44, oh = 46, crash = 49, ride = 51, bell = 53. + +| param | section | label | range | default | notes | +|---|---|---|---|---|---| +| `sk-kick-tune` | KICK | TUNE | -12 … 12 | 0 | linear | +| `sk-kick-decay` | KICK | DECAY | 0.25 … 4 | 1 | exp | +| `sk-kick-level` | KICK | LEVEL | 0 … 1 | 0.8 | pow | +| `sk-snare-tune` | SNARE | TUNE | -12 … 12 | 0 | linear | +| `sk-snare-decay` | SNARE | DECAY | 0.25 … 4 | 1 | exp | +| `sk-snare-level` | SNARE | LEVEL | 0 … 1 | 0.8 | pow | +| `sk-tom-tune` | TOMS | TUNE | -12 … 12 | 0 | linear | +| `sk-tom-decay` | TOMS | DECAY | 0.25 … 4 | 1 | exp | +| `sk-tom-level` | TOMS | LEVEL | 0 … 1 | 0.8 | pow | +| `sk-hat-tune` | HATS | TUNE | -12 … 12 | 0 | linear | +| `sk-hat-decay` | HATS | DECAY | 0.25 … 4 | 1 | exp | +| `sk-hat-level` | HATS | LEVEL | 0 … 1 | 0.7 | pow | +| `sk-crash-tune` | CRASH | TUNE | -12 … 12 | 0 | linear | +| `sk-crash-decay` | CRASH | DECAY | 0.25 … 4 | 1 | exp | +| `sk-crash-level` | CRASH | LEVEL | 0 … 1 | 0.7 | pow | +| `sk-ride-tune` | RIDE | TUNE | -12 … 12 | 0 | linear | +| `sk-ride-decay` | RIDE | DECAY | 0.25 … 4 | 1 | exp | +| `sk-ride-level` | RIDE | LEVEL | 0 … 1 | 0.7 | pow | +| `sk-stick` | PLAY | STICK | -1 … 1 | 0 | linear | +| `sk-wires` | PLAY | WIRES | 0 … 1 | 0.7 | linear | +| `sk-glide` | PLAY | GLIDE | 0 … 2 | 1 | linear | +| `sk-bloom` | PLAY | BLOOM | 0 … 2 | 1 | linear | +| `sk-width` | PLAY | WIDTH | 0 … 1 | 0.6 | linear | +| `sk-level` | PLAY | LEVEL | 0 … 1 | 0.7 | pow | + +Presets: + +- `default` — A studio kit: 18-inch kick, 14-inch snare, three toms, hats, crash and ride +- `funk-dry` — Tight and dead: short heads, crisp snare, closed hats up front +- `gong-wash` — Cymbals left to bloom: heavy coupling and long decays for swells +- `jazz` — Small kit tuned high, light sticks, long washy cymbals, loose wires +- `rock` — Big low tuning, hard sticks, the toms drop in pitch when hit hard + ## ms20 **SM-24 Mono**, instrument, mono. diff --git a/docs/41-physical-models.md b/docs/41-physical-models.md new file mode 100644 index 00000000..da7008f3 --- /dev/null +++ b/docs/41-physical-models.md @@ -0,0 +1,169 @@ +# 41 — Physical models: Hammerwork Grand and Skinwork Kit + +Two machines synthesize their sound by simulating the instrument's +mechanics, sample by sample: + +- `grand` (**Hammerwork Grand**) is a piano: per key, felt hammers strike + stiff strings that drive a soundboard through a moving bridge. +- `kit` (**Skinwork Kit**) is a drum kit: sticks and beaters strike + membranes and cymbals. + +They share a small layer of physical-modeling kernels, +`kernels/10-physical/`. **Status:** built 2026-10-16 on branch +`claude/physical-piano-drums`. + +**Clean-room.** Everything here comes from the published literature +cited inline and from measurements of CC0 recordings. Nothing comes from +another physical-modeling codebase or its data. The scale curves, +hammer and kit constants are literature values or our own estimates, and +each is marked below. + +Code: +- `kernels/10-physical/modal.fy`: exact modal step, T60 → decay rate, + implicit power-law contact. +- `kernels/10-physical/bessel.fy`: the circular membrane's first 40 + modes, generated by `tools/fit/bessel.py`. +- `kernels/06-voices/piano.fy` and `machines/grand/`: the piano. +- `kernels/06-voices/kit.fy` and `machines/kit/`: the kit. + +## The building blocks (`kernels/10-physical`) + +| word | what it is | source | +|---|---|---| +| `zoh-coefs` `modal-step` | one damped mode advanced exactly under a force held for the sample: the undamped closed form plus a decay factor. It is stable at any frequency below Nyquist and keeps the asked-for decay at any pitch. | modal synthesis, Adrien 1991 | +| `t60>sigma` | the decay rate that falls 60 dB in T60 | — | +| `contact-solve` | a striker pressed into a body with `F = K e^p (1 + α ė)`, solved implicitly by four Newton steps on the scalar compliance equation `e = e_free − D F`. It is stable for treble-hammer stiffness, where an explicit force blows up. | Hall 1986, Stulov 1995 (felt); Hunt & Crossley 1975 (damping); Bilbao 2009 ch. 4 | +| `memb-ratio` `memb-m` `memb-shape-at` | circular membrane modes: Bessel-zero ratios j_mn/j_01, nodal diameters, and the radial shape J_m(j_mn r/a) at any radius | Rayleigh; Fletcher & Rossing §3.6 | + +A body is a bank of these modes in fixed-size state arrays, unrolled with +`times`. Each mode's coefficients are built at note-on, so the render only +multiplies and adds. The words take physical units (metres, newtons, +kilograms), so a striker's character comes from its mass and felt law, +not from a filter. + +Other techniques the two voices use, each a candidate for its own kernel +once a third machine needs it: + +- **Moving-bridge coupling.** A modal string whose end is shaken feels + `−c_n b̈` with `c_n = 2(−1)^(n+1)/(nπ)`, and pushes on the bridge with + `Σ (−1)^(n+1) nπT/L q_n`. This is a textbook derivation from the string's + kinetic energy. +- **Resistive dense-mode board.** Above its first modes a board acts like + a resistance at the bridge (an infinite plate's mobility `1/(8√(D m''))`, + Cremer). It is high-passed so the board stays stiff at low frequency. +- **First-order tension modulation.** Each mode's step rotation is bent + by the head's strain energy. The step stays a rotation, so it stays + stable, where an explicit stiffness force diverges on the upper modes + (Berger's approximation; Bilbao 2009 ch. 13). +- **Bounded cubic mode coupling.** One shared coordinate carries a + quartic potential, so the coupling only moves energy between modes. + Its stiffness is capped so the explicit step stays stable. +- **Diffuse-field energy store.** The modes too dense to simulate are one + energy reservoir fed by the discrete modes and radiated as band noise: + statistical energy analysis (Lyon 1975). +- **Lumped collision rattle.** A mass on a spring that collides with a + body through `contact-solve`. Its collision force, times noise, drives a + wide resonant bank. This is the snare wires. + +## Hammerwork Grand + +Per voice (one key), from `piano.fy`: + +| part | model | +|---|---| +| strings | Up to 3 unisons: 1 string below B0, 2 below G#2 [G]. The main string carries 56 modes, the partners 16 each. Above the 16th partial the main string's modes stand for every string of the key. Bass keys stride the main string's upper modes (every 2nd to 4th partial, weighted √stride), so a low A still reaches about 7 kHz. | +| partials | `f_n = n f0 √(1+Bn²)/√(1+B)` (Fletcher 1964). B is lowest near C3 (1e-4) and rises about 3 %/semitone into the treble [G, matched to the Kawai: 2.3e-4 at C4]. | +| scale | length `0.05·2^((108−m)·0.0767)` m, capped at 1.95 [G]. Tension 700 N, rising to 1510 N in the bass [G]. Mass per length follows from f0, so string mass is physical. | +| loss | `σ_n = b1 + b3 ω_n²` (Chaigne & Askenfelt 1994), fitted through the fundamental's T60 `30·2^(−(m−21)/26)` s and HF DEC at 4 kHz [G, fitted to the Kawai]. | +| hammer | Mass, stiffness and exponent interpolate Chaigne & Askenfelt's C2/C4/C7 values: 4.9/3.0/2.2 g; K 1e9/4.5e9/1e11; p 2.3/2.5/3.0. Hysteresis α = 0.6·FELT s/m. Speed is 0.25 m/s at velocity 0 to 6 m/s at 127. | +| strike point | 1/8 of the string in the bass to 1/12.5 at the top [G]. The unison strings meet the hammer ±3 % apart, which fills the strike-point notches. | +| bridge / board | 12 plate modes, 58–985 Hz, ζ 1.6–3.4 % [G]. Each key's bridge point lies on a diagonal across the board. A resistive dense-mode term (2e-4·COUPLE s/kg, high-passed at 220 Hz) is what lets the strings lose energy at a realistic rate and gives the 6 dB/oct radiated rise. The shared bridge couples the unisons: in-phase motion drains fast, out-of-phase motion lingers (Weinreich 1977). | +| output | Board acceleration seen from two microphone points. The dense-mode part pans by key (bass left, treble right). | +| dampers | A released key adds decay to each mode by how far the damper point (strike + 3 %) moves in it. RELEASE sets the fundamental's T60. Keys above E7 (MIDI 88) have no damper. PEDAL lifts all dampers live, for held and released notes, re-blended every 32 samples. | + +**Measured against the CC0 VCSL Kawai grand** (`v3` layer against +velocity 0.7, partials relative to the fundamental over 0.05–0.35 s, +analysis scripts kept in the session scratch): + +| | Kawai C4 | Grand C4 | Kawai C5 | Grand C5 | +|---|---|---|---|---| +| B | 3.0e-4 | 2.3e-4 | 3.1e-4 | 4.8e-4 | +| p2..p6 dB | −2 −19 −13 −9 −26 | 6 −9 −7 −18 −18 | −18 −18 −22 −39 −30 | −7 −20 −24 −29 −29 | +| T60 fundamental | 9.5 s | 5.2 s | 9.7 s | 8.0 s | +| spectral centroid, first 100 ms | 888 Hz | 1238 Hz | 961 Hz | 1384 Hz | + +Velocity runs from a 550 Hz centroid at velocity 0.1 to 2.2 kHz at 1.0, +over 26 dB of level. Against the Kawai, the model's C4 is brighter in its +2nd to 4th partials and decays about half as long. + +**Cost** (ReleaseFast): about 200 ns per ringing voice, 735 ns for the +bench chord. The render word has more stores than lane mode can stash, +so the 16 voices render scalar ("renders without NEON lanes"). Note-on is +a `call:` composition of seven stages, each under the compiler's +pending-store limit. + +**Not modeled:** sympathetic resonance between keys (each voice has its +own board), the duplex scale, longitudinal "phantom" partials, the +second polarization, and key and pedal noise. + +## Skinwork Kit + +Twelve voices, `note-pitch`, on the GM map. Notes outside 35–59 fold +into it by octave. + +| note | piece | body | striker | +|---|---|---|---| +| 35, 36 | kick | membrane, 52 Hz, a 0.229 m head | felt beater, 35 g, p 2.3 | +| 37, 39 | side stick | snare head struck at the rim, light wires | stick across the rim | +| 38, 40 | snare | membrane, 185 Hz, 0.17 m, with wires | hickory stick, 12 g, Hertz p 1.5 | +| 41, 43 | floor tom | membrane, 82 Hz | stick | +| 45, 47 | mid tom | membrane, 118 Hz | stick | +| 48, 50 | high tom | membrane, 165 Hz | stick | +| 42 / 44 / 46 | closed / pedal / open hat | plate, 420 Hz | stick; closed and pedal choke open | +| 49, 52, 55, 57 | crash | plate, 190 Hz | stick | +| 51, 59 | ride | plate, 240 Hz | stick | +| 53 | ride bell | plate, 560 Hz | stick | + +The lower note of each tom pair, and the second kick, sit a little lower +(×0.86). Every size, mass, tuning and T60 is an estimate of a typical kit +[G]. + +- **Heads** use the Bessel ratios, compressed slightly (ratio^0.94) as + air loading does to a real drum. They are struck at a radius where + `J_m(j_mn r)` sets each mode's drive. Loss is `σ = s0 + s1 f²` through + two T60s. +- **Tension modulation.** The gain is `G = 150/c²` per ply (c is the + head's wave speed). That follows from the Mylar's elastic stiffness + against its tension: at 1 mm of excursion a snare head runs 2–3 % + sharp. GLIDE scales it. +- **Strikers.** A hickory stick is a Hertz contact, `K = 4/3 E* √R`: + 4.4e8 on Mylar, 1.2e9 on bronze. The kick beater is felt. The stick's + impact force, high-passed at about 1 kHz, adds the click. +- **Snare wires.** A 4 g lumped mass on a 100 Hz spring, resting a hair + under the head center. WIRES sets the gap. Collisions drive eight wide + buzz modes from 2.1 to 7.6 kHz. +- **Plates.** 40 modes on a stretched, jittered series + `((i + 1.2)/1.2)^1.17`. Strike weights fall as ratio^−0.7. A bounded + cubic coupling (BLOOM) moves energy between them, and a diffuse field + takes over the upper modes' energy as band noise: 1.8–4 kHz high-pass, + 6.5 kHz two-pole low-pass. + +**Cost:** about 530 ns per sample in Debug for the bench hit sequence. +The render word, like the piano's, renders without NEON lanes. + +## Presets + +- `grand`: default, bright-pop, felt-ballad, honky-tonk, sustain-wash, + dry-studio. +- `kit`: default, jazz, rock, funk-dry, gong-wash. + +## Next + +- **Fretwire and Rhodes.** Fretwire could take `contact-solve` for the + pluck and fret buzz, and moving-bridge coupling for its two-stage decay. + Rhodes could become a struck tine-and-tonebar mode pair with an + implicit hammer. +- **Lane mode.** Splitting the render into a lane-eligible word, or + raising the stash limit, would roughly halve the cost. +- **Sympathetic strings.** These need a shared board across voices: a + host-side bus or a single-voice piano. diff --git a/docs/README.md b/docs/README.md index 16b3335d..25925976 100644 --- a/docs/README.md +++ b/docs/README.md @@ -126,6 +126,9 @@ status and the source before assuming a feature is absent or complete. 41. [40-musical-typing.md](40-musical-typing.md) — the computer keyboard as PIANO, GRID (Push-style fourths) or CHORD plus melody, recording into clips, octave echoes, ghost notes and scale marks in the piano roll +42. [41-physical-models.md](41-physical-models.md) — Hammerwork Grand and + Skinwork Kit: modal strings, membranes and plates, implicit hammer and + stick contact, the `kernels/10-physical` building blocks ## Terminology crib sheet diff --git a/kernels/00-primitives/ctx.fy b/kernels/00-primitives/ctx.fy index 4d9cb903..08c23a10 100644 --- a/kernels/00-primitives/ctx.fy +++ b/kernels/00-primitives/ctx.fy @@ -26,7 +26,10 @@ ustruct: Ctx f64 uni ( note-on: unison place in the note's group, -1..1; 0 alone ) f64 phase ( note-on: unison start phase 0..1 for free-running oscillators; 0 alone ) f64 clock ( the sample clock: the block start in prepare / block-prepare / - derive, the event's sample in note hooks ) + derive, the event's sample in note hooks, the step's first + sample in a shared word ) + f64 shared ( pointer: the machine's shared region [shared-size!], or 0; + read with ctx.shared& p@64 ) ; ustruct: Io diff --git a/kernels/06-voices/kit.fy b/kernels/06-voices/kit.fy new file mode 100644 index 00000000..1dfd189f --- /dev/null +++ b/kernels/06-voices/kit.fy @@ -0,0 +1,480 @@ +( kit.fy - a modal physical-model drum kit voice [docs/41]. + + Every hit is a body - a drumhead or a cymbal - rung by a striker. A + voice holds one hit: note-on picks the piece from the note [GM drum + map] and builds its 40 modes; the render runs the same code for all of + them. + + HEADS [kick, snare, toms] are ideal circular membranes + [10-physical/bessel.fy]: modes at the Bessel ratios j_mn/j_01, a little + compressed as air loading does to a real drum, struck at a radius whose + shape value J_m[j_mn r] sets how hard each mode is driven - a center + hit is round, toward the rim the overtones come in. Loss rises with + frequency, sigma = s0 + s1 f^2, fitted through two T60s. Hard hits + stretch the head and raise its tension: the modes run sharp in + proportion to the head's strain energy and glide down as it decays + [tension modulation; Berger's approximation, Bilbao 2009 ch. 13]. The + gain follows from the head's elastic stiffness against its tension. + + CYMBALS AND HATS are plates: 40 inharmonic modes, loss rising with f, + and a quadratic coupling between them through one shared coordinate + [the lowest-order von Karman term], which pumps energy from the struck + low modes into the upper ones - the crash's bloom and wash. + + STRIKERS are point masses meeting the body through the implicit + power-law contact of 10-physical/modal.fy: hickory sticks as a Hertz + contact [p = 1.5, K = 4/3 E* sqrt R], the kick's felt beater softer and + more nonlinear. The stick's impact itself clicks. + + SNARE WIRES are a lumped mass on a spring just under the head's center. + When the head swings into them they collide [the same contact solve], + fly off and fall back, rattling; each collision rings a bank of short + high modes - the wires' buzz - and pushes back on the head. + + A closed or pedal hat chokes any open hat still ringing [through + params.hat-clock]. + + Clean-room: written from the literature cited here; the piece sizes, + tensions and decays are estimates of typical kits, set by ear. ) + +include "../00-primitives/ctx.fy" +include "../00-primitives/math.fy" +include "../04-filters/coeffs.fy" +include "../10-physical/modal.fy" +include "../10-physical/bessel.fy" +include "../00-primitives/rand.fy" + +:: DN 40 ; ( modes per hit ) +:: WN 8 ; ( wire buzz modes ) + +( plate mode ratios: [[i + 1.2]/1.2]^1.17 with a fixed jitter, so no two partials line up, x 1e6 ) +table: cym-ratio 40 + f>i [ 965000 2018090 3216673 4239736 5598953 7069540 8079406 9672488 10611168 12315146 + 14121229 14973928 16882702 17632060 19639425 21747761 22379859 24583770 25094722 27392224 + 29791304 30165907 32657308 32898658 35481277 38166614 38258096 41034146 40983250 43849348 + 46819477 46608319 49668379 49307532 52457109 55712068 55182535 58527107 57841783 61275707 + 61275707 ] swap qnth i>f 0.000001 f* ; +( a fixed weight per plate mode at the strike, x 1e6 ) +table: cym-w 40 + f>i [ 517000 949624 682248 414871 847495 580119 312743 745367 477990 910614 + 643238 375862 808486 541109 973733 706357 438981 871605 604228 336852 + 769476 502100 934724 667347 399971 832595 565219 997843 730466 463090 + 895714 628338 360962 793585 526209 958833 691457 424081 856704 589328 + 589328 ] swap qnth i>f 0.000001 f* ; +( a fixed sign per plate mode: the coupling vector, x 1e6 ) +table: cym-s 40 + f>i [ 1000000 -1000000 -1000000 1000000 1000000 -1000000 -1000000 1000000 1000000 -1000000 + -1000000 1000000 1000000 -1000000 -1000000 1000000 1000000 1000000 -1000000 -1000000 + 1000000 1000000 -1000000 -1000000 1000000 1000000 -1000000 -1000000 1000000 1000000 + -1000000 -1000000 1000000 1000000 -1000000 -1000000 1000000 1000000 -1000000 -1000000 + -1000000 ] swap qnth i>f 0.000001 f* ; +( plate radiation to the left microphone, x 1e6 ) +table: cym-l 40 + f>i [ -140000 999680 139360 -720960 418720 -441600 698080 -162240 977440 117120 + -743200 396480 -463840 675840 -184480 955200 94880 -765440 374240 -486080 + 653600 -206720 932960 72640 -787680 352000 -508320 631360 -228960 910720 + 50400 -809920 329760 -530560 609120 -251200 888480 28160 -832160 307520 + 307520 ] swap qnth i>f 0.000001 f* ; +( plate radiation to the right microphone, x 1e6 ) +table: cym-r 40 + f>i [ -780000 884906 549812 214718 -120376 -455470 -790564 874342 539248 204154 + -130940 -466034 -801128 863778 528684 193590 -141504 -476598 -811692 853214 + 518120 183026 -152068 -487162 -822256 842650 507556 172462 -162632 -497726 + -832820 832086 496992 161898 -173196 -508290 -843384 821522 486428 151334 + 151334 ] swap qnth i>f 0.000001 f* ; +( snare wire buzz modes, Hz, x 1e6 ) +table: wire-hz 8 + f>i [ 2100000000 4065750000 6606480000 9422190000 13112880000 17078550000 21919200000 27334830000 27334830000 ] swap qnth i>f 0.000001 f* ; + +ustruct: KitState + f64 q 40 f64 v 40 + f64 c 40 f64 sw 40 f64 ws 40 f64 rho 40 + f64 b1 40 f64 b2 40 ( exact-step force gains ) + f64 phi 40 ( shape at the strike point ) + f64 gs 40 ( striker force -> modal acceleration ) + f64 pc 40 ( shape at the center / the plate's coupling vector ) + f64 w2 40 ( w^2: tension modulation ) + f64 wl 40 f64 wr 40 ( radiation to each microphone ) + f64 wq 8 f64 wv 8 ( wire buzz modes ) + f64 wc 8 f64 wsw 8 f64 wws 8 f64 wrho 8 f64 wb2 8 + ( the hit ) + f64 piece ( 0 kick .. 11 ride bell ) + f64 plate ( 1 for cymbals and hats ) + f64 wires ( wire coupling 0..1 ) + f64 y f64 en ( center coordinate, strain energy last sample ) + f64 glide ( tension modulation gain ) + f64 bloom ( plate coupling gain, 1/[m^2 s^2] ) + f64 kcap ( the coupling's stiffness cap, 1/s^2 ) + f64 inv-m ( 1 / modal mass ) + f64 su f64 sv f64 se ( striker position, velocity, compression ) + f64 sm f64 sk f64 sp f64 sal ( striker mass, stiffness, exponent, hysteresis ) + f64 scomp ( striker + body compliance at the strike point ) + f64 stick-on + f64 click-lp f64 click-g + f64 wu f64 wuv f64 we ( wire mass position, velocity, compression ) + f64 wcomp f64 wgap f64 wbuzz + f64 gain ( piece level x velocity scaling ) + f64 t0 ( clock at note-on, for the hat choke ) + f64 choke ( 1 once choked ) + f64 rng-l f64 rng-r ( noise streams ) + f64 ed ( diffuse-field energy: the dense modes above the 40 ) + f64 dr f64 kap f64 dgain ( its decay per sample, coupling, radiation gain ) + f64 hpa f64 lpa ( its band: one-pole coefficients ) + f64 hl f64 ll f64 hr f64 lr ( its filter states ) + f64 ll2 f64 lr2 + ( the hit, for the mode stages ) + f64 k-f0 f64 k-s0 f64 k-s1 f64 k-sq f64 k-r f64 k-h f64 k-fny +; + +ustruct: KitParams + ( user-facing: per piece tune [semitones], decay [x], level ) + f64 kick-tune f64 kick-decay f64 kick-level + f64 snare-tune f64 snare-decay f64 snare-level + f64 tom-tune f64 tom-decay f64 tom-level + f64 hat-tune f64 hat-decay f64 hat-level + f64 crash-tune f64 crash-decay f64 crash-level + f64 ride-tune f64 ride-decay f64 ride-level + f64 stick ( striker hardness -1..1 ) + f64 wires ( snare wire amount 0..1 ) + f64 glide ( tension modulation 0..2 ) + f64 bloom ( cymbal coupling 0..2 ) + f64 width ( stereo width 0..1 ) + f64 level + ( derived ) + f64 sr f64 inv-sr + f64 hat-clock ( the clock of the last closed or pedal hat ) +; + +( p a0 .. a11 -- y : the value for piece p ) +dsp: sel12 | p a0 a1 a2 a3 a4 a5 a6 a7 a8 a9 a10 a11 -- y | + a0 + p 0.5 f> a1 rot select p 1.5 f> a2 rot select p 2.5 f> a3 rot select + p 3.5 f> a4 rot select p 4.5 f> a5 rot select p 5.5 f> a6 rot select + p 6.5 f> a7 rot select p 7.5 f> a8 rot select p 8.5 f> a9 rot select + p 9.5 f> a10 rot select p 10.5 f> a11 rot select +; + +dsp: kit-block-prepare | ctx:Ctx state params:KitParams -- | + ctx.sr -> params.sr + 1.0 ctx.sr f/ -> params.inv-sr +; + +( pitch -- piece : the GM drum map; notes outside 35..59 fold into it ) +dsp: kit-piece | pitch -- p notelow | + pitch 34.5 f< pitch 59.5 f> or | out | + out pitch 12.0 f/ ffrac 12.0 f* 0.5 f+ floor 36.0 f+ pitch select | n | + n 36.5 f< 0.0 + n 37.5 f< 2.0 + n 38.5 f< 1.0 + n 39.5 f< 2.0 + n 40.5 f< 1.0 + n 41.5 f< 3.0 + n 42.5 f< 6.0 + n 43.5 f< 3.0 + n 44.5 f< 7.0 + n 45.5 f< 4.0 + n 46.5 f< 8.0 + n 47.5 f< 4.0 + n 48.5 f< 5.0 + n 49.5 f< 9.0 + n 50.5 f< 5.0 + n 51.5 f< 10.0 + n 52.5 f< 9.0 + n 53.5 f< 11.0 + n 56.5 f< 9.0 + n 57.5 f< 9.0 + 10.0 select select select select select select select select select select + select select select select select select select select select select + ( the lower note of each tom pair, and the second kick, sit lower ) + n 35.5 f< n 41.5 f< n 40.5 f> and or n 45.5 f< n 44.5 f> and or n 48.5 f< n 47.5 f> and or + mask>f +; + +( ctx state params -- : note-on part 1, the piece's physical constants ) +dsp: kit-key | ctx:Ctx state:KitState params:KitParams -- | + ctx.hz kit-piece | p low | + p -> state.piece + p 5.5 f> | plm | + plm mask>f -> state.plate + params.inv-sr | h | + h -> state.k-h + params.sr 0.45 f* -> state.k-fny + ctx.clock -> state.t0 + 0.0 -> state.choke + ( a closed or pedal hat chokes the open ones ) + p 5.5 f> p 7.5 f< and ctx.clock params.hat-clock select -> params.hat-clock + ( tuning: the piece's own pitch, its knob, and the lower note of a pair ) + p params.kick-tune params.snare-tune params.snare-tune params.tom-tune params.tom-tune + params.tom-tune params.hat-tune params.hat-tune params.hat-tune params.crash-tune + params.ride-tune params.ride-tune sel12 0.08333333333333333 f* exp2 | tune | + p 52.0 185.0 185.0 82.0 118.0 165.0 420.0 420.0 420.0 190.0 240.0 560.0 sel12 + tune f* low 0.5 f> 0.86 1.0 select f* | f0 | + f0 -> state.k-f0 + ( decay: T60 at f0 and at 3 kHz, times the piece's knob ) + p params.kick-decay params.snare-decay params.snare-decay params.tom-decay params.tom-decay + params.tom-decay params.hat-decay params.hat-decay params.hat-decay params.crash-decay + params.ride-decay params.ride-decay sel12 | dk | + p 0.5 0.6 0.15 1.4 1.0 0.8 0.18 0.06 1.8 5.0 7.0 5.0 sel12 dk f* t60>sigma | sa | + p 0.06 0.25 0.07 0.18 0.18 0.18 0.12 0.04 1.0 1.6 2.2 2.5 sel12 dk f* t60>sigma | sb | + ( membranes lose as f^2, plates as f ) + plm 1.0 2.0 select | sq | + sq -> state.k-sq + 3000.0 sq pow f0 sq pow f- 1.0 fmax | span | + sb sa f- 0.0 fmax span f/ | s1 | + s1 -> state.k-s1 + sa s1 f0 sq pow f* f- 0.0 fmax -> state.k-s0 + ( body: radius, mass, strike radius ) + p 0.229 0.17 0.17 0.2 0.165 0.15 0.18 0.18 0.18 0.23 0.255 0.255 sel12 | a | + p 0.08 0.032 0.032 0.057 0.038 0.032 0.8 0.8 0.8 1.5 2.2 2.2 sel12 0.25 f* | mm | + 1.0 mm f/ -> state.inv-m + p 0.1 0.35 0.92 0.4 0.4 0.4 0.6 0.6 0.6 0.85 0.6 0.12 sel12 -> state.k-r + ( tension modulation: G = 150 / c^2, c = 2 pi f0 a / j01, two-ply heads stiffer ) + 6.283185307179586 f0 f* a f* 2.4048 f/ dup f* | c2 | + p 300.0 150.0 150.0 300.0 150.0 150.0 0.0 0.0 0.0 0.0 0.0 0.0 sel12 c2 f/ + params.glide f* -> state.glide + ( bloom: the coupling matches the linear stiffness at about 0.3 mm ) + 6.283185307179586 f0 f* dup f* | w02 | + p 0.0 0.0 0.0 0.0 0.0 0.0 0.6 0.6 0.6 1.0 0.25 0.1 sel12 params.bloom f* + w02 f* 3.0e6 f* -> state.bloom + w02 0.5 f* -> state.kcap + ( wires: the snare, and a little under a side stick ) + p 0.0 1.0 0.3 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 sel12 params.wires f* | wi | + wi -> state.wires + ( striker: mass, stiffness, exponent, hysteresis, speed ) + p 0.035 0.012 0.02 0.012 0.012 0.012 0.012 0.008 0.012 0.012 0.012 0.012 sel12 | sm | + sm -> state.sm + p 7.3 8.64 8.3 8.64 8.64 8.64 9.08 8.8 9.08 9.08 9.08 9.2 sel12 + params.stick 0.7 f* f+ 3.321928094887362 f* exp2 -> state.sk + p 2.3 1.5 1.5 1.5 1.5 1.5 1.5 1.5 1.5 1.5 1.5 1.5 sel12 -> state.sp + p 0.3 0.05 0.1 0.05 0.05 0.05 0.05 0.2 0.05 0.05 0.05 0.05 sel12 -> state.sal + ctx.vel 0.0 1.0 fclamp | vel | + p 0.6 0.8 0.5 0.8 0.8 0.8 0.6 0.25 0.6 0.8 0.6 0.6 sel12 vel 1.2 pow + p 4.0 9.0 4.0 9.0 9.0 9.0 6.0 1.5 6.0 8.0 6.0 6.0 sel12 f* f+ -> state.sv + -0.00002 -> state.su -0.00002 -> state.se + 1.0 -> state.stick-on + h h f* 0.5 f* state.sm f/ -> state.scomp + 0.0 -> state.click-lp + p 0.25 0.12 0.5 0.08 0.08 0.08 0.15 0.3 0.15 0.08 0.1 0.1 sel12 -> state.click-g + ( wires: rest a hair under the head, closer with more WIRES ) + 0.0 -> state.wu 0.0 -> state.wuv -0.00001 -> state.we + 1.0 wi f- 0.00006 f* 0.000005 f+ -> state.wgap + h h f* 0.5 f* 0.004 f/ -> state.wcomp + 0.0 -> state.y 0.0 -> state.en + ( level: the piece's knob, a gentle velocity curve, and a per-piece trim + that puts each near the same loudness ) + p params.kick-level params.snare-level params.snare-level params.tom-level params.tom-level + params.tom-level params.hat-level params.hat-level params.hat-level params.crash-level + params.ride-level params.ride-level sel12 + p 0.5 0.019 0.054 0.03 0.025 0.024 0.05 0.32 0.045 0.055 0.1 0.1 sel12 f* + params.level f* params.sr f* 0.002 f* -> state.gain + ( the wire buzz bank ) + 0.0 WN [ | j | + wire-hz j f@i 6.283185307179586 f* | w | + w h zoh-coefs | c sw ws b1 b2 | + c state.wc& j f!i sw state.wsw& j f!i ws state.wws& j f!i b2 state.wb2& j f!i + 0.016 j 0.001 f* f- t60>sigma h f* fneg exp state.wrho& j f!i + 0.0 state.wq& j f!i 0.0 state.wv& j f!i + j 1.0 f+ ] times drop + 0.0 -> state.wbuzz + ( the diffuse field: the plates' dense upper modes as one energy store + fed by the discrete modes [statistical energy analysis, Lyon 1975], + decaying at the piece's high-frequency rate, radiating band noise ) + 0.0 -> state.ed 0.0 -> state.hl 0.0 -> state.ll 0.0 -> state.hr 0.0 -> state.lr + 0.0 -> state.ll2 0.0 -> state.lr2 + sb h f* fneg exp -> state.dr + p 0.0 0.0 0.0 0.0 0.0 0.0 1.0 1.0 1.0 1.0 0.5 0.2 sel12 params.bloom f* 160.0 f* -> state.kap + p 0.0 0.0 0.0 0.0 0.0 0.0 1.0 1.0 1.0 1.0 1.0 1.0 sel12 -> state.dgain + params.sr p 0.0 0.0 0.0 0.0 0.0 0.0 4000.0 4000.0 3500.0 1800.0 2200.0 2500.0 sel12 1000.0 fmax + svf-dc-coeff -> state.hpa + params.sr 6500.0 svf-dc-coeff -> state.lpa + ( every hit starts its noise from the same place, like a sample, but + the two channels differ ) + 0.1234567 -> state.rng-l 0.7654321 -> state.rng-r +; + +( state i -- comp : build mode i of the hit and return its compliance at + the strike point [i a literal in each stage] ) +dsp: kit-mode | state:KitState params:KitParams i -- comp | + state.k-h | h | state.plate 0.5 f> | pl | + ( frequency: membranes at the air-loaded Bessel ratios, plates on theirs ) + pl cym-ratio i f@i memb-ratio i f@i log2 0.94 f* exp2 select state.k-f0 f* | fr | + fr state.k-fny f< mask>f | live | + fr state.k-fny fmin 6.283185307179586 f* | w | + w h zoh-coefs | c sw ws b1 b2 | + c state.c& i f!i sw state.sw& i f!i ws state.ws& i f!i + b1 state.b1& i f!i b2 state.b2& i f!i + w w f* state.w2& i f!i + ( the plates' upper modes also lose to the diffuse field, which takes + their energy over as wash ) + state.kap 0.0015 f* fr 2500.0 f- 0.0 fmax f* state.plate f* | sdf | + state.k-s0 state.k-s1 fr state.k-sq pow f* f+ sdf f+ h f* fneg exp state.rho& i f!i + memb-m i f@i | m | + ( shape at the strike point; plates use their fixed weights ) + pl cym-w i f@i cym-s i f@i f* cym-ratio i f@i log2 -0.7 f* exp2 f* + i state.k-r memb-shape-at select live f* | ph | + ph state.phi& i f!i + ph state.inv-m f* state.gs& i f!i + ( center coordinate: only the m = 0 modes move there; plates couple + through a fixed signed vector ) + pl cym-s i f@i 0.5 f* m 0.5 f< mask>f i 0.0 memb-shape-at f* select live f* state.pc& i f!i + ( radiation: the m = 0 modes are monopoles, higher m radiate less; the + microphones sit at r = 0.6 on two angles ) + 1.0 m 1.0 f+ f/ | rad | + i 0.6 memb-shape-at rad f* | mic | + pl cym-l i f@i mic m 0.5 f* cos f* select live f* | wl0 | + pl cym-r i f@i mic m 0.5 1.2 params.width f* f+ f* cos f* select live f* | wr0 | + wl0 wr0 f+ 0.5 f* | mid | + wl0 mid f- params.width f* mid f+ state.wl& i f!i + wr0 mid f- params.width f* mid f+ state.wr& i f!i + 0.0 state.q& i f!i 0.0 state.v& i f!i + ph state.inv-m f* b1 f* ph f* +; + +dsp: kit-modes-0 | ctx state:KitState params -- | + 0.0 state.scomp 16 [ | i cp | i 1.0 f+ cp state params i kit-mode f+ ] times nip -> state.scomp +; +dsp: kit-modes-1 | ctx state:KitState params -- | + 16.0 state.scomp 16 [ | i cp | i 1.0 f+ cp state params i kit-mode f+ ] times nip -> state.scomp +; +dsp: kit-modes-2 | ctx state:KitState params -- | + 32.0 state.scomp 8 [ | i cp | i 1.0 f+ cp state params i kit-mode f+ ] times nip -> state.scomp +; +( the wires' compliance: their mass plus the head at its center ) +dsp: kit-wire-comp | ctx state:KitState params -- | + 0.0 state.wcomp DN [ | i cp | i 1.0 f+ cp state.pc& i f@i dup f* state.inv-m f* state.b1& i f@i f* f+ ] times nip + -> state.wcomp +; + +( ctx state params -- : strike this voice. ) +dsp: kit-note-on | ctx state params | + ctx state params call: kit-key + ctx state params call: kit-modes-0 + ctx state params call: kit-modes-1 + ctx state params call: kit-modes-2 + ctx state params call: kit-wire-comp +; + +( ctx state params -- : every 32 samples - retire the striker once it has + bounced clear, and choke an open hat when a closed one is played. ) +dsp: kit-control | ctx state:KitState params:KitParams -- | + state.sv 0.0 f< state.se -0.001 f< and 0.0 state.stick-on select -> state.stick-on + state.piece 7.5 f> state.piece 8.5 f< and params.hat-clock state.t0 f> and | ch | + ch state.choke 0.5 f< and | now | + ( a choke multiplies every decay factor down to a 40 ms T60 ) + 0.0 DN [ | i | + state.rho& i f@i now 0.9965 1.0 select f* state.rho& i f!i + i 1.0 f+ ] times drop + ch 1.0 state.choke select -> state.choke +; + +( state f -- f : the striker; returns its force on the body ) +dsp: kit-stick | state:KitState params:KitParams fc -- f | + state.glide state.en f* 0.25 fmin | dl | + params.inv-sr | h | + 0.0 0.0 DN [ | i acc | + i 1.0 f+ acc + state.c& i f@i | c | state.sw& i f@i | sw | state.ws& i f@i | ws | + c ws h f* dl f* f- state.q& i f@i f* + sw c h f* sw f- dl f* f+ state.v& i f@i f* f+ + state.rho& i f@i f* + state.pc& i f@i fc f* state.b1& i f@i f* f+ + state.phi& i f@i f* f+ ] times nip | ypred | + params.inv-sr | h | + state.su state.sv h f* f+ | ufree | + ufree ypred f- state.se state.scomp state.sk state.sp state.sal params.sr + contact-solve | e f | + e -> state.se + f state.sm f/ | a | + ufree 0.5 h f* h f* a f* f- -> state.su + state.sv h a f* f- -> state.sv + f +; + +( state -- fw : the snare wires against the head's center ) +dsp: kit-wires | state:KitState params:KitParams -- fw | + params.inv-sr | h | + ( the wires: 100 Hz spring, heavily damped, 4 g ) + state.wuv -394784.176 state.wu f* -251.0 state.wuv f* f+ h f* f+ | vfree | + state.wu vfree h f* f+ | ufree | + ( the head's center moves into the wires: compression = y - u - gap ) + state.y ufree f- state.wgap f- state.we state.wcomp 5000000.0 1.5 0.2 params.sr + contact-solve | e f | + e -> state.we + vfree f 0.004 f/ h f* f+ -> state.wuv + ufree 0.5 h f* h f* f 0.004 f/ f* f+ -> state.wu + ( the collision rings the wire buzz modes. Twenty strands land at + scattered moments, so the force reaching them is the collision force + times noise ) + state.rng-l& rand-b f 45.0 f* f* | fz | + 0.0 0.0 WN [ | j acc | + state.wq& j f@i | q | state.wv& j f@i | v | + state.wc& j f@i | c | state.wrho& j f@i | r | + c q f* state.wsw& j f@i v f* f+ r f* | qn | + state.wws& j f@i q f* fneg c v f* f+ r f* state.wb2& j f@i fz f* f+ | vn | + qn state.wq& j f!i vn state.wv& j f!i + j 1.0 f+ acc vn v f- f+ ] times nip state.wires f* -> state.wbuzz + f +; + +( io state params f fc -- : advance the modes under the striker force f + and the center force fc, and radiate ) +dsp: kit-body | io:Io state:KitState params:KitParams f fc -- | + ( tension modulation: every mode's frequency rises by dl, its share of + the head's strain energy; the step's rotation is bent to first order, + which keeps it a rotation and so stable at any mode frequency ) + state.glide state.en f* 0.25 fmin | dl | + params.inv-sr | h | + 0.0 0.0 0.0 0.0 0.0 DN [ | i y en ol or | + state.q& i f@i | q | state.v& i f@i | v | + state.c& i f@i | c0 | state.sw& i f@i | sw0 | state.ws& i f@i | ws0 | + state.rho& i f@i | r | + state.w2& i f@i | w2 | + c0 ws0 h f* dl f* f- | c | + sw0 c0 h f* sw0 f- dl f* f+ | sw | + ws0 ws0 w2 h f* c0 f* f+ dl f* f+ | ws | + state.gs& i f@i f f* state.pc& i f@i fc f* f+ | fi | + c q f* sw v f* f+ r f* state.b1& i f@i fi f* f+ | qn | + ws q f* fneg c v f* f+ r f* state.b2& i f@i fi f* f+ | vn | + qn state.q& i f!i vn state.v& i f!i + vn v f- | dv | + i 1.0 f+ + y state.pc& i f@i qn f* f+ + en w2 qn f* qn f* f+ + ol state.wl& i f@i dv f* f+ + or state.wr& i f@i dv f* f+ ] times | i y en ol or | + y -> state.y en -> state.en + ( the striker's click: its force, high-passed by a 1 kHz one-pole ) + state.click-lp | cl | + f cl f- 0.12 f* cl f+ | cln | + cln -> state.click-lp + f cln f- state.click-g f* state.inv-m f* params.inv-sr f* | ck | + ( the diffuse field: fed by the modes' strain energy, radiated as noise + in the plate's upper band, its amplitude the field's rms velocity ) + state.ed state.dr f* state.kap en f* params.inv-sr f* f+ | ed | + ed 1.0e-16 f> mask>f ed f* | ed | + ed -> state.ed + ed fsqrt state.dgain f* 7000.0 f* | da | + state.rng-l& rand-b da f* | nl | state.rng-r& rand-b da f* | nr | + state.hl nl state.hl f- state.hpa f* f+ | hl | hl -> state.hl + state.ll nl hl f- state.ll f- state.lpa f* f+ | ll1 | ll1 -> state.ll + state.ll2 ll1 state.ll2 f- state.lpa f* f+ | ll | ll -> state.ll2 + state.hr nr state.hr f- state.hpa f* f+ | hr | hr -> state.hr + state.lr nr hr f- state.lr f- state.lpa f* f+ | lr1 | lr1 -> state.lr + state.lr2 lr1 state.lr2 f- state.lpa f* f+ | lr | lr -> state.lr2 + io.out-l ol ck f+ state.wbuzz f+ ll params.inv-sr f* f+ state.gain f* f+ -> io.out-l + io.out-r or ck f+ state.wbuzz f+ lr params.inv-sr f* f+ state.gain f* f+ -> io.out-r +; + +( io ctx state params -- : one hit, one sample. Output ACCUMULATES. ) +dsp: k-kit-voice | io:Io ctx state:KitState params:KitParams -- | + ( the plate coupling: a cubic restoring force through the shared + coordinate - a quartic potential, so it only moves energy between + modes - with its stiffness capped so the explicit step stays stable ) + state.y | y | + state.bloom y f* y f* | gy | + gy y f* fneg 1.0 gy state.kcap f/ f+ f/ state.plate f* | fnl | + state.wires 0.0 f> [ state params kit-wires ] [ 0.0 ] ifte | fw | + fnl fw state.inv-m f* f- | fc | + state.stick-on 0.5 f> [ state params fc kit-stick ] [ 0.0 ] ifte | f | + io state params f fc kit-body +; diff --git a/kernels/06-voices/piano.fy b/kernels/06-voices/piano.fy new file mode 100644 index 00000000..8864b528 --- /dev/null +++ b/kernels/06-voices/piano.fy @@ -0,0 +1,436 @@ +( piano.fy - a struck-string piano voice built from modes [docs/41]. + + One voice is one key: up to three unison strings, a felt hammer that + strikes them, and a small soundboard the strings drive through the + bridge. Everything is a physical quantity - metres, newtons, + kilograms - so the hammer's tone comes from the felt law and the + strings' masses, not from a filter. + + STRINGS. Each string is a bank of transverse modes with the stiff- + string partials f_n = n f0 sqrt[1 + B n^2] / sqrt[1 + B] [Fletcher + 1964], decaying at sigma_n = b1 + b3 w_n^2 [frequency-dependent loss, + Chaigne and Askenfelt 1994]. Mode n has the shape sin[n pi x/L] and + modal mass mu L/2. The main string carries 56 modes, the two unison + partners 16 each: their low partials beat against the main string's + [UNISON detune], and above the 16th partial the main string's modes + stand for all of a key's strings at once. Bass keys run the main + string's upper modes on every 2nd..4th partial, weighted by the square + root of the stride, so a low A still reaches 7 kHz. + + HAMMER. A point mass of felt [mass, stiffness K, exponent p from the + key; Hall 1986, Stulov 1995] launched at the strings at a speed set by + velocity, pressing them with F = K e^p [1 + alpha de/dt] while the felt + is compressed. contact-solve [10-physical/modal.fy] solves the force + implicitly each sample, so soft hits are short dull pushes and hard + hits a sharper, brighter knock - the felt stiffens as it compresses. + Once the hammer has rebounded clear [checked at control rate] the + render skips the contact solve. + + BRIDGE AND SOUNDBOARD. The strings end on a bridge that moves. The + string force on it, F_b = sum [-1]^[n+1] n pi T/L q_n, drives 12 + soundboard plate modes; the board's acceleration pushes back on each + string mode as -c_n b'' [c_n = 2 [-1]^[n+1]/[n pi]], the inertia of a + string whose end is shaken. That shared bridge couples the unison + strings: their in-phase motion drains into the board quickly and the + out-of-phase part lingers - the piano's two-stage decay [Weinreich + 1977]. Where on the board the key's bridge point sits sets how + strongly each board mode hears it. + + OUTPUT is the board's acceleration seen from two microphone points: + low notes and board resonances spread across the stereo field, and + above the board modes the bridge force passes through flat. + + DAMPERS. A released key drops its damper, adding decay to each mode + by how far the damper point moves in it; PEDAL lifts the dampers. + The top 20 keys have no dampers, as on a grand. The decay factors are + re-blended at control rate, so the pedal is live on held notes. + + Clean-room: written from the literature cited here and fitted by ear + and on the bench; no code or data from other piano models. ) + +include "../00-primitives/ctx.fy" +include "../00-primitives/math.fy" +include "../10-physical/modal.fy" +include "../04-filters/coeffs.fy" ( svf-dc-coeff ) + +:: PN 88 ; ( string modes per voice: 56 main + 16 + 16 ) +:: PN-A 56 ; +:: PN-SEP 16 ; ( partials the unison strings carry separately ) +:: PN-LIN 32 ; ( main-string modes before bass keys start striding ) +:: BN 12 ; ( soundboard modes ) +:: WR 0.12 ; +:: PN-AF 56.0 ; :: PN-SEPF 16.0 ; :: PN-LINF 32.0 ; + +ustruct: PianoState + ( the modes: amplitude [m], velocity [m/s], the exact step [cos, sin/w, + w sin], the decay factor now / damper up / damper down, hammer force + -> mode, bridge acceleration -> mode, mode shape at the strike point + over the strings, mode -> bridge force [N/m] ) + f64 q 88 f64 v 88 + f64 c 88 f64 sw 88 f64 ws 88 + f64 rho 88 f64 ron 88 f64 rfull 88 + f64 gb1 88 f64 gb2 88 f64 cb1 88 f64 cb2 88 + f64 phi 88 f64 kb 88 + f64 bp 12 f64 bv 12 ( board mode displacement, velocity ) + f64 g 12 ( board mode shape at this key's bridge point ) + f64 fb-lp ( bridge force, lowpassed: the resistive part sees the rest ) + f64 vr f64 br ( resistive bridge velocity and displacement ) + f64 wrl f64 wrr ( this key's resistive radiation to each microphone ) + f64 bacc ( bridge acceleration last sample ) + f64 bdisp ( bridge displacement last sample ) + f64 tl ( strings * T/L: the strings' stiffness on the bridge ) + f64 hu f64 hv ( hammer position, velocity [toward the strings] ) + f64 he ( felt compression this sample ) + f64 hcomp ( D: hammer + string compliance at the strike ) + f64 hk f64 hp f64 halpha f64 hmass + f64 nstr ( strings on this key: 1, 2 or 3 ) + f64 ham-on ( 1 while the hammer may touch; control clears it ) + f64 gate ( 1 held, 0 released ) + f64 damped ( 1 if the key has a damper ) + f64 pitch + ( the key, set at note-on for the mode stages ) + f64 k-f0 f64 k-h f64 k-fny f64 k-len f64 k-tens f64 k-mmode + f64 k-bb f64 k-bnorm f64 k-xs f64 k-xd f64 k-s1 f64 k-b3 f64 k-sd + f64 k-stride f64 k-detb f64 k-detc +; + +ustruct: PianoParams + ( user-facing ) + f64 hard ( felt hardness, -1..1: K and p around the key's own ) + f64 felt ( felt hysteresis 0..1 ) + f64 strike ( strike point shift, -1..1 ) + f64 decay ( decay time scale ) + f64 hf-decay ( s: T60 of the partials near 4 kHz, at middle C ) + f64 stiff ( inharmonicity scale ) + f64 unison ( cents between unison strings ) + f64 board ( bridge coupling 0..1 ) + f64 release ( s: damper T60 of the fundamental ) + f64 pedal ( 0 dampers down .. 1 lifted ) + f64 width ( stereo width 0..1 ) + f64 level + ( derived ) + f64 sr f64 inv-sr + f64 ped-k ( [1 - pedal]^2 ) + f64 bc 12 f64 bsw 12 f64 bws 12 f64 brho 12 f64 bb1 12 f64 bb2 12 + f64 bwl 12 f64 bwr 12 ( mic weights per board mode ) + f64 out-g + f64 yr ( bridge mobility of the board's dense modes, s/kg ) + f64 yr-a ( one-pole coefficient: the mobility's high-pass corner ) +; + +( board mode j: plate indices [a, b] of a rectangular board's first + modes in rising order, the mode frequency in Hz and its damping ratio. ) +( 13 cells: table: also fills index len ) +table: board-hz 12 + f>i [ 58 101 142 187 236 291 357 433 522 641 790 985 985 ] swap qnth i>f ; +table: board-a 12 f>i [ 1 2 1 3 2 1 4 3 2 5 4 1 1 ] swap qnth i>f ; +table: board-b 12 f>i [ 1 1 2 1 2 3 1 2 3 1 2 4 4 ] swap qnth i>f ; + +( ctx state params -- : shared board coefficients and knob scalings. ) +dsp: piano-block-prepare | ctx:Ctx state params:PianoParams -- | + ctx.sr | sr | + sr -> params.sr + 1.0 sr f/ | h | + h -> params.inv-sr + 1.0 params.pedal f- dup f* -> params.ped-k + ( board: exact steps; zeta rises gently with the mode number ) + 0.0 BN [ | j | + board-hz j f@i 6.283185307179586 f* | w | + w h zoh-coefs | c sw ws b1 b2 | + c params.bc& j f!i sw params.bsw& j f!i ws params.bws& j f!i + b1 params.bb1& j f!i b2 params.bb2& j f!i + 0.016 j 0.0016 f* f+ w f* h f* fneg exp params.brho& j f!i + ( mic weights: a mode's net volume [odd-odd modes radiate as a + monopole] plus its shape at two points either side of centre ) + board-a j f@i | a | board-b j f@i | b | + 1.0 a cospi f- 1.0 b cospi f- f* a b f* 9.869604401089358 f* f/ | vol | + a 0.28 f* sinpi b 0.45 f* sinpi f* | ml | + a 0.72 f* sinpi b 0.45 f* sinpi f* | mr | + ml mr f+ 0.5 f* | mm | + ml mm f- params.width f* mm f+ | ml | + mr mm f- params.width f* mm f+ | mr | + vol 0.6 f* ml 0.4 f* f+ params.bwl& j f!i + vol 0.6 f* mr 0.4 f* f+ params.bwr& j f!i + j 1.0 f+ ] times drop + ( the board radiates in proportion to its coupling, so it is divided + out: COUPLE changes tone and decay, not level ) + params.level 0.05 f* sr f* params.board 0.05 fmax f/ -> params.out-g + ( above its first modes the board is modally dense and acts like a + resistance at the bridge [an infinite plate's mobility 1/[8 sqrt[D m]], + Cremer], high-passed so the board stays stiff below its first modes ) + params.board 0.0002 f* -> params.yr + sr 220.0 svf-dc-coeff -> params.yr-a +; + +( key m -- string length L [m]: a grand's scale, shortened in the bass. ) +dsp: pn-length | m -- l | 108.0 m f- 0.0767 f* exp2 0.05 f* 0.05 1.95 fclamp ; + +( ctx state params -- : strike this voice. Builds every mode's + coefficients for the key and launches the hammer. ) +( ctx state params -- : note-on, part 1 - the key's scale, loss, hammer + and board point. The mode banks are filled by the stages below; the + host runs them in order as the composition piano-note-on. ) +dsp: piano-key | ctx:Ctx state:PianoState params:PianoParams -- | + ctx.pitch 21.0 108.0 fclamp | m | + m -> state.pitch + m 21.0 f- 87.0 f/ | k | + ctx.hz | f0 | + params.inv-sr | h | + params.sr 0.45 f* | fny | + ( strings: 1 below B0, 2 below G#2, else 3 ) + m 30.0 f< 1.0 m 44.0 f< 2.0 3.0 select select | ns | + ns -> state.nstr + ( the scale: length, tension, mass ) + m pn-length | len | + 48.0 m f- 0.0 fmax 30.0 f* 700.0 f+ | tens | + 2.0 len f* f0 f* | twolf | + tens twolf twolf f* f/ | mu | + mu len f* 0.5 f* | mmode | + ( inharmonicity: lowest near C3, rising steeply into the treble ) + m 48.0 f- | dm | + dm 0.0 f> dm 0.029 f* dm fneg 0.012 f* select -4.0 f+ 3.321928094887362 f* exp2 + params.stiff f* | bb | + 1.0 bb f+ fsqrt | bnorm | + ( strike point: 1/8 of the string in the bass to 1/12.5 at the top ) + 0.125 0.045 k f* f- params.strike 0.025 f* f+ 0.04 0.3 fclamp | xs | + ( loss: fundamental T60 and the T60 near 4 kHz set b1 and b3 ) + m 21.0 f- -0.03846 f* exp2 30.0 f* params.decay f* t60>sigma | s1 | + m 60.0 f- -0.04 f* exp2 params.hf-decay f* params.decay f* t60>sigma | shf | + shf s1 f- 0.0 fmax 25132.741228718345 dup f* f/ | b3 | + ( damper: a release T60 for the fundamental, felt at x = xs + 0.03 ) + params.release t60>sigma s1 f- 0.0 fmax | sd | + m 88.0 f<= mask>f | dmp | + dmp -> state.damped + xs 0.03 f+ | xd | + ( bass stride for the main string's upper modes ) + 160.0 f0 f/ floor 1.0 4.0 fclamp | stride | + ( unison detune, a different spread on each key ) + m 0.6180339887 f* ffrac 0.6 f* 0.7 f+ params.unison f* | dc | + dc fneg 0.0008333333333333334 f* exp2 | detb | ( 2^[-dc/1200] ) + dc 0.6 f* 0.0008333333333333334 f* exp2 | detc | + f0 -> state.k-f0 h -> state.k-h fny -> state.k-fny len -> state.k-len + tens -> state.k-tens mmode -> state.k-mmode bb -> state.k-bb + bnorm -> state.k-bnorm xs -> state.k-xs xd -> state.k-xd s1 -> state.k-s1 + b3 -> state.k-b3 sd -> state.k-sd stride -> state.k-stride + detb -> state.k-detb detc -> state.k-detc + ns tens f* len f/ -> state.tl + ( the board point of this key's bridge, on a diagonal across the board ) + 0.2 0.6 k f* f+ | bx | + 0.72 0.4 k f* f- | by | + 0.0 BN [ | j | + board-a j f@i bx f* sinpi board-b j f@i by f* sinpi f* 0.35 f* params.board f* + state.g& j f!i + 0.0 state.bp& j f!i 0.0 state.bv& j f!i + j 1.0 f+ ] times drop + 0.0 -> state.bacc 0.0 -> state.bdisp + 0.0 -> state.fb-lp 0.0 -> state.vr 0.0 -> state.br + ( the dense modes radiate from around the bridge point: bass keys lean + left, treble keys right, as heard from the player's side ) + k 0.5 f- params.width f* | pan | + 0.5 pan f- WR f* -> state.wrl + 0.5 pan f+ WR f* -> state.wrr + ( hammer: lighter, harder and more nonlinear toward the treble. The + effective masses, stiffnesses and exponents interpolate the values + Chaigne and Askenfelt [1994] fitted at C2, C4 and C7 ) + m 36.0 f- 60.0 f/ -1.155 f* exp2 0.0049 f* | hm | ( 4.9 g at C2, 2.2 g at C7 ) + hm -> state.hmass + m 60.0 f< | lo | + lo m 36.0 f- 0.008333 f* 2.3 f+ m 60.0 f- 0.013889 f* 2.5 f+ select 1.8 3.6 fclamp + -> state.hp + lo m 36.0 f- 0.0272 f* 9.0 f+ m 60.0 f- 0.0374 f* 9.653 f+ select + params.hard f+ 3.321928094887362 f* exp2 -> state.hk + params.felt 0.6 f* -> state.halpha + ( hammer speed: 0.25 m/s pianissimo to 6 m/s fortissimo ) + ctx.vel 0.0 1.0 fclamp 4.584962500721156 f* exp2 0.25 f* -> state.hv + -0.0001 -> state.hu + -0.0001 -> state.he + ( compliance: the hammer's own; the mode stages add the strings' ) + ns h f* h f* 0.5 f* hm f/ -> state.hcomp + 1.0 -> state.ham-on + 1.0 -> state.gate +; +( state i -- comp : fill mode i [a literal in each stage, so every + array access is a fixed field] and return its strike-point compliance. ) +dsp: pn-mode | state:PianoState i -- comp | + state.k-f0 | f0 | state.k-h | h | state.k-fny | fny | state.nstr | ns | + state.k-len | len | state.k-tens | tens | state.k-mmode | mmode | + state.k-bb | bb | state.k-bnorm | bnorm | state.k-xs | xs | state.k-xd | xd | + state.k-s1 | s1 | state.k-b3 | b3 | state.k-sd | sd | state.damped | dmp | + state.k-stride | stride | state.k-detb | detb | state.k-detc | detc | + ( which string and partial this mode is ) + i PN-AF 0.5 f- f< | on-a | + i PN-AF PN-SEPF f+ 0.5 f- f< | on-b0 | + on-a i PN-LINF 0.5 f- f< and | lin | + lin i 1.0 f+ i PN-LINF f- 1.0 f+ stride f* PN-LINF f+ select | na | + on-b0 i PN-AF f- 1.0 f+ i PN-AF f- PN-SEPF f- 1.0 f+ select | nbc | + on-a na nbc select | n | + on-a 1.0 on-b0 detb detc select select | det | + ( present: B needs 2 strings, C needs 3 ) + on-a 1.0 on-b0 ns 1.5 f> mask>f ns 2.5 f> mask>f select select | here | + ( merged: main-string modes above the separate partials carry every string ) + on-a n PN-SEPF 0.5 f+ f> and | merged | + merged ns 1.0 select | um | + on-a lin not and stride fsqrt 1.0 select | wst | + n n f* bb f* 1.0 f+ fsqrt bnorm f/ n f* f0 f* det f* | fn | + fn fny f< mask>f here f* | live | + fn fny fmin 6.283185307179586 f* | w | + w h zoh-coefs | c sw ws b1 b2 | + c state.c& i f!i sw state.sw& i f!i ws state.ws& i f!i + ( the unison strings meet the hammer a few percent apart along their + lengths, which fills the strike-point notches; merged modes average + all three ) + n xs f* sinpi | sa | + n xs f* 1.03 f* sinpi | sb | + n xs f* 0.97 f* sinpi | sc | + on-a merged sa sb f+ sc f+ 0.3333333333333333 f* sa select on-b0 sb sc select select | shp | + shp mmode f/ wst f* um f* live f* | gh | + gh b1 f* | gb1 | + gb1 state.gb1& i f!i gh b2 f* state.gb2& i f!i + n 1.0 f+ turn-sign | sgn | ( [-1]^[n+1] ) + sgn 2.0 f* n 3.141592653589793 f* f/ um f* live f* | cn | + cn b1 f* state.cb1& i f!i cn b2 f* state.cb2& i f!i + shp ns f/ live f* | ph | + ph state.phi& i f!i + sgn n f* 3.141592653589793 f* tens f* len f/ live f* state.kb& i f!i + s1 b3 w f* w f* f+ | sig | + sig h f* fneg exp | ron | + n xd f* sinpi dup f* 0.6 f* 0.4 f+ sd f* dmp f* | sdn | + ron state.ron& i f!i + sig sdn f+ h f* fneg exp state.rfull& i f!i + ron state.rho& i f!i + 0.0 state.q& i f!i 0.0 state.v& i f!i + ph gb1 f* +; + +dsp: pn-modes-0 | ctx state:PianoState params -- | + 0.0 state.hcomp 16 [ | i comp | i 1.0 f+ comp state i pn-mode f+ ] times nip + -> state.hcomp +; +dsp: pn-modes-1 | ctx state:PianoState params -- | + 16.0 state.hcomp 16 [ | i comp | i 1.0 f+ comp state i pn-mode f+ ] times nip + -> state.hcomp +; +dsp: pn-modes-2 | ctx state:PianoState params -- | + 32.0 state.hcomp 16 [ | i comp | i 1.0 f+ comp state i pn-mode f+ ] times nip + -> state.hcomp +; +dsp: pn-modes-3 | ctx state:PianoState params -- | + 48.0 state.hcomp 16 [ | i comp | i 1.0 f+ comp state i pn-mode f+ ] times nip + -> state.hcomp +; +dsp: pn-modes-4 | ctx state:PianoState params -- | + 64.0 state.hcomp 16 [ | i comp | i 1.0 f+ comp state i pn-mode f+ ] times nip + -> state.hcomp +; +dsp: pn-modes-5 | ctx state:PianoState params -- | + 80.0 state.hcomp 8 [ | i comp | i 1.0 f+ comp state i pn-mode f+ ] times nip + -> state.hcomp +; + +( ctx state params -- : strike this voice. Each stage writes at most + 16 modes, which keeps a word's pending stores inside the compiler's + frame. ) +dsp: piano-note-on | ctx state params | + ctx state params call: piano-key + ctx state params call: pn-modes-0 + ctx state params call: pn-modes-1 + ctx state params call: pn-modes-2 + ctx state params call: pn-modes-3 + ctx state params call: pn-modes-4 + ctx state params call: pn-modes-5 +; + + +( ctx state params -- : key up - the damper falls [control blends it in]. ) +dsp: piano-note-off | ctx state:PianoState params -- | + 0.0 -> state.gate +; + +( ctx state params -- : every 32 samples. Blend each mode's decay + between damper up and damper down by the key and the pedal, and retire + the hammer once it has rebounded clear of the strings. ) +dsp: piano-control | ctx state:PianoState params:PianoParams -- | + 1.0 state.gate f- params.ped-k f* state.damped f* | kd | + 0.0 PN [ | i | + state.ron& i f@i | ron | + ron ron state.rfull& i f@i f- kd f* f- state.rho& i f!i + i 1.0 f+ ] times drop + state.hv 0.0 f< state.he -0.002 f< and | gone | + gone 0.0 state.ham-on select -> state.ham-on +; + +( state params ba -- F : the hammer. Predict the strings' mean + displacement at the strike point without a new force, then solve the + felt contact for this sample's force on each string. ) +dsp: piano-hammer | state:PianoState params:PianoParams ba -- f | + 0.0 0.0 PN [ | i acc | + i 1.0 f+ acc + state.c& i f@i state.q& i f@i f* state.sw& i f@i state.v& i f@i f* f+ + state.rho& i f@i f* state.cb1& i f@i ba f* f- + state.phi& i f@i f* f+ ] times nip | ypred | + params.inv-sr | h | + state.hu state.hv h f* f+ | ufree | + ufree ypred f- state.he state.hcomp state.hk state.hp state.halpha params.sr + contact-solve | e f | + e -> state.he + state.nstr f state.hmass f/ f* | a | + ufree 0.5 h f* h f* a f* f- -> state.hu + state.hv h a f* f- -> state.hv + f +; + +( state params f ba -- fb : advance every string mode under the hammer + force f and the bridge acceleration ba; return the net force on the + bridge. ) +dsp: piano-strings | state:PianoState params f ba -- fb | + 0.0 0.0 PN [ | i acc | + state.q& i f@i | q | state.v& i f@i | v | + state.c& i f@i | c | state.rho& i f@i | r | + c q f* state.sw& i f@i v f* f+ r f* + state.gb1& i f@i f f* f+ state.cb1& i f@i ba f* f- | qn | + state.ws& i f@i q f* fneg c v f* f+ r f* + state.gb2& i f@i f f* f+ state.cb2& i f@i ba f* f- | vn | + qn state.q& i f!i vn state.v& i f!i + i 1.0 f+ acc state.kb& i f@i qn f* f+ ] times nip + state.tl state.bdisp f* f- +; + +( io state params fb -- : the soundboard, driven by the bridge force; + its acceleration goes back to the strings and out to the microphones. ) +dsp: piano-board | io:Io state:PianoState params:PianoParams fb -- | + 0.0 0.0 0.0 0.0 0.0 BN [ | j ba bd ol or | + state.g& j f@i | g | + state.bp& j f@i | p | state.bv& j f@i | v | + params.bc& j f@i | c | params.brho& j f@i | r | + g fb f* | fj | + c p f* params.bsw& j f@i v f* f+ r f* params.bb1& j f@i fj f* f+ | pn | + params.bws& j f@i p f* fneg c v f* f+ r f* params.bb2& j f@i fj f* f+ | vn | + pn state.bp& j f!i vn state.bv& j f!i + vn v f- | dv | + j 1.0 f+ + ba g dv f* f+ + bd g pn f* f+ + ol params.bwl& j f@i dv f* f+ + or params.bwr& j f@i dv f* f+ ] times | j ba bd ol or | + ( the resistive part: velocity = mobility x the high-passed force ) + state.fb-lp | lp0 | + fb lp0 f- params.yr-a f* lp0 f+ | lp | + lp -> state.fb-lp + fb lp f- params.yr f* | vrn | + vrn state.vr f- | dvr | + vrn -> state.vr + state.br 0.9995 f* vrn params.inv-sr f* f+ | brn | ( leaky: no drift ) + brn -> state.br + ba dvr f+ params.sr f* -> state.bacc + bd brn f+ -> state.bdisp + io.out-l ol state.wrl dvr f* f+ params.out-g f* f+ -> io.out-l + io.out-r or state.wrr dvr f* f+ params.out-g f* f+ -> io.out-r +; + +( io ctx state params -- : one voice sample. Output ACCUMULATES. ) +dsp: k-piano-voice | io:Io ctx state:PianoState params -- | + state.bacc | ba | + state.ham-on 0.5 f> [ state params ba piano-hammer ] [ 0.0 ] ifte | f | + state params f ba piano-strings | fb | + io state params fb piano-board +; diff --git a/kernels/10-physical/bessel.fy b/kernels/10-physical/bessel.fy new file mode 100644 index 00000000..58bc8c1b --- /dev/null +++ b/kernels/10-physical/bessel.fy @@ -0,0 +1,86 @@ +( bessel.fy - the ideal circular membrane's first 40 modes [docs/41]. + + A membrane of radius a under uniform tension, fixed at the rim, has + modes J_m[j_mn r/a] cos[m theta] at f_mn = j_mn c / [2 pi a], where j_mn + is the n-th zero of the Bessel function J_m [Rayleigh, Theory of Sound, + ch. 9; Fletcher and Rossing, The Physics of Musical Instruments, 3.6]. + The tables hold the 40 lowest, sorted by frequency: the ratio to the + fundamental j_01 = 2.4048, the nodal diameters m, and each mode's + radial shape at 17 radii r/a = 0, 1/16 .. 1, normalized to a peak of 1. + Generated by tools/fit/bessel.py; don't edit by hand. + + memb-shape-at [mode r -- y] reads the shape at r in 0..1 by linear + interpolation; strike points and microphone points use it. ) + +include "../00-primitives/table.fy" + +:: MEMB-N 40 ; + +( j_mn / j_01, x 1e6 ) +table: memb-ratio 40 + f>i [ 1000000 1593341 2135549 2295417 2653066 2917295 3155465 3500147 3598485 3647451 + 4058932 4131738 4230439 4601045 4610052 4831885 4903281 5083567 5130769 5412118 + 5540399 5553126 5650842 5976540 6019356 6152609 6163137 6208732 6482735 6528612 + 6668997 6746213 6848992 7070708 7169427 7325257 7468242 7514501 7604536 7665198 + 7665198 ] swap qnth i>f 0.000001 f* ; + +( nodal diameters m ) +table: memb-m 40 + f>i [ 0 1 2 0 3 1 4 2 0 5 3 6 1 4 7 2 0 8 5 3 + 1 9 6 4 10 2 7 0 11 5 8 3 1 6 9 4 2 0 7 10 + 10 ] swap qnth i>f ; + +( J_m[j_mn r] / max |J_m|, mode-major, 17 radii per mode, x 1e6 ) +table: memb-shape 680 + f>i [ 1000000 994360 977537 949813 911659 863715 806789 741832 669930 592274 510144 424886 337882 250528 164208 80271 0 + 0 204317 399887 578380 732277 855225 942334 990398 998042 965765 895899 792481 661032 508277 341799 169660 0 + 0 26245 102296 220359 368277 530696 690535 830632 935387 992273 993049 934574 819132 654257 452054 228096 0 + 1000000 970464 884467 749592 577647 383510 183684 -5288 -168402 -293577 -372743 -402488 -384243 -323964 -231371 -118831 0 + 0 3011 23376 75020 165575 294564 452936 624061 786054 915066 989063 991488 914227 759405 539694 277045 0 + 0 367804 683417 902877 997318 957340 794191 537570 230483 -77977 -340677 -519971 -593460 -557056 -424908 -226283 0 + 0 326 5042 24113 70303 154470 280819 443437 625146 799203 933699 997841 968812 837662 612820 320253 0 + 0 69484 259091 517070 771917 950560 997007 886734 633218 284971 -86143 -402364 -598873 -639318 -524191 -290067 0 + 1000000 928194 728183 442513 130785 -144341 -331576 -402337 -356278 -219818 -38248 136409 258588 300079 255967 144321 0 + 0 34 1049 7477 28804 78187 168111 304369 480493 674960 852844 972302 994702 895857 675122 359365 0 + 0 10638 79305 237384 473128 731814 932968 998829 883948 595166 194306 -218113 -533219 -668220 -593322 -341867 0 + 0 3 213 2261 11513 38617 98230 203983 360732 557033 761598 926754 999572 938269 728775 395314 0 + 0 519236 886267 998462 835385 463469 11553 -373815 -577510 -552430 -331378 -11016 285415 452693 439512 263352 0 + 0 1455 21653 96949 256822 495306 757899 953649 989805 814678 448710 -12504 -427814 -662672 -642825 -386476 0 + 0 0 42 671 4518 18730 56378 134313 266165 451966 668925 869187 988837 967870 775197 428677 0 + 0 129657 452831 803366 993702 904313 541497 36436 -413873 -635944 -557610 -238384 160894 453885 513879 328573 0 + 1000000 868759 526318 104304 -241879 -398367 -337761 -124391 120783 277978 281795 145861 -50691 -206233 -247128 -161319 0 + 0 0 8 196 1748 8957 31910 87237 193771 361937 580065 805136 966532 986890 815404 459850 0 + 0 185 5498 36778 129219 309691 565634 828336 988485 943699 656584 190829 -299943 -632433 -677498 -425919 0 + 0 24765 174536 473168 809502 995671 883884 469442 -86460 -533328 -666195 -440669 217 404937 553736 379523 0 + 0 655316 989089 855085 357327 -216584 -562285 -527067 -183417 230123 459536 384384 77823 -252570 -400508 -286881 0 + 0 0 2 57 669 4236 17862 56045 139564 286825 497916 738488 935783 997118 850192 489130 0 + 0 22 1327 13252 61688 183397 398673 676160 919124 996289 812070 378519 -160885 -583821 -700127 -461255 0 + 0 4075 57639 236217 547344 869373 998902 787852 272165 -313068 -661497 -589911 -165749 327473 571592 422035 0 + 0 0 0 16 253 1984 9907 35684 99644 225367 423869 671946 899045 1000024 880216 516755 0 + 0 204487 653700 978132 880848 362975 -270545 -628485 -512516 -52953 395110 513422 244494 -183875 -442911 -351090 0 + 0 3 308 4597 28331 104374 269601 528094 813582 990604 917019 543308 -18539 -521548 -712714 -493205 0 + 1000000 793859 301377 -184850 -400771 -278720 26668 264912 270862 69525 -163941 -248663 -133361 76391 211021 171375 0 + 0 0 0 5 95 922 5453 22549 70615 175799 358308 607265 858136 996707 905900 542848 0 + 0 608 17228 106193 330186 665210 949130 958146 582601 -39126 -565797 -677824 -320717 233002 573147 458442 0 + 0 0 70 1548 12628 57615 176655 398968 694515 944098 977050 681819 121515 -449219 -716720 -522242 0 + 0 46868 307171 728462 994939 811087 206046 -435493 -667623 -354809 208701 544667 392346 -82708 -450383 -399096 0 + 0 772542 983556 525062 -188485 -585077 -409912 96290 448665 355136 -51364 -371920 -321927 21968 319904 299049 0 + 0 85 4800 44377 184248 466653 811923 999311 808782 239354 -406462 -705900 -455360 129187 561971 490063 0 + 0 0 15 509 5496 31041 112901 293670 576530 871672 999391 793211 255336 -369723 -713302 -548711 0 + 0 9010 119610 437209 843167 992327 631535 -78923 -624046 -581033 -31822 491249 504114 30806 -436400 -437621 0 + 0 291059 829916 971717 454554 -308065 -644228 -307530 288870 519218 187186 -308981 -434421 -86449 328261 360175 0 + 1000000 705633 77885 -362175 -316395 42652 291490 193024 -98974 -249696 -113264 133831 212661 51162 -154833 -175273 0 + 0 11 1269 17565 97100 307619 646429 948826 943789 489757 -209683 -680635 -564526 21523 540515 517683 0 + 0 0 3 164 2346 16396 70708 211670 468037 785112 991596 878295 380205 -285416 -703420 -572880 0 + 0 ] swap qnth i>f 0.000001 f* ; + +( mode r -- y : mode i's radial shape at r in [0, 1], interpolated ) +dsp: memb-shape-at | i r -- y | + r 0.0 1.0 fclamp 16.0 f* | x | + x floor 15.0 fmin | x0 | + x x0 f- | fr | + i 17.0 f* x0 f+ | j | + memb-shape j f@i | a | + memb-shape j 1.0 f+ f@i | b | + a b a f- fr f* f+ +; diff --git a/kernels/10-physical/modal.fy b/kernels/10-physical/modal.fy new file mode 100644 index 00000000..21ccbb1f --- /dev/null +++ b/kernels/10-physical/modal.fy @@ -0,0 +1,99 @@ +( modal.fy - building blocks for modal physical models: struck strings, + membranes, bars and plates [docs/41]. + + A vibrating body is a bank of independent damped oscillators, one per + mode: q'' + 2 sigma q' + w^2 q = f, where q is the mode's amplitude + in metres [the mode shape is a unit sine or Bessel profile], f its + modal force per unit modal mass, and sigma its decay rate [1/s]. + + modal-step advances one mode by one sample exactly for a force held + over the sample [zero-order hold]: the undamped oscillator's closed + form, then the decay factor rho = exp[-sigma h]. Because the step is + exact, a mode near Nyquist is as stable as a mode at 50 Hz, and the + decay rate is the one asked for at any pitch. Every coefficient + depends only on w, h and sigma, so a voice computes them once per note + [zoh-coefs] and the render only multiplies and adds. + + q' = rho [ c q + s/w v ] + B1 f c = cos wh, s = sin wh + v' = rho [ -w s q + c v ] + B2 f B1 = [1 - c]/w^2 = 2 sin^2[wh/2]/w^2 + B2 = s/w + + contact-solve is the striker: a felt or wooden mass pressed into the + body with the power-law Hunt-Crossley force + + F = K e^p [1 + alpha de/dt], e > 0 the compression + + solved implicitly. Over one sample the compression responds linearly + to the force it creates, e = e_free - D F, with D the striker's and the + body's compliance at the contact point. Newton on that scalar + equation from e_free converges from above in a few steps and stays + stable for the stiff felt of a treble hammer, where an explicit force + would blow up. + + The derivations are textbook: modal synthesis [Adrien 1991], the + power-law felt [Hall 1986; Stulov 1995], Hunt-Crossley damping [Hunt + and Crossley 1975], and the implicit contact solve follows Bilbao, + Numerical Sound Synthesis [2009], ch. 4. ) + +include "../00-primitives/math.fy" + +( T60 seconds -- sigma : the amplitude decay rate that falls 60 dB in T60. ) +dsp: t60>sigma | t60 -- s | 6.907755278982137 t60 0.0001 fmax f/ ; + +( w h -- c s/w w*s B1 B2 : the exact ZOH coefficients of one undamped + mode at angular frequency w over a step h. B1 uses the half-angle form + so it keeps its precision at low w h. ) +dsp: zoh-coefs | w h -- c sw ws b1 b2 | + w h f* | wh | + wh cos | c | + wh sin | s | + wh 0.5 f* sin | sh | + w w f* | w2 | + c s w f/ w s f* 2.0 sh f* sh f* w2 f/ s w f/ +; + +( q v c sw ws rho f b1 b2 -- q' v' : one mode, one sample, force f held. ) +dsp: modal-step | q v c sw ws rho f b1 b2 -- qn vn | + c q f* sw v f* f+ rho f* b1 f f* f+ + ws q f* fneg c v f* f+ rho f* b2 f f* f+ +; + +( e^p and its derivative p e^[p-1] for e > 0 - the felt's law. ) +dsp: felt-pow | e p -- ep dep | + e 1.0e-12 fmax | ec | + ec log2 p f* exp2 | ep | + ep ep p f* ec f/ +; + +( ef eold D K p alpha inv-h -- e F : the implicit power-law Hunt-Crossley + contact. ef is the compression the next sample would have with no + contact force, eold this sample's compression, D the compliance [m/N] + mapping force to compression change, K [N/m^p] and p the felt, alpha + [s/m] its hysteresis, inv-h the sample rate. + + Solves g[e] = e + D F[e] - ef = 0 with + F[e] = K e^p max[0, 1 + alpha [e - eold] inv-h] + by four Newton steps from e = ef. g is increasing and convex for + e > 0, so the iterates fall monotonically toward the root and never + cross into e < 0. Out of contact [ef <= 0] the force is zero and the + compression is ef. ) +dsp: contact-newton | e ef eold D K p alpha inv-h -- en | + e p felt-pow | ep dep | + 1.0 e eold f- alpha f* inv-h f* f+ 0.0 fmax | hc | + K ep f* hc f* | f | + K dep f* hc f* K ep f* alpha f* inv-h f* f+ | df | + e e D f f* f+ ef f- 1.0 D df f* f+ f/ f- 1.0e-12 fmax +; + +dsp: contact-solve | ef eold D K p alpha inv-h -- e F | + ef 1.0e-12 fmax | e | + e ef eold D K p alpha inv-h contact-newton | e | + e ef eold D K p alpha inv-h contact-newton | e | + e ef eold D K p alpha inv-h contact-newton | e | + e ef eold D K p alpha inv-h contact-newton | e | + e p felt-pow | ep dep | + K ep f* 1.0 e eold f- alpha f* inv-h f* f+ 0.0 fmax f* | f | + ef 0.0 f> | in | + in e ef select + in f 0.0 select +; diff --git a/machines/grand/grand.fy b/machines/grand/grand.fy new file mode 100644 index 00000000..5252caf2 --- /dev/null +++ b/machines/grand/grand.fy @@ -0,0 +1,51 @@ +( grand.fy - Hammerwork Grand: a modal physical-model piano [docs/41]. + + The voice is kernels/06-voices/piano.fy: per key, up to three unison + strings as banks of stiff-string modes, a felt hammer solved as an + implicit contact, and a 12-mode soundboard the strings drive through a + moving bridge. Stereo comes from two microphone points over the + board. PEDAL lifts the dampers on held and released notes alike. ) + +include "../../kernels/06-voices/piano.fy" +include "../lib/manifest.fy" + +: manifest + "Hammerwork Grand" voice-sample machine* + "k-piano-voice" render! + "piano-note-on" note-on! + "piano-note-off" note-off! + "piano-block-prepare" block-prepare! + "piano-control" 32 control! + 16 voices! + stereo + PianoState.size state-size! + PianoParams.size params-size! + 560.0 panel-w! + + "HAMMER" "HARD" "gp-hard" PianoParams.hard -1.0 1.0 -0.6 curve-lin knob + "HAMMER" "FELT" "gp-felt" PianoParams.felt 0.0 1.0 0.35 curve-lin knob + "HAMMER" "STRIKE" "gp-strike" PianoParams.strike -1.0 1.0 0.0 curve-lin knob + "HAMMER" 3 strip + + "STRING" "DECAY" "gp-decay" PianoParams.decay 0.25 4.0 1.5 curve-exp knob + "STRING" "HF DEC" "gp-hfdec" PianoParams.hf-decay 0.2 8.0 3.0 curve-exp knob + "STRING" "STIFF" "gp-stiff" PianoParams.stiff 0.0 3.0 1.0 curve-lin knob + "STRING" "UNISON" "gp-unison" PianoParams.unison 0.0 6.0 1.2 curve-lin knob + "STRING" 2 strip + + "BOARD" "COUPLE" "gp-board" PianoParams.board 0.1 2.0 1.0 curve-exp knob + "BOARD" "WIDTH" "gp-width" PianoParams.width 0.0 1.0 0.7 curve-lin knob + "BOARD" 2 strip + + "DAMPER" "RELEASE" "gp-release" PianoParams.release 0.05 2.0 0.25 curve-exp knob + "DAMPER" "PEDAL" "gp-pedal" PianoParams.pedal 0.0 1.0 0.0 curve-lin knob + "DAMPER" "LEVEL" "gp-level" PianoParams.level 0.0 1.0 0.7 curve-pow knob + "DAMPER" 3 strip + 1.0 row + 1.0 cell "HAMMER" 1.0 item + 1.0 cell "STRING" 1.0 item + 1.0 cell "BOARD" 1.0 item + 1.0 cell "DAMPER" 1.0 item + + machine-desc +; diff --git a/machines/grand/presets/bright-pop.preset b/machines/grand/presets/bright-pop.preset new file mode 100644 index 00000000..bbbe84b9 --- /dev/null +++ b/machines/grand/presets/bright-pop.preset @@ -0,0 +1 @@ +{"slab": "preset", "schema": 1, "machine": "grand", "note": "Hard-voiced hammers and a lively board: cuts through a band mix", "params": {"gp-hard": 0.6, "gp-felt": 0.25, "gp-strike": 0.0, "gp-decay": 1.5, "gp-hfdec": 4.5, "gp-stiff": 1.0, "gp-unison": 1.2, "gp-board": 1.3, "gp-width": 0.7, "gp-release": 0.25, "gp-pedal": 0.0, "gp-level": 0.7}} \ No newline at end of file diff --git a/machines/grand/presets/default.preset b/machines/grand/presets/default.preset new file mode 100644 index 00000000..c8cbffa4 --- /dev/null +++ b/machines/grand/presets/default.preset @@ -0,0 +1 @@ +{"slab": "preset", "schema": 1, "machine": "grand", "note": "Concert grand at its own voicing: medium felt, long sustain", "params": {"gp-hard": -0.6, "gp-felt": 0.35, "gp-strike": 0.0, "gp-decay": 1.5, "gp-hfdec": 3.0, "gp-stiff": 1.0, "gp-unison": 1.2, "gp-board": 1.0, "gp-width": 0.7, "gp-release": 0.25, "gp-pedal": 0.0, "gp-level": 0.7}} \ No newline at end of file diff --git a/machines/grand/presets/dry-studio.preset b/machines/grand/presets/dry-studio.preset new file mode 100644 index 00000000..45af0243 --- /dev/null +++ b/machines/grand/presets/dry-studio.preset @@ -0,0 +1 @@ +{"slab": "preset", "schema": 1, "machine": "grand", "note": "Short, focused and close: dampers quick, sustain trimmed", "params": {"gp-hard": -0.6, "gp-felt": 0.35, "gp-strike": 0.0, "gp-decay": 0.8, "gp-hfdec": 2.0, "gp-stiff": 1.0, "gp-unison": 1.2, "gp-board": 0.8, "gp-width": 0.4, "gp-release": 0.12, "gp-pedal": 0.0, "gp-level": 0.7}} \ No newline at end of file diff --git a/machines/grand/presets/felt-ballad.preset b/machines/grand/presets/felt-ballad.preset new file mode 100644 index 00000000..7d37dd5a --- /dev/null +++ b/machines/grand/presets/felt-ballad.preset @@ -0,0 +1 @@ +{"slab": "preset", "schema": 1, "machine": "grand", "note": "Soft felt and a dark top, dampers half lifted: intimate and round", "params": {"gp-hard": -1.0, "gp-felt": 0.6, "gp-strike": 0.0, "gp-decay": 1.5, "gp-hfdec": 1.6, "gp-stiff": 1.0, "gp-unison": 1.2, "gp-board": 1.0, "gp-width": 0.8, "gp-release": 0.25, "gp-pedal": 0.4, "gp-level": 0.7}} \ No newline at end of file diff --git a/machines/grand/presets/honky-tonk.preset b/machines/grand/presets/honky-tonk.preset new file mode 100644 index 00000000..46565166 --- /dev/null +++ b/machines/grand/presets/honky-tonk.preset @@ -0,0 +1 @@ +{"slab": "preset", "schema": 1, "machine": "grand", "note": "Unisons pulled apart and a bright hammer: the saloon upright", "params": {"gp-hard": 0.4, "gp-felt": 0.35, "gp-strike": 0.0, "gp-decay": 0.8, "gp-hfdec": 3.0, "gp-stiff": 1.0, "gp-unison": 5.0, "gp-board": 1.4, "gp-width": 0.5, "gp-release": 0.25, "gp-pedal": 0.0, "gp-level": 0.7}} \ No newline at end of file diff --git a/machines/grand/presets/sustain-wash.preset b/machines/grand/presets/sustain-wash.preset new file mode 100644 index 00000000..d281dee7 --- /dev/null +++ b/machines/grand/presets/sustain-wash.preset @@ -0,0 +1 @@ +{"slab": "preset", "schema": 1, "machine": "grand", "note": "Pedal down: every note rings into the next, wide board", "params": {"gp-hard": -0.6, "gp-felt": 0.35, "gp-strike": 0.0, "gp-decay": 2.2, "gp-hfdec": 3.0, "gp-stiff": 1.0, "gp-unison": 1.2, "gp-board": 1.0, "gp-width": 1.0, "gp-release": 0.6, "gp-pedal": 1.0, "gp-level": 0.7}} \ No newline at end of file diff --git a/machines/kit/kit.fy b/machines/kit/kit.fy new file mode 100644 index 00000000..09fa02cd --- /dev/null +++ b/machines/kit/kit.fy @@ -0,0 +1,69 @@ +( kit.fy - Skinwork Kit: a modal physical-model drum kit [docs/41]. + + The voice is kernels/06-voices/kit.fy: each hit is a drumhead [Bessel + modes, tension glide, snare wires] or a cymbal [coupled plate modes] + struck by a stick or beater through an implicit contact. Notes follow + the GM drum map; notes outside it fold into it by octave. ) + +include "../../kernels/06-voices/kit.fy" +include "../lib/manifest.fy" + +: manifest + "Skinwork Kit" voice-sample machine* + "k-kit-voice" render! + "kit-note-on" note-on! + "kit-block-prepare" block-prepare! + "kit-control" 32 control! + 12 voices! + stereo + note-pitch + KitState.size state-size! + KitParams.size params-size! + 36 "KICK" note-label 38 "SNARE" note-label 37 "SIDE" note-label + 41 "LT" note-label 45 "MT" note-label 48 "HT" note-label + 42 "CH" note-label 44 "PH" note-label 46 "OH" note-label + 49 "CRASH" note-label 51 "RIDE" note-label 53 "BELL" note-label + 760.0 panel-w! + + "KICK" "TUNE" "sk-kick-tune" KitParams.kick-tune -12.0 12.0 0.0 curve-lin knob + "KICK" "DECAY" "sk-kick-decay" KitParams.kick-decay 0.25 4.0 1.0 curve-exp knob + "KICK" "LEVEL" "sk-kick-level" KitParams.kick-level 0.0 1.0 0.8 curve-pow knob + "KICK" 3 strip + "SNARE" "TUNE" "sk-snare-tune" KitParams.snare-tune -12.0 12.0 0.0 curve-lin knob + "SNARE" "DECAY" "sk-snare-decay" KitParams.snare-decay 0.25 4.0 1.0 curve-exp knob + "SNARE" "LEVEL" "sk-snare-level" KitParams.snare-level 0.0 1.0 0.8 curve-pow knob + "SNARE" 3 strip + "TOMS" "TUNE" "sk-tom-tune" KitParams.tom-tune -12.0 12.0 0.0 curve-lin knob + "TOMS" "DECAY" "sk-tom-decay" KitParams.tom-decay 0.25 4.0 1.0 curve-exp knob + "TOMS" "LEVEL" "sk-tom-level" KitParams.tom-level 0.0 1.0 0.8 curve-pow knob + "TOMS" 3 strip + "HATS" "TUNE" "sk-hat-tune" KitParams.hat-tune -12.0 12.0 0.0 curve-lin knob + "HATS" "DECAY" "sk-hat-decay" KitParams.hat-decay 0.25 4.0 1.0 curve-exp knob + "HATS" "LEVEL" "sk-hat-level" KitParams.hat-level 0.0 1.0 0.7 curve-pow knob + "HATS" 3 strip + "CRASH" "TUNE" "sk-crash-tune" KitParams.crash-tune -12.0 12.0 0.0 curve-lin knob + "CRASH" "DECAY" "sk-crash-decay" KitParams.crash-decay 0.25 4.0 1.0 curve-exp knob + "CRASH" "LEVEL" "sk-crash-level" KitParams.crash-level 0.0 1.0 0.7 curve-pow knob + "CRASH" 3 strip + "RIDE" "TUNE" "sk-ride-tune" KitParams.ride-tune -12.0 12.0 0.0 curve-lin knob + "RIDE" "DECAY" "sk-ride-decay" KitParams.ride-decay 0.25 4.0 1.0 curve-exp knob + "RIDE" "LEVEL" "sk-ride-level" KitParams.ride-level 0.0 1.0 0.7 curve-pow knob + "RIDE" 3 strip + "PLAY" "STICK" "sk-stick" KitParams.stick -1.0 1.0 0.0 curve-lin knob + "PLAY" "WIRES" "sk-wires" KitParams.wires 0.0 1.0 0.7 curve-lin knob + "PLAY" "GLIDE" "sk-glide" KitParams.glide 0.0 2.0 1.0 curve-lin knob + "PLAY" "BLOOM" "sk-bloom" KitParams.bloom 0.0 2.0 1.0 curve-lin knob + "PLAY" "WIDTH" "sk-width" KitParams.width 0.0 1.0 0.6 curve-lin knob + "PLAY" "LEVEL" "sk-level" KitParams.level 0.0 1.0 0.7 curve-pow knob + "PLAY" 3 strip + 1.0 row + 1.0 cell "KICK" 1.0 item + 1.0 cell "SNARE" 1.0 item + 1.0 cell "TOMS" 1.0 item + 1.0 cell "HATS" 1.0 item + 1.0 cell "CRASH" 1.0 item + 1.0 cell "RIDE" 1.0 item + 2.0 cell "PLAY" 1.0 item + + machine-desc +; diff --git a/machines/kit/presets/default.preset b/machines/kit/presets/default.preset new file mode 100644 index 00000000..72556317 --- /dev/null +++ b/machines/kit/presets/default.preset @@ -0,0 +1 @@ +{"slab": "preset", "schema": 1, "machine": "kit", "note": "A studio kit: 18-inch kick, 14-inch snare, three toms, hats, crash and ride", "params": {"sk-kick-tune": 0.0, "sk-kick-decay": 1.0, "sk-kick-level": 0.8, "sk-snare-tune": 0.0, "sk-snare-decay": 1.0, "sk-snare-level": 0.8, "sk-tom-tune": 0.0, "sk-tom-decay": 1.0, "sk-tom-level": 0.8, "sk-hat-tune": 0.0, "sk-hat-decay": 1.0, "sk-hat-level": 0.7, "sk-crash-tune": 0.0, "sk-crash-decay": 1.0, "sk-crash-level": 0.7, "sk-ride-tune": 0.0, "sk-ride-decay": 1.0, "sk-ride-level": 0.7, "sk-stick": 0.0, "sk-wires": 0.7, "sk-glide": 1.0, "sk-bloom": 1.0, "sk-width": 0.6, "sk-level": 0.7}} \ No newline at end of file diff --git a/machines/kit/presets/funk-dry.preset b/machines/kit/presets/funk-dry.preset new file mode 100644 index 00000000..076e3c7f --- /dev/null +++ b/machines/kit/presets/funk-dry.preset @@ -0,0 +1 @@ +{"slab": "preset", "schema": 1, "machine": "kit", "note": "Tight and dead: short heads, crisp snare, closed hats up front", "params": {"sk-kick-tune": 0.0, "sk-kick-decay": 0.5, "sk-kick-level": 0.8, "sk-snare-tune": 4.0, "sk-snare-decay": 0.6, "sk-snare-level": 0.8, "sk-tom-tune": 0.0, "sk-tom-decay": 0.6, "sk-tom-level": 0.8, "sk-hat-tune": 0.0, "sk-hat-decay": 0.7, "sk-hat-level": 0.8, "sk-crash-tune": 0.0, "sk-crash-decay": 0.6, "sk-crash-level": 0.7, "sk-ride-tune": 0.0, "sk-ride-decay": 0.6, "sk-ride-level": 0.7, "sk-stick": 0.0, "sk-wires": 0.9, "sk-glide": 1.0, "sk-bloom": 1.0, "sk-width": 0.4, "sk-level": 0.7}} \ No newline at end of file diff --git a/machines/kit/presets/gong-wash.preset b/machines/kit/presets/gong-wash.preset new file mode 100644 index 00000000..77b27f6a --- /dev/null +++ b/machines/kit/presets/gong-wash.preset @@ -0,0 +1 @@ +{"slab": "preset", "schema": 1, "machine": "kit", "note": "Cymbals left to bloom: heavy coupling and long decays for swells", "params": {"sk-kick-tune": 0.0, "sk-kick-decay": 1.0, "sk-kick-level": 0.8, "sk-snare-tune": 0.0, "sk-snare-decay": 1.0, "sk-snare-level": 0.8, "sk-tom-tune": 0.0, "sk-tom-decay": 1.0, "sk-tom-level": 0.8, "sk-hat-tune": 0.0, "sk-hat-decay": 1.8, "sk-hat-level": 0.7, "sk-crash-tune": -5.0, "sk-crash-decay": 2.5, "sk-crash-level": 0.7, "sk-ride-tune": 0.0, "sk-ride-decay": 2.0, "sk-ride-level": 0.7, "sk-stick": 0.0, "sk-wires": 0.7, "sk-glide": 1.0, "sk-bloom": 1.8, "sk-width": 0.6, "sk-level": 0.7}} \ No newline at end of file diff --git a/machines/kit/presets/jazz.preset b/machines/kit/presets/jazz.preset new file mode 100644 index 00000000..dbe1698e --- /dev/null +++ b/machines/kit/presets/jazz.preset @@ -0,0 +1 @@ +{"slab": "preset", "schema": 1, "machine": "kit", "note": "Small kit tuned high, light sticks, long washy cymbals, loose wires", "params": {"sk-kick-tune": 7.0, "sk-kick-decay": 1.6, "sk-kick-level": 0.8, "sk-snare-tune": 3.0, "sk-snare-decay": 1.0, "sk-snare-level": 0.8, "sk-tom-tune": 5.0, "sk-tom-decay": 1.4, "sk-tom-level": 0.8, "sk-hat-tune": 0.0, "sk-hat-decay": 1.0, "sk-hat-level": 0.7, "sk-crash-tune": 0.0, "sk-crash-decay": 1.4, "sk-crash-level": 0.7, "sk-ride-tune": 0.0, "sk-ride-decay": 1.6, "sk-ride-level": 0.8, "sk-stick": -0.5, "sk-wires": 0.5, "sk-glide": 0.6, "sk-bloom": 1.2, "sk-width": 0.6, "sk-level": 0.7}} \ No newline at end of file diff --git a/machines/kit/presets/rock.preset b/machines/kit/presets/rock.preset new file mode 100644 index 00000000..503d0c4d --- /dev/null +++ b/machines/kit/presets/rock.preset @@ -0,0 +1 @@ +{"slab": "preset", "schema": 1, "machine": "kit", "note": "Big low tuning, hard sticks, the toms drop in pitch when hit hard", "params": {"sk-kick-tune": -3.0, "sk-kick-decay": 1.0, "sk-kick-level": 0.8, "sk-snare-tune": -2.0, "sk-snare-decay": 1.3, "sk-snare-level": 0.8, "sk-tom-tune": -4.0, "sk-tom-decay": 1.6, "sk-tom-level": 0.8, "sk-hat-tune": 0.0, "sk-hat-decay": 1.0, "sk-hat-level": 0.7, "sk-crash-tune": 0.0, "sk-crash-decay": 1.0, "sk-crash-level": 0.8, "sk-ride-tune": 0.0, "sk-ride-decay": 1.0, "sk-ride-level": 0.7, "sk-stick": 0.6, "sk-wires": 0.8, "sk-glide": 1.6, "sk-bloom": 1.0, "sk-width": 0.6, "sk-level": 0.7}} \ No newline at end of file diff --git a/machines/lib/manifest.fy b/machines/lib/manifest.fy index 2b333aa7..f9cc85de 100644 --- a/machines/lib/manifest.fy +++ b/machines/lib/manifest.fy @@ -91,6 +91,16 @@ struct: MachineDesc 30 ms after it, takes over that note's voice with ctx.legato = 1 instead of a voice of its own: a hammer-on or pull-off on a string [docs/08] ) + ptr shared ( cstr or 0 - voice machines: a render word [io ctx + shared params --] over the machine's shared region, + run in lockstep with the voices [docs/04 §Shared state] ) + ptr shared-period ( int samples: voices and the shared word alternate in + steps this long ) + ptr shared-size ( int bytes of the shared region, 0 = none; every entry + word sees it as ctx.shared ) + ptr voice-level ( int: state byte offset + 1 of an f64 a voice keeps at + its own level, which idling watches as well as its + output; 0 = output only [docs/04 §Shared state] ) ; struct: ControlDesc @@ -224,6 +234,28 @@ struct: AssetDesc ptr next ptr name ptr ptr-offset ptr len-offset ptr sr-o ( semitones legato-span! : overlapping single notes within this interval play legato on one voice [see MachineDesc.legato-span] ) : legato-span! ( f -- ) _mf-md@ MachineDesc.legato-span! drop ; +( bytes shared-size! : one region of this size for the whole machine, + zeroed at load and reset, that every entry word reads and writes + through ctx.shared [read the pointer with ctx.shared& p@64] - a body all + the voices act on, such as a soundboard or a shared resonator ) +: shared-size! ( n -- ) _mf-md@ MachineDesc.shared-size! drop ; +( "word" period shared! : the shared region's render word [io ctx shared + params --]. The host renders the sounding voices for `period` samples, + one voice at a time in voice order, then this word over the same + samples, and so on: a voice sees what the shared word left after the + previous step, and the shared word sees what every voice added during + it. It accumulates into io like a voice, and keeps running after the + voices sleep until its own output falls under the idle floor + [docs/04 §Shared state] ) +: shared! ( str period -- ) + _mf-md@ MachineDesc.shared-period! drop + cstr-new _mf-md@ MachineDesc.shared! drop ; +( offset voice-level! : the state f64 at `offset` is the voice's own + level, kept by its render word. A voice goes idle when it is released + and both its output and this level stay under the idle floor for a + block - for voices whose sound reaches the output through a shared + word rather than their own io [docs/04 §Shared state] ) +: voice-level! ( offset -- ) 1 + _mf-md@ MachineDesc.voice-level! drop ; : render-lite! ( str offset -- ) _mf-md@ MachineDesc.render-lite-sel! drop cstr-new _mf-md@ MachineDesc.render-lite! drop ; diff --git a/machines/raw_fixtures/shared_body.fy b/machines/raw_fixtures/shared_body.fy new file mode 100644 index 00000000..2d1fb167 --- /dev/null +++ b/machines/raw_fixtures/shared_body.fy @@ -0,0 +1,47 @@ +( shared_body.fy - fixture: voices acting on one shared region + [manifest `shared-size!` / `shared!`, docs/04 §Shared state]. + + Every held voice adds its velocity to the shared drive each sample. The + shared word is a leaky body: it adds the drive to its level, outputs the + level and decays it by half each step, then clears the drive. A voice + also reads the body's level into its own state, so the test can see + that voices observe what the shared word left after the previous step. ) + +include "../../kernels/00-primitives/ctx.fy" + +ustruct: ShVState f64 gate f64 vel f64 seen ; +ustruct: ShVParams f64 dummy ; +ustruct: ShBody f64 drive f64 level ; + +dsp: sh-voice-render | io ctx:Ctx state:ShVState params -- | + ctx.shared& p@64 | sh:ShBody | + sh.level -> state.seen + sh.drive state.gate state.vel f* f+ -> sh.drive +; + +dsp: sh-voice-on + | ctx:Ctx state:ShVState params -- | 1.0 -> state.gate ctx.vel -> state.vel ; +dsp: sh-voice-off + | ctx state:ShVState params -- | 0.0 -> state.gate ; + +dsp: sh-body-render | io:Io ctx body:ShBody params -- | + body.level body.drive f+ | y | + io.out-l y f+ -> io.out-l + y 0.5 f* -> body.level + 0.0 -> body.drive +; + +include "../lib/manifest.fy" + +: manifest + "raw-shared-body" voice-sample machine* + "sh-voice-render" render! + "sh-voice-on" note-on! + "sh-voice-off" note-off! + 4 voices! + ShVState.size state-size! + ShVParams.size params-size! + ShBody.size shared-size! + "sh-body-render" 1 shared! + machine-desc +; diff --git a/machines/raw_fixtures/shared_clock.fy b/machines/raw_fixtures/shared_clock.fy new file mode 100644 index 00000000..a990aa6a --- /dev/null +++ b/machines/raw_fixtures/shared_clock.fy @@ -0,0 +1,48 @@ +( shared_clock.fy - fixture: a shared word that branches on ctx.clock, and + voices heard only through it that report their level with voice-level! + [docs/04 §Shared state]. + + Each held voice adds its gate to the shared drive. The shared word + outputs the drive on odd samples of the host clock and nothing on even + ones, so its output shows where each step falls. A voice makes no sound + itself: its level is 1 while held and decays by 0.9 a sample after the + release, and that field alone keeps it awake. ) + +include "../../kernels/00-primitives/ctx.fy" + +ustruct: SkVState f64 gate f64 lvl ; +ustruct: SkVParams f64 dummy ; +ustruct: SkBody f64 drive ; + +dsp: sk-voice-render | io ctx:Ctx state:SkVState params -- | + ctx.shared& p@64 | sk:SkBody | + state.gate 0.5 f> 1.0 state.lvl 0.9 f* select -> state.lvl + sk.drive state.gate f+ -> sk.drive +; + +dsp: sk-voice-on + | ctx state:SkVState params -- | 1.0 -> state.gate 1.0 -> state.lvl ; +dsp: sk-voice-off + | ctx state:SkVState params -- | 0.0 -> state.gate ; + +dsp: sk-body-render | io:Io ctx:Ctx body:SkBody params -- | + ctx.clock 0.5 f* dup floor f- 0.25 f> [ body.drive ] [ 0.0 ] ifte | y | + io.out-l y f+ -> io.out-l + 0.0 -> body.drive +; + +include "../lib/manifest.fy" + +: manifest + "raw-shared-clock" voice-sample machine* + "sk-voice-render" render! + "sk-voice-on" note-on! + "sk-voice-off" note-off! + 4 voices! + SkVState.size state-size! + SkVParams.size params-size! + SkBody.size shared-size! + "sk-body-render" 1 shared! + SkVState.lvl voice-level! + machine-desc +; diff --git a/src/machine_desc.zig b/src/machine_desc.zig index 5c80ca57..81b97c3b 100644 --- a/src/machine_desc.zig +++ b/src/machine_desc.zig @@ -33,6 +33,8 @@ pub const MAX_NOTE_LABELS = 32; pub const MAX_BUFFERS = 8; pub const MAX_ASSETS = 4; pub const MAX_PATH = 128; +/// Largest shared region a machine may ask for (docs/04 §Shared state). +pub const MAX_SHARED = 1 << 20; /// Largest integer range a display select steps through. pub const MAX_DISPLAY_STEPS = 128; @@ -395,6 +397,16 @@ pub const Desc = struct { control_word: [MAX_WORD]u8 = [_]u8{0} ** MAX_WORD, control_word_len: usize = 0, control_period: usize = 0, + /// Voice machines: one region shared by every voice (docs/04 §Shared + /// state), and the render word that runs over it in lockstep with the + /// voices, `shared_period` samples at a time. 0 / empty = none. + shared_size: usize = 0, + shared_word: [MAX_WORD]u8 = [_]u8{0} ** MAX_WORD, + shared_word_len: usize = 0, + shared_period: usize = 0, + /// State byte offset + 1 of the f64 a voice keeps at its own level, + /// which idling watches besides its output; 0 = output only. + voice_level_sel: usize = 0, /// Params byte offset + 1 of the f64 holding the machine's latency in /// samples (docs/07 §PDC); 0 = none. latency_sel: usize = 0, @@ -489,6 +501,10 @@ pub const Desc = struct { return if (self.control_word_len == 0) null else self.control_word[0..self.control_word_len]; } + pub fn sharedWord(self: *const Desc) ?[]const u8 { + return if (self.shared_word_len == 0) null else self.shared_word[0..self.shared_word_len]; + } + pub fn renderLiteWord(self: *const Desc) ?[]const u8 { return if (self.render_lite_word_len == 0) null else self.render_lite_word[0..self.render_lite_word_len]; } @@ -556,6 +572,10 @@ const MachineDescRaw = extern struct { matrix: Fy.Value, matrix_slots: Fy.Value, legato_span: Fy.Value, + shared: Fy.Value, + shared_period: Fy.Value, + shared_size: Fy.Value, + voice_level: Fy.Value, }; const ModRaw = extern struct { next: Fy.Value, kind: Fy.Value, name: Fy.Value, index: Fy.Value, offset: Fy.Value, flag: Fy.Value }; @@ -692,6 +712,13 @@ pub fn read(host: *FyHost) !Desc { d.control_word_len = try copyBuf(d.control_word[0..], cstrSlice(md.control)); d.control_period = @intCast(asInt(md.control_period)); if (d.control_word_len > 0 and (d.mode != .voice_sample or d.control_period == 0)) return error.InvalidMachineDesc; + d.shared_size = @intCast(@max(0, asInt(md.shared_size))); + d.shared_word_len = try copyBuf(d.shared_word[0..], cstrSlice(md.shared)); + d.shared_period = @intCast(@max(0, asInt(md.shared_period))); + if (d.shared_size > MAX_SHARED) return error.InvalidMachineDesc; + if (d.shared_word_len > 0 and (d.mode != .voice_sample or d.shared_period == 0 or d.shared_size == 0)) + return error.InvalidMachineDesc; + d.voice_level_sel = @intCast(@max(0, asInt(md.voice_level))); // derive-data is an opaque heap pointer (fy `alloc` returns the raw address // as a tagged int); >>2 recovers it. 0 = none. d.derive_data = @intCast(@as(u64, @bitCast(md.derive_data)) >> 2); diff --git a/src/machine_registry.zig b/src/machine_registry.zig index 3f5db9d6..62d823ba 100644 --- a/src/machine_registry.zig +++ b/src/machine_registry.zig @@ -67,6 +67,8 @@ pub const builtin_machines = [_][]const u8{ "machines/cs80/cs80.fy", "machines/jp8/jp8.fy", "machines/rhodes/rhodes.fy", + "machines/grand/grand.fy", + "machines/kit/kit.fy", "machines/bass/bass.fy", "machines/guitar/guitar.fy", "machines/funk/funk.fy", diff --git a/src/machines/fy_raw_machine.zig b/src/machines/fy_raw_machine.zig index d43a6554..9ac48d77 100644 --- a/src/machines/fy_raw_machine.zig +++ b/src/machines/fy_raw_machine.zig @@ -41,6 +41,10 @@ const Ui = ui_core.Ui; const Rect = ui_core.Rect; const MAX_STATE = 2048; +// A machine whose state is larger than MAX_STATE (the piano's mode banks) +// gets its regions from the heap at create instead of the inline buffer, +// up to this size per region. +const MAX_STATE_BIG = 32768; const MAX_PARAMS = 4096; // State regions: polyphonic voice machines get one per voice, effect // machines use two (L/R). The region index reaches kernels as ctx.chan. @@ -276,6 +280,8 @@ pub const KernelCtx = extern struct { // in note hooks. Voices that share one modulator (a global LFO, a // shared noise source) agree on its phase through it. clock: f64 = 0, + // The machine's shared region (manifest `shared-size!`), 0 = none. + shared: usize = 0, }; /// One sample's audio lanes, mirrored by `Io` in ctx.fy. Render words get a @@ -367,6 +373,18 @@ pub const FyRawMachine = struct { // region 1 so per-channel state never cross-talks; polyphonic voice // machines get one region per voice. state_buf: [MAX_REGIONS * MAX_STATE]u8 align(8) = [_]u8{0} ** (MAX_REGIONS * MAX_STATE), + // Heap regions for state over MAX_STATE, MAX_REGIONS of state_stride + // bytes; empty otherwise. Use stateRegion, never state_buf directly. + big_state: []align(8) u8 = &.{}, + state_stride: usize = MAX_STATE, + // The machine's shared region and its render word (docs/04 §Shared + // state). The word runs while a voice sounds, and after, until its own + // output stays under IDLE_FLOOR for a block. + shared_mem: []align(8) u8 = &.{}, + shared_slots: RawSlots = .{}, + shared_caller: ?RawCaller = null, + shared_awake: bool = false, + shared_peak: f64 = 0, params_buf: [MAX_PARAMS]u8 align(8) = [_]u8{0} ** MAX_PARAMS, io: [MAX_BLOCK]IoFrame = [_]IoFrame{.{}} ** MAX_BLOCK, kctx: KernelCtx = .{}, @@ -649,7 +667,7 @@ pub const FyRawMachine = struct { }; errdefer releaseHost(alloc, host, source); const desc = try machine_desc.read(host); - if (desc.state_size > MAX_STATE or desc.params_size > MAX_PARAMS) return error.RawMachineStorageTooLarge; + if (desc.state_size > MAX_STATE_BIG or desc.params_size > MAX_PARAMS) return error.RawMachineStorageTooLarge; if (desc.voices > MAX_REGIONS) return error.RawMachineTooManyVoices; try validateWord(host, desc.renderWord()); @@ -679,6 +697,18 @@ pub const FyRawMachine = struct { self.preset_dir_len = dir.len; self.presets = presets_mod.scanMachine(dir, self.machineId()); } + if (desc.state_size > MAX_STATE) { + self.state_stride = std.mem.alignForward(usize, desc.state_size, 64); + self.big_state = try alloc.alignedAlloc(u8, .@"8", MAX_REGIONS * self.state_stride); + @memset(self.big_state, 0); + } + errdefer if (self.big_state.len > 0) alloc.free(self.big_state); + if (desc.shared_size > 0) { + self.shared_mem = try alloc.alignedAlloc(u8, .@"8", std.mem.alignForward(usize, desc.shared_size, 8)); + @memset(self.shared_mem, 0); + self.kctx.shared = @intFromPtr(self.shared_mem.ptr); + } + errdefer if (self.shared_mem.len > 0) alloc.free(self.shared_mem); try self.allocBuffers(alloc); errdefer self.freeBuffersUpTo(alloc, self.desc.buffer_count); try self.loadAssets(alloc, path); @@ -1122,21 +1152,27 @@ pub const FyRawMachine = struct { } } + /// Region `ch`'s state bytes, desc.state_size long. + fn stateRegion(self: anytype, ch: usize) []align(8) u8 { + const bytes: []align(8) u8 = if (self.big_state.len > 0) self.big_state else @constCast(&self.state_buf); + return @alignCast(bytes[ch * self.state_stride ..][0..self.desc.state_size]); + } + fn writeStateUsize(self: *FyRawMachine, ch: usize, offset: usize, value: usize) void { if (offset + @sizeOf(usize) > self.desc.state_size) return; - const ptr: *align(8) usize = @ptrCast(@alignCast(&self.state_buf[ch * MAX_STATE + offset])); + const ptr: *align(8) usize = @ptrCast(@alignCast(&self.stateRegion(ch)[offset])); ptr.* = value; } fn writeStateF64(self: *FyRawMachine, ch: usize, offset: usize, value: f64) void { if (offset + @sizeOf(f64) > self.desc.state_size) return; - const ptr: *align(8) f64 = @ptrCast(@alignCast(&self.state_buf[ch * MAX_STATE + offset])); + const ptr: *align(8) f64 = @ptrCast(@alignCast(&self.stateRegion(ch)[offset])); ptr.* = value; } fn readStateF64(self: *const FyRawMachine, ch: usize, offset: usize) f64 { if (offset + @sizeOf(f64) > self.desc.state_size) return 0; - const ptr: *align(8) const f64 = @ptrCast(@alignCast(&self.state_buf[ch * MAX_STATE + offset])); + const ptr: *align(8) const f64 = @ptrCast(@alignCast(&self.stateRegion(ch)[offset])); return ptr.*; } @@ -1205,11 +1241,11 @@ pub const FyRawMachine = struct { } fn statePtr(self: *FyRawMachine) usize { - return @intFromPtr(&self.state_buf[0]); + return @intFromPtr(self.stateRegion(0).ptr); } fn statePtrCh(self: *FyRawMachine, ch: usize) usize { - return @intFromPtr(&self.state_buf[ch * MAX_STATE]); + return @intFromPtr(self.stateRegion(ch).ptr); } fn regionCount(self: *const FyRawMachine) usize { @@ -1247,9 +1283,11 @@ pub const FyRawMachine = struct { if (self.desc.deriveWord()) |w| self.derive_caller = try self.compileEntry(w, &self.derive_slots, false); self.render_caller = try self.compileEntry(self.desc.renderWord(), &self.render_slots, true); if (self.desc.renderLiteWord()) |w| self.render_lite_caller = try self.compileEntry(w, &self.render_lite_slots, true); + if (self.desc.sharedWord()) |w| self.shared_caller = try self.compileEntry(w, &self.shared_slots, true); // Poly voices pair up whatever their output; effects need one pass - // per channel to pair (a stereo effect already runs once). - if (if (self.desc.mode == .voice_sample) self.desc.voices > 1 else !self.desc.stereo) { + // per channel to pair (a stereo effect already runs once). Voices + // that act on a shared region render one at a time, in voice order. + if (if (self.desc.mode == .voice_sample) self.desc.voices > 1 and self.desc.shared_size == 0 else !self.desc.stereo) { self.render_lanes_caller = self.compileLanes(self.desc.renderWord(), &self.render_lanes_slots); if (self.desc.renderLiteWord()) |w| self.render_lite_lanes_caller = self.compileLanes(w, &self.render_lite_lanes_slots); } @@ -1341,9 +1379,10 @@ pub const FyRawMachine = struct { v.voice = newestAudioVoice(self); v.pitch = if (v.voice) |vi| self.voice_pitch[vi] else 48; for (&v.state) |*state| @memset(state, 0); - for (0..v.regions) |reg| @memcpy(v.state[reg][0..self.desc.state_size], self.state_buf[reg * MAX_STATE ..][0..self.desc.state_size]); + const shown = @min(self.desc.state_size, MAX_STATE); + for (0..v.regions) |reg| @memcpy(v.state[reg][0..shown], self.stateRegion(reg)[0..shown]); @memset(&v.selected_state, 0); - if (v.voice) |vi| @memcpy(v.selected_state[0..self.desc.state_size], self.state_buf[vi * MAX_STATE ..][0..self.desc.state_size]); + if (v.voice) |vi| @memcpy(v.selected_state[0..shown], self.stateRegion(vi)[0..shown]); v.scope_head = self.scope_head.load(.monotonic); if (self.has_scope) @memcpy(&v.scope, &self.scope_buf); v.graphics = @splat(null); @@ -1453,10 +1492,16 @@ pub const FyRawMachine = struct { /// Block end: released voices that stayed under the floor all block go /// idle. fn updateIdle(self: *FyRawMachine) void { + var any = false; for (0..self.regionCount()) |v| { if (!self.voice_idle[v] and !self.voice_gate[v] and self.voice_peak[v] < IDLE_FLOOR) self.voice_idle[v] = true; self.voice_peak[v] = 0; + if (!self.voice_idle[v]) any = true; } + // The shared word sleeps once no voice sounds and its own output + // stayed under the floor all block. + if (!any and self.shared_peak < IDLE_FLOOR) self.shared_awake = false; + self.shared_peak = 0; } /// True if any knob's smoothed position differs from its target. @@ -2707,6 +2752,43 @@ fn renderVoiceSample(self: *FyRawMachine, ctx: *const machine.MachineCtx, l: []f fn renderVoiceSegment(self: *FyRawMachine, start: usize, end: usize) !void { if (end <= start) return; + if (self.shared_caller == null) return renderVoices(self, start, end); + // Shared state (docs/04): the voices, then the shared word, in steps + // of shared_period samples. + if (!self.shared_awake) return renderVoices(self, start, end); + const period = self.desc.shared_period; + var at = start; + while (at < end) { + const n = @min(period, end - at); + try renderVoices(self, at, at + n); + try renderShared(self, at, at + n); + at += n; + } +} + +/// The shared word over io[start, end), accumulating like a voice; its +/// contribution is what keeps it awake. +fn renderShared(self: *FyRawMachine, start: usize, end: usize) !void { + const io = self.io[start..end]; + const snap = self.voice_snap[0..io.len]; + for (snap, io) |*d, f| d.* = f.out_l; + self.kctx.chan = 0; + // The word sees its step's first sample on ctx.clock, constant over + // the call, so it can branch on where the step falls (docs/04). + const block_clock = self.kctx.clock; + self.kctx.clock = @floatFromInt(self.clock + start); + defer self.kctx.clock = block_clock; + const args = [_]Fy.Dsp2RawArg{ + .{ .ptr = @intFromPtr(&io[0]) }, .{ .ptr = @intFromPtr(&self.kctx) }, + .{ .ptr = @intFromPtr(self.shared_mem.ptr) }, .{ .ptr = self.paramsPtr() }, + }; + _ = try self.shared_caller.?.callRealtime(@intCast(io.len), &args); + var pk = self.shared_peak; + for (snap, io) |b, f| pk = @max(pk, @abs(f.out_l - b)); + self.shared_peak = pk; +} + +fn renderVoices(self: *FyRawMachine, start: usize, end: usize) !void { const caller = try effectCaller(self); // Polyphonic machines render every sounding voice; kernels of // multi-voice machines ACCUMULATE into the host-zeroed out buffer, in @@ -2857,6 +2939,8 @@ fn renderVoice(self: *FyRawMachine, caller: *RawCaller, voice: usize, io: []IoFr } var pk = self.voice_peak[voice]; for (snap, io) |b, f| pk = @max(pk, @abs(f.out_l - b)); + // A voice heard through a shared word reports its own level. + if (self.desc.voice_level_sel > 0) pk = @max(pk, @abs(self.readStateF64(voice, self.desc.voice_level_sel - 1))); self.voice_peak[voice] = pk; } @@ -3036,6 +3120,7 @@ fn startVoice(self: *FyRawMachine, v: usize, k: usize, n: usize, ev: machine.Not self.voice_gate[v] = true; self.voice_idle[v] = false; self.voice_peak[v] = 0; + self.shared_awake = self.shared_caller != null; self.voice_ctl_left[v] = 0; // control runs before the note's first sample self.voice_uni_k[v] = @intCast(k); self.voice_uni_n[v] = @intCast(n); @@ -3303,8 +3388,11 @@ fn resetImpl(state: *anyopaque) void { self.adoptZoneEdits(); self.adoptControls(); for (0..self.regionCount()) |reg| { - @memset(self.state_buf[reg * MAX_STATE ..][0..self.desc.state_size], 0); + @memset(self.stateRegion(reg), 0); } + @memset(self.shared_mem, 0); + self.shared_awake = false; + self.shared_peak = 0; @memset(self.voice_gate[0..], false); @memset(self.voice_pitch[0..], -1); @memset(self.voice_note_id[0..], -1); @@ -3327,6 +3415,8 @@ fn deinitImpl(state: *anyopaque, alloc: std.mem.Allocator) void { alloc.destroy(t); } self.freeBuffersUpTo(alloc, self.desc.buffer_count); + if (self.big_state.len > 0) alloc.free(self.big_state); + if (self.shared_mem.len > 0) alloc.free(self.shared_mem); self.freeAssets(alloc); // derive-data is libc-malloc'd by fy's `alloc`; fy doesn't track it. if (self.desc.derive_data != 0) std.c.free(@ptrFromInt(self.desc.derive_data)); @@ -6917,6 +7007,70 @@ test "raw DSP2 delay machine: two rings, injected once into the stereo region" { } } +test "a machine with state over MAX_STATE gets heap regions, one per voice" { + const inst = try FyRawMachine.create(testing.allocator, "machines/grand/grand.fy"); + const mach = inst.machineInterface(); + defer mach.deinit.?(mach.state, testing.allocator); + + // Hammerwork Grand's mode banks are several times the inline region. + try testing.expect(inst.desc.state_size > MAX_STATE); + try testing.expect(inst.big_state.len >= MAX_REGIONS * inst.desc.state_size); + try testing.expect(inst.state_stride >= inst.desc.state_size); + try testing.expectEqual(inst.statePtrCh(0) + inst.state_stride, inst.statePtrCh(1)); +} + +// Render `blocks` blocks of 256 frames with `events` in the first one; +// returns the summed |L| + |R| and fails on a non-finite sample. +fn testEnergy(mach: machine.Machine, events: []machine.NoteEvent, blocks: usize) !f64 { + var ctx = std.mem.zeroes(machine.MachineCtx); + ctx.sample_rate = 48_000; + ctx.block_size = 256; + var l = [_]f32{0} ** 256; + var r = [_]f32{0} ** 256; + var e: f64 = 0; + for (0..blocks) |b| { + ctx.note_in = @ptrCast(events.ptr); + ctx.note_in_count = if (b == 0) @intCast(events.len) else 0; + testRender(mach, &ctx, &l, &r); + for (l, r) |sl, sr| { + try testing.expect(std.math.isFinite(sl) and std.math.isFinite(sr)); + e += @abs(sl) + @abs(sr); + } + } + return e; +} + +test "Hammerwork Grand: a struck note rings and the damper stops it" { + const inst = try FyRawMachine.create(testing.allocator, "machines/grand/grand.fy"); + const mach = inst.machineInterface(); + defer mach.deinit.?(mach.state, testing.allocator); + + var on = [_]machine.NoteEvent{.{ .sample_offset = 0, .kind = .note_on, .channel = 0, .note_id = 1, .pitch = 60, .velocity = 0.7 }}; + const struck = try testEnergy(mach, &on, 40); // ~0.2 s + try testing.expect(struck > 1.0); + // Still ringing a second later while held... + const held = try testEnergy(mach, on[0..0], 40); + try testing.expect(held > 0.05 * struck); + // ...and the damper takes it down once the key is up. + var off = [_]machine.NoteEvent{.{ .sample_offset = 0, .kind = .note_off, .channel = 0, .note_id = 1, .pitch = 60, .velocity = 0 }}; + _ = try testEnergy(mach, &off, 300); // 1.6 s + const after = try testEnergy(mach, on[0..0], 20); + try testing.expect(after < 0.001 * struck); +} + +test "Skinwork Kit: every piece of the GM map sounds and stays finite" { + const inst = try FyRawMachine.create(testing.allocator, "machines/kit/kit.fy"); + const mach = inst.machineInterface(); + defer mach.deinit.?(mach.state, testing.allocator); + + for ([_]u8{ 36, 37, 38, 41, 42, 44, 45, 46, 48, 49, 51, 53 }) |pitch| { + mach.reset(mach.state); + var on = [_]machine.NoteEvent{.{ .sample_offset = 0, .kind = .note_on, .channel = 0, .note_id = 1, .pitch = pitch, .velocity = 1.0 }}; + const e = try testEnergy(mach, &on, 20); + try testing.expect(e > 0.5); + } +} + test "raw DSP2 voice machine with a buffer: one ring per voice" { const inst = try FyRawMachine.create(testing.allocator, "machines/bass/bass.fy"); const mach = inst.machineInterface(); @@ -7124,6 +7278,92 @@ test "kernel ABI: KernelCtx and IoFrame match ctx.fy's Ctx and Io" { } } +test "shared state: voices and the shared word alternate per step, then the word sleeps" { + const block = 64; + const inst = try FyRawMachine.create(testing.allocator, "machines/raw_fixtures/shared_body.fy"); + const mach = inst.machineInterface(); + defer mach.deinit.?(mach.state, testing.allocator); + try testing.expect(inst.shared_caller != null); + try testing.expect(inst.render_lanes_caller == null); // one voice at a time + var ctx = std.mem.zeroes(machine.MachineCtx); + ctx.sample_rate = 48_000; + ctx.block_size = block; + var l = [_]f32{0} ** block; + var r = [_]f32{0} ** block; + const on = [_]machine.NoteEvent{ + .{ .sample_offset = 0, .kind = .note_on, .channel = 0, .note_id = -1, .pitch = 60, .velocity = 1 }, + .{ .sample_offset = 0, .kind = .note_on, .channel = 0, .note_id = -1, .pitch = 64, .velocity = 0.5 }, + }; + ctx.note_in = &on; + ctx.note_in_count = on.len; + testRender(mach, &ctx, &l, &r); + // y[n] = y[n-1]/2 + 1.5: 1.5, 2.25, ... -> 3. + try testing.expectEqual(@as(f32, 1.5), l[0]); + try testing.expectEqual(@as(f32, 2.25), l[1]); + try testing.expectApproxEqAbs(@as(f32, 3), l[block - 1], 1e-6); + // A voice reads the level the shared word left after the step before. + const seen: *align(1) const f64 = @ptrCast(&inst.stateRegion(0)[16]); + try testing.expectApproxEqAbs(@as(f64, l[block - 2]) * 0.5, seen.*, 1e-6); + + const off = [_]machine.NoteEvent{ + .{ .sample_offset = 0, .kind = .note_off, .channel = 0, .note_id = -1, .pitch = 60, .velocity = 0 }, + .{ .sample_offset = 0, .kind = .note_off, .channel = 0, .note_id = -1, .pitch = 64, .velocity = 0 }, + }; + ctx.note_in = &off; + ctx.note_in_count = off.len; + testRender(mach, &ctx, &l, &r); + ctx.note_in = null; + ctx.note_in_count = 0; + // The body rings down after the voices stop driving it. + try testing.expect(l[0] > 1.0 and l[0] < 3.0); + try testing.expect(inst.shared_awake); // its output was loud this block + testRender(mach, &ctx, &l, &r); + try testing.expect(!inst.shared_awake); // under the floor all block + testRender(mach, &ctx, &l, &r); + for (l) |s| try testing.expectEqual(@as(f32, 0), s); +} + +test "shared state: the shared word sees each step's sample clock; voice-level! keeps a silent voice awake" { + const block = 63; // odd, so the clock's parity flips between blocks + const inst = try FyRawMachine.create(testing.allocator, "machines/raw_fixtures/shared_clock.fy"); + const mach = inst.machineInterface(); + defer mach.deinit.?(mach.state, testing.allocator); + var ctx = std.mem.zeroes(machine.MachineCtx); + ctx.sample_rate = 48_000; + ctx.block_size = block; + var l = [_]f32{0} ** block; + var r = [_]f32{0} ** block; + const on = [_]machine.NoteEvent{.{ .sample_offset = 0, .kind = .note_on, .channel = 0, .note_id = -1, .pitch = 60, .velocity = 1 }}; + ctx.note_in = &on; + ctx.note_in_count = on.len; + // The output is 1 on odd samples of the host clock, across blocks. + var clock: usize = 0; + for (0..2) |_| { + testRender(mach, &ctx, &l, &r); + ctx.note_in = null; + ctx.note_in_count = 0; + for (l, 0..) |s, i| try testing.expectEqual(@as(f32, if ((clock + i) % 2 == 1) 1 else 0), s); + clock += block; + } + + const off = [_]machine.NoteEvent{.{ .sample_offset = 0, .kind = .note_off, .channel = 0, .note_id = -1, .pitch = 60, .velocity = 0 }}; + ctx.note_in = &off; + ctx.note_in_count = off.len; + testRender(mach, &ctx, &l, &r); + ctx.note_in = null; + ctx.note_in_count = 0; + // Released and silent, the voice stays awake while its level field + // peaks over IDLE_FLOOR in a block (read after every step): 0.9, 0.9^64 + // and 0.9^127 do, 0.9^190 doesn't. + for (0..2) |_| { + try testing.expect(!inst.voice_idle[0]); + testRender(mach, &ctx, &l, &r); + } + try testing.expect(!inst.voice_idle[0]); + testRender(mach, &ctx, &l, &r); + try testing.expect(inst.voice_idle[0]); +} + test "stereo flag: stereo voices write L and R, stereo effects get one true-stereo pass" { const block = 64; var ctx = std.mem.zeroes(machine.MachineCtx); diff --git a/tools/fit/bessel.py b/tools/fit/bessel.py new file mode 100644 index 00000000..acf68a48 --- /dev/null +++ b/tools/fit/bessel.py @@ -0,0 +1,102 @@ +#!/usr/bin/env python3 +"""Write kernels/10-physical/bessel.fy: the circular membrane's first 40 +modes (docs/41) - Bessel-zero ratios j_mn/j_01, nodal diameters m, and the +radial shapes J_m(j_mn r) at 17 radii, peak-normalized. + + python3 tools/fit/bessel.py # rewrites the .fy next to it + +J_m comes from its integral form, the zeros from bisection; numpy only. +""" +import os +import numpy as np + +N_MODES = 40 +RADII = 17 +OUT = os.path.join(os.path.dirname(__file__), "..", "..", "kernels", "10-physical", "bessel.fy") + + +def J(m, x): + t = np.linspace(0, np.pi, 4001) + return np.trapezoid(np.cos(m * t[None, :] - np.outer(np.atleast_1d(x), np.sin(t))), t, axis=1) / np.pi + + +def zeros_of(m, count=6, top=40.0): + xs = np.linspace(max(0.1, m), top, 40000) + v = J(m, xs) + out = [] + for i in np.where(np.sign(v[:-1]) != np.sign(v[1:]))[0][:count]: + a, b = xs[i], xs[i + 1] + for _ in range(50): + c = (a + b) / 2 + if np.sign(J(m, a)[0]) == np.sign(J(m, c)[0]): + a = c + else: + b = c + out.append(a) + return out + + +def lst(vals, scale, per): + ints = [int(round(v * scale)) for v in vals] + return "\n ".join(" ".join(str(x) for x in ints[i:i + per]) for i in range(0, len(ints), per)) + + +def main(): + modes = sorted((z, m) for m in range(12) for z in zeros_of(m))[:N_MODES] + z = np.array([q for q, _ in modes]) + m = [mm for _, mm in modes] + rs = np.linspace(0, 1, RADII) + shapes = [] + for q, mm in modes: + peak = np.abs(J(mm, q * np.linspace(0, 1, 400))).max() + shapes.append(J(mm, q * rs) / peak) + flat = list(np.array(shapes).reshape(-1)) + [shapes[-1][-1]] + ratio = list(z / z[0]) + [z[-1] / z[0]] + text = f"""( bessel.fy - the ideal circular membrane's first 40 modes [docs/41]. + + A membrane of radius a under uniform tension, fixed at the rim, has + modes J_m[j_mn r/a] cos[m theta] at f_mn = j_mn c / [2 pi a], where j_mn + is the n-th zero of the Bessel function J_m [Rayleigh, Theory of Sound, + ch. 9; Fletcher and Rossing, The Physics of Musical Instruments, 3.6]. + The tables hold the 40 lowest, sorted by frequency: the ratio to the + fundamental j_01 = 2.4048, the nodal diameters m, and each mode's + radial shape at 17 radii r/a = 0, 1/16 .. 1, normalized to a peak of 1. + Generated by tools/fit/bessel.py; don't edit by hand. + + memb-shape-at [mode r -- y] reads the shape at r in 0..1 by linear + interpolation; strike points and microphone points use it. ) + +include "../00-primitives/table.fy" + +:: MEMB-N {N_MODES} ; + +( j_mn / j_01, x 1e6 ) +table: memb-ratio {N_MODES} + f>i [ {lst(ratio, 1000000, 10)} ] swap qnth i>f 0.000001 f* ; + +( nodal diameters m ) +table: memb-m {N_MODES} + f>i [ {lst(m + [m[-1]], 1, 20)} ] swap qnth i>f ; + +( J_m[j_mn r] / max |J_m|, mode-major, {RADII} radii per mode, x 1e6 ) +table: memb-shape {N_MODES * RADII} + f>i [ {lst(flat, 1000000, RADII)} ] swap qnth i>f 0.000001 f* ; + +( mode r -- y : mode i's radial shape at r in [0, 1], interpolated ) +dsp: memb-shape-at | i r -- y | + r 0.0 1.0 fclamp 16.0 f* | x | + x floor 15.0 fmin | x0 | + x x0 f- | fr | + i 17.0 f* x0 f+ | j | + memb-shape j f@i | a | + memb-shape j 1.0 f+ f@i | b | + a b a f- fr f* f+ +; +""" + with open(OUT, "w") as f: + f.write(text) + print("wrote", os.path.normpath(OUT)) + + +if __name__ == "__main__": + main()