Как воспроизвести один и тот же звук дважды параллельно в 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;
        }
    }
}

0 ответов

Другие вопросы по тегам