Skip to content

Tape (VCR)

🏷️ copy data on transition
🏷️ specific state + event transition
🏷️ any state + event transition
🏷️ machine start side-effect
🏷️ state entry side-effect
🏷️ side-effect cleanup
🏷️ use external apis

About

This machine models an old fashioned VCR (a video cassette tape player).

This machine uses an external HardwareInterface API to control the tape head and motor, and receive events. In the machine’s onStart() side-effect we add event-listeners for the API’s "start" and "end" events; the returned cleanup function removes the event-listeners when the machined is stopped. When we receive one of the API’s events, we convert it to a TapeEvent and send it to the running machine instance.

In some states we use onEnter() side-effects, and the same HardwareInterface API, to engage/disengage the tape-head and start/stop the motor.

This machine’s state data is homogenous and we’re using enableCopyDataOnTransition to simplify some transitions that don’t update the data.

Definition

import { defineMachine } from "yay-machine";
import type { HardwareInterface } from "./HardwareInterface";
/*
* Tape machine: think old school physical VCR or similar
*/
export interface TapeState {
readonly name: "stopped" | "playing" | "paused" | "scrubbing";
readonly speed: number;
readonly aspectRatio: string;
readonly hardware: HardwareInterface;
}
export type TapeEvent =
| {
readonly type: "PLAY" | "PAUSE" | "STOP";
}
| {
readonly type: "SCRUB";
readonly direction: "forward" | "backward";
}
| {
readonly type: "SET_ASPECT_RATIO";
readonly aspectRatio: string; // "12x9" etc
};
export const tapeMachine = defineMachine<TapeState, TapeEvent>({
enableCopyDataOnTransition: true,
initialState: {
name: "stopped",
speed: 0,
aspectRatio: "12x9",
hardware: undefined!,
},
onStart: ({ state, send }) => {
const cleanupFns = [
state.hardware.on("end", () => send({ type: "STOP" })),
state.hardware.on("start", () => send({ type: "STOP" })),
];
return () => cleanupFns.forEach((fn) => fn());
},
states: {
stopped: {
onEnter: ({ state: { hardware } }) => {
hardware.stopMotor();
hardware.disengageHead();
},
},
playing: {
onEnter: ({ state: { hardware, speed } }) => {
hardware.engageHead();
hardware.startMotor("forward", speed);
},
on: {
PAUSE: { to: "paused" },
},
always: {
to: "stopped",
when: ({ state: { hardware } }) => hardware.getPosition() === 1,
},
},
scrubbing: {
onEnter: ({ state: { hardware, speed } }) => {
hardware.startMotor(
speed > 1 ? "forward" : "backward",
Math.abs(speed),
);
},
on: {
PAUSE: { to: "paused" },
SCRUB: [
{
to: "scrubbing",
data: ({ state, event }) => ({
...state,
speed: event.direction === "forward" ? 2 : -2,
}),
when: ({ state, event }) =>
state.speed > 0 !== (event.direction === "forward"),
},
{
to: "scrubbing",
data: ({ state }) => ({
...state,
speed: state.speed > 1 ? state.speed + 1 : state.speed - 1,
}),
},
],
},
},
},
on: {
PLAY: {
to: "playing",
data: ({ state }) => ({ ...state, speed: 1 }),
when: ({ state }) => state.name !== "playing",
},
STOP: {
to: "stopped",
data: ({ state }) => ({ ...state, speed: 0 }),
},
SCRUB: [
{
to: "scrubbing",
when: ({ state: { hardware }, event }) =>
event.direction === "forward" && hardware.getPosition() < 1,
data: ({ state }) => ({ ...state, speed: 2 }),
},
{
to: "scrubbing",
when: ({ state: { hardware }, event }) =>
event.direction === "backward" && hardware.getPosition() > 0,
data: ({ state }) => ({ ...state, speed: -2 }),
},
],
SET_ASPECT_RATIO: {
data: ({ state, event }) => ({
...state,
aspectRatio: event.aspectRatio,
}),
},
},
});

Usage

import type { HardwareInterface } from "./HardwareInterface";
import { tapeMachine } from "./tapeMachine";
// no-op implementation for demonstration only
const hardware: HardwareInterface = {
engageHead() {},
disengageHead() {},
startMotor(_direction: "forward" | "backward", _speed: number) {},
stopMotor() {},
getPosition() {
return 0;
},
on(_name: "end" | "start", _callback: () => void) {
return () => {};
},
};
const tape = tapeMachine
.newInstance({
initialState: { name: "stopped", speed: 0, aspectRatio: "12x9", hardware },
})
.start();
const unsubscribe = tape.subscribe(({ state, event }) => {
console.log("tape state changed", state, event);
});
tape.send({ type: "PLAY" });
tape.send({ type: "SCRUB", direction: "forward" });
tape.send({ type: "SCRUB", direction: "forward" }); // go faster
tape.send({ type: "PLAY" });
tape.send({ type: "PAUSE" });
tape.send({ type: "STOP" });
tape.send({ type: "SCRUB", direction: "backward" });
tape.stop();
unsubscribe();