sample-editor-view
Version:
A Canvas Renderer / Editor UI for AudioBuffers
418 lines (316 loc) • 11.4 kB
JavaScript
/**
* Main program, compiles everything onto one canvas and handles interaction
* @Author: Rikard Lindstrom <code@rikard.io>
* @Filename: SampleEditorView.js
*/
import CanvasUI from './CanvasUI';
import Waveform from './Waveform';
import Ruler from './Ruler';
import LineMarker from './LineMarker';
import LoopMarker from './LoopMarker';
const defaultProperties = {
hZoom: 1,
vZoom: 2,
offset: 0,
background: '#ddd',
color: '#222',
selectColor: '#ddd',
selectBackground: '#222',
width: 640,
height: 320,
channel: 0,
resolution: 1,
startPosition: 42.1,
uiZoomStickiness: 0.1,
duration: 'auto',
visible: true,
loop: true,
loopStart: 0,
loopEnd: 1,
selectStart: 0,
selectEnd: 0,
quantize: 0.0125,
buffer: null
};
class SampleEditorView extends CanvasUI {
constructor(props) {
super(defaultProperties, props);
this.waveForm = new Waveform({});
this.ruler = new Ruler({});
this.startMarker = new LineMarker({});
this.zoomMarker = new LineMarker({visible: false});
this.loopLengthMarker = new LoopMarker({visible: true});
this.loopStartMarker = new LineMarker({visible: true});
this.loopEndMarker = new LineMarker({dir: -1, visible: true});
this.waveForm.props.$link(this.props);
this.ruler.props.$link(this.props, ['hZoom', 'width', 'offset', 'quantize',
'buffer' ]).$map(this.props, { height: v => v / 16 });
this.zoomMarker.props.$link(this.props, ['height']);
this.loopEndMarker.props.$link(this.props, ['height'])
.$map(this.props, { width: v => v / 64 });
this.loopStartMarker.props.$link(this.props, ['height'])
.$map(this.props, { width: v => v / 64 });
this.loopLengthMarker.props.$map(this.props, {
height: v => v / 32
});
this.startMarker.props.$link(this.props, ['height'])
.$map(this.props, { width: v => v / 64 });
this.render = this.render.bind(this);
this._setupUI();
this.props.$on('defered_change', this.renderIfDirty, this);
this.zoomMarker.props.$observe('visible', this.renderIfDirty, this);
this.canvas.classList.add('SampleEditorView');
}
render() {
let ctx = this.ctx;
ctx.drawImage(this.waveForm.renderIfDirty().canvas, 0, 0);
ctx.drawImage(this.zoomMarker.renderIfDirty().canvas,
this.zoomMarker.position, 0);
ctx.drawImage(this.ruler.renderIfDirty().canvas, 0, 0);
ctx.drawImage(this.startMarker.renderIfDirty().canvas,
this._timeToPixel(this.props.startPosition), 10);
this.loopLengthMarker.props.width = this._timeToPixel(this.props.loopEnd) -
this._timeToPixel(this.props.loopStart);
if (this.loopLengthMarker.props.width > 0) {
ctx.drawImage(this.loopLengthMarker.renderIfDirty().canvas,
this._timeToPixel(this.props.loopStart), 20);
}
ctx.drawImage(this.loopStartMarker.renderIfDirty().canvas,
this._timeToPixel(this.props.loopStart), 20);
ctx.drawImage(this.loopEndMarker.renderIfDirty().canvas,
this._timeToPixel(this.props.loopEnd) - this.loopEndMarker.props.width, 20);
}
_timeToPixel(time) {
time -= this.props.offset;
if (time === 0 || this.displayDuration === 0) {
return 1;
}
let px = (time / this.displayDuration) * this.props.width;
return Math.max(1, Math.round(px));
}
_pixelToTime(pixel) {
return (pixel / this.props.width) * this.displayDuration;
}
_getLoopRect() {
return {
x1: this._timeToPixel(this.props.loopStart),
y1: 20,
x2: this._timeToPixel(this.props.loopEnd),
y2: this.props.height - 20
};
}
// a pretty crude hittest to find target from a relative mouse position
_hitTest(point) {
if (point.y < this.ruler.props.height) {
if (this.props.loop) {
let loopRect = this._getLoopRect();
if (point.y >= loopRect.y1 && point.y < loopRect.y2) {
if (Math.abs(point.x - loopRect.x1) < 5) {
return this.loopStartMarker;
} else if (Math.abs(point.x - loopRect.x2) < 5) {
return this.loopEndMarker;
} else if (point.x >= loopRect.x1 && point.x < loopRect.x2) {
return this.loopLengthMarker;
}
}
}
if (point.x) {return this.ruler;}
}
return this.waveForm;
}
// update mouse cursor to reflect active target
_updateCursor(hitTarget, e) {
if (hitTarget === this.ruler) {
this.canvas.style.cursor = 'pointer';
} else if (hitTarget === this.loopStartMarker) {
this.canvas.style.cursor = 'e-resize';
} else if (hitTarget === this.loopEndMarker) {
this.canvas.style.cursor = 'w-resize';
} else if (hitTarget === this.loopLengthMarker) {
this.canvas.style.cursor = 'move';
} else if (hitTarget === this.waveForm && e.altKey) {
this.canvas.style.cursor = 'zoom-in';
} else {
this.canvas.style.cursor = 'auto';
}
}
// almost fully self contained ui interaction
_setupUI() {
let mouseDown = false;
let lastY = 0;
let zoomThresh = 0;
let canvasTarget = null;
let doZoom = false;
let lastMousePos = { x: 0, y: 0 };
let startXTime = 0;
const toRelativeMovement = (e) => {
let rect = this.canvas.getBoundingClientRect();
let pixelRatio = this.pixelRatio;
let x = (e.pageX - rect.left) * pixelRatio.x;
let y = (e.pageY - rect.top) * pixelRatio.y;
let movementX = e.movementX ? e.movementX * pixelRatio.x : 0;
let movementY = e.movementY ? e.movementY * pixelRatio.y : 0;
return {
rect,
x,
y,
movementX,
movementY
};
};
const quantizePosition = ({ x, y }) => {
if (!this.props.quantize) return { x, y };
let offsetPx = (this.props.offset / this.displayDuration) * this.props.width;
let pxQuant = (this.props.quantize / this.displayDuration) * this.props.width;
x += offsetPx;
x = Math.round(x / pxQuant) * pxQuant;
x -= offsetPx;
return { x, y };
};
this.canvas.addEventListener('mousedown', (e)=>{
mouseDown = true;
lastY = null;
zoomThresh = 0;
doZoom = false;
let pos = toRelativeMovement(e);
canvasTarget = this._hitTest(pos);
this._updateCursor(canvasTarget, e);
if (canvasTarget === this.waveForm) {
if (e.altKey) {
doZoom = true;
this.zoomMarker.position = pos.x;
this.zoomMarker.props.visible = true;
this.canvas.requestPointerLock();
}
}
if (!doZoom) {
pos = quantizePosition(pos);
}
startXTime = this._pixelToTime(pos.x) + this.props.offset;
!doZoom && (this.props.selectEnd = this.props.selectStart = startXTime);
lastMousePos = pos;
});
document.addEventListener('mouseup', ()=>{
mouseDown = false;
if (doZoom) {
this.zoomMarker.props.visible = false;
document.exitPointerLock();
doZoom = false;
}
});
document.addEventListener('mousemove', (e)=>{
if (mouseDown) {
let { x, y, movementX, movementY, rect } = toRelativeMovement(e);
let p = { x, y };
if (!doZoom) {
// keep p within boarders of canvas
p.x = Math.max(0, Math.min(this.props.width, p.x));
p = quantizePosition(p);
if (canvasTarget === this.ruler) {
this.updateStartPos(p.x);
}
}
let deltaX = (doZoom && movementX !== undefined ? movementX : (p.x - lastMousePos.x)) / rect.width;
let deltaY = (doZoom && movementY !== undefined ? movementY : (p.y - lastMousePos.y)) / rect.height;
let xTime = this._pixelToTime(p.x) + this.props.offset;
let deltaTime = this._pixelToTime(deltaX) * this.props.width / this.pixelRatio.x;
Object.assign(lastMousePos, p);
if (doZoom) {
if (lastY === null) lastY = p.y;
lastY = p.y;
zoomThresh += Math.abs(deltaY);
let hZoom = Math.max(1, this.props.hZoom + deltaY * this.props.hZoom);
if (zoomThresh > this.props.uiZoomStickiness) {
let zoomDelta = hZoom - this.props.hZoom;
if (zoomDelta !== 0 && hZoom >= 0.5) {
let zoomPerc = zoomDelta / this.props.hZoom;
let posRatio = p.x / (rect.width * this.pixelRatio.x);
this.props.hZoom = hZoom;
this.offset += zoomPerc * posRatio * this.displayDuration;
}
}
this.offset -= (deltaX * 10) / hZoom;
} else if (canvasTarget === this.waveForm) {
if (xTime < startXTime) {
this.updateSelection(this.props.selectStart + deltaTime, startXTime);
} else {
this.updateSelection(startXTime, this.props.selectEnd + deltaTime);
}
} else if (canvasTarget === this.ruler) {
if (p.x >= 0 && p.x < rect.width) {
this.updateStartPos(p.x);
} else {
if (p.x < 0) {
this.offset = Math.max(0, this.props.offset - Math.abs(p.x * 0.1));
} else {
this.offset = Math.min(this.duration, this.props.offset + (p.x - rect.width) * 0.1);
}
}
} else if (canvasTarget === this.loopLengthMarker) {
this.updateLoopPos(this.props.loopStart + deltaTime, this.props.loopEnd + deltaTime);
} else if (canvasTarget === this.loopStartMarker) {
if (xTime <= this.props.loopEnd) {
this.updateLoopPos(this.props.loopStart + deltaTime, this.props.loopEnd);
} else {
this.updateLoopPos(this.props.loopEnd, this.props.loopEnd);
}
} else if (canvasTarget === this.loopEndMarker) {
if (xTime >= this.props.loopStart) {
this.updateLoopPos(this.props.loopStart, this.props.loopEnd + deltaTime);
} else {
this.updateLoopPos(this.props.loopStart, this.props.loopStart);
}
}
} else {
this._updateCursor(this._hitTest(toRelativeMovement(e)), e);
}
});
}
updateSelection(start, end) {
start = Math.max(0, start);
end = Math.min(this.duration, end);
this.props.selectStart = start;
this.props.selectEnd = end;
}
updateLoopPos(start, end) {
if (start < 0) {
let d = Math.abs(start);
end = Math.min(this.duration, end + d);
start += d;
}
if (end > this.duration) {
let d = this.duration - end;
start = Math.max(0, start + d);
end += d;
}
if (start > end) {
start = end;
}
if (start >= 0 && end <= this.duration) {
this.props.loopStart = start;
this.props.loopEnd = end;
}
}
updateStartPos(px) {
let startPos = ((px / this.canvas.width) * this.duration / this.props.hZoom) + this.props.offset;
this.props.startPosition = startPos;
}
get offset() {
return this.props.offset;
}
set offset(v) {
this.props.offset = Math.max(0, Math.min(this.duration - this.displayDuration, v));
}
get buffer() {
return this.props.buffer;
}
set buffer(buffer) {
this.props.buffer = buffer;
}
get pixelRatio() {
let rect = this.canvas.getBoundingClientRect();
let pixelRatio = { x: this.props.width / rect.width, y: this.props.height / rect.height };
return pixelRatio;
}
}
export default SampleEditorView;