nice feature: the microwave's sounds playback speed corresponds to the simulation speed, hahahaha

This commit is contained in:
Joeri Exelmans 2025-10-25 22:57:15 +02:00
parent dd82b0433c
commit 710f7be68c
5 changed files with 82 additions and 35 deletions

View file

@ -215,7 +215,14 @@ export function App() {
throw error; // probably a bug in the interpreter throw error; // probably a bug in the interpreter
} }
} }
setTime({kind: "paused", simtime: 0}); setTime(time => {
if (time.kind === "paused") {
return {...time, simtime: 0};
}
else {
return {...time, since: {simtime: 0, wallclktime: performance.now()}};
}
});
scrollDownSidebar(); scrollDownSidebar();
}, [ast, scrollDownSidebar, setTime, setTrace]); }, [ast, scrollDownSidebar, setTime, setTrace]);
@ -399,7 +406,7 @@ export function App() {
flex: '0 0 content', flex: '0 0 content',
overflowY: "auto", overflowY: "auto",
overflowX: "visible", overflowX: "visible",
maxWidth: 'min(400px,50vw)', maxWidth: '50vw',
}}> }}>
<div className="stackVertical" style={{height:'100%'}}> <div className="stackVertical" style={{height:'100%'}}>
<div <div
@ -439,7 +446,11 @@ export function App() {
</select> </select>
{trace !== null && {trace !== null &&
<div>{ <div>{
plant.render(trace.trace[trace.idx].plantState, event => onRaise(event.name, event.param)) plant.render(
trace.trace[trace.idx].plantState,
event => onRaise(event.name, event.param),
time.kind === "paused" ? 0 : time.scale,
)
}</div>} }</div>}
</PersistentDetails> </PersistentDetails>
<details open={showExecutionTrace} onToggle={e => setShowExecutionTrace(e.newState === "open")}><summary>execution trace</summary></details> <details open={showExecutionTrace} onToggle={e => setShowExecutionTrace(e.newState === "open")}><summary>execution trace</summary></details>

View file

@ -18,6 +18,7 @@ import PlayArrowIcon from '@mui/icons-material/PlayArrow';
import SkipNextIcon from '@mui/icons-material/SkipNext'; import SkipNextIcon from '@mui/icons-material/SkipNext';
import StopIcon from '@mui/icons-material/Stop'; import StopIcon from '@mui/icons-material/Stop';
import { InsertModes } from "./TopPanel/InsertModes"; import { InsertModes } from "./TopPanel/InsertModes";
import { usePersistentState } from "@/util/persistent_state";
export type TopPanelProps = { export type TopPanelProps = {
trace: TraceState | null, trace: TraceState | null,
@ -42,7 +43,7 @@ const ShortCutShowKeys = <kbd>~</kbd>;
export const TopPanel = memo(function TopPanel({trace, time, setTime, onUndo, onRedo, onInit, onClear, onBack, insertMode, setInsertMode, setModal, zoom, setZoom, showKeys, setShowKeys, editHistory}: TopPanelProps) { export const TopPanel = memo(function TopPanel({trace, time, setTime, onUndo, onRedo, onInit, onClear, onBack, insertMode, setInsertMode, setModal, zoom, setZoom, showKeys, setShowKeys, editHistory}: TopPanelProps) {
const [displayTime, setDisplayTime] = useState("0.000"); const [displayTime, setDisplayTime] = useState("0.000");
const [timescale, setTimescale] = useState(1); const [timescale, setTimescale] = usePersistentState("timescale", 1);
const config = trace && trace.trace[trace.idx]; const config = trace && trace.trace[trace.idx];
@ -74,7 +75,7 @@ export const TopPanel = memo(function TopPanel({trace, time, setTime, onUndo, on
} }
}); });
updateDisplayedTime(); updateDisplayedTime();
}, [setTime, updateDisplayedTime]); }, [setTime, timescale, updateDisplayedTime]);
const onTimeScaleChange = useCallback((newValue: string, wallclktime: number) => { const onTimeScaleChange = useCallback((newValue: string, wallclktime: number) => {
const asFloat = parseFloat(newValue); const asFloat = parseFloat(newValue);

View file

@ -10,7 +10,7 @@ import sndBell from "./bell.wav";
import sndRunning from "./running.wav"; import sndRunning from "./running.wav";
import { Plant } from "../Plant"; import { Plant } from "../Plant";
import { RaisedEvent } from "@/statecharts/runtime_types"; import { RaisedEvent } from "@/statecharts/runtime_types";
import { useEffect, useRef, useState } from "react"; import { useEffect, useState } from "react";
import "./Microwave.css"; import "./Microwave.css";
@ -40,6 +40,7 @@ export type MicrowaveState = {
export type MicrowaveProps = { export type MicrowaveProps = {
state: MicrowaveState, state: MicrowaveState,
speed: number,
callbacks: { callbacks: {
startPressed: () => void; startPressed: () => void;
stopPressed: () => void; stopPressed: () => void;
@ -92,41 +93,75 @@ function fetchAudioBuffer(url: string): Promise<AudioBuffer> {
}); });
} }
function playAudioBufer(buf: AudioBuffer, loop: boolean) { // 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(); const src = ctx.createBufferSource();
src.buffer = buf; src.buffer = buf;
src.connect(ctx.destination);
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; if (loop) src.loop = true;
src.start(); src.start();
return () => src.stop(); 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;
},
];
} }
export function Magnetron({state: {timeDisplay, bell, magnetron}, callbacks}: MicrowaveProps) { type AudioCallbacks = [
() => void,
(speed: number) => void,
];
export function Magnetron({state: {timeDisplay, bell, magnetron}, speed, callbacks}: MicrowaveProps) {
const [door, setDoor] = useState<DoorState>("closed"); const [door, setDoor] = useState<DoorState>("closed");
const bufRunningPromise = useRef(fetchAudioBuffer(sndRunning)); const [soundsPlaying, setSoundsPlaying] = useState<AudioCallbacks[]>([]);
const bufBellPromise = useRef(fetchAudioBuffer(sndBell)); 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... // a bit hacky: when the bell-state changes to true, we play the bell sound...
useEffect(() => { useEffect(() => {
if (bell) { if (bell) {
bufBellPromise.current.then(buf => { bufBellPromise.then(buf => {
playAudioBufer(buf, false); const cbs = playAudioBufer(buf, false, speed);
}) setSoundsPlaying(sounds => [...sounds, cbs]);
});
} }
}, [bell]); }, [bell]);
useEffect(() => { useEffect(() => {
if (magnetron === "on") { if (magnetron === "on") {
const stop = bufRunningPromise.current.then(buf => { const stop = bufRunningPromise.then(buf => {
return playAudioBufer(buf, true); 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 () => stop.then(stop => stop());
} }
return () => {}; return () => {};
}, [magnetron]) }, [magnetron])
preload(imgSmallClosedOff, {as: "image"}); useEffect(() => {
soundsPlaying.forEach(([_, setSpeed]) => setSpeed(speed));
}, [soundsPlaying, speed])
// preload(imgSmallClosedOff, {as: "image"});
preload(imgSmallClosedOn, {as: "image"}); preload(imgSmallClosedOn, {as: "image"});
preload(imgSmallOpenedOff, {as: "image"}); preload(imgSmallOpenedOff, {as: "image"});
preload(imgSmallOpenedOn, {as: "image"}); preload(imgSmallOpenedOn, {as: "image"});
@ -147,7 +182,7 @@ export function Magnetron({state: {timeDisplay, bell, magnetron}, callbacks}: Mi
src: url(${fontDigital}); src: url(${fontDigital});
} }
`}</style> `}</style>
<svg style={{maxWidth: 520}} width='100%' height='auto' viewBox="0 0 520 348"> <svg width='400px' height='auto' viewBox="0 0 520 348">
<image xlinkHref={imgs[door][magnetron]} width={520} height={348}/> <image xlinkHref={imgs[door][magnetron]} width={520} height={348}/>
<rect className="microwaveButtonHelper" x={START_X0} y={START_Y0} width={BUTTON_WIDTH} height={BUTTON_HEIGHT} <rect className="microwaveButtonHelper" x={START_X0} y={START_Y0} width={BUTTON_WIDTH} height={BUTTON_HEIGHT}
@ -188,7 +223,7 @@ export const MicrowavePlant: Plant<MicrowaveState> = {
} }
return state; // unknown event - ignore it return state; // unknown event - ignore it
}, },
render: (state, raiseEvent) => <Magnetron state={state} callbacks={{ render: (state, raiseEvent, speed) => <Magnetron state={state} speed={speed} callbacks={{
startPressed: () => raiseEvent({name: "startPressed"}), startPressed: () => raiseEvent({name: "startPressed"}),
stopPressed: () => raiseEvent({name: "stopPressed"}), stopPressed: () => raiseEvent({name: "stopPressed"}),
incTimePressed: () => raiseEvent({name: "incTimePressed"}), incTimePressed: () => raiseEvent({name: "incTimePressed"}),

View file

@ -8,5 +8,5 @@ export type Plant<StateType> = {
initial: StateType; initial: StateType;
reduce: (inputEvent: RaisedEvent, state: StateType) => StateType; reduce: (inputEvent: RaisedEvent, state: StateType) => StateType;
render: (state: StateType, raise: (event: RaisedEvent) => void) => ReactElement; render: (state: StateType, raise: (event: RaisedEvent) => void, timescale: number) => ReactElement;
} }

View file

@ -72,7 +72,7 @@ export function execAction(action: Action, rt: ActionScope): ActionScope {
} }
export function entryActions(simtime: number, state: TransitionSrcTgt, actionScope: ActionScope): ActionScope { export function entryActions(simtime: number, state: TransitionSrcTgt, actionScope: ActionScope): ActionScope {
console.log('enter', stateDescription(state), '...'); // console.log('enter', stateDescription(state), '...');
let {environment, ...rest} = actionScope; let {environment, ...rest} = actionScope;
@ -99,7 +99,7 @@ export function entryActions(simtime: number, state: TransitionSrcTgt, actionSco
} }
export function exitActions(simtime: number, state: TransitionSrcTgt, {enteredStates, ...actionScope}: EnteredScope): ActionScope { export function exitActions(simtime: number, state: TransitionSrcTgt, {enteredStates, ...actionScope}: EnteredScope): ActionScope {
console.log('exit', stateDescription(state), '...'); // console.log('exit', stateDescription(state), '...');
for (const action of state.exitActions) { for (const action of state.exitActions) {
(actionScope = execAction(action, actionScope)); (actionScope = execAction(action, actionScope));
@ -194,7 +194,7 @@ export function enterStates(simtime: number, state: ConcreteState, toEnter: Set<
// exit the given state and all its active descendants // exit the given state and all its active descendants
export function exitCurrent(simtime: number, state: ConcreteState, rt: EnteredScope): ActionScope { export function exitCurrent(simtime: number, state: ConcreteState, rt: EnteredScope): ActionScope {
console.log('exitCurrent', stateDescription(state)); // console.log('exitCurrent', stateDescription(state));
let {enteredStates, history, ...actionScope} = rt; let {enteredStates, history, ...actionScope} = rt;
if (enteredStates.has(state.uid)) { if (enteredStates.has(state.uid)) {
@ -241,7 +241,7 @@ function allowedToFire(arena: OrState, alreadyFiredArenas: OrState[]) {
} }
function attemptSrcState(simtime: number, sourceState: AbstractState, event: RT_Event|undefined, statechart: Statechart, {environment, mode, arenasFired, ...rest}: RT_Statechart & RaisedEvents): (RT_Statechart & RaisedEvents) | undefined { function attemptSrcState(simtime: number, sourceState: AbstractState, event: RT_Event|undefined, statechart: Statechart, {environment, mode, arenasFired, ...rest}: RT_Statechart & RaisedEvents): (RT_Statechart & RaisedEvents) | undefined {
console.log('attemptSrcState', stateDescription(sourceState), arenasFired); // console.log('attemptSrcState', stateDescription(sourceState), arenasFired);
const outgoing = statechart.transitions.get(sourceState.uid) || []; const outgoing = statechart.transitions.get(sourceState.uid) || [];
const labels = outgoing.flatMap(t => const labels = outgoing.flatMap(t =>
t.label t.label
@ -295,7 +295,7 @@ function attemptSrcState(simtime: number, sourceState: AbstractState, event: RT_
for (const activeState of mode) { for (const activeState of mode) {
const s = statechart.uid2State.get(activeState); const s = statechart.uid2State.get(activeState);
if (s?.kind === "pseudo") { if (s?.kind === "pseudo") {
console.log('fire pseudo-state...'); // console.log('fire pseudo-state...');
const newConfig = attemptSrcState(simtime, s, undefined, statechart, {environment, mode, arenasFired: [], ...rest}); const newConfig = attemptSrcState(simtime, s, undefined, statechart, {environment, mode, arenasFired: [], ...rest});
if (newConfig === undefined) { if (newConfig === undefined) {
throw new RuntimeError("Stuck in choice-state.", [activeState]); throw new RuntimeError("Stuck in choice-state.", [activeState]);
@ -311,7 +311,7 @@ function attemptSrcState(simtime: number, sourceState: AbstractState, event: RT_
// A fair step is a response to one (input|internal) event, where possibly multiple transitions are made as long as their arenas do not overlap. A reasonably accurate and more intuitive explanation is that every orthogonal region is allowed to fire at most one transition. // A fair step is a response to one (input|internal) event, where possibly multiple transitions are made as long as their arenas do not overlap. A reasonably accurate and more intuitive explanation is that every orthogonal region is allowed to fire at most one transition.
export function fairStep(simtime: number, event: RT_Event, statechart: Statechart, activeParent: StableState, {arenasFired, ...config}: RT_Statechart & RaisedEvents): RT_Statechart & RaisedEvents { export function fairStep(simtime: number, event: RT_Event, statechart: Statechart, activeParent: StableState, {arenasFired, ...config}: RT_Statechart & RaisedEvents): RT_Statechart & RaisedEvents {
console.log('fairStep', arenasFired); // console.log('fairStep', arenasFired);
for (const state of activeParent.children) { for (const state of activeParent.children) {
if (config.mode.has(state.uid)) { if (config.mode.has(state.uid)) {
const didFire = attemptSrcState(simtime, state, event, statechart, {...config, arenasFired}); const didFire = attemptSrcState(simtime, state, event, statechart, {...config, arenasFired});
@ -320,7 +320,7 @@ export function fairStep(simtime: number, event: RT_Event, statechart: Statechar
} }
else { else {
// no enabled outgoing transitions, try the children: // no enabled outgoing transitions, try the children:
console.log('attempt children'); // console.log('attempt children');
({arenasFired, ...config} = fairStep(simtime, event, statechart, state, {...config, arenasFired})); ({arenasFired, ...config} = fairStep(simtime, event, statechart, state, {...config, arenasFired}));
} }
} }
@ -358,11 +358,11 @@ function resolveHistory(tgt: AbstractState, history: RT_History): Set<string> {
} }
export function fire(simtime: number, t: Transition, ts: Map<string, Transition[]>, label: TransitionLabel, arena: OrState, {mode, environment, history, ...rest}: RT_Statechart & RaisedEvents): RT_Statechart & RaisedEvents { export function fire(simtime: number, t: Transition, ts: Map<string, Transition[]>, label: TransitionLabel, arena: OrState, {mode, environment, history, ...rest}: RT_Statechart & RaisedEvents): RT_Statechart & RaisedEvents {
console.log('will now fire', transitionDescription(t), 'arena', arena); // console.log('will now fire', transitionDescription(t), 'arena', arena);
const srcPath = computePath({ancestor: arena, descendant: t.src as ConcreteState}) as ConcreteState[]; const srcPath = computePath({ancestor: arena, descendant: t.src as ConcreteState}) as ConcreteState[];
console.log(srcPath); // console.log(srcPath);
// console.log('arena:', arena, 'srcPath:', srcPath); // console.log('arena:', arena, 'srcPath:', srcPath);
// exit src and other states up to arena // exit src and other states up to arena
@ -371,8 +371,8 @@ export function fire(simtime: number, t: Transition, ts: Map<string, Transition[
toExit.delete(arena.uid); // do not exit the arena itself toExit.delete(arena.uid); // do not exit the arena itself
const exitedMode = mode.difference(toExit); // active states after exiting const exitedMode = mode.difference(toExit); // active states after exiting
console.log('toExit', toExit); // console.log('toExit', toExit);
console.log('exitedMode', exitedMode); // console.log('exitedMode', exitedMode);
// transition actions // transition actions
for (const action of label.actions) { for (const action of label.actions) {
@ -388,9 +388,9 @@ export function fire(simtime: number, t: Transition, ts: Map<string, Transition[
({enteredStates, environment, history, ...rest} = enterStates(simtime, state, toEnter, {environment, history, ...rest})); ({enteredStates, environment, history, ...rest} = enterStates(simtime, state, toEnter, {environment, history, ...rest}));
const enteredMode = exitedMode.union(enteredStates); const enteredMode = exitedMode.union(enteredStates);
console.log('new mode', enteredMode); // console.log('new mode', enteredMode);
console.log('done firing', transitionDescription(t)); // console.log('done firing', transitionDescription(t));
return {mode: enteredMode, environment, history, ...rest}; return {mode: enteredMode, environment, history, ...rest};
} }