# -*- coding: utf-8 -*-

# 
# mecabrola.rb
# 
# The MIT License (MIT)
#
# Copyright (c) 2016 Takashi Sakamoto
#
# Permission is hereby granted, free of charge, to any person obtaining a copy of this software and
# associated documentation files (the "Software"), to deal in the Software without restriction,
# including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense,
# and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so,
# subject to the following conditions:
#
# The above copyright notice and this permission notice shall be included in all copies or substantial portions
# of the Software.
#
# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED
# TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL
# THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
# OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
# DEALINGS IN THE SOFTWARE.
#

require 'MeCab'
require 'tempfile'

class Mecabrola < Plugin
    # diphone database(フルパス)
    @@dbpath = '~/.mikutter/plugin/mecabrola/jp3/jp3'

    # 声の高さ(Hz)
    @@tone = 440

    # 声の高さの揺れ(Hz)
    @@tone_range = 60

    # 発音の長さ(ms)
    @@mora_duration = 130

    @@convert_table = {
	# 撥音
        'ッ'    => ['q'],
	# 拗音
        'ャ'    => ['xj', 'a'],
        'ュ'    => ['xj', 'u'],
        'ョ'    => ['xj', 'o'],
        # Temporary letters in this stage.
        'ー'    => ['-'],
        'ァ'    => ['xa'],
        'ィ'    => ['xi'],
        'ゥ'    => ['xu'],
        'ェ'    => ['xe'],
        'ォ'    => ['xo'],
        # 母音
        'ア'    => ['a'],
        'イ'    => ['i'],
        'ウ'    => ['u'],
        'エ'    => ['e'],
        'オ'    => ['o'],
        # 母音と子音
        'カ'    => ['k',  'a'],
        'キ'    => ['k',  'i'],
        'ク'    => ['k',  'u'],
        'ケ'    => ['k',  'e'],
        'コ'    => ['k',  'o'],
        'サ'    => ['s',  'a'],
        'シ'    => ['S',  'i'],
        'ス'    => ['s',  'u'],
        'セ'    => ['s',  'e'],
        'ソ'    => ['s',  'o'],
        'タ'    => ['t',  'a'],
        'チ'    => ['tS', 'i'],
        'ツ'    => ['ts', 'u'],
        'テ'    => ['t',  'e'],
        'ト'    => ['t',  'o'],
        'ナ'    => ['n',  'a'],
        'ニ'    => ['n',  'i'],
        'ヌ'    => ['n',  'u'],
        'ネ'    => ['n',  'e'],
        'ノ'    => ['n',  'o'],
        'ハ'    => ['h',  'a'],
        'ヒ'    => ['h',  'i'],
        'フ'    => ['h',  'u'],
        'ヘ'    => ['h',  'e'],
        'ホ'    => ['h',  'o'],
        'マ'    => ['m',  'a'],
        'ミ'    => ['m',  'i'],
        'ム'    => ['m',  'u'],
        'メ'    => ['m',  'e'],
        'モ'    => ['m',  'o'],
        'ヤ'    => ['j',  'a'],
        'ユ'    => ['j',  'u'],
        'ヨ'    => ['j',  'o'],
        'ラ'    => ['rr', 'a'],
        'リ'    => ['rr', 'i'],
        'ル'    => ['rr', 'u'],
        'レ'    => ['rr', 'e'],
        'ロ'    => ['rr', 'o'],
        'ワ'    => ['w',  'a'],
        'ヲ'    => ['o'],
        'ン'    => ['N'],
        # 濁音
        'ガ'    => ['g',  'a'],
        'ギ'    => ['g',  'i'],
        'グ'    => ['g',  'u'],
        'ゲ'    => ['g',  'e'],
        'ゴ'    => ['g',  'o'],
        'ザ'    => ['z',  'a'],
        'ジ'    => ['dZ', 'i'],
        'ズ'    => ['z',  'u'],
        'ゼ'    => ['z',  'e'],
        'ゾ'    => ['z',  'o'],
        'ダ'    => ['d',  'a'],
        'ヂ'    => ['dZ', 'i'],
        'ヅ'    => ['z',  'u'],
        'デ'    => ['d',  'e'],
        'ド'    => ['d',  'o'],
        'バ'    => ['b',  'a'],
        'ビ'    => ['b',  'i'],
        'ブ'    => ['b',  'u'],
        'ベ'    => ['b',  'e'],
        #  半濁音
        'ボ'    => ['b', 'o'],
        'パ'    => ['p', 'a'],
        'ピ'    => ['p', 'i'],
        'プ'    => ['p', 'u'],
        'ペ'    => ['p', 'e'],
        'ポ'    => ['p', 'o'],
    }

    def initialize(args)
        super(args)
        @tokenizer = MeCab::Tagger.new('-Owakati')
    end

    # NAIST Japanese Dictionary version 0.4.0 ユーザーズマニュアル
    # http://sourceforge.jp/projects/naist-jdic/docs/NAIST-JDIC-ja.pdf/ja/1/NAIST-JDIC-ja.pdf.pdf
    #  5. IPA 品詞体系
    #  5.1 名詞
    #  5.2 接頭詞
    #  5.3 動詞
    #  5.4 形容詞
    #  5.5 副詞
    #  5.6 連体詞
    #  5.7 接続詞
    #  5.8 助詞
    #  5.9 助動詞
    #  5.10 感動詞
    #  5.11 記号
    #  5.12 フィラー
    #  5.13 その他
    def tokenize_message(message)
        tokens = []

        result = @tokenizer.parseToNode(message)
        result = result.next    # Skip BOS.
        while result do
            if result.surface != '' then
                elements = result.feature.split(',')

                token = Array.new(6)
                if elements.length() < 8 then
                    token[0] = result.surface
                else
                    token[0] = elements[8]
                end
                token[1] = result.surface
                token[2] = elements[0]
                token[3] = elements[1]
                token[4] = elements[2]
                token[5] = elements[3]

                tokens.push(token)
            end
            result = result.next
        end

        tokens
    end

    def generate_pho_lines(tokens)
        temp = Array.new()
        candidates = Array.new()
        applied = Array.new()

        # Pre-processing
        morphemes = self.convert(tokens)
        morphemes = self.arrange(morphemes)
        morphemes = self.cleanup(morphemes)
        applied = self.apply_sokuon_restrictions(morphemes)
        lines = self.tune_tones(applied)
    end

    def convert(tokens)
        morphemes = []
        tokens.each do |token|
            pos = 0
            token[0].chars do |char|
                if @@convert_table.has_key?(char) then
                    signs = @@convert_table[char]
                else
                    # No sound sign.
                    signs = ['_']
                end
                for sign in signs do
                    morpheme = []
                    morpheme.push(sign)
                    morpheme.push(pos)
                    morpheme.push(char)
                    morpheme.push(token[1])
                    morpheme.push(token[2])
                    morpheme.push(token[3])
                    morpheme.push(token[4])
                    morphemes.push(morpheme)
                end
                pos += 1
            end
        end
        morphemes
    end

    def arrange(morphemes)
        morphemes.each_with_index do |morpheme, index|
            if morpheme[0] == 'q' then
                morphemes[index + 1][0] = 'Q' + morphemes[index + 1][0]
                morphemes[index][0] = ''
            elsif morpheme[0] == 'xj' then
                sign = morphemes[index - 1][0]
                if ["QS", "QdZ", "QtS", "S", "dZ", "tS"].include?(sign) then
                    morphemes[index][0] = '_'
                else
                    morphemes[index][0] = 'j'
                end
                morphemes[index - 1][0] = '_'
                morphemes[index][1] = morphemes[index - 2][1]
                morphemes[index][2] = morphemes[index - 2][2]
                morphemes[index + 1][1] = morphemes[index - 2][1]
                morphemes[index + 1][2] = morphemes[index - 2][2]
            elsif morpheme[0] == 'x' then
                morphemes[index][0] = morpheme[0][1]
                morphemes[index][1] = morphemes[index - 2][1]
                morphemes[index][2] = morphemes[index - 2][2]
            elsif morpheme[0] == '-' then
                morphemes[index][0] = morphemes[index - 1][0]
            end
        end
        morphemes
    end

    def cleanup(morphemes)
        candidate = []
        morphemes.each_with_index do |morpheme, index|
            if morpheme[0] != '' then
                candidate.push(morpheme)
            end
        end
        candidate
    end

    # 促音に関する制限を適用:
    # jp1/jp3は、*-Q/Q-*に対応する収録データが多数不足している.
    # 詳しく知りたい人はデータベースのバイナリ読むときっと幸せになれる.
    # 大まかに言うと、バイナリはヘッダ部、エントリー部、PCMデータ部からなり:
    # 1.0x1Cバイト目からエントリー部が始まる
    # 2.エントリーの構造は「0x00 ASCIIの1〜2バイト 0x00 ASCIIが1〜2バイト
    #   0x00 5バイト」
    #   例:「0x 72 72 00 6F 00 BA 01 1A 1A」これは「rr-o（7272と6F）」の
    #      エントリーを表す
    # 3.エントリーのASCIIを使って表を作成すると、合成可能な記号の組み合わせ表と
    # なる
    def apply_sokuon_restrictions(candidates)
        applied = []
        candidates.each_with_index do |candidate, index|
            applied.push(candidate)

            if candidate != candidates[-1] and candidate[0][0] == 'Q' then
                applied[-1][1] += 200
                applied[-1][2] = '促音の代理'
                applied.push(candidate)

                if candidate[0] == 'QS' and candidates[index - 1][0] == 'o' then
                    alternative = 'S'
                elsif candidate[0] == 'Qs' and 
                   (candidates[index - 1][0] == 'o' or
                    candidates[index + 1][0] == 'u') then
                    alternative = 's'
                elsif candidate[0] == 'Qd' and
                      (['u', 'o'].include?(candidates[index - 1][0]) or
                       ['a', 'e'].include?(candidates[index + 1][0])) then
                    alternative = 'd'
                elsif candidate[0] == 'Qts' then
                    if ['a', 'i', 'e', 'o'].include?(candidates[index - 1][0])
                        alternative = 'ts'
                    else
                        alternative = 'tS'
                    end
                elsif candidate[0] == 'Qz' then
                    alternative = 'ts'
                elsif candidate[0] == 'QdZ' then
                    alternative = 'tS'
                elsif candidate[0] == 'Qp' and
                      candidates[index + 1][0] == 'j' then
                    alternative = 'ts'
                else
                    alternative = ''
                end
                applied[-1][0] = alternative
            end
        end
        applied
    end

    # 配列の調整:
    # 継続時間とピッチを付与する.  Mbrolaはピッチペアを複数与えることで単音に
    # イントネーションを与えることができるが、今回はそれを使ってない. 中国語の
    # 音声合成はさぞかし大変だろう.
    def tune_tones(applied)
        lines = []
        count = 0
        type = ''
        applied.each_with_index do |line, index|
            # 配列の末尾に注意しつつ、1モーラを構成するものをグループにする
            if line != applied[-1] and line[1] == applied[index + 1][1] and
               line[2] == applied[index + 1][2] then
               count += 1
               next
            end

            # 1モーラ境界に到達.
            # 前の品詞と異なったら, このモーラのピッチ下げる.
            if line[3] != type then
                base_tone = @@tone - @@tone_range / 2
                type = line[3]
            else
                if line == applied[-1] then
                    base_tone = @@tone
                    # 次の品詞が異なるなら, このモーラのピッチを結構下げる.
                elsif line[3] != applied[index + 1][3] then
                    base_tone = @@tone - @@tone_range
                # 次が記号なら, このモーラのピッチをちょっとだけ下げる.
                # ここでは, EOSを想定している.
                elsif applied[index + 1][0] == ' ' then
                    base_tone = @@tone - @@tone_range / 2
                else
                    base_tone = @@tone
                end
                type = line[3]
            end

            # 品詞からピッチを決定
            if ['動詞', '名詞', '形容詞', '連体詞'].include?(line[3]) then
                line_tone = base_tone + @@tone_range
            else
                line_tone = base_tone - @@tone_range
            end

            # 1モーラの記号数から単音一つあたりの継続時間を決定
            line_duration = (@@mora_duration / (count + 1)).round

            # lineの作成と置き換え
            j = index - count
            while j <= index do
                line = []
                if [' ', ''].include?(applied[j][0]) then
                    sign = '_'
                else
                    sign = applied[j][0]
                end
                p(sign)
                line.push(sign)
                line.push(line_duration)
                line.push(0)
                line.push(line_tone)
                # 足温器号は2倍の継続時間とする.
                if applied[j][0][0] == 'Q' then
                    line[1] *= 2
                end
                lines.push(line)
                j += 1
            end
            count = 0
        end
        lines
    end

    # Tmpfileを作成し、Mbrolaのphoフォーマットデータを書き出す。
    def write_pho_file(lines)
        Tempfile.open() do |file|
            for line in lines do
                file.puts(line.join("\t"))
            end
            file
        end
    end

    def run_mbrola_aplay(handle)
        command = "mbrola #{@@dbpath} #{handle.path} -.au | aplay"
        Process.spawn(command)
    end
end

Mecabrola.create(:mecabrola) do
    onupdate do |service, messages|
        # Skip when several messages are going to be displayed.
        if messages.length() > 1 then
            next
        end
        if !messages[0].respond_to?('body') then
            next
        end
        # The array includes one message.
        tokens = self.tokenize_message(messages[0].body())
        lines = self.generate_pho_lines(tokens)
        handle = self.write_pho_file(lines)
        self.run_mbrola_aplay(handle)
    end
end
