×
Namespaces

Variants
Actions

在Windows Phone上制作乐谱浏览器

From Nokia Developer Wiki
Jump to: navigation, search
WP Metro Icon Multimedia.png
SignpostIcon WP7 70px.png
Article Metadata

代码示例
兼容于
文章
WS_YiLunLuo 在 19 Jul 2012 创建
最后由 hamishwillee 在 16 Jul 2013 编辑

Contents

简介

作为一款针对消费者的手机,上面的程序当然要好玩。今天,我们就来制作一款好玩的程序:一个乐谱浏览器。它可以让你查看一首歌的五线谱,你可以想象一下当歌曲播放的同时,五线谱也会跟着前进。注意因为我们找不到免费的歌曲,所以本文的示例不会提供播放音乐的功能。你可以在本文的示例的基础上进行修改,添加更多的功能。

MusicView.png

上面截图中黄色的那格代表目前音乐进行到了哪一个位置。

本文使用XAML,本文提供的代码虽然是针对Windows Phone开发的,但是也可以比较简单地迁移到Windows 8 Metro程序。你可以自File:WPMusicView.zip下载本文提供的示例程序。

五线谱介绍

很多时候程序员不能够只会编程,还要了解一些和目前场景相关的知识。例如,本文要介绍如何显示五线谱,所以自然大家首先要对五线谱有一定的认识。如果你忘记了小学里的音乐课上学到的内容,希望接下来这一小段的介绍会有所帮助。我们也顺便介绍一点点和声音相关的物理知识。因为是科普级别的介绍,所以就不给任何公式了。

音乐中有一个叫音符的概念。音符包括音阶,也就是大家所熟悉的do re me fa so la si,等等。如果用稍微专业一点的说法,它们被称作C D E F G A B,其中C对应着do,B对应着si。我也不知道为什么A和B会在G的后面,估计是历史原因吧。

有很多很多的音阶,它们是和音波的频率有关的。大家知道,声音是一种机械波,它的频率越高,给我们的感觉这个声音也就越高(这里第二个高指的是高音低音的高)。如果频率太低或者太高,人的耳朵就听不出来了(但是有些其它动物可以听出来),这种音波通常被称作超声波。人类要想唱出高音,就要发声发得快。毕竟频率值的就是每秒振动的次数。或者,你也可以尝试以较快的速度接近目标,因为当你接近目标的时候,波的波长会变小,相应的,频率就会变高,这一点被称作多普勒效应。

事实上只要音波的频率稍微有点不同,就可以认为是两个不同的音阶。不过如果频率差别不是太大,通常人类的耳朵是区分不清的。因此音乐上仅仅定义了C D E F(do re me fa)等音阶。有时候在这些音阶中还会加入半音,对应着钢琴上的黑键。

在五线谱中,最常见的乐谱叫做C大调,也就是说以C(do)为基准。如果你看到一个高音谱号右边没有其它符号,就说明这是标准的C大调。在C大调中,C的位置在于下加一线(五线谱的五条线最下面那条再往下加出一条线)。D(re)的位置在于下加一间(最下面那条线的下方)。E(mi)的位置在第一线(贯穿最下面那条线)。等等。

除了C大调,G大调也比较常见。如果你看到高音谱号右边有一个#,就说明这是G大调。G大调以G(so)为基准,因此C大调中C的位置被G取代了。同理,F大调(高音谱号右边有一个b)以F为基准。为了方便,本文只考虑C大调。

除了音阶,还有一个比较重要的概念是音长,也就是每个音符要唱多少时间。音长的单位叫做拍。如果一个音符的音长为一拍,在五线谱上就在对应的音阶位置上画一个实心的圆,然后从圆出发,向上或者向下画一条竖线(低于高音do的通常向上画,从高音do开始通常向下画)。如果音长为两拍,就用一个空心圆取代实心圆。如果是四拍,不仅仅用空心圆,连竖线都不用画。此外,如果是半拍,就是在一拍的基础上加一条曲线,从竖线的端点开始画。

除了这些规则,五线谱还有不少琐碎的规则,例如两个半拍连在一起需要加一条横线将它们连接起来,附点表示两个拍子是连起来的,前一拍的音长占3/4,后一拍占1/4,根据2 4拍,3 4拍等规则,每隔几拍需要画一条竖线,等等。本文的示例代码为了简单,一律不考虑这些规则。我们连高音谱号都不画,仅仅画出最基本的几条规则。若是你想让你的程序显得更专业,可以在示例的基础上进行修改,符合更多五线谱标准。

除了五线谱,还有一些其它的常用的乐谱,例如吉他用的六线谱,简谱(用1到7代表C到B),等等,我们就不再详细讨论了。

程序的思路

你也许会觉得制作一个显示五线谱的程序并不是一件简单的事。事实上,如果你使用immediate rendering mode,例如DirectX或者HTML canvas,确实可能要花很多时间和精力。但是Windows Phone以及Windows 8的XAML框架提供了非常强大的数据绑定的功能,因此,我们不需要自己绘制图形,只需要使用一个ListBox,将它绑定到一组数据源,再修改它的ItemTemplate,就可以显示出五线谱啦!是真的,就好像魔法一般,只要一个ListBox,就可以制作出五线谱!这正是XAML强大的地方之一。不相信的话,就再往下看吧。

数据源

首先,我们必须为乐谱准备好数据源。我们来创建一个名为Note的类,用它代表每一个音符。这个类可以实现标准的INotifyPropertyChanged接口,从而当它的属性改变时,UI会得到通知。这个类只需要两个属性:Scale代表音阶,Length代表音长。

    public class Note : INotifyPropertyChanged
{
public double Scale
{
get
{
return this._Scale;
}
set
{
this._Scale = value;
this.OnPropertyChanged("Scale");
}
}
 
private double _Scale;
 
public double Length
{
get
{
return this._Length;
}
set
{
this._Length = value;
this.OnPropertyChanged("Length");
}
}
 
private double _Length;
 
public event PropertyChangedEventHandler PropertyChanged;
 
protected virtual void OnPropertyChanged(string property)
{
if ((this.PropertyChanged != null))
{
this.PropertyChanged(this, new PropertyChangedEventArgs(property));
}
}
}

当然,通常你的数据来自于云端,所以可能仅仅两个属性是不够用的。你可能还需要诸如ID之类的属性。不过为了简单,本文就只使用两个属性。

接下来,我们需要一个view model类,这是MVVM设计模式推荐的做法。在view model中,我们可以通过云服务获取一首歌的乐谱数据。但是为了简单,本文就在Windows Phone客户端直接创建一些数据了。

    public class SongViewModel : INotifyPropertyChanged
{
private Dictionary<double, int> _timeNoteMap = new Dictionary<double, int>();
public event PropertyChangedEventHandler PropertyChanged;
 
public SongViewModel()
{
this.LoadNotes();
}
 
public void LoadNotes()
{
this.Notes = new List<Note>();
this._timeNoteMap.Clear();
this.Notes.Add(new Note() { Scale = 1, Length = 1 });
this.Notes.Add(new Note() { Scale = 8, Length = 1 });
this.Notes.Add(new Note() { Scale = 7, Length = 0.5 });
this.Notes.Add(new Note() { Scale = 6, Length = 3 });
//…
 
double time = 0;
for (int i = 0; i < this.Notes.Count; i++)
{
Note note = this.Notes[i];
this._timeNoteMap[time] = i;
time += note.Length;
}
}
 
public int? GetCurrentNoteIndex(double time)
{
int index = 0;
if (this._timeNoteMap.TryGetValue(time, out index))
{
return index;
}
return null;
}
 
private void NotifyPropertyChange(string propertyName)
{
if (this.PropertyChanged != null)
{
this.PropertyChanged(this, new PropertyChangedEventArgs(propertyName));
}
}
 
private List<Note> _notes;
public List<Note> Notes
{
get { return this._notes; }
set
{
if (this._notes != value)
{
this._notes = value;
this.NotifyPropertyChange("Notes");
}
}
}
}

上面的代码使用一个List<Note>存储这首歌所有的音符,有Dictionary将每个音符时间和在那个list中的index对应了起来,这样一来,在GetCurrentNoteIndex中就可以根据时间查询这一时刻所对应的音符了。至于LoadNotes方法,我们简单地hard code了一些音符。在你自己的程序中,通常这些音符的数据是存储在云上的,你需要从云上将数据下载下来,存储到List<Note>中。

这样一来,数据源就准备完成了。可以看到,我们所做的无非就是通常的数据绑定程序中所要做的事,这和创建一个数据模型对应一组商品的集合(常见的买卖东西的程序),一组对话记录的集合(常见的聊天程序),等等,是非常类似的,并没有什么特别的。真正的重点在于如何显示这些数据。

使用ListBox

大家一定使用过ListBox显示数据吧,刚才我们说的显示一组商品,或者一组对话记录,都是常见的例子。同样,我们也可以用ListBox来显示一组音符,进而展示完整的乐谱。

为了方便,让我们创建一个名为StaffViewer的UserControl。在这里定义一个ListBox:

        <ListBox x:Name="noteList" ItemsSource="{Binding Notes}" Style="{StaticResource SimpleListBoxStyle}">
<ListBox.ItemsPanel>
<ItemsPanelTemplate>
<StackPanel Orientation="Horizontal"/>
</ItemsPanelTemplate>
</ListBox.ItemsPanel>
</ListBox>

这里我们将ListBox的数据源设定为view model中的Notes列表,并且将ItemsPanel设置为一个水平的StackPanel,从而可以从左往右显示音符(默认是从上往下的)。此外,我们还设置了一个Style,在这个Style中,我们设置ListBox以及其中的item的背景为渐变的蓝色,选中项目的背景为渐变的黄色。这些全都可以使用Expression Blend设计出来。由于代码较长,就不在这里列出了。

到了这里,一切还是和显示一组商品或者一组对话记录没什么两样,我们甚至可以尝试创建一个简单的ItemTemplate,用来显示每个音符的Scale(音阶):

        <DataTemplate x:Key="SimpleDataTemplate">
<TextBlock Text="{Binding Scale}" VerticalAlignment="Center"/>
</DataTemplate>

目前为止的显示效果如下:

TestView.png

请注意这无非就是一个横向显示的ListBox,每一项都是一个数,由于修改过了Style,显得稍为好看一点。

创建data template

我们的魔法的精髓在于一个data template。既然我们可以使用简单的data template用数来显示音阶,为什么就不能把这个template弄得更华丽一点,显示出五线谱呢?这个世界上只有想不到,没有做不到!

为了方便,我们不想事先画好五线谱中的那五根线,而是决定让每个音符自己来画。除了那五根线,每个音符自然还要一个符号。如前所述,如果是一拍,就画一个实心的圆加一条竖线,如果是半拍就再加一条曲线,如果是两拍就用空心圆。为了简单,我们不考虑四拍不需要竖线,两个半拍连在一起需要加一条横线,等等复杂的情况。我们只考虑最简单的情况。

画这些东西如果用相对位置会比较麻烦,所以我们用一个Canvas做绝对定位。

        <DataTemplate x:Key="MusicDataTemplate">
<Canvas Width="30" Height="80" RenderTransformOrigin="0.5,0.5">
<Path Width="41" Height="1" Stretch="Fill" Stroke="{StaticResource brush}" Canvas.Top="7" Data="M47,11 L18,11"/>
<Path Width="39" Height="1" Stretch="Fill" Stroke="{StaticResource brush}" Canvas.Left="1" Canvas.Top="22" Data="M2,24 L20.027756,24"/>
<Path Width="41" Height="1" RenderTransformOrigin="0.5,0.5" Stretch="Fill" Stroke="{StaticResource brush}" Canvas.Top="37" Data="M5,39 L16.750995,35.85133"/>
<Path Width="37" Height="1" Stretch="Fill" Stroke="{StaticResource brush}" Canvas.Left="1" Canvas.Top="52" Data="M4,47 L20.03122,47"/>
<Path Width="38" Height="1" Stretch="Fill" Stroke="{StaticResource brush}" Canvas.Top="67" Data="M3,68 L17.775824,64.04083"/>
<Ellipse Width="15" Height="15" Stroke="{StaticResource brush}" Fill="{Binding Length, Converter={StaticResource LengthToFillConverter}}" Canvas.Left="8" Canvas.Top="{Binding Scale, Converter={StaticResource ScaleToTopConverter}}"/>
<Line X1="{Binding Converter={StaticResource ScaleToXConverter}, Path=Scale}" X2="{Binding Converter={StaticResource ScaleToXConverter}, Path=Scale}" Y1="{Binding Converter={StaticResource ScaleToY1Converter}, Path=Scale}" Y2="{Binding Scale, Converter={StaticResource ScaleToY2Converter}}" Stroke="{StaticResource brush}"/>
<Path Visibility="{Binding Length, Converter={StaticResource LengthToVisibilityConverter}}" Width="15" Height="25" Stretch="Fill" Stroke="{StaticResource brush}" Canvas.Left="-6" Canvas.Top="{Binding Scale, Converter={StaticResource ScaleToTopConverter2}}" Data="M12,66 C12,66 -2.5,63.5 -2.5,42.5" RenderTransformOrigin="1.5,0.5" Loaded="Path_Loaded">
<Path.RenderTransform>
<ScaleTransform/>
</Path.RenderTransform>
</Path>
</Canvas>
</DataTemplate>

上面的代码中,在Canvas里我们画了很多Path,Ellipse,还有Line。不要着急,我们一个个解释。

  • 最前面的五根Path:代表五线谱上的五条横线。事实上你也可以用Line来表示。
  • Ellipse:代表音符,如果是二拍或更多就使用一个空心的圆球,否则用实心。为此我们需要一个LengthToFillConverter(代码在下面给出),来判断是否要为这个球填充。此外,它的Canvas.Top属性是由音阶决定的,我们将Top绑定到音阶(Scale)属性上,并且用ScaleToTopConverter来将音阶转换成坐标值。
  • Line:代表竖线。为了方便,即使四拍的音符我们也一律画竖线。若是你需要让你的程序更严格地遵守五线谱标准,你可以尝试将它的Visibility属性绑定到音长(Length),并且通过一个converter来决定是否要显示这条竖线。我们这边仅仅绑定了X1,Y1,X2,Y2属性而以,这样一来就可以确保竖线的位置显示正确了。
  • 最后一个Path:用来显示半拍的曲线。我们将它的Visibility属性绑定到音长,从而只有当音长不足一拍时才会显示。为了简单,不管半拍还是四分之一拍,我们都使用相同的显示方法。此外,因为低于高音do的音符圆圈在上,从高音do往上的音符圆圈在下,所以我们需要设置一个ScaleTransform。我们处理了这个Path的OnLoaded事件,在那里判断音符是否低于高音do,如果是,就将scale设置成-1,从而实现颠倒的效果。
        private void Path_Loaded(object sender, RoutedEventArgs e)
{
Path path = (Path)sender;
ScaleTransform scale = (ScaleTransform)path.RenderTransform;
Note note = (Note)path.DataContext;
if (note.Scale <= 7)
{
scale.ScaleX = -1;
scale.ScaleY = -1;
}
}

其实说实话,要画一个五线谱就是这么简单。因为XAML使用的是retained rendering mode,你不需要写代码告诉系统怎么画画,只要用XAML表述要画什么东西就可以了。相比起前几次我们在Windows 8: 混用CSharp和C++创建DirectX程序这篇wiki中间使用Direct2D,以及在在Windows Phone和Windows 8上绘制数学函数图形这篇wiki中使用HTML canvas,这里的代码算是很简单的了。当然缺点就在于性能没有使用Direct2D以及HTML canvas来得好。不过在显示不是太复杂的乐谱的情况下,问题应该不大才对。

Converters

接下来就是要实现那么多converter了,虽然数量比较多,可是实现的方式都是一样的。我们相信大多数开发过Windows Phone的人都知道如何使用converter,因此就不再详细解释了。

以下是那些converter的代码:

    public class ScaleToTopConverter : IValueConverter
{
public object Convert(object value, System.Type targetType, object parameter, System.Globalization.CultureInfo culture)
{
return 82.5 - (double)value * 7.5;
}
 
public object ConvertBack(object value, System.Type targetType, object parameter, System.Globalization.CultureInfo culture)
{
throw new System.NotImplementedException();
}
}
 
public class ScaleToXConverter : IValueConverter
{
public object Convert(object value, System.Type targetType, object parameter, System.Globalization.CultureInfo culture)
{
if ((double)value <= 7)
{
return 23;
}
else
{
return 8;
}
}
 
public object ConvertBack(object value, System.Type targetType, object parameter, System.Globalization.CultureInfo culture)
{
throw new System.NotImplementedException();
}
}
 
public class ScaleToY1Converter : IValueConverter
{
public object Convert(object value, System.Type targetType, object parameter, System.Globalization.CultureInfo culture)
{
if ((double)value <= 7)
{
return 82.5 - (double)value * 7.5 - 40;
}
else
{
return 82.5 - (double)value * 7.5 + 10;
}
}
 
public object ConvertBack(object value, System.Type targetType, object parameter, System.Globalization.CultureInfo culture)
{
throw new System.NotImplementedException();
}
}
 
public class ScaleToY2Converter : IValueConverter
{
public object Convert(object value, System.Type targetType, object parameter, System.Globalization.CultureInfo culture)
{
if ((double)value <= 7)
{
return 82.5 - (double)value * 7.5 + 7.5;
}
else
{
return 82.5 - (double)value * 7.5 + 52.5;
}
}
 
public object ConvertBack(object value, System.Type targetType, object parameter, System.Globalization.CultureInfo culture)
{
throw new System.NotImplementedException();
}
}
 
public class ScaleToTopConverter2 : IValueConverter
{
public object Convert(object value, System.Type targetType, object parameter, System.Globalization.CultureInfo culture)
{
if ((double)value <= 7)
{
return 82.5 - (double)value * 7.5 - 40;
}
else
{
return 82.5 - (double)value * 7.5 + 27.5;
}
}
 
public object ConvertBack(object value, System.Type targetType, object parameter, System.Globalization.CultureInfo culture)
{
throw new System.NotImplementedException();
}
}
 
public class LengthToVisibilityConverter : IValueConverter
{
public object Convert(object value, System.Type targetType, object parameter, System.Globalization.CultureInfo culture)
{
if ((double)value < 0.75)
{
return Visibility.Visible;
}
else
{
return Visibility.Collapsed;
}
}
 
public object ConvertBack(object value, System.Type targetType, object parameter, System.Globalization.CultureInfo culture)
{
throw new System.NotImplementedException();
}
}
 
public class LengthToFillConverter : IValueConverter
{
public object Convert(object value, System.Type targetType, object parameter, System.Globalization.CultureInfo culture)
{
if ((double)value <= 1)
{
return new SolidColorBrush(Colors.White);
}
else
{
return null;
}
}
 
public object ConvertBack(object value, System.Type targetType, object parameter, System.Globalization.CultureInfo culture)
{
throw new System.NotImplementedException();
}
}

测试

我们来做一个简单的测试,在MainPage中嵌入上面的StaffViewer:

        <local:StaffViewer x:Name="Staff" Margin="10 100 10 0"/>

在code behind中配制好view model,并且用一个定时器不断查询当前播放到了第几个音符:

            SongViewModel viewModel = new SongViewModel();
this.DataContext = viewModel;
 
this.Staff.SetCurrentNoteIndex(0);
this._currenTime = 0;
if (this._timer != null)
{
this._timer.Stop();
}
this._timer = new DispatcherTimer();
this._timer.Interval = TimeSpan.FromSeconds(0.25);
this._timer.Tick += new EventHandler(Timer_Tick);
this._timer.Start();
 
void Timer_Tick(object sender, EventArgs e)
{
SongViewModel viewModel = (SongViewModel)this.DataContext;
int? currentNoteIndex = viewModel.GetCurrentNoteIndex(this._currenTime);
if (currentNoteIndex != null)
{
this.Staff.SetCurrentNoteIndex(currentNoteIndex.Value);
}
this._currenTime += 0.25;
}

这样一来,大家就可以看到本文开始时的那个画面了,随着时间的推移,黄色高亮的音符会渐渐往右边移动,如果到了屏幕最右边,整个乐谱会自动往左移。

总结

有些人可能会觉得这是魔法,我们仅仅使用一个ListBox,就实现了一个五线谱。请记住,XAML的data binding机制是非常灵活的,千万不要以为它只能用列表的形式显示商品数据!除此之外,在Windows Phone上制作一个字体编辑器一文中介绍的方式也是一个“非典型”的数据绑定用法。如果你能活用数据绑定,就可以减少大量的工作量,而且实现很多有意思的效果了。

This page was last modified on 16 July 2013, at 07:19.
89 page views in the last 30 days.

Was this page helpful?

Your feedback about this content is important. Let us know what you think.

 

Thank you!

We appreciate your feedback.

×