From 08dc096792f39328ec50e86cb12a19b2a4bc1e91 Mon Sep 17 00:00:00 2001 From: Joeri Exelmans Date: Sun, 26 Oct 2025 12:46:05 +0100 Subject: [PATCH] wrap pausable / play-rate-able audio in a React hook --- src/App/Plant/Microwave/Microwave.tsx | 108 ++++----------------- src/App/Plant/Microwave/useAudioContext.ts | 58 +++++++++++ 2 files changed, 79 insertions(+), 87 deletions(-) create mode 100644 src/App/Plant/Microwave/useAudioContext.ts diff --git a/src/App/Plant/Microwave/Microwave.tsx b/src/App/Plant/Microwave/Microwave.tsx index e3cf8f7..f3a3026 100644 --- a/src/App/Plant/Microwave/Microwave.tsx +++ b/src/App/Plant/Microwave/Microwave.tsx @@ -13,6 +13,7 @@ import { RaisedEvent } from "@/statecharts/runtime_types"; import { useEffect, useState } from "react"; import "./Microwave.css"; +import { useAudioContext } from "./useAudioContext"; export type MagnetronState = "on" | "off"; export type DoorState = "open" | "closed"; @@ -56,116 +57,49 @@ const imgs = { open: { off: imgSmallOpenedOff, on: imgSmallOpenedOn }, } - const BUTTON_HEIGHT = 18; const BUTTON_WIDTH = 60; - const BUTTON_X0 = 412; -const BUTTON_X1 = BUTTON_X0 + BUTTON_WIDTH; - const START_X0 = BUTTON_X0; const START_Y0 = 234; -const START_X1 = BUTTON_X1; -const START_Y1 = START_Y0 + BUTTON_HEIGHT; - const STOP_X0 = BUTTON_X0; const STOP_Y0 = 211; -const STOP_X1 = BUTTON_X1; -const STOP_Y1 = STOP_Y0 + BUTTON_HEIGHT; - const INCTIME_X0 = BUTTON_X0; const INCTIME_Y0 = 188; -const INCTIME_X1 = BUTTON_X1; -const INCTIME_Y1 = INCTIME_Y0 + BUTTON_HEIGHT; - const DOOR_X0 = 26; const DOOR_Y0 = 68; const DOOR_WIDTH = 353; const DOOR_HEIGHT = 217; -const ctx = new AudioContext(); - -function fetchAudioBuffer(url: string): Promise { - return fetch(url).then(res => { - return res.arrayBuffer(); - }).then(buf => { - return ctx.decodeAudioData(buf); - }); -} - -// Using the Web Audio API was the only way I could get the 'microwave running' sound to properly play gapless in Chrome. -function playAudioBufer(buf: AudioBuffer, loop: boolean, speed: number): AudioCallbacks { - const src = ctx.createBufferSource(); - src.buffer = buf; - - const lowPass = ctx.createBiquadFilter(); - lowPass.type = 'highpass'; - lowPass.frequency.value = 20; // let's not blow up anyone's speakers - - src.connect(lowPass); - lowPass.connect(ctx.destination); - - if (loop) src.loop = true; - src.start(); - return [ - () => src.stop(), - (speed: number) => { - // instead of setting playback rate to 0 (which browsers seem to handle as if playback rate was set to 1, we just set it to a very small value, making it "almost paused") - // combined with the lowpass filter above, this should produce any audible results. - src.playbackRate.value = (speed===0) ? 0.00001 : speed; - return; - }, - ]; -} - -type AudioCallbacks = [ - () => void, - (speed: number) => void, -]; - export function Magnetron({state: {timeDisplay, bell, magnetron}, speed, callbacks}: MicrowaveProps) { const [door, setDoor] = useState("closed"); - const [soundsPlaying, setSoundsPlaying] = useState([]); - const [bufRunningPromise] = useState(() => fetchAudioBuffer(sndRunning)); - const [bufBellPromise] = useState(() => fetchAudioBuffer(sndBell)); - - // a bit hacky: when the bell-state changes to true, we play the bell sound... - useEffect(() => { - if (bell) { - bufBellPromise.then(buf => { - const cbs = playAudioBufer(buf, false, speed); - setSoundsPlaying(sounds => [...sounds, cbs]); - }); - } - }, [bell]); - - useEffect(() => { - if (magnetron === "on") { - const stop = bufRunningPromise.then(buf => { - const cbs = playAudioBufer(buf, true, speed); - setSoundsPlaying(sounds => [...sounds, cbs]); - return () => { - cbs[0](); - setSoundsPlaying(sounds => sounds.filter(cbs_ => cbs_ !== cbs)); - } - }); - return () => stop.then(stop => { - stop(); - }); - } - return () => {}; - }, [magnetron]) - - useEffect(() => { - soundsPlaying.forEach(([_, setSpeed]) => setSpeed(speed)); - }, [soundsPlaying, speed]) + const [playSound, preloadAudio] = useAudioContext(speed); // preload(imgSmallClosedOff, {as: "image"}); preload(imgSmallClosedOn, {as: "image"}); preload(imgSmallOpenedOff, {as: "image"}); preload(imgSmallOpenedOn, {as: "image"}); + preloadAudio(sndRunning); + preloadAudio(sndBell); + + // a bit hacky: when the bell-state changes to true, we play the bell sound... + useEffect(() => { + if (bell) { + playSound(sndBell, false); + } + }, [bell]); + + useEffect(() => { + if (magnetron === "on") { + const stopSoundRunning = playSound(sndRunning, true); + return () => stopSoundRunning(); + } + return () => {}; + }, [magnetron]) + + const openDoor = () => { setDoor("open"); callbacks.doorOpened(); diff --git a/src/App/Plant/Microwave/useAudioContext.ts b/src/App/Plant/Microwave/useAudioContext.ts new file mode 100644 index 0000000..12c51d4 --- /dev/null +++ b/src/App/Plant/Microwave/useAudioContext.ts @@ -0,0 +1,58 @@ +import { memoize } from "@/util/util"; +import { useEffect, useState } from "react"; + + +// I was trying to get the 'microwave running' sound to play gapless on Chrome, and the Web Audio API turned out to be the only thing that worked properly. It has some nice bonus features as well, such as setting the playback rate, and audio filters. + +// The result is a simple Web Audio API wrapper for React: + +export function useAudioContext(speed: number) { + const [{ctx,hipass}] = useState(() => { + const ctx = new AudioContext(); + const hipass = ctx.createBiquadFilter(); + hipass.type = 'highpass'; + hipass.frequency.value = 20; // let's not blow up anyone's speakers + hipass.connect(ctx.destination); + return { + ctx, + hipass, + } + }); + const [sounds, setSounds] = useState([]); + + const url2AudioBuf: (url:string) => Promise = memoize((url: string) => { + return fetch(url) + .then(res => res.arrayBuffer()) + .then(buf => ctx.decodeAudioData(buf)); + }); + + function play(url: string, loop: boolean) { + const srcPromise = url2AudioBuf(url) + .then(audioBuf => { + const src = ctx.createBufferSource(); + src.buffer = audioBuf; + src.connect(hipass); + src.playbackRate.value = speed; + src.loop = loop; + src.start(); + setSounds(sounds => [...sounds, src]); + return src; + }); + // return callback to stop playing + return () => srcPromise.then(src => src.stop()); + } + + useEffect(() => { + if (speed !== 0) { + sounds.forEach(src => { + src.playbackRate.value = speed; + }); + ctx.resume(); + } + else { + ctx.suspend(); + } + }, [speed]); + + return [play, url2AudioBuf] as [(url: string, loop: boolean) => ()=>void, (url:string)=>void]; +}