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