Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
23 commits
Select commit Hold shift + click to select a range
b8031b6
feat(audio): expose getAudioContext() + add tone() procedural primitive
obiot May 19, 2026
b51371a
fix(audio): nudge Howler.ctx creation in getAudioContext()
obiot May 19, 2026
9c9d6c9
refactor(audio): tidy tone() module layout
obiot May 19, 2026
a0427d8
fix(audio): tone() respects master mute / volume + tighten PannerAttr…
obiot May 19, 2026
16ffd94
feat(audio): add noise() — procedural noise burst with optional bands…
obiot May 19, 2026
2ef9356
chore(audio): cleanup pass on audio.ts
obiot May 20, 2026
a49f254
docs(audio): normalize JSDoc style across the audio module
obiot May 20, 2026
aea9d31
fix(audio): guard exponential ramps against non-positive starts + res…
obiot May 20, 2026
43da5cf
docs(audio): drop the example block on getAudioContext()
obiot May 20, 2026
cf6c2d9
feat(audio): add getMasterGain() + route tone/noise through it
obiot May 20, 2026
112db73
docs(audio): clarify why getMasterGain keeps its `?? null` defense
obiot May 20, 2026
eacb8ac
refactor(audio): dedupe tone()/noise() boilerplate into private helpers
obiot May 20, 2026
106ceb8
docs(audio): drop stale "howler 2.0" comment in play()
obiot May 20, 2026
19e98d8
chore(audio): drop dead typeof-boolean guard in play()
obiot May 20, 2026
761691c
refactor(audio): split into backend / playback / procedural + positio…
obiot May 20, 2026
2f5de6f
refactor(audio): wrap Howler globals in backend helpers + WAV round-t…
obiot May 20, 2026
53f28df
refactor(audio): getSoundOrThrow helper + parameterised contract tests
obiot May 20, 2026
f878ba4
fix(audio): address PR review batch — bugs + doc accuracy
obiot May 20, 2026
1f3998c
docs(audio): fix stale "fetch" reference in LoadSettings JSDoc
obiot May 20, 2026
7345059
fix(audio): empty freq array no-op + attack clamp respects short dura…
obiot May 20, 2026
d52891b
feat(pool-matter): procedural sound effects via audio.tone
obiot May 20, 2026
d00764a
fix(pool-matter): retune cue strike and ball click — closer to real pool
obiot May 20, 2026
3241df1
fix(audio): gate getAudioContext nudge + stereo() honest overloads
obiot May 20, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
122 changes: 37 additions & 85 deletions packages/examples/src/examples/plinko-planck/audio.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,43 +3,33 @@
* Copyright (C) 2011 - 2026 AltByte Pte Ltd — MIT License.
* See `packages/examples/LICENSE.md` for full license + asset credits.
*
* Tiny WebAudio synth — no asset files, no loaders, no licensing.
* Two cues:
* - peg clack: short percussive impulse with pitched variation per hit
* (heard 5-10× per drop, so the variation matters a lot — without it
* a flurry of pegs all sound like one continuous buzz)
* - score chime: tonal note whose pitch climbs with the slot tier so
* a 100-pointer reads louder/higher than a 2-pointer
* Two cues, both built on the engine's `audio.tone` primitive — no
* asset files, no loaders, no licensing:
*
* The AudioContext is created lazily on first use — browsers refuse to
* play audio before a user gesture, and `createGameFn` runs at React
* mount which is *not* a gesture. The first click on the DropZone is.
* - peg clack: short percussive impulse with row-pitched + spatially
* panned variation per hit (heard 5-10× per drop, so the variation
* matters — without it a flurry of pegs all sound like one buzz)
* - score chime: tonal A-minor-pentatonic note (two partials a
* fifth apart) whose pitch climbs with the slot tier and pans
* toward whichever side scored
*
* `audio.tone` shares Howler's WebAudio context behind the scenes, so
* the usual browser autoplay gating applies — the first DropZone click
* (a user gesture) unlocks audio for every subsequent call.
*/

let ctx: AudioContext | null = null;

const getCtx = (): AudioContext | null => {
if (ctx) return ctx;
const AudioCtor =
globalThis.AudioContext ??
(globalThis as unknown as { webkitAudioContext?: typeof AudioContext })
.webkitAudioContext;
if (!AudioCtor) return null;
ctx = new AudioCtor();
return ctx;
};
import { audio } from "melonjs";

/**
* Throttle so a single ball pinging two contacts in the same physics
* tick doesn't double-trigger and clip — peg clacks within this window
* are merged.
* tick doesn't double-trigger — peg clacks within this window are
* merged. Stored in ms.
*/
const CLACK_THROTTLE_MS = 16;
let lastClackAt = 0;

/**
* Short percussive impulse for peg hits. Sine carrier modulated by a
* 5 ms exponential decay envelope.
* Short percussive impulse for peg hits.
*
* @param pan stereo position in `[-1, 1]` — left wall to right wall.
* Cones the clack across the soundfield so a flurry of hits also
Expand All @@ -52,57 +42,38 @@ let lastClackAt = 0;
* balls don't fuse into one continuous buzz.
*/
export const playClack = (pan = 0, pitchHint?: number): void => {
const c = getCtx();
if (!c) return;

const now = c.currentTime * 1000;
const now = performance.now();
if (now - lastClackAt < CLACK_THROTTLE_MS) return;
lastClackAt = now;

const t = c.currentTime;
// Top row (hint = 0) rings at 1400 Hz; bottom row (hint = 1) at
// 700 Hz. No hint → random jitter in the same range.
const freq =
pitchHint !== undefined
? 1400 - pitchHint * 700
: 700 + Math.random() * 700;

const osc = c.createOscillator();
osc.type = "sine";
osc.frequency.setValueAtTime(freq, t);
// Slight downward pitch slide gives the clack a "wood block" feel
// vs. a flat sine pulse.
osc.frequency.exponentialRampToValueAtTime(freq * 0.5, t + 0.08);

const gain = c.createGain();
gain.gain.setValueAtTime(0.08, t);
gain.gain.exponentialRampToValueAtTime(0.001, t + 0.08);

const panner = c.createStereoPanner();
panner.pan.setValueAtTime(Math.max(-1, Math.min(1, pan)), t);

osc.connect(gain).connect(panner).connect(c.destination);
osc.start(t);
osc.stop(t + 0.1);
audio.tone({
freq,
duration: 0.08,
gain: 0.08,
pan,
// Downward pitch slide gives the clack a "wood block" feel
// rather than a flat sine pulse.
pitchSlide: 0.5,
});
};

/**
* Tonal chime for slot landings. Two stacked sine partials (a fifth
* apart) with longer decay; base pitch follows an A-minor pentatonic
* ladder keyed on the slot's score, so the five slot tiers ring out
* as `A4 / C5 / E5 / A5 / E6` — recognisable as a musical scale rather
* than an arbitrary pitch table.
* Tonal chime for slot landings — A-minor-pentatonic across the five
* tiers (A4 / C5 / E5 / A5 / E6) with a perfect-fifth partial for a
* richer "ping". Pan tracks the slot's horizontal position.
*
* @param score the slot's point value (drives base pitch)
* @param pan stereo position in `[-1, 1]` — left slot pans left.
* @param pan stereo position in `[-1, 1]`.
*/
export const playChime = (score: number, pan = 0): void => {
const c = getCtx();
if (!c) return;

const t = c.currentTime;
// Tier-based pitch on A-minor pentatonic: 2 → A4, 5 → C5,
// 10 → E5, 30 → A5, 100 → E6.
// Tier → A-minor pentatonic note.
const base =
score >= 100
? 1320
Expand All @@ -114,29 +85,10 @@ export const playChime = (score: number, pan = 0): void => {
? 523
: 440;

// Fundamental.
const osc1 = c.createOscillator();
osc1.type = "sine";
osc1.frequency.setValueAtTime(base, t);

// Perfect fifth above for a richer "ping" rather than a flat sine.
const osc2 = c.createOscillator();
osc2.type = "sine";
osc2.frequency.setValueAtTime(base * 1.5, t);

const gain = c.createGain();
gain.gain.setValueAtTime(0.18, t);
gain.gain.exponentialRampToValueAtTime(0.001, t + 0.35);

const panner = c.createStereoPanner();
panner.pan.setValueAtTime(Math.max(-1, Math.min(1, pan)), t);

osc1.connect(gain);
osc2.connect(gain);
gain.connect(panner).connect(c.destination);

osc1.start(t);
osc2.start(t);
osc1.stop(t + 0.4);
osc2.stop(t + 0.4);
audio.tone({
freq: [base, base * 1.5],
duration: 0.4,
gain: 0.18,
pan,
});
};
105 changes: 105 additions & 0 deletions packages/examples/src/examples/pool-matter/audio.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,105 @@
/**
* melonJS — Pool (Matter) example: procedural sound effects.
* Copyright (C) 2011 - 2026 AltByte Pte Ltd — MIT License.
* See `packages/examples/LICENSE.md` for full license + asset credits.
*
* Five thin wrappers around `audio.tone` — no asset files, no loaders.
* Each maps a gameplay event to a single envelope-shaped tone:
* - playStrike cue-on-ball thwack on release
* - playBallClick classic billiards click on ball-ball contact
* - playRailBounce softer thud on cushion bounce
* - playPocketDrop two-partial ring on a successful pot
* - playScratch descending fail tone when the cue ball is pocketed
*
* Most wrappers take a normalised `velocity` factor in `[0, 1]` so the
* gain scales with impact intensity, plus a `pan` in `[-1, 1]` so a
* shot on the left rail clacks left and a shot on the right rail
* clacks right — same trick as the plinko peg pans.
*/

import { audio } from "melonjs";
import { VIEWPORT_W } from "./constants";

/**
* Convert a world x-coordinate to a stereo pan in `[-1, 1]`. Centre of
* the table is 0; left edge is -1, right edge is 1.
*/
export const panForX = (x: number): number => {
const normalised = (x / VIEWPORT_W) * 2 - 1;
return Math.max(-1, Math.min(1, normalised));
};

/**
* Normalise a matter body's per-step velocity magnitude into a `[0, 1]`
* factor for gain scaling. Pool ball velocities at full break are
* roughly in the 8–12 px/step range; we cap at 10 so a hard break
* tops out at gain factor 1 and lighter contacts scale linearly down.
*/
export const velocityFactor = (speed: number): number => {
return Math.max(0, Math.min(1, speed / 10));
};

/** Soft wood/leather "thock" when the cue tip strikes the cue ball. */
export const playStrike = (power: number, pan = 0): void => {
audio.tone({
freq: 350,
duration: 0.1,
gain: 0.18 * Math.max(0.2, power),
pan,
pitchSlide: 0.3,
});
};

/**
* Phenolic-resin click on ball-ball contact — two partials (mid body +
* upper bite) with random jitter so a multi-ball break stays granular,
* very short duration so it reads as a dry click without lingering ring.
*/
export const playBallClick = (velocity: number, pan = 0): void => {
const v = velocityFactor(velocity);
if (v < 0.05) return; // skip vanishingly soft taps
const jitter = Math.random();
audio.tone({
freq: [420 + jitter * 80, 820 + jitter * 120],
duration: 0.035,
gain: 0.12 * v,
pan,
pitchSlide: 0.5,
});
};

/** Softer low thud on cushion bounces. Distinguishable from ball-ball
* by frequency band (mid-low vs upper-mid). */
export const playRailBounce = (velocity: number, pan = 0): void => {
const v = velocityFactor(velocity);
if (v < 0.05) return;
audio.tone({
freq: 350,
duration: 0.1,
gain: 0.12 * v,
pan,
pitchSlide: 0.5,
});
};

/** Satisfying two-partial ring when a numbered ball drops in a pocket. */
export const playPocketDrop = (pan = 0): void => {
audio.tone({
freq: [440, 660],
duration: 0.25,
gain: 0.18,
pan,
});
};

/** Descending sawtooth "buzzer" when the cue ball gets pocketed. */
export const playScratch = (pan = 0): void => {
audio.tone({
freq: 400,
duration: 0.4,
wave: "sawtooth",
gain: 0.15,
pan,
pitchSlide: 0.3,
});
};
25 changes: 25 additions & 0 deletions packages/examples/src/examples/pool-matter/entities/ball.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,15 +5,18 @@
*/
import type { MatterAdapter } from "@melonjs/matter-adapter";
import {
type CollisionResponse,
type Container,
collision,
Ellipse,
type PhysicsAdapter,
type Renderable,
type Renderer,
Sprite,
Tween,
Vector2d,
} from "melonjs";
import { panForX, playBallClick, playRailBounce } from "../audio";
import {
BALL_DENSITY,
BALL_FRICTION_AIR_ROLL,
Expand Down Expand Up @@ -177,6 +180,28 @@ export class Ball extends Sprite {
this.alwaysUpdate = true;
}

/**
* Procedural sound on collision: ball-ball gets the upper-mid click,
* ball-rail (anything that's not a Ball and not a pocket sensor) gets
* the lower thud. Gain scales with the receiver's velocity at the
* moment of contact; pan tracks the ball's world x.
*/
override onCollisionStart(
_response: CollisionResponse,
other: Renderable,
): void {
if (this.sinking || !this.body) return;
const v = this.body.getVelocity(restVelScratch);
const speed = Math.sqrt(v.x * v.x + v.y * v.y);
const pan = panForX(this.pos.x + this.width / 2);
if (other instanceof Ball) {
playBallClick(speed, pan);
} else if (other.body?.isSensor !== true) {
// Not a Ball, not a sensor → rail / cushion.
playRailBounce(speed, pan);
}
}

/** Is the ball nearly motionless? Used to gate "can the player strike?" */
isAtRest(): boolean {
if (!this.body) return true;
Expand Down
5 changes: 5 additions & 0 deletions packages/examples/src/examples/pool-matter/entities/cue.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
* See `packages/examples/LICENSE.md` for full license + asset credits.
*/
import { event, input, type Pointer, type Renderer } from "melonjs";
import { panForX, playStrike } from "../audio";
import {
BALL_RADIUS,
MAX_DRAG,
Expand Down Expand Up @@ -143,6 +144,10 @@ export class CueBall extends Ball {
dy /= dist;
const impulse = power * STRIKE_FORCE_SCALE;
this.body.applyImpulse(dx * impulse, dy * impulse);
// Cue strike — pitched-down "thwack" panned to the cue ball's
// current x. Power scales gain, so soft taps are quieter than
// full break shots.
playStrike(power, panForX(cueCenterX));
}

override draw(renderer: Renderer): void {
Expand Down
6 changes: 6 additions & 0 deletions packages/examples/src/examples/pool-matter/entities/pocket.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import {
Tween,
Vector2d,
} from "melonjs";
import { panForX, playPocketDrop, playScratch } from "../audio";
import { gameState } from "../gameState";
import { Ball } from "./ball";
import { CUE_SPAWN_X, CUE_SPAWN_Y, CueBall } from "./cue";
Expand Down Expand Up @@ -132,6 +133,9 @@ export class Pocket extends Renderable {
}

if (other instanceof CueBall) {
// Descending fail tone panned to the pocket where the cue
// went down (not the respawn position).
playScratch(panForX(this.pos.x + this.width / 2));
// respawn cue at head-spot, zero velocity. `setPosition` is an
// adapter call (no body-level equivalent); `setVelocity` is on
// the body and takes primitives, no scratch needed.
Expand All @@ -157,6 +161,8 @@ export class Pocket extends Renderable {
// top-left of the bounds rect with width = height = 2 * radius).
const centerX = this.pos.x + this.width / 2;
const centerY = this.pos.y + this.height / 2;
// Satisfying drop ring, panned to the pocket location.
playPocketDrop(panForX(centerX));
other.startSink(this.adapter, centerX, centerY);
// "+1" pop at the pocket — visible feedback for the score tick.
const parent = this.ancestor as Container | undefined;
Expand Down
Loading
Loading