Skip to content

feat: installable live audio playback — [audio] extra, --device selection, friendly device errors#6

Merged
OriNachum merged 2 commits into
mainfrom
feat/live-audio-playback
Jul 8, 2026
Merged

feat: installable live audio playback — [audio] extra, --device selection, friendly device errors#6
OriNachum merged 2 commits into
mainfrom
feat/live-audio-playback

Conversation

@OriNachum

Copy link
Copy Markdown
Contributor

What & why

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".

Changes

Installable playback (opt-in extra)

  • pyproject.toml: [project.optional-dependencies]
    audio = ["sounddevice>=0.4.6", "numpy>=1.24"]. Install with
    uv tool install 'harmonics-cli[audio]'.
  • sounddevice over simpleaudio: it ships cp312 wheels bundling PortAudio
    (simpleaudio has none / needs a C build); numpy is required by
    sounddevice.play().
  • Core dependencies stay empty — the extra is opt-in, so importing the
    package still requires no sound stack. This preserves the CLAUDE.md
    "runtime deps stay empty / offline-testable" invariant and the
    no-backend tests, which assume the real env has no backend.

Device selection

  • --device NAME|INDEX + $HARMONICS_AUDIO_DEVICE (flag overrides env) on
    play / say / demo.
  • With nothing specified, prefer a resampling sound-server device (pipewire,
    then pulse) over the raw default, so --play works when the default sink
    is fixed-rate.

Friendly device errors

  • A live-device failure (PortAudioError, bad sample rate, busy device) now
    surfaces as an exit-2 CliError that names the failure, lists available
    output devices, and points at --device/--wav — not "file a bug".

Docs & tests

  • README live-playback section, explain catalog entries (play/say/demo),
    CLAUDE.md invariant note, CHANGELOG, version 0.6.00.7.0.
  • 17 new tests (auto-select, explicit device, digit-string→int, friendly
    device error, env/flag precedence).

Verification

  • pytest -n auto2355 passed
  • black / isort / flake8 / bandit / markdownlint → clean
  • teken cli doctor . --strict → rc=0
  • Manually verified on a multi-sink host: auto-select routes to pipewire and
    plays; forcing the 16 kHz sink yields the friendly exit-2 error with the
    device listing.

🤖 Generated with Claude Code

https://claude.ai/code/session_01VMUvDkSHhthqU3c5HrhQuj

  • harmonics-cli (Claude)

…tion, friendly device errors

`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) <[email protected]>
Claude-Session: https://claude.ai/code/session_01VMUvDkSHhthqU3c5HrhQuj
@OriNachum

Copy link
Copy Markdown
Contributor Author

/agentic_review

@qodo-code-review

Copy link
Copy Markdown

PR Summary by Qodo

Installable live audio playback extra with device selection and friendly errors

✨ Enhancement 🧪 Tests 📝 Documentation ⚙️ Configuration changes 🕐 40+ Minutes

Grey Divider

AI Description

• Add opt-in audio extra (sounddevice+numpy) so --play works without core deps.
• Add --device / $HARMONICS_AUDIO_DEVICE with pipewire/pulse auto-preference for playback.
• Convert backend/device failures into exit-2 errors with actionable remediation and device list.
Diagram

graph TD
  U(["User"]) --> CLI["CLI verbs (play/say/demo)"] --> RES["Resolve device (flag/env)"] --> PLAY["audio.synth.play(device=...)"] --> SD{{"sounddevice (PortAudio)"}}
  PLAY --> SA{{"simpleaudio"}}
  PLAY --> ERR["CliError (exit 2) w/ hint + device list"]
  SD --> ERR
  SA --> ERR
  subgraph Legend
    direction LR
    _usr(["User"]) ~~~ _mod["Module"] ~~~ _ext{{"External backend"}}
  end
Loading
High-Level Assessment

The following are alternative approaches to this PR:

1. Choose device via PortAudio default host API (instead of name matching)
  • ➕ More robust than substring matching (pipewire/pulse) across locales/custom names
  • ➕ Could directly select the system’s default output for a specific host API
  • ➖ More PortAudio/sounddevice-specific logic; harder to keep backend-agnostic
  • ➖ Still doesn’t help simpleaudio, and may be less obvious to users than --device pipewire
2. Add a `--list-devices` helper command/flag
  • ➕ Makes remediation flow explicit: users can discover indices/names without triggering errors
  • ➕ Reduces need to embed long device listings in error messages
  • ➖ Adds CLI surface area and documentation burden
  • ➖ Doesn’t replace the need for friendly errors when playback fails
3. Standardize on sounddevice only (drop simpleaudio fallback)
  • ➕ One backend to test and document; consistent device-selection behavior
  • ➕ Leverages bundled PortAudio wheels for common platforms
  • ➖ Breaks users who already rely on simpleaudio-only environments
  • ➖ Reduces resilience if sounddevice import fails in niche setups

Recommendation: Current approach is a good fit for the project’s stated invariant (core remains dependency-free/offline-testable) while making --play installable and usable by default on multi-sink Linux via pipewire/pulse preference. The substring-based auto-selection is pragmatic and user-aligned; consider a follow-up --list-devices for discoverability, but it’s not required for this PR.

Files changed (14) +719 / -55

Enhancement (5) +234 / -43
synth.pyAdd device selection, auto-preference, and friendly playback error wrapping +120/-10

Add device selection, auto-preference, and friendly playback error wrapping

• Introduces output-device auto-selection preferring pipewire/pulse when no device is specified, plus digit-string-to-index handling. Wraps backend/device failures (simpleaudio or sounddevice) into structured exit-2 'CliError's that include remediation and best-effort output device listings; updates no-backend remediation to point to the 'audio' extra.

harmonics/audio/synth.py

demo.pyThread '--device' / env device selection into demo live playback +24/-5

Thread '--device' / env device selection into demo live playback

• Adds '--device NAME|INDEX' argument and resolves device from flag then '$HARMONICS_AUDIO_DEVICE'. Passes the resolved device through to demo playback and updates '--play' help text to reference the 'audio' extra.

harmonics/cli/_commands/demo.py

play.pyAdd '--device' flag and env support for 'harmonics play --play' +28/-6

Add '--device' flag and env support for 'harmonics play --play'

• Adds '--device NAME|INDEX' CLI option and resolves it with '$HARMONICS_AUDIO_DEVICE' fallback (flag wins). Passes the device through to 'harmonics.audio.play()' and updates help text to reference the opt-in 'audio' extra.

harmonics/cli/_commands/play.py

say.pyAdd '--device' flag and env support for 'harmonics say --play' +28/-6

Add '--device' flag and env support for 'harmonics say --play'

• Adds '--device NAME|INDEX' CLI option and resolves it with '$HARMONICS_AUDIO_DEVICE' fallback (flag wins). Passes the device through to 'harmonics.audio.play()' and updates help text to reference the opt-in 'audio' extra.

harmonics/cli/_commands/say.py

play.pySupport device selection and friendly device errors in demo clip playback +34/-16

Support device selection and friendly device errors in demo clip playback

• Threads an optional 'device' through backend resolution so sounddevice output selection is resolved once per run. Wraps simpleaudio/sounddevice playback failures using the shared friendly 'CliError' helper and updates remediation to point to the 'audio' extra.

harmonics/demo/play.py

Tests (4) +372 / -0
test_audio.pyAdd tests for device auto-selection, explicit override, and friendly playback errors +160/-0

Add tests for device auto-selection, explicit override, and friendly playback errors

• Introduces fakes exposing 'query_devices()' to verify pipewire/pulse preference and forwarding of explicit devices (including digit-string -> int). Adds coverage ensuring backend play failures are surfaced as exit-2 'CliError's with '--device'/'--wav' remediation.

tests/test_audio.py

test_demo_play.pyTest demo clip playback device forwarding and friendly device errors +98/-0

Test demo clip playback device forwarding and friendly device errors

• Adds tests ensuring 'play_clips()' forwards explicit device strings, auto-selects pipewire when present, and converts backend playback failures into friendly 'CliError's with appropriate remediation.

tests/test_demo_play.py

test_play.pyTest '--device' / env precedence for 'harmonics play --play' +57/-0

Test '--device' / env precedence for 'harmonics play --play'

• Adds tests verifying '--device' is passed through to the audio backend, '$HARMONICS_AUDIO_DEVICE' is used when no flag is provided, and the flag overrides the env var. Confirms default device resolution is 'None' when neither is set.

tests/test_play.py

test_say.pyTest '--device' / env precedence for 'harmonics say --play' +57/-0

Test '--device' / env precedence for 'harmonics say --play'

• Adds tests verifying '--device' is passed through to the audio backend, '$HARMONICS_AUDIO_DEVICE' is used when no flag is provided, and the flag overrides the env var. Confirms default device resolution is 'None' when neither is set.

tests/test_say.py

Documentation (4) +96 / -11
CHANGELOG.mdDocument v0.7.0 live playback install/device/error improvements +28/-0

Document v0.7.0 live playback install/device/error improvements

• Adds 0.7.0 release notes describing the new 'audio' extra, '--device'/env selection behavior, pipewire/pulse preference, and friendly exit-2 device errors. Updates help/remediation messaging notes for '--play'.

CHANGELOG.md

CLAUDE.mdCodify opt-in audio extra and device-selection invariant +6/-0

Codify opt-in audio extra and device-selection invariant

• Extends the project invariants to explicitly require live playback to remain opt-in via 'harmonics-cli[audio]'. Documents '--device'/'$HARMONICS_AUDIO_DEVICE' precedence and default preference for pipewire/pulse devices.

CLAUDE.md

README.mdAdd live playback installation instructions and device flag mention +21/-2

Add live playback installation instructions and device flag mention

• Updates command table to mention '--device' for 'play'/'demo'. Adds a dedicated Live playback section explaining the opt-in 'audio' extra and fallback to '--wav' when no backend is present.

README.md

catalog.pyDocument 'audio' extra, '--device' behavior, and friendly device failures +41/-9

Document 'audio' extra, '--device' behavior, and friendly device failures

• Updates explain entries for play/say/demo to reference the 'harmonics-cli[audio]' extra and its dependencies. Adds documentation for '--device' and '$HARMONICS_AUDIO_DEVICE' precedence, pipewire/pulse preference, and improved exit-2 error behavior with device listings.

harmonics/explain/catalog.py

Other (1) +17 / -1
pyproject.tomlBump version to 0.7.0 and add optional 'audio' dependencies +17/-1

Bump version to 0.7.0 and add optional 'audio' dependencies

• Bumps project version from 0.6.0 to 0.7.0. Adds '[project.optional-dependencies].audio' with 'sounddevice>=0.4.6' and 'numpy>=1.24', keeping core 'dependencies' empty and documenting install guidance and rationale.

pyproject.toml

@qodo-code-review

qodo-code-review Bot commented Jul 8, 2026

Copy link
Copy Markdown

Code Review by Qodo

🐞 Bugs (0) 📘 Rule violations (0) 📜 Skill insights (0)

Context used
✅ Compliance rules (platform): 13 rules
✅ Skills: version-bump

Grey Divider


Remediation recommended

1. Device ignored with simpleaudio ✓ Resolved 🐞 Bug ≡ Correctness
Description
harmonics.audio.synth.play() still prefers simpleaudio and returns early, so
--device/$HARMONICS_AUDIO_DEVICE and the pipewire/pulse auto-selection (implemented only for
sounddevice) are ignored whenever simpleaudio is installed. This conflicts with the user-facing
docs that describe --device as selecting the output device for --play, and prevents users from
forcing the sounddevice path to avoid fixed-rate default sinks.
Code

harmonics/audio/synth.py[R565-573]

+        # 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
Evidence
The implementation explicitly notes that device is ignored on the simpleaudio branch and returns
before reaching the sounddevice path; meanwhile the explain docs present --device as selecting
the output device for --play without caveating backend precedence.

harmonics/audio/synth.py[524-573]
harmonics/audio/synth.py[575-608]
harmonics/explain/catalog.py[169-192]
harmonics/demo/play.py[42-88]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
`harmonics.audio.synth.play()` tries `simpleaudio` first and returns immediately if it’s present. Because `simpleaudio` has no device-selection API, this makes `--device` / `$HARMONICS_AUDIO_DEVICE` ineffective (and also bypasses the pipewire/pulse auto-selection) on systems that happen to have `simpleaudio` installed.

## Issue Context
The CLI/explain docs advertise `--device NAME|INDEX` as selecting the output device for `--play`, and the PR’s main value is routing away from fixed-rate default sinks via the `sounddevice` backend. Backend precedence currently prevents that when `simpleaudio` exists.

## Fix Focus Areas
- harmonics/audio/synth.py[524-618]
- harmonics/demo/play.py[42-118]
- harmonics/explain/catalog.py[169-193]

## Suggested change
- If a `device` is provided (flag or env), prefer `sounddevice` first and only fall back to `simpleaudio` if `sounddevice` is unavailable.
- Consider preferring `sounddevice` unconditionally (it’s now the recommended backend and supports device selection + auto-preference), keeping `simpleaudio` as fallback.
- Ensure `harmonics.demo.play._resolve_backend()` mirrors the same precedence so `harmonics demo --play --device ...` behaves consistently.
- If you intentionally keep `simpleaudio` first, update docs/help to clearly state that `--device` only applies when the `sounddevice` backend is used.

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools



Informational

2. Private helper coupling ✓ Resolved 🐞 Bug ⚙ Maintainability
Description
harmonics.demo.play imports underscore-prefixed helpers (_select_output_device,
_device_playback_error) from harmonics.audio.synth, coupling two modules via non-public APIs and
making future refactors in synth.py riskier. This is a maintainability issue that can be avoided
by moving shared logic into a small internal utility module or making the helpers explicitly public.
Code

harmonics/demo/play.py[33]

+from harmonics.audio.synth import _device_playback_error, _select_output_device
Evidence
The demo playback module directly imports underscore-prefixed helpers from the synth module, and
those helpers are defined as internal functions in harmonics.audio.synth.

harmonics/demo/play.py[33-35]
harmonics/audio/synth.py[457-522]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
`harmonics.demo.play` now imports underscore-prefixed helpers from `harmonics.audio.synth`, creating cross-module coupling to private implementation details.

## Issue Context
Both modules are in-repo, so this isn’t a user-facing bug today, but it increases refactor cost and makes the underscore convention misleading (the helpers are effectively shared APIs now).

## Fix Focus Areas
- harmonics/demo/play.py[24-40]
- harmonics/audio/synth.py[443-522]

## Suggested change
- Move `_select_output_device`, `_output_device_listing`, and `_device_playback_error` into an internal shared module (e.g. `harmonics/audio/_playback.py`), then import from there in both places.
- Alternatively, if you intend them to be stable shared helpers, rename to public (drop the leading underscore) and consider re-exporting from `harmonics.audio` (or clearly document as internal-but-shared).

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


Grey Divider

Qodo Logo

Comment thread harmonics/audio/synth.py Outdated
Comment thread harmonics/demo/play.py Outdated
… (Qodo review)

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) <[email protected]>
Claude-Session: https://claude.ai/code/session_01VMUvDkSHhthqU3c5HrhQuj
@OriNachum OriNachum deployed to testpypi July 8, 2026 13:46 — with GitHub Actions Active
@sonarqubecloud

sonarqubecloud Bot commented Jul 8, 2026

Copy link
Copy Markdown

@OriNachum OriNachum merged commit 496040b into main Jul 8, 2026
8 checks passed
@OriNachum OriNachum deleted the feat/live-audio-playback branch July 8, 2026 14:35
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant