Skip to content

Commit 753e633

Browse files
committed
fix: Stabilize audio playback, streaming, and native library loading
- Resolves `ArgumentOutOfRangeException` during frequent seeks by refining time-stretcher buffer management. - Optimizes 1.0x playback by directly reading data, bypassing time-stretching. - Corrects mono panning volume calculation using constant power. - Enhances network stream reliability by preventing premature stops and adding read timeouts. - Improves native library loading robustness across all platforms. - Adds audio sample clamping to prevent clipping.
1 parent 530a749 commit 753e633

File tree

8 files changed

+205
-109
lines changed

8 files changed

+205
-109
lines changed

Samples/SoundFlow.Samples.SimplePlayer/Program.cs

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -141,7 +141,7 @@ private static void PlaybackControls(ISoundPlayer player)
141141
timer.Start();
142142

143143
Console.WriteLine(
144-
"\nPress 's' to seek, 'p' to pause/play, any other key to exit playback. '+' to increase speed, '-' to decrease speed, 'R' to reset speed to 1.0");
144+
"\nPress 'S' to seek, 'P' to pause/play, any other key to exit playback. 'V' to change volume, '+' to increase speed, '-' to decrease speed, 'R' to reset speed to 1.0");
145145

146146

147147
while (player.State is PlaybackState.Playing or PlaybackState.Paused)
@@ -183,6 +183,13 @@ private static void PlaybackControls(ISoundPlayer player)
183183
player.PlaybackSpeed = 1.0f;
184184
Console.WriteLine($"Speed reset to: {player.PlaybackSpeed:F2}");
185185
break;
186+
case ConsoleKey.V:
187+
Console.WriteLine("Enter volume (e.g., 1.0):");
188+
if (float.TryParse(Console.ReadLine(), out var volume))
189+
player.Volume = volume;
190+
else
191+
Console.WriteLine("Invalid volume.");
192+
break;
186193
default:
187194
player.Stop();
188195
break;

Src/Abstracts/SoundComponent.cs

Lines changed: 6 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -155,12 +155,12 @@ public IReadOnlyList<AudioAnalyzer> Analyzers
155155

156156
private void UpdateVolumePanFactors()
157157
{
158-
_previousVolumePanFactors = _volumePanFactors;
159158
var panValue = Math.Clamp(_pan, 0f, 1f);
160159
_volumePanFactors = new Vector2(
161160
_volume * MathF.Sqrt(1f - panValue),
162161
_volume * MathF.Sqrt(panValue)
163162
);
163+
_previousVolumePanFactors = _volumePanFactors;
164164
}
165165

166166
/// <summary>
@@ -313,11 +313,8 @@ internal void Process(Span<float> outputBuffer)
313313
currentModifiers = _modifiers.Count == 0 ? [] : _modifiers.ToArray();
314314
currentAnalyzers = _analyzers.Count == 0 ? [] : _analyzers.ToArray();
315315

316-
currentVolumePan = Vector2.Lerp(
317-
_previousVolumePanFactors,
318-
_volumePanFactors,
319-
Math.Clamp(128f / workingBuffer.Length, 0, 1)
320-
);
316+
currentVolumePan = _volumePanFactors;
317+
_previousVolumePanFactors = _volumePanFactors;
321318
}
322319

323320
foreach (var modifier in currentModifiers)
@@ -374,7 +371,9 @@ private void ApplyVolumeAndPanning(Span<float> buffer, Vector2 volumePan)
374371
switch (AudioEngine.Channels)
375372
{
376373
case 1:
377-
ApplyMonoVolume(buffer, volumePan.X + volumePan.Y);
374+
// Constant power calculation for mono
375+
var monoGain = MathF.Sqrt(volumePan.X * volumePan.X + volumePan.Y * volumePan.Y);
376+
ApplyMonoVolume(buffer, monoGain);
378377
break;
379378
case 2:
380379
ApplyStereoVolume(buffer, volumePan);

Src/Abstracts/SoundPlayerBase.cs

Lines changed: 102 additions & 55 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
using SoundFlow.Enums;
22
using SoundFlow.Interfaces;
3+
using SoundFlow.Providers;
34

45
namespace SoundFlow.Abstracts;
56

@@ -87,8 +88,7 @@ protected SoundPlayerBase(ISoundDataProvider dataProvider)
8788
var resampleBufferFrames = Math.Max(256, initialSampleRate / 10);
8889
_resampleBuffer = new float[resampleBufferFrames * initialChannels];
8990
_timeStretcher = new WsolaTimeStretcher(initialChannels, _playbackSpeed);
90-
_timeStretcherInputBuffer =
91-
new float[Math.Max(_timeStretcher.MinInputSamplesToProcess * 2, 8192 * initialChannels)];
91+
_timeStretcherInputBuffer = new float[Math.Max(_timeStretcher.MinInputSamplesToProcess * 2, 8192 * initialChannels)];
9292
}
9393

9494
/// <inheritdoc />
@@ -101,6 +101,20 @@ protected override void GenerateAudio(Span<float> output)
101101
return;
102102
}
103103

104+
// Directly read from provider when playback speed is 1.0
105+
if (Math.Abs(_playbackSpeed - 1.0f) < 0.001f)
106+
{
107+
int samplesRead = _dataProvider.ReadBytes(output);
108+
_rawSamplePosition += samplesRead;
109+
110+
if (samplesRead < output.Length)
111+
{
112+
HandleEndOfStream(output[samplesRead..]);
113+
}
114+
return;
115+
}
116+
117+
104118
var channels = AudioEngine.Channels;
105119
// Ensure time stretcher has correct channel count.
106120
if (_timeStretcher.GetTargetSpeed() == 0f && _playbackSpeed != 0f && channels > 0)
@@ -212,6 +226,14 @@ private int FillResampleBuffer(int minSamplesRequiredInOutputBuffer)
212226
Array.Resize(ref _resampleBuffer,
213227
Math.Max(minSamplesRequiredInOutputBuffer, _resampleBuffer.Length * 2));
214228
}
229+
230+
// When playback speed is close to 1.0, use simpler interpolation
231+
if (Math.Abs(_playbackSpeed - 1.0f) < 0.1f)
232+
{
233+
var directRead = _dataProvider.ReadBytes(_resampleBuffer.AsSpan(_resampleBufferValidSamples));
234+
_resampleBufferValidSamples += directRead;
235+
return directRead;
236+
}
215237

216238
var totalSourceSamplesRepresented = 0;
217239

@@ -223,30 +245,41 @@ private int FillResampleBuffer(int minSamplesRequiredInOutputBuffer)
223245

224246
var availableInStretcherInput =
225247
_timeStretcherInputBufferValidSamples - _timeStretcherInputBufferReadOffset;
226-
var providerHasMoreData = _dataProvider.Position < _dataProvider.Length;
248+
var providerHasMoreData = _dataProvider.Position < _dataProvider.Length || _dataProvider.Length == -1; // -1 = unknown length or infinite stream
227249

228250
// If time stretcher input buffer needs more data and provider has it.
229251
if (availableInStretcherInput < _timeStretcher.MinInputSamplesToProcess && providerHasMoreData)
230252
{
231-
// Shift existing valid data to the beginning of the input buffer.
232-
if (_timeStretcherInputBufferReadOffset > 0 && availableInStretcherInput > 0)
253+
// Compact the buffer by moving the remaining valid samples to the start if we have a read offset.
254+
if (_timeStretcherInputBufferReadOffset > 0)
233255
{
234-
Buffer.BlockCopy(_timeStretcherInputBuffer, _timeStretcherInputBufferReadOffset * sizeof(float),
235-
_timeStretcherInputBuffer, 0, availableInStretcherInput * sizeof(float));
256+
// Calculate remaining samples. It should not be negative, but we defend against it.
257+
var remaining = _timeStretcherInputBufferValidSamples - _timeStretcherInputBufferReadOffset;
258+
if (remaining > 0)
259+
{
260+
// Shift the remaining valid data to the beginning of the input buffer.
261+
Buffer.BlockCopy(_timeStretcherInputBuffer, _timeStretcherInputBufferReadOffset * sizeof(float),
262+
_timeStretcherInputBuffer, 0, remaining * sizeof(float));
263+
_timeStretcherInputBufferValidSamples = remaining;
264+
}
265+
else
266+
{
267+
// If no samples remain, the buffer is effectively empty.
268+
_timeStretcherInputBufferValidSamples = 0;
269+
}
270+
// After compacting, the next read position is the start of the buffer.
271+
_timeStretcherInputBufferReadOffset = 0;
236272
}
237273

238-
_timeStretcherInputBufferValidSamples = availableInStretcherInput;
239-
_timeStretcherInputBufferReadOffset = 0;
240-
241274
// Read more data from the data provider into the time stretcher input buffer.
242275
var spaceToReadIntoInput = _timeStretcherInputBuffer.Length - _timeStretcherInputBufferValidSamples;
243276
if (spaceToReadIntoInput > 0)
244277
{
245-
var readFromProvider = _dataProvider.ReadBytes(
246-
_timeStretcherInputBuffer.AsSpan(_timeStretcherInputBufferValidSamples,
247-
spaceToReadIntoInput));
278+
var readFromProvider = _dataProvider.ReadBytes(_timeStretcherInputBuffer.AsSpan(_timeStretcherInputBufferValidSamples, spaceToReadIntoInput));
248279
_timeStretcherInputBufferValidSamples += readFromProvider;
249-
availableInStretcherInput = _timeStretcherInputBufferValidSamples;
280+
281+
// After reading, the available samples have increased. We must recalculate it for the current loop iteration.
282+
availableInStretcherInput = _timeStretcherInputBufferValidSamples - _timeStretcherInputBufferReadOffset;
250283
providerHasMoreData = _dataProvider.Position < _dataProvider.Length;
251284
}
252285
}
@@ -317,8 +350,51 @@ private int FillResampleBuffer(int minSamplesRequiredInOutputBuffer)
317350
/// </summary>
318351
protected virtual void HandleEndOfStream(Span<float> remainingOutputBuffer)
319352
{
320-
if (IsLooping)
353+
// For live streams with unknown length, don't treat buffer underflow as end-of-stream
354+
if (!IsLooping && _dataProvider.Length > 0)
355+
{
356+
// Original end-of-stream handling
357+
if (!remainingOutputBuffer.IsEmpty)
358+
{
359+
var spaceToFill = remainingOutputBuffer.Length;
360+
var currentlyValidInResample = _resampleBufferValidSamples;
361+
362+
if (currentlyValidInResample < spaceToFill)
363+
{
364+
var sourceSamplesFromFinalFill = FillResampleBuffer(Math.Max(currentlyValidInResample, spaceToFill));
365+
_rawSamplePosition += sourceSamplesFromFinalFill;
366+
_rawSamplePosition = Math.Min(_rawSamplePosition, _dataProvider.Length);
367+
}
368+
369+
var toCopy = Math.Min(spaceToFill, _resampleBufferValidSamples);
370+
if (toCopy > 0)
371+
{
372+
SafeCopyTo(_resampleBuffer.AsSpan(0, toCopy), remainingOutputBuffer.Slice(0, toCopy));
373+
var remainingInResampleAfterCopy = _resampleBufferValidSamples - toCopy;
374+
if (remainingInResampleAfterCopy > 0)
375+
{
376+
Buffer.BlockCopy(_resampleBuffer, toCopy * sizeof(float), _resampleBuffer, 0,
377+
remainingInResampleAfterCopy * sizeof(float));
378+
}
379+
380+
_resampleBufferValidSamples = remainingInResampleAfterCopy;
381+
if (toCopy < spaceToFill)
382+
{
383+
remainingOutputBuffer.Slice(toCopy).Clear();
384+
}
385+
}
386+
else
387+
{
388+
remainingOutputBuffer.Clear();
389+
}
390+
}
391+
392+
State = PlaybackState.Stopped;
393+
OnPlaybackEnded();
394+
}
395+
else if (IsLooping)
321396
{
397+
// Original looping handling
322398
var targetLoopStart = Math.Max(0, _loopStartSamples);
323399
var actualLoopEnd = (_loopEndSamples == -1)
324400
? _dataProvider.Length
@@ -334,48 +410,11 @@ protected virtual void HandleEndOfStream(Span<float> remainingOutputBuffer)
334410
return;
335411
}
336412
}
337-
338-
// If not looping or loop points are invalid, fill remaining buffer with what's left and stop.
339-
if (!remainingOutputBuffer.IsEmpty)
413+
// For live streams (Length <= 0), just clear the buffer and continue
414+
else
340415
{
341-
var spaceToFill = remainingOutputBuffer.Length;
342-
var currentlyValidInResample = _resampleBufferValidSamples;
343-
344-
// Attempt one last fill of the resample buffer.
345-
if (currentlyValidInResample < spaceToFill)
346-
{
347-
var sourceSamplesFromFinalFill = FillResampleBuffer(Math.Max(currentlyValidInResample, spaceToFill));
348-
_rawSamplePosition += sourceSamplesFromFinalFill;
349-
_rawSamplePosition = Math.Min(_rawSamplePosition, _dataProvider.Length);
350-
}
351-
352-
// Copy remaining valid samples to output and clear the rest.
353-
var toCopy = Math.Min(spaceToFill, _resampleBufferValidSamples);
354-
if (toCopy > 0)
355-
{
356-
_resampleBuffer.AsSpan(0, toCopy).CopyTo(remainingOutputBuffer.Slice(0, toCopy));
357-
var remainingInResampleAfterCopy = _resampleBufferValidSamples - toCopy;
358-
if (remainingInResampleAfterCopy > 0)
359-
{
360-
// Shift remaining samples in resample buffer.
361-
Buffer.BlockCopy(_resampleBuffer, toCopy * sizeof(float), _resampleBuffer, 0,
362-
remainingInResampleAfterCopy * sizeof(float));
363-
}
364-
365-
_resampleBufferValidSamples = remainingInResampleAfterCopy;
366-
if (toCopy < spaceToFill)
367-
{
368-
remainingOutputBuffer.Slice(toCopy).Clear(); // Clear any unfilled part.
369-
}
370-
}
371-
else
372-
{
373-
remainingOutputBuffer.Clear(); // No valid samples, clear entire buffer.
374-
}
416+
remainingOutputBuffer.Clear();
375417
}
376-
377-
State = PlaybackState.Stopped;
378-
OnPlaybackEnded();
379418
}
380419

381420
/// <summary>
@@ -483,6 +522,14 @@ public bool Seek(int sampleOffset)
483522
}
484523

485524
#endregion
525+
526+
private static void SafeCopyTo(Span<float> source, Span<float> destination)
527+
{
528+
for (var i = 0; i < Math.Min(source.Length, destination.Length); i++)
529+
{
530+
destination[i] = Math.Clamp(source[i], -1f, 1f);
531+
}
532+
}
486533

487534
#region Loop Point Configuration Methods
488535

0 commit comments

Comments
 (0)