Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
33 changes: 33 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.7.0] - 2026-07-08

### Added

- **Live playback is now installable** — an opt-in `audio` extra
(`uv tool install 'harmonics-cli[audio]'` / `pip install 'harmonics-cli[audio]'`)
pulls in `sounddevice` (ships wheels bundling PortAudio) and `numpy`, so
`--play` works out of the box. The core install stays dependency-free and
offline-testable — `--wav`/`--out`/`--html` and every read verb need no extra
— so the "runtime dependencies stay empty" invariant is preserved (the extra
is opt-in; importing the package still requires no sound stack).
- **`--device NAME|INDEX`** on `play` / `say` / `demo`, plus a
`$HARMONICS_AUDIO_DEVICE` env var (the flag overrides the env var), selecting
the output device for `--play`. When unset, playback prefers a resampling
sound-server device (**pipewire**, then **pulse**) over the raw ALSA default,
so `--play` works on a host whose default sink is a fixed-rate device (e.g. a
16 kHz USB audio adapter) that would otherwise reject the synth's 44.1 kHz.

### Changed

- Live playback now prefers the **`sounddevice`** backend over `simpleaudio`
(the order was previously reversed). sounddevice is the only backend that can
honor `--device` and the pipewire/pulse default, so preferring it makes those
always effective when it is installed; `simpleaudio` remains a fallback used
only when sounddevice is unavailable.
- A live-device failure (e.g. a PortAudio invalid-sample-rate error) now surfaces
as a friendly environment error (exit `2`) that names the failure, lists the
available output devices, and points at `--device` / `--wav` — instead of the
generic "unexpected error, file a bug" wrapper (it is not a bug; the device or
its routing is the problem).
- `--play`'s help text and the offline backend's "no backend" remediation now
point at the `harmonics-cli[audio]` extra.

## [0.6.0] - 2026-07-08

### Added
Expand Down
6 changes: 6 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -166,6 +166,12 @@ must not hard-fail on a stray path argument. Keep these contracts intact.
CLI core is cited from `teken` (`afi-cli`), not installed. Keep the pure
text→notes core dependency-free and offline-testable; isolate optional audio
I/O (playback, encoders) so importing the package never requires a sound stack.
Live playback (`--play` on `play`/`say`/`demo`) is opt-in via the
`harmonics-cli[audio]` extra (`sounddevice` + `numpy`), never a core
dependency; a `--device NAME|INDEX` flag / `$HARMONICS_AUDIO_DEVICE` env var
(flag wins) picks the output device, and with neither set, playback prefers a
resampling sound-server device (pipewire, then pulse) over the system default
sink.
- **`.claude/skills/` is vendored cite-don't-import** from *guildmaster* (with a
few tracked divergences). Do **not** hand-edit skill script bodies to "fix"
them here; re-sync per `docs/skill-sources.md`. That file is the provenance
Expand Down
23 changes: 21 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -86,15 +86,34 @@ command you run is `harmonics`.
| `explain <path>` | 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). |
| `play` | Render explicit axes to a note sequence (dry-run; `--wav`/`--play` for audio, `--device` to pick the output). |
| `say "<sentence>"` | 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). |
| `demo` | Tour the whole voice: `--play` / `--html` / `--wav` / `--out` / `--json` (dry-run by default; `--device` picks `--play`'s output). |
| `cli overview` | Describe the CLI surface itself. |

Every command supports `--json`. Results go to stdout, errors/diagnostics to
stderr (never mixed). Exit codes: `0` success, `1` user error, `2` environment
error, `3+` reserved.

### Live playback (`--play`)

The core (text→notes plus offline WAV render) is dependency-free, so a plain
install can already write audio — `--wav`/`--out`/`--html` need no extra. Live
playback (`--play`) needs a real audio backend, kept out of the core deps and
behind an opt-in extra:

```bash
uv tool install 'harmonics-cli[audio]' # or: pip install 'harmonics-cli[audio]'
# already installed? re-run with --force, or inject into the existing tool env:
uv tool install --force --with sounddevice --with numpy harmonics-cli
```

The `audio` extra pulls in [`sounddevice`](https://python-sounddevice.readthedocs.io/)
(ships wheels bundling PortAudio) and `numpy`. A hand-installed `simpleaudio`
also works. With no backend, `--play` fails with a friendly hint (exit 2) — it
never crashes or silently no-ops — so fall back to `--wav out.wav` to write a
file instead.

## Contributing

See [`CLAUDE.md`](CLAUDE.md) for the full conventions — the design-spine mapping,
Expand Down
98 changes: 98 additions & 0 deletions harmonics/audio/_playback.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,98 @@
"""Shared live-playback helpers: output-device selection + friendly errors.

Both live-playback paths — :func:`harmonics.audio.synth.play` (one gesture)
and :func:`harmonics.demo.play.play_clips` (a sequence of pre-rendered clips)
— need the SAME two things once a ``sounddevice`` backend is in hand: pick
which output device to open, and turn a device failure into the project's
structured :class:`~harmonics.cli._errors.CliError` instead of a bare
``PortAudioError``. That shared logic lives here, in one internal module, so
``harmonics.demo.play`` no longer reaches into ``harmonics.audio.synth``'s
internals to get it.

Like the rest of :mod:`harmonics.audio`, importing this module pulls in only
the standard library — the optional ``sounddevice`` backend is passed IN (as
an already-imported module object) by the caller's own lazy import, never
imported here.
"""

from __future__ import annotations

from harmonics.cli._errors import EXIT_ENV_ERROR, CliError

#: Sound-server PortAudio device names that resample (accept an arbitrary
#: sample rate), tried IN ORDER when no device is explicitly requested.
#: Preferring one of these makes live playback "just work" on a host whose raw
#: ALSA ``default`` sink is a FIXED-rate device (e.g. a 16 kHz USB audio
#: adapter) that would otherwise reject the synth's 44.1 kHz — i.e. this is
#: what "default to pipewire" means in practice. On a host without such a
#: device (macOS/Windows, or a plain ALSA box) none match and playback falls
#: back to the backend's own default. Override either way with ``--device`` /
#: ``$HARMONICS_AUDIO_DEVICE`` (see :func:`select_output_device`).
PREFERRED_OUTPUT_DEVICES: tuple[str, ...] = ("pipewire", "pulse")


def select_output_device(sounddevice: object, requested: int | str | None) -> int | str | None:
"""Choose which output device a ``sounddevice`` playback call should open.

An explicitly ``requested`` device always wins: an ``int`` (or an
all-digit string) is treated as a device index, any other non-empty
string as a name substring — both of which ``sounddevice.play(device=...)``
accepts. With nothing requested, prefer a resampling sound-server device
(see :data:`PREFERRED_OUTPUT_DEVICES`) when one is present, returning its
index; otherwise return ``None`` so the caller omits ``device=`` and the
backend uses its own default. Any failure to enumerate devices also yields
``None`` (fall back to the default) rather than raising.
"""
if requested is not None and requested != "":
if isinstance(requested, str) and requested.isdigit():
return int(requested)
return requested
try:
devices = list(sounddevice.query_devices())
except Exception: # noqa: BLE001 - can't enumerate -> just use the default device
return None
for name in PREFERRED_OUTPUT_DEVICES:
for index, dev in enumerate(devices):
if dev.get("max_output_channels", 0) > 0 and name in dev.get("name", "").lower():
return index
return None


def output_device_listing(sounddevice: object) -> str:
"""A short, best-effort ``[index] name`` list of output-capable devices,
for the remediation hint of a device error. Returns ``""`` if the backend
can't be enumerated (the hint then simply omits the listing)."""
try:
devices = list(sounddevice.query_devices())
except Exception: # noqa: BLE001 - listing is best-effort inside an error path
return ""
return "; ".join(
f"[{index}] {dev.get('name', '?')}"
for index, dev in enumerate(devices)
if dev.get("max_output_channels", 0) > 0
)


def device_playback_error(sounddevice: object | None, exc: Exception, sample_rate: int) -> CliError:
"""Wrap a live-device failure (e.g. a PortAudio invalid-sample-rate error
from a sink that can't accept the synth's rate) in the project's
structured :class:`~harmonics.cli._errors.CliError` — an ENVIRONMENT error
(:data:`~harmonics.cli._errors.EXIT_ENV_ERROR`) with an actionable hint —
instead of letting it reach the CLI's last-resort "unexpected error, file
a bug" handler. It is not a bug: the device or its routing is the problem,
so the hint points at ``--device`` and ``--wav``. Pass ``sounddevice=None``
(the ``simpleaudio`` backend, which can't enumerate devices) to omit the
device listing."""
hint = (
f"the audio device could not play {sample_rate} Hz audio. Select another "
"with --device NAME|INDEX or $HARMONICS_AUDIO_DEVICE (e.g. "
"--device pipewire), or render a file with --wav and play it yourself"
)
listing = output_device_listing(sounddevice) if sounddevice is not None else ""
if listing:
hint += f". Output devices: {listing}"
return CliError(
code=EXIT_ENV_ERROR,
message=f"audio playback failed: {exc}",
remediation=hint,
)
97 changes: 67 additions & 30 deletions harmonics/audio/synth.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,11 +15,13 @@
* audio-PRODUCING actions require an explicit flag; the only function here
that touches a real device, :func:`play`, isolates its optional playback
library behind a **lazy, in-function** import (tried in order:
``simpleaudio``, then ``sounddevice``) so importing :mod:`harmonics.audio`
itself never requires a sound stack. If neither library is importable,
:func:`play` raises the same structured :class:`~harmonics.cli._errors.
CliError` every other verb failure uses, rather than a bare
``ImportError`` or a silent no-op.
``sounddevice``, then ``simpleaudio``) so importing :mod:`harmonics.audio`
itself never requires a sound stack. ``sounddevice`` is preferred because it
is the only backend that honors ``--device`` / the pipewire-default output
selection (``simpleaudio`` has no device API); ``simpleaudio`` is a lighter
fallback. If neither library is importable, :func:`play` raises the same
structured :class:`~harmonics.cli._errors.CliError` every other verb failure
uses, rather than a bare ``ImportError`` or a silent no-op.

Articulation — how the voice MOVES between notes
--------------------------------------------------
Expand Down Expand Up @@ -81,6 +83,7 @@
from pathlib import Path
from typing import Sequence

from harmonics.audio._playback import device_playback_error, select_output_device
from harmonics.cli._errors import EXIT_ENV_ERROR, CliError
from harmonics.notes import NoteEvent

Expand Down Expand Up @@ -445,35 +448,36 @@ def play(
*,
sample_rate: int = DEFAULT_SAMPLE_RATE,
articulation: str = "discrete",
device: int | str | None = None,
) -> None:
"""Render ``seq`` and play it through a live playback backend.

Tries an optional playback library, in order: ``simpleaudio``, then
``sounddevice`` — both imported LAZILY, right here, so importing
Tries an optional playback library, in order: ``sounddevice``, then
``simpleaudio`` — both imported LAZILY, right here, so importing
:mod:`harmonics.audio` (or calling :func:`render_wav`/:func:`write_wav`)
never requires either to be installed. If neither is importable, raises
the project's structured :class:`~harmonics.cli._errors.CliError`
(exit code :data:`~harmonics.cli._errors.EXIT_ENV_ERROR`) with a
remediation hint, instead of letting a bare ``ImportError`` (or a
silent no-op) reach the caller.
never requires either to be installed. ``sounddevice`` is preferred
because it is the only backend that honors ``device`` (and the
pipewire/pulse auto-preference); ``simpleaudio`` is a lighter fallback for
an environment that only has it.

``device`` selects the output device for the ``sounddevice`` backend (an
index or a name substring, e.g. ``"pipewire"``); with ``device=None`` a
resampling sound-server device is preferred when present so playback works
on a host whose default sink is fixed-rate — see
:func:`~harmonics.audio._playback.select_output_device`. (``simpleaudio``
has no device-selection API, so ``device`` is silently ignored on that
fallback backend; that only happens when ``sounddevice`` is unavailable.)

Raises the project's structured :class:`~harmonics.cli._errors.CliError`
(exit :data:`~harmonics.cli._errors.EXIT_ENV_ERROR`) in two cases, rather
than letting a bare ``ImportError``, a device ``PortAudioError``, or a
silent no-op reach the caller: (1) neither backend is importable, and
(2) a backend is present but the device fails to play (bad sample rate,
busy device, …) — see
:func:`~harmonics.audio._playback.device_playback_error`.
"""
data = render_wav(seq, sample_rate=sample_rate, articulation=articulation)

try:
import simpleaudio # type: ignore[import-not-found]
except ImportError:
simpleaudio = None # type: ignore[assignment]

if simpleaudio is not None:
with wave.open(io.BytesIO(data), "rb") as wf:
frames = wf.readframes(wf.getnframes())
channels = wf.getnchannels()
sampwidth = wf.getsampwidth()
framerate = wf.getframerate()
play_obj = simpleaudio.WaveObject(frames, channels, sampwidth, framerate).play()
play_obj.wait_done()
return

try:
import sounddevice # type: ignore[import-not-found]
except ImportError:
Expand All @@ -497,12 +501,45 @@ def play(
samples.frombytes(frames)
if sys.byteorder == "big":
samples.byteswap()
sounddevice.play(samples, framerate)
sounddevice.wait()
target = select_output_device(sounddevice, device)
# Only pass device= when one was actually resolved, so a backend (or a
# test double) with no device-selection support still plays on the
# default device.
play_kwargs = {} if target is None else {"device": target}
try:
sounddevice.play(samples, framerate, **play_kwargs)
sounddevice.wait()
except Exception as exc: # noqa: BLE001 - any device failure -> friendly CliError
raise device_playback_error(sounddevice, exc, framerate) from exc
return

try:
import simpleaudio # type: ignore[import-not-found]
except ImportError:
simpleaudio = None # type: ignore[assignment]

if simpleaudio is not None:
with wave.open(io.BytesIO(data), "rb") as wf:
frames = wf.readframes(wf.getnframes())
channels = wf.getnchannels()
sampwidth = wf.getsampwidth()
framerate = wf.getframerate()
# simpleaudio (the fallback) has no device-selection API, so ``device``
# cannot be honored here; a device failure is still converted to the
# structured CliError contract rather than a bare traceback.
try:
play_obj = simpleaudio.WaveObject(frames, channels, sampwidth, framerate).play()
play_obj.wait_done()
except Exception as exc: # noqa: BLE001 - any device failure -> friendly CliError
raise device_playback_error(None, exc, framerate) from exc
return

raise CliError(
code=EXIT_ENV_ERROR,
message="no audio playback backend is installed",
remediation="install 'simpleaudio' or 'sounddevice', or use --wav to write a file",
remediation=(
"install the audio extra: uv tool install 'harmonics-cli[audio]' "
"(pulls in sounddevice), or hand-install 'simpleaudio'; "
"or use --wav to write a file instead"
),
)
29 changes: 24 additions & 5 deletions harmonics/cli/_commands/demo.py
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@
from __future__ import annotations

import argparse
import os
from pathlib import Path

from harmonics.cli._errors import EXIT_ENV_ERROR, CliError
Expand Down Expand Up @@ -80,10 +81,18 @@ def _format_text(clips: list[Clip]) -> str:
return "\n".join(lines)


def _play_live(clips: list[Clip], json_mode: bool) -> None:
def _resolve_device(args: argparse.Namespace) -> str | None:
"""The output device for ``--play``: the ``--device`` flag if given, else
``$HARMONICS_AUDIO_DEVICE``, else ``None`` — in which case
:func:`harmonics.demo.play.play_clips` prefers a resampling sound-server
device (pipewire/pulse) before falling back to the backend's default."""
return args.device or os.environ.get("HARMONICS_AUDIO_DEVICE") or None


def _play_live(clips: list[Clip], device: str | None, 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)
play_clips(clips, device=device)
if json_mode:
emit_result({"played": len(clips)}, json_mode=True)
else:
Expand Down Expand Up @@ -122,7 +131,7 @@ def cmd_demo(args: argparse.Namespace) -> int | None:
clips = showcase(articulation=args.articulation)

if args.play:
_play_live(clips, json_mode)
_play_live(clips, _resolve_device(args), json_mode)
return None

wrote = _write_requested_files(clips, args)
Expand Down Expand Up @@ -175,8 +184,18 @@ def register(sub: argparse._SubParsersAction) -> None:
"--play",
action="store_true",
help=(
"Play every clip live via simpleaudio/sounddevice if installed "
"(else a friendly error)."
"Play every clip live (needs the audio extra: "
"uv tool install 'harmonics-cli[audio]'); else a friendly error."
),
)
p.add_argument(
"--device",
default=None,
metavar="NAME|INDEX",
help=(
"Output device for --play (a name substring or index), e.g. "
"--device pipewire. Overrides $HARMONICS_AUDIO_DEVICE; the default "
"prefers a resampling sound-server device (pipewire/pulse)."
),
)
p.add_argument(
Expand Down
Loading
Loading