Skip to content

feat(player): add reverse playback and fix device/decoder issues - #117

Merged
LSXPrime merged 1 commit into
masterfrom
feature/reverse-playback-and-device-fixes
Oct 22, 2026
Merged

LSXPrime merged 1 commit into
masterfrom
feature/reverse-playback-and-device-fixes

Conversation

@LSXPrime

Copy link
Copy Markdown
Owner

Adds reverse playback, device-level audio processing events, and several decoder and distribution fixes.

Playback:

  • Support negative PlaybackSpeed values for reverse playback, including reverse looping.
  • Bypass WSOLA for negative speeds and use linear interpolation; pitch is affected in reverse.
  • Reset internal buffers when the playback direction changes.
  • Allow speeds down to -1.0x in SimplePlayer sample project and show hours for long files.

Note: Pitch preservation is not available for reverse playback due to WSOLA limitations.

Devices:

  • Move OnAudioProcessed from AudioCaptureDevice to AudioDevice so playback devices raise it too, fixing WebRTC AEC not receiving the far-end stream.
  • Route FullDuplexDevice subscriptions to both child devices.
  • Rename DeviceSwitcher.PreserveCaptureState/RestoreCaptureState to PreserveEventSubscribers/RestoreEventSubscribers and accept AudioDevice.

Decoders:

  • Add configurable seekPointCount to MiniAudioDecoder (default 100) so seeking no longer requires a known stream length.
  • Fix relative seek offset calculation in the decoder seek callback and clamp targets to stream bounds.
  • Use the larger of decoder and metadata length estimates in StreamDataProvider.Length to prevent premature end-of-stream and Time > Duration states.

Build:

  • Patch iOS FFmpeg framework Info.plist for App Store validation (CFBundleShortVersionString, CFBundleVersion, CFBundleName, MinimumOSVersion).
  • Migrate Windows runners to windows-2025 and Visual Studio generator to 18 2026.
  • Switch NuGet publishing to OIDC via NuGet/login@v1.
  • Update Android setup to v4 with explicit JDK 17.

Related Issues

- Add negative playback speed support with reverse audio generation, looping, and WSOLA bypass
- Unify OnAudioProcessed event in AudioDevice base class; fix WebRTC AEC far-end reference (#116)
- Improve MP3 seeking in MiniAudio decoder with configurable seek points and stream-length-agnostic seeking
- Improve StreamDataProvider length calculation with better fallback logic
- Rename DeviceSwitcher methods to PreserveEventSubscribers/RestoreEventSubscribers
- Fix iOS FFMpeg framework Info.plist for App Store validation (#113)
- Update CI: Windows 2025 runners, VS 2026, OIDC NuGet auth, Android setup v4, JDK 17
- Minor fixes: PlaybackEnded placement, time format with hours, reverse EOS handling

Fixes #116, #115, #114, #113
@qodo-free-for-open-source-projects

Copy link
Copy Markdown

PR Summary by Qodo

Add reverse playback and fix audio device and decoder behavior

✨ Enhancement 🐞 Bug fix ⚙️ Configuration changes 🕐 40+ Minutes

Grey Divider

AI Description

• Add reverse playback and looping, with a sample-player speed control and long-file time display.
• Deliver playback audio events for far-end AEC and preserve subscribers when devices switch.
• Improve decoder seeking and duration estimates; update iOS packaging and release workflows.
Diagram

graph TD
  Speed["Playback speed"] --> Direction{"Reverse?"} -->|yes| Reverse["Reverse read"] --> Output["Audio output"]
  Direction -->|no| Forward["Forward read"] --> Stretch["WSOLA optional"] --> Output
  Provider["Data provider"] --> Reverse
  Provider --> Forward
Loading
High-Level Assessment

The following are alternative approaches to this PR:

1. Reverse-aware buffered source reader
  • ➕ Could centralize reverse reads and reduce repeated decoder seeks.
  • ➕ Could make cursor and loop-boundary behavior easier to test independently.
  • ➖ Adds buffering state and complexity across providers.
  • ➖ Is broader than the current player-level implementation.

Recommendation: The player-level approach is a reasonable first implementation because it reuses existing providers and leaves forward WSOLA behavior in place. Review reverse cursor advancement, partial reads, and loop boundaries carefully; a shared reverse reader becomes more compelling if repeated seeking proves costly.

Files changed (22) +430 / -129

Enhancement (6) +197 / -32
library.cPass seek-point count into native decoder configuration +3/-1

Pass seek-point count into native decoder configuration

• Extends decoder configuration allocation to accept a seek-point count and assign it to miniaudio's decoder config.

Native/miniaudio-backend/library.c

library.hDeclare the extended decoder configuration API +1/-1

Declare the extended decoder configuration API

• Adds the seek-point-count argument to the exported native decoder configuration declaration.

Native/miniaudio-backend/library.h

UserInterfaceService.csExpose reverse speeds and hours in the sample player +5/-2

Expose reverse speeds and hours in the sample player

• Allows the decrease-speed control to reach -1.0x. Displays hours for playback times of at least one hour.

Samples/SoundFlow.Samples.SimplePlayer/UserInterfaceService.cs

AudioDevice.csMake processed-audio events available to all devices +28/-1

Make processed-audio events available to all devices

• Defines OnAudioProcessed on the common device base and adds protected invocation and internal subscriber-access helpers for capture and playback devices.

Src/Abstracts/Devices/AudioDevice.cs

SoundPlayerBase.csRender reverse audio and handle reverse looping +158/-25

Render reverse audio and handle reverse looping

• Accepts negative nonzero speeds, resets processing buffers on direction changes, and reads reversed frames directly at -1.0x or through linear interpolation at other reverse speeds. Bypasses WSOLA in reverse and changes loop and end-of-stream handling to account for backward movement.

Src/Abstracts/SoundPlayerBase.cs

Native.csAlign managed decoder interop with native seeking options +2/-2

Align managed decoder interop with native seeking options

• Permits a null decoder seek callback and passes the seek-point count through the native decoder configuration import.

Src/Backends/MiniAudio/Native.cs

Bug fix (5) +154 / -49
FullDuplexDevice.csRoute full-duplex events to both child devices +22/-5

Route full-duplex events to both child devices

• Subscribes and unsubscribes handlers on both capture and playback devices so listeners can receive near-end and far-end frames. Retrieves capture-child subscribers for state preservation.

Src/Abstracts/Devices/FullDuplexDevice.cs

MiniAudioPlaybackDevice.csPublish rendered playback buffers to subscribers +7/-1

Publish rendered playback buffers to subscribers

• Raises OnAudioProcessed after rendering in both float and converted-output paths, making far-end playback samples available to listeners such as WebRTC AEC.

Src/Backends/MiniAudio/Devices/MiniAudioPlaybackDevice.cs

MiniAudioDecoder.csImprove decoder seeking and tolerate unknown length +55/-28

Improve decoder seeking and tolerate unknown length

• Adds an optional seek-point count, omits the native seek callback for non-seekable streams, and allows decoder initialization and seeking when length lookup fails. Corrects relative callback offsets by computing and clamping an absolute stream position.

Src/Backends/MiniAudio/MiniAudioDecoder.cs

MiniAudioEngine.csPreserve processing subscribers across device switches +12/-4

Preserve processing subscribers across device switches

• Transfers event subscribers when switching playback or capture devices and transfers each child device's subscribers when switching a full-duplex device.

Src/Backends/MiniAudio/MiniAudioEngine.cs

StreamDataProvider.csUse the larger available audio-length estimate +58/-11

Use the larger available audio-length estimate

• Compares decoder length with a guarded duration-derived metadata estimate and returns the larger positive value. This aims to avoid understating duration and restricting seeks near the end of a stream.

Src/Providers/StreamDataProvider.cs

Refactor (2) +13 / -29
AudioCaptureDevice.csRemove capture-only processing event implementation +1/-25

Remove capture-only processing event implementation

• Removes the event, invocation helper, and subscriber accessor now supplied by AudioDevice.

Src/Abstracts/Devices/AudioCaptureDevice.cs

DeviceSwitcher.csGeneralize processing-subscriber transfer to audio devices +12/-4

Generalize processing-subscriber transfer to audio devices

• Renames the capture-specific subscriber preservation helpers and accepts AudioDevice so playback subscriptions can be transferred too.

Src/Utils/DeviceSwitcher.cs

Documentation (1) +6 / -2
ISoundPlayer.csDocument negative playback speed semantics +6/-2

Document negative playback speed semantics

• Explains that negative speeds play backward using linear interpolation rather than pitch-preserving time stretching.

Src/Interfaces/ISoundPlayer.cs

Other (8) +60 / -17
build-extensions-apm.ymlPin Windows APM builds to the 2025 runner +2/-2

Pin Windows APM builds to the 2025 runner

• Changes the Windows x64 and x86 build matrix entries from windows-latest to windows-2025.

.github/workflows/build-extensions-apm.yml

build-ffmpeg.ymlPin Windows builds and patch the iOS FFmpeg framework plist +25/-2

Pin Windows builds and patch the iOS FFmpeg framework plist

• Moves Windows builds to windows-2025. Adds a post-build step setting iOS framework bundle version and name fields and adding MinimumOSVersion when absent for App Store validation.

.github/workflows/build-ffmpeg.yml

build-miniaudio.ymlRefresh Windows and Android native build tooling +13/-7

Refresh Windows and Android native build tooling

• Pins Windows builds to windows-2025 and changes the Visual Studio generator to 18 2026. Adds a JDK 17 setup step and upgrades the Android setup action to v4.

.github/workflows/build-miniaudio.yml

build-portmidi.ymlRefresh Windows PortMidi build tooling +4/-4

Refresh Windows PortMidi build tooling

• Pins Windows matrix entries to windows-2025 and selects the Visual Studio 18 2026 CMake generator.

.github/workflows/build-portmidi.yml

release.ymlAuthenticate NuGet publishing through OIDC +15/-1

Authenticate NuGet publishing through OIDC

• Grants the release job token permissions and uses NuGet/login@v1 to obtain a temporary publishing key in place of the stored NuGet API key.

.github/workflows/release.yml

Info.plistChange the bundled iOS miniaudio framework plist +0/-0

Change the bundled iOS miniaudio framework plist

• The file is listed as changed, but the supplied text diff contains no contents or hunks from which to describe its specific changes.

Src/Backends/MiniAudio/runtimes/iOS-arm64/native/miniaudio.framework/Info.plist

miniaudioChange the bundled iOS miniaudio framework binary +0/-0

Change the bundled iOS miniaudio framework binary

• The framework binary is listed as changed; its binary contents and specific differences are not visible in the supplied text diff.

Src/Backends/MiniAudio/runtimes/iOS-arm64/native/miniaudio.framework/miniaudio

SoundFlow.csprojBump the SoundFlow package version +1/-1

Bump the SoundFlow package version

• Changes the package version from 1.4.1 to 1.4.2.

Src/SoundFlow.csproj

@qodo-free-for-open-source-projects

qodo-free-for-open-source-projects Bot commented Oct 22, 2026 •

Copy link
Copy Markdown

Code Review by Qodo

🐞 Bugs (15) 📘 Rule violations (0) 📎 Requirement gaps (0) 🎨 UX issues (0) 🔗 Cross-repo conflicts (0) 📜 Skill insights (0)

Grey Divider


Action required

1. Long reverse buffers repeat audio 🐞 Bug ≡ Correctness
Description
FillResampleBuffer computes every reverse read position from _rawSamplePosition, which
GenerateAudio updates only after finishing the output buffer. When one output call requires
another fill after exhausting the first, it reads the same source region again.
Code

Src/Abstracts/SoundPlayerBase.cs[R388-389]

+                var samplesToRead = spaceAvailableInResampleBuffer;
+                var targetPos = _rawSamplePosition - samplesToRead;
Evidence
The reverse branch calculates its target from the unchanged field and only returns a local negative
read count; the caller applies accumulated counts after the generation loop.

Src/Abstracts/SoundPlayerBase.cs[279-285]
Src/Abstracts/SoundPlayerBase.cs[325-335]
Src/Abstracts/SoundPlayerBase.cs[387-425]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
Multiple reverse fills in one output call can reread the same samples.
## Fix Focus Areas
- Src/Abstracts/SoundPlayerBase.cs[279-335]
- Src/Abstracts/SoundPlayerBase.cs[380-425]
## Recommended Fix
Maintain a cursor for the next reverse source read and move it after each successful fill, independently of the position of audio already rendered.

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


2. Reverse loops play outside their range 🐞 Bug ≡ Correctness
Description
GenerateAudio checks the reverse loop start only before generating a buffer, but its reverse reads
can continue below _loopStartSamples. A callback that crosses the configured start emits
out-of-loop samples before the next callback can wrap.
Code

Src/Abstracts/SoundPlayerBase.cs[R174-176]

+            var actualLoopEnd = (_loopEndSamples == -1) ? _dataProvider.Length : Math.Min(_loopEndSamples, _dataProvider.Length);
+            if (_loopStartSamples < actualLoopEnd && _rawSamplePosition <= _loopStartSamples)
+                Seek(actualLoopEnd, channels);
Evidence
The new loop test runs once before generation; neither reverse read target is bounded by the loop
start, and the direct path copies the resulting samples immediately.

Src/Abstracts/SoundPlayerBase.cs[171-177]
Src/Abstracts/SoundPlayerBase.cs[203-243]
Src/Abstracts/SoundPlayerBase.cs[387-416]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
A reverse read can cross the loop start within one output callback.
## Fix Focus Areas
- Src/Abstracts/SoundPlayerBase.cs[171-177]
- Src/Abstracts/SoundPlayerBase.cs[203-250]
- Src/Abstracts/SoundPlayerBase.cs[387-425]
## Recommended Fix
Limit each reverse source read to the configured loop start, then continue filling the same output buffer from the loop end.

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


3. One-frame reverse loops overflow the stack 🐞 Bug ☼ Reliability
Description
Reverse looping seeks to its end, but Seek(int) clamps that exclusive end position to `Length -
channels`. For a one-frame clip this leaves the playhead at zero, so reverse generation reaches
end-of-stream and recursively seeks and generates without making progress; longer clips also omit
their last frame.
Code

Src/Abstracts/SoundPlayerBase.cs[R629-630]

+                // Forward playback seeks to start. Reverse seeks to end.
+                Seek(_playbackSpeed < 0 ? actualLoopEnd : targetLoopStart, channels);
Evidence
The new reverse EOS branch seeks to the loop end and calls generation again, but the existing seek
clamp reduces a one-frame clip's end to zero; reverse generation at zero immediately invokes EOS.

Src/Abstracts/SoundPlayerBase.cs[203-218]
Src/Abstracts/SoundPlayerBase.cs[626-634]
Src/Abstracts/SoundPlayerBase.cs[738-752]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
The forward seek clamp makes the reverse loop end inaccessible.
## Fix Focus Areas
- Src/Abstracts/SoundPlayerBase.cs[203-216]
- Src/Abstracts/SoundPlayerBase.cs[626-634]
- Src/Abstracts/SoundPlayerBase.cs[738-752]
## Recommended Fix
Allow an aligned, exclusive end position for reverse seeks, including the full provider length, and ensure looping advances before recursively filling output.

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


View action required (3)
4. Resampled files show inflated durations 🐞 Bug ≡ Correctness
Description
StreamDataProvider.Length compares decoder-output samples with a metadata estimate calculated
using the source file's channel count and sample rate. When a caller requests a lower-rate or
lower-channel output format, the new maximum can select incompatible source-format units, inflating
displayed duration and the accepted seek range.
Code

Src/Providers/StreamDataProvider.cs[R149-151]

+                var metadataLengthSamples = FormatInfo.Duration.TotalSeconds
+                                            * FormatInfo.SampleRate
+                                            * FormatInfo.ChannelCount;
Evidence
The explicit-format constructor retains source metadata but creates a decoder in the requested
format; Duration later interprets the selected length using the player format.

Src/Providers/StreamDataProvider.cs[75-95]
Src/Providers/StreamDataProvider.cs[141-170]
Src/Backends/MiniAudio/MiniAudioCodecFactory.cs[26-27]
Src/Abstracts/SoundPlayerBase.cs[109-115]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
The two length estimates use different sample formats when output conversion is requested.
## Fix Focus Areas
- Src/Providers/StreamDataProvider.cs[75-95]
- Src/Providers/StreamDataProvider.cs[141-170]
## Recommended Fix
Convert metadata duration using the decoder's output sample rate and channel count before comparing it with decoder length.

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


5. Reverse speeds other than -1x all play at -1x 🐞 Bug ≡ Correctness
Description
For negative speeds, FillResampleBuffer reverses and copies source frames into _resampleBuffer
without WSOLA, while GenerateAudio advances _currentFractionalFrame by a fixed 1.0f rather
than Math.Abs(_playbackSpeed). Consequently, reverse speeds such as -0.3x, -0.5x, -0.9x, and -2x
all consume approximately one source frame per output frame and play at about -1x, including in the
SimplePlayer sample and turntable use case.
Code

Src/Abstracts/SoundPlayerBase.cs[R380-383]

+        // Reverse playback bypasses the time stretcher entirely and relies on linear interpolation.
+        if (isReverse)
+        {
+            while (_resampleBufferValidSamples < minSamplesRequiredInOutputBuffer)
Evidence
Forward playback gets its speed from WSOLA, so the shared interpolation loop's fixed 1.0f step
assumes the rate has already been applied. The reverse branch only reverses and appends frames,
bypasses WSOLA, and does not call SetSpeed for nonpositive values; the fixed interpolation step
therefore never incorporates the magnitude of a negative speed.

Src/Abstracts/SoundPlayerBase.cs[268-313]
Src/Abstracts/SoundPlayerBase.cs[378-426]
Src/Abstracts/SoundPlayerBase.cs[34-57]
Src/Abstracts/SoundPlayerBase.cs[290-317]
Src/Abstracts/SoundPlayerBase.cs[380-425]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
Reverse playback bypasses WSOLA but consumes one source frame per output frame regardless of the magnitude of the negative playback speed.
## Fix Focus Areas
- Src/Abstracts/SoundPlayerBase.cs[268-335]
- Src/Abstracts/SoundPlayerBase.cs[378-426]
## Recommended Fix
In `GenerateAudio`, advance `_currentFractionalFrame` by `Math.Abs(_playbackSpeed)` when `_playbackSpeed < 0`, while retaining the `1.0f` step for forward playback, whose speed is already applied by WSOLA. Preserve fractional frame positions across calls and ensure `_rawSamplePosition` reflects the source frames actually consumed from the buffer.

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


6. Reverse playback of live streams crashes 🐞 Bug ☼ Reliability
Description
Both reverse paths in SoundPlayerBase call _dataProvider.Seek(targetPos) on every read without
checking _dataProvider.CanSeek. Setting a negative playback speed for a queue, microphone, or
non-seekable stream reaches those calls; their providers throw from Seek, and neither the player
nor MiniAudioPlaybackDevice catches the exception in the native audio callback.
Code

Src/Abstracts/SoundPlayerBase.cs[R397-400]

+                if (samplesToRead == 0) break; // Reached beginning
+
+                _dataProvider.Seek(targetPos);
+                var tempBuf = System.Buffers.ArrayPool<float>.Shared.Rent(samplesToRead);
Evidence
The reverse paths seek unconditionally at lines 220 and 399. StreamDataProvider.Seek throws
InvalidOperationException when CanSeek is false, while QueueDataProvider.Seek and
MicrophoneDataProvider.Seek always throw; the absence of catch blocks in SoundComponent and
MiniAudioPlaybackDevice leaves those exceptions unhandled in the audio generation path.

Src/Providers/StreamDataProvider.cs[206-220]
Src/Providers/QueueDataProvider.cs[264-264]
Src/Providers/MicrophoneDataProvider.cs[176-176]
Src/Abstracts/SoundPlayerBase.cs[214-221]
Src/Abstracts/SoundPlayerBase.cs[203-225]
Src/Abstracts/SoundPlayerBase.cs[387-404]
Src/Providers/QueueDataProvider.cs[248-249]
Src/Providers/MicrophoneDataProvider.cs[43-44]
Src/Providers/MicrophoneDataProvider.cs[169-169]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
Reverse playback seeks on every read even when the provider cannot seek, allowing an exception to escape from the audio callback.
## Fix Focus Areas
- Src/Abstracts/SoundPlayerBase.cs[31-56]
- Src/Abstracts/SoundPlayerBase.cs[203-225]
- Src/Abstracts/SoundPlayerBase.cs[387-404]
## Recommended Fix
In the `PlaybackSpeed` setter, throw `NotSupportedException` when `value < 0 && !_dataProvider.CanSeek`. Add a defensive `CanSeek` check in both reverse branches so an unsupported provider emits silence and ends the stream instead of calling `Seek` from the audio callback.

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools



Remediation recommended

7. Reverse playback time jumps ahead 🐞 Bug ≡ Correctness
Description
FillResampleBuffer reports every prefetched reverse sample as source movement even when most of
those samples remain buffered. After a small output callback fills the much larger resample buffer,
Time jumps backward by the entire prefetched block rather than the audio played.
Code

Src/Abstracts/SoundPlayerBase.cs[R416-418]

+                    tempSpan[..read].CopyTo(_resampleBuffer.AsSpan(_resampleBufferValidSamples));
+                    _resampleBufferValidSamples += read;
+                    totalSourceSamplesRepresented -= read; // Negative to indicate reverse movement
Evidence
Time reads the raw position, while reverse fill subtracts its entire read count and the caller
applies that count regardless of how many buffered frames were output.

Src/Abstracts/SoundPlayerBase.cs[103-108]
Src/Abstracts/SoundPlayerBase.cs[279-335]
Src/Abstracts/SoundPlayerBase.cs[416-425]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
Reverse read-ahead is reported as elapsed playback position.
## Fix Focus Areas
- Src/Abstracts/SoundPlayerBase.cs[103-108]
- Src/Abstracts/SoundPlayerBase.cs[279-335]
- Src/Abstracts/SoundPlayerBase.cs[416-425]
## Recommended Fix
Keep the reverse read cursor separate from the playhead and update the reported position according to source frames actually rendered.

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


8. New reverse playback starts with silence 🐞 Bug ≡ Correctness
Description
The direct reverse path reduces its requested read to _rawSamplePosition and invokes end-of-stream
handling when that position is zero. A newly created, non-looping player starts at zero, and
Play() does not move it, so setting a negative speed before the first play stops without output
unless the caller seeks first.
Code

Src/Abstracts/SoundPlayerBase.cs[R207-216]

+                    if (targetPos < 0)
+                    {
+                        // We are at the beginning, only read what's left
+                        samplesToRead = _rawSamplePosition;
+                        targetPos = 0;
+                    }
+                    
+                    if (samplesToRead == 0)
+                    {
+                        HandleEndOfStream(outputSlice, channels);
Evidence
The raw position defaults to zero, the reverse path handles a zero read as EOS, and Play only
changes playback state.

Src/Abstracts/SoundPlayerBase.cs[11-20]
Src/Abstracts/SoundPlayerBase.cs[203-218]
Src/Abstracts/SoundPlayerBase.cs[674-680]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
A new player has no source samples behind its initial playhead.
## Fix Focus Areas
- Src/Abstracts/SoundPlayerBase.cs[31-56]
- Src/Abstracts/SoundPlayerBase.cs[203-218]
- Src/Abstracts/SoundPlayerBase.cs[674-680]
## Recommended Fix
For a seekable new player started in reverse, position its playhead at the exclusive end before rendering, without overriding an explicit caller seek.

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


9. Base-typed duplex listeners hear nothing 🐞 Bug ≡ Correctness
Description
FullDuplexDevice hides the new base OnAudioProcessed event with a separate forwarding event
instead of overriding subscription behavior. A listener holding the duplex device as AudioDevice
subscribes to its never-raised base event, rather than either child device's capture or playback
event.
Code

Src/Abstracts/Devices/FullDuplexDevice.cs[R33-38]

+    public new event AudioProcessCallback? OnAudioProcessed
   {
-        add => CaptureDevice.OnAudioProcessed += value;
-        remove => CaptureDevice.OnAudioProcessed -= value;
+        add
+        {
+            CaptureDevice.OnAudioProcessed += value;
+            PlaybackDevice.OnAudioProcessed += value;
Evidence
The base event is invoked only on its owning device, while the duplex event routes only
subscriptions made through the derived type; callbacks originate from the child devices.

Src/Abstracts/Devices/AudioDevice.cs[51-57]
Src/Abstracts/Devices/AudioDevice.cs[87-95]
Src/Abstracts/Devices/FullDuplexDevice.cs[29-45]
Src/Backends/MiniAudio/Devices/MiniAudioPlaybackDevice.cs[64-83]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
The hidden duplex event and the base event have different subscriber lists.
## Fix Focus Areas
- Src/Abstracts/Devices/AudioDevice.cs[51-103]
- Src/Abstracts/Devices/FullDuplexDevice.cs[29-45]
## Recommended Fix
Use a common overridable subscription mechanism so subscriptions through either static device type route to both children; preserve that behavior during switching.

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


View review recommended (6)
10. Windows builds request the wrong toolchain 🐞 Bug ☼ Reliability
Description
The miniaudio Windows matrix requests the Visual Studio 2026 CMake generator while its
tool-installation step explicitly installs Visual Studio 2019 Build Tools. If the runner does not
separately provide the requested generator, every affected Windows configuration fails before
compilation; the PortMidi workflow now makes the same generator request without installing it.
Code

.github/workflows/build-miniaudio.yml[153]

+            toolchain: "Visual Studio 18 2026"
Evidence
The matrix supplies the new generator directly to CMake, whereas the miniaudio workflow's explicit
tool installation and path selection are for VS 2019.

.github/workflows/build-miniaudio.yml[147-169]
.github/workflows/build-miniaudio.yml[260-278]
.github/workflows/build-miniaudio.yml[313-319]
.github/workflows/build-portmidi.yml[107-114]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
Windows configuration requests a generator different from the explicitly installed toolchain.
## Fix Focus Areas
- .github/workflows/build-miniaudio.yml[147-169]
- .github/workflows/build-miniaudio.yml[260-278]
- .github/workflows/build-portmidi.yml[107-114]
## Recommended Fix
Request a generator that the workflows install or establish that the selected runner supplies the new generator for every Windows target.

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


11. The iOS framework drops iOS 11 support 🐞 Bug ≡ Correctness
Description
The plist patch declares MinimumOSVersion as 12.0 although the same workflow builds the framework
with an iOS 11.0 deployment target. Successful artifacts therefore advertise a higher minimum
version than the binary was built for, excluding iOS 11 from the distributed framework.
Code

.github/workflows/build-ffmpeg.yml[386]

+            /usr/libexec/PlistBuddy -c "Add :MinimumOSVersion string 12.0" "$PLIST" 2>/dev/null || true
Evidence
The workflow explicitly sets the compiler deployment target to 11.0, writes 12.0 into the framework
plist, and uploads the resulting framework as an artifact.

.github/workflows/build-ffmpeg.yml[364-386]
.github/workflows/build-ffmpeg.yml[412-416]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
The packaged plist declares a different minimum iOS version from the build target.
## Fix Focus Areas
- .github/workflows/build-ffmpeg.yml[364-386]
## Recommended Fix
Set MinimumOSVersion from the same deployment-target value used for compilation, or intentionally update both values together.

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


12. Future codec releases keep an old version 🐞 Bug ⚙ Maintainability
Description
The plist patch hardcodes CFBundleShortVersionString to 1.4.0 independently of the release
version. When the release workflow packs a later FFmpeg package version, its bundled iOS framework
still identifies itself as 1.4.0.
Code

.github/workflows/build-ffmpeg.yml[380]

+            /usr/libexec/PlistBuddy -c "Set :CFBundleShortVersionString 1.4.0" "$PLIST"
Evidence
The patch sets a fixed version; the release workflow supplies its chosen version to the project
before packing, and the project packs runtime files including the framework.

.github/workflows/build-ffmpeg.yml[374-386]
.github/workflows/release.yml[218-245]
Codecs/SoundFlow.Codecs.FFMpeg/SoundFlow.Codecs.FFMpeg.csproj[28-46]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
The framework version is fixed even though package releases accept new versions.
## Fix Focus Areas
- .github/workflows/build-ffmpeg.yml[372-386]
- .github/workflows/release.yml[218-245]
## Recommended Fix
Pass the release version into the framework packaging step and write it to the plist instead of hardcoding 1.4.0.

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


13. Switching reverse to forward skips ahead 🐞 Bug ≡ Correctness
Description
When the direction changes, the PlaybackSpeed setter clears the resample and WSOLA buffers but
never calls _dataProvider.Seek(_rawSamplePosition). The reverse paths leave the provider at the
end of the chunk they last read (the old position), while _rawSamplePosition has already moved
back by read. Forward playback reads straight from the provider without seeking, so after a
reverse-to-forward switch it jumps ahead by up to one buffer (about 0.1 s in the resample path).
From then on, Time, the loop-end checks and the real stream position disagree permanently.
Code

Src/Abstracts/SoundPlayerBase.cs[R39-46]

+            if (Math.Sign(_playbackSpeed) != Math.Sign(value) && _playbackSpeed != 0)
+            {
+                _timeStretcher.Reset();
+                _resampleBufferValidSamples = 0;
+                _timeStretcherInputBufferValidSamples = 0;
+                _timeStretcherInputBufferReadOffset = 0;
+                _currentFractionalFrame = 0f;
+            }
Evidence
In the reverse direct path, the code seeks to targetPos, reads samplesToRead, and leaves the
provider at the old _rawSamplePosition; it then runs _rawSamplePosition -= read. The reverse
resample path behaves the same way. The forward direct path calls
_dataProvider.ReadBytes(outputSlice) with no seek, and the setter's reset block never re-seeks the
provider.

Src/Abstracts/SoundPlayerBase.cs[34-57]
Src/Abstracts/SoundPlayerBase.cs[186-200]
Src/Abstracts/SoundPlayerBase.cs[204-242]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
After reverse playback the data provider is ahead of `_rawSamplePosition`. Switching to forward resumes from the wrong spot.
## Fix Focus Areas
- Src/Abstracts/SoundPlayerBase.cs[39-46]
## Recommended Fix
In the sign-change block, call `_dataProvider.Seek(_rawSamplePosition)` (aligned to a frame and guarded by `_dataProvider.CanSeek`), or call the private `Seek(_rawSamplePosition, Format.Channels)`, which already resets every buffer.

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


14. -1x reverse on MP3s re-seeks every callback 🐞 Bug ➹ Performance
Description
The |speed| == 1 reverse branch calls _dataProvider.Seek(targetPos) for every device buffer
(around 10 ms). With the default seekPointCount = 100, each MiniAudio MP3 seek has to decode
forward from the nearest seek point, which in long files can be tens of seconds of audio. That
decode cost lands on the real-time audio thread every callback and can cause underruns and crackling
when reverse-playing compressed files.
Code

Src/Abstracts/SoundPlayerBase.cs[R220-221]

+                    _dataProvider.Seek(targetPos);
+                    var tempBuf = System.Buffers.ArrayPool<float>.Shared.Rent(samplesToRead);
Evidence
Each loop iteration in the reverse direct path seeks the provider to `_rawSamplePosition -
outputSlice.Length` and reads only one output buffer, so there is one decoder seek per callback.
StreamDataProvider.Seek forwards to MiniAudioDecoder.Seek, which calls
ma_decoder_seek_to_pcm_frame, and the decoder is configured with only 100 seek points.

Src/Abstracts/SoundPlayerBase.cs[202-221]
Src/Backends/MiniAudio/MiniAudioDecoder.cs[193-203]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
Reverse playback at -1x seeks the decoder once per audio callback, which is expensive for compressed formats.
## Fix Focus Areas
- Src/Abstracts/SoundPlayerBase.cs[202-256]
## Recommended Fix
Route -1x reverse through the buffered reverse path, or read a large reversed block (for example 0.25–1 s) into a cache and serve successive callbacks from it, seeking again only once the cache is used up.

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


15. Overestimated length breaks reverse loops 🐞 Bug ≡ Correctness
Description
StreamDataProvider.Length now returns Math.Max(decoderLength, metadataLength), even when the
decoder reports an exact length and the metadata comes from a DurationAccuracy.FastEstimate read.
Reverse looping and HandleEndOfStream seek to actualLoopEnd = _dataProvider.Length, so an
inflated estimate starts reverse playback past the real end of the audio. There, reads return 0 or
come from the wrong position, and with looping on, HandleEndOfStream → Seek(end) →
GenerateAudio can recurse without ever making progress.
Code

Src/Providers/StreamDataProvider.cs[R163-170]

+            return decoderLength switch
+            {
+                // Prefer the larger value when both are available. See the XML doc above for the rationale.
+                // Special case: if one of them is 0 (unknown), use the other.
+                > 0 when metadataLength > 0 => Math.Max(decoderLength, metadataLength),
+                > 0 => decoderLength,
+                _ => metadataLength
+            };
Evidence
The constructor reads metadata with FastEstimate, and the new Length getter prefers the larger
value. The reverse loop and EOS handling use _dataProvider.Length as the reverse start point. In
the -1x branch, read == 0 calls HandleEndOfStream, which (when looping) seeks back to the same
inflated end and calls GenerateAudio again.

Src/Providers/StreamDataProvider.cs[81-86]
Src/Abstracts/SoundPlayerBase.cs[171-177]
Src/Abstracts/SoundPlayerBase.cs[224-230]
Src/Abstracts/SoundPlayerBase.cs[618-632]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
When metadata overestimates the length, reverse playback and looping start beyond the real audio.
## Fix Focus Areas
- Src/Providers/StreamDataProvider.cs[137-172]
- Src/Abstracts/SoundPlayerBase.cs[224-230]
## Recommended Fix
Use `_decoder.Length` whenever it is > 0 and fall back to metadata only when it is unknown. In the reverse path, when a read returns 0 just after a loop seek, stop instead of recursing: for example, pull `_rawSamplePosition` back to the real end, or end playback.

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


Grey Divider

Tip of the day
💡 Did you know, you can ask Qodo to dismiss a finding you disagree with, with your reason on record

More tips ↗ | Customize Qodo ↗ | Qodo docs ↗

Grey Divider

Qodo Logo

Comment on lines +388 to +389
var samplesToRead = spaceAvailableInResampleBuffer;
var targetPos = _rawSamplePosition - samplesToRead;

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Action required

1. Long reverse buffers repeat audio 🐞 Bug ≡ Correctness

FillResampleBuffer computes every reverse read position from _rawSamplePosition, which
GenerateAudio updates only after finishing the output buffer. When one output call requires
another fill after exhausting the first, it reads the same source region again.
Agent Prompt
## Issue description
Multiple reverse fills in one output call can reread the same samples.
## Fix Focus Areas
- Src/Abstracts/SoundPlayerBase.cs[279-335]
- Src/Abstracts/SoundPlayerBase.cs[380-425]
## Recommended Fix
Maintain a cursor for the next reverse source read and move it after each successful fill, independently of the position of audio already rendered.

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools

Comment on lines +416 to +418
tempSpan[..read].CopyTo(_resampleBuffer.AsSpan(_resampleBufferValidSamples));
_resampleBufferValidSamples += read;
totalSourceSamplesRepresented -= read; // Negative to indicate reverse movement

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Remediation recommended

7. Reverse playback time jumps ahead 🐞 Bug ≡ Correctness

FillResampleBuffer reports every prefetched reverse sample as source movement even when most of
those samples remain buffered. After a small output callback fills the much larger resample buffer,
Time jumps backward by the entire prefetched block rather than the audio played.
Agent Prompt
## Issue description
Reverse read-ahead is reported as elapsed playback position.
## Fix Focus Areas
- Src/Abstracts/SoundPlayerBase.cs[103-108]
- Src/Abstracts/SoundPlayerBase.cs[279-335]
- Src/Abstracts/SoundPlayerBase.cs[416-425]
## Recommended Fix
Keep the reverse read cursor separate from the playhead and update the reported position according to source frames actually rendered.

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools

Comment on lines +174 to +176
var actualLoopEnd = (_loopEndSamples == -1) ? _dataProvider.Length : Math.Min(_loopEndSamples, _dataProvider.Length);
if (_loopStartSamples < actualLoopEnd && _rawSamplePosition <= _loopStartSamples)
Seek(actualLoopEnd, channels);

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Action required

2. Reverse loops play outside their range 🐞 Bug ≡ Correctness

GenerateAudio checks the reverse loop start only before generating a buffer, but its reverse reads
can continue below _loopStartSamples. A callback that crosses the configured start emits
out-of-loop samples before the next callback can wrap.
Agent Prompt
## Issue description
A reverse read can cross the loop start within one output callback.
## Fix Focus Areas
- Src/Abstracts/SoundPlayerBase.cs[171-177]
- Src/Abstracts/SoundPlayerBase.cs[203-250]
- Src/Abstracts/SoundPlayerBase.cs[387-425]
## Recommended Fix
Limit each reverse source read to the configured loop start, then continue filling the same output buffer from the loop end.

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools

Comment on lines +629 to +630
// Forward playback seeks to start. Reverse seeks to end.
Seek(_playbackSpeed < 0 ? actualLoopEnd : targetLoopStart, channels);

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Action required

3. One-frame reverse loops overflow the stack 🐞 Bug ☼ Reliability

Reverse looping seeks to its end, but Seek(int) clamps that exclusive end position to `Length -
channels`. For a one-frame clip this leaves the playhead at zero, so reverse generation reaches
end-of-stream and recursively seeks and generates without making progress; longer clips also omit
their last frame.
Agent Prompt
## Issue description
The forward seek clamp makes the reverse loop end inaccessible.
## Fix Focus Areas
- Src/Abstracts/SoundPlayerBase.cs[203-216]
- Src/Abstracts/SoundPlayerBase.cs[626-634]
- Src/Abstracts/SoundPlayerBase.cs[738-752]
## Recommended Fix
Allow an aligned, exclusive end position for reverse seeks, including the full provider length, and ensure looping advances before recursively filling output.

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools

Comment on lines +207 to +216
if (targetPos < 0)
{
// We are at the beginning, only read what's left
samplesToRead = _rawSamplePosition;
targetPos = 0;
}

if (samplesToRead == 0)
{
HandleEndOfStream(outputSlice, channels);

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Remediation recommended

8. New reverse playback starts with silence 🐞 Bug ≡ Correctness

The direct reverse path reduces its requested read to _rawSamplePosition and invokes end-of-stream
handling when that position is zero. A newly created, non-looping player starts at zero, and
Play() does not move it, so setting a negative speed before the first play stops without output
unless the caller seeks first.
Agent Prompt
## Issue description
A new player has no source samples behind its initial playhead.
## Fix Focus Areas
- Src/Abstracts/SoundPlayerBase.cs[31-56]
- Src/Abstracts/SoundPlayerBase.cs[203-218]
- Src/Abstracts/SoundPlayerBase.cs[674-680]
## Recommended Fix
For a seekable new player started in reverse, position its playhead at the exclusive end before rendering, without overriding an explicit caller seek.

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools

Comment on lines +380 to +383
// Reverse playback bypasses the time stretcher entirely and relies on linear interpolation.
if (isReverse)
{
while (_resampleBufferValidSamples < minSamplesRequiredInOutputBuffer)

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Action required

5. Reverse speeds other than -1x all play at -1x 🐞 Bug ≡ Correctness

For negative speeds, FillResampleBuffer reverses and copies source frames into _resampleBuffer
without WSOLA, while GenerateAudio advances _currentFractionalFrame by a fixed 1.0f rather
than Math.Abs(_playbackSpeed). Consequently, reverse speeds such as -0.3x, -0.5x, -0.9x, and -2x
all consume approximately one source frame per output frame and play at about -1x, including in the
SimplePlayer sample and turntable use case.
Agent Prompt
## Issue description
Reverse playback bypasses WSOLA but consumes one source frame per output frame regardless of the magnitude of the negative playback speed.

## Fix Focus Areas
- Src/Abstracts/SoundPlayerBase.cs[268-335]
- Src/Abstracts/SoundPlayerBase.cs[378-426]

## Recommended Fix
In `GenerateAudio`, advance `_currentFractionalFrame` by `Math.Abs(_playbackSpeed)` when `_playbackSpeed < 0`, while retaining the `1.0f` step for forward playback, whose speed is already applied by WSOLA. Preserve fractional frame positions across calls and ensure `_rawSamplePosition` reflects the source frames actually consumed from the buffer.

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools

Comment on lines +39 to +46
if (Math.Sign(_playbackSpeed) != Math.Sign(value) && _playbackSpeed != 0)
{
_timeStretcher.Reset();
_resampleBufferValidSamples = 0;
_timeStretcherInputBufferValidSamples = 0;
_timeStretcherInputBufferReadOffset = 0;
_currentFractionalFrame = 0f;
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Remediation recommended

13. Switching reverse to forward skips ahead 🐞 Bug ≡ Correctness

When the direction changes, the PlaybackSpeed setter clears the resample and WSOLA buffers but
never calls _dataProvider.Seek(_rawSamplePosition). The reverse paths leave the provider at the
end of the chunk they last read (the old position), while _rawSamplePosition has already moved
back by read. Forward playback reads straight from the provider without seeking, so after a
reverse-to-forward switch it jumps ahead by up to one buffer (about 0.1 s in the resample path).
From then on, Time, the loop-end checks and the real stream position disagree permanently.
Agent Prompt
## Issue description
After reverse playback the data provider is ahead of `_rawSamplePosition`. Switching to forward resumes from the wrong spot.

## Fix Focus Areas
- Src/Abstracts/SoundPlayerBase.cs[39-46]

## Recommended Fix
In the sign-change block, call `_dataProvider.Seek(_rawSamplePosition)` (aligned to a frame and guarded by `_dataProvider.CanSeek`), or call the private `Seek(_rawSamplePosition, Format.Channels)`, which already resets every buffer.

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools

Comment on lines +397 to +400
if (samplesToRead == 0) break; // Reached beginning

_dataProvider.Seek(targetPos);
var tempBuf = System.Buffers.ArrayPool<float>.Shared.Rent(samplesToRead);

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Action required

6. Reverse playback of live streams crashes 🐞 Bug ☼ Reliability

Both reverse paths in SoundPlayerBase call _dataProvider.Seek(targetPos) on every read without
checking _dataProvider.CanSeek. Setting a negative playback speed for a queue, microphone, or
non-seekable stream reaches those calls; their providers throw from Seek, and neither the player
nor MiniAudioPlaybackDevice catches the exception in the native audio callback.
Agent Prompt
## Issue description
Reverse playback seeks on every read even when the provider cannot seek, allowing an exception to escape from the audio callback.

## Fix Focus Areas
- Src/Abstracts/SoundPlayerBase.cs[31-56]
- Src/Abstracts/SoundPlayerBase.cs[203-225]
- Src/Abstracts/SoundPlayerBase.cs[387-404]

## Recommended Fix
In the `PlaybackSpeed` setter, throw `NotSupportedException` when `value < 0 && !_dataProvider.CanSeek`. Add a defensive `CanSeek` check in both reverse branches so an unsupported provider emits silence and ends the stream instead of calling `Seek` from the audio callback.

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools

Comment on lines +220 to +221
_dataProvider.Seek(targetPos);
var tempBuf = System.Buffers.ArrayPool<float>.Shared.Rent(samplesToRead);

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Remediation recommended

14. -1x reverse on mp3s re-seeks every callback 🐞 Bug ➹ Performance

The |speed| == 1 reverse branch calls _dataProvider.Seek(targetPos) for every device buffer
(around 10 ms). With the default seekPointCount = 100, each MiniAudio MP3 seek has to decode
forward from the nearest seek point, which in long files can be tens of seconds of audio. That
decode cost lands on the real-time audio thread every callback and can cause underruns and crackling
when reverse-playing compressed files.
Agent Prompt
## Issue description
Reverse playback at -1x seeks the decoder once per audio callback, which is expensive for compressed formats.

## Fix Focus Areas
- Src/Abstracts/SoundPlayerBase.cs[202-256]

## Recommended Fix
Route -1x reverse through the buffered reverse path, or read a large reversed block (for example 0.25–1 s) into a cache and serve successive callbacks from it, seeking again only once the cache is used up.

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools

Comment on lines +163 to +170
return decoderLength switch
{
// Prefer the larger value when both are available. See the XML doc above for the rationale.
// Special case: if one of them is 0 (unknown), use the other.
> 0 when metadataLength > 0 => Math.Max(decoderLength, metadataLength),
> 0 => decoderLength,
_ => metadataLength
};

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Remediation recommended

15. Overestimated length breaks reverse loops 🐞 Bug ≡ Correctness

StreamDataProvider.Length now returns Math.Max(decoderLength, metadataLength), even when the
decoder reports an exact length and the metadata comes from a DurationAccuracy.FastEstimate read.
Reverse looping and HandleEndOfStream seek to actualLoopEnd = _dataProvider.Length, so an
inflated estimate starts reverse playback past the real end of the audio. There, reads return 0 or
come from the wrong position, and with looping on, HandleEndOfStream → Seek(end) →
GenerateAudio can recurse without ever making progress.
Agent Prompt
## Issue description
When metadata overestimates the length, reverse playback and looping start beyond the real audio.

## Fix Focus Areas
- Src/Providers/StreamDataProvider.cs[137-172]
- Src/Abstracts/SoundPlayerBase.cs[224-230]

## Recommended Fix
Use `_decoder.Length` whenever it is > 0 and fall back to metadata only when it is unknown. In the reverse path, when a read returns 0 just after a loop seek, stop instead of recursing: for example, pull `_rawSamplePosition` back to the real end, or end playback.

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools

@greptile-apps

greptile-apps Bot commented Oct 22, 2026 •

Copy link
Copy Markdown

RetriggerView in GreptileConfidence Score: 0/5

[High impact] The PR reaches core audio playback and device paths, stream decoding, and package release workflows.

Fix reverse playback, converted-stream lengths, and full-duplex event routing before merging.

Findings

  1. P1 Reverse speed is ignored ▶
  2. P1 Reverse playback repeats chunks ▶
  3. P1 Changing direction skips audio ▶
  4. P1 Reverse loops escape their bounds ▶
  5. P1 Converted files show wrong durations ▶
  6. P1 Full-duplex callbacks disappear ▶
  7. P2 Seek settings differ by platform ▶
Fix with agent prompt
### Issue 1
Src/Abstracts/SoundPlayerBase.cs:380-381
Negative speeds other than `-1` ignore their magnitude. This branch bypasses `_timeStretcher`, but `GenerateAudio` still advances `_currentFractionalFrame` by exactly `1.0f` per output frame. As a result, `-0.5` and `-2` both play backward at normal speed. Use `Math.Abs(_playbackSpeed)` to advance through the reversed buffer.

### Issue 2
Src/Abstracts/SoundPlayerBase.cs:388-389
A second reverse fill within one `GenerateAudio` call seeks from the same `_rawSamplePosition` as the first fill. That position is updated only after the output loop finishes. At 8 kHz, for example, the buffer holds 800 frames, so a larger output block can reread the first source range instead of continuing backward. Advance a separate read position after each fill, or include the pending movement when calculating `targetPos`.

### Issue 3
Src/Abstracts/SoundPlayerBase.cs:39-45
Switching from reverse to forward clears the buffers but leaves the provider at the wrong position. A reverse read seeks to `targetPos`, reads forward, and leaves the provider at the end of that block, while `_rawSamplePosition` moves to its beginning. Switching to `1.0f` then reads from the provider's old position and skips a block. Seek the provider to the current playback position when changing direction.

### Issue 4
Src/Abstracts/SoundPlayerBase.cs:204-211
Reverse reads stop at zero rather than at `_loopStartSamples`. If a block starts just above a nonzero loop start, it plays audio before the selected region; the loop check runs only before the block starts. Both reverse read paths need to stop at the loop start and wrap while filling the remaining output.

### Issue 5
Src/Providers/StreamDataProvider.cs:149-151
`metadataLength` counts samples in the file's original format, while `_decoder.Length` counts samples in the requested output format. The constructor accepting `AudioFormat` allows those formats to differ. A ten-second, 48 kHz stereo file decoded to 24 kHz mono therefore returns `960000` samples instead of `240000`, making `Duration` forty seconds and allowing seeks beyond the decoded audio. Convert the metadata duration to the decoder's output rate and channel count before comparing lengths.

### Issue 6
Src/Abstracts/Devices/FullDuplexDevice.cs:33
`FullDuplexDevice` hides the new base event with `new`, so subscribing through an `AudioDevice` reference attaches to a separate event that never fires. For example, `AudioDevice device = fullDuplexDevice; device.OnAudioProcessed += handler;` receives neither capture nor playback samples. Make the base event overridable and override its accessors, or forward both child events into the base event.

### Issue 7
Native/miniaudio-backend/library.c:180
The native function now accepts `seekPointCount`, but the packaged Android, Intel macOS, and FreeBSD libraries were not updated. Those unchanged libraries still use the previous implementation, which does not apply the fourth argument. Rebuild the remaining runtime assets so users on those platforms also get the new seek-point setting.

---

For each issue above, determine whether it is valid and should be fixed. If so, fix it directly.

Summary

Adds reverse playback, device-wide audio callbacks, decoder seeking changes, and build and release updates.

  • Players can move backward through audio and loop in reverse.
  • Audio subscribers can receive both captured and rendered samples.
  • MiniAudio streams can seek even when their total length is unknown.
  • Stream-backed audio uses the larger available length estimate.
  • Native builds set platform details for iOS and updated build tools.
  • The release workflow gets a temporary key to publish packages to NuGet.

Diagram

%%{init: {'theme': 'neutral'}}%%
flowchart LR
    Provider[Audio provider] --> Direction{Playback direction}
    Direction -->|Forward| Wsola[WSOLA for changed speed]
    Direction -->|Reverse| Reverse[Seek and reverse frames]
    Wsola --> Mixer[Master mixer]
    Reverse --> Mixer
    Mixer --> Playback[Playback device]
    Playback --> Output[Hardware output]
    Playback --> Event[Audio callbacks]
    Capture[Capture device] --> Event
    Event --> Consumers[Microphone and echo cancellation]
Loading

Reviews (1) · Last reviewed commit: "feat(player): add reverse playback and f..." · Reviewed by Greptile

Comment on lines +380 to +381
// Reverse playback bypasses the time stretcher entirely and relies on linear interpolation.
if (isReverse)

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 Reverse speed is ignored

Negative speeds other than -1 ignore their magnitude. This branch bypasses _timeStretcher, but GenerateAudio still advances _currentFractionalFrame by exactly 1.0f per output frame. As a result, -0.5 and -2 both play backward at normal speed. Use Math.Abs(_playbackSpeed) to advance through the reversed buffer.

Knowledge Base Used: Playback, mixing, and recording components

Prompt To Fix With AI
This is a comment left during a code review.
Path: Src/Abstracts/SoundPlayerBase.cs
Line: 380-381

Comment:
**Reverse speed is ignored**

Negative speeds other than `-1` ignore their magnitude. This branch bypasses `_timeStretcher`, but `GenerateAudio` still advances `_currentFractionalFrame` by exactly `1.0f` per output frame. As a result, `-0.5` and `-2` both play backward at normal speed. Use `Math.Abs(_playbackSpeed)` to advance through the reversed buffer.

**Knowledge Base Used:** [Playback, mixing, and recording components](https://app.greptile.com/lsxprime/-/custom-context/knowledge-base/lsxprime/soundflow/-/docs/playback-mixing-and-recording.md)

---

For each issue above, determine whether it is valid and should be fixed. If so, fix it directly.

Comment on lines +388 to +389
var samplesToRead = spaceAvailableInResampleBuffer;
var targetPos = _rawSamplePosition - samplesToRead;

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 Reverse playback repeats chunks

A second reverse fill within one GenerateAudio call seeks from the same _rawSamplePosition as the first fill. That position is updated only after the output loop finishes. At 8 kHz, for example, the buffer holds 800 frames, so a larger output block can reread the first source range instead of continuing backward. Advance a separate read position after each fill, or include the pending movement when calculating targetPos.

Knowledge Base Used: Playback, mixing, and recording components

Prompt To Fix With AI
This is a comment left during a code review.
Path: Src/Abstracts/SoundPlayerBase.cs
Line: 388-389

Comment:
**Reverse playback repeats chunks**

A second reverse fill within one `GenerateAudio` call seeks from the same `_rawSamplePosition` as the first fill. That position is updated only after the output loop finishes. At 8 kHz, for example, the buffer holds 800 frames, so a larger output block can reread the first source range instead of continuing backward. Advance a separate read position after each fill, or include the pending movement when calculating `targetPos`.

**Knowledge Base Used:** [Playback, mixing, and recording components](https://app.greptile.com/lsxprime/-/custom-context/knowledge-base/lsxprime/soundflow/-/docs/playback-mixing-and-recording.md)

---

For each issue above, determine whether it is valid and should be fixed. If so, fix it directly.

Comment on lines +39 to +45
if (Math.Sign(_playbackSpeed) != Math.Sign(value) && _playbackSpeed != 0)
{
_timeStretcher.Reset();
_resampleBufferValidSamples = 0;
_timeStretcherInputBufferValidSamples = 0;
_timeStretcherInputBufferReadOffset = 0;
_currentFractionalFrame = 0f;

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 Changing direction skips audio

Switching from reverse to forward clears the buffers but leaves the provider at the wrong position. A reverse read seeks to targetPos, reads forward, and leaves the provider at the end of that block, while _rawSamplePosition moves to its beginning. Switching to 1.0f then reads from the provider's old position and skips a block. Seek the provider to the current playback position when changing direction.

Knowledge Base Used: Playback, mixing, and recording components

Prompt To Fix With AI
This is a comment left during a code review.
Path: Src/Abstracts/SoundPlayerBase.cs
Line: 39-45

Comment:
**Changing direction skips audio**

Switching from reverse to forward clears the buffers but leaves the provider at the wrong position. A reverse read seeks to `targetPos`, reads forward, and leaves the provider at the end of that block, while `_rawSamplePosition` moves to its beginning. Switching to `1.0f` then reads from the provider's old position and skips a block. Seek the provider to the current playback position when changing direction.

**Knowledge Base Used:** [Playback, mixing, and recording components](https://app.greptile.com/lsxprime/-/custom-context/knowledge-base/lsxprime/soundflow/-/docs/playback-mixing-and-recording.md)

---

For each issue above, determine whether it is valid and should be fixed. If so, fix it directly.

Comment on lines +204 to +211
var samplesToRead = outputSlice.Length;
var targetPos = _rawSamplePosition - samplesToRead;

if (targetPos < 0)
{
// We are at the beginning, only read what's left
samplesToRead = _rawSamplePosition;
targetPos = 0;

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 Reverse loops escape their bounds

Reverse reads stop at zero rather than at _loopStartSamples. If a block starts just above a nonzero loop start, it plays audio before the selected region; the loop check runs only before the block starts. Both reverse read paths need to stop at the loop start and wrap while filling the remaining output.

Knowledge Base Used: Playback, mixing, and recording components

Prompt To Fix With AI
This is a comment left during a code review.
Path: Src/Abstracts/SoundPlayerBase.cs
Line: 204-211

Comment:
**Reverse loops escape their bounds**

Reverse reads stop at zero rather than at `_loopStartSamples`. If a block starts just above a nonzero loop start, it plays audio before the selected region; the loop check runs only before the block starts. Both reverse read paths need to stop at the loop start and wrap while filling the remaining output.

**Knowledge Base Used:** [Playback, mixing, and recording components](https://app.greptile.com/lsxprime/-/custom-context/knowledge-base/lsxprime/soundflow/-/docs/playback-mixing-and-recording.md)

---

For each issue above, determine whether it is valid and should be fixed. If so, fix it directly.

Comment on lines +149 to +151
var metadataLengthSamples = FormatInfo.Duration.TotalSeconds
* FormatInfo.SampleRate
* FormatInfo.ChannelCount;

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 Converted files show wrong durations

metadataLength counts samples in the file's original format, while _decoder.Length counts samples in the requested output format. The constructor accepting AudioFormat allows those formats to differ. A ten-second, 48 kHz stereo file decoded to 24 kHz mono therefore returns 960000 samples instead of 240000, making Duration forty seconds and allowing seeks beyond the decoded audio. Convert the metadata duration to the decoder's output rate and channel count before comparing lengths.

Knowledge Base Used: Audio data providers

Prompt To Fix With AI
This is a comment left during a code review.
Path: Src/Providers/StreamDataProvider.cs
Line: 149-151

Comment:
**Converted files show wrong durations**

`metadataLength` counts samples in the file's original format, while `_decoder.Length` counts samples in the requested output format. The constructor accepting `AudioFormat` allows those formats to differ. A ten-second, 48 kHz stereo file decoded to 24 kHz mono therefore returns `960000` samples instead of `240000`, making `Duration` forty seconds and allowing seeks beyond the decoded audio. Convert the metadata duration to the decoder's output rate and channel count before comparing lengths.

**Knowledge Base Used:** [Audio data providers](https://app.greptile.com/lsxprime/-/custom-context/knowledge-base/lsxprime/soundflow/-/docs/audio-data-providers.md)

---

For each issue above, determine whether it is valid and should be fixed. If so, fix it directly.

/// Subscribers will receive both near-end (capture) and far-end (playback) frames.
/// </summary>
public event AudioProcessCallback? OnAudioProcessed
public new event AudioProcessCallback? OnAudioProcessed

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 Full-duplex callbacks disappear

FullDuplexDevice hides the new base event with new, so subscribing through an AudioDevice reference attaches to a separate event that never fires. For example, AudioDevice device = fullDuplexDevice; device.OnAudioProcessed += handler; receives neither capture nor playback samples. Make the base event overridable and override its accessors, or forward both child events into the base event.

Knowledge Base Used: Audio devices and MiniAudio backend

Prompt To Fix With AI
This is a comment left during a code review.
Path: Src/Abstracts/Devices/FullDuplexDevice.cs
Line: 33

Comment:
**Full-duplex callbacks disappear**

`FullDuplexDevice` hides the new base event with `new`, so subscribing through an `AudioDevice` reference attaches to a separate event that never fires. For example, `AudioDevice device = fullDuplexDevice; device.OnAudioProcessed += handler;` receives neither capture nor playback samples. Make the base event overridable and override its accessors, or forward both child events into the base event.

**Knowledge Base Used:** [Audio devices and MiniAudio backend](https://app.greptile.com/lsxprime/-/custom-context/knowledge-base/lsxprime/soundflow/-/docs/audio-device-backend.md)

---

For each issue above, determine whether it is valid and should be fixed. If so, fix it directly.

MA_ZERO_OBJECT(pConfig);
*pConfig = ma_decoder_config_init(outputFormat, outputChannels, outputSampleRate);

pConfig->seekPointCount = seekPointCount;

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 Seek settings differ by platform

The native function now accepts seekPointCount, but the packaged Android, Intel macOS, and FreeBSD libraries were not updated. Those unchanged libraries still use the previous implementation, which does not apply the fourth argument. Rebuild the remaining runtime assets so users on those platforms also get the new seek-point setting.

Knowledge Base Used: Audio devices and MiniAudio backend

Prompt To Fix With AI
This is a comment left during a code review.
Path: Native/miniaudio-backend/library.c
Line: 180

Comment:
**Seek settings differ by platform**

The native function now accepts `seekPointCount`, but the packaged Android, Intel macOS, and FreeBSD libraries were not updated. Those unchanged libraries still use the previous implementation, which does not apply the fourth argument. Rebuild the remaining runtime assets so users on those platforms also get the new seek-point setting.

**Knowledge Base Used:** [Audio devices and MiniAudio backend](https://app.greptile.com/lsxprime/-/custom-context/knowledge-base/lsxprime/soundflow/-/docs/audio-device-backend.md)

---

For each issue above, determine whether it is valid and should be fixed. If so, fix it directly.

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

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

Holy fuck, I actually built the native libraries and forgot to replace them in the Src/Backends/MiniAudio/runtimes, well, no one should use them anyway. Users either directly use the NuGet package (which runtimes get automatically updated on release) or just use the release/v1.4.2 branch.

It’s not that I’m too lazy to do another git commit and git push, but I really hate cluttering my Git history with random stuff.

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

You're right—the release workflow rebuilds the native libraries for all RIDs, replaces Src/Backends/MiniAudio/runtimes, and then packs NuGet and updates the release branch. My comment assumed those checked-in runtime binaries were the distribution source; for your supported release paths, that isn't the case. Please disregard it, and sorry for the unnecessary noise.

Tip: You can customize Greptile's behavior for this repo with .greptile/rules.md and .greptile/config.json.

@LSXPrime

Copy link
Copy Markdown
Owner Author

I really like reading AI reviewers comments, it's so beautiful and makes me impressed with how technology has advanced, but like if I give a fuck, just ship.

Jokes aside, these simple modifications took three months because I couldn’t dedicate enough time (let’s hope March 1st comes quickly). I’ve had them running on my machine for a while, but I couldn’t commit until now. So, for now, no further changes, it’s working, and any issues can be fixed later.

@LSXPrime
LSXPrime merged commit 0a1f2c9 into master Oct 22, 2026
6 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

1 participant