waveform-playlist
Version:
Multiple track web audio editor and player with waveform preview
604 lines (517 loc) • 17.5 kB
JavaScript
import _assign from "lodash.assign";
import _forOwn from "lodash.forown";
import { v4 as uuidv4 } from "uuid";
import h from "virtual-dom/h";
import extractPeaks from "webaudio-peaks";
import { FADEIN, FADEOUT } from "fade-maker";
import { secondsToPixels, secondsToSamples } from "./utils/conversions";
import stateClasses from "./track/states";
import CanvasHook from "./render/CanvasHook";
import FadeCanvasHook from "./render/FadeCanvasHook";
import VolumeSliderHook from "./render/VolumeSliderHook";
import StereoPanSliderHook from "./render/StereoPanSliderHook";
const MAX_CANVAS_WIDTH = 1000;
export default class {
constructor() {
this.name = "Untitled";
this.customClass = undefined;
this.waveOutlineColor = undefined;
this.gain = 1;
this.fades = {};
this.peakData = {
type: "WebAudio",
mono: false
};
this.cueIn = 0;
this.cueOut = 0;
this.duration = 0;
this.startTime = 0;
this.endTime = 0;
this.stereoPan = 0;
}
setEventEmitter(ee) {
this.ee = ee;
}
setName(name) {
this.name = name;
}
setCustomClass(className) {
this.customClass = className;
}
setWaveOutlineColor(color) {
this.waveOutlineColor = color;
}
setCues(cueIn, cueOut) {
if (cueOut < cueIn) {
throw new Error("cue out cannot be less than cue in");
}
this.cueIn = cueIn;
this.cueOut = cueOut;
this.duration = this.cueOut - this.cueIn;
this.endTime = this.startTime + this.duration;
}
/*
* start, end in seconds relative to the entire playlist.
*/
trim(start, end) {
const trackStart = this.getStartTime();
const trackEnd = this.getEndTime();
const offset = this.cueIn - trackStart;
if (trackStart <= start && trackEnd >= start || trackStart <= end && trackEnd >= end) {
const cueIn = start < trackStart ? trackStart : start;
const cueOut = end > trackEnd ? trackEnd : end;
this.setCues(cueIn + offset, cueOut + offset);
if (start > trackStart) {
this.setStartTime(start);
}
}
}
setStartTime(start) {
this.startTime = start;
this.endTime = start + this.duration;
}
setPlayout(playout) {
this.playout = playout;
}
setOfflinePlayout(playout) {
this.offlinePlayout = playout;
}
setEnabledStates(enabledStates = {}) {
const defaultStatesEnabled = {
cursor: true,
fadein: true,
fadeout: true,
select: true,
shift: true
};
this.enabledStates = _assign({}, defaultStatesEnabled, enabledStates);
}
setFadeIn(duration, shape = "logarithmic") {
if (duration > this.duration) {
throw new Error("Invalid Fade In");
}
const fade = {
shape,
start: 0,
end: duration
};
if (this.fadeIn) {
this.removeFade(this.fadeIn);
this.fadeIn = undefined;
}
this.fadeIn = this.saveFade(FADEIN, fade.shape, fade.start, fade.end);
}
setFadeOut(duration, shape = "logarithmic") {
if (duration > this.duration) {
throw new Error("Invalid Fade Out");
}
const fade = {
shape,
start: this.duration - duration,
end: this.duration
};
if (this.fadeOut) {
this.removeFade(this.fadeOut);
this.fadeOut = undefined;
}
this.fadeOut = this.saveFade(FADEOUT, fade.shape, fade.start, fade.end);
}
saveFade(type, shape, start, end) {
const id = uuidv4();
this.fades[id] = {
type,
shape,
start,
end
};
return id;
}
removeFade(id) {
delete this.fades[id];
}
setBuffer(buffer) {
this.buffer = buffer;
}
setPeakData(data) {
this.peakData = data;
}
calculatePeaks(samplesPerPixel, sampleRate) {
const cueIn = secondsToSamples(this.cueIn, sampleRate);
const cueOut = secondsToSamples(this.cueOut, sampleRate);
this.setPeaks(extractPeaks(this.buffer, samplesPerPixel, this.peakData.mono, cueIn, cueOut));
}
setPeaks(peaks) {
this.peaks = peaks;
}
setState(state) {
this.state = state;
if (this.state && this.enabledStates[this.state]) {
const StateClass = stateClasses[this.state];
this.stateObj = new StateClass(this);
} else {
this.stateObj = undefined;
}
}
getStartTime() {
return this.startTime;
}
getEndTime() {
return this.endTime;
}
getDuration() {
return this.duration;
}
isPlaying() {
return this.playout.isPlaying();
}
setShouldPlay(bool) {
this.playout.setShouldPlay(bool);
}
setGainLevel(level) {
this.gain = level;
this.playout.setVolumeGainLevel(level);
}
setMasterGainLevel(level) {
this.playout.setMasterGainLevel(level);
}
setStereoPanValue(value) {
this.stereoPan = value;
this.playout.setStereoPanValue(value);
}
setEffects(effectsGraph) {
this.effectsGraph = effectsGraph;
this.playout.setEffects(effectsGraph);
}
/*
startTime, endTime in seconds (float).
segment is for a highlighted section in the UI.
returns a Promise that will resolve when the AudioBufferSource
is either stopped or plays out naturally.
*/
schedulePlay(now, startTime, endTime, config) {
let start;
let duration;
let when = now;
let segment = endTime ? endTime - startTime : undefined;
const defaultOptions = {
shouldPlay: true,
masterGain: 1,
isOffline: false
};
const options = _assign({}, defaultOptions, config);
const playoutSystem = options.isOffline ? this.offlinePlayout : this.playout; // 1) track has no content to play.
// 2) track does not play in this selection.
if (this.endTime <= startTime || segment && startTime + segment < this.startTime) {
// return a resolved promise since this track is technically "stopped".
return Promise.resolve();
} // track should have something to play if it gets here.
// the track starts in the future or on the cursor position
if (this.startTime >= startTime) {
start = 0; // schedule additional delay for this audio node.
when += this.startTime - startTime;
if (endTime) {
segment -= this.startTime - startTime;
duration = Math.min(segment, this.duration);
} else {
duration = this.duration;
}
} else {
start = startTime - this.startTime;
if (endTime) {
duration = Math.min(segment, this.duration - start);
} else {
duration = this.duration - start;
}
}
start += this.cueIn;
const relPos = startTime - this.startTime;
const sourcePromise = playoutSystem.setUpSource(); // param relPos: cursor position in seconds relative to this track.
// can be negative if the cursor is placed before the start of this track etc.
_forOwn(this.fades, fade => {
let fadeStart;
let fadeDuration; // only apply fade if it's ahead of the cursor.
if (relPos < fade.end) {
if (relPos <= fade.start) {
fadeStart = now + (fade.start - relPos);
fadeDuration = fade.end - fade.start;
} else if (relPos > fade.start && relPos < fade.end) {
fadeStart = now - (relPos - fade.start);
fadeDuration = fade.end - fade.start;
}
switch (fade.type) {
case FADEIN:
{
playoutSystem.applyFadeIn(fadeStart, fadeDuration, fade.shape);
break;
}
case FADEOUT:
{
playoutSystem.applyFadeOut(fadeStart, fadeDuration, fade.shape);
break;
}
default:
{
throw new Error("Invalid fade type saved on track.");
}
}
}
});
playoutSystem.setVolumeGainLevel(this.gain);
playoutSystem.setShouldPlay(options.shouldPlay);
playoutSystem.setMasterGainLevel(options.masterGain);
playoutSystem.setStereoPanValue(this.stereoPan);
playoutSystem.play(when, start, duration);
return sourcePromise;
}
scheduleStop(when = 0) {
this.playout.stop(when);
}
renderOverlay(data) {
const channelPixels = secondsToPixels(data.playlistLength, data.resolution, data.sampleRate);
const config = {
attributes: {
style: `position: absolute; top: 0; right: 0; bottom: 0; left: 0; width: ${channelPixels}px; z-index: 9;`
}
};
let overlayClass = "";
if (this.stateObj) {
this.stateObj.setup(data.resolution, data.sampleRate);
const StateClass = stateClasses[this.state];
const events = StateClass.getEvents();
events.forEach(event => {
config[`on${event}`] = this.stateObj[event].bind(this.stateObj);
});
overlayClass = StateClass.getClass();
} // use this overlay for track event cursor position calculations.
return h(`div.playlist-overlay${overlayClass}`, config);
}
renderControls(data) {
const muteClass = data.muted ? ".active" : "";
const soloClass = data.soloed ? ".active" : "";
const isCollapsed = data.collapsed;
const numChan = this.peaks.data.length;
const widgets = data.controls.widgets;
const removeTrack = h("button.btn.btn-danger.btn-xs.track-remove", {
attributes: {
type: "button",
title: "Remove track"
},
onclick: () => {
this.ee.emit("removeTrack", this);
}
}, [h("i.fas.fa-times")]);
const trackName = h("span", [this.name]);
const collapseTrack = h("button.btn.btn-info.btn-xs.track-collapse", {
attributes: {
type: "button",
title: isCollapsed ? "Expand track" : "Collapse track"
},
onclick: () => {
this.ee.emit("changeTrackView", this, {
collapsed: !isCollapsed
});
}
}, [h(`i.fas.${isCollapsed ? "fa-caret-down" : "fa-caret-up"}`)]);
const headerChildren = [];
if (widgets.remove) {
headerChildren.push(removeTrack);
}
headerChildren.push(trackName);
if (widgets.collapse) {
headerChildren.push(collapseTrack);
}
const controls = [h("div.track-header", headerChildren)];
if (!isCollapsed) {
if (widgets.muteOrSolo) {
controls.push(h("div.btn-group", [h(`button.btn.btn-outline-dark.btn-xs.btn-mute${muteClass}`, {
attributes: {
type: "button"
},
onclick: () => {
this.ee.emit("mute", this);
}
}, ["Mute"]), h(`button.btn.btn-outline-dark.btn-xs.btn-solo${soloClass}`, {
onclick: () => {
this.ee.emit("solo", this);
}
}, ["Solo"])]));
}
if (widgets.volume) {
controls.push(h("label.volume", [h("input.volume-slider", {
attributes: {
"aria-label": "Track volume control",
type: "range",
min: 0,
max: 100,
value: 100
},
hook: new VolumeSliderHook(this.gain),
oninput: e => {
this.ee.emit("volumechange", e.target.value, this);
}
})]));
}
if (widgets.stereoPan) {
controls.push(h("label.stereopan", [h("input.stereopan-slider", {
attributes: {
"aria-label": "Track stereo pan control",
type: "range",
min: -100,
max: 100,
value: 100
},
hook: new StereoPanSliderHook(this.stereoPan),
oninput: e => {
this.ee.emit("stereopan", e.target.value / 100, this);
}
})]));
}
}
return h("div.controls", {
attributes: {
style: `height: ${numChan * data.height}px; width: ${data.controls.width}px; position: absolute; left: 0; z-index: 10;`
}
}, controls);
}
render(data) {
const width = this.peaks.length;
const playbackX = secondsToPixels(data.playbackSeconds, data.resolution, data.sampleRate);
const startX = secondsToPixels(this.startTime, data.resolution, data.sampleRate);
const endX = secondsToPixels(this.endTime, data.resolution, data.sampleRate);
let progressWidth = 0;
const numChan = this.peaks.data.length;
const scale = Math.ceil(window.devicePixelRatio);
if (playbackX > 0 && playbackX > startX) {
if (playbackX < endX) {
progressWidth = playbackX - startX;
} else {
progressWidth = width;
}
}
const waveformChildren = [h("div.cursor", {
attributes: {
style: `position: absolute; width: 1px; margin: 0; padding: 0; top: 0; left: ${playbackX}px; bottom: 0; z-index: 5;`
}
})];
const channels = Object.keys(this.peaks.data).map(channelNum => {
const channelChildren = [h("div.channel-progress", {
attributes: {
style: `position: absolute; width: ${progressWidth}px; height: ${data.height}px; z-index: 2;`
}
})];
let offset = 0;
let totalWidth = width;
const peaks = this.peaks.data[channelNum];
while (totalWidth > 0) {
const currentWidth = Math.min(totalWidth, MAX_CANVAS_WIDTH);
const canvasColor = this.waveOutlineColor ? this.waveOutlineColor : data.colors.waveOutlineColor;
channelChildren.push(h("canvas", {
attributes: {
width: currentWidth * scale,
height: data.height * scale,
style: `float: left; position: relative; margin: 0; padding: 0; z-index: 3; width: ${currentWidth}px; height: ${data.height}px;`
},
hook: new CanvasHook(peaks, offset, this.peaks.bits, canvasColor, scale, data.height, data.barWidth, data.barGap)
}));
totalWidth -= currentWidth;
offset += MAX_CANVAS_WIDTH;
} // if there are fades, display them.
if (this.fadeIn) {
const fadeIn = this.fades[this.fadeIn];
const fadeWidth = secondsToPixels(fadeIn.end - fadeIn.start, data.resolution, data.sampleRate);
channelChildren.push(h("div.wp-fade.wp-fadein", {
attributes: {
style: `position: absolute; height: ${data.height}px; width: ${fadeWidth}px; top: 0; left: 0; z-index: 4;`
}
}, [h("canvas", {
attributes: {
width: fadeWidth,
height: data.height
},
hook: new FadeCanvasHook(fadeIn.type, fadeIn.shape, fadeIn.end - fadeIn.start, data.resolution)
})]));
}
if (this.fadeOut) {
const fadeOut = this.fades[this.fadeOut];
const fadeWidth = secondsToPixels(fadeOut.end - fadeOut.start, data.resolution, data.sampleRate);
channelChildren.push(h("div.wp-fade.wp-fadeout", {
attributes: {
style: `position: absolute; height: ${data.height}px; width: ${fadeWidth}px; top: 0; right: 0; z-index: 4;`
}
}, [h("canvas", {
attributes: {
width: fadeWidth,
height: data.height
},
hook: new FadeCanvasHook(fadeOut.type, fadeOut.shape, fadeOut.end - fadeOut.start, data.resolution)
})]));
}
return h(`div.channel.channel-${channelNum}`, {
attributes: {
style: `height: ${data.height}px; width: ${width}px; top: ${channelNum * data.height}px; left: ${startX}px; position: absolute; margin: 0; padding: 0; z-index: 1;`
}
}, channelChildren);
});
waveformChildren.push(channels);
waveformChildren.push(this.renderOverlay(data)); // draw cursor selection on active track.
if (data.isActive === true) {
const cStartX = secondsToPixels(data.timeSelection.start, data.resolution, data.sampleRate);
const cEndX = secondsToPixels(data.timeSelection.end, data.resolution, data.sampleRate);
const cWidth = cEndX - cStartX + 1;
const cClassName = cWidth > 1 ? ".segment" : ".point";
waveformChildren.push(h(`div.selection${cClassName}`, {
attributes: {
style: `position: absolute; width: ${cWidth}px; bottom: 0; top: 0; left: ${cStartX}px; z-index: 4;`
}
}));
}
const waveform = h("div.waveform", {
attributes: {
style: `height: ${numChan * data.height}px; position: relative;`
}
}, waveformChildren);
const channelChildren = [];
let channelMargin = 0;
if (data.controls.show) {
channelChildren.push(this.renderControls(data));
channelMargin = data.controls.width;
}
channelChildren.push(waveform);
const audibleClass = data.shouldPlay ? "" : ".silent";
const customClass = this.customClass === undefined ? "" : `.${this.customClass}`;
return h(`div.channel-wrapper${audibleClass}${customClass}`, {
attributes: {
style: `margin-left: ${channelMargin}px; height: ${data.height * numChan}px;`
}
}, channelChildren);
}
getTrackDetails() {
const info = {
src: this.src,
start: this.startTime,
end: this.endTime,
name: this.name,
customClass: this.customClass,
cuein: this.cueIn,
cueout: this.cueOut,
stereoPan: this.stereoPan,
gain: this.gain,
effects: this.effectsGraph
};
if (this.fadeIn) {
const fadeIn = this.fades[this.fadeIn];
info.fadeIn = {
shape: fadeIn.shape,
duration: fadeIn.end - fadeIn.start
};
}
if (this.fadeOut) {
const fadeOut = this.fades[this.fadeOut];
info.fadeOut = {
shape: fadeOut.shape,
duration: fadeOut.end - fadeOut.start
};
}
return info;
}
}