2023-04-01 02:45:26 +02:00
|
|
|
import { inflateRaw, deflateRaw } from "./pako.js";
|
|
|
|
import { domLoaded, isPlainObject } from "./common.js";
|
2021-11-18 14:36:00 +01:00
|
|
|
|
2022-04-29 00:23:07 +02:00
|
|
|
const searchParams = new URLSearchParams(location.search);
|
|
|
|
|
2022-01-10 14:06:18 +01:00
|
|
|
const timeUnits = [
|
|
|
|
"t",
|
|
|
|
"s", // sec
|
|
|
|
];
|
|
|
|
|
2022-05-11 18:04:06 +02:00
|
|
|
const bytebeat = Object.seal({
|
|
|
|
audioCtx: null,
|
|
|
|
audioWorklet: null,
|
|
|
|
audioGain: null,
|
|
|
|
|
|
|
|
nextErrType: null,
|
|
|
|
nextErr: null,
|
|
|
|
nextErrPriority: undefined,
|
|
|
|
errorPriority: -Infinity,
|
|
|
|
|
|
|
|
canvasCtx: null,
|
|
|
|
drawSettings: { mode: null, scale: null },
|
|
|
|
drawBuffer: [],
|
|
|
|
drawImageData: null,
|
|
|
|
byteSample: 0,
|
|
|
|
|
|
|
|
isPlaying: false,
|
|
|
|
|
|
|
|
songData: { sampleRate: null, mode: null },
|
|
|
|
playSpeed: 1,
|
|
|
|
volume: null,
|
|
|
|
|
|
|
|
timeUnit: null,
|
|
|
|
|
|
|
|
animationFrameId: null,
|
|
|
|
|
|
|
|
canvasElem: null,
|
|
|
|
codeEditor: null,
|
|
|
|
errorElem: null,
|
|
|
|
timeCursorElem: null,
|
|
|
|
|
|
|
|
contentElem: null,
|
|
|
|
|
|
|
|
controlTimeUnit: null,
|
|
|
|
controlTimeUnitLabel: null,
|
|
|
|
controlTimeValue: null,
|
|
|
|
controlScaleUp: null,
|
|
|
|
controlScaleDown: null,
|
|
|
|
controlDrawMode: null,
|
|
|
|
controlPlaybackMode: null,
|
|
|
|
controlSampleRate: null,
|
|
|
|
controlVolume: null,
|
|
|
|
canvasTogglePlay: null,
|
|
|
|
|
|
|
|
async init() {
|
|
|
|
await this.initAudioContext();
|
|
|
|
|
|
|
|
await domLoaded;
|
|
|
|
|
|
|
|
this.contentElem = document.getElementById("content");
|
|
|
|
this.initControls();
|
|
|
|
await this.initTextarea(document.getElementById("code-editor"));
|
|
|
|
|
2023-12-23 05:31:49 +01:00
|
|
|
import("./codemirror.js").then(o => this.initCodemirror(o.default));
|
2022-05-11 18:04:06 +02:00
|
|
|
if (globalThis.loadLibrary !== false)
|
2023-04-01 04:23:27 +02:00
|
|
|
import("./load-library.js");
|
2022-05-11 18:04:06 +02:00
|
|
|
|
|
|
|
this.handleWindowResize(true);
|
|
|
|
document.defaultView.addEventListener("resize", () => this.handleWindowResize(false));
|
|
|
|
|
|
|
|
this.loadSettings();
|
|
|
|
const songData = this.getUrlData();
|
|
|
|
this.setSong(songData, false);
|
|
|
|
this.updateCounterValue();
|
|
|
|
},
|
|
|
|
|
|
|
|
async initAudioContext() {
|
|
|
|
let audioContextSampleRate = Number(searchParams.get("audioContextSampleRate"));
|
|
|
|
if (!(audioContextSampleRate > 0)) // also grabs NaN
|
|
|
|
audioContextSampleRate = 48000; // forced samplerate is a hack for 48000 bytebeats since supersampling won't be ready for a while
|
|
|
|
// this also makes audio quality consistant on different soundsystems, but not always the best it could be
|
|
|
|
|
|
|
|
this.audioCtx = new AudioContext({
|
|
|
|
latencyHint: searchParams.get("audioContextLatencyHint") ?? "balanced",
|
|
|
|
sampleRate: audioContextSampleRate,
|
|
|
|
});
|
|
|
|
|
|
|
|
this.audioGain = new GainNode(this.audioCtx);
|
|
|
|
this.audioGain.connect(this.audioCtx.destination);
|
|
|
|
|
2023-12-25 18:51:35 +01:00
|
|
|
await this.audioCtx.audioWorklet.addModule("/audio-worklet.js");
|
2022-05-11 18:04:06 +02:00
|
|
|
this.audioWorklet = new AudioWorkletNode(this.audioCtx, "bytebeatProcessor", { outputChannelCount: [2] });
|
2022-05-13 19:17:17 +02:00
|
|
|
this.audioWorklet.port.addEventListener("message", e => this.handleMessage(e));
|
2022-05-11 18:04:06 +02:00
|
|
|
this.audioWorklet.port.start();
|
|
|
|
this.audioWorklet.connect(this.audioGain);
|
|
|
|
|
|
|
|
console.info(`started audio with latency ${this.audioCtx.baseLatency * this.audioCtx.sampleRate} at ${this.audioCtx.sampleRate}Hz`);
|
|
|
|
},
|
|
|
|
handleMessage(e) {
|
|
|
|
if (isPlainObject(e.data)) {
|
|
|
|
const data = e.data;
|
|
|
|
if (data.clearCanvas)
|
|
|
|
this.clearCanvas();
|
|
|
|
else if (data.clearDrawBuffer)
|
|
|
|
this.clearDrawBuffer();
|
|
|
|
|
|
|
|
if (typeof data.byteSample === "number")
|
|
|
|
this.setByteSample(data.byteSample, false);
|
|
|
|
if (Array.isArray(data.drawBuffer)) {
|
|
|
|
this.drawBuffer = this.drawBuffer.concat(data.drawBuffer);
|
|
|
|
// prevent buffer accumulation when tab inactive
|
|
|
|
const maxDrawBufferSize = this.getTimeFromXpos(this.canvasElem.width) - 1;
|
|
|
|
if (this.byteSample - 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);
|
|
|
|
}
|
2022-04-29 00:23:07 +02:00
|
|
|
|
2022-05-11 18:04:06 +02:00
|
|
|
if (data.updateUrl)
|
|
|
|
this.setUrlData();
|
2021-11-18 14:36:00 +01:00
|
|
|
|
2022-05-11 18:04:06 +02:00
|
|
|
if (data.errorMessage !== undefined) {
|
|
|
|
if (isPlainObject(data.errorMessage)) {
|
|
|
|
if (
|
|
|
|
typeof data.errorMessage.type === "string" &&
|
|
|
|
typeof data.errorMessage.err === "string" &&
|
|
|
|
typeof (data.errorMessage.priority ?? 0) === "number"
|
|
|
|
) {
|
|
|
|
if (this.isPlaying) {
|
|
|
|
this.nextErrType = data.errorMessage.type;
|
|
|
|
this.nextErr = data.errorMessage.err;
|
|
|
|
this.nextErrPriority = data.errorMessage.priority;
|
|
|
|
} else
|
|
|
|
this.showErrorMessage(data.errorMessage.type, data.errorMessage.err, data.errorMessage.priority);
|
2021-11-18 14:36:00 +01:00
|
|
|
}
|
2022-05-11 18:04:06 +02:00
|
|
|
} else
|
|
|
|
this.hideErrorMessage();
|
2021-11-18 14:36:00 +01:00
|
|
|
}
|
2022-05-11 18:04:06 +02:00
|
|
|
}
|
|
|
|
},
|
|
|
|
saveData: null,
|
|
|
|
initTextarea(textarea) {
|
|
|
|
textarea.addEventListener("input", () => this.refreshCode());
|
|
|
|
{
|
|
|
|
let keyTrap = true;
|
|
|
|
textarea.addEventListener("keydown", e => {
|
|
|
|
if (!e.altKey && !e.ctrlKey) {
|
|
|
|
if (e.key === "Escape") {
|
|
|
|
if (keyTrap) {
|
2022-05-10 20:51:09 +02:00
|
|
|
e.preventDefault();
|
2022-05-11 18:04:06 +02:00
|
|
|
keyTrap = false;
|
|
|
|
}
|
|
|
|
} else if (e.key === "Tab" && keyTrap) {
|
|
|
|
e.preventDefault();
|
|
|
|
const el = e.target;
|
|
|
|
const { selectionStart, selectionEnd } = el;
|
|
|
|
if (e.shiftKey) {
|
|
|
|
// remove indentation on all selected lines
|
|
|
|
let lines = el.value.split("\n");
|
|
|
|
|
|
|
|
let getLine = char => {
|
|
|
|
let line = 0;
|
|
|
|
for (let c = 0; ; line++) {
|
|
|
|
c += lines[line].length;
|
|
|
|
if (c > char) 1;
|
|
|
|
break;
|
|
|
|
}
|
|
|
|
return line;
|
|
|
|
};
|
|
|
|
let
|
|
|
|
startLine = getLine(selectionStart),
|
|
|
|
endLine = getLine(selectionEnd),
|
|
|
|
newSelectionStart = selectionStart,
|
|
|
|
newSelectionEnd = selectionEnd;
|
|
|
|
for (let i = startLine; i <= endLine; i++) {
|
|
|
|
if (lines[i][0] === "\t") {
|
|
|
|
lines[i] = lines[i].slice(1);
|
|
|
|
if (i === startLine)
|
|
|
|
newSelectionStart--;
|
|
|
|
newSelectionEnd--;
|
2022-05-10 20:51:09 +02:00
|
|
|
}
|
2022-03-17 23:46:54 +01:00
|
|
|
}
|
2022-01-14 17:57:49 +01:00
|
|
|
|
2022-05-11 18:04:06 +02:00
|
|
|
el.value = lines.join("\n");
|
|
|
|
el.setSelectionRange(newSelectionStart, newSelectionEnd);
|
|
|
|
} else {
|
|
|
|
// add tab character
|
|
|
|
el.value = `${el.value.slice(0, selectionStart)}\t${el.value.slice(selectionEnd)}`;
|
|
|
|
el.setSelectionRange(selectionStart + 1, selectionStart + 1);
|
|
|
|
}
|
|
|
|
this.refreshCode();
|
|
|
|
} else
|
|
|
|
keyTrap = false;
|
|
|
|
}
|
|
|
|
});
|
|
|
|
}
|
|
|
|
this.codeEditor = textarea;
|
|
|
|
},
|
|
|
|
initCodemirror(createCodemirrorEditor) {
|
|
|
|
const codemirror = createCodemirrorEditor(() => this.refreshCode());
|
|
|
|
let selection = null;
|
|
|
|
if (this.codeEditor) {
|
|
|
|
codemirror.dispatch({ changes: { from: 0, insert: this.codeEditor.value } });
|
|
|
|
if (document.activeElement === this.codeEditor)
|
|
|
|
selection = { anchor: this.codeEditor.selectionStart, head: this.codeEditor.selectionEnd };
|
|
|
|
}
|
|
|
|
(this.codeEditor ?? document.getElementById("code-editor")).replaceWith(codemirror.dom);
|
|
|
|
if (selection) {
|
|
|
|
codemirror.focus();
|
|
|
|
codemirror.dispatch({ selection });
|
|
|
|
}
|
|
|
|
this.codeEditor = codemirror;
|
|
|
|
},
|
|
|
|
get codeEditorText() {
|
|
|
|
if (this.codeEditor instanceof Element)
|
|
|
|
return this.codeEditor.value;
|
|
|
|
else
|
|
|
|
return this.codeEditor.state.doc.toString();
|
|
|
|
},
|
|
|
|
set codeEditorText(value) {
|
|
|
|
if (this.codeEditor instanceof Element)
|
|
|
|
this.codeEditor.value = value;
|
|
|
|
else
|
|
|
|
this.codeEditor.dispatch({ changes: { from: 0, to: this.codeEditor.state.doc.length, insert: value } });
|
|
|
|
},
|
|
|
|
|
|
|
|
getUrlData() {
|
|
|
|
if (window.location.hash && globalThis.useUrlData !== false) {
|
|
|
|
const hash = window.location.hash;
|
|
|
|
const version =
|
|
|
|
hash.startsWith("#v4") ? 4 :
|
|
|
|
hash.startsWith("#v3b64") ? 3 :
|
|
|
|
null;
|
|
|
|
if (version === 4 || version === 3) {
|
|
|
|
let dataString;
|
|
|
|
try {
|
|
|
|
dataString = atob(hash.substring(version === 4 ? 3 : 6));
|
|
|
|
} catch (err) {
|
|
|
|
console.error("Couldn't load data from url:", err);
|
|
|
|
return null;
|
2022-01-09 15:18:05 +01:00
|
|
|
}
|
2022-01-08 19:25:01 +01:00
|
|
|
|
2022-05-11 18:04:06 +02:00
|
|
|
const dataBuffer = new Uint8Array(dataString.length);
|
|
|
|
for (const i in dataString)
|
|
|
|
dataBuffer[i] = dataString.charCodeAt(i);
|
2022-01-08 19:25:01 +01:00
|
|
|
|
2022-05-11 18:04:06 +02:00
|
|
|
let songData;
|
|
|
|
try {
|
|
|
|
const songDataString = inflateRaw(dataBuffer, { to: "string" });
|
|
|
|
songData = JSON.parse(songDataString);
|
|
|
|
} catch (err) {
|
|
|
|
console.error("Couldn't load data from url:", err);
|
|
|
|
return null;
|
|
|
|
}
|
2021-11-18 14:36:00 +01:00
|
|
|
|
2022-05-11 18:04:06 +02:00
|
|
|
return songData;
|
|
|
|
} else
|
|
|
|
console.error("Unrecognized url data");
|
|
|
|
}
|
|
|
|
return null;
|
|
|
|
},
|
|
|
|
setUrlData() {
|
|
|
|
if (globalThis.useUrlData !== false) {
|
|
|
|
const dataBuffer = deflateRaw(JSON.stringify(this.getSong()));
|
|
|
|
const dataString = String.fromCharCode.apply(undefined, dataBuffer);
|
|
|
|
window.location.hash = `#v4${btoa(dataString).replaceAll("=", "")}`;
|
|
|
|
}
|
|
|
|
},
|
|
|
|
initControls() {
|
|
|
|
this.controlTimeUnit = document.getElementById("control-time-unit");
|
|
|
|
this.controlTimeUnitLabel = document.getElementById("control-time-unit-label");
|
|
|
|
this.controlTimeValue = document.getElementById("control-time-value");
|
|
|
|
|
|
|
|
this.controlScaleDown = document.getElementById("control-scaledown");
|
|
|
|
this.controlScaleUp = document.getElementById("control-scaleup");
|
|
|
|
|
|
|
|
this.controlDrawMode = document.getElementById("control-draw-mode");
|
|
|
|
this.controlPlaybackMode = document.getElementById("control-song-mode");
|
|
|
|
this.controlSampleRate = document.getElementById("control-samplerate");
|
|
|
|
this.controlVolume = document.getElementById("control-volume");
|
|
|
|
|
|
|
|
this.errorElem = document.getElementById("error");
|
|
|
|
this.canvasTogglePlay = document.getElementById("canvas-toggleplay");
|
|
|
|
this.timeCursorElem = document.getElementById("canvas-timecursor");
|
|
|
|
this.canvasElem = document.getElementById("canvas-main");
|
|
|
|
this.canvasCtx = this.canvasElem.getContext("2d", { alpha: false });
|
|
|
|
},
|
|
|
|
refreshCode() {
|
|
|
|
if (this.audioWorklet)
|
|
|
|
this.audioWorklet.port.postMessage({ code: this.codeEditorText.trim() });
|
|
|
|
},
|
|
|
|
handleWindowResize(force) {
|
|
|
|
this.autoSizeCanvas(force);
|
|
|
|
},
|
|
|
|
autoSizeCanvas(force) {
|
|
|
|
if (!this.canvasElem.dataset.forcedWidth) {
|
|
|
|
const innerWidth = window.innerWidth;
|
|
|
|
if (innerWidth >= 772) { // 768 is halfway between 512 and 1024, 3 added for outline
|
|
|
|
let width = 1024;
|
|
|
|
while (innerWidth - 516 >= width * 2) // 516px = 4px (outline) + 512px (library)
|
|
|
|
width *= 2;
|
|
|
|
this.setCanvasWidth(width, innerWidth >= 1540, force); // see media queries in css
|
|
|
|
} else
|
|
|
|
this.setCanvasWidth(512, false, force);
|
|
|
|
}
|
|
|
|
},
|
|
|
|
setCanvasWidth(width, horiz, force = false) {
|
|
|
|
if (this.canvasElem) {
|
|
|
|
if (width !== this.canvasElem.width || force) {
|
|
|
|
this.canvasElem.width = width;
|
|
|
|
// TODO: see if it's possible to get rid of this
|
|
|
|
this.contentElem.style.maxWidth = `${width + 4}px`;
|
2022-01-09 03:25:45 +01:00
|
|
|
}
|
2022-05-11 18:04:06 +02:00
|
|
|
}
|
|
|
|
},
|
|
|
|
animationFrame() {
|
|
|
|
this.drawGraphics();
|
|
|
|
if (this.nextErr)
|
|
|
|
this.showErrorMessage(this.nextErrType, this.nextErr, this.nextErrPriority);
|
|
|
|
|
|
|
|
if (this.isPlaying)
|
|
|
|
this.animationFrameId = window.requestAnimationFrame(() => this.animationFrame());
|
|
|
|
else
|
|
|
|
this.animationFrameId = null;
|
|
|
|
},
|
|
|
|
|
|
|
|
getSong(includeDefault = false) {
|
|
|
|
let songData = { code: this.codeEditorText };
|
|
|
|
if (includeDefault || this.songData.sampleRate !== 8000)
|
|
|
|
songData.sampleRate = this.songData.sampleRate;
|
|
|
|
if (includeDefault || this.songData.mode !== "Bytebeat")
|
|
|
|
songData.mode = this.songData.mode;
|
|
|
|
|
|
|
|
return songData;
|
|
|
|
},
|
|
|
|
setSong(songData, play = true) {
|
|
|
|
let code, sampleRate, mode;
|
|
|
|
if (songData !== null) {
|
|
|
|
({ code, sampleRate, mode } = songData);
|
|
|
|
this.codeEditorText = code;
|
|
|
|
}
|
|
|
|
this.applySampleRate(sampleRate ?? 8000);
|
|
|
|
this.applyPlaybackMode(mode ?? "Bytebeat");
|
|
|
|
this.refreshCode();
|
|
|
|
if (play) {
|
|
|
|
this.resetTime();
|
|
|
|
this.togglePlay(true);
|
|
|
|
}
|
|
|
|
},
|
|
|
|
applySampleRate(rate) {
|
|
|
|
this.setSampleRate(rate);
|
|
|
|
this.controlSampleRate.value = rate;
|
|
|
|
},
|
|
|
|
applyPlaybackMode(playbackMode) {
|
|
|
|
this.setPlaybackMode(playbackMode);
|
|
|
|
this.controlPlaybackMode.value = playbackMode;
|
|
|
|
},
|
|
|
|
|
|
|
|
changeScale(amount) {
|
|
|
|
if (amount) {
|
|
|
|
this.drawSettings.scale = Math.max(this.drawSettings.scale + amount, 0);
|
|
|
|
this.clearCanvas(false);
|
|
|
|
if (this.drawSettings.scale <= 0)
|
|
|
|
this.controlScaleDown.setAttribute("disabled", true);
|
|
|
|
else
|
|
|
|
this.controlScaleDown.removeAttribute("disabled");
|
2021-11-18 14:36:00 +01:00
|
|
|
|
2022-05-11 18:04:06 +02:00
|
|
|
this.toggleTimeCursor();
|
|
|
|
this.moveTimeCursor();
|
|
|
|
this.saveSettings();
|
|
|
|
}
|
|
|
|
},
|
|
|
|
updateDrawMode() {
|
|
|
|
this.controlDrawMode.value = this.drawSettings.mode;
|
|
|
|
},
|
|
|
|
setDrawMode(drawMode = this.controlDrawMode.value, save = true) {
|
|
|
|
this.drawSettings.mode = drawMode;
|
|
|
|
if (save)
|
|
|
|
this.saveSettings();
|
|
|
|
},
|
|
|
|
setVolume(save = true, volume) {
|
|
|
|
if (volume !== undefined) {
|
|
|
|
this.volume = volume;
|
|
|
|
this.controlVolume.value = volume;
|
|
|
|
} else
|
|
|
|
this.volume = this.controlVolume.valueAsNumber;
|
|
|
|
|
|
|
|
if (this.audioGain !== null)
|
|
|
|
this.audioGain.gain.value = this.volume * this.volume;
|
|
|
|
|
|
|
|
if (save)
|
|
|
|
this.saveSettings();
|
|
|
|
},
|
2021-11-18 14:36:00 +01:00
|
|
|
|
2022-05-11 18:04:06 +02:00
|
|
|
clearCanvas(clearDrawBuffer = true) {
|
|
|
|
if (this.canvasCtx) {
|
|
|
|
this.canvasCtx.fillRect(0, 0, this.canvasElem.width, this.canvasElem.height);
|
|
|
|
if (clearDrawBuffer)
|
|
|
|
this.clearDrawBuffer();
|
|
|
|
}
|
|
|
|
},
|
|
|
|
clearDrawBuffer() {
|
|
|
|
this.drawBuffer = [];
|
|
|
|
this.drawImageData = null;
|
|
|
|
},
|
|
|
|
fmod(a, b) { return ((a % b) + b) % b; },
|
|
|
|
getXpos(t) { return t / (1 << this.drawSettings.scale); },
|
|
|
|
getTimeFromXpos(x) { return x * (1 << this.drawSettings.scale); },
|
|
|
|
drawGraphics() {
|
|
|
|
const { width, height } = this.canvasElem;
|
|
|
|
|
|
|
|
const bufferLen = this.drawBuffer.length;
|
|
|
|
if (!bufferLen)
|
|
|
|
return;
|
|
|
|
|
|
|
|
const isWaveform = this.drawSettings.mode === "Waveform";
|
|
|
|
const playingForward = this.playSpeed > 0;
|
|
|
|
|
|
|
|
let
|
|
|
|
startTime = this.drawBuffer[0].t + (this.drawBuffer.carry ? 1 : 0),
|
|
|
|
endTime = this.byteSample,
|
|
|
|
lenTime = endTime - startTime,
|
|
|
|
startXPos = this.fmod(this.getXpos(startTime), width),
|
|
|
|
endXPos = startXPos + this.getXpos(lenTime);
|
|
|
|
|
|
|
|
{
|
2021-11-18 14:36:00 +01:00
|
|
|
let
|
2022-05-11 18:04:06 +02:00
|
|
|
drawStartX = Math.floor(startXPos),
|
|
|
|
drawEndX = Math.floor(endXPos),
|
|
|
|
drawLenX = Math.abs(drawEndX - drawStartX) + 1,
|
|
|
|
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;
|
2021-11-18 14:36:00 +01:00
|
|
|
}
|
|
|
|
}
|
2022-05-11 18:04:06 +02:00
|
|
|
lenTime = endTime - startTime;
|
|
|
|
startXPos = this.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;
|
|
|
|
}
|
2021-11-18 14:36:00 +01:00
|
|
|
|
2022-05-11 18:04:06 +02:00
|
|
|
const imagePos = Math.min(drawStartX, drawEndX);
|
|
|
|
// create imageData
|
|
|
|
let imageData = this.canvasCtx.createImageData(drawLenX, height);
|
|
|
|
// create / add drawimageData
|
|
|
|
if (this.drawSettings.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] = this.drawImageData.data[y << 2];
|
|
|
|
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];
|
2021-11-18 14:36:00 +01:00
|
|
|
}
|
2022-05-11 18:04:06 +02:00
|
|
|
}
|
2021-11-18 14:36:00 +01:00
|
|
|
} else
|
2022-05-11 18:04:06 +02:00
|
|
|
this.drawImageData = this.canvasCtx.createImageData(1, height);
|
|
|
|
} else
|
|
|
|
this.drawImageData = null;
|
|
|
|
// set alpha
|
|
|
|
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, nextBufferElemTime, callback, initCallback) => {
|
|
|
|
const startX = this.fmod(Math.floor(this.getXpos(playingForward ? bufferElem.t : nextBufferElemTime + 1)) - imagePos, width);
|
|
|
|
const endX = this.fmod(Math.ceil(this.getXpos(playingForward ? nextBufferElemTime : bufferElem.t + 1)) - imagePos, width);
|
|
|
|
if (initCallback)
|
|
|
|
initCallback(startX);
|
|
|
|
for (let xPos = startX; xPos !== endX; xPos = this.fmod(xPos + 1, width))
|
|
|
|
callback(xPos, false);
|
|
|
|
};
|
|
|
|
|
|
|
|
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
|
|
|
|
isWaveform && 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] = 150;
|
|
|
|
else {
|
|
|
|
imageData.data[pos] = 0; // clear out NaN red
|
|
|
|
imageData.data[pos + 1] = 150;
|
2022-04-29 20:23:12 +02:00
|
|
|
}
|
|
|
|
}
|
2022-05-11 18:04:06 +02:00
|
|
|
}
|
|
|
|
})
|
|
|
|
);
|
2021-11-18 14:36:00 +01:00
|
|
|
}
|
|
|
|
}
|
2022-05-11 18:04:06 +02:00
|
|
|
// put imageData
|
|
|
|
this.canvasCtx.putImageData(imageData, imagePos, 0);
|
|
|
|
if (endXPos >= width)
|
|
|
|
this.canvasCtx.putImageData(imageData, imagePos - width, 0);
|
|
|
|
else if (endXPos < 0)
|
|
|
|
this.canvasCtx.putImageData(imageData, imagePos + width, 0);
|
|
|
|
// write to drawImageData
|
|
|
|
if (this.drawSettings.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];
|
2021-11-18 14:36:00 +01:00
|
|
|
}
|
|
|
|
}
|
2022-05-11 18:04:06 +02:00
|
|
|
}
|
2021-11-18 14:36:00 +01:00
|
|
|
|
2022-05-11 18:04:06 +02:00
|
|
|
// cursor
|
|
|
|
this.moveTimeCursor(endTime);
|
|
|
|
|
|
|
|
// clear buffer except last sample
|
|
|
|
this.drawBuffer = [{ t: endTime, value: this.drawBuffer[bufferLen - 1].value, carry: true }];
|
|
|
|
},
|
|
|
|
moveTimeCursor(time = this.byteSample) {
|
|
|
|
if (this.timeCursorElem && this.timeCursorVisible()) {
|
|
|
|
const width = this.canvasElem.width;
|
|
|
|
if (this.playSpeed > 0) {
|
|
|
|
this.timeCursorElem.style.removeProperty("right");
|
|
|
|
this.timeCursorElem.style.left = `${this.fmod(Math.ceil(this.getXpos(time)), width) / width * 100}%`;
|
|
|
|
} else {
|
|
|
|
this.timeCursorElem.style.removeProperty("left");
|
|
|
|
this.timeCursorElem.style.right = `${(1 - (this.fmod(Math.ceil(this.getXpos(time)), width) + 1) / width) * 100}%`;
|
2022-01-09 03:25:45 +01:00
|
|
|
}
|
2022-05-11 18:04:06 +02:00
|
|
|
}
|
|
|
|
},
|
|
|
|
hideErrorMessage() {
|
|
|
|
if (this.errorElem) {
|
|
|
|
this.errorElem.innerText = "";
|
|
|
|
|
|
|
|
this.nextErr = null;
|
|
|
|
this.nextErrType = null;
|
|
|
|
this.nextErrPriority = undefined;
|
|
|
|
this.errorPriority = -Infinity;
|
|
|
|
}
|
|
|
|
},
|
|
|
|
showErrorMessage(errType, err, priority = 0) {
|
|
|
|
if (this.errorElem && (this.errorPriority < 2 || priority > 0)) {
|
|
|
|
this.errorElem.dataset.errType = errType;
|
|
|
|
this.errorElem.innerText = err.toString();
|
|
|
|
|
|
|
|
this.nextErr = null;
|
|
|
|
this.nextErrType = null;
|
|
|
|
this.nextErrPriority = undefined;
|
|
|
|
this.errorPriority = priority;
|
|
|
|
|
2022-01-09 03:25:45 +01:00
|
|
|
if (this.audioWorklet)
|
2022-05-11 18:04:06 +02:00
|
|
|
this.audioWorklet.port.postMessage({ displayedError: true });
|
|
|
|
}
|
|
|
|
},
|
|
|
|
|
|
|
|
resetTime() {
|
|
|
|
this.setByteSample(0, true, true);
|
|
|
|
if (!this.isPlaying)
|
|
|
|
this.canvasTogglePlay.classList.add("canvas-toggleplay-show");
|
|
|
|
},
|
|
|
|
setByteSample(value, send = true, clear = false) {
|
|
|
|
if (this.audioWorklet && isFinite(value)) {
|
|
|
|
this.byteSample = value;
|
|
|
|
this.updateCounterValue();
|
|
|
|
if (send)
|
|
|
|
this.audioWorklet.port.postMessage({ setByteSample: [value, clear] });
|
|
|
|
this.moveTimeCursor();
|
|
|
|
}
|
|
|
|
},
|
|
|
|
setPlaybackMode(playbackMode) {
|
|
|
|
if (this.audioWorklet) {
|
|
|
|
this.songData.mode = playbackMode;
|
|
|
|
this.setUrlData();
|
|
|
|
this.audioWorklet.port.postMessage({ songData: this.songData });
|
|
|
|
}
|
|
|
|
},
|
|
|
|
setSampleRate(sampleRate) {
|
|
|
|
if (this.audioWorklet) {
|
|
|
|
this.songData.sampleRate = sampleRate;
|
|
|
|
this.audioWorklet.port.postMessage({ songData: this.songData, updateSampleRatio: true });
|
|
|
|
this.toggleTimeCursor();
|
|
|
|
}
|
|
|
|
},
|
|
|
|
setSampleRateDivisor(sampleRateDivisor) {
|
|
|
|
if (this.audioWorklet)
|
|
|
|
this.audioWorklet.port.postMessage({ sampleRateDivisor, updateSampleRatio: true });
|
|
|
|
},
|
|
|
|
setPlaySpeed(playSpeed) {
|
|
|
|
if (this.audioWorklet && this.playSpeed !== playSpeed) {
|
|
|
|
this.playSpeed = playSpeed;
|
|
|
|
this.audioWorklet.port.postMessage({ playSpeed, updateSampleRatio: true });
|
|
|
|
}
|
|
|
|
},
|
|
|
|
toggleTimeCursor() {
|
|
|
|
if (this.timeCursorElem)
|
|
|
|
this.timeCursorElem.classList.toggle("disabled", !this.timeCursorVisible());
|
|
|
|
},
|
|
|
|
timeCursorVisible() {
|
|
|
|
return this.songData.sampleRate >> this.drawSettings.scale < 3950;
|
|
|
|
},
|
|
|
|
togglePlay(isPlaying) {
|
|
|
|
if (this.audioWorklet && isPlaying !== this.isPlaying) {
|
|
|
|
this.canvasTogglePlay.classList.toggle("canvas-toggleplay-pause", isPlaying);
|
|
|
|
if (isPlaying) {
|
|
|
|
// Play
|
|
|
|
this.canvasTogglePlay.classList.remove("canvas-toggleplay-show");
|
|
|
|
if (this.audioCtx?.resume)
|
|
|
|
this.audioCtx.resume();
|
2022-05-13 19:17:17 +02:00
|
|
|
this.animationFrameId = window.requestAnimationFrame(() => this.animationFrame());
|
2022-05-11 18:04:06 +02:00
|
|
|
} else {
|
|
|
|
if (this.isRecording) {
|
|
|
|
this.audioRecorder.stop();
|
|
|
|
this.isRecording = false;
|
2021-11-18 14:36:00 +01:00
|
|
|
}
|
2022-05-11 18:04:06 +02:00
|
|
|
this.animationFrameId = null;
|
2022-01-10 14:06:18 +01:00
|
|
|
}
|
2022-05-11 18:04:06 +02:00
|
|
|
this.isPlaying = isPlaying;
|
|
|
|
this.audioWorklet.port.postMessage({ isPlaying });
|
|
|
|
}
|
|
|
|
},
|
|
|
|
|
|
|
|
updateCounterValue() {
|
|
|
|
this.controlTimeValue.placeholder = this.convertToUnit(this.byteSample);
|
|
|
|
},
|
|
|
|
convertFromUnit(value, unit = this.timeUnit) {
|
|
|
|
switch (unit) {
|
|
|
|
case "t": return value;
|
|
|
|
case "s": return value * this.songData.sampleRate;
|
|
|
|
}
|
|
|
|
},
|
|
|
|
// IMPORTANT: this function is ONLY used for text formatting, does not work for many conversions, and is inaccurate.
|
|
|
|
convertToUnit(value, unit = this.timeUnit) {
|
|
|
|
switch (unit) {
|
|
|
|
case "t": return value;
|
|
|
|
case "s": return (value / this.songData.sampleRate).toFixed(3);
|
|
|
|
}
|
|
|
|
},
|
|
|
|
setTimeUnit(value, updateCounter = true) {
|
|
|
|
if (value !== undefined) {
|
|
|
|
if (typeof value === "number")
|
|
|
|
value = timeUnits[value];
|
|
|
|
this.timeUnit = value;
|
2022-01-10 14:06:18 +01:00
|
|
|
this.controlTimeUnitLabel.innerText = this.timeUnit;
|
2022-05-11 18:04:06 +02:00
|
|
|
} else
|
|
|
|
this.timeUnit = this.controlTimeUnitLabel.innerText;
|
2022-01-08 19:04:55 +01:00
|
|
|
|
2022-05-11 18:04:06 +02:00
|
|
|
if (updateCounter)
|
2022-01-10 14:06:18 +01:00
|
|
|
this.updateCounterValue();
|
2022-05-11 18:04:06 +02:00
|
|
|
this.saveSettings();
|
|
|
|
},
|
|
|
|
changeTimeUnit() {
|
|
|
|
this.timeUnit = timeUnits[(timeUnits.indexOf(this.timeUnit) + 1) % timeUnits.length];
|
|
|
|
this.controlTimeUnitLabel.innerText = this.timeUnit;
|
|
|
|
|
|
|
|
this.updateCounterValue();
|
|
|
|
this.saveSettings();
|
|
|
|
},
|
|
|
|
saveSettings() {
|
|
|
|
if (globalThis.useLocalStorage !== false)
|
|
|
|
localStorage.settings = JSON.stringify({ drawSettings: this.drawSettings, volume: this.volume, timeUnit: this.timeUnit });
|
|
|
|
},
|
|
|
|
loadSettings() {
|
|
|
|
if (localStorage.settings && globalThis.useLocalStorage !== false) {
|
|
|
|
let settings;
|
|
|
|
try {
|
|
|
|
settings = JSON.parse(localStorage.settings);
|
|
|
|
} catch (err) {
|
|
|
|
console.error("Couldn't load settings!", localStorage.settings);
|
|
|
|
localStorage.clear();
|
|
|
|
this.loadDefaultSettings();
|
|
|
|
return;
|
|
|
|
}
|
2022-01-10 14:06:18 +01:00
|
|
|
|
2022-05-11 18:04:06 +02:00
|
|
|
if (Object.hasOwnProperty.call(settings, "drawSettings")) {
|
|
|
|
this.drawSettings = settings.drawSettings;
|
|
|
|
this.updateDrawMode();
|
|
|
|
} else {
|
|
|
|
this.setDrawMode(undefined, false);
|
|
|
|
this.drawSettings.scale = 5;
|
|
|
|
}
|
2022-01-10 14:06:18 +01:00
|
|
|
|
2022-05-11 18:04:06 +02:00
|
|
|
if (Object.hasOwnProperty.call(settings, "volume"))
|
|
|
|
this.setVolume(false, settings.volume);
|
|
|
|
else
|
|
|
|
this.setVolume(false);
|
2022-01-10 14:06:18 +01:00
|
|
|
|
2022-05-11 18:04:06 +02:00
|
|
|
if (Object.hasOwnProperty.call(settings, "timeUnit"))
|
|
|
|
this.setTimeUnit(settings.timeUnit, false);
|
|
|
|
else
|
|
|
|
this.setTimeUnit(undefined, false);
|
|
|
|
} else
|
|
|
|
this.loadDefaultSettings();
|
|
|
|
},
|
|
|
|
loadDefaultSettings() {
|
|
|
|
this.setDrawMode(undefined, false);
|
|
|
|
this.drawSettings.scale = 5;
|
|
|
|
this.setVolume(false);
|
|
|
|
this.setTimeUnit(undefined, false);
|
|
|
|
}
|
2021-11-18 14:36:00 +01:00
|
|
|
});
|
|
|
|
|
2023-12-25 21:56:34 +01:00
|
|
|
// used in html
|
2022-05-13 19:17:17 +02:00
|
|
|
Object.defineProperty(globalThis, "bytebeat", { value: bytebeat });
|
2023-12-23 05:31:49 +01:00
|
|
|
|
2023-04-01 02:45:26 +02:00
|
|
|
bytebeat.init();
|
|
|
|
export default bytebeat;
|