revolution/voicevox-core
revolution/voicevox-core is a PHP package for integrating VOICEVOX Core text-to-speech into Laravel and other PHP applications. Generate Japanese speech audio from text, select available voices, and add offline TTS capabilities to your projects with a simple API.
revolution/voicevox-core is a thin PHP FFI binding over the VOICEVOX Core C API — a Japanese neural TTS engine. It ships no binaries and no models; it is the glue between your PHP process and a libcore shared library you supply.
Version alignment changed in 0.17.0 — read this before installing. The package jumped from 0.1.5 to 0.17.0 so that its version now tracks the upstream core it binds (0.17.x). That is a convenience, but it is also a Composer trap: ^0.1 resolves to >=0.1.0 <0.2.0, so an app constrained to ^0.1.5 will never see 0.17.0 via composer update. You must edit composer.json explicitly. The upside is that one number now describes the whole stack — package, libcore, .vvm models, and dictionary all come from VOICEVOX Core 0.17.
Prerequisites, in order of likelihood to bite you:
ext-ffi compiled/enabled (php -m | grep ffi).libcore.so, core.dll, or libcore.dylib) from the upstream VOICEVOX/voicevox_core releases. Earlier cores are not what this release binds against — see Gotchas.open_jtalk_dic_utf_8-1.11), also from the core release assets..vvm voice model file (e.g. Zundamon, Shikoku Metan) from the same 0.17 release.Install and lay out assets:
composer require revolution/voicevox-core:^0.17
storage/voicevox/
├── libcore.so # or core.dll / libcore.dylib
├── open_jtalk_dic_utf_8-1.11/ # directory, not a single file
└── models/
├── 0.vvm # 四国めたん
└── 3.vvm # ずんだもん
First use case — text to WAV in a tinker session:
use Revolution\VoicevoxCore\VoicevoxCore;
$core = new VoicevoxCore(
corePath: storage_path('voicevox/libcore.so'),
dictPath: storage_path('voicevox/open_jtalk_dic_utf_8-1.11'),
);
$wav = $core->tts('こんにちは、ずんだもんです。', speakerId: 3);
file_put_contents(storage_path('app/hello.wav'), $wav);
Start in php artisan tinker from the CLI, not through a browser request — CLI is where FFI is enabled by default, and where you can iterate on speaker IDs and paths without fighting FPM config. The signatures are thin wrappers over the C API; read src/ directly, because the README does not document every argument — and because after a jump like 0.1.5 → 0.17.0, the source diff is the changelog (the release notes only say "add support for VOICEVOX Core 0.17 and update related components").
First debugging step if it fails: the constructor throws when the library can't be dlopen'd. ldd storage/voicevox/libcore.so on Linux will tell you immediately whether onnxruntime is resolvable — this is the single most common first-run failure, and it has nothing to do with PHP. If instead you upgraded from ^0.1 and now get load or call failures, suspect a stale core binary before you suspect the package.
Treat core initialization as a process-lifetime singleton. Loading the library and a voice model costs hundreds of milliseconds and hundreds of MB of RAM. Never construct VoicevoxCore inside a controller action. Bind it once and reuse:
// app/Providers/VoicevoxServiceProvider.php
$this->app->singleton(VoicevoxCore::class, fn () => new VoicevoxCore(
corePath: config('voicevox.core'),
dictPath: config('voicevox.dict'),
));
$this->app->singleton(TtsService::class, fn ($app) => new TtsService(
$app->make(VoicevoxCore::class),
speakerIds: config('voicevox.speakers'),
));
Run synthesis in a queue worker, not in the web request. A queued job boots its own PHP process, keeps the model warm across jobs, and gives you FFI enabled via CLI. The controller dispatches a job; the job writes a WAV to disk or S3 and records the path.
final class SynthesizeSpeech implements ShouldQueue
{
public function __construct(
public string $text,
public int $speakerId,
public string $outputPath,
) {}
public function handle(VoicevoxCore $core): void
{
Storage::put($this->outputPath, $core->tts($this->text, $this->speakerId));
}
}
Keep the version triple in one place. With 0.17.0 the Composer constraint, the libcore release tag in your Dockerfile, and the .vvm/dictionary asset URLs are the same version string. Derive all three from a single build arg or .env value (VOICEVOX_VERSION=0.17.0) so an upgrade is one edit, not three that can drift apart.
Cache by content hash of every synthesis parameter. TTS is deterministic for a given (text, speaker, speed, pitch, intonation, volume) tuple, so cache keys are trivially correct:
$key = 'tts/'.hash('xxh128', serialize([$text, $speakerId, $speed, $pitchScale, $intonationScale]));
$url = Cache::rememberForever($key, fn () => $this->synthesizeAndStore(...));
Note that a core upgrade can change the audio for the same key — bump a namespace prefix (tts/v2/...) when you move to 0.17 or your users get old audio forever.
Use the two-stage pipeline when you need control. The one-shot tts() is fine for canned phrases, but any product surface needs the accent-phrase query split so users can fix mispronunciations of proper nouns, or so you can tweak speedScale/pitchScale per utterance without re-running text analysis. The pattern is: build query → inspect/modify accent phrases → synthesize. Cache the query object (it's JSON-serializable) alongside the audio. This is the part of the surface most likely to have moved in the 0.17 binding — re-read src/ for the exact argument list rather than trusting a snippet from an older project.
Preload speakers, don't lazy-load them per request. Most wrappers expose a speaker-load call and an isLoadedSpeaker-style check. Load your fixed cast at worker boot; a job that has to load a 150 MB model per message will fall over on queue throughput.
// AppServiceProvider::boot() or a queue-worker starting hook
foreach (config('voicevox.speakers') as $id) {
if (! $core->isLoadedSpeaker($id)) {
$core->loadModel(config("voicevox.models.{$id}"), $id);
}
}
Chunk long text at punctuation boundaries (。!?、newlines), synthesize chunks, and concatenate the PCM. VOICEVOX degrades badly in both latency and prosody on multi-paragraph input, and there is no internal streaming API at this layer.
Testing: define a TtsInterface, bind the real core in production and a fixture-returning fake in tests. Never let CI depend on the shared library being present:
protected function setUp(): void
{
if (! extension_loaded('ffi') || ! file_exists(config('voicevox.core'))) {
$this->markTestSkipped('VOICEVOX core not available.');
}
}
Assert on WAV header properties (RIFF, 24 kHz, mono, 16-bit) and duration bounds — never on raw bytes, which shift with core versions. That header assertion is worth running once by hand as your upgrade sanity check for 0.17.
CI/Docker: a php:8.3-cli image with ffi.enable=1, the core release unzipped to /opt/voicevox, and LD_LIBRARY_PATH=/opt/voicevox is a ~10-line Dockerfile. Bake it into a shared base image so your app container and workers match exactly — and bump that base image in the same PR that bumps the Composer constraint.
ffi.enable is the wall you will hit first. Under FPM the default is preload, which means FFI::cdef() at runtime throws FFI API is restricted. Options: set ffi.enable=1 (accepting the broader attack surface), or scope FFI to the CLI SAPI and do all synthesis in queue workers — the latter is the correct architecture anyway. If you must have synchronous web TTS, ffi.enable=preload plus an opcache.preload script that defines the bindings works but is fragile.
The 0.17.0 upgrade is a major-version upgrade wearing a minor-version costume. Semver would call 0.1.5 → 0.17.0 a minor bump under 0.x, but the binding was regenerated against a new C API. Treat it as breaking: read the diff from 0.1.5 to 0.17.0, re-verify every method you call (constructor arguments, tts() keyword arguments, the query/accent-phrase helpers), and run a real synthesis before merging. Do not pair the new PHP package with an old libcore — the failure mode is an opaque load error or a segfault, not a clean exception.
Version-lock the core binary against the .vvm models and the dictionary. Model files, dictionary, and core releases are coupled; a mismatch surfaces as an opaque load failure or a segfault rather than a clean exception. This is the highest-risk window during the 0.17 upgrade: it is very easy to bump Composer and the shared library while leaving open_jtalk_dic_utf_8-1.11 and your .vvm files from the previous release. Pull all assets from the 0.17 release in one go and pin the tag in your Dockerfile.
Segfaults are silent and kill the worker. Because this is raw FFI over C++, memory errors don't surface as PHP exceptions. Symptoms: queue worker exits with code 139, no stack trace. Mitigations: never share one VoicevoxCore instance across forked processes (a pcntl_forking worker pool will corrupt it), set --max-jobs low enough that a supervised restart is cheap, and wrap synthesis in a supervisor with restart policy. Suspect a bad text encoding or an out-of-range speaker/parameter first — and immediately after an upgrade, suspect a core/model mismatch.
Text must be valid UTF-8. Passing Latin-1 or mojibake from a database column produces either an empty accent-phrase list or a crash inside Open JTalk. Validate and normalize before it reaches the library.
The dictPath is a directory containing sys.dic, unk.dic, and friends — not sys.dic itself. Pointing it at the inner file is a common misconfiguration and fails at synthesis, not at construction.
Licensing is layered, and this is the part that surprises teams. The PHP package is MIT, VOICEVOX Core itself carries its own (LGPL-family) terms, and each voice model has separate character-specific terms of use — commercial-use permissions, required credit strings ("VOICEVOX:ずんだもん"), and redistribution rules all vary per character. Confirm the terms for every speaker ID you ship, and re-check when you move to a new core release, since model terms are revised over time. A TTS feature with an unattributed voice is a real compliance risk, not a footnote.
Output is 24 kHz mono 16-bit PCM WAV. If your pipeline (telephony, podcast, video mux) expects other rates, resample downstream — there is no resampling option in the core API. Re-verify this assumption after the 0.17 upgrade rather than trusting it from memory.
Ecosystem risk is still real, but slightly improved. 0.17.0 brings a second human contributor (@kawax) alongside the original maintainer, and Dependabot is now keeping the GitHub Actions current — both healthy signals, neither a guarantee. The package is still pre-1.0, has negligible download volume, and its version numbers now move with upstream rather than with its own API stability. Mitigation is unchanged and cheap: read src/ end to end (it's small), pin the exact minor version in composer.json ("revolution/voicevox-core": "0.17.*" if you want no surprises), and vendor or fork it if TTS becomes load-bearing. The 0.1.5 → 0.17.0 jump with no CHANGELOG entry is exactly the kind of thing that argues for pinning.
Extension points are limited by design. The wrapper exposes the C API's surface and little else. Anything you want — SSML-ish markup, phoneme overrides, streaming chunk assembly, voice-specific pronunciation dictionaries — belongs in a service class around the package, not in a patch to it. That separation is also what makes the eventual fork or reimplementation cheap.
Log every synthesis call with its parameters and wall-clock duration. Without that, a latency regression from "we added one more speaker to a warm worker" or "someone raised the queue's memory limit" is invisible until users complain that the bot got slow. Log the package version too — after an upgrade, that column is how you find out whether the new core actually made things faster or slower.
How can I help you explore Laravel packages today?