0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

【VOICEVOXでラップ】songノートからtalkモーラに秒数を反映させるツール!

Last updated at Posted at 2025-08-12

こんにちは!はちもりと申します。
VOICEVOXが好きなので記事を書きます。

みなさん、VOICEVOXでラップを作りたいですよね!
その夢を叶えるツールを作りました!!!!!
ずばり、 「songノートからtalkモーラに秒数を反映させるツール」 です。

ラップの作り方を考える

VOICEVOXには トーク(テキスト読み上げ)ソング(歌声合成) の2つがあります。

image.png

合成音声でのラップ制作は「リズムの厳密さ」と「喋りとしての自然さ」の両立が鍵ですが、この2つを単独で満たすのは意外と難しいです。

アプローチA:トークだけで攻める

  • 良い点:日本語のアクセント/イントネーションが自然。調声の“喋りらしさ”が出しやすい。
  • 難点:各モーラの 長さパラメータ を手で詰める必要があり、
    • BPMや譜割(四分/八分/三連/スウィング…)を多数のモーラに正確に割り当てるのが大変
    • 「このモーラはBPM120の四分(=500ms)で、この次は八分(=250ms)…」といった音楽的指定を大量に行うコストが高い。

アプローチB:ソングだけで攻める

  • 良い点:ピアノロールはテンポ/譜割の表現が得意。ノートの長さ・配置でリズムを厳密に作れる。
  • 難点:喋りの抑揚(子音/母音の配分・アクセント)を音符やピッチ線で再現するのは手間。
    • いわゆるトークロイド的な手法になり、キャラごとに調声が必要になりがち。

じゃあ、役割分担しよう!

  • トークは「喋りの自然さ(アクセント/イントネーション)を出す装置
  • ソングは「リズム(譜割)を記述する装置
    に割り切って、両者の良いとこ取りをします。

image.png

例:BPM120で「こんにちは」をラップ

  • BPM120なら四分音符=500ms、八分音符=250ms。
  • 5モーラ(コ/ン/ニ/チ/ワ)に、四分, 八分, 八分, 八分, 四分 みたいにノートで譜割を打つ
  • そのノート実時間(秒)を、トーク側の各モーラ長へそのまま配る。
    BPMにピタッと合う喋りになり、かつイントネーションはトークの強みを活かせます。

※モーラ:日本語の最小拍で、多くは「1文字=1モーラ」。

ツールの使い方

VOICEVOXの ソング で打ち込んだノートのリズム(テンポ/休符を含む実時間)を、トーク の各モーラ(拍)へ自動で割り当てる ブラウザツール です。
リズムはソングで厳密に、イントネーションはトークに任せる を実現します。

  • 外部送信なし・ブラウザ内で完結(単一HTML)
  • .vvproj を読み込み、同期済み .vvproj をダウンロード

手順

1. VOICEVOXエディタで新規プロジェクトを作成

image.png

2. トークにセリフを並べる

image.png

トーク画面ではこの時点で、使用したいキャラクターに変更する。

3. ソングにラップしたいリズムを ノートとして打ち込む(音高は何でもOK)

image.png

モーラ数=ノート数 にする。トーク画面とソング画面をよく見比べよう。

4. .vvproj を保存 → ツールに通して再保存 → エディタで読み直し

image.png

5. トークを再生成して再生。指定した通りのリズムで喋る はずです

image.png

イントネーションはトーク側で調整する。(アクセント位置、抑揚など)

任意のBPM/譜割で“しゃべるラップ” を素早く作れます!

実装

具体的な実装

仕組み(やっていることの流れ)

  1. 必要な情報を読み込む

    • ソング:テンポ一覧(tempos)、四分音符あたりのtick数(tpqn)、ノートの開始位置と長さ(position / duration
    • トーク:各モーラの長さ(consonantLength / vowelLength
  2. ノートの長さを秒に換算

    • 計算式:ticks / tpqn * (60 / bpm)
    • 可変テンポにも対応し、テンポが変わる区間ごとに計算して合計する
  3. 休符(無音部分)の処理

    • 最初にある休符は無視(しゃべり出しを遅らせないため)
    • 途中の休符は「直前のアクセントフレーズ」のポーズ時間(pauseMora.vowelLength)に加算
  4. モーラ内での時間配分

    • 元の子音:母音の比率をそのまま保ちつつ、合計時間を目標の秒数に合わせてスケーリング
    • 子音・母音のどちらも未設定の場合は、すべて母音に割り当てる
  5. 結果を書き出す

    • 秒数は小数第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に忠実で、喋りは自然――実用的な“しゃべるラップ”が作れます!
0
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?