Skip to content

Commit e053af8

Browse files
authored
feat: route imported and frozen clips through volume, ring, and pitch modulation (#27)
1 parent b19cfc4 commit e053af8

6 files changed

Lines changed: 111 additions & 2 deletions

File tree

‎src/app.js‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -217,4 +217,5 @@ refresh();
217217

218218
import { installLibrary } from './library.js';
219219
import { installAudioLibrary } from './audio-library.js';
220-
app.ready=installLibrary(app).then(()=>installAudioLibrary(app));
220+
import { installModulation } from './modulation-ui.js';
221+
app.ready=installLibrary(app).then(()=>{installAudioLibrary(app);installModulation(app);});

‎src/library.js‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,7 @@ export async function installLibrary(app){
1515
if(p.assets.some(k=>!result[k]))throw new Error('A required audio source is missing. Restore its project bundle.');
1616
return result;
1717
};
18-
async function refreshEntries(){entries=(await store.list('sounds')).sort((a,b)=>a.order-b.order);renderBoard();}
18+
async function refreshEntries(){entries=(await store.list('sounds')).sort((a,b)=>a.order-b.order);renderBoard();window.dispatchEvent(new Event('sigint-library-change'));}
1919
function retain(asset){cache.delete(asset.id);cache.set(asset.id,asset);cacheBytes=[...cache.values()].reduce((n,a)=>n+a.samples.byteLength,0);while(cacheBytes>8*1024**2&&cache.size>1){const key=cache.keys().next().value;cacheBytes-=cache.get(key).samples.byteLength;cache.delete(key);}return asset;}
2020
async function clip(key){if(cache.has(key))return retain(cache.get(key));const a=await store.get('audio',key);if(!a)throw new Error('Saved audio is missing. Open its project and save a new render.');return retain(a);}
2121
async function play(entry){

‎src/modulation-ui.js‎

Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,62 @@
1+
import { COLORS } from './model.js';
2+
export function installModulation(app){
3+
const el=app.element,panel=app.addPanel('Shape one sound with another');
4+
const heading=el('p',{class:'hint'},'Choose a frozen audio clip as the modulator. It can follow volume, multiply the waveform, or bend pitch. Saved sounds are copied by content, so later edits to a pad do not change this sound.');
5+
const grid=el('div',{class:'control-grid',id:'modulation-controls'}),formula=el('code',{class:'route-formula',id:'modulation-formula'});
6+
panel.append(heading,grid,formula);
7+
let refreshToken=0;
8+
function field(label,node){return el('label',{},label,node);}
9+
function select(id,values,value,change){
10+
const node=el('select',{id,'data-mod-key':id,onchange:e=>change(e.target.value)},...values.map(([value,text])=>el('option',{value},text)));node.value=value;return node;
11+
}
12+
function numeric(id,value,min,max,step,change){return el('input',{id,'data-mod-key':id,type:'number',value,min,max,step,onchange:e=>change(Number(e.target.value))});}
13+
function check(id,value,change){return el('input',{id,'data-mod-key':id,type:'checkbox',checked:value,onchange:e=>change(e.target.checked)});}
14+
async function attachAudio(id,key){
15+
try{
16+
if(!id){app.edit(p=>{p.modulation[key]='';p.modulation.enabled=false;});return;}
17+
const asset=app.assets[id]||await app.store.get('audio',id);if(!asset)throw new Error('That clip is unavailable');
18+
if(!app.project.assets.includes(id)&&app.project.assets.length>=8)throw new Error('Remove an unused clip before adding a ninth audio source');
19+
app.assets[id]=asset;app.edit(p=>{if(!p.assets.includes(id))p.assets.push(id);p.modulation[key]=id;});
20+
}catch(e){app.status(e.message,true);refresh();}
21+
}
22+
async function refresh(){
23+
const token=++refreshToken,p=app.project,m=p.modulation;
24+
const [meta,sounds]=await Promise.all([app.store.list('audioMeta'),app.store.list('sounds')]);
25+
if(token!==refreshToken)return;
26+
const focus=document.activeElement?.dataset.modKey;
27+
const available=new Map();
28+
for(const a of meta)if(p.assets.includes(a.id))available.set(a.id,a.name+' · imported/frozen clip');
29+
for(const s of sounds.sort((a,b)=>a.order-b.order))if(!available.has(s.audioId))available.set(s.audioId,s.name+' · soundboard');
30+
const options=[['','Choose a clip…'],...available.entries()];
31+
const set=(key,value)=>app.edit(p=>{p.modulation[key]=value;});
32+
grid.replaceChildren(
33+
field('Enable modulation',check('mod-enabled',m.enabled,v=>{if(v&&!m.sourceId){app.status('Choose a modulator clip first.',true);refresh();return;}set('enabled',v);})),
34+
field('Modulator clip',select('mod-source',options,m.sourceId,id=>attachAudio(id,'sourceId'))),
35+
field('Effect',select('mod-mode',[['volume','Volume follower'],['ring','Ring modulation'],['pitch','Pitch / varispeed']],m.mode,value=>set('mode',value))),
36+
field('Apply to',select('mod-target',[['mix','Current sound / mix'],...(p.mode==='timeline'?[]:[['oscillator','One oscillator']]),['asset','Another audio clip']],m.target,value=>{
37+
app.edit(p=>{p.modulation.target=value;if(value==='asset'&&!p.modulation.carrierId)p.modulation.enabled=false;});
38+
}))
39+
);
40+
if(m.target==='oscillator'){
41+
const node=select('mod-oscillator',p.voices.map(v=>[v.id,v.name]),m.oscillatorId,v=>set('oscillatorId',v));node.style.borderColor=COLORS[p.voices.find(v=>v.id===m.oscillatorId).color];
42+
grid.append(field('Carrier oscillator',node));
43+
}
44+
if(m.target==='asset')grid.append(field('Carrier clip',select('mod-carrier',options,m.carrierId,id=>attachAudio(id,'carrierId'))));
45+
if(m.mode==='pitch')grid.append(field('Pitch depth · semitones ±',numeric('mod-semitones',m.semitones,0,24,.1,v=>set('semitones',v))));
46+
else grid.append(field('Depth · 0–1',numeric('mod-depth',m.depth,0,1,.01,v=>set('depth',v))));
47+
if(m.mode==='volume')grid.append(field('Attack · seconds',numeric('mod-attack',m.attack,.001,1,.001,v=>set('attack',v))),field('Release · seconds',numeric('mod-release',m.release,.001,1,.001,v=>set('release',v))));
48+
grid.append(field('Modulator starts · seconds',numeric('mod-offset',m.offset,0,10,.001,v=>set('offset',v))),field('Loop modulator',check('mod-loop',m.sourceLoop,v=>set('sourceLoop',v))));
49+
if(m.target==='asset'||(m.target==='mix'&&m.mode==='pitch'))grid.append(field('Loop carrier',check('carrier-loop',m.carrierLoop,v=>set('carrierLoop',v))));
50+
formula.textContent=m.mode==='volume'?'y(t) = carrier(t) × [1 − d(t) + d(t) × envelope(|modulator(t)|)]'
51+
:m.mode==='ring'?'y(t) = carrier(t) × [1 − d(t) + d(t) × modulator(t)]'
52+
:m.target==='oscillator'?'f(t) = basePitch(t) × 2^(D(t) × modulator(t) / 12); phase = integral of frequency'
53+
:'readhead(t) = integral of 2^(D(t) × modulator(t) / 12); y(t) = carrier(readhead(t)). Pitch and timing change together.';
54+
if(!m.sourceLoop)formula.textContent+=' Outside the modulator clip: unchanged carrier.';
55+
if(focus)grid.querySelector('[data-mod-key="'+CSS.escape(focus)+'"]')?.focus();
56+
}
57+
app.onChange(()=>refresh().catch(e=>app.status(e.message,true)));app.onAssetsChanged=()=>refresh().catch(e=>app.status(e.message,true));
58+
const oldRefresh=app.library.refresh;app.library.refresh=async()=>{await oldRefresh();await refresh();};
59+
// Soundboard operations publish a local event after their atomic transaction.
60+
window.addEventListener('sigint-library-change',()=>refresh().catch(e=>app.status(e.message,true)));
61+
refresh().catch(e=>app.status(e.message,true));app.modulationUI={refresh};
62+
}

‎src/style.css‎

Lines changed: 2 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

‎tests/browser.mjs‎

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -51,6 +51,19 @@ try {
5151
await page.waitForFunction(t=>document.querySelector('#import-state').textContent.startsWith('Kept tone.'+t),type,{timeout:30000});
5252
}
5353
assert.equal(await page.locator('.asset-row').count(),2);
54+
await page.locator('#mod-mode').selectOption('ring');
55+
await page.locator('#mod-depth').fill('1');await page.locator('#mod-depth').press('Tab');
56+
await page.locator('#mod-enabled').check();
57+
await page.getByRole('button',{name:'Play sound',exact:true}).click();
58+
await page.waitForFunction(()=>!!window.testApp.lastRender);
59+
assert.ok(await page.evaluate(()=>window.testApp.lastRender.peak>0));
60+
await page.locator('#stop').click();
61+
await page.locator('#mod-mode').selectOption('pitch');
62+
await page.locator('#mod-target').selectOption('oscillator');
63+
await page.locator('#mod-oscillator').selectOption(await page.evaluate(()=>window.testApp.project.voices[0].id));
64+
const route=await page.evaluate(()=>({target:window.testApp.project.modulation.oscillatorId,selected:window.testApp.project.selectedId}));
65+
assert.notEqual(route.target,route.selected);
66+
await page.getByRole('button',{name:'Play sound',exact:true}).click();await page.waitForFunction(()=>!!window.testApp.lastRender);await page.locator('#stop').click();
5467
assert.deepEqual(errors,[]);
5568
if(name==='chromium')console.log('VISUAL:'+((await page.screenshot({type:'jpeg',quality:55,fullPage:true})).toString('base64')));
5669
console.log(name+': draw, voices, envelopes, undo/redo, worker audio, and Stop passed');

‎tests/render.test.mjs‎

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -48,3 +48,34 @@ test('sample-rate conversion preserves duration and bounded low-frequency tone',
4848
const out=resamplePCM(v,44100,48000);assert.equal(out.length,4800);
4949
assert.ok(Math.max(...out)<.51);
5050
});
51+
52+
function toneAmplitude(samples,rate,hz,from=.025,to=.085){
53+
let re=0,im=0,count=0;
54+
for(let i=Math.round(from*rate);i<Math.min(samples.length,Math.round(to*rate));i++){re+=samples[i]*Math.cos(2*Math.PI*hz*i/rate);im+=samples[i]*Math.sin(2*Math.PI*hz*i/rate);count++;}
55+
return 2*Math.hypot(re,im)/count;
56+
}
57+
test('constant bipolar pitch modulators produce octave ratios with continuous integrated phase',async()=>{
58+
for(const sign of [-1,1]){
59+
const p=project();p.duration=.12;p.voices[0].hz=1000;p.assets=['dc'];
60+
p.modulation={...p.modulation,enabled:true,mode:'pitch',target:'oscillator',sourceId:'dc',semitones:12};
61+
const result=await render(p,{dc:{sampleRate:48000,samples:new Float32Array(9600).fill(sign)}});
62+
assert.ok(toneAmplitude(result.samples,48000,sign===1?2000:500)>.65);
63+
}
64+
});
65+
test('clip pitch uses a varispeed readhead and explicit carrier loop behavior',async()=>{
66+
const p=project();p.duration=.12;p.assets=['tone','dc'];
67+
p.modulation={...p.modulation,enabled:true,mode:'pitch',target:'asset',sourceId:'dc',carrierId:'tone',semitones:12};
68+
const assets={tone:{sampleRate:48000,samples:Float32Array.from({length:4800},(_,i)=>.5*Math.sin(2*Math.PI*1000*i/48000))},dc:{sampleRate:48000,samples:new Float32Array(9600).fill(1)}};
69+
let result=await render(p,assets);
70+
assert.ok(toneAmplitude(result.samples,48000,2000,.02,.04)>.47);
71+
assert.ok(result.samples.slice(4000,5000).every(x=>Math.abs(x)<1e-8));
72+
p.modulation.carrierLoop=true;result=await render(p,assets);
73+
assert.ok(toneAmplitude(result.samples,48000,2000,.07,.10)>.47);
74+
});
75+
test('volume follower attack, offset, and dry fallback have distinct audible behavior',async()=>{
76+
const p=project();p.duration=.15;p.voices[0].hz=1000;p.assets=['level'];
77+
p.modulation={...p.modulation,enabled:true,mode:'volume',target:'mix',sourceId:'level',depth:1,attack:.01,release:.03,offset:.02};
78+
const a=new Float32Array(4800).fill(.5),out=await render(p,{level:{sampleRate:48000,samples:a}});
79+
const early=toneAmplitude(out.samples,48000,1000,.024,.029),settled=toneAmplitude(out.samples,48000,1000,.07,.09),outside=toneAmplitude(out.samples,48000,1000,.13,.14);
80+
assert.ok(early<settled);assert.ok(settled>.32&&settled<.37);assert.ok(outside>.65);
81+
});

0 commit comments

Comments
 (0)