spessasynth_core
Version:
MIDI and SoundFont2/DLS library with no compromises
375 lines (342 loc) • 9.91 kB
JavaScript
/**
* samples.js
* purpose: parses soundfont samples, resamples if needed.
* loads sample data, handles async loading of sf3 compressed samples
*/
import { SpessaSynthWarn } from "../../utils/loggin.js";
import { IndexedByteArray } from "../../utils/indexed_array.js";
// should be reasonable for most cases
const RESAMPLE_RATE = 48000;
/**
* @enum {number}
*/
export const sampleTypes = {
monoSample: 1,
rightSample: 2,
leftSample: 4,
linkedSample: 8,
romMonoSample: 32769,
romRightSample: 32770,
romLeftSample: 32772,
romLinkedSample: 32776
};
/**
* @typedef {function} EncodeVorbisFunction
* @param channelAudioData {Float32Array[]}
* @param sampleRate {number}
* @param channels {number}
* @param quality {number} -0.1 to 1
* @returns {Uint8Array}
*/
export class BasicSample
{
/**
* The sample's name
* @type {string}
*/
sampleName;
/**
* Sample rate in Hz
* @type {number}
*/
sampleRate;
/**
* Original pitch of the sample as a MIDI note number
* @type {number}
*/
samplePitch;
/**
* Pitch correction, in cents. Can be negative
* @type {number}
*/
samplePitchCorrection;
/**
* Linked sample, unused if mono
* @type {BasicSample|undefined}
*/
linkedSample;
/**
* The type of the sample
* @type {sampleTypes}
*/
sampleType;
/**
* Relative to the start of the sample in sample points
* @type {number}
*/
sampleLoopStartIndex;
/**
* Relative to the start of the sample in sample points
* @type {number}
*/
sampleLoopEndIndex;
/**
* Indicates if the sample is compressed using vorbis SF3
* @type {boolean}
*/
isCompressed;
/**
* The compressed sample data if the sample has been compressed
* @type {Uint8Array|undefined}
*/
compressedData = undefined;
/**
* Sample's linked instruments (the instruments that use it)
* note that duplicates are allowed since one instrument can use the same sample multople times
* @type {BasicInstrument[]}
*/
linkedInstruments = [];
/**
* The sample's audio data
* @type {Float32Array}
*/
sampleData = undefined;
/**
* Indicates if the data was overriden, so it cannot be copied back unchanged
* @type {boolean}
*/
dataOverriden = false;
/**
* The basic representation of a sample
* @param sampleName {string} The sample's name
* @param sampleRate {number} The sample's rate in Hz
* @param samplePitch {number} The sample's pitch as a MIDI note number
* @param samplePitchCorrection {number} The sample's pitch correction in cents
* @param sampleType {sampleTypes|number} The sample's type, an enum that can indicate SF3
* @param loopStart {number} The sample's loop start relative to the sample start in sample points
* @param loopEnd {number} The sample's loop end relative to the sample start in sample points
*/
constructor(
sampleName,
sampleRate,
samplePitch,
samplePitchCorrection,
sampleType,
loopStart,
loopEnd
)
{
this.sampleName = sampleName;
this.sampleRate = sampleRate;
this.samplePitch = samplePitch;
this.samplePitchCorrection = samplePitchCorrection;
this.sampleLoopStartIndex = loopStart;
this.sampleLoopEndIndex = loopEnd;
this.sampleType = sampleType;
}
/**
* If the sample is linked to another sample
* @returns {boolean}
*/
get isLinked()
{
return this.sampleType === sampleTypes.rightSample ||
this.sampleType === sampleTypes.leftSample ||
this.sampleType === sampleTypes.linkedSample;
}
/**
* The sample's use count
* @type {number}
*/
get useCount()
{
return this.linkedInstruments.length;
}
/**
* Get raw data for writing the file
* @param allowVorbis {boolean}
* @return {Uint8Array} either s16 or vorbis data
* @virtual
*/
getRawData(allowVorbis = true)
{
return this.encodeS16LE();
}
resampleData(newSampleRate)
{
let audioData = this.getAudioData();
const ratio = newSampleRate / this.sampleRate;
const resampled = new Float32Array(Math.floor(audioData.length * ratio));
for (let i = 0; i < resampled.length; i++)
{
resampled[i] = audioData[Math.floor(i * (1 / ratio))];
}
audioData = resampled;
this.sampleRate = newSampleRate;
// adjust loop points
this.sampleLoopStartIndex = Math.floor(this.sampleLoopStartIndex * ratio);
this.sampleLoopEndIndex = Math.floor(this.sampleLoopEndIndex * ratio);
this.sampleData = audioData;
}
/**
* @param quality {number}
* @param encodeVorbis {EncodeVorbisFunction}
*/
compressSample(quality, encodeVorbis)
{
// no need to compress
if (this.isCompressed)
{
return;
}
// compress, always mono!
try
{
// if the sample rate is too low or too high, resample
let audioData = this.getAudioData();
if (this.sampleRate < 8000 || this.sampleRate > 96000)
{
this.resampleData(RESAMPLE_RATE);
audioData = this.getAudioData();
}
this.compressedData = encodeVorbis([audioData], 1, this.sampleRate, quality);
// flag as compressed
this.isCompressed = true;
// allow the data to be copied from the compressedData chunk during the write operation
this.dataOverriden = false;
}
catch (e)
{
SpessaSynthWarn(`Failed to compress ${this.sampleName}. Leaving as uncompressed!`);
delete this.compressedData;
// flag as uncompressed
this.isCompressed = false;
}
}
/**
* @param type {sampleTypes|number}
*/
setSampleType(type)
{
this.sampleType = type;
if (!this.isLinked)
{
// unlink the other sample
if (this.linkedSample)
{
this.linkedSample.linkedSample = undefined;
this.linkedSample.sampleType = type;
}
this.linkedSample = undefined;
}
if ((type & 0x8000) > 0)
{
throw new Error("ROM samples are not supported.");
}
}
deleteSample()
{
if (this.useCount > 0)
{
throw new Error(`Cannot delete sample that has ${this.useCount} usages.`);
}
this.unlinkSample();
}
// noinspection JSUnusedGlobalSymbols
/**
* Unlinks a sample link
*/
unlinkSample()
{
this.setSampleType(sampleTypes.monoSample);
}
// noinspection JSUnusedGlobalSymbols
/**
* Links a stereo sample
* @param sample {BasicSample} the sample to link to
* @param type {sampleTypes} either left, right or linked
*/
setLinkedSample(sample, type)
{
this.linkedSample = sample;
sample.linkedSample = this;
if (type === sampleTypes.leftSample)
{
this.setSampleType(sampleTypes.leftSample);
sample.setSampleType(sampleTypes.rightSample);
}
else if (type === sampleTypes.rightSample)
{
this.setSampleType(sampleTypes.rightSample);
sample.setSampleType(sampleTypes.leftSample);
}
else if (type === sampleTypes.linkedSample)
{
this.setSampleType(sampleTypes.linkedSample);
sample.setSampleType(sampleTypes.linkedSample);
}
else
{
throw new Error("Invalid sample type: " + type);
}
}
/**
* @param instrument {BasicInstrument}
*/
linkTo(instrument)
{
this.linkedInstruments.push(instrument);
}
/**
* @param instrument {BasicInstrument}
*/
unlinkFrom(instrument)
{
const index = this.linkedInstruments.indexOf(instrument);
if (index < 0)
{
throw new Error(`Cannot unlink ${instrument.instrumentName} from ${this.sampleName}: not linked.`);
}
this.linkedInstruments.splice(index, 1);
}
/**
* @returns {Float32Array}
* @virtual
*/
getAudioData()
{
if (!this.sampleData)
{
throw new Error("Error! Sample data is undefined. Is the method overriden properly?");
}
return this.sampleData;
}
/**
* Encodes s16le sample
* @return {IndexedByteArray}
*/
encodeS16LE()
{
const data = this.getAudioData();
const data16 = new Int16Array(data.length);
const len = data.length;
for (let i = 0; i < len; i++)
{
let sample = data[i] * 32768;
// Clamp for safety (do not use Math.max/Math.min here)
if (sample > 32767)
{
sample = 32767;
}
else if (sample < -32768)
{
sample = -32768;
}
data16[i] = sample;
}
return new IndexedByteArray(data16.buffer);
}
// noinspection JSUnusedGlobalSymbols
/**
* REPLACES the audio data
* @param audioData {Float32Array}
* @virtual
*/
setAudioData(audioData)
{
this.isCompressed = false;
delete this.compressedData;
this.sampleData = audioData;
this.dataOverriden = true;
}
}