Как воспроизвести один и тот же звук дважды параллельно в CSCore?
Недавно я захотел поиграть с некоторыми аудио вещами и найти аудио библиотеку, которую я нашел CSCore. Поскольку я новичок в кодировании звука на этом уровне абстракции (довольно низко для меня), я испытываю некоторые проблемы с воспроизведением.
То, чего я хочу достичь, - это иметь возможность воспроизводить один и тот же звук в буфере памяти несколько раз в одно и то же время (параллельно). Как это должно быть сделано в CSCore?
Сначала я изо всех сил пытался играть что-нибудь параллельно (два разных звука). Что я наконец понял, читая сэмплы CSCore и их источник на GitHub, так это то, что для того, чтобы воспроизводить что-либо параллельно с использованием одного SoundOut, мне нужно создать SampleSource или WaveSource, который выполняет микширование звука. Таким образом, с помощью примера микшера, который предоставляется в GitHub, я реализовал свой собственный простой звуковой микшер (то есть ISampleSource, смешивающий несколько ISampleSource-ов). Большой! Он воспроизводит два звука одновременно из двух разных источников.
Но вот где я застрял. Загрузка отдельного звука с диска в память. Я хочу иметь возможность воспроизводить его несколько раз, возможно, с перекрытием (параллельное воспроизведение). Как будто у меня есть звук 60 секунд, который я начинаю играть, и через 5 секунд я хочу запустить его в другой раз, когда они перекрываются в том, что вы можете услышать.
Когда я просто добавляю один и тот же ISampleSource (мой звук, который я хочу воспроизвести) два раза в список микшеров, я получаю странный вывод (звук воспроизводится быстрее с глюками). Я предполагаю, что это происходит из-за чтения того же потока (и перемещения его позиции вперед) один раз по первой ссылке ISampleSource в списке микшера и один раз другим. Подводя итог, я не могу использовать тот же ISampleSource, чтобы воспроизводить его сразу параллельно, так как он с состоянием.
Итак, мой второй подход состоял в том, чтобы использовать Position для отслеживания того, когда была определенная запись в списке микшеров, когда ее запрашивали Read(). Я имею в виду, что когда я дважды добавляю один и тот же ISampleSource в список микшеров, он сохраняет дополнительные данные о позиции для каждой записи списка. Таким образом, имея один и тот же ISampleSource, который передается в микшер два раза, когда его просят прочитать, он просматривает список источников для микширования и сначала устанавливает свою позицию в том месте, где он закончил в последний раз для этой записи списка, а затем читает этот источник. Таким образом, даже если я использую один и тот же источник дважды, его позиция обрабатывается отдельно для каждой регистрации в списке микшеров. Отлично, я действительно получил то, что ожидал - один и тот же звук воспроизводится несколько раз в одно и то же время (параллельно), но он не кристально чистый - у меня немного потрескивает выходной сигнал, как будто у меня физический дефект кабеля. Проблема присутствует даже для одного микшируемого и воспроизводимого звука. Я заметил, что когда я закомментирую строку, которая устанавливает ISampleSource.Position, проблема исчезнет. Поэтому я полагаю, что поиск потока - это проблема. К сожалению, я не уверен, почему именно это проблема. В реализации SampleSource/WaveSource я видел, что его реализация Position выравнивает позицию обратного потока с выравниванием по размеру блока. Может быть, в этом причина, но я точно не знаю.
Моя следующая идея - реализовать SharedMemoryStream, который похож на MemoryStream, но иметь возможность иметь один и тот же фактический буфер памяти для нескольких экземпляров такого потока. Тогда он будет самостоятельно отслеживать свою позицию (чтобы не искать, с которой у меня проблемы), имея только одно представление загруженного звука в памяти. Я не уверен, будет ли он работать так, как ожидалось (скоро попробую), но некоторые из моих экспериментов показали, что он будет не очень эффективным с точки зрения памяти и процессора - при использовании WaveFileReader он создает внутри себя некоторые WaveFileChunks, которые потребляют довольно много память и время должны быть построены. Поэтому я все еще вижу некоторые другие проблемы при использовании "SharedMemoryStream", чтобы иметь отдельный поток с одним буфером, так как мне понадобится WaveFileReader для каждого потока, и такой WaveFileReader будет необходим для запроса Play (для постановки ISampleSource в микшере).
Я делаю здесь что-то явно не так, что сделал бы только новичок?
PS. Извините за долгое развитие моих подходов и экспериментов. Просто хотел уточнить, где я нахожусь с моим пониманием CSCore и обработки звука в целом. Я готов удалить ненужные части в моем вопросе / описании.
Обновление: добавлен минимальный пример кода, позволяющий воспроизвести проблему.
Вот минимальный пример моего кода, с которым у меня есть проблема.
// Program.cs
using CSCore;
using CSCore.Codecs;
using CSCore.SoundOut;
using CSCore.Streams;
namespace AudioProblem
{
internal static class Program
{
private static void Main(string[] args)
{
var soundOut = new WasapiOut();
var soundMixer = new SoundMixer();
var sound = LoadSound("Heroic Demise (New).mp3");
soundOut.Initialize(soundMixer.ToWaveSource());
soundOut.Play();
soundMixer.AddSound(sound);
// Use the same sample source to have the same sound in play after 5 seconds.
// So two sounds are playing at the same time but are phase shifted by 5 seconds.
//Thread.Sleep(TimeSpan.FromSeconds(5));
//soundMixer.AddSound(sound);
}
private static ISampleSource LoadSound(string filePath)
{
var waveFileReader = CodecFactory.Instance.GetCodec(filePath);
return new CachedSoundSource(waveFileReader).ToSampleSource();
}
}
}
// SoundMixer.cs
using System;
using System.Collections.Generic;
using CSCore;
namespace AudioProblem
{
internal class SoundMixer : ISampleSource
{
private readonly object _lock = new object();
private readonly List<SoundSource> _soundSources = new List<SoundSource>();
private float[] _internalBuffer;
public SoundMixer()
{
var sampleRate = 44100;
var bits = 32;
var channels = 2;
var audioEncoding = AudioEncoding.IeeeFloat;
WaveFormat = new WaveFormat(sampleRate, bits, channels, audioEncoding);
}
public int Read(float[] buffer, int offset, int count)
{
var numberOfSamplesStoredInBuffer = 0;
if (count > 0 && _soundSources.Count > 0)
lock (_lock)
{
Array.Clear(buffer, offset, count);
_internalBuffer = _internalBuffer.CheckBuffer(count);
for (var i = _soundSources.Count - 1; i >= 0; i--)
{
var soundSource = _soundSources[i];
// Here is the magic. Look at Read implementation.
soundSource.Read(_internalBuffer, count);
for (int j = offset, k = 0; k < soundSource.SamplesRead; j++, k++)
{
buffer[j] += _internalBuffer[k];
}
if (soundSource.SamplesRead > numberOfSamplesStoredInBuffer)
numberOfSamplesStoredInBuffer = soundSource.SamplesRead;
if (soundSource.SamplesRead == 0) _soundSources.Remove(soundSource);
}
}
return count;
}
public void Dispose()
{
throw new NotImplementedException();
}
public bool CanSeek => false;
public WaveFormat WaveFormat { get; }
public long Position
{
get => 0;
set => throw new NotSupportedException($"{nameof(SoundMixer)} does not support setting the {nameof(Position)}.");
}
public long Length => 0;
public void AddSound(ISampleSource sound)
{
lock (_lock)
{
_soundSources.Add(new SoundSource(sound));
}
}
private class SoundSource
{
private readonly ISampleSource _sound;
private long _position;
public SoundSource(ISampleSource sound)
{
_sound = sound;
}
public int SamplesRead { get; private set; }
public void Read(float[] buffer, int count)
{
// Set last remembered position (initially 0).
// If this line is commented out, sound in my headphones is clear. But with this line it is crackling.
// If this line is commented out, if two SoundSource use the same ISampleSource output is buggy,
// but if line is present those are playing correctly but with crackling.
_sound.Position = _position;
// Read count of new samples.
SamplesRead = _sound.Read(buffer, 0, count);
// Remember position to be able to continue from where this SoundSource has finished last time.
_position = _sound.Position;
}
}
}
}
Обновление 2: я нашел решение, которое работает для меня - подробности см. Ниже.
Похоже, что мое первоначальное представление о проблеме вполне правильно, но не подтверждено на 100%. Я ввел счетчик, который выполняется в реализации чтения SoundSource, чтобы подсчитать, сколько сэмплов было прочитано в общей сложности для воспроизведения всего звукового файла. И я получил разные значения для случая, когда я просто воспроизводил поток напрямую, и другой случай, когда я сохранял и восстанавливал позицию в каждом вызове чтения. Для последнего я посчитал больше сэмплов, чем содержал настоящий звуковой файл, поэтому я предполагаю, что из-за этих перенесенных сэмплов появилось некоторое потрескивание. Я предполагаю, что позиция на уровне ISampleSource страдает от этой проблемы, поскольку она выравнивает позицию по размеру внутреннего блока, поэтому это свойство кажется недостаточным для остановки и продолжения с таким уровнем точности.
Поэтому я попробовал эту идею с SharedMemoryStream, чтобы увидеть, будет ли работать управление сохранением и восстановлением позиции на более низком уровне. И это, кажется, очень хорошо. Кроме того, мой первоначальный страх перед тяжелым созданием WaveSource / SampleSource с использованием этого подхода, по-видимому, на самом деле не является проблемой - я провел несколько простых тестов, и у них были очень низкие затраты ресурсов процессора и памяти.
Ниже мой код, реализующий этот подход, если что-то не понятно или может быть сделано лучше, или с самого начала это должно быть сделано другим способом, пожалуйста, дайте мне знать.
// Program.cs
using System;
using System.IO;
using System.Threading;
using CSCore;
using CSCore.Codecs.WAV;
using CSCore.SoundOut;
namespace AudioProblem
{
internal static class Program
{
private static void Main(string[] args)
{
var soundOut = new WasapiOut();
var soundMixer = new SoundMixer();
var sound = LoadSound("Heroic Demise (New).wav");
soundOut.Initialize(soundMixer.ToWaveSource());
soundOut.Play();
// Play first from shallow copy of shared stream
soundMixer.AddSound(new WaveFileReader(sound.MakeShared()).ToSampleSource());
Thread.Sleep(TimeSpan.FromSeconds(5));
// Play second from another shallow copy of shared stream
soundMixer.AddSound(new WaveFileReader(sound.MakeShared()).ToSampleSource());
Thread.Sleep(TimeSpan.FromSeconds(5));
soundOut.Stop();
}
private static SharedMemoryStream LoadSound(string filePath)
{
return new SharedMemoryStream(File.ReadAllBytes(filePath));
}
}
}
// SoundMixer.cs
using System;
using System.Collections.Generic;
using CSCore;
namespace AudioProblem
{
internal class SoundMixer : ISampleSource
{
private readonly List<SoundSource> _soundSources = new List<SoundSource>();
private readonly object _soundSourcesLock = new object();
private bool _disposed;
private float[] _internalBuffer;
public SoundMixer()
{
var sampleRate = 44100;
var bits = 32;
var channels = 2;
var audioEncoding = AudioEncoding.IeeeFloat;
WaveFormat = new WaveFormat(sampleRate, bits, channels, audioEncoding);
}
public int Read(float[] buffer, int offset, int count)
{
var numberOfSamplesStoredInBuffer = 0;
Array.Clear(buffer, offset, count);
lock (_soundSourcesLock)
{
CheckIfDisposed();
if (count > 0 && _soundSources.Count > 0)
{
_internalBuffer = _internalBuffer.CheckBuffer(count);
for (var i = _soundSources.Count - 1; i >= 0; i--)
{
var soundSource = _soundSources[i];
soundSource.Read(_internalBuffer, count);
for (int j = offset, k = 0; k < soundSource.SamplesRead; j++, k++)
{
buffer[j] += _internalBuffer[k];
}
if (soundSource.SamplesRead > numberOfSamplesStoredInBuffer)
numberOfSamplesStoredInBuffer = soundSource.SamplesRead;
if (soundSource.SamplesRead == 0)
{
_soundSources.Remove(soundSource);
soundSource.Dispose();
}
}
// TODO Normalize!
}
}
return count;
}
public void Dispose()
{
lock (_soundSourcesLock)
{
_disposed = true;
foreach (var soundSource in _soundSources)
{
soundSource.Dispose();
}
_soundSources.Clear();
}
}
public bool CanSeek => !_disposed;
public WaveFormat WaveFormat { get; }
public long Position
{
get
{
CheckIfDisposed();
return 0;
}
set => throw new NotSupportedException($"{nameof(SoundMixer)} does not support seeking.");
}
public long Length
{
get
{
CheckIfDisposed();
return 0;
}
}
public void AddSound(ISampleSource sound)
{
lock (_soundSourcesLock)
{
CheckIfDisposed();
// TODO Check wave format compatibility?
_soundSources.Add(new SoundSource(sound));
}
}
private void CheckIfDisposed()
{
if (_disposed) throw new ObjectDisposedException(nameof(SoundMixer));
}
private class SoundSource : IDisposable
{
private readonly ISampleSource _sound;
public SoundSource(ISampleSource sound)
{
_sound = sound;
}
public int SamplesRead { get; private set; }
public void Dispose()
{
_sound.Dispose();
}
public void Read(float[] buffer, int count)
{
SamplesRead = _sound.Read(buffer, 0, count);
}
}
}
}
// SharedMemoryStream.cs
using System;
using System.IO;
namespace AudioProblem
{
internal sealed class SharedMemoryStream : Stream
{
private readonly object _lock;
private readonly RefCounter _refCounter;
private readonly MemoryStream _sourceMemoryStream;
private bool _disposed;
private long _position;
public SharedMemoryStream(byte[] buffer) : this(new object(), new RefCounter(), new MemoryStream(buffer))
{
}
private SharedMemoryStream(object @lock, RefCounter refCounter, MemoryStream sourceMemoryStream)
{
_lock = @lock;
lock (_lock)
{
_refCounter = refCounter;
_sourceMemoryStream = sourceMemoryStream;
_refCounter.Count++;
}
}
public override bool CanRead
{
get
{
lock (_lock)
{
return !_disposed;
}
}
}
public override bool CanSeek
{
get
{
lock (_lock)
{
return !_disposed;
}
}
}
public override bool CanWrite => false;
public override long Length
{
get
{
lock (_lock)
{
CheckIfDisposed();
return _sourceMemoryStream.Length;
}
}
}
public override long Position
{
get
{
lock (_lock)
{
CheckIfDisposed();
return _position;
}
}
set
{
lock (_lock)
{
CheckIfDisposed();
_position = value;
}
}
}
// Creates another shallow copy of stream that uses the same underlying MemoryStream
public SharedMemoryStream MakeShared()
{
lock (_lock)
{
CheckIfDisposed();
return new SharedMemoryStream(_lock, _refCounter, _sourceMemoryStream);
}
}
public override void Flush()
{
}
public override long Seek(long offset, SeekOrigin origin)
{
lock (_lock)
{
CheckIfDisposed();
_sourceMemoryStream.Position = Position;
var seek = _sourceMemoryStream.Seek(offset, origin);
Position = _sourceMemoryStream.Position;
return seek;
}
}
public override void SetLength(long value)
{
throw new NotSupportedException($"{nameof(SharedMemoryStream)} is read only stream.");
}
// Uses position that is unique for each copy of shared stream
// to read underlying MemoryStream that is common for all shared copies
public override int Read(byte[] buffer, int offset, int count)
{
lock (_lock)
{
CheckIfDisposed();
_sourceMemoryStream.Position = Position;
var read = _sourceMemoryStream.Read(buffer, offset, count);
Position = _sourceMemoryStream.Position;
return read;
}
}
public override void Write(byte[] buffer, int offset, int count)
{
throw new NotSupportedException($"{nameof(SharedMemoryStream)} is read only stream.");
}
// Reference counting to dispose underlying MemoryStream when all shared copies are disposed
protected override void Dispose(bool disposing)
{
lock (_lock)
{
if (disposing)
{
_disposed = true;
_refCounter.Count--;
if (_refCounter.Count == 0) _sourceMemoryStream?.Dispose();
}
base.Dispose(disposing);
}
}
private void CheckIfDisposed()
{
if (_disposed) throw new ObjectDisposedException(nameof(SharedMemoryStream));
}
private class RefCounter
{
public int Count;
}
}
}