一触即发

使 Windows Phone 成为乐器

Charles Petzold

下载代码示例

Charles Petzold
每个 Windows Phone 都具有一个内置扬声器和一个耳机插孔,如果仅将其用于拨打电话,的确有些大材小用。幸运的是,Windows Phone 应用程序还可以使用手机中的音频设施播放音乐或其他声音信号。正如我在本专栏最近一期中所讲述的,Windows Phone 应用程序可以播放用户音乐库中存储的 MP3 或 WMA 文件,或者从 Internet 上下载的文件。

Windows Phone 应用程序还可以动态生成音频波形,这项技术称为“音频流式处理”。这是一项数据极其密集的活动: 为获得 CD 品质的声音,您需要按每秒 44,100 个样本的速率为左右声道同时生成 16 位样本,或者每秒生成多达 176,400 个字节!

但音频流式处理是一项强大的技术。如果将其与多点触控技术相结合,则可以将您的手机变成电子乐器,还有什么比这更有趣的吗?

构思一个 Theremin

Theremin 是由俄国发明家 Léon Theremin 于二十世纪二十年代制作的最简单的电子乐器之一。Theremin 的演奏者实际上并不接触该乐器。而是演奏者的双手相对于两根天线来回移动,以便分别控制声音的音量和音高。这会产生一种从一个音符滑向另一个音符的奇怪且颤抖的哀号音—我们在电影“爱德华大夫”、“地球停转之日”、摇滚乐队演奏的某些音乐以及“生活大爆炸”第 4 季第 12 集中会听到这样的声音。(与大众的看法相反,Theremin 并没有用于演奏“星舰迷航记”的主题曲。)

能否将 Windows Phone 变成手持式 Theremin?这正是我的目标。

古典 Theremin 通过外差技术发出声音,该项技术可将两个高频波形进行组合,从而产生相应音频范围的不同音调。但如果在计算机软件中生成波形,则该项技术有些不切实际。直接生成音频波形更有意义。

在突发通过手机方向控制声音,或使程序通过手机的摄像头查看和解释手势的想法后,我决定采用更实际的方法: 手机屏幕上的手指是二维坐标点,这样,程序便能够将一个轴用于频率,另一个轴用于振幅。

以智能方式实现此操作需要一些关于我们如何感知乐声的知识。

像素、音高和振幅

多亏了 Ernst Weber 和 Gustav Fechner 在十九世纪的创举,我们才知道,人类的感知是对数级的,而不是线性的。对于刺激大小的增量线性变化,我们的感知是不同的。但我们对与大小成比例的变化的感知是相同的,为了方便起见,我们通常将其称为微量增加或微量减少。(此现象超出我们的感觉器官。例如,我们会认为 1 美元与 2 美元之间的差异要比 100 美元与 101 美元之间的差异大得多。)

人类对频率大约在 20Hz 和 20,000Hz 之间的声音较为敏感,但我们对频率的感知不是线性的。在许多文化中,音调围绕八度音构建,后者是频率的翻倍。唱“Somewhere Over the Rainbow”时,第一个词的两个音节相差八度,无论其中的跳跃是从 100Hz 到 200Hz,还是从 1,000Hz 到 2,000Hz。因此,人类听觉的范围大约为 10 个八度音。

八度音之所以被称作八度音,是因为在西方音乐中,它包含 8 个字母音符的音阶,其中最后一个音符是高于第一个音符的八度音: A、B、C、D、E、F、G、A(称为小音阶)或 C、D、E、F、G、A、B、C(大音阶)。

鉴于这些音符的衍生方式,在感知上,它们彼此之间的距离并不相等。所有音符都等距的音阶还需要 5 个音符,共计 12 个(第一个音符并不计数两次): C、C#、D、D#、E、F、F#、G、G#、A、A# 和 B。其中每个音级都称为半音程,如果它们的间隔相同(当它们在常见的平均律分律中),则每个音符的频率都是它下面的音符的频率的两倍的十二次方根(约 1.059)。

这些半音程可进一步分为 100 森特。八度音有 1,200 森特。森特之间的倍增音级为 2 的 1,200 次方根 (1.000578)。当然,人类对频率变化的敏感度差别很大,但通常被引证为约 5 森特。

了解音乐的物理和数学背景很有必要,因为 Theremin 程序需要将手指的像素位置转换为频率。完成这种转换后,每个八度音才会与同等数量的像素相对应。如果我们确定 Theremin 的 4 个八度音范围在横向模式中与 Windows Phone 屏幕的 800 像素长度相对应,则每八度音 200 像素,或每像素 6 森特,这与人类感知的限制极为相符。

波形的振幅确定我们感知音量的方式,而这也是对数级的。分贝被定义为两种功率级别的比率的以 10 为底的对数的 10 倍。由于波形的功率是振幅的平方,因此两个振幅之间的分贝差异为:

CD 音频使用 16 位样本,使最大振幅和最小振幅之间的比率为 65,536。采用 65,536 的以 10 为底的对数乘以 20,便会得到 96 分贝范围。

一分贝大约增加振幅的 12%。人类对振幅变化的感知敏感度远不如对频率的敏感度。人们需要几分贝才能注意到音量的变化,所以可在 Windows Phone 屏幕的 480 像素尺寸上轻松实现这一点。

使之变为现实

本文的可下载代码是一个名为 MusicalInstruments 的 Visual Studio 解决方案。Petzold.MusicSynthesis 项目是一个 DLL,主要包括我在上月的本专栏期刊 (msdn.microsoft.com/magazine/hh852599) 中讨论的文件。Theremin 应用程序项目包含一个横向页面。

Theremin 应生成哪种类型的波型?从理论上讲,它是正弦波,但事实上它是有点变形的正弦波,如果您尝试在 Internet 上搜索此问题,则不会找到太多议论。对于我的版本,我坚持使用正弦波,这样似乎更合理。

图 1 所示,MainPage.xaml.cs 文件定义多个常量值,并计算控制显示的像素与音符相对应的方式的两个整数。

图 1 Theremin 的振幅和频率计算

public partial class MainPage : PhoneApplicationPage
{
  static readonly Pitch MIN_PITCH = new Pitch(Note.C, 3);
  static readonly Pitch MAX_PITCH = new Pitch(Note.C, 7);
  static readonly double MIN_FREQ = MIN_PITCH.Frequency;
  static readonly double MAX_FREQ = MAX_PITCH.Frequency;
  static readonly double MIN_FREQ_LOG2 = Math.Log(MIN_FREQ) / Math.Log(2);
  static readonly double MAX_FREQ_LOG2 = Math.Log(MAX_FREQ) / Math.Log(2);
  ...
double xStart;      // The X coordinate corresponding to MIN_PITCH
  int xDelta;         // The number of pixels per semitone
  void OnLoaded(object sender, EventArgs args)
  {
    int count = MAX_PITCH.MidiNumber - MIN_PITCH.MidiNumber;
    xDelta = (int)((ContentPanel.ActualWidth - 4) / count);
    xStart = (int)((ContentPanel.ActualWidth - count * xDelta) / 2);
    ...
}
  ...
double CalculateAmplitude(double y)
  {
    return Math.Min(1, Math.Pow(10, -4 * (1 - y / ContentPanel.ActualHeight)));
  }
  double CalculateFrequency(double x)
  {
    return Math.Pow(2, MIN_FREQ_LOG2 + (x - xStart) / xDelta / 12);
  }
  ...
}

范围是从中央 C 下的 C(频率约为 130.8Hz)到中央 C 上的 C 三个八度音(约为 2,093Hz)。两种方法根据从 Touch.FrameReported 事件获取的触摸点的坐标计算频率和相对振幅(范围从 0 到 1)。

如果只使用这些值来控制正弦波振荡器,则听起来根本不像 Theremin。手指在屏幕上移动时,该程序不会获取沿途每个像素的事件。您会听到非常分散的音级,而不是非常平滑的频率滑音。为了解决这个问题,我创建了一个专门的振荡器类,如图 2 所示。此振荡器继承 Frequency 属性,但另外定义了三个属性: Amplitude、DestinationAmplitude 和 DestinationFrequency。使用倍增因子,振荡器本身可提供滑音。该代码无法真的预测手指的移动速度,但在多数情况下它似乎运行正常。

图 2 ThereminOscillator 类

public class ThereminOscillator : Oscillator
{
  readonly double ampStep;
  readonly double freqStep;
  public const double MIN_AMPLITUDE = 0.0001;
  public ThereminOscillator(int sampleRate)
    : base(sampleRate)
  {
    ampStep = 1 + 0.12 * 1000 / sampleRate;     // ~1 db per msec
    freqStep = 1 + 0.005 * 1000 / sampleRate;   // ~10 cents per msec
  }
  public double Amplitude { set; get; }
  public double DestinationAmplitude { get; set; }
  public double DestinationFrequency { set; get; }
  public override short GetNextSample(double angle)
  {
    this.Frequency *= this.Frequency < this.DestinationFrequency ?
freqStep : 1 / freqStep;
    this.Amplitude *= this.Amplitude < this.DestinationAmplitude ?
ampStep : 1 / ampStep;
    this.Amplitude = Math.Max(MIN_AMPLITUDE, Math.Min(1, this.Amplitude));
    return (short)(short.MaxValue * this.Amplitude * Math.Sin(angle));
  }
}

图 3 显示了 MainPage 类中的 Touch.FrameReported 事件的处理程序。 手指首次在屏幕上触摸时,会将振幅设置为最小值,所以音量加大。 释放手指时,声音会逐渐消逝。

图 3 Theremin 中的 Touch.FrameReported 处理程序

void OnTouchFrameReported(object sender, TouchFrameEventArgs args)
{
  TouchPointCollection touchPoints = args.GetTouchPoints(ContentPanel);
  foreach (TouchPoint touchPoint in touchPoints)
  {
    Point pt = touchPoint.Position;
    int id = touchPoint.TouchDevice.Id;
    switch (touchPoint.Action)
    {
      case TouchAction.Down:
        oscillator.Amplitude = ThereminOscillator.MIN_AMPLITUDE;
        oscillator.DestinationAmplitude = CalculateAmplitude(pt.Y);
        oscillator.Frequency = CalculateFrequency(pt.X);
        oscillator.DestinationFrequency = oscillator.Frequency;
        HighlightLines(pt.X, true);
        touchID = id;
        break;
      case TouchAction.Move:
        if (id == touchID)
        {
           oscillator.DestinationFrequency = CalculateFrequency(pt.X);
           oscillator.DestinationAmplitude = CalculateAmplitude(pt.Y);
           HighlightLines(pt.X, true);
        }
        break;
      case TouchAction.Up:
        if (id == touchID)
        {
          oscillator.DestinationAmplitude = 0;
          touchID = Int32.MinValue;
          // Remove highlighting
          HighlightLines(0, false);
        }
        break;
      }
    }
}

正如代码所示,Theremin 程序只生成一个音调,并且会忽略多个手指。

尽管 Theremin 频率经常发生变化,但屏幕显示的是指示分散音符的线条。 这些线条以红色表示 C,蓝色表示 F(琴弦所用的颜色),白色表示本位音,灰色表示临时符(高半音符号)。 使用该程序一会后,我确定它需要一些指示手指真正位于哪个音符上的可视反馈,所以我根据其离触摸点的距离加宽了这些线条。 图 4 为手指介于 C 和 C# 之间但离 C 更近时的显示。

The Theremin Display
图 4 Theremin 显示

延迟和失真

基于软件的音乐合成的一大问题是延迟 - 用户输入和声音的后续变化之间的延迟。 这几乎是不可避免的: Silverlight 中的音频流要求应用程序从 MediaStreamSource 派生,并覆盖 GetSampleAsync 方法,从而根据需要通过 Memory­Stream 对象提供音频数据。 在内部,此音频数据保留在缓冲区中。 此缓冲区的存在可确保声音在播放时没有任何令人不安的间隔,但当然,始终需要填充缓冲区之后才能播放缓冲区。

幸运的是,MediaStreamSource 定义了一个名为 AudioBufferLength 的属性,它指示声音的缓冲区大小(毫秒)。 (此属性受保护,只能在打开媒体之前在 MediaStreamSource 衍生项中设置。) 默认值为 1,000(或 1 秒),但可以将其设置为最低值 15。 降低设置的值会增加 OS 和 MediaStreamSource 衍生项之间的交互,并可能听到断续声。 但是,我发现最低设置 15 似乎是令人满意的。

另一个潜在问题是无法制成数据。 您的程序每秒需要生成上万或十几万字节,如果无法有效实现这一点,则声音会开始断断续续,您会听到许多噼啪声。

有几种方法可以修复这个问题: 您可使您的音频生成管道更有效(我将稍后讨论),您也可以降低采样率。 我发现 CD 采样率为 44,100 对我的程序过大,所以我将它降低为 22,050。 还可能需要将它进一步降低至 11,025。 在许多不同的 Windows Phone 设备上测试您的音频程序始终是个好的做法。 在商业产品中,您可能需要向用户提供降低采样率的选项。

多个振荡器

合成器库的 Mixer 组件具有将多个输入汇编到复合左通道和右通道的作业。 该作业非常简单,但请记住,每个输入都是一个 16 位振幅的波形,输出也是 16 位振幅的波形,所以必须根据输入数量削减输入。 例如,如果 Mixer 组件有 10 个输入,则必须将每个输入削减为其原始值的十分之一。

其中包括深刻含义: 在播放音乐期间,如果不增加或减少剩余输入量,则无法添加或移除 Mixer 输入。 如果需要可同时播放 25 种不同声音的程序,则您将需要 25 个常量 Mixer 输入。

对于 MusicalInstruments 解决方案中的 Harp 应用程序,情况就是这样。 我设想一种带弦乐器,我可以使用指尖来弹奏,但我还可弹敲来获得常见的竖琴滑音。

图 5 所示,它从外观上与 Theremin 非常相似,但只有两个八度音,而不是四个八度音。 临时符(高半音符号)的弦位于上方,本位音位于下方,在某些方面与称为交叉弦竖琴的竖琴类型相似。 您可演奏五音阶滑音(在上方)、半音阶滑音(在中间)或全音阶滑音(在下方)。

The Harp Program
图 5 Harp 程序

为了获得真实的声音,我使用了 SawtoothOscillator 类的 25 个实例,从而生成了与弦音非常相似的简单锯齿波形。 还需要制作一个基础包络发成器。 在真实生活中,乐音不会立即开始和停止。 声音需要一段时间开始,然后可能逐渐消逝(如钢琴或竖琴),或在音乐家停止演奏后逐渐消逝。 包络生成器可控制这些变化。 我不需要和成熟的上冲-衰退-维持-释放 (ADSR) 包络一样复杂的内容,所以我创建了一个较为简单的 AttackDecayEnvelope 类。 (在真实生活中,声音的音质 - 由其谐波组件控制 - 也会在单个音调的持续时间中发生变化,所以音质也应由包络发生器控制。)

为了获得可视反馈,我决定我要让琴弦振动。 每根琴弦实际都是一个二阶贝塞尔线段,中央控制点与两个端点在一条直线上。 通过将重复的 PointAnimation 应用于控制点,我可以使琴弦振动。

实际上,这是个灾难。 振动看起来很好,但声音恶化为许多噼啪声,极其难听。 我想寻找好一点的办法: 我使用了 DispatcherTimer,并以比真实动画更慢的速率手动偏移这些点。

使用 Harp 程序播放一会后,我对拨拉琴弦需要不断移动手势非常不满,所以我添加了一些代码,只需要轻拍一下即可触发声音。 那时,我可能应将程序的名称从 Harp 更改为 HammeredDulcimer,但我保留原样。

避免浮点

在我进行大部分开发时使用的 Windows Phone 设备上,Harp 运行正常。 在另一 Windows Phone 上,它发出许多噼啪声,表明无法足够快地填充缓冲区。 此分析已通过将采样率减半得到确认。 采样率为 11,025Hz 时,噼啪声停止,但我不情愿牺牲音质。

相反,我开始仔细查看每秒提供成千上万样本的管道。 这些类(Mixer、MixerInput、SawtoothOscillator 和 AttackDecayEnvelope)都有一个共同点: 它们在计算这些样本时都以某种方式使用浮点运算。 转换为整数计算是否有助于加快此管道的速度,从而使情况发生改变?

我重新编写了我的 AttackDecayEnvelope 类以使用整数运算,然后对 SawtoothOscillator 执行同样操作,如图 6 所示。 这些更改显著提高了性能。

图 6 SawtoothOscillator 的整数版本

public class SawtoothOscillator : IMonoSampleProvider
{
  int sampleRate;
  uint angle;
  uint angleIncrement;
  public SawtoothOscillator(int sampleRate)
  {
    this.sampleRate = sampleRate;
  }
  public double Frequency
  {
    set
    {
      angleIncrement = (uint)(UInt32.MaxValue * value / sampleRate);
    }
    get
    {
      return (double)angleIncrement * sampleRate / UInt32.MaxValue;
    }
  }
  public short GetNextSample()
  {
    angle += angleIncrement;
    return (short)((angle >> 16) + short.MinValue);
  }
}

在使用浮点的振荡器中,angle 和 angle­Increment 变量是 double 类型,其中 angle 的范围为 0 至 2π,angleIncrement 的计算方法如下:

对于每个采样,angle 按 angleIncrement 递增。

我未从 SawtoothOscillator 中完全消除浮点。 公共频率属性仍定义为 double,但只在设置振荡器频率时使用。 Angle 和 angleIncrement 均是不带符号的 32 位整数。 当 angleIncrement 增加 angle 的值时,会使用完整的 32 位值,但只将最前面 16 位用作计算波形的值。

即使进行这些更改,程序仍无法在被我视为“慢手机”(与我的“快手机”相比)上正常运行。手指在整个屏幕上快速移动时,仍会发出一些噼啪声。

但在任何乐器上发生的现象也会发生在电子乐器上: 您需要熟悉乐器,并了解其功能和限制。

Charles Petzold 是《MSDN 杂志》的长期供稿人。 他的网站是 charlespetzold.com

衷心感谢以下技术专家对本文的审阅:Mark Hopkins