Roman numerals for analysis

This function is designed to simplify the creation of Roman numerals for harmonic analysis.

Syntax: \markup \rN { ...list of symbols... }

Enter a Roman numeral as a list of symbols, with each element separated by spaces. List symbols in order of appearance, omitting those not needed: Roman numeral; letter for a quality requiring a special indicator (i.e., diminished, half-diminished, etc.); top (or only) number of inversion symbol; bottom number; "/" (if secondary function); Roman numeral.

Preceding either Roman numeral with "s" or "b" or "n" will attach a sharp, flat, or natural: for example, "svi" or "bVII" (quotation marks not needed). Note names are possible: Cs, Dn, Eb.

Use the following symbols for qualities (if a superscript indication is needed): "o" for diminished, "h" for half-diminished, "+" for augmented, "b" for flat. You may use any combination of "M" and "m" here: M, mm, MM7, Mm, Mmm9, etc. Added notes are also possible: add6, add9, etc.

The analysis can be created in a Lyrics context.

\version "2.24.0"

%% http://lsr.di.unimi.it/LSR/Item?id=710

%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%
%% A function to create Roman numerals for harmonic analysis.
%%
%% Syntax: \markup \rN { ...list of symbols... }
%%
%% List symbols in this order (as needed): Roman numeral, quality, top number of
%% inversion symbol, bottom number, "/" (if secondary function), Roman numeral.
%%
%% "bVII" creates flat VII; "svi" creates sharp vi; "Ab" creates A-flat; "As" A-sharp
%%
%% Qualities: use "o" for diminished, "h" for half-diminished,
%% "+" for augmented, "b" for flat.  Use any combination of "M" and "m":
%% M, m, MM7, Mm, mm, Mmm9, etc. Added-note chords: add, add6, etc.
%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%

#(define rN-size -1) %% change to vary size of numerals

#(define scaling (magstep rN-size))

%%% change constant to adjust distance between characters
#(define X-separation (* scaling 0.2)) 

%%% symmetrical distance between inversion figures and midline
#(define inversion-Y-separation (* scaling 0.1)) 

#(define dim
   (markup
    #:override `(thickness . ,scaling)
    #:draw-circle (* scaling 0.25) (* scaling 0.1) #f))

#(define half-dim
   (markup
    #:override `(thickness . ,scaling)
    #:combine
    (#:combine dim 
               #:draw-line `(,(* scaling -0.3) . ,(* scaling -0.3)))
    #:draw-line `(,(* scaling 0.3) . ,(* scaling 0.3))))

#(define augmented
   (markup
    #:override `(thickness . ,scaling)
    #:combine
    (#:combine #:draw-line `(,(* scaling -0.25) . 0) 
               #:draw-line `(0 . ,(* scaling -0.25)))
    (#:combine #:draw-line `(,(* scaling 0.25) . 0) 
               #:draw-line `(0 . ,(* scaling 0.25)))))

#(define (acc? str num) (string? number?)
   (eq? num (string-index str (char-set #\b #\s #\n)))) %% checks for accidental

#(define acc `((#\b . ,(markup #:flat))
               (#\s . ,(markup #:sharp))
               (#\n . ,(markup #:natural))))

#(define-markup-command (rN layout props symbols) (markup-list?)
   ;; isolate and normalize segment of list before slash (if any)
   (let* ((up-to-slash (car (split-list-by-separator symbols (lambda (x) (equal? x "/")))))
          (first-part (append up-to-slash (make-list (- 4 (length up-to-slash)) "")))
          (normalized
           (if (or (string-index (cadr first-part) (string->char-set "mMaAdD"))
                   (not (null? (lset-intersection equal? '("o" "h" "+" "b") (cdr first-part)))))
               first-part
               (list (car first-part) "" (cadr first-part) (caddr first-part))))
          (base (car normalized))
          (quality (cadr normalized))
          (quality-marker
           (cond ((equal? "o" quality) (markup #:raise (* 0.5 scaling) dim))
                 ((equal? "h" quality) (markup #:raise (* 0.5 scaling) half-dim))
                 ((equal? "+" quality) (markup #:raise (* 0.5 scaling) augmented))
                 ((equal? "b" quality) (markup #:raise (* 0.5 scaling) #:flat))
                 ((equal? "" quality) (markup #:null))
                 (else (markup quality))))
          (upper (caddr normalized))
          (lower (cadddr normalized))
          ;; isolate slash and what follows
          (second-part (if (member "/" symbols) (member "/" symbols) '("" "")))
          (rN-two (cadr second-part))
          (base-stencil
           (interpret-markup layout
                             (cons (list `(word-space . ,X-separation) `(font-size . ,rN-size)) props)
                             (markup base)))
          ;; calculate Y midpoint of base (for positioning quality and inversion)
          (vertical-offset (/ (interval-length (ly:stencil-extent base-stencil Y)) 2))
          (inversion-stencil
           (if (equal? lower "")
               (ly:stencil-translate-axis (interpret-markup layout
                                                            (cons (list `(word-space . ,X-separation)) props)
                                                            (markup #:fontsize (- rN-size 5) upper))
                                          inversion-Y-separation Y)

               (ly:stencil-aligned-to
                (ly:stencil-combine-at-edge
                 (interpret-markup layout
                                   (cons (list `(word-space . ,X-separation)) props)
                                   (markup #:fontsize (- rN-size 5) upper))
                 Y DOWN
                 (interpret-markup layout
                                   (cons (list `(word-space . ,X-separation)) props)
                                   (markup #:fontsize (- rN-size 5) lower))
                 (* 2 inversion-Y-separation))
                Y CENTER)))
          (quality-marker-stencil
           (ly:stencil-translate-axis (interpret-markup layout
                                                        (cons (list `(word-space . ,X-separation)) props)
                                                        (markup #:fontsize (- rN-size 5) quality-marker))
                                      inversion-Y-separation Y))
          
          ;; base, quality marker, and inversion
          (one
           (ly:stencil-combine-at-edge

            (ly:stencil-combine-at-edge
             (interpret-markup layout
                               (cons (list `(word-space . ,X-separation)) props)
                               ;; accommodates an accidental either before or after
                               (cond ((acc? base 0)
                                      (markup #:fontsize (- rN-size 4)
                                              #:raise (* 2 vertical-offset) #:vcenter
                                              (assoc-ref acc (string-ref base 0))
                                              #:fontsize rN-size (substring base 1)))

                                     ((acc? base (1- (string-length base)))
                                      (markup #:fontsize rN-size
                                              (substring base 0 (1- (string-length base)))
                                              #:fontsize (- rN-size 4) #:raise (/ scaling 2)
                                              (assoc-ref acc (string-ref base (1- (string-length base))))))

                                     (else (markup #:fontsize rN-size base))))
             X RIGHT
             (ly:stencil-translate-axis quality-marker-stencil vertical-offset Y) 
             (if (equal? "" quality) 0 X-separation))

            X RIGHT
            (ly:stencil-translate-axis inversion-stencil vertical-offset Y)
            (if (equal? "" upper) 0 X-separation)))

          ;; slash and after
          (two
           (ly:stencil-combine-at-edge
            (interpret-markup layout
                              (cons (list `(word-space . ,X-separation) `(font-size . ,rN-size)) props)
                              (if (equal? "" lower)
                                  (markup (car second-part))
                                  (markup #:hspace X-separation (car second-part))))
            X RIGHT
            (interpret-markup layout
                              (cons (list `(word-space . ,X-separation)) props)
                              (cond ((acc? rN-two 0)
                                     (markup #:fontsize (- rN-size 4)
                                             #:raise scaling (assoc-ref acc (string-ref rN-two 0))
                                             #:fontsize rN-size (substring rN-two 1)))

                                    ((acc? rN-two (1- (string-length rN-two)))
                                     (markup #:fontsize rN-size
                                             (substring rN-two 0 (1- (string-length rN-two)))
                                             #:fontsize (- rN-size 4) #:raise (/ scaling 2)
                                             (assoc-ref acc (string-ref rN-two (1- (string-length rN-two))))))

                                    (else (markup #:fontsize rN-size rN-two))))
            X-separation)))

     (if (equal? rN-two "")
         one
         (ly:stencil-combine-at-edge one X RIGHT two 0))))

bassline = \relative c' {
  \clef bass
  \key g \major
  \time 3/4
  g4 fis f
  e es es
  d2 d,4
  g2.
  \bar "||"
}

analysis = \lyricmode {
  \set stanza = #"G:     " % use spaces to adjust position of key indication
  \markup \rN { I } \markup \rN { V 6 5 } \markup \rN { vii o 4 3 / IV }
  \markup \rN { IV 6 } \markup \rN { ii h 4 3 } \markup \rN { Fr + 6 }
  \markup \rN { I 6 4 } \markup \rN { V 7 }
  \markup \rN { I }
}

\score {
  \new Staff <<
    \new Voice = "bass" { \bassline }
    \new Lyrics \lyricsto "bass" { \analysis }
  >>
  \layout {
    \context {
      \Score
      \override SpacingSpanner.shortest-duration-space = #5
    }
  }
}