feat(aterm): play the role's sound mark from pre-rendered samples (#1256) #1277

Merged
coilyco-ops merged 2 commits from aos/claude/mu55-sound-mark into main 2026-08-26 08:18:47 +00:00
Member

Closes #1256. Tier 2 of the #1245 dispatch, built on #1251.

What ships

just aterm-sounds reads the live roster and renders one WAV per timbre into aterm/sounds/, committed and embedded. Ten samples, 324 KB, one per personality:

personality    timbre       contour            pulse              length  centroid
outward        brass-swell  widening-rise      long-single         0.80s     594 Hz
imaginative    celesta      expanding-arc      shimmering-three    0.80s   1,489 Hz
warm           felt-mallet  rising-embrace     gentle-three        0.80s     591 Hz
tenacious      floor-tom    rising-return      driving-two         0.70s     365 Hz
empirical      glass-tap    level-hold         measured-two        0.71s   1,461 Hz
immersed       hull-hum     sustained-descent  slow-swell          0.80s     187 Hz
protective     low-gong     descending-cover   guarded-pair        0.80s     261 Hz
decisive       snare-crack  falling-cut        single-accent       0.42s   1,187 Hz
playful        toy-piano    zigzag-rise        syncopated-three    0.80s   1,488 Hz
grounded       wood-block   low-return         steady-pair         0.49s   1,282 Hz

Spectral centroid spans 187 Hz to 1,489 Hz, so these are ten voices rather than ten pitches of one. All normalized to the same 0.62 peak, with RMS from 0.08 (snare-crack) to 0.21 (felt-mallet), which is the difference between a crack and a swell surviving the normalization.

Verified end to end in a pty: aterm platform cached and played floor-tom then wood-block, which is the pair the issue names.

The renderer reads the vocabulary, it does not match it

Same property as #1255's figures. timbre picks a voice from a table, and a timbre outside it gets a soft mallet pitched from its own name rather than silence. contour's direction word bends the pitch, so tumbling-fall falls. pulse's count word and manner word set the rhythm, so hurried-three strikes three times. Tested against values the roster does not ship.

Two synthesis defects the tests caught

Worth naming, because both had already produced shipped-looking files:

  1. Every hit broke on its first sample. The attack envelope starts at exactly 0 and the render loop breaks below 0.0005, so it broke at elapsed == 0 and wrote nothing. The first ten WAVs were 34 KB of silence and looked fine on disk.
  2. Normalization scaled by one. Moving the spec into internal/soundspec renamed soundPeak to peak, which the local peak variable in normalize already shadowed, so scale := peak / peak. The re-rendered files then clipped at 1.00.

Neither is visible without measuring the samples, which is why the test asserts peak and determinism rather than that a file exists.

Silence

Off for --silent, for ATERM_SILENT, and whenever stdout is not a character device, which covers CI and every non-interactive launch. No player on PATH is silence, and a playback failure is swallowed rather than printed on top of the identity card. A silenced launch resolves no player and writes no cache, which the test asserts.

afplay on macOS; paplay, aplay -q, then play -q on Linux, first on PATH. Playback runs in a goroutine under a 4-second budget so the harness is not held up.

Layout

The spec moved to aterm/internal/soundspec so both the launcher and aterm/soundgen can use it. soundgen is the generator behind just aterm-sounds; the samples are committed because the issue asks for them to be auditionable, and because a release must not depend on a roster read.

Verification

  • just aterm-fmt, just aterm-lint, just aterm-test clean; uv run pytest 724 passed; pre-commit run --all-files clean
  • internal/soundspec tests: determinism byte for byte, the normalized peak, the length cap, contour and pulse grammar including syncopation moving rather than adding a hit, WAV header sizes, and filename sanitizing
  • sound_test.go: every embedded sample is a WAV carrying audio, every timbre the fixtures ship has a sample (which is what catches a roster move with no regenerate), the card carries the mark, all three silence paths, and the session stage accepting --silent
  • the pty run above

Docs

docs/aterm.md is at 7,995 of 8,000 chars, so --silent, ATERM_SILENT, and just aterm-sounds are documented in --help and the recipe description only. docs/FEATURES.md carries the boundary change. Third instance on #1274.

Closes #1256. Tier 2 of the #1245 dispatch, built on #1251. ## What ships `just aterm-sounds` reads the live roster and renders one WAV per timbre into `aterm/sounds/`, committed and embedded. Ten samples, 324 KB, one per personality: ``` personality timbre contour pulse length centroid outward brass-swell widening-rise long-single 0.80s 594 Hz imaginative celesta expanding-arc shimmering-three 0.80s 1,489 Hz warm felt-mallet rising-embrace gentle-three 0.80s 591 Hz tenacious floor-tom rising-return driving-two 0.70s 365 Hz empirical glass-tap level-hold measured-two 0.71s 1,461 Hz immersed hull-hum sustained-descent slow-swell 0.80s 187 Hz protective low-gong descending-cover guarded-pair 0.80s 261 Hz decisive snare-crack falling-cut single-accent 0.42s 1,187 Hz playful toy-piano zigzag-rise syncopated-three 0.80s 1,488 Hz grounded wood-block low-return steady-pair 0.49s 1,282 Hz ``` Spectral centroid spans 187 Hz to 1,489 Hz, so these are ten voices rather than ten pitches of one. All normalized to the same 0.62 peak, with RMS from 0.08 (snare-crack) to 0.21 (felt-mallet), which is the difference between a crack and a swell surviving the normalization. Verified end to end in a pty: `aterm platform` cached and played `floor-tom` then `wood-block`, which is the pair the issue names. ## The renderer reads the vocabulary, it does not match it Same property as #1255's figures. `timbre` picks a voice from a table, and a timbre outside it gets a soft mallet pitched from its own name rather than silence. `contour`'s direction word bends the pitch, so `tumbling-fall` falls. `pulse`'s count word and manner word set the rhythm, so `hurried-three` strikes three times. Tested against values the roster does not ship. ## Two synthesis defects the tests caught Worth naming, because both had already produced shipped-looking files: 1. **Every hit broke on its first sample.** The attack envelope starts at exactly 0 and the render loop breaks below 0.0005, so it broke at `elapsed == 0` and wrote nothing. The first ten WAVs were 34 KB of silence and looked fine on disk. 2. **Normalization scaled by one.** Moving the spec into `internal/soundspec` renamed `soundPeak` to `peak`, which the local peak variable in `normalize` already shadowed, so `scale := peak / peak`. The re-rendered files then clipped at 1.00. Neither is visible without measuring the samples, which is why the test asserts peak and determinism rather than that a file exists. ## Silence Off for `--silent`, for `ATERM_SILENT`, and whenever stdout is not a character device, which covers CI and every non-interactive launch. No player on PATH is silence, and a playback failure is swallowed rather than printed on top of the identity card. A silenced launch resolves no player and writes no cache, which the test asserts. `afplay` on macOS; `paplay`, `aplay -q`, then `play -q` on Linux, first on PATH. Playback runs in a goroutine under a 4-second budget so the harness is not held up. ## Layout The spec moved to `aterm/internal/soundspec` so both the launcher and `aterm/soundgen` can use it. `soundgen` is the generator behind `just aterm-sounds`; the samples are committed because the issue asks for them to be auditionable, and because a release must not depend on a roster read. ## Verification * `just aterm-fmt`, `just aterm-lint`, `just aterm-test` clean; `uv run pytest` 724 passed; `pre-commit run --all-files` clean * `internal/soundspec` tests: determinism byte for byte, the normalized peak, the length cap, contour and pulse grammar including syncopation moving rather than adding a hit, WAV header sizes, and filename sanitizing * `sound_test.go`: every embedded sample is a WAV carrying audio, every timbre the fixtures ship has a sample (which is what catches a roster move with no regenerate), the card carries the mark, all three silence paths, and the session stage accepting `--silent` * the pty run above ## Docs `docs/aterm.md` is at 7,995 of 8,000 chars, so `--silent`, `ATERM_SILENT`, and `just aterm-sounds` are documented in `--help` and the recipe description only. `docs/FEATURES.md` carries the boundary change. Third instance on #1274.
feat(aterm): play the role's sound mark from pre-rendered samples (#1256)
All checks were successful
ci / ward-doctor (pull_request) Successful in 9s
ci / aos-eval-tests (pull_request) Successful in 22s
ci / aos-cli-tests (pull_request) Successful in 32s
ci / gate (pull_request) Successful in 57s
d622c61426
Every overlay ships sound_mark{timbre, contour, pulse} per personality and
nothing in the estate had ever played one. A role carries two personalities, so
a role sound mark is a two-note pair: platform is floor-tom then wood-block.

Pre-rendered, as decided. `just aterm-sounds` reads the live roster and renders
one WAV per timbre into aterm/sounds/, which is committed and embedded, so a
launch needs no synth engine and every sample can be auditioned and rejected by
ear before it ships. Adding a personality means regenerating; the generator
prints the spec revision it rendered from.

The renderer reads the vocabulary rather than matching it. timbre picks a voice
from a table with a pitched soft-mallet fallback, contour's direction word bends
the pitch, and pulse's count and manner words set the rhythm. An unshipped mark
still speaks instead of going silent.

Playback is afplay on macOS and paplay, aplay, or play on Linux, the first one
on PATH, in the background so the harness is not held up. Silent for --silent,
for ATERM_SILENT, and whenever stdout is not a character device, which covers
CI and every non-interactive launch. No player on PATH is silence, never a
diagnostic on top of the identity card.

Two synthesis defects the tests caught: every hit broke on its first sample
because the attack envelope starts at zero, and normalization scaled by one
because a local shadowed the peak constant. Both had shipped silent-then-clipped
samples before the assertions existed.

Closes #1256

Co-authored-by: Kai Siren <coilysiren@gmail.com>
Co-authored-by: Claude <noreply@anthropic.com>
Agent-Role: platform
Merge remote-tracking branch 'origin/main' into aos/claude/mu55-sound-mark
All checks were successful
ci / ward-doctor (pull_request) Successful in 10s
ci / aos-eval-tests (pull_request) Successful in 20s
ci / aos-cli-tests (pull_request) Successful in 31s
ci / gate (pull_request) Successful in 52s
dev-base-pr / build (pull_request) Successful in 8m27s
a6728bf2fb
Co-authored-by: Kai Siren <coilysiren@gmail.com>
Co-authored-by: Claude <noreply@anthropic.com>
Agent-Role: platform

# Conflicts:
#	docs/FEATURES.md
Sign in to join this conversation.
No reviewers
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set

Reference
coilyco-flight-deck/agentic-os!1277
No description provided.