<?xml version="1.0" encoding="UTF-8"?>
<rss xmlns:content="http://purl.org/rss/1.0/modules/content/" xmlns:wfw="http://wellformedweb.org/CommentAPI/" xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:sy="http://purl.org/rss/1.0/modules/syndication/" xmlns:slash="http://purl.org/rss/1.0/modules/slash/" version="2.0">
  <channel>
    <title>逆瀬川ちゃんのほーむぺーじ</title>
    <link>https://nyosegawa.com/</link>
    <atom:link href="https://nyosegawa.com/feed.xml" rel="self" type="application/rss+xml"/>
    <description>逆瀬川のポートフォリオ・ブログ</description>
    <lastBuildDate>Mon, 12 Oct 2026 09:53:50 GMT</lastBuildDate>
    <language>en</language>
    <generator>Lume 0deb7b0594d6be12fca9bc001beaf6ab30cfdeca</generator>
    <item>
      <title>speech.cpp 0.8.4 released: quantized versions of every model, and more</title>
      <link>https://nyosegawa.com/en/posts/speech-cpp-weight-types-and-ggml-fork/</link>
      <guid isPermaLink="false">https://nyosegawa.com/en/posts/speech-cpp-weight-types-and-ggml-fork/</guid>
      <description>
        speech.cpp 0.8.4 adds Q8_0, Q6_K, Q5_K and Q4_K files for nine of its models, all but Silero VAD. I measured how much memory each saves and how much accuracy it costs, model by model; here is how to choose, and what to watch for when you update.
      </description>
      <content:encoded>
        <![CDATA[<p>Hi! This is Sakasegawa (<a href="https://x.com/gyakuse">@gyakuse</a>)!</p>
<p>Today I'm writing about the 0.8.4 release of <a href="https://github.com/nyosegawa/speech.cpp">speech.cpp</a>. The biggest change is that almost every model now comes in smaller files that fit on PCs with little memory.</p>
<!--more-->
<p>speech.cpp is an engine that runs speech synthesis, speech recognition and voice activity detection models in C++ (<a href="https://nyosegawa.com/en/posts/speech-cpp/">earlier post</a>).</p>
<p>0.8.4 brings four changes:</p>
<p><img src="https://nyosegawa.com/img/en/speech-cpp-weight-types-and-ggml-fork/changes-overview.png" alt="What changed in 0.8.4"></p>
<p>The biggest is that you can choose the size of each model's file. A smaller type takes this much less memory:</p>
<p><img src="https://nyosegawa.com/img/en/speech-cpp-weight-types-and-ggml-fork/memory-overview.png" alt="Memory by type (parakeet-ja)"></p>
<p>The speed changes differently by model. Qwen3-ASR and Qwen3-TTS get faster the smaller the type, while FastConformer (ReazonSpeech and the others) and Irodori-TTS stay about the same. Below, the time to recognize one utterance:</p>
<p><img src="https://nyosegawa.com/img/en/speech-cpp-weight-types-and-ggml-fork/speed-overview.png" alt="Speed by type (time to recognize one utterance)"></p>
<p>Both were measured on an Apple M5. This post goes through the accuracy, memory and speed of every model, with numbers.</p>
<h2 id="what-changed-in-0.8.4" tabindex="-1"><a href="https://nyosegawa.com/en/posts/speech-cpp-weight-types-and-ggml-fork/#what-changed-in-0.8.4" class="header-anchor">What changed in 0.8.4</a></h2>
<ul>
<li>Quantized files
<ul>
<li>Nine models, all but Silero VAD, come in Q6_K, Q5_K and Q4_K</li>
<li>Models that only had an F16 file also come in Q8_0</li>
<li>Qwen3-TTS and Qwen3-ASR also get faster in the smaller types</li>
</ul>
</li>
<li>ReazonSpeech and parakeet-ja now use their Q8_0 file when named alone
<ul>
<li>The accuracy is almost the same as F16, and the memory is close to half</li>
</ul>
</li>
<li>Fixed Qwen3-TTS's Q5_K and Q4_K stopping with an error partway through synthesis on Vulkan</li>
<li>The memory peak while a model loads dropped by about 150 to 320 MB</li>
<li>On the Mac, a program that embeds <code>libspeech</code> no longer has its environment variables changed</li>
</ul>
<h2 id="updating" tabindex="-1"><a href="https://nyosegawa.com/en/posts/speech-cpp-weight-types-and-ggml-fork/#updating" class="header-anchor">Updating</a></h2>
<p>Run the same command you installed with. On Apple silicon Macs and Linux:</p>
<pre><code class="language-bash">curl -fsSL https://raw.githubusercontent.com/nyosegawa/speech.cpp/main/install.sh | sh
</code></pre>
<p>On Windows, in PowerShell:</p>
<pre><code class="language-powershell">irm https://raw.githubusercontent.com/nyosegawa/speech.cpp/main/install.ps1 | iex
</code></pre>
<p>ReazonSpeech (<code>reazonspeech-v2</code>) and parakeet-ja (<code>parakeet-tdt_ctc-0.6b-ja</code>) now use their Q8_0 file instead of F16 when named alone, so the first run after updating downloads the Q8_0 file (0.66 GB). To keep using F16, add <code>:f16</code> after the name, as in <code>reazonspeech-v2:f16</code>. If you no longer need the F16 file, this removes it:</p>
<pre><code class="language-bash">speech rm reazonspeech-v2:f16
</code></pre>
<h2 id="using-a-quantized-type" tabindex="-1"><a href="https://nyosegawa.com/en/posts/speech-cpp-weight-types-and-ggml-fork/#using-a-quantized-type" class="header-anchor">Using a quantized type</a></h2>
<p>Add a colon and the type after the model's name. The first time, the file is downloaded from Hugging Face.</p>
<pre><code class="language-bash">speech asr qwen3-asr-1.7b:q6_k meeting.wav
speech tts qwen3-tts-0.6b:q6_k &quot;It will be sunny in Tokyo tomorrow.&quot;
speech serve qwen3-tts-1.7b:q4_k qwen3-asr-0.6b silero-vad
</code></pre>
<p><code>speech models</code> lists the types each model has.</p>
<pre><code>qwen3-tts-0.6b                 q8_0  1.21 GB  yes      synthesis    de en es fr it ja ko pt ru zh
qwen3-tts-0.6b:q6_k            q6_k  0.99 GB  no       synthesis    de en es fr it ja ko pt ru zh
qwen3-tts-0.6b:q5_k            q5_k  0.87 GB  no       synthesis    de en es fr it ja ko pt ru zh
qwen3-tts-0.6b:q4_k            q4_k  0.76 GB  no       synthesis    de en es fr it ja ko pt ru zh
</code></pre>
<p>The number in a type is roughly how many bits each weight takes. The smaller the number, the smaller the file and the memory, and the lower the accuracy. How much lower turned out to differ a lot between models.</p>
<h2 id="choosing-a-type-for-each-model" tabindex="-1"><a href="https://nyosegawa.com/en/posts/speech-cpp-weight-types-and-ggml-fork/#choosing-a-type-for-each-model" class="header-anchor">Choosing a type for each model</a></h2>
<p>First, the short version: what to pick when you want to save memory. How far a model goes down with almost no loss of accuracy differs by model:</p>
<p><img src="https://nyosegawa.com/img/en/speech-cpp-weight-types-and-ggml-fork/how-far.png" alt="How far each model goes down with almost no loss of accuracy"></p>
<p>The memory and accuracy of each model are in the chart and the table below. The memory is the peak of one recognition or synthesis.</p>
<p><img src="https://nyosegawa.com/img/en/speech-cpp-weight-types-and-ggml-fork/memory-by-type.png" alt="Memory with the file up to 0.8.3 and with a smaller type in 0.8.4"></p>
<table>
<thead>
<tr>
<th>Model</th>
<th>Named alone</th>
<th>To save memory</th>
<th>What changes</th>
</tr>
</thead>
<tbody>
<tr>
<td>parakeet-ja</td>
<td>Q8_0 (0.78 GB)</td>
<td>Q4_K (0.47 GB)</td>
<td>Almost nothing</td>
</tr>
<tr>
<td>ReazonSpeech</td>
<td>Q8_0 (0.78 GB)</td>
<td>Q4_K (0.47 GB)</td>
<td>Almost nothing</td>
</tr>
<tr>
<td>parakeet-v3</td>
<td>F16 (1.37 GB)</td>
<td>Q6_K (0.63 GB)</td>
<td>WER up 0.1 points</td>
</tr>
<tr>
<td>Qwen3-ASR 0.6B</td>
<td>Q8_0 (1.00 GB)</td>
<td>Q6_K (0.84 GB)</td>
<td>Almost nothing</td>
</tr>
<tr>
<td>Qwen3-ASR 1.7B</td>
<td>Q8_0 (2.34 GB)</td>
<td>Q6_K (1.85 GB)</td>
<td>Almost nothing</td>
</tr>
<tr>
<td>Qwen3-TTS 0.6B</td>
<td>Q8_0 (1.37 GB)</td>
<td>Q6_K (1.15 GB)</td>
<td>Almost nothing; Q4_K misreads more</td>
</tr>
<tr>
<td>Qwen3-TTS 1.7B</td>
<td>Q8_0 (2.44 GB)</td>
<td>Q6_K (1.98 GB)</td>
<td>No difference we could measure</td>
</tr>
<tr>
<td>Irodori-TTS</td>
<td>F16 (2.03 GB)</td>
<td>Q6_K (1.16 GB)</td>
<td>No difference we could measure</td>
</tr>
<tr>
<td>Irodori-TTS MF</td>
<td>F16 (2.04 GB)</td>
<td>Q6_K (1.16 GB)</td>
<td>Every quantized type is a little harder to understand</td>
</tr>
</tbody>
</table>
<p>The rest of this section shows the numbers behind that table. Everything was measured with <a href="https://github.com/nyosegawa/speech-bench">speech-bench</a> on an Apple M5 with Metal; Appendix E has the details.</p>
<h3 id="japanese-speech-recognition" tabindex="-1"><a href="https://nyosegawa.com/en/posts/speech-cpp-weight-types-and-ggml-fork/#japanese-speech-recognition" class="header-anchor">Japanese speech recognition</a></h3>
<p>The CER on the Japanese test set of Common Voice 8.0 (4,483 utterances). The number after the slash is the CER when other spellings of the same words, such as 「行う」 and 「行なう」, count as correct.</p>
<table>
<thead>
<tr>
<th>Type</th>
<th style="text-align:right">File</th>
<th style="text-align:right">Memory</th>
<th style="text-align:right">parakeet-ja</th>
<th style="text-align:right">ReazonSpeech</th>
</tr>
</thead>
<tbody>
<tr>
<td>F16</td>
<td style="text-align:right">1.24 GB</td>
<td style="text-align:right">1.37 GB</td>
<td style="text-align:right">7.88% / 2.98%</td>
<td style="text-align:right">12.02% / 7.13%</td>
</tr>
<tr>
<td>Q8_0</td>
<td style="text-align:right">0.66 GB</td>
<td style="text-align:right">0.78 GB</td>
<td style="text-align:right">7.93% / 3.00%</td>
<td style="text-align:right">12.05% / 7.15%</td>
</tr>
<tr>
<td>Q6_K</td>
<td style="text-align:right">0.51 GB</td>
<td style="text-align:right">0.63 GB</td>
<td style="text-align:right">7.88% / 3.00%</td>
<td style="text-align:right">11.98% / 7.12%</td>
</tr>
<tr>
<td>Q5_K</td>
<td style="text-align:right">0.43 GB</td>
<td style="text-align:right">0.54 GB</td>
<td style="text-align:right">7.79% / 2.97%</td>
<td style="text-align:right">11.99% / 7.14%</td>
</tr>
<tr>
<td>Q4_K</td>
<td style="text-align:right">0.36 GB</td>
<td style="text-align:right">0.47 GB</td>
<td style="text-align:right">7.94% / 3.01%</td>
<td style="text-align:right">12.16% / 7.23%</td>
</tr>
</tbody>
</table>
<p>parakeet-ja and ReazonSpeech barely move even at Q4_K, while the memory goes from 1.37 GB to 0.47 GB. If memory is short, Q4_K is a safe pick for both.</p>
<table>
<thead>
<tr>
<th>Type</th>
<th style="text-align:right">0.6B file</th>
<th style="text-align:right">Memory</th>
<th style="text-align:right">Qwen3-ASR 0.6B</th>
<th style="text-align:right">1.7B file</th>
<th style="text-align:right">Memory</th>
<th style="text-align:right">Qwen3-ASR 1.7B</th>
</tr>
</thead>
<tbody>
<tr>
<td>Q8_0</td>
<td style="text-align:right">0.84 GB</td>
<td style="text-align:right">1.00 GB</td>
<td style="text-align:right">11.89% / 7.05%</td>
<td style="text-align:right">2.18 GB</td>
<td style="text-align:right">2.34 GB</td>
<td style="text-align:right">9.49% / 4.67%</td>
</tr>
<tr>
<td>Q6_K</td>
<td style="text-align:right">0.68 GB</td>
<td style="text-align:right">0.84 GB</td>
<td style="text-align:right">12.01% / 7.02%</td>
<td style="text-align:right">1.68 GB</td>
<td style="text-align:right">1.85 GB</td>
<td style="text-align:right">9.52% / 4.72%</td>
</tr>
<tr>
<td>Q5_K</td>
<td style="text-align:right">0.59 GB</td>
<td style="text-align:right">0.75 GB</td>
<td style="text-align:right">12.49% / 7.57%</td>
<td style="text-align:right">1.41 GB</td>
<td style="text-align:right">1.58 GB</td>
<td style="text-align:right">9.85% / 4.96%</td>
</tr>
<tr>
<td>Q4_K</td>
<td style="text-align:right">0.51 GB</td>
<td style="text-align:right">0.67 GB</td>
<td style="text-align:right">14.23% / 8.66%</td>
<td style="text-align:right">1.16 GB</td>
<td style="text-align:right">1.32 GB</td>
<td style="text-align:right">11.21% / 5.96%</td>
</tr>
</tbody>
</table>
<p>Qwen3-ASR stays close to Q8_0 down to Q6_K, and its CER rises at Q5_K and Q4_K. One interesting point: the 1.7B model at Q4_K (1.32 GB) has a lower CER than the 0.6B model at Q8_0 (1.00 GB). If you have another 0.3 GB to spare, the 1.7B model at Q4_K recognizes more accurately than the 0.6B model.</p>
<h3 id="recognition-in-english-and-other-languages" tabindex="-1"><a href="https://nyosegawa.com/en/posts/speech-cpp-weight-types-and-ggml-fork/#recognition-in-english-and-other-languages" class="header-anchor">Recognition in English and other languages</a></h3>
<p>parakeet-v3 (<code>parakeet-tdt-0.6b-v3</code>) was measured by WER on 300 English utterances of FLEURS. Its files and memory are about the same as parakeet-ja's.</p>
<table>
<thead>
<tr>
<th>Type</th>
<th style="text-align:right">WER</th>
</tr>
</thead>
<tbody>
<tr>
<td>F16</td>
<td style="text-align:right">8.76%</td>
</tr>
<tr>
<td>Q8_0</td>
<td style="text-align:right">9.06%</td>
</tr>
<tr>
<td>Q6_K</td>
<td style="text-align:right">8.87%</td>
</tr>
<tr>
<td>Q5_K</td>
<td style="text-align:right">9.13%</td>
</tr>
<tr>
<td>Q4_K</td>
<td style="text-align:right">9.51%</td>
</tr>
</tbody>
</table>
<h3 id="speech-synthesis" tabindex="-1"><a href="https://nyosegawa.com/en/posts/speech-cpp-weight-types-and-ggml-fork/#speech-synthesis" class="header-anchor">Speech synthesis</a></h3>
<p>For synthesis, 20 Japanese sentences were synthesized with three seeds, Qwen3-ASR 1.7B transcribed the audio, and the CER was taken against the original text. Each number is the mean of 60 syntheses. The same type moves by a few points from seed to seed, so a difference of one or two points does not say which type is better.</p>
<table>
<thead>
<tr>
<th>Type</th>
<th style="text-align:right">0.6B file</th>
<th style="text-align:right">Memory</th>
<th style="text-align:right">Qwen3-TTS 0.6B</th>
<th style="text-align:right">1.7B file</th>
<th style="text-align:right">Memory</th>
<th style="text-align:right">Qwen3-TTS 1.7B</th>
</tr>
</thead>
<tbody>
<tr>
<td>Q8_0</td>
<td style="text-align:right">1.21 GB</td>
<td style="text-align:right">1.37 GB</td>
<td style="text-align:right">6.14%</td>
<td style="text-align:right">2.29 GB</td>
<td style="text-align:right">2.44 GB</td>
<td style="text-align:right">6.25%</td>
</tr>
<tr>
<td>Q6_K</td>
<td style="text-align:right">0.99 GB</td>
<td style="text-align:right">1.15 GB</td>
<td style="text-align:right">6.03%</td>
<td style="text-align:right">1.82 GB</td>
<td style="text-align:right">1.98 GB</td>
<td style="text-align:right">4.42%</td>
</tr>
<tr>
<td>Q5_K</td>
<td style="text-align:right">0.87 GB</td>
<td style="text-align:right">1.03 GB</td>
<td style="text-align:right">7.35%</td>
<td style="text-align:right">1.57 GB</td>
<td style="text-align:right">1.72 GB</td>
<td style="text-align:right">6.02%</td>
</tr>
<tr>
<td>Q4_K</td>
<td style="text-align:right">0.76 GB</td>
<td style="text-align:right">0.92 GB</td>
<td style="text-align:right">13.60%</td>
<td style="text-align:right">1.33 GB</td>
<td style="text-align:right">1.48 GB</td>
<td style="text-align:right">7.19%</td>
</tr>
</tbody>
</table>
<p>Only Qwen3-TTS 0.6B at Q4_K more than doubled its CER over Q8_0. To shrink the 0.6B model, stop at Q6_K. That said, from what I heard, every quantized version of Qwen3-TTS tends to come out with flatter intonation. That is hard to put into a score, so it's worth trying them and listening for yourself. The 1.7B model at Q4_K stayed within the seed-to-seed spread on average. Still, when I later listened sentence by sentence, it turned a sentence with English words into entirely different words, so the table above suggests Q6_K.</p>
<table>
<thead>
<tr>
<th>Type</th>
<th style="text-align:right">MF file</th>
<th style="text-align:right">Memory</th>
<th style="text-align:right">Irodori-TTS MF</th>
<th style="text-align:right">File</th>
<th style="text-align:right">Memory</th>
<th style="text-align:right">Irodori-TTS</th>
</tr>
</thead>
<tbody>
<tr>
<td>F16</td>
<td style="text-align:right">1.92 GB</td>
<td style="text-align:right">2.04 GB</td>
<td style="text-align:right">5.92%</td>
<td style="text-align:right">1.91 GB</td>
<td style="text-align:right">2.03 GB</td>
<td style="text-align:right">3.43%</td>
</tr>
<tr>
<td>Q8_0</td>
<td style="text-align:right">1.21 GB</td>
<td style="text-align:right">1.33 GB</td>
<td style="text-align:right">8.35%</td>
<td style="text-align:right">1.20 GB</td>
<td style="text-align:right">1.32 GB</td>
<td style="text-align:right">4.09%</td>
</tr>
<tr>
<td>Q6_K</td>
<td style="text-align:right">1.04 GB</td>
<td style="text-align:right">1.16 GB</td>
<td style="text-align:right">7.35%</td>
<td style="text-align:right">1.03 GB</td>
<td style="text-align:right">1.16 GB</td>
<td style="text-align:right">3.65%</td>
</tr>
<tr>
<td>Q5_K</td>
<td style="text-align:right">0.95 GB</td>
<td style="text-align:right">1.07 GB</td>
<td style="text-align:right">7.74%</td>
<td style="text-align:right">0.94 GB</td>
<td style="text-align:right">1.07 GB</td>
<td style="text-align:right">4.37%</td>
</tr>
<tr>
<td>Q4_K</td>
<td style="text-align:right">0.86 GB</td>
<td style="text-align:right">0.99 GB</td>
<td style="text-align:right">7.57%</td>
<td style="text-align:right">0.86 GB</td>
<td style="text-align:right">0.98 GB</td>
<td style="text-align:right">4.54%</td>
</tr>
</tbody>
</table>
<p>Irodori-TTS (<code>irodori-tts</code>) was measured with 16 steps (the default is 40). Down to Q4_K, its difference from F16 stayed within the seed-to-seed spread. Unlike Qwen3-TTS, the quantized versions kept a normal intonation. On the other hand, effects such as the caption's may have weakened. Both Irodori-TTS models keep the codec that decodes the audio in F32 in every type, so their files shrink less than the other models'.</p>
<p>The MF model got worse in every quantized type. Its F16 CER per seed was 5.31 to 6.30%, and the means of the quantized types (7.35 to 8.35%) fall outside that range. MF makes its audio in four steps, so an error made in one step may survive to the end. MF delivers its first audio in about a fifth of the time Irodori-TTS takes with 16 steps, so the choice comes down to memory versus speed.</p>
<h3 id="the-type-a-name-uses-alone" tabindex="-1"><a href="https://nyosegawa.com/en/posts/speech-cpp-weight-types-and-ggml-fork/#the-type-a-name-uses-alone" class="header-anchor">The type a name uses alone</a></h3>
<p>The type a model uses when named alone was decided like this:</p>
<ul>
<li>A recognition model uses Q8_0 when its CER is within 0.2 points of F16's, with and without other spellings counted as correct</li>
<li>A synthesis model uses Q8_0 when its mean CER falls within the range of F16's per-seed CERs</li>
<li>Q6_K, Q5_K and Q4_K are never used, however good their numbers</li>
</ul>
<p>The third rule is there because a type that does well on this data may still do worse on other recordings or another GPU. If that happened to the type a name uses alone, it would reach everyone who never chose a type. The smaller types are for people who choose them after seeing the numbers above.</p>
<p>With these rules, parakeet-ja and ReazonSpeech changed to Q8_0. parakeet-v3 stays at F16, since its WER rose 0.3 points at Q8_0, and both Irodori-TTS models stay at F16.</p>
<h2 id="using-qwen3-tts's-q5_k-and-q4_k-on-vulkan" tabindex="-1"><a href="https://nyosegawa.com/en/posts/speech-cpp-weight-types-and-ggml-fork/#using-qwen3-tts's-q5_k-and-q4_k-on-vulkan" class="header-anchor">Using Qwen3-TTS's Q5_K and Q4_K on Vulkan</a></h2>
<p>Qwen3-TTS's Q5_K and Q4_K need 0.8.4 on Vulkan. With the Vulkan build of 0.8.3 or earlier, these two files stop partway through synthesis with this error:</p>
<pre><code>speech: internal: no token has a finite logit to pick from
</code></pre>
<p>The cause is in ggml's Vulkan backend: a value in the middle of the computation went past the largest number F16 can hold (65504) and became infinite. 0.8.4 builds on a ggml with a fix; Appendix A explains the cause and the fix. Q8_0 and Q6_K synthesize correctly on 0.8.3 and earlier too.</p>
<p>To carry this fix, speech.cpp now builds on my fork of ggml (<a href="https://github.com/nyosegawa/ggml/tree/speech.cpp">nyosegawa/ggml</a>) instead of the upstream repository. Appendix C describes how the fork is kept.</p>
<h2 id="memory-and-speed" tabindex="-1"><a href="https://nyosegawa.com/en/posts/speech-cpp-weight-types-and-ggml-fork/#memory-and-speed" class="header-anchor">Memory and speed</a></h2>
<p>0.8.4 also lowers the memory peak while a model loads. 0.8.3 read each tensor from the file into a temporary buffer as large as the largest tensor; 0.8.4 reads 16 MB at a time, so the peak drops by that buffer.</p>
<table>
<thead>
<tr>
<th>Model (M5, Metal)</th>
<th style="text-align:right">0.8.3</th>
<th style="text-align:right">0.8.4</th>
</tr>
</thead>
<tbody>
<tr>
<td>Qwen3-TTS 1.7B Q8_0</td>
<td style="text-align:right">2758 MB</td>
<td style="text-align:right">2443 MB</td>
</tr>
<tr>
<td>Qwen3-ASR 0.6B Q8_0</td>
<td style="text-align:right">1154 MB</td>
<td style="text-align:right">1005 MB</td>
</tr>
<tr>
<td>Irodori-TTS F16</td>
<td style="text-align:right">2171 MB</td>
<td style="text-align:right">2030 MB</td>
</tr>
</tbody>
</table>
<p>On Vulkan the memory also drops after loading. On an RTX 2080, a Qwen3-ASR 1.7B worker waiting for requests went from 2635 MB to 2325 MB; Appendix D explains why.</p>
<p>For the same type, the speed is the same as 0.8.3, and so is the time to load. On the Mac, 0.8.4 computes exactly what 0.8.3 did: the output of all ten models matched 0.8.3 byte for byte.</p>
<h3 id="speed-by-type" tabindex="-1"><a href="https://nyosegawa.com/en/posts/speech-cpp-weight-types-and-ggml-fork/#speed-by-type" class="header-anchor">Speed by type</a></h3>
<p>The type does change the speed. I measured the time to the first audio and the time to make a second of audio (RTF) for synthesis, and the time from a finished utterance to its text for recognition. The type a name uses alone is 100%. First, the Apple M5:</p>
<p><img src="https://nyosegawa.com/img/en/speech-cpp-weight-types-and-ggml-fork/speed-by-type.png" alt="Speed by quantized type (Apple M5)"></p>
<ul>
<li>Qwen3-TTS and Qwen3-ASR get faster the smaller the type
<ul>
<li>At Q4_K, Qwen3-ASR 1.7B recognizes 27% faster, and Qwen3-TTS makes its audio in about 30% less time</li>
<li>Qwen3-TTS's first audio already arrives in 0.04 to 0.06 s, and Q4_K changes it by a few milliseconds</li>
</ul>
</li>
<li>The three FastConformer models (parakeet-ja, ReazonSpeech, parakeet-v3) and Irodori-TTS run at about the same speed in every type</li>
</ul>
<p>Qwen3-TTS and Qwen3-ASR read all their weights from memory for every token they make, so smaller weights mean less to read and a faster model. FastConformer and Irodori-TTS process many frames in each computation, so the computing takes longer than reading the weights, which is likely why smaller weights do not make them faster.</p>
<p>I measured the same on an RTX 2080 (Vulkan) under Windows:</p>
<p><img src="https://nyosegawa.com/img/en/speech-cpp-weight-types-and-ggml-fork/speed-by-type-rtx2080.png" alt="Speed by quantized type (RTX 2080)"></p>
<p>The pattern is the same as on the M5. At Q4_K, Qwen3-ASR 1.7B recognizes 25% faster and Qwen3-TTS 1.7B makes its audio in 22% less time, while FastConformer and Irodori-TTS stay about the same. This release's fix changed how Vulkan computes Q4_K and Q5_K, and they are still faster than Q8_0. On the RTX 2080, parakeet-ja and ReazonSpeech are also 10 to 20% slower in F16 than in Q8_0, so using Q8_0 when they are named alone pays off in speed too.</p>
<h3 id="load-time" tabindex="-1"><a href="https://nyosegawa.com/en/posts/speech-cpp-weight-types-and-ggml-fork/#load-time" class="header-anchor">Load time</a></h3>
<p>Smaller types also load faster. I measured it on an Apple M5 with the system's file cache emptied (the first use after starting the computer) and with the file in the cache (later uses).</p>
<p><img src="https://nyosegawa.com/img/en/speech-cpp-weight-types-and-ggml-fork/load-by-type.png" alt="Time to load a model"></p>
<ul>
<li>The load time shrinks about in proportion to the file
<ul>
<li>The three FastConformer models load in 0.35 s at F16 and about 0.2 s at Q4_K</li>
<li>Qwen3-TTS 1.7B and Qwen3-ASR 1.7B take a little over 0.6 s at Q8_0 with the cache empty, and just under 0.5 s at Q4_K</li>
</ul>
</li>
<li>With the file in the cache, every model loads in about half the time it takes with the cache empty</li>
</ul>
<p>The M5's SSD is fast, so every model loads within 0.7 s even with the cache empty. The first run after installing, though, also takes about 15 s to prepare Metal's shaders, once.</p>
<h2 id="summary" tabindex="-1"><a href="https://nyosegawa.com/en/posts/speech-cpp-weight-types-and-ggml-fork/#summary" class="header-anchor">Summary</a></h2>
<ul>
<li>In 0.8.4, every model but Silero VAD comes in types from Q8_0 down to Q4_K</li>
<li>parakeet-ja, ReazonSpeech, Qwen3-ASR and Irodori-TTS barely change down to Q6_K, and parakeet-ja and ReazonSpeech not even at Q4_K</li>
<li>If you use Qwen3-TTS's Q5_K or Q4_K on Vulkan, update to 0.8.4</li>
</ul>
<h2 id="appendix-a%3A-why-q5_k-and-q4_k-gave-nan-on-vulkan" tabindex="-1"><a href="https://nyosegawa.com/en/posts/speech-cpp-weight-types-and-ggml-fork/#appendix-a%3A-why-q5_k-and-q4_k-gave-nan-on-vulkan" class="header-anchor">Appendix A: why Q5_K and Q4_K gave NaN on Vulkan</a></h2>
<h3 id="how-it-showed-up" tabindex="-1"><a href="https://nyosegawa.com/en/posts/speech-cpp-weight-types-and-ggml-fork/#how-it-showed-up" class="header-anchor">How it showed up</a></h3>
<p>I found it while running the published files one by one on an RTX 2080 under Windows. Only Qwen3-TTS 0.6B synthesized or failed depending on the type. The same files synthesized fine on the Windows CPU and with Metal on the Mac.</p>
<table>
<thead>
<tr>
<th>Qwen3-TTS 0.6B</th>
<th>Vulkan (RTX 2080)</th>
</tr>
</thead>
<tbody>
<tr>
<td>Q8_0</td>
<td>synthesizes</td>
</tr>
<tr>
<td>Q6_K</td>
<td>synthesizes</td>
</tr>
<tr>
<td>Q5_K</td>
<td>error</td>
</tr>
<tr>
<td>Q4_K</td>
<td>error</td>
</tr>
</tbody>
</table>
<p>ggml's Vulkan backend has environment variables that turn off some ways of computing. Trying them one at a time, synthesis worked only with <code>GGML_VK_DISABLE_MMVQ</code> or <code>GGML_VK_DISABLE_INTEGER_DOT_PRODUCT</code> set. Both stop the backend from computing matrix products with integer dot products.</p>
<h3 id="the-block-sum-of-q8_1-goes-past-f16" tabindex="-1"><a href="https://nyosegawa.com/en/posts/speech-cpp-weight-types-and-ggml-fork/#the-block-sum-of-q8_1-goes-past-f16" class="header-anchor">The block sum of q8_1 goes past F16</a></h3>
<p>When ggml's Vulkan backend computes a matrix product with integer dot products, it quantizes the activations that multiply the weights to a format called q8_1. q8_1 groups 32 values and stores 32 int8 values, an F16 scale <code>d</code> and an F16 sum <code>s</code>. <code>s</code> is about the sum of the 32 activations.</p>
<p><img src="https://nyosegawa.com/img/en/speech-cpp-weight-types-and-ggml-fork/q8-1-overflow.png" alt="The block sum of q8_1 overflows F16"></p>
<p>The largest number F16 can hold is 65504, so 32 activations above 2048 in a row make <code>s</code> infinite. LLMs are known to produce very large activations in a few channels (<a href="https://arxiv.org/abs/2402.17762">Sun et al., 2024</a>), and somewhere in Qwen3-TTS they reached this size.</p>
<p>Whether <code>s</code> is used depends on the weight type. Q4_K stores a scale <code>d</code> and a minimum <code>m</code> for every 32 weights and writes a weight as <code>w = d·q − m</code> (q an integer from 0 to 15). Writing an activation as <code>a ≈ d_a·r</code> (r an int8), the dot product of 32 of them is:</p>
<pre><code class="language-math">\sum_i w_i a_i \approx d \, d_a \sum_i q_i r_i \; - \; m \, s, \qquad s = d_a \sum_i r_i
</code></pre>
<p>The first term is an integer dot product, and the second uses <code>s</code>. ggml's shader computes Q4_K and Q5_K exactly this way:</p>
<pre><code class="language-glsl">return ACC_TYPE(float(cache_b.ds.x) * float(cache_a[ib_a].dm.x) * float(q_sum) - float(cache_a[ib_a].dm.y) * float(cache_b.ds.y));
</code></pre>
<p><a href="https://github.com/ggml-org/ggml/blob/v0.25.3/src/ggml-vulkan/vulkan-shaders/mul_mmq_funcs.glsl">ggml-org/ggml v0.25.3: src/ggml-vulkan/vulkan-shaders/mul_mmq_funcs.glsl</a></p>
<p><code>cache_b.ds.y</code> is <code>s</code>. When it is infinite, the result is infinite or NaN. Q8_0 and Q6_K have no minimum, so they have no second term and never read <code>s</code>, which is why only those two synthesized. Reading the shaders, eight types use <code>s</code>: Q2_0, Q4_0, Q4_1, Q5_0, Q5_1, Q4_K, Q5_K and IQ1_S.</p>
<p>On the CPU, Q4_K quantizes the activations to a different format, Q8_K, which stores its sums as int16 and its scale as F32, so the problem does not arise there.</p>
<h3 id="the-fix" tabindex="-1"><a href="https://nyosegawa.com/en/posts/speech-cpp-weight-types-and-ggml-fork/#the-fix" class="header-anchor">The fix</a></h3>
<p>For the eight types that use <code>s</code>, the backend no longer computes through q8_1. ggml already does the same for Q6_K on NVIDIA GPUs, and the fix adds these eight types to that condition. Nothing changes for Q8_0 and Q6_K.</p>
<p>A Codex review of the fix found one more problem. Without q8_1, ggml converts the weights back to F16 and computes the product from them. When a matrix product has no precision set, this path sums in F16 and clips results above 65504 to 65504. Qwen3's decoder in speech.cpp sets no precision, so an input whose block sums stay under 65504 but whose products go past it would be clipped after the fix, where it was correct before. So these eight types now sum in F32. The q8_1 path always summed in integers and F32, so the fixed path handles values as large as before.</p>
<h3 id="test-cases" tabindex="-1"><a href="https://nyosegawa.com/en/posts/speech-cpp-weight-types-and-ggml-fork/#test-cases" class="header-anchor">Test cases</a></h3>
<p>ggml's tests (<code>test-backend-ops</code>) had no case that fails on this problem, so I added two kinds:</p>
<ul>
<li>Activations from 2048 to 4096, so that the sum of 32 goes past F16</li>
<li>Activations from 512 to 1024 and weights from 0.5 to 1, so that the sums fit but the products go past F16</li>
</ul>
<p>Each runs for Q4_0, Q5_0, Q4_K and Q5_K as a matrix-vector product, a small batch and a large batch. Q4_1 and Q5_1 are left out because the CPU they are compared with also quantizes their activations to q8_1 and returns <code>-inf</code> the same way.</p>
<table>
<thead>
<tr>
<th><code>test-backend-ops -b Vulkan0</code> (RTX 2080)</th>
<th style="text-align:right">Before</th>
<th style="text-align:right">After</th>
</tr>
</thead>
<tbody>
<tr>
<td>Added cases</td>
<td style="text-align:right">8 / 24</td>
<td style="text-align:right">24 / 24</td>
</tr>
<tr>
<td>All MUL_MAT cases</td>
<td style="text-align:right">1138 / 1154</td>
<td style="text-align:right">1154 / 1154</td>
</tr>
<tr>
<td>All MUL_MAT_ID cases</td>
<td style="text-align:right">937 / 937</td>
<td style="text-align:right">937 / 937</td>
</tr>
</tbody>
</table>
<p>With the fix, Qwen3-TTS synthesizes at Q5_K and Q4_K too, and its Q8_0 output is byte for byte the same as 0.8.3's.</p>
<h2 id="appendix-b%3A-why-metal's-tensor-api-stays-off" tabindex="-1"><a href="https://nyosegawa.com/en/posts/speech-cpp-weight-types-and-ggml-fork/#appendix-b%3A-why-metal's-tensor-api-stays-off" class="header-anchor">Appendix B: why Metal's Tensor API stays off</a></h2>
<p>On M5 and later chips, ggml's Metal backend computes matrix products with Metal 4's Tensor API. When I ported Irodori-TTS, I found that on the M5 this computation writes past the end of its output (<a href="https://github.com/nyosegawa/speech.cpp/issues/13">speech.cpp #13</a>). It happens when the number of output columns is 64 modulo 128, and turns the codec's 64-frame windows into noise.</p>
<p>So up to 0.8.3, speech.cpp set the environment variable <code>GGML_METAL_TENSOR_DISABLE</code> while it prepared the devices. But that changes the environment of any program embedding <code>libspeech</code>, and it is not safe while another thread reads the environment. Since I was forking ggml anyway, I tried fixing where it writes and using the Tensor API.</p>
<p>With the write fixed, 3 of speech.cpp's 25 Metal checks failed.</p>
<table>
<thead>
<tr>
<th>Check (M5, F32)</th>
<th>Without the Tensor API</th>
<th>With the Tensor API</th>
</tr>
</thead>
<tbody>
<tr>
<td>Irodori-TTS v4.1-Small, SNR of the synthesized audio against the official implementation</td>
<td>8.7 to 62.5 dB</td>
<td>2.3 to 48.3 dB</td>
</tr>
<tr>
<td>ReazonSpeech, 311 seconds of audio recognized with beam search</td>
<td>the same text as NeMo</td>
<td>a few characters differ</td>
</tr>
</tbody>
</table>
<p>Two runs of the same build give the same values, so this is not sampling noise. Even the check that runs the DiT once (109 cases) showed the median SNR dropping from 59.1 dB to 52.9 dB, and that difference piled up over 40 sampling steps. The cause is an argument ggml passes to the Tensor API's matrix product:</p>
<pre><code class="language-cpp">mpp::tensor_ops::matmul2d&lt;
    mpp::tensor_ops::matmul2d_descriptor(
        NRB, NRA, static_cast&lt;int&gt;(dynamic_extent), false, true, true,
        mpp::tensor_ops::matmul2d_descriptor::mode::multiply_accumulate),
    execution_simdgroups&lt;N_MM_SIMD_GROUP_X * N_MM_SIMD_GROUP_Y&gt;&gt; mm;
</code></pre>
<p><a href="https://github.com/ggml-org/ggml/blob/v0.25.3/src/ggml-metal/kernels/mul_mm.metal">ggml-org/ggml v0.25.3: src/ggml-metal/kernels/mul_mm.metal</a></p>
<p>The sixth argument is <code>relaxed_precision</code>, and <code>true</code> allows a faster, less precise computation. Setting it to <code>false</code> passed all 25 checks, and Irodori-TTS's SNR became 14.0 to 64.8 dB, better than without the Tensor API. But now it was slower.</p>
<table>
<thead>
<tr>
<th>M5, median</th>
<th style="text-align:right">Without the Tensor API</th>
<th style="text-align:right">Tensor API (<code>relaxed_precision</code> <code>false</code>)</th>
</tr>
</thead>
<tbody>
<tr>
<td>Irodori-TTS MF, to the first audio</td>
<td style="text-align:right">0.251 s</td>
<td style="text-align:right">0.351 s</td>
</tr>
<tr>
<td>Irodori-TTS 16 steps, to the first audio</td>
<td style="text-align:right">1.239 s</td>
<td style="text-align:right">1.762 s</td>
</tr>
<tr>
<td>Qwen3-TTS 0.6B, to the first audio</td>
<td style="text-align:right">0.039 s</td>
<td style="text-align:right">0.072 s</td>
</tr>
<tr>
<td>Qwen3-ASR 0.6B, recognition</td>
<td style="text-align:right">0.123 s</td>
<td style="text-align:right">0.150 s</td>
</tr>
<tr>
<td>ReazonSpeech, recognition</td>
<td style="text-align:right">0.078 s</td>
<td style="text-align:right">0.107 s</td>
</tr>
</tbody>
</table>
<p>Every model got 22 to 85% slower. With a choice between less precision and less speed, the Tensor API is off by default. ggml already leaves it off by default on chips before the M5, and the fork extends that to every chip. speech.cpp no longer needs to set the environment variable, and the Mac computes exactly what 0.8.3 did.</p>
<h2 id="appendix-c%3A-the-ggml-fork" tabindex="-1"><a href="https://nyosegawa.com/en/posts/speech-cpp-weight-types-and-ggml-fork/#appendix-c%3A-the-ggml-fork" class="header-anchor">Appendix C: the ggml fork</a></h2>
<p>The fixes in Appendices A and B both change ggml's source. So speech.cpp's submodule now points at the <code>speech.cpp</code> branch of <a href="https://github.com/nyosegawa/ggml/tree/speech.cpp">nyosegawa/ggml</a> (<a href="https://github.com/nyosegawa/speech.cpp/pull/111">#111</a>).</p>
<p><img src="https://nyosegawa.com/img/en/speech-cpp-weight-types-and-ggml-fork/fork-branch.png" alt="The shape of the fork's branch"></p>
<p>A fork that drifts far from upstream becomes hard to update. To keep that from happening, the fork follows these rules:</p>
<ul>
<li>The branch is an upstream ggml tag, plus one commit of test cases, plus one commit for each fix</li>
<li>Every fix comes with a test case that fails on its bug</li>
<li>Moving to a new ggml tag starts with only the test-case commit on the new tag
<ul>
<li>A test case that passes without its fix means upstream ggml has fixed the bug, and the fix is dropped</li>
</ul>
</li>
</ul>
<p>The Windows machine has no C++ compiler, so running CI with <code>checks=true</code> now also builds ggml's <code>test-backend-ops.exe</code>. Downloading and running it on Windows shows whether a fix is still needed.</p>
<h2 id="appendix-d%3A-why-loading-takes-less-memory" tabindex="-1"><a href="https://nyosegawa.com/en/posts/speech-cpp-weight-types-and-ggml-fork/#appendix-d%3A-why-loading-takes-less-memory" class="header-anchor">Appendix D: why loading takes less memory</a></h2>
<p>0.8.3 read each GGUF tensor into a <code>std::vector</code> and then sent it to the device. That buffer grew to the size of the largest tensor. In the Qwen3 models the largest is the text embedding, 331 MB in Qwen3-TTS and Qwen3-ASR 1.7B. 0.8.4 reads and sends 16 MB at a time (<a href="https://github.com/nyosegawa/speech.cpp/pull/105">#105</a>).</p>
<p>On Vulkan there is a second saving. ggml's Vulkan backend stages data in a temporary host buffer before writing it to the GPU, and that buffer stays at the size of the largest write until the process ends. Writing a 331 MB tensor at once leaves a 331 MB buffer behind; writing 16 MB at a time leaves 16 MB.</p>
<h2 id="appendix-e%3A-how-it-was-measured" tabindex="-1"><a href="https://nyosegawa.com/en/posts/speech-cpp-weight-types-and-ggml-fork/#appendix-e%3A-how-it-was-measured" class="header-anchor">Appendix E: how it was measured</a></h2>
<ul>
<li>Mac: Apple M5, macOS 26.2, Metal</li>
<li>Windows: Windows 11, NVIDIA GeForce RTX 2080, Vulkan SDK 1.4.357.0</li>
<li>Accuracy and memory of each type: speech-bench, 2026-10-12, M5</li>
<li>Synthesis CER: the 20 sentences of speech-bench's <code>prompts/speak-ja-JP.json</code>, seeds 1, 2 and 3
<ul>
<li>Qwen3-TTS used the speaker <code>ono_anna</code>, and Irodori-TTS a voice file made from one reference recording</li>
<li>Transcribed with Qwen3-ASR 1.7B Q8_0</li>
</ul>
</li>
<li>Speed: synthesis is the median of the same 20 sentences, seed 1, three rounds. Recognition uses Common Voice's 4,479 utterances on the M5 and its first 100 (99 kept) on the RTX 2080; parakeet-v3 uses English FLEURS, 299 utterances on the M5 and 100 on the RTX 2080</li>
<li>Memory: the peak memory footprint of one synthesis or recognition with <code>speech</code> (macOS)</li>
<li>Q6_K, Q5_K and Q4_K quantize 256 values at a time, so a matrix whose rows are not a multiple of 256 stays Q8_0. That applies to Qwen3-ASR 0.6B's encoder (rows of 896) and to FastConformer's prediction network and joint (640)</li>
</ul>
<h2 id="references" tabindex="-1"><a href="https://nyosegawa.com/en/posts/speech-cpp-weight-types-and-ggml-fork/#references" class="header-anchor">References</a></h2>
<ul>
<li>speech.cpp
<ul>
<li><a href="https://github.com/nyosegawa/speech.cpp">speech.cpp</a></li>
<li><a href="https://github.com/nyosegawa/speech.cpp/releases/tag/v0.8.4">v0.8.4</a></li>
<li><a href="https://github.com/nyosegawa/speech.cpp/pull/105">#105 Load a model's tensors from its file 16 MB at a time</a></li>
<li><a href="https://github.com/nyosegawa/speech.cpp/pull/110">#110 Offer every weight type of each model</a></li>
<li><a href="https://github.com/nyosegawa/speech.cpp/pull/111">#111 Build on ggml from a fork</a></li>
<li><a href="https://github.com/nyosegawa/ggml/tree/speech.cpp">nyosegawa/ggml speech.cpp branch</a></li>
</ul>
</li>
<li>Models (Hugging Face)
<ul>
<li><a href="https://huggingface.co/sakasegawa/Qwen3-TTS-12Hz-0.6B-CustomVoice-GGUF">sakasegawa/Qwen3-TTS-12Hz-0.6B-CustomVoice-GGUF</a></li>
<li><a href="https://huggingface.co/sakasegawa/Qwen3-ASR-0.6B-GGUF">sakasegawa/Qwen3-ASR-0.6B-GGUF</a></li>
<li><a href="https://huggingface.co/sakasegawa/reazonspeech-nemo-v2-GGUF">sakasegawa/reazonspeech-nemo-v2-GGUF</a></li>
<li><a href="https://huggingface.co/sakasegawa/Irodori-TTS-v4.1-Small-MF-GGUF">sakasegawa/Irodori-TTS-v4.1-Small-MF-GGUF</a></li>
</ul>
</li>
<li>ggml
<ul>
<li><a href="https://github.com/ggml-org/ggml">ggml-org/ggml</a></li>
<li><a href="https://arxiv.org/abs/2402.17762">Massive Activations in Large Language Models</a></li>
<li><a href="https://github.com/ggml-org/llama.cpp/pull/12135">ggml-org/llama.cpp #12135: Vulkan: Add DP4A MMQ and Q8_1 quantization shader</a></li>
<li><a href="https://github.com/ggml-org/llama.cpp/pull/30105">ggml-org/llama.cpp #30105: vulkan: honor FP32 source precision in MUL_MAT_ID</a></li>
<li><a href="https://github.com/deepbeepmeep/kernels/issues/4">deepbeepmeep/kernels #4: MMQ returns NaN when a 32-activation block sum exceeds 65504 (CUDA)</a></li>
</ul>
</li>
<li>Measurement
<ul>
<li><a href="https://github.com/nyosegawa/speech-bench">speech-bench</a></li>
</ul>
</li>
<li>Related
<ul>
<li><a href="https://nyosegawa.com/en/posts/speech-cpp/">speech.cpp</a></li>
<li><a href="https://github.com/nyosegawa/speech.cpp/issues/13">speech.cpp #13: Metal on Apple M5 corrupts matrix products whose column count is 64 modulo 128</a></li>
</ul>
</li>
</ul>
]]>
      </content:encoded>
      <pubDate>Mon, 12 Oct 2026 00:00:00 GMT</pubDate>
    </item>
    <item>
      <title>speech.cpp 0.8.4 リリース: 全モデルの量子化版公開など</title>
      <link>https://nyosegawa.com/posts/speech-cpp-weight-types-and-ggml-fork/</link>
      <guid isPermaLink="false">https://nyosegawa.com/posts/speech-cpp-weight-types-and-ggml-fork/</guid>
      <description>
        speech.cpp 0.8.4ではSilero VADを除く9つのモデルにQ8_0、Q6_K、Q5_K、Q4_Kのファイルを用意しました。メモリをどれだけ減らせて精度がどれだけ下がるのかをモデルごとに計測したので、選び方と更新するときの注意をまとめます。
      </description>
      <content:encoded>
        <![CDATA[<p>こんにちは！逆瀬川ちゃん (<a href="https://x.com/gyakuse">@gyakuse</a>) です！</p>
<p>今日は<a href="https://github.com/nyosegawa/speech.cpp">speech.cpp</a>の0.8.4のリリースについて書いていきます。いちばん大きな変更は、ほぼすべてのモデルにメモリの少ないPCでも動かせる小さなファイルを用意したことです。</p>
<!--more-->
<p>speech.cppは音声合成、音声認識、発話区間検出のモデルをC++で動かすエンジンです (<a href="https://nyosegawa.com/posts/speech-cpp/">前の記事</a>)。</p>
<p>0.8.4で変わったことは大きく分けて次の4つです。</p>
<p><img src="https://nyosegawa.com/img/speech-cpp-weight-types-and-ggml-fork/changes-overview.png" alt="0.8.4で変わったこと"></p>
<p>いちばん大きいのは、モデルごとにファイルの大きさを選択できるようになったことです。小さいタイプを選ぶとメモリはこのくらい減ります。</p>
<p><img src="https://nyosegawa.com/img/speech-cpp-weight-types-and-ggml-fork/memory-overview.png" alt="タイプごとのメモリ (parakeet-jaの例)"></p>
<p>速さの変わり方はモデルによって違います。Qwen3-ASRとQwen3-TTSは小さいタイプほど速くなり、FastConformer (ReazonSpeechなど) とIrodori-TTSはほとんど変わりません。下の図は1発話を認識する時間を比較した例です。</p>
<p><img src="https://nyosegawa.com/img/speech-cpp-weight-types-and-ggml-fork/speed-overview.png" alt="タイプごとの速さ (1発話を認識する時間)"></p>
<p>どちらもApple M5で計測した値です。この記事ではモデルごとの精度とメモリと速さを数字で見ていきます。</p>
<h2 id="0.8.4%E3%81%A7%E5%A4%89%E3%82%8F%E3%81%A3%E3%81%9F%E3%81%93%E3%81%A8" tabindex="-1"><a href="https://nyosegawa.com/posts/speech-cpp-weight-types-and-ggml-fork/#0.8.4%E3%81%A7%E5%A4%89%E3%82%8F%E3%81%A3%E3%81%9F%E3%81%93%E3%81%A8" class="header-anchor">0.8.4で変わったこと</a></h2>
<ul>
<li>量子化版のファイルを用意しました
<ul>
<li>Silero VADを除く9つのモデルでQ6_K、Q5_K、Q4_Kを選択できます</li>
<li>これまでF16のファイルしかなかったモデルではQ8_0も選択できます</li>
<li>Qwen3-TTSとQwen3-ASRは小さいタイプにすると速くもなります</li>
</ul>
</li>
<li>ReazonSpeechとparakeet-jaは名前だけで指定するとQ8_0のファイルを使うようになりました
<ul>
<li>精度はF16とほとんど同じで、メモリはほぼ半分になります</li>
</ul>
</li>
<li>VulkanでQwen3-TTSのQ5_KとQ4_Kが合成の途中でエラーになる問題を直しました</li>
<li>モデルを読み込むときのメモリのピークが150〜320 MBほど下がりました</li>
<li>Macで <code>libspeech</code> を組み込んだプログラムの環境変数を書き換えなくなりました</li>
</ul>
<h2 id="%E6%9B%B4%E6%96%B0%E3%81%AE%E3%81%97%E3%81%8B%E3%81%9F" tabindex="-1"><a href="https://nyosegawa.com/posts/speech-cpp-weight-types-and-ggml-fork/#%E6%9B%B4%E6%96%B0%E3%81%AE%E3%81%97%E3%81%8B%E3%81%9F" class="header-anchor">更新のしかた</a></h2>
<p>インストールしたときと同じコマンドで更新できます。Apple siliconのMacとLinuxでは次のコマンドを実行します。</p>
<pre><code class="language-bash">curl -fsSL https://raw.githubusercontent.com/nyosegawa/speech.cpp/main/install.sh | sh
</code></pre>
<p>WindowsではPowerShellで次のコマンドを実行します。</p>
<pre><code class="language-powershell">irm https://raw.githubusercontent.com/nyosegawa/speech.cpp/main/install.ps1 | iex
</code></pre>
<p>ReazonSpeech (<code>reazonspeech-v2</code>) とparakeet-ja (<code>parakeet-tdt_ctc-0.6b-ja</code>) は、名前だけで指定したときに使うファイルがF16からQ8_0に変わりました。そのため更新したあとに初めて使うときにQ8_0のファイル (0.66 GB) をダウンロードします。F16を使い続けたい場合は <code>reazonspeech-v2:f16</code> のように名前の後ろに <code>:f16</code> を付けてください。F16のファイルがもう要らなければ次のコマンドで消せます。</p>
<pre><code class="language-bash">speech rm reazonspeech-v2:f16
</code></pre>
<h2 id="%E9%87%8F%E5%AD%90%E5%8C%96%E7%89%88%E3%81%AE%E4%BD%BF%E3%81%84%E6%96%B9" tabindex="-1"><a href="https://nyosegawa.com/posts/speech-cpp-weight-types-and-ggml-fork/#%E9%87%8F%E5%AD%90%E5%8C%96%E7%89%88%E3%81%AE%E4%BD%BF%E3%81%84%E6%96%B9" class="header-anchor">量子化版の使い方</a></h2>
<p>使い方はモデルの名前の後ろにコロンとタイプを付けるだけです。初めて使うときにそのファイルをHugging Faceからダウンロードします。</p>
<pre><code class="language-bash">speech asr qwen3-asr-1.7b:q6_k meeting.wav
speech tts qwen3-tts-0.6b:q6_k &quot;明日の東京は晴れです。&quot;
speech serve qwen3-tts-1.7b:q4_k qwen3-asr-0.6b silero-vad
</code></pre>
<p><code>speech models</code> を実行すると、どのモデルにどのタイプがあるかを確かめられます。</p>
<pre><code>qwen3-tts-0.6b                 q8_0  1.21 GB  yes      synthesis    de en es fr it ja ko pt ru zh
qwen3-tts-0.6b:q6_k            q6_k  0.99 GB  no       synthesis    de en es fr it ja ko pt ru zh
qwen3-tts-0.6b:q5_k            q5_k  0.87 GB  no       synthesis    de en es fr it ja ko pt ru zh
qwen3-tts-0.6b:q4_k            q4_k  0.76 GB  no       synthesis    de en es fr it ja ko pt ru zh
</code></pre>
<p>タイプの数字はウェイトの1つの値をおよそ何ビットで表すかを表しています。数字が小さいほどファイルとメモリは小さくなり、そのぶん精度は下がります。ただ、どのくらい下がるかはモデルによってかなり違いました。</p>
<h2 id="%E3%83%A2%E3%83%87%E3%83%AB%E3%81%94%E3%81%A8%E3%81%AE%E9%81%B8%E3%81%B3%E6%96%B9" tabindex="-1"><a href="https://nyosegawa.com/posts/speech-cpp-weight-types-and-ggml-fork/#%E3%83%A2%E3%83%87%E3%83%AB%E3%81%94%E3%81%A8%E3%81%AE%E9%81%B8%E3%81%B3%E6%96%B9" class="header-anchor">モデルごとの選び方</a></h2>
<p>先にメモリを減らしたいときにどれを選べばよいかをまとめておきます。精度をほとんど落とさずに下げられるタイプは、モデルによってこのように違いました。</p>
<p><img src="https://nyosegawa.com/img/speech-cpp-weight-types-and-ggml-fork/how-far.png" alt="精度をほとんど落とさずに下げられるタイプ"></p>
<p>モデルごとのメモリと精度は次の図と表のとおりです。メモリは1回認識または合成したときの最大の値です。</p>
<p><img src="https://nyosegawa.com/img/speech-cpp-weight-types-and-ggml-fork/memory-by-type.png" alt="0.8.3までのファイルと、0.8.4で選択できる小さいタイプのメモリ"></p>
<table>
<thead>
<tr>
<th>モデル</th>
<th>名前だけで指定したとき</th>
<th>メモリを減らしたいとき</th>
<th>精度の変わり方</th>
</tr>
</thead>
<tbody>
<tr>
<td>parakeet-ja</td>
<td>Q8_0 (0.78 GB)</td>
<td>Q4_K (0.47 GB)</td>
<td>ほとんど変わりません</td>
</tr>
<tr>
<td>ReazonSpeech</td>
<td>Q8_0 (0.78 GB)</td>
<td>Q4_K (0.47 GB)</td>
<td>ほとんど変わりません</td>
</tr>
<tr>
<td>parakeet-v3</td>
<td>F16 (1.37 GB)</td>
<td>Q6_K (0.63 GB)</td>
<td>WERが0.1ポイント上がります</td>
</tr>
<tr>
<td>Qwen3-ASR 0.6B</td>
<td>Q8_0 (1.00 GB)</td>
<td>Q6_K (0.84 GB)</td>
<td>ほとんど変わりません</td>
</tr>
<tr>
<td>Qwen3-ASR 1.7B</td>
<td>Q8_0 (2.34 GB)</td>
<td>Q6_K (1.85 GB)</td>
<td>ほとんど変わりません</td>
</tr>
<tr>
<td>Qwen3-TTS 0.6B</td>
<td>Q8_0 (1.37 GB)</td>
<td>Q6_K (1.15 GB)</td>
<td>ほとんど変わりません。Q4_Kは読み間違いが増えます</td>
</tr>
<tr>
<td>Qwen3-TTS 1.7B</td>
<td>Q8_0 (2.44 GB)</td>
<td>Q6_K (1.98 GB)</td>
<td>計測した範囲では差が見えません</td>
</tr>
<tr>
<td>Irodori-TTS</td>
<td>F16 (2.03 GB)</td>
<td>Q6_K (1.16 GB)</td>
<td>計測した範囲では差が見えません</td>
</tr>
<tr>
<td>Irodori-TTS MF</td>
<td>F16 (2.04 GB)</td>
<td>Q6_K (1.16 GB)</td>
<td>量子化するとどのタイプでも少し聞き取りにくくなります</td>
</tr>
</tbody>
</table>
<p>ここからはこの表のもとになった数字を見ていきます。計測にはすべて<a href="https://github.com/nyosegawa/speech-bench">speech-bench</a>を使い、Apple M5のMetalで動かしています。細かい条件はAppendix Eにまとめました。</p>
<h3 id="%E6%97%A5%E6%9C%AC%E8%AA%9E%E3%81%AE%E9%9F%B3%E5%A3%B0%E8%AA%8D%E8%AD%98" tabindex="-1"><a href="https://nyosegawa.com/posts/speech-cpp-weight-types-and-ggml-fork/#%E6%97%A5%E6%9C%AC%E8%AA%9E%E3%81%AE%E9%9F%B3%E5%A3%B0%E8%AA%8D%E8%AD%98" class="header-anchor">日本語の音声認識</a></h3>
<p>Common Voice 8.0の日本語テストセット (4,483発話) でCERを計測しました。スラッシュの後ろの数字は「行う」と「行なう」のような表記の違いを正解として数えたときのCERです。</p>
<table>
<thead>
<tr>
<th>タイプ</th>
<th style="text-align:right">ファイル</th>
<th style="text-align:right">メモリ</th>
<th style="text-align:right">parakeet-ja</th>
<th style="text-align:right">ReazonSpeech</th>
</tr>
</thead>
<tbody>
<tr>
<td>F16</td>
<td style="text-align:right">1.24 GB</td>
<td style="text-align:right">1.37 GB</td>
<td style="text-align:right">7.88% / 2.98%</td>
<td style="text-align:right">12.02% / 7.13%</td>
</tr>
<tr>
<td>Q8_0</td>
<td style="text-align:right">0.66 GB</td>
<td style="text-align:right">0.78 GB</td>
<td style="text-align:right">7.93% / 3.00%</td>
<td style="text-align:right">12.05% / 7.15%</td>
</tr>
<tr>
<td>Q6_K</td>
<td style="text-align:right">0.51 GB</td>
<td style="text-align:right">0.63 GB</td>
<td style="text-align:right">7.88% / 3.00%</td>
<td style="text-align:right">11.98% / 7.12%</td>
</tr>
<tr>
<td>Q5_K</td>
<td style="text-align:right">0.43 GB</td>
<td style="text-align:right">0.54 GB</td>
<td style="text-align:right">7.79% / 2.97%</td>
<td style="text-align:right">11.99% / 7.14%</td>
</tr>
<tr>
<td>Q4_K</td>
<td style="text-align:right">0.36 GB</td>
<td style="text-align:right">0.47 GB</td>
<td style="text-align:right">7.94% / 3.01%</td>
<td style="text-align:right">12.16% / 7.23%</td>
</tr>
</tbody>
</table>
<p>parakeet-jaとReazonSpeechはQ4_Kまで下げてもCERがほとんど動きませんでした。メモリは1.37 GBから0.47 GBまで減るので、このふたつはメモリが足りなければ迷わずQ4_Kにしてよさそうです。</p>
<table>
<thead>
<tr>
<th>タイプ</th>
<th style="text-align:right">0.6Bのファイル</th>
<th style="text-align:right">メモリ</th>
<th style="text-align:right">Qwen3-ASR 0.6B</th>
<th style="text-align:right">1.7Bのファイル</th>
<th style="text-align:right">メモリ</th>
<th style="text-align:right">Qwen3-ASR 1.7B</th>
</tr>
</thead>
<tbody>
<tr>
<td>Q8_0</td>
<td style="text-align:right">0.84 GB</td>
<td style="text-align:right">1.00 GB</td>
<td style="text-align:right">11.89% / 7.05%</td>
<td style="text-align:right">2.18 GB</td>
<td style="text-align:right">2.34 GB</td>
<td style="text-align:right">9.49% / 4.67%</td>
</tr>
<tr>
<td>Q6_K</td>
<td style="text-align:right">0.68 GB</td>
<td style="text-align:right">0.84 GB</td>
<td style="text-align:right">12.01% / 7.02%</td>
<td style="text-align:right">1.68 GB</td>
<td style="text-align:right">1.85 GB</td>
<td style="text-align:right">9.52% / 4.72%</td>
</tr>
<tr>
<td>Q5_K</td>
<td style="text-align:right">0.59 GB</td>
<td style="text-align:right">0.75 GB</td>
<td style="text-align:right">12.49% / 7.57%</td>
<td style="text-align:right">1.41 GB</td>
<td style="text-align:right">1.58 GB</td>
<td style="text-align:right">9.85% / 4.96%</td>
</tr>
<tr>
<td>Q4_K</td>
<td style="text-align:right">0.51 GB</td>
<td style="text-align:right">0.67 GB</td>
<td style="text-align:right">14.23% / 8.66%</td>
<td style="text-align:right">1.16 GB</td>
<td style="text-align:right">1.32 GB</td>
<td style="text-align:right">11.21% / 5.96%</td>
</tr>
</tbody>
</table>
<p>Qwen3-ASRはQ6_KまではQ8_0とほぼ同じで、Q5_KとQ4_Kに下げるにつれてCERが上がっていきます。おもしろいのは1.7BのQ4_K (1.32 GB) が0.6BのQ8_0 (1.00 GB) よりCERが低いことです。メモリにあと0.3 GBの余裕があるなら、0.6Bのまま使うより1.7BをQ4_Kで使うほうが正確に認識できます。</p>
<h3 id="%E8%8B%B1%E8%AA%9E%E3%81%AA%E3%81%A9%E3%81%AE%E9%9F%B3%E5%A3%B0%E8%AA%8D%E8%AD%98" tabindex="-1"><a href="https://nyosegawa.com/posts/speech-cpp-weight-types-and-ggml-fork/#%E8%8B%B1%E8%AA%9E%E3%81%AA%E3%81%A9%E3%81%AE%E9%9F%B3%E5%A3%B0%E8%AA%8D%E8%AD%98" class="header-anchor">英語などの音声認識</a></h3>
<p>parakeet-v3 (<code>parakeet-tdt-0.6b-v3</code>) はFLEURSの英語300発話でWERを計測しました。ファイルとメモリはparakeet-jaとほぼ同じです。</p>
<table>
<thead>
<tr>
<th>タイプ</th>
<th style="text-align:right">WER</th>
</tr>
</thead>
<tbody>
<tr>
<td>F16</td>
<td style="text-align:right">8.76%</td>
</tr>
<tr>
<td>Q8_0</td>
<td style="text-align:right">9.06%</td>
</tr>
<tr>
<td>Q6_K</td>
<td style="text-align:right">8.87%</td>
</tr>
<tr>
<td>Q5_K</td>
<td style="text-align:right">9.13%</td>
</tr>
<tr>
<td>Q4_K</td>
<td style="text-align:right">9.51%</td>
</tr>
</tbody>
</table>
<h3 id="%E9%9F%B3%E5%A3%B0%E5%90%88%E6%88%90" tabindex="-1"><a href="https://nyosegawa.com/posts/speech-cpp-weight-types-and-ggml-fork/#%E9%9F%B3%E5%A3%B0%E5%90%88%E6%88%90" class="header-anchor">音声合成</a></h3>
<p>合成は日本語の20文を3つのシードで合成し、その音声をQwen3-ASR 1.7Bで聞き取って元の文とのCERを計測しました。表の数字は60回の平均です。合成は同じタイプでもシードによってCERが数ポイント変わるので、1〜2ポイントの差ではどちらが良いとは言えません。</p>
<table>
<thead>
<tr>
<th>タイプ</th>
<th style="text-align:right">0.6Bのファイル</th>
<th style="text-align:right">メモリ</th>
<th style="text-align:right">Qwen3-TTS 0.6B</th>
<th style="text-align:right">1.7Bのファイル</th>
<th style="text-align:right">メモリ</th>
<th style="text-align:right">Qwen3-TTS 1.7B</th>
</tr>
</thead>
<tbody>
<tr>
<td>Q8_0</td>
<td style="text-align:right">1.21 GB</td>
<td style="text-align:right">1.37 GB</td>
<td style="text-align:right">6.14%</td>
<td style="text-align:right">2.29 GB</td>
<td style="text-align:right">2.44 GB</td>
<td style="text-align:right">6.25%</td>
</tr>
<tr>
<td>Q6_K</td>
<td style="text-align:right">0.99 GB</td>
<td style="text-align:right">1.15 GB</td>
<td style="text-align:right">6.03%</td>
<td style="text-align:right">1.82 GB</td>
<td style="text-align:right">1.98 GB</td>
<td style="text-align:right">4.42%</td>
</tr>
<tr>
<td>Q5_K</td>
<td style="text-align:right">0.87 GB</td>
<td style="text-align:right">1.03 GB</td>
<td style="text-align:right">7.35%</td>
<td style="text-align:right">1.57 GB</td>
<td style="text-align:right">1.72 GB</td>
<td style="text-align:right">6.02%</td>
</tr>
<tr>
<td>Q4_K</td>
<td style="text-align:right">0.76 GB</td>
<td style="text-align:right">0.92 GB</td>
<td style="text-align:right">13.60%</td>
<td style="text-align:right">1.33 GB</td>
<td style="text-align:right">1.48 GB</td>
<td style="text-align:right">7.19%</td>
</tr>
</tbody>
</table>
<p>Qwen3-TTS 0.6BのQ4_KだけはCERがQ8_0の倍以上になりました。0.6Bを小さくしたいときはQ6_Kまでにしておくのがおすすめです。ただわたしが聞いてみたかぎり、Qwen3-TTSはすべての量子化版において抑揚が抑えられたものが生成されやすい傾向にあるように感じられました。このあたりはなかなかスコアにしづらいので、実際使ってみて、聞いてみて確かめてみると良いと思います。1.7BのQ4_Kは平均ではシードによる差の範囲に収まっていました。ただ、あとで1文ずつ聞いて比較したときに英単語の混ざる文がまったく違う言葉になった例があったので、表ではQ6_Kをおすすめにしています。</p>
<table>
<thead>
<tr>
<th>タイプ</th>
<th style="text-align:right">MFのファイル</th>
<th style="text-align:right">メモリ</th>
<th style="text-align:right">Irodori-TTS MF</th>
<th style="text-align:right">ファイル</th>
<th style="text-align:right">メモリ</th>
<th style="text-align:right">Irodori-TTS</th>
</tr>
</thead>
<tbody>
<tr>
<td>F16</td>
<td style="text-align:right">1.92 GB</td>
<td style="text-align:right">2.04 GB</td>
<td style="text-align:right">5.92%</td>
<td style="text-align:right">1.91 GB</td>
<td style="text-align:right">2.03 GB</td>
<td style="text-align:right">3.43%</td>
</tr>
<tr>
<td>Q8_0</td>
<td style="text-align:right">1.21 GB</td>
<td style="text-align:right">1.33 GB</td>
<td style="text-align:right">8.35%</td>
<td style="text-align:right">1.20 GB</td>
<td style="text-align:right">1.32 GB</td>
<td style="text-align:right">4.09%</td>
</tr>
<tr>
<td>Q6_K</td>
<td style="text-align:right">1.04 GB</td>
<td style="text-align:right">1.16 GB</td>
<td style="text-align:right">7.35%</td>
<td style="text-align:right">1.03 GB</td>
<td style="text-align:right">1.16 GB</td>
<td style="text-align:right">3.65%</td>
</tr>
<tr>
<td>Q5_K</td>
<td style="text-align:right">0.95 GB</td>
<td style="text-align:right">1.07 GB</td>
<td style="text-align:right">7.74%</td>
<td style="text-align:right">0.94 GB</td>
<td style="text-align:right">1.07 GB</td>
<td style="text-align:right">4.37%</td>
</tr>
<tr>
<td>Q4_K</td>
<td style="text-align:right">0.86 GB</td>
<td style="text-align:right">0.99 GB</td>
<td style="text-align:right">7.57%</td>
<td style="text-align:right">0.86 GB</td>
<td style="text-align:right">0.98 GB</td>
<td style="text-align:right">4.54%</td>
</tr>
</tbody>
</table>
<p>Irodori-TTS (<code>irodori-tts</code>) は16ステップで合成して計測しました (デフォルトは40ステップです)。Q4_Kまで下げてもF16との差はシードによる差の範囲に収まっていました。こちらはQwen3-TTSとことなり、量子化したものでも声の抑揚はふつうでした。一方でキャプションの効果など薄れている可能性はあります。なおIrodori-TTSは2つのモデルとも音声をデコードするコーデックをどのタイプでもF32のまま持っているので、ほかのモデルほどファイルが小さくなりません。</p>
<p>MFのほうは量子化したタイプがどれもF16より悪くなりました。F16をシードごとに計測したCERは5.31〜6.30%で、量子化したタイプの平均 (7.35〜8.35%) はこの範囲の外にあります。MFは4ステップで音声を作るので、1回の計算で生じた誤差が最後まで残りやすいのかもしれません。MFは最初の音声が届くまでの時間が16ステップで合成したIrodori-TTSの5分の1ほどと短いので、メモリと速さのどちらを取るかで選ぶことになります。</p>
<h3 id="%E5%90%8D%E5%89%8D%E3%81%A0%E3%81%91%E3%81%A7%E6%8C%87%E5%AE%9A%E3%81%97%E3%81%9F%E3%81%A8%E3%81%8D%E3%81%AB%E4%BD%BF%E3%81%86%E3%82%BF%E3%82%A4%E3%83%97" tabindex="-1"><a href="https://nyosegawa.com/posts/speech-cpp-weight-types-and-ggml-fork/#%E5%90%8D%E5%89%8D%E3%81%A0%E3%81%91%E3%81%A7%E6%8C%87%E5%AE%9A%E3%81%97%E3%81%9F%E3%81%A8%E3%81%8D%E3%81%AB%E4%BD%BF%E3%81%86%E3%82%BF%E3%82%A4%E3%83%97" class="header-anchor">名前だけで指定したときに使うタイプ</a></h3>
<p>名前だけで指定したときにどのタイプを使うかは次のように決めました。</p>
<ul>
<li>認識のモデルでは、Q8_0とF16のCERの差が表記の違いを数えても数えなくても0.2ポイント以内ならQ8_0を使います</li>
<li>合成のモデルでは、Q8_0のCERの平均がF16をシードごとに計測したCERの範囲に入るならQ8_0を使います</li>
<li>Q6_K、Q5_K、Q4_Kは数字が良くても使いません</li>
</ul>
<p>3つ目は、今回のデータでは良くても別の録音や別のGPUで精度が下がることがありうるからです。名前だけで指定したときのタイプでそうなると、タイプを選んでいない人全員に影響します。小さいタイプは上の数字を見たうえで選んでもらうことにしました。</p>
<p>この決め方でparakeet-jaとReazonSpeechはQ8_0に変わりました。parakeet-v3はQ8_0でWERが0.3ポイント上がったのでF16のままです。Irodori-TTSは2つのモデルともF16のままにしています。</p>
<h2 id="vulkan%E3%81%A7qwen3-tts%E3%81%AEq5_k%E3%81%A8q4_k%E3%82%92%E4%BD%BF%E3%81%86%E3%81%A8%E3%81%8D" tabindex="-1"><a href="https://nyosegawa.com/posts/speech-cpp-weight-types-and-ggml-fork/#vulkan%E3%81%A7qwen3-tts%E3%81%AEq5_k%E3%81%A8q4_k%E3%82%92%E4%BD%BF%E3%81%86%E3%81%A8%E3%81%8D" class="header-anchor">VulkanでQwen3-TTSのQ5_KとQ4_Kを使うとき</a></h2>
<p>Qwen3-TTSのQ5_KとQ4_KをVulkanで使うには0.8.4が必要です。0.8.3以前のVulkan版でこの2つのファイルを使うと、合成の途中で次のエラーになります。</p>
<pre><code>speech: internal: no token has a finite logit to pick from
</code></pre>
<p>原因はggmlのVulkanバックエンドにありました。計算の途中の値がF16で表せる最大の値 (65504) を超えて無限大になっていたのです。0.8.4ではspeech.cppが使うggmlに修正を入れています。原因と直し方はAppendix Aに書きました。Q8_0とQ6_Kは0.8.3以前でも正しく合成できます。</p>
<p>この修正を入れるために、speech.cppはggmlの公式のリポジトリではなくわたしのフォーク (<a href="https://github.com/nyosegawa/ggml/tree/speech.cpp">nyosegawa/ggml</a>) を使うようになりました。フォークの扱い方はAppendix Cにあります。</p>
<h2 id="%E3%83%A1%E3%83%A2%E3%83%AA%E3%81%A8%E9%80%9F%E3%81%95" tabindex="-1"><a href="https://nyosegawa.com/posts/speech-cpp-weight-types-and-ggml-fork/#%E3%83%A1%E3%83%A2%E3%83%AA%E3%81%A8%E9%80%9F%E3%81%95" class="header-anchor">メモリと速さ</a></h2>
<p>0.8.4ではモデルを読み込むときのメモリのピークも下がりました。0.8.3はファイルからテンソルを読むときに、いちばん大きなテンソルと同じ大きさの一時バッファを使っていました。0.8.4は16 MBずつ読むので、そのバッファの分だけピークが下がります。</p>
<table>
<thead>
<tr>
<th>モデル (M5、Metal)</th>
<th style="text-align:right">0.8.3</th>
<th style="text-align:right">0.8.4</th>
</tr>
</thead>
<tbody>
<tr>
<td>Qwen3-TTS 1.7B Q8_0</td>
<td style="text-align:right">2758 MB</td>
<td style="text-align:right">2443 MB</td>
</tr>
<tr>
<td>Qwen3-ASR 0.6B Q8_0</td>
<td style="text-align:right">1154 MB</td>
<td style="text-align:right">1005 MB</td>
</tr>
<tr>
<td>Irodori-TTS F16</td>
<td style="text-align:right">2171 MB</td>
<td style="text-align:right">2030 MB</td>
</tr>
</tbody>
</table>
<p>Vulkanでは読み込みが終わったあとのメモリも減りました。RTX 2080でQwen3-ASR 1.7Bのワーカーが待機している間のメモリは2635 MBから2325 MBになっています。理由はAppendix Dに書きました。</p>
<p>同じタイプなら速さは0.8.3から変わっていません。読み込みにかかる時間も同じです。Macでは0.8.3と同じ計算をしていて、10個のモデルの出力が0.8.3とバイト単位で一致しました。</p>
<h3 id="%E3%82%BF%E3%82%A4%E3%83%97%E3%81%94%E3%81%A8%E3%81%AE%E9%80%9F%E3%81%95" tabindex="-1"><a href="https://nyosegawa.com/posts/speech-cpp-weight-types-and-ggml-fork/#%E3%82%BF%E3%82%A4%E3%83%97%E3%81%94%E3%81%A8%E3%81%AE%E9%80%9F%E3%81%95" class="header-anchor">タイプごとの速さ</a></h3>
<p>タイプを変えると速さも変わります。合成は最初の音声が届くまでの時間と音声1秒を作るのにかかる時間 (RTF) を計測しました。認識は話し終えた1発話を渡してからテキストが返るまでの時間です。名前だけで指定したときのタイプを100%として並べています。まずApple M5の結果です。</p>
<p><img src="https://nyosegawa.com/img/speech-cpp-weight-types-and-ggml-fork/speed-by-type.png" alt="量子化のタイプごとの速さ (Apple M5)"></p>
<ul>
<li>Qwen3-TTSとQwen3-ASRはタイプを小さくするほど速くなります
<ul>
<li>Q4_KにするとQwen3-ASR 1.7Bの認識の時間は27%短くなり、Qwen3-TTSが音声を作る時間は3割ほど短くなりました</li>
<li>Qwen3-TTSの最初の音声までの時間はもともと0.04〜0.06秒と短く、Q4_Kにしても数ミリ秒しか変わりません</li>
</ul>
</li>
<li>FastConformerの3モデル (parakeet-ja、ReazonSpeech、parakeet-v3) とIrodori-TTSはどのタイプでも速さがほとんど変わりません</li>
</ul>
<p>Qwen3-TTSとQwen3-ASRはトークンを1つ作るたびにウェイト全体をメモリから読み込みます。ウェイトが小さいほど読み込む量が減るので、そのぶん速くなります。FastConformerとIrodori-TTSは1回の計算でたくさんのフレームをまとめて処理します。ウェイトを読み込む時間より計算の時間のほうが長いので、ウェイトを小さくしても速くならないのだと考えられます。</p>
<p>WindowsのRTX 2080 (Vulkan) でも同じように計測しました。</p>
<p><img src="https://nyosegawa.com/img/speech-cpp-weight-types-and-ggml-fork/speed-by-type-rtx2080.png" alt="量子化のタイプごとの速さ (RTX 2080)"></p>
<p>傾向はM5と同じです。Q4_KにするとQwen3-ASR 1.7Bの認識の時間は25%短くなり、Qwen3-TTS 1.7Bが音声を作る時間は22%短くなりました。FastConformerとIrodori-TTSはほとんど変わりません。Vulkanでは今回の修正でQ4_KとQ5_Kの計算のしかたが変わりましたが、それでもQ8_0より速いままです。またRTX 2080ではparakeet-jaとReazonSpeechのF16がQ8_0より1〜2割遅く、名前だけで指定したときにQ8_0を使うようにしたことは速さの面でも得になっています。</p>
<h3 id="%E8%AA%AD%E3%81%BF%E8%BE%BC%E3%81%BF%E3%81%AE%E6%99%82%E9%96%93" tabindex="-1"><a href="https://nyosegawa.com/posts/speech-cpp-weight-types-and-ggml-fork/#%E8%AA%AD%E3%81%BF%E8%BE%BC%E3%81%BF%E3%81%AE%E6%99%82%E9%96%93" class="header-anchor">読み込みの時間</a></h3>
<p>タイプを小さくするとモデルを読み込む時間も短くなります。OSのファイルキャッシュを空にした場合 (PCを起動して初めて使うときなど) と、キャッシュに載っている場合 (2回目以降に使うとき) に分けてApple M5で計測しました。</p>
<p><img src="https://nyosegawa.com/img/speech-cpp-weight-types-and-ggml-fork/load-by-type.png" alt="モデルの読み込みにかかる時間"></p>
<ul>
<li>読み込みの時間はファイルの大きさにほぼ比例して短くなります
<ul>
<li>FastConformerの3モデルはF16の0.35秒がQ4_Kでは0.2秒ほどになりました</li>
<li>Qwen3-TTS 1.7BとQwen3-ASR 1.7Bはキャッシュが空のときでQ8_0の0.6秒台がQ4_Kでは0.5秒弱になりました</li>
</ul>
</li>
<li>キャッシュに載っているときはどのモデルもキャッシュが空のときの半分ほどで読み込めます</li>
</ul>
<p>M5のSSDは速いので、どのモデルもキャッシュが空の状態から0.7秒以内に読み込めます。ただしインストールして最初に動かすときだけは、これとは別にMetalのシェーダーの準備に15秒ほどかかります。</p>
<h2 id="%E3%81%BE%E3%81%A8%E3%82%81" tabindex="-1"><a href="https://nyosegawa.com/posts/speech-cpp-weight-types-and-ggml-fork/#%E3%81%BE%E3%81%A8%E3%82%81" class="header-anchor">まとめ</a></h2>
<ul>
<li>0.8.4ではSilero VAD以外のすべてのモデルでQ8_0からQ4_Kまでのタイプを選択できるようになりました</li>
<li>parakeet-ja、ReazonSpeech、Qwen3-ASR、Irodori-TTSはQ6_Kまで下げても精度がほとんど変わりませんでした。parakeet-jaとReazonSpeechはQ4_Kでも変わりません</li>
<li>VulkanでQwen3-TTSのQ5_KかQ4_Kを使うときは0.8.4に更新してください</li>
</ul>
<h2 id="appendix-a%3A-vulkan%E3%81%A7q5_k%E3%81%A8q4_k%E3%81%8Cnan%E3%81%AB%E3%81%AA%E3%81%A3%E3%81%9F%E5%8E%9F%E5%9B%A0" tabindex="-1"><a href="https://nyosegawa.com/posts/speech-cpp-weight-types-and-ggml-fork/#appendix-a%3A-vulkan%E3%81%A7q5_k%E3%81%A8q4_k%E3%81%8Cnan%E3%81%AB%E3%81%AA%E3%81%A3%E3%81%9F%E5%8E%9F%E5%9B%A0" class="header-anchor">Appendix A: VulkanでQ5_KとQ4_KがNaNになった原因</a></h2>
<h3 id="%E8%A6%8B%E3%81%A4%E3%81%91%E3%81%9F%E3%81%8D%E3%81%A3%E3%81%8B%E3%81%91" tabindex="-1"><a href="https://nyosegawa.com/posts/speech-cpp-weight-types-and-ggml-fork/#%E8%A6%8B%E3%81%A4%E3%81%91%E3%81%9F%E3%81%8D%E3%81%A3%E3%81%8B%E3%81%91" class="header-anchor">見つけたきっかけ</a></h3>
<p>公開したファイルをWindowsのRTX 2080で1つずつ動かしていたときに見つけました。Qwen3-TTS 0.6Bだけ、タイプによって合成できたりできなかったりしました。同じファイルでもWindowsのCPUとMacのMetalでは合成できます。</p>
<table>
<thead>
<tr>
<th>Qwen3-TTS 0.6B</th>
<th>Vulkan (RTX 2080)</th>
</tr>
</thead>
<tbody>
<tr>
<td>Q8_0</td>
<td>合成できる</td>
</tr>
<tr>
<td>Q6_K</td>
<td>合成できる</td>
</tr>
<tr>
<td>Q5_K</td>
<td>エラーになる</td>
</tr>
<tr>
<td>Q4_K</td>
<td>エラーになる</td>
</tr>
</tbody>
</table>
<p>ggmlのVulkanバックエンドには一部の計算方法を使わないようにする環境変数があります。1つずつ試すと、<code>GGML_VK_DISABLE_MMVQ</code> か <code>GGML_VK_DISABLE_INTEGER_DOT_PRODUCT</code> を設定したときだけ合成できました。どちらも行列積を整数の内積で計算する方法を止める変数です。</p>
<h3 id="q8_1%E3%81%AE%E3%83%96%E3%83%AD%E3%83%83%E3%82%AF%E3%81%AE%E5%90%88%E8%A8%88%E3%81%8Cf16%E3%82%92%E8%B6%85%E3%81%88%E3%82%8B" tabindex="-1"><a href="https://nyosegawa.com/posts/speech-cpp-weight-types-and-ggml-fork/#q8_1%E3%81%AE%E3%83%96%E3%83%AD%E3%83%83%E3%82%AF%E3%81%AE%E5%90%88%E8%A8%88%E3%81%8Cf16%E3%82%92%E8%B6%85%E3%81%88%E3%82%8B" class="header-anchor">q8_1のブロックの合計がF16を超える</a></h3>
<p>ggmlのVulkanバックエンドは行列積を整数の内積で計算するときに、ウェイトに掛けるActivationをq8_1という形式に量子化します。q8_1は32個の値をひとまとまりにして、int8の値32個とF16のスケール <code>d</code> とF16の合計 <code>s</code> を持ちます。<code>s</code> は32個のActivationを足した値にほぼ等しくなります。</p>
<p><img src="https://nyosegawa.com/img/speech-cpp-weight-types-and-ggml-fork/q8-1-overflow.png" alt="q8_1のブロックの合計がF16からあふれる"></p>
<p>F16で表せる最大の値は65504なので、2048を超えるActivationが32個続くと <code>s</code> は無限大になります。LLMではごく一部のチャンネルにとても大きなActivationが出ることが知られていて (<a href="https://arxiv.org/abs/2402.17762">Sun et al., 2024</a>)、Qwen3-TTSでもどこかでこの大きさに達していました。</p>
<p><code>s</code> を使うかどうかはウェイトのタイプで決まります。Q4_Kは32個のウェイトごとにスケール <code>d</code> と最小値 <code>m</code> を持ち、ウェイトを <code>w = d·q − m</code> で表します (qは0〜15の整数)。Activationを <code>a ≈ d_a·r</code> (rはint8) と表すと、32個分の内積は次のようになります。</p>
<pre><code class="language-math">\sum_i w_i a_i \approx d \, d_a \sum_i q_i r_i \; - \; m \, s, \qquad s = d_a \sum_i r_i
</code></pre>
<p>1つ目の項は整数の内積で計算できて、2つ目の項で <code>s</code> を使います。ggmlのシェーダーでもQ4_KとQ5_Kはこの式のとおりに計算しています。</p>
<pre><code class="language-glsl">return ACC_TYPE(float(cache_b.ds.x) * float(cache_a[ib_a].dm.x) * float(q_sum) - float(cache_a[ib_a].dm.y) * float(cache_b.ds.y));
</code></pre>
<p><a href="https://github.com/ggml-org/ggml/blob/v0.25.3/src/ggml-vulkan/vulkan-shaders/mul_mmq_funcs.glsl">ggml-org/ggml v0.25.3: src/ggml-vulkan/vulkan-shaders/mul_mmq_funcs.glsl</a></p>
<p><code>cache_b.ds.y</code> が <code>s</code> です。これが無限大だと結果も無限大かNaNになります。Q8_0とQ6_Kには最小値がないので2つ目の項がなく、<code>s</code> を使いません。Q8_0とQ6_Kだけが合成できたのはこのためです。シェーダーを読むと <code>s</code> を使うタイプはQ2_0、Q4_0、Q4_1、Q5_0、Q5_1、Q4_K、Q5_K、IQ1_Sの8つでした。</p>
<p>CPUでQ4_Kを計算するときはActivationをQ8_Kという別の形式にしています。Q8_Kは合計をint16で、スケールをF32で持つので、この問題は起きません。</p>
<h3 id="%E7%9B%B4%E3%81%97%E6%96%B9" tabindex="-1"><a href="https://nyosegawa.com/posts/speech-cpp-weight-types-and-ggml-fork/#%E7%9B%B4%E3%81%97%E6%96%B9" class="header-anchor">直し方</a></h3>
<p><code>s</code> を使う8つのタイプではq8_1を使う計算をしないようにしました。ggmlはNVIDIAのGPUでQ6_Kを同じように外していて、その条件にこの8つを足しています。Q8_0とQ6_Kの計算は変わりません。</p>
<p>この修正をCodexにレビューしてもらったところ、もう1つ問題が見つかりました。q8_1を使わない場合、ggmlはウェイトをF16に戻してから行列積を計算します。このとき行列積の精度が指定されていないとF16のまま足し合わせ、65504を超えた結果を65504に切り詰めます。speech.cppのQwen3のデコーダは精度を指定していません。そのためブロックの合計は65504以内でも積の結果が65504を超えると、修正前には正しかった値が修正後に切り詰められてしまいます。そこでこの8つのタイプはF32で足し合わせるようにしました。q8_1を使う計算はもともと整数とF32で足していたので、修正前と同じ大きさの値まで扱えます。</p>
<h3 id="%E3%83%86%E3%82%B9%E3%83%88%E3%82%B1%E3%83%BC%E3%82%B9" tabindex="-1"><a href="https://nyosegawa.com/posts/speech-cpp-weight-types-and-ggml-fork/#%E3%83%86%E3%82%B9%E3%83%88%E3%82%B1%E3%83%BC%E3%82%B9" class="header-anchor">テストケース</a></h3>
<p>ggmlのテスト (<code>test-backend-ops</code>) にはこの問題で失敗するケースがなかったので、2種類を足しました。</p>
<ul>
<li>Activationを2048〜4096にして、32個の合計がF16を超えるケース</li>
<li>Activationを512〜1024、ウェイトを0.5〜1にして、合計は超えないけれど積の結果がF16を超えるケース</li>
</ul>
<p>これをQ4_0、Q5_0、Q4_K、Q5_Kのそれぞれで、行列とベクトルの積と小さなバッチと大きなバッチの3通りで計算します。Q4_1とQ5_1は比較する相手のCPUもActivationをq8_1にしていて同じように <code>-inf</code> を返すので、ケースに入れていません。</p>
<table>
<thead>
<tr>
<th><code>test-backend-ops -b Vulkan0</code> (RTX 2080)</th>
<th style="text-align:right">修正前</th>
<th style="text-align:right">修正後</th>
</tr>
</thead>
<tbody>
<tr>
<td>追加したケース</td>
<td style="text-align:right">8 / 24</td>
<td style="text-align:right">24 / 24</td>
</tr>
<tr>
<td>MUL_MAT のすべてのケース</td>
<td style="text-align:right">1138 / 1154</td>
<td style="text-align:right">1154 / 1154</td>
</tr>
<tr>
<td>MUL_MAT_ID のすべてのケース</td>
<td style="text-align:right">937 / 937</td>
<td style="text-align:right">937 / 937</td>
</tr>
</tbody>
</table>
<p>修正したビルドではQwen3-TTSのQ5_KとQ4_Kも合成でき、Q8_0の出力は0.8.3とバイト単位で同じでした。</p>
<h2 id="appendix-b%3A-metal%E3%81%AEtensor-api%E3%82%92%E4%BD%BF%E3%81%A3%E3%81%A6%E3%81%84%E3%81%AA%E3%81%84%E7%90%86%E7%94%B1" tabindex="-1"><a href="https://nyosegawa.com/posts/speech-cpp-weight-types-and-ggml-fork/#appendix-b%3A-metal%E3%81%AEtensor-api%E3%82%92%E4%BD%BF%E3%81%A3%E3%81%A6%E3%81%84%E3%81%AA%E3%81%84%E7%90%86%E7%94%B1" class="header-anchor">Appendix B: MetalのTensor APIを使っていない理由</a></h2>
<p>ggmlのMetalバックエンドはM5以降のチップでは行列積をMetal 4のTensor APIで計算します。ところがIrodori-TTSを移植したときに、M5ではこの計算が出力の範囲の外まで書き込むことがわかりました (<a href="https://github.com/nyosegawa/speech.cpp/issues/13">speech.cpp #13</a>)。出力の列数を128で割って64余るときに起きて、コーデックの64フレームのウィンドウがノイズになります。</p>
<p>そのため0.8.3までは、speech.cppがデバイスを準備する間だけ環境変数 <code>GGML_METAL_TENSOR_DISABLE</code> を設定していました。ただこれは <code>libspeech</code> を組み込んだプログラムの環境変数を書き換えることになりますし、ほかのスレッドが同時に環境変数を読むと安全ではありません。ggmlのフォークを作ることになったので、書き込む位置を直したうえでTensor APIを使えないかを試しました。</p>
<p>書き込む位置を直してspeech.cppのMetalのチェックを実行すると、25個のうち3個が失敗しました。</p>
<table>
<thead>
<tr>
<th>チェック (M5、F32)</th>
<th>Tensor APIなし</th>
<th>Tensor APIあり</th>
</tr>
</thead>
<tbody>
<tr>
<td>Irodori-TTS v4.1-Small、公式の実装との合成音声のSNR</td>
<td>8.7〜62.5 dB</td>
<td>2.3〜48.3 dB</td>
</tr>
<tr>
<td>ReazonSpeech、311秒の音声をビームサーチで認識</td>
<td>NeMoと同じテキスト</td>
<td>数文字違う</td>
</tr>
</tbody>
</table>
<p>同じビルドで2回実行すると同じ値が出るので、サンプリングのばらつきではありません。DiTを1回計算するだけのチェック (109項目) でもSNRの中央値が59.1 dBから52.9 dBに下がっていて、この差が40ステップのサンプリングで積み重なっていました。原因はggmlがTensor APIの行列積に渡している引数でした。</p>
<pre><code class="language-cpp">mpp::tensor_ops::matmul2d&lt;
    mpp::tensor_ops::matmul2d_descriptor(
        NRB, NRA, static_cast&lt;int&gt;(dynamic_extent), false, true, true,
        mpp::tensor_ops::matmul2d_descriptor::mode::multiply_accumulate),
    execution_simdgroups&lt;N_MM_SIMD_GROUP_X * N_MM_SIMD_GROUP_Y&gt;&gt; mm;
</code></pre>
<p><a href="https://github.com/ggml-org/ggml/blob/v0.25.3/src/ggml-metal/kernels/mul_mm.metal">ggml-org/ggml v0.25.3: src/ggml-metal/kernels/mul_mm.metal</a></p>
<p>6番目の引数は <code>relaxed_precision</code> で、<code>true</code> は精度を下げて速く計算してよいという意味です。これを <code>false</code> にすると25個のチェックはすべて通り、Irodori-TTSのSNRは14.0〜64.8 dBとTensor APIを使わないときより良くなりました。しかしその一方で遅くなってしまいました。</p>
<table>
<thead>
<tr>
<th>M5、中央値</th>
<th style="text-align:right">Tensor APIなし</th>
<th style="text-align:right">Tensor API (<code>relaxed_precision</code> が <code>false</code>)</th>
</tr>
</thead>
<tbody>
<tr>
<td>Irodori-TTS MF、最初の音声まで</td>
<td style="text-align:right">0.251 s</td>
<td style="text-align:right">0.351 s</td>
</tr>
<tr>
<td>Irodori-TTS 16ステップ、最初の音声まで</td>
<td style="text-align:right">1.239 s</td>
<td style="text-align:right">1.762 s</td>
</tr>
<tr>
<td>Qwen3-TTS 0.6B、最初の音声まで</td>
<td style="text-align:right">0.039 s</td>
<td style="text-align:right">0.072 s</td>
</tr>
<tr>
<td>Qwen3-ASR 0.6B、認識</td>
<td style="text-align:right">0.123 s</td>
<td style="text-align:right">0.150 s</td>
</tr>
<tr>
<td>ReazonSpeech、認識</td>
<td style="text-align:right">0.078 s</td>
<td style="text-align:right">0.107 s</td>
</tr>
</tbody>
</table>
<p>どのモデルも22〜85%遅くなります。精度か速さのどちらかを下げることになるので、Tensor APIはデフォルトで使わないことにしました。ggmlもM5より前のチップではTensor APIをデフォルトで使っていないので、フォークではこれをすべてのチップに広げています。これでspeech.cppは環境変数を設定しなくてよくなり、Macの計算は0.8.3と同じになりました。</p>
<h2 id="appendix-c%3A-ggml%E3%81%AE%E3%83%95%E3%82%A9%E3%83%BC%E3%82%AF" tabindex="-1"><a href="https://nyosegawa.com/posts/speech-cpp-weight-types-and-ggml-fork/#appendix-c%3A-ggml%E3%81%AE%E3%83%95%E3%82%A9%E3%83%BC%E3%82%AF" class="header-anchor">Appendix C: ggmlのフォーク</a></h2>
<p>Appendix AとBの修正はどちらもggmlのソースを変える必要があります。そこでspeech.cppのサブモジュールを<a href="https://github.com/nyosegawa/ggml/tree/speech.cpp">nyosegawa/ggml</a>の <code>speech.cpp</code> ブランチに変えました (<a href="https://github.com/nyosegawa/speech.cpp/pull/111">#111</a>)。</p>
<p><img src="https://nyosegawa.com/img/speech-cpp-weight-types-and-ggml-fork/fork-branch.png" alt="フォークのブランチの形"></p>
<p>フォークは公式のリポジトリとの差が大きくなると更新できなくなります。そうならないように次の決まりで扱っています。</p>
<ul>
<li>ブランチはggmlの公式のタグの上に、テストケースのコミットを1つと不具合ごとの修正のコミットを積んだものにします</li>
<li>修正にはその不具合で失敗するテストケースを必ず付けます</li>
<li>ggmlの新しいタグに上げるときは、まずテストケースのコミットだけを新しいタグに載せて実行します
<ul>
<li>修正なしで通ったテストケースがあれば公式のggmlで直ったということなので、その修正を外します</li>
</ul>
</li>
</ul>
<p>WindowsのマシンにはC++のコンパイラがないので、CIを <code>checks=true</code> で実行したときにggmlの <code>test-backend-ops.exe</code> も作るようにしました。Windowsではそれをダウンロードして実行すれば、修正がまだ要るかどうかがわかります。</p>
<h2 id="appendix-d%3A-%E8%AA%AD%E3%81%BF%E8%BE%BC%E3%81%BF%E3%81%AE%E3%83%A1%E3%83%A2%E3%83%AA%E3%81%8C%E6%B8%9B%E3%81%A3%E3%81%9F%E7%90%86%E7%94%B1" tabindex="-1"><a href="https://nyosegawa.com/posts/speech-cpp-weight-types-and-ggml-fork/#appendix-d%3A-%E8%AA%AD%E3%81%BF%E8%BE%BC%E3%81%BF%E3%81%AE%E3%83%A1%E3%83%A2%E3%83%AA%E3%81%8C%E6%B8%9B%E3%81%A3%E3%81%9F%E7%90%86%E7%94%B1" class="header-anchor">Appendix D: 読み込みのメモリが減った理由</a></h2>
<p>0.8.3はGGUFのテンソルを1つずつ <code>std::vector</code> に読んでからデバイスに送っていました。このバッファはいちばん大きなテンソルの大きさまで大きくなります。Qwen3系ではテキストの埋め込みがいちばん大きく、Qwen3-TTSとQwen3-ASR 1.7Bでは331 MBあります。0.8.4では16 MBずつ読んで送るようにしました (<a href="https://github.com/nyosegawa/speech.cpp/pull/105">#105</a>)。</p>
<p>Vulkanではもう1つ減る理由があります。ggmlのVulkanバックエンドはGPUに書き込むデータをホスト側の一時バッファに置いてから送ります。このバッファはいちばん大きな書き込みの大きさのまま、プロセスが終わるまで残ります。331 MBのテンソルを一度に書くと331 MBのバッファが残り続けますが、16 MBずつ書けば16 MBで済みます。</p>
<h2 id="appendix-e%3A-%E8%A8%88%E6%B8%AC%E3%81%AE%E6%9D%A1%E4%BB%B6" tabindex="-1"><a href="https://nyosegawa.com/posts/speech-cpp-weight-types-and-ggml-fork/#appendix-e%3A-%E8%A8%88%E6%B8%AC%E3%81%AE%E6%9D%A1%E4%BB%B6" class="header-anchor">Appendix E: 計測の条件</a></h2>
<ul>
<li>Mac: Apple M5、macOS 26.2、Metal</li>
<li>Windows: Windows 11、NVIDIA GeForce RTX 2080、Vulkan SDK 1.4.357.0</li>
<li>タイプごとの精度とメモリ: speech-bench、2026-10-12、M5</li>
<li>合成のCER: speech-benchの <code>prompts/speak-ja-JP.json</code> の20文、シードは1、2、3
<ul>
<li>Qwen3-TTSの話者は <code>ono_anna</code> で、Irodori-TTSは1つの参照音声から作った声のファイルを使いました</li>
<li>聞き取りにはQwen3-ASR 1.7BのQ8_0を使いました</li>
</ul>
</li>
<li>速さ: 合成は同じ20文をシード1で3回ずつ合成した中央値です。認識はM5ではCommon Voiceの4,479発話、RTX 2080ではその最初の100発話 (99発話を使用) です。parakeet-v3はFLEURSの英語で、M5では299発話、RTX 2080では100発話です</li>
<li>メモリ: <code>speech</code> で1回合成または認識したときのpeak memory footprint (macOS)</li>
<li>Q6_K、Q5_K、Q4_Kは256個の値をひとまとまりにして量子化するので、行の長さが256で割り切れない行列はQ8_0のまま残ります。Qwen3-ASR 0.6Bのエンコーダ (行の長さ896) と、FastConformerのprediction networkとjoint (640) がこれに当たります</li>
</ul>
<h2 id="references" tabindex="-1"><a href="https://nyosegawa.com/posts/speech-cpp-weight-types-and-ggml-fork/#references" class="header-anchor">References</a></h2>
<ul>
<li>speech.cpp
<ul>
<li><a href="https://github.com/nyosegawa/speech.cpp">speech.cpp</a></li>
<li><a href="https://github.com/nyosegawa/speech.cpp/releases/tag/v0.8.4">v0.8.4</a></li>
<li><a href="https://github.com/nyosegawa/speech.cpp/pull/105">#105 Load a model's tensors from its file 16 MB at a time</a></li>
<li><a href="https://github.com/nyosegawa/speech.cpp/pull/110">#110 Offer every weight type of each model</a></li>
<li><a href="https://github.com/nyosegawa/speech.cpp/pull/111">#111 Build on ggml from a fork</a></li>
<li><a href="https://github.com/nyosegawa/ggml/tree/speech.cpp">nyosegawa/ggml speech.cpp branch</a></li>
</ul>
</li>
<li>モデル (Hugging Face)
<ul>
<li><a href="https://huggingface.co/sakasegawa/Qwen3-TTS-12Hz-0.6B-CustomVoice-GGUF">sakasegawa/Qwen3-TTS-12Hz-0.6B-CustomVoice-GGUF</a></li>
<li><a href="https://huggingface.co/sakasegawa/Qwen3-ASR-0.6B-GGUF">sakasegawa/Qwen3-ASR-0.6B-GGUF</a></li>
<li><a href="https://huggingface.co/sakasegawa/reazonspeech-nemo-v2-GGUF">sakasegawa/reazonspeech-nemo-v2-GGUF</a></li>
<li><a href="https://huggingface.co/sakasegawa/Irodori-TTS-v4.1-Small-MF-GGUF">sakasegawa/Irodori-TTS-v4.1-Small-MF-GGUF</a></li>
</ul>
</li>
<li>ggml
<ul>
<li><a href="https://github.com/ggml-org/ggml">ggml-org/ggml</a></li>
<li><a href="https://arxiv.org/abs/2402.17762">Massive Activations in Large Language Models</a></li>
<li><a href="https://github.com/ggml-org/llama.cpp/pull/12135">ggml-org/llama.cpp #12135: Vulkan: Add DP4A MMQ and Q8_1 quantization shader</a></li>
<li><a href="https://github.com/ggml-org/llama.cpp/pull/30105">ggml-org/llama.cpp #30105: vulkan: honor FP32 source precision in MUL_MAT_ID</a></li>
<li><a href="https://github.com/deepbeepmeep/kernels/issues/4">deepbeepmeep/kernels #4: MMQ returns NaN when a 32-activation block sum exceeds 65504 (CUDA)</a></li>
</ul>
</li>
<li>測定
<ul>
<li><a href="https://github.com/nyosegawa/speech-bench">speech-bench</a></li>
</ul>
</li>
<li>関連する記事
<ul>
<li><a href="https://nyosegawa.com/posts/speech-cpp/">speech.cpp</a></li>
<li><a href="https://github.com/nyosegawa/speech.cpp/issues/13">speech.cpp #13: Metal on Apple M5 corrupts matrix products whose column count is 64 modulo 128</a></li>
</ul>
</li>
</ul>
]]>
      </content:encoded>
      <pubDate>Mon, 12 Oct 2026 00:00:00 GMT</pubDate>
    </item>
    <item>
      <title>How far can the OpenAI Decisions API go on image recognition tasks? A comparison with other methods</title>
      <link>https://nyosegawa.com/en/posts/openai-decisions-api-image-classification/</link>
      <guid isPermaLink="false">https://nyosegawa.com/en/posts/openai-decisions-api-image-classification/</guid>
      <description>
        I compared the OpenAI Decisions API, released in October 2026, against trained SigLIP2 models, the Responses API, and Codex Agent on five tasks: fine-grained bird classification, action recognition, document classification, action anticipation, and anomaly detection. I look at accuracy, latency, cost, and probability calibration.
      </description>
      <content:encoded>
        <![CDATA[<p>Hi there! This is Sakasegawa-chan (<a href="https://x.com/gyakuse">@gyakuse</a>)!</p>
<p>Today I'd like to look at the OpenAI Decisions API, released in beta on October 6, 2026, and see how accurate it is on image recognition tasks, comparing it with good old trained image models, the Responses API, and a coding agent.</p>
<!--more-->
<p>The Decisions API takes questions like &quot;Is there a scratch in this image?&quot; or &quot;What type of document is this?&quot; and returns the answer as probabilities instead of text. OpenAI says it is about 10x faster than the Responses API, and positions it for classification and routing.</p>
<p>A classification API sounds like it should work for image recognition out of the box. But image recognition already has a strong competitor: dedicated models trained on labeled data. So I prepared five tasks of different kinds (image classification, multi-label classification, action anticipation, and anomaly detection) and compared six methods on the same test sets.</p>
<h2 id="about-the-openai-decisions-api" tabindex="-1"><a href="https://nyosegawa.com/en/posts/openai-decisions-api-image-classification/#about-the-openai-decisions-api" class="header-anchor">About the OpenAI Decisions API</a></h2>
<p>Let's start with what the Decisions API is.</p>
<p><img src="https://nyosegawa.com/img/en/openai-decisions-api-image-classification/decisions-flow.png" alt="How the Decisions API works"></p>
<p>You send the Decisions API something to judge (text, images, or both) and a list of questions. For each question, you write the criteria in natural language as <code>instructions</code>, and the model returns each answer as probabilities. The endpoint is <code>POST /v1/decisions</code>, and the only supported model right now is <code>gpt-6-luna</code>.</p>
<p>There are three question types.</p>
<table>
<thead>
<tr>
<th>Type</th>
<th>What you specify</th>
<th>What you get back</th>
<th>Example</th>
</tr>
</thead>
<tbody>
<tr>
<td><code>predicate</code></td>
<td>Only <code>instructions</code></td>
<td><code>probability</code> that the condition is true</td>
<td>&quot;The product has a scratch&quot;</td>
</tr>
<tr>
<td><code>choice</code></td>
<td>2 to 255 candidates</td>
<td>The chosen <code>choice</code>, per-candidate <code>probabilities</code>, and <code>confidence</code></td>
<td>&quot;Which of 16 document types is this?&quot;</td>
</tr>
<tr>
<td><code>score</code></td>
<td><code>levels</code> ordered from low to high</td>
<td><code>score</code>, the probability-weighted mean of level indices, and per-level probabilities</td>
<td>&quot;Which of 3 severity levels is this bug?&quot;</td>
</tr>
</tbody>
</table>
<p>Independent questions about the same input can be sent together in one request. For example, you can ask &quot;Is there a scratch?&quot; and &quot;What is the product category?&quot; at once.</p>
<p>The response looks like this. It is a real output from this experiment, where the model picked a bird species out of 200 candidates, with the middle omitted.</p>
<pre><code class="language-json">{
  &quot;model&quot;: &quot;gpt-6-luna&quot;,
  &quot;answers&quot;: [
    {
      &quot;type&quot;: &quot;choice&quot;,
      &quot;name&quot;: &quot;species&quot;,
      &quot;choice&quot;: &quot;186&quot;,
      &quot;probabilities&quot;: [
        {&quot;value&quot;: &quot;0&quot;, &quot;probability&quot;: 0.02},
        {&quot;value&quot;: &quot;1&quot;, &quot;probability&quot;: 0.01},
        ...
        {&quot;value&quot;: &quot;186&quot;, &quot;probability&quot;: 0.38},
        ...
      ],
      &quot;confidence&quot;: 0.38
    }
  ],
  &quot;usage&quot;: {&quot;input_tokens&quot;: 2346, &quot;output_tokens&quot;: 0, ...}
}
</code></pre>
<p>Notice that <code>output_tokens</code> is 0. The Decisions API doesn't generate text; it only returns probabilities over the candidates.</p>
<h3 id="how-is-it-different-from-the-responses-api%3F" tabindex="-1"><a href="https://nyosegawa.com/en/posts/openai-decisions-api-image-classification/#how-is-it-different-from-the-responses-api%3F" class="header-anchor">How is it different from the Responses API?</a></h3>
<p>If all you want is to pick one of a fixed set of candidates, you can do that with Structured Outputs in the Responses API too. Pass a JSON Schema with an <code>enum</code>, and you will never get a value outside the candidates. The difference is in how the answer is produced.</p>
<p><img src="https://nyosegawa.com/img/en/openai-decisions-api-image-classification/responses-vs-decisions.png" alt="How the Responses API and the Decisions API differ"></p>
<p>The Responses API does reasoning first, then generates text (JSON) token by token. Raising the reasoning effort makes it think more, but it also takes longer. The Decisions API returns per-candidate probabilities in a single decision, so its latency is shorter.</p>
<p>The other difference is the nature of the probabilities. If you ask the Responses API to &quot;write your confidence from 0 to 1,&quot; you'll get a number, but it is a number the model wrote as text. The Decisions API returns a distribution over all candidates, so you can use it directly to set thresholds or pull out the Top-5. How far you can trust these probabilities is something I check later in the article.</p>
<h3 id="pricing" tabindex="-1"><a href="https://nyosegawa.com/en/posts/openai-decisions-api-image-classification/#pricing" class="header-anchor">Pricing</a></h3>
<p>Pricing is also computed differently from the Responses API. Even with the same <code>gpt-6-luna</code>, the Decisions API only charges for input tokens.</p>
<table>
<thead>
<tr>
<th>Billed item</th>
<th style="text-align:right">Decisions API</th>
<th style="text-align:right">Responses API (gpt-6-luna)</th>
</tr>
</thead>
<tbody>
<tr>
<td>Input</td>
<td style="text-align:right">$0.10 / 1M tokens</td>
<td style="text-align:right">$0.10 / 1M tokens</td>
</tr>
<tr>
<td>Cache read</td>
<td style="text-align:right">No extra charge</td>
<td style="text-align:right">$0.01 / 1M tokens</td>
</tr>
<tr>
<td>Cache write</td>
<td style="text-align:right">No extra charge</td>
<td style="text-align:right">$0.125 / 1M tokens</td>
</tr>
<tr>
<td>Output</td>
<td style="text-align:right">Not billed</td>
<td style="text-align:right">$0.50 / 1M tokens</td>
</tr>
</tbody>
</table>
<p>The input price is the same, so if you only need a short JSON back, the difference from the Responses API is small. When the Responses API produces reasoning tokens or long outputs, it costs more. On the other hand, long questions or candidate descriptions increase input tokens for the Decisions API too, so it isn't always cheaper.</p>
<h2 id="what-tasks-is-it-good-for%3F" tabindex="-1"><a href="https://nyosegawa.com/en/posts/openai-decisions-api-image-classification/#what-tasks-is-it-good-for%3F" class="header-anchor">What tasks is it good for?</a></h2>
<p>Now that we know how it works, let's think about what it is good for. The official guide lists classification, routing, and prioritization as examples. It also describes a voice-controlled app where the Live API handles the conversation and the Decisions API picks an action such as &quot;go to the next slide&quot; or &quot;do nothing.&quot;</p>
<p>These have three things in common.</p>
<ul>
<li>The answer fits into a fixed set of candidates, true/false, or one of a few levels</li>
<li>You want the answer quickly, every time</li>
<li>You want to use how likely the answer is as a number in later processing</li>
</ul>
<p>On the other hand, the official guide recommends Structured Outputs or function calling in the Responses API when you want to extract information into JSON with an arbitrary schema, have the model write explanations, or call tools with arguments.</p>
<p>Much of image recognition can be written in this &quot;answer from a fixed set of candidates&quot; form. But the contents vary a lot: telling 200 bird species apart by their feather patterns and deciding whether a person is riding a bicycle need very different abilities. The goal of the second half of this article is to find out which image recognition tasks the Decisions API can handle.</p>
<h2 id="usage%2C-speed%2C-and-cost" tabindex="-1"><a href="https://nyosegawa.com/en/posts/openai-decisions-api-image-classification/#usage%2C-speed%2C-and-cost" class="header-anchor">Usage, speed, and cost</a></h2>
<p>Before that, let's look at how to call it and at the speed and cost measured in this experiment.</p>
<h3 id="usage" tabindex="-1"><a href="https://nyosegawa.com/en/posts/openai-decisions-api-image-classification/#usage" class="header-anchor">Usage</a></h3>
<p>In the Python SDK, <code>client.decisions.create(...)</code> is available from 3.26.0. In my experiment code I sent requests with the SDK's generic <code>client.post</code>. Below is the part of the HICO task, which judges relations between people and objects, where 20 actions are asked as <code>predicate</code> questions in one request.</p>
<pre><code class="language-python">messages = [{
    &quot;role&quot;: &quot;user&quot;,
    &quot;content&quot;: [
        {&quot;type&quot;: &quot;input_text&quot;, &quot;text&quot;: task},
        {&quot;type&quot;: &quot;input_image&quot;,
         &quot;image_url&quot;: &quot;data:image/png;base64,&quot; + b64,
         &quot;detail&quot;: &quot;high&quot;},
    ],
}]

questions = [
    {
        &quot;type&quot;: &quot;predicate&quot;,
        &quot;name&quot;: f&quot;label_{k}&quot;,
        &quot;instructions&quot;: f&quot;Does the image show {v}? Judge each interaction &quot;
                        &quot;independently. A person and object merely &quot;
                        &quot;co-occurring is insufficient.&quot;,
    }
    for k, v in classes.items()  # e.g. &quot;a person riding a bicycle&quot;
]

result = client.post(
    &quot;/decisions&quot;,
    cast_to=dict,
    body={&quot;model&quot;: &quot;gpt-6-luna&quot;, &quot;input&quot;: messages, &quot;questions&quot;: questions},
)
scores = [a[&quot;probability&quot;] for a in result[&quot;answers&quot;]]
</code></pre>
<p>For the 200-species bird classification, I made a single <code>choice</code> question with the 200 species names as candidates.</p>
<pre><code class="language-python">questions = [{
    &quot;type&quot;: &quot;choice&quot;,
    &quot;name&quot;: &quot;species&quot;,
    &quot;instructions&quot;: task,
    &quot;choices&quot;: [{&quot;value&quot;: k, &quot;description&quot;: v} for k, v in classes.items()],
}]
</code></pre>
<p>Images are passed to <code>input_image</code> as base64 data URLs. A single request can include up to 128 images.</p>
<h3 id="speed" tabindex="-1"><a href="https://nyosegawa.com/en/posts/openai-decisions-api-image-classification/#speed" class="header-anchor">Speed</a></h3>
<p>Here is the latency distribution per item across the five tasks.</p>
<p><img src="https://nyosegawa.com/img/en/openai-decisions-api-image-classification/latency-box.png" alt="Latency distribution of the API methods and the Agent"></p>
<table>
<thead>
<tr>
<th>Task</th>
<th>Input images</th>
<th style="text-align:right">Decisions</th>
<th style="text-align:right">Responses (medium)</th>
<th style="text-align:right">Codex Agent</th>
<th style="text-align:right">Responses / Decisions</th>
</tr>
</thead>
<tbody>
<tr>
<td>CUB</td>
<td>1</td>
<td style="text-align:right">0.31 s</td>
<td style="text-align:right">3.47 s</td>
<td style="text-align:right">9.85 s</td>
<td style="text-align:right">11.4x</td>
</tr>
<tr>
<td>HICO</td>
<td>1</td>
<td style="text-align:right">0.87 s</td>
<td style="text-align:right">4.17 s</td>
<td style="text-align:right">11.12 s</td>
<td style="text-align:right">4.8x</td>
</tr>
<tr>
<td>RVL-CDIP</td>
<td>1</td>
<td style="text-align:right">0.49 s</td>
<td style="text-align:right">2.42 s</td>
<td style="text-align:right">9.27 s</td>
<td style="text-align:right">5.0x</td>
</tr>
<tr>
<td>EPIC</td>
<td>4</td>
<td style="text-align:right">2.85 s</td>
<td style="text-align:right">10.43 s</td>
<td style="text-align:right">14.94 s</td>
<td style="text-align:right">3.7x</td>
</tr>
<tr>
<td>MVTec LOCO</td>
<td>4</td>
<td style="text-align:right">6.29 s</td>
<td style="text-align:right">10.23 s</td>
<td style="text-align:right">14.66 s</td>
<td style="text-align:right">1.6x</td>
</tr>
</tbody>
</table>
<p>The numbers are medians. On single-image tasks, the Decisions API returned in 0.3 to 0.9 seconds, about 5 to 11 times faster than the Responses API. On EPIC and LOCO, which take four images, it took 3 to 6 seconds and the gap shrank. The reasoning effort for the Responses API was <code>medium</code>.</p>
<h3 id="cost" tabindex="-1"><a href="https://nyosegawa.com/en/posts/openai-decisions-api-image-classification/#cost" class="header-anchor">Cost</a></h3>
<p>Next are the API cost per 1,000 items and the number of input tokens per request.</p>
<p><img src="https://nyosegawa.com/img/en/openai-decisions-api-image-classification/cost-tokens.png" alt="Cost per 1,000 items and input tokens"></p>
<p>Costs are estimates computed from <code>usage</code>. Running 5 tasks x 200 items through both APIs came to about $0.59 in total.</p>
<p>Which API was cheaper flipped depending on the task.</p>
<ul>
<li>HICO: the Decisions API costs more
<ul>
<li>All 20 <code>instructions</code> count as input tokens</li>
<li>About 4.7x the input tokens of the Responses API</li>
</ul>
</li>
<li>EPIC and LOCO: the Responses API costs about 1.7 to 4.9x more
<ul>
<li>Reasoning tokens are billed as output</li>
</ul>
</li>
</ul>
<p>In short, the cost of the Decisions API is decided by how you design the questions, and the cost of the Responses API is decided by how much it reasons.</p>
<h2 id="comparing-on-image-recognition-tasks" tabindex="-1"><a href="https://nyosegawa.com/en/posts/openai-decisions-api-image-classification/#comparing-on-image-recognition-tasks" class="header-anchor">Comparing on image recognition tasks</a></h2>
<p>Now for the main part. I looked at how well the Decisions API works for image recognition on five tasks.</p>
<h3 id="the-five-tasks" tabindex="-1"><a href="https://nyosegawa.com/en/posts/openai-decisions-api-image-classification/#the-five-tasks" class="header-anchor">The five tasks</a></h3>
<p><img src="https://nyosegawa.com/img/en/openai-decisions-api-image-classification/tasks.png" alt="The five image tasks"></p>
<table>
<thead>
<tr>
<th>Task</th>
<th>Kind</th>
<th>Problem</th>
<th style="text-align:right">train / val / test</th>
<th>Metric</th>
</tr>
</thead>
<tbody>
<tr>
<td><a href="https://www.vision.caltech.edu/datasets/cub_200_2011/">CUB-200-2011</a></td>
<td>Image classification</td>
<td>Fine-grained classification of birds into 200 species</td>
<td style="text-align:right">2,000 / 1,000 / 200</td>
<td>Top-1 accuracy</td>
</tr>
<tr>
<td><a href="https://websites.umich.edu/~ywchao/hico/">HICO</a></td>
<td>Multi-label classification</td>
<td>Judge 20 actions from relations between people and objects, as multiple labels</td>
<td style="text-align:right">389 / 195 / 200</td>
<td>macro F1</td>
</tr>
<tr>
<td><a href="https://adamharley.com/rvl-cdip/">RVL-CDIP</a> Mini</td>
<td>Image classification</td>
<td>Classify document images into 16 types</td>
<td style="text-align:right">160 / 80 / 200</td>
<td>accuracy</td>
</tr>
<tr>
<td><a href="https://epic-kitchens.github.io/">EPIC-KITCHENS-100</a></td>
<td>Action anticipation</td>
<td>Predict the next action out of 20 candidates from 4 past frames</td>
<td style="text-align:right">400 / 100 / 200</td>
<td>Top-1 accuracy</td>
</tr>
<tr>
<td><a href="https://www.mvtec.com/company/research/datasets/mvtec-loco">MVTec LOCO AD</a></td>
<td>Anomaly detection</td>
<td>Using normal samples as reference, find structural anomalies such as scratches and logical anomalies in counts or layout</td>
<td style="text-align:right">300 normal / 100 normal / 200</td>
<td>Mean per-category AUROC</td>
</tr>
</tbody>
</table>
<p>I picked them so that the required abilities would be as varied as possible. Each test set has 200 items, so I also look at 95% CIs.</p>
<h3 id="the-six-methods" tabindex="-1"><a href="https://nyosegawa.com/en/posts/openai-decisions-api-image-classification/#the-six-methods" class="header-anchor">The six methods</a></h3>
<p><img src="https://nyosegawa.com/img/en/openai-decisions-api-image-classification/methods.png" alt="The six methods compared"></p>
<table>
<thead>
<tr>
<th>Method</th>
<th>What it does</th>
</tr>
</thead>
<tbody>
<tr>
<td>SigLIP2 zero-shot</td>
<td>Classifies by similarity between the image and text prompts for the candidates. Not trained on the task's labels</td>
</tr>
<tr>
<td>SigLIP2 linear probe</td>
<td>Freezes the image encoder and trains a linear classifier (logistic regression) on top of the embeddings</td>
</tr>
<tr>
<td>SigLIP2 FT</td>
<td>Trains the image encoder and the classification head for 3 epochs</td>
</tr>
<tr>
<td>Decisions API</td>
<td>Gets probabilities with <code>choice</code> or <code>predicate</code></td>
</tr>
<tr>
<td>Responses API</td>
<td>Passes the image and candidates and gets the result with Structured Outputs (reasoning effort <code>medium</code>)</td>
</tr>
<tr>
<td>Codex Agent</td>
<td>Gives the image to <code>codex exec</code> and lets it decide while cropping and resizing (reasoning effort <code>medium</code>)</td>
</tr>
</tbody>
</table>
<p>The image model is <code>google/siglip2-base-patch16-256</code>, and the APIs and the Agent use <code>gpt-6-luna</code>. The APIs and the Agent get no training data, only the candidate names and the prompt. So the SigLIP2 linear probe and FT use labeled data, while zero-shot, the APIs, and the Agent don't.</p>
<p>Only for LOCO, anomalous images can't be used for training, so I replaced the linear probe with &quot;kNN distance to normal samples&quot; and FT with &quot;a teacher-student model trained on normal images only.&quot; The APIs and the Agent get three normal samples plus the image to judge.</p>
<h3 id="overall-results" tabindex="-1"><a href="https://nyosegawa.com/en/posts/openai-decisions-api-image-classification/#overall-results" class="header-anchor">Overall results</a></h3>
<p>Here are the results for 5 tasks x 6 methods.</p>
<p><img src="https://nyosegawa.com/img/en/openai-decisions-api-image-classification/overview-heatmap.png" alt="Metrics for 5 tasks x 6 methods"></p>
<p>The best method differs from task to task. Trained SigLIP2 was best on CUB and HICO, the Responses API on RVL-CDIP and LOCO, and the Agent on EPIC. The Decisions API wasn't first on any task, but it was close to first on HICO and RVL-CDIP, and last on CUB.</p>
<p>With only 200 test items, though, the question is how much the differences in point estimates mean. Let's add 95% confidence intervals (CIs).</p>
<p><img src="https://nyosegawa.com/img/en/openai-decisions-api-image-classification/score-ci.png" alt="Metrics and 95% CIs for each method"></p>
<p>On EPIC the CIs overlap almost everywhere, so it's hard to say any method is better. On the other hand, the Decisions API on CUB and SigLIP2 zero-shot on HICO and LOCO clearly stand apart from the rest.</p>
<p>Below, I go through the tasks where the Decisions API was strong, where it was weak, and where it lost to the Responses API.</p>
<h3 id="where-the-decisions-api-was-strong%3A-hico-and-rvl-cdip" tabindex="-1"><a href="https://nyosegawa.com/en/posts/openai-decisions-api-image-classification/#where-the-decisions-api-was-strong%3A-hico-and-rvl-cdip" class="header-anchor">Where the Decisions API was strong: HICO and RVL-CDIP</a></h3>
<p>On HICO, the Decisions API reached a macro F1 of 74.9%, 9.8 points above the Responses API's 65.1%, and close to the trained SigLIP2 FT (78.8%).</p>
<table>
<thead>
<tr>
<th>Method</th>
<th style="text-align:right">macro F1</th>
<th style="text-align:right">micro F1</th>
<th style="text-align:right">mAP</th>
</tr>
</thead>
<tbody>
<tr>
<td>SigLIP2 zero-shot</td>
<td style="text-align:right">29.5%</td>
<td style="text-align:right">37.9%</td>
<td style="text-align:right">69.1%</td>
</tr>
<tr>
<td>SigLIP2 linear probe</td>
<td style="text-align:right">74.7%</td>
<td style="text-align:right">84.2%</td>
<td style="text-align:right">81.3%</td>
</tr>
<tr>
<td>SigLIP2 FT</td>
<td style="text-align:right">78.8%</td>
<td style="text-align:right">88.0%</td>
<td style="text-align:right">82.7%</td>
</tr>
<tr>
<td>Decisions API</td>
<td style="text-align:right">74.9%</td>
<td style="text-align:right">87.6%</td>
<td style="text-align:right">90.6%</td>
</tr>
<tr>
<td>Responses API</td>
<td style="text-align:right">65.1%</td>
<td style="text-align:right">83.5%</td>
<td style="text-align:right">Not computed</td>
</tr>
<tr>
<td>Codex Agent</td>
<td style="text-align:right">67.5%</td>
<td style="text-align:right">84.2%</td>
<td style="text-align:right">Not computed</td>
</tr>
</tbody>
</table>
<p>HICO asks for 20 actions to be judged independently, which maps directly onto 20 <code>predicate</code> questions. I had the Responses API write all 20 true/false values in one JSON, and its recall was low at 73.5% (84.7% for the Decisions API), with many missed actions. On mAP, which measures ranking by probability, the Decisions API was the highest at 90.6%.</p>
<p>On RVL-CDIP, the Responses API reached 72.0% and the Decisions API 70.0%, more than 10 points above SigLIP2 trained on 10 images per class (59.5%). The Decisions API matched the Responses API's accuracy at a fifth of the latency.</p>
<h3 id="where-the-decisions-api-was-weak%3A-cub" tabindex="-1"><a href="https://nyosegawa.com/en/posts/openai-decisions-api-image-classification/#where-the-decisions-api-was-weak%3A-cub" class="header-anchor">Where the Decisions API was weak: CUB</a></h3>
<p>On CUB, the Decisions API scored 53.0%, the lowest of the six methods and 14 points below the Responses API's 67.0%. Many of the 200 species can only be told apart by feather patterns or beak shape. The Responses API can narrow down the candidates while putting features into words during reasoning, but the Decisions API has to produce probabilities for 200 candidates in a single decision. I think that is where the gap comes from.</p>
<h3 id="where-the-responses-api-was-strong%3A-loco-and-epic" tabindex="-1"><a href="https://nyosegawa.com/en/posts/openai-decisions-api-image-classification/#where-the-responses-api-was-strong%3A-loco-and-epic" class="header-anchor">Where the Responses API was strong: LOCO and EPIC</a></h3>
<p>On LOCO, the Responses API had the highest AUROC at 0.871, and the Decisions API reached 0.817.</p>
<table>
<thead>
<tr>
<th>Method</th>
<th style="text-align:right">Overall</th>
<th style="text-align:right">Logical anomalies</th>
<th style="text-align:right">Structural anomalies</th>
</tr>
</thead>
<tbody>
<tr>
<td>SigLIP2 zero-shot</td>
<td style="text-align:right">0.580</td>
<td style="text-align:right">0.638</td>
<td style="text-align:right">0.522</td>
</tr>
<tr>
<td>SigLIP2 kNN (distance to normal samples)</td>
<td style="text-align:right">0.773</td>
<td style="text-align:right">0.766</td>
<td style="text-align:right">0.780</td>
</tr>
<tr>
<td>SigLIP2 FT (teacher-student)</td>
<td style="text-align:right">0.727</td>
<td style="text-align:right">0.707</td>
<td style="text-align:right">0.747</td>
</tr>
<tr>
<td>Decisions API</td>
<td style="text-align:right">0.817</td>
<td style="text-align:right">0.766</td>
<td style="text-align:right">0.868</td>
</tr>
<tr>
<td>Responses API</td>
<td style="text-align:right">0.871</td>
<td style="text-align:right">0.867</td>
<td style="text-align:right">0.875</td>
</tr>
<tr>
<td>Codex Agent</td>
<td style="text-align:right">0.840</td>
<td style="text-align:right">0.795</td>
<td style="text-align:right">0.884</td>
</tr>
</tbody>
</table>
<p>On structural anomalies such as scratches and stains, the two APIs were about equally accurate. On logical anomalies, where the number or layout of parts is wrong, the Responses API was clearly ahead. To spot these, you need to look at the three normal samples, work out &quot;what should be there, how many, and where,&quot; and compare that with the image being judged. The Responses API was better at problems like this, where you have to compare several images and think.</p>
<p>On EPIC, every method had a low Top-1 accuracy of 14 to 24%, and the 95% CIs overlapped heavily, so there was no clear difference between methods.</p>
<h3 id="a-side-note%3A-choose-a-better-image-model" tabindex="-1"><a href="https://nyosegawa.com/en/posts/openai-decisions-api-image-classification/#a-side-note%3A-choose-a-better-image-model" class="header-anchor">A side note: choose a better image model</a></h3>
<p>So far I have used SigLIP2 as the shared image model, but switching to a model that fits the target easily raises accuracy, even when you only train a linear layer.</p>
<p><img src="https://nyosegawa.com/img/en/openai-decisions-api-image-classification/cub-bioclip.png" alt="CUB with different image encoders"></p>
<p>On CUB, training a linear probe on <a href="https://huggingface.co/imageomics/bioclip-2.5-vith14">BioCLIP 2.5</a>, which is pretrained on images of living things, with the same 2,000 training images gave 93.0% (SigLIP2 FT: 76.0%). Before choosing an API, look for a pretrained model that fits your target (yes, obviously).</p>
<h3 id="accuracy-and-latency" tabindex="-1"><a href="https://nyosegawa.com/en/posts/openai-decisions-api-image-classification/#accuracy-and-latency" class="header-anchor">Accuracy and latency</a></h3>
<p>To see accuracy and latency together, I plotted latency on the x-axis and the metric on the y-axis.</p>
<p><img src="https://nyosegawa.com/img/en/openai-decisions-api-image-classification/score-vs-latency.png" alt="Accuracy vs. latency"></p>
<p>The closer to the top left, the faster and more accurate the method. On HICO and RVL-CDIP, the Decisions API is the furthest top-left among the APIs. Trained SigLIP2 runs locally in 20 to 80 ms per item, almost two orders of magnitude faster than the APIs.</p>
<p>The Agent took 10 to 15 seconds per item on every task, and there was no task where it clearly beat the Responses API. It rarely used cropping or resizing, and I saw no improvement worth the extra time.</p>
<h3 id="how-far-can-you-trust-the-decisions-api's-probabilities%3F" tabindex="-1"><a href="https://nyosegawa.com/en/posts/openai-decisions-api-image-classification/#how-far-can-you-trust-the-decisions-api's-probabilities%3F" class="header-anchor">How far can you trust the Decisions API's probabilities?</a></h3>
<p>Finally, let's check how well the Decisions API's probabilities match actual accuracy (calibration). If 80% of the answers with probability 0.8 are correct, you can use that probability directly to set a threshold.</p>
<p><img src="https://nyosegawa.com/img/en/openai-decisions-api-image-classification/decisions-calibration.png" alt="Reliability diagram for the Decisions API's probabilities"></p>
<p>How far off they were depended on the task and the question type. ECE is the Expected Calibration Error; the closer to 0, the better the probabilities match accuracy.</p>
<ul>
<li><code>predicate</code>
<ul>
<li>HICO: almost on the diagonal, ECE 0.024</li>
<li>LOCO: ranks well (AUROC 0.817), but ECE is 0.254</li>
</ul>
</li>
<li><code>choice</code>
<ul>
<li>CUB: probabilities come out low (mean 37.3%, accuracy 53.0%)</li>
<li>EPIC: probabilities come out high (mean 37.9%, accuracy 19.5%)</li>
<li>RVL-CDIP: fairly close (mean 76.6%, accuracy 70.0%)</li>
</ul>
</li>
</ul>
<p>The Decisions API's probabilities are easy to use for ranking, but whether you can treat their values as accuracy depends on the task. It is safer to check against labeled data before deciding on a threshold.</p>
<h2 id="choosing-a-method" tabindex="-1"><a href="https://nyosegawa.com/en/posts/openai-decisions-api-image-classification/#choosing-a-method" class="header-anchor">Choosing a method</a></h2>
<p>Image recognition methods should be chosen not only by accuracy, but also by latency and cost requirements, whether you can prepare training data, and how hard the problem is. First, here are the numbers for the methods compared in this article.</p>
<table>
<thead>
<tr>
<th></th>
<th>Training an image model (linear probe, FT)</th>
<th>Zero-shot (SigLIP2)</th>
<th>Decisions API</th>
<th>Responses API</th>
<th>Agent</th>
</tr>
</thead>
<tbody>
<tr>
<td>Latency (1 image)</td>
<td>20 to 80 ms (local)</td>
<td>20 to 80 ms (local)</td>
<td>0.3 to 0.9 s</td>
<td>2.4 to 4.2 s</td>
<td>9 to 11 s</td>
</tr>
<tr>
<td>API cost per 1,000 items</td>
<td>None</td>
<td>None</td>
<td>$0.10 to 0.38</td>
<td>$0.18 to 0.51</td>
<td>$0.87 to 1.19 at API prices</td>
</tr>
<tr>
<td>Training data</td>
<td>Required</td>
<td>Not needed</td>
<td>Not needed</td>
<td>Not needed</td>
<td>Not needed</td>
</tr>
<tr>
<td>Good at</td>
<td>Problems with training data and a well-matched model</td>
<td>Coarse classes that can be told apart by name</td>
<td>Classification with a dozen or so candidates, judging several conditions at once</td>
<td>Distinguishing fine details, comparing several images</td>
<td>Problems where zooming in and re-checking the image helps</td>
</tr>
<tr>
<td>Bad at</td>
<td>Little training data and a poorly matched model</td>
<td>Fine-grained judgments like the five tasks here</td>
<td>Fine-grained classification with many candidates</td>
<td>Tight latency requirements</td>
<td>Tight latency or volume requirements</td>
</tr>
</tbody>
</table>
<p>Local methods have no API cost, but you still pay for GPUs and electricity. I ran the Agent on a ChatGPT plan this time, so its cost is the usage converted at API prices. Also, the Agent's &quot;good at&quot; row is an assumption; in this experiment it didn't clearly beat the Responses API on any task.</p>
<p>In practice, methods I didn't compare are also options. Placed by the amount of training data and latency, they look like this.</p>
<p><img src="https://nyosegawa.com/img/en/openai-decisions-api-image-classification/approach-map.png" alt="Where image recognition methods sit"></p>
<ul>
<li>When you have little training data
<ul>
<li>kNN on embeddings: register a few images per class and classify; adding a class needs no training</li>
<li>Few-shot VLM: pass reference images for each class to the Responses API or similar</li>
</ul>
</li>
<li>When the volume is high and API cost or latency doesn't fit
<ul>
<li>Distillation: label data with the Decisions or Responses API and train an image model on those labels</li>
<li>Cascade: let an image model decide first and send only low-probability items to the API</li>
</ul>
</li>
<li>When you can't send images to an external API
<ul>
<li>Local VLM: run an open-weight VLM on your own GPUs</li>
</ul>
</li>
<li>When you have plenty of training data and want a more accurate VLM
<ul>
<li>Fine-tuned VLM: train an open-weight VLM with LoRA or similar</li>
</ul>
</li>
</ul>
<p>In a cascade, the Decisions API's probabilities can serve as the threshold for deciding whether to call the API. But as we just saw, how far off the probabilities are depends on the task, so the threshold has to be set with labeled data.</p>
<p>Putting it together, the order of decisions looks like this.</p>
<ul>
<li>You have plenty of training data
<ul>
<li>Train an image model (start with a linear probe, then FT if needed)</li>
</ul>
</li>
<li>You only have a few images per class
<ul>
<li>Try kNN on embeddings or a few-shot VLM</li>
</ul>
</li>
<li>You have no training data
<ul>
<li>If the volume is high, label with an API and distill, or build a cascade</li>
<li>If you need answers in about a second, the Decisions API</li>
<li>If you can wait a few seconds and need fine details or multi-image comparison, the Responses API</li>
<li>If the volume is low and re-checking images or searching helps, the Agent</li>
</ul>
</li>
<li>You can't send images outside
<ul>
<li>A local VLM, or train an image model</li>
</ul>
</li>
</ul>
<p>Training an image model has the lowest latency and cost, but as RVL-CDIP showed, it loses to the APIs when training data is scarce and the model doesn't fit the target. If you have no training data and aren't sure, the Decisions API is an easy baseline to start with.</p>
<h2 id="how-to-improve-each-method" tabindex="-1"><a href="https://nyosegawa.com/en/posts/openai-decisions-api-image-classification/#how-to-improve-each-method" class="header-anchor">How to improve each method</a></h2>
<p>Every method here was compared with almost default settings, so each still has room to grow.</p>
<h3 id="training-an-image-model" tabindex="-1"><a href="https://nyosegawa.com/en/posts/openai-decisions-api-image-classification/#training-an-image-model" class="header-anchor">Training an image model</a></h3>
<ul>
<li>Choose a pretrained model that fits the target
<ul>
<li>Like BioCLIP 2.5 in the side note, this had the biggest effect in this experiment</li>
</ul>
</li>
<li>Add more training data (about 10 per class this time)</li>
<li>Revisit the FT settings
<ul>
<li>Only 3 epochs this time, so there is room for data augmentation and learning rate tuning</li>
</ul>
</li>
</ul>
<h3 id="decisions-api" tabindex="-1"><a href="https://nyosegawa.com/en/posts/openai-decisions-api-image-classification/#decisions-api" class="header-anchor">Decisions API</a></h3>
<ul>
<li>Optimize the <code>instructions</code> and the <code>description</code> of each <code>choice</code>
<ul>
<li><a href="https://arxiv.org/abs/2507.19457">GEPA</a> is an optimizer that has an LLM reflect on failed examples and rewrite the prompt</li>
<li>In DSPy it is available as <code>dspy.GEPA</code>. With the standalone <a href="https://github.com/gepa-ai/gepa">gepa</a> package you write your own evaluation function, so the Decisions API's question definitions could also be optimized</li>
</ul>
</li>
<li>Narrow down the candidates before asking
<ul>
<li>When there are 200 candidates as in CUB, pass only an image model's Top-10 to <code>choice</code>, or split into two stages, family then species</li>
</ul>
</li>
<li>Calibrate the probabilities
<ul>
<li>Set thresholds with labeled data, or correct them with temperature scaling</li>
</ul>
</li>
<li>Pass reference images for each class along with the input (up to 128 per request)</li>
</ul>
<h3 id="responses-api" tabindex="-1"><a href="https://nyosegawa.com/en/posts/openai-decisions-api-image-classification/#responses-api" class="header-anchor">Responses API</a></h3>
<ul>
<li>Write it as a DSPy module and optimize the prompt with GEPA</li>
<li>Raise the reasoning effort (<code>medium</code> this time)</li>
<li>Narrow down the candidates with an image model first</li>
<li>Pass reference images for each class as few-shot examples</li>
</ul>
<h3 id="agent" tabindex="-1"><a href="https://nyosegawa.com/en/posts/openai-decisions-api-image-classification/#agent" class="header-anchor">Agent</a></h3>
<ul>
<li>Give it task-specific knowledge with Agent Skills</li>
<li>Give it a tool to search for reference images</li>
<li>Let it call a trained image model as a tool, and leave checking the result to the Agent</li>
</ul>
<h2 id="summary" tabindex="-1"><a href="https://nyosegawa.com/en/posts/openai-decisions-api-image-classification/#summary" class="header-anchor">Summary</a></h2>
<ul>
<li>The Decisions API was strong at judging several conditions at once (HICO) and at document classification with little data (RVL-CDIP), and was 1.6 to 11 times faster than the Responses API, but it lost to the Responses API on 200-species fine-grained classification (CUB) and on anomaly detection that requires reading rules from normal samples (LOCO)</li>
<li>If you have labeled data and an image model that fits the target, training an image model wins on both latency and accuracy; on CUB, a linear probe on BioCLIP 2.5 reached 93 to 94%</li>
<li>The Decisions API's probabilities are easy to use for ranking, but calibration varies by task, so check them against labeled data before relying on them</li>
</ul>
<h2 id="appendix" tabindex="-1"><a href="https://nyosegawa.com/en/posts/openai-decisions-api-image-classification/#appendix" class="header-anchor">Appendix</a></h2>
<h3 id="a.-experimental-details" tabindex="-1"><a href="https://nyosegawa.com/en/posts/openai-decisions-api-image-classification/#a.-experimental-details" class="header-anchor">A. Experimental details</a></h3>
<ul>
<li>The image model is <code>google/siglip2-base-patch16-256</code>. Zero-shot uses similarity to text prompts, the linear probe is logistic regression on frozen embeddings, and FT trains the image encoder and classification head for 3 epochs</li>
<li>The APIs and the Agent use <code>gpt-6-luna</code>. Reasoning effort for the Responses API and Codex Agent was <code>medium</code>; no equivalent setting was given to the Decisions API. Images were sent with <code>detail: high</code></li>
<li>For CUB the APIs ran sequentially; for the other tasks each method ran up to 4 requests in parallel. For EPIC and LOCO, the first 2 items were smoke tests and are excluded from latency. Local latency includes image loading, preprocessing, and inference, but not model startup or training</li>
<li>For CUB, I used 10 images per species from the official train split for training, 5 for validation, and 1 per species from the official test split, 200 in total, as the test set. The APIs and the Agent only got the 200 species names, no bounding boxes or part annotations</li>
<li>For HICO, I picked 4 actions each for bicycle, horse, cup, book, and chair, 20 labels in total. Of the 200 test images x 20 labels, only the 682 pairs with official positive/negative annotations (393 positive) are evaluated. This is not comparable with official scores on all 600 HICO labels</li>
<li>For RVL-CDIP, I used 10 images per class for train, 5 for val, and 12 to 13 for test from the public Mini version. OCR results included in the mirror were not given to the models</li>
<li>For EPIC, I used 4 frames at 7.1, 5.1, 3.1, and 1.1 seconds before the target action starts, and split so that no video is shared between train, val, and test. Of the 20 candidates, 14 appear as correct answers in the test set</li>
<li>For LOCO, the 200-image test set over 5 categories consists of 100 normal, 50 logical anomaly, and 50 structural anomaly images. Anomalous images were used neither for training nor for threshold tuning. The APIs and the Agent got 3 normal samples, while the two SigLIP2 methods used 60 normal samples per category, so the amount of information differs between methods</li>
<li>Codex Agent ran in a per-case OS sandbox, and I confirmed that it could be denied reading the directory with the ground-truth labels, rewriting the inputs, and making external network connections from commands</li>
</ul>
<h3 id="b.-what-this-experiment-can't-tell-you" tabindex="-1"><a href="https://nyosegawa.com/en/posts/openai-decisions-api-image-classification/#b.-what-this-experiment-can't-tell-you" class="header-anchor">B. What this experiment can't tell you</a></h3>
<ul>
<li>This is a small comparison with 200 items per task. It doesn't establish which method is better on the full datasets or in general</li>
<li>The APIs and the Agent use no labeled data, while the training-based methods do. Internal image processing also differs between methods</li>
<li>Except for BioCLIP 2.5 in the side note, the image model is the shared SigLIP2, not a dedicated model well tuned for each task. FT is also a small trial with only 3 epochs</li>
<li>Overlap between pretraining data and test images was not checked for any method</li>
<li>Costs are estimates from <code>usage</code> and the price list, not checked against invoices</li>
</ul>
<h3 id="c.-making-the-decisions-api-talk-one-hiragana-character-at-a-time" tabindex="-1"><a href="https://nyosegawa.com/en/posts/openai-decisions-api-image-classification/#c.-making-the-decisions-api-talk-one-hiragana-character-at-a-time" class="header-anchor">C. Making the Decisions API talk one hiragana character at a time</a></h3>
<p>This one is just for fun. Since a <code>choice</code> can have up to 255 candidates, I wondered whether making every hiragana character a candidate would let it hold a conversation by next character prediction instead of next token prediction, so I built it. Of course, this is not what the Decisions API is meant for.</p>
<p><img src="https://nyosegawa.com/img/openai-decisions-api-image-classification/hiragana-chat.jpg" alt="A chat that replies by choosing one hiragana character at a time"></p>
<p>The mechanism is simple.</p>
<ul>
<li>The candidates are 80 hiragana characters, the Japanese comma and period, and <code>&lt;END&gt;</code>, 83 in total</li>
<li>Put the conversation history and the reply so far into <code>input</code>, and have <code>choice</code> pick the next character</li>
<li>Append the chosen character to the reply and ask again (stop at <code>&lt;END&gt;</code> or at 50 characters)</li>
<li>Each candidate's <code>description</code> contains the reply with that character appended</li>
</ul>
<pre><code class="language-javascript">choices: VALUES.map(value =&gt; ({ value, description: value === END
  ? `現在の返答 ${JSON.stringify(prefix)} を完成した回答として終了します。`
  : `次の一文字として「${value}」を追加します。追加後の返答は ${JSON.stringify(prefix + value)} です。` }))
</code></pre>
<p>The median latency per character is about 250 ms, so the characters come out at about human typing speed. But every request sends the whole conversation and the descriptions of all 83 candidates, which costs about 3,000 tokens per character.</p>
<p>As for the conversation, see the screenshot above. To &quot;Hello, nice to see you again today&quot; it replied &quot;あとでね&quot; (&quot;later&quot;), and when I introduced myself it said &quot;あとでね&quot; again. On the other hand, when I asked whether it remembered my name, it correctly answered &quot;なつき&quot; (Natsuki).</p>
<p><img src="https://nyosegawa.com/img/openai-decisions-api-image-classification/hiragana-chat-2.jpg" alt="A short greeting and a run of the same character"></p>
<p>To &quot;Greet me briefly&quot; it replied &quot;ちわ。&quot; (&quot;hey.&quot;), and to &quot;Keep writing あ for 50 characters&quot; it obediently kept lining up &quot;あ&quot;.</p>
<p>Putting 86 example conversations and every per-character choice in them (1,560 steps) into the context as many-shot examples raised the score on predicting the next character of a half-written reply from 5 to 7 out of 8. Even so, when starting a conversation from an empty reply, it often collapsed into repeating the same character, like &quot;おこごごごご...&quot;. Once it picks a strange character, the reply containing it becomes the next input, and I think it can't recover from there.</p>
<h2 id="references" tabindex="-1"><a href="https://nyosegawa.com/en/posts/openai-decisions-api-image-classification/#references" class="header-anchor">References</a></h2>
<ul>
<li>OpenAI Decisions API
<ul>
<li><a href="https://developers.openai.com/api/docs/guides/decisions">Decisions guide</a></li>
<li><a href="https://developers.openai.com/api/reference/resources/decisions/methods/create">Decisions API reference</a></li>
<li><a href="https://developers.openai.com/api/docs/guides/decisions-voice">Decisions with voice</a></li>
<li><a href="https://developers.openai.com/api/docs/changelog#october-2026">Changelog (October 2026)</a></li>
<li><a href="https://platform.openai.com/decisions">Decisions Playground</a></li>
</ul>
</li>
<li>Other OpenAI docs
<ul>
<li><a href="https://developers.openai.com/api/docs/pricing">Pricing</a></li>
<li><a href="https://developers.openai.com/api/docs/models/gpt-6-luna">GPT-6 Luna</a></li>
<li><a href="https://developers.openai.com/api/docs/guides/structured-outputs">Structured Outputs</a></li>
<li><a href="https://developers.openai.com/api/docs/guides/prompt-caching">Prompt caching</a></li>
</ul>
</li>
<li>Prompt optimization
<ul>
<li><a href="https://arxiv.org/abs/2507.19457">GEPA: Reflective Prompt Evolution Can Outperform Reinforcement Learning</a></li>
<li><a href="https://dspy.ai/api/optimizers/GEPA/overview/">dspy.GEPA</a></li>
<li><a href="https://github.com/gepa-ai/gepa">gepa-ai/gepa</a></li>
</ul>
</li>
<li>Image models
<ul>
<li><a href="https://huggingface.co/google/siglip2-base-patch16-256">google/siglip2-base-patch16-256</a></li>
<li><a href="https://huggingface.co/imageomics/bioclip-2.5-vith14">imageomics/bioclip-2.5-vith14</a></li>
</ul>
</li>
<li>Datasets
<ul>
<li><a href="https://www.vision.caltech.edu/datasets/cub_200_2011/">CUB-200-2011</a></li>
<li><a href="https://websites.umich.edu/~ywchao/hico/">HICO</a></li>
<li><a href="https://adamharley.com/rvl-cdip/">RVL-CDIP</a></li>
<li><a href="https://epic-kitchens.github.io/">EPIC-KITCHENS-100</a></li>
<li><a href="https://www.mvtec.com/company/research/datasets/mvtec-loco">MVTec LOCO AD</a></li>
</ul>
</li>
</ul>
]]>
      </content:encoded>
      <pubDate>Sun, 11 Oct 2026 00:00:00 GMT</pubDate>
    </item>
    <item>
      <title>OpenAI Decisions APIで画像認識タスクがどこまでできるか様々な手法と比較してみた</title>
      <link>https://nyosegawa.com/posts/openai-decisions-api-image-classification/</link>
      <guid isPermaLink="false">https://nyosegawa.com/posts/openai-decisions-api-image-classification/</guid>
      <description>
        2026年10月に公開されたOpenAI Decisions APIを、鳥のfine-grained分類・行動認識・書類分類・action anticipation・異常検知の5タスクで、SigLIP2を学習する手法、Responses API、Codex Agentと比較しました。精度・レイテンシ・費用と、確率のキャリブレーションを見ていきます。
      </description>
      <content:encoded>
        <![CDATA[<p>こんにちは！逆瀬川ちゃん (<a href="https://x.com/gyakuse">@gyakuse</a>) です！</p>
<p>今日は2026年10月6日にbetaとして公開されたOpenAI Decisions APIについて、画像認識タスクでどのくらいの精度が出るのかを、昔ながらの画像モデルの学習やResponses API、Coding Agentと比べながら見ていきたいと思います。</p>
<!--more-->
<p>Decisions APIは「この画像に傷はある？」「この書類はどの種類？」といった質問を投げると、答えを文章ではなく確率で返してくれるAPIです。OpenAIはResponses APIの約10倍速いと案内していて、分類やルーティングの用途を想定しています。</p>
<p>分類のAPIと聞くと、画像認識にそのまま使えそうに見えます。ただ、画像認識には教師データで学習した専用モデルという強い競合がいます。そこで今回は、画像分類、マルチラベル分類、行動予測、異常検知という性質の違う5つのタスクを用意して、6つの手法を同じテストセットで比べました。</p>
<h2 id="openai-decisions-api%E3%81%AB%E3%81%A4%E3%81%84%E3%81%A6" tabindex="-1"><a href="https://nyosegawa.com/posts/openai-decisions-api-image-classification/#openai-decisions-api%E3%81%AB%E3%81%A4%E3%81%84%E3%81%A6" class="header-anchor">OpenAI Decisions APIについて</a></h2>
<p>まずはDecisions APIがどういうものかを見ていきます。</p>
<p><img src="https://nyosegawa.com/img/openai-decisions-api-image-classification/decisions-flow.png" alt="Decisions APIの流れ"></p>
<p>Decisions APIには、判定対象（テキスト、画像、またはその両方）と、質問のリストを渡します。質問ごとに判定基準を <code>instructions</code> として自然言語で書いておくと、モデルがそれぞれの答えを確率として返します。エンドポイントは <code>POST /v1/decisions</code> で、対応モデルは今のところ <code>gpt-6-luna</code> だけです。</p>
<p>質問には3つの型があります。</p>
<table>
<thead>
<tr>
<th>型</th>
<th>指定するもの</th>
<th>返ってくるもの</th>
<th>例</th>
</tr>
</thead>
<tbody>
<tr>
<td><code>predicate</code></td>
<td><code>instructions</code> だけ</td>
<td>条件がtrueである確率 <code>probability</code></td>
<td>「商品に傷がある」</td>
</tr>
<tr>
<td><code>choice</code></td>
<td>2〜255個の候補</td>
<td>選ばれた候補 <code>choice</code>、各候補の確率 <code>probabilities</code>、<code>confidence</code></td>
<td>「書類は16種類のどれか」</td>
</tr>
<tr>
<td><code>score</code></td>
<td>低い順に並べた <code>levels</code></td>
<td>levelの番号を確率で重み付けした平均 <code>score</code>、各levelの確率</td>
<td>「不具合の深刻度は3段階のどれか」</td>
</tr>
</tbody>
</table>
<p>同じ入力に対する独立した質問は、1つのリクエストにまとめて投げられます。たとえば「傷があるか」と「商品カテゴリは何か」を一度に聞けます。</p>
<p>レスポンスは次のような形です。今回の実験で鳥の種類を200候補から選ばせたときの実際の出力を、途中を省略して載せています。</p>
<pre><code class="language-json">{
  &quot;model&quot;: &quot;gpt-6-luna&quot;,
  &quot;answers&quot;: [
    {
      &quot;type&quot;: &quot;choice&quot;,
      &quot;name&quot;: &quot;species&quot;,
      &quot;choice&quot;: &quot;186&quot;,
      &quot;probabilities&quot;: [
        {&quot;value&quot;: &quot;0&quot;, &quot;probability&quot;: 0.02},
        {&quot;value&quot;: &quot;1&quot;, &quot;probability&quot;: 0.01},
        ...
        {&quot;value&quot;: &quot;186&quot;, &quot;probability&quot;: 0.38},
        ...
      ],
      &quot;confidence&quot;: 0.38
    }
  ],
  &quot;usage&quot;: {&quot;input_tokens&quot;: 2346, &quot;output_tokens&quot;: 0, ...}
}
</code></pre>
<p><code>output_tokens</code> が0になっているところが特徴的です。Decisions APIは文章を生成せず、候補への確率だけを返します。</p>
<h3 id="responses-api%E3%81%A8%E4%BD%95%E3%81%8C%E9%81%95%E3%81%86%E3%81%AE%E3%81%8B" tabindex="-1"><a href="https://nyosegawa.com/posts/openai-decisions-api-image-classification/#responses-api%E3%81%A8%E4%BD%95%E3%81%8C%E9%81%95%E3%81%86%E3%81%AE%E3%81%8B" class="header-anchor">Responses APIと何が違うのか</a></h3>
<p>固定の候補から1つ選ばせるだけなら、Responses APIのStructured Outputsでも作れます。<code>enum</code> を使ったJSON Schemaを渡せば、候補以外の値は返ってきません。では何が違うのかというと、処理の流れが違います。</p>
<p><img src="https://nyosegawa.com/img/openai-decisions-api-image-classification/responses-vs-decisions.png" alt="Responses APIとDecisions APIの処理の違い"></p>
<p>Responses APIは、reasoningをしてから、文章（JSON）をtokenごとに生成して返します。reasoning effortを上げれば考える量が増えますが、そのぶん時間がかかります。Decisions APIは1回の判定で候補ごとの確率を返すので、レイテンシが短くなります。</p>
<p>もう1つの違いは、確率の性質です。Responses APIに「confidenceを0〜1で書いて」と頼むと数値は返ってきますが、それはモデルが文章として書いた数値です。Decisions APIの確率は候補全体に対する分布として返ってくるので、閾値を決めたり、Top-5を取り出したりする処理にそのまま使えます。この確率がどこまで信用できるかは、後半で確かめます。</p>
<h3 id="%E6%96%99%E9%87%91" tabindex="-1"><a href="https://nyosegawa.com/posts/openai-decisions-api-image-classification/#%E6%96%99%E9%87%91" class="header-anchor">料金</a></h3>
<p>料金もResponses APIとは計算の仕方が違います。同じ <code>gpt-6-luna</code> でも、Decisions APIは入力tokenだけに課金します。</p>
<table>
<thead>
<tr>
<th>課金対象</th>
<th style="text-align:right">Decisions API</th>
<th style="text-align:right">Responses API（gpt-6-luna）</th>
</tr>
</thead>
<tbody>
<tr>
<td>入力</td>
<td style="text-align:right">$0.10 / 1M token</td>
<td style="text-align:right">$0.10 / 1M token</td>
</tr>
<tr>
<td>キャッシュ読み取り</td>
<td style="text-align:right">追加課金なし</td>
<td style="text-align:right">$0.01 / 1M token</td>
</tr>
<tr>
<td>キャッシュ書き込み</td>
<td style="text-align:right">追加課金なし</td>
<td style="text-align:right">$0.125 / 1M token</td>
</tr>
<tr>
<td>出力</td>
<td style="text-align:right">課金なし</td>
<td style="text-align:right">$0.50 / 1M token</td>
</tr>
</tbody>
</table>
<p>入力の単価は同じなので、短いJSONを返させるだけならResponses APIとの差は大きくありません。Responses APIでreasoning tokenや長い出力が発生すると、そのぶん高くなります。逆に、Decisions APIでも質問や候補の説明が長ければ入力tokenが増えるので、常に安いわけではありません。</p>
<h2 id="%E3%81%A9%E3%81%86%E3%81%84%E3%81%86%E3%82%BF%E3%82%B9%E3%82%AF%E3%81%AB%E5%90%91%E3%81%84%E3%81%A6%E3%81%84%E3%82%8B%E3%81%8B" tabindex="-1"><a href="https://nyosegawa.com/posts/openai-decisions-api-image-classification/#%E3%81%A9%E3%81%86%E3%81%84%E3%81%86%E3%82%BF%E3%82%B9%E3%82%AF%E3%81%AB%E5%90%91%E3%81%84%E3%81%A6%E3%81%84%E3%82%8B%E3%81%8B" class="header-anchor">どういうタスクに向いているか</a></h2>
<p>さて、仕組みが分かったところで、Decisions APIが向いているタスクを考えてみます。公式ガイドでは、分類、ルーティング、優先度付けが例に挙がっています。また、音声で操作するアプリで、Live APIが会話を受け持ち、Decisions APIが「次のスライドへ進む」「何もしない」といった操作を選ぶ構成も紹介されています。</p>
<p>これらに共通するのは、次の3つの性質です。</p>
<ul>
<li>答えが固定の候補か、true／falseか、levelのどれかに収まる</li>
<li>毎回すばやく答えが欲しい</li>
<li>答えの確からしさを数値として後段の処理で使いたい</li>
</ul>
<p>逆に、任意のスキーマのJSONで情報を抽出したり、説明文を書かせたり、引数つきでツールを呼ばせたりする場合は、Responses APIのStructured Outputsやfunction callingを使うように公式ガイドでも案内されています。</p>
<p>画像認識の多くは、この「答えが固定の候補に収まる」形で書けます。ただし中身はいろいろで、200種類の鳥を羽の模様で見分ける問題と、人が自転車に乗っているかを判定する問題では、必要な能力がかなり違います。どういう画像認識タスクならDecisions APIで足りるのかを調べるのが、この記事の後半の目的です。</p>
<h2 id="%E4%BD%BF%E3%81%84%E6%96%B9%E3%83%BB%E9%80%9F%E5%BA%A6%E3%83%BB%E8%B2%BB%E7%94%A8" tabindex="-1"><a href="https://nyosegawa.com/posts/openai-decisions-api-image-classification/#%E4%BD%BF%E3%81%84%E6%96%B9%E3%83%BB%E9%80%9F%E5%BA%A6%E3%83%BB%E8%B2%BB%E7%94%A8" class="header-anchor">使い方・速度・費用</a></h2>
<p>その前に、実際の呼び出し方と、今回の実験で測った速度と費用を見ておきます。</p>
<h3 id="%E4%BD%BF%E3%81%84%E6%96%B9" tabindex="-1"><a href="https://nyosegawa.com/posts/openai-decisions-api-image-classification/#%E4%BD%BF%E3%81%84%E6%96%B9" class="header-anchor">使い方</a></h3>
<p>Python SDKでは3.26.0以降で <code>client.decisions.create(...)</code> が使えます。今回の実験コードでは、SDKの汎用の <code>client.post</code> でリクエストを送りました。以下は人と物の関係を判定するHICOタスクで、20個の行動を <code>predicate</code> として1リクエストで聞いた部分です。</p>
<pre><code class="language-python">messages = [{
    &quot;role&quot;: &quot;user&quot;,
    &quot;content&quot;: [
        {&quot;type&quot;: &quot;input_text&quot;, &quot;text&quot;: task},
        {&quot;type&quot;: &quot;input_image&quot;,
         &quot;image_url&quot;: &quot;data:image/png;base64,&quot; + b64,
         &quot;detail&quot;: &quot;high&quot;},
    ],
}]

questions = [
    {
        &quot;type&quot;: &quot;predicate&quot;,
        &quot;name&quot;: f&quot;label_{k}&quot;,
        &quot;instructions&quot;: f&quot;Does the image show {v}? Judge each interaction &quot;
                        &quot;independently. A person and object merely &quot;
                        &quot;co-occurring is insufficient.&quot;,
    }
    for k, v in classes.items()  # 例: &quot;a person riding a bicycle&quot;
]

result = client.post(
    &quot;/decisions&quot;,
    cast_to=dict,
    body={&quot;model&quot;: &quot;gpt-6-luna&quot;, &quot;input&quot;: messages, &quot;questions&quot;: questions},
)
scores = [a[&quot;probability&quot;] for a in result[&quot;answers&quot;]]
</code></pre>
<p>200種類の鳥の分類では、<code>choice</code> 型の質問を1つ作り、候補に200種の種名を並べました。</p>
<pre><code class="language-python">questions = [{
    &quot;type&quot;: &quot;choice&quot;,
    &quot;name&quot;: &quot;species&quot;,
    &quot;instructions&quot;: task,
    &quot;choices&quot;: [{&quot;value&quot;: k, &quot;description&quot;: v} for k, v in classes.items()],
}]
</code></pre>
<p>画像は <code>input_image</code> にbase64のdata URLとして渡しています。1リクエストに入れられる画像は最大128枚です。</p>
<h3 id="%E9%80%9F%E5%BA%A6" tabindex="-1"><a href="https://nyosegawa.com/posts/openai-decisions-api-image-classification/#%E9%80%9F%E5%BA%A6" class="header-anchor">速度</a></h3>
<p>今回の5タスクで測った1件あたりのレイテンシの分布です。</p>
<p><img src="https://nyosegawa.com/img/openai-decisions-api-image-classification/latency-box.png" alt="API手法とAgentのレイテンシ分布"></p>
<table>
<thead>
<tr>
<th>タスク</th>
<th>入力画像</th>
<th style="text-align:right">Decisions</th>
<th style="text-align:right">Responses（medium）</th>
<th style="text-align:right">Codex Agent</th>
<th style="text-align:right">Responses / Decisions</th>
</tr>
</thead>
<tbody>
<tr>
<td>CUB</td>
<td>1枚</td>
<td style="text-align:right">0.31秒</td>
<td style="text-align:right">3.47秒</td>
<td style="text-align:right">9.85秒</td>
<td style="text-align:right">11.4倍</td>
</tr>
<tr>
<td>HICO</td>
<td>1枚</td>
<td style="text-align:right">0.87秒</td>
<td style="text-align:right">4.17秒</td>
<td style="text-align:right">11.12秒</td>
<td style="text-align:right">4.8倍</td>
</tr>
<tr>
<td>RVL-CDIP</td>
<td>1枚</td>
<td style="text-align:right">0.49秒</td>
<td style="text-align:right">2.42秒</td>
<td style="text-align:right">9.27秒</td>
<td style="text-align:right">5.0倍</td>
</tr>
<tr>
<td>EPIC</td>
<td>4枚</td>
<td style="text-align:right">2.85秒</td>
<td style="text-align:right">10.43秒</td>
<td style="text-align:right">14.94秒</td>
<td style="text-align:right">3.7倍</td>
</tr>
<tr>
<td>MVTec LOCO</td>
<td>4枚</td>
<td style="text-align:right">6.29秒</td>
<td style="text-align:right">10.23秒</td>
<td style="text-align:right">14.66秒</td>
<td style="text-align:right">1.6倍</td>
</tr>
</tbody>
</table>
<p>数値は中央値です。画像1枚のタスクでは、Decisions APIは0.3〜0.9秒で返り、Responses APIより約5〜11倍速くなりました。ただし画像を4枚渡すEPICとLOCOでは3〜6秒かかり、差も小さくなっています。Responses APIのreasoning effortは <code>medium</code> です。</p>
<h3 id="%E8%B2%BB%E7%94%A8" tabindex="-1"><a href="https://nyosegawa.com/posts/openai-decisions-api-image-classification/#%E8%B2%BB%E7%94%A8" class="header-anchor">費用</a></h3>
<p>次は1,000件あたりのAPI料金と、1リクエストの入力token数です。</p>
<p><img src="https://nyosegawa.com/img/openai-decisions-api-image-classification/cost-tokens.png" alt="1,000件あたりの料金と入力token"></p>
<p>料金は <code>usage</code> から計算した概算で、5タスク×200件を両方のAPIで回しても合計約$0.59でした。</p>
<p>どちらが安いかはタスクによって逆転しました。</p>
<ul>
<li>HICO：Decisions APIが高い
<ul>
<li>20個の <code>instructions</code> がすべて入力tokenになる</li>
<li>入力tokenはResponses APIの約4.7倍</li>
</ul>
</li>
<li>EPIC・LOCO：Responses APIが約1.7〜4.9倍高い
<ul>
<li>reasoning tokenが出力として課金される</li>
</ul>
</li>
</ul>
<p>整理すると、Decisions APIの費用は質問の設計で決まり、Responses APIの費用はreasoningの量で決まります。</p>
<h2 id="%E7%94%BB%E5%83%8F%E8%AA%8D%E8%AD%98%E3%82%BF%E3%82%B9%E3%82%AF%E3%81%A7%E6%AF%94%E8%BC%83%E3%81%97%E3%81%A6%E3%81%BF%E3%82%8B" tabindex="-1"><a href="https://nyosegawa.com/posts/openai-decisions-api-image-classification/#%E7%94%BB%E5%83%8F%E8%AA%8D%E8%AD%98%E3%82%BF%E3%82%B9%E3%82%AF%E3%81%A7%E6%AF%94%E8%BC%83%E3%81%97%E3%81%A6%E3%81%BF%E3%82%8B" class="header-anchor">画像認識タスクで比較してみる</a></h2>
<p>ここからが本題です。Decisions APIが画像認識でどこまで使えるかを、5つのタスクで調べました。</p>
<h3 id="5%E3%81%A4%E3%81%AE%E3%82%BF%E3%82%B9%E3%82%AF" tabindex="-1"><a href="https://nyosegawa.com/posts/openai-decisions-api-image-classification/#5%E3%81%A4%E3%81%AE%E3%82%BF%E3%82%B9%E3%82%AF" class="header-anchor">5つのタスク</a></h3>
<p><img src="https://nyosegawa.com/img/openai-decisions-api-image-classification/tasks.png" alt="5つの画像タスク"></p>
<table>
<thead>
<tr>
<th>タスク</th>
<th>種類</th>
<th>問題</th>
<th style="text-align:right">train／val／test</th>
<th>評価指標</th>
</tr>
</thead>
<tbody>
<tr>
<td><a href="https://www.vision.caltech.edu/datasets/cub_200_2011/">CUB-200-2011</a></td>
<td>画像分類</td>
<td>鳥の画像から200種のどれかを当てるfine-grained分類</td>
<td style="text-align:right">2,000／1,000／200</td>
<td>Top-1 accuracy</td>
</tr>
<tr>
<td><a href="https://websites.umich.edu/~ywchao/hico/">HICO</a></td>
<td>マルチラベル分類</td>
<td>人と物の関係から20種類の行動を複数ラベルで判定する</td>
<td style="text-align:right">389／195／200</td>
<td>macro F1</td>
</tr>
<tr>
<td><a href="https://adamharley.com/rvl-cdip/">RVL-CDIP</a> Mini</td>
<td>画像分類</td>
<td>書類画像を16種類に分類する</td>
<td style="text-align:right">160／80／200</td>
<td>accuracy</td>
</tr>
<tr>
<td><a href="https://epic-kitchens.github.io/">EPIC-KITCHENS-100</a></td>
<td>行動予測</td>
<td>過去4フレームから次の行動を20候補から予測する（action anticipation）</td>
<td style="text-align:right">400／100／200</td>
<td>Top-1 accuracy</td>
</tr>
<tr>
<td><a href="https://www.mvtec.com/company/research/datasets/mvtec-loco">MVTec LOCO AD</a></td>
<td>異常検知</td>
<td>正常例を基準に、傷などの構造的異常や、個数・配置の論理的異常を見つける</td>
<td style="text-align:right">正常300／正常100／200</td>
<td>カテゴリ平均AUROC</td>
</tr>
</tbody>
</table>
<p>必要な能力がなるべくばらけるように選びました。どのタスクもテストセットは200件なので、95% CIも合わせて見ていきます。</p>
<h3 id="6%E3%81%A4%E3%81%AE%E6%89%8B%E6%B3%95" tabindex="-1"><a href="https://nyosegawa.com/posts/openai-decisions-api-image-classification/#6%E3%81%A4%E3%81%AE%E6%89%8B%E6%B3%95" class="header-anchor">6つの手法</a></h3>
<p><img src="https://nyosegawa.com/img/openai-decisions-api-image-classification/methods.png" alt="比較した6つの手法"></p>
<table>
<thead>
<tr>
<th>手法</th>
<th>内容</th>
</tr>
</thead>
<tbody>
<tr>
<td>SigLIP2 zero-shot</td>
<td>画像と候補のテキストプロンプトの類似度で分類します。対象タスクのラベルでは学習していません</td>
</tr>
<tr>
<td>SigLIP2 linear probe</td>
<td>画像エンコーダをfreezeし、embeddingの上で線形分類器（ロジスティック回帰）を学習します</td>
</tr>
<tr>
<td>SigLIP2 FT</td>
<td>画像エンコーダと分類ヘッドを3 epochs学習します</td>
</tr>
<tr>
<td>Decisions API</td>
<td><code>choice</code> または <code>predicate</code> で確率を取得します</td>
</tr>
<tr>
<td>Responses API</td>
<td>画像と候補を渡し、Structured Outputsで分類結果を取得します（reasoning effort <code>medium</code>）</td>
</tr>
<tr>
<td>Codex Agent</td>
<td><code>codex exec</code> に画像を渡し、cropやresizeをしながら判定させます（reasoning effort <code>medium</code>）</td>
</tr>
</tbody>
</table>
<p>画像モデルには <code>google/siglip2-base-patch16-256</code> を使い、APIとAgentには <code>gpt-6-luna</code> を使いました。APIとAgentには学習データを渡さず、候補の名前とプロンプトだけを渡しています。つまり、SigLIP2のlinear probeとFTは教師データを使い、zero-shot、API、Agentは使わないという条件の違いがあります。</p>
<p>LOCOだけは異常画像で学習できないので、linear probeを「正常例とのkNN距離」に、FTを「正常画像だけで学習するteacher-student方式」に置き換えました。APIとAgentには、正常例3枚と判定対象1枚を渡しています。</p>
<h3 id="%E5%85%A8%E4%BD%93%E3%81%AE%E7%B5%90%E6%9E%9C" tabindex="-1"><a href="https://nyosegawa.com/posts/openai-decisions-api-image-classification/#%E5%85%A8%E4%BD%93%E3%81%AE%E7%B5%90%E6%9E%9C" class="header-anchor">全体の結果</a></h3>
<p>5タスク×6手法の結果を並べると次のようになりました。</p>
<p><img src="https://nyosegawa.com/img/openai-decisions-api-image-classification/overview-heatmap.png" alt="5タスク×6手法の評価指標"></p>
<p>一番良い手法がタスクごとにばらばらです。CUBとHICOでは学習したSigLIP2が、RVL-CDIPとLOCOではResponses APIが、EPICではAgentが最高値でした。Decisions APIはどのタスクでも1位にはなっていませんが、HICOとRVL-CDIPでは1位との差が小さく、CUBでは最下位でした。</p>
<p>ただ、200件の評価では点推定の差がどこまで意味を持つかが問題になります。95%信頼区間（CI）を付けて見てみます。</p>
<p><img src="https://nyosegawa.com/img/openai-decisions-api-image-classification/score-ci.png" alt="各手法の評価指標と95% CI"></p>
<p>EPICは全体的にCIが重なっていて、どの手法が良いとも言いにくい状態です。一方で、CUBのDecisions API、HICOのSigLIP2 zero-shot、LOCOのSigLIP2 zero-shotなどは、明らかに他から離れています。</p>
<p>ここからは、Decisions APIが強かったタスク、弱かったタスク、Responses APIに負けたタスクの順に見ていきます。</p>
<h3 id="decisions-api%E3%81%8C%E5%BC%B7%E3%81%8B%E3%81%A3%E3%81%9F%E3%82%BF%E3%82%B9%E3%82%AF%EF%BC%9Ahico%E3%81%A8rvl-cdip" tabindex="-1"><a href="https://nyosegawa.com/posts/openai-decisions-api-image-classification/#decisions-api%E3%81%8C%E5%BC%B7%E3%81%8B%E3%81%A3%E3%81%9F%E3%82%BF%E3%82%B9%E3%82%AF%EF%BC%9Ahico%E3%81%A8rvl-cdip" class="header-anchor">Decisions APIが強かったタスク：HICOとRVL-CDIP</a></h3>
<p>HICOでは、Decisions APIのmacro F1は74.9%で、Responses APIの65.1%を9.8ポイント上回り、学習したSigLIP2 FT（78.8%）に近い値になりました。</p>
<table>
<thead>
<tr>
<th>手法</th>
<th style="text-align:right">macro F1</th>
<th style="text-align:right">micro F1</th>
<th style="text-align:right">mAP</th>
</tr>
</thead>
<tbody>
<tr>
<td>SigLIP2 zero-shot</td>
<td style="text-align:right">29.5%</td>
<td style="text-align:right">37.9%</td>
<td style="text-align:right">69.1%</td>
</tr>
<tr>
<td>SigLIP2 linear probe</td>
<td style="text-align:right">74.7%</td>
<td style="text-align:right">84.2%</td>
<td style="text-align:right">81.3%</td>
</tr>
<tr>
<td>SigLIP2 FT</td>
<td style="text-align:right">78.8%</td>
<td style="text-align:right">88.0%</td>
<td style="text-align:right">82.7%</td>
</tr>
<tr>
<td>Decisions API</td>
<td style="text-align:right">74.9%</td>
<td style="text-align:right">87.6%</td>
<td style="text-align:right">90.6%</td>
</tr>
<tr>
<td>Responses API</td>
<td style="text-align:right">65.1%</td>
<td style="text-align:right">83.5%</td>
<td style="text-align:right">未集計</td>
</tr>
<tr>
<td>Codex Agent</td>
<td style="text-align:right">67.5%</td>
<td style="text-align:right">84.2%</td>
<td style="text-align:right">未集計</td>
</tr>
</tbody>
</table>
<p>HICOは20個の行動を独立に判定する問題なので、<code>predicate</code> を20個並べる形にそのまま合います。Responses APIには20個のtrue／falseを1つのJSONで書かせましたが、recallが73.5%（Decisions APIは84.7%）と低く、行動の見落としが目立ちました。確率によるランキングで測るmAPでは、Decisions APIが90.6%で最も高くなっています。</p>
<p>RVL-CDIPでは、Responses APIが72.0%、Decisions APIが70.0%で、各クラス10枚で学習したSigLIP2（59.5%）を10ポイント以上上回りました。Decisions APIはResponses APIとほぼ同じ精度を、5分の1のレイテンシで出しています。</p>
<h3 id="decisions-api%E3%81%8C%E5%BC%B1%E3%81%8B%E3%81%A3%E3%81%9F%E3%82%BF%E3%82%B9%E3%82%AF%EF%BC%9Acub" tabindex="-1"><a href="https://nyosegawa.com/posts/openai-decisions-api-image-classification/#decisions-api%E3%81%8C%E5%BC%B1%E3%81%8B%E3%81%A3%E3%81%9F%E3%82%BF%E3%82%B9%E3%82%AF%EF%BC%9Acub" class="header-anchor">Decisions APIが弱かったタスク：CUB</a></h3>
<p>CUBでは、Decisions APIは53.0%で6手法の中で最も低く、Responses APIの67.0%とは14ポイントの差がありました。200種の中には、羽の模様やくちばしの形でしか見分けられない種が多くあります。Responses APIはreasoningで特徴を言葉にしながら候補を絞れますが、Decisions APIは1回の判定で200候補の確率を出すので、この差が出たのだと考えています。</p>
<h3 id="responses-api%E3%81%8C%E5%BC%B7%E3%81%8B%E3%81%A3%E3%81%9F%E3%82%BF%E3%82%B9%E3%82%AF%EF%BC%9Aloco%E3%81%A8epic" tabindex="-1"><a href="https://nyosegawa.com/posts/openai-decisions-api-image-classification/#responses-api%E3%81%8C%E5%BC%B7%E3%81%8B%E3%81%A3%E3%81%9F%E3%82%BF%E3%82%B9%E3%82%AF%EF%BC%9Aloco%E3%81%A8epic" class="header-anchor">Responses APIが強かったタスク：LOCOとEPIC</a></h3>
<p>LOCOでは、Responses APIのAUROCが0.871で最も高く、Decisions APIは0.817でした。</p>
<table>
<thead>
<tr>
<th>手法</th>
<th style="text-align:right">全体</th>
<th style="text-align:right">論理的異常（logical）</th>
<th style="text-align:right">構造的異常（structural）</th>
</tr>
</thead>
<tbody>
<tr>
<td>SigLIP2 zero-shot</td>
<td style="text-align:right">0.580</td>
<td style="text-align:right">0.638</td>
<td style="text-align:right">0.522</td>
</tr>
<tr>
<td>SigLIP2 kNN（正常例との距離）</td>
<td style="text-align:right">0.773</td>
<td style="text-align:right">0.766</td>
<td style="text-align:right">0.780</td>
</tr>
<tr>
<td>SigLIP2 FT（teacher-student）</td>
<td style="text-align:right">0.727</td>
<td style="text-align:right">0.707</td>
<td style="text-align:right">0.747</td>
</tr>
<tr>
<td>Decisions API</td>
<td style="text-align:right">0.817</td>
<td style="text-align:right">0.766</td>
<td style="text-align:right">0.868</td>
</tr>
<tr>
<td>Responses API</td>
<td style="text-align:right">0.871</td>
<td style="text-align:right">0.867</td>
<td style="text-align:right">0.875</td>
</tr>
<tr>
<td>Codex Agent</td>
<td style="text-align:right">0.840</td>
<td style="text-align:right">0.795</td>
<td style="text-align:right">0.884</td>
</tr>
</tbody>
</table>
<p>傷や汚れのような構造的異常では、2つのAPIの精度はほぼ同じでした。一方、部品の個数や配置がおかしい論理的異常では、Responses APIのほうがはっきり高くなりました。これを見抜くには、正常例3枚を見て「何がいくつ、どこにあるべきか」をつかみ、判定する画像と見比べなければなりません。こういう複数の画像を見比べて考える問題は、Responses APIのほうが得意でした。</p>
<p>EPICは全手法でTop-1 accuracyが14〜24%と低く、95% CIも大きく重なっていて、手法の差ははっきりしませんでした。</p>
<h3 id="%E4%BD%99%E8%AB%87%EF%BC%9A%E7%94%BB%E5%83%8F%E3%83%A2%E3%83%87%E3%83%AB%E3%82%92%E9%81%B8%E3%81%B3%E7%9B%B4%E3%81%99" tabindex="-1"><a href="https://nyosegawa.com/posts/openai-decisions-api-image-classification/#%E4%BD%99%E8%AB%87%EF%BC%9A%E7%94%BB%E5%83%8F%E3%83%A2%E3%83%87%E3%83%AB%E3%82%92%E9%81%B8%E3%81%B3%E7%9B%B4%E3%81%99" class="header-anchor">余談：画像モデルを選び直す</a></h3>
<p>ここまでSigLIP2を共通の画像モデルとして使ってきましたが、対象に合うモデルに変えると、線形層だけの学習でもあっさり精度が上がります。</p>
<p><img src="https://nyosegawa.com/img/openai-decisions-api-image-classification/cub-bioclip.png" alt="CUBにおける画像エンコーダの違い"></p>
<p>CUBで、生物の画像で事前学習された<a href="https://huggingface.co/imageomics/bioclip-2.5-vith14">BioCLIP 2.5</a>に同じ学習データ2,000枚でlinear probeを学習すると、93.0%になりました（SigLIP2 FTは76.0%）。APIを選ぶ前に対象に合う事前学習モデルを探すのがよいです（あたりまえ体操）。</p>
<h3 id="%E7%B2%BE%E5%BA%A6%E3%81%A8%E3%83%AC%E3%82%A4%E3%83%86%E3%83%B3%E3%82%B7" tabindex="-1"><a href="https://nyosegawa.com/posts/openai-decisions-api-image-classification/#%E7%B2%BE%E5%BA%A6%E3%81%A8%E3%83%AC%E3%82%A4%E3%83%86%E3%83%B3%E3%82%B7" class="header-anchor">精度とレイテンシ</a></h3>
<p>精度とレイテンシを同時に見るために、横軸にレイテンシ、縦軸に評価指標をとって並べました。</p>
<p><img src="https://nyosegawa.com/img/openai-decisions-api-image-classification/score-vs-latency.png" alt="精度とレイテンシの関係"></p>
<p>左上にあるほど、速くて精度が高い手法です。HICOとRVL-CDIPでは、APIの中でDecisions APIが最も左上にいます。学習したSigLIP2はローカルで1件20〜80msなので、APIより2桁近く速く動きます。</p>
<p>Agentはどのタスクでも1件10〜15秒かかり、Responses APIを明確に上回ったタスクはありませんでした。cropやresizeを使ったケースも少なく、かかる時間に見合う改善は見られませんでした。</p>
<h3 id="decisions-api%E3%81%AE%E7%A2%BA%E7%8E%87%E3%81%AF%E3%81%A9%E3%81%93%E3%81%BE%E3%81%A7%E4%BF%A1%E3%81%98%E3%82%89%E3%82%8C%E3%82%8B%E3%81%8B" tabindex="-1"><a href="https://nyosegawa.com/posts/openai-decisions-api-image-classification/#decisions-api%E3%81%AE%E7%A2%BA%E7%8E%87%E3%81%AF%E3%81%A9%E3%81%93%E3%81%BE%E3%81%A7%E4%BF%A1%E3%81%98%E3%82%89%E3%82%8C%E3%82%8B%E3%81%8B" class="header-anchor">Decisions APIの確率はどこまで信じられるか</a></h3>
<p>最後に、Decisions APIの確率が実際のaccuracyとどれくらい一致しているか（キャリブレーション）を確かめます。確率0.8の答えの8割が正しければ、その確率をそのまま閾値の設定に使えます。</p>
<p><img src="https://nyosegawa.com/img/openai-decisions-api-image-classification/decisions-calibration.png" alt="Decisions APIの確率のreliability diagram"></p>
<p>ずれ方はタスクと質問の型によって違いました。ECEはExpected Calibration Errorで、0に近いほど確率とaccuracyが合っています。</p>
<ul>
<li><code>predicate</code>
<ul>
<li>HICO：ほぼ対角線上で、ECEは0.024</li>
<li>LOCO：ランキングはできる（AUROC 0.817）が、ECEは0.254</li>
</ul>
</li>
<li><code>choice</code>
<ul>
<li>CUB：確率が低めに出る（平均37.3%、accuracy 53.0%）</li>
<li>EPIC：確率が高めに出る（平均37.9%、accuracy 19.5%）</li>
<li>RVL-CDIP：比較的近い（平均76.6%、accuracy 70.0%）</li>
</ul>
</li>
</ul>
<p>Decisions APIの確率はランキングには使いやすい一方、値をそのままaccuracyとして扱えるかはタスクによって違います。閾値はラベル付きのデータで確かめてから決めたほうが安全です。</p>
<h2 id="%E4%BD%BF%E3%81%84%E5%88%86%E3%81%91" tabindex="-1"><a href="https://nyosegawa.com/posts/openai-decisions-api-image-classification/#%E4%BD%BF%E3%81%84%E5%88%86%E3%81%91" class="header-anchor">使い分け</a></h2>
<p>画像認識の手法は、精度だけでなく、レイテンシや費用の要件、学習データを用意できるか、問題の難しさで選ぶ必要があります。まず、今回比べた手法の数字を並べます。</p>
<table>
<thead>
<tr>
<th></th>
<th>画像モデルの学習（linear probe、FT）</th>
<th>zero-shot（SigLIP2）</th>
<th>Decisions API</th>
<th>Responses API</th>
<th>Agent</th>
</tr>
</thead>
<tbody>
<tr>
<td>レイテンシ（画像1枚）</td>
<td>20〜80ms（ローカル）</td>
<td>20〜80ms（ローカル）</td>
<td>0.3〜0.9秒</td>
<td>2.4〜4.2秒</td>
<td>9〜11秒</td>
</tr>
<tr>
<td>1,000件あたりのAPI料金</td>
<td>なし</td>
<td>なし</td>
<td>$0.10〜0.38</td>
<td>$0.18〜0.51</td>
<td>API単価換算で$0.87〜1.19</td>
</tr>
<tr>
<td>学習データ</td>
<td>必要</td>
<td>不要</td>
<td>不要</td>
<td>不要</td>
<td>不要</td>
</tr>
<tr>
<td>得意な問題</td>
<td>学習データと合うモデルがある問題</td>
<td>候補の名前だけで分けられる大まかな分類</td>
<td>候補が十数個の分類、複数条件の同時判定</td>
<td>細部での見分け、複数画像の比較</td>
<td>画像を拡大して調べ直す意味がある問題</td>
</tr>
<tr>
<td>苦手な問題</td>
<td>学習データが少なく、モデルが合わない問題</td>
<td>今回の5タスクのような細かい判定</td>
<td>候補の多いfine-grained分類</td>
<td>レイテンシが厳しい用途</td>
<td>レイテンシや件数の要件が厳しい用途</td>
</tr>
</tbody>
</table>
<p>ローカルの手法のAPI料金はなしですが、GPUや電気代はかかります。Agentは今回ChatGPTの利用枠で動かしたので、料金はusageをAPIの単価で換算した値です。また、Agentの「得意な問題」は想定で、今回はどのタスクでもResponses APIを明確には上回りませんでした。</p>
<p>実際には、今回比べていない手法も候補になります。学習データの量とレイテンシで並べると、次のような位置づけになります。</p>
<p><img src="https://nyosegawa.com/img/openai-decisions-api-image-classification/approach-map.png" alt="画像認識の手法の位置づけ"></p>
<ul>
<li>学習データが少ないときの手法
<ul>
<li>embeddingのkNN：各クラス数枚の画像を登録するだけで分類でき、クラスの追加にも学習が要らない</li>
<li>VLMのfew-shot：Responses APIなどに各クラスの参照画像を一緒に渡す</li>
</ul>
</li>
<li>件数が多く、APIの費用やレイテンシが合わないときの手法
<ul>
<li>蒸留：Decisions APIやResponses APIでラベルを付け、そのラベルで画像モデルを学習する</li>
<li>カスケード：画像モデルで先に判定し、確率が低いものだけAPIに回す</li>
</ul>
</li>
<li>画像を外部のAPIに送れないときの手法
<ul>
<li>ローカルVLM：オープンウェイトのVLMを自前のGPUで動かす</li>
</ul>
</li>
<li>学習データが十分にあり、VLMの精度を上げたいときの手法
<ul>
<li>VLMのFT：オープンウェイトのVLMをLoRAなどで学習する</li>
</ul>
</li>
</ul>
<p>カスケードでは、Decisions APIの確率をAPIに回すかどうかの閾値に使えます。ただし、先ほど見たように確率のずれ方はタスクによって違うので、閾値はラベル付きデータで決める必要があります。</p>
<p>これらをまとめると、判断の順番は次のようになります。</p>
<ul>
<li>学習データが十分にある
<ul>
<li>画像モデルを学習する（まずlinear probe、足りなければFT）</li>
</ul>
</li>
<li>各クラス数枚だけある
<ul>
<li>embeddingのkNNか、VLMのfew-shotを試す</li>
</ul>
</li>
<li>学習データがない
<ul>
<li>件数が多いなら、APIでラベルを付けて蒸留するか、カスケードにする</li>
<li>1秒前後で返したいなら、Decisions API</li>
<li>数秒待てて、細部や複数画像の比較が必要なら、Responses API</li>
<li>件数が少なく、画像の調べ直しや検索に意味があるなら、Agent</li>
</ul>
</li>
<li>画像を外部に送れない
<ul>
<li>ローカルVLMか、画像モデルの学習</li>
</ul>
</li>
</ul>
<p>画像モデルの学習はレイテンシも費用も最も小さいものの、RVL-CDIPのように学習データが少なくモデルが対象に合わないと、APIに負けます。学習データがなく迷うときは、まずDecisions APIをベースラインにするのが手軽です。</p>
<h2 id="%E3%81%9D%E3%82%8C%E3%81%9E%E3%82%8C%E3%81%AE%E6%89%8B%E6%B3%95%E3%82%92%E6%94%B9%E5%96%84%E3%81%99%E3%82%8B%E3%81%AB%E3%81%AF" tabindex="-1"><a href="https://nyosegawa.com/posts/openai-decisions-api-image-classification/#%E3%81%9D%E3%82%8C%E3%81%9E%E3%82%8C%E3%81%AE%E6%89%8B%E6%B3%95%E3%82%92%E6%94%B9%E5%96%84%E3%81%99%E3%82%8B%E3%81%AB%E3%81%AF" class="header-anchor">それぞれの手法を改善するには</a></h2>
<p>今回はどの手法もほぼ初期設定のまま比べたので、それぞれまだ伸ばす余地があります。</p>
<h3 id="%E7%94%BB%E5%83%8F%E3%83%A2%E3%83%87%E3%83%AB%E3%81%AE%E5%AD%A6%E7%BF%92" tabindex="-1"><a href="https://nyosegawa.com/posts/openai-decisions-api-image-classification/#%E7%94%BB%E5%83%8F%E3%83%A2%E3%83%87%E3%83%AB%E3%81%AE%E5%AD%A6%E7%BF%92" class="header-anchor">画像モデルの学習</a></h3>
<ul>
<li>対象に合う事前学習モデルを選ぶ
<ul>
<li>余談のBioCLIP 2.5のように、今回いちばん効果が大きかった方法です</li>
</ul>
</li>
<li>学習データを増やす（今回は各クラス10枚前後）</li>
<li>FTの条件を見直す
<ul>
<li>今回は3 epochsだけなので、data augmentationや学習率の調整の余地があります</li>
</ul>
</li>
</ul>
<h3 id="decisions-api" tabindex="-1"><a href="https://nyosegawa.com/posts/openai-decisions-api-image-classification/#decisions-api" class="header-anchor">Decisions API</a></h3>
<ul>
<li><code>instructions</code> と、<code>choice</code> の <code>description</code> を最適化する
<ul>
<li><a href="https://arxiv.org/abs/2507.19457">GEPA</a>は、失敗した例をLLMに振り返らせてプロンプトを書き換えるoptimizerです</li>
<li>DSPyでは <code>dspy.GEPA</code> として使えます。単体の <a href="https://github.com/gepa-ai/gepa">gepa</a> パッケージなら評価関数を自分で書けるので、Decisions APIの質問定義も最適化の対象にできそうです</li>
</ul>
</li>
<li>候補を絞ってから聞く
<ul>
<li>CUBのように候補が200個ある場合は、画像モデルのTop-10だけを <code>choice</code> に渡したり、科と種の2段階に分けたりします</li>
</ul>
</li>
<li>確率をキャリブレーションする
<ul>
<li>ラベル付きデータで閾値を決めたり、temperature scalingで補正したりします</li>
</ul>
</li>
<li>各クラスの参照画像を一緒に渡す（1リクエスト128枚まで）</li>
</ul>
<h3 id="responses-api" tabindex="-1"><a href="https://nyosegawa.com/posts/openai-decisions-api-image-classification/#responses-api" class="header-anchor">Responses API</a></h3>
<ul>
<li>DSPyのモジュールとして書き、GEPAでプロンプトを最適化する</li>
<li>reasoning effortを上げる（今回は <code>medium</code>）</li>
<li>画像モデルで候補を絞ってから渡す</li>
<li>各クラスの参照画像をfew-shotとして渡す</li>
</ul>
<h3 id="agent" tabindex="-1"><a href="https://nyosegawa.com/posts/openai-decisions-api-image-classification/#agent" class="header-anchor">Agent</a></h3>
<ul>
<li>Agent Skillsでタスク固有の知識を渡す</li>
<li>参照画像を検索するツールを持たせる</li>
<li>学習した画像モデルをツールとして呼ばせ、Agentにはその結果の確認を任せる</li>
</ul>
<h2 id="%E3%81%BE%E3%81%A8%E3%82%81" tabindex="-1"><a href="https://nyosegawa.com/posts/openai-decisions-api-image-classification/#%E3%81%BE%E3%81%A8%E3%82%81" class="header-anchor">まとめ</a></h2>
<ul>
<li>Decisions APIは、複数条件の同時判定（HICO）と少データの書類分類（RVL-CDIP）で強く、Responses APIより1.6〜11倍速い一方、200種のfine-grained分類（CUB）と正常例からルールを読む異常検知（LOCO）ではResponses APIに負けました</li>
<li>教師データと対象に合う画像モデルがあるなら、画像モデルの学習がレイテンシでも精度でも有力で、CUBではBioCLIP 2.5のlinear probeで93〜94%に届きました</li>
<li>Decisions APIの確率はランキングには使いやすいものの、キャリブレーションはタスクによって違うので、ラベル付きデータで確かめてから使うのがおすすめです</li>
</ul>
<h2 id="appendix" tabindex="-1"><a href="https://nyosegawa.com/posts/openai-decisions-api-image-classification/#appendix" class="header-anchor">Appendix</a></h2>
<h3 id="a.-%E5%AE%9F%E9%A8%93%E6%9D%A1%E4%BB%B6%E3%81%AE%E8%A9%B3%E7%B4%B0" tabindex="-1"><a href="https://nyosegawa.com/posts/openai-decisions-api-image-classification/#a.-%E5%AE%9F%E9%A8%93%E6%9D%A1%E4%BB%B6%E3%81%AE%E8%A9%B3%E7%B4%B0" class="header-anchor">A. 実験条件の詳細</a></h3>
<ul>
<li>画像モデルは <code>google/siglip2-base-patch16-256</code> です。zero-shotはテキストプロンプトとの類似度、linear probeはfreezeしたembeddingの上のロジスティック回帰、FTは画像エンコーダと分類ヘッドを3 epochs学習しました</li>
<li>APIとAgentのモデルは <code>gpt-6-luna</code> です。Responses APIとCodex Agentのreasoning effortは <code>medium</code> で、Decisions APIには同等の指定をしていません。画像は <code>detail: high</code> で渡しました</li>
<li>APIは、CUBでは直列実行し、他のタスクでは手法ごとに最大4並列で実行しました。EPICとLOCOでは最初の2件をスモークテストとしてレイテンシの集計から除いています。ローカルのレイテンシには、画像の読み込み・前処理・推論を含み、モデルの起動と学習は含みません</li>
<li>CUBは公式trainから各種10枚をtrain、5枚をvalに使い、公式testから各種1枚の計200枚をテストセットにしました。APIとAgentには200種の種名だけを渡し、鳥のbounding boxや部位のアノテーションは渡していません</li>
<li>HICOは自転車・馬・カップ・本・椅子について各4行動、計20ラベルを選びました。テスト200枚×20ラベルのうち、公式アノテーションでpositive／negativeが分かる682組（positive 393組）だけで評価しています。HICO全600ラベルの公式スコアとは比較できません</li>
<li>RVL-CDIPは公開Mini版から各クラス10枚をtrain、5枚をval、12〜13枚をtestに使いました。ミラーに含まれるOCR結果はモデルに渡していません</li>
<li>EPICは対象行動の開始より7.1、5.1、3.1、1.1秒前の4フレームを使い、train・val・testで同じ動画が混ざらないように分割しました。20候補のうちテストセットで正解として現れたのは14種類です</li>
<li>LOCOは5カテゴリについて、テストセット200枚を正常100枚、論理的異常50枚、構造的異常50枚で構成しました。異常画像は学習にも閾値調整にも使っていません。APIとAgentには正常例3枚、SigLIP2の2手法には各カテゴリ60枚の正常例を使ったので、手法間で使える情報量は同じではありません</li>
<li>Codex AgentはケースごとのOS sandboxで動かし、正解ラベルを含むディレクトリの読み取り、入力の書き換え、コマンドからの外部通信を拒否できることを確認しました</li>
</ul>
<h3 id="b.-%E3%81%93%E3%81%AE%E5%AE%9F%E9%A8%93%E3%81%A7%E8%A8%80%E3%81%88%E3%81%AA%E3%81%84%E3%81%93%E3%81%A8" tabindex="-1"><a href="https://nyosegawa.com/posts/openai-decisions-api-image-classification/#b.-%E3%81%93%E3%81%AE%E5%AE%9F%E9%A8%93%E3%81%A7%E8%A8%80%E3%81%88%E3%81%AA%E3%81%84%E3%81%93%E3%81%A8" class="header-anchor">B. この実験で言えないこと</a></h3>
<ul>
<li>各タスク200件の小規模な比較です。データセット全体や手法一般の優劣を断定するものではありません</li>
<li>APIとAgentは教師データを使わず、学習ベースの手法は使います。また、内部の画像処理は手法ごとに異なります</li>
<li>画像モデルは余談のBioCLIP 2.5を除いて共通のSigLIP2で、各タスクの専用モデルを十分に調整した比較ではありません。FTも3 epochsの小規模な試行です</li>
<li>事前学習データとテスト画像の重複は、どの手法でも確認できていません</li>
<li>料金は <code>usage</code> と料金表から計算した概算で、請求書とは照合していません</li>
</ul>
<h3 id="c.-decisions-api%E3%81%A7%E3%81%B2%E3%82%89%E3%81%8C%E3%81%AA%E3%82%921%E5%AD%97%E3%81%9A%E3%81%A4%E3%81%97%E3%82%83%E3%81%B9%E3%82%89%E3%81%9B%E3%82%8B" tabindex="-1"><a href="https://nyosegawa.com/posts/openai-decisions-api-image-classification/#c.-decisions-api%E3%81%A7%E3%81%B2%E3%82%89%E3%81%8C%E3%81%AA%E3%82%921%E5%AD%97%E3%81%9A%E3%81%A4%E3%81%97%E3%82%83%E3%81%B9%E3%82%89%E3%81%9B%E3%82%8B" class="header-anchor">C. Decisions APIでひらがなを1字ずつしゃべらせる</a></h3>
<p>おまけのあそびです。<code>choice</code> の候補は最大255個なので、ひらがなを全部候補にすれば、next token predictionではなくnext character predictionで会話できるのでは、と思って作ってみました。もちろん、Decisions APIに向いた使い方ではありません。</p>
<p><img src="https://nyosegawa.com/img/openai-decisions-api-image-classification/hiragana-chat.jpg" alt="ひらがなを1字ずつ選んで会話するチャット"></p>
<p>仕組みは単純です。</p>
<ul>
<li>候補はひらがな80字、読点、句点、<code>&lt;END&gt;</code> の83個</li>
<li>会話履歴と、ここまで出た返答を <code>input</code> に入れて、次の1字を <code>choice</code> で選ばせる</li>
<li>選ばれた1字を返答の末尾に足して、また聞く（<code>&lt;END&gt;</code> が出るか、50字で止める）</li>
<li>各候補の <code>description</code> には、その1字を足したあとの返答を書いておく</li>
</ul>
<pre><code class="language-javascript">choices: VALUES.map(value =&gt; ({ value, description: value === END
  ? `現在の返答 ${JSON.stringify(prefix)} を完成した回答として終了します。`
  : `次の一文字として「${value}」を追加します。追加後の返答は ${JSON.stringify(prefix + value)} です。` }))
</code></pre>
<p>1字あたりのレイテンシは中央値で約250msなので、人がタイピングするくらいの速さで文字が出てきます。ただし毎回、会話全体と83候補の説明を送るので、1字あたり約3,000 tokenを使います。</p>
<p>会話のほうは、上の画像のとおりです。「こんにちは。今日もよろしくね」には「あとでね」と返され、名乗っても「あとでね」でした。一方で、名前を聞くと「なつき」とちゃんと答えます。</p>
<p><img src="https://nyosegawa.com/img/openai-decisions-api-image-classification/hiragana-chat-2.jpg" alt="短いあいさつと、同じ文字の繰り返し"></p>
<p>「短くあいさつして」には「ちわ。」と返し、「あを50文字続けて」には素直に「あ」を並べ続けました。</p>
<p>86件の会話例と、そのすべての1字分の選択（1,560段階）をmany-shotとして入れると、書きかけの返答から次の1字を当てる問題は8問中5問から7問に上がりました。それでも、空の返答から会話を始めると「おこごごごご…」のように同じ文字を繰り返して崩れることが多くありました。一度おかしな1字を選ぶと、それを含んだ返答が次の入力になるので、そこから戻れなくなるのだと思います。</p>
<p><img src="https://nyosegawa.com/img/openai-decisions-api-image-classification/hiragana-chat-5.jpg" alt="新海誠の人気作を聞くと"></p>
<p><img src="https://nyosegawa.com/img/openai-decisions-api-image-classification/hiragana-chat-3.jpg" alt="名前を聞くと"></p>
<p><img src="https://nyosegawa.com/img/openai-decisions-api-image-classification/hiragana-chat-4.jpg" alt="好きな漫画を聞くと"></p>
<p>こいつ…</p>
<h2 id="references" tabindex="-1"><a href="https://nyosegawa.com/posts/openai-decisions-api-image-classification/#references" class="header-anchor">References</a></h2>
<ul>
<li>OpenAI Decisions API
<ul>
<li><a href="https://developers.openai.com/api/docs/guides/decisions">Decisions guide</a></li>
<li><a href="https://developers.openai.com/api/reference/resources/decisions/methods/create">Decisions API reference</a></li>
<li><a href="https://developers.openai.com/api/docs/guides/decisions-voice">Decisions with voice</a></li>
<li><a href="https://developers.openai.com/api/docs/changelog#october-2026">Changelog (October 2026)</a></li>
<li><a href="https://platform.openai.com/decisions">Decisions Playground</a></li>
</ul>
</li>
<li>OpenAI その他
<ul>
<li><a href="https://developers.openai.com/api/docs/pricing">Pricing</a></li>
<li><a href="https://developers.openai.com/api/docs/models/gpt-6-luna">GPT-6 Luna</a></li>
<li><a href="https://developers.openai.com/api/docs/guides/structured-outputs">Structured Outputs</a></li>
<li><a href="https://developers.openai.com/api/docs/guides/prompt-caching">Prompt caching</a></li>
</ul>
</li>
<li>プロンプト最適化
<ul>
<li><a href="https://arxiv.org/abs/2507.19457">GEPA: Reflective Prompt Evolution Can Outperform Reinforcement Learning</a></li>
<li><a href="https://dspy.ai/api/optimizers/GEPA/overview/">dspy.GEPA</a></li>
<li><a href="https://github.com/gepa-ai/gepa">gepa-ai/gepa</a></li>
</ul>
</li>
<li>画像モデル
<ul>
<li><a href="https://huggingface.co/google/siglip2-base-patch16-256">google/siglip2-base-patch16-256</a></li>
<li><a href="https://huggingface.co/imageomics/bioclip-2.5-vith14">imageomics/bioclip-2.5-vith14</a></li>
</ul>
</li>
<li>データセット
<ul>
<li><a href="https://www.vision.caltech.edu/datasets/cub_200_2011/">CUB-200-2011</a></li>
<li><a href="https://websites.umich.edu/~ywchao/hico/">HICO</a></li>
<li><a href="https://adamharley.com/rvl-cdip/">RVL-CDIP</a></li>
<li><a href="https://epic-kitchens.github.io/">EPIC-KITCHENS-100</a></li>
<li><a href="https://www.mvtec.com/company/research/datasets/mvtec-loco">MVTec LOCO AD</a></li>
</ul>
</li>
</ul>
]]>
      </content:encoded>
      <pubDate>Sun, 11 Oct 2026 00:00:00 GMT</pubDate>
    </item>
    <item>
      <title>個人情報漏洩が日常になってしまった世界でどうしていくべきか</title>
      <link>https://nyosegawa.com/posts/data-leak-era/</link>
      <guid isPermaLink="false">https://nyosegawa.com/posts/data-leak-era/</guid>
      <description>2026年は大規模な個人情報の漏洩が続いています。いま何が流出しているのか、流出した情報でどんな連絡が届くのか、わたしたちは何をすればいいのかを、グラフと例でまとめます。</description>
      <content:encoded>
        <![CDATA[<p>こんにちは！逆瀬川ちゃん (<a href="https://x.com/gyakuse">@gyakuse</a>) です！</p>
<p>今日は個人情報がどんどん流出していく時代に、わたしたちは何をすればいいのかを考えていきたいと思います。</p>
<!--more-->
<h2 id="%E4%BB%8A%E9%80%B1%E3%81%A0%E3%81%91%E3%81%A7%E3%82%82" tabindex="-1"><a href="https://nyosegawa.com/posts/data-leak-era/#%E4%BB%8A%E9%80%B1%E3%81%A0%E3%81%91%E3%81%A7%E3%82%82" class="header-anchor">今週だけでも</a></h2>
<p>さいきん個人情報の漏洩のニュースが本当に多いです。10月7日から9日にかけては政府機関が相次いで「不正アクセスによる漏洩が続いている」と注意を呼びかけていました。同じ週だけでもスカイチケット(約1,464万件)、ビッグエコー(約872万件)、ブックオフ(最大約643万件)、ローソンID(約216万件)の漏洩が公表されています。これらに登録しているひとはけっこう多そうです。</p>
<p>そこで国内外の漏洩と、流出した情報が悪用された例を集めてみました。</p>
<h2 id="%E3%81%84%E3%81%BE%E3%81%A9%E3%82%8C%E3%81%8F%E3%82%89%E3%81%84%E6%B5%81%E5%87%BA%E3%81%97%E3%81%A6%E3%81%84%E3%82%8B%E3%81%AE%E3%81%8B" tabindex="-1"><a href="https://nyosegawa.com/posts/data-leak-era/#%E3%81%84%E3%81%BE%E3%81%A9%E3%82%8C%E3%81%8F%E3%82%89%E3%81%84%E6%B5%81%E5%87%BA%E3%81%97%E3%81%A6%E3%81%84%E3%82%8B%E3%81%AE%E3%81%8B" class="header-anchor">いまどれくらい流出しているのか</a></h2>
<p><img src="https://nyosegawa.com/img/data-leak-era/tsr-over-1m.png" alt="上場企業の100万件以上の漏洩の件数"></p>
<p>東京商工リサーチの集計を見ると、上場企業の100万件以上の漏洩は2025年が1年で6件でした。2026年は10月5日の時点ですでに10件あります。1,000万件以上の漏洩にいたっては2025年までの14年間で3件だったのに、2026年だけで3件起きています。</p>
<p>上場していない会社も含めてこれまでの大きな漏洩と並べるとこうなります。</p>
<p><img src="https://nyosegawa.com/img/data-leak-era/history.png" alt="国内の大きな漏洩"></p>
<p>青が2026年に公表されたものです。「歴代最大級」と言われたベネッセ(2014年)に近い規模の漏洩が今年はいくつも起きています。</p>
<h3 id="%E4%BD%95%E3%81%8C%E6%B5%81%E5%87%BA%E3%81%97%E3%81%9F%E3%81%8B" tabindex="-1"><a href="https://nyosegawa.com/posts/data-leak-era/#%E4%BD%95%E3%81%8C%E6%B5%81%E5%87%BA%E3%81%97%E3%81%9F%E3%81%8B" class="header-anchor">何が流出したか</a></h3>
<p><img src="https://nyosegawa.com/img/data-leak-era/datatype.png" alt="流出した情報の種類"></p>
<p>氏名、メールアドレス、電話番号はほとんどの漏洩に入っています。今年は身分証の画像まで流出し始めました。タイムズカーでは運転免許証などの画像が約160万件流出して、金融庁が金融機関に画像での本人確認をやめるよう求めるところまで来ています。</p>
<h3 id="%E3%81%A9%E3%81%93%E3%81%8B%E3%82%89%E6%BC%8F%E6%B4%A9%E3%81%97%E3%81%9F%E3%81%8B" tabindex="-1"><a href="https://nyosegawa.com/posts/data-leak-era/#%E3%81%A9%E3%81%93%E3%81%8B%E3%82%89%E6%BC%8F%E6%B4%A9%E3%81%97%E3%81%9F%E3%81%8B" class="header-anchor">どこから漏洩したか</a></h3>
<p><img src="https://nyosegawa.com/img/data-leak-era/cause.png" alt="漏洩の原因"></p>
<p>アプリやシステムの弱点、盗まれたパスワード、ランサムウェア、委託先などが並んでいます。どれも利用者の側ではどうしようもないものばかりで、3分の1は原因も公表されていません。</p>
<p>自分がどれだけ気をつけていても、漏洩は止められません。次は、流出した情報がそのあとどう使われるのかを見ていきます。</p>
<aside class="promo">
<p class="promo-label">ここでいったんCMです。</p>
<p class="promo-message">デスクトップ向けのアシスタントを作りました！よかったら使ってみてください。</p>
<p><a href="https://asist-agent.com/"><img src="https://nyosegawa.com/img/speech-cpp/asist-banner.jpg" alt="ASIST: 話しかけるだけで、予定もメールも片づく。Mac と Windows で使えるリアルタイムアシスタント"></a></p>
<p class="promo-links"><a class="promo-button promo-primary" href="https://asist-agent.com/">公式サイトを見る</a><a class="promo-button" href="https://github.com/nyosegawa/asist">GitHub</a></p>
</aside>
<h2 id="%E6%B5%81%E5%87%BA%E3%81%97%E3%81%9F%E3%82%89%E4%BD%95%E3%81%8C%E8%B5%B7%E3%81%8D%E3%82%8B%E3%81%AE%E3%81%8B" tabindex="-1"><a href="https://nyosegawa.com/posts/data-leak-era/#%E6%B5%81%E5%87%BA%E3%81%97%E3%81%9F%E3%82%89%E4%BD%95%E3%81%8C%E8%B5%B7%E3%81%8D%E3%82%8B%E3%81%AE%E3%81%8B" class="header-anchor">流出したら何が起きるのか</a></h2>
<h3 id="%E3%81%B0%E3%82%89%E3%81%B0%E3%82%89%E3%81%AE%E6%83%85%E5%A0%B1%E3%81%8C1%E4%BA%BA%E5%88%86%E3%81%AB%E3%81%BE%E3%81%A8%E3%81%BE%E3%82%8B" tabindex="-1"><a href="https://nyosegawa.com/posts/data-leak-era/#%E3%81%B0%E3%82%89%E3%81%B0%E3%82%89%E3%81%AE%E6%83%85%E5%A0%B1%E3%81%8C1%E4%BA%BA%E5%88%86%E3%81%AB%E3%81%BE%E3%81%A8%E3%81%BE%E3%82%8B" class="header-anchor">ばらばらの情報が1人分にまとまる</a></h3>
<p><img src="https://nyosegawa.com/img/data-leak-era/profile.png" alt="犯人が1人分の情報をまとめた例"></p>
<p>1つのサービスから流出するのはその人の情報の一部だけです。ところがメールアドレスや電話番号が同じものをつなげていくと、上のように1人分の詳しい情報がまとまってしまいます。ここから先の例は会社も人物も番号も顔の画像もすべて架空のもので、黄色の部分が流出した情報から分かるところです。</p>
<p>こういう情報を持った相手から次のような連絡が届きます。</p>
<h3 id="%E3%80%8C%E6%BC%8F%E6%B4%A9%E3%81%AE%E3%81%8A%E8%A9%AB%E3%81%B3%E3%80%8D%E3%81%AE%E3%83%A1%E3%83%BC%E3%83%AB" tabindex="-1"><a href="https://nyosegawa.com/posts/data-leak-era/#%E3%80%8C%E6%BC%8F%E6%B4%A9%E3%81%AE%E3%81%8A%E8%A9%AB%E3%81%B3%E3%80%8D%E3%81%AE%E3%83%A1%E3%83%BC%E3%83%AB" class="header-anchor">「漏洩のお詫び」のメール</a></h3>
<p><img src="https://nyosegawa.com/img/data-leak-era/mock-apology-mail.png" alt="漏洩のお詫びを装うメールの例"></p>
<p>プロバイダーのインターリンクを名乗って、こんな感じの偽の「流出のお詫び」メールが実際に送られています。面白いのは(面白くはないんですが)、インターリンク自体は漏洩していないことです。お詫びメールに似せた文面なら、どの会社の名前でも詐欺に使えてしまいます。ちなみにこの秋の大きな漏洩のあとに偽のお詫びメールが届いたという報告はまだありません。</p>
<h3 id="%E4%BA%88%E7%B4%84%E7%95%AA%E5%8F%B7%E3%82%92%E7%9F%A5%E3%81%A3%E3%81%A6%E3%81%84%E3%82%8B%E3%83%A1%E3%83%83%E3%82%BB%E3%83%BC%E3%82%B8" tabindex="-1"><a href="https://nyosegawa.com/posts/data-leak-era/#%E4%BA%88%E7%B4%84%E7%95%AA%E5%8F%B7%E3%82%92%E7%9F%A5%E3%81%A3%E3%81%A6%E3%81%84%E3%82%8B%E3%83%A1%E3%83%83%E3%82%BB%E3%83%BC%E3%82%B8" class="header-anchor">予約番号を知っているメッセージ</a></h3>
<img src="https://nyosegawa.com/img/data-leak-era/mock-booking.png" alt="ホテルの予約番号を書いた偽のメッセージの例" width="340">
<p>2026年5月にはBooking.comでホテルを予約した人にこんなメッセージがWhatsAppで届いています。氏名も予約番号も宿泊日もすべて正しかったそうです。これはさすがに信じてしまいそうです。</p>
<h3 id="%E3%80%8C%E8%AD%A6%E5%AF%9F%E3%80%8D%E3%81%8B%E3%82%89%E3%81%AE%E9%9B%BB%E8%A9%B1" tabindex="-1"><a href="https://nyosegawa.com/posts/data-leak-era/#%E3%80%8C%E8%AD%A6%E5%AF%9F%E3%80%8D%E3%81%8B%E3%82%89%E3%81%AE%E9%9B%BB%E8%A9%B1" class="header-anchor">「警察」からの電話</a></h3>
<img src="https://nyosegawa.com/img/data-leak-era/mock-police-call.png" alt="自分の情報を言い当てる警察を名乗る電話の例" width="340">
<p>ニセ警察詐欺の被害は2025年だけで約1,005億円ありました。トビラシステムズの調査では、本物だと信じた人の54.1%が「相手が自分の情報を知っていたから」と答えています。</p>
<h3 id="%E9%A1%94%E3%81%8C%E8%A6%8B%E3%81%88%E3%82%8B%E3%83%93%E3%83%87%E3%82%AA%E9%80%9A%E8%A9%B1" tabindex="-1"><a href="https://nyosegawa.com/posts/data-leak-era/#%E9%A1%94%E3%81%8C%E8%A6%8B%E3%81%88%E3%82%8B%E3%83%93%E3%83%87%E3%82%AA%E9%80%9A%E8%A9%B1" class="header-anchor">顔が見えるビデオ通話</a></h3>
<img src="https://nyosegawa.com/img/data-leak-era/mock-video-call.png" alt="AIで作った顔の警察官とのビデオ通話の例" width="340">
<p>2025年にはAIで作った顔の「警察官」がビデオ通話に出てくる詐欺グループが逮捕されています。ちなみにこの画像の男性もAIで作った架空の人物です。実在の人に見えないように、この記事の人物はわざと3DCG風にしています。</p>
<h3 id="%E8%87%AA%E5%AE%85%E3%81%AB%E5%B1%8A%E3%81%8F%E6%89%8B%E7%B4%99" tabindex="-1"><a href="https://nyosegawa.com/posts/data-leak-era/#%E8%87%AA%E5%AE%85%E3%81%AB%E5%B1%8A%E3%81%8F%E6%89%8B%E7%B4%99" class="header-anchor">自宅に届く手紙</a></h3>
<p><img src="https://nyosegawa.com/img/data-leak-era/mock-letter.png" alt="補償金の案内を装う手紙の例"></p>
<p>海外では暗号資産ウォレットのLedgerを名乗る偽の手紙が届いています。日本でも2024年に国民生活センターを名乗る電話とハガキがありました。</p>
<h3 id="%E3%81%A9%E3%82%93%E3%81%AA%E6%89%8B%E6%AE%B5%E3%81%A7%E6%9D%A5%E3%82%8B%E3%81%8B" tabindex="-1"><a href="https://nyosegawa.com/posts/data-leak-era/#%E3%81%A9%E3%82%93%E3%81%AA%E6%89%8B%E6%AE%B5%E3%81%A7%E6%9D%A5%E3%82%8B%E3%81%8B" class="header-anchor">どんな手段で来るか</a></h3>
<p><img src="https://nyosegawa.com/img/data-leak-era/channels.png" alt="流出した情報の悪用はどんな手段で来たか"></p>
<p>集めた例を数えてみると、いちばん多いのは電話でメールよりも多くなっていました。</p>
<h2 id="%E9%A1%94%E3%82%82%E5%A3%B0%E3%82%82ai%E3%81%A7%E4%BD%9C%E3%82%8C%E3%82%8B" tabindex="-1"><a href="https://nyosegawa.com/posts/data-leak-era/#%E9%A1%94%E3%82%82%E5%A3%B0%E3%82%82ai%E3%81%A7%E4%BD%9C%E3%82%8C%E3%82%8B" class="header-anchor">顔も声もAIで作れる</a></h2>
<p><img src="https://nyosegawa.com/img/data-leak-era/face-material.png" alt="Webサイトの顔の画像1枚からビデオ会議の画面を作る"></p>
<p>試しに左の画像1枚だけを元にして、ビデオ会議に映っているような右の画像を作ってみたら、約20秒でできました。左の画像は会社の役員紹介ページに載っている写真を想定したもので、どちらもAIで作った架空の人物です。ここではわざと3DCG風にしていますが、写真と見分けがつかないような画像も同じくらいの手間で作れます。</p>
<p>実際に香港では偽の上司や同僚が出てくるビデオ会議で約2,500万ドルが送金されています(2024年)。イタリアでは国防相の声をまねた電話で約100万ユーロが送金されました(2025年)。</p>
<h2 id="%E3%81%93%E3%82%8C%E3%81%8B%E3%82%89%E5%A2%97%E3%81%88%E3%81%9D%E3%81%86%E3%81%AA%E3%81%93%E3%81%A8" tabindex="-1"><a href="https://nyosegawa.com/posts/data-leak-era/#%E3%81%93%E3%82%8C%E3%81%8B%E3%82%89%E5%A2%97%E3%81%88%E3%81%9D%E3%81%86%E3%81%AA%E3%81%93%E3%81%A8" class="header-anchor">これから増えそうなこと</a></h2>
<p><img src="https://nyosegawa.com/img/data-leak-era/mock-ai-support.png" alt="犯人がなりすましてAIのサポートに頼む例"></p>
<p>AIのサポートがだまされる例も出てきました。Meta AIのサポートをだましてInstagramを乗っ取る手口では、Metaが少なくとも2万225人に通知しています。この場合は本物の利用者には何の知らせも来ないのがつらいところです。</p>
<p>ほかにも自分のAIアシスタントがメールに隠された指示で動かされたり、会話できるAIが一人ひとりに合わせた詐欺電話を大量にかけたりするのは、そう遠くないうちに起きそうだな〜と思っています。</p>
<h2 id="%E3%82%8F%E3%81%9F%E3%81%97%E3%81%9F%E3%81%A1%E3%81%AB%E3%81%A7%E3%81%8D%E3%82%8B%E3%81%93%E3%81%A8" tabindex="-1"><a href="https://nyosegawa.com/posts/data-leak-era/#%E3%82%8F%E3%81%9F%E3%81%97%E3%81%9F%E3%81%A1%E3%81%AB%E3%81%A7%E3%81%8D%E3%82%8B%E3%81%93%E3%81%A8" class="header-anchor">わたしたちにできること</a></h2>
<h3 id="%E4%BF%A1%E3%81%98%E3%82%8B%E3%81%8B%E3%81%A9%E3%81%86%E3%81%8B%E3%81%AE%E6%B1%BA%E3%82%81%E6%96%B9%E3%82%92%E5%A4%89%E3%81%88%E3%82%8B" tabindex="-1"><a href="https://nyosegawa.com/posts/data-leak-era/#%E4%BF%A1%E3%81%98%E3%82%8B%E3%81%8B%E3%81%A9%E3%81%86%E3%81%8B%E3%81%AE%E6%B1%BA%E3%82%81%E6%96%B9%E3%82%92%E5%A4%89%E3%81%88%E3%82%8B" class="header-anchor">信じるかどうかの決め方を変える</a></h3>
<p>相手が自分の情報を知っていても、顔が見えても、声が家族でも、本物とは限りません。連絡が来たら公式アプリや自分でブックマークしたサイト、カードの裏の電話番号から自分で確かめるのがいちばんです。</p>
<p>届いたリンクや電話番号やQRコードは使わないようにします。「急いで」「誰にも言わないで」「補償が受けられなくなる」と言われたら、いったん手を止めます。警察が電話やビデオ通話でお金の話をすることはありません。職場なら振込先の変更はメール以外の方法で確かめるようにしておくと安心です。</p>
<h3 id="%E7%A7%81%E3%81%9F%E3%81%A1%E3%81%8C%E3%82%84%E3%82%8C%E3%82%8B%E3%81%93%E3%81%A8" tabindex="-1"><a href="https://nyosegawa.com/posts/data-leak-era/#%E7%A7%81%E3%81%9F%E3%81%A1%E3%81%8C%E3%82%84%E3%82%8C%E3%82%8B%E3%81%93%E3%81%A8" class="header-anchor">私たちがやれること</a></h3>
<p>すぐできることを大事な順に並べるとこうなります。</p>
<ol>
<li>メールとApple・Googleのアカウントにパスキーか2段階認証を設定します</li>
<li>パスワードの使い回しをやめます。パスワードマネージャーを使うのが楽です</li>
<li>カードと銀行の利用通知をオンにします</li>
<li>携帯電話会社の契約に暗証番号を設定します</li>
<li>家族と合言葉を決めておきます</li>
</ol>
<p>Meta AIの件で被害に遭ったのも2段階認証をしていないアカウントでした。1と2をやっておくだけでも、流出した情報だけでは乗っ取られにくくなります。</p>
<h3 id="%E6%BC%8F%E6%B4%A9%E3%81%AE%E3%81%8A%E7%9F%A5%E3%82%89%E3%81%9B%E3%81%8C%E6%9D%A5%E3%81%9F%E3%82%89" tabindex="-1"><a href="https://nyosegawa.com/posts/data-leak-era/#%E6%BC%8F%E6%B4%A9%E3%81%AE%E3%81%8A%E7%9F%A5%E3%82%89%E3%81%9B%E3%81%8C%E6%9D%A5%E3%81%9F%E3%82%89" class="header-anchor">漏洩のお知らせが来たら</a></h3>
<table>
<thead>
<tr>
<th>流出したもの</th>
<th>やること</th>
</tr>
</thead>
<tbody>
<tr>
<td>パスワード</td>
<td>同じパスワードを使っているところも全部変える</td>
</tr>
<tr>
<td>カード番号</td>
<td>カード会社に連絡する</td>
</tr>
<tr>
<td>住所や電話番号</td>
<td>その会社を名乗る連絡は、まず偽物だと思って扱う</td>
</tr>
<tr>
<td>身分証の画像</td>
<td>信用情報機関(CICなど)の本人申告制度で、なりすましへの注意を登録する</td>
</tr>
</tbody>
</table>
<h3 id="%E6%B8%A1%E3%81%99%E6%83%85%E5%A0%B1%E3%82%92%E6%B8%9B%E3%82%89%E3%81%99" tabindex="-1"><a href="https://nyosegawa.com/posts/data-leak-era/#%E6%B8%A1%E3%81%99%E6%83%85%E5%A0%B1%E3%82%92%E6%B8%9B%E3%82%89%E3%81%99" class="header-anchor">渡す情報を減らす</a></h3>
<p>使っていないアカウントは消して、任意の項目は入力しないようにしておきます。顔や声の入った動画を誰に公開しているかも、一度見直しておくといいと思います。</p>
<h2 id="%E3%81%BE%E3%81%A8%E3%82%81" tabindex="-1"><a href="https://nyosegawa.com/posts/data-leak-era/#%E3%81%BE%E3%81%A8%E3%82%81" class="header-anchor">まとめ</a></h2>
<ul>
<li>漏洩はもう個人では防げないので、流出しても困らない準備を先にしておくのがよさそうです</li>
<li>知らない相手からの連絡は中身が正しくても、公式サイトや公式アプリで確かめてから動きます</li>
</ul>
<h2 id="references" tabindex="-1"><a href="https://nyosegawa.com/posts/data-leak-era/#references" class="header-anchor">References</a></h2>
<ul>
<li>政府機関の注意喚起
<ul>
<li><a href="https://www.ipa.go.jp/security/security-alert/2026/alert20261009.html">IPA: 不正アクセスによる漏えい等の事案を踏まえ、速やかに実施すべき対策等について</a></li>
<li><a href="https://www.jpcert.or.jp/at/2026/at260030.html">JPCERT/CC: 直近で相次いでいる国内組織における不正アクセスに関する注意喚起</a></li>
<li><a href="https://www.ppc.go.jp/news/careful_information/261007_alert/">個人情報保護委員会: 大規模な漏えい等事案を踏まえた対応について(注意喚起)</a></li>
</ul>
</li>
<li>統計
<ul>
<li><a href="https://www.tsr-net.co.jp/data/detail/1203305_1527.html">東京商工リサーチ: 上場企業「個人情報漏えい」、過去最悪ペース</a></li>
<li><a href="https://www.npa.go.jp/bureau/safetylife/sos47/new-topics/241218/02.html">警察庁: ニセ警察詐欺に注意!</a></li>
<li><a href="https://www.npa.go.jp/bureau/safetylife/sos47/assets/img/new-topics/detail/260605/01/01.pdf">警察庁: 令和7年の特殊詐欺の認知・検挙状況等</a></li>
</ul>
</li>
<li>漏洩
<ul>
<li><a href="https://internet.watch.impress.co.jp/docs/news/2147116.html">INTERNET Watch: スカイチケットの個人情報流出</a></li>
<li><a href="https://internet.watch.impress.co.jp/docs/news/2147032.html">INTERNET Watch: ビッグエコーの情報漏洩</a></li>
<li><a href="https://share.timescar.jp/news/2026/0929/1816.html">タイムズカー: 不正アクセスによる個人情報漏えいについて</a></li>
<li><a href="https://newsroom.kddi.com/news/assets/2026/kddi_nr_s-73_4619/kddi_nr_s-73_4619_pdf_01.pdf">KDDI: ISP事業者向けメールシステムへの不正アクセスについて</a></li>
</ul>
</li>
<li>流出した情報が悪用された例
<ul>
<li><a href="https://faq.interlink.or.jp/faq2/View/wcDisplayContent.aspx?id=1424">インターリンク: 弊社からのメールを装ったフィッシングメールについて</a></li>
<li><a href="https://www.j-cast.com/2026/05/19514827.html?p=all">J-CAST: Booking.comの予約情報を使ったWhatsAppのメッセージ</a></li>
<li><a href="https://www.itmedia.co.jp/news/article/2609/16/2000001559/">ITmedia: 警察官を名乗る詐欺電話の通話録音(トビラシステムズ)</a></li>
<li><a href="https://www.npa.go.jp/publications/statistics/cybersecurity/data/R8kami/R08_kami_cyber_jousei.pdf">警察庁: 令和8年上半期におけるサイバー空間をめぐる脅威の情勢等について</a></li>
<li><a href="https://www.theblock.co/post/352479/ledger-confirms-physical-scam-letters-requesting-seed-phrase">The Block: Ledger confirms physical scam letters requesting seed phrase</a></li>
<li><a href="https://www.kokusen.go.jp/news/data/n-20240807_2.html">国民生活センター: 国民生活センターをかたる電話に注意</a></li>
<li><a href="https://constructionmanagement.co.uk/arup-victim-of-multimillion-deepfake-scam/">Construction Management: Arup victim of multimillion deepfake scam</a></li>
<li><a href="https://stratnewsglobal.com/europe/italian-police-freeze-cash-from-ai-voice-scam-that-targeted-business-leaders">StratNews Global: Italian police freeze cash from AI voice scam</a></li>
<li><a href="https://www.gizmodo.jp/article/meta-says-thousands-of-instagram-accounts-were-breached-through-its-ai-support-assistant/">GIZMODO Japan: MetaのAIサポートアシスタントを悪用したInstagramアカウントの乗っ取り</a></li>
</ul>
</li>
</ul>
]]>
      </content:encoded>
      <pubDate>Sat, 10 Oct 2026 00:00:00 GMT</pubDate>
    </item>
    <item>
      <title>Building speech.cpp, a Speech Synthesis and Recognition Engine for Voice Conversation</title>
      <link>https://nyosegawa.com/en/posts/speech-cpp/</link>
      <guid isPermaLink="false">https://nyosegawa.com/en/posts/speech-cpp/</guid>
      <description>
        I built speech.cpp, a ggml-based engine for speech synthesis and recognition. Here is how I check it against the official implementations stage by stage, make it fast enough for conversation, and run it as one executable on Mac, Windows and Linux, with comparisons against other implementations.
      </description>
      <content:encoded>
        <![CDATA[<p>Hi there! This is Sakasegawa (<a href="https://x.com/gyakuse">@gyakuse</a>)!</p>
<p>Today I'd like to write about speech.cpp, the speech synthesis and recognition engine I've been building for voice conversation.</p>
<!--more-->
<h2 id="what-i-built" tabindex="-1"><a href="https://nyosegawa.com/en/posts/speech-cpp/#what-i-built" class="header-anchor">What I built</a></h2>
<p><a href="https://github.com/nyosegawa/speech.cpp">speech.cpp</a> is a C++ engine that runs speech synthesis and recognition models on <a href="https://github.com/ggml-org/ggml">ggml</a>. It uses Metal on Mac and Vulkan on Windows and Linux for GPU inference, and can run on the CPU without a GPU.</p>
<p>These are the models it currently supports. I converted their original weights into GGUF files for speech.cpp and uploaded them to Hugging Face.</p>
<h3 id="speech-synthesis-models" tabindex="-1"><a href="https://nyosegawa.com/en/posts/speech-cpp/#speech-synthesis-models" class="header-anchor">Speech synthesis models</a></h3>
<table>
<thead>
<tr>
<th>Model</th>
<th>Languages</th>
<th>GGUF files for speech.cpp</th>
</tr>
</thead>
<tbody>
<tr>
<td>Qwen3-TTS</td>
<td>10 languages</td>
<td><a href="https://huggingface.co/sakasegawa/Qwen3-TTS-12Hz-0.6B-CustomVoice-GGUF">0.6B</a> / <a href="https://huggingface.co/sakasegawa/Qwen3-TTS-12Hz-1.7B-CustomVoice-GGUF">1.7B</a></td>
</tr>
<tr>
<td>Irodori-TTS</td>
<td>Japanese</td>
<td><a href="https://huggingface.co/sakasegawa/Irodori-TTS-v4.1-Small-MF-GGUF">v4.1-Small-MF</a> / <a href="https://huggingface.co/sakasegawa/Irodori-TTS-v4.1-Small-GGUF">v4.1-Small</a></td>
</tr>
</tbody>
</table>
<p>Qwen3-TTS offers nine preset voices. Irodori-TTS lets you specify a voice with a recording or a description; MF is a good choice when speed matters.</p>
<h3 id="speech-recognition-models" tabindex="-1"><a href="https://nyosegawa.com/en/posts/speech-cpp/#speech-recognition-models" class="header-anchor">Speech recognition models</a></h3>
<table>
<thead>
<tr>
<th>Model</th>
<th>Languages</th>
<th>GGUF files for speech.cpp</th>
</tr>
</thead>
<tbody>
<tr>
<td>Qwen3-ASR</td>
<td>30 languages</td>
<td><a href="https://huggingface.co/sakasegawa/Qwen3-ASR-0.6B-GGUF">0.6B</a> / <a href="https://huggingface.co/sakasegawa/Qwen3-ASR-1.7B-GGUF">1.7B</a></td>
</tr>
<tr>
<td>parakeet-tdt_ctc-0.6b-ja</td>
<td>Japanese</td>
<td><a href="https://huggingface.co/sakasegawa/parakeet-tdt_ctc-0.6b-ja-GGUF">Download</a></td>
</tr>
<tr>
<td>parakeet-tdt-0.6b-v3</td>
<td>25 European languages</td>
<td><a href="https://huggingface.co/sakasegawa/parakeet-tdt-0.6b-v3-GGUF">Download</a></td>
</tr>
<tr>
<td>ReazonSpeech NeMo v2</td>
<td>Japanese</td>
<td><a href="https://huggingface.co/sakasegawa/reazonspeech-nemo-v2-GGUF">Download</a></td>
</tr>
</tbody>
</table>
<p>Qwen3-ASR can take proper nouns and other context in a prompt. For Japanese, parakeet-ja is fast, while ReazonSpeech transcribes with beam search. I compare accuracy and speed later in the article.</p>
<h3 id="voice-activity-detection" tabindex="-1"><a href="https://nyosegawa.com/en/posts/speech-cpp/#voice-activity-detection" class="header-anchor">Voice activity detection</a></h3>
<table>
<thead>
<tr>
<th>Model</th>
<th>Languages</th>
<th>GGUF files for speech.cpp</th>
</tr>
</thead>
<tbody>
<tr>
<td>Silero VAD v6.2</td>
<td>Language-independent</td>
<td><a href="https://huggingface.co/sakasegawa/silero-vad-GGUF">Download</a></td>
</tr>
</tbody>
</table>
<p>It finds the regions of audio that contain speech, for transcribing long recordings in parts or detecting when someone has finished speaking.</p>
<h2 id="try-it" tabindex="-1"><a href="https://nyosegawa.com/en/posts/speech-cpp/#try-it" class="header-anchor">Try it</a></h2>
<h3 id="install" tabindex="-1"><a href="https://nyosegawa.com/en/posts/speech-cpp/#install" class="header-anchor">Install</a></h3>
<p>Installation takes one command. On Apple silicon Macs and x86-64 Linux:</p>
<pre><code class="language-sh">curl -fsSL https://raw.githubusercontent.com/nyosegawa/speech.cpp/main/install.sh | sh
</code></pre>
<p>On Windows x64, run this in PowerShell (a Vulkan-capable GPU driver is required):</p>
<pre><code class="language-powershell">irm https://raw.githubusercontent.com/nyosegawa/speech.cpp/main/install.ps1 | iex
</code></pre>
<p>The Linux releases require a CPU with AVX2, FMA and F16C, and glibc 2.34 or later. Without a Vulkan loader, the installer selects the CPU build. Intel Macs need a build from source. See the <a href="https://github.com/nyosegawa/speech.cpp/blob/main/docs/install.md">installation guide</a> for the requirements.</p>
<p>Select a model by name. The first time you use it, speech.cpp downloads it from Hugging Face into your OS's cache directory.</p>
<h3 id="try-it-in-the-browser" tabindex="-1"><a href="https://nyosegawa.com/en/posts/speech-cpp/#try-it-in-the-browser" class="header-anchor">Try it in the browser</a></h3>
<p>The easiest way to get started is to open the demo page:</p>
<pre><code class="language-sh">speech serve --open
</code></pre>
<p><img src="https://nyosegawa.com/img/speech-cpp/page.png" alt="The speech serve demo page"></p>
<p>The Speak, Transcribe and Live tabs let you select a model and read text aloud, transcribe a recording or audio file, or transcribe your microphone while you speak.</p>
<h3 id="read-text-aloud" tabindex="-1"><a href="https://nyosegawa.com/en/posts/speech-cpp/#read-text-aloud" class="header-anchor">Read text aloud</a></h3>
<p>You can also use the command line. Here is Qwen3-TTS reading Japanese:</p>
<pre><code class="language-sh">speech tts qwen3-tts-0.6b --voice ono_anna --language ja -o out.wav &quot;明日の東京は晴れです。&quot;
</code></pre>
<h3 id="use-a-voice-of-your-choice" tabindex="-1"><a href="https://nyosegawa.com/en/posts/speech-cpp/#use-a-voice-of-your-choice" class="header-anchor">Use a voice of your choice</a></h3>
<p>With Irodori-TTS, you can pass a reference WAV file directly to read text in that voice. This example saves the synthesized audio to <code>out.wav</code>.</p>
<pre><code class="language-sh">speech tts irodori-tts-mf \
    --add-voice me=my-voice.wav --voice me \
    -o out.wav &quot;こんにちは。&quot;
</code></pre>
<p>If you use the same voice repeatedly, you can also create a voice file once with <code>speech voice</code>. This avoids encoding the reference audio each time you pass in the WAV file.</p>
<pre><code class="language-sh">speech voice irodori-tts-mf my-voice.wav my-voice.voice.gguf
speech tts irodori-tts-mf \
    --add-voice me=my-voice.voice.gguf --voice me \
    -o out.wav &quot;こんにちは。&quot;
</code></pre>
<p>Without reference audio, you can describe the voice in words. This example asks for a low, calm male voice speaking slowly:</p>
<pre><code class="language-sh">speech tts irodori-tts-mf --voice none \
    --instructions &quot;低く落ち着いた男性の声で、ゆっくりと読み上げてください。&quot; \
    -o out.wav &quot;明日の東京は晴れです。&quot;
</code></pre>
<h3 id="transcribe-audio" tabindex="-1"><a href="https://nyosegawa.com/en/posts/speech-cpp/#transcribe-audio" class="header-anchor">Transcribe audio</a></h3>
<p>Qwen3-ASR can take proper nouns in a prompt:</p>
<pre><code class="language-sh">speech asr parakeet-tdt_ctc-0.6b-ja utterance.wav
speech asr qwen3-asr-1.7b --language ja --prompt &quot;逆瀬川, Codex, Claude&quot; utterance.wav
</code></pre>
<h3 id="transcribe-long-recordings-or-a-microphone" tabindex="-1"><a href="https://nyosegawa.com/en/posts/speech-cpp/#transcribe-long-recordings-or-a-microphone" class="header-anchor">Transcribe long recordings or a microphone</a></h3>
<p>For long recordings, Silero VAD finds speech regions, which are transcribed separately. With <code>--live</code>, speech.cpp transcribes your microphone while you speak.</p>
<pre><code class="language-sh">speech asr reazonspeech-v2 --vad silero-vad meeting.wav
speech asr reazonspeech-v2 --vad silero-vad --live
</code></pre>
<h3 id="run-an-http-server" tabindex="-1"><a href="https://nyosegawa.com/en/posts/speech-cpp/#run-an-http-server" class="header-anchor">Run an HTTP server</a></h3>
<p>The HTTP server accepts requests in the same format as the OpenAI API:</p>
<pre><code class="language-sh">speech serve irodori-tts-mf --add-voice me=my-voice.voice.gguf
curl http://127.0.0.1:8080/v1/audio/speech -H 'Content-Type: application/json' \
    -d '{&quot;input&quot;: &quot;明日の東京は晴れです。&quot;, &quot;voice&quot;: &quot;me&quot;}' -o out.wav
</code></pre>
<p>In Python, you can receive PCM as it arrives:</p>
<pre><code class="language-python">import requests
import wave

with requests.post(&quot;http://127.0.0.1:8080/v1/audio/speech&quot;,
                   json={&quot;input&quot;: &quot;明日の東京は晴れです。&quot;, &quot;voice&quot;: &quot;me&quot;, &quot;response_format&quot;: &quot;pcm&quot;},
                   stream=True) as r:
    r.raise_for_status()
    with wave.open(&quot;out.wav&quot;, &quot;wb&quot;) as w:
        w.setnchannels(1)
        w.setsampwidth(2)
        w.setframerate(int(r.headers[&quot;X-Sample-Rate&quot;]))
        for chunk in r.iter_content(chunk_size=None):
            w.writeframes(chunk)
</code></pre>
<h3 id="embed-it-in-an-application" tabindex="-1"><a href="https://nyosegawa.com/en/posts/speech-cpp/#embed-it-in-an-application" class="header-anchor">Embed it in an application</a></h3>
<p>The HTTP server or worker is usually enough. To embed it directly, use the C API (from the <a href="https://github.com/nyosegawa/speech.cpp/blob/main/docs/c-api.md">C API documentation</a>):</p>
<pre><code class="language-c">speech_model * model = NULL;
speech_request * request = NULL;
if (check(speech_model_load(&quot;Irodori-TTS-866M-MF-v4.1-F16.gguf&quot;, NULL, &amp;model)) &amp;&amp;
    check(speech_voice_add(model, &quot;bright&quot;, &quot;bright-young-woman-10s.voice.gguf&quot;)) &amp;&amp;
    check(speech_request_new(model, &amp;request)) &amp;&amp;
    check(speech_request_set_text(request, &quot;明日の東京は晴れです。&quot;)) &amp;&amp;
    check(speech_request_set_string(request, SPEECH_OPT_VOICE, &quot;bright&quot;)) &amp;&amp;
    check(speech_synthesize(request, on_audio, NULL))) {
    /* on_audio receives the audio as it is generated. */
}
speech_request_free(request);
speech_model_free(model);
</code></pre>
<h2 id="why-i-built-it" tabindex="-1"><a href="https://nyosegawa.com/en/posts/speech-cpp/#why-i-built-it" class="header-anchor">Why I built it</a></h2>
<p>I'm building <a href="https://nyosegawa.com/en/posts/asist-realtime-voice-assistant/">ASIST</a>, a voice assistant for the desktop. The hardest part of voice conversation is the wait. In human conversation, the gap between one speaker finishing and the next beginning is around 200 milliseconds (<a href="https://www.pnas.org/doi/10.1073/pnas.0903616106">Stivers et al., 2009</a>). When an assistant stays silent for a second, I start wondering whether it heard me.</p>
<aside class="promo">
<p class="promo-label">A short commercial break.</p>
<p class="promo-message">I made a desktop assistant! Give it a try if it sounds useful.</p>
<p><a href="https://asist-agent.com/en/"><img src="https://nyosegawa.com/img/speech-cpp/asist-banner.jpg" alt="ASIST: a real-time assistant for Mac and Windows that handles your schedule and email through conversation"></a></p>
<p class="promo-links"><a class="promo-button promo-primary" href="https://asist-agent.com/en/">Visit the website</a><a class="promo-button" href="https://github.com/nyosegawa/asist">GitHub</a></p>
</aside>
<p>For Japanese speech, Aratako's Irodori-TTS is wonderful. It reads naturally, and reference audio lets it speak in that voice. But the original implementation is a little slow for real-time conversation.</p>
<p>Windows was particularly painful. My test machine has an RTX 2080 with 8 GB of VRAM, and my attempts to run speech models with Python and CUDA kept running into trouble.</p>
<ul>
<li>I couldn't get Qwen3-TTS's official PyTorch package running on Windows.</li>
<li>The CUDA 13 build of llama.cpp, which I used for recognition, lacked machine code for the RTX 20 series and would not start. The CUDA 12 build worked, but compiling GPU code on its first launch took 28 seconds.</li>
</ul>
<p>That CUDA and Python setup required an NVIDIA GPU from the RTX 20 series or newer, driver version 580 or later, and several gigabytes of Python environment to distribute.</p>
<p>Each model also ran differently. ASIST used llama.cpp's llama-server for recognition and a worker I wrote for synthesis. llama-server needed two model files, the model and mmproj, a loopback port and its key, and a watchdog to stop it if the app crashed. Its 4,096-token context also meant utterances longer than about 270 seconds failed.</p>
<p>I wanted one engine for both synthesis and recognition, running on the GPU on Mac, Windows and Linux, fast enough for conversation, and producing the same results as the official implementations. First, I will look at how to shorten the wait for speech to begin.</p>
<h2 id="speech-synthesis%3A-start-quickly-and-keep-playing" tabindex="-1"><a href="https://nyosegawa.com/en/posts/speech-cpp/#speech-synthesis%3A-start-quickly-and-keep-playing" class="header-anchor">Speech synthesis: start quickly and keep playing</a></h2>
<h3 id="look-at-the-time-to-first-audio" tabindex="-1"><a href="https://nyosegawa.com/en/posts/speech-cpp/#look-at-the-time-to-first-audio" class="header-anchor">Look at the time to first audio</a></h3>
<p>ASIST splits the language model's reply into sentences and starts reading them as they arrive. It sends one sentence at a time to Irodori-TTS, while Qwen3-TTS receives waiting sentences grouped into at most 300 characters. It can synthesize the continuation while earlier audio plays, so the user's initial wait is for the first sentence to begin. I therefore focus on time to first audio, rather than overall speed measured as RTF, the seconds of computation needed per second of audio.</p>
<p>For the measurements, each implementation read the same 20 texts, including backchannels, short replies, longer explanations and Japanese mixed with English words. I used the same voices and seeds where applicable and took the median. The Appendix gives the details.</p>
<h3 id="how-irodori-tts-works" tabindex="-1"><a href="https://nyosegawa.com/en/posts/speech-cpp/#how-irodori-tts-works" class="header-anchor">How Irodori-TTS works</a></h3>
<p>Irodori-TTS does not emit one token at a time like an LLM. Its Diffusion Transformer (DiT), following <a href="https://jordandarefsky.com/blog/2025/echo/">Echo-TTS</a>, generates codec latents for the whole text at once: 32-dimensional vectors at 25 Hz, representing 48 kHz audio. The codec converts them into a waveform. A separate model predicts the audio duration before generation.</p>
<p><img src="https://nyosegawa.com/img/speech-cpp/pipeline.png" alt="The Irodori-TTS pipeline"></p>
<p>Because the DiT processes the whole text together, it cannot start speaking partway through. In the official implementation, text preparation, duration prediction, every sampler step and all codec decoding finish before audio is returned.</p>
<p>There are two models, differing in how the DiT turns noise into speech.</p>
<p>The original v4.1-Small is trained with <a href="https://arxiv.org/abs/2209.03003">Rectified Flow</a>. It connects noise and speech with straight lines and learns the velocity, how far and in which direction to move, at each point along them. Generation repeatedly predicts that velocity and takes a small step, 40 times. Classifier-free guidance (CFG), which strengthens adherence to text and speaker conditioning, requires three DiT evaluations per step in the first half, making this fairly expensive.</p>
<p>v4.1-Small-MF is distilled from v4.1-Small using <a href="https://arxiv.org/abs/2505.13447">MeanFlow</a>. Instead of the instantaneous velocity, it learns the average velocity over an interval. A large step along the instantaneous velocity can leave a curved trajectory, but its average over the interval can take you to the destination. The student learns the teacher's CFG-guided trajectory, so it needs only one DiT evaluation per step and produces audio in four steps. See <a href="https://github.com/Aratako/Irodori-TTS/blob/main/docs/meanflow.md">Irodori-TTS's MeanFlow explanation</a>.</p>
<p><img src="https://nyosegawa.com/img/speech-cpp/rf-vs-meanflow.png" alt="How Rectified Flow and MeanFlow take steps"></p>
<p>According to the <a href="https://huggingface.co/Aratako/Irodori-TTS-v4.1-Small-MF">MF model card</a>, simply reducing v4.1-Small to four steps drops speaker similarity from 0.75 to 0.38, while MF preserves 0.74 with four steps. Conversation needs speed, so ASIST uses MF.</p>
<h3 id="where-the-official-implementation-spends-its-time" tabindex="-1"><a href="https://nyosegawa.com/en/posts/speech-cpp/#where-the-official-implementation-spends-its-time" class="header-anchor">Where the official implementation spends its time</a></h3>
<p>I measured the stages of the official MF implementation on M5 with MPS, reading a 33-character Japanese text.</p>
<table>
<thead>
<tr>
<th>Stage</th>
<th>WAVE reference</th>
<th>Pre-encoded reference latents</th>
</tr>
</thead>
<tbody>
<tr>
<td>Reference encoding</td>
<td>0.92–1.04 s</td>
<td>0.001–0.005 s</td>
</tr>
<tr>
<td>Duration prediction</td>
<td>0.25–0.28 s</td>
<td>0.22–0.24 s</td>
</tr>
<tr>
<td>Sampler (4 steps)</td>
<td>0.62–0.63 s</td>
<td>0.55–0.60 s</td>
</tr>
<tr>
<td>Codec decoding</td>
<td>1.29–1.40 s</td>
<td>1.30–1.41 s</td>
</tr>
<tr>
<td>Total</td>
<td>3.09–3.35 s</td>
<td>2.07–2.25 s</td>
</tr>
</tbody>
</table>
<p>Passing a WAVE reference causes it to be encoded again for every request. Passing the already encoded latents removes that second of work and produces exactly the same audio. The largest remaining cost is codec decoding. With MF reducing the sampler to four steps, the codec takes longer than the DiT.</p>
<h3 id="ways-to-make-it-faster" tabindex="-1"><a href="https://nyosegawa.com/en/posts/speech-cpp/#ways-to-make-it-faster" class="header-anchor">Ways to make it faster</a></h3>
<p>The breakdown suggests a few approaches. MF already reduces the number of steps, and saved reference latents avoid repeated encoding.</p>
<p>Other people have also written about accelerating Irodori-TTS. <a href="https://note.com/rosso_blog/n/n3eaee67d2c70">Rosso's article</a> profiles a sentence with v4 on an RTX 3080 and finds that GPU dispatch overhead costs more than the DiT computation itself. Recording the DiT in a CUDA Graph removes that overhead, while rounding predicted durations up to a small set of lengths accommodates different sentence lengths. Generating 5.1 seconds of audio drops from 0.90 to 0.30 seconds, and reducing the sampler to eight steps with Sway Sampling brings it to 0.23 seconds.</p>
<p>On Mac, <a href="https://zenn.dev/yoshitetsu/articles/e616831f96b44a">osushi_cr's article</a> uses v2 on an M1 Max with MPS. Reducing 40 steps to eight with Sway Sampling, keeping the model loaded and generating the next sentence during playback brings five sentences from 28.77 to 10.58 seconds. They report that bf16 was slower on MPS and moving the codec to the CPU roughly doubled the time.</p>
<p>Both improve how PyTorch is run. For speech.cpp, I also considered other directions because I wanted the same engine to use GPUs on both Mac and Windows.</p>
<p>Eight-bit quantization could reduce the workload. But in my Irodori-TTS test, CER measured by transcribing 20 synthesized texts rose from 2.99% to 3.81%, with audible degradation too.</p>
<p>Another option is leaving PyTorch for another runtime. ONNX Runtime's <a href="https://onnxruntime.ai/docs/execution-providers/CoreML-ExecutionProvider.html">CoreML execution provider on Mac</a> is a preview and slows down when input shapes change. Its <a href="https://onnxruntime.ai/docs/execution-providers/DirectML-ExecutionProvider.html">DirectML execution provider on Windows</a> has entered maintenance mode. MLX runs only on Mac. I chose ggml because I wanted GPU support on both Mac and Windows.</p>
<p><a href="https://github.com/ggml-org/ggml">ggml</a> is the C/C++ tensor library behind llama.cpp and whisper.cpp. It has Metal, Vulkan, CUDA and CPU backends, and builds computation graphs on each call, so changing text lengths are manageable. It can be linked into the executable, leaving users with no extra runtime to install.</p>
<p>Finally, there is the option of starting playback before all the audio is ready. The DiT cannot stream its work, but once it finishes, the codec can decode and return audio in pieces. Since the codec is more expensive than the DiT with MF, that should cut the initial wait substantially.</p>
<h3 id="what-i-did-in-speech.cpp" tabindex="-1"><a href="https://nyosegawa.com/en/posts/speech-cpp/#what-i-did-in-speech.cpp" class="header-anchor">What I did in speech.cpp</a></h3>
<p>I ported MF to ggml, saved reference latents as voice files, and split codec decoding into windows.</p>
<p>Instead of decoding the whole output at once, the codec returns the first 12 frames, or 0.48 seconds of audio, immediately. While those play, it decodes the rest in windows of 24 to 48 frames. Based on the measured speed of previous windows, it chooses the largest window it can finish while the listener still has 0.1 seconds of audio buffered. Slower machines get smaller windows; faster machines get larger ones.</p>
<p><img src="https://nyosegawa.com/img/speech-cpp/chunked-decode.png" alt="Decoding codec windows while earlier audio plays"></p>
<p>The window boundaries must not change the sound. Decoding ten extra frames on either side and discarding that padding produces the same waveform as full decoding. The output is bit-identical at every supported window size, so a fixed seed produces the same audio even when the machine's speed changes the windows. Most of the decoding now overlaps playback instead of adding to the initial wait.</p>
<p>There is another detail specific to conversation. People often start talking while the assistant is still speaking. If cancelling a sentence leaves the sampler running to completion, the next reply has to wait. I added cancellation checks between sampler steps. After cancelling a 120-character text, the time to first audio for the next reply dropped from 881 to 351 milliseconds.</p>
<h3 id="streaming-qwen3-tts-one-frame-at-a-time" tabindex="-1"><a href="https://nyosegawa.com/en/posts/speech-cpp/#streaming-qwen3-tts-one-frame-at-a-time" class="header-anchor">Streaming Qwen3-TTS one frame at a time</a></h3>
<p>Unlike Irodori-TTS, Qwen3-TTS generates audio tokens one frame at a time, like an LLM. Each frame represents 0.08 seconds of audio and contains tokens from 16 codebooks. The talker, a Qwen3 decoder, emits the first token, and the code predictor fills in the other 15. The codec is causal too, so each completed frame can be decoded and streamed immediately. speech.cpp carries the codec's intermediate state between calls, making frame-by-frame decoding produce almost the same waveform as full decoding. This is the difference from qwentts.cpp that I will compare below.</p>
<h3 id="comparing-implementations" tabindex="-1"><a href="https://nyosegawa.com/en/posts/speech-cpp/#comparing-implementations" class="header-anchor">Comparing implementations</a></h3>
<p>I compared the same 20 texts on M5 with the same reference voice and seed. Every implementation encoded the reference only once. The speech.cpp names are bold, as are the fastest values within each model. Later tables use the same convention.</p>
<table>
<thead>
<tr>
<th>Model</th>
<th>Implementation</th>
<th>First audio (median)</th>
<th>First audio (p90)</th>
<th>RTF</th>
<th>CER</th>
</tr>
</thead>
<tbody>
<tr>
<td>v4.1-Small-MF</td>
<td><strong>speech.cpp</strong> (Metal, F16)</td>
<td><strong>0.25 s</strong></td>
<td><strong>0.52 s</strong></td>
<td><strong>0.17</strong></td>
<td>6.14%</td>
</tr>
<tr>
<td>v4.1-Small-MF</td>
<td><a href="https://github.com/Blaizzy/mlx-audio">mlx-audio</a> (MLX, FP16)</td>
<td>1.01 s</td>
<td>2.67 s</td>
<td>0.17</td>
<td>4.48%</td>
</tr>
<tr>
<td>v4.1-Small-MF</td>
<td>Official implementation (PyTorch, MPS, FP32)</td>
<td>1.33 s</td>
<td>3.19 s</td>
<td>0.21</td>
<td>8.62%</td>
</tr>
<tr>
<td>v4.1-Small, 16 steps</td>
<td><strong>speech.cpp</strong> (Metal, F16)</td>
<td><strong>1.24 s</strong></td>
<td><strong>3.40 s</strong></td>
<td>0.34</td>
<td>3.15%</td>
</tr>
<tr>
<td>v4.1-Small, 16 steps</td>
<td>mlx-audio</td>
<td>1.84 s</td>
<td>4.54 s</td>
<td>0.31</td>
<td>1.99%</td>
</tr>
<tr>
<td>v4.1-Small, 16 steps</td>
<td>Official implementation</td>
<td>2.69 s</td>
<td>7.37 s</td>
<td>0.45</td>
<td>3.48%</td>
</tr>
<tr>
<td>v4-Small, 16 steps</td>
<td>audio.cpp v0.9.0 (codec on Metal)</td>
<td>1.55 s</td>
<td>4.26 s</td>
<td><strong>0.28</strong></td>
<td>9.62%</td>
</tr>
<tr>
<td>v4-Small, 16 steps</td>
<td>audio.cpp v0.9.0 (codec on CPU)</td>
<td>10.22 s</td>
<td>30.11 s</td>
<td>1.77</td>
<td>5.31%</td>
</tr>
<tr>
<td>Qwen3-TTS 0.6B</td>
<td><strong>speech.cpp</strong> (Metal, Q8_0)</td>
<td>0.04 s</td>
<td>0.05 s</td>
<td>0.31</td>
<td>5.80%</td>
</tr>
<tr>
<td>Qwen3-TTS 1.7B</td>
<td><strong>speech.cpp</strong> (Metal, Q8_0)</td>
<td>0.06 s</td>
<td>0.11 s</td>
<td>0.42</td>
<td>2.99%</td>
</tr>
</tbody>
</table>
<p>I also measured on Windows with an RTX 2080 and Vulkan. mlx-audio runs only on Mac, and I did not test the official implementation on Windows, so neither appears here.</p>
<table>
<thead>
<tr>
<th>Model</th>
<th>Implementation</th>
<th>First audio (median)</th>
<th>First audio (p90)</th>
<th>RTF</th>
<th>CER</th>
</tr>
</thead>
<tbody>
<tr>
<td>v4.1-Small-MF</td>
<td><strong>speech.cpp</strong> (Vulkan, F16)</td>
<td>0.12 s</td>
<td>0.20 s</td>
<td>0.07</td>
<td>7.13%</td>
</tr>
<tr>
<td>v4.1-Small, 16 steps</td>
<td><strong>speech.cpp</strong> (Vulkan, F16)</td>
<td><strong>0.52 s</strong></td>
<td><strong>1.12 s</strong></td>
<td><strong>0.13</strong></td>
<td>3.15%</td>
</tr>
<tr>
<td>v4-Small, 16 steps</td>
<td>audio.cpp v0.9.0 (Vulkan)</td>
<td>1.06 s</td>
<td>3.01 s</td>
<td>0.19</td>
<td>3.81%</td>
</tr>
<tr>
<td>Qwen3-TTS 0.6B</td>
<td><strong>speech.cpp</strong> (Vulkan, Q8_0)</td>
<td>0.03 s</td>
<td>0.04 s</td>
<td>0.27</td>
<td>7.96%</td>
</tr>
<tr>
<td>Qwen3-TTS 1.7B</td>
<td><strong>speech.cpp</strong> (Vulkan, Q8_0)</td>
<td>0.04 s</td>
<td>0.05 s</td>
<td>0.32</td>
<td>2.82%</td>
</tr>
</tbody>
</table>
<p>The tables measure the time from a synthesis request to the first returned audio, which is not necessarily when the voice begins. Qwen3-TTS 0.6B often generates silence at the beginning of a sentence. Although its first audio chunk arrives in 0.04 seconds, the voice starts roughly half a second later in the median: 0.24–0.48 seconds across four voices, and as much as 1.9 seconds. The official implementation does the same. Reading the same text 20 times with ono_anna gave a median of 0.89 seconds of leading silence. The corresponding figures for 1.7B and Irodori-TTS MF were 0.10 and 0.03 seconds, so their voices begin sooner in conversation.</p>
<p>The official implementation, mlx-audio and audio.cpp return audio only after generating the whole sentence, making their time to first audio equal to the total synthesis time. speech.cpp returns its first decoded window instead. MF's RTF is almost identical to mlx-audio's, 0.174 versus 0.171, so overall throughput is similar; windowed decoding accounts for most of the difference in startup latency. CER is measured by transcribing the synthesized audio with Qwen3-ASR 1.7B. Each implementation generates noise differently, so a single run can vary by several percentage points. audio.cpp does not support MF, so its comparison uses RF with 16 steps.</p>
<h3 id="keeping-playback-continuous" tabindex="-1"><a href="https://nyosegawa.com/en/posts/speech-cpp/#keeping-playback-continuous" class="header-anchor">Keeping playback continuous</a></h3>
<p>A quick start is not enough if playback then stalls. With streamed chunks, each new chunk must arrive before the previously received audio finishes playing. Smaller initial chunks start sooner but leave less time for the next chunk to arrive.</p>
<p>Other implementations handle this differently. The official Qwen3-TTS implementation sends four frames, 0.32 seconds, from the start (<a href="https://arxiv.org/abs/2601.15621">Qwen3-TTS Technical Report</a>). This gives more margin against underruns, but the first audio waits for all four frames. <a href="https://github.com/FunAudioLLM/CosyVoice">CosyVoice</a> increases each chunk by a fixed multiplier that a person selects for the machine's speed. The player can also accumulate more audio before starting, at the cost of extra latency.</p>
<p>speech.cpp's Qwen3-TTS starts small and grows: one frame, one frame, two frames, then four frames at a time.</p>
<p><img src="https://nyosegawa.com/img/speech-cpp/adaptive-chunks.png" alt="Fixed-size chunks compared with speech.cpp"></p>
<p>On both M5 and RTX 2080, generating a frame takes much less than the 0.08 seconds of audio it represents. Starting with a small chunk therefore gives the next chunk time to arrive before the audio runs out. Grouping later frames in fours keeps the number of codec calls close to the original.</p>
<p>I considered adapting the chunk sizes to measured speed. However, changing chunk boundaries also changes waveform values by a tiny, inaudible amount. Choosing boundaries at runtime would make the same seed produce slightly different audio on different runs, so I use a fixed sequence. This describes playing the generated audio as-is, including any leading silence.</p>
<p>I checked continuity by recording when chunks arrived for the same 20 texts and assuming playback began immediately with the first chunk. All 20 texts played without underruns on both M5 and RTX 2080, for Qwen3-TTS 0.6B and 1.7B and Irodori-TTS MF and the 16-step model.</p>
<p>ASIST removes only the nearly silent beginning. Once a sound such as a breath or sigh begins, it preserves everything after it, including pauses. Removing the initial silence also removes time in which later audio could be generated, so the player waits after the first sound arrives until the waiting time plus the received audio duration totals 0.2 seconds. A sentence that already has at least 0.2 seconds buffered while waiting behind the previous sentence starts without an additional delay.</p>
<p>I also ran the same 20 texts in the released ASIST 0.8.0 on M5. With the model already loaded, the median time from synthesis start to the scheduled playback of the voiced portion was 0.40 seconds for Qwen3-TTS 0.6B, 0.25 seconds for 1.7B and 0.26 seconds for Irodori-TTS MF. These values come from the Web Audio playback schedule and exclude speaker and output-device latency.</p>
<p>This makes synthesis fast enough to begin a conversational reply and continue playing it.</p>
<h3 id="checking-against-the-official-implementation%2C-stage-by-stage" tabindex="-1"><a href="https://nyosegawa.com/en/posts/speech-cpp/#checking-against-the-official-implementation%2C-stage-by-stage" class="header-anchor">Checking against the official implementation, stage by stage</a></h3>
<p>Moving a model to another runtime changes the order and precision of calculations a little. For synthesis, the voice may change; for recognition, the transcription may change. The trouble is that listening alone may not reveal it. Synthesis starts from noise, so the voice already varies from one run to the next, while recognition differences may appear only once in dozens of examples.</p>
<p>For each model I port to speech.cpp, I compare its intermediate stages against the official implementation.</p>
<p>For Irodori-TTS, for example, I compare text processing, the DiT and decoding like this:</p>
<p><img src="https://nyosegawa.com/img/speech-cpp/stage-check.png" alt="Comparing each stage against the official implementation"></p>
<ol>
<li>Run the official implementation in float32 on the CPU with fixed noise, and save each stage's inputs and outputs.</li>
<li>Feed the same saved inputs into each stage of the port.</li>
<li>Compare the outputs. For numerical data, use SNR to measure how small the error is relative to the signal; for tokens and strings, check exact equality.</li>
</ol>
<p>The other models follow the same approach with different stages. Qwen3-TTS is compared using greedy decoding without sampling. Recognition models are compared at the audio feature, encoder and decoder stages.</p>
<p>Starting each stage with the same input makes the first divergence easy to find. In CPU float32, every Irodori-TTS stage agrees with the official implementation at an SNR of at least 86 dB. Greedy Qwen3-TTS produces the same 54 frames, and parakeet and ReazonSpeech produce exactly the same transcripts in all the checks. The tables are in the Appendix.</p>
<p>These comparisons also made some differences in other implementations easier to spot.</p>
<ul>
<li>llama.cpp's Qwen3-ASR sometimes transcribed differently from the official implementation. Across ten recordings, tested with automatic and explicit language selection for 20 runs in total, 0.6B differed six times and 1.7B four times. Where the official implementation wrote 「群島や湖では必ずしもヨット」, for example, 1.7B wrote 「軍港や湖ではカマザタ寿司もヨット」. The inputs differ in several small ways: the initial system message is missing, the log-mel features have one extra frame, and the final partial second is padded with zeros whose audio tokens are also passed to the model.</li>
<li>Streaming Qwen3-TTS in <a href="https://github.com/ServeurpersoCom/qwentts.cpp">qwentts.cpp</a> introduced a trembling noise that did not occur when the same text was synthesized all at once. Qwen3-TTS's codec generates new audio as a continuation of previous audio. Carrying its intermediate state across chunks therefore makes streamed and full synthesis produce almost the same waveform. speech.cpp does this, and I checked that decoding one frame at a time differs from full decoding by an inaudibly small amount, with an SNR of 133 dB.</li>
<li><a href="https://github.com/CrispStrobe/CrispASR">CrispASR</a>'s ReazonSpeech added 「あっ。」 or 「うん。」 that was not in recordings with silence at their edges, in four of the first ten Common Voice examples. Trimming the silence reduced this to a few of 4,479 examples.</li>
<li>With <a href="https://github.com/0xShug0/audio.cpp">audio.cpp</a>'s Irodori-TTS codec on Metal, a distorted copy of the voice appeared 14 dB below the voice itself, and nominally silent regions were raised as well.</li>
</ul>
<p>These are all differences that are hard to catch by listening alone. Conversely, once each stage is checked against the official implementation, I can change the computations for speed and quickly check that the results still agree. Next comes listening to the user.</p>
<h2 id="speech-recognition%3A-getting-the-port-right" tabindex="-1"><a href="https://nyosegawa.com/en/posts/speech-cpp/#speech-recognition%3A-getting-the-port-right" class="header-anchor">Speech recognition: getting the port right</a></h2>
<h3 id="port-fastconformer-once" tabindex="-1"><a href="https://nyosegawa.com/en/posts/speech-cpp/#port-fastconformer-once" class="header-anchor">Port FastConformer once</a></h3>
<p>The first recognition models I ported were NVIDIA NeMo models. parakeet-tdt_ctc-0.6b-ja for Japanese, parakeet-tdt-0.6b-v3 for 25 European languages, and ReazonSpeech NeMo v2 for Japanese all share the <a href="https://arxiv.org/abs/2305.05084">FastConformer</a> architecture. They compute log-mel features, reduce the time dimension by a factor of eight with convolutions, run through 24 Conformer layers, then decode tokens.</p>
<p>Their differences are relatively small:</p>
<table>
<thead>
<tr>
<th></th>
<th>parakeet-ja</th>
<th>parakeet-v3</th>
<th>ReazonSpeech</th>
</tr>
</thead>
<tbody>
<tr>
<td>Mel bins</td>
<td>80</td>
<td>128</td>
<td>80</td>
</tr>
<tr>
<td>attention</td>
<td>Whole recording</td>
<td>Whole recording</td>
<td>10.24 s on either side (local attention)</td>
</tr>
<tr>
<td>Decoder</td>
<td>TDT</td>
<td>TDT</td>
<td>RNN-T beam search</td>
</tr>
</tbody>
</table>
<p>I therefore ported FastConformer once and made these differences settings in the GGUF file.</p>
<p>The decoding behavior follows NeMo's default <code>transcribe()</code>. parakeet-ja also has a CTC head, but NeMo defaults to TDT, and the two produce different text for some utterances, so I ported only TDT. ReazonSpeech uses the model's configured beam search with width four rather than NeMo's greedy decoder, because greedy decoding sometimes drops punctuation or changes words. Greedy is much lighter, though, so it is available on request. Across 4,483 Common Voice examples on M5, it raises CER from 12.03% to 12.50% while reducing median latency from 0.110 to 0.077 seconds.</p>
<h3 id="bringing-qwen3-asr-into-the-engine-too" tabindex="-1"><a href="https://nyosegawa.com/en/posts/speech-cpp/#bringing-qwen3-asr-into-the-engine-too" class="header-anchor">Bringing Qwen3-ASR into the engine too</a></h3>
<p>Qwen3-ASR connects an audio encoder to a Qwen3 language model. The language model caches state and emits one token at a time, exactly the work llama.cpp is good at. Initially, I intended to leave Qwen3-ASR in llama.cpp and have speech.cpp handle only FastConformer. There seemed little reason to implement the same thing twice.</p>
<p>But the stage comparisons showed the prompt differences mentioned earlier, with different transcripts in four to six of 20 runs. Utterances longer than about 270 seconds also failed. And keeping llama-server meant ASIST still had a second runtime alongside speech.cpp.</p>
<p>So I changed course and ported Qwen3-ASR too. I had already implemented the Qwen3 decoder for Qwen3-TTS's talker and could reuse it directly. The new work was the audio encoder, whose attention stays within eight-second windows, and prompts matching the official <a href="https://github.com/QwenLM/Qwen3-ASR">qwen-asr</a> implementation. That includes prefilling the answer with <code>language Japanese&lt;asr_text&gt;</code> when Japanese is specified. With those inputs aligned, the transcripts matched the official implementation.</p>
<p>Once the transcripts matched, I wanted to measure recognition accuracy. But in Japanese, measuring &quot;correctness&quot; brings its own difficulty.</p>
<h2 id="measuring-japanese-transcription-while-allowing-spelling-variants" tabindex="-1"><a href="https://nyosegawa.com/en/posts/speech-cpp/#measuring-japanese-transcription-while-allowing-spelling-variants" class="header-anchor">Measuring Japanese transcription while allowing spelling variants</a></h2>
<p>Recognition accuracy is usually measured by character error rate, CER, against a reference transcript. Japanese, however, offers several ways to write the same spoken words. If the reference says 「三十分」 and the transcript says 「30分」, both mean the spoken &quot;thirty minutes,&quot; but ordinary CER counts the difference as an error.</p>
<table>
<thead>
<tr>
<th>Reference</th>
<th>Transcript</th>
<th>Count as an error?</th>
</tr>
</thead>
<tbody>
<tr>
<td>27パーセント</td>
<td>27%</td>
<td>No (another spelling of the same words)</td>
</tr>
<tr>
<td>三十分</td>
<td>30分</td>
<td>No</td>
</tr>
<tr>
<td>今日</td>
<td>きょう</td>
<td>No</td>
</tr>
<tr>
<td>README</td>
<td>リードミ</td>
<td>No</td>
</tr>
<tr>
<td>機会</td>
<td>機械</td>
<td>Yes (homophones with different meanings)</td>
</tr>
<tr>
<td>3時半</td>
<td>3時30分</td>
<td>Yes (same meaning, different spoken words)</td>
</tr>
</tbody>
</table>
<p>As models improve, CER increasingly measures whether they chose the reference's spelling rather than whether they heard the words correctly.</p>
<p>This is not unique to Japanese, and there is research on it:</p>
<ul>
<li><a href="https://aclanthology.org/2023.cawl-1.8/">Karita, Sproat and Ishikawa (CAWL 2023)</a> expand Japanese references into a lattice of possible spellings before scoring. Human reviewers accepted 95.4% of the variants, and CER dropped by 2.4–3.1 percentage points depending on the task.</li>
<li><a href="https://arxiv.org/abs/2603.00941">OIWER (ICASSP 2026)</a> uses an LLM to identify spelling variants in Indian languages. Error rates fell by 6.3 percentage points on average and aligned better with human judgments.</li>
<li><a href="https://arxiv.org/abs/2509.24613">HiKE (EACL Findings 2026)</a> annotates loanwords in an evaluation set of Korean-English code-switched speech.</li>
<li><a href="https://arxiv.org/abs/2609.21084">Kacprzak and Fraś (2026)</a> report that number normalization alone can move Polish WER by more than two percentage points, exceeding the differences between systems.</li>
<li>The long-established NIST sclite tool also supports reference alternatives such as <code>{ a / b / @ }</code>.</li>
</ul>
<p>In <a href="https://github.com/nyosegawa/speech-bench">speech-bench</a>, I now report both ordinary CER and CER allowing accepted spelling variants. Each reference sentence is annotated like this:</p>
<ul>
<li>Add kana readings to non-kana text: <code>明日《あした》</code>, <code>｜Zoom《ズーム》</code>.</li>
<li>List alternative spellings that a reading alone cannot express: <code>［九《く》時《じ》／9時］</code>.</li>
<li>Allow fillers such as &quot;um&quot; to be omitted: <code>［えーと／えっと／］</code>.</li>
</ul>
<p>The transcript is compared with the closest spelling that these annotations permit. The denominator remains the original reference length, so this CER cannot exceed ordinary CER. Homophones with different meanings, such as 機会 (opportunity) and 機械 (machine), remain errors. So do different spoken wordings with the same meaning, such as 3時半 (half past three) and 3時30分 (three thirty).</p>
<p>I published the annotations for the 4,483 Japanese Common Voice 8.0 test examples on Hugging Face as <a href="https://huggingface.co/datasets/sakasegawa/common-voice-ja-accepted-spellings">sakasegawa/common-voice-ja-accepted-spellings</a>, under CC0. The included scoring script produces the same error counts as speech-bench across 17,932 transcripts. The audio and reference text must be obtained from their respective distributors and combined with these annotations.</p>
<p>For example, accepting spelling variants lowers Qwen3-ASR 1.7B's CER on Common Voice from 9.25% to 4.56%. Roughly half of what had counted as errors were spelling differences. The following tables show both metrics.</p>
<p>That gave me a benchmark for accuracy. But the newly ported speech.cpp was slower than llama.cpp.</p>
<h2 id="catching-up-with-llama.cpp" tabindex="-1"><a href="https://nyosegawa.com/en/posts/speech-cpp/#catching-up-with-llama.cpp" class="header-anchor">Catching up with llama.cpp</a></h2>
<p>On M5, speech.cpp 0.7.0 decoded Qwen3-ASR 0.6B at 123–132 tokens per second, compared with llama.cpp's 158–165. Decoding accounted for 90% of recognition time, so it needed attention.</p>
<p>I made two changes.</p>
<p>First, flash attention. The Qwen3 decoder had computed attention as two matrix multiplications and a softmax. ggml's <code>ggml_flash_attn_ext</code> computes these together without storing the full scores, which is faster on the GPU. With Metal on M5, attention for 2,000 cached positions fell from 2.62 to 2.20 milliseconds, and for 8,000 positions from 9.59 to 8.10 milliseconds. On the CPU it was slower, rising from 22.1 to 51.2 milliseconds at 8,000 positions. At model load, I therefore check <code>ggml_backend_supports_op</code> and use flash attention only when the GPU backend supports it.</p>
<p>Flash attention divides the work into small blocks and updates softmax incrementally, avoiding a large score matrix in memory (<a href="https://arxiv.org/abs/2205.14135">Dao et al., 2022</a>). The CUDA library FlashAttention-2 targets RTX 30-series and newer GPUs, but ggml implements the approach in its own Metal and Vulkan shaders, so it also works on an RTX 2080 with Vulkan.</p>
<p>Second, I reused the computation graph between decoder steps. Building a ggml graph takes time, so it need not be rebuilt while its shape stays the same.</p>
<p>M5 decoding then reached 146–158 tokens per second for 0.6B, alongside llama.cpp's 147–157, and 58–63 for 1.7B, alongside 58–62. On RTX 2080, it became 13–25% faster than speech.cpp 0.7.0.</p>
<p>Here are the Japanese Common Voice 8.0 results, 4,483 examples, on Windows with an RTX 2080. VAD trims the silence at both ends. The second error-rate column allows the spelling variants described above. Latency runs from the request to the returned transcript. I also included NVIDIA's <a href="https://github.com/NVIDIA/NeMo-Speech.cpp">NeMo-Speech.cpp</a>, another ggml implementation of FastConformer.</p>
<table>
<thead>
<tr>
<th>Model</th>
<th>Implementation</th>
<th>Weights</th>
<th>CER</th>
<th>CER with accepted spellings</th>
<th>Latency (median)</th>
<th>Latency (p90)</th>
</tr>
</thead>
<tbody>
<tr>
<td>Qwen3-ASR 1.7B</td>
<td><strong>speech.cpp</strong></td>
<td>Q8_0</td>
<td>9.50%</td>
<td>4.68%</td>
<td><strong>0.155 s</strong></td>
<td><strong>0.286 s</strong></td>
</tr>
<tr>
<td>Qwen3-ASR 1.7B</td>
<td>llama.cpp b11246</td>
<td>Q8_0</td>
<td>9.28%</td>
<td>4.60%</td>
<td>0.173 s</td>
<td>0.289 s</td>
</tr>
<tr>
<td>Qwen3-ASR 0.6B</td>
<td><strong>speech.cpp</strong></td>
<td>Q8_0</td>
<td>11.90%</td>
<td>7.04%</td>
<td><strong>0.087 s</strong></td>
<td><strong>0.158 s</strong></td>
</tr>
<tr>
<td>Qwen3-ASR 0.6B</td>
<td>llama.cpp b11246</td>
<td>Q8_0</td>
<td>11.80%</td>
<td>6.94%</td>
<td>0.105 s</td>
<td>0.167 s</td>
</tr>
<tr>
<td>parakeet-tdt_ctc-0.6b-ja</td>
<td><strong>speech.cpp</strong></td>
<td>F16</td>
<td>7.88%</td>
<td>2.98%</td>
<td><strong>0.069 s</strong></td>
<td><strong>0.130 s</strong></td>
</tr>
<tr>
<td>parakeet-tdt_ctc-0.6b-ja</td>
<td>CrispASR v0.8.38</td>
<td>Q8_0</td>
<td>7.93%</td>
<td>3.01%</td>
<td>0.189 s</td>
<td>0.280 s</td>
</tr>
<tr>
<td>ReazonSpeech NeMo v2</td>
<td><strong>speech.cpp</strong> (beam search)</td>
<td>F16</td>
<td>12.03%</td>
<td>7.14%</td>
<td>0.157 s</td>
<td>0.268 s</td>
</tr>
<tr>
<td>ReazonSpeech NeMo v2</td>
<td><strong>speech.cpp</strong> (greedy)</td>
<td>F16</td>
<td>12.48%</td>
<td>7.68%</td>
<td>0.092 s</td>
<td>0.149 s</td>
</tr>
<tr>
<td>ReazonSpeech NeMo v2</td>
<td>CrispASR v0.8.38</td>
<td>Q8_0</td>
<td>11.72%</td>
<td>6.79%</td>
<td>0.196 s</td>
<td>0.302 s</td>
</tr>
<tr>
<td>ReazonSpeech NeMo v2</td>
<td>NeMo-Speech.cpp v0.2.0 (greedy)</td>
<td>F16</td>
<td>11.81%</td>
<td>6.99%</td>
<td><strong>0.059 s</strong></td>
<td><strong>0.101 s</strong></td>
</tr>
</tbody>
</table>
<p>Qwen3-ASR's accuracy is close to llama.cpp's, with latency 10% lower for 1.7B and 17% lower for 0.6B. parakeet matches CrispASR's accuracy with one-third of the latency. Some of that difference may come from the weights: speech.cpp uses F16 and CrispASR Q8_0.</p>
<p>For ReazonSpeech, NeMo-Speech.cpp was fastest and slightly more accurate. Even comparing greedy decoders, it was 1.6 times faster than speech.cpp. It was also 1.4 times faster on M5 and faster for parakeet-v3, as shown in the Appendix. I plan to investigate the FastConformer difference for a later release. Although CrispASR's ReazonSpeech sometimes adds 「あっ。」 around silence, in these trimmed recordings it was slightly more accurate than speech.cpp's beam search. Common Voice contains read speech; conversational speech may give different results.</p>
<p>llama.cpp is still a little faster in some M5 cases. Across 4,483 Common Voice examples, median latency was similar, but llama.cpp's p90 was 8% shorter. The difference grows with utterance length: for a 25.5-second Japanese utterance, 0.6B took 0.80 seconds in speech.cpp and 0.75 in llama.cpp. Decoding is now comparable, so the difference lies in the audio encoder and prompt processing. llama.cpp uses Metal 4's tensor API for those matrix multiplications; speech.cpp does not. A bug is the reason, as I'll explain next.</p>
<h2 id="a-bug-i-found-on-m5" tabindex="-1"><a href="https://nyosegawa.com/en/posts/speech-cpp/#a-bug-i-found-on-m5" class="header-anchor">A bug I found on M5</a></h2>
<p>Just when the Irodori-TTS port seemed finished, one of 64 M5 samples turned into noise in its latter half. In 「暗証番号は4桁で、8264です。」 (&quot;The PIN has four digits: 8264&quot;), the &quot;8264&quot; ending was buried in static. The same text sounded clean on the CPU and RTX 2080.</p>
<p>The cause was ggml's Metal matrix multiplication. On M5 and newer chips, ggml uses Metal 4's tensor API. Its kernel wrote beyond the output only when the number of output columns was 64 modulo 128: 64, 192, 320 and so on. Those writes corrupted an adjacent tensor, changing the result from run to run.</p>
<p>The noisy sample hit exactly that condition. At the time, codec windows contained 48 frames with ten padding frames on either side. The third window of a 114-frame output covered frames 50–114, exactly 64 frames.</p>
<p>ggml's own <code>test-backend-ops</code> passed because it lacked this shape. Adding 64-column and 192-column cases made the tensor API fail, while disabling it with <code>GGML_METAL_TENSOR_DISABLE=1</code> made them pass (<a href="https://github.com/nyosegawa/speech.cpp/issues/13">speech.cpp#13</a>).</p>
<p>There is only so much I can do before ggml itself is fixed. After comparing the same 20 texts on M5, I decided the difference was acceptable—or rather, something I had to accept—so speech.cpp disables the tensor API.</p>
<table>
<thead>
<tr>
<th></th>
<th>tensor API</th>
<th>Disabled</th>
</tr>
</thead>
<tbody>
<tr>
<td>Irodori-TTS MF, First audio (median)</td>
<td>0.19 s</td>
<td>0.23 s</td>
</tr>
<tr>
<td>Irodori-TTS 16 steps, First audio (median)</td>
<td>0.85 s</td>
<td>1.12 s</td>
</tr>
<tr>
<td>Qwen3-TTS 0.6B, First audio (median)</td>
<td>0.045 s</td>
<td>0.043 s</td>
</tr>
<tr>
<td>Irodori-TTS codec, SNR against official implementation</td>
<td>47.6 dB</td>
<td>68.0 dB</td>
</tr>
</tbody>
</table>
<p>MF's first audio became 0.04 seconds slower, but the corruption disappeared and agreement with the official implementation improved. This is also a major factor in the M5 recognition gap discussed earlier. Once a newer ggml passes these shapes, I intend to enable the tensor API again and remeasure.</p>
<h2 id="supporting-openai-realtime-transcription" tabindex="-1"><a href="https://nyosegawa.com/en/posts/speech-cpp/#supporting-openai-realtime-transcription" class="header-anchor">Supporting OpenAI Realtime Transcription</a></h2>
<p>So far, I have discussed individual utterances. In practice, I also wanted to transcribe long recordings such as meetings and show text while someone was speaking. Version 0.8.0 adds both.</p>
<h3 id="whole-recordings-can-lose-sentences" tabindex="-1"><a href="https://nyosegawa.com/en/posts/speech-cpp/#whole-recordings-can-lose-sentences" class="header-anchor">Whole recordings can lose sentences</a></h3>
<p>While using the demo page, I noticed entire spoken sentences disappearing. The page sent audio in 20-second pieces. When a piece contained three or four sentences, ReazonSpeech sometimes dropped the first two. Official NeMo dropped the same sentences on the same audio, so this was a model characteristic, not a porting error.</p>
<p>I measured it by joining Japanese FLEURS and Common Voice test utterances with 0.3–1.0-second gaps into recordings lasting several minutes, then transcribing them whole and by speech region. The Appendix explains the construction. A sentence counts as dropped when more than half its characters disappear.</p>
<table>
<thead>
<tr>
<th>Model</th>
<th>Whole (321 FLEURS / 600 Common Voice sentences)</th>
<th>By speech region (same sets)</th>
</tr>
</thead>
<tbody>
<tr>
<td>ReazonSpeech NeMo v2</td>
<td>67 / 522</td>
<td>4 / 46</td>
</tr>
<tr>
<td>parakeet-tdt_ctc-0.6b-ja</td>
<td>264 / 266</td>
<td>0 / 11</td>
</tr>
<tr>
<td>Qwen3-ASR 0.6B</td>
<td>1 / 1</td>
<td>0 / 2</td>
</tr>
</tbody>
</table>
<p>The two FastConformer models lose many sentences when given several minutes at once. Qwen3-ASR loses very few, and its CER is even slightly better on whole recordings, 9.80% versus 11.44% on FLEURS. I suspect its LLM decoder benefits from the longer context. ReazonSpeech still loses 46 Common Voice sentences when processed by region because short utterances from different speakers sometimes land in one region; that is a feature of this artificially concatenated test.</p>
<p>I ported <a href="https://github.com/snakers4/silero-vad">Silero VAD</a> to find speech regions, transcribe them separately and join the results. It is a small 1.2 MB model and returns the same sample-level boundaries as the official <code>get_speech_timestamps</code>. Regions end after 0.5 seconds of silence, following OpenAI's default <code>server_vad</code> setting. Short pauses can still produce regions longer than 20 seconds, so I tested maximum region lengths from eight to 30 seconds and selected ten seconds, which gave the lowest average CER.</p>
<p>The HTTP server enables this with the OpenAI transcription API's <code>chunking_strategy</code>. First, start the server with both a recognition model and a VAD model. If the server from the earlier example is still running, stop it with Ctrl+C first.</p>
<pre><code class="language-sh">speech serve reazonspeech-v2 silero-vad
</code></pre>
<p>Call it from another terminal using the OpenAI Python SDK:</p>
<pre><code class="language-python">from openai import OpenAI

client = OpenAI(base_url=&quot;http://127.0.0.1:8080/v1&quot;, api_key=&quot;unused&quot;)
with open(&quot;meeting.wav&quot;, &quot;rb&quot;) as f:
    print(client.audio.transcriptions.create(model=&quot;reazonspeech-nemo-v2&quot;, file=f, chunking_strategy=&quot;auto&quot;).text)
</code></pre>
<h3 id="showing-text-while-someone-speaks" tabindex="-1"><a href="https://nyosegawa.com/en/posts/speech-cpp/#showing-text-while-someone-speaks" class="header-anchor">Showing text while someone speaks</a></h3>
<p>The other addition is live transcription. It follows <a href="https://developers.openai.com/api/docs/guides/realtime-transcription">OpenAI Realtime transcription</a>, so OpenAI clients can use it by changing the endpoint.</p>
<p>During speech, the Realtime API sends text in <code>delta</code> events. These can append text but cannot rewrite earlier text. OpenAI's <code>gpt-live-transcribe</code> also accumulates deltas and replaces the result with the full <code>completed</code> transcript at the end. Each time speech.cpp retranscribes the growing audio, it compares the result with the previous pass and sends only the prefix that agrees in two consecutive passes. Once speech ends, it sends the final transcript in <code>completed</code>.</p>
<p>I measured this on an otherwise idle M5 by streaming audio made from six utterances in real time, three times per model.</p>
<table>
<thead>
<tr>
<th></th>
<th>ReazonSpeech NeMo v2</th>
<th>Qwen3-ASR 0.6B</th>
</tr>
</thead>
<tbody>
<tr>
<td>Speech-start event (from onset)</td>
<td>0.25–0.27 s</td>
<td>0.25–0.27 s</td>
</tr>
<tr>
<td>First text (from onset)</td>
<td>0.69–1.32 s</td>
<td>0.50–1.15 s</td>
</tr>
<tr>
<td>Final transcript (from offset)</td>
<td>0.70–0.79 s</td>
<td>0.72–0.90 s</td>
</tr>
</tbody>
</table>
<p>About 0.6 seconds of the delay after speech ends comes from waiting to establish 0.5 seconds of silence. Lowering <code>silence_duration_ms</code> shortens it but makes breaths more likely to split utterances. The first text arrives later because two transcription passes must agree.</p>
<p>The command-line <code>--live</code> option transcribes a microphone in the same way. <a href="https://github.com/mackron/miniaudio">miniaudio</a> is linked into the executable, so there is no SDL2 or PortAudio installation to manage.</p>
<p>ASIST does not use this particular mechanism. It decides when an utterance ends itself, using its backchannel classifier and <a href="https://github.com/MaAI-Kyoto/MaAI">MaAI</a>'s turn-ending predictions. It passes its own completed utterances to the speech.cpp worker.</p>
<h2 id="what-bringing-everything-together-gives-me" tabindex="-1"><a href="https://nyosegawa.com/en/posts/speech-cpp/#what-bringing-everything-together-gives-me" class="header-anchor">What bringing everything together gives me</a></h2>
<p>I have discussed synthesis and recognition separately, but putting them in one engine has benefits of its own.</p>
<p>First, there is less to distribute. speech.cpp needs one <code>speech</code> executable and one model file. The Mac executable in version 0.8.2 is about 6.7 MB; the Windows executable is 48 MB because it includes Vulkan shaders. Each model, including its codec, fits in one GGUF file. Releases provide macOS Metal, Windows Vulkan, and Linux Vulkan and CPU builds; the installer selects the appropriate one. Neither CUDA nor Python is required.</p>
<p>Startup is quick too. ASIST loads speech models only when the microphone is enabled, so slow loading would leave the user waiting after switching it on.</p>
<p>I measured the time from process launch until the first request could be accepted on M5, with model files already in the OS file cache.</p>
<table>
<thead>
<tr>
<th>Implementation</th>
<th>Runtime requirements</th>
<th>Startup to ready</th>
</tr>
</thead>
<tbody>
<tr>
<td><strong>speech.cpp</strong></td>
<td>One executable</td>
<td><strong>0.4 s</strong></td>
</tr>
<tr>
<td>mlx-audio</td>
<td>Python 3.12 and MLX</td>
<td>2.3–3.1 s</td>
</tr>
<tr>
<td>audio.cpp</td>
<td>Server executable</td>
<td>2.0–3.8 s</td>
</tr>
<tr>
<td>Official implementation</td>
<td>Python 3.10 and PyTorch</td>
<td>9.0–11.0 s</td>
</tr>
</tbody>
</table>
<p>Every model is called through the same interfaces.</p>
<p><img src="https://nyosegawa.com/img/speech-cpp/overview.png" alt="The structure of speech.cpp"></p>
<p>There are four ways to use it:</p>
<ul>
<li>The <code>speech</code> command: <code>speech tts</code> reads text, <code>speech asr</code> transcribes audio, and <code>speech voice</code> creates a voice file from reference audio.</li>
<li>The HTTP server, <code>speech serve</code>: it follows the OpenAI audio APIs, <code>/v1/audio/speech</code> and <code>/v1/audio/transcriptions</code>, and Realtime transcription at <code>/v1/realtime</code>.</li>
<li>The worker, <code>speech worker</code>: it exchanges JSON Lines over standard input and output. This is what ASIST uses.</li>
<li>The C API, <code>speech.h</code>: all three interfaces above are built on it.</li>
</ul>
<p>ASIST now runs both synthesis and recognition through speech.cpp workers and no longer bundles llama-server.</p>
<h2 id="what-i-ended-up-with" tabindex="-1"><a href="https://nyosegawa.com/en/posts/speech-cpp/#what-i-ended-up-with" class="header-anchor">What I ended up with</a></h2>
<ul>
<li>Nine synthesis and recognition models, plus Silero VAD, run in one ggml-based engine with a single executable on Mac, Windows and Linux.</li>
<li>While checking each stage against the official implementation, I reduced Irodori-TTS's time to first audio to 0.25 seconds on M5 and made Qwen3-ASR faster than llama.cpp on RTX 2080.</li>
<li>I chose correctness over speed by disabling the M5 tensor API affected by the bug. FastConformer still trails NeMo-Speech.cpp in speed, which is something to work on next.</li>
<li>Transcribing long recordings by Silero VAD regions greatly reduces dropped sentences, and live transcription works through an OpenAI-compatible Realtime interface.</li>
</ul>
<h2 id="update%3A-faster-fastconformer-in-0.8.3-(2026-10-10)" tabindex="-1"><a href="https://nyosegawa.com/en/posts/speech-cpp/#update%3A-faster-fastconformer-in-0.8.3-(2026-10-10)" class="header-anchor">Update: faster FastConformer in 0.8.3 (2026-10-10)</a></h2>
<p>The FastConformer work I mentioned above is now in <a href="https://github.com/nyosegawa/speech.cpp/releases/tag/v0.8.3">speech.cpp 0.8.3</a>. I reduced repeated memory allocation inside the FFT, cached position computations that stay the same across inputs, and reused decoder computation graphs.</p>
<p>Running 0.8.2 and 0.8.3 alternately on the same audio gave these median times to a returned transcript. All models use F16 weights; loading and the first warmup request are excluded.</p>
<table>
<thead>
<tr>
<th>Model</th>
<th>M5 (Metal), 0.8.2 → 0.8.3</th>
<th>RTX 2080 (Vulkan), 0.8.2 → 0.8.3</th>
</tr>
</thead>
<tbody>
<tr>
<td>ReazonSpeech NeMo v2, greedy</td>
<td>72 → 54 ms</td>
<td>76 → 47 ms</td>
</tr>
<tr>
<td>parakeet-tdt-0.6b-v3</td>
<td>102 → 93 ms</td>
<td>128 → 82 ms</td>
</tr>
<tr>
<td>parakeet-tdt_ctc-0.6b-ja</td>
<td>56 → 52 ms</td>
<td>85 → 40 ms</td>
</tr>
</tbody>
</table>
<p>The Japanese timing sample uses the first 100 Common Voice inputs, 99 retained by VAD, repeated three times. English uses 300 FLEURS inputs, 299 retained by VAD, once each. These are different sample counts from the 4,483-input Japanese tables above, so they do not replace those results. ReazonSpeech's default beam search also improved from 104 to 85 ms on M5.</p>
<p>I checked accuracy separately on the complete sets on M5. Compared with the original article's run, ReazonSpeech greedy's CER on the 4,483 Japanese inputs changed from 12.50% to 12.49%, while CER allowing spelling variants stayed at 7.69%. Five of the 4,479 VAD-retained transcripts changed; empty results stayed at four. Parakeet v3's WER on 300 English inputs stayed at 8.76%.</p>
<p>Alternating requests with NeMo-Speech.cpp on the same audio now gives roughly equal speed on Windows. On M5, NeMo-Speech.cpp remains about 3 ms faster for ReazonSpeech and 7 ms faster for Parakeet v3, but disabling its tensor API makes speech.cpp as fast or faster. speech.cpp keeps the tensor API workaround described above. The <a href="https://github.com/nyosegawa/speech.cpp/pull/91#issuecomment-6085048115">PR's measurement report</a> has the full conditions and p90 values.</p>
<p>The model files have not changed. Updating speech.cpp is enough; existing GGUF files still work.</p>
<h2 id="appendix" tabindex="-1"><a href="https://nyosegawa.com/en/posts/speech-cpp/#appendix" class="header-anchor">Appendix</a></h2>
<h3 id="measurement-method" tabindex="-1"><a href="https://nyosegawa.com/en/posts/speech-cpp/#measurement-method" class="header-anchor">Measurement method</a></h3>
<p>For comparisons between inference implementations, I used <a href="https://github.com/nyosegawa/speech-bench">speech-bench</a>, which I also develop. speech.cpp was a 0.8.0 candidate, commit <code>f5ab84c</code>, built locally for M5 and by CI for RTX 2080. All implementations were measured on October 8, 2026.</p>
<p>Synthesis was measured on M5 with 32 GB of memory and macOS 26.2, and on Windows 11 with an RTX 2080, 8 GB of VRAM and driver 591.86. The 20 texts in speech-bench's <code>prompts/speak-ja-JP.json</code> include backchannels, replies, longer explanations and Japanese mixed with English words. Irodori-TTS used the approximately ten-second <code>voice-bright-young-woman</code> reference generated for speech-bench, with seed 1. Qwen3-TTS used the preset ono_anna voice, with a new seed each time. The first text was synthesized once before measurement to exclude first-use GPU overhead. CER was calculated by transcribing the generated audio with Qwen3-ASR 1.7B.</p>
<p>ASIST playback was measured on October 9, 2026, using the released version 0.8.0, which bundles speech.cpp 0.8.2, on the same M5. After one warmup text per model, I played each of the same 20 texts once, with backchannels and bridge phrases disabled. Qwen3-TTS used ono_anna; Irodori-TTS used ASIST's bundled calm-young-woman voice. The conversation API returned the requested text exactly in all 60 cases. Synthesis start was estimated by subtracting the app's reported synthesis time from the time that measurement arrived in the renderer. Voice onset was the first 20 ms window of the scheduled audio with RMS at or above 0.004. Inter-process communication adds measurement error; output-device latency is excluded.</p>
<p>For underrun checks, the speech.cpp worker read the same 20 texts while I recorded chunk arrival times. Assuming playback starts with the first chunk, there is no underrun if every later chunk arrives before all preceding audio finishes playing.</p>
<p>Recognition used the Common Voice 8.0 Japanese test set, 4,483 examples with 93,471 reference characters, in the same order for every implementation on M5 and RTX 2080. silero-vad v4 found the speech boundaries; trimming retained 0.2 seconds on each side. Four examples with no detected speech were excluded. Qwen3-ASR used explicit <code>ja</code>, and latency excluded model loading.</p>
<p>Here are the M5 recognition results:</p>
<table>
<thead>
<tr>
<th>Model</th>
<th>Implementation</th>
<th>Weights</th>
<th>CER</th>
<th>CER with accepted spellings</th>
<th>Latency (median)</th>
<th>Latency (p90)</th>
</tr>
</thead>
<tbody>
<tr>
<td>Qwen3-ASR 1.7B</td>
<td><strong>speech.cpp</strong></td>
<td>Q8_0</td>
<td>9.49%</td>
<td>4.67%</td>
<td>0.309 s</td>
<td>0.544 s</td>
</tr>
<tr>
<td>Qwen3-ASR 1.7B</td>
<td>llama.cpp b11246</td>
<td>Q8_0</td>
<td>9.29%</td>
<td>4.62%</td>
<td><strong>0.307 s</strong></td>
<td><strong>0.499 s</strong></td>
</tr>
<tr>
<td>Qwen3-ASR 0.6B</td>
<td><strong>speech.cpp</strong></td>
<td>Q8_0</td>
<td>11.89%</td>
<td>7.05%</td>
<td><strong>0.132 s</strong></td>
<td>0.231 s</td>
</tr>
<tr>
<td>Qwen3-ASR 0.6B</td>
<td>llama.cpp b11246</td>
<td>Q8_0</td>
<td>11.79%</td>
<td>6.95%</td>
<td>0.133 s</td>
<td><strong>0.212 s</strong></td>
</tr>
<tr>
<td>parakeet-tdt_ctc-0.6b-ja</td>
<td><strong>speech.cpp</strong></td>
<td>F16</td>
<td>7.88%</td>
<td>2.98%</td>
<td><strong>0.059 s</strong></td>
<td><strong>0.086 s</strong></td>
</tr>
<tr>
<td>parakeet-tdt_ctc-0.6b-ja</td>
<td>CrispASR v0.8.38</td>
<td>Q8_0</td>
<td>8.03%</td>
<td>3.12%</td>
<td>0.071 s</td>
<td>0.088 s</td>
</tr>
<tr>
<td>ReazonSpeech NeMo v2</td>
<td><strong>speech.cpp</strong> (beam search)</td>
<td>F16</td>
<td>12.03%</td>
<td>7.13%</td>
<td>0.110 s</td>
<td>0.167 s</td>
</tr>
<tr>
<td>ReazonSpeech NeMo v2</td>
<td><strong>speech.cpp</strong> (greedy)</td>
<td>F16</td>
<td>12.50%</td>
<td>7.69%</td>
<td>0.077 s</td>
<td>0.104 s</td>
</tr>
<tr>
<td>ReazonSpeech NeMo v2</td>
<td>CrispASR v0.8.38</td>
<td>Q8_0</td>
<td>11.79%</td>
<td>6.88%</td>
<td>0.074 s</td>
<td>0.097 s</td>
</tr>
<tr>
<td>ReazonSpeech NeMo v2</td>
<td>NeMo-Speech.cpp v0.2.0 (greedy)</td>
<td>F16</td>
<td>11.93%</td>
<td>7.10%</td>
<td><strong>0.054 s</strong></td>
<td><strong>0.075 s</strong></td>
</tr>
</tbody>
</table>
<p>Here is parakeet-tdt-0.6b-v3 on 300 English FLEURS utterances:</p>
<table>
<thead>
<tr>
<th>Platform</th>
<th>Implementation</th>
<th>Weights</th>
<th>WER</th>
<th>Latency (median)</th>
<th>Latency (p90)</th>
</tr>
</thead>
<tbody>
<tr>
<td>M5</td>
<td><strong>speech.cpp</strong></td>
<td>F16</td>
<td>8.76%</td>
<td>0.103 s</td>
<td>0.167 s</td>
</tr>
<tr>
<td>M5</td>
<td>NeMo-Speech.cpp v0.2.0</td>
<td>F16</td>
<td>9.06%</td>
<td><strong>0.085 s</strong></td>
<td><strong>0.134 s</strong></td>
</tr>
<tr>
<td>RTX 2080</td>
<td><strong>speech.cpp</strong></td>
<td>F16</td>
<td>8.78%</td>
<td>0.152 s</td>
<td>0.221 s</td>
</tr>
<tr>
<td>RTX 2080</td>
<td>NeMo-Speech.cpp v0.2.0</td>
<td>F16</td>
<td>9.06%</td>
<td><strong>0.094 s</strong></td>
<td><strong>0.130 s</strong></td>
</tr>
</tbody>
</table>
<p>NeMo-Speech.cpp does not publish F16 weights, so speech-bench used its conversion script to produce F16 files.</p>
<p>The long-recording experiment used speech.cpp's <code>measure/region_cap_compare.py</code> on M5 with Metal. It took 321 Japanese FLEURS test sentences, one recording per sentence, and the first 600 Common Voice 8.0 Japanese test recordings, trimmed them with Silero VAD, normalized them to -23 dBFS and joined them with 0.3–1.0-second gaps. Every fourth gap was two to eight seconds long, with -60 dBFS noise in the gaps. The resulting 105.6 minutes were transcribed with Japanese specified. CER used NFKC normalization with punctuation and whitespace removed. Results for each maximum region length follow, as FLEURS / Common Voice:</p>
<table>
<thead>
<tr>
<th>Region limit</th>
<th>ReazonSpeech CER</th>
<th>Dropped sentences</th>
<th>parakeet-ja CER</th>
<th>Dropped sentences</th>
<th>Qwen3-ASR 0.6B CER</th>
<th>Dropped sentences</th>
</tr>
</thead>
<tbody>
<tr>
<td>Whole recording</td>
<td>25.29% / 86.01%</td>
<td>67 / 522</td>
<td>83.39% / 44.87%</td>
<td>264 / 266</td>
<td>9.80% / 14.48%</td>
<td>1 / 1</td>
</tr>
<tr>
<td>No limit</td>
<td>11.97% / 16.81%</td>
<td>12 / 42</td>
<td>6.39% / 10.82%</td>
<td>3 / 19</td>
<td>10.55% / 14.21%</td>
<td>0 / 2</td>
</tr>
<tr>
<td>20 s</td>
<td>11.32% / 16.81%</td>
<td>10 / 42</td>
<td>6.06% / 10.82%</td>
<td>1 / 19</td>
<td>10.65% / 14.21%</td>
<td>0 / 2</td>
</tr>
<tr>
<td>15 s</td>
<td>10.90% / 16.96%</td>
<td>9 / 43</td>
<td>5.99% / 10.36%</td>
<td>2 / 16</td>
<td>10.71% / 14.11%</td>
<td>0 / 2</td>
</tr>
<tr>
<td>10 s</td>
<td>10.25% / 17.17%</td>
<td>4 / 46</td>
<td>5.75% / 9.97%</td>
<td>0 / 11</td>
<td>11.44% / 14.17%</td>
<td>0 / 2</td>
</tr>
<tr>
<td>8 s</td>
<td>10.31% / 17.38%</td>
<td>2 / 44</td>
<td>6.21% / 9.58%</td>
<td>0 / 8</td>
<td>11.63% / 14.28%</td>
<td>0 / 1</td>
</tr>
</tbody>
</table>
<p>Live-transcription latency was measured by joining reference dump audio with silence, converting it to 24 kHz, and streaming it to <code>/v1/realtime</code> in real-time 20-millisecond pieces. Speech onset and offset are the edges of the unpadded speech regions.</p>
<h3 id="stage-by-stage-comparison-results" tabindex="-1"><a href="https://nyosegawa.com/en/posts/speech-cpp/#stage-by-stage-comparison-results" class="header-anchor">Stage-by-stage comparison results</a></h3>
<p>These Irodori-TTS results start each stage from inputs dumped by the official implementation, on Apple M5:</p>
<table>
<thead>
<tr>
<th>Stage</th>
<th>CPU, F32</th>
<th>Metal, F32</th>
</tr>
</thead>
<tbody>
<tr>
<td>Text normalization and tokenizer (164 texts)</td>
<td>All match</td>
<td>All match</td>
</tr>
<tr>
<td>Text conditioning</td>
<td>118–123 dB</td>
<td>65–123 dB</td>
</tr>
<tr>
<td>Speaker conditioning</td>
<td>111 dB</td>
<td>51 dB</td>
</tr>
<tr>
<td>Duration prediction</td>
<td>All match</td>
<td>All match</td>
</tr>
<tr>
<td>Each DiT step</td>
<td>95 dB or higher</td>
<td>46 dB or higher</td>
</tr>
<tr>
<td>Sampler output (MF)</td>
<td>86–122 dB</td>
<td>36–67 dB</td>
</tr>
<tr>
<td>Codec decoding</td>
<td>119 dB</td>
<td>68 dB</td>
</tr>
<tr>
<td>Windowed versus full decoding</td>
<td>Match</td>
<td>Match</td>
</tr>
</tbody>
</table>
<p>Metal's SNR is lower because ggml's Metal matrix multiplication rounds inputs to half precision. MF takes only four large steps, so those differences remain in the latents. On Metal, the waveform therefore differs while speaking the same content.</p>
<p>Here is FastConformer, parakeet-tdt_ctc-0.6b-ja, on three Japanese FLEURS utterances lasting 6.36, 10.50 and 25.50 seconds:</p>
<table>
<thead>
<tr>
<th>Stage</th>
<th>CPU, F32</th>
<th>Metal</th>
</tr>
</thead>
<tbody>
<tr>
<td>Features</td>
<td>117–127 dB</td>
<td>Same (computed on CPU)</td>
</tr>
<tr>
<td>Convolutional subsampling</td>
<td>123 dB</td>
<td>68–70 dB</td>
</tr>
<tr>
<td>Encoder output (after 24 layers)</td>
<td>114–118 dB</td>
<td>63–64 dB</td>
</tr>
<tr>
<td>Tokens from official encoder output</td>
<td>Match</td>
<td>Match</td>
</tr>
<tr>
<td>Transcripts from audio (all stages ported)</td>
<td>All three match</td>
<td>All three match</td>
</tr>
</tbody>
</table>
<h3 id="inside-the-gguf-file" tabindex="-1"><a href="https://nyosegawa.com/en/posts/speech-cpp/#inside-the-gguf-file" class="header-anchor">Inside the GGUF file</a></h3>
<p>Each speech.cpp model occupies one GGUF file, including its codec. Names and languages use the standard GGUF metadata keys, so tools that read GGUF can display that information.</p>
<table>
<thead>
<tr>
<th>Key</th>
<th>Contents</th>
</tr>
</thead>
<tbody>
<tr>
<td><code>general.architecture</code></td>
<td>Which implementation runs the model: <code>qwen3-tts</code>, <code>irodori-tts</code>, <code>fastconformer</code> or <code>qwen3-asr</code></td>
</tr>
<tr>
<td><code>general.name</code>, <code>general.organization</code>, <code>general.size_label</code>, etc.</td>
<td>Model name, publishing organization and parameter count</td>
</tr>
<tr>
<td><code>general.source.url</code></td>
<td>The source repository and revision</td>
</tr>
<tr>
<td><code>general.languages</code></td>
<td>Supported synthesis or recognition languages, as ISO 639 codes</td>
</tr>
<tr>
<td><code>speech.layout</code>, <code>speech.requires</code></td>
<td>File layout version and the earliest speech.cpp version that can read it</td>
</tr>
<tr>
<td><code>speech.task</code>, <code>speech.sample_rate</code></td>
<td>Synthesis or recognition, and the audio sample rate</td>
</tr>
</tbody>
</table>
<p>Before loading weights, speech.cpp checks that metadata and tensor names, shapes and types are complete. It rejects missing or unexpected entries by name. <code>speech info</code> displays this information without loading the weights.</p>
<h3 id="reproducing-the-tensor-api-bug" tabindex="-1"><a href="https://nyosegawa.com/en/posts/speech-cpp/#reproducing-the-tensor-api-bug" class="header-anchor">Reproducing the tensor API bug</a></h3>
<p>Add matrix-multiplication cases with 64 and 192 output columns to ggml's <code>test-backend-ops</code> and run them on M5. They fail with incorrect results through the tensor API and pass when <code>GGML_METAL_TENSOR_DISABLE=1</code> disables it. Detailed instructions are in <a href="https://github.com/nyosegawa/speech.cpp/issues/13">speech.cpp#13</a>.</p>
<h2 id="references" tabindex="-1"><a href="https://nyosegawa.com/en/posts/speech-cpp/#references" class="header-anchor">References</a></h2>
<ul>
<li>speech.cpp
<ul>
<li><a href="https://github.com/nyosegawa/speech.cpp">nyosegawa/speech.cpp</a></li>
<li><a href="https://github.com/nyosegawa/speech-bench">nyosegawa/speech-bench</a></li>
<li><a href="https://github.com/nyosegawa/speech.cpp/issues/13">speech.cpp#13: Metal on Apple M5 corrupts matrix products whose column count is 64 modulo 128</a></li>
<li><a href="https://huggingface.co/datasets/sakasegawa/common-voice-ja-accepted-spellings">sakasegawa/common-voice-ja-accepted-spellings</a></li>
<li><a href="https://github.com/ggml-org/ggml">ggml-org/ggml</a></li>
</ul>
</li>
<li>Models
<ul>
<li><a href="https://github.com/Aratako/Irodori-TTS">Aratako/Irodori-TTS</a></li>
<li><a href="https://huggingface.co/Aratako/Irodori-TTS-v4.1-Small-MF">Aratako/Irodori-TTS-v4.1-Small-MF</a></li>
<li><a href="https://github.com/Aratako/Irodori-TTS/blob/main/docs/meanflow.md">MeanFlow Distillation (Irodori-TTS docs)</a></li>
<li><a href="https://huggingface.co/Qwen/Qwen3-TTS-12Hz-0.6B-CustomVoice">Qwen/Qwen3-TTS-12Hz-0.6B-CustomVoice</a></li>
<li><a href="https://huggingface.co/Qwen/Qwen3-ASR-1.7B">Qwen/Qwen3-ASR-1.7B</a></li>
<li><a href="https://huggingface.co/nvidia/parakeet-tdt_ctc-0.6b-ja">nvidia/parakeet-tdt_ctc-0.6b-ja</a></li>
<li><a href="https://huggingface.co/nvidia/parakeet-tdt-0.6b-v3">nvidia/parakeet-tdt-0.6b-v3</a></li>
<li><a href="https://huggingface.co/reazon-research/reazonspeech-nemo-v2">reazon-research/reazonspeech-nemo-v2</a></li>
<li><a href="https://github.com/snakers4/silero-vad">snakers4/silero-vad</a></li>
</ul>
</li>
<li>OpenAI
<ul>
<li><a href="https://developers.openai.com/api/docs/guides/realtime-transcription">Realtime transcription</a></li>
</ul>
</li>
<li>Paper
<ul>
<li><a href="https://arxiv.org/abs/2209.03003">Flow Straight and Fast: Learning to Generate and Transfer Data with Rectified Flow</a></li>
<li><a href="https://arxiv.org/abs/2505.13447">Mean Flows for One-step Generative Modeling</a></li>
<li><a href="https://arxiv.org/abs/2205.14135">FlashAttention: Fast and Memory-Efficient Exact Attention with IO-Awareness</a></li>
<li><a href="https://arxiv.org/abs/2305.05084">Fast Conformer with Linearly Scalable Attention for Efficient Speech Recognition</a></li>
<li><a href="https://www.pnas.org/doi/10.1073/pnas.0903616106">Universals and cultural variation in turn-taking in conversation</a></li>
<li><a href="https://aclanthology.org/2023.cawl-1.8/">Lenient Evaluation of Japanese Speech Recognition: Modeling Naturally Occurring Spelling Inconsistency</a></li>
<li><a href="https://arxiv.org/abs/2603.00941">Towards Orthographically-Informed Evaluation of Speech Recognition Systems for Indian Languages</a></li>
<li><a href="https://arxiv.org/abs/2509.24613">HiKE: Hierarchical Evaluation Framework for Korean-English Code-Switching Speech Recognition</a></li>
<li><a href="https://arxiv.org/abs/2609.21084">The Hidden Cost of Digits: Number Normalization and WER in ASR Systems</a></li>
<li><a href="https://jordandarefsky.com/blog/2025/echo/">Echo-TTS</a></li>
<li><a href="https://arxiv.org/abs/2601.15621">Qwen3-TTS Technical Report</a></li>
</ul>
</li>
<li>Other implementations
<ul>
<li><a href="https://github.com/ggml-org/llama.cpp">ggml-org/llama.cpp</a></li>
<li><a href="https://github.com/FunAudioLLM/CosyVoice">FunAudioLLM/CosyVoice</a></li>
<li><a href="https://github.com/Blaizzy/mlx-audio">Blaizzy/mlx-audio</a></li>
<li><a href="https://github.com/0xShug0/audio.cpp">0xShug0/audio.cpp</a></li>
<li><a href="https://github.com/mackron/miniaudio">mackron/miniaudio</a></li>
<li><a href="https://github.com/MaAI-Kyoto/MaAI">MaAI-Kyoto/MaAI</a></li>
</ul>
</li>
<li>Articles on accelerating Irodori-TTS
<ul>
<li><a href="https://note.com/rosso_blog/n/n3eaee67d2c70">irodori-TTS を速くする方法を理解したい (株式会社Rosso)</a></li>
<li><a href="https://zenn.dev/yoshitetsu/articles/e616831f96b44a">AITuber向けに Mac MPS で Irodori-TTS をチューニングして2.72倍高速化 / bf16 では遅くなった (osushi_cr)</a></li>
</ul>
</li>
<li>ONNX Runtime
<ul>
<li><a href="https://onnxruntime.ai/docs/execution-providers/CoreML-ExecutionProvider.html">CoreML Execution Provider</a></li>
<li><a href="https://onnxruntime.ai/docs/execution-providers/DirectML-ExecutionProvider.html">DirectML Execution Provider</a></li>
</ul>
</li>
</ul>
]]>
      </content:encoded>
      <pubDate>Fri, 09 Oct 2026 00:00:00 GMT</pubDate>
    </item>
    <item>
      <title>音声対話のための音声合成・音声認識エンジン、speech.cppを作ってみた話</title>
      <link>https://nyosegawa.com/posts/speech-cpp/</link>
      <guid isPermaLink="false">https://nyosegawa.com/posts/speech-cpp/</guid>
      <description>
        ggmlで動く音声合成と音声認識のエンジン、speech.cppを作りました。公式の実装と段階ごとに比較して同じ結果を出すこと、会話に間に合う速さ、MacとWindowsとLinuxで実行ファイル1つで動くことを目指した話と、ほかの実装との比較をまとめます。
      </description>
      <content:encoded>
        <![CDATA[<p>こんにちは！逆瀬川ちゃん (<a href="https://x.com/gyakuse">@gyakuse</a>) です！</p>
<p>今日は音声対話のために作った音声合成と音声認識のエンジン、speech.cppについてまとめていきたいと思います。</p>
<!--more-->
<h2 id="%E4%BD%9C%E3%81%A3%E3%81%9F%E3%82%82%E3%81%AE" tabindex="-1"><a href="https://nyosegawa.com/posts/speech-cpp/#%E4%BD%9C%E3%81%A3%E3%81%9F%E3%82%82%E3%81%AE" class="header-anchor">作ったもの</a></h2>
<p><a href="https://github.com/nyosegawa/speech.cpp">speech.cpp</a>は、音声合成と音声認識のモデルを<a href="https://github.com/ggml-org/ggml">ggml</a>の上で動かすC++のエンジンです。MacではMetal、WindowsとLinuxではVulkanでGPUを使い、GPUがなければCPUで動きます。</p>
<p>いま動かせるのは次のモデルです。どれも、元のモデルの重みをspeech.cpp用のGGUFのファイルに変換して、Hugging Faceに置いています。</p>
<h3 id="%E9%9F%B3%E5%A3%B0%E5%90%88%E6%88%90%E3%83%A2%E3%83%87%E3%83%AB" tabindex="-1"><a href="https://nyosegawa.com/posts/speech-cpp/#%E9%9F%B3%E5%A3%B0%E5%90%88%E6%88%90%E3%83%A2%E3%83%87%E3%83%AB" class="header-anchor">音声合成モデル</a></h3>
<table>
<thead>
<tr>
<th>モデル</th>
<th>言語</th>
<th>speech.cpp用のGGUF</th>
</tr>
</thead>
<tbody>
<tr>
<td>Qwen3-TTS</td>
<td>10言語</td>
<td><a href="https://huggingface.co/sakasegawa/Qwen3-TTS-12Hz-0.6B-CustomVoice-GGUF">0.6B</a> / <a href="https://huggingface.co/sakasegawa/Qwen3-TTS-12Hz-1.7B-CustomVoice-GGUF">1.7B</a></td>
</tr>
<tr>
<td>Irodori-TTS</td>
<td>日本語</td>
<td><a href="https://huggingface.co/sakasegawa/Irodori-TTS-v4.1-Small-MF-GGUF">v4.1-Small-MF</a> / <a href="https://huggingface.co/sakasegawa/Irodori-TTS-v4.1-Small-GGUF">v4.1-Small</a></td>
</tr>
</tbody>
</table>
<p>Qwen3-TTSは用意された9人の声から選べます。Irodori-TTSは録音や言葉で声を指定でき、速さを重視するならMFが向いています。</p>
<h3 id="%E9%9F%B3%E5%A3%B0%E8%AA%8D%E8%AD%98%E3%83%A2%E3%83%87%E3%83%AB" tabindex="-1"><a href="https://nyosegawa.com/posts/speech-cpp/#%E9%9F%B3%E5%A3%B0%E8%AA%8D%E8%AD%98%E3%83%A2%E3%83%87%E3%83%AB" class="header-anchor">音声認識モデル</a></h3>
<table>
<thead>
<tr>
<th>モデル</th>
<th>言語</th>
<th>speech.cpp用のGGUF</th>
</tr>
</thead>
<tbody>
<tr>
<td>Qwen3-ASR</td>
<td>30言語</td>
<td><a href="https://huggingface.co/sakasegawa/Qwen3-ASR-0.6B-GGUF">0.6B</a> / <a href="https://huggingface.co/sakasegawa/Qwen3-ASR-1.7B-GGUF">1.7B</a></td>
</tr>
<tr>
<td>parakeet-tdt_ctc-0.6b-ja</td>
<td>日本語</td>
<td><a href="https://huggingface.co/sakasegawa/parakeet-tdt_ctc-0.6b-ja-GGUF">ダウンロード</a></td>
</tr>
<tr>
<td>parakeet-tdt-0.6b-v3</td>
<td>ヨーロッパの25言語</td>
<td><a href="https://huggingface.co/sakasegawa/parakeet-tdt-0.6b-v3-GGUF">ダウンロード</a></td>
</tr>
<tr>
<td>ReazonSpeech NeMo v2</td>
<td>日本語</td>
<td><a href="https://huggingface.co/sakasegawa/reazonspeech-nemo-v2-GGUF">ダウンロード</a></td>
</tr>
</tbody>
</table>
<p>Qwen3-ASRは、固有名詞などをプロンプトで教えられます。日本語ではparakeet-jaが速く、ReazonSpeechはビームサーチで書き起こします。精度と速さは後半で比較します。</p>
<h3 id="%E9%9F%B3%E5%A3%B0%E5%8C%BA%E9%96%93%E6%A4%9C%E5%87%BA%E3%83%A2%E3%83%87%E3%83%AB" tabindex="-1"><a href="https://nyosegawa.com/posts/speech-cpp/#%E9%9F%B3%E5%A3%B0%E5%8C%BA%E9%96%93%E6%A4%9C%E5%87%BA%E3%83%A2%E3%83%87%E3%83%AB" class="header-anchor">音声区間検出モデル</a></h3>
<table>
<thead>
<tr>
<th>モデル</th>
<th>言語</th>
<th>speech.cpp用のGGUF</th>
</tr>
</thead>
<tbody>
<tr>
<td>Silero VAD v6.2</td>
<td>言語によらない</td>
<td><a href="https://huggingface.co/sakasegawa/silero-vad-GGUF">ダウンロード</a></td>
</tr>
</tbody>
</table>
<p>音声のどこで人が話しているかを見つけます。長尺の録音を区間ごとに書き起こしたり、話し終わりを見つけたりするのに使います。</p>
<h2 id="%E4%BD%BF%E3%81%A3%E3%81%A6%E3%81%BF%E3%82%8B" tabindex="-1"><a href="https://nyosegawa.com/posts/speech-cpp/#%E4%BD%BF%E3%81%A3%E3%81%A6%E3%81%BF%E3%82%8B" class="header-anchor">使ってみる</a></h2>
<h3 id="%E3%82%A4%E3%83%B3%E3%82%B9%E3%83%88%E3%83%BC%E3%83%AB%E3%81%99%E3%82%8B" tabindex="-1"><a href="https://nyosegawa.com/posts/speech-cpp/#%E3%82%A4%E3%83%B3%E3%82%B9%E3%83%88%E3%83%BC%E3%83%AB%E3%81%99%E3%82%8B" class="header-anchor">インストールする</a></h3>
<p>インストールは1行です。Apple siliconのMacと、x86-64のLinuxではこちら、</p>
<pre><code class="language-sh">curl -fsSL https://raw.githubusercontent.com/nyosegawa/speech.cpp/main/install.sh | sh
</code></pre>
<p>Windows x64ではPowerShellで実行します（Vulkan対応のGPUドライバーが必要です）。</p>
<pre><code class="language-powershell">irm https://raw.githubusercontent.com/nyosegawa/speech.cpp/main/install.ps1 | iex
</code></pre>
<p>Linuxの配布版には、AVX2・FMA・F16Cに対応するCPUとglibc 2.34以降が必要です。Vulkanローダーが入っていなければ、インストーラーがCPU版を選びます。Intel Macではソースからビルドしてください。詳しい条件は<a href="https://github.com/nyosegawa/speech.cpp/blob/main/docs/install.md">インストールの説明</a>にあります。</p>
<p>モデルは名前で指定します。初めて使うときに、Hugging Faceから取ってきてOSのキャッシュのフォルダに置きます。</p>
<h3 id="%E3%83%96%E3%83%A9%E3%82%A6%E3%82%B6%E3%81%A7%E8%A9%A6%E3%81%99" tabindex="-1"><a href="https://nyosegawa.com/posts/speech-cpp/#%E3%83%96%E3%83%A9%E3%82%A6%E3%82%B6%E3%81%A7%E8%A9%A6%E3%81%99" class="header-anchor">ブラウザで試す</a></h3>
<p>いちばん手軽なのは、試すためのページを開くことです。</p>
<pre><code class="language-sh">speech serve --open
</code></pre>
<p><img src="https://nyosegawa.com/img/speech-cpp/page.png" alt="speech serveの試すページ"></p>
<p>Speak、Transcribe、Liveのタブで、モデルを選んで文を読ませたり、録音や音声ファイルを書き起こしたり、マイクに話しているそばから書き起こしたりできます。</p>
<h3 id="%E6%96%87%E3%82%92%E8%AA%AD%E3%81%BF%E4%B8%8A%E3%81%92%E3%82%8B" tabindex="-1"><a href="https://nyosegawa.com/posts/speech-cpp/#%E6%96%87%E3%82%92%E8%AA%AD%E3%81%BF%E4%B8%8A%E3%81%92%E3%82%8B" class="header-anchor">文を読み上げる</a></h3>
<p>コマンドからも使えます。Qwen3-TTSで読む場合です。</p>
<pre><code class="language-sh">speech tts qwen3-tts-0.6b --voice ono_anna --language ja -o out.wav &quot;明日の東京は晴れです。&quot;
</code></pre>
<h3 id="%E5%A5%BD%E3%81%8D%E3%81%AA%E5%A3%B0%E3%81%A7%E8%AA%AD%E3%81%BF%E4%B8%8A%E3%81%92%E3%82%8B" tabindex="-1"><a href="https://nyosegawa.com/posts/speech-cpp/#%E5%A5%BD%E3%81%8D%E3%81%AA%E5%A3%B0%E3%81%A7%E8%AA%AD%E3%81%BF%E4%B8%8A%E3%81%92%E3%82%8B" class="header-anchor">好きな声で読み上げる</a></h3>
<p>Irodori-TTSは、参照音声のWAVファイルを直接渡して、その声で読み上げられます。この例では、合成した音声を<code>out.wav</code>に保存します。</p>
<pre><code class="language-sh">speech tts irodori-tts-mf \
    --add-voice me=my-voice.wav --voice me \
    -o out.wav &quot;こんにちは。&quot;
</code></pre>
<p>同じ声を繰り返し使うなら、<code>speech voice</code>で声のファイルを一度作っておくこともできます。WAVを渡すたびに参照音声をエンコードする時間を省けます。</p>
<pre><code class="language-sh">speech voice irodori-tts-mf my-voice.wav my-voice.voice.gguf
speech tts irodori-tts-mf \
    --add-voice me=my-voice.voice.gguf --voice me \
    -o out.wav &quot;こんにちは。&quot;
</code></pre>
<p>参照音声がなくても、声の特徴を言葉で書けば、それに合った声で読んでくれます。</p>
<pre><code class="language-sh">speech tts irodori-tts-mf --voice none \
    --instructions &quot;低く落ち着いた男性の声で、ゆっくりと読み上げてください。&quot; \
    -o out.wav &quot;明日の東京は晴れです。&quot;
</code></pre>
<h3 id="%E6%9B%B8%E3%81%8D%E8%B5%B7%E3%81%93%E3%81%99" tabindex="-1"><a href="https://nyosegawa.com/posts/speech-cpp/#%E6%9B%B8%E3%81%8D%E8%B5%B7%E3%81%93%E3%81%99" class="header-anchor">書き起こす</a></h3>
<p>Qwen3-ASRは、固有名詞をプロンプトで教えられます。</p>
<pre><code class="language-sh">speech asr parakeet-tdt_ctc-0.6b-ja utterance.wav
speech asr qwen3-asr-1.7b --language ja --prompt &quot;逆瀬川, Codex, Claude&quot; utterance.wav
</code></pre>
<h3 id="%E9%95%B7%E5%B0%BA%E3%81%AE%E9%8C%B2%E9%9F%B3%E3%82%84%E3%83%9E%E3%82%A4%E3%82%AF%E3%81%8B%E3%82%89%E6%9B%B8%E3%81%8D%E8%B5%B7%E3%81%93%E3%81%99" tabindex="-1"><a href="https://nyosegawa.com/posts/speech-cpp/#%E9%95%B7%E5%B0%BA%E3%81%AE%E9%8C%B2%E9%9F%B3%E3%82%84%E3%83%9E%E3%82%A4%E3%82%AF%E3%81%8B%E3%82%89%E6%9B%B8%E3%81%8D%E8%B5%B7%E3%81%93%E3%81%99" class="header-anchor">長尺の録音やマイクから書き起こす</a></h3>
<p>長尺の録音は、Silero VADで話している区間を見つけて、区間ごとに書き起こします。<code>--live</code>にすると、マイクに向かって話しているそばから書き起こします。</p>
<pre><code class="language-sh">speech asr reazonspeech-v2 --vad silero-vad meeting.wav
speech asr reazonspeech-v2 --vad silero-vad --live
</code></pre>
<h3 id="http%E3%82%B5%E3%83%BC%E3%83%90%E3%83%BC%E3%81%A8%E3%81%97%E3%81%A6%E4%BD%BF%E3%81%86" tabindex="-1"><a href="https://nyosegawa.com/posts/speech-cpp/#http%E3%82%B5%E3%83%BC%E3%83%90%E3%83%BC%E3%81%A8%E3%81%97%E3%81%A6%E4%BD%BF%E3%81%86" class="header-anchor">HTTPサーバーとして使う</a></h3>
<p>HTTPサーバーを立てると、OpenAIのAPIと同じ形で呼べます。</p>
<pre><code class="language-sh">speech serve irodori-tts-mf --add-voice me=my-voice.voice.gguf
curl http://127.0.0.1:8080/v1/audio/speech -H 'Content-Type: application/json' \
    -d '{&quot;input&quot;: &quot;明日の東京は晴れです。&quot;, &quot;voice&quot;: &quot;me&quot;}' -o out.wav
</code></pre>
<p>Pythonからは、届いたところからPCMを受け取れます。</p>
<pre><code class="language-python">import requests
import wave

with requests.post(&quot;http://127.0.0.1:8080/v1/audio/speech&quot;,
                   json={&quot;input&quot;: &quot;明日の東京は晴れです。&quot;, &quot;voice&quot;: &quot;me&quot;, &quot;response_format&quot;: &quot;pcm&quot;},
                   stream=True) as r:
    r.raise_for_status()
    with wave.open(&quot;out.wav&quot;, &quot;wb&quot;) as w:
        w.setnchannels(1)
        w.setsampwidth(2)
        w.setframerate(int(r.headers[&quot;X-Sample-Rate&quot;]))
        for chunk in r.iter_content(chunk_size=None):
            w.writeframes(chunk)
</code></pre>
<h3 id="%E3%82%A2%E3%83%97%E3%83%AA%E3%81%AB%E7%B5%84%E3%81%BF%E8%BE%BC%E3%82%80" tabindex="-1"><a href="https://nyosegawa.com/posts/speech-cpp/#%E3%82%A2%E3%83%97%E3%83%AA%E3%81%AB%E7%B5%84%E3%81%BF%E8%BE%BC%E3%82%80" class="header-anchor">アプリに組み込む</a></h3>
<p>ふつうはHTTPサーバーかワーカーで足ります。アプリに直接組み込みたいときは、C APIを使います (<a href="https://github.com/nyosegawa/speech.cpp/blob/main/docs/c-api.md">C API</a>より)。</p>
<pre><code class="language-c">speech_model * model = NULL;
speech_request * request = NULL;
if (check(speech_model_load(&quot;Irodori-TTS-866M-MF-v4.1-F16.gguf&quot;, NULL, &amp;model)) &amp;&amp;
    check(speech_voice_add(model, &quot;bright&quot;, &quot;bright-young-woman-10s.voice.gguf&quot;)) &amp;&amp;
    check(speech_request_new(model, &amp;request)) &amp;&amp;
    check(speech_request_set_text(request, &quot;明日の東京は晴れです。&quot;)) &amp;&amp;
    check(speech_request_set_string(request, SPEECH_OPT_VOICE, &quot;bright&quot;)) &amp;&amp;
    check(speech_synthesize(request, on_audio, NULL))) {
    /* on_audio が、できた音声を少しずつ受け取る */
}
speech_request_free(request);
speech_model_free(model);
</code></pre>
<h2 id="%E3%81%AA%E3%81%9C%E4%BD%9C%E3%81%A3%E3%81%9F%E3%81%8B" tabindex="-1"><a href="https://nyosegawa.com/posts/speech-cpp/#%E3%81%AA%E3%81%9C%E4%BD%9C%E3%81%A3%E3%81%9F%E3%81%8B" class="header-anchor">なぜ作ったか</a></h2>
<p>わたしは<a href="https://nyosegawa.com/posts/asist-realtime-voice-assistant/">ASIST</a>というデスクトップ向けの音声対話アシスタントを作っています。音声対話でいちばんしんどいのは待ち時間です。人どうしの会話では、相手が話し終えてから次の人が話し始めるまでは200ミリ秒くらいだそうです (<a href="https://www.pnas.org/doi/10.1073/pnas.0903616106">Stivers et al., 2009</a>)。アシスタントが1秒黙っていると聞こえているか心配になります。</p>
<aside class="promo">
<p class="promo-label">ここでいったんCMです。</p>
<p class="promo-message">デスクトップ向けのアシスタントを作りました！よかったら使ってみてください。</p>
<p><a href="https://asist-agent.com/"><img src="https://nyosegawa.com/img/speech-cpp/asist-banner.jpg" alt="ASIST: 話しかけるだけで、予定もメールも片づく。Mac と Windows で使えるリアルタイムアシスタント"></a></p>
<p class="promo-links"><a class="promo-button promo-primary" href="https://asist-agent.com/">公式サイトを見る</a><a class="promo-button" href="https://github.com/nyosegawa/asist">GitHub</a></p>
</aside>
<p>日本語の声はAratakoさんのIrodori-TTSがとても良くて、読みが自然ですし、参照音声を渡せばその声で話してくれます。ただ、そのまま動かすと若干遅く、リアルタイムの会話には少し向きません。</p>
<p>Windowsではめちゃくちゃつらく、検証に使っているマシンはRTX 2080 (8 GB) なのですが、PythonとCUDAで音声のモデルを動かそうとすると、ことごとくうまくいきませんでした。</p>
<ul>
<li>Qwen3-TTSは、PyTorchの公式のパッケージでWindowsで動かせませんでした</li>
<li>音声認識に使っていたllama.cppは、CUDA 13向けのビルドがRTX 20向けの機械語を含んでおらず起動しませんでした。CUDA 12向けのビルドは動きましたが、最初の起動でGPUのコードのコンパイルに28秒かかりました</li>
</ul>
<p>CUDAとPythonで行くなら、NVIDIAのGPUでドライバー580以上、RTX 20以降が前提になり、数GBのPythonの環境も配ることになります。</p>
<p>しかも、モデルごとに動かし方がばらばらでした。ASISTでは、音声認識をllama.cppのllama-serverで、読み上げを自分で作ったワーカーで動かしていました。llama-serverのほうは、モデルとmmprojの2つのファイル、ループバックのポートとその鍵、アプリが落ちたときに止める見張りが要り、文脈が4096トークンなので約270秒より長い発話は失敗していました。</p>
<p>なので、音声合成も音声認識もひとつのエンジンで動かせて、MacでもWindowsでもLinuxでも同じものがGPUで動いて、会話に間に合う速さで、公式の実装と同じ結果を出すものが欲しい気持ちがありました。まずは、声が出るまでを短くする話からです。</p>
<h2 id="%E9%9F%B3%E5%A3%B0%E5%90%88%E6%88%90%3A-%E5%A3%B0%E3%81%8C%E5%87%BA%E3%82%8B%E3%81%BE%E3%81%A7%E3%82%92%E7%9F%AD%E3%81%8F%E3%81%97%E3%81%A6%E9%80%94%E5%88%87%E3%82%8C%E3%81%AA%E3%81%84%E3%82%88%E3%81%86%E3%81%AB%E3%81%99%E3%82%8B" tabindex="-1"><a href="https://nyosegawa.com/posts/speech-cpp/#%E9%9F%B3%E5%A3%B0%E5%90%88%E6%88%90%3A-%E5%A3%B0%E3%81%8C%E5%87%BA%E3%82%8B%E3%81%BE%E3%81%A7%E3%82%92%E7%9F%AD%E3%81%8F%E3%81%97%E3%81%A6%E9%80%94%E5%88%87%E3%82%8C%E3%81%AA%E3%81%84%E3%82%88%E3%81%86%E3%81%AB%E3%81%99%E3%82%8B" class="header-anchor">音声合成: 声が出るまでを短くして途切れないようにする</a></h2>
<h3 id="%E6%9C%80%E5%88%9D%E3%81%AE%E5%A3%B0%E3%81%8C%E5%87%BA%E3%82%8B%E3%81%BE%E3%81%A7%E3%82%92%E8%A6%8B%E3%82%8B" tabindex="-1"><a href="https://nyosegawa.com/posts/speech-cpp/#%E6%9C%80%E5%88%9D%E3%81%AE%E5%A3%B0%E3%81%8C%E5%87%BA%E3%82%8B%E3%81%BE%E3%81%A7%E3%82%92%E8%A6%8B%E3%82%8B" class="header-anchor">最初の声が出るまでを見る</a></h3>
<p>ASISTは会話のモデルの返事を文ごとに分けて、届いたものから読み上げています。Irodori-TTSには1文ずつ渡し、Qwen3-TTSには待っている文を合計300文字以内でまとめて渡します。続きは先の音声を再生しているあいだに作れるので、利用者が最初に待つのは最初の文の声が出るまでです。なので全体の速さ (RTF、1秒の音声を作るのに何秒かかるか) よりも、最初の声が出るまでの時間を見ていきます。</p>
<p>計測するときは、相槌、短い返事、長めの説明、英単語まじりのテキストなど20文を、どの実装にも同じ声で読ませて中央値を取りました。Irodori-TTSのseedは固定し、Qwen3-TTSのseedは毎回変えています (くわしくはAppendixに書いています)。</p>
<h3 id="irodori-tts%E3%81%AE%E3%81%97%E3%81%8F%E3%81%BF" tabindex="-1"><a href="https://nyosegawa.com/posts/speech-cpp/#irodori-tts%E3%81%AE%E3%81%97%E3%81%8F%E3%81%BF" class="header-anchor">Irodori-TTSのしくみ</a></h3>
<p>Irodori-TTSは、LLMのように1トークンずつ出していくモデルではありません。<a href="https://jordandarefsky.com/blog/2025/echo/">Echo-TTS</a>にならったDiffusion Transformer (DiT) が、音声コーデックの潜在表現 (latent、48 kHz、25 Hz、32次元) をテキスト全体ぶん一気に作り、コーデックがそれを波形に戻します。音声の長さは、生成の前に別のモデルが予測します。</p>
<p><img src="https://nyosegawa.com/img/speech-cpp/pipeline.png" alt="Irodori-TTSの処理の流れ"></p>
<p>DiTはテキスト全体をいっぺんに見て解くので、途中から声を出すことはできません。公式の実装では、声が出るまでに、テキストの準備、長さの予測、サンプラーの全ステップ、コーデックのデコードが全部終わっている必要があります。</p>
<p>DiTがノイズから音声を作るやり方の違いで、モデルが2つあります。</p>
<p>もとのv4.1-Smallは<a href="https://arxiv.org/abs/2209.03003">Rectified Flow</a>で学習しています。ノイズと音声のあいだを直線で結んで、その途中の各点で「どちらにどれだけ進めばいいか」(速度) を覚えさせます。生成するときは、いまの速度を予測して少し進む、を40回繰り返します。テキストや話者に強く従わせるCFGのために、前半はDiTを3回ずつ計算するので、けっこう重いです。</p>
<p>v4.1-Small-MFは、v4.1-Smallを教師モデルとして<a href="https://arxiv.org/abs/2505.13447">MeanFlow</a>で蒸留したモデルです。MeanFlowは、その時点の速度ではなく、ある区間の平均の速度を覚えます。その時点の速度のまま大股で進むと曲がった道からはみ出してしまいますが、区間の平均の速度がわかっていれば大股でも目的地に着けます。教師モデルのCFG付きの道筋をそのまま学ぶので、生徒モデルは1ステップにDiTを1回計算するだけで済み、4ステップで音声ができます (<a href="https://github.com/Aratako/Irodori-TTS/blob/main/docs/meanflow.md">Irodori-TTSのMeanFlowの説明</a>)。</p>
<p><img src="https://nyosegawa.com/img/speech-cpp/rf-vs-meanflow.png" alt="Rectified FlowとMeanFlowの進み方の違い"></p>
<p><a href="https://huggingface.co/Aratako/Irodori-TTS-v4.1-Small-MF">MFのモデルカード</a>によると、v4.1-Smallをそのまま4ステップにすると参照音声との声の近さが0.75から0.38まで落ちますが、MFなら4ステップで0.74を保てます。会話では速さが大事なので、ASISTではMFを使っています。</p>
<h3 id="%E5%85%AC%E5%BC%8F%E3%81%AE%E5%AE%9F%E8%A3%85%E3%81%AF%E3%81%A9%E3%81%93%E3%81%AB%E6%99%82%E9%96%93%E3%82%92%E4%BD%BF%E3%81%A3%E3%81%A6%E3%81%84%E3%82%8B%E3%81%8B" tabindex="-1"><a href="https://nyosegawa.com/posts/speech-cpp/#%E5%85%AC%E5%BC%8F%E3%81%AE%E5%AE%9F%E8%A3%85%E3%81%AF%E3%81%A9%E3%81%93%E3%81%AB%E6%99%82%E9%96%93%E3%82%92%E4%BD%BF%E3%81%A3%E3%81%A6%E3%81%84%E3%82%8B%E3%81%8B" class="header-anchor">公式の実装はどこに時間を使っているか</a></h3>
<p>では公式の実装は何に時間を使っているのでしょうか。M5のMPSで、MFに33文字のテキストを読ませて処理ごとに計測しました。</p>
<table>
<thead>
<tr>
<th>処理</th>
<th>参照音声をWAVEで渡す</th>
<th>エンコードした潜在表現を渡す</th>
</tr>
</thead>
<tbody>
<tr>
<td>参照音声のエンコード</td>
<td>0.92〜1.04秒</td>
<td>0.001〜0.005秒</td>
</tr>
<tr>
<td>長さの予測</td>
<td>0.25〜0.28秒</td>
<td>0.22〜0.24秒</td>
</tr>
<tr>
<td>サンプラー (4ステップ)</td>
<td>0.62〜0.63秒</td>
<td>0.55〜0.60秒</td>
</tr>
<tr>
<td>コーデックのデコード</td>
<td>1.29〜1.40秒</td>
<td>1.30〜1.41秒</td>
</tr>
<tr>
<td>合計</td>
<td>3.09〜3.35秒</td>
<td>2.07〜2.25秒</td>
</tr>
</tbody>
</table>
<p>参照音声をWAVEファイルで渡すと、読ませるたびに毎回エンコードしています。一度エンコードした潜在表現を渡せばこの1秒はなくなり、音声もまったく同じものが出ます。残りでいちばん時間がかかっているのは、コーデックのデコードでした。MFでステップが4まで減ったので、DiTよりコーデックのほうが重くなっています。</p>
<h3 id="%E9%80%9F%E3%81%8F%E3%81%99%E3%82%8B%E6%96%B9%E6%B3%95%E3%82%92%E8%80%83%E3%81%88%E3%82%8B" tabindex="-1"><a href="https://nyosegawa.com/posts/speech-cpp/#%E9%80%9F%E3%81%8F%E3%81%99%E3%82%8B%E6%96%B9%E6%B3%95%E3%82%92%E8%80%83%E3%81%88%E3%82%8B" class="header-anchor">速くする方法を考える</a></h3>
<p>この内訳を見ながら、速くする方法を考えてみます。</p>
<p>ステップを減らすのは、MFを使えば済みます。参照音声のエンコードは、一度作った潜在表現を使い回せば済みます。</p>
<p>Irodori-TTSを速くする工夫は、ほかの方も試して記事にしています。<a href="https://note.com/rosso_blog/n/n3eaee67d2c70">Rossoさんの記事</a>は、RTX 3080とv4で1文の時間を処理ごとに分けて測り、DiTの計算そのものより、GPUに命令を出す手間で時間がかかっていることを確かめています。そこでDiTをCUDA Graphに記録して命令を出す手間を省き、文ごとに違う音声の長さは、予測した長さを少し切り上げていくつかの長さにそろえることで対応しています。5.1秒の音声を作る時間が0.90秒から0.30秒になり、Sway Samplingでステップを8に減らすと0.23秒になっています。</p>
<p>Macでは<a href="https://zenn.dev/yoshitetsu/articles/e616831f96b44a">osushi_crさんの記事</a>があります。M1 MaxのMPSとv2で、Sway Samplingでステップを40から8に減らし、モデルを常駐させて次の文を再生中に作っておくことで、5文の読み上げを28.77秒から10.58秒にしています。MPSではbf16にするとかえって遅くなり、コーデックをCPUに置くと2倍ほど遅くなったそうです。</p>
<p>どちらもPyTorchのまま、動かし方を工夫して速くしています。speech.cppでは、MacでもWindowsでも同じものをGPUで動かしたいので、別の方向も考えました。</p>
<p>重みを8ビットに量子化して軽くする手もあります。ただ、Irodori-TTSで試すと、20文を書き起こしたCERが2.99%から3.81%に増え、聞いてわかる崩れも出ました。</p>
<p>PyTorchから別の実行環境に移す方法もあります。ONNX Runtimeは、Macでは<a href="https://onnxruntime.ai/docs/execution-providers/CoreML-ExecutionProvider.html">CoreMLを経由</a>し、これはプレビュー扱いで入力の形が変わると遅くなります。Windowsの<a href="https://onnxruntime.ai/docs/execution-providers/DirectML-ExecutionProvider.html">DirectML</a>は保守だけの扱いになりました。MLXはMacでしか動きません。MacとWindowsの両方でGPUを使いたいので、ggmlを選びました。</p>
<p><a href="https://github.com/ggml-org/ggml">ggml</a>は、llama.cppやwhisper.cppのもとになっているC/C++のテンソルのライブラリです。Metal、Vulkan、CUDA、CPU向けの計算をそれぞれ持っていて、計算グラフは呼ぶたびに組み立てるので、テキストの長さが毎回違っても困りません。実行ファイルにそのまま組み込めるので、使う側は何もインストールしなくて済みます。</p>
<p>最後に、作り終える前に声を出してしまう方法です。DiTはテキスト全体ぶんを一度に作るので途中では出せませんが、DiTが終わったあとのコーデックのデコードは、少しずつ進めて流せます。MFではコーデックのほうがDiTより重いので、ここを少しずつ流せば待ち時間はかなり減るはずです。</p>
<h3 id="speech.cpp%E3%81%A7%E3%82%84%E3%81%A3%E3%81%9F%E3%81%93%E3%81%A8" tabindex="-1"><a href="https://nyosegawa.com/posts/speech-cpp/#speech.cpp%E3%81%A7%E3%82%84%E3%81%A3%E3%81%9F%E3%81%93%E3%81%A8" class="header-anchor">speech.cppでやったこと</a></h3>
<p>speech.cppでは、MFをggmlに移植し、参照音声は潜在表現にしてファイル (声のファイル) に保存し、コーデックをウィンドウに分けてデコードしました。</p>
<p>コーデックは、全体を一度にデコードするのをやめました。最初の12フレーム (0.48秒ぶん) をデコードしたらすぐ返して再生を始め、残りは再生しているあいだに24〜48フレームずつデコードします。ウィンドウの大きさは、それまでのウィンドウで計測したデコードの速さから、聞いている人の手元に0.1秒ぶんの音声が残っているうちに終わる、いちばん大きいものを選びます。遅いPCでは小さく、速いPCでは大きくなります。</p>
<p><img src="https://nyosegawa.com/img/speech-cpp/chunked-decode.png" alt="コーデックをウィンドウに分けてデコードし、再生と重ねる"></p>
<p>ウィンドウに分けるときは、つなぎ目で音が変わらないようにしています。各ウィンドウの前後に10フレームずつ余分にデコードして、その部分を捨てると、一度にデコードしたときと同じ波形になります。どの大きさで分けてもビット単位で同じなので、PCの速さでウィンドウの大きさが変わっても、同じseedなら同じ音声が出ます。これでデコードの時間のほとんどが再生と重なり、待ち時間にはほぼ入らなくなりました。</p>
<p>もうひとつ、会話ならではの工夫があります。会話では、アシスタントが話している途中でこちらが話し始めることがよくあります。そのとき読み上げ中の文を取り消しても、サンプラーが最後まで回ってしまうと次の返事が待たされます。そこでサンプラーのステップの合間に取り消されていないかを確かめるようにしました。120文字のテキストを取り消した直後の返事は、声が出るまで881ミリ秒かかっていたのが351ミリ秒になりました。</p>
<h3 id="qwen3-tts%E3%81%AF1%E3%83%95%E3%83%AC%E3%83%BC%E3%83%A0%E3%81%9A%E3%81%A4%E6%B5%81%E3%81%99" tabindex="-1"><a href="https://nyosegawa.com/posts/speech-cpp/#qwen3-tts%E3%81%AF1%E3%83%95%E3%83%AC%E3%83%BC%E3%83%A0%E3%81%9A%E3%81%A4%E6%B5%81%E3%81%99" class="header-anchor">Qwen3-TTSは1フレームずつ流す</a></h3>
<p>Qwen3-TTSは、Irodori-TTSとは逆に、LLMのように音声トークンを1フレームずつ出していくモデルです。1フレームは0.08秒ぶんの音声で、16個のコードブックのトークンでできています。talker (Qwen3のデコーダー) が各フレームの1つ目のトークンを出し、code predictorが残りの15個を埋めます。コーデックも過去の入力しか見ないので、1フレームできるたびにデコードして流せます。speech.cppではコーデックの途中の状態を次の呼び出しに持ち越しているので、1フレームずつデコードしても、全体を一度にデコードしたときとほぼ同じ波形になります (後で比較するqwentts.cppとの違いは、ここです)。</p>
<h3 id="%E3%81%BB%E3%81%8B%E3%81%AE%E5%AE%9F%E8%A3%85%E3%81%A8%E6%AF%94%E8%BC%83%E3%81%99%E3%82%8B" tabindex="-1"><a href="https://nyosegawa.com/posts/speech-cpp/#%E3%81%BB%E3%81%8B%E3%81%AE%E5%AE%9F%E8%A3%85%E3%81%A8%E6%AF%94%E8%BC%83%E3%81%99%E3%82%8B" class="header-anchor">ほかの実装と比較する</a></h3>
<p>M5で、同じ20文を、同じ参照音声、同じseedで読ませて比較しました。参照音声のエンコードは、どの実装も一度だけにしています。speech.cppの行は名前を太字にし、同じモデルの中でいちばん速い値も太字にしました (このあとの表も同じです)。</p>
<table>
<thead>
<tr>
<th>モデル</th>
<th>実装</th>
<th>最初の音声まで (中央値)</th>
<th>最初の音声まで (p90)</th>
<th>RTF</th>
<th>CER</th>
</tr>
</thead>
<tbody>
<tr>
<td>v4.1-Small-MF</td>
<td><strong>speech.cpp</strong> (Metal、F16)</td>
<td><strong>0.25秒</strong></td>
<td><strong>0.52秒</strong></td>
<td><strong>0.17</strong></td>
<td>6.14%</td>
</tr>
<tr>
<td>v4.1-Small-MF</td>
<td><a href="https://github.com/Blaizzy/mlx-audio">mlx-audio</a> (MLX、FP16)</td>
<td>1.01秒</td>
<td>2.67秒</td>
<td>0.17</td>
<td>4.48%</td>
</tr>
<tr>
<td>v4.1-Small-MF</td>
<td>公式の実装 (PyTorch、MPS、FP32)</td>
<td>1.33秒</td>
<td>3.19秒</td>
<td>0.21</td>
<td>8.62%</td>
</tr>
<tr>
<td>v4.1-Small、16ステップ</td>
<td><strong>speech.cpp</strong> (Metal、F16)</td>
<td><strong>1.24秒</strong></td>
<td><strong>3.40秒</strong></td>
<td>0.34</td>
<td>3.15%</td>
</tr>
<tr>
<td>v4.1-Small、16ステップ</td>
<td>mlx-audio</td>
<td>1.84秒</td>
<td>4.54秒</td>
<td>0.31</td>
<td>1.99%</td>
</tr>
<tr>
<td>v4.1-Small、16ステップ</td>
<td>公式の実装</td>
<td>2.69秒</td>
<td>7.37秒</td>
<td>0.45</td>
<td>3.48%</td>
</tr>
<tr>
<td>v4-Small、16ステップ</td>
<td>audio.cpp v0.9.0 (コーデックもMetal)</td>
<td>1.55秒</td>
<td>4.26秒</td>
<td><strong>0.28</strong></td>
<td>9.62%</td>
</tr>
<tr>
<td>v4-Small、16ステップ</td>
<td>audio.cpp v0.9.0 (コーデックはCPU)</td>
<td>10.22秒</td>
<td>30.11秒</td>
<td>1.77</td>
<td>5.31%</td>
</tr>
<tr>
<td>Qwen3-TTS 0.6B</td>
<td><strong>speech.cpp</strong> (Metal、Q8_0)</td>
<td>0.04秒</td>
<td>0.05秒</td>
<td>0.31</td>
<td>5.80%</td>
</tr>
<tr>
<td>Qwen3-TTS 1.7B</td>
<td><strong>speech.cpp</strong> (Metal、Q8_0)</td>
<td>0.06秒</td>
<td>0.11秒</td>
<td>0.42</td>
<td>2.99%</td>
</tr>
</tbody>
</table>
<p>RTX 2080 (Vulkan) のWindowsでも計測しました。mlx-audioはMacでしか動かず、公式の実装はWindowsでは試していないので、この表には入っていません。</p>
<table>
<thead>
<tr>
<th>モデル</th>
<th>実装</th>
<th>最初の音声まで (中央値)</th>
<th>最初の音声まで (p90)</th>
<th>RTF</th>
<th>CER</th>
</tr>
</thead>
<tbody>
<tr>
<td>v4.1-Small-MF</td>
<td><strong>speech.cpp</strong> (Vulkan、F16)</td>
<td>0.12秒</td>
<td>0.20秒</td>
<td>0.07</td>
<td>7.13%</td>
</tr>
<tr>
<td>v4.1-Small、16ステップ</td>
<td><strong>speech.cpp</strong> (Vulkan、F16)</td>
<td><strong>0.52秒</strong></td>
<td><strong>1.12秒</strong></td>
<td><strong>0.13</strong></td>
<td>3.15%</td>
</tr>
<tr>
<td>v4-Small、16ステップ</td>
<td>audio.cpp v0.9.0 (Vulkan)</td>
<td>1.06秒</td>
<td>3.01秒</td>
<td>0.19</td>
<td>3.81%</td>
</tr>
<tr>
<td>Qwen3-TTS 0.6B</td>
<td><strong>speech.cpp</strong> (Vulkan、Q8_0)</td>
<td>0.03秒</td>
<td>0.04秒</td>
<td>0.27</td>
<td>7.96%</td>
</tr>
<tr>
<td>Qwen3-TTS 1.7B</td>
<td><strong>speech.cpp</strong> (Vulkan、Q8_0)</td>
<td>0.04秒</td>
<td>0.05秒</td>
<td>0.32</td>
<td>2.82%</td>
</tr>
</tbody>
</table>
<p>表の時間は、合成を要求してから最初の音声が届くまでです。声が実際に始まるまでとは違います。Qwen3-TTS 0.6Bは、文の頭に無音を作ることが多いモデルです。最初の音声は0.04秒で届いても、声が聞こえ始めるのは中央値で0.5秒ほどあとでした (4人の声で0.24〜0.48秒、長いときは1.9秒)。公式の実装でも同じで、ono_annaに同じ文を20回読ませると、頭の無音は中央値で0.89秒ありました。1.7Bは0.10秒、Irodori-TTSのMFは0.03秒なので、会話で使うなら1.7BかIrodori-TTSのほうが早く声が聞こえます。</p>
<p>公式の実装もmlx-audioもaudio.cppも、音声を全部作り終えてから返すので、声が出るまでの時間は1文を作り終える時間と同じです。speech.cppは最初のウィンドウをデコードしたら返すので、ここで差がつきます。MFのRTFはmlx-audioとほぼ同じ (0.174と0.171) なので、全体の速さはあまり変わらず、声が出るまでの差はほとんどがウィンドウに分けてデコードしたおかげです。CERは、合成した音声をQwen3-ASR 1.7Bで書き起こして数えたもので、実装ごとにノイズの作り方が違うので、1回の計測だと数ポイントは上下します。audio.cppはMFに対応していないので、RFの16ステップで比較しています。</p>
<h3 id="%E5%A3%B0%E3%81%8C%E5%87%BA%E3%81%9F%E3%81%82%E3%81%A8%E3%81%AB%E9%80%94%E5%88%87%E3%82%8C%E3%81%AA%E3%81%84%E3%82%88%E3%81%86%E3%81%AB%E3%81%99%E3%82%8B" tabindex="-1"><a href="https://nyosegawa.com/posts/speech-cpp/#%E5%A3%B0%E3%81%8C%E5%87%BA%E3%81%9F%E3%81%82%E3%81%A8%E3%81%AB%E9%80%94%E5%88%87%E3%82%8C%E3%81%AA%E3%81%84%E3%82%88%E3%81%86%E3%81%AB%E3%81%99%E3%82%8B" class="header-anchor">声が出たあとに途切れないようにする</a></h3>
<p>声が早く出ても、途中で途切れては困ります。音声をチャンクに分けて少しずつ渡すときは、届いた音声を再生し終える前に、次のチャンクが届いている必要があります。最初のチャンクを小さくすれば声は早く出ますが、そのぶん次のチャンクが間に合いにくくなります。</p>
<p>ほかの実装は、これをそれぞれ別のやり方で避けています。Qwen3-TTSの公式は、最初から4フレーム (0.32秒分) ずつ送ります (<a href="https://arxiv.org/abs/2601.15621">Qwen3-TTS Technical Report</a>)。途切れにくい代わりに、最初の声は4フレームを作り終えるまで出ません。<a href="https://github.com/FunAudioLLM/CosyVoice">CosyVoice</a>は、チャンクを毎回決まった倍率で大きくしていきます。倍率は、動かすPCの速さに合わせて人が決めます。再生する側で多めに貯めてから鳴らすやり方もありますが、そのぶん声が出るのが遅れます。</p>
<p>speech.cppのQwen3-TTSでは、最初は小さく、だんだん大きくしていくようにしました。チャンクの大きさは、1フレーム、1フレーム、2フレーム、そのあとは4フレームずつです。</p>
<p><img src="https://nyosegawa.com/img/speech-cpp/adaptive-chunks.png" alt="チャンクの大きさを固定した場合とspeech.cppの比較"></p>
<p>1フレームを作るのにかかる時間は、M5でもRTX 2080でも、1フレームぶんの音声 (0.08秒) よりずっと短いので、小さいチャンクで始めれば、次のチャンクが届く前に手持ちの音声が尽きることはありません。そのあとは4フレームずつまとめて送るので、コーデックを呼ぶ回数もほとんど増えません。</p>
<p>チャンクの大きさを、その場の速さを計測しながら決めることも考えました。ただ、チャンクの分け方が変わると、聞き分けられないほどわずかですが音声の値も変わります。その場の速さで分け方を決めると、同じseedでも実行するたびに音声が変わってしまうので、決まった表にしています。ここまでの話は、生成した音声を頭の無音も含めて、そのまま再生する場合です。</p>
<p>本当に途切れないかも確かめました。同じ20文を読ませ、最初のチャンクが届いたらすぐ再生を始めたとして、手元の音声を鳴らし終える前に次のチャンクが届いているかを見ています。M5でもRTX 2080でも、Qwen3-TTSの0.6Bと1.7B、Irodori-TTSのMFと16ステップのどれも、20文すべてで一度も途切れませんでした。</p>
<p>ASISTでは、頭のほぼ無音な部分だけを削ります。息やため息などの音が始まったら、その後の間も残します。ただ、頭を削るとその間に次の音声を作る余裕もなくなるので、最初の音が届いてから、届いている音声の長さと合わせて0.2秒になるだけ待って鳴らします。すでに前の文の再生を待つ間に0.2秒以上届いていれば、追加では待ちません。</p>
<p>公開版のASIST 0.8.0でも、M5で同じ20文を読ませて確かめました。モデルを読み込んだあと、合成開始から声の部分を再生する予定時刻までの中央値は、Qwen3-TTS 0.6Bが0.40秒、1.7Bが0.25秒、Irodori-TTS MFが0.26秒でした。これはWeb Audioの再生予定から求めた値で、スピーカーや出力機器の遅延は含めていません。</p>
<p>これで音声合成は、会話に間に合う速さで、途切れずに話せるようになりました。</p>
<h3 id="%E5%85%AC%E5%BC%8F%E5%AE%9F%E8%A3%85%E3%81%A8%E5%90%8C%E3%81%98%E5%BD%A2%E7%8A%B6%E3%81%A7%E3%81%82%E3%82%8B%E3%81%93%E3%81%A8%E3%82%92%E3%81%97%E3%81%A3%E3%81%8B%E3%82%8A%E7%A2%BA%E3%81%8B%E3%82%81%E3%82%8B" tabindex="-1"><a href="https://nyosegawa.com/posts/speech-cpp/#%E5%85%AC%E5%BC%8F%E5%AE%9F%E8%A3%85%E3%81%A8%E5%90%8C%E3%81%98%E5%BD%A2%E7%8A%B6%E3%81%A7%E3%81%82%E3%82%8B%E3%81%93%E3%81%A8%E3%82%92%E3%81%97%E3%81%A3%E3%81%8B%E3%82%8A%E7%A2%BA%E3%81%8B%E3%82%81%E3%82%8B" class="header-anchor">公式実装と同じ形状であることをしっかり確かめる</a></h3>
<p>モデルを別の実行環境に移すと、計算の順番や精度が少しずつ変わります。音声合成なら声が少し変わり、音声認識なら書き起こしのテキストが変わります。困るのは、聞いただけでは気づけないことです。音声合成は毎回ノイズから作るので、もともと読むたびに声が少し違いますし、音声認識の違いは数十件に1件にしか出ないこともあります。</p>
<p>そこでspeech.cppでは、移植するモデルごとに、公式の実装と段階ごとに比較するようにしました。</p>
<p>たとえばIrodori-TTSでは、テキストの処理、DiT、デコードの段階ごとに、次のように比べています。</p>
<p><img src="https://nyosegawa.com/img/speech-cpp/stage-check.png" alt="公式の実装と段階ごとに比較する"></p>
<ol>
<li>公式の実装をCPUのfloat32で、ノイズを固定して動かし、各段階の入力と出力をファイルに書き出す</li>
<li>移植したほうの各段階に、書き出した同じ入力を渡す</li>
<li>出力を比較する。数値はSNR (誤差が信号よりどれだけ小さいか) で、トークンや文字列は完全に一致するかで見る</li>
</ol>
<p>ほかのモデルも、段階の分け方が違うだけで同じように比べています。Qwen3-TTSはサンプリングせずにgreedyで動かして比べ、音声認識のモデルは、音声の特徴量、エンコーダー、デコーダーの段階ごとに比べます。</p>
<p>1段ずつ同じ入力から始めるので、どこでずれたかがすぐわかります。CPUのfloat32なら、Irodori-TTSはどの段階も公式と86 dB以上の精度で一致し、Qwen3-TTSはgreedyデコードで読ませると公式と同じ54フレームを出し、parakeetとReazonSpeechは音声から書き起こしたテキストがすべて公式と一致します (表はAppendixにあります)。</p>
<p>この比較をしていると、ほかの実装でずれてるとこにも気がつけるようになりました。</p>
<ul>
<li>llama.cppのQwen3-ASRは、公式と違うテキストを書くことがありました。10個の録音を書き起こさせると (言語を自動にした場合と指定した場合で、合わせて20回)、0.6Bで6回、1.7Bで4回が公式と違っていました。たとえば公式が「群島や湖では必ずしもヨット」と書いたところを、1.7Bは「軍港や湖ではカマザタ寿司もヨット」と書いています。原因は、モデルに渡す入力が公式と少しずつ違うことでした。最初に置く指示 (systemの発言) が抜けていて、音声の特徴量 (log-mel) が1フレーム多く、音声の最後の1秒に満たない部分をゼロで埋めて、その無音のぶんまで音声のトークンにしています</li>
<li><a href="https://github.com/ServeurpersoCom/qwentts.cpp">qwentts.cpp</a>のQwen3-TTSは、少しずつ流しながら作ると、声に震えるような雑音が乗りました。同じテキストを一度に全部作ったときは乗りません。Qwen3-TTSのコーデックは、それまでの音声の続きとして次の音声を作る仕組みです。なので、チャンクをまたいでコーデックの途中の状態を引き継げば、少しずつ作っても一度に作ったときとほぼ同じ波形になります。speech.cppはそうしていて、1フレームずつ作った音声と一度に作った音声の差が、聞き分けられないほど小さい (133 dB) ことを確かめています</li>
<li><a href="https://github.com/CrispStrobe/CrispASR">CrispASR</a>のReazonSpeechは、前後に無音の残る録音で、録音にない「あっ。」や「うん。」を書き足しました (Common Voiceの最初の10文のうち4文)。前後の無音を詰めると、4,479文のうち数文に減ります</li>
<li><a href="https://github.com/0xShug0/audio.cpp">audio.cpp</a>のIrodori-TTSは、コーデックをMetalで動かすと、声の14 dB下に歪んだ同じ声が重なり、無音のはずの部分も持ち上がります</li>
</ul>
<p>どれも、聞き比べただけでは気づきにくい違いです。逆に、段階ごとに公式と同じだと確かめられていれば、速くするために計算の形を変えても、結果が変わっていないことをすぐ確かめられます。次は、ユーザーの声を聞くほうです。</p>
<h2 id="%E9%9F%B3%E5%A3%B0%E8%AA%8D%E8%AD%98%3A-%E3%81%8D%E3%82%8C%E3%81%84%E3%81%AB%E7%A7%BB%E6%A4%8D%E3%81%A7%E3%81%8D%E3%82%8B%E3%82%88%E3%81%86%E3%81%AB%E3%81%8C%E3%82%93%E3%81%B0%E3%82%8B" tabindex="-1"><a href="https://nyosegawa.com/posts/speech-cpp/#%E9%9F%B3%E5%A3%B0%E8%AA%8D%E8%AD%98%3A-%E3%81%8D%E3%82%8C%E3%81%84%E3%81%AB%E7%A7%BB%E6%A4%8D%E3%81%A7%E3%81%8D%E3%82%8B%E3%82%88%E3%81%86%E3%81%AB%E3%81%8C%E3%82%93%E3%81%B0%E3%82%8B" class="header-anchor">音声認識: きれいに移植できるようにがんばる</a></h2>
<h3 id="fastconformer%E3%82%921%E5%9B%9E%E3%81%A0%E3%81%91%E7%A7%BB%E6%A4%8D%E3%81%99%E3%82%8B" tabindex="-1"><a href="https://nyosegawa.com/posts/speech-cpp/#fastconformer%E3%82%921%E5%9B%9E%E3%81%A0%E3%81%91%E7%A7%BB%E6%A4%8D%E3%81%99%E3%82%8B" class="header-anchor">FastConformerを1回だけ移植する</a></h3>
<p>音声認識のモデルで最初に移植したのは、NVIDIAのNeMoのモデルです。parakeet-tdt_ctc-0.6b-ja (日本語)、parakeet-tdt-0.6b-v3 (ヨーロッパの25言語)、ReazonSpeech NeMo v2 (日本語) の3つは、どれも<a href="https://arxiv.org/abs/2305.05084">FastConformer</a>という同じ形をしています。log-melの特徴を作り、畳み込みで時間方向を8分の1にして、24層のConformerに通し、最後のデコーダーでトークンにします。</p>
<p>違いは細かいところだけです。</p>
<table>
<thead>
<tr>
<th></th>
<th>parakeet-ja</th>
<th>parakeet-v3</th>
<th>ReazonSpeech</th>
</tr>
</thead>
<tbody>
<tr>
<td>melのbin</td>
<td>80</td>
<td>128</td>
<td>80</td>
</tr>
<tr>
<td>attention</td>
<td>録音全体</td>
<td>録音全体</td>
<td>前後10.24秒 (local attention)</td>
</tr>
<tr>
<td>デコーダー</td>
<td>TDT</td>
<td>TDT</td>
<td>RNN-T のビームサーチ</td>
</tr>
</tbody>
</table>
<p>なので、FastConformerを1回だけ移植して、違うところはGGUFのファイルに書いた設定で切り替えるようにしました。</p>
<p>デコードのやり方は、NeMoの<code>transcribe()</code>がデフォルトで使うものに合わせています。parakeet-jaにはCTCの頭もついているのですが、NeMoはデフォルトでTDTを使い、2つは一部の発話で違うテキストを書くので、TDTだけを移植しました。ReazonSpeechも、NeMoのgreedyデコードではなく、モデルの設定にあるビームサーチ (幅4) を使います。greedyデコードだと、句読点が抜けたり別の言葉になったりする発話があったからです。ただgreedyのほうがずっと軽いので、頼めばgreedyでもデコードできるようにしました。Common Voiceの4,483文では、M5でCERが12.03%から12.50%に上がるかわりに、待ち時間の中央値は0.110秒から0.077秒になります。</p>
<h3 id="qwen3-asr%E3%82%82%E7%B5%84%E3%81%BF%E8%BE%BC%E3%82%80%E3%81%93%E3%81%A8%E3%81%AB" tabindex="-1"><a href="https://nyosegawa.com/posts/speech-cpp/#qwen3-asr%E3%82%82%E7%B5%84%E3%81%BF%E8%BE%BC%E3%82%80%E3%81%93%E3%81%A8%E3%81%AB" class="header-anchor">Qwen3-ASRも組み込むことに</a></h3>
<p>Qwen3-ASRは、音声のエンコーダーの後ろにQwen3の言語モデルがつながった形をしています。言語モデルの部分は、キャッシュを持って1トークンずつ書き出していくので、llama.cppがもともと得意なところです。なので最初は、Qwen3-ASRはllama.cppで動かして、speech.cppではFastConformerだけを動かすつもりでした。同じことを2回作る必要はないと考えたからです。</p>
<p>ところが、段階ごとに比較してみると、前に書いたとおりllama.cppのプロンプトは公式と違い、20回中4〜6回でテキストが変わりました。約270秒より長い発話も失敗します。それに、llama-serverを動かし続ける限り、ASISTはspeech.cppとは別の実行環境をもう1つ抱えることになります。</p>
<p>そこで考え直して、Qwen3-ASRもspeech.cppに移植しました。実はQwen3のデコーダーは、Qwen3-TTSのtalkerですでに移植していました。同じものなので、そのまま使い回せます。新しく作ったのは、8秒ずつのウィンドウの中だけを見る音声のエンコーダーと、公式の<a href="https://github.com/QwenLM/Qwen3-ASR">qwen-asr</a>が書くとおりのプロンプトです。言語を指定すると、答えの書き出しに<code>language Japanese&lt;asr_text&gt;</code>を置いてモデルを導くところまで、公式と同じにしています。こうすると、書き起こしのテキストは公式と同じになりました。</p>
<p>公式と同じ書き起こしが出せるようになったので、次はどのくらい正しく聞き取れているかを計測したくなります。ところが日本語では、この「正しさ」を計測すること自体にも難しさがありました。</p>
<h2 id="%E8%A1%A8%E8%A8%98%E3%81%AE%E6%8F%BA%E3%82%8C%E3%82%92%E8%A8%B1%E3%81%97%E3%81%A6%E6%97%A5%E6%9C%AC%E8%AA%9E%E3%81%AE%E6%9B%B8%E3%81%8D%E8%B5%B7%E3%81%93%E3%81%97%E3%81%AE%E7%B2%BE%E5%BA%A6%E3%82%92%E8%A8%88%E6%B8%AC%E3%81%99%E3%82%8B" tabindex="-1"><a href="https://nyosegawa.com/posts/speech-cpp/#%E8%A1%A8%E8%A8%98%E3%81%AE%E6%8F%BA%E3%82%8C%E3%82%92%E8%A8%B1%E3%81%97%E3%81%A6%E6%97%A5%E6%9C%AC%E8%AA%9E%E3%81%AE%E6%9B%B8%E3%81%8D%E8%B5%B7%E3%81%93%E3%81%97%E3%81%AE%E7%B2%BE%E5%BA%A6%E3%82%92%E8%A8%88%E6%B8%AC%E3%81%99%E3%82%8B" class="header-anchor">表記の揺れを許して日本語の書き起こしの精度を計測する</a></h2>
<p>音声認識の正しさは、ふつう書き起こしと参照のテキストの文字の誤り率 (CER) で計測します。ただ日本語は、同じ言葉にいくつもの書き方があります。参照が「三十分」で書き起こしが「30分」なら、聞き取りは合っているのに、CERでは誤りとして数えられてしまいます。</p>
<table>
<thead>
<tr>
<th>参照</th>
<th>書き起こし</th>
<th>誤りとして数えるべきか</th>
</tr>
</thead>
<tbody>
<tr>
<td>27パーセント</td>
<td>27%</td>
<td>数えない (同じ言葉の別の書き方)</td>
</tr>
<tr>
<td>三十分</td>
<td>30分</td>
<td>数えない</td>
</tr>
<tr>
<td>今日</td>
<td>きょう</td>
<td>数えない</td>
</tr>
<tr>
<td>README</td>
<td>リードミ</td>
<td>数えない</td>
</tr>
<tr>
<td>機会</td>
<td>機械</td>
<td>数える (同じ音でも、意味がまったく違う言葉)</td>
</tr>
<tr>
<td>3時半</td>
<td>3時30分</td>
<td>数える (意味は同じでも、話した言葉と違う)</td>
</tr>
</tbody>
</table>
<p>モデルが良くなるほど、CERは「聞き取れたか」より「参照と同じ書き方を選んだか」を計測するようになっていきます。</p>
<p>これは日本語に限った話ではなく、いくつも研究があります。</p>
<ul>
<li><a href="https://aclanthology.org/2023.cawl-1.8/">Karita, Sproat, Ishikawa (CAWL 2023)</a>は、日本語の参照のテキストを、ありうる書き方の組み合わせ (ラティス) に広げてから採点しています。人が見て95.4%の書き方を妥当と判断し、CERはタスクによって2.4〜3.1ポイント下がりました</li>
<li><a href="https://arxiv.org/abs/2603.00941">OIWER (ICASSP 2026)</a>は、インドの言語で、LLMを使って書き方の揺れを拾っています。誤り率は平均で6.3ポイント下がり、人の感覚に近くなりました</li>
<li><a href="https://arxiv.org/abs/2509.24613">HiKE (EACL Findings 2026)</a>は、韓国語と英語が混ざる話し言葉の評価セットで、外来語にラベルを付けています</li>
<li><a href="https://arxiv.org/abs/2609.21084">Kacprzak and Fraś (2026)</a>は、ポーランド語で、数字を正規化するかどうかだけでWERが2ポイント以上動き、システム同士の差を上回ることがあると報告しています</li>
<li>昔からある評価ツールのNIST sclite も、参照に<code>{ a / b / @ }</code>のように別の書き方を並べて書けるようになっています</li>
</ul>
<p>そこで、計測に使っている<a href="https://github.com/nyosegawa/speech-bench">speech-bench</a>では、ふつうのCERのほかに、表記の揺れを許したCERも出すようにしました。参照のテキストの1文ごとに、次のような注釈を付けておきます。</p>
<ul>
<li>かなで書かれていない部分には、読みをかなで付ける: <code>明日《あした》</code>、<code>｜Zoom《ズーム》</code></li>
<li>読みだけでは表せない別の書き方を並べる: <code>［九《く》時《じ》／9時］</code></li>
<li>「えーと」のようなフィラーは、なくてもよいことにする: <code>［えーと／えっと／］</code></li>
</ul>
<p>書き起こしは、この注釈から作れる書き方のうち、いちばん近いものと比較して誤りを数えます。分母は参照のテキストの文字数のままなので、表記の揺れを許したCERは、ふつうのCERより大きくなることはありません。同じ音でも意味の違う言葉 (機会 と 機械) や、意味は同じでも話した言葉と違うもの (3時半 と 3時30分) は許さないので、本当の聞き間違いは誤りとして残ります。</p>
<p>Common Voice 8.0の日本語のテスト (4,483文) に付けた注釈は、<a href="https://huggingface.co/datasets/sakasegawa/common-voice-ja-accepted-spellings">sakasegawa/common-voice-ja-accepted-spellings</a>としてHugging Faceに公開しています (CC0)。採点するスクリプトも付けていて、speech-benchと同じ誤りを数えることを、17,932件の書き起こしで確かめています。Common Voiceの音声とテキストはそれぞれの配布元から取ってきて、このデータセットの注釈と合わせて使います。</p>
<p>表記の揺れを許すと、たとえばCommon VoiceでのQwen3-ASR 1.7BのCERは9.25%から4.56%になります。誤りとして数えられていたもののおよそ半分が、書き方の違いだったということです。このあとの表では、両方のCERを並べています。</p>
<p>精度を比較するためのベンチマークはこれで準備できました。ただ、移植した直後のspeech.cppは、llama.cppより遅かったのです。</p>
<h2 id="llama.cpp%E3%81%A8%E5%90%8C%E7%A8%8B%E5%BA%A6%E3%81%AB%E3%81%AA%E3%82%8B%E3%81%BE%E3%81%A7%E9%80%9F%E3%81%8F%E3%81%99%E3%82%8B" tabindex="-1"><a href="https://nyosegawa.com/posts/speech-cpp/#llama.cpp%E3%81%A8%E5%90%8C%E7%A8%8B%E5%BA%A6%E3%81%AB%E3%81%AA%E3%82%8B%E3%81%BE%E3%81%A7%E9%80%9F%E3%81%8F%E3%81%99%E3%82%8B" class="header-anchor">llama.cppと同程度になるまで速くする</a></h2>
<p>M5でQwen3-ASR 0.6Bを動かすと、speech.cpp 0.7.0は1秒に123〜132トークンを書き出し、llama.cppは158〜165トークンでした。音声認識の時間の9割はこのデコードなので、ここを詰めないとllama.cppより遅いままです。</p>
<p>やったことは2つです。</p>
<p>1つ目はflash attentionです。speech.cppのQwen3のデコーダーは、attentionを2回の行列積とsoftmaxで計算していました。ggmlの<code>ggml_flash_attn_ext</code>はスコアを溜めずにまとめて計算するので、GPUではこちらのほうが速くなります。M5のMetalでは、キャッシュが2,000位置なら2.62ミリ秒が2.20ミリ秒に、8,000位置なら9.59ミリ秒が8.10ミリ秒になりました。ただCPUでは逆に、8,000位置で22.1ミリ秒が51.2ミリ秒と遅くなります。なので、モデルを読み込むときに、そのGPUのバックエンドがflash attentionを計算できるか (<code>ggml_backend_supports_op</code>) を見て、できるときだけ使うようにしました。</p>
<p>flash attentionは、attentionを小さなブロックに分けて、softmaxを少しずつ更新しながら計算し、大きなスコアの行列をメモリに置かない計算のやり方の名前です (<a href="https://arxiv.org/abs/2205.14135">Dao et al., 2022</a>)。CUDAのライブラリのFlashAttention-2はRTX 30シリーズ以降向けですが、ggmlは同じやり方をMetalやVulkanのシェーダーで自前で書いているので、RTX 2080のVulkanでも使えます。</p>
<p>2つ目は、デコードの1ステップごとの計算グラフを、トークンの間で使い回すことです。ggmlでは計算グラフを組み立てるのにも時間がかかるので、形が同じあいだは組み立て直さないようにしました。</p>
<p>これで、M5のデコードは0.6Bで1秒に146〜158トークン (llama.cppは147〜157)、1.7Bで58〜63トークン (同じく58〜62) と並びました。RTX 2080では、0.7.0より13〜25%速くなりました。</p>
<p>Common Voice 8.0の日本語のテスト (4,483文) を、RTX 2080のWindowsで書き起こしてみた結果がこちらです。前後の無音はVADで詰めています。CERは書き起こしの文字の誤り率で、その右の列が前の章の表記の揺れを許したCERです。待ち時間は、要求を送ってからテキストが返るまでです。NVIDIAが出している<a href="https://github.com/NVIDIA/NeMo-Speech.cpp">NeMo-Speech.cpp</a>も、speech.cppと同じくggmlでFastConformerを動かす実装なので、一緒に比べました。</p>
<table>
<thead>
<tr>
<th>モデル</th>
<th>実装</th>
<th>重み</th>
<th>CER</th>
<th>表記の揺れを許したCER</th>
<th>待ち時間 (中央値)</th>
<th>待ち時間 (p90)</th>
</tr>
</thead>
<tbody>
<tr>
<td>Qwen3-ASR 1.7B</td>
<td><strong>speech.cpp</strong></td>
<td>Q8_0</td>
<td>9.50%</td>
<td>4.68%</td>
<td><strong>0.155秒</strong></td>
<td><strong>0.286秒</strong></td>
</tr>
<tr>
<td>Qwen3-ASR 1.7B</td>
<td>llama.cpp b11246</td>
<td>Q8_0</td>
<td>9.28%</td>
<td>4.60%</td>
<td>0.173秒</td>
<td>0.289秒</td>
</tr>
<tr>
<td>Qwen3-ASR 0.6B</td>
<td><strong>speech.cpp</strong></td>
<td>Q8_0</td>
<td>11.90%</td>
<td>7.04%</td>
<td><strong>0.087秒</strong></td>
<td><strong>0.158秒</strong></td>
</tr>
<tr>
<td>Qwen3-ASR 0.6B</td>
<td>llama.cpp b11246</td>
<td>Q8_0</td>
<td>11.80%</td>
<td>6.94%</td>
<td>0.105秒</td>
<td>0.167秒</td>
</tr>
<tr>
<td>parakeet-tdt_ctc-0.6b-ja</td>
<td><strong>speech.cpp</strong></td>
<td>F16</td>
<td>7.88%</td>
<td>2.98%</td>
<td><strong>0.069秒</strong></td>
<td><strong>0.130秒</strong></td>
</tr>
<tr>
<td>parakeet-tdt_ctc-0.6b-ja</td>
<td>CrispASR v0.8.38</td>
<td>Q8_0</td>
<td>7.93%</td>
<td>3.01%</td>
<td>0.189秒</td>
<td>0.280秒</td>
</tr>
<tr>
<td>ReazonSpeech NeMo v2</td>
<td><strong>speech.cpp</strong> (ビームサーチ)</td>
<td>F16</td>
<td>12.03%</td>
<td>7.14%</td>
<td>0.157秒</td>
<td>0.268秒</td>
</tr>
<tr>
<td>ReazonSpeech NeMo v2</td>
<td><strong>speech.cpp</strong> (greedy)</td>
<td>F16</td>
<td>12.48%</td>
<td>7.68%</td>
<td>0.092秒</td>
<td>0.149秒</td>
</tr>
<tr>
<td>ReazonSpeech NeMo v2</td>
<td>CrispASR v0.8.38</td>
<td>Q8_0</td>
<td>11.72%</td>
<td>6.79%</td>
<td>0.196秒</td>
<td>0.302秒</td>
</tr>
<tr>
<td>ReazonSpeech NeMo v2</td>
<td>NeMo-Speech.cpp v0.2.0 (greedy)</td>
<td>F16</td>
<td>11.81%</td>
<td>6.99%</td>
<td><strong>0.059秒</strong></td>
<td><strong>0.101秒</strong></td>
</tr>
</tbody>
</table>
<p>Qwen3-ASRは、精度がllama.cppとほぼ同じで、待ち時間は1.7Bで10%、0.6Bで17%短くなりました。parakeetは、精度がCrispASRと同じで、待ち時間は3分の1です。重みがspeech.cppではF16、CrispASRではQ8_0なので、差の一部は重みの違いかもしれません。</p>
<p>ReazonSpeechは、NeMo-Speech.cppがいちばん速く、精度も少し上でした。greedyどうしで比べても、speech.cppより1.6倍速いです。M5でも1.4倍の差があり、parakeet-v3でもNeMo-Speech.cppのほうが速かったので (表はAppendixにあります)、FastConformerのどこで差がついているかを調べて、次のリリースで詰めるつもりです。CrispASRのReazonSpeechは、前の章で書いたように前後の無音に「あっ。」を書き足しますが、無音を詰めたこの条件では、speech.cppのビームサーチより少し正確でした。Common Voiceは文を読み上げた録音なので、会話の話し言葉ではまた違う結果になりえます。</p>
<p>ちなみに、M5ではまだllama.cppのほうが少し速いところがあります。Common Voiceの4,483文では待ち時間の中央値が並び、p90はllama.cppのほうが8%短くなりました。長い発話ほど差が出ていて、25.5秒の日本語の発話では、0.6Bはspeech.cppが0.80秒、llama.cppが0.75秒でした。デコードは並んだので、差が出ているのは音声のエンコーダーとプロンプトの読み込みです。llama.cppはここの行列積をMetal 4のtensor APIで計算していて、speech.cppはそれを使っていません。使わなかったのはちょっとしたバグが理由でした。以下でそれについて軽く説明します。</p>
<h2 id="m5%E3%81%A7%E8%A6%8B%E3%81%A4%E3%81%91%E3%81%9F%E4%B8%8D%E5%85%B7%E5%90%88" tabindex="-1"><a href="https://nyosegawa.com/posts/speech-cpp/#m5%E3%81%A7%E8%A6%8B%E3%81%A4%E3%81%91%E3%81%9F%E4%B8%8D%E5%85%B7%E5%90%88" class="header-anchor">M5で見つけた不具合</a></h2>
<p>Irodori-TTSの移植が終わって完成と思ったころ、M5で読ませた64本のうち1本だけ、音声の後半が雑音になりました。「暗証番号は4桁で、8264です。」の「8264です」がザーッという音に埋もれています。同じテキストをCPUやRTX 2080で読ませると、きれいに出ます。</p>
<p>調べると、原因はggmlのMetalの行列積でした。M5以降のチップでは、ggmlはMetal 4のtensor APIで行列積を計算します。このkernelが、出力の列の数を128で割って64余るとき (64、192、320…) だけ、出力の外まで書き込んでいました。はみ出した書き込みが隣のテンソルを書き換えるので、毎回違う結果になります。</p>
<p>雑音になった音声は、ちょうどこの条件に当たっていました。そのころはコーデックを48フレームずつ、前後に10フレームずつ足してデコードしていたので、114フレームの音声の3つ目のウィンドウが50〜114フレーム、つまりぴったり64フレームだったのです。</p>
<p>ggml自身のテスト (<code>test-backend-ops</code>) は通っていました。この形のケースがなかったからです。64列と192列のケースを足すとtensor APIでは失敗し、<code>GGML_METAL_TENSOR_DISABLE=1</code>でtensor APIを止めると通りました (<a href="https://github.com/nyosegawa/speech.cpp/issues/13">speech.cpp#13</a>)。</p>
<p>本体が直る前にできることは限られています。speech.cppではM5で同じ20文を読ませて比較してみてこの差は受容できる（というか、するしかない）と考え、tensor APIを止めています。</p>
<table>
<thead>
<tr>
<th></th>
<th>tensor API</th>
<th>止めたとき</th>
</tr>
</thead>
<tbody>
<tr>
<td>Irodori-TTS MF、最初の音声まで (中央値)</td>
<td>0.19秒</td>
<td>0.23秒</td>
</tr>
<tr>
<td>Irodori-TTS 16ステップ、最初の音声まで (中央値)</td>
<td>0.85秒</td>
<td>1.12秒</td>
</tr>
<tr>
<td>Qwen3-TTS 0.6B、最初の音声まで (中央値)</td>
<td>0.045秒</td>
<td>0.043秒</td>
</tr>
<tr>
<td>Irodori-TTSのコーデック、公式とのSNR</td>
<td>47.6 dB</td>
<td>68.0 dB</td>
</tr>
</tbody>
</table>
<p>声が出るまでは0.04秒遅くなりましたが、雑音の心配がなくなり、ついでに公式との差も小さくなりました。前の章のM5での音声認識の差もこれが大きいです。ggmlの新しいバージョンでこの形のケースが通るようになったら、tensor APIを使うように戻して計測し直すつもりです。</p>
<h2 id="openai%E3%81%AErealtime-transcription%E3%81%AB%E5%AF%BE%E5%BF%9C%E3%81%99%E3%82%8B" tabindex="-1"><a href="https://nyosegawa.com/posts/speech-cpp/#openai%E3%81%AErealtime-transcription%E3%81%AB%E5%AF%BE%E5%BF%9C%E3%81%99%E3%82%8B" class="header-anchor">OpenAIのRealtime Transcriptionに対応する</a></h2>
<p>さて、ここまでは1つの発話を書き起こす話でした。使っていると、会議のような長尺の録音を書き起こしたり、話しているそばから文字を出したりしたくなります。0.8.0では、この2つを入れました。</p>
<h3 id="%E9%95%B7%E5%B0%BA%E3%81%AE%E9%8C%B2%E9%9F%B3%E3%82%92%E4%B8%B8%E3%81%94%E3%81%A8%E6%B8%A1%E3%81%99%E3%81%A8%E6%96%87%E3%81%8C%E6%B6%88%E3%81%88%E3%82%8B" tabindex="-1"><a href="https://nyosegawa.com/posts/speech-cpp/#%E9%95%B7%E5%B0%BA%E3%81%AE%E9%8C%B2%E9%9F%B3%E3%82%92%E4%B8%B8%E3%81%94%E3%81%A8%E6%B8%A1%E3%81%99%E3%81%A8%E6%96%87%E3%81%8C%E6%B6%88%E3%81%88%E3%82%8B" class="header-anchor">長尺の録音を丸ごと渡すと文が消える</a></h3>
<p>試すページで書き起こしていると、話したはずの文がまるごと抜けることがありました。ページは音声を20秒ずつに切ってサーバーに送っていて、その20秒に3つか4つの文が入っていると、ReazonSpeechが前の2文を落としていたのです。公式のNeMoに同じ音声を渡しても同じ文を落としたので、移植の誤りではなく、モデルの性質です。</p>
<p>どれくらい落ちるのかを測りました。FLEURSとCommon Voiceの日本語のテストの文を、0.3〜1.0秒の間をあけてつないで数分の録音を作り、丸ごとと、話している区間ごとに書き起こしました (作り方はAppendixに書きました)。半分以上の文字が消えた文を「落ちた」と数えています。</p>
<table>
<thead>
<tr>
<th>モデル</th>
<th>丸ごと (FLEURS 321文 / Common Voice 600文)</th>
<th>区間ごと (同じく)</th>
</tr>
</thead>
<tbody>
<tr>
<td>ReazonSpeech NeMo v2</td>
<td>67文 / 522文</td>
<td>4文 / 46文</td>
</tr>
<tr>
<td>parakeet-tdt_ctc-0.6b-ja</td>
<td>264文 / 266文</td>
<td>0文 / 11文</td>
</tr>
<tr>
<td>Qwen3-ASR 0.6B</td>
<td>1文 / 1文</td>
<td>0文 / 2文</td>
</tr>
</tbody>
</table>
<p>FastConformerの2つは、数分を丸ごと渡すと文をまとめて落とします。いっぽうQwen3-ASRはほとんど落とさず、CERも丸ごとのほうが少しよいくらいでした (FLEURSで9.80%と11.44%)。LLMのデコーダーが、前後の文脈を長く使えるからだと思います。区間ごとでもCommon VoiceでReazonSpeechが46文落としているのは、話す人の違う短い文が1つの区間に入ってしまうためで、つないだ録音ならではの数字です。</p>
<p>そこで、音声のどこで人が話しているかを見つける<a href="https://github.com/snakers4/silero-vad">Silero VAD</a>を移植して、区間ごとに書き起こしてつなぐようにしました。Silero VADは1.2 MBの小さなモデルで、公式の<code>get_speech_timestamps</code>と同じ区間をサンプル単位で返します。区間の終わりの決まりはOpenAIの<code>server_vad</code>の既定に合わせて、0.5秒の無音で区切ります。間の短い話し方だと1つの区間が20秒を超えるので、区間の長さの上限を8秒から30秒まで変えて測り、平均のCERがいちばん低かった10秒にしました。</p>
<p>HTTPサーバーでは、OpenAIの書き起こしのAPIの<code>chunking_strategy</code>で区間ごとになります。まず、音声認識とVADの両方のモデルを読み込んでサーバーを起動します。前の例のサーバーが動いていれば、Ctrl+Cで止めてから実行してください。</p>
<pre><code class="language-sh">speech serve reazonspeech-v2 silero-vad
</code></pre>
<p>別のターミナルから、OpenAIのPythonのSDKで呼び出せます。</p>
<pre><code class="language-python">from openai import OpenAI

client = OpenAI(base_url=&quot;http://127.0.0.1:8080/v1&quot;, api_key=&quot;unused&quot;)
with open(&quot;meeting.wav&quot;, &quot;rb&quot;) as f:
    print(client.audio.transcriptions.create(model=&quot;reazonspeech-nemo-v2&quot;, file=f, chunking_strategy=&quot;auto&quot;).text)
</code></pre>
<h3 id="%E8%A9%B1%E3%81%97%E3%81%A6%E3%81%84%E3%82%8B%E3%81%9D%E3%81%B0%E3%81%8B%E3%82%89%E6%96%87%E5%AD%97%E3%82%92%E5%87%BA%E3%81%99" tabindex="-1"><a href="https://nyosegawa.com/posts/speech-cpp/#%E8%A9%B1%E3%81%97%E3%81%A6%E3%81%84%E3%82%8B%E3%81%9D%E3%81%B0%E3%81%8B%E3%82%89%E6%96%87%E5%AD%97%E3%82%92%E5%87%BA%E3%81%99" class="header-anchor">話しているそばから文字を出す</a></h3>
<p>もう1つは、話しているそばから文字を出すことです。形は<a href="https://developers.openai.com/api/docs/guides/realtime-transcription">OpenAIのRealtime APIの書き起こし</a>に合わせたので、OpenAIのクライアントは接続先を変えるだけで使えます。</p>
<p>Realtime APIでは、話している途中の文字は<code>delta</code>というイベントで送ります。<code>delta</code>は後ろに足していくことしかできず、前に送った文字を書き換える手段がありません。OpenAIの<code>gpt-live-transcribe</code>も、<code>delta</code>を足していき、話し終わりの<code>completed</code>で全体を置き換える形です。speech.cppは、話している途中の音声を読み直すたびに前回の結果と比べて、2回続けて同じだった先頭の部分だけを<code>delta</code>で送り、話し終わったら<code>completed</code>で確定した文を送ります。</p>
<p>M5でほかに何も動かさず、6つの発話をつないだ音声を実時間で3回ずつ流して計りました。</p>
<table>
<thead>
<tr>
<th></th>
<th>ReazonSpeech NeMo v2</th>
<th>Qwen3-ASR 0.6B</th>
</tr>
</thead>
<tbody>
<tr>
<td>話し始めを知らせるまで (話し始めから)</td>
<td>0.25〜0.27秒</td>
<td>0.25〜0.27秒</td>
</tr>
<tr>
<td>最初の文字まで (話し始めから)</td>
<td>0.69〜1.32秒</td>
<td>0.50〜1.15秒</td>
</tr>
<tr>
<td>確定した文まで (話し終わりから)</td>
<td>0.70〜0.79秒</td>
<td>0.72〜0.90秒</td>
</tr>
</tbody>
</table>
<p>話し終わりから確定までのうち0.6秒は、0.5秒の無音が続くまで待つ時間です。<code>silence_duration_ms</code>で短くできますが、そのぶん息つぎで発話が切れやすくなります。最初の文字が少し遅いのは、2回の読み直しが一致するのを待っているからです。</p>
<p>コマンドの<code>--live</code>は、マイクから同じように書き起こします。マイクは<a href="https://github.com/mackron/miniaudio">miniaudio</a>を実行ファイルに組み込んで読んでいるので、SDL2やPortAudioを入れなくても動きます。</p>
<p>ちなみにASISTはこの仕組みを使っていません。話し終わったかどうかを相槌の分類器や<a href="https://github.com/MaAI-Kyoto/MaAI">MaAI</a>の話し終わりの予測も使って自分で決めているからです。ASISTでは自分で切った発話をspeech.cppのワーカーに渡しています。</p>
<h2 id="%E3%81%B2%E3%81%A8%E3%81%A4%E3%81%AB%E3%81%BE%E3%81%A8%E3%82%81%E3%81%A6%E3%82%88%E3%81%8B%E3%81%A3%E3%81%9F%E3%81%93%E3%81%A8" tabindex="-1"><a href="https://nyosegawa.com/posts/speech-cpp/#%E3%81%B2%E3%81%A8%E3%81%A4%E3%81%AB%E3%81%BE%E3%81%A8%E3%82%81%E3%81%A6%E3%82%88%E3%81%8B%E3%81%A3%E3%81%9F%E3%81%93%E3%81%A8" class="header-anchor">ひとつにまとめてよかったこと</a></h2>
<p>ここまで、音声合成と音声認識を別々に見てきましたが、ぜんぶが同じエンジンに入っていることにもいいことがあります。</p>
<p>まず、配るものが少なくて済みます。speech.cppは、実行ファイル<code>speech</code>が1つと、モデルのファイルが1つあれば動きます。実行ファイルはMacの0.8.2で約6.7 MB、WindowsではVulkanのシェーダーを中に持つので48 MBです。モデルは、コーデックも含めて1つのGGUFのファイルにまとめました。Releasesには、macOS (Metal)、Windows (Vulkan)、Linux (VulkanとCPU) 向けのビルドを置いていて、インストーラーがPCに合うものを選んで入れます。CUDAもPythonも要りません。</p>
<p>起動してから話せるようになるまでも速いです。ASISTはマイクを入れたときだけ音声のモデルを読み込むので、ここが遅いとマイクを入れてもしばらく話せません。</p>
<p>M5で起動してから最初の要求を受け付けられるようになるまでを計測しました (モデルのファイルはOSのキャッシュに載っている状態です)。</p>
<table>
<thead>
<tr>
<th>実装</th>
<th>動かすのに要るもの</th>
<th>起動してから準備ができるまで</th>
</tr>
</thead>
<tbody>
<tr>
<td><strong>speech.cpp</strong></td>
<td>実行ファイル1つ</td>
<td><strong>0.4秒</strong></td>
</tr>
<tr>
<td>mlx-audio</td>
<td>Python 3.12とMLX</td>
<td>2.3〜3.1秒</td>
</tr>
<tr>
<td>audio.cpp</td>
<td>サーバーの実行ファイル</td>
<td>2.0〜3.8秒</td>
</tr>
<tr>
<td>公式の実装</td>
<td>Python 3.10とPyTorch</td>
<td>9.0〜11.0秒</td>
</tr>
</tbody>
</table>
<p>どのモデルも、同じやり方で呼び出せます。</p>
<p><img src="https://nyosegawa.com/img/speech-cpp/overview.png" alt="speech.cppの構成"></p>
<p>呼び出し方は4つあります。</p>
<ul>
<li><code>speech</code>コマンド: テキストを読む<code>speech tts</code>、音声を書き起こす<code>speech asr</code>、参照音声から声のファイルを作る<code>speech voice</code>など</li>
<li>HTTPサーバー (<code>speech serve</code>): OpenAIの音声のAPI (<code>/v1/audio/speech</code>と<code>/v1/audio/transcriptions</code>) と、Realtime APIの書き起こし (<code>/v1/realtime</code>) と同じ形で答える</li>
<li>ワーカー (<code>speech worker</code>): 標準入出力でJSON Linesをやりとりする。ASISTはこれで動かしている</li>
<li>C API (<code>speech.h</code>): 上の3つも、すべてこのC APIの上に作っている</li>
</ul>
<p>ASISTは、読み上げも音声認識もspeech.cppのワーカーで動かすようになり、llama-serverを同梱するのをやめました。</p>
<h2 id="%E3%81%BE%E3%81%A8%E3%82%81" tabindex="-1"><a href="https://nyosegawa.com/posts/speech-cpp/#%E3%81%BE%E3%81%A8%E3%82%81" class="header-anchor">まとめ</a></h2>
<ul>
<li>音声合成と音声認識の9つのモデルと音声区間検出のSilero VADを、ggmlで動くひとつのエンジンにまとめ、MacとWindowsとLinuxで実行ファイル1つで動くようにしました</li>
<li>公式の実装と段階ごとに比較して同じ結果を出すことを確かめながら、M5でIrodori-TTSの声が出るまでを0.25秒にし、RTX 2080ではQwen3-ASRをllama.cppより速くしました</li>
<li>M5のtensor APIの不具合は、速さより正しさを取って、tensor APIを止めて避けました。FastConformerの速さはNeMo-Speech.cppにまだ負けているので、次に詰めます</li>
<li>長尺の録音はSilero VADの区間ごとに書き起こすことで文が落ちることが大きく減り、話しているそばからの書き起こしはOpenAIのRealtime APIと同じ形で使えるようになりました</li>
</ul>
<h2 id="%E8%BF%BD%E8%A8%98%3A-0.8.3%E3%81%A7fastconformer%E3%82%92%E9%AB%98%E9%80%9F%E5%8C%96%E3%81%97%E3%81%BE%E3%81%97%E3%81%9F-(2026-10-10)" tabindex="-1"><a href="https://nyosegawa.com/posts/speech-cpp/#%E8%BF%BD%E8%A8%98%3A-0.8.3%E3%81%A7fastconformer%E3%82%92%E9%AB%98%E9%80%9F%E5%8C%96%E3%81%97%E3%81%BE%E3%81%97%E3%81%9F-(2026-10-10)" class="header-anchor">追記: 0.8.3でFastConformerを高速化しました (2026-10-10)</a></h2>
<p>上で「次のリリースで詰めるつもりです」と書いたFastConformerを、<a href="https://github.com/nyosegawa/speech.cpp/releases/tag/v0.8.3">speech.cpp 0.8.3</a>で高速化しました。FFTの中で繰り返していたメモリの確保、入力が変わっても同じ位置情報の計算、デコーダーの計算グラフの作り直しを減らしています。</p>
<p>0.8.2と0.8.3を同じ音声で交互に動かすと、文字起こしが返るまでの時間の中央値は次のようになりました。どれもF16で、読み込みと初回のウォームアップを除いた計測です。</p>
<table>
<thead>
<tr>
<th>モデル</th>
<th>M5 (Metal)、0.8.2 → 0.8.3</th>
<th>RTX 2080 (Vulkan)、0.8.2 → 0.8.3</th>
</tr>
</thead>
<tbody>
<tr>
<td>ReazonSpeech NeMo v2、greedy</td>
<td>72 → 54 ms</td>
<td>76 → 47 ms</td>
</tr>
<tr>
<td>parakeet-tdt-0.6b-v3</td>
<td>102 → 93 ms</td>
<td>128 → 82 ms</td>
</tr>
<tr>
<td>parakeet-tdt_ctc-0.6b-ja</td>
<td>56 → 52 ms</td>
<td>85 → 40 ms</td>
</tr>
</tbody>
</table>
<p>日本語の速度はCommon Voiceの先頭100件のうちVADで残った99件を3回、英語はFLEURSの300件のうち残った299件を1回ずつ測りました。本文の日本語4,483件の表とは件数が違うので、その表を置き換える数字ではありません。ReazonSpeechの標準のbeam searchも、M5では104 → 85 msになりました。</p>
<p>精度はM5で別に全件を確認しました。記事の前回計測と比べて、ReazonSpeechのgreedyは日本語4,483件でCERが12.50 → 12.49%、表記の揺れを許したCERは7.69%のままです。VADで除外された4件を除く4,479件中5件で文字起こしが変わり、空の結果は4件のままでした。parakeet-v3の英語300件もWERは8.76%のままです。</p>
<p>NeMo-Speech.cppとも同じ音声を交互に処理させると、Windowsではほぼ同じ速さになりました。M5ではNeMo-Speech.cppのほうがReazonSpeechで約3 ms、parakeet-v3で約7 ms速いですが、NeMo側もtensor APIを止めると同等以上でした。speech.cppでは、前述のtensor APIの回避策を保ったままです。詳しい条件とp90は<a href="https://github.com/nyosegawa/speech.cpp/pull/91#issuecomment-6085048115">PRの計測結果</a>にあります。</p>
<p>モデルのファイルは変えていません。speech.cpp本体を更新すれば、手元のGGUFをそのまま使えます。</p>
<h2 id="appendix" tabindex="-1"><a href="https://nyosegawa.com/posts/speech-cpp/#appendix" class="header-anchor">Appendix</a></h2>
<h3 id="%E8%A8%88%E6%B8%AC%E6%96%B9%E6%B3%95" tabindex="-1"><a href="https://nyosegawa.com/posts/speech-cpp/#%E8%A8%88%E6%B8%AC%E6%96%B9%E6%B3%95" class="header-anchor">計測方法</a></h3>
<p>実装どうしの比較には、自分で作っている<a href="https://github.com/nyosegawa/speech-bench">speech-bench</a>を使いました。speech.cppは0.8.0の候補 (コミットf5ab84c) で、M5では手元のビルド、RTX 2080ではCIのビルドを使い、すべての実装を2026-10-08の同じ日に計測しています。</p>
<p>音声合成は、M5 (32 GB、macOS 26.2) と、RTX 2080 (8 GB、ドライバー591.86) のWindows 11で計測しました。テキストはspeech-benchの<code>prompts/speak-ja-JP.json</code>の20文で、相槌、返事、長めの説明、英単語まじりのテキストが入っています。Irodori-TTSの声はspeech-benchで作った約10秒の参照音声 (voice-bright-young-woman) で、seedは1です。Qwen3-TTSは用意された声のono_annaで、seedは毎回変わります。GPUを初めて使うときの時間を除くため、最初の1文は一度読ませてから計測しています。CERは、合成した音声をQwen3-ASR 1.7Bで書き起こして数えました。</p>
<p>ASISTの再生時間は、2026-10-09に公開版0.8.0 (同梱のspeech.cppは0.8.2) を同じM5で測りました。各モデルで1文をウォームアップしたあと、上と同じ20文を1回ずつ再生し、相槌とつなぎの一言は切っています。Qwen3-TTSの声はono_anna、Irodori-TTSはASISTに同梱するcalm-young-womanです。会話APIが指定どおりの文を返したことも全60文で確認しました。合成開始の時刻は、アプリが報告する合成時間を画面側で受け取った時刻から引いて推定しています。声の始まりは、再生する音声の20 msごとのRMSが0.004以上になる最初の区間としました。プロセス間通信の遅れが誤差に入り、機器の出力遅延は含めません。</p>
<p>途切れないかの確認は、speech.cppのワーカーに同じ20文を読ませて、チャンクが届いた時刻を記録して数えています。最初のチャンクが届いた時刻に再生を始めたとして、それぞれのチャンクが、その前までの音声を鳴らし終える時刻より前に届いていれば途切れません。</p>
<p>音声認識は、Common Voice 8.0の日本語のテスト (4,483文、参照の文字は93,471文字) を、M5とRTX 2080で、どの実装にも同じ順に聞かせました。音声の前後の無音は、silero-vad v4で声のある範囲を見つけて、前後に0.2秒ずつ残して詰めています (声が見つからなかった4文は除いています)。Qwen3-ASRには言語に<code>ja</code>を指定し、待ち時間には読み込みを含めていません。</p>
<p>M5での音声認識の結果です。</p>
<table>
<thead>
<tr>
<th>モデル</th>
<th>実装</th>
<th>重み</th>
<th>CER</th>
<th>表記の揺れを許したCER</th>
<th>待ち時間 (中央値)</th>
<th>待ち時間 (p90)</th>
</tr>
</thead>
<tbody>
<tr>
<td>Qwen3-ASR 1.7B</td>
<td><strong>speech.cpp</strong></td>
<td>Q8_0</td>
<td>9.49%</td>
<td>4.67%</td>
<td>0.309秒</td>
<td>0.544秒</td>
</tr>
<tr>
<td>Qwen3-ASR 1.7B</td>
<td>llama.cpp b11246</td>
<td>Q8_0</td>
<td>9.29%</td>
<td>4.62%</td>
<td><strong>0.307秒</strong></td>
<td><strong>0.499秒</strong></td>
</tr>
<tr>
<td>Qwen3-ASR 0.6B</td>
<td><strong>speech.cpp</strong></td>
<td>Q8_0</td>
<td>11.89%</td>
<td>7.05%</td>
<td><strong>0.132秒</strong></td>
<td>0.231秒</td>
</tr>
<tr>
<td>Qwen3-ASR 0.6B</td>
<td>llama.cpp b11246</td>
<td>Q8_0</td>
<td>11.79%</td>
<td>6.95%</td>
<td>0.133秒</td>
<td><strong>0.212秒</strong></td>
</tr>
<tr>
<td>parakeet-tdt_ctc-0.6b-ja</td>
<td><strong>speech.cpp</strong></td>
<td>F16</td>
<td>7.88%</td>
<td>2.98%</td>
<td><strong>0.059秒</strong></td>
<td><strong>0.086秒</strong></td>
</tr>
<tr>
<td>parakeet-tdt_ctc-0.6b-ja</td>
<td>CrispASR v0.8.38</td>
<td>Q8_0</td>
<td>8.03%</td>
<td>3.12%</td>
<td>0.071秒</td>
<td>0.088秒</td>
</tr>
<tr>
<td>ReazonSpeech NeMo v2</td>
<td><strong>speech.cpp</strong> (ビームサーチ)</td>
<td>F16</td>
<td>12.03%</td>
<td>7.13%</td>
<td>0.110秒</td>
<td>0.167秒</td>
</tr>
<tr>
<td>ReazonSpeech NeMo v2</td>
<td><strong>speech.cpp</strong> (greedy)</td>
<td>F16</td>
<td>12.50%</td>
<td>7.69%</td>
<td>0.077秒</td>
<td>0.104秒</td>
</tr>
<tr>
<td>ReazonSpeech NeMo v2</td>
<td>CrispASR v0.8.38</td>
<td>Q8_0</td>
<td>11.79%</td>
<td>6.88%</td>
<td>0.074秒</td>
<td>0.097秒</td>
</tr>
<tr>
<td>ReazonSpeech NeMo v2</td>
<td>NeMo-Speech.cpp v0.2.0 (greedy)</td>
<td>F16</td>
<td>11.93%</td>
<td>7.10%</td>
<td><strong>0.054秒</strong></td>
<td><strong>0.075秒</strong></td>
</tr>
</tbody>
</table>
<p>parakeet-tdt-0.6b-v3を、FLEURSの英語の300文で比べた結果です。</p>
<table>
<thead>
<tr>
<th>環境</th>
<th>実装</th>
<th>重み</th>
<th>WER</th>
<th>待ち時間 (中央値)</th>
<th>待ち時間 (p90)</th>
</tr>
</thead>
<tbody>
<tr>
<td>M5</td>
<td><strong>speech.cpp</strong></td>
<td>F16</td>
<td>8.76%</td>
<td>0.103秒</td>
<td>0.167秒</td>
</tr>
<tr>
<td>M5</td>
<td>NeMo-Speech.cpp v0.2.0</td>
<td>F16</td>
<td>9.06%</td>
<td><strong>0.085秒</strong></td>
<td><strong>0.134秒</strong></td>
</tr>
<tr>
<td>RTX 2080</td>
<td><strong>speech.cpp</strong></td>
<td>F16</td>
<td>8.78%</td>
<td>0.152秒</td>
<td>0.221秒</td>
</tr>
<tr>
<td>RTX 2080</td>
<td>NeMo-Speech.cpp v0.2.0</td>
<td>F16</td>
<td>9.06%</td>
<td><strong>0.094秒</strong></td>
<td><strong>0.130秒</strong></td>
</tr>
</tbody>
</table>
<p>NeMo-Speech.cppには公開されたF16のファイルがないので、speech-benchがNeMo-Speech.cppの変換スクリプトでF16に変換したファイルを使っています。</p>
<p>長尺の録音の計測は、speech.cppの<code>measure/region_cap_compare.py</code>でM5 (Metal) を使って行いました。FLEURSの日本語のテストの321文 (各文の読み1つ) と、Common Voice 8.0の日本語のテストの最初の600文の録音を、それぞれSilero VADで声のある範囲に切り、音量を-23 dBFSにそろえて、0.3〜1.0秒の間をあけてつないでいます。4つに1つの間は2〜8秒の無音にして、間と無音には-60 dBFSの雑音を入れました。こうして作った105.6分の録音を、言語を日本語に決めて書き起こし、NFKCで正規化して句読点と空白を除いたCERを数えています。区間の長さの上限ごとの結果はこちらです (FLEURS / Common Voice)。</p>
<table>
<thead>
<tr>
<th>上限</th>
<th>ReazonSpeech CER</th>
<th>落ちた文</th>
<th>parakeet-ja CER</th>
<th>落ちた文</th>
<th>Qwen3-ASR 0.6B CER</th>
<th>落ちた文</th>
</tr>
</thead>
<tbody>
<tr>
<td>丸ごと</td>
<td>25.29% / 86.01%</td>
<td>67 / 522</td>
<td>83.39% / 44.87%</td>
<td>264 / 266</td>
<td>9.80% / 14.48%</td>
<td>1 / 1</td>
</tr>
<tr>
<td>なし</td>
<td>11.97% / 16.81%</td>
<td>12 / 42</td>
<td>6.39% / 10.82%</td>
<td>3 / 19</td>
<td>10.55% / 14.21%</td>
<td>0 / 2</td>
</tr>
<tr>
<td>20秒</td>
<td>11.32% / 16.81%</td>
<td>10 / 42</td>
<td>6.06% / 10.82%</td>
<td>1 / 19</td>
<td>10.65% / 14.21%</td>
<td>0 / 2</td>
</tr>
<tr>
<td>15秒</td>
<td>10.90% / 16.96%</td>
<td>9 / 43</td>
<td>5.99% / 10.36%</td>
<td>2 / 16</td>
<td>10.71% / 14.11%</td>
<td>0 / 2</td>
</tr>
<tr>
<td>10秒</td>
<td>10.25% / 17.17%</td>
<td>4 / 46</td>
<td>5.75% / 9.97%</td>
<td>0 / 11</td>
<td>11.44% / 14.17%</td>
<td>0 / 2</td>
</tr>
<tr>
<td>8秒</td>
<td>10.31% / 17.38%</td>
<td>2 / 44</td>
<td>6.21% / 9.58%</td>
<td>0 / 8</td>
<td>11.63% / 14.28%</td>
<td>0 / 1</td>
</tr>
</tbody>
</table>
<p>話しながらの書き起こしの時間は、ダンプを無音でつないだ音声を24 kHzにして、20ミリ秒ずつ実時間で<code>/v1/realtime</code>に送って計りました。話し始めと話し終わりは、余白を除いた区間の端から数えています。</p>
<h3 id="%E6%AE%B5%E9%9A%8E%E3%81%94%E3%81%A8%E3%81%AE%E6%AF%94%E8%BC%83%E3%81%AE%E7%B5%90%E6%9E%9C" tabindex="-1"><a href="https://nyosegawa.com/posts/speech-cpp/#%E6%AE%B5%E9%9A%8E%E3%81%94%E3%81%A8%E3%81%AE%E6%AF%94%E8%BC%83%E3%81%AE%E7%B5%90%E6%9E%9C" class="header-anchor">段階ごとの比較の結果</a></h3>
<p>Irodori-TTSの各段階を、公式の実装の書き出した入力から動かしたときの結果です (Apple M5)。</p>
<table>
<thead>
<tr>
<th>処理</th>
<th>CPU、F32</th>
<th>Metal、F32</th>
</tr>
</thead>
<tbody>
<tr>
<td>テキストの正規化とトークナイザ (164文)</td>
<td>すべて一致</td>
<td>すべて一致</td>
</tr>
<tr>
<td>テキストの条件</td>
<td>118〜123 dB</td>
<td>65〜123 dB</td>
</tr>
<tr>
<td>話者の条件</td>
<td>111 dB</td>
<td>51 dB</td>
</tr>
<tr>
<td>長さの予測</td>
<td>すべて一致</td>
<td>すべて一致</td>
</tr>
<tr>
<td>DiTの各ステップ</td>
<td>95 dB以上</td>
<td>46 dB以上</td>
</tr>
<tr>
<td>サンプラーの出力 (MF)</td>
<td>86〜122 dB</td>
<td>36〜67 dB</td>
</tr>
<tr>
<td>コーデックのデコード</td>
<td>119 dB</td>
<td>68 dB</td>
</tr>
<tr>
<td>ウィンドウに分けてデコードしたときと一度にデコードしたとき</td>
<td>一致</td>
<td>一致</td>
</tr>
</tbody>
</table>
<p>MetalがCPUより低いのは、ggmlのMetalの行列積が入力を半精度に丸めるからです。MFは4回の大きなステップで進むので、その差が潜在表現に残ります。そのためMetalでは、波形は同じにならず、同じ内容を話す音声になります。</p>
<p>FastConformer (parakeet-tdt_ctc-0.6b-ja) を、FLEURSの日本語の3つの発話 (6.36秒、10.50秒、25.50秒) で比較した結果です。</p>
<table>
<thead>
<tr>
<th>処理</th>
<th>CPU、F32</th>
<th>Metal</th>
</tr>
</thead>
<tbody>
<tr>
<td>特徴量</td>
<td>117〜127 dB</td>
<td>同じ (CPUで計算)</td>
</tr>
<tr>
<td>畳み込みでの間引き</td>
<td>123 dB</td>
<td>68〜70 dB</td>
</tr>
<tr>
<td>エンコーダーの出力 (24層のあと)</td>
<td>114〜118 dB</td>
<td>63〜64 dB</td>
</tr>
<tr>
<td>公式のエンコーダーの出力からのトークン</td>
<td>一致</td>
<td>一致</td>
</tr>
<tr>
<td>音声からの文 (すべての段階が移植したもの)</td>
<td>3つとも一致</td>
<td>3つとも一致</td>
</tr>
</tbody>
</table>
<h3 id="gguf%E3%81%AE%E4%B8%AD%E8%BA%AB" tabindex="-1"><a href="https://nyosegawa.com/posts/speech-cpp/#gguf%E3%81%AE%E4%B8%AD%E8%BA%AB" class="header-anchor">GGUFの中身</a></h3>
<p>speech.cppのモデルは、どれもコーデックまで含めた1つのGGUFのファイルです。モデルの名前や言語は、GGUFの仕様にある標準のキーにそのまま書いているので、GGUFを読めるツールならモデルの情報を表示できます。</p>
<table>
<thead>
<tr>
<th>キー</th>
<th>中身</th>
</tr>
</thead>
<tbody>
<tr>
<td><code>general.architecture</code></td>
<td>どのコードで動かすか (<code>qwen3-tts</code>、<code>irodori-tts</code>、<code>fastconformer</code>、<code>qwen3-asr</code>)</td>
</tr>
<tr>
<td><code>general.name</code>、<code>general.organization</code>、<code>general.size_label</code> など</td>
<td>モデルの名前、公開している組織、パラメータの数</td>
</tr>
<tr>
<td><code>general.source.url</code></td>
<td>変換したもとのリポジトリと、そのリビジョン</td>
</tr>
<tr>
<td><code>general.languages</code></td>
<td>話せる、または聞ける言語 (ISO 639のコード)</td>
</tr>
<tr>
<td><code>speech.layout</code>、<code>speech.requires</code></td>
<td>ファイルの形のバージョンと、それを読める最初のspeech.cppのバージョン</td>
</tr>
<tr>
<td><code>speech.task</code>、<code>speech.sample_rate</code></td>
<td>音声合成か音声認識か、音声のサンプリングレート</td>
</tr>
</tbody>
</table>
<p>読み込むときは、重みを読む前に、キーとテンソルの名前、形、型がすべてそろっているかを確かめ、足りないものや余計なものがあれば名前を出して断ります。<code>speech info</code>を使うと、重みを読まずにこれらの情報だけを表示できます。</p>
<h3 id="tensor-api%E3%81%AE%E4%B8%8D%E5%85%B7%E5%90%88%E3%81%AE%E5%86%8D%E7%8F%BE" tabindex="-1"><a href="https://nyosegawa.com/posts/speech-cpp/#tensor-api%E3%81%AE%E4%B8%8D%E5%85%B7%E5%90%88%E3%81%AE%E5%86%8D%E7%8F%BE" class="header-anchor">tensor APIの不具合の再現</a></h3>
<p>ggmlの<code>test-backend-ops</code>に、出力の列の数が64と192の行列積のケースを足して、M5で動かすと再現します。tensor APIでは結果がずれて失敗し、環境変数<code>GGML_METAL_TENSOR_DISABLE=1</code>でtensor APIを止めると通ります。くわしい手順は<a href="https://github.com/nyosegawa/speech.cpp/issues/13">speech.cpp#13</a>に書いています。</p>
<h2 id="references" tabindex="-1"><a href="https://nyosegawa.com/posts/speech-cpp/#references" class="header-anchor">References</a></h2>
<ul>
<li>speech.cpp
<ul>
<li><a href="https://github.com/nyosegawa/speech.cpp">nyosegawa/speech.cpp</a></li>
<li><a href="https://github.com/nyosegawa/speech-bench">nyosegawa/speech-bench</a></li>
<li><a href="https://github.com/nyosegawa/speech.cpp/issues/13">speech.cpp#13: Metal on Apple M5 corrupts matrix products whose column count is 64 modulo 128</a></li>
<li><a href="https://huggingface.co/datasets/sakasegawa/common-voice-ja-accepted-spellings">sakasegawa/common-voice-ja-accepted-spellings</a></li>
<li><a href="https://github.com/ggml-org/ggml">ggml-org/ggml</a></li>
</ul>
</li>
<li>モデル
<ul>
<li><a href="https://github.com/Aratako/Irodori-TTS">Aratako/Irodori-TTS</a></li>
<li><a href="https://huggingface.co/Aratako/Irodori-TTS-v4.1-Small-MF">Aratako/Irodori-TTS-v4.1-Small-MF</a></li>
<li><a href="https://github.com/Aratako/Irodori-TTS/blob/main/docs/meanflow.md">MeanFlow Distillation (Irodori-TTS docs)</a></li>
<li><a href="https://huggingface.co/Qwen/Qwen3-TTS-12Hz-0.6B-CustomVoice">Qwen/Qwen3-TTS-12Hz-0.6B-CustomVoice</a></li>
<li><a href="https://huggingface.co/Qwen/Qwen3-ASR-1.7B">Qwen/Qwen3-ASR-1.7B</a></li>
<li><a href="https://huggingface.co/nvidia/parakeet-tdt_ctc-0.6b-ja">nvidia/parakeet-tdt_ctc-0.6b-ja</a></li>
<li><a href="https://huggingface.co/nvidia/parakeet-tdt-0.6b-v3">nvidia/parakeet-tdt-0.6b-v3</a></li>
<li><a href="https://huggingface.co/reazon-research/reazonspeech-nemo-v2">reazon-research/reazonspeech-nemo-v2</a></li>
<li><a href="https://github.com/snakers4/silero-vad">snakers4/silero-vad</a></li>
</ul>
</li>
<li>OpenAI
<ul>
<li><a href="https://developers.openai.com/api/docs/guides/realtime-transcription">Realtime transcription</a></li>
</ul>
</li>
<li>Paper
<ul>
<li><a href="https://arxiv.org/abs/2209.03003">Flow Straight and Fast: Learning to Generate and Transfer Data with Rectified Flow</a></li>
<li><a href="https://arxiv.org/abs/2505.13447">Mean Flows for One-step Generative Modeling</a></li>
<li><a href="https://arxiv.org/abs/2205.14135">FlashAttention: Fast and Memory-Efficient Exact Attention with IO-Awareness</a></li>
<li><a href="https://arxiv.org/abs/2305.05084">Fast Conformer with Linearly Scalable Attention for Efficient Speech Recognition</a></li>
<li><a href="https://www.pnas.org/doi/10.1073/pnas.0903616106">Universals and cultural variation in turn-taking in conversation</a></li>
<li><a href="https://aclanthology.org/2023.cawl-1.8/">Lenient Evaluation of Japanese Speech Recognition: Modeling Naturally Occurring Spelling Inconsistency</a></li>
<li><a href="https://arxiv.org/abs/2603.00941">Towards Orthographically-Informed Evaluation of Speech Recognition Systems for Indian Languages</a></li>
<li><a href="https://arxiv.org/abs/2509.24613">HiKE: Hierarchical Evaluation Framework for Korean-English Code-Switching Speech Recognition</a></li>
<li><a href="https://arxiv.org/abs/2609.21084">The Hidden Cost of Digits: Number Normalization and WER in ASR Systems</a></li>
<li><a href="https://jordandarefsky.com/blog/2025/echo/">Echo-TTS</a></li>
<li><a href="https://arxiv.org/abs/2601.15621">Qwen3-TTS Technical Report</a></li>
</ul>
</li>
<li>ほかの実装
<ul>
<li><a href="https://github.com/ggml-org/llama.cpp">ggml-org/llama.cpp</a></li>
<li><a href="https://github.com/FunAudioLLM/CosyVoice">FunAudioLLM/CosyVoice</a></li>
<li><a href="https://github.com/Blaizzy/mlx-audio">Blaizzy/mlx-audio</a></li>
<li><a href="https://github.com/0xShug0/audio.cpp">0xShug0/audio.cpp</a></li>
<li><a href="https://github.com/mackron/miniaudio">mackron/miniaudio</a></li>
<li><a href="https://github.com/MaAI-Kyoto/MaAI">MaAI-Kyoto/MaAI</a></li>
</ul>
</li>
<li>Irodori-TTSを速くする記事
<ul>
<li><a href="https://note.com/rosso_blog/n/n3eaee67d2c70">irodori-TTS を速くする方法を理解したい (株式会社Rosso)</a></li>
<li><a href="https://zenn.dev/yoshitetsu/articles/e616831f96b44a">AITuber向けに Mac MPS で Irodori-TTS をチューニングして2.72倍高速化 / bf16 では遅くなった (osushi_cr)</a></li>
</ul>
</li>
<li>ONNX Runtime
<ul>
<li><a href="https://onnxruntime.ai/docs/execution-providers/CoreML-ExecutionProvider.html">CoreML Execution Provider</a></li>
<li><a href="https://onnxruntime.ai/docs/execution-providers/DirectML-ExecutionProvider.html">DirectML Execution Provider</a></li>
</ul>
</li>
</ul>
]]>
      </content:encoded>
      <pubDate>Fri, 09 Oct 2026 00:00:00 GMT</pubDate>
    </item>
    <item>
      <title>I Built a Voice Assistant for the Desktop</title>
      <link>https://nyosegawa.com/en/posts/asist-realtime-voice-assistant/</link>
      <guid isPermaLink="false">https://nyosegawa.com/en/posts/asist-realtime-voice-assistant/</guid>
      <description>
        I built ASIST, a voice assistant for the desktop (Windows / Mac) that backchannels while you talk and answers with cards. This post covers what I worked hard on in the voice conversation, how I develop it, and how I made the promotional video.
      </description>
      <content:encoded>
        <![CDATA[<p>Hi there! This is Sakasegawa (<a href="https://x.com/gyakuse">@gyakuse</a>)!</p>
<p>Today I'd like to introduce &quot;<a href="https://asist-agent.com/">ASIST</a>&quot;, a voice assistant for the desktop (Windows / Mac) that I built, and go over the parts I worked hard on while making it.</p>
<!--more-->
<h2 id="i-wanted-a-general-purpose-assistant-with-a-futuristic-ui" tabindex="-1"><a href="https://nyosegawa.com/en/posts/asist-realtime-voice-assistant/#i-wanted-a-general-purpose-assistant-with-a-futuristic-ui" class="header-anchor">I wanted a general-purpose assistant with a futuristic UI</a></h2>
<p>Personal AI agents have been popping up lately. Well-known examples include dots, Grok Bot, Muse and Manus.
They are very well made, but what I really want is something like you see in anime and movies, with several panels floating softly around. Yet even in the 21st century, mid-air displays and AR glasses haven't gone mainstream. So for now I decided to build it as a desktop application.</p>
<p>Before starting, I wrote down what I wanted to value in an AI assistant.</p>
<ul>
<li>It feels futuristic</li>
<li>The conversation feels good</li>
<li>Its memory is reliable</li>
<li>It helps with everyday life</li>
<li>It can do anything on the PC, as long as it's connected to it</li>
<li>It can be used from anywhere</li>
<li>It is richly extensible</li>
</ul>
<p>In the first version of ASIST, I think I managed to do a fair job on all of these except the last two. For the remaining two, I plan to bring in a more general version of <a href="https://nyosegawa.com/en/posts/skill-with-app/">an earlier idea</a> for extensibility, and to handle where it can be used as well.</p>
<h2 id="so-what-is-asist%3F" tabindex="-1"><a href="https://nyosegawa.com/en/posts/asist-realtime-voice-assistant/#so-what-is-asist%3F" class="header-anchor">So what is ASIST?</a></h2>
<p>ASIST is a voice assistant for the desktop. When you talk to it, it answers by voice and puts the weather, your schedule or an email draft on cards next to the conversation. Long-running work can be handed off to the codex or claude CLI once you approve it.</p>
<p><img src="https://nyosegawa.com/img/asist-realtime-voice-assistant/app-home.jpg" alt="The ASIST home screen"></p>
<p>The official site is <a href="https://asist-agent.com/">asist-agent.com</a>. You can download the app from <a href="https://github.com/nyosegawa/asist/releases/latest">GitHub Releases</a>, and the <a href="https://asist-agent.com/en/docs/start/install/">Install</a> page of the documentation walks through everything from installation to the first-run setup. To run it you need an Apple Silicon Mac (macOS 14 or later) or an x64 Windows 11 PC, plus an API key for the conversation model (one of Anthropic, OpenAI, Google or Cerebras). The source code is open source under the MIT License at <a href="https://github.com/nyosegawa/asist">nyosegawa/asist</a>.</p>
<p>Here are the main features.</p>
<ul>
<li>It talks at a nice tempo</li>
<li>It opens cards to show you the information you need (there are 16 kinds of cards, including weather, schedule, email, to-dos, exchange rates and maps)</li>
<li>Tasks, notes, email, the calendar and more open as mini apps inside the application</li>
</ul>
<h2 id="what-i-worked-hard-on-in-the-voice-conversation" tabindex="-1"><a href="https://nyosegawa.com/en/posts/asist-realtime-voice-assistant/#what-i-worked-hard-on-in-the-voice-conversation" class="header-anchor">What I worked hard on in the voice conversation</a></h2>
<p>I plan to write a separate, detailed post on this, but the hardest part of a voice assistant is the wait. With a cascade setup (ASR→LLM→TTS), a naive implementation takes about three seconds to respond. So ASIST buys time until the first reaction by slipping in backchannels and similar short responses.</p>
<p><img src="https://nyosegawa.com/img/en/asist-realtime-voice-assistant/voice-pipeline.png" alt="The voice processing flow"></p>
<h2 id="about-development" tabindex="-1"><a href="https://nyosegawa.com/en/posts/asist-realtime-voice-assistant/#about-development" class="header-anchor">About development</a></h2>
<p>These days I mainly develop with Claude (Opus 5.5), and use Codex (Sol-6.1) for things like evaluating models.
Since coding agents became common, all sorts of development environments have come into fashion. I also built a <a href="https://x.com/nyosegawa/status/2025517872622239840/photo/1">kanban-style agent session manager</a>, even made it work on mobile, and used it for a while, but in the meantime the Claude and Codex desktop apps got good. Just doing things the ordinary way works best.</p>
<p>I also keep Agent Skills to about ten, limited to the things I really do often.
Documentation mostly goes stale, so I put decisions in docs/adr and things to do in issues. ADRs go stale too, so let's keep at it.</p>
<h2 id="making-the-promotional-video" tabindex="-1"><a href="https://nyosegawa.com/en/posts/asist-realtime-voice-assistant/#making-the-promotional-video" class="header-anchor">Making the promotional video</a></h2>
<p>I made a 48-second introduction video for YouTube and X with Claude.</p>
<iframe
  width="100%"
  height="405"
  src="https://www.youtube.com/embed/fAtpmG9QMcw"
  title="ASIST introduction video"
  frameborder="0"
  allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share"
  allowfullscreen
></iframe>
<p><a href="https://www.youtube.com/watch?v=fAtpmG9QMcw">Watch on YouTube</a></p>
<p>The setup is simple: the scenes are built with HTML and GSAP, headless Chrome captures them every 1/30 of a second, and ffmpeg puts it all together. <code>npm run promo:video</code> rebuilds it at any time. I no longer need frameworks like Remotion or HyperFrames, so I didn't use them this time.</p>
<p><img src="https://nyosegawa.com/img/asist-realtime-voice-assistant/video-frames.jpg" alt="Frames from the video"></p>
<p>Scene changes and the moments when cards appear are aligned to the beats of the music's tempo (106 BPM). So that fast-moving things blur naturally, each frame is captured 16 times and averaged, which adds motion blur.</p>
<p>For the BGM, I made three candidates with Gemini's Lyria 3.5, played each against the video, and chose the music box one. The track is 58 seconds long, so I joined two points where the chords are very similar, skipped the part in between, and fit it into 48 seconds. The sound effects were written in numpy by a coding agent. What an age we live in.</p>
<h2 id="references" tabindex="-1"><a href="https://nyosegawa.com/en/posts/asist-realtime-voice-assistant/#references" class="header-anchor">References</a></h2>
<ul>
<li><a href="https://github.com/nyosegawa/asist">nyosegawa/asist</a></li>
<li><a href="https://ai.google.dev/gemini-api/docs/music-generation">Gemini API: Music generation (Lyria)</a></li>
<li><a href="https://platform.openai.com/docs/guides/image-generation">OpenAI Images API</a></li>
</ul>
]]>
      </content:encoded>
      <pubDate>Thu, 24 Sep 2026 00:00:00 GMT</pubDate>
    </item>
    <item>
      <title>デスクトップ向け音声対話アシスタントを作った話</title>
      <link>https://nyosegawa.com/posts/asist-realtime-voice-assistant/</link>
      <guid isPermaLink="false">https://nyosegawa.com/posts/asist-realtime-voice-assistant/</guid>
      <description>話しかけると相槌を打ち、答えをカードで返すデスクトップ (Windows / Mac) 向けの音声対話アシスタントASISTを作りました。音声対話で頑張ったところ、開発の進め方、プロモーション動画の作り方をまとめます。</description>
      <content:encoded>
        <![CDATA[<p>こんにちは！逆瀬川ちゃん (<a href="https://x.com/gyakuse">@gyakuse</a>) です！</p>
<p>今日はデスクトップ (Windows / Mac) 向けの音声対話アシスタント「<a href="https://asist-agent.com/">ASIST</a>」を作ったので、その紹介と、作るときに頑張ったところをまとめていきたいと思います。</p>
<!--more-->
<h2 id="%E6%9C%AA%E6%9D%A5%E3%81%A3%E3%81%BD%E3%81%84ui%E3%81%AE%E6%B1%8E%E7%94%A8%E3%82%A2%E3%82%B7%E3%82%B9%E3%82%BF%E3%83%B3%E3%83%88%E3%81%8C%E4%BD%9C%E3%82%8A%E3%81%9F%E3%81%8B%E3%81%A3%E3%81%9F" tabindex="-1"><a href="https://nyosegawa.com/posts/asist-realtime-voice-assistant/#%E6%9C%AA%E6%9D%A5%E3%81%A3%E3%81%BD%E3%81%84ui%E3%81%AE%E6%B1%8E%E7%94%A8%E3%82%A2%E3%82%B7%E3%82%B9%E3%82%BF%E3%83%B3%E3%83%88%E3%81%8C%E4%BD%9C%E3%82%8A%E3%81%9F%E3%81%8B%E3%81%A3%E3%81%9F" class="header-anchor">未来っぽいUIの汎用アシスタントが作りたかった</a></h2>
<p>さいきんパーソナルAIエージェントが増えてきています。代表的なものとしては、dotsやGrok Bot、Muse、Manusあたりでしょうか。
とてもよく作られている反面で、どっちかというとわたしはアニメや映画に出てくるような、複数のパネルがふわ〜っと動くものを欲しい気持ちがあります。でも、21世紀だというのに空中ディスプレイやARグラスは普及はしてません。ひとまずデスクトップアプリケーションとして作ることにしました。</p>
<p>AIアシスタントをつくるうえで、だいじにしたいことを最初にまとめました。</p>
<ul>
<li>未来感があること</li>
<li>会話体験が良いこと</li>
<li>記憶が確かなこと</li>
<li>日常の支援ができること</li>
<li>PC上の作業であれば接続をすれば何でもできること</li>
<li>どこからでも使えること</li>
<li>拡張性が豊かであること</li>
</ul>
<p>ASISTの最初のバージョンでは、これらのうち、さいごの2つ以外についてはある程度頑張れたかなと思っています。のこる2つについても、拡張性にかんしては<a href="https://nyosegawa.com/posts/skill-with-app/">以前のアイデア</a>をもう少し汎用的にしたものを、使う場所についてもうまく対応していく予定です。</p>
<h2 id="%E3%81%82%E3%82%89%E3%81%9F%E3%82%81%E3%81%A6asist%E3%81%A8%E3%81%AF" tabindex="-1"><a href="https://nyosegawa.com/posts/asist-realtime-voice-assistant/#%E3%81%82%E3%82%89%E3%81%9F%E3%82%81%E3%81%A6asist%E3%81%A8%E3%81%AF" class="header-anchor">あらためてASISTとは</a></h2>
<p>ASISTはデスクトップ向けの音声対話アシスタントです。話しかけると声で答えて、天気や予定やメールの下書きを会話の横にカードで出します。時間のかかる作業は、承認したあとにcodexかclaudeのCLIへ任せられます。</p>
<p><img src="https://nyosegawa.com/img/asist-realtime-voice-assistant/app-home.jpg" alt="ASISTのホーム画面"></p>
<p>公式サイトは<a href="https://asist-agent.com/">asist-agent.com</a>です。アプリは<a href="https://github.com/nyosegawa/asist/releases/latest">GitHubのReleases</a>からダウンロードでき、インストールから初回セットアップまでの手順はドキュメントの<a href="https://asist-agent.com/docs/start/install/">インストール</a>のページにまとめています。動かすにはApple SiliconのMac (macOS 14以降) かx64のWindows 11と、会話に使うモデルのAPIキー (Anthropic、OpenAI、Google、Cerebrasのどれか1つ) が必要です。ソースコードは<a href="https://github.com/nyosegawa/asist">nyosegawa/asist</a>でMIT Licenseのオープンソースとして公開しています。</p>
<p>主な機能は以下のとおりです。</p>
<ul>
<li>いい感じのテンポで話してくれる</li>
<li>必要な情報はカードを開いて見せてくれる (天気、予定、メール、To-Do、為替、地図など16種類のカードがあります)</li>
<li>タスク、メモ、メール、カレンダーなどはミニアプリとしてアプリケーション上で開くことができます</li>
</ul>
<h2 id="%E9%9F%B3%E5%A3%B0%E5%AF%BE%E8%A9%B1%E3%81%A7%E9%A0%91%E5%BC%B5%E3%81%A3%E3%81%9F%E3%81%A8%E3%81%93%E3%82%8D" tabindex="-1"><a href="https://nyosegawa.com/posts/asist-realtime-voice-assistant/#%E9%9F%B3%E5%A3%B0%E5%AF%BE%E8%A9%B1%E3%81%A7%E9%A0%91%E5%BC%B5%E3%81%A3%E3%81%9F%E3%81%A8%E3%81%93%E3%82%8D" class="header-anchor">音声対話で頑張ったところ</a></h2>
<p>これは別途詳細記事を書く予定なのですが、音声アシスタントでいちばんしんどいのは待ち時間です。cascadeな構成(ASR→LLM→TTS)だと素朴につくると3秒くらいかかってしまいます。そこで、相槌などを挟んで最初のリアクションまでの時間稼ぎをしています。</p>
<p><img src="https://nyosegawa.com/img/asist-realtime-voice-assistant/voice-pipeline.png" alt="音声処理の流れ"></p>
<h2 id="%E9%96%8B%E7%99%BA%E3%81%AB%E3%81%A4%E3%81%84%E3%81%A6" tabindex="-1"><a href="https://nyosegawa.com/posts/asist-realtime-voice-assistant/#%E9%96%8B%E7%99%BA%E3%81%AB%E3%81%A4%E3%81%84%E3%81%A6" class="header-anchor">開発について</a></h2>
<p>さいきんはおもにClaude (Opus5.5) で開発していて、Modelの評価などではCodex (Sol-6.1) を使っています。
Coding Agentが一般化してからいろいろな開発環境が流行り、自分も<a href="https://x.com/nyosegawa/status/2025517872622239840/photo/1">カンバン形式のAgentセッションマネージャー</a>を作り、モバイル対応もして使っていたのですが、そのあいだにClaude/Codexのデスクトップアプリがまともになってきました。普通にやるのがよいです。</p>
<p>また、Agent Skillsは10個程度にして、本当によくやることに絞ってます。
documentはだいたい腐るので、docs/adrに決定事項、issuesにやることを入れています。ADRも腐るので、頑張っていきましょう。</p>
<h2 id="%E3%83%97%E3%83%AD%E3%83%A2%E3%83%BC%E3%82%B7%E3%83%A7%E3%83%B3%E5%8B%95%E7%94%BB%E3%82%92%E4%BD%9C%E3%82%8B" tabindex="-1"><a href="https://nyosegawa.com/posts/asist-realtime-voice-assistant/#%E3%83%97%E3%83%AD%E3%83%A2%E3%83%BC%E3%82%B7%E3%83%A7%E3%83%B3%E5%8B%95%E7%94%BB%E3%82%92%E4%BD%9C%E3%82%8B" class="header-anchor">プロモーション動画を作る</a></h2>
<p>ClaudeでYouTubeとX向けの48秒の紹介動画を作りました。</p>
<iframe
  width="100%"
  height="405"
  src="https://www.youtube.com/embed/fAtpmG9QMcw"
  title="ASIST 紹介動画"
  frameborder="0"
  allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share"
  allowfullscreen
></iframe>
<p><a href="https://www.youtube.com/watch?v=fAtpmG9QMcw">YouTubeで見る</a></p>
<p>構成はシンプルで、HTMLとGSAPで場面を作り、headless Chromeで1/30秒ずつ撮って、ffmpegでまとめます。<code>npm run promo:video</code> でいつでも作り直せます。RemotionやHyperFramesのような仕組みはもう使わなくてよくなったので今回は利用していません。</p>
<p><img src="https://nyosegawa.com/img/asist-realtime-voice-assistant/video-frames.jpg" alt="動画のコマ"></p>
<p>場面の切り替えやカードが出てくる時刻は、曲のテンポ (106 BPM) の拍にそろえています。速く動くものが自然ににじんで見えるように、1コマごとに16回撮って平均し、モーションブラーもかけています。</p>
<p>BGMはGeminiのLyria 3.5で3案作り、動画に当てて聞き比べてオルゴールにしました。曲は58秒あるので、和音がよく似ている2か所をつないで途中を飛ばし、48秒に収めています。効果音はCoding Agentがnumpyで書きました。すごい時代です。</p>
<h2 id="references" tabindex="-1"><a href="https://nyosegawa.com/posts/asist-realtime-voice-assistant/#references" class="header-anchor">References</a></h2>
<ul>
<li><a href="https://github.com/nyosegawa/asist">nyosegawa/asist</a></li>
<li><a href="https://ai.google.dev/gemini-api/docs/music-generation">Gemini API: Music generation (Lyria)</a></li>
<li><a href="https://platform.openai.com/docs/guides/image-generation">OpenAI Images API</a></li>
</ul>
]]>
      </content:encoded>
      <pubDate>Thu, 24 Sep 2026 00:00:00 GMT</pubDate>
    </item>
    <item>
      <title>生成AI以前と以後でエンジニアの文章はどう変わったのか: Qiitaの7万記事を数えてみた話</title>
      <link>https://nyosegawa.com/posts/qiita-writing-before-after-ai/</link>
      <guid isPermaLink="false">https://nyosegawa.com/posts/qiita-writing-before-after-ai/</guid>
      <description>2019年から2026年までの各年8月にQiitaへ投稿された約7万件の記事を集めて、文の長さ、読点、太字や箇条書き、語彙が生成AI以前と以後でどう変わったのかを調べました</description>
      <content:encoded>
        <![CDATA[<p>こんにちは！逆瀬川ちゃん (<a href="https://x.com/gyakuse">@gyakuse</a>) です！</p>
<p>今日は生成AI以前と以後でエンジニアが書く文章がどう変わったのかを、Qiitaの記事を数えて確かめた結果をまとめていきたいと思います。</p>
<!--more-->
<p>最近の技術記事を読んでいると、見覚えのある言い回しに出会うことが増えました。「地味に効きます」「静かに壊れる」「〜とは別物です」みたいなやつです。太字と箇条書きもやたらと多い気がします。ただ、こういうのは肌感だけで語るとだいたい外すので、2019年から2026年までの各年8月にQiitaへ投稿された記事をすべて集めて、実際に数えてみました。</p>
<p>先に結果をざっくり書いておきます。</p>
<ul>
<li>記事の組み立て方はかなり変わっていました。2026年の記事は2019〜2022年と比べて、地の文がおよそ2倍に長くなり、太字はおよそ3倍、箇条書きの割合はおよそ1.8倍になっています</li>
<li>1文の長さは35字から39字に伸びていましたが、これはAI以前から続いていた変化の延長でほぼ説明できる範囲でした</li>
<li>読点は増えていました。1文あたりの読点は0.63個から0.95個になっています</li>
<li>「実測」「既定」「入口」「事故」「別物」のように、AI以前はほとんど使われなかった語が、2026年には5〜7%の記事に出てくるようになっていました</li>
<li>一方で、AIっぽい言い回しの代表のように言われる「しましょう」は、むしろ減っていました</li>
</ul>
<p>以下では、どうやって数えたのか、何がわかって何がわからないのかを順番に見ていきます。</p>
<h2 id="%E3%80%8C%E6%96%87%E7%AB%A0%E3%81%8C%E5%A4%89%E3%82%8F%E3%81%A3%E3%81%9F%E3%80%8D%E3%82%92%E4%BD%95%E3%81%A7%E6%B8%AC%E3%82%8B%E3%81%8B" tabindex="-1"><a href="https://nyosegawa.com/posts/qiita-writing-before-after-ai/#%E3%80%8C%E6%96%87%E7%AB%A0%E3%81%8C%E5%A4%89%E3%82%8F%E3%81%A3%E3%81%9F%E3%80%8D%E3%82%92%E4%BD%95%E3%81%A7%E6%B8%AC%E3%82%8B%E3%81%8B" class="header-anchor">「文章が変わった」を何で測るか</a></h2>
<p>「AIっぽい文章」という言葉はよく使われますが、中身はわりとふわっとしています。英語圏では、これを実際に数えた研究がいくつもあります。PubMedの論文の要旨1,500万件以上で「delve」のような語が過去の傾向より増えた分を数えて、2024年の要旨の少なくとも13.5%がLLMで処理されたと推定した研究（<a href="https://doi.org/10.1126/sciadv.adt3813">Kobak et al., 2025</a>）や、学会の査読コメントのうちLLMが大きく書き換えた割合を推定した研究（<a href="https://arxiv.org/abs/2403.07183">Liang et al., 2024</a>）がよく知られています。論文だけでなく、消費者の苦情、企業のプレスリリース、求人でも、ChatGPTの公開直後から同じような変化が起きていて、2024年末には1〜2割程度の文書にLLMの助けがあったと推定されています（<a href="https://doi.org/10.1016/j.patter.2025.101366">Liang et al., 2025</a>）。技術文書では、GitHubのプルリクエストの説明文を集めて語の使い方でまとめたところ、あるまとまりが2025年初めの0.7%から2026年半ばには39%に増え、その特徴語が「load-bearing」だったという分析があります（<a href="https://github.com/louisabraham/load-bearing">Abraham, 2026</a>）。</p>
<p>書き手の側でも、AIっぽさはかなり気にされるようになっています。英語では、AIが書いた文章の特徴を取り除くClaude Code向けのスキル（<a href="https://github.com/blader/humanizer">blader/humanizer</a>）が作られ、たくさんのフォークが生まれています。日本語でも、「重要なのは」「このように」のような定型句や単調な文体を見つけて自然な日本語に直すAgent Skill（<a href="https://github.com/coji/natural-japanese">natural-japanese</a>）が人気を集めています。どちらも、AIっぽいとされる書き方を具体的なパターンとして挙げて、それを見つけて書き直させるものです。</p>
<p>ただ、日本語の技術記事を生成AIの前後で比べて数えた分析は、探した範囲では見つかりませんでした。近いものとしては、自分の記事と同じタグのほかの人の記事を比べて、英語で見つかった特徴語が日本語の記事にはほとんど出てこず、うまく測れなかったと報告している記事があります（<a href="https://qiita.com/suwa_nobu/items/4231026d13f7527c0f69">suwa_nobu, 2026</a>）。英語の特徴語をそのまま持ってきても日本語の記事では測れないので、日本語の記事の中で何が増えたのかを、一から探す必要があります。</p>
<p>そこで今回は、次のような指標を決めて数えることにしました。</p>
<table>
<thead>
<tr>
<th>分類</th>
<th>指標</th>
</tr>
</thead>
<tbody>
<tr>
<td>文</td>
<td>平均文長、文長のばらつき（変動係数）、読点と読点の間の文字数</td>
</tr>
<tr>
<td>記事</td>
<td>地の文の文字数、箇条書きの割合、太字の頻度</td>
</tr>
<tr>
<td>語彙</td>
<td>2026年に増えた語と減った語、AI以前はまれだった語</td>
</tr>
<tr>
<td>そのほか</td>
<td>見出し、絵文字、ダッシュ、文体、文末の言い回し、読みやすさなど</td>
</tr>
</tbody>
</table>
<p>文と記事の6つを主要な指標にして、データを見る前に「箇条書きと太字は増える」「文長のばらつきは小さくなる」と予想を書いておきました。LLMの出力は文の長さがそろいやすく、箇条書きと太字を多く使うと言われているからです。指標をたくさん比べると、偶然の差がどこかで出てきやすくなります。なので、何を主に見るかと、差があるとみなす基準は先に決めて、設計書に残してから数えています。語彙の分析の一部（AI以前はまれだった語を探す分析など）は、結果を見た後に加えたものです。</p>
<p>さて、何を測るかが決まったら、次はどのデータで比べるかです。</p>
<h2 id="qiita%E3%81%AE%E5%90%84%E5%B9%B48%E6%9C%88%E3%81%AE%E8%A8%98%E4%BA%8B%E3%82%92%E3%81%99%E3%81%B9%E3%81%A6%E9%9B%86%E3%82%81%E3%82%8B" tabindex="-1"><a href="https://nyosegawa.com/posts/qiita-writing-before-after-ai/#qiita%E3%81%AE%E5%90%84%E5%B9%B48%E6%9C%88%E3%81%AE%E8%A8%98%E4%BA%8B%E3%82%92%E3%81%99%E3%81%B9%E3%81%A6%E9%9B%86%E3%82%81%E3%82%8B" class="header-anchor">Qiitaの各年8月の記事をすべて集める</a></h2>
<p>比べるのは、2019年から2026年までの各年8月に投稿された記事です。同じ8月どうしで比べれば、夏休みの学生の投稿のような季節の影響をそろえられます。ChatGPTの公開は2022年11月30日なので、2019〜2022年をAI以前とし、この4年分で年ごとの揺れの大きさとそれまでの傾向を見ます。2023〜2025年は移行期として、変化の途中を見るのに使います。</p>
<h3 id="qiita-api%E3%81%A77%E4%B8%87%E8%A8%98%E4%BA%8B%E3%82%92%E5%8F%96%E3%82%8B" tabindex="-1"><a href="https://nyosegawa.com/posts/qiita-writing-before-after-ai/#qiita-api%E3%81%A77%E4%B8%87%E8%A8%98%E4%BA%8B%E3%82%92%E5%8F%96%E3%82%8B" class="header-anchor">Qiita APIで7万記事を取る</a></h3>
<p><a href="https://qiita.com/api/v2/docs">Qiita API v2</a> の記事一覧は、<code>created:&gt;=2026-08-01 created:&lt;2026-08-02</code> のように作成日で検索できます。ただし1回の検索で取れるのは100件×100ページの1万件までで、2026年8月は11,562件あったので、1日ずつ検索しています。一覧APIの結果に本文のMarkdownが含まれているので、記事ごとに追加でリクエストする必要はありません。認証トークンを使えば1時間に1,000回まで送れるので、すべて取っても30分かからないくらいでした。</p>
<table>
<thead>
<tr>
<th>年</th>
<th style="text-align:right">取得した記事</th>
<th style="text-align:right">分析の対象</th>
</tr>
</thead>
<tbody>
<tr>
<td>2019</td>
<td style="text-align:right">9,309</td>
<td style="text-align:right">6,034</td>
</tr>
<tr>
<td>2020</td>
<td style="text-align:right">10,407</td>
<td style="text-align:right">7,076</td>
</tr>
<tr>
<td>2021</td>
<td style="text-align:right">8,145</td>
<td style="text-align:right">5,250</td>
</tr>
<tr>
<td>2022</td>
<td style="text-align:right">7,103</td>
<td style="text-align:right">4,116</td>
</tr>
<tr>
<td>2023</td>
<td style="text-align:right">6,920</td>
<td style="text-align:right">4,990</td>
</tr>
<tr>
<td>2024</td>
<td style="text-align:right">7,815</td>
<td style="text-align:right">5,813</td>
</tr>
<tr>
<td>2025</td>
<td style="text-align:right">9,263</td>
<td style="text-align:right">6,983</td>
</tr>
<tr>
<td>2026</td>
<td style="text-align:right">11,562</td>
<td style="text-align:right">10,469</td>
</tr>
</tbody>
</table>
<p>合計70,524件です。ここから、スライドモードの記事、AI以前の記事のうち2022年11月30日以降に更新されたもの（あとからAIで手を入れた可能性があるため）、地の文が300字未満の短い記事、日本語以外の記事を除いて、50,731件を分析しました。</p>
<p>2026年8月は8年間でいちばん投稿が多い月でした。短い記事の割合もかなり違っていて、地の文が300字未満の記事は2019〜2022年には各年の3割前後あったのに、2026年は7%しかありません。このあたりからすでに、2026年の記事が長くなっている気配があります。</p>
<h3 id="%E3%83%87%E3%83%BC%E3%82%BF%E3%81%AE%E6%89%B1%E3%81%84" tabindex="-1"><a href="https://nyosegawa.com/posts/qiita-writing-before-after-ai/#%E3%83%87%E3%83%BC%E3%82%BF%E3%81%AE%E6%89%B1%E3%81%84" class="header-anchor">データの扱い</a></h3>
<p>記事は公式のAPIで、利用制限の範囲内で2026年9月11日に取得しました。記事の著作権は各投稿者にあるので、取得した本文は手元での集計にだけ使っています。この記事に載せているのは集計した値と、多くの記事に共通して出てくる短い言い回しだけです。言い回しの例は、2026年に20人以上の著者が使っていたものに限りました。記事の本文、著者名、記事のURL、取得したデータは公開していません。また、この記事は個人の分析で、Qiita株式会社とは関係ありません。</p>
<h3 id="%E3%82%B3%E3%83%BC%E3%83%89%E3%82%92%E9%99%A4%E3%81%84%E3%81%A6%E3%80%8C%E5%9C%B0%E3%81%AE%E6%96%87%E3%80%8D%E3%82%92%E5%8F%96%E3%82%8A%E5%87%BA%E3%81%99" tabindex="-1"><a href="https://nyosegawa.com/posts/qiita-writing-before-after-ai/#%E3%82%B3%E3%83%BC%E3%83%89%E3%82%92%E9%99%A4%E3%81%84%E3%81%A6%E3%80%8C%E5%9C%B0%E3%81%AE%E6%96%87%E3%80%8D%E3%82%92%E5%8F%96%E3%82%8A%E5%87%BA%E3%81%99" class="header-anchor">コードを除いて「地の文」を取り出す</a></h3>
<p>技術記事にはコードブロックや表、コマンドの出力がたくさん入っています。これを混ぜたまま文の長さを数えると意味のない値になるので、Markdownを1行ずつ見て、コード、表、引用、見出し、箇条書きを分けました。残った普通の段落の文章を「地の文」と呼んでいます。文の長さと読点は地の文だけで数え、箇条書きは「箇条書きの割合」として別に数えています。</p>
<p>ひとつ悩ましかったのは文の区切り方です。Qiitaでは段落の中の改行がそのまま改行として表示されるので、句点を打たずに1行ずつ書く人がそこそこいます。そこで行の終わりも文の終わりとみなし、行が読点で終わっているときだけ次の行につなげることにしました。細かい規則はAppendix Aにまとめています。</p>
<h3 id="%E3%80%8Cai%E3%81%8C%E3%81%AA%E3%81%8B%E3%81%A3%E3%81%9F%E3%82%89%E3%80%8D%E3%81%AE%E4%BA%88%E6%B8%AC%E5%80%A4%E3%81%A8%E6%AF%94%E3%81%B9%E3%82%8B" tabindex="-1"><a href="https://nyosegawa.com/posts/qiita-writing-before-after-ai/#%E3%80%8Cai%E3%81%8C%E3%81%AA%E3%81%8B%E3%81%A3%E3%81%9F%E3%82%89%E3%80%8D%E3%81%AE%E4%BA%88%E6%B8%AC%E5%80%A4%E3%81%A8%E6%AF%94%E3%81%B9%E3%82%8B" class="header-anchor">「AIがなかったら」の予測値と比べる</a></h3>
<p>比べ方はこうです。2019〜2022年の年ごとの平均に直線を当てはめて、そのまま2026年まで延ばします。これを「AIがなかった場合の予測値」とみなして、2026年の値との差を見ます。差はAI以前の記事の標準偏差で割って、指標どうしで比べられるようにしました。0.2で小さい差、0.5で中くらい、0.8で大きい差というのが、よく使われるCohenの目安です。</p>
<p>もうひとつ気にしたのは、同じ著者の記事どうしは似ることです。1人で何十本も書く人がいると、記事単位で数えた信頼区間は狭くなりすぎます。そこで、著者を単位にしたブートストラップ（著者を復元抽出して計算し直すことを1,000回繰り返す）で信頼区間を出しています。1年に数千件あると、わずかな差でもp値が小さくなるので、p値は使っていません。計算の詳細はAppendix Bにあります。</p>
<p>準備が整ったので、結果を見ていきましょう。</p>
<h2 id="%E8%A8%98%E4%BA%8B%E3%81%AE%E7%B5%84%E3%81%BF%E7%AB%8B%E3%81%A6%E6%96%B9%E3%81%8C%E5%A4%A7%E3%81%8D%E3%81%8F%E5%A4%89%E3%82%8F%E3%81%A3%E3%81%9F" tabindex="-1"><a href="https://nyosegawa.com/posts/qiita-writing-before-after-ai/#%E8%A8%98%E4%BA%8B%E3%81%AE%E7%B5%84%E3%81%BF%E7%AB%8B%E3%81%A6%E6%96%B9%E3%81%8C%E5%A4%A7%E3%81%8D%E3%81%8F%E5%A4%89%E3%82%8F%E3%81%A3%E3%81%9F" class="header-anchor">記事の組み立て方が大きく変わった</a></h2>
<p><img src="https://nyosegawa.com/img/qiita-writing-before-after-ai/primary_trends.png" alt="主要な指標の推移"></p>
<p>青い点が比べる年（2019〜2022年と2026年）で、灰色の点が移行期（2023〜2025年）です。破線はAI以前の直線を延ばした予測値です。</p>
<table>
<thead>
<tr>
<th>指標</th>
<th style="text-align:right">AI以前の平均</th>
<th style="text-align:right">予測値</th>
<th style="text-align:right">2026年</th>
<th>予測値との差</th>
</tr>
</thead>
<tbody>
<tr>
<td>平均文長（字）</td>
<td style="text-align:right">35.4</td>
<td style="text-align:right">38.9</td>
<td style="text-align:right">39.2</td>
<td>+0.01（−0.15〜+0.12）</td>
</tr>
<tr>
<td>文長の変動係数</td>
<td style="text-align:right">0.609</td>
<td style="text-align:right">0.546</td>
<td style="text-align:right">0.535</td>
<td>−0.07（−0.19〜+0.05）</td>
</tr>
<tr>
<td>読点間の平均文字数（字）</td>
<td style="text-align:right">21.2</td>
<td style="text-align:right">22.7</td>
<td style="text-align:right">19.2</td>
<td>−0.17（−0.33〜−0.08）</td>
</tr>
<tr>
<td>地の文の文字数（字）</td>
<td style="text-align:right">976</td>
<td style="text-align:right">994</td>
<td style="text-align:right">1,914</td>
<td>+0.89（+0.75〜+1.02）</td>
</tr>
<tr>
<td>箇条書きの割合</td>
<td style="text-align:right">8.9%</td>
<td style="text-align:right">8.0%</td>
<td style="text-align:right">16.4%</td>
<td>+0.57（+0.45〜+0.69）</td>
</tr>
<tr>
<td>太字の頻度（1,000字あたり）</td>
<td style="text-align:right">1.26</td>
<td style="text-align:right">1.27</td>
<td style="text-align:right">3.83</td>
<td>+0.84（+0.65〜+1.01）</td>
</tr>
</tbody>
</table>
<p>括弧の中は95%信頼区間です。地の文の文字数は分布が右に長く伸びるので、幾何平均で見ています。</p>
<p>企業アカウントの記事やAI関連のタグが付いた記事を除いたり、著者ごとに平均してから集計したりしても、結果はほとんど変わりませんでした（Appendix C）。2026年の記事の34%にはAI関連のタグが付いていますが、変化はAIについて書いた記事だけで起きているわけではなさそうです。</p>
<h3 id="%E9%95%B7%E3%81%8F%E3%81%A6%E5%A4%AA%E5%AD%97%E3%81%A8%E7%AE%87%E6%9D%A1%E6%9B%B8%E3%81%8D%E3%81%AE%E5%A4%9A%E3%81%84%E8%A8%98%E4%BA%8B%E3%81%AB%E3%81%AA%E3%81%A3%E3%81%9F" tabindex="-1"><a href="https://nyosegawa.com/posts/qiita-writing-before-after-ai/#%E9%95%B7%E3%81%8F%E3%81%A6%E5%A4%AA%E5%AD%97%E3%81%A8%E7%AE%87%E6%9D%A1%E6%9B%B8%E3%81%8D%E3%81%AE%E5%A4%9A%E3%81%84%E8%A8%98%E4%BA%8B%E3%81%AB%E3%81%AA%E3%81%A3%E3%81%9F" class="header-anchor">長くて太字と箇条書きの多い記事になった</a></h3>
<p>いちばんはっきり変わったのは記事の組み立て方です。地の文の文字数は976字から1,914字とおよそ2倍になり、太字は1,000字あたり1.26個から3.83個とおよそ3倍、箇条書きの割合は8.9%から16.4%になりました。どれも予測値との差が+0.57〜+0.89で、中くらいから大きい変化にあたります。予想していた「箇条書きと太字は増える」は、そのとおりになりました。</p>
<p>太字と箇条書きがなぜ増えたのかを考えるうえでは、人の評価者も報酬モデルもリストや太字の多い回答を好むことを示した研究（<a href="https://aclanthology.org/2025.acl-long.1308/">Zhang et al., 2025</a>）や、Chatbot Arenaで回答の長さや太字、リストの数をそろえて比べると順位が大きく入れ替わったという分析（<a href="https://www.lmsys.org/blog/2024-08-28-style-control/">Li et al., 2024</a>）が参考になります。人に好まれやすい書式を身につけたモデルの文章が、そのまま記事に入ってきているのかもしれません。</p>
<p>面白いのは時期の違いです。箇条書きと太字は2025年がいちばん多く（箇条書き19.7%、太字4.45個）、2026年は少し減っています。それに対して地の文の長さは、2023〜2025年は1,100字前後で、2026年に一気に伸びています。同じ「AI以後」でも、2025年と2026年では変わり方が違うようです。</p>
<h3 id="%E6%96%87%E3%81%AE%E9%95%B7%E3%81%95%E3%81%AF%E3%82%82%E3%81%A8%E3%82%82%E3%81%A8%E4%BC%B8%E3%81%B3%E3%81%A6%E3%81%84%E3%81%9F" tabindex="-1"><a href="https://nyosegawa.com/posts/qiita-writing-before-after-ai/#%E6%96%87%E3%81%AE%E9%95%B7%E3%81%95%E3%81%AF%E3%82%82%E3%81%A8%E3%82%82%E3%81%A8%E4%BC%B8%E3%81%B3%E3%81%A6%E3%81%84%E3%81%9F" class="header-anchor">文の長さはもともと伸びていた</a></h3>
<p>1文の長さは2019年の34.7字から2026年の39.2字に伸びています。これだけ見ると「AIで文が長くなった」と言いたくなるのですが、2019〜2022年のあいだにもすでに伸びていました。直線を延ばした予測値は38.9字で、2026年の値とほぼ同じです。米国土木学会の論文の要旨を25年分調べた研究でも、LLM以前から要旨と文が長くなる傾向が続いていたと報告されています（<a href="https://arxiv.org/abs/2602.03864">Sanger and Maurer, 2026</a>）。AIの影響を見るときは、こうした長い傾向を差し引かないといけません。ただ、2022年の平均は1文が極端に長い記事1件で押し上げられていて、極端な値を抑えて計算し直すと、文の長さもわずかに伸びた可能性が出てきます（Appendix C）。</p>
<p>文長のばらつき（変動係数）も同じで、0.62前後から0.535に小さくなっていますが、AI以前からの傾向で説明できる範囲でした。「ばらつきは小さくなる」という予想は、傾向を超える変化としては確かめられませんでした。</p>
<h3 id="%E8%AA%AD%E7%82%B9%E3%81%AF%E5%A2%97%E3%81%88%E3%81%9F" tabindex="-1"><a href="https://nyosegawa.com/posts/qiita-writing-before-after-ai/#%E8%AA%AD%E7%82%B9%E3%81%AF%E5%A2%97%E3%81%88%E3%81%9F" class="header-anchor">読点は増えた</a></h3>
<p>読点と読点の間の文字数は、21.2字から19.2字に縮みました。1文あたりの読点の数でいうと、0.63個から0.95個に増えています。文が少し長くなって、読点はそれ以上に増えた、という感じです。予測値との差は−0.17で、小さい差の目安にはぎりぎり届かないのですが、AI以前の隣り合う年の差の最大値は超えています。</p>
<p>さきほどの土木学会の研究でも、LLMの助けを受けたと判定された要旨ではカンマの使用が増えていました（<a href="https://arxiv.org/abs/2602.03864">Sanger and Maurer, 2026</a>）。日本語でも、GPTが書いた求人の文章は人の文章より句読点が多いという報告があります（<a href="https://tech-blog.lapras.com/techBlogs/compare-gpt">LAPRAS, 2023</a>）。ただしこちらは企業のブログで、比べた文章の数は書かれていません。</p>
<p>組み立て方が変わったのはわかりました。では、使われている語はどう変わったのでしょうか。</p>
<h2 id="%E8%AA%9E%E5%BD%99%E3%81%AF%E4%BD%95%E3%81%8C%E5%A2%97%E3%81%88%E3%81%A6%E4%BD%95%E3%81%8C%E6%B8%9B%E3%81%A3%E3%81%9F%E3%81%8B" tabindex="-1"><a href="https://nyosegawa.com/posts/qiita-writing-before-after-ai/#%E8%AA%9E%E5%BD%99%E3%81%AF%E4%BD%95%E3%81%8C%E5%A2%97%E3%81%88%E3%81%A6%E4%BD%95%E3%81%8C%E6%B8%9B%E3%81%A3%E3%81%9F%E3%81%8B" class="header-anchor">語彙は何が増えて何が減ったか</a></h2>
<h3 id="%E6%89%8B%E9%A0%86%E3%82%92%E6%9B%B8%E3%81%8F%E6%96%87%E7%AB%A0%E3%81%8B%E3%82%89%E4%BB%95%E7%B5%84%E3%81%BF%E3%82%92%E8%AA%AC%E6%98%8E%E3%81%99%E3%82%8B%E6%96%87%E7%AB%A0%E3%81%B8" tabindex="-1"><a href="https://nyosegawa.com/posts/qiita-writing-before-after-ai/#%E6%89%8B%E9%A0%86%E3%82%92%E6%9B%B8%E3%81%8F%E6%96%87%E7%AB%A0%E3%81%8B%E3%82%89%E4%BB%95%E7%B5%84%E3%81%BF%E3%82%92%E8%AA%AC%E6%98%8E%E3%81%99%E3%82%8B%E6%96%87%E7%AB%A0%E3%81%B8" class="header-anchor">手順を書く文章から仕組みを説明する文章へ</a></h3>
<p>語はMeCab（fugashi）とUniDicで形態素解析して、原形で数えています。AI以前と2026年の語の出現回数を対数オッズ比で比べました。偶然の差を拾わないように、2026年の記事を著者単位で2つの組に分けて、片方で選んだ語がもう片方でも同じ方向に出るかを確かめています。この表は、AI関連のタグが付いた記事も含めて数えています。</p>
<table>
<thead>
<tr>
<th>2026年に多い語</th>
<th>AI以前に多い語</th>
</tr>
</thead>
<tbody>
<tr>
<td>設計、判断、検証、整理、運用、レビュー、経路、安全、重要、失敗、承認、境界、構造、品質、根拠</td>
<td>インストール、コマンド、設定、クリック、表示、ファイル、ボタン、ディレクトリ、ダウンロード</td>
</tr>
<tr>
<td>だけ、まま、ず、その、同じ、別、一方、全部、実際</td>
<td>以下、下記、上記、参考、こちら、先ほど</td>
</tr>
<tr>
<td>残る、効く、見える、直す、測る、壊れる、止まる、任せる</td>
<td>思う、感じ、〜てる、〜てみる、とりあえず、色々、簡単、わかる</td>
</tr>
</tbody>
</table>
<p>AIの話題の語（claude、エージェント、プロンプトなど）や、rails、dockerのような技術名は表から外しています。</p>
<p>AI以前の記事は、手を動かした手順を、画面やコマンドを指しながら、書き手の感想を交えて書くものが多かったようです。「以下のコマンドを実行します」「こちらを参考にしました」「とりあえず動いた感じです」のような文章です（この3つは説明のために私が作った例です）。2026年の記事では、設計や検証のような抽象的な語で、仕組みと運用を説明する文章が増えています。</p>
<h3 id="%E8%A8%98%E4%BA%8B%E3%81%8C%E9%95%B7%E3%81%8F%E3%81%AA%E3%82%8B%E3%81%A8%E3%81%82%E3%82%8A%E3%81%B5%E3%82%8C%E3%81%9F%E8%AA%9E%E3%81%8C%E3%80%8C%E5%A2%97%E3%81%88%E3%81%A6%E3%80%8D%E8%A6%8B%E3%81%88%E3%82%8B" tabindex="-1"><a href="https://nyosegawa.com/posts/qiita-writing-before-after-ai/#%E8%A8%98%E4%BA%8B%E3%81%8C%E9%95%B7%E3%81%8F%E3%81%AA%E3%82%8B%E3%81%A8%E3%81%82%E3%82%8A%E3%81%B5%E3%82%8C%E3%81%9F%E8%AA%9E%E3%81%8C%E3%80%8C%E5%A2%97%E3%81%88%E3%81%A6%E3%80%8D%E8%A6%8B%E3%81%88%E3%82%8B" class="header-anchor">記事が長くなるとありふれた語が「増えて」見える</a></h3>
<p>Kobakらにならった excess vocabulary の方法も試しました。AI以前の4年の傾向から2026年の文書頻度（その語を含む記事の割合）を予測して、実際にどれだけ上回ったかを見る方法です。</p>
<p>ところが、これをそのまま使うと、上位に「へ」「だけ」「まま」「まで」「同じ」のようなありふれた語が並びます。2026年の記事は地の文が約2倍に長いので、記事全体で数えると、ありふれた語ほど「その語を含む記事」の割合が上がってしまうからです。論文の要旨のように長さのそろった文章なら問題にならないのですが、ブログ記事では長さの変化を差し引かないといけません。</p>
<p>そこで、各記事の先頭200語だけで数え直してみました。AI関連のタグが付いた記事を除いて、語を選ぶのに使わなかった検証用の組で見ると、「設計」はおよそ4倍、「整理」は6倍、「壊れる」は7倍、「瞬間」と「止まる」は8倍、「単なる」は13倍、「実測」は18倍になっていました。記事が長くなった分を差し引いても、これらの語は増えています。</p>
<h3 id="%E3%80%8C%E5%8A%B9%E3%81%8F%E3%80%8D%E3%80%8C%E7%9E%AC%E9%96%93%E3%80%8D%E3%80%8C%E9%9D%99%E3%81%8B%E3%81%AB%E3%80%8D" tabindex="-1"><a href="https://nyosegawa.com/posts/qiita-writing-before-after-ai/#%E3%80%8C%E5%8A%B9%E3%81%8F%E3%80%8D%E3%80%8C%E7%9E%AC%E9%96%93%E3%80%8D%E3%80%8C%E9%9D%99%E3%81%8B%E3%81%AB%E3%80%8D" class="header-anchor">「効く」「瞬間」「静かに」</a></h3>
<p>語の変化の中でも、個人的にいちばん肌感と合っていたのは次のような語です。</p>
<table>
<thead>
<tr>
<th>語</th>
<th style="text-align:right">AI以前</th>
<th style="text-align:right">2026年</th>
<th>2026年によく見られる言い回し</th>
</tr>
</thead>
<tbody>
<tr>
<td>効く</td>
<td style="text-align:right">2.0%</td>
<td style="text-align:right">22.3%</td>
<td>〜が効いてくる、地味に効きます</td>
</tr>
<tr>
<td>瞬間</td>
<td style="text-align:right">0.9%</td>
<td style="text-align:right">13.7%</td>
<td>〜した瞬間</td>
</tr>
<tr>
<td>壊れる</td>
<td style="text-align:right">0.8%</td>
<td style="text-align:right">13.1%</td>
<td>〜が壊れている、〜も壊れない</td>
</tr>
<tr>
<td>境界</td>
<td style="text-align:right">1.0%</td>
<td style="text-align:right">12.8%</td>
<td>〜の境界を、境界値</td>
</tr>
<tr>
<td>実測</td>
<td style="text-align:right">0.1%</td>
<td style="text-align:right">9.4%</td>
<td>実測では、〜の実測値</td>
</tr>
<tr>
<td>静か</td>
<td style="text-align:right">0.1%</td>
<td style="text-align:right">4.9%</td>
<td>静かに壊れる</td>
</tr>
<tr>
<td>黙って</td>
<td style="text-align:right">0.1%</td>
<td style="text-align:right">3.4%</td>
<td>黙って捨てられる、黙って無視される</td>
</tr>
<tr>
<td>正本</td>
<td style="text-align:right">0.0%</td>
<td style="text-align:right">1.7%</td>
<td>〜を正本にする、〜を正本として</td>
</tr>
</tbody>
</table>
<p>地の文と箇条書きにその語を含む記事の割合です。この表は、AI関連のタグが付いた記事も含めて数えています。次の節の表はAI関連のタグが付いた記事を除いて数えているので、同じ語でも値が違います（「実測」は9.4%と7.2%）。</p>
<p>「効く」を含む記事は2%から22%と、10倍以上になりました。「〜た瞬間に」の形は2026年に428人の著者が使っていて、ごくありふれた言い回しになっています。「静かに壊れる」「黙って無視される」は英語の silently break や silently ignored に、「〜した瞬間」は the moment に、「正本」は source of truth にそのまま対応していそうです。英語で考えたことを日本語に置き換えたような言い回しが、日本語の技術記事に入ってきているように見えます。ただし、これは用例を見たうえでの解釈で、翻訳された文章だと確かめたわけではありません。</p>
<p>また、これはAIが書いた文章が増えただけとは限りません。英語では、台本のない会話（ポッドキャスト）でも、ChatGPTの公開後に「delve」のような語が急に増えたことが報告されています（<a href="https://arxiv.org/abs/2409.01754">Yakura et al., 2024</a>）。人がAIの文章を読むうちに、その言い回しを自分でも使うようになっている可能性があります。</p>
<p>ここまでで気になる語はいくつも見つかったのですが、上位の語だけを見ていると、ほかの語に気づけません。そこで、AI以前はまれだった語をまとめて探してみました。</p>
<h2 id="ai%E4%BB%A5%E5%89%8D%E3%81%AF%E3%81%BE%E3%82%8C%E3%81%A72026%E5%B9%B4%E3%81%AB%E5%BA%83%E3%81%8C%E3%81%A3%E3%81%9F%E8%AA%9E%E5%BD%99" tabindex="-1"><a href="https://nyosegawa.com/posts/qiita-writing-before-after-ai/#ai%E4%BB%A5%E5%89%8D%E3%81%AF%E3%81%BE%E3%82%8C%E3%81%A72026%E5%B9%B4%E3%81%AB%E5%BA%83%E3%81%8C%E3%81%A3%E3%81%9F%E8%AA%9E%E5%BD%99" class="header-anchor">AI以前はまれで2026年に広がった語彙</a></h2>
<h3 id="%E6%8E%A2%E3%81%97%E6%96%B9" tabindex="-1"><a href="https://nyosegawa.com/posts/qiita-writing-before-after-ai/#%E6%8E%A2%E3%81%97%E6%96%B9" class="header-anchor">探し方</a></h3>
<p>AI関連のタグが付いた記事を除いて、次の条件をすべて満たす語を探しました。</p>
<ul>
<li>AI以前にその語を含む記事が0.5%以下</li>
<li>2026年の2つの組のどちらでも0.8%以上、かつAI以前の5倍以上</li>
<li>英字の語、固有名詞、数ではない</li>
</ul>
<p>これで402語が見つかりました。ただ、候補の語の使われ方を眺めていると、まったく同じ言い回しがちょうど同じ件数ずつ並んでいる語がいくつもありました。1人の著者が似た記事をまとめて投稿していると、文書頻度だけでは語が広まったように見えてしまいます。そこで「2026年にその語を使った著者が30人以上」「1人の著者の記事が、その語を含む記事の2割以下」という条件を足して、17語を除きました。残った385語は、その語を使った著者の割合で比べても、2026年はAI以前の8.8倍（中央値）です。</p>
<p><img src="https://nyosegawa.com/img/qiita-writing-before-after-ai/new_words.png" alt="AI以前はまれで、2026年に広がった語"></p>
<p>分け方は私の判断ですが、目立つ語を並べると、次の5つのまとまりがありました。表の言い回しは、2026年に20人以上の著者が使っていたものから選んでいます。20人以上に共通する言い回しがなかった語は「なし」としています。</p>
<h3 id="%E7%A2%BA%E3%81%8B%E3%82%81%E3%82%8B%E3%81%93%E3%81%A8%E3%82%84%E7%96%91%E3%81%86%E3%81%93%E3%81%A8%E3%82%92%E8%A1%A8%E3%81%99%E8%AA%9E" tabindex="-1"><a href="https://nyosegawa.com/posts/qiita-writing-before-after-ai/#%E7%A2%BA%E3%81%8B%E3%82%81%E3%82%8B%E3%81%93%E3%81%A8%E3%82%84%E7%96%91%E3%81%86%E3%81%93%E3%81%A8%E3%82%92%E8%A1%A8%E3%81%99%E8%AA%9E" class="header-anchor">確かめることや疑うことを表す語</a></h3>
<table>
<thead>
<tr>
<th>語</th>
<th style="text-align:right">AI以前</th>
<th style="text-align:right">2026年</th>
<th>言い回し</th>
</tr>
</thead>
<tbody>
<tr>
<td>実測</td>
<td style="text-align:right">0.1%</td>
<td style="text-align:right">7.2%</td>
<td>実測では、〜の実測値</td>
</tr>
<tr>
<td>疑う</td>
<td style="text-align:right">0.5%</td>
<td style="text-align:right">6.3%</td>
<td>〜を疑って、疑う前に</td>
</tr>
<tr>
<td>照合</td>
<td style="text-align:right">0.3%</td>
<td style="text-align:right">4.5%</td>
<td>〜と照合して</td>
</tr>
<tr>
<td>見落とす</td>
<td style="text-align:right">0.3%</td>
<td style="text-align:right">4.1%</td>
<td>見落とされがち</td>
</tr>
<tr>
<td>突き合わせる</td>
<td style="text-align:right">0.06%</td>
<td style="text-align:right">3.7%</td>
<td>〜を突き合わせると</td>
</tr>
<tr>
<td>断定</td>
<td style="text-align:right">0.08%</td>
<td style="text-align:right">2.4%</td>
<td>〜とは断定できません</td>
</tr>
<tr>
<td>取り違える</td>
<td style="text-align:right">0.02%</td>
<td style="text-align:right">1.4%</td>
<td>〜を取り違えると</td>
</tr>
</tbody>
</table>
<h3 id="%E4%BD%8D%E7%BD%AE%E3%82%84%E5%BD%B9%E5%89%B2%E3%82%92%E5%A0%B4%E6%89%80%E3%82%84%E7%89%A9%E3%81%AB%E3%81%9F%E3%81%A8%E3%81%88%E3%82%8B%E8%AA%9E" tabindex="-1"><a href="https://nyosegawa.com/posts/qiita-writing-before-after-ai/#%E4%BD%8D%E7%BD%AE%E3%82%84%E5%BD%B9%E5%89%B2%E3%82%92%E5%A0%B4%E6%89%80%E3%82%84%E7%89%A9%E3%81%AB%E3%81%9F%E3%81%A8%E3%81%88%E3%82%8B%E8%AA%9E" class="header-anchor">位置や役割を場所や物にたとえる語</a></h3>
<table>
<thead>
<tr>
<th>語</th>
<th style="text-align:right">AI以前</th>
<th style="text-align:right">2026年</th>
<th>言い回し</th>
</tr>
</thead>
<tbody>
<tr>
<td>入口</td>
<td style="text-align:right">0.4%</td>
<td style="text-align:right">6.8%</td>
<td>〜の入口として、〜の入口に</td>
</tr>
<tr>
<td>土台</td>
<td style="text-align:right">0.3%</td>
<td style="text-align:right">5.5%</td>
<td>〜の土台に、〜するための土台</td>
</tr>
<tr>
<td>道具</td>
<td style="text-align:right">0.3%</td>
<td style="text-align:right">4.1%</td>
<td>〜するための道具</td>
</tr>
<tr>
<td>核心</td>
<td style="text-align:right">0.07%</td>
<td style="text-align:right">2.4%</td>
<td>〜の核心です</td>
</tr>
<tr>
<td>主役</td>
<td style="text-align:right">0.1%</td>
<td style="text-align:right">1.9%</td>
<td>〜の主役は、今回の主役</td>
</tr>
<tr>
<td>構図</td>
<td style="text-align:right">0.09%</td>
<td style="text-align:right">1.6%</td>
<td>〜という構図</td>
</tr>
<tr>
<td>線引き</td>
<td style="text-align:right">0.04%</td>
<td style="text-align:right">1.3%</td>
<td>〜という線引き</td>
</tr>
</tbody>
</table>
<p>「〜するための土台」「〜するための道具」の形は、それぞれ59人と97人の著者が使っていました。物事の役割を、場所や物にたとえて説明する書き方です。</p>
<h3 id="%E9%9A%9C%E5%AE%B3%E3%82%84%E5%A4%B1%E6%95%97%E3%82%92%E8%A1%A8%E3%81%99%E8%AA%9E" tabindex="-1"><a href="https://nyosegawa.com/posts/qiita-writing-before-after-ai/#%E9%9A%9C%E5%AE%B3%E3%82%84%E5%A4%B1%E6%95%97%E3%82%92%E8%A1%A8%E3%81%99%E8%AA%9E" class="header-anchor">障害や失敗を表す語</a></h3>
<table>
<thead>
<tr>
<th>語</th>
<th style="text-align:right">AI以前</th>
<th style="text-align:right">2026年</th>
<th>言い回し</th>
</tr>
</thead>
<tbody>
<tr>
<td>事故</td>
<td style="text-align:right">0.3%</td>
<td style="text-align:right">6.8%</td>
<td>〜という事故、事故が起きる、事故を防ぐ</td>
</tr>
<tr>
<td>混ざる</td>
<td style="text-align:right">0.4%</td>
<td style="text-align:right">4.9%</td>
<td>〜が混ざっている</td>
</tr>
<tr>
<td>落とし穴</td>
<td style="text-align:right">0.3%</td>
<td style="text-align:right">4.0%</td>
<td>落とし穴があります</td>
</tr>
<tr>
<td>破綻</td>
<td style="text-align:right">0.2%</td>
<td style="text-align:right">2.7%</td>
<td>破綻します</td>
</tr>
<tr>
<td>実害</td>
<td style="text-align:right">0.08%</td>
<td style="text-align:right">1.4%</td>
<td>実害はありません</td>
</tr>
<tr>
<td>素通り</td>
<td style="text-align:right">0.04%</td>
<td style="text-align:right">1.1%</td>
<td>〜を素通りして</td>
</tr>
</tbody>
</table>
<h3 id="%E4%BD%9C%E6%A5%AD%E3%82%92%E6%AF%94%E5%96%A9%E7%9A%84%E3%81%AB%E8%A1%A8%E3%81%99%E5%8B%95%E8%A9%9E" tabindex="-1"><a href="https://nyosegawa.com/posts/qiita-writing-before-after-ai/#%E4%BD%9C%E6%A5%AD%E3%82%92%E6%AF%94%E5%96%A9%E7%9A%84%E3%81%AB%E8%A1%A8%E3%81%99%E5%8B%95%E8%A9%9E" class="header-anchor">作業を比喩的に表す動詞</a></h3>
<table>
<thead>
<tr>
<th>語</th>
<th style="text-align:right">AI以前</th>
<th style="text-align:right">2026年</th>
<th>言い回し</th>
</tr>
</thead>
<tbody>
<tr>
<td>切り分ける</td>
<td style="text-align:right">0.5%</td>
<td style="text-align:right">5.2%</td>
<td>原因を切り分ける</td>
</tr>
<tr>
<td>潰す</td>
<td style="text-align:right">0.3%</td>
<td style="text-align:right">4.2%</td>
<td>1つずつ潰していく</td>
</tr>
<tr>
<td>踏み込む</td>
<td style="text-align:right">0.3%</td>
<td style="text-align:right">2.7%</td>
<td>〜まで踏み込んで</td>
</tr>
<tr>
<td>塞ぐ</td>
<td style="text-align:right">0.04%</td>
<td style="text-align:right">1.5%</td>
<td>なし</td>
</tr>
<tr>
<td>溶かす</td>
<td style="text-align:right">0.1%</td>
<td style="text-align:right">1.3%</td>
<td>時間を溶かしました</td>
</tr>
<tr>
<td>逃がす</td>
<td style="text-align:right">0.01%</td>
<td style="text-align:right">1.2%</td>
<td>なし</td>
</tr>
<tr>
<td>倒す</td>
<td style="text-align:right">0.2%</td>
<td style="text-align:right">1.1%</td>
<td>〜側に倒す</td>
</tr>
</tbody>
</table>
<h3 id="%E5%88%A4%E6%96%AD%E3%82%84%E8%A9%95%E4%BE%A1%E3%82%92%E8%BF%B0%E3%81%B9%E3%82%8B%E8%AA%9E" tabindex="-1"><a href="https://nyosegawa.com/posts/qiita-writing-before-after-ai/#%E5%88%A4%E6%96%AD%E3%82%84%E8%A9%95%E4%BE%A1%E3%82%92%E8%BF%B0%E3%81%B9%E3%82%8B%E8%AA%9E" class="header-anchor">判断や評価を述べる語</a></h3>
<table>
<thead>
<tr>
<th>語</th>
<th style="text-align:right">AI以前</th>
<th style="text-align:right">2026年</th>
<th>言い回し</th>
</tr>
</thead>
<tbody>
<tr>
<td>既定</td>
<td style="text-align:right">0.5%</td>
<td style="text-align:right">7.1%</td>
<td>既定では、〜は既定で</td>
</tr>
<tr>
<td>別物</td>
<td style="text-align:right">0.4%</td>
<td style="text-align:right">5.5%</td>
<td>〜とは別物、〜は別物です</td>
</tr>
<tr>
<td>定番</td>
<td style="text-align:right">0.5%</td>
<td style="text-align:right">3.6%</td>
<td>〜が定番です</td>
</tr>
<tr>
<td>要点</td>
<td style="text-align:right">0.5%</td>
<td style="text-align:right">3.2%</td>
<td>〜の要点は、〜が要点です</td>
</tr>
<tr>
<td>素朴</td>
<td style="text-align:right">0.2%</td>
<td style="text-align:right">2.7%</td>
<td>〜という素朴な</td>
</tr>
<tr>
<td>定石</td>
<td style="text-align:right">0.1%</td>
<td style="text-align:right">1.8%</td>
<td>〜のが定石です</td>
</tr>
<tr>
<td>桁違い</td>
<td style="text-align:right">0.05%</td>
<td style="text-align:right">1.1%</td>
<td>〜が桁違いに</td>
</tr>
</tbody>
</table>
<p>「〜のが定石」（61人）、「〜が要点です」（40人）、「〜が定番です」（35人）のように、評価の名詞を「〜です」で置いて文を締める形も見られます。</p>
<h3 id="%E5%A2%97%E3%81%88%E3%81%9F%E6%99%82%E6%9C%9F%E3%81%AE%E9%81%95%E3%81%84" tabindex="-1"><a href="https://nyosegawa.com/posts/qiita-writing-before-after-ai/#%E5%A2%97%E3%81%88%E3%81%9F%E6%99%82%E6%9C%9F%E3%81%AE%E9%81%95%E3%81%84" class="header-anchor">増えた時期の違い</a></h3>
<p><img src="https://nyosegawa.com/img/qiita-writing-before-after-ai/word_waves.png" alt="増えた時期の違い"></p>
<p>増えた時期で分けてみると、385語のうち197語は、増えた分の8割以上が2025年から2026年のあいだに起きていました。反対に、増えた分の6割以上が2025年までに起きていた語は36語で、残りの152語はその中間です。2025年までに増えた語には「強み」「両立」「堅牢」「早期」のようなビジネス文書に多い語が多く、どれも2023年から増え始めています。</p>
<p>主要な指標でも、箇条書きと太字は2025年がピークで、地の文の長さは2026年に大きく伸びていました。Qiitaの記事を見るかぎり、2026年はClaude CodeやCodexのようなCoding Agentが一気に広まった年でもあります。ClaudeCode、Codex、Cursorなどのタグが付いた記事は、2025年8月の3.1%から2026年8月には9.5%に増えました。コードだけでなく記事もCoding Agentと一緒に書く人が増えたことが、2026年の変化に関係していそうです。ただ、今回のデータだけで理由を言い切ることはできません。</p>
<h3 id="%E8%A8%80%E3%81%84%E6%8F%9B%E3%81%88%E3%81%AA%E3%81%AE%E3%81%8B%E8%B6%B3%E3%81%95%E3%82%8C%E3%81%9F%E3%81%AE%E3%81%8B" tabindex="-1"><a href="https://nyosegawa.com/posts/qiita-writing-before-after-ai/#%E8%A8%80%E3%81%84%E6%8F%9B%E3%81%88%E3%81%AA%E3%81%AE%E3%81%8B%E8%B6%B3%E3%81%95%E3%82%8C%E3%81%9F%E3%81%AE%E3%81%8B" class="header-anchor">言い換えなのか足されたのか</a></h3>
<p>「既定」が増えたなら「デフォルト」は減ったのかと思って確かめてみました。その語を含む記事の割合で見ると、「デフォルト」は15.6%から13.9%とほとんど減っていません。ただ、2026年の記事は長くなっているので、記事の割合だけでは使われ方の変化がわかりません。そこで、地の文と箇条書きの1万字あたりの回数でも比べてみました（AI関連のタグが付いた記事を除いて、文字列で数えています）。</p>
<table>
<thead>
<tr>
<th>語</th>
<th style="text-align:right">AI以前</th>
<th style="text-align:right">2025年</th>
<th style="text-align:right">2026年</th>
</tr>
</thead>
<tbody>
<tr>
<td>既定</td>
<td style="text-align:right">0.05</td>
<td style="text-align:right">0.15</td>
<td style="text-align:right">0.55</td>
</tr>
<tr>
<td>デフォルト</td>
<td style="text-align:right">1.88</td>
<td style="text-align:right">1.32</td>
<td style="text-align:right">1.01</td>
</tr>
<tr>
<td>要点</td>
<td style="text-align:right">0.03</td>
<td style="text-align:right">0.07</td>
<td style="text-align:right">0.14</td>
</tr>
<tr>
<td>ポイント</td>
<td style="text-align:right">1.31</td>
<td style="text-align:right">1.86</td>
<td style="text-align:right">2.05</td>
</tr>
<tr>
<td>実測</td>
<td style="text-align:right">0.01</td>
<td style="text-align:right">0.03</td>
<td style="text-align:right">0.79</td>
</tr>
<tr>
<td>計測</td>
<td style="text-align:right">0.22</td>
<td style="text-align:right">0.16</td>
<td style="text-align:right">0.48</td>
</tr>
<tr>
<td>ハマる</td>
<td style="text-align:right">0.26</td>
<td style="text-align:right">0.13</td>
<td style="text-align:right">0.27</td>
</tr>
</tbody>
</table>
<p>1万字あたりで見ると、「デフォルト」は1.88回から1.01回とほぼ半分になり、「既定」は0.05回から0.55回に増えています。一部は「既定」に置き換わったのかもしれません。それに対して「要点」と「ポイント」、「実測」と「計測」は、どちらも増えています。こちらは言い換えではなく、語が足されたように見えます。「ハマる」（「ハマった」「ハマり」などを含む）は2025年に半分まで減ったあと、2026年にはAI以前と同じ水準に戻っていました。</p>
<p>さて、語彙のほかにも、数えてみると変わっていたところがいくつかありました。</p>
<h2 id="%E3%81%9D%E3%81%AE%E3%81%BB%E3%81%8B%E3%81%AB%E5%A4%89%E3%82%8F%E3%81%A3%E3%81%9F%E3%81%93%E3%81%A8" tabindex="-1"><a href="https://nyosegawa.com/posts/qiita-writing-before-after-ai/#%E3%81%9D%E3%81%AE%E3%81%BB%E3%81%8B%E3%81%AB%E5%A4%89%E3%82%8F%E3%81%A3%E3%81%9F%E3%81%93%E3%81%A8" class="header-anchor">そのほかに変わったこと</a></h2>
<p><img src="https://nyosegawa.com/img/qiita-writing-before-after-ai/exploratory_effects.png" alt="探索的な指標"></p>
<p>主要な指標のほかに、判断には使わない探索的な指標もいろいろ数えています。目立ったものを挙げます（すべての値はAppendix Dにあります）。</p>
<table>
<thead>
<tr>
<th>指標</th>
<th style="text-align:right">AI以前</th>
<th style="text-align:right">2026年</th>
</tr>
</thead>
<tbody>
<tr>
<td>見出しに「まとめ」がある記事の割合</td>
<td style="text-align:right">12%</td>
<td style="text-align:right">52%</td>
</tr>
<tr>
<td>見出しに絵文字を含む記事の割合</td>
<td style="text-align:right">0.9%</td>
<td style="text-align:right">5.9%</td>
</tr>
<tr>
<td>タイトルの文字数</td>
<td style="text-align:right">33.9</td>
<td style="text-align:right">42.2</td>
</tr>
<tr>
<td>ダッシュ「—」「―」（1,000字あたり）</td>
<td style="text-align:right">0.04</td>
<td style="text-align:right">0.43</td>
</tr>
<tr>
<td>です・ます調の文の割合</td>
<td style="text-align:right">50%</td>
<td style="text-align:right">66%</td>
</tr>
<tr>
<td>です・ます調とだ・である調が混ざった記事の割合</td>
<td style="text-align:right">48%</td>
<td style="text-align:right">31%</td>
</tr>
<tr>
<td>漢字の割合</td>
<td style="text-align:right">19.0%</td>
<td style="text-align:right">24.4%</td>
</tr>
<tr>
<td>カタカナの割合</td>
<td style="text-align:right">15.3%</td>
<td style="text-align:right">14.1%</td>
</tr>
<tr>
<td>感嘆符（1,000字あたり）</td>
<td style="text-align:right">0.93</td>
<td style="text-align:right">0.29</td>
</tr>
<tr>
<td>文末の「しましょう」（1,000字あたり）</td>
<td style="text-align:right">0.114</td>
<td style="text-align:right">0.034</td>
</tr>
<tr>
<td>文頭の「また、」（1,000字あたり）</td>
<td style="text-align:right">0.207</td>
<td style="text-align:right">0.119</td>
</tr>
<tr>
<td>文頭の「さらに、」（1,000字あたり）</td>
<td style="text-align:right">0.012</td>
<td style="text-align:right">0.034</td>
</tr>
</tbody>
</table>
<p>「まとめ」の見出しは、半分以上の記事に入るようになりました。ダッシュはおよそ10倍です。英語ではChatGPTがem dashを使いすぎることが「ChatGPT hyphen」と呼ばれて話題になり（<a href="https://www.rollingstone.com/culture/culture-features/chatgpt-hypen-em-dash-ai-writing-1235314945/">Rolling Stone</a>）、OpenAIが「指示すれば使わないように直した」と発表するほどでした（<a href="https://techcrunch.com/2025/11/14/openai-says-its-fixed-chatgpts-em-dash-problem/">TechCrunch</a>）。医学系のプレプリントでも、em dashを含む要旨の割合がChatGPT以前の4.2%から以後の11.6%に増え、2025年には20.3%になったと報告されています（<a href="https://arxiv.org/abs/2606.29540">Czuma, 2026</a>）。LLMがMarkdownで書く習慣を学んだ結果、Markdownの記号を使わないように指示してもem dashだけは残る、という仮説を立てた研究もあります（<a href="https://arxiv.org/abs/2603.27006">Freeburg, 2026</a>）。日本語の技術記事でも、同じ記号が増えています。</p>
<p>意外だったのは、AIっぽいと言われがちな「しましょう」「することが可能です」「また、」が減っていたことです。「しましょう」を含む記事の割合は10.3%から6.0%に下がりました。「〜しましょう」で読者に呼びかける書き方はもともと少数派でしたが、さらに減っています。英語でも、「delve」はAIらしい語として話題になったあとで減った一方、ほかのAIに多い語は増え続けたという報告があります（<a href="https://arxiv.org/abs/2502.09606">Geng and Trotta, 2025</a>）。AIらしいと知られた言い回しは、書き手が避けたり、モデルの側で使わなくなったりするのかもしれません。冒頭で書いたような、AIっぽさを消すAgent Skillが広まっていることも、これと同じ流れにあるように見えます。</p>
<p>感嘆符も3割ほどに減っていて、です・ます調で統一された、感嘆符の少ない文章が増えています。漢字の割合が上がってカタカナの割合が下がったのは、漢語が増えたこと（語数に対する漢語の割合は18.1%から21.4%）と関係していそうです。論文の要旨でも、ChatGPTの後に接続語が減り、読みにくくなったと報告されています（<a href="https://arxiv.org/abs/2505.12218">Bao et al., 2025</a>）。今回もjReadabilityは2.59から2.02に下がり、難しい側に動きました。ただしjReadabilityは平均文長を式に含むので、文が長くなった分だけ難しい側に出る点には注意が必要です。</p>
<h2 id="%E4%BD%95%E3%81%8C%E3%82%8F%E3%81%8B%E3%81%A3%E3%81%A6%E4%BD%95%E3%81%8C%E3%82%8F%E3%81%8B%E3%82%89%E3%81%AA%E3%81%84%E3%81%8B" tabindex="-1"><a href="https://nyosegawa.com/posts/qiita-writing-before-after-ai/#%E4%BD%95%E3%81%8C%E3%82%8F%E3%81%8B%E3%81%A3%E3%81%A6%E4%BD%95%E3%81%8C%E3%82%8F%E3%81%8B%E3%82%89%E3%81%AA%E3%81%84%E3%81%8B" class="header-anchor">何がわかって何がわからないか</a></h2>
<p>ここまでの結果から言えるのは、2026年のQiitaの記事が、AI以前の傾向から予測されるものとは違っているというところまでです。いくつか注意があります。</p>
<ul>
<li>AIが書いた文章が増えたことと、人間の書き方が変わったことは区別できていません。全体の平均を比べると、両方がまとめて出てきます</li>
<li>AI関連のタグが付いていない記事にもAIの話題は含まれうるので、タグで除いても話題の影響を完全には取り除けていません</li>
<li>AI以前の記事は、2026年9月まで削除されずに残った記事だけです。スパムや質の低い記事が消えていれば、条件はそろっていません</li>
<li>8月の記事だけを見ているので、ほかの月でも同じかはわかりません</li>
<li>画像の入れ方など、Qiitaの編集画面の変化が書き方の指標に影響している可能性があります</li>
<li>予測値は4年分の点に直線を当てはめたもので、AI以前の傾向がそのまま続いたという前提に頼っています</li>
<li>ある語が増えた理由がAIの書いた文章にあるのかは確かめていません。語の分け方と、言い回しの解釈も私の判断によるものです</li>
</ul>
<h2 id="%E3%81%BE%E3%81%A8%E3%82%81" tabindex="-1"><a href="https://nyosegawa.com/posts/qiita-writing-before-after-ai/#%E3%81%BE%E3%81%A8%E3%82%81" class="header-anchor">まとめ</a></h2>
<ul>
<li>2026年のQiitaの記事は、AI以前と比べて地の文がおよそ2倍に長く、太字はおよそ3倍、箇条書きはおよそ1.8倍になりました。文の長さの変化は、AI以前からの傾向の延長でほぼ説明できました</li>
<li>「実測」「既定」「入口」「事故」「別物」「効く」「静かに」のような語が一気に広がり、その多くは2025年から2026年のあいだに増えています</li>
<li>「しましょう」のような、よく言われるAIっぽさの目印は、むしろ減っていました</li>
</ul>
<h2 id="appendix-a%3A-%E5%89%8D%E5%87%A6%E7%90%86%E3%81%AE%E8%A6%8F%E5%89%87" tabindex="-1"><a href="https://nyosegawa.com/posts/qiita-writing-before-after-ai/#appendix-a%3A-%E5%89%8D%E5%87%A6%E7%90%86%E3%81%AE%E8%A6%8F%E5%89%87" class="header-anchor">Appendix A: 前処理の規則</a></h2>
<p>Markdownの本文を上から1行ずつ読み、次のように分けました。</p>
<table>
<thead>
<tr>
<th>種類</th>
<th>判定</th>
<th>扱い</th>
</tr>
</thead>
<tbody>
<tr>
<td>コードブロック</td>
<td><code>```</code> または <code>~~~</code> で囲まれた範囲</td>
<td>除く</td>
</tr>
<tr>
<td>字下げされたコード</td>
<td>箇条書きの外で、空行のあとの4つ以上の空白かタブで始まる行</td>
<td>除く</td>
</tr>
<tr>
<td>数式ブロック</td>
<td><code>$$</code> で囲まれた範囲</td>
<td>除く</td>
</tr>
<tr>
<td>HTMLのコメント</td>
<td><code>&lt;!--</code> から <code>--&gt;</code> まで</td>
<td>除く</td>
</tr>
<tr>
<td>表</td>
<td><code>|</code> で始まる行</td>
<td>除く</td>
</tr>
<tr>
<td>引用</td>
<td><code>&gt;</code> で始まる行</td>
<td>除く</td>
</tr>
<tr>
<td>見出し</td>
<td><code>#</code> で始まる行</td>
<td>地の文に含めず、見出しの数として数える</td>
</tr>
<tr>
<td>水平線</td>
<td><code>---</code>、<code>***</code>、<code>___</code> だけの行</td>
<td>除く</td>
</tr>
<tr>
<td>注記ブロック</td>
<td><code>:::note</code> と <code>:::</code> の行</td>
<td>記号の行だけを除き、中の文章は地の文として扱う</td>
</tr>
<tr>
<td>脚注の定義</td>
<td><code>[^...]:</code> で始まる行</td>
<td>除く</td>
</tr>
<tr>
<td>箇条書き</td>
<td><code>-</code>、<code>*</code>、<code>+</code>、<code>1.</code> で始まる行と、その後に続く字下げされた行</td>
<td>箇条書きとして別に数える</td>
</tr>
<tr>
<td>地の文</td>
<td>それ以外の空でない行</td>
<td>文の指標に使う</td>
</tr>
</tbody>
</table>
<p>行の中では、インラインコード、インラインの数式、HTMLのコメントとタグ、画像、URLを除き、リンクは文字だけを残しました。太字は数えてから記号を外しています。文字数は空白を除いて数えました。</p>
<p>文は「。」「！」「？」「!」「?」の直後で区切り、閉じかっこが続くときは閉じかっこまでを前の文に含めます。行の終わりも文の終わりとみなしますが、行が読点で終わるときは次の行とつなげます。行の終わりを文の終わりとみなさず、段落の中の行をすべてつなげて句読点だけで区切った場合も計算しましたが、平均文長の差は−0.05、変動係数の差は−0.08で、結論は変わりませんでした。</p>
<h2 id="appendix-b%3A-%E4%BA%88%E6%B8%AC%E5%80%A4%E3%81%A8%E5%B7%AE%E3%81%AE%E8%A8%88%E7%AE%97" tabindex="-1"><a href="https://nyosegawa.com/posts/qiita-writing-before-after-ai/#appendix-b%3A-%E4%BA%88%E6%B8%AC%E5%80%A4%E3%81%A8%E5%B7%AE%E3%81%AE%E8%A8%88%E7%AE%97" class="header-anchor">Appendix B: 予測値と差の計算</a></h2>
<p>指標の年ごとの平均を <code>m_y</code> とします。2019〜2022年の4点に最小二乗法で直線を当てはめ、傾きを <code>b</code> として、2026年の予測値を次のように求めます。</p>
<pre><code class="language-math">\hat{m}_{2026} = \bar{m}_{\mathrm{pre}} + b \times (2026 - 2020.5)
</code></pre>
<p>ここで <code>m_pre</code> の平均は2019〜2022年の年ごとの平均の平均で、2020.5はこの4年の中央です。差は、AI以前の記事単位の標準偏差 <code>s_pre</code> で割って標準化します。</p>
<pre><code class="language-math">d = \frac{m_{2026} - \hat{m}_{2026}}{s_{\mathrm{pre}}}
</code></pre>
<p>信頼区間は、著者を単位にしたブートストラップで出しました。全期間の著者を復元抽出し、選ばれた著者の記事をすべて使って年ごとの平均を計算し直し、直線の当てはめから <code>d</code> までをやり直すことを1,000回繰り返して、2.5パーセンタイルと97.5パーセンタイルをとっています。地の文の文字数は対数をとってから同じ計算をしました。</p>
<h2 id="appendix-c%3A-%E6%9D%A1%E4%BB%B6%E3%82%92%E5%A4%89%E3%81%88%E3%81%9F%E8%A8%88%E7%AE%97" tabindex="-1"><a href="https://nyosegawa.com/posts/qiita-writing-before-after-ai/#appendix-c%3A-%E6%9D%A1%E4%BB%B6%E3%82%92%E5%A4%89%E3%81%88%E3%81%9F%E8%A8%88%E7%AE%97" class="header-anchor">Appendix C: 条件を変えた計算</a></h2>
<p><img src="https://nyosegawa.com/img/qiita-writing-before-after-ai/primary_effects.png" alt="予測値との差と、条件を変えた計算"></p>
<p>除外や集計の条件を変えて、予測値との差を計算し直しました。</p>
<table>
<thead>
<tr>
<th>指標</th>
<th style="text-align:right">主な分析</th>
<th style="text-align:right">企業アカウントを除く</th>
<th style="text-align:right">投稿キャンペーンを除く</th>
<th style="text-align:right">AI関連タグを除く</th>
<th style="text-align:right">著者ごとに平均</th>
<th style="text-align:right">下限100字</th>
<th style="text-align:right">下限1,000字</th>
<th style="text-align:right">極端な値を置き換える</th>
<th style="text-align:right">句読点だけで区切る</th>
</tr>
</thead>
<tbody>
<tr>
<td>平均文長</td>
<td style="text-align:right">+0.01</td>
<td style="text-align:right">+0.03</td>
<td style="text-align:right">+0.01</td>
<td style="text-align:right">+0.00</td>
<td style="text-align:right">−0.11</td>
<td style="text-align:right">+0.06</td>
<td style="text-align:right">−0.09</td>
<td style="text-align:right">+0.15</td>
<td style="text-align:right">−0.05</td>
</tr>
<tr>
<td>文長の変動係数</td>
<td style="text-align:right">−0.07</td>
<td style="text-align:right">−0.09</td>
<td style="text-align:right">−0.07</td>
<td style="text-align:right">−0.06</td>
<td style="text-align:right">−0.07</td>
<td style="text-align:right">−0.03</td>
<td style="text-align:right">−0.11</td>
<td style="text-align:right">−0.06</td>
<td style="text-align:right">−0.08</td>
</tr>
<tr>
<td>読点間の平均文字数</td>
<td style="text-align:right">−0.17</td>
<td style="text-align:right">−0.17</td>
<td style="text-align:right">−0.17</td>
<td style="text-align:right">−0.16</td>
<td style="text-align:right">−0.18</td>
<td style="text-align:right">−0.19</td>
<td style="text-align:right">−0.18</td>
<td style="text-align:right">−0.41</td>
<td style="text-align:right">−</td>
</tr>
<tr>
<td>地の文の文字数</td>
<td style="text-align:right">+0.89</td>
<td style="text-align:right">+0.95</td>
<td style="text-align:right">+0.88</td>
<td style="text-align:right">+0.77</td>
<td style="text-align:right">+0.75</td>
<td style="text-align:right">+1.03</td>
<td style="text-align:right">+0.54</td>
<td style="text-align:right">+0.88</td>
<td style="text-align:right">−</td>
</tr>
<tr>
<td>箇条書きの割合</td>
<td style="text-align:right">+0.57</td>
<td style="text-align:right">+0.59</td>
<td style="text-align:right">+0.57</td>
<td style="text-align:right">+0.56</td>
<td style="text-align:right">+0.47</td>
<td style="text-align:right">+0.43</td>
<td style="text-align:right">+0.74</td>
<td style="text-align:right">+0.58</td>
<td style="text-align:right">−</td>
</tr>
<tr>
<td>太字の頻度</td>
<td style="text-align:right">+0.84</td>
<td style="text-align:right">+0.87</td>
<td style="text-align:right">+0.85</td>
<td style="text-align:right">+0.80</td>
<td style="text-align:right">+0.89</td>
<td style="text-align:right">+0.86</td>
<td style="text-align:right">+0.83</td>
<td style="text-align:right">+0.91</td>
<td style="text-align:right">−</td>
</tr>
</tbody>
</table>
<ul>
<li>下限100字と下限1,000字は、地の文の文字数の下限を300字から変えた計算です</li>
<li>極端な値を置き換える計算は、AI以前の記事の1パーセンタイルより小さい値と99パーセンタイルより大きい値を、その境目の値に置き換えています。2022年には地の文の1文の平均が約2,900字の記事が1件あり、平均文長と読点間の平均文字数を押し上げていたので、この計算も載せています</li>
<li>句読点だけで区切る計算は、行の終わりを文の終わりとみなさない規則で、平均文長と変動係数だけに関係します</li>
</ul>
<p>どの条件でも、地の文の長さ、箇条書き、太字の差は+0.4以上で、平均文長とばらつきの差は±0.2の範囲に収まっています。</p>
<h2 id="appendix-d%3A-%E8%AA%9E%E5%BD%99%E3%81%AE%E5%88%86%E6%9E%90%E3%81%AE%E6%96%B9%E6%B3%95%E3%81%A8%E6%8E%A2%E7%B4%A2%E7%9A%84%E3%81%AA%E6%8C%87%E6%A8%99" tabindex="-1"><a href="https://nyosegawa.com/posts/qiita-writing-before-after-ai/#appendix-d%3A-%E8%AA%9E%E5%BD%99%E3%81%AE%E5%88%86%E6%9E%90%E3%81%AE%E6%96%B9%E6%B3%95%E3%81%A8%E6%8E%A2%E7%B4%A2%E7%9A%84%E3%81%AA%E6%8C%87%E6%A8%99" class="header-anchor">Appendix D: 語彙の分析の方法と探索的な指標</a></h2>
<p>対数オッズ比は、Monroeらの informative Dirichlet prior を使いました。語 <code>w</code> の出現回数を2026年で <code>y_i</code>、AI以前で <code>y_j</code>、全期間で <code>a_w</code> とし、それぞれの総語数を <code>n_i</code>、<code>n_j</code>、<code>a_0</code> とします。</p>
<pre><code class="language-math">\begin{aligned}
\delta_w &amp;= \log\frac{y_i + a_w}{n_i + a_0 - y_i - a_w} \\
&amp;\quad - \log\frac{y_j + a_w}{n_j + a_0 - y_j - a_w}
\end{aligned}
</code></pre>
<pre><code class="language-math">z_w = \frac{\delta_w}{\sqrt{\frac{1}{y_i + a_w} + \frac{1}{y_j + a_w}}}
</code></pre>
<p>excess vocabulary は、語ごとにAI以前の4年の文書頻度に直線を当てはめ、傾きを <code>b_w</code> として、2026年の予測値 <code>q_w</code> との差と比を見ます。傾きが負のときに0とするのは、もともと減っていた語の増加を大きく見積もらないためです。Kobakらは2021年と2022年の2点から2年先の2024年を予測していますが、今回は4年分に直線を当てはめて4年先を予測しています。</p>
<pre><code class="language-math">\begin{aligned}
q_w &amp;= p_{w,2022} + 4 \times \max(b_w, 0) \\
\Delta_w &amp;= p_{w,2026} - q_w \\
r_w &amp;= \frac{p_{w,2026}}{q_w}
\end{aligned}
</code></pre>
<p>探索的な指標の全結果です。差は予測値との差で、単位はAI以前の標準偏差です。</p>
<table>
<thead>
<tr>
<th>指標</th>
<th style="text-align:right">AI以前</th>
<th style="text-align:right">2025年</th>
<th style="text-align:right">2026年</th>
<th style="text-align:right">予測値との差</th>
<th style="text-align:right">AI以前の平均との差</th>
</tr>
</thead>
<tbody>
<tr>
<td>MATTR（語彙の多様性）</td>
<td style="text-align:right">0.575</td>
<td style="text-align:right">0.607</td>
<td style="text-align:right">0.624</td>
<td style="text-align:right">+1.12</td>
<td style="text-align:right">+1.00</td>
</tr>
<tr>
<td>見出しに「まとめ」がある記事の割合</td>
<td style="text-align:right">12.1%</td>
<td style="text-align:right">38.0%</td>
<td style="text-align:right">52.0%</td>
<td style="text-align:right">+1.05</td>
<td style="text-align:right">+1.24</td>
</tr>
<tr>
<td>ダッシュ「—」「―」</td>
<td style="text-align:right">0.043</td>
<td style="text-align:right">0.062</td>
<td style="text-align:right">0.426</td>
<td style="text-align:right">+0.95</td>
<td style="text-align:right">+0.81</td>
</tr>
<tr>
<td>漢字の割合</td>
<td style="text-align:right">19.0%</td>
<td style="text-align:right">22.6%</td>
<td style="text-align:right">24.4%</td>
<td style="text-align:right">+0.58</td>
<td style="text-align:right">+0.97</td>
</tr>
<tr>
<td>1文あたりの読点の数</td>
<td style="text-align:right">0.627</td>
<td style="text-align:right">0.785</td>
<td style="text-align:right">0.949</td>
<td style="text-align:right">+0.56</td>
<td style="text-align:right">+0.78</td>
</tr>
<tr>
<td>見出しに絵文字を含む記事の割合</td>
<td style="text-align:right">0.9%</td>
<td style="text-align:right">11.4%</td>
<td style="text-align:right">5.9%</td>
<td style="text-align:right">+0.52</td>
<td style="text-align:right">+0.53</td>
</tr>
<tr>
<td>タイトルの文字数</td>
<td style="text-align:right">33.9</td>
<td style="text-align:right">37.6</td>
<td style="text-align:right">42.2</td>
<td style="text-align:right">+0.46</td>
<td style="text-align:right">+0.51</td>
</tr>
<tr>
<td>漢語の割合</td>
<td style="text-align:right">18.1%</td>
<td style="text-align:right">21.7%</td>
<td style="text-align:right">21.4%</td>
<td style="text-align:right">+0.31</td>
<td style="text-align:right">+0.67</td>
</tr>
<tr>
<td>です・ます調の文の割合</td>
<td style="text-align:right">50.2%</td>
<td style="text-align:right">56.8%</td>
<td style="text-align:right">66.1%</td>
<td style="text-align:right">+0.23</td>
<td style="text-align:right">+0.47</td>
</tr>
<tr>
<td>文頭「さらに、」</td>
<td style="text-align:right">0.012</td>
<td style="text-align:right">0.027</td>
<td style="text-align:right">0.034</td>
<td style="text-align:right">+0.20</td>
<td style="text-align:right">+0.21</td>
</tr>
<tr>
<td>表の数</td>
<td style="text-align:right">0.212</td>
<td style="text-align:right">0.344</td>
<td style="text-align:right">0.498</td>
<td style="text-align:right">+0.20</td>
<td style="text-align:right">+0.26</td>
</tr>
<tr>
<td>タイトルに「徹底解説」を含む記事の割合</td>
<td style="text-align:right">0.0%</td>
<td style="text-align:right">0.3%</td>
<td style="text-align:right">0.3%</td>
<td style="text-align:right">+0.18</td>
<td style="text-align:right">+0.19</td>
</tr>
<tr>
<td>一人称</td>
<td style="text-align:right">0.649</td>
<td style="text-align:right">0.678</td>
<td style="text-align:right">0.882</td>
<td style="text-align:right">+0.14</td>
<td style="text-align:right">+0.19</td>
</tr>
<tr>
<td>和語の割合</td>
<td style="text-align:right">62.2%</td>
<td style="text-align:right">59.5%</td>
<td style="text-align:right">63.0%</td>
<td style="text-align:right">+0.13</td>
<td style="text-align:right">+0.09</td>
</tr>
<tr>
<td>感情を表す語</td>
<td style="text-align:right">0.051</td>
<td style="text-align:right">0.026</td>
<td style="text-align:right">0.039</td>
<td style="text-align:right">+0.12</td>
<td style="text-align:right">−0.04</td>
</tr>
<tr>
<td>全角コロン「：」</td>
<td style="text-align:right">0.596</td>
<td style="text-align:right">1.910</td>
<td style="text-align:right">0.834</td>
<td style="text-align:right">+0.10</td>
<td style="text-align:right">+0.13</td>
</tr>
<tr>
<td>矢印「→」</td>
<td style="text-align:right">0.333</td>
<td style="text-align:right">0.741</td>
<td style="text-align:right">0.498</td>
<td style="text-align:right">+0.09</td>
<td style="text-align:right">+0.12</td>
</tr>
<tr>
<td>見出しに「おわりに」がある記事の割合</td>
<td style="text-align:right">4.9%</td>
<td style="text-align:right">8.2%</td>
<td style="text-align:right">10.9%</td>
<td style="text-align:right">+0.02</td>
<td style="text-align:right">+0.29</td>
</tr>
<tr>
<td>ひらがなの割合</td>
<td style="text-align:right">37.3%</td>
<td style="text-align:right">34.8%</td>
<td style="text-align:right">38.6%</td>
<td style="text-align:right">+0.02</td>
<td style="text-align:right">+0.14</td>
</tr>
<tr>
<td>文末「について解説します」</td>
<td style="text-align:right">0.003</td>
<td style="text-align:right">0.017</td>
<td style="text-align:right">0.007</td>
<td style="text-align:right">+0.00</td>
<td style="text-align:right">+0.05</td>
</tr>
<tr>
<td>絵文字の数</td>
<td style="text-align:right">0.300</td>
<td style="text-align:right">0.672</td>
<td style="text-align:right">0.389</td>
<td style="text-align:right">−0.00</td>
<td style="text-align:right">+0.04</td>
</tr>
<tr>
<td>タイトルに「してみた」を含む記事の割合</td>
<td style="text-align:right">3.1%</td>
<td style="text-align:right">3.7%</td>
<td style="text-align:right">3.6%</td>
<td style="text-align:right">−0.03</td>
<td style="text-align:right">+0.03</td>
</tr>
<tr>
<td>話し言葉の文末</td>
<td style="text-align:right">0.039</td>
<td style="text-align:right">0.027</td>
<td style="text-align:right">0.012</td>
<td style="text-align:right">−0.04</td>
<td style="text-align:right">−0.11</td>
</tr>
<tr>
<td>英字の割合</td>
<td style="text-align:right">19.8%</td>
<td style="text-align:right">17.2%</td>
<td style="text-align:right">14.2%</td>
<td style="text-align:right">−0.07</td>
<td style="text-align:right">−0.47</td>
</tr>
<tr>
<td>文体が混ざった記事の割合</td>
<td style="text-align:right">48.0%</td>
<td style="text-align:right">47.3%</td>
<td style="text-align:right">30.8%</td>
<td style="text-align:right">−0.08</td>
<td style="text-align:right">−0.34</td>
</tr>
<tr>
<td>見出しに「はじめに」がある記事の割合</td>
<td style="text-align:right">19.6%</td>
<td style="text-align:right">38.5%</td>
<td style="text-align:right">35.5%</td>
<td style="text-align:right">−0.09</td>
<td style="text-align:right">+0.40</td>
</tr>
<tr>
<td>「(笑)」</td>
<td style="text-align:right">0.009</td>
<td style="text-align:right">0.009</td>
<td style="text-align:right">0.004</td>
<td style="text-align:right">−0.11</td>
<td style="text-align:right">−0.04</td>
</tr>
<tr>
<td>文末「できます」</td>
<td style="text-align:right">0.576</td>
<td style="text-align:right">0.681</td>
<td style="text-align:right">0.570</td>
<td style="text-align:right">−0.11</td>
<td style="text-align:right">−0.01</td>
</tr>
<tr>
<td>文末「しましょう」</td>
<td style="text-align:right">0.114</td>
<td style="text-align:right">0.095</td>
<td style="text-align:right">0.034</td>
<td style="text-align:right">−0.12</td>
<td style="text-align:right">−0.16</td>
</tr>
<tr>
<td>文末「することが可能です」</td>
<td style="text-align:right">0.012</td>
<td style="text-align:right">0.015</td>
<td style="text-align:right">0.004</td>
<td style="text-align:right">−0.15</td>
<td style="text-align:right">−0.07</td>
</tr>
<tr>
<td>jReadability（小さいほど難しい）</td>
<td style="text-align:right">2.59</td>
<td style="text-align:right">2.39</td>
<td style="text-align:right">2.02</td>
<td style="text-align:right">−0.18</td>
<td style="text-align:right">−0.46</td>
</tr>
<tr>
<td>感嘆符</td>
<td style="text-align:right">0.933</td>
<td style="text-align:right">0.987</td>
<td style="text-align:right">0.295</td>
<td style="text-align:right">−0.18</td>
<td style="text-align:right">−0.25</td>
</tr>
<tr>
<td>文頭「なお、」</td>
<td style="text-align:right">0.064</td>
<td style="text-align:right">0.042</td>
<td style="text-align:right">0.046</td>
<td style="text-align:right">−0.19</td>
<td style="text-align:right">−0.06</td>
</tr>
<tr>
<td>タイトルに【】を含む記事の割合</td>
<td style="text-align:right">14.2%</td>
<td style="text-align:right">20.8%</td>
<td style="text-align:right">16.2%</td>
<td style="text-align:right">−0.19</td>
<td style="text-align:right">+0.06</td>
</tr>
<tr>
<td>コードの文字数の割合</td>
<td style="text-align:right">28.9%</td>
<td style="text-align:right">24.3%</td>
<td style="text-align:right">18.9%</td>
<td style="text-align:right">−0.19</td>
<td style="text-align:right">−0.40</td>
</tr>
<tr>
<td>文頭「また、」</td>
<td style="text-align:right">0.207</td>
<td style="text-align:right">0.156</td>
<td style="text-align:right">0.119</td>
<td style="text-align:right">−0.39</td>
<td style="text-align:right">−0.17</td>
</tr>
<tr>
<td>カタカナの割合</td>
<td style="text-align:right">15.3%</td>
<td style="text-align:right">16.7%</td>
<td style="text-align:right">14.1%</td>
<td style="text-align:right">−0.50</td>
<td style="text-align:right">−0.18</td>
</tr>
<tr>
<td>外来語の割合</td>
<td style="text-align:right">7.9%</td>
<td style="text-align:right">8.1%</td>
<td style="text-align:right">6.8%</td>
<td style="text-align:right">−0.50</td>
<td style="text-align:right">−0.29</td>
</tr>
<tr>
<td>見出しの数</td>
<td style="text-align:right">5.726</td>
<td style="text-align:right">8.010</td>
<td style="text-align:right">5.636</td>
<td style="text-align:right">−0.55</td>
<td style="text-align:right">−0.01</td>
</tr>
<tr>
<td>タイトルに「完全ガイド」を含む記事の割合</td>
<td style="text-align:right">0.0%</td>
<td style="text-align:right">0.5%</td>
<td style="text-align:right">0.6%</td>
<td style="text-align:right">−</td>
<td style="text-align:right">−</td>
</tr>
</tbody>
</table>
<p>文頭と文末の言い回し、記号、一人称、感情を表す語、話し言葉の文末、「(笑)」、見出しの数、表の数、絵文字の数は、地の文と箇条書きの1,000字あたりの回数です。見出しの数は予測値との差が−0.55と大きく出ていますが、AI以前の平均との差はほぼ0です。2022年の値が高かったせいで予測値が高くなっただけで、ほとんど変わっていません。MATTRは<a href="https://doi.org/10.1080/09296171003643098">Covington and McFall (2010)</a> の方法で窓の幅を100語にしています。jReadabilityは<a href="https://jreadability.net/">jReadability</a>の式で計算しました。</p>
<h2 id="references" tabindex="-1"><a href="https://nyosegawa.com/posts/qiita-writing-before-after-ai/#references" class="header-anchor">References</a></h2>
<ul>
<li>データ
<ul>
<li><a href="https://qiita.com/api/v2/docs">Qiita API v2 ドキュメント</a></li>
<li><a href="https://openai.com/index/chatgpt/">Introducing ChatGPT (OpenAI)</a></li>
</ul>
</li>
<li>LLMが文章に与えた影響
<ul>
<li><a href="https://doi.org/10.1126/sciadv.adt3813">Delving into LLM-assisted writing in biomedical publications through excess vocabulary (Kobak et al., Science Advances, 2025)</a></li>
<li><a href="https://arxiv.org/abs/2403.07183">Monitoring AI-Modified Content at Scale: A Case Study on the Impact of ChatGPT on AI Conference Peer Reviews (Liang et al., ICML 2024)</a></li>
<li><a href="https://doi.org/10.1016/j.patter.2025.101366">The widespread adoption of large language model-assisted writing across society (Liang et al., Patterns, 2025)</a></li>
<li><a href="https://arxiv.org/abs/2602.03864">Have Large Language Models Enhanced the Way Civil &amp; Environmental Engineers Write? (Sanger and Maurer, 2026)</a></li>
<li><a href="https://arxiv.org/abs/2505.12218">Examining linguistic shifts in academic writing before and after the launch of ChatGPT (Bao et al., 2025)</a></li>
<li><a href="https://arxiv.org/abs/2409.01754">Empirical evidence of Large Language Model's influence on human spoken communication (Yakura et al., 2024)</a></li>
<li><a href="https://arxiv.org/abs/2502.09606">Human-LLM Coevolution: Evidence from Academic Writing (Geng and Trotta, 2025)</a></li>
</ul>
</li>
<li>技術文書と日本語の記事
<ul>
<li><a href="https://github.com/louisabraham/load-bearing">The load-bearing vocabulary of Claude (Abraham, 2026)</a></li>
<li><a href="https://qiita.com/suwa_nobu/items/4231026d13f7527c0f69">AIの口癖を日本語記事で測ろうとして、3回とも失敗した (suwa_nobu, 2026)</a></li>
<li><a href="https://tech-blog.lapras.com/techBlogs/compare-gpt">GPT の出力文章と人の書いた文章の比較 (LAPRAS, 2023)</a></li>
</ul>
</li>
<li>AIっぽさを消すためのAgent Skill
<ul>
<li><a href="https://github.com/blader/humanizer">blader/humanizer</a></li>
<li><a href="https://github.com/coji/natural-japanese">coji/natural-japanese</a></li>
</ul>
</li>
<li>書式と記号
<ul>
<li><a href="https://aclanthology.org/2025.acl-long.1308/">From Lists to Emojis: How Format Bias Affects Model Alignment (Zhang et al., ACL 2025)</a></li>
<li><a href="https://www.lmsys.org/blog/2024-08-28-style-control/">Does style matter? Disentangling style and substance in Chatbot Arena (Li et al., LMSYS, 2024)</a></li>
<li><a href="https://arxiv.org/abs/2606.29540">Em-ergence of the em-dash: a population-level rise in em-dash frequency in medRxiv preprints (Czuma, 2026)</a></li>
<li><a href="https://arxiv.org/abs/2603.27006">The Last Fingerprint: How Markdown Training Shapes LLM Prose (Freeburg, 2026)</a></li>
<li><a href="https://www.rollingstone.com/culture/culture-features/chatgpt-hypen-em-dash-ai-writing-1235314945/">'ChatGPT Hyphen': Are Em Dashes a Giveaway of AI Writing? (Rolling Stone)</a></li>
<li><a href="https://techcrunch.com/2025/11/14/openai-says-its-fixed-chatgpts-em-dash-problem/">OpenAI says it's fixed ChatGPT's em dash problem (TechCrunch)</a></li>
</ul>
</li>
<li>方法
<ul>
<li><a href="https://doi.org/10.1093/pan/mpn018">Fightin' Words: Lexical Feature Selection and Evaluation for Identifying the Content of Political Conflict (Monroe et al., 2008)</a></li>
<li><a href="https://doi.org/10.1080/09296171003643098">Cutting the Gordian Knot: The Moving-Average Type-Token Ratio (MATTR) (Covington and McFall, 2010)</a></li>
<li><a href="https://jreadability.net/">jReadability</a></li>
<li><a href="https://github.com/polm/fugashi">fugashi</a></li>
<li><a href="https://github.com/polm/unidic-lite">unidic-lite</a></li>
</ul>
</li>
</ul>
]]>
      </content:encoded>
      <pubDate>Fri, 11 Sep 2026 00:00:00 GMT</pubDate>
    </item>
  </channel>
</rss>