OpenCCVoice for DVSwitch — デバッグ・ジャーニー

版数対象: V1.75 〜 V1.93
期間: 2026 年 7 月上旬(数日間の集中デバッグ)
環境: Raspberry Pi 3B / Raspberry Pi OS Bookworm 32bit (armhf) / Pi-Star ベース / DVSwitch (Analog_Bridge + MMDVM_Bridge + md380-emu) / TGIF ネットワーク
主対象: DMR デジピーター自動応答システム dvswitch_bot.pyji2tab/OpenCCVoice-For-DVSwitch


この文書について

この記録は、OpenCCVoice for DVSwitch の運用中に直面した「頭欠け」「MD-619 の受信スタック」「コールサイン表示引きずり」など一連の不可解な症状を、パケットキャプチャと自作解析器で電波レベルまで追い詰めて解決した過程の全記録です。

途中で立てた仮説の多くは棄却されました。棄却された仮説も、その棄却プロセスごと残します。後から同じ症状に遭遇した人が「これは違うのか」を先に確認できるようにするのが本文書の目的だからです。

各節は「症状 → 仮説 → 検証 → 結果」の順で書きます。実測データ・コード差分・設計判断は可能な限り原文で残しています。


全体マップ(版と主題)

主題結果
V1.75コールサイン応答のRAMキャッシュ導入実機で SoX 失敗(後に V1.76 で修正)
V1.76.part 拡張子事故を .building.wav に修正キャッシュ機構が実用化
V1.77キャッシュ即応答時の頭欠け対策(TX lead 1.0s)頭欠けが実測で解消
V1.78〜V1.80GPIO タイミング応答の実験と撤退最終的にGPIO非依存へ
V1.81TGIFChanger との相互影響を検証衝突なしを確認
V1.82USRP 終端の多重送信MD-619 ハングは直らず
V1.83送信前 SET_INFO メタデータ送出 (pyUC 互換)MD-619 ハングは直らず
V1.84無音のノイズ充填MD-619 ハング解消(真因判明)
V1.85ノイズ床引き上げとゲート開放バースト効果限定的
V1.86〜V1.87無音助走と TX lead の再構成頭切れ改善
V1.88頭シャー短縮/終端ケロケロ対策音質向上
V1.89リード音のトーン化ゲロゲロ・プツプツ解消
V1.90Talker Alias 引きずり対策(自局主張の強化)効果なしと後に判明
V1.91送信直列化・TEMP競合根治並行制御の Race Condition 修正
V1.92外部レビュー指摘の反映掃除責任の明確化
V1.93キャッシュディレクトリ存在保証の強化実機事故対応

第0章 舞台設定

システム構成

OpenCCVoice for DVSwitch は、DMR ネットワークで人間の声を検出(カーチャンク=短時間キーアップ)したり通常のQSOを検出したりして、自動で音声IDやアナウンスを応答するボットです。日本の電波法(無線局運用規則第30条:長時間交信中の10分ごとID)にも準拠します。

信号の流れはこう組まれています:

無線機 → ホットスポット(SFR) → Analog_Bridge (USRP 51000)
                              → MMDVM_Bridge → TGIF ネットワーク → 戻る
                              ← Analog_Bridge (USRP 51001) ← MMDVM_Bridge

                              [MMDVM_Bridge のログを監視]
                                          ↓
                              dvswitch_bot.py
                                          ↓ (応答生成: Open JTalk + SoX)
                              → Analog_Bridge (USRP 51000)

ボットは MMDVM_Bridge のログを常時 tail し、voice header from XXend of voice transmission の行を検出して応答を作ります。応答音声は Open JTalk(日本語TTS、mei_normal 音声)で合成し、SoX で 8kHz/16bit/mono に整えて USRP プロトコルで Analog_Bridge に注入します。

応答の骨格

応答音声の構造は基本こうです:

[イントロWAV「こちらは」] + [合成音声「JI2TAB局の」] + [アウトロWAV]

イントロとアウトロは事前録音の固定 WAV で、真ん中だけを Open JTalk で毎回合成します。この設計により、合成の負荷を最小限にしつつ、聞き心地を良くしています。


第1章 応答を速くする ─ キャッシュ設計

発端:合成が遅い

Open JTalk の合成は Pi 3B で約 1.5 秒。SoX 処理・パディング・送出を合わせると、検出から実際に音声が出るまで 約 2 秒 の遅延がありました。カーチャンクが 0.8 秒程度なのに対して、応答開始が 2 秒後というのは体感でかなり遅い。

同じコールサインが何度もアクセスしてくることに着目し、コールサイン単位のキャッシュを設計しました。

設計:/dev/shm 上のRAMキャッシュ

方針を3つ決めました:

  1. 場所は /dev/shm(tmpfs / RAM ディスク)。SD カード保護になり、プロセス再起動で自動クリアされる
  2. PID で名前空間を分離 (/dev/shm/ocv_reply_cache_<PID>/)。複数プロセス・古いプロセスとの混線を防ぐ
  3. キャッシュキーはコールサインだけJI2TAB.wav

さらに「ヘッダ受信時に先行生成」する プリキャッシュ を追加。相手が話している間にバックグラウンドで合成しておき、終端検出時にはキャッシュ完成済み、という設計です。

署名によるキャッシュ整合性

問題はキャッシュの陳腐化です。イントロWAV を差し替えた、TX ゲインを変えた、GAP を変えた──こうした変更のたびに手動でキャッシュを消すのは非現実的。そこで署名(signature) を導入しました:

def _reply_signature():
    intro = _resolve_wav(USE_CSTM_INTRO, CSTM_INTRO_WAV, FIXED_INTRO_WAV, "intro")
    return "|".join([
        CACHE_SCHEMA,
        intro, _mtime(intro),
        FIXED_OUTRO_WAV, _mtime(FIXED_OUTRO_WAV),
        f"{GAP_AFTER_INTRO_SEC}",
        f"{TX_GAIN}",
        VOICE_PATH,
    ])

キャッシュファイル JI2TAB.wav の隣に JI2TAB.wav.sig を置き、生成時の署名を書いておく。次回アクセス時に現行署名と一致すれば命中、違えば自動再生成。手動介入なしで整合性が保たれる構造です。

V1.75 → V1.76:.part 拡張子事故

V1.75 を実機で動かしたら、いきなりこのエラー:

sox FAIL formats: no handler for file extension `part'
[!!] SoX failed   rc=2
[!!] Gen failed   JI2HPJ   hybrid audio

原因は一時ファイル名でした。私は「書きかけを読ませない」ために JI2TAB.wav.part という一時名で作り、完成後に os.replaceJI2TAB.wav に差し替える設計にしていました。しかし SoX は出力の拡張子でフォーマットを判別するため、.part は「未知の拡張子」として弾かれてしまう。

修正はこう。一時名を必ず .wav にする:

tmp = f"{path[:-4]}.building.wav" if path.endswith(".wav") else f"{path}.building.wav"

これで V1.76。キャッシュ機構が本格稼働しました。

教訓: SoX に渡すファイル名の拡張子は「そのまま出力形式指定」として扱われる。.part .tmp などの一般的な一時名は使えない。


第2章 頭欠けとの戦い ─ V1.77 の TX lead

症状

V1.76 でキャッシュが効いて即応答するようになった途端、新しい症状が出ました:

「こちらは」の頭が切れる。

キャッシュ命中で応答が速くなりすぎ、相手のアンキー直後に応答を出すと、SFR(同一周波数半複信)中継の RX→TX 切り替えが間に合わず、イントロの頭が RF に乗らずに消える。

実測での切り分け

まず「キャッシュWAVの中身が悪いのか、送出タイミングが悪いのか」を切り分けました。キャッシュから読んだWAV と直接生成した WAV をバイト比較:

cache WAV : (2.2, 8000, 1, 4, 70480)
direct WAV: (2.2, 8000, 1, 4, 70480)
先頭400フレーム一致: True

尺・サイズ・先頭フレームまで同一。キャッシュ内容ではなく、送出タイミングの問題だと確定。

対策:TX lead

命中時のみ、送出前に RF ターンアラウンド保護の待ちREPLY_TX_LEAD_DELAY_SEC)を入れる:

if was_hit and REPLY_TX_LEAD_DELAY_SEC > 0:
    logger.info(_fmt("..", "TX lead", val, f"{REPLY_TX_LEAD_DELAY_SEC:.1f}s"))
    time.sleep(REPLY_TX_LEAD_DELAY_SEC)

ミス時(初回・キャッシュ差し替え直後)は合成に約2秒かかり、それがガードを兼ねるので追加待ちなし。

最短値の追い込み

実測で下限を探りました:

  • 1.5s → OK
  • 1.2s → OK
  • 1.0s → OK(頭欠けなし)
  • 0.8s → NG(時々欠ける)
  • 0.5s → NG(「こちらは」あたりまで欠ける)

境界は 1.0s と 0.5s の間。総リード = TX lead + 前パディング1.5s で、この中継機は 2.5s あれば必ずセーフ、2.0s だとアウト、と分かりました。安定を優先して 1.0s で確定。

教訓: キャッシュ即応答は速すぎることがある。SFR 中継の物理的な折り返し時間は、ソフトでは短縮できない。実測で最小値を追い込むしかない。


第3章 GPIO の寄り道 ─ V1.78〜V1.80

同じ Pi 上で TGIFChanger-Py が GPIO17 を「受信中インジケーター」として制御していました。相手のアンキー(=GPIO の HIGH→LOW 遷移)を借りれば、TGIF 往復の遅延を消せるのでは、という発想が出ました。

V1.78:GPIO LOW を待つ実装

/sys/class/gpio/gpio17/value を 50ms ポーリングして LOW を検出したら送出、というシンプルな実装。実機で試したところ、GPIO LOW を待っている間に別のカーチャンクが来て already talking でスキップされるという副作用が判明。

さらに根本的な問題として:

  • GPIO ファイルの読み取り権限が環境依存
  • TGIFChanger-Py の稼働に依存
  • そもそも GPIO の LOW タイミングが「相手のアンキー」を正確に示すわけではない

V1.80:GPIO 依存を完全排除

ユーザーからの明確な指示:「タイミング概念は活かしたいが、GPIO は使わない」。

これは正しい判断でした。GPIO は「TGIFChanger の実装詳細」であって、ボット側が依存すべきインターフェースではない。V1.77 で確立した「TX lead 1.0秒の実時間待ち」の方が、経路も権限も設定も問わず動く堅牢な設計です。

V1.80 では GPIO 関連コードを全削除、V1.77 の TX lead 方式に統一しました。

教訓: 隣で動いている別プロセスの内部状態を借りるのは、短期的に効いても長期的に脆い。「タイミングの概念」を借りたいだけなら、自分側で完結する方式(実時間待ち)を選ぶべき。

V1.81:相互影響の検証

V1.80 で GPIO 依存を切ったあと、V1.81 は「同じ Pi で動く TGIFChanger-Py v2.3.4 との相互影響」を検証するだけの版としてリリースしました:

項目dvswitch_botTGIFChanger-Py判定
監視ログ/var/log/mmdvm/MMDVM_Bridge-*.log/var/log/pi-star/MMDVM-*.log別ファイル・両方読取専用
GPIO触らないGPIO17 制御重ならない
/dev/shmocv_reply_cache_<PID>/使わない名前空間の衝突なし
外部コマンドopen_jtalk / soxgpioset / pinctrl別系統
ネットワークUDP 127.0.0.1:51000UDS + TGIF API(http)ポート競合なし

「衝突なし」と確認できたので、以後は両者を安心して同居させました。


第4章 MD-619 ハングの真因を追う

症状

ある日、Radioddity MD-619(ディスプレイなし機)を持っている運用者から報告:

「OCV の音声を受信した後、無線機が受信状態のまま固まる。電源も切れなくなる。」

しかも決定的な情報:

  • 他の局(アナログ音声)を受信しても固まらない
  • OCV の応答を受信したときだけ、確実に固まる
  • 他メーカーの中継経路でも同じ症状
  • 途中で信号から抜けると固まらないことがある

「OCV だけ」「必ず」「途中から」──この3つが揃った症状は、OCV のストリームに 他のクライアントとは違う何か固有のもの があることを強く示唆します。

仮説群の立ち上げ

順に潰していきます:

仮説A:音声データの中身が悪い → 棄却。ボットの音声は Open JTalk の PCM を Analog_Bridge で AMBE に再エンコードするため、RF 上のビット構造は人間の声と同一。

仮説B:USRP 終端パケットが1発だけで取りこぼされる → V1.82 で USRP_EOT_REPEAT = 3 として3連打に。実機テスト、ハング再発。棄却。

仮説C:USRP メタデータ(SET_INFO)が欠落 → V1.83 で pyUC 公式クライアントとバイト単位で同一の SET_INFO を送信前に送出。実機テスト、ハング再発。棄却。

仮説D:DMR フレームの中の埋め込みデータ(Talker Alias)が異常 → ダッシュボードでは発信元 ID・コールサインとも正常表示。だが埋め込みTAは画面では確認できない。仮説として保留。

tcpdump へ

ここで方向転換しました。RFに乗っているものを直接見よう。

Pi 上で tcpdump を回して、DMR ネットワーク(Homebrew プロトコル、UDP 62031)のパケットをキャプチャします:

sudo tcpdump -i any -Z root -w /tmp/dmr.pcap udp port 62031

そして解析器を自作しました。DMR フレームは 264bit の複雑なビット並びなので、既存ツールを探すより自作の方が早い。dmrd_analyze.py の初版が生まれました。


第5章 tcpdump が全てを変えた ─ 51 フレームの一致

解析器の設計

dmrd_analyze.py の役割:

  1. pcap を自前パース(外部ライブラリ不要、Pi でそのまま動く)
  2. UDP payload から DMRD フレームを抽出
  3. 各ストリームを「送信 (TX→net) / 受信 (net→RX)」で分類
  4. ヘッダ数・終端数・フレーム種別(VoiceLC/Terminator/Voice A-F)を集計
  5. 音声フレーム中央48bit(EMB+embedded fragment)を種類別に集計

Ethernet / Linux SLL / SLL2 の複数の linktype に対応、pcap を新旧両方読める、といった実務的な機能も入れました。

決定的な発見

OCV と JJ2ZAR(通常局)を両方含む pcap を取って解析すると:

通常局 JJ2ZAR の折り返し(net→RX):

ヘッダ:3  終端:1  総フレーム:42
並び: VVVAAAbbbcccdddeeefff...T

OCV の折り返し(net→RX):

ヘッダ:0  終端:3  総フレーム:259  ⚠️ ヘッダ無し!
並び: AbcdefAbcdefAbcdef...  ← いきなり音声から始まる

OCV のストリームだけ、VoiceLCHeader が完全にゼロ。

送信側(TX→net)ではボットは確かにヘッダを送出しています(VoiceLCHeaderx2)。それが TGIF を通って戻ってくると、ヘッダ2発が全部消えている

51 フレームの一致

さらに衝撃的だったのは、消えているフレーム数です:

  • 1回目: 送信 275 → 配送 224。差 51 フレーム
  • 2回目: 送信 281 → 配送 230。差 51 フレーム

51 × 20ms = 3.06 秒。何の数字か?

OCV の応答の頭には、前パディング1.5s + 焼き込み頭無音1.5s = ちょうど 3.0 秒の無音がある。

つまり私の実験では、TGIF は先頭の無音区間を配送していないように見えた。 音が始まった所からストリームを配送し始めており、ヘッダは無音区間の前にあるため、無音と一緒に落ちていた。

これが MD-619 の受信スタックの直接原因でした。ヘッダなしのストリームを受け取った受信機は「途中参加(late entry)」処理を強要され、これに弱いファームが破綻する。

「アナログ音声では起きない」「OCV だけ起きる」ことの説明もつきます。人間の声にはノイズフロアがあってビット的にゼロにはならない。合成音声の無音は数学的にゼロ。私の環境では、TGIF はこのゼロを配送しなかった。

なぜ TGIF はそう振る舞ったのか(推測)

あくまで私の推測ですが、TGIF はネットワーク帯域を節約するため「無音は転送しない」ようなエネルギー閾値のゲート機構を持っているのではないか、と考えています。実際、私の実験ではノイズフロアのある実音声はこのゲートを通過できた一方、デジタル完全無音は通過できませんでした。

私は TGIF の公式仕様を確認したわけではありませんが、少なくとも私の実験では TGIF 側でこう振る舞ったのであって、ボット側の実装ミスではないと考えています。いずれにせよ、ボット側で対処する必要はありました。

教訓: RF 経由の「送信したもの」と「相手が受信するもの」は同じではない。ネットワークがどこかで加工していないか、実際のパケットを見て確認するのが唯一確実な方法。


第6章 AMBE ボコーダとの対話 ─ V1.84〜V1.89

V1.84:無音のノイズ充填

対策の方針は明快:送出する PCM の完全ゼロを、聞こえないほど微小なノイズに置き換える。 人の声のノイズフロアと同じく AMBE が「非無音」として符号化し、私の実験では TGIF のゲートが通過するようになった。

NOISE_FILL_ENABLED = True
NOISE_FILL_AMP = 150   # 16bit PCM 振幅(150/32768 ≈ -47dBFS)

置換対象は「前パディング・後パディング・WAV内のゼロブロック」の全部。

実機テスト結果: MD-619 のハングが解消。 症状が完全に消えました。真因を突き止めた瞬間です。

V1.84 の副作用 ─ シャー音

代償として、応答の頭に約3秒間「シャー」というノイズが聞こえる状態になりました。これは実用に耐えない。

さらにパケット解析すると、スキップは 51 フレーム → 22 フレーム に減っていました。ゲートが開くタイミングが 3.06s → 1.32s に早まった。だが、まだヘッダは食われる。

V1.85:ゲート開放バーストと振幅引き上げ

もし「エネルギー積算型のゲート」なら、音量を上げれば早く開くはず。V1.85 では:

  • ノイズ振幅を 150 → 600(-47dBFS → -35dBFS)に引き上げ
  • 先頭300msだけ 2500(-22dBFS)の強めバースト

実機テスト結果:スキップは 22 フレームで変化なし。 ヘッダも通らない。

発見:私の実験では、TGIF のゲートに「約 1.3 秒の固定床」があるように見えた。 音量を上げても通過し始めるまでの時間は縮まず、バーストは無意味だった。

V1.86:構造の再設計

ゲート開放後の無音は正常に転送されることが分かったので、ノイズは先頭だけあればいい

[ノイズ 1.5s(TGIFが約1.3s食い、届くのは0.2sだけ)]
[真の無音(ゲート開放後なので無音でも転送される)]
[実音声]

しかしこの版で「こちらは」の頭欠けが再発。ゲート開放後の助走が 0.18 秒しかなく、ヘッダ喪失ストリームを途中参加で受ける側の同期が間に合わない。

V1.87:無音助走の復活

同期に必要な助走を確保します:

[ノイズ 1.5s(先頭)]
[真の無音 1.5s(同期助走)]
[実音声]

V1.86 以降は WAV 内のゼロを置換しない設計なので、この助走は真の無音として届く(V1.84 のシャーは出ない)。

V1.88:ノイズ短縮と EOT 単発

さらにノイズを短縮:

NOISE_FILL_AMP = 100      # 振幅も下げる
NOISE_LEAD_PACKETS = 65   # 65×20ms=1.3s(ゲート床を跨ぐ最小限)

そして V1.82 で入れた EOT 3連打を単発に戻しました。キャプチャで OCV だけ TerminatorLC が3個流れることを確認し、これが受信機の終端処理を乱していた疑いが濃厚だったため。

V1.89:AMBE との対話 ─ ノイズをトーンに

しかし新しい報告:「こちらは」の前にゲロゲロ・プツプツ音、時々シャー。

これは AMBE ボコーダの性質から予測できる症状でした:

  • AMBE は音声用ボコーダ。 ホワイトノイズを入れると「うがい声」様の歪みに符号化される(ゲロゲロ)
  • 振幅100は AMBE の無音判定境界。 無音/非無音フレームが交互になってプツプツ音を生む
  • ゲート開放タイミングの揺れで届く尻尾の長さが変わりシャーが顔を出す

対策:リード音をホワイトノイズ→100Hz の微小トーンに変更。

def _init_noise_blocks():
    global _TONE_BLOCK, _TONE_FADES
    base = [_math.sin(2 * _math.pi * 100.0 * i / 8000.0) for i in range(160)]
    _TONE_BLOCK = struct.pack("<160h", *[int(NOISE_FILL_AMP * s) for s in base])
    # 末尾フェード(無音への遷移ポップを防ぐ)
    _TONE_FADES = [struct.pack("<160h", *[int(NOISE_FILL_AMP * f * s) for s in base])
                   for f in (0.75, 0.55, 0.35, 0.20, 0.08)]

100Hz を選んだ理由:20ms ブロックにちょうど2周期で、ブロック連結時に位相が完全連続。検証で「サンプル間ジャンプ最大12(理論値どおり)」を確認。飛びのない滑らかな信号は AMBE が綺麗にハムとして載せる。

実機テスト結果:ゲロゲロ・プツプツが消失。 聞こえるとしてもごく薄いハムのみ。

教訓: ボコーダは「声のためのコーデック」であり、声でないもの(ホワイトノイズ)を通すと予測不能な出力になる。周期信号なら綺麗に載る。信号処理の基礎を思い出せば当然のことだが、ボット開発の文脈からは見落としがち。


第7章 Talker Alias 表示引きずりの謎

症状

「JJ2ZAR がカーチャンクしたあと OCV が応答すると、無線機の表示が JJ2YYK ではなく JJ2ZAR のまま」

ダッシュボードでは応答は正しく JJ2YYK と記録されている。だが RF で受信している側の表示が引きずられる。

仮説X:Analog_Bridge がメタデータを保持している

Analog_Bridge が「最後に聞いた局」のコールサインをメタデータとして保持し、次の送信の Talker Alias(TA、ストリーム埋め込みの文字列)に埋め込んでいる、という仮説。

V1.90:自局アイデンティティの三重主張

対策として SET_INFO を三重に送るように変更:

  1. 起動時
  2. 他局の受信が終わるたび_handle_rx_duration の入口で)
  3. 送信直前(V1.83 から)

しかし Analog_Bridge のログを確認すると、AB は SET_INFO を確かに受理していた

I: USRP packet type: USRP_TYPE_TEXT (JJ2YYK) -> 4402396

V1.83 の実装は届いていた。それでも表示は引きずる。仮説Xでは説明できません。

仮説Y:受信機側の途中参加処理

新しい仮説:私の実験では TGIF がヘッダを落とすため、無線機は「途中参加」で受信していた。途中参加時に発信者表示を更新しない機種は、直前の表示(=カーチャンクした JJ2ZAR)を保持し続けるのではないか。

埋め込み LC / TA デコーダの移植

判決を下すには「ストリームに実際に埋め込まれた TA」を直接デコードするしかない。DMR の埋め込み LC は 128bit のデータを 4フレーム(B/C/D/E)にわたって分割送信し、デインタリーブ→Hamming(16,11,4) FEC→5bit CRC で組み立てる複雑な構造です。

MMDVMHost の実装(DMREmbeddedData.cpp / Hamming.cpp / CRC.cpp)を Python に移植し、emblc.py として dmrd_analyze.py に統合。

検証として、既知の JJ2ZAR 断片をデコードしたら「dst=44833 src=4402519」──実データと完全一致。デコーダの正しさが確認できました。

判決

OCV のストリームをデコード:

埋め込みLC/TA デコード:
    GroupLC dst=44833 src=4402396  x23
    TA_Header data=764a4a3259594b  x6
    TA_Block1 data=20416963686950  x6
    TA_Block2 data=726566204a6170  x6
    TA_Block3 data=616e204d4d4456  x5
    ★ Talker Alias = 'JJ2YYK AichiPref Japan MMDV'  (format=ISO8859-1, len=27)

OCV のストリームは、発信元 ID も Talker Alias も完全に正しい。 しかも TA の中身 JJ2YYK AichiPref Japan MMDV(M_Bridge) は MMDVM_Bridge.ini の Callsign + Location + Description そのもの。TA は MMDVM_Bridge が自分の ini から生成しており、AB の「最後に聞いた局」は TA に影響していない

仮説X完全棄却、仮説Y確定。 表示引きずりは受信機側の問題であり、ボット側で対処すべきものはない。

V1.90 の位置づけ

V1.90 の SET_INFO 再主張は結果的に「無害だが不要」と判明しました。ただし将来 AB の実装が変わったときに保険になるので、そのまま残しています。

教訓: 症状の原因が「送信側」か「受信側」かを、実際のパケットまで降りて確認するのは重要。ダッシュボード表示は途中で加工されているので判断材料にできない。RF で流れているビット列そのものを見るしかない。


第8章 並行処理の Race Condition ─ V1.91・V1.92

発見の経緯

デバッグの主戦線と並行して、別の運用者から報告:

ボットの予期せぬクラッシュ・SoX エラー(マルチスレッドの競合)

  • 「sox FAIL」というエラーがログに残り音声生成に失敗
  • 起動直後に他局の信号を受信するとストリームが壊れる
  • 起動アナウンスが他の送信処理を無視して同時に実行(二重送信)
  • バックグラウンドで次の音声を生成中に、別スレッドが作業ファイル(TEMP_48K など)を横から強制削除する Race Condition

これはコード上で実在するバグでした。

問題1:二重送信

起動アナウンス関数 _send_startup_announcement は他の送信経路(_reply_executor)と共有ロックを持っておらず、起動直後に他局を受信すると応答と並走可能でした。

問題2:TEMP 削除の Race Condition

_reply_executor の finally が共有一時ファイルを削除する構造:

finally:
    for tmp in (TEMP_FINAL, TEMP_48K, TEMP_8K, TEMP_INTRO_PADDED):
        if os.path.exists(tmp):
            try:
                os.remove(tmp)

問題は、これがプリキャッシュスレッドの _generate_hybrid 実行中に走り得ること。生成中のファイルを横から消すと SoX が失敗する。

V1.91:修正

対策1:送信直列化ロック _tx_lock の新設。

_tx_lock = threading.Lock()

_reply_executor_send_startup_announcement の両方の入口で取得。ロック順序は常に _tx_lock(外)→ _gen_lock(内)でデッドロックを回避。

対策2:実行中の TEMP 削除を廃止。

/dev/shm 上の小ファイルで各生成が上書きするため、実行中削除は元々不要でした。掃除は起動時の一括のみに。

外部レビュー

V1.91 について外部(AI 系)レビューを受けました。3つの指摘:

  1. HTMLエスケープが残っている
  2. _tx_lock の説明と _start_worker() の実装が矛盾
  3. TX_GAIN のフォールバックが Strict Validation と矛盾

検証結果:

  1. 事実誤認。 納品ファイルには &lt; などのエスケープは存在せず、py_compile も通る。Web 画面への貼り付け時に画面側がエスケープしたコピーをレビューしていた
  2. 妥当。 動作自体は正しく、報告された二重送信バグは直っている。しかし変更履歴の「後着は待機し順番に送出される」という記述は言い過ぎで、正確には「通常経路はスキップ、起動アナウンス衝突時のみ待機」だった。変更履歴を訂正
  3. 前提違い。 「Strict Validation」はレビュー側が別途作った版の設計。当系譜(V1.68 以来)ではフォールバックが意図した設計。「無人デジピーターでは設定ミス1つで停波するより警告して動き続ける方が正しい」というポリシー

指摘4として「TEMP 削除は _gen_lock 内の方が明確」という提案があり、これは採用。中間ファイル削除を _generate_hybrid の finally(_gen_lock 保持中)に移動、掃除責任が明確になりました。

V1.92:レビュー反映

def _generate_hybrid(intro, middle_text, outro, out_path=TEMP_FINAL, head_silence=0.0):
    ...
    with _gen_lock:
        try:
            ...
            return True
        except ...:
            return False
        finally:
            # V1.92: 中間ファイルの掃除は「生成の責任」として _gen_lock 保持のままここで行う
            # 注意: out_path(TEMP_FINAL/キャッシュ)は呼び出し元が送信で読むためここでは削除しない
            for _t in (TEMP_48K, TEMP_8K, TEMP_INTRO_PADDED):
                _safe_remove(_t)

並行テストで「起動アナウンス+カーチャンク応答の同時発生 → 直列化して両方完走」「送信3連発と並走するプリキャッシュ3局が全て成功(sox FAIL ゼロ)」を確認。

教訓: 外部レビューは(事実誤認を含んでいたとしても)自分の変更履歴の記述の甘さを浮き彫りにしてくれる。特に「後着は待機」のような踏み込みすぎた表現は要注意。実装の実態を正確に書くこと。


第9章 キャッシュディレクトリ消失事件 ─ V1.93

症状

V1.92 を配置した後、実機で新たなエラー:

sox FAIL formats: can't open output file `/dev/shm/ocv_reply_cache_5358/JI2TAB.building.wav': No such file or directory
[!!] SoX failed   rc=2
[!!] Precache     JI2TAB      failed

原因の切り分け:

ls -la /dev/shm/ocv_reply_cache_5358/
# → 存在しない

キャッシュディレクトリ本体が消えていました。 SoX の「No such file or directory」は書き込み先ディレクトリがない、と言っている。

原因の推測

いくつかの可能性がありました:

  1. /dev/shm の逼迫 → df -h /dev/shm で1%使用しかなく棄却
  2. ボット自身が消している → コード上見当たらない
  3. 別プロセスの掃除機構(tmpreaper 等)
  4. tmpfs 特有の挙動

根本原因は完全には特定できませんでした。 正直に言うと。

V1.93:保険による対処

原因が分からなくても症状は止められます。二段の保険を入れました:

保険1:実行時の存在保証

def _ensure_cache_dir():
    if REPLY_CACHE_ENABLED:
        try:
            os.makedirs(CACHE_DIR, exist_ok=True)
        except OSError:
            pass

ビルド直前に必ず呼ぶ。ディレクトリが消えていても自動再作成してから SoX に書く。

保険2:起動時の強化

_init_reply_cache を「あれば削除/なければ作成」の二択から、「常に makedirs(exist_ok=True)」に変更。二重の守り。

検証

意図的に shutil.rmtree(CACHE_DIR) で削除した状態から自動復旧するかテスト:

  • 初回生成 OK
  • Dir 削除 → 自動再作成して生成成功
  • Dir 削除 → 生成 を繰り返しても全成功

実機で動作確認済み、SoX 失敗の再発なし。

教訓

根本原因が分からなくても、対症療法で守るのは正しい。 特に無人運用のシステムでは、原因調査を続けながら現場が止まらないようにする方が優先度が高い。V1.93 の保険を入れた上で、次に事象が起きたときに追加情報(時刻・Dir 状態・/dev/shm 使用率)を取れる状態にしておく、というのが実践的な対応。


終章 得られた知見

DVSwitch 系で嵌りやすい罠

  1. SoX の拡張子問題(第1章): 一時ファイル名の拡張子で SoX の出力形式が決まる。.part .tmp などは使えない。必ず .wav にする
  2. SFR 中継の折り返し時間(第2章): ソフトでは短縮できない物理制約。実測で追い込むしかない
  3. TGIF の無音ゲート(第5-6章): デジタル完全無音は転送されない。前パディングは非無音(微小トーン推奨)
  4. AMBE ボコーダとホワイトノイズ(第6章): 声用コーデックにノイズは通せない。周期信号を使う
  5. DMR の途中参加処理(第7章): ヘッダ喪失ストリームは受信機によって表示が更新されない場合がある
  6. /dev/shm ディレクトリの寿命(第9章): 起動時のみ作る設計は危うい。実行時にも保証すべき

tcpdump + 自作解析器の威力

このデバッグで最も効いたツールは、pcap を読む自作 Python スクリプトでした。

  • 外部依存なし(scapy 等不要)
  • 自分の解析軸で情報を集計できる(ストリーム別・ヘッダ有無・埋め込みデータ)
  • 徐々に育てられる(v1.0 → v1.3、機能追加が容易)

「51 フレームの一致」も「Talker Alias が JJ2YYK と正しい」ことも、この自作解析器なしには判明しませんでした。症状の原因が分からないときは、電波レベルまで降りる。ダッシュボード表示や中間ログでは判断材料にならない。

仮説と棄却のプロセス

このデバッグでは、5つ以上の仮説を立て、そのうち4つを棄却しました:

  • 音声データの中身 → 棄却
  • USRP 終端の1発頼み → 棄却
  • USRP メタデータ欠落 → 棄却
  • Talker Alias の中身 → 棄却
  • 先頭無音のスキップ → 正解

棄却された仮説も無駄ではありません。それぞれの検証で機能追加(EOT多重化・SET_INFO 送出・TA 監視強化)が入り、結果として堅牢になりました。「正しい対策」だけを追い求めず、「その仮説が正しくなくても悪化しない対策」を積むのは、無人運用システムでは有効な戦略です。

AI 協働開発の実際

このデバッグ全体は、開発者とAI(Claude)の対話形式で進行しました。AI 側の役割:

  • ソースの網羅的な検索と分析
  • 実測データからの仮説立案
  • 自作解析器の実装
  • コード差分の生成と検証

開発者側の役割:

  • 症状の正確な報告
  • 実機での配置と検証
  • 「これは違う気がする」という直感の提示
  • 方針の最終判断(GPIO 撤退、キューよりスキップ、等)

AI が生成したコードには複数のバグがありました(V1.75 の .part 事故、V1.91 の Race Condition の見落とし、V1.92→V1.93 のディレクトリ消失事故)。しかし実測での検証サイクルにより、いずれも次の版で修正されています。AI は「試行の速度」を上げるが、「正しさ」は実測で保証するしかない。 両者の役割分担が明確なら、複雑なシステムのデバッグでも協働は成立します。


付録A:主要な源泉

  • DVSwitch 公式クライアント pyUC: https://github.com/DVSwitch/USRP_Client/blob/master/pyUC.py
    • USRP プロトコルの正規実装。sendMetadata() はバイト単位で参照した
  • MMDVMHost: https://github.com/g4klx/MMDVMHost
    • DMR 埋め込み LC のデコード(DMREmbeddedData.cpp / Hamming.cpp / CRC.cpp)を Python に移植
  • DMR (ETSI TS 102 361): 埋め込み LC の構造、Talker Alias の書式

付録B:確定した定数値(V1.93 時点)

# 応答タイミング
REPLY_TX_LEAD_DELAY_SEC = 1.0        # 命中時の SFR 折り返し保護(wall-clock 待ち)
PRE_AUDIO_SILENCE_SEC = 1.5           # 焼き込み助走(受信機の途中参加同期用)

# TGIF ゲート対策
NOISE_FILL_ENABLED = True             # 100Hz 微小トーンでの先頭リード
NOISE_FILL_AMP = 100                  # トーン振幅(-50dBFS)
NOISE_LEAD_PACKETS = 65               # リード継続時間(1.3s、TGIF ゲート床を跨ぐ)

# USRP プロトコル
TX_METADATA_ENABLED = True            # 送信前 SET_INFO(pyUC 互換)
USRP_EOT_REPEAT = 1                   # 終端 keyup=0 の送出回数

# キャッシュ
REPLY_CACHE_ENABLED = True
PREWARM_ON_HEADER = True
CACHE_SCHEMA = "v1"

付録C:デバッグに使った独自ツール

  • dmrd_analyze.py v1.3: pcap → DMRD ストリーム分析器
  • emblc.py: 埋め込み LC / Talker Alias デコーダ(MMDVMHost 移植)

これらはこのプロジェクトのリポジトリに残しておきます。将来同じ DVSwitch 系のデバッグをする人の役に立つはずです。

📝 執筆:JI2TAB(尾張旭 DMR デジピーター 管理人)
🏛 JJ2YYK あいちデジタルコミュニケーションハムクラブ

この記録は 2026 年 7 月の実測ベースです。 TGIF ネットワークの仕様や DVSwitch の実装は将来変わる可能性があります。同じ症状に遭遇した方は、まずこの記録の第5章「51 フレームの一致」の検証を、ご自分の環境で再現してみてください。数字が違えば、そこから新しいデバッグが始まります。

JJ2YYK
  • JJ2YYK

1件のピンバック

コメントする

メールアドレスが公開されることはありません。 が付いている欄は必須項目です