Saltar al contenido principal

Objetivo

Aprovechar tal cual el .vvproj del editor VOICEVOX para poder regenerar talk y canciones desde Laravel.

Estructura del nivel superior

.vvproj es JSON en UTF-8. Talk y song se guardan en el mismo archivo.
{
  "appVersion": "0.25.2",
  "talk": {
    "audioKeys": [],
    "audioItems": {}
  },
  "song": {
    "tpqn": 480,
    "tempos": [],
    "timeSignatures": [],
    "tracks": {},
    "trackOrder": []
  }
}
ClaveContenido
appVersionVersión del editor al guardar
talkDatos de talk (audioKeys + audioItems)
songDatos de canción (tempo, compás, pistas)

Sección talk

talk.audioKeys es un array ordenado y talk.audioItems es un Record indexado por ID.
{
  "audioKeys": ["audio-item-uuid"],
  "audioItems": {
    "audio-item-uuid": {
      "text": "ずんだもんなのだ",
      "voice": {
        "engineId": "engine-uuid",
        "speakerId": "speaker-uuid",
        "styleId": 3
      },
      "query": {
        "accentPhrases": [],
        "speedScale": 1,
        "pitchScale": 0
      },
      "presetKey": "preset-uuid"
    }
  }
}

TalkAudioItem

ClaveContenido
textTexto de entrada
voice.engineIdID del motor (corresponde a /engine_manifest)
voice.speakerIdUUID del speaker
voice.styleIdID de estilo. Se pasa tal cual a /synthesis?speaker={styleId}
queryEquivalente a AudioQuery
presetKeyID de preset del editor. Para los presets del lado Laravel, consulta Presets
Si el .vvproj ya tiene query guardada, puedes saltarte /audio_query y sintetizar directamente.

accentPhrases

ClaveContenido
morasArray de moras (a veces se omiten los relativos a consonant)
accentPosición del acento (empieza en 1)
pauseMoraPuede aparecer para introducir silencios en puntuación, etc.
isInterrogativeMarca de frase interrogativa

Sección song

{
  "tpqn": 480,
  "tempos": [{ "position": 0, "bpm": 120 }],
  "timeSignatures": [{ "measureNumber": 1, "beats": 4, "beatType": 4 }],
  "tracks": {
    "track-uuid": {
      "name": "無名トラック",
      "singer": { "engineId": "engine-uuid", "styleId": 3003 },
      "notes": []
    }
  },
  "trackOrder": ["track-uuid"]
}
ClaveContenido
tpqnTicks Per Quarter Note. El estándar es 480
temposMapa de tempo (position en ticks)
timeSignaturesMapa de compás (measureNumber empieza en 1)
tracksRecord indexado por Track ID
trackOrderOrden de visualización y reproducción. Debe coincidir con el conjunto de claves de tracks

Track

ClaveContenido
singer.styleIdID final que se pasa a /frame_synthesis?speaker={styleId}
notesArray de notas
keyRangeAdjustmentAjuste de tono por semitonos
volumeRangeAdjustmentAjuste de volumen
pitchEditData / volumeEditDataDatos de edición por frame
phonemeTimingEditDataEdición de timing de fonemas por Note ID
solo / muteSe usan para decidir qué pistas se reproducen

Note

ClaveContenido
idID de nota (único)
positionTick de inicio de la nota
durationLongitud de la nota en ticks
noteNumberNúmero de nota MIDI
lyricLetra

Conversión tick / segundo / frame

Con un tempo único, se convierte así:
seconds = ticks / tpqn * 60 / bpm
frames = round(seconds * frameRate)
Con cambios de tempo, se integra por tramos usando tempos en orden ascendente de position.
function ticksToSeconds(int $targetTick, int $tpqn, array $tempos): float
{
    $seconds = 0.0;
    $currentTick = 0;

    foreach ($tempos as $index => $tempo) {
        $nextTick = $tempos[$index + 1]['position'] ?? $targetTick;
        $segmentEnd = min($targetTick, $nextTick);

        if ($segmentEnd <= $currentTick) {
            break;
        }

        $bpm = $tempo['bpm'];
        $seconds += (($segmentEnd - $currentTick) / $tpqn) * (60 / $bpm);
        $currentTick = $segmentEnd;
    }

    return $seconds;
}
Para la implementación que convierte de ticks a longitud de frame con el helper Note::len(), consulta Detalle de Score y Note.

Flujo de generación de audio de canción

Precauciones al editar directamente

  • El conjunto de claves de tracks y trackOrder deben coincidir siempre
  • Sincroniza también talk.audioKeys con talk.audioItems
  • Mantén position >= 0, duration >= 1 y noteNumber en 0..127
  • tempos[0].position suele ser 0 y timeSignatures[0].measureNumber suele ser 1
  • Si es posible, conserva las claves desconocidas al volver a guardar, para no romper la compatibilidad con versiones futuras

Ejemplo de código Laravel

Ejemplo mínimo que lee un .vvproj y genera talk y canción.
use Illuminate\Support\Facades\Storage;
use Revolution\Voicevox\Client\TalkAudioQuery;
use Revolution\Voicevox\Song\Note;
use Revolution\Voicevox\Song\Score;
use Revolution\Voicevox\Voicevox;

$project = json_decode(
    Storage::disk('local')->get('voicevox/sample.vvproj'),
    true,
    flags: JSON_THROW_ON_ERROR,
);

// Talk: sintetiza directamente con la query ya guardada
foreach ($project['talk']['audioKeys'] as $audioKey) {
    $item = $project['talk']['audioItems'][$audioKey];

    Voicevox::talk($item['text'], id: $item['voice']['styleId'])
        ->tap(fn (TalkAudioQuery $talk) => $talk->audioQuery = array_replace($talk->audioQuery, $item['query']))
        ->generate(id: $item['voice']['styleId'])
        ->storeAs('vvproj/talk', "{$audioKey}.wav");
}

// Song: aquí se convierte el duration de la primera pista en frame_length y se genera
$trackId = $project['song']['trackOrder'][0];
$track = $project['song']['tracks'][$trackId];
$bpm = $project['song']['tempos'][0]['bpm'] ?? 120;

$score = Score::make([
    Note::make(length: 15, lyric: '', key: null),
    ...collect($track['notes'])->map(
        fn (array $note) => Note::make(
            length: Note::len(ticks: $note['duration'], bpm: $bpm),
            lyric: $note['lyric'] ?? 'ら',
            key: $note['noteNumber'],
            id: $note['id'] ?? null,
        ),
    )->all(),
    Note::make(length: 2, lyric: '', key: null),
]);

Voicevox::song($score, teacher: 6000)
    ->generate(id: $track['singer']['styleId'])
    ->storeAs('vvproj/song', "{$trackId}.wav");
Última modificación el 13 de julio de 2026