import test from 'node:test'; import assert from 'node:assert/strict'; import { LichenAdapter } from '../lib/playground/v2/lichen-adapter.mjs'; const notes=[[0,8,60,75,1],[2,2,62,96,2],[9,1,64,96,3]]; function adapter(){const calls=[];const sound={enabled:false,ctx:{currentTime:20},enable:async()=>{},voice:(...n)=>calls.push(n),syncGain(){},cancel(){calls.push('cancel');this.enabled=false;},setScrollProgress(p){this.progress=p;},destroy(){calls.push('destroy');},modulate(){}};return {calls,sound,adapter:new LichenAdapter({notes,durationBeats:10},sound)};} test('resume sounds held notes with remaining durations; advance sounds only new onsets',()=>{ const r=adapter();r.adapter.startAt(3,30); assert.deepEqual(r.calls.map(n=>[n[0],n[2]]),[[60,10],[62,2]]); r.calls.length=0;r.adapter.schedule(8.9,9.1,30,20);assert.equal(r.calls.length,1);assert.equal(r.calls[0][0],64);assert.ok(Math.abs(r.calls[0][1]-20.2)<1e-9); }); test('manual position is silent and cancellation drops prior voices',()=>{ const r=adapter();r.adapter.cancel();r.adapter.position(.7);assert.deepEqual(r.calls,['cancel']);assert.equal(r.sound.progress,.7); r.adapter.dispose();r.adapter.dispose();assert.deepEqual(r.calls,['cancel','destroy']); }); import { LichenSound } from '../lib/playground/v2/lichen-sound.mjs'; test('Lichen cancellation clears pending notes and stops every old oscillator',()=>{ const s=new LichenSound({notes,loopBeats:10});const stops=[],envelopes=[]; const gain={value:.6,cancelScheduledValues:t=>envelopes.push(['cancel',t]),setValueAtTime:()=>{},setTargetAtTime:(v,t)=>envelopes.push(['target',v,t])}; s.ctx={currentTime:4,state:'running'};s.master={gain};s.enabled=true;s.visualQueue=[{at:8}]; s.voices.add({env:{gain},oscs:[{stop:t=>stops.push(t)},{stop:t=>stops.push(t)}]}); s.cancel();assert.equal(s.enabled,false);assert.equal(s.visualQueue.length,0);assert.equal(s.voices.size,0); assert.deepEqual(stops,[4.12,4.12]);assert.ok(envelopes.some(e=>e[0]==='target'&&e[1]===0)); }); test('Lichen creates no audio on construction and disposal closes once',()=>{ const s=new LichenSound({notes,loopBeats:10});assert.equal(s.ctx,null);let closes=0; s.ctx={state:'running',currentTime:0,close(){closes++;this.state='closed';}}; s.destroy();s.destroy();assert.equal(closes,1); }); test('resume places the actual HRTF panner at the selected orbit before sounding notes',()=>{ const s=new LichenSound({notes,loopBeats:10,durationBeats:10});const changes=[]; const param=()=>({value:0,cancelScheduledValues(){},setValueAtTime(value){this.value=value;changes.push(value);}}); s.ctx={currentTime:3};s.musicLateral={pan:param()};s.musicPanner={positionX:param(),positionY:param(),positionZ:param()};s.syncGain=()=>{};s.modulate=()=>{}; s.voice=()=>{assert.ok(Math.abs(s.musicPanner.positionX.value-Math.sin(.55+.75*Math.PI*4)*2.6)<1e-9);changes.push('voice');}; const adapter=new LichenAdapter({notes,loopBeats:10,durationBeats:10},s);adapter.position(.75);adapter.startAt(7.5,32); assert.equal(changes.at(-1),'voice');assert.equal(s.orbitAngle,s.orbitTarget); }); test('continuous spatial wrapping moves by the shortest angular path',()=>{ const s=new LichenSound({notes,loopBeats:10});s.orbitAngle=.55+4*Math.PI-.01;s.orbitTarget=.55;s.lastSpatialTime=1;s.placeMusic=()=>{}; const before=s.orbitAngle;s.updateSpatial(1.02);assert.ok(s.orbitAngle>before);assert.ok(s.orbitAngle-before<.02); }); test('loop-start voices preserve the continuous panner and schedule exactly one onset',()=>{ const calls=[];const sound={ctx:{currentTime:4},scheduleSpatialReset:(p,t)=>calls.push(['spatial',p,t]),voice:(n,t)=>calls.push(['voice',n,t])}; const a=new LichenAdapter({notes:[[0,1,60,75,1]],durationBeats:10},sound);a.schedule(0,.1,30,4.1,true); assert.deepEqual(calls,[['voice',60,4.1]]); }); test('continuous smoothing preserves the queued loop panner reset, and cancellation removes it',()=>{ const events=[];const param=()=>({value:0,cancelScheduledValues(t){events.push(['cancel',t]);},setValueAtTime(v,t){events.push(['set',v,t]);},setTargetAtTime(){}}); const s=new LichenSound({notes,loopBeats:10});s.ctx={currentTime:4,state:'running'};s.musicLateral={pan:param()};s.musicPanner={positionX:param(),positionY:param(),positionZ:param()}; s.scheduleSpatialReset(0,4.1);s.placeMusic(.54,4.02); assert.ok(events.some(e=>e[0]==='set'&&e[2]===4.1&&Math.abs(e[1]-Math.sin(.55)*2.6)<1e-9)); s.cancel();assert.equal(s.spatialReset,null); }); // Removing reconciliation would cause repeated attacks and arbitrary silence in gaps. test('manual reconciliation retains identical voices, replaces harmony on reverse/jumps, and bounds gap harmony',()=>{ const calls=[];const voices=new Set();const sound={enabled:true,ctx:{currentTime:0},voices,syncGain(){},setScrollProgress(){},modulate(){},releaseVoice(v){calls.push(['release',v.note]);voices.delete(v);},sustainVoice(v){v.sustained=true;},voice(note,at,hold,velocity,track,pan,sustained){const v={note,track,at,end:Infinity,sustained};voices.add(v);calls.push(['voice',note,sustained]);return v;}}; const a=new LichenAdapter({notes,durationBeats:10},sound);a.perform(3,25);assert.deepEqual([...voices].map(v=>v.note),[60,62]); a.perform(3.01,50);assert.equal(calls.filter(v=>v[0]==='voice').length,2); a.perform(8.5,0);assert.equal(voices.size,2);assert.equal([...voices][0].note,60); a.perform(9.5,120);assert.equal(voices.size,1);assert.equal([...voices][0].note,64); a.perform(1,30);assert.equal([...voices][0].note,60);for(let i=0;i<1000;i++)a.perform(1,0);assert.equal(voices.size,1); }); // Lifecycle gates must not be dropped by the 45ms modulation throttle. test('rapid sustain adoption then release always schedules a fade before oscillator stop',()=>{ for(const nativeHold of [false,true]){ const events=[],s=new LichenSound({notes,loopBeats:10});s.ctx={currentTime:1}; const gain={value:.1,cancelScheduledValues:t=>events.push(['cancel',t]),setValueAtTime:(v,t)=>events.push(['set',v,t]),setTargetAtTime:(v,t,tau)=>events.push(['target',v,t,tau])}; if(nativeHold)gain.cancelAndHoldAtTime=t=>events.push(['hold',t]); const oscs=[0,1].map(()=>({stop:t=>events.push(['stop',t])})),v={env:{gain},oscs,peak:.2,sustained:false}; s.sustainVoice(v);s.ctx.currentTime=1.02;s.releaseVoice(v); assert.ok(events.some(e=>e[0]==='target'&&e[1]===0&&e[2]===1.02&&e[3]===.07)); assert.deepEqual(events.filter(e=>e[0]==='stop').map(e=>e[1]),[1e9+1,1e9+1,1.37,1.37]); const count=events.length;s.releaseVoice(v);assert.equal(events.length,count); } }); import { MIDI_SCORE } from '../lib/playground/v2/lichen-score.mjs'; test('real score 3.95 to 4.01 releases adopted pitch 62 while retaining pitch 60 phase',()=>{ const s=new LichenSound(MIDI_SCORE);s.ctx={currentTime:1};s.enabled=true;s.modulate=()=>{}; const events=new Map(); const make=(note)=>{const calls=[];events.set(note,calls);const gain={value:.1,cancelAndHoldAtTime:t=>calls.push(['hold',t]),setTargetAtTime:(v,t)=>calls.push(['target',v,t])};return{note,track:1,at:0,end:10,peak:.2,sustained:false,env:{gain},oscs:[{stop(){}},{stop(){}}]};}; const retained=make(60),departing=make(62);s.voices.add(retained);s.voices.add(departing); const added=[];s.voice=(note)=>{added.push(note);return{};};const a=new LichenAdapter(MIDI_SCORE,s); a.perform(3.95,30);s.ctx.currentTime=1.02;a.perform(4.01,30); assert.ok(events.get(62).some(e=>e[0]==='target'&&e[1]===0&&e[2]===1.02)); assert.equal(retained.releasing,undefined);assert.equal(s.voices.has(retained),true); assert.equal(events.get(60).filter(e=>e[0]==='target').length,1);assert.deepEqual(added,[64]); }); // A future cancelled onset must never become a zero-to-peak release ramp. test('future score voice cancellation silences the pending onset before it can sound',()=>{ const events=[],s=new LichenSound({notes,loopBeats:10});s.ctx={currentTime:1};const gain={value:0,cancelAndHoldAtTime:t=>events.push(['hold',t]),cancelScheduledValues:t=>events.push(['cancel',t]),setValueAtTime:(v,t)=>events.push(['set',v,t]),setTargetAtTime:(v,t)=>events.push(['target',v,t])};const v={at:1.1,env:{gain},oscs:[{stop:t=>events.push(['stop',t])},{stop:t=>events.push(['stop',t])}]};s.releaseVoice(v);assert.ok(events.some(e=>e[0]==='set'&&e[1]===0&&e[2]===1));assert.ok(events.filter(e=>e[0]==='stop').every(e=>e[1]<=1.1)); }); test('retiming score voices keeps current oscillator identity and removes obsolete future onsets',()=>{ const calls=[],voices=new Set();const sound={enabled:true,ctx:{currentTime:1},voices,syncGain(){},modulate(){},releaseVoice(v){v.releasing=true;calls.push(['release',v.note]);},retimeVoice(v,hold){calls.push(['retime',v.note,hold]);v.sustained=false;},voice(note,at,hold,velocity,track){const v={note,at,hold,track,end:10};voices.add(v);calls.push(['voice',note]);return v;}}; const current={note:60,track:1,at:0,end:10},future={note:62,track:1,at:1.1,end:10};voices.add(current);voices.add(future);const a=new LichenAdapter({notes,durationBeats:10},sound);a.retime(1,48,1);assert.ok(voices.has(current));assert.equal(current.releasing,undefined);assert.ok(future.releasing);assert.deepEqual(calls,[['retime',60,8.75],['release',62]]); }); test('manual timeline movement cannot add foreground gesture notes',()=>{ const s=new LichenSound({notes,loopBeats:10});s.ctx={state:'running',currentTime:2};s.enabled=true;s.seeking=true;const played=[];s.voice=(...args)=>played.push(args);s.gesture(.5,.5,1,1,true);assert.equal(played.length,0);s.seeking=false;s.gesture(.5,.5,1,1,true);assert.equal(played.length,1);assert.equal(played[0][4],5); }); // Changing tempo during an attack must keep its original endpoint and phase. test('tempo reconciliation preserves an in-progress score attack before retiming its gate',()=>{ const events=[],s=new LichenSound({notes,loopBeats:10});s.ctx={currentTime:.65};const gain={value:.054,cancelAndHoldAtTime(){},setValueAtTime:(v,t)=>events.push(['set',v,t]),linearRampToValueAtTime:(v,t)=>events.push(['linear',v,t]),exponentialRampToValueAtTime(){}};const v={peak:.13,release:5.6,attackEnd:1.56,env:{gain},oscs:[{stop(){}},{stop(){}}]};s.retimeVoice(v,9);assert.ok(events.some(e=>e[0]==='linear'&&e[1]===.13&&e[2]===1.56));assert.ok(events.some(e=>e[0]==='set'&&e[1]===.054&&e[2]===.65)); }); // Taking over near a loop boundary must remove its queued hard panner reset. test('manual takeover holds panner automation when discarding a future loop reset',()=>{ const events=[],param=()=>({value:.4,cancelAndHoldAtTime:t=>events.push(['hold',t])}),s=new LichenSound({notes,loopBeats:10});s.ctx={currentTime:1};s.musicLateral={pan:param()};s.musicPanner={positionX:param(),positionY:param(),positionZ:param()};s.spatialReset={at:1.1,angle:.55};s.cancelSpatialReset();assert.equal(s.spatialReset,null);assert.equal(events.length,4);assert.ok(events.every(e=>e[1]===1)); }); // Retrying after the voice cap drains must fill missing notes, not duplicate retained ones. test('automatic recovery from the voice cap reconciles partially admitted harmony',()=>{ const played=[],voices=new Set(),s={enabled:true,ctx:{currentTime:1},voices,syncGain(){},snapSpatial(){},setScrollProgress(){},modulate(){},retimeVoice(){},releaseVoice(v){v.releasing=true;},voice(note,at,hold,vel,track){played.push(note);const v={note,track,at,end:10};voices.add(v);return v;}};const kept={note:60,track:1,at:0,end:10};voices.add(kept);const a=new LichenAdapter({notes,durationBeats:10},s);a.pendingAuto={pace:32,after:.9};a.position(.3);assert.deepEqual(played,[62]);assert.ok(voices.has(kept)); }); // At beat8 the first C4 is still in release while the repeated C4 is attacking. function repeatedScoreVoices(){ const s=new LichenSound(MIDI_SCORE),events=new Map();s.ctx={currentTime:0};s.enabled=true;s.syncGain=()=>{};s.modulate=()=>{}; s.voice=(note,at,hold,velocity,track,pan,sustained=false)=>{const calls=[],gain={value:.01,cancelScheduledValues:t=>calls.push(['cancel',t]),cancelAndHoldAtTime:t=>calls.push(['hold',t]),setValueAtTime:(v,t)=>calls.push(['set',v,t]),linearRampToValueAtTime:(v,t)=>calls.push(['linear',v,t]),exponentialRampToValueAtTime(){},setTargetAtTime:(v,t)=>calls.push(['target',v,t])};const v={note,track,at,gate:at+hold,end:at+hold+5.6,release:5.6,peak:.13,attackEnd:at+1.55,sustained,env:{gain},oscs:[{stop(){}},{stop(){}}]};events.set(v,calls);s.voices.add(v);return v;}; const a=new LichenAdapter(MIDI_SCORE,s),first=MIDI_SCORE.notes.find(n=>n[0]===0&&n[2]===60),repeat=MIDI_SCORE.notes.find(n=>n[0]===8&&n[2]===60); const old=a.playNote(first,first[1],32,.01),current=a.playNote(repeat,repeat[1],32,15);s.ctx.currentTime=8.05*60/32; return{s,a,old,current,events,repeat}; } test('real beat8.05 BPM change preserves repeated C4 attack and never revives beat0 release tail',()=>{ const {s,a,old,current,events}=repeatedScoreVoices(),oscs=current.oscs,oldGate=old.gate; a.retime(8.05,48);assert.equal(current.releasing,undefined);assert.equal(current.oscs,oscs);assert.equal(old.gate,oldGate);assert.ok(!events.get(old).some(e=>e[0]==='linear')); assert.ok(events.get(current).some(e=>e[0]==='linear'&&e[1]===current.peak&&e[2]===16.55));assert.ok(Math.abs(current.gate-(s.ctx.currentTime+3.95*60/48))<1e-9); }); test('manual Hold chooses current repeated occurrence, then preserves its phase across same-pitch reverse and Auto resume',()=>{ const {a,old,current}=repeatedScoreVoices(),oscs=current.oscs;a.perform(8.05,0);assert.equal(current.sustained,true);assert.equal(old.releasing,true); a.perform(.05,30);assert.equal(current.oscs,oscs);assert.equal(current.releasing,undefined);a.retime(.05,48);assert.equal(current.oscs,oscs);assert.equal(current.releasing,undefined);assert.equal(current.sustained,false); }); test('loop repetitions of the same score object select the current gate rather than a previous cycle tail',()=>{ const {s,a,events}=repeatedScoreVoices();s.voices.clear();s.ctx.currentTime=0;const note=[0,3,60,75,1];a.score={notes:[note],durationBeats:4}; const old=a.playNote(note,3,60,.01),current=a.playNote(note,3,60,4);s.ctx.currentTime=4.05;a.retime(.05,48); assert.equal(current.releasing,undefined);assert.ok(events.get(current).some(e=>e[0]==='linear'));assert.ok(!events.get(old).some(e=>e[0]==='linear'));assert.equal(old.gate,3.01); });