From 1638f0261b16e3736b56032b48ae23b9478ce434 Mon Sep 17 00:00:00 2001 From: qiin2333 <414382190@qq.com> Date: Fri, 14 Aug 2026 11:13:14 +0800 Subject: [PATCH 1/4] feat(core): add authored stereo haptics analysis --- .gitignore | 1 + ALGORITHM_PROVENANCE.md | 18 +- Android.mk | 1 + CHANGELOG.md | 9 + CMakeLists.txt | 12 +- README.md | 25 +- VERSION.txt | 2 +- include/moonlight_haptics/authored_haptics.h | 116 +++++++ include/moonlight_haptics/version.h | 6 +- platform/android/build.gradle | 2 +- src/core/authored_haptics_engine.cpp | 312 +++++++++++++++++++ tests/abi_test.cpp | 6 + tests/authored_haptics_test.cpp | 117 +++++++ tests/c_api_test.c | 11 +- tests/package_consumer/CMakeLists.txt | 2 +- tools/check_licenses.py | 4 +- 16 files changed, 630 insertions(+), 14 deletions(-) create mode 100644 include/moonlight_haptics/authored_haptics.h create mode 100644 src/core/authored_haptics_engine.cpp create mode 100644 tests/authored_haptics_test.cpp diff --git a/.gitignore b/.gitignore index 7e0b217..98caf92 100644 --- a/.gitignore +++ b/.gitignore @@ -1,4 +1,5 @@ /build/ +/build-*/ /consumer-build/ /dist/ /install/ diff --git a/ALGORITHM_PROVENANCE.md b/ALGORITHM_PROVENANCE.md index e9fc050..cdaa402 100644 --- a/ALGORITHM_PROVENANCE.md +++ b/ALGORITHM_PROVENANCE.md @@ -2,7 +2,7 @@ # Algorithm provenance -Moonlight Audio Haptics 0.5 contains four provenance classes: +Moonlight Audio Haptics 0.7 contains five provenance classes: 1. the FFT, spectral/PCEN feature path, robust onset picker, IR mapping, and platform-neutral engine are project-written Apache-2.0 code; @@ -11,11 +11,25 @@ Moonlight Audio Haptics 0.5 contains four provenance classes: 3. `src/core/causal_rhythm_clock.*` is project-written Apache-2.0 code informed by the published Real-Time Predominant Local Pulse method; 4. the GAME percussive/tonal features are project-written Apache-2.0 code - informed by published median-filter HPSS and SuperFlux methods. + informed by published median-filter HPSS and SuperFlux methods; +5. the ABI v2 authored-stereo analyzer is project-written Apache-2.0 code + using a first-order causal low-pass energy split and standard time-domain + amplitude, attack, zero-crossing, and cross-correlation measurements. The SDK does not compile or link aubio. It has no third-party runtime binary or model dependency. +## Authored-stereo fallback + +`src/core/authored_haptics_engine.cpp` is a project-written streaming feature +extractor. Each source lane maintains its own state; no downmix, antiphase +fusion, pitch tracker, learned model, or device response curve is used. The +roughly 200 Hz split is a first-order exponential low-pass whose coefficient is +derived from the configured sample rate. RMS, peak, positive attack, zero +crossing rate, and normalized lane correlation use their standard definitions. +These features form a lossy transport fallback and are not claimed to recreate +the source PCM or estimate musical pitch. + ## Public methods used - The iterative radix-2 transform uses the standard Cooley-Tukey FFT diff --git a/Android.mk b/Android.mk index 2d47992..a55c034 100644 --- a/Android.mk +++ b/Android.mk @@ -5,6 +5,7 @@ LOCAL_PATH := $(call my-dir) include $(CLEAR_VARS) LOCAL_MODULE := moonlight_haptics_core LOCAL_SRC_FILES := src/core/audio_haptics_engine.cpp \ + src/core/authored_haptics_engine.cpp \ src/core/causal_onset_detector.cpp \ src/core/causal_rhythm_clock.cpp \ src/core/feature_extractor.cpp \ diff --git a/CHANGELOG.md b/CHANGELOG.md index 683d3f2..78bce6e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,15 @@ while the public ABI has its own explicit version reported by ## Unreleased +- Add ABI v2 authored-stereo analysis for Sunshine fallback transports while + preserving the existing ABI v1 scene-authoring API. +- Preserve left/right source lanes in causal 5 ms IR frames with no processing + allocation, locking, device calibration, or actuator-specific curves. +- Reset cleanly on explicit discontinuity or sequence gaps and flush a marked + partial tail at end-of-stream. +- Add ABI-size, lane-isolation, chunk-invariance, discontinuity, timestamp, and + end-of-stream regression coverage. + ## 0.6.0 - 2026-07-24 - Add a dialogue-aware GAME soft mask using the bundled WebRTC GMM VAD, diff --git a/CMakeLists.txt b/CMakeLists.txt index 51197e6..0cb80e9 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -3,7 +3,7 @@ cmake_minimum_required(VERSION 3.16) project(moonlight_haptics - VERSION 0.6.0 + VERSION 0.7.0 DESCRIPTION "Portable real-time audio-to-haptics core" LANGUAGES C CXX ) @@ -54,6 +54,7 @@ set_target_properties(moonlight_haptics_fvad_objects PROPERTIES add_library(moonlight_haptics_core STATIC src/core/audio_haptics_engine.cpp + src/core/authored_haptics_engine.cpp src/core/causal_onset_detector.cpp src/core/causal_rhythm_clock.cpp src/core/feature_extractor.cpp @@ -163,6 +164,15 @@ if(MOONLIGHT_HAPTICS_BUILD_TESTS) moonlight_haptics_enable_test_assertions(moonlight_haptics_abi_test) add_test(NAME moonlight_haptics_abi COMMAND moonlight_haptics_abi_test) + add_executable(moonlight_haptics_authored_test + tests/authored_haptics_test.cpp) + target_link_libraries(moonlight_haptics_authored_test + PRIVATE moonlight_haptics_core) + moonlight_haptics_enable_test_assertions( + moonlight_haptics_authored_test) + add_test(NAME moonlight_haptics_authored + COMMAND moonlight_haptics_authored_test) + add_executable(moonlight_haptics_dsp_integration_test tests/dsp_integration_test.cpp) target_include_directories(moonlight_haptics_dsp_integration_test diff --git a/README.md b/README.md index 920a697..8baf44d 100644 --- a/README.md +++ b/README.md @@ -5,7 +5,8 @@ Portable C++17 audio-to-haptics core shared by Android, HarmonyOS and future server-side hosts such as Sunshine. -Version 0.5 keeps the stable ABI v1 and emits portable haptic IR from mono or +Version 0.7 keeps the stable scene-authoring ABI v1 and adds an additive ABI v2 +Sunshine analysis path. The v1 engine emits portable haptic IR from mono or multichannel PCM. It combines channel-aware spectral energy fusion, PCEN-style adaptation, and robust onset picking with a portable Apache-2.0 adaptation of AOSP HapticGenerator's causal actuator-shaped waveform. The AOSP waveform is @@ -55,6 +56,21 @@ current ABI has no channel-layout mask, so the dialogue path uses a layout-agnostic downmix and neutral centre evidence rather than guessing which channel is centre. A future ABI may add an explicit channel layout. +The separate authored-stereo API in +`include/moonlight_haptics/authored_haptics.h` is for Sunshine deployments +where a client cannot receive original DualSense haptics PCM. It emits one +device-independent lane per source channel every 5 ms: RMS and peak amplitude, +attack strength, low-band energy ratio, and zero-crossing texture. The lanes +are never downmixed. Sequence gaps and explicit discontinuities reset filter +history and mark the first new frame so a client can ramp cleanly instead of +replaying stale energy. End-of-stream flushes a marked partial frame. + +This authored IR is intentionally lossy and is not a replacement for raw PCM. +Sunshine should negotiate raw PCM first, analyze once only for clients needing +the fallback, and keep the analyzer outside the privileged capture sidecar. +Moonlight remains responsible for device calibration, actuator mapping, and +the final render curve. + ## Build and test ```bash @@ -91,7 +107,8 @@ human-readable release tag in a comment. ## ABI rules -- Public types live in `include/moonlight_haptics/audio_haptics.h` and compile as C11. +- Public types live in `include/moonlight_haptics/audio_haptics.h` and + `include/moonlight_haptics/authored_haptics.h` and compile as C11. - Every public struct starts with `struct_size`; callers zero-initialize structs. - `AhHapticFrame` is fixed at 80 bytes in ABI v1. Minor evolution consumes reserved fields; changing the array element size requires a new API/ABI. @@ -108,6 +125,10 @@ human-readable release tag in a comment. 32-bit atomics and may be updated from one control thread. - A larger PCM input may span multiple analysis hops, so the caller provides a fixed output array sized with `ah_get_max_output_frames()`. +- The ABI v2 authored analyzer accepts exactly two ordered haptics channels. + It preserves filter history across arbitrary input chunks, automatically + resets on a sequence gap, and exposes a separate capacity query that includes + a buffered partial window. ## License boundary diff --git a/VERSION.txt b/VERSION.txt index a918a2a..faef31a 100644 --- a/VERSION.txt +++ b/VERSION.txt @@ -1 +1 @@ -0.6.0 +0.7.0 diff --git a/include/moonlight_haptics/authored_haptics.h b/include/moonlight_haptics/authored_haptics.h new file mode 100644 index 0000000..b2c454e --- /dev/null +++ b/include/moonlight_haptics/authored_haptics.h @@ -0,0 +1,116 @@ +// SPDX-License-Identifier: Apache-2.0 + +#ifndef MOONLIGHT_HAPTICS_AUTHORED_HAPTICS_H +#define MOONLIGHT_HAPTICS_AUTHORED_HAPTICS_H + +#include +#include + +#include "moonlight_haptics/audio_haptics.h" + +#ifdef __cplusplus +extern "C" { +#endif + +/* + * Device-independent, two-lane authored haptics analysis. + * + * This API is a fallback for transports that cannot carry the original + * haptics PCM. It intentionally preserves the two source lanes and leaves + * actuator calibration and rendering to the client. + */ +typedef struct AhAuthoredEngine AhAuthoredEngine; + +typedef uint32_t AhAuthoredInputFlags; +enum { + AH_AUTHORED_INPUT_NONE = 0, + AH_AUTHORED_INPUT_STREAM_START = 1u << 0, + AH_AUTHORED_INPUT_DISCONTINUITY = 1u << 1, + AH_AUTHORED_INPUT_STREAM_END = 1u << 2 +}; + +typedef uint32_t AhAuthoredFrameFlags; +enum { + AH_AUTHORED_FRAME_NONE = 0, + AH_AUTHORED_FRAME_DISCONTINUITY = 1u << 0, + AH_AUTHORED_FRAME_PARTIAL = 1u << 1, + AH_AUTHORED_FRAME_STREAM_END = 1u << 2, + AH_AUTHORED_FRAME_SILENT = 1u << 3 +}; + +typedef struct AhAuthoredConfig { + uint32_t struct_size; + uint32_t sample_rate; + uint32_t channel_count; /* Must be 2. */ + uint32_t analysis_hop_frames; /* 0 selects 5 ms. */ + uint32_t feature_flags; /* Must be 0 in ABI v2. */ + uint32_t reserved[7]; +} AhAuthoredConfig; + +typedef struct AhAuthoredProcessInput { + uint32_t struct_size; + const int16_t* interleaved_pcm; + uint32_t frame_count; + uint32_t flags; /* AhAuthoredInputFlags bitset. */ + uint64_t first_sample_time_us; + uint32_t sequence_number; /* Monotonic per stream, wraps naturally. */ + uint32_t reserved; +} AhAuthoredProcessInput; + +typedef struct AhAuthoredLaneFrame { + float rms_amplitude; /* Linear full scale, 0.0 .. 1.0. */ + float peak_amplitude; /* Linear full scale, 0.0 .. 1.0. */ + float transient_strength; /* Relative attack, 0.0 .. 1.0. */ + float low_band_ratio; /* Energy below roughly 200 Hz, 0.0 .. 1.0. */ + float zero_crossing_rate_hz; /* Texture hint; not a pitch estimate. */ + uint32_t reserved[3]; +} AhAuthoredLaneFrame; + +typedef struct AhAuthoredHapticFrame { + uint32_t struct_size; + uint32_t flags; /* AhAuthoredFrameFlags bitset. */ + uint64_t timestamp_us; /* End time of the represented PCM window. */ + uint32_t source_sequence_number; + uint32_t source_frame_count; + AhAuthoredLaneFrame lanes[2]; /* Source order: left, right. */ + float lane_correlation; /* -1.0 .. 1.0; diagnostic only. */ + uint32_t reserved[3]; +} AhAuthoredHapticFrame; + +#define AH_AUTHORED_CONFIG_V2_SIZE 48u +#define AH_AUTHORED_PROCESS_INPUT_V2_SIZE 40u +#define AH_AUTHORED_LANE_FRAME_V2_SIZE 32u +#define AH_AUTHORED_HAPTIC_FRAME_V2_SIZE 104u + +MOONLIGHT_HAPTICS_API AhStatus ah_authored_config_init( + AhAuthoredConfig* config, + uint32_t sample_rate); + +MOONLIGHT_HAPTICS_API AhStatus ah_authored_create( + const AhAuthoredConfig* config, + AhAuthoredEngine** out_engine); + +MOONLIGHT_HAPTICS_API uint32_t ah_authored_get_max_output_frames( + const AhAuthoredEngine* engine, + uint32_t input_frame_count, + uint32_t input_flags); + +/* + * No allocation, locking, logging, or platform calls occur in this function. + * On AH_STATUS_BUFFER_TOO_SMALL, input is not consumed and out_count is zero. + */ +MOONLIGHT_HAPTICS_API AhStatus ah_authored_process_i16( + AhAuthoredEngine* engine, + const AhAuthoredProcessInput* input, + AhAuthoredHapticFrame* out_frames, + uint32_t out_capacity, + uint32_t* out_count); + +MOONLIGHT_HAPTICS_API void ah_authored_reset(AhAuthoredEngine* engine); +MOONLIGHT_HAPTICS_API void ah_authored_destroy(AhAuthoredEngine* engine); + +#ifdef __cplusplus +} // extern "C" +#endif + +#endif // MOONLIGHT_HAPTICS_AUTHORED_HAPTICS_H diff --git a/include/moonlight_haptics/version.h b/include/moonlight_haptics/version.h index 5fab539..be802e9 100644 --- a/include/moonlight_haptics/version.h +++ b/include/moonlight_haptics/version.h @@ -4,11 +4,11 @@ #define MOONLIGHT_HAPTICS_VERSION_H #define MOONLIGHT_HAPTICS_VERSION_MAJOR 0 -#define MOONLIGHT_HAPTICS_VERSION_MINOR 6 +#define MOONLIGHT_HAPTICS_VERSION_MINOR 7 #define MOONLIGHT_HAPTICS_VERSION_PATCH 0 -#define MOONLIGHT_HAPTICS_VERSION_STRING "0.6.0" +#define MOONLIGHT_HAPTICS_VERSION_STRING "0.7.0" #define MOONLIGHT_HAPTICS_PARAMETER_SET_VERSION "action-rpg-p4g-v6-dialogue" -#define MOONLIGHT_HAPTICS_ABI_VERSION 1u +#define MOONLIGHT_HAPTICS_ABI_VERSION 2u #endif // MOONLIGHT_HAPTICS_VERSION_H diff --git a/platform/android/build.gradle b/platform/android/build.gradle index 8680195..abca644 100644 --- a/platform/android/build.gradle +++ b/platform/android/build.gradle @@ -7,7 +7,7 @@ plugins { } group = 'com.moonlight.haptics' -version = (findProperty('sdkVersion') ?: '0.6.0-SNAPSHOT').toString() +version = (findProperty('sdkVersion') ?: '0.7.0-SNAPSHOT').toString() android { namespace 'com.moonlight.haptics.android' diff --git a/src/core/authored_haptics_engine.cpp b/src/core/authored_haptics_engine.cpp new file mode 100644 index 0000000..de9e709 --- /dev/null +++ b/src/core/authored_haptics_engine.cpp @@ -0,0 +1,312 @@ +// SPDX-License-Identifier: Apache-2.0 + +#include "moonlight_haptics/authored_haptics.h" + +#include +#include +#include +#include +#include + +namespace { + +constexpr uint32_t kMinimumSampleRate = 8000; +constexpr uint32_t kMaximumSampleRate = 192000; +constexpr float kSilenceFloor = 1.0F / 32768.0F; +constexpr float kPi = 3.14159265358979323846F; + +struct LaneAccumulator { + double squareSum = 0.0; + double lowSquareSum = 0.0; + float peak = 0.0F; + float lowPass = 0.0F; + float previousSample = 0.0F; + float previousRms = 0.0F; + float previousPeak = 0.0F; + uint32_t zeroCrossings = 0; + bool hasPreviousSample = false; +}; + +float Clamp01(float value) noexcept { + return std::max(0.0F, std::min(1.0F, value)); +} + +AhStatus ValidateConfig(const AhAuthoredConfig* config) noexcept { + if (config == nullptr || config->struct_size < AH_AUTHORED_CONFIG_V2_SIZE) { + return AH_STATUS_INVALID_ARGUMENT; + } + if (config->sample_rate < kMinimumSampleRate || + config->sample_rate > kMaximumSampleRate || + config->channel_count != 2U) { + return AH_STATUS_UNSUPPORTED; + } + if (config->feature_flags != 0U) return AH_STATUS_UNSUPPORTED; + if (config->analysis_hop_frames > config->sample_rate / 20U) { + return AH_STATUS_UNSUPPORTED; + } + return AH_STATUS_OK; +} + +} // namespace + +struct AhAuthoredEngine { + AhAuthoredEngine(uint32_t sampleRateValue, uint32_t hopFramesValue) + : sampleRate(sampleRateValue), + hopFrames(hopFramesValue), + lowPassAlpha(1.0F - std::exp( + -2.0F * kPi * 200.0F / static_cast(sampleRateValue))) {} + + uint32_t sampleRate; + uint32_t hopFrames; + float lowPassAlpha; + uint32_t accumulatedFrames = 0; + uint32_t expectedSequence = 0; + bool hasExpectedSequence = false; + bool markDiscontinuity = true; + LaneAccumulator lanes[2]; + double crossSum = 0.0; +}; + +namespace { + +void ResetWindow(AhAuthoredEngine& engine) noexcept { + engine.accumulatedFrames = 0; + engine.crossSum = 0.0; + for (LaneAccumulator& lane : engine.lanes) { + lane.squareSum = 0.0; + lane.lowSquareSum = 0.0; + lane.peak = 0.0F; + lane.zeroCrossings = 0; + } +} + +void ResetStream(AhAuthoredEngine& engine) noexcept { + ResetWindow(engine); + engine.hasExpectedSequence = false; + engine.markDiscontinuity = true; + for (LaneAccumulator& lane : engine.lanes) { + lane.lowPass = 0.0F; + lane.previousSample = 0.0F; + lane.previousRms = 0.0F; + lane.previousPeak = 0.0F; + lane.hasPreviousSample = false; + } +} + +void EmitFrame(AhAuthoredEngine& engine, + AhAuthoredHapticFrame& output, + uint64_t timestampUs, + uint32_t sourceSequence, + uint32_t flags) noexcept { + std::memset(&output, 0, sizeof(output)); + output.struct_size = AH_AUTHORED_HAPTIC_FRAME_V2_SIZE; + output.flags = flags; + if (engine.markDiscontinuity) { + output.flags |= AH_AUTHORED_FRAME_DISCONTINUITY; + engine.markDiscontinuity = false; + } + output.timestamp_us = timestampUs; + output.source_sequence_number = sourceSequence; + output.source_frame_count = engine.accumulatedFrames; + + bool silent = true; + for (uint32_t index = 0; index < 2U; ++index) { + LaneAccumulator& source = engine.lanes[index]; + AhAuthoredLaneFrame& lane = output.lanes[index]; + const double count = static_cast(engine.accumulatedFrames); + const float rms = static_cast(std::sqrt(source.squareSum / count)); + const float lowRms = static_cast( + std::sqrt(source.lowSquareSum / count)); + lane.rms_amplitude = Clamp01(rms); + lane.peak_amplitude = Clamp01(source.peak); + lane.transient_strength = Clamp01( + std::max(0.0F, rms - source.previousRms) * 4.0F + + std::max(0.0F, source.peak - source.previousPeak) * 0.5F); + lane.low_band_ratio = Clamp01( + (lowRms * lowRms) / (rms * rms + 1.0e-12F)); + lane.zero_crossing_rate_hz = + static_cast(source.zeroCrossings) * + static_cast(engine.sampleRate) / + (2.0F * static_cast(engine.accumulatedFrames)); + source.previousRms = rms; + source.previousPeak = source.peak; + silent = silent && source.peak <= kSilenceFloor; + } + + const double denominator = std::sqrt( + engine.lanes[0].squareSum * engine.lanes[1].squareSum); + output.lane_correlation = denominator > 1.0e-12 + ? std::max(-1.0F, std::min(1.0F, + static_cast(engine.crossSum / denominator))) + : 0.0F; + if (silent) output.flags |= AH_AUTHORED_FRAME_SILENT; +} + +} // namespace + +extern "C" { + +AhStatus ah_authored_config_init(AhAuthoredConfig* config, + uint32_t sample_rate) { + if (config == nullptr) return AH_STATUS_INVALID_ARGUMENT; + std::memset(config, 0, sizeof(*config)); + config->struct_size = AH_AUTHORED_CONFIG_V2_SIZE; + config->sample_rate = sample_rate; + config->channel_count = 2U; + config->analysis_hop_frames = (sample_rate + 100U) / 200U; + return ValidateConfig(config); +} + +AhStatus ah_authored_create(const AhAuthoredConfig* config, + AhAuthoredEngine** out_engine) { + if (out_engine == nullptr) return AH_STATUS_INVALID_ARGUMENT; + *out_engine = nullptr; + const AhStatus validation = ValidateConfig(config); + if (validation != AH_STATUS_OK) return validation; + const uint32_t hopFrames = config->analysis_hop_frames == 0U + ? (config->sample_rate + 100U) / 200U + : config->analysis_hop_frames; + if (hopFrames == 0U) return AH_STATUS_UNSUPPORTED; + try { + *out_engine = new AhAuthoredEngine(config->sample_rate, hopFrames); + } catch (const std::bad_alloc&) { + return AH_STATUS_OUT_OF_MEMORY; + } catch (...) { + return AH_STATUS_UNSUPPORTED; + } + return AH_STATUS_OK; +} + +uint32_t ah_authored_get_max_output_frames(const AhAuthoredEngine* engine, + uint32_t input_frame_count, + uint32_t input_flags) { + if (engine == nullptr) return 0U; + const bool resets = + (input_flags & (AH_AUTHORED_INPUT_STREAM_START | + AH_AUTHORED_INPUT_DISCONTINUITY)) != 0U; + const uint64_t total = (resets ? 0U : engine->accumulatedFrames) + + static_cast(input_frame_count); + uint64_t count = total / engine->hopFrames; + if ((input_flags & AH_AUTHORED_INPUT_STREAM_END) != 0U && + total % engine->hopFrames != 0U) { + ++count; + } + return count > std::numeric_limits::max() + ? std::numeric_limits::max() + : static_cast(count); +} + +AhStatus ah_authored_process_i16(AhAuthoredEngine* engine, + const AhAuthoredProcessInput* input, + AhAuthoredHapticFrame* out_frames, + uint32_t out_capacity, + uint32_t* out_count) { + if (out_count == nullptr) return AH_STATUS_INVALID_ARGUMENT; + *out_count = 0U; + constexpr uint32_t kValidInputFlags = + static_cast(AH_AUTHORED_INPUT_STREAM_START) | + static_cast(AH_AUTHORED_INPUT_DISCONTINUITY) | + static_cast(AH_AUTHORED_INPUT_STREAM_END); + if (engine == nullptr || input == nullptr || + input->struct_size < AH_AUTHORED_PROCESS_INPUT_V2_SIZE || + (input->frame_count > 0U && input->interleaved_pcm == nullptr) || + static_cast(input->frame_count) * 2U > + std::numeric_limits::max() || + (input->flags & ~kValidInputFlags) != 0U) { + return AH_STATUS_INVALID_ARGUMENT; + } + const uint64_t durationUs = + static_cast(input->frame_count) * 1000000ULL / + engine->sampleRate; + if (input->first_sample_time_us > + std::numeric_limits::max() - durationUs) { + return AH_STATUS_BAD_STATE; + } + + const bool explicitReset = + (input->flags & (AH_AUTHORED_INPUT_STREAM_START | + AH_AUTHORED_INPUT_DISCONTINUITY)) != 0U; + const bool sequenceGap = engine->hasExpectedSequence && + input->sequence_number != engine->expectedSequence; + const uint32_t bufferedBefore = (explicitReset || sequenceGap) + ? 0U + : engine->accumulatedFrames; + const uint64_t total = static_cast(bufferedBefore) + + input->frame_count; + uint64_t required = total / engine->hopFrames; + if ((input->flags & AH_AUTHORED_INPUT_STREAM_END) != 0U && + total % engine->hopFrames != 0U) { + ++required; + } + if (required > out_capacity) return AH_STATUS_BUFFER_TOO_SMALL; + if (required > 0U && out_frames == nullptr) return AH_STATUS_INVALID_ARGUMENT; + + if (explicitReset || sequenceGap) ResetStream(*engine); + engine->expectedSequence = input->sequence_number + 1U; + engine->hasExpectedSequence = true; + + for (uint32_t frameIndex = 0; frameIndex < input->frame_count; ++frameIndex) { + const size_t offset = static_cast(frameIndex) * 2U; + const float values[2] = { + static_cast(input->interleaved_pcm[offset]) / 32768.0F, + static_cast(input->interleaved_pcm[offset + 1U]) / 32768.0F}; + for (uint32_t laneIndex = 0; laneIndex < 2U; ++laneIndex) { + LaneAccumulator& lane = engine->lanes[laneIndex]; + const float value = values[laneIndex]; + lane.lowPass += engine->lowPassAlpha * (value - lane.lowPass); + lane.squareSum += static_cast(value) * value; + lane.lowSquareSum += static_cast(lane.lowPass) * lane.lowPass; + lane.peak = std::max(lane.peak, std::abs(value)); + if (lane.hasPreviousSample && + ((value >= 0.0F) != (lane.previousSample >= 0.0F))) { + ++lane.zeroCrossings; + } + lane.previousSample = value; + lane.hasPreviousSample = true; + } + engine->crossSum += static_cast(values[0]) * values[1]; + ++engine->accumulatedFrames; + + if (engine->accumulatedFrames == engine->hopFrames) { + const uint64_t timestamp = input->first_sample_time_us + + (static_cast(frameIndex) + 1U) * 1000000ULL / + engine->sampleRate; + uint32_t flags = AH_AUTHORED_FRAME_NONE; + if ((input->flags & AH_AUTHORED_INPUT_STREAM_END) != 0U && + frameIndex + 1U == input->frame_count) { + flags |= AH_AUTHORED_FRAME_STREAM_END; + } + EmitFrame(*engine, out_frames[*out_count], timestamp, + input->sequence_number, flags); + ++(*out_count); + ResetWindow(*engine); + } + } + + if ((input->flags & AH_AUTHORED_INPUT_STREAM_END) != 0U) { + if (engine->accumulatedFrames > 0U) { + const uint64_t timestamp = input->first_sample_time_us + + static_cast(input->frame_count) * 1000000ULL / + engine->sampleRate; + EmitFrame(*engine, out_frames[*out_count], timestamp, + input->sequence_number, + AH_AUTHORED_FRAME_PARTIAL | + AH_AUTHORED_FRAME_STREAM_END); + ++(*out_count); + ResetWindow(*engine); + } + engine->hasExpectedSequence = false; + } + + return *out_count == 0U ? AH_STATUS_OK : AH_STATUS_OUTPUT_AVAILABLE; +} + +void ah_authored_reset(AhAuthoredEngine* engine) { + if (engine != nullptr) ResetStream(*engine); +} + +void ah_authored_destroy(AhAuthoredEngine* engine) { + delete engine; +} + +} // extern "C" diff --git a/tests/abi_test.cpp b/tests/abi_test.cpp index 33dbfea..221d66c 100644 --- a/tests/abi_test.cpp +++ b/tests/abi_test.cpp @@ -1,6 +1,7 @@ // SPDX-License-Identifier: Apache-2.0 #include "moonlight_haptics/audio_haptics.h" +#include "moonlight_haptics/authored_haptics.h" #include #include @@ -21,6 +22,11 @@ static_assert(AH_FRAME_MUSIC_RESTART == (1u << 5), "music restart flag changed"); static_assert(sizeof(AhConfig) == 60, "AhConfig ABI v1 changed"); static_assert(sizeof(AhHapticFrame) == 80, "AhHapticFrame ABI v1 changed"); +static_assert(sizeof(AhAuthoredConfig) == 48, "AhAuthoredConfig ABI v2 changed"); +static_assert(sizeof(AhAuthoredProcessInput) == 40, + "AhAuthoredProcessInput ABI v2 changed"); +static_assert(sizeof(AhAuthoredHapticFrame) == 104, + "AhAuthoredHapticFrame ABI v2 changed"); static_assert(offsetof(AhHapticFrame, timestamp_us) == 8, "timestamp offset changed"); static_assert(offsetof(AhHapticFrame, continuous_amplitude) == 16, "continuous amplitude offset changed"); diff --git a/tests/authored_haptics_test.cpp b/tests/authored_haptics_test.cpp new file mode 100644 index 0000000..eb922eb --- /dev/null +++ b/tests/authored_haptics_test.cpp @@ -0,0 +1,117 @@ +// SPDX-License-Identifier: Apache-2.0 + +#include "moonlight_haptics/authored_haptics.h" + +#include +#include +#include +#include +#include + +namespace { + +std::vector MakeStereoTone(uint32_t frames, + float leftHz, + float rightHz) { + std::vector pcm(static_cast(frames) * 2U); + constexpr float kPi = 3.14159265358979323846F; + for (uint32_t index = 0; index < frames; ++index) { + const float time = static_cast(index) / 48000.0F; + pcm[static_cast(index) * 2U] = static_cast( + std::sin(2.0F * kPi * leftHz * time) * 16000.0F); + pcm[static_cast(index) * 2U + 1U] = rightHz == 0.0F + ? 0 + : static_cast( + std::sin(2.0F * kPi * rightHz * time) * 16000.0F); + } + return pcm; +} + +AhAuthoredProcessInput Input(const int16_t* pcm, + uint32_t frames, + uint32_t sequence, + uint32_t flags, + uint64_t timestamp) { + AhAuthoredProcessInput input{}; + input.struct_size = AH_AUTHORED_PROCESS_INPUT_V2_SIZE; + input.interleaved_pcm = pcm; + input.frame_count = frames; + input.flags = flags; + input.first_sample_time_us = timestamp; + input.sequence_number = sequence; + return input; +} + +} // namespace + +int main() { + static_assert(sizeof(AhAuthoredConfig) == AH_AUTHORED_CONFIG_V2_SIZE, + "authored config ABI changed"); + static_assert(sizeof(AhAuthoredProcessInput) == + AH_AUTHORED_PROCESS_INPUT_V2_SIZE, + "authored input ABI changed"); + static_assert(sizeof(AhAuthoredLaneFrame) == AH_AUTHORED_LANE_FRAME_V2_SIZE, + "authored lane ABI changed"); + static_assert(sizeof(AhAuthoredHapticFrame) == + AH_AUTHORED_HAPTIC_FRAME_V2_SIZE, + "authored frame ABI changed"); + + AhAuthoredConfig config{}; + assert(ah_authored_config_init(&config, 48000U) == AH_STATUS_OK); + assert(config.channel_count == 2U); + assert(config.analysis_hop_frames == 240U); + + AhAuthoredEngine* engine = nullptr; + assert(ah_authored_create(&config, &engine) == AH_STATUS_OK); + + const std::vector isolated = MakeStereoTone(240U, 120.0F, 0.0F); + AhAuthoredProcessInput input = Input( + isolated.data(), 240U, 10U, AH_AUTHORED_INPUT_STREAM_START, 1000000U); + AhAuthoredHapticFrame output[2]{}; + uint32_t count = 0U; + assert(ah_authored_process_i16(engine, &input, output, 2U, &count) == + AH_STATUS_OUTPUT_AVAILABLE); + assert(count == 1U); + assert(output[0].timestamp_us == 1005000U); + assert((output[0].flags & AH_AUTHORED_FRAME_DISCONTINUITY) != 0U); + assert(output[0].lanes[0].rms_amplitude > 0.2F); + assert(output[0].lanes[1].rms_amplitude == 0.0F); + assert(output[0].lanes[0].low_band_ratio > 0.2F); + const float oneShotRms = output[0].lanes[0].rms_amplitude; + + // Chunk boundaries do not change a complete hop's output. + ah_authored_reset(engine); + AhAuthoredProcessInput first = Input( + isolated.data(), 100U, 20U, AH_AUTHORED_INPUT_STREAM_START, 2000000U); + assert(ah_authored_process_i16(engine, &first, nullptr, 0U, &count) == + AH_STATUS_OK); + AhAuthoredProcessInput second = Input( + isolated.data() + 200U, 140U, 21U, AH_AUTHORED_INPUT_NONE, 2002083U); + assert(ah_authored_process_i16(engine, &second, output, 2U, &count) == + AH_STATUS_OUTPUT_AVAILABLE); + assert(count == 1U); + assert(std::abs(output[0].lanes[0].rms_amplitude - oneShotRms) < 1.0e-6F); + + // A sequence gap resets history and marks the next frame for a clean UX. + AhAuthoredProcessInput gap = Input( + isolated.data(), 240U, 23U, AH_AUTHORED_INPUT_NONE, 3000000U); + assert(ah_authored_process_i16(engine, &gap, output, 2U, &count) == + AH_STATUS_OUTPUT_AVAILABLE); + assert((output[0].flags & AH_AUTHORED_FRAME_DISCONTINUITY) != 0U); + + // End-of-stream flushes a partial window instead of dropping the tail. + AhAuthoredProcessInput tail = Input( + isolated.data(), 80U, 24U, + AH_AUTHORED_INPUT_STREAM_START | AH_AUTHORED_INPUT_STREAM_END, + 4000000U); + assert(ah_authored_get_max_output_frames(engine, 80U, tail.flags) == 1U); + assert(ah_authored_process_i16(engine, &tail, output, 1U, &count) == + AH_STATUS_OUTPUT_AVAILABLE); + assert(count == 1U); + assert(output[0].source_frame_count == 80U); + assert((output[0].flags & AH_AUTHORED_FRAME_PARTIAL) != 0U); + assert((output[0].flags & AH_AUTHORED_FRAME_STREAM_END) != 0U); + + ah_authored_destroy(engine); + return 0; +} diff --git a/tests/c_api_test.c b/tests/c_api_test.c index 13271d0..366048b 100644 --- a/tests/c_api_test.c +++ b/tests/c_api_test.c @@ -1,18 +1,25 @@ // SPDX-License-Identifier: Apache-2.0 #include "moonlight_haptics/audio_haptics.h" +#include "moonlight_haptics/authored_haptics.h" #include #include #include int main(void) { - assert(ah_get_abi_version() == 1u); - assert(strcmp(ah_get_version_string(), "0.6.0") == 0); + assert(ah_get_abi_version() == 2u); + assert(strcmp(ah_get_version_string(), "0.7.0") == 0); assert(strcmp(ah_get_parameter_set_version(), "action-rpg-p4g-v6-dialogue") == 0); assert(strcmp(ah_status_string(AH_STATUS_OK), "ok") == 0); + AhAuthoredConfig authored_config; + assert(ah_authored_config_init(&authored_config, 48000u) == AH_STATUS_OK); + AhAuthoredEngine* authored_engine = NULL; + assert(ah_authored_create(&authored_config, &authored_engine) == AH_STATUS_OK); + ah_authored_destroy(authored_engine); + AhConfig config; assert(ah_config_init(&config, 48000u, 2u) == AH_STATUS_OK); assert(config.struct_size == AH_CONFIG_V1_SIZE); diff --git a/tests/package_consumer/CMakeLists.txt b/tests/package_consumer/CMakeLists.txt index 9cfece5..e3f9a9a 100644 --- a/tests/package_consumer/CMakeLists.txt +++ b/tests/package_consumer/CMakeLists.txt @@ -3,7 +3,7 @@ cmake_minimum_required(VERSION 3.16) project(moonlight_haptics_package_consumer LANGUAGES CXX) -find_package(moonlight_haptics 0.6.0 CONFIG REQUIRED) +find_package(moonlight_haptics 0.7.0 CONFIG REQUIRED) add_executable(moonlight_haptics_package_consumer main.cpp) target_link_libraries(moonlight_haptics_package_consumer diff --git a/tools/check_licenses.py b/tools/check_licenses.py index 0d5d525..5e80980 100644 --- a/tools/check_licenses.py +++ b/tools/check_licenses.py @@ -46,7 +46,9 @@ def iter_source_files(root: Path): for path in root.rglob("*"): - if not path.is_file() or any(part in SKIP_DIRS for part in path.parts): + if not path.is_file() or any( + part in SKIP_DIRS or part.startswith("build-") + for part in path.parts): continue if path.suffix.lower() in SOURCE_SUFFIXES or path.name == "CMakeLists.txt": yield path From a1f2f3dc6ffd10acd74c6bbad7ac7736c55f2a7a Mon Sep 17 00:00:00 2001 From: qiin2333 <414382190@qq.com> Date: Fri, 14 Aug 2026 11:52:29 +0800 Subject: [PATCH 2/4] fix(core): harden authored stereo ABI boundaries --- README.md | 2 +- include/moonlight_haptics/authored_haptics.h | 6 ++++-- src/core/authored_haptics_engine.cpp | 5 +++++ tests/abi_test.cpp | 5 +++-- tests/authored_haptics_test.cpp | 21 ++++++++++++++++++++ tools/check_licenses.py | 3 ++- 6 files changed, 36 insertions(+), 6 deletions(-) diff --git a/README.md b/README.md index 8baf44d..ffeba06 100644 --- a/README.md +++ b/README.md @@ -125,7 +125,7 @@ human-readable release tag in a comment. 32-bit atomics and may be updated from one control thread. - A larger PCM input may span multiple analysis hops, so the caller provides a fixed output array sized with `ah_get_max_output_frames()`. -- The ABI v2 authored analyzer accepts exactly two ordered haptics channels. +- The ABI v2 authored-stereo analyzer accepts exactly two ordered haptics channels. It preserves filter history across arbitrary input chunks, automatically resets on a sequence gap, and exposes a separate capacity query that includes a buffered partial window. diff --git a/include/moonlight_haptics/authored_haptics.h b/include/moonlight_haptics/authored_haptics.h index b2c454e..f76c632 100644 --- a/include/moonlight_haptics/authored_haptics.h +++ b/include/moonlight_haptics/authored_haptics.h @@ -62,7 +62,8 @@ typedef struct AhAuthoredLaneFrame { float peak_amplitude; /* Linear full scale, 0.0 .. 1.0. */ float transient_strength; /* Relative attack, 0.0 .. 1.0. */ float low_band_ratio; /* Energy below roughly 200 Hz, 0.0 .. 1.0. */ - float zero_crossing_rate_hz; /* Texture hint; not a pitch estimate. */ + /* Zero crossings per second divided by two; a texture hint, not pitch. */ + float zero_crossing_rate_hz; uint32_t reserved[3]; } AhAuthoredLaneFrame; @@ -78,7 +79,8 @@ typedef struct AhAuthoredHapticFrame { } AhAuthoredHapticFrame; #define AH_AUTHORED_CONFIG_V2_SIZE 48u -#define AH_AUTHORED_PROCESS_INPUT_V2_SIZE 40u +#define AH_AUTHORED_PROCESS_INPUT_V2_SIZE \ + ((uint32_t)(offsetof(AhAuthoredProcessInput, reserved) + sizeof(uint32_t))) #define AH_AUTHORED_LANE_FRAME_V2_SIZE 32u #define AH_AUTHORED_HAPTIC_FRAME_V2_SIZE 104u diff --git a/src/core/authored_haptics_engine.cpp b/src/core/authored_haptics_engine.cpp index de9e709..9c60719 100644 --- a/src/core/authored_haptics_engine.cpp +++ b/src/core/authored_haptics_engine.cpp @@ -8,6 +8,10 @@ #include #include +static_assert(sizeof(AhAuthoredProcessInput) == + AH_AUTHORED_PROCESS_INPUT_V2_SIZE, + "authored input ABI prefix must cover the complete struct"); + namespace { constexpr uint32_t kMinimumSampleRate = 8000; @@ -296,6 +300,7 @@ AhStatus ah_authored_process_i16(AhAuthoredEngine* engine, ResetWindow(*engine); } engine->hasExpectedSequence = false; + engine->markDiscontinuity = true; } return *out_count == 0U ? AH_STATUS_OK : AH_STATUS_OUTPUT_AVAILABLE; diff --git a/tests/abi_test.cpp b/tests/abi_test.cpp index 221d66c..d2d027f 100644 --- a/tests/abi_test.cpp +++ b/tests/abi_test.cpp @@ -23,8 +23,9 @@ static_assert(AH_FRAME_MUSIC_RESTART == (1u << 5), static_assert(sizeof(AhConfig) == 60, "AhConfig ABI v1 changed"); static_assert(sizeof(AhHapticFrame) == 80, "AhHapticFrame ABI v1 changed"); static_assert(sizeof(AhAuthoredConfig) == 48, "AhAuthoredConfig ABI v2 changed"); -static_assert(sizeof(AhAuthoredProcessInput) == 40, - "AhAuthoredProcessInput ABI v2 changed"); +static_assert(AH_AUTHORED_PROCESS_INPUT_V2_SIZE == + offsetof(AhAuthoredProcessInput, reserved) + sizeof(uint32_t), + "AhAuthoredProcessInput ABI v2 prefix changed"); static_assert(sizeof(AhAuthoredHapticFrame) == 104, "AhAuthoredHapticFrame ABI v2 changed"); static_assert(offsetof(AhHapticFrame, timestamp_us) == 8, "timestamp offset changed"); diff --git a/tests/authored_haptics_test.cpp b/tests/authored_haptics_test.cpp index eb922eb..623f2cb 100644 --- a/tests/authored_haptics_test.cpp +++ b/tests/authored_haptics_test.cpp @@ -57,6 +57,8 @@ int main() { "authored frame ABI changed"); AhAuthoredConfig config{}; + assert(ah_authored_config_init(&config, 7999U) == AH_STATUS_UNSUPPORTED); + assert(ah_authored_config_init(&config, 192001U) == AH_STATUS_UNSUPPORTED); assert(ah_authored_config_init(&config, 48000U) == AH_STATUS_OK); assert(config.channel_count == 2U); assert(config.analysis_hop_frames == 240U); @@ -69,6 +71,17 @@ int main() { isolated.data(), 240U, 10U, AH_AUTHORED_INPUT_STREAM_START, 1000000U); AhAuthoredHapticFrame output[2]{}; uint32_t count = 0U; + assert(ah_authored_process_i16(engine, &input, output, 2U, nullptr) == + AH_STATUS_INVALID_ARGUMENT); + AhAuthoredProcessInput invalidFlags = input; + invalidFlags.flags |= 1U << 31U; + assert(ah_authored_process_i16( + engine, &invalidFlags, output, 2U, &count) == + AH_STATUS_INVALID_ARGUMENT); + assert(count == 0U); + assert(ah_authored_process_i16(engine, &input, output, 0U, &count) == + AH_STATUS_BUFFER_TOO_SMALL); + assert(count == 0U); assert(ah_authored_process_i16(engine, &input, output, 2U, &count) == AH_STATUS_OUTPUT_AVAILABLE); assert(count == 1U); @@ -112,6 +125,14 @@ int main() { assert((output[0].flags & AH_AUTHORED_FRAME_PARTIAL) != 0U); assert((output[0].flags & AH_AUTHORED_FRAME_STREAM_END) != 0U); + // STREAM_END establishes a new boundary even if the host omits START on + // the next stream, allowing the client to apply a clean fade-in. + AhAuthoredProcessInput afterEnd = Input( + isolated.data(), 240U, 25U, AH_AUTHORED_INPUT_NONE, 5000000U); + assert(ah_authored_process_i16(engine, &afterEnd, output, 1U, &count) == + AH_STATUS_OUTPUT_AVAILABLE); + assert((output[0].flags & AH_AUTHORED_FRAME_DISCONTINUITY) != 0U); + ah_authored_destroy(engine); return 0; } diff --git a/tools/check_licenses.py b/tools/check_licenses.py index 5e80980..79b1c98 100644 --- a/tools/check_licenses.py +++ b/tools/check_licenses.py @@ -46,9 +46,10 @@ def iter_source_files(root: Path): for path in root.rglob("*"): + relative_parts = path.relative_to(root).parts if not path.is_file() or any( part in SKIP_DIRS or part.startswith("build-") - for part in path.parts): + for part in relative_parts[:-1]): continue if path.suffix.lower() in SOURCE_SUFFIXES or path.name == "CMakeLists.txt": yield path From 3ca5da47043cc8669e7780a89e830cbc84af53d1 Mon Sep 17 00:00:00 2001 From: qiin2333 <414382190@qq.com> Date: Fri, 14 Aug 2026 13:56:38 +0800 Subject: [PATCH 3/4] fix(authored): make analysis timing chunk invariant --- include/moonlight_haptics/authored_haptics.h | 2 +- src/core/authored_haptics_engine.cpp | 74 +++++++++++++++----- tests/authored_haptics_test.cpp | 3 + 3 files changed, 61 insertions(+), 18 deletions(-) diff --git a/include/moonlight_haptics/authored_haptics.h b/include/moonlight_haptics/authored_haptics.h index f76c632..0c1cb2e 100644 --- a/include/moonlight_haptics/authored_haptics.h +++ b/include/moonlight_haptics/authored_haptics.h @@ -62,7 +62,7 @@ typedef struct AhAuthoredLaneFrame { float peak_amplitude; /* Linear full scale, 0.0 .. 1.0. */ float transient_strength; /* Relative attack, 0.0 .. 1.0. */ float low_band_ratio; /* Energy below roughly 200 Hz, 0.0 .. 1.0. */ - /* Zero crossings per second divided by two; a texture hint, not pitch. */ + /* Sign transitions per second; a texture hint, not a pitch estimate. */ float zero_crossing_rate_hz; uint32_t reserved[3]; } AhAuthoredLaneFrame; diff --git a/src/core/authored_haptics_engine.cpp b/src/core/authored_haptics_engine.cpp index 9c60719..ceb19f9 100644 --- a/src/core/authored_haptics_engine.cpp +++ b/src/core/authored_haptics_engine.cpp @@ -3,6 +3,7 @@ #include "moonlight_haptics/authored_haptics.h" #include +#include #include #include #include @@ -65,7 +66,10 @@ struct AhAuthoredEngine { float lowPassAlpha; uint32_t accumulatedFrames = 0; uint32_t expectedSequence = 0; + uint64_t timelineAnchorUs = 0; + uint64_t timelineFrames = 0; bool hasExpectedSequence = false; + bool hasTimeline = false; bool markDiscontinuity = true; LaneAccumulator lanes[2]; double crossSum = 0.0; @@ -87,6 +91,9 @@ void ResetWindow(AhAuthoredEngine& engine) noexcept { void ResetStream(AhAuthoredEngine& engine) noexcept { ResetWindow(engine); engine.hasExpectedSequence = false; + engine.hasTimeline = false; + engine.timelineAnchorUs = 0; + engine.timelineFrames = 0; engine.markDiscontinuity = true; for (LaneAccumulator& lane : engine.lanes) { lane.lowPass = 0.0F; @@ -131,7 +138,7 @@ void EmitFrame(AhAuthoredEngine& engine, lane.zero_crossing_rate_hz = static_cast(source.zeroCrossings) * static_cast(engine.sampleRate) / - (2.0F * static_cast(engine.accumulatedFrames)); + static_cast(engine.accumulatedFrames); source.previousRms = rms; source.previousPeak = source.peak; silent = silent && source.peak <= kSilenceFloor; @@ -146,6 +153,24 @@ void EmitFrame(AhAuthoredEngine& engine, if (silent) output.flags |= AH_AUTHORED_FRAME_SILENT; } +bool TimestampForFramePosition(uint64_t anchorUs, + uint64_t framePosition, + uint32_t sampleRate, + uint64_t& timestampUs) noexcept { + const uint64_t wholeSeconds = framePosition / sampleRate; + const uint64_t remainingFrames = framePosition % sampleRate; + if (wholeSeconds > std::numeric_limits::max() / 1000000ULL) { + return false; + } + const uint64_t deltaUs = wholeSeconds * 1000000ULL + + remainingFrames * 1000000ULL / sampleRate; + if (anchorUs > std::numeric_limits::max() - deltaUs) { + return false; + } + timestampUs = anchorUs + deltaUs; + return true; +} + } // namespace extern "C" { @@ -219,19 +244,23 @@ AhStatus ah_authored_process_i16(AhAuthoredEngine* engine, (input->flags & ~kValidInputFlags) != 0U) { return AH_STATUS_INVALID_ARGUMENT; } - const uint64_t durationUs = - static_cast(input->frame_count) * 1000000ULL / - engine->sampleRate; - if (input->first_sample_time_us > - std::numeric_limits::max() - durationUs) { - return AH_STATUS_BAD_STATE; - } - const bool explicitReset = (input->flags & (AH_AUTHORED_INPUT_STREAM_START | AH_AUTHORED_INPUT_DISCONTINUITY)) != 0U; const bool sequenceGap = engine->hasExpectedSequence && input->sequence_number != engine->expectedSequence; + const bool startsTimeline = explicitReset || sequenceGap || !engine->hasTimeline; + const uint64_t timelineFramesBefore = startsTimeline ? 0U : engine->timelineFrames; + if (timelineFramesBefore > std::numeric_limits::max() - input->frame_count) { + return AH_STATUS_BAD_STATE; + } + const uint64_t timelineFramesAfter = timelineFramesBefore + input->frame_count; + uint64_t inputEndTimestamp = 0; + if (!TimestampForFramePosition( + startsTimeline ? input->first_sample_time_us : engine->timelineAnchorUs, + timelineFramesAfter, engine->sampleRate, inputEndTimestamp)) { + return AH_STATUS_BAD_STATE; + } const uint32_t bufferedBefore = (explicitReset || sequenceGap) ? 0U : engine->accumulatedFrames; @@ -246,6 +275,11 @@ AhStatus ah_authored_process_i16(AhAuthoredEngine* engine, if (required > 0U && out_frames == nullptr) return AH_STATUS_INVALID_ARGUMENT; if (explicitReset || sequenceGap) ResetStream(*engine); + if (!engine->hasTimeline) { + engine->timelineAnchorUs = input->first_sample_time_us; + engine->timelineFrames = 0; + engine->hasTimeline = true; + } engine->expectedSequence = input->sequence_number + 1U; engine->hasExpectedSequence = true; @@ -270,11 +304,15 @@ AhStatus ah_authored_process_i16(AhAuthoredEngine* engine, } engine->crossSum += static_cast(values[0]) * values[1]; ++engine->accumulatedFrames; + ++engine->timelineFrames; if (engine->accumulatedFrames == engine->hopFrames) { - const uint64_t timestamp = input->first_sample_time_us + - (static_cast(frameIndex) + 1U) * 1000000ULL / - engine->sampleRate; + uint64_t timestamp = 0; + const bool validTimestamp = TimestampForFramePosition( + engine->timelineAnchorUs, engine->timelineFrames, + engine->sampleRate, timestamp); + (void)validTimestamp; + assert(validTimestamp); uint32_t flags = AH_AUTHORED_FRAME_NONE; if ((input->flags & AH_AUTHORED_INPUT_STREAM_END) != 0U && frameIndex + 1U == input->frame_count) { @@ -289,9 +327,12 @@ AhStatus ah_authored_process_i16(AhAuthoredEngine* engine, if ((input->flags & AH_AUTHORED_INPUT_STREAM_END) != 0U) { if (engine->accumulatedFrames > 0U) { - const uint64_t timestamp = input->first_sample_time_us + - static_cast(input->frame_count) * 1000000ULL / - engine->sampleRate; + uint64_t timestamp = 0; + const bool validTimestamp = TimestampForFramePosition( + engine->timelineAnchorUs, engine->timelineFrames, + engine->sampleRate, timestamp); + (void)validTimestamp; + assert(validTimestamp); EmitFrame(*engine, out_frames[*out_count], timestamp, input->sequence_number, AH_AUTHORED_FRAME_PARTIAL | @@ -299,8 +340,7 @@ AhStatus ah_authored_process_i16(AhAuthoredEngine* engine, ++(*out_count); ResetWindow(*engine); } - engine->hasExpectedSequence = false; - engine->markDiscontinuity = true; + ResetStream(*engine); } return *out_count == 0U ? AH_STATUS_OK : AH_STATUS_OUTPUT_AVAILABLE; diff --git a/tests/authored_haptics_test.cpp b/tests/authored_haptics_test.cpp index 623f2cb..184e17f 100644 --- a/tests/authored_haptics_test.cpp +++ b/tests/authored_haptics_test.cpp @@ -90,6 +90,8 @@ int main() { assert(output[0].lanes[0].rms_amplitude > 0.2F); assert(output[0].lanes[1].rms_amplitude == 0.0F); assert(output[0].lanes[0].low_band_ratio > 0.2F); + assert(output[0].lanes[0].zero_crossing_rate_hz >= 180.0F); + assert(output[0].lanes[0].zero_crossing_rate_hz < 300.0F); const float oneShotRms = output[0].lanes[0].rms_amplitude; // Chunk boundaries do not change a complete hop's output. @@ -104,6 +106,7 @@ int main() { AH_STATUS_OUTPUT_AVAILABLE); assert(count == 1U); assert(std::abs(output[0].lanes[0].rms_amplitude - oneShotRms) < 1.0e-6F); + assert(output[0].timestamp_us == 2005000U); // A sequence gap resets history and marks the next frame for a clean UX. AhAuthoredProcessInput gap = Input( From 6afdfc29029d3735f37f225a00ba6518b83146e1 Mon Sep 17 00:00:00 2001 From: qiin2333 <414382190@qq.com> Date: Fri, 14 Aug 2026 15:25:38 +0800 Subject: [PATCH 4/4] test(authored): verify error output reset --- tests/authored_haptics_test.cpp | 2 ++ 1 file changed, 2 insertions(+) diff --git a/tests/authored_haptics_test.cpp b/tests/authored_haptics_test.cpp index 184e17f..791f1c8 100644 --- a/tests/authored_haptics_test.cpp +++ b/tests/authored_haptics_test.cpp @@ -75,10 +75,12 @@ int main() { AH_STATUS_INVALID_ARGUMENT); AhAuthoredProcessInput invalidFlags = input; invalidFlags.flags |= 1U << 31U; + count = 7U; assert(ah_authored_process_i16( engine, &invalidFlags, output, 2U, &count) == AH_STATUS_INVALID_ARGUMENT); assert(count == 0U); + count = 7U; assert(ah_authored_process_i16(engine, &input, output, 0U, &count) == AH_STATUS_BUFFER_TOO_SMALL); assert(count == 0U);