bytebeat-composer/src/interface.ts
2024-08-09 15:07:45 -04:00

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`;
}
}
}