13 Commits

Author SHA1 Message Date
apirrone
cb30e0f8f5 Chorale: a restart is a new performance, a bench piece, and a piece override
**The two-songs-at-once bug, diagnosed and fixed.** A follower adopted its
conductor and then never re-read the beacon's piece byte. Restart the conductor's
`robotctl chorale` — which on a bench happens constantly — and it re-settles,
re-flips its coin, and may pick the other piece; the follower, seeing the same
conductor id, kept accepting beats and kept singing the old song against the new
one. Even a same-piece restart broke it: the beat counter resets to zero, the
phase lock reads that as garbage, the outlier filter then rejects every real beat,
and the follower free-runs on a stale fit. A restart — a changed piece, or the
counter stepping backwards — is now treated as what it is: a new performance. New
score, new phase lock, new roster, and a conductor now singing something this
build does not know sends the follower back to listening, where quiet beats wrong.

**DUCK_CHORALE_PIECE=<id>** pins the conductor's pick, for testing one song
without restarting the chorale until the coin lands right. Only consulted when
this duck conducts — a follower sings what the beacon names, because an ensemble
where everyone insists on their own song is not one. An unknown id is warned about
and ignored rather than obeyed into silence.

**Piece 3, TEST ONLY: the Outer Wilds theme.** Unlike wistful and duck-strut,
which are original precisely because everything this idiom evokes is in copyright,
this one is the copyrighted piece — it exists to exercise the registry with music
people recognise, it is marked for removal at every site it touches, and it must
not ride a release.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-25 11:44:04 +02:00
apirrone
bd7b84291f Chorale: a second piece, and the conductor picks
"Duck Strut" — D major, 126 bpm, an oom-pah bass, tenor backbeats, and a middle
section where the tenor echoes the soprano an octave down. Everything the wistful
chorale is not, including per-voice rhythm the text format cannot write — which is
why the source of truth is `scores/duck_strut.mid`, a MIDI file in the repo:
editing the arrangement in MuseScore and committing the export is the workflow the
importer was built for. A test pins what the ducks depend on (full SATB, part
ranges, the tempo, the rhythmic independence), so an edited export cannot silently
break them.

The beacon's `piece` byte finally earns its place: a small registry maps id →
score, the conductor picks one when a performance starts (from the clock's low
bits — as good as a coin for something that happens seconds after humans put ducks
near each other, and deterministic under a test that controls the clock), and
followers load whatever the beacon names, tempo and all — the phase lock counts
that piece's beats, so loading the wrong bpm would desynchronise everything.

A duck that does not know a piece id keeps listening rather than joining: it
cannot sing a song it does not have, and guessing is how two ducks end up
performing different pieces at each other. It also does not start a rival
performance in the same room. That is the right degradation for a mixed-version
flock — an old duck near newer ones stays politely quiet.

Also, by popular demand from the duck's owner: `pet_detect` unset now means off
in both modes, not on-while-walking. Cooing at every incidental head scratch wore
thin. Opt back in with `pet_detect = true`.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-25 11:18:41 +02:00
apirrone
16d0a80936 Chorale polish from the first performance: the hiss, the beaks, and a sway
Both ducks sing. Three notes from the audience, each traced to something real:

**"The bass has a lot of white noise" — a unit error, ×5.** The streamed breath
scaled the pink-noise integrator by a guessed 0.15 where its steady-state
deviation is ~8.5, leaving the streamed voice's breath five times louder than
the offline voice it claims to match. The bass duck's seed happened to roll a
breathy personality, so it hissed loudest. Normalised properly (unit deviation,
then the offline noise's ~0.25 RMS), the choral voice breathes at 0.15× its solo
level as a choir member should, and the breath now also loses whatever fraction
of the voice the speaker rolloff takes — noise is broadband, so an uncut breath
left the quietest notes the noisiest. Found while in there: the rolloff's lift
cap was renormalised straight back out by the gain (computed after instead of
before), so MAX_BASS_LIFT was a no-op and a too-deep note came back full-loudness
harsh. The gain anchors first now, and the cap-actually-caps test would have
caught the original saturation.

**"The mouths don't always open" — phonetics lost to performance.** The shipped
piece opens with six beats of `oo`, and a phonetically-honest oo of 0.15 is a
duck audibly singing through a closed beak. The vowels are stage vowels now —
exaggerated open, same ordering, only the hum keeps the beak shut, which is what
humming is. And the beak re-articulates: the audio already releases the last 8%
of each note so repeated pitches separate; the mouth now visibly does the same,
so a run of same-vowel notes stops reading as one long weird note.

**Expressive heads, from the sync work, free.** head_expression() is a pure
function of the shared score position — roll swaying by the bar, yaw drifting by
the phrase, a small bob on the beat, and a lift on the high notes of the duck's
own line. Because every duck computes it from the same beat, the whole ensemble
sways in phase with nobody coordinating anything: choreography as a side effect
of the phase lock. Amplitudes small (the head carries the ToF), slewed in and
out with the mouth's own time constant, and the pitch sign is flagged in the code
as the thing to flip if hardware disagrees.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-25 10:28:08 +02:00
apirrone
1394935940 Chorale, from the first two-duck run: four bugs, one of them a hardware fact
**The addresses rotate.** This is why nothing ever synchronised. The logs said it
plainly once read:

    another duck register=62 id=145 from=57:23:04:53:3B:E7
    another duck register=62 id=145 from=78:60:FD:9B:98:6E
    another duck register=62 id=145 from=48:BE:86:0E:A9:F9

One duck, a new Bluetooth address every few seconds — BLE privacy, which this
project deliberately turned on for pad pairing. And re-registering an
advertisement, which is *how a beat reaches the air*, rotates it immediately. So
every beat arrived from a brand-new address, the conductor pinned by the previous
one stopped matching, and the phase lock was handed exactly one beat and never
locked. The follower therefore never sang, which is exactly what two ducks in a
room did. Identity is the beacon's `id` now — which is what it was carried for.

**Parts were assigned by arrival order, not by voice.** `seat_all` folded
`seat_by_register` down the roster, and `Part::ensemble(1)` is `[Soprano]`, so the
*first* duck got the soprano line whatever its register was and the second got the
bass. On the robots the low duck sang soprano. Parts now go out in register order,
lowest voice to lowest part. The nested ensemble sets mean a duck arriving at the
register the group was missing still moves nobody; one arriving below the current
bass shifts a part, which is the price of everyone being on the right line at all.

**An idle beacon never changed, so nobody was noticed.** A payload that does not
change is an advertisement that is never re-registered, and a duck is reported to a
scanner when it turns up at a new address — on the radio's schedule, not ours. Two
willing ducks found each other in tens of seconds. The idle beacon now carries a
slow counter purely to make itself change.

**The mouth, twice.** It was written only while a note actually sounded, so between
notes the target stayed at whatever the hold pose left there and a beak could
simply stay open. Fixed by writing it every tick — which introduced a worse bug
reported straight away: with `[chorale] accept = true` the chorale then owned the
mouth *whenever it was enabled*, so the trigger could not open it on a robot that
was not singing and might never sing. Opting in now grants the chorale nothing
until a chorale is actually running. The target is also slewed rather than snapped:
`ah` is 0.90 and `mm` is 0.02, and a servo asked to jump between them on a 20 ms
tick twitches instead of singing.

**Saturation.** Two causes, both mine. The bass rebalance was allowed to pile a
whole note onto harmonics two to four (4x), which through a small driver is harsh
rather than loud — halved. And each duck sang at full scale, when *four ducks in a
room sum acoustically*: the offline preview divides its mix by the square root of
the voice count and nothing on hardware divides anything, so a duck now arrives
already knowing it is one of several. A lone duck is a little quiet, which is the
right way round.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-24 18:55:01 +02:00
apirrone
25b75df251 Chorale: test the whole quartet, and say where four is the actual cap
The sync simulation only had two followers, so "four ducks" was designed for and
not demonstrated. It now runs a conductor and three, and the bar is *harder* that
way rather than merely bigger: what has to hold is the worst disagreement between
any two, and three followers have three pairs to go wrong instead of one. Still
inside the 20 ms budget.

Also documented a real cap that two of the doc comments were vague about. Four
parts means `ChoraleBeacon::MAX_ROSTER` is four, and `robotd`'s roster guard stops
there — so a fifth duck in a room keeps listening rather than joining.
`sounds::chorale::seat` can double a part, and says so, but nothing seats a fifth
duck over the radio; that path is reachable only by a caller that seats an ensemble
itself, such as the offline render. The two places that would have to change are
now named in one of them.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-24 17:54:55 +02:00
apirrone
d7b6f15398 The duck chorale, end to end: two ducks in a room sing together
`robotctl chorale` and they find each other over Bluetooth, work out who sings what,
and sing — with no shared clock, no pairing, no network, and nobody in charge.

**Off by default.** `[chorale] accept` in robotd.toml, and it is the whole section:
a chorale moves the mouth and the head, so a duck that started animating because
another duck walked into the room would be doing motion nobody asked for, in
someone's living room. Off also means *invisible* rather than politely declining —
an un-opted-in duck puts nothing on the air at all, so it is not even enumerable.

**How the work divides.** `btd` owns the radio and does no thinking: it advertises
bytes it was handed and reports bytes it heard. `robotd` owns the behaviour and the
voice and touches no radio. Between them is `chorale.*` on one connection carrying
both directions — a subscription down saying what to advertise, notifications up
saying what arrived (API v14).

`btd` reports an **age**, not a timestamp, and that is load-bearing: the two daemons
share a machine but not an epoch, and an age survives a socket in a way another
process's clock reading does not. `robotd` subtracts it on arrival.

**The conductor owns the seating, and that is not a convenience.** Seating depends
on join order, so a duck seating *itself* from whatever it happened to hear
disagrees with one that heard a different subset — and both then sing alto. So the
conductor keeps the roster, broadcasts it, and everyone replays `seat_all` over it.
One source of truth, which is what a conductor is for. A duck not yet in the roster
does not guess a part; it waits to be seated on the next beat, which is what makes
joining free rather than negotiated.

Found while testing: a duck that walks out of range must keep its **seat**. Pruning
it would shift everyone below onto a different part — the one harm the roster exists
to prevent — so its line goes unsung instead, exactly as in a choir somebody left,
and only the *voice count* drops.

**Singing is a function of time, not of samples played.** The loop publishes where
the ensemble is, in beats, and the writer thread turns that into notes. So a duck
whose audio stalls resumes in the right place rather than a bar behind, and one that
joins mid-piece comes in where the piece actually is.

Also here:

 - The bass fix asked for by the duck-speaker test, now applied on the robot too:
   the writer sets a 300 Hz speaker rolloff, so a low note is carried by harmonics
   the driver can make instead of a fundamental it cannot.
 - `robotctl chorale`, a live readout that names the part once it is settled so it
   survives in the scrollback.
 - One PCM opener and one `SIGPIPE` mask shared by the theremin and the chorale,
   which were the same code twice.
 - A cheatsheet section written in the order the questions actually come up.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-24 17:41:02 +02:00
apirrone
8200106cfa Chorale radio: the beacon on its own advertising instance, and a passive scan
The Bluetooth half, plus the bass fix the duck-speaker test asked for.

**A second advertising instance, registered on demand.** The board reports five
with one in use. Two reasons it is a separate instance rather than four more bytes
on the existing one, and both are traps:

 - The controller reports a 251-byte advertising budget, so BlueZ would accept a
   bigger payload on the front-door advertisement — and because it picks legacy
   against extended PDUs by size, would flip it to extended and make the robot
   invisible to a legacy-only scanner. Phone discovery would regress and it would
   read as a Bluetooth fault, not a chorale one.
 - A controller interleaves its instances, so a second one *halves the rate of the
   first*. `bluez.rs`'s 100–150 ms was tuned against measurements — the default
   1.28 s left a robot absent for up to 31 s at a time — so a permanent second
   instance would spend that margin on a feature nobody has switched on. The
   beacon exists only while a chorale does.

**A hardware-offloaded passive scan.** BlueZ's advertisement monitor filters on a
byte pattern in the controller, so the host wakes only for advertisements that are
already beacons, and the duck transmits nothing to listen — which matters when one
antenna carries this, the gamepad and wifi. `RssiSamplingPeriod::All`, because the
payload is what changes and a beat reported once is not a beat.

The tag in the payload earns its byte twice over: without it the *other* instance's
four bytes of IPv4 arrive here as a beat, and a beacon arrives at `duck-btctl` as a
robot at a nonsense address. Both directions are pinned by tests, along with the
scan pattern matching what is actually broadcast byte for byte — get that wrong and
the controller silently drops every beat.

**Bass, from the duck-speaker test: "not great but ok".** It was the low line, and
the fix is counterintuitive — make low notes carry by making the *fundamental*
quieter. A coin-sized driver produces almost nothing below a few hundred hertz, so
a bass line's fundamental is not quiet but absent, and the amplitude spent on it is
spent on nothing while eating the headroom the rest of the note needs. Pushing
harder is how a small driver is made to distort. So `set_speaker_rolloff` takes the
weight off harmonics the driver cannot make and gives it to the ones it can; the
pitch survives because pitch does not live in the fundamental — a series spaced
130 Hz apart is heard as a 130 Hz note whether or not anything is at 130 Hz.
Verified by FFT: energy below the rolloff falls, energy above rises, every harmonic
survives so the perceived pitch is unchanged, and the total is a rebalance rather
than a gain. On by default at 300 Hz, since the duck is the target; `--rolloff 0`
renders for a full-range system.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-24 17:21:12 +02:00
apirrone
230c0b8f30 Chorale beacon: the wire layout, in the crate that owns wire contracts
`SupportedInstances` on the board reports 5 advertising instances with one in use,
so the beacon gets its own instance: the existing advertisement stays under 31
bytes and legacy, which is what phones and `duck-btctl scan` see. Growing that one
past 31 would flip it to extended PDUs and make the duck invisible to a
legacy-only scanner — a real trap, given the controller reports a 251-byte budget
and would happily accept it.

The struct moves out of `sounds` and into `duck-ipc-proto`. `sounds` is a
standalone music crate with no protocol dependency, and this is a contract between
daemons and across a radio — the same reasoning that puts `btd::adv`'s address
layout in one place so the broadcasting half and the scanning half cannot disagree.
What stays in `sounds::chorale::beat` is the timing maths, which deals only in beat
numbers and seconds and knows nothing about radios.

Five bytes: a tag, the piece, the beat, the register, a tie-break id. The tag earns
its byte — without it a scanner reads the *other* instance's four bytes of IPv4
address as a beat, and 192.168.1.42 is a perfectly plausible beacon.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-24 17:04:37 +02:00
apirrone
a6662be799 Chorale sync: a conductor beacon, and joining without reshuffling
The design for keeping ducks together over Bluetooth only, with the two pieces
where the risk actually lives — both pure logic, both testable on a laptop with no
radio involved.

**Nobody shares a clock; they share a beat.** There is no clock to sync — the
boards have no RTC agreement and no NTP — so establishing an offset would mean a
connection, a bond, and a central-role BLE client `btd` does not have. Instead the
conducting duck puts a beat counter in its advertisement and bumps it once per
musical beat; everyone else passively scans, and the arrival of a new counter value
*is* the downbeat. No timestamps, no offset estimation, no pairing. Passive
scanning is the whole radio requirement.

The error is not air time (microseconds) but the advertising slot: `btd` spaces
those 100–150 ms apart on purpose, so each beat is heard somewhere in a 50 ms
window, independently per beat. Two things turn that into single-digit
milliseconds — the conductor delays its *own* playback by the middle of that window
so it is wrong in the same direction as everyone else, and followers average the
phase over a sliding window so independent jitter falls as 1/sqrt(n).

**The tempo is deliberately not estimated, and that was a fix rather than a
shortcut.** The first version fitted a line through (beat, arrival) and read the
tempo off its slope — textbook, and measurably worse: a line fit evaluated at the
*newest* point, which is exactly where "where are we now" is asked, carries ~4x the
error it carries at the window's centre, because slope uncertainty compounds with
distance from the centroid. Two simulated followers came out 36 ms apart, over
budget. Holding the period at the score's tempo and averaging only the phase
removes the slope term and lands at a third of that. It is also the honest model:
both ducks read the tempo off the same score, and their crystals differ by parts per
million — 3 ms over a minute-long piece, against the tens of milliseconds the
estimator was contributing.

That claim is a test, not an argument: two simulated followers driven through
independent 50 ms slot jitter for two minutes must agree to within 20 ms, and the
conductor must be inside its own ensemble — which is what catches `SLOT_MEAN_S`
being wrong.

Also found: `Conductor::due` re-announced beat 0 on every poll, so at the
advertising rate that is eight beat-zeroes a second and a follower's average built
on nonsense. "Beat 0 has gone out" and "nothing has gone out yet" are different
states and were collapsed into one counter.

**Joining without reshuffling.** `cast` sorts by register, so a low duck arriving at
a duet would take the bass and shove the current bass up to alto — a part change
mid-piece. `seat` is the incremental form: existing singers keep their parts, the
newcomer takes what is free. It works because `Part::ensemble`'s sets are nested
({S} ⊂ {B,S} ⊂ {B,A,S} ⊂ {B,T,A,S}), so there is always exactly one part open until
the fourth duck arrives — an invariant now pinned by its own test. Still decided by
every duck independently with nothing negotiated: they all observed the same beacon,
so they all compute the same seating.

Only the register goes on the air, quantised to a byte, not the seed — casting
consumes the pitch centre and nothing else, and a robot's seed is its identity. The
controller reports a 251-byte advertising budget, so this is small by choice rather
than necessity: every field is something four ducks have to agree about.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-24 17:00:51 +02:00
apirrone
aa1a8126e9 Chorale: notes as the score, a text format, and MIDI import
All three, because they are one change: `Score` now holds `Vec<Note>` rather than
`Vec<Gesture>`, and that is what lets there be more than one way to write a song.

**Notes, not gestures.** `Gesture` is now one *front end* — the hand-authoring one,
where "assemble this chord from the bass up" is the intent you want in the source.
A MIDI file has no gestures to recover: staggered entries and solos are already
plain note timings. Both paths converge on notes, and everything downstream reads
one thing.

**A text format** (`sounds/scores/*.duckscore`), loaded at runtime, note names
rather than MIDI numbers, running state for what a score marks once and means until
further notice. The shipped piece *is* `scores/wistful.duckscore`, embedded with
`include_str!`, so the worked example and the default score cannot drift apart.
Errors name the line and quote it — a score is hand-written data, so a parse error
is the normal way to find out you mistyped a note name.

**MIDI import**, no dependency: an SMF is a length-prefixed chunk format and the
subset a score needs is note-on, note-off, tempo and track name. This is the front
end that actually unlocks writing music — MuseScore becomes the score editor, and
the whole public-domain four-part repertoire is already available as MIDI.

Tracks become parts by **mean pitch**, not track order. Track order is wrong twice
over: notation editors write top staff first (the reverse of a voicing), and a DAW
file may have the tempo track, empty tracks, or any order at all. A track *named*
"Soprano" is believed first, since a human wrote it down. A single polyphonic track
— what "export MIDI" gives you from many tools — is split by pitch rank per chord.
Velocity becomes the dynamic. A tempo change is sung at the first tempo and *says*
it was, because four robots honouring a tempo map over a network is a promise this
cannot keep.

Also: notes carry a dynamic and a vowel. Ducks cannot pronounce words, but a vowel
is most of what a listener hears as singing rather than humming — `oo` and `ee` are
unmistakably different even with only a formant boost on one harmonic of seven, and
the mouth opening a vowel implies is the *same number* the beak servo gets, so it is
visible as well as audible. `mm` sits back in the mix, which is how a real choir
holds a chord behind a soloist.

One rule worth writing down: a tie survives a change of *dynamic* but not of
*vowel*. A mark over a sounding note is a crescendo — re-attacking it would be the
one thing a crescendo is not — while a new syllable is a new note. Found because
`dynamic f` mid-phrase was chopping held notes into pieces.

Verified end to end rather than only in unit tests: a hand-built 4-track SMF in
notation-editor order imports with its parts right, its tempo read, four different
rhythms intact, and all four fundamentals in tune by FFT.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-24 16:33:11 +02:00
apirrone
860b72c6cc Duck chorale, part one: the sound, on a laptop
Several ducks singing one piece in four parts, rendered offline and mixed down, so
the arrangement can be judged before any two ducks have to agree on a clock.

    sounds chorale --voices 4 --out chorale.wav

Two decisions that are the whole design:

**Identity is timbre, not pitch.** A duck's loudest distinguishing trait is its
pitch centre, and letting that shift the notes — the obvious way to keep each duck
sounding like itself — would mean four ducks each singing the chord in their own
tuning, which is beating, not harmony. So the note is absolute from one shared
reference (A440, equal temperament) and what each duck keeps is everything else:
harmonics, formant, nasality, breath, and the tamed remains of its vibrato.
Register is used for *casting* instead — the lowest duck sings bass — which is
deterministic from the seeds, so real ducks can agree on parts with no leader and
no negotiation.

**A perfectly synchronised chorus is the wrong target.** Four voices on the same
sample at the same frequency sound like one organ with a thick stop. What makes an
ensemble is being *almost* together and *almost* in tune, so a few cents of pitch
spread and ±15 ms of onset spread are added on purpose, derived from each duck's
seed. That also sets the bar for the sync work to come: ±20 ms, not ±1 ms — and a
chord's *tuning*, which is what actually has to be exact, needs no synchronisation
at all, only a constant.

The score is written in gestures rather than block chords, because a chorale
breathes: `Build` assembles a chord from staggered entries and holds it, `Solo` is
one voice over a held triad, `Rest` is a breath. They compile to per-part notes
with ties merged, so a common tone across a chord change is one note and not two —
audibly the difference between a chorale and a list of chords. The shipped piece
opens and closes with the chord assembling and has a soprano line in the middle.

The default piece is original: the close-harmony records this evokes are all in
copyright, so the idiom is the reference and the notes are not.

An automatic transposition was tried and removed — it aimed each duck's part at its
own pitch centre, pinned at its clamp on every ensemble, and thinned the bass. A
duck's centre is where its quacks sit; the synth's harmonics are relative to f0, so
singing below your centre costs nothing. Casting already respects register.

Verified by FFT rather than by ear: all four fundamentals land within ±8 cents of
equal temperament, no voice is buried, and the borrowed F minor is genuinely minor
(Ab3 fifteen times A3).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-24 16:16:03 +02:00
apirrone
d195808ac8 The ToF theremin: a hand's distance is a note, and the mouth opens with it
The head's 8x8 depth sensor becomes an instrument. Closeness drives the pitch,
the level and the mouth opening — one number, not three tunings of it, because a
duck whose beak opens on a different curve from its pitch reads as an animation
over a sound rather than as an animal making one.

Hand versus wall is the whole difficulty, and neither cheap test works. "A small
near blob with free space around it" fails below ~15 cm, where the field of view
(0.828 x distance across) is narrower than a hand. "The duck is standing still,
so anything that moves is a hand" fails twice: a standing duck sways, and a
theremin note must *hold* when the hand holds.

So the test is a background, captured when the instrument is armed: whatever is
in front of the duck then — wall, table, empty room — becomes the silent zero,
and only returns nearer than it by a margin play. A duck that walked up to a wall
and stopped has that wall as its zero, at whatever distance it happens to be. The
margin sits above the trunk's sway, which is the real reason "not walking" is not
the same as "the frame is not moving". A hand already in front of the beak when
it arms is refused rather than frozen in as the zero, and a plane fit over inverse
ranges (linear, so exact and cheap) is what keeps a wall from being refused as one
enormous hand.

The voice is synthesized, not resampled. sounds::Stream is the same synth turned
inside out: same Personality harmonics, same vibrato/jitter/breath/quack-AM, the
same softening the wheee recipe applies — so a duck's theremin is that duck's
wheee held for as long as the hand stays. Block boundaries are provably not in
the signal (including the timbre refresh, which is on a sample cadence rather
than per block), and an open mouth lifts the harmonic tilt, so the visible
gesture and the audible one are the same gesture.

- sounds: `Stream` + `sounds theremin` to audition a scripted sweep off-robot
- kinematics: background, arming refusals, plane fit
- robotd: a tofd reader thread, the state machine, a low-latency PCM writer
- proto: robot.theremin, an optional theremin block in robot.state, API v13
- robotctl theremin: arm, live readout, put down on Ctrl-C
- [theremin] in robotd.toml, and the cheatsheet section

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-24 15:15:07 +02:00
apirrone
626556779b Audio & microphone: the robot's voice and ear, all in Rust (API v10)
Migration step: the prototype's audio stack, with its two satellite
repos absorbed as workspace crates and the Python removed entirely.

sounds/ is microduck_sounds rewritten in Rust: the seedable synth
(personality, recipes, variants) plus a CLI whose ensure-bank replaces
generate_sounds.sh — no venv, no numpy, no ffmpeg. It renders at
48 kHz natively (the resample step existed only because the synth was
22.05 kHz and the Radxa's I2S tree is 48k-family), with the two
rate-tuned constants rescaled to keep their time-domain character. The
RNG is a vendored xoshiro256++ with a pinned-stream test, because the
generator IS the voice: every robot re-rolls once (bank v5, the same
event as a synth retune), then stays itself forever. The seed
derivation (SoC serial -> sha256 -> u32) is unchanged, so each robot
keeps its personality traits' provenance. The parrot experiment stays
unported.

pet-detect/ is microduck_pet_detect plus the prototype's pet_worker:
mel extractor, streaming detector, the arecord worker and the ambient
sound sentry, the pet-detect/pet-features binaries, the vendored model
(shipped as models/pet_detect.onnx) and the training script — which
extracts features through the Rust binary, so train/infer parity
crosses the port intact.

robotd owns playback (root already, and the codec PCM is single-
client): greet as the loop comes up, goodbye peck on shutdown, coo on
petting (walk mode, per the prototype's launch lines), and the new
robot.sound call (v10) for the rest. padd's triggers do what the
prototype's did — RT quacks on its rising edge, LT rides the wheee,
streamed start->loop->end into one aplay with the writer paced 250 ms
ahead. The hold is a per-tick notification that decays, so a client
that dies mid-ride leaves a ride that lands. robotctl quack replaces
the quack binary, over the socket — so it also works from a laptop on
a forwarded socket, which is when you actually need to know which duck
you are talking to.

Found by the fake-bus torture test: robotd restores SIGPIPE's default
disposition at startup, so a write into a dead aplay killed the whole
daemon — the wheee writer thread now blocks SIGPIPE and gets EPIPE
like it expects.

Provisioning: setup-board.sh gains configure_audio() — alsa-utils +
DKMS toolchain, the Armbian vendor kernel (+headers), the i2c3 +
codec/I2S overlays compiled from deploy/audio/, the aic3x DKMS module
(the vendor kernel does not build SND_SOC_AIC3X), and the mixer-init
service ordered Before=robotd. The voice bank is NOT provisioned
there: every release install renders it via postinstall (sounds
ensure-bank, idempotent by seed+version marker). Packaging ships the
three new binaries and the model at all three sites, with a tripwire
for the model like the policies have.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-20 14:59:32 +02:00