From 10a81ba47428765070b3c3f708d416984dbc4f2c Mon Sep 17 00:00:00 2001 From: Andras Schmelczer Date: Sat, 16 May 2026 13:46:19 +0100 Subject: [PATCH] v good --- definitions.d.ts | 4 + e2e/app.spec.ts | 290 ++++++++++- public/apple-touch-icon-180x180.png | Bin 1234 -> 1233 bytes public/favicon.ico | Bin 914 -> 892 bytes public/favicon.svg | 12 +- public/maskable-icon-512x512.png | Bin 3481 -> 3445 bytes public/pwa-192x192.png | Bin 1687 -> 1552 bytes public/pwa-512x512.png | Bin 4160 -> 4041 bytes public/pwa-64x64.png | Bin 709 -> 690 bytes scripts/check-unused-exports.mjs | 22 +- src/audio/garden-audio-energy.test.ts | 7 +- src/audio/garden-audio-gesture-state.ts | 385 +++++++++++++++ src/audio/garden-audio-input.ts | 2 + src/audio/garden-audio-types.ts | 3 + src/audio/garden-audio.test.ts | 73 ++- src/audio/garden-audio.ts | 126 ++--- src/audio/generative-piano.test.ts | 26 +- src/audio/generative-piano.ts | 456 ++++++++++++++++-- src/audio/noise-burst-player.ts | 5 +- src/audio/piano-sampler.ts | 109 +---- src/config.ts | 7 +- src/config/color-interactions.ts | 8 +- src/config/types.ts | 7 +- src/game-loop/agent-population.test.ts | 11 +- src/game-loop/agent-population.ts | 64 +-- src/game-loop/frame-performance.test.ts | 47 ++ src/game-loop/frame-performance.ts | 81 +++- src/game-loop/game-loop.ts | 48 +- src/game-loop/pointer-input.test.ts | 8 + src/game-loop/pointer-input.ts | 19 +- src/index.ts | 39 +- src/page/config-pane.ts | 19 +- src/page/menu-hider.ts | 195 +++++--- src/pipelines/agents/agent-pipeline.ts | 4 + src/pipelines/brush/brush-pipeline.ts | 25 +- src/pipelines/render/render.wgsl | 3 +- src/style/_app-shell.scss | 4 + src/style/_control-dock.scss | 42 +- src/style/_loading.scss | 8 +- src/style/_motion.scss | 9 +- .../graphics/get-workgroup-counts.test.ts | 34 ++ src/utils/graphics/get-workgroup-counts.ts | 11 + src/utils/graphics/initialize-context.ts | 2 +- src/utils/graphics/initialize-gpu.test.ts | 253 ++++++++++ tsconfig.json | 4 +- 45 files changed, 1978 insertions(+), 494 deletions(-) create mode 100644 src/audio/garden-audio-gesture-state.ts create mode 100644 src/game-loop/frame-performance.test.ts create mode 100644 src/utils/graphics/get-workgroup-counts.test.ts create mode 100644 src/utils/graphics/initialize-gpu.test.ts diff --git a/definitions.d.ts b/definitions.d.ts index 934370e..c90ad44 100644 --- a/definitions.d.ts +++ b/definitions.d.ts @@ -2,3 +2,7 @@ declare module '*.wgsl?raw' { const content: string; export default content; } + +interface HTMLCanvasElement { + getContext(contextId: 'webgpu'): GPUCanvasContext | null; +} diff --git a/e2e/app.spec.ts b/e2e/app.spec.ts index e42de2f..f0381bf 100644 --- a/e2e/app.spec.ts +++ b/e2e/app.spec.ts @@ -1,12 +1,77 @@ -import { expect, test } from '@playwright/test'; +import { expect, test, type Page } from '@playwright/test'; -test('loads the app shell and WebGPU fallback in Chromium', async ({ page }) => { +type WebGpuFailureMode = 'adapter-null' | 'adapter-rejects' | 'device-rejects'; + +const disableWebGpu = async (page: Page) => { await page.addInitScript(() => { Object.defineProperty(navigator, 'gpu', { configurable: true, value: undefined, }); }); +}; + +const emulateWebGpuFailure = async (page: Page, mode: WebGpuFailureMode) => { + await page.addInitScript((failureMode) => { + const limits = { + maxBufferSize: 256 * 1024 * 1024, + maxComputeWorkgroupsPerDimension: 65_535, + maxStorageBufferBindingSize: 128 * 1024 * 1024, + }; + const adapter = { + features: new Set(), + info: { + architecture: 'test', + description: 'Playwright fake adapter', + device: 'test-device', + isFallbackAdapter: false, + subgroupMaxSize: 0, + subgroupMinSize: 0, + vendor: 'test-vendor', + }, + limits, + requestDevice: async () => { + if (failureMode === 'device-rejects') { + throw new Error('Playwright fake device failure'); + } + + return {}; + }, + }; + + Object.defineProperty(navigator, 'gpu', { + configurable: true, + value: { + getPreferredCanvasFormat: () => 'rgba8unorm', + requestAdapter: async () => { + if (failureMode === 'adapter-null') { + return null; + } + + if (failureMode === 'adapter-rejects') { + throw new Error('Playwright fake adapter failure'); + } + + return adapter; + }, + }, + }); + }, mode); +}; + +const getFirstSwatchColor = (page: Page) => + page + .locator('.color-swatch') + .first() + .evaluate((element) => getComputedStyle(element).backgroundColor); + +const getGardenBackground = (page: Page) => + page.evaluate(() => + document.documentElement.style.getPropertyValue('--garden-background').trim() + ); + +test('loads the app shell and WebGPU fallback in Chromium', async ({ page }) => { + await disableWebGpu(page); await page.goto('/'); @@ -21,3 +86,224 @@ test('loads the app shell and WebGPU fallback in Chromium', async ({ page }) => await page.getByRole('button', { name: 'About' }).click(); await expect(page.getByRole('heading', { name: 'Fleeting Garden' })).toBeVisible(); }); + +test('keeps fallback controls interactive and accessible', async ({ page }) => { + await disableWebGpu(page); + + await page.goto('/'); + await expect(page.locator('body')).not.toHaveClass(/is-loading/); + + const aboutButton = page.getByRole('button', { name: 'About' }); + const aboutPanel = page.locator('#info-panel'); + await expect(aboutButton).toHaveAttribute('aria-expanded', 'false'); + await aboutButton.click(); + await expect(aboutButton).toHaveAttribute('aria-expanded', 'true'); + await expect(aboutPanel).toHaveAttribute('aria-hidden', 'false'); + await expect(aboutPanel).not.toHaveAttribute('inert', ''); + await expect(page.getByRole('heading', { name: 'Fleeting Garden' })).toBeVisible(); + await page.keyboard.press('Escape'); + await expect(aboutButton).toHaveAttribute('aria-expanded', 'false'); + await expect(aboutPanel).toHaveAttribute('aria-hidden', 'true'); + await expect(aboutPanel).toHaveAttribute('inert', ''); + + const settingsButton = page.getByRole('button', { name: 'Show config overlay' }); + await settingsButton.click(); + await expect(page.getByRole('button', { name: 'Hide config overlay' })).toHaveAttribute( + 'aria-expanded', + 'true' + ); + await expect(page.locator('.config-pane')).toBeVisible(); + + const soundButton = page.locator('button.sound'); + await expect(soundButton).toHaveAttribute('aria-pressed', 'false'); + await soundButton.click(); + await expect(soundButton).toHaveAttribute('aria-pressed', 'true'); + await expect(soundButton).toHaveAttribute('aria-label', 'Unmute audio'); + await page.reload(); + await expect(page.locator('body')).not.toHaveClass(/is-loading/); + await expect(page.locator('button.sound')).toHaveAttribute('aria-pressed', 'true'); + + const initialSwatchColor = await getFirstSwatchColor(page); + const initialBackground = await getGardenBackground(page); + await page.getByRole('button', { name: 'Next vibe' }).click(); + await expect.poll(() => getFirstSwatchColor(page)).not.toBe(initialSwatchColor); + await expect.poll(() => getGardenBackground(page)).not.toBe(initialBackground); + + await page.getByRole('button', { name: 'Draw colour 2' }).click(); + await expect(page.locator('.color-swatch').nth(1)).toHaveClass(/active/); + await expect(page.locator('.color-swatch').first()).not.toHaveClass(/active/); + + const mirrorSlider = page.locator('.mirror-segment-slider'); + await mirrorSlider.evaluate((input) => { + const slider = input as HTMLInputElement; + slider.value = '3'; + slider.dispatchEvent(new Event('input', { bubbles: true })); + }); + await expect(page.locator('.mirror-segment-control')).toHaveAttribute( + 'title', + '3 thirds' + ); + await expect(page.locator('.mirror-segment-control')).toHaveClass(/active/); +}); + +( + [ + { + expectedCode: 'webgpu-adapter-unavailable', + expectedMessage: + 'WebGPU is available, but this browser could not provide a compatible GPU adapter.', + mode: 'adapter-null', + }, + { + expectedCode: 'webgpu-adapter-unavailable', + expectedMessage: 'Could not request a WebGPU adapter.', + mode: 'adapter-rejects', + }, + { + expectedCode: 'webgpu-device-unavailable', + expectedMessage: 'Could not create a WebGPU device for this adapter.', + mode: 'device-rejects', + }, + ] satisfies Array<{ + expectedCode: string; + expectedMessage: string; + mode: WebGpuFailureMode; + }> +).forEach(({ expectedCode, expectedMessage, mode }) => { + test(`reports ${mode} startup failures without leaving the shell loading`, async ({ + page, + }) => { + await emulateWebGpuFailure(page, mode); + + await page.goto('/'); + + await expect(page.locator('body')).not.toHaveClass(/is-loading/); + await expect(page.getByRole('toolbar', { name: 'Garden toolbar' })).toBeVisible(); + await expect(page.getByRole('alert')).toContainText(expectedMessage); + await expect(page.getByRole('alert')).toContainText(expectedCode); + }); +}); + +test('serves the production bundle without missing browser assets', async ({ page }) => { + const browserFailures: Array = []; + + page.on('requestfailed', (request) => { + const failure = request.failure(); + browserFailures.push(`${request.method()} ${request.url()} ${failure?.errorText}`); + }); + page.on('response', (response) => { + if (response.status() >= 400) { + browserFailures.push(`${response.status()} ${response.url()}`); + } + }); + + await disableWebGpu(page); + await page.goto('/'); + await expect(page.locator('body')).not.toHaveClass(/is-loading/); + + expect(browserFailures).toEqual([]); +}); + +[ + { height: 720, name: 'desktop', width: 1280 }, + { height: 844, name: 'mobile', width: 390 }, +].forEach(({ height, name, width }) => { + test(`keeps the fallback shell usable on ${name}`, async ({ page }) => { + await page.setViewportSize({ height, width }); + await disableWebGpu(page); + + await page.goto('/'); + await expect(page.locator('body')).not.toHaveClass(/is-loading/); + + const canvasBox = await page + .getByRole('img', { name: 'Interactive generative garden canvas' }) + .boundingBox(); + expect(canvasBox?.width).toBeGreaterThan(0); + expect(canvasBox?.height).toBeGreaterThan(0); + await expect(page.getByRole('toolbar', { name: 'Garden toolbar' })).toBeVisible(); + await expect(page.getByRole('button', { name: 'About' })).toBeVisible(); + await expect(page.getByRole('alert')).toContainText('Fleeting Garden needs WebGPU'); + + const aboutButtonReceivesPointer = await page + .getByRole('button', { name: 'About' }) + .evaluate((button) => { + const rect = button.getBoundingClientRect(); + const target = document.elementFromPoint( + rect.left + rect.width / 2, + rect.top + rect.height / 2 + ); + + return button === target || button.contains(target); + }); + + expect(aboutButtonReceivesPointer).toBe(true); + }); +}); + +test('hides the bottom dock after the cursor leaves fullscreen controls', async ({ + page, +}) => { + await disableWebGpu(page); + + await page.goto('/'); + await expect(page.getByRole('toolbar', { name: 'Garden toolbar' })).toBeVisible(); + + await page.getByRole('button', { name: 'Enter fullscreen' }).click(); + await expect + .poll(() => page.evaluate(() => Boolean(document.fullscreenElement))) + .toBe(true); + + await page.mouse.move(640, 120); + await page.evaluate(() => { + if (document.activeElement instanceof HTMLElement) { + document.activeElement.blur(); + } + }); + + await expect(page.locator('aside.control-dock')).toHaveClass(/menu-hidden/, { + timeout: 6000, + }); + await expect(page.locator('.garden-controls')).not.toBeVisible(); + await expect + .poll(() => + page + .locator('aside.control-dock') + .evaluate((dock) => dock.getBoundingClientRect().top >= window.innerHeight) + ) + .toBe(true); + + await page.mouse.move(640, 700); + await expect(page.locator('aside.control-dock')).not.toHaveClass(/menu-hidden/); + await expect(page.locator('.garden-controls')).toBeVisible(); + await expect + .poll(() => + page + .locator('aside.control-dock') + .evaluate((dock) => dock.getBoundingClientRect().bottom <= window.innerHeight) + ) + .toBe(true); +}); + +test('keeps the bottom dock visible in mobile fullscreen', async ({ page }) => { + await page.setViewportSize({ height: 844, width: 390 }); + await disableWebGpu(page); + + await page.goto('/'); + await expect(page.getByRole('toolbar', { name: 'Garden toolbar' })).toBeVisible(); + + await page.getByRole('button', { name: 'Enter fullscreen' }).click(); + await expect + .poll(() => page.evaluate(() => Boolean(document.fullscreenElement))) + .toBe(true); + + await page.mouse.move(195, 120); + await page.evaluate(() => { + if (document.activeElement instanceof HTMLElement) { + document.activeElement.blur(); + } + }); + await page.waitForTimeout(5200); + + await expect(page.locator('aside.control-dock')).not.toHaveClass(/menu-hidden/); + await expect(page.getByRole('button', { name: 'Show config overlay' })).toBeVisible(); +}); diff --git a/public/apple-touch-icon-180x180.png b/public/apple-touch-icon-180x180.png index 78ea11e4bfdf2884f45e9e5aed178f5881282165..257c79aa0c586cb128139494ff7e6d9cf94c44ed 100644 GIT binary patch delta 1184 zcmV;R1Yi5o3DF6VEHOaV$p8QU|6QUiB{Xb1SJzRDq(N{eNlq$$dqu0HJH)uEzrfSn z-HD8l>g(*qbqYq2T^N5yNkl=zn`(B^_IvBIjXyXR--K);Wdy|Vs8=s;3S2}yVK0%MzeU9t(n1x2Wck8#g z>(A4lgC6n2fzfI~(`fa9&TaJ}dPW=V`VVw>KJLwz(Nmdp#N=bTj5cM`5i{zUt7yD* z{6(LK`Y<1YWY&M7)6v64^i&Mp3~*_l9* zqqiAY&qj}r)06E-AMcYkt=5f>(?K;P znX62gLURxGoc8VhHEqoD`j}asR*lW4VVO1urRsS^A5N!5y(bq4ce~{$mte9ZA8hNP zRd+u$bT>r*hNDk^!&D2s`Zjc*PWSIO2Dd@On|Pm>HF`CLOa2m2>o&e_Yw|ajO6YZ? zlfQ1&LVthc7n0La1+6}#pi>)Nbv~`P&sfkb`fn88^S#KgA{EeTbHk4Re~)Tt9eN$6 z`@?B}b#$MWg|^;Li#wpfShI@sYjiWvFxpt>rIdDsen#3#>76wVq8G6IDYLo`)i?T8 ziSW{BW4(MAtggf7&#PlT!Ce;pA^t?aBK>*P+xmZKu<~!M;_-~M*_>dZD%v=?E&F@> zd%dNzp;Mq58bahH0N=O7sd2HHqjrPWzzM~!y* zXppi_t8rK$?a*$gWc8#!2Gg z-NV1IiHwl_1$7FMT^N5zNkl3Yf)=!(1ubYn3tG^E7PO!REoeatniU<#$s&LvDgd<87)zq# zgsMp46bqrFRRzn&0_d{jW9(G)q!?-RoiC%d_dbe%SwW5VO%_a|8PBUS@xW@b7 z=s9S*M$gUJtyVmZHZSNtv(<{F(W-wzcbL~|#WFfx(~**o=`a5SFt|YY&AMHAl&F-I{*ve>6%BYZ$!7*Y5c}a@63OP?b%XqPWP*8%YNdkt%w`y zM01r5)6(9E)~%$3F%u# zo9_G2u=?_A7(IL;`q}8=d*abQKQZ_kG(L&=H6w4BZSW5NC!p3cK5uKkyAy2#JyTTtIZF#MYL}3uB~QMqjxds<(SYr(KuME5g|om(4-ZxJlbmVvZc`l z%qxFtI<(V8%9TVHGB2vB(8bJ4Y9h4N1JG3EJ_Z9R(USb0GoR zbE91r8Y(3EFV$I36ghsh%RytEM*p-LGai5Kvo+8*$_c>ZzK zSmkB-cqzv7k6?#7=y3iK?8{Us9oKxEf7m^`T4;CvSb>YLh4z>8=&=@h%sG_Ax7g_2 z<6r8a^NxS1(`ui4q>*pWoN#y)`}z_h!7*KJO^(z9(ONU79X;2WIq{G`wp@pi@Hk~f zrWxWR>A{u0iCK5}h)~~1n~xpFIu>R7@lxp6TVKfSeJp#xu=gR5O_S@N^1kc=!xuS! zTF`Y{{{LVUE8~(goX5F00000NkvXXu0mjfn6Fs= diff --git a/public/favicon.ico b/public/favicon.ico index 5307896753216e8ba1385e9cb4f6492933db5ae2..d26cebec93ecaba891abf64541d6f348454c8989 100644 GIT binary patch delta 843 zcmV-R1GM~-2mA&P000310RS*C000312mof04?uqyNkl4pCQ21n-B06c#ZhyaB@q zk#TsOdwt#>tfr5@+=t)z?+9$8vD1JG1rSGN($s0}pRNGqNE@+*0pO-8QE?Mztgsd0 z=IEWB3M2u@Ssj-V05?{Nf?%;v9u%vcitX^e&@eoJ1O(V0J48n zg9&3)_qs*^0U#ntxF`r!;OAjG>v^@ytL+0IGw^PjfH;dFu&r}$z2D1JjZ6T@UYyU1j5Gb{WJN6il+dPlngK!y0P!|#Llf*2*Aq$r-G((0^?olKK+?Rn9W{Sx#I6C9 z#rISZ&?Z`G4FFF=+ahU7K;s$!KGT&w`&6nzrwvXihUH3(+>I00=Y*P69P9U~)>eW_~Cd_l5@TKYQ#_`~#j7 VDWNfmGB*GK002ovPDHLkV1iqTf%gCa delta 865 zcmV-n1D^c+29gI4000310RS*C000312mpMM4?uq|NklBjE7>2!ctp&Bf z4p=25Y3L9_hT^)Y5Ih*1rj$-CEo8`6I)u*c(#iA(n3m$H*}P=-;Gv7Xc5|TMKj19+ z0p=^Osxv29r@K?EJ9&7Cu@vcj?t8!QjO^0shfk$VR!MZ=&5P>2`>yx8T=9Bk*PE7I z@78~DACd``UGFxyXT|Hm1H5PfI1lQlxPU3cjvQzTPf7rwwr$uEfmFO+Du7Azeyu@g z^%L5v?%W9?5*t(IKX1PyI=l?@=kYgf2tH2BhBcLWtUNPMp?>au)T@H)O<7|H0O)j6 zs}HkrfSZFK+Sh^SC4kB75BI;U0MOwj?QMVWW$K0h00u`UGM@zC*BaU};sJ8sq*$Yh zDzH`E(Q0ORUIOH08ei?zTsX$g^J|TWd0w&+ zn;QUbDl!kLOePA2Dq5+4XjNI>3lDHsKM|VZH!eS6V`D;(SnC014rY@^C*ROF;cI`^ z%rKs|^2PvEszHKSsmKTbjPd;->J49WBQplTHz=a9u8$Iq34r)GWj(Qu4|9$Q0Dd!L z69p@swy`-y0^p|)pK-pahmuig4Smd8F^TvZ#aE&Z`KxP1Dd_L){7-GV47-nM= zukoJf{Q0Dl2}BzJrJs%33y~4t5URlb53kdJDHN`ad#oJ*D}(IH3?xM)DPl79yG^BP zC;_1SSxnqj<3UT>=u!ZbUL`VlX*Oyv_$X=tfZnEg;vhto5CR~7Hey35aY}#c2_Zn1 zhRs|3Zc_~aW6ZW$5-2`mR{&6^BIKR~f;T=&D*$P`Wl?l-ezXdJ$8;c5*)45rKIsHW zi9A}wgVIe{ed4MReYjikdPN$WBoVwu9 - + - - - - + + + + diff --git a/public/maskable-icon-512x512.png b/public/maskable-icon-512x512.png index a7224f97e9ceddb434465b24f59f6ed5bc89d75f..ea4b3c21e225fa295a7f3f072e282383ddbc6736 100644 GIT binary patch literal 3445 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 QzyJUM07*qoM6N<$g2o$_p8x;= delta 1650 zcmV-&295cU43`a%BwIXCOjJcoX>q5JJZo4r5EUP4UN8?79uE{A>BdOKsX-4E9{(?+ z|JY3@ASgsAHfkPF=q#3;By7higl%Clpo%(f_aXEE000VfQchCWf9pv^K~#9!?Va0}qbd-F<1z}7Grs@3Zo8AtL^%kGP_H4Sx1=@1E zef8B>|9{-)w!#V|a20D2rn7<(`p(T@IM zFWf00EHt zDb@5NB}-P*dh-`I)1M44Hly9^MUR^PN%2yNTrC0kr~Qi$3Z+5405a2Bx=avNbAX(- z5+z(vuL3Ctz?5)7LkCP#b}@yN0fk&p%>iQaiJ#4adJfQD0D0C0W77yaP)WB9Mc=Rn ze;rDKzEuSIQ(&4-uJP+dkPHBbb20S52k>aqhXw)Usl{c*jVxUZU}ys%F98^Pd^xbn zV)OtNHzPvx06(=oQFAxNZ@=o%0F-JaRsdHo;-%KwnB@=219em{`&=Iv<#t1tX02I` zx$<|T>%Uc600H1yO~<4(1b~S7N=w0)f3S%l$kqVdgss#3?W6ETJG+io1BgiU$b_kF zch~V^0G?=nxWC&YxELT7KgTbv2M`Gir)?0CYeg%7$f%mc*?G?emjdu9$EH{oGy;f4 zh0Fnnw5eZ!qalWuCh#0i;jFM~l$aGjV#0b8sHJyqQZSf5`Dw z-F7-gAX1uEB^obiVh#0c(J0K}fm*&utSW7Uw5cK=n8PxV1B3jEU_Fmd{ztGBR7sfdYu9?Z0wnfoBTP=f5r# z0vG`QF#g9vCx@Q~e_k@5_e3cGZYf)fIywAw$@a373`GE3>le*i0P{)#N2eGH0En6S z#2}h#ms~V}GbG z+5wmu15OYJund%Je=Y!&1dbKcC;%|~{~nqo1I!GZ<{SXa0LrEVZ~_s)na{!ilz|jY z1+WaLY#IQ}0P+M;2w-MlHKzaw23R5ifIfWlyL0a)i0kr@}j?3{wq;R5K~qp%1j`-$g3tPZyWaJhhZPbBMb zJ75u-sf>n>f3(8_5IotjLmfb9f{4Zr2F0+k6+haP;H;o{2+02NxCFQTun9=o5zz#< z{cx)(O+V?qLpFbT!iXXs{+AO#WdiAL*}B6)Al?0TL_iRZn*R)>=D9$-+d17k+U_?v w=$XM^L3ET7QVnGJQIvLtjQ{GZuVCvx;15eV$Q*O#3;+NC07*qoM6N<$g3dkX?*IS* diff --git a/public/pwa-512x512.png b/public/pwa-512x512.png index 0809d42a3a5eabe8410f1d6d9eabfff351bedf37..c171454b3803eaaab1f6bb6bb1342a30702355c6 100644 GIT binary patch literal 4041 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 z zS=_as_EU={ROJ>;a(r#7D->I0Y7wq(HK6&^m+pr>tBZ{-wVvgrGln$n24b zjMen{4g2nFAQlU$!al#fbaSUF^ZVkmfH((?dei5(BfHaDbA8~}N1H_xqVdVlS{YR6X~!%MqAziFTje2&;+ zz)|HJ_E1;)8jTTxs}+if-I9e(S?B9?;pDdi8t;R!d5-()eq8z{|Lv4Vj1 zxar~q1bwcKq$j}Op(S`PlCx=hF6;1M&}10lj8p7c{YVpdUwp$c)))p{^Xa1?W(hsq zm{T;+Iwyc{5g)u`bvbGu9SUxmkP&h{=!t93p^TxmYCg6CD3)g!iD1A_C*$cXAHd%1 z4Wrg@Tp||Ana?$hG=-tKiD)Tj@O^ytDNxAz2_qh@c|1~X&sn-tN%?cyeC!dT*jeI8 zA=I(8&UNGgjMj{@k&B^-J$|6bcnl8em?@M#ya2@&$N3lx1LM);0pOI(`8vRWe7RAL zh|>wA(mDT~x*NNRq!2k?`vaebb1vjEKCM}4CvgRWO7nLc#la?jw~-v={trN|yTlF% z*zGQ{=HP(4#Dat4{{<6HI_oYmo0G4$2~zWfa_9pm)9ve$AN zpTm_#;DTD?gx);B zYO;elFn`Da1-qs({vd`?2FYR|m>t~v1(2H^dcgf;?KH-QqkIgKNsih>SR#(HN5LE= z3}Fzw;9a&y0#L4~+j>1$g;SW@2*i0^6dQ`(@c^MquAE3gSO~M4Gc(WNk_9YdUOsPQ zVnA3#6g?@{vrNZmFx^KV2MII`+x$a{-ABj3t_xUGG4a3zdTQyuk$198zy&+9kAA%L z!7|WSytV&sKvTq3EBxM1z_(;>351y-!Cvjm{vDa>!xt;JN5uT3d z_d#~8kU{PDnMFK0n@sTGmm;_KaNSorA1UunRwQninQ@VA4gfu=0>A4FxSsSSzI7G^ zSu&Re7lLSZ3dj6~Hw>~LkvHuxGEE0&*4S1w4_enixs@UblxxL*BH;$=#oug%NO{z( zhhAg|zIxJneEbAvn?MPCU+FP?V+Q!#xpLe0-6A_Msl%Q)JmszGU>9I{z@Ne_LpL5! zmW-5a^}>_yAeRkHpC&G7%%R)n>md1E%FFXvB5=W9Jci5xS=&u%DUZuK$8g)b4pCT< z4j3pGaBq?pj3zQnz@E7YuXhf&u}kx9kfjsL^2=cwaFhf-P%164VR8~2rq|&NQDFfN z%GcnAI{CC#h8Kh}H&I2BP5|AYQOyAf-&+s3JWng<1TOJctcNWy@YGSc9+bNr)$vAM zzT8<*Z-W65;xffn_Bj0F-_~E1z|IkMd9(hYj*qWt8E_?PvsA1pKaUmSf67itzKJXt z%^&DP@p8J!T3^{0Ox#fGqt!AlHT@2s*-djuV(!(dsv0M;Th+e;?6q1#4K|9UwvNB6 z-vp-YuA#Tx5t${VcB@Q`dH9KP5uVD95`q80Px&U->hU`-KdDT5dxMZ^`|8`<-?EIf z{g#hbj5Om_PuZ=wGk?W?aoTap@&A@{LA}-{>n$cFCS2r#KR`&}^r@u*(wzSQ7g%sC diff --git a/public/pwa-64x64.png b/public/pwa-64x64.png index 7a20069897b5440ffadeae5b2d09c72ee961dddc..cfe866731b53d6d99680ed54f12057b44b5ffb50 100644 GIT binary patch delta 660 zcmV;F0&D%n1+oQ@B!5v*OjJcoX>kw~A1ZlxEscl|6&o_2m=6>lIli+G6dn&0A1;iC z5EUOl*2w=|qUctY7A`f?Pl-Luzci|$Mwe*y4GL_t(|+Rc~A zmV_V-MavN_KqeLS|39=99MHxxP`&8467*e@ARh91{UhadQdRg_Rh87a$6n$Pl~+WK zLsg&rB@R~^>L2b2koa3l+q*1snBPOl!3ij8)O(Wv2XGexqypRm8gCU60HCn|dYw-e zK=lQnf&%VRFo1{pe|Mbob_L8AoOjB({RmjgPid!Aw_Uezz~Ns;9>N1)*c{Yugng)h z0JS@C9AIBQ`w{QF23&(-BQ*0nl{N}#=fqk^1GG7*<}w-}?Qy+`1{C(VpNdGoNY{=div(nQ&r^JpKs2E^^?g}r9Yl8N uJkzisiu8B$o4yMKi9?{`o&H_V{sV#`rP4<9go^+G3IG5}MNUMnLSTZIRv8@t delta 685 zcmV;e0#g051;quBB!5m&OjJcoX>p~FIu8^cS4=Dq6dn&0AG@GG92g|ZtU_5&D-abQ z|JY6cFQeovls_6dGcqO4DTwC8NcPc7uqJnjB4J=1OLk~8pNcy5EuCFdE~;U~CjbBd z3UpFVQvjgRMxSEV`~DukG^g4C000SaNLh0L00Za%00Za&wSRk3YXATO2T4RhRCwCW zm)mxPAPhz8EHwyn6|w&Rj~4NU)Tx2?q320(ooy1r419e2BXv#v0{Sd?)lGL}uW63z z6wv_96EqipP4iV}`T^b(8ial5`yoZ_S#*f}z26g3YKKlh^g-io5*)m?LxeM0eCF{Ob@CzDZm1FsJ~T84I98eC}lN_d`f{|9OFLXfCY%1z{hwX&_$$R)$a0kzU06y z;FmE(ab)hR58~Q)7=Swb{pYur2tcUZyS;z`ES0moJb!{Q?4cp;Ks-Q&CK#iJ&I5>T z6Z32T-~o2~?$ShD(0qW}cMgo>-7n9N0FYt-@tZ%TXMl8*W+aqgppNOu$qWEE!WNNG zeM_fxB4@)m&h$Ak!w;g1L>0CN1E04ad5Jdp(8Sgza!AWb&F$QVFa zzDNX^S&8NoK#Bll13+N_VY9BMG*Sp4>nW|4tW+|b6B}0yaN(rdkzxQ@kLztQpxy2Z zrqFNpn^#o`sE#{N` { base.endsWith('.ts') ? base : null, ].filter(Boolean); - return candidates.find((candidate) => existsSync(candidate) && fileSet.has(candidate)) ?? null; + return ( + candidates.find((candidate) => existsSync(candidate) && fileSet.has(candidate)) ?? + null + ); }; const exportKey = (file, name) => `${path.resolve(file)}:${name}`; const isExported = (node) => ts.canHaveModifiers(node) && - (ts.getModifiers(node) ?? []).some((modifier) => modifier.kind === ts.SyntaxKind.ExportKeyword); + (ts.getModifiers(node) ?? []).some( + (modifier) => modifier.kind === ts.SyntaxKind.ExportKeyword + ); const isDefaultExported = (node) => ts.canHaveModifiers(node) && - (ts.getModifiers(node) ?? []).some((modifier) => modifier.kind === ts.SyntaxKind.DefaultKeyword); + (ts.getModifiers(node) ?? []).some( + (modifier) => modifier.kind === ts.SyntaxKind.DefaultKeyword + ); const exportedDeclarations = new Map(); const usedExports = new Set(); @@ -167,10 +174,15 @@ const parsedFiles = files.map((file) => ({ })); parsedFiles.forEach(({ file, sourceFile }) => collectImportUsage(file, sourceFile)); -parsedFiles.forEach(({ file, sourceFile }) => collectExportedDeclarations(file, sourceFile)); +parsedFiles.forEach(({ file, sourceFile }) => + collectExportedDeclarations(file, sourceFile) +); const unusedExports = Array.from(exportedDeclarations.entries()) - .filter(([key, declaration]) => !usedExports.has(key) && !wildcardUsedFiles.has(declaration.file)) + .filter( + ([key, declaration]) => + !usedExports.has(key) && !wildcardUsedFiles.has(declaration.file) + ) .map(([, declaration]) => declaration) .sort((left, right) => `${left.file}:${left.name}`.localeCompare(`${right.file}:${right.name}`) diff --git a/src/audio/garden-audio-energy.test.ts b/src/audio/garden-audio-energy.test.ts index b82c074..39f8051 100644 --- a/src/audio/garden-audio-energy.test.ts +++ b/src/audio/garden-audio-energy.test.ts @@ -1,10 +1,11 @@ import { describe, expect, it } from 'vitest'; +import { appConfig } from '../config'; import { GardenAudioEnergy } from './garden-audio-energy'; describe('GardenAudioEnergy', () => { it('suspends activity but keeps a fading level when the gesture ends', () => { - const energy = new GardenAudioEnergy(); + const energy = new GardenAudioEnergy(appConfig.audioEngine); energy.beginGesture(0); energy.recordStroke(0.8, 0.1); @@ -24,7 +25,7 @@ describe('GardenAudioEnergy', () => { }); it('uses recent stroke intensity rather than gesture duration alone', () => { - const energy = new GardenAudioEnergy(); + const energy = new GardenAudioEnergy(appConfig.audioEngine); energy.beginGesture(0); energy.recordStroke(1, 0.1); @@ -38,7 +39,7 @@ describe('GardenAudioEnergy', () => { }); it('raises activity immediately when a stroke is recorded', () => { - const energy = new GardenAudioEnergy(); + const energy = new GardenAudioEnergy(appConfig.audioEngine); energy.beginGesture(0); energy.recordStroke(0.12, 0.05); diff --git a/src/audio/garden-audio-gesture-state.ts b/src/audio/garden-audio-gesture-state.ts new file mode 100644 index 0000000..ce5aa4f --- /dev/null +++ b/src/audio/garden-audio-gesture-state.ts @@ -0,0 +1,385 @@ +import type { GardenAudioEngineConfig } from '../config'; +import { clamp, clamp01 } from '../utils/clamp'; +import type { + GardenAudioColorIndex, + GardenAudioStroke, + GardenAudioTouchDown, +} from './garden-audio-types'; +import type { GardenAudioStrokeMetrics } from './garden-audio-input'; + +type GardenAudioGestureMode = 'calm' | 'active' | 'manic' | 'afterglow'; + +interface GardenAudioGestureFrame { + mode: GardenAudioGestureMode; + activity: number; + maniaAmount: number; + panBias: number; + registerBias: number; + brightnessBias: number; + contour: number; + pressure: number; + pressureDelta: number; + mirrorAmount: number; + speedAmount: number; +} + +interface GestureSample { + at: number; + speed: number; + acceleration: number; + distancePixels: number; + turned: boolean; +} + +const WINDOW_SECONDS = 0.75; +const BIN_SECONDS = 0.05; +const MIN_TURN_DEGREES = 55; +const MIN_TURN_DISTANCE_PIXELS = 6; + +const DEFAULT_FRAME: GardenAudioGestureFrame = { + mode: 'calm', + activity: 0, + maniaAmount: 0, + panBias: 0, + registerBias: 0, + brightnessBias: 0, + contour: 0, + pressure: 0, + pressureDelta: 0, + mirrorAmount: 0, + speedAmount: 0, +}; + +export class GardenAudioGestureState { + private readonly samples: Array = []; + private gestureClockSeconds = 0; + private isGestureActive = false; + private previousPressure = 0; + private previousVelocityPixelsPerSecond = 0; + private previousVector: [number, number] | null = null; + private maniaAmount = 0; + private peakActivity = 0; + private lastFrame: GardenAudioGestureFrame = DEFAULT_FRAME; + + public constructor( + private readonly speedForFullEnergyPixelsPerSecond: number, + private readonly inputConfig: GardenAudioEngineConfig['input'] + ) {} + + public beginGesture(): void { + this.samples.length = 0; + this.gestureClockSeconds = 0; + this.isGestureActive = true; + this.previousPressure = 0; + this.previousVelocityPixelsPerSecond = 0; + this.previousVector = null; + this.maniaAmount = 0; + this.peakActivity = 0; + this.lastFrame = DEFAULT_FRAME; + } + + public endGesture(): GardenAudioGestureFrame { + this.isGestureActive = false; + this.samples.length = 0; + this.previousVector = null; + this.previousVelocityPixelsPerSecond = 0; + this.maniaAmount = 0; + this.lastFrame = { + ...this.lastFrame, + mode: this.peakActivity >= 0.42 ? 'afterglow' : 'calm', + activity: 0, + maniaAmount: 0, + speedAmount: 0, + }; + return this.lastFrame; + } + + public recordTouchDown({ + touch, + colorIndex, + mirrorAmount, + pressure, + strength, + }: { + touch: GardenAudioTouchDown; + colorIndex: GardenAudioColorIndex; + mirrorAmount: number; + pressure: number; + strength: number; + }): GardenAudioGestureFrame { + const spatial = getSpatialBias(touch.position, touch.canvasSize); + const normalizedStrength = clamp01(strength); + + this.previousPressure = pressure; + this.peakActivity = Math.max(this.peakActivity, normalizedStrength); + this.lastFrame = { + mode: normalizedStrength >= 0.38 ? 'active' : 'calm', + activity: normalizedStrength, + maniaAmount: 0, + panBias: spatial.panBias, + registerBias: spatial.registerBias, + brightnessBias: spatial.brightnessBias, + contour: colorIndex === 2 ? 0.25 : colorIndex === 0 ? -0.15 : 0, + pressure, + pressureDelta: 0, + mirrorAmount, + speedAmount: 0, + }; + + return this.lastFrame; + } + + public recordStroke({ + stroke, + metrics, + mirrorAmount, + }: { + stroke: GardenAudioStroke; + metrics: GardenAudioStrokeMetrics; + mirrorAmount: number; + }): GardenAudioGestureFrame { + const elapsedSeconds = this.getElapsedSeconds(stroke); + this.gestureClockSeconds += elapsedSeconds; + + const dx = stroke.to[0] - stroke.from[0]; + const dy = stroke.to[1] - stroke.from[1]; + const distancePixels = metrics.distancePixels; + const speedRatio = + metrics.speedPixelsPerSecond / + Math.max(1, this.speedForFullEnergyPixelsPerSecond); + const speed = smoothstep(0.45, 1.2, speedRatio); + const acceleration = smoothstep( + 3, + 12, + Math.abs(metrics.speedPixelsPerSecond - this.previousVelocityPixelsPerSecond) / + (Math.max(1, this.speedForFullEnergyPixelsPerSecond) * elapsedSeconds) + ); + const currentVector: [number, number] = + distancePixels > 0.001 ? [dx / distancePixels, dy / distancePixels] : [0, 0]; + const turned = this.getTurned(currentVector, distancePixels, metrics.speedAmount); + const spatial = getSpatialBias(stroke.to, stroke.canvasSize); + const pressureDelta = clamp(metrics.pressure - this.previousPressure, -1, 1); + const contour = distancePixels > 0.001 ? clamp(-dy / distancePixels, -1, 1) : 0; + + if (distancePixels > 0.5) { + this.samples.push({ + at: this.gestureClockSeconds, + speed, + acceleration, + distancePixels, + turned, + }); + } + this.trimSamples(); + + const features = this.getWindowFeatures(); + const distanceFeature = smoothstep(10, 90, metrics.distancePixels); + const normalIntensity = clamp01( + 0.1 + + features.speed * 0.46 + + metrics.pressure * 0.2 + + distanceFeature * 0.16 + + mirrorAmount * 0.08 + ); + const hasKineticChange = features.acceleration > 0.35 || features.turns > 0.35; + const maniaGate = + !stroke.isErasing && + this.isGestureActive && + this.gestureClockSeconds > 0.2 && + features.pathPixels > 60 && + features.speed > 0.45 && + hasKineticChange; + const maniaEvidence = maniaGate + ? clamp01( + features.speed * 0.34 + + features.acceleration * 0.26 + + features.strokeFrequency * 0.2 + + features.turns * 0.2 + ) * + (1 + mirrorAmount * 0.22) + : 0; + const maniaTarget = smoothstep(0.55, 0.85, maniaEvidence); + const timeConstant = maniaTarget > this.maniaAmount ? 0.12 : 0.65; + const maniaMove = 1 - Math.exp(-elapsedSeconds / timeConstant); + + this.maniaAmount += (maniaTarget - this.maniaAmount) * maniaMove; + this.previousPressure = metrics.pressure; + this.previousVelocityPixelsPerSecond = metrics.speedPixelsPerSecond; + this.previousVector = currentVector; + + const activity = clamp01(normalIntensity + this.maniaAmount * 0.28); + this.peakActivity = Math.max(this.peakActivity, activity); + this.lastFrame = { + mode: this.getMode(activity, this.maniaAmount), + activity, + maniaAmount: clamp01(this.maniaAmount), + panBias: spatial.panBias, + registerBias: spatial.registerBias, + brightnessBias: clamp01( + spatial.brightnessBias * 0.65 + metrics.pressure * 0.2 + speed * 0.15 + ), + contour, + pressure: metrics.pressure, + pressureDelta, + mirrorAmount, + speedAmount: metrics.speedAmount, + }; + + return this.lastFrame; + } + + public getFrame(): GardenAudioGestureFrame { + return this.lastFrame; + } + + public reset(): void { + this.samples.length = 0; + this.gestureClockSeconds = 0; + this.isGestureActive = false; + this.previousPressure = 0; + this.previousVelocityPixelsPerSecond = 0; + this.previousVector = null; + this.maniaAmount = 0; + this.peakActivity = 0; + this.lastFrame = DEFAULT_FRAME; + } + + private getElapsedSeconds(stroke: GardenAudioStroke): number { + if ( + stroke.elapsedSeconds !== undefined && + Number.isFinite(stroke.elapsedSeconds) && + stroke.elapsedSeconds > 0 + ) { + return clamp(stroke.elapsedSeconds, 0.001, 0.15); + } + + return this.inputConfig.fallbackFrameSeconds; + } + + private getTurned( + currentVector: [number, number], + distancePixels: number, + speedAmount: number + ): boolean { + if ( + !this.previousVector || + distancePixels <= MIN_TURN_DISTANCE_PIXELS || + speedAmount <= 0.35 + ) { + return false; + } + + const dot = clamp( + this.previousVector[0] * currentVector[0] + + this.previousVector[1] * currentVector[1], + -1, + 1 + ); + const degrees = (Math.acos(dot) * 180) / Math.PI; + return degrees > MIN_TURN_DEGREES; + } + + private trimSamples(): void { + const earliest = this.gestureClockSeconds - WINDOW_SECONDS; + while (this.samples.length > 0 && this.samples[0].at < earliest) { + this.samples.shift(); + } + } + + private getWindowFeatures(): { + speed: number; + acceleration: number; + strokeFrequency: number; + turns: number; + pathPixels: number; + } { + if (this.samples.length === 0) { + return { + speed: 0, + acceleration: 0, + strokeFrequency: 0, + turns: 0, + pathPixels: 0, + }; + } + + const first = this.samples[0]; + const last = this.samples[this.samples.length - 1]; + const spanSeconds = clamp(last.at - first.at, 0.2, WINDOW_SECONDS); + const bins = new Set(); + let pathPixels = 0; + let turnCount = 0; + + this.samples.forEach((sample) => { + if (sample.distancePixels > 1) { + bins.add(Math.floor(sample.at / BIN_SECONDS)); + } + if (sample.turned) { + turnCount += 1; + } + pathPixels += sample.distancePixels; + }); + + return { + speed: percentile(this.samples.map((sample) => sample.speed), 0.75), + acceleration: percentile( + this.samples.map((sample) => sample.acceleration), + 0.75 + ), + strokeFrequency: smoothstep(6, 14, bins.size / spanSeconds), + turns: smoothstep(2, 7, turnCount / spanSeconds), + pathPixels, + }; + } + + private getMode(activity: number, maniaAmount: number): GardenAudioGestureMode { + if (maniaAmount >= 0.72) { + return 'manic'; + } + + return activity >= 0.38 ? 'active' : 'calm'; + } +} + +const getSpatialBias = ( + position: ArrayLike | undefined, + canvasSize: ArrayLike | undefined +): { + panBias: number; + registerBias: number; + brightnessBias: number; +} => { + if (!position || !canvasSize) { + return { + panBias: 0, + registerBias: 0, + brightnessBias: 0.5, + }; + } + + const width = Math.max(1, canvasSize[0]); + const height = Math.max(1, canvasSize[1]); + const x = clamp01(position[0] / width); + const y = clamp01(position[1] / height); + + return { + panBias: clamp(x * 2 - 1, -1, 1), + registerBias: clamp(1 - y * 2, -1, 1), + brightnessBias: clamp01(1 - y * 0.72), + }; +}; + +const percentile = (values: Array, amount: number): number => { + if (values.length === 0) { + return 0; + } + + const sorted = [...values].sort((a, b) => a - b); + const index = clamp(Math.floor((sorted.length - 1) * amount), 0, sorted.length - 1); + return sorted[index]; +}; + +const smoothstep = (edge0: number, edge1: number, value: number): number => { + const amount = clamp01((value - edge0) / (edge1 - edge0)); + return amount * amount * (3 - 2 * amount); +}; diff --git a/src/audio/garden-audio-input.ts b/src/audio/garden-audio-input.ts index 015e881..b3fa64b 100644 --- a/src/audio/garden-audio-input.ts +++ b/src/audio/garden-audio-input.ts @@ -5,6 +5,7 @@ import { GardenAudioStroke } from './garden-audio-types'; export interface GardenAudioStrokeMetrics { distancePixels: number; pressure: number; + speedPixelsPerSecond: number; speedAmount: number; effectiveEnergy: number; } @@ -35,6 +36,7 @@ export const getStrokeMetrics = ( return { distancePixels, pressure, + speedPixelsPerSecond, speedAmount, effectiveEnergy, }; diff --git a/src/audio/garden-audio-types.ts b/src/audio/garden-audio-types.ts index b6edc29..71eb314 100644 --- a/src/audio/garden-audio-types.ts +++ b/src/audio/garden-audio-types.ts @@ -18,6 +18,7 @@ export interface GardenAudioStroke { isErasing: boolean; pressure?: number; velocityPixelsPerSecond?: number; + elapsedSeconds?: number; eraserSizePixels?: number; mirrorSegmentCount?: number; pointerType?: string; @@ -26,6 +27,8 @@ export interface GardenAudioStroke { export interface GardenAudioTouchDown { vibe: VibePreset; colorIndex: number; + position?: ArrayLike; + canvasSize?: ArrayLike; mirrorSegmentCount?: number; pressure?: number; pointerType?: string; diff --git a/src/audio/garden-audio.test.ts b/src/audio/garden-audio.test.ts index c026afe..6d4f7da 100644 --- a/src/audio/garden-audio.test.ts +++ b/src/audio/garden-audio.test.ts @@ -1,5 +1,6 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { appConfig } from '../config'; import { VIBE_PRESETS } from '../vibes'; import { GardenAudio } from './garden-audio'; import { gardenAudioConfig, GardenAudioConfig } from './garden-audio-config'; @@ -7,6 +8,7 @@ import { gardenAudioConfig, GardenAudioConfig } from './garden-audio-config'; const calls = { constructed: 0, resumed: 0, + sourcesStarted: 0, }; let contextState: AudioContextState = 'suspended'; @@ -22,13 +24,16 @@ class FakeAudioParam { class FakeAudioNode { public readonly gain = new FakeAudioParam(); public readonly frequency = new FakeAudioParam(); + public readonly Q = new FakeAudioParam(); public readonly threshold = new FakeAudioParam(); public readonly knee = new FakeAudioParam(); public readonly ratio = new FakeAudioParam(); public readonly attack = new FakeAudioParam(); public readonly release = new FakeAudioParam(); public readonly delayTime = new FakeAudioParam(); + public readonly pan = new FakeAudioParam(); public type = ''; + public addEventListener = vi.fn(); public connect = vi.fn(); public disconnect = vi.fn(); } @@ -78,6 +83,10 @@ class FakeAudioContext { return new FakeAudioNode() as unknown as DelayNode; } + public createStereoPanner(): StereoPannerNode { + return new FakeAudioNode() as unknown as StereoPannerNode; + } + public createBuffer(_channels: number, length: number): AudioBuffer { return new FakeAudioBuffer(length) as unknown as AudioBuffer; } @@ -89,7 +98,9 @@ class FakeAudioContext { stop: () => void; }; node.buffer = null; - node.start = vi.fn(); + node.start = vi.fn(() => { + calls.sourcesStarted += 1; + }); node.stop = vi.fn(); return node; } @@ -108,6 +119,7 @@ describe('GardenAudio startup policy', () => { beforeEach(() => { calls.constructed = 0; calls.resumed = 0; + calls.sourcesStarted = 0; contextState = 'suspended'; vi.stubGlobal('AudioContext', FakeAudioContext); vi.stubGlobal('fetch', vi.fn().mockRejectedValue(new Error('not loaded in tests'))); @@ -118,7 +130,11 @@ describe('GardenAudio startup policy', () => { }); it('does not create an AudioContext from passive audio paths', () => { - const audio = new GardenAudio(makeConfig()); + const audio = new GardenAudio( + makeConfig(), + appConfig.audioEngine, + appConfig.simulation.maxMirrorSegmentCount + ); const vibe = VIBE_PRESETS[0]; audio.start(vibe); @@ -135,7 +151,11 @@ describe('GardenAudio startup policy', () => { }); it('only resumes a suspended context from a user gesture start', () => { - const audio = new GardenAudio(makeConfig()); + const audio = new GardenAudio( + makeConfig(), + appConfig.audioEngine, + appConfig.simulation.maxMirrorSegmentCount + ); const vibe = VIBE_PRESETS[0]; audio.start(vibe, { userGesture: true }); @@ -150,4 +170,51 @@ describe('GardenAudio startup policy', () => { expect(calls.resumed).toBe(1); }); + + it('skips cold piano fallback while preserving eraser noise', () => { + const audio = new GardenAudio( + makeConfig(), + appConfig.audioEngine, + appConfig.simulation.maxMirrorSegmentCount + ); + const vibe = VIBE_PRESETS[0]; + + audio.start(vibe, { userGesture: true }); + expect(calls.sourcesStarted).toBe(1); + + audio.beginGesture(); + audio.touchDown({ + vibe, + colorIndex: 1, + position: [30, 40], + canvasSize: [100, 100], + pressure: 0.7, + }); + audio.stroke({ + vibe, + from: [30, 40], + to: [60, 60], + canvasSize: [100, 100], + colorIndex: 1, + isErasing: false, + pressure: 0.7, + velocityPixelsPerSecond: 1600, + }); + + expect(calls.sourcesStarted).toBe(1); + + audio.stroke({ + vibe, + from: [60, 60], + to: [75, 80], + canvasSize: [100, 100], + colorIndex: 1, + eraserSizePixels: 30, + isErasing: true, + pressure: 0.7, + velocityPixelsPerSecond: 1200, + }); + + expect(calls.sourcesStarted).toBe(2); + }); }); diff --git a/src/audio/garden-audio.ts b/src/audio/garden-audio.ts index 01d5a5d..b736d9c 100644 --- a/src/audio/garden-audio.ts +++ b/src/audio/garden-audio.ts @@ -1,10 +1,11 @@ -import { appConfig } from '../config'; +import type { GardenAudioEngineConfig } from '../config'; import { clamp, clamp01 } from '../utils/clamp'; import { VibePreset } from '../vibes'; import { GardenAudioConfig } from './garden-audio-config'; import { GardenAudioEnergy } from './garden-audio-energy'; +import { GardenAudioGestureState } from './garden-audio-gesture-state'; import { GardenAudioGraph } from './garden-audio-graph'; -import { GardenAudioStrokeMetrics, getStrokeMetrics } from './garden-audio-input'; +import { getStrokeMetrics } from './garden-audio-input'; import { getVibeProfile, normalizeColorIndex } from './garden-audio-music'; import type { GardenAudioColorIndex, @@ -29,6 +30,7 @@ export class GardenAudio { private readonly piano: PianoSampler; private readonly noise: NoiseBurstPlayer; private readonly energy: GardenAudioEnergy; + private readonly gestureState: GardenAudioGestureState; private readonly pianoEngine: GenerativePianoEngine; private currentVibeId: string | null = null; @@ -41,12 +43,22 @@ export class GardenAudio { private lastEraserAt = Number.NEGATIVE_INFINITY; private lastVibeStingerAt = Number.NEGATIVE_INFINITY; - public constructor(private readonly config: GardenAudioConfig) { - this.graph = new GardenAudioGraph(config); - this.piano = new PianoSampler(config, this.graph); - this.noise = new NoiseBurstPlayer(this.graph); - this.energy = new GardenAudioEnergy(); - this.pianoEngine = new GenerativePianoEngine(config, (note) => this.piano.play(note)); + public constructor( + private readonly config: GardenAudioConfig, + private readonly engineConfig: GardenAudioEngineConfig, + private readonly maxMirrorSegmentCount: number + ) { + this.graph = new GardenAudioGraph(config, engineConfig); + this.piano = new PianoSampler(config, engineConfig, this.graph); + this.noise = new NoiseBurstPlayer(engineConfig, this.graph); + this.energy = new GardenAudioEnergy(engineConfig); + this.gestureState = new GardenAudioGestureState( + config.rhythm.speedForFullEnergyPixelsPerSecond, + engineConfig.input + ); + this.pianoEngine = new GenerativePianoEngine(config, engineConfig, (note) => + this.piano.play(note) + ); } public start(vibe: VibePreset, options: GardenAudioStartOptions = {}): void { @@ -76,7 +88,7 @@ export class GardenAudio { this.graph.setMasterGain( this.config.masterVolume, options.userGesture === true - ? appConfig.audioEngine.muteRampSeconds + ? this.engineConfig.muteRampSeconds : this.config.fadeInSeconds ); @@ -110,8 +122,8 @@ export class GardenAudio { public setMuted(isMuted: boolean): void { this.isMuted = isMuted; this.graph.setMasterGain( - isMuted ? appConfig.audioEngine.muteGain : this.config.masterVolume, - isMuted ? appConfig.audioEngine.muteRampSeconds : this.config.fadeInSeconds + isMuted ? this.engineConfig.muteGain : this.config.masterVolume, + isMuted ? this.engineConfig.muteRampSeconds : this.config.fadeInSeconds ); } @@ -122,11 +134,13 @@ export class GardenAudio { } this.isGestureActive = true; + this.gestureState.beginGesture(); this.energy.beginGesture(context.currentTime); this.pianoEngine.beginGesture(); } public endGesture(): void { + this.gestureState.endGesture(); this.isGestureActive = false; this.energy.endGesture(); this.pianoEngine.endGesture(); @@ -146,6 +160,13 @@ export class GardenAudio { const mirrorAmount = this.getMirrorAmount(touch.mirrorSegmentCount ?? 1); const pressure = this.getTouchPressure(touch.pressure, touch.pointerType); const strength = clamp01(0.36 + pressure * 0.34 + mirrorAmount * 0.22); + const frame = this.gestureState.recordTouchDown({ + touch, + colorIndex: this.selectedColorIndex, + mirrorAmount, + pressure, + strength, + }); this.energy.recordStroke(strength, context.currentTime); this.pianoEngine.recordTouchDown({ @@ -154,6 +175,13 @@ export class GardenAudio { strength, selectedColorIndex: this.selectedColorIndex, mirrorAmount, + panBias: frame.panBias, + registerBias: frame.registerBias, + brightnessBias: frame.brightnessBias, + contour: frame.contour, + pressureAmount: frame.pressure, + pressureDelta: frame.pressureDelta, + maniaAmount: frame.maniaAmount, }); } @@ -197,7 +225,8 @@ export class GardenAudio { const metrics = getStrokeMetrics( stroke, this.config.rhythm.speedForFullEnergyPixelsPerSecond, - this.config.input.pressureFallback + this.config.input.pressureFallback, + this.engineConfig.input ); const now = context.currentTime; @@ -210,7 +239,8 @@ export class GardenAudio { } const mirrorAmount = this.getMirrorAmount(stroke.mirrorSegmentCount ?? 1); - const strokeEnergy = this.getStrokeMusicActivity(stroke, metrics, mirrorAmount); + const frame = this.gestureState.recordStroke({ stroke, metrics, mirrorAmount }); + const strokeEnergy = frame.activity; this.energy.recordStroke(strokeEnergy, now); this.pianoEngine.recordStroke({ vibe: stroke.vibe, @@ -218,6 +248,13 @@ export class GardenAudio { activity: strokeEnergy, selectedColorIndex: this.selectedColorIndex, mirrorAmount, + panBias: frame.panBias, + registerBias: frame.registerBias, + brightnessBias: frame.brightnessBias, + contour: frame.contour, + pressureAmount: frame.pressure, + pressureDelta: frame.pressureDelta, + maniaAmount: frame.maniaAmount, }); } @@ -227,6 +264,7 @@ export class GardenAudio { this.piano.reset(); this.energy.reset(); + this.gestureState.reset(); this.pianoEngine.reset(); this.currentVibeId = null; this.hasStarted = false; @@ -246,7 +284,7 @@ export class GardenAudio { const now = context.currentTime; if ( now - this.lastVibeStingerAt < - appConfig.audioEngine.vibeChangeStingerMinIntervalSeconds + this.engineConfig.vibeChangeStingerMinIntervalSeconds ) { return; } @@ -266,10 +304,10 @@ export class GardenAudio { } const sizeAmount = clamp01( - (stroke.eraserSizePixels ?? appConfig.audioEngine.eraser.defaultSizePixels) / + (stroke.eraserSizePixels ?? this.engineConfig.eraser.defaultSizePixels) / Math.max( 1, - stroke.canvasSize[0] * appConfig.audioEngine.eraser.canvasWidthRatioForFullSize + stroke.canvasSize[0] * this.engineConfig.eraser.canvasWidthRatioForFullSize ) ); const x = clamp01(stroke.to[0] / Math.max(1, stroke.canvasSize[0])); @@ -277,22 +315,22 @@ export class GardenAudio { this.config.eraser.filterMinHz + (this.config.eraser.filterMaxHz - this.config.eraser.filterMinHz) * clamp01( - speedAmount * appConfig.audioEngine.eraser.filterSpeedWeight + - pressure * appConfig.audioEngine.eraser.filterPressureWeight + - sizeAmount * appConfig.audioEngine.eraser.filterSizeWeight + speedAmount * this.engineConfig.eraser.filterSpeedWeight + + pressure * this.engineConfig.eraser.filterPressureWeight + + sizeAmount * this.engineConfig.eraser.filterSizeWeight ); if (now - this.lastEraserAt >= this.config.eraser.minIntervalSeconds) { this.lastEraserAt = now; this.noise.play({ startTime: now, - durationSeconds: appConfig.audioEngine.eraser.durationSeconds, + durationSeconds: this.engineConfig.eraser.durationSeconds, gain: this.config.eraser.noiseGain * - (appConfig.audioEngine.eraser.gainBase + - speedAmount * appConfig.audioEngine.eraser.gainSpeedWeight + - pressure * appConfig.audioEngine.eraser.gainPressureWeight + - sizeAmount * appConfig.audioEngine.eraser.gainSizeWeight), + (this.engineConfig.eraser.gainBase + + speedAmount * this.engineConfig.eraser.gainSpeedWeight + + pressure * this.engineConfig.eraser.gainPressureWeight + + sizeAmount * this.engineConfig.eraser.gainSizeWeight), filterHz, pan: clamp(x * 2 - 1, -1, 1), }); @@ -307,7 +345,7 @@ export class GardenAudio { const profile = getVibeProfile(this.config, snapshot.vibe); const activity = snapshot.isErasing - ? appConfig.audioEngine.delay.erasingActivity + ? this.engineConfig.delay.erasingActivity : this.energy.getLevel(); this.graph.updateDelay(profile, activity); } @@ -323,7 +361,7 @@ export class GardenAudio { } private getMirrorAmount(mirrorSegmentCount: number): number { - const maxMirrorSegmentCount = Math.max(1, appConfig.simulation.maxMirrorSegmentCount); + const maxMirrorSegmentCount = Math.max(1, this.maxMirrorSegmentCount); const segmentCount = clamp( Number.isFinite(mirrorSegmentCount) ? mirrorSegmentCount : 1, 1, @@ -337,44 +375,16 @@ export class GardenAudio { return clamp01((segmentCount - 1) / (maxMirrorSegmentCount - 1)); } - private getStrokeMusicActivity( - stroke: GardenAudioStroke, - metrics: GardenAudioStrokeMetrics, - mirrorAmount: number - ): number { - const speedRatio = - (stroke.velocityPixelsPerSecond ?? 0) / - Math.max(1, this.config.rhythm.speedForFullEnergyPixelsPerSecond); - const speedDrive = smoothstep(0.35, 1.1, speedRatio); - const speedOverdrive = smoothstep(1.15, 1.8, speedRatio); - const distanceDrive = smoothstep(10, 90, metrics.distancePixels); - const baseStroke = clamp01( - 0.08 + speedDrive * 0.5 + metrics.pressure * 0.2 + distanceDrive * 0.22 - ); - const mirrorWild = smoothstep(0.45, 0.9, mirrorAmount); - const maniaDrive = speedOverdrive * smoothstep(0.62, 0.82, baseStroke); - const maniaBoost = maniaDrive * (0.18 + mirrorWild * 0.62); - - return clamp01( - baseStroke * (0.68 + mirrorAmount * 0.3) + - 0.025 + - mirrorAmount * 0.045 + - maniaBoost - ); - } - private getTouchPressure(pressure: number | undefined, pointerType?: string): number { if (pressure !== undefined && Number.isFinite(pressure) && pressure > 0) { return clamp01(pressure); } return pointerType === 'pen' - ? Math.max(appConfig.audioEngine.input.penMinPressure, this.config.input.pressureFallback) + ? Math.max( + this.engineConfig.input.penMinPressure, + this.config.input.pressureFallback + ) : this.config.input.pressureFallback; } } - -const smoothstep = (edge0: number, edge1: number, value: number): number => { - const amount = clamp01((value - edge0) / (edge1 - edge0)); - return amount * amount * (3 - 2 * amount); -}; diff --git a/src/audio/generative-piano.test.ts b/src/audio/generative-piano.test.ts index f75bf8c..90a65ad 100644 --- a/src/audio/generative-piano.test.ts +++ b/src/audio/generative-piano.test.ts @@ -1,5 +1,6 @@ import { describe, expect, it } from 'vitest'; +import { appConfig } from '../config'; import { VIBE_PRESETS } from '../vibes'; import { gardenAudioConfig } from './garden-audio-config'; import { PianoNote } from './garden-audio-types'; @@ -7,9 +8,13 @@ import { GenerativePianoEngine } from './generative-piano'; const makeEngine = () => { const notes: Array = []; - const engine = new GenerativePianoEngine(gardenAudioConfig, (note) => { - notes.push(note); - }); + const engine = new GenerativePianoEngine( + gardenAudioConfig, + appConfig.audioEngine, + (note) => { + notes.push(note); + } + ); return { engine, notes }; }; @@ -17,7 +22,9 @@ const makeEngine = () => { const getBeatSeconds = (): number => 60 / gardenAudioConfig.rhythm.bpm; const getBeatsPerBar = (): number => - Math.round(gardenAudioConfig.rhythm.stepsPerBar / gardenAudioConfig.rhythm.stepsPerBeat); + Math.round( + gardenAudioConfig.rhythm.stepsPerBar / gardenAudioConfig.rhythm.stepsPerBeat + ); const renderBars = ( engine: GenerativePianoEngine, @@ -45,9 +52,8 @@ const countNotesBetween = ( startSeconds: number, endSeconds: number ): number => - notes.filter( - (note) => note.startTime >= startSeconds && note.startTime < endSeconds - ).length; + notes.filter((note) => note.startTime >= startSeconds && note.startTime < endSeconds) + .length; describe('GenerativePianoEngine', () => { it('plays quiet background music even when the garden is idle', () => { @@ -56,10 +62,8 @@ describe('GenerativePianoEngine', () => { renderBars(engine, 0); expect(notes.length).toBeGreaterThan(0); - expect(notes.some((note) => note.durationSeconds > getBeatSeconds() * 12)).toBe( - true - ); - expect(Math.max(...notes.map((note) => note.velocity))).toBeLessThan(0.16); + expect(notes.some((note) => note.durationSeconds > getBeatSeconds() * 6)).toBe(true); + expect(Math.max(...notes.map((note) => note.velocity))).toBeLessThan(0.12); }); it('keeps the background sparse instead of filling every beat', () => { diff --git a/src/audio/generative-piano.ts b/src/audio/generative-piano.ts index 503f030..2e3ce1e 100644 --- a/src/audio/generative-piano.ts +++ b/src/audio/generative-piano.ts @@ -1,4 +1,4 @@ -import { appConfig } from '../config'; +import type { GardenAudioEngineConfig } from '../config'; import { clamp, clamp01 } from '../utils/clamp'; import { VibePreset } from '../vibes'; import { @@ -6,7 +6,11 @@ import { GardenAudioConfig, GardenAudioVibeProfile, } from './garden-audio-config'; -import { degreeToSemitone, getChordIntervals, getVibeProfile } from './garden-audio-music'; +import { + degreeToSemitone, + getChordIntervals, + getVibeProfile, +} from './garden-audio-music'; import { GardenAudioColorIndex, PianoNote } from './garden-audio-types'; interface RenderLookaheadRequest { @@ -23,6 +27,13 @@ interface StrokeAccentRequest { activity: number; selectedColorIndex: GardenAudioColorIndex; mirrorAmount?: number; + panBias?: number; + registerBias?: number; + brightnessBias?: number; + contour?: number; + pressureAmount?: number; + pressureDelta?: number; + maniaAmount?: number; } interface TouchDownRequest { @@ -31,6 +42,13 @@ interface TouchDownRequest { strength: number; selectedColorIndex: GardenAudioColorIndex; mirrorAmount?: number; + panBias?: number; + registerBias?: number; + brightnessBias?: number; + contour?: number; + pressureAmount?: number; + pressureDelta?: number; + maniaAmount?: number; } interface Register { @@ -61,6 +79,14 @@ interface BrushPhraseLayer { selectedColorIndex: GardenAudioColorIndex; energy: number; mirrorAmount: number; + motifOffsets: Array; + panBias: number; + registerBias: number; + brightnessBias: number; + contour: number; + pressureAmount: number; + pressureDelta: number; + maniaAmount: number; } const COLOR_POOLS: [ColorPool, ColorPool, ColorPool] = [ @@ -134,6 +160,9 @@ const BRUSH_STREAM_IDLE_INTERVAL_BEATS = 2; const BRUSH_STREAM_ACTIVE_INTERVAL_BEATS = 1; const BRUSH_STREAM_INTENSE_INTERVAL_BEATS = 0.5; const BRUSH_STREAM_MANIC_INTERVAL_BEATS = 0.25; +const BRUSH_MOTIF_MAX_STEPS = 8; +const BRUSH_MOTIF_CANON_DELAY_SECONDS = 0.055; +const PAD_DURATION_BAR_SCALE = 0.46; export class GenerativePianoEngine { private nextBeatAt: number | null = null; @@ -154,22 +183,23 @@ export class GenerativePianoEngine { public constructor( private readonly config: GardenAudioConfig, + private readonly engineConfig: GardenAudioEngineConfig, private readonly playNote: (note: PianoNote) => void ) {} public prime(now: number): void { if (this.nextBeatAt === null) { - this.nextBeatAt = now + appConfig.audioEngine.startDelaySeconds; + this.nextBeatAt = now + this.engineConfig.startDelaySeconds; } this.timelineStartedAt ??= now; - this.nextBrushStreamAt ??= now + appConfig.audioEngine.startDelaySeconds; + this.nextBrushStreamAt ??= now + this.engineConfig.startDelaySeconds; } public cue(now: number): void { - this.nextBeatAt = now + appConfig.audioEngine.startDelaySeconds; + this.nextBeatAt = now + this.engineConfig.startDelaySeconds; this.timelineStartedAt = now; this.beatIndex = 0; - this.nextBrushStreamAt = now + appConfig.audioEngine.startDelaySeconds; + this.nextBrushStreamAt = now + this.engineConfig.startDelaySeconds; this.brushStreamNoteIndex = 0; this.lastBrushStreamMidi = null; } @@ -188,9 +218,25 @@ export class GenerativePianoEngine { strength, selectedColorIndex, mirrorAmount = 0, + panBias = 0, + registerBias = 0, + brightnessBias = 0.5, + contour = 0, + pressureAmount = 0, + pressureDelta = 0, + maniaAmount = 0, }: TouchDownRequest): void { const normalizedStrength = clamp01(strength); const normalizedMirrorAmount = clamp01(mirrorAmount); + const normalizedMotif = this.normalizeMotif({ + panBias, + registerBias, + brightnessBias, + contour, + pressureAmount, + pressureDelta, + maniaAmount, + }); this.isWaitingForGestureAccent = false; this.lastGestureAccentAt = now; @@ -201,8 +247,17 @@ export class GenerativePianoEngine { strength: normalizedStrength, selectedColorIndex, mirrorAmount: normalizedMirrorAmount, + ...normalizedMotif, + }); + this.playTouchNote({ + vibe, + now, + selectedColorIndex, + strength: normalizedStrength, + panBias: normalizedMotif.panBias, + registerBias: normalizedMotif.registerBias, + brightnessBias: normalizedMotif.brightnessBias, }); - this.playTouchNote(vibe, now, selectedColorIndex, normalizedStrength); } public recordStroke({ @@ -211,9 +266,25 @@ export class GenerativePianoEngine { activity, selectedColorIndex, mirrorAmount = 0, + panBias = 0, + registerBias = 0, + brightnessBias = 0.5, + contour = 0, + pressureAmount = 0, + pressureDelta = 0, + maniaAmount = 0, }: StrokeAccentRequest): void { const strength = clamp01(activity); const normalizedMirrorAmount = clamp01(mirrorAmount); + const normalizedMotif = this.normalizeMotif({ + panBias, + registerBias, + brightnessBias, + contour, + pressureAmount, + pressureDelta, + maniaAmount, + }); if ( this.isWaitingForGestureAccent && @@ -225,11 +296,19 @@ export class GenerativePianoEngine { strength, selectedColorIndex, mirrorAmount: normalizedMirrorAmount, + ...normalizedMotif, }); return; } this.isWaitingForGestureAccent = false; + this.updateBrushPhraseLayer({ + now, + strength, + selectedColorIndex, + mirrorAmount: normalizedMirrorAmount, + ...normalizedMotif, + }); if ( strength >= STROKE_ACCENT_THRESHOLD && now - this.lastStrokeAccentAt >= STROKE_ACCENT_MIN_INTERVAL_SECONDS @@ -385,22 +464,22 @@ export class GenerativePianoEngine { const chord = this.getChord(profile, barIndex); const intervals = getChordIntervals(chord, true); const rootMidi = profile.rootMidi + chord.rootOffset; - const durationSeconds = this.getBarDurationSeconds() * CHORD_BARS * 0.88; + const durationSeconds = this.getBarDurationSeconds() * CHORD_BARS * PAD_DURATION_BAR_SCALE; const notes = [ { source: { baseMidi: rootMidi, offsets: [0] }, register: PAD_REGISTERS[0], - velocity: 0.082, + velocity: 0.052, }, { source: { baseMidi: rootMidi, offsets: [intervals[1]] }, register: PAD_REGISTERS[1], - velocity: 0.064, + velocity: 0.041, }, { source: { baseMidi: rootMidi, offsets: [intervals[3], intervals[2]] }, register: PAD_REGISTERS[2], - velocity: 0.052, + velocity: 0.033, }, ]; @@ -412,8 +491,8 @@ export class GenerativePianoEngine { startTime, durationSeconds, pan: register.pan, - delaySend: 0.018, - lowpassHz: this.getLowpassHz(profile, midi, expression * 0.45), + delaySend: 0.008, + lowpassHz: this.getLowpassHz(profile, midi, expression * 0.28), }); }); } @@ -519,7 +598,7 @@ export class GenerativePianoEngine { this.config.colorVoices[selectedColorIndex].velocityMultiplier, startTime: now + - appConfig.audioEngine.startDelaySeconds + + this.engineConfig.startDelaySeconds + index * GESTURE_ACCENT_SPACING_SECONDS, durationSeconds: 0.48 + strength * 0.22, pan: this.getColorPan(selectedColorIndex), @@ -529,14 +608,26 @@ export class GenerativePianoEngine { } } - private playTouchNote( - vibe: VibePreset, - now: number, - selectedColorIndex: GardenAudioColorIndex, - strength: number - ): void { + private playTouchNote({ + vibe, + now, + selectedColorIndex, + strength, + panBias, + registerBias, + brightnessBias, + }: { + vibe: VibePreset; + now: number; + selectedColorIndex: GardenAudioColorIndex; + strength: number; + panBias: number; + registerBias: number; + brightnessBias: number; + }): void { const profile = getVibeProfile(this.config, vibe); const pool = COLOR_POOLS[selectedColorIndex]; + const register = this.getBiasedRegister(pool, registerBias, 0); const chord = this.getChord(profile, this.getGlobalBarIndex(now)); const chordIntervals = getChordIntervals(chord, false); const rootMidi = profile.rootMidi + chord.rootOffset; @@ -545,7 +636,7 @@ export class GenerativePianoEngine { baseMidi: rootMidi, offsets: this.getSupportOffsets(chordIntervals, selectedColorIndex), }, - pool, + register, this.lastMidiByColor[selectedColorIndex], true ); @@ -559,9 +650,13 @@ export class GenerativePianoEngine { this.config.colorVoices[selectedColorIndex].velocityMultiplier, startTime: now, durationSeconds: 0.55 + strength * 0.18, - pan: this.getColorPan(selectedColorIndex), + pan: this.getLayerPan(selectedColorIndex, panBias, 0, 0), delaySend: 0.006, - lowpassHz: this.getLowpassHz(profile, midi, clamp01(0.45 + strength * 0.45)), + lowpassHz: this.getLowpassHz( + profile, + midi, + clamp01(0.45 + strength * 0.35 + brightnessBias * 0.2) + ), }); } @@ -571,12 +666,26 @@ export class GenerativePianoEngine { strength, selectedColorIndex, mirrorAmount, + panBias, + registerBias, + brightnessBias, + contour, + pressureAmount, + pressureDelta, + maniaAmount, }: { vibe: VibePreset; now: number; strength: number; selectedColorIndex: GardenAudioColorIndex; mirrorAmount: number; + panBias: number; + registerBias: number; + brightnessBias: number; + contour: number; + pressureAmount: number; + pressureDelta: number; + maniaAmount: number; }): void { const lifetimeSeconds = BRUSH_LAYER_BASE_SECONDS + @@ -590,6 +699,18 @@ export class GenerativePianoEngine { selectedColorIndex, energy: strength, mirrorAmount, + motifOffsets: this.getInitialMotifOffsets({ + selectedColorIndex, + registerBias, + contour, + }), + panBias, + registerBias, + brightnessBias, + contour, + pressureAmount, + pressureDelta, + maniaAmount, }); if (this.brushPhraseLayers.length > MAX_BRUSH_PHRASE_LAYERS) { @@ -597,6 +718,55 @@ export class GenerativePianoEngine { } } + private updateBrushPhraseLayer({ + now, + strength, + selectedColorIndex, + mirrorAmount, + panBias, + registerBias, + brightnessBias, + contour, + pressureAmount, + pressureDelta, + maniaAmount, + }: { + now: number; + strength: number; + selectedColorIndex: GardenAudioColorIndex; + mirrorAmount: number; + panBias: number; + registerBias: number; + brightnessBias: number; + contour: number; + pressureAmount: number; + pressureDelta: number; + maniaAmount: number; + }): void { + const layer = this.brushPhraseLayers[this.brushPhraseLayers.length - 1]; + if (!layer || layer.expiresAt <= now) { + return; + } + + const followAmount = 0.24 + clamp01(strength) * 0.24; + layer.selectedColorIndex = selectedColorIndex; + layer.energy = Math.max(layer.energy * 0.94, strength); + layer.mirrorAmount = Math.max(layer.mirrorAmount * 0.96, mirrorAmount); + layer.panBias = mix(layer.panBias, panBias, followAmount); + layer.registerBias = mix(layer.registerBias, registerBias, followAmount); + layer.brightnessBias = mix(layer.brightnessBias, brightnessBias, followAmount); + layer.contour = mix(layer.contour, contour, followAmount); + layer.pressureAmount = mix(layer.pressureAmount, pressureAmount, followAmount); + layer.pressureDelta = pressureDelta; + layer.maniaAmount = Math.max(layer.maniaAmount * 0.92, maniaAmount); + layer.motifOffsets.push( + this.getMotifOffset({ registerBias, contour, pressureDelta, strength }) + ); + if (layer.motifOffsets.length > BRUSH_MOTIF_MAX_STEPS) { + layer.motifOffsets = layer.motifOffsets.slice(-BRUSH_MOTIF_MAX_STEPS); + } + } + private renderBrushPhraseLayers({ vibe, now, @@ -610,8 +780,8 @@ export class GenerativePianoEngine { activity: number; selectedColorIndex: GardenAudioColorIndex; }): void { - const earliestStart = now + appConfig.audioEngine.piano.scheduleAheadSeconds; - this.nextBrushStreamAt ??= now + appConfig.audioEngine.startDelaySeconds; + const earliestStart = now + this.engineConfig.piano.scheduleAheadSeconds; + this.nextBrushStreamAt ??= now + this.engineConfig.startDelaySeconds; this.brushPhraseLayers = this.brushPhraseLayers.filter( (layer) => layer.expiresAt > earliestStart @@ -631,6 +801,7 @@ export class GenerativePianoEngine { startTime: this.nextBrushStreamAt, intensity: frame.intensity, selectedColorIndex: frame.selectedColorIndex ?? selectedColorIndex, + layer: frame.layer, }); } this.nextBrushStreamAt += this.getBrushStreamIntervalSeconds(frame.intensity); @@ -643,14 +814,22 @@ export class GenerativePianoEngine { startTime, intensity, selectedColorIndex, + layer, }: { vibe: VibePreset; startTime: number; intensity: number; selectedColorIndex: GardenAudioColorIndex; + layer: BrushPhraseLayer | null; }): void { const profile = getVibeProfile(this.config, vibe); const pool = COLOR_POOLS[selectedColorIndex]; + const maniaAmount = layer?.maniaAmount ?? clamp01((intensity - 0.82) / 0.18); + const register = this.getBiasedRegister( + pool, + layer?.registerBias ?? 0, + maniaAmount * 0.45 + ); const chord = this.getChord(profile, this.getGlobalBarIndex(startTime)); const chordIntervals = getChordIntervals(chord, false); const rootMidi = profile.rootMidi + chord.rootOffset; @@ -662,12 +841,29 @@ export class GenerativePianoEngine { } : { baseMidi: profile.rootMidi, - offsets: this.rotate( - pool.scaleDegrees, - this.brushStreamNoteIndex + selectedColorIndex - ).map((degree) => degreeToSemitone(profile, degree)), + offsets: this.getBrushMotifDegrees({ + layer, + pool, + selectedColorIndex, + }).map((degree) => degreeToSemitone(profile, degree)), }; - const midi = this.chooseMidi(source, pool, this.lastBrushStreamMidi, true); + const midi = this.chooseMidi(source, register, this.lastBrushStreamMidi, true); + const pan = this.getLayerPan( + selectedColorIndex, + layer?.panBias ?? 0, + maniaAmount, + layer?.mirrorAmount ?? 0 + ); + const durationSeconds = clamp( + 0.48 + intensity * 0.08 - maniaAmount * 0.34, + 0.14, + 0.62 + ); + const delaySend = clamp( + 0.012 + intensity * 0.011 + (layer?.mirrorAmount ?? 0) * 0.004 - maniaAmount * 0.006, + 0.006, + 0.032 + ); this.lastBrushStreamMidi = midi; this.lastMidiByColor[selectedColorIndex] = midi; @@ -677,11 +873,38 @@ export class GenerativePianoEngine { (0.1 + intensity * 0.13) * this.config.colorVoices[selectedColorIndex].velocityMultiplier, startTime, - durationSeconds: 0.42 + intensity * 0.22, - pan: this.getColorPan(selectedColorIndex), - delaySend: 0.012 + intensity * 0.01, - lowpassHz: this.getLowpassHz(profile, midi, clamp01(0.35 + intensity * 0.65)), + durationSeconds, + pan, + delaySend, + lowpassHz: this.getLowpassHz( + profile, + midi, + clamp01( + 0.32 + + intensity * 0.48 + + (layer?.brightnessBias ?? 0.5) * 0.14 + + maniaAmount * 0.18 + ) + ), }); + + if (maniaAmount >= 0.62 && (this.brushStreamNoteIndex % 2 === 1 || intensity >= 0.9)) { + const echoMidi = midi + 12 <= 88 ? midi + 12 : midi - 12; + this.playNote({ + midi: echoMidi, + velocity: + (0.045 + intensity * 0.05) * + this.config.colorVoices[selectedColorIndex].velocityMultiplier, + startTime: + startTime + + BRUSH_MOTIF_CANON_DELAY_SECONDS + + (layer?.mirrorAmount ?? 0) * 0.04, + durationSeconds: Math.max(0.11, durationSeconds * 0.68), + pan: clamp(-pan * 0.75, -1, 1), + delaySend: Math.max(0.006, delaySend * 0.72), + lowpassHz: this.getLowpassHz(profile, echoMidi, 0.62 + maniaAmount * 0.24), + }); + } } private getBrushStreamFrame( @@ -690,17 +913,19 @@ export class GenerativePianoEngine { ): { intensity: number; selectedColorIndex: GardenAudioColorIndex | null; + layer: BrushPhraseLayer | null; } { const layerStates = this.brushPhraseLayers.map((layer) => ({ layer, intensity: layer.energy * this.getBrushPhraseFade(layer, startTime) * - (0.8 + layer.mirrorAmount * 0.45), + (0.8 + layer.mirrorAmount * 0.45 + layer.maniaAmount * 0.42), })); - const dominant = layerStates.reduce< - { layer: BrushPhraseLayer; intensity: number } | null - >((best, state) => { + const dominant = layerStates.reduce<{ + layer: BrushPhraseLayer; + intensity: number; + } | null>((best, state) => { if (state.intensity <= 0) { return best; } @@ -712,8 +937,11 @@ export class GenerativePianoEngine { ); return { - intensity: clamp01(activity * 0.45 + layeredIntensity), + intensity: clamp01( + activity * 0.42 + layeredIntensity + (dominant?.layer.maniaAmount ?? 0) * 0.18 + ), selectedColorIndex: dominant?.layer.selectedColorIndex ?? null, + layer: dominant?.layer ?? null, }; } @@ -735,6 +963,142 @@ export class GenerativePianoEngine { return clamp01(1 - ageSeconds / Math.max(0.001, lifetimeSeconds)); } + private normalizeMotif({ + panBias, + registerBias, + brightnessBias, + contour, + pressureAmount, + pressureDelta, + maniaAmount, + }: { + panBias: number; + registerBias: number; + brightnessBias: number; + contour: number; + pressureAmount: number; + pressureDelta: number; + maniaAmount: number; + }): { + panBias: number; + registerBias: number; + brightnessBias: number; + contour: number; + pressureAmount: number; + pressureDelta: number; + maniaAmount: number; + } { + return { + panBias: clamp(panBias, -1, 1), + registerBias: clamp(registerBias, -1, 1), + brightnessBias: clamp01(brightnessBias), + contour: clamp(contour, -1, 1), + pressureAmount: clamp01(pressureAmount), + pressureDelta: clamp(pressureDelta, -1, 1), + maniaAmount: clamp01(maniaAmount), + }; + } + + private getInitialMotifOffsets({ + selectedColorIndex, + registerBias, + contour, + }: { + selectedColorIndex: GardenAudioColorIndex; + registerBias: number; + contour: number; + }): Array { + const start = selectedColorIndex - 1 + Math.round(registerBias); + const motion = contour > 0.2 ? 1 : contour < -0.2 ? -1 : 0; + return [start, start + motion, start + motion * 2, start + motion]; + } + + private getMotifOffset({ + registerBias, + contour, + pressureDelta, + strength, + }: { + registerBias: number; + contour: number; + pressureDelta: number; + strength: number; + }): number { + const contourStep = contour > 0.3 ? 1 : contour < -0.3 ? -1 : 0; + const registerStep = Math.round(registerBias * 2); + const pressureStep = pressureDelta > 0.08 ? 1 : pressureDelta < -0.08 ? -1 : 0; + const energyStep = strength >= 0.82 ? 1 : strength >= 0.55 ? 0 : -1; + return clamp(contourStep + registerStep + pressureStep + energyStep, -3, 4); + } + + private getBrushMotifDegrees({ + layer, + pool, + selectedColorIndex, + }: { + layer: BrushPhraseLayer | null; + pool: ColorPool; + selectedColorIndex: GardenAudioColorIndex; + }): Array { + const colorOffset = this.config.colorVoices[selectedColorIndex].scaleDegreeOffset; + if (!layer || layer.motifOffsets.length === 0) { + return this.rotate(pool.scaleDegrees, this.brushStreamNoteIndex + colorOffset); + } + + const motifOffset = + layer.motifOffsets[this.brushStreamNoteIndex % layer.motifOffsets.length]; + const contourOffset = + layer.contour > 0.28 + ? this.brushStreamNoteIndex % 3 + : layer.contour < -0.28 + ? -(this.brushStreamNoteIndex % 3) + : 0; + const pressureLift = layer.pressureAmount > 0.68 ? 1 : 0; + const baseOffset = colorOffset + motifOffset + contourOffset + pressureLift; + + return this.rotate( + pool.scaleDegrees.map((degree) => degree + baseOffset), + this.brushStreamNoteIndex + ); + } + + private getBiasedRegister( + register: Register, + registerBias: number, + maniaAmount: number + ): Register { + const shift = Math.round(registerBias * 7 + maniaAmount * 4); + const midiMin = clamp(register.midiMin + shift, 36, 86); + const midiMax = clamp(register.midiMax + shift, midiMin + 4, 91); + + return { + midiMin, + midiMax, + preferredMidi: clamp(register.preferredMidi + shift, midiMin, midiMax), + pan: register.pan, + }; + } + + private getLayerPan( + selectedColorIndex: GardenAudioColorIndex, + panBias: number, + maniaAmount: number, + mirrorAmount: number + ): number { + const shimmer = + maniaAmount > 0.4 + ? Math.sin(this.brushStreamNoteIndex * Math.PI * 0.5) * mirrorAmount * 0.14 + : 0; + + return clamp( + this.getColorPan(selectedColorIndex) + + panBias * (0.18 + maniaAmount * 0.42) + + shimmer, + -1, + 1 + ); + } + private chooseMidi( pitchSource: PitchSource, register: Register, @@ -822,10 +1186,7 @@ export class GenerativePianoEngine { return [chordIntervals[2], 12, chordIntervals[3], chordIntervals[1] + 12]; } - private getChord( - profile: GardenAudioVibeProfile, - barIndex: number - ): GardenAudioChord { + private getChord(profile: GardenAudioVibeProfile, barIndex: number): GardenAudioChord { const progressionIndex = Math.floor(barIndex / CHORD_BARS) % profile.progression.length; return profile.progression[progressionIndex]; @@ -852,8 +1213,8 @@ export class GenerativePianoEngine { return clamp( this.config.piano.lowpassHz * profile.brightness * (0.58 + expression * 0.32) + midiLift, - appConfig.audioEngine.piano.lowpassMinHz, - appConfig.audioEngine.piano.lowpassMaxHz + this.engineConfig.piano.lowpassMinHz, + this.engineConfig.piano.lowpassMaxHz ); } @@ -862,7 +1223,7 @@ export class GenerativePianoEngine { return; } - const earliestStart = now + appConfig.audioEngine.piano.scheduleAheadSeconds; + const earliestStart = now + this.engineConfig.piano.scheduleAheadSeconds; if (this.nextBeatAt >= earliestStart) { return; } @@ -898,3 +1259,6 @@ export class GenerativePianoEngine { return values.map((_, index) => values[(index + offset) % values.length]); } } + +const mix = (from: number, to: number, amount: number): number => + from + (to - from) * clamp01(amount); diff --git a/src/audio/noise-burst-player.ts b/src/audio/noise-burst-player.ts index 3280978..31b3045 100644 --- a/src/audio/noise-burst-player.ts +++ b/src/audio/noise-burst-player.ts @@ -28,10 +28,7 @@ export class NoiseBurstPlayer { filter.type = 'bandpass'; filter.frequency.setValueAtTime(filterHz, scheduledStart); filter.Q.value = this.engineConfig.noiseBurst.filterQ; - envelope.gain.setValueAtTime( - this.engineConfig.noiseBurst.silentGain, - scheduledStart - ); + envelope.gain.setValueAtTime(this.engineConfig.noiseBurst.silentGain, scheduledStart); envelope.gain.exponentialRampToValueAtTime( Math.max(this.engineConfig.noiseBurst.silentGain, gain), scheduledStart + this.engineConfig.noiseBurst.attackSeconds diff --git a/src/audio/piano-sampler.ts b/src/audio/piano-sampler.ts index d0a678e..fab0e13 100644 --- a/src/audio/piano-sampler.ts +++ b/src/audio/piano-sampler.ts @@ -58,15 +58,6 @@ export class PianoSampler { const sample = this.findNearestSample(midi); if (!sample) { - this.playFallbackPluck({ - midi, - velocity, - startTime, - durationSeconds, - pan, - delaySend, - lowpassHz, - }); return; } @@ -84,7 +75,8 @@ export class PianoSampler { (this.engineConfig.piano.sustainBase + noteVelocity * this.engineConfig.piano.sustainVelocityRange); const sustainAt = - scheduledStart + Math.max(this.engineConfig.piano.minDurationSeconds, durationSeconds); + scheduledStart + + Math.max(this.engineConfig.piano.minDurationSeconds, durationSeconds); const releaseAt = sustainAt + sustainSeconds; const releaseSeconds = this.config.piano.releaseSeconds; const stopAt = releaseAt + releaseSeconds; @@ -108,10 +100,7 @@ export class PianoSampler { source.buffer = sample.buffer; source.playbackRate.setValueAtTime( - Math.pow( - 2, - (midi - sample.midi) / this.engineConfig.piano.pitchSemitonesPerOctave - ), + Math.pow(2, (midi - sample.midi) / this.engineConfig.piano.pitchSemitonesPerOctave), scheduledStart ); filter.type = 'lowpass'; @@ -140,11 +129,7 @@ export class PianoSampler { sustainSeconds * this.engineConfig.piano.sustainBase ) ); - gain.gain.setTargetAtTime( - this.engineConfig.piano.minGain, - releaseAt, - releaseSeconds - ); + gain.gain.setTargetAtTime(this.engineConfig.piano.minGain, releaseAt, releaseSeconds); panner.pan.setValueAtTime(clamp(pan, -1, 1), scheduledStart); source.connect(filter); @@ -196,90 +181,4 @@ export class PianoSampler { private trimActiveVoices(now: number): void { this.activeVoices = this.activeVoices.filter((voice) => voice.stopAt > now); } - - private playFallbackPluck({ - midi, - velocity, - startTime, - durationSeconds, - pan, - delaySend = 0, - lowpassHz = this.config.piano.lowpassHz, - }: PianoNote): void { - const { context, eventBus, delayInput } = this.graph; - if (!context || !eventBus) { - return; - } - - const scheduledStart = Math.max( - context.currentTime + this.engineConfig.piano.scheduleAheadSeconds, - startTime - ); - const oscillator = context.createOscillator(); - const filter = context.createBiquadFilter(); - const gain = context.createGain(); - const panner = context.createStereoPanner(); - let sendGain: GainNode | null = null; - const noteVelocity = clamp01(velocity); - const noteGainValue = Math.max( - this.engineConfig.piano.minGain, - this.config.piano.gain * noteVelocity * 0.42 - ); - const releaseAt = - scheduledStart + Math.max(this.engineConfig.piano.minDurationSeconds, durationSeconds); - const stopAt = releaseAt + this.config.piano.releaseSeconds; - - oscillator.type = 'triangle'; - oscillator.frequency.setValueAtTime( - 440 * Math.pow(2, (midi - 69) / appConfig.audioEngine.piano.pitchSemitonesPerOctave), - scheduledStart - ); - filter.type = 'lowpass'; - filter.frequency.setValueAtTime( - clamp( - lowpassHz * 0.72, - this.engineConfig.piano.lowpassMinHz, - this.engineConfig.piano.lowpassMaxHz - ), - scheduledStart - ); - filter.Q.value = this.engineConfig.piano.filterQ; - gain.gain.setValueAtTime(this.engineConfig.piano.minGain, scheduledStart); - gain.gain.exponentialRampToValueAtTime( - noteGainValue, - scheduledStart + this.engineConfig.piano.gainAttackSeconds - ); - gain.gain.setTargetAtTime( - this.engineConfig.piano.minGain, - releaseAt, - this.config.piano.releaseSeconds - ); - panner.pan.setValueAtTime(clamp(pan, -1, 1), scheduledStart); - - oscillator.connect(filter); - filter.connect(gain); - gain.connect(panner); - panner.connect(eventBus); - - if (delayInput && delaySend > 0) { - sendGain = context.createGain(); - sendGain.gain.value = delaySend * 0.5; - panner.connect(sendGain); - sendGain.connect(delayInput); - } - - oscillator.start(scheduledStart); - oscillator.stop(stopAt + this.engineConfig.piano.tailStopExtraSeconds); - oscillator.addEventListener( - 'ended', - () => { - oscillator.disconnect(); - filter.disconnect(); - gain.disconnect(); - panner.disconnect(); - sendGain?.disconnect(); - }, - { once: true } - ); - } } diff --git a/src/config.ts b/src/config.ts index d45cb61..a31e01a 100644 --- a/src/config.ts +++ b/src/config.ts @@ -169,8 +169,8 @@ export const appConfig = { }, menuHider: { bottomRevealDistancePx: 96, - intervalMs: 50, - timeToLiveMs: 3500, + desktopMediaQuery: '(min-width: 600px) and (hover: hover) and (pointer: fine)', + hideDelayMs: 3000, }, pipelines: { brush: { @@ -194,9 +194,6 @@ export const appConfig = { fpsHeadroom: 0.95, fpsSmoothingNew: 0.06, fpsSmoothingRetain: 0.94, - initialTargetAgentBudget: 20_000, - rampAgentsPerSecond: 20_000, - refreshTargetDecay: 0.995, }, brushEffectFramesPerSecond: 60, globalAgentCap: 10_000_000, diff --git a/src/config/color-interactions.ts b/src/config/color-interactions.ts index b6c870e..cfb5ff8 100644 --- a/src/config/color-interactions.ts +++ b/src/config/color-interactions.ts @@ -1,7 +1,4 @@ -import type { - AgentColorInteractionSettings, - NumberControlConfig, -} from './types'; +import type { AgentColorInteractionSettings, NumberControlConfig } from './types'; const agentInteractionOptions: Record = { Follow: 1, @@ -46,7 +43,8 @@ export const createColorInteractionSettings = ( const random = createSeededRandom(hashString(seedSource)); const values = Object.values(agentInteractionOptions); const randomInteraction = () => - values[Math.floor(random() * values.length)] ?? defaultColorInteractionSettings.color1ToColor2; + values[Math.floor(random() * values.length)] ?? + defaultColorInteractionSettings.color1ToColor2; return { color1ToColor1: 1, diff --git a/src/config/types.ts b/src/config/types.ts index 6557e4f..0bb8989 100644 --- a/src/config/types.ts +++ b/src/config/types.ts @@ -169,8 +169,8 @@ export interface GardenAppConfig { }; menuHider: { bottomRevealDistancePx: number; - intervalMs: number; - timeToLiveMs: number; + desktopMediaQuery: string; + hideDelayMs: number; }; pipelines: { brush: { @@ -197,9 +197,6 @@ export interface GardenAppConfig { fpsHeadroom: number; fpsSmoothingNew: number; fpsSmoothingRetain: number; - initialTargetAgentBudget: number; - rampAgentsPerSecond: number; - refreshTargetDecay: number; }; brushEffectFramesPerSecond: number; globalAgentCap: number; diff --git a/src/game-loop/agent-population.test.ts b/src/game-loop/agent-population.test.ts index 93dbcba..43e5917 100644 --- a/src/game-loop/agent-population.test.ts +++ b/src/game-loop/agent-population.test.ts @@ -32,14 +32,9 @@ const createPopulation = () => { return new AgentPopulation(pipeline); }; -const setPopulationCounts = ( - population: AgentPopulation, - activeCount: number, - targetBudget: number -) => { +const setPopulationActiveCount = (population: AgentPopulation, activeCount: number) => { Object.assign(population as unknown as Record, { activeCount, - targetBudget, }); }; @@ -60,7 +55,7 @@ describe('AgentPopulation adaptive budget', () => { it('expands beyond the 1M start cap only when new agents arrive under healthy FPS', () => { const population = createPopulation(); - setPopulationCounts(population, 1_000_000, 1_000_000); + setPopulationActiveCount(population, 1_000_000); population.growBudget(1 / 60, 60, 60); population.spawnStrokeAgents(vec2.fromValues(0, 0), vec2.fromValues(16, 0)); @@ -74,7 +69,7 @@ describe('AgentPopulation adaptive budget', () => { it('decreases the cap and active count slowly when FPS falls below the threshold', () => { const population = createPopulation(); - setPopulationCounts(population, 1_000_000, 1_000_000); + setPopulationActiveCount(population, 1_000_000); population.growBudget(10, 50, 60); diff --git a/src/game-loop/agent-population.ts b/src/game-loop/agent-population.ts index 8919472..ca47029 100644 --- a/src/game-loop/agent-population.ts +++ b/src/game-loop/agent-population.ts @@ -18,7 +18,6 @@ const ADAPTIVE_CAP_DECREASE_AGENTS_PER_SECOND = export class AgentPopulation { private activeCount = 0; - private targetBudget = appConfig.simulation.budget.initialTargetAgentBudget; private replacementCursor = 0; private canExpandAdaptiveCap = true; private shouldCompactAfterErase = false; @@ -33,24 +32,16 @@ export class AgentPopulation { return this.activeCount; } - public get targetAgentBudget(): number { - return this.targetBudget; - } - public get maxAgentCount(): number { return this.pipeline.maxAgentCount; } public initializeIntroAgents(canvasSize: vec2): void { settings.agentBudgetMax = this.clampAdaptiveCap(settings.agentBudgetMax); - this.targetBudget = Math.min( - this.pipeline.maxAgentCount, - settings.agentBudgetMax, - INITIAL_AGENT_COUNT - ); + const introAgentCount = Math.min(settings.agentBudgetMax, INITIAL_AGENT_COUNT); this.writeAgentBatch( createIntroTitleAgents({ - count: this.targetBudget, + count: introAgentCount, width: canvasSize[0], height: canvasSize[1], }) @@ -59,11 +50,7 @@ export class AgentPopulation { public onVibeChanged(): void { settings.agentBudgetMax = this.clampAdaptiveCap(settings.agentBudgetMax); - this.targetBudget = Math.min( - this.targetBudget, - settings.agentBudgetMax, - this.pipeline.maxAgentCount - ); + this.trimActiveCountToBudget(); } public growBudget( @@ -72,18 +59,6 @@ export class AgentPopulation { refreshTargetFps: number ): void { this.updateAdaptiveCap(deltaTime, smoothedFps, refreshTargetFps); - - const cap = this.clampAdaptiveCap(settings.agentBudgetMax); - if ( - this.targetBudget < cap && - smoothedFps > refreshTargetFps * appConfig.simulation.budget.fpsHeadroom - ) { - this.targetBudget = Math.min( - cap, - this.targetBudget + - Math.ceil(appConfig.simulation.budget.rampAgentsPerSecond * deltaTime) - ); - } } public resizeAgents(scale: vec2): void { @@ -110,7 +85,6 @@ export class AgentPopulation { this.activeCount = compactedAgentCount; this.replacementCursor = compactedAgentCount === 0 ? 0 : this.replacementCursor % compactedAgentCount; - this.targetBudget = Math.max(this.targetBudget, compactedAgentCount); } finally { this.isCompacting = false; } @@ -157,7 +131,7 @@ export class AgentPopulation { const count = data.length / AGENT_FLOAT_COUNT; this.expandAdaptiveCapForPendingAgents(count); - const available = Math.max(0, this.targetBudget - this.activeCount); + const available = Math.max(0, settings.agentBudgetMax - this.activeCount); const appendCount = Math.min(count, available); if (appendCount > 0) { @@ -196,10 +170,12 @@ export class AgentPopulation { ): void { const previousCap = this.clampAdaptiveCap(settings.agentBudgetMax); this.canExpandAdaptiveCap = + refreshTargetFps <= 0 || smoothedFps >= refreshTargetFps * appConfig.simulation.budget.fpsHeadroom; if (this.canExpandAdaptiveCap) { settings.agentBudgetMax = previousCap; + this.trimActiveCountToBudget(); return; } @@ -209,33 +185,31 @@ export class AgentPopulation { ); const nextCap = this.clampAdaptiveCap(previousCap - decrease); settings.agentBudgetMax = nextCap; - this.targetBudget = Math.min(this.targetBudget, nextCap); - - if (this.activeCount > this.targetBudget) { - this.activeCount = Math.max(this.targetBudget, this.activeCount - decrease); - this.replacementCursor = - this.activeCount === 0 ? 0 : this.replacementCursor % this.activeCount; - } + this.trimActiveCountToBudget(decrease); } private expandAdaptiveCapForPendingAgents(requestedAgentCount: number): void { - const available = Math.max(0, this.targetBudget - this.activeCount); + const available = Math.max(0, settings.agentBudgetMax - this.activeCount); if (requestedAgentCount <= available || !this.canExpandAdaptiveCap) { return; } const currentCap = this.clampAdaptiveCap(settings.agentBudgetMax); - if (this.targetBudget < currentCap) { + const pendingAgentCount = requestedAgentCount - available; + settings.agentBudgetMax = this.clampAdaptiveCap(currentCap + pendingAgentCount); + } + + private trimActiveCountToBudget(maxDecrease = Number.POSITIVE_INFINITY): void { + if (this.activeCount <= settings.agentBudgetMax) { return; } - const pendingAgentCount = requestedAgentCount - available; - const nextCap = this.clampAdaptiveCap(currentCap + pendingAgentCount); - settings.agentBudgetMax = nextCap; - this.targetBudget = Math.max( - this.targetBudget, - Math.min(nextCap, this.activeCount + requestedAgentCount) + this.activeCount = Math.max( + settings.agentBudgetMax, + this.activeCount - Math.max(1, Math.ceil(maxDecrease)) ); + this.replacementCursor = + this.activeCount === 0 ? 0 : this.replacementCursor % this.activeCount; } private clampAdaptiveCap(value: number): number { diff --git a/src/game-loop/frame-performance.test.ts b/src/game-loop/frame-performance.test.ts new file mode 100644 index 0000000..cd74739 --- /dev/null +++ b/src/game-loop/frame-performance.test.ts @@ -0,0 +1,47 @@ +import { describe, expect, it } from 'vitest'; + +import { FramePerformance } from './frame-performance'; + +describe('FramePerformance refresh target', () => { + it('uses 60 FPS as the fixed adaptive budget target', () => { + const performance = new FramePerformance(); + + [123, 126, 130, 121, 60, 30].forEach((fps) => performance.update(1 / fps)); + + expect(performance.refreshTargetFps).toBe(60); + }); + + it('keeps latest and smoothed FPS separate from the fixed target', () => { + const performance = new FramePerformance(); + + performance.update(1 / 120); + + expect(performance.latestFps).toBe(120); + expect(performance.smoothedFps).toBeGreaterThan(60); + expect(performance.refreshTargetFps).toBe(60); + }); + + it('snaps the display refresh estimate to a stable screen frequency', () => { + const performance = new FramePerformance(); + + [123, 126, 130, 121, 124, 127, 125, 122].forEach((fps) => + performance.update(1 / fps) + ); + + expect(performance.refreshTargetFps).toBe(60); + expect(performance.displayRefreshFps).toBe(120); + }); + + it('ignores a single startup spike before settling the display refresh estimate', () => { + const performance = new FramePerformance(); + + performance.update(1 / 240); + + expect(performance.displayRefreshFps).toBe(60); + + Array.from({ length: 8 }).forEach(() => performance.update(1 / 120)); + + expect(performance.refreshTargetFps).toBe(60); + expect(performance.displayRefreshFps).toBe(120); + }); +}); diff --git a/src/game-loop/frame-performance.ts b/src/game-loop/frame-performance.ts index 6edb415..ea82d71 100644 --- a/src/game-loop/frame-performance.ts +++ b/src/game-loop/frame-performance.ts @@ -4,18 +4,28 @@ interface TelemetrySnapshot { frameCpuStartedAt: number; encodeCpuMs: number; activeAgentCount: number; - targetAgentBudget: number; + agentBudgetMax: number; canvas: HTMLCanvasElement; devicePixelRatio: number; renderSpeed: number; } +const COMMON_DISPLAY_REFRESH_RATES = [ + 50, 60, 72, 75, 90, 100, 120, 144, 165, 180, 240, +] as const; +const DISPLAY_REFRESH_CONFIRMATION_FRAMES = 8; +const DISPLAY_REFRESH_SNAP_TOLERANCE = 0.15; + export class FramePerformance { public latestFps = 60; public smoothedFps = 60; - public refreshTargetFps = 60; + public displayRefreshFps = 60; + public readonly refreshTargetFps = 60; private lastTelemetryAt = 0; + private hasConfirmedDisplayRefreshFps = false; + private pendingDisplayRefreshFps = 0; + private pendingDisplayRefreshFrameCount = 0; public markCpuStart(): number { return appConfig.telemetry.enabled ? performance.now() : 0; @@ -28,10 +38,7 @@ export class FramePerformance { public update(deltaTime: number): void { const fps = 1 / Math.max(deltaTime, appConfig.deltaTime.minDeltaTimeSeconds); this.latestFps = fps; - this.refreshTargetFps = Math.max( - this.refreshTargetFps * appConfig.simulation.budget.refreshTargetDecay, - fps - ); + this.updateDisplayRefreshEstimate(fps); this.smoothedFps = this.smoothedFps * appConfig.simulation.budget.fpsSmoothingRetain + fps * appConfig.simulation.budget.fpsSmoothingNew; @@ -41,7 +48,7 @@ export class FramePerformance { frameCpuStartedAt, encodeCpuMs, activeAgentCount, - targetAgentBudget, + agentBudgetMax, canvas, devicePixelRatio, renderSpeed, @@ -60,8 +67,9 @@ export class FramePerformance { fps: Math.round(this.latestFps), smoothedFps: Math.round(this.smoothedFps), refreshTargetFps: Math.round(this.refreshTargetFps), + displayRefreshFps: Math.round(this.displayRefreshFps), activeAgentCount, - targetAgentBudget, + agentBudgetMax, canvasWidth: canvas.width, canvasHeight: canvas.height, dpr: devicePixelRatio, @@ -70,4 +78,61 @@ export class FramePerformance { encodeCpuMs, }); } + + private updateDisplayRefreshEstimate(fps: number): void { + const displayRefreshFps = this.snapDisplayRefreshRate(fps); + if (displayRefreshFps === null) { + this.resetPendingDisplayRefreshEstimate(); + return; + } + + if ( + this.hasConfirmedDisplayRefreshFps && + displayRefreshFps < this.displayRefreshFps + ) { + this.resetPendingDisplayRefreshEstimate(); + return; + } + + if (displayRefreshFps !== this.pendingDisplayRefreshFps) { + this.pendingDisplayRefreshFps = displayRefreshFps; + this.pendingDisplayRefreshFrameCount = 1; + } else { + this.pendingDisplayRefreshFrameCount += 1; + } + + if (this.pendingDisplayRefreshFrameCount < DISPLAY_REFRESH_CONFIRMATION_FRAMES) { + return; + } + + this.displayRefreshFps = displayRefreshFps; + this.hasConfirmedDisplayRefreshFps = true; + this.resetPendingDisplayRefreshEstimate(); + } + + private snapDisplayRefreshRate(fps: number): number | null { + if (!Number.isFinite(fps) || fps <= 0) { + return null; + } + + let nearestRefreshRate: number = COMMON_DISPLAY_REFRESH_RATES[0]; + let nearestDifference = Math.abs(fps - nearestRefreshRate); + + COMMON_DISPLAY_REFRESH_RATES.forEach((refreshRate) => { + const difference = Math.abs(fps - refreshRate); + if (difference < nearestDifference) { + nearestRefreshRate = refreshRate; + nearestDifference = difference; + } + }); + + return nearestDifference / nearestRefreshRate <= DISPLAY_REFRESH_SNAP_TOLERANCE + ? nearestRefreshRate + : null; + } + + private resetPendingDisplayRefreshEstimate(): void { + this.pendingDisplayRefreshFps = 0; + this.pendingDisplayRefreshFrameCount = 0; + } } diff --git a/src/game-loop/game-loop.ts b/src/game-loop/game-loop.ts index 01c8448..c919112 100644 --- a/src/game-loop/game-loop.ts +++ b/src/game-loop/game-loop.ts @@ -22,7 +22,11 @@ export default class GameLoop { private static readonly DEV_STATS_INTERVAL_MS = 250; private readonly resources: GameLoopResources; - private readonly audio = new GardenAudio(gardenAudioConfig); + private readonly audio = new GardenAudio( + gardenAudioConfig, + appConfig.audioEngine, + appConfig.simulation.maxMirrorSegmentCount + ); private readonly renderInputs = new RenderInputCache(); private readonly introPrompt: IntroPrompt; private readonly eraserPreview: EraserPreview; @@ -30,12 +34,13 @@ export default class GameLoop { private readonly agentPopulation: AgentPopulation; private readonly export4KRenderer: Export4KRenderer; private readonly framePerformance = new FramePerformance(); - private readonly devStatsElement: HTMLDivElement | null = null; + private readonly devStatsElement: HTMLDivElement | null; private readonly seed = Math.floor(Math.random() * 0xffffffff).toString(16); private readonly resizeListener = this.resize.bind(this); private readonly keydownListener: (event: KeyboardEvent) => void; private lastDevStatsUpdateAt = 0; + private isStatsOverlayPinned = false; private hasFinished = false; private readonly finished = Promise.withResolvers(); @@ -46,9 +51,8 @@ export default class GameLoop { ui: GardenUi ) { this.resize(); - if (import.meta.env.DEV) { - this.devStatsElement = this.createDevStatsElement(); - } + this.devStatsElement = this.createDevStatsElement(); + this.syncDevStatsVisibility(); this.resources = new GameLoopResources(canvas, device, this.canvasSize); this.introPrompt = new IntroPrompt(ui.prompt); this.eraserPreview = new EraserPreview(canvas, ui.eraserPreview); @@ -108,6 +112,17 @@ export default class GameLoop { this.audio.setMuted(isMuted); } + public setStatsOverlayPinned(isPinned: boolean): void { + const wasVisible = this.shouldShowDevStats; + this.isStatsOverlayPinned = isPinned; + this.syncDevStatsVisibility(); + + if (!wasVisible && this.shouldShowDevStats) { + this.lastDevStatsUpdateAt = Number.NEGATIVE_INFINITY; + this.updateDevStats(performance.now()); + } + } + public startAudio(userGesture = false): void { this.audio.start(activeVibe, { userGesture }); } @@ -205,7 +220,7 @@ export default class GameLoop { frameCpuStartedAt, encodeCpuMs, activeAgentCount: this.agentPopulation.activeAgentCount, - targetAgentBudget: this.agentPopulation.targetAgentBudget, + agentBudgetMax: settings.agentBudgetMax, canvas: this.canvas, devicePixelRatio: this.devicePixelRatio, renderSpeed: settings.renderSpeed, @@ -235,22 +250,31 @@ export default class GameLoop { private updateDevStats(time: DOMHighResTimeStamp): void { if ( !this.devStatsElement || + !this.shouldShowDevStats || time - this.lastDevStatsUpdateAt < GameLoop.DEV_STATS_INTERVAL_MS ) { return; } this.lastDevStatsUpdateAt = time; + const displayRefreshFps = Math.round(this.framePerformance.displayRefreshFps); this.devStatsElement.textContent = [ - `FPS ${this.framePerformance.smoothedFps.toFixed(1)} / ${Math.round( - this.framePerformance.refreshTargetFps - )}`, + `FPS ${this.framePerformance.smoothedFps.toFixed(1)} / ${displayRefreshFps}`, `Agents ${this.formatDevStatNumber(this.agentPopulation.activeAgentCount)}`, - `Target ${this.formatDevStatNumber(this.agentPopulation.targetAgentBudget)}`, `Cap ${this.formatDevStatNumber(settings.agentBudgetMax)}`, ].join('\n'); } + private syncDevStatsVisibility(): void { + if (!this.devStatsElement) { + return; + } + + const isVisible = this.shouldShowDevStats; + this.devStatsElement.hidden = !isVisible; + this.devStatsElement.setAttribute('aria-hidden', String(!isVisible)); + } + private formatDevStatNumber(value: number): string { return Math.max(0, Math.round(value)).toLocaleString('en-US'); } @@ -298,4 +322,8 @@ export default class GameLoop { : 1; return Math.min(GameLoop.MAX_MIRROR_SEGMENT_COUNT, Math.max(1, Math.round(count))); } + + private get shouldShowDevStats(): boolean { + return import.meta.env.DEV || this.isStatsOverlayPinned; + } } diff --git a/src/game-loop/pointer-input.test.ts b/src/game-loop/pointer-input.test.ts index 8f99179..dbe7381 100644 --- a/src/game-loop/pointer-input.test.ts +++ b/src/game-loop/pointer-input.test.ts @@ -183,6 +183,14 @@ describe('GardenPointerInput drawing startup', () => { expect(onStartDrawing).toHaveBeenCalledTimes(1); expect(audio.start).toHaveBeenCalledWith(expect.anything(), { userGesture: true }); expect(audio.beginGesture).toHaveBeenCalledTimes(1); + expect(audio.touchDown).toHaveBeenCalledWith( + expect.objectContaining({ + canvasSize: [300, 200], + colorIndex: 0, + position: expect.any(Float32Array), + }) + ); + expect(audio.stroke).not.toHaveBeenCalled(); expect(brushPipeline.addSwipeSegment).toHaveBeenCalledTimes(1); expect(spawnStrokeAgents).toHaveBeenCalledTimes(1); expect(canvas.capturedPointerIds).toEqual([9]); diff --git a/src/game-loop/pointer-input.ts b/src/game-loop/pointer-input.ts index 6ac1c35..f7c873c 100644 --- a/src/game-loop/pointer-input.ts +++ b/src/game-loop/pointer-input.ts @@ -110,11 +110,14 @@ export class GardenPointerInput { return; } + const position = this.getCanvasPointerPosition(event); this.options.audio.start(activeVibe, { userGesture: event.isTrusted }); this.options.audio.beginGesture(); this.options.audio.touchDown({ vibe: activeVibe, colorIndex: settings.selectedColorIndex, + position, + canvasSize: this.options.getCanvasSize(), mirrorSegmentCount: this.options.getMirrorSegmentCount(), pressure: this.getPointerPressure(event), pointerType: event.pointerType, @@ -174,12 +177,8 @@ export class GardenPointerInput { }; private addSwipeAt(event: PointerEvent, options: { emitAudio?: boolean } = {}): void { - const rect = this.canvas.getBoundingClientRect(); const devicePixelRatio = this.options.getDevicePixelRatio(); - const position = vec2.fromValues( - (event.clientX - rect.left) * devicePixelRatio, - (event.clientY - rect.top) * devicePixelRatio - ); + const position = this.getCanvasPointerPosition(event); const previousPosition = this.lastPointerPosition ?? position; const previousTimeMs = this.lastPointerEventTimeMs ?? event.timeStamp; const elapsedSeconds = Math.max( @@ -219,6 +218,7 @@ export class GardenPointerInput { isErasing: this.isErasing, pressure: pressure > 0 ? pressure : this.lastPointerPressure, velocityPixelsPerSecond, + elapsedSeconds, eraserSizePixels: settings.eraserSize * devicePixelRatio, mirrorSegmentCount: this.options.getMirrorSegmentCount(), pointerType: event.pointerType, @@ -228,6 +228,15 @@ export class GardenPointerInput { this.lastPointerEventTimeMs = event.timeStamp; } + private getCanvasPointerPosition(event: PointerEvent): vec2 { + const rect = this.canvas.getBoundingClientRect(); + const devicePixelRatio = this.options.getDevicePixelRatio(); + return vec2.fromValues( + (event.clientX - rect.left) * devicePixelRatio, + (event.clientY - rect.top) * devicePixelRatio + ); + } + private addSmoothedBrushSample(position: vec2): void { const previousSample = this.smoothedStrokePoints[this.smoothedStrokePoints.length - 1]; diff --git a/src/index.ts b/src/index.ts index c4a78a1..c191b60 100644 --- a/src/index.ts +++ b/src/index.ts @@ -68,9 +68,9 @@ const renderRuntimeMessage = ( }; const elements = { - aside: queryRequiredElement('aside', HTMLDivElement), + aside: queryRequiredElement('aside', HTMLElement), infoButton: queryRequiredElement('button.info', HTMLButtonElement), - infoElement: queryRequiredElement('.info-page', HTMLDivElement), + infoElement: queryRequiredElement('.info-page', HTMLElement), minimizeFullScreenButton: queryRequiredElement( 'button.minimize-full-screen', HTMLButtonElement @@ -84,20 +84,14 @@ const elements = { restartButton: queryRequiredElement('button.restart', HTMLButtonElement), canvas: queryRequiredElement('canvas', HTMLCanvasElement), eraserPreview: queryRequiredElement('.eraser-preview', HTMLDivElement), - errorContainer: queryRequiredElement('.errors-container', HTMLDivElement), + errorContainer: queryRequiredElement('.errors-container', HTMLElement), previousVibe: queryRequiredElement('.previous-vibe', HTMLButtonElement), nextVibe: queryRequiredElement('.next-vibe', HTMLButtonElement), swatches: queryRequiredElements('.color-swatch', HTMLButtonElement), eraserSizeControl: queryRequiredElement('.eraser-size-control', HTMLLabelElement), eraserSizeSlider: queryRequiredElement('.eraser-size-slider', HTMLInputElement), - mirrorSegmentControl: queryRequiredElement( - '.mirror-segment-control', - HTMLLabelElement - ), - mirrorSegmentSlider: queryRequiredElement( - '.mirror-segment-slider', - HTMLInputElement - ), + mirrorSegmentControl: queryRequiredElement('.mirror-segment-control', HTMLLabelElement), + mirrorSegmentSlider: queryRequiredElement('.mirror-segment-slider', HTMLInputElement), export4k: queryRequiredElement('.export-4k', HTMLButtonElement), exportStatus: queryRequiredElement('.export-status', HTMLSpanElement), prompt: queryRequiredElement('.garden-prompt', HTMLDivElement), @@ -222,6 +216,7 @@ const main = async () => { const configPane = new ConfigPane({ settingsButton: elements.settingsButton, onConfigChange: syncRuntimeUi, + onOpenChange: (isOpen) => game?.setStatsOverlayPinned(isOpen), onRuntimeChange: syncRuntimeUi, onRuntimeReset: () => { resetSettings(); @@ -241,8 +236,7 @@ const main = async () => { () => FullScreenHandler.isInFullScreenMode() && !configPane.isOpen && - !infoPageHandler.isOpen, - { persistentElement: elements.settingsButton } + !infoPageHandler.isOpen ); new FullScreenHandler( elements.minimizeFullScreenButton, @@ -250,13 +244,6 @@ const main = async () => { document.body ); - const fontsReady = document.fonts.ready.catch(() => undefined); - setLoadingStage('Connecting to GPU…', 0.1); - const gpu = await initializeGpu(); - setLoadingStage('Loading fonts…', 0.4); - await fontsReady; - setLoadingStage('Compiling shaders…', 0.7); - elements.restartButton.addEventListener('click', () => game?.destroy()); elements.soundButton.addEventListener('click', (event) => { isAudioMuted = !isAudioMuted; @@ -267,8 +254,6 @@ const main = async () => { } }); - const deltaTimeCalculator = new DeltaTimeCalculator(); - elements.previousVibe.addEventListener('click', (event) => { const current = VIBE_PRESETS.findIndex((vibe) => vibe.id === activeVibe.id); const vibe = @@ -345,6 +330,15 @@ const main = async () => { renderMirrorSegmentUi(); renderAudioUi(game); + const fontsReady = document.fonts.ready.catch(() => undefined); + setLoadingStage('Connecting to GPU…', 0.1); + const gpu = await initializeGpu(); + setLoadingStage('Loading fonts…', 0.4); + await fontsReady; + setLoadingStage('Compiling shaders…', 0.7); + + const deltaTimeCalculator = new DeltaTimeCalculator(); + let isFirstStart = true; while (!shouldStop) { game = new GameLoop(elements.canvas, gpu, deltaTimeCalculator, { @@ -352,6 +346,7 @@ const main = async () => { eraserPreview: elements.eraserPreview, exportStatus: elements.exportStatus, }); + game.setStatsOverlayPinned(configPane.isOpen); renderPaletteUi(game); renderEraserSizeUi(game); renderMirrorSegmentUi(); diff --git a/src/page/config-pane.ts b/src/page/config-pane.ts index bf27c78..730d691 100644 --- a/src/page/config-pane.ts +++ b/src/page/config-pane.ts @@ -38,6 +38,7 @@ const isColorReactionKey = (key: string): key is ColorReactionKey => interface ConfigPaneOptions { onConfigChange: () => void; + onOpenChange?: (isOpen: boolean) => void; onRestart: () => void; onRuntimeChange: () => void; onRuntimeReset: () => void; @@ -90,10 +91,7 @@ const getNumberBindingParams = ( export class ConfigPane { private readonly container: HTMLDivElement; private readonly pane: Pane; - private readonly colorReactionSelects = new Map< - ColorReactionKey, - HTMLSelectElement - >(); + private readonly colorReactionSelects = new Map(); private readonly colorReactionSwatches: Array<{ colorIndex: number; element: HTMLElement; @@ -139,7 +137,7 @@ export class ConfigPane { this.setUpRuntimeTab(tabs.pages[0]); this.setUpConfigTab(tabs.pages[1]); - this.syncButton(); + this.syncOpenState(); } public get isOpen(): boolean { @@ -150,17 +148,17 @@ export class ConfigPane { this.state.activeVibeId = activeVibe.id; this.pane.refresh(); this.syncColorReactionMatrix(); - this.syncButton(); + this.syncOpenState(); } private readonly toggle = () => { this.pane.hidden = !this.pane.hidden; - this.syncButton(); + this.syncOpenState(); }; private setHidden(isHidden: boolean): void { this.pane.hidden = isHidden; - this.syncButton(); + this.syncOpenState(); } private setUpRuntimeTab(container: PaneContainer): void { @@ -428,6 +426,11 @@ export class ConfigPane { : 'Show config overlay'; } + private syncOpenState(): void { + this.syncButton(); + this.options.onOpenChange?.(this.isOpen); + } + public close(): void { this.setHidden(true); } diff --git a/src/page/menu-hider.ts b/src/page/menu-hider.ts index b2ea459..5b26b0e 100644 --- a/src/page/menu-hider.ts +++ b/src/page/menu-hider.ts @@ -1,107 +1,144 @@ import { appConfig } from '../config'; -interface MenuHiderOptions { - persistentElement?: HTMLElement; -} - export class MenuHider { - private static readonly DEFAULT_TIME_TO_LIVE = appConfig.menuHider.timeToLiveMs; - private static readonly INTERVAL = appConfig.menuHider.intervalMs; - private static readonly BOTTOM_REVEAL_DISTANCE = - appConfig.menuHider.bottomRevealDistancePx; - private readonly interactiveElements: Array; - private timeToLive = MenuHider.DEFAULT_TIME_TO_LIVE; + private readonly desktopMediaQuery = window.matchMedia( + appConfig.menuHider.desktopMediaQuery + ); + private hideTimeout: number | undefined; private isHidden = false; + private pointerInside = false; public constructor( private readonly element: HTMLElement, - private readonly shouldBeHidden: () => boolean, - private readonly options: MenuHiderOptions = {} + private readonly shouldBeHidden: () => boolean ) { - this.interactiveElements = Array.from( - element.querySelectorAll( - 'a[href], button, input, select, textarea, [tabindex]' - ) + element.addEventListener('pointerenter', this.onPointerEnter); + element.addEventListener('pointerleave', this.onPointerLeave); + element.addEventListener('focusin', this.onFocusIn); + element.addEventListener('focusout', this.onFocusOut); + window.addEventListener('pointermove', this.onPointerMove, { passive: true }); + document.addEventListener('fullscreenchange', this.onVisibilityContextChange); + this.desktopMediaQuery.addEventListener('change', this.onVisibilityContextChange); + + this.reveal(); + } + + private get canAutoHide(): boolean { + return ( + this.desktopMediaQuery.matches && + this.shouldBeHidden() && + !this.pointerInside && + !this.element.contains(document.activeElement) ); - - if (options.persistentElement) { - element.classList.add('has-persistent-settings'); - } - - setInterval(() => { - this.timeToLive = Math.max(0, this.timeToLive - MenuHider.INTERVAL); - this.updateVisibility(); - }, MenuHider.INTERVAL); - - element.addEventListener('mouseover', this.wakeUp); - element.addEventListener('focusin', this.wakeUp); - element.addEventListener('pointerdown', this.wakeUp); - window.addEventListener('pointermove', this.wakeUpNearViewportBottom, { - passive: true, - }); - window.addEventListener('pointerdown', this.wakeUp, { - capture: true, - passive: true, - }); - window.addEventListener('touchstart', this.wakeUp, { - capture: true, - passive: true, - }); - window.addEventListener('keydown', this.wakeUp, { capture: true }); - window.addEventListener('focusin', this.wakeUp, { capture: true }); - - this.updateVisibility(); } - private readonly wakeUp = () => { - this.timeToLive = MenuHider.DEFAULT_TIME_TO_LIVE; - this.updateVisibility(); + private readonly onPointerEnter = () => { + this.pointerInside = true; + this.reveal(); }; - private readonly wakeUpNearViewportBottom = (event: PointerEvent) => { - const viewportHeight = window.innerHeight || document.documentElement.clientHeight; - const revealStart = viewportHeight - MenuHider.BOTTOM_REVEAL_DISTANCE; - - if (event.clientY >= revealStart) { - this.wakeUp(); - } + private readonly onPointerLeave = () => { + this.pointerInside = false; + this.scheduleHide(); }; - private updateVisibility() { - const focusWithin = this.element.contains(document.activeElement); - const shouldHide = this.timeToLive === 0 && this.shouldBeHidden() && !focusWithin; + private readonly onFocusIn = () => { + this.reveal(); + }; - if (this.isHidden === shouldHide) { + private readonly onFocusOut = () => { + window.setTimeout(() => this.scheduleHide(), 0); + }; + + private readonly onPointerMove = (event: PointerEvent) => { + if (!this.desktopMediaQuery.matches || !this.shouldBeHidden()) { + this.reveal(); return; } - this.isHidden = shouldHide; - this.element.classList.toggle('menu-hidden', shouldHide); - this.syncAccessibility(shouldHide); - } - - private syncAccessibility(shouldHide: boolean): void { - const persistentElement = this.options.persistentElement; - - if (!persistentElement) { - this.element.style.opacity = shouldHide ? '0' : '1'; - this.element.setAttribute('aria-hidden', String(shouldHide)); - this.element.inert = shouldHide; + if (this.isPointerOverDock(event.clientX, event.clientY)) { + this.pointerInside = true; + this.reveal(); return; } - this.element.style.opacity = ''; + this.pointerInside = false; + + if (this.isHidden) { + if (this.isNearViewportBottom(event.clientY)) { + this.reveal(); + this.scheduleHide(); + } + return; + } + + this.scheduleHide(); + }; + + private readonly onVisibilityContextChange = () => { + if (!this.desktopMediaQuery.matches || !this.shouldBeHidden()) { + this.reveal(); + return; + } + + this.scheduleHide(); + }; + + private scheduleHide(): void { + if (!this.canAutoHide) { + this.clearHideTimeout(); + this.reveal(); + return; + } + + if (this.hideTimeout !== undefined) { + return; + } + + this.hideTimeout = window.setTimeout(() => { + this.hideTimeout = undefined; + if (this.canAutoHide) { + this.hide(); + } + }, appConfig.menuHider.hideDelayMs); + } + + private reveal(): void { + this.clearHideTimeout(); + this.isHidden = false; + this.element.classList.remove('menu-hidden'); this.element.setAttribute('aria-hidden', 'false'); this.element.inert = false; + } - this.interactiveElements.forEach((interactiveElement) => { - const isPersistentElement = interactiveElement === persistentElement; + private hide(): void { + this.isHidden = true; + this.element.classList.add('menu-hidden'); + this.element.setAttribute('aria-hidden', 'true'); + this.element.inert = true; + } - interactiveElement.inert = shouldHide && !isPersistentElement; - interactiveElement.toggleAttribute( - 'aria-hidden', - shouldHide && !isPersistentElement - ); - }); + private clearHideTimeout(): void { + if (this.hideTimeout === undefined) { + return; + } + + window.clearTimeout(this.hideTimeout); + this.hideTimeout = undefined; + } + + private isPointerOverDock(clientX: number, clientY: number): boolean { + const rect = this.element.getBoundingClientRect(); + return ( + clientX >= rect.left && + clientX <= rect.right && + clientY >= rect.top && + clientY <= rect.bottom + ); + } + + private isNearViewportBottom(clientY: number): boolean { + const viewportHeight = window.innerHeight || document.documentElement.clientHeight; + return clientY >= viewportHeight - appConfig.menuHider.bottomRevealDistancePx; } } diff --git a/src/pipelines/agents/agent-pipeline.ts b/src/pipelines/agents/agent-pipeline.ts index 4e1515f..843fe83 100644 --- a/src/pipelines/agents/agent-pipeline.ts +++ b/src/pipelines/agents/agent-pipeline.ts @@ -106,6 +106,10 @@ export class AgentPipeline { trailMapOut: GPUTextureView, sourceMap: GPUTextureView ) { + if (this.agentCount <= 0) { + return; + } + const bindGroup = this.getBindGroup(trailMapIn, trailMapOut, sourceMap); const passEncoder = commandEncoder.beginComputePass(); diff --git a/src/pipelines/brush/brush-pipeline.ts b/src/pipelines/brush/brush-pipeline.ts index d228b79..6c4987c 100644 --- a/src/pipelines/brush/brush-pipeline.ts +++ b/src/pipelines/brush/brush-pipeline.ts @@ -21,6 +21,7 @@ export class BrushPipeline { private static readonly MAX_LINE_COUNT = appConfig.pipelines.brush.maxLineCount; private static readonly VERTICES_PER_LINE_SEGMENT = 6; private static readonly ATTRIBUTES_PER_LINE_SEGMENT = 6; + private static readonly FEATHER_RADIUS_RATIO = 0.22; private readonly bindGroupLayout: GPUBindGroupLayout; private readonly bindGroup: GPUBindGroup; @@ -92,6 +93,18 @@ export class BrushPipeline { targets: [ { format: 'rgba16float', + blend: { + color: { + operation: 'max', + srcFactor: 'one', + dstFactor: 'one', + }, + alpha: { + operation: 'max', + srcFactor: 'one', + dstFactor: 'one', + }, + }, }, ], }, @@ -143,8 +156,14 @@ export class BrushPipeline { selectedColorIndex, isErasing, }: BrushSettings & { selectedColorIndex: number; isErasing: boolean }) { - this.uniformValues[0] = brushSize / 2; - this.uniformValues[1] = Math.floor((brushSize / 2) * brushSizeVariation); + const brushRadius = brushSize / 2; + const brushRadiusVariation = Math.floor(brushRadius * brushSizeVariation); + const brushFeather = Math.max(1, brushRadius * BrushPipeline.FEATHER_RADIUS_RATIO); + const brushGeometryRadius = + brushRadius + Math.max(0, brushRadiusVariation) + brushFeather; + + this.uniformValues[0] = brushRadius; + this.uniformValues[1] = brushRadiusVariation; this.uniformValues[2] = 0; this.uniformValues[3] = 0; this.uniformValues[4] = !isErasing && selectedColorIndex === 0 ? 1 : 0; @@ -178,7 +197,7 @@ export class BrushPipeline { floatOffset, segment.from, segment.to, - brushSize / 2 + brushGeometryRadius ); } diff --git a/src/pipelines/render/render.wgsl b/src/pipelines/render/render.wgsl index 5864693..0e8f860 100644 --- a/src/pipelines/render/render.wgsl +++ b/src/pipelines/render/render.wgsl @@ -39,12 +39,13 @@ fn fragment(@location(0) uv: vec2) -> @location(0) vec4 { strengths.r * settings.colorA + strengths.g * settings.colorB + strengths.b * settings.colorC; + let normalizedTraceColor = traceColor / max(1.0, strengths.r + strengths.g + strengths.b); let brushColor = sourceStrengths.r * settings.colorA + sourceStrengths.g * settings.colorB + sourceStrengths.b * settings.colorC; let brushStrength = clamp(max(max(sourceStrengths.r, sourceStrengths.g), sourceStrengths.b), 0, 1); - let color = max(traceColor, brushColor * (1.2 + brushStrength * 1.6)); + let color = max(normalizedTraceColor, brushColor * (1.2 + brushStrength * 1.6)); let strength = clamp(max(max(max(strengths.r, strengths.g), strengths.b), brushStrength), 0, 1); diff --git a/src/style/_app-shell.scss b/src/style/_app-shell.scss index 86d78b9..c7e43ae 100644 --- a/src/style/_app-shell.scss +++ b/src/style/_app-shell.scss @@ -73,6 +73,10 @@ html > body { pointer-events: none; user-select: none; white-space: pre; + + &[hidden] { + display: none; + } } > .errors-container { diff --git a/src/style/_control-dock.scss b/src/style/_control-dock.scss index 6949610..b891e5e 100644 --- a/src/style/_control-dock.scss +++ b/src/style/_control-dock.scss @@ -1,17 +1,19 @@ html > body > aside.control-dock { + --dock-hidden-translate-y: calc(100% + env(safe-area-inset-bottom, 0px) + 16px); + position: absolute; - left: 50%; - bottom: env(safe-area-inset-bottom); + left: 0; + right: 0; + bottom: env(safe-area-inset-bottom, 0px); z-index: 4; width: min(calc(100vw - 1rem), 980px); - transform: translate(-50%, 0); - translate: 0 0; + margin: 0 auto; + transform: translateY(0); visibility: visible; pointer-events: none; transition: opacity var(--transition-time-long), transform var(--transition-time-long), - translate var(--transition-time-long), visibility 0s; > .toolbar-row, @@ -22,7 +24,7 @@ html > body > aside.control-dock { &.menu-hidden { opacity: 0; visibility: hidden; - transform: translate(-50%, 10px); + transform: translateY(var(--dock-hidden-translate-y)); pointer-events: none; transition: opacity var(--transition-time-long), @@ -34,32 +36,4 @@ html > body > aside.control-dock { pointer-events: none; } } - - &.menu-hidden.has-persistent-settings { - opacity: 1; - visibility: visible; - transform: translate(-50%, 0); - - > .pages, - > .toolbar-row > .vibe-button, - > .toolbar-row > .toolbar-shell > .garden-controls, - > .toolbar-row > .toolbar-shell > nav.buttons > button:not(.settings), - > .toolbar-row > .toolbar-shell > nav.buttons > .export-status { - opacity: 0; - visibility: hidden; - pointer-events: none; - } - - > .toolbar-row, - > .toolbar-row > .toolbar-shell, - > .toolbar-row > .toolbar-shell > nav.buttons { - pointer-events: none; - } - - > .toolbar-row > .toolbar-shell > nav.buttons > button.settings { - visibility: visible; - opacity: 1; - pointer-events: auto; - } - } } diff --git a/src/style/_loading.scss b/src/style/_loading.scss index ff97098..a8ca9b8 100644 --- a/src/style/_loading.scss +++ b/src/style/_loading.scss @@ -71,11 +71,7 @@ bottom: 0; width: var(--loading-progress); border-radius: inherit; - background: linear-gradient( - 90deg, - rgb(255 255 255 / 72%), - rgb(255 255 255 / 96%) - ); + background: linear-gradient(90deg, rgb(255 255 255 / 72%), rgb(255 255 255 / 96%)); box-shadow: 0 0 12px rgb(255 255 255 / 38%); transition: width var(--transition-time-long) ease-out; } @@ -94,7 +90,7 @@ html > body.is-loading { aside.control-dock { opacity: 0; visibility: hidden; - translate: 0 36px; + transform: translateY(var(--dock-hidden-translate-y)); } } diff --git a/src/style/_motion.scss b/src/style/_motion.scss index 005a69f..b12cc37 100644 --- a/src/style/_motion.scss +++ b/src/style/_motion.scss @@ -12,10 +12,7 @@ } > aside.control-dock { - &, - &.menu-hidden { - transform: translateX(-50%); - } + transform: translateY(0); > .toolbar-row { button:hover, @@ -30,5 +27,9 @@ } } } + + &.is-loading aside.control-dock { + transform: translateY(0); + } } } diff --git a/src/utils/graphics/get-workgroup-counts.test.ts b/src/utils/graphics/get-workgroup-counts.test.ts new file mode 100644 index 0000000..36dda74 --- /dev/null +++ b/src/utils/graphics/get-workgroup-counts.test.ts @@ -0,0 +1,34 @@ +import { describe, expect, it } from 'vitest'; + +import { getWorkgroupCounts } from './get-workgroup-counts'; + +const makeDevice = (maxComputeWorkgroupsPerDimension: number): GPUDevice => + ({ + limits: { + maxComputeWorkgroupsPerDimension, + }, + }) as GPUDevice; + +describe('getWorkgroupCounts', () => { + it('returns at least one workgroup for positive invocation counts', () => { + expect(getWorkgroupCounts(makeDevice(65_535), 1, 64)).toEqual([1, 1, 1]); + expect(getWorkgroupCounts(makeDevice(65_535), 65, 64)).toEqual([2, 1, 1]); + }); + + it('rejects zero and non-finite dispatch inputs', () => { + const device = makeDevice(65_535); + + expect(() => getWorkgroupCounts(device, 0, 64)).toThrow(/positive finite/); + expect(() => getWorkgroupCounts(device, -1, 64)).toThrow(/positive finite/); + expect(() => getWorkgroupCounts(device, Number.POSITIVE_INFINITY, 64)).toThrow( + /positive finite/ + ); + expect(() => getWorkgroupCounts(device, 1, 0)).toThrow(/positive finite/); + }); + + it('rejects invocation counts that exceed device workgroup limits', () => { + expect(() => getWorkgroupCounts(makeDevice(2), 9, 1)).toThrow( + 'Cannot have this many invocations' + ); + }); +}); diff --git a/src/utils/graphics/get-workgroup-counts.ts b/src/utils/graphics/get-workgroup-counts.ts index fe016e7..e6a648f 100644 --- a/src/utils/graphics/get-workgroup-counts.ts +++ b/src/utils/graphics/get-workgroup-counts.ts @@ -3,6 +3,17 @@ export const getWorkgroupCounts = ( invocationCount: number, workgroupSize: number ): [number, number, number] => { + if ( + !Number.isFinite(invocationCount) || + !Number.isFinite(workgroupSize) || + invocationCount <= 0 || + workgroupSize <= 0 + ) { + throw new Error( + 'Invocation count and workgroup size must be positive finite numbers' + ); + } + const workgroupCount = Math.ceil(invocationCount / workgroupSize); const workgroupCountX = Math.min( diff --git a/src/utils/graphics/initialize-context.ts b/src/utils/graphics/initialize-context.ts index 94d29c1..2a50c9e 100644 --- a/src/utils/graphics/initialize-context.ts +++ b/src/utils/graphics/initialize-context.ts @@ -7,7 +7,7 @@ export const initializeContext = ({ device: GPUDevice; canvas: HTMLCanvasElement; }): GPUCanvasContext => { - const context = canvas.getContext('webgpu' as any) as GPUCanvasContext | null; + const context = canvas.getContext('webgpu'); if (!context) { throw new RuntimeError( diff --git a/src/utils/graphics/initialize-gpu.test.ts b/src/utils/graphics/initialize-gpu.test.ts new file mode 100644 index 0000000..cdabd2b --- /dev/null +++ b/src/utils/graphics/initialize-gpu.test.ts @@ -0,0 +1,253 @@ +import { afterEach, describe, expect, it, vi } from 'vitest'; + +import { ErrorCode, ErrorHandler, RuntimeError, Severity } from '../error-handler'; +import { initializeGpu } from './initialize-gpu'; + +const gpuLimits = { + maxBufferSize: 256 * 1024 * 1024, + maxComputeWorkgroupsPerDimension: 65_535, + maxStorageBufferBindingSize: 128 * 1024 * 1024, +} as GPUSupportedLimits; + +const observedErrors: Array<{ + code?: string; + message: string; + severity: Severity; +}> = []; + +ErrorHandler.addOnErrorListener((error) => { + observedErrors.push(error); +}); + +const defer = () => { + let resolve!: (value: T) => void; + const promise = new Promise((nextResolve) => { + resolve = nextResolve; + }); + + return { promise, resolve }; +}; + +const stubBrowser = ({ + gpu, + isSecureContext = true, +}: { + gpu?: GPU; + isSecureContext?: boolean; +}) => { + vi.stubGlobal('window', { isSecureContext }); + vi.stubGlobal('navigator', { gpu }); +}; + +const createDevice = ( + lost: Promise = new Promise(() => {}) +) => { + const listeners = new Map(); + const device = { + addEventListener: vi.fn((type: string, listener: EventListener) => { + listeners.set(type, listener); + }), + lost, + } as unknown as GPUDevice; + + return { device, listeners }; +}; + +const createAdapter = ({ + requestDevice = vi.fn(), +}: { + requestDevice?: ReturnType; +} = {}) => + ({ + features: new Set(), + info: { + architecture: 'test', + description: 'unit-test adapter', + device: 'test-device', + isFallbackAdapter: false, + subgroupMaxSize: 0, + subgroupMinSize: 0, + vendor: 'test-vendor', + }, + limits: gpuLimits, + requestDevice, + }) as unknown as GPUAdapter; + +const captureInitializeGpuError = async (): Promise => { + try { + await initializeGpu(); + } catch (error) { + expect(error).toBeInstanceOf(RuntimeError); + return error as RuntimeError; + } + + throw new Error('Expected initializeGpu to reject.'); +}; + +describe('initializeGpu', () => { + afterEach(() => { + observedErrors.length = 0; + vi.restoreAllMocks(); + vi.unstubAllGlobals(); + }); + + it('rejects insecure contexts before touching WebGPU', async () => { + stubBrowser({ isSecureContext: false }); + + const error = await captureInitializeGpuError(); + + expect(error.code).toBe(ErrorCode.WEBGPU_INSECURE_CONTEXT); + expect(error.message).toContain('WebGPU requires a secure context'); + }); + + it('rejects browsers without navigator.gpu', async () => { + stubBrowser({}); + + const error = await captureInitializeGpuError(); + + expect(error.code).toBe(ErrorCode.WEBGPU_UNSUPPORTED); + expect(error.message).toContain('Fleeting Garden needs WebGPU'); + expect(error.details).toMatchObject({ + hasNavigatorGpu: false, + isSecureContext: true, + }); + }); + + it('wraps adapter request exceptions with adapter diagnostics', async () => { + const requestAdapter = vi.fn(async () => { + throw new Error('adapter request failed'); + }); + stubBrowser({ gpu: { requestAdapter } as unknown as GPU }); + + const error = await captureInitializeGpuError(); + + expect(requestAdapter).toHaveBeenCalledOnce(); + expect(requestAdapter).toHaveBeenCalledWith({ powerPreference: 'high-performance' }); + expect(error.code).toBe(ErrorCode.WEBGPU_ADAPTER_UNAVAILABLE); + expect(error.message).toBe('Could not request a WebGPU adapter.'); + expect(error.details).toMatchObject({ + causeMessage: 'adapter request failed', + powerPreference: 'high-performance', + }); + }); + + it('tries the default adapter before reporting adapter unavailability', async () => { + const requestAdapter = vi.fn(async () => null); + stubBrowser({ gpu: { requestAdapter } as unknown as GPU }); + + const error = await captureInitializeGpuError(); + + expect(requestAdapter).toHaveBeenNthCalledWith(1, { + powerPreference: 'high-performance', + }); + expect(requestAdapter).toHaveBeenNthCalledWith(2, undefined); + expect(error.code).toBe(ErrorCode.WEBGPU_ADAPTER_UNAVAILABLE); + expect(error.message).toContain('could not provide a compatible GPU adapter'); + }); + + it('requests the device with the adapter limits needed by the pipelines', async () => { + const { device } = createDevice(); + const requestDevice = vi.fn(async () => device); + const adapter = createAdapter({ requestDevice }); + stubBrowser({ + gpu: { requestAdapter: vi.fn(async () => adapter) } as unknown as GPU, + }); + + await expect(initializeGpu()).resolves.toBe(device); + + expect(requestDevice).toHaveBeenCalledWith({ + requiredLimits: { + maxBufferSize: gpuLimits.maxBufferSize, + maxComputeWorkgroupsPerDimension: gpuLimits.maxComputeWorkgroupsPerDimension, + maxStorageBufferBindingSize: gpuLimits.maxStorageBufferBindingSize, + }, + }); + expect(device.addEventListener).toHaveBeenCalledWith( + 'uncapturederror', + expect.any(Function) + ); + }); + + it('wraps device request failures with required limit details', async () => { + const requestDevice = vi.fn(async () => { + throw new Error('device request failed'); + }); + const adapter = createAdapter({ requestDevice }); + stubBrowser({ + gpu: { requestAdapter: vi.fn(async () => adapter) } as unknown as GPU, + }); + + const error = await captureInitializeGpuError(); + + expect(error.code).toBe(ErrorCode.WEBGPU_DEVICE_UNAVAILABLE); + expect(error.message).toBe('Could not create a WebGPU device for this adapter.'); + expect(error.details).toMatchObject({ + causeMessage: 'device request failed', + requiredLimits: { + maxBufferSize: gpuLimits.maxBufferSize, + maxComputeWorkgroupsPerDimension: gpuLimits.maxComputeWorkgroupsPerDimension, + maxStorageBufferBindingSize: gpuLimits.maxStorageBufferBindingSize, + }, + }); + }); + + it('routes uncaptured GPU errors through the runtime error handler', async () => { + const { device, listeners } = createDevice(); + const adapter = createAdapter({ requestDevice: vi.fn(async () => device) }); + stubBrowser({ + gpu: { requestAdapter: vi.fn(async () => adapter) } as unknown as GPU, + }); + + await initializeGpu(); + listeners.get('uncapturederror')?.({ + error: new Error('uncaptured GPU validation failure'), + } as unknown as GPUUncapturedErrorEvent); + + expect(observedErrors.at(-1)).toMatchObject({ + code: ErrorCode.WEBGPU_UNCAPTURED_ERROR, + message: 'uncaptured GPU validation failure', + severity: Severity.ERROR, + }); + }); + + it('reports unexpected device loss but ignores intentional destruction', async () => { + const unexpectedLoss = defer(); + const { device } = createDevice(unexpectedLoss.promise); + const adapter = createAdapter({ requestDevice: vi.fn(async () => device) }); + stubBrowser({ + gpu: { requestAdapter: vi.fn(async () => adapter) } as unknown as GPU, + }); + + await initializeGpu(); + unexpectedLoss.resolve({ + message: 'device lost during rendering', + reason: 'unknown', + } as GPUDeviceLostInfo); + await Promise.resolve(); + + expect(observedErrors.at(-1)).toMatchObject({ + code: ErrorCode.WEBGPU_DEVICE_LOST, + message: 'device lost during rendering', + severity: Severity.ERROR, + }); + + observedErrors.length = 0; + const destroyedLoss = defer(); + const { device: destroyedDevice } = createDevice(destroyedLoss.promise); + const destroyedAdapter = createAdapter({ + requestDevice: vi.fn(async () => destroyedDevice), + }); + stubBrowser({ + gpu: { requestAdapter: vi.fn(async () => destroyedAdapter) } as unknown as GPU, + }); + + await initializeGpu(); + destroyedLoss.resolve({ + message: 'device destroyed intentionally', + reason: 'destroyed', + } as GPUDeviceLostInfo); + await Promise.resolve(); + + expect(observedErrors).toEqual([]); + }); +}); diff --git a/tsconfig.json b/tsconfig.json index d2eb3bf..0c38b71 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -15,8 +15,8 @@ "forceConsistentCasingInFileNames": true, "strict": true, - "noUnusedLocals": false, - "noUnusedParameters": false + "noUnusedLocals": true, + "noUnusedParameters": true }, "include": ["src/**/*", "definitions.d.ts", "vite.config.ts"] }