diff --git a/.devague/current b/.devague/current index 9d2230f..b227c25 100644 --- a/.devague/current +++ b/.devague/current @@ -1 +1 @@ -harmonics-gives-an-agent-or-robot-its-own-non-spee +harmonics-ships-a-demo-command-one-command-tours-t diff --git a/.devague/current_plan b/.devague/current_plan index 9d2230f..b227c25 100644 --- a/.devague/current_plan +++ b/.devague/current_plan @@ -1 +1 @@ -harmonics-gives-an-agent-or-robot-its-own-non-spee +harmonics-ships-a-demo-command-one-command-tours-t diff --git a/.devague/frames/harmonics-ships-a-demo-command-one-command-tours-t.json b/.devague/frames/harmonics-ships-a-demo-command-one-command-tours-t.json new file mode 100644 index 0000000..d261f12 --- /dev/null +++ b/.devague/frames/harmonics-ships-a-demo-command-one-command-tours-t.json @@ -0,0 +1,229 @@ +{ + "slug": "harmonics-ships-a-demo-command-one-command-tours-t", + "title": "harmonics ships a demo command: one command tours the whole agent voice \u2014 play it live, write a self-contained HTML gallery, or stream the clips into your own code", + "schema_version": 1, + "status": "exported", + "created": "2026-07-08T10:50:15Z", + "updated": "2026-07-08T10:55:36Z", + "claims": [ + { + "id": "c1", + "kind": "announcement", + "text": "harmonics ships a demo command: one command tours the whole agent voice \u2014 play it live, write a self-contained HTML gallery, or stream the clips into your own code", + "origin": "user", + "status": "confirmed", + "honesty_conditions": [ + { + "id": "h1", + "text": "a single 'harmonics demo' with no args runs (dry-run, lists the tour), and each output mode is reachable by one documented flag (--play / --html / --wav / --out / --json)", + "status": "confirmed" + } + ], + "hard_questions": [], + "links": [] + }, + { + "id": "c2", + "kind": "audience", + "text": "developers integrating the harmonics voice, humans who want to hear it without hand-running a script, and other Python code embedding the tour", + "origin": "llm", + "status": "confirmed", + "honesty_conditions": [ + { + "id": "h9", + "text": "explain/README names all three audiences and each maps to a concrete mode: humans -> --play/--html, integrating developers -> showcase(), embedding code -> --json/showcase()", + "status": "confirmed" + } + ], + "hard_questions": [], + "links": [] + }, + { + "id": "c3", + "kind": "before_state", + "text": "v0.5.0 can render the whole voice, but there is no single command that shows it off \u2014 the 24-clip live-test gallery was a hand-built browser script, not shippable", + "origin": "llm", + "status": "confirmed", + "honesty_conditions": [ + { + "id": "h10", + "text": "demo reproduces the live-test tour as a shipped command \u2014 the same clip families the hand-built gallery had, rendered by the real CLI, not a one-off script", + "status": "confirmed" + } + ], + "hard_questions": [], + "links": [] + }, + { + "id": "c4", + "kind": "after_state", + "text": "one command tours the voice across every design axis in three output modes \u2014 play live, write a file (HTML gallery / per-clip WAVs / concatenated WAV), or stream/import \u2014 dry-run by default", + "origin": "llm", + "status": "confirmed", + "honesty_conditions": [ + { + "id": "h11", + "text": "one 'harmonics demo' invocation reaches every output mode via a single flag each and covers all axis groups; tests over the mode flags verify this", + "status": "confirmed" + } + ], + "hard_questions": [], + "links": [] + }, + { + "id": "c5", + "kind": "why_it_matters", + "text": "the voice is the product; a one-command showcase is how anyone discovers and enjoys it without wiring the pipeline themselves, and it doubles as a living end-to-end integration test", + "origin": "llm", + "status": "confirmed", + "honesty_conditions": [ + { + "id": "h12", + "text": "running demo exercises the whole pipeline end-to-end (axes -> mapping/contour -> variation -> synth), so a break anywhere surfaces as a failing demo test", + "status": "confirmed" + } + ], + "hard_questions": [], + "links": [] + }, + { + "id": "c6", + "kind": "boundary", + "text": "packaging over the existing pipeline (axes/mapping/identity/variation/inference/contour/stress + audio backend), not new synthesis \u2014 no new axes, motifs, or general audio-export tooling; the matrix is curated and fixed", + "origin": "llm", + "status": "confirmed", + "honesty_conditions": [ + { + "id": "h13", + "text": "a test asserts demo introduces no new synthesis/axis code path \u2014 it renders only via the existing render_gesture/text_contour/render_wav and the axes vocabulary", + "status": "confirmed" + } + ], + "hard_questions": [], + "links": [] + }, + { + "id": "c7", + "kind": "success_signal", + "text": "harmonics demo with no flags dry-runs (lists clips + axes); --json and showcase() yield note sequences per clip offline with no audio device; --html writes a deterministic self-contained gallery; only --play needs a backend; explain + doctor stay green", + "origin": "llm", + "status": "confirmed", + "honesty_conditions": [ + { + "id": "h2", + "text": "tests assert: no-flag dry-run emits the clip list with axes; --json emits a note sequence per clip; importing harmonics.demo pulls in no audio backend (simpleaudio/sounddevice stay unimported)", + "status": "confirmed" + } + ], + "hard_questions": [], + "links": [] + }, + { + "id": "c8", + "kind": "requirement", + "text": "three output modes: --play (live, sequential, needs a backend), file (--html gallery / --wav DIR per-clip / --out concatenated WAV), and stream/import (public showcase() + --json)", + "origin": "llm", + "status": "confirmed", + "honesty_conditions": [ + { + "id": "h3", + "text": "each mode is tested: --play is guarded by a lazy backend import raising the friendly CliError when absent; --html/--wav/--out write valid files with no audio device; showcase()/--json run with no device", + "status": "confirmed" + } + ], + "hard_questions": [], + "links": [] + }, + { + "id": "c9", + "kind": "requirement", + "text": "a public harmonics.demo.showcase() yields one (label, axes, note_sequence, wav_bytes) per clip, deterministic and offline (WAV bytes rendered with no audio device), so other Python code can embed or stream the tour", + "origin": "llm", + "status": "confirmed", + "honesty_conditions": [ + { + "id": "h4", + "text": "showcase() is importable from harmonics.demo, yields a (label, axes, note_sequence, wav_bytes) tuple per clip, needs no audio device, and is deterministic \u2014 two calls produce byte-identical wav_bytes", + "status": "confirmed" + } + ], + "hard_questions": [], + "links": [] + }, + { + "id": "c10", + "kind": "requirement", + "text": "the showcase matrix lives as data (a table of label + argv/axes) covering intents, identity across ~5 agents, confidence/urgency shading, say-tracks-words sentences, and stress \u2014 so it is easy to extend without touching rendering", + "origin": "llm", + "status": "confirmed", + "honesty_conditions": [ + { + "id": "h5", + "text": "the matrix is a module-level data table; a test enumerates it and asserts it covers all five content groups (intents, identity, shading, say, stress); adding a row requires no change to rendering code", + "status": "confirmed" + } + ], + "hard_questions": [], + "links": [] + }, + { + "id": "c11", + "kind": "requirement", + "text": "the pure text->notes path stays dependency-free and offline-testable: default dry-run lists clips+axes, --json/--html/--wav/--out all work with no audio device and --html is deterministic (snapshot-testable); only --play imports a backend", + "origin": "llm", + "status": "confirmed", + "honesty_conditions": [ + { + "id": "h6", + "text": "the full test path passes with audio backends uninstalled, and --html output is byte-deterministic across two runs (a snapshot test pins it)", + "status": "confirmed" + } + ], + "hard_questions": [], + "links": [] + }, + { + "id": "c12", + "kind": "requirement", + "text": "demo ships an explain catalog entry and keeps doctor / the afi rubric green (every verb resolves in the catalog; producing sound or files needs an explicit flag)", + "origin": "llm", + "status": "confirmed", + "honesty_conditions": [ + { + "id": "h7", + "text": "test_every_catalog_path_resolves covers the ('demo',) path and 'teken cli doctor . --strict' stays green", + "status": "confirmed" + } + ], + "hard_questions": [], + "links": [] + }, + { + "id": "c13", + "kind": "decision", + "text": "the verb is named 'demo' (showcase/tour are rejected alternatives), consistent with the harmonics command surface", + "origin": "llm", + "status": "confirmed", + "honesty_conditions": [], + "hard_questions": [], + "links": [] + }, + { + "id": "c14", + "kind": "requirement", + "text": "demo exposes a top-level --articulation {discrete,speechy,smooth,alien} flag that re-renders the entire tour in that voice (default smooth, note sequences unchanged), and the matrix gains a dedicated 'articulations' group rendering one sentence in all four styles back-to-back", + "origin": "llm", + "status": "confirmed", + "honesty_conditions": [ + { + "id": "h8", + "text": "a test asserts --articulation re-renders every clip through the chosen synth style with note sequences unchanged, and the 'articulations' matrix group yields exactly the four styles (discrete/speechy/smooth/alien) for one sentence", + "status": "confirmed" + } + ], + "hard_questions": [], + "links": [] + } + ], + "open_vagueness": [] +} diff --git a/.devague/plans/harmonics-ships-a-demo-command-one-command-tours-t.json b/.devague/plans/harmonics-ships-a-demo-command-one-command-tours-t.json new file mode 100644 index 0000000..d0e10cf --- /dev/null +++ b/.devague/plans/harmonics-ships-a-demo-command-one-command-tours-t.json @@ -0,0 +1,288 @@ +{ + "slug": "harmonics-ships-a-demo-command-one-command-tours-t", + "title": "harmonics ships a demo command: one command tours the whole agent voice \u2014 play it live, write a self-contained HTML gallery, or stream the clips into your own code", + "frame_slug": "harmonics-ships-a-demo-command-one-command-tours-t", + "schema_version": 1, + "status": "exported", + "created": "2026-07-08T10:56:47Z", + "updated": "2026-07-08T11:02:30Z", + "targets": [ + { + "id": "c1", + "kind": "announcement", + "text": "harmonics ships a demo command: one command tours the whole agent voice \u2014 play it live, write a self-contained HTML gallery, or stream the clips into your own code" + }, + { + "id": "h1", + "kind": "honesty", + "text": "a single 'harmonics demo' with no args runs (dry-run, lists the tour), and each output mode is reachable by one documented flag (--play / --html / --wav / --out / --json)" + }, + { + "id": "c2", + "kind": "audience", + "text": "developers integrating the harmonics voice, humans who want to hear it without hand-running a script, and other Python code embedding the tour" + }, + { + "id": "h9", + "kind": "honesty", + "text": "explain/README names all three audiences and each maps to a concrete mode: humans -> --play/--html, integrating developers -> showcase(), embedding code -> --json/showcase()" + }, + { + "id": "c3", + "kind": "before_state", + "text": "v0.5.0 can render the whole voice, but there is no single command that shows it off \u2014 the 24-clip live-test gallery was a hand-built browser script, not shippable" + }, + { + "id": "h10", + "kind": "honesty", + "text": "demo reproduces the live-test tour as a shipped command \u2014 the same clip families the hand-built gallery had, rendered by the real CLI, not a one-off script" + }, + { + "id": "c4", + "kind": "after_state", + "text": "one command tours the voice across every design axis in three output modes \u2014 play live, write a file (HTML gallery / per-clip WAVs / concatenated WAV), or stream/import \u2014 dry-run by default" + }, + { + "id": "h11", + "kind": "honesty", + "text": "one 'harmonics demo' invocation reaches every output mode via a single flag each and covers all axis groups; tests over the mode flags verify this" + }, + { + "id": "c5", + "kind": "why_it_matters", + "text": "the voice is the product; a one-command showcase is how anyone discovers and enjoys it without wiring the pipeline themselves, and it doubles as a living end-to-end integration test" + }, + { + "id": "h12", + "kind": "honesty", + "text": "running demo exercises the whole pipeline end-to-end (axes -> mapping/contour -> variation -> synth), so a break anywhere surfaces as a failing demo test" + }, + { + "id": "c6", + "kind": "boundary", + "text": "packaging over the existing pipeline (axes/mapping/identity/variation/inference/contour/stress + audio backend), not new synthesis \u2014 no new axes, motifs, or general audio-export tooling; the matrix is curated and fixed" + }, + { + "id": "h13", + "kind": "honesty", + "text": "a test asserts demo introduces no new synthesis/axis code path \u2014 it renders only via the existing render_gesture/text_contour/render_wav and the axes vocabulary" + }, + { + "id": "c7", + "kind": "success_signal", + "text": "harmonics demo with no flags dry-runs (lists clips + axes); --json and showcase() yield note sequences per clip offline with no audio device; --html writes a deterministic self-contained gallery; only --play needs a backend; explain + doctor stay green" + }, + { + "id": "h2", + "kind": "honesty", + "text": "tests assert: no-flag dry-run emits the clip list with axes; --json emits a note sequence per clip; importing harmonics.demo pulls in no audio backend (simpleaudio/sounddevice stay unimported)" + }, + { + "id": "c8", + "kind": "requirement", + "text": "three output modes: --play (live, sequential, needs a backend), file (--html gallery / --wav DIR per-clip / --out concatenated WAV), and stream/import (public showcase() + --json)" + }, + { + "id": "h3", + "kind": "honesty", + "text": "each mode is tested: --play is guarded by a lazy backend import raising the friendly CliError when absent; --html/--wav/--out write valid files with no audio device; showcase()/--json run with no device" + }, + { + "id": "c9", + "kind": "requirement", + "text": "a public harmonics.demo.showcase() yields one (label, axes, note_sequence, wav_bytes) per clip, deterministic and offline (WAV bytes rendered with no audio device), so other Python code can embed or stream the tour" + }, + { + "id": "h4", + "kind": "honesty", + "text": "showcase() is importable from harmonics.demo, yields a (label, axes, note_sequence, wav_bytes) tuple per clip, needs no audio device, and is deterministic \u2014 two calls produce byte-identical wav_bytes" + }, + { + "id": "c10", + "kind": "requirement", + "text": "the showcase matrix lives as data (a table of label + argv/axes) covering intents, identity across ~5 agents, confidence/urgency shading, say-tracks-words sentences, and stress \u2014 so it is easy to extend without touching rendering" + }, + { + "id": "h5", + "kind": "honesty", + "text": "the matrix is a module-level data table; a test enumerates it and asserts it covers all five content groups (intents, identity, shading, say, stress); adding a row requires no change to rendering code" + }, + { + "id": "c11", + "kind": "requirement", + "text": "the pure text->notes path stays dependency-free and offline-testable: default dry-run lists clips+axes, --json/--html/--wav/--out all work with no audio device and --html is deterministic (snapshot-testable); only --play imports a backend" + }, + { + "id": "h6", + "kind": "honesty", + "text": "the full test path passes with audio backends uninstalled, and --html output is byte-deterministic across two runs (a snapshot test pins it)" + }, + { + "id": "c12", + "kind": "requirement", + "text": "demo ships an explain catalog entry and keeps doctor / the afi rubric green (every verb resolves in the catalog; producing sound or files needs an explicit flag)" + }, + { + "id": "h7", + "kind": "honesty", + "text": "test_every_catalog_path_resolves covers the ('demo',) path and 'teken cli doctor . --strict' stays green" + }, + { + "id": "c14", + "kind": "requirement", + "text": "demo exposes a top-level --articulation {discrete,speechy,smooth,alien} flag that re-renders the entire tour in that voice (default smooth, note sequences unchanged), and the matrix gains a dedicated 'articulations' group rendering one sentence in all four styles back-to-back" + }, + { + "id": "h8", + "kind": "honesty", + "text": "a test asserts --articulation re-renders every clip through the chosen synth style with note sequences unchanged, and the 'articulations' matrix group yields exactly the four styles (discrete/speechy/smooth/alien) for one sentence" + } + ], + "tasks": [ + { + "id": "t1", + "summary": "Showcase matrix as data (harmonics/demo/matrix.py)", + "origin": "llm", + "status": "confirmed", + "acceptance_criteria": [ + "harmonics/demo/matrix.py defines a module-level data table (list of clip specs: label, group, kind [play|say], plus axes/argv or sentence) with NO rendering code and NO audio import", + "tests/test_demo_matrix.py enumerates the table and asserts all six groups are present: intents (all 6 intents), identity (~5 distinct agents), shading (confidence+urgency), say (sentences), stress, articulations", + "the articulations group renders exactly one sentence in the four styles discrete/speechy/smooth/alien; adding a row requires no change outside matrix.py" + ], + "deps": [], + "covers": [ + "c3", + "h10", + "c6", + "c10", + "h5", + "c14", + "h8" + ] + }, + { + "id": "t2", + "summary": "Public showcase() core (harmonics/demo/core.py + demo/__init__.py)", + "origin": "llm", + "status": "confirmed", + "acceptance_criteria": [ + "harmonics.demo.showcase() is importable and yields one (label, axes, note_sequence, wav_bytes) per matrix clip; a test asserts the 4-tuple shape and that the count equals the matrix length", + "showcase() is deterministic and offline: two calls produce byte-identical wav_bytes, and importing harmonics.demo imports no audio backend (simpleaudio/sounddevice absent from sys.modules)", + "showcase() renders only via the existing pipeline (render_gesture/text_contour/apply_stress/apply_variation/identity + audio.synth.render_wav) \u2014 a test asserts the note sequence for a sample clip equals the direct pipeline output (no new synthesis)" + ], + "deps": [ + "t1" + ], + "covers": [ + "c9", + "h4", + "c5", + "h12", + "h13", + "c11", + "h2" + ] + }, + { + "id": "t3", + "summary": "HTML gallery renderer (harmonics/demo/gallery.py)", + "origin": "llm", + "status": "confirmed", + "acceptance_criteria": [ + "harmonics/demo/gallery.py render_gallery(clips) -> str returns a self-contained HTML string: WAVs embedded as base64 data-URIs, per-clip label/axes/notes, light+dark themes, and NO external hosts (no http(s):// references)", + "output is byte-deterministic: render_gallery(clips) called twice on the same clips returns identical strings; tests/test_demo_gallery.py pins this and asserts the no-external-reference property, with no audio device" + ], + "deps": [ + "t2" + ], + "covers": [ + "h6", + "c11" + ] + }, + { + "id": "t4", + "summary": "File-output modes (harmonics/demo/files.py)", + "origin": "llm", + "status": "confirmed", + "acceptance_criteria": [ + "harmonics/demo/files.py provides write_wav_dir(clips, dir) [one WAV per clip], write_concat_wav(clips, path) [one concatenated WAV with a short gap between clips], and json_payload(clips) [note-sequence-per-clip structure]", + "tests/test_demo_files.py asserts all three run with no audio device, the written files have a valid RIFF/WAVE header, and json_payload(clips) is JSON-serializable with one note sequence per clip" + ], + "deps": [ + "t2" + ], + "covers": [ + "c8", + "h3" + ] + }, + { + "id": "t5", + "summary": "Live playback mode (harmonics/demo/play.py)", + "origin": "llm", + "status": "confirmed", + "acceptance_criteria": [ + "harmonics/demo/play.py play_clips(clips) plays each clip wav_bytes in sequence via a LAZY backend import (simpleaudio/sounddevice); when neither is installed it raises the friendly CliError, never a bare ImportError", + "tests/test_demo_play.py asserts importing harmonics.demo.play imports no backend at module load, and play_clips raises CliError (not ImportError) when both backends are absent (monkeypatched sys.modules)" + ], + "deps": [ + "t2" + ], + "covers": [ + "c8", + "h3" + ] + }, + { + "id": "t6", + "summary": "The demo CLI verb + registration (harmonics/cli/_commands/demo.py, wire into cli/__init__.py)", + "origin": "llm", + "status": "confirmed", + "acceptance_criteria": [ + "harmonics demo with no flags dry-runs: emits the clip list with axes to stdout via emit_result, imports no audio backend, exits 0; --json emits the note-sequence-per-clip payload on the same stream", + "each output mode is reachable by one flag: --html FILE (gallery), --wav DIR (per-clip), --out FILE (concatenated), --play (live); producing sound or files requires the explicit flag; I/O failures raise CliError (no traceback leaks)", + "--articulation {discrete,speechy,smooth,alien} re-renders the whole tour through that synth style (default smooth); a test asserts changing --articulation changes wav bytes but leaves note sequences unchanged", + "the verb is registered via one register(sub) line in cli/__init__.py; harmonics demo --help works; cmd_demo returns int|None; tests/test_demo_cli.py covers dry-run, --json, each file flag (tmp path), the --play CliError path, and --articulation" + ], + "deps": [ + "t3", + "t4", + "t5" + ], + "covers": [ + "c1", + "h1", + "c4", + "h11", + "c7", + "h2", + "c8", + "h3", + "c12", + "c14", + "h8" + ] + }, + { + "id": "t7", + "summary": "Explain catalog entry for demo + doctor stays green (harmonics/explain/catalog.py)", + "origin": "llm", + "status": "confirmed", + "acceptance_criteria": [ + "harmonics/explain/catalog.py gains a ('demo',) entry documenting the three modes, every flag, and --articulation; test_every_catalog_path_resolves covers ('demo',)", + "the explain entry names all three audiences mapped to modes (humans -> --play/--html, integrating developers -> showcase(), embedding code -> --json/showcase()); teken cli doctor . --strict stays green" + ], + "deps": [ + "t6" + ], + "covers": [ + "c2", + "h9", + "c12", + "h7" + ] + } + ], + "risks": [] +} diff --git a/CHANGELOG.md b/CHANGELOG.md index 20f38d0..93ddea0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,39 @@ All notable changes to this project will be documented in this file. Format follows [Keep a Changelog](https://keepachangelog.com/). This project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [0.6.0] - 2026-07-08 + +### Added + +- **`harmonics demo`** — one command that tours the whole agent voice across the + design spine, in three output modes (issue #4): + - **Play / hear it** — `--play` streams every clip through the audio backend; + `--html FILE` writes a self-contained, browser-playable gallery (WAVs + embedded as data-URIs, per-clip axes + note summary, both light/dark + themes, deterministic so it is snapshot-testable). + - **Write files** — `--wav DIR` (one WAV per clip) and `--out FILE` (one + concatenated tour WAV with short gaps between clips). + - **Stream / import** — a public `harmonics.demo.showcase()` yields one + `(label, axes, note_sequence, wav_bytes)` per clip; `--json` emits the + note-sequence-per-clip payload on stdout. + - **Dry-run by default** — no flags lists the curated clips + axes; producing + sound or files needs an explicit flag. + - **`--articulation {discrete,speechy,smooth,alien}`** re-renders the whole + tour in one voice; the default renders each clip in its own articulation, so + the dedicated `articulations` section still demonstrates all four styles. + - The tour is a curated 28-clip data matrix (`harmonics/demo/matrix.py`) across + six groups — intents, identity, confidence/urgency shading, say-tracks-words, + stress, and articulations — packaging over the existing v0.5.0 pipeline with + **no new synthesis**. The pure text→notes path stays offline and + dependency-free (only `--play` needs an audio backend). +- `harmonics.cli._commands.say.render_notes()` — the sentence → notes pipeline + extracted as a public function, now shared by `harmonics say` and `demo` (a + single source of truth for the spoken voice; `cmd_say` behaviour unchanged). + +### Changed + +- The `explain` catalog gains a `demo` entry, and the root page lists it. + ## [0.5.0] - 2026-07-08 ### Added diff --git a/README.md b/README.md index cbcdc67..adb7021 100644 --- a/README.md +++ b/README.md @@ -38,16 +38,19 @@ phonemes, only meaning. ## Status -Scaffold today: the agent-first CLI, mesh identity, skill kit, and CI baseline -are in place; the audio domain is being built. The pure text→notes core is -designed to stay dependency-free and offline-testable — tests assert on note -sequences, not on a speaker. Coming text-to-notes verbs: +The agent-first CLI, mesh identity, skill kit, and CI baseline are in place, and +the **voice domain has shipped**. The pure text→notes core stays dependency-free +and offline-testable — tests assert on note sequences, not on a speaker. The +text-to-notes verbs: - `harmonics play --intent success --confidence high --urgency low` — render explicit axes to a note sequence (dry-run by default; `--play`/`--out` produce sound or a file). - `harmonics say "done, tests all green"` — infer the axes from a sentence, then render. +- `harmonics demo` — tour the whole voice in one command: dry-run by default, or + `--play` / `--html` / `--wav` / `--out` / `--json` to hear it, write a + browser-playable gallery, or stream the clips into your own code. ## What you get today @@ -83,6 +86,9 @@ command you run is `harmonics`. | `explain ` | Markdown docs for any noun/verb path. | | `overview` | Read-only descriptive snapshot of the agent. | | `doctor` | Check the agent-identity invariants (prompt-file-present, backend-consistency). | +| `play` | Render explicit axes to a note sequence (dry-run; `--wav`/`--play` for audio). | +| `say ""` | Infer axes from a sentence and render it in the agent's voice. | +| `demo` | Tour the whole voice: `--play` / `--html` / `--wav` / `--out` / `--json` (dry-run by default). | | `cli overview` | Describe the CLI surface itself. | Every command supports `--json`. Results go to stdout, errors/diagnostics to diff --git a/docs/plans/2026-07-08-harmonics-ships-a-demo-command-one-command-tours-t.md b/docs/plans/2026-07-08-harmonics-ships-a-demo-command-one-command-tours-t.md new file mode 100644 index 0000000..937d12b --- /dev/null +++ b/docs/plans/2026-07-08-harmonics-ships-a-demo-command-one-command-tours-t.md @@ -0,0 +1,66 @@ +# Build Plan — harmonics ships a demo command: one command tours the whole agent voice — play it live, write a self-contained HTML gallery, or stream the clips into your own code + +slug: `harmonics-ships-a-demo-command-one-command-tours-t` · status: `exported` · from frame: `harmonics-ships-a-demo-command-one-command-tours-t` + +> harmonics ships a demo command: one command tours the whole agent voice — play it live, write a self-contained HTML gallery, or stream the clips into your own code + +## Tasks + +### t1 — Showcase matrix as data (harmonics/demo/matrix.py) + +- covers: c3, h10, c6, c10, h5, c14, h8 +- acceptance: + - harmonics/demo/matrix.py defines a module-level data table (list of clip specs: label, group, kind [play|say], plus axes/argv or sentence) with NO rendering code and NO audio import + - tests/test_demo_matrix.py enumerates the table and asserts all six groups are present: intents (all 6 intents), identity (~5 distinct agents), shading (confidence+urgency), say (sentences), stress, articulations + - the articulations group renders exactly one sentence in the four styles discrete/speechy/smooth/alien; adding a row requires no change outside matrix.py + +### t2 — Public showcase() core (harmonics/demo/core.py + demo/__init__.py) + +- depends on: t1 +- covers: c9, h4, c5, h12, h13, c11, h2 +- acceptance: + - harmonics.demo.showcase() is importable and yields one (label, axes, note_sequence, wav_bytes) per matrix clip; a test asserts the 4-tuple shape and that the count equals the matrix length + - showcase() is deterministic and offline: two calls produce byte-identical wav_bytes, and importing harmonics.demo imports no audio backend (simpleaudio/sounddevice absent from sys.modules) + - showcase() renders only via the existing pipeline (render_gesture/text_contour/apply_stress/apply_variation/identity + audio.synth.render_wav) — a test asserts the note sequence for a sample clip equals the direct pipeline output (no new synthesis) + +### t3 — HTML gallery renderer (harmonics/demo/gallery.py) + +- depends on: t2 +- covers: h6, c11 +- acceptance: + - harmonics/demo/gallery.py render_gallery(clips) -> str returns a self-contained HTML string: WAVs embedded as base64 data-URIs, per-clip label/axes/notes, light+dark themes, and NO external hosts (no http(s):// references) + - output is byte-deterministic: render_gallery(clips) called twice on the same clips returns identical strings; tests/test_demo_gallery.py pins this and asserts the no-external-reference property, with no audio device + +### t4 — File-output modes (harmonics/demo/files.py) + +- depends on: t2 +- covers: c8, h3 +- acceptance: + - harmonics/demo/files.py provides write_wav_dir(clips, dir) [one WAV per clip], write_concat_wav(clips, path) [one concatenated WAV with a short gap between clips], and json_payload(clips) [note-sequence-per-clip structure] + - tests/test_demo_files.py asserts all three run with no audio device, the written files have a valid RIFF/WAVE header, and json_payload(clips) is JSON-serializable with one note sequence per clip + +### t5 — Live playback mode (harmonics/demo/play.py) + +- depends on: t2 +- covers: c8, h3 +- acceptance: + - harmonics/demo/play.py play_clips(clips) plays each clip wav_bytes in sequence via a LAZY backend import (simpleaudio/sounddevice); when neither is installed it raises the friendly CliError, never a bare ImportError + - tests/test_demo_play.py asserts importing harmonics.demo.play imports no backend at module load, and play_clips raises CliError (not ImportError) when both backends are absent (monkeypatched sys.modules) + +### t6 — The demo CLI verb + registration (harmonics/cli/_commands/demo.py, wire into cli/__init__.py) + +- depends on: t3, t4, t5 +- covers: c1, h1, c4, h11, c7, h2, c8, h3, c12, c14, h8 +- acceptance: + - harmonics demo with no flags dry-runs: emits the clip list with axes to stdout via emit_result, imports no audio backend, exits 0; --json emits the note-sequence-per-clip payload on the same stream + - each output mode is reachable by one flag: --html FILE (gallery), --wav DIR (per-clip), --out FILE (concatenated), --play (live); producing sound or files requires the explicit flag; I/O failures raise CliError (no traceback leaks) + - --articulation {discrete,speechy,smooth,alien} re-renders the whole tour through that synth style (default smooth); a test asserts changing --articulation changes wav bytes but leaves note sequences unchanged + - the verb is registered via one register(sub) line in cli/__init__.py; harmonics demo --help works; cmd_demo returns int|None; tests/test_demo_cli.py covers dry-run, --json, each file flag (tmp path), the --play CliError path, and --articulation + +### t7 — Explain catalog entry for demo + doctor stays green (harmonics/explain/catalog.py) + +- depends on: t6 +- covers: c2, h9, c12, h7 +- acceptance: + - harmonics/explain/catalog.py gains a ('demo',) entry documenting the three modes, every flag, and --articulation; test_every_catalog_path_resolves covers ('demo',) + - the explain entry names all three audiences mapped to modes (humans -> --play/--html, integrating developers -> showcase(), embedding code -> --json/showcase()); teken cli doctor . --strict stays green diff --git a/docs/specs/2026-07-08-harmonics-ships-a-demo-command-one-command-tours-t.md b/docs/specs/2026-07-08-harmonics-ships-a-demo-command-one-command-tours-t.md new file mode 100644 index 0000000..c6de668 --- /dev/null +++ b/docs/specs/2026-07-08-harmonics-ships-a-demo-command-one-command-tours-t.md @@ -0,0 +1,53 @@ +# harmonics ships a demo command: one command tours the whole agent voice — play it live, write a self-contained HTML gallery, or stream the clips into your own code + +> harmonics ships a demo command: one command tours the whole agent voice — play it live, write a self-contained HTML gallery, or stream the clips into your own code + +## Audience + +- developers integrating the harmonics voice, humans who want to hear it without hand-running a script, and other Python code embedding the tour + +## Before → After + +- Before: v0.5.0 can render the whole voice, but there is no single command that shows it off — the 24-clip live-test gallery was a hand-built browser script, not shippable +- After: one command tours the voice across every design axis in three output modes — play live, write a file (HTML gallery / per-clip WAVs / concatenated WAV), or stream/import — dry-run by default + +## Why it matters + +- the voice is the product; a one-command showcase is how anyone discovers and enjoys it without wiring the pipeline themselves, and it doubles as a living end-to-end integration test + +## Requirements + +- three output modes: --play (live, sequential, needs a backend), file (--html gallery / --wav DIR per-clip / --out concatenated WAV), and stream/import (public showcase() + --json) + - honesty: each mode is tested: --play is guarded by a lazy backend import raising the friendly CliError when absent; --html/--wav/--out write valid files with no audio device; showcase()/--json run with no device +- a public harmonics.demo.showcase() yields one (label, axes, note_sequence, wav_bytes) per clip, deterministic and offline (WAV bytes rendered with no audio device), so other Python code can embed or stream the tour + - honesty: showcase() is importable from harmonics.demo, yields a (label, axes, note_sequence, wav_bytes) tuple per clip, needs no audio device, and is deterministic — two calls produce byte-identical wav_bytes +- the showcase matrix lives as data (a table of label + argv/axes) covering intents, identity across ~5 agents, confidence/urgency shading, say-tracks-words sentences, and stress — so it is easy to extend without touching rendering + - honesty: the matrix is a module-level data table; a test enumerates it and asserts it covers all five content groups (intents, identity, shading, say, stress); adding a row requires no change to rendering code +- the pure text->notes path stays dependency-free and offline-testable: default dry-run lists clips+axes, --json/--html/--wav/--out all work with no audio device and --html is deterministic (snapshot-testable); only --play imports a backend + - honesty: the full test path passes with audio backends uninstalled, and --html output is byte-deterministic across two runs (a snapshot test pins it) +- demo ships an explain catalog entry and keeps doctor / the afi rubric green (every verb resolves in the catalog; producing sound or files needs an explicit flag) + - honesty: test_every_catalog_path_resolves covers the ('demo',) path and 'teken cli doctor . --strict' stays green +- demo exposes a top-level --articulation {discrete,speechy,smooth,alien} flag that re-renders the entire tour in that voice (default smooth, note sequences unchanged), and the matrix gains a dedicated 'articulations' group rendering one sentence in all four styles back-to-back + - honesty: a test asserts --articulation re-renders every clip through the chosen synth style with note sequences unchanged, and the 'articulations' matrix group yields exactly the four styles (discrete/speechy/smooth/alien) for one sentence + +## Honesty conditions + +- a single 'harmonics demo' with no args runs (dry-run, lists the tour), and each output mode is reachable by one documented flag (--play / --html / --wav / --out / --json) +- explain/README names all three audiences and each maps to a concrete mode: humans -> --play/--html, integrating developers -> showcase(), embedding code -> --json/showcase() +- demo reproduces the live-test tour as a shipped command — the same clip families the hand-built gallery had, rendered by the real CLI, not a one-off script +- one 'harmonics demo' invocation reaches every output mode via a single flag each and covers all axis groups; tests over the mode flags verify this +- running demo exercises the whole pipeline end-to-end (axes -> mapping/contour -> variation -> synth), so a break anywhere surfaces as a failing demo test +- a test asserts demo introduces no new synthesis/axis code path — it renders only via the existing render_gesture/text_contour/render_wav and the axes vocabulary +- tests assert: no-flag dry-run emits the clip list with axes; --json emits a note sequence per clip; importing harmonics.demo pulls in no audio backend (simpleaudio/sounddevice stay unimported) + +## Success signals + +- harmonics demo with no flags dry-runs (lists clips + axes); --json and showcase() yield note sequences per clip offline with no audio device; --html writes a deterministic self-contained gallery; only --play needs a backend; explain + doctor stay green + +## Scope / boundaries + +- packaging over the existing pipeline (axes/mapping/identity/variation/inference/contour/stress + audio backend), not new synthesis — no new axes, motifs, or general audio-export tooling; the matrix is curated and fixed + +## Decisions + +- the verb is named 'demo' (showcase/tour are rejected alternatives), consistent with the harmonics command surface diff --git a/harmonics/cli/__init__.py b/harmonics/cli/__init__.py index 96b5326..8f8a2a9 100644 --- a/harmonics/cli/__init__.py +++ b/harmonics/cli/__init__.py @@ -63,6 +63,7 @@ def _argv_has_json(argv: list[str] | None) -> bool: def _build_parser() -> argparse.ArgumentParser: from harmonics.cli._commands import cli as _cli_group + from harmonics.cli._commands import demo as _demo_cmd from harmonics.cli._commands import doctor as _doctor_cmd from harmonics.cli._commands import explain as _explain_cmd from harmonics.cli._commands import learn as _learn_cmd @@ -95,6 +96,7 @@ def _build_parser() -> argparse.ArgumentParser: _doctor_cmd.register(sub) _play_cmd.register(sub) _say_cmd.register(sub) + _demo_cmd.register(sub) _cli_group.register(sub) # Register your own noun groups here: # from harmonics.cli._commands import my_noun as _my_noun_group diff --git a/harmonics/cli/_commands/demo.py b/harmonics/cli/_commands/demo.py new file mode 100644 index 0000000..b280edb --- /dev/null +++ b/harmonics/cli/_commands/demo.py @@ -0,0 +1,191 @@ +"""``harmonics demo`` — a curated tour of the whole agent voice. + +Wires the already-built demo pipeline into a CLI verb: :func:`harmonics.demo. +showcase` renders the curated tour (:mod:`harmonics.demo.matrix`) to a list of +:class:`~harmonics.demo.core.Clip`\\ s, and this module only decides where +those clips GO — played live, written to a gallery/WAVs, or streamed as the +note-sequence-per-clip JSON payload. It makes **no new synthesis decisions** +of its own, mirroring ``harmonics say``'s dispatch shape +(:mod:`harmonics.cli._commands.say`): + +* **``--play``** (takes priority over every file flag): plays every clip live + via :func:`harmonics.demo.play.play_clips` (lazy backend: ``simpleaudio`` + then ``sounddevice``); with neither installed it raises the pipeline's own + friendly :class:`~harmonics.cli._errors.CliError` — this module lets that + propagate rather than re-wrapping it into a generic error. +* **File flags** (``--html``/``--wav``/``--out``, any combination, all + independent of one another): :func:`harmonics.demo.gallery.render_gallery` + for a self-contained HTML gallery, :func:`harmonics.demo.files. + write_wav_dir` for one WAV per clip, :func:`harmonics.demo.files. + write_concat_wav` for the whole tour as one WAV. Each write is wrapped so an + ``OSError`` (missing parent directory, permission denied, …) becomes a + structured :class:`CliError` (exit :data:`~harmonics.cli._errors. + EXIT_ENV_ERROR`) instead of a bare traceback. +* **Neither**: the dry-run default. ``--json`` streams :func:`harmonics.demo. + files.json_payload` (one note-sequence entry per clip); otherwise a compact + human listing, one line per clip. + +``--articulation`` (default ``None``) is deliberately NOT ``"smooth"``: +``None`` means "render each clip in its OWN curated articulation" (see +:func:`harmonics.demo.core.showcase`'s own doc), so the dedicated +``"articulations"`` tour section still demonstrates all four wav styles side +by side. Passing an explicit style (e.g. ``--articulation alien``) overrides +the WHOLE tour to that one voice. Either way the note sequences themselves +never change — only how they are synthesized to wav — so ``--json``/the +dry-run listing are identical regardless of ``--articulation``. +""" + +from __future__ import annotations + +import argparse +from pathlib import Path + +from harmonics.cli._errors import EXIT_ENV_ERROR, CliError +from harmonics.cli._output import emit_result +from harmonics.demo import Clip, showcase +from harmonics.demo.files import json_payload, write_concat_wav, write_wav_dir +from harmonics.demo.gallery import render_gallery +from harmonics.demo.play import play_clips + +#: Axis fields shown in the dry-run listing's summary, in the design-spine's +#: canonical order (matches ``harmonics.axes.Axes.as_dict``). +_AXIS_FIELDS: tuple[str, ...] = ("intent", "confidence", "urgency", "state", "identity") + + +def _raise_write_error(path: str, err: OSError) -> None: + """Translate an ``OSError`` from a file write into the structured + :class:`CliError` contract (missing parent dir, permission denied, disk + full, …) instead of letting it bubble up as an "unexpected" error.""" + raise CliError( + code=EXIT_ENV_ERROR, + message=f"could not write {path}: {err}", + remediation="check the path, permissions, and that the parent directory exists", + ) from err + + +def _axes_summary(clip: Clip) -> str: + """The clip's SET (non-``None``) axes fields as a compact ``k=v`` list, + for the dry-run listing.""" + fields = clip.axes.as_dict() + return ", ".join( + f"{field}={fields[field]}" for field in _AXIS_FIELDS if fields[field] is not None + ) + + +def _format_text(clips: list[Clip]) -> str: + """A compact, human-readable listing: one line per clip.""" + if not clips: + return "(no clips)" + lines = [f"{clip.label} [{_axes_summary(clip)}] {len(clip.notes)} notes" for clip in clips] + return "\n".join(lines) + + +def _play_live(clips: list[Clip], json_mode: bool) -> None: + """``--play``: play every clip live. ``play_clips`` raises its own + ``CliError`` when no backend is installed; that propagates unchanged.""" + play_clips(clips) + if json_mode: + emit_result({"played": len(clips)}, json_mode=True) + else: + emit_result(f"played {len(clips)} clip(s)", json_mode=False) + + +def _write_requested_files(clips: list[Clip], args: argparse.Namespace) -> list[str]: + """Write whichever of ``--html``/``--wav``/``--out`` were requested — all + independent of one another (unlike ``--play``, which takes priority and + returns early); returns the summary strings for the "wrote" line.""" + wrote: list[str] = [] + if args.html: + try: + Path(args.html).write_text(render_gallery(clips), encoding="utf-8") + except OSError as err: + _raise_write_error(args.html, err) + wrote.append(args.html) + if args.wav: + try: + paths = write_wav_dir(clips, args.wav) + except OSError as err: + _raise_write_error(args.wav, err) + wrote.append(f"{args.wav} ({len(paths)} files)") + if args.out: + try: + write_concat_wav(clips, args.out) + except OSError as err: + _raise_write_error(args.out, err) + wrote.append(args.out) + return wrote + + +def cmd_demo(args: argparse.Namespace) -> int | None: + json_mode = bool(getattr(args, "json", False)) + + clips = showcase(articulation=args.articulation) + + if args.play: + _play_live(clips, json_mode) + return None + + wrote = _write_requested_files(clips, args) + if wrote: + if json_mode: + emit_result({"wrote": wrote, "clips": len(clips)}, json_mode=True) + else: + emit_result(f"wrote {', '.join(wrote)}", json_mode=False) + return None + + if json_mode: + emit_result(json_payload(clips), json_mode=True) + else: + emit_result(_format_text(clips), json_mode=False) + return None + + +def register(sub: argparse._SubParsersAction) -> None: + p = sub.add_parser( + "demo", + help=( + "Tour the whole agent voice: play it, write an HTML gallery / " + "WAVs, or stream the note sequences (dry-run by default)." + ), + ) + p.add_argument( + "--json", + action="store_true", + help="Emit the note-sequence-per-clip payload as JSON.", + ) + p.add_argument( + "--html", + default=None, + metavar="FILE", + help="Write a self-contained, browser-playable HTML gallery to FILE.", + ) + p.add_argument( + "--wav", + default=None, + metavar="DIR", + help="Write one WAV per clip into DIR.", + ) + p.add_argument( + "--out", + default=None, + metavar="FILE", + help="Write one concatenated WAV of the whole tour to FILE.", + ) + p.add_argument( + "--play", + action="store_true", + help=( + "Play every clip live via simpleaudio/sounddevice if installed " + "(else a friendly error)." + ), + ) + p.add_argument( + "--articulation", + choices=("discrete", "speechy", "smooth", "alien"), + default=None, + help=( + "Re-render the WHOLE tour in this voice; default: each clip's " + "own (the 'articulations' section already shows all four)." + ), + ) + p.set_defaults(func=cmd_demo) diff --git a/harmonics/cli/_commands/say.py b/harmonics/cli/_commands/say.py index 47f2653..a0d5349 100644 --- a/harmonics/cli/_commands/say.py +++ b/harmonics/cli/_commands/say.py @@ -329,22 +329,41 @@ def _emit(seq: list[NoteEvent], args: argparse.Namespace, json_mode: bool) -> No _dry_run(seq, json_mode) -def cmd_say(args: argparse.Namespace) -> int | None: - json_mode = bool(getattr(args, "json", False)) - - clean, stressed = parse_emphasis(args.sentence) +def render_notes( + sentence: str, + *, + agent: str | None = None, + seq: int | str | None = None, +) -> list[NoteEvent]: + """Render ``sentence`` to the agent's voice note sequence — the shared + core of :func:`cmd_say` (also reused by ``harmonics demo``). + + Parses emphasis markers, infers the axes from the clean text, resolves + the speaking agent's voice signature (``agent``, else this package's own + default identity), renders the word-tracking melodic contour, axis-shades + it, re-emphasizes stressed words, and applies the optional deterministic + ``seq`` micro-variation — in that order, exactly mirroring ``cmd_say``. + """ + clean, stressed = parse_emphasis(sentence) axes = infer_axes(clean) - if args.as_agent: - axes = axes.with_(identity=args.as_agent) - sig = signature_for(args.as_agent) + if agent: + axes = axes.with_(identity=agent) + sig = signature_for(agent) else: sig = derive_signature(DEFAULT_IDENTITY) - seq = text_contour(clean, root_pitch=sig.root_pitch, instrument=sig.instrument) - seq = _shade_by_axes(seq, axes) - seq = apply_stress(seq, stressed) - if args.seq is not None: - seq = apply_variation(seq, args.seq) + seq_notes = text_contour(clean, root_pitch=sig.root_pitch, instrument=sig.instrument) + seq_notes = _shade_by_axes(seq_notes, axes) + seq_notes = apply_stress(seq_notes, stressed) + if seq is not None: + seq_notes = apply_variation(seq_notes, seq) + return seq_notes + + +def cmd_say(args: argparse.Namespace) -> int | None: + json_mode = bool(getattr(args, "json", False)) + + seq = render_notes(args.sentence, agent=args.as_agent, seq=args.seq) _emit(seq, args, json_mode) return None diff --git a/harmonics/demo/__init__.py b/harmonics/demo/__init__.py new file mode 100644 index 0000000..328d9cc --- /dev/null +++ b/harmonics/demo/__init__.py @@ -0,0 +1,23 @@ +"""The ``harmonics demo`` package — a curated tour of the agent voice. + +Exposes the public surface: the curated data (:mod:`harmonics.demo.matrix`) +and the deterministic, offline renderer (:mod:`harmonics.demo.core`) that +turns it into concrete clips. Deliberately does NOT import the (not-yet- +built) gallery/file/live-playback modules here, so importing this package +stays backend-free — no HTML renderer, no file writer, no audio device import +anywhere in this module's own import graph. +""" + +from __future__ import annotations + +from harmonics.demo.core import Clip, showcase +from harmonics.demo.matrix import GROUPS, MATRIX, ClipSpec, iter_clips + +__all__ = [ + "showcase", + "Clip", + "ClipSpec", + "GROUPS", + "MATRIX", + "iter_clips", +] diff --git a/harmonics/demo/core.py b/harmonics/demo/core.py new file mode 100644 index 0000000..9c2a08d --- /dev/null +++ b/harmonics/demo/core.py @@ -0,0 +1,101 @@ +"""``harmonics.demo.showcase`` — the public, deterministic, offline renderer. + +Turns the curated tour (:mod:`harmonics.demo.matrix`) into concrete clips: +one ``(label, axes, notes, wav)`` per :class:`~harmonics.demo.matrix.ClipSpec` +in the matrix. This module makes **no new synthesis decisions** — it only +resolves each clip's axes/signature and dispatches to the EXISTING voice +pipeline, the same one ``harmonics play`` and ``harmonics say`` themselves +use: + +* a ``"play"`` clip resolves an :class:`~harmonics.axes.Axes` from its + explicit intent/confidence/urgency/state/agent fields, a + :class:`~harmonics.identity.Signature` from its agent (or this module's own + default identity), and renders it with + :func:`~harmonics.mapping.render_gesture` — exactly what ``harmonics play`` + does; +* a ``"say"`` clip infers its axes from the sentence + (:func:`~harmonics.inference.infer_axes`) and renders its notes with + :func:`harmonics.cli._commands.say.render_notes` — the exact function + ``harmonics say`` itself calls (see that module's extraction). + +Every clip's wav is rendered with :func:`harmonics.audio.render_wav`, which is +pure/offline — no audio device, no live-playback import anywhere in this +module's import graph (:mod:`harmonics.audio.play`'s optional backend import +is lazy and lives inside ``play`` itself, never touched here). + +Deterministic: :func:`showcase` takes no clock/randomness input, so calling it +twice yields byte-identical wavs and equal note lists for every clip. +""" + +from __future__ import annotations + +from typing import NamedTuple + +from harmonics.audio import render_wav +from harmonics.axes import Axes +from harmonics.cli._commands import say +from harmonics.demo.matrix import MATRIX, ClipSpec +from harmonics.identity import derive_signature, signature_for +from harmonics.inference import infer_axes +from harmonics.mapping import render_gesture +from harmonics.notes import NoteEvent +from harmonics.stress import parse_emphasis + +#: The voice signature used when a clip specifies no agent: harmonics-cli's +#: own identity (mirrors ``play.DEFAULT_IDENTITY`` / ``say.DEFAULT_IDENTITY``). +DEFAULT_IDENTITY = "harmonics-cli" + + +class Clip(NamedTuple): + """One rendered showcase clip: a labeled axes + note sequence + wav.""" + + label: str + axes: Axes + notes: list[NoteEvent] + wav: bytes + + +def _render_clip(spec: ClipSpec, articulation: str | None) -> Clip: + """Render one :class:`~harmonics.demo.matrix.ClipSpec` to a :class:`Clip`. + + ``articulation`` overrides the spec's own wav style when given (``None`` + means "use ``spec.articulation``", the clip's own curated style). + """ + if spec.kind == "play": + axes = Axes( + intent=spec.intent, + confidence=spec.confidence, + urgency=spec.urgency, + state=spec.state, + identity=spec.agent, + ) + sig = signature_for(spec.agent) if spec.agent else derive_signature(DEFAULT_IDENTITY) + notes = render_gesture(axes, root_pitch=sig.root_pitch, instrument=sig.instrument) + else: + clean, _ = parse_emphasis(spec.sentence) + axes = infer_axes(clean) + notes = say.render_notes(spec.sentence, agent=spec.agent) + + art = articulation if articulation is not None else spec.articulation + wav = render_wav(notes, articulation=art) + + return Clip(label=spec.label, axes=axes, notes=notes, wav=wav) + + +def showcase(*, articulation: str | None = None) -> list[Clip]: + """Render the full curated tour to a list of :class:`Clip`\\ s. + + With ``articulation=None`` (the default), each clip renders its wav with + ITS OWN :attr:`~harmonics.demo.matrix.ClipSpec.articulation` (only the + ``"articulations"`` group's clips differ from one another in this mode). + Passing an explicit ``articulation`` (e.g. ``"alien"``) OVERRIDES every + clip to that single style — the whole tour re-rendered in one voice — + which powers the CLI's top-level ``--articulation`` flag. Either way the + note sequences themselves are identical (articulation only changes HOW a + fixed note sequence is synthesized to wav, never the sequence itself). + + Returns a materialized ``list`` (not a lazy generator) in stable MATRIX + order, so callers can ``len()`` it and it is trivially reproducible: + deterministic inputs in, byte-identical wavs and equal note lists out. + """ + return [_render_clip(spec, articulation) for spec in MATRIX] diff --git a/harmonics/demo/files.py b/harmonics/demo/files.py new file mode 100644 index 0000000..f6e8a44 --- /dev/null +++ b/harmonics/demo/files.py @@ -0,0 +1,140 @@ +"""``harmonics demo``'s file/data output helpers — ``--wav DIR`` / ``--out +FILE`` / ``--json``. + +All three pure, offline, stdlib-only functions turn a materialized +``list[Clip]`` (see :mod:`harmonics.demo.core`) into files or plain data: + +* :func:`write_wav_dir` — one WAV per clip, deterministically named. +* :func:`write_concat_wav` — the whole tour as ONE concatenated WAV, clips + separated by a short silent gap. +* :func:`json_payload` — a JSON-serializable view of the clips (label, axes, + notes) with no raw ``wav`` bytes. + +Nothing here touches an audio device or a live-playback backend +(``simpleaudio`` / ``sounddevice``) — only :mod:`wave`, :mod:`io`, +:mod:`json`, :mod:`pathlib`, and :mod:`os` from the standard library. Every +clip's own ``wav`` bytes are already-rendered, valid RIFF/WAVE audio (44100 +Hz, mono, 16-bit PCM — see :class:`harmonics.demo.core.Clip`); these helpers +only read/rewrite that container, they never synthesize. +""" + +from __future__ import annotations + +import io +import os +import wave +from pathlib import Path +from typing import Any + +from harmonics.demo.core import Clip + +#: The WAV format every clip is expected to share (see ``Clip.wav`` docs): +#: mono, 16-bit PCM, 44100 Hz. +_EXPECTED_CHANNELS = 1 +_EXPECTED_SAMPWIDTH = 2 +_EXPECTED_FRAMERATE = 44100 + +#: The silent gap inserted between clips in :func:`write_concat_wav`. +_GAP_SECONDS = 0.25 + + +def _slugify(label: str) -> str: + """Deterministically slugify a clip label for use in a filename. + + Lowercase; any run of non-alphanumeric characters collapses to a single + ``'-'``; leading/trailing ``'-'`` is stripped. E.g. ``"intent: ack"`` -> + ``"intent-ack"``, ``"say (spark): all done here"`` -> + ``"say-spark-all-done-here"``. + """ + chars: list[str] = [] + prev_dash = False + for ch in label.lower(): + if ch.isalnum(): + chars.append(ch) + prev_dash = False + elif not prev_dash: + chars.append("-") + prev_dash = True + return "".join(chars).strip("-") + + +def write_wav_dir(clips: list[Clip], directory: str | Path) -> list[str]: + """Write one WAV file per clip into ``directory`` (created if missing). + + Filenames are deterministic: a zero-padded index (keeps order and + guarantees uniqueness even if two labels slugify the same) plus the + slugified label, e.g. ``"00_intent-ack.wav"``. Returns the written paths + in clip order. + """ + directory = Path(directory) + os.makedirs(directory, exist_ok=True) + + width = max(2, len(str(max(len(clips) - 1, 0)))) + written: list[str] = [] + for idx, clip in enumerate(clips): + slug = _slugify(clip.label) + path = directory / f"{idx:0{width}d}_{slug}.wav" + path.write_bytes(clip.wav) + written.append(str(path)) + return written + + +def _read_clip_frames(clip: Clip) -> bytes: + """Read one clip's raw PCM frames, validating its WAV format matches the + shared 44100Hz mono 16-bit contract every clip is expected to honor.""" + with wave.open(io.BytesIO(clip.wav), "rb") as wf: + params = (wf.getnchannels(), wf.getsampwidth(), wf.getframerate()) + expected = (_EXPECTED_CHANNELS, _EXPECTED_SAMPWIDTH, _EXPECTED_FRAMERATE) + if params != expected: + raise ValueError( + f"clip {clip.label!r} has wav format {params} " + f"(channels, sampwidth, framerate), expected {expected}" + ) + return wf.readframes(wf.getnframes()) + + +def write_concat_wav(clips: list[Clip], path: str | Path) -> None: + """Write ONE WAV concatenating every clip's audio in order, separated by + a short (~0.25s) silent gap. + + All clips must share the 44100Hz mono 16-bit format documented on + :class:`~harmonics.demo.core.Clip`; raises :class:`ValueError` if any + clip disagrees. Writes a valid RIFF/WAVE file to ``path``. + """ + if not clips: + raise ValueError("write_concat_wav requires at least one clip") + + frames = [_read_clip_frames(clip) for clip in clips] + + n_silence_frames = int(_GAP_SECONDS * _EXPECTED_FRAMERATE) + silence = b"\x00\x00" * n_silence_frames + + with wave.open(str(path), "wb") as out: + out.setnchannels(_EXPECTED_CHANNELS) + out.setsampwidth(_EXPECTED_SAMPWIDTH) + out.setframerate(_EXPECTED_FRAMERATE) + for idx, clip_frames in enumerate(frames): + if idx > 0: + out.writeframes(silence) + out.writeframes(clip_frames) + + +def json_payload(clips: list[Clip]) -> list[dict[str, Any]]: + """A JSON-serializable ``list``, one dict per clip: its ``label``, its + ``axes`` (a plain dict of only the SET, non-``None`` axis fields), and + its ``notes`` (``[NoteEvent.to_dict(), ...]``). + + Deliberately carries no ``wav`` bytes (not JSON-serializable); + ``json.dumps(json_payload(clips))`` always succeeds. + """ + payload: list[dict[str, Any]] = [] + for clip in clips: + axes_dict = {k: v for k, v in clip.axes.as_dict().items() if v is not None} + payload.append( + { + "label": clip.label, + "axes": axes_dict, + "notes": [note.to_dict() for note in clip.notes], + } + ) + return payload diff --git a/harmonics/demo/gallery.py b/harmonics/demo/gallery.py new file mode 100644 index 0000000..64e69ee --- /dev/null +++ b/harmonics/demo/gallery.py @@ -0,0 +1,266 @@ +"""``harmonics demo --html`` — the pure HTML gallery renderer. + +Turns a list of :class:`~harmonics.demo.core.Clip` into a single, +self-contained, browser-playable HTML document: one card per clip, showing +its label, its set (non-``None``) :class:`~harmonics.axes.Axes` fields as +chips, a compact note-sequence summary, and a playable ``