65.9K
CodeProject 正在变化。 阅读更多。
Home

制作 Windows Phone 上的录音机

starIconstarIconstarIconstarIcon
emptyStarIcon
starIcon

4.92/5 (23投票s)

2011 年 3 月 31 日

CPOL

14分钟阅读

viewsIcon

113245

downloadIcon

5307

演示在 Windows Phone 7 上制作录音机所需的步骤,包括将录音的原始字节转换为 WAVE 文件。

引言

在论坛上,经常会遇到一些关于使用麦克风、从中制作可用录音以及其他一些问题。我相信这些问题还会再次出现,我认为有一个示例可以供我参考将很有价值。我制作了这个语音备忘录应用程序,已经通过了 Marketplace 的认证,并向所有愿意使用它的人提供了源代码。您可以随意以任何方式使用此代码。如果您想在自己的应用程序中使用它,我将非常感谢您发送一条消息,告诉我您发现代码很有用。但如果您不这样做,我不会因此而责怪您。此代码是免费且无义务的。但我强烈建议您不要以未经修改的形式将其提交到 Windows Phone Marketplace。

Screenshot

我显然还没有花任何精力来使这个程序的界面美观。这篇帖子完全是关于功能的,而且由于我免费提供代码,我不想在我的图形设计师身上投入太多,结果只把图像资产白白送出去。如上所述,如果您使用此代码,您可以自行应用自己的图形。既然我已经完成了这个程序,我计划下周对其进行更多更改(首要任务:让程序看起来更美观!)。

我需要什么?

您需要使用的唯一软件是一台 Windows PC 以及来自 http://developer.windowsphone.com 的 Windows Phone 开发人员工具(免费下载!)。我正在使用 Visual Studio 2010 Ultimate。但开发者工具中的 Express 版本同样适用。

决定功能集

在开始开发应用程序之前,我坐下来列出了我希望应用程序具备的功能并对其进行了优先级排序。按任意顺序,我考虑过的一些事情包括:

  • 导出录音的能力
  • 以 WAV 格式保存录音
  • 为录音添加注释
  • 加速或减慢录音速度
  • 更改声音
  • 合并、分割和编辑录音
  • 导出为 MP3
  • 对备忘录进行分类
  • 按时/日期激活的提醒

如您所见,语音备忘录应用程序可以添加很多不同的功能。它很快就可以从简单的东西发展到复杂的东西。我没有使应用程序过于复杂,而是选择了一个最小的功能集,以便能够完成一个主要目标,即实际制作出一些可以交付的东西,并且足够简单,这样我就不会有很多可能出现错误的潜在地方。精简后的功能集如下:

  • 以 WAV 格式保存录音
  • 按日期或名称排序录音
  • 在锁屏下录音
  • 为录音添加注释

这是一个简单的开始,以后可以在此基础上添加其他功能。

从 Silverlight 应用程序中使用 XNA 类

在 Windows Phone 上,您可以创建两种类型的应用程序:一种使用 Silverlight 来构建用户界面,另一种则使用 XNA 渲染类来构建用户界面。您必须专门使用一种 UI 显示层,而不能同时使用。您无法从 Silverlight 应用程序中使用 XNA 渲染类,反之亦然。Silverlight 提供了许多控件,可以用于从设计器构建应用程序的用户界面,例如按钮、文本框、标签等。在 XNA 中,您负责为显示信息构建自己的解决方案。因此,我在此应用程序中使用了 Silverlight UI。

要录制音频,我必须使用 Microsoft.Xna.Framework.Audio 中的 Microphone 类。虽然您无法在 Silverlight 应用程序中使用 XNA 渲染类,但可以使用许多其他 XNA 类。使用与音频相关的 XNA 类需要定期调用 FrameworkDispatcher.Update()。您可以利用 Microsoft 提供的一个示例 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 类录制音频

互联网上有很多关于如何在 WP7 上录制音频的示例。不幸的是,其中许多也包含相同的错误。在展示录制实现的实现代码之前,我想通过视觉方式说明录制是如何工作的,以便也能展示这个错误。

Microphone 类会分块录制音频,并将每个块传递回您的程序,同时继续录制下一个块。为此,Microphone 类有自己的内存缓冲区,它会填充此缓冲区。假设您正在录制短语“The quick brown fox jumped over the lazy dog。”。现在,我们还假设麦克风的缓冲区一次只能录制一个词(在现实生活中,事情通常不会如此干净利落地完成,但我请求您暂时搁置您应用此想法的能力)。

Visual representation of the relationship between the microphone, it's buffer, and your program

麦克风、其缓冲区和您的程序

您开始说短语,麦克风缓冲区就充满了您说“the”这个词的声音。

The microphone has recorded the word 'the' but hasn't yet passed it to the program

一旦缓冲区满了,它就会被传递给程序,麦克风开始用下一个要录制的词填充新缓冲区。程序接收缓冲区,并有机会对其进行处理。由于此程序用于保存和重放录制的音频片段,因此程序将保存音频块,并等待下一个块附加到前一个块之后。

the program has received the first word and is currently recording the second

程序在录制第二个词时已收到第一个词。

每当录制完一个块时,它就会被传递给程序,程序将其附加到它已经接收到的块中。许多在线示例中存在的错误发生在用户说出最后一个词时。

the program has received the first word and is currently recording the second

在许多在线示例中,当用户说出“dog”这个词并按下“停止”按钮时,程序就会停止接收来自麦克风的进一步信息。但是最后一个词还没有从麦克风缓冲区传递到程序!最终结果是程序收到了除最后一个词以外的所有内容。为了避免这个问题,应该发生的情况是:当用户停止录音机时,程序不应立即停止,而应等到接收到最后一个缓冲区后再停止。在最坏的情况下,句子结束后可能还有一些声音也被录制下来,但这比丢失数据要好。可以通过减小缓冲区大小来减少捕获的额外数据量。

创建执行上述操作的代码非常简单。要获取 Microphone 类的实例,我们只需从 Microphone.Current 获取。当麦克风正在录制时,它会通过引发 BufferReady 事件来通知我们的程序缓冲区已准备好读取。发生这种情况时,我们可以通过调用 GetBuffer(byte[] destination) 来获取缓冲区数据。对于此方法,我们必须传递一个用于接收数据的字节数组。这个缓冲区需要多大?Microphone 类还有两个其他成员可以帮助我们确定所需的大小。Microphone.BufferDuration 将告诉我们麦克风缓冲区可以存储多少秒,而 Microphone.GetSampleSizeInBytes(Timespan ) 方法将告诉我们特定长度的录音需要多少字节。将两者结合起来,我们可以通过 Microphone.GetSampleSizeInBytes(Microphone.BufferDuration) 找到所需的缓冲区大小。一旦您有了 Microphone 类的实例,订阅了 BufferReady 事件,并且创建了用于接收数据的缓冲区,就可以通过调用 Microphone.Start() 来开始录制过程。

BufferReady 的事件处理程序中,有几件事需要完成。从缓冲区检索数据后,需要将其累积在某个地方。累积数据后,我们需要检查是否已发出停止录制的请求。如果是,则使用 Microphone.Stop() 命令 Microphone 实例停止通过数据传输,并执行任何必要的持久化录制的操作。对于累积数据,我将使用内存流,然后在录制完成后将其写入隔离存储。我的一个要求是音频数据以 WAV 格式保存。此要求通过在写入所有接收到的字节之前写入正确的 wave 头来满足。在此处不再赘述如何做到这一点,我将引用我之前关于此主题的博客文章。我执行上述所有操作的代码如下:

public 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 用于此字段。但将来,我可能会添加导入录音(可能是立体声)的功能,因此我将从 Wave 头部提取此数据。同样,我也可以从 Microphone 类获取采样率,而不是从 Wave 头部获取。但为了我将来考虑的事情,我也将从头部获取它。Wave 数据本身是头部之后的所有内容。一旦 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; }
}

我提供了一个简化视图,以使类易于阅读。此类需要是可序列化的,以便我能够从隔离存储中读取和写入它。因此,该类用 [DataContract] 属性装饰,属性用 [DataMember] 属性装饰。我还计划将此类实例绑定到 UI 元素。因此,此类需要实现 INotifyPropertyChanged 接口。此类的版本如下。它看起来没有那么多的输入。我使用 Visual Studio Snippets 来自动化生成部分代码。

[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 Data
var myDataSaver = new DataSaver<RecordingDetails>() {};
myDataSaver.SaveMyData(LastSelectedRecording, 
                       LastSelectedRecording.SourceFileName);

//Loading Data
var 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;
}

保存状态和墓碑化

您的程序可能会随时被中断,例如来电,或者用户为了进行搜索或其他操作而离开。发生这种情况时,您的应用程序将进入墓碑化状态;操作系统将保存用户所在的页面,并让您的程序有机会保存其他数据。当程序重新加载时,开发人员必须确保采取措施正确重新加载状态。在大多数情况下,我不需要担心墓碑化,因为程序的大部分状态数据都会及时持久化到隔离存储中。而且需要保存的状态数据不多;录音会立即提交,以及程序设置的更改。如果您想了解更多关于墓碑化的信息,我强烈建议您不要将此作为探索它的资源。

现在怎么办?

Marketplace 中有许多备忘录录音机。为什么要再做一个?有其他与声音相关的应用程序可以利用此代码中实现的功能。语音备忘录录音机不是我的最终目标。我的最终目标实际上不是单一的,有很多应用程序可以从这段代码中派生出来。目前,我预计由此应用程序产生的源代码演化如下。

Source Phylogeny

潜在派生应用程序的源代码演化。

不要过于字面地理解这张图。它只是为了说明一个概念。但如您从上图所示,我可以采用这段代码,进行一些更改,并产生具有不同目的的东西。如果我在程序中添加了对录音进行转换的功能,那么我将拥有一个变声器。通过一些傅里叶分析和其他代码,我可以产生一个可以从录音中打印乐谱的东西(注意:我在上面称之为转录器,但我的术语可能不准确)。

准备认证

认证可能需要几个小时到几天不等。准备应用程序进行认证所需的最少文件是包含您的应用程序的 XAP(请记住执行发布构建!),至少一张截图,以及几张不同尺寸的图标(200x200、173x173 和 99x99 像素)。我不会在这里介绍认证过程,但将在后续文章中详细介绍。在等待认证期间,您可以通过在您的网站上准备一个宣传页面来消磨时间。有一些标准的图片可以引导用户访问 Marketplace。您可以在此处下载这些图片,它们有各种尺寸、颜色和语言。

您的应用程序通过认证后,您就可以看到其直接链接。就此应用程序而言,它是 http://social.zune.net/redirect?type=phoneApp&id=268c6119-d755-e011-854c-00237de2db9e。结合图像,我得到了一个可识别的下载链接,可以放在宣传页面上。

download image

下一步是什么?

我将此代码作为示例发布。从这里开始,我将改进我自己的应用程序版本,并且我可能不会更新本文中的版本,除非进行小的 bug 修复。

历史

  • 2010 年 3 月 31 日 - 首次发布。
© . All rights reserved.