【新版】Emacs の起動時と終了時に「“プロ生ちゃん”のシステムボイス」音源を再生する

2014年版エントリー公開のおしらせ

最新の設定については以下のエントリーを参照のこと。
【2014年版】Emacs の起動時と終了時に「“プロ生ちゃん”のシステムボイス」音源を再生する - blechmusikの日記

この新版について

このエントリーは以下のエントリーの新版である。

Emacs の起動時と終了時に「“プロ生ちゃん”のシステムボイス」音源を再生する - blechmusikの日記

追加した機能は次の通りだ。

  1. ファイル名をほとんど意識せずに再生対象の音源を選択できるようにした
  2. helm (anything) でワード一覧を表示して、そこから音源を再生したり、ファイル名やワードを出力できるようにした

f:id:blechmusik2:20130907005013p:plain


機能を追加するにあたって、参考にしたウェブサイトはこれらである。


後日追記: f.el と s.el を使うよう一部を書き換えた。

はじめに

上坂すみれが声を務める“プロ生ちゃん”のシステムボイスが無償公開 - 窓の杜に触れて、Emacs の起動時と終了時に音源を再生することを思いついた。さまざまな音源が配布されているが、この中から挨拶の類いのメッセージを選び、使ってみよう。

 以下の3点をあらかじめ断っておきたい。

  1. 圧縮ファイルを展開した音源ファイル一式は ~/pronama-voice/ 以下にディレクトリの構造を保ったまま置いている
  2. ダウンロード(クリエイター向け素材) | プログラミング生放送で配布されている公式のワードリスト(.xslx)を.csvに書き出し、~/pronama-voice/ に置く(~/pronama-voice/kei_voice_word_list.csv
  3. EmacsWindows 環境で動作する gnupack の NTEmacs である

AutoHotkeyを使って音源を再生する

 ところで音源を再生するにはどうすればよいのだろうか。 Emacs から音源を直接再生する手順も、そして (Vista 以降の) Windowsで他の音楽再生アプリケーションを使用せずに音源を再生する方法も私は分からなかった。

 そこで AutoHotkey の soundplay コマンドを使って指定したメディアファイルを再生するスクリプトを作成し、実行バイナリ化してみる。

for i,v in get_command_argument() {
    filename := slash_to_backslash(v)
    SoundPlay, %Filename%, 1
}
ExitApp
return


slash_to_backslash(string){
    StringReplace, string, string, /, \
    return string
}


get_command_argument(){
    global
    local arg_list := []
    
    Loop,100
    {
        if ("" != Trim(%A_Index%)){
            arg_list.insert(%A_Index%)
        }
    }
    
    return arg_list
}

 Emacs 側から渡されるパスにスラッシュが含まれている可能性を想定して、スラッシュをバックスラッシュに書き換えることにした。

実行ファイル化したAutoHotkeyスクリプトEmacs から呼び出す

 次に行うのは Emacs lisp を使って上記のAutoHotkeyスクリプトを実行し、音源を再生することだ。file-truename関数を使ってパスを展開することにしたから、これによってAutoHotkeyスクリプト側でシンボリック・リンクを張ったファイルについても問題なく取り扱える。

(defvar soundplay-command (file-truename "~/soundplay/soundplay.exe"))

(defun play-sound (sound-file)  (with-temp-buffer
    (start-process "sync" nil
                   soundplay-command
                   sound-file
                   )))

;; はじめるよ
(play-sound (file-truename "~/pronama-voice/kei_voice_02/kei_voice_055.wav"))

 こうして「はじめるよ」と語る音源を再生することができた。

 だが、これでは Emacs の終了時に音源を再生し続けることができない。start-process の実行中にEmacsを終了させるとそこで音源の再生が中断してしまうのだ。この問題に対処するには start-process ではなく start-process-shell-command を使うことになる。

(defun play-sound (sound-file)
  (start-process-shell-command "async"
                               nil
                               soundplay-command
                               sound-file))

音源のワードからファイル名を自動的に取得する

 これで音源を再生する準備が整ったが、しかし、再生する音源のファイル名を逐一設定するのは面倒なことだろう。最終的には音源のワードからファイル名を自動的に取得したいものである。そこでまず、すべての音源のファイル名を一括で取得する処理と、csv形式のワードリストを読み込んでリスト化する処理を書いてみたい。

(defun get-kei-voice-filename-list (directory)
  (mapcar (lambda (x) (f-short x))
          (f--files directory (equal (f-ext it) "wav") t)))

;; number, text => (number text)
(defun get-kei-voice-word-list (csv-file)
  ;; ex.)
  ;; (26 #("きゃ" 0 2 (charset japanese-jisx0208)) "")
  ;; (27 #("やったね" 0 4 (charset japanese-jisx0208)) "")
  ;; (28 #("いたっ" 0 3 (charset japanese-jisx0208)) "")
  ;; (29 #("えいっ" 0 3 (charset japanese-jisx0208)) "")
  ;; (30 #("がーーん/液晶割れちゃった…" 0 14 (charset japanese-jisx0208)) "")
  (labels ((read-lines (file)
                       (with-temp-buffer
                         (insert-file-contents file)
                         (split-string (buffer-string) "\n" t)
                         ))
           (string->number-and-text (x)
                                    (let ((l (split-string x ",")))
                                      (cons (string-to-number (car l)) (cdr l))))
           )
    (mapcar #'string->number-and-text (read-lines csv-file))))


(setq voice-directory "~/pronama-voice/"
      kei-voice-filename-list (get-kei-voice-filename-list voice-directory)
      kei-voice-word-list-file (concat voice-directory "kei_voice_word_list.csv")
      kei-voice-word-list (get-kei-voice-word-list kei-voice-word-list-file))

そしてリスト化したワードリストをもとに音源の番号とワードを取得するにはこう書くだろう。word->voice-without-cut-filename関数はカット無しのファイル名を、word->voice-with-cut-filename関数はカットありのファイル名を返す。

(defun get-voice-number-and-word (string l)
  (loop for data in kei-voice-word-list
        when (string-match string (cadr data))
        return (cons (car data) (cadr data))))

(defun get-voice-number (string l)
  (let ((result (get-voice-number-in-detail string l)))
    (when result
      (list (car result) 0))))

(defun get-voice-number-in-detail (string l)
  (let* ((number-and-word (get-voice-number-and-word string l))
         (number (car number-and-word))
         (word (cdr number-and-word)))
    (when (stringp word)
      (let ((result (split-string word "/")))
        (list number
              (loop for x in result
                    for i = 1 then (+ 1 i)
                    when (string-match string x)
                    return (if (= 1 (length result)) 0 i)))))))

(defun number-list->voice-filename (l)
  ;; (9 0) => kei_voice_009.wav
  ;; (9 2) => kei_voice_009_phrase2.wav
  (cond
   ((< 0 (cadr l)) (format "kei_voice_%03d_phrase%d.wav" (car l) (cadr l)))
   ((= 61 (car l)) (format "kei_voice_%03d_a.wav" (car l)))
   (t (format "kei_voice_%03d.wav" (car l)))))

(defun owari-dayo-a->b (filename)
  (replace-regexp-in-string "a\\(\.wav\\)" "b\\1" filename))

;; get full name of a voice file by word
(defun word->voice-filename-aux (fn string)
  (let ((number-list (funcall fn string kei-voice-word-list)))
    (when number-list
      (loop for x in kei-voice-filename-list
            when (string-match (number-list->voice-filename number-list) x)
            return x))))

(defun word->voice-without-cut-filename (string)
  (word->voice-filename-aux #'get-voice-number string))

(defun word->voice-with-cut-filename (string)
  (word->voice-filename-aux #'get-voice-number-in-detail string))

これで一通り設定し終えた。ワードをもとに音源を再生してみるにはこう書けばよい。ワードを指定する際にはEmacs正規表現を利用することができる。

(play-sound (word->voice-without-cut-filename "おはよう"))
(play-sound (word->voice-with-cut-filename "おはよう"))

残念ながら「おわり、だよ」には二種類あるのにかかわらず、上記の設定では一方の音源ファイルしか取得していない。他方の音源ファイルを取得するには、owari-dayo-a->b 関数を利用する。

(play-sound (word->voice-with-cut-filename "おわり、だよ"))
(play-sound (owari-dayo-a->b (word->voice-with-cut-filename "おわり、だよ")))

再生対象の音源を充実させる

 次は再生対象の音源を充実させたい。音源の充実策としていくつかの音源をまず用意し、それに加えて特定の時間帯のみ利用できる音源も用意する。幸いなことに、朝昼夜の挨拶の音源が存在するから、これを利用しよう。そしてこのように用意した音源からどれか一つをその都度ランダムに選び出せばよい。なお、ここではカット無し版の音源ファイルを一律に取得するようにしている。

(setq voice-word-good-morning '("おはよう")
      voice-word-good-afternoon '("こんにちは")
      voice-word-good-evening '("こんばんは")
      voice-word-good-night '("おやすみ")
      voice-words-startup '("はじめるよ"
                            "スタート")
      voice-words-kill '("またきてね"
                         "しゅう~りょう~"
                         "おわり、だよ"
                         "はい、しゅーりょー")
      )

(loop for (file . word)
      in '(
           (voice-file-good-morning . voice-word-good-morning)
           (voice-file-good-afternoon . voice-word-good-afternoon)
           (voice-file-good-evening . voice-word-good-evening)
           (voice-file-good-night . voice-word-good-night)
           (voice-files-startup . voice-words-startup)
           (voice-files-kill . voice-words-kill)
           )
      do (set file
              (mapcar #'word->voice-without-cut-filename (symbol-value word))))


;; おわり、だよの別バージョンも再生対象にする
(add-to-list 'voice-files-kill
             (owari-dayo-a->b (word->voice-with-cut-filename "おわり、だよ")))


(defun play-voice-startup ()
  (play-sound (voice-file-startup)))
      
(defun play-voice-kill ()
  (play-sound (voice-file-kill)))


(lexical-let ((hour (string-to-number (format-time-string "%H" (current-time)))))
  (labels ((get-wav-filename (files)
                             (file-truename (nth (random (length files)) files))))
    ;;  5時~9時: +おはよう
    ;; 11時~14時: +こんにちは
    ;; 20時~ 3時: +こんばんは
    (defun voice-file-startup ()
      (lexical-let ((voice-files
                     (append voice-files-startup
                             (cond
                              ((and (<= 5 hour) (< hour 9)) voice-file-good-morning)
                              ((and (<= 11 hour) (< hour 14)) voice-file-good-afternoon)
                              ((or (<= 20 hour) (< hour 3)) voice-file-good-evening)
                              (t nil)))))
        (get-wav-filename voice-files)))

    ;; 20時~: +おやすみ
    (defun voice-file-kill ()
      (lexical-let ((voice-files
                     (append voice-files-kill
                             (cond
                              ((<= 20 hour) voice-file-good-night)
                              (t nil)))))
        (get-wav-filename voice-files)))
    ))

Emacs の起動時と終了時に音源を再生するようフックを設定する

 最後に、Emacs の起動時と終了時に音源を再生するようフックを設定しよう。フックの処理としてEmacs の起動時向けにはemacs-startup-hookを、Emacs の終了時向けにはkill-emacs-hookを使う。注意しなければならないのは、 音源を再生してからEmacs を終了するまでの間には少々待機時間を設けねばならないことだ。待機時間を設けないと、終了時に音源を再生しようとする前に Emacs 本体が終了してしまう。ここでは0.1秒間待機することにした。

;; (play-sound-startup)
(add-hook 'emacs-startup-hook 'play-sound-startup)

;; (play-sound-kill)
(add-hook 'kill-emacs-hook (lambda ()
                             (play-sound-kill)
                             (sleep-for 0.1)
                             ))


これで設定が完了した。

helm (anything) でワード一覧を表示し、そこから音源を再生したり、ファイル名やワードを出力する

上記のように設定するには何かしらのワードを指定しなければならないから、ワード一覧をもとにEmacs 上で音声を再生したりワードを出力する機能を実装してみたい。幸いなことにhelm (anything) を用いると次の画面の通りのリストと選択画面を容易に用意できる。

f:id:blechmusik2:20130907004353p:plain
f:id:blechmusik2:20130907004415p:plain

(defun helm-voice-files ()
  (interactive)
  (let ((helm-candidate-number-limit (length kei-voice-filename-list)))
    (helm (list
           helm-c-source-voice-files))))

(defun fontify-string-with-keyword-face (string)
 (with-temp-buffer
  (insert string)
  (add-text-properties (point-min) (point-max)
                      '(face font-lock-keyword-face))
  (buffer-string)))


(setq helm-c-source-voice-files      
      '((name . "pronama voice files")
        (candidates . kei-voice-filename-list)
        (candidate-transformer . (lambda (candidates)
                                   (mapcar
                                    (function (lambda(arg)
                                                (let* ((file (replace-regexp-in-string "^.+/" "" arg))
                                                       (main-number (car (voice-filename->number-list file)))
                                                       (sub-number (cadr (voice-filename->number-list file)))
                                                       (word (voice-filename->word file)))
                                                  (cons (if (string-match "/cut/" arg)
                                                            (fontify-string-with-keyword-face
                                                             (format 
                                                              (format "%03d[%d]: %s"
                                                                      main-number
                                                                      sub-number
                                                                      word)))
                                                          (format "%03d[F]: %s"
                                                                  main-number
                                                                  word))
                                                        arg))))
                                    candidates)))
        (action . (("play voice" . play-sound)
                   ("insert filename" . insert-string)
                   ("insert words" . (lambda (f)
                                       (insert-string (voice-filename->word f))))
                   ))))

(defun voice-filename->number-list (filename)
  (lexical-let* ((file-info (s-replace "kei_voice_" "" (f-base filename)))
                 (number-list (split-string file-info "_phrase")))
    (list (string-to-number (car number-list))
          (if (< 1 (length number-list))
              (string-to-number (cadr number-list))
            0))))

(defun voice-filename->word (filename)
  (labels ((get-voice-word-by-number-list (number-list l)
                                          (loop for data in l
                                                when (equal (car number-list) (car data))
                                                return (let* ((words (cadr data))
                                                              (words-list (split-string words "/"))
                                                              (n (cadr number-list)))
                                                         (if (= n 0)
                                                             words
                                                           (nth (- n 1) words-list))))))
    (get-voice-word-by-number-list (voice-filename->number-list filename) kei-voice-word-list)))

まとめ

ここまでのemacs lisp の設定を纏めるとこうなる。

;; rejeep/f.el · GitHub
;; https://github.com/rejeep/f.el
(require 'f)

;; magnars/s.el · GitHub
;; https://github.com/magnars/s.el
(require 's)

(defvar soundplay-command (file-truename "~/soundplay/soundplay.exe"))


(defun play-sound (sound-file)
  (start-process-shell-command "async"
                               nil
                               soundplay-command
                               sound-file))


;; get file names by list type
(defun get-kei-voice-filename-list (directory)
  (mapcar (lambda (x) (f-short x))
          (f--files directory (equal (f-ext it) "wav") t)))

;; number, text => (number text)
(defun get-kei-voice-word-list (csv-file)
  ;; ex.)
  ;; (26 #("きゃ" 0 2 (charset japanese-jisx0208)) "")
  ;; (27 #("やったね" 0 4 (charset japanese-jisx0208)) "")
  ;; (28 #("いたっ" 0 3 (charset japanese-jisx0208)) "")
  ;; (29 #("えいっ" 0 3 (charset japanese-jisx0208)) "")
  ;; (30 #("がーーん/液晶割れちゃった…" 0 14 (charset japanese-jisx0208)) "")
  (labels ((read-lines (file)
                       (with-temp-buffer
                         (insert-file-contents file)
                         (split-string (buffer-string) "\n" t)
                         ))
           (string->number-and-text (x)
                                    (let ((l (split-string x ",")))
                                      (cons (string-to-number (car l)) (cdr l))))
           )
    (mapcar #'string->number-and-text (read-lines csv-file))))


(defun get-voice-number-and-word (string l)
  (loop for data in kei-voice-word-list
        when (string-match string (cadr data))
        return (cons (car data) (cadr data))))

(defun get-voice-number (string l)
  (let ((result (get-voice-number-in-detail string l)))
    (when result
      (list (car result) 0))))

(defun get-voice-number-in-detail (string l)
  (let* ((number-and-word (get-voice-number-and-word string l))
         (number (car number-and-word))
         (word (cdr number-and-word)))
    (when (stringp word)
      (let ((result (split-string word "/")))
        (list number
              (loop for x in result
                    for i = 1 then (+ 1 i)
                    when (string-match string x)
                    return (if (= 1 (length result)) 0 i)))))))

(defun number-list->voice-filename (l)
  ;; (9 0) => kei_voice_009.wav
  ;; (9 2) => kei_voice_009_phrase2.wav
  (cond
   ((< 0 (cadr l)) (format "kei_voice_%03d_phrase%d.wav" (car l) (cadr l)))
   ((= 61 (car l)) (format "kei_voice_%03d_a.wav" (car l)))
   (t (format "kei_voice_%03d.wav" (car l)))))

(defun owari-dayo-a->b (filename)
  (replace-regexp-in-string "a\\(\.wav\\)" "b\\1" filename))

;; get full name of voice file from word
(defun word->voice-filename-aux (fn string)
  (let ((number-list (funcall fn string kei-voice-word-list)))
    (when number-list
      (loop for x in kei-voice-filename-list
            when (string-match (number-list->voice-filename number-list) x)
            return x))))

(defun word->voice-without-cut-filename (string)
  (word->voice-filename-aux #'get-voice-number string))

(defun word->voice-with-cut-filename (string)
  (word->voice-filename-aux #'get-voice-number-in-detail string))


(setq voice-directory "~/pronama-voice/"
      kei-voice-filename-list (get-kei-voice-filename-list voice-directory)

      ;; ダウンロード(クリエイター向け素材) | プログラミング生放送
      ;; http://pronama.azurewebsites.net/pronama/download/
      kei-voice-word-list-file (concat voice-directory "kei_voice_word_list.csv")
      kei-voice-word-list (get-kei-voice-word-list kei-voice-word-list-file)

      voice-word-good-morning '("おはよう")
      voice-word-good-afternoon '("こんにちは")
      voice-word-good-evening '("こんばんは")
      voice-word-good-night '("おやすみ")
      voice-words-startup '("はじめるよ"
                            "スタート")
      voice-words-kill '("またきてね"
                         "しゅう~りょう~"
                         "おわり、だよ"
                         "はい、しゅーりょー")
      )


(loop for (file . word)
      in '(
           (voice-file-good-morning . voice-word-good-morning)
           (voice-file-good-afternoon . voice-word-good-afternoon)
           (voice-file-good-evening . voice-word-good-evening)
           (voice-file-good-night . voice-word-good-night)
           (voice-files-startup . voice-words-startup)
           (voice-files-kill . voice-words-kill)
           )
      do (set file
              (mapcar #'word->voice-without-cut-filename (symbol-value word))))


;; おわり、だよの別バージョンも再生対象にする
(add-to-list 'voice-files-kill
             (owari-dayo-a->b (word->voice-with-cut-filename "おわり、だよ")))


(defun play-voice-startup ()
  (play-sound (voice-file-startup)))
      
(defun play-voice-kill ()
  (play-sound (voice-file-kill)))


(lexical-let ((hour (string-to-number (format-time-string "%H" (current-time)))))
  (labels ((get-wav-filename (files)
                             (file-truename (nth (random (length files)) files))))
    ;;  5時~9時: +おはよう
    ;; 11時~14時: +こんにちは
    ;; 20時~ 3時: +こんばんは
    (defun voice-file-startup ()
      (lexical-let ((voice-files
                     (append voice-files-startup
                             (cond
                              ((and (<= 5 hour) (< hour 9)) voice-file-good-morning)
                              ((and (<= 11 hour) (< hour 14)) voice-file-good-afternoon)
                              ((or (<= 20 hour) (< hour 3)) voice-file-good-evening)
                              (t nil)))))
        (get-wav-filename voice-files)))

    ;; 20時~: +おやすみ
    (defun voice-file-kill ()
      (lexical-let ((voice-files
                     (append voice-files-kill
                             (cond
                              ((<= 20 hour) voice-file-good-night)
                              (t nil)))))
        (get-wav-filename voice-files)))
    ))


;; (play-voice-startup)
(add-hook 'emacs-startup-hook 'play-voice-startup)

;; (play-voice-kill)
(add-hook 'kill-emacs-hook (lambda ()
                             (play-voice-kill)
                             (sleep-for 0.5)
                             ))

(defun helm-voice-files ()
  (interactive)
  (let ((helm-candidate-number-limit (length kei-voice-filename-list)))
    (helm (list
           helm-c-source-voice-files))))

(defun fontify-string-with-keyword-face (string)
 (with-temp-buffer
  (insert string)
  (add-text-properties (point-min) (point-max)
                      '(face font-lock-keyword-face))
  (buffer-string)))


(setq helm-c-source-voice-files      
      '((name . "pronama voice files")
        (candidates . kei-voice-filename-list)
        (candidate-transformer . (lambda (candidates)
                                   (mapcar
                                    (function (lambda(arg)
                                                (let* ((file (replace-regexp-in-string "^.+/" "" arg))
                                                       (main-number (car (voice-filename->number-list file)))
                                                       (sub-number (cadr (voice-filename->number-list file)))
                                                       (word (voice-filename->word file)))
                                                  (cons (if (string-match "/cut/" arg)
                                                            (fontify-string-with-keyword-face
                                                             (format 
                                                              (format "%03d[%d]: %s"
                                                                      main-number
                                                                      sub-number
                                                                      word)))
                                                          (format "%03d[F]: %s"
                                                                  main-number
                                                                  word))
                                                        arg))))
                                    candidates)))
        (action . (("play voice" . play-sound)
                   ("insert filename" . insert-string)
                   ("insert words" . (lambda (f)
                                       (insert-string (voice-filename->word f))))
                   ))))

(defun voice-filename->number-list (filename)
  (lexical-let* ((file-info (s-replace "kei_voice_" "" (f-base filename)))
                 (number-list (split-string file-info "_phrase")))
    (list (string-to-number (car number-list))
          (if (< 1 (length number-list))
              (string-to-number (cadr number-list))
            0))))

(defun voice-filename->word (filename)
  (labels ((get-voice-word-by-number-list (number-list l)
                                          (loop for data in l
                                                when (equal (car number-list) (car data))
                                                return (let* ((words (cadr data))
                                                              (words-list (split-string words "/"))
                                                              (n (cadr number-list)))
                                                         (if (= n 0)
                                                             words
                                                           (nth (- n 1) words-list))))))
    (get-voice-word-by-number-list (voice-filename->number-list filename) kei-voice-word-list)))

おわりに

 このエントリーでは Emacs の起動時と終了時に「“プロ生ちゃん”のシステムボイス」音源を再生する手順を説明した。このエントリーでは省略したが、任意のプログラムのコンパイルや実行、デバッグといった場面において当該音源を活用できるのはいうまでもない。配布されている音源を活用してよりよい開発環境を構築したいものだ。

 なお今回配布された音源には、アルファベット以外のキーの読み上げは収録されているが、それとは対象的にアルファベットの部分は一切収録されていない。そして、カットやコピー、ペーストといった、キーを複数個組み合わせて行う一般的な処理の読み上げも収録されていない。これらが補完されればより利用しやすい音源集となるだろう。