The Texas Instruments Speak & Math launched in 1980. It was a chunky blue plastic learning toy with an orange carrying handle, a row of cream and coloured keys and a vacuum fluorescent display that glowed a cool teal-green. It spoke to children in a synthesised voice, posed arithmetic problems and told them whether they were right or wrong. It was genuinely clever for 1980 and genuinely delightful for the children who grew up with one.
This is a browser recreation of that device. It runs entirely in one HTML file, uses the Web Speech API to actually speak the questions aloud and synthesises its own sound effects through the Web Audio API. Turn your sound on before you press the ON key.
What was the Speak & Math?
The original Speak & Math was part of Texas Instruments’ Speak & Spell family, designed by Paul Breedlove and his colleagues at TI’s Consumer Products division. Where the Speak & Spell focused on literacy, the Speak & Math focused on arithmetic: it could pose addition, subtraction, multiplication and division problems, ask children to identify which of two numbers was greater, dictate numbers for them to type and run a number-guessing game called Number Stumper.
The device used a custom TMS5100 speech synthesis chip based on linear predictive coding — a technique that models the human vocal tract as a series of filter coefficients rather than storing actual audio. The result was recognisably human speech from a chip small enough and cheap enough to embed in a toy. It was, by any measure, an extraordinary piece of engineering for its era.
What made it memorable was not the technology in isolation but the combination: a physical object you could hold, keys you could press, a glowing display and a voice that responded to you. Children who grew up with the Speak & Math in the early 1980s remembered it with genuine affection. This browser version attempts to capture some of that feeling.
Code Breakdown
The CSS design system
The stylesheet describes the entire physical appearance of the device through CSS custom properties.
:root {
--case-blue: #1d5fb0;
--case-blue-dark: #123f78;
--case-blue-deep: #0c2c56;
--panel-navy: #0f345f;
--orange: #f5821f;
--orange-dark: #c25f0a;
--key-cream: #f3ecdc;
--key-cream-edge: #cbc2ab;
--key-red: #e23b2e;
--key-teal: #1fa79a;
--vfd-bg: #04120c;
--vfd-glow: #77ffd2;
--vfd-dim: #123d2f;
--text-light: #eaf2ff;
}
These twelve tokens do essentially all the colour work in the file. The blue family (--case-blue, --case-blue-dark, --case-blue-deep) maps to the graduated gradient on the case. The orange pair handles the handle and the activity key colour. The cream values describe the standard key face and edge. The VFD group (--vfd-bg, --vfd-glow, --vfd-dim) controls the display in its active and inactive states.
The body background is a large radial gradient that lightens at the top, simulating a photography backdrop that throws focus towards the machine:
body {
background: radial-gradient(1200px 700px at 50% -10%, #3b4a63 0%, #1d2533 55%, #12161f 100%);
}
The physical structure: handle, case and screws
The HTML hierarchy directly mirrors the physical object.
<div class="machine-wrap machine off" id="machine">
<div class="handle" aria-hidden="true"></div>
<div class="case">
<div class="screw tl"></div>
<div class="screw tr"></div>
<div class="screw bl"></div>
<div class="screw br"></div>
...
</div>
</div>
The handle sits above the case in the DOM and uses margin: 0 auto -14px to overlap the case by 14 pixels, making it appear physically attached. Its CSS uses border-bottom: none to open the underside so the case can continue seamlessly beneath it:
.handle {
width: 62%;
height: 58px;
margin: 0 auto -14px;
border: 14px solid var(--orange);
border-bottom: none;
border-radius: 34px 34px 0 0;
box-shadow:
inset 0 4px 0 rgba(255,255,255,.35),
inset 0 -6px 0 var(--orange-dark),
0 6px 10px rgba(0,0,0,.35);
}
The inset 0 4px 0 rgba(255,255,255,.35) creates a highlight on the inner top edge — the highlight you would see on the curved inside of a physical plastic handle. inset 0 -6px 0 var(--orange-dark) adds a shadow on the inner lower edge, reinforcing the 3D curve. The drop shadow beneath anchors the handle to the case.
The four corner screws are pure CSS elements with no content. Each uses a radial gradient to simulate a domed screw head:
.screw {
position: absolute;
width: 14px;
height: 14px;
border-radius: 50%;
background: radial-gradient(circle at 35% 30%, #9db6d6, #45608a 60%, #223a5c);
box-shadow: inset 0 1px 2px rgba(0,0,0,.6);
}
.screw::after {
content: "";
position: absolute;
left: 2px; right: 2px; top: 50%;
height: 2px; margin-top: -1px;
background: #132844;
border-radius: 2px;
transform: rotate(35deg);
}
The ::after pseudo-element is the screw slot — a narrow dark bar rotated 35 degrees, which reads immediately as a Phillips head even at 14 pixels.
The case gradient
.case {
background: linear-gradient(180deg,
#2a72c8 0%,
var(--case-blue) 22%,
var(--case-blue-dark) 100%);
border-radius: 34px;
box-shadow:
inset 0 3px 0 rgba(255,255,255,.28),
inset 0 -10px 18px rgba(0,0,0,.35),
inset 8px 0 14px rgba(255,255,255,.06),
inset -8px 0 14px rgba(0,0,0,.25),
0 22px 40px rgba(0,0,0,.55);
}
Five box-shadow values combine to create the illusion of a plastic form factor. The top inset is a bright highlight where the light would catch the upper curve. The bottom inset is a shadow where the plastic curves away. The left inset is a faint reflected highlight from the opposite side. The right inset is a shadow from the same direction. The outer value is the drop shadow beneath the whole device.
The VFD display
The vacuum fluorescent display is a multi-layer effect. The outer .display-bezel provides a dark inset housing. The .vfd element itself is the glowing screen area.
.vfd {
background: radial-gradient(120% 160% at 50% 0%, #0a2418 0%, var(--vfd-bg) 60%);
border-radius: 8px;
min-height: 86px;
box-shadow: inset 0 0 24px rgba(0,0,0,.9);
overflow: hidden;
}
Two pseudo-elements sit on top of the content. ::before is a diagonal highlight simulating the glass surface reflection:
.vfd::before {
content: "";
position: absolute;
inset: 0;
background: linear-gradient(115deg, rgba(255,255,255,.10) 0%, rgba(255,255,255,0) 28%);
pointer-events: none;
}
::after is the scanline overlay — a repeating gradient that darkens every other 2px strip, replicating the phosphor dot-matrix structure of the original display:
.vfd::after {
content: "";
position: absolute;
inset: 0;
background: repeating-linear-gradient(0deg, rgba(0,0,0,.22) 0 2px, transparent 2px 4px);
pointer-events: none;
mix-blend-mode: multiply;
}
mix-blend-mode: multiply means the dark bands darken the glowing text beneath rather than covering it with an opaque layer. The text itself glows via text-shadow:
.vfd-main {
color: var(--vfd-glow);
text-shadow: 0 0 6px rgba(119,255,210,.9), 0 0 18px rgba(119,255,210,.45);
}
Two shadows — a tight inner glow and a wider outer halo — produce the characteristic bloom of a real VFD character. When the machine is off, the vfd.off class removes the text-shadow and reduces opacity, leaving only the faint --vfd-dim colour: the dimly visible but unlit phosphor.
The speaker grille
.speaker {
flex: 1;
max-width: 210px;
height: 56px;
border-radius: 14px;
background: repeating-linear-gradient(90deg, #0c2c56 0 6px, #1a4a86 6px 12px);
box-shadow: inset 0 3px 8px rgba(0,0,0,.6), inset 0 -1px 0 rgba(255,255,255,.15);
}
The speaker grille is a repeating-linear-gradient that alternates between two shades of blue at 6px intervals, producing the appearance of vertical slots. The inner shadow darkens the interior. This is the same technique used for the scanlines — CSS gradients are extremely effective at simulating physical textures that would otherwise require images.
The key system
All buttons share a base .key class that provides the illusion of depth and physical depression.
button.key {
background: linear-gradient(180deg, #fff 0%, var(--key-cream) 45%, #e2d8c2 100%);
box-shadow:
inset 0 2px 0 rgba(255,255,255,.9),
inset 0 -4px 0 var(--key-cream-edge),
0 4px 0 #8d8570,
0 7px 10px rgba(0,0,0,.45);
border-radius: 12px;
padding: 10px 4px 12px;
}
button.key:active, button.key.pressed {
transform: translateY(3px);
box-shadow:
inset 0 2px 0 rgba(255,255,255,.7),
inset 0 -2px 0 var(--key-cream-edge),
0 1px 0 #8d8570,
0 2px 4px rgba(0,0,0,.4);
}
The resting state uses 0 4px 0 #8d8570 as a hard bottom edge — this is the “side” of the keycap in resting position. When pressed, transform: translateY(3px) moves the key down by 3px and the hard bottom shadow reduces from 4px to 1px, making the key appear to have physically descended into the keyboard. This is a well-established CSS button technique but executed with particular care here: the resting shadow height, the travel distance and the pressed shadow height all match.
Colour variants follow the same shadow structure with different colour values:
.key.orange {
background: linear-gradient(180deg, #ffb066 0%, var(--orange) 45%, #d96f10 100%);
box-shadow:
inset 0 2px 0 rgba(255,255,255,.55),
inset 0 -4px 0 var(--orange-dark),
0 4px 0 #7c3f05,
0 7px 10px rgba(0,0,0,.45);
}
Five colour variants are defined: cream (default), orange (activities), teal (ON), red (OFF, ERASE) and navy (operators and function keys). Each matches a key colour visible on the original hardware.
The .key.wide class uses grid-column: span 2 to produce the double-width ENTER and = keys in the bottom row without any additional markup.
The Web Audio sound engine
The audio system is built around a lazy-initialised AudioContext. The audio() function creates the context on first call and resumes it if suspended — browsers require a user gesture before audio can play, so the context is not created until the first key press.
function tone(freq, dur, type, vol, when) {
const ctx = audio(); if (!ctx) return;
const t0 = ctx.currentTime + (when || 0);
const o = ctx.createOscillator();
const g = ctx.createGain();
o.type = type || "square";
o.frequency.setValueAtTime(freq, t0);
g.gain.setValueAtTime(0.0001, t0);
g.gain.exponentialRampToValueAtTime(vol || 0.12, t0 + 0.01);
g.gain.exponentialRampToValueAtTime(0.0001, t0 + dur);
o.connect(g).connect(ctx.destination);
o.start(t0); o.stop(t0 + dur + 0.02);
}
The gain envelope attacks from near-zero to the target volume in 10ms and decays exponentially to near-zero at dur. Starting from 0.0001 rather than zero is required because exponentialRampToValueAtTime cannot ramp from zero — the logarithmic scale breaks at zero. Each tone creates its own oscillator and gain node, uses them and discards them.
The when parameter allows notes to be scheduled in the future. The sound effects use this for chords and arpeggios:
const sfx = {
right() { [660, 880, 1100, 1470].forEach((f,i) => tone(f,.11,"triangle",.12, i*.09)); },
fanfare() { [523,659,784,1046,784,1046].forEach((f,i) => tone(f,.14,"triangle",.13, i*.12)); },
power() { [400,600,800,1200].forEach((f,i) => tone(f,.09,"square",.1, i*.07)); },
off() { [900,600,350].forEach((f,i) => tone(f,.09,"square",.1, i*.06)); }
};
right() is an ascending four-note arpeggio in triangle wave — soft and bell-like. fanfare() is six notes, ascending then resolving back through two closing notes. power() is a rising square-wave chord sequence. off() is the reverse: three descending pitches that land on a lower note, signalling shutdown. The wrong-answer sound uses sawtooth waves — a harsher timbre that reads immediately as negative feedback.
The Web Speech API integration
const speech = {
voice: null,
pick() {
const vs = window.speechSynthesis ? speechSynthesis.getVoices() : [];
this.voice = vs.find(v => /en[-_]/i.test(v.lang) && /male|daniel|david|alex|fred/i.test(v.name))
|| vs.find(v => /^en/i.test(v.lang)) || vs[0] || null;
},
say(text, cb) {
if (!window.speechSynthesis) { if (cb) setTimeout(cb, 300); return; }
speechSynthesis.cancel();
const u = new SpeechSynthesisUtterance(text);
if (this.voice) u.voice = this.voice;
u.pitch = 0.55;
u.rate = 0.85;
u.volume = 1;
if (cb) { u.onend = cb; u.onerror = cb; }
speechSynthesis.speak(u);
}
};
Voice selection tries to find a male English voice first — targeting named voices like Daniel, David and Alex that are available on macOS and iOS — then falls back to any English voice, then any voice at all. speechSynthesis.onvoiceschanged re-runs pick() because browsers often load voices asynchronously after the page loads.
pitch: 0.55 and rate: 0.85 give the speech a slightly lower, slower quality that is somewhat closer to a synthesised voice than the browser’s default. The cb callback is attached to both onend and onerror — both fire when the utterance finishes, whether successfully or not. This is important: the game’s state machine progresses through a callback chain, so if speech fails silently without firing either event, the game locks up.
The speechSynthesis.cancel() call at the start of every say() stops any currently speaking utterance. Without this, queued utterances from rapid key presses would stack up and play in sequence long after the user has moved on.
The state machine
All mutable state lives in a single S object:
const S = {
power: false,
activity: null,
level: 1,
probNum: 0,
correctCount: 0,
tries: 0,
entry: "",
answer: null,
promptSpeech: "",
displayProblem: "",
locked: false,
stump: null
};
locked is the key field. When locked is true, key presses that would normally advance the game are ignored. The machine becomes locked whenever speech is playing and a callback will be used to unlock it and advance. This prevents the player from pressing keys during a spoken announcement and producing a corrupted state.
The flow of a single problem is:
nextProblem()incrementsprobNumand calls the active generator (e.g.genSolve)- The generator sets
S.answer,S.displayProblemandS.promptSpeech, callsshow()and callsspeech.say() - The player types digits and presses ENTER
onEnter()comparesparseInt(S.entry, 10)toS.answer- Correct:
handleCorrect()locks the machine, plays the right sound, shows the answer on the display, speaks praise and callsnextProblem()from the speech callback - Wrong on first attempt:
handleWrong()plays the wrong sound, speaks “wrong, try again” and resetsS.entry - Wrong on second attempt: shows the correct answer, locks, speaks it and advances
After 10 problems, finishRound() calculates the percentage score and speaks a personalised result.
The five activity generators
Solve It generates arithmetic problems scaled to the difficulty level. Level 1 uses single-digit addition and subtraction. Level 2 adds multiplication and extends to two-digit numbers. Level 3 adds division and extends to three digits, generating division problems by choosing the quotient and divisor first and computing the dividend — guaranteeing integer answers without trial and error.
else if (op === "div") {
b = rnd(2, 12); const q = rnd(2, 12); a = b * q;
}
Greater/Less generates two unequal numbers and asks whether the first is greater or less than the second. It uses a dedicated key pair rather than numeric entry. The onCompare function is called when the > or < key is pressed during a Greater/Less problem.
Write It speaks a number and asks the player to type it. The display shows _ — no visual hint. The number range is 3–99 at level 1, 100–999 at level 2 and 1,000–99,999 at level 3. The answer comparison is numeric, so leading zeros are handled correctly.
Word Problems uses arrays of template functions indexed by level. Each template takes two numbers and returns a tuple of the problem sentence and the numerical answer:
const T2 = [
(a,b) => ["Stickers come in packs of "+a+". You buy "+b+" packs. How many stickers is that?", a*b],
(a,b) => ["A bus has "+a+" seats. "+b+" seats are empty. How many seats are taken?", a-b],
// ...
];
The templates at level 3 are slightly more sophisticated, involving division scenarios where the answer is the quotient rather than the product.
Number Stumper is the most complex mode. The machine thinks of a number with a level-determined number of digits (3, 4 or 5). The player guesses, and after each guess the machine reveals whether the guess was too high or too low. Correctly guessed individual digits are filled in — the display shows something like 4 _ 7 _ as the player narrows down the number.
for (let i = 0; i < st.digits; i++) {
if (S.entry[i] === secretStr[i]) st.known[i] = secretStr[i];
}
This partial-reveal mechanic mirrors the original Speak & Math’s Number Stumper and adds strategic depth: even wrong guesses yield digit information if any individual digit matches positionally.
The keyboard and button wiring
Every interactive element uses data-* attributes as identifiers, which avoids the need for any element references beyond the five display and state elements grabbed at startup.
document.querySelectorAll("[data-act]").forEach(btn => {
btn.addEventListener("click", () => {
if (!S.power) { sfx.wrong(); return; }
if (S.locked) return;
audio(); sfx.enter(); speech.stop();
startActivity(btn.dataset.act);
});
});
document.querySelectorAll("[data-k]").forEach(btn => {
btn.addEventListener("click", () => press(btn.dataset.k));
});
Physical keyboard support maps common keys to their logical equivalents:
const KEYMAP = {
"Enter": "enter", "=": "eq", "Backspace": "erase",
">": "gt", "<": "lt", "+": "add", "-": "sub", "*": "mul", "/": "div",
"r": "repeat", "R": "repeat", "l": "level", "L": "level"
};
Keyboard presses also trigger a visual pressed class on the corresponding button for 120ms, keeping the mouse and keyboard experiences consistent.
The .off class on #machine reduces button saturation and brightness via filter: saturate(.7) brightness(.92), visually communicating that the device is powered down without hiding or disabling the buttons.
The lamp indicators
Two small status indicators sit inside the VFD bezel and show the current level and problem progress.
function updateLamps() {
lampLevel.textContent = "LEVEL " + S.level;
lampScore.textContent = "PROB " + Math.min(S.probNum, ROUND) + "/" + ROUND;
lampLevel.classList.toggle("on", S.power);
lampScore.classList.toggle("on", S.power && !!S.activity);
}
The on class adds opacity: 1 and a green text shadow, simulating an illuminated indicator LED. When the machine is off or no activity is selected, they are dimmed to opacity: 0.25.
Full Source Code
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Speak and Math</title>
<style>
:root{
--case-blue:#1d5fb0;
--case-blue-dark:#123f78;
--case-blue-deep:#0c2c56;
--panel-navy:#0f345f;
--orange:#f5821f;
--orange-dark:#c25f0a;
--key-cream:#f3ecdc;
--key-cream-edge:#cbc2ab;
--key-red:#e23b2e;
--key-teal:#1fa79a;
--vfd-bg:#04120c;
--vfd-glow:#77ffd2;
--vfd-dim:#123d2f;
--text-light:#eaf2ff;
}
*{box-sizing:border-box; -webkit-tap-highlight-color:transparent;}
html,body{margin:0;padding:0;}
body{
min-height:100vh;
background:
radial-gradient(1200px 700px at 50% -10%, #3b4a63 0%, #1d2533 55%, #12161f 100%);
font-family:"Arial Narrow", "Helvetica Neue", Arial, sans-serif;
display:flex;
align-items:center;
justify-content:center;
padding:28px 12px 48px;
color:var(--text-light);
}
.machine-wrap{width:min(640px,100%);}
.handle{
width:62%;
height:58px;
margin:0 auto -14px;
border:14px solid var(--orange);
border-bottom:none;
border-radius:34px 34px 0 0;
box-shadow:
inset 0 4px 0 rgba(255,255,255,.35),
inset 0 -6px 0 var(--orange-dark),
0 6px 10px rgba(0,0,0,.35);
position:relative;
z-index:0;
}
.case{
position:relative;
z-index:1;
background:linear-gradient(180deg,#2a72c8 0%, var(--case-blue) 22%, var(--case-blue-dark) 100%);
border-radius:34px;
padding:22px 24px 30px;
box-shadow:
inset 0 3px 0 rgba(255,255,255,.28),
inset 0 -10px 18px rgba(0,0,0,.35),
inset 8px 0 14px rgba(255,255,255,.06),
inset -8px 0 14px rgba(0,0,0,.25),
0 22px 40px rgba(0,0,0,.55);
}
.case::after{
content:"";
}
.screw{
position:absolute;width:14px;height:14px;border-radius:50%;
background:radial-gradient(circle at 35% 30%, #9db6d6, #45608a 60%, #223a5c);
box-shadow:inset 0 1px 2px rgba(0,0,0,.6);
}
.screw::after{
content:"";position:absolute;left:2px;right:2px;top:50%;height:2px;margin-top:-1px;
background:#132844;border-radius:2px;transform:rotate(35deg);
}
.screw.tl{top:14px;left:16px;} .screw.tr{top:14px;right:16px;}
.screw.bl{bottom:14px;left:16px;} .screw.br{bottom:14px;right:16px;}
.topbar{
display:flex;align-items:center;justify-content:space-between;
gap:14px;margin:2px 6px 14px;
}
.logo{
background:var(--orange);
color:#fff;
font-weight:900;
font-family:Arial, Helvetica, sans-serif;
font-style:italic;
letter-spacing:.5px;
padding:7px 16px 8px;
border-radius:12px;
font-size:clamp(18px,4vw,26px);
text-shadow:0 2px 0 rgba(0,0,0,.25);
box-shadow:inset 0 2px 0 rgba(255,255,255,.4), inset 0 -3px 0 var(--orange-dark), 0 3px 6px rgba(0,0,0,.3);
white-space:nowrap;
}
.logo small{font-size:.5em;font-weight:700;display:block;font-style:normal;letter-spacing:2px;margin-top:-2px;}
.speaker{
flex:1;max-width:210px;height:56px;border-radius:14px;
background:
repeating-linear-gradient(90deg, #0c2c56 0 6px, #1a4a86 6px 12px);
box-shadow:inset 0 3px 8px rgba(0,0,0,.6), inset 0 -1px 0 rgba(255,255,255,.15);
}
.display-bezel{
background:linear-gradient(180deg,#0a1f3c,#122c52);
border-radius:16px;
padding:12px 14px;
box-shadow:inset 0 4px 10px rgba(0,0,0,.7), inset 0 -1px 0 rgba(255,255,255,.12);
margin:0 4px 16px;
}
.vfd{
position:relative;
background:
radial-gradient(120% 160% at 50% 0%, #0a2418 0%, var(--vfd-bg) 60%);
border-radius:8px;
min-height:86px;
padding:14px 16px 10px;
overflow:hidden;
box-shadow:inset 0 0 24px rgba(0,0,0,.9);
}
.vfd::before{
content:"";position:absolute;inset:0;
background:linear-gradient(115deg, rgba(255,255,255,.10) 0%, rgba(255,255,255,0) 28%);
pointer-events:none;
}
.vfd::after{
content:"";position:absolute;inset:0;
background:repeating-linear-gradient(0deg, rgba(0,0,0,.22) 0 2px, transparent 2px 4px);
pointer-events:none;mix-blend-mode:multiply;
}
.vfd-main{
font-family:"Courier New", ui-monospace, Menlo, monospace;
font-weight:700;
font-size:clamp(24px,6vw,40px);
letter-spacing:.14em;
color:var(--vfd-glow);
text-shadow:0 0 6px rgba(119,255,210,.9), 0 0 18px rgba(119,255,210,.45);
min-height:1.2em;
white-space:nowrap;
overflow:hidden;
}
.vfd-sub{
font-family:"Courier New", ui-monospace, monospace;
font-size:clamp(11px,2.6vw,14px);
letter-spacing:.22em;
color:var(--vfd-glow);
opacity:.75;
text-shadow:0 0 5px rgba(119,255,210,.7);
margin-top:6px;
text-transform:uppercase;
min-height:1.1em;
white-space:nowrap;overflow:hidden;
}
.vfd.off .vfd-main,.vfd.off .vfd-sub{color:var(--vfd-dim);text-shadow:none;opacity:.5;}
.vfd-status{
position:absolute;top:8px;right:12px;display:flex;gap:10px;
font-family:Arial, sans-serif;font-size:9px;letter-spacing:1px;color:var(--vfd-glow);
}
.lamp{display:flex;align-items:center;gap:4px;opacity:.25;transition:opacity .2s;}
.lamp.on{opacity:1;text-shadow:0 0 6px rgba(119,255,210,.8);}
.lamp::before{content:"";width:7px;height:7px;border-radius:50%;background:currentColor;box-shadow:0 0 6px currentColor;}
.panel{
background:linear-gradient(180deg,#144073,var(--panel-navy));
border-radius:20px;
padding:16px 16px 18px;
box-shadow:inset 0 3px 8px rgba(0,0,0,.55), inset 0 -1px 0 rgba(255,255,255,.1);
}
.section-label{
font-size:10px;letter-spacing:2.5px;text-transform:uppercase;
color:#9fc0e8;margin:2px 4px 8px;font-weight:700;
}
.activity-row{
display:grid;
grid-template-columns:repeat(5,1fr);
gap:10px;
margin-bottom:16px;
}
button.key{
appearance:none;border:none;cursor:pointer;
font-family:"Arial Narrow", Arial, sans-serif;
border-radius:12px;
padding:10px 4px 12px;
font-weight:800;
letter-spacing:.4px;
color:#20242c;
background:linear-gradient(180deg,#fff 0%, var(--key-cream) 45%, #e2d8c2 100%);
box-shadow:
inset 0 2px 0 rgba(255,255,255,.9),
inset 0 -4px 0 var(--key-cream-edge),
0 4px 0 #8d8570,
0 7px 10px rgba(0,0,0,.45);
transition:transform .05s, box-shadow .05s, filter .15s;
user-select:none;
line-height:1.05;
}
button.key:active, button.key.pressed{
transform:translateY(3px);
box-shadow:
inset 0 2px 0 rgba(255,255,255,.7),
inset 0 -2px 0 var(--key-cream-edge),
0 1px 0 #8d8570,
0 2px 4px rgba(0,0,0,.4);
}
button.key:focus-visible{outline:3px solid #ffd27a;outline-offset:2px;}
.key .big{display:block;font-size:clamp(15px,3.6vw,20px);}
.key .tiny{display:block;font-size:9px;letter-spacing:1px;margin-top:2px;font-weight:700;color:#5a5343;text-transform:uppercase;}
.key.orange{
color:#fff;
background:linear-gradient(180deg,#ffb066 0%, var(--orange) 45%, #d96f10 100%);
box-shadow:
inset 0 2px 0 rgba(255,255,255,.55),
inset 0 -4px 0 var(--orange-dark),
0 4px 0 #7c3f05,
0 7px 10px rgba(0,0,0,.45);
text-shadow:0 1px 0 rgba(0,0,0,.25);
}
.key.orange .tiny{color:#ffe6cc;}
.key.red{
color:#fff;
background:linear-gradient(180deg,#ff8a7e 0%, var(--key-red) 45%, #b32418 100%);
box-shadow:inset 0 2px 0 rgba(255,255,255,.5), inset 0 -4px 0 #8f1d13, 0 4px 0 #5c0f08, 0 7px 10px rgba(0,0,0,.45);
text-shadow:0 1px 0 rgba(0,0,0,.3);
}
.key.red .tiny{color:#ffd9d4;}
.key.teal{
color:#fff;
background:linear-gradient(180deg,#63d9cc 0%, var(--key-teal) 45%, #147d73 100%);
box-shadow:inset 0 2px 0 rgba(255,255,255,.5), inset 0 -4px 0 #0e6058, 0 4px 0 #093f3a, 0 7px 10px rgba(0,0,0,.45);
text-shadow:0 1px 0 rgba(0,0,0,.3);
}
.key.teal .tiny{color:#d5fbf6;}
.key.navy{
color:#fff;
background:linear-gradient(180deg,#4d7fc0 0%, #2a5a9c 45%, #1c4278 100%);
box-shadow:inset 0 2px 0 rgba(255,255,255,.4), inset 0 -4px 0 #14315a, 0 4px 0 #0b2140, 0 7px 10px rgba(0,0,0,.45);
text-shadow:0 1px 0 rgba(0,0,0,.3);
}
.key.navy .tiny{color:#cfe1f8;}
.main-grid{
display:grid;
grid-template-columns:repeat(5,1fr);
gap:10px;
}
.key.wide{grid-column:span 2;}
.machine.off button.key{filter:saturate(.7) brightness(.92);}
.foot{
text-align:center;
margin-top:18px;
font-size:11.5px;
letter-spacing:.4px;
color:#8ea3c4;
}
.foot b{color:#c8d8f2;}
@media (max-width:480px){
.case{padding:16px 12px 22px;border-radius:26px;}
.activity-row{gap:7px;}
.main-grid{gap:7px;}
button.key{border-radius:10px;padding:9px 2px 11px;}
.speaker{height:44px;}
.handle{height:44px;border-width:11px;}
}
@media (prefers-reduced-motion:reduce){
button.key{transition:none;}
}
</style>
</head>
<body>
<div class="machine-wrap machine off" id="machine">
<div class="handle" aria-hidden="true"></div>
<div class="case">
<div class="screw tl"></div><div class="screw tr"></div>
<div class="screw bl"></div><div class="screw br"></div>
<div class="topbar">
<div class="logo">Speak & Math<small>TALKING LEARNING AID</small></div>
<div class="speaker" aria-hidden="true"></div>
</div>
<div class="display-bezel">
<div class="vfd off" id="vfd">
<div class="vfd-status">
<span class="lamp" id="lampLevel">LEVEL 1</span>
<span class="lamp" id="lampScore">PROB 0/10</span>
</div>
<div class="vfd-main" id="displayMain"> </div>
<div class="vfd-sub" id="displaySub"> </div>
</div>
</div>
<div class="panel">
<div class="section-label">Activities</div>
<div class="activity-row">
<button class="key orange" data-act="solve"><span class="big">SOLVE</span><span class="tiny">It</span></button>
<button class="key orange" data-act="gl"><span class="big">> <</span><span class="tiny">Greater / Less</span></button>
<button class="key orange" data-act="word"><span class="big">WORD</span><span class="tiny">Problems</span></button>
<button class="key orange" data-act="write"><span class="big">WRITE</span><span class="tiny">It</span></button>
<button class="key orange" data-act="stumper"><span class="big">NUMBER</span><span class="tiny">Stumper</span></button>
</div>
<div class="section-label">Keyboard</div>
<div class="main-grid">
<button class="key teal" data-k="on"><span class="big">ON</span><span class="tiny">Go</span></button>
<button class="key" data-k="7"><span class="big">7</span></button>
<button class="key" data-k="8"><span class="big">8</span></button>
<button class="key" data-k="9"><span class="big">9</span></button>
<button class="key navy" data-k="div"><span class="big">÷</span></button>
<button class="key navy" data-k="level"><span class="big">LEVEL</span><span class="tiny">1·2·3</span></button>
<button class="key" data-k="4"><span class="big">4</span></button>
<button class="key" data-k="5"><span class="big">5</span></button>
<button class="key" data-k="6"><span class="big">6</span></button>
<button class="key navy" data-k="mul"><span class="big">×</span></button>
<button class="key navy" data-k="repeat"><span class="big">REPEAT</span><span class="tiny">Say again</span></button>
<button class="key" data-k="1"><span class="big">1</span></button>
<button class="key" data-k="2"><span class="big">2</span></button>
<button class="key" data-k="3"><span class="big">3</span></button>
<button class="key navy" data-k="sub"><span class="big">−</span></button>
<button class="key red" data-k="off"><span class="big">OFF</span></button>
<button class="key" data-k="gt"><span class="big">></span><span class="tiny">Greater</span></button>
<button class="key" data-k="0"><span class="big">0</span></button>
<button class="key" data-k="lt"><span class="big"><</span><span class="tiny">Less</span></button>
<button class="key navy" data-k="add"><span class="big">+</span></button>
<button class="key red" data-k="erase"><span class="big">ERASE</span></button>
<button class="key teal wide" data-k="enter"><span class="big">ENTER</span></button>
<button class="key navy wide" data-k="eq"><span class="big">=</span></button>
</div>
</div>
</div>
<p class="foot">A fan-made homage to the classic 1980 talking math tutor. <b>Turn your sound on</b> — it speaks!
Keyboard works too: digits, <b>+ − * /</b>, <b>< ></b>, <b>Enter</b>, <b>Backspace</b> = erase, <b>R</b> = repeat, <b>L</b> = level.</p>
</div>
<script>
(function(){
"use strict";
let ac = null;
function audio(){
if(!ac){ try{ ac = new (window.AudioContext||window.webkitAudioContext)(); }catch(e){} }
if(ac && ac.state === "suspended") ac.resume();
return ac;
}
function tone(freq, dur, type, vol, when){
const ctx = audio(); if(!ctx) return;
const t0 = ctx.currentTime + (when||0);
const o = ctx.createOscillator();
const g = ctx.createGain();
o.type = type||"square";
o.frequency.setValueAtTime(freq, t0);
g.gain.setValueAtTime(0.0001, t0);
g.gain.exponentialRampToValueAtTime(vol||0.12, t0+0.01);
g.gain.exponentialRampToValueAtTime(0.0001, t0+dur);
o.connect(g).connect(ctx.destination);
o.start(t0); o.stop(t0+dur+0.02);
}
const sfx = {
key(){ tone(1150, .06, "square", .07); },
enter(){ tone(880,.07,"square",.09); tone(1320,.09,"square",.08,.07); },
right(){ [660,880,1100,1470].forEach((f,i)=>tone(f,.11,"triangle",.12,i*.09)); },
wrong(){ tone(160,.28,"sawtooth",.14); tone(120,.3,"sawtooth",.12,.05); },
fanfare(){ [523,659,784,1046,784,1046].forEach((f,i)=>tone(f,.14,"triangle",.13,i*.12)); },
power(){ [400,600,800,1200].forEach((f,i)=>tone(f,.09,"square",.1,i*.07)); },
off(){ [900,600,350].forEach((f,i)=>tone(f,.09,"square",.1,i*.06)); }
};
const speech = {
voice:null,
pick(){
const vs = window.speechSynthesis ? speechSynthesis.getVoices() : [];
this.voice = vs.find(v=>/en[-_]/i.test(v.lang) && /male|daniel|david|alex|fred/i.test(v.name))
|| vs.find(v=>/^en/i.test(v.lang)) || vs[0] || null;
},
say(text, cb){
if(!window.speechSynthesis){ if(cb) setTimeout(cb, 300); return; }
speechSynthesis.cancel();
const u = new SpeechSynthesisUtterance(text);
if(this.voice) u.voice = this.voice;
u.pitch = 0.55;
u.rate = 0.85;
u.volume = 1;
if(cb){ u.onend = cb; u.onerror = cb; }
speechSynthesis.speak(u);
},
stop(){ if(window.speechSynthesis) speechSynthesis.cancel(); }
};
if(window.speechSynthesis){
speech.pick();
speechSynthesis.onvoiceschanged = ()=>speech.pick();
}
const $ = s=>document.querySelector(s);
const machineEl = $("#machine");
const vfd = $("#vfd");
const dMain = $("#displayMain");
const dSub = $("#displaySub");
const lampLevel = $("#lampLevel");
const lampScore = $("#lampScore");
function show(main, sub){
dMain.textContent = (main===undefined||main===null||main==="") ? " " : main;
if(sub !== undefined) dSub.textContent = sub==="" ? " " : sub;
}
function updateLamps(){
lampLevel.textContent = "LEVEL " + S.level;
lampScore.textContent = "PROB " + Math.min(S.probNum, ROUND) + "/" + ROUND;
lampLevel.classList.toggle("on", S.power);
lampScore.classList.toggle("on", S.power && !!S.activity);
}
const rnd = (a,b)=>Math.floor(Math.random()*(b-a+1))+a;
const pick = arr=>arr[rnd(0,arr.length-1)];
const OPSYM = {add:"+", sub:"−", mul:"×", div:"÷"};
const OPWORD = {add:"plus", sub:"minus", mul:"times", div:"divided by"};
const ROUND = 10;
const S = {
power:false,
activity:null,
level:1,
probNum:0,
correctCount:0,
tries:0,
entry:"",
answer:null,
promptSpeech:"",
displayProblem:"",
locked:false,
stump:null
};
function genSolve(){
const L = S.level;
let op, a, b;
if(L===1){
op = pick(["add","sub"]);
a = rnd(1,9); b = rnd(1,9);
if(op==="sub" && b>a) [a,b]=[b,a];
}else if(L===2){
op = pick(["add","sub","mul"]);
if(op==="mul"){ a=rnd(2,9); b=rnd(2,9); }
else { a=rnd(10,99); b=rnd(1,99); if(op==="sub" && b>a) [a,b]=[b,a]; }
}else{
op = pick(["add","sub","mul","div"]);
if(op==="mul"){ a=rnd(3,12); b=rnd(3,19); }
else if(op==="div"){ b=rnd(2,12); const q=rnd(2,12); a=b*q; }
else { a=rnd(25,999); b=rnd(10,999); if(op==="sub" && b>a) [a,b]=[b,a]; }
}
const ans = op==="add"?a+b: op==="sub"?a-b: op==="mul"?a*b: a/b;
S.answer = ans;
S.displayProblem = a+" "+OPSYM[op]+" "+b+" = ";
S.promptSpeech = a+" "+OPWORD[op]+" "+b+" equals?";
show(S.displayProblem, "SOLVE IT · TYPE ANSWER, ENTER");
speech.say(S.promptSpeech);
}
function genGL(){
const L = S.level;
const hi = L===1?20 : L===2?200 : 5000;
let a = rnd(0,hi), b = rnd(0,hi);
while(a===b) b = rnd(0,hi);
S.answer = a>b ? ">" : "<";
S.displayProblem = a+" ? "+b;
S.promptSpeech = "Is "+a+" greater than, or less than "+b+"?";
show(S.displayProblem, "PRESS > OR <");
speech.say(S.promptSpeech);
}
function genWrite(){
const L = S.level;
const n = L===1 ? rnd(3,99) : L===2 ? rnd(100,999) : rnd(1000,99999);
S.answer = n;
S.displayProblem = "";
S.promptSpeech = "Write the number "+n+".";
show("_", "WRITE IT · TYPE WHAT I SAY");
speech.say(S.promptSpeech);
}
function genWord(){
const L = S.level;
let a,b,ans,text,op;
const T1 = [
(a,b)=>["You have "+a+" apples. A friend gives you "+b+" more. How many apples do you have?", a+b],
(a,b)=>["There are "+a+" birds in a tree. "+b+" fly away. How many birds are left?", a-b],
(a,b)=>["You read "+a+" pages, then "+b+" more pages. How many pages did you read?", a+b],
(a,b)=>["A box holds "+a+" crayons. You take out "+b+". How many crayons stay in the box?", a-b]
];
const T2 = [
(a,b)=>["Stickers come in packs of "+a+". You buy "+b+" packs. How many stickers is that?", a*b],
(a,b)=>["A bus has "+a+" seats. "+b+" seats are empty. How many seats are taken?", a-b],
(a,b)=>["You save "+a+" pennies each day for "+b+" days. How many pennies do you save?", a*b],
(a,b)=>["A book has "+a+" pages. You have read "+b+". How many pages are left?", a-b]
];
const T3 = [
(a,b)=>["There are "+(a*b)+" cookies shared equally among "+b+" friends. How many cookies does each friend get?", a],
(a,b)=>["A train travels "+a+" miles every hour for "+b+" hours. How far does it travel?", a*b],
(a,b)=>["You have "+(a*b)+" marbles in bags of "+b+". How many bags do you have?", a],
(a,b)=>["Tickets cost "+a+" dollars each. What do "+b+" tickets cost?", a*b]
];
if(L===1){ a=rnd(2,9); b=rnd(1,9); if(b>a)[a,b]=[b,a]; [text,ans]=pick(T1)(a,b); }
else if(L===2){ a=rnd(3,12); b=rnd(2,9); if(b>a)[a,b]=[b,a]; [text,ans]=pick(T2)(a,b); }
else { a=rnd(3,12); b=rnd(2,9); [text,ans]=pick(T3)(a,b); }
S.answer = ans;
S.displayProblem = "";
S.promptSpeech = text;
show("?", "WORD PROBLEM · LISTEN, THEN TYPE");
speech.say(text);
}
function genStumper(){
const digits = S.level===1?3 : S.level===2?4 : 5;
const lo = Math.pow(10,digits-1), hi = Math.pow(10,digits)-1;
S.stump = { secret:rnd(lo,hi), digits:digits, known:Array(digits).fill("_"), guesses:0 };
S.answer = S.stump.secret;
S.displayProblem = "";
S.promptSpeech = "I am thinking of a "+digits+" digit number. Guess my number.";
show(S.stump.known.join(" "), "NUMBER STUMPER · GUESS + ENTER");
speech.say(S.promptSpeech);
}
const GENS = {solve:genSolve, gl:genGL, write:genWrite, word:genWord, stumper:genStumper};
const ACTNAME = {solve:"Solve it", gl:"Greater than, less than", word:"Word problems", write:"Write it", stumper:"Number stumper"};
function startActivity(act){
S.activity = act;
S.probNum = 0;
S.correctCount = 0;
S.stump = null;
speech.say("Here is "+ACTNAME[act]+", level "+S.level+".", ()=>nextProblem());
show("", ACTNAME[act].toUpperCase()+" · LEVEL "+S.level);
updateLamps();
}
function nextProblem(){
if(!S.power || !S.activity) return;
S.probNum++;
S.tries = 0;
S.entry = "";
updateLamps();
if(S.probNum > ROUND){ finishRound(); return; }
GENS[S.activity]();
}
function finishRound(){
const pct = Math.round(S.correctCount/ROUND*100);
S.locked = true;
sfx.fanfare();
show("SCORE "+pct+"%", S.correctCount+" OF "+ROUND+" CORRECT");
let line = "Here is your score. "+S.correctCount+" out of "+ROUND+" correct. That is "+pct+" percent. ";
line += pct===100 ? "Perfect score! You are a math whiz!"
: pct>=80 ? "Great job!"
: pct>=50 ? "Good work. Keep practicing."
: "Nice try. Let's practice some more.";
speech.say(line, ()=>{
S.locked = false;
S.activity = null;
updateLamps();
show("PICK AN ACTIVITY","OR PRESS LEVEL");
});
}
function redrawEntry(){
if(S.activity==="stumper" && S.stump){
const g = S.entry.padEnd(S.stump.digits,"_").slice(0,S.stump.digits);
show(g.split("").join(" "), undefined);
}else if(S.activity==="solve"){
show(S.displayProblem + (S.entry||"_"));
}else{
show(S.entry||"_");
}
}
function handleCorrect(){
S.correctCount++;
S.locked = true;
sfx.right();
const praise = pick(["That is correct.","Correct!","You are right.","That is right. Super!","Correct. Well done!"]);
if(S.activity==="solve") show(S.displayProblem + S.answer, "CORRECT!");
else if(S.activity==="gl") show(S.displayProblem.replace("?", S.answer), "CORRECT!");
else show(String(S.answer), "CORRECT!");
speech.say(praise, ()=>{ S.locked=false; nextProblem(); });
}
function handleWrong(){
S.tries++;
sfx.wrong();
if(S.tries===1){
S.entry = "";
speech.say("Wrong. Try again.", ()=>{
if(S.activity==="solve") show(S.displayProblem+"_","TRY AGAIN");
else if(S.activity==="gl") show(S.displayProblem,"TRY AGAIN · > OR <");
else show("_","TRY AGAIN");
});
show(S.activity==="solve" ? S.displayProblem+"_" : (S.activity==="gl" ? S.displayProblem : "_"), "TRY AGAIN");
}else{
S.locked = true;
const ansText = S.answer===">" ? "greater than" : S.answer==="<" ? "less than" : String(S.answer);
if(S.activity==="solve") show(S.displayProblem + S.answer, "THE ANSWER");
else if(S.activity==="gl") show(S.displayProblem.replace("?", S.answer), "THE ANSWER");
else show(String(S.answer), "THE ANSWER");
speech.say("Wrong. The answer is "+ansText+".", ()=>{ S.locked=false; nextProblem(); });
}
}
function checkStumperGuess(){
const st = S.stump;
if(!st) return;
if(S.entry.length !== st.digits){
sfx.wrong();
speech.say("Guess a "+st.digits+" digit number.");
return;
}
st.guesses++;
const guess = parseInt(S.entry,10);
const secretStr = String(st.secret);
for(let i=0;i<st.digits;i++){
if(S.entry[i]===secretStr[i]) st.known[i]=secretStr[i];
}
if(guess===st.secret){
S.correctCount++;
S.locked = true;
sfx.right();
show(secretStr.split("").join(" "), "GOT IT IN "+st.guesses+" GUESSES");
speech.say("You got it! My number was "+st.secret+". You took "+st.guesses+" guesses.", ()=>{
S.locked=false; nextProblem();
});
}else{
sfx.wrong();
const hint = guess>st.secret ? "too big" : "too small";
S.entry = "";
show(st.known.join(" "), "YOUR GUESS WAS "+hint.toUpperCase());
speech.say(String(guess)+" is "+hint+". Try again.");
}
}
function onEnter(){
if(!S.power || S.locked || !S.activity) return;
if(S.activity==="gl"){ sfx.wrong(); speech.say("Press the greater than, or less than key."); return; }
if(S.activity==="stumper"){ sfx.enter(); checkStumperGuess(); return; }
if(S.entry===""){ sfx.wrong(); return; }
sfx.enter();
const val = parseInt(S.entry,10);
S.entry = "";
if(val === S.answer) handleCorrect(); else handleWrong();
}
function onCompare(sym){
if(!S.power || S.locked) return;
if(S.activity!=="gl"){ sfx.key(); return; }
sfx.enter();
if(sym === S.answer) handleCorrect(); else handleWrong();
}
function press(k){
if(k==="on"){
audio();
if(!S.power){
S.power = true;
machineEl.classList.remove("off");
vfd.classList.remove("off");
sfx.power();
show("HELLO","PICK AN ACTIVITY");
speech.say("Hello. This is math time! Pick an activity, or press level.");
updateLamps();
}else if(S.activity && !S.locked){
speech.stop(); nextProblem();
}
return;
}
if(k==="off"){
if(!S.power) return;
speech.stop();
sfx.off();
S.power=false; S.activity=null; S.entry=""; S.stump=null;
machineEl.classList.add("off");
vfd.classList.add("off");
show("",""); updateLamps();
return;
}
if(!S.power){ return; }
if(k==="level"){
if(S.locked) return;
sfx.key();
S.level = S.level%3 + 1;
updateLamps();
speech.say("Level "+S.level+".");
if(S.activity){ startActivity(S.activity); }
else show("LEVEL "+S.level, "PICK AN ACTIVITY");
return;
}
if(k==="repeat"){
if(S.promptSpeech && S.activity && !S.locked){ sfx.key(); speech.say(S.promptSpeech); }
return;
}
if(k==="erase"){
if(S.locked) return;
sfx.key();
S.entry="";
if(S.activity) redrawEntry();
return;
}
if(k==="enter" || k==="eq"){ onEnter(); return; }
if(k==="gt"){ onCompare(">"); return; }
if(k==="lt"){ onCompare("<"); return; }
if(k==="add"||k==="sub"||k==="mul"||k==="div"){ sfx.key(); return; }
if(/^[0-9]$/.test(k)){
if(S.locked || !S.activity){ sfx.key(); return; }
if(S.activity==="gl"){ sfx.key(); return; }
sfx.key();
const maxLen = S.activity==="stumper" && S.stump ? S.stump.digits : 7;
if(S.entry.length < maxLen) S.entry += k;
redrawEntry();
return;
}
}
document.querySelectorAll("[data-act]").forEach(btn=>{
btn.addEventListener("click", ()=>{
if(!S.power){ sfx.wrong(); return; }
if(S.locked) return;
audio(); sfx.enter(); speech.stop();
startActivity(btn.dataset.act);
});
});
document.querySelectorAll("[data-k]").forEach(btn=>{
btn.addEventListener("click", ()=>press(btn.dataset.k));
});
const KEYMAP = {
"Enter":"enter","=":"eq","Backspace":"erase","Delete":"erase",
">":"gt","<":"lt","+":"add","-":"sub","*":"mul","/":"div",
"r":"repeat","R":"repeat","l":"level","L":"level",
"o":"on","O":"on"
};
document.addEventListener("keydown", e=>{
if(e.metaKey||e.ctrlKey||e.altKey) return;
let k = null;
if(/^[0-9]$/.test(e.key)) k = e.key;
else if(KEYMAP[e.key]) k = KEYMAP[e.key];
if(k){
e.preventDefault();
press(k);
const btn = document.querySelector('[data-k="'+k+'"]');
if(btn){ btn.classList.add("pressed"); setTimeout(()=>btn.classList.remove("pressed"),120); }
}
});
show("PRESS ON","");
updateLamps();
})();
</script>
</body>
</html>