#4 の最後に、こう書いた。「次は WASM+WebUSB でブラウザに載せて、『URLを開くとテレビが映る』ところまで持っていきたい。……が、それはまた別の話」。その別の話を、本当にやった回。Rustで書いた復調器①〜⑥を、まるごとブラウザの中で動かす。ドングルはWebUSBで直結。サーバも、ネイティブアプリも、GNU Radioも要らない。タブを開くだけで、生の地デジが映って、音が鳴る。
復調器を、そのままブラウザへ ── WebAssembly
まず驚くほどあっさり通ったのがWASM化だった。#1〜#4で書いた crates/isdbt-dsp ── FFT(rustfft)、複素数(num-complex)、Viterbi、リードソロモン、そのすべてが wasm32 にそのままコンパイルできる。復調器をライブラリの StreamingDecoder にまとめ、薄いラッパーを被せるだけ:
#[wasm_bindgen]
impl WasmDecoder {
pub fn feed(&mut self, iq: &[u8]) -> Vec<u8> { // IQ(u8) を入れると MPEG-TS が出る
self.inner.feed(iq)
}
}
wasm-pack build --target web。約250KBの .wasm が出る。Nodeで走らせると、ネイティブと1バイト違わず同じTSパケットが出た。②同期から⑥逆拡散まで、この250KBの中で全部起きている。ここは楽勝だった。難所は、その先だった。
ドングルを、ブラウザから直に叩く ── WebUSB と、R828Dの罠
WebUSBは、ブラウザからUSBデバイスの制御転送・バルク転送を直接叩けるAPIだ。つまり librtlsdr 相当のRTL2832Uドライバを、JavaScriptで自分で書く。これが茨だった。最初の実機テスト、取れたIQは std=0.2 ── ほぼ真っ平ら、信号ゼロ。チューナが同調していない。
原因は二つ、どちらも「思い込み」だった。ひとつ目:私はチューナをR820T(I2Cアドレス0x34)だと決めてかかっていた。だが RTL-SDR Blog V4 のチューナは R828D、I2Cアドレスは0x74。0x34宛の読み書きは全部stallして当たり前だった。レジスタ0を読んで 0x69 が返ればチューナ確定 ── アドレスを走査して自動検出するようにした。
ふたつ目:R820T/R828DはゼロIFではなく、3.57MHzの低IF+実ADCで動く。デモッド側を 0xb1=0x1a(ゼロIF無効)、0x08=0x4d(I側ADCのみ)、IF周波数の設定、スペクトル反転 ── と正しく組み、チューナのLOは 中心周波数+3.57MHz に。フィルタ校正やPLLのVCO探索まで、実績あるlibrtlsdr/rtlsdrjsに忠実に写した。すると:
std=0.2(真っ平ら) → std=42.8(健全な信号)
→ 取得IQを自作復調器に通す → 復号率 100%
ブラウザのAPIだけで叩いたドングルから、地上波ワンセグを捕まえて、自分の復調器で100%解けた。ここまでで半分。だが、まだ「録って→後で解く」だ。本当の壁は「実時間で、映す」だった。
実時間の罠 ── オフライン検証は、何も保証していなかった
白状すると、ここまでの検証はすべて「IQをファイルに保存 → あとでオフラインで復号」だった。ファイルは落ち着いて処理できる。だが実時間のライブは、待ってくれない。ブラウザで「接続」を押した瞬間、抑え込んでいた問題が連鎖で噴き出した。
- WASMがクラッシュ(
unreachable):同期できない弱電界だと、入力バッファが無限に伸びてWASMがメモリ枯渇→abort。ファイルは有限なので露見しなかった。→ 未ロック時はバッファを上限で抑える。 - NaNでパニック:ライブのノイズでパイロットがほぼ0の搬送波が出ると、等化
Y/Hが Inf/NaN に。それがViterbiのメトリクスを汚し、最良状態を選ぶmax_byがNaNで落ちる。→ ソフト値を有限値にサニタイズ、比較もNaN安全に。 - サンプル欠落:重い同期計算がWebUSBの読み取りループをブロックする隙に、RTL2832のFIFOが溢れてサンプルが飛ぶ→OFDMの連続性が壊れて同期できない。→ 転送を常時32本in-flightにする多重バッファ読みで、処理が一瞬止まってもFIFOを吸い出し続ける。
ここまで潰して、ようやく同期+整列ロック。TSも出た。mpegts.jsは生TSをpush給餌できず、ブラウザのMSEは video/mp2t を受けないので、映像は WebCodecsのVideoDecoderで直接H.264をデコードしてcanvasに描く方式にした(このワンセグ波は1つのPESに複数フレームを AUD=NALタイプ9 区切りで詰めるので、AU境界はPUSIでなくAUDで切る、という細かい罠つき)。そして ── 映った。けれど、動かない。
「映ったのに動かない」の正体 ── Web Worker
絵は出る。でも1秒に1コマ程度で、ほぼ静止画。速度不足を疑って実測すると、意外な事実:WASMはネイティブ比たった1.26倍遅いだけで、ロック後の定常状態は約1.3倍リアルタイム ── ちゃんと追いつく。犯人は生の速度ではなく、メインスレッドの飽和だった。WebUSB読み取り・WASM復調・WebCodecsデコード・canvas描画を全部1本のスレッドで殴り合わせていて、描画コールバックが割り込めなかったのだ。
答えは Web Worker。メインスレッドは「WebUSBで読んで、生IQをワーカーへ0コピーで渡す」だけにする。重い復調・H.264/AACデコード・OffscreenCanvasへの描画は、全部ワーカーの中で回す。さらに、ワーカー内でも同期処理が描画を食わないよう、処理を小分けにして合間にイベントループへ制御を返す。これで、メインは詰まらず、絵が実時間で動き出した。動きは、完璧になった。
音を、途切れず、ズレなく ── WebCodecs と AudioWorklet
残るは音。ワンセグ音声は AAC-LC(ADTS形式、24kHz基底+SBRで48kHz出力・ステレオ)。PID 0x152からADTSフレームを切り出し、WebCodecsのAudioDecoderに渡すとPCMが返る(ADTSはそのまま食える)。最初はチャンクごとに音源ノードを並べて鳴らしたが、プツプツと切れた。境界クリックとアンダーランだ。そこで AudioWorkletのリングバッファに替え、音声スレッド上でサンプル単位に連結して1本の連続ストリームとして出す。プツプツは消えた。
次はA/Vのズレ。音が映像に追いつかない。理由は起動時のバックログにあった。ロックまでに数秒ぶんのIQが溜まる。映像は「最新の1コマだけ描く」ので早送りで一気に追いつくが、音声は全サンプルを等速で鳴らすしかなく、その差ぶん遅れる。直し方は「ロックできた瞬間、溜まったバックログを捨てて『今』から復号する」。ただし乱暴に捨てると整列が壊れる。そこで 204シンボル(=1 OFDMフレーム=64 RSブロック=エネルギー拡散PRBSのちょうど1周期、しかも 204÷4 の余りが0)単位で捨てる ── これで位相もPRBSリセットもバイト整列も全部保たれる。飛ばした先から復号しても、成功率は99.5%を保った。映像も音声も「今」から始まる。ズレは消えた。
開くと、映る

https://yasu-home.com/1seg/ ── Chrome/EdgeでこのURLを開き、RTL-SDRを挿して「接続」を押すと、数秒のロックのあと、canvasに生放送が映って、音が鳴る。手元にドングルが無くても、rtl_sdr の生IQファイルを選べば、その場でブラウザが復号して再生する。ソースは全部 github.com/Ryujiyasu/1seg に置いた。
u8のIQバイト列が、OFDMのシンボルになり、QPSKの点になり、ビットになり、TSパケットになり、H.264とAACになって、人の顔と、時計と、テロップと、その声になる ── その全部が、いまブラウザのタブの中で、実時間で流れている。①RF入力→②同期→③等化→④TMCC・デインターリーブ→⑤Viterbi+RS→⑥逆拡散→H.264/AAC再生。#0でRTL-SDRのドライバに躓いたところから始まって、ここまで来た。GNU Radioに頼らない現代のRust実装 ── 調べた限り誰もやっていなかったこの一台は、いまやURLひとつになった。開けば、テレビが映る。それで、完成だ。
コメントを残す