bytebeat-composer/src/oscillioscope.ts

249 lines
8.2 KiB
TypeScript

export type DrawSample = { t: number; value: [number, number] };
function fmod(a: number, b: number) {
return ((a % b) + b) % b;
}
export class Oscillioscope {
private readonly ctx: CanvasRenderingContext2D;
private readonly timeCursor: HTMLElement;
settings = { scale: 5 };
private drawBuffer: DrawSample[] = [];
private drawImageData: ImageData | null = null;
constructor(canvas: HTMLCanvasElement, timeCursor: HTMLElement) {
this.ctx = canvas.getContext("2d", { alpha: false })!;
this.timeCursor = timeCursor;
}
private getXpos(t: number): number {
return t / (1 << this.settings.scale);
}
private getTimeFromXpos(x: number): number {
return x * (1 << this.settings.scale);
}
addToBuffer(data: DrawSample[], sample: number) {
this.drawBuffer = this.drawBuffer.concat(data);
// prevent buffer accumulation when tab inactive
const maxDrawBufferSize = this.getTimeFromXpos(this.ctx.canvas.width) - 1;
if (sample - this.drawBuffer[this.drawBuffer.length >> 1].t > maxDrawBufferSize) {
// reasonable lazy cap
this.drawBuffer = this.drawBuffer.slice(this.drawBuffer.length >> 1);
} else if (this.drawBuffer.length > maxDrawBufferSize) {
// emergency cap
this.drawBuffer = this.drawBuffer.slice(-maxDrawBufferSize);
}
}
clear(clear = true) {
this.ctx.fillRect(0, 0, this.ctx.canvas.width, this.ctx.canvas.height);
if (clear) {
this.clearBuffer();
}
}
private clearBuffer() {
this.drawBuffer = [];
this.drawImageData = null;
}
draw(endTime: number, playingForward: boolean) {
const { width, height } = this.ctx.canvas;
const bufferLen = this.drawBuffer.length;
if (!bufferLen) {
return;
}
let startTime = this.drawBuffer[0].t + (this.drawBuffer.carry ? 1 : 0);
let lenTime = endTime - startTime;
let startXPos = fmod(this.getXpos(startTime), width);
let endXPos = startXPos + this.getXpos(lenTime);
{
let drawStartX = Math.floor(startXPos);
let drawEndX = Math.floor(endXPos);
let drawLenX = Math.abs(drawEndX - drawStartX) + 1;
let drawOverflow = false;
// clip draw area if too large
if (drawLenX > width) {
// TODO: put this into a better section so the variables don't all have to be set again
startTime = this.getTimeFromXpos(this.getXpos(endTime) - width);
let sliceIndex = 0;
for (let i in this.drawBuffer) {
// TODO: replace this with binary search
if ((this.drawBuffer[i + 1]?.t ?? endTime) <= startTime - 1) {
sliceIndex += 1;
} else {
this.drawBuffer[i].t = startTime - 1;
this.drawBuffer[i].carry = true;
this.drawBuffer = this.drawBuffer.slice(sliceIndex);
break;
}
}
lenTime = endTime - startTime;
startXPos = fmod(this.getXpos(startTime), width);
endXPos = startXPos + this.getXpos(lenTime);
drawStartX = Math.ceil(startXPos); // this is a bit of a hack, since this doesn't relate to the other variables properly
// i can only get away with this because the other vars like startTime and such aren't used
// the proper solution would be to somehow round up startTime by a pixel
drawEndX = Math.floor(endXPos);
drawLenX = Math.abs(drawEndX - drawStartX) + 1;
drawOverflow = true;
}
const imagePos = Math.min(drawStartX, drawEndX);
// create imageData
let imageData = this.ctx.createImageData(drawLenX, height);
// create / add drawimageData
if (this.settings.scale) {
// full zoom can't have multiple samples on one pixel
if (this.drawImageData) {
if (!drawOverflow) {
// fill in starting area of image data with previously drawn samples
let x = playingForward ? 0 : drawLenX - 1;
for (let y = 0; y < height; y++) {
imageData.data[((drawLenX * y + x) << 2) + 0] =
this.drawImageData.data[(y << 2) + 0];
imageData.data[((drawLenX * y + x) << 2) + 1] =
this.drawImageData.data[(y << 2) + 1];
imageData.data[((drawLenX * y + x) << 2) + 2] =
this.drawImageData.data[(y << 2) + 2];
}
}
} else {
this.drawImageData = this.ctx.createImageData(1, height);
}
} else {
this.drawImageData = null;
}
// set alpha
// TODO flatten
for (let x = 0; x < drawLenX; x++) {
for (let y = 0; y < height; y++) {
imageData.data[((drawLenX * y + x) << 2) + 3] = 255;
}
}
// draw
const iterateOverHorizontalLine = (
bufferElem: DrawSample,
nextBufferElemTime: number,
callback: (xPos: number) => void,
initCallback?: (xPos: number) => void,
) => {
const startX = fmod(
Math.floor(this.getXpos(playingForward ? bufferElem.t : nextBufferElemTime + 1)) -
imagePos,
width,
);
const endX = fmod(
Math.ceil(this.getXpos(playingForward ? nextBufferElemTime : bufferElem.t + 1)) -
imagePos,
width,
);
if (initCallback) {
initCallback(startX);
}
for (let xPos = startX; xPos !== endX; xPos = fmod(xPos + 1, width)) {
callback(xPos);
}
};
for (let i = this.drawBuffer[0].t < startTime ? 1 : 0; i < bufferLen; i++) {
let lastBufferElem = this.drawBuffer[i - 1] ?? null;
let bufferElem = this.drawBuffer[i];
let nextBufferElemTime = this.drawBuffer[i + 1]?.t ?? endTime;
if (isNaN(bufferElem.value[0]) || isNaN(bufferElem.value[1])) {
iterateOverHorizontalLine(bufferElem, nextBufferElemTime, xPos => {
for (let h = 0; h < 256; h++) {
const pos = (drawLenX * h + xPos) << 2;
imageData.data[pos] = 96;
}
});
}
for (let c = 0; c < 2; c++) {
if (bufferElem.value[c] >= 0 && bufferElem.value[c] < 256) {
// NaN check is implicit here
iterateOverHorizontalLine(
bufferElem,
nextBufferElemTime,
xPos => {
const pos = (drawLenX * (255 - bufferElem.value[c]) + xPos) << 2;
if (c) {
imageData.data[pos] = imageData.data[pos + 2] = 255;
} else {
imageData.data[pos] = 0; // clear out NaN red
imageData.data[pos + 1] = 255;
}
},
// Waveform draw mode connectors
lastBufferElem && !isNaN(lastBufferElem.value[c])
? xPos => {
const dir = lastBufferElem.value[c] < bufferElem.value[c] ? -1 : 1;
for (
let h = 255 - lastBufferElem.value[c];
h !== 255 - bufferElem.value[c];
h += dir
) {
const pos = (drawLenX * h + xPos) << 2;
if (imageData.data[pos] === 0) {
// don't overwrite filled cells
if (c) {
imageData.data[pos] = imageData.data[pos + 2] = 140;
} else {
imageData.data[pos] = 0; // clear out NaN red
imageData.data[pos + 1] = 140;
}
}
}
}
: () => {},
);
}
}
}
// put imageData
this.ctx.putImageData(imageData, imagePos, 0);
if (endXPos >= width) {
this.ctx.putImageData(imageData, imagePos - width, 0);
} else if (endXPos < 0) {
this.ctx.putImageData(imageData, imagePos + width, 0);
}
// write to drawImageData
if (this.settings.scale) {
// full zoom can't have multiple samples on one pixel
const x = playingForward ? drawLenX - 1 : 0;
for (let y = 0; y < height; y++) {
this.drawImageData.data[y << 2] = imageData.data[(drawLenX * y + x) << 2];
this.drawImageData.data[(y << 2) + 1] =
imageData.data[((drawLenX * y + x) << 2) + 1];
this.drawImageData.data[(y << 2) + 2] =
imageData.data[((drawLenX * y + x) << 2) + 2];
}
}
}
// clear buffer except last sample
this.drawBuffer = [{ t: endTime, value: this.drawBuffer[bufferLen - 1].value, carry: true }];
}
// TODO don't make this depend on play direction
moveTimeCursor(time: number, playingForward: boolean) {
if (!this.timeCursor.classList.contains("disabled")) {
const width = this.ctx.canvas.width;
if (playingForward) {
this.timeCursor.style.removeProperty("right");
this.timeCursor.style.left = `${
(fmod(Math.ceil(this.getXpos(time)), width) / width) * 100
}%`;
} else {
this.timeCursor.style.removeProperty("left");
this.timeCursor.style.right = `${
(1 - (fmod(Math.ceil(this.getXpos(time)), width) + 1) / width) * 100
}%`;
}
}
}
showTimeCursor(show: boolean) {
this.timeCursor.classList.toggle("disabled", !show);
}
}