LoginSignup
0
0

More than 1 year has passed since last update.

JSで読んだSMFをファミコン風音源でとりあえず鳴らす

Posted at

これの続きです

実時間変換

まず各イベントのデルタタイムを実時間に変換する
基本的に
60000000/分解能/テンポ*デルタタイム積算
テンポは途中で変更が入る
個人的に悩んだのは例えばノートオンからノートオフまでの間に別トラックでテンポ変更が入った場合のテンポの扱い
自明といえば自明だがもともとmidiはリアルタイムで動くものなのでノートオンからテンポ変更までとテンポ変更からノートオフまで別のテンポで計算する必要がある気がした
実装としては一度すべてのトラックでデルタタイム(相対的なtick)から絶対的なtickに変換してトラックを結合してソートしてから相対的なtickに戻してテンポを考慮して積算していくような感じ
できたものがこれ

//let w=smfin(blob);
//トラックを結合して絶対時間に変換
const smfuni=w=>w.tracks
	.flatMap((x,trk)=>x.reduce((a,y)=>(y={...y,t:a[0]+=y.dt,trk},delete y.dt,a.push(y),a),[0]).slice(1))//絶対化
	.sort((a,b)=>a.t-b.t)
	.reduce((a,x)=>(
		[x.t,a[0]]=[x.t-a[0],x.t],//相対化と保存
		x.t=a[1]*x.t+a[a.length-1].t,//実時間変換して絶対化
		x.name=='meta'&&x.type==0x51&&(a[1]=((x.data[0]<<16)|(x.data[1]<<8)|x.data[2])/w.header.division*1e-6),//テンポ設定
		a.push(x),a),[0,0,{t:0}]).slice(3);
w.uni=smfuni(w);

デルタタイムdtを実時間tに変換している

webaudio向けにフォーマット

midiはnoteOn,noteOffと一つの音符に対してイベントが分かれているのでこれをwebaudioで鳴らしやすいように変換する
noteOnを見つけた時点で指定のチャンネルの指定の鍵盤番号にその情報を入れてnoteOffで音符を確定を繰り返す
サステインが踏まれている場合は踏まれてる間のnoteOffで「サステインが踏まれている間に離された」状態にしてnoteOnのときに音符を確定する
もちろんそのあとに同じ鍵盤が押されれることもあるのでその点も留意
サステインの影響で鍵盤の押し始めが前後するので最後にソートも忘れずに

w=w.uni.reduce((a,x)=>{
	if(x.name=='noteOn'&&x.vel>0){
		if(a[x.ch].notes[x.note].pushed!=0){
			const {note,ch,trk,vel,t}=a[x.ch].notes[x.note];a.push({note,ch,trk,vel,t,d:x.t-t});
		}
		a[x.ch].notes[x.note]={...x,pushed:1};
	}
	else if(((x.name=='noteOn'&&x.vel==0)||x.name=='noteOff')&&a[x.ch].notes[x.note].pushed==1){
		if(a[x.ch].sus)a[x.ch].notes[x.note].pushed=-1;
		else{
			const {note,ch,trk,vel,t}=a[x.ch].notes[x.note];a[x.ch].notes[x.note].pushed=0;
			a.push({note,ch,trk,vel,t,d:x.t-t});
		}
	}
	else if(x.name=='ctrl'&&x.ctrl==0x40){
		if(a[x.ch].sus!=(a[x.ch].sus=(x.value>>>6))&&!a[x.ch].sus){
			a.push(...a[x.ch].notes.flatMap(y=>{
				if(!~y.pushed){
					const {note,ch,trk,vel,t}=y;y.pushed=0;
					return[{note,ch,trk,vel,t,d:x.t-t}];
				}else return[];
			}));
		}
	}
	return a;
},new Array(16).fill().map(()=>({notes:new Array(127).fill().map(()=>({pushed:0})),sus:0}))).slice(16);
w=w.sort((a,b)=>a.t-b.t);

/*
[
    {ch: 0,d: 0.4736834999999999,note: 66,t: 0.5000000000000001,vel: 0.15748031496062995}
    ...
]
*/

ひとまずこれで鍵盤番号とその開始時間と長さと強さが得られた

ファミコン風音源の生成

  • 適当に調べて勝手な解釈で作っているので多少ずれている箇所があるかもしれないです

ファミコン風音源について軽く説明するとファミコンは基本的に6種類の波形が出せる

PWM(
    12.5%(01000000),
    25%(01100000),
    50%(01111000),
    75%(10011111)
),
三角波(4bit(fed...32100123...def)),
ノイズ(計算式はググれば出てくる)

今回は前章で楽器の判定を行わなかったのでドラムパートが分からない
したがってノイズを除いた5種類の波形を(トラック番号をもとに適当に)鳴らしていく

波形の実装について最初は三角波をwaveShaperで変形させていたのだがwebkit系での動作が重かったのでperiodicWaveを使った
periodicWaveはフーリエ級数を渡すことで任意の波形のoscillatorを作れるものだが任意の波形をフーリエ級数にするための手段は無い
今回はこちらの記事を参考にFFTを走らせた

直流成分のカットを忘れずに

const fft=(w,l=12)=>{
	w=w.flatMap(x=>new Array(2**l/w.length).fill().map(()=>[x,0]));
	const add=([[a,b],[c,d]])=>[a+c,b+d],sub=([[a,b],[c,d]])=>[a-c,b-d],mul=([a,b],[c,d])=>[a*c-b*d,a*d+b*c],cm=t=>[Math.cos(t),Math.sin(t)],trs=x=>x[0].map((_,i)=>x.map(y=>y[i])),
		core=(n=w.length,t=-Math.PI/n,p=0,o=1,x,y)=>n==1?[w[p]]:(y=core(n/=2,t*=2,p+o,o*=2),x=core(n,t,p,o).map((z,i)=>[z,mul(y[i],cm(t*i))]),x.map(add).concat(x.map(sub)));
	return trs(core()).map(x=>new Float32Array([0,...x.slice(1)]));
},
pwav=[[0,1,0,0,0,0,0,0],[0,1,1,0,0,0,0,0],[0,1,1,1,1,0,0,0],[1,0,0,1,1,1,1,1],Array.from('fedcba98765432100123456789abcdef',x=>parseInt(x,16)/15)].map(x=>actx.createPeriodicWave(...fft(x.map(y=>y*2-1))));
// pwav [PeriodicWave,PeriodicWave,PeriodicWave,PeriodicWave,PeriodicWave]

あとはこれをoscillaterにぶち込めば普通に使える

鳴らす

webAudio向けにフォーマットしたデータを一定個数ずつ2秒早く流し込む
gainを使って弦楽器風の減衰をつけた
鳴り始め鳴り終わりのプチプチ音を無くすために一瞬フェードをかけた

const main=(o=0,l=256,ot=0)=>{
	console.log(`note:${o}, t_corr:${ot.toFixed(3)}`);
	ot+=w[o].t;
	w.slice(o,o+l).forEach(x=>{
		const osc=actx.createOscillator(),g0=actx.createGain(),g1=actx.createGain(),d=x.d,ct=actx.currentTime+x.t-ot;
		osc.setPeriodicWave(pwav[x.trk%5]);
		osc.frequency.value=440*(2**((x.note-69)/12))*((x.ch==9?5:(x.ch%5))==5?.002:1);
		x.vel/=127;x.vel*=.2;
		g0.gain.setTargetAtTime(0,ct,230/(x.note**1.5));
		g1.gain.setValueAtTime(0,0);g1.gain.setValueAtTime(0,ct);g1.gain.linearRampToValueAtTime(x.vel,ct+.01);g1.gain.setValueAtTime(x.vel,ct+d);g1.gain.linearRampToValueAtTime(0,ct+d+.02);
		[osc,g0,g1,master].reduce((a,x)=>(a.connect(x),x));
		osc.start(ct);osc.stop(ct+d+.02);
	});
	const t=actx.currentTime,next=w[o+l].t-ot;
	if(w[o+l])setTimeout(()=>main(o+l,l,actx.currentTime-t-next),(next-2)*1000);
};
main();

できた

めでたしめでたし

とりあえず鳴っているだけなので他のコントロールも読めるようにしたい

0
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
0