在windows phone的開發中,有時候我們需要在程式中嵌入一段語音,至少這要比打字速度快上很多。之前在android和ios的市場上上已經發現了這種整合錄音功能的應用,貌似那兩個系統都提供了介面,我想在windows phone肯定也能做到這一點。遺憾的是,在國內網站上面搜尋時找到的資料很少,當我按英文檢索時立刻就發現了一篇很有用的資料,Making
a Voice Recorder on Windows Phone 於是下面就是對這篇文章的粗略翻譯(略去了我認為不重要的東西)。
確定特徵功能
在開始寫應用之前,我想好了這個程式要實現哪些功能,列表如下:
- 匯出錄音
- 用WAV格式儲存錄音
- 為錄音添加備忘
- 加快或放慢錄音速度
- 改變語音
- 混合,分割,編輯錄音
- 以MP3格式匯出
- 分類標記
- 時間/日期提醒
你可以看到,在一個錄音程式應用之中可以添加進很多不同的東西,應用也就很快由從簡單變得很複雜。為了不讓應用變得過於複雜,我選擇了一個能實現我主要目地的最小功能集合,由於比較簡單這樣也避免了在很多地方引入潛在的BUG,縮減的功能集合如下:
- 以WAV格式儲存錄音
- 按照日期或名字對錄音排序
- 在螢幕鎖定時進行錄音
- 為錄音添加備忘
使用Silverlight應用中的XNA類你可以在windows phone上面建立兩種類型的應用,一種是使用Silverlight作為UI的應用,另一種是使用XNA渲染類作為UI的應用。開發時兩者只能選其一。Silverlight提供了一些控制項能夠讓你在建立應用UI的時候使用,例如button, textbox,label等等;而在XNA中,你需要建立你自己需要的一切來達到你想要展示出來的效果。基於以上這些特點,最終在該應用中我採用了Silverlight UI。為了錄音,我必須使用從Microsoft.Xna.Framework.Audio派生出來的Microphone類。儘管我們不能再Silverlight應用中使用XNA渲染類,我們仍然可以使用很多其他的XNA類。使用音頻相關的XNA類需要間斷性的調用FrameworkDispatcher.Update()。為了避免使用計時器調用函數時程式邏輯的迴圈,你可要使用微軟提供的一個ApplicationService例子,該例子可以實現相同的功能。這個類將會很好的為你調用這個函數,整個類如下:
public class XNAFrameworkDispatcherService : IApplicationService{ private DispatcherTimer frameworkDispatcherTimer; public XNAFrameworkDispatcherService() { this.frameworkDispatcherTimer = new DispatcherTimer(); this.frameworkDispatcherTimer.Interval = TimeSpan.FromTicks(333333); this.frameworkDispatcherTimer.Tick += frameworkDispatcherTimer_Tick; FrameworkDispatcher.Update(); } void frameworkDispatcherTimer_Tick(object sender, EventArgs e) { FrameworkDispatcher.Update(); } void IApplicationService.StartService(ApplicationServiceContext context) { this.frameworkDispatcherTimer.Start(); } void IApplicationService.StopService() { this.frameworkDispatcherTimer.Stop(); }}
一旦這個類在你的工程中被聲明,它需要被作為應用生命期的對象添加進來。這裡有很多方法實現這一點,但是我喜歡把他添加到App.xaml之中。
<Application.ApplicationLifetimeObjects> <!--Required object that handles lifetime events for the application--> <shell:PhoneApplicationService Launching="Application_Launching" Closing="Application_Closing" Activated="Application_Activated" Deactivated="Application_Deactivated"/> <local:XNAFrameworkDispatcherService /> </Application.ApplicationLifetimeObjects>
完成了這一步,我不再需要再考慮FrameworkDispatcher.Update.它會在程式啟動時自動執行並在程式關閉時自動終止。使用Microphone類錄音Microphone類把錄音記錄在組塊中,然後在記錄一個新組塊時把前一組塊傳遞到你的程式中。為了做到這一點,Microphone類擁有它自己的記憶體緩衝區。例如當你要記錄一個短句:"The quick brown fox jumped over the lazy dog."現在我們同時假定麥克風的緩衝區去一次能夠記錄一個單詞。圖1:麥克風,緩衝區,你的程式你開始說這個短句,麥克風緩衝區填滿了你說出單詞"The"的聲音。一旦緩衝區滿了,它就被傳送到程式中,同時麥克風開始用下一個記錄的單詞填充一個新的緩衝區。程式接收到緩衝區並能夠對其進行處理。考慮到程式是為了儲存和播放錄音,程式將會儲存音頻組塊並且等待下一個組塊以便拼接在前一個的後面。(註:這裡buffer翻譯為緩衝區,不過感覺有點彆扭,將就看了)圖3: 程式接收到第一個單詞,同時第二個單詞被記錄當每一個組塊被記錄後,它會被傳遞到程式中,程式把該組塊拼接在它已經收到的組塊之後。不過當使用者說完最後一個單詞時,這裡會產生BUG在很多網上的例子中,當使用者說完最後一個單詞dog並且按下停止按鈕時,程式停止接收從麥克風傳過來的更多資訊,但最後一個單詞還沒有從麥克風的緩衝區傳遞到程式緩衝區中。最終的結果就是程式接收到了除了最後一個單詞以外的全部內容。為了防止這個問題,需要考慮當使用者停止錄音時該發生什麼,程式應該等待直到它接收到停止前的最後一個緩衝區而不是立即停止。在最壞的情況下,句子結尾後的一些雜音可能也會被記錄下來,但這也好過遺失資料。我們可以通過減少緩衝區的大小來減少接收到多餘的資料量。建立實現上面功能的代碼還是相對簡單的。為了擷取一個Microphone類的執行個體,我們可以從Microphone.Current擷取。當麥克風在錄音時,通過產生一個BufferReady事件,它會告知我們的程式一個緩衝區可以讀取了。隨後我們可以使用GetBuffer(byte[] destination)擷取緩衝區的資料。在這個方法中,我們需要傳遞一個用於接收資料的位元組數組。這個數組大小如何設定?Microphone類擁有兩個成員來協助我們確定需要的大小。Microphone.BufferDuration讓我們知道麥克風的緩衝區能儲存多少時間的錄音,Microphone.GetSampleSizeInBytes(Timespan)方法告訴我們記錄一個特定長度的錄音需要多少位元組。把兩者結合在一起,我們需要的緩衝區大小就是Microphone.GetSampleSizeInBytes(Microphone.BufferDuration).一旦你擁有了Microphone類的一個執行個體,把其關聯到BufferReady事件中,建立好了接收資料的緩衝區,錄音過程可以通過調用Microphone.Start()開始。在BufferReady處理事件程式中需要做一些操作。當資料從緩衝區取出時,需要在某個地方把它們收集起來。當這些資料被收集後,我們需要檢查是否存在一個停止錄音的請求。如果有請求,就告訴Microphone執行個體在使用Microphone.Stop()之前停止發送資料,並且進行保持錄音的操作。為了收集資料,我將使用一個memory stream在錄音結束時把它寫入到隔離儲存區中。我的一個要求是使用WAV格式儲存錄音資料,這可以通過在寫完所有接收到的位元組前先寫一個合適的波頭實現。(參看:writing
a proper wave header)。下面是完成上面操作的代碼:
//code for recording from the microphone and saving to a filepublic void StartRecording(){ if (_currentMicrophone == null) { _currentMicrophone = Microphone.Default; _currentMicrophone.BufferReady += new EventHandler<EventArgs>(_currentMicrophone_BufferReady); _audioBuffer = new byte[_currentMicrophone.GetSampleSizeInBytes( _currentMicrophone.BufferDuration)]; _sampleRate = _currentMicrophone.SampleRate; } _stopRequested = false; _currentRecordingStream = new MemoryStream(1048576); _currentMicrophone.Start();}public void RequestStopRecording(){ _stopRequested = true;}void _currentMicrophone_BufferReady(object sender, EventArgs e){ _currentMicrophone.GetData(_audioBuffer); _currentRecordingStream.Write(_audioBuffer,0,_audioBuffer.Length); if (!_stopRequested) return; _currentMicrophone.Stop(); var isoStore = System.IO.IsolatedStorage.IsolatedStorageFile.GetUserStoreForApplication(); using (var targetFile = isoStore.CreateFile(FileName)) { WaveHeaderWriter.WriteHeader(targetFile, (int)_currentRecordingStream.Length, 1, _sampleRate); var dataBuffer = _currentRecordingStream.GetBuffer(); targetFile.Write(dataBuffer,0,(int)_currentRecordingStream.Length); targetFile.Flush(); targetFile.Close(); }}
錄音回放為了回放錄音,我將使用SoundEffect類。類似於Microphone類,SoundEffect也是一個XNA音頻類需要定期調用FrameworkDispatcher.Update()函數。有兩種方法可以讓我載入WAVE檔案。我可以自己對波頭進行解碼,或者讓SoundEffect類來做,這裡我給出的自己解碼的過程,這樣其他人可以對檔案進行修改。(註:並沒有發現作者給出的自己寫的參考)當通過建構函式初始化SoundEffect時,需要知道三個資料:錄音的音頻資料,採樣率,記錄音軌的數量。這個應用記錄的只是普通音而非立體聲。所以這裡一直都只有一個音軌。我可以傳遞AudioChannels.Mono作為參數。但在以後,我會加入匯入錄音的功能(錄音可能是立體聲),所以我把這個資料從波頭上取下。類似的,我也可以從Microphone類中擷取採樣率而不是從波頭中擷取。但為了以後著想,我還是從波頭擷取資料,波頭之後跟著的就是波資料。一旦SoundEffect被初始化,為了播放錄音,我需要擷取一個SoundEffectInstance執行個體然後調用Play方法。我不認為我需要解釋為什麼一次只播放一個錄音,所以在播放一個新的錄音片段前,我先檢查記憶體中是否已經存在一個片段,如果存在就將其終止。
public void PlayRecording(RecordingDetails source){ if(_currentSound!=null) { _currentSound.Stop(); _currentSound = null; } var isoStore = System.IO.IsolatedStorage.IsolatedStorageFile. GetUserStoreForApplication(); if(isoStore.FileExists(source.FilePath)) { byte[] fileContents; using (var fileStream = isoStore.OpenFile(source.FilePath, FileMode.Open)) { fileContents = new byte[(int) fileStream.Length]; fileStream.Read(fileContents, 0, fileContents.Length); fileStream.Close();//not really needed, but it makes me feel better. } int sampleRate =((fileContents[24] << 0) | (fileContents[25] << 8) | (fileContents[26] << 16) | (fileContents[27] << 24)); AudioChannels channels = (fileContents[22] == 1) ? AudioChannels.Mono : AudioChannels.Stereo; var se = new SoundEffect(fileContents, 44, fileContents.Length - 44, sampleRate, channels, 0, 0); _currentSound = se.CreateInstance(); _currentSound.Play(); }}
通過SoundEffect.FromFile載入聲音是簡單直接的
public void PlayRecording(RecordingDetails source){ SoundEffect se; if(_currentSound!=null) { _currentSound.Stop(); _currentSound = null; } var isoStore = System.IO.IsolatedStorage. IsolatedStorageFile.GetUserStoreForApplication(); if(isoStore.FileExists(source.FilePath)) { byte[] fileContents; using (var fileStream = isoStore.OpenFile(source.FilePath, FileMode.Open)) { se = SoundEffect.FromStream(fileStream); fileStream.Close();//not really needed, but it makes me feel better. } _currentSound = se.CreateInstance(); _currentSound.Play(); }}
跟蹤錄音除了把錄音記錄在隔離儲存之中外,我想跟蹤一些其他的資訊,例如錄音建立的日期,錄音標題,錄音備忘等等。通過給檔案命名或者從檔案資料中推斷錄製日期從而給錄音一個標題是可能的,但這種解決方案並不持久。在對檔案命名時存在一些字元的限制,並且在以後當我增加匯入或匯出檔案時,可能存在檔案日期丟失的情況。因此我建立了一個類用來儲存我想要在錄音上跟蹤的資訊。類如下所示:
public class RecordingDetails{ public string Title { get; set; } public string Details { get; set; } public DateTime TimeStamp { get; set; } public string FilePath { get; set; } public string SourcePath { get; set; }}
為了讓這個類易讀,這裡我給了一個簡化的形式。這個類需要被序列化以便我可以從隔離儲存中進行讀寫。所以這個類被標記上[DateContract]屬性,其成員被標記上[DateMember]屬性。我計劃把這個類的執行個體綁到UI元素中,所以這個類需以用INotifyPropertyChanged介面進行實現。該類的版本如下:
[DataContract]public class RecordingDetails: INotifyPropertyChanged { // Title - generated from ObservableField snippet - Joel Ivory Johnson private string _title; [DataMember] public string Title { get { return _title; } set { if (_title != value) { _title = value; OnPropertyChanged("Title"); } } } //----- // Details - generated from ObservableField snippet - Joel Ivory Johnson private string _details; [DataMember] public string Details { get { return _details; } set { if (_details != value) { _details = value; OnPropertyChanged("Details"); } } } //----- // FilePath - generated from ObservableField snippet - Joel Ivory Johnson private string _filePath; [DataMember] public string FilePath { get { return _filePath; } set { if (_filePath != value) { _filePath = value; OnPropertyChanged("FilePath"); } } } //----- // TimeStamp - generated from ObservableField snippet - Joel Ivory Johnson private DateTime _timeStamp; [DataMember] public DateTime TimeStamp { get { return _timeStamp; } set { if (_timeStamp != value) { _timeStamp = value; OnPropertyChanged("TimeStamp"); } } } //----- // SourceFileName - generated from ObservableField snippet - Joel Ivory Johnson private string _sourceFileName; [IgnoreDataMember] public string SourceFileName { get { return _sourceFileName; } set { if (_sourceFileName != value) { _sourceFileName = value; OnPropertyChanged("SourceFileName"); } } } //----- // IsNew - generated from ObservableField snippet - Joel Ivory Johnson private bool _isNew = false; [IgnoreDataMember] public bool IsNew { get { return _isNew; } set { if (_isNew != value) { _isNew = value; OnPropertyChanged("IsNew"); } } } //----- // IsDirty - generated from ObservableField snippet - Joel Ivory Johnson private bool _isDirty = false; [IgnoreDataMember] public bool IsDirty { get { return _isDirty; } set { if (_isDirty != value) { _isDirty = value; OnPropertyChanged("IsDirty"); } } } //----- public void Copy(RecordingDetails source) { this.Details = source.Details; this.FilePath = source.FilePath; this.SourceFileName = source.SourceFileName; this.TimeStamp = source.TimeStamp; this.Title = source.Title; } public event PropertyChangedEventHandler PropertyChanged; protected void OnPropertyChanged(string propertyName) { if (PropertyChanged != null) { PropertyChanged(this, new PropertyChangedEventArgs(propertyName)); } }}
[DataMember]屬性貫穿了整個代碼,所以我可以使用資料合約序列化讀寫該類。由於我使用了DataContractSerializer,我並不需要過於操心當檔案被儲存和載入時具體的編碼細節。同時使用隔離儲存不是很難,我使用了之前我自己寫的一個實用類的變種(具體看這裡)來把序列化和還原序列化的簡化為一小段代碼。當使用者建立一個新的錄音時,該類的一個新的執行個體也會被建立。除了標題,備忘和時間戳記外,這個類也包含描述該錄音的路徑,一個指出資料從哪載入的原始檔案名稱的非序列化成員SourceFileName。如果沒有這些資訊,當使用者決定更新資料時,便無法知道當儲存內容時哪個檔案需要被重寫。
//Saving Datavar myDataSaver = new DataSaver<RecordingDetails>() {};myDataSaver.SaveMyData(LastSelectedRecording, LastSelectedRecording.SourceFileName);//Loading Datavar myDataSaver = new DataSaver<RecordingDetails>();var item = myDataSaver.LoadMyData(LastSelectedRecording.SourceFileName);
這樣操作後,你就擁有了進行錄音,儲存錄音,載入錄音的全部資訊,當程式第一次啟動時,我讓其載入所有的RecordingDetails並且把他們載入到我的視圖模式中的一個ObservableCollection中。在那裡它們可以以一種列表的形式展現給使用者。
public void LoadData(){ var isoStore = System.IO.IsolatedStorage.IsolatedStorageFile.GetUserStoreForApplication(); var recordingList = isoStore.GetFileNames("data/*.xml"); var myDataSaver = new DataSaver<RecordingDetails>(); Items.Clear(); foreach (var desc in recordingList.Select(item => { var result =myDataSaver.LoadMyData(String.Format("data/{0}", item)); result.SourceFileName = String.Format("data/{0}", item); return result; })) { Items.Add(desc); } this.IsDataLoaded = true;}
儲存狀態和墓碑化
你的程式可以在任何時間被中斷,例如一個電話,一個突然跳出程式進行的搜尋等等。當這一切發生時,你的應用會被墓碑化;作業系統將會儲存使用者所在的頁面並且給程式儲存其他資料的機會。當程式被再次載入時,開發人員必須確保採取適當步驟重新載入狀態。大多數情況下,我並不需要擔心墓碑化因為程式大部分狀態資料被迅速儲存到隔離儲存中。這裡也沒有多少需要儲存的狀態資料,因為錄音隨著程式設定的改變會被立即執行。
下面內容不再翻譯,無非是作者提出自己想增加應用功能的說明。最後給出源碼和應用:
:http://www.codeproject.com/KB/windows-phone-7/WpVoiceMemo/WpVoiceMemo.zip