Click here to Skip to main content
65,938 articles
CodeProject is changing. Read more.
Articles
(untagged)

Music Notation in Blazor - Part I

0.00/5 (No votes)
1 Aug 2018 1  
Client-side music notation rendering in Blazor

 

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:

  1. .NET Core SDK 2.1 - you can get it from here
  2. Visual Studio 2017 version 15.7 - already available as an update to VS 2017
  3. 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));  //https://github.com/aspnet/Blazor/issues/934
    }

    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.

License

This article has no explicit license attached to it but may contain usage terms in the article text or the download files themselves. If in doubt please contact the author via the discussion board below.

A list of licenses authors might use can be found here