windows phone开发学习--录音

在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"的声音。
windows phone开发学习--录音_第1张图片

一旦缓冲区满了,它就被传送到程序中,同时麦克风开始用下一个记录的单词填充一个新的缓冲区。程序接收到缓冲区并能够对其进行处理。考虑到程序是为了保存和播放录音,程序将会保存音频组块并且等待下一个组块以便拼接在前一个的后面。(注:这里buffer翻译为缓冲区,不过感觉有点别扭,将就看了)
windows phone开发学习--录音_第2张图片
图3: 程序接收到第一个单词,同时第二个单词被记录

当每一个组块被记录后,它会被传递到程序中,程序把该组块拼接在它已经收到的组块之后。不过当用户说完最后一个单词时,这里会产生BUG
windows phone开发学习--录音_第3张图片

在很多网上的例子中,当用户说完最后一个单词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 file
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作为参数。但在以后,我会加入导入录音的功能(录音可能是立体声),所以我把这个数据从波头上取下。类似的,我也可以从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 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;
}

保存状态和墓碑化

你的程序可以在任何时间被中断,例如一个电话,一个突然跳出程序进行的搜索等等。当这一切发生时,你的应用会被墓碑化;操作系统将会保存用户所在的页面并且给程序保存其他数据的机会。当程序被再次载入时,开发者必须确保采取适当步骤重新加载状态。大多数情况下,我并不需要担心墓碑化因为程序大部分状态数据被迅速保存到隔离存储中。这里也没有多少需要保存的状态数据,因为录音随着程序设置的改变会被立即执行。


下面内容不再翻译,无非是作者提出自己想增加应用功能的说明。最后给出源码下载地址和应用截图:

windows phone开发学习--录音_第4张图片


下载地址:http://www.codeproject.com/KB/windows-phone-7/WpVoiceMemo/WpVoiceMemo.zip

你可能感兴趣的:(windows phone开发学习--录音)