Spring naar hoofdtekst

ChordPro naar HTML converter

Geplaatst op door ,
Laatste aanpassing op .

Inleiding

Sinds ik weer als toetsenist aan de band Tie-Break verbonden ben, ervaar ik dat papieren bladmuziek ook nadelen heeft - zeer zeker voor een slechtziende zoals ik. Als de volgorde van repeteren verschilde van de volgorde tijdens het optreden, moest ik continu zoeken in de stapel met uitwerkingen, die alfabetisch gesorteerd waren. Dit moest toch overzichtelijker kunnen?

Een deel van de andere bandleden maakte gebruik van OnSong, een iOS-app waarmee je een verzameling digitale bladmuziek comfortabel kunt beheren en gebruiken. Na bestudering van het bestandsformaat besloot ik om al mijn muziek om te zetten en de app te kopen. Tot slot nog een derdehands iPad op de kop tikken en de digitale revolutie op de bühne kon beginnen!

Tegenslag

Bij het experimenteren ontdekte ik een bug in OnSong, die ik meldde via hun suppert-afdeling. Bij de volgende bug dacht ik slim te zijn en keek op de lijst met aankomende updates; misschien was de bug al bekend en verholpen in de volgende versie van OnSong. Wat bleek? Wordt iOS 5.0 (dat op mijn iPad 1 draait) vanaf de komende update niet meer ondersteund. Effectief betekende dit, dat mijn iPad het voor zijn hele verdere digitale leven moest doen met versie 1.99995 van OnSong.

Monsterklus

Als webontwikkelaar zoek ik in zo'n geval altijd naar andere mogelijkheden om, bijvoorbeeld met een webapplicatie in een browser, mijn doel alsnog te bereiken. Hoewel OnSong en verhelijkbare apps ook offline (zonder internetverbinding) te gebruiken zijn, kies ik voor een online variant. Hierdoor kan ik met mijn huidige kennis en vaardigheden een heel eind vooruit. Zou ik er in de toekomst voor kiezen om de applicatie ook offline te willen gebruiken, kan dat bijvoorbeeld met behulp van HTML5 WebStorage.

ChordPro-formaat

De basis van het OnSong-bestandsformaat is het ChordPro-formaat, een losse specificatie die door de meest uiteenlopende applicaties en programma's wordt gebruikt. Omdat het geen officiële standaard is, hangt het van de implementatie af, hoe het gegenereerde bestand uitziet. De basis is echter altijd gelijk, hieronder een voorbeeld:

{title: Will they remember?}
{artist: Frans-Willem Post}
{time: 4/4}
{key: C}
{tempo: 66}

Verse 1:
[C] spent the day [Caug]talking [C6] i'm glad that it's [Caug]over
[C] 'm allmost re[Caug]gretting [C6] i invited them [C7]over
[F] but it's [Fm]been worth my [C]while [C7]
[F] see them a[Fm]gain in a little [C]while [C7]
[F] but, will they re[Ab7]mem[G7]ber?

Dit wordt uiteindelijk zoiets als dit:

               Will the remember?
               Frans-Willem Post
                                      4/4, C, 66 BPM
Verse 1:
C                Caug    C6                   Caug
   spent the day talking   I'm glad that it's over
C               Caug     C6                C7
   'm allmost regretting    I invited them over
F           Fm            C     C7
   but it's been worth my while
F            Fm               C     C7
   see them again in a little while
F                   Ab7 G7
   but, will they remember?

Het bestand bevat als eerste een aantal gegevens in accolades, zoals titel, artiest, tempo, maat- en toonsoort. Daarna volgen één of meerdere gedeelten die van een label voorzien kunnen zijn. De akkoorden staan middenin de tekst tussen rechte haken.

Aanpak

Hoe kon ik met eenvoudige HTML één tekstregel met akkoorden transformeren naar twee regels waarin de akkoorden precies op de juiste plaats staan; boven de betreffende lettergreep? Dit was een doordenkertje... maar uiteindelijk bleken tabellen de juiste aanpak.

Ik kon voor elke regel in het bronbestand een HTML-tabel met twee rijen genereren, waarbij in de bovenste rij de akkoorden kwamen te staan, in de onderste de tekst in kleine stukjes. Het automatisch meegroeien van de td-cel zou er voor zorgen dat het akkoord boven de juiste lettergreep zou blijven staan. Een aantal uitdagingen lagen wel nog op de loer: een reeks van meerdere akkoorden zonder tekst, akkoorden aan het begin en einde van een regel…

Script

In wezen bestaat de converter uit twee gedeelten: de parser, een script dat het bronbestand analyseert en 'leegplukt', en de uiteindelijke HTML-output. Hieronder volgt een vereenvoudigde weergave van de daadwerkelijke code:

Parser

class SongParser
{
    private static $_remarkRegex = '!^\s*#(?P<REMARK>.*)$!';

    private static $_kvRegex = '!^\{(?P<KEY>[^:]+):\s*(?P<VALUE>[^}]+)\}$!';

    private static $_lblRegex = '!^(?P<LABEL>[^:]+):$!';

    public static function parseFile(string $filename)
    {
        $lines = file($filename, FILE_IGNORE_NEW_LINES);
        if (false === $lines) {
            return false;
        }

        $s = new Song();
        $section = new SongSection();

        foreach ($lines as $l) {
            $m = array();

            if (preg_match(static::$_remarkRegex, $l, $m) === 1) {

                // The line is a remark, discard and continue
                continue;

            } else if (preg_match(static::$_kvRegex, $l, $m) === 1) {

                // The line contains metadata, fill the Song's properties
                switch ($m['KEY']) {
                case 'title':
                    $s->setTitle($m['VALUE']);
                    break;
                case 'artist':
                    $artists = preg_split(
                        '!;!u', $m['VALUE'], -1, PREG_SPLIT_NO_EMPTY
                    );
                    foreach ($artists as $a) {
                        $s->addArtist($a);
                    }
                    break;
                case 'time':
                    $s->setTimeSignature($m['VALUE']);
                    break;
                case 'tempo':
                    $s->setTempo(intval($m['VALUE']));
                    break;
                case 'key':
                    $k = new Key($m['VALUE']);
                    $s->setKey($k);
                    break;
                case 'author':
                    $s->setAuthor($m['VALUE']);
                    break;

                case 'comment':
                case 'c':
                case 'comment_bold':
                case 'cb':
                case 'comment_italic':
                case 'ci':
                    // Pass comments to the line handling code
                    $section->addLine($l);
                    break;
                }

            } else if (preg_match(static::$_lblRegex, $l, $m) === 1) {

                // The line contains a section label
                $section->setLabel($m['LABEL']);

            } else if (F\Utils::isEmpty(trim($l))) {

                // The line is empty, start a new section
                $section = new SongSection();
                $s->addSection($section);

            } else {

                // The line has no special meaning, add it to the current section
                $section->addLine($l);
            }
        }

        if (!$s->getSections()) {
            $s->addSection($section);
        }

        return $s;
    }
}

HTML-weergave

<?php
function h(string $s) {
    return htmlentities($s, ENT_QUOTES | ENT_HTML5, 'UTF-8');
}

$oArtists = h(implode(' & ', $song->getArtists()));
$oTimeSignature = h($song->getTimeSignature());
$oKey = h(implode(' / ', $song->getKey()->getKeys()));
$oTempo = sprintf('%0d', $song->getTempo());

$oHTML = '';
foreach ($song->getSections() as $sect) {
    if (!empty($sect->getLabel())) {
        $oHTML .= sprintf(
            '<h3>%1$s:</h3>'.PHP_EOL,
            h($sect->getLabel())
        );
    }
    foreach ($sect->getLines() as $l) {
        if (isEmpty(trim($l))) {
            continue;
        }

        $commentMatches = '';
        $reComment = '!^\s*\{
            (?:
                c(?P<STYLESHORT>[bi])?              # short style directive
                |
                comment(?P<STYLELONG>_bold|_italic)? # long style directive 
            )
            :(?P<VALUE>[^}]+)                       # actual comment
        \}\s*$!x';

        if (preg_match($reComment, $l, $commentMatches) === 1) {
            ob_start();
            include 'html/tpl_p_comment.php';
            $oHTML .= ob_get_clean();
            continue;
        }

        $isChordsOnly = (preg_match('!^\s*(\[[^\]]+\]\s*)+$!', $l) === 1);
        $parts = preg_split(
            '!(\[[^\]]+\])!', $l, -1,
            PREG_SPLIT_DELIM_CAPTURE | PREG_SPLIT_OFFSET_CAPTURE
        );

        if ($isChordsOnly) {
            ob_start();
            include 'html/tpl_table_chords.php';
            $oHTML .= ob_get_clean();
        } else {
            ob_start();
            include 'html/tpl_table_chords-lyrics.php';
            $oHTML .= ob_get_clean();
        }
    }
    $oHTML .= '<hr />';
}

De HTML-uitvoer ziet er dan ongeveer zo uit:

<h1><span class="title"><?php print $oTitle ?></span><br />
    <span class="artist"><?php print $oArtists ?></span></h1>
<div class="right">
    <span class="time"><?php print $oTimeSignature ?></span>,
    <span class="key"><?php print $oKey ?></span>,
    <span class="tempo"><?php print $oTempo ?> BPM</span>
</div>
<?php print $oHTML ?>

De verschillende stukjes HTML-code worden apart geinclude-ed.

Akkoorden

$oTDs = '';
foreach ($parts as $p) {
    if (empty($p[0])) {
        continue;
    }
    $oTDs .= sprintf(
        '<td>%1$s</td>'.PHP_EOL,
        h(trim($p[0], '[]'))
    );
}
<table>
<tr class="tr-chords"><?php print $oTDs ?></tr>
</table>

Akkoorden met tekst

$oTDsChords = '';
$oTDsLyrics = '';

foreach ($parts as $p) {
    if (empty($p[0])) {
        continue;
    }

    $isChord = (preg_match('!^\[[^\]]+\]$!', $p[0]) === 1);
    if ($isChord) {

        $lastOnLine = (strlen($l) == ($p[1] + strlen($p[1])));
        $followedBySpace = (substr($l, $p[1] + strlen($p[0]), 1) === ' ');
        $restIsChordsOnly
            = (preg_match('!^\s*(\[[^\]]+\]\s*)+$!', substr($l, $p[1])) === 1);

        if ($lastOnLine || $followedBySpace || $restIsChordsOnly) {
            $oTDsLyrics .= sprintf('<td>&nbsp;</td>'.PHP_EOL);
        }

        $oTDsChords .= sprintf(
            '<td>%1$s</td>'.PHP_EOL,
            h(trim($p[0], '[]'))
        );
    } else {

        $onStartOfLine = ($p[1] == 0);
        $startsWithSpace = (substr($p[0], 0, 1) === ' ');
        $notPrecededByChord = (substr($l, $p[1]-1, 1) !== ']');

        if ($onStartOfLine || $startsWithSpace || $notPrecededByChord) {
            $oTDsChords .= sprintf('<td>&nbsp;</td>'.PHP_EOL);
        }

        $oTDsLyrics .= sprintf(
            '<td>%1$s</td>'.PHP_EOL,
            h($p[0])
        );
    }
}
<table>
<tr class="tr-chords"><?php print $oTDsChords ?></tr>
<tr class="tr-lyrics"><?php print $oTDsLyrics ?></tr>
</table>

Commentaar

$extraClasses = '';
$matchedStyles = array(
    $commentMatches['STYLESHORT'], $commentMatches['STYLELONG']
); 

foreach ($matchedStyles as $style) {
    switch ($style) {
    case 'b':
    case '_bold':
        $extraClasses .= ' bold';
        break;

    case 'i':
    case '_italic':
        $extraClasses .= ' italic';
        break;
    }
}
<p class="comment<?php print $extraClasses ?>">
<?php print h($commentMatches['VALUE']) ?>
</p>

Wordt vervolgd

De bovenstaande code is slechts het begin van het ChordProApp-project, maar wel het belangrijkste deel. Het was een uitdaging om in mijn straatje van PHP, HTML en CSS een nieuwe uitdaging aan te gaan. Zo gauw ik méér te melden heb, verschijnt er weer een nieuw artikeltje. Tot dan!

Inhoudsopgave

Doorzoek de onderstaande categorieën om de lijst met artikelen te filteren.