A stranger walks up to an upright piano, says "play something sad in D minor", and the keys move. No screen, no app. Real strings, real hammers, and the only interface is speaking to it.
Ninety-six channels under the keybed — 88 keys, a damper pair, spares. Six driver boards on one bus, one Teensy, a 24 V rail, and a speech pipeline that turns a sentence into a schedule of solenoid pulses.
| Build | Where | Brain | All-in |
|---|---|---|---|
| A — connected | Gallery, venue, home | Raspberry Pi 5, cloud speech | 2166 |
| B — offline | Festival, desert, no signal | Jetson Orin Nano, all local | 2489 |
The actuator half is identical, so building A first and swapping the brain later costs nothing extra and gets sound out six weeks sooner.
The rail as the control surface sees it. Fifty-two white keys, thirty-six black, each depressing and bleeding light when its coil engages, over a strip showing all 88 channels of the bus. Two channels are faulted — ch44 and ch67 — exactly as the reference build reports them.
| Board | Qty | Sheet | Parts | Nets | ERC |
|---|---|---|---|---|---|
| 16-channel solenoid driver | 6 | A2 | 91 | 58 | 0 |
| Controller — Teensy, level shift, 5 V | 1 | A4 | 9 | 15 | 0 |
The 339 off-grid endpoints are the ones that mattered: pins that only looked connected, producing a schematic that renders perfectly and a netlist that is wrong.
Every drawing here is a projection of the actual solid — visible edges in flux blue, hidden in steel. Change a constant at the top of hardware/cad/piano_rig.py and all of them follow, along with the STEP files a fabricator would receive.
| Constant | Meaning | mm |
|---|---|---|
| SOL_L × W × H | Open-frame envelope | 32·22·20 |
| STROKE | Plunger travel | 10.0 |
| KEY_L | Piano key, front to tail | 480.0 |
| PIVOT_X | Balance pin from the front | 180.0 |
| PUSH_X | Where the plunger meets the key | 330.0 |
| LOST_M | Gap — the whole of Stage 5 | 0.5 |
| KEY_PITCH | White key spacing | 23.5 |
Pushing up behind the balance pin is identical to pressing down in front of it. That is why the solenoids hide under the keybed, why the piano still plays by hand, and why nothing is visible from the front.
# plunger tip reaches SOL_H + STROKE above the base; # drop the solenoid so it lands LOST_M below the key z = -(SOL_H + STROKE) - LOST_M sol = solenoid().locate(Location((PUSH_X, 0, z)))
~/.claude/skills/render/.venv/bin/python3 hardware/cad/piano_rig.py
A key is not a switch. Pressing it starts a chain of seven linked parts that ends with a felt hammer thrown at a string and a damper lifted off it — and the retrofit has to drive that chain without altering any of it. Every figure below names the parts a technician names, and every regulation figure is the published vertical-action spec, not an estimate.
Modelled as solids and projected, so the linkage positions are geometry rather than illustration. Watch the hammer: it is thrown, not pushed — the jack escapes 2 mm before the string and the hammer completes the last of its travel on momentum alone.
| Part | What it does to you |
|---|---|
| Capstan | This is where lost motion is normally taken up. Your rod adds a second lost-motion adjustment below the key — set the capstan first, then the rod. |
| Jack / let-off | The hammer is free for the last 2 mm. Pulse energy past let-off does nothing except heat the coil. |
| Backcheck | Catches the hammer 15 mm from the string. If the key is held down by a coil, the hammer stays checked and cannot repeat — this is why R15's auto-off matters musically, not just thermally. |
| Damper spoon | Lifts at about half key travel, so a half-pressed key sustains without sounding. A weak pulse can lift the damper and never strike. |
| Bridle strap | Returns the hammer. It is also the first thing to perish on an old upright — check all 88 before you blame your firmware. |
One octave at true widths — seven naturals at 22.4 mm and five sharps at 11 mm, the sharps starting 95 mm behind the key fronts. This is the pitch the rail has to fit under, and it is why the solenoids sit in two staggered rows.
| Part | Number | Spec | Qty | Ext $ | Basis |
|---|---|---|---|---|---|
| Core actuator rail — both builds | |||||
| Key solenoid | JF-1039B | 24 V, 25 N, 10 mm. Rated 400 mA, measures ~750 mA | 96 | 288 | ALLOW |
| Damper solenoid | JF-1050B | Two in series — one lacks the travel | 2 | 18 | ALLOW |
| Power MOSFET | IRLZ44NPBF | Infineon logic-level, 55 V 47 A, TO-220AB · $0.7642 @100, 26,228 in stock | 100 | 76 | QUOTED |
| Shift register | SN74HC595N | TI 16-PDIP · signal only · $1.37 @10 | 20 | 27 | QUOTED |
| Level shifter | SN74HCT125N | TI · HCT not HC · $0.92 | 5 | 5 | QUOTED |
| Flyback diode | 1N5408 | 3 A 1000 V, one per coil · $0.1331 @100 | 100 | 13 | QUOTED |
| Gate passives | — | 100 R series + 100 k pulldown per channel | 200 | 10 | ALLOW |
| Driver PCB | — | 6 × 16-channel 2-layer, includes one respin | 1 | 85 | ALLOW |
| Connectors | — | 5.08 mm terminals, IDC headers | 1 | 35 | ALLOW |
| Microcontroller | Teensy 4.1 | 600 MHz M7, native USB MIDI · $31.50 in stock · one spare | 2 | 63 | QUOTED |
| Power supply | SE-600-24 | Mean Well 24 V 25 A 600 W · $89.95, 86 in stock | 1 | 90 | QUOTED |
| Bulk capacitance | — | 4700 µF 50 V at each board | 4 | 22 | ALLOW |
| Protection | — | 30 A ANL fuse, mushroom E-stop, DC distribution | 1 | 45 | ALLOW |
| Harness + bus | — | 20 AWG ×500 ft, 8 AWG ×25 ft, ferrules | 1 | 132 | ALLOW |
| Mounting + felt | — | Under-keybed board, rod extensions, damper felt | 1 | 135 | ALLOW |
| CORE RAIL | 1045 | ||||
| Build A — cloud connected | |||||
| Compute | Raspberry Pi 5 8GB | $175 in stock — proposal carried the $95 MSRP | 1 | 175 | QUOTED |
| Pi accessories | — | Cooler, 64 GB A2 card, 27 W supply | 1 | 35 | ALLOW |
| Mic array | reSpeaker XVF3800 | 4-mic, AEC + beamforming, 5 m · $60.99 | 1 | 61 | QUOTED |
| Network | — | Wi-Fi 6 router + LTE fallback | 1 | 55 | ALLOW |
| BUILD A ADD-ON | 326 | ||||
| Build B — fully offline | |||||
| Edge compute | Jetson Orin Nano Super | $399 · DC-native, so no inverter off-grid | 1 | 399 | LISTED |
| Directional mic | — | Shotgun or boundary — crowd noise defeats a 360° array | 1 | 75 | ALLOW |
| Local corpus | — | 1 TB NVMe: MIDI library, Whisper, Ollama weights | 1 | 85 | ALLOW |
| Sealed enclosure | — | NEMA box, MERV-13, positive pressure | 1 | 90 | ALLOW |
| BUILD B ADD-ON | 649 | ||||
| The instrument — absent from the original BOM entirely | |||||
| Used upright piano | — | Free to $400. Play it before you accept it | 1 | 200 | ALLOW |
| Piano moving | — | Local professional, +$40–100 per stair flight | 1 | 275 | MARKET |
| Tuning ×2 | — | Before install and after — the move alone detunes it | 2 | 200 | ALLOW |
| Shop tools | — | Crimper, DMM, current-limiting bench supply | 1 | 120 | ALLOW |
| INSTRUMENT & LOGISTICS | 795 | ||||
| Configuration | Cost | +15% | Proposal |
|---|---|---|---|
| Build A — connected | 2166 | 2491 | 840 |
| Build B — offline | 2489 | 2862 | 1260 |
| Build B + off-grid power | 3339 | 3839 | — |
The 15% contingency is for a first build: one PCB respin, a bag of counterfeit MOSFETs, a solenoid batch that measures differently than the sample. The proposal was not padded — it was scoped to electronics only. About half the gap is the instrument and its logistics; most of the rest is 2026 silicon pricing.
| When | What | $ | Gate |
|---|---|---|---|
| Wk 1 | 5 solenoids, 2 damper units | 80 | none — this is the gate |
| Wk 1–2 | One channel of parts | 40 | S1 passed |
| Wk 3 | PCB + one board's parts | 120 | S2 passed |
| Wk 5 | The big order | 455 | S3 passed |
| Wk 6 | Piano + move | 475 | — |
| Wk 8 | Compute, mic, network | 326 | — |
| Wk 12 | Build B only | 649 | if going offline |
Each stage ends in a test you can pass or fail. Nothing advances on a stage that has not passed — least of all Stage 1, which exists to stop you buying ninety-six of the wrong part.
You needBench supply with current limit · DMM · kitchen scale · stopwatch
One key plays soft and loud on demand, with four numbers written down. If force is under ~15 N or thermal under 20 s, change the part now — before $288 of solenoids arrives.
You needTeensy · 74HCT125 · 74HC595 · IRLZ44N · 1N5408 · oscilloscope
Key plays over USB MIDI, flyback visibly clamped, no brownouts, cool MOSFET. R12 and R13 demonstrated.
Sixteen channels simultaneous for ten minutes, no thermal event, no rail sag. Budget one respin.
a. E-stop pressed. b. Teensy disconnected. c. Energise and confirm every gate reads 0 V. d. Release the E-stop; confirm nothing twitches. e. Connect a Teensy running firmware that does nothing; confirm still nothing. f. Only now load the driver firmware.
If anything moves at c, d or e, stop. That is R17 failing, and it is the failure that burns coils.
Every channel addressable, and R15–R17 each demonstrated by actively trying to violate them.
This is the stage that decides whether the project finishes. Ninety-six individual adjustments. It cannot be rushed, delegated or parallelised — give it three weeks of calendar and schedule nothing against it.
All 88 play from the rail, the piano still plays normally under your fingers (R6), no audible clack.
Sustain audibly changes the decay. Cheapest musical improvement in the build.
This is why the schedule is thirteen weeks and not nineteen. The stack targets a virtual MIDI port, so it is built and tested from week two while the hardware is still parts in boxes.
Spoken request → correct MIDI in under three seconds, and an unrecognised request produces a spoken reply rather than silence (R10).
A chromatic scale at one MIDI velocity sounds even across all 88. Audible, not measurable on paper.
Two hours, no thermal event, nothing stuck or dropped. If it survives this it will survive an audience.
A full day in dust, no filtered-air loss, no host resets, and a stranger able to walk up, speak, and hear it play.
Five stages between a sentence and a note, and a hard boundary in the middle. Everything left of the MIDI line is Python on the host and may be slow; everything right of it is C++ on the Teensy and may not.
"Play something sad" only works if the corpus is tagged, and raw MIDI files are not. Budget time for indexing: title, composer, key, tempo, mood. Key and tempo come out of the files; mood does not, and is worth a one-off pass with a language model over titles and metadata before deployment rather than at request time.
Ninety-six channels of independent PWM is more than any microcontroller has in hardware. The answer is to hold a 96-bit shadow register, recompute it at the PWM tick, and clock the whole frame out to the chained 74HC595s. A Teensy 4.1 at 600 MHz does this with room to spare — an RP2040 at 133 MHz manages 256-level PWM on the same topology using DMA, so headroom is not the constraint.
A solenoid moving a key is not on or off. OFF → ACTIVATION → VELOCITY → ON → DEACTIVATION. Activation runs at full duty to overcome inertia, fixed and calibrated per note. Only the velocity phase carries loudness. Feeding MIDI velocity straight to a duty cycle skips the momentum phase, so quiet notes fail to speak and loud ones arrive late.
Because every note takes a different time to reach the key, scheduling works backwards from a universal key delay — schedule the final state first, then place activation and velocity behind it. Chords land together instead of arpeggiating themselves (R5), at the cost of a fixed latency nobody can hear.
// none of these are configurable at runtime constexpr uint32_t MAX_ON_MS = 10000; // R15: coil cooks at ~40 s constexpr uint8_t MAX_SIMULTANEOUS = 30; // R16: supply, not musicality constexpr uint32_t HOST_TIMEOUT_MS = 500; // R17: silence means stop void serviceSafety(uint32_t now) { // R15 — force-release anything held too long, whatever MIDI says for (uint8_t ch = 0; ch < N_CHANNELS; ++ch) if (state[ch] == ON && now - onSince[ch] > MAX_ON_MS) forceRelease(ch, "held past thermal limit"); // R17 — a quiet host is a dead host if (now - lastHostMsg > HOST_TIMEOUT_MS) allChannelsOff(); // R14 — the E-stop cut the rail; stop pretending we are playing if (analogRead(PIN_RAIL_SENSE) < RAIL_LIVE) { allChannelsOff(); reportToHost("24V rail down — E-stop or fuse"); } }
| Symptom | Most likely cause |
|---|---|
| One note never sounds | Lost motion too large on that key, or a dead channel. Fire it and listen for the plunger. |
| One note always quiet | Stale calibration — re-sweep that note only |
| A note sticks on | Shorted MOSFET or a floating register. E-stop immediately — firmware gives you ten seconds, then it is on you |
| Notes drop in dense passages | Admission control working as designed. Check the rail is not sagging first. |
| Chords arpeggiate | Universal key delay too short. Raise it. |
| Buzzing while held | Hold PWM in the audible band — raise toward 12–20 kHz |
| Host resets mid-chord | Check every flyback diode, then that grounds bond at exactly one point |
| Whole rail dead | Fuse or E-stop. The controller should already be telling you which. |
| Piano feels wrong to play | Violates R6. Find it before playing the instrument again. |