From ce9729c16bfaf3b42658c9525879f5ee1297cdf4 Mon Sep 17 00:00:00 2001 From: "prompt.ac/@jeffrey" Date: Thu, 17 Sep 2026 23:03:37 -0700 Subject: [PATCH] play aesel sounds against the aesthetic computer net clock Sound previews were scheduled from a local Audio element, so two machines with the same loop drifted apart. net-clock.js mirrors the disk API's clock: sample /api/clock, take server time minus the request midpoint, ease later samples 25%, and derive beat, loop phase and next beat from shared time alone, the way the clock piece does. The main process samples it through Electron's net (the renderer's CSP can't reach the site) and hands it to the renderer over IPC, throttled to one fetch per five seconds. The preview is now a Web Audio deck that pairs shared time with the context clock: loops start at the shared phase, one-shots wait for the next beat, and a running loop relocks if it drifts past 20ms. Scores carry their grid, loops must be a whole number of beats, and the compose tools ask for notes on beats and loops on whole bars. Tests cover the clock math with fake fetch and time; two fake machines with different skews land on the same beat. Co-Authored-By: Claude Fable 5.1 --- easel/desktop/local-preview.cjs | 5 ++- easel/desktop/main.cjs | 13 ++++++- easel/desktop/media-preview.js | 57 +++++++++++++++++++++++++--- easel/desktop/net-clock.js | 55 +++++++++++++++++++++++++++ easel/desktop/preload.cjs | 1 + easel/media/sound/README.md | 11 ++++++ easel/src/media/sound.mjs | 18 +++++++-- easel/test/net-clock.test.mjs | 66 +++++++++++++++++++++++++++++++++ easel/test/sound.test.mjs | 4 ++ 9 files changed, 219 insertions(+), 11 deletions(-) create mode 100644 easel/desktop/net-clock.js create mode 100644 easel/test/net-clock.test.mjs diff --git a/easel/desktop/local-preview.cjs b/easel/desktop/local-preview.cjs index ff3a9b1709..ed37d36be0 100644 --- a/easel/desktop/local-preview.cjs +++ b/easel/desktop/local-preview.cjs @@ -33,6 +33,9 @@ async function localPreview(workspace, preview) { dimensions = dimensionsCache.get(key) || dimensions; } const bytes = fs.readFileSync(target); - return {...dimensions,mime:preview.mime,internalPath:target,data:`data:${preview.mime};base64,${bytes.toString('base64')}`,text:preview.mime==='text/plain'?bytes.toString('utf8').slice(0,100000):undefined}; + // A sound carries its beat grid so the deck can lock the loop to the shared clock. + let grid; + if (preview.mime === 'audio/wav') try { const score = JSON.parse(fs.readFileSync(path.join(path.dirname(target),'score.json'),'utf8')); if ([score.bpm,score.beats].every(n=>Number.isFinite(n)&&n>0)) grid = {bpm:score.bpm,beats:score.beats,loop:score.loop===true}; } catch {} + return {...dimensions,grid,mime:preview.mime,internalPath:target,data:`data:${preview.mime};base64,${bytes.toString('base64')}`,text:preview.mime==='text/plain'?bytes.toString('utf8').slice(0,100000):undefined}; } module.exports = {localPreview}; diff --git a/easel/desktop/main.cjs b/easel/desktop/main.cjs index b58f4b054a..02bd967a2b 100644 --- a/easel/desktop/main.cjs +++ b/easel/desktop/main.cjs @@ -1,5 +1,5 @@ const {chmodSync}=require('node:fs'); -const { app, BrowserWindow, ipcMain, shell, Menu, clipboard, screen, nativeImage } = require('electron'); +const { app, BrowserWindow, ipcMain, shell, Menu, clipboard, screen, nativeImage, net } = require('electron'); const pty = require('node-pty'); const { spawn } = require('node:child_process'); const { join, resolve } = require('node:path'); @@ -7,6 +7,7 @@ const { closeSync, copyFileSync, mkdirSync, openSync, readdirSync, readFileSync, const {followSlabTheme,FALLBACK} = require('./slab-theme.cjs'); const {createHash,randomUUID} = require('node:crypto'); const {localPreview} = require('./local-preview.cjs'); +const NetClock = require('./net-clock.js'); const {createUpdater} = require('./updater.cjs'); const {startFrameCapture} = require('./frame-capture.cjs'); const { tmpdir, homedir } = require('node:os'); @@ -398,6 +399,16 @@ app.on('web-contents-created', (_event, contents) => { }); }); +// The shared music clock. Chromium's stack carries a browser user agent, which +// Cloudflare wants; the renderer's CSP cannot reach the site itself. The +// renderer asks whenever it is playing; fetches are throttled here. +const netClock = NetClock.createClock({ sample: () => NetClock.sample({ fetch: net.fetch, site: process.env.EASEL_SITE || 'https://aesthetic.computer' }) }); +let netClockAt = 0; +ipcMain.handle('net-clock', async event => { + if (!isWindow(event)) return null; + if (Date.now() - netClockAt > 5000) { netClockAt = Date.now(); try { await netClock.resync(); } catch {} } + return netClock.toJSON(); +}); // Native menus live outside the renderer viewport; return only the chosen index. ipcMain.handle('notebook-context-menu', (event, items) => { if(!isWindow(event)||!Array.isArray(items)||items.length>32)return -1; diff --git a/easel/desktop/media-preview.js b/easel/desktop/media-preview.js index 012fcbcafa..465dca8c19 100644 --- a/easel/desktop/media-preview.js +++ b/easel/desktop/media-preview.js @@ -7,6 +7,49 @@ window.addEventListener('pointerup',()=>{draggingPreview=false;dragSent=false;}); let key='', player=null, playerFrame=0, emulator=null, generation=0; const ready = () => { const box=document.getElementById('artifact'); box.classList.remove('refresh'); requestAnimationFrame(()=>box.classList.add('refresh')); }; + // The shared music clock (net-clock.js). Main measures the offset against + // /api/clock; here it is adopted and beats are counted from it, never from + // Date.now or the AudioContext alone. Loaded like the emulator: this file is + // wired in index.html, the clock rides along with it. + const netClock=new Promise((resolve,reject)=>{const script=document.createElement('script');script.src='net-clock.js';script.onload=()=>resolve(window.NetClock);script.onerror=()=>reject(new Error('Could not load the net clock.'));document.head.append(script);}); + let clock=null,syncedAt=0; + const syncClock=async(force)=>{if(!clock||(!force&&performance.now()-syncedAt<5000))return;syncedAt=performance.now();try{const s=await window.aesel.clock?.();if(s)clock.adopt(s.offset);}catch{}}; + netClock.then(NetClock=>{clock=NetClock.createClock();window.aeselClock=clock;}).catch(()=>{}); + // One AudioContext for every tape; 24 kHz matches the rendered WAV so the + // decoded samples are the file's samples. + let deckContext=null;const audioContext=()=>deckContext||(deckContext=new AudioContext({sampleRate:24000})); + // A tape deck over Web Audio. A loop starts at the phase the shared clock + // says the loop is at right now: clock.time() and context.currentTime are + // read as a pair, then everything is scheduled on the context's timeline. Two + // machines with the same score wrap on the same beat. A one-shot phrase waits + // for the next shared beat, like a sync button. + const tapeDeck=(NetClock,buffer,grid)=>{ + const context=audioContext(),loopSec=grid?.loop?buffer.duration:0; + let source=null,anchor=0,head=0; // anchor: the context time where tape position 0 sits + const stop=()=>{if(source){source.onended=null;try{source.stop();}catch{}source=null;}}; + const position=()=>{const t=context.currentTime-anchor;return loopSec?((t%loopSec)+loopSec)%loopSec:Math.min(Math.max(0,t),buffer.duration);}; + const start=offset=>{ + const lead=.05,at=clock.time()+lead*1000;let when=context.currentTime+lead; + if(grid&&loopSec)offset=NetClock.loop(at,grid).phase*loopSec; + else if(grid)when+=(NetClock.nextBeat(at,grid.bpm)-at)/1000; + source=context.createBufferSource();source.buffer=buffer;source.loop=!!loopSec; + source.onended=()=>{source=null;head=0;deck.paused=true;deck.onended?.();}; + source.connect(context.destination);source.start(when,offset);anchor=when-offset; + if(grid)console.log(`🕰️ deck ${loopSec?'loop':'phrase'} on shared beat ${NetClock.beat(at,grid.bpm).index} (offset ${Math.round(clock.offset)}ms, rtt ${Math.round(clock.rtt)}ms)`); + }; + const deck={ + paused:true,loop:!!loopSec,duration:buffer.duration,onplay:null,onpause:null,onended:null, + get currentTime(){return deck.paused?head:position();}, + set currentTime(v){head=Math.max(0,Math.min(buffer.duration,v));if(!deck.paused&&!loopSec){stop();start(head);}}, // a loop stays locked + async play(){if(!deck.paused)return;await context.resume();await syncClock(true);start(head);deck.paused=false;deck.onplay?.();}, + pause(){if(deck.paused)return;head=position();stop();deck.paused=true;deck.onpause?.();}, + // A resync or the buffer's sample rounding can walk a running loop off the grid; snap it back. + relock(){if(deck.paused||!loopSec)return;const drift=position()-NetClock.loop(clock.time(),grid).phase*loopSec;if(Math.abs(drift)>.02&&Math.abs(drift) { if (state.medium === 'piece') { ++generation; @@ -28,18 +71,20 @@ if(preview.mime==='image/png') { const img=new Image();img.alt=state.piece||'Picture';img.draggable=false;img.onload=()=>{if(current!==generation)return;window.setPreviewDimensions(img.naturalWidth,img.naturalHeight);ready();};img.src=preview.data;host.append(img); } else if(preview.mime==='audio/wav') { - player=new Audio(preview.data);const audio=player; - const canvas=document.createElement('canvas');canvas.className='tape-player';canvas.width=192;canvas.height=128;canvas.tabIndex=0;canvas.setAttribute('role','button');canvas.setAttribute('aria-label','Play sound; drag to scrub; drag beyond the Easel window to export');host.append(canvas); - const context=new OfflineAudioContext(1,1,24000); - const buffer=await context.decodeAudioData(await(await fetch(preview.data)).arrayBuffer()); + const NetClock=await netClock; + const buffer=await audioContext().decodeAudioData(await(await fetch(preview.data)).arrayBuffer()); if(current!==generation)return; + const grid=preview.grid||null,audio=tapeDeck(NetClock,buffer,grid);player=audio;void syncClock(); + const canvas=document.createElement('canvas');canvas.className='tape-player';canvas.width=192;canvas.height=128;canvas.tabIndex=0;canvas.setAttribute('role','button');canvas.setAttribute('aria-label',`Play sound; drag to scrub${audio.loop?' (a loop stays locked to the shared beat)':''}; drag beyond the Easel window to export`);host.append(canvas); const samples=buffer.getChannelData(0),ctx=canvas.getContext('2d'),peaks=[]; for(let x=0;x<192;x++){let peak=0;for(let i=Math.floor(x*samples.length/192);i{cancelAnimationFrame(playerFrame);playerFrame=0;if(current!==generation)return;const progress=audio.duration?audio.currentTime/audio.duration:0; + const draw=()=>{cancelAnimationFrame(playerFrame);playerFrame=0;if(current!==generation)return;audio.relock();void syncClock();const progress=audio.duration?audio.currentTime/audio.duration:0; ctx.fillStyle='#07182c';ctx.fillRect(0,0,192,128); + if(grid){ctx.fillStyle='#ffffff14';for(let b=1;bserverTime-(t0+(t1-t0)/2); + function createClock({sample:take,now=Date.now}={}){ + let offset=0,synced=0,rtt=0,fetching=null; + const clock={ + get offset(){return offset;},get synced(){return synced;},get rtt(){return rtt;}, + // The first sample is taken whole; later ones ease in so one slow round + // trip cannot yank the beat. + resync(){ + if(fetching||!take)return fetching||Promise.resolve(offset); + fetching=take().then(s=>{rtt=s.t1-s.t0;const target=estimate(s);offset+=(target-offset)*(synced?BLEND:1);synced++;return offset;}).finally(()=>{fetching=null;}); + return fetching; + }, + // Take an offset another process already measured on this same machine. + adopt(value){if(Number.isFinite(value)){offset=value;synced++;}}, + time:()=>now()+offset, + toJSON:()=>({offset,synced,rtt}), + }; + return clock; + } + const beatMs=bpm=>60000/bpm; + // Which beat the shared moment `t` sits on, counted from the UTC epoch. + function beat(t,bpm){const period=beatMs(bpm),index=Math.floor(t/period),start=index*period;return {index,period,start,phase:(t-start)/period,next:start+period};} + // A loop of `beats` beats starts on every beat whose index is a multiple of + // its length, so equal scores on different machines wrap on the same beat. + function loop(t,{bpm,beats}){const period=beatMs(bpm)*beats,index=Math.floor(t/period),start=index*period;return {index,period,start,phase:(t-start)/period,next:start+period,beat:Math.floor((t-start)/beatMs(bpm))};} + const nextBeat=(t,bpm)=>beat(t,bpm).next; + const api={BLEND,sample,estimate,createClock,beatMs,beat,loop,nextBeat}; + if(typeof module!=='undefined'&&module.exports)module.exports=api;else scope.NetClock=api; +})(typeof window==='undefined'?globalThis:window); diff --git a/easel/desktop/preload.cjs b/easel/desktop/preload.cjs index 791f062bed..ae792a56a5 100644 --- a/easel/desktop/preload.cjs +++ b/easel/desktop/preload.cjs @@ -21,6 +21,7 @@ contextBridge.exposeInMainWorld('aesel', { openPiece: url => { if (typeof url === 'string') ipcRenderer.send('open-piece',url); }, openPaper: () => ipcRenderer.send('open-paper'), dragPreview: () => ipcRenderer.send('preview-drag'), + clock: () => ipcRenderer.invoke('net-clock'), onNotice: fn => ipcRenderer.on('desktop-notice', (_event, message) => { if (typeof message === 'string') fn(message.slice(0, 2000)); }), onFullscreenState: fn => ipcRenderer.on('fullscreen-state', (_event, state) => { if (state && typeof state.app === 'boolean' && typeof state.preview === 'boolean') fn({ app: state.app, preview: state.preview }); diff --git a/easel/media/sound/README.md b/easel/media/sound/README.md index 3056824e75..85ce8ba549 100644 --- a/easel/media/sound/README.md +++ b/easel/media/sound/README.md @@ -24,6 +24,17 @@ the exact original paths and SHA-256 hashes. The Aesel repository license applie ``` Times are beats (quarter notes). Presets are `lead`, `pad`, and `stab`. + +Beats are a shared grid, not a local stopwatch. The desktop deck +(`easel/desktop/net-clock.js`, after AC's `clock.mjs` and the `clock` disk api) +syncs a virtual UTC against `/api/clock` and counts beat `floor(time / (60000 / +bpm))` from the epoch, so `bpm` is the music rate every open Aesel agrees on. +A loop of `beats` beats wraps on every beat whose index is a multiple of +`beats`, and playing it starts at that shared phase; a one-shot phrase waits for +the next shared beat. Two machines holding the same `bpm`, `beats` and `loop` +therefore land on the same beat. To compose against it: keep `at` on beats or +simple subdivisions, make loops a whole number of beats (required) and whole +bars — 4, 8, 16 — when you want them to line up with other loops. `set_score` validates and renders a full recipe. `render` reads the saved recipe. `rhythm` replaces notes using `{ "hits": 3, "pulses": 8, "midi": 60 }` and enables looping. `analyze` measures the saved WAV without changing its audio. diff --git a/easel/src/media/sound.mjs b/easel/src/media/sound.mjs index 2f3979ab92..98aa8076f2 100644 --- a/easel/src/media/sound.mjs +++ b/easel/src/media/sound.mjs @@ -1,4 +1,10 @@ // Portable compositional Pop lane. No provider, samples, or executable score code. +// +// Time is a shared grid. Playback (desktop net-clock.js, after AC's clock.mjs) +// counts beats from the UTC epoch at the score's bpm, so `bpm` is the music +// rate every open Aesel agrees on and a loop of N beats wraps on every beat +// whose index is a multiple of N. Notes sit at beat offsets from the loop's +// start; a loop is a whole number of beats — whole bars (4, 8, 16) compose best. import { readFile, writeFile, mkdir, lstat, rename, unlink } from 'node:fs/promises'; import { join } from 'node:path'; import { createHash, randomUUID } from 'node:crypto'; @@ -22,6 +28,7 @@ export function validateScore(raw) { if (instrument !== 'sinepower') throw new Error('Available instrument: sinepower'); const loop = raw.loop ?? false; if(typeof loop !== 'boolean') throw new Error('loop must be boolean'); + if(loop && !Number.isInteger(beats)) throw new Error('A loop must be a whole number of beats so it wraps on the shared beat grid'); if (!Array.isArray(raw.notes) || raw.notes.length > 128) throw new Error('notes must be an array of at most 128 events'); let work = 0; const notes = raw.notes.map(n => { @@ -42,6 +49,10 @@ export function validateScore(raw) { }); return {format:1,bpm,beats,instrument,preset,loop,notes,effects}; } +// The grid a score plays on: seconds per beat, the loop's length, and how it sits in bars. +export function scoreGrid({bpm,beats}) { + return {bpm,beats,beatSec:60/bpm,loopSec:beats*60/bpm,bars:beats%4===0?beats/4:null}; +} export function renderScore(raw) { const score=validateScore(raw), length=Math.round(score.beats*60/score.bpm*RATE); const out=new Float32Array(length), tail = score.preset === 'pad' ? 2.2 : .5; @@ -85,13 +96,14 @@ async function save(root,raw) { await atomic(root,'analysis.json',JSON.stringify(analysis)+'\n'); await atomic(root,'provenance.json',JSON.stringify(provenance,null,2)+'\n'); await atomic(root,'score.json',scoreBytes); - return {files:OUTPUTS,preview:{path:'sound.wav',mime:'audio/wav'},summary:`${score.notes.length} notes · ${analysis.duration.toFixed(2)}s · ${score.preset}${score.loop?' loop':''}`,analysis}; + const grid=scoreGrid(score); + return {files:OUTPUTS,preview:{path:'sound.wav',mime:'audio/wav'},summary:`${score.notes.length} notes · ${analysis.duration.toFixed(2)}s · ${score.preset}${score.loop?' loop':''} · ${score.beats} beats${grid.bars?` (${grid.bars} bar${grid.bars===1?'':'s'})`:''} @ ${score.bpm}bpm`,analysis,grid}; } const noteSchema={type:'object',properties:{at:{type:'number'},midi:{type:'integer'},duration:{type:'number'},gain:{type:'number'}},required:['at','midi'],additionalProperties:false}; export const actions=[ - {name:'set_score',description:'Compose and render a short Pop sinepower phrase. Beats are quarter notes; MIDI 24–96, bpm 30–300, at most 30 seconds and 128 notes. Presets lead/pad/stab. Optional wobble or bitcrush effects.',inputSchema:{type:'object',properties:{score:{type:'object',properties:{bpm:{type:'number'},beats:{type:'number'},preset:{type:'string',enum:['lead','pad','stab']},loop:{type:'boolean'},notes:{type:'array',items:noteSchema},effects:{type:'array',items:{type:'object'}}},required:['notes']}},required:['score'],additionalProperties:false}}, + {name:'set_score',description:'Compose and render a short Pop sinepower phrase. Beats are quarter notes on a beat grid shared by every open Aesel (bpm is the shared music rate); note.at is the beat offset from the loop start, so put onsets on beats or simple subdivisions. A loop must be a whole number of beats and composes best as whole bars (4, 8, 16); loops start on shared beat boundaries when played. MIDI 24–96, bpm 30–300, at most 30 seconds and 128 notes. Presets lead/pad/stab. Optional wobble or bitcrush effects.',inputSchema:{type:'object',properties:{score:{type:'object',properties:{bpm:{type:'number'},beats:{type:'number'},preset:{type:'string',enum:['lead','pad','stab']},loop:{type:'boolean'},notes:{type:'array',items:noteSchema},effects:{type:'array',items:{type:'object'}}},required:['notes']}},required:['score'],additionalProperties:false}}, {name:'render',description:'Render current editable score.json into sound.wav.',inputSchema:{type:'object',properties:{},additionalProperties:false}}, - {name:'rhythm',description:'Replace notes with a maximally even Pop Bjorklund rhythm; preserve tempo, timbre and effects.',inputSchema:{type:'object',properties:{pulses:{type:'integer',minimum:1,maximum:64},hits:{type:'integer',minimum:0,maximum:64},midi:{type:'integer',minimum:24,maximum:96}},required:['pulses','hits'],additionalProperties:false}}, + {name:'rhythm',description:'Replace notes with a maximally even Pop Bjorklund rhythm over the loop, pulses dividing its beats; preserve tempo, timbre and effects. Pulses equal to or a multiple of the beat count keep every onset on the shared grid.',inputSchema:{type:'object',properties:{pulses:{type:'integer',minimum:1,maximum:64},hits:{type:'integer',minimum:0,maximum:64},midi:{type:'integer',minimum:24,maximum:96}},required:['pulses','hits'],additionalProperties:false}}, {name:'analyze',description:'Measure the saved WAV: peak, RMS, duration and waveform. Does not regenerate audio.',inputSchema:{type:'object',properties:{},additionalProperties:false}}, ]; export async function create({root}) {return save(root,{bpm:100,beats:8,preset:'stab',notes:[60,64,67,72].map((midi,i)=>({at:i*1.5,midi,duration:1,gain:.3}))});} diff --git a/easel/test/net-clock.test.mjs b/easel/test/net-clock.test.mjs new file mode 100644 index 0000000000..ec163b02e7 --- /dev/null +++ b/easel/test/net-clock.test.mjs @@ -0,0 +1,66 @@ +import test from 'node:test';import assert from 'node:assert/strict'; +import {mkdtemp,mkdir,writeFile,rm} from 'node:fs/promises';import {tmpdir} from 'node:os';import {join} from 'node:path'; +import NetClock from '../desktop/net-clock.js'; +import {localPreview} from '../desktop/local-preview.cjs'; +const {sample,estimate,createClock,beat,loop,nextBeat,beatMs,BLEND}=NetClock; +// A machine: its own Date.now, and a fake /api/clock that answers in true time +// after `rtt` ms. `skew` is how far its local clock sits from true time. +const machine=({skew,rtt,now=1_700_000_000_000})=>{let local=now+skew;const m={skew,now:()=>local,tick:ms=>{local+=ms;},true:()=>local-m.skew}; + m.fetch=async()=>{m.tick(rtt/2);const iso=new Date(m.true()).toISOString();m.tick(rtt/2);return {ok:true,text:async()=>iso};}; + m.clock=createClock({sample:()=>sample({fetch:m.fetch,now:m.now,site:'https://ac.test'}),now:m.now});return m;}; +test('offset is server time minus the request midpoint, taken whole first and eased a quarter after',async()=>{ + assert.equal(estimate({t0:1000,t1:1200,serverTime:5100}),4000); + const m=machine({skew:-4000,rtt:200}); + assert.equal(await m.clock.resync(),4000);assert.equal(m.clock.rtt,200);assert.equal(m.clock.synced,1); + assert.equal(m.clock.time(),m.true()); + m.skew=-3000; // the local clock jumps a second ahead: the next estimate says 3000 + assert.equal(await m.clock.resync(),4000+(3000-4000)*BLEND);assert.equal(BLEND,.25); +}); +test('a second sample eases in: one slow round trip cannot yank the beat',async()=>{ + const m=machine({skew:-4000,rtt:200});await m.clock.resync(); + // a burst of lag answers late: the estimate lands 400ms off + const slow=async()=>{m.tick(100);const iso=new Date(m.true()-400).toISOString();m.tick(100);return {ok:true,text:async()=>iso};}; + const c=createClock({sample:()=>sample({fetch:slow,now:m.now}),now:m.now});c.adopt(4000); + assert.equal(await c.resync(),4000-400*BLEND); + // a concurrent resync shares the in-flight sample + const a=c.resync(),b=c.resync();assert.equal(a,b);await a; +}); +test('sample rejects bad answers without touching the offset',async()=>{ + const m=machine({skew:0,rtt:10}); + const bad=createClock({sample:()=>sample({fetch:async()=>({ok:false,status:503}),now:m.now}),now:m.now}); + await assert.rejects(bad.resync(),/503/);assert.equal(bad.offset,0);assert.equal(bad.synced,0); + const junk=createClock({sample:()=>sample({fetch:async()=>({ok:true,text:async()=>'soon'}),now:m.now}),now:m.now}); + await assert.rejects(junk.resync(),/unreadable/); +}); +test('beat index, phase and next beat come from the epoch at the bpm, as clock.mjs counts them',()=>{ + assert.equal(beatMs(120),500); + assert.deepEqual(beat(1500,120),{index:3,period:500,start:1500,phase:0,next:2000}); + assert.deepEqual(beat(1625,120),{index:3,period:500,start:1500,phase:.25,next:2000}); + assert.equal(nextBeat(1625,120),2000);assert.equal(nextBeat(1500,120),2000); + const t=1_700_000_000_123;const b=beat(t,100);assert.equal(b.index,Math.floor(t/600));assert.ok(b.phase>=0&&b.phase<1);assert.equal(b.next,b.start+600); +}); +test('a loop of N beats wraps on beats whose index is a multiple of N',()=>{ + const grid={bpm:120,beats:8}; // 4000ms loop + assert.deepEqual(loop(9000,grid),{index:2,period:4000,start:8000,phase:.25,next:12000,beat:2}); + assert.equal(loop(8000,grid).phase,0);assert.equal(loop(11999,grid).beat,7);assert.equal(loop(12000,grid).beat,0); + assert.equal(loop(9000,grid).start%beatMs(120),0); +}); +test('two machines with different local clocks land on the same beat and loop phase once synced',async()=>{ + const a=machine({skew:-2345,rtt:120}),b=machine({skew:+987,rtt:40}); + await a.clock.resync();await b.clock.resync();b.tick(a.true()-b.true()); // meet at the same true instant + const grid={bpm:100,beats:16}; + for(const step of [0,1,733,4000,17_500]){a.tick(step);b.tick(step); + assert.equal(beat(a.clock.time(),100).index,beat(b.clock.time(),100).index); + assert.ok(Math.abs(loop(a.clock.time(),grid).phase-loop(b.clock.time(),grid).phase)<1e-9);} + // unsynced they disagree by the skew + assert.notEqual(beat(a.now(),100).index,beat(b.now(),100).index); +}); +test('the sound preview carries its score grid so the deck can lock the loop',async t=>{ + const workspace=await mkdtemp(join(tmpdir(),'easel-clock-'));t.after(()=>rm(workspace,{recursive:true,force:true})); + const root=join(workspace,'.easel-media','art');await mkdir(root,{recursive:true}); + await writeFile(join(root,'sound.wav'),Buffer.alloc(44));await writeFile(join(root,'score.json'),JSON.stringify({bpm:90,beats:8,loop:true,notes:[]})); + const preview=await localPreview(workspace,{mime:'audio/wav',path:join(root,'sound.wav')}); + assert.deepEqual(preview.grid,{bpm:90,beats:8,loop:true}); + await writeFile(join(root,'score.json'),'{'); + assert.equal((await localPreview(workspace,{mime:'audio/wav',path:join(root,'sound.wav')})).grid,undefined); +}); diff --git a/easel/test/sound.test.mjs b/easel/test/sound.test.mjs index 400bfd1fbc..877dea6600 100644 --- a/easel/test/sound.test.mjs +++ b/easel/test/sound.test.mjs @@ -16,3 +16,7 @@ test('invalid renders preserve previous audio and reject pathological scores',as test('symlink outputs cannot escape artifact root',async t=>{const root=await fixture(t),outside=await fixture(t);await writeFile(join(outside,'target'),'untouched');await symlink(join(outside,'target'),join(root,'sound.wav'));await assert.rejects(create({root}),/Unsafe artifact path/);assert.equal(await readFile(join(outside,'target'),'utf8'),'untouched');}); test('portable copied toolkit runs without monorepo or dependencies',async t=>{const root=await fixture(t);await cp(new URL('../media/sound/',import.meta.url),join(root,'media/sound'),{recursive:true});await cp(new URL('../src/media/',import.meta.url),join(root,'src/media'),{recursive:true});const isolated=await import(pathToFileURL(join(root,'src/media/sound.mjs')).href);const result=await isolated.create({root:join(root,'artifact')});assert.ok(result.analysis.rms>0);}); test('malformed WAV cannot allocate from forged data lengths',async t=>{const root=await fixture(t);await create({root});const wav=await readFile(join(root,'sound.wav'));wav.writeUInt32LE(0xfffffffe,40);await writeFile(join(root,'sound.wav'),wav);await assert.rejects(run({root,action:'analyze'}),/Expected an aesel mono PCM WAV/);}); +test('loops are whole beats and every render reports the shared grid it plays on',async t=>{const root=await fixture(t);const result=await create({root});assert.deepEqual(result.grid,{bpm:100,beats:8,beatSec:.6,loopSec:4.8,bars:2});assert.match(result.summary,/8 beats \(2 bars\) @ 100bpm/); + await assert.rejects(run({root,action:'set_score',input:{score:{bpm:120,beats:2.5,loop:true,notes:[]}}}),/whole number of beats/); + const phrase=await run({root,action:'set_score',input:{score:{bpm:120,beats:2.5,loop:false,notes:[]}}});assert.equal(phrase.grid.bars,null); + await run({root,action:'set_score',input:{score:{bpm:120,beats:4,notes:[]}}});const loop=await run({root,action:'rhythm',input:{hits:4,pulses:8}});assert.equal(loop.grid.loopSec,2);assert.equal(loop.grid.bars,1);assert.equal(JSON.parse(await readFile(join(root,'score.json'))).notes[1].at,1);}); -- 2.51.2