From c878bfe34313d2e25a4951af2a4611528fe86788 Mon Sep 17 00:00:00 2001 From: Ori Nachum Date: Wed, 8 Jul 2026 18:27:22 +0300 Subject: [PATCH 1/2] feat: talk skill + teach the voice in `learn` Add a first-party `.claude/skills/talk/` skill that renders a sentence (`harmonics say`) or explicit axes (`harmonics play`) to a WAV and plays it through a system player (pw-play/paplay/aplay/ffplay/afplay). This is the reliable "hear it" path: it needs no live-audio backend, so the agent's non-speech voice is audible even without the optional `harmonics-cli[audio]` extra that `--play` requires (a dev checkout omits it on purpose, to keep the no-backend test path clean). Teach it in `harmonics learn` so any agent can pick it up: the command map now lists play/say/demo, a "Talking out loud" section covers the two pathways + dry-run/--wav/--play, and a step-by-step "Build a talk skill" recipe (mirrored in the structured JSON `voice`/`talk_skill` keys) shows how to wrap the voice. Record `talk` as first-party in `docs/skill-sources.md` so a guildmaster re-sync never clobbers it. Rubric gate (`teken cli doctor . --strict`), 2357 tests, and all four lint gates pass. Version bumped 0.7.0 -> 0.8.0. Co-Authored-By: Claude Opus 4.8 (1M context) --- .claude/skills/talk/SKILL.md | 86 +++++++++++++++++++++++++ .claude/skills/talk/scripts/talk.sh | 98 +++++++++++++++++++++++++++++ CHANGELOG.md | 11 ++++ docs/skill-sources.md | 11 ++++ harmonics/cli/_commands/learn.py | 57 +++++++++++++++++ pyproject.toml | 2 +- 6 files changed, 264 insertions(+), 1 deletion(-) create mode 100644 .claude/skills/talk/SKILL.md create mode 100755 .claude/skills/talk/scripts/talk.sh diff --git a/.claude/skills/talk/SKILL.md b/.claude/skills/talk/SKILL.md new file mode 100644 index 0000000..630bee4 --- /dev/null +++ b/.claude/skills/talk/SKILL.md @@ -0,0 +1,86 @@ +--- +name: talk +type: command +description: Make the agent actually TALK out loud in its own non-speech harmonic voice, using the `harmonics` CLI (say/play). Renders a sentence or explicit axes to audio and plays it through a system player — so you hear it even without the optional [audio] extra that `--play` needs. Use when the user says "talk", "talk to me", "say something", "speak", "say it out loud", "tell me something", or wants to hear the agent's voice rather than read text. First-party to harmonics-cli (not vendored); slated to be surfaced in `harmonics learn`. +--- + +# Talk + +This is the agent's own **voice** — not TTS, not words. harmonics turns meaning +(*intent, confidence, urgency, state, identity*) into short, pleasant sonic +gestures. "Talking" here means rendering a sentence or a set of axes to notes +and **sounding them out loud**. + +Two pathways, both from the `harmonics` CLI: + +- **`say ""`** — infer the voice from a natural sentence. Emphasize a + word with `*asterisks*` or ALL-CAPS. This is the default for "talk to me". +- **`play --intent … --confidence … --urgency … --state …`** — drive the voice + from explicit axes when you know exactly what you want to express. + +Both are **dry-run by default** (they print the note sequence). To actually +*hear* it you need audio out. The built-in `--play` flag needs the optional +`harmonics-cli[audio]` extra (`sounddevice`); in a dev checkout that extra is +deliberately **not** installed (it would break the no-backend test path), so +`--play` fails with a friendly hint. The reliable path everywhere is to render a +WAV (`--wav FILE`, pure-Python, no device or extra needed) and pipe it to a +system player. `scripts/talk.sh` does exactly that. + +## Usage + +```bash +# Say a sentence out loud (renders + plays; also prints the note sequence) +bash .claude/skills/talk/scripts/talk.sh "done, tests all green" + +# Emphasis shapes the voice +bash .claude/skills/talk/scripts/talk.sh "that is *wonderful*, Ori" + +# Shape the voice: identity, articulation, deterministic variation +bash .claude/skills/talk/scripts/talk.sh "handing off now" --as harmonics-cli --articulation smooth --seq 7 + +# Explicit axes instead of a sentence +bash .claude/skills/talk/scripts/talk.sh --axes --intent success --confidence high --urgency calm --state done + +# Keep the rendered WAV instead of a throwaway temp file +bash .claude/skills/talk/scripts/talk.sh "welcome back" --keep /tmp/hello.wav +``` + +The script prints the dry-run note sequence to stderr (so you can see what was +"said"), renders a WAV, and plays it through the first available of `pw-play`, +`paplay`, `aplay`, `ffplay`, or `afplay`. If none exists, it prints the WAV path +so you can play it yourself. + +## The axes (the design spine) + +| Axis | Values | What it shades | +|------|--------|----------------| +| **intent** | ack, question, success, failure, thinking, handoff | timbre / motif family | +| **confidence** | low → high | consonance, resolved vs. suspended cadence | +| **urgency** | calm → urgent | tempo, attack sharpness, repetition | +| **state** | idle, working, blocked, done | sustained pad vs. discrete events | +| **identity** (`--as`) | which agent | signature motif / key / instrument | + +`say` infers these from the sentence; `play` takes them as flags. Run +`harmonics play --help` / `harmonics say --help` for the exact accepted values. + +## Direct CLI (what the script wraps) + +```bash +harmonics say "done, tests all green" # dry-run: print notes +harmonics say "done, tests all green" --wav out.wav # render audio (no extra) +harmonics say "done, tests all green" --play # live (needs [audio] extra) +harmonics play --intent success --confidence high # explicit axes +``` + +Use `uv run harmonics …` from inside the checkout if `harmonics` is not on PATH. + +## Notes + +- **No sound stack required to render.** `--wav` uses the stdlib `wave` module; + it does not pull in `numpy`/`sounddevice`. Only live `--play` needs the extra. +- **Keep it pleasant.** This voice plays repeatedly next to a human — prefer + `calm`/`low`-urgency renders for routine chatter; save sharp/urgent for real + alerts. +- **Roadmap:** this capability is slated to be taught directly by + `harmonics learn` (its command map already grows toward `say`/`play`). Until + then, this skill is the front door for "talk to me". diff --git a/.claude/skills/talk/scripts/talk.sh b/.claude/skills/talk/scripts/talk.sh new file mode 100755 index 0000000..cbcbc03 --- /dev/null +++ b/.claude/skills/talk/scripts/talk.sh @@ -0,0 +1,98 @@ +#!/usr/bin/env bash +# talk.sh — make the agent actually TALK in its own non-speech harmonic voice. +# +# Renders a sentence (via `harmonics say`) or explicit axes (via `harmonics +# play`, with --axes) to a WAV and plays it through a system audio player. This +# is the reliable "hear it" path: it needs no live-audio backend, because it +# renders to a file (`--wav`, pure-Python) and hands that file to the OS player, +# rather than relying on `harmonics … --play` (which needs the optional +# harmonics-cli[audio] extra, absent in a dev checkout). +# +# Usage: +# talk.sh "" [harmonics say flags...] # default: `say` +# talk.sh --axes [harmonics play flags...] # explicit axes: `play` +# talk.sh "..." --keep FILE # keep the WAV +# +# Our own flags: --axes (use `play` instead of `say`), --keep FILE (save WAV). +# Everything else is passed straight through to the harmonics verb, so you can +# use --as, --seq, --articulation, --intent, --confidence, etc. The dry-run note +# sequence is printed to stderr; the WAV path is echoed at the end. + +set -euo pipefail + +MODE="say" +KEEP="" +ARGS=() + +while [[ $# -gt 0 ]]; do + case "$1" in + --axes) MODE="play"; shift ;; + --keep) KEEP="${2:?--keep needs a FILE}"; shift 2 ;; + --help|-h) + sed -n '2,20p' "$0" | sed 's/^# \{0,1\}//' + exit 0 ;; + *) ARGS+=("$1"); shift ;; + esac +done + +if [[ "$MODE" == "say" && ${#ARGS[@]} -eq 0 ]]; then + echo "talk: nothing to say — give a sentence, or use --axes for explicit axes" >&2 + exit 1 +fi + +# Locate the harmonics command: prefer an installed console script (may carry +# the [audio] extra); fall back to `uv run` inside a checkout. +if command -v harmonics >/dev/null 2>&1; then + HARMONICS=(harmonics) +elif command -v uv >/dev/null 2>&1; then + HARMONICS=(uv run harmonics) +else + echo "talk: need either 'harmonics' or 'uv' on PATH" >&2 + exit 2 +fi + +# 1) Show what is being "said" (dry-run note sequence -> stderr). +"${HARMONICS[@]}" "$MODE" "${ARGS[@]}" >&2 || { + echo "talk: harmonics $MODE failed (see above)" >&2 + exit 1 +} + +# 2) Render to a WAV (no live-audio backend needed). +if [[ -n "$KEEP" ]]; then + WAV="$KEEP" + CLEANUP="" +else + WAV="$(mktemp --suffix=.wav 2>/dev/null || mktemp -t talk.XXXXXX.wav)" + CLEANUP="$WAV" +fi +trap '[[ -n "$CLEANUP" ]] && rm -f "$CLEANUP"' EXIT + +"${HARMONICS[@]}" "$MODE" "${ARGS[@]}" --wav "$WAV" >/dev/null + +# 3) Play it through the first available system player. +play_wav() { + local f="$1" + if command -v pw-play >/dev/null 2>&1; then pw-play "$f" + elif command -v paplay >/dev/null 2>&1; then paplay "$f" + elif command -v aplay >/dev/null 2>&1; then aplay -q "$f" + elif command -v ffplay >/dev/null 2>&1; then ffplay -nodisp -autoexit -loglevel quiet "$f" + elif command -v afplay >/dev/null 2>&1; then afplay "$f" + else return 127; fi +} + +if play_wav "$WAV"; then + echo "talk: played ${WAV}" >&2 +else + rc=$? + if [[ $rc -eq 127 ]]; then + # No player found — keep the file and tell the user where it is. + CLEANUP="" + echo "talk: no audio player found (tried pw-play/paplay/aplay/ffplay/afplay)." >&2 + echo "talk: WAV written to ${WAV} — play it yourself." >&2 + echo "$WAV" + exit 0 + fi + echo "talk: playback failed (player exit ${rc}); WAV at ${WAV}" >&2 + CLEANUP="" + exit "$rc" +fi diff --git a/CHANGELOG.md b/CHANGELOG.md index 2ec1cb6..66c2ac9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,17 @@ 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.8.0] - 2026-07-08 + +### Added + +- harmonics say/play now taught in `harmonics learn` — the command map lists play/say/demo, plus a "Talking out loud" section and a step-by-step "Build a talk skill" recipe (text + structured JSON `voice`/`talk_skill` keys) so any agent can learn to speak. +- New first-party `.claude/skills/talk/` skill: renders a sentence (`say`) or explicit axes (`play`) to a WAV and plays it through a system player (pw-play/paplay/aplay/ffplay/afplay), so the agent's non-speech voice is audible without the optional `[audio]` extra that `--play` needs. + +### Changed + +- `docs/skill-sources.md` now records `talk` as a first-party (non-vendored) skill so a guildmaster re-sync never clobbers it. + ## [0.7.0] - 2026-07-08 ### Added diff --git a/docs/skill-sources.md b/docs/skill-sources.md index abd3e47..dabb988 100644 --- a/docs/skill-sources.md +++ b/docs/skill-sources.md @@ -35,6 +35,17 @@ is load-bearing, even where guildmaster's upstream copy omits it. | `assign-to-workforce` | `../guildmaster/.claude/skills/assign-to-workforce/` | **devague** (re-broadcast via guildmaster) | plan→parallel-implementation leg of the devague workflow chain. Verbatim (already carried `type: command`). | 2026-05-26 (guildmaster 0.6.0) | | `ask-colleague` | `../colleague/.claude/skills/ask-colleague/` | **colleague** (renamed from convertible; vendored directly — guildmaster re-broadcast pending) | The first-party front door to the `colleague` CLI: hand a scoped task to a *different* engine/mind via `explore` / `review` / `write`, grade a finished work item via `feedback` (the ROI loop), and reap stale/corrupt `colleague/*` branches a crashed run left behind via `clean`. Every verb takes `--json` (result JSON on stdout, diagnostics on stderr). `explore`/`review` run isolated in a throwaway `git worktree`; `write` **previews by default** (throwaway worktree, no side effects) and refuses a dirty tree only when applying (`--apply` / `--pr`). Verbatim except one consumer-identifying clause in the Provenance paragraph (`colleague vendors from guildmaster` → `harmonics-cli vendors from guildmaster`); already carried `type: command`. Optional runtime dep: **`colleague`** on PATH. | 2026-06-12 (colleague 1.7.0, direct) | +## First-party skills (not vendored) + +These originate **in harmonics-cli** and are not pulled from any upstream. A +re-sync must never overwrite or remove them; they carry `type: command` like the +vendored ones so the `colleague`/`claude` backend's `core.skill_loader` picks +them up. + +| Skill | Origin | Notes | +|-------|--------|-------| +| `talk` | harmonics-cli (first-party) | The front door for "talk to me": renders a sentence (`harmonics say`) or explicit axes (`harmonics play`) to a WAV and plays it through a system player (`pw-play`/`paplay`/`aplay`/…), so the agent's non-speech voice is audible even without the optional `harmonics-cli[audio]` extra that `--play` needs. `scripts/talk.sh` is pure passthrough to the harmonics verbs. Slated to be surfaced in `harmonics learn`. | + ## Re-sync procedure ```bash diff --git a/harmonics/cli/_commands/learn.py b/harmonics/cli/_commands/learn.py index ee1d640..774ccbf 100644 --- a/harmonics/cli/_commands/learn.py +++ b/harmonics/cli/_commands/learn.py @@ -31,8 +31,36 @@ harmonics explain ... Markdown docs for any noun/verb path. harmonics overview Descriptive snapshot of the agent. harmonics doctor Check the agent-identity invariants. + harmonics play Render explicit axes to notes (dry-run). + harmonics say "" Render a sentence in the agent's voice (dry-run). + harmonics demo Tour the whole voice (play / gallery / stream). harmonics cli overview Describe the CLI surface itself. +Talking out loud +---------------- +`say` and `play` are the voice. Both are DRY-RUN by default — they print a note +sequence (`start dur pitch velocity timbre`), assert-able offline with no audio +device. To actually sound it: + harmonics say "done, tests all green" --wav out.wav # render a file (no extra) + harmonics say "done, tests all green" --play # live (needs [audio] extra) +Emphasize a word with *asterisks* or ALL-CAPS. `play` takes the axes as flags +(--intent/--confidence/--urgency/--state/--as); `say` infers them from the +sentence. + +Build a "talk" skill (so any agent can speak) +--------------------------------------------- +Wrap the voice in a skill under `.claude/skills/talk/` so "talk to me" just works: + 1. SKILL.md — frontmatter `type: command` (load-bearing for the skill loader) + plus a description with trigger words ("talk", "say something"). Document + the two pathways (`say` a sentence, `play` explicit axes) and the axes. + 2. scripts/talk.sh — the "hear it" path that needs no audio backend: + a. print the dry-run notes (harmonics say "$s"), + b. render a WAV (harmonics say "$s" --wav "$f") # stdlib, no extra, + c. play "$f" through the first of pw-play/paplay/aplay/ffplay/afplay. +Prefer WAV+system-player over `--play`: `--play` needs the optional +harmonics-cli[audio] extra, which a dev checkout deliberately omits. Keep the +render offline and the palette pleasant — it plays next to a human. + Machine-readable output ----------------------- Every command supports --json. Errors in JSON mode emit @@ -66,8 +94,37 @@ def _as_json_payload() -> dict[str, object]: {"path": ["explain"], "summary": "Markdown docs by path."}, {"path": ["overview"], "summary": "Descriptive snapshot of the agent."}, {"path": ["doctor"], "summary": "Check the agent-identity invariants."}, + {"path": ["play"], "summary": "Render explicit axes to notes (dry-run)."}, + {"path": ["say"], "summary": "Render a sentence in the agent's voice (dry-run)."}, + {"path": ["demo"], "summary": "Tour the whole voice (play / gallery / stream)."}, {"path": ["cli", "overview"], "summary": "Describe the CLI surface."}, ], + "voice": { + "verbs": ["say", "play"], + "dry_run_default": True, + "axes": ["intent", "confidence", "urgency", "state", "identity"], + "emphasis": "*asterisks* or ALL-CAPS raise a word", + "render_offline": "harmonics say '' --wav out.wav", + "play_live": "harmonics say '' --play # needs harmonics-cli[audio]", + }, + "talk_skill": { + "purpose": "Wrap the voice so any agent can talk out loud.", + "location": ".claude/skills/talk/", + "files": { + "SKILL.md": ( + "frontmatter type: command + trigger words + the two " + "pathways (say/play) and the axes" + ), + "scripts/talk.sh": ( + "print notes -> render --wav -> play via " "pw-play/paplay/aplay/ffplay/afplay" + ), + }, + "why_wav_not_play": ( + "--play needs the optional harmonics-cli[audio] extra; a dev " + "checkout omits it, so render --wav (stdlib, offline) and pipe " + "the file to a system player instead." + ), + }, "exit_codes": { "0": "success", "1": "user-input error", diff --git a/pyproject.toml b/pyproject.toml index 60a1153..55ff2e8 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "harmonics-cli" -version = "0.7.0" +version = "0.8.0" description = "An agent + CLI giving agents and robots non-TTS (non-speech) audio: express meaning through pleasant sonic signatures — chimes, flutes, pulses, and tonal motifs mapped to intent, confidence, urgency, state, and identity. Turns text/sentences into notes and audio (text-to-notes / text-to-audio)." readme = "README.md" license = "Apache-2.0" From 5211c47774737586cfe85e0f926549ef72b9bd63 Mon Sep 17 00:00:00 2001 From: Ori Nachum Date: Wed, 8 Jul 2026 19:10:04 +0300 Subject: [PATCH 2/2] fix: address PR #7 review (Qodo + SonarCloud) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit talk.sh: - reject conflicting output flags (--play/--wav/--out/--midi) that would break the wrapper's render-then-play pipeline; direct users to --keep (Qodo #3545273922: --play took precedence over --wav → play on missing file) - add --dry-run/--notes-only (print the note sequence, skip render+play) so the dry-run path is explicit; play-on-invoke stays the deliberate "talk out loud" default (Qodo #3545273916) - validate --intent is present in --axes mode (harmonics play requires it), with a friendly example (Qodo #3545273930) - make the stdout contract match the header: echo the WAV path to stdout only when it persists (--keep or no-player fallback) (Qodo #3545273936) learn.py: - merge the implicitly concatenated string literals into one (SonarCloud python:S5799 at learn.py:119) uv.lock: sync editable harmonics-cli 0.7.0 -> 0.8.0 to match pyproject. Gates: 2357 tests, black/isort/flake8/bandit, shellcheck, teken cli doctor . --strict all pass. Co-Authored-By: Claude Opus 4.8 (1M context) --- .claude/skills/talk/scripts/talk.sh | 44 ++++++++++++++++++++++++++--- harmonics/cli/_commands/learn.py | 4 +-- uv.lock | 2 +- 3 files changed, 42 insertions(+), 8 deletions(-) diff --git a/.claude/skills/talk/scripts/talk.sh b/.claude/skills/talk/scripts/talk.sh index cbcbc03..54ce59f 100755 --- a/.claude/skills/talk/scripts/talk.sh +++ b/.claude/skills/talk/scripts/talk.sh @@ -12,24 +12,38 @@ # talk.sh "" [harmonics say flags...] # default: `say` # talk.sh --axes [harmonics play flags...] # explicit axes: `play` # talk.sh "..." --keep FILE # keep the WAV +# talk.sh "..." --dry-run # notes only, no render/play # -# Our own flags: --axes (use `play` instead of `say`), --keep FILE (save WAV). +# Our own flags: --axes (use `play` instead of `say`), --keep FILE (save WAV), +# --dry-run/--notes-only (print the note sequence only; skip render and +# playback entirely). +# --play, --wav, --out, and --midi are NOT accepted here — this wrapper owns +# audio output end-to-end; use --keep FILE to save the WAV instead. # Everything else is passed straight through to the harmonics verb, so you can -# use --as, --seq, --articulation, --intent, --confidence, etc. The dry-run note -# sequence is printed to stderr; the WAV path is echoed at the end. +# use --as, --seq, --articulation, --intent, --confidence, --urgency, --state, +# etc. The dry-run note sequence is always printed to stderr; on success the +# WAV path is echoed to stdout ONLY when the file persists (i.e. --keep, or +# the no-player fallback below). set -euo pipefail MODE="say" KEEP="" +DRY_RUN="" ARGS=() while [[ $# -gt 0 ]]; do case "$1" in --axes) MODE="play"; shift ;; --keep) KEEP="${2:?--keep needs a FILE}"; shift 2 ;; + --dry-run|--notes-only) + DRY_RUN=1; shift ;; + --play|--wav|--out|--midi) + echo "talk: '$1' is not supported here — this wrapper owns audio output." >&2 + echo "talk: use --keep FILE to save the WAV, or --dry-run for notes only." >&2 + exit 1 ;; --help|-h) - sed -n '2,20p' "$0" | sed 's/^# \{0,1\}//' + sed -n '2,26p' "$0" | sed 's/^# \{0,1\}//' exit 0 ;; *) ARGS+=("$1"); shift ;; esac @@ -40,6 +54,23 @@ if [[ "$MODE" == "say" && ${#ARGS[@]} -eq 0 ]]; then exit 1 fi +if [[ "$MODE" == "play" ]]; then + has_intent="" + if [[ ${#ARGS[@]} -gt 0 ]]; then + for a in "${ARGS[@]}"; do + if [[ "$a" == "--intent" ]]; then + has_intent=1 + break + fi + done + fi + if [[ -z "$has_intent" ]]; then + echo "talk: --axes needs --intent (harmonics play requires it)." >&2 + echo "talk: e.g. talk.sh --axes --intent success --confidence high" >&2 + exit 1 + fi +fi + # Locate the harmonics command: prefer an installed console script (may carry # the [audio] extra); fall back to `uv run` inside a checkout. if command -v harmonics >/dev/null 2>&1; then @@ -57,6 +88,10 @@ fi exit 1 } +if [[ -n "$DRY_RUN" ]]; then + exit 0 +fi + # 2) Render to a WAV (no live-audio backend needed). if [[ -n "$KEEP" ]]; then WAV="$KEEP" @@ -82,6 +117,7 @@ play_wav() { if play_wav "$WAV"; then echo "talk: played ${WAV}" >&2 + [[ -n "$KEEP" ]] && echo "$WAV" else rc=$? if [[ $rc -eq 127 ]]; then diff --git a/harmonics/cli/_commands/learn.py b/harmonics/cli/_commands/learn.py index 774ccbf..c5ea5c5 100644 --- a/harmonics/cli/_commands/learn.py +++ b/harmonics/cli/_commands/learn.py @@ -115,9 +115,7 @@ def _as_json_payload() -> dict[str, object]: "frontmatter type: command + trigger words + the two " "pathways (say/play) and the axes" ), - "scripts/talk.sh": ( - "print notes -> render --wav -> play via " "pw-play/paplay/aplay/ffplay/afplay" - ), + "scripts/talk.sh": "print notes, render --wav, then play via a system player", }, "why_wav_not_play": ( "--play needs the optional harmonics-cli[audio] extra; a dev " diff --git a/uv.lock b/uv.lock index 5c7f81e..041bfc7 100644 --- a/uv.lock +++ b/uv.lock @@ -264,7 +264,7 @@ wheels = [ [[package]] name = "harmonics-cli" -version = "0.7.0" +version = "0.8.0" source = { editable = "." } [package.optional-dependencies]