;;;; lilypond-song.el --- Emacs support for LilyPond singing ;;;; ;;;; This file is part of LilyPond, the GNU music typesetter. ;;;; ;;;; Copyright (C) 2006 Brailcom, o.p.s. ;;;; Author: Milan Zamazal ;;;; ;;;; 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 . ;;; 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) (".*\\\\|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