Mac上のLLMで、ラジオの英語を日本語字幕にする
海外のラジオを聞きながら、日本語訳も追いたい。その翻訳をMac上のLLMで行うのが、個人開発アプリ「方寸」のラジオ機能です。
音声認識で確定した英文を、Qwen3.5またはTranslateGemmaへ渡し、日本語を字幕へ追加します。翻訳にはMLX向けの4bitモデルを使い、Mac上で推論します。画面からモデルを切り替え、いま聞いている文を優先して訳す構成です。
流れは 受信音声 → Parakeetで英語字幕 → Qwen3.5 / TranslateGemmaで日本語訳 の二段階です。
この記事では、二つのモデル系列への入力方法、翻訳処理の分離、字幕との同期を中心に紹介します。Flutterの局ダイヤル、地域局の地図、録音管理も扱います。対象は2026年10月13日時点のコードで、実モデルの精度や速度を比較した記事ではありません。
左の大きな円で局を選び、右に英語と日本語を置きます。英語は、音声の進みに合わせて、読まれた単語の色が変わります。前の文は小さく残るので、聞き逃した場所を見返せます。
この動画の字幕は紹介用の自作例文です。UIの操作デモであり、音声認識や翻訳の速度を測定した動画ではありません。 撮影用の独立した応答を使い、無音の音声ファイルはプレーヤーの時計を進めるために使っています。実際の放送や既存の録音の字幕を、そのまま動画へ載せたものではありません。
1. Qwen3.5とTranslateGemmaを、Mac上で翻訳に使う
翻訳の選択肢は次のとおりです。
| 選択肢 | 翻訳への入力 | 使用する配布物 |
|---|---|---|
| TranslateGemma 12B | 翻訳専用テンプレート | MLX向け4bit版 |
| TranslateGemma 4B | 翻訳専用テンプレート | MLX向け4bit版 |
| Qwen3.5 9B | 翻訳指示・thinking無効 | MLX向け4bit版 |
| Qwen3.5 4B | 翻訳指示・thinking無効 | MLX向け4bit版 |
| 訳さない | ― | 英文だけを表示 |
表のモデルは mlx-community による原版のMLX向け4bit変換版で、mlx-lm から読み込みます。音声認識用Parakeetは別モデルで、この4bitの一覧には含めません。
TranslateGemmaは、翻訳用の入力形式を使う
TranslateGemmaは、翻訳を目的に訓練されたGoogleのモデル系列です。専用のチャットテンプレートがあり、方寸ではユーザーメッセージの content に、type=text、source_lang_code=en、target_lang_code=ja と翻訳対象の英文を入れます。言語を入力の構造で指定する形です。GoogleのTranslateGemmaモデルカード
Qwen3.5は、汎用モデルへ翻訳を指示する
Qwen3.5には「英語を自然な日本語字幕へ訳し、日本語訳だけを返す。説明や注釈は付けない」というシステム指示と英文を渡し、汎用モデルを字幕翻訳へ絞って使います。
チャットテンプレートには enable_thinking=False を指定します。公式カードもthinkingを無効にして直接応答を得る方法を案内しています。字幕では訳文だけを表示したいため、この設定を使います。Qwen3.5 9Bモデルカード
4bitとモデル規模は、メモリとの兼ね合いで考える
MLXはApple Silicon向けのフレームワークで、CPUとGPUが同じメモリへアクセスする構成を利用します。MLXのUnified Memory
量子化は重みを低い精度で保持し、メモリの要求量を減らす方法です。4bit版を選んでも、原版と同一の出力になるとは限りません。Hugging Faceの量子化の説明
4B・9B・12Bはモデル規模の呼称で、RAM容量ではありません。同程度の量子化なら、大きいモデルほど重みも増えます。キャッシュや音声処理にもメモリを使うため、配布容量だけで必要量は決められません。mlx-lm の資料も、RAMに対して大きなモデルでは処理が遅くなり得ると説明しています。mlx-lm: Large Models
モデル選択は、訳文の質と、音声認識との並行動作に必要な余裕の両方で考えます。今回、モデル規模・系列ごとの速度や英日翻訳の優劣は実測していません。
別プロセスと優先キューで、現在の文を先に訳す
翻訳はPythonの multiprocessing のspawn方式で別プロセスへ送ります。音声認識のスレッドを翻訳の終了待ちで止めない構造です。同じMacのGPUを使うため、プロセスを分けても計算資源の競合は残ります。
古い文から順番に訳すだけでは、処理が遅れたときに現在の文の訳が届きません。そこで、再生位置をもとに、いま聞いている文やこれから聞く文を優先します。聞き終わった文は後に回し、過去分だけが残ったら新しいものから扱います。
翻訳の生成条件は温度0、上限220トークンです。出力の文字種も確認しますが、翻訳の意味が正しいことを保証する処理ではありません。
再生中でもモデルを替えられる
画面下部からモデルを替えると、旧翻訳プロセスを止め、未翻訳の最近の文を新しいモデルへ渡し、選択を保存します。
メニューのGB表示はモデルデータの容量の目安で、実測したRAM使用量ではありません。
未取得のモデルは選べない表示にし、切り替えとダウンロードを分けます。録音再生は保存済みの字幕を使うため、この選択欄は出しません。音楽局でも翻訳欄を出さない条件があります。
2. Flutterの画面と、ローカルラジオサービス
Flutterの表示画面と、Pythonのローカルラジオサービスを分けています。
macOS版とWeb版は同じFlutterのコードを使います。画面のラジオ部分は RadioController が状態を持ち、RadioStage がそれを描きます。
ラジオサービスは局一覧、音声、字幕、翻訳設定、録音一覧を提供します。既定の接続先は 127.0.0.1:8788 です。
ローカルで行うのは音声認識と翻訳です。放送、番組・曲情報、画像の取得と任意の録音転送には外部通信があります。
3. 音声は、聞き取り用と再生用に分ける
ラジオサービスは、受信した同じ音声から二つのデータを作ります。
| 用途 | 形式 |
|---|---|
| 音声認識 | 16kHz・モノラルのPCM |
| アプリでの再生 | 44.1kHz・ステレオ、128kbpsのMP3 |
受信と変換にはPyAVを使います。PyAVはFFmpegへのPythonバインディングで、音声のデコードや変換をPythonから扱えます。PyAV公式資料
同じ受信音声から認識用PCMと再生用MP3を作り、単語の時刻と再生位置を対応付けます。
再生用MP3では、途中のフレームから再生できるようにbit reservoirを無効にしています。前の文へ戻って聞き直す機能があるため、最初から連続して再生する場合だけを考えた構成にはしていません。
音声と字幕に共通する基準は、放送局の時計ではなく、受信した音声の先頭から何秒進んだかです。PCM側は受信サンプル数、MP3側はフレーム数から秒数を求めます。
4. 音声認識は「途中の文」と「確定した文」を分ける
音声認識には、mlx-community/parakeet-tdt-0.6b-v2 と parakeet-mlx を使います。元のParakeet TDT 0.6B v2はNVIDIAの英語音声認識モデルで、単語の時刻情報を扱えます。方寸では、Apple Silicon向けのMLX変換物をライブラリから読み込んでいます。NVIDIAのモデルカード、parakeet-mlx
処理は、1秒ごとに独立した短文を切り出して訳す方式ではありません。約1秒ぶん新しい音声が届くと、まだ確定していない部分を含めて認識し直します。認識窓は最大24秒です。
文の区切りは、句読点、長い間、文の長さなどを使って判断します。後続の文を認識し、直前の文末から一定量の音声が届いた場合や、最後の文に終止記号があり、その後の音声も届いた場合に確定します。実装には1.6秒・1.8秒の待ち条件がありますが、これは文を確定するための規則です。字幕がその秒数で必ず完成するという意味ではありません。
途中の文は draft として返します。認識し直すことで変わる可能性があるため、確定した文とは分けます。画面側でも、受信末尾から1秒以内に終わるdraftの単語を除き、直近の変動しやすい部分をすぐに見せないようにしています。
確定した文には番号を付け、その番号をもとに更新します。翻訳が後から届いたときにも、文を新しく増やすのではなく、同じ番号の文へ日本語を追加できます。
5. 字幕JSONは、文章だけでなく時刻を持つ
一つの字幕を説明用の自作例文で表すと、次のようになります。時刻や番号も説明用です。
{
"n": 7,
"start": 10.0,
"end": 11.8,
"text": "We will draw a map.",
"ja": "地図を描きましょう。",
"words": [
["We", 10.0, 10.2],
["will", 10.2, 10.5],
["draw", 10.5, 10.9],
["a", 10.9, 11.1],
["map.", 11.1, 11.8]
]
}
文の start / end と、単語ごとの開始・終了時刻を持ちます。ja は翻訳が終わるまで空の場合もあります。
画面は再生位置と単語の開始時刻を比較し、読まれた語を黒く、これから読む語を薄く表示します。現在の文は大きく、前の文は小さく残します。日本語訳が届いたら、英文の下へ青で表示します。
英語の字幕はCal Sans、日本語訳は「けいふぉんと!」です。書体と色を変え、読みたい言語へ目を移しやすくしています。
字幕データは版番号で差分を返します。アプリは前回受け取った rev を after として送り、文番号を使って既存の文を更新します。毎回すべての字幕を最初から送り直す構造にはしていません。
6. 同期の基準は、音声の開始位置とプレーヤーの時計
認識や翻訳が終わった瞬間と、音声を再生している位置は一致するとは限りません。アプリの再生位置は次の考え方で求めます。
受信音声内の現在位置
= サービスが返した audio_start
+ プレーヤーが再生した秒数
サービスは、アプリへ渡した音声が、共通の時間軸のどこから始まったかを audio_start で知らせます。アプリはプレーヤーの時計を足して、字幕の単語時刻と比較します。
macOS版の音声はAVPlayerで再生し、その時計をNativeBridge経由で読みます。音声の時計は250msごと、字幕APIは400msごとに確認します。画面側は100msごとに位置を見ますが、単語や表示する秒数が変わらなければ更新しません。時計を読み直すまでの間は、実際に再生中のときだけ短く補間します。
「画面を開いてから何秒たったか」ではなく、プレーヤーがどこまで進んだかを軸にするため、音声が止まっているのに文字だけ進み続ける状況を避ける設計です。
少し待ってから音声を届ける
サービスには、受信末尾から4秒手前までを音声配信の範囲にする設定があります。通常の再生開始時には、さらに16秒ぶん戻した位置を選びます。十分な受信音声があれば、末尾の20秒前から始める形です。短い場合は先頭までに丸めます。
これは、認識と翻訳に先に処理する時間を持たせるための構成です。4秒は端から端までの遅延保証ではありません。 配信元での遅れ、開始時のバッファ、文の確定、推論、プレーヤーの準備などは別に関わります。
アプリへ届く時点で日本語がまだない文も、英文だけ先に表示できます。翻訳が追加されたときに同じ文を更新するので、英語字幕と日本語訳が同時に完成することを前提にしません。
文を押すと、そこから聞き直す
以前の文をクリックすると、その文の開始より0.25秒前から音声を取り直します。短い余裕を付け、単語の頭を切り落としにくくするためです。戻って聞いている間は、ライブ位置へ戻る操作も表示します。
再聴取用の音声はメモリ上で最大30分、字幕は既定で400文を保持します。二つは保持単位が違うため、「過去30分の全字幕が必ずそろっている」とは言い切れません。この一時的な再聴取用バッファと、ファイルに残す録音は別の仕組みです。
7. 地域局48局を、地図から選ぶ
登録された局は、コード上では二つに分けます。
| 区分 | 現在の登録数 | 選び方 |
|---|---|---|
| 緯度・経度のない局 | 15 | 円形ダイヤル |
| 緯度・経度のある地域局 | 48 | 地図 |
全体で63局です。この数は静的な登録数であり、全局の配信が常時利用できることを確認した数ではありません。
地域局のレコードには、局名、町名、緯度・経度があります。例えばCornwallはトゥルーロ、Londonはロンドンというように、局と場所を対応付けます。
地図の動画も自作例文によるUIデモです。方向キーで局を移動し、Enterで局の表示を切り替えています。
地図はFlutterの CustomPainter で描きます。海岸線はNatural Earthの1:50mデータをもとにしたworld-atlasのデータから取り出し、生成済みの座標列としてアプリへ組み込んでいます。Natural Earthは元データをpublic domainで提供しています。Natural Earthの利用条件
アイルランド島の内部の国境を海岸線から外し、小さな島を省き、角を間引いた現在の配列は24輪郭・1,023頂点です。局を描くために、実行時に外部の地図タイルを取得する処理は使っていません。
緯度・経度を画面へ置く
横方向は経度に cos(55°) を掛け、縦方向は緯度を反転します。緯度55°を基準に経度の幅を補正し、北を上に置く簡易な描画です。海岸線と局の点は同じ変換を使います。
変数名を簡略化した説明用の抜粋は次のとおりです。
final narrow = math.cos(55 * math.pi / 180);
Offset at(double lon, double lat) => Offset(
left + lon * narrow * scale,
top - lat * scale,
);
地図の輪郭と局の点は別のPainterに分けます。動かない海岸線は RepaintBoundary で囲み、選択の印だけをアニメーションに応じて描き直す構成です。Flutterの CustomPainter には repaint へ通知元を渡す仕組みがあり、アニメーションに合わせた再描画を扱えます。Flutter: CustomPainter
点へ近づくと局名が出る
地図上の点は小さくても、選択範囲は点そのものより広くしています。ポインターから26px以内で最も近い局を選び、黒地の局名・町名ラベルを出します。右端に近いときは、ラベルを点の左へ出します。
ホバー中の局はオレンジ、再生中の地域局は青い細い円と十字で示します。再生局の印は、単語の進みに合わせて少し広がります。地図を開いていても、音声と字幕の時間軸が画面の動きに関わります。
上下左右キーでも局へ移れます。指定方向の広い扇形にある局を候補とし、前進方向の距離に横ずれのコストを加えて選びます。EnterやSpaceで焦点の局を再生します。ホバーのラベルはキーボードの焦点より優先するため、キー移動を見るときはポインターを地図外へ出すと分かりやすくなります。
広いウィンドウでは地図の横に字幕を並べ、字幕の幅が230px未満になる場合は地図だけを出す条件があります。局を探すための図と、英文を読むための領域を、画面幅に合わせて使い分けます。
8. 音楽局では、字幕の代わりに曲情報を出す
局の設定が captions: false の場合、音声認識と翻訳の経路を使わず、聞くための局として扱います。受信・デコード・再生用MP3への変換は行いますが、GPUの音声認識・翻訳推論を動かす扱いにはしません。
BBCの局では、提供される番組名、曲名、歌手、画像を表示する処理があります。曲情報は放送局の窓口へ問い合わせ、受信秒数を壁時計へ対応付けて、遅らせて再生している位置の曲を選びます。
音声を遅らせているのに曲名だけ現時点のものを出すと、聞いている曲と合いません。ここでも「いま再生している位置」を使います。
画像が取得できなければ、その部分を表示しない構成です。すべての局で曲情報が取れることや、提供元の窓口が今後も同じ仕様で使えることまでは保証しません。
9. 録音は、再聴取バッファと別に管理する
ライブ視聴とは別に、受信音声を保存し、あとから字幕を付ける録音の仕組みがあります。以下は実装の説明で、紹介のために録音を新たに開始していません。
録音は時計の区切りに合わせ、既定では1時間単位にします。受信したAACやMP3の音声パケットを、デコードし直さず保存します。書きかけは .part として扱い、起動し直したときに途中までの録音として引き取る処理があります。接続が切れたり時刻が大きく飛んだりした場合は、ファイルを分けます。
字幕付けは後処理です。ライブのように約1秒ごと同じ部分を認識し直すのではなく、録音を30秒の窓で扱い、確定した文末から次へ進みます。音声認識モデルを下ろしてから翻訳モデルを読み込み、同時に持つモデルを減らす構成です。
字幕を付けた結果は .mka にまとめます。音声に、生成された言語のSRTと、文・単語時刻のJSON記録を添えます。日本語訳が空なら日本語SRTトラックは作りません。音声の格納は再圧縮せず、パケットを別コンテナへ移します。FFmpeg: Streamcopy
仕上げたファイルは、音声の時間、字幕数、JSONの読み直しなどを検査してから置き換えます。ライブで字幕を付ける局を再生している間は、録音の字幕付けを待たせる処理もあります。
後処理の pace は、計算した時間に応じて休む割合です。例えば 0.5 は処理に使った時間と同じだけ休む計算で、GPU使用率を測って50%へ制御する指定ではありません。
送った印だけで、手元の録音を消さない
任意の送り先への転送にはrcloneを使う処理があります。送信後に送り先のファイルを確認して印を作り、保存日数が過ぎたときにも、削除直前に送り先を再確認します。確認できなければ手元を残します。
送り先のサイズと、利用できる場合のMD5を確認します。MD5を返さない送り先ではサイズによる確認へ切り替えるため、どの送り先でも必ずハッシュが一致することを確認する構成ではありません。rclone: lsjson、Google Driveのハッシュ
アプリの録音一覧に出すのは、仕上がった手元のファイルです。日時のチップから選び、文を押して聞き直したり、シークバーで移動したりできます。Driveにしかないファイルを、そのままストリーミングする機能とは分けています。
10. ソースを読む入口と、APIの役割
ラジオに関係する主なファイルは次のとおりです。
apps/admin/lib/
radio.dart コントローラー・再生状態・字幕・画面
radio_map.dart 地域局の地図・ヒット判定・描画
coast.dart 生成済みの海岸線
theme.dart 色と書体
shell.dart 下部操作帯・メニュー
services/radio/
stations.json 局の登録情報
coast.py 海岸線データの生成
src/housun_radio/
source.py 受信・音声変換
tape.py 音声の保持と秒数
hearing.py 音声認識
captions.py 文の確定・字幕差分
translate.py 翻訳モデル・キュー・プロセス
server.py ラジオAPI
nowplaying.py 曲・番組情報
recorder.py 音声の保存
record.py 字幕付け・転送・保存期間
bundle.py 字幕とJSONの格納
recorded.py 手元の録音を再生
ラジオAPIでは、用途を次のように分けます。下表は既定のラジオサービス配下のパスです。
| API | 役割 |
|---|---|
GET /stations |
局一覧と地図用の座標 |
GET /state |
ラジオ・モデルの状態 |
POST /play / POST /stop
|
局を再生する・止める |
PUT /translation |
翻訳モデルを選ぶ |
GET /captions |
版番号以降の字幕と状態 |
GET /audio |
ライブまたは指定位置の音声 |
GET /recorded |
仕上がった手元の録音一覧 |
GET /recorded/record |
録音の文と単語時刻 |
GET /recorded/audio |
録音内の指定位置からの音声 |
字幕側には after、session、listener を渡し、音声側にも再生を識別する listener を渡します。音声を開き直したときは新しい識別子を使い、別の再生で返ってきた開始位置を現在の再生へ混ぜないようにします。
いま確認できる範囲と、これから確かめたいこと
現在のコードには、字幕の差分、文の確定、再聴取、モデル切り替え、地域局の選択、録音の転送・削除条件を扱うテストがあります。実モデルでの長時間の動作は、これとは別に確認が必要です。
Qwen3.5とTranslateGemmaの英日字幕での優劣、実際の放送での認識・翻訳の精度、機種やモデルごとの処理負荷、端から端までの遅延は、この記事では測定していません。録音字幕の外部プレーヤーでの互換性も確認対象です。
現段階では、個人向けのラジオ画面と各処理をつないだ実装です。汎用アプリとしての配布・運用まで完成した状態ではありません。




