smoosic
Version:
<sub>[Github site](https://github.com/Smoosic/smoosic) | [source documentation](https://smoosic.github.io/Smoosic/release/docs/modules.html) | [change notes](https://aarondavidnewman.github.io/Smoosic/changes.html) | [application](https://smoosic.github.i
497 lines (485 loc) • 18 kB
text/typescript
// [Smoosic](https://github.com/AaronDavidNewman/Smoosic)
// Copyright (c) Aaron David Newman 2021.
import { SuiOscillator, SuiSampler, SuiWavetable, SynthWavetable } from './oscillator';
import { SmoAudioScore } from '../../smo/xform/audioTrack';
import { SuiScoreView } from '../sui/scoreView';
import { SmoScore } from '../../smo/data/score';
import { SmoSelector } from '../../smo/xform/selections';
import { SmoTie } from '../../smo/data/staffModifiers';
import { SmoAudioPitch } from '../../smo/data/music';
import { SuiAudioAnimationParams } from './musicCursor';
import { ScoreRoadMapBuilder } from './roadmap';
/**
* Create audio player for the score from the start point
* @category SuiAudio
*/
export interface SuiAudioPlayerParams {
startIndex: number,
view: SuiScoreView,
score: SmoScore,
audioAnimation: SuiAudioAnimationParams
}
/**
* Parameters used to create just-in-time oscillators
* @category SuiAudio
*/
export interface SoundParams {
frequencies: number[],
duration: number,
offsetPct: number,
durationPct: number,
volume: number,
noteType: string,
instrument: string,
selector: SmoSelector
}
/**
* A list of sound parameters for just-in-time oscillator creation
* @category SuiAudio
*/
export interface SoundParamMeasureLink {
soundParams: Record<number, SoundParams[]>,
endTicks: number,
measureIndex: number,
next: SoundParamMeasureLink | null
}
/**
* A set of oscillators to be played at a certain time.
* @category SuiAudio
*/
export interface CuedAudioContext {
oscs: SuiOscillator[],
playMeasureIndex: number,
playTickIndex: number,
waitTime: number,
offsetPct: number,
durationPct: number,
selector: SmoSelector
}
/**
* A list of oscillators. We keep them in a list until played so we
* can GC them if playing is cancelled
* @category SuiAudio
*/
export interface CuedAudioLink {
sound: CuedAudioContext;
next: CuedAudioLink | null;
}
/**
* Maintain a list of buffers ready to play, since this is a
* system resource.
* @category SuiAudio
*/
export class CuedAudioContexts {
soundHead: CuedAudioLink | null = null;
soundTail: CuedAudioLink | null = null;
paramLinkHead: SoundParamMeasureLink | null = null;
paramLinkTail: SoundParamMeasureLink | null = null;
soundListLength = 0;
playWaitTimer = 0;
complete: boolean = false;
addToTail(cuedSound: CuedAudioContext) {
const tail = { sound: cuedSound, next: null };
if (this.soundTail === null) {
this.soundTail = tail;
this.soundHead = tail;
} else {
this.soundTail.next = { sound: cuedSound, next: null };
this.soundTail = this.soundTail.next;
}
this.soundListLength += cuedSound.oscs.length;
}
advanceHead(): CuedAudioContext | null {
if (this.soundHead === null) {
return null;
}
const cuedSound = this.soundHead.sound;
this.soundHead = this.soundHead.next;
this.soundListLength -= cuedSound.oscs.length;
return cuedSound;
}
get soundCount() {
return this.soundListLength;
}
reset() {
this.soundHead = null;
this.soundTail = null;
this.paramLinkHead = null;
this.paramLinkTail = null;
this.soundListLength = 0;
this.playWaitTimer = 0;
this.complete = false;
}
}
/**
* Play the music, ja!
* @category SuiAudio
*/
export class SuiAudioPlayer {
static _playing: boolean = false;
static instanceId: number = 0;
static duplicatePitchThresh = 4;
static voiceThresh = 16;
static _playingInstance: SuiAudioPlayer | null = null;
static set playing(val) {
SuiAudioPlayer._playing = val;
}
static get audioBufferSize() {
return 512;
}
static incrementInstanceId() {
const id = SuiAudioPlayer.instanceId + 1;
SuiAudioPlayer.instanceId = id;
return id;
}
static get playing() {
if (typeof (SuiAudioPlayer._playing) === 'undefined') {
SuiAudioPlayer._playing = false;
}
return SuiAudioPlayer._playing;
}
static pausePlayer() {
if (SuiAudioPlayer._playingInstance) {
const a = SuiAudioPlayer._playingInstance;
a.paused = true;
a.audioAnimation.clearAudioAnimationHandler(0);
}
SuiAudioPlayer.playing = false;
}
instanceId: number;
paused: boolean;
view: SuiScoreView;
score: SmoScore;
cuedSounds: CuedAudioContexts;
audioDefaults = SuiOscillator.defaults;
openTies: Record<string, SoundParams | null> = {};
audioAnimation: SuiAudioAnimationParams;
constructor(parameters: SuiAudioPlayerParams) {
this.instanceId = SuiAudioPlayer.incrementInstanceId();
this.paused = false;
this.view = parameters.view;
this.score = parameters.score;
// Assume tempo is same for all measures
this.cuedSounds = new CuedAudioContexts();
this.audioAnimation = parameters.audioAnimation;
}
getNoteSoundData(measureIndex: number) {
const measureNotes: Record<number, SoundParams[]> = {};
let measureTicks = this.score.staves[0].measures[measureIndex].getMaxTicksVoice();
const freqDuplicates: Record<number, Record<number, number>> = {};
const voiceCount: Record<number, number> = {};
this.score.staves.forEach((staff, staffIx) => {
const measure = staff.measures[measureIndex];
measure.voices.forEach((voice, voiceIx) => {
let curTick = 0;
const instrument = staff.getStaffInstrument(measure.measureNumber.measureIndex);
voice.notes.forEach((smoNote, tickIx) => {
const frequencies: number[] = [];
const xpose = -1 * measure.transposeIndex;
const selector: SmoSelector = SmoSelector.default;
selector.measure = measureIndex;
selector.staff = staffIx;
selector.voice = voiceIx;
selector.tick = tickIx;
let ties: SmoTie[] = [];
const tieIx = '' + staffIx + '-' + measureIndex + '-' + voiceIx;
const prevMeasureIx = '' + staffIx + '-' + (measureIndex - 1) + '-' + voiceIx;
const silent = instrument.instrument === 'none';
if (smoNote.noteType === 'n' && !smoNote.isHidden() && !silent) {
ties = staff.getTiesStartingAt(selector);
smoNote.pitches.forEach((pitch, pitchIx) => {
const freq = SmoAudioPitch.smoPitchToFrequency(pitch, xpose, smoNote.getMicrotone(pitchIx) ?? null);
const freqRound = Math.round(freq);
if (!freqDuplicates[curTick]) {
freqDuplicates[curTick] = {};
voiceCount[curTick] = 0;
}
const freqBeat = freqDuplicates[curTick];
if (!freqBeat[freqRound]) {
freqBeat[freqRound] = 0;
}
if (freqBeat[freqRound] < SuiAudioPlayer.duplicatePitchThresh && voiceCount[curTick] < SuiAudioPlayer.voiceThresh) {
frequencies.push(freq);
freqBeat[freqRound] += 1;
voiceCount[curTick] += 1;
}
});
const duration = smoNote.tickCount;
const volume = SmoAudioScore.volumeFromNote(smoNote, SmoAudioScore.dynamicVolumeMap.mf);
const soundData: SoundParams = {
frequencies,
volume,
offsetPct: curTick / measureTicks,
durationPct: duration / measureTicks,
noteType: smoNote.noteType,
duration,
instrument: instrument.instrument,
selector
};
const pushTickArray = (curTick: number, soundData: SoundParams) => {
if (typeof(measureNotes[curTick]) === 'undefined') {
measureNotes[curTick] = [];
}
measureNotes[curTick].push(soundData);
}
// If this is continuation of tied note, just change duration
if (this.openTies[prevMeasureIx]) {
this.openTies[prevMeasureIx]!.duration += duration;
if (ties.length === 0) {
this.openTies[prevMeasureIx] = null;
}
}
else if (this.openTies[tieIx]) {
this.openTies[tieIx]!.duration += duration;
if (ties.length === 0) {
this.openTies[tieIx] = null;
}
} else if (ties.length) {
// If start of tied note, record the tie note, the next note in this voice
// will adjust duration
this.openTies[tieIx] = soundData;
pushTickArray(curTick, soundData);
} else {
pushTickArray(curTick, soundData);
}
}
curTick += Math.round(smoNote.tickCount);
});
});
});
const keys = Object.keys(measureNotes).map((x) => parseInt(x, 10));
if (keys.length) {
measureTicks -= keys.reduce((a, b) => a > b ? a : b);
}
return { endTicks: measureTicks, measureNotes };
}
createCuedSound(measureIndex: number) {
let i = 0;
let j = 0;
const roadmap = new ScoreRoadMapBuilder(this.score);
roadmap.populate(measureIndex);
console.log(JSON.stringify(roadmap.jumpQueue, null, ' '));
let measureBeat = 0;
if (!SuiAudioPlayer.playing || this.cuedSounds.paramLinkHead === null) {
return;
}
// TODO base on the selection start.
const { endTicks, measureNotes } = { endTicks: this.cuedSounds.paramLinkHead.endTicks, measureNotes: this.cuedSounds.paramLinkHead.soundParams };
this.cuedSounds.paramLinkHead = this.cuedSounds.paramLinkHead.next;
const maxMeasures = this.score.staves[0].measures.length;
const smoTemp = this.score.staves[0].measures[measureIndex].getTempo();
const tempo = smoTemp.bpm * (smoTemp.beatDuration / 4096);
const keys: number[] = [];
Object.keys(measureNotes).forEach((key) => {
keys.push(parseInt(key, 10));
});
// There is a key for each note in the measure. The value is the number of ticks before that note is played
for (j = 0; j < keys.length; ++j) {
const beatTime = keys[j];
const soundData = measureNotes[beatTime];
let durationPct = 0;
let offsetPct = 0;
if (soundData.length === 0) {
console.log('empty sound measure');
continue;
}
soundData.forEach((ss) => {
if (durationPct === 0) {
durationPct = ss.durationPct;
offsetPct = ss.offsetPct;
}
durationPct = Math.min(durationPct, ss.durationPct);
offsetPct = Math.min(offsetPct, ss.offsetPct);
});
const cuedSound: CuedAudioContext = { oscs: [], waitTime: 0, playMeasureIndex: measureIndex, playTickIndex: j,
offsetPct, durationPct, selector: soundData[0].selector };
const timeRatio = 60000 / (tempo * 4096);
// If there is complete silence here, put a silent beat
if (beatTime > measureBeat) {
const params = this.audioDefaults;
params.frequency = 0;
params.duration = (beatTime - measureBeat) * timeRatio;
params.gain = 0;
params.useReverb = false;
const silence: CuedAudioContext = { oscs: [], waitTime: params.duration, playMeasureIndex: measureIndex, playTickIndex: j,
offsetPct, durationPct, selector: soundData[0].selector };
silence.oscs.push(new SuiSampler(params));
this.cuedSounds.addToTail(silence);
measureBeat = beatTime;
}
this.cuedSounds.addToTail(cuedSound);
soundData.forEach((sound) => {
const adjDuration = Math.round(sound.duration * timeRatio) + 150;
for (i = 0; i < sound.frequencies.length && sound.noteType === 'n'; ++i) {
const freq = sound.frequencies[i];
const params = this.audioDefaults;
params.frequency = freq;
params.duration = adjDuration;
params.gain = sound.volume;
params.instrument = sound.instrument;
params.useReverb = this.score.audioSettings.reverbEnable;
if (this.score.audioSettings.playerType === 'synthesizer') {
params.wavetable = SynthWavetable;
params.waveform = this.score.audioSettings.waveform;
cuedSound.oscs.push(new SuiWavetable(params));
} else {
cuedSound.oscs.push(new SuiSampler(params));
}
}
});
if (j + 1 < keys.length) {
const diff = (keys[j + 1] - keys[j]);
cuedSound.waitTime = diff * timeRatio;
measureBeat += diff;
} else if (!roadmap.isDone) {
// If the next measure, calculate the frequencies for the next track.
cuedSound.waitTime = endTicks * timeRatio;
} else {
this.cuedSounds.complete = true;
}
// }, 1);
}
}
populateSounds(measureIndex: number) {
if (!SuiAudioPlayer.playing) {
return;
}
const interval = 20;
let draining = false;
const buffer = SuiAudioPlayer.audioBufferSize;
const timer = setInterval(() => {
if (this.cuedSounds.complete || SuiAudioPlayer.playing === false) {
clearInterval(timer);
return;
}
if (this.cuedSounds.paramLinkHead === null) {
this.cuedSounds.complete = true;
return;
}
if (draining && this.cuedSounds.soundCount > buffer / 4) {
return;
}
if (this.cuedSounds.soundCount > buffer) {
draining = true;
return;
}
draining = false;
this.createCuedSound(measureIndex);
}, interval);
}
playSounds() {
this.cuedSounds.playWaitTimer = 0;
let previousDuration = 0;
const timer = () => {
setTimeout(() => {
const cuedSound = this.cuedSounds.advanceHead();
if (cuedSound === null) {
SuiAudioPlayer._playing = false;
this.audioAnimation.clearAudioAnimationHandler(previousDuration);
return;
}
if (SuiAudioPlayer._playing === false) {
this.audioAnimation.clearAudioAnimationHandler(previousDuration);
return;
}
if (cuedSound.oscs.length === 0) {
this.cuedSounds.playWaitTimer = cuedSound.waitTime;
console.warn('empty oscs in playback');
timer();
return;
}
previousDuration = cuedSound.oscs[0].duration;
SuiAudioPlayer._playChord(cuedSound.oscs);
this.audioAnimation.audioAnimationHandler(this.view, cuedSound.selector,
cuedSound.offsetPct, cuedSound.durationPct);
this.cuedSounds.playWaitTimer = cuedSound.waitTime;
timer();
}, this.cuedSounds.playWaitTimer);
}
timer();
}
playAfter(milliseconds: number, oscs: SuiOscillator[]) {
setTimeout(() => {
SuiAudioPlayer._playChord(oscs);
}, milliseconds)
}
startPlayer(measureIndex: number) {
this.openTies = {};
this.cuedSounds.reset();
this.cuedSounds.paramLinkHead = null;
this.cuedSounds.paramLinkTail = null;
const roadmap = new ScoreRoadMapBuilder(this.score);
roadmap.populate(measureIndex);
while (!roadmap.isDone) {
const nextMeasure = roadmap.getAndAdvance();
const { endTicks, measureNotes } = this.getNoteSoundData(nextMeasure);
const node = {
soundParams: measureNotes,
endTicks,
measureIndex: nextMeasure,
next: null
};
if (this.cuedSounds.paramLinkHead === null) {
this.cuedSounds.paramLinkHead = node;
this.cuedSounds.paramLinkTail = node;
} else {
this.cuedSounds.paramLinkTail!.next = node;
this.cuedSounds.paramLinkTail = this.cuedSounds.paramLinkTail!.next;
}
}
setTimeout(() => {
this.populateSounds(measureIndex);
}, 1);
const bufferThenPlay = () => {
setTimeout(() => {
if (this.cuedSounds.soundListLength >= SuiAudioPlayer.audioBufferSize || this.cuedSounds.complete) {
this.playSounds();
} else {
bufferThenPlay();
}
}, 50);
}
bufferThenPlay();
}
static stopPlayer() {
if (SuiAudioPlayer._playingInstance) {
const a = SuiAudioPlayer._playingInstance;
a.audioAnimation.clearAudioAnimationHandler(0);
a.paused = false;
a.cuedSounds.reset();
}
SuiAudioPlayer.playing = false;
}
static get playingInstance() {
if (!SuiAudioPlayer._playingInstance) {
return null;
}
return SuiAudioPlayer._playingInstance;
}
// the oscAr contains an oscillator for each pitch in the chord.
// each inner oscillator is a promise, the combined promise is resolved when all
// the beats have completed.
static _playChord(oscAr: SuiOscillator[]) {
var par: Promise<void>[] = [];
oscAr.forEach((osc) => {
par.push(osc.play());
});
return Promise.all(par);
}
// Starts the player.
play() {
let i = 0;
if (SuiAudioPlayer.playing) {
return;
}
SuiAudioPlayer._playingInstance = this;
SuiAudioPlayer.playing = true;
const startIndex = this.view.tracker.getFirstMeasureOfSelection()?.measureNumber.measureIndex ?? 0;
//for (i = this.startIndex; i < this.score.staves[0].measures.length; ++i) {
// this.tracks.push(SuiAudioPlayer.getTrackSounds(this.audio.tracks, i));
// }
// const sounds = SuiAudioPlayer.getTrackSounds(this.audio.tracks, this.startIndex);
// this.playSoundsAtOffset(sounds, 0);
this.startPlayer(startIndex);
}
}