Namespaces

Variants
Actions

Please note that as of October 24, 2014, the Nokia Developer Wiki will no longer be accepting user contributions, including new entries, edits and comments, as we begin transitioning to our new home, in the Windows Phone Development Wiki. We plan to move over the majority of the existing entries over the next few weeks. Thanks for all your past and future contributions.

在Windows Phone上制作乐谱浏览器

From 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.
150 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.

×