Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Implement OnSong parser #461

Draft
wants to merge 19 commits into
base: master
Choose a base branch
from
Draft
Show file tree
Hide file tree
Changes from 18 commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .eslintignore
Original file line number Diff line number Diff line change
Expand Up @@ -2,5 +2,6 @@ lib
node_modules
src/formatter/templates/*.js
src/parser/chord_pro_peg_parser.js
src/parser/on_song_grammar.js
src/normalize_mappings/suffix-normalize-mapping.js
src/normalize_mappings/enharmonic-normalize.js
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -3,5 +3,6 @@ node_modules
lib
src/formatter/templates/*.js
src/parser/chord_pro_peg_parser.js
src/parser/on_song_grammar.js
.tool-versions
src/normalize_mappings/suffix-normalize-mapping.js
6 changes: 4 additions & 2 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -19,22 +19,24 @@
"@babel/eslint-parser": "^7.16.3",
"@babel/plugin-transform-runtime": "^7.10.1",
"@babel/preset-env": "^7.10.2",
"annotate-code": "^2.0.1",
"babel-plugin-handlebars-inline-precompile": "^2.1.1",
"eslint": "^8.3.0",
"eslint-config-airbnb-base": "^15.0.0",
"eslint-plugin-import": "^2.21.2",
"husky": "^7.0.0",
"jest": "^27.0.1",
"jsdoc-to-markdown": "^7.1.0",
"pegjs": "^0.10.0",
"peggy": "^1.2.0",
"pegjs-backtrace": "^0.2.1",
"pinst": "^2.1.6",
"print": "^1.2.0"
},
"scripts": {
"build:template": "handlebars \"src/formatter/templates/$TEMPLATE.handlebars\" -f \"src/formatter/templates/$TEMPLATE.js\" --known each --known if --known with --known paragraphClasses --known isChordLyricsPair --known isTag --known isComment --known shouldRenderLine --known hasChordContents --known lineHasContents --known lineClasses --known toUpperCase --known paragraphClasses --commonjs handlebars",
"build:templates": "TEMPLATE=html_div_formatter yarn build:template && TEMPLATE=html_table_formatter yarn build:template",
"build:suffix-normalize": "rm -rf src/normalize_mappings/suffix-normalize-mapping.js && node src/normalize_mappings/generate-suffix-normalize-mapping.js",
"build:pegjs": "pegjs -o src/parser/chord_pro_peg_parser.js src/parser/chord_pro_grammar.pegjs",
"build:pegjs": "peggy -o src/parser/chord_pro_peg_parser.js src/parser/chord_pro_grammar.pegjs && peggy src/parser/on_song_grammar.pegjs",
bkeepers marked this conversation as resolved.
Show resolved Hide resolved
"build:code-generate": "yarn build:suffix-normalize && yarn build:templates && yarn build:pegjs",
"build:sources": "rm -rf lib && babel src --out-dir lib",
"build": "yarn build:code-generate && yarn build:sources",
Expand Down
227 changes: 227 additions & 0 deletions src/parser/on_song_grammar.pegjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,227 @@
// This is a grammar for the OnSong format as documented here:
// https://www.onsongapp.com/docs/features/formats/onsong/

{
const warnings = options.warnings || []

function warn(message, location) {
warnings.push({message, location})
}

// Use the first character of each word in the section name, e.g. "Verse 3" => "V3"
function sectionAbbr(name) {
return name?.match(/\b(\w)/g)?.join('').toUpperCase();
}

// Rearrange sections according to the `Flow` metatag
function reflow(metadata, sections) {
const flow = metadata.find(m => m.name === 'flow')?.value;

if(flow) {
return flow.map(name => {
if(typeof name === 'string') {
return sections.find(section => {
return section.name === name || sectionAbbr(section.name) === name
})
} else {
return name;
}
});
} else {
return sections;
}
}
}

ChordSheet
= metadata:Metadata sections:Section* {
return { type: "chordsheet", metadata, sections: reflow(metadata, sections), warnings }
}

// https://www.onsongapp.com/docs/features/formats/onsong/metadata/
Metadata
= (@Metatag EOL __)*

Metatag "metatag"
= "{" _ @Metatag _ "}"
/ _ "flow"i _ ":" _ value:Flow {
return { type: "metatag", name: 'flow', value }
}
/ _ name:((@MetaName _ ":") / ImplicitMetaName) _ value:MetaValue {
return { type: "metatag", name: name.toLowerCase().trim(), value }
}

MetaName
= $[^\r\n:{,]+

ImplicitMetaName
= & { return location().start.line <= 2 } {
switch(location().start.line) {
case 1: return 'title'; // First line is implicitly title
case 2: return 'artist'; // Second line is implicitly artist
}
}

MetaValue
= $[^\r\n}]+

Flow
= head:FlowKeyword tail:(_ "," _ @FlowKeyword)+ { return [head, ...tail] }
/ (@FlowAbbr _)+

FlowKeyword
= MusicalInstruction / SectionName

FlowAbbr
= abbr:$([A-Z]i / [0-9])+ { return abbr.toUpperCase() }

// https://www.onsongapp.com/docs/features/formats/onsong/section/
Section
// {start_of_*}
= DirectiveSection
// Section with explcit name and maybe a body
/ name:SectionHeader __ items:SectionBody* { return { type: 'section', name, items } }
// Section without explicit name
/ items:SectionBody+ { return { type: 'section', name: null, items } }

DirectiveSection
= "{" _ ("start_of_" / "so") type:DirectiveSectionTypes _ name:(":" _ @MetaValue)? "}" EOL
items:SectionBody*
"{" _ ("end_of_" / "eo") DirectiveSectionTypes _ "}" EOL // FIXME: must match start tag
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

FIXME found

{
return { type: 'section', name: name || type, items }
}

DirectiveSectionTypes
= "b" "ridge"? { return "bridge" }
/ "c" "horus"? { return "chorus" }
/ "p" "art"? { return "part" }
/ "v" "erse"? { return "verse" }

SectionName
= MetaName

SectionHeader
= name:SectionName _ ":" EOL {
return name.trim()
}

SectionBody
= !SectionHeader @(Tab / Stanza) __

Stanza
= lines:Line+ {
return { type: 'stanza', lines }
}

Line
= parts:(@ChordsOverLyrics / @InlineAnnotation+ EOL) {
return {type: 'line', parts }
}

// The other way to express chords in lyrics is to place the chords on a line above the lyrics and
// use space characters to align the chords with lyrics. This is supported since most music found
// in other formats use this technique. Here is an example of chords over lyrics:
//
// Verse 1:
// D G D
// Amazing Grace, how sweet the sound,
ChordsOverLyrics
= annotations:(_ @(annotation:(Chord / MusicalInstruction) { return { annotation, column: location().start.column } }))+ EOL
lyrics:(@Lyrics EOL)? {
// FIXME: Is there a better way to do this in PEG?
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

FIXME found

// Or, is there a more idiomatic way to merge chords and lyrics into pairs?

// First annotation does not start at beginning of line, add an empty one
if(annotations[0]?.column > 1) annotations.unshift({annotation: null, column: 1})

// Ensure lyrics are a string
if(!lyrics) lyrics = ''

const items = [];

for (let index = 0; index < annotations.length; index++) {
const { annotation, column } = annotations[index];
const startColumn = column - 1;
const endColumn = annotations[index + 1]?.column - 1 || lyrics.length;
const l = lyrics.padEnd(endColumn, ' ').slice(startColumn, endColumn)

items.push({type: 'annotation', annotation, lyrics: l})
}

return items;
}

InlineAnnotation
= annotation:(BracketedChord / MusicalInstruction) lyrics:Lyrics {
return { type: 'annotation', annotation, lyrics }
}
/ annotation:(BracketedChord / MusicalInstruction) {
return { type: 'annotation', annotation, lyrics: '' }
}
/ lyrics:Lyrics {
return { type: 'annotation', lyrics, annotation: null }
}

BracketedChord "chord"
= !Escape "[" @Chord "]"
/ !Escape "[" chord:$[^\]]+ "]" {
warn(`Unknown chord: ${chord}`, location())
}

// OnSong recognizes chords using the following set of rules:
// https://www.onsongapp.com/docs/features/formats/onsong/chords/#chords-over-lyrics
Chord
= value:$(ChordLetter (ChordModifier / ChordSuffix / ChordNumericPosition)* (Space? "/" Space? ChordLetter ChordModifier?)?) {
return { type: 'chord', value }
}

// It must start with a capital A, B, C, D, E, F, G or H (used in some languages)
ChordLetter = [A-H]
ChordModifier = [#♯b♭]
ChordSuffix = "sus" / "maj"i / "min" / "man" / "m"i / "aug" / "dim"
ChordNumericPosition
= (ChordModifier / "add")? ([02345679] / "11" / "13")
/ "(" ChordNumericPosition ")"

Lyrics "lyrics"
= $Char+

Tab
= "{" _ "sot" _ "}" NewLine content:$(TabLine __)+ "{" _ "eot" _ "}" {
return { type: 'tab', content }
}
/ "{start_of_tab}" NewLine content:$(TabLine __)+ "{end_of_tab}" {
return { type: 'tab', content }
}

TabLine = [^\r\n{]+ NewLine

MusicalInstruction // e.g. (Repeat 8x)
= "(" content:$[^)]+ ")" {
return { type: 'instruction', content }
}

Char
= [^\|\[\\{#\(\r\n]

Escape
= "\\"

Space "space"
= [ \t]

NewLine "new line"
= "\r\n" / "\r" / "\n"

EOL "end of line" // Strict linebreak or end of file
= _ (NewLine / EOF)

EOF "end of file"
= !.

_ // Insignificant space
= Space*

__ // Insignificant whitespace
= (Space / NewLine)*
Loading