diff --git a/src/App/App.tsx b/src/App/App.tsx index e06bc1a..385a742 100644 --- a/src/App/App.tsx +++ b/src/App/App.tsx @@ -215,7 +215,14 @@ export function App() { 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(); }, [ast, scrollDownSidebar, setTime, setTrace]); @@ -399,7 +406,7 @@ export function App() { flex: '0 0 content', overflowY: "auto", overflowX: "visible", - maxWidth: 'min(400px,50vw)', + maxWidth: '50vw', }}>
{trace !== null &&
{ - 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, + ) }
}
setShowExecutionTrace(e.newState === "open")}>execution trace
diff --git a/src/App/TopPanel.tsx b/src/App/TopPanel.tsx index b6d69e2..1b78f60 100644 --- a/src/App/TopPanel.tsx +++ b/src/App/TopPanel.tsx @@ -18,6 +18,7 @@ import PlayArrowIcon from '@mui/icons-material/PlayArrow'; import SkipNextIcon from '@mui/icons-material/SkipNext'; import StopIcon from '@mui/icons-material/Stop'; import { InsertModes } from "./TopPanel/InsertModes"; +import { usePersistentState } from "@/util/persistent_state"; export type TopPanelProps = { trace: TraceState | null, @@ -42,7 +43,7 @@ const ShortCutShowKeys = ~; 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 [timescale, setTimescale] = useState(1); + const [timescale, setTimescale] = usePersistentState("timescale", 1); const config = trace && trace.trace[trace.idx]; @@ -74,7 +75,7 @@ export const TopPanel = memo(function TopPanel({trace, time, setTime, onUndo, on } }); updateDisplayedTime(); - }, [setTime, updateDisplayedTime]); + }, [setTime, timescale, updateDisplayedTime]); const onTimeScaleChange = useCallback((newValue: string, wallclktime: number) => { const asFloat = parseFloat(newValue); diff --git a/src/Plant/Microwave/Microwave.tsx b/src/Plant/Microwave/Microwave.tsx index 4feb84f..e3cf8f7 100644 --- a/src/Plant/Microwave/Microwave.tsx +++ b/src/Plant/Microwave/Microwave.tsx @@ -10,7 +10,7 @@ import sndBell from "./bell.wav"; import sndRunning from "./running.wav"; import { Plant } from "../Plant"; import { RaisedEvent } from "@/statecharts/runtime_types"; -import { useEffect, useRef, useState } from "react"; +import { useEffect, useState } from "react"; import "./Microwave.css"; @@ -40,6 +40,7 @@ export type MicrowaveState = { export type MicrowaveProps = { state: MicrowaveState, + speed: number, callbacks: { startPressed: () => void; stopPressed: () => void; @@ -92,41 +93,75 @@ function fetchAudioBuffer(url: string): Promise { }); } -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(); 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; 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("closed"); - const bufRunningPromise = useRef(fetchAudioBuffer(sndRunning)); - const bufBellPromise = useRef(fetchAudioBuffer(sndBell)); + 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.current.then(buf => { - playAudioBufer(buf, false); - }) + bufBellPromise.then(buf => { + const cbs = playAudioBufer(buf, false, speed); + setSoundsPlaying(sounds => [...sounds, cbs]); + }); } }, [bell]); useEffect(() => { if (magnetron === "on") { - const stop = bufRunningPromise.current.then(buf => { - return playAudioBufer(buf, true); + 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 () => stop.then(stop => stop()); } return () => {}; }, [magnetron]) - preload(imgSmallClosedOff, {as: "image"}); + useEffect(() => { + soundsPlaying.forEach(([_, setSpeed]) => setSpeed(speed)); + }, [soundsPlaying, speed]) + + // preload(imgSmallClosedOff, {as: "image"}); preload(imgSmallClosedOn, {as: "image"}); preload(imgSmallOpenedOff, {as: "image"}); preload(imgSmallOpenedOn, {as: "image"}); @@ -147,7 +182,7 @@ export function Magnetron({state: {timeDisplay, bell, magnetron}, callbacks}: Mi src: url(${fontDigital}); } `} - + = { } return state; // unknown event - ignore it }, - render: (state, raiseEvent) => raiseEvent({name: "startPressed"}), stopPressed: () => raiseEvent({name: "stopPressed"}), incTimePressed: () => raiseEvent({name: "incTimePressed"}), diff --git a/src/Plant/Plant.ts b/src/Plant/Plant.ts index 69aef28..239f774 100644 --- a/src/Plant/Plant.ts +++ b/src/Plant/Plant.ts @@ -8,5 +8,5 @@ export type Plant = { initial: 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; } diff --git a/src/statecharts/interpreter.ts b/src/statecharts/interpreter.ts index c19faa0..11df97f 100644 --- a/src/statecharts/interpreter.ts +++ b/src/statecharts/interpreter.ts @@ -72,7 +72,7 @@ export function execAction(action: Action, rt: 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; @@ -99,7 +99,7 @@ export function entryActions(simtime: number, state: TransitionSrcTgt, actionSco } 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) { (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 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; 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 { - console.log('attemptSrcState', stateDescription(sourceState), arenasFired); + // console.log('attemptSrcState', stateDescription(sourceState), arenasFired); const outgoing = statechart.transitions.get(sourceState.uid) || []; const labels = outgoing.flatMap(t => t.label @@ -295,7 +295,7 @@ function attemptSrcState(simtime: number, sourceState: AbstractState, event: RT_ for (const activeState of mode) { const s = statechart.uid2State.get(activeState); 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}); if (newConfig === undefined) { 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. 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) { if (config.mode.has(state.uid)) { const didFire = attemptSrcState(simtime, state, event, statechart, {...config, arenasFired}); @@ -320,7 +320,7 @@ export function fairStep(simtime: number, event: RT_Event, statechart: Statechar } else { // no enabled outgoing transitions, try the children: - console.log('attempt children'); + // console.log('attempt children'); ({arenasFired, ...config} = fairStep(simtime, event, statechart, state, {...config, arenasFired})); } } @@ -358,11 +358,11 @@ function resolveHistory(tgt: AbstractState, history: RT_History): Set { } export function fire(simtime: number, t: Transition, ts: Map, 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[]; - console.log(srcPath); + // console.log(srcPath); // console.log('arena:', arena, 'srcPath:', srcPath); // exit src and other states up to arena @@ -371,8 +371,8 @@ export function fire(simtime: number, t: Transition, ts: Map