File: System\Media\SoundPlayer.cs
Web Access
Project: src\src\runtime\src\libraries\System.Windows.Extensions\src\System.Windows.Extensions.csproj (System.Windows.Extensions)
// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.

using System.ComponentModel;
using System.Diagnostics;
using System.Diagnostics.CodeAnalysis;
using System.IO;
using System.Net;
using System.Runtime.InteropServices;
using System.Runtime.Serialization;
using System.Threading;
using System.Threading.Tasks;

namespace System.Media
{
    [ToolboxItem(false)]
    public class SoundPlayer : Component, ISerializable
    {
        private const int BlockSize = 1024;
        private const int DefaultLoadTimeout = 10000; // 10 secs

        private Uri? _uri;
        private string _soundLocation = string.Empty;
        private int _loadTimeout = DefaultLoadTimeout;

        // used to lock all synchronous calls to the SoundPlayer object
        private readonly ManualResetEvent _semaphore = new ManualResetEvent(true);

        // the worker copyTask
        // we start the worker copyTask ONLY from entry points in the SoundPlayer API
        // we also set the tread to null only from the entry points in the SoundPlayer API
        private Task? _copyTask;
        private CancellationTokenSource? _copyTaskCancellation;

        // local buffer information
        private int _currentPos;
        private Stream? _stream;
        private Exception? _lastLoadException;
        private bool _doesLoadAppearSynchronous;
        private byte[]? _streamData;
        private AsyncOperation? _asyncOperation;
        private readonly SendOrPostCallback _loadAsyncOperationCompleted;

        // event
        private static readonly object s_eventLoadCompleted = new object();
        private static readonly object s_eventSoundLocationChanged = new object();
        private static readonly object s_eventStreamChanged = new object();

        public SoundPlayer()
        {
            _loadAsyncOperationCompleted = new SendOrPostCallback(LoadAsyncOperationCompleted);
        }

        public SoundPlayer(string soundLocation) : this()
        {
            SetupSoundLocation(soundLocation ?? string.Empty);
        }

        public SoundPlayer(Stream? stream) : this()
        {
            _stream = stream;
        }

#if NET
        [Obsolete(Obsoletions.LegacyFormatterImplMessage, DiagnosticId = Obsoletions.LegacyFormatterImplDiagId, UrlFormat = Obsoletions.SharedUrlFormat)]
        [EditorBrowsable(EditorBrowsableState.Never)]
#endif
        protected SoundPlayer(SerializationInfo serializationInfo, StreamingContext context)
        {
            throw new PlatformNotSupportedException();
        }

        public int LoadTimeout
        {
            get => _loadTimeout;
            set
            {
                if (value < 0)
                {
                    throw new ArgumentOutOfRangeException(nameof(LoadTimeout), value, SR.SoundAPILoadTimeout);
                }

                _loadTimeout = value;
            }
        }

        public string SoundLocation
        {
            get => _soundLocation;
            set
            {
                value ??= string.Empty;

                if (_soundLocation.Equals(value))
                {
                    return;
                }

                SetupSoundLocation(value);
                OnSoundLocationChanged(EventArgs.Empty);
            }
        }

        public Stream? Stream
        {
            get
            {
                // if the path is set, we should return null
                // Path and Stream are mutually exclusive
                if (_uri != null)
                {
                    return null;
                }

                return _stream;
            }
            set
            {
                if (_stream == value)
                {
                    return;
                }

                SetupStream(value);
                OnStreamChanged(EventArgs.Empty);
            }
        }

        public bool IsLoadCompleted { get; private set; }

        public object? Tag { get; set; }

        public void LoadAsync()
        {
            // if we have a file there is nothing to load - we just pass the file to the PlaySound function
            // if we have a stream, then we start loading the stream async
            if (_uri != null && _uri.IsFile)
            {
                Debug.Assert(_stream == null, "we can't have a stream and a path at the same time");
                IsLoadCompleted = true;

                FileInfo fi = new FileInfo(_uri.LocalPath);
                if (!fi.Exists)
                {
                    throw new FileNotFoundException(SR.SoundAPIFileDoesNotExist, _soundLocation);
                }

                OnLoadCompleted(new AsyncCompletedEventArgs(null, false, null));
                return;
            }

            // if we are actively loading, keep it running
            if (_copyTask != null && !_copyTask.IsCompleted)
            {
                return;
            }
            IsLoadCompleted = false;
            _streamData = null;
            _currentPos = 0;

            _asyncOperation = AsyncOperationManager.CreateOperation(null);

            LoadStream(false);
        }

        private void LoadAsyncOperationCompleted(object? arg)
        {
            OnLoadCompleted((AsyncCompletedEventArgs)arg!);
        }

        // called for loading a stream synchronously
        // called either when the user is setting the path/stream and we are loading
        // or when loading took more time than the time out
        private void CleanupStreamData()
        {
            _currentPos = 0;
            _streamData = null;
            IsLoadCompleted = false;
            _lastLoadException = null;
            _doesLoadAppearSynchronous = false;
            _copyTask = null;
            _semaphore.Set();
        }

        public void Load()
        {
            // if we have a file there is nothing to load - we just pass the file to the PlaySound function
            // if we have a stream, then we start loading the stream sync
            if (_uri != null && _uri.IsFile)
            {
                Debug.Assert(_stream == null, "we can't have a stream and a path at the same time");
                FileInfo fi = new FileInfo(_uri.LocalPath);
                if (!fi.Exists)
                {
                    throw new FileNotFoundException(SR.SoundAPIFileDoesNotExist, _soundLocation);
                }
                IsLoadCompleted = true;
                OnLoadCompleted(new AsyncCompletedEventArgs(null, false, null));
                return;
            }

            LoadSync();
        }

        private void LoadAndPlay(int flags)
        {
            // When the user does not specify a sound location nor a stream, play Beep
            if (string.IsNullOrEmpty(_soundLocation) && _stream == null)
            {
                SystemSounds.Beep.Play();
                return;
            }

            if (_uri != null && _uri.IsFile)
            {
                // Someone can call SoundPlayer::set_Location between the time
                // LoadAndPlay validates the sound file and the time it calls PlaySound.
                // The SoundPlayer will end up playing an un-validated sound file.
                // The solution is to store the uri.LocalPath on a local variable
                string localPath = _uri.LocalPath;

                // Play the path - don't use uri.AbsolutePath because that gives problems
                // when there are whitespaces in file names
                IsLoadCompleted = true;

                ValidateSoundFile(localPath);
                Interop.WinMM.PlaySound(localPath, IntPtr.Zero, Interop.WinMM.SND_NODEFAULT | flags);
            }
            else
            {
                LoadSync();
                Debug.Assert(_streamData != null);
                ValidateSoundData(_streamData);
                Interop.WinMM.PlaySound(_streamData, IntPtr.Zero, Interop.WinMM.SND_MEMORY | Interop.WinMM.SND_NODEFAULT | flags);
            }
        }

        private void CancelLoad()
        {
            _copyTaskCancellation?.Cancel();
            _copyTaskCancellation = null;
        }

        private void LoadSync()
        {
            Debug.Assert((_uri == null || !_uri.IsFile), "we only load streams");

            // first make sure that any possible download ended
            if (!_semaphore.WaitOne(LoadTimeout, false))
            {
                CancelLoad();
                CleanupStreamData();
                throw new TimeoutException(SR.SoundAPILoadTimedOut);
            }

            // if we have data, then we are done
            if (_streamData != null)
            {
                return;
            }

            // setup the http stream
            if (_uri != null && !_uri.IsFile && _stream == null)
            {
#pragma warning disable SYSLIB0014 // WebRequest, HttpWebRequest, ServicePoint, and WebClient are obsolete. Use HttpClient instead.
                WebRequest webRequest = WebRequest.Create(_uri);
#pragma warning restore SYSLIB0014
                webRequest.Timeout = LoadTimeout;

                WebResponse webResponse;
                webResponse = webRequest.GetResponse();

                // now get the stream
                _stream = webResponse.GetResponseStream();
            }

            // DO NOT assert - NRE is expected for null stream
            // See SoundPlayerTests.Load_NullStream_ThrowsNullReferenceException
            if (_stream!.CanSeek)
            {
                // if we can get data synchronously, then get it
                LoadStream(true);
            }
            else
            {
                // the data can't be loaded synchronously
                // load it async, then wait for it to finish
                _doesLoadAppearSynchronous = true; // to avoid OnFailed call.
                LoadStream(false);

                if (!_semaphore.WaitOne(LoadTimeout, false))
                {
                    CancelLoad();
                    CleanupStreamData();
                    throw new TimeoutException(SR.SoundAPILoadTimedOut);
                }

                _doesLoadAppearSynchronous = false;

                if (_lastLoadException != null)
                {
                    throw _lastLoadException;
                }
            }

            // we don't need the worker copyThread anymore
            _copyTask = null;
        }

        private void LoadStream(bool loadSync)
        {
            if (loadSync && _stream!.CanSeek)
            {
                int streamLen = (int)_stream.Length;
                _currentPos = 0;
                _streamData = new byte[streamLen];
                _stream.ReadExactly(_streamData);
                IsLoadCompleted = true;
                OnLoadCompleted(new AsyncCompletedEventArgs(null, false, null));
            }
            else
            {
                // lock any synchronous calls on the Sound object
                _semaphore.Reset();
                // start loading
                var cts = new CancellationTokenSource();
                _copyTaskCancellation = cts;
                _copyTask = CopyStreamAsync(cts.Token);
            }
        }

        public void Play()
        {
            LoadAndPlay(Interop.WinMM.SND_ASYNC);
        }

        public void PlaySync()
        {
            LoadAndPlay(Interop.WinMM.SND_SYNC);
        }

        public void PlayLooping()
        {
            LoadAndPlay(Interop.WinMM.SND_LOOP | Interop.WinMM.SND_ASYNC);
        }

        private static Uri? ResolveUri(string partialUri)
        {
            Uri? result = null;
            try
            {
                result = new Uri(partialUri);
            }
            catch (UriFormatException)
            {
                // eat URI parse exceptions
            }

            if (result == null)
            {
                // try relative to appbase
                try
                {
                    result = new Uri(Path.GetFullPath(partialUri));
                }
                catch (UriFormatException)
                {
                    // eat URI parse exceptions
                }
            }

            return result;
        }

        private void SetupSoundLocation(string soundLocation)
        {
            // if we are loading a file, stop it right now
            if (_copyTask != null)
            {
                CancelLoad();
                CleanupStreamData();
            }

            _uri = ResolveUri(soundLocation);
            _soundLocation = soundLocation;
            _stream = null;
            if (_uri == null)
            {
                if (!string.IsNullOrEmpty(soundLocation))
                {
                    throw new UriFormatException(SR.SoundAPIBadSoundLocation);
                }
            }
            else
            {
                if (!_uri.IsFile)
                {
                    // we are referencing a web resource ...
                    // we treat it as a stream...
                    _streamData = null;
                    _currentPos = 0;
                    IsLoadCompleted = false;
                }
            }
        }

        private void SetupStream(Stream? stream)
        {
            if (_copyTask != null)
            {
                CancelLoad();
                CleanupStreamData();
            }

            _stream = stream;
            _soundLocation = string.Empty;
            _streamData = null;
            _currentPos = 0;
            IsLoadCompleted = false;
            if (stream != null)
            {
                _uri = null;
            }
        }

        public void Stop()
        {
            Interop.WinMM.PlaySound((byte[]?)null, IntPtr.Zero, Interop.WinMM.SND_PURGE);
        }

        public event AsyncCompletedEventHandler? LoadCompleted
        {
            add
            {
                Events.AddHandler(s_eventLoadCompleted, value);
            }
            remove
            {
                Events.RemoveHandler(s_eventLoadCompleted, value);
            }
        }

        public event EventHandler? SoundLocationChanged
        {
            add
            {
                Events.AddHandler(s_eventSoundLocationChanged, value);
            }
            remove
            {
                Events.RemoveHandler(s_eventSoundLocationChanged, value);
            }
        }

        public event EventHandler? StreamChanged
        {
            add
            {
                Events.AddHandler(s_eventStreamChanged, value);
            }
            remove
            {
                Events.RemoveHandler(s_eventStreamChanged, value);
            }
        }

        protected virtual void OnLoadCompleted(AsyncCompletedEventArgs e)
        {
            ((AsyncCompletedEventHandler?)Events[s_eventLoadCompleted])?.Invoke(this, e);
        }

        protected virtual void OnSoundLocationChanged(EventArgs e)
        {
            ((EventHandler?)Events[s_eventSoundLocationChanged])?.Invoke(this, e);
        }

        protected virtual void OnStreamChanged(EventArgs e)
        {
            ((EventHandler?)Events[s_eventStreamChanged])?.Invoke(this, e);
        }

        private async Task CopyStreamAsync(CancellationToken cancellationToken)
        {
            Exception? exception = null;
            try
            {
                // setup the http stream
                if (_uri != null && !_uri.IsFile && _stream == null)
                {
#pragma warning disable SYSLIB0014 // WebRequest, HttpWebRequest, ServicePoint, and WebClient are obsolete. Use HttpClient instead.
                    WebRequest webRequest = WebRequest.Create(_uri);
#pragma warning restore SYSLIB0014
                    using (cancellationToken.Register(r => ((WebRequest)r!).Abort(), webRequest))
                    {
                        WebResponse webResponse = await webRequest.GetResponseAsync().ConfigureAwait(false);
                        _stream = webResponse.GetResponseStream();
                    }
                }

                _streamData = new byte[BlockSize];

                Debug.Assert(_stream != null);
                int readBytes = await _stream.ReadAsync(_streamData.AsMemory(_currentPos, BlockSize), cancellationToken).ConfigureAwait(false);
                int totalBytes = readBytes;

                while (readBytes > 0)
                {
                    _currentPos += readBytes;
                    if (_streamData.Length < _currentPos + BlockSize)
                    {
                        byte[] newData = new byte[_streamData.Length * 2];
                        Array.Copy(_streamData, newData, _streamData.Length);
                        _streamData = newData;
                    }
                    readBytes = await _stream.ReadAsync(_streamData.AsMemory(_currentPos, BlockSize), cancellationToken).ConfigureAwait(false);
                    totalBytes += readBytes;
                }
            }
            catch (Exception ex)
            {
                exception = ex;
            }

            _lastLoadException = exception;
            IsLoadCompleted = true;
            _semaphore.Set();

            if (!_doesLoadAppearSynchronous)
            {
                // Post notification back to the UI thread.
                AsyncCompletedEventArgs ea = exception is OperationCanceledException ?
                    new AsyncCompletedEventArgs(null, cancelled: true, null) :
                    new AsyncCompletedEventArgs(exception, cancelled: false, null);
                Debug.Assert(_asyncOperation != null);
                _asyncOperation.PostOperationCompleted(_loadAsyncOperationCompleted, ea);
            }
        }

        private unsafe void ValidateSoundFile(string fileName)
        {
            IntPtr hMIO = Interop.WinMM.mmioOpen(fileName, IntPtr.Zero, Interop.WinMM.MMIO_READ | Interop.WinMM.MMIO_ALLOCBUF);
            if (hMIO == IntPtr.Zero)
            {
                throw new FileNotFoundException(SR.SoundAPIFileDoesNotExist, _soundLocation);
            }

            try
            {
                Interop.WinMM.WAVEFORMATEX? waveFormat = null;
                var ckRIFF = new Interop.WinMM.MMCKINFO()
                {
                    fccType = mmioFOURCC('W', 'A', 'V', 'E')
                };
                var ck = default(Interop.WinMM.MMCKINFO);
                if (Interop.WinMM.mmioDescend(hMIO, &ckRIFF, null, Interop.WinMM.MMIO_FINDRIFF) != 0)
                {
                    throw new InvalidOperationException(SR.Format(SR.SoundAPIInvalidWaveFile, _soundLocation));
                }

                while (Interop.WinMM.mmioDescend(hMIO, &ck, &ckRIFF, 0) == 0)
                {
                    if (ck.dwDataOffset + ck.cksize > ckRIFF.dwDataOffset + ckRIFF.cksize)
                    {
                        throw new InvalidOperationException(SR.SoundAPIInvalidWaveHeader);
                    }

                    if (ck.ckID == mmioFOURCC('f', 'm', 't', ' '))
                    {
                        if (waveFormat == null)
                        {
                            int dw = ck.cksize;
                            if (dw < Marshal.SizeOf<Interop.WinMM.WAVEFORMATEX>())
                            {
                                dw = Marshal.SizeOf<Interop.WinMM.WAVEFORMATEX>();
                            }

                            waveFormat = new Interop.WinMM.WAVEFORMATEX();
                            var data = new byte[dw];
                            if (Interop.WinMM.mmioRead(hMIO, data, dw) != dw)
                            {
                                throw new InvalidOperationException(SR.Format(SR.SoundAPIReadError, _soundLocation));
                            }

                            fixed (byte* pdata = data)
                            {
                                Marshal.PtrToStructure((IntPtr)pdata, waveFormat);
                            }
                        }
                        else
                        {
                            // multiple formats?
                        }
                    }
                    Interop.WinMM.mmioAscend(hMIO, &ck, 0);
                }

                if (waveFormat == null)
                {
                    throw new InvalidOperationException(SR.SoundAPIInvalidWaveHeader);
                }
                if (waveFormat.wFormatTag != Interop.WinMM.WAVE_FORMAT_PCM &&
                    waveFormat.wFormatTag != Interop.WinMM.WAVE_FORMAT_ADPCM &&
                    waveFormat.wFormatTag != Interop.WinMM.WAVE_FORMAT_IEEE_FLOAT)
                {
                    throw new InvalidOperationException(SR.SoundAPIFormatNotSupported);
                }

            }
            finally
            {
                if (hMIO != IntPtr.Zero)
                {
                    Interop.WinMM.mmioClose(hMIO, 0);
                }
            }
        }

        private static void ValidateSoundData(byte[] data)
        {
            // validate the RIFF header
            if (data.Length < 12 ||
                !data.AsSpan().StartsWith("RIFF"u8) ||
                !data.AsSpan(8).StartsWith("WAVE"u8))
            {
                throw new InvalidOperationException(SR.SoundAPIInvalidWaveHeader);
            }

            // we only care about "fmt " chunk
            int position = 12;
            int len = data.Length;
            short wFormatTag = -1;
            bool fmtChunkFound = false;
            while (!fmtChunkFound && position < len - 8)
            {
                if (data.AsSpan(position).StartsWith("fmt "u8))
                {
                    // fmt chunk
                    fmtChunkFound = true;
                    int chunkSize = BytesToInt(data[position + 7], data[position + 6], data[position + 5], data[position + 4]);

                    // get the cbSize from the WAVEFORMATEX
                    int sizeOfWAVEFORMAT = 16;
                    if (chunkSize != sizeOfWAVEFORMAT)
                    {
                        // we are dealing w/ WAVEFORMATEX
                        // do extra validation
                        int sizeOfWAVEFORMATEX = 18;

                        // make sure the buffer is big enough to store a short
                        if (len < position + 8 + sizeOfWAVEFORMATEX - 1)
                        {
                            throw new InvalidOperationException(SR.SoundAPIInvalidWaveHeader);
                        }

                        short cbSize = BytesToInt16(data[position + 8 + sizeOfWAVEFORMATEX - 1],
                                                    data[position + 8 + sizeOfWAVEFORMATEX - 2]);
                        if (cbSize + sizeOfWAVEFORMATEX != chunkSize)
                        {
                            throw new InvalidOperationException(SR.SoundAPIInvalidWaveHeader);
                        }
                    }

                    // make sure the buffer passed in is big enough to store a short
                    if (len < position + 9)
                    {
                        throw new InvalidOperationException(SR.SoundAPIInvalidWaveHeader);
                    }
                    wFormatTag = BytesToInt16(data[position + 9], data[position + 8]);

                    position += chunkSize + 8;
                }
                else
                {
                    position += 8 + BytesToInt(data[position + 7], data[position + 6], data[position + 5], data[position + 4]);
                }
            }

            if (!fmtChunkFound)
            {
                throw new InvalidOperationException(SR.SoundAPIInvalidWaveHeader);
            }

            if (wFormatTag != Interop.WinMM.WAVE_FORMAT_PCM &&
                wFormatTag != Interop.WinMM.WAVE_FORMAT_ADPCM &&
                wFormatTag != Interop.WinMM.WAVE_FORMAT_IEEE_FLOAT)
            {
                throw new InvalidOperationException(SR.SoundAPIFormatNotSupported);
            }
        }

        private static short BytesToInt16(byte ch0, byte ch1)
        {
            int res;
            res = ch1;
            res |= ch0 << 8;
            return (short)res;
        }

        private static int BytesToInt(byte ch0, byte ch1, byte ch2, byte ch3)
        {
            return mmioFOURCC((char)ch3, (char)ch2, (char)ch1, (char)ch0);
        }

        private static int mmioFOURCC(char ch0, char ch1, char ch2, char ch3)
        {
            int result = 0;
            result |= ch0;
            result |= ch1 << 8;
            result |= ch2 << 16;
            result |= ch3 << 24;
            return result;
        }

        void ISerializable.GetObjectData(SerializationInfo info, StreamingContext context)
        {
            throw new PlatformNotSupportedException();
        }
    }
}