From dcb35fe6533455d0bd8e9cc3775314b3fe0ee28c Mon Sep 17 00:00:00 2001 From: Ori Nachum Date: Wed, 8 Jul 2026 16:26:06 +0300 Subject: [PATCH 1/2] =?UTF-8?q?feat:=20installable=20live=20audio=20playba?= =?UTF-8?q?ck=20=E2=80=94=20[audio]=20extra,=20--device=20selection,=20fri?= =?UTF-8?q?endly=20device=20errors?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `harmonics ... --play` was unusable out of the box: no audio backend shipped with the package, and even after installing one it opened the raw ALSA default device — which on a multi-sink host is often a fixed-rate sink (e.g. a 16 kHz USB audio adapter) that rejects the synth's 44.1 kHz, failing with a misleading "unexpected error, file a bug". - Add opt-in `audio` extra (sounddevice>=0.4.6 + numpy>=1.24). Core deps stay empty/offline-testable per the CLAUDE.md invariant; the extra is opt-in so importing the package still needs no sound stack. sounddevice over simpleaudio: it ships cp312 wheels bundling PortAudio. - Add --device NAME|INDEX + $HARMONICS_AUDIO_DEVICE (flag wins) on play/say/demo. With none set, prefer a resampling sound-server device (pipewire, then pulse) so playback works when the default sink is fixed-rate. - Convert live-device failures (PortAudioError) into a friendly exit-2 CliError that lists output devices and points at --device/--wav, instead of the "unexpected error, file a bug" wrapper. - Docs (README, explain catalog, CLAUDE.md), CHANGELOG, version 0.6.0 -> 0.7.0, and 17 new tests. Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_01VMUvDkSHhthqU3c5HrhQuj --- CHANGELOG.md | 28 +++++ CLAUDE.md | 6 ++ README.md | 23 ++++- harmonics/audio/synth.py | 130 ++++++++++++++++++++++-- harmonics/cli/_commands/demo.py | 29 +++++- harmonics/cli/_commands/play.py | 34 +++++-- harmonics/cli/_commands/say.py | 34 +++++-- harmonics/demo/play.py | 50 ++++++--- harmonics/explain/catalog.py | 50 +++++++-- pyproject.toml | 18 +++- tests/test_audio.py | 160 +++++++++++++++++++++++++++++ tests/test_demo_play.py | 98 ++++++++++++++++++ tests/test_play.py | 57 +++++++++++ tests/test_say.py | 57 +++++++++++ uv.lock | 174 +++++++++++++++++++++++++++++++- 15 files changed, 892 insertions(+), 56 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 93ddea0..a5ede68 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,34 @@ 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 + +- 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 diff --git a/CLAUDE.md b/CLAUDE.md index 81bc329..e9f3088 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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 diff --git a/README.md b/README.md index adb7021..074ae58 100644 --- a/README.md +++ b/README.md @@ -86,15 +86,34 @@ command you run is `harmonics`. | `explain ` | Markdown docs for any noun/verb path. | | `overview` | Read-only descriptive snapshot of the agent. | | `doctor` | Check the agent-identity invariants (prompt-file-present, backend-consistency). | -| `play` | Render explicit axes to a note sequence (dry-run; `--wav`/`--play` for audio). | +| `play` | Render explicit axes to a note sequence (dry-run; `--wav`/`--play` for audio, `--device` to pick the output). | | `say ""` | Infer axes from a sentence and render it in the agent's voice. | -| `demo` | Tour the whole voice: `--play` / `--html` / `--wav` / `--out` / `--json` (dry-run by default). | +| `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, diff --git a/harmonics/audio/synth.py b/harmonics/audio/synth.py index 2608092..d565980 100644 --- a/harmonics/audio/synth.py +++ b/harmonics/audio/synth.py @@ -440,22 +440,114 @@ def write_wav( Path(path).write_bytes(data) +# --- live playback: output-device selection + friendly device errors ---------- + +#: 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 :func:`play` should open on ``sounddevice``. + + 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 :func:`play` 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``.""" + 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, + ) + + def play( seq: Sequence[NoteEvent], *, 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 :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. + + ``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:`_select_output_device`. (``simpleaudio`` has no device-selection + API, so ``device`` is ignored on that backend.) + + 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:`_device_playback_error`. """ data = render_wav(seq, sample_rate=sample_rate, articulation=articulation) @@ -470,8 +562,14 @@ def play( channels = wf.getnchannels() sampwidth = wf.getsampwidth() framerate = wf.getframerate() - play_obj = simpleaudio.WaveObject(frames, channels, sampwidth, framerate).play() - play_obj.wait_done() + # simpleaudio has no device-selection API, so ``device`` applies to the + # sounddevice backend only; a device failure here 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 try: @@ -497,12 +595,24 @@ 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 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" + ), ) diff --git a/harmonics/cli/_commands/demo.py b/harmonics/cli/_commands/demo.py index b280edb..bb72d17 100644 --- a/harmonics/cli/_commands/demo.py +++ b/harmonics/cli/_commands/demo.py @@ -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 @@ -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: @@ -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) @@ -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( diff --git a/harmonics/cli/_commands/play.py b/harmonics/cli/_commands/play.py index 71ef497..021347d 100644 --- a/harmonics/cli/_commands/play.py +++ b/harmonics/cli/_commands/play.py @@ -41,6 +41,7 @@ from __future__ import annotations import argparse +import os from pathlib import Path from harmonics.axes import CONFIDENCES, INTENTS, STATES, URGENCIES, Axes @@ -116,7 +117,17 @@ def _raise_articulation_error(articulation: str, err: ValueError) -> None: ) from err -def _play_live(notes: list[NoteEvent], articulation: str, 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.audio.play` prefers a resampling sound-server device + (pipewire/pulse) before falling back to the backend's own default.""" + return args.device or os.environ.get("HARMONICS_AUDIO_DEVICE") or None + + +def _play_live( + notes: list[NoteEvent], articulation: str, device: str | None, json_mode: bool +) -> None: """``--play``: render and play ``notes`` through a live backend.""" # Lazy import: harmonics.audio's own optional playback backend is # isolated behind this call, so importing this module (and every other @@ -126,7 +137,7 @@ def _play_live(notes: list[NoteEvent], articulation: str, json_mode: bool) -> No from harmonics.audio import play as play_audio try: - play_audio(notes, articulation=articulation) + play_audio(notes, articulation=articulation, device=device) except ValueError as err: _raise_articulation_error(articulation, err) if json_mode: @@ -175,7 +186,7 @@ def _emit(notes: list[NoteEvent], args: argparse.Namespace, json_mode: bool) -> """Dispatch to the right output path: ``--play`` > ``--wav`` > ``--out`` > the dry-run default — mirrors the priority documented on each flag.""" if args.play: - _play_live(notes, args.articulation, json_mode) + _play_live(notes, args.articulation, _resolve_device(args), json_mode) elif args.wav: _write_wav_file(notes, args.wav, args.articulation, json_mode) elif args.out: @@ -258,9 +269,20 @@ def register(sub: argparse._SubParsersAction) -> None: "--play", action="store_true", help=( - "Render and play audio live via 'simpleaudio' or 'sounddevice' if " - "installed; otherwise fails with a friendly error (use --wav to " - "capture to a file with no device)." + "Render and play audio live (needs the audio extra: " + "uv tool install 'harmonics-cli[audio]'); otherwise fails with a " + "friendly error (use --wav to capture to a file with no device)." + ), + ) + 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) so " + "playback works when the system default sink is a fixed-rate one." ), ) p.add_argument( diff --git a/harmonics/cli/_commands/say.py b/harmonics/cli/_commands/say.py index a0d5349..bf97048 100644 --- a/harmonics/cli/_commands/say.py +++ b/harmonics/cli/_commands/say.py @@ -91,6 +91,7 @@ import argparse import json +import os from dataclasses import replace from pathlib import Path @@ -236,7 +237,17 @@ def _raise_articulation_error(articulation: str, err: ValueError) -> None: ) from err -def _play_live(seq: list[NoteEvent], articulation: str, 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.audio.play` prefers a resampling sound-server device + (pipewire/pulse) before falling back to the backend's own default.""" + return args.device or os.environ.get("HARMONICS_AUDIO_DEVICE") or None + + +def _play_live( + seq: list[NoteEvent], articulation: str, device: str | None, json_mode: bool +) -> None: """``--play``: render and play ``seq`` through a live backend.""" # Lazy import: harmonics.audio's own optional playback backend is # isolated behind this call, so importing this module (and every other @@ -248,7 +259,7 @@ def _play_live(seq: list[NoteEvent], articulation: str, json_mode: bool) -> None from harmonics.audio import play as play_audio try: - play_audio(seq, articulation=articulation) + play_audio(seq, articulation=articulation, device=device) except ValueError as err: _raise_articulation_error(articulation, err) if json_mode: @@ -315,7 +326,7 @@ def _emit(seq: list[NoteEvent], args: argparse.Namespace, json_mode: bool) -> No write whichever of ``--out``/``--midi``/``--wav`` were requested (any combination), or fall back to the dry-run default.""" if args.play: - _play_live(seq, args.articulation, json_mode) + _play_live(seq, args.articulation, _resolve_device(args), json_mode) return wrote = _write_requested_files(seq, args) @@ -415,9 +426,20 @@ def register(sub: argparse._SubParsersAction) -> None: "--play", action="store_true", help=( - "Render and play audio live via 'simpleaudio' or 'sounddevice' if " - "installed; otherwise fails with a friendly error (use --wav to " - "capture to a file with no device)." + "Render and play audio live (needs the audio extra: " + "uv tool install 'harmonics-cli[audio]'); otherwise fails with a " + "friendly error (use --wav to capture to a file with no device)." + ), + ) + 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) so " + "playback works when the system default sink is a fixed-rate one." ), ) p.add_argument( diff --git a/harmonics/demo/play.py b/harmonics/demo/play.py index 3febb7a..ebe4627 100644 --- a/harmonics/demo/play.py +++ b/harmonics/demo/play.py @@ -30,6 +30,7 @@ from array import array from typing import Callable +from harmonics.audio.synth import _device_playback_error, _select_output_device from harmonics.cli._errors import EXIT_ENV_ERROR, CliError from harmonics.demo.core import Clip @@ -38,16 +39,20 @@ DEFAULT_GAP_SECONDS = 0.4 -def _resolve_backend() -> Callable[[bytes], None]: +def _resolve_backend(device: int | str | None = None) -> Callable[[bytes], None]: """Resolve a live playback backend, lazily. Tries ``simpleaudio`` first, then ``sounddevice`` — both imported LAZILY, right here, so importing :mod:`harmonics.demo.play` never requires either to be installed. Returns a callable that plays one - clip's WAV bytes using whichever backend was found. Raises - :class:`CliError` (:data:`EXIT_ENV_ERROR`) with a remediation hint if - neither library is importable — never a bare ``ImportError``, never a - silent no-op. + clip's WAV bytes using whichever backend was found; a device failure + inside that callable is converted to :class:`CliError` (never a bare + ``PortAudioError``). ``device`` selects the ``sounddevice`` output device + (index or name substring; ``None`` prefers a resampling sound-server + device — see :func:`harmonics.audio.synth._select_output_device`), and is + resolved ONCE here alongside the backend. Raises :class:`CliError` + (:data:`EXIT_ENV_ERROR`) with a remediation hint if neither library is + importable — never a bare ``ImportError``, never a silent no-op. """ try: import simpleaudio # type: ignore[import-not-found] @@ -62,8 +67,13 @@ def _play_with_simpleaudio(wav_bytes: bytes) -> None: nchannels = wf.getnchannels() sampwidth = wf.getsampwidth() framerate = wf.getframerate() - play_obj = simpleaudio.play_buffer(frames, nchannels, sampwidth, framerate) - play_obj.wait_done() + # simpleaudio has no device-selection API, so ``device`` applies to + # the sounddevice backend only; a device failure is still wrapped. + try: + play_obj = simpleaudio.play_buffer(frames, nchannels, sampwidth, framerate) + 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 _play_with_simpleaudio @@ -73,6 +83,8 @@ def _play_with_simpleaudio(wav_bytes: bytes) -> None: sounddevice = None # type: ignore[assignment] if sounddevice is not None: + target = _select_output_device(sounddevice, device) + play_kwargs = {} if target is None else {"device": target} def _play_with_sounddevice(wav_bytes: bytes) -> None: with wave.open(io.BytesIO(wav_bytes), "rb") as wf: @@ -87,8 +99,11 @@ def _play_with_sounddevice(wav_bytes: bytes) -> None: samples.frombytes(frames) if sys.byteorder == "big": samples.byteswap() - sounddevice.play(samples, framerate) - sounddevice.wait() + 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 _play_with_sounddevice @@ -96,23 +111,26 @@ def _play_with_sounddevice(wav_bytes: bytes) -> None: code=EXIT_ENV_ERROR, message="no audio playback backend available", remediation=( - "install 'simpleaudio' or 'sounddevice' (pip install simpleaudio), " + "install the audio extra: uv tool install 'harmonics-cli[audio]' " + "(pulls in sounddevice), or hand-install 'simpleaudio'; " "or use --wav/--out/--html to write a file instead" ), ) -def play_clips(clips: list[Clip], *, gap_seconds: float = DEFAULT_GAP_SECONDS) -> None: +def play_clips( + clips: list[Clip], *, gap_seconds: float = DEFAULT_GAP_SECONDS, device: int | str | None = None +) -> None: """Play each clip's already-rendered wav in sequence through a live backend. Plays ``clip.wav`` for every clip in ``clips``, in order, pausing ``gap_seconds`` between consecutive clips (no trailing pause after the - last one). The backend (``simpleaudio`` then ``sounddevice``) is - resolved once, lazily, before any clip plays — see :func:`_resolve_ - backend` — so a missing backend raises :class:`CliError` immediately, - before any sound is produced. + last one). The backend (``simpleaudio`` then ``sounddevice``) and the + output ``device`` are resolved once, lazily, before any clip plays — see + :func:`_resolve_backend` — so a missing backend raises :class:`CliError` + immediately, before any sound is produced. """ - play_one = _resolve_backend() + play_one = _resolve_backend(device) last_index = len(clips) - 1 for i, clip in enumerate(clips): play_one(clip.wav) diff --git a/harmonics/explain/catalog.py b/harmonics/explain/catalog.py index 59199c8..ccb8df7 100644 --- a/harmonics/explain/catalog.py +++ b/harmonics/explain/catalog.py @@ -176,9 +176,20 @@ - `--wav FILE` — renders and writes a real WAV audio file to `FILE`. No live device needed — this only touches the filesystem. - `--play` — renders and plays the gesture live through `simpleaudio` or - `sounddevice` (tried in that order, whichever is installed). If neither is - installed, fails with a structured `CliError` and a remediation hint to - install one or use `--wav`/`--out` instead. + `sounddevice` (tried in that order, whichever is installed). `sounddevice` + ships via the opt-in `harmonics-cli[audio]` extra (`uv tool install + 'harmonics-cli[audio]'`, pulling in `sounddevice` + `numpy`), so the core + install stays dependency-free. If neither backend is installed, fails with + a structured `CliError` and a remediation hint to install one or use + `--wav`/`--out` instead. +- `--device NAME|INDEX` — selects the output device for `--play` (a name + substring or index, e.g. `--device pipewire`). Falls back to + `$HARMONICS_AUDIO_DEVICE` when unset (the flag wins over the env var); with + neither given, prefers a resampling sound-server device (pipewire, then + pulse) so playback still works when the system default sink is a + fixed-rate device. A device failure emits a friendly environment error + (exit `2`) listing available output devices and pointing at + `--device`/`--wav`, instead of a generic crash. ## `--articulation` — how the voice moves between notes @@ -270,9 +281,20 @@ device needed — this only touches the filesystem. - `--play` — renders and plays the utterance live through `simpleaudio` or `sounddevice` (tried in that order, whichever is installed); takes - priority over `--out`/`--midi`/`--wav` if given alongside them. If neither - library is installed, fails with a structured `CliError` and a - remediation hint to install one or use `--wav`/`--out`/`--midi` instead. + priority over `--out`/`--midi`/`--wav` if given alongside them. + `sounddevice` ships via the opt-in `harmonics-cli[audio]` extra (`uv tool + install 'harmonics-cli[audio]'`, pulling in `sounddevice` + `numpy`), so + the core install stays dependency-free. If neither library is installed, + fails with a structured `CliError` and a remediation hint to install one + or use `--wav`/`--out`/`--midi` instead. +- `--device NAME|INDEX` — selects the output device for `--play` (a name + substring or index, e.g. `--device pipewire`). Falls back to + `$HARMONICS_AUDIO_DEVICE` when unset (the flag wins over the env var); with + neither given, prefers a resampling sound-server device (pipewire, then + pulse) so playback still works when the system default sink is a + fixed-rate device. A device failure emits a friendly environment error + (exit `2`) listing available output devices and pointing at + `--device`/`--wav` instead of a generic crash. ## `--articulation` — how the voice moves between notes @@ -327,9 +349,19 @@ - `--play` — play every clip live via `simpleaudio`/`sounddevice` (tried in that order, whichever is installed); takes priority over every file flag - below if given alongside them. If neither library is installed, fails - with a structured `CliError` and a remediation hint instead of a silent - no-op. + below if given alongside them. `sounddevice` ships via the opt-in + `harmonics-cli[audio]` extra (`uv tool install 'harmonics-cli[audio]'`, + pulling in `sounddevice` + `numpy`), so the core install stays + dependency-free. If neither library is installed, fails with a structured + `CliError` and a remediation hint instead of a silent no-op. +- `--device NAME|INDEX` — selects the output device for `--play` (a name + substring or index, e.g. `--device pipewire`). Falls back to + `$HARMONICS_AUDIO_DEVICE` when unset (the flag wins over the env var); with + neither given, prefers a resampling sound-server device (pipewire, then + pulse) so playback still works when the system default sink is a + fixed-rate device. A device failure emits a friendly environment error + (exit `2`) listing available output devices and pointing at + `--device`/`--wav` instead of a generic crash. - `--html FILE` — write a self-contained, browser-playable HTML gallery to FILE. - `--wav DIR` — write one WAV file per clip into DIR (created if missing). diff --git a/pyproject.toml b/pyproject.toml index d3077cc..60a1153 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "harmonics-cli" -version = "0.6.0" +version = "0.7.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" @@ -15,6 +15,22 @@ classifiers = [ ] dependencies = [] +# Opt-in audio I/O. The core (text->notes + offline WAV render) stays +# dependency-free and offline-testable — see CLAUDE.md's "runtime dependencies +# stay empty" invariant — so live playback is isolated behind this extra rather +# than in `dependencies`. `harmonics play`/`say`/`demo` only need it for the +# live `--play` flag; `--wav`/`--out`/`--html` and every read/describe verb work +# with no extra installed. +# +# uv tool install 'harmonics-cli[audio]' # or: pip install 'harmonics-cli[audio]' +# +# sounddevice ships manylinux/macOS/Windows wheels that bundle PortAudio (no +# compiler needed, unlike simpleaudio, which has no cp312 wheels); its +# `play()` convenience function requires numpy. `harmonics.audio.play` still +# also accepts a hand-installed `simpleaudio` if you prefer it. +[project.optional-dependencies] +audio = ["sounddevice>=0.4.6", "numpy>=1.24"] + [project.urls] Homepage = "https://github.com/agentculture/harmonics-cli" Issues = "https://github.com/agentculture/harmonics-cli/issues" diff --git a/tests/test_audio.py b/tests/test_audio.py index d776fe4..88bb21b 100644 --- a/tests/test_audio.py +++ b/tests/test_audio.py @@ -345,3 +345,163 @@ def test_play_sounddevice_byteswaps_samples_on_big_endian_host( samples, samplerate = fake_sd.played assert samplerate == DEFAULT_SAMPLE_RATE assert tuple(samples) == expected + + +# --- play(): output-device selection (auto-select, explicit override) ------- + + +class _FakeSoundDeviceWithDevices: + """Fake sounddevice exposing ``query_devices()`` so + :func:`~harmonics.audio.synth._select_output_device`'s auto-selection + logic can be exercised, and a ``play()`` that records the resolved + ``device=`` kwarg (unlike :class:`_FakeSoundDevice` above, which has no + device-selection support at all).""" + + def __init__(self, devices: list[dict]) -> None: + self.devices = devices + self.played: tuple | None = None + self.device: int | str | None = None + self.waited = False + + def query_devices(self) -> list[dict]: + return self.devices + + def play(self, samples, samplerate, device=None) -> None: # noqa: ANN001 + self.played = (samples, samplerate) + self.device = device + + def wait(self) -> None: + self.waited = True + + +class _FakeSoundDeviceRaisingOnPlay: + """Fake sounddevice whose ``play()`` always raises, to exercise the + friendly ``CliError`` conversion for a real device failure (e.g. a + PortAudioError for a sample rate the device can't accept).""" + + def __init__(self, devices: list[dict] | None = None) -> None: + self.devices = devices if devices is not None else [] + + def query_devices(self) -> list[dict]: + return self.devices + + def play(self, samples, samplerate, device=None) -> None: # noqa: ANN001 + raise RuntimeError("Invalid sample rate") + + def wait(self) -> None: # pragma: no cover - never reached, play() raises first + pass + + +def test_play_auto_selects_pipewire_device_when_present( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Prefers pipewire even when a non-pipewire output device sorts earlier + in ``query_devices()``.""" + monkeypatch.setitem(sys.modules, "simpleaudio", None) + devices = [ + {"name": "HDA default", "max_output_channels": 2}, + {"name": "pipewire", "max_output_channels": 64}, + {"name": "some input", "max_output_channels": 0}, + ] + fake_sd = _FakeSoundDeviceWithDevices(devices) + monkeypatch.setitem(sys.modules, "sounddevice", fake_sd) + + play(_seq()) + + assert fake_sd.played is not None + assert fake_sd.device == 1 + + +def test_play_auto_selects_pulse_device_when_no_pipewire( + monkeypatch: pytest.MonkeyPatch, +) -> None: + monkeypatch.setitem(sys.modules, "simpleaudio", None) + devices = [ + {"name": "HDA default", "max_output_channels": 2}, + {"name": "pulse", "max_output_channels": 32}, + ] + fake_sd = _FakeSoundDeviceWithDevices(devices) + monkeypatch.setitem(sys.modules, "sounddevice", fake_sd) + + play(_seq()) + + assert fake_sd.played is not None + assert fake_sd.device == 1 + + +def test_play_explicit_device_string_bypasses_auto_select( + monkeypatch: pytest.MonkeyPatch, +) -> None: + monkeypatch.setitem(sys.modules, "simpleaudio", None) + devices = [{"name": "pipewire", "max_output_channels": 64}] + fake_sd = _FakeSoundDeviceWithDevices(devices) + monkeypatch.setitem(sys.modules, "sounddevice", fake_sd) + + play(_seq(), device="hw:CARD=X") + + assert fake_sd.played is not None + assert fake_sd.device == "hw:CARD=X" + + +def test_play_explicit_digit_string_device_forwarded_as_int( + monkeypatch: pytest.MonkeyPatch, +) -> None: + monkeypatch.setitem(sys.modules, "simpleaudio", None) + fake_sd = _FakeSoundDeviceWithDevices([]) + monkeypatch.setitem(sys.modules, "sounddevice", fake_sd) + + play(_seq(), device="3") + + assert fake_sd.played is not None + assert fake_sd.device == 3 + assert isinstance(fake_sd.device, int) + + +# --- play(): a live device failure surfaces the friendly CliError ----------- + + +def test_play_sounddevice_failure_raises_friendly_cli_error( + monkeypatch: pytest.MonkeyPatch, +) -> None: + monkeypatch.setitem(sys.modules, "simpleaudio", None) + fake_sd = _FakeSoundDeviceRaisingOnPlay([]) + monkeypatch.setitem(sys.modules, "sounddevice", fake_sd) + + with pytest.raises(CliError) as exc: + play(_seq()) + + assert exc.value.code == EXIT_ENV_ERROR + assert exc.value.message.startswith("audio playback failed:") + assert "--device" in exc.value.remediation + assert "--wav" in exc.value.remediation + + +class _FakeWaveObjectRaisingOnPlay: + """Stand-in for ``simpleaudio.WaveObject`` whose ``play()`` always + raises, to exercise the friendly ``CliError`` conversion on the + simpleaudio branch (mirrors :class:`_FakeSoundDeviceRaisingOnPlay` above + for the sounddevice branch).""" + + def __init__( + self, audio_data: bytes, num_channels: int, bytes_per_sample: int, sample_rate: int + ) -> None: + pass + + def play(self): + raise RuntimeError("device busy") + + +def test_play_simpleaudio_failure_raises_friendly_cli_error( + monkeypatch: pytest.MonkeyPatch, +) -> None: + fake_module = type(sys)("simpleaudio") + fake_module.WaveObject = _FakeWaveObjectRaisingOnPlay # type: ignore[attr-defined] + monkeypatch.setitem(sys.modules, "simpleaudio", fake_module) + + with pytest.raises(CliError) as exc: + play(_seq()) + + assert exc.value.code == EXIT_ENV_ERROR + assert exc.value.message.startswith("audio playback failed:") + assert "--device" in exc.value.remediation + assert "--wav" in exc.value.remediation diff --git a/tests/test_demo_play.py b/tests/test_demo_play.py index 408be33..715fc7f 100644 --- a/tests/test_demo_play.py +++ b/tests/test_demo_play.py @@ -220,3 +220,101 @@ def test_play_clips_default_gap_is_positive() -> None: sig = inspect.signature(play_clips) assert sig.parameters["gap_seconds"].default == pytest.approx(0.4) + + +# --- play_clips(): output-device selection (device param threaded through) -- + + +class _FakeSoundDeviceWithDevices: + """Fake sounddevice exposing ``query_devices()`` so + :func:`~harmonics.audio.synth._select_output_device`'s auto-selection + logic can be exercised, and a ``play()`` that records the resolved + ``device=`` kwarg (unlike :class:`_FakeSoundDevice` above, which has no + device-selection support at all).""" + + def __init__(self, devices: list[dict]) -> None: + self.devices = devices + self.played: tuple | None = None + self.device: int | str | None = None + self.waited = False + + def query_devices(self) -> list[dict]: + return self.devices + + def play(self, samples, samplerate, device=None) -> None: # noqa: ANN001 + self.played = (samples, samplerate) + self.device = device + + def wait(self) -> None: + self.waited = True + + +def test_play_clips_forwards_explicit_device_string( + monkeypatch: pytest.MonkeyPatch, +) -> None: + monkeypatch.setitem(sys.modules, "simpleaudio", None) + fake_sd = _FakeSoundDeviceWithDevices([]) + monkeypatch.setitem(sys.modules, "sounddevice", fake_sd) + + from harmonics.demo.play import play_clips + + clip = _make_clip("one") + play_clips([clip], gap_seconds=0.0, device="pipewire") + + assert fake_sd.played is not None + assert fake_sd.device == "pipewire" + + +def test_play_clips_auto_selects_pipewire_device( + monkeypatch: pytest.MonkeyPatch, +) -> None: + monkeypatch.setitem(sys.modules, "simpleaudio", None) + devices = [ + {"name": "HDA default", "max_output_channels": 2}, + {"name": "pipewire", "max_output_channels": 64}, + ] + fake_sd = _FakeSoundDeviceWithDevices(devices) + monkeypatch.setitem(sys.modules, "sounddevice", fake_sd) + + from harmonics.demo.play import play_clips + + clip = _make_clip("one") + play_clips([clip], gap_seconds=0.0) + + assert fake_sd.played is not None + assert fake_sd.device == 1 + + +class _FakeSoundDeviceRaisingOnPlay: + """Fake sounddevice whose ``play()`` always raises, to exercise the + friendly ``CliError`` conversion for a real device failure.""" + + def __init__(self, devices: list[dict] | None = None) -> None: + self.devices = devices if devices is not None else [] + + def query_devices(self) -> list[dict]: + return self.devices + + def play(self, samples, samplerate, device=None) -> None: # noqa: ANN001 + raise RuntimeError("Invalid sample rate") + + def wait(self) -> None: # pragma: no cover - never reached, play() raises first + pass + + +def test_play_clips_sounddevice_failure_raises_friendly_cli_error( + monkeypatch: pytest.MonkeyPatch, +) -> None: + monkeypatch.setitem(sys.modules, "simpleaudio", None) + fake_sd = _FakeSoundDeviceRaisingOnPlay() + monkeypatch.setitem(sys.modules, "sounddevice", fake_sd) + + from harmonics.demo.play import play_clips + + clip = _make_clip("one") + with pytest.raises(CliError) as exc: + play_clips([clip], gap_seconds=0.0) + + assert exc.value.code == EXIT_ENV_ERROR + assert "--device" in exc.value.remediation + assert "--wav" in exc.value.remediation diff --git a/tests/test_play.py b/tests/test_play.py index fb80037..aac2ce2 100644 --- a/tests/test_play.py +++ b/tests/test_play.py @@ -355,3 +355,60 @@ def test_explain_play_resolves(capsys: pytest.CaptureFixture[str]) -> None: assert rc == 0 out = capsys.readouterr().out assert "# harmonics play" in out + + +# --- --device / $HARMONICS_AUDIO_DEVICE resolution --------------------------- + + +def _make_capture(): + captured: dict[str, object] = {} + + def fake_play(seq, *, articulation="discrete", device=None): + captured["seq"] = seq + captured["articulation"] = articulation + captured["device"] = device + + return captured, fake_play + + +def test_play_device_flag_is_passed_to_backend(monkeypatch: pytest.MonkeyPatch) -> None: + captured, fake_play = _make_capture() + monkeypatch.setattr("harmonics.audio.play", fake_play) + + rc = main(["play", "--intent", "success", "--play", "--device", "pipewire"]) + + assert rc == 0 + assert captured["device"] == "pipewire" + + +def test_play_device_env_var_used_when_no_flag(monkeypatch: pytest.MonkeyPatch) -> None: + captured, fake_play = _make_capture() + monkeypatch.setattr("harmonics.audio.play", fake_play) + monkeypatch.setenv("HARMONICS_AUDIO_DEVICE", "pulse") + + rc = main(["play", "--intent", "success", "--play"]) + + assert rc == 0 + assert captured["device"] == "pulse" + + +def test_play_device_flag_overrides_env_var(monkeypatch: pytest.MonkeyPatch) -> None: + captured, fake_play = _make_capture() + monkeypatch.setattr("harmonics.audio.play", fake_play) + monkeypatch.setenv("HARMONICS_AUDIO_DEVICE", "pulse") + + rc = main(["play", "--intent", "success", "--play", "--device", "hw:1"]) + + assert rc == 0 + assert captured["device"] == "hw:1" + + +def test_play_device_defaults_to_none(monkeypatch: pytest.MonkeyPatch) -> None: + captured, fake_play = _make_capture() + monkeypatch.setattr("harmonics.audio.play", fake_play) + monkeypatch.delenv("HARMONICS_AUDIO_DEVICE", raising=False) + + rc = main(["play", "--intent", "success", "--play"]) + + assert rc == 0 + assert captured["device"] is None diff --git a/tests/test_say.py b/tests/test_say.py index ae88f2a..db01909 100644 --- a/tests/test_say.py +++ b/tests/test_say.py @@ -503,3 +503,60 @@ def test_explain_say_resolves(capsys: pytest.CaptureFixture[str]) -> None: assert rc == 0 out = capsys.readouterr().out assert "# harmonics say" in out + + +# --- --device / $HARMONICS_AUDIO_DEVICE resolution --------------------------- + + +def _make_capture(): + captured: dict[str, object] = {} + + def fake_play(seq, *, articulation="discrete", device=None): + captured["seq"] = seq + captured["articulation"] = articulation + captured["device"] = device + + return captured, fake_play + + +def test_say_device_flag_is_passed_to_backend(monkeypatch: pytest.MonkeyPatch) -> None: + captured, fake_play = _make_capture() + monkeypatch.setattr("harmonics.audio.play", fake_play) + + rc = main(["say", "all tests passed", "--play", "--device", "pipewire"]) + + assert rc == 0 + assert captured["device"] == "pipewire" + + +def test_say_device_env_var_used_when_no_flag(monkeypatch: pytest.MonkeyPatch) -> None: + captured, fake_play = _make_capture() + monkeypatch.setattr("harmonics.audio.play", fake_play) + monkeypatch.setenv("HARMONICS_AUDIO_DEVICE", "pulse") + + rc = main(["say", "all tests passed", "--play"]) + + assert rc == 0 + assert captured["device"] == "pulse" + + +def test_say_device_flag_overrides_env_var(monkeypatch: pytest.MonkeyPatch) -> None: + captured, fake_play = _make_capture() + monkeypatch.setattr("harmonics.audio.play", fake_play) + monkeypatch.setenv("HARMONICS_AUDIO_DEVICE", "pulse") + + rc = main(["say", "all tests passed", "--play", "--device", "hw:1"]) + + assert rc == 0 + assert captured["device"] == "hw:1" + + +def test_say_device_defaults_to_none(monkeypatch: pytest.MonkeyPatch) -> None: + captured, fake_play = _make_capture() + monkeypatch.setattr("harmonics.audio.play", fake_play) + monkeypatch.delenv("HARMONICS_AUDIO_DEVICE", raising=False) + + rc = main(["say", "all tests passed", "--play"]) + + assert rc == 0 + assert captured["device"] is None diff --git a/uv.lock b/uv.lock index eb13e5a..5c7f81e 100644 --- a/uv.lock +++ b/uv.lock @@ -49,6 +49,91 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/94/51/f975cae76d44274cc2868dc9040ac5d58d464784610234455b4e7b19c6ef/black-26.5.1-py3-none-any.whl", hash = "sha256:4ed7f7da04046d2e488437170797d3b4a4ad83906683bcb7dfc68b673bbce5e2", size = 213693, upload-time = "2026-05-18T16:53:33.964Z" }, ] +[[package]] +name = "cffi" +version = "2.1.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pycparser", marker = "implementation_name != 'PyPy'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/57/5f/ff100cae70ebe9d8df1c01a00e510e45d9adb5c1fdda84791b199141de97/cffi-2.1.0.tar.gz", hash = "sha256:efc1cdd798b1aaf39b4610bba7aad28c9bea9b910f25c784ccf9ec1fa719d1f9", size = 531036, upload-time = "2026-07-06T21:34:30.382Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1e/85/990925db5df586ec90beb97529c853497e7f85ba0234830447faf41c3057/cffi-2.1.0-cp312-cp312-macosx_10_15_x86_64.whl", hash = "sha256:df2b82571a1b30f58a87bf4e5a9e78d2b1eff6c6ce8fd3aa3757221f93f0863f", size = 184829, upload-time = "2026-07-06T21:32:44.324Z" }, + { url = "https://files.pythonhosted.org/packages/4b/92/e7bb136ad6b5352603732cf907ef862ca103f20f2031c1735a46300c20c9/cffi-2.1.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:78474632761faa0fb96f30b1c928c84ebcf68713cbb80d15bab09dfe61640fde", size = 184728, upload-time = "2026-07-06T21:32:45.683Z" }, + { url = "https://files.pythonhosted.org/packages/c3/c0/d1ec30ffb370f748f2fb54425972bfef9871e0132e82fb589c46b6676049/cffi-2.1.0-cp312-cp312-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:5972433ad71a9e46516584ef60a0fda12d9dc459938d1539c3ddecf9bdc1368d", size = 214815, upload-time = "2026-07-06T21:32:48.557Z" }, + { url = "https://files.pythonhosted.org/packages/1b/dc/5620cf930688be01f2d673804291de757a934c90b946dbdc3d84130c2ea4/cffi-2.1.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:b6422532152adf4e59b110cb2808cee7a033800952f5c036b4af047ee43199e7", size = 222429, upload-time = "2026-07-06T21:32:49.848Z" }, + { url = "https://files.pythonhosted.org/packages/4b/a4/77b53abbf7a1e0beb9637edbef2a94d15f9c822f591e85d439ffd91519a6/cffi-2.1.0-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:46b1c8db8f6122420f32d02fffb924c2fe9bc772d228c7c711748fff56aabb2b", size = 210315, upload-time = "2026-07-06T21:32:51.221Z" }, + { url = "https://files.pythonhosted.org/packages/58/0c/f528df19cc94b675087324d4760d9e6d5bfae97d6217aa4fac43de4f5fcc/cffi-2.1.0-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:d9fafc5aa2e2a39aaf7f8cc0c1f044a9b07fca12e558dca53a3cc5c654ad67a7", size = 208859, upload-time = "2026-07-06T21:32:52.512Z" }, + { url = "https://files.pythonhosted.org/packages/62/f2/c9522a81c32132799a1972c39f5c5f8b4c8b9f00488a23feaa6c06f07741/cffi-2.1.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:1e9f50d192a3e525b15a75ab5114e442d83d657b7ec29182a991bc9a88fd3a66", size = 221844, upload-time = "2026-07-06T21:32:53.704Z" }, + { url = "https://files.pythonhosted.org/packages/6e/28/bd53988b9833e8f8ad539d26f4c07a6b3f6bcb1e9e02e7ca038250b3428d/cffi-2.1.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:98fff996e983a36d3aa2eca83af40c5821202e7e6f32d13ae94e3d2286f10cfe", size = 225287, upload-time = "2026-07-06T21:32:54.907Z" }, + { url = "https://files.pythonhosted.org/packages/79/99/0d0fd37f055224085f42bbb2c022d002e17dde4a97972822327b07d84101/cffi-2.1.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:379de10ce1ba048b1448599d1b37b24caee16309d1ac98d3982fc997f768700b", size = 223681, upload-time = "2026-07-06T21:32:56.329Z" }, + { url = "https://files.pythonhosted.org/packages/b0/80/c138990aa2a70b1a269f6e06348729836d733d6f970867943f61d367f8cc/cffi-2.1.0-cp312-cp312-win32.whl", hash = "sha256:9b8f0f26ca4e7513c534d351eca551947d053fac438f2a04ac96d882909b0d3a", size = 175269, upload-time = "2026-07-06T21:32:57.777Z" }, + { url = "https://files.pythonhosted.org/packages/a8/eb/f636456ff21a83fc13c032b58cc5dde061691546ac79efa284b2989b7982/cffi-2.1.0-cp312-cp312-win_amd64.whl", hash = "sha256:c97f080ea627e2863524c5af3836e2270b5f5dfff1f104392b959f8df0c5d384", size = 185881, upload-time = "2026-07-06T21:32:59.253Z" }, + { url = "https://files.pythonhosted.org/packages/dd/2c/400ea43e721727dca8a65c4521390e9196757caba4a45643acb2b63271b8/cffi-2.1.0-cp312-cp312-win_arm64.whl", hash = "sha256:6d194185eabd279f1c05ebe3504265ddfc5ad2b58d0714f7db9f01da592e9eb6", size = 180088, upload-time = "2026-07-06T21:33:02.278Z" }, + { url = "https://files.pythonhosted.org/packages/96/88/a996879e2eeccb815f6e3a5967b12a308257412acec882039d386bd2aa7b/cffi-2.1.0-cp313-cp313-ios_13_0_arm64_iphoneos.whl", hash = "sha256:10537b1df4967ca26d21e5072d7d54188354483b91dc75058968d3f0cf13fbda", size = 194331, upload-time = "2026-07-06T21:33:03.697Z" }, + { url = "https://files.pythonhosted.org/packages/58/85/7ae00d5c8dd6266f4e944c3db630f3c5c9a98b61d469c714d848b1d8138a/cffi-2.1.0-cp313-cp313-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:a95b05f9baf29b91171b3a8bd2020b028835243e7b0ff6bb23e2a3c228518b1b", size = 196966, upload-time = "2026-07-06T21:33:05.353Z" }, + { url = "https://files.pythonhosted.org/packages/8c/e9/45c3a76ad8d43ad9261f4c95436da61128d3ca545d72b9612c0ab5be0b1c/cffi-2.1.0-cp313-cp313-macosx_10_15_x86_64.whl", hash = "sha256:15faec4adfff450819f3aee0e2e02c812de6edb88203aa58807955db2003472a", size = 184795, upload-time = "2026-07-06T21:33:06.699Z" }, + { url = "https://files.pythonhosted.org/packages/84/4c/82f132cb4418ee6d953d982b19191e87e2a6372c8a4ce36e50b69d6ade4a/cffi-2.1.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:716ff8ec22f20b4d988b12884086bcef0fc99737043e503f7a3935a6be99b1ea", size = 184746, upload-time = "2026-07-06T21:33:08.071Z" }, + { url = "https://files.pythonhosted.org/packages/a0/1c/4ed5a0e5bdca6cbc275556de3328dd1b76fd0c11cc13c88fe66d1d8715f2/cffi-2.1.0-cp313-cp313-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:63960549e4f8dc41e31accb97b975abaecfc44c03e396c093a6436763c2ea7db", size = 214747, upload-time = "2026-07-06T21:33:09.671Z" }, + { url = "https://files.pythonhosted.org/packages/3a/a6/e879bb68cc23a2bc9ba8f4b7d8019f0c2694bad2ab6c4a3701d429439f58/cffi-2.1.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:ff067a8d8d880e7809e4ac88eb009bb848870115317b306666502ccad30b147f", size = 222392, upload-time = "2026-07-06T21:33:10.896Z" }, + { url = "https://files.pythonhosted.org/packages/88/f6/01890cfd63c08f8eb96a8319b0443690197d240a8bd6346048cf7bde9190/cffi-2.1.0-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:3b926723c13eba9f81d2ef3820d63aeceec3b2d4639906047bf675cb8a7a500d", size = 210285, upload-time = "2026-07-06T21:33:12.251Z" }, + { url = "https://files.pythonhosted.org/packages/a6/cf/2b684132056f438567b61e19d690dd31cd0921ace051e0a458be6074369e/cffi-2.1.0-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:47ff3a8bfd8cb9da1af7524b965127095055654c177fcfc7578debcb015eecd0", size = 208801, upload-time = "2026-07-06T21:33:13.617Z" }, + { url = "https://files.pythonhosted.org/packages/6f/08/f2e7d62c460faae0926f2d6e423694aa409ced3bc1fe2927a0a6e5f05416/cffi-2.1.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:799416bae98336e400981ff6e532d67d5c709cfb30afb79865a1315f94b0e224", size = 221808, upload-time = "2026-07-06T21:33:15.466Z" }, + { url = "https://files.pythonhosted.org/packages/38/37/04f54b8e63a02f3d908332c9effbf8c366167c6f733ed8a3d4f79b7e2a1e/cffi-2.1.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:961be50688f7fba2fa65f63712d3b9b341a22311f5253460ce933f52f0de1c8c", size = 225241, upload-time = "2026-07-06T21:33:16.869Z" }, + { url = "https://files.pythonhosted.org/packages/a9/d6/c72eecca433cd3e681c65ed313ab4835d9d4a379704d0f628a6a05f51c2e/cffi-2.1.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:bf5c6cf48238b0eb4c086978c492ad1cbc22373fc5b2d7353b3a598ce6db887a", size = 223588, upload-time = "2026-07-06T21:33:18.239Z" }, + { url = "https://files.pythonhosted.org/packages/c6/4b/e706f67279140f92939da3475ad610df18bfd52d50f14953a8e5fede71d5/cffi-2.1.0-cp313-cp313-win32.whl", hash = "sha256:db3eb7d46527159a878ec3460e9d40615bc25ba337d477db681aea6e4f05c5d2", size = 175248, upload-time = "2026-07-06T21:33:19.799Z" }, + { url = "https://files.pythonhosted.org/packages/5a/47/59eb7975cb0e4ef0afa764ea945b29a5bb4537a9f771cb7d6c8a5dd74c95/cffi-2.1.0-cp313-cp313-win_amd64.whl", hash = "sha256:8e74a6135550c4748af665b1b1118b6aab33b1fc6a16f9aff630af107c3b4512", size = 185717, upload-time = "2026-07-06T21:33:21.47Z" }, + { url = "https://files.pythonhosted.org/packages/5a/af/34fee85c48f8d94efc8597bc09470c9dd274c145f1c12e0fbc6ab6d38d74/cffi-2.1.0-cp313-cp313-win_arm64.whl", hash = "sha256:2282cd5e38aa8accd03e99d1256af8411c84cdbee6a89d841b563fdbd1f3e50f", size = 180114, upload-time = "2026-07-06T21:33:22.515Z" }, + { url = "https://files.pythonhosted.org/packages/d8/f0/81478e482afa03f6d18dc8f2afb5edc45b3080853b634b5ed91961be0998/cffi-2.1.0-cp314-cp314-ios_13_0_arm64_iphoneos.whl", hash = "sha256:d2117334c3af3bdcb9a88522b844a2bdb5efdc4f71c6c822df55486ae1c3347a", size = 194142, upload-time = "2026-07-06T21:33:23.657Z" }, + { url = "https://files.pythonhosted.org/packages/7d/95/8de304305cd9204974b0ca051b86d307cafca13aa575a0ef1b44d92c0d8c/cffi-2.1.0-cp314-cp314-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:702c436735fbe99d59ada02a1f65cfc0d31c0ee8b7290912f8fbc5cd1e4b16c3", size = 196819, upload-time = "2026-07-06T21:33:25.007Z" }, + { url = "https://files.pythonhosted.org/packages/20/71/7c8372d30e42415602ed9f268f7cfd66f1b855fed881ecd168bcb45dbc0b/cffi-2.1.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:1ff3456eab0d889592d1936d6125bbfbc7ae4d3354a700f8bd80450a66445d4d", size = 184965, upload-time = "2026-07-06T21:33:26.605Z" }, + { url = "https://files.pythonhosted.org/packages/d6/5c/584e626835f0375c928176c04137c96927165cb8733cdb3150ec04e5ee5e/cffi-2.1.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:c4165821e131d6d4ca444347c2b694e2311bcfa3fe5a861cc72968f28867beac", size = 184952, upload-time = "2026-07-06T21:33:27.823Z" }, + { url = "https://files.pythonhosted.org/packages/2e/d2/065fcae1c73979fac8e054462478d0ff8a29c40cdc2ed7ea5676a061df53/cffi-2.1.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:276f20fffd7b396e12516ba8edf9509210ac248cbbc5acbc39cd512f9f59ebe6", size = 222353, upload-time = "2026-07-06T21:33:29.178Z" }, + { url = "https://files.pythonhosted.org/packages/ed/a5/e8bbb1ce5b3ac2f53ad6a10bde44318a5a8d99d4f4a000d44a6e39aeb3e4/cffi-2.1.0-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:7d5980a3433d4b71a5e120f9dd551403d7824e31e2e67124fe2769c404c06913", size = 210051, upload-time = "2026-07-06T21:33:30.534Z" }, + { url = "https://files.pythonhosted.org/packages/28/ed/c127d3ac36e899c965e3361357c3befacd6578c03f40125183e41c3b219e/cffi-2.1.0-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:6ca4919c6e4f89aa99c42510b42cf54596892c00b3f9077f6bdd1505e24b9c8d", size = 208630, upload-time = "2026-07-06T21:33:31.753Z" }, + { url = "https://files.pythonhosted.org/packages/cc/d7/97d3136f81db489ec8d1d67748c110d6c994268fd7528014aa9f2b085e4e/cffi-2.1.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:d53d10f7da99ae46f7373b9150393e9c5eab9b224909982b43832668de4779f5", size = 221593, upload-time = "2026-07-06T21:33:33.044Z" }, + { url = "https://files.pythonhosted.org/packages/d3/27/93195977168ee63aed233a1a0993a2178798654d1f4bddcdd321d6fd3b21/cffi-2.1.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:c351efb95e832a853a29361675f33a7ce53de1a109cd73fd47af0712213aa4ce", size = 225146, upload-time = "2026-07-06T21:33:34.224Z" }, + { url = "https://files.pythonhosted.org/packages/b3/c1/6dbd291ee2ae5a50a034aa057207081f545923bbf15dad4511e985aafff5/cffi-2.1.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:dbf7c7a88e2bac086f06d14577332760bdeecc42bdec8ac4077f6260557d9326", size = 223240, upload-time = "2026-07-06T21:33:35.57Z" }, + { url = "https://files.pythonhosted.org/packages/0f/6f/ade5ce9863a57992a6ea3d0d10d7e29b8749fc127204b3d493d667b2815f/cffi-2.1.0-cp314-cp314-win32.whl", hash = "sha256:1854b724d00f6654c742097d5387569021be12d3a0f770eae1df8f8acfcc6acd", size = 177723, upload-time = "2026-07-06T21:33:51.626Z" }, + { url = "https://files.pythonhosted.org/packages/41/de/92b9eeed4ae4a21d6fd9b2a2c8505cbed573299902ea73981cc13f7ff62c/cffi-2.1.0-cp314-cp314-win_amd64.whl", hash = "sha256:1b96bfe2c4bd825681b7d311ad6d9b7280a091f43e8f63da5729638083cd3bfb", size = 187937, upload-time = "2026-07-06T21:33:53.403Z" }, + { url = "https://files.pythonhosted.org/packages/2e/1a/cc6ae6c2913a03aab8898eee57963cf1035b8df5872ed8b9115fcc7e2be8/cffi-2.1.0-cp314-cp314-win_arm64.whl", hash = "sha256:7d28dff1db6764108bc30788d85d61c876beff416d9a49cb9dd7c5a9f34f5804", size = 183001, upload-time = "2026-07-06T21:33:54.74Z" }, + { url = "https://files.pythonhosted.org/packages/14/f0/134c00ce0779ec86dea2aa1aac69339c2741a8045072676763512363a2ea/cffi-2.1.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:7ea6b3e2c4250ff1de21c630fe72d0f63eb95c2c32ffbf64a358cf4a8836d714", size = 188538, upload-time = "2026-07-06T21:33:36.792Z" }, + { url = "https://files.pythonhosted.org/packages/50/d8/3b86aba791cb610d24e8a3e1b2cd529e71fa15096b04e4d4e360049d4a4c/cffi-2.1.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:6af371f3767faeffc6ac1ef57cdfd25844403e9d3f476c5537caee499de96376", size = 188230, upload-time = "2026-07-06T21:33:38.011Z" }, + { url = "https://files.pythonhosted.org/packages/14/d0/117dcd9209255ad8571fbc8c92ef32593a1d294dcec91ddc4e4db50606f2/cffi-2.1.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:eb4e8997a49aa2c08a3e43c9045d224448b8941d88e7ac163c7d383e560cbf98", size = 223899, upload-time = "2026-07-06T21:33:39.514Z" }, + { url = "https://files.pythonhosted.org/packages/b6/3d/f20f8b886b254e3ad10e15cd4186d3aed49f3e6a35ab37aab9f8f25f7c03/cffi-2.1.0-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:bf01d8c84cbea96b944c73b22182e6c7c432b3475632b8111dbfdc95ddad6e13", size = 211652, upload-time = "2026-07-06T21:33:40.851Z" }, + { url = "https://files.pythonhosted.org/packages/28/3b/fad54de07260b93ddeef4b96d0131d57ea900675df1d410ae1deee52d7a6/cffi-2.1.0-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:33eb1ad83ebe8f313e0df035c406227d55a79456704a863fad9842136af5ad7d", size = 210755, upload-time = "2026-07-06T21:33:42.183Z" }, + { url = "https://files.pythonhosted.org/packages/cc/82/3d5c705acb7abbba9bbd7d79b8e62e0f25b6120eb7ae6ac49f1b721722fe/cffi-2.1.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:ac0f1a2d0cfa7eea3f2aaf006ab6e70e8feeb16b75d65b7e5939982ca2f11056", size = 223933, upload-time = "2026-07-06T21:33:43.603Z" }, + { url = "https://files.pythonhosted.org/packages/6c/d0/47e338384ab6b1004241002fa616301020cea4fc95f283506565d252f276/cffi-2.1.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:c16914df9fb7f500e440e6875fa23ff5e0b31db01fa9c06af98d59a91f0dc2e4", size = 226749, upload-time = "2026-07-06T21:33:45.046Z" }, + { url = "https://files.pythonhosted.org/packages/70/25/65bd5b58ea4bfdfc15cde02cb5365f89ef8ab8b2adfb8fe5c4bd4233382f/cffi-2.1.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:5ecbd0499275d57506d397eebe1981cee87b47fcd9ef5c22cab7ed7644a39a94", size = 225703, upload-time = "2026-07-06T21:33:46.374Z" }, + { url = "https://files.pythonhosted.org/packages/dc/78/aa01ac599a8a4322533d45a1f9bc93b338276d2d59dabbe7c6d92a775c81/cffi-2.1.0-cp314-cp314t-win32.whl", hash = "sha256:7d034dcffa09e9a46c93fa3a3be402096cb5354ac6e41ab8e5cc9cd8b642ad76", size = 182857, upload-time = "2026-07-06T21:33:47.696Z" }, + { url = "https://files.pythonhosted.org/packages/b9/26/d00496b22de4d4228f32dde94ad996f350c8aad676d63bcca0743c8dea4d/cffi-2.1.0-cp314-cp314t-win_amd64.whl", hash = "sha256:0582a58f3051372229ca8e7f5f589f9e5632678208d8636fea3676711fdf7fe5", size = 194065, upload-time = "2026-07-06T21:33:48.953Z" }, + { url = "https://files.pythonhosted.org/packages/d5/dd/0c7dbf815a579ff005008a2d815a55d6bb047c349eef536d9dc53d3f0a8d/cffi-2.1.0-cp314-cp314t-win_arm64.whl", hash = "sha256:510aeeeac94811b138077451da1fb18b308a5feab47dd2b603af55804155e1c8", size = 186404, upload-time = "2026-07-06T21:33:50.309Z" }, + { url = "https://files.pythonhosted.org/packages/55/c7/8c8c50cb11c6750051daf12164098a9a6f027ac4356967fd4d800a07f242/cffi-2.1.0-cp315-cp315-ios_13_0_arm64_iphoneos.whl", hash = "sha256:2e9dabb9abcb7ad15938c7196ad5c1718a4e6d33cc79b4c0209bdb64c4a54a5c", size = 194121, upload-time = "2026-07-06T21:33:56.109Z" }, + { url = "https://files.pythonhosted.org/packages/99/e2/67680bf19a6b60d2bb7ff83baefa2a4c3d2d7dc0f3277034b802e1fc504c/cffi-2.1.0-cp315-cp315-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:37f525a7e7e50c017fdebe58b787be310ad59357ae43a053943a6e1a6c526001", size = 196820, upload-time = "2026-07-06T21:33:57.288Z" }, + { url = "https://files.pythonhosted.org/packages/ed/da/4bbe583a3b3a5c8c60892124fe17f3fa3656523faf0d3484eae90f091853/cffi-2.1.0-cp315-cp315-macosx_10_15_x86_64.whl", hash = "sha256:95f2954c2c9473d892eca6e0409f3568b37ab62a8eedb122461f73cc273476e3", size = 184936, upload-time = "2026-07-06T21:33:58.765Z" }, + { url = "https://files.pythonhosted.org/packages/e5/4b/1f4c36ab273980d7aa75bb126ea4f8971f24a96108acad3a0a084028c57b/cffi-2.1.0-cp315-cp315-macosx_11_0_arm64.whl", hash = "sha256:cdf2448aab5f661c9315308ec8b93f4e8a1a67a3c733f8631067a2b67d5913dc", size = 185045, upload-time = "2026-07-06T21:34:00.085Z" }, + { url = "https://files.pythonhosted.org/packages/ef/c3/ad299dc38f3583f8d916b299f028af418a9ec98bc695fcbebeae7420691c/cffi-2.1.0-cp315-cp315-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:90bec57cf82089383bd06a605b3eb8daebf7e5a668520beaf6e327a83a947699", size = 222342, upload-time = "2026-07-06T21:34:01.814Z" }, + { url = "https://files.pythonhosted.org/packages/eb/d8/df4543cc087245044ed02ef3ad8e0a26619d0075ac7a77a12dc81177851b/cffi-2.1.0-cp315-cp315-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:6274dcb2d15cef48daa73ed1be5a40d501d74dccd0cd6db364776d12cb6ba022", size = 210073, upload-time = "2026-07-06T21:34:03.255Z" }, + { url = "https://files.pythonhosted.org/packages/2c/0e/fac738d73728c6cea2a88a2883dca54892496cbba88a1dc1f2909cb8a6f5/cffi-2.1.0-cp315-cp315-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:2b71d409cccee78310ab5dec549aed052aaea483346e282c7b02362596e01bb0", size = 208551, upload-time = "2026-07-06T21:34:04.433Z" }, + { url = "https://files.pythonhosted.org/packages/e6/3f/0b04a700dd64f465c93020253a793a82c9b4dff9961f48facd0df945d9b8/cffi-2.1.0-cp315-cp315-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:7d3538f9c0e50670f4deb93dbb696576e60590369cae2faf7de681e597a8a1f1", size = 221649, upload-time = "2026-07-06T21:34:06.157Z" }, + { url = "https://files.pythonhosted.org/packages/5d/7c/b7379a5704c79eda57ce075869ba70a0368d1c850f803b3c0d078d39dcaf/cffi-2.1.0-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:8f9ec95b8a043d3dfbc74d9abc6f7baf524dd27a8dc160b0a32ff9cdab650c28", size = 225203, upload-time = "2026-07-06T21:34:07.489Z" }, + { url = "https://files.pythonhosted.org/packages/5a/02/d5e6c43ea85c41bda2a184a3418f195fe7cf602967a8d2b94e085b83deef/cffi-2.1.0-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:af5e2915d41fe6c961694d7bfdc8562942638200f3ce2765dfb8b745cf997629", size = 223263, upload-time = "2026-07-06T21:34:08.712Z" }, + { url = "https://files.pythonhosted.org/packages/2c/d8/772b8259bf75749adffb1c546828978381fb516f60cf701f6c83daf60c85/cffi-2.1.0-cp315-cp315-win32.whl", hash = "sha256:0a42c688d19fca6e095a53c6a6e2295a5b050a8b289f109adab02a9e61a25de6", size = 177696, upload-time = "2026-07-06T21:34:26.355Z" }, + { url = "https://files.pythonhosted.org/packages/2f/dd/afa2191fc6d57fedd26e5844a2fe2fcc0bbfa00961bbaa5a41e4921e7cca/cffi-2.1.0-cp315-cp315-win_amd64.whl", hash = "sha256:bccbbb5ee76a61f9d99b5bf3846a51d7fca4b6a732fe46f89295610edaf41853", size = 187914, upload-time = "2026-07-06T21:34:27.58Z" }, + { url = "https://files.pythonhosted.org/packages/05/ef/6cd4f8c671517162379dc79cfae5aea9106bc38abb89628d5c16adf6a838/cffi-2.1.0-cp315-cp315-win_arm64.whl", hash = "sha256:8d35c139744adb3e727cd51b1a18324bbe44b8bd41bf8322bca4d41289f48eda", size = 183004, upload-time = "2026-07-06T21:34:28.905Z" }, + { url = "https://files.pythonhosted.org/packages/11/b6/12fc55092817a5faa26fb8c40c7f9d662e11a46ee248c137aafc42517d92/cffi-2.1.0-cp315-cp315t-macosx_10_15_x86_64.whl", hash = "sha256:f9912624a0c0b834b7520d7769b3644453aabc0a7e1c839da7359f050750e9bc", size = 188378, upload-time = "2026-07-06T21:34:09.926Z" }, + { url = "https://files.pythonhosted.org/packages/8d/2e/cdac88979f295fde5daa69622c7d2111e56e7ceb94f211357fbe452339e4/cffi-2.1.0-cp315-cp315t-macosx_11_0_arm64.whl", hash = "sha256:df92f2aba50eb4d96718b68ef76f2e57a57b54f2fa62333496d16c6d585a85ca", size = 188319, upload-time = "2026-07-06T21:34:11.101Z" }, + { url = "https://files.pythonhosted.org/packages/e0/27/1d0b408497e41a74795af122d7b603c418c5fed0171450f899afd04e594f/cffi-2.1.0-cp315-cp315t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:0520e1f4c35f44e209cbbb421b67eec42e6a157f59444dfb6058874ff3610e5d", size = 223904, upload-time = "2026-07-06T21:34:12.606Z" }, + { url = "https://files.pythonhosted.org/packages/8b/31/e115c985105dd7ffb32444505f18ceb874bb42d992af05d5dced7ecf1980/cffi-2.1.0-cp315-cp315t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:3681e031db29958a7502f5c0c9d6bbc4c36cb20f7b104086fa642d1799631ff8", size = 211554, upload-time = "2026-07-06T21:34:13.987Z" }, + { url = "https://files.pythonhosted.org/packages/5a/67/9e6e09409336d9e515c58367e7cfcf4f89df06ad25252675595a58eb59d5/cffi-2.1.0-cp315-cp315t-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:762f99479dcb369f60ab9017ad4ab97a36a1dd7c1ee5a3b15db0f4b8659120cd", size = 210795, upload-time = "2026-07-06T21:34:15.972Z" }, + { url = "https://files.pythonhosted.org/packages/19/e5/d3cc82a4a0be7902af279c04181ad038449c096734464a5ae1de3e1401bd/cffi-2.1.0-cp315-cp315t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:0611e7ebf90573a535ebdc33ae9da222d037853983e13359f580fab781ca017f", size = 223843, upload-time = "2026-07-06T21:34:17.509Z" }, + { url = "https://files.pythonhosted.org/packages/b9/65/b434abc97ce7cecc2c640fde160507c0ecc7e21544b483ba3325d2e2ea17/cffi-2.1.0-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:86cf8755a791f72c85dc287128cc62d4f24d392e3f1e15837245623f4a33cccc", size = 226773, upload-time = "2026-07-06T21:34:19.05Z" }, + { url = "https://files.pythonhosted.org/packages/b5/9f/d4dc66ca651eb1145a133314cda721abf13cfac3d28c4a0402263ae6ad75/cffi-2.1.0-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:ba00f661f8ba35d075c937174e27c2c421cec3942fd2e0ea3e66996757c0fdd9", size = 225719, upload-time = "2026-07-06T21:34:20.576Z" }, + { url = "https://files.pythonhosted.org/packages/68/5a/e536c528bc8057496c360c0978559a2dc45653f89dd6151078aa7d8fca1a/cffi-2.1.0-cp315-cp315t-win32.whl", hash = "sha256:cb96698e3c7413d906ce83f8ffd245ec1bd94707541f299d0ce4d6b0193e982b", size = 182760, upload-time = "2026-07-06T21:34:22.059Z" }, + { url = "https://files.pythonhosted.org/packages/d3/0b/0ffe8b82d3875bced5fa1e7986a7a46b748262a40ab7f60b475eb9fb1bb3/cffi-2.1.0-cp315-cp315t-win_amd64.whl", hash = "sha256:f146d154428a2523f9cc7936c02353c2459b8f6cf07d3cd1ee1c0a611109c5d5", size = 193769, upload-time = "2026-07-06T21:34:23.589Z" }, + { url = "https://files.pythonhosted.org/packages/a0/17/1073b53b68c9b5ca6914adf5f8bf55aacc2d3be102418c90700160ea8605/cffi-2.1.0-cp315-cp315t-win_arm64.whl", hash = "sha256:cbb7640ce37159548d2147b5b8c241f962143d4c71231431820783f4dc78f210", size = 186405, upload-time = "2026-07-06T21:34:24.857Z" }, +] + [[package]] name = "click" version = "8.4.1" @@ -179,9 +264,15 @@ wheels = [ [[package]] name = "harmonics-cli" -version = "0.6.0" +version = "0.7.0" source = { editable = "." } +[package.optional-dependencies] +audio = [ + { name = "numpy" }, + { name = "sounddevice" }, +] + [package.dev-dependencies] dev = [ { name = "bandit" }, @@ -195,6 +286,11 @@ dev = [ ] [package.metadata] +requires-dist = [ + { name = "numpy", marker = "extra == 'audio'", specifier = ">=1.24" }, + { name = "sounddevice", marker = "extra == 'audio'", specifier = ">=0.4.6" }, +] +provides-extras = ["audio"] [package.metadata.requires-dev] dev = [ @@ -265,6 +361,57 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/79/7b/2c79738432f5c924bef5071f933bcc9efd0473bac3b4aa584a6f7c1c8df8/mypy_extensions-1.1.0-py3-none-any.whl", hash = "sha256:1be4cccdb0f2482337c4743e60421de3a356cd97508abadd57d47403e94f5505", size = 4963, upload-time = "2025-04-22T14:54:22.983Z" }, ] +[[package]] +name = "numpy" +version = "2.5.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/22/fd/89965aa4ac08c74998539fcbf24fa3540f3e15237fbeb6bcf9c908f4aade/numpy-2.5.1.tar.gz", hash = "sha256:a48a113e6afea91f5608793bafa7ef2ad481fefbda87ec5069f483de61cb9fa3", size = 20755553, upload-time = "2026-07-04T17:08:00.933Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/62/7b/14687aa674250e5e546f616f486b0d56d3631cd5b2415739141ce40bdcea/numpy-2.5.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:2c889b56fe48b1018f764b0eec8df59ab654e9148aa91faa12596043500de277", size = 16801574, upload-time = "2026-07-04T17:06:12.423Z" }, + { url = "https://files.pythonhosted.org/packages/e1/19/cc5bb2a3f2913d27d6dbb2c78d25921fabaedc6741d4a5a615a11f3c5bf3/numpy-2.5.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:ab451b59c5643c570974c43aef780703ef1d3b4965d2be07afd530615a9358d1", size = 11772250, upload-time = "2026-07-04T17:06:15.726Z" }, + { url = "https://files.pythonhosted.org/packages/42/77/fdf34a71dd30f54979b18603bee915e0aaf825b07afe79acd60b04b691e2/numpy-2.5.1-cp312-cp312-macosx_14_0_arm64.whl", hash = "sha256:78798bd5b9ad744056af8efa90e3b9ddaa53272a0848a483084a1cc0a13b2dc0", size = 5331516, upload-time = "2026-07-04T17:06:17.913Z" }, + { url = "https://files.pythonhosted.org/packages/ce/e2/eb7efa015b4cce41e2517bf182a7fce0d7d5b9d9ed76a29bfa0f4fe4505c/numpy-2.5.1-cp312-cp312-macosx_14_0_x86_64.whl", hash = "sha256:2ae0ca40bcb22d6ba59c1dfd5446f49940b0f2d821fde133f10dda11f816b84e", size = 6664863, upload-time = "2026-07-04T17:06:20.02Z" }, + { url = "https://files.pythonhosted.org/packages/a9/4b/a2b32dd94ee9ffbeecb28152240042a3949db33b1c834d44090b80e1b3b8/numpy-2.5.1-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:61ac47e772e6b8ea489e1d2f441a34c5c3ac17327e7ce294cbdf535795ad4e75", size = 15167977, upload-time = "2026-07-04T17:06:21.621Z" }, + { url = "https://files.pythonhosted.org/packages/b8/a9/6e73d68500f80773f65f0654ea932019d6694329a0eb0ed0533de38df376/numpy-2.5.1-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:59fda5e192b570217ec2580c96f00e9a7e12ef6866a900eb089b62c1a32545ca", size = 16672469, upload-time = "2026-07-04T17:06:24.064Z" }, + { url = "https://files.pythonhosted.org/packages/24/7d/ad3e59015135f5261c95fd4cafeff159c955febd83a99a1d9250c4233815/numpy-2.5.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:f7119ebff1a9829e9f431a4f9d28e703023bb6b9fe7c8f724467dbfc27c94ab3", size = 16527531, upload-time = "2026-07-04T17:06:26.69Z" }, + { url = "https://files.pythonhosted.org/packages/83/d0/a39b2fbcde9cb17a1dac678f254b33a6336298af9df338824c685425d5e8/numpy-2.5.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:e824c2acf8862052246be5a44c15da1777940c60d010dd2aab897824d9c430f9", size = 18431940, upload-time = "2026-07-04T17:06:29.521Z" }, + { url = "https://files.pythonhosted.org/packages/04/12/cff070947791c1ed425ff76413189adbdc2fbe215eba7ce7fa454a03c7f8/numpy-2.5.1-cp312-cp312-win32.whl", hash = "sha256:08d60c810432eb83360958dea0999ac4cfb94531ea8efcbf0b7f277c2068aeb2", size = 6066764, upload-time = "2026-07-04T17:06:32.571Z" }, + { url = "https://files.pythonhosted.org/packages/65/66/53f31807a48a750f9d748da273bc3fcedd12b27ff1f3e373bfec55ef2dc0/numpy-2.5.1-cp312-cp312-win_amd64.whl", hash = "sha256:f7d60026c0bdb1380e83bfa7a0419c4577ee4b9a08880afcb6dadeb74c649fa2", size = 12430966, upload-time = "2026-07-04T17:06:34.926Z" }, + { url = "https://files.pythonhosted.org/packages/2b/2a/d1a88066b1c14186f5d3c0d18c94f17b064511982bab0578d49ee9d43c29/numpy-2.5.1-cp312-cp312-win_arm64.whl", hash = "sha256:17a25e09640602e10bc8de0e6fa2b3fd68eedd84ba6d7842dc8f32f9ab87bd0b", size = 10350488, upload-time = "2026-07-04T17:06:37.785Z" }, + { url = "https://files.pythonhosted.org/packages/eb/07/ec2a3f0c91761581d4b7104a740791800025983f9a4dc4e73f91a99aeac4/numpy-2.5.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:0bfebd8695f9863592fe744be833a258120b14a9f39da255e8aa8fade2c0ddd1", size = 16796419, upload-time = "2026-07-04T17:06:40.37Z" }, + { url = "https://files.pythonhosted.org/packages/ab/ab/ddb499fc4f8780354395face5b65c7fd107bcd6e1d667a5f07d046956f6f/numpy-2.5.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:30b44a6b53a7ae63c54c089a8726e5563ed302716c5b7ccc85afade40b0e7ff6", size = 11765832, upload-time = "2026-07-04T17:06:42.768Z" }, + { url = "https://files.pythonhosted.org/packages/88/b3/3c28c558a09fc72100c646dac6d2fce8e834c471b0edca01a29996706117/numpy-2.5.1-cp313-cp313-macosx_14_0_arm64.whl", hash = "sha256:6165343f81b56ef8f514f396989e529b61d9dc709b99421b07e9f3e698e2287d", size = 5325143, upload-time = "2026-07-04T17:06:45.466Z" }, + { url = "https://files.pythonhosted.org/packages/5e/0e/ce19b985bb15c596f4f05954e76cccc77c845083b3b8f938a6c68e523128/numpy-2.5.1-cp313-cp313-macosx_14_0_x86_64.whl", hash = "sha256:4939237038ada79308dda3204ac6462df056b5672b2e25db1149cf873668b3e1", size = 6659749, upload-time = "2026-07-04T17:06:47.288Z" }, + { url = "https://files.pythonhosted.org/packages/2e/20/1ee6614d64332a1bba6411f38e68cb79eec1b2459e20a623777c5c5492a2/numpy-2.5.1-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1c6759f538fb912fc46de0a6b1758ccf7b57bc7c7ebebc23974fdac3de8db0cd", size = 15164716, upload-time = "2026-07-04T17:06:49.494Z" }, + { url = "https://files.pythonhosted.org/packages/ed/a7/2bcd3fdbb87804755c35b729bf8709d62025c5f4cfd7d5b2415997097515/numpy-2.5.1-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9726558e8db4a5bf7929a70ae50f63abda4daf0efe810e3bfbab95976f75fc1a", size = 16661440, upload-time = "2026-07-04T17:06:52.061Z" }, + { url = "https://files.pythonhosted.org/packages/fc/d7/a41e3310c886fe457d36e670bbf24fae411aca8a7b6ad92a32afd924077c/numpy-2.5.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:3935f3b419b244a02732676fa5317a9193cc596a4c0646db07e5b421229ac9f7", size = 16526305, upload-time = "2026-07-04T17:06:54.605Z" }, + { url = "https://files.pythonhosted.org/packages/53/75/4333a9a707c1edd3a4e1a0c58eca52c0f31e55089fa80db02b5565b24df7/numpy-2.5.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:dc932a65ded7ce9013d120845a2514dcccb1a67bfc8deb8d37633762951904a6", size = 18423008, upload-time = "2026-07-04T17:06:57.54Z" }, + { url = "https://files.pythonhosted.org/packages/ee/90/e314a32b1c11a2ffe818ddad3a57b50b4b6e1b6c487192eb50cdef0415d0/numpy-2.5.1-cp313-cp313-win32.whl", hash = "sha256:4b4ff1608417eb7a59da7b967bbb798cacfe071d2caf526a24281cd562072ed9", size = 6063885, upload-time = "2026-07-04T17:07:00.14Z" }, + { url = "https://files.pythonhosted.org/packages/10/70/800b3fca480af32df9e8ea9f3d4a0c8feb4b32d7f195d174eabbda4829ad/numpy-2.5.1-cp313-cp313-win_amd64.whl", hash = "sha256:6c3fe51bc6a16453d452997053454f309e8e0ed7b42d6b361ce4ac8c32913d74", size = 12425674, upload-time = "2026-07-04T17:07:02.387Z" }, + { url = "https://files.pythonhosted.org/packages/8b/0b/196350c122f50f6ca56846f2d71efd5e0d24b7b2e07355e019b2e2c7a11e/numpy-2.5.1-cp313-cp313-win_arm64.whl", hash = "sha256:f7feb014281029e628ba2d5a007407443b06e418b6fe451d1e2adcbc8eba0107", size = 10350256, upload-time = "2026-07-04T17:07:04.878Z" }, + { url = "https://files.pythonhosted.org/packages/db/f4/731b6085a83faf6ca843394cbd5e217280c214399f7e8b21b9f552af0ae2/numpy-2.5.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:7c786fe9a5bbe360022e584c5a34cf6b54265c71bd7ec8ac3d8fec38968071f8", size = 16795063, upload-time = "2026-07-04T17:07:07.374Z" }, + { url = "https://files.pythonhosted.org/packages/bf/64/0e215f2048dd11a55bb989ed41b3585ef57452404e638d703a211a3e4157/numpy-2.5.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:32985c896d897419ef8da6917872d80b78ad0ea26d85b23245c7366ffde76d75", size = 11776652, upload-time = "2026-07-04T17:07:09.907Z" }, + { url = "https://files.pythonhosted.org/packages/b5/59/2b844c7a6e9deff69b404a66221e1542937734f65d5e6e39411876053862/numpy-2.5.1-cp314-cp314-macosx_14_0_arm64.whl", hash = "sha256:efd736408cc97c79b9e6917338dfc8f06013b2274f992e96b1d9a81a71e2a2c2", size = 5335944, upload-time = "2026-07-04T17:07:12.227Z" }, + { url = "https://files.pythonhosted.org/packages/86/51/9bf7cb2cabcebc9e017e4ec7e6322b378317a542c08b4cb68479c1efc716/numpy-2.5.1-cp314-cp314-macosx_14_0_x86_64.whl", hash = "sha256:ab84dc6b074fa881cae55bea94cc4f68e285181ba7f32497bf7dee6b1496165b", size = 6656266, upload-time = "2026-07-04T17:07:14.368Z" }, + { url = "https://files.pythonhosted.org/packages/83/3e/fb7615b211b82a32f44d5180a6d421b61f84d4fadd578b48ba4ac34e189f/numpy-2.5.1-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:caf3e317d33d60c37986b452613f4ab51246d0691350c03d0cb4a898627f4a95", size = 15179720, upload-time = "2026-07-04T17:07:16.272Z" }, + { url = "https://files.pythonhosted.org/packages/41/5f/0f992cb24560673496c5d68de61913b57166ce530ffda07c1f280e0cc464/numpy-2.5.1-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:54ad769f17bc2d833b620851989f62054fb9ab93c969d9e1dc3c8e3d56beea21", size = 16664835, upload-time = "2026-07-04T17:07:19.021Z" }, + { url = "https://files.pythonhosted.org/packages/a2/2f/97d6475ee91afe2587797d09446f9d3e475ad4cb681662d824809327b75a/numpy-2.5.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:c12afb53450fa976d4c681c50a7423729a4c51c0465ed9f32b8a9cabbc472373", size = 16539135, upload-time = "2026-07-04T17:07:22.015Z" }, + { url = "https://files.pythonhosted.org/packages/c4/5b/4db81e4ba0be7e2776b1de68c82aa862c7f8ec27e1b4927d4ae075e20678/numpy-2.5.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:e8c11c405efc5ff6816d5983c96cdfa215bab3428961243af3ff59b228490438", size = 18426684, upload-time = "2026-07-04T17:07:24.941Z" }, + { url = "https://files.pythonhosted.org/packages/1f/64/c0ba2d90724d450279a7df8f32057241070250a26a7e2b5337d77347f481/numpy-2.5.1-cp314-cp314-win32.whl", hash = "sha256:f2479a47f8d5932d1718168a681ad6e536a9df484c83cfcf9de365e164537ace", size = 6116103, upload-time = "2026-07-04T17:07:27.622Z" }, + { url = "https://files.pythonhosted.org/packages/c1/1a/837f9ed7405adcd7a40538792eb169eddd8fa5630c16a1ef49dae71a30f4/numpy-2.5.1-cp314-cp314-win_amd64.whl", hash = "sha256:24d0eb82c0541d3415a33425db64ae439dffccd7b4dbcb30e7c35120205c506a", size = 12562177, upload-time = "2026-07-04T17:07:29.887Z" }, + { url = "https://files.pythonhosted.org/packages/22/ed/49707938b6dd0a78a9178dd93227dc89e4c11af47f5c798d70366e8d0483/numpy-2.5.1-cp314-cp314-win_arm64.whl", hash = "sha256:5a4c988b38d261deeeaad9954e3deb091ad905c94e8bb6708654ef1d97f286b0", size = 10627739, upload-time = "2026-07-04T17:07:32.568Z" }, + { url = "https://files.pythonhosted.org/packages/a6/c7/bb4b882cfe7f299cbc8b66e42e7dd78cf9d14e40f9469fc5e3db7e15b3bd/numpy-2.5.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:a33276be12fa045805f477f22482088b66bb758ffbe89a9d21457de863a32e22", size = 11894709, upload-time = "2026-07-04T17:07:34.941Z" }, + { url = "https://files.pythonhosted.org/packages/40/3f/5af7f4a7f6224aef48017aa82bb6174c7a659d724be0c75017b7e64a55b4/numpy-2.5.1-cp314-cp314t-macosx_14_0_arm64.whl", hash = "sha256:f089d7b00756190aacf1f5d34bdf38c3c430ac82b4f868f8cede73380460fce7", size = 5453810, upload-time = "2026-07-04T17:07:37.495Z" }, + { url = "https://files.pythonhosted.org/packages/20/c9/3474309bc94d634d3f9c3eddf03250ecb8c22cd948ef16fef69a77cc5d7b/numpy-2.5.1-cp314-cp314t-macosx_14_0_x86_64.whl", hash = "sha256:09e9bfd8d2cf479c7d174804fb3811c53a8e9f20a37444008606b57d6b7a826d", size = 6761189, upload-time = "2026-07-04T17:07:39.563Z" }, + { url = "https://files.pythonhosted.org/packages/90/8a/558ae39fdd55d7e7f7fef9a84a6e964ac6b23edbd2a07e52bb084500507d/numpy-2.5.1-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e68d8dd1e7eba712948f2053a29ec86917bc70ba1358df869d9f06649ef9cf09", size = 15225039, upload-time = "2026-07-04T17:07:41.682Z" }, + { url = "https://files.pythonhosted.org/packages/63/27/ca7392b2d030277bdf0273e7d23255b3ee57d57a7c170a6f4fb3981e1e5d/numpy-2.5.1-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:99d5095fa265a0c4152e7bb12759e14381ef5496152f1ce58f44bdf55c44beb4", size = 16701306, upload-time = "2026-07-04T17:07:44.611Z" }, + { url = "https://files.pythonhosted.org/packages/02/42/03d53ae7996c44d4374a8262e9dc41671fd56cbb98f7d47ef85cf5da4c6b/numpy-2.5.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:ab87a91b3cc3382b8956095bd8f95e00cf679bb81554339be1a2ba404a1473c1", size = 16589955, upload-time = "2026-07-04T17:07:47.694Z" }, + { url = "https://files.pythonhosted.org/packages/7b/15/6c1784ae469640e65db111e9a34b3d0f14d91e8a38b9ce34810ced370dbb/numpy-2.5.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:224ca51130ef7da85bea2191625181cb4f337f9cb64b471f10c1a12aa8b60077", size = 18464252, upload-time = "2026-07-04T17:07:50.684Z" }, + { url = "https://files.pythonhosted.org/packages/94/a8/f98e50356cf167df656c526c2dfeec2d7dde182f2a3da4b458a5938e2776/numpy-2.5.1-cp314-cp314t-win32.whl", hash = "sha256:6eab239876581b2b3c5a242281b6007bbdbcd1c7085d7709bb57c5929b11e6bf", size = 6263298, upload-time = "2026-07-04T17:07:53.445Z" }, + { url = "https://files.pythonhosted.org/packages/72/ac/96ae880cdecad0b3275d9359fcec72667b49a4863c9f12942e43679dda02/numpy-2.5.1-cp314-cp314t-win_amd64.whl", hash = "sha256:83ce9c80d5b521b0d77ddcbe5447c218d247929b6cc056ca5351342accfff0af", size = 12748623, upload-time = "2026-07-04T17:07:55.384Z" }, + { url = "https://files.pythonhosted.org/packages/a1/5a/4d2b1601df3602dba7a14f3348ba9bfe94a18adb428e693df6154c293831/numpy-2.5.1-cp314-cp314t-win_arm64.whl", hash = "sha256:5a6db61f9aaa57e369905c67d852045d3c4f7126405b29d09b19dec118e9c9cb", size = 10697674, upload-time = "2026-07-04T17:07:58.506Z" }, +] + [[package]] name = "packaging" version = "26.2" @@ -310,6 +457,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/d7/27/a58ddaf8c588a3ef080db9d0b7e0b97215cee3a45df74f3a94dbbf5c893a/pycodestyle-2.14.0-py2.py3-none-any.whl", hash = "sha256:dd6bf7cb4ee77f8e016f9c8e74a35ddd9f67e1d5fd4184d86c3b98e07099f42d", size = 31594, upload-time = "2025-06-20T18:49:47.491Z" }, ] +[[package]] +name = "pycparser" +version = "3.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/1b/7d/92392ff7815c21062bea51aa7b87d45576f649f16458d78b7cf94b9ab2e6/pycparser-3.0.tar.gz", hash = "sha256:600f49d217304a5902ac3c37e1281c9fe94e4d0489de643a9504c5cdfdfc6b29", size = 103492, upload-time = "2026-01-21T14:26:51.89Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0c/c3/44f3fbbfa403ea2a7c779186dc20772604442dde72947e7d01069cbe98e3/pycparser-3.0-py3-none-any.whl", hash = "sha256:b727414169a36b7d524c1c3e31839a521725078d7b2ff038656844266160a992", size = 48172, upload-time = "2026-01-21T14:26:50.693Z" }, +] + [[package]] name = "pyflakes" version = "3.4.0" @@ -459,6 +615,22 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/82/3b/64d4899d73f91ba49a8c18a8ff3f0ea8f1c1d75481760df8c68ef5235bf5/rich-15.0.0-py3-none-any.whl", hash = "sha256:33bd4ef74232fb73fe9279a257718407f169c09b78a87ad3d296f548e27de0bb", size = 310654, upload-time = "2026-04-12T08:24:02.83Z" }, ] +[[package]] +name = "sounddevice" +version = "0.5.5" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "cffi" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/2a/f9/2592608737553638fca98e21e54bfec40bf577bb98a61b2770c912aab25e/sounddevice-0.5.5.tar.gz", hash = "sha256:22487b65198cb5bf2208755105b524f78ad173e5ab6b445bdab1c989f6698df3", size = 143191, upload-time = "2026-01-23T18:36:43.529Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1e/0a/478e441fd049002cf308520c0d62dd8333e7c6cc8d997f0dda07b9fbcc46/sounddevice-0.5.5-py3-none-any.whl", hash = "sha256:30ff99f6c107f49d25ad16a45cacd8d91c25a1bcdd3e81a206b921a3a6405b1f", size = 32807, upload-time = "2026-01-23T18:36:35.649Z" }, + { url = "https://files.pythonhosted.org/packages/56/f9/c037c35f6d0b6bc3bc7bfb314f1d6f1f9a341328ef47cd63fc4f850a7b27/sounddevice-0.5.5-py3-none-macosx_10_6_x86_64.macosx_10_6_universal2.whl", hash = "sha256:05eb9fd6c54c38d67741441c19164c0dae8ce80453af2d8c4ad2e7823d15b722", size = 108557, upload-time = "2026-01-23T18:36:37.41Z" }, + { url = "https://files.pythonhosted.org/packages/88/a1/d19dd9889cd4bce2e233c4fac007cd8daaf5b9fe6e6a5d432cf17be0b807/sounddevice-0.5.5-py3-none-win32.whl", hash = "sha256:1234cc9b4c9df97b6cbe748146ae0ec64dd7d6e44739e8e42eaa5b595313a103", size = 317765, upload-time = "2026-01-23T18:36:39.047Z" }, + { url = "https://files.pythonhosted.org/packages/c3/0e/002ed7c4c1c2ab69031f78989d3b789fee3a7fba9e586eb2b81688bf4961/sounddevice-0.5.5-py3-none-win_amd64.whl", hash = "sha256:cfc6b2c49fb7f555591c78cb8ecf48d6a637fd5b6e1db5fec6ed9365d64b3519", size = 365324, upload-time = "2026-01-23T18:36:40.496Z" }, + { url = "https://files.pythonhosted.org/packages/4e/39/a61d4b83a7746b70d23d9173be688c0c6bfc7173772344b7442c2c155497/sounddevice-0.5.5-py3-none-win_arm64.whl", hash = "sha256:3861901ddd8230d2e0e8ae62ac320cdd4c688d81df89da036dcb812f757bb3e6", size = 317115, upload-time = "2026-01-23T18:36:42.235Z" }, +] + [[package]] name = "stevedore" version = "5.8.0" From df667e0c46073f3f13c93e1ec5e77624578e1f39 Mon Sep 17 00:00:00 2001 From: Ori Nachum Date: Wed, 8 Jul 2026 16:44:51 +0300 Subject: [PATCH 2/2] fix(audio): prefer sounddevice backend; extract shared device helpers (Qodo review) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Addresses two Qodo review comments on PR #6: - #3544349308 (Bug/Correctness — "Device ignored with simpleaudio"): play()/_resolve_backend() tried simpleaudio FIRST and returned early, so --device / $HARMONICS_AUDIO_DEVICE and the pipewire/pulse auto-preference (sounddevice-only) were silently ignored whenever simpleaudio was installed — contradicting the --play docs. Reorder to prefer sounddevice (the only backend that honors device selection); simpleaudio is now a fallback used only when sounddevice is unavailable. New regression tests assert sounddevice wins when both backends are present and that --device is honored. - #3544349315 (Maintainability — "Private helper coupling"): demo/play.py imported underscore-prefixed helpers from audio/synth.py. Extract select_output_device / output_device_listing / device_playback_error into a new internal module harmonics/audio/_playback.py; synth.py and demo/play.py both import from there. No cross-module private coupling. Docstrings, help text, and CHANGELOG updated to the new backend order; fallback-path tests made deterministic (patch sounddevice=None) and renamed for honesty. Full suite 2357 passed; black/isort/flake8/bandit/teken clean. Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_01VMUvDkSHhthqU3c5HrhQuj --- CHANGELOG.md | 5 + harmonics/audio/_playback.py | 98 ++++++++++++++++++++ harmonics/audio/synth.py | 157 +++++++++----------------------- harmonics/cli/_commands/play.py | 5 +- harmonics/cli/_commands/say.py | 2 +- harmonics/demo/play.py | 86 ++++++++--------- tests/test_audio.py | 45 ++++++++- tests/test_demo_play.py | 44 ++++++++- 8 files changed, 277 insertions(+), 165 deletions(-) create mode 100644 harmonics/audio/_playback.py diff --git a/CHANGELOG.md b/CHANGELOG.md index a5ede68..2ec1cb6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -25,6 +25,11 @@ adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). ### 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 diff --git a/harmonics/audio/_playback.py b/harmonics/audio/_playback.py new file mode 100644 index 0000000..66ef747 --- /dev/null +++ b/harmonics/audio/_playback.py @@ -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, + ) diff --git a/harmonics/audio/synth.py b/harmonics/audio/synth.py index d565980..1cabfce 100644 --- a/harmonics/audio/synth.py +++ b/harmonics/audio/synth.py @@ -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 -------------------------------------------------- @@ -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 @@ -440,87 +443,6 @@ def write_wav( Path(path).write_bytes(data) -# --- live playback: output-device selection + friendly device errors ---------- - -#: 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 :func:`play` should open on ``sounddevice``. - - 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 :func:`play` 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``.""" - 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, - ) - - def play( seq: Sequence[NoteEvent], *, @@ -530,48 +452,32 @@ def play( ) -> 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. + 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:`_select_output_device`. (``simpleaudio`` has no device-selection - API, so ``device`` is ignored on that backend.) + :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:`_device_playback_error`. + 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() - # simpleaudio has no device-selection API, so ``device`` applies to the - # sounddevice backend only; a device failure here 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 - try: import sounddevice # type: ignore[import-not-found] except ImportError: @@ -595,7 +501,7 @@ def play( samples.frombytes(frames) if sys.byteorder == "big": samples.byteswap() - target = _select_output_device(sounddevice, device) + 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. @@ -604,7 +510,28 @@ def play( 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 + 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( diff --git a/harmonics/cli/_commands/play.py b/harmonics/cli/_commands/play.py index 021347d..2750071 100644 --- a/harmonics/cli/_commands/play.py +++ b/harmonics/cli/_commands/play.py @@ -27,10 +27,11 @@ and either raises a structured :class:`~harmonics.cli._errors.CliError` (not a bare traceback) if the write itself fails, e.g. a missing parent directory. ``--play`` renders the gesture and plays it via a live backend -(:func:`harmonics.audio.play`, tried in order: ``simpleaudio``, then -``sounddevice``) if either is installed; with neither installed it fails +(:func:`harmonics.audio.play`, tried in order: ``sounddevice``, then +``simpleaudio``) if either is installed; with neither installed it fails loudly with a friendly ``CliError`` hint rather than silently no-op'ing — use ``--wav`` instead when you just want a file and no device at all. +``--device`` picks which output device ``--play`` opens (see its flag help). ``--articulation`` (default ``"smooth"``) selects HOW ``--wav``/``--play`` synthesize the gesture — a continuous gliding voice by default, or the diff --git a/harmonics/cli/_commands/say.py b/harmonics/cli/_commands/say.py index bf97048..53ebf47 100644 --- a/harmonics/cli/_commands/say.py +++ b/harmonics/cli/_commands/say.py @@ -76,7 +76,7 @@ :class:`~harmonics.cli._errors.CliError` (not a bare traceback) if the write itself fails, e.g. a missing parent directory. ``--play`` renders the utterance and plays it via a live backend (:func:`harmonics.audio.play`, -tried in order: ``simpleaudio``, then ``sounddevice``) if either is +tried in order: ``sounddevice``, then ``simpleaudio``) if either is installed; with neither installed it fails loudly with a friendly ``CliError`` hint rather than silently no-op'ing — use ``--wav`` instead when you just want a file and no device at all. diff --git a/harmonics/demo/play.py b/harmonics/demo/play.py index ebe4627..f8211d5 100644 --- a/harmonics/demo/play.py +++ b/harmonics/demo/play.py @@ -7,13 +7,13 @@ showcase()`` produced, so each clip's curated articulation is preserved. Mirrors ``harmonics.audio.synth.play``'s backend-selection and error- -conversion logic (see that module's docstring) but resolves the backend -**once** for the whole sequence rather than once per clip, and operates on -already-rendered WAV bytes instead of a :class:`~harmonics.notes.NoteEvent` -sequence: +conversion logic — it shares the very same device helpers from +:mod:`harmonics.audio._playback` — but resolves the backend **once** for the +whole sequence rather than once per clip, and operates on already-rendered +WAV bytes instead of a :class:`~harmonics.notes.NoteEvent` sequence: -* the lazy, in-function backend import (tried in order: ``simpleaudio``, - then ``sounddevice``) means importing this module never requires a sound +* the lazy, in-function backend import (tried in order: ``sounddevice``, + then ``simpleaudio``) means importing this module never requires a sound stack — no third-party import happens at module import time; * when neither library is importable, :func:`play_clips` raises the project's structured :class:`~harmonics.cli._errors.CliError` (exit code @@ -30,7 +30,7 @@ from array import array from typing import Callable -from harmonics.audio.synth import _device_playback_error, _select_output_device +from harmonics.audio._playback import device_playback_error, select_output_device from harmonics.cli._errors import EXIT_ENV_ERROR, CliError from harmonics.demo.core import Clip @@ -42,48 +42,28 @@ def _resolve_backend(device: int | str | None = None) -> Callable[[bytes], None]: """Resolve a live playback backend, lazily. - Tries ``simpleaudio`` first, then ``sounddevice`` — both imported + Tries ``sounddevice`` first, then ``simpleaudio`` — both imported LAZILY, right here, so importing :mod:`harmonics.demo.play` never - requires either to be installed. Returns a callable that plays one - clip's WAV bytes using whichever backend was found; a device failure - inside that callable is converted to :class:`CliError` (never a bare - ``PortAudioError``). ``device`` selects the ``sounddevice`` output device - (index or name substring; ``None`` prefers a resampling sound-server - device — see :func:`harmonics.audio.synth._select_output_device`), and is - resolved ONCE here alongside the backend. Raises :class:`CliError` + requires either to be installed. ``sounddevice`` is preferred because it + is the only backend that honors ``device`` (``simpleaudio`` is a lighter + fallback used only when ``sounddevice`` is unavailable). Returns a + callable that plays one clip's WAV bytes using whichever backend was + found; a device failure inside that callable is converted to + :class:`CliError` (never a bare ``PortAudioError``). ``device`` selects + the ``sounddevice`` output device (index or name substring; ``None`` + prefers a resampling sound-server device — see + :func:`harmonics.audio._playback.select_output_device`), and is resolved + ONCE here alongside the backend. Raises :class:`CliError` (:data:`EXIT_ENV_ERROR`) with a remediation hint if neither library is importable — never a bare ``ImportError``, never a silent no-op. """ - try: - import simpleaudio # type: ignore[import-not-found] - except ImportError: - simpleaudio = None # type: ignore[assignment] - - if simpleaudio is not None: - - def _play_with_simpleaudio(wav_bytes: bytes) -> None: - with wave.open(io.BytesIO(wav_bytes), "rb") as wf: - frames = wf.readframes(wf.getnframes()) - nchannels = wf.getnchannels() - sampwidth = wf.getsampwidth() - framerate = wf.getframerate() - # simpleaudio has no device-selection API, so ``device`` applies to - # the sounddevice backend only; a device failure is still wrapped. - try: - play_obj = simpleaudio.play_buffer(frames, nchannels, sampwidth, framerate) - 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 _play_with_simpleaudio - try: import sounddevice # type: ignore[import-not-found] except ImportError: sounddevice = None # type: ignore[assignment] if sounddevice is not None: - target = _select_output_device(sounddevice, device) + target = select_output_device(sounddevice, device) play_kwargs = {} if target is None else {"device": target} def _play_with_sounddevice(wav_bytes: bytes) -> None: @@ -103,10 +83,34 @@ def _play_with_sounddevice(wav_bytes: bytes) -> None: 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 + raise device_playback_error(sounddevice, exc, framerate) from exc return _play_with_sounddevice + try: + import simpleaudio # type: ignore[import-not-found] + except ImportError: + simpleaudio = None # type: ignore[assignment] + + if simpleaudio is not None: + + def _play_with_simpleaudio(wav_bytes: bytes) -> None: + with wave.open(io.BytesIO(wav_bytes), "rb") as wf: + frames = wf.readframes(wf.getnframes()) + nchannels = 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 + # wrapped into the structured CliError contract. + try: + play_obj = simpleaudio.play_buffer(frames, nchannels, sampwidth, framerate) + 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 _play_with_simpleaudio + raise CliError( code=EXIT_ENV_ERROR, message="no audio playback backend available", @@ -125,7 +129,7 @@ def play_clips( Plays ``clip.wav`` for every clip in ``clips``, in order, pausing ``gap_seconds`` between consecutive clips (no trailing pause after the - last one). The backend (``simpleaudio`` then ``sounddevice``) and the + last one). The backend (``sounddevice`` then ``simpleaudio``) and the output ``device`` are resolved once, lazily, before any clip plays — see :func:`_resolve_backend` — so a missing backend raises :class:`CliError` immediately, before any sound is produced. diff --git a/tests/test_audio.py b/tests/test_audio.py index 88bb21b..086e8f0 100644 --- a/tests/test_audio.py +++ b/tests/test_audio.py @@ -157,6 +157,7 @@ def test_play_threads_articulation_through_to_render(monkeypatch: pytest.MonkeyP fake_module = type(sys)("simpleaudio") fake_module.WaveObject = _FakeWaveObject # type: ignore[attr-defined] monkeypatch.setitem(sys.modules, "simpleaudio", fake_module) + monkeypatch.setitem(sys.modules, "sounddevice", None) seq = _seq() play(seq, articulation="alien") @@ -250,10 +251,13 @@ def play(self) -> _FakePlayObj: return obj -def test_play_uses_simpleaudio_when_available(monkeypatch: pytest.MonkeyPatch) -> None: +def test_play_uses_simpleaudio_when_sounddevice_absent(monkeypatch: pytest.MonkeyPatch) -> None: + """``simpleaudio`` is only the fallback backend: it is used here because + ``sounddevice`` is unavailable, not because it is preferred.""" fake_module = type(sys)("simpleaudio") fake_module.WaveObject = _FakeWaveObject # type: ignore[attr-defined] monkeypatch.setitem(sys.modules, "simpleaudio", fake_module) + monkeypatch.setitem(sys.modules, "sounddevice", None) seq = _seq() play(seq) @@ -284,9 +288,11 @@ def wait(self) -> None: self.waited = True -def test_play_falls_back_to_sounddevice_when_simpleaudio_absent( +def test_play_uses_sounddevice_when_available( monkeypatch: pytest.MonkeyPatch, ) -> None: + """``sounddevice`` is the preferred backend now: it is used whenever it is + importable, even with no ``simpleaudio`` fallback in play.""" monkeypatch.setitem(sys.modules, "simpleaudio", None) fake_sd = _FakeSoundDevice() monkeypatch.setitem(sys.modules, "sounddevice", fake_sd) @@ -301,6 +307,38 @@ def test_play_falls_back_to_sounddevice_when_simpleaudio_absent( assert fake_sd.waited is True +def test_play_prefers_sounddevice_over_simpleaudio_when_both_present( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Regression for the reorder: when BOTH backends are importable, + ``sounddevice`` must be chosen (and ``--device`` honored) rather than + ``simpleaudio`` silently winning and ignoring ``device``.""" + simpleaudio_calls: list[tuple] = [] + + class _FakeWaveObjectRecording: + def __init__( + self, audio_data: bytes, num_channels: int, bytes_per_sample: int, sample_rate: int + ) -> None: + simpleaudio_calls.append((audio_data, num_channels, bytes_per_sample, sample_rate)) + + def play(self) -> _FakePlayObj: + return _FakePlayObj() + + fake_simpleaudio = type(sys)("simpleaudio") + fake_simpleaudio.WaveObject = _FakeWaveObjectRecording # type: ignore[attr-defined] + monkeypatch.setitem(sys.modules, "simpleaudio", fake_simpleaudio) + + fake_sd = _FakeSoundDeviceWithDevices([{"name": "pipewire", "max_output_channels": 64}]) + monkeypatch.setitem(sys.modules, "sounddevice", fake_sd) + + play(_seq(), device="pipewire") + + assert fake_sd.played is not None + assert fake_sd.device == "pipewire" + assert fake_sd.waited is True + assert simpleaudio_calls == [] + + # --- play(): sounddevice path decodes correctly on a big-endian host -------- @@ -352,7 +390,7 @@ def test_play_sounddevice_byteswaps_samples_on_big_endian_host( class _FakeSoundDeviceWithDevices: """Fake sounddevice exposing ``query_devices()`` so - :func:`~harmonics.audio.synth._select_output_device`'s auto-selection + :func:`~harmonics.audio._playback.select_output_device`'s auto-selection logic can be exercised, and a ``play()`` that records the resolved ``device=`` kwarg (unlike :class:`_FakeSoundDevice` above, which has no device-selection support at all).""" @@ -497,6 +535,7 @@ def test_play_simpleaudio_failure_raises_friendly_cli_error( fake_module = type(sys)("simpleaudio") fake_module.WaveObject = _FakeWaveObjectRaisingOnPlay # type: ignore[attr-defined] monkeypatch.setitem(sys.modules, "simpleaudio", fake_module) + monkeypatch.setitem(sys.modules, "sounddevice", None) with pytest.raises(CliError) as exc: play(_seq()) diff --git a/tests/test_demo_play.py b/tests/test_demo_play.py index 715fc7f..edc4186 100644 --- a/tests/test_demo_play.py +++ b/tests/test_demo_play.py @@ -111,7 +111,11 @@ def wait_done(self) -> None: self.waited = True -def test_play_clips_uses_simpleaudio_when_available(monkeypatch: pytest.MonkeyPatch) -> None: +def test_play_clips_uses_simpleaudio_when_sounddevice_absent( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """``simpleaudio`` is only the fallback backend: it is used here because + ``sounddevice`` is unavailable, not because it is preferred.""" calls: list[tuple] = [] def fake_play_buffer(frames, nchannels, sampwidth, framerate): # noqa: ANN001 @@ -122,6 +126,7 @@ def fake_play_buffer(frames, nchannels, sampwidth, framerate): # noqa: ANN001 fake_module = type(sys)("simpleaudio") fake_module.play_buffer = fake_play_buffer # type: ignore[attr-defined] monkeypatch.setitem(sys.modules, "simpleaudio", fake_module) + monkeypatch.setitem(sys.modules, "sounddevice", None) from harmonics.demo.play import play_clips @@ -149,9 +154,11 @@ def wait(self) -> None: self.waited = True -def test_play_clips_falls_back_to_sounddevice_when_simpleaudio_absent( +def test_play_clips_uses_sounddevice_when_available( monkeypatch: pytest.MonkeyPatch, ) -> None: + """``sounddevice`` is the preferred backend now: it is used whenever it is + importable, even with no ``simpleaudio`` fallback in play.""" monkeypatch.setitem(sys.modules, "simpleaudio", None) fake_sd = _FakeSoundDevice() monkeypatch.setitem(sys.modules, "sounddevice", fake_sd) @@ -168,6 +175,35 @@ def test_play_clips_falls_back_to_sounddevice_when_simpleaudio_absent( assert fake_sd.waited is True +def test_play_clips_prefers_sounddevice_over_simpleaudio_when_both_present( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Regression for the reorder: when BOTH backends are importable, + ``sounddevice`` must be chosen (and ``--device`` honored) rather than + ``simpleaudio`` silently winning and ignoring ``device``.""" + simpleaudio_calls: list[tuple] = [] + + def fake_play_buffer(frames, nchannels, sampwidth, framerate): # noqa: ANN001 + simpleaudio_calls.append((frames, nchannels, sampwidth, framerate)) + return _FakePlayObj() + + fake_simpleaudio = type(sys)("simpleaudio") + fake_simpleaudio.play_buffer = fake_play_buffer # type: ignore[attr-defined] + monkeypatch.setitem(sys.modules, "simpleaudio", fake_simpleaudio) + + fake_sd = _FakeSoundDeviceWithDevices([{"name": "pipewire", "max_output_channels": 64}]) + monkeypatch.setitem(sys.modules, "sounddevice", fake_sd) + + from harmonics.demo.play import play_clips + + play_clips([_make_clip("one")], gap_seconds=0.0, device="pipewire") + + assert fake_sd.played is not None + assert fake_sd.device == "pipewire" + assert fake_sd.waited is True + assert simpleaudio_calls == [] + + # --- play_clips(): sequencing (multiple clips, gap between them) ------------ @@ -181,6 +217,7 @@ def fake_play_buffer(frames, nchannels, sampwidth, framerate): # noqa: ANN001 fake_module = type(sys)("simpleaudio") fake_module.play_buffer = fake_play_buffer # type: ignore[attr-defined] monkeypatch.setitem(sys.modules, "simpleaudio", fake_module) + monkeypatch.setitem(sys.modules, "sounddevice", None) from harmonics.demo.play import play_clips @@ -200,6 +237,7 @@ def test_play_clips_sleeps_between_but_not_after_last_clip( fake_module = type(sys)("simpleaudio") fake_module.play_buffer = lambda *a, **k: _FakePlayObj() # type: ignore[attr-defined] monkeypatch.setitem(sys.modules, "simpleaudio", fake_module) + monkeypatch.setitem(sys.modules, "sounddevice", None) sleeps: list[float] = [] monkeypatch.setattr(time, "sleep", lambda s: sleeps.append(s)) @@ -227,7 +265,7 @@ def test_play_clips_default_gap_is_positive() -> None: class _FakeSoundDeviceWithDevices: """Fake sounddevice exposing ``query_devices()`` so - :func:`~harmonics.audio.synth._select_output_device`'s auto-selection + :func:`~harmonics.audio._playback.select_output_device`'s auto-selection logic can be exercised, and a ``play()`` that records the resolved ``device=`` kwarg (unlike :class:`_FakeSoundDevice` above, which has no device-selection support at all)."""