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);});