Skip to content

Enable Hermite reads; add orbit read guard, soft-clip writes, and cadence thresholds#66

Open
ovelhaaa wants to merge 1 commit into
mainfrom
codex/fix-crackle-artifacts-in-orbit-effect
Open

Enable Hermite reads; add orbit read guard, soft-clip writes, and cadence thresholds#66
ovelhaaa wants to merge 1 commit into
mainfrom
codex/fix-crackle-artifacts-in-orbit-effect

Conversation

@ovelhaaa

@ovelhaaa ovelhaaa commented May 21, 2026

Copy link
Copy Markdown
Owner

Motivation

  • Reduce zipper/noise and enable higher-quality interpolation when available by exposing a compile-time Hermite read path and using it in reads.
  • Prevent unstable reads from reading too close to the write pointer and reduce harsh feedback growth by guarding read positions and soft-clipping writes into the delay buffer.
  • Reduce unnecessary expensive coefficient updates by adding stricter cadence thresholds for lowpass and diffuser updates.

Description

  • Adds ORBIT_DELAY_ENABLE_HERMITE=1 compile definition in core/CMakeLists.txt and introduces readDelayWrapped helper to select Hermite vs linear reads based on that define.
  • Adds kLowpassCadenceUpdateDeltaHz, kDiffuserCadenceUpdateDelta, and kOrbitReadGuardMinSamples/kOrbitReadGuardMaxSamples constants to orbit_delay_core.h and uses them in maybeApplyLowpassCutoff and maybeApplyDiffuserAmount to refine cadence-driven updates.
  • In processChannelFast clamps/adjusts readPos to a forward guard zone around the write pointer to avoid near-write reads, routes reads through readDelayWrapped, and applies softClipPolynomial to buffer writes before calling delay.write.

Testing

  • Built the core with the updated CMakeLists.txt and verified compilation with the ORBIT_DELAY_ENABLE_HERMITE define enabled using cmake and cmake --build.
  • Executed the project's automated test suite with ctest, and all tests completed successfully.
  • Ran a debug build smoke test exercising processChannel to verify no runtime NaNs from the new read-guard and soft-clip code and observed stable outputs.

Codex Task

Summary by CodeRabbit

  • New Features

    • Introduced Hermite interpolation for enhanced delay audio quality and improved sound fidelity.
  • Bug Fixes

    • Optimized parameter update behavior with threshold-based gating for stable, smooth automation.
    • Enhanced delay read safety with boundary guard logic to prevent edge-case artifacts.
    • Applied soft-clipping to feedback processing to prevent distortion buildup.

Review Change Stack

@coderabbitai

coderabbitai Bot commented May 21, 2026

Copy link
Copy Markdown
Contributor
📝 Walkthrough

Walkthrough

This PR enhances the delay processing engine by enabling conditional Hermite interpolation at compile time, refactoring delay reads to use the new logic, gating parameter updates on audible deltas, introducing guard logic to protect forward read positions, and applying soft-clipping to feedback writes.

Changes

Delay read quality and parameter update gating

Layer / File(s) Summary
Hermite compile-time flag and tuning constants
core/CMakeLists.txt, core/include/orbit_delay_core.h
CMake target orbit_delay_core now defines ORBIT_DELAY_ENABLE_HERMITE=1 as a public compile definition. Header constants added for lowpass/diffuser cadence update deltas and orbit read-guard min/max sample thresholds.
Wrapped delay-read with conditional interpolation
core/src/orbit_delay_core.cpp
New internal readDelayWrapped helper selects Hermite or linear interpolation based on the compile-time flag, centralizing the interpolation choice.
Audible cadence gating for parameter updates
core/src/orbit_delay_core.cpp
Lowpass and diffuser coefficient updates now require both a cadence trigger and a minimum delta threshold to apply, preventing inaudible parameter changes on every cadence tick.
Guarded forward-read position and soft-clipped feedback
core/src/orbit_delay_core.cpp
Forward read position computation now clamps a guard window away from write-position edges using the new constants. Delay reads use readDelayWrapped in both accidental-reverse and forward modes. Feedback written to the delay buffer is soft-clipped via softClipPolynomial(toBuffer) instead of raw toBuffer.

Estimated code review effort

🎯 3 (Moderate) | ⏱️ ~25 minutes

Possibly related PRs

  • ovelhaaa/orbit-echo#65: Introduces identical Hermite/linear wrapped read helper, orbit forward read-guard logic, and soft-clipped feedback writes alongside the same ORBIT_DELAY_ENABLE_HERMITE compile flag.
  • ovelhaaa/orbit-echo#62: Modifies OrbitDelayCore::processChannelFast read-position handling in reverse/AccidentalReverse modes, overlapping with the delay read path changes in this PR.
  • ovelhaaa/orbit-echo#60: Introduces softClipPolynomial utility that is now applied to feedback-to-delay writes in this PR.
🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 12.50% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title directly and specifically summarizes all the main changes in the PR: enabling Hermite reads, adding orbit read guard, soft-clip writes, and cadence thresholds.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch codex/fix-crackle-artifacts-in-orbit-effect
⚔️ Resolve merge conflicts
  • Resolve merge conflict in branch codex/fix-crackle-artifacts-in-orbit-effect

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

@gemini-code-assist gemini-code-assist Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code Review

This pull request enables Hermite interpolation, introduces a read guard to prevent pointer collisions in orbit mode, and adds soft clipping to the feedback loop. It also refines the update logic for lowpass and diffuser parameters using a cadence-based system. The review feedback identifies that the new cadence update thresholds are redundant compared to the immediate update thresholds, potentially preventing parameters from reaching their targets. Additionally, a correction is suggested for the read guard calculation to ensure it remains valid for small delay buffers.

Comment on lines +386 to +387
const bool cadenceAndAudible = heavyParamCadenceHit_ && delta >= kLowpassCadenceUpdateDeltaHz;
if (lowpassDirty_ || delta >= kLowpassUpdateDeltaHz || cadenceAndAudible) {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

The logic for cadence-driven updates is redundant and likely inverted. Currently, kLowpassUpdateDeltaHz is 20.0 and kLowpassCadenceUpdateDeltaHz is 80.0. Since delta >= 80.0 implies delta >= 20.0, the cadenceAndAudible condition is dead code—the filter will update every sample as soon as the delta exceeds 20.0, regardless of the cadence hit.

Furthermore, this change prevents the filter from ever reaching its final target if the smoother stops moving while the delta is less than 20Hz (whereas previously it would sync on the next cadence hit). To achieve the goal of reducing updates while maintaining accuracy, you should update immediately for large changes and on cadence for smaller ones.

Suggested change
const bool cadenceAndAudible = heavyParamCadenceHit_ && delta >= kLowpassCadenceUpdateDeltaHz;
if (lowpassDirty_ || delta >= kLowpassUpdateDeltaHz || cadenceAndAudible) {
const bool cadenceAndAudible = heavyParamCadenceHit_ && delta >= kLowpassUpdateDeltaHz;
if (lowpassDirty_ || delta >= kLowpassCadenceUpdateDeltaHz || cadenceAndAudible) {

Comment on lines +398 to +399
const bool cadenceAndAudible = heavyParamCadenceHit_ && delta >= kDiffuserCadenceUpdateDelta;
if (diffuserDirty_ || delta >= kDiffuserUpdateDelta || cadenceAndAudible) {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

Similar to the lowpass logic, the cadence check here is redundant because kDiffuserCadenceUpdateDelta (0.03) is greater than kDiffuserUpdateDelta (0.01). The immediate update condition will always trigger before the cadence condition.

Suggested change
const bool cadenceAndAudible = heavyParamCadenceHit_ && delta >= kDiffuserCadenceUpdateDelta;
if (diffuserDirty_ || delta >= kDiffuserUpdateDelta || cadenceAndAudible) {
const bool cadenceAndAudible = heavyParamCadenceHit_ && delta >= kDiffuserUpdateDelta;
if (diffuserDirty_ || delta >= kDiffuserCadenceUpdateDelta || cadenceAndAudible) {

#endif
const float writePos = static_cast<float>(delay.writePos);
const float maxGuardBySize = 0.25f * delaySize;
const float guardSamples = clampf(maxGuardBySize, kOrbitReadGuardMinSamples, kOrbitReadGuardMaxSamples);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

The guardSamples calculation floors at kOrbitReadGuardMinSamples (16), which can exceed maxGuardBySize (25% of the buffer) for small delay lines. If guardSamples becomes too large relative to delaySize, the read pointer may become stuck or the guard condition may always be true. The clamp should ensure the guard does not exceed the available safety margin.

Suggested change
const float guardSamples = clampf(maxGuardBySize, kOrbitReadGuardMinSamples, kOrbitReadGuardMaxSamples);
const float guardSamples = clampf(kOrbitReadGuardMaxSamples, kOrbitReadGuardMinSamples, maxGuardBySize);

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 34541b6e76

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment on lines +638 to +640
const float guardSamples = clampf(maxGuardBySize, kOrbitReadGuardMinSamples, kOrbitReadGuardMaxSamples);
const float distanceForward = wrapPosFloat(readPos - writePos, delaySize, invDelaySize);
if (distanceForward < guardSamples || distanceForward > (delaySize - guardSamples)) {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Cap read guard relative to delay size

Clamp guardSamples so it cannot exceed half the buffer size; with the current fixed minimum of 16 samples, any attached delay buffer smaller than 32 samples has no valid read region (distanceForward < guard or > size-guard is always true), so reads are forced to writePos + guard every sample. Since attachBufferMono/attachBuffers explicitly accept sizes down to 4, this regresses valid configurations by collapsing orbit/offset modulation into a fixed tap.

Useful? React with 👍 / 👎.

const float delta = std::fabs(clampedToneHz - appliedToneHz_);
if (lowpassDirty_ || heavyParamCadenceHit_ || delta >= kLowpassUpdateDeltaHz) {
const bool cadenceAndAudible = heavyParamCadenceHit_ && delta >= kLowpassCadenceUpdateDeltaHz;
if (lowpassDirty_ || delta >= kLowpassUpdateDeltaHz || cadenceAndAudible) {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Preserve lowpass updates for sub-20 Hz tone moves

Reintroduce a cadence-based fallback for small tone deltas; after this change, if lowpassDirty_ is false and the tone change stays below 20 Hz, maybeApplyLowpassCutoff never applies the new cutoff (delta >= 20 is false and cadenceAndAudible requires delta >= 80). Tone parameter updates do not set lowpassDirty_, so small but intentional adjustments can be ignored indefinitely.

Useful? React with 👍 / 👎.

const float delta = std::fabs(smoothedSmear - appliedSmear_);
if (diffuserDirty_ || heavyParamCadenceHit_ || delta >= kDiffuserUpdateDelta) {
const bool cadenceAndAudible = heavyParamCadenceHit_ && delta >= kDiffuserCadenceUpdateDelta;
if (diffuserDirty_ || delta >= kDiffuserUpdateDelta || cadenceAndAudible) {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Preserve diffuser updates for sub-0.01 smear moves

Keep a cadence-triggered path for small smear adjustments; with this condition, once diffuserDirty_ is cleared, smear changes below 0.01 never propagate (delta >= 0.01 is false and the cadence branch now also requires delta >= 0.03). That creates dead zones for fine automation/knob moves that previously converged through cadence updates.

Useful? React with 👍 / 👎.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 2

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@core/src/orbit_delay_core.cpp`:
- Around line 386-387: The cadence check (heavyParamCadenceHit_ && delta >=
kLowpassCadenceUpdateDeltaHz) is currently masked by the broader delta checks
(delta >= kLowpassUpdateDeltaHz / kDiffuserUpdateDelta), so cadence never
changes behavior; modify the boolean logic so cadence-triggered updates only
fire when the regular update thresholds would NOT already fire — for example,
replace the current condition with something like: if (lowpassDirty_ || delta >=
kLowpassUpdateDeltaHz || (heavyParamCadenceHit_ && delta >=
kLowpassCadenceUpdateDeltaHz && delta < kLowpassUpdateDeltaHz)) and apply the
analogous change for the diffuser branch (use kDiffuserCadenceUpdateDelta and
kDiffuserUpdateDelta / diffuserDirty_), ensuring the cadence guard is combined
with a check that delta is below the broader update threshold so cadence gating
actually takes effect.
- Around line 637-642: The guardSamples value may exceed the actual delaySize
for small buffers (causing reads to collapse onto the write head); ensure
guardSamples is bounded by the buffer feasibility before use by clamping it
against delaySize (e.g., limit to at most delaySize * 0.5f or delaySize - 1
sample equivalent) after computing maxGuardBySize and before comparing to
distanceForward; update the logic around maxGuardBySize/guardSamples (and the
subsequent use with wrapPosFloat, readPos, writePos, invDelaySize) so
guardSamples cannot be larger than the delay buffer allows.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: 8b1b1b21-1089-4aea-af15-7516868f1f48

📥 Commits

Reviewing files that changed from the base of the PR and between 9168ce5 and 34541b6.

📒 Files selected for processing (3)
  • core/CMakeLists.txt
  • core/include/orbit_delay_core.h
  • core/src/orbit_delay_core.cpp

Comment on lines +386 to +387
const bool cadenceAndAudible = heavyParamCadenceHit_ && delta >= kLowpassCadenceUpdateDeltaHz;
if (lowpassDirty_ || delta >= kLowpassUpdateDeltaHz || cadenceAndAudible) {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Cadence thresholds are currently ineffective due to condition dominance

Line 387 and Line 399 already trigger on lower deltas (kLowpassUpdateDeltaHz, kDiffuserUpdateDelta), so the new cadence checks never change behavior (80 >= 20, 0.03 >= 0.01). This defeats the intended cadence gating/perf win.

One concrete way to restore cadence-gated behavior
-    const bool cadenceAndAudible = heavyParamCadenceHit_ && delta >= kLowpassCadenceUpdateDeltaHz;
-    if (lowpassDirty_ || delta >= kLowpassUpdateDeltaHz || cadenceAndAudible) {
+    const bool cadenceAndAudible = heavyParamCadenceHit_ && delta >= kLowpassCadenceUpdateDeltaHz;
+    if (lowpassDirty_ || cadenceAndAudible) {
@@
-    const bool cadenceAndAudible = heavyParamCadenceHit_ && delta >= kDiffuserCadenceUpdateDelta;
-    if (diffuserDirty_ || delta >= kDiffuserUpdateDelta || cadenceAndAudible) {
+    const bool cadenceAndAudible = heavyParamCadenceHit_ && delta >= kDiffuserCadenceUpdateDelta;
+    if (diffuserDirty_ || cadenceAndAudible) {

Also applies to: 398-399

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@core/src/orbit_delay_core.cpp` around lines 386 - 387, The cadence check
(heavyParamCadenceHit_ && delta >= kLowpassCadenceUpdateDeltaHz) is currently
masked by the broader delta checks (delta >= kLowpassUpdateDeltaHz /
kDiffuserUpdateDelta), so cadence never changes behavior; modify the boolean
logic so cadence-triggered updates only fire when the regular update thresholds
would NOT already fire — for example, replace the current condition with
something like: if (lowpassDirty_ || delta >= kLowpassUpdateDeltaHz ||
(heavyParamCadenceHit_ && delta >= kLowpassCadenceUpdateDeltaHz && delta <
kLowpassUpdateDeltaHz)) and apply the analogous change for the diffuser branch
(use kDiffuserCadenceUpdateDelta and kDiffuserUpdateDelta / diffuserDirty_),
ensuring the cadence guard is combined with a check that delta is below the
broader update threshold so cadence gating actually takes effect.

Comment on lines +637 to +642
const float maxGuardBySize = 0.25f * delaySize;
const float guardSamples = clampf(maxGuardBySize, kOrbitReadGuardMinSamples, kOrbitReadGuardMaxSamples);
const float distanceForward = wrapPosFloat(readPos - writePos, delaySize, invDelaySize);
if (distanceForward < guardSamples || distanceForward > (delaySize - guardSamples)) {
readPos = wrapPosFloat(writePos + guardSamples, delaySize, invDelaySize);
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Guard size can collapse reads onto/near write position for small delay buffers

With Line 638 clamped to a minimum of 16 samples, guardSamples can exceed delaySize for valid small buffers, making the clamp branch always trigger and potentially wrapping to the write head. Clamp guard by buffer-size feasibility before use.

Suggested fix
-        const float maxGuardBySize = 0.25f * delaySize;
-        const float guardSamples = clampf(maxGuardBySize, kOrbitReadGuardMinSamples, kOrbitReadGuardMaxSamples);
+        const float maxGuardBySize = 0.25f * delaySize;
+        const float requestedGuard = clampf(maxGuardBySize, kOrbitReadGuardMinSamples, kOrbitReadGuardMaxSamples);
+        const float maxSafeGuard = clampf(0.5f * (delaySize - 1.0f), 1.0f, kOrbitReadGuardMaxSamples);
+        const float guardSamples = clampf(requestedGuard, 1.0f, maxSafeGuard);
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
const float maxGuardBySize = 0.25f * delaySize;
const float guardSamples = clampf(maxGuardBySize, kOrbitReadGuardMinSamples, kOrbitReadGuardMaxSamples);
const float distanceForward = wrapPosFloat(readPos - writePos, delaySize, invDelaySize);
if (distanceForward < guardSamples || distanceForward > (delaySize - guardSamples)) {
readPos = wrapPosFloat(writePos + guardSamples, delaySize, invDelaySize);
}
const float maxGuardBySize = 0.25f * delaySize;
const float requestedGuard = clampf(maxGuardBySize, kOrbitReadGuardMinSamples, kOrbitReadGuardMaxSamples);
const float maxSafeGuard = clampf(0.5f * (delaySize - 1.0f), 1.0f, kOrbitReadGuardMaxSamples);
const float guardSamples = clampf(requestedGuard, 1.0f, maxSafeGuard);
const float distanceForward = wrapPosFloat(readPos - writePos, delaySize, invDelaySize);
if (distanceForward < guardSamples || distanceForward > (delaySize - guardSamples)) {
readPos = wrapPosFloat(writePos + guardSamples, delaySize, invDelaySize);
}
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@core/src/orbit_delay_core.cpp` around lines 637 - 642, The guardSamples value
may exceed the actual delaySize for small buffers (causing reads to collapse
onto the write head); ensure guardSamples is bounded by the buffer feasibility
before use by clamping it against delaySize (e.g., limit to at most delaySize *
0.5f or delaySize - 1 sample equivalent) after computing maxGuardBySize and
before comparing to distanceForward; update the logic around
maxGuardBySize/guardSamples (and the subsequent use with wrapPosFloat, readPos,
writePos, invDelaySize) so guardSamples cannot be larger than the delay buffer
allows.

@ovelhaaa

Copy link
Copy Markdown
Owner Author

@coderabbitai resolve merge conflicts

@coderabbitai

coderabbitai Bot commented May 21, 2026

Copy link
Copy Markdown
Contributor

An unexpected error occurred while resolving merge conflicts:

Invalid tree info - https://docs.github.com/rest/git/trees#create-a-tree

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant