無音や弱い雑音に「ご視聴ありがとうございました」と書く Whisper を、CGo なしの Go で飼いならす
はじめに:Go のサービスに、日本語の文字起こしを「ローカルで」足したい
Go で書いたサービスや CLI に、日本語音声の文字起こしを組み込みたい。でも C コンパイラ入りのビルド環境は持ち込みたくないし、音声をクラウドに送るのもできれば避けたい。そんなときの選択肢として気になっていたのが、Ardan Labs の bucky です。whisper.cpp の C API をほぼそのまま Go から呼べて、しかも CGo を使わず、WAV・MP3・FLAC のデコードまで pure Go で済ませると README にあります 1。
この記事では bucky v1.1.3 を実際に入れて、「CGo なしで本当に動くのか」「日本語の精度はどのくらいか」を、whisper の tiny・base・small の 3 モデルで測りました。先に答えを書くと、small を言語 ja 固定で動かせば、合成音声の 12 文で文字誤り率(CER)2.89%、96 秒の 1 ファイルでは 1.31% まで下がりました。今回の合成音声では、tiny と base は「下書き」程度の結果でした。そして、日本語で使うときに踏みやすい設定の罠が 3 つ見つかりました(既定言語が英語、タイムスタンプを切ると長い音声の途中が抜ける、無音で「ご視聴ありがとうございました」が出る)。
対象読者: Go で音声認識をローカルに組み込みたい方、CGo の whisper バインディングやクラウド API と比べて検討している方
扱う範囲: CGo なしで読み込める仕組み(purego・libffi・bucky install -lib が取ってくるもの)、日本語 CER と CPU 実行時間の実測、VAD・タイムスタンプ・ストリーミング例の挙動、選び方、失敗しやすい条件
扱わない範囲: medium 以上のモデル、GPU(CUDA・Vulkan・Metal)、量子化モデル、話者分離、クラウド API の実測
読み終えると: 自分の CPU で同じ計測を再現でき、bucky を採用するか、どのモデルと設定で始めるかを判断できます
結論:small + 言語固定 + タイムスタンプ有効から始める
精度を求めるなら small を選び、日本語とわかっている音声では言語を ja に固定します。whisper.cpp の C API の既定言語は en で、言語を指定しない例では、今回の日本語音声から英文が出力されました 2。bucky の CLI は既定で自動判定するため、この点は API と異なります 3。
30 秒を超える音声ではタイムスタンプを有効のままにします。 NoTimestamps = 1(bucky の CLI では --segments なし)だと、今回の 96 秒の音声で複数の文が抜けました(CER 45.67%)。CER は誤りの割合であり、欠落した文の割合ではありません。
無音や雑音だけの区間がある音声では VAD(Silero)の利用を検討します。今回の無音・弱い雑音のテストでは不要な出力が消え、長尺音声の先頭タイムスタンプも VAD が検出した発話開始位置に揃いました。ただし、句読点が消えるなどの変化もあり、あらゆる音声での改善を保証するものではありません。
「CGo なし」は「依存なし」ではありません。今回の Linux 環境では glibc、libffi.so.8、それに bucky install -lib で取る whisper.cpp の共有ライブラリ(約 18 MB)とモデルファイルが実行時に必要です 4。
C ツールチェーンを持ち込みたくない、音声を外に出したくない、GPU なしの CPU で十分、という条件なら bucky は有力です。反対に、雑音の多い実録音で高精度が必要なら、大きいモデルや GPU、クラウド API との比較が必要です。
前提・検証環境
項目
内容
検証日
2026-10-07(JST)
対象
bucky v1.1.3(Apache-2.0、go 1.26)、whisper.cpp v1.9.4(bucky の既定 pin)
実行環境
Linux x86_64、Intel Xeon Processor 8 vCPU(他の処理と共有、load average 約 5〜7)、Go 1.26.8
ライブラリ
bucky-builder の whisper-v1.9.4-bin-ubuntu-cpu-x64.tar.gz(CPU 版)
モデル
ggml-tiny.bin(75 MiB)、ggml-base.bin(142 MiB)、ggml-small.bin(466 MiB)、ggml-silero-v5.1.2.bin(VAD)
推論設定
4 スレッド、greedy(ビーム幅 5 は別途比較)、言語 ja 固定
音声
Open JTalk 1.11 で合成した日本語 12 文(自作の文、声は CC BY 3.0 の HTS voice)
成功条件
CGO_ENABLED=0 でビルドしたバイナリが文字起こしでき、CER と時間を出力できること
bucky は whisper.h を 1 対 1 で写した高水準 API と、pure Go の音声デコーダを提供するライブラリです 1:1。README によれば llama.cpp を扱う yzma の兄弟プロジェクトで、Ardan Labs の Kronk に OpenAI 互換の文字起こしエンドポイントを持たせることを目標にしています 1:2。v1.1.3 の README に記載された共有ライブラリの既定 pin は whisper.cpp v1.9.4 です 1:3。Go の要求バージョンと依存バージョンは同タグの go.mod でも確認できます 5。
計測した CPU は他の処理と共有していたため、時間は揺れます。そこで各条件を 3 回ずつ実行して中央値を採り、各出力ファイルの先頭に load average を記録しました。認識結果のテキストは 3 回とも同じでした。時間の絶対値より、モデル間・条件間の比を見てください。
仕組み:CGo なしで whisper.cpp を呼べる理由と、それでも残る依存
結論から書くと、bucky は ビルド時には C コンパイラを使わず、実行時に共有ライブラリを dlopen して関数ポインタを呼ぶ 方式です。だから CGO_ENABLED=0 でビルドできますが、そのぶん実行時に必要なファイルが増えます。
purego で開き、libffi で呼ぶ
README には「CGo は使わず、すべての C 呼び出しは purego と JupiterRider/ffi を通る」とあります 1:4。purego は Cgo なしで Go から C 関数を呼ぶためのライブラリで、C コンパイラなしでクロスコンパイルできることを利点に挙げています 6。JupiterRider/ffi は purego の上に libffi のバインディングを載せたもので、libffi が受け持つのは、関数の型情報から各プラットフォームの呼び出し規約に合わせた呼び出しを組み立てる部分です 4:1。
v1.1.3 のソース(pkg/loader/loader.go)を追うと、ライブラリのパスを決めて開く処理は次の流れです 7。続いて、whisper API の各シンボルを解決します。
whisper.Load(path) が loader.LoadLibrary(path, "whisper") を呼びます。path が空なら環境変数 BUCKY_LIB を使い、OS に応じて libwhisper.so・whisper.dll・libwhisper.dylib のファイル名を組み立てます。
そのファイルを ffi.Load で開きます。中身は purego の Dlopen です。
whisper_full などの関数を 1 つずつ lib.Prep("シンボル名", 戻り値の型, 引数の型...) で解決し、libffi の呼び出し記述子として保持します。
3 の Prep を使っているのがポイントです。whisper_full は設定構造体 whisper_full_params を 値渡し で受け取ります。bucky はこの構造体を Go 側で WhisperFullParams としてパディングまで含めて写し、libffi 用の型記述も持っています。構造体の値渡しは ABI ごとにレジスタとスタックの使い方が違うので、その面倒を libffi に任せる設計です。
libffi 自体については、ffi の README に「Linux などでは OS の libffi を使い、macOS と Windows では埋め込んだバイナリを実行時に展開する」と書かれています 4:2。実際に Linux で libffi.so.8 を読めない状態(unshare -m で別のマウント名前空間を作り、そこでだけ /dev/null を被せた状態)にして起動すると、main に入る前にパッケージ初期化で落ちました。
panic: /lib/x86_64-linux-gnu/libffi.so.8: file too short
goroutine 1 [running]:
github.com/jupiterrider/ffi.init.0()
/home/box/go/pkg/mod/github.com/jupiterrider/ffi@v0.7.0/init.go:26 +0x21d
exit=2
Debian・Ubuntu なら libffi8 パッケージです 4:3。Python が入っている環境ならたいてい既にありますが、distroless のような最小コンテナでは自分で入れる必要があります。
bucky install -lib が取ってくるもの
bucky install -lib ./lib を実行すると、bucky-builder の GitHub Releases から OS・CPU に合ったアーカイブを取ってきて展開します 8。今回の環境では次のようになりました。
installing whisper.cpp version v1.9.4@sha256:56e7b4ae8508ca674535f36e92378c2257975a03b523ebc15e93dd89d816a77e to ./lib
[##################################################] 100.0% - 6/6 MiB (107.48 MiB/s)
done.
バージョン文字列の @sha256: 以降は、bucky-builder が公開するマニフェストのハッシュです。bucky はこの値をソースに埋め込んで固定しており、マニフェストに書かれたアーカイブのハッシュと照合してから展開します。展開後の lib/bucky-install.json には、取得元 URL、アーカイブの SHA-256、展開した各ファイルの SHA-256 が記録されていました。
中身は約 18 MB で、libwhisper.so.1.9.4、libggml.so、libggml-base.so に加えて、libggml-cpu-haswell.so・libggml-cpu-skylakex.so・libggml-cpu-sapphirerapids.so など 14 個の CPU 変種 が入っています。bucky-builder の README によれば、Linux x64 の CPU 版は GGML_CPU_ALL_VARIANTS=ON でビルドしてあり、実行時に CPU に合った変種を選ぶ方式です 8:1。libwhisper.so の RUNPATH は $ORIGIN になっていて、同じディレクトリの ggml を見つけられます。CUDA 版と Vulkan 版のアーカイブも用意されています 8:2。
この「実行時に選ぶ」を行うのが whisper.Init(path) です。中で ggml の ggml_backend_load_all_from_path を呼び、ディレクトリ内のバックエンドを登録します。Load だけで Init を呼ばないと CPU バックエンドが登録されないので、順番は Load → Init → モデル読み込みです。bucky system で見ると、この環境では AVX-512 と AMX を持つ CPU 向けの sapphirerapids 版が選ばれていました。
-- Library --
path: /workspace/zenn-articles/bucky/work/lib
load_backend: loaded CPU backend from /workspace/zenn-articles/bucky/work/lib/libggml-cpu-sapphirerapids.so
version: 1.9.4-dev
「CGo なし」は「静的バイナリ」ではない
後で載せる計測プログラムを CGO_ENABLED=0 でビルドし、ldd と go version -m で確かめました。
build exit=0
linux-vdso.so.1 (0x00007fd5b5a20000)
libdl.so.2 => /lib/x86_64-linux-gnu/libdl.so.2 (0x00007fd5b5a0a000)
libpthread.so.0 => /lib/x86_64-linux-gnu/libpthread.so.0 (0x00007fd5b5a05000)
libc.so.6 => /lib/x86_64-linux-gnu/libc.so.6 (0x00007fd5b5811000)
/lib64/ld-linux-x86-64.so.2 (0x00007fd5b5a22000)
dep github.com/ardanlabs/bucky v1.1.3 h1:kWdFifRFPexm2qGykD5EUjU/LMiYkMJ78siPBTQC47I=
dep github.com/ebitengine/purego v0.10.2 h1:W809HbnvzAxgdm+aOvlSekrM16wGCdT/e76+9tS7gzE=
dep github.com/jupiterrider/ffi v0.7.0 h1:RKsl6Ascal+3kyAqR5Qcbp83LceQMLc1VZbPfHWoNzs=
build CGO_ENABLED=0
今回の構成では、ビルドは C コンパイラなしで通りましたが、出来上がったバイナリは libc と動的ローダーに依存していました。これは今回の Linux バイナリの確認結果であり、CGO_ENABLED=0 や dlopen の使用だけから、すべての環境のリンク方式を一般化するものではありません。つまり bucky の「CGo なし」がうれしいのは ビルド側(C ツールチェーン不要、クロスコンパイルしやすい 6:1)で、実行側 では glibc、libffi、lib/ 一式、モデルファイルを配る必要があります。Go の単一バイナリを scratch イメージにコピーするだけ、という運用にはなりません。
導入:ライブラリとモデルを用意する
作業ディレクトリを 1 つ作り、CLI、ライブラリ、モデルを置きました。CLI は go install で入ります。モデルは bucky の model get でも取れますが、後述する理由から Hugging Face の ggerganov/whisper.cpp から直接取得し、SHA1 を確認しました 9。
CLI(Go 1.26 系が必要。GOTOOLCHAIN=auto なら自動で取得される)
GOBIN=$PWD/bin go install github.com/ardanlabs/bucky@v1.1.3
./bin/bucky install -lib ./lib
export BUCKY_LIB=$PWD/lib
モデルは Hugging Face から直接取得する
mkdir -p models
for m in tiny base small; do
curl -L -o models/ggml-$m.bin huggingface.co
done
curl -L -o models/ggml-silero-v5.1.2.bin huggingface.co
sha1sum models/*.bin
465707469ff3a37a2b9b8d8f89f2f99de7299dac models/ggml-base.bin
a372f48dcf0bd9e4330eef2802bc46e061c19634 models/ggml-silero-v5.1.2.bin
55356645c2b361a969dfd0ef2c5a50d530afd8d5 models/ggml-small.bin
bd577a113a864445d4c299885e0cb97d4ba92b5f models/ggml-tiny.bin
tiny・base・small の SHA1 は Hugging Face のモデル一覧に載っている値と一致しました 9:1。bucky の MODELS.md は small を「多くの本番用途の既定」、tiny を「単体テストやスモークテスト向け」と位置づけています 10。
自分のプログラムからは、Go モジュールに bucky を追加して使います。今回の計測プログラムは go get github.com/ardanlabs/bucky@v1.1.3 golang.org/x/text で依存を入れ、間接依存として purego v0.10.2 と ffi v0.7.0 が入りました。
検証用の日本語音声を作る
日本語の精度を測るには、正解テキストと音声の組が必要です。権利関係をはっきりさせるため、今回は 自分で書いた 12 文を Open JTalk で合成 しました。Open JTalk 1.11 は Modified BSD ライセンスで、HTS voice 1.05 も同じサイトで配布されています 11。声には名古屋工業大学の nitech_jp_atr503_m001(CC BY 3.0)を使いました。辞書は Debian の open-jtalk-mecab-naist-jdic(BSD-3-Clause)です。
12 文には、日常文のほか、同音異義語(差す・刺す、開店・回転)、数字と日付、敬語、くだけた会話、IT 用語を混ぜています。Open JTalk の読み(「3月15日」を「サンガツジューゴニチ」と読むかなど)は事前に全文確認しました。
s01 今日は朝から雨が降っていたので、駅まで傘を差して歩きました。
s02 会議の資料は共有フォルダに置いてあるので、明日までに目を通してください。
s03 サーバーのメモリ使用量が増えたため、キャッシュの設定を見直しました。
s04 新しい店舗は3月15日に開店し、初日には約200人が訪れました。
s05 名古屋から新幹線に乗って、京都で友人と待ち合わせをしました。
s06 お忙しいところ恐れ入りますが、ご確認のほどよろしくお願いいたします。
s07 音声認識の精度は、話す速さや周囲の雑音によって大きく変わります。
s08 ねえ、昨日の映画どうだった?思ったより面白かったよね。
s09 来週の火曜日は午後2時から、オンラインで打ち合わせを行う予定です。
s10 このアプリはインターネットに接続しなくても、端末の中だけで文字起こしができます。
s11 図書館で借りた本を返し忘れていたことに気づき、慌てて窓口へ向かいました。
s12 検証用の音声は合成したものなので、実際の会話よりも聞き取りやすい点に注意が必要です。
この 12 文から、条件を変えた音声を作ります。clean は Open JTalk の標準速度、fast は話速 1.4 倍、noisy は clean に SNR 10 dB の白色雑音を足したものです。さらに 12 文を 1.5 秒の無音で連結し、前後に 3 秒の無音を付けた 96 秒の長尺ファイルと、その MP3・FLAC 版、音声を含まない 10 秒のファイル 2 種類(完全な無音と、弱い雑音だけ)も作りました。
#!/usr/bin/env bash
Open JTalk で日本語の検証音声を合成し、条件違いのファイルを作る。
使い方: bash code/corpus/make_corpus.sh 音声ファイル.htsvoice 出力ディレクトリ
set -euo pipefail
VOICE=$1
OUT=$2
DIC=/var/lib/mecab/dic/open-jtalk/naist-jdic
HERE=$(cd "$(dirname "$0")" && pwd)
TOOLS="python3 $HERE/audio_tools.py"
mkdir -p "$OUT"/{clean,fast,noisy,long,silence}
while IFS=$'\t' read -r id text; do
printf '%s\n' "$text" > "$OUT/clean/$id.txt"
open_jtalk -x "$DIC" -m "$VOICE" -ow "$OUT/clean/$id.wav" "$OUT/clean/$id.txt"
open_jtalk -x "$DIC" -m "$VOICE" -r 1.4 -ow "$OUT/fast/$id.wav" "$OUT/clean/$id.txt"
$TOOLS noise "$OUT/clean/$id.wav" "$OUT/noisy/$id.wav" 10
done < "$HERE/sentences.tsv"
12 文を 1.5 秒の無音で連結した長尺ファイル(前後 3 秒も無音)と、その MP3 / FLAC 版
$TOOLS concat "$OUT/long/long.wav" 1.5 "$OUT"/clean/s*.wav
ffmpeg -loglevel error -y -i "$OUT/long/long.wav" -codec:a libmp3lame -b:a 64k "$OUT/long/long.mp3"
ffmpeg -loglevel error -y -i "$OUT/long/long.wav" -codec:a flac "$OUT/long/long.flac"
音声を含まないファイル(ハルシネーション確認用)
$TOOLS silence "$OUT/silence/zero10s.wav" 10
$TOOLS silence "$OUT/silence/hiss10s.wav" 10 0.01
echo "corpus ready: $(find "$OUT" -name '*.wav' | wc -l) wav files"
波形の加工は numpy を使う小さなスクリプトに分けました。雑音は乱数の seed を固定しているので、同じ音声が再生成されます。実際に別ディレクトリへ作り直し、計測に使ったファイルとバイト単位で一致することを確認しています。
#!/usr/bin/env python3
"""検証用 WAV を加工する小さな道具(標準ライブラリ + numpy)。
noise IN OUT SNR_DB 白色雑音を指定 SNR で加える(seed 固定)
concat OUT GAP_S IN... 無音 GAP_S 秒を挟んで連結し、前後にも 3 秒の無音を付ける
silence OUT SEC [RMS] 無音(RMS>0 なら弱い白色雑音のみ)を作る
"""
import sys
import wave
import numpy as np
RATE = 48000
def read(path):
with wave.open(path, "rb") as w:
assert w.getnchannels() == 1 and w.getsampwidth() == 2, path
rate = w.getframerate()
x = np.frombuffer(w.readframes(w.getnframes()), dtype=np.int16)
return x.astype(np.float64) / 32768.0, rate
def write(path, x, rate):
y = np.clip(x, -1.0, 1.0)
with wave.open(path, "wb") as w:
w.setnchannels(1)
w.setsampwidth(2)
w.setframerate(rate)
w.writeframes((y * 32767.0).astype(np.int16).tobytes())
def main():
cmd = sys.argv[1]
rng = np.random.default_rng(20261007)
if cmd == "noise":
x, rate = read(sys.argv[2])
snr_db = float(sys.argv[4])
p_sig = np.mean(x**2)
p_noise = p_sig / (10 ** (snr_db / 10))
write(sys.argv[3], x + rng.normal(0, np.sqrt(p_noise), x.size), rate)
elif cmd == "concat":
gap = float(sys.argv[3])
parts, rate = [], RATE
for p in sys.argv[4:]:
x, rate = read(p)
parts += [x, np.zeros(int(gap * rate))]
pad = np.zeros(3 * rate)
write(sys.argv[2], np.concatenate([pad] + parts[:-1] + [pad]), rate)
elif cmd == "silence":
sec = float(sys.argv[3])
rms = float(sys.argv[4]) if len(sys.argv) > 4 else 0.0
write(sys.argv[2], rng.normal(0, rms, int(sec * RATE)) if rms > 0 else np.zeros(int(sec * RATE)), RATE)
else:
sys.exit(f"unknown command {cmd}")
if name == "main":
main()
合成音声は話速も発音も一定で、人の会話より聞き取りやすいはずです。以降の CER は この合成音声・話者・文集合で得られた値 として読んでください。認識精度は高めに出ている可能性がありますが、CER の上限・下限や実録音での性能を示す値ではありません。人の声の録音(CC0 などの音源)での計測は今回行っていません。
jabench:bucky で文字起こしして CER を出す
計測には、bucky の API を直接呼ぶ小さなプログラム jabench を書きました。音声ファイルを pure Go でデコードし、whisper.Full で文字起こしして、正解との CER と時間を出します。以下のコードはコメント内の source=... のパスに保存してください。音声生成には Open JTalk・辞書・HTS voice・Python 3・NumPy・FFmpeg が別途必要です。
掲載コードでは、-segments は表示だけでなく認識設定も変えます。省略すると NoTimestamps = 1、付けるとタイムスタンプ有効です。計測条件を保持するためこの実装を掲載していますが、長尺音声では必ず -segments を付けてください。
// jabench は bucky(CGo なしの whisper.cpp バインディング)で日本語音声を
// 文字起こしし、文字誤り率(CER)と処理時間を表示する検証用プログラム。
//
// go run ./jabench -model ../models/ggml-tiny.bin -ref corpus/sentences.tsv ../audio/clean/*.wav
package main
import (
"bufio"
"flag"
"fmt"
"log"
"os"
"path/filepath"
"runtime"
"strings"
"time"
"unsafe"
"github.com/ardanlabs/bucky/pkg/audio"
"github.com/ardanlabs/bucky/pkg/utils"
"github.com/ardanlabs/bucky/pkg/whisper"
)
func main() {
var (
modelPath = flag.String("model", "", "GGML 形式の whisper モデル(必須)")
lang = flag.String("lang", "ja", 言語コード。"auto" で自動判定、"-" で whisper.cpp の既定値のまま)
threads = flag.Int("threads", 4, "CPU スレッド数")
beam = flag.Int("beam", 0, "0 より大きければビームサーチ(ビーム幅)")
vadModel = flag.String("vad", "", "Silero VAD の GGML モデル。指定すると whisper.cpp 内蔵の VAD を有効にする")
refPath = flag.String("ref", "", "参照テキストの TSV(id と text をタブ区切り)。id は音声ファイル名から拡張子を除いたもの")
segments = flag.Bool("segments", false, "セグメントごとのタイムスタンプを表示する")
)
flag.Parse()
if *modelPath == "" || flag.NArg() == 0 {
log.Fatalf("usage: jabench -model ggml-xxx.bin [flags] audio...")
}
libPath := os.Getenv("BUCKY_LIB")
if err := whisper.Load(libPath); err != nil { // dlopen + シンボル解決
log.Fatalf("whisper.Load: %v", err)
}
if err := whisper.Init(libPath); err != nil { // libggml-cpu-*.so をレジストリに登録
log.Fatalf("whisper.Init: %v", err)
}
whisper.LogSet(whisper.LogSilent())
refs, err := loadRefs(*refPath)
if err != nil {
log.Fatal(err)
}
t0 := time.Now()
ctx, err := whisper.InitFromFileWithParams(*modelPath, whisper.ContextDefaultParams())
if err != nil {
log.Fatalf("load model: %v", err)
}
defer whisper.Free(ctx)
fmt.Printf("# model=%s load=%s threads=%d beam=%d vad=%t lang=%s\n",
filepath.Base(*modelPath), time.Since(t0).Round(time.Millisecond), *threads, *beam, *vadModel != "", *lang)
var sumEdits, sumChars int
var sumAudio, sumFull time.Duration
for _, path := range flag.Args() {
samples, decodeDur, err := decodeFile(path)
if err != nil {
log.Fatal(err)
}
audioDur := time.Duration(float64(len(samples)) / whisper.SampleRate * float64(time.Second))
p := params(*threads, *beam, *segments)
var keep whisper.StringRefs
switch *lang {
case "-": // Language フィールドに触らない(whisper.cpp の既定値は "en")
case "auto":
keep.SetLanguage(&p, "auto")
default:
keep.SetLanguage(&p, *lang)
}
var vadPath *byte
if *vadModel != "" {
// bucky v1.1.3 には VadModelPath 用のヘルパーがないので C 文字列を自前で用意する
vadPath, _ = utils.BytePtrFromString(*vadModel)
p.Vad = 1
p.VadModelPath = uintptr(unsafe.Pointer(vadPath))
}
start := time.Now()
if err := whisper.Full(ctx, p, samples); err != nil {
log.Fatalf("%s: %v", path, err)
}
fullDur := time.Since(start)
keep.KeepAlive()
runtime.KeepAlive(vadPath)
var text strings.Builder
for i := range whisper.FullNSegments(ctx) {
seg := whisper.FullGetSegmentText(ctx, i)
text.WriteString(seg)
if *segments {
fmt.Printf(" [%s -> %s] %s\n", cs(whisper.FullGetSegmentT0(ctx, i)), cs(whisper.FullGetSegmentT1(ctx, i)), strings.TrimSpace(seg))
}
}
hyp := strings.TrimSpace(text.String())
id := strings.TrimSuffix(filepath.Base(path), filepath.Ext(path))
detected := whisper.LangStr(whisper.FullLangID(ctx))
cerCol := "-"
if ref, ok := refs[id]; ok {
e, n := cer(ref, hyp)
sumEdits += e
sumChars += n
cerCol = fmt.Sprintf("%.3f", float64(e)/float64(max(n, 1)))
}
sumAudio += audioDur
sumFull += fullDur
fmt.Printf("%s\taudio=%.2fs\tdecode=%dms\tfull=%dms\trtf=%.3f\tlang=%s\tcer=%s\t%s\n",
id, audioDur.Seconds(), decodeDur.Milliseconds(), fullDur.Milliseconds(),
fullDur.Seconds()/audioDur.Seconds(), detected, cerCol, hyp)
}
if sumChars > 0 {
fmt.Printf("# TOTAL files=%d audio=%.1fs full=%.1fs rtf=%.3f chars=%d edits=%d CER=%.2f%%\n",
flag.NArg(), sumAudio.Seconds(), sumFull.Seconds(), sumFull.Seconds()/sumAudio.Seconds(),
sumChars, sumEdits, 100*float64(sumEdits)/float64(sumChars))
}
}
// params は検証で使う whisper_full_params を作る。
func params(threads, beam int, segments bool) whisper.WhisperFullParams {
strategy := whisper.SamplingGreedy
if beam > 0 {
strategy = whisper.SamplingBeamSearch
}
p := whisper.FullDefaultParams(strategy)
if beam > 0 {
p.BeamSearchBeamSize = int32(beam)
}
p.NThreads = int32(threads)
p.PrintProgress = 0
p.PrintRealtime = 0
p.PrintTimestamps = 0
if !segments {
p.NoTimestamps = 1
}
return p
}
// decodeFile は WAV / MP3 / FLAC を pure Go でデコードし、16 kHz モノラルの float32 にする。
func decodeFile(path string) ([]float32, time.Duration, error) {
f, err := os.Open(path)
if err != nil {
return nil, 0, err
}
defer f.Close()
start := time.Now()
samples, err := audio.Decode(f)
if err != nil {
return nil, 0, fmt.Errorf("%s: %w", path, err)
}
return samples, time.Since(start), nil
}
func loadRefs(path string) (map[string]string, error) {
refs := map[string]string{}
if path == "" {
return refs, nil
}
f, err := os.Open(path)
if err != nil {
return nil, err
}
defer f.Close()
sc := bufio.NewScanner(f)
for sc.Scan() {
if id, text, ok := strings.Cut(sc.Text(), "\t"); ok {
refs[id] = text
}
}
return refs, sc.Err()
}
// cs はセグメント時刻(10 ミリ秒単位)を mm:ss.mmm にする。
func cs(t int64) string {
ms := t * 10
return fmt.Sprintf("%02d:%02d.%03d", ms/60000, ms/1000%60, ms%1000)
}
いくつか、bucky の API を使ううえでのポイントがあります。
言語の指定: WhisperFullParams の Language は C 文字列へのポインタです。Go の文字列から C 文字列を作り、whisper.Full が終わるまで GC に回収されないように保つ必要があります。bucky の StringRefs はこれを肩代わりしてくれる型で、SetLanguage で設定し、呼び出し後に KeepAlive を呼びます。空文字を渡すと NULL になり、whisper.cpp 側で自動判定になります。
VAD モデルのパス: v1.1.3 の StringRefs には VadModelPath 用のヘルパーがありません。そこで utils.BytePtrFromString で C 文字列を作り、runtime.KeepAlive で生存期間を保っています。
タイムスタンプの単位: FullGetSegmentT0・FullGetSegmentT1 は 10 ミリ秒単位の整数です。
デコード: audio.Decode は先頭のバイト列から WAV・MP3・FLAC を判定し、モノラル化と 16 kHz へのリサンプリングまで行います 1:5。
CER は、正解と認識結果を NFKC で正規化し、句読点・記号・空白を除いてから、文字単位の編集距離を正解の文字数で割ります。全角と半角の差は吸収しますが、漢字とかな、漢数字と算用数字の違いは誤りとして数えます。表記ゆれも誤りに入るので、意味が通っていても 0% にはなりにくい厳しめの指標です。
package main
import (
"strings"
"unicode"
"golang.org/x/text/unicode/norm"
)
// normalize は CER 計算用に表記を揃える。NFKC で全角・半角を統一し、
// 句読点・記号・空白を落とす。漢字とかな、算用数字と漢数字の違いは残す。
func normalize(s string) []rune {
var b strings.Builder
for _, r := range norm.NFKC.String(s) {
if unicode.IsPunct(r) || unicode.IsSymbol(r) || unicode.IsSpace(r) {
continue
}
b.WriteRune(r)
}
return []rune(b.String())
}
// editDistance は文字単位のレーベンシュタイン距離(置換・削除・挿入)を返す。
func editDistance(ref, hyp []rune) int {
prev := make([]int, len(hyp)+1)
cur := make([]int, 0, len(hyp)+1)
for j := range prev {
prev[j] = j
}
for i, rc := range ref {
cur = append(cur[:0], i+1)
for j, hc := range hyp {
cost := 1
if rc == hc {
cost = 0
}
cur = append(cur, min(prev[j+1]+1, cur[j]+1, prev[j]+cost))
}
prev, cur = cur, prev
}
return prev[len(hyp)]
}
// cer は (編集回数, 参照文字数) を返す。CER = 編集回数 / 参照文字数。
func cer(ref, hyp string) (edits, refLen int) {
r, h := normalize(ref), normalize(hyp)
return editDistance(r, h), len(r)
}
正規化の方針はテストで固定しました。
package main
import "testing"
func TestCER(t *testing.T) {
tests := []struct {
ref, hyp string
edits, chars int
}{
{"今日は雨です。", "今日は雨です", 0, 6}, // 句読点は無視
{"午後2時から", "午後2時から", 0, 6}, // 全角数字は NFKC で揃う
{"午後2時から", "午後二時から", 1, 6}, // 漢数字との違いは 1 文字の置換
{"傘を差して", "傘をさして", 1, 5}, // 漢字とかなの違いも誤りに数える
{"ねえ、どうだった?", "ねえどうだった", 0, 7}, // 記号・空白は無視
{"", "ご視聴ありがとうございました", 14, 0}, // 無音への幻覚は挿入だけになる
}
for _, tt := range tests {
e, n := cer(tt.ref, tt.hyp)
if e != tt.edits || n != tt.chars {
t.Errorf("cer(%q, %q) = (%d, %d), want (%d, %d)", tt.ref, tt.hyp, e, n, tt.edits, tt.chars)
}
}
}
code ディレクトリを Go モジュールとして初期化していない場合は、先に次のコマンドで依存を用意します。
cd code
go mod init example.com/bucky-ja
go get github.com/ardanlabs/bucky@v1.1.3 golang.org/x/text
テストと、tiny で 1 文だけ動かした結果です(code ディレクトリで実行)。
go test ./jabench
BUCKY_LIB=$PWD/../lib go run ./jabench -model ../models/ggml-tiny.bin -ref corpus/sentences.tsv ../audio/clean/s12.wav
ok example.com/bucky-ja/jabench (cached)
load_backend: loaded CPU backend from /workspace/zenn-articles/bucky/work/code/../lib/libggml-cpu-sapphirerapids.so
model=ggml-tiny.bin load=124ms threads=4 beam=0 vad=false lang=ja
s12 audio=7.66s decode=2ms full=295ms rtf=0.039 lang=ja cer=0.050 検証用の音声は合成したものなので、実際の回はよりも聞き取りやすい点に注意が必要です。
TOTAL files=1 audio=7.7s full=0.3s rtf=0.039 chars=40 edits=2 CER=5.00%
lang=ja は whisper.cpp が最終的に使った言語、cer= はこのファイルの CER です。この文は tiny でも比較的よく認識できましたが、それでも「会話」が「回は」になり、40 文字中 2 文字の誤り(CER 5.00%)が出ています。次の節で見るとおり、12 文全体では tiny の誤りはずっと多くなります。
結果:tiny・base・small の CER と速度
12 文(正解 381 文字、音声 73.5 秒)を 1 ファイルずつ処理したときの結果です。時間は whisper.Full の合計(3 回の中央値)、RTF は処理時間を音声長で割った値で、1 より小さければ実時間より速いことを示します。モデル読み込み時間は別に示しました。表の「音声 73.5 秒」は clean/noisy の合計であり、fast の音声長は異なります。RTF は各条件の実際の音声長を分母にしています。以下の表・ログは検証時の測定記録です。
モデル
条件
処理時間(中央値)
RTF
読み込み
誤り数/正解文字数
CER
tiny
clean
3.5 秒
0.048
103 ms
76/381
19.95%
tiny
fast(1.4 倍速)
3.6 秒
0.068
102 ms
62/381
16.27%
tiny
noisy(SNR 10 dB)
4.4 秒
0.060
107 ms
188/381
49.34%
base
clean
7.2 秒
0.098
170 ms
43/381
11.29%
base
fast
7.4 秒
0.140
158 ms
38/381
9.97%
base
noisy
16.3 秒(13.1〜25.0)
0.222
163 ms
163/381
42.78%
small
clean
27.6 秒
0.376
477 ms
11/381
2.89%
small
fast
24.3 秒
0.460
578 ms
13/381
3.41%
small
noisy
28.8 秒
0.392
525 ms
47/381
12.34%
計測条件を揃えて追試する場合の実行例です。以下は追試用コマンドであり、新たな実行ログではありません(code ディレクトリで実行)。短い 12 ファイルの比較は、掲載プログラムの既定どおりタイムスタンプなし・VAD なしです。
export BUCKY_LIB="$PWD/../lib"
CGO_ENABLED=0 go build -o ../bin/jabench ./jabench
for m in tiny base small; do
for condition in clean fast noisy; do
for run in 1 2 3; do
uptime
../bin/jabench -model ../models/ggml-$m.bin -lang ja -threads 4 \
-ref corpus/sentences.tsv ../audio/$condition/*.wav
done
done
done
連結した long.wav に対応する参照テキスト(id は long)
awk -F '\t' 'BEGIN { printf "long\t" } { printf "%s", $2 } END { printf "\n" }' \
corpus/sentences.tsv > corpus/long.tsv
長尺は -segments でタイムスタンプを有効にする
../bin/jabench -model ../models/ggml-small.bin -lang ja -threads 4 \
-segments -ref corpus/long.tsv ../audio/long/long.wav
VAD ありの比較では上記に次を追加する
-vad ../models/ggml-silero-v5.1.2.bin
モデルの差は「誤りの質」に出る
tiny の CER は約 20% で、文としては読めないことが多いです。たとえば s01 は「今日は柔らかめが降っていたので、平きまで重させて…」となりました。base は 11% 前後で、要点はつかめるものの固有名詞や漢字の選択が崩れます。small は 2.89% で、残った 11 文字の誤りは次の 3 種類でした。
種類
例(正解→認識)
誤り文字数
同音異義
差して→刺して、開店→回転
3
表記ゆれ
置いて→おいて、恐れ入り→おそれいり
4
聞き誤り
音声→本性、本を→方の
4
表記ゆれは意味として誤りではないので、実質的な誤りはさらに少ないと言えます。一方、同音異義の「開店→回転」は文脈で判断すべき誤りで、small でも残りました。OpenAI の whisper リポジトリも、性能は言語によって大きく異なると注意しています 12。パラメータ数は tiny が 39M、base が 74M、small が 244M で 12:1、日本語ではこの規模の差がそのまま効いている印象です。
雑音には弱く、base は遅くもなる
SNR 10 dB の白色雑音を足すと、全モデルで CER が大きく悪化しました(small でも 12.34%)。目立ったのは base の処理時間で、clean の 7.2 秒から 16.3 秒へ倍以上に増え、3 回の幅も 13.1〜25.0 秒とばらつきました。
これは whisper.cpp の 温度フォールバック によるものと考えられます。whisper.cpp の既定値では、トークン列のエントロピーを用いた失敗判定(entropy_thold = 2.4)や平均対数確率(logprob_thold = -1.0)などに基づき、温度を 0.2 ずつ上げて同じ区間をデコードし直します(temperature_inc = 0.2)2:1。entropy_thold は文字列の圧縮率ではありません。平均対数確率による判定には無音確率も関係します。bucky の CLI には --temperature-inc があるので、0 にしてフォールバックを止めて比べました(base、noisy の 12 ファイル、CLI はファイルごとにモデルを読み込み直すため読み込み時間込み)。これは先の jabench の 3 回中央値とは別の計測であり、両表の時間を直接比較することはできません。
設定
合計時間
CER
既定(temperature_inc = 0.2)
10,292 ms
42.78%
--temperature-inc 0
8,985 ms
72.70%
フォールバックを止めると速くはなりますが、CER は 72.70% まで悪化しました。1 文では「ハイアス」が 34 回繰り返され、最後に壊れた UTF-8 の文字が出ています。フォールバックは繰り返しループから抜けるための安全装置なので、時間のばらつきが気になっても止めないほうがよい、というのが今回の結論です(なお、既定設定の CLI の CER が jabench と一致したことで、計算方法の確認にもなりました)。
ビーム幅 5 は少し良くなり、約 1.5 倍遅くなる
small でビームサーチ(ビーム幅 5)も試しました。clean は CER 2.89% → 2.62%(27.6 秒 → 40.7 秒)、noisy は 12.34% → 9.71%(28.8 秒 → 43.2 秒)でした。雑音下では効果がありますが、今回の clean での改善幅は小さい結果でした。統計的な有意差は評価していません。まず greedy で始め、雑音のある音声で精度が足りないときに検討すればよさそうです。
短いファイルを何本も処理すると割高になる
同じ 12 文を 1 本につないだ 96 秒のファイルでは、small の処理時間は 9〜12 秒(RTF 約 0.09〜0.13)でした。12 本に分けたときの 27.6 秒より明らかに速いです。whisper は音声を 30 秒の窓で処理するので 12:2、5〜8 秒の短いファイルでも 1 窓分の計算がかかると考えると説明がつきます。12 本の合計 27.6 秒を 12 で割った約 2.3 秒が、96 秒(4 窓分)を処理した約 9 秒の 4 分の 1 とほぼ同じなのも、この解釈と合っています。
短い発話を大量に処理するシステムでは、ファイルを細かく切るほど割高になります。今回の結果では、1 ファイルにまとめることで処理時間を短縮できました。後述の VAD も無音が多い音声での候補ですが、短い発話を個別に処理する場合に必ず高速化するとは限りません。
デコード時間とメモリ
pure Go のデコードは、5〜8 秒の WAV で 1〜6 ms でした。96 秒のファイルでは WAV が 25〜36 ms、FLAC が 203〜233 ms、MP3(64 kbps)が 826 ms で、いずれも推論時間に比べれば小さい値です。MP3 と FLAC でも small の CER は 1.31% で WAV と同じでした(tiny は MP3 だけ 20.21% と 1 文字増えました)。96 秒を処理したときの最大 RSS は tiny 276 MiB、base 339 MiB、small 741 MiB でした。
VAD・タイムスタンプ・ストリーミング:日本語で使う前に知っておきたいこと
bucky は whisper.cpp の C API をそのまま写しているので、VAD、セグメントごとのタイムスタンプ、トークン単位の情報も Go から扱えます 1:6。ただし既定値が日本語向けとは限らず、組み合わせによって結果が大きく変わりました。
言語を指定しないと英訳される
whisper.cpp v1.9.4 の whisper_full_default_params 2:2 では、language の既定値が "en" です。whisper.h のコメントにあるとおり、自動判定にしたい場合は NULL、空文字、"auto" のいずれかを渡す必要があります。bucky の examples/hello や examples/streaming は言語を設定していないので、日本語の音声を渡すと英語が出てきます。実際に Language に触らずに 3 文を処理すると、small は次のように出力しました。
s01 audio=5.33s decode=1ms full=1661ms rtf=0.312 lang=en cer=1.929 It was raining in the morning, so I walked all the way to the station.
今回の出力は日本語の文字起こしではなく、内容を英語で表したものになっています。掲載した s01 の cer=1.929 は約 192.9% です。原稿には別に CER 175.53% と記録されていますが、掲載ログの s01 の値とは一致しません。挿入が多い場合、CER は 100% を超えることがあります。なお、既定値の Translate は false なので、「言語 en の指定」と「翻訳タスクを明示する設定」は区別してください 2:3。自動判定("auto")にすると tiny も small も ja を正しく選び、CER は ja 固定と同じでした。ただし small では言語判定の分だけ時間が増え、3 文で 7.7 秒から 9.7 秒になりました。日本語だとわかっているなら固定するのが確実です。bucky の CLI(bucky whisper transcribe)は --lang の既定が空文字(自動判定)なので、今回 CLI を使った条件では日本語で出力されました 3:1。
タイムスタンプを切ると、長い音声の途中が抜ける
今回いちばん驚いたのがこれです。96 秒の音声を small で処理したとき、タイムスタンプを有効にすると CER 1.31% でしたが、NoTimestamps = 1 にすると 45.67% に悪化しました。CLI でも同じことが起きます。--segments なしで実行すると、出力は次のように途中の文が抜けました。
今日は朝から雨が降っていたので、 駅まで傘を刺して歩きました。会議の資料は共有フォルダにおいてあるので、 明日までに目を通してください。サーバーのメモリ使用量が増えたため、 キャッシュの設定を見直しました。お忙しいところおそれいりますが、 ご確認のほどよろしくお願いいたします。来週の火曜日は午後2時から、 オンラインで打ち合わせを行う予定です。このアプリはインターネットに接続しなくても、 端末の中だけで文字起こしができます。も聞き取りやすい点に注意が必要です。
同じファイルで --segments を付けると、12 文すべてが出ます。
[00:00.000 -> 00:07.000] 今日は朝から雨が降っていたので、 駅まで傘を刺して歩きました。
[00:07.000 -> 00:14.000] 会議の資料は共有フォルダにおいてあるので、 明日までに目を通してください。
[00:14.000 -> 00:22.000] サーバーのメモリ使用量が増えたため、 キャッシュの設定を見直しました。
[00:22.000 -> 00:32.000] 新しい店舗は3月15日に回転し、初日には約200人が訪れました。
[00:32.000 -> 00:40.000] 名古屋から新幹線に乗って、 京都で友人と待ち合わせをしました。
[00:40.000 -> 00:47.000] お忙しいところ恐れ入りますが、 ご確認のほどよろしくお願いいたします。
[00:47.000 -> 00:55.000] 音声認識の精度は、話す速さや周囲の雑音によって大きく変わります。
[00:55.000 -> 01:01.000] ねえ、昨日の映画どうだった、思ったより面白かったよね。
[01:01.000 -> 01:09.000] 来週の火曜日は午後2時から、 オンラインで打ち合わせを行う予定です。
[01:09.000 -> 01:17.000] このアプリはインターネットに接続しなくても、 端末の中だけで文字起こしができます。
[01:17.000 -> 01:25.000] 図書館で借りた方を返し忘れていたことに気づき、 慌てて窓口へ向かいました。
[01:25.000 -> 01:34.000] 検証用の音声は合成したものなので、 実際の会話よりも聞き取りやすい点に注意が必要です。
この欠落を説明する実装上の手がかりが、whisper.cpp のシーク処理です 2:4。whisper は 30 秒の窓ごとにデコードし 12:3、通常はデコード結果のタイムスタンプトークンを見て「どこまで読んだか」を決め、次の窓の開始位置にします。v1.9.4 の whisper_full_with_state では、no_timestamps(または single_segment)が有効なとき、終了トークンが出た時点で シーク幅を窓の長さ(30 秒)に固定 します。そのため、1 つの窓の途中でモデルが出力を終えると、残りの発話は読まれずに次の 30 秒へ進みます。実際に抜けたのは 22〜40 秒、47〜61 秒、77〜85 秒付近の文で、窓の後半にあたる位置でした。
bucky の README の hello 例 1:7、examples/transcribe、CLI の非 --segments モードは、いずれも NoTimestamps = 1 を設定しています。30 秒以内なら欠落しないと保証できるわけではありませんが、会議録音などの長い音声を処理するなら、タイムスタンプは有効のまま にして、表示が不要なら Go 側で使わなければよいだけです。なお、後述の VAD を有効にした場合は、タイムスタンプなしでも CER 10.5% でした。軽減はされますが、解決にはなりません。
VAD:無音の幻聴を止め、タイムスタンプを実際の発話に合わせる
whisper.cpp の VAD は、音声から発話区間を検出し、その区間だけを whisper に渡すことで処理を速くできる仕組みです 13。bucky では WhisperFullParams の Vad と VadModelPath を設定すれば whisper.Full の中で使えます。VAD だけを単独で呼ぶ API もあるので、まず発話区間がどう検出されるかを見てみます。
// vadseg は bucky の VAD API(whisper.cpp 内蔵の Silero VAD)だけを使い、
// 音声ファイル中の発話区間を表示する。whisper モデルは読み込まない。
//
// go run ./vadseg ../models/ggml-silero-v5.1.2.bin ../audio/long/long.wav
package main
import (
"fmt"
"log"
"os"
"time"
"github.com/ardanlabs/bucky/pkg/audio"
"github.com/ardanlabs/bucky/pkg/whisper"
)
func main() {
if len(os.Args) != 3 {
log.Fatalf("usage: vadseg ggml-silero-xxx.bin audio")
}
libPath := os.Getenv("BUCKY_LIB")
if err := whisper.Load(libPath); err != nil {
log.Fatal(err)
}
if err := whisper.Init(libPath); err != nil {
log.Fatal(err)
}
whisper.LogSet(whisper.LogSilent())
cparams := whisper.VadDefaultContextParams()
cparams.NThreads = 4
vctx, err := whisper.VadInitFromFileWithParams(os.Args[1], cparams)
if err != nil {
log.Fatal(err)
}
defer whisper.VadFree(vctx)
f, err := os.Open(os.Args[2])
if err != nil {
log.Fatal(err)
}
defer f.Close()
samples, err := audio.Decode(f)
if err != nil {
log.Fatal(err)
}
p := whisper.VadDefaultParams()
fmt.Printf("# threshold=%.2f min_speech=%dms min_silence=%dms pad=%dms\n",
p.Threshold, p.MinSpeechDurationMs, p.MinSilenceDurationMs, p.SpeechPadMs)
start := time.Now()
segs, err := whisper.VadSegmentsFromSamples(vctx, p, samples)
if err != nil {
log.Fatal(err)
}
defer whisper.VadFreeSegments(segs)
var speech float64
n := whisper.VadSegmentsNSegments(segs)
for i := range n {
// t0 / t1 は 10 ミリ秒単位で返る
t0 := whisper.VadSegmentsGetSegmentT0(segs, i) / 100
t1 := whisper.VadSegmentsGetSegmentT1(segs, i) / 100
speech += float64(t1 - t0)
fmt.Printf("speech %2d: %6.2fs -> %6.2fs\n", i+1, t0, t1)
}
total := float64(len(samples)) / whisper.SampleRate
fmt.Printf("# segments=%d speech=%.1fs / audio=%.1fs (%.0f%%) vad=%dms\n",
n, speech, total, 100*speech/total, time.Since(start).Milliseconds())
}
load_backend: loaded CPU backend from /workspace/zenn-articles/bucky/work/code/../lib/libggml-cpu-sapphirerapids.so
threshold=0.50 min_speech=250ms min_silence=100ms pad=30ms
speech 1: 3.39s -> 5.44s
speech 2: 5.83s -> 7.81s
speech 3: 10.24s -> 12.99s
speech 4: 13.38s -> 15.33s
speech 5: 17.70s -> 20.13s
speech 6: 20.55s -> 22.46s
speech 7: 24.87s -> 27.97s
speech 8: 28.39s -> 30.88s
speech 9: 33.28s -> 35.23s
speech 10: 35.68s -> 37.98s
speech 11: 40.35s -> 42.30s
speech 12: 42.72s -> 45.31s
speech 13: 47.68s -> 49.18s
speech 14: 49.67s -> 53.09s
speech 15: 55.49s -> 55.84s
speech 16: 56.19s -> 57.57s
speech 17: 57.92s -> 59.77s
speech 18: 62.15s -> 64.25s
speech 19: 64.64s -> 67.07s
speech 20: 69.47s -> 72.35s
speech 21: 72.80s -> 75.36s
speech 22: 77.73s -> 81.09s
speech 23: 81.47s -> 83.33s
speech 24: 85.73s -> 88.41s
speech 25: 88.87s -> 92.51s
segments=25 speech=57.5s / audio=96.0s (60%) vad=201ms
96 秒のうち発話は約 6 割で、先頭 3 秒の無音もきちんと除かれています(VadSegmentsGetSegmentT0 は float32 型で 10 ミリ秒単位の値を返すため、100 で割っています 14)。12 文に対して 25 区間になっているのは、読点の位置の短い間でも区切られるためです。VAD の処理は 0.2〜0.4 秒程度でした(実行のたびに負荷で変わります)。
VAD が最も効いたのは、音声を含まないファイルです。VAD なしで 10 秒の無音・弱い雑音を渡すと、どのモデルも何かを「聞き取って」しまいました。
モデル
完全な無音
弱い雑音(RMS 0.01)
tiny
(笑)
ご視聴ありがとうございました
base
(音楽)
(水溶け)
small
(音楽)
(音)
whisper の幻聴としてよく知られる「ご視聴ありがとうございました」が tiny で再現しました。VAD を有効にすると、全モデルで出力は空になり、処理時間も 0.5〜1.7 秒から 21〜33 ms に下がりました。録音の前後に無音がある、マイクを開きっぱなしにする、といった用途では、VAD は今回のような不要な出力への有力な対策です。発話の取りこぼしも評価したうえで採用してください。なお、何もデコードされなかった場合、FullLangID が返す言語は en になっていました。発話をデコードしていない場合、この値を言語判定の結果として扱うべきではありません。
96 秒の長尺に VAD を併用すると、タイムスタンプも変わります。VAD なしでは先頭の 3 秒の無音も含めて 00:00.000 から始まり、区切りも 7〜10 秒の粗いものでしたが、VAD ありでは最初のセグメントは、VAD が検出した最初の発話区間の開始と同じ 00:03.390 から始まりました。
[00:03.390 -> 00:09.510] 今日は朝から雨が降っていたので 駅まで傘を刺して歩きました
[00:09.510 -> 00:14.800] 会議の資料は共有フォルダにおいて あるので明日までに目を通して
[00:14.800 -> 00:19.690] ください サーバーのメモリー使用料が増えた
[00:19.690 -> 00:28.500] ためキャッシュの設定を見直しました 新しい店舗は3月15日に回転し初
一方で、句読点が消え、セグメントが文の途中(「回転し初 / 日には」)で切れるようになりました。CER も 1.31% から 2.10% へ少し悪化しています(「メモリー使用料」のような語の選択の違い)。発話区間をつないで whisper に渡すので、文のまとまりの手がかりが弱くなるのだと思われます。今回の音声では文間の無音が短いため、速度の差もほとんどありませんでした(9.0 秒前後で同程度)。
使い分けとしては、無音や雑音の区間が多い音声、発話区間を考慮したタイムスタンプが欲しい場合は VAD あり、文単位の読みやすい出力が欲しく、無音が少ない場合は VAD なし が目安です。なお、whisper.cpp の README では新しい Silero VAD のモデルも案内されているので 13:1、v5.1.2 以外での挙動は未確認です。
ストリーミング例は「窓分割」で、日本語では境界で文が欠ける
bucky には examples/streaming と examples/streaming-realtime があります 1:8。examples/streaming は音声を 10 秒の窓(1 秒の重なり)に分けて順に whisper.Full を呼び、前の窓の末尾トークンを次の窓のプロンプトに渡す実装でした。真のストリーミング推論ではなく、窓分割による疑似ストリーミングです 15。この説明と以下の計測は examples/streaming を対象とし、examples/streaming-realtime は未計測です。
そのまま small で 96 秒の音声を渡すと、言語未設定のため英語が出て、「The new tempo is 3/15」が 3 回繰り返されるなど崩れました。そこで例に SetLanguage(&wparams, "ja") の 1 行を足し(計測用に CER の表示も追加)、測ってみると、small の CER は 23.36%、処理時間は読み込み込みで 21.7 秒でした。1 本で処理した場合(1.31%、9〜12 秒)より精度も速度も悪く、文が窓の境界で欠けたり(s03 と s12 の前半が抜けました)、別の文に置き換わったりしています。tiny では 28.87% でした。
この実装でリアルタイム字幕のような途中結果を得るには窓分割を使いますが、日本語で精度を保つには、窓の長さや VAD での区切りを調整する前提で考えたほうがよさそうです。録音済みファイルなら、1 本のまま whisper.Full に渡すのが最も精度が高い結果でした。
比較と選び方:bucky・CGo の whisper バインディング・クラウド API
最終的な選択を左右するのは、精度そのものより「ビルドと配布をどこで重くするか」と「音声を外に出せるか」でした。CGo のバインディングとクラウド API は今回実測していないので、以下はドキュメントにもとづく比較です。
比較軸
bucky(purego・libffi)
whisper.cpp 公式の Go バインディング(CGo)
クラウド API(例: OpenAI の文字起こし)
ビルド
C コンパイラ不要、CGO_ENABLED=0 で通る
libwhisper.a をビルドし、C_INCLUDE_PATH と LIBRARY_PATH を設定してリンクする 16
HTTP クライアントだけ
実行時に必要なもの
glibc、libffi、lib/(約 18 MB)、モデル(small で 466 MiB)
モデルに加え、ビルド構成に応じた OS・C/C++ ランタイムなど(libwhisper 自体は静的リンクする手順)
ネットワークと API キー
音声の所在
手元から出ない
手元から出ない
外部に送る。1 ファイル 25 MB までの制限がある 17
推論ハードウェアの切り替え
CPU・CUDA・Vulkan などの対応アーカイブを選ぶ。GPU 用ドライバー等の実行時要件は別途確認 8:3
自分でビルド設定を変える
不要
whisper.cpp の更新
bucky-builder の新しいビルドと bucky の pin を待つ(v1.1.3 は v1.9.4)
自分で好きなバージョンをビルドできる
不要
精度と速度
今回の実測: small で CER 2.89%、RTF 約 0.1〜0.4(4 スレッド)
未計測(モデル・設定・バックエンド等を揃えた比較が必要)
未計測
bucky が向くのは、Go のビルドパイプラインに C ツールチェーンを持ち込みたくない場合、複数 OS 向けにクロスコンパイルしたい場合 6:2、音声を外部に出せない場合です。CUDA 版・Vulkan 版のビルド済みアーカイブが用意されているので 8:4、GPU へ移る道があるのも利点です(今回 GPU 版は試していません)。
CGo のバインディングが向くのは、すでに C のビルド環境があり、whisper.cpp を自分でパッチしたりバージョンを細かく選んだりしたい場合です。公式手順は静的ライブラリ libwhisper.a をリンクする形なので 16:1、libwhisper の共有ライブラリを別配布せずリンクしたい場合には候補になります。ただし、完全な静的バイナリになるとは限らず、モデルファイルも別途必要です。
クラウド API が向くのは、手元に計算資源を置きたくない場合、少量の音声を手早く処理したい場合です。ただし音声データを外部に送ること、ファイルサイズの上限があることが前提になります 17:1。
モデルの選び方については、今回の数値からは、日本語で文字起こし結果を人が読むなら small 以上、tiny と base は「キーワードが含まれるかを粗く調べる」程度の用途が現実的だと考えます。small の RTF は 4 スレッドで 0.1〜0.4 なので、同じ RTF を単純に外挿すると 1 時間の音声で 6〜24 分程度の計算になります。ただし、1 時間の実録音は未計測であり、負荷や音声条件による変化を含まない概算です。
失敗しやすい条件と切り分け
実際に踏んだ、または再現した問題を、発生条件、最初に見るところ、原因、対処の順にまとめます。
日本語なのに英語が出る
発生条件: Language を設定せずに whisper.Full を呼ぶ。bucky の hello 例や streaming 例をそのまま使う。
観測点: 出力が英文になる。whisper.LangStr(whisper.FullLangID(ctx)) が en を返す。
原因: whisper.cpp v1.9.4 の既定言語が "en" のため。whisper 自体は多言語モデルで、言語を指定すれば日本語を書き起こせます 12:4。
対処: StringRefs.SetLanguage(&p, "ja") で固定します。言語が混在するなら "auto" にします(判定の分だけ遅くなります)。
長い音声の途中が抜ける
発生条件: 30 秒を超える音声で NoTimestamps = 1 にする。CLI で --segments を付けない。
観測点: 出力が短すぎる。文と文のつながりが不自然になる(今回は「…見直しました。お忙しいところ…」のように途中が飛びました)。
原因: タイムスタンプなしでは、窓の途中で出力が終わっても 30 秒先へシークするため。
対処: タイムスタンプを有効にしておき、不要なら Go 側で捨てます。
無音で「ご視聴ありがとうございました」が出る
発生条件: 無音や雑音だけの区間を VAD なしで渡す。
観測点: 発話がないはずの区間に定型句や「(音楽)」が出る。
原因: 発話がない入力でもモデルが文字列を生成する場合があります。今回のログだけでは、その内部要因までは特定できていません。
対処: VAD を有効にします。no_speech_thold などのしきい値調整は今回試していません。
雑音の多い音声で精度が崩れ、処理時間がばらつく
発生条件: SNR が低い音声。今回は SNR 10 dB の白色雑音で small でも CER 12.34%。
観測点: CER の悪化に加え、base で処理時間が 2 倍以上になり、実行ごとにばらつく。
原因の候補: 温度フォールバックによる再デコード。雑音は認識そのものも難しくし、時間の変動には共有 CPU の負荷も関係し得ます。今回、各要因の寄与は分離していません。
対処: モデルを大きくするか、ビーム幅 5(small で 9.71%)を試します。フォールバックを切ると繰り返しループが出たので推奨しません。前処理でのノイズ除去は今回未検証です。
bucky model get が止まる
発生条件: 出力先ディレクトリ(既定は ~/models)が存在しない状態で、非対話環境(CI、パイプ、エージェントからの実行など)から bucky model get tiny を実行する。
観測点: 進捗バーが出ないまま止まる。端末で実行すると次の確認が表示されています。
Directory /tmp/bucky-mtest does not exist.
Would you like to create it? [y/N]: exit=124
原因: ディレクトリ作成の確認を標準入力から待っているため(ネットワークが遅いわけではありません)。
対処: -y を付けると、tiny は 3 秒でダウンロードが終わりました。あらかじめディレクトリを作るか、Hugging Face から直接 curl で取得しても同じファイルです(SHA-256 が一致しました)10:19:2。
起動直後に panic する、または BUCKY_LIB のエラーになる
発生条件: Linux で libffi.so.8 がない。BUCKY_LIB も Load の引数も空。
観測点: panic: ... libffi.so.8 ...(github.com/jupiterrider/ffi.init.0)、または whisper.Load: library path not specified and BUCKY_LIB env variable not set。
原因: libffi は Linux では OS のものを使う 4:4。ライブラリの場所はパスか環境変数で渡す必要がある。
対処: libffi8 をインストールし、コンテナイメージにも含めます。lib/ を配布物に同梱し、BUCKY_LIB を設定します。
計測で確認できていないこと
人の声の録音での CER。合成音声は話速・発音が一定で、実際の会話(言いよどみ、方言、重なり)より易しいため、今回の CER は楽観的な値です。
medium・large 系、量子化モデル、GPU、arm64・macOS・Windows での挙動と速度。
CGo バインディングとクラウド API の精度・速度(実測していません)。
時間は共有 CPU での計測で、専有環境ではより短く安定すると考えられます。
まとめ:CGo なしで十分使える。ただし設定は日本語向けに自分で決める
Go から CGo なしで whisper.cpp を呼ぶ、という bucky の狙いは、v1.1.3 で問題なく実現できていました。CGO_ENABLED=0 でビルドでき、pure Go のデコーダで WAV・MP3・FLAC をそのまま渡せます。ただし実行時には glibc、libffi、lib/ 一式、モデルが必要で、配布の重さはビルドから実行環境側に移るだけです。
日本語の精度は、合成音声の条件で small が CER 2.89%(96 秒の 1 本なら 1.31%)、base が 11%、tiny が 20% でした。人が読む文字起こしには small 以上を選び、雑音や実録音では精度が落ちる前提で評価してください。
設定面では、言語を ja に固定する、長い音声ではタイムスタンプを切らない、無音がありそうなら VAD を有効にする の 3 点を最初に決めておくのがおすすめです。どれも既定値のままだと静かに結果が壊れるタイプの問題でした。
次の一手としては、自分たちの実際の録音を数本用意して、この記事の jabench で small と base の CER を測ってみることをおすすめします。正解テキストを作る手間はかかりますが、合成音声との差がわかれば、モデルの大きさや GPU の要否をかなり具体的に判断できるはずです!
音声合成には Open JTalk 11:1 と HTS voice「NIT ATR503 M001」(nitech_jp_atr503_m001 1.05、Copyright (c) 2003-2012 Nagoya Institute of Technology / 2003-2008 Tokyo Institute of Technology、HTS Working Group 配布、CC BY 3.0)を使用しました。
参考文献
脚注
ardanlabs/bucky(README、v1.1.3 時点。取得日: 2026-10-07) github.com/ardanlabs/bucky/blob/v1.1.3/README.md ↩︎ ↩︎ ↩︎ ↩︎ ↩︎ ↩︎ ↩︎ ↩︎ ↩︎
whisper.cpp v1.9.4 実装(既定値・フォールバック・シーク処理。レビュー時確認: 2026-10-20) github.com/ggml-org/whisper.cpp/blob/v1.9.4/src/w… ↩︎ ↩︎ ↩︎ ↩︎ ↩︎
bucky v1.1.3 whisper.go(CLI の言語・タイムスタンプ設定。レビュー時確認: 2026-10-20) github.com/ardanlabs/bucky/blob/v1.1.3/cmd/whispe… ↩︎ ↩︎
JupiterRider/ffi: A purego binding for libffi(取得日: 2026-10-07) github.com/jupiterrider/ffi/tree/v0.7.0 ↩︎ ↩︎ ↩︎ ↩︎ ↩︎
bucky v1.1.3 go.mod(レビュー時確認: 2026-10-20) github.com/ardanlabs/bucky/blob/v1.1.3/go.mod ↩︎
ebitengine/purego: A library for calling C functions from Go without Cgo(取得日: 2026-10-07) github.com/ebitengine/purego/tree/v0.10.2 ↩︎ ↩︎ ↩︎
bucky v1.1.3 loader.go(レビュー時確認: 2026-10-20) github.com/ardanlabs/bucky/blob/v1.1.3/pkg/loader… ↩︎
ardanlabs/bucky-builder(取得日: 2026-10-07) ardanlabs/bucky-builder ↩︎ ↩︎ ↩︎ ↩︎ ↩︎
ggerganov/whisper.cpp(Hugging Face、モデル一覧と SHA。取得日: 2026-10-07) huggingface.co ↩︎ ↩︎ ↩︎
bucky MODELS.md(取得日: 2026-10-07) github.com/ardanlabs/bucky/blob/v1.1.3/MODELS.md ↩︎ ↩︎
Open JTalk(取得日: 2026-10-07) open-jtalk.sourceforge.net ↩︎ ↩︎
openai/whisper README(取得日: 2026-10-07) openai/whisper ↩︎ ↩︎ ↩︎ ↩︎ ↩︎
whisper.cpp v1.9.4 README(VAD の節。取得日: 2026-10-07) github.com/ggml-org/whisper.cpp/blob/v1.9.4/READM… ↩︎ ↩︎
bucky v1.1.3 vad.go(時刻の型・単位。レビュー時確認: 2026-10-20) github.com/ardanlabs/bucky/blob/v1.1.3/pkg/whispe… ↩︎
bucky v1.1.3 streaming 例(レビュー時確認: 2026-10-20) github.com/ardanlabs/bucky/blob/v1.1.3/examples/s… ↩︎
whisper.cpp v1.9.4 Go bindings README(取得日: 2026-10-07) github.com/ggml-org/whisper.cpp/blob/v1.9.4/bindi… ↩︎ ↩︎
OpenAI Speech to text ガイド(取得日: 2026-10-07) platform.openai.com ↩︎ ↩︎
2 likes
Mentions
@tomgreenwald Thanks, I was looking for this those days. Check Kronk for inference engine, really cool and fast+cache+go. ardanlabs/kronk
[小ネタ] go build 時の ldflags -X オプションの使われ方を調べてみた こんにちは。特撮熱が爆上がり中のbun913と申します。 私は普段QAエンジニアとして働いており、できるだけAI活用による生産性向上を目指して業務を進めておりましたが、最近ついに「理解を後回しにしたらだめだな」と気づきました。 そんな中で、書籍「部下としてのAI」を拝見しました。 note.com その中で著者の牛尾さんが推奨されていたディープコードリーディングという概念にであい、「AIによる業務変革が起こり、AIによるコード生成が行われる時代においても、ちゃんとバックエンドコードが何をしているか読めるようになりたい」と思い、Go言語で書かれたリポジトリのコードリーディングを毎日行うようにしてみました。 note.com そのための仕組みづくりなどを行ったのですが、その点については別途記事を書かせていただければと思います。今日は、以下のリポジトリのコードを読み始めて早速目についた go build のオプションについて調べてみました。 ardanlabs/service ldflags -X ってなんぞや 冒頭でもお話しした↑のリポジトリは、Goやk8sを使った上でどのようにソースコードを構成するかある種お手本のようなリポジトリとなっています。 その中で「どのようにコンテナイメージをビルドしているのかな?」と確認していたところ、go build -ldflags "-X main.build=${BUILD_REF}" と書かれているところがあり目に留まりました。 github.com/ardanlabs/service/blob/88c91946ec9444e… 挙動や説明については以下のブログ記事が非常にわかりやすかったです。 kazuhira-r.hatenablog.com 例えば以下のようなコードがある時に package main import "fmt" var build = "dev" func main() { fmt.Printf("build: %s\n", build) } 以下のようにオプションなしでビルドしてビルド成果物を実行すると以下のような出力になります。 通常のビルド go build -o bin/default main.go 実行する ./bin/default 出力 build: dev が以下のようにオプションをつけることで build の値を上書きできました。 ビルド時に build の値を指定 go build -ldflags "-X main.build=v1.2.3" -o bin/versioned main.go 実行する ./bin/versioned 出力 build: v1.2.3 ユースケースは? 例えば紹介した以下のサービスでは、build というトップレベルの変数をビルド時に上書きすることで、サービスの実行時のログに特定のバージョンを埋め込んでいました。 github.com/ardanlabs/service/blob/88c91946ec9444e… コミットハッシュやコンテナイメージのタグ名などを埋め込んで、どのバージョンのアプリでログがでたかトレースできそうですね。 その他 gh などのCLIツールでバージョン番号などを埋め込んで gh --version などのコマンドで出力できるようにするパターンなども拝見しました。 github.com/cli/cli/blob/f7a06a18a91eef5c0b218b549… なんで ldflags っていう名称なの? お恥ずかしながら私は知らなかったのですが、ldflags という名前は「リンカー用のフラグ」ということでUnix系でお馴染みの命名だそうです。 ld コマンドというものがあり、コンパイル済みの部品を繋いで実行ファイルなどを作るリンカーの役割をするとのことです。 こちらはAIに出してもらったイメージで正確性に欠けるかもしれませんが、わかりやすかったので記載します。 main.c → コンパイル → main.o ─┐ ├─ ldでリンク → 実行ファイル util.c → コンパイル → util.o ─┘ Go の -ldflags は、Go のリンカーである go tool link に引数を渡すためのオプションです。リンカー用のフラグということで、ldflags という名前のイメージがつきました。 以下、go tool link の説明より抜粋しております。 -X importpath.name=value Set the value of the string variable in importpath named name to value. This is only effective if the variable is declared in the source code either uninitialized or initialized to a constant string expression. -X will not work if the initializer makes a function call or refers to other variables. Note that before Go 1.5 this option took two separate arguments. pkg.go.dev まとめ AI時代こそちゃんとコードを読もうとコードリーディングを始めました 早速 go で書かれたリポジトリを見ていたところ、 go build のさいの ldflags -X というオプションがあり気になって調べてみました 再掲となりますが、以下のブログが非常にわかりやすかったのですが、私なりに言葉の由来やユースケースなどを調べてみたので本記事に記載させていただきました Goでのビルド時に使う、-ldflagsフラグと-Xについて調べてみた(go tool link) 以上、最後までお読みいただきありがとうございました。
Go 服务扩成两个副本,模型也要多加载一份吗? Kronk 有进程内 SDK,也有独立模型服务。 假设两个副本用 SDK 各自加载模型,扩应用也涉及模型资源;若调用独立服务,客户端变多不等于模型服务副本翻倍。 先分清扩容单元,不直接推算内存或成本。 据文档推演,未实测: ardanlabs/kronk
Stop Using Python Wrappers: Build Hardware-Accelerated Local AI Directly in Go with Kronk Stop Using Python Wrappers: Build Hardware-Accelerated Local AI Directly in Go with Kronk TL;DR Python remains the default language for AI prototyping, but deploying Python services into production brings massive dependency bloat, fragile environments, and heavy memory overhead. Kronk bridges this gap by bringing hardware-accelerated local inference directly to Go applications using native C/C++ bindings for llama.cpp, whisper.cpp, and stable-diffusion.cpp. It allows backend engineers to run multimodal AI workloads with zero Python runtime dependencies and bare-metal performance. --- Key Features & Technical Highlights - Native Hardware Acceleration: Direct CGO bindings tap straight into Metal (macOS), CUDA (NVIDIA), and Vulkan/CPU backends without inter-process communication (IPC) latency. - Unified Multimodal Engine: Manage text generation (LLMs via llama.cpp), speech-to-text (STT via whisper.cpp), and image generation (diffusion models via stable-diffusion.cpp) through a single, idiomatic Go API. - Production-Ready Model Server: Includes an out-of-the-box, lightweight OpenAI-compatible HTTP inference server designed for low-latency concurrent requests. - Minimal Footprint: Compile down to lean, production-ready Go binaries—ditching massive Conda/Virtualenv setups and brittle Docker containers. - First-Class Concurrency: Exploit Go's native goroutines to manage batch processing, request queuing, and context cancellations seamlessly across hardware devices. --- Quick Start You can pull and build Kronk or integrate it as a package directly into your Go workflow. 1. Run the Pre-Built Inference Server Clone and run Kronk to spin up a local OpenAI-compatible inference server: git clone https://github.com/ardanlabs/kronk cd kronk # Build the binary with hardware acceleration (e.g., Metal for macOS) make build-metal # Launch server pointing to your GGUF model ./bin/kronk-server --model-path ./models/llama-3-8b-instruct.Q4_K_M.gguf --port 8080 2. Native Go Code Example Integrate local inference directly inside your backend logic: package main import ( "context" "fmt" "log" "github.com/ardanlabs/kronk/pkg/llama" ) func main() { ctx := context.Background() // Initialize the model runner model, err := llama.NewModel("./models/llama-3-8b-instruct.Q4_K_M.gguf", llama.WithGPU(true)) if err != nil { log.Fatalf("Failed to load model: %v", err) } defer model.Close() // Run local inference prompt := "Explain Go channels in two concise sentences." resp, err := model.Generate(ctx, prompt, llama.WithMaxTokens(100)) if err != nil { log.Fatalf("Inference failed: %v", err) } fmt.Printf("Response:\n%s\n", resp.Text) } --- Why It Matters If you are an infrastructure architect, platform engineer, or backend developer building modern web services, orchestrating a fleet of heavy Python inference microservices just to run small-to-medium GGUF models is unnecessary architectural debt. Kronk is ideal for: - Edge & Embedded Systems: Single-binary deployment for local edge nodes with restricted disk and RAM limits. - Go-Centric Stacks: Teams already maintaining Kubernetes controllers, microservices, or APIs in Go who want native local AI without introducing Python/Node runtimes. - Cost-Conscious Teams: Eliminating cloud API overhead by running local, private models directly on internal hardware without sacrificing concurrency. --- 🛠️ Recommended AI Stack & Resources - Cloud GPU Hosting: Scale up open-source models without the enterprise price tag. Use RunPod to deploy on-demand GPUs starting at fractions of a dollar per hour. - AI Code Editor: Supercharge your Golang & CGO development with Cursor—the AI-native code editor engineered for 10x developer productivity. - Production Database: Pair your local AI engines with scalable vector search and serverless persistence using Supabase or Pinecone. - Global Dev Network: Ensure high-speed model weights downloads, unrestricted Hugging Face access, and low-latency remote server administration via WD-Gold Network. - Newsletter: Subscribe to Local AI Daily to get curated breakdowns of emerging open-source AI tooling, edge inference benchmarks, and deployment patterns. - Sponsorship & Partnerships: Want to showcase your developer tool or AI infrastructure project to thousands of engineers? Contact: [fengdahui195@gmail.com](mailto:fengdahui195@gmail.com).
無音や弱い雑音に「ご視聴ありがとうございました」と書く Whisper を、CGo なしの Go で飼いならす はじめに:Go のサービスに、日本語の文字起こしを「ローカルで」足したい Go で書いたサービスや CLI に、日本語音声の文字起こしを組み込みたい。でも C コンパイラ入りのビルド環境は持ち込みたくないし、音声をクラウドに送るのもできれば避けたい。そんなときの選択肢として気になっていたのが、Ardan Labs の bucky です。whisper.cpp の C API をほぼそのまま Go から呼べて、しかも CGo を使わず、WAV・MP3・FLAC のデコードまで pure Go で済ませると README にあります 1。 この記事では bucky v1.1.3 を実際に入れて、「CGo なしで本当に動くのか」「日本語の精度はどのくらいか」を、whisper の tiny・base・small の 3 モデルで測りました。先に答えを書くと、small を言語 ja 固定で動かせば、合成音声の 12 文で文字誤り率(CER)2.89%、96 秒の 1 ファイルでは 1.31% まで下がりました。今回の合成音声では、tiny と base は「下書き」程度の結果でした。そして、日本語で使うときに踏みやすい設定の罠が 3 つ見つかりました(既定言語が英語、タイムスタンプを切ると長い音声の途中が抜ける、無音で「ご視聴ありがとうございました」が出る)。 対象読者: Go で音声認識をローカルに組み込みたい方、CGo の whisper バインディングやクラウド API と比べて検討している方 扱う範囲: CGo なしで読み込める仕組み(purego・libffi・bucky install -lib が取ってくるもの)、日本語 CER と CPU 実行時間の実測、VAD・タイムスタンプ・ストリーミング例の挙動、選び方、失敗しやすい条件 扱わない範囲: medium 以上のモデル、GPU(CUDA・Vulkan・Metal)、量子化モデル、話者分離、クラウド API の実測 読み終えると: 自分の CPU で同じ計測を再現でき、bucky を採用するか、どのモデルと設定で始めるかを判断できます 結論:small + 言語固定 + タイムスタンプ有効から始める 精度を求めるなら small を選び、日本語とわかっている音声では言語を ja に固定します。whisper.cpp の C API の既定言語は en で、言語を指定しない例では、今回の日本語音声から英文が出力されました 2。bucky の CLI は既定で自動判定するため、この点は API と異なります 3。 30 秒を超える音声ではタイムスタンプを有効のままにします。 NoTimestamps = 1(bucky の CLI では --segments なし)だと、今回の 96 秒の音声で複数の文が抜けました(CER 45.67%)。CER は誤りの割合であり、欠落した文の割合ではありません。 無音や雑音だけの区間がある音声では VAD(Silero)の利用を検討します。今回の無音・弱い雑音のテストでは不要な出力が消え、長尺音声の先頭タイムスタンプも VAD が検出した発話開始位置に揃いました。ただし、句読点が消えるなどの変化もあり、あらゆる音声での改善を保証するものではありません。 「CGo なし」は「依存なし」ではありません。今回の Linux 環境では glibc、libffi.so.8、それに bucky install -lib で取る whisper.cpp の共有ライブラリ(約 18 MB)とモデルファイルが実行時に必要です 4。 C ツールチェーンを持ち込みたくない、音声を外に出したくない、GPU なしの CPU で十分、という条件なら bucky は有力です。反対に、雑音の多い実録音で高精度が必要なら、大きいモデルや GPU、クラウド API との比較が必要です。 前提・検証環境 項目 内容 検証日 2026-10-07(JST) 対象 bucky v1.1.3(Apache-2.0、go 1.26)、whisper.cpp v1.9.4(bucky の既定 pin) 実行環境 Linux x86_64、Intel Xeon Processor 8 vCPU(他の処理と共有、load average 約 5〜7)、Go 1.26.8 ライブラリ bucky-builder の whisper-v1.9.4-bin-ubuntu-cpu-x64.tar.gz(CPU 版) モデル ggml-tiny.bin(75 MiB)、ggml-base.bin(142 MiB)、ggml-small.bin(466 MiB)、ggml-silero-v5.1.2.bin(VAD) 推論設定 4 スレッド、greedy(ビーム幅 5 は別途比較)、言語 ja 固定 音声 Open JTalk 1.11 で合成した日本語 12 文(自作の文、声は CC BY 3.0 の HTS voice) 成功条件 CGO_ENABLED=0 でビルドしたバイナリが文字起こしでき、CER と時間を出力できること bucky は whisper.h を 1 対 1 で写した高水準 API と、pure Go の音声デコーダを提供するライブラリです 1:1。README によれば llama.cpp を扱う yzma の兄弟プロジェクトで、Ardan Labs の Kronk に OpenAI 互換の文字起こしエンドポイントを持たせることを目標にしています 1:2。v1.1.3 の README に記載された共有ライブラリの既定 pin は whisper.cpp v1.9.4 です 1:3。Go の要求バージョンと依存バージョンは同タグの go.mod でも確認できます 5。 計測した CPU は他の処理と共有していたため、時間は揺れます。そこで各条件を 3 回ずつ実行して中央値を採り、各出力ファイルの先頭に load average を記録しました。認識結果のテキストは 3 回とも同じでした。時間の絶対値より、モデル間・条件間の比を見てください。 仕組み:CGo なしで whisper.cpp を呼べる理由と、それでも残る依存 結論から書くと、bucky は ビルド時には C コンパイラを使わず、実行時に共有ライブラリを dlopen して関数ポインタを呼ぶ 方式です。だから CGO_ENABLED=0 でビルドできますが、そのぶん実行時に必要なファイルが増えます。 purego で開き、libffi で呼ぶ README には「CGo は使わず、すべての C 呼び出しは purego と JupiterRider/ffi を通る」とあります 1:4。purego は Cgo なしで Go から C 関数を呼ぶためのライブラリで、C コンパイラなしでクロスコンパイルできることを利点に挙げています 6。JupiterRider/ffi は purego の上に libffi のバインディングを載せたもので、libffi が受け持つのは、関数の型情報から各プラットフォームの呼び出し規約に合わせた呼び出しを組み立てる部分です 4:1。 v1.1.3 のソース(pkg/loader/loader.go)を追うと、ライブラリのパスを決めて開く処理は次の流れです 7。続いて、whisper API の各シンボルを解決します。 whisper.Load(path) が loader.LoadLibrary(path, "whisper") を呼びます。path が空なら環境変数 BUCKY_LIB を使い、OS に応じて libwhisper.so・whisper.dll・libwhisper.dylib のファイル名を組み立てます。 そのファイルを ffi.Load で開きます。中身は purego の Dlopen です。 whisper_full などの関数を 1 つずつ lib.Prep("シンボル名", 戻り値の型, 引数の型...) で解決し、libffi の呼び出し記述子として保持します。 3 の Prep を使っているのがポイントです。whisper_full は設定構造体 whisper_full_params を 値渡し で受け取ります。bucky はこの構造体を Go 側で WhisperFullParams としてパディングまで含めて写し、libffi 用の型記述も持っています。構造体の値渡しは ABI ごとにレジスタとスタックの使い方が違うので、その面倒を libffi に任せる設計です。 libffi 自体については、ffi の README に「Linux などでは OS の libffi を使い、macOS と Windows では埋め込んだバイナリを実行時に展開する」と書かれています 4:2。実際に Linux で libffi.so.8 を読めない状態(unshare -m で別のマウント名前空間を作り、そこでだけ /dev/null を被せた状態)にして起動すると、main に入る前にパッケージ初期化で落ちました。 panic: /lib/x86_64-linux-gnu/libffi.so.8: file too short goroutine 1 [running]: github.com/jupiterrider/ffi.init.0() /home/box/go/pkg/mod/github.com/jupiterrider/ffi@v0.7.0/init.go:26 +0x21d exit=2 Debian・Ubuntu なら libffi8 パッケージです 4:3。Python が入っている環境ならたいてい既にありますが、distroless のような最小コンテナでは自分で入れる必要があります。 bucky install -lib が取ってくるもの bucky install -lib ./lib を実行すると、bucky-builder の GitHub Releases から OS・CPU に合ったアーカイブを取ってきて展開します 8。今回の環境では次のようになりました。 installing whisper.cpp version v1.9.4@sha256:56e7b4ae8508ca674535f36e92378c2257975a03b523ebc15e93dd89d816a77e to ./lib [##################################################] 100.0% - 6/6 MiB (107.48 MiB/s) done. バージョン文字列の @sha256: 以降は、bucky-builder が公開するマニフェストのハッシュです。bucky はこの値をソースに埋め込んで固定しており、マニフェストに書かれたアーカイブのハッシュと照合してから展開します。展開後の lib/bucky-install.json には、取得元 URL、アーカイブの SHA-256、展開した各ファイルの SHA-256 が記録されていました。 中身は約 18 MB で、libwhisper.so.1.9.4、libggml.so、libggml-base.so に加えて、libggml-cpu-haswell.so・libggml-cpu-skylakex.so・libggml-cpu-sapphirerapids.so など 14 個の CPU 変種 が入っています。bucky-builder の README によれば、Linux x64 の CPU 版は GGML_CPU_ALL_VARIANTS=ON でビルドしてあり、実行時に CPU に合った変種を選ぶ方式です 8:1。libwhisper.so の RUNPATH は $ORIGIN になっていて、同じディレクトリの ggml を見つけられます。CUDA 版と Vulkan 版のアーカイブも用意されています 8:2。 この「実行時に選ぶ」を行うのが whisper.Init(path) です。中で ggml の ggml_backend_load_all_from_path を呼び、ディレクトリ内のバックエンドを登録します。Load だけで Init を呼ばないと CPU バックエンドが登録されないので、順番は Load → Init → モデル読み込みです。bucky system で見ると、この環境では AVX-512 と AMX を持つ CPU 向けの sapphirerapids 版が選ばれていました。 -- Library -- path: /workspace/zenn-articles/bucky/work/lib load_backend: loaded CPU backend from /workspace/zenn-articles/bucky/work/lib/libggml-cpu-sapphirerapids.so version: 1.9.4-dev 「CGo なし」は「静的バイナリ」ではない 後で載せる計測プログラムを CGO_ENABLED=0 でビルドし、ldd と go version -m で確かめました。 build exit=0 linux-vdso.so.1 (0x00007fd5b5a20000) libdl.so.2 => /lib/x86_64-linux-gnu/libdl.so.2 (0x00007fd5b5a0a000) libpthread.so.0 => /lib/x86_64-linux-gnu/libpthread.so.0 (0x00007fd5b5a05000) libc.so.6 => /lib/x86_64-linux-gnu/libc.so.6 (0x00007fd5b5811000) /lib64/ld-linux-x86-64.so.2 (0x00007fd5b5a22000) dep github.com/ardanlabs/bucky v1.1.3 h1:kWdFifRFPexm2qGykD5EUjU/LMiYkMJ78siPBTQC47I= dep github.com/ebitengine/purego v0.10.2 h1:W809HbnvzAxgdm+aOvlSekrM16wGCdT/e76+9tS7gzE= dep github.com/jupiterrider/ffi v0.7.0 h1:RKsl6Ascal+3kyAqR5Qcbp83LceQMLc1VZbPfHWoNzs= build CGO_ENABLED=0 今回の構成では、ビルドは C コンパイラなしで通りましたが、出来上がったバイナリは libc と動的ローダーに依存していました。これは今回の Linux バイナリの確認結果であり、CGO_ENABLED=0 や dlopen の使用だけから、すべての環境のリンク方式を一般化するものではありません。つまり bucky の「CGo なし」がうれしいのは ビルド側(C ツールチェーン不要、クロスコンパイルしやすい 6:1)で、実行側 では glibc、libffi、lib/ 一式、モデルファイルを配る必要があります。Go の単一バイナリを scratch イメージにコピーするだけ、という運用にはなりません。 導入:ライブラリとモデルを用意する 作業ディレクトリを 1 つ作り、CLI、ライブラリ、モデルを置きました。CLI は go install で入ります。モデルは bucky の model get でも取れますが、後述する理由から Hugging Face の ggerganov/whisper.cpp から直接取得し、SHA1 を確認しました 9。 CLI(Go 1.26 系が必要。GOTOOLCHAIN=auto なら自動で取得される) GOBIN=$PWD/bin go install github.com/ardanlabs/bucky@v1.1.3 ./bin/bucky install -lib ./lib export BUCKY_LIB=$PWD/lib モデルは Hugging Face から直接取得する mkdir -p models for m in tiny base small; do curl -L -o models/ggml-$m.bin huggingface.co done curl -L -o models/ggml-silero-v5.1.2.bin huggingface.co sha1sum models/*.bin 465707469ff3a37a2b9b8d8f89f2f99de7299dac models/ggml-base.bin a372f48dcf0bd9e4330eef2802bc46e061c19634 models/ggml-silero-v5.1.2.bin 55356645c2b361a969dfd0ef2c5a50d530afd8d5 models/ggml-small.bin bd577a113a864445d4c299885e0cb97d4ba92b5f models/ggml-tiny.bin tiny・base・small の SHA1 は Hugging Face のモデル一覧に載っている値と一致しました 9:1。bucky の MODELS.md は small を「多くの本番用途の既定」、tiny を「単体テストやスモークテスト向け」と位置づけています 10。 自分のプログラムからは、Go モジュールに bucky を追加して使います。今回の計測プログラムは go get github.com/ardanlabs/bucky@v1.1.3 golang.org/x/text で依存を入れ、間接依存として purego v0.10.2 と ffi v0.7.0 が入りました。 検証用の日本語音声を作る 日本語の精度を測るには、正解テキストと音声の組が必要です。権利関係をはっきりさせるため、今回は 自分で書いた 12 文を Open JTalk で合成 しました。Open JTalk 1.11 は Modified BSD ライセンスで、HTS voice 1.05 も同じサイトで配布されています 11。声には名古屋工業大学の nitech_jp_atr503_m001(CC BY 3.0)を使いました。辞書は Debian の open-jtalk-mecab-naist-jdic(BSD-3-Clause)です。 12 文には、日常文のほか、同音異義語(差す・刺す、開店・回転)、数字と日付、敬語、くだけた会話、IT 用語を混ぜています。Open JTalk の読み(「3月15日」を「サンガツジューゴニチ」と読むかなど)は事前に全文確認しました。 s01 今日は朝から雨が降っていたので、駅まで傘を差して歩きました。 s02 会議の資料は共有フォルダに置いてあるので、明日までに目を通してください。 s03 サーバーのメモリ使用量が増えたため、キャッシュの設定を見直しました。 s04 新しい店舗は3月15日に開店し、初日には約200人が訪れました。 s05 名古屋から新幹線に乗って、京都で友人と待ち合わせをしました。 s06 お忙しいところ恐れ入りますが、ご確認のほどよろしくお願いいたします。 s07 音声認識の精度は、話す速さや周囲の雑音によって大きく変わります。 s08 ねえ、昨日の映画どうだった?思ったより面白かったよね。 s09 来週の火曜日は午後2時から、オンラインで打ち合わせを行う予定です。 s10 このアプリはインターネットに接続しなくても、端末の中だけで文字起こしができます。 s11 図書館で借りた本を返し忘れていたことに気づき、慌てて窓口へ向かいました。 s12 検証用の音声は合成したものなので、実際の会話よりも聞き取りやすい点に注意が必要です。 この 12 文から、条件を変えた音声を作ります。clean は Open JTalk の標準速度、fast は話速 1.4 倍、noisy は clean に SNR 10 dB の白色雑音を足したものです。さらに 12 文を 1.5 秒の無音で連結し、前後に 3 秒の無音を付けた 96 秒の長尺ファイルと、その MP3・FLAC 版、音声を含まない 10 秒のファイル 2 種類(完全な無音と、弱い雑音だけ)も作りました。 #!/usr/bin/env bash Open JTalk で日本語の検証音声を合成し、条件違いのファイルを作る。 使い方: bash code/corpus/make_corpus.sh 音声ファイル.htsvoice 出力ディレクトリ set -euo pipefail VOICE=$1 OUT=$2 DIC=/var/lib/mecab/dic/open-jtalk/naist-jdic HERE=$(cd "$(dirname "$0")" && pwd) TOOLS="python3 $HERE/audio_tools.py" mkdir -p "$OUT"/{clean,fast,noisy,long,silence} while IFS=$'\t' read -r id text; do printf '%s\n' "$text" > "$OUT/clean/$id.txt" open_jtalk -x "$DIC" -m "$VOICE" -ow "$OUT/clean/$id.wav" "$OUT/clean/$id.txt" open_jtalk -x "$DIC" -m "$VOICE" -r 1.4 -ow "$OUT/fast/$id.wav" "$OUT/clean/$id.txt" $TOOLS noise "$OUT/clean/$id.wav" "$OUT/noisy/$id.wav" 10 done < "$HERE/sentences.tsv" 12 文を 1.5 秒の無音で連結した長尺ファイル(前後 3 秒も無音)と、その MP3 / FLAC 版 $TOOLS concat "$OUT/long/long.wav" 1.5 "$OUT"/clean/s*.wav ffmpeg -loglevel error -y -i "$OUT/long/long.wav" -codec:a libmp3lame -b:a 64k "$OUT/long/long.mp3" ffmpeg -loglevel error -y -i "$OUT/long/long.wav" -codec:a flac "$OUT/long/long.flac" 音声を含まないファイル(ハルシネーション確認用) $TOOLS silence "$OUT/silence/zero10s.wav" 10 $TOOLS silence "$OUT/silence/hiss10s.wav" 10 0.01 echo "corpus ready: $(find "$OUT" -name '*.wav' | wc -l) wav files" 波形の加工は numpy を使う小さなスクリプトに分けました。雑音は乱数の seed を固定しているので、同じ音声が再生成されます。実際に別ディレクトリへ作り直し、計測に使ったファイルとバイト単位で一致することを確認しています。 #!/usr/bin/env python3 """検証用 WAV を加工する小さな道具(標準ライブラリ + numpy)。 noise IN OUT SNR_DB 白色雑音を指定 SNR で加える(seed 固定) concat OUT GAP_S IN... 無音 GAP_S 秒を挟んで連結し、前後にも 3 秒の無音を付ける silence OUT SEC [RMS] 無音(RMS>0 なら弱い白色雑音のみ)を作る """ import sys import wave import numpy as np RATE = 48000 def read(path): with wave.open(path, "rb") as w: assert w.getnchannels() == 1 and w.getsampwidth() == 2, path rate = w.getframerate() x = np.frombuffer(w.readframes(w.getnframes()), dtype=np.int16) return x.astype(np.float64) / 32768.0, rate def write(path, x, rate): y = np.clip(x, -1.0, 1.0) with wave.open(path, "wb") as w: w.setnchannels(1) w.setsampwidth(2) w.setframerate(rate) w.writeframes((y * 32767.0).astype(np.int16).tobytes()) def main(): cmd = sys.argv[1] rng = np.random.default_rng(20261007) if cmd == "noise": x, rate = read(sys.argv[2]) snr_db = float(sys.argv[4]) p_sig = np.mean(x**2) p_noise = p_sig / (10 ** (snr_db / 10)) write(sys.argv[3], x + rng.normal(0, np.sqrt(p_noise), x.size), rate) elif cmd == "concat": gap = float(sys.argv[3]) parts, rate = [], RATE for p in sys.argv[4:]: x, rate = read(p) parts += [x, np.zeros(int(gap * rate))] pad = np.zeros(3 * rate) write(sys.argv[2], np.concatenate([pad] + parts[:-1] + [pad]), rate) elif cmd == "silence": sec = float(sys.argv[3]) rms = float(sys.argv[4]) if len(sys.argv) > 4 else 0.0 write(sys.argv[2], rng.normal(0, rms, int(sec * RATE)) if rms > 0 else np.zeros(int(sec * RATE)), RATE) else: sys.exit(f"unknown command {cmd}") if name == "main": main() 合成音声は話速も発音も一定で、人の会話より聞き取りやすいはずです。以降の CER は この合成音声・話者・文集合で得られた値 として読んでください。認識精度は高めに出ている可能性がありますが、CER の上限・下限や実録音での性能を示す値ではありません。人の声の録音(CC0 などの音源)での計測は今回行っていません。 jabench:bucky で文字起こしして CER を出す 計測には、bucky の API を直接呼ぶ小さなプログラム jabench を書きました。音声ファイルを pure Go でデコードし、whisper.Full で文字起こしして、正解との CER と時間を出します。以下のコードはコメント内の source=... のパスに保存してください。音声生成には Open JTalk・辞書・HTS voice・Python 3・NumPy・FFmpeg が別途必要です。 掲載コードでは、-segments は表示だけでなく認識設定も変えます。省略すると NoTimestamps = 1、付けるとタイムスタンプ有効です。計測条件を保持するためこの実装を掲載していますが、長尺音声では必ず -segments を付けてください。 // jabench は bucky(CGo なしの whisper.cpp バインディング)で日本語音声を // 文字起こしし、文字誤り率(CER)と処理時間を表示する検証用プログラム。 // // go run ./jabench -model ../models/ggml-tiny.bin -ref corpus/sentences.tsv ../audio/clean/*.wav package main import ( "bufio" "flag" "fmt" "log" "os" "path/filepath" "runtime" "strings" "time" "unsafe" "github.com/ardanlabs/bucky/pkg/audio" "github.com/ardanlabs/bucky/pkg/utils" "github.com/ardanlabs/bucky/pkg/whisper" ) func main() { var ( modelPath = flag.String("model", "", "GGML 形式の whisper モデル(必須)") lang = flag.String("lang", "ja", 言語コード。"auto" で自動判定、"-" で whisper.cpp の既定値のまま) threads = flag.Int("threads", 4, "CPU スレッド数") beam = flag.Int("beam", 0, "0 より大きければビームサーチ(ビーム幅)") vadModel = flag.String("vad", "", "Silero VAD の GGML モデル。指定すると whisper.cpp 内蔵の VAD を有効にする") refPath = flag.String("ref", "", "参照テキストの TSV(id と text をタブ区切り)。id は音声ファイル名から拡張子を除いたもの") segments = flag.Bool("segments", false, "セグメントごとのタイムスタンプを表示する") ) flag.Parse() if *modelPath == "" || flag.NArg() == 0 { log.Fatalf("usage: jabench -model ggml-xxx.bin [flags] audio...") } libPath := os.Getenv("BUCKY_LIB") if err := whisper.Load(libPath); err != nil { // dlopen + シンボル解決 log.Fatalf("whisper.Load: %v", err) } if err := whisper.Init(libPath); err != nil { // libggml-cpu-*.so をレジストリに登録 log.Fatalf("whisper.Init: %v", err) } whisper.LogSet(whisper.LogSilent()) refs, err := loadRefs(*refPath) if err != nil { log.Fatal(err) } t0 := time.Now() ctx, err := whisper.InitFromFileWithParams(*modelPath, whisper.ContextDefaultParams()) if err != nil { log.Fatalf("load model: %v", err) } defer whisper.Free(ctx) fmt.Printf("# model=%s load=%s threads=%d beam=%d vad=%t lang=%s\n", filepath.Base(*modelPath), time.Since(t0).Round(time.Millisecond), *threads, *beam, *vadModel != "", *lang) var sumEdits, sumChars int var sumAudio, sumFull time.Duration for _, path := range flag.Args() { samples, decodeDur, err := decodeFile(path) if err != nil { log.Fatal(err) } audioDur := time.Duration(float64(len(samples)) / whisper.SampleRate * float64(time.Second)) p := params(*threads, *beam, *segments) var keep whisper.StringRefs switch *lang { case "-": // Language フィールドに触らない(whisper.cpp の既定値は "en") case "auto": keep.SetLanguage(&p, "auto") default: keep.SetLanguage(&p, *lang) } var vadPath *byte if *vadModel != "" { // bucky v1.1.3 には VadModelPath 用のヘルパーがないので C 文字列を自前で用意する vadPath, _ = utils.BytePtrFromString(*vadModel) p.Vad = 1 p.VadModelPath = uintptr(unsafe.Pointer(vadPath)) } start := time.Now() if err := whisper.Full(ctx, p, samples); err != nil { log.Fatalf("%s: %v", path, err) } fullDur := time.Since(start) keep.KeepAlive() runtime.KeepAlive(vadPath) var text strings.Builder for i := range whisper.FullNSegments(ctx) { seg := whisper.FullGetSegmentText(ctx, i) text.WriteString(seg) if *segments { fmt.Printf(" [%s -> %s] %s\n", cs(whisper.FullGetSegmentT0(ctx, i)), cs(whisper.FullGetSegmentT1(ctx, i)), strings.TrimSpace(seg)) } } hyp := strings.TrimSpace(text.String()) id := strings.TrimSuffix(filepath.Base(path), filepath.Ext(path)) detected := whisper.LangStr(whisper.FullLangID(ctx)) cerCol := "-" if ref, ok := refs[id]; ok { e, n := cer(ref, hyp) sumEdits += e sumChars += n cerCol = fmt.Sprintf("%.3f", float64(e)/float64(max(n, 1))) } sumAudio += audioDur sumFull += fullDur fmt.Printf("%s\taudio=%.2fs\tdecode=%dms\tfull=%dms\trtf=%.3f\tlang=%s\tcer=%s\t%s\n", id, audioDur.Seconds(), decodeDur.Milliseconds(), fullDur.Milliseconds(), fullDur.Seconds()/audioDur.Seconds(), detected, cerCol, hyp) } if sumChars > 0 { fmt.Printf("# TOTAL files=%d audio=%.1fs full=%.1fs rtf=%.3f chars=%d edits=%d CER=%.2f%%\n", flag.NArg(), sumAudio.Seconds(), sumFull.Seconds(), sumFull.Seconds()/sumAudio.Seconds(), sumChars, sumEdits, 100*float64(sumEdits)/float64(sumChars)) } } // params は検証で使う whisper_full_params を作る。 func params(threads, beam int, segments bool) whisper.WhisperFullParams { strategy := whisper.SamplingGreedy if beam > 0 { strategy = whisper.SamplingBeamSearch } p := whisper.FullDefaultParams(strategy) if beam > 0 { p.BeamSearchBeamSize = int32(beam) } p.NThreads = int32(threads) p.PrintProgress = 0 p.PrintRealtime = 0 p.PrintTimestamps = 0 if !segments { p.NoTimestamps = 1 } return p } // decodeFile は WAV / MP3 / FLAC を pure Go でデコードし、16 kHz モノラルの float32 にする。 func decodeFile(path string) ([]float32, time.Duration, error) { f, err := os.Open(path) if err != nil { return nil, 0, err } defer f.Close() start := time.Now() samples, err := audio.Decode(f) if err != nil { return nil, 0, fmt.Errorf("%s: %w", path, err) } return samples, time.Since(start), nil } func loadRefs(path string) (map[string]string, error) { refs := map[string]string{} if path == "" { return refs, nil } f, err := os.Open(path) if err != nil { return nil, err } defer f.Close() sc := bufio.NewScanner(f) for sc.Scan() { if id, text, ok := strings.Cut(sc.Text(), "\t"); ok { refs[id] = text } } return refs, sc.Err() } // cs はセグメント時刻(10 ミリ秒単位)を mm:ss.mmm にする。 func cs(t int64) string { ms := t * 10 return fmt.Sprintf("%02d:%02d.%03d", ms/60000, ms/1000%60, ms%1000) } いくつか、bucky の API を使ううえでのポイントがあります。 言語の指定: WhisperFullParams の Language は C 文字列へのポインタです。Go の文字列から C 文字列を作り、whisper.Full が終わるまで GC に回収されないように保つ必要があります。bucky の StringRefs はこれを肩代わりしてくれる型で、SetLanguage で設定し、呼び出し後に KeepAlive を呼びます。空文字を渡すと NULL になり、whisper.cpp 側で自動判定になります。 VAD モデルのパス: v1.1.3 の StringRefs には VadModelPath 用のヘルパーがありません。そこで utils.BytePtrFromString で C 文字列を作り、runtime.KeepAlive で生存期間を保っています。 タイムスタンプの単位: FullGetSegmentT0・FullGetSegmentT1 は 10 ミリ秒単位の整数です。 デコード: audio.Decode は先頭のバイト列から WAV・MP3・FLAC を判定し、モノラル化と 16 kHz へのリサンプリングまで行います 1:5。 CER は、正解と認識結果を NFKC で正規化し、句読点・記号・空白を除いてから、文字単位の編集距離を正解の文字数で割ります。全角と半角の差は吸収しますが、漢字とかな、漢数字と算用数字の違いは誤りとして数えます。表記ゆれも誤りに入るので、意味が通っていても 0% にはなりにくい厳しめの指標です。 package main import ( "strings" "unicode" "golang.org/x/text/unicode/norm" ) // normalize は CER 計算用に表記を揃える。NFKC で全角・半角を統一し、 // 句読点・記号・空白を落とす。漢字とかな、算用数字と漢数字の違いは残す。 func normalize(s string) []rune { var b strings.Builder for _, r := range norm.NFKC.String(s) { if unicode.IsPunct(r) || unicode.IsSymbol(r) || unicode.IsSpace(r) { continue } b.WriteRune(r) } return []rune(b.String()) } // editDistance は文字単位のレーベンシュタイン距離(置換・削除・挿入)を返す。 func editDistance(ref, hyp []rune) int { prev := make([]int, len(hyp)+1) cur := make([]int, 0, len(hyp)+1) for j := range prev { prev[j] = j } for i, rc := range ref { cur = append(cur[:0], i+1) for j, hc := range hyp { cost := 1 if rc == hc { cost = 0 } cur = append(cur, min(prev[j+1]+1, cur[j]+1, prev[j]+cost)) } prev, cur = cur, prev } return prev[len(hyp)] } // cer は (編集回数, 参照文字数) を返す。CER = 編集回数 / 参照文字数。 func cer(ref, hyp string) (edits, refLen int) { r, h := normalize(ref), normalize(hyp) return editDistance(r, h), len(r) } 正規化の方針はテストで固定しました。 package main import "testing" func TestCER(t *testing.T) { tests := []struct { ref, hyp string edits, chars int }{ {"今日は雨です。", "今日は雨です", 0, 6}, // 句読点は無視 {"午後2時から", "午後2時から", 0, 6}, // 全角数字は NFKC で揃う {"午後2時から", "午後二時から", 1, 6}, // 漢数字との違いは 1 文字の置換 {"傘を差して", "傘をさして", 1, 5}, // 漢字とかなの違いも誤りに数える {"ねえ、どうだった?", "ねえどうだった", 0, 7}, // 記号・空白は無視 {"", "ご視聴ありがとうございました", 14, 0}, // 無音への幻覚は挿入だけになる } for _, tt := range tests { e, n := cer(tt.ref, tt.hyp) if e != tt.edits || n != tt.chars { t.Errorf("cer(%q, %q) = (%d, %d), want (%d, %d)", tt.ref, tt.hyp, e, n, tt.edits, tt.chars) } } } code ディレクトリを Go モジュールとして初期化していない場合は、先に次のコマンドで依存を用意します。 cd code go mod init example.com/bucky-ja go get github.com/ardanlabs/bucky@v1.1.3 golang.org/x/text テストと、tiny で 1 文だけ動かした結果です(code ディレクトリで実行)。 go test ./jabench BUCKY_LIB=$PWD/../lib go run ./jabench -model ../models/ggml-tiny.bin -ref corpus/sentences.tsv ../audio/clean/s12.wav ok example.com/bucky-ja/jabench (cached) load_backend: loaded CPU backend from /workspace/zenn-articles/bucky/work/code/../lib/libggml-cpu-sapphirerapids.so model=ggml-tiny.bin load=124ms threads=4 beam=0 vad=false lang=ja s12 audio=7.66s decode=2ms full=295ms rtf=0.039 lang=ja cer=0.050 検証用の音声は合成したものなので、実際の回はよりも聞き取りやすい点に注意が必要です。 TOTAL files=1 audio=7.7s full=0.3s rtf=0.039 chars=40 edits=2 CER=5.00% lang=ja は whisper.cpp が最終的に使った言語、cer= はこのファイルの CER です。この文は tiny でも比較的よく認識できましたが、それでも「会話」が「回は」になり、40 文字中 2 文字の誤り(CER 5.00%)が出ています。次の節で見るとおり、12 文全体では tiny の誤りはずっと多くなります。 結果:tiny・base・small の CER と速度 12 文(正解 381 文字、音声 73.5 秒)を 1 ファイルずつ処理したときの結果です。時間は whisper.Full の合計(3 回の中央値)、RTF は処理時間を音声長で割った値で、1 より小さければ実時間より速いことを示します。モデル読み込み時間は別に示しました。表の「音声 73.5 秒」は clean/noisy の合計であり、fast の音声長は異なります。RTF は各条件の実際の音声長を分母にしています。以下の表・ログは検証時の測定記録です。 モデル 条件 処理時間(中央値) RTF 読み込み 誤り数/正解文字数 CER tiny clean 3.5 秒 0.048 103 ms 76/381 19.95% tiny fast(1.4 倍速) 3.6 秒 0.068 102 ms 62/381 16.27% tiny noisy(SNR 10 dB) 4.4 秒 0.060 107 ms 188/381 49.34% base clean 7.2 秒 0.098 170 ms 43/381 11.29% base fast 7.4 秒 0.140 158 ms 38/381 9.97% base noisy 16.3 秒(13.1〜25.0) 0.222 163 ms 163/381 42.78% small clean 27.6 秒 0.376 477 ms 11/381 2.89% small fast 24.3 秒 0.460 578 ms 13/381 3.41% small noisy 28.8 秒 0.392 525 ms 47/381 12.34% 計測条件を揃えて追試する場合の実行例です。以下は追試用コマンドであり、新たな実行ログではありません(code ディレクトリで実行)。短い 12 ファイルの比較は、掲載プログラムの既定どおりタイムスタンプなし・VAD なしです。 export BUCKY_LIB="$PWD/../lib" CGO_ENABLED=0 go build -o ../bin/jabench ./jabench for m in tiny base small; do for condition in clean fast noisy; do for run in 1 2 3; do uptime ../bin/jabench -model ../models/ggml-$m.bin -lang ja -threads 4 \ -ref corpus/sentences.tsv ../audio/$condition/*.wav done done done 連結した long.wav に対応する参照テキスト(id は long) awk -F '\t' 'BEGIN { printf "long\t" } { printf "%s", $2 } END { printf "\n" }' \ corpus/sentences.tsv > corpus/long.tsv 長尺は -segments でタイムスタンプを有効にする ../bin/jabench -model ../models/ggml-small.bin -lang ja -threads 4 \ -segments -ref corpus/long.tsv ../audio/long/long.wav VAD ありの比較では上記に次を追加する -vad ../models/ggml-silero-v5.1.2.bin モデルの差は「誤りの質」に出る tiny の CER は約 20% で、文としては読めないことが多いです。たとえば s01 は「今日は柔らかめが降っていたので、平きまで重させて…」となりました。base は 11% 前後で、要点はつかめるものの固有名詞や漢字の選択が崩れます。small は 2.89% で、残った 11 文字の誤りは次の 3 種類でした。 種類 例(正解→認識) 誤り文字数 同音異義 差して→刺して、開店→回転 3 表記ゆれ 置いて→おいて、恐れ入り→おそれいり 4 聞き誤り 音声→本性、本を→方の 4 表記ゆれは意味として誤りではないので、実質的な誤りはさらに少ないと言えます。一方、同音異義の「開店→回転」は文脈で判断すべき誤りで、small でも残りました。OpenAI の whisper リポジトリも、性能は言語によって大きく異なると注意しています 12。パラメータ数は tiny が 39M、base が 74M、small が 244M で 12:1、日本語ではこの規模の差がそのまま効いている印象です。 雑音には弱く、base は遅くもなる SNR 10 dB の白色雑音を足すと、全モデルで CER が大きく悪化しました(small でも 12.34%)。目立ったのは base の処理時間で、clean の 7.2 秒から 16.3 秒へ倍以上に増え、3 回の幅も 13.1〜25.0 秒とばらつきました。 これは whisper.cpp の 温度フォールバック によるものと考えられます。whisper.cpp の既定値では、トークン列のエントロピーを用いた失敗判定(entropy_thold = 2.4)や平均対数確率(logprob_thold = -1.0)などに基づき、温度を 0.2 ずつ上げて同じ区間をデコードし直します(temperature_inc = 0.2)2:1。entropy_thold は文字列の圧縮率ではありません。平均対数確率による判定には無音確率も関係します。bucky の CLI には --temperature-inc があるので、0 にしてフォールバックを止めて比べました(base、noisy の 12 ファイル、CLI はファイルごとにモデルを読み込み直すため読み込み時間込み)。これは先の jabench の 3 回中央値とは別の計測であり、両表の時間を直接比較することはできません。 設定 合計時間 CER 既定(temperature_inc = 0.2) 10,292 ms 42.78% --temperature-inc 0 8,985 ms 72.70% フォールバックを止めると速くはなりますが、CER は 72.70% まで悪化しました。1 文では「ハイアス」が 34 回繰り返され、最後に壊れた UTF-8 の文字が出ています。フォールバックは繰り返しループから抜けるための安全装置なので、時間のばらつきが気になっても止めないほうがよい、というのが今回の結論です(なお、既定設定の CLI の CER が jabench と一致したことで、計算方法の確認にもなりました)。 ビーム幅 5 は少し良くなり、約 1.5 倍遅くなる small でビームサーチ(ビーム幅 5)も試しました。clean は CER 2.89% → 2.62%(27.6 秒 → 40.7 秒)、noisy は 12.34% → 9.71%(28.8 秒 → 43.2 秒)でした。雑音下では効果がありますが、今回の clean での改善幅は小さい結果でした。統計的な有意差は評価していません。まず greedy で始め、雑音のある音声で精度が足りないときに検討すればよさそうです。 短いファイルを何本も処理すると割高になる 同じ 12 文を 1 本につないだ 96 秒のファイルでは、small の処理時間は 9〜12 秒(RTF 約 0.09〜0.13)でした。12 本に分けたときの 27.6 秒より明らかに速いです。whisper は音声を 30 秒の窓で処理するので 12:2、5〜8 秒の短いファイルでも 1 窓分の計算がかかると考えると説明がつきます。12 本の合計 27.6 秒を 12 で割った約 2.3 秒が、96 秒(4 窓分)を処理した約 9 秒の 4 分の 1 とほぼ同じなのも、この解釈と合っています。 短い発話を大量に処理するシステムでは、ファイルを細かく切るほど割高になります。今回の結果では、1 ファイルにまとめることで処理時間を短縮できました。後述の VAD も無音が多い音声での候補ですが、短い発話を個別に処理する場合に必ず高速化するとは限りません。 デコード時間とメモリ pure Go のデコードは、5〜8 秒の WAV で 1〜6 ms でした。96 秒のファイルでは WAV が 25〜36 ms、FLAC が 203〜233 ms、MP3(64 kbps)が 826 ms で、いずれも推論時間に比べれば小さい値です。MP3 と FLAC でも small の CER は 1.31% で WAV と同じでした(tiny は MP3 だけ 20.21% と 1 文字増えました)。96 秒を処理したときの最大 RSS は tiny 276 MiB、base 339 MiB、small 741 MiB でした。 VAD・タイムスタンプ・ストリーミング:日本語で使う前に知っておきたいこと bucky は whisper.cpp の C API をそのまま写しているので、VAD、セグメントごとのタイムスタンプ、トークン単位の情報も Go から扱えます 1:6。ただし既定値が日本語向けとは限らず、組み合わせによって結果が大きく変わりました。 言語を指定しないと英訳される whisper.cpp v1.9.4 の whisper_full_default_params 2:2 では、language の既定値が "en" です。whisper.h のコメントにあるとおり、自動判定にしたい場合は NULL、空文字、"auto" のいずれかを渡す必要があります。bucky の examples/hello や examples/streaming は言語を設定していないので、日本語の音声を渡すと英語が出てきます。実際に Language に触らずに 3 文を処理すると、small は次のように出力しました。 s01 audio=5.33s decode=1ms full=1661ms rtf=0.312 lang=en cer=1.929 It was raining in the morning, so I walked all the way to the station. 今回の出力は日本語の文字起こしではなく、内容を英語で表したものになっています。掲載した s01 の cer=1.929 は約 192.9% です。原稿には別に CER 175.53% と記録されていますが、掲載ログの s01 の値とは一致しません。挿入が多い場合、CER は 100% を超えることがあります。なお、既定値の Translate は false なので、「言語 en の指定」と「翻訳タスクを明示する設定」は区別してください 2:3。自動判定("auto")にすると tiny も small も ja を正しく選び、CER は ja 固定と同じでした。ただし small では言語判定の分だけ時間が増え、3 文で 7.7 秒から 9.7 秒になりました。日本語だとわかっているなら固定するのが確実です。bucky の CLI(bucky whisper transcribe)は --lang の既定が空文字(自動判定)なので、今回 CLI を使った条件では日本語で出力されました 3:1。 タイムスタンプを切ると、長い音声の途中が抜ける 今回いちばん驚いたのがこれです。96 秒の音声を small で処理したとき、タイムスタンプを有効にすると CER 1.31% でしたが、NoTimestamps = 1 にすると 45.67% に悪化しました。CLI でも同じことが起きます。--segments なしで実行すると、出力は次のように途中の文が抜けました。 今日は朝から雨が降っていたので、 駅まで傘を刺して歩きました。会議の資料は共有フォルダにおいてあるので、 明日までに目を通してください。サーバーのメモリ使用量が増えたため、 キャッシュの設定を見直しました。お忙しいところおそれいりますが、 ご確認のほどよろしくお願いいたします。来週の火曜日は午後2時から、 オンラインで打ち合わせを行う予定です。このアプリはインターネットに接続しなくても、 端末の中だけで文字起こしができます。も聞き取りやすい点に注意が必要です。 同じファイルで --segments を付けると、12 文すべてが出ます。 [00:00.000 -> 00:07.000] 今日は朝から雨が降っていたので、 駅まで傘を刺して歩きました。 [00:07.000 -> 00:14.000] 会議の資料は共有フォルダにおいてあるので、 明日までに目を通してください。 [00:14.000 -> 00:22.000] サーバーのメモリ使用量が増えたため、 キャッシュの設定を見直しました。 [00:22.000 -> 00:32.000] 新しい店舗は3月15日に回転し、初日には約200人が訪れました。 [00:32.000 -> 00:40.000] 名古屋から新幹線に乗って、 京都で友人と待ち合わせをしました。 [00:40.000 -> 00:47.000] お忙しいところ恐れ入りますが、 ご確認のほどよろしくお願いいたします。 [00:47.000 -> 00:55.000] 音声認識の精度は、話す速さや周囲の雑音によって大きく変わります。 [00:55.000 -> 01:01.000] ねえ、昨日の映画どうだった、思ったより面白かったよね。 [01:01.000 -> 01:09.000] 来週の火曜日は午後2時から、 オンラインで打ち合わせを行う予定です。 [01:09.000 -> 01:17.000] このアプリはインターネットに接続しなくても、 端末の中だけで文字起こしができます。 [01:17.000 -> 01:25.000] 図書館で借りた方を返し忘れていたことに気づき、 慌てて窓口へ向かいました。 [01:25.000 -> 01:34.000] 検証用の音声は合成したものなので、 実際の会話よりも聞き取りやすい点に注意が必要です。 この欠落を説明する実装上の手がかりが、whisper.cpp のシーク処理です 2:4。whisper は 30 秒の窓ごとにデコードし 12:3、通常はデコード結果のタイムスタンプトークンを見て「どこまで読んだか」を決め、次の窓の開始位置にします。v1.9.4 の whisper_full_with_state では、no_timestamps(または single_segment)が有効なとき、終了トークンが出た時点で シーク幅を窓の長さ(30 秒)に固定 します。そのため、1 つの窓の途中でモデルが出力を終えると、残りの発話は読まれずに次の 30 秒へ進みます。実際に抜けたのは 22〜40 秒、47〜61 秒、77〜85 秒付近の文で、窓の後半にあたる位置でした。 bucky の README の hello 例 1:7、examples/transcribe、CLI の非 --segments モードは、いずれも NoTimestamps = 1 を設定しています。30 秒以内なら欠落しないと保証できるわけではありませんが、会議録音などの長い音声を処理するなら、タイムスタンプは有効のまま にして、表示が不要なら Go 側で使わなければよいだけです。なお、後述の VAD を有効にした場合は、タイムスタンプなしでも CER 10.5% でした。軽減はされますが、解決にはなりません。 VAD:無音の幻聴を止め、タイムスタンプを実際の発話に合わせる whisper.cpp の VAD は、音声から発話区間を検出し、その区間だけを whisper に渡すことで処理を速くできる仕組みです 13。bucky では WhisperFullParams の Vad と VadModelPath を設定すれば whisper.Full の中で使えます。VAD だけを単独で呼ぶ API もあるので、まず発話区間がどう検出されるかを見てみます。 // vadseg は bucky の VAD API(whisper.cpp 内蔵の Silero VAD)だけを使い、 // 音声ファイル中の発話区間を表示する。whisper モデルは読み込まない。 // // go run ./vadseg ../models/ggml-silero-v5.1.2.bin ../audio/long/long.wav package main import ( "fmt" "log" "os" "time" "github.com/ardanlabs/bucky/pkg/audio" "github.com/ardanlabs/bucky/pkg/whisper" ) func main() { if len(os.Args) != 3 { log.Fatalf("usage: vadseg ggml-silero-xxx.bin audio") } libPath := os.Getenv("BUCKY_LIB") if err := whisper.Load(libPath); err != nil { log.Fatal(err) } if err := whisper.Init(libPath); err != nil { log.Fatal(err) } whisper.LogSet(whisper.LogSilent()) cparams := whisper.VadDefaultContextParams() cparams.NThreads = 4 vctx, err := whisper.VadInitFromFileWithParams(os.Args[1], cparams) if err != nil { log.Fatal(err) } defer whisper.VadFree(vctx) f, err := os.Open(os.Args[2]) if err != nil { log.Fatal(err) } defer f.Close() samples, err := audio.Decode(f) if err != nil { log.Fatal(err) } p := whisper.VadDefaultParams() fmt.Printf("# threshold=%.2f min_speech=%dms min_silence=%dms pad=%dms\n", p.Threshold, p.MinSpeechDurationMs, p.MinSilenceDurationMs, p.SpeechPadMs) start := time.Now() segs, err := whisper.VadSegmentsFromSamples(vctx, p, samples) if err != nil { log.Fatal(err) } defer whisper.VadFreeSegments(segs) var speech float64 n := whisper.VadSegmentsNSegments(segs) for i := range n { // t0 / t1 は 10 ミリ秒単位で返る t0 := whisper.VadSegmentsGetSegmentT0(segs, i) / 100 t1 := whisper.VadSegmentsGetSegmentT1(segs, i) / 100 speech += float64(t1 - t0) fmt.Printf("speech %2d: %6.2fs -> %6.2fs\n", i+1, t0, t1) } total := float64(len(samples)) / whisper.SampleRate fmt.Printf("# segments=%d speech=%.1fs / audio=%.1fs (%.0f%%) vad=%dms\n", n, speech, total, 100*speech/total, time.Since(start).Milliseconds()) } load_backend: loaded CPU backend from /workspace/zenn-articles/bucky/work/code/../lib/libggml-cpu-sapphirerapids.so threshold=0.50 min_speech=250ms min_silence=100ms pad=30ms speech 1: 3.39s -> 5.44s speech 2: 5.83s -> 7.81s speech 3: 10.24s -> 12.99s speech 4: 13.38s -> 15.33s speech 5: 17.70s -> 20.13s speech 6: 20.55s -> 22.46s speech 7: 24.87s -> 27.97s speech 8: 28.39s -> 30.88s speech 9: 33.28s -> 35.23s speech 10: 35.68s -> 37.98s speech 11: 40.35s -> 42.30s speech 12: 42.72s -> 45.31s speech 13: 47.68s -> 49.18s speech 14: 49.67s -> 53.09s speech 15: 55.49s -> 55.84s speech 16: 56.19s -> 57.57s speech 17: 57.92s -> 59.77s speech 18: 62.15s -> 64.25s speech 19: 64.64s -> 67.07s speech 20: 69.47s -> 72.35s speech 21: 72.80s -> 75.36s speech 22: 77.73s -> 81.09s speech 23: 81.47s -> 83.33s speech 24: 85.73s -> 88.41s speech 25: 88.87s -> 92.51s segments=25 speech=57.5s / audio=96.0s (60%) vad=201ms 96 秒のうち発話は約 6 割で、先頭 3 秒の無音もきちんと除かれています(VadSegmentsGetSegmentT0 は float32 型で 10 ミリ秒単位の値を返すため、100 で割っています 14)。12 文に対して 25 区間になっているのは、読点の位置の短い間でも区切られるためです。VAD の処理は 0.2〜0.4 秒程度でした(実行のたびに負荷で変わります)。 VAD が最も効いたのは、音声を含まないファイルです。VAD なしで 10 秒の無音・弱い雑音を渡すと、どのモデルも何かを「聞き取って」しまいました。 モデル 完全な無音 弱い雑音(RMS 0.01) tiny (笑) ご視聴ありがとうございました base (音楽) (水溶け) small (音楽) (音) whisper の幻聴としてよく知られる「ご視聴ありがとうございました」が tiny で再現しました。VAD を有効にすると、全モデルで出力は空になり、処理時間も 0.5〜1.7 秒から 21〜33 ms に下がりました。録音の前後に無音がある、マイクを開きっぱなしにする、といった用途では、VAD は今回のような不要な出力への有力な対策です。発話の取りこぼしも評価したうえで採用してください。なお、何もデコードされなかった場合、FullLangID が返す言語は en になっていました。発話をデコードしていない場合、この値を言語判定の結果として扱うべきではありません。 96 秒の長尺に VAD を併用すると、タイムスタンプも変わります。VAD なしでは先頭の 3 秒の無音も含めて 00:00.000 から始まり、区切りも 7〜10 秒の粗いものでしたが、VAD ありでは最初のセグメントは、VAD が検出した最初の発話区間の開始と同じ 00:03.390 から始まりました。 [00:03.390 -> 00:09.510] 今日は朝から雨が降っていたので 駅まで傘を刺して歩きました [00:09.510 -> 00:14.800] 会議の資料は共有フォルダにおいて あるので明日までに目を通して [00:14.800 -> 00:19.690] ください サーバーのメモリー使用料が増えた [00:19.690 -> 00:28.500] ためキャッシュの設定を見直しました 新しい店舗は3月15日に回転し初 一方で、句読点が消え、セグメントが文の途中(「回転し初 / 日には」)で切れるようになりました。CER も 1.31% から 2.10% へ少し悪化しています(「メモリー使用料」のような語の選択の違い)。発話区間をつないで whisper に渡すので、文のまとまりの手がかりが弱くなるのだと思われます。今回の音声では文間の無音が短いため、速度の差もほとんどありませんでした(9.0 秒前後で同程度)。 使い分けとしては、無音や雑音の区間が多い音声、発話区間を考慮したタイムスタンプが欲しい場合は VAD あり、文単位の読みやすい出力が欲しく、無音が少ない場合は VAD なし が目安です。なお、whisper.cpp の README では新しい Silero VAD のモデルも案内されているので 13:1、v5.1.2 以外での挙動は未確認です。 ストリーミング例は「窓分割」で、日本語では境界で文が欠ける bucky には examples/streaming と examples/streaming-realtime があります 1:8。examples/streaming は音声を 10 秒の窓(1 秒の重なり)に分けて順に whisper.Full を呼び、前の窓の末尾トークンを次の窓のプロンプトに渡す実装でした。真のストリーミング推論ではなく、窓分割による疑似ストリーミングです 15。この説明と以下の計測は examples/streaming を対象とし、examples/streaming-realtime は未計測です。 そのまま small で 96 秒の音声を渡すと、言語未設定のため英語が出て、「The new tempo is 3/15」が 3 回繰り返されるなど崩れました。そこで例に SetLanguage(&wparams, "ja") の 1 行を足し(計測用に CER の表示も追加)、測ってみると、small の CER は 23.36%、処理時間は読み込み込みで 21.7 秒でした。1 本で処理した場合(1.31%、9〜12 秒)より精度も速度も悪く、文が窓の境界で欠けたり(s03 と s12 の前半が抜けました)、別の文に置き換わったりしています。tiny では 28.87% でした。 この実装でリアルタイム字幕のような途中結果を得るには窓分割を使いますが、日本語で精度を保つには、窓の長さや VAD での区切りを調整する前提で考えたほうがよさそうです。録音済みファイルなら、1 本のまま whisper.Full に渡すのが最も精度が高い結果でした。 比較と選び方:bucky・CGo の whisper バインディング・クラウド API 最終的な選択を左右するのは、精度そのものより「ビルドと配布をどこで重くするか」と「音声を外に出せるか」でした。CGo のバインディングとクラウド API は今回実測していないので、以下はドキュメントにもとづく比較です。 比較軸 bucky(purego・libffi) whisper.cpp 公式の Go バインディング(CGo) クラウド API(例: OpenAI の文字起こし) ビルド C コンパイラ不要、CGO_ENABLED=0 で通る libwhisper.a をビルドし、C_INCLUDE_PATH と LIBRARY_PATH を設定してリンクする 16 HTTP クライアントだけ 実行時に必要なもの glibc、libffi、lib/(約 18 MB)、モデル(small で 466 MiB) モデルに加え、ビルド構成に応じた OS・C/C++ ランタイムなど(libwhisper 自体は静的リンクする手順) ネットワークと API キー 音声の所在 手元から出ない 手元から出ない 外部に送る。1 ファイル 25 MB までの制限がある 17 推論ハードウェアの切り替え CPU・CUDA・Vulkan などの対応アーカイブを選ぶ。GPU 用ドライバー等の実行時要件は別途確認 8:3 自分でビルド設定を変える 不要 whisper.cpp の更新 bucky-builder の新しいビルドと bucky の pin を待つ(v1.1.3 は v1.9.4) 自分で好きなバージョンをビルドできる 不要 精度と速度 今回の実測: small で CER 2.89%、RTF 約 0.1〜0.4(4 スレッド) 未計測(モデル・設定・バックエンド等を揃えた比較が必要) 未計測 bucky が向くのは、Go のビルドパイプラインに C ツールチェーンを持ち込みたくない場合、複数 OS 向けにクロスコンパイルしたい場合 6:2、音声を外部に出せない場合です。CUDA 版・Vulkan 版のビルド済みアーカイブが用意されているので 8:4、GPU へ移る道があるのも利点です(今回 GPU 版は試していません)。 CGo のバインディングが向くのは、すでに C のビルド環境があり、whisper.cpp を自分でパッチしたりバージョンを細かく選んだりしたい場合です。公式手順は静的ライブラリ libwhisper.a をリンクする形なので 16:1、libwhisper の共有ライブラリを別配布せずリンクしたい場合には候補になります。ただし、完全な静的バイナリになるとは限らず、モデルファイルも別途必要です。 クラウド API が向くのは、手元に計算資源を置きたくない場合、少量の音声を手早く処理したい場合です。ただし音声データを外部に送ること、ファイルサイズの上限があることが前提になります 17:1。 モデルの選び方については、今回の数値からは、日本語で文字起こし結果を人が読むなら small 以上、tiny と base は「キーワードが含まれるかを粗く調べる」程度の用途が現実的だと考えます。small の RTF は 4 スレッドで 0.1〜0.4 なので、同じ RTF を単純に外挿すると 1 時間の音声で 6〜24 分程度の計算になります。ただし、1 時間の実録音は未計測であり、負荷や音声条件による変化を含まない概算です。 失敗しやすい条件と切り分け 実際に踏んだ、または再現した問題を、発生条件、最初に見るところ、原因、対処の順にまとめます。 日本語なのに英語が出る 発生条件: Language を設定せずに whisper.Full を呼ぶ。bucky の hello 例や streaming 例をそのまま使う。 観測点: 出力が英文になる。whisper.LangStr(whisper.FullLangID(ctx)) が en を返す。 原因: whisper.cpp v1.9.4 の既定言語が "en" のため。whisper 自体は多言語モデルで、言語を指定すれば日本語を書き起こせます 12:4。 対処: StringRefs.SetLanguage(&p, "ja") で固定します。言語が混在するなら "auto" にします(判定の分だけ遅くなります)。 長い音声の途中が抜ける 発生条件: 30 秒を超える音声で NoTimestamps = 1 にする。CLI で --segments を付けない。 観測点: 出力が短すぎる。文と文のつながりが不自然になる(今回は「…見直しました。お忙しいところ…」のように途中が飛びました)。 原因: タイムスタンプなしでは、窓の途中で出力が終わっても 30 秒先へシークするため。 対処: タイムスタンプを有効にしておき、不要なら Go 側で捨てます。 無音で「ご視聴ありがとうございました」が出る 発生条件: 無音や雑音だけの区間を VAD なしで渡す。 観測点: 発話がないはずの区間に定型句や「(音楽)」が出る。 原因: 発話がない入力でもモデルが文字列を生成する場合があります。今回のログだけでは、その内部要因までは特定できていません。 対処: VAD を有効にします。no_speech_thold などのしきい値調整は今回試していません。 雑音の多い音声で精度が崩れ、処理時間がばらつく 発生条件: SNR が低い音声。今回は SNR 10 dB の白色雑音で small でも CER 12.34%。 観測点: CER の悪化に加え、base で処理時間が 2 倍以上になり、実行ごとにばらつく。 原因の候補: 温度フォールバックによる再デコード。雑音は認識そのものも難しくし、時間の変動には共有 CPU の負荷も関係し得ます。今回、各要因の寄与は分離していません。 対処: モデルを大きくするか、ビーム幅 5(small で 9.71%)を試します。フォールバックを切ると繰り返しループが出たので推奨しません。前処理でのノイズ除去は今回未検証です。 bucky model get が止まる 発生条件: 出力先ディレクトリ(既定は ~/models)が存在しない状態で、非対話環境(CI、パイプ、エージェントからの実行など)から bucky model get tiny を実行する。 観測点: 進捗バーが出ないまま止まる。端末で実行すると次の確認が表示されています。 Directory /tmp/bucky-mtest does not exist. Would you like to create it? [y/N]: exit=124 原因: ディレクトリ作成の確認を標準入力から待っているため(ネットワークが遅いわけではありません)。 対処: -y を付けると、tiny は 3 秒でダウンロードが終わりました。あらかじめディレクトリを作るか、Hugging Face から直接 curl で取得しても同じファイルです(SHA-256 が一致しました)10:19:2。 起動直後に panic する、または BUCKY_LIB のエラーになる 発生条件: Linux で libffi.so.8 がない。BUCKY_LIB も Load の引数も空。 観測点: panic: ... libffi.so.8 ...(github.com/jupiterrider/ffi.init.0)、または whisper.Load: library path not specified and BUCKY_LIB env variable not set。 原因: libffi は Linux では OS のものを使う 4:4。ライブラリの場所はパスか環境変数で渡す必要がある。 対処: libffi8 をインストールし、コンテナイメージにも含めます。lib/ を配布物に同梱し、BUCKY_LIB を設定します。 計測で確認できていないこと 人の声の録音での CER。合成音声は話速・発音が一定で、実際の会話(言いよどみ、方言、重なり)より易しいため、今回の CER は楽観的な値です。 medium・large 系、量子化モデル、GPU、arm64・macOS・Windows での挙動と速度。 CGo バインディングとクラウド API の精度・速度(実測していません)。 時間は共有 CPU での計測で、専有環境ではより短く安定すると考えられます。 まとめ:CGo なしで十分使える。ただし設定は日本語向けに自分で決める Go から CGo なしで whisper.cpp を呼ぶ、という bucky の狙いは、v1.1.3 で問題なく実現できていました。CGO_ENABLED=0 でビルドでき、pure Go のデコーダで WAV・MP3・FLAC をそのまま渡せます。ただし実行時には glibc、libffi、lib/ 一式、モデルが必要で、配布の重さはビルドから実行環境側に移るだけです。 日本語の精度は、合成音声の条件で small が CER 2.89%(96 秒の 1 本なら 1.31%)、base が 11%、tiny が 20% でした。人が読む文字起こしには small 以上を選び、雑音や実録音では精度が落ちる前提で評価してください。 設定面では、言語を ja に固定する、長い音声ではタイムスタンプを切らない、無音がありそうなら VAD を有効にする の 3 点を最初に決めておくのがおすすめです。どれも既定値のままだと静かに結果が壊れるタイプの問題でした。 次の一手としては、自分たちの実際の録音を数本用意して、この記事の jabench で small と base の CER を測ってみることをおすすめします。正解テキストを作る手間はかかりますが、合成音声との差がわかれば、モデルの大きさや GPU の要否をかなり具体的に判断できるはずです! 音声合成には Open JTalk 11:1 と HTS voice「NIT ATR503 M001」(nitech_jp_atr503_m001 1.05、Copyright (c) 2003-2012 Nagoya Institute of Technology / 2003-2008 Tokyo Institute of Technology、HTS Working Group 配布、CC BY 3.0)を使用しました。 参考文献 脚注 ardanlabs/bucky(README、v1.1.3 時点。取得日: 2026-10-07) github.com/ardanlabs/bucky/blob/v1.1.3/README.md ↩︎ ↩︎ ↩︎ ↩︎ ↩︎ ↩︎ ↩︎ ↩︎ ↩︎ whisper.cpp v1.9.4 実装(既定値・フォールバック・シーク処理。レビュー時確認: 2026-10-20) github.com/ggml-org/whisper.cpp/blob/v1.9.4/src/w… ↩︎ ↩︎ ↩︎ ↩︎ ↩︎ bucky v1.1.3 whisper.go(CLI の言語・タイムスタンプ設定。レビュー時確認: 2026-10-20) github.com/ardanlabs/bucky/blob/v1.1.3/cmd/whispe… ↩︎ ↩︎ JupiterRider/ffi: A purego binding for libffi(取得日: 2026-10-07) github.com/jupiterrider/ffi/tree/v0.7.0 ↩︎ ↩︎ ↩︎ ↩︎ ↩︎ bucky v1.1.3 go.mod(レビュー時確認: 2026-10-20) github.com/ardanlabs/bucky/blob/v1.1.3/go.mod ↩︎ ebitengine/purego: A library for calling C functions from Go without Cgo(取得日: 2026-10-07) github.com/ebitengine/purego/tree/v0.10.2 ↩︎ ↩︎ ↩︎ bucky v1.1.3 loader.go(レビュー時確認: 2026-10-20) github.com/ardanlabs/bucky/blob/v1.1.3/pkg/loader… ↩︎ ardanlabs/bucky-builder(取得日: 2026-10-07) ardanlabs/bucky-builder ↩︎ ↩︎ ↩︎ ↩︎ ↩︎ ggerganov/whisper.cpp(Hugging Face、モデル一覧と SHA。取得日: 2026-10-07) huggingface.co ↩︎ ↩︎ ↩︎ bucky MODELS.md(取得日: 2026-10-07) github.com/ardanlabs/bucky/blob/v1.1.3/MODELS.md ↩︎ ↩︎ Open JTalk(取得日: 2026-10-07) open-jtalk.sourceforge.net ↩︎ ↩︎ openai/whisper README(取得日: 2026-10-07) openai/whisper ↩︎ ↩︎ ↩︎ ↩︎ ↩︎ whisper.cpp v1.9.4 README(VAD の節。取得日: 2026-10-07) github.com/ggml-org/whisper.cpp/blob/v1.9.4/READM… ↩︎ ↩︎ bucky v1.1.3 vad.go(時刻の型・単位。レビュー時確認: 2026-10-20) github.com/ardanlabs/bucky/blob/v1.1.3/pkg/whispe… ↩︎ bucky v1.1.3 streaming 例(レビュー時確認: 2026-10-20) github.com/ardanlabs/bucky/blob/v1.1.3/examples/s… ↩︎ whisper.cpp v1.9.4 Go bindings README(取得日: 2026-10-07) github.com/ggml-org/whisper.cpp/blob/v1.9.4/bindi… ↩︎ ↩︎ OpenAI Speech to text ガイド(取得日: 2026-10-07) platform.openai.com ↩︎ ↩︎
Kronk: Embed llama.cpp, whisper.cpp, and Stable Diffusion Directly into Native Go Binaries TL;DR Stop juggling clunky Python microservices or multi-container orchestrations just to execute model inference in production. Kronk from Ardan Labs provides a robust, idiomatic Go framework for native, hardware-accelerated local AI inference using llama.cpp, whisper.cpp, and stable-diffusion.cpp. It delivers a single static binary deployment model with zero external runtime dependencies, dramatically reducing operational footprint and inference latency. --- Key Technical Features - Unified CGo Bindings: Seamlessly interfaces with state-of-the-art C/C++ inference engines (llama.cpp, whisper.cpp, stable-diffusion.cpp) directly from your Go runtime. - True Hardware Acceleration: Built-in support for Apple Metal, NVIDIA CUDA, and Vulkan backends right out of the box—no Python or Torch overhead. - Production-Ready Server & Embedded Engine: Use Kronk as a standalone, multi-modal model server with OpenAI-compatible endpoints, or import it as an in-process Go library. - Zero-Dependency Deployments: Ship a single compiled Go binary to production, avoiding complex Python virtual environments, heavy Docker images, and brittle glue code. - Multi-Modal Capabilities: Generate text with LLMs, transcribe audio with Whisper, and generate imagery with Stable Diffusion via clean, concurrent Go APIs. --- Quick Start You can easily run Kronk as a standalone server or embed it directly into your Go code. 1. Running the Model Server Clone and build Kronk with hardware acceleration enabled (e.g., Metal on macOS or CUDA on Linux): git clone https://github.com/ardanlabs/kronk cd kronk # Build the binary go build -tags metal ./cmd/kronk # Run inference server loading a local GGUF model ./kronk server --model-path ./models/llama-3-8b-instruct.Q4_K_M.gguf --port 8080 2. Embedded Go Inference Example Embed text completion directly inside your backend services: package main import ( "context" "fmt" "log" "github.com/ardanlabs/kronk/pkg/llama" ) func main() { ctx := context.Background() // Initialize the model engine with GPU acceleration engine, err := llama.NewEngine(llama.Config{ ModelPath: "./models/llama-3-8b-instruct.Q4_K_M.gguf", NGPULayers: 33, }) if err != nil { log.Fatalf("Failed to load model: %v", err) } defer engine.Close() // Stream completions with native Go channels respCh, err := engine.Generate(ctx, "Explain Raft consensus in Go:") if err != nil { log.Fatal(err) } for chunk := range respCh { fmt.Print(chunk.Text) } } --- Why It Matters Deploying AI pipelines typically involves bridging Go-based backend systems with containerized Python runtimes, causing high memory usage, cold-start latency, and tricky cross-service communication. Kronk eliminates the Python tax entirely. If you are a Go engineer building on-premise AI agents, edge computing applications, or high-performance cloud APIs, Kronk lets you harness the speed of native C++ kernels with Go’s world-class concurrency patterns and tooling. --- 🛠️ Recommended AI Stack & Resources - Cloud GPU Hosting: Need high-performance compute to train, fine-tune, or benchmark models? Spin up on-demand H100s, A100s, and RTX 4090s instantly with RunPod. - AI Code Editor: Maximize engineering throughput using Cursor, the AI-first code editor built for rapid software development. - Production Vector DB: Ship reliable retrieval-augmented generation (RAG) at scale with Supabase or Pinecone. - Global Dev Network: Ensure ultra-fast, uninterrupted model weights downloads, seamless Hugging Face access, and low-latency remote machine management via WD-Gold Network. - Newsletter CTA: Never miss a breakthrough in local inference and infrastructure. Subscribe to Local AI Daily for curated open-source engineering deep dives. - Sponsorship & Partnerships: Want to showcase your developer tools or infrastructure to thousands of AI builders? Get in touch at [fengdahui195@gmail.com](mailto:fengdahui195@gmail.com).