The canonical additive harmonic synthesizer of the Harmonic Beacon ecosystem: 32 voices + waveshaper + per-voice LFO + sidechain.
This package is the reconciled standalone extraction of the evolved Shaper in
digital-beacon. The older NaturalHarmony/harmonic_shaper implementation is
historical; its Minilab3 controller was recovered here because it had been lost
from the fork.
python -m venv .venv
source .venv/bin/activate
pip install -e .PortAudio must be available on the host for real-time output through
sounddevice.
python -m harmonic_shaper
# or, after installation:
harmonic-shaperThe default process starts the audio engine, native OSC listener, MIDI controllers when present (Launchpad, Minilab3, and a native keyboard note source), and the FastAPI state service.
Standalone mode defaults to sequential natural-harmonic banks: adjacent keys
select n=1..32 at exact f1*n frequencies. The lower configured bank is
momentary; the upper bank is toggle/sustain. No 12-TET pitch adaptation is
applied. See Native MIDI Harmonic Banks
for configuration and controller calibration. No NaturalHarmony beacon is
required. Useful flags:
python -m harmonic_shaper --slave # opt in to NH /beacon/* broadcasts
python -m harmonic_shaper --f1 40.40 --anchor 24
python -m harmonic_shaper --native-midi-momentary-start 24 --native-midi-toggle-start 72
python -m harmonic_shaper --native-midi-mode legacy_hybrid
python -m harmonic_shaper --no-native-midi # keyboards off; pads/CC still on
python -m harmonic_shaper --no-audio # headless control/API process
python -m harmonic_shaper --no-midi
python -m harmonic_shaper --no-api
python -m harmonic_shaper --helpDefault bindings:
- UDP
:9002: current v1 wire protocol under/digital/*. - UDP
:9001: optional/beacon/*slave input, only with--slave. - HTTP
127.0.0.1:8080:GET /api/state,POST /api/shaper/*, and WebSocket/ws.
/shaper/* is the planned native namespace. It is intentionally not mapped on
the wire yet because renaming /digital/* requires a contract version bump.
The extracted offline reference is also installable:
harmonic-shaper-synth-pure input.wav --out output.wavIt retains the fork's voice-analysis and NumPy rendering path for the clipping work tracked after this extraction.
pytest -qSee the extraction report for the module map, fork reconciliation, dependency audit, and clipping notes.
MIT — see LICENSE.
The movement laboratory can render PCM without opening an audio device using
AudioEngine.render_block(now=<logical_seconds>). It runs the same kernel as
the production callback, with the configured block size and a caller-owned
monotonic clock. Submit LaboratoryInput controls with that same logical time;
leases and envelopes retain their production behavior. Each offline run owns a
fresh store and engine. Rendering on an engine that is running a stream is
rejected. Audio remains stereo float32 after shaping/master/soft limiting;
voice_frame() describes oscillators before shaping/limiting, not the PCM.
The capture tap is after the soft limiter and copies the exact float32
samples assigned to PortAudio. It is separate from the legacy pre-limiter
attach_recorder hook. Nothing is recorded until an explicit start.
POST /api/audio/capture/start:{"max_seconds":120,"queue_blocks":128}. Requires a running audio engine; rejects overlapping starts and invalid limits. Optionalowneris an opaque 1..80-character alphanumeric/underscore/hyphen nonce. Repeating that owner with the same settings returns its existing capture (also after completion), allowing recovery of a lost start acknowledgement. Reusing it with different settings is rejected; it is not authentication.GET /api/audio/capture: progress/error, sample bounds, queue size and output directory.POST /api/audio/capture/stop: optionally{"id":"<capture-id>"}; a stale identifier cannot stop a newer capture.- Default private output:
~/.local/share/harmonic-shaper/laboratory-captures/<id>/.audio.wavis stereo float after shape/master/limiter;blocks.jsonlpreserves sample index, callback monotonic/DAC timestamps, pre-shape voices and crop size.manifest.jsonis written atomically after the files close. Capture state and manifest include module hashes and Python/numpy/soundfile versions observed when recording starts (files on disk, not an attestation of loaded modules).
A bounded single-producer/single-consumer deque holds copied blocks. The callback never waits for disk or queue capacity; the writer runs separately. Queue overflow, clock discontinuity, sample-rate changes, callback status or stream failure mark capture failed. Overflow/disk failure do not stop synthesis. Duration ends exactly at the requested sample count; early stop closes the current contiguous interval. The RIFF size limit is validated before starting. Start/stop ownership is serialized outside the callback. This is Python software, not a hard realtime deadline guarantee.
Captured PCM is Shaper's digital output, excluding downstream mixer/device gain or other programs. This first foundation does not capture video or laboratory configuration changes; the Weaver UI/session collector and audiovisual alignment are subsequent LAB-09 work. No camera capture or retrospective buffer is enabled. Abrupt process termination can leave an interrupted file: this version does not claim crash recovery. The running laboratory is not upgraded by checking out this branch. A change in AudioEngine also changes the strict offline engine hash; use the original pinned checkout for an old frozen PCM request, or create a new run.
Verification: pytest tests/test_capture.py tests/test_offline_render.py tests/test_laboratory.py tests/test_audio_smoke.py tests/test_pads_v2_audio.py -q:
32 tests passed. Capture tests cover exact post-limiter samples versus the separate
pre-limiter tap, sample limits/crop, early stop, bounded overflow, disk/clock/stream
errors, stale stop, concurrent starts and validation/API. These are hardware-free
checks; real device latency, audiovisual synchronization and listening remain open.
Interrupted capture recovery (POSIX): POST /api/audio/capture/recover with
{"id":"<32-character capture id>"}. An advisory writer lock rejects active
recordings. New captures preserve capture.json and periodically flush audio/journal
outside the callback. Recovery parses the float WAV header even when length fields
are stale and writes only complete, contiguous, journal-confirmed blocks to a new
recovered/<id>/ folder. Raw WAV/journal/manifests remain unchanged. Result status
is recovered, never complete; truncated or unconfirmed tails are excluded. This
handles process interruption, not a guarantee against power loss or disk corruption.
Legacy captures without the lock/metadata contract are rejected. API accepts IDs
under its configured capture root, not caller-supplied paths.
Recovery retries are idempotent for unchanged raw hashes: a previously recovered
prefix is returned with reused=true after verifying its WAV/journal hashes. A
modified recovered artifact is rejected rather than silently copied again. Raw
changes allow a new result; every result retains its source hashes. GET
/api/audio/capture/recovery-contract advertises schema 1 and
idempotent_source_hashes=true and pollable_jobs=true. The legacy synchronous
route remains available. For resumable recovery, POST
/api/audio/capture/recovery-jobs with {"id":"<capture-id>","job_id":"<client-uuid-hex>"}.
GET /api/audio/capture/recovery-jobs/<job-id> observes queued/running/recovered/
failed/interrupted without starting work. Same job ID/capture recovers its receipt;
another capture with that ID is rejected. Receipts persist under recovery-jobs;
writer locks distinguish live jobs from interrupted ones across service restart.
An interrupted/failed receipt does not restart automatically; a new explicit
attempt needs a new job ID. Completed receipts describe that attempt, not a fresh
verification of current raw files; legacy recovery/exports verify artifacts.
GET /api/audio/output lists stereo outputs visible to this process and reports
requested/effective sample rate, block size and output revision. POST accepts
expected_revision, device (index/name/null), sample_rate and block_size
(128/256/512/1024/2048). Release voices and stop captures first. Settings are
checked before closing the stream; a failed open attempts to restore the previous
output. Identical settings do not reopen it. These settings are session-local;
startup flags still configure the next session. JACK uses the graph sample rate,
so requesting 96 kHz does not change a 48 kHz PipeWire server.