picoaudio
Version:
PicoAudio.js is light MIDI player using Web Audio API
1,322 lines (1,220 loc) • 127 kB
JavaScript
/*
argsObj {
debug,
audioContext,
picoAudio,
etc (this.settings.xxx)
}
*/
function picoAudioConstructor(argsObj) {
this.debug = false;
this.isStarted = false;
this.isPlayed = false;
this.settings = {
masterVolume: 1,
generateVolume: 0.15,
tempo: 120,
basePitch: 440,
resolution: 480,
isWebMIDI: false,
WebMIDIPortOutputs: null,
WebMIDIPortOutput: null,
WebMIDIPort: -1, // -1:auto
WebMIDIPortSysEx: true, // MIDIデバイスのフルコントロールをするかどうか(SysExを使うかどうか)(httpsじゃないと使えない)
isReverb: true, // リバーブONにするか
reverbVolume: 1.5,
initReverb: 10,
isChorus: true,
chorusVolume: 0.5,
isCC111: true,
loop: false,
isSkipBeginning: false, // 冒頭の余白をスキップ
isSkipEnding: true, // 末尾の空白をスキップ
holdOnValue: 64,
maxPoly: -1, // 同時発音数 -1:infinity
maxPercPoly: -1, // 同時発音数(パーカッション) -1:infinity
isOfflineRendering: false, // TODO 演奏データを作成してから演奏する
isSameDrumSoundOverlap: false, // 同じドラムの音が重なることを許容するか
baseLatency: -1 // レイテンシの設定 -1:auto
};
// argsObjで設定値が指定されていたら上書きする
rewriteVar(this, argsObj, "debug");
for (let key in this.settings) {
rewriteVar(this.settings, argsObj, key);
}
this.events = [];
this.trigger = {
isNoteTrigger: true,
play: ()=>{},
stop: ()=>{},
noteOn: ()=>{},
noteOff: ()=>{},
songEnd: ()=>{}
};
this.states = {
isPlaying: false,
startTime: 0,
stopTime: 0,
stopFuncs: [],
webMIDIWaitState: null,
webMIDIStopTime: 0,
playIndices: [0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0],
updateBufTime: 100,
updateBufMaxTime: 350,
updateIntervalTime: 0,
latencyLimitTime: 0
};
this.hashedDataList = [];
this.hashedMessageList = [];
this.playData = null;
this.channels = [];
this.tempoTrack = [
{ timing: 0, value: 120 },
{ timing: 0, value: 120 }
];
this.cc111Time = -1;
this.onSongEndListener = null;
this.baseLatency = 0.01;
// チャンネルの設定値(音色, 減衰, 音量) //
for (let i=0; i<17; i++) {
this.channels.push([0, 0, 1]);
}
// AudioContextがある場合はそのまま初期化、なければAudioContextを用いる初期化をinit()で
if (argsObj && argsObj.audioContext) {
this.init(argsObj);
}
}
function rewriteVar(dist, src, vr) {
if (src && src[vr] != null && dist && dist[vr] != null) {
dist[vr] = src[vr];
}
}
/**
* 固定パターンの乱数を提供するクラス
*/
class RandomUtil {
/**
* 乱数のシード値をリセットする
*/
static resetSeed() {
this.init = true;
this.x = 123456789;
this.y = 362436069;
this.z = 521288629;
this.w = 8867512;
}
/**
* 乱数を返す
*
* Math.random() と違い、毎回固定パターンで乱数が返される
* Xorshiftアルゴリズム
* @returns {number} 乱数
*/
static random() {
if (!this.init) this.resetSeed();
const t = this.x ^ (this.x << 11);
this.x = this.y; this.y = this.z; this.z = this.w;
let r = this.w = (this.w ^ (this.w >>> 19)) ^ (t ^ (t >>> 8));
r = Math.abs(r) / 2147483648 % 2;
return r;
}
}
/**
* 補間を提供するクラス
*/
class InterpolationUtil {
/**
* 波形を線形補間する
* @param {AudioBuffer} buffer 補間結果を入れるAudioBuffer
* @param {Array} vtBufs 仮想音源の配列([Float32Array, Float32Array])
*/
static lerpWave(buffer, vtBufs) {
// 仮想サンプルレート音源を本番音源に変換する //
const bufferSize = buffer.getChannelData(0).length;
const vtBufsSize = vtBufs[0].length;
if (bufferSize == vtBufsSize) { // 線形補間の必要なし //
for (let ch=0; ch<2; ch++) {
const data = buffer.getChannelData(ch);
const vtBuf = vtBufs[ch];
for (let i=0; i<bufferSize; i++) {
data[i] = vtBuf[i];
}
}
} else { // 線形補間 //
const ratio = vtBufsSize / bufferSize;
for (let ch=0; ch<2; ch++) {
const data = buffer.getChannelData(ch);
const vtBuf = vtBufs[ch];
for (let i=0; i<bufferSize; i++) {
// 線形補間しながら波形を作成 //
// TODO 音がまだ少し違和感あるので、スプライン補正に変更した方がいいかも //
const idxF = i * ratio;
const idx1 = Math.trunc(idxF);
const idx2 = (idx1 + 1) % vtBufsSize;
const idxR = idxF - idx1;
const w = vtBuf[idx1] * (1 - idxR) + vtBuf[idx2] * idxR;
data[i] = w;
}
}
}
}
}
function init(argsObj) {
if (this.isStarted) return;
this.isStarted = true;
const audioContext = argsObj && argsObj.audioContext;
const picoAudio = argsObj && argsObj.picoAudio;
// AudioContextを生成 //
const AudioContext = window.AudioContext || window.webkitAudioContext;
this.context = audioContext ? audioContext : new AudioContext();
// マスターボリューム //
// リアルタイムで音量変更するためにdestination前にgainNodeを一つ噛ませる
this.masterGainNode = this.context.createGain();
this.masterGainNode.gain.value = this.settings.masterVolume;
// 仮想サンプルレート //
const sampleRate = this.context.sampleRate;
const sampleRateVT = sampleRate >= 48000 ? 48000 : sampleRate;
// ホワイトノイズ //
if (picoAudio && picoAudio.whitenoise) { // 使いまわし
this.whitenoise = picoAudio.whitenoise;
} else {
RandomUtil.resetSeed(); // 乱数パターンを固定にする(Math.random()を使わない)
// 再生環境のサンプルレートによって音が変わってしまうので //
// 一旦仮想サンプルレートで音源を作成する //
const seLength = 1;
const sampleLength = sampleRate * seLength;
const sampleLengthVT = sampleRateVT * seLength;
const vtBufs = [];
for (let ch=0; ch<2; ch++) {
vtBufs.push(new Float32Array(sampleLengthVT));
const vtBuf = vtBufs[ch];
for (let i=0; i<sampleLengthVT; i++) {
const r = RandomUtil.random();
vtBuf[i] = r * 2 - 1;
}
}
// 仮想サンプルレート音源を本番音源に変換する //
this.whitenoise = this.context.createBuffer(2, sampleLength, sampleRate);
InterpolationUtil.lerpWave(this.whitenoise, vtBufs);
}
// リバーブ用のインパルス応答音声データ作成(てきとう) //
if (picoAudio && picoAudio.impulseResponse) { // 使いまわし
this.impulseResponse = picoAudio.impulseResponse;
} else {
RandomUtil.resetSeed(); // 乱数パターンを固定にする(Math.random()を使わない)
// 再生環境のサンプルレートによって音が変わってしまうので //
// 一旦仮想サンプルレートで音源を作成する //
const seLength = 3.5;
const sampleLength = sampleRate * seLength;
const sampleLengthVT = sampleRateVT * seLength;
const vtBufs = [];
for (let ch=0; ch<2; ch++) {
vtBufs.push(new Float32Array(sampleLengthVT));
const vtBuf = vtBufs[ch];
for (let i=0; i<sampleLengthVT; i++) {
const v = ((sampleLengthVT - i) / sampleLengthVT);
const s = i / sampleRateVT;
const d = (s < 0.030 ? 0 : v)
* (s >= 0.030 && s < 0.031 ? v*2 : v)
* (s >= 0.040 && s < 0.042 ? v*1.5 : v)
* (s >= 0.050 && s < 0.054 ? v*1.25 : v)
* RandomUtil.random() * 0.2 * Math.pow((v-0.030), 4);
vtBuf[i] = d;
}
}
// 仮想サンプルレート音源を本番音源に変換する //
this.impulseResponse = this.context.createBuffer(2, sampleLength, this.context.sampleRate);
InterpolationUtil.lerpWave(this.impulseResponse, vtBufs);
}
// リバーブ用のAudioNode作成・接続 //
this.convolver = this.context.createConvolver();
this.convolver.buffer = this.impulseResponse;
this.convolver.normalize = true;
this.convolverGainNode = this.context.createGain();
this.convolverGainNode.gain.value = this.settings.reverbVolume;
this.convolver.connect(this.convolverGainNode);
this.convolverGainNode.connect(this.masterGainNode);
this.masterGainNode.connect(this.context.destination);
// コーラス用のAudioNode作成・接続 //
this.chorusDelayNode = this.context.createDelay();
this.chorusGainNode = this.context.createGain();
this.chorusOscillator = this.context.createOscillator();
this.chorusLfoGainNode = this.context.createGain();
this.chorusDelayNode.delayTime.value = 0.025;
this.chorusLfoGainNode.gain.value = 0.010;
this.chorusOscillator.frequency.value = 0.05;
this.chorusGainNode.gain.value = this.settings.chorusVolume;
this.chorusOscillator.connect(this.chorusLfoGainNode);
this.chorusLfoGainNode.connect(this.chorusDelayNode.delayTime);
this.chorusDelayNode.connect(this.chorusGainNode);
this.chorusGainNode.connect(this.masterGainNode);
this.masterGainNode.connect(this.context.destination);
this.chorusOscillator.start(0);
// レイテンシの設定 //
this.baseLatency = this.context.baseLatency || this.baseLatency;
if (this.settings.baseLatency != -1) {
this.baseLatency = this.settings.baseLatency;
}
}
class Performance {
static now() {
// Unsupport performance.now()
if (this._now == null) {
if (typeof window.performance === "undefined") {
this._now = () => { return window.Date.now(); };
} else {
this._now = () => { return window.performance.now(); };
}
}
return this._now();
}
}
const Number_MAX_SAFE_INTEGER = 0x1FFFFFFFFFFFFF;
function setData(data) {
if (this.debug) {
var syoriTimeS = Performance.now();
}
if (this.states.isPlaying) this.stop();
this.playData = data;
this.settings.resolution = data.header.resolution;
this.settings.tempo = data.tempo || 120;
this.tempoTrack = data.tempoTrack;
this.cc111Time = data.cc111Time;
this.firstNoteOnTiming = data.firstNoteOnTiming;
this.lastNoteOffTiming = data.lastNoteOffTiming;
this.firstNoteOnTime = data.firstNoteOnTime;
this.lastNoteOffTime = data.lastNoteOffTime;
this.lastEventTiming = data.lastEventTiming;
this.lastEventTime = data.lastEventTime;
this.initStatus();
if (this.debug) {
const syoriTimeE = Performance.now();
console.log("setData time", syoriTimeE - syoriTimeS);
}
return this;
}
function initStatus(isSongLooping, isLight) {
// WebMIDI使用中の場合、initStatus()連打の対策 //
if (this.settings.isWebMIDI) {
if (this.states.webMIDIWaitState != null) return;
}
// 演奏中の場合、停止する //
this.stop(isSongLooping);
// statesを初期化 //
this.states = {
isPlaying: false,
startTime: 0,
stopTime: 0,
stopFuncs: [],
webMIDIWaitState: null,
webMIDIStopTime: this.states.webMIDIStopTime,
playIndices: [0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0],
updateBufTime: this.states.updateBufTime,
updateBufMaxTime: this.states.updateBufMaxTime,
updateIntervalTime: this.states.updateIntervalTime,
latencyLimitTime: this.states.latencyLimitTime,
noteOnAry: [],
noteOffAry: []
};
// WebMIDIの初期化・リセットメッセージ送信 //
if (this.settings.isWebMIDI && !isLight) {
if (isSongLooping)
return;
if (this.settings.WebMIDIPortOutput == null) {
this.startWebMIDI();
return;
}
if (this.settings.WebMIDIPortSysEx) {
// GM1システム・オン
this.settings.WebMIDIPortOutput.send([0xF0, 0x7E, 0x7F, 0x09, 0x01, 0xF7]);
} else {
// SysExの使用が拒否されているので、できる限り設定値を初期値に戻す
for (let t=0; t<16; t++) {
this.settings.WebMIDIPortOutput.send([0xC0+t, 0]);
this.settings.WebMIDIPortOutput.send([0xE0+t, 0, 64]);
// ピッチあたりのずれがひどくなる場合がある よくわからない
this.settings.WebMIDIPortOutput.send([0xB0+t, 100, 0]);
this.settings.WebMIDIPortOutput.send([0xB0+t, 101, 0]);
this.settings.WebMIDIPortOutput.send([0xB0+t, 6, 2]); //pitchbend
this.settings.WebMIDIPortOutput.send([0xB0+t, 100, 1]);
this.settings.WebMIDIPortOutput.send([0xB0+t, 96, 0]);
this.settings.WebMIDIPortOutput.send([0xB0+t, 97, 64]); //tuning?
this.settings.WebMIDIPortOutput.send([0xB0+t, 7, 100]); // volume
this.settings.WebMIDIPortOutput.send([0xB0+t, 10, 64]); // pan
this.settings.WebMIDIPortOutput.send([0xB0+t, 11, 127]); // expression
//this.settings.WebMIDIPortOutput.send([0xB0+t, 91, 40]); // リバーブ以外のエフェクトに設定される場合がありそうなのでコメントアウト
//this.settings.WebMIDIPortOutput.send([0xB0+t, 93, 0]); // コーラス以外のエフェクトに設定されるのか音が出なくなる場合があるのでコメントアウト
this.settings.WebMIDIPortOutput.send([0xB0+t, 98, 0]);
this.settings.WebMIDIPortOutput.send([0xB0+t, 99, 0]);
//this.settings.WebMIDIPortOutput.send([0xB0+t, 121, 0]);
this.settings.WebMIDIPortOutput.send([0xB0+t, 122, 0]);
}
}
}
}
class ArrayUtil extends Array {
/**
* 配列から要素1つを削除する
*
* Array.splice(index, 1); を高速化する
* 特に配列末尾、又は配列先頭を削除するときに高速処理が期待できる
* @param {Array} array 配列
* @param {number} index 添え字
*/
static delete(array, index) {
if (index == array.length-1) array.pop(); // 配列末尾をArray.pop()で削除すると高速化する
else if (index == 0) array.shift(); // 配列先頭をArray.shift()で削除すると高速化する(あまり変わらない環境もある)
else array.splice(index, 1); // 配列先頭・末尾以外を削除する場合はArray.splice()で削除する
}
}
class ParseUtil {
/**
* バイト配列内に含まれる"データ長"を数値に変換する
* @param {Uint8Array} arr バイト配列
* @param {number} startIdx データ長の始点の場所(index)
* @param {number} endIdx データ長の終点の場所(index) - 1
* @returns {number} データ長
*/
static getInt(arr, startIdx, endIdx) {
let value = 0;
for (let i=startIdx; i<endIdx; i++) {
value = (value << 8) + arr[i];
}
return value;
}
/**
* バイト配列内に含まれる"可変長のデータ長"を数値に変換する
* @param {Uint8Array} arr バイト配列
* @param {number} startIdx データ長の始点の場所(index)
* @param {number} endIdx データ長の終点の場所(index) - 1 (終点の場所は多くてもかまわない)
* @returns {Array} [データ長, "可変長のデータ長"のバイト数]
*/
static variableLengthToInt(arr, startIdx, endIdx) {
let i = startIdx;
let value = 0;
while (i < endIdx-1 && arr[i] >= 0x80) {
if (i < startIdx+4) value = (value<<7) + (arr[i]-0x80);
i++;
}
value = (value<<7) + arr[i];
i++;
return [value, i-startIdx];
}
/**
* デルタタイムの順番になるように配列に挿入
* @param {PicoAudio} that PicoAudioインスタンス
* @param {number} ch チャンネル番号
* @param {number} time デルタタイム
* @param {number} p 対象のMIDIイベントの場所(SMFデータ内の位置)
* @param {number} len MIDIイベントの長さ
*/
static chIndicesInsert(that, ch, time, p, len) {
const indices = ch.indices;
// デルタタイムの順番になるようにリスト配列に挿入 //
if (ch.indicesLength >= 4 && time < indices[ch.indicesFoot]) {
// Insert //
while (ch.indicesCur != -1) {
if (time<indices[ch.indicesCur]) {
if (ch.indicesCur == ch.indicesHead) {
ch.indicesHead = ch.indicesLength;
} else {
indices[ch.indicesPre+3] = ch.indicesLength;
}
indices[ch.indicesLength] = time;
indices[ch.indicesLength+1] = len;
indices[ch.indicesLength+2] = p;
indices[ch.indicesLength+3] = ch.indicesCur;
ch.indicesPre = ch.indicesLength;
ch.indicesLength += 4;
break;
}
ch.indicesPre = ch.indicesCur;
ch.indicesCur = indices[ch.indicesCur+3];
}
} else {
// Push //
if (ch.indicesLength >= 4) {
indices[ch.indicesFoot+3] = ch.indicesLength;
} else {
ch.indicesHead = 0;
}
ch.indicesFoot = ch.indicesLength;
indices[ch.indicesLength] = time;
indices[ch.indicesLength+1] = len;
indices[ch.indicesLength+2] = p;
indices[ch.indicesLength+3] = -1;
ch.indicesLength += 4;
}
}
}
class UpdateNote {
/**
* 1ms毎処理用の変数を初期化
*/
static init(picoAudio, currentTime) {
this.updatePreTime = Performance.now();
this.pPreTime = Performance.now();
this.cPreTime = picoAudio.context.currentTime * 1000;
this.pTimeSum = 0;
this.cTimeSum = 0;
this.cnt = 0;
this.initCurrentTime = currentTime;
}
/**
* 再生中、1ms毎に呼ばれるコールバック
* (ブラウザの制限で実際は最短4ms毎に呼ばれる)
* @returns {number} 現在の時間
*/
static update(picoAudio) {
const context = picoAudio.context;
const settings = picoAudio.settings;
const states = picoAudio.states;
const baseLatency = picoAudio.baseLatency;
const updateNowTime = Performance.now();
const updatePreTime = this.updatePreTime;
let pPreTime = this.pPreTime;
let cPreTime = this.cPreTime;
let pTimeSum = this.pTimeSum;
let cTimeSum = this.cTimeSum;
let cnt = this.cnt;
// サウンドが重くないか監視(フリーズ対策) //
// performance.now()とAudioContext.currentTimeの時間差を計算し
// AudioContext.currentTimeが遅れていたら処理落ちしていると判断する
let updateBufTime = updateNowTime - updatePreTime;
const pTime = updateNowTime;
const cTime = context.currentTime * 1000;
pTimeSum += pTime - pPreTime;
cTimeSum += cTime - cPreTime;
pPreTime = pTime;
cPreTime = cTime;
const latencyTime = pTimeSum - cTimeSum;
states.latencyTime = latencyTime;
// サウンドが重い場合、負荷軽減処理を発動するリミットを上げていく //
if (latencyTime >= 100) { // currentTimeが遅い(サウンドが重い)
states.latencyLimitTime += latencyTime;
cTimeSum += 100;
} else if (latencyTime <= -100) { // currentTimeが速い(誤差)
cTimeSum = pTimeSum;
} else {
if (states.latencyLimitTime > 0) { // currentTimeが丁度いい
states.latencyLimitTime -= updateBufTime*0.003;
if (states.latencyLimitTime < 0) states.latencyLimitTime = 0;
}
}
// ノートを先読み度合いを自動調整(予約しすぎると重くなる) //
states.updateIntervalTime = updateBufTime;
if (states.updateBufTime < updateBufTime) { // 先読み遅れている場合
states.updateBufTime = updateBufTime;
} else { // 先読み量に余裕がある場合
// 先読み量を少しずつ減らす //
if (states.updateBufMaxTime > 350) {
states.updateBufMaxTime -= states.updateBufMaxTime*0.002;
}
// 先読み量を少しずつ増やす //
if (states.updateBufTime < 20) {
states.updateBufTime += states.updateBufTime*0.0005;
}
if (states.updateBufMaxTime >= 10 && states.updateBufMaxTime < 340) {
states.updateBufMaxTime += states.updateBufMaxTime*0.002;
}
}
// 先読み量が足りなくなった場合
if (states.updateBufTime > states.updateBufMaxTime) {
if (updateBufTime >= 900 && states.latencyLimitTime <= 150) {
// バックグラウンドっぽくて重くない場合、バックグラウンド再生
states.updateBufMaxTime += updateBufTime;
} else { // 通常
const tempTime = updateBufTime - states.updateBufMaxTime;
states.updateBufTime = states.updateBufMaxTime;
// 先読み量が小さい場合大きくする
if (states.updateBufMaxTime < 10) {
states.updateBufTime = states.updateBufMaxTime;
states.updateBufMaxTime *= 1.25;
} else {
states.updateBufMaxTime += tempTime / 2;
}
}
if (states.updateBufMaxTime > 1100) states.updateBufMaxTime = 1100;
}
// サウンドが重すぎる場合、先読み度合いを小さくして負荷軽減 //
if (states.latencyLimitTime > 150) {
cTimeSum = pTimeSum;
states.latencyLimitTime -= 5;
if (states.latencyLimitTime > 1000) states.latencyLimitTime = 1000;
// ノート先読みをかなり小さくする(フリーズ対策)
states.updateBufMaxTime = 1;
states.updateBufTime = 1;
updateBufTime = 1;
}
// 再生処理 //
for (let ch=0; ch<16; ch++) {
const notes = picoAudio.playData.channels[ch].notes;
let idx = states.playIndices[ch];
for (; idx<notes.length; idx++) {
const note = notes[idx];
const curTime = cnt == 0 ? this.initCurrentTime - states.startTime
: context.currentTime - states.startTime;
// 終わったノートは演奏せずにスキップ
if (curTime >= note.stopTime) continue;
// (シークバーで途中から再生時)startTimeが過ぎたものは鳴らさない
if (cnt == 0 && curTime > note.startTime + baseLatency) continue;
// 演奏開始時間 - 先読み時間(ノート予約) になると演奏予約or演奏開始
if (curTime < note.startTime - states.updateBufTime/1000) break;
// PicoAudio音源の再生処理 //
if (!settings.isWebMIDI) {
// 予約ノート数が急激に増えそうな時、先読み量を小さくしておく //
if (states.stopFuncs.length >= 350 && states.updateBufTime < 1000) {
states.updateBufTime = 12;
states.updateBufMaxTime = states.updateBufTime;
}
// レトロモード(和音制限モード) //
if (settings.maxPoly != -1 || settings.maxPercPoly != -1) {
let polyCnt = 0;
let percCnt = 0;
states.stopFuncs.forEach((tar) => {
if (!tar.note) return;
if (tar.note.channel != 9) {
if (note.start >= tar.note.start && note.start < tar.note.stop) {
polyCnt++;
}
} else {
if (note.start == tar.note.start) {
percCnt++;
}
}
});
if ((note.channel != 9 && polyCnt >= settings.maxPoly)
|| (note.channel == 9 && percCnt >= settings.maxPercPoly)) {
continue;
}
}
// 1ノート分の再生処理(WebAudioで再生) //
const stopFunc =
note.channel != 9 ? picoAudio.createNote(note)
: picoAudio.createPercussionNote(note);
if (!stopFunc) continue; // 無音の場合、処理しない
picoAudio.pushFunc({
note: note,
stopFunc: stopFunc
});
}
states.noteOnAry.push(note);
}
// notesのどこまで再生したかを記憶して、次回コールバック時にそこから処理を始める
states.playIndices[ch] = idx;
}
// noteOnの時間になったか監視 //
this.checkNoteOn(picoAudio);
// noteOffの時間になったか監視 //
this.checkNoteOff(picoAudio);
// WebMIDIの再生処理 //
if (settings.isWebMIDI && settings.WebMIDIPortOutput != null) {
const messages = picoAudio.playData.messages;
const smfData = picoAudio.playData.smfData;
let idx = states.playIndices[16]; // 17chはWebMIDI用
for (; idx<messages.length; idx++) {
const message = messages[idx];
const curTime = context.currentTime - states.startTime;
// 終わったノートは演奏せずにスキップ
if (curTime > message.time + 1) continue;
// 演奏開始時間 - 先読み時間(ノート予約) になると演奏予約or演奏開始
if (curTime < message.time - 1) break;
// WebMIDIでMIDIメッセージを送信する処理 //
const pLen = message.smfPtrLen;
const p = message.smfPtr;
const time = message.time;
const state = smfData[p];
if (state!=0xff) {
try {
if (state==0xF0 || state==0xF7) {
// sysExのMIDIメッセージ
if (settings.WebMIDIPortSysEx) {
// 長さ情報を取り除いて純粋なSysExメッセージにする
const lengthAry = ParseUtil.variableLengthToInt(smfData, p+1, p+1+4);
const sysExStartP = p+1+lengthAry[1];
const sysExEndP = sysExStartP+lengthAry[0];
const webMIDIMes = new Uint8Array(1 + lengthAry[0]);
webMIDIMes[0] = state;
const size = sysExEndP - sysExStartP;
for (let i=0; i<size; i++)
webMIDIMes[i+1] = smfData[sysExStartP + i];
settings.WebMIDIPortOutput.send(webMIDIMes,
(time - context.currentTime + Performance.now()/1000 + states.startTime) * 1000);
}
} else {
// sysEx以外のMIDIメッセージ
const sendMes = [];
for (let i=0; i<pLen; i++) sendMes.push(smfData[p+i]);
settings.WebMIDIPortOutput.send(sendMes,
(time - context.currentTime + Performance.now()/1000 + states.startTime) * 1000);
}
} catch(e) {
console.log(e, p, pLen, time, state);
}
}
}
// messagesのどこまで送信したかを記憶して、次回コールバック時にそこから処理を始める
states.playIndices[16] = idx;
}
// 1msコールバックが呼ばれた回数をカウント
cnt ++;
// 変数を反映 //
this.updatePreTime = updateNowTime;
this.pPreTime = pPreTime;
this.cPreTime = cPreTime;
this.pTimeSum = pTimeSum;
this.cTimeSum = cTimeSum;
this.cnt = cnt;
}
/**
* noteOnの時間になったか監視
* @param {PicoAudio} picoAudio PicoAudioインスタンス
*/
static checkNoteOn(picoAudio) {
const context = picoAudio.context;
const trigger = picoAudio.trigger;
const states = picoAudio.states;
const noteOnAry = picoAudio.states.noteOnAry;
const noteOffAry = picoAudio.states.noteOffAry;
for (let i=0; i<noteOnAry.length; i++) {
const tempNote = noteOnAry[i];
const nowTime = context.currentTime - states.startTime;
if (tempNote.startTime - nowTime <= 0) {
ArrayUtil.delete(noteOnAry, i); // noteOnAry.splice(i, 1); の高速化
noteOffAry.push(tempNote);
// イベント発火
if (trigger.isNoteTrigger) trigger.noteOn(tempNote);
picoAudio.fireEvent('noteOn', tempNote);
i--;
}
}
}
/**
* noteOffの時間になったか監視
* @param {PicoAudio} picoAudio PicoAudioインスタンス
*/
static checkNoteOff(picoAudio) {
const context = picoAudio.context;
const trigger = picoAudio.trigger;
const states = picoAudio.states;
const noteOffAry = picoAudio.states.noteOffAry;
for (let i=0; i<noteOffAry.length; i++) {
const tempNote = noteOffAry[i];
const nowTime = context.currentTime - states.startTime;
if ((tempNote.channel != 9 && tempNote.stopTime - nowTime <= 0)
|| (tempNote.channel == 9 && tempNote.drumStopTime - nowTime <= 0)) {
ArrayUtil.delete(noteOffAry, i); // noteOffAry.splice(i, 1); の高速化
picoAudio.clearFunc("note", tempNote);
// イベント発火
if (trigger.isNoteTrigger) trigger.noteOff(tempNote);
picoAudio.fireEvent('noteOff', tempNote);
i--;
}
}
}
}
function play(isSongLooping) {
const context = this.context;
const settings = this.settings;
const trigger = this.trigger;
const states = this.states;
// Chrome Audio Policy 対策 //
if (context.resume) context.resume();
// 再生中の場合、処理しない //
if (states.isPlaying) return;
// WebMIDIの場合、少し待ってから再生する //
if (settings.isWebMIDI && !isSongLooping) {
// Web MIDI API使用時はstop()から800ms程待機すると音がバグりにくい
if (states.webMIDIWaitState != "completed") {
if (states.webMIDIWaitState != "waiting") { // play()連打の対策
// stop()から1000ms後にplay()を実行
states.webMIDIWaitState = "waiting";
let waitTime = 1000 - (context.currentTime - states.webMIDIStopTime)*1000;
if (states.webMIDIStopTime == 0) waitTime = 1000; // MIDI Portをopenして最初に呼び出すときも少し待つ
setTimeout(() => {
states.webMIDIWaitState = "completed";
states.isPlaying = false;
this.play();
}, waitTime);
}
return;
} else {
states.webMIDIWaitState = null;
}
}
// 変数を用意 //
const currentTime = context.currentTime;
this.isPlayed = true;
states.isPlaying = true;
states.startTime = !states.startTime && !states.stopTime ? currentTime : (states.startTime + currentTime - states.stopTime);
states.stopFuncs = [];
// 冒頭の余白をスキップ //
if (settings.isSkipBeginning) {
const firstNoteOnTime = this.firstNoteOnTime;
if (-states.startTime + currentTime < firstNoteOnTime) {
this.setStartTime(firstNoteOnTime + states.startTime - currentTime);
}
}
// 曲終了コールバックを予約 //
let reserveSongEnd;
const reserveSongEndFunc = () => {
this.clearFunc("rootTimeout", reserveSongEnd);
const finishTime = this.getTime(Number_MAX_SAFE_INTEGER);
if (finishTime - context.currentTime + states.startTime <= 0) {
// 予定の時間以降に曲終了
trigger.songEnd();
this.onSongEnd();
this.fireEvent('songEnd');
} else {
// 処理落ちしたりしてまだ演奏中の場合、1ms後に曲終了コールバックを呼び出すよう予約
reserveSongEnd = setTimeout(reserveSongEndFunc, 1);
this.pushFunc({
rootTimeout: reserveSongEnd,
stopFunc: () => { clearTimeout(reserveSongEnd); }
});
}
};
const finishTime = this.getTime(Number_MAX_SAFE_INTEGER);
const reserveSongEndTime = (finishTime - context.currentTime + states.startTime) * 1000;
reserveSongEnd = setTimeout(reserveSongEndFunc, reserveSongEndTime);
this.pushFunc({
rootTimeout: reserveSongEnd,
stopFunc: () => { clearTimeout(reserveSongEnd); }
});
// 再生開始をコールバックに通知 //
trigger.play();
this.fireEvent('play');
// 1ms毎コールバックの準備 //
UpdateNote.init(this, currentTime);
// 1ms毎コールバックを開始 //
const reserve = setInterval(() => {
UpdateNote.update(this);
}, 1);
this.pushFunc({
rootTimeout: reserve,
stopFunc: () => { clearInterval(reserve); }
});
}
function stop(isSongLooping) {
const states = this.states;
// 再生していない場合、何もしない //
if (states.isPlaying == false) return;
// ステータスを停止状態にする・終了処理を呼ぶ //
states.isPlaying = false;
states.stopTime = this.context.currentTime;
states.stopFuncs.forEach((n) => { // 再生中の音の停止関数を呼ぶ
n.stopFunc();
});
states.stopFuncs = [];
states.playIndices.forEach((n, i, ary) => {
ary[i] = 0;
});
states.noteOnAry = [];
states.noteOffAry = [];
// WebMIDIで再生中の場合、停止メッセージを送信 //
if (this.settings.isWebMIDI) {
if (isSongLooping)
return;
if (this.settings.WebMIDIPortOutput == null)
return;
states.webMIDIStopTime = this.context.currentTime;
setTimeout(() => {
for (let t=0; t<16; t++) {
this.settings.WebMIDIPortOutput.send([0xB0+t, 120, 0]);
}
}, 1000);
}
// 停止をコールバックに通知 //
this.trigger.stop();
this.fireEvent('pause');
this.fireEvent('stop');
}
function createBaseNote(option, isDrum, isExpression, nonChannel, nonStop) {
// 最低限の変数を準備(無音の場合は処理終了するため) //
const settings = this.settings;
const context = this.context;
const songStartTime = this.states.startTime;
const baseLatency = this.baseLatency;
const channel = nonChannel ? 0 : (option.channel || 0);
const velocity = (option.velocity) * Number(nonChannel ? 1 : (this.channels[channel][2] != null ? this.channels[channel][2] : 1)) * settings.generateVolume;
let isGainValueZero = true;
// 無音の場合は処理終了 //
if (velocity <= 0) return {isGainValueZero: true};
// 音量の変化を設定 //
const expGainValue = velocity * ((option.expression ? option.expression[0].value : 100) / 127);
const expGainNode = context.createGain();
expGainNode.gain.value = expGainValue;
if (isExpression) {
option.expression ? option.expression.forEach((p) => {
const v = velocity * (p.value / 127);
if (v > 0) isGainValueZero = false;
const t = Math.max(0, p.time + songStartTime + baseLatency);
expGainNode.gain.setValueAtTime(v, t);
}) : false;
} else {
if (expGainValue > 0) {
isGainValueZero = false;
}
}
// 無音の場合は処理終了 //
if (isGainValueZero) { // 音量が常に0なら音を鳴らさない
return {isGainValueZero: true};
}
// 全ての変数を準備 //
const start = option.startTime + songStartTime + baseLatency;
const stop = option.stopTime + songStartTime + baseLatency;
const pitch = settings.basePitch * Math.pow(Math.pow(2, 1/12), (option.pitch || 69) - 69);
const oscillator = !isDrum ? context.createOscillator() : context.createBufferSource();
const panNode = context.createStereoPanner ? context.createStereoPanner()
: context.createPanner ? context.createPanner()
: { pan: { setValueAtTime: ()=>{} } };
const gainNode = context.createGain();
const stopGainNode = context.createGain();
// ドラムはホワイトノイズ、ドラム以外はoscillatorを設定 //
// oscillatorはピッチ変動も設定 //
if (!isDrum) {
oscillator.type = option.type || "sine";
oscillator.detune.value = 0;
oscillator.frequency.value = pitch;
option.pitchBend ? option.pitchBend.forEach((p) => {
const t = Math.max(0, p.time + songStartTime + baseLatency);
oscillator.frequency.setValueAtTime(
settings.basePitch * Math.pow(Math.pow(2, 1/12), option.pitch - 69 + p.value),
t
);
}) : false;
} else {
oscillator.loop = true;
oscillator.buffer = this.whitenoise;
}
// パンの初期値を設定 //
const panValue = option.pan && option.pan[0].value != 64 ? (option.pan[0].value / 127) * 2 - 1 : 0;
initPanValue(context, panNode, panValue);
// パンの変動を設定 //
if (context.createStereoPanner || context.createPanner) {
// StereoPannerNode or PannerNode がどちらかでも使える
let firstNode = true;
if (context.createStereoPanner) {
// StereoPannerNode が使える
option.pan ? option.pan.forEach((p) => {
if (firstNode) {
firstNode = false;
return;
}
const v = Math.min(1.0, p.value == 64 ? 0 : (p.value / 127) * 2 - 1);
const t = Math.max(0, p.time + songStartTime + baseLatency);
panNode.pan.setValueAtTime(v, t);
}) : false;
} else if (context.createPanner) {
// StereoPannerNode が未サポート、PannerNode が使える
if (panNode.positionX) {
// setValueAtTimeが使える
// Old Browser
let firstPan = true;
option.pan ? option.pan.forEach((p) => {
if (firstPan) {
firstPan = false;
return;
}
const v = p.value == 64 ? 0 : (p.value / 127) * 2 - 1;
const posObj = convPosition(v);
const t = Math.max(0, p.time + songStartTime + baseLatency);
panNode.positionX.setValueAtTime(posObj.x, t);
panNode.positionY.setValueAtTime(posObj.y, t);
panNode.positionZ.setValueAtTime(posObj.z, t);
}) : false;
} else {
// iOS
// setValueAtTimeが使えないためsetTimeoutでパンの動的変更
option.pan ? option.pan.forEach((p) => {
if (firstNode) {
firstNode = false;
return;
}
const reservePan = setTimeout(() => {
this.clearFunc("pan", reservePan);
const v = Math.min(1.0, p.value == 64 ? 0 : (p.value / 127) * 2 - 1);
const posObj = convPosition(v);
panNode.setPosition(posObj.x, posObj.y, posObj.z);
}, (p.time + songStartTime + baseLatency - context.currentTime) * 1000);
this.pushFunc({
pan: reservePan,
stopFunc: () => { clearTimeout(reservePan); }
});
}) : false;
}
}
oscillator.connect(panNode);
panNode.connect(expGainNode);
} else {
// StereoPannerNode、PannerNode が未サポート
oscillator.connect(expGainNode);
}
// AudioNodeを接続 //
expGainNode.connect(gainNode);
gainNode.connect(stopGainNode);
stopGainNode.connect(this.masterGainNode);
this.masterGainNode.connect(context.destination);
// モジュレーションの変動を設定 //
let modulationOscillator;
let modulationGainNode;
if (!isDrum && option.modulation && (option.modulation.length >= 2 || option.modulation[0].value > 0)) {
modulationOscillator = context.createOscillator();
modulationGainNode = context.createGain();
let firstNode = true;
option.modulation ? option.modulation.forEach((p) => {
if (firstNode) {
firstNode = false;
return;
}
const m = Math.min(1.0, p.value / 127);
const t = Math.max(0, p.time + songStartTime + baseLatency);
modulationGainNode.gain.setValueAtTime(
pitch * 10 / 440 * m,
t
);
}) : false;
const m = Math.min(1.0, option.modulation ? option.modulation[0].value / 127 : 0);
modulationGainNode.gain.value = pitch * 10 / 440 * m;
modulationOscillator.frequency.value = 6;
modulationOscillator.connect(modulationGainNode);
modulationGainNode.connect(oscillator.frequency);
}
// リバーブの変動を設定 //
if (this.settings.isReverb && option.reverb && (option.reverb.length >= 2 || option.reverb[0].value > 0)) {
const convolver = this.convolver;
const convolverGainNode = context.createGain();
let firstNode = true;
option.reverb ? option.reverb.forEach((p) => {
if (firstNode) {
firstNode = false;
return;
}
const r = Math.min(1.0, p.value / 127);
const t = Math.max(0, p.time + songStartTime + baseLatency);
convolverGainNode.gain.setValueAtTime(r, t);
}) : false;
const r = Math.min(1.0, option.reverb ? option.reverb[0].value / 127 : 0);
convolverGainNode.gain.value = r;
gainNode.connect(stopGainNode);
stopGainNode.connect(convolverGainNode);
convolverGainNode.connect(convolver);
}
// コーラスの変動を設定 //
if (this.settings.isChorus && option.chorus && (option.chorus.length >= 2 || option.chorus[0].value > 0)) {
const chorusDelayNode = this.chorusDelayNode;
const chorusGainNode = context.createGain();
let firstNode = true;
option.chorus ? option.chorus.forEach((p) => {
if (firstNode) {
firstNode = false;
return;
}
const c = Math.min(1.0, p.value / 127);
const t = Math.max(0, p.time + songStartTime + baseLatency);
chorusGainNode.gain.setValueAtTime(c, t);
}) : false;
let c = Math.min(1.0, option.chorus ? option.chorus[0].value / 127 : 0);
chorusGainNode.gain.value = c;
gainNode.connect(stopGainNode);
stopGainNode.connect(chorusGainNode);
chorusGainNode.connect(chorusDelayNode);
}
// モジュレーションをスタート //
if (modulationOscillator) {
modulationOscillator.start(start);
this.stopAudioNode(modulationOscillator, stop, modulationGainNode);
}
// oscillator又はホワイトノイズをスタート //
oscillator.start(start);
if (!isDrum && !nonChannel && !nonStop) {
this.stopAudioNode(oscillator, stop, stopGainNode);
}
// AudioNodeやパラメータを返す //
return {
start: start,
stop: stop,
pitch: pitch,
channel: channel,
velocity: velocity,
oscillator: oscillator,
panNode: panNode,
gainNode: gainNode,
stopGainNode: stopGainNode,
isGainValueZero: false
};
}
/**
* パンの初期値を設定
* @param {PannerNode | StereoPannerNode} panNode
* @param {number} panValue
*/
function initPanValue(context, panNode, panValue) {
if (context.createStereoPanner) {
if (panValue > 1.0) panValue = 1.0;
panNode.pan.value = panValue;
} else if(context.createPanner) {
// iOS, Old Browser
const posObj = convPosition(panValue);
panNode.panningModel = "equalpower";
panNode.setPosition(posObj.x, posObj.y, posObj.z);
}
}
/**
* pan値を基に、PannerNode用の値を{x, y, z}で返す
* @param {number} panValue panの値
* @returns Object{x, y, z}
*/
function convPosition(panValue) {
if (panValue > 1.0) panValue = 1.0;
const obj = {};
const panAngle = panValue * 90;
obj.x = Math.sin(panAngle * (Math.PI / 180));
obj.y = 0;
obj.z = -Math.cos(panAngle * (Math.PI / 180));
return obj;
}
function createNote(option) {
const note = this.createBaseNote(option, false, true, false, true); // oscillatorのstopはこちらで実行するよう指定
if (note.isGainValueZero) return null;
const oscillator = note.oscillator;
const gainNode = note.gainNode;
const stopGainNode = note.stopGainNode;
let isPizzicato = false;
let isNoiseCut = false;
let note2;
// 音色の設定 //
switch (this.channels[note.channel][0]*1000 || option.instrument) {
// Sine
case 1000:
case 6: case 15: case 24: case 26: case 46: case 50: case 51:
case 52: case 53: case 54: case 82: case 85: case 86:
{
oscillator.type = "sine";
gainNode.gain.value *= 1.5;
break;
}
// Square
case 2000:
case 4: case 12: case 13: case 16: case 19: case 20: case 32: case 34: case 45: case 48: case 49:
case 55: case 56: case 57: case 61: case 62: case 63: case 71: case 72: case 73: case 74: case 75:
case 76: case 77: case 78: case 79: case 80: case 84:
{
oscillator.type = "square";
gainNode.gain.value *= 0.8;
break;
}
// Sawtooth
case 3000:
case 0: case 1: case 2: case 3: case 7: case 17: case 18: case 21: case 22: case 23: case 27:
case 28: case 29: case 30: case 36: case 37: case 38: case 39: case 40: case 41: case 42: case 43:
case 44: case 47: case 59: case 64: case 65: case 66: case 67: case 68: case 69: case 70: case 87:
{
oscillator.type = "sawtooth";
break;
}
// Triangle
case 4000:
case 8: case 9: case 10: case 11: case 14: case 25: case 31: case 33: case 35: case 58: case 60:
case 83: case 88: case 89: case 90: case 91: case 92: case 93: case 94: case 95:
{
oscillator.type = "triangle";
gainNode.gain.value *= 1.5;
break;
}
// Other - Square
default:{
oscillator.type = "square";
}
}
// 音の終わりのプチプチノイズが気になるので、音の終わりに5ms減衰してノイズ軽減 //
if ((oscillator.type == "sine" || oscillator.type == "triangle")
&& !isPizzicato && note.stop - note.start > 0.01) {
isNoiseCut = true;
}
// 減衰の設定 //
switch (this.channels[note.channel][1]/10 || option.instrument) {
// ピッチカート系減衰
case 0.2:
case 12: case 13: case 45: case 55:
{
isPizzicato = true;
gainNode.gain.value *= 1.1;
gainNode.gain.setValueAtTime(gainNode.gain.value, note.start);
gainNode.gain.linearRampToValueAtTime(0.0, note.start+0.2);
this.stopAudioNode(oscillator, note.start+0.2, stopGainNode);
break;
}
// ピアノ程度に伸ばす系
case 0.3:
case 0: case 1: case 2: case 3: case 6: case 9: case 11: case 14: case 15:
case 32: case 36: case 37: case 46: case 47:
{
gainNode.gain.value *= 1.1;
const decay = (128-option.pitch)/128;
gainNode.gain.setValueAtTime(gainNode.gain.value, note.start);
gainNode.gain.linearRampToValueAtTime(gainNode.gain.value*0.85, note.start+decay*decay/8);
gainNode.gain.linearRampToValueAtTime(gainNode.gain.value*0.8, note.start+decay*decay/4);
gainNode.gain.setTargetAtTime(0, note.start+decay*decay/4, 5*decay*decay);
this.stopAudioNode(oscillator, note.stop, stopGainNode, isNoiseCut);
break;
}
// ギター系
case 0.4:
case 24: case 25: case 26: case 27: case 28: case 29: case 30: case 31: case 34:
{
gainNode.gain.value *= 1.1;
gainNode.gain.setValueAtTime(gainNode.gain.value, note.start);
gainNode.gain.linearRampToValueAtTime(0.0, note.start+1.0+note.velocity*4);
this.stopAudioNode(oscillator, note.stop, stopGainNode, isNoiseCut);
break;
}
// 減衰していくけど終わらない系
case 0.5:
case 4: case 5: case 7: case 8: case 10: case 33: case 35:
{
gainNode.gain.value *= 1.0;
gainNode.gain.setValueAtTime(gainNode.gain.value, note.start);
gainNode.gain.linearRampToValueAtTime(gainNode.gain.value*0.95, note.start+0.1);
gainNode.gain.setValueAtTime(gainNode.gain.value*0.95, note.start+0.1);
gainNode.gain.linearRampToValueAtTime(0.0, note.start+2.0+note.velocity*10);
this.stopAudioNode(oscillator, note.stop, stopGainNode, isNoiseCut);
break;
}
case 119: // Reverse Cymbal
{
gainNode.gain.value = 0;
this.stopAudioNode(oscillator, note.stop, stopGainNode, isNoiseCut);
note2 = this.createBaseNote(option, true, true);
if (note2.isGainValueZero) break;
note2.oscillator.playbackRate.setValueAtTime((option.pitch+1)/128, note.start);
note2.gainNode.gain.setValueAtTime(0, note.start);
note2.gainNode.gain.linearRampToValueAtTime(1.3, note.start+2);
this.stopAudioNode(note2.oscillator, note.stop, note2.stopGainNode);
break;
}
default: {
gainNode.gain.value *= 1.1;
gainNode.gain.setValueAtTime(gainNode.gain.value, note.start);
this.stopAudioNode(oscillator, note.stop, stopGainNode, isNoiseCut);
}
}
// 音をストップさせる関数を返す //
return () => {
this.stopAudioNode(oscillator, 0, stopGainNode, true);
if (note2 && note2.oscillator) this.stopAudioNode(note2.oscillator, 0, note2.stopGainNode, true);
};
}
function createPercussionNote(option) {
const note = this.createBaseNote(option, true, false);
if (note.isGainValueZero) return null;
const source = note.oscillator;
const gainNode = note.gainNode;
const stopGainNode = note.stopGainNode;
let start = note.start;
const velocity = 1; // ドラム全体の音量調整用
const note2 = this.createBaseNote(option, false, false, true);
const oscillator = note2.oscillator;
const gainNode2 = note2.gainNode;
const stopGainNode2 = note2.stopGainNode;
const nextSame