From b6998eac198d71ef56d7afe495b43f335a1d0a52 Mon Sep 17 00:00:00 2001 From: "prompt.ac/@jeffrey" Date: Thu, 17 Sep 2026 21:14:47 -0700 Subject: [PATCH] add aesel sound waveforms and keep notebook motion and scrolling together --- easel/README.md | 6 +- easel/desktop/donkey.css | 11 ++ easel/desktop/index.html | 2 +- easel/desktop/notebook-donkey.js | 6 +- easel/desktop/notebook-layout.js | 74 +++++--- easel/desktop/package-lock.json | 4 +- easel/desktop/package.json | 2 +- easel/desktop/preview-waveform.js | 91 +++++++++ easel/desktop/renderer.js | 8 +- easel/desktop/scripts/linux-download.html | 4 +- easel/desktop/transcript.js | 10 +- easel/package.json | 2 +- easel/test/desktop-interaction.electron.cjs | 2 +- easel/test/notebook-scroll.electron.cjs | 117 ++++++++++++ easel/test/preview-waveform.electron.cjs | 175 ++++++++++++++++++ system/public/aesel/index.html | 4 +- system/public/aesthetic.computer/bios.mjs | 7 + .../lib/output-waveform.mjs | 42 +++++ 18 files changed, 517 insertions(+), 50 deletions(-) create mode 100644 easel/desktop/preview-waveform.js create mode 100644 easel/test/notebook-scroll.electron.cjs create mode 100644 easel/test/preview-waveform.electron.cjs create mode 100644 system/public/aesthetic.computer/lib/output-waveform.mjs diff --git a/easel/README.md b/easel/README.md index aee7268b12..4283752cc7 100644 --- a/easel/README.md +++ b/easel/README.md @@ -77,7 +77,11 @@ manually resized footprint, with no hover zoom. Clicking it gives the piece its keyboard; clicking the notebook returns to message input. The donkey's temporary parenthetical thought bubble follows public activity and clears when the run ends. New desktop windows show a themed notebook on first paint, before engine startup; -terminal initialization stays hidden. +terminal initialization stays hidden. A thin waveform behind the title follows +actual speaker output and fades away in silence. The donkey and thought bubble +stay together and share a small bounce (still with reduced motion enabled). +Preview wrapping settles before paint so window-height changes do not rebound +through the conversation; new replies follow the bottom only when you are there. `/about`, or clicking **AESEL**, opens the feature map. Click **@handle** to open your profile in a browser. Header targets highlight on hover in terminals that diff --git a/easel/desktop/donkey.css b/easel/desktop/donkey.css index b47ac8fed6..cdc4427720 100644 --- a/easel/desktop/donkey.css +++ b/easel/desktop/donkey.css @@ -311,3 +311,14 @@ body:not(.preview-fullscreen) #artifact-shell[data-resized]{width:var(--resized- #activity-caption::before{width:5px;height:5px;left:-9px} #activity-caption::after{width:2px;height:2px;left:-15px;top:72%} #activity-caption[hidden]{display:none} + +#preview-waveform{position:fixed;left:14px;width:calc(100% - 28px);height:32px;z-index:4;pointer-events:none;opacity:0;transition:opacity .35s;overflow:hidden} +#preview-waveform.sounding{opacity:.45} +#preview-waveform path{fill:none;stroke:var(--aesel-accent,#ff78b2);stroke-width:1;stroke-linejoin:round;stroke-linecap:round} +body.preview-fullscreen #preview-waveform{display:none} +@media(prefers-reduced-motion:reduce){#preview-waveform{transition:none}} + +#notebook-activity{display:inline-flex;flex-wrap:nowrap;align-items:center;vertical-align:middle;max-width:100%;white-space:normal} +#notebook-activity[hidden]{display:none} +#notebook-activity #prompt-feedback{flex:none} +#notebook-activity #activity-caption{flex:0 1 auto;min-width:0;max-width:34ch;box-sizing:border-box;translate:0 var(--activity-bounce,0px)} diff --git a/easel/desktop/index.html b/easel/desktop/index.html index 4c2a951be1..260e0cd9f5 100644 --- a/easel/desktop/index.html +++ b/easel/desktop/index.html @@ -5,4 +5,4 @@
- + diff --git a/easel/desktop/notebook-donkey.js b/easel/desktop/notebook-donkey.js index c0fce5f598..b72fdad001 100644 --- a/easel/desktop/notebook-donkey.js +++ b/easel/desktop/notebook-donkey.js @@ -13,7 +13,11 @@ window.installNotebookDonkey=anchor=>{ canvas.style.left=`${Math.max(10,Math.min(innerWidth-width-8,rect.left+8))}px`; canvas.style.top=`${Math.max(sheet.top,rect.top+(rect.height-size)/2)}px`; const dpr=Math.min(devicePixelRatio||1,2),pixels=Math.round(size*dpr),pixelWidth=Math.round(width*dpr);if(canvas.width!==pixelWidth||canvas.height!==pixels){canvas.width=pixelWidth;canvas.height=pixels;} - const box=boxes[reduced.matches?0:Math.floor(performance.now()/90)%8],scale=Math.min(pixelWidth/box.width,pixels/box.height); + const step=reduced.matches?0:Math.floor(performance.now()/90)%8; + const bounce=reduced.matches?0:[0,-1,-2,-1,0,1,2,1][step]; + canvas.style.translate=`0 ${bounce}px`; + anchor.parentElement.style.setProperty('--activity-bounce',`${bounce}px`); + const box=boxes[step],scale=Math.min(pixelWidth/box.width,pixels/box.height); context.clearRect(0,0,pixelWidth,pixels);context.drawImage(source,box.x,box.y,box.width,box.height,(pixelWidth-box.width*scale)/2,(pixels-box.height*scale)/2,box.width*scale,box.height*scale); canvas.hidden=false;if(!reduced.matches){timer=setTimeout(paint,90);} } diff --git a/easel/desktop/notebook-layout.js b/easel/desktop/notebook-layout.js index 58cb3fdca5..380c41d5a7 100644 --- a/easel/desktop/notebook-layout.js +++ b/easel/desktop/notebook-layout.js @@ -10,43 +10,59 @@ exclusion.id = "notebook-preview-space"; exclusion.setAttribute("aria-hidden", "true"); content.prepend(exclusion); - let frame = 0; + let frame = 0, + previousHeight = view.clientHeight, + atBottom = true; const set = (name, value) => { if (exclusion.style.getPropertyValue(name) !== value) exclusion.style.setProperty(name, value); }; const layout = () => { frame = 0; - const box = preview.getBoundingClientRect(), - page = content.getBoundingClientRect(); - const visible = - !preview.hidden && - !view.hidden && - !document.body.classList.contains("preview-fullscreen") && - box.width > 0 && - box.height > 0; - const gap = 16, - bottom = box.bottom - page.top + gap; - const width = Math.min( - page.width, - Math.max(0, page.right - box.left + gap), - ); - const reserve = page.width - width < 220 ? page.width : width; - exclusion.hidden = !visible || bottom <= 0 || width <= 0; - if (!exclusion.hidden) { - set("width", `${Math.ceil(reserve)}px`); - set("height", `${Math.ceil(bottom)}px`); - set( - "shape-outside", - `inset(${Math.max(0, Math.floor(box.top - page.top - gap))}px 0 0 0)`, + const height = view.clientHeight; + const pin = + view.scrollHeight - view.scrollTop - height <= 2 || + (height !== previousHeight && atBottom); + // Float reflow can shrink scrollHeight and clamp scrollTop. Resolve that + // feedback in this frame instead of letting scroll events bounce the page. + for (let pass = 0; pass < 24; pass++) { + const before = view.scrollTop; + const box = preview.getBoundingClientRect(), + page = content.getBoundingClientRect(); + const visible = + !preview.hidden && + !view.hidden && + !document.body.classList.contains("preview-fullscreen") && + box.width > 0 && + box.height > 0; + const gap = 16, + bottom = box.bottom - page.top + gap; + const width = Math.min( + page.width, + Math.max(0, page.right - box.left + gap), ); + const reserve = page.width - width < 220 ? page.width : width; + exclusion.hidden = !visible || bottom <= 0 || width <= 0; + if (!exclusion.hidden) { + set("width", `${Math.ceil(reserve)}px`); + set("height", `${Math.ceil(bottom)}px`); + set( + "shape-outside", + `inset(${Math.max(0, Math.floor(box.top - page.top - gap))}px 0 0 0)`, + ); + } + if (title) { + const available = visible + ? Math.max(0, box.left - title.getBoundingClientRect().left - gap) + : view.clientWidth - 28; + const maxWidth = `${available}px`; + if (title.style.maxWidth !== maxWidth) title.style.maxWidth = maxWidth; + } + if (pin) view.scrollTop = view.scrollHeight; + if (Math.abs(view.scrollTop - before) < 0.5) break; } - if (title) { - const available = visible - ? Math.max(0, box.left - title.getBoundingClientRect().left - gap) - : view.clientWidth - 28; - title.style.maxWidth = `${available}px`; - } + previousHeight = height; + atBottom = view.scrollHeight - view.scrollTop - height <= 2; }; window.layoutNotebookPreview = () => { if (!frame) frame = requestAnimationFrame(layout); diff --git a/easel/desktop/package-lock.json b/easel/desktop/package-lock.json index 9ccc3fcd39..7b9b90bdee 100644 --- a/easel/desktop/package-lock.json +++ b/easel/desktop/package-lock.json @@ -1,12 +1,12 @@ { "name": "aesel-desktop", - "version": "0.7.6", + "version": "0.7.7", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "aesel-desktop", - "version": "0.7.6", + "version": "0.7.7", "dependencies": { "@xterm/addon-fit": "0.10.0", "@xterm/addon-webgl": "0.18.0", diff --git a/easel/desktop/package.json b/easel/desktop/package.json index 1146115abf..65aca26e99 100644 --- a/easel/desktop/package.json +++ b/easel/desktop/package.json @@ -1,7 +1,7 @@ { "name": "aesel-desktop", "productName": "aesel", - "version": "0.7.6", + "version": "0.7.7", "private": true, "main": "main.cjs", "scripts": { diff --git a/easel/desktop/preview-waveform.js b/easel/desktop/preview-waveform.js new file mode 100644 index 0000000000..c22c7d4e13 --- /dev/null +++ b/easel/desktop/preview-waveform.js @@ -0,0 +1,91 @@ +window.installPreviewWaveform = (preview) => { + const ns = "http://www.w3.org/2000/svg"; + const line = document.createElementNS(ns, "svg"); + line.id = "preview-waveform"; + line.setAttribute("viewBox", "0 0 127 32"); + line.setAttribute("preserveAspectRatio", "none"); + line.setAttribute("aria-hidden", "true"); + const path = document.createElementNS(ns, "path"); + path.setAttribute("vector-effect", "non-scaling-stroke"); + line.append(path); + document.body.append(line); + const motion = matchMedia("(prefers-reduced-motion: reduce)"); + let ready = false, + timer = 0, + generation = 0, + lastSound = 0; + const clear = () => { + line.classList.remove("sounding"); + lastSound = 0; + }; + async function poll() { + const current = generation; + let active = false; + try { + if ( + !ready || + document.hidden || + document.body.classList.contains("preview-fullscreen") || + preview.hidden || + preview.isAudioMuted() + ) { + clear(); + return; + } + const samples = await preview.executeJavaScript( + "window.AC?.readOutputWaveform?.() || []", + ); + if (current !== generation || !ready) return; + const values = Array.isArray(samples) + ? samples + .slice(0, 128) + .map((v) => (Number.isFinite(v) ? Math.max(-1, Math.min(1, v)) : 0)) + : []; + active = values.some((v) => Math.abs(v) > 0.0005); + if (active) lastSound = performance.now(); + line.classList.toggle( + "sounding", + !!lastSound && performance.now() - lastSound < 600, + ); + if (!line.classList.contains("sounding")) return; + const title = document.getElementById("qr-label").getBoundingClientRect(); + const top = document + .getElementById("artifact-shell") + .getBoundingClientRect().top; + line.style.top = `${Math.max(0, (title.height ? title.top + title.height / 2 : top + 8) - 16)}px`; + path.setAttribute( + "d", + motion.matches || !active + ? "M0 16H127" + : values + .map( + (v, i) => + `${i ? "L" : "M"}${(i * 127) / Math.max(1, values.length - 1)} ${(16 - Math.tanh(v * 4) * 13).toFixed(2)}`, + ) + .join(""), + ); + } catch { + clear(); + } finally { + if (current === generation && ready) + timer = setTimeout(poll, active && !motion.matches ? 50 : 200); + } + } + const stop = () => { + ready = false; + generation++; + clearTimeout(timer); + clear(); + }; + preview.addEventListener("did-start-loading", stop); + preview.addEventListener("destroyed", stop); + preview.addEventListener("dom-ready", () => { + stop(); + ready = true; + void poll(); + }); + document.addEventListener("visibilitychange", () => { + if (document.hidden) clear(); + }); + window.addEventListener("beforeunload", stop, { once: true }); +}; diff --git a/easel/desktop/renderer.js b/easel/desktop/renderer.js index cc8c34c3c9..80aecf6ac4 100644 --- a/easel/desktop/renderer.js +++ b/easel/desktop/renderer.js @@ -176,12 +176,12 @@ const promptLine=document.createElement('div');promptLine.id='prose-prompt';prom promptLine.setAttribute('aria-hidden','true');document.getElementById('notebook-page').append(promptLine); const promptFeedback=document.createElement('span');promptFeedback.id='prompt-feedback';promptFeedback.hidden=true;document.body.append(promptFeedback); const activityCaption=document.createElement('span');activityCaption.id='activity-caption';activityCaption.hidden=true;activityCaption.setAttribute('role','status');activityCaption.setAttribute('aria-live','off'); +const activityGroup=document.createElement('span');activityGroup.id='notebook-activity';activityGroup.hidden=true;activityGroup.append(promptFeedback,activityCaption);document.body.append(activityGroup); window.installNotebookDonkey(promptFeedback); window.placeNotebookActivity=()=>{ const last=Array.from(document.querySelectorAll('#notebook-page article[data-kind="user"]')).at(-1); const target=promptState?.text?promptLine:(last?.lastElementChild||last||promptLine); - if(promptFeedback.parentElement!==target)target.append(promptFeedback); - if(activityCaption.parentElement!==target||promptFeedback.nextSibling!==activityCaption)promptFeedback.after(activityCaption); + if(activityGroup.parentElement!==target)target.append(activityGroup); }; let promptState=null; function positionPrompt(){ @@ -196,7 +196,8 @@ function updatePrompt(value){ document.body.dataset.notebookReady='true'; const draftChanged=promptState&&(promptState.text!==value.text||promptState.cursor!==value.cursor); promptState=value;promptLine.hidden=!!value.hidden;positionPrompt(); - activityCaption.hidden=!!value.hidden||!value.feedback||!value.activity; + activityGroup.hidden=!!value.hidden||!value.feedback; + activityCaption.hidden=activityGroup.hidden||!value.activity; activityCaption.textContent=activityCaption.hidden?'':`(${value.activity})`; promptFeedback.textContent='';promptFeedback.setAttribute('role','img');promptFeedback.setAttribute('aria-label',value.feedback||'Idle');promptFeedback.hidden=!!value.hidden||!value.feedback; const chars=Array.from(value.text||''),index=Math.max(0,Math.min(chars.length,value.cursor??chars.length)); @@ -446,6 +447,7 @@ window.addEventListener('blur',()=>forwardGamepad([])); const artifact = document.getElementById('artifact'); window.installPreviewShutter(artifact); +window.installPreviewWaveform(preview); document.getElementById('version').setAttribute('aria-label','Piece version'); const previewViewport = document.getElementById('preview-viewport'); // The guest keeps a display-aspect viewport, 128 pixels high. Only its composited surface diff --git a/easel/desktop/scripts/linux-download.html b/easel/desktop/scripts/linux-download.html index adc267cb47..5c700e6dab 100644 --- a/easel/desktop/scripts/linux-download.html +++ b/easel/desktop/scripts/linux-download.html @@ -12,8 +12,8 @@ sudo apt install ./Easel-0.7.0-linux-amd64.deb
chmod +x Easel-0.7.0-linux-x86_64.AppImage
 ./Easel-0.7.0-linux-x86_64.AppImage

macOS

-

Download for Apple silicon

-

ZIP archive · signed and notarized · macOS 12+

+

Download for Apple silicon

+

ZIP archive · signed and notarized · macOS 12+

If FUSE is unavailable, try --appimage-extract-and-run. On Ubuntu, use the .deb above to install the required sandbox. Do not disable the sandbox.

aesel requires sharing conversations and artifact revision references with authorized Aesthetic Computer staff for product improvement, including when using your own provider. Read and accept the disclosure before use. Hosted generation requires account access; bringing your own Claude or Codex requires its CLI and account.

Tested on Ubuntu 24.04 x86-64. ARM builds are not included. SHA-256 checksums · Aesthetic Computer

diff --git a/easel/desktop/transcript.js b/easel/desktop/transcript.js index 3a8cb99a0d..1dbe1b7edc 100644 --- a/easel/desktop/transcript.js +++ b/easel/desktop/transcript.js @@ -8,12 +8,10 @@ const prose=document.createElement('div');prose.id='notebook-content';paper.append(prose); window.alignNotebookRuling=()=>{ const spacing=parseFloat(getComputedStyle(view).lineHeight)||24; - const titleHeight=title&&!title.hidden?title.getBoundingClientRect().height:0; - paper.style.minHeight=`${Math.max(0,Math.floor((view.clientHeight-titleHeight)/spacing))*spacing}px`; const origin=paper.getBoundingClientRect().top; - const clipped=Math.max(0,view.getBoundingClientRect().top-origin); - const clip=Math.ceil((clipped-.01)/spacing)*spacing; - paper.style.clipPath='none'; + const pageTop=origin-view.getBoundingClientRect().top+view.scrollTop; + const minimum=`${Math.max(0,Math.floor((view.clientHeight-pageTop)/spacing))*spacing}px`; + if(paper.style.minHeight!==minimum)paper.style.minHeight=minimum; document.documentElement.style.setProperty('--notebook-offset',`${origin%spacing}px`); window.layoutNotebookPreview?.(); }; @@ -53,7 +51,7 @@ const newestAssistant=value.entries.filter(entry=>entry.kind==='assistant'&&entry.text?.trim()).at(-1)?.id; latest=value; view.hidden=!!value.hidden; - const stick=view.scrollHeight-view.scrollTop-view.clientHeight<40; + const stick=view.scrollHeight-view.scrollTop-view.clientHeight<=2; const selection=window.getSelection(); if(selection&&!selection.isCollapsed&&view.contains(selection.anchorNode)){view.pending=value;return;} const ids=new Set(); diff --git a/easel/package.json b/easel/package.json index 74366473e1..d40b4c2acf 100644 --- a/easel/package.json +++ b/easel/package.json @@ -1,6 +1,6 @@ { "name": "aesel", - "version": "0.7.6", + "version": "0.7.7", "private": true, "type": "module", "scripts": { diff --git a/easel/test/desktop-interaction.electron.cjs b/easel/test/desktop-interaction.electron.cjs index 0ab4387918..2f4a40eda8 100644 --- a/easel/test/desktop-interaction.electron.cjs +++ b/easel/test/desktop-interaction.electron.cjs @@ -82,7 +82,7 @@ let win; await js( `getComputedStyle(document.getElementById('activity-caption')).display`, ), - "inline-block", + "block", ); fs.writeFileSync( "/tmp/aesel-thought-bubble.png", diff --git a/easel/test/notebook-scroll.electron.cjs b/easel/test/notebook-scroll.electron.cjs new file mode 100644 index 0000000000..1ee5832418 --- /dev/null +++ b/easel/test/notebook-scroll.electron.cjs @@ -0,0 +1,117 @@ +const { app, BrowserWindow, ipcMain } = require("electron"); +const path = require("node:path"), + assert = require("node:assert/strict"); +const delay = (ms) => new Promise((r) => setTimeout(r, ms)); +let win; +(async () => { + await app.whenReady(); + ipcMain.handle("native-prox-title", () => null); + win = new BrowserWindow({ + show: false, + width: 700, + height: 847, + webPreferences: { + preload: path.resolve("easel/desktop/preload.cjs"), + contextIsolation: true, + sandbox: true, + backgroundThrottling: false, + }, + }); + await win.loadFile(path.resolve("easel/desktop/index.html")); + const js = (s) => win.webContents.executeJavaScript(s); + win.webContents.send("state", { + medium: "piece", + piece: "scroll-check", + version: 1, + status: "complete", + preview: {}, + }); + const entries = Array.from({ length: 14 }, (_, i) => ({ + id: String(i), + kind: i % 2 ? "assistant" : "user", + text: + i % 2 + ? "The cow moves through the scrolling grass, bouncing and swishing his tail as the music plays." + : "Give the cow a little techno.", + })); + await js(`window.updateConversation({entries:${JSON.stringify(entries)}})`); + win.webContents.send( + "output", + "\x1b]777;easel-prompt:" + + JSON.stringify({ text: "", cursor: 0, feedback: "", hidden: false }) + + "\x07", + ); + const snapshot = () => + js( + `(()=>{const v=document.getElementById('conversation'),p=document.getElementById('prose-prompt').getBoundingClientRect();return {top:v.scrollTop,height:v.scrollHeight,gap:v.scrollHeight-v.scrollTop-v.clientHeight,promptBottom:p.bottom,bottom:v.getBoundingClientRect().bottom}})()`, + ); + for (const [height, width] of [ + [847, 200], + [420, 560], + [340, 200], + [340, 450], + [650, 200], + ]) { + win.setSize(700, height); + await delay(80); + await js( + `{const s=document.getElementById('artifact-shell');s.dataset.resized='true';for(const [k,v] of Object.entries({width:${width},height:240,right:14,top:14}))s.style.setProperty('--resized-preview-'+k,v+'px');}`, + ); + await delay(80); + await js(`document.getElementById('conversation').scrollTop=1e6`); + const trace = []; + for (let i = 0; i < 7; i++) { + await delay(60); + trace.push(await snapshot()); + } + assert( + Math.max(...trace.map((t) => t.top)) - + Math.min(...trace.map((t) => t.top)) <= + 1, + `scroll rebound: ${JSON.stringify(trace)}`, + ); + assert( + trace.at(-1).gap <= 2, + `bottom detached: ${JSON.stringify(trace.at(-1))}`, + ); + assert(trace.at(-1).promptBottom <= trace.at(-1).bottom + 1); + } + // A reader even one line above the bottom owns their scroll position. + await js( + `{const v=document.getElementById('conversation');v.scrollTop-=24;}`, + ); + await delay(80); + const before = await snapshot(); + entries.push({ + id: "next", + kind: "assistant", + text: "More music arrives without pulling your reading position down.", + }); + await js(`window.updateConversation({entries:${JSON.stringify(entries)}})`); + await delay(180); + assert( + Math.abs((await snapshot()).top - before.top) <= 1, + "A new reply must not steal a reader’s scroll", + ); + const original = await snapshot(); + await js( + `document.getElementById('qr-label').style.transform='scale(1.7)';window.alignNotebookRuling()`, + ); + await delay(120); + assert.equal( + (await snapshot()).height, + original.height, + "Title hover must not resize the scrolling paper", + ); + console.log( + "PASS: stable bottom edge, window/preview height changes, visible input, reading position, title hover.", + ); +})() + .catch((e) => { + console.error(e); + process.exitCode = 1; + }) + .finally(() => { + win?.destroy(); + app.quit(); + }); diff --git a/easel/test/preview-waveform.electron.cjs b/easel/test/preview-waveform.electron.cjs new file mode 100644 index 0000000000..f98c0f4d38 --- /dev/null +++ b/easel/test/preview-waveform.electron.cjs @@ -0,0 +1,175 @@ +// Actual Web Audio samples cross the guest boundary into a quiet notebook line. +const { app, BrowserWindow, ipcMain } = require("electron"); +const fs = require("node:fs"), + path = require("node:path"), + assert = require("node:assert/strict"); +const delay = (ms) => new Promise((r) => setTimeout(r, ms)); +app.commandLine.appendSwitch("autoplay-policy", "no-user-gesture-required"); +let win; +(async () => { + await app.whenReady(); + ipcMain.handle("native-prox-title", () => null); + win = new BrowserWindow({ + show: false, + width: 900, + height: 650, + webPreferences: { + preload: path.resolve("easel/desktop/preload.cjs"), + contextIsolation: true, + sandbox: true, + webviewTag: true, + backgroundThrottling: false, + }, + }); + await win.loadFile(path.resolve("easel/desktop/index.html")); + const js = (code) => win.webContents.executeJavaScript(code); + win.webContents.send("state", { + medium: "piece", + piece: "waveform", + version: 1, + status: "working", + preview: {}, + }); + win.webContents.send("theme", { + background: "#142131", + foreground: "#fffbea", + cursor: "#eeaacc", + }); + await js( + `window.updateConversation({entries:[{id:'u',kind:'user',text:'Give the cow a little techno.'}]})`, + ); + win.webContents.send( + "output", + "\x1b]777;easel-prompt:" + + JSON.stringify({ + text: "", + cursor: 0, + feedback: "Working", + activity: + "listening to the techno while the cow runs through the field", + hidden: false, + }) + + "\x07", + ); + const attached = new Promise((r) => + win.webContents.once("did-attach-webview", (_, guest) => + guest.once("did-finish-load", () => r(guest)), + ), + ); + await js( + `document.getElementById('piece').src='data:text/html,';`, + ); + const guest = await attached; + const meter = fs + .readFileSync( + "system/public/aesthetic.computer/lib/output-waveform.mjs", + "utf8", + ) + .replace("export function", "function"); + await guest.executeJavaScript( + `${meter}\nwindow.ctx=new AudioContext();window.gain=ctx.createGain();gain.gain.value=.12;window.quiet=ctx.createGain();quiet.gain.value=0;gain.connect(quiet).connect(ctx.destination);window.osc=ctx.createOscillator();osc.frequency.value=220;osc.connect(gain);osc.start();window.AC={readOutputWaveform:createOutputWaveform(()=>[gain])};ctx.resume();`, + ); + await delay(550); + assert.equal( + await js( + `document.getElementById('preview-waveform').classList.contains('sounding')`, + ), + true, + ); + const waveform = await js( + `document.querySelector('#preview-waveform path').getAttribute('d')`, + ); + assert(waveform.includes("L"), "A sampled waveform, not a canned animation"); + const points = [...waveform.matchAll(/[ML][\d.]+ ([\d.]+)/g)].map( + (m) => +m[1], + ); + assert(Math.max(...points) - Math.min(...points) > 5); + assert.equal( + await js( + `getComputedStyle(document.getElementById('preview-waveform')).pointerEvents`, + ), + "none", + ); + assert.equal( + await js( + `document.getElementById('preview-waveform').getAttribute('aria-hidden')`, + ), + "true", + ); + for (const width of [380, 900]) { + win.setSize(width, 650); + await delay(220); + const row = await js( + `(()=>{const a=document.getElementById('prompt-feedback').getBoundingClientRect(),b=document.getElementById('activity-caption').getBoundingClientRect(),g=document.getElementById('notebook-activity').getBoundingClientRect();return {sameRow:Math.abs(a.top+a.height/2-b.top-b.height/2)<3,beside:b.left>=a.right,width:g.width,available:innerWidth,donkey:getComputedStyle(document.getElementById('notebook-thinking-donkey')).translate,bubble:getComputedStyle(document.getElementById('activity-caption')).translate}})()`, + ); + assert(row.sameRow && row.beside, JSON.stringify(row)); + assert(row.width <= row.available); + assert.equal( + row.donkey, + row.bubble, + "Donkey and bubble share their bounce", + ); + } + await delay(200); + fs.writeFileSync( + "/tmp/aesel-waveform-bubble.png", + (await win.webContents.capturePage()).toPNG(), + ); + guest.setAudioMuted(true); + await delay(250); + assert.equal( + await js( + `document.getElementById('preview-waveform').classList.contains('sounding')`, + ), + false, + ); + guest.setAudioMuted(false); + await guest.executeJavaScript("gain.gain.value=0"); + await delay(900); + assert.equal( + await js( + `document.getElementById('preview-waveform').classList.contains('sounding')`, + ), + false, + "Silence hides the line", + ); + await guest.executeJavaScript("gain.gain.value=.12"); + await delay(300); + assert.equal( + await js( + `document.getElementById('preview-waveform').classList.contains('sounding')`, + ), + true, + ); + await js(`document.getElementById('piece').src='data:text/html,quiet'`); + await delay(150); + assert.equal( + await js( + `document.getElementById('preview-waveform').classList.contains('sounding')`, + ), + false, + "Navigation clears old audio", + ); + win.webContents.debugger.attach("1.3"); + await win.webContents.debugger.sendCommand("Emulation.setEmulatedMedia", { + features: [{ name: "prefers-reduced-motion", value: "reduce" }], + }); + await delay(180); + assert.equal( + await js( + `getComputedStyle(document.getElementById('activity-caption')).translate`, + ), + "0px", + ); + console.log( + "PASS: live audio, silence, mute, navigation, narrow thought-bubble row, shared bounce, reduced motion.", + ); +})() + .catch((e) => { + console.error(e); + process.exitCode = 1; + }) + .finally(() => { + win?.destroy(); + app.quit(); + }); diff --git a/system/public/aesel/index.html b/system/public/aesel/index.html index 680a6a340d..126ba45532 100644 --- a/system/public/aesel/index.html +++ b/system/public/aesel/index.html @@ -14,7 +14,7 @@

Make art and software with AI.

- Download for Mac + Download for Mac
Apple silicon · macOS 12+ · Linux & more
@@ -24,7 +24,7 @@
-

Downloads

Sign in with Aesthetic Computer.
Use AC, Claude, or Codex.

Mac Apple silicon · DMG↓Linux Ubuntu / Debian · x86-64↓AppImage Other Linux desktops · x86-64↓
Terminalcurl -fsSL https://aesthetic.computer/aesel.sh | sh

Requires Node.js 22+. Run ac after installing.

Mac 0.7.6 · Linux 0.7.0 (Easel)

+

Downloads

Sign in with Aesthetic Computer.
Use AC, Claude, or Codex.

Mac Apple silicon · DMG↓Linux Ubuntu / Debian · x86-64↓AppImage Other Linux desktops · x86-64↓
Terminalcurl -fsSL https://aesthetic.computer/aesel.sh | sh

Requires Node.js 22+. Run ac after installing.

Mac 0.7.7 · Linux 0.7.0 (Easel)

diff --git a/system/public/aesthetic.computer/bios.mjs b/system/public/aesthetic.computer/bios.mjs index 096f3c36df..0ab0ccf6f1 100644 --- a/system/public/aesthetic.computer/bios.mjs +++ b/system/public/aesthetic.computer/bios.mjs @@ -9,6 +9,7 @@ import { Pen } from "./lib/pen.mjs"; import { Box } from "./lib/geo.mjs"; import { Keyboard } from "./lib/keyboard.mjs"; import { Gamepad } from "./lib/gamepad.mjs"; +import { createOutputWaveform } from "./lib/output-waveform.mjs"; import { startCapturingMotion, stopCapturingMotion } from "./lib/motion.mjs"; import { speak, speakAPI } from "./lib/speech.mjs"; import * as UI from "./lib/ui.mjs"; @@ -3164,6 +3165,12 @@ async function boot(parsed, bpm = 60, resolution, debug) { wrapper.appendChild(backgroundMusicEl); let analyserCtx, analyserSrc, analyser, frequencyData; + window.AC ||= {}; + window.AC.readOutputWaveform = createOutputWaveform(() => [ + speakerGain, + ...(!backgroundMusicEl?.paused && !backgroundMusicEl?.muted ? [analyser] : []), + ...Object.values(streamAudio).filter(entry => !entry.audio.paused && !entry.audio.muted).map(entry => entry.analyser), + ]); let currentBackgroundTrack; function playBackgroundMusic(n, volume) { diff --git a/system/public/aesthetic.computer/lib/output-waveform.mjs b/system/public/aesthetic.computer/lib/output-waveform.mjs new file mode 100644 index 0000000000..ef389817e9 --- /dev/null +++ b/system/public/aesthetic.computer/lib/output-waveform.mjs @@ -0,0 +1,42 @@ +// A lazy, silent tap of the piece's speaker outputs. No audio is rerouted. +export function createOutputWaveform(getSources) { + const taps = new Map(); + const read = () => { + const sources = new Set( + getSources().filter((source) => source?.context?.state === "running"), + ); + for (const [source, tap] of taps) { + if (sources.has(source)) continue; + if (tap.owned) source.disconnect(tap.analyser); + taps.delete(source); + } + if (!sources.size) return []; + const mixed = new Array(128).fill(0); + for (const source of sources) { + let tap = taps.get(source); + if (!tap) { + const owned = typeof source.getFloatTimeDomainData !== "function"; + const analyser = owned ? source.context.createAnalyser() : source; + if (owned) { + analyser.fftSize = 512; + source.connect(analyser); + } + tap = { analyser, owned, samples: new Float32Array(analyser.fftSize) }; + taps.set(source, tap); + } + tap.analyser.getFloatTimeDomainData(tap.samples); + for (let i = 0; i < mixed.length; i++) + mixed[i] += + tap.samples[Math.floor((i * tap.samples.length) / mixed.length)]; + } + return mixed.map((value) => + Number.isFinite(value) ? Math.max(-1, Math.min(1, value)) : 0, + ); + }; + read.dispose = () => { + for (const [source, tap] of taps) + if (tap.owned) source.disconnect(tap.analyser); + taps.clear(); + }; + return read; +} -- 2.51.2