Skip to content

About

This is an Antelope Control App implementation on linux for Orion Studio Synergy Core, could add more devices in the future

Resources

Stars

8 stars

Watchers

4 watching

Forks

Latest commit

 

History

256 Commits

Folders and files

Repository files navigation

antelope-ctl

Open-source Linux control for Antelope Audio USB interfaces, reverse-engineered from USB traffic capture on hardware the contributors own (plus facts from Antelope's own public manuals). No vendor software, firmware, or source is used; the device firmware is not touched.

Looking for the wire format? The protocol spec -- frame types, the bus-vs-channel address spaces, every bitmask, the 0x73 state-report byte-map, talkback, output trim, meter calibration, open questions -- lives in PROTOCOL.md. This README is the guide to using the tool.

Documentation map

file what's in it
README.md (this file) using the CLI; adding a param / a device; RE ground rules
AGENTS.md sole project instruction entry point; local handoff and open tasks have separate roles
PROTOCOL.md the reverse-engineered wire format in reference form — frames, opcodes, state-report byte maps, the 0x74/0x75 readback protocol (§4a), per-device notes (§14)
docs/profile-schema.md what every key in profiles/*.json means, and which the code reads — start here if you're writing a profile or a client (webUI)
docs/profile-labeling.md the cross-client labels and feature-manifest contract — how to add a device and keep shared parameter vocabulary consistent
docs/discrete-remote-agent-playbook.md remote-only workflow for completing the Discrete 4 / 4 Pro / 8 Pro profiles with the repository's probes, capture tools, and safety rules
CAPTURING.md how to capture USB traffic — usbmon on Linux (incl. the webUI + usbmon method), Windows VM + USBPcap, or native macOS
docs/orion-afx-workflow.md current AFX evidence, existing tools, proposed Launcher batch automation and effect completion criteria
profiles/*.json the machine-readable source of truth, one per device (orion_studio_sc is the reference; also zen_go_sc, discrete_8_pro_sc, discrete_4_sc, discrete_4_pro_sc) + mic_models.json and the shared afx_effects.json reference catalog
SCOPE.md / EULA-ANALYSIS.md the distinction between device-side AFX Real-Time controls and host-side Native/Cosmos plugins, plus this repo's control and licensing boundaries

Naming

The UI and application-facing software call the bundled Mix 1 reverb Gazelle Reverb. AuraVerb remains the hardware/protocol name used in captures, reverse-engineering findings, and wire-level identifiers. The existing CLI subcommand, profile key, and API path (auraverb) remain as compatibility identifiers.

Roughly what's decoded (2026-09): preamp gain/mode/phantom/phase + link, ADAT/S/PDIF gain and device link controls, output buses (monitor/HP/line/reamp) with dim/mute/mono, the full routing matrix (15 dests, 12 source banks, read

  • write, self-verifying), the virtual mixer (4 mixes, read + write), Gazelle Reverb (read/write, hw-verified), mic modeling / emuMic (frame decoded), sample rate / clock source / pan law / DC-coupling / oscillator / screen brightness / output trim (all SET_GLOBAL), talkback, and the surround monitoring tab (per-speaker 16-band EQ + global + 2.1 bass management; Room Correction turned out to be the per-speaker EQ). In-band readback (0x74/0x75) covers routing, mixer, the bundled reverb, device identity, preamp/channel state, both surround frames (categories 0x1a per-speaker EQ / 0x1b global), and the newly structured Orion nested records: category 0x0b link tables, 0x15 AFX instance counts, 0x16 mic-emulation state, and 0x19 AFX strip order. The CLI and WebUI display those profile-declared records. There is no built-in per-input-channel EQ on this device: input EQ uses an AFX Real-Time effect; AFX EQ parameter controls have no verified implementation (SCOPE.md, PROTOCOL.md). Normal WebUI format writes are limited to 2.0 and 2.1. The global format wire path was directly round-trip tested through 9.1.6 with tools/surround_format_selftest.py. The meaning of classic category 0x07, the outer query bounds for 0x0c, and some 0x74 channel-group names remain unresolved. Tagged AFX instance-state queries use a separate namespace. Category 0x0b index 0 contains six link flags for the device's 12 physical preamps, paired as 1/2 through 11/12. ADAT uses the eight records at 0x0b:1; S/PDIF uses the single record at 0x0b:2. Direct device transitions and subsequent Windows VM Launcher checks on 2026-09-30 confirmed input link selectors 0=Preamp, 1=ADAT, 2=S/PDIF. Earlier Launcher button captures used incorrect digital selectors; the old project interpretation was corrected. The AFX plugin-chain slot frame (0x23/0xd7, which channel holds which plugin instance) is field-mapped for observation in PROTOCOL.md §12a. General loading remains outside scope; the owner's explicitly requested Orion captured-effect rack pilot is a bounded exception (SCOPE.md). Its operator-driven test interface supports whole-chain loading/removal/reordering and live complete parameter blocks. The AFX-tab channel stereo-link is in scope — it is plain SET_LINK (space 0x04). See PROTOCOL.md §13 for the live open list.

AFX profile preparation

AFX preparation separates device capacity from effect definitions. Orion's afx metadata declares 32 mono channels and eight insert slots per channel, as described in Antelope's public demonstration. The shared profiles/afx_effects.json catalog contains 80 reference effect entries and 729 declared control names from Gazelle's Discrete 4 findings, with pinned source provenance. The first three descriptions are drawn from public effect pages. The Orion Memory Cat Brigade implementation records six observed 0–100 knob fields and two binary switch fields from an owner-labelled capture. Switch labels are owner-confirmed: raw0=550ms/Chorus, raw1=1100ms/Tremolo. Parameter/processing-state readbacks are captured for instances0–2; intermediate physical scaling remains unresolved. The typed operator path separately records verified chain/link round trips and owner-reported parameter operation. Owner-labelled load captures establish Orion type IDs for Memory Cat, V12 Chorus, BBD-Chorus, Instinct and Master De-Esser; catalog commands stay null. This metadata does not enable a general effect writer. See the profile schema.

The WebUI's AFX button, between Routing and Mix 1, opens a detachable window with a channel selector and one channel overview on the left, and the selected channel's effects rack on the right. Orion offers 32 channels; the rack shows all eight slot panels together and remembers the selected channel while the main page remains open. Each effect's presentation has its own JavaScript and CSS module. Memory Cat provides six draggable knobs and two mode selectors in an original Gazelle rack design. Local previews retain settings per channel/slot and support drag-and-drop or keyboard-button reordering.

The AFX channel dropdown shows a loaded-effect count (for example, AFX 1 · 2 FX) from slot readbacks only when at least one effect occupies that channel. Empty and unavailable channels have no count; existing link markers remain visible.

Opening AFX shows the device rack. Select any of AFX 1–32; each slot in the left channel frame has an effect selector grouped into Dynamics, EQ & Filters, Modulation, Delay & Reverb, Pitch & Tuning, Amps & Cabinets, Preamps, Saturation & Distortion, and Other. These are presentation categories, not device capabilities. Memory Cat, Instinct, Master De-Esser, V12 Chorus and BBD-Chorus have independently captured Orion load IDs; unmapped entries are greyed out. Selecting a different effect replaces a supported insert; selecting Empty removes it. Loading uses fresh instance counters and the complete slot inventory, measured instance indices, and verified whole-chain writes. Counts describe resources, not license ownership. Direct load/remove tests on AFX 3–32 verified matching slot readbacks and exact restoration; AFX 1 was owner-tested earlier. AFX 2 was not part of those mono trials. Only Memory Cat has parameter controls so far. Drag effects within or between the left slot list and the rack to reorder the selected chain.

The compact pair button sits inside the left channel header and follows the selection: AFX 1 or 2 shows 1–2, AFX 3 or 4 shows 3–4, through 31–32. Its glow and accessible label identify the current link state. Linked channel-menu entries show their partner. Link indicators use device readback: all 16 space-4 flags were independently verified against 0x0b:4 records 0–15 on 2026-10-01. Records 16–31 remain unmapped. Opening or refreshing reads link flags and selected chain/settings, including the partner when linked. The button sends only the flag, preserving both chains. Stereo parameter sharing is enabled for Memory Cat's bounded operator pilot: edits update the existing selected instance and a same-slot Memory Cat partner when present, using fresh chain readbacks. Audible confirmation and a Memory Cat-specific vendor stereo capture remain pending. Other-effect parameter controls remain unavailable. Adding, replacing, removing and reordering effects works while linked: the controller reads both adjacent chains, preflights both edits, allocates distinct instances and writes left then right, matching the captures. Both resulting chains must verify. Loads require two available instances and empty target slots on both sides; removal/replacement/reordering requires matching effect types at the edited slot. Conflicting chains remain unchanged for independent editing. If the selected linked rack is empty and the partner has effects, the popup selects that populated channel so its actual controls remain visible. Links on other pairs do not block that channel.

Memory Cat instances0–2 now initialize from real device readback on opening, channel selection, loading and reconnect. The JSON profiles describe a separate tagged instance-state query, its eight parameter offsets and processing flag. The owner's Launcher-restart capture returns the exact minimum/maximum/near-50% values without replaying parameter writes. New effects were also confirmed active after Bypass All; loading preserves the device-assigned state. Power-cycle persistence remains untested. Other instance reads/effect fields remain guarded; unknown settings stay unavailable. No hardcoded starting preset is sent. Knob and switch edits send automatically from the first edit; there is no Apply step. Opening or refreshing never sends settings. Requests are throttled and coalesced to the latest settings, with one in flight and no rack repaint during a drag. Failed writes pause live sending until an explicit Retry live controls click; they are not retried automatically. A missing or mismatching post-write slot reply, or mismatching parameter reply, disables further AFX writes for the server session; Refresh remains read-only and does not clear that failure. An instance-query timeout blocks further captured instance reads and edits until the device reconnects, because a late reply carries no instance identity. Reconnect clears old slot inventory, link flags and initialized parameter drafts. Wait for the full inventory before loading. After loading or reconnect, the selected rack queries fresh settings before its first edit. The owner confirmed both switches:0=550ms/Chorus,1=1100ms/Tremolo. The WebUI labels them from the Orion JSON. The owner reports the effect and applied parameter settings work. A 2026-10-01 Level 100→99→100 self-test found no stable parameter readback in the observed HID streams; the original settings were resent to restore the baseline, with the command acknowledged but no parameter readback to independently verify restoration. This does not establish absence of a separate parameter-readback protocol; the new restart capture identifies it. That older restoration trial remains send-only evidence. See PROTOCOL.md §12a for the effect IDs, complete device-test findings and remaining evidence limits. The Orion AFX workflow documents Linux readback recording, reuse of existing captures and proposed Launcher batch automation, with completion criteria for each available effect.

Local preview explicitly switches to drafts; Connect device rack returns to device slots. Opening the rack or changing channels sends no hardware mutations.

Evidence rule for write claims

For profile-audit purposes, a writable parameter may be marked confirmed only when its evidence records a dated, model-local write, a device-side readback showing the before/after value, and restoration of the original value (or an explicit user request to retain the state for cross-controller validation). Vendor software traffic, UI or audio behaviour, and an undifferentiated moving byte do not by themselves constitute a readback witness. Observed channel indices may confirm membership, but the maximum safe index remains derived until an out-of-range probe is performed; clients must not probe one casually because a sibling device faults hard on an invalid index.

Legal status & disclaimer

This is an independent interoperability project. It is not affiliated with, authorized by, or endorsed by Antelope Audio. "Antelope", "Orion", "Discrete", "Synergy Core" and related names are trademarks of their respective owner and are used here only to identify the hardware this software interoperates with.

  • No Antelope software, firmware, or source code is included or redistributed here. The device is not modified and its firmware is not touched.
  • The protocol description was produced by observing USB traffic to/from hardware the contributors own, on their own machines, for the sole purpose of making that hardware usable on operating systems the vendor does not support (Linux). Reverse engineering for interoperability is recognised in the US (e.g. Sega v. Accolade, Sony v. Connectix) and expressly permitted in the EU (Software Directive 2009/24/EC, Art. 6).
  • Antelope's own publicly published documentation (user manuals, spec sheets, signal-flow diagrams from their download pages) is used only as a source of facts -- channel counts, feature names, signal flow. No manual text, tables, or diagrams are reproduced here; documents are cited in the profile's evidence fields.
  • Packet captures committed under captures/ are recordings of the contributor's own device on the contributor's own machine, kept minimal and included only as evidence for a documented finding. Device serial numbers are redacted where practical.
  • AFX means device-side Synergy Core Real-Time effects here. Antelope describes these as running on a supported device, with a Real-Time license assigned to that device through Launcher. Their parameter controls are in scope when the effect is already available under a license assigned to the target device. Native plugins run in a DAW on the computer; Cosmos membership provides Native versions, not Real-Time licenses. This project does not handle license assignment, activation, entitlement traffic, or plugin loading. See SCOPE.md and EULA-ANALYSIS.md; the Cosmos EULA is not treated as the license for AFX Real-Time controls.
  • Use at your own risk. Sending control frames to hardware can put it in unexpected states; see "hazards" in the profile JSON. No warranty.

If you are a rights holder with a concern, please open an issue.

License

  • Code, CLI, web UI and docs: GPL-3.0-or-later (see LICENSE). Copyleft on purpose -- a fork that ships a modified version has to ship its source too, so this can't be quietly taken closed. GPL-3.0 (not 2.0) for the explicit contributor patent grant, which is the real protection a reverse-engineering project has, and for Apache-2.0 dependency compatibility.
  • Device profiles (profiles/): MIT (see profiles/LICENSE). The hardware knowledge -- request tables, offsets, ranges, bounds, quirks, evidence -- is kept permissive so it can flow into any project, including copyleft ones. Most of it is uncopyrightable fact anyway.

Copyright (c) 2026 diafebus. Contributions are accepted under the same terms (GPL-3.0-or-later for code, MIT for profiles); by opening a pull request you agree to license your contribution that way.

Requirements

  • Linux, Python 3.9+ (no third-party packages -- stdlib only).
  • Read/write access to the device's hidraw node. Either run with sudo, or add a udev rule granting your user access to the Antelope device's vid/pid (see device in the profile JSON) so you don't need root every time.
  • Captures for adding new params are done either from a Windows VM (Antelope Launcher + USBPcap) against the passed-through hardware, or -- simpler, when the control exists in this project's own web UI -- with usbmon on the Linux host while the web UI drives one control. See CAPTURING.md.

Layout

profiles/orion_studio_sc.json   <- single source of truth for the Orion Studio Synergy Core protocol
profiles/discrete_8_pro_sc.json  <- sibling device (peer-contributed)
profiles/zen_go_sc.json        <- sibling device (Zen Go Synergy Core), profile-driven first pass
profiles/discrete_4_sc.json      <- sibling device, STUB (status: UNCONFIRMED -- transport never captured)
profiles/discrete_4_pro_sc.json  <- sibling device, STUB (status: UNCONFIRMED -- transport never captured)
profiles/mic_models.json       <- account-bound mic-modelling ("emuMic") model catalogue
antelope/transport.py          <- generic HID open/read/write (no device-specific code)
antelope/protocol.py           <- generic frame build/parse, driven entirely by the profile
antelope/cli.py                <- generic CLI, driven entirely by the profile
tools/README.md                <- tool index, safety classes, and command guide
tools/capture_diff.py          <- offline helper for finding new params from captures
tools/scan_capture.py          <- offline helper: auto-finds the transition across a whole capture (Windows TSV)
tools/scan_macos_capture.py    <- same, for native-macOS (Darwin XHC) pcapng -- see CAPTURING.md
tools/hid_probe.py             <- dump the HID report descriptor + probe for a readable Feature report
tools/selftest.py              <- round-trip self-test against real hardware via the readback (read-only by default, --write for restore-guaranteed writes)
tools/surround_eq_selftest.py  <- bounded Surround EQ readback and one-field write/restore test
tools/surround_format_selftest.py <- bounded Surround format readback and reversible format write probes
tools/surround_eq_position_selftest.py <- Surround EQ PRE/POST readback and one-bit write/restore probe
CAPTURING.md                   <- how to capture USB traffic (Windows VM + USBPcap, or native macOS)
PROTOCOL.md                    <- the reverse-engineered wire format, in reference form
captures/                      <- analyzed .tsv exports + raw .pcapng captures/ (full-fidelity)

Rule of thumb: if you're about to hardcode a byte offset, param_id, or magic number anywhere in antelope/, stop -- it belongs in the profile JSON instead. That's what keeps this working across new params and new devices without touching the Python. (When a new feature needs a genuinely different frame shape -- see channel_link below -- it gets its own block under profile["frame"], rather than being forced into the existing one.)

Using it (Orion Studio Synergy Core)

--profile accepts a path, a bare filename, or a short name (orion / zen_go / discrete_8_pro), and defaults to orion (or $ANTELOPE_PROFILE). The examples below spell out --profile profiles/orion_studio_sc.json; antelope-ctl status on its own works too.

Physical input channels (12 hybrid inputs, addressed 0-11):

python3 -m antelope.cli --profile profiles/orion_studio_sc.json status
python3 -m antelope.cli --profile profiles/orion_studio_sc.json set-mode 0 mic
python3 -m antelope.cli --profile profiles/orion_studio_sc.json set-gain 0 12
python3 -m antelope.cli --profile profiles/orion_studio_sc.json set-phantom 0 on
python3 -m antelope.cli --profile profiles/orion_studio_sc.json set-invert 0 on
python3 -m antelope.cli --profile profiles/orion_studio_sc.json meter          # see below re: dB calibration

Channel link -- pairs adjacent inputs (ch1+ch2, ch3+ch4, ... ch11+ch12) for recording a stereo signal. Address either channel in the pair:

python3 -m antelope.cli --profile profiles/orion_studio_sc.json set-link 2 on   # links ch3+ch4
python3 -m antelope.cli --profile profiles/orion_studio_sc.json set-link 3 off  # same pair

Important (2026-08, confirmed on real hardware): the device itself does NOT propagate mode/gain/phantom/phase across a linked pair -- that's the official Launcher software sending extra commands, which set-link now replicates. See "Channel link is real, but the syncing you see isn't the device doing it" below before assuming any other tool will behave the same way against this hardware.

ADAT inputs -- a separate 16-channel space (ADAT ch 0-15), gain plus a confirmed eight-pair link control (no mode/phantom/phase):

python3 -m antelope.cli --profile profiles/orion_studio_sc.json adat-status
python3 -m antelope.cli --profile profiles/orion_studio_sc.json set-adat-gain 0 6      # ADAT ch1, +6 dB
python3 -m antelope.cli --profile profiles/orion_studio_sc.json set-adat-link 0 on     # links ADAT ch1+ch2 using space 1

ADAT links use SET_LINK space=1, pair indices 0–7, with device flags at category 0x0b, index 1, bytes 0–7. Each selector was independently toggled and restored on 2026-09-30 while all other link tables stayed unchanged. All eight flags were then left ON at the user's request; after reconnecting the Windows VM, the Launcher displayed all eight ADAT links ON.

The WebUI reads Preamp, ADAT, and S/PDIF flags from their separate tables and refreshes the relevant table after a link write. A one-time browser cache migration discards saved link entries from the old incorrect mapping; complete device readback repopulates the buttons. CLI link commands use the same corrected selectors but retain their separately labeled local cache for gain mirroring; mark-adat-link only changes that CLI cache.

The user confirmed on 2026-09-30 that corrected WebUI ADAT and S/PDIF link writes and readbacks work, and the intended links survive controller restarts. These input-link mappings are confirmed.

Earlier ADAT-button captures sent space 0, which targets Preamp flags. Earlier S/PDIF-button captures sent space 1, which targets ADAT. These UI commands explained both the Preamp changes and the misleading historical S/PDIF attribution of ADAT byte 0, as well as the apparent failure to retain the intended digital-input links across Launcher restarts. ADAT pairs 7/8 failed the old tests because they were sent to the six-pair Preamp space. Gain coupling remains software behavior: controllers send a separate gain command to each linked channel. These link-flag trials did not test firmware gain propagation or retention through a physical device power cycle.

S/PDIF input -- a 2-channel space (0 = L, 1 = R), gain + link only:

python3 -m antelope.cli --profile profiles/orion_studio_sc.json spdif-status
python3 -m antelope.cli --profile profiles/orion_studio_sc.json set-spdif-gain 0 6   # S/PDIF L, +6 dB
python3 -m antelope.cli --profile profiles/orion_studio_sc.json set-spdif-link on    # links L+R

S/PDIF links use SET_LINK space=2, pair index 0, and read back at 0x0b:2 byte 0. A direct OFF→ON write on 2026-09-30 changed only that byte, with Preamp links OFF and all ADAT links ON. The state was deliberately left ON for a Windows VM reconnect; the user confirmed the S/PDIF indicator ON. S/PDIF gain remains parameter 0x5c, state-report offsets 91/92.

Output buses -- monitor A/B, headphone 1/2, plus the settings-tab Line and Reamp outputs. These are a different address space from the input channels above (see "Buses vs. channels" below); use either the numeric bus id or its name:

python3 -m antelope.cli --profile profiles/orion_studio_sc.json bus-status
python3 -m antelope.cli --profile profiles/orion_studio_sc.json set-bus-level monitor_a 60
python3 -m antelope.cli --profile profiles/orion_studio_sc.json set-bus-dim mona on
python3 -m antelope.cli --profile profiles/orion_studio_sc.json set-bus-mute hp1 on
python3 -m antelope.cli --profile profiles/orion_studio_sc.json set-bus-mono hp2 off

Device-global settings

python3 -m antelope.cli --profile profiles/orion_studio_sc.json set-brightness 75    # front-panel screen, 0-100
python3 -m antelope.cli --profile profiles/orion_studio_sc.json sample-rate           # show current rate
python3 -m antelope.cli --profile profiles/orion_studio_sc.json set-sample-rate 96k   # 32k/44.1k/48k/88.2k/96k/176.4k/192k
python3 -m antelope.cli --profile profiles/orion_studio_sc.json clock-source           # show (0=Oven 1=WordClock 2=ADAT 3=ADATx2 4=ADATx4 5=SPDIF 6=USB)
python3 -m antelope.cli --profile profiles/orion_studio_sc.json clock-source --set 1   # -> Word Clock (disruptive)
python3 -m antelope.cli --profile profiles/orion_studio_sc.json pan-law --set 2        # 0=-6dB 1=-3dB 2=-4.5dB 3=0dB
  • Screen brightness only works when the device talks to a native host -- a VM Launcher no-ops the same slider (see the section further down).
  • set-sample-rate and clock-source --set are disruptive: the device drops audio and re-locks its clock (~1 s), and selecting a clock source that is not present/locked leaves the device unlocked. Change them with nothing streaming, then confirm (sample-rate / clock-source). Clock source reads back from 0x73 @19; the running rate reads back both as the 0-6 index (0x73 @18) and in Hz (0x73 @21-23, shown by sample-rate). pan law has no device readback, so pan-law reports only what this CLI last sent.
  • Two things block a rate/clock write on Linux (neither is a device limit -- macOS is unaffected): (1) any app or the audio server holding the USB audio interface -- release it first, e.g. pactl set-card-profile <orion-card> off (restore afterwards); (2) the write is also ignored while the clock source is USB (the device follows the host) -- set the clock to Oven, change the rate, set the clock back.

Device identity -- and why no serial is stored here

python3 -m antelope.cli identity            # name / hw rev / firmware
python3 -m antelope.cli identity --serial   # ...and the serial, on request
name      OrionStudio_III
hw rev    7.0
firmware  4.41
stamp     <u32> (?= YYYY-MM-DD, unconfirmed)
serial    <13 chars, hidden -- pass --serial to show>

(The stamp is shown as a placeholder above for the same reason as the serial -- it is per-unit data, so no real value from anyone's hardware is committed here.)

Policy: no device serial is committed to this repo, ever. Profiles describe the layout of the identity record (frame.readback.identity); the value is read from the hardware on demand via readback category 0x01. A serial identifies a specific physical unit and ties to an Antelope account, so it is treated as the owner's data, not project data.

identity therefore hides it unless you pass --serial, so it can't slip into a pasted terminal log or a bug report by accident. If some future online feature needs it, it should ask the device at that moment rather than read it from a file.

The same applies to captures and tool output: tools/readback_enum.py blanks category 0x01, and tools/*_out.txt is gitignored. If you are filing an issue, redact it.

Routing matrix

The routing frame (opcode 0x53) is decoded: after the destination byte it's a plain array of (source_bank, source_index) pairs, one per output channel of that destination, all sent every time. The device does report routing back -- via the 0x74/0x75 in-band query protocol, category 0x03 (decoded 2026-08-31, PROTOCOL.md §4a). So matrix-status is a live read of all 15 destination groups, and route verifies every write against a read-back and seeds keep from the device. Wired write destinations: line out (16 channels), HP1, HP2, Monitor A, Monitor B, Reamp (2) -- all hardware round-tripped against a real Orion Studio Synergy Core.

python3 -m antelope.cli ... route hp1 all preamp3 preamp4    # set every channel (seeds the cache)
python3 -m antelope.cli ... route lineout all compplay1..16  # range shorthand for a 16-ch seed
python3 -m antelope.cli ... route hp1 R preamp7              # change one channel, keep the rest
python3 -m antelope.cli ... route lineout 3 afx5             # line-out channel 3 <- AFX 5
python3 -m antelope.cli ... route lineout 4 mix2R            # <- virtual mix 2, right
python3 -m antelope.cli ... route hp2 mute                   # mute every channel
python3 -m antelope.cli ... route lineout 6 mute             # mute one channel
python3 -m antelope.cli ... matrix-status                    # LIVE read of the whole matrix from the device
python3 -m antelope.cli ... mix-status 1                     # LIVE read of virtual Mix 1 (cat 0x04)
python3 -m antelope.cli ... readback 0x03 0                  # raw: routing record for dest 0 (line out)
python3 -m antelope.cli ... readback 0x0b 0                  # six Preamp-pair link flags
python3 -m antelope.cli ... readback 0x16 0                  # structured mic-emulation state
python3 -m antelope.cli ... readback 0x19 0                  # structured AFX strip order
python3 -m antelope.cli ... readback                         # list the readback categories
  • Channel selector: 1-based (route lineout 3 ...). L/R = 1/2 for the true stereo destinations (HP1/HP2/Mon A/Mon B) -- not Reamp, whose two outs are separate mono (route reamp 1 / route reamp 2).
  • Source spec: preampN (1-12), emumicN (mic-modeled preamp, numbered 5-12 by preamp like the Launcher), compplayN (Computer Playback; playbackN alias), adatN (1-16), afxN (1-32), surroundN (1-16), spdifL/R, mix1L…mix4R, oscN (1-2), mute, or keep.
  • No un-route -- same as the Antelope Launcher: you replace a channel's source or set it to mute; there is no "empty" state.
  • Per-channel edits just work now. A per-channel route reads the current group from the device first (category 0x03), so you no longer have to seed the cache. route <dest> all <s1> <s2> … still sets the whole group at once; in all, a range token like compplay1..16 (or compplay1-16, descending adat16..1) expands to that many sequential sources.
  • matrix-status is a live device read of all 15 destination groups (line out, HP1/2, Mon A/B, Reamp, Comp Rec, ADAT out, S/PDIF out, AFX in, Mix Ch 1-4, Surround in). It falls back to the CLI cache only if the device is unreachable. The readback is the same 0x74/0x75 in-band protocol the Antelope Launcher uses -- PROTOCOL.md §4a, frame.readback in the profile.
  • All 15 destination groups are writable (2026-09-01): line_out, hp1, hp2, mona, monb, reamp, com_rec, adat_out, spdif_out, afx_in, mix_ch1-mix_ch4, surround_in. spdif_out takes L/R like the other stereo pairs.
  • mix_chN is the virtual mixer's input side. Its 32 channels are what each of mix N's 32 strips listens to -- so route mix_ch1 5 preamp3 picks strip 5's source and mix-set 1 5 --fader -6 sets that same strip's level. Two halves of one signal path.
  • Mind what you are pointing at: com_rec is what the computer records from the interface, and adat_out / spdif_out are physical digital outputs. Re-pointing those changes real signal flow, not just monitoring. Every write is read back and confirmed, so you will see exactly what landed.

See PROTOCOL.md §4a + §7 and frame.readback / frame.routing_command in the profile.

Virtual mixer -- Mix 1-4 (decoded; CLI and WebUI controls)

The Mix windows are a separate UI from the routing matrix -- mixing happens there, and each mix's L/R then shows up as a source in the matrix (mix1L … mix4R). Decoded 2026-08 from macos-mix1-send-pan-fader-mute-solo-link (opcode 0x17 / param 0xd4):

  • one frame per (mix 0-3, channel 1-32) strip, carrying fader (0 dB … −90 dB), pan (−30 … +30, centre 0x20), mute ([21] bit 6), solo ([21] bit 7), and send (0 … 96, 0 = 0 dB and 96 = −∞).
  • soloing a channel makes the Launcher re-send all 32 strips (that's how we know each mix has 32 inputs).
  • mix channel link = SET_LINK with a new space byte 0x03 (0 = Preamp, 1 = ADAT, 2 = S/PDIF, 3 = mixer); software-mirrored and scoped independently per mix. A link in Mix 1 never mirrors a Mix 2-4 pair.
  • not in the passive 0x73 stream, but mixer state is readable via the 0x74/0x75 query protocol -- category 0x04, index = mix number 0-3, fully decoded and hardware round-trip verified 2026-08-31.

Reading it back. mix-status is a live read of the device:

python3 -m antelope.cli mix-status          # all four mixes
python3 -m antelope.cli mix-status 1        # just Mix 1
Mix 1  (master + 32 strips)
  ch     fader     pan     send    flags
  mast   0 dB      +0      96/96
  1      -24 dB    -30     0/96
  2      0 dB      +30     95/96
  ...

The record is 33 three-byte slots <fader> <pan|mute|solo> <send> -- the same field order as the write frame -- and slot N is the strip the write frame addresses as channel N. Verified by writing a distinctive strip (mix 1 / ch 5 / fader 40 / pan +12 / mute / send 33 → 28 6c 21) and reading exactly those bytes back at slot 5, with no other slot touched. Slot 0 is the mix master, addressed as channel 0 by the write frame -- confirmed by capturing the Launcher sweeping the Mix 1 master fader (68 frames, all d4 05 00 00 <fader> 20 60). See PROTOCOL.md §4a.

Changing a strip. mix-set names the mix, the strip, and only the fields you want to change:

python3 -m antelope.cli mix-set 1 5 --fader -24 --pan 10 --mute on
python3 -m antelope.cli mix-set 1 master --fader -6      # the mix master
python3 -m antelope.cli mix-set 2 12 --send 80 --solo off
python3 -m antelope.cli mix-set 1 5                      # no flags = just show it
Mix 1 ch 5: fader 0 dB  pan -30  send 95/96
     -> ch 5: fader -24 dB  pan +10  send 95/96  MUTE
verified: device readback matches
  • The strip is read back first, so unnamed fields are kept. The write frame carries the strip's whole state every time, so a naive mix-set would silently reset everything you didn't mention. This one reads category 0x04, applies your flags, writes the result and re-reads to verify -- the same read-modify-write route does. There is no local cache. If the read fails it refuses to write rather than clobber blindly.
  • --fader is dB, 0 (unity) to −90. The sign is optional: the mixer only attenuates, so 24 and -24 both mean −24 dB.
  • master (or 0) addresses the mix master; 1-32 are the input strips. Same address space as the readback slots.
  • If other strips move too, it says so -- a linked pair or a solo re-muting the rest, both host-side behaviours the Launcher also has.

protocol.build_mix_command(profile, mix, channel, fader, pan_deg, send, mute, solo) builds the frame directly. See PROTOCOL.md §12 and frame.mix_command.

⚠ Never query a readback index past a category's record count. The firmware does not bounds-check it: category 0x04 index 5 crashed the Orion Studio Synergy Core outright (Cortex-M BusFault_Handler, physical power cycle required). The CLI and tools/readback_enum.py now refuse to go out of range. See PROTOCOL.md §4a.

Mic modeling / emuMic (decoded, not in the CLI yet)

The front-panel EMU button on preamps 5-12 (8 channels; the button shows per channel only in Mic mode) runs Antelope's mic-emulation DSP (for their Edge Solo / Edge Duo / Edge Note modelling mics). Same opcode 0x17 as the mixer -- [16] is 0xe5 instead of 0xd4. Decoded 2026-08-31: per preamp, an enable bit, a model id (0 = EdgeDuo / raw, 1…18 = emulations), a channel-order swap switch, and a polar pattern -- with model 0 a free 0-100 morph (omni → cardioid → figure-8), with a selected model a 0-based index into that model's pattern list (fixed / 3-way / 9-detent), preset to the model's default on select. Enabling also auto-turns on 48 V phantom and links the preamp pair; disabling reverses all of it (phantom off, gain re-sync, unlink). Current state is available as eight records from readback category 0x16 (target, model, swap, pattern). The write path still has no verified write/readback round-trip, so the WebUI controls remain optimistic while the readback diagnostics show the device response.

The modeled signal appears as routing source bank 0x01 (emumicN, N = preamp 5-12). Preamps 5-12 confirmed on the wire 2026-09-03 (a usbmon capture of the project's webUI driving EMU on preamp 5/6): the 0xe5 frame's channel byte is 0x00 for preamp 5 … 0x07 for preamp 12 (a -4 bias on the 0-based index), and each change is written to both channels of the pair. The EMU button shows per channel only in Mic mode (earlier work saw "7-12" because that unit had 5-6 in Line). A listening test confirmed the taps carry the modeled sound and model selection audibly changes it, matching the Launcher; emumic5 and emumic6 are one mono signal for a mono emulation.

protocol.build_micmodeling_command(profile, channel, enabled, pattern, swap, model) builds the frame. The model list is account-bound (Edge mics + model packs activate against an Antelope account), so it lives in profiles/mic_models.json as one account's snapshot -- a client should let the user pick by name. Not in the CLI yet.

Gazelle Reverb (the bundled reverb on the Mix 1 window; called AuraVerb in the hardware/protocol findings) has its own frame -- opcode 0x1d / param 0xda. Fully decoded (2026-08-31): byte 28 = on/off, plus 8 DSP controls, each a plain 0-100 byte -- Room Size (@19), Color (@20), Pre-Delay (@21, 0-100 → 0-32 ms), Early Reflection Gain (@23), Late Reflection Delay (@24), Richness (@25), Reverb Time (@26), Reverb Level (@27). Gazelle Reverb is bundled with the device (no per-plugin activation), so it's in scope. Decoded + hardware round-trip verified 2026-09-03: it reads back via the 0x74/0x75 query protocol as category 0x0a (one record, a block per mix), so the CLI now live-reads the device and does a verified read-modify-write; the local cache is only an offline fallback:

antelope-ctl ... auraverb                          # live Gazelle Reverb state (all 4 mixes)
antelope-ctl ... auraverb --on                      # enable Gazelle Reverb
antelope-ctl ... auraverb --reverb-time 55 --color 40 --room-size 70
antelope-ctl ... auraverb --off --defaults          # reset params, disable

protocol.build_auraverb_command(profile, params, enabled) / protocol.parse_auraverb_record. See PROTOCOL.md §12 / §4a and frame.auraverb_command.

Buses vs. channels

The SET_PARAM command frame has one byte (channel_offset) that means different things depending on which param you're setting:

  • For gain / input_mode / phantom / phase_invert, it's a physical input index, 0-11.
  • For bus_level / bus_dim / bus_mute / bus_mono, it's an output bus id instead. All 6 are now identified (2026-08): 0=monitor A, 1=headphone 1, 2=headphone 2, 3=line out, 4=reamp, 5=monitor B. Ids 3/4 turned out to be the settings-tab Line and Reamp output levels (not the headphone-3/4 that was previously guessed). bus_dim/bus_mono were restore-guaranteed live-round-tripped on all six ids on 2026-09-14, including Line and Reamp; bus 1 was temporarily moved off the raw-96 silent endpoint so its status marker could be tested. bus_mute is confirmed on 3 only. The Orion III has two physical reamp outputs (Reamp 1 / Reamp 2 -- separate mono outs for two guitar amps); bus 4 is one shared "Reamp" level slider, whether Reamp 1/2 have independent levels is untested.

Don't mix the two up -- set-gain 5 10 and set-bus-level 5 60 both use "5" but mean completely different things (physical channel 6 vs. Monitor B).

Reading bus_mute back: the bus status byte's 0x04 bit is set both by an explicit mute and whenever that bus sits at bus_level == 96 (the silent / maximum-attenuation endpoint) -- reproduced on buses 0, 3 and 4. A bus-status reader must check the level: at 96, 0x04 means "at the silent endpoint", not necessarily muted.

Adding a new param (e.g. routing)

Same discipline as Phase 1/2 -- capture, correlate against a real user action, confirm, only then trust it. The AntelopeAudio folder can provide a useful schema and shared-family hypothesis, but it does not establish another device's category bounds, field semantics, or transition behavior. Nothing is promoted from a sibling device on byte patterns alone.

  1. Capture a full, untruncated report -- see CAPTURING.md for the step-by-step (tshark, usbmon, USBPcap, or Wireshark are all suitable raw capture paths; Wireshark's plain-text export truncates past ~110 bytes and hides anything past the first ~90 bytes of a 320-byte report).
  2. In the official Launcher, change only the one thing you're investigating (e.g. routing), and note the state report just before and just after.
  3. Save those two 320-byte reports as hex dumps and run:
    python3 tools/capture_diff.py before.hex after.hex \
        --known-offset 49 --known-offset 61   # suppress already-explained bytes
    
    This prints exactly which offsets changed and how -- your candidate field for the new param. If you dumped a whole session instead (see CAPTURING.md), use tools/scan_capture.py on the .tsv instead -- it walks every report in order and finds the transition(s) for you, so you don't have to hand-pick before/after frame numbers.
  4. Also check what the Launcher actually SENT, not just what changed in the readback. The state-report diff only tells you which bytes moved; it doesn't tell you the opcode, param_id, or exact frame layout the Launcher used to cause that. Filter the same capture for outgoing URB_INTERRUPT out frames (magic 0x70) and look at the opcode byte (offset 4) and the bytes after param_id_offset (offset 16) directly -- don't assume they land at the usual channel_offset/value_offset (17/18). channel_link turned out to use a different opcode (0x14, not the usual 0x13) with its pair-index and enabled bytes shifted one position later (18/19) -- the state-report diff alone would never have revealed that shift, only the outgoing command bytes did. Talkback (2026-08) turned up a third opcode, 0x12 (frame.global_command): same header as SET_PARAM but for device-global params with no target, so the value byte sits at offset 17 (where SET_PARAM keeps its channel byte) and offset 18 is unused. talkback_button / _source / _gain all use it; talkback_dest_assign uses the ordinary 0x13 because it does take a target. Moral: check the opcode first, then figure out where the payload bytes actually are for that opcode.
  5. Add an entry to profiles/orion_studio_sc.json under "params" with "status": "unconfirmed" and your candidate offset/id in "notes".
  6. If it looks like the existing SET_PARAM(param_id, channel, value) shape, test it deliberately with the CLI's escape hatch:
    python3 -m antelope.cli --profile profiles/orion_studio_sc.json raw-set 0 0x53 10
    
    and confirm via another capture that only the expected offset moved, across a few different values, before calling it confirmed. raw-set enforces profile["constraints"] (target must be a valid index in some address space) and prints a hazard note for an unmapped param_id -- --force bypasses the bound. Never sweep opcodes or feed it values from an untrusted source: on the sibling Discrete 8 Pro, opcodes 0x01/0x02 and out-of-range indices wedged/BusFaulted the unit (see profile["hazards"] and PROTOCOL.md §14).
  7. Don't assume a new feature fits the SET_PARAM shape. Seven opcodes are known now (0x12/0x13/0x14/0x17/0x1d/0x53/0xab), each with its own frame block under "frame" and its own build_*_command() in protocol.py. Routing (0x53 / frame.routing_command) is the worked example: a distinct opcode whose payload is an array of (bank, index) pairs (one per output channel of the destination), not a fixed field -- and the first read of it (op bytes 02 01 / 00 02) was wrong until a single-variable capture (macos-matrix-ch1-12-mute-hp1L/-hp1R) showed 00 02 was just the other channel's untouched routing. Confirm the shape from a capture that changes ONE thing; add a new "frame" block and builder rather than bending an existing one.
  8. Once confirmed, flip "status" to "confirmed", add real ranges/enums, and add a proper subcommand to antelope/cli.py if it deserves one (or leave it on raw-set if it's rarely used).

Adding a new Antelope device

Copy profiles/orion_studio_sc.json to profiles/<device>.json, reverse its VID/PID and protocol from a fresh capture (don't assume it matches -- different product lines may use a different frame format entirely), and point --profile at the new file. antelope/transport.py, antelope/protocol.py, and antelope/cli.py should need zero changes if the new device follows the same general "vendor HID, N-byte reports, SET_PARAM(id, channel, value)" shape. If it doesn't, that's a sign the frame format itself needs to become part of the profile too (it already mostly is -- see "frame" in the JSON).

Every device profile also carries a versioned profile_schema, labels, and features block. Read docs/profile-labeling.md when adding or porting a profile so a future launcher (including Rust) can reuse the same stable keys without copying device-specific wire facts.

Sibling profiles so far:

profile device PID state
orion_studio_sc.json Orion Studio Synergy Core 0xa221 reference; most complete
discrete_8_pro_sc.json Discrete 8 Pro 0xa2b5 peer-contributed
zen_go_sc.json Zen Go Synergy Core 0xa015 first pass from USBPcap/TUI evidence -- preamps / buses / sample rate / clock, two mixer layouts, q03 mixer-input mirrors, q0b/03 mixer links, and selector-gated mixer-strip lanes are mapped; DSP, mic-modelling, complete category bounds, physical meter ownership/calibration, and some routing roles remain open (see open_questions in the file)

The family shares magic 0x70 command / opcode @4 / param_id @16 / 320-byte reports / HID interface 3 (EP 0x01 OUT, 0x82 IN) and most param IDs. It does not share report magics, the mixer frame shape, or source-bank numbers -- verify each against a capture, don't inherit.

The shared WebUI consumes those profile differences without copying Zen Go values into the Orion path. For Zen Go, the profile-confirmed source controls use the four mirrored routing records (destinations 6-9), the two mixer readback layouts expose 16 strips with no master/send, 0x49 selects the Monitor/HP1 or HP2 surface for the shared 16-lane meter bank, and a complete q0b/03 bitmap can seed the visible mixer-pair links. These mappings remain device-specific evidence; they are not defaults for other Antelope products.

Known limits and incomplete evidence

This is a reference of what the published implementation does not claim to have verified. It is not a development task tracker; the active Orion completion queue is maintained locally in the ignored AUDIT.md.

See "status": "unconfirmed" entries in the profile, and "unresolved_state_offsets". Nothing there is used by the CLI's normal commands -- it's flagged so nobody mistakes "solved the MVP" for "solved the whole protocol."

As of the 2026-08 vumeter session: all 12 physical inputs are confirmed working through the CLI (set-mode/set-gain/set-phantom/set-invert), and Hi-Z is hardware-limited to channels 1-4 (channels.hiz_channels in the profile; set-mode <ch> hiz on ch 5-12 now refuses unless you pass --force).

As of the follow-up 2026-08 mona/monb/hp1/hp2/chlink captures:

  • Output buses are confirmed. Monitor A, Monitor B, Headphone 1, and Headphone 2 each have a bus_level (0-96, 0/0x00=0dB maximum, 96/0x60=-inf/silent) plus bus_dim/bus_mute/bus_mono booleans, all readable back from a new bus_block in the state report (see bus-status). Update (2026-08): bus ids 3 and 4 are the settings-tab Line and Reamp output levels -- confirmed in settings-linevol-mute-reampvol-toggle via full bus_level sweeps (readback offsets 37 and 40) plus a bus_mute toggle on bus 3. A restore-guaranteed self-test on 2026-09-14 also toggled bus_dim and bus_mono on all six bus ids, including Line and Reamp, and restored every flag and level. All 6 bus slots are now identified; see "Buses vs. channels". The endpoint meaning is device-confirmed: Monitor A reads raw 0 at maximum (0 dB) and raw 96 at minimum (-inf); the command/state byte itself is unchanged.

  • Channel link syncs gain, phantom, AND phase_invert live -- but not mode. set-link <channel> on/off sends a real, verified command (a distinct opcode/frame from everything else -- see frame.link_command in the profile), and all 6 pairs were individually confirmed in the capture. A follow-up capture (ch-link-gain-ph-inv-test, 2026-08) showed that once a pair is linked, changing gain, phantom, or phase_invert on either channel mirrors to the other channel immediately -- but input_mode does not, and the Launcher grays out the mode control while linked. The confirmed workflow to change mode on a linked pair is: unlink -> set mode independently per channel (gain resets to fit the new mode's range) -> re-link. This matches user reports and is expected device behavior, not a CLI bug. Still no dedicated link-enabled bit appears in the state report -- everything observed changing there is explained as a side effect of the gain/status sync above, not a standalone flag. The extracted Orion panel schema and manager-server log do identify readback category 0x0b as five nested link tables: six Preamp pairs at index 0, eight ADAT pairs at index 1, one S/PDIF pair at index 2, mixer at index 3 and AFX at index 4. Input flags were independently correlated on 2026-09-30 and now drive their own WebUI buttons. Historical digital-button traffic used incorrect selectors; those writes did not establish the intended domain.

    Re-verified independently (2026-08) against the raw all_reports_ch-link-gain-ph-inv-test.tsv: every report in that capture has first byte 0x70, 0x73, or 0x75 -- no other magic byte shows up anywhere in the session, so there's no hidden fourth report type carrying a link flag either. The capture toggled pair 0 (ch1&ch2) on/off/on/off (4 0x70/0x14/0xa2 frames, pair_index always 0x00); diffing the 0x73 state report from immediately before to immediately after each of those 4 commands shows zero changed bytes every time -- consistent with (not just "not yet found", but actively re-confirmed for this session's pair-0 toggles) there being no readback bit. Separately, the live gain-mirroring behavior itself is real and fast: sweeping ch0's gain while linked produces a matching ch1 state-report update about 8ms later, in lockstep, for the whole sweep -- this is what set-link's new before/after check (below) leans on.

    set-link still does an indirect confirmation. The CLI has not yet adopted the confirmed 0x0b:0 flag for its own verification; set-link ... on snapshots gain/phantom/phase_invert for both channels in the pair before and after sending the command. If they disagreed before and agree after, that's real (if indirect) evidence the link engaged, grounded in the confirmed mirroring behavior above -- not a guess. If they already agreed before (nothing to observe changing), or still disagree after, the CLI says so plainly instead of claiming confirmation it doesn't have. set-link ... off still can't be confirmed this way (disengaging has no known observable effect on its own).

    status now shows a link indicator, e.g.:

     0  mic     0dB   on   off -.
     1  mic     0dB   on   off -'   (linked, pair 0 -- CLI-tracked, not device-confirmed)
    

    This is a small local cache (~/.cache/antelope-ctl/link_state_<vid>_<pid>.json) of the last set-link command this CLI has sent for each pair. The newly mapped 0x0b table is not yet a verified control-state source: the earlier whole- report diff did not query category 0x0b, and the manager log mostly shows zero-valued responses. The cache therefore remains the safe fallback and can go stale if link state changes some other way (the Launcher, another instance, or a power cycle). Treat the markers as "what I last told the device" until the transition capture is done.

    Channel link is real, but the syncing you see isn't the device doing it (confirmed 2026-08, real hardware). Using an earlier version of this CLI to send only the raw SET_LINK frame did engage a real link flag -- visible on the Orion's own front-panel control, so the hardware itself really is tracking "linked" -- but each channel kept whatever mode/gain it already had. Linking ch1 (mic, 20dB) and ch2 (line, 10dB) through the Launcher app snaps ch2 to mic/20dB immediately; doing the exact same SET_LINK frame from the CLI alone did not. Re-examining the outgoing frames in all_reports_ch-link-gain-ph-inv-test.tsv explains why: every gain change made while linked in that capture shows two separate outgoing SET_PARAM(gain=0x50, channel, value) frames (channel 0, then channel 1, ~2ms apart, identical value) -- not one command with an observed two-channel effect. The "live sync" documented above is the Launcher software choosing to send two commands every time, not the device firmware fanning one write out to both channels. Cleanest proof (user, 2026-08): turning the physical gain wheel on a linked channel moves only that channel -- the wheel bypasses all host software, so the device plainly has no link-propagation logic, and the Launcher only mirrors changes it makes in its own UI. profiles/orion_studio_sc.json's params.channel_link.side_effects and .live_sync_while_linked notes have been updated to say this plainly, so nobody re-discovers it as a "CLI bug" later.

    cli.py now replicates the Launcher's behavior itself:

    • set-link ... on immediately pushes the higher-numbered channel's mode/gain/phantom/phase_invert to match the lower one's (only sending a command for fields that actually differ, mode-before-gain-before-phantom to respect real constraints -- see _push_full_sync).
    • From then on, for as long as this CLI's local cache says the pair is linked, set-gain/set-phantom/set-invert on either channel also sends the same command to its partner (mirroring what the Launcher's two-frames-per-change behavior does). set-mode on a linked channel is refused (--force to override) -- mode genuinely can't be changed on a linked pair on real hardware; the confirmed workflow is still unlink -> set mode independently -> re-link.
    • This mirroring is driven entirely by the local cache, not a device fact. If you link a pair through the official Launcher instead of this CLI, this CLI won't know and won't mirror -- use the new mark-link <channel> on/off command to tell it (updates only the cache, sends nothing to the device) so set-gain/etc. mirror correctly afterward.
  • Resolved (2026-08; endpoint semantics confirmed on the device 2026-09-14): the "unexplained" mute status bit that flipped on by itself when bus_level hit 96 in the monitor-A capture is real and reproducible -- settings-linevol-mute-reampvol-toggle sweeps buses 3 and 4 to the silent endpoint repeatedly and the 0x04 bit tracks level == 96 exactly, every pass. So the bit means "muted or at the silent endpoint"; a bus-status reader must special-case level == 96. The connected device was read at both endpoints: Monitor A reported raw 0 at maximum volume and raw 96 at minimum volume. See params.bus_mute and unresolved_state_offsets in the profile.

  • A single-byte state-report change at offsets 17 and 19 (seen once in the original vumeter-test-ch1 session) turned up again in two later, otherwise-unrelated captures, always ~3.0-3.06s from the start of the capture and with no link/unlink action anywhere nearby -- this rules out "link-state readback" as the explanation and points instead to a periodic or connection-startup event. Still not deliberately isolated, so it stays in unresolved_state_offsets.

As of the 2026-08 ADAT test (adat-ch1-2-3-12-link12 capture):

  • ADAT gain is confirmed, including the outgoing param_id: param_id 0x5b, channel_offset = adat_channel_index (0-indexed, ADAT ch1=0 ... ch16=15), range -6..+12 dB matching the UI. Readback lives at adat_gain_base_offset = 75, one byte per channel (confirmed for channels 1, 2, 3, and 12 in this capture). params.adat_gain.id is now 0x5b -- distinct from physical-channel gain's 0x50, as suspected.
  • ADAT UI link frames were captured, with pair indices encoded as adat_channel_index // 2 over the 16-channel space (eight pairs, indices 0-7). This confirms what the UI sent, not that the device engaged an ADAT link. The old interpretation of the six Preamp readback records as ADAT state was incorrect. The UI's paired gain writes show software mirroring, not device-side ADAT link behavior.

As of the follow-up 2026-08 ADAT-link1-2-7-8 capture (activate link1, link2, link7, link8 in order; sweep gain on ch1/ch3/ch13/ch15; deactivate link8, link7, link2, link1):

  • Confirms the UI's pair-index encoding, not device ADAT link state. link1/2/7/8 sent pair_index 0x00/0x01/0x06/0x07 respectively, each followed by software gain-sync writes for the matching pair. ADAT has eight pairs; the six 0x0b:0 records belong to the six physical Preamp pairs and cannot confirm any ADAT pair. The captured ON/OFF frames show command traffic only; they do not verify an ADAT link took effect.
  • A momentary missed-sync frame was observed and is noted but not investigated further (per instruction): while sweeping ch1's gain down, one step (ch1 -> -6dB) has no matching mirrored command for ch2, which stayed at -4dB. Looked like a one-off dropped command on the wire rather than a parsing artifact. Worth another look if it recurs during real use.
  • Historical ADAT link mapping error (superseded 2026-09-30). The physical-pair-0-ON frame (ch-link-on-off.pcapng) and the ADAT-pair-0-ON frame (ADAT-link1-2-7-8.pcapng) were compared byte-for-byte across all 320 bytes plus the USB metadata (endpoint 0x01, device address 2, interface, direction): zero differences. The six 0x0b:0 records are the six physical Preamp pairs, covering the device's 12 physical inputs. ADAT has eight pairs and is not reported by that table. The earlier project mapping treated the Preamp table as ADAT readback and was incorrect. Identical wire frames alone do not establish that an ADAT link was engaged or that one command affects both domains.

Talkback (confirmed, 2026-08 -- not yet in the CLI)

Three captures (talkback-bttn, talkback-select, talkback-gain-int-ch1-2-12) pinned down the whole talkback feature. It is protocol-confirmed but deliberately not wired into cli.py yet (protocol-first; add commands later if wanted). Full details in profiles/orion_studio_sc.json -- frame.global_command, state_report.talkback_block, and the four talkback_* params. Summary:

Control opcode / param payload readback (0x73 report)
Talkback button (hold-to-talk) 0x12 / 0x1f value@17: 1=press, 0=release offset 73, bit 6 (0x40)
Talkback source 0x12 / 0x27 index@17: 0 = INT (built-in talkback mic, the one behind the unit's physical TB button), 1-12 = preamps 1-12 offset 73, bits 0-1 only (source & 3)
Talkback gain (per source) 0x12 / 0x20 value@17: raw 0-96 (dB direction not independently confirmed) offset 74 (selected source's gain)
Talkback destination assign 0x13 / 0x5d dest 0-3 @17, on/off @18 offset 73, bits 2-5 (dest N = bit N+2)

Key takeaways:

  • New opcode 0x12 (global_command) -- for global params with no target; value goes at offset 17, not 18. See "Adding a new param" above.
  • Talkback gain is per-source. Select a source, then set its gain; offset 74 always shows the currently selected source's value.
  • Source readback is incomplete -- only the low 2 bits of the source index appear (offset 73 bits 0-1). Track the full 0-12 value client-side.
  • Untested overlap: offset-73 bits 2-3 are shared between the source index (values 4-12) and the dest-assign bitmask (dest0/dest1). The three captures never set a high source index and a destination at the same time, so how the packed byte behaves with talkback fully configured is still unverified -- one combined capture would close this.
  • Destinations = Monitor A / Monitor B / HP1 / HP2 (user-confirmed), four toggle buttons in the Monitors/Headphones menu -- not routing- matrix sources. They combine: talkback can go to all four at once (the bitmask holds multiple bits). Exact index→name order (0-3) isn't pinned down; likely 0=Mon A, 1=Mon B, 2=HP1, 3=HP2.

Output trim (confirmed -- not yet in the CLI)

settings-trim-mona-monb-line-panlaw pinned down the settings-tab output trim selectors: SET_PARAM(0x4b, target, value), target 0/1/2 = Monitor A / Monitor B / Line Out, value a 7-position selector. It's an output-reference-level selector, 1 dBu per step: 0 = 20 dBu … 6 = 14 dBu (user-confirmed against the Launcher). Readback is packed into two bytes just before bus_block: target 0 at offset 24 (value << 4), target 1 at offset 25 bits 2-4 (value << 2), target 2 at offset 25 bits 5-7 (value << 5). Re-confirmed live on the device 2026-09-03 -- swept all three targets, the readback tracked value << shift exactly, restored. See params.output_trim and state_report.output_trim_block.

  • Pan law — DECODED 2026-09-03 (panning-law-6-3-45-0): it is not 0x4b target 3 (ruled out) but its own SET_GLOBAL param 0x24, value 0=-6 dB 1=-3 dB 2=-4.5 dB 3=0 dB (Launcher button order). No device readback. CLI pan-law [--set N].
  • Readback category 0x16 is the mic-emulation state table: one outer record containing eight {target, emu_model, ch_swap, pattern} records. It is unrelated to trim and pan law; the CLI/WebUI expose it as a read-only structured response.

Clock source -- DECODED 2026-09-03

clock-source-WC-adat-adatx2-adatx4-spdif-usb-oven: SET_GLOBAL param 0x04, value 0=Oven (internal) 1=Word Clock 2=ADAT 3=ADAT x2 4=ADAT x4 5=S/PDIF 6=USB. Readback = 0x73 offset 19, 1:1 — which also finally explains the "0x73 @19 blip ~3 s after connect" seen in every capture (the device re-locking from USB to its saved source at startup). CLI clock-source [--set N].

Screen brightness -- CONFIRMED, real device command (2026-08, native macOS)

The VM capture showed nothing for the brightness slider, which had it filed as host-side. Wrong -- the VM Launcher just no-ops the slider. Recaptured on native macOS (macos-scrbrght-0-100-50-multvalue) with the device's physical screen visibly changing:

  • Command: opcode 0x12 (global), param 0x0e, value 0-100 (0x00-0x64) at payload offset 17.
  • Readback: 0x73 state report offset 26 = the value, exactly, on all 25 commands.
  • CLI: set-brightness <0-100> (via protocol.build_global_command, the first SET_GLOBAL builder -- talkback params can use it next). Hardware-confirmed: the CLI command changes the device's physical screen. See params.screen_brightness.

Lesson: "zero frames under the VM" ≠ host-side. The VM Launcher silently drops some controls. Re-check on native macOS before concluding.

Sample rate -- CONFIRMED (2026-08, native macOS)

macos-smplrt-32k-44k1-48-88k2-96k-176k4-192k: stepping every rate sent opcode 0x12 (global), param 0x03, an index 0-6 at offset 17 (0 = 32000, 1 = 44100, 2 = 48000, 3 = 88200, 4 = 96000, 5 = 176400, 6 = 192000). Readback is 0x73 offset 18, tracking ~1 s behind each command (clock re-lock). CLI: sample-rate / set-sample-rate <hz> (params.sample_rate). Hardware round-trip verified 2026-09-04 across every rate 44.1k-192k. Offsets 21-23 are the same rate in Hz (24-bit big-endian) and 27 is the rate family (0x10 >> [21]: base / 2x / 4x) -- both decoded in that sweep; sample-rate prints the measured Hz too.

Oscillator and surround tab -- decoded 2026-09-01; DC-coupling readback confirmed 2026-09-14

  • Oscillator / test-tone generator -- the settings-tab panel is SET_GLOBAL (0x12), param 0x0a, one packed byte @17: 0x01/0x04 = osc 1/2 frequency, bits 4-5 (0x30) = level (0 / -6 / -12 / -18 dBFS), 0x40/0x80 = osc 1/2 mute. The earlier "sends zero frames" was an inbound-only capture. Separately, inserting an oscillator into an output is the 0x53 routing frame, source bank 0x0c (params.oscillator, params.routing).
  • DC-coupling -- SET_GLOBAL (0x12), param 0x26, value 0/1. A live WebUI check on 2026-09-14 confirmed device readback at 0x73 byte 93 bit 0: Off -> On moved 0x00 -> 0x01, and On -> Off restored 0x00. Talkback fast/normal/safe latency modes still send nothing (host-side) -- a trustworthy negative, since the same capture carried DC-coupling's OUT frames. Thunderbolt/buffer settings are probably host driver only, but that rests on a capture that cannot be trusted -- see PROTOCOL.md §11.
  • Surround monitoring tab -- two whole-state frames: 0xab/0xeb = global (EQ pre/post [18] bit 7, level, delay, format, per-speaker bypass/mute/dim masks, 2.0/2.1 bass-management window) and 0x87/0xea = per-speaker ×16 ([18] = speaker 0-15, level + polarity, delay, a full 16-band parametric EQ per speaker). Decoded 2026-09-03 from the srrnd-* captures. Both read back (found 2026-09-04): global = readback category 0x1b, per-speaker EQ = category 0x1a. The CLI decodes both readbacks, and the WebUI Surround tab polls both categories. Its 2.0/2.1 global delay/level and format paths perform a fresh read-modify-write; EQ PRE/POST is a confirmed one-bit global write. Mute and dim remain read-only. Speaker-monitor bypass and the per-speaker phase, delay, and level fields were hardware round-tripped on speakers 0 and 1 against fresh readbacks and restored exactly. The WebUI exposes those per-speaker controls alongside the EQ head controls. EQ knobs write one field at a time through a fresh read-modify-write; direct graph drags use the bounded /api/surround/eq/point path to update frequency and gain together in one complete record. Both paths preserve the rest of the state and perform a post-write readback. The graph supports damped point dragging and Q adjustment by wheel only while the pointer is over a point. The 2.0 Bass Management popup is writable for its L/R strips using the same bounded fresh-readback path as 2.1's L/R/LFE strips. tools/surround_eq_selftest.py can read all 16 records or, with explicit confirmation, probe one frequency, Q, gain, or raw mode byte in one selected band and restore the complete record. The per-speaker head mapping is now confirmed by the combined delay/level/phase readback probe. Room Correction turned out to be just the Launcher computing a curve host-side and writing it into that 0x87 per-speaker EQ -- no opcode, no toggle (params.surround_speaker is the RC interface). The global format wire path was subsequently round-tripped through 9.1.6 on the connected Orion Studio III; the WebUI still exposes only 2.0 / 2.1 until the licence-dependent vendor behavior is better understood. The Bass Management popup displays only the active strips; 2.1 is ordered L · R · LFE with fixed strip widths. Faders use the profile dB range for a reload-safe percentage position, retain the supplied artwork, accept exact values by double-click, and keep their travel inset from the readout and Mute/Solo row. Its bounded writes cover crossover cutoffs, filter order, bypass, fader, mute, filter type, link, and solo. Each field was toggled in both 2.0 and 2.1 and matched the immediate category-0x1b readback; distinct fader values also survived the 2.0/2.1 transitions. See params.surround_monitor + params.surround_speaker.

For a targeted hardware probe, stop the WebUI first so it releases the HID device, then run the dedicated self-test. It reads all speakers by default:

python3 tools/surround_eq_selftest.py

Write mode requires one speaker, one band, one field, an explicit value, and an acknowledgement of the 0x87 write path. It verifies the readback and restores the complete record:

python3 tools/surround_eq_selftest.py --write --speaker 0 --band 3 --parameter gain --value -1.00 --confirm-experimental-write

Frequency and Q use Hz and the Q value shown by the UI; gain uses dB. Mode is accepted only as a raw byte because its end-band labels are not fully proven.

The global EQ position can be independently read and round-tripped with:

python3 tools/surround_eq_position_selftest.py --write --confirm-experimental-write

The probe toggles PRE/POST, verifies category 0x1b, and restores the original complete global state.

The combined Surround control probe exercises 2.0/2.1 Bass Management faders, filter type, Link, Solo, and speaker delay/level/phase/bypass, then restores the saved global and speaker records:

python3 tools/surround_format_selftest.py --write \
  --test-bass-controls --test-speaker-controls \
  --confirm-format-write --confirm-surround-control-write

Connect handshake & routing readback -- resolved (2026-08, native macOS)

Four native-macOS captures of the Launcher connecting to the device (captures/macos-captures/macos-antelopeINIT-*): device powered fully off, Launcher quit, start recording, launch Launcher, power device on. Two of them (...poweroff-on2-itsavedstate, ...poweroff-on3-itsavedstate) were recorded with deliberately different LineOut routing -- preamp 1-12 vs computer-playback, swapped between the two.

  • The whole host->device connect handshake is one frame: SET_PARAM(param 0x49, channel 1, value 0). Present in all four macOS captures and in the older Windows AntelopeINIT.tsv -- cross-platform confirmed. The Launcher also sends the 0x74 topology/readback query burst; the device answers those queries with 0x75 responses while normal 0x73/0x75 polling continues. In on2 the Launcher also sent a short SET_PARAM(gain 0x50) sequence -- the user nudging gain sliders to force the buggy Launcher to flush state, not handshake or device behaviour.
  • The routing readback is present at connect. The 0x74 routing queries are identical between the on2/on3 captures, but their corresponding 0x75 category-0x03 responses contain the deliberately different routes. Earlier analysis compared the requests and filtered the responses as meter traffic. The USB descriptors are identical, the final 0x73 state report differs only in one preamp-gain byte and meter noise, and there is no 0x53 frame in either direction. So the readback fires later -- prime suspect: opening the routing-matrix tab (these captures only watched the connect).
  • macOS capture format: Darwin "XHC" pcapng wraps each 320-byte vendor HID report in a 40-byte pseudo-header (frame.len == 360); payload byte 0 is the usual magic. tools/scan_capture.py only reads the Windows TSV layout -- use tools/scan_macos_capture.py for these (see CAPTURING.md).

Current meter status (2026-09-12)

Physical-input raw meters use 0x73 full-report bytes 221–232. Their dBFS calibration and separate clip indication remain unknown; the old 0x75 curve below does not apply to this bank.

Mixer windows expose a selector-gated 32-strip meter bank. The live restore-safe test confirmed all four mixer-window selections: SET_PARAM(0x49, target 1, value 0..3) gates 0x73 @157..188 (strip N is @156+N), while the separate Meters-window selections 21..24 gate a second copy at @125..156 (strip N is @124+N). The selectors read back at 0x73[122] and [121] respectively.

Surround meters are now mapped. A restore-safe direct-HID sweep temporarily selected the already-probed 9.1.6 layout, routed Oscillator 1 directly to each of the 16 Surround In channels, and read only free-running 0x75 reports with byte [1] = 0x1f. Both selector 25 (Surround In) and selector 9 (Surround Out) expose the same primary one-byte bank: Surround channel N is at full-report offset @33 + N, or @34..@49 for channels 1..16. Raw 96 is silence and active test samples reached approximately raw 18; this is not yet a calibrated dB scale. The 0x73 virtual-mixer meters were excluded. Weaker changes around @50..@52 remain unresolved auxiliary candidates and are not part of the per-channel map. The original format, routing, selector, and oscillator mute state were restored after the test.

The physical Preamp 1 tone was observed at 0x73 @221 (raw 18, reaching 0 at louder moments); the other physical preamps, emuMic, Computer Playback, ADAT In, and S/PDIF In still need their corresponding live sources. A separate top-level-tab capture contains only device-to-host reports: no HID OUT command, no 0x49 selector transition, and fixed selector bytes 0x73[121]=22, [122]=0. It therefore does not prove that ADAT metering is independent; ADAT input must be tested with a signal, ideally in a capture that includes both OUT and IN traffic. See PROTOCOL.md §9 and the profile.

The live route tests for Line Out, HP1, HP2, Monitor A/B, Reamp, ADAT Out, and S/PDIF Out found no stable meter lane in the HID reports; host-side isochronous-audio metering remains possible. This is distinct from the mixer-window selector at [122].

Historical meter interpretation (superseded for physical inputs)

The meter report (magic 0x75) originally had one candidate offset: frame.meter_report.channel_meter_base_offset = 32, one byte per channel (same ordering as gain/status), scale inverted -- resting near 0x60 (96) and falling toward 0x00 as the input gets louder. That came from a single correlated capture (scream into ch1 while it was hot, watch byte 32 dive to 0 and climb back), not the capture-correlate-confirm-repeat discipline the rest of the profile follows.

As of the 2026-08 vumeter-sinewave session, channel 0's offset and scale are confirmed, with a real raw-byte -> dBFS calibration (frame.meter_report.db_curve) to go with it. Method: feed a steady 1kHz sine into ch0 (a tone sweeps more predictably than white noise) and sweep gain down from its max instead of touching the source level, watching the Launcher's own on-screen meter. The useful trick: click phase_invert on/off as a marker after each reference point -- it's a real SET_PARAM command, so it shows up as an unambiguous, precisely-timestamped status_base_offset bit flip in the very same state-report capture, with no need for a second outgoing-frame capture just to bookmark timestamps. Two swipes were done this way: one by color (clip, then where orange stops appearing, then where yellow stops appearing = -12dB) and one by absolute dB steps read off the Launcher meter (clip, -10, -20, -30, -40, -60). The reconstructed raw meter byte at each marker tracks the target dB exactly 1:1, raw == -dB, across the whole sweep from 0 to -60 -- see db_curve and its notes for the full point list and one leftover oddity (a stray marker click at raw=72 that doesn't fit either swipe's sequence or the line at all, presumably an incidental click made while resetting gain between swipes).

The same session also pinned down the meter LED color thresholds in frame.meter_report.led_scale: yellow/green at exactly -12dB (both stated directly and matching the measured raw=12 point), and orange/yellow at -4dB (matching two independent estimates -- the captured marker's raw=4, and the user's own LED-segment count). There's no separate solid-red band below clip -- orange runs straight through to 0dB, and red only appears at clip itself.

All 12 channels' meter offsets are now confirmed working (2026-08 -- each channel's meter tracks its own signal correctly). db_curve is still from the channel-0-only sweep above and is applied to every channel for lack of a per-channel one -- reasonable estimate, not independently verified past channel 0. Don't build anything on the deep-silence tail past -60dB either -- it isn't swept yet, and the stray raw=72 point above hints it may not stay linear that far down. Try python3 -m antelope.cli --profile profiles/orion_studio_sc.json meter while making noise into a channel to see it live.

Capture format matters. Wireshark's plain-text/"Copy as Text" export only exposes ~111 of each 320-byte report's bytes, which is why early raw-byte meter claims remained unconfirmed. Always use tshark -x, or better, the whole-session -T fields dump described in CAPTURING.md (that's what all of the mona/monb/hp1/hp2/chlink captures used, and it's also what let tools/scan_capture.py find every transition automatically instead of hand-picking frame numbers).

About

This is an Antelope Control App implementation on linux for Orion Studio Synergy Core, could add more devices in the future

Resources

Stars

8 stars

Watchers

4 watching

Forks

Releases

Packages

Contributors

Languages