s4s-editor / src /lib /audio /audio-effects.js
soiz1's picture
Update src/lib/audio/audio-effects.js
54abf5d verified
import EchoEffect from './effects/echo-effect.js';
import RobotEffect from './effects/robot-effect.js';
import VolumeEffect from './effects/volume-effect.js';
import FadeEffect from './effects/fade-effect.js';
import MuteEffect from './effects/mute-effect.js';
import LowPassEffect from './effects/lowpass-effect.js';
import HighPassEffect from './effects/highpass-effect.js';
const effectTypes = {
ROBOT: 'robot',
REVERSE: 'reverse',
LOUDER: 'higher',
SOFTER: 'lower',
FASTER: 'faster',
SLOWER: 'slower',
ECHO: 'echo',
FADEIN: 'fade in',
FADEOUT: 'fade out',
MUTE: 'mute',
LOWPASS: 'low pass',
HIGHPASS: 'high pass'
};
const centsToFrequency = (cents) => {
return Math.round(1000000 * Math.pow(2, (cents / 100 / 12))) / 1000000;
}
class AudioEffects {
static get effectTypes () {
return effectTypes;
}
constructor (buffer, options, trimStart, trimEnd) {
this.trimStartSeconds = (trimStart * buffer.length) / buffer.sampleRate;
this.trimEndSeconds = (trimEnd * buffer.length) / buffer.sampleRate;
this.adjustedTrimStartSeconds = this.trimStartSeconds;
this.adjustedTrimEndSeconds = this.trimEndSeconds;
// Some effects will modify the playback rate and/or number of samples.
// Need to precompute those values to create the offline audio context.
const pitchRatio = Math.pow(2, 4 / 12); // A major third
let sampleCount = buffer.length;
const affectedSampleCount = Math.floor((this.trimEndSeconds - this.trimStartSeconds) *
buffer.sampleRate);
let adjustedAffectedSampleCount = affectedSampleCount;
const unaffectedSampleCount = sampleCount - affectedSampleCount;
// These affect the sampleCount
this.playbackRate = 1;
switch (options.preset) {
case effectTypes.ECHO:
sampleCount = Math.max(sampleCount,
Math.floor((this.trimEndSeconds + EchoEffect.TAIL_SECONDS) * buffer.sampleRate));
break;
case effectTypes.FASTER:
this.playbackRate = pitchRatio;
adjustedAffectedSampleCount = Math.floor(affectedSampleCount / this.playbackRate);
sampleCount = unaffectedSampleCount + adjustedAffectedSampleCount;
break;
case effectTypes.SLOWER:
this.playbackRate = 1 / pitchRatio;
adjustedAffectedSampleCount = Math.floor(affectedSampleCount / this.playbackRate);
sampleCount = unaffectedSampleCount + adjustedAffectedSampleCount;
break;
default:
if (Object.prototype.hasOwnProperty.call(options, "pitch")) {
this.playbackRate = centsToFrequency(options.pitch);
adjustedAffectedSampleCount = Math.floor(affectedSampleCount / this.playbackRate);
sampleCount = unaffectedSampleCount + adjustedAffectedSampleCount;
}
break;
}
const durationSeconds = sampleCount / buffer.sampleRate;
this.adjustedTrimEndSeconds = this.trimStartSeconds +
(adjustedAffectedSampleCount / buffer.sampleRate);
this.adjustedTrimStart = this.adjustedTrimStartSeconds / durationSeconds;
this.adjustedTrimEnd = this.adjustedTrimEndSeconds / durationSeconds;
let audioContextSampleRate = buffer.sampleRate;
let audioContextSampleCount = sampleCount;
if (Object.prototype.hasOwnProperty.call(options, "sampleRateEnforced")) {
const newSampleRate = options.sampleRateEnforced;
audioContextSampleRate = newSampleRate;
audioContextSampleCount = Math.floor((sampleCount / buffer.sampleRate) * newSampleRate);
}
if (window.OfflineAudioContext) {
this.audioContext = new window.OfflineAudioContext(1, audioContextSampleCount, audioContextSampleRate);
} else {
// Need to use webkitOfflineAudioContext, which doesn't support all sample rates.
// Resample by adjusting sample count to make room and set offline context to desired sample rate.
const sampleScale = 44100 / audioContextSampleRate;
this.audioContext = new window.webkitOfflineAudioContext(1, sampleScale * audioContextSampleCount, 44100);
}
// All effects not seen below use the original buffer because it is not modified.
this.buffer = buffer;
// For the reverse effect we need to manually reverse the data into a new audio buffer
// to prevent overwriting the original, so that the undo stack works correctly.
// Doing buffer.reverse() would mutate the original data.
if (options.preset === effectTypes.REVERSE) {
const buffer = this.buffer;
const originalBufferData = buffer.getChannelData(0);
const newBuffer = this.audioContext.createBuffer(1, buffer.length, buffer.sampleRate);
const newBufferData = newBuffer.getChannelData(0);
const bufferLength = buffer.length;
const startSamples = Math.floor(this.trimStartSeconds * buffer.sampleRate);
const endSamples = Math.floor(this.trimEndSeconds * buffer.sampleRate);
let counter = 0;
for (let i = 0; i < bufferLength; i++) {
if (i >= startSamples && i < endSamples) {
newBufferData[i] = originalBufferData[endSamples - counter - 1];
counter++;
} else {
newBufferData[i] = originalBufferData[i];
}
}
this.buffer = newBuffer;
}
if (Object.prototype.hasOwnProperty.call(options, "sampleRate")) {
// We can't overwrite the original buffer so we make a clone.
const buffer = this.buffer;
const originalBufferData = buffer.getChannelData(0);
const newBuffer = this.audioContext.createBuffer(1, buffer.length, buffer.sampleRate);
const newBufferData = newBuffer.getChannelData(0);
const bufferLength = buffer.length;
// Our clone from earlier also needs to keep the original buffer's sample rate, so we need to make yet another buffer.
const sampleRateBuffer = this.makeSampleRateBuffer(buffer, durationSeconds, options.sampleRate);
const sampleRateBufferData = sampleRateBuffer.getChannelData(0);
const startSamples = Math.floor(this.trimStartSeconds * buffer.sampleRate);
const endSamples = Math.floor(this.trimEndSeconds * buffer.sampleRate);
for (let i = 0; i < bufferLength; i++) {
if (i >= startSamples && i < endSamples) {
// We need to convert sampleRate back to the current buffer's sampleRate
const sampleRateModifiedIndex = i * (sampleRateBuffer.sampleRate / buffer.sampleRate);
const lowerIndex = Math.floor(sampleRateModifiedIndex);
const upperIndex = Math.min(lowerIndex + 1, sampleRateBuffer.length - 1);
const interpolation = sampleRateModifiedIndex - lowerIndex;
const sample =
sampleRateBufferData[lowerIndex] * (1 - interpolation) +
sampleRateBufferData[upperIndex] * interpolation;
// This works without Number.isFinite but it breaks the waveform preview SVG because sample can be NaN
newBufferData[i] = Number.isFinite(sample) ? sample : 0;
} else {
newBufferData[i] = originalBufferData[i];
}
}
this.buffer = newBuffer;
}
this.source = this.audioContext.createBufferSource();
this.source.buffer = this.buffer;
this.options = options;
}
makeSampleRateBuffer(buffer, durationSeconds, newSampleRate) {
const originalBufferData = buffer.getChannelData(0);
const newBufferLength = Math.floor(durationSeconds * newSampleRate);
const newBuffer = this.audioContext.createBuffer(1, newBufferLength, newSampleRate);
const newBufferData = newBuffer.getChannelData(0);
const bufferLength = buffer.length;
// this does work with just bufferLength but causes cut-off when newSampleRate is larger than the current sample rate
for (let i = 0; i < newBufferLength; i++) {
const originalIndex = i * (buffer.sampleRate / newSampleRate);
const lowerIndex = Math.floor(originalIndex);
const upperIndex = Math.min(lowerIndex + 1, bufferLength - 1);
const interpolation = originalIndex - lowerIndex;
const sample =
originalBufferData[lowerIndex] * (1 - interpolation) +
originalBufferData[upperIndex] * interpolation;
newBufferData[i] = sample;
}
return newBuffer;
}
process (done) {
// Some effects need to use more nodes and must expose an input and output
let input;
let output;
switch (this.options.preset) {
case effectTypes.FASTER:
case effectTypes.SLOWER:
this.source.playbackRate.setValueAtTime(this.playbackRate, this.adjustedTrimStartSeconds);
this.source.playbackRate.setValueAtTime(1.0, this.adjustedTrimEndSeconds);
break;
case effectTypes.LOUDER:
({input, output} = new VolumeEffect(this.audioContext, 1.25,
this.adjustedTrimStartSeconds, this.adjustedTrimEndSeconds));
break;
case effectTypes.SOFTER:
({input, output} = new VolumeEffect(this.audioContext, 0.75,
this.adjustedTrimStartSeconds, this.adjustedTrimEndSeconds));
break;
case effectTypes.ECHO:
({input, output} = new EchoEffect(this.audioContext,
this.adjustedTrimStartSeconds, this.adjustedTrimEndSeconds));
break;
case effectTypes.ROBOT:
({input, output} = new RobotEffect(this.audioContext,
this.adjustedTrimStartSeconds, this.adjustedTrimEndSeconds));
break;
case effectTypes.LOWPASS:
({input, output} = new LowPassEffect(this.audioContext,
this.adjustedTrimStartSeconds, this.adjustedTrimEndSeconds));
break;
case effectTypes.HIGHPASS:
({input, output} = new HighPassEffect(this.audioContext,
this.adjustedTrimStartSeconds, this.adjustedTrimEndSeconds));
break;
case effectTypes.FADEIN:
({input, output} = new FadeEffect(this.audioContext, true,
this.adjustedTrimStartSeconds, this.adjustedTrimEndSeconds));
break;
case effectTypes.FADEOUT:
({input, output} = new FadeEffect(this.audioContext, false,
this.adjustedTrimStartSeconds, this.adjustedTrimEndSeconds));
break;
case effectTypes.MUTE:
({input, output} = new MuteEffect(this.audioContext,
this.adjustedTrimStartSeconds, this.adjustedTrimEndSeconds));
break;
default:
if (Object.prototype.hasOwnProperty.call(this.options, "pitch")) {
this.source.playbackRate.setValueAtTime(this.playbackRate, this.adjustedTrimStartSeconds);
this.source.playbackRate.setValueAtTime(1.0, this.adjustedTrimEndSeconds);
}
if (Object.prototype.hasOwnProperty.call(this.options, "volume")) {
({input, output} = new VolumeEffect(this.audioContext, this.options.volume,
this.adjustedTrimStartSeconds, this.adjustedTrimEndSeconds));
}
break;
}
if (input && output) {
this.source.connect(input);
output.connect(this.audioContext.destination);
} else {
// No effects nodes are needed, wire directly to the output
this.source.connect(this.audioContext.destination);
}
this.source.start();
this.audioContext.startRendering();
this.audioContext.oncomplete = ({renderedBuffer}) => {
done(renderedBuffer, this.adjustedTrimStart, this.adjustedTrimEnd);
};
}
}
export default AudioEffects;