end0tknr's kipple - web写経開発

太宰府天満宮の狛犬って、妙にカワイイ

javascript + perl から voicevox web api を 用い tts音声合成

voicevox for win は、無料で利用できるにも関わらず、 高品質な音声合成アプリです。

しかも web api経由で利用できますので、 javascript + perl から voicevox web api を 用い tts音声合成してみました。

全体構成

┌────┐  ┌────┐  ┌────┐  ┌──────┐
│html+js ├→│  iis   ├→│perl cgi├→│voicevox api│
└────┘  └────┘  └────┘  └──────┘

画面構成

html

<!DOCTYPE html>
<html lang="ja">
<head>
  <meta charset="UTF-8">
  <title>VOICEVOX 音声合成</title>
  <style>
    body {font-family: "Helvetica Neue", Arial,"Hiragino Kaku Gothic ProN",
                       "Hiragino Sans", Meiryo, sans-serif;
          color: #555;}
    a:visited { color: #00f; }
    h1 {margin:5px 0; font-size:1.5em; font-family:"HG丸ゴシックM-PRO";
        display: flex; align-items: center;}
    h1 img { width:32px; height:32px; margin-right:5px; }
    
    h2 {font-size:1.2em;
        font-family:"HG丸ゴシックM-PRO";
        border-left: solid 8px #ABD4AD;
        clear: both;
        padding: 2px;}
    #toast_msg      {color  : #ff4500;}
    #toast_msg.hide {display: none;   }
  
table{border-collapse:collapse;table-layout:fixed;width:100%;}
td,th{border:1px solid #ccc;padding:2px 4px;font-size:0.9em;height:24px;}
tr{height:24px;}
td.name{max-width:300px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;}
button{cursor:pointer;padding:2px 8px}
.phrase{display:inline-block;margin:2px;padding:2px;border:1px solid #999;background:#fff;}
.mora{display:inline-block;margin:2px;padding:1px;cursor:pointer;user-select:none;font-size:0.85em;}
.mora.accent{background:#ffeb3b;font-weight:bold;}
.mora:hover{background:#e0e0e0;}
  </style>
  <link rel="icon" type="image/png" sizes="16x16" href="voicevox_favicon.png">
</head>
<body>
  <h1>
    <img src="voicevox_icon.jpg"/>
    VOICEVOX - 音声合成
  </h1>
  <div>
    音声合成ソフトの
    <a href="https://voicevox.hiroshiba.jp/" target="_blank">VOICEVOX</a>
    に ブラウザ用画面を設け、複数名による同時利用を可能にしました。<br/>
    <span style="color:#f00"></span>
    <a href="https://voicevox.hiroshiba.jp/term/" target="_blank">VOICEVOX</a>
    の規約に従い、作成した資料には「VOICEVOX:波音リツ」の記載をお願いします
  </div>
  <div id="login_form" style="width:800px; text-align:right;">
    <input type="text"     id="uid"
           autocomplete="off" placeholder="SmileID">
    <input type="password" id="upw"
           autocomplete="off" placeholder="パスワード">
    <button type="button" id="login_btn" style="width:70px">
      Login
    </button>
  </div>
  
  <div id="toast_msg" class="hide"></div>

  <div id="user_info" style="width:800px; text-align:right; display:none;">
    <span style="font-size:small;"></span>
    <button type="button" id="logout_btn" style="width:70px">
      Logout
    </button>
  </div>

  <div id="audio_form" style="display:none;">
    <h2>step1 - スクリプト入力</h2>

    <div>
      最大 500文字。「、」「。」で80文字以内で区切ってください。
    </div>
    
    <textarea id="text" rows="12" cols="110"
          >音声合成ソフトの VOICEVOX に ブラウザ用画面を設け、複数名による同時利用を可能にしました。
※ VOICEVOX の規約に従い、作成した資料には「VOICEVOX:波音リツ」の記載をお願いします</textarea><br/>
      
    <button id="synthBtn">音声合成</button>
    <img id="loadingGif" src="loading.gif" style="display:none; margin-left:10px;"
     alt="読み込み中...">
    
    <h2>step2 - イントネーションPitch編集</h2>
    <div>
      音節(モーラ)のイントネーションPitchをで調整できます。
    </div>
    
    <table style="width:750px;">
      <thead>
        <tr>
          <th>スクリプト</th>
          <th style="width:100px">size</th>
          <th style="width:150px">time</th>
          <th style="width:60px">操作</th></tr>
      </thead>
      <tbody id="tbody"></tbody>
    </table>
    
    <div id="accentPanel"
     style="display:none; margin-top:10px; border:1px solid #ccc; padding:10px; background:#f9f9f9;">
      <div style="display:flex; align-items:center; gap:10px;">
        <audio id="accentAudio" controls preload="metadata" style="width:300px;"></audio>
        <button id="synthWithAccentBtn">この内容で合成</button>
        <button id="resetAccentBtn">初期化</button>
        <button id="closeAccentBtn">キャンセル</button>
      </div>
      <div id="org_text"></div>
      <div id="accentPhrases"></div>
      <div style="margin-top:10px; padding:5px; border-top:1px solid #ddd;">
        <label>この文の後の無音時間:
          <input type="number" id="pauseInput"
         min="0" max="5" step="0.5" value="0" style="width:60px;"></label>
      </div>
    </div>
    
    <h2>step3 - 音声ダウンロード</h2>
    <div id="mergedPanel" style="display:none; margin-top:10px;">
      <div style="margin-top:10px; width:300px;">
        <audio id="mergedAudio" controls preload="auto" style="width:100%;"></audio>
      </div>
    </div>
    
  </div><!--#audio_form-->

  <template id="rowTemplate">
    <tr>
      <td class="name"></td>
      <td style="text-align:center;" class="size"></td>
      <td style="text-align:center;" class="time"></td>
      <td style="text-align:center;">
        <button class="accentBtn">編集</button>
      </td>
    </tr>
  </template>
  <script src="voicevox.js"></script>
</body>
</html>

javascript

'use strict';
//const login_url = "../chk_user.html";
const login_url = "../chk_user.pl";
const allowed_depts = ["開発部","生産グループ"];
const voicevox_api  = "voicevox.pl";


class VoicevoxWavManager {
    constructor(tbodyId, textId, templateId) {
        this.tbody = document.getElementById(tbodyId);
        this.textElement = document.getElementById(textId);
        this.template = document.getElementById(templateId);
        this.audioQuery = null;
        this.queryText = null;
        this.sentenceQueries = []; // 分割した文字列とアクセント情報を保存
    }
    
    init_window() {
        this.add_event_listener();
    }

    add_event_listener=()=> {
        document.querySelector("#login_btn").addEventListener(
            'click',()=>{ this.login(); });
        document.querySelector("#logout_btn").addEventListener(
            'click',()=>{ this.logout(); });
        document.querySelector("#uid").addEventListener('keypress',(e)=>{
            if (e.keyCode == 13) this.login(); // ENTER
        });
        document.querySelector("#upw").addEventListener('keypress',(e)=>{
            if (e.keyCode == 13) this.login(); // ENTER
        });
        document.getElementById('synthBtn').addEventListener(
            'click',() => this.synth() );
        document.getElementById('synthWithAccentBtn').addEventListener(
            'click', () => { this.synthWithAccent(); });

        document.getElementById('resetAccentBtn').addEventListener(
            'click', () => { this.resetAccent(); });

        document.getElementById('closeAccentBtn').addEventListener(
            'click', () => {
                document.getElementById('accentPanel').style.display = 'none';
                this.currentEditingFile = null;
            });
    }
    
    toast_msg=(msg, msec)=>{
        if( msec==undefined ||  msec==null){
            msec = 5000;
        }
        let msg_container = document.querySelector("#toast_msg");
        msg_container.innerHTML = msg;
        
        msg_container.className = msg_container.className.replace("hide","");
        setTimeout(()=>{ msg_container.className="hide";},msec);
    }
    
    login =async()=> {
        let form_data = new URLSearchParams();
        form_data.append('id', document.querySelector("#uid").value);
        form_data.append('pw', document.querySelector("#upw").value);

        let response;
        try {
            if( login_url.match(/.*\.pl$/) ){
                response = await fetch(login_url,
                                       {method:"POST",
                                        body:form_data.toString() });
            } else {
                response = await fetch(login_url);
            }
        } catch (error) {
            this.toast_msg("認証システムが応答せず、ログイン処理に失敗しました");
            return undefined;
        }
        if (! response.ok ){
            this.toast_msg("ユーザIDまたは、パスワードが誤っているようです");
            return undefined;
        }

        let res_txt =
            new TextDecoder('utf-8').decode( await response.arrayBuffer()) ||"";
        let user_info = res_txt.split("\t");
        if(user_info.length < 2){
            this.toast_msg("ユーザの所属部署を確認できない為、ご利用頂けません");
            return undefined;
        }

        let use_ok = false;
        for(let allowed_dept of allowed_depts ){
            if( user_info[1].indexOf( allowed_dept ) >= 0){
                use_ok = true;
                break;
            }
        }
        
        if(! use_ok){
            this.toast_msg(
                "このシステムは住宅カンパニー開発統括部の方のみ、利用可能です");
            return undefined;
        }

        let user_info_str = user_info.join("@");
        document.querySelector("#user_info span").textContent = user_info_str;

        document.querySelector("#user_info").style.display = "block";
        document.querySelector("#login_form").style.display= "none";

        document.querySelector("#audio_form").style.display = "block";
    }
    
    logout() {
        document.querySelector("#user_info span").textContent = "";
        document.querySelector("#uid").value = "";
        document.querySelector("#upw").value = "";
        
        document.querySelector("#audio_form").style.display = "none";
        
        document.querySelector("#user_info").style.display = "none";
        document.querySelector("#login_form").style.display= "block";
        document.querySelector("#uid").focus();
    }
    

    getLoginUser(){
        const user_info =
              document.querySelector("#user_info span").textContent.split("@");
        return user_info[0].trim();
    }
    
    synth=async()=>{
        const uid = this.getLoginUser();

        const text = this.textElement.value;
        if (text.length == 0) {
            alert('音声合成するスクリプトを入力してください');
            return;
        }

        // 音声合成済みfileを一旦、全削除
        await this.delUsersList();

        // ファイルがない場合もtableを空にしてから音声合成を開始
        this.tbody.innerHTML = '';

        try {
            this.startSynthesis(uid, text);
        } catch (error) {
            alert(error.message);
        }
    }
    
    startSynthesis=async(uid, text)=>{
        // ローディングGIFを表示
        const loadingGif = document.getElementById('loadingGif');
        if (loadingGif) loadingGif.style.display = 'inline';

        try {
            // アクセント編集情報をリセット
            this.audioQuery = null;
            this.queryText = null;
            this.sentenceQueries = []; // 保存されたアクセント情報をクリア
            this.mergedFile = null;
            document.getElementById('accentPanel').style.display = 'none';
            document.getElementById('mergedPanel').style.display = 'none';

            // 2. 「、」「。」で最大80文字以内に分割
            const sentences = this.splitText(text, 80);

            let i = 0;
            for( const sentence of sentences ){

                const accent = await this.calcAccent(uid,sentence);

                const wav_json =
                      await this.accent_to_wav( uid, accent, "", sentence );
                
                this.sentenceQueries.push({index  : i,
                                           text   : sentence,
                                           query  : accent,
                                           wavFile: wav_json.file,
                                           size   : wav_json.size,
                                           time   : wav_json.time,
                                           pause  : 0});

                const tr = this.addTblTr( {index  : i,
                                           text   : sentence,
                                           query  : accent,
                                           wavFile: wav_json.file,
                                           size   : wav_json.size,
                                           time   : wav_json.time});
                this.tbody.appendChild(tr);
                i++;
            }

            // 全ての音声合成が完了したら、WAVファイルを結合
            await this.mergeAllWavFiles(uid);
        } finally {
            // ローディングGIFを非表示
            if (loadingGif) loadingGif.style.display = 'none';
        }
    }

    mergeAllWavFiles=async(uid)=>{
        // sentenceQueriesからファイル名とpause情報を作成
        // pause値は既にsentenceQueriesに保存されている
        const filesWithPause = this.sentenceQueries.map(sq => ({
            file: sq.wavFile,
            pause: sq.pause
        })).filter(item => item.file);

        if (filesWithPause.length === 0) {
            console.log('結合するファイルがありません');
            return;
        }

        console.log('結合するファイル:', filesWithPause);

        const req_params = new URLSearchParams({
            action: 'merge',
            uid: uid,
            json: JSON.stringify({ files: filesWithPause })
        });
    const req_url = voicevox_api +"?action=merge";
        try {
            const response = await fetch(req_url, {
                method: 'POST',
                body: req_params
            });
            const result = await response.json();

            if (result.status === 'ok' && result.file) {
                console.log('結合完了:', result.file);

                // 結合済みパネルを表示
                const mergedPanel = document.getElementById('mergedPanel');
                const mergedAudio = document.getElementById('mergedAudio');

                // キャッシュを無効化して読み込み
                mergedAudio.src = 'wav/' + result.file + '?' + Date.now();
                mergedAudio.load();

                mergedPanel.style.display = 'block';

                // 結合ファイル名を保存
                this.mergedFile = result.file;
            }
        } catch (error) {
            console.error('結合エラー:', error);
        }
    }

    addTblTr( trInfo ){
        const clone = this.template.content.cloneNode(true);
        const tr = clone.querySelector('tr');

        const nameCell = tr.querySelector('.name');
        nameCell.textContent = trInfo.text;

        const sizeInKB = (parseInt(trInfo.size) / 1024).toFixed(1);
        tr.querySelector('.size').textContent = sizeInKB + ' KB';

        tr.querySelector('.time').textContent = trInfo.time;

        const accentBtn = tr.querySelector('.accentBtn');
        accentBtn.addEventListener('click', () => {
            this.editAccentForFile( trInfo );
        });
        return tr;
    }

    cleanText(text) {
        // 改行と全角・半角スペースを除去
        return text.replace(/[\r\n\s ]+/g, '');
    }

    splitText(text, maxLength) {
        // 1. 改行と空白を除去
        const cleanedText = this.cleanText(text);

        if (cleanedText.length > 500) {
            throw new Error('テキストが長すぎます');
        }

        // 3. 「。」で分割
        const sentences =
              cleanedText.split('。').filter(s => s.length>0).map(s=> s +'。');

        // 最後の要素が「。」で終わらない場合の処理
        if (sentences.length > 0 && !cleanedText.endsWith('。')) {
            sentences[sentences.length - 1] =
                sentences[sentences.length - 1].slice(0, -1);
        }

        const result = [];

        // 4. 80文字を超える文を「、」で分割
        for (const sentence of sentences) {
            if (sentence.length <= maxLength) {
                result.push(sentence);
            } else {
                // 「、」で分割
                const parts = sentence.split('、').filter(p => p.length > 0);
                let combined = '';

                for (let i = 0; i < parts.length; i++) {
                    const part = parts[i] + (i < parts.length - 1 ? '、' : '');

                    // 5. 「、」で分割後も80文字を超える場合はエラー
                    if (part.length > maxLength) {
                        throw new Error(
                            `分割後も80文字超です:${part.substring(0, 20)}..`);
                    }

                    if ((combined + part).length <= maxLength) {
                        combined += part;
                    } else {
                        if (combined.length > 0) {
                            result.push(combined);
                        }
                        combined = part;
                    }
                }

                if (combined.length > 0) {
                    result.push(combined);
                }
            }
        }

        return result;
    }
    
    calcAccent=async(uid,sentence)=>{
        const req_params = new URLSearchParams({action: 'audio_query',
                                                uid :  uid,
                                                text: sentence });
    const req_url = voicevox_api +"?action=audio_query";
        const response = await fetch(req_url,
                     {method: 'POST',
                                      body: req_params});
        const res_json = await response.json();
        return res_json.query;
    }
    
    synthWithAccent=async()=>{
        if (!this.audioQuery || !this.currentEditingFile) {
            alert('アクセント情報が見つかりません');
            return;
        }

        // ローディングGIFを表示
        const loadingGif = document.getElementById('loadingGif');
        if (loadingGif) loadingGif.style.display = 'inline';

        try {
            const uid = this.getLoginUser();
            const accent = this.audioQuery;
            const originalFilename = this.currentEditingFile;
            const originalText = this.queryText;

            // デバッグ: POSTする前のデータを確認
            console.log('synthWithAccent - POSTアクセント:',
                        JSON.stringify(accent));
            console.log('synthWithAccent - 対象ファイル:', originalFilename);
            console.log('synthWithAccent - 元のテキスト:', originalText);

            // 元のファイル名と元のテキストを指定して上書き
            const wav_json = await this.accent_to_wav(uid,
                                                      accent,
                                                      originalFilename,
                                                      originalText);

            if (wav_json.status === 'ok' && wav_json.file) {
                console.log('アクセント編集後の再合成完了:', wav_json);

                // sentenceQueriesの該当データを更新
                const queryData = this.sentenceQueries.find(
                    sq => sq.wavFile === originalFilename);
                if (queryData) {
                    queryData.query = accent;
                    queryData.size = wav_json.size;
                    queryData.time = wav_json.time;

                    // 無音時間の値を保存
                    const pauseInput = document.getElementById('pauseInput');
                    if (pauseInput) {
                        queryData.pause = parseFloat(pauseInput.value) || 0;
                    }
                }

                // tableの該当行を更新
                const rows = this.tbody.querySelectorAll('tr');
                rows.forEach((row, index) => {
                    const queryItem = this.sentenceQueries[index];
                    if (queryItem && queryItem.wavFile === originalFilename) {
                        row.querySelector('.size').textContent =
                            (parseInt(wav_json.size) / 1024).toFixed(1) + ' KB';
                        row.querySelector('.time').textContent = wav_json.time;
                    }
                });

                // アクセント編集パネルのaudio要素を更新
                const audio = document.getElementById('accentAudio');
                if (audio) {
                    // キャッシュを無効化し再読み込み
                    audio.src = 'wav/' + originalFilename + '?' + Date.now();
                    audio.load();
                }

                // マージファイルを再生成(無音時間を反映)
                await this.mergeAllWavFiles(uid);
            }

        } finally {
            // ローディングGIFを非表示
            if (loadingGif) loadingGif.style.display = 'none';
        }
    }

    resetAccent=async()=>{
        if (!this.queryText || !this.currentEditingFile) {
            alert('初期化する対象が見つかりません');
            return;
        }

        // ローディングGIFを表示
        const loadingGif = document.getElementById('loadingGif');
        if (loadingGif) loadingGif.style.display = 'inline';

        try {
            const uid = this.getLoginUser();
            const originalFilename = this.currentEditingFile;
            const originalText = this.queryText;

            console.log('=== アクセントを初期化(VOICEVOXデフォルト) ===');
            console.log('対象ファイル:', originalFilename);
            console.log('テキスト:', originalText);

            // VOICEVOXのデフォルトでアクセント計算
            const newAccent = await this.calcAccent(uid, originalText);

            // 新しいアクセント情報で音声合成
            const wav_json = await this.accent_to_wav(uid,
                                                      newAccent,
                                                      originalFilename,
                                                      originalText);

            if (wav_json.status === 'ok' && wav_json.file) {
                console.log('初期化完了:', wav_json);

                // sentenceQueriesの該当データを更新
                const queryData = this.sentenceQueries.find(
                    sq => sq.wavFile === originalFilename);
                if (queryData) {
                    queryData.query = newAccent;
                    queryData.size = wav_json.size;
                    queryData.time = wav_json.time;
                }

                // audioQueryを更新
                this.audioQuery = newAccent;

                // tableの該当行を更新
                const rows = this.tbody.querySelectorAll('tr');
                rows.forEach((row, index) => {
                    const queryItem = this.sentenceQueries[index];
                    if (queryItem && queryItem.wavFile === originalFilename) {
                        row.querySelector('.size').textContent =
                            (parseInt(wav_json.size) / 1024).toFixed(1) + ' KB';
                        row.querySelector('.time').textContent = wav_json.time;
                    }
                });

                // アクセント編集パネルを再表示(新しいアクセント情報で)
                this.showAccentEditorForFile();

                // マージファイルを再生成
                console.log('初期化後、マージファイルを再生成します');
                await this.mergeAllWavFiles(uid);
            }
        } finally {
            // ローディングGIFを非表示
            if (loadingGif) loadingGif.style.display = 'none';
        }
    }


    accent_to_wav=async(uid,accent,filename,text='')=>{
        const queryString = JSON.stringify(accent);

        const req_params = new URLSearchParams({
            action: 'synth_with_query',
            uid :  uid,
            query: queryString,
            filename: filename,
            text: text
        });

    const req_url = voicevox_api +"?action=synth_with_query";
        const response = await fetch(req_url,{method: 'POST',
                                              body: req_params});
        const res_json = await response.json();
        console.log('accent_to_wav - サーバーレスポンス:', res_json);
        return res_json;
    }
    
    delUsersList=async(uid)=> {
        let req_params = new URLSearchParams({action: "delete_all",
                                              uid   : this.getLoginUser()});
        let req_url = voicevox_api +"?"+ req_params;
        let response = await fetch(req_url, {method:"GET"});
        return response.json();
    }

    

    editAccentForFile(queryData) {
        // ファイルに紐付いたアクセント情報を編集
        this.audioQuery = queryData.query;
        this.queryText = queryData.text;
        this.currentEditingFile = queryData.wavFile;
        
        this.showAccentEditorForFile();
    }

    showAccentEditorForFile() {
        const panel = document.getElementById('accentPanel');
        const container = document.getElementById('accentPhrases');
        container.innerHTML = '';

        // audio要素を設定(キャッシュ無効化)
        const audio = document.getElementById('accentAudio');
        audio.src = 'wav/' + this.currentEditingFile + '?' + Date.now();
        audio.load();

        // 元のテキストを表示
        document.getElementById('org_text').textContent = this.queryText;

        // 無音時間の入力フィールドに現在の値を設定
        const pauseInput = document.getElementById('pauseInput');
        const queryData = this.sentenceQueries.find(
            sq => sq.wavFile === this.currentEditingFile);
        if (pauseInput && queryData) {
            pauseInput.value = queryData.pause || 0;
        }

        this.audioQuery.accent_phrases.forEach((phrase, phraseIdx) => {
            const phraseDiv = document.createElement('div');
            phraseDiv.className = 'phrase';
            phraseDiv.style.marginBottom = '2px';

            phrase.moras.forEach((mora, moraIdx) => {
                // モーラ表示用のコンテナ
                const moraContainer = document.createElement('div');
                moraContainer.style.display = 'inline-block';
                moraContainer.style.marginRight = '2px';
                moraContainer.style.textAlign = 'center';

                // モーラのテキスト
                const moraSpan = document.createElement('div');
                moraSpan.className = 'mora';
                moraSpan.textContent = mora.text;
                moraSpan.style.fontSize = '16px';
                moraSpan.style.fontWeight = 'bold';
                moraSpan.style.marginBottom = '2px';

                // pitch入力フィールド
                const pitchInput = document.createElement('input');
                pitchInput.type = 'number';
                pitchInput.min = '0';
                pitchInput.max = '10';
                pitchInput.step = '0.05';
                pitchInput.value = mora.pitch.toFixed(2);
                pitchInput.style.width = '40px';
                pitchInput.style.fontSize = '12px';
                pitchInput.dataset.phraseIdx = phraseIdx;
                pitchInput.dataset.moraIdx = moraIdx;

                // pitch値が変更されたときの処理
                pitchInput.addEventListener('input', (e) => {
                    const newPitch = parseFloat(e.target.value);
                    if (!isNaN(newPitch)) {
                        this.audioQuery.accent_phrases[phraseIdx].moras[moraIdx].pitch = newPitch;
                        // sentenceQueriesも更新
                        const queryData = this.sentenceQueries.find(
                            sq => sq.wavFile === this.currentEditingFile);
                        if (queryData) {
                            queryData.query = this.audioQuery;
                        }
                    }
                });

                moraContainer.appendChild(moraSpan);
                moraContainer.appendChild(pitchInput);
                phraseDiv.appendChild(moraContainer);
            });

            container.appendChild(phraseDiv);
        });
        panel.style.display = 'block';
    }
}

// インスタンス作成とイベント設定
const manager = new VoicevoxWavManager('tbody', 'text', 'rowTemplate');
manager.init_window();

perl cgi

#!C:/Strawberry/perl/bin/perl
use strict;
use warnings;
use utf8;
use CGI;
use JSON qw(encode_json decode_json);
use LWP::UserAgent;
use URI::Escape qw(uri_escape_utf8);
use File::Spec;
use POSIX qw(strftime);
use Encode qw(decode);

binmode STDOUT, ':raw';

my $VOICEVOX = 'http://127.0.0.1:50021';
my $BASE_DIR = 'C:/inetpub/wwwroot/tts';
my $WAV_DIR  = "$BASE_DIR/wav";
my $SPEAKER  = 9; # 波音リツ

main();

sub main {
    mkdir $WAV_DIR unless -d $WAV_DIR;

    my $q = CGI->new;
    $q->charset('UTF-8');
    my $ua = LWP::UserAgent->new(timeout => 30);
    
    my $action = $q->param('action') || '';
    my $uid = $q->param('uid') || 'unknown_user';
    
    # step 0 全削除
    if ($action eq 'delete_all') {
        return delete_all($q, $uid);
    }
    # step 1 元textから 音声合成用のクエリ作成 ( アクセント取得 )
    if ($action eq 'audio_query') {
        return audio_query($q,$ua);
    }
    # step 2 クエリ付き合成
    if ($action eq 'synth_with_query') {
        return synth_with_query($q,$ua,$uid);
    }
    # step 3 そのユーザの複数のwavを結合
    if ($action eq 'merge') {
        return merge($q, $ua,$uid);
    }
    return json_out($q,{status=>'error',msg=>'unknown action'});
}

# 元textから 音声合成用クエリ作成 ( アクセント取得 )
sub audio_query {
    my ($q, $ua) = @_;

    my $text = decode('UTF-8', $q->param('text'));
    my $uid  = $q->param('uid') || 'unknown_user';

    my $enc = uri_escape_utf8($text);
    # refer to https://voicevox.github.io/voicevox_engine/api/
    my $url = "$VOICEVOX/audio_query?text=$enc&speaker=$SPEAKER";

    my $rq = $ua->post($url);
    error_out("audio_query failed") unless $rq->is_success;

    my $qjson = decode_json($rq->decoded_content);
    json_out($q,{status=>'ok',query=>$qjson,debug_text=>$text});
}

# 音声合成用クエリから音声合成 ( wav作成 )
sub synth_with_query {
    my ($q, $ua, $uid) = @_;

    my $query_str = $q->param('query') // '';
    return error_out('query empty') if ($query_str eq '');
    
    my $rs = $ua->post("$VOICEVOX/synthesis?speaker=$SPEAKER",
                       Content_Type => 'application/json',
                       Content => $query_str );
    return error_out("synthesis failed") unless $rs->is_success;

    my $filename = $q->param('filename') // '';
    # filenameパラメータがなければ連番で新規生成
    if ($filename eq '' or not $filename =~ /\.wav$/o ) {
        my $max_num = 0;
        opendir my $dh, $WAV_DIR or error_out("open dir fail");
        for my $f (readdir $dh) {
            if ( $f =~ /^${uid}_part(\d+)\.wav$/ ) {
                my $num = int($1);
                $max_num = $num if $num > $max_num;
            }
        }
        closedir $dh;
        $filename = sprintf("%s_part%02d.wav", $uid, $max_num + 1);
    }
    my $path = File::Spec->catfile($WAV_DIR,$filename);

    # wav fileとして保存
    open my $fh,'>:raw',$path or error_out("cannot write wav");
    print $fh $rs->content;
    close $fh;

    my @stat = stat($path);

    json_out($q,
             {status=>'ok',
              file=>$filename,
              size=>$stat[7],
              time=>strftime("%y-%m-%d %H:%M:%S",localtime($stat[9]))});
}

sub merge {
    my ($q, $ua, $uid) = @_;

    my $data = decode_json($q->param('json')||'{}');
    my @files=@{$data->{files}||[]};
    return error_out("no files") unless @files;

    my $out="${uid}_merge.wav";
    my $op=File::Spec->catfile($WAV_DIR,$out);

    my $header = '';
    my $total_data = '';
    my $sample_rate = 24000;  # VOICEVOX default
    my $channels = 1;         # モノラル
    my $bits_per_sample = 16; # 16bit

    for my $item (@files) {
        # 新形式: {file: "xxx.wav", pause: 1.5} または 旧形式: "xxx.wav"
        my $f = ref($item) eq 'HASH' ? $item->{file} : $item;
        my $pause_sec = ref($item) eq 'HASH' ? ($item->{pause} || 0) : 0;

        my $path = File::Spec->catfile($WAV_DIR,$f);
        open my $I,'<:raw',$path or next;

        my $h;
        read($I, $h, 44);

        # 最初のファイルのヘッダーを保存してサンプルレートを取得
        if ($header eq '') {
            $header = $h;
            # サンプルレート取得(byte 24-27)
            $sample_rate = unpack('V', substr($h, 24, 4));
            # チャンネル数取得(byte 22-23)
            $channels = unpack('v', substr($h, 22, 2));
            # ビット深度取得(byte 34-35)
            $bits_per_sample = unpack('v', substr($h, 34, 2));
        }

        # データ部分を読み込んで結合
        my $buf;
        while(read($I, $buf, 8192)) {
            $total_data .= $buf;
        }
        close $I;

        # pause_secが指定されている場合、無音データを追加
        if ($pause_sec > 0) {
            my $bytes_per_sample = $bits_per_sample / 8;
            my $silence_samples = int($sample_rate * $pause_sec);
            my $silence_bytes = $silence_samples * $channels * $bytes_per_sample;

            # 16bit符号付き整数の無音(値0)
            $total_data .= pack('v*', (0) x ($silence_samples * $channels));
        }
    }

    # ヘッダーのサイズ情報を更新
    my $data_size = length($total_data);
    substr($header, 4, 4) = pack('V', $data_size + 36);  # ファイルサイズ - 8
    substr($header, 40, 4) = pack('V', $data_size);      # データサイズ

    open my $O,'>:raw',$op or error_out("merge create fail");
    print $O $header;
    print $O $total_data;
    close $O;

    json_out($q,{status=>'ok',file=>$out});
}

sub delete_all {
    my ($q, $uid) = @_;

    opendir my $dh,$WAV_DIR or error_out("open dir fail");
    my $cnt=0;
    for my $f (readdir $dh) {
        next unless $f =~ /$uid\_.*\.wav$/;
        
        my $p = File::Spec->catfile($WAV_DIR,$f);
        if (-f $p) { unlink $p; $cnt++; }
    }
    closedir $dh;
    json_out($q,{status=>'ok',deleted=>$cnt});
}

sub json_out {
    my ($q,$d) = @_;
    print $q->header(-type=>'application/json',-charset=>'utf-8');
    print encode_json($d);
    exit;
}

sub error_out { json_out({status=>'error',msg=>shift}); }

u