Music Notation in .NET
用于桌面、移动和 Web 应用程序的音乐符号制作开源库
引言
本文简要介绍了最近以开源项目形式发布的库Manufaktura.Controls
中最重要的一些基础知识。该项目是我八年前创建的另外两个项目的延续,这两个项目在以下文章中有描述:
- https://codeproject.org.cn/Articles/87329/PSAM-Control-Library
- https://codeproject.org.cn/Articles/89582/PSAM-WPF-Control-Library
这些年来我的编程技能有了显著提高,但Manufaktura.Controls
仍然使用了一些来自这两个旧项目的代码。我彻底改变了架构以支持跨平台实现,但你仍然可以看到一些旧的意大利面条式代码,尤其是在渲染策略的主体部分。
我将代码发布为开源项目,因为它作为一个纯粹的商业项目并没有给我带来多少利润。我希望它作为开源项目会更有用,并迅速围绕它形成一个庞大的社区。
由于附件大小限制,本文附带的源代码仅包含最重要的库。你可以在 GIT 仓库中找到全部代码:https://bitbucket.org/Ajcek/manufakturalibraries
有什么用(对于不熟悉我库的人来说)
Manufaktura.Controls
是一组 .NET 库,用 C# 编写,用于在桌面、Web 和移动应用程序中渲染音乐符号。核心库是跨平台的,因此几乎可以在任何 .NET 环境中使用,例如 .NET Framework, .NET Core, UWP, Mono, Silverlight 等。它提供了 WPF, WinForms, UWP, ASP.NET MVC, ASP.NET Core 等的实现。还有用于 Silverlight 和 Windows 8 的旧版库。
这些库的主要目的是渲染乐谱,但也提供其他功能,例如 MusicXML 解析、MIDI 播放以及对音乐中有用的数学运算的辅助方法。
解决方案概览
解决方案中有两个主要库:Manufaktura.Controls
和Manufaktura.Music
。这些是跨平台库,因此可以在所有 .NET 环境中使用:Web、桌面和移动。以前,它们充当可移植类库,目前它们的目标是 .NET Standard 1.1。
Manufaktura.Music
定义了音乐的底层概念,例如音程、节奏、比例等。它独立于音乐符号,因此Manufaktura.Music
中定义的模型不了解音符、休止符、谱号等概念。该库还定义了模型之间的算术运算,例如音程的转位、音高比较等。
Manufaktura.Controls
是解决方案中最大的库。它定义了西方音乐符号模型、渲染器、解析器等。
Manufaktura.Music 的架构
Manufaktura.Music
的主要概念是:音程、音高、音级、比例、节奏时值和调式。
声音音高概念由三个结构表示
Step
– 定义一个音阶的音级(A、B、C、D、E、F 或 G),可以被改变(增或减)。音级不了解确切的音高。Pitch
– 继承自Step
。这是一个精确八度中具有精确 MIDI 音高的Step
。TunedPitch
– 继承自Pitch
。这是一个具有精确频率的Pitch
。例如:A4 在 440Hz 或 A4 在 415Hz。
音程也分为三个结构
DiatonicInterval
– 由音级数定义。例如:二度、三度、八度等。Interval
– 继承自DiatonicInterval
。定义音级数和半音数。例如:小二度、大二度、减三度等。BoundInterval
– 继承自 Interval。表示从某个音高开始的音程 – 类似于有钩向量的概念。例如:从 C 到 E 的大三度。
比例只是可以转换为双精度浮点数或 cents 的分数。例如:Proportion.Sesquialtera
返回 3/2 的分数。
还有其他类,如RhythmicDurations
和Scales
。所有这些类或多或少都在Manufaktura.Controls
的低级抽象模型中使用。
Manufaktura.Controls 的架构
Manufaktura.Controls
是解决方案中最大的库。它定义了模型、解析器(用于解析MusicXml
)、渲染器和渲染策略。
模型
概述
模型包含表示不同西方音乐符号概念的类,例如音符、休止符、谱号、小节线等。在很大程度上,模型基于MusicXml
规范,但也利用了Manufaktura.Music
库中的概念,例如,Note 由Pitch
和RhythmicDuration
组成。Manufaktura.Controls.Model
中的一些类可以被视为比Manufaktura.Music
类更低一级的抽象,例如Pitch
可以提升为Note
,Note
可以还原为Pitch
等。
创建乐谱模型
Score
包含所有要绘制的音乐符号。创建乐谱有两种方式
- 通过 API 手动创建
- 通过解析器自动创建
你可以从这篇文章中学习手动创建乐谱模型的基础知识
五线谱规则
手动创建模型与使用解析器创建乐谱不同。当你解析MusicXml
时,解析器会自动应用MusicXml
文件中包含的一些数据,例如小节中的水平音符位置、符杆方向等。
如果你通过 API 手动添加音符和其他符号,则某些属性(如符杆方向)由五线谱规则决定。五线谱规则是继承自StaffRule
的类。例如,NoteStemRule
在将音符插入Staff
时会自动确定符杆方向。当StaffRules
被插入到继承自ItemManagingCollection<TItem>
的集合中时,它们会自动应用。这个类最常用的实现是MusicalSymbolCollection
,它管理五线谱上的项目。
渲染器和渲染策略
Manufaktura.Controls
为每个平台使用单一的代码库。这是通过称为Renderers
和RenderStrategies
的abstract
类实现的
Renderers
– 它们定义了如何绘制基本形状,如线条、文本和贝塞尔曲线,但它们与音乐符号完全无关。RenderStrategies
– 将音乐转换为基本形状,如线条、文本和贝塞尔曲线,但不知道如何绘制它们。
ScoreRendererBase
类有五个主要的abstract
方法
DrawLine
DrawArc
DrawText
DrawBezier
DrawCharacterInBound
这五个方法在派生类中实现。例如,WPFCanvasScoreRenderer
通过创建Line
形状并将其放置在画布上来绘制线条
public override void DrawLine(Primitives.Point startPoint,
Primitives.Point endPoint, Primitives.Pen pen, MusicalSymbol owner)
{
if (!EnsureProperPage(owner)) return;
if (Settings.RenderingMode != ScoreRenderingModes.Panorama)
{
startPoint = startPoint.Translate(CurrentScore.DefaultPageSettings);
endPoint = endPoint.Translate(CurrentScore.DefaultPageSettings);
}
var line = new Line();
line.Stroke = new SolidColorBrush(ConvertColor(pen.Color));
line.X1 = startPoint.X;
line.X2 = endPoint.X;
line.Y1 = startPoint.Y;
line.Y2 = endPoint.Y;
line.StrokeThickness = pen.Thickness;
line.Visibility = BoolToVisibility(owner.IsVisible);
Canvas.Children.Add(line);
OwnershipDictionary.Add(line, owner);
}
HtmlSvgScoreRenderer
创建 SVG 标签并将其添加到表示 SVG 画布的 XML 文档中
public override void DrawLine(Point startPoint, Point endPoint, Pen pen, Model.MusicalSymbol owner)
{
if (!EnsureProperPage(owner)) return;
if (Settings.RenderingMode != ScoreRenderingModes.Panorama &&
!TypedSettings.IgnorePageMargins)
{
startPoint = startPoint.Translate(CurrentScore.DefaultPageSettings);
endPoint = endPoint.Translate(CurrentScore.DefaultPageSettings);
}
var element = new XElement("line",
new XAttribute("x1", startPoint.X.ToStringInvariant()),
new XAttribute("y1", startPoint.Y.ToStringInvariant()),
new XAttribute("x2", endPoint.X.ToStringInvariant()),
new XAttribute("y2", endPoint.Y.ToStringInvariant()),
new XAttribute("style", pen.ToCss()),
new XAttribute("id", BuildElementId(owner)));
var playbackAttributes = BuildPlaybackAttributes(owner);
foreach (var playbackAttr in playbackAttributes)
{
element.Add(new XAttribute(playbackAttr.Key, playbackAttr.Value));
}
if (startPoint.Y < ClippedAreaY) ClippedAreaY = startPoint.Y;
if (endPoint.Y < ClippedAreaY) ClippedAreaY = endPoint.Y;
if (startPoint.X > ActualWidth) ActualWidth = startPoint.X;
if (endPoint.X > ActualWidth) ActualWidth = endPoint.X;
if (startPoint.Y > ActualHeight) ActualHeight = startPoint.Y;
if (endPoint.Y > ActualHeight) ActualHeight = endPoint.Y;
Canvas.Add(element);
}
请注意,Canvas
属性可以是任何对象(例如,一个控件、XML 文档等)。Canvas
类型作为类型参数传递给ScoreRenderer
。
RenderStrategies
派生自MusicalSymbolRenderStrategy
。这是一个泛型类型 – 合适的渲染策略由类型参数匹配。例如,NoteRenderStrategy
派生自MusicalSymbolRenderStrategy<Note>
。每个渲染器的主要方法是
public override void Render(Barline element, ScoreRendererBase renderer)
第一个参数是要绘制的元素。第二个参数是乐谱渲染器实例。每个平台都使用不同的乐谱渲染器实现,但Render
方法的代码独立于任何实现 – 它只是告诉Renderer
绘制基本形状(如线条、文本等),然后Renderer
完成其余工作。
渲染乐谱时,会为乐谱的每个元素注入不同的RenderStrategies
,以便每个元素都使用适当的RenderStrategy
进行渲染。请注意,不同渲染器策略的构造函数接受不同的参数,例如,KeyRenderStrategy
只使用IScoreService
,而NoteRenderStrategy
还使用IBeamingService
、IMeasurementService
等。这些服务通过简单的 IoC 机制自动注入到每个渲染策略中。这些服务的作用是存储不同渲染策略实例之间的共享数据,并为一些更复杂的、可供不同渲染器重用的计算提供帮助。最常用的服务是IScoreService
,它存储了当前的五线谱 X 位置等信息。
这是一个绘制拍号的渲染策略示例
/// <summary>
/// Strategy for rendering a TimeSignature.
/// </summary>
public class TimeSignatureRenderStrategy : MusicalSymbolRenderStrategy<TimeSignature>
{
/// <summary>
/// Initializes a new instance of TimeSignatureRenderStrategy
/// </summary>
/// <param name="scoreService"></param>
public TimeSignatureRenderStrategy(IScoreService scoreService) : base(scoreService)
{
}
/// <summary>
/// Renders time signature symbol with specific score renderer
/// </summary>
/// <param name="element"></param>
/// <param name="renderer"></param>
public override void Render(TimeSignature element, ScoreRendererBase renderer)
{
var topLinePosition = scoreService.CurrentLinePositions[0];
if (element.Measure.Elements.FirstOrDefault() == element)
scoreService.CursorPositionX += renderer.LinespacesToPixels(1); //Żeby był lekki
//margines między kreską taktową a symbolem.
//Być może ta linijka będzie do usunięcia
if (element.SignatureType != TimeSignatureType.Numbers)
{
renderer.DrawCharacter(element.GetCharacter
(renderer.Settings.CurrentFont), MusicFontStyles.MusicFont,
scoreService.CursorPositionX, topLinePosition +
renderer.LinespacesToPixels(2), element);
element.TextBlockLocation = new Primitives.Point
(scoreService.CursorPositionX, topLinePosition + renderer.LinespacesToPixels(2));
}
else
{
if (renderer.IsSMuFLFont)
{
renderer.DrawString(SMuFLGlyphs.Instance.BuildTimeSignatureNumberFromGlyphs
(element.NumberOfBeats),
MusicFontStyles.MusicFont, scoreService.CursorPositionX,
topLinePosition + renderer.LinespacesToPixels(1), element);
renderer.DrawString(SMuFLGlyphs.Instance.BuildTimeSignatureNumberFromGlyphs
(element.TypeOfBeats),
MusicFontStyles.MusicFont, scoreService.CursorPositionX,
topLinePosition + renderer.LinespacesToPixels(3), element);
element.TextBlockLocation = new Primitives.Point
(scoreService.CursorPositionX, topLinePosition + renderer.LinespacesToPixels(3));
}
else
{
renderer.DrawString(Convert.ToString(element.NumberOfBeats),
MusicFontStyles.TimeSignatureFont, scoreService.CursorPositionX,
topLinePosition + renderer.LinespacesToPixels(2), element);
renderer.DrawString(Convert.ToString(element.TypeOfBeats),
MusicFontStyles.TimeSignatureFont, scoreService.CursorPositionX,
topLinePosition + renderer.LinespacesToPixels(4), element);
element.TextBlockLocation = new Primitives.Point(scoreService.CursorPositionX,
topLinePosition + renderer.LinespacesToPixels(4));
}
}
scoreService.CursorPositionX += 20;
}
}
特定平台实现
概述
解决方案中最多的库是Manufaktura.Controls
的特定平台实现。这些库在类数量方面也是最小的。通常,它们包含以下项目
- 特定平台的
ScoreRenderer
实现 - 控件(桌面和移动)或 Razor 扩展(Web)利用特定的
ScoreRenderer
来绘制乐谱,有时还提供一些用户体验逻辑,如音符拖动等。 - 媒体播放器 – 用于乐谱播放。WPF 和 WinForms 版本使用
Manufaktura.Controls.Desktop
中包含的共享 MIDI 播放实现。
字体和字体度量
Manufaktura.Controls
使用两种字体
- Polihymnia – 由 Ben Laenen 的 Euterpe 字体创建(https://packages.debian.org/stable/fonts/fonts-oflb-euterpe)。该字体仅包含原始 Euterpe 字体的部分字符,因此您应避免使用它。
- 符合 SMuFL 标准的字体。您可以使用任何兼容 SMuFL 1.2 标准的字体。本文介绍了如何加载 SMuFL 字体和元数据:http://manufaktura-programow.pl/en-US/Articles/Display?id=8
如果您打算实现自己的ScoreRenderer
,则需要了解一些关于字体度量的信息。首先,Polihymnia 和 SMuFL 字体设计的方式是,基线位置与五线谱上的线(或格子的中心)位置重合。例如,如果您将一个音符头放在第 3 条线的 Y 坐标上,音符头的中心将精确地出现在第 3 条线上。不幸的是,不同的框架提供了两种完全不同的文本定位方式
- 文本块坐标是字体基线的坐标。HTML SVG 就是这样工作的。
- 文本块坐标是文本块边界框左下角的坐标(例如:WPF 中的 TextBlock 元素)。
第二种行为是不希望的,因此我们应该通过将文本块位置的基线位置进行转换来纠正文本位置。基线坐标可以从字体度量中读取,字体度量对于每个平台都需要以不同的方式检索。例如,WPF 的做法如下:
var baseline = typeface.FontFamily.Baseline * textBlock.FontSize;
Canvas.SetLeft(textBlock, location.X);
Canvas.SetTop(textBlock, location.Y - baseline);
这在 WinForms (System.Graphics
) 中完全不同
var baselineDesignUnits = font.FontFamily.GetCellAscent(font.Style);
var baselinePixels = (baselineDesignUnits * font.Size) / font.FontFamily.GetEmHeight(font.Style);
Canvas.DrawString(text, font, new SolidBrush(ConvertColor(color)),
new PointF((float)location.X - 4, (float)location.Y - baselinePixels));
在 UWP 应用中没有简单的方法可以做到这一点,所以我决定让程序员手动提供基线位置。
测试应用和单元测试
在解决方案中,有一些测试应用和单元测试项目。最重要的项目是Manufatura.Controls.VisualTests
。这是一个单元测试项目,它将一些预定义的乐谱(以MusicXml
格式提供)渲染为位图。它将创建的位图与之前创建的位图进行比较,并在红色中标记所有差异,这样您就可以轻松地跟踪提交之间的回归情况。
这张图片显示了示例视觉测试结果。在两个代码版本之间进行了一些连音符的更正。差异用红色标记
额外资源
您可以在此处阅读有关Manufaktura.Controls
的其他文章和教程
这是一个 Bitbucket 仓库
关于开始一切的旧项目的两篇文章
- https://codeproject.org.cn/Articles/87329/PSAM-Control-Library
- https://codeproject.org.cn/Articles/89582/PSAM-WPF-Control-Library
仍需改进的领域
合适的 Xamarin 实现
有一个Xamarin.Forms
实现,带有 Android 的渲染器。它仍处于 beta 阶段,我没有时间开发它。
渲染管道
渲染机制未针对渲染多页进行优化。它首先渲染所有音符和音乐符号,然后绘制所有线条。还应该有一种虚拟化机制,只渲染屏幕上可见的乐谱部分。
支持更多音乐符号概念
一些音乐符号仍不支持。例如,该库无法渲染渐强和渐弱标记。
HTML 中的客户端渲染
目前,所有 Web 实现(Manufaktura.Controls.AspNetMvc
、Manufaktura.Controls.AspNetCore
)都使用服务器端渲染。
我曾尝试使用 CSHTML5(http://cshtml5.com/)来实现这一点,但我没有时间处理它,而且 CSHTML5 仍处于开发阶段。有三种方法可以实现客户端渲染
- 继续 CSHTML5 实现项目。
- 也许使用一些基于
WebAssembly
的库,例如Blazor
。 - 创建一个
ScoreRenderer
来创建 Vexflow(http://www.vexflow.com/)或 Verovio(http://www.verovio.org/index.xhtml)脚本。