こんにちは!はちもりと申します。
VOICEVOXが好きなので記事を書きます。
みなさん、VOICEVOXでラップを作りたいですよね!
その夢を叶えるツールを作りました!!!!!
ずばり、 「songノートからtalkモーラに秒数を反映させるツール」 です。
ラップの作り方を考える
VOICEVOXには トーク(テキスト読み上げ) と ソング(歌声合成) の2つがあります。
合成音声でのラップ制作は「リズムの厳密さ」と「喋りとしての自然さ」の両立が鍵ですが、この2つを単独で満たすのは意外と難しいです。
アプローチA:トークだけで攻める
- 良い点:日本語のアクセント/イントネーションが自然。調声の“喋りらしさ”が出しやすい。
- 難点:各モーラの 長さパラメータ を手で詰める必要があり、
- BPMや譜割(四分/八分/三連/スウィング…)を多数のモーラに正確に割り当てるのが大変。
- 「このモーラはBPM120の四分(=500ms)で、この次は八分(=250ms)…」といった音楽的指定を大量に行うコストが高い。
アプローチB:ソングだけで攻める
- 良い点:ピアノロールはテンポ/譜割の表現が得意。ノートの長さ・配置でリズムを厳密に作れる。
- 難点:喋りの抑揚(子音/母音の配分・アクセント)を音符やピッチ線で再現するのは手間。
- いわゆるトークロイド的な手法になり、キャラごとに調声が必要になりがち。
じゃあ、役割分担しよう!
- トークは「喋りの自然さ(アクセント/イントネーション)を出す装置」
-
ソングは「リズム(譜割)を記述する装置」
に割り切って、両者の良いとこ取りをします。
例:BPM120で「こんにちは」をラップ
- BPM120なら四分音符=500ms、八分音符=250ms。
- 5モーラ(コ/ン/ニ/チ/ワ)に、
四分, 八分, 八分, 八分, 四分
みたいにノートで譜割を打つ。 - そのノート実時間(秒)を、トーク側の各モーラ長へそのまま配る。
→ BPMにピタッと合う喋りになり、かつイントネーションはトークの強みを活かせます。
※モーラ:日本語の最小拍で、多くは「1文字=1モーラ」。
ツールの使い方
VOICEVOXの ソング で打ち込んだノートのリズム(テンポ/休符を含む実時間)を、トーク の各モーラ(拍)へ自動で割り当てる ブラウザツール です。
リズムはソングで厳密に、イントネーションはトークに任せる を実現します。
- 外部送信なし・ブラウザ内で完結(単一HTML)
-
.vvproj
を読み込み、同期済み.vvproj
をダウンロード
手順
1. VOICEVOXエディタで新規プロジェクトを作成
2. トークにセリフを並べる
トーク画面ではこの時点で、使用したいキャラクターに変更する。
3. ソングにラップしたいリズムを ノートとして打ち込む(音高は何でもOK)
モーラ数=ノート数 にする。トーク画面とソング画面をよく見比べよう。
4. .vvproj
を保存 → ツールに通して再保存 → エディタで読み直し
5. トークを再生成して再生。指定した通りのリズムで喋る はずです
イントネーションはトーク側で調整する。(アクセント位置、抑揚など)
→ 任意のBPM/譜割で“しゃべるラップ” を素早く作れます!
実装
具体的な実装
仕組み(やっていることの流れ)
-
必要な情報を読み込む
-
ソング:テンポ一覧(
tempos
)、四分音符あたりのtick数(tpqn
)、ノートの開始位置と長さ(position
/duration
) -
トーク:各モーラの長さ(
consonantLength
/vowelLength
)
-
ソング:テンポ一覧(
-
ノートの長さを秒に換算
- 計算式:
ticks / tpqn * (60 / bpm)
- 可変テンポにも対応し、テンポが変わる区間ごとに計算して合計する
- 計算式:
-
休符(無音部分)の処理
- 最初にある休符は無視(しゃべり出しを遅らせないため)
- 途中の休符は「直前のアクセントフレーズ」のポーズ時間(
pauseMora.vowelLength
)に加算
-
モーラ内での時間配分
- 元の子音:母音の比率をそのまま保ちつつ、合計時間を目標の秒数に合わせてスケーリング
- 子音・母音のどちらも未設定の場合は、すべて母音に割り当てる
-
結果を書き出す
- 秒数は小数第6位まで丸め
- 更新済みのデータを新しい
.vvproj
として保存
ポイント:ソングのノート時間とトークのモーラ長がぴったり一致し、休符は自然な“間”として反映されます。
※tpqn = 四分音符あたりのtick数
プログラム
このプログラムは、一部AIの支援を受けて作成しています。
<!doctype html>
<html lang="ja">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Convert-vvproj-song-to-talk-length</title>
<style>
:root {
--bg: #f8f9fa;
--card: #ffffff;
--muted: #6c757d;
--accent: #007bff;
--text-color: #212529;
--border-color: #dee2e6;
--input-bg: #ffffff;
}
body {
margin: 0;
font: 14px/1.6 system-ui, -apple-system, "Segoe UI", Roboto, "Hiragino Sans", "Noto Sans JP", sans-serif;
color: var(--text-color);
background: linear-gradient(180deg, var(--bg), #f1f3f5 60%, var(--bg));
min-height: 100vh;
}
header {
padding: 20px;
text-align: center;
}
h1 {
font-size: 20px;
margin: 0 0 6px;
color: #343a40;
}
main {
max-width: 960px;
margin: 0 auto;
padding: 16px;
}
.card {
background: var(--card);
border: 1px solid var(--border-color);
border-radius: 14px;
padding: 24px;
box-shadow: 0 6px 24px rgba(0,0,0,.08);
}
.grid {
display: grid;
gap: 16px;
grid-template-columns: 1fr;
}
.row {
display: flex;
gap: 12px;
align-items: center;
flex-wrap: wrap;
}
label {
color: var(--muted);
font-weight: 500;
}
input[type="file"] {
display: block;
}
select, button {
background: var(--input-bg);
color: var(--text-color);
border: 1px solid var(--border-color);
border-radius: 10px;
padding: 8px 12px;
font: inherit;
box-shadow: 0 1px 2px rgba(0,0,0,.05);
}
select:focus, button:focus {
outline: none;
border-color: var(--accent);
box-shadow: 0 0 0 3px rgba(0, 123, 255, .25);
}
button {
cursor: pointer;
}
button:disabled {
cursor: not-allowed;
opacity: 0.7;
}
button.primary {
background: var(--accent);
color: #ffffff;
border-color: var(--accent);
font-weight: 700;
}
.muted {
color: var(--muted);
}
pre {
white-space: pre-wrap;
word-break: break-word;
background: #f1f3f5;
border: 1px solid var(--border-color);
border-radius: 12px;
padding: 16px;
max-height: 280px;
overflow: auto;
color: #495057;
}
</style>
</head>
<body>
<header>
<h1>VOICEVOXでsongノートからtalkモーラに秒数を反映させるツール</h1>
<div class="muted">ファイルは外部に送信されず、すべてブラウザ内で処理されます。</div>
</header>
<main class="grid">
<section class="card grid">
<div class="row">
<label>1. .vvproj ファイルを選択</label>
<input id="file" type="file" accept=".vvproj,application/json" />
</div>
<div class="row">
<label>2. 歌声トラックを選択</label>
<select id="track"></select>
</div>
<div class="row">
<button class="primary" id="run" disabled>✅ 3. 反映してダウンロード</button>
<span id="meta" class="muted"></span>
</div>
<div>
<label>ログ</label>
<pre id="log">ファイルを選択すると詳細が表示されます。</pre>
</div>
</section>
</main>
<script>
(() => {
'use strict';
/*** 小ユーティリティ ***/
const el = (id) => document.getElementById(id);
const logEl = el('log');
const trackSel = el('track');
const metaEl = el('meta');
const runBtn = el('run');
let vvproj = null;
let currentPath = null;
let trackList = [];
let chosenTrack = null;
const setLog = (s) => logEl.textContent = s;
const pretty = (obj) => JSON.stringify(obj, null, 2);
const roundTo = (x, d) => {
if (!Number.isFinite(x)) return 0;
if (d < 0) return x;
const k = 10 ** d;
return Math.round(x * k) / k;
};
/*** VVPROJ 解析 ***/
function collectTracksInfo(song) {
const tracks = [];
const tracksObj = song?.tracks;
if (!tracksObj) return [];
const trackOrder = Array.isArray(song.trackOrder) ? song.trackOrder : Object.keys(tracksObj);
if (!Array.isArray(tracksObj)) {
for (const id of trackOrder) {
const trackData = tracksObj[id];
if (trackData) {
tracks.push({ id: String(id), name: trackData.name || String(id) });
}
}
} else {
tracksObj.forEach((tr, i) => {
const id = tr?.id || `#${i}`;
const name = tr?.name || id;
tracks.push({ id, name });
});
}
return tracks;
}
function loadNotesFromTrack(song, trackId) {
const notes = [];
const loadFromArray = (arr) => {
if (!Array.isArray(arr)) return;
for (const n of arr) {
if (!n || typeof n !== 'object') continue;
const position = Number(n.position);
const duration = Number(n.duration);
if (Number.isFinite(position) && Number.isFinite(duration)) {
notes.push({ position, duration });
}
}
};
if (song?.tracks && !Array.isArray(song.tracks)) {
const tr = song.tracks[trackId];
if (tr) {
if (tr.notes) loadFromArray(tr.notes);
if (!notes.length && Array.isArray(tr.clips)) {
for (const c of tr.clips) if (c?.notes) loadFromArray(c.notes);
}
}
}
if (!notes.length && Array.isArray(song?.tracks)) {
let found = null;
for (let i = 0; i < song.tracks.length; i++) {
const tr = song.tracks[i];
if (tr?.id === trackId) { found = tr; break; }
}
if (!found && typeof trackId === 'string' && trackId.startsWith('#')) {
const idx = Number(trackId.slice(1));
if (Number.isInteger(idx) && idx >= 0 && idx < song.tracks.length) found = song.tracks[idx];
}
if (found) {
if (found.notes) loadFromArray(found.notes);
if (!notes.length && Array.isArray(found.clips)) {
for (const c of found.clips) if (c?.notes) loadFromArray(c.notes);
}
}
}
notes.sort((a, b) => a.position - b.position || a.duration - b.duration);
return notes;
}
function buildTempoMap(song) {
const tempos = [];
if (Array.isArray(song?.tempos)) {
for (const t of song.tempos) {
const positionTick = Number(t?.position) || 0;
const bpm = Number(t?.bpm) || 120;
tempos.push({ positionTick, bpm });
}
}
if (!tempos.length) tempos.push({ positionTick: 0, bpm: 120 });
tempos.sort((a, b) => a.positionTick - b.positionTick || a.bpm - b.bpm);
if (tempos[0].positionTick !== 0) tempos.unshift({ positionTick: 0, bpm: tempos[0].bpm });
return tempos;
}
function secondsFromTickSpan(pos, dur, tpqn, tempos) {
if (dur <= 0) return 0;
const end = pos + dur;
let cur = pos, sec = 0;
const nextTempoIndex = (tick) => {
let i = 0; for (; i + 1 < tempos.length; i++) if (tick < tempos[i + 1].positionTick) break; return i;
};
while (cur < end) {
const idx = nextTempoIndex(cur);
const bpm = tempos[idx].bpm;
const segEnd = (idx + 1 < tempos.length) ? Math.min(end, tempos[idx + 1].positionTick) : end;
const segTicks = Math.max(0, segEnd - cur);
if (segTicks > 0) {
const beats = segTicks / tpqn;
sec += beats * (60 / bpm);
}
if (segEnd <= cur) break;
cur = segEnd;
}
return sec;
}
function flattenMorasWithMap(root) {
const moras = [];
const apIndexOfMora = [];
const talk = root?.talk;
const apObjs = [];
if (!talk) return { moras, apIndexOfMora, apObjs };
if (Array.isArray(talk.audioKeys) && talk.audioItems && typeof talk.audioItems === 'object') {
for (const k of talk.audioKeys) {
const item = talk.audioItems[k];
if (!item?.query || !Array.isArray(item.query.accentPhrases)) continue;
for (let ai = 0; ai < item.query.accentPhrases.length; ai++) {
const ap = item.query.accentPhrases[ai];
apObjs.push(ap);
if (!Array.isArray(ap?.moras)) continue;
for (let mi = 0; mi < ap.moras.length; mi++) {
moras.push(ap.moras[mi]);
apIndexOfMora.push(apObjs.length - 1);
}
}
}
}
return { moras, apIndexOfMora, apObjs };
}
function ensurePauseMora(ap) {
if (!ap.pauseMora || typeof ap.pauseMora !== 'object') {
ap.pauseMora = { text: '、', vowel: 'pau', vowelLength: 0, pitch: 0 };
} else {
if (!('text' in ap.pauseMora)) ap.pauseMora.text = '、';
if (!('vowel' in ap.pauseMora)) ap.pauseMora.vowel = 'pau';
if (!('vowelLength' in ap.pauseMora)) ap.pauseMora.vowelLength = 0;
if (!('pitch' in ap.pauseMora)) ap.pauseMora.pitch = 0;
}
}
function buildEventsFromNotes(notes) {
const evs = [];
if (!notes.length) return evs;
if (notes[0].position > 0) {
evs.push({ position: 0, duration: notes[0].position, isRest: true });
}
for (let i = 0; i < notes.length; i++) {
const cur = notes[i];
const curEnd = cur.position + cur.duration;
evs.push({ position: cur.position, duration: cur.duration, isRest: false });
if (i + 1 < notes.length) {
const nxt = notes[i + 1];
if (nxt.position > curEnd) {
evs.push({ position: curEnd, duration: nxt.position - curEnd, isRest: true });
}
}
}
return evs;
}
/*** 実処理 ***/
function reflectSongToTalk(vv, selectedTrackId) {
const roundDigits = 6;
const strict = true;
const song = vv?.song;
if (!song) throw new Error('vvproj に song セクションがありません');
const tpqn = Number(song.tpqn) || 480;
const tempos = buildTempoMap(song);
if (!trackList.length) throw new Error('song.tracks が空です');
let trackId = selectedTrackId || '';
if (!trackId) {
for (const track of trackList) {
const notes = loadNotesFromTrack(song, track.id);
if (notes.length) {
trackId = track.id;
break;
}
}
if (!trackId) trackId = trackList[0].id;
}
const notes = loadNotesFromTrack(song, trackId);
if (!notes.length) throw new Error(`トラック(${trackId})にノートがありません`);
const { moras, apIndexOfMora, apObjs } = flattenMorasWithMap(vv);
if (!moras.length) throw new Error('talk 側の moras が見つかりません');
const nNotes = notes.length, nMoras = moras.length;
if (strict && nNotes !== nMoras) throw new Error(`件数不一致: notes=${nNotes} moras=${nMoras}`);
for (const ap of apObjs) {
if (Array.isArray(ap.moras)) {
for (const m of ap.moras) {
if (m?.text === '、') m.text = '';
}
}
if (ap.pauseMora) {
ap.pauseMora.vowelLength = 0;
}
}
const events = buildEventsFromNotes(notes);
const N = Math.min(nNotes, nMoras);
const secForMora = new Array(moras.length).fill(0);
const restSecForAp = new Array(apObjs.length).fill(0);
let moraIdx = 0, usedRests = 0, skippedNotes = 0, skippedRests = 0, skippedLeadingRests = 0;
for (const ev of events) {
if (!ev.isRest) {
if (moraIdx >= nMoras) { skippedNotes++; continue; }
const s = secondsFromTickSpan(ev.position, ev.duration, tpqn, tempos);
secForMora[moraIdx++] += s;
} else {
const s = secondsFromTickSpan(ev.position, ev.duration, tpqn, tempos);
if (moraIdx === 0) { skippedLeadingRests++; continue; }
if (moraIdx >= nMoras) { skippedRests++; continue; }
const apIndex = apIndexOfMora[moraIdx - 1];
restSecForAp[apIndex] += s;
usedRests++;
}
}
for (let ai = 0; ai < apObjs.length; ai++) {
const s = restSecForAp[ai];
if (s <= 0) continue;
const ap = apObjs[ai];
ensurePauseMora(ap);
ap.pauseMora.text = '、';
ap.pauseMora.vowel = 'pau';
ap.pauseMora.pitch = 0;
ap.pauseMora.vowelLength = roundTo(s, roundDigits);
}
let zeroSumCount = 0, maxChange = 0;
for (let i = 0; i < N; i++) {
const s = secForMora[i];
if (s <= 0) continue;
const m = moras[i];
const c0 = Number(m.consonantLength) || 0;
const v0 = Number(m.vowelLength) || 0;
const sum0 = c0 + v0;
let nc = 0, nv = 0;
if (sum0 > 0) { nc = s * (c0 / sum0); nv = s - nc; }
else { nv = s; zeroSumCount++; }
nc = roundTo(nc, roundDigits);
nv = roundTo(nv, roundDigits);
const diff = s - (nc + nv);
nv = roundTo(nv + diff, roundDigits);
maxChange = Math.max(maxChange, Math.abs(nc - c0), Math.abs(nv - v0));
m.consonantLength = nc; m.vowelLength = nv;
}
return {
trackId, nNotes, nMoras, processedMoras: N,
tpqn, temposCount: tempos.length,
usedRests, skippedNotes, skippedRests, skippedLeadingRests,
zeroSumCount, maxChange: Number(maxChange.toFixed(6))
};
}
/*** UI ***/
function populateTracks(song) {
trackList = collectTracksInfo(song);
trackSel.innerHTML = '';
for (const track of trackList) {
const opt = document.createElement('option');
opt.value = track.id;
opt.textContent = track.name;
trackSel.appendChild(opt);
}
chosenTrack = '';
}
async function handleFile(file) {
setLog('読み込み中…');
try {
currentPath = file.name;
const text = await file.text();
vvproj = JSON.parse(text);
if (!vvproj || typeof vvproj !== 'object') throw new Error('JSON ではありません');
if (!vvproj.song) throw new Error('vvproj に song セクションがありません');
populateTracks(vvproj.song);
runBtn.disabled = false;
metaEl.textContent = `${file.name} を読み込みました`;
setLog(pretty({
song_tpqn: Number(vvproj.song.tpqn) || 480,
tempos: vvproj.song.tempos?.length ?? 0,
trackCandidates: trackList.length,
talk_audioKeys: vvproj.talk?.audioKeys?.length ?? 0
}));
} catch (e) {
vvproj = null; runBtn.disabled = true; metaEl.textContent = '';
setLog('[ERROR] ' + (e?.message || e));
}
}
function downloadReflected() {
if (!vvproj) return;
const base = (currentPath || 'output').replace(/\.vvproj$/i, '');
const outName = `${base}_reflected.vvproj`;
const blob = new Blob([JSON.stringify(vvproj, null, 2)], { type: 'application/json' });
const a = document.createElement('a');
a.href = URL.createObjectURL(blob);
a.download = outName;
document.body.appendChild(a);
a.click();
a.remove();
URL.revokeObjectURL(a.href);
}
/*** イベント ***/
el('file').addEventListener('change', (ev) => {
const f = ev.target.files?.[0]; if (f) handleFile(f);
});
trackSel.addEventListener('change', () => {
chosenTrack = trackSel.value || '';
});
runBtn.addEventListener('click', () => {
if (!vvproj) return;
try {
const stats = reflectSongToTalk(vvproj, chosenTrack);
setLog('[OK] 反映完了\n' + pretty(stats));
downloadReflected();
} catch (e) {
setLog('[ERROR] ' + (e?.message || e));
}
});
})();
</script>
</body>
</html>
まとめ
- 譜割はソング、喋りはトーク の分業で、ラップ制作の手間を大幅削減
- ツールで 音符→秒 を変換し、各モーラへ反映
- BPMに忠実で、喋りは自然――実用的な“しゃべるラップ”が作れます!