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) {