From 3ebc22a306be99464f1ab54ee91889a9c297f0a9 Mon Sep 17 00:00:00 2001 From: "prompt.ac/@jeffrey" Date: Mon, 27 Jul 2026 20:33:58 -0700 Subject: [PATCH] Complete No Paint input and sound lifecycle --- docs/nopaint-construct-audio-spec.md | 19 +++- .../aesthetic.computer/disks/nopaint.mjs | 106 ++++++++++++++++-- tests/browser/nopaint-journey.test.mjs | 58 +++++++++- 3 files changed, 163 insertions(+), 20 deletions(-) diff --git a/docs/nopaint-construct-audio-spec.md b/docs/nopaint-construct-audio-spec.md index 0a04b2b1ec..c667620425 100644 --- a/docs/nopaint-construct-audio-spec.md +++ b/docs/nopaint-construct-audio-spec.md @@ -12,15 +12,17 @@ Audio `PlayByName`. Runtime playback is implemented in | Control phase | Canonical asset | Event-sheet evidence | |---|---|---| | No down | `generic - no button pressed (metal brush)` | Core / `CheckButtonsCore`, event 270 | -| No held crossing | `generic - button rollover` | Core / `CheckButtonsCore`, event 278 | +| No held crossing | `generic - no button pressed (metal brush)` | Core / `CheckButtonsCore`, event 270 | | No release | `generic - no button released (middle)` | Core / `No & Paint` / `No` | | Paint down | `generic - paint button pressed (psst)` | Core / `CheckButtonsCore`, event 281 | -| Paint held crossing | `generic - button rollover` | Core / `CheckButtonsCore`, event 289 | +| Paint held crossing | `generic - paint button pressed (psst)` | Core / `CheckButtonsCore`, event 281 | | Paint release | `generic - paint button released (cha)` | Core / `No & Paint` / `Paint` | +| Passive hover | `generic - button rollover` | Core / `CheckButtonsCore`, events 278 and 289 | A pointer may go down on one decision, remain held, cross the boundary, and -release on the other. Crossing announces the newly selected control; only the -control under release performs the decision. +release on the other. Crossing replays the destination's press cue; only the +control under release performs the decision. Passive mouse hover uses the +rollover cue once on entry rather than on every movement tick. ## Pause, completion, and saving @@ -36,7 +38,14 @@ control under release performs the decision. In No Paint 3.0, tapping the painting enters completion mode. Back returns to the decision loop; tapping the painting again is the same Back transition. -Done invokes the existing AC prompt `done` command. +Done invokes the existing AC prompt `done` command. Space and a drag beginning +on the painting both perform pause: pressing plays `pressing pause`, entering +plays `entering pause`, and resuming plays `pause release`. + +Long brush/theme playback is proposal-owned. It must be ended when a proposal +is replaced, the piece pauses, completion mode opens, the seed changes, or the +piece exits. Resuming restarts the current proposal's cue. One-shot decision +and primitive samples end naturally. ## Brush and tool cues diff --git a/system/public/aesthetic.computer/disks/nopaint.mjs b/system/public/aesthetic.computer/disks/nopaint.mjs index 989edfade7..f633006843 100644 --- a/system/public/aesthetic.computer/disks/nopaint.mjs +++ b/system/public/aesthetic.computer/disks/nopaint.mjs @@ -28,6 +28,8 @@ let archiveOrigin = null; let paintingResolution = null; let finishMode = false; let doneCount = 0; +let paintingDragPaused = false; +let hoveredDecision = null; const cueSamples = new Map(); let cueEvents = []; @@ -41,6 +43,7 @@ const LEGACY_CUES = Object.freeze({ back: "generic - pause release.webm", "done-down": "generic - save button pressed.webm", done: "generic - save button released.webm", + "pause-down": "generic - pressing pause.webm", "pause-in": "generic - entering pause.webm", "pause-out": "generic - pause release.webm", "primitive-release": "primitive - released.webm", @@ -62,6 +65,8 @@ const BRUSH_CUES = Object.freeze({ const brushCueSamples = new Map(); let brushCueProposal = 0; +let activeBrushSound = null; +let activeBrushKind = null; function playCue(api, name) { const fallback = () => api.sound?.synth?.({ @@ -86,12 +91,38 @@ function playBrushCue(api, kind) { if (brushCueProposal === proposalNumber) return; const sample = brushCueSamples.get(kind); if (!sample || !api.sound?.play) return; + stopBrushCue(); brushCueProposal = proposalNumber; + activeBrushKind = kind; cueEvents.push({ name: `brush:${kind}`, path: "legacy" }); - api.sound.play(sample, { volume: 0.48 }); + const playing = api.sound.play(sample, { volume: 0.48 }, { + kill: () => { + if (activeBrushSound !== playing) return; + activeBrushSound = null; + activeBrushKind = null; + publishTestState(); + }, + }); + activeBrushSound = playing; + publishTestState(); +} + +function stopBrushCue() { + if (!activeBrushSound) return; + const kind = activeBrushKind; + const playing = activeBrushSound; + activeBrushSound = null; + activeBrushKind = null; + playing.kill?.(0.04); + cueEvents.push({ name: `brush-stop:${kind}`, path: "lifecycle" }); publishTestState(); } +function resumeBrushCue(api) { + brushCueProposal = 0; + if (proposal?.kind) playBrushCue(api, proposal.kind); +} + function initialNavigationURL() { if (typeof window === "undefined") return null; const initial = performance.getEntriesByType?.("navigation")?.[0]?.name; @@ -128,7 +159,12 @@ function interfaceLayout(screen) { return { stage: viewport, bar: { x: 0, y: screen.height - barHeight, w: screen.width, h: barHeight }, - statusHeight, + status: { + x: 0, + y: screen.height - statusHeight, + w: screen.width, + h: statusHeight, + }, scale, }; } @@ -137,14 +173,14 @@ function positionButtons(screen) { // The recovered instrument keeps the decision pair together along the // bottom edge: No on the left, Paint larger on the right. They are the // architecture of the surface, not ordinary toolbar buttons. - const { bar, statusHeight } = interfaceLayout(screen); + const { bar, status } = interfaceLayout(screen); const gap = Math.max(4, Math.floor(screen.width * 0.006)); const margin = Math.max(6, Math.floor(screen.width * 0.008)); const available = screen.width - margin * 2 - gap; const noWidth = Math.floor(available * 0.38); const paintWidth = available - noWidth; - const buttonY = bar.y + statusHeight; - const decisionHeight = bar.h - statusHeight - margin; + const buttonY = bar.y + margin; + const decisionHeight = status.y - buttonY - margin; const no = noButton.btn || noButton; const paint = paintButton.btn || paintButton; no.box ||= {}; @@ -240,6 +276,7 @@ function clearProposal({ flatten, needsPaint, page, screen, system }) { } function chooseProposal(api) { + stopBrushCue(); transition("choosing"); const resolution = paintingResolution || { width: api.system.painting.width, @@ -360,8 +397,10 @@ function togglePaused(api) { if (loopState === "paused") { playCue(api, "pause-out"); transition(stateBeforePause); + resumeBrushCue(api); } else if (loopState === "proposing") { playCue(api, "pause-in"); + stopBrushCue(); stateBeforePause = loopState; transition("paused"); } @@ -413,6 +452,8 @@ function testSnapshot() { audio: { ready: [...cueSamples.keys()], brushReady: [...brushCueSamples.keys()], + activeBrush: activeBrushKind, + hovered: hoveredDecision, events: cueEvents.map((event) => ({ ...event })), }, lastDownload, @@ -428,6 +469,7 @@ function testSnapshot() { height: testApi.screen.height, }, controlBar: { ...layout.bar }, + modeline: { ...layout.status }, } : null, paintingFingerprint: paintingFingerprint(testApi?.system?.painting), origin: archiveOrigin ? { ...archiveOrigin } : null, @@ -490,8 +532,12 @@ function boot({ colon, debug, hud, net, num, params, screen, store, system, ui, saveCount = 0; finishMode = false; doneCount = 0; + paintingDragPaused = false; + hoveredDecision = null; cueEvents = []; brushCueProposal = 0; + activeBrushSound = null; + activeBrushKind = null; lastDownload = null; archiveOrigin = archiveId ? { type: "nopaint-archive", @@ -709,7 +755,7 @@ function paint($) { renderProposal($); $.system.nopaint.needsPresent = true; - const { bar, stage, scale } = interfaceLayout($.screen); + const { bar, stage, status, scale } = interfaceLayout($.screen); // Present the entire fixed-resolution painting above the controls. The // viewport may fit/letterbox responsively, but its pixels never reflow. $.wipe(18); @@ -720,7 +766,7 @@ function paint($) { $.ink(18).box(bar, "fill"); $.ink(255, 180).write( `No Paint ${NOPAINT_VERSION} · ${definition?.label || proposal.kind} ${proposalNumber}${paused} · seed ${sessionSeed}`, - { x: 8, y: bar.y + 5 }, + { x: 8, y: status.y + 5 }, ); positionButtons($.screen); @@ -745,16 +791,19 @@ function act($) { playCue($, "back"); finishMode = false; transition(stateBeforePause === "paused" ? "proposing" : stateBeforePause); + resumeBrushCue($); $.needsPaint(); publishTestState(); }; if (finishMode) { backButton.btn.act(e, { + hover: () => playCue($, "rollover"), down: () => playCue($, "button-down"), push: leaveFinishMode, }); doneButton.btn.act(e, { + hover: () => playCue($, "rollover"), down: () => playCue($, "done-down"), push: () => { playCue($, "done"); @@ -772,24 +821,51 @@ function act($) { return; } + const stage = interfaceLayout($.screen).stage; + if ((e.device === "mouse" || e.is("move")) && !noButton.btn.down && !paintButton.btn.down) { + const target = noButton.btn.box.contains(e) + ? "no" + : paintButton.btn.box.contains(e) + ? "paint" + : null; + if (target !== hoveredDecision) { + hoveredDecision = target; + if (target) playCue($, "rollover"); + } + } + if ( + loopState === "proposing" && + e.is("draw:1") && + e.drag && + e.drag.x >= stage.x && e.drag.x <= stage.x + stage.w && + e.drag.y >= stage.y && e.drag.y <= stage.y + stage.h + ) { + paintingDragPaused = true; + playCue($, "pause-down"); + togglePaused($); + return; + } + noButton.btn.act(e, { down: () => playCue($, "no-down"), - rollover: () => playCue($, "rollover"), cancel: () => playCue($, "back"), push: () => discardProposal($), }); paintButton.btn.act(e, { down: () => playCue($, "paint-down"), - rollover: () => playCue($, "rollover"), cancel: () => playCue($, "back"), push: () => commitProposal($), }); - const stage = interfaceLayout($.screen).stage; if ( e.is("lift:1") && e.x >= stage.x && e.x <= stage.x + stage.w && e.y >= stage.y && e.y <= stage.y + stage.h ) { + if (paintingDragPaused) { + paintingDragPaused = false; + return; + } + stopBrushCue(); finishMode = true; stateBeforePause = loopState; transition("paused"); @@ -799,6 +875,7 @@ function act($) { } if (isAny(e, [ "keyboard:down:n", + "keyboard:down:arrowleft", "keyboard:down:escape", "voice:no", "robot:no", @@ -808,12 +885,16 @@ function act($) { if (isAny(e, [ "keyboard:down:enter", "keyboard:down:p", + "keyboard:down:arrowright", "voice:paint", "robot:paint", "nopaint:paint", ])) commitProposal($); - if (e.is("keyboard:down:space")) togglePaused($); + if (e.is("keyboard:down:space")) { + playCue($, "pause-down"); + togglePaused($); + } } // The conductor makes decisions explicitly; generic pointer-lift baking must @@ -821,6 +902,7 @@ function act($) { function bake() {} function leave() { + stopBrushCue(); if (typeof window !== "undefined") delete window.__acNoPaintTest; testChannel?.close(); testChannel = null; @@ -831,7 +913,7 @@ function meta() { return { title: "No Paint", desc: "Collaborate with a proposing machine: press No to discard or Paint to keep.", - controls: "No: N/Escape · Paint: Enter/P · pause: Space · save/share: S", + controls: "No: Left/N/Escape · Paint: Right/Enter/P · pause: Space or drag painting", params: "optional deterministic session seed", example: "nopaint award-entry", }; diff --git a/tests/browser/nopaint-journey.test.mjs b/tests/browser/nopaint-journey.test.mjs index 1ac1754df5..4ca3181a5d 100644 --- a/tests/browser/nopaint-journey.test.mjs +++ b/tests/browser/nopaint-journey.test.mjs @@ -98,7 +98,7 @@ try { await ac.page.setViewport({ width: viewport.width, height: viewport.height }); await new Promise((resolve) => setTimeout(resolve, 350)); const state = await ac.nopaintState(); - const { paintingViewport: stage, controlBar: bar } = state.layout; + const { paintingViewport: stage, controlBar: bar, modeline } = state.layout; const resolution = state.layout.paintingResolution; const screenResolution = state.layout.screenResolution; const { no, paint } = state.controls; @@ -126,6 +126,8 @@ try { `${viewport.label}: controls stay inside bar`); expect(no.x + no.w <= paint.x && paint.x + paint.w <= bar.x + bar.w, `${viewport.label}: No and Paint do not overlap`); + expect(no.y + no.h <= modeline.y && paint.y + paint.h <= modeline.y, + `${viewport.label}: modeline stays below No and Paint`); } // Synthetic viewport probes may exceed the filming display. Return to the // harness's screen-safe size before taking any more native Frame receipts. @@ -138,7 +140,7 @@ try { await scenario("No rejects without changing the accepted painting", async (expect) => { const before = await ac.nopaintState(); - performanceResults.decisions.no = await ac.measureNopaintDecision("KeyN"); + performanceResults.decisions.no = await ac.measureNopaintDecision("ArrowLeft"); const after = await ac.nopaintState(); await receipt("02-after-no"); expect(after?.proposalNumber === 2, "No advances to proposal 2"); @@ -154,7 +156,7 @@ try { await scenario("Paint commits and persists the proposal score", async (expect) => { const before = await ac.nopaintState(); - performanceResults.decisions.paint = await ac.measureNopaintDecision("KeyP"); + performanceResults.decisions.paint = await ac.measureNopaintDecision("ArrowRight"); const after = await ac.nopaintState(); await receipt("03-after-paint"); expect(after?.proposalNumber === 3, "Paint advances to proposal 3"); @@ -196,6 +198,23 @@ try { expect(recentCues.includes("no-down"), "hold begins with the No press cue"); expect(recentCues.includes("paint-down") || recentCues.includes("rollover"), "crossing announces Paint"); expect(recentCues.includes("paint"), "release emits the Paint cue before the next brush theme"); + const stopIndex = after.audio.events.findLastIndex(({ name }) => name.startsWith("brush-stop:")); + const nextIndex = after.audio.events.findLastIndex(({ name }) => name === `brush:${after.operation}`); + expect(stopIndex >= 0 && nextIndex > stopIndex, "the old brush sound ends before the next cue starts"); + const rolloversBefore = after.audio.events.filter(({ name }) => name === "rollover").length; + for (let attempt = 0; attempt < 3; attempt += 1) { + await ac.page.mouse.move(rect.x + 2 + attempt, rect.y + 2 + attempt); + await ac.wait(100); + await ac.page.mouse.move(no.x, no.y); + await ac.wait(100); + const probe = await ac.nopaintState(); + if (probe.audio.events.filter(({ name }) => name === "rollover").length > rolloversBefore) break; + } + const hovered = await ac.nopaintState(); + expect( + hovered.audio.events.filter(({ name }) => name === "rollover").length > rolloversBefore, + `hovering No emits one recovered rollover cue (hovered=${hovered.audio.hovered})`, + ); }); await scenario("Pause freezes and resumes the live proposal", async (expect) => { @@ -205,12 +224,45 @@ try { const stillPaused = await ac.nopaintState(); await receipt("04-paused"); expect(paused?.state === "paused", `state is paused (got ${paused?.state})`); + expect(paused?.audio?.activeBrush === null, "pause ends the active brush sound"); + expect(paused?.audio?.events?.findLast(({ name }) => name === "pause-down")?.path === "legacy", + "pressing pause uses the recovered press sound"); + expect(paused?.audio?.events?.findLast(({ name }) => name === "pause-in")?.path === "legacy", + "entering pause uses the recovered pause sound"); expect(stillPaused?.proposalFrame === paused?.proposalFrame, "proposal frame freezes while paused"); await ac.press("Space"); await ac.wait(250); const resumed = await ac.nopaintState(); expect(resumed?.state === "proposing", "Space resumes proposing"); expect(resumed?.proposalFrame > stillPaused?.proposalFrame, "proposal animation resumes"); + expect(resumed?.audio?.events?.findLast(({ name }) => name.startsWith("brush:"))?.name === + `brush:${resumed?.operation}`, "unpause restarts the current brush sound"); + expect(resumed?.audio?.events?.findLast(({ name }) => name === "pause-out")?.path === "legacy", + "unpause uses the recovered pause-release sound"); + + const rect = await ac.page.evaluate(() => Array.from(document.querySelectorAll("canvas")) + .map((canvas) => { + const box = canvas.getBoundingClientRect(); + return { x: box.x, y: box.y, width: box.width, height: box.height }; + }) + .filter((box) => box.width > 0 && box.height > 0) + .sort((a, b) => b.width * b.height - a.width * a.height)[0]); + const stage = resumed.layout.paintingViewport; + const screen = resumed.layout.screenResolution; + const start = { + x: rect.x + (stage.x + stage.w * 0.35) * rect.width / screen.width, + y: rect.y + (stage.y + stage.h * 0.35) * rect.height / screen.height, + }; + await ac.page.mouse.move(start.x, start.y); + await ac.page.mouse.down(); + await ac.page.mouse.move(start.x + 48, start.y + 36, { steps: 5 }); + await ac.page.mouse.up(); + await ac.wait(150); + const dragPaused = await ac.nopaintState(); + expect(dragPaused?.state === "paused", "dragging from the painting always pauses"); + expect(dragPaused?.finishMode === false, "the drag release cannot open Done mode"); + await ac.press("Space"); + await ac.wait(150); }); await scenario("Proposal performance produces an automated profile", async (expect) => { -- 2.51.2