Monorepo for Aesthetic.Computer aesthetic.computer
4
fork

Configure Feed

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

notepat: add WAVE_GUN DWG synth + 'war' kit alongside perc

Physically-modeled weapon synthesis via closed-breech/open-muzzle
acoustic waveguide with parallel body-mode resonators. 12 GunPresets
(pistol→ricochet) parameterized by real barrel dimensions; pgup/pgdn
now cycles each grid side through off→perc→war. Held weapons (LMG
auto-fire, sniper ring, grenade rumble, ricochet doppler) use the
same press/release machinery as open-hat / crash / ride.

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

+750 -62
+269 -51
fedac/native/pieces/notepat.mjs
··· 518 518 b: [200, 50, 255], 519 519 }; 520 520 521 - // === PERCUSSION LAYOUT === 522 - // Pgup toggles drum kit for the left octave grid, Pgdn for the right. 523 - // When active, the 12 notes in that grid become drum hits with their own 524 - // synth recipes and optional per-drum recorded samples. 525 - let percussionLeft = false; 526 - let percussionRight = false; 521 + // === KIT LAYOUT === 522 + // Pgup cycles the left octave grid's kit through off → perc → war → off. 523 + // Pgdn does the same for the right grid. "perc" replaces the 12 notes 524 + // with drum hits (TR-808 / 909 analogues). "war" replaces them with 525 + // physically-modeled weapons fired through the WAVE_GUN DWG synth. 526 + // Per-side state lets a child play drums on one hand and guns on the 527 + // other. 528 + let kitLeft = "off"; // "off" | "perc" | "war" 529 + let kitRight = "off"; 530 + // Backwards-compatible convenience (some code paths just want a bool). 531 + function percussionActive(side) { return side === "left" ? kitLeft !== "off" : kitRight !== "off"; } 527 532 528 533 // Per-octave master volume. Two vertical sliders (left of the left grid, 529 534 // right of the right grid) scale every note / drum played on that side so ··· 706 711 n: [220, 220, 230], // noise — pale grey 707 712 }; 708 713 709 - // Return drum name if the given (letter, grid offset) is a live drum pad, else null. 710 - // offset 0 = left grid, 1 = right grid. Low/high extras (z, x, ;, ', ]) stay melodic. 711 - function percussionDrumFor(letter, offset) { 712 - if (!PERCUSSION_NAMES[letter]) return null; 713 - if (offset === 0 && percussionLeft) return PERCUSSION_NAMES[letter]; 714 - if (offset === 1 && percussionRight) return PERCUSSION_NAMES[letter]; 714 + // === WAR KIT === 715 + // Natural notes = 7 core weapons; sharps = 5 accent items. Each name 716 + // matches a GunPreset in the native WAVE_GUN synth (see audio.c 717 + // gun_presets[]). The kit parallels the perc layout so muscle memory 718 + // transfers: one-shots land on perc-one-shot keys, sustained fire on 719 + // sustained perc keys (LMG ↔ open hat, sniper ↔ ride, grenade ↔ crash). 720 + const WAR_NAMES = { 721 + c: "pistol", d: "rifle", e: "shotgun", f: "suppressed", 722 + g: "smg", a: "lmg", b: "sniper", 723 + "c#": "grenade", "d#": "rpg", 724 + "f#": "reload", "g#": "cock", "a#": "ricochet", 725 + }; 726 + 727 + // 3-char labels for tight pad rendering 728 + const WAR_LABELS = { 729 + c: "PST", d: "RFL", e: "SHG", f: "SUP", 730 + g: "SMG", a: "LMG", b: "SNP", 731 + "c#": "GRN", "d#": "RPG", 732 + "f#": "RLD", "g#": "CCK", "a#": "RIC", 733 + }; 734 + 735 + // Gunmetal / field palette — earthy khakis, rusts, steels, brass. 736 + const WAR_COLORS = { 737 + c: [140, 140, 150], // pistol — gunmetal steel 738 + d: [ 90, 110, 70], // rifle — olive drab 739 + e: [120, 80, 60], // shotgun — brown + steel 740 + f: [ 70, 70, 80], // suppressed — matte black 741 + g: [150, 130, 90], // smg — khaki 742 + a: [100, 90, 60], // lmg — field green 743 + b: [180, 160, 110], // sniper — desert tan 744 + "c#": [ 80, 90, 60], // grenade — dark olive 745 + "d#": [200, 100, 60], // rpg — rust orange 746 + "f#": [130, 130, 140], // reload — silver-gray 747 + "g#": [160, 140, 100], // cock — brass 748 + "a#": [210, 210, 230], // ricochet — metallic white 749 + }; 750 + 751 + // Which weapons sustain while the key is held (release kills the voice). 752 + const WAR_SUSTAIN = { 753 + a: true, // lmg — retriggers internally via audio.c retrig_period 754 + b: true, // sniper — long reverberant tail 755 + "c#": true,// grenade — slow rumbling release 756 + "a#": true,// ricochet — pitch drops on release (doppler) 757 + }; 758 + 759 + // Finite durations for one-shot weapons (seconds). These control the 760 + // voice's auto-kill after the internal excitation has rung out. Longer 761 + // durations preserve the body-mode ring. 762 + const WAR_DURATION = { 763 + c: 0.30, // pistol 764 + d: 0.55, // rifle 765 + e: 0.80, // shotgun 766 + f: 0.20, // suppressed 767 + g: 0.18, // smg 768 + "d#": 2.50, // rpg (motor burn + delayed boom) 769 + "f#": 0.35, // reload 770 + "g#": 0.45, // cock (rack + lock) 771 + }; 772 + 773 + // Release fade on sustained weapons (seconds). Sniper/grenade fade 774 + // slowly so the tail is audible; LMG snaps off faster. 775 + const WAR_RELEASE = { 776 + a: 0.08, // lmg — abrupt stop (muzzle cut) 777 + b: 0.80, // sniper — long reverberant fade 778 + "c#": 1.20,// grenade — slow rumble-out 779 + "a#": 0.35,// ricochet — natural decay after pitch drop 780 + }; 781 + 782 + // Stochastic pad graphics (same format as PERCUSSION_NOTATION). These 783 + // are just visuals — the actual synth is the DWG. Each signature hints 784 + // at the weapon's spectral content so the pad communicates "what fires". 785 + const WAR_NOTATION = { 786 + c: [["t",1500,1.0],["n",4000,0.55],["q",8500,0.4]], // pistol 787 + d: [["n",5000,0.6],["t",800,0.9],["q",2400,0.5],["q",6000,0.4]], // rifle 788 + e: [["n",400,1.6],["n",1200,1.2],["n",3500,0.8]], // shotgun 789 + f: [["n",700,0.6],["s",1500,0.3]], // suppressed 790 + g: [["n",3000,0.5],["q",1200,0.6],["q",3500,0.5]], // smg 791 + a: [["n",1800,0.8],["t",600,1.0],["q",4500,0.4]], // lmg 792 + b: [["s",350,1.5],["s",950,1.1],["n",2800,0.7]], // sniper 793 + "c#": [["s", 80,2.0],["n",250,1.5],["n",1200,1.0]], // grenade 794 + "d#": [["n",600,2.0],["n",2000,1.2]], // rpg 795 + "f#": [["q",2200,0.6],["q",4500,0.5],["n",8000,0.4]], // reload 796 + "g#": [["q",1800,0.6],["q",4200,0.5]], // cock 797 + "a#": [["s",3000,0.9],["s",5500,0.7],["s",9000,0.5]], // ricochet 798 + }; 799 + 800 + // === KIT HELPERS === 801 + // Unified accessors: these return the active-kit metadata for a given 802 + // (letter, gridOffset) so render code doesn't need to branch on 803 + // kitLeft/kitRight everywhere. 804 + function activeKitForOffset(offset) { 805 + return offset === 0 ? kitLeft : kitRight; 806 + } 807 + function kitNamesFor(kit) { 808 + if (kit === "war") return WAR_NAMES; 809 + if (kit === "perc") return PERCUSSION_NAMES; 715 810 return null; 716 811 } 812 + function kitLabelsFor(kit) { 813 + if (kit === "war") return WAR_LABELS; 814 + if (kit === "perc") return PERCUSSION_LABELS; 815 + return null; 816 + } 817 + function kitColorsFor(kit) { 818 + if (kit === "war") return WAR_COLORS; 819 + if (kit === "perc") return PERCUSSION_COLORS; 820 + return null; 821 + } 822 + function kitNotationFor(kit) { 823 + if (kit === "war") return WAR_NOTATION; 824 + if (kit === "perc") return PERCUSSION_NOTATION; 825 + return null; 826 + } 827 + 828 + // Cycle the kit state for a side: off → perc → war → off. 829 + function cycleKit(side) { 830 + const cur = side === "left" ? kitLeft : kitRight; 831 + const next = cur === "off" ? "perc" : (cur === "perc" ? "war" : "off"); 832 + if (side === "left") kitLeft = next; else kitRight = next; 833 + return next; 834 + } 835 + 836 + // Return drum/weapon name if the given (letter, grid offset) is a live 837 + // kit pad, else null. offset 0 = left grid, 1 = right grid. Low/high 838 + // extras (z, x, ;, ', ]) stay melodic. 839 + function percussionDrumFor(letter, offset) { 840 + const info = kitDrumFor(letter, offset); 841 + return info ? info.name : null; 842 + } 843 + // Richer version that also returns which kit is active, so callers can 844 + // route to the right synth (playPercussion vs playWar). 845 + function kitDrumFor(letter, offset) { 846 + const kit = activeKitForOffset(offset); 847 + if (kit === "off") return null; 848 + const names = kitNamesFor(kit); 849 + if (!names || !names[letter]) return null; 850 + return { kit, name: names[letter] }; 851 + } 717 852 718 853 // === DRUM PAN: QWERTY physical key position === 719 854 // Pan each drum hit by where the key actually lives on the keyboard so ··· 751 886 } 752 887 753 888 // Push a drum flash entry so paint() will pulse the background in that 754 - // drum's characteristic color. Called from every drum trigger site 889 + // drum/weapon's characteristic color. Called from every kit trigger site 755 890 // (keyboard act(), touch-tap, drag-rollover, reverse-playback replay). 756 - function flashDrum(letter) { 757 - const color = PERCUSSION_COLORS[letter] || [200, 200, 200]; 891 + function flashDrum(letter, kit = "perc") { 892 + const table = kit === "war" ? WAR_COLORS : PERCUSSION_COLORS; 893 + const color = table[letter] || [200, 200, 200]; 758 894 drumFlashes.push({ color, frame, life: DRUM_FLASH_LIFE }); 759 895 if (drumFlashes.length > 8) drumFlashes.shift(); 760 896 } ··· 848 984 } 849 985 hold = makePercussionHold(letter, volume, pan, pitchFactor, voices, gridOffset, baseVolume); 850 986 } else { 851 - // Live-play: pass a holdVoices array that playPercussion populates with 852 - // infinite-duration sustain voices. These keep ringing until key-up 853 - // calls releasePercussionHold, which fades them out per-voice. 987 + // Live-play: pass a holdVoices array that the kit synth populates 988 + // with sustain voices. These keep ringing until key-up calls 989 + // releasePercussionHold, which fades them out per-voice. Dispatch 990 + // to the active kit on this grid side. 991 + const kit = activeKitForOffset(gridOffset); 854 992 const voices = []; 855 - playPercussion(sound, letter, volume, pan, pitchFactor, "down", voices); 993 + if (kit === "war") { 994 + playWar(sound, letter, volume, pan, pitchFactor, "down", voices); 995 + } else { 996 + playPercussion(sound, letter, volume, pan, pitchFactor, "down", voices); 997 + } 856 998 hold = makePercussionHold(letter, volume, pan, pitchFactor, voices, gridOffset, baseVolume); 999 + hold.kit = kit; 857 1000 } 858 1001 859 1002 rememberPercussionHoldFromVoices(hold); 860 - flashDrum(letter); 1003 + flashDrum(letter, hold?.kit || activeKitForOffset(gridOffset)); 861 1004 recordPlayback({ kind: "drum", letter, octave: octaveValue, vel: volume, pan, pitch: pitchFactor }); 862 1005 if (key) trail[key] = { note: letter, octave: octaveValue, brightness: 1.0 }; 863 1006 return hold; ··· 1200 1343 } 1201 1344 } 1202 1345 1346 + // Fire a WAR-kit weapon through the native WAVE_GUN DWG synth. 1347 + // All the physically-modeled parameters (bore length, body modes, 1348 + // supersonic N-wave, retrigger cadence) live in audio.c's gun_presets[]. 1349 + // This function just chooses between a finite one-shot and an infinite- 1350 + // duration sustained voice, and pushes the right release bookkeeping 1351 + // into `holdVoices` so releasePercussionHold can tear it down. 1352 + function playWar(sound, letter, volume = 1.0, pan = 0, pitchFactor = 1.0, phase = "both", holdVoices = null) { 1353 + if (!sound?.synth) return; 1354 + const preset = WAR_NAMES[letter]; 1355 + if (!preset) return; 1356 + const v = Math.max(0.1, Math.min(2.2, volume)); 1357 + // pitchFactor maps to the DWG's pressure scale rather than a pitch — 1358 + // harder strikes → more combustion energy. Clamp to sane range. 1359 + const pressure = Math.max(0.4, Math.min(1.8, pitchFactor)); 1360 + const fireDown = phase !== "up"; 1361 + if (!fireDown) return; 1362 + const isLive = phase === "down" && Array.isArray(holdVoices); 1363 + const sustained = !!WAR_SUSTAIN[letter]; 1364 + 1365 + if (sustained && isLive) { 1366 + // Held weapon — infinite-duration voice killed on release. 1367 + const handle = sound.synth({ 1368 + type: `gun-${preset}`, 1369 + tone: pressure, // encoded as pressure multiplier (see js-bindings.c) 1370 + duration: Infinity, 1371 + volume: v, 1372 + attack: 0, 1373 + decay: 0, 1374 + pan, 1375 + }); 1376 + if (handle) { 1377 + holdVoices.push({ 1378 + handle, 1379 + releaseFade: WAR_RELEASE[letter] ?? 0.2, 1380 + tailSeconds: WAR_RELEASE[letter] ?? 0.5, 1381 + }); 1382 + } 1383 + return; 1384 + } 1385 + 1386 + // One-shot weapon — finite-duration voice. The native DWG envelope 1387 + // generates the bang; `duration` just lets body-mode ring continue 1388 + // for the weapon's characteristic tail before auto-kill. 1389 + const duration = WAR_DURATION[letter] ?? 0.3; 1390 + const handle = sound.synth({ 1391 + type: `gun-${preset}`, 1392 + tone: pressure, 1393 + duration, 1394 + volume: v, 1395 + attack: 0, 1396 + decay: Math.max(0.02, duration * 0.7), 1397 + pan, 1398 + }); 1399 + if (isLive && handle) { 1400 + // Track the voice so sim() can adjust pan/volume during its tail, 1401 + // mirroring how addHit does for perc kit. 1402 + holdVoices.push({ 1403 + handle, 1404 + ignoreRelease: true, 1405 + releaseFade: 0, 1406 + tailSeconds: duration, 1407 + }); 1408 + } 1409 + } 1410 + 1203 1411 function flashPercussionNotice(text) { 1204 1412 percussionNotice = { text, until: frame + 120 }; // ~2 seconds at 60fps 1205 1413 } ··· 1713 1921 } 1714 1922 return; 1715 1923 } 1716 - // PgUp: toggle percussion layout on the LEFT octave grid 1717 - if (key === "pageup") { 1718 - percussionLeft = !percussionLeft; 1719 - const label = percussionLeft ? "percussion left on" : "percussion left off"; 1720 - flashPercussionNotice(percussionLeft ? "◀ DRUMS ON" : "◀ drums off"); 1721 - sound?.speak?.(label); 1722 - // Rising / falling two-tone feedback 1723 - sound?.synth?.({ type: "triangle", tone: percussionLeft ? 440 : 660, duration: 0.08, volume: 0.18, attack: 0.002, decay: 0.07, pan: -0.6 }); 1724 - setTimeout(() => sound?.synth?.({ 1725 - type: "triangle", tone: percussionLeft ? 660 : 440, 1726 - duration: 0.1, volume: 0.18, attack: 0.002, decay: 0.09, pan: -0.6, 1727 - }), 70); 1728 - if (percussionLeft) playPercussion(sound, "c", 1.6, -0.4); 1729 - return; 1730 - } 1731 - // PgDn: toggle percussion layout on the RIGHT octave grid 1732 - if (key === "pagedown") { 1733 - percussionRight = !percussionRight; 1734 - const label = percussionRight ? "percussion right on" : "percussion right off"; 1735 - flashPercussionNotice(percussionRight ? "DRUMS ON ▶" : "drums off ▶"); 1924 + // PgUp: cycle LEFT grid kit (off → perc → war → off). 1925 + // PgDn: same for RIGHT grid. Each cycle fires a sample of the new 1926 + // kit ("c" = kick for perc, pistol for war) so the sound confirms 1927 + // which mode you just entered. 1928 + if (key === "pageup" || key === "pagedown") { 1929 + const side = key === "pageup" ? "left" : "right"; 1930 + const arrow = side === "left" ? "◀" : "▶"; 1931 + const pan = side === "left" ? -0.4 : 0.4; 1932 + const feedbackPan = side === "left" ? -0.6 : 0.6; 1933 + const next = cycleKit(side); 1934 + const label = `${side} ${next === "off" ? "notes" : next}`; 1935 + const banner = { 1936 + off: side === "left" ? `${arrow} notes` : `notes ${arrow}`, 1937 + perc: side === "left" ? `${arrow} DRUMS` : `DRUMS ${arrow}`, 1938 + war: side === "left" ? `${arrow} WAR` : `WAR ${arrow}`, 1939 + }[next]; 1940 + flashPercussionNotice(banner); 1736 1941 sound?.speak?.(label); 1737 - sound?.synth?.({ type: "triangle", tone: percussionRight ? 440 : 660, duration: 0.08, volume: 0.18, attack: 0.002, decay: 0.07, pan: 0.6 }); 1942 + // Two-tone UI feedback — rising when enabling, falling when off. 1943 + const isOn = next !== "off"; 1944 + sound?.synth?.({ type: "triangle", tone: isOn ? 440 : 660, duration: 0.08, volume: 0.18, attack: 0.002, decay: 0.07, pan: feedbackPan }); 1738 1945 setTimeout(() => sound?.synth?.({ 1739 - type: "triangle", tone: percussionRight ? 660 : 440, 1740 - duration: 0.1, volume: 0.18, attack: 0.002, decay: 0.09, pan: 0.6, 1946 + type: "triangle", tone: isOn ? 660 : 440, 1947 + duration: 0.10, volume: 0.18, attack: 0.002, decay: 0.09, pan: feedbackPan, 1741 1948 }), 70); 1742 - if (percussionRight) playPercussion(sound, "c", 1.6, 0.4); 1949 + // Preview the kit's signature sound on "c" so the user hears what 1950 + // they just switched to (kick for perc, pistol for war). 1951 + if (next === "perc") playPercussion(sound, "c", 1.6, pan); 1952 + else if (next === "war") playWar(sound, "c", 1.2, pan); 1743 1953 return; 1744 1954 } 1745 1955 // F12 (star key): recital mode — hide UI, show only colored backdrops ··· 3453 3663 globalThis.__gridInfo = { leftX, rightX, gridTop, btnW, btnH, gap }; 3454 3664 3455 3665 function drawGrid(grid, startX, octOffset, side) { 3456 - const isPerc = (side === "left" && percussionLeft) || (side === "right" && percussionRight); 3666 + // Active kit for this side: "off" = melodic notes, "perc" = drums, 3667 + // "war" = physically-modeled weapons. Colors, labels and notation 3668 + // glyphs all come from the kit tables. 3669 + const kit = side === "left" ? kitLeft : kitRight; 3670 + const isKit = kit !== "off"; 3671 + const kitNames = kitNamesFor(kit); 3672 + const kitLabels = kitLabelsFor(kit); 3673 + const kitColors = kitColorsFor(kit); 3674 + const kitNotation = kitNotationFor(kit); 3457 3675 for (let r = 0; r < rows; r++) { 3458 3676 for (let c = 0; c < cols; c++) { 3459 3677 const noteName = grid[r][c]; ··· 3466 3684 const isActive = key && sounds[key] !== undefined; 3467 3685 const trailInfo = key && trail[key]; 3468 3686 const sharp = letter.includes("#"); 3469 - const drumActive = isPerc && !!PERCUSSION_NAMES[letter]; 3470 - const nc = drumActive ? (PERCUSSION_COLORS[letter] || noteColor(letter)) : noteColor(letter); 3687 + const drumActive = isKit && kitNames && !!kitNames[letter]; 3688 + const nc = drumActive ? (kitColors[letter] || noteColor(letter)) : noteColor(letter); 3471 3689 3472 3690 const x = startX + c * (btnW + gap); 3473 3691 const y = gridTop + r * (btnH + gap); ··· 3532 3750 // positions jitter by ±1 px and noise dots are re-scattered, so the 3533 3751 // viewer sees the randomness mechanism animating even when idle. 3534 3752 if (drumActive) { 3535 - const sig = PERCUSSION_NOTATION[letter]; 3753 + const sig = kitNotation && kitNotation[letter]; 3536 3754 if (sig) { 3537 3755 // Usable plot area inside the pad (leave room for label at top 3538 3756 // and bottom-label area). ··· 3607 3825 if (drumActive && btnH > 12) { 3608 3826 if (isActive) ink(255, 255, 255, 210); 3609 3827 else { const sl = dark ? (sharp ? 100 : 150) : (sharp ? 120 : 80); ink(sl, sl, sl); } 3610 - const fullName = PERCUSSION_NAMES[letter] || ""; 3611 - const shortLabel = PERCUSSION_LABELS[letter] || (letter + noteOctave); 3828 + const fullName = (kitNames && kitNames[letter]) || ""; 3829 + const shortLabel = (kitLabels && kitLabels[letter]) || (letter + noteOctave); 3612 3830 const fullPx = fullName.length * 6 + 4; 3613 3831 const bottomLabel = (fullName && btnW >= fullPx) ? fullName : shortLabel; 3614 3832 write(bottomLabel, { x: x + 2, y: y + btnH - 12, size: 1, font: "font_1" }); ··· 4399 4617 ["shift", "quick mode"], 4400 4618 ]], 4401 4619 ["DRUMS", [255, 140, 90], [ 4402 - ["pgup/pgdn", "drum kit L / R"], 4620 + ["pgup/pgdn", "kit L / R: off\u2192drums\u2192war"], 4403 4621 ["space", "reverse loop pedal"], 4404 4622 ]], 4405 4623 ["WAVE", [220, 140, 255], [
+358 -3
fedac/native/src/audio.c
··· 240 240 return 0.3 * into_bore; 241 241 } 242 242 243 + // ============================================================ 244 + // Gun DWG (digital waveguide barrel model) 245 + // ============================================================ 246 + // 247 + // Signal flow (per voice, per sample): 248 + // 249 + // excitation ──► (+) ──► boreDelay ─┬──► muzzleHPF ──► out 250 + // ▲ │ 251 + // │ breech_reflect │ 252 + // │ ▼ 253 + // └─── boreLP ◄──── (−1 open-end refl) 254 + // 255 + // excitation ──► 3× bodyModes ──► +out 256 + // (parallel) 257 + // 258 + // The bore delay line (length = bore_length_s × SR) is a closed-breech 259 + // (+), open-muzzle (−) acoustic tube — the fundamental cavity resonance 260 + // of the barrel. Bore length sets the "boom" frequency; bore_loss LPF 261 + // damps the loop so it rings briefly rather than sustains like a flute. 262 + // 263 + // The excitation is NOT continuous (unlike the whistle's DC breath); it's 264 + // a short-lived pressure pulse that decays exponentially with rate 265 + // env_decay_mult, modeling combustion gas expansion. Turbulent noise rides 266 + // on top (noise_gain) for the broadband hiss/crack content. 267 + // 268 + // Body modes are 3 parallel resonators (unit-gain pole pair) representing 269 + // the steel barrel/receiver ringing from the stress wave. These give each 270 + // weapon its metallic "character" — sniper rings lower & longer, SMG 271 + // rings brighter & shorter. 272 + // 273 + // N-wave (supersonic crack) and two-click (cock/reload) are modeled as 274 + // delayed secondary triggers via gun_secondary_trig / gun_secondary_amp. 275 + // 276 + // Ricochet uses the pitch-sweep machinery (gun_pitch_mult) which stretches 277 + // the bore delay during release — creates a doppler-style pitch drop. 278 + // 279 + // Bore buffer is SHARED with the whistle (whistle_bore_buf) since a voice 280 + // is only ever one wave type. 2048 samples at 192kHz covers down to ~94 Hz. 281 + 282 + typedef struct { 283 + double bore_length_s; // seconds (= 2L/c for half-wave open tube) 284 + double bore_loss; // 1-pole LPF alpha in loop (higher = brighter, lower = duller) 285 + double breech_reflect; // 0..1 (closed end, 1=perfect reflection) 286 + double pressure; // excitation peak level 287 + double env_rate; // excitation decay rate (1/sec): ~3000 = tight, ~300 = slow 288 + double noise_gain; // turbulent gas noise amount 289 + double body_freq[3]; // body mode freqs (Hz) 290 + double body_q[3]; // body mode Q (higher = longer ring) 291 + double body_amp[3]; // body mode mix amplitudes 292 + double radiation; // muzzle HPF 1-zero coefficient (higher = brighter radiation) 293 + double secondary_delay_s; // 0 = no 2nd trigger; else seconds until N-wave/2nd click 294 + double secondary_amp; // amplitude of 2nd trigger relative to primary 295 + int sustain_fire; // 1 = retrigger while held (LMG) 296 + double retrig_period_s; // seconds between retrigs (60/RPM) 297 + } GunPresetParams; 298 + 299 + // Per-weapon DWG parameters. Tuned from published barrel dimensions + 300 + // forensic acoustic characterizations; tweak by ear after testing. 301 + // c (speed of sound) ≈ 340 m/s → bore_length_s = 2·L/c for a half-wave 302 + // open tube. Example: pistol L=0.1m → 0.000588s. 303 + static const GunPresetParams gun_presets[GUN_PRESET_COUNT] = { 304 + // --- GUN_PISTOL (9mm, L≈100mm) — sharp crack, little sub, quick tail 305 + { .bore_length_s = 0.000588, .bore_loss = 0.55, .breech_reflect = 0.92, 306 + .pressure = 1.2, .env_rate = 3000.0, .noise_gain = 0.6, 307 + .body_freq = {1500, 4000, 8500}, .body_q = {30, 25, 20}, 308 + .body_amp = {0.30, 0.20, 0.15}, .radiation = 0.985, 309 + .secondary_delay_s = 0, .secondary_amp = 0, 310 + .sustain_fire = 0, .retrig_period_s = 0 }, 311 + // --- GUN_RIFLE (AR-15, L≈400mm) — crack + body ring + supersonic N-wave 312 + { .bore_length_s = 0.00235, .bore_loss = 0.50, .breech_reflect = 0.95, 313 + .pressure = 1.5, .env_rate = 2500.0, .noise_gain = 0.5, 314 + .body_freq = {800, 2400, 6000}, .body_q = {35, 30, 22}, 315 + .body_amp = {0.35, 0.25, 0.15}, .radiation = 0.988, 316 + .secondary_delay_s = 0.0008, .secondary_amp = 0.55, 317 + .sustain_fire = 0, .retrig_period_s = 0 }, 318 + // --- GUN_SHOTGUN (12ga, L≈660mm, wide bore) — low boom + scatter 319 + { .bore_length_s = 0.00388, .bore_loss = 0.40, .breech_reflect = 0.88, 320 + .pressure = 1.8, .env_rate = 1800.0, .noise_gain = 0.9, 321 + .body_freq = {400, 1200, 3500}, .body_q = {20, 18, 15}, 322 + .body_amp = {0.40, 0.25, 0.15}, .radiation = 0.965, 323 + .secondary_delay_s = 0, .secondary_amp = 0, 324 + .sustain_fire = 0, .retrig_period_s = 0 }, 325 + // --- GUN_SMG (MP5, L≈225mm) — fast mid-crack 326 + { .bore_length_s = 0.00132, .bore_loss = 0.58, .breech_reflect = 0.92, 327 + .pressure = 1.0, .env_rate = 3500.0, .noise_gain = 0.5, 328 + .body_freq = {1200, 3500, 7500}, .body_q = {32, 28, 20}, 329 + .body_amp = {0.30, 0.20, 0.13}, .radiation = 0.978, 330 + .secondary_delay_s = 0, .secondary_amp = 0, 331 + .sustain_fire = 0, .retrig_period_s = 0 }, 332 + // --- GUN_SUPPRESSED (pistol with can) — muffled "pfft" 333 + // Heavy bore loss simulates absorptive suppressor baffles; low radiation 334 + // HPF keeps the bright crack from escaping. 335 + { .bore_length_s = 0.00100, .bore_loss = 0.85, .breech_reflect = 0.80, 336 + .pressure = 0.5, .env_rate = 1500.0, .noise_gain = 1.0, 337 + .body_freq = {600, 1500, 3000}, .body_q = {10, 8, 6}, 338 + .body_amp = {0.15, 0.10, 0.05}, .radiation = 0.85, 339 + .secondary_delay_s = 0, .secondary_amp = 0, 340 + .sustain_fire = 0, .retrig_period_s = 0 }, 341 + // --- GUN_LMG (M60, L≈560mm, auto-fire) — held = rapid fire ~600 RPM 342 + { .bore_length_s = 0.00329, .bore_loss = 0.48, .breech_reflect = 0.94, 343 + .pressure = 1.4, .env_rate = 2200.0, .noise_gain = 0.55, 344 + .body_freq = {600, 1800, 4500}, .body_q = {30, 25, 20}, 345 + .body_amp = {0.35, 0.25, 0.15}, .radiation = 0.982, 346 + .secondary_delay_s = 0.0006, .secondary_amp = 0.45, 347 + .sustain_fire = 1, .retrig_period_s = 0.1 }, // 600 RPM 348 + // --- GUN_SNIPER (.50, L≈740mm) — massive pressure, long tail, strong N-wave 349 + { .bore_length_s = 0.00435, .bore_loss = 0.35, .breech_reflect = 0.97, 350 + .pressure = 2.0, .env_rate = 1500.0, .noise_gain = 0.7, 351 + .body_freq = {350, 950, 2800}, .body_q = {50, 40, 30}, 352 + .body_amp = {0.50, 0.30, 0.15}, .radiation = 0.992, 353 + .secondary_delay_s = 0.0012, .secondary_amp = 0.70, 354 + .sustain_fire = 0, .retrig_period_s = 0 }, 355 + // --- GUN_GRENADE — not a barrel; wide cavity with slow pressure release 356 + { .bore_length_s = 0.01000, .bore_loss = 0.25, .breech_reflect = 0.60, 357 + .pressure = 1.6, .env_rate = 400.0, .noise_gain = 1.5, 358 + .body_freq = {80, 250, 1200}, .body_q = {15, 12, 10}, 359 + .body_amp = {0.60, 0.35, 0.15}, .radiation = 0.70, 360 + .secondary_delay_s = 0, .secondary_amp = 0, 361 + .sustain_fire = 0, .retrig_period_s = 0 }, 362 + // --- GUN_RPG — long motor burn + delayed boom 363 + { .bore_length_s = 0.00300, .bore_loss = 0.30, .breech_reflect = 0.50, 364 + .pressure = 1.2, .env_rate = 150.0, .noise_gain = 2.5, 365 + .body_freq = {200, 600, 2000}, .body_q = {8, 6, 5}, 366 + .body_amp = {0.40, 0.30, 0.20}, .radiation = 0.60, 367 + .secondary_delay_s = 0.25, .secondary_amp = 1.5, // delayed explosion 368 + .sustain_fire = 0, .retrig_period_s = 0 }, 369 + // --- GUN_RELOAD — magazine clack (short metallic transient) 370 + { .bore_length_s = 0.00010, .bore_loss = 0.70, .breech_reflect = 0.90, 371 + .pressure = 0.6, .env_rate = 4000.0, .noise_gain = 0.3, 372 + .body_freq = {2200, 4500, 8000}, .body_q = {25, 20, 15}, 373 + .body_amp = {0.40, 0.30, 0.15}, .radiation = 0.92, 374 + .secondary_delay_s = 0.08, .secondary_amp = 0.65, // insert + click 375 + .sustain_fire = 0, .retrig_period_s = 0 }, 376 + // --- GUN_COCK — bolt-action click-clack (two-click) 377 + { .bore_length_s = 0.00015, .bore_loss = 0.65, .breech_reflect = 0.88, 378 + .pressure = 0.7, .env_rate = 3500.0, .noise_gain = 0.35, 379 + .body_freq = {1800, 4200, 7500}, .body_q = {20, 18, 14}, 380 + .body_amp = {0.45, 0.25, 0.15}, .radiation = 0.92, 381 + .secondary_delay_s = 0.055, .secondary_amp = 0.80, // rack → lock 382 + .sustain_fire = 0, .retrig_period_s = 0 }, 383 + // --- GUN_RICOCHET — metallic ping, pitch-drops on release (doppler) 384 + { .bore_length_s = 0.00040, .bore_loss = 0.15, .breech_reflect = 0.90, 385 + .pressure = 0.8, .env_rate = 600.0, .noise_gain = 0.3, 386 + .body_freq = {3000, 5500, 9000}, .body_q = {60, 50, 40}, 387 + .body_amp = {0.40, 0.25, 0.15}, .radiation = 0.975, 388 + .secondary_delay_s = 0, .secondary_amp = 0, 389 + .sustain_fire = 0, .retrig_period_s = 0 }, 390 + }; 391 + 392 + // Initialize a voice's gun state from a preset. Called from audio_synth_gun. 393 + static void gun_init_voice(ACVoice *v, GunPreset preset, double sr) { 394 + if (preset < 0 || preset >= GUN_PRESET_COUNT) preset = GUN_PISTOL; 395 + const GunPresetParams *p = &gun_presets[preset]; 396 + 397 + v->gun_preset = (int)preset; 398 + v->gun_bore_delay = p->bore_length_s * sr; 399 + if (v->gun_bore_delay < 4.0) v->gun_bore_delay = 4.0; 400 + if (v->gun_bore_delay > 2040.0) v->gun_bore_delay = 2040.0; 401 + v->gun_bore_loss = p->bore_loss; 402 + v->gun_bore_lp = 0.0; 403 + v->gun_breech_reflect = p->breech_reflect; 404 + v->gun_pressure = p->pressure; 405 + v->gun_pressure_env = 1.0; // fire the excitation on note-on 406 + // Per-sample exponential decay: y *= exp(-env_rate / sr) each sample. 407 + v->gun_env_decay_mult = exp(-p->env_rate / sr); 408 + v->gun_noise_gain = p->noise_gain; 409 + v->gun_radiation_a = p->radiation; 410 + v->gun_rad_prev = 0.0; 411 + v->gun_secondary_trig = p->secondary_delay_s > 0 ? p->secondary_delay_s * sr : 0.0; 412 + v->gun_secondary_amp = p->secondary_amp; 413 + v->gun_sustain_fire = p->sustain_fire; 414 + v->gun_retrig_timer = 0.0; 415 + v->gun_retrig_period = p->retrig_period_s; 416 + 417 + // Precompute body mode biquad coefficients (pole pair resonator): 418 + // y[n] = x[n] + a1·y[n-1] − a2·y[n-2] 419 + // a1 = 2·r·cos(w), a2 = r² 420 + // r = exp(-π·f / (Q·sr)), w = 2π·f/sr 421 + // The closer r is to 1, the longer the ring (higher Q). 422 + for (int i = 0; i < 3; i++) { 423 + double f = p->body_freq[i]; 424 + double q = p->body_q[i]; 425 + if (q < 1.0) q = 1.0; 426 + double r = exp(-M_PI * f / (q * sr)); 427 + double w = 2.0 * M_PI * f / sr; 428 + v->gun_body_a1[i] = 2.0 * r * cos(w); 429 + v->gun_body_a2[i] = r * r; 430 + v->gun_body_amp[i] = p->body_amp[i]; 431 + v->gun_body_y1[i] = 0.0; 432 + v->gun_body_y2[i] = 0.0; 433 + } 434 + 435 + // Clear the shared bore buffer (reused from whistle slot). 436 + memset(v->whistle_bore_buf, 0, sizeof(v->whistle_bore_buf)); 437 + v->whistle_bore_w = 0; 438 + 439 + // Pitch sweep: nominal 1.0 at trigger. Ricochet sets target=2.5 on 440 + // release so bore stretches → pitch drops. Others stay at 1.0. 441 + v->gun_pitch_mult = 1.0; 442 + v->gun_pitch_target = 1.0; 443 + // Slow slew for audible doppler glide (~300ms sweep at 192kHz). 444 + v->gun_pitch_slew = 1.0 / (0.3 * sr); 445 + } 446 + 447 + // Called when a gun voice enters VOICE_KILLING — sets up the release- 448 + // time behaviors (ricochet pitch drop). 449 + static inline void gun_on_release(ACVoice *v) { 450 + if (v->type != WAVE_GUN) return; 451 + if (v->gun_preset == GUN_RICOCHET) { 452 + // Stretch the bore on release → pitch drops (doppler-style). 453 + v->gun_pitch_target = 2.8; 454 + } 455 + } 456 + 457 + static inline double generate_gun_sample(ACVoice *v, double sample_rate) { 458 + // === 1. Excitation source === 459 + // Primary pressure pulse: decays exponentially. LMG retriggers it 460 + // on cadence while held; two-click weapons fire a secondary shot 461 + // after gun_secondary_trig samples have elapsed. 462 + double excite = 0.0; 463 + if (v->gun_pressure_env > 0.00002) { 464 + uint32_t n = xorshift32(&v->noise_seed); 465 + double white = ((double)n / (double)UINT32_MAX) * 2.0 - 1.0; 466 + excite = v->gun_pressure_env * v->gun_pressure 467 + * (1.0 + v->gun_noise_gain * white); 468 + v->gun_pressure_env *= v->gun_env_decay_mult; 469 + } 470 + 471 + // Secondary trigger (N-wave for rifles, 2nd click for cock/reload, 472 + // delayed explosion for RPG). Fires once when countdown reaches 0. 473 + if (v->gun_secondary_trig > 0.0) { 474 + v->gun_secondary_trig -= 1.0; 475 + if (v->gun_secondary_trig <= 0.0) { 476 + v->gun_pressure_env = v->gun_secondary_amp; 477 + // Prevent refire: zero the countdown. 478 + v->gun_secondary_trig = 0.0; 479 + // If this gun has sustain fire, the secondary doesn't count 480 + // against the retrig cycle — it's a one-shot accent. 481 + } 482 + } 483 + 484 + // LMG sustain fire — retrigger the excitation envelope while held. 485 + // Only runs for infinite-duration voices (still VOICE_ACTIVE, not 486 + // killing) so releasing the key stops the loop. 487 + if (v->gun_sustain_fire && v->state == VOICE_ACTIVE 488 + && isinf(v->duration) && v->gun_retrig_period > 0.0) { 489 + v->gun_retrig_timer += 1.0 / sample_rate; 490 + if (v->gun_retrig_timer >= v->gun_retrig_period) { 491 + v->gun_retrig_timer -= v->gun_retrig_period; 492 + v->gun_pressure_env = 1.0; 493 + // Small pressure jitter to avoid robotic sameness. 494 + double j = ((double)xorshift32(&v->noise_seed) / (double)UINT32_MAX); 495 + v->gun_pressure_env *= 0.82 + j * 0.32; // ±18% variation 496 + } 497 + } 498 + 499 + // === 2. Pitch sweep (ricochet release doppler) === 500 + // Exp-approach toward target; coefficient tuned so full sweep 501 + // (1.0 → 2.8) takes ~250ms at 192kHz. 502 + if (v->gun_pitch_mult != v->gun_pitch_target) { 503 + v->gun_pitch_mult += (v->gun_pitch_target - v->gun_pitch_mult) * 0.00012; 504 + } 505 + double bore_delay = v->gun_bore_delay * v->gun_pitch_mult; 506 + if (bore_delay < 4.0) bore_delay = 4.0; 507 + if (bore_delay > 2040.0) bore_delay = 2040.0; 508 + 509 + // === 3. Bore delay loop (closed breech / open muzzle) === 510 + const int BORE_N = 2048; 511 + double bore_out = whistle_frac_read(v->whistle_bore_buf, BORE_N, 512 + v->whistle_bore_w, bore_delay); 513 + // Open-end reflection: inverting + loss via 1-pole LPF (higher 514 + // frequencies lose more energy per round-trip). bore_loss is the 515 + // LPF mix toward the new input; (1-bore_loss) keeps the old state. 516 + v->gun_bore_lp = v->gun_bore_loss * (-bore_out) 517 + + (1.0 - v->gun_bore_loss) * v->gun_bore_lp; 518 + double refl = v->gun_bore_lp; 519 + 520 + // Closed breech (+reflection) + new excitation enters the tube. 521 + double into_bore = excite + refl * v->gun_breech_reflect; 522 + v->whistle_bore_buf[v->whistle_bore_w] = (float)into_bore; 523 + v->whistle_bore_w = (v->whistle_bore_w + 1) % BORE_N; 524 + 525 + // === 4. Muzzle radiation === 526 + // Open end radiates highs preferentially — model as a 1-zero HPF 527 + // (differentiator) on the bore input. radiation_a near 1 = bright, 528 + // near 0 = dull. 529 + double radiated = into_bore - v->gun_radiation_a * v->gun_rad_prev; 530 + v->gun_rad_prev = into_bore; 531 + 532 + // === 5. Body modes (parallel resonators on excitation) === 533 + // Each pole-pair biquad is excited by the same combustion pulse, 534 + // giving the weapon its metallic ring without affecting the bore 535 + // tone. We drive with the primary `excite` so modes respond to 536 + // every retrigger too. 537 + double body = 0.0; 538 + for (int i = 0; i < 3; i++) { 539 + double y = excite 540 + + v->gun_body_a1[i] * v->gun_body_y1[i] 541 + - v->gun_body_a2[i] * v->gun_body_y2[i]; 542 + v->gun_body_y2[i] = v->gun_body_y1[i]; 543 + v->gun_body_y1[i] = y; 544 + body += y * v->gun_body_amp[i]; 545 + } 546 + 547 + // === 6. Mix === 548 + // Radiated muzzle + metallic body. Scale so peaks stay near 1.0 549 + // before the per-voice volume × envelope × fade multiplication. 550 + double out = radiated * 0.55 + body * 0.45; 551 + 552 + // Apply ADSR envelope (attack + decay). For guns, attack should 553 + // usually be 0 so the excitation is instant; decay extends the 554 + // audible tail beyond the DWG's natural ring. 555 + double env = compute_envelope(v); 556 + return out * env; 557 + } 558 + 243 559 static inline double compute_fade(ACVoice *v) { 244 560 if (v->state != VOICE_KILLING) return 1.0; 245 561 if (v->fade_duration <= 0.0) return 0.0; ··· 282 598 case WAVE_WHISTLE: 283 599 s = generate_whistle_sample(v, sample_rate); 284 600 break; 601 + case WAVE_GUN: 602 + s = generate_gun_sample(v, sample_rate); 603 + break; 285 604 default: 286 605 s = 0.0; 287 606 } ··· 291 610 v->frequency += (v->target_frequency - v->frequency) * 0.0003; // ~5ms at 192kHz 292 611 } 293 612 294 - // Advance phase for basic oscillators; whistle uses its own resonators. 295 - if (v->type != WAVE_WHISTLE) { 613 + // Advance phase for basic oscillators; whistle/gun use their own DWG state. 614 + if (v->type != WAVE_WHISTLE && v->type != WAVE_GUN) { 296 615 v->phase += v->frequency / sample_rate; 297 616 if (v->phase >= 1.0) v->phase -= 1.0; 298 617 } ··· 1335 1654 v->id = ++audio->next_id; 1336 1655 v->started_at = audio->time; 1337 1656 1338 - if (type == WAVE_NOISE || type == WAVE_WHISTLE) { 1657 + if (type == WAVE_NOISE || type == WAVE_WHISTLE || type == WAVE_GUN) { 1339 1658 v->noise_seed = (uint32_t)(audio->next_id * 2654435761u); 1340 1659 } 1341 1660 if (type == WAVE_NOISE) { 1342 1661 setup_noise_filter(v, (double)(audio->actual_rate ? audio->actual_rate : AUDIO_SAMPLE_RATE)); 1662 + } else if (type == WAVE_GUN) { 1663 + // Caller (audio_synth_gun) sets the preset via gun_init_voice 1664 + // after this base init runs. 1343 1665 } else if (type == WAVE_WHISTLE) { 1344 1666 // Clear the waveguide state — bore + jet delay buffers and the 1345 1667 // loop filter / DC blocker. Without this, leftover state from a ··· 1367 1689 audio->voices[i].state = VOICE_KILLING; 1368 1690 audio->voices[i].fade_duration = fade > 0 ? fade : 0.025; 1369 1691 audio->voices[i].fade_elapsed = 0.0; 1692 + // Gun-specific release behaviors (e.g. ricochet pitch drop). 1693 + if (audio->voices[i].type == WAVE_GUN) { 1694 + gun_on_release(&audio->voices[i]); 1695 + } 1370 1696 break; 1371 1697 } 1372 1698 } 1373 1699 pthread_mutex_unlock(&audio->lock); 1700 + } 1701 + 1702 + uint64_t audio_synth_gun(ACAudio *audio, GunPreset preset, double duration, 1703 + double volume, double attack, double decay, 1704 + double pan, double pressure_scale) { 1705 + if (!audio) return 0; 1706 + // Delegate base voice setup (slot alloc, envelope fields, noise seed). 1707 + // Frequency is unused for guns — the DWG cavity resonance comes from 1708 + // the preset's bore_length, not v->frequency. We pass 110 to keep 1709 + // the smoothing code happy. 1710 + uint64_t id = audio_synth(audio, WAVE_GUN, 110.0, duration, volume, 1711 + attack, decay, pan); 1712 + if (!id) return 0; 1713 + 1714 + pthread_mutex_lock(&audio->lock); 1715 + ACVoice *v = NULL; 1716 + for (int i = 0; i < AUDIO_MAX_VOICES; i++) { 1717 + if (audio->voices[i].id == id) { v = &audio->voices[i]; break; } 1718 + } 1719 + if (v) { 1720 + double sr = (double)(audio->actual_rate ? audio->actual_rate 1721 + : AUDIO_SAMPLE_RATE); 1722 + gun_init_voice(v, preset, sr); 1723 + if (pressure_scale > 0.0 && pressure_scale != 1.0) { 1724 + v->gun_pressure *= pressure_scale; 1725 + } 1726 + } 1727 + pthread_mutex_unlock(&audio->lock); 1728 + return id; 1374 1729 } 1375 1730 1376 1731 void audio_update(ACAudio *audio, uint64_t id, double freq,
+70 -1
fedac/native/src/audio.h
··· 29 29 WAVE_SAWTOOTH, 30 30 WAVE_SQUARE, 31 31 WAVE_NOISE, 32 - WAVE_WHISTLE 32 + WAVE_WHISTLE, 33 + WAVE_GUN 33 34 } WaveType; 35 + 36 + // Gun voice presets — each maps to a physically-modeled weapon. 37 + // The barrel is a closed-breech / open-muzzle acoustic tube (DWG), 38 + // excited once per note-on by a combustion pressure pulse + turbulent 39 + // noise. Body modes are parallel resonators representing the steel 40 + // frame ringing. Bore length sets the fundamental cavity resonance; 41 + // longer barrels → lower "boom" frequency. See gun_presets[] in audio.c 42 + // for the per-weapon parameters. 43 + typedef enum { 44 + GUN_PISTOL = 0, // 9mm — short barrel, bright crack 45 + GUN_RIFLE, // AR/AK — medium barrel + supersonic N-wave 46 + GUN_SHOTGUN, // 12ga — wide bore, heavy low-end 47 + GUN_SMG, // MP5 — short barrel, fast rattle 48 + GUN_SUPPRESSED, // silenced pistol — muffled "pfft" 49 + GUN_LMG, // M60 auto-fire — retriggers while held 50 + GUN_SNIPER, // .50 cal — huge pressure, long tail 51 + GUN_GRENADE, // explosion — low cavity, slow release 52 + GUN_RPG, // rocket — long burn, delayed boom 53 + GUN_RELOAD, // magazine clack — metallic click 54 + GUN_COCK, // bolt cock — two-click (primary + delayed) 55 + GUN_RICOCHET, // metallic ping — pitch-drops on release 56 + GUN_PRESET_COUNT 57 + } GunPreset; 34 58 35 59 typedef struct { 36 60 VoiceState state; ··· 69 93 // Jet delay line — shorter, models embouchure travel time (~0.32×bore). 70 94 float whistle_jet_buf[512]; 71 95 int whistle_jet_w; 96 + // === Gun DWG state (see generate_gun_sample) === 97 + // Most of these are copied from the preset on note-on; mutable ones 98 + // (pressure_env, body_y1/y2, bore_lp, rad_prev) evolve each sample. 99 + // The bore delay buffer is shared with `whistle_bore_buf` since a 100 + // voice can only be one wave type at a time. 101 + int gun_preset; // GunPreset index (for debug) 102 + double gun_bore_delay; // samples (= bore_length_s * sr) 103 + double gun_bore_loss; // 1-pole LPF alpha in bore loop 104 + double gun_bore_lp; // LPF state 105 + double gun_breech_reflect; // closed-breech reflection gain (0..1) 106 + double gun_pressure; // excitation peak (weapon power) 107 + double gun_pressure_env; // live excitation envelope 0..1 108 + double gun_env_decay_mult; // per-sample decay multiplier (exp) 109 + double gun_noise_gain; // turbulent gas noise modulation depth 110 + double gun_radiation_a; // muzzle HPF 1-zero coefficient (0..1) 111 + double gun_rad_prev; // HPF previous input 112 + // Secondary excitation — fires once more at secondary_trig samples 113 + // elapsed. Used for supersonic N-wave (rifle/sniper) and for the 114 + // second click of a cock/reload two-click gesture. 115 + double gun_secondary_trig; // sample countdown (<=0 = fired) 116 + double gun_secondary_amp; // relative amplitude of 2nd shot 117 + // Sustained fire (LMG) — retrigger the excitation on cadence while 118 + // the voice is held (infinite-duration voice, released via kill). 119 + int gun_sustain_fire; 120 + double gun_retrig_timer; // seconds 121 + double gun_retrig_period; // seconds (60 / RPM) 122 + // Body mode resonators — 3 parallel biquads excited by same pulse. 123 + // Coefficients precomputed from preset on note-on. 124 + double gun_body_a1[3], gun_body_a2[3]; 125 + double gun_body_amp[3]; 126 + double gun_body_y1[3], gun_body_y2[3]; 127 + // Pitch sweep (ricochet) — multiplier applied to bore delay each 128 + // sample. When voice enters VOICE_KILLING, target flips so the bore 129 + // stretches → doppler drop during release. 130 + double gun_pitch_mult; // current (smoothed) 131 + double gun_pitch_target; // target (set on trigger / release) 132 + double gun_pitch_slew; // per-sample approach rate 72 133 } ACVoice; 73 134 74 135 typedef struct { ··· 225 286 uint64_t audio_synth(ACAudio *audio, WaveType type, double freq, 226 287 double duration, double volume, double attack, 227 288 double decay, double pan); 289 + 290 + // Add a new gun voice with a specific preset (applies DWG parameters). 291 + // `volume` scales the output, `pan` places it in stereo. `duration` is 292 + // normally INFINITY for held guns (LMG sustain fire) or finite for 293 + // one-shots; the internal DWG excitation handles the bang envelope. 294 + uint64_t audio_synth_gun(ACAudio *audio, GunPreset preset, double duration, 295 + double volume, double attack, double decay, 296 + double pan, double pressure_scale); 228 297 229 298 // Kill a voice with fade 230 299 void audio_kill(ACAudio *audio, uint64_t id, double fade);
+53 -7
fedac/native/src/js-bindings.c
··· 813 813 if (strcmp(type, "whistle") == 0 || strcmp(type, "ocarina") == 0 || 814 814 strcmp(type, "flute") == 0 || strcmp(type, "skullwhistle") == 0 || 815 815 strcmp(type, "skull-whistle") == 0) return WAVE_WHISTLE; 816 + if (strncmp(type, "gun", 3) == 0) return WAVE_GUN; 816 817 // composite → treat as sine for now 817 818 return WAVE_SINE; 819 + } 820 + 821 + // Parse the preset suffix for a gun-* wave type string. 822 + // Accepts both "gun-pistol" and "gunpistol" forms; defaults to pistol. 823 + static GunPreset parse_gun_preset(const char *type) { 824 + if (!type) return GUN_PISTOL; 825 + if (strncmp(type, "gun", 3) != 0) return GUN_PISTOL; 826 + const char *p = type + 3; 827 + if (*p == '-' || *p == '_') p++; 828 + if (!*p) return GUN_PISTOL; 829 + if (strcmp(p, "pistol") == 0) return GUN_PISTOL; 830 + if (strcmp(p, "rifle") == 0) return GUN_RIFLE; 831 + if (strcmp(p, "shotgun") == 0) return GUN_SHOTGUN; 832 + if (strcmp(p, "smg") == 0) return GUN_SMG; 833 + if (strcmp(p, "suppressed") == 0 || strcmp(p, "silenced") == 0) return GUN_SUPPRESSED; 834 + if (strcmp(p, "lmg") == 0 || strcmp(p, "mg") == 0) return GUN_LMG; 835 + if (strcmp(p, "sniper") == 0) return GUN_SNIPER; 836 + if (strcmp(p, "grenade") == 0) return GUN_GRENADE; 837 + if (strcmp(p, "rpg") == 0 || strcmp(p, "rocket") == 0) return GUN_RPG; 838 + if (strcmp(p, "reload") == 0) return GUN_RELOAD; 839 + if (strcmp(p, "cock") == 0 || strcmp(p, "bolt") == 0) return GUN_COCK; 840 + if (strcmp(p, "ricochet") == 0 || strcmp(p, "pew") == 0) return GUN_RICOCHET; 841 + return GUN_PISTOL; 818 842 } 819 843 820 844 // synthObj.kill(fade) — method on synth return object ··· 875 899 876 900 JSValue opts = argv[0]; 877 901 878 - // Parse type 902 + // Parse type. For "gun-*" strings we also extract the preset so the 903 + // DWG synth can branch to audio_synth_gun below. 879 904 WaveType wt = WAVE_SINE; 905 + GunPreset gun_preset = GUN_PISTOL; 906 + int is_gun = 0; 880 907 JSValue type_v = JS_GetPropertyStr(ctx, opts, "type"); 881 908 if (JS_IsString(type_v)) { 882 909 const char *ts = JS_ToCString(ctx, type_v); 883 910 wt = parse_wave_type(ts); 911 + if (wt == WAVE_GUN) { 912 + gun_preset = parse_gun_preset(ts); 913 + is_gun = 1; 914 + } 884 915 JS_FreeCString(ctx, ts); 885 916 } 886 917 JS_FreeValue(ctx, type_v); ··· 927 958 if (JS_IsNumber(v)) JS_ToFloat64(ctx, &pan, v); 928 959 JS_FreeValue(ctx, v); 929 960 930 - // Create voice 931 - uint64_t id = audio_synth(audio, wt, freq, duration, volume, attack, decay, pan); 932 - fprintf(stderr, "[synth] type=%d freq=%.1f vol=%.2f dur=%.1f id=%lu\n", 933 - wt, freq, volume, duration, (unsigned long)id); 934 - ac_log("[synth] type=%d freq=%.1f vol=%.2f dur=%.1f id=%lu\n", 935 - wt, freq, volume, duration, (unsigned long)id); 961 + // Create voice. Guns take a separate path so we can seed per-preset 962 + // DWG parameters (bore length, body modes, etc). 963 + uint64_t id; 964 + if (is_gun) { 965 + // Let `tone` (1.0 default) scale the combustion pressure so pads 966 + // can express accent via velocity. A JS caller passing tone=1.0 967 + // = unity pressure from the preset. 968 + double pressure_scale = freq > 0 && freq < 5.0 ? freq : 1.0; 969 + id = audio_synth_gun(audio, gun_preset, duration, volume, attack, 970 + decay, pan, pressure_scale); 971 + fprintf(stderr, "[synth] gun preset=%d vol=%.2f dur=%.1f id=%lu\n", 972 + gun_preset, volume, duration, (unsigned long)id); 973 + ac_log("[synth] gun preset=%d vol=%.2f dur=%.1f id=%lu\n", 974 + gun_preset, volume, duration, (unsigned long)id); 975 + } else { 976 + id = audio_synth(audio, wt, freq, duration, volume, attack, decay, pan); 977 + fprintf(stderr, "[synth] type=%d freq=%.1f vol=%.2f dur=%.1f id=%lu\n", 978 + wt, freq, volume, duration, (unsigned long)id); 979 + ac_log("[synth] type=%d freq=%.1f vol=%.2f dur=%.1f id=%lu\n", 980 + wt, freq, volume, duration, (unsigned long)id); 981 + } 936 982 937 983 // Return sound object with kill(), update(), startedAt 938 984 JSValue snd = JS_NewObject(ctx);