diff --git a/.github/workflows/build-extensions-apm.yml b/.github/workflows/build-extensions-apm.yml
index f258cd1a..6b8e09c0 100644
--- a/.github/workflows/build-extensions-apm.yml
+++ b/.github/workflows/build-extensions-apm.yml
@@ -11,14 +11,14 @@ jobs:
matrix:
include:
# Windows builds
- - os: windows-latest
+ - os: windows-2025
rid: win-x64
platform: Windows
arch: x64
toolchain: mingw
cross_file: "windows-x64.crossfile"
- - os: windows-latest
+ - os: windows-2025
rid: win-x86
platform: Windows
arch: x86
diff --git a/.github/workflows/build-ffmpeg.yml b/.github/workflows/build-ffmpeg.yml
index 59861616..ae19710a 100644
--- a/.github/workflows/build-ffmpeg.yml
+++ b/.github/workflows/build-ffmpeg.yml
@@ -16,11 +16,11 @@ jobs:
matrix:
include:
- arch: x64
- os: windows-latest
+ os: windows-2025
msystem: MINGW64
mingw-package-prefix: mingw-w64-x86_64
- arch: x86
- os: windows-latest
+ os: windows-2025
msystem: MINGW32
mingw-package-prefix: mingw-w64-i686
- arch: arm64
@@ -368,6 +368,29 @@ jobs:
-DCMAKE_OSX_ARCHITECTURES=arm64 \
-DCMAKE_OSX_DEPLOYMENT_TARGET=11.0
cmake --build . --parallel --verbose
+
+ - name: Patch Info.plist for App Store Validation
+ run: |
+ PLIST="build/runtimes/ios-arm64/native/soundflow-ffmpeg.framework/Info.plist"
+ if [ -f "$PLIST" ]; then
+ echo "Patching $PLIST to meet altool/App Store requirements..."
+
+ # PlistBuddy is more reliable for CI scripting.
+ # 'Set' updates the key if it exists.
+ /usr/libexec/PlistBuddy -c "Set :CFBundleShortVersionString 1.4.0" "$PLIST"
+ /usr/libexec/PlistBuddy -c "Set :CFBundleVersion 1" "$PLIST"
+ /usr/libexec/PlistBuddy -c "Set :CFBundleName SoundFlow FFmpeg" "$PLIST"
+
+ # 'Add' creates the key if it doesn't exist (CMake sometimes omits MinimumOSVersion).
+ # We use '|| true' to ignore the error if it already exists.
+ /usr/libexec/PlistBuddy -c "Add :MinimumOSVersion string 12.0" "$PLIST" 2>/dev/null || true
+
+ echo "Patched Info.plist contents:"
+ cat "$PLIST"
+ else
+ echo "Warning: Info.plist not found at expected path: $PLIST"
+ fi
+
- name: Analyze Dependencies
run: |
ARTIFACT_DIR="build/runtimes/ios-arm64/native"
diff --git a/.github/workflows/build-miniaudio.yml b/.github/workflows/build-miniaudio.yml
index 4d733530..0211800d 100644
--- a/.github/workflows/build-miniaudio.yml
+++ b/.github/workflows/build-miniaudio.yml
@@ -144,29 +144,29 @@ jobs:
matrix:
include:
# Windows builds
- - os: windows-latest
+ - os: windows-2025
rid: win-x64
platform: Windows
arch: x64
cmake_target_arch: x64
lib_extension: ".dll"
- toolchain: "Visual Studio 17 2022"
+ toolchain: "Visual Studio 18 2026"
- - os: windows-latest
+ - os: windows-2025
rid: win-x86
platform: Windows
arch: x86
cmake_target_arch: Win32
lib_extension: ".dll"
- toolchain: "Visual Studio 17 2022"
+ toolchain: "Visual Studio 18 2026"
- - os: windows-latest
+ - os: windows-2025
rid: win-arm64
platform: Windows
arch: ARM64
cmake_target_arch: ARM64
lib_extension: ".dll"
- toolchain: "Visual Studio 17 2022"
+ toolchain: "Visual Studio 18 2026"
# Linux builds
- os: ubuntu-22.04
@@ -284,10 +284,16 @@ jobs:
run: |
sudo apt-get update
sudo apt-get install -y cmake gcc g++ file binutils
+
+ - name: Set up JDK 17
+ uses: actions/setup-java@v6
+ with:
+ distribution: 'temurin'
+ java-version: '17'
- name: Install Dependencies (Android)
if: matrix.platform == 'Android'
- uses: android-actions/setup-android@v2
+ uses: android-actions/setup-android@v4
- name: Install Cross-Compilation tools (Linux)
if: matrix.platform == 'Linux' && (matrix.arch == 'armv7l' || matrix.arch == 'aarch64')
diff --git a/.github/workflows/build-portmidi.yml b/.github/workflows/build-portmidi.yml
index b0b057d6..e666b3d0 100644
--- a/.github/workflows/build-portmidi.yml
+++ b/.github/workflows/build-portmidi.yml
@@ -12,17 +12,17 @@ jobs:
matrix:
include:
# Windows (MSVC)
- - os: windows-latest
+ - os: windows-2025
rid: win-x64
platform: Windows
arch: x64
cmake_generator_arch: x64
- - os: windows-latest
+ - os: windows-2025
rid: win-x86
platform: Windows
arch: x86
cmake_generator_arch: Win32
- - os: windows-latest
+ - os: windows-2025
rid: win-arm64
platform: Windows
arch: ARM64
@@ -107,7 +107,7 @@ jobs:
run: |
CMAKE_ARGS=()
if [[ "$RUNNER_OS" == "Windows" ]]; then
- CMAKE_ARGS+=("-G" "Visual Studio 17 2022" "-A" "${{ matrix.cmake_generator_arch }}")
+ CMAKE_ARGS+=("-G" "Visual Studio 18 2026" "-A" "${{ matrix.cmake_generator_arch }}")
elif [[ "$RUNNER_OS" == "macOS" ]]; then
CMAKE_ARGS+=("-DCMAKE_OSX_ARCHITECTURES=${{ matrix.arch }}")
fi
diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml
index 0b3bdcab..73e94f26 100644
--- a/.github/workflows/release.yml
+++ b/.github/workflows/release.yml
@@ -52,6 +52,10 @@ jobs:
inputs.release_webrtc == true ||
inputs.release_portmidi == true
runs-on: ubuntu-latest
+ permissions:
+ contents: write
+ actions: read
+ id-token: write
steps:
- name: Checkout repository
uses: actions/checkout@v4
@@ -238,13 +242,23 @@ jobs:
update_and_pack "Midi/SoundFlow.Midi.PortMidi/SoundFlow.Midi.PortMidi.csproj"
fi
+ - name: NuGet login (OIDC -> temporary API key)
+ if: inputs.publish_to_nuget == true
+ uses: NuGet/login@v1
+ id: nuget_login
+ with:
+ user: ${{ secrets.NUGET_USER }}
+
- name: Push Packages to NuGet.org
if: inputs.publish_to_nuget == true
run: |
if [ -z "$(ls -A packages/*.nupkg 2>/dev/null)" ]; then
echo "No packages were created. Skipping NuGet push."
else
- dotnet nuget push "packages/*.nupkg" --source "https://api.nuget.org/v3/index.json" --api-key ${{ secrets.NUGET_API_KEY }} --skip-duplicate
+ dotnet nuget push "packages/*.nupkg" \
+ --source "https://api.nuget.org/v3/index.json" \
+ --api-key ${{ steps.nuget_login.outputs.NUGET_API_KEY }} \
+ --skip-duplicate
fi
- name: Upload NuGet Packages as Artifact
diff --git a/Native/miniaudio-backend/library.c b/Native/miniaudio-backend/library.c
index a5341d4f..09efd2a0 100644
--- a/Native/miniaudio-backend/library.c
+++ b/Native/miniaudio-backend/library.c
@@ -168,7 +168,7 @@ MA_API ma_device_config* sf_allocate_device_config(const ma_device_type deviceTy
// Allocate memory for a decoder configuration struct.
MA_API ma_decoder_config *sf_allocate_decoder_config(const ma_format outputFormat, const ma_uint32 outputChannels,
- const ma_uint32 outputSampleRate) {
+ const ma_uint32 outputSampleRate, const ma_uint32 seekPointCount) {
ma_decoder_config *pConfig = sf_create(ma_decoder_config);
if (pConfig == NULL) {
return NULL;
@@ -177,6 +177,8 @@ MA_API ma_decoder_config *sf_allocate_decoder_config(const ma_format outputForma
MA_ZERO_OBJECT(pConfig);
*pConfig = ma_decoder_config_init(outputFormat, outputChannels, outputSampleRate);
+ pConfig->seekPointCount = seekPointCount;
+
return pConfig;
}
diff --git a/Native/miniaudio-backend/library.h b/Native/miniaudio-backend/library.h
index 3a92fb5d..562d76f1 100644
--- a/Native/miniaudio-backend/library.h
+++ b/Native/miniaudio-backend/library.h
@@ -111,7 +111,7 @@ MA_API ma_device_config *sf_allocate_device_config(ma_device_type deviceType, ma
// Allocate memory for a decoder configuration struct.
MA_API ma_decoder_config *sf_allocate_decoder_config(ma_format outputFormat, ma_uint32 outputChannels,
- ma_uint32 outputSampleRate);
+ ma_uint32 outputSampleRate, ma_uint32 seekPointCount);
// Allocate memory for an encoder configuration struct.
MA_API ma_encoder_config *sf_allocate_encoder_config(ma_format format,
diff --git a/Samples/SoundFlow.Samples.SimplePlayer/UserInterfaceService.cs b/Samples/SoundFlow.Samples.SimplePlayer/UserInterfaceService.cs
index 95045cee..4fd5766f 100644
--- a/Samples/SoundFlow.Samples.SimplePlayer/UserInterfaceService.cs
+++ b/Samples/SoundFlow.Samples.SimplePlayer/UserInterfaceService.cs
@@ -29,11 +29,14 @@ public static void DisplayPlaybackControls(SoundPlayer player)
Console.SetCursorPosition(0, Console.CursorTop);
Console.Write(new string(' ', Console.WindowWidth - 1)); // Clear the line
Console.SetCursorPosition(0, Console.CursorTop);
- Console.Write($"\rTime: {TimeSpan.FromSeconds(player.Time):mm\\:ss\\.ff} / {TimeSpan.FromSeconds(player.Duration):mm\\:ss\\.ff} | Speed: {player.PlaybackSpeed:F1}x | Vol: {player.Volume:F1} ");
+ Console.Write($"\rTime: {FormatTime(TimeSpan.FromSeconds(player.Time))} / {FormatTime(TimeSpan.FromSeconds(player.Duration))} | Speed: {player.PlaybackSpeed:F1}x | Vol: {player.Volume:F1} ");
if (originalLeft < Console.WindowWidth)
{
Console.SetCursorPosition(originalLeft, originalTop);
}
+
+ string FormatTime(TimeSpan t) =>
+ t.TotalHours >= 1 ? t.ToString(@"hh\:mm\:ss\.ff") : t.ToString(@"mm\:ss\.ff");
}
};
timer.Start();
@@ -59,7 +62,7 @@ public static void DisplayPlaybackControls(SoundPlayer player)
player.PlaybackSpeed = Math.Min(player.PlaybackSpeed + 0.1f, 4.0f);
break;
case ConsoleKey.OemMinus or ConsoleKey.Subtract:
- player.PlaybackSpeed = Math.Max(0.1f, player.PlaybackSpeed - 0.1f);
+ player.PlaybackSpeed = Math.Max(-1f, player.PlaybackSpeed - 0.1f);
break;
case ConsoleKey.T:
currentQuality = (WsolaPerformancePreset)(((int)currentQuality + 1) % 4);
diff --git a/Src/Abstracts/Devices/AudioCaptureDevice.cs b/Src/Abstracts/Devices/AudioCaptureDevice.cs
index 847d1db8..d787915e 100644
--- a/Src/Abstracts/Devices/AudioCaptureDevice.cs
+++ b/Src/Abstracts/Devices/AudioCaptureDevice.cs
@@ -7,11 +7,6 @@ namespace SoundFlow.Abstracts.Devices;
///
public abstract class AudioCaptureDevice : AudioDevice
{
- ///
- /// Occurs when new audio samples are captured from this device.
- ///
- public event AudioProcessCallback? OnAudioProcessed;
-
///
/// Initializes a new instance of the class.
///
@@ -19,23 +14,4 @@ public abstract class AudioCaptureDevice : AudioDevice
/// The desired audio format.
/// The device configuration.
protected AudioCaptureDevice(AudioEngine engine, AudioFormat format, DeviceConfig config) : base(engine, format, config) { }
-
- ///
- /// Invokes the event with the captured samples.
- /// This method is intended to be called by the backend implementation.
- ///
- /// The captured audio samples.
- protected virtual void InvokeOnAudioProcessed(Span samples)
- {
- OnAudioProcessed?.Invoke(samples, Capability);
- }
-
- ///
- /// Gets the invocation list of the OnAudioProcessed event. For internal engine use only.
- ///
- /// An array of delegates subscribed to the event.
- internal Delegate[] GetEventSubscribers()
- {
- return OnAudioProcessed?.GetInvocationList() ?? [];
- }
-}
+}
\ No newline at end of file
diff --git a/Src/Abstracts/Devices/AudioDevice.cs b/Src/Abstracts/Devices/AudioDevice.cs
index b1a35daa..fd057df4 100644
--- a/Src/Abstracts/Devices/AudioDevice.cs
+++ b/Src/Abstracts/Devices/AudioDevice.cs
@@ -48,6 +48,14 @@ public abstract class AudioDevice : IDisposable
///
public EventHandler? OnDisposed;
+ ///
+ /// Occurs when audio samples are processed by this device.
+ /// For capture devices, this is raised when new audio is captured from the hardware.
+ /// For playback devices, this is raised after the master mixer renders the final output buffer,
+ /// just before it is sent to the hardware.
+ ///
+ public event AudioProcessCallback? OnAudioProcessed;
+
///
/// Initializes a new instance of the class.
///
@@ -76,8 +84,27 @@ protected AudioDevice(AudioEngine engine, AudioFormat format, DeviceConfig confi
///
public abstract void Dispose();
+ ///
+ /// Invokes the event with the processed samples.
+ /// This method is intended to be called by the backend implementation.
+ ///
+ /// The processed audio samples.
+ protected virtual void InvokeOnAudioProcessed(Span samples)
+ {
+ OnAudioProcessed?.Invoke(samples, Capability);
+ }
+
+ ///
+ /// Gets the invocation list of the OnAudioProcessed event. For internal engine use only.
+ ///
+ /// An array of delegates subscribed to the event.
+ internal virtual Delegate[] GetEventSubscribers()
+ {
+ return OnAudioProcessed?.GetInvocationList() ?? [];
+ }
+
///
/// Called when the audio device is disposed.
///
protected virtual void OnDisposedHandler() => OnDisposed?.Invoke(this, EventArgs.Empty);
-}
+}
\ No newline at end of file
diff --git a/Src/Abstracts/Devices/FullDuplexDevice.cs b/Src/Abstracts/Devices/FullDuplexDevice.cs
index 4d6e8e20..9d82406e 100644
--- a/Src/Abstracts/Devices/FullDuplexDevice.cs
+++ b/Src/Abstracts/Devices/FullDuplexDevice.cs
@@ -27,12 +27,21 @@ public sealed class FullDuplexDevice : AudioDevice, IDisposable
public Mixer MasterMixer => PlaybackDevice.MasterMixer;
///
- /// Occurs when audio data is processed by the capture device.
+ /// Occurs when audio data is processed by either the capture or playback device.
+ /// Subscribers will receive both near-end (capture) and far-end (playback) frames.
///
- public event AudioProcessCallback? OnAudioProcessed
+ public new event AudioProcessCallback? OnAudioProcessed
{
- add => CaptureDevice.OnAudioProcessed += value;
- remove => CaptureDevice.OnAudioProcessed -= value;
+ add
+ {
+ CaptureDevice.OnAudioProcessed += value;
+ PlaybackDevice.OnAudioProcessed += value;
+ }
+ remove
+ {
+ CaptureDevice.OnAudioProcessed -= value;
+ PlaybackDevice.OnAudioProcessed -= value;
+ }
}
///
@@ -87,4 +96,12 @@ public override void Dispose()
IsDisposed = true;
}
-}
+
+ ///
+ internal override Delegate[] GetEventSubscribers()
+ {
+ // Subscribers are routed to the child devices, so we retrieve them from there.
+ // Returning CaptureDevice's list is sufficient as both will have the same subscribers.
+ return CaptureDevice.GetEventSubscribers();
+ }
+}
\ No newline at end of file
diff --git a/Src/Abstracts/SoundPlayerBase.cs b/Src/Abstracts/SoundPlayerBase.cs
index 0d26d3cf..9402af38 100644
--- a/Src/Abstracts/SoundPlayerBase.cs
+++ b/Src/Abstracts/SoundPlayerBase.cs
@@ -31,12 +31,28 @@ public float PlaybackSpeed
get => _playbackSpeed;
set
{
- if (value <= 0)
- throw new ArgumentOutOfRangeException(nameof(value), "Playback speed must be greater than zero.");
+ if (value == 0)
+ throw new ArgumentOutOfRangeException(nameof(value), "Playback speed must be greater than or less than zero.");
+
+ // If the sign of the speed changes (e.g., forward to reverse), reset internal buffers
+ // to prevent processing stale data in the wrong direction.
+ if (Math.Sign(_playbackSpeed) != Math.Sign(value) && _playbackSpeed != 0)
+ {
+ _timeStretcher.Reset();
+ _resampleBufferValidSamples = 0;
+ _timeStretcherInputBufferValidSamples = 0;
+ _timeStretcherInputBufferReadOffset = 0;
+ _currentFractionalFrame = 0f;
+ }
+
if (Math.Abs(_playbackSpeed - value) > 1e-6f)
{
_playbackSpeed = value;
- _timeStretcher.SetSpeed(_playbackSpeed);
+ // WSOLA does not support negative speeds; we bypass it for reverse playback.
+ if (value > 0)
+ {
+ _timeStretcher.SetSpeed(_playbackSpeed);
+ }
}
}
}
@@ -77,6 +93,11 @@ public void SetTimeStretchQuality(WsolaPerformancePreset preset)
///
public bool IsLooping { get; set; }
+
+ ///
+ /// Occurs when playback ends.
+ ///
+ public event EventHandler? PlaybackEnded;
///
public float Time =>
@@ -140,39 +161,105 @@ protected override void GenerateAudio(Span output, int channels)
}
// Proactively check for looping before generating audio. This handles loops where a specific end point is set.
- if (IsLooping && _loopEndSamples != -1)
+ if (IsLooping && _loopEndSamples != -1 && _playbackSpeed > 0)
{
// Ensure loop is valid, and we've reached or passed the end point.
if (_loopStartSamples < _loopEndSamples && _rawSamplePosition >= _loopEndSamples)
Seek(_loopStartSamples, channels);
}
+
+ // Proactively check for reverse looping
+ if (IsLooping && _playbackSpeed < 0)
+ {
+ var actualLoopEnd = (_loopEndSamples == -1) ? _dataProvider.Length : Math.Min(_loopEndSamples, _dataProvider.Length);
+ if (_loopStartSamples < actualLoopEnd && _rawSamplePosition <= _loopStartSamples)
+ Seek(actualLoopEnd, channels);
+ }
- // Directly read from provider when playback speed is 1.0
- if (Math.Abs(_playbackSpeed - 1.0f) < 0.001f)
+ // Directly read from provider when playback speed is 1.0 or -1.0
+ if (Math.Abs(Math.Abs(_playbackSpeed) - 1.0f) < 0.001f)
{
var outputSlice = output;
// Loop until the output buffer is full or the stream truly ends.
while (!outputSlice.IsEmpty)
{
- var samplesReadThisCall = _dataProvider.ReadBytes(outputSlice);
-
- // A return value of 0 is the ONLY reliable end-of-stream signal.
- if (samplesReadThisCall == 0)
+ if (_playbackSpeed > 0)
{
- // The data provider is exhausted. Handle the end of stream for the remaining part of the buffer.
- HandleEndOfStream(outputSlice, channels);
- break; // Exit the read loop
+ var samplesReadThisCall = _dataProvider.ReadBytes(outputSlice);
+ // A return value of 0 is the ONLY reliable end-of-stream signal.
+ if (samplesReadThisCall == 0)
+ {
+ // The data provider is exhausted. Handle the end of stream for the remaining part of the buffer.
+ HandleEndOfStream(outputSlice, channels);
+ break; // Exit the read loop
+ }
+
+ _rawSamplePosition += samplesReadThisCall;
+ outputSlice = outputSlice.Slice(samplesReadThisCall);
}
+ else
+ {
+ // Reverse playback (speed == -1.0)
+ 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;
+ }
+
+ if (samplesToRead == 0)
+ {
+ HandleEndOfStream(outputSlice, channels);
+ break;
+ }
- _rawSamplePosition += samplesReadThisCall;
- outputSlice = outputSlice.Slice(samplesReadThisCall);
+ _dataProvider.Seek(targetPos);
+ var tempBuf = System.Buffers.ArrayPool.Shared.Rent(samplesToRead);
+ try
+ {
+ var tempSpan = tempBuf.AsSpan(0, samplesToRead);
+ var read = _dataProvider.ReadBytes(tempSpan);
+ if (read == 0)
+ {
+ HandleEndOfStream(outputSlice, channels);
+ break;
+ }
+
+ // Reverse frames in place
+ for (var f = 0; f < read / channels / 2; f++)
+ {
+ for (var c = 0; c < channels; c++)
+ {
+ (tempSpan[f * channels + c], tempSpan[(read / channels - 1 - f) * channels + c]) = (tempSpan[(read / channels - 1 - f) * channels + c], tempSpan[f * channels + c]);
+ }
+ }
+
+ tempSpan.Slice(0, read).CopyTo(outputSlice);
+ _rawSamplePosition -= read;
+
+ if (read < outputSlice.Length)
+ {
+ // We hit the beginning, handle the rest
+ HandleEndOfStream(outputSlice.Slice(read), channels);
+ break;
+ }
+ outputSlice = outputSlice.Slice(read);
+ }
+ finally
+ {
+ System.Buffers.ArrayPool.Shared.Return(tempBuf);
+ }
+ }
}
return;
}
// Ensure time stretcher has correct channel count.
- if (_timeStretcher.GetTargetSpeed() == 0f && _playbackSpeed != 0f && channels > 0)
+ if (_playbackSpeed > 0 && _timeStretcher.GetTargetSpeed() == 0f && _playbackSpeed != 0f && channels > 0)
_timeStretcher.SetChannels(channels);
var outputFramesTotal = output.Length / channels;
@@ -195,7 +282,7 @@ protected override void GenerateAudio(Span output, int channels)
if (_resampleBufferValidSamples < samplesRequiredInBufferForInterpolation)
{
_rawSamplePosition += totalSourceSamplesAdvancedThisCall;
- _rawSamplePosition = Math.Min(_rawSamplePosition, _dataProvider.Length);
+ _rawSamplePosition = Math.Max(0, _rawSamplePosition);
HandleEndOfStream(output[outputBufferOffset..], channels);
return;
}
@@ -245,7 +332,7 @@ protected override void GenerateAudio(Span output, int channels)
// Update raw sample position based on actual source samples advanced.
_rawSamplePosition += totalSourceSamplesAdvancedThisCall;
- _rawSamplePosition = Math.Min(_rawSamplePosition, _dataProvider.Length);
+ _rawSamplePosition = Math.Max(0, _rawSamplePosition);
}
///
@@ -288,7 +375,57 @@ private int FillResampleBuffer(int minSamplesRequiredInOutputBuffer, int channel
}
var totalSourceSamplesRepresented = 0;
+ var isReverse = _playbackSpeed < 0;
+
+ // Reverse playback bypasses the time stretcher entirely and relies on linear interpolation.
+ if (isReverse)
+ {
+ while (_resampleBufferValidSamples < minSamplesRequiredInOutputBuffer)
+ {
+ var spaceAvailableInResampleBuffer = _resampleBuffer.Length - _resampleBufferValidSamples;
+ if (spaceAvailableInResampleBuffer == 0) break;
+
+ var samplesToRead = spaceAvailableInResampleBuffer;
+ var targetPos = _rawSamplePosition - samplesToRead;
+
+ if (targetPos < 0)
+ {
+ samplesToRead = _rawSamplePosition;
+ targetPos = 0;
+ }
+
+ if (samplesToRead == 0) break; // Reached beginning
+
+ _dataProvider.Seek(targetPos);
+ var tempBuf = System.Buffers.ArrayPool.Shared.Rent(samplesToRead);
+ try
+ {
+ var tempSpan = tempBuf.AsSpan(0, samplesToRead);
+ var read = _dataProvider.ReadBytes(tempSpan);
+ if (read == 0) break; // True end of stream
+ // Reverse frames in place
+ for (var f = 0; f < read / channels / 2; f++)
+ {
+ for (var c = 0; c < channels; c++)
+ {
+ (tempSpan[f * channels + c], tempSpan[(read / channels - 1 - f) * channels + c]) = (tempSpan[(read / channels - 1 - f) * channels + c], tempSpan[f * channels + c]);
+ }
+ }
+
+ tempSpan[..read].CopyTo(_resampleBuffer.AsSpan(_resampleBufferValidSamples));
+ _resampleBufferValidSamples += read;
+ totalSourceSamplesRepresented -= read; // Negative to indicate reverse movement
+ }
+ finally
+ {
+ System.Buffers.ArrayPool.Shared.Return(tempBuf);
+ }
+ }
+ return totalSourceSamplesRepresented;
+ }
+
+ // Forward playback with time stretching
// Loop to fill _resampleBuffer until minimum required samples are met.
while (_resampleBufferValidSamples < minSamplesRequiredInOutputBuffer)
{
@@ -449,7 +586,7 @@ protected virtual void HandleEndOfStream(Span remainingOutputBuffer, int
{
var sourceSamplesFromFinalFill = FillResampleBuffer(Math.Max(currentlyValidInResample, spaceToFill), channels);
_rawSamplePosition += sourceSamplesFromFinalFill;
- _rawSamplePosition = Math.Min(_rawSamplePosition, _dataProvider.Length);
+ _rawSamplePosition = Math.Max(0, _rawSamplePosition);
}
var toCopy = Math.Min(spaceToFill, _resampleBufferValidSamples);
@@ -489,7 +626,8 @@ protected virtual void HandleEndOfStream(Span remainingOutputBuffer, int
// Check if the loop is valid (start < end, and start is within bounds).
if (targetLoopStart < actualLoopEnd && targetLoopStart < _dataProvider.Length)
{
- Seek(targetLoopStart, channels);
+ // Forward playback seeks to start. Reverse seeks to end.
+ Seek(_playbackSpeed < 0 ? actualLoopEnd : targetLoopStart, channels);
if (!remainingOutputBuffer.IsEmpty)
GenerateAudio(remainingOutputBuffer, channels);
}
@@ -520,11 +658,6 @@ protected virtual void OnPlaybackEnded()
if (!isEffectivelyLooping) Enabled = false;
}
- ///
- /// Occurs when playback ends.
- ///
- public event EventHandler? PlaybackEnded;
-
#region Audio Playback Control
///
diff --git a/Src/Backends/MiniAudio/Devices/MiniAudioPlaybackDevice.cs b/Src/Backends/MiniAudio/Devices/MiniAudioPlaybackDevice.cs
index 6105d96f..2c835d00 100644
--- a/Src/Backends/MiniAudio/Devices/MiniAudioPlaybackDevice.cs
+++ b/Src/Backends/MiniAudio/Devices/MiniAudioPlaybackDevice.cs
@@ -64,6 +64,9 @@ private void ProcessAudioCallback(nint pOutput, nint pInput, uint frameCount, Mi
{
var buffer = Extensions.GetSpan(pOutput, length);
ProcessAndFillBuffer(buffer, device.Format.Channels);
+
+ // Raise the event to provide far-end audio data to subscribers (like WebRTC AEC)
+ InvokeOnAudioProcessed(buffer);
return;
}
@@ -76,7 +79,10 @@ private void ProcessAudioCallback(nint pOutput, nint pInput, uint frameCount, Mi
// 1. Generate the audio signal into our temporary float buffer.
ProcessAndFillBuffer(buffer, device.Format.Channels);
- // 2. Convert the float buffer to the device's native format.
+ // 2. Raise the event to provide far-end audio data to subscribers (like WebRTC AEC)
+ InvokeOnAudioProcessed(buffer);
+
+ // 3. Convert the float buffer to the device's native format.
DeviceBufferHelper.ConvertToDeviceFormat(buffer, pOutput, length, device.Format.Format);
}
finally
diff --git a/Src/Backends/MiniAudio/MiniAudioDecoder.cs b/Src/Backends/MiniAudio/MiniAudioDecoder.cs
index 92177a9e..cf6ae5a9 100644
--- a/Src/Backends/MiniAudio/MiniAudioDecoder.cs
+++ b/Src/Backends/MiniAudio/MiniAudioDecoder.cs
@@ -17,7 +17,7 @@ internal sealed unsafe class MiniAudioDecoder : ISoundDecoder
// Keep references to delegates to prevent GC collection while native code uses them
private readonly Native.BufferProcessingCallback _readCallback;
- private readonly Native.SeekCallback _seekCallbackCallback;
+ private readonly Native.SeekCallback? _seekCallbackCallback;
private bool _endOfStreamReached;
private byte[]? _rentedReadBuffer;
@@ -30,32 +30,41 @@ internal sealed unsafe class MiniAudioDecoder : ISoundDecoder
/// The format of the audio samples.
/// The number of audio channels.
/// The sample rate of the audio.
- public MiniAudioDecoder(Stream stream, SampleFormat sampleFormat, int channels, int sampleRate)
+ /// Controls how many seek points the decoder maintains internally
+ public MiniAudioDecoder(Stream stream, SampleFormat sampleFormat, int channels, int sampleRate, uint seekPointCount = 100) // 0 = disable, use binary search only)
{
_stream = stream ?? throw new ArgumentNullException(nameof(stream));
SampleFormat = sampleFormat;
Channels = channels;
SampleRate = sampleRate;
-
- var configPtr = Native.AllocateDecoderConfig(SampleFormat, (uint)Channels, (uint)SampleRate);
+
+ var configPtr = Native.AllocateDecoderConfig(SampleFormat, (uint)Channels, (uint)SampleRate, seekPointCount);
_decoder = Native.AllocateDecoder();
// Store delegates in fields to prevent GC collection
_readCallback = ReadCallback;
- _seekCallbackCallback = SeekCallback;
+
+ // Only assign and use the seek callback if the underlying stream supports seeking.
+ if (_stream.CanSeek)
+ {
+ _seekCallbackCallback = SeekCallback;
+ }
var result = Native.DecoderInit(_readCallback, _seekCallbackCallback, nint.Zero, configPtr, _decoder);
Native.Free(configPtr);
if (result != MiniAudioResult.Success)
throw new MiniaudioException("MiniAudio", result, "Unable to initialize decoder.");
-
- result = Native.DecoderGetLengthInPcmFrames(_decoder, out var length);
- if (result != MiniAudioResult.Success)
- throw new MiniaudioException("MiniAudio", result, "Unable to get decoder length.");
- Length = (int)length * Channels;
+ // Attempt to get the length. For some formats (like VBR MP3s without a Xing header), this may fail.
+ result = Native.DecoderGetLengthInPcmFrames(_decoder, out var length);
+ Length = result switch
+ {
+ MiniAudioResult.Success => (int)length * Channels,
+ _ => 0 // Length is unknown (e.g., VBR without Xing header or live stream)
+ };
+
_endOfStreamReached = false;
}
@@ -185,16 +194,10 @@ public bool Seek(int offset)
{
lock (_syncLock)
{
- MiniAudioResult miniAudioResult;
- if (Length == 0)
- {
- miniAudioResult = Native.DecoderGetLengthInPcmFrames(_decoder, out var length);
- if (miniAudioResult != MiniAudioResult.Success || (int)length == 0) return false;
- Length = (int)length * Channels;
- }
-
+ // We don't strictly need the total length to seek if the stream is seekable.
+ // MiniAudio's internal decoder can binary search or scan.
_endOfStreamReached = false;
- miniAudioResult = Native.DecoderSeekToPcmFrame(_decoder, (ulong)(offset / Channels));
+ var miniAudioResult = Native.DecoderSeekToPcmFrame(_decoder, (ulong)(offset / Channels));
return miniAudioResult == MiniAudioResult.Success;
}
}
@@ -271,24 +274,48 @@ private MiniAudioResult SeekCallback(nint _, long byteOffset, SeekPoint point)
lock (_syncLock)
{
if (!_stream.CanSeek)
- return MiniAudioResult.NoDataAvailable;
+ {
+ return MiniAudioResult.InvalidOperation;
+ }
- // Basic bounds check to prevent seeking past EOF if stream supports Length
- try
+ long streamLength = _stream.Length;
+ long targetOffset;
+
+ // MiniAudio's binary search uses relative offsets to seek backward.
+ // We must calculate the absolute target position first, clamp it, and then execute the seek.
+ if (point == SeekPoint.FromCurrent)
{
- if (byteOffset >= 0 && byteOffset < _stream.Length - 1)
- _stream.Seek(byteOffset, point == SeekPoint.FromCurrent ? SeekOrigin.Current : SeekOrigin.Begin);
+ long currentPos = _stream.Position;
+ targetOffset = currentPos + byteOffset;
}
- catch (NotSupportedException)
+ else
{
- // Some streams claim CanSeek but throw on Length or Position
- Log.Critical("Stream does not support seeking.");
- return MiniAudioResult.InvalidOperation;
+ targetOffset = byteOffset;
+ }
+
+ // Clamp targetOffset to stream bounds to prevent exceptions
+ if (targetOffset < 0)
+ {
+ targetOffset = 0;
+ }
+
+ if (streamLength > 0 && targetOffset > streamLength)
+ {
+ // Allow seeking to the very end of the stream (streamLength) for EOF checks
+ targetOffset = streamLength;
}
+ // Always use SeekOrigin.Begin with the absolute targetOffset to avoid relative offset no-op bugs
+ _stream.Seek(targetOffset, SeekOrigin.Begin);
return MiniAudioResult.Success;
}
}
+ catch (NotSupportedException)
+ {
+ // Some streams claim CanSeek but throw on Length or Position
+ Log.Critical("Stream does not support seeking.");
+ return MiniAudioResult.InvalidOperation;
+ }
catch (Exception)
{
Log.Critical("Failed to seek stream.");
diff --git a/Src/Backends/MiniAudio/MiniAudioEngine.cs b/Src/Backends/MiniAudio/MiniAudioEngine.cs
index 7cff89dd..756fbedf 100644
--- a/Src/Backends/MiniAudio/MiniAudioEngine.cs
+++ b/Src/Backends/MiniAudio/MiniAudioEngine.cs
@@ -268,12 +268,18 @@ public override AudioPlaybackDevice SwitchDevice(AudioPlaybackDevice oldDevice,
DeviceConfig? config = null)
{
var wasRunning = oldDevice.IsRunning;
+
+ // Preserve both components and event subscribers
var preservedComponents = DeviceSwitcher.PreservePlaybackState(oldDevice);
+ var preservedSubscribers = DeviceSwitcher.PreserveEventSubscribers(oldDevice);
oldDevice.Dispose();
var newDevice = InitializePlaybackDevice(newDeviceInfo, oldDevice.Format, config);
+
+ // Restore state
DeviceSwitcher.RestorePlaybackState(newDevice, preservedComponents);
+ DeviceSwitcher.RestoreEventSubscribers(newDevice, preservedSubscribers);
if (wasRunning) newDevice.Start();
@@ -285,12 +291,12 @@ public override AudioCaptureDevice SwitchDevice(AudioCaptureDevice oldDevice, De
DeviceConfig? config = null)
{
var wasRunning = oldDevice.IsRunning;
- var preservedSubscribers = DeviceSwitcher.PreserveCaptureState(oldDevice);
+ var preservedSubscribers = DeviceSwitcher.PreserveEventSubscribers(oldDevice);
oldDevice.Dispose();
var newDevice = InitializeCaptureDevice(newDeviceInfo, oldDevice.Format, config);
- DeviceSwitcher.RestoreCaptureState(newDevice, preservedSubscribers);
+ DeviceSwitcher.RestoreEventSubscribers(newDevice, preservedSubscribers);
if (wasRunning) newDevice.Start();
@@ -305,7 +311,8 @@ public override FullDuplexDevice SwitchDevice(FullDuplexDevice oldDevice, Device
// Preserve state from both underlying devices
var preservedComponents = DeviceSwitcher.PreservePlaybackState(oldDevice.PlaybackDevice);
- var preservedSubscribers = DeviceSwitcher.PreserveCaptureState(oldDevice.CaptureDevice);
+ var preservedPlaybackSubscribers = DeviceSwitcher.PreserveEventSubscribers(oldDevice.PlaybackDevice);
+ var preservedCaptureSubscribers = DeviceSwitcher.PreserveEventSubscribers(oldDevice.CaptureDevice);
// Use old device info if new info is not provided
var playbackInfo = newPlaybackInfo ?? oldDevice.PlaybackDevice.Info;
@@ -317,7 +324,8 @@ public override FullDuplexDevice SwitchDevice(FullDuplexDevice oldDevice, Device
// Restore state to the new underlying devices
DeviceSwitcher.RestorePlaybackState(newDevice.PlaybackDevice, preservedComponents);
- DeviceSwitcher.RestoreCaptureState(newDevice.CaptureDevice, preservedSubscribers);
+ DeviceSwitcher.RestoreEventSubscribers(newDevice.PlaybackDevice, preservedPlaybackSubscribers);
+ DeviceSwitcher.RestoreEventSubscribers(newDevice.CaptureDevice, preservedCaptureSubscribers);
if (wasRunning) newDevice.Start();
diff --git a/Src/Backends/MiniAudio/Native.cs b/Src/Backends/MiniAudio/Native.cs
index 229668d5..92e4ce89 100644
--- a/Src/Backends/MiniAudio/Native.cs
+++ b/Src/Backends/MiniAudio/Native.cs
@@ -177,7 +177,7 @@ public static partial MiniAudioResult EncoderWritePcmFrames(nint pEncoder, nint
#region Decoder
[LibraryImport(LibraryName, EntryPoint = "ma_decoder_init")]
- public static partial MiniAudioResult DecoderInit(BufferProcessingCallback onRead, SeekCallback onSeekCallback, nint pUserData,
+ public static partial MiniAudioResult DecoderInit(BufferProcessingCallback onRead, SeekCallback? onSeekCallback, nint pUserData,
nint pConfig, nint pDecoder);
[LibraryImport(LibraryName, EntryPoint = "ma_decoder_uninit")]
@@ -242,7 +242,7 @@ public static partial MiniAudioResult DecoderReadPcmFrames(nint decoder, nint fr
public static partial nint AllocateDevice();
[LibraryImport(LibraryName, EntryPoint = "sf_allocate_decoder_config")]
- public static partial nint AllocateDecoderConfig(SampleFormat format, uint channels, uint sampleRate);
+ public static partial nint AllocateDecoderConfig(SampleFormat format, uint channels, uint sampleRate, uint seekPointCount);
[LibraryImport(LibraryName, EntryPoint = "sf_allocate_encoder_config")]
public static partial nint AllocateEncoderConfig(SampleFormat format, uint channels,
diff --git a/Src/Backends/MiniAudio/runtimes/iOS-arm64/native/miniaudio.framework/Info.plist b/Src/Backends/MiniAudio/runtimes/iOS-arm64/native/miniaudio.framework/Info.plist
index c7eeb590..3321d10f 100644
Binary files a/Src/Backends/MiniAudio/runtimes/iOS-arm64/native/miniaudio.framework/Info.plist and b/Src/Backends/MiniAudio/runtimes/iOS-arm64/native/miniaudio.framework/Info.plist differ
diff --git a/Src/Backends/MiniAudio/runtimes/iOS-arm64/native/miniaudio.framework/miniaudio b/Src/Backends/MiniAudio/runtimes/iOS-arm64/native/miniaudio.framework/miniaudio
index 673d2ad7..4400156d 100644
Binary files a/Src/Backends/MiniAudio/runtimes/iOS-arm64/native/miniaudio.framework/miniaudio and b/Src/Backends/MiniAudio/runtimes/iOS-arm64/native/miniaudio.framework/miniaudio differ
diff --git a/Src/Backends/MiniAudio/runtimes/linux-arm/native/libminiaudio.so b/Src/Backends/MiniAudio/runtimes/linux-arm/native/libminiaudio.so
index 7ea12204..f16c50da 100644
Binary files a/Src/Backends/MiniAudio/runtimes/linux-arm/native/libminiaudio.so and b/Src/Backends/MiniAudio/runtimes/linux-arm/native/libminiaudio.so differ
diff --git a/Src/Backends/MiniAudio/runtimes/linux-arm64/native/libminiaudio.so b/Src/Backends/MiniAudio/runtimes/linux-arm64/native/libminiaudio.so
index 3a156898..39e466ad 100644
Binary files a/Src/Backends/MiniAudio/runtimes/linux-arm64/native/libminiaudio.so and b/Src/Backends/MiniAudio/runtimes/linux-arm64/native/libminiaudio.so differ
diff --git a/Src/Backends/MiniAudio/runtimes/linux-x64/native/libminiaudio.so b/Src/Backends/MiniAudio/runtimes/linux-x64/native/libminiaudio.so
index 2186157d..89e5e6e2 100644
Binary files a/Src/Backends/MiniAudio/runtimes/linux-x64/native/libminiaudio.so and b/Src/Backends/MiniAudio/runtimes/linux-x64/native/libminiaudio.so differ
diff --git a/Src/Backends/MiniAudio/runtimes/osx-arm64/native/libminiaudio.dylib b/Src/Backends/MiniAudio/runtimes/osx-arm64/native/libminiaudio.dylib
index b1a1a1ea..46589e67 100644
Binary files a/Src/Backends/MiniAudio/runtimes/osx-arm64/native/libminiaudio.dylib and b/Src/Backends/MiniAudio/runtimes/osx-arm64/native/libminiaudio.dylib differ
diff --git a/Src/Backends/MiniAudio/runtimes/win-arm64/native/miniaudio.dll b/Src/Backends/MiniAudio/runtimes/win-arm64/native/miniaudio.dll
index b3744c8f..cb5a673d 100644
Binary files a/Src/Backends/MiniAudio/runtimes/win-arm64/native/miniaudio.dll and b/Src/Backends/MiniAudio/runtimes/win-arm64/native/miniaudio.dll differ
diff --git a/Src/Backends/MiniAudio/runtimes/win-x64/native/miniaudio.dll b/Src/Backends/MiniAudio/runtimes/win-x64/native/miniaudio.dll
index ba7911ad..9919d1fb 100644
Binary files a/Src/Backends/MiniAudio/runtimes/win-x64/native/miniaudio.dll and b/Src/Backends/MiniAudio/runtimes/win-x64/native/miniaudio.dll differ
diff --git a/Src/Backends/MiniAudio/runtimes/win-x86/native/miniaudio.dll b/Src/Backends/MiniAudio/runtimes/win-x86/native/miniaudio.dll
index b14e18e2..f250c7d2 100644
Binary files a/Src/Backends/MiniAudio/runtimes/win-x86/native/miniaudio.dll and b/Src/Backends/MiniAudio/runtimes/win-x86/native/miniaudio.dll differ
diff --git a/Src/Interfaces/ISoundPlayer.cs b/Src/Interfaces/ISoundPlayer.cs
index 76014bb4..243730b5 100644
--- a/Src/Interfaces/ISoundPlayer.cs
+++ b/Src/Interfaces/ISoundPlayer.cs
@@ -25,8 +25,12 @@ public interface ISoundPlayer : IDisposable
///
/// Gets or sets the playback speed of the sound player.
/// A value of 1.0 represents normal speed. Values greater than 1.0 increase the speed, and values less than 1.0 decrease it.
+ /// Negative values (e.g., -1.0) indicate reverse playback. When playing in reverse, time-stretching (pitch preservation)
+ /// is bypassed, and linear interpolation is used, which will affect the pitch.
///
- /// The current implementation uses linear interpolation which may affect the pitch.
+ ///
+ /// The current implementation uses linear interpolation for negative values which may affect the pitch.
+ ///
float PlaybackSpeed { get; set; }
///
@@ -133,4 +137,4 @@ public interface ISoundPlayer : IDisposable
/// The loop start time as a TimeSpan. Must be non-negative.
/// The loop end time as a TimeSpan, optional. Use null to loop to the natural end of the audio. Must be greater than or equal to startTime, or null.
void SetLoopPoints(TimeSpan startTime, TimeSpan? endTime = null);
-}
+}
\ No newline at end of file
diff --git a/Src/Providers/StreamDataProvider.cs b/Src/Providers/StreamDataProvider.cs
index 2108ac45..22bc951b 100644
--- a/Src/Providers/StreamDataProvider.cs
+++ b/Src/Providers/StreamDataProvider.cs
@@ -63,7 +63,7 @@ public StreamDataProvider(AudioEngine engine, Stream stream, ReadOptions? option
: TimeSpan.Zero
};
}
-
+
SampleRate = _decoder.SampleRate;
_decoder.EndOfStreamReached += EndOfStreamReached;
}
@@ -77,14 +77,14 @@ public StreamDataProvider(AudioEngine engine, Stream stream, ReadOptions? option
public StreamDataProvider(AudioEngine engine, AudioFormat format, Stream stream)
{
_stream = stream ?? throw new ArgumentNullException(nameof(stream));
-
+
var formatInfoResult = SoundMetadataReader.Read(_stream, new ReadOptions
{
- ReadTags = false,
- ReadAlbumArt = false,
+ ReadTags = false,
+ ReadAlbumArt = false,
DurationAccuracy = DurationAccuracy.FastEstimate
});
-
+
if (formatInfoResult is { IsSuccess: true, Value: not null })
{
// Path 1: Metadata read successfully. Use the discovered formatId with the user's target format.
@@ -97,7 +97,7 @@ public StreamDataProvider(AudioEngine engine, AudioFormat format, Stream stream)
// Path 2: Metadata read failed. Fall back to probing, providing the user's format as a hint.
_stream.Position = 0;
_decoder = engine.CreateDecoder(_stream, out var detectedFormat, format);
-
+
// Create a basic FormatInfo from what the decoder found.
FormatInfo = new SoundFormatInfo
{
@@ -110,7 +110,7 @@ public StreamDataProvider(AudioEngine engine, AudioFormat format, Stream stream)
: TimeSpan.Zero
};
}
-
+
SampleRate = _decoder.SampleRate;
_decoder.EndOfStreamReached += EndOfStreamReached;
@@ -119,10 +119,57 @@ public StreamDataProvider(AudioEngine engine, AudioFormat format, Stream stream)
///
public int Position { get; private set; }
- ///
- public int Length => _decoder.Length > 0 || FormatInfo == null
- ? _decoder.Length
- : (int)(FormatInfo.Duration.TotalSeconds * FormatInfo.SampleRate * FormatInfo.ChannelCount);
+ ///
+ /// Gets the total length of the audio data in samples.
+ ///
+ ///
+ /// Two independent length estimates are available at this layer:
+ ///
+ /// - _decoder.Length — returned by the underlying codec (e.g. ma_decoder_get_length_in_pcm_frames).
+ /// - FormatInfo.Duration — read by from ID3/Xing/Info tags.
+ ///
+ /// Both can be wrong for VBR MP3s without a Xing/Info header, but they fail independently: the metadata
+ /// reader and the codec use different parsing paths. When they disagree, we pick the larger value.
+ /// A too-small length is far worse than a too-large one — it makes the displayed Duration lie,
+ /// breaks seeking near the end, can trigger premature end-of-stream handling, and produces impossible
+ /// states like Time > Duration
+ ///
+ public int Length
+ {
+ get
+ {
+ var decoderLength = _decoder.Length;
+
+ int metadataLength;
+ if (FormatInfo != null
+ && FormatInfo.Duration > TimeSpan.Zero
+ && FormatInfo.SampleRate > 0
+ && FormatInfo.ChannelCount > 0)
+ {
+ var metadataLengthSamples = FormatInfo.Duration.TotalSeconds
+ * FormatInfo.SampleRate
+ * FormatInfo.ChannelCount;
+
+ if (metadataLengthSamples is >= 0 and <= int.MaxValue)
+ metadataLength = (int)metadataLengthSamples;
+ else
+ metadataLength = 0;
+ }
+ else
+ {
+ metadataLength = 0;
+ }
+
+ 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
+ };
+ }
+ }
///
public bool CanSeek => _stream.CanSeek;
diff --git a/Src/SoundFlow.csproj b/Src/SoundFlow.csproj
index 5b636bde..1cb4febf 100644
--- a/Src/SoundFlow.csproj
+++ b/Src/SoundFlow.csproj
@@ -17,7 +17,7 @@
logo.png
README.md
audio, sound, mp3, wav, playback, record, voice, volume, fft, simd, crossplatform, miniaudio, c#, .net, echo, noise
- 1.4.1
+ 1.4.2
https://github.com/LSXPrime/SoundFlow/releases
true
LSXPrime
diff --git a/Src/Utils/DeviceSwitcher.cs b/Src/Utils/DeviceSwitcher.cs
index b301dd37..85a6d51e 100644
--- a/Src/Utils/DeviceSwitcher.cs
+++ b/Src/Utils/DeviceSwitcher.cs
@@ -11,6 +11,8 @@ internal static class DeviceSwitcher
///
/// Preserves the state of a playback device by extracting its sound components.
///
+ /// The playback device to preserve.
+ /// A read-only collection of sound components.
public static IReadOnlyCollection PreservePlaybackState(AudioPlaybackDevice device)
{
// Return a copy of the list of components from the master mixer.
@@ -20,6 +22,8 @@ public static IReadOnlyCollection PreservePlaybackState(AudioPla
///
/// Restores the state to a new playback device by re-adding the preserved components.
///
+ /// The new playback device.
+ /// The preserved sound components.
public static void RestorePlaybackState(AudioPlaybackDevice device, IReadOnlyCollection components)
{
foreach (var component in components)
@@ -29,17 +33,21 @@ public static void RestorePlaybackState(AudioPlaybackDevice device, IReadOnlyCol
}
///
- /// Preserves the state of a capture device by extracting its event subscribers.
+ /// Preserves the state of any audio device by extracting its event subscribers.
///
- public static Delegate[] PreserveCaptureState(AudioCaptureDevice device)
+ /// The device to preserve.
+ /// An array of delegates subscribed to the device's processing event.
+ public static Delegate[] PreserveEventSubscribers(AudioDevice device)
{
return device.GetEventSubscribers();
}
///
- /// Restores the state to a new capture device by re-adding the preserved event subscribers.
+ /// Restores the state to a new audio device by re-adding the preserved event subscribers.
///
- public static void RestoreCaptureState(AudioCaptureDevice device, Delegate[] subscribers)
+ /// The new device.
+ /// The preserved event subscribers.
+ public static void RestoreEventSubscribers(AudioDevice device, Delegate[] subscribers)
{
foreach (var subscriber in subscribers)
{