From f3bd06bd91a15faa82f36ed881af808112f86a4d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lucas=20M=C3=B3dolo?= Date: Fri, 9 Oct 2026 17:59:44 -0400 Subject: [PATCH 1/2] feat(audeze): implement the real Maxwell protocol and add custom EQ Replace the fixed, captured packets with an implementation of the protocol the headset actually speaks: requests are built from command, key and value, split into HID reports, and matched against the reply the headset sends back. Errors reported by the headset now reach the user instead of being ignored. Maxwell and Maxwell 2 share this code through a new AudezeMaxwellDevice base class, so each device file only holds what differs between them. Add custom equalizer support for the original Maxwell. The ten band gains (-12 to 12 dB, whole steps) are written to the selected custom preset, applied immediately and stored on the headset. Also: - fix sidetone being toggled on every call instead of set - report the current sidetone level - round inactive time up to the nearest value the headset supports - add protocol tests, including EQ coefficient reference vectors --- README.md | 6 +- lib/devices/audeze_maxwell.hpp | 721 ++++++++++++++++---------- lib/devices/audeze_maxwell2.hpp | 400 +------------- lib/devices/audeze_maxwell_device.hpp | 651 +++++++++++++++++++++++ tests/test_protocols.cpp | 563 ++++++++++++++++++++ 5 files changed, 1675 insertions(+), 666 deletions(-) create mode 100644 lib/devices/audeze_maxwell_device.hpp diff --git a/README.md b/README.md index f5bf269d..53c10628 100644 --- a/README.md +++ b/README.md @@ -187,8 +187,8 @@ sudo udevadm control --reload-rules && sudo udevadm trigger | HyperX Cloud 3 | All | x | | | | | | | | | | | | | | | | | | | ROCCAT Elo 7.1 Air | All | | | | x | x | | | | | | | | | | | | | | | ROCCAT Elo 7.1 USB | All | | | | x | | | | | | | | | | | | | | | -| Audeze Maxwell | All | x | x | | | x | x | x | | x | | | | | x | | | x | | -| Audeze Maxwell 2 | All | x | x | | | x | x | x | | x | | | | | | | | x | | +| Audeze Maxwell | All | x | x | | | x | x | x | | x | x | | | | x | | | x | x | +| Audeze Maxwell 2 | All | x | x | | | x | x | x | | x | | | | | | | | x | x | | Lenovo Wireless VoIP Headset | All | x | x | | | x | | x | x | x | | | x | | x | | | | | | Plantronics Voyager 8200 UC (BT600) | L/W | x | x | | x | | | x | | | | | | | x | | | | | | Jabra Link 390 (paired headset) | L/M | x | x | | x | x | | x | | | | | | | x | | | | x | @@ -200,6 +200,8 @@ sudo udevadm control --reload-rules && sudo udevadm trigger **Platform:** All = Linux, macOS, Windows | L/M = Linux and macOS only | L/W = Linux and Windows only +Original Audeze Maxwell custom EQ edits the selected custom preset (`-p 6` through `-p 9`), with ten whole-dB gains from -12 to +12. Close Audeze before use. + \* Only available on some product variants of that device. Sidetone status reading, for instance, is verified only for the SteelSeries Arctis Nova 7 Gen 2 (`1038:227e`). > **Note:** Some Corsair headsets may need additional configuration - see [Adding a Corsair device](docs/ADDING_A_CORSAIR_DEVICE.md). Some headsets (HS80, HS70 wired, RGB Elite, Virtuoso) expose sidetone via ALSA mixer instead. diff --git a/lib/devices/audeze_maxwell.hpp b/lib/devices/audeze_maxwell.hpp index 0b70820f..eabd71e9 100644 --- a/lib/devices/audeze_maxwell.hpp +++ b/lib/devices/audeze_maxwell.hpp @@ -1,11 +1,19 @@ #pragma once -#include "../result_types.hpp" -#include "hid_device.hpp" +#include "audeze_maxwell_device.hpp" +#include #include +#include #include +#include +#include +#include +#include +#include +#include +#include #include -#include +#include using namespace std::string_view_literals; @@ -15,59 +23,52 @@ namespace headsetcontrol { * @brief Audeze Maxwell Gaming Headset * * Features: - * - Sidetone (32 levels) + * - Sidetone (level read from the headset, 32 levels by default) * - Inactive time with discrete levels * - Volume limiter * - Equalizer presets (10 total: 6 default + 4 custom) + * - Custom EQ (ten whole-dB gains, edits the selected custom preset) * - Battery status * - Chatmix * - Voice prompts * - Mic noise filter * - * Special Requirements: - * - 14-packet initialization sequence followed by 6 status requests - * - 60ms delay between packets (official software sends at 52ms but 60ms is more reliable in testing) - * - Uses hid_get_input_report for responses + * The protocol is implemented in audeze_maxwell_device.hpp. The custom EQ adds these + * commands: + * + * read stored data 0C 0A E8 03 -> 5B: 0C 0A + * reclaim storage 03 0A -> 5B: 03 0A + * store data 0D 0A -> 5B: 0D 0A + * apply to the DSP 03 0E 00 -> 5B: 03 0E + * + * and are 16-bit, low byte first, and is 00 on success. The + * byte in the reclaim reply is not a status; the dongle sends 01 in normal operation. + * + * Each custom preset has two stored items: the settings the Audeze app shows + * (EF00 + slot) and the DSP coefficients computed from them (E42C + slot). */ -class AudezeMaxwell : public HIDDevice { +class AudezeMaxwell : public AudezeMaxwellDevice { public: static constexpr std::array SUPPORTED_PRODUCT_IDS { 0x4b19, // Maxwell 0x4b18, // Maxwell Xbox Dongle }; - static constexpr int MSG_SIZE = 62; - static constexpr int REPORT_ID = 0x06; - static constexpr int DELAY_US = 60000; // 60ms - - // 14-packet initialization sequence - static constexpr std::array, 14> UNIQUE_REQUESTS { { { 0x06, 0x08, 0x80, 0x05, 0x5A, 0x04, 0x00, 0x01, 0x09, 0x20 }, - { 0x06, 0x08, 0x80, 0x05, 0x5A, 0x04, 0x00, 0x01, 0x09, 0x25 }, - { 0x06, 0x07, 0x80, 0x05, 0x5A, 0x03, 0x00, 0x07, 0x1C }, - { 0x06, 0x08, 0x80, 0x05, 0x5A, 0x04, 0x00, 0x01, 0x09, 0x28 }, - { 0x06, 0x08, 0x80, 0x05, 0x5A, 0x04, 0x00, 0x83, 0x2C, 0x01 }, - { 0x06, 0x08, 0x80, 0x05, 0x5A, 0x04, 0x00, 0x83, 0x2C, 0x07 }, - { 0x06, 0x07, 0x00, 0x05, 0x5A, 0x03, 0x00, 0x07, 0x1C }, - { 0x06, 0x08, 0x80, 0x05, 0x5A, 0x04, 0x00, 0x01, 0x09, 0x2D }, - { 0x06, 0x08, 0x80, 0x05, 0x5A, 0x04, 0x00, 0x01, 0x09, 0x2C }, - { 0x06, 0x08, 0x80, 0x05, 0x5A, 0x04, 0x00, 0x01, 0x09 }, - { 0x06, 0x08, 0x80, 0x05, 0x5A, 0x04, 0x00, 0x83, 0x2C, 0x0B }, - { 0x06, 0x08, 0x80, 0x05, 0x5A, 0x04, 0x00, 0x01, 0x09, 0x24 }, - { 0x06, 0x08, 0x80, 0x05, 0x5A, 0x04, 0x00, 0x01, 0x09, 0x2F }, - { 0x06, 0x07, 0x80, 0x05, 0x5A, 0x03, 0x00, 0xD6, 0x0C } } }; - - // 6 packet status request sequence - static constexpr std::array, 6> STATUS_REQUESTS { { { 0x06, 0x08, 0x80, 0x05, 0x5A, 0x04, 0x00, 0x01, 0x09, 0x22 }, - { 0x06, 0x08, 0x80, 0x05, 0x5A, 0x04, 0x00, 0x01, 0x09 }, - { 0x06, 0x08, 0x80, 0x05, 0x5A, 0x04, 0x00, 0x83, 0x2C, 0x0B }, - { 0x06, 0x08, 0x80, 0x05, 0x5A, 0x04, 0x00, 0x01, 0x09, 0x24 }, - { 0x06, 0x08, 0x80, 0x05, 0x5A, 0x04, 0x00, 0x01, 0x09, 0x2C }, - { 0x06, 0x08, 0x80, 0x05, 0x5A, 0x04, 0x00, 0x83, 0x2C, 0x07 } } }; - - constexpr uint16_t getVendorId() const override - { - return 0x3329; // Audeze - } + static constexpr uint8_t VOLUME_LIMITER_ON = 0x88; + static constexpr uint8_t VOLUME_LIMITER_OFF = 0x8E; + + // Presets as the headset numbers them (1-10); the last four are the custom ones + static constexpr uint8_t FIRST_CUSTOM_PRESET = 7; + static constexpr uint8_t LAST_CUSTOM_PRESET = 10; + + static constexpr Command CMD_EQ_APPLY { 0x03, 0x0E }; + static constexpr Command CMD_STORAGE_RECLAIM { 0x03, 0x0A }; + static constexpr Command CMD_STORAGE_READ { 0x0C, 0x0A }; + static constexpr Command CMD_STORAGE_WRITE { 0x0D, 0x0A }; + + static constexpr uint16_t STORAGE_KEY_EQ_SETTINGS = 0xEF00; + static constexpr uint16_t STORAGE_KEY_EQ_COEFFICIENTS = 0xE42C; + static constexpr uint16_t STORAGE_READ_MAX_LENGTH = 1000; std::vector getProductIds() const override { @@ -83,7 +84,7 @@ class AudezeMaxwell : public HIDDevice { { return B(CAP_SIDETONE) | B(CAP_INACTIVE_TIME) | B(CAP_VOLUME_LIMITER) | B(CAP_EQUALIZER_PRESET) | B(CAP_BATTERY_STATUS) | B(CAP_CHATMIX_STATUS) - | B(CAP_VOICE_PROMPTS) | B(CAP_NOISE_FILTER); + | B(CAP_VOICE_PROMPTS) | B(CAP_NOISE_FILTER) | B(CAP_SIDETONE_STATUS) | B(CAP_EQUALIZER); } constexpr capability_detail getCapabilityDetail([[maybe_unused]] enum capabilities cap) const override @@ -91,305 +92,487 @@ class AudezeMaxwell : public HIDDevice { return { .usagepage = 0xff13, .usageid = 0x1, .interface_id = 0 }; } - static constexpr int EQUALIZER_PRESETS_COUNT = 10; + Result setVolumeLimiter(hid_device* device_handle, bool enabled) override + { + if (auto result = writeKey(device_handle, KEY_VOLUME_LIMITER, enabled ? VOLUME_LIMITER_ON : VOLUME_LIMITER_OFF); !result) { + return result.error(); + } + + return VolumeLimiterResult { .enabled = enabled }; + } - uint8_t getEqualizerPresetsCount() const override + std::optional getEqualizerInfo() const override { - return EQUALIZER_PRESETS_COUNT; + return EqualizerInfo { + .bands_count = static_cast(EQ_FREQUENCIES.size()), + .bands_baseline = 0, + .bands_step = 1.0f, + .bands_min = static_cast(EQ_GAIN_MIN), + .bands_max = static_cast(EQ_GAIN_MAX) + }; } -private: /** - * @brief Send request and get input report with 60ms delay + * @brief Change the gains of the custom preset that is currently selected * - * Audeze HQ sends packets at ~60ms intervals. Sending too quickly causes issues. + * Follows Audeze.exe writeEqBands: apply the new coefficients, store the settings, + * store the coefficients. Each command is sent exactly once. A later failure can + * leave the active EQ or one stored item changed, so it is reported and never + * presented as an atomic update. Both stored items are read back at the end. */ - Result sendGetInputReport(hid_device* device_handle, std::span data, std::span buff = {}) const + Result setEqualizer(hid_device* device_handle, const EqualizerSettings& settings) override { - std::this_thread::sleep_for(std::chrono::microseconds(DELAY_US)); + if (settings.bands.size() != EQ_FREQUENCIES.size() || !std::ranges::all_of(settings.bands, isSupportedEqGain)) { + return DeviceError::invalidParameter("Maxwell EQ needs ten whole-dB gains from -12 to 12"); + } - if (auto result = writeHID(device_handle, data, MSG_SIZE); !result) { - return result.error(); + auto slot = readSelectedCustomSlot(device_handle); + if (!slot) { + return slot.error(); } + const auto settings_key = static_cast(STORAGE_KEY_EQ_SETTINGS + *slot); + const auto coefficients_key = static_cast(STORAGE_KEY_EQ_COEFFICIENTS + *slot); - if (buff.empty()) { - std::array tempBuff { 0x7 }; - auto read_result = getInputReport(device_handle, tempBuff); - if (!read_result || *read_result != MSG_SIZE) { - return DeviceError::hidError("Failed to get input report"); - } - } else { - buff[0] = 0x7; - auto read_result = getInputReport(device_handle, buff); - if (!read_result || *read_result != MSG_SIZE) { - return DeviceError::hidError("Failed to get input report"); - } + // Only the gains are replaced; everything else the app stored is kept + auto stored_settings = readStoredData(device_handle, settings_key, EQ_SETTINGS_SIZE); + if (!stored_settings) { + return stored_settings.error(); + } + auto bands = readEqBands(*stored_settings); + if (!bands) { + return bands.error(); + } + for (size_t i = 0; i < bands->size(); ++i) { + (*bands)[i].gain = settings.bands[i]; + writeEqBandGain(*stored_settings, i, settings.bands[i]); + } + auto coefficients = eqCoefficients(*bands); + if (!coefficients) { + return coefficients.error(); } - return {}; + if (auto result = applyEq(device_handle, *coefficients); !result) { + return result.error(); + } + if (auto result = storeData(device_handle, settings_key, *stored_settings); !result) { + return result.error(); + } + if (auto result = storeData(device_handle, coefficients_key, *coefficients); !result) { + return result.error(); + } + + if (auto result = verifyStoredData(device_handle, settings_key, *stored_settings, "Maxwell EQ readback differs from the saved settings; update may be partial"); !result) { + return result.error(); + } + if (auto result = verifyStoredData(device_handle, coefficients_key, *coefficients, "Maxwell coefficient readback differs; EQ update may be partial"); !result) { + return result.error(); + } + return EqualizerResult {}; } +protected: + // Non-static so tests can shorten them + std::chrono::milliseconds eq_timeout_ { 1500 }; + std::chrono::milliseconds eq_fragment_interval_ { 20 }; + +private: + // Positions in the replies of the storage commands: 05 5B + static constexpr size_t STORAGE_STATUS_OFFSET = 6; + static constexpr size_t STORAGE_KEY_OFFSET = 7; + static constexpr size_t STORAGE_LENGTH_OFFSET = 9; + static constexpr size_t STORAGE_DATA_OFFSET = 11; + /** - * @brief Get device status (battery, chatmix, sidetone, mic mute) - * - * Must send all 6 status requests to get consistent information + * EQ requests are never repeated: storing twice is not known to be safe. The reports + * are paced because the wireless dongle can silently drop an unpaced burst of long + * command fragments (confirmed by live EQ apply/restore testing). */ - struct MaxwellStatus { - BatteryResult battery; - int chatmix_level; - int sidetone_level; - int eq_setting; - int ainf_level; - bool sidetone_enabled; - bool mic_muted; - }; - - Result getDeviceStatus(hid_device* device_handle) const + RequestOptions eqRequestOptions() const { - std::array, 6> status_buffs {}; + return { .attempts = 1, .timeout = eq_timeout_, .fragment_interval = eq_fragment_interval_ }; + } - // Send UNIQUE_REQUESTS packets - for (auto unique_request : UNIQUE_REQUESTS) { - if (auto result = sendGetInputReport(device_handle, unique_request); !result) { - return result.error(); - } + /// Slot (0-3) of the selected custom preset; an error if a built-in preset is selected + Result readSelectedCustomSlot(hid_device* device_handle) const + { + auto preset = readKey(device_handle, KEY_EQ_PRESET); + if (!preset) { + return preset.error(); } - - // Send STATUS_REQUESTS packets - for (int i = 0; i < 6; ++i) { - if (auto result = sendGetInputReport(device_handle, STATUS_REQUESTS[i], status_buffs[i]); !result) { - return result.error(); - } + if (*preset < FIRST_CUSTOM_PRESET || *preset > LAST_CUSTOM_PRESET) { + return DeviceError::invalidParameter("Select a Maxwell custom preset with -p 6..9 before editing EQ"); } + return static_cast(*preset - FIRST_CUSTOM_PRESET); + } - // Status_buffs structure - // status_buffs[0] = battery_status - // status_buffs[1] = mic_status - // status_buffs[2] = eq - // status_buffs[3] = chatmix - // status_buffs[4] = ainf_level - // status_buffs[5] = sidetone_level - - MaxwellStatus status {}; - - const auto& buff = status_buffs[0]; - - // Initialize battery status as unavailable - status.battery.status = BATTERY_UNAVAILABLE; - status.battery.level_percent = -1; - - // Parse battery level - if (buff[0] == 0x00) { - status.battery.status = BATTERY_UNAVAILABLE; - status.battery.level_percent = -1; - } else { - // Find battery level in response (level is first byte after d6 0c 00 00 in packet) - for (int i = 0; i < MSG_SIZE - 4; ++i) { - if (buff[i] == 0xD6 && buff[i + 1] == 0x0C && buff[i + 2] == 0x00 && buff[i + 3] == 0x00) { - status.battery.level_percent = buff[i + 4]; - status.battery.status = BATTERY_AVAILABLE; - break; - } - } + Result readStoredData(hid_device* device_handle, uint16_t key, size_t expected_size) const + { + auto body = commandBody(CMD_STORAGE_READ); + appendLE16(body, key); + appendLE16(body, STORAGE_READ_MAX_LENGTH); + + // A reply too short to carry a key is accepted so it is reported as invalid below + auto reply = request( + device_handle, body, [key](const Message& message) { + return isMessage(message, TYPE_RESPONSE, CMD_STORAGE_READ) + && (message.size() < STORAGE_LENGTH_OFFSET || hasId(message, STORAGE_KEY_OFFSET, key)); + }, + eqRequestOptions()); + if (!reply) { + return reply.error(); } + if (reply->size() != STORAGE_DATA_OFFSET + expected_size + || (*reply)[STORAGE_STATUS_OFFSET] != STATUS_OK + || readLE16(*reply, STORAGE_LENGTH_OFFSET) != expected_size) { + return DeviceError::protocolError("Maxwell returned an invalid custom EQ slot"); + } + return Message(reply->begin() + STORAGE_DATA_OFFSET, reply->end()); + } - // Parse microphone mute status (FF = unmuted) - status.mic_muted = status_buffs[1][12] != 0xFF; - status.battery.mic_status = status.mic_muted ? MICROPHONE_UP : MICROPHONE_UNKNOWN; - - // Parse current eq setting - // 1 = Audeze, 2 = Treble Boost, 3 = Bass Boost, 4 = Immersive, 5 = Competition, - // 6 = Footsteps, 7 = EQ1, 8 = EQ2, 9 = EQ3, 10 = EQ4 - status.eq_setting = status_buffs[2][12]; - - // Parse chatmix (0-20 range, center at 10) - status.chatmix_level = map(status_buffs[3][12], 0, 20, 0, 128); + /// Read stored data back and compare it with what was written + Result verifyStoredData(hid_device* device_handle, uint16_t key, const Message& expected, std::string_view mismatch) const + { + auto stored = readStoredData(device_handle, key, expected.size()); + if (!stored) { + return stored.error(); + } + if (*stored != expected) { + return DeviceError::protocolError(mismatch); + } + return {}; + } - // Parse current mic noise filter level - // 0 = Off, 1 = Low, 2 = High - status.ainf_level = status_buffs[4][12]; + /** + * @brief Send an EQ command and check its acknowledgement + * + * @param key Storage key the acknowledgement has to repeat, if the command has one + * @param checked Whether byte 6 of the acknowledgement is a status that must be zero + */ + Result eqCommand(hid_device* device_handle, const Message& body, std::string_view operation, + std::optional key = std::nullopt, bool checked = true) const + { + const Command command { body[0], body[1] }; + const size_t reply_size = key ? STORAGE_KEY_OFFSET + 2 : STORAGE_STATUS_OFFSET + 1; + + auto reply = request( + device_handle, body, [command, key](const Message& message) { + return isMessage(message, TYPE_RESPONSE, command) + && (!key || message.size() < STORAGE_LENGTH_OFFSET || hasId(message, STORAGE_KEY_OFFSET, *key)); + }, + eqRequestOptions()); + if (!reply) { + auto error = reply.error(); + error.message = std::format("Maxwell {} failed: {}; EQ update may be partial. Close Audeze before retrying.", operation, error.message); + return error; + } + if (reply->size() != reply_size || (checked && (*reply)[STORAGE_STATUS_OFFSET] != STATUS_OK)) { + return DeviceError::protocolError(std::format("Maxwell rejected {}; EQ update may be partial", operation)); + } + return {}; + } - // Parse sidetone (0-31 range) - status.sidetone_level = map(status_buffs[5][12], 0, 31, 0, 128); - status.sidetone_enabled = status_buffs[5][12] != 0; + /// Make the DSP use new coefficients right away, without storing them + Result applyEq(hid_device* device_handle, std::span coefficients) const + { + auto body = commandBody(CMD_EQ_APPLY, { 0x00 }); + body.insert(body.end(), coefficients.begin(), coefficients.end()); + return eqCommand(device_handle, body, "apply EQ"); + } - return status; + Result reclaimStorage(hid_device* device_handle, size_t length) const + { + auto body = commandBody(CMD_STORAGE_RECLAIM); + appendLE16(body, static_cast(length)); + + // Unlike apply/save, Audeze's reclaim helper does not interpret byte 6 + // as a zero-success status (RVA 24740 / predicate 1B570). The dongle + // replies 01 here during normal operation. Framing and opcode are + // validated; the saves and the byte-for-byte readback catch the rest. + return eqCommand(device_handle, body, "prepare EQ storage", std::nullopt, false); } -public: - // Rich Results V2 API - Result getBattery(hid_device* device_handle) override + /// Store data under a key, reclaiming the space for it first as Audeze.exe does + Result storeData(hid_device* device_handle, uint16_t key, std::span data) const { - auto status_result = getDeviceStatus(device_handle); - if (!status_result) { - return status_result.error(); + if (auto result = reclaimStorage(device_handle, data.size()); !result) { + return result.error(); } - return status_result->battery; + auto body = commandBody(CMD_STORAGE_WRITE); + appendLE16(body, key); + body.insert(body.end(), data.begin(), data.end()); + return eqCommand(device_handle, body, "save EQ", key); } - Result setSidetone(hid_device* device_handle, uint8_t level) override - { - // Maxwell range: 0 to 31 - uint8_t mapped = map(level, 0, 128, 0, 31); + // ------------------------------------------------------------------------ + // Custom EQ data: the settings the Audeze app stores for a custom preset and + // the DSP coefficients computed from them. Static and without HID access, so + // it can be checked on its own. + // ------------------------------------------------------------------------ +public: + static constexpr std::array EQ_FREQUENCIES { 32, 64, 125, 250, 500, 1000, 2000, 4000, 8000, 16000 }; + + static constexpr double EQ_GAIN_MIN = -12; + static constexpr double EQ_GAIN_MAX = 12; + static constexpr double EQ_MAX_BANDWIDTH = 48000; + + // The stored settings are a header followed by one record per band. Only the + // fields below are interpreted; everything else is kept as it is. + static constexpr size_t EQ_HEADER_SIZE = 5; + static constexpr size_t EQ_BAND_RECORD_SIZE = 18; + static constexpr size_t EQ_BAND_FREQUENCY_OFFSET = 2; + static constexpr size_t EQ_BAND_GAIN_OFFSET = 6; + static constexpr size_t EQ_BAND_BANDWIDTH_OFFSET = 10; + static constexpr size_t EQ_SETTINGS_SIZE = EQ_HEADER_SIZE + EQ_BAND_RECORD_SIZE * EQ_FREQUENCIES.size(); + + struct EqBand { + double frequency; + double gain; + double bandwidth; + }; - std::array cmd { 0x06, 0x09, 0x80, 0x05, 0x5A, 0x05, 0x00, 0x00, 0x09, 0x2C, 0x00, mapped }; + /// True for the gains the app can produce: whole dB within the supported range + static bool isSupportedEqGain(double gain) + { + return std::isfinite(gain) && gain >= EQ_GAIN_MIN && gain <= EQ_GAIN_MAX && std::round(gain) == gain; + } - // Sidetone is enabled whenever level changes - auto result = sendGetInputReport(device_handle, cmd); - if (!result) { - return result.error(); + /// Parse the bands out of the stored settings + static Result> readEqBands(std::span settings) + { + if (settings.size() != EQ_SETTINGS_SIZE) { + return DeviceError::protocolError("Unexpected Maxwell custom EQ size (expected ten bands)"); } - - if (mapped == 0) { - // Toggle sidetone off - std::array toggle_cmd { 0x06, 0x09, 0x80, 0x05, 0x5A, 0x05, 0x00, 0x82, 0x2C, 0x07, 0x00, 0x00 }; - result = sendGetInputReport(device_handle, toggle_cmd); - if (!result) { - return result.error(); + std::vector bands; + for (size_t i = 0; i < EQ_FREQUENCIES.size(); ++i) { + const size_t record = eqBandOffset(i); + EqBand band { + .frequency = readEqBandValue(settings, record + EQ_BAND_FREQUENCY_OFFSET), + .gain = readEqBandValue(settings, record + EQ_BAND_GAIN_OFFSET), + .bandwidth = readEqBandValue(settings, record + EQ_BAND_BANDWIDTH_OFFSET) + }; + if (band.frequency != EQ_FREQUENCIES[i] || band.bandwidth <= 0 || band.bandwidth > EQ_MAX_BANDWIDTH) { + return DeviceError::protocolError("Unsupported Maxwell EQ band layout or bandwidth"); } + bands.push_back(band); } + return bands; + } - return SidetoneResult { - .current_level = level, - .min_level = 0, - .max_level = 128, - .device_min = 0, - .device_max = 31 - }; + /// Store a whole-dB gain in a band's record, leaving the rest of the settings untouched + static void writeEqBandGain(std::span settings, size_t band, double gain) + { + const auto hundredths = static_cast(gain * EQ_VALUES_PER_UNIT); + writeLE32(settings, eqBandOffset(band) + EQ_BAND_GAIN_OFFSET, static_cast(hundredths)); } - Result setInactiveTime(hid_device* device_handle, uint8_t minutes) override + /// Coefficient block for one sample rate: band count, five words per band, output gain + static Result eqCoefficientsForRate(std::span bands, int rate) { - std::array cmd { 0x06, 0x10, 0x80, 0x05, 0x5A, 0x0C, 0x00, 0x82, 0x2C, 0x01, 0x00 }; - - if (minutes > 0) { - // Round up to the nearest value the headset supports - uint16_t final_minutes = 360; - if (minutes <= 5) - final_minutes = 5; - else if (minutes <= 10) - final_minutes = 10; - else if (minutes <= 15) - final_minutes = 15; - else if (minutes <= 30) - final_minutes = 30; - else if (minutes <= 45) - final_minutes = 45; - else if (minutes <= 60) - final_minutes = 60; - else if (minutes <= 90) - final_minutes = 90; - else if (minutes <= 120) - final_minutes = 120; - else if (minutes <= 240) - final_minutes = 240; - - uint16_t seconds = final_minutes * 60; - - cmd[11] = 0x01; // Inactive time flag - cmd[13] = seconds & 0xFF; // LSB - cmd[14] = (seconds >> 8) & 0xFF; // MSB - cmd[15] = 0x01; // Constant flag - cmd[17] = cmd[13]; // LSB again - cmd[18] = cmd[14]; // MSB again - } - - auto result = sendGetInputReport(device_handle, cmd); - if (!result) { - return result.error(); + const bool known_rate = std::ranges::any_of(EQ_SAMPLE_RATES, [rate](const EqSampleRate& supported) { + return supported.rate == rate; + }); + if (bands.empty() || bands.size() > EQ_FREQUENCIES.size() || !known_rate) { + return DeviceError::invalidParameter("Invalid Maxwell EQ band count or sample rate"); + } + if (!std::ranges::all_of(bands, [rate](const EqBand& band) { return isSupportedEqBand(band, rate); })) { + return DeviceError::invalidParameter("Maxwell EQ requires finite whole-dB gains from -12 to 12"); } - return InactiveTimeResult { - .minutes = minutes, - .min_minutes = 0, - .max_minutes = 255 - }; - } + // The app uses integral dB gains. Its DLL orders descending gain, retaining + // input order for ties. Fractional gains are deliberately not exposed here. + std::vector ordered(bands.begin(), bands.end()); + std::stable_sort(ordered.begin(), ordered.end(), [](const EqBand& a, const EqBand& b) { return a.gain > b.gain; }); + std::vector filters; + for (const auto& band : ordered) { + filters.push_back(peakingFilter(band, rate)); + } - Result setVolumeLimiter(hid_device* device_handle, bool enabled) override - { - std::array cmd { 0x6, 0x9, 0x80, 0x5, 0x5a, 0x5, 0x0, 0x0, 0x9, 0x28, 0x0, enabled ? uint8_t(0x88) : uint8_t(0x8e) }; + const auto peaks = cascadePeaks(filters, rate); + const double peak = peaks.back(); + const double prescale = std::max(1.0, peak / EQ_STAGE_PEAK_LIMIT); + double scale_product = 1; + + Message result; + appendLE16(result, static_cast(bands.size())); + for (size_t i = 0; i < filters.size(); ++i) { + auto filter = filters[i]; + double scale = std::max({ std::abs(filter[COEF_B0]), std::abs(filter[COEF_B1] * 0.5), std::abs(filter[COEF_B2]) }); + const double first_scale = i == 0 ? prescale * EQ_FIRST_STAGE_MARGIN : 1.0; + if (peaks[i] / (scale_product * scale * first_scale) > EQ_STAGE_PEAK_LIMIT) { + scale = std::max(1.0, scale); + } + scale *= first_scale; + scale_product *= scale; - auto result = sendGetInputReport(device_handle, cmd); - if (!result) { - return result.error(); + // Only the feed-forward coefficients are scaled; all of them are rounded to 24 bits + for (size_t j = 0; j < filter.size(); ++j) { + if (j < COEF_A1) { + filter[j] /= scale; + } + const double quantum = hasHalfResolution(j) ? Q22 : Q23; + filter[j] = std::floor(std::clamp(filter[j] * quantum + 0.5, -Q23, Q23 - 1)) / quantum; + } + if (!isStableFilter(filter)) { + return DeviceError::invalidParameter("Maxwell EQ filter becomes unstable after quantization"); + } + for (size_t j = 0; j < filter.size(); ++j) { + appendDsp32(result, static_cast(filter[j] * (hasHalfResolution(j) ? Q30 : Q31))); + } } - return VolumeLimiterResult { .enabled = enabled }; + // Mode 1, requested rescaling 0 dB. Limit the overall peak, matching + // change_rescale_cofe. Gain is rounded at Q18 and stored at Q26. + const double gain = scale_product * std::min(1.0, EQ_OUTPUT_PEAK_LIMIT / peak); + if (!std::isfinite(gain) || gain <= 0 || gain >= 32) { + return DeviceError::invalidParameter("Maxwell EQ gain exceeds the DSP range"); + } + const auto fixed_gain = static_cast(std::floor(gain * Q18 + 0.5)); + if (fixed_gain > Q23 - 1) { + return DeviceError::invalidParameter("Maxwell EQ rounded gain exceeds the DSP range"); + } + appendDsp32(result, fixed_gain * 256); + return result; } - Result setVoicePrompts(hid_device* device_handle, bool enabled) override + /// Coefficient payload for all sample rates, as applied to the DSP and stored on the headset + static Result eqCoefficients(std::span bands) { - // Voice prompt level: 0 (0%) to 15 (100%), default 7 (50%) - // API doesn't support level, so use 7 when on, 0 when off - std::array cmd { 0x06, 0x09, 0x80, 0x05, 0x5A, 0x05, 0x00, 0x00, 0x09, 0x20, 0x00, enabled ? uint8_t(0x07) : uint8_t(0x00) }; - - auto result = sendGetInputReport(device_handle, cmd); - if (!result) { - return result.error(); + Message result { static_cast(EQ_SAMPLE_RATES.size()), 0, 0, 0 }; + for (const auto& sample_rate : EQ_SAMPLE_RATES) { + auto block = eqCoefficientsForRate(bands, sample_rate.rate); + if (!block) { + return block.error(); + } + appendLE16(result, sample_rate.id); + appendLE16(result, static_cast(block->size() / 2)); + result.insert(result.end(), block->begin(), block->end()); } + return result; + } - return VoicePromptsResult { .enabled = enabled }; +private: + // Band fields are signed 32-bit little endian values in hundredths (Hz or dB) + static constexpr double EQ_VALUE_SCALE = 0.01; + static constexpr int EQ_VALUES_PER_UNIT = 100; + + // Sample rates the headset needs coefficients for, with the ID each one has on the wire + struct EqSampleRate { + int rate; + uint8_t id; + }; + static constexpr std::array EQ_SAMPLE_RATES { { { 44100, 1 }, { 48000, 2 }, { 88200, 5 }, { 96000, 6 } } }; + + // Fixed point scales of the DSP + static constexpr double Q18 = 262144.0; + static constexpr double Q22 = 4194304.0; + static constexpr double Q23 = 8388608.0; + static constexpr double Q30 = 1073741824.0; + static constexpr double Q31 = 2147483648.0; + + // Headroom rules of AirohaPeqLibrary.dll + static constexpr double EQ_STAGE_PEAK_LIMIT = 4.0; + static constexpr double EQ_OUTPUT_PEAK_LIMIT = 8.0; // 18.06 dB + static constexpr double EQ_FIRST_STAGE_MARGIN = 1.19; + + // Biquad coefficients, normalised to a0 = 1 + enum EqCoefficient : size_t { + COEF_B0, + COEF_B1, + COEF_B2, + COEF_A1, + COEF_A2 + }; + using EqFilter = std::array; + + static uint32_t readLE32(std::span bytes, size_t offset) + { + return uint32_t(bytes[offset]) | (uint32_t(bytes[offset + 1]) << 8) + | (uint32_t(bytes[offset + 2]) << 16) | (uint32_t(bytes[offset + 3]) << 24); } - Result getChatmix(hid_device* device_handle) override + static void writeLE32(std::span bytes, size_t offset, uint32_t value) { - auto status_result = getDeviceStatus(device_handle); - if (!status_result) { - return status_result.error(); + for (size_t byte = 0; byte < 4; ++byte) { + bytes[offset + byte] = static_cast(value >> (8 * byte)); } + } - int level = status_result->chatmix_level; - - // Calculate percentages (0-20 range, 10 = center) - // level is already mapped to 0-128 - int game_pct = (level >= 64) ? 100 : map(level, 0, 64, 0, 100); - int chat_pct = (level <= 64) ? 100 : map(level, 64, 128, 100, 0); + // DSP words are high halfword first, with each halfword little-endian. + static void appendDsp32(Message& bytes, int32_t value) + { + const auto bits = static_cast(value); + appendLE16(bytes, static_cast(bits >> 16)); + appendLE16(bytes, static_cast(bits)); + } - return ChatmixResult { - .level = level, - .game_volume_percent = game_pct, - .chat_volume_percent = chat_pct - }; + /// Start of a band's record in the stored settings + static constexpr size_t eqBandOffset(size_t band) + { + return EQ_HEADER_SIZE + EQ_BAND_RECORD_SIZE * band; } - Result setEqualizerPreset(hid_device* device_handle, uint8_t preset) override + static double readEqBandValue(std::span settings, size_t offset) { - // Maxwell supports presets 0-9 (mapped to device presets 1-10) - // 0-5 are built-in presets, 6-9 are custom presets - if (preset >= EQUALIZER_PRESETS_COUNT) { - return DeviceError::invalidParameter("Device only supports presets 0-9"); - } + return std::bit_cast(readLE32(settings, offset)) * EQ_VALUE_SCALE; + } - // Device uses 1-10 range internally - uint8_t device_preset = preset + 1; + static bool isSupportedEqBand(const EqBand& band, int rate) + { + return std::isfinite(band.frequency) && std::isfinite(band.bandwidth) + && band.frequency > 0 && band.frequency < rate / 2 + && band.bandwidth > 0 && band.bandwidth <= EQ_MAX_BANDWIDTH + && isSupportedEqGain(band.gain); + } - std::array cmd { 0x06, 0x09, 0x80, 0x05, 0x5A, 0x05, 0x00, 0x00, 0x09, 0x00, 0x00, device_preset }; + static EqFilter peakingFilter(const EqBand& band, double rate) + { + const double omega = band.frequency * (2 * std::numbers::pi) / rate; + const double amplitude = std::sqrt(std::pow(10.0, band.gain / 20.0)); + const double alpha = std::sin(omega) / (2 * (band.frequency / band.bandwidth)); + const double a0 = 1 + alpha / amplitude; + const double b1 = -2 * std::cos(omega) / a0; + return { (1 + alpha * amplitude) / a0, b1, (1 - alpha * amplitude) / a0, + b1, (1 - alpha / amplitude) / a0 }; + } - auto result = sendGetInputReport(device_handle, cmd); - if (!result) { - return result.error(); - } + static std::complex filterResponse(const EqFilter& filter, std::complex z) + { + return (filter[COEF_B0] + filter[COEF_B1] * z + filter[COEF_B2] * z * z) + / (1.0 + filter[COEF_A1] * z + filter[COEF_A2] * z * z); + } - return EqualizerPresetResult { .preset = preset, .total_presets = EQUALIZER_PRESETS_COUNT }; + static bool isStableFilter(const EqFilter& filter) + { + // Jury stability conditions for z^2 + a1*z + a2. + return std::abs(filter[COEF_A2]) < 1 && 1 + filter[COEF_A1] + filter[COEF_A2] > 0 + && 1 - filter[COEF_A1] + filter[COEF_A2] > 0; } - Result setNoiseFilter(hid_device* device_handle, uint8_t level) override + /// b1 and a1 are stored with one fractional bit less than the other coefficients + static constexpr bool hasHalfResolution(size_t coefficient) { - // Maxwell 2 has three levels for the noise filter: high, low, and - // off (2,1 and 0) - if (level > 2) { - return DeviceError::invalidParameter("Noise filter level must be 0, 1, or 2"); - } - std::array cmd { 0x06, 0x09, 0x80, 0x05, 0x5A, 0x05, 0x00, 0x00, 0x09, 0x24, 0x00, level }; + return coefficient == COEF_B1 || coefficient == COEF_A1; + } - auto result = sendGetInputReport(device_handle, cmd); - if (!result) { - return result.error(); + /** + * @brief Peak gain of each prefix of a filter cascade + * + * Element i is the peak of filters 0..i in series, on the DLL's 1 Hz grid (DC inclusive, + * Nyquist exclusive). It reproduces the per-stage headroom decisions without global state. + */ + static std::vector cascadePeaks(std::span filters, int rate) + { + std::vector peaks(filters.size(), 0.0); + for (int frequency = 0; frequency < rate / 2; ++frequency) { + const auto z = std::polar(1.0, -2 * std::numbers::pi * frequency / rate); + std::complex cascade { 1, 0 }; + for (size_t i = 0; i < filters.size(); ++i) { + cascade *= filterResponse(filters[i], z); + peaks[i] = std::max(peaks[i], std::abs(cascade)); + } } - - return NoiseFilterResult { .level = level }; + return peaks; } }; } // namespace headsetcontrol diff --git a/lib/devices/audeze_maxwell2.hpp b/lib/devices/audeze_maxwell2.hpp index 0a197f34..fee80443 100644 --- a/lib/devices/audeze_maxwell2.hpp +++ b/lib/devices/audeze_maxwell2.hpp @@ -1,12 +1,8 @@ #pragma once -#include "../result_types.hpp" -#include "hid_device.hpp" +#include "audeze_maxwell_device.hpp" #include -#include -#include #include -#include using namespace std::string_view_literals; @@ -16,7 +12,7 @@ namespace headsetcontrol { * @brief Audeze Maxwell 2 Gaming Headset * * Features: - * - Sidetone (32 levels) + * - Sidetone (level read from the headset, 32 levels by default) * - Inactive time with discrete levels * - Equalizer presets (10 total: 6 default + 4 custom) * - Battery status @@ -24,51 +20,15 @@ namespace headsetcontrol { * - Voice prompts * - Mic noise filter * - * Special Requirements: - * - 14-packet initialization sequence followed by 6 status requests - * - 60ms delay between packets (official software sends at 52ms but 60ms is more reliable in testing) - * - Uses hid_get_input_report for responses + * The protocol is implemented in audeze_maxwell_device.hpp. */ -class AudezeMaxwell2 : public HIDDevice { +class AudezeMaxwell2 : public AudezeMaxwellDevice { public: static constexpr std::array SUPPORTED_PRODUCT_IDS { 0x4b29, // Maxwell 2 (PlayStation/PC version) 0x4b28 // Maxwell 2 (Xbox version) }; - static constexpr int MSG_SIZE = 62; - static constexpr int REPORT_ID = 0x06; - static constexpr int DELAY_US = 60000; // 60ms - - // 14-packet initialization sequence - static constexpr std::array, 14> UNIQUE_REQUESTS { { { 0x06, 0x08, 0x80, 0x05, 0x5A, 0x04, 0x00, 0x01, 0x09, 0x20 }, - { 0x06, 0x08, 0x80, 0x05, 0x5A, 0x04, 0x00, 0x01, 0x09, 0x25 }, - { 0x06, 0x07, 0x80, 0x05, 0x5A, 0x03, 0x00, 0x07, 0x1C }, - { 0x06, 0x08, 0x80, 0x05, 0x5A, 0x04, 0x00, 0x01, 0x09, 0x28 }, - { 0x06, 0x08, 0x80, 0x05, 0x5A, 0x04, 0x00, 0x83, 0x2C, 0x01 }, - { 0x06, 0x08, 0x80, 0x05, 0x5A, 0x04, 0x00, 0x83, 0x2C, 0x07 }, - { 0x06, 0x07, 0x00, 0x05, 0x5A, 0x03, 0x00, 0x07, 0x1C }, - { 0x06, 0x08, 0x80, 0x05, 0x5A, 0x04, 0x00, 0x01, 0x09, 0x2D }, - { 0x06, 0x08, 0x80, 0x05, 0x5A, 0x04, 0x00, 0x01, 0x09, 0x2C }, - { 0x06, 0x08, 0x80, 0x05, 0x5A, 0x04, 0x00, 0x01, 0x09 }, - { 0x06, 0x08, 0x80, 0x05, 0x5A, 0x04, 0x00, 0x83, 0x2C, 0x0B }, - { 0x06, 0x08, 0x80, 0x05, 0x5A, 0x04, 0x00, 0x01, 0x09, 0x24 }, - { 0x06, 0x08, 0x80, 0x05, 0x5A, 0x04, 0x00, 0x01, 0x09, 0x2F }, - { 0x06, 0x07, 0x80, 0x05, 0x5A, 0x03, 0x00, 0xD6, 0x0C } } }; - - // 6 packet status request sequence - static constexpr std::array, 6> STATUS_REQUESTS { { { 0x06, 0x08, 0x80, 0x05, 0x5A, 0x04, 0x00, 0x01, 0x09, 0x22 }, - { 0x06, 0x08, 0x80, 0x05, 0x5A, 0x04, 0x00, 0x01, 0x09 }, - { 0x06, 0x08, 0x80, 0x05, 0x5A, 0x04, 0x00, 0x83, 0x2C, 0x0B }, - { 0x06, 0x08, 0x80, 0x05, 0x5A, 0x04, 0x00, 0x01, 0x09, 0x24 }, - { 0x06, 0x08, 0x80, 0x05, 0x5A, 0x04, 0x00, 0x01, 0x09, 0x2C }, - { 0x06, 0x08, 0x80, 0x05, 0x5A, 0x04, 0x00, 0x83, 0x2C, 0x07 } } }; - - constexpr uint16_t getVendorId() const override - { - return 0x3329; // Audeze - } - std::vector getProductIds() const override { return { SUPPORTED_PRODUCT_IDS.begin(), SUPPORTED_PRODUCT_IDS.end() }; @@ -83,362 +43,12 @@ class AudezeMaxwell2 : public HIDDevice { { return B(CAP_SIDETONE) | B(CAP_INACTIVE_TIME) | B(CAP_EQUALIZER_PRESET) | B(CAP_BATTERY_STATUS) | B(CAP_CHATMIX_STATUS) | B(CAP_VOICE_PROMPTS) - | B(CAP_NOISE_FILTER); + | B(CAP_NOISE_FILTER) | B(CAP_SIDETONE_STATUS); } constexpr capability_detail getCapabilityDetail([[maybe_unused]] enum capabilities cap) const override { return { .usagepage = 0xff13, .usageid = 0x1, .interface_id = 5 }; } - - static constexpr int EQUALIZER_PRESETS_COUNT = 10; - - uint8_t getEqualizerPresetsCount() const override - { - return EQUALIZER_PRESETS_COUNT; - } - -private: - /** - * @brief Get device status (battery, chatmix, sidetone, mic mute) - * - * Must send all 6 status requests to get consistent information - */ - struct MaxwellStatus { - BatteryResult battery; - int chatmix_level; - int sidetone_level; - int eq_setting; - int ainf_level; - bool sidetone_enabled; - bool mic_muted; - }; - - /** - * @brief Send request and get input report with 60ms delay to match Audeze HQ. - */ - Result sendGetInputReport(hid_device* device_handle, std::span data, std::span buff = {}) const - { - std::this_thread::sleep_for(std::chrono::microseconds(DELAY_US)); - - if (auto result = writeHID(device_handle, data, MSG_SIZE); !result) { - return result.error(); - } - - if (buff.empty()) { - std::array tempBuff { 0x7 }; - auto read_result = getInputReport(device_handle, tempBuff); - if (!read_result || *read_result != MSG_SIZE) { - return DeviceError::hidError("Failed to get input report"); - } - } else { - buff[0] = 0x7; - auto read_result = getInputReport(device_handle, buff); - if (!read_result || *read_result != MSG_SIZE) { - return DeviceError::hidError("Failed to get input report"); - } - } - - return {}; - } - - /** - * @brief Get comprehensive device status (battery, chatmix, sidetone, etc.) - * - * Must send all initialization and status requests packets to get consistent information - */ - Result getDeviceStatus(hid_device* device_handle) const - { - std::array, 6> status_buffs {}; - - // Send UNIQUE_REQUESTS packets - for (const auto& unique_request : UNIQUE_REQUESTS) { - auto result = sendGetInputReport(device_handle, unique_request); - if (!result) { - return result.error(); - } - } - - // Send STATUS_REQUESTS packets - for (int i = 0; i < 6; ++i) { - auto result = sendGetInputReport(device_handle, STATUS_REQUESTS[i], status_buffs[i]); - if (!result) { - return result.error(); - } - } - - // Status_buffs structure - // status_buffs[0] = battery_status - // status_buffs[1] = mic_status - // status_buffs[2] = eq - // status_buffs[3] = chatmix - // status_buffs[4] = ainf_level - // status_buffs[5] = sidetone_level - - MaxwellStatus status {}; - - const auto& buff = status_buffs[0]; - - // Initialize battery status as unavailable - status.battery.status = BATTERY_UNAVAILABLE; - status.battery.level_percent = -1; - - // Parse battery level - if (buff[0] == 0x00) { - status.battery.status = BATTERY_UNAVAILABLE; - status.battery.level_percent = -1; - } else { - // Find battery level in response (level is first byte after d6 0c 00 00 in packet) - for (int i = 0; i < MSG_SIZE - 4; ++i) { - if (buff[i] == 0xD6 && buff[i + 1] == 0x0C && buff[i + 2] == 0x00 && buff[i + 3] == 0x00) { - status.battery.level_percent = buff[i + 4]; - status.battery.status = BATTERY_AVAILABLE; - break; - } - } - } - - // Parse microphone mute status (FF = unmuted) - status.mic_muted = status_buffs[1][12] != 0xFF; - - // Parse current eq setting - // 1 = Audeze, 2 = Treble Boost, 3 = Bass Boost, 4 = Immersive, 5 = Competition, - // 6 = Footsteps, 7 = EQ1, 8 = EQ2, 9 = EQ3, 10 = EQ4 - status.eq_setting = status_buffs[2][12]; - - // Parse chatmix (0-20 range, center at 10) - status.chatmix_level = map(status_buffs[3][12], 0, 20, 0, 128); - - // Parse current mic noise filter level - // 0 = Off, 1 = Low, 2 = High - status.ainf_level = status_buffs[4][12]; - - // Parse sidetone (0-31 range) - status.sidetone_level = map(status_buffs[5][12], 0, 31, 0, 128); - status.sidetone_enabled = status_buffs[5][12] != 0; - - return status; - } - - static constexpr auto STATUS_REUSE_WINDOW = std::chrono::milliseconds(500); - - /** - * @brief Reuse one status read for every info capability. - * - * getDeviceStatus() costs 21 packets at 60 ms and already returns everything, - * but each getter called it again. Keyed on the handle since one instance - * serves every attached Maxwell 2. - * - * The reused status also carries sidetone, equalizer and noise filter, so - * every setter that writes one of those calls invalidateStatus(). A getter - * built on top of this must keep that list in sync, otherwise a set followed - * by a get inside the window returns the value from before the write. - */ - Result statusFor(hid_device* device_handle) - { - if (last_status_ && last_handle_ == device_handle - && std::chrono::steady_clock::now() - read_at_ < STATUS_REUSE_WINDOW) { - return *last_status_; - } - - auto status = getDeviceStatus(device_handle); - if (!status) { - return status.error(); - } - - last_handle_ = device_handle; - last_status_ = *status; - // After the read, not before: it takes ~1.4 s, so a timestamp taken going - // in would already have expired. - read_at_ = std::chrono::steady_clock::now(); - - return *status; - } - - // Drop the reused status after a write that changes one of its fields. - void invalidateStatus() - { - last_status_.reset(); - } - - hid_device* last_handle_ = nullptr; - std::optional last_status_; - std::chrono::steady_clock::time_point read_at_ {}; - -public: - // Rich Results V2 API - Result getBattery(hid_device* device_handle) override - { - auto status_result = statusFor(device_handle); - if (!status_result) { - return status_result.error(); - } - - return status_result->battery; - } - - Result setSidetone(hid_device* device_handle, uint8_t level) override - { - invalidateStatus(); - - // Maxwell range: 0 to 31 - uint8_t mapped = map(level, 0, 128, 0, 31); - - std::array cmd { 0x06, 0x09, 0x80, 0x05, 0x5A, 0x05, 0x00, 0x00, 0x09, 0x2C, 0x00, mapped }; - - // Sidetone is enabled whenever level changes - auto result = sendGetInputReport(device_handle, cmd); - if (!result) { - return result.error(); - } - - if (mapped == 0) { - // Toggle sidetone off - std::array toggle_cmd { 0x06, 0x09, 0x80, 0x05, 0x5A, 0x05, 0x00, 0x82, 0x2C, 0x07, 0x00, 0x00 }; - result = sendGetInputReport(device_handle, toggle_cmd); - if (!result) { - return result.error(); - } - } - - return SidetoneResult { - .current_level = level, - .min_level = 0, - .max_level = 128, - .device_min = 0, - .device_max = 31 - }; - } - - Result setInactiveTime(hid_device* device_handle, uint8_t minutes) override - { - std::array cmd { 0x06, 0x10, 0x80, 0x05, 0x5A, 0x0C, 0x00, 0x82, 0x2C, 0x01, 0x00 }; - - /* The headset supports auto shutdown times up to 6 hours or 360 minutes but since this uses an 8-bit - * integer it is limited to 255. Here it is truncated to 240 minutes (4 hours) which is the closest - * option from the Audeze software. - */ - - if (minutes > 0) { - // Round to supported values - uint16_t final_minutes = 240; - if (minutes <= 5) - final_minutes = 5; - else if (minutes <= 10) - final_minutes = 10; - else if (minutes <= 15) - final_minutes = 15; - else if (minutes <= 30) - final_minutes = 30; - else if (minutes <= 45) - final_minutes = 45; - else if (minutes <= 60) - final_minutes = 60; - else if (minutes <= 90) - final_minutes = 90; - else if (minutes <= 120) - final_minutes = 120; - else if (minutes <= 240) - final_minutes = 240; - - uint16_t seconds = final_minutes * 60; - - cmd[11] = 0x01; // Inactive time flag - cmd[13] = seconds & 0xFF; // LSB - cmd[14] = (seconds >> 8) & 0xFF; // MSB - cmd[15] = 0x01; // Constant flag - cmd[17] = cmd[13]; // LSB again - cmd[18] = cmd[14]; // MSB again - } - - auto result = sendGetInputReport(device_handle, cmd); - if (!result) { - return result.error(); - } - - return InactiveTimeResult { - .minutes = minutes, - .min_minutes = 0, - .max_minutes = 240 - }; - } - - Result setVoicePrompts(hid_device* device_handle, bool enabled) override - { - // Voice prompt level: 0 (0%) to 15 (100%), default 7 (50%) - // API doesn't support level, so use 7 when on, 0 when off - std::array cmd { 0x06, 0x09, 0x80, 0x05, 0x5A, 0x05, 0x00, 0x00, 0x09, 0x20, 0x00, enabled ? uint8_t(0x07) : uint8_t(0x00) }; - - auto result = sendGetInputReport(device_handle, cmd); - if (!result) { - return result.error(); - } - - return VoicePromptsResult { .enabled = enabled }; - } - - Result getChatmix(hid_device* device_handle) override - { - auto status_result = statusFor(device_handle); - if (!status_result) { - return status_result.error(); - } - - int level = status_result->chatmix_level; - - // Calculate percentages (0-20 range, 10 = center) - // level is already mapped to 0-128 - int game_pct = (level >= 64) ? 100 : map(level, 0, 64, 0, 100); - int chat_pct = (level <= 64) ? 100 : map(level, 64, 128, 100, 0); - - return ChatmixResult { - .level = level, - .game_volume_percent = game_pct, - .chat_volume_percent = chat_pct - }; - } - - Result setEqualizerPreset(hid_device* device_handle, uint8_t preset) override - { - // Maxwell supports presets 0-9 (mapped to device presets 1-10) - // 0-5 are built-in presets, 6-9 are custom presets - if (preset >= EQUALIZER_PRESETS_COUNT) { - return DeviceError::invalidParameter("Device only supports presets 0-9"); - } - - invalidateStatus(); - - // Device uses 1-10 range internally - uint8_t device_preset = preset + 1; - - std::array cmd { 0x06, 0x09, 0x80, 0x05, 0x5A, 0x05, 0x00, 0x00, 0x09, 0x00, 0x00, device_preset }; - - auto result = sendGetInputReport(device_handle, cmd); - if (!result) { - return result.error(); - } - - return EqualizerPresetResult { .preset = preset, .total_presets = EQUALIZER_PRESETS_COUNT }; - } - - Result setNoiseFilter(hid_device* device_handle, uint8_t level) override - { - // Maxwell 2 has three levels for the noise filter: high, low, and - // off (2,1 and 0) - if (level > 2) { - return DeviceError::invalidParameter("Noise filter level must be 0, 1, or 2"); - } - - invalidateStatus(); - - std::array cmd { 0x06, 0x09, 0x80, 0x05, 0x5A, 0x05, 0x00, 0x00, 0x09, 0x24, 0x00, level }; - - auto result = sendGetInputReport(device_handle, cmd); - if (!result) { - return result.error(); - } - - return NoiseFilterResult { .level = level }; - } }; } // namespace headsetcontrol diff --git a/lib/devices/audeze_maxwell_device.hpp b/lib/devices/audeze_maxwell_device.hpp new file mode 100644 index 00000000..864491fd --- /dev/null +++ b/lib/devices/audeze_maxwell_device.hpp @@ -0,0 +1,651 @@ +#pragma once + +#include "../result_types.hpp" +#include "device_utils.hpp" +#include "hid_device.hpp" +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +namespace headsetcontrol { + +/** + * @brief Base class for Audeze Maxwell devices (Maxwell and Maxwell 2) + * + * Implements the Airoha based control protocol both models share. + * + * Recovered from Audeze.exe and a USB capture of the original Maxwell dongle. + * + * HID reports are 62 bytes: output report 0x06 via hid_write, input report 0x07 via + * hid_get_input_report. + * + * + * + * len : number of valid fragment bytes, 0..59. Everything after them is padding or + * leftovers of older reports and must be ignored. A report with len 0 means + * "nothing new yet", whatever its remaining bytes look like. + * route : 0x80 to reach the headset through the dongle + * + * The fragments form a stream of messages. One report can carry several messages and a + * message can span several reports: + * + * 05 + * + * type : 5A request, 5B response, 5C notification, 5D asynchronous result + * len : body length; the first two body bytes identify the command + * + * Commands used, as request body -> reply body. Fixed bytes are written in hex, the + * bytes that vary are named: + * + * : which setting, in the 09 family (the KEY_ constants) + * : which setting, in the 2C family (the PARAM_ constants) + * : the setting itself; one byte unless noted + * : 00 on success + * + * read key 01 09 00 -> 5B: 01 09 00 + * write key 00 09 00 -> 5B: 00 09 00 00 + * read parameter 83 2C 00 -> 5B: 83 2C 00 + * write parameter 82 2C 00 -> 5B: 82 2C 00 + * battery D6 0C 00 -> 5B: D6 0C + * 5D: D6 0C 00 00 + * + * A parameter value can be several bytes long (auto power off). Writing the sidetone + * parameter toggles sidetone whatever value is sent. + * + * Replies carry no sequence number, so they are matched on the command identity and + * anything else that is queued is skipped. + */ +class AudezeMaxwellDevice : public HIDDevice { +public: + static constexpr size_t MSG_SIZE = 62; + static constexpr uint8_t REPORT_ID_OUT = 0x06; + static constexpr uint8_t REPORT_ID_IN = 0x07; + static constexpr size_t FRAGMENT_OFFSET = 3; + static constexpr size_t MAX_FRAGMENT_SIZE = MSG_SIZE - FRAGMENT_OFFSET; + static constexpr uint8_t ROUTE_HEADSET = 0x80; + + static constexpr uint8_t MESSAGE_MARKER = 0x05; + static constexpr uint8_t TYPE_REQUEST = 0x5A; + static constexpr uint8_t TYPE_RESPONSE = 0x5B; + static constexpr uint8_t TYPE_RESULT = 0x5D; + static constexpr size_t TYPE_OFFSET = 1; + static constexpr size_t LENGTH_OFFSET = 2; + static constexpr size_t HEADER_SIZE = 4; + static constexpr size_t MAX_MESSAGE_SIZE = 1024; + + static constexpr uint8_t STATUS_OK = 0x00; + + using Message = std::vector; + using Report = std::array; + + /// The first two body bytes, which identify a command + using Command = std::array; + + static constexpr Command CMD_WRITE_KEY { 0x00, 0x09 }; + static constexpr Command CMD_READ_KEY { 0x01, 0x09 }; + static constexpr Command CMD_WRITE_PARAMETER { 0x82, 0x2C }; + static constexpr Command CMD_READ_PARAMETER { 0x83, 0x2C }; + static constexpr Command CMD_BATTERY { 0xD6, 0x0C }; + + // Keys of the 01 09 (read) / 00 09 (write) family + static constexpr uint8_t KEY_EQ_PRESET = 0x00; + static constexpr uint8_t KEY_VOICE_PROMPT_LEVEL = 0x20; + static constexpr uint8_t KEY_MIC_MUTE = 0x22; + static constexpr uint8_t KEY_NOISE_FILTER = 0x24; + static constexpr uint8_t KEY_VOLUME_LIMITER = 0x28; + static constexpr uint8_t KEY_SIDETONE_LEVEL = 0x2C; + static constexpr uint8_t KEY_SIDETONE_MAX_LEVEL = 0x2D; + + // Parameters of the 83 2C (read) / 82 2C (write) family + static constexpr uint8_t PARAM_AUTO_POWER_OFF = 0x01; + static constexpr uint8_t PARAM_SIDETONE_ENABLED = 0x07; + static constexpr uint8_t PARAM_CHATMIX = 0x0B; + + // KEY_MIC_MUTE reads 00 while muted and FF otherwise + static constexpr uint8_t MIC_MUTED = 0x00; + + // Voice prompt level: 0 (0%) to 15 (100%), default 7 (50%) + static constexpr uint8_t VOICE_PROMPT_LEVEL_OFF = 0x00; + static constexpr uint8_t VOICE_PROMPT_LEVEL_DEFAULT = 0x07; + + static constexpr uint8_t SIDETONE_API_MAX = 128; + static constexpr uint8_t DEFAULT_SIDETONE_MAX = 31; + static constexpr uint8_t CHATMIX_MAX = 20; + static constexpr uint8_t NOISE_FILTER_MAX = 2; + + static constexpr int EQUALIZER_PRESETS_COUNT = 10; + + constexpr uint16_t getVendorId() const override + { + return VENDOR_AUDEZE; + } + + uint8_t getEqualizerPresetsCount() const override + { + return EQUALIZER_PRESETS_COUNT; + } + + /// Append a 16-bit value, low byte first + static void appendLE16(Message& bytes, uint16_t value) + { + bytes.push_back(static_cast(value & 0xFF)); + bytes.push_back(static_cast(value >> 8)); + } + + /// The 16-bit value stored low byte first at @p offset + static uint16_t readLE16(std::span bytes, size_t offset) + { + return static_cast(bytes[offset] | (bytes[offset + 1] << 8)); + } + + /// Build a message: 05 + static Message buildMessage(uint8_t type, std::span body) + { + Message message { MESSAGE_MARKER, type }; + appendLE16(message, static_cast(body.size())); + message.insert(message.end(), body.begin(), body.end()); + return message; + } + + /// Body of a request: the command followed by its arguments + static Message commandBody(Command command, std::initializer_list arguments = {}) + { + Message body(command.begin(), command.end()); + body.insert(body.end(), arguments.begin(), arguments.end()); + return body; + } + + /// Split a message into zero padded output reports + static std::vector buildReports(std::span message) + { + std::vector reports; + for (size_t offset = 0; offset < message.size(); offset += MAX_FRAGMENT_SIZE) { + const size_t length = std::min(MAX_FRAGMENT_SIZE, message.size() - offset); + + Report report {}; + report[0] = REPORT_ID_OUT; + report[1] = static_cast(length); + report[2] = ROUTE_HEADSET; + std::copy_n(message.begin() + offset, length, report.begin() + FRAGMENT_OFFSET); + reports.push_back(report); + } + return reports; + } + + /// The valid fragment of an input report; empty if it carries nothing new + static std::span reportFragment(std::span report) + { + if (report.size() < FRAGMENT_OFFSET) { + return {}; + } + const size_t length = report[1]; + if (length > MAX_FRAGMENT_SIZE || FRAGMENT_OFFSET + length > report.size()) { + return {}; + } + return report.subspan(FRAGMENT_OFFSET, length); + } + + /** + * @brief Take the next complete message off the front of a fragment stream + * + * Bytes that cannot start a message are dropped. Returns an empty message if the + * stream holds no complete message yet. + */ + static Message extractMessage(std::vector& stream) + { + while (!stream.empty()) { + if (stream[0] != MESSAGE_MARKER) { + stream.erase(stream.begin()); + continue; + } + if (stream.size() < HEADER_SIZE) { + break; + } + const size_t size = HEADER_SIZE + readLE16(stream, LENGTH_OFFSET); + if (size > MAX_MESSAGE_SIZE) { + stream.erase(stream.begin()); + continue; + } + if (stream.size() < size) { + break; + } + Message message(stream.begin(), stream.begin() + size); + stream.erase(stream.begin(), stream.begin() + size); + return message; + } + return {}; + } + +protected: + // Non-static so tests can shorten them + std::chrono::milliseconds response_timeout_ { 400 }; + std::chrono::milliseconds poll_interval_ { 20 }; + + static constexpr int MAX_ATTEMPTS = 3; + + /// How a request is sent and how long its reply is awaited + struct RequestOptions { + /// How often the request is sent while it times out; 1 if it is not safe to repeat + int attempts = MAX_ATTEMPTS; + /// How long to wait for the reply; response_timeout_ if not set + std::optional timeout {}; + /// Pause between the reports of a request that does not fit into one + std::chrono::milliseconds fragment_interval { 0 }; + }; + + /// True if a message is of the given type and belongs to the given command + static bool isMessage(const Message& message, uint8_t type, Command command) + { + return message.size() >= HEADER_SIZE + command.size() + && message[TYPE_OFFSET] == type + && std::equal(command.begin(), command.end(), message.begin() + HEADER_SIZE); + } + + /// True if a message carries the 16-bit @p id (a key or a parameter) at @p offset + static bool hasId(const Message& message, size_t offset, uint16_t id) + { + return message.size() >= offset + 2 && readLE16(message, offset) == id; + } + + /** + * @brief Wait for the message accepted by @p accept, skipping everything else + * + * The headset answers after roughly 60-120 ms, and until then the dongle returns + * reports with length 0, so the input report is polled until the timeout. + */ + template + Result awaitMessage(hid_device* device_handle, Predicate accept, std::chrono::milliseconds timeout) const + { + const auto deadline = std::chrono::steady_clock::now() + timeout; + std::vector stream; + + while (true) { + for (auto message = extractMessage(stream); !message.empty(); message = extractMessage(stream)) { + if (accept(message)) { + return message; + } + } + + if (std::chrono::steady_clock::now() >= deadline) { + return DeviceError::timeout("No matching reply from the Maxwell"); + } + std::this_thread::sleep_for(poll_interval_); + + Report report { REPORT_ID_IN }; + auto read_result = getInputReport(device_handle, report); + if (!read_result) { + return read_result.error(); + } + + const auto fragment = reportFragment(std::span(report).first(std::min(*read_result, report.size()))); + stream.insert(stream.end(), fragment.begin(), fragment.end()); + } + } + + /// Write the reports of one request, pausing for @p interval between them + Result sendReports(hid_device* device_handle, std::span reports, std::chrono::milliseconds interval) const + { + for (size_t i = 0; i < reports.size(); ++i) { + if (i > 0 && interval.count() > 0) { + std::this_thread::sleep_for(interval); + } + if (auto result = writeHID(device_handle, reports[i], MSG_SIZE); !result) { + return result.error(); + } + } + return {}; + } + + /** + * @brief Send a request and return the reply accepted by @p accept + * + * Reading an input report consumes it, so another program polling the same dongle + * (the Audeze app does so about once a second) can take a reply meant for us, while + * we see its replies instead. The request is therefore repeated on a timeout, unless + * @p options says otherwise. + */ + template + Result request(hid_device* device_handle, const Message& body, Predicate accept, const RequestOptions& options) const + { + const auto reports = buildReports(buildMessage(TYPE_REQUEST, body)); + const auto timeout = options.timeout.value_or(response_timeout_); + + Result reply = DeviceError::timeout("No matching reply from the Maxwell"); + for (int attempt = 0; attempt < options.attempts; ++attempt) { + if (auto result = sendReports(device_handle, reports, options.fragment_interval); !result) { + return result.error(); + } + + reply = awaitMessage(device_handle, accept, timeout); + if (reply || reply.error().code != DeviceError::Code::Timeout) { + break; + } + } + return reply; + } + + template + Result request(hid_device* device_handle, const Message& body, Predicate accept) const + { + return request(device_handle, body, accept, RequestOptions {}); + } + + Result readKey(hid_device* device_handle, uint8_t key) const + { + // Reply: 05 5B 06 00 01 09 00 + static constexpr size_t KEY_OFFSET = 6; + static constexpr size_t STATUS_OFFSET = 8; + static constexpr size_t VALUE_OFFSET = 9; + + auto reply = request(device_handle, commandBody(CMD_READ_KEY, { key, 0x00 }), [key](const Message& message) { + return isMessage(message, TYPE_RESPONSE, CMD_READ_KEY) && hasId(message, KEY_OFFSET, key); + }); + if (!reply) { + return reply.error(); + } + if (reply->size() <= VALUE_OFFSET || (*reply)[STATUS_OFFSET] != STATUS_OK) { + return DeviceError::protocolError(std::format("Maxwell rejected reading key 0x{:02x}", key)); + } + return (*reply)[VALUE_OFFSET]; + } + + Result writeKey(hid_device* device_handle, uint8_t key, uint8_t value) const + { + // Reply: 05 5B 05 00 00 09 00 00 + static constexpr size_t FAMILY_OFFSET = 5; + static constexpr size_t KEY_OFFSET = 6; + + // Audeze.exe only checks the family byte and the key of this acknowledgement + auto reply = request(device_handle, commandBody(CMD_WRITE_KEY, { key, 0x00, value }), [key](const Message& message) { + return message.size() > KEY_OFFSET && message[TYPE_OFFSET] == TYPE_RESPONSE + && message[FAMILY_OFFSET] == CMD_WRITE_KEY[1] && message[KEY_OFFSET] == key; + }); + if (!reply) { + return reply.error(); + } + return {}; + } + + Result readParameter(hid_device* device_handle, uint8_t parameter) const + { + // Reply: 05 5B 06 00 83 2C 00 + static constexpr size_t STATUS_OFFSET = 6; + static constexpr size_t PARAMETER_OFFSET = 7; + static constexpr size_t VALUE_OFFSET = 9; + + auto reply = request(device_handle, commandBody(CMD_READ_PARAMETER, { parameter, 0x00 }), [parameter](const Message& message) { + return isMessage(message, TYPE_RESPONSE, CMD_READ_PARAMETER) && hasId(message, PARAMETER_OFFSET, parameter); + }); + if (!reply) { + return reply.error(); + } + if (reply->size() <= VALUE_OFFSET || (*reply)[STATUS_OFFSET] != STATUS_OK) { + return DeviceError::protocolError(std::format("Maxwell rejected reading parameter 0x{:02x}", parameter)); + } + return (*reply)[VALUE_OFFSET]; + } + + Result writeParameter(hid_device* device_handle, uint8_t parameter, std::span value, const RequestOptions& options) const + { + // Reply: 05 5B 05 00 82 2C 00 + static constexpr size_t STATUS_OFFSET = 6; + static constexpr size_t PARAMETER_OFFSET = 7; + + auto body = commandBody(CMD_WRITE_PARAMETER, { parameter, 0x00 }); + body.insert(body.end(), value.begin(), value.end()); + + auto reply = request( + device_handle, body, [parameter](const Message& message) { + return isMessage(message, TYPE_RESPONSE, CMD_WRITE_PARAMETER) && hasId(message, PARAMETER_OFFSET, parameter); + }, + options); + if (!reply) { + return reply.error(); + } + if ((*reply)[STATUS_OFFSET] != STATUS_OK) { + return DeviceError::protocolError(std::format("Maxwell rejected writing parameter 0x{:02x}", parameter)); + } + return {}; + } + + Result writeParameter(hid_device* device_handle, uint8_t parameter, std::span value) const + { + return writeParameter(device_handle, parameter, value, RequestOptions {}); + } + + /// Highest sidetone level the headset accepts + uint8_t readSidetoneMax(hid_device* device_handle) const + { + auto max_level = readKey(device_handle, KEY_SIDETONE_MAX_LEVEL); + if (!max_level || *max_level == 0) { + return DEFAULT_SIDETONE_MAX; + } + return *max_level; + } + + /** + * @brief Switch sidetone on or off + * + * The headset toggles sidetone on every write to this parameter, whatever value is + * sent (verified on an original Maxwell). So the state is read first, the write is + * only sent if it differs, and the result is read back. + */ + Result setSidetoneEnabled(hid_device* device_handle, bool enabled) const + { + static constexpr int MAX_TOGGLES = 2; + + for (int toggles = 0;; ++toggles) { + auto current = readParameter(device_handle, PARAM_SIDETONE_ENABLED); + if (!current) { + return current.error(); + } + const bool is_enabled = *current != 0x00; + if (is_enabled == enabled) { + return {}; + } + if (toggles == MAX_TOGGLES) { + return DeviceError::protocolError("Maxwell did not change the sidetone state"); + } + + // Sent once, as repeating it would toggle back. A lost acknowledgement does + // not matter: the state read above decides. + const std::array value { static_cast(enabled ? 0x01 : 0x00) }; + auto result = writeParameter(device_handle, PARAM_SIDETONE_ENABLED, value, { .attempts = 1 }); + if (!result && result.error().code != DeviceError::Code::Timeout) { + return result.error(); + } + } + } + +public: + Result getBattery(hid_device* device_handle) override + { + // Replies: 05 5B 03 00 D6 0C , then 05 5D 05 00 D6 0C 00 00 + static constexpr size_t STATUS_OFFSET = 6; + static constexpr size_t PERCENT_OFFSET = 8; + + BatteryResult battery { .level_percent = -1, .status = BATTERY_UNAVAILABLE }; + + // Only the result is needed; a failed acknowledgement ends the wait early + auto result = request(device_handle, commandBody(CMD_BATTERY, { 0x00 }), [](const Message& message) { + const bool has_level = isMessage(message, TYPE_RESULT, CMD_BATTERY) && message.size() > PERCENT_OFFSET; + const bool refused = isMessage(message, TYPE_RESPONSE, CMD_BATTERY) && message.size() > STATUS_OFFSET + && message[STATUS_OFFSET] != STATUS_OK; + return has_level || refused; + }); + if (!result) { + // The headset does not answer while it is switched off + if (result.error().code == DeviceError::Code::Timeout) { + return battery; + } + return result.error(); + } + if ((*result)[TYPE_OFFSET] != TYPE_RESULT) { + return battery; + } + + battery.level_percent = std::min((*result)[PERCENT_OFFSET], 100); + battery.status = BATTERY_AVAILABLE; + + if (auto mic = readKey(device_handle, KEY_MIC_MUTE); mic && *mic == MIC_MUTED) { + battery.mic_status = MICROPHONE_UP; + } + + return battery; + } + + Result setSidetone(hid_device* device_handle, uint8_t level) override + { + const uint8_t device_max = readSidetoneMax(device_handle); + const uint8_t mapped = map(level, 0, SIDETONE_API_MAX, 0, device_max); + const bool enabled = mapped > 0; + + // The level and the on/off state are stored separately. Writing a level also + // switches sidetone on, so level 0 only switches it off and keeps the stored level. + if (enabled) { + if (auto result = writeKey(device_handle, KEY_SIDETONE_LEVEL, mapped); !result) { + return result.error(); + } + } + + if (auto result = setSidetoneEnabled(device_handle, enabled); !result) { + return result.error(); + } + + return SidetoneResult { + .current_level = level, + .min_level = 0, + .max_level = SIDETONE_API_MAX, + .device_min = 0, + .device_max = device_max, + .is_muted = !enabled, + .device_level = mapped + }; + } + + Result getSidetone(hid_device* device_handle) override + { + auto device_level = readKey(device_handle, KEY_SIDETONE_LEVEL); + if (!device_level) { + return device_level.error(); + } + + auto enabled = readParameter(device_handle, PARAM_SIDETONE_ENABLED); + if (!enabled) { + return enabled.error(); + } + + const uint8_t device_max = std::max(readSidetoneMax(device_handle), *device_level); + const bool muted = *enabled == 0 || *device_level == 0; + + return SidetoneResult { + .current_level = muted ? uint8_t(0) : map(*device_level, 0, device_max, 0, SIDETONE_API_MAX), + .min_level = 0, + .max_level = SIDETONE_API_MAX, + .device_min = 0, + .device_max = device_max, + .is_muted = muted, + .device_level = *device_level + }; + } + + Result setInactiveTime(hid_device* device_handle, uint8_t minutes) override + { + // Round up to the nearest value the headset supports. The longest one is 6 hours, + // which the 8-bit argument cannot express, so anything above 4 hours selects it. + static constexpr std::array SUPPORTED_MINUTES { 5, 10, 15, 30, 45, 60, 90, 120, 240 }; + static constexpr uint16_t LONGEST_MINUTES = 360; + + uint16_t seconds = 0; + if (minutes > 0) { + const auto supported = std::ranges::lower_bound(SUPPORTED_MINUTES, minutes); + seconds = (supported != SUPPORTED_MINUTES.end() ? *supported : LONGEST_MINUTES) * 60; + } + + // Enable flag and seconds (little endian), sent twice + const uint8_t enabled = seconds > 0 ? 0x01 : 0x00; + const auto lsb = static_cast(seconds & 0xFF); + const auto msb = static_cast(seconds >> 8); + const std::array value { enabled, 0x00, lsb, msb, enabled, 0x00, lsb, msb }; + if (auto result = writeParameter(device_handle, PARAM_AUTO_POWER_OFF, value); !result) { + return result.error(); + } + + return InactiveTimeResult { + .minutes = minutes, + .min_minutes = 0, + .max_minutes = 255 + }; + } + + Result setVoicePrompts(hid_device* device_handle, bool enabled) override + { + // The API has no level, so prompts are either off or at the default level + const uint8_t prompt_level = enabled ? VOICE_PROMPT_LEVEL_DEFAULT : VOICE_PROMPT_LEVEL_OFF; + if (auto result = writeKey(device_handle, KEY_VOICE_PROMPT_LEVEL, prompt_level); !result) { + return result.error(); + } + + return VoicePromptsResult { .enabled = enabled }; + } + + Result getChatmix(hid_device* device_handle) override + { + auto raw = readParameter(device_handle, PARAM_CHATMIX); + if (!raw) { + return raw.error(); + } + + // 0-20 range, 10 = center + const int level = map(static_cast(std::min(*raw, CHATMIX_MAX)), 0, static_cast(CHATMIX_MAX), 0, 128); + + const int game_pct = (level >= 64) ? 100 : map(level, 0, 64, 0, 100); + const int chat_pct = (level <= 64) ? 100 : map(level, 64, 128, 100, 0); + + return ChatmixResult { + .level = level, + .game_volume_percent = game_pct, + .chat_volume_percent = chat_pct + }; + } + + Result setEqualizerPreset(hid_device* device_handle, uint8_t preset) override + { + // Maxwell supports presets 0-9 (mapped to device presets 1-10) + // 0-5 are built-in presets, 6-9 are custom presets + // 1 = Audeze, 2 = Treble Boost, 3 = Bass Boost, 4 = Immersive, 5 = Competition, + // 6 = Footsteps, 7 = EQ1, 8 = EQ2, 9 = EQ3, 10 = EQ4 + if (preset >= EQUALIZER_PRESETS_COUNT) { + return DeviceError::invalidParameter("Device only supports presets 0-9"); + } + + if (auto result = writeKey(device_handle, KEY_EQ_PRESET, static_cast(preset + 1)); !result) { + return result.error(); + } + + return EqualizerPresetResult { .preset = preset, .total_presets = EQUALIZER_PRESETS_COUNT }; + } + + Result setNoiseFilter(hid_device* device_handle, uint8_t level) override + { + // Three levels for the noise filter: high, low, and off (2, 1 and 0) + if (level > NOISE_FILTER_MAX) { + return DeviceError::invalidParameter("Noise filter level must be 0, 1, or 2"); + } + + if (auto result = writeKey(device_handle, KEY_NOISE_FILTER, level); !result) { + return result.error(); + } + + return NoiseFilterResult { .level = level }; + } +}; + +} // namespace headsetcontrol diff --git a/tests/test_protocols.cpp b/tests/test_protocols.cpp index cc722739..5c077fb9 100644 --- a/tests/test_protocols.cpp +++ b/tests/test_protocols.cpp @@ -10,6 +10,8 @@ */ #include "device.hpp" +#include "devices/audeze_maxwell.hpp" +#include "devices/audeze_maxwell2.hpp" #include "devices/corsair_device.hpp" #include "devices/logitech_astro_a50_gen4.hpp" #include "devices/logitech_gpro_x2_lightspeed.hpp" @@ -22,6 +24,7 @@ #include "utility.hpp" #include +#include #include #include #include @@ -1332,6 +1335,556 @@ void testAstroA50Gen4TimeoutRecovery() // Test Runner // ============================================================================ +// ============================================================================ +// Audeze Maxwell Tests +// ============================================================================ + +/** + * @brief Serves queued input reports; once the queue is empty the dongle has nothing + * new and answers with a zero-length report. + */ +class MaxwellScriptedHID : public ScriptedHIDInterface { +public: + /// Number of requests that get no reply before the queue is served + size_t silent_writes = 0; + + [[nodiscard]] auto getInputReport(hid_device* /*device_handle*/, std::span data) + -> Result override + { + std::vector report { 0x07, 0x00, 0x80 }; + + // An empty queued reply leaves the current request unanswered + if (!replies.empty() && replies.front() && replies.front()->empty()) { + if (unanswered_write_ == 0) { + unanswered_write_ = writes.size(); + } + if (writes.size() > unanswered_write_) { + replies.pop_front(); + unanswered_write_ = 0; + } + } + + if (!replies.empty() && unanswered_write_ == 0 && writes.size() >= silent_writes + 1) { + auto reply = std::move(replies.front()); + replies.pop_front(); + if (!reply) { + return reply.error(); + } + report = std::move(*reply); + } + report.resize(data.size(), 0); + std::copy(report.begin(), report.end(), data.begin()); + return data.size(); + } + +private: + size_t unanswered_write_ = 0; +}; + +class TestableAudezeMaxwell : public AudezeMaxwell { +public: + mutable MaxwellScriptedHID hid; + + TestableAudezeMaxwell() + { + response_timeout_ = std::chrono::milliseconds(20); + poll_interval_ = std::chrono::milliseconds(0); + eq_timeout_ = std::chrono::milliseconds(20); + eq_fragment_interval_ = std::chrono::milliseconds(0); + } + + [[nodiscard]] auto getHIDInterface() const -> HIDInterface& override { return hid; } +}; + +/// Build an input report: 07 LEN 80 FRAGMENT..., followed by leftover bytes as on the real dongle +static std::vector maxwellReport(std::initializer_list fragment) +{ + std::vector report { 0x07, static_cast(fragment.size()), 0x80 }; + report.insert(report.end(), fragment.begin(), fragment.end()); + // Stale battery result, as seen after the valid bytes in the capture + for (uint8_t stale : { 0x05, 0x5D, 0x05, 0x00, 0xD6, 0x0C, 0x00, 0x00, 0x3E }) { + report.push_back(stale); + } + report.resize(AudezeMaxwell::MSG_SIZE, 0); + return report; +} + +static std::vector maxwellWritePrefix(const std::vector& write, size_t count) +{ + return { write.begin(), write.begin() + std::min(count, write.size()) }; +} + +void testAudezeMaxwellFraming() +{ + std::cout << " Testing Audeze Maxwell framing..." << std::endl; + + std::array body { 0x01, 0x09, 0x22, 0x00 }; + auto message = AudezeMaxwell::buildMessage(AudezeMaxwell::TYPE_REQUEST, body); + auto reports = AudezeMaxwell::buildReports(message); + ASSERT_EQ(1, static_cast(reports.size()), "Short message should fit one report"); + + std::vector expected { 0x06, 0x08, 0x80, 0x05, 0x5A, 0x04, 0x00, 0x01, 0x09, 0x22, 0x00 }; + expected.resize(AudezeMaxwell::MSG_SIZE, 0); + ASSERT_TRUE(std::equal(expected.begin(), expected.end(), reports[0].begin()), "Mic mute read should match the captured report"); + + // A long message is split into 59-byte fragments + std::vector long_body(100, 0xAA); + auto long_reports = AudezeMaxwell::buildReports(AudezeMaxwell::buildMessage(AudezeMaxwell::TYPE_REQUEST, long_body)); + ASSERT_EQ(2, static_cast(long_reports.size()), "104 bytes should need two reports"); + ASSERT_EQ(59, static_cast(long_reports[0][1]), "First fragment should be full"); + ASSERT_EQ(45, static_cast(long_reports[1][1]), "Second fragment should hold the rest"); + ASSERT_EQ(100, static_cast(long_reports[0][5]), "Body length should be little endian"); + + // A zero-length report carries nothing, whatever follows the header + auto stale = maxwellReport({}); + ASSERT_TRUE(AudezeMaxwell::reportFragment(stale).empty(), "Zero-length report should yield no bytes"); + + // Two messages in one fragment, then garbage and an incomplete message + std::vector stream { 0x05, 0x5B, 0x03, 0x00, 0xD6, 0x0C, 0x00, 0x05, 0x5D, 0x05, 0x00, 0xD6, 0x0C, 0x00, 0x00, 0x3E, 0xFF, 0x05, 0x5B, 0x06 }; + ASSERT_EQ(7, static_cast(AudezeMaxwell::extractMessage(stream).size()), "Acknowledgement should be 7 bytes"); + ASSERT_EQ(9, static_cast(AudezeMaxwell::extractMessage(stream).size()), "Result should be 9 bytes"); + ASSERT_TRUE(AudezeMaxwell::extractMessage(stream).empty(), "Incomplete message should not be returned"); + ASSERT_EQ(3, static_cast(stream.size()), "Garbage should be dropped, the partial message kept"); + + std::cout << " [OK] Audeze Maxwell framing verified" << std::endl; +} + +void testAudezeMaxwellBattery() +{ + std::cout << " Testing Audeze Maxwell battery..." << std::endl; + + TestableAudezeMaxwell device; + // Capture frames 117-122: an empty report with stale bytes, then acknowledgement and result together + device.hid.replies.emplace_back(maxwellReport({})); + device.hid.replies.emplace_back(maxwellReport({ 0x05, 0x5B, 0x03, 0x00, 0xD6, 0x0C, 0x00, 0x05, 0x5D, 0x05, 0x00, 0xD6, 0x0C, 0x00, 0x00, 0x3E })); + device.hid.replies.emplace_back(maxwellReport({ 0x05, 0x5B, 0x06, 0x00, 0x01, 0x09, 0x22, 0x00, 0x00, 0x00 })); + + auto battery = device.getBattery(nullptr); + ASSERT_TRUE(battery.hasValue(), "Battery should be read"); + ASSERT_EQ(62, battery->level_percent, "Battery should be 62%"); + ASSERT_EQ(static_cast(BATTERY_AVAILABLE), static_cast(battery->status), "Battery should be available"); + ASSERT_EQ(static_cast(MICROPHONE_UP), static_cast(battery->mic_status), "Muted microphone should be reported"); + + ASSERT_EQ(2, static_cast(device.hid.writes.size()), "Battery and mic mute requests only"); + ASSERT_TRUE((maxwellWritePrefix(device.hid.writes[0], 10) == std::vector { 0x06, 0x07, 0x80, 0x05, 0x5A, 0x03, 0x00, 0xD6, 0x0C, 0x00 }), "Battery request should match the capture"); + + // Stale battery bytes behind a zero length must never be taken as a reading + TestableAudezeMaxwell offline; + auto unavailable = offline.getBattery(nullptr); + ASSERT_TRUE(unavailable.hasValue(), "A silent headset is not an error"); + ASSERT_EQ(static_cast(BATTERY_UNAVAILABLE), static_cast(unavailable->status), "Battery should be unavailable"); + ASSERT_EQ(-1, unavailable->level_percent, "No level without a reply"); + + // Unmuted microphone, acknowledgement and result in separate reports + TestableAudezeMaxwell unmuted; + unmuted.hid.replies.emplace_back(maxwellReport({ 0x05, 0x5B, 0x03, 0x00, 0xD6, 0x0C, 0x00 })); + unmuted.hid.replies.emplace_back(maxwellReport({ 0x05, 0x5D, 0x05, 0x00, 0xD6, 0x0C, 0x00, 0x00, 0x64 })); + unmuted.hid.replies.emplace_back(maxwellReport({ 0x05, 0x5B, 0x06, 0x00, 0x01, 0x09, 0x22, 0x00, 0x00, 0xFF })); + auto full = unmuted.getBattery(nullptr); + ASSERT_TRUE(full.hasValue(), "Battery should be read"); + ASSERT_EQ(100, full->level_percent, "Battery should be 100%"); + ASSERT_EQ(static_cast(MICROPHONE_UNKNOWN), static_cast(full->mic_status), "Unmuted microphone is not reported as up"); + + // Another program took the acknowledgement: the result alone is enough + TestableAudezeMaxwell shared; + shared.hid.replies.emplace_back(maxwellReport({ 0x05, 0x5D, 0x05, 0x00, 0xD6, 0x0C, 0x00, 0x00, 0x3D, 0x05, 0x5B, 0x06, 0x00, 0x01, 0x09, 0x24, 0x00, 0x00, 0x00 })); + shared.hid.replies.emplace_back(maxwellReport({ 0x05, 0x5B, 0x06, 0x00, 0x01, 0x09, 0x22, 0x00, 0x00, 0xFF })); + auto without_ack = shared.getBattery(nullptr); + ASSERT_TRUE(without_ack.hasValue(), "Battery should be read"); + ASSERT_EQ(61, without_ack->level_percent, "Battery should be 61%"); + + // Another program took the whole reply: the request is repeated + TestableAudezeMaxwell retried; + retried.hid.silent_writes = 2; + retried.hid.replies.emplace_back(maxwellReport({ 0x05, 0x5B, 0x03, 0x00, 0xD6, 0x0C, 0x00, 0x05, 0x5D, 0x05, 0x00, 0xD6, 0x0C, 0x00, 0x00, 0x3E })); + retried.hid.replies.emplace_back(maxwellReport({ 0x05, 0x5B, 0x06, 0x00, 0x01, 0x09, 0x22, 0x00, 0x00, 0xFF })); + auto after_retry = retried.getBattery(nullptr); + ASSERT_TRUE(after_retry.hasValue(), "Battery should be read"); + ASSERT_EQ(62, after_retry->level_percent, "Battery should be read on the third attempt"); + ASSERT_EQ(4, static_cast(retried.hid.writes.size()), "Three battery requests and the mic mute request"); + + // A failed acknowledgement means no reading + TestableAudezeMaxwell refused; + refused.hid.replies.emplace_back(maxwellReport({ 0x05, 0x5B, 0x03, 0x00, 0xD6, 0x0C, 0x01 })); + auto no_reading = refused.getBattery(nullptr); + ASSERT_TRUE(no_reading.hasValue(), "A refused battery request is not an error"); + ASSERT_EQ(static_cast(BATTERY_UNAVAILABLE), static_cast(no_reading->status), "Battery should be unavailable"); + ASSERT_EQ(1, static_cast(refused.hid.writes.size()), "A refused request is not repeated"); + + std::cout << " [OK] Audeze Maxwell battery verified" << std::endl; +} + +void testAudezeMaxwellReplyMatching() +{ + std::cout << " Testing Audeze Maxwell reply matching..." << std::endl; + + // Replies to other commands and a notification are queued ahead of the chatmix reply, + // which itself is split over two reports + TestableAudezeMaxwell device; + device.hid.replies.emplace_back(maxwellReport({ 0x05, 0x5B, 0x06, 0x00, 0x01, 0x09, 0x24, 0x00, 0x00, 0x02, 0x05, 0x5C, 0x03, 0x00, 0x80, 0x2C, 0x03 })); + device.hid.replies.emplace_back(maxwellReport({ 0x05, 0x5B, 0x06, 0x00, 0x83, 0x2C, 0x00, 0x07, 0x00, 0x01 })); + device.hid.replies.emplace_back(maxwellReport({ 0x05, 0x5B, 0x06, 0x00, 0x83, 0x2C })); + device.hid.replies.emplace_back(maxwellReport({})); + device.hid.replies.emplace_back(maxwellReport({ 0x00, 0x0B, 0x00, 0x0A })); + + auto chatmix = device.getChatmix(nullptr); + ASSERT_TRUE(chatmix.hasValue(), "Chatmix should be read"); + ASSERT_EQ(64, chatmix->level, "Raw 10 of 20 is the center"); + ASSERT_EQ(100, chatmix->game_volume_percent, "Game volume at center"); + ASSERT_EQ(100, chatmix->chat_volume_percent, "Chat volume at center"); + ASSERT_TRUE((maxwellWritePrefix(device.hid.writes[0], 11) == std::vector { 0x06, 0x08, 0x80, 0x05, 0x5A, 0x04, 0x00, 0x83, 0x2C, 0x0B, 0x00 }), "Chatmix request should match the capture"); + + // No matching reply is a timeout, not a value read from another command's reply + TestableAudezeMaxwell silent; + silent.hid.replies.emplace_back(maxwellReport({ 0x05, 0x5B, 0x06, 0x00, 0x01, 0x09, 0x24, 0x00, 0x00, 0x02 })); + auto missing = silent.getChatmix(nullptr); + ASSERT_TRUE(missing.hasError(), "Chatmix without a reply should fail"); + ASSERT_TRUE(missing.error().code == DeviceError::Code::Timeout, "Failure should be a timeout"); + + // Non-zero status + TestableAudezeMaxwell rejected; + rejected.hid.replies.emplace_back(maxwellReport({ 0x05, 0x5B, 0x06, 0x00, 0x83, 0x2C, 0x01, 0x0B, 0x00, 0x0A })); + auto error = rejected.getChatmix(nullptr); + ASSERT_TRUE(error.hasError(), "Rejected read should fail"); + ASSERT_TRUE(error.error().code == DeviceError::Code::ProtocolError, "Failure should be a protocol error"); + + std::cout << " [OK] Audeze Maxwell reply matching verified" << std::endl; +} + +void testAudezeMaxwellSidetone() +{ + std::cout << " Testing Audeze Maxwell sidetone..." << std::endl; + + // Capture: level 9 while sidetone is switched off + TestableAudezeMaxwell device; + device.hid.replies.emplace_back(maxwellReport({ 0x05, 0x5B, 0x06, 0x00, 0x01, 0x09, 0x2C, 0x00, 0x00, 0x09 })); + device.hid.replies.emplace_back(maxwellReport({ 0x05, 0x5B, 0x06, 0x00, 0x83, 0x2C, 0x00, 0x07, 0x00, 0x00 })); + device.hid.replies.emplace_back(maxwellReport({ 0x05, 0x5B, 0x06, 0x00, 0x01, 0x09, 0x2D, 0x00, 0x00, 0x1F })); + + auto off = device.getSidetone(nullptr); + ASSERT_TRUE(off.hasValue(), "Sidetone should be read"); + ASSERT_TRUE(off->is_muted, "Disabled sidetone should be muted"); + ASSERT_EQ(0, static_cast(off->current_level), "Disabled sidetone reports level 0"); + ASSERT_EQ(9, static_cast(off->device_level), "Stored level should be kept"); + ASSERT_EQ(31, static_cast(off->device_max), "Maximum should come from the headset"); + + const std::vector max_reply = maxwellReport({ 0x05, 0x5B, 0x06, 0x00, 0x01, 0x09, 0x2D, 0x00, 0x00, 0x0F }); + const std::vector level_ack = maxwellReport({ 0x05, 0x5B, 0x05, 0x00, 0x00, 0x09, 0x2C, 0x00, 0x00 }); + const std::vector toggle_ack = maxwellReport({ 0x05, 0x5B, 0x05, 0x00, 0x82, 0x2C, 0x00, 0x07, 0x00 }); + const std::vector state_on = maxwellReport({ 0x05, 0x5B, 0x06, 0x00, 0x83, 0x2C, 0x00, 0x07, 0x00, 0x01 }); + const std::vector state_off = maxwellReport({ 0x05, 0x5B, 0x06, 0x00, 0x83, 0x2C, 0x00, 0x07, 0x00, 0x00 }); + const std::vector level_write = { 0x06, 0x09, 0x80, 0x05, 0x5A, 0x05, 0x00, 0x00, 0x09, 0x2C, 0x00, 0x0F }; + const std::vector toggle = { 0x06, 0x09, 0x80, 0x05, 0x5A, 0x05, 0x00, 0x82, 0x2C, 0x07, 0x00 }; + + // Writing the level switches sidetone on, so the state is only checked, not written + TestableAudezeMaxwell setter; + setter.hid.replies.emplace_back(max_reply); + setter.hid.replies.emplace_back(level_ack); + setter.hid.replies.emplace_back(state_on); + + auto set = setter.setSidetone(nullptr, 128); + ASSERT_TRUE(set.hasValue(), "Sidetone should be set"); + ASSERT_EQ(15, static_cast(set->device_level), "128 should map to the maximum the headset reported"); + ASSERT_EQ(3, static_cast(setter.hid.writes.size()), "Maximum query, level and state query"); + ASSERT_TRUE(maxwellWritePrefix(setter.hid.writes[1], 12) == level_write, "Level write"); + ASSERT_TRUE(maxwellWritePrefix(setter.hid.writes[2], 9) != maxwellWritePrefix(toggle, 9), "An enabled sidetone must not be toggled"); + + // Still off after the level write: toggled once and read back + TestableAudezeMaxwell still_off; + for (const auto& reply : { max_reply, level_ack, state_off, toggle_ack, state_on }) { + still_off.hid.replies.emplace_back(reply); + } + ASSERT_TRUE(still_off.setSidetone(nullptr, 128).hasValue(), "Sidetone should be switched on"); + ASSERT_EQ(5, static_cast(still_off.hid.writes.size()), "Maximum, level, state, toggle, state"); + ASSERT_TRUE(maxwellWritePrefix(still_off.hid.writes[3], 11) == toggle, "Toggle"); + + // Level 0 switches an enabled sidetone off and keeps the stored level + TestableAudezeMaxwell disable; + for (const auto& reply : { max_reply, state_on, toggle_ack, state_off }) { + disable.hid.replies.emplace_back(reply); + } + auto disabled = disable.setSidetone(nullptr, 0); + ASSERT_TRUE(disabled.hasValue(), "Sidetone should be switched off"); + ASSERT_TRUE(disabled->is_muted, "Level 0 is muted"); + ASSERT_EQ(4, static_cast(disable.hid.writes.size()), "Maximum, state, toggle, state"); + ASSERT_TRUE(maxwellWritePrefix(disable.hid.writes[2], 11) == toggle, "Toggle"); + for (const auto& write : disable.hid.writes) { + ASSERT_TRUE(maxwellWritePrefix(write, 10) != maxwellWritePrefix(level_write, 10), "The stored level must not be overwritten"); + } + + // Level 0 while it is already off must not toggle it back on + TestableAudezeMaxwell already_off; + already_off.hid.replies.emplace_back(max_reply); + already_off.hid.replies.emplace_back(state_off); + ASSERT_TRUE(already_off.setSidetone(nullptr, 0).hasValue(), "Sidetone should stay off"); + ASSERT_EQ(2, static_cast(already_off.hid.writes.size()), "Maximum and state query only"); + + // A lost toggle acknowledgement must not repeat the toggle: that would undo it + TestableAudezeMaxwell lost_ack; + lost_ack.hid.replies.emplace_back(max_reply); + lost_ack.hid.replies.emplace_back(state_on); + lost_ack.hid.replies.emplace_back(std::vector {}); + lost_ack.hid.replies.emplace_back(state_off); + ASSERT_TRUE(lost_ack.setSidetone(nullptr, 0).hasValue(), "The read back state decides"); + ASSERT_EQ(4, static_cast(lost_ack.hid.writes.size()), "The toggle is sent once"); + + // A headset that never changes state is an error + TestableAudezeMaxwell stuck; + for (const auto& reply : { max_reply, state_on, toggle_ack, state_on, toggle_ack, state_on }) { + stuck.hid.replies.emplace_back(reply); + } + ASSERT_TRUE(stuck.setSidetone(nullptr, 0).hasError(), "A state that does not change should fail"); + + // A setter without acknowledgement fails + TestableAudezeMaxwell unacknowledged; + auto failed = unacknowledged.setNoiseFilter(nullptr, 1); + ASSERT_TRUE(failed.hasError(), "Missing acknowledgement should fail"); + + std::cout << " [OK] Audeze Maxwell sidetone verified" << std::endl; +} + +void testAudezeMaxwellInactiveTime() +{ + std::cout << " Testing Audeze Maxwell inactive time..." << std::endl; + + const std::vector ack = maxwellReport({ 0x05, 0x5B, 0x05, 0x00, 0x82, 0x2C, 0x00, 0x01, 0x00 }); + + // 4 minutes rounds up to 5 minutes = 300 s = 0x012C + TestableAudezeMaxwell device; + device.hid.replies.emplace_back(ack); + ASSERT_TRUE(device.setInactiveTime(nullptr, 4).hasValue(), "Inactive time should be set"); + ASSERT_TRUE((maxwellWritePrefix(device.hid.writes[0], 19) == std::vector { 0x06, 0x10, 0x80, 0x05, 0x5A, 0x0C, 0x00, 0x82, 0x2C, 0x01, 0x00, 0x01, 0x00, 0x2C, 0x01, 0x01, 0x00, 0x2C, 0x01 }), "Five minute request should match the documented bytes"); + + // 0 disables it + device.hid.replies.emplace_back(ack); + ASSERT_TRUE(device.setInactiveTime(nullptr, 0).hasValue(), "Inactive time should be disabled"); + ASSERT_TRUE((maxwellWritePrefix(device.hid.writes[1], 19) == std::vector { 0x06, 0x10, 0x80, 0x05, 0x5A, 0x0C, 0x00, 0x82, 0x2C, 0x01, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00 }), "Disable request"); + + // Above 240 minutes the longest setting is used: 360 minutes = 21600 s = 0x5460 + device.hid.replies.emplace_back(ack); + ASSERT_TRUE(device.setInactiveTime(nullptr, 250).hasValue(), "Inactive time should be set"); + ASSERT_EQ(0x60, static_cast(device.hid.writes[2][13]), "Seconds low byte"); + ASSERT_EQ(0x54, static_cast(device.hid.writes[2][14]), "Seconds high byte"); + + // A rejected write is an error + device.hid.replies.emplace_back(maxwellReport({ 0x05, 0x5B, 0x05, 0x00, 0x82, 0x2C, 0x01, 0x01, 0x00 })); + ASSERT_TRUE(device.setInactiveTime(nullptr, 30).hasError(), "Non-zero status should fail"); + + std::cout << " [OK] Audeze Maxwell inactive time verified" << std::endl; +} + +static std::vector maxwellEqTemplate() +{ + std::vector ui(AudezeMaxwell::EQ_SETTINGS_SIZE, 0); + ui[0] = 1; + ui[1] = 0xA5; // Uninterpreted header and band fields must survive an edit. + for (size_t i = 0; i < AudezeMaxwell::EQ_FREQUENCIES.size(); ++i) { + const size_t base = 5 + 18 * i; + ui[base] = static_cast(i); + ui[base + 1] = 0x01; + ui[base + 14] = 0x5A; + for (size_t byte = 0; byte < 4; ++byte) { + const auto value = static_cast(AudezeMaxwell::EQ_FREQUENCIES[i] * 100); + ui[base + 2 + byte] = static_cast(value >> (8 * byte)); + ui[base + 10 + byte] = static_cast(value >> (8 * byte)); + } + } + return ui; +} + +static void queueMaxwellMessage(TestableAudezeMaxwell& device, const std::vector& body) +{ + auto reports = AudezeMaxwell::buildReports(AudezeMaxwell::buildMessage(AudezeMaxwell::TYPE_RESPONSE, body)); + for (auto& report : reports) { + report[0] = AudezeMaxwell::REPORT_ID_IN; + device.hid.replies.emplace_back(std::vector(report.begin(), report.end())); + } +} + +static void queueMaxwellEqSlot(TestableAudezeMaxwell& device, uint8_t slot, const std::vector& ui) +{ + std::vector body { 0x0C, 0x0A, 0, slot, 0xEF, static_cast(ui.size()), 0 }; + body.insert(body.end(), ui.begin(), ui.end()); + queueMaxwellMessage(device, body); +} + +void testAudezeMaxwellEqCoefficients() +{ + const std::vector bands { { 125, -3, 100 }, { 1000, 6, 700 }, { 8000, -12, 5000 } }; + // Independent golden outputs from installed AirohaPeqLibrary.dll. + const std::array goldens { + "0300906b0022829c00d2735d00a49a8500724c770095ff7f00ffc8800065797e008b1881003fd97d00d1ff7f00ff2fd100d71c60000cdddd001a6b2300ee94030062", + "0300906b0022d29b005e855e00b812850021fa7700d8ff7f00ffb78000d1997e001701810044067e002cff7f00ffa4c700dd6c61008b70d600ed3c26004b9c03008a", + "0300906b00225b9800604064000d89820091907b0035ff7f00ff638000343c7f002e8b80006deb7e00bdff7f00ffa09c00a20d6c006a5baf00f58e3f0076ec0300ae", + "0300906b002207980075d364002d4f820099ea7b00e2ff7f00ff5b8000124c7f000c8080000b027f0019ff7f00ff329900fb686d008f7eab00872843005ef8030072" + }; + const std::array rates { 44100, 48000, 88200, 96000 }; + for (size_t i = 0; i < rates.size(); ++i) { + auto bytes = AudezeMaxwell::eqCoefficientsForRate(bands, rates[i]); + ASSERT_TRUE(bytes.hasValue(), "Coefficient generation succeeds"); + std::string hex; + for (auto byte : *bytes) { + hex += std::format("{:02x}", byte); + } + ASSERT_TRUE(hex == goldens[i], "Portable coefficients match independent DLL output"); + } + auto ui = maxwellEqTemplate(); + auto parsed = AudezeMaxwell::readEqBands(ui); + ASSERT_TRUE(parsed.hasValue(), "Ten band template parses"); + auto payload = AudezeMaxwell::eqCoefficients(*parsed); + ASSERT_TRUE(payload.hasValue(), "Four sample-rate payload builds"); + ASSERT_EQ(844u, payload->size(), "Ten-band coefficient payload length"); + ASSERT_EQ(4, static_cast((*payload)[0]), "Four sample rates"); + for (size_t i = 0; i < 4; ++i) { + ASSERT_EQ((std::array { 1, 2, 5, 6 })[i], static_cast((*payload)[4 + 210 * i]), "Sample-rate ID"); + ASSERT_EQ(103, static_cast((*payload)[6 + 210 * i]), "Word count includes bands and gain"); + } + ui.pop_back(); + ASSERT_TRUE(AudezeMaxwell::readEqBands(ui).hasError(), "Reject truncated settings"); + ui = maxwellEqTemplate(); + ui[17] = 0xFF; + ASSERT_TRUE(AudezeMaxwell::readEqBands(ui).hasError(), "Reject invalid bandwidth"); + ASSERT_TRUE(AudezeMaxwell::eqCoefficientsForRate(bands, 32000).hasError(), "Reject unsupported sample rate"); + auto invalid = bands; + invalid[0].gain = std::numeric_limits::quiet_NaN(); + ASSERT_TRUE(AudezeMaxwell::eqCoefficients(invalid).hasError(), "Reject nonfinite coefficients"); +} + +void testAudezeMaxwellCustomEq() +{ + for (uint8_t slot = 0; slot < 4; ++slot) { + TestableAudezeMaxwell device; + auto original = maxwellEqTemplate(); + auto expected = original; + EqualizerSettings settings(std::vector(10, 0)); + settings.bands[1] = -3; + const auto gain = static_cast(int32_t(-300)); + for (size_t byte = 0; byte < 4; ++byte) { + expected[5 + 18 + 6 + byte] = static_cast(gain >> (8 * byte)); + } + queueMaxwellMessage(device, { 0x01, 0x09, 0x00, 0x00, 0x00, static_cast(slot + 7) }); + queueMaxwellEqSlot(device, slot, original); + queueMaxwellMessage(device, { 0x03, 0x0E, 0 }); + queueMaxwellMessage(device, { 0x03, 0x0A, 1 }); + // Wrong slot acknowledgement is ignored. + queueMaxwellMessage(device, { 0x0D, 0x0A, 0, static_cast(slot + 1), 0xEF }); + queueMaxwellMessage(device, { 0x0D, 0x0A, 0, slot, 0xEF }); + queueMaxwellMessage(device, { 0x03, 0x0A, 1 }); + queueMaxwellMessage(device, { 0x0D, 0x0A, 0, static_cast(0x2C + slot), 0xE4 }); + queueMaxwellEqSlot(device, slot, expected); + auto expected_bands = AudezeMaxwell::readEqBands(expected); + auto coefficients = AudezeMaxwell::eqCoefficients(*expected_bands); + std::vector coefficient_reply { 0x0C, 0x0A, 0, static_cast(0x2C + slot), 0xE4, 0x4C, 3 }; + coefficient_reply.insert(coefficient_reply.end(), coefficients->begin(), coefficients->end()); + queueMaxwellMessage(device, coefficient_reply); + ASSERT_TRUE(device.setEqualizer(nullptr, settings).hasValue(), "Custom EQ is saved and verified"); + + std::vector stream; + std::vector messages; + for (const auto& write : device.hid.writes) { + ASSERT_EQ(62u, write.size(), "EQ report size"); + ASSERT_EQ(0x80, static_cast(write[2]), "Headset route"); + const auto fragment = AudezeMaxwell::reportFragment(write); + stream.insert(stream.end(), fragment.begin(), fragment.end()); + for (auto message = AudezeMaxwell::extractMessage(stream); !message.empty(); message = AudezeMaxwell::extractMessage(stream)) { + messages.push_back(std::move(message)); + } + } + ASSERT_TRUE(stream.empty(), "No incomplete output message"); + ASSERT_EQ(9u, messages.size(), "Preset query, slot read, five update commands and two readbacks"); + ASSERT_EQ(40u, device.hid.writes.size(), "Fragmented EQ report count"); + ASSERT_EQ(851u, messages[2].size(), "Realtime coefficient message"); + ASSERT_TRUE((messages[3] == std::vector { 5, 0x5A, 4, 0, 3, 0x0A, 185, 0 }), "Prepare UI byte count, not key"); + ASSERT_TRUE(std::equal(expected.begin(), expected.end(), messages[4].begin() + 8), "Only requested gains change in stored UI data"); + ASSERT_TRUE((messages[5] == std::vector { 5, 0x5A, 4, 0, 3, 0x0A, 0x4C, 3 }), "Prepare coefficient byte count"); + ASSERT_EQ(0x2C + slot, static_cast(messages[6][6]), "Correct coefficient slot"); + ASSERT_TRUE(std::equal(messages[2].begin() + 7, messages[2].end(), messages[6].begin() + 8), "Applied and saved coefficients are identical"); + } + AudezeMaxwell2 other_model; + ASSERT_TRUE(!(other_model.getCapabilities() & B(CAP_EQUALIZER)), "Original EQ must not be advertised for Maxwell 2"); + ASSERT_TRUE(other_model.setEqualizer(nullptr, EqualizerSettings {}).hasError(), "Maxwell 2 does not inherit the writer"); +} + +void testAudezeMaxwellEqFailures() +{ + const EqualizerSettings flat(std::vector(10, 0)); + for (float value : { -13.0f, 13.0f, 0.5f, std::numeric_limits::infinity(), std::numeric_limits::quiet_NaN() }) { + TestableAudezeMaxwell device; + auto invalid = flat; + invalid.bands[0] = value; + ASSERT_TRUE(device.setEqualizer(nullptr, invalid).hasError(), "Reject invalid gains"); + ASSERT_TRUE(device.hid.writes.empty(), "Validate before any USB traffic"); + } + TestableAudezeMaxwell wrong_count; + ASSERT_TRUE(wrong_count.setEqualizer(nullptr, EqualizerSettings {}).hasError(), "Reject wrong band count"); + ASSERT_TRUE(wrong_count.hid.writes.empty(), "Wrong count sends nothing"); + TestableAudezeMaxwell builtin; + queueMaxwellMessage(builtin, { 1, 9, 0, 0, 0, 1 }); + ASSERT_TRUE(builtin.setEqualizer(nullptr, flat).hasError(), "Never overwrite an unspecified custom slot"); + ASSERT_EQ(1u, builtin.hid.writes.size(), "Built-in preset only queried"); + // Stop on failure at every update stage; never retransmit NV writes. + const std::array, 5> acknowledgements { + std::vector { 3, 0x0E, 0 }, { 3, 0x0A, 0 }, { 0x0D, 0x0A, 0, 0, 0xEF }, + { 3, 0x0A, 0 }, { 0x0D, 0x0A, 0, 0x2C, 0xE4 } + }; + const std::array write_counts { 17, 18, 22, 23, 38 }; + for (size_t stage = 0; stage < acknowledgements.size(); ++stage) { + for (bool timeout : { false, true }) { + TestableAudezeMaxwell device; + queueMaxwellMessage(device, { 1, 9, 0, 0, 0, 7 }); + queueMaxwellEqSlot(device, 0, maxwellEqTemplate()); + for (size_t i = 0; i < stage; ++i) { + queueMaxwellMessage(device, acknowledgements[i]); + } + if (!timeout) { + auto refused = acknowledgements[stage]; + if (stage == 1 || stage == 3) { + refused.pop_back(); // Reclaim's byte 6 is not a status. + } else { + refused[2] = 1; + } + queueMaxwellMessage(device, refused); + } + auto result = device.setEqualizer(nullptr, flat); + ASSERT_TRUE(result.hasError(), "Missing or rejected ACK fails"); + ASSERT_EQ(write_counts[stage], device.hid.writes.size(), "No retry or later write after a failure"); + } + } + TestableAudezeMaxwell malformed; + queueMaxwellMessage(malformed, { 1, 9, 0, 0, 0, 7 }); + queueMaxwellEqSlot(malformed, 0, std::vector(10, 0)); + ASSERT_TRUE(malformed.setEqualizer(nullptr, flat).hasError(), "Malformed slot is rejected before writes"); + ASSERT_EQ(2u, malformed.hid.writes.size(), "Only queries before malformed slot failure"); + + TestableAudezeMaxwell readback; + queueMaxwellMessage(readback, { 1, 9, 0, 0, 0, 7 }); + queueMaxwellEqSlot(readback, 0, maxwellEqTemplate()); + for (const auto& ack : acknowledgements) { + queueMaxwellMessage(readback, ack); + } + auto wrong_ui = maxwellEqTemplate(); + wrong_ui[0] ^= 1; + queueMaxwellEqSlot(readback, 0, wrong_ui); + ASSERT_TRUE(readback.setEqualizer(nullptr, flat).hasError(), "Readback mismatch must not report success"); + + TestableAudezeMaxwell coefficient_readback; + queueMaxwellMessage(coefficient_readback, { 1, 9, 0, 0, 0, 7 }); + const auto original = maxwellEqTemplate(); + queueMaxwellEqSlot(coefficient_readback, 0, original); + for (const auto& ack : acknowledgements) { + queueMaxwellMessage(coefficient_readback, ack); + } + queueMaxwellEqSlot(coefficient_readback, 0, original); + auto bands = AudezeMaxwell::readEqBands(original); + auto coefficients = AudezeMaxwell::eqCoefficients(*bands); + std::vector reply { 0x0C, 0x0A, 0, 0x2C, 0xE4, 0x4C, 3 }; + reply.insert(reply.end(), coefficients->begin(), coefficients->end()); + reply.back() ^= 1; + queueMaxwellMessage(coefficient_readback, reply); + ASSERT_TRUE(coefficient_readback.setEqualizer(nullptr, flat).hasError(), "Coefficient mismatch must not report success"); + ASSERT_EQ(40u, coefficient_readback.hid.writes.size(), "Coefficient readback failure does not retry writes"); +} + void runAllProtocolTests() { std::cout << "\n============================================" << std::endl; @@ -1388,6 +1941,16 @@ void runAllProtocolTests() runTest("Logitech PRO X2 EQ Quantization", testLogitechProX2OnboardEqCoefficientQuantization); runTest("Logitech PRO X2 Onboard EQ Payload", testLogitechProX2OnboardEqPayloadBuilding); + std::cout << "\n=== Audeze Maxwell Protocol ===" << std::endl; + runTest("Audeze Maxwell Framing", testAudezeMaxwellFraming); + runTest("Audeze Maxwell Battery", testAudezeMaxwellBattery); + runTest("Audeze Maxwell Reply Matching", testAudezeMaxwellReplyMatching); + runTest("Audeze Maxwell Sidetone", testAudezeMaxwellSidetone); + runTest("Audeze Maxwell Inactive Time", testAudezeMaxwellInactiveTime); + runTest("Audeze Maxwell EQ Coefficients", testAudezeMaxwellEqCoefficients); + runTest("Audeze Maxwell Custom EQ", testAudezeMaxwellCustomEq); + runTest("Audeze Maxwell EQ Failures", testAudezeMaxwellEqFailures); + std::cout << "\n=== Plantronics Protocol ===" << std::endl; runTest("Plantronics BT600 Battery Parsing", testPlantronicsBT600BatteryParsing); runTest("Plantronics BT600 Reply Matching", testPlantronicsBT600ReplyMatching); From 93e9fbc7faecae9d1c125143d44c889d173ee4d2 Mon Sep 17 00:00:00 2001 From: Lucas Modolo Date: Fri, 9 Oct 2026 20:49:59 -0400 Subject: [PATCH 2/2] docs: Update README to remove Audeze EQ edits details Removed information about original Audeze Maxwell EQ edits. --- README.md | 2 -- 1 file changed, 2 deletions(-) diff --git a/README.md b/README.md index 53c10628..0a0302ac 100644 --- a/README.md +++ b/README.md @@ -200,8 +200,6 @@ sudo udevadm control --reload-rules && sudo udevadm trigger **Platform:** All = Linux, macOS, Windows | L/M = Linux and macOS only | L/W = Linux and Windows only -Original Audeze Maxwell custom EQ edits the selected custom preset (`-p 6` through `-p 9`), with ten whole-dB gains from -12 to +12. Close Audeze before use. - \* Only available on some product variants of that device. Sidetone status reading, for instance, is verified only for the SteelSeries Arctis Nova 7 Gen 2 (`1038:227e`). > **Note:** Some Corsair headsets may need additional configuration - see [Adding a Corsair device](docs/ADDING_A_CORSAIR_DEVICE.md). Some headsets (HS80, HS70 wired, RGB Elite, Virtuoso) expose sidetone via ALSA mixer instead.