voicevox for win は、無料で利用できるにも関わらず、
高品質な音声合成アプリです。
しかも web api経由で利用できますので、
javascript + perl から voicevox web api を 用い tts音声合成してみました。
全体構成
┌────┐ ┌────┐ ┌────┐ ┌──────┐
│html+js ├→│ iis ├→│perl cgi├→│voicevox api│
└────┘ └────┘ └────┘ └──────┘
画面構成

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>
'use strict';
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();
});
document.querySelector("#upw").addEventListener('keypress',(e)=>{
if (e.keyCode == 13) this.login();
});
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;
}
await this.delUsersList();
this.tbody.innerHTML = '';
try {
this.startSynthesis(uid, text);
} catch (error) {
alert(error.message);
}
}
startSynthesis=async(uid, text)=>{
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';
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++;
}
await this.mergeAllWavFiles(uid);
} finally {
if (loadingGif) loadingGif.style.display = 'none';
}
}
mergeAllWavFiles=async(uid)=>{
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) {
const cleanedText = this.cleanText(text);
if (cleanedText.length > 500) {
throw new Error('テキストが長すぎます');
}
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 = [];
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 ? '、' : '');
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;
}
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;
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);
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;
}
}
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;
}
});
const audio = document.getElementById('accentAudio');
if (audio) {
audio.src = 'wav/' + originalFilename + '?' + Date.now();
audio.load();
}
await this.mergeAllWavFiles(uid);
}
} finally {
if (loadingGif) loadingGif.style.display = 'none';
}
}
resetAccent=async()=>{
if (!this.queryText || !this.currentEditingFile) {
alert('初期化する対象が見つかりません');
return;
}
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);
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);
const queryData = this.sentenceQueries.find(
sq => sq.wavFile === originalFilename);
if (queryData) {
queryData.query = newAccent;
queryData.size = wav_json.size;
queryData.time = wav_json.time;
}
this.audioQuery = newAccent;
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 {
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 = '';
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';
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;
pitchInput.addEventListener('input', (e) => {
const newPitch = parseFloat(e.target.value);
if (!isNaN(newPitch)) {
this.audioQuery.accent_phrases[phraseIdx].moras[moraIdx].pitch = newPitch;
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();
#!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';
if ($action eq 'delete_all') {
return delete_all($q, $uid);
}
if ($action eq 'audio_query') {
return audio_query($q,$ua);
}
if ($action eq 'synth_with_query') {
return synth_with_query($q,$ua,$uid);
}
if ($action eq 'merge') {
return merge($q, $ua,$uid);
}
return json_out($q,{status=>'error',msg=>'unknown action'});
}
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);
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});
}
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') // '';
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);
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;
my $channels = 1;
my $bits_per_sample = 16;
for my $item (@files) {
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;
$sample_rate = unpack('V', substr($h, 24, 4));
$channels = unpack('v', substr($h, 22, 2));
$bits_per_sample = unpack('v', substr($h, 34, 2));
}
my $buf;
while(read($I, $buf, 8192)) {
$total_data .= $buf;
}
close $I;
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;
$total_data .= pack('v*', (0) x ($silence_samples * $channels));
}
}
my $data_size = length($total_data);
substr($header, 4, 4) = pack('V', $data_size + 36);
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