From c49b799ee3052f065d6ff9e5546b03c53d7aa1ec Mon Sep 17 00:00:00 2001 From: Ori Nachum Date: Wed, 8 Jul 2026 13:56:31 +0300 Subject: [PATCH 01/11] =?UTF-8?q?spec:=20harmonics=20demo=20command=20?= =?UTF-8?q?=E2=80=94=20voice=20showcase=20(play=20/=20file=20/=20stream)?= =?UTF-8?q?=20(devague=20/think)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Converged Announcement Frame for issue #4: a `harmonics demo` verb that tours the agent voice across every design axis in three output modes — play live, write a file (--html gallery / --wav dir / --out concatenated), or stream via a public showcase() + --json. Dry-run by default; offline/deterministic pure path; articulation flag + dedicated 'articulations' row (per user decision). 12 claims + 15 honesty conditions, all confirmed; gate passed. Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_01VMUvDkSHhthqU3c5HrhQuj --- .devague/current | 2 +- ...ps-a-demo-command-one-command-tours-t.json | 229 ++++++++++++++++++ ...hips-a-demo-command-one-command-tours-t.md | 53 ++++ 3 files changed, 283 insertions(+), 1 deletion(-) create mode 100644 .devague/frames/harmonics-ships-a-demo-command-one-command-tours-t.json create mode 100644 docs/specs/2026-07-08-harmonics-ships-a-demo-command-one-command-tours-t.md 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/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/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 From e56e7871f3c61f367a2ff555a9706e879e5bec30 Mon Sep 17 00:00:00 2001 From: Ori Nachum Date: Wed, 8 Jul 2026 14:02:40 +0300 Subject: [PATCH 02/11] =?UTF-8?q?plan:=20harmonics=20demo=20command=20?= =?UTF-8?q?=E2=80=94=207=20tasks=20/=205=20waves=20(devague=20/spec-to-pla?= =?UTF-8?q?n)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Buildable plan seeded from the converged demo-command frame. 26 coverage targets, all covered; acyclic graph. Waves: matrix -> showcase() core -> {gallery, files, play} (wide, file-disjoint) -> CLI verb+register -> explain. Each task is TDD-gated with its own test_demo_*.py. Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_01VMUvDkSHhthqU3c5HrhQuj --- .devague/current_plan | 2 +- ...ps-a-demo-command-one-command-tours-t.json | 288 ++++++++++++++++++ ...hips-a-demo-command-one-command-tours-t.md | 66 ++++ 3 files changed, 355 insertions(+), 1 deletion(-) create mode 100644 .devague/plans/harmonics-ships-a-demo-command-one-command-tours-t.json create mode 100644 docs/plans/2026-07-08-harmonics-ships-a-demo-command-one-command-tours-t.md 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/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/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 From 8444690aaab2233f828c1a3bd3b6ce79048be48e Mon Sep 17 00:00:00 2001 From: Ori Nachum Date: Wed, 8 Jul 2026 14:10:48 +0300 Subject: [PATCH 03/11] demo(t1): showcase matrix as data (harmonics/demo/matrix.py) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Curated 28-clip tour across 6 groups (intents, identity, shading, say, stress, articulations) as a pure ClipSpec data table — no rendering, no audio import. 14 tests. Covers c3,h10,c6,c10,h5,c14,h8. Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_01VMUvDkSHhthqU3c5HrhQuj --- harmonics/demo/__init__.py | 1 + harmonics/demo/matrix.py | 216 +++++++++++++++++++++++++++++++++++++ tests/test_demo_matrix.py | 157 +++++++++++++++++++++++++++ 3 files changed, 374 insertions(+) create mode 100644 harmonics/demo/__init__.py create mode 100644 harmonics/demo/matrix.py create mode 100644 tests/test_demo_matrix.py diff --git a/harmonics/demo/__init__.py b/harmonics/demo/__init__.py new file mode 100644 index 0000000..c82592c --- /dev/null +++ b/harmonics/demo/__init__.py @@ -0,0 +1 @@ +"""The ``harmonics demo`` package — a curated tour of the agent voice.""" diff --git a/harmonics/demo/matrix.py b/harmonics/demo/matrix.py new file mode 100644 index 0000000..193a7c3 --- /dev/null +++ b/harmonics/demo/matrix.py @@ -0,0 +1,216 @@ +"""``harmonics demo``'s curated showcase matrix — pure data. + +This module defines the tour: which clips ``harmonics demo`` renders, and +grouped how. It is deliberately just data — a frozen dataclass, the ordered +group names, and the curated table itself — so it stays unit-testable +offline with no synthesis, rendering, or audio import in the path. A later +task turns this data into wav/html/live output; this module only describes +*what* the tour contains. + +Two clip ``kind``s exist, mirroring the CLI's two audio verbs: + +* ``"play"`` — explicit-axes clips (``harmonics play --intent ...``). +* ``"say"`` — sentence clips (``harmonics say "..."``). + +The six curated groups (:data:`GROUPS`) walk a listener through the whole +design spine: every intent, several agent identities, confidence/urgency +shading, sentence-to-tune tracking, emphasis stress, and the four wav +articulation styles. +""" + +from __future__ import annotations + +from dataclasses import dataclass + +from harmonics.axes import INTENTS + +#: The six curated tour sections, in the order ``demo`` walks them. +GROUPS: tuple[str, ...] = ( + "intents", + "identity", + "shading", + "say", + "stress", + "articulations", +) + + +@dataclass(frozen=True) +class ClipSpec: + """One clip in the showcase tour — a fully-specified ``play`` or ``say`` + invocation, plus the wav articulation style to render it with. + + ``kind`` picks which of the two field blocks apply: ``"play"`` clips use + ``intent``/``confidence``/``urgency``/``state`` (``sentence`` stays + ``None``); ``"say"`` clips use ``sentence`` (the ``play``-only axis + fields stay ``None`` — ``say`` infers its own axes from the sentence). + """ + + label: str + group: str + kind: str + agent: str | None = None + intent: str | None = None + confidence: str | None = None + urgency: str | None = None + state: str | None = None + sentence: str | None = None + articulation: str = "smooth" + + +# --- 1. intents: one clip per value in harmonics.axes.INTENTS --------------- + +_INTENTS_GROUP: tuple[ClipSpec, ...] = tuple( + ClipSpec(label=f"intent: {intent}", group="intents", kind="play", intent=intent) + for intent in INTENTS +) + +# --- 2. identity: the same intent, five distinct agent voices --------------- + +_IDENTITY_AGENTS: tuple[str, ...] = ( + "harmonics-cli", + "spark", + "daria", + "culture", + "steward", +) + +_IDENTITY_GROUP: tuple[ClipSpec, ...] = tuple( + ClipSpec( + label=f"identity: {agent}", + group="identity", + kind="play", + agent=agent, + intent="success", + ) + for agent in _IDENTITY_AGENTS +) + +# --- 3. shading: confidence/urgency extremes on the same intent ------------- + +_SHADING_GROUP: tuple[ClipSpec, ...] = ( + ClipSpec( + label="shading: confidence=high", + group="shading", + kind="play", + intent="success", + confidence="high", + ), + ClipSpec( + label="shading: confidence=low", + group="shading", + kind="play", + intent="success", + confidence="low", + ), + ClipSpec( + label="shading: urgency=calm", + group="shading", + kind="play", + intent="success", + urgency="calm", + ), + ClipSpec( + label="shading: urgency=urgent", + group="shading", + kind="play", + intent="success", + urgency="urgent", + ), +) + +# --- 4. say: sentences across intents, plus one sentence in two voices ----- + +_SAY_GROUP: tuple[ClipSpec, ...] = ( + ClipSpec( + label="say: all tests passed", + group="say", + kind="say", + sentence="all the tests passed, the build is green", + ), + ClipSpec( + label="say: should I proceed?", + group="say", + kind="say", + sentence="should I proceed with the deploy?", + ), + ClipSpec( + label="say: let me think", + group="say", + kind="say", + sentence="hmm, let me think about that", + ), + ClipSpec( + label="say: deploy failed", + group="say", + kind="say", + sentence="the deploy failed", + ), + ClipSpec( + label="say: handing off", + group="say", + kind="say", + sentence="handing off to the next agent", + ), + ClipSpec( + label="say (spark): all done here", + group="say", + kind="say", + agent="spark", + sentence="all done here", + ), + ClipSpec( + label="say (daria): all done here", + group="say", + kind="say", + agent="daria", + sentence="all done here", + ), +) + +# --- 5. stress: the same instruction, plain vs. *emphasized* --------------- + +_STRESS_GROUP: tuple[ClipSpec, ...] = ( + ClipSpec( + label="stress: plain", + group="stress", + kind="say", + sentence="push it now", + ), + ClipSpec( + label="stress: *now*", + group="stress", + kind="say", + sentence="push it *now*", + ), +) + +# --- 6. articulations: one sentence, all four wav synthesis styles -------- + +_ARTICULATIONS_SENTENCE = "all the tests passed, the build is green" + +_ARTICULATIONS_GROUP: tuple[ClipSpec, ...] = tuple( + ClipSpec( + label=f"articulation: {style}", + group="articulations", + kind="say", + sentence=_ARTICULATIONS_SENTENCE, + articulation=style, + ) + for style in ("discrete", "speechy", "smooth", "alien") +) + +#: The full curated tour, in stable presentation order (see :data:`GROUPS`). +MATRIX: tuple[ClipSpec, ...] = ( + _INTENTS_GROUP + + _IDENTITY_GROUP + + _SHADING_GROUP + + _SAY_GROUP + + _STRESS_GROUP + + _ARTICULATIONS_GROUP +) + + +def iter_clips() -> tuple[ClipSpec, ...]: + """Return the full ordered matrix (stable order).""" + return MATRIX diff --git a/tests/test_demo_matrix.py b/tests/test_demo_matrix.py new file mode 100644 index 0000000..f137a99 --- /dev/null +++ b/tests/test_demo_matrix.py @@ -0,0 +1,157 @@ +"""Tests for the ``harmonics.demo.matrix`` showcase data table. + +This module is pure data (no rendering, no audio import) — the tests assert +the shape of the curated tour, not any sound it might later produce. +""" + +from __future__ import annotations + +import inspect + +from harmonics.axes import CONFIDENCES, INTENTS, URGENCIES +from harmonics.demo import matrix as matrix_module +from harmonics.demo.matrix import GROUPS, MATRIX, ClipSpec, iter_clips + +# --- structural sanity -------------------------------------------------- + + +def test_groups_is_the_expected_six() -> None: + assert GROUPS == ( + "intents", + "identity", + "shading", + "say", + "stress", + "articulations", + ) + + +def test_every_clip_group_is_known_and_all_groups_appear() -> None: + for clip in MATRIX: + assert clip.group in GROUPS + assert {clip.group for clip in MATRIX} == set(GROUPS) + + +def test_matrix_is_a_tuple_of_clipspec() -> None: + assert isinstance(MATRIX, tuple) + assert len(MATRIX) > 0 + for clip in MATRIX: + assert isinstance(clip, ClipSpec) + + +# --- intents group -------------------------------------------------------- + + +def test_intents_group_has_exactly_one_clip_per_intent() -> None: + intents_clips = [c for c in MATRIX if c.group == "intents"] + assert all(c.kind == "play" for c in intents_clips) + assert {c.intent for c in intents_clips} == set(INTENTS) + assert len(intents_clips) == len(INTENTS) + + +# --- identity group --------------------------------------------------------- + + +def test_identity_group_has_at_least_five_distinct_agents() -> None: + identity_clips = [c for c in MATRIX if c.group == "identity"] + assert len(identity_clips) >= 5 + agents = {c.agent for c in identity_clips} + assert len(agents) >= 5 + assert all(c.kind == "play" for c in identity_clips) + assert all(c.intent == "success" for c in identity_clips) + + +# --- shading group ------------------------------------------------------ + + +def test_shading_group_covers_confidence_and_urgency_extremes() -> None: + shading_clips = [c for c in MATRIX if c.group == "shading"] + assert all(c.kind == "play" for c in shading_clips) + confidences = {c.confidence for c in shading_clips if c.confidence is not None} + urgencies = {c.urgency for c in shading_clips if c.urgency is not None} + assert {"low", "high"} <= confidences <= set(CONFIDENCES) + assert {"calm", "urgent"} <= urgencies <= set(URGENCIES) + + +# --- say group ------------------------------------------------------------ + + +def test_say_group_has_several_sentences_across_intents() -> None: + say_clips = [c for c in MATRIX if c.group == "say"] + assert all(c.kind == "say" for c in say_clips) + assert all(c.sentence for c in say_clips) + sentences = {c.sentence for c in say_clips} + assert len(sentences) >= 5 + + # the same sentence in two different agents' voices + same_sentence_clips = [c for c in say_clips if c.sentence == "all done here"] + agents = {c.agent for c in same_sentence_clips} + assert agents == {"spark", "daria"} + + +# --- stress group ----------------------------------------------------------- + + +def test_stress_group_has_plain_and_emphasized_variants() -> None: + stress_clips = [c for c in MATRIX if c.group == "stress"] + assert len(stress_clips) == 2 + assert all(c.kind == "say" for c in stress_clips) + sentences = {c.sentence for c in stress_clips} + assert sentences == {"push it now", "push it *now*"} + + +# --- articulations group ----------------------------------------------------- + + +def test_articulations_group_is_one_sentence_in_four_styles() -> None: + art_clips = [c for c in MATRIX if c.group == "articulations"] + assert len(art_clips) == 4 + assert all(c.kind == "say" for c in art_clips) + sentences = {c.sentence for c in art_clips} + assert len(sentences) == 1 + assert {c.articulation for c in art_clips} == { + "discrete", + "speechy", + "smooth", + "alien", + } + + +# --- kind/field consistency --------------------------------------------- + + +def test_play_clips_have_intent_and_no_sentence() -> None: + for clip in MATRIX: + if clip.kind == "play": + assert clip.intent is not None + assert clip.sentence is None + + +def test_say_clips_have_sentence() -> None: + for clip in MATRIX: + if clip.kind == "say": + assert clip.sentence is not None + + +def test_kind_is_always_play_or_say() -> None: + for clip in MATRIX: + assert clip.kind in ("play", "say") + + +# --- iter_clips ------------------------------------------------------------- + + +def test_iter_clips_returns_matrix() -> None: + result = iter_clips() + assert result == MATRIX + assert len(result) > 0 + + +# --- no audio import in this module ----------------------------------------- + + +def test_matrix_module_does_not_reference_harmonics_audio() -> None: + source = inspect.getsource(matrix_module) + assert "harmonics.audio" not in source + assert "simpleaudio" not in source + assert "sounddevice" not in source From 0622fcc4b93d3fd65b65785105beab099dda3db0 Mon Sep 17 00:00:00 2001 From: Ori Nachum Date: Wed, 8 Jul 2026 14:16:58 +0300 Subject: [PATCH 04/11] demo(t2): public showcase() core (harmonics/demo/core.py) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit showcase() yields (label, axes, notes, wav) per matrix clip — deterministic, offline (no audio backend imported), reusing the existing pipeline. Extracts a public say.render_notes() (cmd_say behavior unchanged) so 'say' clips sound exactly like harmonics say. --articulation override re-renders the whole tour. 9 tests. Covers c9,h4,c5,h12,h13,c11,h2. Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_01VMUvDkSHhthqU3c5HrhQuj --- harmonics/cli/_commands/say.py | 43 ++++++--- harmonics/demo/__init__.py | 24 ++++- harmonics/demo/core.py | 101 +++++++++++++++++++++ tests/test_demo_showcase.py | 158 +++++++++++++++++++++++++++++++++ 4 files changed, 313 insertions(+), 13 deletions(-) create mode 100644 harmonics/demo/core.py create mode 100644 tests/test_demo_showcase.py 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 index c82592c..328d9cc 100644 --- a/harmonics/demo/__init__.py +++ b/harmonics/demo/__init__.py @@ -1 +1,23 @@ -"""The ``harmonics demo`` package — a curated tour of the agent voice.""" +"""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/tests/test_demo_showcase.py b/tests/test_demo_showcase.py new file mode 100644 index 0000000..f1d4f87 --- /dev/null +++ b/tests/test_demo_showcase.py @@ -0,0 +1,158 @@ +"""Tests for ``harmonics.demo.showcase`` — the public, deterministic, offline +renderer that turns the curated matrix (:mod:`harmonics.demo.matrix`) into +concrete ``(label, axes, notes, wav)`` clips. + +Covers: shape/length against the matrix, determinism across repeat calls, +that the audio backend never gets touched (no live-playback import in the +path), that ``showcase`` reuses the EXISTING voice pipeline rather than +reimplementing synthesis (proved by rebuilding one ``play`` clip and one +``say`` clip's expected notes independently and comparing), and that the +top-level ``articulation`` override changes the rendered wav bytes without +touching the note sequence (the note sequence is articulation-independent). +""" + +from __future__ import annotations + +import sys + +from harmonics.axes import Axes +from harmonics.cli._commands import say +from harmonics.demo import Clip, showcase +from harmonics.demo.matrix import MATRIX +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 + +# --- shape / length ----------------------------------------------------------- + + +def test_showcase_length_matches_matrix() -> None: + clips = showcase() + assert len(clips) == len(MATRIX) + + +def test_showcase_returns_a_list() -> None: + clips = showcase() + assert isinstance(clips, list) + + +def test_every_clip_is_a_well_formed_four_tuple() -> None: + clips = showcase() + for clip in clips: + assert isinstance(clip, Clip) + label, axes, notes, wav = clip + assert isinstance(label, str) and label + assert isinstance(axes, Axes) + assert isinstance(notes, list) and len(notes) > 0 + assert all(isinstance(n, NoteEvent) for n in notes) + assert isinstance(wav, bytes) and len(wav) > 0 + assert wav.startswith(b"RIFF") + + +# --- determinism --------------------------------------------------------------- + + +def test_showcase_is_deterministic_across_calls() -> None: + first = showcase() + second = showcase() + assert len(first) == len(second) + for a, b in zip(first, second): + assert a.label == b.label + assert a.axes == b.axes + assert a.notes == b.notes + assert a.wav == b.wav + + +# --- offline: no live-playback backend is ever imported ----------------------- + + +def test_showcase_never_imports_a_live_audio_backend() -> None: + showcase() + assert "simpleaudio" not in sys.modules + assert "sounddevice" not in sys.modules + + +# --- no new synthesis: showcase reuses the existing voice pipeline ------------ + + +def test_showcase_play_clip_matches_render_gesture_directly() -> None: + play_specs = [c for c in MATRIX if c.kind == "play"] + assert play_specs + spec = play_specs[0] + idx = MATRIX.index(spec) + + clips = showcase() + clip = clips[idx] + + expected_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("harmonics-cli") + expected_notes = render_gesture( + expected_axes, root_pitch=sig.root_pitch, instrument=sig.instrument + ) + + assert clip.axes == expected_axes + assert clip.notes == expected_notes + + +def test_showcase_say_clip_matches_render_notes_directly() -> None: + say_specs = [c for c in MATRIX if c.kind == "say"] + assert say_specs + spec = say_specs[0] + idx = MATRIX.index(spec) + + clips = showcase() + clip = clips[idx] + + clean, _ = parse_emphasis(spec.sentence) + expected_axes = infer_axes(clean) + expected_notes = say.render_notes(spec.sentence, agent=spec.agent) + + assert clip.axes == expected_axes + assert clip.notes == expected_notes + + +# --- articulation override ------------------------------------------------------ + + +def test_articulation_override_changes_wav_but_not_notes() -> None: + default_clips = showcase() + alien_clips = showcase(articulation="alien") + + assert len(default_clips) == len(alien_clips) + + changed_wav = False + for default_clip, alien_clip in zip(default_clips, alien_clips): + assert default_clip.notes == alien_clip.notes + if default_clip.wav != alien_clip.wav: + changed_wav = True + assert changed_wav + + +def test_articulation_none_uses_each_clips_own_articulation() -> None: + # The "articulations" group's clips each carry a distinct ``articulation`` + # on their spec; showcase(articulation=None) must honor each clip's own + # style rather than applying one style to all of them. + from harmonics.audio import render_wav + + art_specs = [c for c in MATRIX if c.group == "articulations"] + assert {c.articulation for c in art_specs} == { + "discrete", + "speechy", + "smooth", + "alien", + } + + clips = showcase() + for spec in art_specs: + idx = MATRIX.index(spec) + clip = clips[idx] + expected_wav = render_wav(clip.notes, articulation=spec.articulation) + assert clip.wav == expected_wav From bf60ee161743a6d6801cbebccef65346c18ee6bc Mon Sep 17 00:00:00 2001 From: Ori Nachum Date: Wed, 8 Jul 2026 14:24:55 +0300 Subject: [PATCH 05/11] demo(t3): self-contained HTML gallery renderer (demo/gallery.py) render_gallery(clips) -> deterministic self-contained HTML: base64 WAV data-URIs, per-clip axes chips + note summary, both themes, no external hosts. 11 tests. Covers h6,c11. Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_01VMUvDkSHhthqU3c5HrhQuj --- harmonics/demo/gallery.py | 262 +++++++++++++++++++++++++++++++++++++ tests/test_demo_gallery.py | 135 +++++++++++++++++++ 2 files changed, 397 insertions(+) create mode 100644 harmonics/demo/gallery.py create mode 100644 tests/test_demo_gallery.py diff --git a/harmonics/demo/gallery.py b/harmonics/demo/gallery.py new file mode 100644 index 0000000..d25d034 --- /dev/null +++ b/harmonics/demo/gallery.py @@ -0,0 +1,262 @@ +"""``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 ``