Monorepo for Aesthetic.Computer aesthetic.computer
4
fork

Configure Feed

Select the types of activity you want to include in your feed.

notepat: share drum synth between web + native (lib/percussion.mjs)

Extract the 12-drum kit (7 naturals + 5 accent sharps) and its layered
synth kernel into a new `/lib/percussion.mjs` module. Both the web
notepat piece and the ac-native notepat piece now import it so sources
don't diverge — tweaking a drum in one place updates both.

Web notepat gains a drum-mode toggle ("drm" button, cascades left of
os/m4l/wave/oct). In drum mode every key press fires a drum instead of
a pitched note (c=kick, d=snare, e=clap, f=snap, g=hat-c, a=hat-o,
b=ride, c#=crash, d#=splash, f#=cowbell, g#=block, a#=tambo).

Also folds in two earlier fixes:
- Top-bar piano keys now cap at the leftmost button edge so os/m4l/
wave/oct/drm never overlap the mini piano cascade on narrow screens.
- desktop.mjs platform-download fallback URLs now point at silo's
/desktop/download/:platform endpoint (302 to the current release)
instead of dead /desktop/mac paths.

Native notepat keeps its drum-voice inspector by passing an onVoice
callback through the shared kernel.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

+1088 -1019
+22 -386
fedac/native/pieces/notepat.mjs
··· 1 1 // notepat-native.mjs — Traditional notepat for ac-native bare metal 2 2 3 + import { 4 + PERCUSSION_NAMES, 5 + PERCUSSION_LABELS, 6 + PERCUSSION_COLORS, 7 + PERCUSSION_NOTATION, 8 + NOTATION_WAVE_RGB, 9 + HAT_FREQS, 10 + playPercussion as sharedPlayPercussion, 11 + } from "/lib/percussion.mjs"; 12 + 3 13 // setTimeout polyfill — QuickJS on ac-native does not provide setTimeout. 4 14 // Pending callbacks are queued here and flushed each tick from sim() via 5 15 // __tickPendingTimeouts(). Delays are in milliseconds and match browser ··· 685 695 let percussionNotice = null; // { text, until } — transient visual flash 686 696 let percussionSampleBank = {}; // drumName -> { data: Float32Array, len, rate } 687 697 688 - // Natural notes = 7 basic drums; sharps = 5 accent percussion. 689 - const PERCUSSION_NAMES = { 690 - c: "kick", d: "snare", e: "clap", f: "snap", 691 - g: "hat-c", a: "hat-o", b: "ride", 692 - "c#": "crash", "d#": "splash", 693 - "f#": "cowbell", "g#": "block", "a#": "tambo", 694 - }; 695 - 696 - // 3-char display labels shown on the drum pads in place of note names. 697 - const PERCUSSION_LABELS = { 698 - c: "BAS", d: "SNR", e: "CLP", f: "SNP", 699 - g: "HHC", a: "HHO", b: "RDE", 700 - "c#": "CRS", "d#": "SPL", 701 - "f#": "CBL", "g#": "BLK", "a#": "TMB", 702 - }; 703 - 704 - // Metallic / earthy colors to visually distinguish drum pads from melodic keys. 705 - const PERCUSSION_COLORS = { 706 - c: [220, 90, 40], // kick — deep orange 707 - d: [220, 180, 110], // snare — tan 708 - e: [240, 220, 130], // clap — pale yellow 709 - f: [220, 240, 140], // snap — yellow-green 710 - g: [120, 220, 180], // closed hat — mint 711 - a: [120, 200, 240], // open hat — cyan 712 - b: [180, 180, 230], // ride — silver-blue 713 - "c#": [220, 150, 240], // crash — lavender 714 - "d#": [240, 160, 220], // splash — pink 715 - "f#": [200, 150, 80], // cowbell — brass 716 - "g#": [190, 120, 70], // block — wood brown 717 - "a#": [230, 210, 170], // tambourine — sandy 718 - }; 719 - 720 - // Graphic-notation signatures — compact descriptions of each drum's internal 721 - // voices so drawGrid() can render animated glyphs inside each percussion pad. 722 - // Each element is either a tonal "line" (horizontal bar whose Y = log(freq)) 723 - // or "noise" (random dots scattered around the frequency band). `v` is the 724 - // nominal volume, used for line thickness / dot density. 725 - // t: "s"=sine "t"=triangle "q"=square "w"=sawtooth "n"=noise 726 - // Drawn with per-frame random jitter so the viewer sees the stochastic 727 - // mechanism in action while the pad is idle. 728 - const PERCUSSION_NOTATION = { 729 - c: [["t",1800,1.0],["n",4000,0.55],["q",180,1.2],["w",90,1.0],["s",44,1.9],["s",54,1.3],["q",120,0.85]], 730 - d: [["n",2200,0.55],["t",220,0.4],["q",180,0.2]], 731 - e: [["q",2500,0.9],["n",6000,0.6],["n",1600,0.75],["n",1700,0.6],["n",1500,0.5],["n",1800,0.45]], 732 - f: [["n",3200,0.45],["q",1800,0.22],["t",2400,0.18]], 733 - g: [["n",7000,0.35],["n",5000,0.2]], 734 - a: [["n",6500,0.3],["n",4800,0.18]], 735 - b: [["n",4200,0.28],["q",3100,0.1],["q",4600,0.08]], 736 - "c#": [["n",3500,0.42],["n",6500,0.28],["q",4200,0.08]], 737 - "d#": [["n",5500,0.38],["n",8500,0.25]], 738 - "f#": [["q",810,0.22],["q",540,0.18]], 739 - "g#": [["t",900,0.35],["q",1800,0.14]], 740 - "a#": [["n",7000,0.3],["n",4500,0.18],["q",6500,0.1]], 741 - }; 742 - // Wave type → accent color for the notation glyph 743 - const NOTATION_WAVE_RGB = { 744 - s: [120, 200, 255], // sine — sky blue 745 - t: [120, 255, 180], // triangle — mint 746 - q: [255, 220, 120], // square — amber 747 - w: [255, 140, 80], // sawtooth — orange 748 - n: [220, 220, 230], // noise — pale grey 749 - }; 698 + // Drum layout, labels, colors, notation glyphs, and the synth kernel itself 699 + // are shared with the web notepat in /lib/percussion.mjs. Locally we just 700 + // wrap the kernel so we can keep feeding the drum-voice inspector overlay. 750 701 751 702 // === WAR KIT === 752 703 // Natural notes = 7 core weapons; sharps = 5 accent items. Each name ··· 1653 1604 // 1654 1605 // Phase split: live keyboard/touch plays DOWN on key press and UP on 1655 1606 // key release. Reverse playback uses `both` to replay complete drums. 1607 + // Wrapper around the shared percussion kernel. Keeps the voice-inspector 1608 + // overlay fed by using the `onVoice` callback — everything else is in 1609 + // /lib/percussion.mjs now. 1656 1610 function playPercussion(sound, letter, volume = 1.0, pan = 0, pitchFactor = 1.0, phase = "both", holdVoices = null) { 1657 1611 if (!sound?.synth) return; 1658 - const v = Math.max(0.1, Math.min(2.2, volume)); 1659 - const pf = Math.max(0.25, Math.min(4, pitchFactor)); 1660 - // "down" = live press: fire transients + start sustain voices (pushed to holdVoices) 1661 - // "both" = reverse replay: fire a complete, self-contained drum using finite durations 1662 - // "up" = (legacy) release fragment — now handled by releasePercussionHold's kill fades 1663 - const fireDown = phase !== "up"; 1664 1612 const isLive = phase === "down" && Array.isArray(holdVoices); 1665 - 1666 - // Per-hit random helpers (inline, stateless). `rj` jitters around a center 1667 - // by a ± fraction; `rn` returns a uniform range. 1668 - const rj = (center, frac) => center * (1 + (Math.random() - 0.5) * 2 * frac); 1669 - const rn = (min, max) => min + Math.random() * (max - min); 1670 - 1671 - // BPM-locked flam unit (legacy multi-burst timing). Now mostly unused 1672 - // because we lean on staggered `attack` envelopes instead of setTimeout. 1673 - const flam = (60000 / Math.max(40, Math.min(240, metronomeBPM || 120))) * 0.025; 1674 - 1675 - // Push a sustain voice onto holdVoices if live, otherwise fire it as 1676 - // a finite-duration one-shot (for reverse replay / "both" mode). 1677 - // - releaseFade: how quickly this voice damps when the key lifts 1678 - // - releaseUpdate: optional tone/volume shift before the kill fade 1679 - // (e.g. open hat → closed: lower tone first, then fade) 1680 - // - onRelease: optional callback that fires a standalone release 1681 - // burst (e.g. closed hat's subtle "lift click") 1682 - const addSustain = (params, bothDuration, releaseFade, releaseUpdate, onRelease) => { 1683 - if (drumInspectBuilder) drumInspectBuilder.voices.push({ 1684 - type: params?.type, tone: params?.tone, duration: bothDuration, 1685 - volume: params?.volume, attack: params?.attack, decay: params?.decay, 1686 - pan: params?.pan, kind: "sustain", 1687 - }); 1688 - if (isLive) { 1689 - const handle = sound.synth({ ...params, duration: Infinity }); 1690 - if (handle) { 1691 - const liveEntry = { handle, releaseFade, releaseUpdate, onRelease }; 1692 - if (params?.tone !== undefined) liveEntry.baseTone = params.tone / pf; 1693 - if (params?.volume !== undefined) liveEntry.baseVolumeUnit = params.volume / v; 1694 - if (params?.base !== undefined) liveEntry.sampleBase = params.base; 1695 - if (releaseUpdate?.tone !== undefined) liveEntry.releaseBaseTone = releaseUpdate.tone / pf; 1696 - if (releaseUpdate?.volume !== undefined) liveEntry.releaseBaseVolumeUnit = releaseUpdate.volume / v; 1697 - holdVoices.push(liveEntry); 1698 - } 1699 - return handle; 1700 - } else { 1701 - return sound.synth({ ...params, duration: bothDuration }); 1702 - } 1703 - }; 1704 - // One-shot hit voices still need to be tracked during live play so pitch 1705 - // and per-side level changes continue through the audible decay after key-up. 1706 - const addHit = (params) => { 1707 - if (drumInspectBuilder) drumInspectBuilder.voices.push({ 1708 - type: params?.type, tone: params?.tone, duration: params?.duration, 1709 - volume: params?.volume, attack: params?.attack, decay: params?.decay, 1710 - pan: params?.pan, kind: "hit", 1711 - }); 1712 - const handle = sound.synth(params); 1713 - if (isLive && handle) { 1714 - const liveEntry = { 1715 - handle, 1716 - ignoreRelease: true, 1717 - releaseFade: 0, 1718 - tailSeconds: Math.max( 1719 - Number(params?.duration) || 0, 1720 - (Number(params?.attack) || 0) + (Number(params?.decay) || 0), 1721 - ), 1722 - }; 1723 - if (params?.tone !== undefined) liveEntry.baseTone = params.tone / pf; 1724 - if (params?.volume !== undefined) liveEntry.baseVolumeUnit = params.volume / v; 1725 - if (params?.base !== undefined) liveEntry.sampleBase = params.base; 1726 - holdVoices.push(liveEntry); 1727 - } 1728 - return handle; 1729 - }; 1730 - // Register a release-only burst (no sustain voice needed). Useful for 1731 - // drums like closed hat that should be one-shot during hold but play 1732 - // a tiny release click on key-up. The "handle" is a dummy so the 1733 - // release loop picks up the onRelease callback. 1734 - const addReleaseBurst = (onRelease) => { 1735 - if (isLive && onRelease) { 1736 - holdVoices.push({ handle: null, releaseFade: 0, onRelease }); 1737 - } 1738 - }; 1739 - 1740 - // TR-808 hi-hat 6-square inharmonic cluster (exact Roland frequencies). 1741 - // Drop 205/304 for synth clarity since we can't HPF away the low content. 1742 - const HAT_FREQS = [800, 540, 522.7, 369.6]; 1743 - 1744 - if (!fireDown) return; 1745 - 1746 - // Inspector: start fresh builder on each live press so we capture exactly 1747 - // the voices that fire for THIS hit. 1748 1613 if (isLive || phase === "both") { 1749 1614 drumInspectBuilder = { 1750 1615 letter, ··· 1752 1617 voices: [], 1753 1618 }; 1754 1619 } 1755 - 1756 - switch (letter) { 1757 - // === ONE-SHOT DRUMS === 1758 - // These fire as transient events only — the physical gesture is a strike, 1759 - // not a held note. No sustain voices, no release transition. Hold duration 1760 - // doesn't affect them. Applies to: kick, snare, clap, snap, closed hat. 1761 - 1762 - case "c": { // kick — tight TR-808: transient + short body + optional sub 1763 - const downPan = pan + rn(-0.02, 0.02); 1764 - // Design: emphasize the TRANSIENT peak. Previous version stacked six 1765 - // loud low-freq sines with long decays which summed into a muddy 1766 - // "farty" blob on the drum bus (no auto-normalize). New version has 1767 - // ONE attack, ONE body, ONE sub — each with sharp envelopes. 1768 - // 1769 - // Layer budget: stick click + pitch snap + body + sub = 4 voices. 1770 - // The body layer carries most of the perceived "kick" on laptops; 1771 - // the sub adds depth on real speakers without bleeding into body. 1772 - 1773 - // 1. Stick click — very short noise transient at 2.5kHz for the 1774 - // beater attack. This is the CUT that makes the kick audible. 1775 - addHit({ type: "noise", tone: 2500 * pf, duration: 0.0025, volume: rj(0.50, 0.12) * v, attack: 0.0002, decay: 0.0022, pan: downPan }); 1776 - // 2. Pitch snap — 200Hz→150Hz perceived sweep via overlapping sines 1777 - // Both very short so they read as a single downward "thump" 1778 - addHit({ type: "sine", tone: 200 * pf, duration: 0.012, volume: rj(1.1, 0.10) * v, attack: 0.0005, decay: 0.011, pan: downPan }); 1779 - // 3. Body — 150Hz sine, SHORT decay (40ms). Single tone, not a drone. 1780 - // This is the laptop-audible "punch" frequency. 1781 - addHit({ type: "sine", tone: 150 * pf, duration: 0.045, volume: rj(1.3, 0.10) * v, attack: 0.001, decay: 0.044, pan: downPan }); 1782 - // 4. Mid-low body — 90Hz sine, medium-short decay. Adds weight 1783 - // without muddying. 1784 - addHit({ type: "sine", tone: 90 * pf, duration: 0.080, volume: rj(0.85, 0.12) * v, attack: 0.002, decay: 0.078, pan: downPan }); 1785 - // 5. Sub tail — 55Hz sine, long decay. Inaudible on laptops, adds 1786 - // body on headphones/subwoofers. Volume REDUCED from 1.9 to 1.0 1787 - // so it doesn't dominate the transient. 1788 - addHit({ type: "sine", tone: 55 * pf, duration: rj(0.35, 0.20), volume: rj(1.0, 0.12) * v, attack: 0.003, decay: 0.345, pan: downPan }); 1789 - break; 1790 - } 1791 - 1792 - case "d": { // snare — TR-909-leaning: big transient + short tone + dominant noise 1793 - const downPan = pan + rn(-0.02, 0.02); 1794 - // Design: previous snare had 808-style LONG tonal pair (120ms) which 1795 - // exposed the sines as a "bleh" sound — too tonal, not enough snap. 1796 - // New version leans 909: sharper crack, SHORT tonal pair, noise- 1797 - // dominant. The 238/476 Hz pair is still there for metallic beat 1798 - // character but only lasts ~30ms before the noise takes over. 1799 - 1800 - // 1. Sharp stick crack — higher freq, shorter, LOUDER than before 1801 - addHit({ type: "noise", tone: 3500 * pf, duration: 0.004, volume: rj(0.95, 0.10) * v, attack: 0.0001, decay: 0.004, pan: downPan }); 1802 - // 2. 808 tonal pair — SHORT decay (30ms was 120ms), QUIETER so noise dominates 1803 - addHit({ type: "sine", tone: 238 * pf, duration: 0.030, volume: rj(0.35, 0.12) * v, attack: 0.0003, decay: 0.029, pan: downPan }); 1804 - addHit({ type: "sine", tone: 476 * pf, duration: 0.030, volume: rj(0.28, 0.12) * v, attack: 0.0003, decay: 0.029, pan: downPan }); 1805 - // 3. Primary wire noise — DOMINANT layer, bright, medium-short decay 1806 - addHit({ type: "noise", tone: 3500 * pf, duration: rj(0.11, 0.20), volume: rj(0.85, 0.10) * v, attack: 0.0005, decay: 0.108, pan: downPan + rn(-0.04, 0.04) }); 1807 - // 4. Mid wire noise — adds weight without muddiness 1808 - addHit({ type: "noise", tone: 1800 * pf, duration: rj(0.07, 0.20), volume: rj(0.38, 0.15) * v, attack: 0.0008, decay: 0.068, pan: downPan + rn(-0.04, 0.04) }); 1809 - // 5. Triangle body fundamental — adds warmth, very short 1810 - addHit({ type: "triangle", tone: 180 * pf, duration: 0.025, volume: rj(0.22, 0.15) * v, attack: 0.001, decay: 0.024, pan: downPan }); 1811 - break; 1812 - } 1813 - 1814 - case "e": { // clap — TR-808 4-burst pattern via staggered attacks (one-shot) 1815 - const downPan = pan + rn(-0.06, 0.02); 1816 - // Three rapid bursts — attacks 5/15/25 ms create ~10 ms spacing 1817 - addHit({ type: "noise", tone: 1000 * pf, duration: 0.025, volume: rj(0.90, 0.15) * v, attack: 0.005, decay: 0.020, pan: downPan }); 1818 - addHit({ type: "noise", tone: 1100 * pf, duration: 0.035, volume: rj(0.95, 0.15) * v, attack: 0.015, decay: 0.020, pan: downPan }); 1819 - addHit({ type: "noise", tone: 900 * pf, duration: 0.045, volume: rj(0.85, 0.15) * v, attack: 0.025, decay: 0.020, pan: downPan }); 1820 - // Bright edge on the first burst 1821 - addHit({ type: "noise", tone: 3000 * pf, duration: 0.008, volume: rj(0.55, 0.15) * v, attack: 0.001, decay: 0.007, pan: downPan }); 1822 - // The 4th burst — "room tail" with long decay (120ms) 1823 - addHit({ type: "noise", tone: 1000 * pf, duration: rj(0.14, 0.25), volume: rj(0.85, 0.15) * v, attack: 0.045, decay: 0.135, pan: downPan + rn(-0.02, 0.10) }); 1824 - addHit({ type: "noise", tone: 2200 * pf, duration: rj(0.10, 0.25), volume: rj(0.35, 0.18) * v, attack: 0.050, decay: 0.095, pan: downPan + rn(-0.02, 0.10) }); 1825 - break; 1826 - } 1827 - 1828 - case "f": { // snap — finger snap physics (one-shot) 1829 - const downPan = pan + rn(-0.04, 0.04); 1830 - // Sharp broadband click (thumb-middle friction release) 1831 - addHit({ type: "noise", tone: 6000 * pf, duration: 0.003, volume: rj(0.70, 0.15) * v, attack: 0.0001, decay: 0.0028, pan: downPan }); 1832 - // Palm cavity resonance at ~2100 Hz — the "pop" tone 1833 - addHit({ type: "sine", tone: 2100 * pf, duration: rj(0.045, 0.20), volume: rj(0.55, 0.12) * v, attack: 0.0005, decay: 0.044, pan: downPan }); 1834 - // Upper bite at 3500 Hz 1835 - addHit({ type: "sine", tone: 3500 * pf, duration: rj(0.020, 0.25), volume: rj(0.28, 0.18) * v, attack: 0.0005, decay: 0.019, pan: downPan }); 1836 - break; 1837 - } 1838 - 1839 - case "g": { // closed hi-hat — 4-square cluster + subtle lift click on release 1840 - const downPan = pan + rn(-0.03, 0.03); 1841 - // Full cluster — the short decay is the "closed" character 1842 - for (const f of HAT_FREQS) { 1843 - addHit({ type: "square", tone: f * pf, duration: rj(0.008, 0.20), volume: rj(0.18, 0.18) * v, attack: 0.0005, decay: 0.0075, pan: downPan }); 1844 - } 1845 - // Bright noise top for the "tss" 1846 - addHit({ type: "noise", tone: 8000 * pf, duration: rj(0.040, 0.20), volume: rj(0.38, 0.12) * v, attack: 0.0005, decay: 0.038, pan: downPan }); 1847 - // Lift click: on key-up, fire a tiny high-noise transient to 1848 - // represent the stick leaving the hat. Subtle — real closed-hat 1849 - // "opens" don't ring, they just have a mechanical release click. 1850 - addReleaseBurst(() => { 1851 - sound.synth({ type: "noise", tone: 9000 * pf, duration: 0.004, volume: rj(0.22, 0.20) * v, attack: 0.0002, decay: 0.0038, pan: downPan }); 1852 - sound.synth({ type: "square", tone: 6000 * pf, duration: 0.003, volume: rj(0.08, 0.25) * v, attack: 0.0003, decay: 0.0027, pan: downPan }); 1853 - }); 1854 - break; 1855 - } 1856 - 1857 - case "a": { // open hi-hat — TR-808 cluster, LONG sustain. Foot-pedal release = damp fast. 1858 - const downPan = pan + rn(-0.04, 0.04); 1859 - // Transient: the 4-square attack cluster 1860 - for (const f of HAT_FREQS) { 1861 - addHit({ type: "square", tone: f * pf, duration: 0.012, volume: rj(0.16, 0.18) * v, attack: 0.0005, decay: 0.011, pan: downPan }); 1862 - } 1863 - // Transient: bright noise chip 1864 - addHit({ type: "noise", tone: 8200 * pf, duration: 0.012, volume: rj(0.42, 0.12) * v, attack: 0.0003, decay: 0.011, pan: downPan }); 1865 - // SUSTAIN: the signature open-hat shimmer. Rings until key release. 1866 - // Release = hi-hat foot pedal closing — dampens the top end fast. 1867 - // releaseUpdate drops the tone first (simulates bandpass closing), 1868 - // then the kill fade does the rest. 1869 - addSustain( 1870 - { type: "noise", tone: 7000 * pf, volume: rj(0.32, 0.15) * v, attack: 0.003, decay: 0, pan: downPan + rn(-0.02, 0.08) }, 1871 - rj(0.40, 0.25), 1872 - 0.12, // 120ms foot-pedal close 1873 - { tone: 3500 } // dampen brightness on release 1874 - ); 1875 - addSustain( 1876 - { type: "noise", tone: 5000 * pf, volume: rj(0.20, 0.18) * v, attack: 0.003, decay: 0, pan: downPan + rn(-0.02, 0.08) }, 1877 - rj(0.25, 0.25), 1878 - 0.10, 1879 - { tone: 2800 } 1880 - ); 1881 - // Persistent metallic square from the cluster for body 1882 - addSustain( 1883 - { type: "square", tone: 800 * pf, volume: rj(0.08, 0.20) * v, attack: 0.005, decay: 0, pan: downPan }, 1884 - rj(0.20, 0.25), 1885 - 0.08 1886 - ); 1887 - break; 1888 - } 1889 - 1890 - case "b": { // ride — bell ping + long shimmer sustain 1891 - const downPan = pan + rn(-0.03, 0.03); 1892 - // Transient: stick tip on the cluster (sharp attack) 1893 - addHit({ type: "square", tone: 800 * pf, duration: 0.020, volume: rj(0.10, 0.18) * v, attack: 0.0005, decay: 0.019, pan: downPan }); 1894 - addHit({ type: "square", tone: 540 * pf, duration: 0.020, volume: rj(0.08, 0.18) * v, attack: 0.0005, decay: 0.019, pan: downPan }); 1895 - // SUSTAIN: bell ping pair (perfect-5th) — THE ride signature 1896 - addSustain( 1897 - { type: "sine", tone: 440 * pf, volume: rj(0.24, 0.12) * v, attack: 0.0008, decay: 0, pan: downPan }, 1898 - rj(0.40, 0.20), 1899 - 0.25 // long ring release — bell decays slowly 1900 - ); 1901 - addSustain( 1902 - { type: "sine", tone: 587 * pf, volume: rj(0.20, 0.12) * v, attack: 0.0008, decay: 0, pan: downPan }, 1903 - rj(0.40, 0.20), 1904 - 0.25 1905 - ); 1906 - // SUSTAIN: long shimmer body 1907 - addSustain( 1908 - { type: "noise", tone: 4200 * pf, volume: rj(0.26, 0.12) * v, attack: 0.005, decay: 0, pan: downPan + rn(-0.03, 0.03) }, 1909 - rj(0.9, 0.20), 1910 - 0.30 1911 - ); 1912 - break; 1913 - } 1914 - 1915 - case "c#": { // crash — explosive noise attack + LONG shimmer wash 1916 - const downPan = pan + rn(-0.05, 0.05); 1917 - // Transient: loud noise splash 1918 - addHit({ type: "noise", tone: 8000 * pf, duration: 0.030, volume: rj(0.75, 0.15) * v, attack: 0.0005, decay: 0.029, pan: downPan }); 1919 - // Transient: hat cluster for metallic attack 1920 - for (const f of HAT_FREQS) { 1921 - addHit({ type: "square", tone: f * pf, duration: 0.030, volume: rj(0.12, 0.20) * v, attack: 0.0005, decay: 0.029, pan: downPan }); 1922 - } 1923 - // SUSTAIN: the long wash — crash's whole character 1924 - addSustain( 1925 - { type: "noise", tone: 5000 * pf, volume: rj(0.45, 0.12) * v, attack: 0.008, decay: 0, pan: downPan + rn(-0.04, 0.04) }, 1926 - rj(1.4, 0.18), 1927 - 0.45 // long release fade 1928 - ); 1929 - addSustain( 1930 - { type: "noise", tone: 7500 * pf, volume: rj(0.30, 0.15) * v, attack: 0.008, decay: 0, pan: downPan + rn(-0.04, 0.04) }, 1931 - rj(0.9, 0.18), 1932 - 0.35 1933 - ); 1934 - // Cluster fragment body 1935 - addSustain( 1936 - { type: "square", tone: 800 * pf, volume: rj(0.08, 0.20) * v, attack: 0.008, decay: 0, pan: downPan }, 1937 - rj(0.5, 0.20), 1938 - 0.20 1939 - ); 1940 - break; 1941 - } 1942 - 1943 - case "d#": { // splash — short bright cymbal burst (one-shot) 1944 - const downPan = pan + rn(-0.04, 0.04); 1945 - // Bright attack 1946 - addHit({ type: "noise", tone: 9000 * pf, duration: 0.012, volume: rj(0.55, 0.15) * v, attack: 0.0003, decay: 0.011, pan: downPan }); 1947 - addHit({ type: "square", tone: 800 * pf, duration: 0.015, volume: rj(0.14, 0.20) * v, attack: 0.0005, decay: 0.014, pan: downPan }); 1948 - addHit({ type: "square", tone: 540 * pf, duration: 0.015, volume: rj(0.10, 0.20) * v, attack: 0.0005, decay: 0.014, pan: downPan }); 1949 - // Short wash — splash is all quick burst, no sustain 1950 - addHit({ type: "noise", tone: 6000 * pf, duration: rj(0.35, 0.20), volume: rj(0.42, 0.12) * v, attack: 0.004, decay: 0.345, pan: downPan + rn(-0.03, 0.03) }); 1951 - addHit({ type: "noise", tone: 8500 * pf, duration: rj(0.22, 0.20), volume: rj(0.25, 0.15) * v, attack: 0.004, decay: 0.215, pan: downPan + rn(-0.03, 0.03) }); 1952 - break; 1953 - } 1954 - 1955 - case "f#": { // cowbell — TR-808: two triangles 800/540 Hz (one-shot) 1956 - const downPan = pan + rn(-0.03, 0.03); 1957 - // Stick click 1958 - addHit({ type: "square", tone: 1800 * pf, duration: 0.004, volume: rj(0.35, 0.15) * v, attack: 0.0002, decay: 0.0038, pan: downPan }); 1959 - // The two bell triangles — natural decay 1960 - addHit({ type: "triangle", tone: 800 * pf, duration: rj(0.28, 0.20), volume: rj(0.42, 0.12) * v, attack: 0.0008, decay: 0.275, pan: downPan }); 1961 - addHit({ type: "triangle", tone: 540 * pf, duration: rj(0.28, 0.20), volume: rj(0.36, 0.12) * v, attack: 0.0008, decay: 0.275, pan: downPan }); 1962 - break; 1963 - } 1964 - 1965 - case "g#": { // wood block — single triangle @ 2500 Hz (one-shot) 1966 - const downPan = pan + rn(-0.03, 0.03); 1967 - // Stick click 1968 - addHit({ type: "noise", tone: 5000 * pf, duration: 0.002, volume: rj(0.35, 0.18) * v, attack: 0.0001, decay: 0.0018, pan: downPan }); 1969 - // Block tones 1970 - addHit({ type: "triangle", tone: 2500 * pf, duration: rj(0.050, 0.25), volume: rj(0.52, 0.12) * v, attack: 0.0003, decay: 0.048, pan: downPan }); 1971 - addHit({ type: "triangle", tone: 1250 * pf, duration: rj(0.050, 0.25), volume: rj(0.18, 0.18) * v, attack: 0.0005, decay: 0.048, pan: downPan }); 1972 - break; 1973 - } 1974 - 1975 - case "a#": { // tambourine — staggered jingle bursts (one-shot) 1976 - const downPan = pan + rn(-0.04, 0.04); 1977 - // 3 staggered noise bursts via attack offsets (jingle rattle) 1978 - addHit({ type: "noise", tone: 7000 * pf, duration: 0.08, volume: rj(0.38, 0.18) * v, attack: 0.002, decay: 0.075, pan: downPan }); 1979 - addHit({ type: "noise", tone: 7500 * pf, duration: 0.09, volume: rj(0.30, 0.18) * v, attack: 0.015, decay: 0.075, pan: downPan }); 1980 - addHit({ type: "noise", tone: 6500 * pf, duration: 0.10, volume: rj(0.25, 0.18) * v, attack: 0.030, decay: 0.070, pan: downPan }); 1981 - // High square ting 1982 - addHit({ type: "square", tone: 6000 * pf, duration: 0.030, volume: rj(0.14, 0.20) * v, attack: 0.001, decay: 0.028, pan: downPan }); 1983 - // Long jingle tail 1984 - addHit({ type: "noise", tone: 7000 * pf, duration: rj(0.20, 0.22), volume: rj(0.32, 0.18) * v, attack: 0.050, decay: 0.195, pan: downPan + rn(-0.04, 0.04) }); 1985 - addHit({ type: "noise", tone: 4500 * pf, duration: rj(0.15, 0.22), volume: rj(0.20, 0.18) * v, attack: 0.055, decay: 0.145, pan: downPan + rn(-0.04, 0.04) }); 1986 - break; 1987 - } 1988 - } 1989 - // Commit inspector snapshot if we captured voices 1620 + sharedPlayPercussion(sound, letter, { 1621 + volume, pan, pitchFactor, phase, holdVoices, 1622 + onVoice: (voice) => { 1623 + if (drumInspectBuilder) drumInspectBuilder.voices.push(voice); 1624 + }, 1625 + }); 1990 1626 if (drumInspectBuilder && drumInspectBuilder.voices.length > 0) { 1991 1627 lastDrumInspect = { ...drumInspectBuilder, until: frame + 300 }; 1992 1628 }
+4 -4
system/public/aesthetic.computer/disks/desktop.mjs
··· 4 4 // Fetches real release info from GitHub. 5 5 // Detects if running inside Electron app and shows update status. 6 6 7 - // Download URLs (fallback) 7 + // Download URLs (fallback — silo's platform redirect serves the current release) 8 8 const DOWNLOADS = { 9 - mac: `/desktop/mac`, 10 - win: `/desktop/win`, 11 - linux: `/desktop/linux`, 9 + mac: `https://silo.aesthetic.computer/desktop/download/mac`, 10 + win: `https://silo.aesthetic.computer/desktop/download/win`, 11 + linux: `https://silo.aesthetic.computer/desktop/download/linux`, 12 12 }; 13 13 14 14 // GitHub release info
+723 -629
system/public/aesthetic.computer/disks/notepat.mjs
··· 12 12 decodeBitmapToSample, 13 13 loadPaintingAsAudio, 14 14 } from "../lib/pixel-sample.mjs"; 15 + import { playPercussion } from "../lib/percussion.mjs"; 15 16 16 17 // 🎹 NuPhy Air60 HE — WebHID analog pressure support 17 18 // Sends activation commands then reads 0xA0 analog reports with per-key pressure. ··· 675 676 const topPianoHeight = 15; 676 677 // Push piano right when .com superscript is shown to avoid overlap with HUD label 677 678 const topPianoStartX = dotComMode ? 75 : 54; 678 - const availableWidth = Math.max(0, screen.width - topPianoStartX); 679 + // Cap the piano's right edge at the leftmost top-bar button (os/m4l/wave/oct) 680 + // so keys never slide under the button strip on narrow screens. 681 + const leftmostButtonX = Math.min( 682 + osBtn?.box?.x ?? Infinity, 683 + abletonBtn?.box?.x ?? Infinity, 684 + waveBtn?.box?.x ?? Infinity, 685 + octBtn?.box?.x ?? Infinity, 686 + drumBtn?.box?.x ?? Infinity, 687 + ); 688 + const rightEdge = Number.isFinite(leftmostButtonX) 689 + ? leftmostButtonX - 3 690 + : screen.width; 691 + const availableWidth = Math.max(0, rightEdge - topPianoStartX); 679 692 680 693 const fullWidth = Math.min(140, Math.floor(availableWidth * 0.5)); 681 694 const fullWhiteKeyWidth = Math.floor(fullWidth / MINI_PIANO_WHITE_KEYS.length); ··· 894 907 "+b", 895 908 ]; 896 909 897 - const buttonNoteLookup = new Set(buttonNotes); 898 - const MIDI_NOTE_NAMES = ["c", "c#", "d", "d#", "e", "f", "f#", "g", "g#", "a", "a#", "b"]; 899 - 900 - const midiActiveNotes = new Map(); 910 + const buttonNoteLookup = new Set(buttonNotes); 911 + const MIDI_NOTE_NAMES = ["c", "c#", "d", "d#", "e", "f", "f#", "g", "g#", "a", "a#", "b"]; 912 + 913 + const midiActiveNotes = new Map(); 901 914 902 915 const MIDI_PITCH_BEND_RANGE = 2; // Semitones up/down for pitch wheel. 903 916 let midiPitchBendValue = 0; // Normalized -1..1 position of the wheel. ··· 1089 1102 1090 1103 // let qrcells; 1091 1104 1092 - let waveBtn, octBtn, osBtn, abletonBtn; 1105 + let waveBtn, octBtn, osBtn, abletonBtn, drumBtn; 1106 + let drumMode = false; // When true, all note triggers play the shared drumkit instead. 1093 1107 let slideBtn, roomBtn, glitchBtn, quickBtn; // Toggle buttons for slide/room/glitch/quick modes 1094 1108 let metroBtn, bpmMinusBtn, bpmPlusBtn; // Metronome controls 1095 1109 let melodyAliasBtn; ··· 1251 1265 1252 1266 // song = parseSong(rawSong); 1253 1267 1254 - let startupSfx; 1255 - let udpServer; 1256 - let relaySocket = null; 1257 - let relaySourceHandle = ""; 1258 - let relaySourceMachineId = ""; 1259 - let relaySubscribeAll = false; 1260 - let relaySources = []; 1261 - let relayStatusText = "relay off"; 1262 - let relayLastEventText = ""; 1263 - let relayLastEventAt = 0; 1264 - let relayNeedsPanic = false; 1265 - let relayMessageHandler = null; 1266 - const relayMidiQueue = []; 1267 - const relayActiveNotes = new Map(); 1268 - 1269 - let stampleSampleId = null; 1270 - let stampleSampleData = null; 1271 - let stampleSampleRate = null; 1268 + let startupSfx; 1269 + let udpServer; 1270 + let relaySocket = null; 1271 + let relaySourceHandle = ""; 1272 + let relaySourceMachineId = ""; 1273 + let relaySubscribeAll = false; 1274 + let relaySources = []; 1275 + let relayStatusText = "relay off"; 1276 + let relayLastEventText = ""; 1277 + let relayLastEventAt = 0; 1278 + let relayNeedsPanic = false; 1279 + let relayMessageHandler = null; 1280 + const relayMidiQueue = []; 1281 + const relayActiveNotes = new Map(); 1282 + 1283 + let stampleSampleId = null; 1284 + let stampleSampleData = null; 1285 + let stampleSampleRate = null; 1272 1286 let stampleNeedleProgress = 0; 1273 1287 let stampleNeedleNote = null; 1274 1288 let stampleProgressTick = 0; 1275 1289 let kidlispSampleRefreshTick = 0; 1276 1290 1277 - let autopatHud = null; 1278 - let autopatTypeface = null; 1279 - 1280 - let picture; 1281 - let matrixFont; // MatrixChunky8 font for note letters 1282 - 1283 - function normalizeRelayHandle(handle) { 1284 - return `${handle || ""}`.trim().replace(/^@+/, "").toLowerCase(); 1285 - } 1286 - 1287 - function relayTargetLabel() { 1288 - if (relaySubscribeAll) return "all"; 1289 - if (relaySourceHandle && relaySourceMachineId) return `@${relaySourceHandle} ${relaySourceMachineId}`; 1290 - if (relaySourceHandle) return `@${relaySourceHandle}`; 1291 - if (relaySourceMachineId) return relaySourceMachineId; 1292 - return "off"; 1293 - } 1294 - 1295 - function relaySubscriptionPayload() { 1296 - if (relaySubscribeAll) return { all: true }; 1297 - if (relaySourceHandle || relaySourceMachineId) { 1298 - return { 1299 - handle: relaySourceHandle || undefined, 1300 - machineId: relaySourceMachineId || undefined, 1301 - }; 1302 - } 1303 - return null; 1304 - } 1305 - 1306 - function updateRelayBridgeState() { 1307 - if (typeof window === "undefined") return; 1308 - window.acNotepatRelay = { 1309 - status: relayStatusText, 1310 - target: relayTargetLabel(), 1311 - sources: relaySources, 1312 - lastEvent: relayLastEventText, 1313 - setSource(next = {}) { 1314 - setRelaySource(next); 1315 - }, 1316 - }; 1317 - } 1318 - 1319 - function requestRelaySources() { 1320 - relaySocket?.send?.("notepat:midi:sources"); 1321 - } 1322 - 1323 - function sendRelaySubscription() { 1324 - const payload = relaySubscriptionPayload(); 1325 - if (!relaySocket?.send) return; 1326 - if (!payload) { 1327 - relaySocket.send("notepat:midi:unsubscribe", true); 1328 - relayStatusText = "relay off"; 1329 - updateRelayBridgeState(); 1330 - return; 1331 - } 1332 - relaySocket.send("notepat:midi:subscribe", payload); 1333 - relayStatusText = `relay ${relayTargetLabel()}`; 1334 - updateRelayBridgeState(); 1335 - } 1336 - 1337 - function setRelaySource(next = {}) { 1338 - const hasHandle = Object.prototype.hasOwnProperty.call(next, "handle"); 1339 - const hasMachineId = Object.prototype.hasOwnProperty.call(next, "machineId"); 1340 - const hasAll = Object.prototype.hasOwnProperty.call(next, "all"); 1341 - 1342 - if (hasHandle) { 1343 - relaySourceHandle = normalizeRelayHandle(next.handle); 1344 - } 1345 - if (hasMachineId) { 1346 - relaySourceMachineId = `${next.machineId || ""}`.trim(); 1347 - } else if (hasHandle) { 1348 - relaySourceMachineId = ""; 1349 - } 1350 - if (hasAll) { 1351 - relaySubscribeAll = next.all === true; 1352 - } 1353 - if (relaySubscribeAll) { 1354 - relaySourceHandle = ""; 1355 - relaySourceMachineId = ""; 1356 - } 1357 - relayNeedsPanic = true; 1358 - requestRelaySources(); 1359 - sendRelaySubscription(); 1360 - } 1361 - 1362 - function handleRelaySocketMessage(id, type, content) { 1363 - if (type === "connected" || type === "connected:already") { 1364 - requestRelaySources(); 1365 - sendRelaySubscription(); 1366 - return; 1367 - } 1368 - 1369 - if (type === "notepat:midi:sources") { 1370 - relaySources = Array.isArray(content?.sources) ? content.sources : []; 1371 - updateRelayBridgeState(); 1372 - return; 1373 - } 1374 - 1375 - if (type === "notepat:midi:subscribed") { 1376 - relayStatusText = `relay ${relayTargetLabel()}`; 1377 - updateRelayBridgeState(); 1378 - return; 1379 - } 1380 - 1381 - if (type === "notepat:midi:unsubscribed") { 1382 - relayStatusText = "relay off"; 1383 - relayNeedsPanic = true; 1384 - updateRelayBridgeState(); 1385 - return; 1386 - } 1387 - 1388 - if (type === "notepat:midi") { 1389 - relayMidiQueue.push(content); 1390 - relayLastEventText = `${content?.handle ? "@" + content.handle + " " : ""}${content?.event || "event"} ${content?.note ?? ""}`; 1391 - relayLastEventAt = Date.now(); 1392 - updateRelayBridgeState(); 1393 - } 1394 - } 1291 + let autopatHud = null; 1292 + let autopatTypeface = null; 1293 + 1294 + let picture; 1295 + let matrixFont; // MatrixChunky8 font for note letters 1296 + 1297 + function normalizeRelayHandle(handle) { 1298 + return `${handle || ""}`.trim().replace(/^@+/, "").toLowerCase(); 1299 + } 1300 + 1301 + function relayTargetLabel() { 1302 + if (relaySubscribeAll) return "all"; 1303 + if (relaySourceHandle && relaySourceMachineId) return `@${relaySourceHandle} ${relaySourceMachineId}`; 1304 + if (relaySourceHandle) return `@${relaySourceHandle}`; 1305 + if (relaySourceMachineId) return relaySourceMachineId; 1306 + return "off"; 1307 + } 1308 + 1309 + function relaySubscriptionPayload() { 1310 + if (relaySubscribeAll) return { all: true }; 1311 + if (relaySourceHandle || relaySourceMachineId) { 1312 + return { 1313 + handle: relaySourceHandle || undefined, 1314 + machineId: relaySourceMachineId || undefined, 1315 + }; 1316 + } 1317 + return null; 1318 + } 1319 + 1320 + function updateRelayBridgeState() { 1321 + if (typeof window === "undefined") return; 1322 + window.acNotepatRelay = { 1323 + status: relayStatusText, 1324 + target: relayTargetLabel(), 1325 + sources: relaySources, 1326 + lastEvent: relayLastEventText, 1327 + setSource(next = {}) { 1328 + setRelaySource(next); 1329 + }, 1330 + }; 1331 + } 1332 + 1333 + function requestRelaySources() { 1334 + relaySocket?.send?.("notepat:midi:sources"); 1335 + } 1336 + 1337 + function sendRelaySubscription() { 1338 + const payload = relaySubscriptionPayload(); 1339 + if (!relaySocket?.send) return; 1340 + if (!payload) { 1341 + relaySocket.send("notepat:midi:unsubscribe", true); 1342 + relayStatusText = "relay off"; 1343 + updateRelayBridgeState(); 1344 + return; 1345 + } 1346 + relaySocket.send("notepat:midi:subscribe", payload); 1347 + relayStatusText = `relay ${relayTargetLabel()}`; 1348 + updateRelayBridgeState(); 1349 + } 1350 + 1351 + function setRelaySource(next = {}) { 1352 + const hasHandle = Object.prototype.hasOwnProperty.call(next, "handle"); 1353 + const hasMachineId = Object.prototype.hasOwnProperty.call(next, "machineId"); 1354 + const hasAll = Object.prototype.hasOwnProperty.call(next, "all"); 1355 + 1356 + if (hasHandle) { 1357 + relaySourceHandle = normalizeRelayHandle(next.handle); 1358 + } 1359 + if (hasMachineId) { 1360 + relaySourceMachineId = `${next.machineId || ""}`.trim(); 1361 + } else if (hasHandle) { 1362 + relaySourceMachineId = ""; 1363 + } 1364 + if (hasAll) { 1365 + relaySubscribeAll = next.all === true; 1366 + } 1367 + if (relaySubscribeAll) { 1368 + relaySourceHandle = ""; 1369 + relaySourceMachineId = ""; 1370 + } 1371 + relayNeedsPanic = true; 1372 + requestRelaySources(); 1373 + sendRelaySubscription(); 1374 + } 1375 + 1376 + function handleRelaySocketMessage(id, type, content) { 1377 + if (type === "connected" || type === "connected:already") { 1378 + requestRelaySources(); 1379 + sendRelaySubscription(); 1380 + return; 1381 + } 1382 + 1383 + if (type === "notepat:midi:sources") { 1384 + relaySources = Array.isArray(content?.sources) ? content.sources : []; 1385 + updateRelayBridgeState(); 1386 + return; 1387 + } 1388 + 1389 + if (type === "notepat:midi:subscribed") { 1390 + relayStatusText = `relay ${relayTargetLabel()}`; 1391 + updateRelayBridgeState(); 1392 + return; 1393 + } 1394 + 1395 + if (type === "notepat:midi:unsubscribed") { 1396 + relayStatusText = "relay off"; 1397 + relayNeedsPanic = true; 1398 + updateRelayBridgeState(); 1399 + return; 1400 + } 1401 + 1402 + if (type === "notepat:midi") { 1403 + relayMidiQueue.push(content); 1404 + relayLastEventText = `${content?.handle ? "@" + content.handle + " " : ""}${content?.event || "event"} ${content?.note ?? ""}`; 1405 + relayLastEventAt = Date.now(); 1406 + updateRelayBridgeState(); 1407 + } 1408 + } 1395 1409 1396 - async function boot({ 1410 + async function boot({ 1397 1411 params, 1398 1412 api, 1399 1413 colon, ··· 1408 1422 sound, 1409 1423 clock, 1410 1424 query, 1411 - }) { 1412 - autopatApi = api; 1413 - autopatHud = hud; 1414 - autopatTypeface = typeface; 1415 - setSoundContext({ 1416 - synth: sound?.synth, 1417 - play: sound?.play, 1418 - freq: sound?.freq, 1419 - }); 1425 + }) { 1426 + autopatApi = api; 1427 + autopatHud = hud; 1428 + autopatTypeface = typeface; 1429 + setSoundContext({ 1430 + synth: sound?.synth, 1431 + play: sound?.play, 1432 + freq: sound?.freq, 1433 + }); 1420 1434 1421 1435 // ✨ Show ".com" superscript in the HUD corner label (notepat.com branding) 1422 1436 hud.superscript(".com"); 1423 1437 dotComMode = true; 1424 1438 1425 1439 // 🎹 Check if we're in DAW mode (loaded from Ableton M4L) 1426 - dawMode = query?.daw === "1" || query?.daw === 1 || query?.daw === true; 1427 - console.log("🎹 Notepat: dawMode =", dawMode, "query.daw =", query?.daw, typeof query?.daw); 1440 + dawMode = query?.daw === "1" || query?.daw === 1 || query?.daw === true; 1441 + console.log("🎹 Notepat: dawMode =", dawMode, "query.daw =", query?.daw, typeof query?.daw); 1428 1442 1429 1443 // Also check if we already have DAW data (survives hot reload) 1430 1444 if (!dawMode && sound.daw?.bpm) { ··· 1480 1494 // Disabled: dynamic audio reinit was breaking audio - now using 48kHz globally 1481 1495 pendingAudioReinit = false; 1482 1496 1483 - // fps(4); 1484 - udpServer = net.udp(); // For sending messages to `tv`. 1485 - relaySourceHandle = normalizeRelayHandle(query?.relayHandle); 1486 - relaySourceMachineId = `${query?.relayMachine || ""}`.trim(); 1487 - relaySubscribeAll = query?.relayAll === "1" || query?.relay === "all"; 1488 - relayStatusText = relaySubscriptionPayload() ? `relay ${relayTargetLabel()}` : "relay off"; 1489 - relaySocket = net.socket(handleRelaySocketMessage); 1490 - requestRelaySources(); 1491 - sendRelaySubscription(); 1492 - if (typeof window !== "undefined") { 1493 - if (relayMessageHandler) window.removeEventListener("message", relayMessageHandler); 1494 - relayMessageHandler = (event) => { 1495 - const data = event?.data; 1496 - if (!data || typeof data !== "object") return; 1497 - if (data.type === "notepat:midi:set-source" || data.type === "notepat:midi:subscribe") { 1498 - setRelaySource({ 1499 - handle: data.handle, 1500 - machineId: data.machineId, 1501 - all: data.all === true, 1502 - }); 1503 - } else if (data.type === "notepat:midi:unsubscribe") { 1504 - setRelaySource({ handle: "", machineId: "", all: false }); 1505 - } else if (data.type === "notepat:midi:sources") { 1506 - requestRelaySources(); 1507 - } 1508 - }; 1509 - window.addEventListener("message", relayMessageHandler); 1510 - updateRelayBridgeState(); 1511 - } 1512 - 1513 - // Create picture buffer at quarter resolution (quarter width, quarter height) 1514 - const pictureWidth = Math.max(1, Math.floor(screen.width / 4)); 1497 + // fps(4); 1498 + udpServer = net.udp(); // For sending messages to `tv`. 1499 + relaySourceHandle = normalizeRelayHandle(query?.relayHandle); 1500 + relaySourceMachineId = `${query?.relayMachine || ""}`.trim(); 1501 + relaySubscribeAll = query?.relayAll === "1" || query?.relay === "all"; 1502 + relayStatusText = relaySubscriptionPayload() ? `relay ${relayTargetLabel()}` : "relay off"; 1503 + relaySocket = net.socket(handleRelaySocketMessage); 1504 + requestRelaySources(); 1505 + sendRelaySubscription(); 1506 + if (typeof window !== "undefined") { 1507 + if (relayMessageHandler) window.removeEventListener("message", relayMessageHandler); 1508 + relayMessageHandler = (event) => { 1509 + const data = event?.data; 1510 + if (!data || typeof data !== "object") return; 1511 + if (data.type === "notepat:midi:set-source" || data.type === "notepat:midi:subscribe") { 1512 + setRelaySource({ 1513 + handle: data.handle, 1514 + machineId: data.machineId, 1515 + all: data.all === true, 1516 + }); 1517 + } else if (data.type === "notepat:midi:unsubscribe") { 1518 + setRelaySource({ handle: "", machineId: "", all: false }); 1519 + } else if (data.type === "notepat:midi:sources") { 1520 + requestRelaySources(); 1521 + } 1522 + }; 1523 + window.addEventListener("message", relayMessageHandler); 1524 + updateRelayBridgeState(); 1525 + } 1526 + 1527 + // Create picture buffer at quarter resolution (quarter width, quarter height) 1528 + const pictureWidth = Math.max(1, Math.floor(screen.width / 4)); 1515 1529 const pictureHeight = Math.max(1, Math.floor(screen.height / 4)); 1516 1530 picture = painting(pictureWidth, pictureHeight, ({ wipe }) => { 1517 1531 wipe("gray"); ··· 1667 1681 } 1668 1682 } 1669 1683 1670 - buildOctButton(api); 1671 - buildWaveButton(api); 1672 - buildAbletonButton(api); 1673 - buildOsButton(api); 1674 - buildToggleButtons(api); 1675 - buildMetronomeButtons(api); 1684 + buildOctButton(api); 1685 + buildWaveButton(api); 1686 + buildAbletonButton(api); 1687 + buildOsButton(api); 1688 + buildDrumButton(api); 1689 + buildToggleButtons(api); 1690 + buildMetronomeButtons(api); 1676 1691 1677 1692 const newOctave = 1678 1693 parseInt(colon[0]) || parseInt(colon[1]) || parseInt(colon[2]); ··· 1685 1700 setupButtons(api); 1686 1701 } 1687 1702 1688 - function sim({ sound, simCount, num, clock, painting }) { 1689 - const simTick = typeof simCount === "bigint" ? Number(simCount) : simCount; 1690 - setSoundContext({ 1691 - synth: sound?.synth, 1692 - play: sound?.play, 1693 - freq: sound?.freq, 1694 - num, 1695 - }); 1696 - flushRelayMidiQueue(); 1697 - 1698 - if (lowLatencyMode) { 1703 + function sim({ sound, simCount, num, clock, painting }) { 1704 + const simTick = typeof simCount === "bigint" ? Number(simCount) : simCount; 1705 + setSoundContext({ 1706 + synth: sound?.synth, 1707 + play: sound?.play, 1708 + freq: sound?.freq, 1709 + num, 1710 + }); 1711 + flushRelayMidiQueue(); 1712 + 1713 + if (lowLatencyMode) { 1699 1714 if (simTick % 3 === 0) sound.speaker?.poll(); 1700 1715 } else { 1701 1716 sound.speaker?.poll(); ··· 4585 4600 4586 4601 updateTheme({ num }); 4587 4602 4588 - if (tap) { 4589 - ink("yellow"); 4590 - write("tap", { right: 6, top: 6 }); 4591 - } else if (!paintPictureOverlay) { 4592 - const downloadRailLeft = Math.min( 4593 - osBtn?.box?.x ?? Infinity, 4594 - abletonBtn?.box?.x ?? Infinity, 4595 - ); 4596 - const downloadRailRight = Math.max( 4597 - (osBtn?.box?.x ?? 0) + (osBtn?.box?.w ?? 0), 4598 - (abletonBtn?.box?.x ?? 0) + (abletonBtn?.box?.w ?? 0), 4599 - ); 4600 - if (Number.isFinite(downloadRailLeft) && downloadRailRight > downloadRailLeft) { 4601 - ink(10, 28, 34, 210).box( 4602 - downloadRailLeft - 3, 4603 - 0, 4604 - downloadRailRight - downloadRailLeft + 6, 4605 - TOP_BAR_BOTTOM - 1, 4606 - ); 4607 - ink(55, 120, 135, 170).box( 4608 - downloadRailLeft - 3, 4609 - 0, 4610 - downloadRailRight - downloadRailLeft + 6, 4611 - TOP_BAR_BOTTOM - 1, 4612 - "outline", 4613 - ); 4614 - } 4615 - 4616 - abletonBtn?.paint((btn) => { 4617 - ink(btn.down ? [52, 48, 90] : [30, 28, 62]).box( 4618 - btn.box.x, 4619 - btn.box.y + 3, 4620 - btn.box.w, 4621 - btn.box.h - 3, 4622 - ); 4623 - if (btn.over && !btn.down) { 4624 - ink(255, 255, 255, 24).box( 4625 - btn.box.x, 4626 - btn.box.y + 3, 4627 - btn.box.w, 4628 - btn.box.h - 3, 4629 - ); 4630 - ink(150, 175, 255, 160).box( 4631 - btn.box.x, 4632 - btn.box.y + 3, 4633 - btn.box.w, 4634 - btn.box.h - 3, 4635 - "outline", 4636 - ); 4637 - } 4638 - ink(90, 120, 210).line( 4639 - btn.box.x + btn.box.w, 4640 - btn.box.y + 3, 4641 - btn.box.x + btn.box.w, 4642 - btn.box.y + btn.box.h - 1, 4643 - ); 4644 - ink(btn.down ? [235, 240, 255] : [175, 205, 255]).write( 4645 - "m4l", 4646 - { x: btn.box.x + 3, y: btn.box.y + 5 }, 4647 - undefined, undefined, false, "MatrixChunky8" 4648 - ); 4649 - }); 4650 - 4651 - osBtn?.paint((btn) => { 4652 - ink(btn.down ? [20, 70, 70] : [10, 45, 45]).box( 4653 - btn.box.x, 4654 - btn.box.y + 3, 4655 - btn.box.w, 4656 - btn.box.h - 3, 4657 - ); 4658 - if (btn.over && !btn.down) { 4659 - ink(255, 255, 255, 24).box( 4660 - btn.box.x, 4661 - btn.box.y + 3, 4662 - btn.box.w, 4663 - btn.box.h - 3, 4664 - ); 4665 - ink(100, 255, 255, 140).box( 4666 - btn.box.x, 4667 - btn.box.y + 3, 4668 - btn.box.w, 4669 - btn.box.h - 3, 4670 - "outline", 4671 - ); 4672 - } 4673 - ink(70, 160, 160).line( 4674 - btn.box.x + btn.box.w, 4675 - btn.box.y + 3, 4676 - btn.box.x + btn.box.w, 4677 - btn.box.y + btn.box.h - 1, 4678 - ); 4679 - ink(btn.down ? [220, 255, 255] : [120, 255, 255]).write( 4680 - "os", 4681 - { x: btn.box.x + 3, y: btn.box.y + 5 }, 4682 - undefined, undefined, false, "MatrixChunky8" 4683 - ); 4684 - }); 4685 - 4686 - waveBtn?.paint((btn) => { 4603 + if (tap) { 4604 + ink("yellow"); 4605 + write("tap", { right: 6, top: 6 }); 4606 + } else if (!paintPictureOverlay) { 4607 + const downloadRailLeft = Math.min( 4608 + osBtn?.box?.x ?? Infinity, 4609 + abletonBtn?.box?.x ?? Infinity, 4610 + ); 4611 + const downloadRailRight = Math.max( 4612 + (osBtn?.box?.x ?? 0) + (osBtn?.box?.w ?? 0), 4613 + (abletonBtn?.box?.x ?? 0) + (abletonBtn?.box?.w ?? 0), 4614 + ); 4615 + if (Number.isFinite(downloadRailLeft) && downloadRailRight > downloadRailLeft) { 4616 + ink(10, 28, 34, 210).box( 4617 + downloadRailLeft - 3, 4618 + 0, 4619 + downloadRailRight - downloadRailLeft + 6, 4620 + TOP_BAR_BOTTOM - 1, 4621 + ); 4622 + ink(55, 120, 135, 170).box( 4623 + downloadRailLeft - 3, 4624 + 0, 4625 + downloadRailRight - downloadRailLeft + 6, 4626 + TOP_BAR_BOTTOM - 1, 4627 + "outline", 4628 + ); 4629 + } 4630 + 4631 + abletonBtn?.paint((btn) => { 4632 + ink(btn.down ? [52, 48, 90] : [30, 28, 62]).box( 4633 + btn.box.x, 4634 + btn.box.y + 3, 4635 + btn.box.w, 4636 + btn.box.h - 3, 4637 + ); 4638 + if (btn.over && !btn.down) { 4639 + ink(255, 255, 255, 24).box( 4640 + btn.box.x, 4641 + btn.box.y + 3, 4642 + btn.box.w, 4643 + btn.box.h - 3, 4644 + ); 4645 + ink(150, 175, 255, 160).box( 4646 + btn.box.x, 4647 + btn.box.y + 3, 4648 + btn.box.w, 4649 + btn.box.h - 3, 4650 + "outline", 4651 + ); 4652 + } 4653 + ink(90, 120, 210).line( 4654 + btn.box.x + btn.box.w, 4655 + btn.box.y + 3, 4656 + btn.box.x + btn.box.w, 4657 + btn.box.y + btn.box.h - 1, 4658 + ); 4659 + ink(btn.down ? [235, 240, 255] : [175, 205, 255]).write( 4660 + "m4l", 4661 + { x: btn.box.x + 3, y: btn.box.y + 5 }, 4662 + undefined, undefined, false, "MatrixChunky8" 4663 + ); 4664 + }); 4665 + 4666 + osBtn?.paint((btn) => { 4667 + ink(btn.down ? [20, 70, 70] : [10, 45, 45]).box( 4668 + btn.box.x, 4669 + btn.box.y + 3, 4670 + btn.box.w, 4671 + btn.box.h - 3, 4672 + ); 4673 + if (btn.over && !btn.down) { 4674 + ink(255, 255, 255, 24).box( 4675 + btn.box.x, 4676 + btn.box.y + 3, 4677 + btn.box.w, 4678 + btn.box.h - 3, 4679 + ); 4680 + ink(100, 255, 255, 140).box( 4681 + btn.box.x, 4682 + btn.box.y + 3, 4683 + btn.box.w, 4684 + btn.box.h - 3, 4685 + "outline", 4686 + ); 4687 + } 4688 + ink(70, 160, 160).line( 4689 + btn.box.x + btn.box.w, 4690 + btn.box.y + 3, 4691 + btn.box.x + btn.box.w, 4692 + btn.box.y + btn.box.h - 1, 4693 + ); 4694 + ink(btn.down ? [220, 255, 255] : [120, 255, 255]).write( 4695 + "os", 4696 + { x: btn.box.x + 3, y: btn.box.y + 5 }, 4697 + undefined, undefined, false, "MatrixChunky8" 4698 + ); 4699 + }); 4700 + 4701 + drumBtn?.paint((btn) => { 4702 + const base = drumMode ? [90, 30, 30] : [30, 20, 40]; 4703 + const bright = drumMode ? [220, 110, 110] : [120, 120, 180]; 4704 + ink(btn.down ? [140, 60, 60] : base).box( 4705 + btn.box.x, 4706 + btn.box.y + 3, 4707 + btn.box.w, 4708 + btn.box.h - 3, 4709 + ); 4710 + if (btn.over && !btn.down) { 4711 + ink(255, 255, 255, 24).box( 4712 + btn.box.x, 4713 + btn.box.y + 3, 4714 + btn.box.w, 4715 + btn.box.h - 3, 4716 + ); 4717 + ink(255, 160, 160, 140).box( 4718 + btn.box.x, 4719 + btn.box.y + 3, 4720 + btn.box.w, 4721 + btn.box.h - 3, 4722 + "outline", 4723 + ); 4724 + } 4725 + ink(bright).line( 4726 + btn.box.x + btn.box.w, 4727 + btn.box.y + 3, 4728 + btn.box.x + btn.box.w, 4729 + btn.box.y + btn.box.h - 1, 4730 + ); 4731 + ink(btn.down ? [255, 220, 220] : bright).write( 4732 + "drm", 4733 + { x: btn.box.x + 3, y: btn.box.y + 5 }, 4734 + undefined, undefined, false, "MatrixChunky8" 4735 + ); 4736 + }); 4737 + 4738 + waveBtn?.paint((btn) => { 4687 4739 ink(btn.down ? [40, 40, 100] : "darkblue").box( 4688 4740 btn.box.x, 4689 4741 btn.box.y + 3, ··· 5541 5593 const connectedGamepads = {}; 5542 5594 let miniMapActiveNote = null; 5543 5595 let miniMapActiveKey = null; 5544 - let topBarPianoActiveNote = null; // Track active note from top bar piano 5545 - let soundContext = null; 5546 - 5547 - function setSoundContext(ctx) { 5548 - soundContext = ctx; 5549 - } 5550 - 5551 - function lowerBaseOctave() { 5552 - return parseInt(octave) + lowerOctaveShift; 5553 - } 5554 - 5555 - function upperBaseOctave() { 5556 - return parseInt(octave) + 1 + upperOctaveShift; 5557 - } 5558 - 5559 - function midiNoteToRelayButton(noteNumber) { 5560 - if (typeof noteNumber !== "number") return null; 5561 - 5562 - const normalizedNote = ((noteNumber % 12) + 12) % 12; 5563 - const noteName = MIDI_NOTE_NAMES[normalizedNote]; 5564 - const noteOctave = Math.floor(noteNumber / 12) - 1; 5565 - const lowerOct = lowerBaseOctave(); 5566 - const upperOct = upperBaseOctave(); 5567 - 5568 - if (noteOctave === lowerOct && buttonNoteLookup.has(noteName)) { 5569 - return noteName; 5570 - } 5571 - 5572 - if (noteOctave === upperOct) { 5573 - const upperNote = `+${noteName}`; 5574 - if (buttonNoteLookup.has(upperNote)) return upperNote; 5575 - } 5576 - 5577 - if (noteOctave < lowerOct) { 5578 - const baseNote = noteOctave < lowerOct - 1 ? noteName : `+${noteName}`; 5579 - if (buttonNoteLookup.has(baseNote)) return baseNote; 5580 - } 5581 - 5582 - if (noteOctave > upperOct) { 5583 - const baseNote = noteOctave > upperOct + 1 ? noteName : `+${noteName}`; 5584 - if (buttonNoteLookup.has(baseNote)) return baseNote; 5585 - } 5586 - 5587 - if (buttonNoteLookup.has(noteName)) return noteName; 5588 - if (buttonNoteLookup.has(`+${noteName}`)) return `+${noteName}`; 5589 - return null; 5590 - } 5591 - 5592 - function startRelayButtonNote(buttonNote, velocity = 127) { 5593 - if (!buttonNote) return false; 5594 - 5595 - const num = soundContext?.num; 5596 - const synth = soundContext?.synth; 5597 - const freq = soundContext?.freq; 5598 - 5599 - if (song && buttonNote.toUpperCase() !== song?.[songIndex]?.[0]) { 5600 - synth?.({ 5601 - type: "noise-white", 5602 - tone: 1000, 5603 - duration: 0.05, 5604 - volume: 0.3, 5605 - attack: 0, 5606 - }); 5607 - return false; 5608 - } 5609 - 5610 - anyDown = true; 5611 - noteShake[buttonNote] = 3; 5612 - 5613 - let noteName = buttonNote; 5614 - let targetOctave = lowerBaseOctave(); 5615 - if (buttonNote.startsWith("+")) { 5616 - noteName = buttonNote.slice(1); 5617 - targetOctave = upperBaseOctave(); 5618 - } 5619 - 5620 - const tone = `${targetOctave}${noteName.toUpperCase()}`; 5621 - const active = orderedByCount(sounds); 5622 - 5623 - if (slide && active.length > 0) { 5624 - sounds[active[0]]?.sound?.update({ tone, duration: 0.1 }); 5625 - tonestack[buttonNote] = { 5626 - count: Object.keys(tonestack).length, 5627 - tone, 5628 - }; 5629 - sounds[buttonNote] = sounds[active[0]]; 5630 - if (sounds[buttonNote]) sounds[buttonNote].note = buttonNote; 5631 - delete sounds[active[0]]; 5632 - applyPitchBendToNotes([buttonNote], { immediate: true }); 5633 - } else { 5634 - tonestack[buttonNote] = { 5635 - count: Object.keys(tonestack).length, 5636 - tone, 5637 - }; 5638 - 5639 - const pan = getPanForButtonNote(buttonNote); 5640 - let soundHandle = makeNoteSound(tone, velocity, pan); 5641 - 5642 - if (!soundHandle || typeof soundHandle.kill !== "function") { 5643 - const velocityRatio = velocity === undefined ? 1 : velocity / 127; 5644 - const clampedRatio = num?.clamp 5645 - ? num.clamp(velocityRatio, 0, 1) 5646 - : Math.max(0, Math.min(1, velocityRatio)); 5647 - const minVelocityVolume = 0.05; 5648 - const fallbackVolume = 5649 - toneVolume * (minVelocityVolume + (1 - minVelocityVolume) * clampedRatio); 5650 - soundHandle = synth?.({ 5651 - type: "sine", 5652 - tone: freq?.(tone), 5653 - attack: quickFade ? 0.0015 : attack, 5654 - decay: 0.9, 5655 - duration: 0.4, 5656 - volume: fallbackVolume, 5657 - pan, 5658 - }); 5659 - } 5660 - 5661 - sounds[buttonNote] = { 5662 - note: buttonNote, 5663 - count: active.length + 1, 5664 - sound: soundHandle, 5665 - }; 5666 - 5667 - applyPitchBendToNotes([buttonNote], { immediate: true }); 5668 - 5669 - if (buttonNote.toUpperCase() === song?.[songIndex]?.[0]) { 5670 - songNoteDown = true; 5671 - } 5672 - 5673 - delete trail[buttonNote]; 5674 - 5675 - if (autopatApi) pictureAdd(autopatApi, tone); 5676 - } 5677 - 5678 - if (buttons[buttonNote]) { 5679 - buttons[buttonNote].down = true; 5680 - buttons[buttonNote].over = true; 5681 - } 5682 - 5683 - return true; 5684 - } 5685 - 5686 - function stopRelayButtonNote(buttonNote) { 5687 - if (!buttonNote) return; 5688 - 5689 - const orderedTones = orderedByCount(tonestack); 5690 - 5691 - if (slide && orderedTones.length > 1 && sounds[buttonNote]) { 5692 - const previousKey = orderedTones[orderedTones.length - 2]; 5693 - const previousTone = tonestack[previousKey]?.tone; 5694 - if (previousTone) { 5695 - sounds[buttonNote]?.sound?.update({ tone: previousTone, duration: 0.1 }); 5696 - sounds[previousKey] = sounds[buttonNote]; 5697 - if (sounds[previousKey]) sounds[previousKey].note = previousKey; 5698 - applyPitchBendToNotes([previousKey], { immediate: true }); 5699 - } 5700 - } else if (sounds[buttonNote]?.sound) { 5701 - const soundEntry = sounds[buttonNote]; 5702 - const lifespan = soundEntry.sound?.startedAt 5703 - ? performance.now() / 1000 - soundEntry.sound.startedAt 5704 - : 0.1; 5705 - const fade = max(0.075, min(lifespan, 0.15)); 5706 - soundEntry.sound.kill(quickFade ? fastFade : fade); 5707 - } 5708 - 5709 - if (buttonNote.toUpperCase() === song?.[songIndex]?.[0]) { 5710 - songIndex = (songIndex + 1) % song.length; 5711 - songNoteDown = false; 5712 - songShifting = true; 5713 - } 5714 - 5715 - delete tonestack[buttonNote]; 5716 - delete sounds[buttonNote]; 5717 - trail[buttonNote] = 1; 5718 - 5719 - if (buttons[buttonNote]) { 5720 - buttons[buttonNote].down = false; 5721 - buttons[buttonNote].over = false; 5722 - } 5723 - } 5724 - 5725 - function flushRelayMidiQueue() { 5726 - if (relayNeedsPanic) { 5727 - for (const buttonNote of relayActiveNotes.values()) { 5728 - stopRelayButtonNote(buttonNote); 5729 - } 5730 - relayActiveNotes.clear(); 5731 - relayNeedsPanic = false; 5732 - } 5733 - 5734 - while (relayMidiQueue.length > 0) { 5735 - const event = relayMidiQueue.shift(); 5736 - const noteNumber = Number(event?.note); 5737 - if (!Number.isFinite(noteNumber)) continue; 5738 - 5739 - const key = [ 5740 - normalizeRelayHandle(event?.handle), 5741 - `${event?.machineId || "unknown"}`, 5742 - `${event?.channel ?? 0}`, 5743 - `${Math.round(noteNumber)}`, 5744 - ].join(":"); 5745 - 5746 - if (event?.event === "note_off" || Number(event?.velocity) === 0) { 5747 - const activeButtonNote = relayActiveNotes.get(key) || midiNoteToRelayButton(Math.round(noteNumber)); 5748 - if (activeButtonNote) stopRelayButtonNote(activeButtonNote); 5749 - relayActiveNotes.delete(key); 5750 - continue; 5751 - } 5752 - 5753 - const buttonNote = midiNoteToRelayButton(Math.round(noteNumber)); 5754 - if (!buttonNote) continue; 5755 - if (relayActiveNotes.has(key)) { 5756 - stopRelayButtonNote(relayActiveNotes.get(key)); 5757 - } 5758 - if (startRelayButtonNote(buttonNote, Number(event?.velocity) || 127)) { 5759 - relayActiveNotes.set(key, buttonNote); 5760 - } 5761 - } 5762 - } 5763 - 5764 - function makeNoteSound(tone, velocity = 127, pan = 0) { 5765 - const synth = soundContext?.synth; 5596 + let topBarPianoActiveNote = null; // Track active note from top bar piano 5597 + let soundContext = null; 5598 + 5599 + function setSoundContext(ctx) { 5600 + soundContext = ctx; 5601 + } 5602 + 5603 + function lowerBaseOctave() { 5604 + return parseInt(octave) + lowerOctaveShift; 5605 + } 5606 + 5607 + function upperBaseOctave() { 5608 + return parseInt(octave) + 1 + upperOctaveShift; 5609 + } 5610 + 5611 + function midiNoteToRelayButton(noteNumber) { 5612 + if (typeof noteNumber !== "number") return null; 5613 + 5614 + const normalizedNote = ((noteNumber % 12) + 12) % 12; 5615 + const noteName = MIDI_NOTE_NAMES[normalizedNote]; 5616 + const noteOctave = Math.floor(noteNumber / 12) - 1; 5617 + const lowerOct = lowerBaseOctave(); 5618 + const upperOct = upperBaseOctave(); 5619 + 5620 + if (noteOctave === lowerOct && buttonNoteLookup.has(noteName)) { 5621 + return noteName; 5622 + } 5623 + 5624 + if (noteOctave === upperOct) { 5625 + const upperNote = `+${noteName}`; 5626 + if (buttonNoteLookup.has(upperNote)) return upperNote; 5627 + } 5628 + 5629 + if (noteOctave < lowerOct) { 5630 + const baseNote = noteOctave < lowerOct - 1 ? noteName : `+${noteName}`; 5631 + if (buttonNoteLookup.has(baseNote)) return baseNote; 5632 + } 5633 + 5634 + if (noteOctave > upperOct) { 5635 + const baseNote = noteOctave > upperOct + 1 ? noteName : `+${noteName}`; 5636 + if (buttonNoteLookup.has(baseNote)) return baseNote; 5637 + } 5638 + 5639 + if (buttonNoteLookup.has(noteName)) return noteName; 5640 + if (buttonNoteLookup.has(`+${noteName}`)) return `+${noteName}`; 5641 + return null; 5642 + } 5643 + 5644 + function startRelayButtonNote(buttonNote, velocity = 127) { 5645 + if (!buttonNote) return false; 5646 + 5647 + const num = soundContext?.num; 5648 + const synth = soundContext?.synth; 5649 + const freq = soundContext?.freq; 5650 + 5651 + if (song && buttonNote.toUpperCase() !== song?.[songIndex]?.[0]) { 5652 + synth?.({ 5653 + type: "noise-white", 5654 + tone: 1000, 5655 + duration: 0.05, 5656 + volume: 0.3, 5657 + attack: 0, 5658 + }); 5659 + return false; 5660 + } 5661 + 5662 + anyDown = true; 5663 + noteShake[buttonNote] = 3; 5664 + 5665 + let noteName = buttonNote; 5666 + let targetOctave = lowerBaseOctave(); 5667 + if (buttonNote.startsWith("+")) { 5668 + noteName = buttonNote.slice(1); 5669 + targetOctave = upperBaseOctave(); 5670 + } 5671 + 5672 + const tone = `${targetOctave}${noteName.toUpperCase()}`; 5673 + const active = orderedByCount(sounds); 5674 + 5675 + if (slide && active.length > 0) { 5676 + sounds[active[0]]?.sound?.update({ tone, duration: 0.1 }); 5677 + tonestack[buttonNote] = { 5678 + count: Object.keys(tonestack).length, 5679 + tone, 5680 + }; 5681 + sounds[buttonNote] = sounds[active[0]]; 5682 + if (sounds[buttonNote]) sounds[buttonNote].note = buttonNote; 5683 + delete sounds[active[0]]; 5684 + applyPitchBendToNotes([buttonNote], { immediate: true }); 5685 + } else { 5686 + tonestack[buttonNote] = { 5687 + count: Object.keys(tonestack).length, 5688 + tone, 5689 + }; 5690 + 5691 + const pan = getPanForButtonNote(buttonNote); 5692 + let soundHandle = makeNoteSound(tone, velocity, pan); 5693 + 5694 + if (!soundHandle || typeof soundHandle.kill !== "function") { 5695 + const velocityRatio = velocity === undefined ? 1 : velocity / 127; 5696 + const clampedRatio = num?.clamp 5697 + ? num.clamp(velocityRatio, 0, 1) 5698 + : Math.max(0, Math.min(1, velocityRatio)); 5699 + const minVelocityVolume = 0.05; 5700 + const fallbackVolume = 5701 + toneVolume * (minVelocityVolume + (1 - minVelocityVolume) * clampedRatio); 5702 + soundHandle = synth?.({ 5703 + type: "sine", 5704 + tone: freq?.(tone), 5705 + attack: quickFade ? 0.0015 : attack, 5706 + decay: 0.9, 5707 + duration: 0.4, 5708 + volume: fallbackVolume, 5709 + pan, 5710 + }); 5711 + } 5712 + 5713 + sounds[buttonNote] = { 5714 + note: buttonNote, 5715 + count: active.length + 1, 5716 + sound: soundHandle, 5717 + }; 5718 + 5719 + applyPitchBendToNotes([buttonNote], { immediate: true }); 5720 + 5721 + if (buttonNote.toUpperCase() === song?.[songIndex]?.[0]) { 5722 + songNoteDown = true; 5723 + } 5724 + 5725 + delete trail[buttonNote]; 5726 + 5727 + if (autopatApi) pictureAdd(autopatApi, tone); 5728 + } 5729 + 5730 + if (buttons[buttonNote]) { 5731 + buttons[buttonNote].down = true; 5732 + buttons[buttonNote].over = true; 5733 + } 5734 + 5735 + return true; 5736 + } 5737 + 5738 + function stopRelayButtonNote(buttonNote) { 5739 + if (!buttonNote) return; 5740 + 5741 + const orderedTones = orderedByCount(tonestack); 5742 + 5743 + if (slide && orderedTones.length > 1 && sounds[buttonNote]) { 5744 + const previousKey = orderedTones[orderedTones.length - 2]; 5745 + const previousTone = tonestack[previousKey]?.tone; 5746 + if (previousTone) { 5747 + sounds[buttonNote]?.sound?.update({ tone: previousTone, duration: 0.1 }); 5748 + sounds[previousKey] = sounds[buttonNote]; 5749 + if (sounds[previousKey]) sounds[previousKey].note = previousKey; 5750 + applyPitchBendToNotes([previousKey], { immediate: true }); 5751 + } 5752 + } else if (sounds[buttonNote]?.sound) { 5753 + const soundEntry = sounds[buttonNote]; 5754 + const lifespan = soundEntry.sound?.startedAt 5755 + ? performance.now() / 1000 - soundEntry.sound.startedAt 5756 + : 0.1; 5757 + const fade = max(0.075, min(lifespan, 0.15)); 5758 + soundEntry.sound.kill(quickFade ? fastFade : fade); 5759 + } 5760 + 5761 + if (buttonNote.toUpperCase() === song?.[songIndex]?.[0]) { 5762 + songIndex = (songIndex + 1) % song.length; 5763 + songNoteDown = false; 5764 + songShifting = true; 5765 + } 5766 + 5767 + delete tonestack[buttonNote]; 5768 + delete sounds[buttonNote]; 5769 + trail[buttonNote] = 1; 5770 + 5771 + if (buttons[buttonNote]) { 5772 + buttons[buttonNote].down = false; 5773 + buttons[buttonNote].over = false; 5774 + } 5775 + } 5776 + 5777 + function flushRelayMidiQueue() { 5778 + if (relayNeedsPanic) { 5779 + for (const buttonNote of relayActiveNotes.values()) { 5780 + stopRelayButtonNote(buttonNote); 5781 + } 5782 + relayActiveNotes.clear(); 5783 + relayNeedsPanic = false; 5784 + } 5785 + 5786 + while (relayMidiQueue.length > 0) { 5787 + const event = relayMidiQueue.shift(); 5788 + const noteNumber = Number(event?.note); 5789 + if (!Number.isFinite(noteNumber)) continue; 5790 + 5791 + const key = [ 5792 + normalizeRelayHandle(event?.handle), 5793 + `${event?.machineId || "unknown"}`, 5794 + `${event?.channel ?? 0}`, 5795 + `${Math.round(noteNumber)}`, 5796 + ].join(":"); 5797 + 5798 + if (event?.event === "note_off" || Number(event?.velocity) === 0) { 5799 + const activeButtonNote = relayActiveNotes.get(key) || midiNoteToRelayButton(Math.round(noteNumber)); 5800 + if (activeButtonNote) stopRelayButtonNote(activeButtonNote); 5801 + relayActiveNotes.delete(key); 5802 + continue; 5803 + } 5804 + 5805 + const buttonNote = midiNoteToRelayButton(Math.round(noteNumber)); 5806 + if (!buttonNote) continue; 5807 + if (relayActiveNotes.has(key)) { 5808 + stopRelayButtonNote(relayActiveNotes.get(key)); 5809 + } 5810 + if (startRelayButtonNote(buttonNote, Number(event?.velocity) || 127)) { 5811 + relayActiveNotes.set(key, buttonNote); 5812 + } 5813 + } 5814 + } 5815 + 5816 + function makeNoteSound(tone, velocity = 127, pan = 0) { 5817 + const synth = soundContext?.synth; 5766 5818 const play = soundContext?.play; 5767 5819 const freq = soundContext?.freq; 5768 5820 const num = soundContext?.num; ··· 5935 5987 5936 5988 if (downs[note]) return false; 5937 5989 5990 + // Drum mode: each key fires the shared 12-drum kit instead of a pitched note. 5991 + // Strip octave prefix (++, +, -) and lowercase so "C" or "+c" both land on `c`. 5992 + if (drumMode && soundContext) { 5993 + const letter = note.replace(/^[+\-]+/, "").toLowerCase(); 5994 + const pan = getPanForButtonNote(note); 5995 + playPercussion(soundContext, letter, { 5996 + volume: Math.max(0.1, velocity / 127), 5997 + pan, 5998 + pitchFactor: 1.0, 5999 + phase: "both", 6000 + }); 6001 + return true; 6002 + } 6003 + 5938 6004 let noteUpper = note.toUpperCase(); 5939 6005 keys += noteUpper; 5940 6006 const active = orderedByCount(sounds); ··· 6087 6153 } 6088 6154 } 6089 6155 if (e.is("reframed")) { 6090 - setupButtons(api); 6091 - buildOctButton(api); 6092 - buildWaveButton(api); 6093 - buildAbletonButton(api); 6094 - buildOsButton(api); 6095 - buildToggleButtons(api); 6096 - buildMetronomeButtons(api); 6156 + setupButtons(api); 6157 + buildOctButton(api); 6158 + buildWaveButton(api); 6159 + buildAbletonButton(api); 6160 + buildOsButton(api); 6161 + buildDrumButton(api); 6162 + buildToggleButtons(api); 6163 + buildMetronomeButtons(api); 6097 6164 // Resize picture to quarter resolution (half width, half height) 6098 6165 const resizedPictureWidth = Math.max(1, Math.floor(screen.width / 2)); 6099 6166 const resizedPictureHeight = Math.max(1, Math.floor(screen.height / 2)); ··· 6186 6253 6187 6254 // 🎭 Tap on top bar to toggle recital mode (minimal wireframe UI) 6188 6255 // Only in visualizer area (between piano end and waveBtn), not on piano keys 6189 - if (e.is("touch") && e.y < TOP_BAR_BOTTOM && !projector && !paintPictureOverlay && !recitalMode) { 6190 - // Check that tap is in the visualizer area (after piano, before waveBtn) 6191 - const topBarBase = dotComMode ? 75 : 54; 6192 - const topPianoWidth = Math.min(140, Math.floor((screen.width - topBarBase) * 0.5)); 6193 - const topPianoEndX = topBarBase + topPianoWidth; 6194 - const vizLeft = topPianoEndX; // Start after piano 6195 - const vizRight = Math.min( 6196 - osBtn?.box?.x ?? Infinity, 6197 - abletonBtn?.box?.x ?? Infinity, 6198 - waveBtn?.box?.x ?? screen.width, 6199 - ) - 1; 6200 - if (e.x >= vizLeft && e.x <= vizRight) { 6201 - recitalMode = true; 6202 - recitalBlinkPhase = 0; 6203 - } 6204 - } 6256 + if (e.is("touch") && e.y < TOP_BAR_BOTTOM && !projector && !paintPictureOverlay && !recitalMode) { 6257 + // Check that tap is in the visualizer area (after piano, before waveBtn) 6258 + const topBarBase = dotComMode ? 75 : 54; 6259 + const topPianoWidth = Math.min(140, Math.floor((screen.width - topBarBase) * 0.5)); 6260 + const topPianoEndX = topBarBase + topPianoWidth; 6261 + const vizLeft = topPianoEndX; // Start after piano 6262 + const vizRight = Math.min( 6263 + drumBtn?.box?.x ?? Infinity, 6264 + osBtn?.box?.x ?? Infinity, 6265 + abletonBtn?.box?.x ?? Infinity, 6266 + waveBtn?.box?.x ?? screen.width, 6267 + ) - 1; 6268 + if (e.x >= vizLeft && e.x <= vizRight) { 6269 + recitalMode = true; 6270 + recitalBlinkPhase = 0; 6271 + } 6272 + } 6205 6273 6206 6274 if ((e.is("touch") || e.is("lift")) && !paintPictureOverlay && !projector) { 6207 6275 const sampleRateText = getSampleRateText(speaker?.sampleRate); ··· 6346 6414 api.beep(); 6347 6415 waveIndex = (waveIndex + 1) % wavetypes.length; 6348 6416 wave = wavetypes[waveIndex]; 6349 - buildWaveButton(api); 6350 - buildAbletonButton(api); 6351 - buildOsButton(api); 6417 + buildWaveButton(api); 6418 + buildAbletonButton(api); 6419 + buildOsButton(api); 6420 + buildDrumButton(api); 6352 6421 } 6353 6422 6354 6423 // if (e.is("keyboard:down:shift") && !e.repeat) { ··· 7093 7162 cleanupOrphanedSounds(pens); 7094 7163 } 7095 7164 7096 - octBtn?.act(e, { 7097 - down: () => api.beep(400), 7098 - push: (btn) => { 7099 - api.beep(); 7100 - waveIndex = (waveIndex + 1) % wavetypes.length; 7101 - const octNum = parseInt(octave); 7102 - octave = max(1, (octNum + 1) % 10).toString(); 7103 - buildOctButton(api); 7104 - buildWaveButton(api); 7105 - buildAbletonButton(api); 7106 - buildOsButton(api); 7107 - }, 7108 - }); 7165 + octBtn?.act(e, { 7166 + down: () => api.beep(400), 7167 + push: (btn) => { 7168 + api.beep(); 7169 + waveIndex = (waveIndex + 1) % wavetypes.length; 7170 + const octNum = parseInt(octave); 7171 + octave = max(1, (octNum + 1) % 10).toString(); 7172 + buildOctButton(api); 7173 + buildWaveButton(api); 7174 + buildAbletonButton(api); 7175 + buildOsButton(api); 7176 + buildDrumButton(api); 7177 + }, 7178 + }); 7109 7179 7110 - waveBtn?.act(e, { 7111 - down: () => api.beep(400), 7112 - push: (btn) => { 7113 - api.beep(); 7114 - waveIndex = (waveIndex + 1) % wavetypes.length; 7115 - wave = wavetypes[waveIndex]; 7116 - buildWaveButton(api); 7117 - buildAbletonButton(api); 7118 - buildOsButton(api); 7119 - }, 7120 - }); 7121 - 7122 - abletonBtn?.act(e, { 7123 - down: () => api.beep(400), 7124 - push: () => { 7125 - api.beep(); 7126 - jump("ableton"); 7127 - }, 7128 - }); 7129 - 7130 - osBtn?.act(e, { 7131 - down: () => api.beep(400), 7132 - push: () => { 7133 - api.beep(); 7134 - jump("os"); 7135 - }, 7136 - }); 7137 - 7138 - // 🎛️ Toggle button interactions 7180 + waveBtn?.act(e, { 7181 + down: () => api.beep(400), 7182 + push: (btn) => { 7183 + api.beep(); 7184 + waveIndex = (waveIndex + 1) % wavetypes.length; 7185 + wave = wavetypes[waveIndex]; 7186 + buildWaveButton(api); 7187 + buildAbletonButton(api); 7188 + buildOsButton(api); 7189 + buildDrumButton(api); 7190 + }, 7191 + }); 7192 + 7193 + abletonBtn?.act(e, { 7194 + down: () => api.beep(400), 7195 + push: () => { 7196 + api.beep(); 7197 + jump("ableton"); 7198 + }, 7199 + }); 7200 + 7201 + osBtn?.act(e, { 7202 + down: () => api.beep(400), 7203 + push: () => { 7204 + api.beep(); 7205 + jump("os"); 7206 + }, 7207 + }); 7208 + 7209 + drumBtn?.act(e, { 7210 + down: () => api.beep(400), 7211 + push: () => { 7212 + api.beep(drumMode ? 200 : 600); 7213 + drumMode = !drumMode; 7214 + }, 7215 + }); 7216 + 7217 + // 🎛️ Toggle button interactions 7139 7218 slideBtn?.act(e, { 7140 7219 push: () => { 7141 7220 api.beep(); ··· 8251 8330 waveBtn.displayWave = displayWave; 8252 8331 } 8253 8332 8254 - function buildOctButton({ screen, ui, typeface }) { 8255 - const isNarrow = screen.width < 200; 8256 - const glyphWidth = typeface?.glyphs?.["0"]?.resolution?.[0] ?? matrixFont?.glyphs?.["0"]?.resolution?.[0] ?? 6; 8257 - const octWidth = octave.length * glyphWidth; 8333 + function buildOctButton({ screen, ui, typeface }) { 8334 + const isNarrow = screen.width < 200; 8335 + const glyphWidth = typeface?.glyphs?.["0"]?.resolution?.[0] ?? matrixFont?.glyphs?.["0"]?.resolution?.[0] ?? 6; 8336 + const octWidth = octave.length * glyphWidth; 8258 8337 const margin = isNarrow ? 2 : 4; 8259 8338 octBtn = new ui.Button( 8260 8339 screen.width - octWidth - 6 - margin * 2, ··· 8262 8341 octWidth + margin * 2 + 7, 8263 8342 10 + margin * 2 - 1 + 2, 8264 8343 ); 8265 - octBtn.id = "oct-button"; 8266 - octBtn.isNarrow = isNarrow; 8267 - } 8268 - 8269 - function buildAbletonButton({ ui }) { 8270 - const margin = 4; 8271 - const labelWidth = 3 * 6; 8272 - const buttonWidth = labelWidth + margin * 2; 8273 - const buttonHeight = 10 + margin * 2 - 1 + 2; 8274 - const waveX = waveBtn?.box?.x ?? 9999; 8275 - abletonBtn = new ui.Button( 8276 - waveX - buttonWidth - 3, 8277 - 0, 8278 - buttonWidth, 8279 - buttonHeight, 8280 - ); 8281 - abletonBtn.id = "ableton-button"; 8282 - } 8283 - 8284 - function buildOsButton({ ui }) { 8285 - const margin = 4; 8286 - const labelWidth = 2 * 6; 8287 - const buttonWidth = labelWidth + margin * 2; 8288 - const buttonHeight = 10 + margin * 2 - 1 + 2; 8289 - const abletonX = abletonBtn?.box?.x ?? waveBtn?.box?.x ?? 9999; 8290 - osBtn = new ui.Button( 8291 - abletonX - buttonWidth - 3, 8292 - 0, 8293 - buttonWidth, 8294 - buttonHeight, 8295 - ); 8296 - osBtn.id = "os-button"; 8297 - } 8344 + octBtn.id = "oct-button"; 8345 + octBtn.isNarrow = isNarrow; 8346 + } 8347 + 8348 + function buildAbletonButton({ ui }) { 8349 + const margin = 4; 8350 + const labelWidth = 3 * 6; 8351 + const buttonWidth = labelWidth + margin * 2; 8352 + const buttonHeight = 10 + margin * 2 - 1 + 2; 8353 + const waveX = waveBtn?.box?.x ?? 9999; 8354 + abletonBtn = new ui.Button( 8355 + waveX - buttonWidth - 3, 8356 + 0, 8357 + buttonWidth, 8358 + buttonHeight, 8359 + ); 8360 + abletonBtn.id = "ableton-button"; 8361 + } 8362 + 8363 + function buildOsButton({ ui }) { 8364 + const margin = 4; 8365 + const labelWidth = 2 * 6; 8366 + const buttonWidth = labelWidth + margin * 2; 8367 + const buttonHeight = 10 + margin * 2 - 1 + 2; 8368 + const abletonX = abletonBtn?.box?.x ?? waveBtn?.box?.x ?? 9999; 8369 + osBtn = new ui.Button( 8370 + abletonX - buttonWidth - 3, 8371 + 0, 8372 + buttonWidth, 8373 + buttonHeight, 8374 + ); 8375 + osBtn.id = "os-button"; 8376 + } 8377 + 8378 + function buildDrumButton({ ui }) { 8379 + const margin = 4; 8380 + const labelWidth = 3 * 6; // "drm" 8381 + const buttonWidth = labelWidth + margin * 2; 8382 + const buttonHeight = 10 + margin * 2 - 1 + 2; 8383 + const osX = osBtn?.box?.x ?? abletonBtn?.box?.x ?? waveBtn?.box?.x ?? 9999; 8384 + drumBtn = new ui.Button( 8385 + osX - buttonWidth - 3, 8386 + 0, 8387 + buttonWidth, 8388 + buttonHeight, 8389 + ); 8390 + drumBtn.id = "drum-button"; 8391 + } 8298 8392 8299 8393 // Build metronome controls and toggle buttons with responsive layout 8300 8394 // Calculates available space and shortens labels as needed to prevent overlap
+339
system/public/aesthetic.computer/lib/percussion.mjs
··· 1 + // Percussion synth kit — shared between web notepat and native notepat. 2 + // Exports the 12-drum layout, display labels, pad colors, and a pure 3 + // `playPercussion(sound, letter, opts)` that fires the layered voices 4 + // for a single drum on the supplied AC sound API. 5 + // 6 + // Natural notes = 7 core drums (kick/snare/clap/snap/hat-c/hat-o/ride). 7 + // Sharps = 5 accents (crash/splash/cowbell/block/tambo). 8 + // 9 + // The native kit layered a voice-inspector and hold-release machinery on 10 + // top of this; both are kept callback-based here so the module itself has 11 + // no persistent state and works identically on web + native. 12 + 13 + // TR-808 hi-hat 6-square inharmonic cluster (trimmed to 4 for clarity). 14 + export const HAT_FREQS = [800, 540, 522.7, 369.6]; 15 + 16 + export const PERCUSSION_NAMES = { 17 + c: "kick", d: "snare", e: "clap", f: "snap", 18 + g: "hat-c", a: "hat-o", b: "ride", 19 + "c#": "crash", "d#": "splash", 20 + "f#": "cowbell", "g#": "block", "a#": "tambo", 21 + }; 22 + 23 + // 3-char display labels shown on drum pads in place of note names. 24 + export const PERCUSSION_LABELS = { 25 + c: "BAS", d: "SNR", e: "CLP", f: "SNP", 26 + g: "HHC", a: "HHO", b: "RDE", 27 + "c#": "CRS", "d#": "SPL", 28 + "f#": "CBL", "g#": "BLK", "a#": "TMB", 29 + }; 30 + 31 + // Metallic / earthy colors to visually distinguish drum pads from keys. 32 + export const PERCUSSION_COLORS = { 33 + c: [220, 90, 40], // kick — deep orange 34 + d: [220, 180, 110], // snare — tan 35 + e: [240, 220, 130], // clap — pale yellow 36 + f: [220, 240, 140], // snap — yellow-green 37 + g: [120, 220, 180], // closed hat — mint 38 + a: [120, 200, 240], // open hat — cyan 39 + b: [180, 180, 230], // ride — silver-blue 40 + "c#": [220, 150, 240], // crash — lavender 41 + "d#": [240, 160, 220], // splash — pink 42 + "f#": [200, 150, 80], // cowbell — brass 43 + "g#": [190, 120, 70], // block — wood brown 44 + "a#": [230, 210, 170], // tambourine — sandy 45 + }; 46 + 47 + // Graphic-notation voice signatures for the pad-idle animation renderer. 48 + // t: "s"=sine "t"=triangle "q"=square "w"=sawtooth "n"=noise 49 + // Format per voice: [type, freq, volume]. Consumed by drawGrid() in each 50 + // notepat implementation. 51 + export const PERCUSSION_NOTATION = { 52 + c: [["t",1800,1.0],["n",4000,0.55],["q",180,1.2],["w",90,1.0],["s",44,1.9],["s",54,1.3],["q",120,0.85]], 53 + d: [["n",2200,0.55],["t",220,0.4],["q",180,0.2]], 54 + e: [["q",2500,0.9],["n",6000,0.6],["n",1600,0.75],["n",1700,0.6],["n",1500,0.5],["n",1800,0.45]], 55 + f: [["n",3200,0.45],["q",1800,0.22],["t",2400,0.18]], 56 + g: [["n",7000,0.35],["n",5000,0.2]], 57 + a: [["n",6500,0.3],["n",4800,0.18]], 58 + b: [["n",4200,0.28],["q",3100,0.1],["q",4600,0.08]], 59 + "c#": [["n",3500,0.42],["n",6500,0.28],["q",4200,0.08]], 60 + "d#": [["n",5500,0.38],["n",8500,0.25]], 61 + "f#": [["q",810,0.22],["q",540,0.18]], 62 + "g#": [["t",900,0.35],["q",1800,0.14]], 63 + "a#": [["n",7000,0.3],["n",4500,0.18],["q",6500,0.1]], 64 + }; 65 + 66 + // Wave type → accent color for the notation glyph. 67 + export const NOTATION_WAVE_RGB = { 68 + s: [120, 200, 255], // sine — sky blue 69 + t: [120, 255, 180], // triangle — mint 70 + q: [255, 220, 120], // square — amber 71 + w: [255, 140, 80], // sawtooth — orange 72 + n: [220, 220, 230], // noise — pale grey 73 + }; 74 + 75 + // Fire the layered voices for a single drum. Pure function — no 76 + // persistent state and no module-level side effects. 77 + // 78 + // Params: 79 + // sound AC sound API (needs .synth). 80 + // letter "c" | "d" | ... | "a#". Unknown letters are ignored. 81 + // opts: 82 + // volume master volume scalar (0.1–2.2 clamped). Default 1.0. 83 + // pan stereo pan (−1..1). Default 0. 84 + // pitchFactor tone multiplier for transpose (0.25–4 clamped). Default 1. 85 + // phase "down" (live key press, sustains go into holdVoices), 86 + // "up" (legacy release — ignored), or 87 + // "both" (self-contained one-shot, sustains play finite). 88 + // Default "both". 89 + // holdVoices array to receive live-sustain voice records. If absent 90 + // or phase !== "down", sustains fire as finite durations. 91 + // onVoice optional callback(voiceDesc) called once per voice 92 + // fired, for inspector/debug overlays. `voiceDesc` has 93 + // { type, tone, duration, volume, attack, decay, pan, kind } 94 + // where kind is "hit" or "sustain". 95 + export function playPercussion(sound, letter, opts = {}) { 96 + if (!sound?.synth) return; 97 + const { 98 + volume = 1.0, 99 + pan = 0, 100 + pitchFactor = 1.0, 101 + phase = "both", 102 + holdVoices = null, 103 + onVoice = null, 104 + } = opts; 105 + 106 + const v = Math.max(0.1, Math.min(2.2, volume)); 107 + const pf = Math.max(0.25, Math.min(4, pitchFactor)); 108 + 109 + const fireDown = phase !== "up"; 110 + const isLive = phase === "down" && Array.isArray(holdVoices); 111 + if (!fireDown) return; 112 + 113 + // Per-hit random helpers (inline, stateless). `rj` jitters around a center 114 + // by a ± fraction; `rn` returns a uniform range. 115 + const rj = (center, frac) => center * (1 + (Math.random() - 0.5) * 2 * frac); 116 + const rn = (min, max) => min + Math.random() * (max - min); 117 + 118 + const inspect = (params, kind) => { 119 + if (!onVoice) return; 120 + onVoice({ 121 + type: params?.type, 122 + tone: params?.tone, 123 + duration: params?.duration, 124 + volume: params?.volume, 125 + attack: params?.attack, 126 + decay: params?.decay, 127 + pan: params?.pan, 128 + kind, 129 + }); 130 + }; 131 + 132 + const addSustain = (params, bothDuration, releaseFade, releaseUpdate, onRelease) => { 133 + inspect({ ...params, duration: bothDuration }, "sustain"); 134 + if (isLive) { 135 + const handle = sound.synth({ ...params, duration: Infinity }); 136 + if (handle) { 137 + const liveEntry = { handle, releaseFade, releaseUpdate, onRelease }; 138 + if (params?.tone !== undefined) liveEntry.baseTone = params.tone / pf; 139 + if (params?.volume !== undefined) liveEntry.baseVolumeUnit = params.volume / v; 140 + if (params?.base !== undefined) liveEntry.sampleBase = params.base; 141 + if (releaseUpdate?.tone !== undefined) liveEntry.releaseBaseTone = releaseUpdate.tone / pf; 142 + if (releaseUpdate?.volume !== undefined) liveEntry.releaseBaseVolumeUnit = releaseUpdate.volume / v; 143 + holdVoices.push(liveEntry); 144 + } 145 + return handle; 146 + } 147 + return sound.synth({ ...params, duration: bothDuration }); 148 + }; 149 + 150 + const addHit = (params) => { 151 + inspect(params, "hit"); 152 + const handle = sound.synth(params); 153 + if (isLive && handle) { 154 + const liveEntry = { 155 + handle, 156 + ignoreRelease: true, 157 + releaseFade: 0, 158 + tailSeconds: Math.max( 159 + Number(params?.duration) || 0, 160 + (Number(params?.attack) || 0) + (Number(params?.decay) || 0), 161 + ), 162 + }; 163 + if (params?.tone !== undefined) liveEntry.baseTone = params.tone / pf; 164 + if (params?.volume !== undefined) liveEntry.baseVolumeUnit = params.volume / v; 165 + if (params?.base !== undefined) liveEntry.sampleBase = params.base; 166 + holdVoices.push(liveEntry); 167 + } 168 + return handle; 169 + }; 170 + 171 + const addReleaseBurst = (onRelease) => { 172 + if (isLive && onRelease) holdVoices.push({ handle: null, releaseFade: 0, onRelease }); 173 + }; 174 + 175 + switch (letter) { 176 + // === ONE-SHOT DRUMS (c/d/e/f/g) === 177 + case "c": { // kick — tight TR-808: transient + short body + optional sub 178 + const downPan = pan + rn(-0.02, 0.02); 179 + addHit({ type: "noise", tone: 2500 * pf, duration: 0.0025, volume: rj(0.50, 0.12) * v, attack: 0.0002, decay: 0.0022, pan: downPan }); 180 + addHit({ type: "sine", tone: 200 * pf, duration: 0.012, volume: rj(1.1, 0.10) * v, attack: 0.0005, decay: 0.011, pan: downPan }); 181 + addHit({ type: "sine", tone: 150 * pf, duration: 0.045, volume: rj(1.3, 0.10) * v, attack: 0.001, decay: 0.044, pan: downPan }); 182 + addHit({ type: "sine", tone: 90 * pf, duration: 0.080, volume: rj(0.85, 0.12) * v, attack: 0.002, decay: 0.078, pan: downPan }); 183 + addHit({ type: "sine", tone: 55 * pf, duration: rj(0.35, 0.20), volume: rj(1.0, 0.12) * v, attack: 0.003, decay: 0.345, pan: downPan }); 184 + break; 185 + } 186 + 187 + case "d": { // snare — TR-909-leaning: big transient + short tone + dominant noise 188 + const downPan = pan + rn(-0.02, 0.02); 189 + addHit({ type: "noise", tone: 3500 * pf, duration: 0.004, volume: rj(0.95, 0.10) * v, attack: 0.0001, decay: 0.004, pan: downPan }); 190 + addHit({ type: "sine", tone: 238 * pf, duration: 0.030, volume: rj(0.35, 0.12) * v, attack: 0.0003, decay: 0.029, pan: downPan }); 191 + addHit({ type: "sine", tone: 476 * pf, duration: 0.030, volume: rj(0.28, 0.12) * v, attack: 0.0003, decay: 0.029, pan: downPan }); 192 + addHit({ type: "noise", tone: 3500 * pf, duration: rj(0.11, 0.20), volume: rj(0.85, 0.10) * v, attack: 0.0005, decay: 0.108, pan: downPan + rn(-0.04, 0.04) }); 193 + addHit({ type: "noise", tone: 1800 * pf, duration: rj(0.07, 0.20), volume: rj(0.38, 0.15) * v, attack: 0.0008, decay: 0.068, pan: downPan + rn(-0.04, 0.04) }); 194 + addHit({ type: "triangle", tone: 180 * pf, duration: 0.025, volume: rj(0.22, 0.15) * v, attack: 0.001, decay: 0.024, pan: downPan }); 195 + break; 196 + } 197 + 198 + case "e": { // clap — TR-808 4-burst pattern via staggered attacks (one-shot) 199 + const downPan = pan + rn(-0.06, 0.02); 200 + addHit({ type: "noise", tone: 1000 * pf, duration: 0.025, volume: rj(0.90, 0.15) * v, attack: 0.005, decay: 0.020, pan: downPan }); 201 + addHit({ type: "noise", tone: 1100 * pf, duration: 0.035, volume: rj(0.95, 0.15) * v, attack: 0.015, decay: 0.020, pan: downPan }); 202 + addHit({ type: "noise", tone: 900 * pf, duration: 0.045, volume: rj(0.85, 0.15) * v, attack: 0.025, decay: 0.020, pan: downPan }); 203 + addHit({ type: "noise", tone: 3000 * pf, duration: 0.008, volume: rj(0.55, 0.15) * v, attack: 0.001, decay: 0.007, pan: downPan }); 204 + addHit({ type: "noise", tone: 1000 * pf, duration: rj(0.14, 0.25), volume: rj(0.85, 0.15) * v, attack: 0.045, decay: 0.135, pan: downPan + rn(-0.02, 0.10) }); 205 + addHit({ type: "noise", tone: 2200 * pf, duration: rj(0.10, 0.25), volume: rj(0.35, 0.18) * v, attack: 0.050, decay: 0.095, pan: downPan + rn(-0.02, 0.10) }); 206 + break; 207 + } 208 + 209 + case "f": { // snap — finger snap physics (one-shot) 210 + const downPan = pan + rn(-0.04, 0.04); 211 + addHit({ type: "noise", tone: 6000 * pf, duration: 0.003, volume: rj(0.70, 0.15) * v, attack: 0.0001, decay: 0.0028, pan: downPan }); 212 + addHit({ type: "sine", tone: 2100 * pf, duration: rj(0.045, 0.20), volume: rj(0.55, 0.12) * v, attack: 0.0005, decay: 0.044, pan: downPan }); 213 + addHit({ type: "sine", tone: 3500 * pf, duration: rj(0.020, 0.25), volume: rj(0.28, 0.18) * v, attack: 0.0005, decay: 0.019, pan: downPan }); 214 + break; 215 + } 216 + 217 + case "g": { // closed hi-hat — 4-square cluster + subtle lift click on release 218 + const downPan = pan + rn(-0.03, 0.03); 219 + for (const f of HAT_FREQS) { 220 + addHit({ type: "square", tone: f * pf, duration: rj(0.008, 0.20), volume: rj(0.18, 0.18) * v, attack: 0.0005, decay: 0.0075, pan: downPan }); 221 + } 222 + addHit({ type: "noise", tone: 8000 * pf, duration: rj(0.040, 0.20), volume: rj(0.38, 0.12) * v, attack: 0.0005, decay: 0.038, pan: downPan }); 223 + addReleaseBurst(() => { 224 + sound.synth({ type: "noise", tone: 9000 * pf, duration: 0.004, volume: rj(0.22, 0.20) * v, attack: 0.0002, decay: 0.0038, pan: downPan }); 225 + sound.synth({ type: "square", tone: 6000 * pf, duration: 0.003, volume: rj(0.08, 0.25) * v, attack: 0.0003, decay: 0.0027, pan: downPan }); 226 + }); 227 + break; 228 + } 229 + 230 + case "a": { // open hi-hat — TR-808 cluster, LONG sustain. Foot-pedal release = damp fast. 231 + const downPan = pan + rn(-0.04, 0.04); 232 + for (const f of HAT_FREQS) { 233 + addHit({ type: "square", tone: f * pf, duration: 0.012, volume: rj(0.16, 0.18) * v, attack: 0.0005, decay: 0.011, pan: downPan }); 234 + } 235 + addHit({ type: "noise", tone: 8200 * pf, duration: 0.012, volume: rj(0.42, 0.12) * v, attack: 0.0003, decay: 0.011, pan: downPan }); 236 + addSustain( 237 + { type: "noise", tone: 7000 * pf, volume: rj(0.32, 0.15) * v, attack: 0.003, decay: 0, pan: downPan + rn(-0.02, 0.08) }, 238 + rj(0.40, 0.25), 239 + 0.12, 240 + { tone: 3500 }, 241 + ); 242 + addSustain( 243 + { type: "noise", tone: 5000 * pf, volume: rj(0.20, 0.18) * v, attack: 0.003, decay: 0, pan: downPan + rn(-0.02, 0.08) }, 244 + rj(0.25, 0.25), 245 + 0.10, 246 + { tone: 2800 }, 247 + ); 248 + addSustain( 249 + { type: "square", tone: 800 * pf, volume: rj(0.08, 0.20) * v, attack: 0.005, decay: 0, pan: downPan }, 250 + rj(0.20, 0.25), 251 + 0.08, 252 + ); 253 + break; 254 + } 255 + 256 + case "b": { // ride — bell ping + long shimmer sustain 257 + const downPan = pan + rn(-0.03, 0.03); 258 + addHit({ type: "square", tone: 800 * pf, duration: 0.020, volume: rj(0.10, 0.18) * v, attack: 0.0005, decay: 0.019, pan: downPan }); 259 + addHit({ type: "square", tone: 540 * pf, duration: 0.020, volume: rj(0.08, 0.18) * v, attack: 0.0005, decay: 0.019, pan: downPan }); 260 + addSustain( 261 + { type: "sine", tone: 440 * pf, volume: rj(0.24, 0.12) * v, attack: 0.0008, decay: 0, pan: downPan }, 262 + rj(0.40, 0.20), 263 + 0.25, 264 + ); 265 + addSustain( 266 + { type: "sine", tone: 587 * pf, volume: rj(0.20, 0.12) * v, attack: 0.0008, decay: 0, pan: downPan }, 267 + rj(0.40, 0.20), 268 + 0.25, 269 + ); 270 + addSustain( 271 + { type: "noise", tone: 4200 * pf, volume: rj(0.26, 0.12) * v, attack: 0.005, decay: 0, pan: downPan + rn(-0.03, 0.03) }, 272 + rj(0.9, 0.20), 273 + 0.30, 274 + ); 275 + break; 276 + } 277 + 278 + case "c#": { // crash — explosive noise attack + LONG shimmer wash 279 + const downPan = pan + rn(-0.05, 0.05); 280 + addHit({ type: "noise", tone: 8000 * pf, duration: 0.030, volume: rj(0.75, 0.15) * v, attack: 0.0005, decay: 0.029, pan: downPan }); 281 + for (const f of HAT_FREQS) { 282 + addHit({ type: "square", tone: f * pf, duration: 0.030, volume: rj(0.12, 0.20) * v, attack: 0.0005, decay: 0.029, pan: downPan }); 283 + } 284 + addSustain( 285 + { type: "noise", tone: 5000 * pf, volume: rj(0.45, 0.12) * v, attack: 0.008, decay: 0, pan: downPan + rn(-0.04, 0.04) }, 286 + rj(1.4, 0.18), 287 + 0.45, 288 + ); 289 + addSustain( 290 + { type: "noise", tone: 7500 * pf, volume: rj(0.30, 0.15) * v, attack: 0.008, decay: 0, pan: downPan + rn(-0.04, 0.04) }, 291 + rj(0.9, 0.18), 292 + 0.35, 293 + ); 294 + addSustain( 295 + { type: "square", tone: 800 * pf, volume: rj(0.08, 0.20) * v, attack: 0.008, decay: 0, pan: downPan }, 296 + rj(0.5, 0.20), 297 + 0.20, 298 + ); 299 + break; 300 + } 301 + 302 + case "d#": { // splash — short bright cymbal burst (one-shot) 303 + const downPan = pan + rn(-0.04, 0.04); 304 + addHit({ type: "noise", tone: 9000 * pf, duration: 0.012, volume: rj(0.55, 0.15) * v, attack: 0.0003, decay: 0.011, pan: downPan }); 305 + addHit({ type: "square", tone: 800 * pf, duration: 0.015, volume: rj(0.14, 0.20) * v, attack: 0.0005, decay: 0.014, pan: downPan }); 306 + addHit({ type: "square", tone: 540 * pf, duration: 0.015, volume: rj(0.10, 0.20) * v, attack: 0.0005, decay: 0.014, pan: downPan }); 307 + addHit({ type: "noise", tone: 6000 * pf, duration: rj(0.35, 0.20), volume: rj(0.42, 0.12) * v, attack: 0.004, decay: 0.345, pan: downPan + rn(-0.03, 0.03) }); 308 + addHit({ type: "noise", tone: 8500 * pf, duration: rj(0.22, 0.20), volume: rj(0.25, 0.15) * v, attack: 0.004, decay: 0.215, pan: downPan + rn(-0.03, 0.03) }); 309 + break; 310 + } 311 + 312 + case "f#": { // cowbell — TR-808: two triangles 800/540 Hz (one-shot) 313 + const downPan = pan + rn(-0.03, 0.03); 314 + addHit({ type: "square", tone: 1800 * pf, duration: 0.004, volume: rj(0.35, 0.15) * v, attack: 0.0002, decay: 0.0038, pan: downPan }); 315 + addHit({ type: "triangle", tone: 800 * pf, duration: rj(0.28, 0.20), volume: rj(0.42, 0.12) * v, attack: 0.0008, decay: 0.275, pan: downPan }); 316 + addHit({ type: "triangle", tone: 540 * pf, duration: rj(0.28, 0.20), volume: rj(0.36, 0.12) * v, attack: 0.0008, decay: 0.275, pan: downPan }); 317 + break; 318 + } 319 + 320 + case "g#": { // wood block — single triangle @ 2500 Hz (one-shot) 321 + const downPan = pan + rn(-0.03, 0.03); 322 + addHit({ type: "noise", tone: 5000 * pf, duration: 0.002, volume: rj(0.35, 0.18) * v, attack: 0.0001, decay: 0.0018, pan: downPan }); 323 + addHit({ type: "triangle", tone: 2500 * pf, duration: rj(0.050, 0.25), volume: rj(0.52, 0.12) * v, attack: 0.0003, decay: 0.048, pan: downPan }); 324 + addHit({ type: "triangle", tone: 1250 * pf, duration: rj(0.050, 0.25), volume: rj(0.18, 0.18) * v, attack: 0.0005, decay: 0.048, pan: downPan }); 325 + break; 326 + } 327 + 328 + case "a#": { // tambourine — staggered jingle bursts (one-shot) 329 + const downPan = pan + rn(-0.04, 0.04); 330 + addHit({ type: "noise", tone: 7000 * pf, duration: 0.08, volume: rj(0.38, 0.18) * v, attack: 0.002, decay: 0.075, pan: downPan }); 331 + addHit({ type: "noise", tone: 7500 * pf, duration: 0.09, volume: rj(0.30, 0.18) * v, attack: 0.015, decay: 0.075, pan: downPan }); 332 + addHit({ type: "noise", tone: 6500 * pf, duration: 0.10, volume: rj(0.25, 0.18) * v, attack: 0.030, decay: 0.070, pan: downPan }); 333 + addHit({ type: "square", tone: 6000 * pf, duration: 0.030, volume: rj(0.14, 0.20) * v, attack: 0.001, decay: 0.028, pan: downPan }); 334 + addHit({ type: "noise", tone: 7000 * pf, duration: rj(0.20, 0.22), volume: rj(0.32, 0.18) * v, attack: 0.050, decay: 0.195, pan: downPan + rn(-0.04, 0.04) }); 335 + addHit({ type: "noise", tone: 4500 * pf, duration: rj(0.15, 0.22), volume: rj(0.20, 0.18) * v, attack: 0.055, decay: 0.145, pan: downPan + rn(-0.04, 0.04) }); 336 + break; 337 + } 338 + } 339 + }