第九章:特定於平臺的API呼叫(四)

wangccsy發表於2018-07-16

特定於平臺的聲音生成

現在為了本章的真正目的:給MonkeyTap發聲。所有三個平臺都支援API,允許程式動態生成和播放音訊波形。這是MonkeyTapWithSound程式採用的方法。
商業音樂檔案通常以諸如MP3之類的格式壓縮。但是當一個程式演算法演算法生成波形時,未壓縮的格式會更加方便。最基本的技術 – 所有三個平臺都支援 – 稱為脈衝編碼調製或PCM。除了花哨的名字,它很簡單,它是用於在音樂CD上儲存聲音的技術。
PCM波形由一系列恆定速率的樣本描述,稱為取樣率。音樂CD使用標準速率為每秒44,100個樣本。如果不需要高音質,計算機程式生成的音訊檔案通常使用一半(22,050)或四分之一(11,025)的取樣率。可記錄和再現的最高頻率是取樣率的一半。
每個樣本都是固定大小,用於定義該時間點波形的幅度。音樂CD上的樣本是帶符號的16位值。當聲音質量無關緊要時,8位樣本很常見。某些環境支援浮點值。多個樣本可以容納立體聲或任意數量的聲道。對於移動裝置上的簡單音效,單聲道聲音通常很好。
MonkeyTapWithSound中的聲音生成演算法是針對16位單聲道樣本進行硬編碼的,但取樣率由常量指定,並且可以輕鬆更改。
現在您已經瞭解了DependencyService的工作原理,讓我們檢查新增到MonkeyTap的程式碼,將其轉換為MonkeyTapWithSound,讓我們從上到下看一下。為避免重現大量程式碼,新專案包含MonkeyTap專案中MonkeyTap.xaml和MonkeyTap.xaml.cs檔案的連結。
在Visual Studio中,通過從專案選單中選擇“新增”>“現有項”,可以將專案新增為專案作為現有檔案的連結。然後使用“新增現有項”對話方塊導航到該檔案。從“新增”按鈕的下拉選單中選擇“新增為連結”。
在Xamarin Studio中,從專案的工具選單中選擇新增>新增檔案。開啟檔案後,會彈出“新增檔案到資料夾”警告框。選擇“新增指向該檔案的連結”。
但是,在Visual Studio中執行這些步驟後,還需要手動編輯Mon?keyTapWithSound.csproj檔案,以將MonkeyTapPage.xaml檔案更改為EmbeddedResource,將Generator更改為MSBuild:UpdateDesignTimeXaml。此外,還將一個DependentUpon標記新增到MonkeyTapPage.xaml.cs檔案中以引用MonkeyTapPage.xaml檔案。這會導致程式碼隱藏檔案在檔案列表中的XAML檔案下縮排。
然後,MonkeyTapWithSoundPage類派生自MonkeyTapPage類。雖然MonkeyTapPage類是由XAML檔案和程式碼隱藏檔案定義的,但MonkeyTapWithSoundPage僅是程式碼。當以這種方式派生類時,必須將XAML檔案中的事件的原始程式碼隱藏檔案中的事件處理程式定義為受保護的,這是這種情況。
MonkeyTap類還將flashDuration常量定義為protected,並將兩個方法定義為protected和virtual。 MonkeyTapWithSoundPage重寫這兩個方法來呼叫一個名為SoundPlayer.PlaySound的靜態方法:

namespace MonkeyTapWithSound
{
    class MonkeyTapWithSoundPage : MonkeyTap.MonkeyTapPage
    {
        const int errorDuration = 500;
        // Diminished 7th in 1st inversion: C, Eb, F#, A
        double[] frequencies = { 523.25, 622.25, 739.99, 880 };
        protected override void BlinkBoxView(int index)
        {
            SoundPlayer.PlaySound(frequencies[index], flashDuration);
            base.BlinkBoxView(index);
        }
        protected override void EndGame()
        {
            SoundPlayer.PlaySound(65.4, errorDuration);
            base.EndGame();
        }
    }
}

SoundPlayer.PlaySound方法接受頻率和持續時間(以毫秒為單位)。 每一件事 – 音量,聲音的諧波組成以及聲音是如何產生的 – 都是PlaySound方法的責任。 但是,此程式碼隱含地假設SoundPlayer.PlaySound立即返回,並且不等待聲音完成播放。 幸運的是,所有這三個平臺都支援以這種方式執行的聲音生成API。
使用PlaySound靜態方法的SoundPlayer類是MonkeyTapWithSound PCL專案的一部分。 此方法的職責是為聲音定義PCM資料的陣列。 此陣列的大小取決於取樣率和持續時間。 for迴圈計算定義所請求頻率的三角波的樣本:

namespace MonkeyTapWithSound
{
    class SoundPlayer
    {
        const int samplingRate = 22050;
        /* Hard-coded for monaural, 16-bit-per-sample PCM */
        public static void PlaySound( double frequency = 440, int duration = 250 )
        {
            short[] shortBuffer    = new short[samplingRate * duration / 1000];
            double    angleIncrement    = frequency / samplingRate;
            double    angle        = 0; /* normalized 0 to 1 */
            for ( int i = 0; i < shortBuffer.Length; i++ )
            {
                /* Define triangle wave */
                double sample;
                /* 0 to 1 */
                if ( angle < 0.25 )
                    sample = 4 * angle;
                /* 1 to -1 */
                else if ( angle < 0.75 )
                    sample = 4 * (0.5 - angle);
                /* -1 to 0 */
                else
                    sample = 4 * (angle - 1);
                shortBuffer[i]    = (short) (32767 * sample);
                angle        += angleIncrement;
                while ( angle > 1 )
                    angle -= 1;
            }
            byte[] byteBuffer = new byte[2 * shortBuffer.Length];
            Buffer.BlockCopy( shortBuffer, 0, byteBuffer, 0, byteBuffer.Length );
            DependencyService.Get<IPlatformSoundPlayer>().PlaySound( samplingRate, byteBuffer );
        }
    }
}

雖然樣本是16位整數,但是其中兩個平臺希望資料以位元組陣列的形式存在,因此使用Buffer.BlockCopy在末尾附近進行轉換。 該方法的最後一行使用DependencyService將具有采樣率的此位元組陣列傳遞給各個平臺。
DependencyService.Get方法引用IPlatformSoundPlayer介面,該介面定義了PlaySound方法的簽名:

namespace MonkeyTapWithSound
{
    public interface IPlatformSoundPlayer
    {
        void PlaySound(int samplingRate, byte[] pcmData);
    }
}

現在來了困難的部分:為三個平臺編寫這個PlaySound方法!
iOS版本使用AVAudioPlayer,它需要包含Wave?form音訊檔案格式(.wav)檔案中使用的標頭的資料。 這裡的程式碼彙編了MemoryBuffer中的資料,然後將其轉換為NSData物件:

using System;
using System.IO;
using System.Text;
using Xamarin.Forms;
using AVFoundation;
using Foundation;
[assembly: Dependency( typeof(MonkeyTapWithSound.iOS.PlatformSoundPlayer) )]
namespace MonkeyTapWithSound.iOS
{
    public class PlatformSoundPlayer : IPlatformSoundPlayer
    {
        const int    numChannels    = 1;
        const int    bitsPerSample    = 16;
        public void PlaySound( int samplingRate, byte[] pcmData )
        {
            int        numSamples    = pcmData.Length / (bitsPerSample / 8);
            MemoryStream    memoryStream    = new MemoryStream();
            BinaryWriter    writer        = new BinaryWriter( memoryStream, Encoding.ASCII );
            /* Construct WAVE header. */
            writer.Write( new char[] { `R`, `I`, `F`, `F` } );
            writer.Write( 36 + sizeof(short) * numSamples );
            writer.Write( new char[] { `W`, `A`, `V`, `E` } );
            writer.Write( new char[] { `f`, `m`, `t`, ` ` } );              /* format chunk */
            writer.Write( 16 );                                             /* PCM chunk size */
            writer.Write( (short) 1 );                                      /* PCM format flag */
            writer.Write( (short) numChannels );
            writer.Write( samplingRate );
            writer.Write( samplingRate * numChannels * bitsPerSample / 8 ); /* byte rate */
            writer.Write( (short) (numChannels * bitsPerSample / 8) );      /* block align */
            writer.Write( (short) bitsPerSample );
            writer.Write( new char[] { `d`, `a`, `t`, `a` } );              /* data chunk */
            writer.Write( numSamples * numChannels * bitsPerSample / 8 );
            /* Write data as well. */
            writer.Write( pcmData, 0, pcmData.Length );
            memoryStream.Seek( 0, SeekOrigin.Begin );
            NSData        data        = NSData.FromStream( memoryStream );
            AVAudioPlayer    audioPlayer    = AVAudioPlayer.FromData( data );
            audioPlayer.Play();
        }
    }
}

請注意兩個要點:PlatformSoundPlayer實現IPlatformSoundPlayer介面,並使用Dependency屬性標記類。
Android版本使用AudioTrack類,結果更容易一些。 但是,AudioTrack物件不能重疊,所以有必要儲存前一個物件並停止播放,然後開始下一個物件:

using System;
using Android.Media;
using Xamarin.Forms;
[assembly: Dependency( typeof(MonkeyTapWithSound.Droid.PlatformSoundPlayer) )]
namespace MonkeyTapWithSound.Droid
{
    public class PlatformSoundPlayer : IPlatformSoundPlayer
    {
        AudioTrack previousAudioTrack;
        public void PlaySound( int samplingRate, byte[] pcmData )
        {
            if ( previousAudioTrack != null )
            {
                previousAudioTrack.Stop();
                previousAudioTrack.Release();
            }
            AudioTrack audioTrack = new AudioTrack( Stream.Music,
                                samplingRate,
                                ChannelOut.Mono,
                                Android.Media.Encoding.Pcm16bit,
                                pcmData.Length * sizeof(short),
                                AudioTrackMode.Static );
            audioTrack.Write( pcmData, 0, pcmData.Length );
            audioTrack.Play();
            previousAudioTrack = audioTrack;
        }
    }
}

三個Windows和Windows Phone平臺可以使用MediaStreamSource。 為了避免大量重複程式碼,MonkeyTapWithSound解決方案包含一個名為WinRuntimeShared的額外SAP專案,該專案僅由三個平臺都可以使用的類組成:

using System;
using System.Runtime.InteropServices.WindowsRuntime;
using Windows.Media.Core;
using Windows.Media.MediaProperties;
using Windows.Storage.Streams;
using Windows.UI.Xaml.Controls;
namespace MonkeyTapWithSound.WinRuntimeShared
{
    public class SharedSoundPlayer
    {
        MediaElement    mediaElement = new MediaElement();
        TimeSpan    duration;
        public void PlaySound( int samplingRate, byte[] pcmData )
        {
            AudioEncodingProperties audioProps =
                AudioEncodingProperties.CreatePcm( (uint) samplingRate, 1, 16 );
            AudioStreamDescriptor    audioDesc    = new AudioStreamDescriptor( audioProps );
            MediaStreamSource    mss        = new MediaStreamSource( audioDesc );
            bool            samplePlayed    = false;
            mss.SampleRequested += (sender, args) =>
            {
                if ( samplePlayed )
                    return;
                IBuffer            ibuffer = pcmData.AsBuffer();
                MediaStreamSample    sample    =
                    MediaStreamSample.CreateFromBuffer( ibuffer, TimeSpan.Zero );
                sample.Duration        = TimeSpan.FromSeconds( pcmData.Length / 2.0 / samplingRate );
                args.Request.Sample    = sample;
                samplePlayed        = true;
            };
            mediaElement.SetMediaStreamSource( mss );
        }
    }
}

此SAP專案由三個Windows和Windows Phone專案引用,每個專案包含相同的(名稱空間除外)PlatformSoundPlayer類:

using System;
using Xamarin.Forms;
[assembly: Dependency( typeof(MonkeyTapWithSound.UWP.PlatformSoundPlayer) )]
namespace MonkeyTapWithSound.UWP
{
    public class PlatformSoundPlayer : IPlatformSoundPlayer
    {
        WinRuntimeShared.SharedSoundPlayer sharedSoundPlayer;
        public void PlaySound( int samplingRate, byte[] pcmData )
        {
            if ( sharedSoundPlayer == null )
            {
                sharedSoundPlayer = new WinRuntimeShared.SharedSoundPlayer();
            }
            sharedSoundPlayer.PlaySound( samplingRate, pcmData );
        }
    }
}

使用DependencyService來執行特定於平臺的雜務是非常強大的,但是當涉及到使用者介面元素時,這種方法不足。 如果您需要擴充套件裝飾Xamarin.Forms應用程式頁面的檢視庫,那麼這項工作涉及建立特定於平臺的渲染器,這是本書最後一章中討論的過程。


相關文章