本文章是由機器翻譯。

UI 最前線

Windows Phone 7 中的錄音功能

Charles Petzold

下載代碼示例

早在 1984 年,Apple 在第一批推出 Macintosh 的平面廣告中,就宣傳過它的滑鼠設計,廣告語非常吸引人:“有些滑鼠有兩個按鈕。Macintosh 只有一個。想按錯都難。”

當然事實並非完全如此。通過一個按鈕執行諸多功能,可能和多個按鈕一樣讓人困擾。但是,就 UI 設計的簡易性而言,不會按錯按鈕的設計當然非常有說服力。

盡可能精簡 UI,這一點在為智慧手機程式設計時甚至更為重要。手機尺寸有限。容不下太多按鈕,用手指按這些按鈕不可能像滑鼠一樣準確。按鈕太多意味著更容易按錯按鈕。

不利的一面是,限制 UI 通常會限制程式的功能,權衡這兩方面並非易事。生活離不開利弊權衡。

設計思路

我想過,編寫一個 Windows Phone 7 程式來錄製一小段語音備忘錄(例如“記得取乾洗衣服”和“想出了一個不錯的電影情節:男孩遇見女孩”)很有趣。

當然,這類程式很有用,在公共場合使用這些程式炫耀新 Windows Phone 也是一個理由。對我而言,更重要的是,使用錄音功能可以錄製某些實踐經驗,可以播放手機支援的課程。

但是,程式設計方面的問題超出了我的預期。即使我之前編寫過代碼,在腦海中反復思考程式的設計。

開始,我認為只有兩個按鈕(“Record”和“Play”)很好,它們可以通過切換方式工作。按“Record”按鈕開始錄製,再按一次則停止錄製。程式將音訊資料保存在獨立的存儲中。按“Play”按鈕開始播放。每按一次“Record”按鈕將覆蓋之前的備忘錄,因此程式不需要“Delete”按鈕。

我甚至草率地通過實現語音啟動功能將程式精簡為一個“Play”按鈕。程式將連續錄製,僅在它包含語音時保存資料。但是這樣一來,如果不引入某種手動閾值設置,很難區分背景聲音與真正的語音資料。我放棄了單按鈕設計。

我最初的方案適合一個備忘錄,要錄製多個備忘錄就不行了。這之後,我想將程式設計為只維護一個音訊檔,在以前的備忘錄末尾追加新的備忘錄。因為只有一個大檔,“Play”按鈕將按順序播放所有備忘錄。當然,程式不會讓這個檔無限增大,因此,肯定需要設計一個“Delete”按鈕來刪除整個檔,即所有備忘錄。

不,這不是一個好辦法。我需要為每個備忘錄維護單獨的檔,允許逐個刪除這些備忘錄。但這樣一來,需要通過某種方法為使用者提供所有單個的檔供其播放和刪除,程式的複雜性增加了。毫無疑問,我需要一個 ListBox 和某種為使用者標識每個備忘錄的方法,或許可以使用使用者提供的關鍵字或者(更糟糕的)實際檔案名進行標識。

不,不,決不能用這種方法!我看了一眼電話應答機。每個呼叫或備忘錄都分別錄製下來,它們只是通過編號顯示在簡單的顯示幕上。“Previous”和“Next”方向按鈕可以轉到上一呼叫或下一呼叫,對“Play”按鈕進行了補充。每當刪除備忘錄或呼叫時,備忘錄會重新編號。我知道我不需要對備忘錄進行編號,但可以在手機的大顯示幕上顯示每個備忘錄的詳細資訊,包括錄製日期、持續時間和檔大小。

當我意識到,在程式的主螢幕上放置一個 ListBox,不僅可用於選擇,也可以用於播放,我終於找到了突破口。

使用程式

當然,最終設計是以極簡的形式實現完備的備忘錄管理系統。可下載的 SpeakMemo 專案是為 Silverlight for Windows Phone 編寫的,需要使用 Windows Phone 7 開發工具。您可以在手機模擬器上運行該程式,它看起來可以正常運行,但不會實際錄製或播放任何語音。

SpeakMemo 程式第一次運行時,螢幕顯示內容如圖 1 所示。

圖 1 初始 SpeakMemoScreen

一個按鈕!或者,至少在相當簡潔的螢幕上顯示了一個啟用的 按鈕。該按鈕顯示獨立存儲中還有多少空間,該空間對應多大的錄音檔。(不,這個程式不允許錄製長達 17 小時的備忘錄!)

按“Record”按鈕,它將變為紅色並閃爍,同時即時更新持續時間指示符,如圖 2 所示。

圖 2 錄製時的 SpeakMemo

再次按“Record”按鈕,錄製的備忘錄將顯示在螢幕上,包括錄製的日期和時間、持續時間、存儲空間和“Play”按鈕,如圖 3 所示。

圖 3 包含一個備忘錄的 SpeakMemo

當然,您可以按“Play”按鈕播放備忘錄,該按鈕會在“播放”與“暫停”模式間切換。

只有一個備忘錄時可能並不明顯,但錄製的備忘錄是按時間順序逆序存儲在 ListBox 中的,如圖 4 所示,因此,如果積累有多個備忘錄,可以滾動查看和播放這些備忘錄。

圖 4 SpeakMemo ListBox

Silverlight 的一項強大功能是 DataTemplate,它可用於定義 ListBox 中各項的外觀。DataTemplate 可以包含其他控制項,例如按鈕。我很高興編寫了一個在 DataTemplate 中放置一個按鈕的實際應用程式。

您還可以通過刪除單個備忘錄來管理所收集的備忘錄。選中一個備忘錄時,將啟用“Delete”按鈕。可能是受在 DataTemplate 中放置按鈕的啟示,我又利用了一下 Silverlight 技巧,在“Delete”按鈕內放置了兩個按鈕。按下“Delete”時,會顯示這些按鈕,它們執行傳統的確認功能,如圖 5 所示。

圖 5 確認刪除

播放某個備忘錄時,該備忘錄會處於選中狀態,但是,如果按“Play”按鈕右側區域選中備忘錄,是不會播放相應備忘錄的。該程式可以同時進行備忘錄的播放、錄製和刪除操作。

手機和語音

人們一度希望 Windows Phone 7 能具有 Microsoft .NET Framework System.Speech 命名空間中提供的某些語音辨識和合成支援。可能以後會實現這些支援。

到那時,可以從手機的麥克風捕獲語音,然後使用 Microsoft.Xna.Framework.Audio 命名空間中的類通過手機揚聲器播放語音。這些是 XNA 類,但也可以用在 Silverlight 程式中。要在 Silverlight 專案中使用 XNA 類,只需將對 Microsoft.Xna.Framework.dll 的引用添加到專案的引用並忽略警告消息。

Microsoft.Xna.Framework.Audio 命名空間中的類與 Microsoft.Xna.Framework.Media 命名空間中的類是完全獨立的。Media 命名空間包含用於從手機音樂庫播放音樂的類,這些音樂是 MP3 或 WMA 格式的壓縮音訊檔,是 Song 類型的物件。我撰寫的書《Programming Windows Phone 7》(Microsoft Press, 2010) 的第 18 章仲介紹了如何訪問音樂庫,這本書可從 bit.ly/dr0Hdz 免費下載。在我的網站博客文章中,也演示了如何播放存儲在程式自身或者通過 Internet (bit.ly/ea73Fz) 下載的 MP3 或 WMA 檔。

與此不同,Microsoft.Xna.Framework.Audio 命名空間中的類使用標準 PCM 格式的未壓縮音訊資料,這與音訊 CD 和 Windows WAV 檔所用的方法相同。使用 PCM,類比語音振幅按統一速率(通常為每秒 8,000 至 48,000 個樣本)進行採樣,每個樣本通常存儲為 8 位或 16 位值。特殊語音所要求的存儲為持續時間(以秒為單位)乘以取樣速率,再乘以每個採樣的位元組數(身歷聲為兩倍)。

如果需要在 Windows Phone 7 應用程式中提供語音辨識支援,您必須自己提供該支援,極有可能通過 Web 服務提供。同樣,要求將文本轉換為語音的程式也可能會使用 Web 服務,或者等到手機提供該支援。面向 Windows Phone 的 Microsoft Translator 應用程式使用 Microsoft Translator 服務 (microsofttranslator.com) 實現該支援。Translator Starter Kit 的代碼和文檔在 MSDN (msdn.microsoft.com/library/gg521144(VS.92).aspx) 和 AppHub (create.msdn.com/education/catalog/sample/translatorstarterkit) 上發佈。

使用 XNA 音訊服務時,Silverlight 程式必須以與視頻刷新率大致相同的速率調用靜態 FrameworkDispatcher.Update 方法,Windows Phone 7 上的視頻刷新率大約為每秒 30 次。有關如何執行此操作的描述,請參見 XNA 連線文檔 (msdn.microsoft.com/library/ff842408) 中的文章“在 Windows Phone 應用程式中支援 XNA Framework 事件”。在 SpeakMemo 中,XnaFrameworkDispatcherService 類處理此任務。此類在 App.xaml 檔中產生實體。

錄音

要通過手機麥克風錄音,請使用 Microphone 類。可能需要使用靜態 Default 屬性創建此類的實例:

Microphone microphone = Microphone.Default;

或者,使用靜態 All 屬性提供 Microphone 物件的集合,但這樣可能需要向使用者顯示清單以便選擇一項。

取樣速率是固定的,不能更改,SampleRate 屬性報告的取樣速率為每秒 16,000 個樣本。 根據 Nyquist 採樣定理,在錄製音訊不超過 8,000 Hz 的聲音時此取樣速率是合適的。 這適合語音,但無法實現很好的音樂效果。 每個樣本都是 2 個位元組寬的單聲道,這意味著每秒錄音需要 32,000 個位元組,每分鐘為 1.9MB。

麥克風資料傳遞到程式的緩衝區,緩衝區實際上是位元組陣列。 您將安裝 BufferReader 事件的處理常式,接著調用 Start 開始錄製。 Microphone 物件觸發 BufferReady 事件時,代碼使用一個位元組陣列調用 GetData。 從 GetData 返回時,緩衝區已填充 PCM 資料。 需要程式停止錄製時,再次調用 GetData 獲取最新的部分緩衝區。 此方法返回傳遞給陣列的位元組數。 然後調用 Stop。

Microphone 僅允許指定傳遞給 GetData 的緩衝區的位元組大小。 BufferSize 屬性是 TimeSpan 值,必須介於 100 毫秒到 1,000 毫秒(一秒)之間,以 10 毫秒為增量。 在 SpeakMemo 中,我保留了預設值 1,000。

為方便起見,Microphone 類提供兩個方法用於轉換緩衝區大小與時間。 遺憾的是,這些方法有點讓人困惑,因為它們的名稱包含“sample”。GetSampleDuration 方法基本上是將位元組大小除以 32,000 並返回指示秒數的 TimeSpan。 GetSampleSizeInBytes 是將 TimeSpan 持續時間(以秒為單位)乘以 32,000。

當 SpeakMemo 進行錄製時,它將多個 32,000 位元組的緩衝區聚合到一個泛型 List 集合中。 錄製停止後,程式在單獨的存儲中將所有單個緩衝區保存為一個檔。

在決定不包括關鍵字功能來標識備忘錄後,我希望檔只包含 PCM 資料,不包含任何補充資訊。 但是,我十分驚訝地意識到 Silverlight for Windows Phone 中的 IsolatedStorageFile 類沒有用於訪問檔創建時間或最後寫入時間的方法,我認為這一資訊對於使用者來說很重要。

這意味著檔案名本身必須包含日期和時間。 我首先嘗試通過 DateTime 物件使用“s”和“u”格式設置選項來創建檔案名,但這不起作用。 (至於為什麼不起作用,我將它作為一個簡單的練習留給讀者。)接著,我將日期和時間的各部分組合在一起,構造了我自己的檔案名字串。

XNA 聲音播放

使用 Microsoft.Xna.Framework.Audio 命名空間中的相關 SoundEffect 和 SoundEffectInstance 類可以播放預先錄製的聲音,在 XNA 遊戲上下文中,這兩個類名稱與其常見功能不一致! 但靜態 SoundEffect.FromStream 方法要求 Stream 物件引用一個包含 RIFF 頭的標準 Windows WAV 檔,而我不想為檔案格式費心。

為了使用原始的 PCM 資料而不是 WAV 檔,您可能希望改為使用 DynamicSoundEffectInstance 類,該類派生自 SoundEffectInstance。 此類適用于從 Microphone 類生成的資料或者動態創建自己的波形資料的程式(例如音樂合成器程式)。

DynamicSoundEffectInstance 構造函數需要一個取樣速率和許多聲道;如果要使用此類處理從 Microphone 生成的資料,顯然需要使之保持一致:

DynamicSoundEffectInstance playback = 
  new DynamicSoundEffectInstance(
  microphone.SampleRate, AudioChannels.Mono);

另一方面,如果要讓播放的語音聽起來像語速很快的數來寶一樣,只需將第一個參數乘以 2。DynamicSoundEffectInstance 需要具有 16 位樣本大小的資料。此類有 Play、Pause、Resume 和 Stop 方法,可用於控制播放,State 屬性指示當前狀態。此類與 Microphone 的情形在某些方面相反:它在需要新緩衝區時觸發 BufferNeeded 事件。您的任務是使用 PCM 資料填充緩衝區並調用 SubmitBuffer。

為避免語音中出現聽得見的間隙,一般情況下,需要在 DynamicSoundEffectInstance 類中維護一個緩衝區佇列,在上一緩衝區仍在播放時提交新的緩衝區。此類的作用在於,通過 PendingBufferCount 屬性來指示佇列中的緩衝區數目。BufferNeeded 事件在 PendingBufferCount 更改且小於或等於 2 時觸發。

但是,如果只是需要播放 PCM 資料的整個區塊,則可以調用 SubmitBuffer 而無需觸發 BufferNeeded 事件。剛開始,我就是這樣在 SpeakMemo 程式中使用此類的,但我發現它無法確定緩衝區何時完成播放。此類沒有“狀態已更改”事件,即使有,DynamicSoundEffectInstance 也不會在緩衝區完成播放時從“播放”狀態切換為“停止”狀態。它需要更多的緩衝區。如果不知道這一資訊,則無法使程式正確切換“Play/Pause”按鈕的顯示。

我最後處理 BufferNeeded 事件,不過,這只是為了檢查 PendingBufferCount 屬性。當 PendingBufferCount 降至零時,緩衝區就完成了播放。

存儲問題

SpeakMemo 在獨立的存儲中存儲錄製的備忘錄。從概念上講,獨立的存儲是應用程式的私有空間,但從物理上講,它是整個存儲區域的一部分,與臺式電腦的硬碟相似。這裡存儲了所有的應用程式可執行檔以及手機的照片庫、音樂庫、視頻庫等。Windows Phone 7 的硬體規範要求手機至少有 8GB 的快閃記憶體作為此存儲區域,當此存儲容量不足時,手機會提醒使用者。

存儲備忘錄檔不是我主要關心的問題。我更關注程式的堆。除了快閃記憶體外,Windows Phone 7 硬體規範還要求具有 256MB 的 RAM。這是應用程式在運行時佔用的記憶體,它提供程式的本地堆。根據我的經驗,SpeakMemo 可以分配最多 90MB 的陣列,再大就會引發記憶體不足異常。這相當於使用麥克風錄製大約 47 分鐘的語音。

這並不表示 Windows Phone 7 程式必須限制為 47 分鐘的錄製時間。但如果程式要錄製這麼長時間的連續語音,則必須逐步將緩衝區保存到獨立的存儲才能釋放記憶體,然後在播放檔時增量式載入檔。這不是 SpeakMemo 的構造方式。該程式會保存並載入整個檔,我不願意放棄這麼簡單的結構。

因此,我只是將備忘錄持續時間設置為最大 10 分鐘。錄製達到該長度後,即會停止並保存(保存本身需要數秒)。為了使程式簡單,沒有任何警告。錄製直接停止,就像使用者按下停止按鈕一樣。當程式終止或停用(例如邏輯刪除期間)時也會出現這種自動停止並保存的過程。

當然,播放 10 分鐘的備忘錄也不方便。“Play”按鈕將在播放和暫停模式間切換,但沒有快退和快進方式。這些功能是可以添加的,相信您知道該怎樣做,對吧?

是的,更多按鈕。甚至可能是一個滑塊。

Charles Petzold 是 MSDN 雜誌 *的長期特約編輯。*他的新書《Programming Windows Phone 7》(Microsoft Press, 2010) 可從 bit.ly/dr0Hdz 免費下載。

衷心感謝以下技術專家對本文的審閱: Mark Hopkins