411 lines
10 KiB
TypeScript
411 lines
10 KiB
TypeScript
import { sign0, f32ToRound10, isPlainObject, type Song, type SongMode, type StrongPartial } from "./common.ts";
|
|
import elements from "./elements.ts";
|
|
// this shouldn't need to load when useUrlData is false,
|
|
// but if it was loaded conditionally then it can't inline
|
|
import { fromUrlData, setUrlData } from "./url/mod.ts";
|
|
import { CodeEditor } from "./code-editor/mod.ts";
|
|
import { Oscillioscope } from "./oscillioscope.ts";
|
|
|
|
const timeUnits = ["t", "s"] as const;
|
|
type TimeUnit = (typeof timeUnits)[number];
|
|
|
|
const errTypes = ["compile", "firstrun", "runtime"] as const;
|
|
type ErrType = (typeof errTypes)[number];
|
|
type Error = { err: string, type?: ErrType }
|
|
|
|
// XXX: global variables
|
|
let timeUnit: TimeUnit;
|
|
let volume: number;
|
|
let sample = 0;
|
|
/**
|
|
* when paused, uses 0 and -0 to indicate last playing direction
|
|
* so that the time cursor is offset in the right direction.
|
|
*/
|
|
let playSpeed = 0;
|
|
let songData: {
|
|
sampleRate: number;
|
|
mode: SongMode;
|
|
} = {
|
|
sampleRate: 8000, // float32
|
|
mode: "Bytebeat",
|
|
};
|
|
let nextErr: Error | undefined;
|
|
|
|
const searchParams = new URLSearchParams(location.search);
|
|
|
|
const audioPromise = initAudioContext();
|
|
|
|
const codeEditor = new CodeEditor(refresh);
|
|
const osc = new Oscillioscope(elements.canvas, elements.timeCursor);
|
|
|
|
if (globalThis.loadLibrary !== false) {
|
|
import("./library/load.ts");
|
|
}
|
|
|
|
const { audioCtx, audioGain, audioWorklet } = await audioPromise;
|
|
|
|
loadSettings();
|
|
|
|
{
|
|
let urlData: Song | undefined;
|
|
if (location.hash && globalThis.useUrlData !== false) {
|
|
urlData = await fromUrlData(location.hash);
|
|
}
|
|
|
|
setSong(urlData, false);
|
|
updateCounterValue();
|
|
}
|
|
|
|
async function initAudioContext() {
|
|
// null casts to 0 which is fine
|
|
// @ts-expect-error
|
|
let audioContextSampleRate = +searchParams.get("baseSampleRate");
|
|
// also true for NaN
|
|
if (!(audioContextSampleRate > 0)) {
|
|
audioContextSampleRate = 48000; // TODO this should be set to an lcm > 44100
|
|
}
|
|
|
|
const audioCtx = new AudioContext({
|
|
latencyHint: (searchParams.get("latencyHint") as AudioContextLatencyCategory) ?? "balanced",
|
|
sampleRate: audioContextSampleRate,
|
|
});
|
|
|
|
const audioGain = new GainNode(audioCtx);
|
|
audioGain.connect(audioCtx.destination);
|
|
|
|
await audioCtx.audioWorklet.addModule("/audio-worklet.js");
|
|
const audioWorklet = new AudioWorkletNode(audioCtx, "bytebeatProcessor", {
|
|
outputChannelCount: [2],
|
|
});
|
|
audioWorklet.port.addEventListener("message", e => handleMessage(e));
|
|
audioWorklet.port.start();
|
|
audioWorklet.connect(audioGain);
|
|
|
|
console.info(
|
|
`started audio with latency ${audioCtx.baseLatency * audioCtx.sampleRate} at ${
|
|
audioCtx.sampleRate
|
|
}Hz`,
|
|
);
|
|
return { audioCtx, audioGain, audioWorklet };
|
|
}
|
|
|
|
function refresh() {
|
|
if (audioWorklet) {
|
|
audioWorklet.port.postMessage({ code: codeEditor.getCode().trim() });
|
|
}
|
|
}
|
|
// TODO: bug, this calls refresh twice every time because
|
|
// setting codemirror programmatically also calls refresh
|
|
// the code text input might also have this issue?
|
|
export function setSong(songData?: StrongPartial<Song>, play = true) {
|
|
let code, sampleRate, mode;
|
|
if (songData) {
|
|
({ code, sampleRate, mode } = songData);
|
|
codeEditor.setCode(code);
|
|
}
|
|
setSampleRate(sampleRate ?? 8000);
|
|
setSongMode(mode ?? "Bytebeat");
|
|
refresh();
|
|
if (play) {
|
|
resetTime();
|
|
setPlaySpeed(1);
|
|
}
|
|
}
|
|
/** refresh should be called after this. */
|
|
function setSampleRate(sampleRate: number) {
|
|
if (audioWorklet) {
|
|
const rate = Math.min(Math.max(Math.fround(sampleRate), 2), 2 ** 24 - 1);
|
|
// implicit cast
|
|
elements.sampleRate.value = f32ToRound10(rate) as unknown as string;
|
|
songData.sampleRate = rate;
|
|
// TODO if funcbeat change time so that sec stays the same
|
|
audioWorklet.port.postMessage({ songData, updateSampleRatio: true });
|
|
osc.showTimeCursor(timeCursorShouldBeVisible());
|
|
}
|
|
}
|
|
/** refresh should be called after this. */
|
|
function setSongMode(mode: SongMode) {
|
|
if (audioWorklet) {
|
|
elements.playbackMode.value = mode;
|
|
songData.mode = mode;
|
|
audioWorklet.port.postMessage({ songData });
|
|
}
|
|
}
|
|
|
|
export function getSong(): Song {
|
|
return { code: codeEditor.getCode(), ...songData };
|
|
}
|
|
|
|
function handleMessage(e: MessageEvent<unknown>) {
|
|
if (isPlainObject(e.data)) {
|
|
const data = e.data;
|
|
if (data.clearCanvas) {
|
|
osc.clear();
|
|
} else if (data.clearDrawBuffer) {
|
|
osc.clearBuffer();
|
|
}
|
|
|
|
if (typeof data.sample === "number") {
|
|
setByteSample(data.sample, false);
|
|
}
|
|
if (Array.isArray(data.drawBuffer)) {
|
|
osc.addToBuffer(data.drawBuffer, sample);
|
|
}
|
|
|
|
if (data.updateUrl && globalThis.useUrlData !== false) {
|
|
setUrlData(getSong());
|
|
}
|
|
|
|
if (data.errorMessage !== undefined) {
|
|
if (isPlainObject(data.errorMessage)) {
|
|
const { err, type } = data.errorMessage;
|
|
if (typeof err === "string" && errTypes.includes(type)) {
|
|
nextErr = { err, type };
|
|
}
|
|
} else {
|
|
nextErr = { err: "" };
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
function timeCursorShouldBeVisible(): boolean {
|
|
return songData.sampleRate >> osc.settings.scale < 3950;
|
|
}
|
|
function changeScale(amount: number) {
|
|
if (amount) {
|
|
osc.settings.scale = Math.max(osc.settings.scale + amount, 0);
|
|
osc.clear(false);
|
|
elements.scaleDown.toggleAttribute("disabled", osc.settings.scale <= 0);
|
|
|
|
osc.showTimeCursor(timeCursorShouldBeVisible());
|
|
if (!playSpeed) {
|
|
osc.moveTimeCursor(sample, true);
|
|
}
|
|
saveSettings();
|
|
}
|
|
}
|
|
|
|
/** show the error in variable nextErr. */
|
|
function updateErrorMessage() {
|
|
if (nextErr) {
|
|
elements.error.innerText = nextErr.err;
|
|
elements.error.dataset["errType"] = nextErr.type;
|
|
|
|
if (audioWorklet) {
|
|
audioWorklet.port.postMessage({ recievedError: true });
|
|
}
|
|
nextErr = undefined;
|
|
}
|
|
}
|
|
|
|
function resetTime() {
|
|
setByteSample(0, true, true);
|
|
if (!playSpeed) {
|
|
elements.canvasTogglePlay.classList.add("canvas-toggleplay-show");
|
|
}
|
|
}
|
|
/// TODO document
|
|
function setByteSample(value: number, send = true, clear = false) {
|
|
if (audioWorklet && isFinite(value)) {
|
|
sample = value;
|
|
updateCounterValue();
|
|
if (send) {
|
|
audioWorklet.port.postMessage({ setByteSample: [value, clear] });
|
|
}
|
|
osc.moveTimeCursor(sample, sign0(playSpeed));
|
|
}
|
|
}
|
|
|
|
function setPlaySpeed(value: number) {
|
|
if (audioWorklet && value !== playSpeed) {
|
|
elements.canvasTogglePlay.classList.toggle("canvas-toggleplay-pause", !!value);
|
|
if (value) {
|
|
elements.canvasTogglePlay.classList.remove("canvas-toggleplay-show");
|
|
audioCtx.resume();
|
|
playSpeed = value;
|
|
} else {
|
|
//audioCtx.suspend();
|
|
playSpeed /= Infinity; // preserve sign
|
|
}
|
|
audioWorklet.port.postMessage({ playSpeed, updateSampleRatio: true });
|
|
}
|
|
}
|
|
function togglePlay() {
|
|
setPlaySpeed(+!playSpeed);
|
|
}
|
|
|
|
function animationFrame() {
|
|
osc.draw(sample, sign0(playSpeed));
|
|
osc.moveTimeCursor(sample, sign0(playSpeed));
|
|
updateErrorMessage();
|
|
|
|
requestAnimationFrame(() => animationFrame());
|
|
}
|
|
animationFrame();
|
|
|
|
function updateCounterValue() {
|
|
// implicit cast
|
|
elements.timeValue.placeholder = convertToUnit(sample) as unknown as string;
|
|
}
|
|
function convertFromUnit(value: number, unit = timeUnit) {
|
|
switch (unit) {
|
|
case "t":
|
|
return value;
|
|
case "s":
|
|
return value * songData.sampleRate;
|
|
}
|
|
}
|
|
// IMPORTANT: this function is ONLY used for text formatting, does not work for many conversions, and is inaccurate.
|
|
function convertToUnit(value: number, unit = timeUnit) {
|
|
switch (unit) {
|
|
case "t":
|
|
return value;
|
|
case "s":
|
|
return (value / songData.sampleRate).toFixed(3);
|
|
}
|
|
}
|
|
function setTimeUnit(value?: number | TimeUnit, userInput = true) {
|
|
if (value !== undefined) {
|
|
if (typeof value === "number") {
|
|
timeUnit = timeUnits[value];
|
|
} else {
|
|
timeUnit = value;
|
|
}
|
|
elements.timeUnitLabel.innerText = timeUnit;
|
|
} else {
|
|
timeUnit = elements.timeUnitLabel.innerText;
|
|
}
|
|
|
|
if (userInput) {
|
|
updateCounterValue();
|
|
saveSettings();
|
|
}
|
|
}
|
|
function changeTimeUnit() {
|
|
timeUnit = timeUnits[(timeUnits.indexOf(timeUnit) + 1) % timeUnits.length];
|
|
elements.timeUnitLabel.innerText = timeUnit;
|
|
|
|
updateCounterValue();
|
|
saveSettings();
|
|
}
|
|
|
|
function setSampleRateDivisor(sampleRateDivisor: number) {
|
|
if (audioWorklet && sampleRateDivisor > 0) {
|
|
audioWorklet.port.postMessage({ sampleRateDivisor, updateSampleRatio: true });
|
|
}
|
|
}
|
|
|
|
function setVolume(value?: number, save = true) {
|
|
if (value !== undefined) {
|
|
volume = value;
|
|
// implicit cast
|
|
elements.volume.value = volume as unknown as string;
|
|
}
|
|
volume = elements.volume.valueAsNumber;
|
|
|
|
audioGain.gain.value = volume ** 2;
|
|
|
|
if (save) {
|
|
saveSettings();
|
|
}
|
|
}
|
|
|
|
type Settings = {
|
|
drawSettings: typeof osc.settings;
|
|
volume: number;
|
|
timeUnit: TimeUnit;
|
|
};
|
|
function loadDefaultSettings() {
|
|
setVolume(undefined, false);
|
|
setTimeUnit(undefined, false);
|
|
}
|
|
function loadSettings(): Settings {
|
|
if (globalThis.useLocalStorage !== false && localStorage["settings"]) {
|
|
let settings: {
|
|
drawSettings?: typeof osc.settings;
|
|
volume?: typeof volume;
|
|
timeUnit?: typeof timeUnit;
|
|
};
|
|
try {
|
|
// TODO: this should be validated
|
|
settings = JSON.parse(localStorage["settings"]);
|
|
} catch (err) {
|
|
console.error("Couldn't load settings!", localStorage["settings"]);
|
|
localStorage.clear();
|
|
loadDefaultSettings();
|
|
return;
|
|
}
|
|
|
|
if (settings.drawSettings) {
|
|
osc.settings = settings.drawSettings;
|
|
} else {
|
|
osc.settings.scale = 5;
|
|
}
|
|
|
|
setVolume(settings.volume, false);
|
|
|
|
if (settings.timeUnit) {
|
|
setTimeUnit(settings.timeUnit, false);
|
|
} else {
|
|
setTimeUnit(undefined, false);
|
|
}
|
|
} else {
|
|
loadDefaultSettings();
|
|
}
|
|
}
|
|
function saveSettings() {
|
|
if (globalThis.useLocalStorage !== false) {
|
|
localStorage["settings"] = JSON.stringify({
|
|
drawSettings: osc.settings,
|
|
volume,
|
|
timeUnit,
|
|
});
|
|
}
|
|
}
|
|
|
|
// used in html
|
|
Object.defineProperty(globalThis, "bytebeat", {
|
|
value: {
|
|
changeTimeUnit,
|
|
setByteSample,
|
|
convertFromUnit,
|
|
togglePlay,
|
|
resetTime,
|
|
setPlaySpeed,
|
|
setVolume,
|
|
changeScale,
|
|
setSongMode,
|
|
setSampleRate,
|
|
setSampleRateDivisor,
|
|
refresh,
|
|
},
|
|
});
|
|
|
|
// TODO: move this to a different file
|
|
autoSizeCanvas(true);
|
|
addEventListener("resize", () => autoSizeCanvas());
|
|
export function autoSizeCanvas(force = false) {
|
|
if (!elements.canvas.dataset["forcedWidth"]) {
|
|
// 768 is halfway between 512 and 1024, 3 added for outline
|
|
if (innerWidth >= 768 + 3) {
|
|
let width = 1024;
|
|
while (innerWidth - 516 >= width * 2) {
|
|
// 516px = 4px (outline) + 512px (library)
|
|
width *= 2;
|
|
}
|
|
setCanvasWidth(width, force);
|
|
} else {
|
|
setCanvasWidth(512, force);
|
|
}
|
|
}
|
|
}
|
|
export function setCanvasWidth(width: number, force = false) {
|
|
if (elements.canvas) {
|
|
if (width !== elements.canvas.width || force) {
|
|
elements.canvas.width = width;
|
|
// TODO: see if it's possible to get rid of this
|
|
elements.main.style.maxWidth = `${width + 4}px`;
|
|
}
|
|
}
|
|
}
|