UPDATE: This is the first part of my Blazor series. This article applies to Blazor 0.4. The second part, concerning Blazor 0.5.1, can be found here: https://www.codeproject.com/Articles/1254712/Music-Notation-in-Blazor-Part-2
Introduction
I recently published an article about Manufaktura.Controls
library which enables music notation rendering in various web, desktop and mobile environments. Unfortunately, all web implementations provided by the library are server-based. It is not a problem if you plan to display static score (like in this example) but it can cause a significant lag if you want to modify the score dynamically, as can be seen in this example (when you add notes with keyboard control on the left, the notes appear with some delay because the rendering is done on server side).
There are some JavaScript music notation libraries like Vexflow but the greatest advantage of Manufaktura.Controls
is a single code base for every implementation. In this article, I will show how to render scores on client side with existing Manufaktura.Controls
code base. For this purpose, I will use Blazor, a .NET web framework which runs in the browser with WebAssembly
.
Creating Components
I am not going to describe in detail how Blazor works and how to create a Blazor project. This topic is already covered by various articles, like this one. Let me briefly say that you need the following components to start a Blazor project:
- .NET Core SDK 2.1 - you can get it from here
- Visual Studio 2017 version 15.7 - already available as an update to VS 2017
- Language service extension for Blazor which can be downloaded from here
Then in VS, you just have to start a new ASP.NET Core project and select Blazor as a template.
Let's assume that we have already created an empty Blazor project.
First of all, we are going to create a NoteViewer
component - a similar idea to NoteViewer
control in desktop implementations of Manufaktura.Controls
or NoteViewerFor
Razor extensions for ASP.NET MVC and ASP.NET Core. You can see how this concepts works in these articles. Now let's add NoteViewer.cshtml to Shared folder:
@using Manufaktura.Controls.Model
@using Manufaktura.Controls.Rendering.Implementations
<RawHtml Content="@RenderScore()"></RawHtml>
@functions {
[Parameter]
Score Score { get; set; }
[Parameter]
HtmlScoreRendererSettings Settings { get; set; }
private int canvasIdCount = 0;
public string RenderScore()
{
IScore2HtmlBuilder builder;
if (Settings.RenderSurface == HtmlScoreRendererSettings.HtmlRenderSurface.Canvas)
builder = new Score2HtmlCanvasBuilder
(Score, string.Format("scoreCanvas{0}", canvasIdCount), Settings);
else if (Settings.RenderSurface == HtmlScoreRendererSettings.HtmlRenderSurface.Svg)
builder = new Score2HtmlSvgBuilder
(Score, string.Format("scoreCanvas{0}", canvasIdCount), Settings);
else throw new NotImplementedException("Unsupported rendering engine.");
string html = builder.Build();
canvasIdCount++;
return html;
}
}
The above component takes two parameters: a Score
that has to be rendered and the Settings
object. Settings
are taken directly from web implementations of Manufaktura.Controls
and are described here in detail. A RenderScore()
method uses IScore2HtmlBuilder
to convert Score
to HTML code.
Blazor currently doesn't offer a possibility to render raw HTML (like in Html.Raw()
method in Razor) so we are going to create a component for this purpose. Add RawHtml.cshtml to Shared folder:
@using HtmlAgilityPack;
@using Microsoft.AspNetCore.Blazor;
@using Microsoft.AspNetCore.Blazor.RenderTree;
@if (Content == null)
{
<span>Loading...</span>
}
else
{
@DynamicHtml
}
@functions {
[Parameter] string Content { get; set; }
RenderFragment DynamicHtml { get; set; }
protected override void OnInit()
{
RenderHtml();
}
private void RenderHtml()
{
DynamicHtml = null;
DynamicHtml = builder =>
{
var HtmlContent = Content;
if (HtmlContent == null) return;
var htmlDoc = new HtmlDocument();
htmlDoc.LoadHtml(HtmlContent);
var htmlBody = htmlDoc.DocumentNode;
Decend(htmlBody, builder);
};
}
private void Decend(HtmlNode ds, RenderTreeBuilder b)
{
foreach (var nNode in ds.ChildNodes)
{
if (nNode.NodeType == HtmlNodeType.Element)
{
b.OpenElement(0, nNode.Name);
if (nNode.HasAttributes) Attributes(nNode, b);
if (nNode.HasChildNodes) Decend(nNode, b);
b.CloseElement();
}
else
{
if (nNode.NodeType == HtmlNodeType.Text)
{
b.AddContent(0, nNode.InnerText);
}
}
}
}
private void Attributes(HtmlNode n, RenderTreeBuilder b)
{
foreach (var a in n.Attributes)
{
b.AddAttribute(0, a.Name, a.Value);
}
}
}
The code of this component is taken (with some modifications) from this project: https://github.com/EdCharbeneau/BlazeDown
As you can see, RawHtml
component uses HtmlAgilityPack
. You can get it from Nuget (take the .NET Core version).
A bit of explanation what happens here: first of all, we have an HTML code created by IScore2HtmlBuilder
implementation. The HTML code is in a form of string
so we have to explicitly tell Blazor how to render it. First, we parse the HTML code with HtmlAgilityPack
. Then we iterate on all child nodes and attributes and tell RenderTreeBuilder
to render them one by one. This is done in a delegate method which takes RenderTreeBuilder
as a parameter. Blazor calls this method during data binding.
Using the Components on a Page
Now we can add the NoteViewer
component to a page. Insert this to Index.cshtml file:
@using Manufaktura.Controls.Model
@using Manufaktura.Controls.Linq
@using Manufaktura.Controls.Extensions
@using Manufaktura.Controls.Rendering
@using Manufaktura.Controls.Rendering.Implementations
@using Manufaktura.Music.Model
@using Manufaktura.Music.Model.MajorAndMinor
@using Manufaktura.Controls.Model.Fonts
@page "/"
<h1>Hello, world!</h1>
Welcome to your new app.
<NoteViewer Score=@score Settings=@settings />
<button class="btn btn-primary" onclick="@AddNote">Add note</button>
@functions {
Score score = Score.CreateOneStaffScore(Clef.Treble, MajorScale.C);
HtmlScoreRendererSettings settings = new HtmlScoreRendererSettings
{
RenderSurface = HtmlScoreRendererSettings.HtmlRenderSurface.Svg
};
void AddNote()
{
score.FirstStaff.Elements.Add
(new Note(Pitch.G4, RhythmicDuration.Quarter));
}
protected override void OnInit()
{
base.OnInit();
score.FirstStaff.AddRange(StaffBuilder
.FromPitches(Pitch.C4, Pitch.D4, Pitch.E4, Pitch.F4, Pitch.G4, Pitch.E4)
.AddRhythm("8 8 8 8 4 4"));
var musicFontUris = new[]
{ "/fonts/Polihymnia.svg", "/fonts/Polihymnia.ttf", "/fonts/Polihymnia.woff" };
settings.RenderingMode = ScoreRenderingModes.AllPages;
settings.Fonts.Add(MusicFontStyles.MusicFont,
new HtmlFontInfo("Polihymnia", 22, musicFontUris));
settings.Fonts.Add(MusicFontStyles.StaffFont,
new HtmlFontInfo("Polihymnia", 24, musicFontUris));
settings.Fonts.Add(MusicFontStyles.GraceNoteFont,
new HtmlFontInfo("Polihymnia", 14, musicFontUris));
settings.Fonts.Add(MusicFontStyles.LyricsFont,
new HtmlFontInfo("Open Sans", 9, "/fonts/OpenSans-Regular.ttf"));
settings.Fonts.Add(MusicFontStyles.TimeSignatureFont,
new HtmlFontInfo("Open Sans", 12, "/fonts/OpenSans-Regular.ttf"));
settings.Fonts.Add(MusicFontStyles.DirectionFont,
new HtmlFontInfo("Open Sans", 10, "/fonts/OpenSans-Regular.ttf"));
settings.Scale = 1;
settings.CustomElementPositionRatio = 0.8;
settings.IgnorePageMargins = true;
}
}
In order to make this work, you have to reference Manufaktura.Controls
and Manufaktura.Music
libraries. You can find them in the article mentioned at the beginning or get releases from this page. You also have to add music font to the solution. You can find Polihymnia.ttf in files attached to this article.
In OnInit
method, a sample Score
is created using a StaffBuilder
API. More information on creating scores can be found in articles on this page.
Running the App
Now you can run the app which should look like this:
It looks like we managed to render a simple score on client side with existing Manufaktura.Controls
codebase. But what happens when we click Add note button? According to AddNote
method implementation, a new note should appear on the staff:
void AddNote()
{
score.FirstStaff.Elements.Add(new Note(Pitch.G4, RhythmicDuration.Quarter));
}
Unfortunately, it throws an exception instead:
MonoPlatform.ts:70 Uncaught Error: Microsoft.AspNetCore.Blazor.Browser.Interop.JavaScriptException:
Cannot set attribute on non-element child
This problem is similar to issues described here:
I suppose it's a bug in Blazor (version 0.4 was used in this example) and I hope that the creators of Blazor will solve it in the future. I will update this article if a new version of Blazor solves this problem or I find a workaround.
Points of Interest
Let's see what happens if I use Rebeam()
method during Score
creation:
score.FirstStaff.AddRange(StaffBuilder
.FromPitches(Pitch.C4, Pitch.D4, Pitch.E4, Pitch.F4, Pitch.G4, Pitch.E4)
.AddRhythm("8 8 8 8 4 4")
.Rebeam());
The exception is thrown:
Uncaught (in promise) Error: System.MemberAccessException:
Cannot create an abstract class: System.Reflection.Emit.DynamicMethod
at System.Linq.Expressions.Compiler.LambdaCompiler.Compile
(:59341/System.Linq.Expressions.LambdaExpression lambda) <0x1fcd558 +
0x00016> in <656221f224e346f8864575303b78815b>:0
at System.Linq.Expressions.LambdaExpression.Compile
(:59341/System.Boolean preferInterpretation) <0x1fcd308 + 0x0002a>
in <656221f224e346f8864575303b78815b>:0
at :59341/System.Linq.Expressions.LambdaExpression.Compile () <0x1fcd030 +
0x0000a> in <656221f224e346f8864575303b78815b>:0
at Manufaktura.Controls.Extensions.StaffBuilder+<>c.<Rebeam>b__13_1
(:59341/System.Reflection.TypeInfo t) <0x1e1bac0 + 0x00028> in <97e4516ea72e4e27bcedc5e90becc4b7>:0
at :59341/System.Linq.Enumerable+WhereSelectEnumerableIterator`2[TSource,TResult].MoveNext ()
<0x1e1ae68 + 0x0008c> in <ae6c925511ec4c7fa3cc179890e4f18f>:0
at :59341/System.Linq.Enumerable+<CastIterator>d__29`1[TResult].MoveNext ()
<0x1e1a830 + 0x000ac> in <ae6c925511ec4c7fa3cc179890e4f18f>:0
The Rebeam()
method searches the Assembly to find a proper RebeamStrategy
but there is a condition to omit abstract
classes. I don't understand why it tries to instantiate an abstract
class. Maybe it's another bug in Blazor. I will post this issue to Blazor creators and update this article if the solution is found.