Minor improvements

This commit is contained in:
Andras Schmelczer 2026-05-26 20:59:01 +01:00
commit 4e24df1511
9 changed files with 175 additions and 100 deletions

View file

@ -28,10 +28,10 @@ const graphTuning = {
latencyHint: 'interactive',
outputFilterType: 'highpass',
compressor: {
thresholdDb: -18,
kneeDb: 18,
ratio: 2.1,
attackSeconds: 0.018,
thresholdDb: -22,
kneeDb: 12,
ratio: 4.5,
attackSeconds: 0.006,
releaseSeconds: 0.18,
},
} as const;
@ -87,10 +87,12 @@ export class GardenAudioGraph {
const context = new AudioContextConstructor({
latencyHint: graphTuning.latencyHint,
});
const outputBus = context.createGain();
const masterGain = context.createGain();
const highPass = context.createBiquadFilter();
const compressor = context.createDynamicsCompressor();
outputBus.gain.value = 1;
masterGain.gain.value = 0;
highPass.type = graphTuning.outputFilterType;
highPass.frequency.value = outputHighPassFrequencyHz;
@ -100,15 +102,17 @@ export class GardenAudioGraph {
compressor.attack.value = graphTuning.compressor.attackSeconds;
compressor.release.value = graphTuning.compressor.releaseSeconds;
masterGain.connect(highPass);
// Keep peak control independent from the user's volume slider.
outputBus.connect(highPass);
highPass.connect(compressor);
compressor.connect(context.destination);
compressor.connect(masterGain);
masterGain.connect(context.destination);
this.context = context;
this.masterGain = masterGain;
this.noiseBuffer = this.createNoiseBuffer(context);
this.createDelay(context, masterGain);
this.createBuses(context, masterGain);
this.createDelay(context, outputBus);
this.createBuses(context, outputBus);
return context;
}
@ -224,7 +228,7 @@ export class GardenAudioGraph {
}
}
private createDelay(context: AudioContext, masterGain: GainNode): void {
private createDelay(context: AudioContext, outputBus: GainNode): void {
const delayInput = context.createGain();
const delayNode = context.createDelay(graphTuning.delayMaxSeconds);
const delayFeedback = context.createGain();
@ -250,7 +254,7 @@ export class GardenAudioGraph {
delayFeedback.connect(delayNode);
delayNode.connect(returnLowPass);
returnLowPass.connect(delayOutput);
delayOutput.connect(masterGain);
delayOutput.connect(outputBus);
this.delayInput = delayInput;
this.delayNode = delayNode;
@ -258,10 +262,10 @@ export class GardenAudioGraph {
this.delayOutput = delayOutput;
}
private createBuses(context: AudioContext, masterGain: GainNode): void {
private createBuses(context: AudioContext, outputBus: GainNode): void {
const eventBus = context.createGain();
eventBus.gain.value = graphTuning.eventBusGain;
eventBus.connect(masterGain);
eventBus.connect(outputBus);
this.eventBus = eventBus;
this.pianoBuses.clear();

84
src/config/eraser-size.ts Normal file
View file

@ -0,0 +1,84 @@
export interface CssPixelSize {
height: number;
width: number;
}
export const ERASER_SIZE_MIN = 24;
export const ERASER_SIZE_MAX = 480;
const ERASER_MAX_SHORT_SIDE_RATIO = 0.55;
const getNormalizedEraserSizeMax = (maxSize: number): number => {
const safeMaxSize = Number.isFinite(maxSize) ? Math.floor(maxSize) : ERASER_SIZE_MAX;
return Math.max(ERASER_SIZE_MIN, Math.min(ERASER_SIZE_MAX, safeMaxSize));
};
export const getElementCssPixelSize = (element: Element): CssPixelSize => {
const rect = element.getBoundingClientRect();
const { clientHeight = 0, clientWidth = 0 } = element as HTMLElement;
return {
height: rect.height || clientHeight,
width: rect.width || clientWidth,
};
};
export const getEraserSizeMaxForCssSize = ({ height, width }: CssPixelSize): number => {
const shortestSide = Math.min(height, width);
if (!Number.isFinite(shortestSide) || shortestSide <= 0) {
return ERASER_SIZE_MAX;
}
return getNormalizedEraserSizeMax(
Math.floor(shortestSide * ERASER_MAX_SHORT_SIDE_RATIO)
);
};
export const clampEraserSize = (
value: number,
maxSize = ERASER_SIZE_MAX,
fallbackSize = ERASER_SIZE_MIN
): number => {
const max = getNormalizedEraserSizeMax(maxSize);
const fallback = Number.isFinite(fallbackSize) ? fallbackSize : ERASER_SIZE_MIN;
const safeValue = Number.isFinite(value) ? value : fallback;
return Math.min(max, Math.max(ERASER_SIZE_MIN, Math.round(safeValue)));
};
export const getEffectiveEraserSize = (
size: number,
cssSize: CssPixelSize,
fallbackSize = ERASER_SIZE_MIN
): number => clampEraserSize(size, getEraserSizeMaxForCssSize(cssSize), fallbackSize);
export const getEraserSizeRatio = (size: number, maxSize = ERASER_SIZE_MAX): number => {
const max = getNormalizedEraserSizeMax(maxSize);
if (max === ERASER_SIZE_MIN) {
return 0;
}
return (clampEraserSize(size, max) - ERASER_SIZE_MIN) / (max - ERASER_SIZE_MIN);
};
const ERASER_SLIDER_MIN = 0;
const ERASER_SLIDER_MAX = 1;
const clampSliderRatio = (value: number): number => {
const safeValue = Number.isFinite(value) ? value : ERASER_SLIDER_MIN;
return Math.min(ERASER_SLIDER_MAX, Math.max(ERASER_SLIDER_MIN, safeValue));
};
export const getEraserSizeFromSliderRatio = (
sliderRatio: number,
maxSize = ERASER_SIZE_MAX
): number => {
const max = getNormalizedEraserSizeMax(maxSize);
return clampEraserSize(
ERASER_SIZE_MIN + (max - ERASER_SIZE_MIN) * clampSliderRatio(sliderRatio) ** 2,
max
);
};
export const getEraserSliderRatioFromSize = (
size: number,
maxSize = ERASER_SIZE_MAX
): number => Math.sqrt(getEraserSizeRatio(size, maxSize));

View file

@ -1,3 +1,4 @@
import { getEffectiveEraserSize } from '../config/eraser-size';
import { settings } from '../settings';
export class EraserPreview {
@ -49,9 +50,15 @@ export class EraserPreview {
};
}
if (this.previousSize !== settings.eraserSize) {
this.element.style.setProperty('--eraser-preview-size', `${settings.eraserSize}px`);
this.previousSize = settings.eraserSize;
const rect = this.canvas.getBoundingClientRect();
const size = getEffectiveEraserSize(settings.eraserSize, {
height: rect.height || this.canvas.clientHeight,
width: rect.width || this.canvas.clientWidth,
});
if (this.previousSize !== size) {
this.element.style.setProperty('--eraser-preview-size', `${size}px`);
this.previousSize = size;
}
if (
@ -63,7 +70,6 @@ export class EraserPreview {
return;
}
const rect = this.canvas.getBoundingClientRect();
const left = `${this.previewClientPosition.x - rect.left}px`;
const top = `${this.previewClientPosition.y - rect.top}px`;
if (this.previousLeft !== left) {

View file

@ -2,6 +2,7 @@ import { vec2 } from 'gl-matrix';
import { GardenAudio } from '../audio/garden-audio';
import { createGardenAudioConfig } from '../audio/garden-audio-config';
import { getEffectiveEraserSize, getElementCssPixelSize } from '../config/eraser-size';
import { activeVibe, settings } from '../settings';
import { DeltaTimeCalculator } from '../utils/delta-time-calculator';
import { rgbColorToCss, type RgbColor } from '../utils/rgb-color';
@ -209,7 +210,11 @@ export default class GameLoop {
const runtimeSettings = { ...settings };
const introProgress = this.introPrompt.progress;
const canvasPixelRatio = this.canvasPixelRatio;
const eraserPixelSize = runtimeSettings.eraserSize * canvasPixelRatio;
const eraserCssSize = getEffectiveEraserSize(
runtimeSettings.eraserSize,
getElementCssPixelSize(this.canvas)
);
const eraserPixelSize = eraserCssSize * canvasPixelRatio;
const isErasing = this.pointerInput.isEraseMode;
const accentColor =
channelColors[runtimeSettings.selectedColorIndex] ?? channelColors[0];

View file

@ -3,9 +3,11 @@ import { describe, expect, it } from 'vitest';
import {
ERASER_SIZE_MAX,
ERASER_SIZE_MIN,
getEffectiveEraserSize,
getEraserSizeFromSliderRatio,
getEraserSizeMaxForCssSize,
getEraserSliderRatioFromSize,
} from './eraser-size-control';
} from '../config/eraser-size';
describe('eraser size slider mapping', () => {
it('maps slider position quadratically to eraser size', () => {
@ -23,4 +25,15 @@ describe('eraser size slider mapping', () => {
expect(getEraserSliderRatioFromSize(quarterRangeSize)).toBe(0.5);
expect(getEraserSliderRatioFromSize(ERASER_SIZE_MAX)).toBe(1);
});
it('uses a responsive max size on small canvases', () => {
const mobileMax = getEraserSizeMaxForCssSize({ height: 640, width: 390 });
expect(mobileMax).toBeLessThan(ERASER_SIZE_MAX);
expect(getEraserSizeFromSliderRatio(1, mobileMax)).toBe(mobileMax);
expect(getEraserSliderRatioFromSize(ERASER_SIZE_MAX, mobileMax)).toBe(1);
expect(getEffectiveEraserSize(ERASER_SIZE_MAX, { height: 640, width: 390 })).toBe(
mobileMax
);
});
});

View file

@ -1,40 +1,25 @@
import {
clampEraserSize,
ERASER_SIZE_MAX,
getElementCssPixelSize,
getEraserSizeFromSliderRatio,
getEraserSizeMaxForCssSize,
getEraserSizeRatio,
getEraserSliderRatioFromSize,
} from '../config/eraser-size';
import type GameLoop from '../game-loop/game-loop';
import { DEFAULT_ERASER_SIZE, settings } from '../settings';
import { queryRequiredElement } from '../utils/dom';
export const ERASER_SIZE_MIN = 24;
export const ERASER_SIZE_MAX = 480;
const ERASER_CONTROL_SCALE_MIN = 0.74;
const ERASER_CONTROL_SCALE_MAX = 1.34;
const clampEraserSize = (value: number): number => {
const safeValue = Number.isFinite(value) ? value : DEFAULT_ERASER_SIZE;
return Math.min(ERASER_SIZE_MAX, Math.max(ERASER_SIZE_MIN, Math.round(safeValue)));
};
const ERASER_SLIDER_MIN = 0;
const ERASER_SLIDER_MAX = 1;
const ERASER_SLIDER_STEP = 0.001;
const clampSliderRatio = (value: number): number => {
const safeValue = Number.isFinite(value) ? value : ERASER_SLIDER_MIN;
return Math.min(ERASER_SLIDER_MAX, Math.max(ERASER_SLIDER_MIN, safeValue));
};
const getEraserSizeRatio = (size: number): number => {
return (clampEraserSize(size) - ERASER_SIZE_MIN) / (ERASER_SIZE_MAX - ERASER_SIZE_MIN);
};
export const getEraserSizeFromSliderRatio = (sliderRatio: number): number => {
return clampEraserSize(
ERASER_SIZE_MIN +
(ERASER_SIZE_MAX - ERASER_SIZE_MIN) * clampSliderRatio(sliderRatio) ** 2
);
};
export const getEraserSliderRatioFromSize = (size: number): number =>
Math.sqrt(getEraserSizeRatio(size));
const clampStoredEraserSize = (value: number): number =>
clampEraserSize(value, ERASER_SIZE_MAX, DEFAULT_ERASER_SIZE);
interface EraserSizeControlOptions {
getGame: () => GameLoop | null;
@ -48,6 +33,7 @@ export class EraserSizeControl {
HTMLLabelElement
);
private readonly slider = queryRequiredElement('.eraser-size-slider', HTMLInputElement);
private readonly canvas = queryRequiredElement('canvas', HTMLCanvasElement);
private isActive = false;
public constructor(private readonly options: EraserSizeControlOptions) {
@ -55,7 +41,10 @@ export class EraserSizeControl {
this.control.addEventListener('click', this.activate);
this.slider.addEventListener('focus', this.activate);
this.slider.addEventListener('input', () => {
settings.eraserSize = getEraserSizeFromSliderRatio(Number(this.slider.value));
settings.eraserSize = getEraserSizeFromSliderRatio(
Number(this.slider.value),
this.getResponsiveMaxSize()
);
this.activate();
this.render();
this.options.onChange();
@ -63,19 +52,21 @@ export class EraserSizeControl {
}
public render(): void {
const size = clampEraserSize(settings.eraserSize);
if (settings.eraserSize !== size) {
settings.eraserSize = size;
const maxSize = this.getResponsiveMaxSize();
const storedSize = clampStoredEraserSize(settings.eraserSize);
if (settings.eraserSize !== storedSize) {
settings.eraserSize = storedSize;
}
const sliderRatio = getEraserSliderRatioFromSize(size);
const size = clampEraserSize(storedSize, maxSize, DEFAULT_ERASER_SIZE);
const sliderRatio = getEraserSliderRatioFromSize(size, maxSize);
this.slider.min = ERASER_SLIDER_MIN.toString();
this.slider.max = ERASER_SLIDER_MAX.toString();
this.slider.step = ERASER_SLIDER_STEP.toString();
this.slider.value = sliderRatio.toString();
this.slider.setAttribute('aria-valuetext', `${size}px`);
const sizeRatio = getEraserSizeRatio(size);
const sizeRatio = getEraserSizeRatio(size, maxSize);
const scale =
ERASER_CONTROL_SCALE_MIN +
(ERASER_CONTROL_SCALE_MAX - ERASER_CONTROL_SCALE_MIN) * sizeRatio;
@ -95,6 +86,10 @@ export class EraserSizeControl {
this.options.onActivate();
};
private getResponsiveMaxSize(): number {
return getEraserSizeMaxForCssSize(getElementCssPixelSize(this.canvas));
}
private syncActiveState(): void {
this.control.classList.toggle('active', this.isActive);
this.slider.setAttribute(

View file

@ -2,13 +2,10 @@
html > body > aside.control-dock > .info-page {
width: min(100%, 520px);
max-height: min(62vh, 480px);
max-height: min(62dvh, 480px);
max-height: 200vh;
max-height: 200dvh;
margin: 0 auto 10px;
overflow-x: hidden;
overflow-y: auto;
overscroll-behavior: contain;
touch-action: pan-y;
overflow: hidden;
border: 1px solid rgb(255 255 255 / 46%);
border-radius: 8px;
background:
@ -20,26 +17,12 @@ html > body > aside.control-dock > .info-page {
0 16px 42px rgb(0 0 0 / 30%),
0 2px 10px rgb(0 0 0 / 18%);
backdrop-filter: blur(16px) saturate(118%);
scrollbar-width: thin;
scrollbar-color: rgb(69 98 88 / 62%) transparent;
-webkit-overflow-scrolling: touch;
transition:
max-height var(--transition-time-long),
opacity var(--transition-time-long),
transform var(--transition-time-long),
margin-bottom var(--transition-time-long);
&::-webkit-scrollbar-track,
&::-webkit-scrollbar {
background-color: transparent;
width: 6px;
}
&::-webkit-scrollbar-thumb {
background-color: rgb(69 98 88 / 62%);
border-radius: 8px;
}
&:focus-visible {
outline: 2px solid rgb(17 56 45);
outline-offset: 3px;
@ -225,8 +208,6 @@ html > body > aside.control-dock > .info-page {
@include on-small-screen {
width: min(100%, 520px);
max-height: min(58vh, 500px);
max-height: min(58dvh, 500px);
.info-page__content {
gap: 0.75rem;
@ -244,26 +225,4 @@ html > body > aside.control-dock > .info-page {
line-height: 1.45;
}
}
@media (max-height: 420px) {
max-height: min(
58vh,
max(
10rem,
calc(
100vh - 168px - env(safe-area-inset-top, 0px) - env(safe-area-inset-bottom, 0px)
)
)
);
max-height: min(
58dvh,
max(
10rem,
calc(
100dvh -
168px - env(safe-area-inset-top, 0px) - env(safe-area-inset-bottom, 0px)
)
)
);
}
}

View file

@ -16,15 +16,24 @@
html {
height: 100%;
overscroll-behavior: none;
touch-action: manipulation;
user-select: none;
-webkit-font-smoothing: antialiased;
-webkit-touch-callout: none;
-webkit-user-select: none;
-moz-osx-font-smoothing: grayscale;
text-rendering: optimizeLegibility;
}
body {
font-family: 'Open Sans', sans-serif;
overscroll-behavior: none;
touch-action: manipulation;
user-select: none;
-webkit-tap-highlight-color: transparent;
-webkit-touch-callout: none;
-webkit-user-select: none;
}
.visually-hidden {