! Copyright (C) 2026 Serre
! See https://factorcode.org/license.txt for BSD license.
USING: accessors arrays assocs calendar classes.tuple
combinators generalizations kernel lexer math math.parser
multiline namespaces peg.ebnf present sequences 5line.midi
sequences.generalizations shuffle sorting strings threads ;
IN: 5line
! == Abstraction of a Musical Note
! * TODO: Rests, triplets, arbitrary midi (drums)
TUPLE: note
name accidental octave
length dot? tied? ;
! Tied only applies if the *previous* note is tied to this one:
! X. Y. Z == XYZ played as one tie
SYMBOLS: sharp flat natural rest
whole-note half-note quarter-note eigth-note
sixteenth-note thirtysecond-note ;
CONSTANT: shorthand-table H{
{ "w" whole-note } { "h" half-note }
{ "q" quarter-note } { "e" eigth-note }
{ "s" sixteenth-note } { "t" thirtysecond-note }
{ "#" sharp } { "b" flat } { "n" natural } }
CONSTANT: note-names { "C" "D" "E" "F" "G" "A" "B" }
! * Note Syntax:
! Ex. "Cb3-h.t" -- C3 Flat, Tied & Dotted Half Note.
EBNF: parse-note [=[
name = [A-G]
=> [[ '{ _ } >string ]]
accidental = { "#" | "b" | "n" }
=> [[ shorthand-table at ]]
octave = [0-9]
=> [[ 48 - ]]
length = { "w" | "h" | "q" | "e" | "s" | "t" }
=> [[ shorthand-table at ]]
note = name accidental? octave "-" length "."? "t"?
=> [[ 3 swap remove-nth \ note prefix unpack-tuple ]]
]=]
SYNTAX: N: scan-token parse-note suffix ;
: unparse-note ( note -- string )
{ [ name>> ]
[ accidental>> shorthand-table value-at ]
[ octave>> number>string ] [ drop "-" ]
[ length>> shorthand-table value-at ]
[ dot?>> "." f ? ] [ tied?>> "t" f ? ]
} cleave 7 narray sift concat ;
M: note present unparse-note ;
TUPLE: pattern
spots key-sig tempo time-sig ;
! (Kind of obnoxiously picky)
DEFER: (unravel-pattern-ebnf)
EBNF: parse-pattern [=[
whitespace = " "+
head = [0-9]+'/'[0-9]+ " @ " [0-9]+ " bpm in key " [-0-9]+
=> [[ [ string>number ] map sift ]]
note = (whitespace?)~ ( [0-9a-zA-Z] | [#.-] )+ (" "?)~
=> [[ >string parse-note ]]
spot = ('\n'?)~ (note)+ '\n'~
end = "|| FIN"
pattern = head '\n'~ (spot)+ end~
=> [[ (unravel-pattern-ebnf) ]]
]=]
: (unravel-pattern-ebnf) ( ast -- pattern )
pattern new
over second >>spots
over first
[ first ] [ second ] bi 2array >>time-sig
over first third >>tempo
over first fourth >>key-sig
nip ;
SYNTAX: PATTERN:
"!" parse-multiline-string parse-pattern suffix! ;
! == Working with Pitches
DEFER: (n.fix)
:: raise-note ( note by -- note' )
! Sometimes doesn't work inside of map/each calls?
note dup name>>
note-names [ index by + 7 /mod (n.fix) ] keep nth
[ [ + ] curry change-octave ] [ >>name ]
bi-curry* compose call ;
: (n.fix) ( d.octave d.note -- d.octave d.note )
dup 0 < [ [ 1 - ] [ 7 + ] bi* ] when ;
: n+ ( note -- note ) 1 raise-note ;
: n- ( note -- note ) -1 raise-note ;
! ** Should be key-aware for Naturals, double sharps etc.
: n-sharpen ( note -- note )
[ flat = f sharp ? ] change-accidental ;
: n-flatten ( note -- note )
[ sharp = f flat ? ] change-accidental ;
CONSTANT: key-sharps
{ "F" "C" "E" "D" "A" "E" }
CONSTANT: key-flats
{ "B" "E" "A" "D" "G" "C" }
: keysig-notes ( int -- notes accidentals )
7 /mod [ -6 * ] dip +
{ { [ dup 0 > ] [ key-sharps resize-array sharp ] }
{ [ dup 0 < ] [ -1 * key-flats resize-array flat ] }
[ drop f natural ]
} cond ;
! == Converting Pitch to MIDI Message Values
: note-numbers ( -- alist )
note-names { 0 2 4 5 7 9 11 } zip ;
: note>natural-midi ( note -- midi-key )
[ name>> note-numbers at ] [ octave>> ] bi 12 * + ;
: do-accidentals ( int accidental -- int )
{ { sharp [ 1 + ] } { flat [ 1 - ] } [ drop ] } case ;
: note>baseline-midi ( note -- midi-key )
[ note>natural-midi ] keep accidental>> do-accidentals ;
: note>delta-keysig ( note keysig -- int )
[ name>> ] [ keysig-notes ] bi* -rot member?
[ 0 swap do-accidentals ] [ drop 0 ] if ;
: note>midi ( note keysig -- midi-key )
over accidental>> natural = [ drop note>natural-midi ] [
[ note>delta-keysig ] [ drop note>baseline-midi ] 2bi +
] if ;
! == Working with Note Duration
! * A spot is a sequence of notes. The length of a spot is the
! length of it's shortest(?) note. Notes on a single pattern can
! only start after the end of the last spot.
CONSTANT: time-values H{
{ whole-note 1 } { half-note 1/2 }
{ quarter-note 1/4 } { eigth-note 1/8 }
{ sixteenth-note 1/16 } { thirtysecond-note 1/32 }
}
: time-val ( note -- fraction )
[ length>> time-values at ] [ dot?>> ] bi [ 3/2 * ] when ;
: spot-val ( spot -- fraction ) [ time-val ] map sort first ;
:: time-val>duration ( val tempo time-sig -- dt )
! That took embarassingly long to figure out TT_TT
time-sig second :> beat-multiplier
val beat-multiplier * :> effective-beat
effective-beat tempo / 60 * seconds ;
: note-duration ( note tempo time-sig -- dt )
[ time-val ] 2dip time-val>duration ;
: spot-duration ( spot tempo time-sig -- fraction )
[ spot-val ] 2dip time-val>duration ;
! == Playing Notes & Spots.
! * Ties are a nightmare if we insist on using "dial-for" on
! them, so instead we break up starting and ending a tone into
! distinct actions that ties can skip over.
INITIALIZED-SYMBOL: active-ties
[ { } ] ! List of MIDI Note/Channel pairs
: already-tied? ( midi-key ch. -- ? )
2array active-ties get-global member? ;
:: (play-note) ( midi-key tied? dt ch. -- )
midi-key ch.
2dup already-tied?
[ 2array [ remove ] curry
active-ties swap change-global ]
[ dial-on ]
if
tied?
[ midi-key ch. 2array [ suffix ] curry
active-ties swap change-global ]
[ midi-key dt ch. dial-off-in ]
if ;
: play-note ( note keysig tempo timesig ch. -- )
[ [ 2drop [ note>midi ] [ drop tied?>> ] 2bi ]
[ nipd note-duration ] 4 nbi
] dip (play-note) ;
: play-spot ( spot keysig tempo timesig ch. -- )
[ [ play-note ] 4 ncurry each ]
[ drop nipd spot-duration ] 5 nbi
sleep ;
: play-pattern ( pattern ch. -- )
[ { [ spots>> ] [ key-sig>> ] [ tempo>> ] [ time-sig>> ] }
cleave ] dip [ play-spot ] 4 ncurry each ;
! Multiple patterns in series we call a sequence
: play-sequence ( patterns ch. prog -- )
dupd swap chg-prog [ play-pattern ] curry each ;
! Multiple sequences in parallel we call a track
: play-track ( sequences/ch.s/progs -- )
[ first3 [ play-sequence stop ] 3curry "5Line Track"
spawn drop ]
each ;
! Stopping playback in the middle of a tie can break later
! attempts at playback if not cleaned up afterwards
: clear-note-state ( -- )
midi-panic { } active-ties set-global ;
! == Dealing with Measures
: measure-threshold ( timesig -- time-val )
! "3/4" and "6/8" are different meters, but the measures
! hold the same time-value of notes.
[ first ] [ second ] bi / ;
: measure-sum ( measure -- time-val ) [ spot-val ] map sum ;
: append-to-measures ( measures spot time-sig -- measures )
dupdd [ ?last [ { } ] unless* ] [ ] [ measure-threshold ]
tri* pick measure-sum <=
! When a measure's full, start a new one
[ 1array nip suffix ]
! Otherwise add the spot to this one.
[ suffix [ [ empty? ] [ but-last ] 1unless ] dip suffix ]
if ;
: measures ( spots timesig -- measures )
[ append-to-measures ] curry [ { } ] dip reduce ;
! * Repeating this reduce could be expensive for long patterns.
! Maybe some kind of caching is in order?