Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
281 changes: 281 additions & 0 deletions core.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,281 @@
'use strict';
/* ============================================================
CORE ENGINE — parse / corrupt / serialize Standard MIDI Files
============================================================ */
function mulberry32(seed){let a=seed>>>0;return function(){a|=0;a=(a+0x6D2B79F5)|0;let t=Math.imul(a^(a>>>15),1|a);t=(t+Math.imul(t^(t>>>7),61|t))^t;return((t^(t>>>14))>>>0)/4294967296;};}
function clamp(v,lo,hi){return v<lo?lo:v>hi?hi:v}
function irnd(rnd,lo,hi){return lo+Math.floor(rnd()*(hi-lo+1))}

function parseMidi(bytes){
const data=bytes instanceof Uint8Array?bytes:new Uint8Array(bytes);
let pos=0;
function u32(){const v=(data[pos]<<24)|(data[pos+1]<<16)|(data[pos+2]<<8)|data[pos+3];pos+=4;return v>>>0}
function u16(){const v=(data[pos]<<8)|data[pos+1];pos+=2;return v}
function u8(){return data[pos++]}
function vlq(){let v=0,b;do{b=u8();v=(v<<7)|(b&0x7f)}while(b&0x80);return v}
if(u32()!==0x4D546864)throw new Error('Not a MIDI file — missing MThd header');
const hlen=u32(),format=u16(),ntrks=u16(),division=u16();
pos+=hlen-6;
const tracks=[];
for(let t=0;t<ntrks&&pos<data.length;t++){
if(u32()!==0x4D54726B)throw new Error('Bad track chunk at track '+t);
const len=u32(),end=pos+len,events=[];
let tick=0,runningStatus=0;
while(pos<end){
tick+=vlq();
let status=data[pos];
if(status<0x80){status=runningStatus}else{pos++}
if(status>=0x80&&status<=0xEF)runningStatus=status;
const type=status&0xF0,ch=status&0x0F;
if(status===0xFF){
const metaType=u8(),mlen=vlq();
const payload=data.slice(pos,pos+mlen);pos+=mlen;
events.push({tick,kind:'meta',metaType,data:payload});
}else if(status===0xF0||status===0xF7){
const slen=vlq();
const payload=data.slice(pos,pos+slen);pos+=slen;
events.push({tick,kind:'sysex',status,data:payload});
}else if(type===0xC0||type===0xD0){
events.push({tick,kind:'channel',type,ch,d1:u8()});
}else if(type>=0x80&&type<=0xE0){
const d1=u8(),d2=u8();
const k={tick,kind:'channel',type,ch,d1,d2};
if(type===0x90&&d2===0)k.type=0x80;
events.push(k);
}else{pos=end}
}
pos=end;
tracks.push(events);
}
return{format,division,tracks};
}

function serializeMidi(midi){
const out=[];
const push=(...b)=>{for(const x of b)out.push(x)};
function u32(v){push((v>>>24)&255,(v>>>16)&255,(v>>>8)&255,v&255)}
function u16(v){push((v>>>8)&255,v&255)}
push(0x4D,0x54,0x68,0x64);u32(6);
u16(midi.format);u16(midi.tracks.length);u16(midi.division);
const eotLast=e=>(e.kind==='meta'&&e.metaType===0x2F)?1:0;
for(const events of midi.tracks){
const sorted=events.slice().sort((a,b)=>a.tick-b.tick||eotLast(a)-eotLast(b));
const body=[];
const bp=(...b)=>{for(const x of b)body.push(x)};
const bvlq=v=>{v=Math.max(0,Math.round(v));const st=[v&0x7f];while((v>>=7))st.unshift((v&0x7f)|0x80);bp(...st)};
let last=0,wroteEOT=false;
for(const e of sorted){
if(e.kind==='meta'&&e.metaType===0x2F&&wroteEOT)continue;
bvlq(e.tick-last);last=e.tick;
if(e.kind==='meta'){
bp(0xFF,e.metaType);bvlq(e.data.length);bp(...e.data);
if(e.metaType===0x2F)wroteEOT=true;
}else if(e.kind==='sysex'){
bp(e.status);bvlq(e.data.length);bp(...e.data);
}else{
bp(((e.type===0x80?0x80:e.type)|e.ch),e.d1&0x7f);
if(e.type!==0xC0&&e.type!==0xD0)bp((e.d2??0)&0x7f);
}
}
if(!wroteEOT){bvlq(0);bp(0xFF,0x2F,0x00)}
push(0x4D,0x54,0x72,0x6B);u32(body.length);push(...body);
}
return new Uint8Array(out);
}

const NOTE_NAMES=['C','C#','D','D#','E','F','F#','G','G#','A','A#','B'];
const nn=n=>NOTE_NAMES[n%12]+(Math.floor(n/12)-1);
const GM=['Piano','Chromatic','Organ','Guitar','Bass','Strings','Ensemble','Brass','Reed','Pipe','Synth Lead','Synth Pad','Synth FX','Ethnic','Percussive','FX'];
const gmName=p=>GM[Math.floor(p/8)]+' '+(p%8+1);

function corruptMidi(midi,params,log){
const rnd=mulberry32(params.seed);
const div=midi.division&0x8000?480:midi.division;
const out={format:midi.format,division:midi.division,tracks:[]};
const stats={changed:0,dropped:0,injected:0};
for(let ti=0;ti<midi.tracks.length;ti++){
const src=midi.tracks[ti];
const events=[];
const active=new Map();
let sawProgram=false,sawNotes=false;
for(const e0 of src){
const e=Object.assign({},e0);
if(e.kind==='channel'){
if(e.type===0x90){
sawNotes=true;
const key=e.ch+':'+e.d1;
const rec={newPitch:e.d1,dropped:false};
if(rnd()<params.drop){
rec.dropped=true;stats.dropped++;
if(log)log.push(['drop','note '+nn(e.d1)+' ch'+(e.ch+1)+' deleted @ tick '+e.tick]);
}else{
if(rnd()<params.pitch){
const range=Math.max(2,Math.round(params.pitch*36));
const np=clamp(e.d1+irnd(rnd,-range,range),0,127);
if(np!==e.d1){
if(log)log.push(['pitch','note '+nn(e.d1)+' \u2192 '+nn(np)+' ch'+(e.ch+1)+' @ tick '+e.tick]);
rec.newPitch=np;e.d1=np;stats.changed++;
}
}
if(rnd()<params.velocity){
const nv=1+Math.floor(rnd()*126);
if(log&&Math.abs(nv-e.d2)>25)log.push(['vel','velocity '+e.d2+' \u2192 '+nv+' @ tick '+e.tick]);
e.d2=nv;stats.changed++;
}
}
if(!active.has(key))active.set(key,[]);
active.get(key).push(rec);
if(rec.dropped)continue;
}else if(e.type===0x80){
const key=e.ch+':'+e.d1;
const list=active.get(key);
const rec=list&&list.length?list.shift():null;
if(rec){if(rec.dropped)continue;e.d1=rec.newPitch}
}else if(e.type===0xC0){
sawProgram=true;
if(rnd()<params.instrument){
const np=Math.floor(rnd()*128);
if(log)log.push(['prog',gmName(e.d1)+' \u2192 '+gmName(np)+' ch'+(e.ch+1)+' @ tick '+e.tick]);
e.d1=np;stats.changed++;
}
}
}else if(e.kind==='meta'&&e.metaType===0x51&&e.data.length===3){
if(rnd()<params.tempo){
const us=(e.data[0]<<16)|(e.data[1]<<8)|e.data[2];
const factor=Math.pow(2,(rnd()*2-1)*(1+params.tempo*2));
const nus=clamp(Math.round(us*factor),60000,4000000);
e.data=new Uint8Array([(nus>>16)&255,(nus>>8)&255,nus&255]);
if(log)log.push(['tempo',Math.round(60e6/us)+' \u2192 '+Math.round(60e6/nus)+' BPM @ tick '+e.tick]);
stats.changed++;
}
}
events.push(e);
}
if(!sawProgram&&sawNotes&&rnd()<params.instrument){
const chs=new Set(events.filter(x=>x.kind==='channel'&&x.type===0x90).map(x=>x.ch));
for(const ch of chs){
if(ch===9)continue;
const np=Math.floor(rnd()*128);
events.unshift({tick:0,kind:'channel',type:0xC0,ch,d1:np});
if(log)log.push(['prog','injected '+gmName(np)+' on ch'+(ch+1)]);
stats.injected++;
}
}
if(params.smear>0){
const maxJ=params.smear*div;
const pairs=new Map();
for(const e of events){
if(e.kind!=='channel'||(e.type!==0x90&&e.type!==0x80))continue;
const key=e.ch+':'+e.d1;
if(e.type===0x90){
if(rnd()<Math.min(1,params.smear*1.5)){
const j=Math.round((rnd()*2-1)*maxJ);
if(j!==0){e.tick=Math.max(0,e.tick+j);stats.changed++;if(log&&Math.abs(j)>div/3)log.push(['time','note shifted '+(j>0?'+':'')+j+' ticks'])}
}
if(!pairs.has(key))pairs.set(key,[]);
pairs.get(key).push(e);
}else{
const list=pairs.get(key);
const on=list&&list.length?list.shift():null;
if(on&&rnd()<Math.min(1,params.smear*1.5)){
const j=Math.round((rnd()*2-1)*maxJ);
e.tick=Math.max(on.tick+1,e.tick+j);
}else if(on&&e.tick<=on.tick){
e.tick=on.tick+1;
}
}
}
}
if(ti===0&&params.tempo>0.5){
const maxTick=events.reduce((m,e)=>Math.max(m,e.tick),0);
const n=Math.floor((params.tempo-0.5)*8*rnd());
for(let i=0;i<n;i++){
const us=clamp(Math.round(150000+rnd()*1500000),60000,4000000);
events.push({tick:Math.floor(rnd()*maxTick),kind:'meta',metaType:0x51,data:new Uint8Array([(us>>16)&255,(us>>8)&255,us&255])});
if(log)log.push(['tempo','injected lurch to '+Math.round(60e6/us)+' BPM']);
stats.injected++;
}
}
out.tracks.push(events);
}
out._stats=stats;
return out;
}

function extractNotes(midi){
const ppq=midi.division&0x8000?480:midi.division;
let tempos=[];
for(const tr of midi.tracks)for(const e of tr)
if(e.kind==='meta'&&e.metaType===0x51&&e.data.length===3)
tempos.push({tick:e.tick,us:(e.data[0]<<16)|(e.data[1]<<8)|e.data[2]});
tempos.sort((a,b)=>a.tick-b.tick);
if(!tempos.length||tempos[0].tick>0)tempos.unshift({tick:0,us:500000});
let acc=0;
for(let i=0;i<tempos.length;i++){
if(i>0)acc+=(tempos[i].tick-tempos[i-1].tick)*tempos[i-1].us/ppq/1e6;
tempos[i].sec=acc;
}
function tickToSec(tick){
let t=tempos[0];
for(const x of tempos){if(x.tick<=tick)t=x;else break}
return t.sec+(tick-t.tick)*t.us/ppq/1e6;
}
const notes=[],programs=[];
for(const tr of midi.tracks){
const open=new Map();
for(const e of tr){
if(e.kind!=='channel')continue;
if(e.type===0x90){
const key=e.ch+':'+e.d1;
if(!open.has(key))open.set(key,[]);
open.get(key).push({start:tickToSec(e.tick),pitch:e.d1,vel:e.d2,ch:e.ch});
}else if(e.type===0x80){
const key=e.ch+':'+e.d1;
const list=open.get(key);
const n=list&&list.length?list.shift():null;
if(n){n.end=tickToSec(e.tick);if(n.end>n.start)notes.push(n)}
}else if(e.type===0xC0){
programs.push({sec:tickToSec(e.tick),ch:e.ch,prog:e.d1});
}
}
for(const list of open.values())for(const n of list){n.end=n.start+0.5;notes.push(n)}
}
notes.sort((a,b)=>a.start-b.start);
programs.sort((a,b)=>a.sec-b.sec);
const duration=notes.reduce((m,n)=>Math.max(m,n.end),0);
return{notes,programs,duration};
}

function makeDemoMidi(){
const ppq=480;
const t0=[{tick:0,kind:'meta',metaType:0x51,data:new Uint8Array([0x07,0xA1,0x20])}];
const melody=[],bass=[],drums=[];
const scale=[60,62,64,67,69,72,74,76];
let tick=0;
for(let bar=0;bar<8;bar++)for(let i=0;i<8;i++){
const p=scale[(bar*3+i*2)%scale.length];
melody.push({tick,kind:'channel',type:0x90,ch:0,d1:p,d2:90});
melody.push({tick:tick+ppq/2-10,kind:'channel',type:0x80,ch:0,d1:p,d2:0});
tick+=ppq/2;
}
tick=0;
const roots=[48,45,41,43];
for(let bar=0;bar<8;bar++){
const r=roots[bar%4];
bass.push({tick,kind:'channel',type:0x90,ch:1,d1:r,d2:100});
bass.push({tick:tick+ppq*4-20,kind:'channel',type:0x80,ch:1,d1:r,d2:0});
tick+=ppq*4;
}
tick=0;
for(let beat=0;beat<32;beat++){
const d=beat%2===0?36:38;
drums.push({tick,kind:'channel',type:0x90,ch:9,d1:d,d2:110});
drums.push({tick:tick+60,kind:'channel',type:0x80,ch:9,d1:d,d2:0});
drums.push({tick:tick+ppq/2,kind:'channel',type:0x90,ch:9,d1:42,d2:70});
drums.push({tick:tick+ppq/2+40,kind:'channel',type:0x80,ch:9,d1:42,d2:0});
tick+=ppq;
}
melody.unshift({tick:0,kind:'channel',type:0xC0,ch:0,d1:80});
bass.unshift({tick:0,kind:'channel',type:0xC0,ch:1,d1:38});
return{format:1,division:ppq,tracks:[t0,melody,bass,drums]};
}
Loading