Ambient Sound

Ambient Sound for BookNest's Bookstore

A shop is never silent. bookstore.js gains createAmbience(scene, camera): a computed room tone of brown noise and a clock ticking on the left bookcase, both on an ambience bus, heard from the shopper's camera:

bookstore.js: room tone and a ticking clockJavaScript
async function createAmbience(scene, camera) {     // Section 7.19.4: room tone and a clock
  const audio = await BABYLON.CreateAudioEngineAsync();   // unmute button until a gesture
  const rate = 48000;
  const buffer = (seconds, sample) => {             // an AudioBuffer from a function of time
    const data = new Float32Array(rate * seconds).map((_, i) => sample(i / rate));
    const result = new AudioBuffer({ length: data.length, sampleRate: rate });
    result.copyToChannel(data, 0);
    return result;
  };
  let level = 0;                                    // brown noise: a soft, low room tone
  const hum = buffer(4, () => (level = (level + 0.02 * (Math.random() * 2 - 1)) / 1.02) * 3.5);
  const tick = buffer(1, (t) => Math.sin(2 * Math.PI * 2000 * t) * Math.exp(-400 * t));
  const ambience = await BABYLON.CreateAudioBusAsync('ambience', { volume: 0.4 });
  const options = { loop: true, autoplay: true, outBus: ambience };   // start once unlocked
  const roomTone = await BABYLON.CreateSoundAsync('roomTone', hum, options);
  const clock = await BABYLON.CreateSoundAsync('clock', tick,
    { ...options, spatialEnabled: true });           // one tick a second, placed in the room
  clock.spatial.attach(scene.getMeshByName('bookcaseLeft'));   // on top of the left case
  audio.listener.attach(camera);                    // hear from the shopper's position
  return { audio, ambience, roomTone, clock };
}

Both loops use autoplay, so they start on the first click. Headless under Playwright 26,280 , the page loaded with the engine suspended and both sounds Starting; a scripted mouse click, which Chrome 1 treats as a real gesture, set the engine running and the ambience bus's analyzer then showed signal, though both state getters still read Starting (a one-shot play() after the unlock reports Started, then Stopped). A headless capture records pixels, not sound, so none of it is heard. Brown noise (each sample a small step from the last) hums where white noise would hiss. Keep ambience quiet and offer a mute control: sound that starts on a click surprises people who clicked for something else.

BookNest's ambience: a computed brown-noise room tone and a spatial clock ticking on the left bookcaseHTMLLive
<!doctype html>
<style>
  body { margin: 0; background: #f7f3ec; font: 13px system-ui, sans-serif; color: #333; }
  canvas { width: 100%; max-width: 600px; height: 320px; display: block; touch-action: none; outline: none; }
  #bar { max-width: 600px; display: flex; gap: 10px; align-items: center; padding: 6px 8px; } button { font: inherit; padding: 3px 10px; } #info { font-family: monospace; font-size: 12px; }
</style>
<canvas id="view"></canvas>
<div id="bar"><button id="sound">Sound on</button><span id="info"></span></div>
<script src="https://cdn.jsdelivr.net/npm/babylonjs@9.28.0/babylon.js"></script>
<script>
// ---- BookNest's bookstore: the helpers the book keeps in bookstore.js ----
const ROOM = { width: 8, depth: 6, height: 3, wall: 0.15 };
const BOOKS = [
  { id: 1, title: 'The Quiet Harbor', author: 'Mara Ellison', color: '#1f5f8b', pages: 312, price: 14.99, rating: 4.6, inStock: true },
  { id: 2, title: 'Patterns of the Deep Web', author: 'Tomas Reyes', color: '#5b3f99', pages: 428, price: 32.5, rating: 4.4, inStock: true },
  { id: 3, title: 'Salt and Saffron', author: 'Priya Nair', color: '#e09a10', pages: 256, price: 24, rating: 4.8, inStock: false },
  { id: 4, title: 'Small Steps to Big Summits', author: 'Dana Whitfield', color: '#3f7d3a', pages: 198, price: 18.5, rating: 4.3, inStock: true },
  { id: 5, title: "The Clockmaker's Paradox", author: 'Julian Voss', color: '#b5452f', pages: 344, price: 16.75, rating: 4.7, inStock: true },
  { id: 6, title: 'Gardens in Glass', author: 'Lena Hart', color: '#2a9d8f', pages: 176, price: 21.3, rating: 4.5, inStock: true },
];
function createScene(engine) {
  const scene = new BABYLON.Scene(engine);
  scene.clearColor = BABYLON.Color4.FromHexString('#cfd8dcff');
  const sky = new BABYLON.HemisphericLight('sky', new BABYLON.Vector3(0.3, 1, -0.4), scene);
  sky.groundColor = new BABYLON.Color3(0.35, 0.32, 0.3);
  return scene;
}
function paint(scene, name, hex) {                  // a matte color
  const material = new BABYLON.StandardMaterial(name, scene);
  material.diffuseColor = BABYLON.Color3.FromHexString(hex);
  material.specularColor = BABYLON.Color3.Black();
  return material;
}
function createRoom(scene) {                        // floor and three walls, front open
  const room = new BABYLON.TransformNode('room', scene);
  const floor = BABYLON.MeshBuilder.CreateGround('floor', { width: ROOM.width, height: ROOM.depth }, scene);
  floor.material = paint(scene, 'oak', '#b08d6a');
  floor.parent = room;
  const plaster = paint(scene, 'plaster', '#fff3e0');
  for (const [name, width, x, z] of [['backWall', 8, 0, 3], ['leftWall', 6, -4, 0], ['rightWall', 6, 4, 0]]) {
    const wall = BABYLON.MeshBuilder.CreateBox(name, { width, height: ROOM.height, depth: ROOM.wall }, scene);
    wall.position.set(x, ROOM.height / 2, z);
    wall.rotation.y = x ? Math.PI / 2 : 0;
    wall.material = plaster;
    wall.parent = room;
  }
  return room;
}
function createBookcase(scene, name, x) {           // 8 boxes merged into 1 mesh
  const [w, h, d, t] = [3, 2.2, 0.35, 0.04];
  const parts = [[t, h, d, -w / 2, h / 2, 0], [t, h, d, w / 2, h / 2, 0], [w, h, t, 0, h / 2, d / 2 - t / 2]];
  for (let i = 0; i < 5; i++) parts.push([w, t, d, 0, 0.1 + i * 0.5, 0]);
  const bookcase = BABYLON.Mesh.MergeMeshes(parts.map(([width, height, depth, px, py, pz]) => {
    const part = BABYLON.MeshBuilder.CreateBox('part', { width, height, depth }, scene);
    part.position.set(px, py, pz);
    return part;
  }), true);
  bookcase.name = name;
  bookcase.material = paint(scene, 'walnut', '#6d4c33');
  bookcase.position.set(x, 0, ROOM.depth / 2 - ROOM.wall / 2 - d / 2);   // against the back wall
  return bookcase;
}
function drawCover(scene, book) {                   // cover art painted on a canvas
  const tex = new BABYLON.DynamicTexture('cover' + book.id, { width: 256, height: 384 }, scene);
  const c = tex.getContext();
  c.fillStyle = book.color; c.fillRect(0, 0, 256, 384);
  c.fillStyle = 'rgba(0,0,0,0.4)'; c.fillRect(0, 0, 18, 384);         // the dark left edge
  c.strokeStyle = '#fff8e1'; c.lineWidth = 3; c.strokeRect(34, 24, 202, 336);
  c.fillStyle = '#fff8e1'; c.textAlign = 'center'; c.font = 'bold 30px Georgia';
  const lines = [''];
  for (const word of book.title.split(' ')) {
    const test = (lines[lines.length - 1] + ' ' + word).trim();
    if (c.measureText(test).width > 180) lines.push(word); else lines[lines.length - 1] = test;
  }
  lines.forEach((line, i) => c.fillText(line, 135, 110 + i * 38));
  c.font = '20px Georgia'; c.fillText(book.author, 135, 330);
  tex.update();
  return tex;
}
function placeBooks(scene, bookcase, shelf = 2) {   // six books, spines out, covers on +x
  const strip = new BABYLON.Vector4(0, 0, 0.07, 1);
  const faceUV = [strip, strip, new BABYLON.Vector4(0, 0, 1, 1), strip, strip, strip];
  let x = -0.35;                                    // left end of the row, local to the bookcase
  return BOOKS.map((record) => {
    const width = record.pages / 4000 + 0.02;       // spine thickness from page count
    const book = BABYLON.MeshBuilder.CreateBox(`book${record.id}`, { width, height: 0.3, depth: 0.22, faceUV, wrap: true }, scene);
    book.parent = bookcase;
    book.position.set(x + width / 2, 0.12 + shelf * 0.5 + 0.15, -0.02);
    x += width + 0.012;
    book.material = new BABYLON.StandardMaterial(record.title, scene);
    book.material.diffuseTexture = drawCover(scene, record);
    book.material.specularColor = new BABYLON.Color3(0.15, 0.15, 0.15);
    book.metadata = record;                         // the record, for picking
    return book;
  });
}
// The book cuts the storefront with CSG2 (Manifold WebAssembly); this sandbox cannot download
// the .wasm, so the older pure-JavaScript BABYLON.CSG makes the same cuts here.
function createFrontWall(scene) {
  const box = (width, height, depth, x, y) => {
    const mesh = BABYLON.MeshBuilder.CreateBox('cut', { width, height, depth }, scene);
    mesh.position.set(x, y, -ROOM.depth / 2);
    return mesh;
  };
  const solid = box(ROOM.width, ROOM.height, ROOM.wall, 0, ROOM.height / 2);
  const windowHole = box(3.2, 1.6, 1, -1.4, 1.6), doorHole = box(1.1, 2.2, 1, 2.3, 1.1);
  const wall = BABYLON.CSG.FromMesh(solid).subtract(BABYLON.CSG.FromMesh(windowHole))
    .subtract(BABYLON.CSG.FromMesh(doorHole)).toMesh('frontWall', scene.getMaterialByName('plaster'), scene);
  [solid, windowHole, doorHole].forEach((mesh) => mesh.dispose());
  return wall;
}
// ---- end of the bookstore helpers ----

function lightRoom(scene) {                         // call after the meshes exist
  scene.getLightByName('sky').intensity = 0.5;      // the sky light becomes fill light
  const sun = new BABYLON.DirectionalLight('sun', new BABYLON.Vector3(0.2, -0.33, 1), scene);
  sun.intensity = 0.8;
  sun.diffuse = BABYLON.Color3.FromHexString('#fff1d6');   // warm afternoon sun
  const ceiling = BABYLON.MeshBuilder.CreateBox('ceiling', { width: ROOM.width, height: ROOM.wall, depth: ROOM.depth }, scene);
  ceiling.position.y = ROOM.height + ROOM.wall / 2;
  ceiling.layerMask = 0x10000000;                   // no camera draws it; it still casts
  const shadows = new BABYLON.CascadedShadowGenerator(2048, sun);
  Object.assign(shadows, { numCascades: 3, shadowMaxZ: 20, lambda: 1,
    autoCalcDepthBounds: true, bias: 0.002, normalBias: 0.01 });
  for (const mesh of scene.meshes) {
    if (mesh.name !== 'floor') shadows.addShadowCaster(mesh);
    mesh.receiveShadows = true;
  }
  const lamp = new BABYLON.PointLight('lamp', new BABYLON.Vector3(-1.2, 2.3, 1.4), scene);
  lamp.diffuse = BABYLON.Color3.FromHexString('#ffd9a0');
  Object.assign(lamp, { intensity: 0.7, range: 5 });   // a warm reading lamp
  return { sun, lamp, shadows };
}

async function createAmbience(scene, camera) {     // room tone and a ticking clock
  const audio = await BABYLON.CreateAudioEngineAsync({ disableDefaultUI: true });   // our own button unlocks it
  const rate = 48000;
  const buffer = (seconds, sample) => {             // an AudioBuffer from a function of time
    const data = new Float32Array(rate * seconds).map((_, i) => sample(i / rate));
    const result = new AudioBuffer({ length: data.length, sampleRate: rate });
    result.copyToChannel(data, 0);
    return result;
  };
  let level = 0;                                    // brown noise: a soft, low room tone
  const hum = buffer(4, () => (level = (level + 0.02 * (Math.random() * 2 - 1)) / 1.02) * 3.5);
  const tick = buffer(1, (t) => Math.sin(2 * Math.PI * 2000 * t) * Math.exp(-400 * t));
  const ambience = await BABYLON.CreateAudioBusAsync('ambience', { volume: 0.4 });
  const options = { loop: true, autoplay: true, outBus: ambience };   // start once unlocked
  const roomTone = await BABYLON.CreateSoundAsync('roomTone', hum, options);
  const clock = await BABYLON.CreateSoundAsync('clock', tick,
    { ...options, spatialEnabled: true });           // one tick a second, placed in the room
  clock.spatial.attach(scene.getMeshByName('bookcaseLeft'));   // on the left case
  audio.listener.attach(camera);                    // hear from the shopper's position
  return { audio, ambience, roomTone, clock };
}

const canvas = document.getElementById('view');
const engine = new BABYLON.Engine(canvas, true, {}, true);
const scene = createScene(engine);
createRoom(scene);
const left = createBookcase(scene, 'bookcaseLeft', -1.8);
placeBooks(scene, left);
createBookcase(scene, 'bookcaseRight', 1.8);
// A mantel clock on top of the left bookcase: where the ticking comes from
const clock = BABYLON.MeshBuilder.CreateCylinder('clockFace', { height: 0.08, diameter: 0.35 }, scene);
clock.rotation.x = Math.PI / 2;
clock.position.set(-1.8, 2.42, 2.7);
clock.material = paint(scene, 'clockFaceMat', '#fff8e1');
const hand = BABYLON.MeshBuilder.CreateBox('hand', { width: 0.015, height: 0.14, depth: 0.01 }, scene);
hand.setPivotPoint(new BABYLON.Vector3(0, -0.07, 0));
hand.position.set(-1.8, 2.49, 2.65);
hand.material = paint(scene, 'handMat', '#222222');
lightRoom(scene);
const camera = new BABYLON.UniversalCamera('walk', new BABYLON.Vector3(0.5, 1.6, -2), scene);
camera.setTarget(new BABYLON.Vector3(-1, 1.5, 3));
camera.minZ = 0.05;
camera.attachControl(canvas, true);

// Rings spreading from the clock once a second, like its ticks
const ring = BABYLON.MeshBuilder.CreateTorus('tickRing', { diameter: 0.4, thickness: 0.01, tessellation: 48 }, scene);
ring.rotation.x = Math.PI / 2;
ring.position.copyFrom(clock.position);
ring.material = new BABYLON.StandardMaterial('ringMat', scene);
ring.material.emissiveColor = BABYLON.Color3.FromHexString('#e09a10');
scene.onBeforeRenderObservable.add(() => {
  const t = (performance.now() / 1000) % 1;
  ring.scaling.setAll(1 + t * 3);
  ring.visibility = 1 - t;
  hand.rotation.z = -Math.floor(performance.now() / 1000) * Math.PI / 30;   // a tick a second
});

const info = document.getElementById('info');
createAmbience(scene, camera).then(({ audio, ambience, roomTone, clock: tick }) => {
  const report = () => { info.textContent = `engine ${audio.state}, ambience volume ${ambience.volume.toFixed(2)}`; };
  report();
  const button = document.getElementById('sound');
  let on = false;
  button.addEventListener('click', async () => {    // a mute control, and the unlocking gesture
    await audio.unlockAsync();
    on = !on;
    ambience.setVolume(on ? 0.4 : 0, { duration: 0.5 });   // fade
    if (on && roomTone.state !== BABYLON.SoundState.Started) { roomTone.play(); tick.play(); }
    button.textContent = on ? 'Sound off' : 'Sound on';
    setTimeout(report, 600);
  });
}).catch((error) => { info.textContent = `audio unavailable: ${error.message}`; });
engine.runRenderLoop(() => scene.render());
addEventListener('resize', () => engine.resize());
</script>