Spaces:
Sleeping
Sleeping
;;;; lilypond-song.el --- Emacs support for LilyPond singing | |
;;;; | |
;;;; This file is part of LilyPond, the GNU music typesetter. | |
;;;; | |
;;;; Copyright (C) 2006--2020 Brailcom, o.p.s. | |
;;;; Author: Milan Zamazal <[email protected]> | |
;;;; | |
;;;; LilyPond is free software: you can redistribute it and/or modify | |
;;;; it under the terms of the GNU General Public License as published by | |
;;;; the Free Software Foundation, either version 3 of the License, or | |
;;;; (at your option) any later version. | |
;;;; | |
;;;; LilyPond is distributed in the hope that it will be useful, | |
;;;; but WITHOUT ANY WARRANTY; without even the implied warranty of | |
;;;; MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the | |
;;;; GNU General Public License for more details. | |
;;;; | |
;;;; You should have received a copy of the GNU General Public License | |
;;;; along with LilyPond. If not, see <http://www.gnu.org/licenses/>. | |
;;; Commentary: | |
;; This file adds Emacs support for singing lyrics of LilyPond files. | |
;; It extends lilypond-mode with the following commands (see their | |
;; documentation for more information): | |
;; | |
;; - M-x LilyPond-command-sing (C-c C-a) | |
;; - M-x LilyPond-command-sing-and-play (C-c C-q) | |
;; - M-x LilyPond-command-sing-last (C-c C-z) | |
;; | |
;; Note these commands are not available from the standard LilyPond mode | |
;; command menus. | |
;;; Code: | |
(require 'cl) | |
(require 'lilypond-mode) | |
(ignore-errors (require 'ecasound)) | |
;;; User options | |
(defcustom LilyPond-synthesize-command "lilysong" | |
"Command used to sing LilyPond files." | |
:group 'LilyPond | |
:type 'string) | |
(defcustom LilyPond-play-command (or (executable-find "ecaplay") "play") | |
"Command used to play WAV files." | |
:group 'LilyPond | |
:type 'string) | |
;; In case you would like to use fluidsynth (not recommended as fluidsynth | |
;; can perform wave file synthesis only in real time), you can use the | |
;; following setting: | |
;; (setq LilyPond-midi->wav-command "fluidsynth -nil -a file soundfont.sf2 '%s' && sox -t raw -s -r 44100 -w -c 2 fluidsynth.raw '%t'") | |
(defcustom LilyPond-midi->wav-command "timidity -Ow %m -s %r -o '%t' '%s'" | |
"Command used to make a WAV file from a MIDI file. | |
%s in the string is replaced with the source MIDI file name, | |
%t is replaced with the target WAV file name. | |
%r is replaced with rate. | |
%m is replaced with lilymidi call." | |
:group 'LilyPond | |
:type 'string) | |
(defcustom LilyPond-voice-rates | |
'((".*czech.*" . 44100) | |
(".*\\<fi\\(\\>\\|nnish\\).*" . 22050) | |
(".*" . 16000)) | |
"Alist of regexps matching voices and the corresponding voice rates. | |
It may be necessary to define proper voice rates here in order to | |
avoid ecasound resampling problems." | |
:group 'LilyPond | |
:type '(alist :key-type regexp :value-type integer)) | |
(defcustom LilyPond-use-ecasound (and (featurep 'ecasound) | |
(executable-find "ecasound") | |
t) | |
"If non-nil, use ecasound for mixing and playing songs." | |
:group 'LilyPond | |
:type 'boolean) | |
(defcustom LilyPond-voice-track-regexp "voice" | |
"Perl regexp matching names of MIDI tracks to be ignored on sing&play." | |
:group 'LilyPond | |
:type 'string) | |
(defcustom LilyPond-lilymidi-command "\"`lilymidi --prefix-tracks -Q --filter-tracks '%s' '%f'`\"" | |
"Command to insert into LilyPond-midi->wav-command calls. | |
%f is replaced with the corresponding MIDI file name. | |
%s is replaced with `LilyPond-voice-track-regexp'." | |
:group 'LilyPond | |
:type 'string) | |
;;; Lyrics language handling | |
(defvar lilysong-language nil) | |
(make-variable-buffer-local 'lilysong-language) | |
(defvar lilysong-last-language nil) | |
(make-variable-buffer-local 'lilysong-last-language) | |
(defvar lilysong-languages '("cs" "en")) | |
(defvar lilysong-voices nil) | |
(defun lilysong-voices () | |
(or lilysong-voices | |
(with-temp-buffer | |
(call-process "lilysong" nil t nil "--list-voices") | |
(call-process "lilysong" nil t nil "--list-languages") | |
(goto-char (point-min)) | |
(while (not (eobp)) | |
(push (buffer-substring-no-properties | |
(line-beginning-position) (line-end-position)) | |
lilysong-voices) | |
(forward-line)) | |
lilysong-voices))) | |
(defun lilysong-change-language () | |
"Change synthesis language or voice of the current document." | |
(interactive) | |
(setq lilysong-language | |
(completing-read "Lyrics language or voice: " | |
(mapcar 'list (lilysong-voices))))) | |
(defun lilysong-update-language () | |
(unless lilysong-language | |
(lilysong-change-language))) | |
;;; Looking for \festival* and \midi commands | |
(defun lilysong-document-files () | |
(let ((resulting-files ()) | |
(stack (list (LilyPond-get-master-file)))) | |
(while (not (null stack)) | |
(let ((file (expand-file-name (pop stack)))) | |
(when (and (file-exists-p file) | |
(not (member file resulting-files))) | |
(push file resulting-files) | |
(save-excursion | |
(save-restriction | |
(set-buffer (find-file-noselect file nil)) | |
(widen) | |
(goto-char (point-min)) | |
(while (re-search-forward "^[^%\n]*\\\\include +\"\\([^\"]+\\)\"" nil t) | |
(push (match-string 1) stack))))))) | |
(nreverse resulting-files))) | |
(defvar lilysong-festival-command-regexp | |
"^[^%\n]*\\\\festival\\(syl\\)? +#\"\\([^\"]+\\)\"") | |
(defun lilysong-find-song (direction) | |
"Find XML file name of the nearest Festival command in the given DIRECTION. | |
DIRECTION is one of the symbols `forward' or `backward'. | |
If no Festival command is found in the current buffer, return nil. | |
The point is left at the position where the command occurrence was found." | |
(save-match-data | |
(when (funcall (if (eq direction 'backward) | |
're-search-backward | |
're-search-forward) | |
lilysong-festival-command-regexp nil t) | |
(match-string-no-properties 2)))) | |
(defun lilysong-current-song () | |
"Return the XML file name corresponding to the song around current point. | |
If there is none, return nil." | |
(save-excursion | |
(or (progn (end-of-line) (lilysong-find-song 'backward)) | |
(progn (beginning-of-line) (lilysong-find-song 'forward))))) | |
(defun lilysong-all-songs (&optional limit-to-region) | |
"Return list of XML file names of the song commands in the current buffer. | |
If there are none, return an empty list. | |
If LIMIT-TO-REGION is non-nil, look for the commands in the current region | |
only." | |
(let ((result '()) | |
(current nil)) | |
(save-excursion | |
(save-restriction | |
(when limit-to-region | |
(narrow-to-region (or (mark) (point)) (point))) | |
(goto-char (point-min)) | |
(while (setq current (lilysong-find-song 'forward)) | |
(push current result)))) | |
(nreverse result))) | |
(defun lilysong-walk-files (collector) | |
(save-excursion | |
(mapcar (lambda (f) | |
(set-buffer (find-file-noselect f)) | |
(funcall collector)) | |
(lilysong-document-files)))) | |
(defun lilysong-all-songs* () | |
"Return list of XML file names of the song commands in the current document." | |
(remove-duplicates (apply #'append (lilysong-walk-files #'lilysong-all-songs)) | |
:test #'equal)) | |
(defvar lilysong-song-history nil) | |
(make-variable-buffer-local 'lilysong-song-history) | |
(defvar lilysong-last-song-list nil) | |
(make-variable-buffer-local 'lilysong-last-song-list) | |
(defvar lilysong-last-command-args nil) | |
(make-variable-buffer-local 'lilysong-last-command-args) | |
(defun lilysong-song-list (multi) | |
(cond | |
((eq multi 'all) | |
(lilysong-all-songs*)) | |
(multi | |
(lilysong-select-songs)) | |
(t | |
(lilysong-select-single-song)))) | |
(defun lilysong-select-single-song () | |
(let ((song (lilysong-current-song))) | |
(if song | |
(list song) | |
(error "No song found")))) | |
(defun lilysong-select-songs () | |
(let* ((all-songs (lilysong-all-songs*)) | |
(available-songs all-songs) | |
(initial-songs (if (or (not lilysong-last-song-list) | |
(eq LilyPond-command-current | |
'LilyPond-command-region)) | |
(lilysong-all-songs t) | |
lilysong-last-song-list)) | |
(last-input (completing-read | |
(format "Sing file%s: " | |
(if initial-songs | |
(format " (default `%s')" | |
(mapconcat 'identity initial-songs | |
", ")) | |
"")) | |
(mapcar 'list all-songs) | |
nil t nil | |
'lilysong-song-history))) | |
(if (equal last-input "") | |
initial-songs | |
(let ((song-list '()) | |
default-input) | |
(while (not (equal last-input "")) | |
(push last-input song-list) | |
(setq default-input (second (member last-input available-songs))) | |
(setq available-songs (remove last-input available-songs)) | |
(setq last-input (completing-read "Sing file: " | |
(mapcar #'list available-songs) | |
nil t default-input | |
'lilysong-song-history))) | |
(setq lilysong-last-song-list (nreverse song-list)))))) | |
(defun lilysong-count-midi-words () | |
(count-rexp (point-min) (point-max) "^[^%]*\\\\midi")) | |
(defun lilysong-midi-list (multi) | |
(if multi | |
(let ((basename (file-name-sans-extension (buffer-file-name))) | |
(count (apply #'+ (save-match-data | |
(lilysong-walk-files #'lilysong-count-midi-words)))) | |
(midi-files '())) | |
(while (> count 0) | |
(setq count (1- count)) | |
(if (= count 0) | |
(push (concat basename ".midi") midi-files) | |
(push (format "%s-%d.midi" basename count) midi-files))) | |
midi-files) | |
(list (LilyPond-string-current-midi)))) | |
;;; Compilation | |
(defun lilysong-file->wav (filename &optional extension) | |
(format "%s.%s" (save-match-data | |
(if (string-match "\\.midi$" filename) | |
filename | |
(file-name-sans-extension filename))) | |
(or extension "wav"))) | |
(defun lilysong-file->ewf (filename) | |
(lilysong-file->wav filename "ewf")) | |
(defstruct lilysong-compilation-data | |
command | |
makefile | |
buffer | |
songs | |
midi | |
in-parallel) | |
(defvar lilysong-compilation-data nil) | |
(defun lilysong-sing (songs &optional midi-files in-parallel) | |
(setq lilysong-last-command-args (list songs midi-files in-parallel)) | |
(lilysong-update-language) | |
(add-to-list 'compilation-finish-functions 'lilysong-after-compilation) | |
(setq songs (mapcar #'expand-file-name songs)) | |
(let* ((makefile (lilysong-makefile (current-buffer) songs midi-files)) | |
(command (format "make -f %s" makefile))) | |
(setq lilysong-compilation-data | |
(make-lilysong-compilation-data | |
:command command | |
:makefile makefile | |
:buffer (current-buffer) | |
:songs songs | |
:midi midi-files | |
:in-parallel in-parallel)) | |
(save-some-buffers (not compilation-ask-about-save)) | |
(unless (equal lilysong-language lilysong-last-language) | |
(mapc #'(lambda (f) (when (file-exists-p f) (delete-file f))) | |
(append songs (mapcar 'lilysong-file->wav midi-files)))) | |
(if (lilysong-up-to-date-p makefile) | |
(lilysong-process-generated-files lilysong-compilation-data) | |
(compile command)))) | |
(defun lilysong-up-to-date-p (makefile) | |
(equal (call-process "make" nil nil nil "-f" makefile "-q") 0)) | |
(defun lilysong-makefile (buffer songs midi-files) | |
(let ((temp-file (make-temp-file "Makefile.lilysong-el")) | |
(language lilysong-language)) | |
(with-temp-file temp-file | |
(let ((source-files (save-excursion | |
(set-buffer buffer) | |
(lilysong-document-files))) | |
(master-file (save-excursion | |
(set-buffer buffer) | |
(LilyPond-get-master-file))) | |
(lilyfiles (append songs midi-files))) | |
(insert "all:") | |
(dolist (f (mapcar 'lilysong-file->wav (append songs midi-files))) | |
(insert " " f)) | |
(insert "\n") | |
(when lilyfiles | |
(dolist (f songs) | |
(insert f " ")) | |
(when midi-files | |
(dolist (f midi-files) | |
(insert f " "))) | |
(insert ": " master-file "\n") | |
(insert "\t" LilyPond-lilypond-command " " master-file "\n") | |
(dolist (f songs) | |
(insert (lilysong-file->wav f) ": " f "\n") | |
(insert "\t" LilyPond-synthesize-command " $< " (or language "") "\n")) | |
;; We can't use midi files in ecasound directly, because setpos | |
;; doesn't work on them. | |
(let ((lilymidi LilyPond-lilymidi-command) | |
(voice-rate (format "%d" (or (cdr (assoc-if (lambda (key) (string-match key language)) | |
LilyPond-voice-rates)) | |
16000)))) | |
(when (string-match "%s" lilymidi) | |
(setq lilymidi (replace-match LilyPond-voice-track-regexp nil nil lilymidi))) | |
(dolist (f midi-files) | |
(insert (lilysong-file->wav f) ": " f "\n") | |
(let ((command LilyPond-midi->wav-command) | |
(lilymidi* lilymidi)) | |
(when (string-match "%s" command) | |
(setq command (replace-match f nil nil command))) | |
(when (string-match "%t" command) | |
(setq command (replace-match (lilysong-file->wav f) nil nil command))) | |
(when (string-match "%r" command) | |
(setq command (replace-match voice-rate nil nil command))) | |
(when (string-match "%f" lilymidi*) | |
(setq lilymidi (replace-match f nil nil lilymidi*))) | |
(when (string-match "%m" command) | |
(setq command (replace-match lilymidi nil nil command))) | |
(insert "\t" command "\n"))) | |
)))) | |
temp-file)) | |
(defun lilysong-after-compilation (buffer message) | |
(let ((data lilysong-compilation-data)) | |
(when (and data | |
(equal compile-command | |
(lilysong-compilation-data-command data))) | |
(unwind-protect | |
(when (lilysong-up-to-date-p (lilysong-compilation-data-makefile data)) | |
(lilysong-process-generated-files data)) | |
(delete-file (lilysong-compilation-data-makefile data)))))) | |
(defun lilysong-process-generated-files (data) | |
(with-current-buffer (lilysong-compilation-data-buffer data) | |
(setq lilysong-last-language lilysong-language)) | |
(lilysong-play-files (lilysong-compilation-data-in-parallel data) | |
(lilysong-compilation-data-songs data) | |
(lilysong-compilation-data-midi data))) | |
;;; Playing files | |
(defun lilysong-play-files (in-parallel songs midi-files) | |
(funcall (if LilyPond-use-ecasound | |
'lilysong-play-with-ecasound | |
'lilysong-play-with-play) | |
in-parallel songs midi-files)) | |
(defun lilysong-call-play (files) | |
(apply 'start-process "lilysong-el" nil LilyPond-play-command files)) | |
(defun lilysong-play-with-play (in-parallel songs midi-files) | |
(let ((files (mapcar 'lilysong-file->wav (append songs midi-files)))) | |
(if in-parallel | |
(dolist (f files) | |
(lilysong-call-play (list f))) | |
(lilysong-call-play files)))) | |
(defun lilysong-make-ewf-files (files) | |
(let ((offset 0.0)) | |
(dolist (f files) | |
(let* ((wav-file (lilysong-file->wav f)) | |
(length (with-temp-buffer | |
(call-process "ecalength" nil t nil "-s" wav-file) | |
(goto-char (point-max)) | |
(forward-line -1) | |
(read (current-buffer))))) | |
(with-temp-file (lilysong-file->ewf f) | |
(insert "source = " wav-file "\n") | |
(insert (format "offset = %s\n" offset)) | |
(insert "start-position = 0.0\n") | |
(insert (format "length = %s\n" length)) | |
(insert "looping = false\n")) | |
(setq offset (+ offset length)))))) | |
(when (and (featurep 'ecasound) | |
(not (fboundp 'eci-cs-set-param))) | |
(defeci cs-set-param ((parameter "sChainsetup option: " "%s")))) | |
(defun lilysong-play-with-ecasound (in-parallel songs midi-files) | |
(ecasound) | |
(eci-cs-add "lilysong-el") | |
(eci-cs-select "lilysong-el") | |
(eci-cs-remove) | |
(eci-cs-add "lilysong-el") | |
(eci-cs-select "lilysong-el") | |
(eci-cs-set-param "-z:mixmode,sum") | |
(unless in-parallel | |
(lilysong-make-ewf-files songs) | |
;; MIDI files should actually start with each of the songs | |
(mapc 'lilysong-make-ewf-files (mapcar 'list midi-files))) | |
(let* ((file->wav (if in-parallel 'lilysong-file->wav 'lilysong-file->ewf)) | |
(files (mapcar file->wav (append songs midi-files)))) | |
(dolist (f files) | |
(eci-c-add f) | |
(eci-c-select f) | |
(eci-ai-add f)) | |
(eci-c-select-all) | |
(eci-ao-add-default) | |
(let* ((n (length songs)) | |
(right (if (<= n 1) 50 0)) | |
(step (if (<= n 1) 0 (/ 100.0 (1- n))))) | |
(dolist (f songs) | |
(let ((chain (funcall file->wav f))) | |
(eci-c-select chain) | |
(eci-cop-add "-erc:1,2") | |
(eci-cop-add (format "-epp:%f" (min right 100))) | |
(incf right step)))) | |
(eci-start))) | |
;;; User commands | |
(defun lilysong-arg->multi (arg) | |
(cond | |
((not arg) | |
nil) | |
((or | |
(numberp arg) | |
(equal arg '(4))) | |
t) | |
(t | |
'all))) | |
(defun lilysong-command (arg play-midi?) | |
(let* ((multi (lilysong-arg->multi arg)) | |
(song-list (lilysong-song-list multi)) | |
(midi-list (if play-midi? (lilysong-midi-list multi)))) | |
(message "Singing %s" (mapconcat 'identity song-list ", ")) | |
(lilysong-sing song-list midi-list (if play-midi? t (listp arg))))) | |
(defun LilyPond-command-sing (&optional arg) | |
"Sing lyrics of the current LilyPond buffer. | |
Without any prefix argument, sing current \\festival* command. | |
With the universal prefix argument, ask which parts to sing. | |
With a double universal prefix argument, sing all the parts. | |
With a numeric prefix argument, ask which parts to sing and sing them | |
sequentially rather than in parallel." | |
(interactive "P") | |
(lilysong-command arg nil)) | |
(defun LilyPond-command-sing-and-play (&optional arg) | |
"Sing lyrics and play midi of the current LilyPond buffer. | |
Without any prefix argument, sing and play current \\festival* and \\midi | |
commands. | |
With the universal prefix argument, ask which parts to sing and play. | |
With a double universal prefix argument, sing and play all the parts." | |
(interactive "P") | |
(lilysong-command arg t)) | |
(defun LilyPond-command-sing-last () | |
"Repeat last LilyPond singing command." | |
(interactive) | |
(if lilysong-last-command-args | |
(apply 'lilysong-sing lilysong-last-command-args) | |
(error "No previous singing command"))) | |
(defun LilyPond-command-clean () | |
"Remove generated *.xml and *.wav files used for singing." | |
(interactive) | |
(flet ((delete-file* (file) | |
(when (file-exists-p file) | |
(delete-file file)))) | |
(dolist (xml-file (lilysong-song-list 'all)) | |
(delete-file* xml-file) | |
(delete-file* (lilysong-file->wav xml-file))) | |
(mapc 'delete-file* (mapcar 'lilysong-file->wav (lilysong-midi-list 'all))))) | |
(define-key LilyPond-mode-map "\C-c\C-a" 'LilyPond-command-sing) | |
(define-key LilyPond-mode-map "\C-c\C-q" 'LilyPond-command-sing-and-play) | |
(define-key LilyPond-mode-map "\C-c\C-x" 'LilyPond-command-clean) | |
(define-key LilyPond-mode-map "\C-c\C-z" 'LilyPond-command-sing-last) | |
(easy-menu-add-item LilyPond-command-menu nil | |
["Sing Current" LilyPond-command-sing t]) | |
(easy-menu-add-item LilyPond-command-menu nil | |
["Sing Selected" (LilyPond-command-sing '(4)) t]) | |
(easy-menu-add-item LilyPond-command-menu nil | |
["Sing All" (LilyPond-command-sing '(16)) t]) | |
(easy-menu-add-item LilyPond-command-menu nil | |
["Sing Selected Sequentially" (LilyPond-command-sing 1) t]) | |
(easy-menu-add-item LilyPond-command-menu nil | |
["Sing and Play Current" LilyPond-command-sing-and-play t]) | |
(easy-menu-add-item LilyPond-command-menu nil | |
["Sing and Play Selected" (LilyPond-command-sing-and-play '(4)) t]) | |
(easy-menu-add-item LilyPond-command-menu nil | |
["Sing and Play All" (LilyPond-command-sing-and-play '(16)) t]) | |
(easy-menu-add-item LilyPond-command-menu nil | |
["Sing Last" LilyPond-command-sing-last t]) | |
;;; Announce | |
(provide 'lilypond-song) | |
;;; lilypond-song.el ends here | |