Chordpro export
This commit is contained in:
2
.gitignore
vendored
2
.gitignore
vendored
@@ -1,8 +1,8 @@
|
|||||||
# ---> Lilypond
|
# ---> Lilypond
|
||||||
*.pdf
|
*.pdf
|
||||||
|
*.cho
|
||||||
*.ps
|
*.ps
|
||||||
*.midi
|
*.midi
|
||||||
*.mid
|
*.mid
|
||||||
*.log
|
*.log
|
||||||
*~
|
*~
|
||||||
|
|
||||||
|
|||||||
@@ -38,6 +38,7 @@
|
|||||||
\include "eps_file_from_song_dir.ily"
|
\include "eps_file_from_song_dir.ily"
|
||||||
\include "title_with_category_images.ily"
|
\include "title_with_category_images.ily"
|
||||||
\include "chord_settings.ily"
|
\include "chord_settings.ily"
|
||||||
|
\include "chordpro.ily"
|
||||||
\include "transposition.ily"
|
\include "transposition.ily"
|
||||||
\include "verses_with_chords.ily"
|
\include "verses_with_chords.ily"
|
||||||
\include "arrows_in_scores.ily"
|
\include "arrows_in_scores.ily"
|
||||||
|
|||||||
@@ -158,6 +158,8 @@ override-stanza =
|
|||||||
#(define (stanza . stanzanumbers)
|
#(define (stanza . stanzanumbers)
|
||||||
#{
|
#{
|
||||||
\once \override StanzaNumber.details.custom-realstanza = ##t % set this to signal that there is a real stanza and no repeat signs
|
\once \override StanzaNumber.details.custom-realstanza = ##t % set this to signal that there is a real stanza and no repeat signs
|
||||||
|
\once \override StanzaNumber.details.custom-stanza-type = #'verse
|
||||||
|
\once \override StanzaNumber.details.custom-stanza-numbers = #stanzanumbers
|
||||||
\applyContext
|
\applyContext
|
||||||
#(lambda (context)
|
#(lambda (context)
|
||||||
(handle-stanza-numbers context stanzanumbers
|
(handle-stanza-numbers context stanzanumbers
|
||||||
@@ -169,6 +171,8 @@ ref =
|
|||||||
#(define-music-function (stanzanumbers lyrics) ((number-list? (list)) ly:music?)
|
#(define-music-function (stanzanumbers lyrics) ((number-list? (list)) ly:music?)
|
||||||
#{ \lyricmode {
|
#{ \lyricmode {
|
||||||
\once \override StanzaNumber.details.custom-realstanza = ##t % set this to signal that there is a real stanza and no repeat signs
|
\once \override StanzaNumber.details.custom-realstanza = ##t % set this to signal that there is a real stanza and no repeat signs
|
||||||
|
\once \override StanzaNumber.details.custom-stanza-type = #'ref
|
||||||
|
\once \override StanzaNumber.details.custom-stanza-numbers = #stanzanumbers
|
||||||
\applyContext
|
\applyContext
|
||||||
#(lambda (context)
|
#(lambda (context)
|
||||||
(handle-stanza-numbers context stanzanumbers
|
(handle-stanza-numbers context stanzanumbers
|
||||||
@@ -186,6 +190,8 @@ bridge =
|
|||||||
#(define-music-function (stanzanumbers lyrics) ((number-list? (list)) ly:music?)
|
#(define-music-function (stanzanumbers lyrics) ((number-list? (list)) ly:music?)
|
||||||
#{ \lyricmode {
|
#{ \lyricmode {
|
||||||
\once \override StanzaNumber.details.custom-realstanza = ##t % set this to signal that there is a real stanza and no repeat signs
|
\once \override StanzaNumber.details.custom-realstanza = ##t % set this to signal that there is a real stanza and no repeat signs
|
||||||
|
\once \override StanzaNumber.details.custom-stanza-type = #'bridge
|
||||||
|
\once \override StanzaNumber.details.custom-stanza-numbers = #stanzanumbers
|
||||||
\applyContext
|
\applyContext
|
||||||
#(lambda (context)
|
#(lambda (context)
|
||||||
(handle-stanza-numbers context stanzanumbers
|
(handle-stanza-numbers context stanzanumbers
|
||||||
@@ -201,8 +207,11 @@ bridge =
|
|||||||
|
|
||||||
% prints a repStart Sign as stanza if the tag 'repeats is kept.
|
% prints a repStart Sign as stanza if the tag 'repeats is kept.
|
||||||
% if there was a stanza already set by the stanza function with StanzaNumber.details.custom-realstanza = ##t we set that also as stanza.
|
% if there was a stanza already set by the stanza function with StanzaNumber.details.custom-realstanza = ##t we set that also as stanza.
|
||||||
|
% Sets custom-inline-text for ChordPro export so it can collect the repeat sign separately
|
||||||
repStartWithTag = \lyricmode {
|
repStartWithTag = \lyricmode {
|
||||||
\tag #'repeats {
|
\tag #'repeats {
|
||||||
|
\once \override StanzaNumber.details.custom-inline-text = \repStart
|
||||||
|
\once \override StanzaNumber.details.custom-inline-direction = #LEFT
|
||||||
\applyContext
|
\applyContext
|
||||||
#(lambda (context)
|
#(lambda (context)
|
||||||
(let ((lastStanza (ly:context-property context 'stanza))
|
(let ((lastStanza (ly:context-property context 'stanza))
|
||||||
@@ -220,6 +229,8 @@ repStartWithTag = \lyricmode {
|
|||||||
|
|
||||||
repStopWithTag = \lyricmode {
|
repStopWithTag = \lyricmode {
|
||||||
\tag #'repeats {
|
\tag #'repeats {
|
||||||
|
\once \override StanzaNumber.details.custom-inline-text = \repStop
|
||||||
|
\once \override StanzaNumber.details.custom-inline-direction = #RIGHT
|
||||||
\once \override StanzaNumber.font-series = #'normal
|
\once \override StanzaNumber.font-series = #'normal
|
||||||
\once \override StanzaNumber.direction = 1
|
\once \override StanzaNumber.direction = 1
|
||||||
\set stanza = \markup { \pad-x-right #1 \repStop }
|
\set stanza = \markup { \pad-x-right #1 \repStop }
|
||||||
|
|||||||
@@ -40,34 +40,19 @@ shiftChords = #(define-music-function (parser location xshift chords) (number? l
|
|||||||
|
|
||||||
altChord =
|
altChord =
|
||||||
#(define-music-function (parser location mainchord altchord) (ly:music? ly:music?)
|
#(define-music-function (parser location mainchord altchord) (ly:music? ly:music?)
|
||||||
(let* ((remove-point-and-click
|
(let* ((chord-name (lambda (in-pitches bass inversion context)
|
||||||
(lambda (grob)
|
(make-line-markup (list
|
||||||
(ly:grob-set-property! grob 'cause #f)
|
(ignatzek-chord-names in-pitches bass inversion context)
|
||||||
(ly:text-interface::print grob)))
|
(make-hspace-markup 0.3)
|
||||||
(chord-name (lambda (in-pitches bass inversion context) #{
|
(parenthesis-ignatzek-chord-names (music-pitches (transposable (cons (car (music-pitches mainchord)) (car in-pitches)) (music-clone altchord))) '() '() context)
|
||||||
\markup {
|
))
|
||||||
\translate #'(-0.5 . 0)
|
)))
|
||||||
\score {
|
|
||||||
\chords { \transposable #(cons (car (music-pitches mainchord)) (car in-pitches)) { #(music-clone mainchord) \klamm #(music-clone altchord) } }
|
|
||||||
\layout {
|
|
||||||
\LAYOUT
|
|
||||||
\context {
|
|
||||||
\ChordNames
|
|
||||||
\override ChordName.extra-spacing-width = #'(0 . 0.3)
|
|
||||||
\override ChordName.stencil = #remove-point-and-click
|
|
||||||
}
|
|
||||||
\context {
|
|
||||||
\Score
|
|
||||||
\override SpacingSpanner.spacing-increment = 0
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}#})))
|
|
||||||
#{
|
#{
|
||||||
\once \set chordNameFunction = #chord-name
|
\once \set chordNameFunction = #chord-name
|
||||||
#mainchord
|
#mainchord
|
||||||
#}))
|
#}))
|
||||||
|
|
||||||
|
|
||||||
% Akkorde werden so transponiert, dass sie passen, wenn man mit Kapo im angegebenen Bund spielt
|
% Akkorde werden so transponiert, dass sie passen, wenn man mit Kapo im angegebenen Bund spielt
|
||||||
capoTranspose =
|
capoTranspose =
|
||||||
#(define-music-function (fret chords) (number? ly:music?)
|
#(define-music-function (fret chords) (number? ly:music?)
|
||||||
|
|||||||
885
private_includes/base/chordpro.ily
Normal file
885
private_includes/base/chordpro.ily
Normal file
@@ -0,0 +1,885 @@
|
|||||||
|
%% ChordPro Export Engraver
|
||||||
|
%% =========================
|
||||||
|
%% Single engraver in Score context that collects all data
|
||||||
|
|
||||||
|
#(use-modules (ice-9 format))
|
||||||
|
#(use-modules (srfi srfi-1))
|
||||||
|
|
||||||
|
%% Helper function to convert German minor chord notation to ChordPro format
|
||||||
|
%% ChordPro accepts German note names (H, Fis, etc.) but not lowercase for minor
|
||||||
|
#(define (german-chord-to-chordpro chord-str)
|
||||||
|
"Convert lowercase German minor chords (e.g., 'am7', 'fis') to ChordPro format (e.g., 'Am7', 'Fism')"
|
||||||
|
(if (or (not (string? chord-str)) (string-null? chord-str))
|
||||||
|
chord-str
|
||||||
|
(let* ((first-char (string-ref chord-str 0)))
|
||||||
|
(if (char-lower-case? first-char)
|
||||||
|
;; It's a minor chord - capitalize first letter and add 'm' after accidentals
|
||||||
|
(let* ((rest-str (if (> (string-length chord-str) 1)
|
||||||
|
(substring chord-str 1)
|
||||||
|
""))
|
||||||
|
;; Find where to insert 'm': after 'is' or 'es' if present, otherwise right after root
|
||||||
|
(insert-pos (cond
|
||||||
|
((string-prefix? "is" rest-str) 2)
|
||||||
|
((string-prefix? "es" rest-str) 2)
|
||||||
|
(else 0)))
|
||||||
|
(before-m (substring rest-str 0 insert-pos))
|
||||||
|
(after-m (substring rest-str insert-pos)))
|
||||||
|
(string-append (string (char-upcase first-char)) before-m "m" after-m))
|
||||||
|
;; Already uppercase - return as is
|
||||||
|
chord-str))))
|
||||||
|
|
||||||
|
%% Helper function to convert chord music object to chord name string
|
||||||
|
#(define (music-to-chord-name chord-music)
|
||||||
|
"Extract pitches from a chord music object and convert to German chord name"
|
||||||
|
(if (not chord-music)
|
||||||
|
""
|
||||||
|
(let* ((pitches (music-pitches chord-music)))
|
||||||
|
(if (or (not pitches) (null? pitches))
|
||||||
|
"" ; No pitches found
|
||||||
|
(let* (;; Use ignatzek chord naming with German root names
|
||||||
|
(chord-markup ((chord-name:name-markup 'deutsch) pitches #f #f #f))
|
||||||
|
;; Convert markup to string
|
||||||
|
(chord-str (if (markup? chord-markup)
|
||||||
|
(markup->string chord-markup)
|
||||||
|
""))
|
||||||
|
;; Remove spaces
|
||||||
|
(cleaned (string-delete #\space chord-str)))
|
||||||
|
cleaned)))))
|
||||||
|
|
||||||
|
%% Helper function to normalize multiple spaces to single space
|
||||||
|
#(define (normalize-spaces str)
|
||||||
|
"Replace multiple consecutive spaces with a single space"
|
||||||
|
(let loop ((chars (string->list str))
|
||||||
|
(result '())
|
||||||
|
(prev-was-space #f))
|
||||||
|
(if (null? chars)
|
||||||
|
(list->string (reverse result))
|
||||||
|
(let ((char (car chars)))
|
||||||
|
(if (char=? char #\space)
|
||||||
|
(if prev-was-space
|
||||||
|
;; Skip this space
|
||||||
|
(loop (cdr chars) result #t)
|
||||||
|
;; Keep this space
|
||||||
|
(loop (cdr chars) (cons char result) #t))
|
||||||
|
;; Not a space
|
||||||
|
(loop (cdr chars) (cons char result) #f))))))
|
||||||
|
|
||||||
|
%% Helper function to extract chord names from markup, handling \altChord format
|
||||||
|
#(define (extract-chord-names-from-markup markup)
|
||||||
|
"Extract chord names from markup, handling complex structures like \altChord which produces 'B(Gm)' format"
|
||||||
|
(let* ((raw-str (cond
|
||||||
|
((string? markup) markup)
|
||||||
|
((markup? markup) (markup->string markup))
|
||||||
|
((pair? markup) (extract-chord-names-from-markup (car markup)))
|
||||||
|
(else (format #f "~a" markup))))
|
||||||
|
;; Remove all spaces
|
||||||
|
(clean-str (string-delete #\space raw-str)))
|
||||||
|
;; Check if this is an altChord format: "B(Gm)" or similar
|
||||||
|
(if (string-index clean-str #\()
|
||||||
|
;; Multiple chords: split by parentheses
|
||||||
|
(let* ((open-paren (string-index clean-str #\())
|
||||||
|
(close-paren (string-index clean-str #\)))
|
||||||
|
(first-chord (substring clean-str 0 open-paren))
|
||||||
|
(second-chord (if (and open-paren close-paren)
|
||||||
|
(substring clean-str (+ open-paren 1) close-paren)
|
||||||
|
"")))
|
||||||
|
;; Format as:[B][(Gm)] - first chord normal, second in parens
|
||||||
|
(string-append first-chord "][(" second-chord ")"))
|
||||||
|
;; Single chord: return as is
|
||||||
|
clean-str)))
|
||||||
|
|
||||||
|
%% Global state - shared across all engraver instances!
|
||||||
|
#(define chordpro-syllables-collected '())
|
||||||
|
#(define chordpro-chords-collected '())
|
||||||
|
#(define chordpro-breaks-collected '())
|
||||||
|
#(define chordpro-stanza-numbers '())
|
||||||
|
%% Format: (moment verse-idx text direction) for inline text markers like repStart/repStop
|
||||||
|
#(define chordpro-inline-texts-collected '())
|
||||||
|
%%Track break moments to detect verse changes
|
||||||
|
#(define chordpro-seen-break-moments '())
|
||||||
|
%% Increments with each verse (each chordlyrics call)
|
||||||
|
#(define chordpro-current-verse-index 0)
|
||||||
|
|
||||||
|
%% Configuration and metadata (set by layout_bottom.ily or song file)
|
||||||
|
#(define chordpro-export-enabled #t)
|
||||||
|
#(define chordpro-current-filename "output")
|
||||||
|
#(define chordpro-header-title "Untitled")
|
||||||
|
#(define chordpro-header-authors #f)
|
||||||
|
|
||||||
|
%% Write flag (ensures we only write once, not for each finalize call)
|
||||||
|
#(define chordpro-file-written #f)
|
||||||
|
|
||||||
|
%% Helper function to format stanza label for ChordPro
|
||||||
|
#(define (format-chordpro-stanza-label stanza-type stanza-numbers)
|
||||||
|
"Generate ChordPro label based on stanza type and optional numbers"
|
||||||
|
(let* ((has-numbers (and stanza-numbers (not (null? stanza-numbers))))
|
||||||
|
(numbers-string (if has-numbers
|
||||||
|
(string-join (map (lambda (n) (format #f "~a" n)) stanza-numbers) ", ")
|
||||||
|
"")))
|
||||||
|
(cond
|
||||||
|
((eq? stanza-type 'ref)
|
||||||
|
(if has-numbers
|
||||||
|
(format #f (if (defined? 'refStringWithNumbers) refStringWithNumbers "Ref. ~a:") numbers-string)
|
||||||
|
(if (defined? 'refString) refString "Ref.:")))
|
||||||
|
((eq? stanza-type 'bridge)
|
||||||
|
(if has-numbers
|
||||||
|
(format #f (if (defined? 'bridgeStringWithNumbers) bridgeStringWithNumbers "Bridge ~a:") numbers-string)
|
||||||
|
(if (defined? 'bridgeString) bridgeString "Bridge:")))
|
||||||
|
(else #f))))
|
||||||
|
|
||||||
|
%% Single engraver in Score context
|
||||||
|
#(define ChordPro_score_collector
|
||||||
|
(lambda (context)
|
||||||
|
(let ((pending-syllable #f)
|
||||||
|
(this-verse-index #f))
|
||||||
|
(make-engraver
|
||||||
|
;; Initialize - called when engraver is created (once per \chordlyrics)
|
||||||
|
((initialize engraver)
|
||||||
|
;; Each \chordlyrics call creates a new Score, so this marks a new verse
|
||||||
|
(when (> chordpro-current-verse-index 0)
|
||||||
|
;; Reset break tracking for new verse
|
||||||
|
(set! chordpro-seen-break-moments '()))
|
||||||
|
;; Store the verse index for this instance
|
||||||
|
(set! this-verse-index chordpro-current-verse-index)
|
||||||
|
;; Increment global index for next verse
|
||||||
|
(set! chordpro-current-verse-index (+ chordpro-current-verse-index 1)))
|
||||||
|
|
||||||
|
;; Event listeners
|
||||||
|
(listeners
|
||||||
|
;; LyricEvent from Lyrics context
|
||||||
|
((lyric-event engraver event)
|
||||||
|
(let ((text (ly:event-property event 'text))
|
||||||
|
(moment (ly:context-current-moment context)))
|
||||||
|
(when (string? text)
|
||||||
|
;; On first lyric event for this engraver instance, capture the verse index
|
||||||
|
(when (not this-verse-index)
|
||||||
|
(set! this-verse-index chordpro-current-verse-index)
|
||||||
|
(set! chordpro-current-verse-index (+ chordpro-current-verse-index 1)))
|
||||||
|
|
||||||
|
;; Save previous syllable if any (it had no hyphen)
|
||||||
|
(when pending-syllable
|
||||||
|
(set! chordpro-syllables-collected
|
||||||
|
(cons (list (car pending-syllable) (cadr pending-syllable) #f (caddr pending-syllable))
|
||||||
|
chordpro-syllables-collected)))
|
||||||
|
;; Store new syllable as pending (with THIS instance's verse index)
|
||||||
|
(set! pending-syllable (list moment text this-verse-index)))))
|
||||||
|
|
||||||
|
;; HyphenEvent from Lyrics context
|
||||||
|
((hyphen-event engraver event)
|
||||||
|
(when pending-syllable
|
||||||
|
(set! chordpro-syllables-collected
|
||||||
|
(cons (list (car pending-syllable) (cadr pending-syllable) #t (caddr pending-syllable))
|
||||||
|
chordpro-syllables-collected))
|
||||||
|
(set! pending-syllable #f)))
|
||||||
|
|
||||||
|
;; BreakEvent - store break moment
|
||||||
|
((break-event engraver event)
|
||||||
|
(let ((moment (ly:context-current-moment context)))
|
||||||
|
|
||||||
|
;; Store break with this instance's verse index
|
||||||
|
(set! chordpro-breaks-collected
|
||||||
|
(cons (cons moment this-verse-index) chordpro-breaks-collected)))))
|
||||||
|
|
||||||
|
;; Acknowledge grobs from child contexts
|
||||||
|
(acknowledgers
|
||||||
|
;; StanzaNumber grobs to extract stanza type and text
|
||||||
|
((stanza-number-interface engraver grob source-engraver)
|
||||||
|
(let* ((details (ly:grob-property grob 'details '()))
|
||||||
|
(custom-realstanza (ly:assoc-get 'custom-realstanza details #f))
|
||||||
|
(stanza-type (ly:assoc-get 'custom-stanza-type details 'verse))
|
||||||
|
(stanza-numbers (ly:assoc-get 'custom-stanza-numbers details '()))
|
||||||
|
;; Check for custom inline text (e.g., repStart/repStop with direction)
|
||||||
|
(custom-inline-text (ly:assoc-get 'custom-inline-text details #f))
|
||||||
|
(custom-inline-direction (ly:assoc-get 'custom-inline-direction details CENTER))
|
||||||
|
;; Try to read stanza text from multiple sources:
|
||||||
|
;; 1. From the grob's text property
|
||||||
|
(stanza-text-from-grob (ly:grob-property grob 'text #f))
|
||||||
|
;; 2. From the Lyrics context's stanza property
|
||||||
|
(lyrics-context (ly:translator-context source-engraver))
|
||||||
|
(stanza-text-from-context (if lyrics-context
|
||||||
|
(ly:context-property lyrics-context 'stanza #f)
|
||||||
|
#f))
|
||||||
|
;; Use context property if available, otherwise grob property
|
||||||
|
(stanza-markup (or stanza-text-from-context stanza-text-from-grob))
|
||||||
|
;; For ref/bridge, always use format-chordpro-stanza-label
|
||||||
|
;; For verse, extract from markup (includes roman numerals if \romanStanza was used)
|
||||||
|
(stanza-text (if (or (eq? stanza-type 'ref) (eq? stanza-type 'bridge))
|
||||||
|
(format-chordpro-stanza-label stanza-type stanza-numbers)
|
||||||
|
(if (markup? stanza-markup)
|
||||||
|
(markup->string stanza-markup)
|
||||||
|
(if stanza-markup
|
||||||
|
(format #f "~a" stanza-markup)
|
||||||
|
#f))))
|
||||||
|
(existing (assoc this-verse-index chordpro-stanza-numbers))
|
||||||
|
(moment (ly:context-current-moment (ly:translator-context source-engraver)))
|
||||||
|
(direction (ly:grob-property grob 'direction CENTER)))
|
||||||
|
|
||||||
|
;; If custom-inline-text is set, always collect it as inline text (regardless of realstanza)
|
||||||
|
;; This allows repStart/repStop to be collected even when combined with real stanza numbers
|
||||||
|
(when custom-inline-text
|
||||||
|
(set! chordpro-inline-texts-collected
|
||||||
|
(cons (list moment this-verse-index custom-inline-text custom-inline-direction)
|
||||||
|
chordpro-inline-texts-collected)))
|
||||||
|
|
||||||
|
;; If this is NOT a real stanza marker (custom-realstanza not set or ##f),
|
||||||
|
;; collect it as inline text with direction info
|
||||||
|
(when (and (not custom-realstanza) (not custom-inline-text))
|
||||||
|
(when stanza-text ; Only if there's text to display
|
||||||
|
(set! chordpro-inline-texts-collected
|
||||||
|
(cons (list moment this-verse-index stanza-text direction)
|
||||||
|
chordpro-inline-texts-collected))))
|
||||||
|
|
||||||
|
;; Store or update stanza info for this verse (only if it's a real stanza marker)
|
||||||
|
;; Entry format: (verse-idx stanza-text stanza-type realstanza-flag)
|
||||||
|
(when custom-realstanza
|
||||||
|
(if existing
|
||||||
|
;; Update existing entry with type and text if available
|
||||||
|
;; IMPORTANT: Don't overwrite realstanza=##t markers and their text
|
||||||
|
(let* ((existing-text (cadr existing))
|
||||||
|
(existing-type (caddr existing))
|
||||||
|
(existing-realstanza (if (> (length existing) 3) (cadddr existing) #f)))
|
||||||
|
(set! chordpro-stanza-numbers
|
||||||
|
(map (lambda (entry)
|
||||||
|
(if (= (car entry) this-verse-index)
|
||||||
|
(list (car entry)
|
||||||
|
;; If existing entry is a real stanza, don't overwrite text
|
||||||
|
;; Otherwise, use new text if available
|
||||||
|
(if existing-realstanza
|
||||||
|
existing-text
|
||||||
|
(or stanza-text existing-text))
|
||||||
|
;; Update type only if custom-realstanza is set
|
||||||
|
(if custom-realstanza stanza-type existing-type)
|
||||||
|
;; Preserve ##t realstanza flag
|
||||||
|
(or existing-realstanza custom-realstanza))
|
||||||
|
entry))
|
||||||
|
chordpro-stanza-numbers)))
|
||||||
|
;; Create new entry with type, text, and realstanza flag
|
||||||
|
(set! chordpro-stanza-numbers
|
||||||
|
(cons (list this-verse-index stanza-text stanza-type custom-realstanza)
|
||||||
|
chordpro-stanza-numbers)))))) ; closes set!, if, when, stanza-number-interface
|
||||||
|
|
||||||
|
;; ChordName grobs from ChordNames context
|
||||||
|
;; Store grob reference - visibility will be recorded by ChordPro_chord_visibility_recorder
|
||||||
|
((chord-name-interface engraver grob source-engraver)
|
||||||
|
(let* ((moment (ly:context-current-moment context))
|
||||||
|
;; Get details property to check for altChord
|
||||||
|
(details (ly:grob-property grob 'details '()))
|
||||||
|
(alt-main-name (assoc-get 'alt-chord-main-name details #f))
|
||||||
|
(alt-alt-name (assoc-get 'alt-chord-alt-name details #f))
|
||||||
|
;; Extract chord name
|
||||||
|
(chord-name-final
|
||||||
|
(if (and alt-main-name alt-alt-name)
|
||||||
|
;; This is an altChord - use the pre-extracted names
|
||||||
|
(let ((main (german-chord-to-chordpro alt-main-name))
|
||||||
|
(alt (german-chord-to-chordpro alt-alt-name)))
|
||||||
|
;; Check if main is empty - if so, only output alt in parens
|
||||||
|
(if (string-null? main)
|
||||||
|
(string-append "[(" alt ")]") ; Only alt chord: [(D7)]
|
||||||
|
;; Format as: [B][(Gm)] (complete with all brackets)
|
||||||
|
(string-append "[" main "][(" alt ")]")))
|
||||||
|
;; Normal chord - extract from markup text
|
||||||
|
(let* ((chord-text (ly:grob-property grob 'text))
|
||||||
|
(chord-names-str (extract-chord-names-from-markup chord-text))
|
||||||
|
;; Check if it contains "][(": multiple chords (shouldn't happen anymore)
|
||||||
|
(has-bracket (string-index chord-names-str #\])))
|
||||||
|
(if has-bracket
|
||||||
|
;; Multiple chords from old logic: manually split and process
|
||||||
|
(let* ((bracket-pos (string-index chord-names-str #\]))
|
||||||
|
(first-part (substring chord-names-str 0 bracket-pos))
|
||||||
|
(rest (substring chord-names-str bracket-pos))
|
||||||
|
(open-pos (string-index rest #\())
|
||||||
|
(close-pos (string-index rest #\)))
|
||||||
|
(second-part (if (and open-pos close-pos)
|
||||||
|
(substring rest (+ open-pos 1) close-pos)
|
||||||
|
""))
|
||||||
|
(first-converted (german-chord-to-chordpro first-part))
|
||||||
|
(second-converted (german-chord-to-chordpro second-part)))
|
||||||
|
(string-append first-converted "][(" second-converted ")"))
|
||||||
|
;; Single chord: just convert
|
||||||
|
(german-chord-to-chordpro chord-names-str))))))
|
||||||
|
;; Store grob reference along with chord data (unless empty)
|
||||||
|
;; Format: (moment chord-name verse-index grob)
|
||||||
|
;; Filter out completely empty chords, but convert ][(X) to [(X)]
|
||||||
|
(let ((processed-chord-name
|
||||||
|
(cond
|
||||||
|
;; Empty chord
|
||||||
|
((or (string-null? chord-name-final)
|
||||||
|
(string=? chord-name-final "")
|
||||||
|
(string=? chord-name-final "]["))
|
||||||
|
#f) ; Filter out
|
||||||
|
;; Pattern ][(X) - empty main, only alt in parens: convert to [(X)]
|
||||||
|
((string-prefix? "][(" chord-name-final)
|
||||||
|
;; ][(D7) → [(D7)]
|
||||||
|
(let* ((inner (substring chord-name-final 3 (- (string-length chord-name-final) 1))))
|
||||||
|
(string-append "[(" inner ")]")))
|
||||||
|
;; Normal chord
|
||||||
|
(else chord-name-final))))
|
||||||
|
(when processed-chord-name
|
||||||
|
(set! chordpro-chords-collected
|
||||||
|
(cons (list moment processed-chord-name this-verse-index grob) chordpro-chords-collected))))))
|
||||||
|
|
||||||
|
;; LyricText grobs to get stanza number
|
||||||
|
((lyric-syllable-interface engraver grob source-engraver)
|
||||||
|
(let* ((stanza-grob (ly:grob-property grob 'stanza #f))
|
||||||
|
;; Get the Lyrics context from the source engraver
|
||||||
|
(lyrics-context (ly:translator-context source-engraver))
|
||||||
|
(stanza-context (if lyrics-context
|
||||||
|
(ly:context-property lyrics-context 'stanza #f)
|
||||||
|
#f))
|
||||||
|
(stanza-value (or stanza-grob stanza-context)))
|
||||||
|
|
||||||
|
(when stanza-value
|
||||||
|
;; Only update existing stanza entries (don't create new ones)
|
||||||
|
;; New entries should only be created by stanza-number-interface
|
||||||
|
(let* ((existing (assoc this-verse-index chordpro-stanza-numbers))
|
||||||
|
(existing-text (if existing (cadr existing) #f))
|
||||||
|
(stanza-text (if (markup? stanza-value)
|
||||||
|
(markup->string stanza-value)
|
||||||
|
(format #f "~a" stanza-value))))
|
||||||
|
;; Only update if entry exists and existing text is empty/false and new text is non-empty
|
||||||
|
(when (and existing
|
||||||
|
(not (and (string? existing-text) (not (string-null? existing-text))))
|
||||||
|
(string? stanza-text)
|
||||||
|
(not (string-null? stanza-text)))
|
||||||
|
;; Update existing entry with text (keep type and realstanza flag)
|
||||||
|
(set! chordpro-stanza-numbers
|
||||||
|
(map (lambda (entry)
|
||||||
|
(if (= (car entry) this-verse-index)
|
||||||
|
(list (car entry)
|
||||||
|
(or existing-text stanza-text) ; Keep existing text if present
|
||||||
|
(caddr entry) ; Keep stanza-type
|
||||||
|
(if (> (length entry) 3) (cadddr entry) #f)) ; Keep realstanza-flag if exists
|
||||||
|
entry))
|
||||||
|
chordpro-stanza-numbers))))))) ; schließt lyric-syllable-interface
|
||||||
|
) ; schließt acknowledgers
|
||||||
|
;; End of timestep
|
||||||
|
((stop-translation-timestep engraver)
|
||||||
|
(when pending-syllable
|
||||||
|
(set! chordpro-syllables-collected
|
||||||
|
(cons (list (car pending-syllable) (cadr pending-syllable) #f (caddr pending-syllable))
|
||||||
|
chordpro-syllables-collected))
|
||||||
|
(set! pending-syllable #f)))
|
||||||
|
|
||||||
|
;; Finalize - just write debug, don't filter chords yet
|
||||||
|
;; (Filtering happens in chordpro-write-from-engraver-data where grob properties are final)
|
||||||
|
((finalize engraver)
|
||||||
|
;; Save any remaining pending syllable
|
||||||
|
(when pending-syllable
|
||||||
|
(set! chordpro-syllables-collected
|
||||||
|
(cons (list (car pending-syllable) (cadr pending-syllable) #f (caddr pending-syllable))
|
||||||
|
chordpro-syllables-collected))
|
||||||
|
(set! pending-syllable #f))
|
||||||
|
|
||||||
|
;; Write debug output (overwrite each time, final version will be complete)
|
||||||
|
(with-output-to-file "/tmp/chordpro_debug.txt"
|
||||||
|
(lambda ()
|
||||||
|
(display (format #f "ChordPro: Collected ~a syllables, ~a chords (unfiltered), ~a breaks, ~a verses\n"
|
||||||
|
(length chordpro-syllables-collected)
|
||||||
|
(length chordpro-chords-collected)
|
||||||
|
(length chordpro-breaks-collected)
|
||||||
|
chordpro-current-verse-index))
|
||||||
|
(display "All syllables:\n")
|
||||||
|
(for-each
|
||||||
|
(lambda (syl)
|
||||||
|
(display (format #f " V~a: '~a' hyphen=~a at ~a\n"
|
||||||
|
(cadddr syl) (cadr syl) (if (caddr syl) "YES" "NO") (car syl))))
|
||||||
|
(reverse chordpro-syllables-collected))
|
||||||
|
(newline)
|
||||||
|
(display "Breaks:\n")
|
||||||
|
(for-each
|
||||||
|
(lambda (brk)
|
||||||
|
(display (format #f " V~a at ~a\n" (cdr brk) (car brk))))
|
||||||
|
(reverse chordpro-breaks-collected))
|
||||||
|
(newline)
|
||||||
|
(display "All chords (after visibility filtering):\n")
|
||||||
|
(for-each
|
||||||
|
(lambda (chord)
|
||||||
|
(display (format #f " V~a: ~a at ~a\n"
|
||||||
|
(caddr chord) (cadr chord) (car chord))))
|
||||||
|
(reverse chordpro-chords-collected))
|
||||||
|
(newline)
|
||||||
|
(display "Stanza numbers:\n")
|
||||||
|
(for-each
|
||||||
|
(lambda (stanza-entry)
|
||||||
|
(display (format #f " V~a: '~a' (type: ~a, real: ~a)\n"
|
||||||
|
(car stanza-entry) (cadr stanza-entry) (caddr stanza-entry) (cadddr stanza-entry))))
|
||||||
|
(reverse chordpro-stanza-numbers))
|
||||||
|
(newline)
|
||||||
|
(display "Inline texts (repStart/repStop etc.):\n")
|
||||||
|
(for-each
|
||||||
|
(lambda (inline-entry)
|
||||||
|
(display (format #f " V~a: '~a' at ~a (dir: ~a)\n"
|
||||||
|
(cadr inline-entry) (caddr inline-entry) (car inline-entry) (cadddr inline-entry))))
|
||||||
|
(reverse chordpro-inline-texts-collected)))))
|
||||||
|
|
||||||
|
))))
|
||||||
|
|
||||||
|
%% Helper functions to format and write ChordPro
|
||||||
|
#(define (chordpro-write-from-engraver-data num-verses)
|
||||||
|
"Write ChordPro file from collected engraver data"
|
||||||
|
(let* ((filename (if (defined? 'chordpro-current-filename)
|
||||||
|
chordpro-current-filename
|
||||||
|
"output"))
|
||||||
|
(output-file (string-append filename ".cho"))
|
||||||
|
;; Reverse all lists (they were collected in reverse order)
|
||||||
|
(syllables (reverse chordpro-syllables-collected))
|
||||||
|
(chords (reverse chordpro-chords-collected))
|
||||||
|
(breaks (reverse chordpro-breaks-collected)))
|
||||||
|
|
||||||
|
(with-output-to-file output-file
|
||||||
|
(lambda ()
|
||||||
|
;; Write metadata
|
||||||
|
(display (format #f "{title: ~a}\n"
|
||||||
|
(if (defined? 'chordpro-header-title)
|
||||||
|
chordpro-header-title
|
||||||
|
"Untitled")))
|
||||||
|
(when (and (defined? 'chordpro-header-authors) chordpro-header-authors)
|
||||||
|
(display (format #f "{artist: ~a}\n"
|
||||||
|
(format-chordpro-authors chordpro-header-authors))))
|
||||||
|
(newline)
|
||||||
|
|
||||||
|
;; Write each verse in reverse order (they were collected backwards)
|
||||||
|
(let loop ((verse-idx (- num-verses 1)))
|
||||||
|
(when (>= verse-idx 0)
|
||||||
|
;; Get syllables, chords and breaks for this verse
|
||||||
|
(let* ((verse-syllables (filter (lambda (s) (= (cadddr s) verse-idx)) syllables))
|
||||||
|
(verse-chords-raw (filter (lambda (c)
|
||||||
|
(and (list? c)
|
||||||
|
(>= (length c) 3)
|
||||||
|
(= (caddr c) verse-idx)))
|
||||||
|
chords))
|
||||||
|
;; Simple filtering: remove repeated consecutive chords
|
||||||
|
(verse-chords (let filter-repeats ((chords-left verse-chords-raw)
|
||||||
|
(last-chord #f)
|
||||||
|
(result '()))
|
||||||
|
(if (null? chords-left)
|
||||||
|
(reverse result)
|
||||||
|
(let* ((chord (car chords-left))
|
||||||
|
(chord-name (cadr chord)))
|
||||||
|
(if (equal? chord-name last-chord)
|
||||||
|
;; Skip repeated chord
|
||||||
|
(filter-repeats (cdr chords-left) last-chord result)
|
||||||
|
;; Include new chord
|
||||||
|
(filter-repeats (cdr chords-left) chord-name
|
||||||
|
(cons (list (car chord) chord-name (caddr chord)) result)))))))
|
||||||
|
(verse-breaks (filter (lambda (b) (= (cdr b) verse-idx)) breaks))
|
||||||
|
(verse-inline-texts (filter (lambda (r) (= (cadr r) verse-idx))
|
||||||
|
(reverse chordpro-inline-texts-collected)))
|
||||||
|
;; Get stanza info (number, type, and realstanza flag) for this verse
|
||||||
|
(stanza-entry (find (lambda (s) (= (car s) verse-idx)) chordpro-stanza-numbers))
|
||||||
|
(stanza-num (if stanza-entry (cadr stanza-entry) #f))
|
||||||
|
(stanza-type (if stanza-entry (caddr stanza-entry) 'verse))
|
||||||
|
(realstanza (if stanza-entry (cadddr stanza-entry) #f))
|
||||||
|
;; Determine ChordPro directive based on type
|
||||||
|
(start-directive (cond
|
||||||
|
((eq? stanza-type 'ref) "start_of_chorus")
|
||||||
|
((eq? stanza-type 'bridge) "start_of_bridge")
|
||||||
|
(else "start_of_verse")))
|
||||||
|
(end-directive (cond
|
||||||
|
((eq? stanza-type 'ref) "end_of_chorus")
|
||||||
|
((eq? stanza-type 'bridge) "end_of_bridge")
|
||||||
|
(else "end_of_verse"))))
|
||||||
|
|
||||||
|
(when (not (null? verse-syllables))
|
||||||
|
;; If realstanza is ##t, output with ChordPro directives
|
||||||
|
;; Otherwise, just output the text (e.g., for repStartWithTag/repStopWithTag)
|
||||||
|
(if realstanza
|
||||||
|
(begin
|
||||||
|
(if (and stanza-num (not (string-null? stanza-num)))
|
||||||
|
(display (format #f "{~a: label=\"~a\"}\n" start-directive stanza-num))
|
||||||
|
(display (format #f "{~a}\n" start-directive)))
|
||||||
|
(display (format-verse-as-chordpro verse-syllables verse-chords verse-breaks verse-inline-texts))
|
||||||
|
(display (format #f "\n{~a}\n\n" end-directive)))
|
||||||
|
(begin
|
||||||
|
;; For non-real stanzas, just output stanza marker as comment if present
|
||||||
|
(when (and stanza-num (not (string-null? stanza-num)))
|
||||||
|
(display (format #f "# ~a\n" stanza-num)))
|
||||||
|
(display (format-verse-as-chordpro verse-syllables verse-chords verse-breaks verse-inline-texts))
|
||||||
|
(display "\n\n"))))
|
||||||
|
|
||||||
|
(loop (- verse-idx 1)))))))
|
||||||
|
|
||||||
|
))
|
||||||
|
|
||||||
|
#(define (format-verse-as-chordpro syllables chords breaks inline-texts)
|
||||||
|
"Format one verse as ChordPro text with line breaks.
|
||||||
|
Chords are placed at syllable boundaries, not word boundaries.
|
||||||
|
Inline texts (𝄆 𝄇 etc.) are inserted based on their direction property."
|
||||||
|
(let* (;; Get break moments
|
||||||
|
(break-moments (sort (map (lambda (b) (ly:moment-main (car b))) breaks) <))
|
||||||
|
;; Map each chord to the nearest following syllable
|
||||||
|
(chord-to-syllable-map (map-chords-to-syllables chords syllables))
|
||||||
|
;; Sort inline texts by moment
|
||||||
|
(sorted-inline-texts (sort inline-texts (lambda (a b) (ly:moment<? (car a) (car b)))))
|
||||||
|
;; Build formatted output
|
||||||
|
(lines '())
|
||||||
|
(current-line '())
|
||||||
|
(current-word-parts '())
|
||||||
|
(pending-word-suffix-chords '()) ; Collect suffix chords during word building
|
||||||
|
(word-start-moment #f))
|
||||||
|
|
||||||
|
;; Process each syllable
|
||||||
|
(let process-syllables ((syls-left syllables) (syl-idx 0) (breaks-left break-moments) (inlines-left sorted-inline-texts) (pending-line-chords '()))
|
||||||
|
(if (null? syls-left)
|
||||||
|
;; Finish last word and line
|
||||||
|
(begin
|
||||||
|
(unless (null? current-word-parts)
|
||||||
|
;; Append pending suffix chords to the last word part
|
||||||
|
(when (not (null? pending-word-suffix-chords))
|
||||||
|
(let ((last-part (car current-word-parts))
|
||||||
|
(suffix-str (string-concatenate (reverse pending-word-suffix-chords))))
|
||||||
|
(set! current-word-parts (cons (string-append last-part suffix-str) (cdr current-word-parts)))
|
||||||
|
(set! pending-word-suffix-chords '())))
|
||||||
|
(set! current-line (cons (string-concatenate (reverse current-word-parts)) current-line)))
|
||||||
|
(unless (null? current-line)
|
||||||
|
(set! lines (cons (reverse current-line) lines))))
|
||||||
|
|
||||||
|
;; Process next syllable
|
||||||
|
(let* ((syl-entry (car syls-left))
|
||||||
|
(syl-moment (ly:moment-main (car syl-entry)))
|
||||||
|
(syl-text-raw (cadr syl-entry))
|
||||||
|
;; Normalize: treat whitespace-only strings as empty (lyric extenders come as spaces)
|
||||||
|
(syl-text (if (and (string? syl-text-raw)
|
||||||
|
(not (string-null? syl-text-raw))
|
||||||
|
(string-every char-set:whitespace syl-text-raw))
|
||||||
|
""
|
||||||
|
syl-text-raw))
|
||||||
|
(has-hyphen (caddr syl-entry))
|
||||||
|
(rest-syls (cdr syls-left))
|
||||||
|
;; Find chords for this syllable position (including extenders!)
|
||||||
|
(syl-chords (filter (lambda (cm) (= (caddr cm) syl-idx)) chord-to-syllable-map))
|
||||||
|
;; Separate chords into prefix (before/on syllable) and suffix (after syllable)
|
||||||
|
;; Separate chords into prefix (before/on syllable) and suffix (after syllable)
|
||||||
|
(prefix-chords (filter (lambda (c) (<= (ly:moment-main (car c)) syl-moment)) syl-chords))
|
||||||
|
(suffix-chords-all (filter (lambda (c) (> (ly:moment-main (car c)) syl-moment)) syl-chords))
|
||||||
|
;; Filter suffix chords: only keep those where NO line break exists between syllable and chord
|
||||||
|
;; If a break is between the syllable and chord, the chord belongs to the next line
|
||||||
|
(suffix-chords (filter (lambda (c)
|
||||||
|
(let ((chord-moment (ly:moment-main (car c))))
|
||||||
|
;; Keep chord if no break exists, or if break is not between syl and chord
|
||||||
|
(or (null? breaks-left)
|
||||||
|
(let ((next-break (car breaks-left)))
|
||||||
|
;; Only keep if break is AFTER the chord (or before/at syllable)
|
||||||
|
;; Reject if: syl-moment < next-break <= chord-moment
|
||||||
|
(not (and (> next-break syl-moment)
|
||||||
|
(<= next-break chord-moment)))))))
|
||||||
|
suffix-chords-all))
|
||||||
|
;; Collect chords that were filtered out - they belong to the next line
|
||||||
|
(next-line-chords (filter (lambda (c)
|
||||||
|
(let ((chord-moment (ly:moment-main (car c))))
|
||||||
|
(and (not (null? breaks-left))
|
||||||
|
(let ((next-break (car breaks-left)))
|
||||||
|
(and (> next-break syl-moment)
|
||||||
|
(<= next-break chord-moment))))))
|
||||||
|
suffix-chords-all))
|
||||||
|
;; Create chord prefix: add space after chord if it plays on a rest (before syllable)
|
||||||
|
(chord-prefix (if (null? prefix-chords)
|
||||||
|
""
|
||||||
|
(string-join
|
||||||
|
(map (lambda (c)
|
||||||
|
(let* ((chord-moment (ly:moment-main (car c)))
|
||||||
|
(chord-name (cadr c))
|
||||||
|
;; Check if chord plays before syllable (on a rest)
|
||||||
|
(on-rest? (< chord-moment syl-moment))
|
||||||
|
;; Check if chord already has brackets
|
||||||
|
(has-brackets (string-prefix? "[" chord-name)))
|
||||||
|
(if has-brackets
|
||||||
|
;; Already has brackets - just add space if needed
|
||||||
|
(if on-rest?
|
||||||
|
(string-append chord-name " ")
|
||||||
|
chord-name)
|
||||||
|
;; Add brackets
|
||||||
|
(if on-rest?
|
||||||
|
(string-append "[" chord-name "] ")
|
||||||
|
(string-append "[" chord-name "]")))))
|
||||||
|
prefix-chords)
|
||||||
|
"")))
|
||||||
|
;; Create chord suffix for chords that play after the syllable (on rests)
|
||||||
|
(chord-suffix (if (null? suffix-chords)
|
||||||
|
""
|
||||||
|
(string-join
|
||||||
|
(map (lambda (c)
|
||||||
|
(let ((chord-name (cadr c)))
|
||||||
|
;; Check if chord already has brackets (e.g., "[(D7)]")
|
||||||
|
(if (string-prefix? "[" chord-name)
|
||||||
|
chord-name ; Already has brackets
|
||||||
|
(string-append "[" chord-name "]"))))
|
||||||
|
suffix-chords)
|
||||||
|
"")))
|
||||||
|
;; Check for line break
|
||||||
|
(next-syl-moment (if (null? rest-syls) 999999 (ly:moment-main (car (car rest-syls)))))
|
||||||
|
(break-here (and (not (null? breaks-left))
|
||||||
|
(let ((next-break (car breaks-left)))
|
||||||
|
(and (> next-break syl-moment)
|
||||||
|
(<= next-break next-syl-moment))))))
|
||||||
|
|
||||||
|
;; Check for inline texts at this syllable position
|
||||||
|
(let* ((inline-result
|
||||||
|
(let collect-inlines ((inls inlines-left) (collected '()))
|
||||||
|
(if (or (null? inls)
|
||||||
|
(let ((inl-entry (car inls)))
|
||||||
|
(> (ly:moment-main (car inl-entry)) syl-moment)))
|
||||||
|
(list (reverse collected) inls)
|
||||||
|
(collect-inlines (cdr inls) (cons (car inls) collected)))))
|
||||||
|
(inlines-to-insert (car inline-result))
|
||||||
|
(new-inlines-left (cadr inline-result))
|
||||||
|
;; Separate by direction: LEFT texts go before, RIGHT texts go after
|
||||||
|
(before-texts (filter (lambda (i) (eqv? (cadddr i) LEFT)) inlines-to-insert))
|
||||||
|
(after-texts (filter (lambda (i) (eqv? (cadddr i) RIGHT)) inlines-to-insert)))
|
||||||
|
|
||||||
|
;; If this is the start of a new line and we have pending chords from the previous line
|
||||||
|
(when (and (null? current-line) (not (null? pending-line-chords)))
|
||||||
|
;; If we're continuing a word (current-word-parts not empty), add chords as infix
|
||||||
|
;; Otherwise add them as a separate word-like element
|
||||||
|
(if (not (null? current-word-parts))
|
||||||
|
;; Add pending chords as infix to the last word part
|
||||||
|
(let* ((last-part (car current-word-parts))
|
||||||
|
(rest-parts (cdr current-word-parts))
|
||||||
|
(pending-chord-str (string-join
|
||||||
|
(map (lambda (c)
|
||||||
|
(let ((chord-name (cadr c)))
|
||||||
|
(if (string-prefix? "[" chord-name)
|
||||||
|
chord-name ; Already has brackets
|
||||||
|
(string-append "[" chord-name "]"))))
|
||||||
|
pending-line-chords)
|
||||||
|
"")))
|
||||||
|
(set! current-word-parts (cons (string-append last-part pending-chord-str) rest-parts)))
|
||||||
|
;; No word continuation - add as separate element
|
||||||
|
(let ((pending-chord-str (string-join
|
||||||
|
(map (lambda (c)
|
||||||
|
(let ((chord-name (cadr c)))
|
||||||
|
(if (string-prefix? "[" chord-name)
|
||||||
|
chord-name ; Already has brackets
|
||||||
|
(string-append "[" chord-name "]"))))
|
||||||
|
pending-line-chords)
|
||||||
|
"")))
|
||||||
|
(set! current-line (cons pending-chord-str current-line)))))
|
||||||
|
|
||||||
|
;; Insert texts that should appear BEFORE the syllable (direction = LEFT)
|
||||||
|
(for-each
|
||||||
|
(lambda (inline-text)
|
||||||
|
(set! current-line (cons (caddr inline-text) current-line)))
|
||||||
|
before-texts)
|
||||||
|
|
||||||
|
;; Track word start
|
||||||
|
(when (null? current-word-parts)
|
||||||
|
(set! word-start-moment syl-moment))
|
||||||
|
|
||||||
|
;; Add text to current word only if syllable is not empty (not a lyric extender _)
|
||||||
|
;; Handle chords: if we're continuing a word (current-word-parts not empty),
|
||||||
|
;; prefix chords should be inserted between syllables as infixes (without trailing space)
|
||||||
|
(unless (string-null? syl-text)
|
||||||
|
(if (null? current-word-parts)
|
||||||
|
;; First syllable of word: use prefix as normal
|
||||||
|
(set! current-word-parts (cons (string-append chord-prefix syl-text) current-word-parts))
|
||||||
|
;; Continuation of word: insert prefix chord between previous syllable and this one
|
||||||
|
(begin
|
||||||
|
;; Append prefix chord to the LAST syllable (strip trailing space from chord-prefix for infix use)
|
||||||
|
(unless (string-null? chord-prefix)
|
||||||
|
(let* ((last-part (car current-word-parts))
|
||||||
|
(rest-parts (cdr current-word-parts))
|
||||||
|
;; Remove trailing space from chord-prefix if present
|
||||||
|
(chord-infix (if (string-suffix? " " chord-prefix)
|
||||||
|
(substring chord-prefix 0 (- (string-length chord-prefix) 1))
|
||||||
|
chord-prefix)))
|
||||||
|
(set! current-word-parts (cons (string-append last-part chord-infix) rest-parts))))
|
||||||
|
;; Add current syllable
|
||||||
|
(set! current-word-parts (cons syl-text current-word-parts)))))
|
||||||
|
|
||||||
|
;; Always collect suffix chords (even from extenders), they'll be output at word end
|
||||||
|
(unless (string-null? chord-suffix)
|
||||||
|
(set! pending-word-suffix-chords (cons chord-suffix pending-word-suffix-chords)))
|
||||||
|
|
||||||
|
;; Complete word only if: no hyphen AND syllable has text (not an extender)
|
||||||
|
(when (and (not has-hyphen) (not (string-null? syl-text)))
|
||||||
|
(if (null? current-word-parts)
|
||||||
|
;; No word parts (shouldn't happen) - just output suffix chords if any
|
||||||
|
(unless (null? pending-word-suffix-chords)
|
||||||
|
(set! current-line (cons (string-concatenate (reverse pending-word-suffix-chords)) current-line))
|
||||||
|
(set! pending-word-suffix-chords '()))
|
||||||
|
;; Normal word completion with all collected suffix chords
|
||||||
|
(let ((word-with-suffix
|
||||||
|
(if (null? pending-word-suffix-chords)
|
||||||
|
(string-concatenate (reverse current-word-parts))
|
||||||
|
(string-append
|
||||||
|
(string-concatenate (reverse current-word-parts))
|
||||||
|
(string-concatenate (reverse pending-word-suffix-chords))))))
|
||||||
|
(set! current-line (cons word-with-suffix current-line))
|
||||||
|
(set! current-word-parts '())
|
||||||
|
(set! pending-word-suffix-chords '()))))
|
||||||
|
(for-each
|
||||||
|
(lambda (inline-text)
|
||||||
|
(set! current-line (cons (caddr inline-text) current-line)))
|
||||||
|
after-texts)
|
||||||
|
|
||||||
|
;; If break here, complete line (word parts continue on next line)
|
||||||
|
(if break-here
|
||||||
|
(begin
|
||||||
|
(set! lines (cons (reverse current-line) lines))
|
||||||
|
(set! current-line '())
|
||||||
|
(process-syllables rest-syls (+ syl-idx 1) (cdr breaks-left) new-inlines-left next-line-chords))
|
||||||
|
(process-syllables rest-syls (+ syl-idx 1) breaks-left new-inlines-left '())))))
|
||||||
|
|
||||||
|
;; Format all lines and normalize multiple spaces
|
||||||
|
(string-join
|
||||||
|
(map (lambda (line-words)
|
||||||
|
(let ((line (string-join line-words " ")))
|
||||||
|
(normalize-spaces line)))
|
||||||
|
(reverse lines))
|
||||||
|
"\n"))))
|
||||||
|
|
||||||
|
#(define (map-chords-to-syllables chords syllables)
|
||||||
|
"For each chord, find the nearest following syllable.
|
||||||
|
If no following syllable within tolerance, use the last preceding syllable.
|
||||||
|
Returns list of (chord-moment chord-name syllable-index)"
|
||||||
|
(let ((tolerance (ly:make-moment 1/4)))
|
||||||
|
(filter-map
|
||||||
|
(lambda (chord-entry)
|
||||||
|
(let* ((chord-moment (car chord-entry))
|
||||||
|
(chord-name (cadr chord-entry))
|
||||||
|
;; Find syllables that start AT or AFTER this chord (within tolerance)
|
||||||
|
(candidate-syllables-forward
|
||||||
|
(filter-map
|
||||||
|
(lambda (syl-entry syl-idx)
|
||||||
|
(let* ((syl-moment (car syl-entry))
|
||||||
|
(diff (ly:moment-sub syl-moment chord-moment)))
|
||||||
|
;; Syllable must start at or after chord, within tolerance
|
||||||
|
(if (and (not (ly:moment<? diff (ly:make-moment 0))) ; diff >= 0
|
||||||
|
(ly:moment<=? diff tolerance))
|
||||||
|
(list diff syl-idx)
|
||||||
|
#f)))
|
||||||
|
syllables
|
||||||
|
(iota (length syllables))))) ; syllable indices
|
||||||
|
|
||||||
|
;; If no forward match, try to find the last syllable BEFORE the chord
|
||||||
|
(if (not (null? candidate-syllables-forward))
|
||||||
|
;; Pick the closest following syllable (smallest diff)
|
||||||
|
(let* ((sorted (sort candidate-syllables-forward (lambda (a b) (ly:moment<? (car a) (car b)))))
|
||||||
|
(best (car sorted))
|
||||||
|
(best-syl-idx (cadr best)))
|
||||||
|
(list chord-moment chord-name best-syl-idx))
|
||||||
|
;; No following syllable - find last preceding syllable
|
||||||
|
(let ((preceding-syllables
|
||||||
|
(filter-map
|
||||||
|
(lambda (syl-entry syl-idx)
|
||||||
|
(let* ((syl-moment (car syl-entry))
|
||||||
|
(diff (ly:moment-sub chord-moment syl-moment)))
|
||||||
|
;; Chord must come after syllable (diff > 0)
|
||||||
|
(if (ly:moment<? (ly:make-moment 0) diff)
|
||||||
|
(list diff syl-idx syl-moment)
|
||||||
|
#f)))
|
||||||
|
syllables
|
||||||
|
(iota (length syllables)))))
|
||||||
|
(if (null? preceding-syllables)
|
||||||
|
#f ; No syllables at all - skip this chord
|
||||||
|
;; Pick the latest preceding syllable (smallest diff = closest before chord)
|
||||||
|
(let* ((sorted (sort preceding-syllables (lambda (a b) (ly:moment<? (car a) (car b)))))
|
||||||
|
(best (car sorted))
|
||||||
|
(best-syl-idx (cadr best)))
|
||||||
|
(list chord-moment chord-name best-syl-idx)))))))
|
||||||
|
chords)))
|
||||||
|
|
||||||
|
#(define (join-syllables-into-words syllables)
|
||||||
|
"Join syllables into words based on hyphen flags"
|
||||||
|
;; syllables structure: (moment text has-hyphen verse-idx)
|
||||||
|
(let ((result '())
|
||||||
|
(current-word-parts '())
|
||||||
|
(word-start-moment #f))
|
||||||
|
(for-each
|
||||||
|
(lambda (syl-entry)
|
||||||
|
(let* ((moment (car syl-entry))
|
||||||
|
(text (cadr syl-entry))
|
||||||
|
(has-hyphen (caddr syl-entry)))
|
||||||
|
;; Track the moment of the first syllable of each word
|
||||||
|
(when (null? current-word-parts)
|
||||||
|
(set! word-start-moment moment))
|
||||||
|
|
||||||
|
;; Add syllable part to current word
|
||||||
|
(set! current-word-parts (cons text current-word-parts))
|
||||||
|
|
||||||
|
;; If no hyphen after this syllable, the word is complete
|
||||||
|
(unless has-hyphen
|
||||||
|
(let ((complete-word (string-concatenate (reverse current-word-parts))))
|
||||||
|
(set! result (cons (cons word-start-moment complete-word) result)))
|
||||||
|
(set! current-word-parts '()))))
|
||||||
|
syllables)
|
||||||
|
|
||||||
|
;; Return words in correct order
|
||||||
|
(reverse result)))
|
||||||
|
|
||||||
|
#(define (find-chord-at-moment chords moment)
|
||||||
|
"Find chord at or close to the given syllable moment"
|
||||||
|
;; chords structure: (moment name verse-idx)
|
||||||
|
;; Strategy: Find the CLOSEST chord that comes AT or AFTER the word start
|
||||||
|
;; This way each chord is only matched to the nearest preceding word
|
||||||
|
(let ((tolerance (ly:make-moment 1/4))) ; Allow chord up to 1/4 after word start
|
||||||
|
(let loop ((remaining chords) (best-match #f) (best-diff #f))
|
||||||
|
(cond
|
||||||
|
((null? remaining) best-match)
|
||||||
|
(else
|
||||||
|
(let* ((chord-entry (car remaining))
|
||||||
|
(chord-moment (car chord-entry))
|
||||||
|
(chord-name (cadr chord-entry))
|
||||||
|
(diff (ly:moment-sub chord-moment moment))) ; positive if chord is after word
|
||||||
|
;; Only consider chords that are AT or AFTER the word start (diff >= 0)
|
||||||
|
;; and within tolerance
|
||||||
|
(let ((valid (and (not (ly:moment<? diff (ly:make-moment 0))) ; diff >= 0
|
||||||
|
(ly:moment<=? diff tolerance))))
|
||||||
|
(if (and valid
|
||||||
|
(or (not best-diff)
|
||||||
|
(ly:moment<? diff best-diff))) ; prefer closest (smallest diff)
|
||||||
|
(loop (cdr remaining) chord-name diff)
|
||||||
|
(loop (cdr remaining) best-match best-diff)))))))))
|
||||||
|
|
||||||
|
#(define (ly:moment=? a b)
|
||||||
|
"Check if two moments are equal"
|
||||||
|
(and (not (ly:moment<? a b))
|
||||||
|
(not (ly:moment<? b a))))
|
||||||
|
|
||||||
|
#(define (ly:moment-abs m)
|
||||||
|
"Get absolute value of moment"
|
||||||
|
(if (ly:moment<? m (ly:make-moment 0))
|
||||||
|
(ly:moment-sub (ly:make-moment 0) m)
|
||||||
|
m))
|
||||||
|
|
||||||
|
#(define (ly:moment<=? m1 m2)
|
||||||
|
"Check if m1 <= m2"
|
||||||
|
(or (ly:moment<? m1 m2)
|
||||||
|
(ly:moment=? m1 m2)))
|
||||||
|
|
||||||
|
#(define (ly:moment-add m1 m2)
|
||||||
|
"Add two moments"
|
||||||
|
(ly:moment-sub m1 (ly:moment-sub (ly:make-moment 0) m2)))
|
||||||
|
|
||||||
|
#(define (format-chordpro-authors authors)
|
||||||
|
"Format authors list for ChordPro"
|
||||||
|
(cond
|
||||||
|
((list? authors)
|
||||||
|
(string-join
|
||||||
|
(filter (lambda (s) (not (string-null? s)))
|
||||||
|
(map (lambda (author-info)
|
||||||
|
(cond
|
||||||
|
((pair? author-info) (car author-info))
|
||||||
|
((string? author-info) author-info)
|
||||||
|
(else "")))
|
||||||
|
authors))
|
||||||
|
", "))
|
||||||
|
(else "")))
|
||||||
|
|
||||||
|
%% Define markup command for delayed ChordPro write BEFORE modifying TEXT_PAGES
|
||||||
|
%% Uses delay-stencil-evaluation to write after all engraver data is collected
|
||||||
|
#(define-markup-command (chordpro-delayed-write layout props)
|
||||||
|
()
|
||||||
|
#:category other
|
||||||
|
"Invisible markup that writes ChordPro file during stencil evaluation (after all engraver data is collected)"
|
||||||
|
;; We use a delayed stencil to ensure all engraver data is available
|
||||||
|
(ly:make-stencil
|
||||||
|
`(delay-stencil-evaluation
|
||||||
|
,(delay (begin
|
||||||
|
(when (and (defined? 'chordpro-export-enabled)
|
||||||
|
chordpro-export-enabled
|
||||||
|
(not chordpro-file-written)
|
||||||
|
(> chordpro-current-verse-index 0))
|
||||||
|
(chordpro-write-from-engraver-data chordpro-current-verse-index)
|
||||||
|
(set! chordpro-file-written #t))
|
||||||
|
empty-stencil)))))
|
||||||
@@ -379,6 +379,8 @@ Chord_lyrics_spacing_engraver =
|
|||||||
\override SpacingSpanner.spacing-increment = 0
|
\override SpacingSpanner.spacing-increment = 0
|
||||||
% \override SpacingSpanner.packed-spacing = ##t
|
% \override SpacingSpanner.packed-spacing = ##t
|
||||||
\consists \Chord_lyrics_spacing_engraver
|
\consists \Chord_lyrics_spacing_engraver
|
||||||
|
% ChordPro engraver in Score context to collect all data
|
||||||
|
\consists \ChordPro_score_collector
|
||||||
\remove Bar_number_engraver
|
\remove Bar_number_engraver
|
||||||
\remove Mark_engraver
|
\remove Mark_engraver
|
||||||
\remove Jump_engraver
|
\remove Jump_engraver
|
||||||
|
|||||||
@@ -39,6 +39,20 @@ TEXT = \markuplist {
|
|||||||
TEXT_PAGES
|
TEXT_PAGES
|
||||||
(list TEXT))))
|
(list TEXT))))
|
||||||
|
|
||||||
|
%% Add invisible ChordPro write trigger to last TEXT_PAGES markuplist
|
||||||
|
#(when (and (defined? 'chordpro-export-enabled) chordpro-export-enabled (pair? TEXT_PAGES))
|
||||||
|
(let* ((last-index (- (length TEXT_PAGES) 1))
|
||||||
|
(last-page (list-ref TEXT_PAGES last-index))
|
||||||
|
(modified-last-page #{
|
||||||
|
\markuplist {
|
||||||
|
#last-page
|
||||||
|
\chordpro-delayed-write
|
||||||
|
}
|
||||||
|
#}))
|
||||||
|
(set! TEXT_PAGES
|
||||||
|
(append (list-head TEXT_PAGES last-index)
|
||||||
|
(list modified-last-page)))))
|
||||||
|
|
||||||
#(define (add-text-pages text-pages)
|
#(define (add-text-pages text-pages)
|
||||||
(if (pair? text-pages)
|
(if (pair? text-pages)
|
||||||
(begin
|
(begin
|
||||||
@@ -55,12 +69,34 @@ TEXT = \markuplist {
|
|||||||
(if header (set! $defaultheader header))
|
(if header (set! $defaultheader header))
|
||||||
(if paper (set! $defaultpaper paper))
|
(if paper (set! $defaultpaper paper))
|
||||||
)
|
)
|
||||||
|
|
||||||
|
;; ChordPro export: Store filename and extract metadata from basicSongInfo FIRST
|
||||||
|
(when (and (defined? 'chordpro-export-enabled) chordpro-export-enabled)
|
||||||
|
;; Use ly:parser-output-name which returns the output basename
|
||||||
|
;; This will write relative to current working directory (same as PDF)
|
||||||
|
(let ((output-name (ly:parser-output-name)))
|
||||||
|
(set! chordpro-current-filename output-name))
|
||||||
|
|
||||||
|
;; Try to extract metadata from basicSongInfo \header block
|
||||||
|
(when (defined? 'basicSongInfo)
|
||||||
|
(let* ((header-alist (ly:module->alist basicSongInfo))
|
||||||
|
(title (assoc-ref header-alist 'title))
|
||||||
|
(authors (assoc-ref header-alist 'authors)))
|
||||||
|
(when title
|
||||||
|
(set! chordpro-header-title
|
||||||
|
(if (markup? title)
|
||||||
|
(markup->string title)
|
||||||
|
(if (string? title) title "Untitled"))))
|
||||||
|
(when authors
|
||||||
|
(set! chordpro-header-authors authors)))))
|
||||||
|
|
||||||
(add-score #{
|
(add-score #{
|
||||||
\score {
|
\score {
|
||||||
\MUSIC
|
\MUSIC
|
||||||
\layout { \LAYOUT }
|
\layout { \LAYOUT }
|
||||||
}#})
|
}#})
|
||||||
(add-text-pages TEXT_PAGES)
|
(add-text-pages TEXT_PAGES)
|
||||||
|
|
||||||
(add-score #{
|
(add-score #{
|
||||||
\score {
|
\score {
|
||||||
\unfoldRepeats { \MUSIC \INLINESCOREMUSIC }
|
\unfoldRepeats { \MUSIC \INLINESCOREMUSIC }
|
||||||
|
|||||||
Reference in New Issue
Block a user