55
23

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

デジタル復調の学習を目的として、ワンセグチューナーで地デジのフルセグTSを抜く

Posted at

お詫びと訂正

 タイトルにて「ワンセグチューナー」と表記しておりますが、正しくは「ワンセグチューナーの技術を応用したSDR受信機」となります。お詫びして訂正いたします。

本題

 さて、茶番はこのあたりにして(今回は使わなかったと言うだけで、本物の「ワンセグチューナー」でもフルセグを受信できるはずなので、あながちタイトが誤っているというわけではないのですが、茶番をやりたいための茶番でした)。

 今回はデジタル変調の復調を学習することを目的としているので、それ以外の部分(例えば受信機のハードウェアや、リードソロモン誤り訂正のような情報理論的分野)には触れません。また、限定受信を目的としたスクランブルの解除や映像の復号にも一切触れません。一方で、海外の人が作ったGNU Radioのソースコードをコピペして「受信したよ!」と言っても学習という意味では全く意味がありませんから、ISDB-Tの復調に関しては可能な限り自力で頑張ることにします。とはいえ、FFTライブラリのように汎用的なモジュールは積極的に利用します。今回は個人の遊びの範疇なので、このあたりの拘束条件は自分が納得できるように適当に決めています。

お断り

 私は教育機関や研究機関、あるいは企業などにおいて放送・通信・情報理論等の分野と関わった経験が一切ありません。誤った解説を多々行っていると思いますがご了承ください。
 これからこれらの道へ進みたい方は以降を読まないことをおすすめします。誤った知識を覚えると学習し直すのに数倍の苦労を要します。注意してください。
 逆に言えば、完璧な素人でもググったりするだけでもこのくらいはできるよ、ということで、温かい目で見ていただければと思います。

情報源

 ISDB-TはARIBから規格が公開されています。ただし英語へ翻訳した版のみ無料で公開されており、オリジナルの日本語版は有料での販売です。

 ISDB-T自体(主に送信側の仕様)はSTD-B31で規定されています。
 標準規格の入手について(STD-B31)|一般社団法人 電波産業会

 受信機はSTD-B21で「望ましい仕様」が公開されています。
 標準規格の入手について(STD-B21)|一般社団法人 電波産業会

 基本的にはSTD-B31に従って(後ろから)処理していくことになります。
 受信処理の基本的な流れはSTD-B21の図「Outline of a receiving block diagram」にブロックダイアグラムが記載されていますが、必ずしもこの通りに処理する必要はなく、SDRで受信する場合は処理方法を変えたほうが楽な部分もあるので、あくまでも参考程度です。

 ISDB-Tの受信に使用している個別の技術は他の通信仕様と共通している箇所も多いので、ググれば色々と情報が出てくると思います。

復調するISDB-Tのパラメータ

 ISDB-Tは受信のしやすさや誤り訂正の強度に応じて様々な放送モードが規定されています。それらすべてのモードに対応するのは面倒ですし、テスト環境(信号発生器等)もなしに実装するのも困難です。実装したところで使い道もないですし。というわけで、現在放送されているパラメータだけ実装することとします。
 具体的には6MHz、モード3、ガードインターバル比1/8、Layer A[ 部分受信、QPSK、2/3、4 ]、Layer B[ 12セグ、64QAM、3/4、2 ]というようなパラメータになります。

開発環境

 Windows11のパソコンを使用し、C# 12にて開発を行いました。最初に簡単な動作確認を目的としてBitmapを使用していますが、ISDB-Tの復調に関してはOSに依存した処理は無いはずなので、最近の.NETが走る環境であれば大抵の環境で動くはずです。
 C#のusingディレクティブは省略しているので、プログラムを試す場合は必要に応じて追加してください。たいていはVisual Studioがよしなにやってくれるはずです。

 今回は実装の簡単さを優先し、パフォーマンス(処理速度)に関してはほとんど考慮していません。そのため、最終的にIQファイルからTSファイルへ変換する速度は実速度の15倍以上(@ Core i7-12700、シングルコア、デバッグビルド)遅いです。

NuGet

 NuGetのライブラリをいくつか使用します。

  • 最初に動作確認のためにSystem.Drawing.Commonを使用します(必須ではない)。Windows以外の環境では使えないらしいです。
  • 複素数およびFFTの取り扱いのためにMathNet.Numericsを使用します。
  • 受信したパケットの誤り訂正のためにZXing.Netのリードソロモンデコーダを使用します。

Q. なぜGNU Radioを使わないの?

 A. GNU Radioを使ったことがないから

電波の受信

 放送の受信には例に漏れずワンセグチューナー系のSDRドングルを使用します。具体的にはRTL-SDR blogのVer.3ドングルを使用しました。この系統のドングルは帯域幅の上限が2.5MHz程度であり、フルセグ放送の5.7MHzを受信するには性能が足りません。一方で現在放送されているISDB-Tは大量のパイロット信号を含むOFDMで構成されており、フレーム構造も相まって、複数の受信機でパラレルに受信するのが容易な仕組みとなっています。そのため、SDRドングルを3本並べることで必要な帯域幅を確保しています。理想的には4本欲しいところですが、3本でも十分実用的に動作します。

 ドングルの接続にはRTL-SDR blog版のlibrtlsdrを使用しました。

 GitHub - rtlsdrblog/rtl-sdr-blog: Modified Osmocom drivers with enhancements for RTL-SDR Blog V3 and V4 units.

 受信にはバイナリを使用しますが、コマンドの確認のためにソースコードも一緒にダウンロードしておくと便利です。

 librtlsdrはいくつかのソフトウェア等が付属しており、その中のrtl_sdr.exeを使用すれば受信データを直接IQファイルのバイナリとして書き出す事ができます。しかしこのソフトウェアは起動に若干の時間がかかり、複数のドングルで並列に受信しようとすると十分な同時性を確保できないため、他の方法を使用する必要があります。
 今回はrtl_tcp.exeを使用する方法を採用しました。すなわち時間のかかる起動処理は予め行っておき、複数のドングル(複数のrtl_tcp.exe)へ同時に接続することで、十分な同時性を確保するという手法です。
 また、TCP経由で接続することで、受信機と計算処理を行うPCを情報の劣化無しに離れた位置へ設置できるという利点もあります。

サーバー側

 rtl_tcpはrtl_tcp.exe -a 192.168.1.201 -p 1234 -d 0のようなコマンドで起動します。-aと-pでIPアドレス/ポートを指定し、-dでデバイス番号を指定します。複数のドングルを使用する場合はそれぞれに異なるデバイス番号とポート番号を指定する必要があります。
 アドレスの指定にはIPアドレスの他にコンピューター名も指定できます。アドレスのパースにはgetaddrinfoを使用しているようです。どのような優先度になっているのかよくわかりませんが、自分の環境ではリンクローカルIPv6が使用されました。今回はLAN経由で受信機と解析PCを別の場所に置いていますが、同一のPCで処理する場合は-aを指定しなければ127.0.0.1が使用されます。
 -dで指定するデバイス名は、数字を与えればデバイスに割り振られる番号として認識し、文字列を与えるとデバイスに設定された文字列として認識するようです(デバイスの名前はrtl_eeprom.exeで書き換え可能ですが、デバイスを破壊する危険性があるので使う際は注意してください)。今回はすべてのドングルを分配器経由で同じアンテナに接続していますが、デバイス名を使用すれば指向性や帯域の違うアンテナに接続されたドングルを選択して起動することができます。
 サーバー側はrtl_tcpが起動していればそれだけでいいのですが、注意点として、ネットワークの帯域幅はドングル1本あたり32Mibps(2.048Mspsの場合)必要なので、今回のように3本並列で使用する場合は100Mbpsでは心もとなく、安心して使うには1GbEが必要です。

 ドングル1本ずつポートやデバイス番号を替えて起動するのは面倒なので、バッチファイルを作っておきます。

rtl_tcpを複数個起動するバッチファイル
start "-d 0" "%USERPROFILE%\Downloads\rtl-sdr-blog-1.3.4 Release\x64\rtl_tcp.exe" -a %COMPUTERNAME% -p 1234 -d 0
timeout /t 1 > nul
start "-d 1" "%USERPROFILE%\Downloads\rtl-sdr-blog-1.3.4 Release\x64\rtl_tcp.exe" -a %COMPUTERNAME% -p 1235 -d 1
timeout /t 1 > nul
start "-d 2" "%USERPROFILE%\Downloads\rtl-sdr-blog-1.3.4 Release\x64\rtl_tcp.exe" -a %COMPUTERNAME% -p 1236 -d 2

 パスは適当に書き換えてください。
 -aにコンピュータ名を挿入しているので、アドレスはいい感じのアドレスが割り振られると思います。固定アドレスで運用したい場合はそのアドレスを指定してください。
 途中でtimeoutを挟んでいますが、これは複数のウインドウが連番で起動するようにするためなので、見た目に拘ったりする必要がないなら連続して起動できます。

 ドングル等の構成は以下のようになります。

2023-12-26_02-34-06_DSC06649.jpg

 NUCフォームファクタ(モバイルCeleron 2.0GHz)にドングル3本を接続し、テレビ用の分配器を経由してアンテナへ接続します。操作(rtl_tcp.exeの起動やシャットダウン操作等)はリモートデスクトップで行うと割り切れば画面や操作デバイスは不要で、かなりコンパクトな受信機構成となります。サーバー側は信号処理は一切行わず、USBからLANへデータを横流しするだけなので、CPUは1.0GHz程度でアイドルレベルでした(リモートデスクトップのほうが高負荷)。

クライアント側

 TCPで接続し、いくつかのコマンドを投げた上で、送られてきたデータをファイルに記録します。
 いきなり地デジ放送を受信しても、うまく復調できなかったときに原因の切り分けが困難なので、まずは見た目でわかりやすい電波(FMラジオ)を受信して動作確認を行います。

rtl_tcp.exeのクライアント(複数を並列して記録)
internal class Program
{
    static readonly DateTime _startDate = DateTime.Now;
    static readonly TimeSpan _rxLength = TimeSpan.Zero;
    static readonly string _serverName = "server-name";
    static readonly int _portStart = 1234;

    const int _syncBufferSize = 4096;

    static Program()
    {
        // 直ちに開始させると少しずれるので、少し遅延させる(環境に応じて調整)
        _startDate = DateTime.Now.AddSeconds(1.5);
        _startDate -= new TimeSpan(_startDate.TimeOfDay.Ticks % TimeSpan.TicksPerSecond);

        (_gain, _sps, _freqs) = (22, (uint)2048e3, [(uint)84.15e6, (uint)84.25e6, (uint)84.35e6]);
    }

    static readonly uint _gain;
    static readonly uint _sps;
    static readonly uint[] _freqs;

    static int _inhibit;

    static void Main()
    {
        using var tcpClients =
            Connect(
                _serverName, _portStart, _freqs.Length,
                TimeSpan.FromSeconds(0.2))
            .Result;

        Console.WriteLine($"servers");
        foreach (var v in tcpClients)
        {
            Console.WriteLine($"  {v.Client.RemoteEndPoint as IPEndPoint}");
        }
        Console.WriteLine();

        if (tcpClients.Length < _freqs.Length)
        {
            Console.WriteLine(string.Format(
                "{0} 個のサーバーに接続できませんでした",
                _freqs.Length - tcpClients.Length));
            return;
        }

        foreach (var a in tcpClients.SkipLast(_freqs.Length))
        { // 必要以上に確保した分は破棄
            a.Dispose();
        }

        _inhibit = _freqs.Length + 1; // 1個多く止めておき、タイマで解除

        using CancellationTokenSource cts = new();

        Task[] tasks = [
            ..
            tcpClients.Zip(_freqs)
            .Select(a =>
            {
                var filename = $"{_startDate:yyyyMMddTHHmmss}_{a.Second}Hz.wav";
                Console.WriteLine($"@\"{Path.GetFullPath(filename)}\",");
                return RxIqData(a.First, filename, a.Second, cts.Token);
            }),

            SamplingTimer(cts.Token),
            ShowRxTime(cts.Token),
        ];

        Console.CancelKeyPress += (object? _, ConsoleCancelEventArgs e) =>
        {
            if (!cts.IsCancellationRequested)
            {
                cts.Cancel();
                e.Cancel = true;
            }
        };

        // Anyで待ってタスクがどれか一つでも落ちたら(e.g. タイマー、通信エラー)CTSで他のタスクも終了させる
        Task.WaitAny(tasks);
        cts.Cancel();
        Task.WaitAll(tasks, 1000);
    }

    static async Task RxIqData(
        TcpClient tcpClient,
        string filename,
        uint freq,
        CancellationToken cancellationToken)
    {
        using FileStream fs = new(filename, FileMode.Create, FileAccess.Write);
        WavHeader.Create(1, 2, _sps, 8).Write(fs);

        try
        {
            using var ns = tcpClient.GetStream();
            ns.WriteTimeout = ns.ReadTimeout = 1000;

            ; void SendCommand(byte cmd, uint arg)
            {
                var tmp = IPAddress.HostToNetworkOrder((int)arg);
                ns.Write([cmd, .. MemoryMarshal.Cast<int, byte>(new(ref tmp))]);
            }

            SendCommand(0x1, freq);
            SendCommand(0x2, _sps);
            SendCommand(0x8, 0); // AGC off
            SendCommand(0xD, _gain);

            Interlocked.Decrement(ref _inhibit);

            {
                var buff = new byte[_syncBufferSize];
                while (0 < _inhibit)
                {
                    for (int n = 0, m; n < buff.Length; n += m)
                    {
                        m = await ns.ReadAsync(buff, cancellationToken);
                        if (m == 0) { throw new IOException(); }
                    }
                }
            }

            await ns.CopyToAsync(fs, cancellationToken);
        }
        catch (OperationCanceledException) { }
        finally
        {
            WavHeader.UpdateSubchunk2Size(fs);
        }
    }

    static async Task SamplingTimer(CancellationToken cancellationToken)
    {
        try
        {
            var wait = _startDate - DateTime.Now;
            if (TimeSpan.Zero < wait)
            {
                await Task.Delay(wait, cancellationToken);
            }

            Interlocked.Decrement(ref _inhibit);

            await Task.Delay(
                TimeSpan.Zero < _rxLength
                    ? _rxLength
                    : TimeSpan.FromDays(49),
                cancellationToken);
        }
        catch (OperationCanceledException) { }
    }

    static async Task ShowRxTime(CancellationToken cancellationToken)
    {
        var start = _startDate;
        var len = _rxLength.Ticks;

        Console.CursorVisible = false;

        while (true)
        {
            string state;
            long ticks;
            var now = DateTime.Now;
            if (now < start)
            {
                state = "wait";
                ticks = (start - now).Ticks;
            }
            else
            {
                state = "rec";
                ticks =
                    0 == len
                    ? (now - start).Ticks
                    : (start - now).Ticks + len;
            }

            ticks -= ticks % TimeSpan.TicksPerSecond;
            Console.Write($"\r{state,4} {new TimeSpan(ticks)}...\x1B[K");

            try
            {
                await Task.Delay(100, cancellationToken);
            }
            catch (OperationCanceledException) { break; }
        }

        Console.WriteLine();
        Console.CursorVisible = true;
    }

    static async Task<IDisposableArray<TcpClient>> Connect(
        string serverName,
        int portStart,
        int portN,
        TimeSpan timeout)
    {
        var addresses = await Dns.GetHostAddressesAsync(serverName);

        using CancellationTokenSource cts = new(timeout);

        ConcurrentBag<TcpClient> bag = [];

        try
        {
            Parallel.ForEach(
                addresses
                .SelectMany(address =>
                    Enumerable.Range(portStart, portN)
                    .Select(port => (address, port))),
                new ParallelOptions { CancellationToken = cts.Token },
                async a =>
                {
                    TcpClient client = new();
                    try
                    {
                        await client.ConnectAsync(a.address, a.port, cts.Token);

                        bag.Add(client);

                        if (portN <= bag.Count)
                        {
                            cts.Cancel();
                        }
                    }
                    catch
                    {
                        client.Dispose();
                        return;
                    }
                });
        }
        catch (OperationCanceledException) { }

        return new([..
            bag.OrderBy(a => (a.Client.RemoteEndPoint as IPEndPoint)?.Port ?? 0)]);
    }
}

class IDisposableArray<T>(T[] objects) : IDisposable, IEnumerable<T>
    where T : IDisposable
{
    public int Length => objects.Length;

    public void Dispose()
    {
        foreach (var obj in objects)
        {
            obj.Dispose();
        }
    }

    public IEnumerator<T> GetEnumerator()
     => ((IEnumerable<T>)objects).GetEnumerator();

    IEnumerator IEnumerable.GetEnumerator()
     => objects.GetEnumerator();
}

[StructLayout(LayoutKind.Sequential, Pack = 1)]
record struct WavHeader(
    uint ChunkID,
    uint ChunkSize,
    uint Format,
    uint Subchunk1ID,
    uint Subchunk1Size,
    ushort AudioFormat,
    ushort NumChannels,
    uint SampleRate,
    uint ByteRate,
    ushort BlockAlign,
    ushort BitsPerSample,
    uint Subchunk2ID,
    uint Subchunk2Size)
{
    public override readonly string ToString()
     => string.Format("{0} {{ {1} }}",
            nameof(WavHeader),
            string.Join(", ",
                $"{nameof(ChunkID)} = \"{Uint2Str(ChunkID)}\"",
                $"{nameof(ChunkSize)} = {ChunkSize}",
                $"{nameof(Format)} = \"{Uint2Str(Format)}",
                $"{nameof(Subchunk1ID)} = \"{Uint2Str(Subchunk1ID)}\"",
                $"{nameof(Subchunk1Size)} = {Subchunk1Size}",
                $"{nameof(AudioFormat)} = {AudioFormat}",
                $"{nameof(NumChannels)} = {NumChannels}",
                $"{nameof(SampleRate)} = {SampleRate}",
                $"{nameof(ByteRate)} = {ByteRate}",
                $"{nameof(BlockAlign)} = {BlockAlign}",
                $"{nameof(BitsPerSample)} = {BitsPerSample}",
                $"{nameof(Subchunk2ID)} = \"{Uint2Str(Subchunk2ID)}\"",
                $"{nameof(Subchunk2Size)} = {Subchunk2Size}"));

    public static WavHeader Create(
        ushort format,
        ushort channels,
        uint samplerate,
        ushort bitspersample,
        ushort? blockalign = null)
         => new(Str2Uint("RIFF"), 0,
                Str2Uint("WAVE"),
                Str2Uint("fmt "), 16,
                format, channels, samplerate,
                (blockalign ??= (ushort)((bitspersample + 7) / 8 * channels)) * samplerate,
                blockalign.Value, bitspersample,
                Str2Uint("data"), uint.MaxValue);

    private static uint Str2Uint(string a)
    {
        Span<byte> tmp = stackalloc byte[4];
        if (!Encoding.UTF8.TryGetBytes(a, tmp, out var bytesWritten) ||
            bytesWritten != 4)
        {
            throw new InvalidOperationException();
        }

        return BitConverter.ToUInt32(tmp);
    }

    private static string Uint2Str(uint a)
     => Encoding.UTF8.GetString(MemoryMarshal.Cast<uint, byte>(new(ref a)));

    public void Write(Stream stream)
     => stream.Write(MemoryMarshal.Cast<WavHeader, byte>(new Span<WavHeader>(ref this)));

    public static void UpdateSubchunk2Size(Stream stream)
    {
        var pos = stream.Position;
        var len = pos - 44;
        if (len <= uint.MaxValue)
        {
            stream.Position = 40;

            var tmp = (uint)len;
            stream.Write(MemoryMarshal.Cast<uint, byte>(new(ref tmp)));

            stream.Position = pos;
        }
    }

    public static bool TryLoad(Stream stream, [MaybeNullWhen(false)] out WavHeader header, out bool dataChunk)
    {
        var pos = stream.Position;

        header = new();
        dataChunk = false;

        var tmp = MemoryMarshal.Cast<WavHeader, byte>(new(ref header));
        if (tmp.Length == stream.Read(tmp) &&
            Str2Uint("RIFF") == header.ChunkID &&
            Str2Uint("WAVE") == header.Format &&
            Str2Uint("fmt ") == header.Subchunk1ID &&
            header.Subchunk1Size == 16)
        {
            dataChunk = Str2Uint("data") == header.Subchunk2ID;
            return true;
        }

        stream.Position = pos;
        return false;
    }
}

 このプログラムではヘッダとしてWAVファイルのヘッダを付与し、FMラジオを記録します(適当なところでctrl-cで止めてください)。受信したIQファイルは例えばSDR#のような汎用のSDR復調ソフトウェアで読み込むことができます。
 server-nameにはサーバ側のIPアドレスを設定しておきます。コンピュータ名を指定すれば名前解決によってIPアドレスを探しますが、名前解決を行う場合は一旦インターネット上のDNSサーバーに問い合わせを行うらしく、起動時に数秒の待ちが発生します(Google Public DNSであれば1秒弱、ルータ経由のデフォルト設定だと4秒程度、環境依存)。
 _startDateに日時を設定しておけば、その時間になってからサンプリングを開始します。_rxLengthと合わせて使えば予約録画的な使い方もできます。ただし待機中もTCP接続を維持しています。
 _rxLengthに時間を設定すれば指定時間が経過した時点でサンプリングを終了します。デフォルトでは_rxLengthが未指定で、カウントアップを表示しながら無制限に(ctrl-cが押されるまで)サンプリングを行います。
 今回は簡易的な実装なため、起動時から_startDateまでの時間、また_rxLengthの長さは最大で2ヶ月弱程度しか設定できません。普通は問題にならないはずなので、このような実装で妥協しています。
 _freqsには受信地点で受信可能なFMラジオの周波数を指定してください。後で確認する際にわかりやすいように、ドングルごとに100kHz差を与え、また50kHzのオフセットを与えることでIQファイルのDCに乗らないようにしています。

 WAVファイルはヘッダが32bitのため最大でも4GBがリミットで、そのために64bitへ拡張されたWAV規格もありますが、一部のソフトウェアは通常のWAVで4GBを超えるファイルも取り扱うことが可能であり(SDR#等は非対応)、このプログラムでもそれに則った実装になっています(単にヘッダの32bitデータサイズを無視するだけですが)。例えば2.048Mspsで1時間記録すると15GB程度になり、それが3本ありますから、45GB/h程度のデータレートになります。2層のBlu-rayの容量が50GBですから、Blu-ray1枚で1時間のフルセグ放送の生データを記録できます。通常、2層のBlu-rayには地上デジタル放送を6時間記録できますから、このIQファイルは映像データに比べておよそ6倍程度冗長なデータだと言えます。

 FMラジオ(80MHz前後)に対応したアンテナを使用して記録し、汎用のSDRソフトウェアで復調を行うことで正しく受信できているか確認します。古い住宅の場合はアナログ放送を受信するためにVHFアンテナがついていることがあり、TVの送信所とFMラジオの送信所が同じ場所にある場合は、これを使ってFMラジオも受信できます。VHFアンテナを撤去していたり、アナログ放送終了後にアンテナを設置した場合はUHFアンテナしか設置されておらず、FMラジオを受信するには別のアンテナを使用する必要があります。FMラジオを受信するのは受信機の動作確認を行うためなので、あまり強く受信する必要はありません。送信所からそう離れていない場合は窓際にダイポールアンテナを1本立てるだけでも十分受信できると思います。

 さて、ネットワーク経由で取得したIQファイルですが、狙い通りに受信できているか(特に時間方向のズレが十分小さいか)、簡単なプログラムでスペクトルを画像化してみたいと思います。

3個のIQファイルからスペクトルを画像化
var FFTpoints = 2048;
var averages = 10;
var N = 2000;
var grid = 100;
var pos = (long)(44 + 4096000 * 1);
var (file1, file2, file3) = (
   @"file1.wav",
   @"file2.wav",
   @"file3.wav");

using var fs1 = OpenFileAndDumpInfo(file1);
using var fs2 = OpenFileAndDumpInfo(file2);
using var fs3 = OpenFileAndDumpInfo(file3);

float[][] magns1 = [], magns2 = [], magns3 = [];
Task.WaitAll([
   Task.Run(() => magns1 = PowerSpectrumFromFile(fs1)),
   Task.Run(() => magns2 = PowerSpectrumFromFile(fs2)),
   Task.Run(() => magns3 = PowerSpectrumFromFile(fs3)),
]);

using Bitmap bmp = Rendering();

bmp.Save("log.png");

; Stream OpenFileAndDumpInfo(string filename)
{
   Console.WriteLine(filename);
           
   FileStream fs = new(filename, FileMode.Open, FileAccess.Read);
   if (WavHeader.TryLoad(fs, out var header, out var dataChunk))
   {
       double sps = header.SampleRate;
       Console.WriteLine(header);
       Console.WriteLine($"{sps / FFTpoints * grid / 1e3} kHz/div, {1 / sps * FFTpoints * averages * grid} sec/div @ {sps / 1e6}Msps");
   }

   Console.WriteLine();

   fs.Position += pos;
   return fs;
}

; float[][] PowerSpectrumFromFile(Stream stream)
{
   List<float[]> list = new(N);

   var buff1 = new byte[2 * FFTpoints];
   var buff2 = new Complex32[FFTpoints];

   while (list.Count < N)
   {
       var magSq = new float[FFTpoints];
       for (int i = 0; i < averages; i++)
       {
           if (buff1.Length != stream.Read(buff1))
           { goto exit; }

           var cast = MemoryMarshal.Cast<byte, (byte, byte)>(buff1);
           for (int j = 0; j < cast.Length; j++)
           {
               var (a, b) = cast[j];
               buff2[j] = new(a - 127.35f, b - 127.35f);
           }

           for (int j = 0; j < cast.Length; j += 2)
           {
               buff2[j] *= -Complex32.One;
           }

           Fourier.Forward(buff2, FourierOptions.Matlab);

           for (int j = 0; j < buff2.Length; j++)
           {
               magSq[j] += buff2[j].MagnitudeSquared;
           }
       }

       for (int i = 0; i < magSq.Length; i++)
       {
           magSq[i] = float.Pow(magSq[i], 1 / 4f);
       }

       list.Add(magSq);
   }
exit:;

   return [.. list];
}

; Bitmap Rendering()
{
   var offset = -FloatMin3(
       magns1.Min(a => a.Min()),
       magns2.Min(a => a.Min()),
       magns3.Min(a => a.Min()));
   var gain = 255 / (offset + FloatMax3(
       magns1.Max(a => a.Max()),
       magns2.Max(a => a.Max()),
       magns3.Max(a => a.Max())));

   Bitmap bmp = new(magns1[0].Length, IntMin3(magns1.Length, magns2.Length, magns3.Length));

   var bmpdata = bmp.LockBits(new() { Size = bmp.Size }, ImageLockMode.WriteOnly, PixelFormat.Format32bppArgb);
   var line = new int[bmpdata.Width];

   for (int y = 0; y < bmpdata.Height; y++)
   {
       var magn1 = magns1[y];
       var magn2 = magns2[y];
       var magn3 = magns3[y];
       for (int x = 0; x < bmpdata.Width; x++)
       {
           var a = (byte)((magn1[x] + offset) * gain);
           var b = (byte)((magn2[x] + offset) * gain);
           var c = (byte)((magn3[x] + offset) * gain);
           line[x] = unchecked((int)0xFF000000) | c << 16 | b << 8 | a;
       }

       Marshal.Copy(line, 0, bmpdata.Scan0 + bmpdata.Stride * y, line.Length);
   }

   bmp.UnlockBits(bmpdata);

   using var g = Graphics.FromImage(bmp);
   using Pen pen = new(Color.FromArgb(64, Color.White));

   for (int i = 0; i < bmp.Height; i += grid)
   {
       g.DrawLine(pen, 0, i, bmp.Width, i);
   }

   g.TranslateTransform(bmp.Width / 2, 0);
   g.DrawLine(pen, 0, 0, 0, bmp.Height);
   for (int i = grid; i < bmp.Width / 2; i += grid)
   {
       g.DrawLine(pen, i, 0, i, bmp.Height);
       g.DrawLine(pen, -i, 0, -i, bmp.Height);
   }

   return bmp;

   ; static float FloatMin3(float a, float b, float c)
    => float.Min(float.Min(a, b), c);

   ; static float FloatMax3(float a, float b, float c)
    => float.Max(float.Max(a, b), c);

   ; static int IntMin3(int a, int b, int c)
    => int.Min(int.Min(a, b), c);
}

 試しに2.048Mspsで取得したFMラジオの電波を画像化してみます。FFTポイント数が2048なので周波数分解能は1kHz、それを10回平均化しているので時間分解能は10ms、高さ2000pxで描画範囲は20秒、という画像になります。3個のIQファイルはそれぞれRGBの輝度で表示されます。

image.png

 画像はNHK FMを正時に受信したもので、時報の信号が見えています。グリッドは100px間隔です(この画像の場合は100kHz/div, 1sec/div)。前述の記録プログラムでは3本のドングルに100kHz差で周波数を指定していますが、画像でも100px(つまり100kHz)離れて描画されています。

 分解能を400kHz/div, 25ms/divへ変更し、時報の440Hz(0.1秒)の部分を画像化したのが以下の画像です。

image.png

 3chともほぼ同じ位相で受信できており、少なくとも半周期(0.5/440≒1ms)程度の精度でドングル間の同期が取れていることがわかります。毎回ここまで綺麗にそろうことは稀ですが、それでもせいぜい数十ミリ秒前半くらいの程度の差に収まるはずです。ISDB-Tの受信において同時性は1フレーム(日本の場合約230ms)より十分短ければ良いはずなので、おそらく問題ない程度だと思います。

テレビの受信

 FMラジオで受信機の動作確認ができたので、いよいよ地デジ放送の受信を行います。

 地デジ放送はUHF帯を利用しており、周波数は物理チャンネルという数値で管理されています。放送局と物理チャンネルの組み合わせは地域によって異なりますが、オンラインの一覧表で確認できます。

 まず、A-PABのTV放送エリアの地図を使用して、受信可能な放送局・中継局を探します。
 A-PAB 放送エリアのめやす

 続いて、その放送局・中継局の名前から物理チャンネルを探します。例えばDXアンテナのサポートページにエリア別の一覧があるので、ページ内検索で放送局・中継局の名前を入れたりして探します。
 https://qa.dxantenna.co.jp/faq_detail.html?id=2130

 現在のところ、物理chは13から52が使用されていて、6MHz間隔(日本の場合)で設置されています。仮に物理ch0番目を想定すると中心周波数は395MHzであり、特定の物理chの中心周波数は395+6*物理ch番号で計算できます。例えば28chであれば395+6*28=563となり、物理28chの中心周波数は563MHzとなります。
 ただし、地デジ放送は+1/7MHzのオフセットが加算されるため、実際には395+1/7+6*28≒563.1428MHzのような値になります(このオフセットはアナログ放送(下側chの音声搬送波が近い)との互換性のために付与されたものだそうです)。

 地デジ放送は6MHzのチャンネルを14セグメントに分割し、その内の13セグメントを放送に使用し、残りを0.5セグメントずつガードバンドに使用しています。そのため、放送を受信するには6/14*13≒5.571MHzの帯域を受信する必要があり、これを3本のドングルでパラレルに受信するには約1.857MHz間隔で受信を行います。最終的に、例えば物理28chを3本のドングルで受信するには、それぞれ561.29MHz563.14MHz565.00MHzを受信することになります。許容できる周波数の誤差はプログラムの実装次第ですが、今回の場合は10kHz程度で丸めてしまって問題ありません。
 また、地デジ放送では受信側のサンプリングレートも指定されており、フルセグで放送は512/63MHz≒8.127MHzを使用することが求められています。ただし安価なドングルではこのサンプリングレートは不可能ですから、もっと低いサンプリングレートを選択します。ワンセグ放送を受信する場合は64/63MHz≒1.016MHzを使用できますが、3本のドングルでフルセグ放送を受信する場合はもう一つ上の128/63MHz≒2.032MHzを使用します。サンプリングレートがズレていると後で面倒なので、できるだけ正確に指定します。rtl_tcpでは1Hz分解能で指定できるので、2031746Hzを指定します。

 フルセグ放送の受信には帯域幅が広く、指向性があり、利得の高いアンテナが必要です。ダイポールでは利得や指向性が不足し、ログペリでは利得や周波数特性が不足するはずです。アマチュア無線用の430MHz用の八木で地デジ放送を受信しようとするとテレビ放送の帯域では周波数特性がガタガタでデコードが難しくなります。結論として、安物でもいいからテレビ用のアンテナを使用することが唯一の選択肢だと思います(もちろん既設のテレビ視聴用アンテナがあればそれを使うのが一番簡単です)。

 3本のドングルでフルセグ放送を受信した際のスペクトラムは以下のようになります。

image.png

 時間方向にはほとんど変化がなく、周波数方向では色の変化(伝搬特性や受信機ごとの特性の違い)が見えています。受信機の特性によって見え方が異なりますが、ガードバンドも見えています(右側が顕著)。
 ISDB-Tのスペクトラムはあまり面白いものではありません。デコードしていくとだんだん面白くなっていきます(たぶん)。

ISDB-Tのサンプリングレートについて

 ISDB-Tのモード3では有効シンボル長が1008マイクロ秒と定められています。この逆数(約0.992kHz)がOFDMのキャリア間隔であり、その整数倍、特にFFTと相性がいいように2^n倍をサンプリングレートとして使用します。例えばフルスペックのフルセグ受信機は8192ポイントのFFTを使用しており、8.127Mspsとなります。今回の場合は2048ポイントのFFTを使用しますから、1/1008u*2048≒2.032Mspsとなります。
 他の計算方法として、帯域幅から求めることもできます。例えば6MHz / (14 * 432) ≒ 0.992kHzと計算できます。異なる帯域幅を受信する場合は帯域幅から計算するのが簡単だと思います(例えば 8MHz / (14 * 432) * 1024 ≒ 1.3545Mspsなど)。

ISDB-Tの復調

 さて、サンプルデータ(ISDB-Tを記録したIQファイル)を入手したところで、いよいよISDB-Tの復調が始まります。

信号レベルの確認

 受信したIQファイルですが、まず初めに信号レベルの確認を行います。
 今回はあちこち手抜きなので、SNRが低いデータは正しく復調できません(どんなシステムでもSNRが悪くなれば復調性能は落ちますが、今回のコードはより早く復調性能が落ちます)。ということで、まずは正しい信号レベルであることを確認する必要があります。

信号レベルのヒストグラム化
using StreamWriter sw = new("log.txt");

var (file1, file2, file3) = (
    @"file1.wav",
    @"file2.wav",
    @"file3.wav");

using FileStream fs1 = new(file1, FileMode.Open, FileAccess.Read);
using FileStream fs2 = new(file2, FileMode.Open, FileAccess.Read);
using FileStream fs3 = new(file3, FileMode.Open, FileAccess.Read);

fs1.Position = fs1.Length / 4 * 2;
fs2.Position = fs2.Length / 4 * 2;
fs3.Position = fs3.Length / 4 * 2;

ToHistogram(fs1, out var counter1);
ToHistogram(fs2, out var counter2);
ToHistogram(fs3, out var counter3);

foreach (var (i, (a, b, c)) in counter1.Zip(counter2, counter3).Select((a, i) => (i, a)))
{
    sw.WriteLine($"{i}\t{a:0.00E0}\t{b:0.00E0}\t{c:0.00E0}");
}

static void ToHistogram(Stream stream, out float[] dst)
{
    var buff = new byte[8192];
    var tmp = new int[256];
    var N = 0;

    for (int i = 0; i < 512; i++, N += buff.Length)
    {
        if (stream.Read(buff) != buff.Length)
        {
            break;
        }

        foreach (var a in buff)
        {
            tmp[a]++;
        }
    }

    dst = tmp.Select(a => a / (float)N).ToArray();
}

 単純にIQファイルをbyte配列として読み出して値の出現回数を数えるだけです。

image.png

 適正な受信レベルの場合、このように山なりというか、正規分布というか、Sinc型というか、とにかくそんな感じの形になります。
 山の裾が十分に広がり、飽和したサンプル(0や255の数)が十分に少ない状態を目指します。
 ドングルは3本とも同じゲインを設定していますが、青と橙・灰でゲインが異なっているようです。これは青と橙・灰の購入時期が違うため(橙と灰のドングルは同時に購入)ロット差が影響していると考えられます。今回の用途ではこの程度の個体差は問題ありません。

 以下の2枚は極端に過小および過大なレベルのヒストグラムです。

image.png

image.png

 過小な場合は中央付近に固まり、過大な場合は両端(飽和して0と255)に固まります。信号レベルが正しくない場合は復調が困難になります。サンプリングのプログラムでgainの値を変えて適正なレベルになるように調整してください。

 ちなみに、FMラジオを適正なレベルで受信すると以下のようなヒストグラムになります。

image.png

 シングルキャリアの正弦波なので、両肩上がりの矩形に近い形になります。

シンボルの同期

 まずはOFDMのシンボルの同期を行います。ISDB-Tは間欠無くシンボルが連続して送られてくるため、最初に1回同期してしまえば基本的に再同期は必要ありません(放送が中断したり、信号レベルが変化したりして同期が外れたりすると再同期が必要になりますが、今回は考慮しません)。

シンボルの同期
var effectiveSymbolLength = 2048;
var guardIntervalRatio = 8;

using StreamWriter sw = new("log.txt");

var (file1, file2, file3) = (
    @"file1.wav",
    @"file2.wav",
    @"file3.wav");

using ContinuousOFDMSymbolLoader loader1 = new(new FileStream(file1, FileMode.Open, FileAccess.Read) { Position = 2 * 4_063_492 }, guardIntervalRatio, effectiveSymbolLength);
using ContinuousOFDMSymbolLoader loader2 = new(new FileStream(file2, FileMode.Open, FileAccess.Read) { Position = 2 * 4_063_492 }, guardIntervalRatio, effectiveSymbolLength);
using ContinuousOFDMSymbolLoader loader3 = new(new FileStream(file3, FileMode.Open, FileAccess.Read) { Position = 2 * 4_063_492 }, guardIntervalRatio, effectiveSymbolLength);

loader1.SyncSymbol(16, out var correlationValues1);
loader2.SyncSymbol(16, out var correlationValues2);
loader3.SyncSymbol(16, out var correlationValues3);

for (int i = 0; i < correlationValues1.Length; i++)
{
    sw.WriteLine(string.Join('\t',
        i,
        correlationValues1[i],
        correlationValues2[i],
        correlationValues3[i]));
}

var symbol1 = loader1.TakeSymbol();
var symbol2 = loader2.TakeSymbol();
var symbol3 = loader3.TakeSymbol();

for (int i = 0; i < symbol1.Length; i++)
{
    sw.WriteLine(string.Join('\t',
        i, "", "", "",
        symbol1[i].Magnitude, symbol1[i].Phase / float.Pi * 180,
        symbol2[i].Magnitude, symbol2[i].Phase / float.Pi * 180,
        symbol3[i].Magnitude, symbol3[i].Phase / float.Pi * 180));
}

class ContinuousOFDMSymbolLoader(Stream stream, int guardIntervalRatio, int effectiveSymbolLength, bool leaveOpen = false) : IDisposable
{
    private static readonly Complex32 _offset = new(127.375f, 127.375f);
    private readonly Stream _stream = stream;
    private readonly int _guardIntervalLength = effectiveSymbolLength / guardIntervalRatio;
    private readonly int _effectiveSymbolLength = effectiveSymbolLength;
    private readonly bool _leaveOpen = leaveOpen;
    private readonly byte[] _rawBuff = new byte[2 * (effectiveSymbolLength / guardIntervalRatio + effectiveSymbolLength)];

    public void Dispose()
    {
        if (!_leaveOpen)
        {
            _stream.Dispose();
        }
    }

    public void SyncSymbol(int N, out float[] result)
    {
        var (GIL, ESL) = (_guardIntervalLength, _effectiveSymbolLength);
        using var dispose1 = ArrayPoolWrapper.Rent(2 * (N + 1) * (GIL + ESL), out Span<byte> buff1);

        Read(buff1);

        using var dispose2 = ArrayPoolWrapper.Rent(buff1.Length / 2 - ESL, out Span<Complex32> buff2);
        var cast = MemoryMarshal.Cast<byte, (byte, byte)>(buff1);

        var offset = _offset;
        for (int i = 0; i < buff2.Length; i++)
        {
            var (a, b) = cast[i];
            var (c, d) = cast[i + ESL];
            buff2[i] =
                (new Complex32(a, b) - offset) *
                (new Complex32(c, d) - offset).Conjugate();
        }

        result = new float[GIL + ESL];

        for (int i = 0; i + GIL < buff2.Length; i++)
        {
            var accum = Complex32.Zero;
            for (int j = 0; j < GIL; j++)
            {
                accum += buff2[i + j];
            }
            result[i % result.Length] += accum.Magnitude;
        }

        Seek(result.AsSpan().IndexOf(result.Max()));
    }

    public Complex32[] TakeSymbol()
    {
        var (GIL, ESL) = (_guardIntervalLength, _effectiveSymbolLength);
        var rawBuff = _rawBuff;

        Read(rawBuff);

        var cast = MemoryMarshal.Cast<byte, (byte, byte)>(rawBuff);

        var accum = Complex32.Zero;
        var offset = _offset;
        for (int i = 0, j = ESL; i < GIL; i++, j++)
        {
            var (a, b) = cast[i];
            var (c, d) = cast[j];
            accum +=
                (new Complex32(a, b) - offset) *
                (new Complex32(c, d) - offset).Conjugate();
        }

        var angle = Complex32.FromPolarCoordinates(1 / float.Sqrt(accum.Magnitude), 0);
        var speed = Complex32.FromPolarCoordinates(1, accum.Phase / ESL + float.Pi);
        var symbol = new Complex32[ESL];

        for (int i = 0, j = GIL; i < ESL; i++, j++, angle *= speed)
        {
            var (a, b) = cast[j];
            symbol[i] = (new Complex32(a, b) - offset) * angle;
        }

        Fourier.Forward(symbol, FourierOptions.Matlab);

        return symbol;
    }

    public void Seek(int samples)
     => _stream.Position += 2 * samples;

    private void Read(Span<byte> dst)
    {
        for (int n = 0, m; n < dst.Length; n += m)
        {
            m = _stream.Read(dst[n..]);
            if (m == 0) { throw new IOException(); }
        }
    }
}

class ArrayPoolWrapper
{
    public static IDisposable Rent<T>(int length, out Span<T> result)
    {
        var rent = ArrayPool<T>.Shared.Rent(length);
        result = rent.AsSpan(0, length);
        return new ArrayPoolSharedReturn<T>(rent);
    }

    private class ArrayPoolSharedReturn<T>(T[]? array) : IDisposable
    {
        public void Dispose()
        {
            if (array is T[] a)
            {
                array = null;
                ArrayPool<T>.Shared.Return(a);
            }
        }
    }
}

 相関値をグラフにすると以下のようになります。

image.png

 ピーク付近で綺麗な三角形になり、その外側は十分に低いレベルになります。相関値は信号レベル等に影響を受けます。信号レベルが低い(ヒストグラムで山が狭い)青色は相関値も低く、信号レベルが高い(山が広い)灰色は信号レベルも高くなっています。灰色よりも少し山が狭い橙色は相関値でも灰色より少し低い値になっています。
 1回目はシンボルに同期していないのでそれぞれの山があちこちに出ていますが、もう一度相関処理を行えば、次はシンボルに同期して(すべて0または1、-1あたりで)山が見えるはずです。前後にずれる場合は受信機のサンプリングレートやクロックのエラーが考えられます。

 中央部のセグメントのMagnitude/Phaseをグラフにすると以下のようになります。

image.png

 中央のセグメントNo.0にQPSK/BPSKらしい形が見えています。その外側は64QAMなのでこの状態では判別しづらいです。

 3本のドングルからコンスタレーションを画像化すると以下のようになります。

image.png

 0から2までそれぞれ低周波側・中間付近・高周波側です。
 0番は64QAM/BPSKが見えているような感じがありますが、1番や2番は正しく動いているか怪しいですね。とはいえ、無視して続けます(この時点で綺麗なコンスタレーションが見れるのはだいぶ運がいいです。大抵は中央や右側のように、どう好意的に見てもコンスタレーションに見えない状態になります)。

帯域幅やモード、ガードインターバル比の判定

 試しに約1.3545MspsでサンプリングしたIQファイルを相関処理してみます。これは帯域幅8MHzのISDB-T(ワンセグ)の受信を目的としたサンプリングレートです。

image.png

 有効サンプル長とガードインターバル比を変えて、36種類の相関を行っています。正しいパラメータである6MHz, Mode 3, 1/8の相関値のみピークが出て、それ以外ではほぼ平坦な相関値を示しています。ピークとフロアの比が閾値を超えていればそのモードで放送が行われている、というような判定ができると思います。どの組み合わせでも閾値を超えなければ、そのチャンネルでは放送が行われていない、というような判定でチャンネルスキャンを行うこともできるはずです。
 このようにして帯域幅、モード、GI比を判定できます。モードやGI比は使用するサンプル数の違いですが、帯域幅が異なる場合はサンプリングレートを正しいものに設定し直す必要があります。
 帯域幅の違いはサンプリングレートで、GI比の違いはOFDM復調処理で簡単に吸収できますが、モードが違う場合は処理をかなり変える必要があって、複数モードへの対応は結構面倒そうです。

周波数オフセットの推定

 受信周波数を設定する際に10kHzで丸めたので、OFDMのキャリアがズレているはずです。ということでこのズレの量を推定します。周波数を設定する際に1Hzまで設定したとしても、送信側や受信側のクロックエラーでズレたりするので、「ズレないように受信する」より「ズレても問題ように復調する」ほうが楽です。例えば500MHzを受信した際に受信クロックが1ppmズレていると500Hzのズレとなり、キャリア1本分をギリギリ超えるズレになります。また、送信側は、本来は1Hz未満(実際には0.2Hz未満程度らしい)の精度で放送されていますが、送信出力次第では最大で20kHzまで許容されているようです(送信出力が極めて低い場合のみですが)。
「うまく復調できない場合は受信機のクロック補正をうまく受信できるようになるまで調整してください」というのはちょっと無責任だと思うので、ちゃんと処理します。

 ISDB-Tではデータキャリアの振幅は平均で1で、補助信号(AC1やTMCCなど)は4/3(約1.33)の強さで出力されています。パイロット信号(Scattered Pilot)も4/3の強さですが、これは4シンボルで繰り返し信号になり、4nシンボルで平均化すれば約1.083程度になります。
 ということで、キャリア毎にMagnitudeを4シンボル分平均化し、それに対してAC1/TMCCの配置で相関処理を行います。

周波数オフセットの推定
int[][] _AC1 = {
//  AC1_1 AC1_2 AC1_3 AC1_4 AC1_5 AC1_6 AC1_7 AC1_8
    [  10,   28,  161,  191,  277,  316,  335,  425 ], // Seg.11
    [  20,   40,  182,  208,  251,  295,  400,  421 ], // Seg. 9
    [   4,   89,  148,  197,  224,  280,  331,  413 ], // Seg. 7
    [  98,  101,  118,  136,  269,  299,  385,  424 ], // Seg. 5
    [  11,  101,  128,  148,  290,  316,  359,  403 ], // Seg. 3
    [  76,   97,  112,  197,  256,  305,  332,  388 ], // Seg. 1
    [   7,   89,  206,  209,  226,  244,  377,  407 ], // Seg. 0
    [  61,  100,  119,  209,  236,  256,  398,  424 ], // Seg. 2
    [  35,   79,  184,  205,  220,  305,  364,  413 ], // Seg. 4
    [   8,   64,  115,  197,  314,  317,  334,  352 ], // Seg. 6
    [  53,   83,  169,  208,  227,  317,  344,  364 ], // Seg. 8
    [  74,  100,  143,  187,  292,  313,  328,  413 ], // Seg.10
    [  40,   89,  116,  172,  223,  305,  422,  425 ], // Seg.12
};
int[][] _TMCC = {
//   TMCC1 TMCC2 TMCC3 TMCC4
    [  70,  133,  233,  410 ], // Seg.11
    [  44,  155,  265,  355 ], // Seg. 9
    [  83,  169,  301,  425 ], // Seg. 7
    [  23,  178,  241,  341 ], // Seg. 5
    [  86,  152,  263,  373 ], // Seg. 3
    [  31,  191,  277,  409 ], // Seg. 1
    [ 101,  131,  286,  349 ], // Seg. 0
    [  17,  194,  260,  371 ], // Seg. 2
    [  49,  139,  299,  385 ], // Seg. 4
    [  85,  209,  239,  394 ], // Seg. 6
    [  25,  125,  302,  368 ], // Seg. 8
    [  47,  157,  247,  407 ], // Seg.10
    [  61,  193,  317,  347 ], // Seg.12
};

var _AC1FullSeg =
    _AC1.SelectMany((a, i) => a.Select(a => a + i * 432))
    .OrderBy(a => a).ToArray();
var _TMCCFullSeg =
    _TMCC.SelectMany((a, i) => a.Select(a => a + i * 432))
    .OrderBy(a => a)
    .ToArray();
var _ACandTMCCFullSeg =
    _AC1FullSeg.Concat(_TMCCFullSeg)
    .OrderBy(a => a)
    .ToArray();

loader1.SyncSymbol(8, out _);
loader2.SyncSymbol(8, out _);
loader3.SyncSymbol(8, out _);

CoarseFrequencyOffset cfo1 = new(_ACandTMCCFullSeg, 1784 + 1872 * -1 - 128, 256, effectiveSymbolLength);
CoarseFrequencyOffset cfo2 = new(_ACandTMCCFullSeg, 1784 + 1872 * +0 - 128, 256, effectiveSymbolLength);
CoarseFrequencyOffset cfo3 = new(_ACandTMCCFullSeg, 1784 + 1872 * +1 - 128, 256, effectiveSymbolLength);

Span<Complex32[]> symbols = new Complex32[4][];

for (int i = 0; i < symbols.Length; i++) { symbols[i] = loader1.TakeSymbol(); }
Console.WriteLine(cfo1.Process(symbols));

for (int i = 0; i < symbols.Length; i++) { symbols[i] = loader2.TakeSymbol(); }
Console.WriteLine(cfo2.Process(symbols));

for (int i = 0; i < symbols.Length; i++) { symbols[i] = loader3.TakeSymbol(); }
Console.WriteLine(cfo3.Process(symbols));

{
    var a = cfo1.Magnitudes();
    var b = cfo2.Magnitudes();
    var c = cfo3.Magnitudes();
    for (int i = 0; i < a.Length; i++)
    {
        sw.WriteLine(string.Join('\t',
            i, a[i], b[i], c[i]));
    }
}

{
    var a = cfo1.Correlation();
    var b = cfo2.Correlation();
    var c = cfo3.Correlation();
    for (int i = 0; i < a.Length; i++)
    {
        sw.WriteLine(string.Join('\t',
            i - effectiveSymbolLength, "", "", "",
            a[i], b[i], c[i]));
    }
}

class CoarseFrequencyOffset(int[] carriers, int start, int width, int effectiveSymbolLength)
{
    private readonly float[] _buff1 = new float[effectiveSymbolLength];
    private readonly float[] _buff2 = new float[width];

    public int Process(Span<Complex32[]> symbols)
    {
        var buff1 = _buff1;
        Array.Clear(buff1);

        foreach (var symbol in symbols)
        {
            for (int i = 0; i < buff1.Length; i++)
            {
                buff1[i] += symbol[i].MagnitudeSquared;
            }
        }

        var buff2 = _buff2;
        for (int i = 0, j = start; i < buff2.Length; i++, j++)
        {
            buff2[i] = Correlation(j);
        }

        return start + buff2.AsSpan().IndexOf(buff2.Max());
    }

    public float Correlation(int i)
    {
        var (buff, accum) = (_buff1, 0f);
        foreach (var j in carriers)
        {
            var k = j - i;
            if (0 <= k && k < buff.Length)
            {
                accum += buff[k];
            }
        }
        return accum;
    }

    public float[] Correlation()
    {
        var result = new float[_buff1.Length + carriers.Max()];
        for (int i = 0, j = -_buff1.Length; i < result.Length; i++, j++)
        {
            result[i] = Correlation(j);
        }
        return result;
    }

    public float[] Magnitudes()
     => _buff1.Select(float.Sqrt).ToArray();
}

 キャリアに対する相関値は以下のようになります。

image.png

 AC/TMCCの配置は1404本で周期的なので、複数のピークが立ちます。また伝播経路や受信機の周波数特性によってピークが綺麗に出ないことがあるので、相関させる範囲はある程度狭い範囲(今回は±128キャリア)で制限します。キャリア1本あたり約1kHzなので、最大で128kHz程度のズレまでを許容できる、という感じの設定です。
 相関結果は一番左側のキャリアの番号で出力されます。今回の場合は受信機1(青色、低周波側)が-83番目のキャリア、受信機2(橙色、中央部)が1779番目のキャリア、受信機3(灰色、高周波側)が3655番目のキャリア、という感じで並んでいます。1番目と2番目の距離は1862本、2番目と3番目の距離は1876本、という感じで受信できています。5616本のキャリアを3分割するので、理論的には1872本間隔で並ぶはずですが、受信する際に周波数を10kHzで丸めたため少しズレた結果になります(実を言うと周波数ズレの吸収がうまく動いている動作確認のためにわざと10kHzで丸めて受信位置をずらしていたわけですが)。

シンボル等化

 本来は不要な作業ですが、シンボルの等化(周波数軸での回転の打ち消し)を行います。また、同時にFFT窓の移動の補正も行います。
 本物のISDB-T受信機は受信機側のサンプリングクロックを送信機のサンプリングクロックに同期するフィードバックがあるのでFFT窓が移動することはありませんが、今回は予め受信したIQファイルを使用するので、FFT窓を固定することができません。FFT窓がずれると、数サンプル程度であれば周波数軸上でコンスタレーションが回転する程度の影響で済みますが、それでもTMCC(DBPSK)の復調に影響があります。また、さらにずれるとOFDMの復調もできなくなります。

 周波数方向のコンスタレーションの回転を推定するには位相が既知の信号が必要です。TMCCはセグメント内で同一の情報が複数本(モード3で4本)挿入されているため、これを基準として使用します。一応、規格的にはセグメント間でのTMCCの同一性は保証されていないため、セグメント内で比較してやる必要があります(日本で使用されている運用ではセグメント間でも同じ信号が出ていますが)。モード3の場合にはセグメント内にTMCCが4本、モード2の場合は2本ありますが、モード1の場合はセグメント内にTMCCが1本しかないので、この用途には使用できません。
 AC1はTMCCよりも密に配置されていますが、AC1はキャリア毎に異なるデータを入れることができるので、この用途では使用できません。

シンボルの等化
var _wi = ScatteredPilotPRBS(13 * 432 + 1, 3 / 4f);

static Complex32[] ScatteredPilotPRBS(int N, float gain)
{
    ReadOnlySpan<Complex32> LUT = [new(gain, 0), new(-gain, 0)];
    var result = new Complex32[N];

    for (int i = 0, reg = 0x7FF; i < result.Length; i++, reg >>= 1)
    {
        result[i] = LUT[reg & 1];

        switch (reg & 0b101)
        {
            case 0b100:
            case 0b001:
                reg |= 0x800;
                break;
        }
    }

    return result;
}

float SymbolEqualize(int leftmost, Span<Complex32> carriers)
{
    var segment = (leftmost + carriers.Length / 2 - 432 / 2) / 432;

    var tmcc = _TMCC[segment];
    var a = segment * 432 + tmcc[tmcc.Length / 2 - 1];
    var b = segment * 432 + tmcc[tmcc.Length / 2];

    var wi = _wi;
    var tmp =
         carriers[a - leftmost] * wi[a] *
        (carriers[b - leftmost] * wi[b]).Conjugate();

    var speed = Complex32.Pow(tmp, 1f / (b - a));
    speed /= speed.Magnitude;

    var angle = Complex32.Pow(speed, -carriers.Length / 2);
    angle /= float.Sqrt(tmp.Magnitude);

    for (int i = 0; i < carriers.Length; i++, angle *= speed)
    {
        carriers[i] *= angle;
    }

    return carriers.Length * speed.Phase / (float.Pi * 2);
}

float SymbolsEqualize(ReadOnlySpan<(int leftmost, Complex32[] carriers)> symbols)
{
    var accum = 0f;
    foreach (var (leftmost, carriers) in symbols)
    {
        accum += SymbolEqualize(leftmost, carriers);
    }
    return accum / symbols.Length;
}

float LoadSymbol(Span<(int, Complex32[])> dst, ContinuousOFDMSymbolLoader loader, CoarseFrequencyOffset cfo)
{
    Span<Complex32[]> tmp = new Complex32[dst.Length][];

    for (int i = 0; i < tmp.Length; i++)
    {
        tmp[i] = loader.TakeSymbol();
    }

    var leftmost = cfo.Process(tmp);

    for (int i = 0; i < dst.Length; i++)
    {
        dst[i] = (leftmost, tmp[i]);
    }

    return SymbolsEqualize(dst);
}

Span<(int, Complex32[])> symbols1 = new (int, Complex32[])[4];
Span<(int, Complex32[])> symbols2 = new (int, Complex32[])[4];
Span<(int, Complex32[])> symbols3 = new (int, Complex32[])[4];

for (int i = 0; i < 500; i++)
{
    var shift1 = LoadSymbol(symbols1, loader1, cfo1);
    var shift2 = LoadSymbol(symbols2, loader2, cfo2);
    var shift3 = LoadSymbol(symbols3, loader3, cfo3);

    if (shift1 < -0.75) { loader1.Seek(-1); } else if (0.75 < shift1) { loader1.Seek(1); }
    if (shift2 < -0.75) { loader2.Seek(-1); } else if (0.75 < shift2) { loader2.Seek(1); }
    if (shift3 < -0.75) { loader3.Seek(-1); } else if (0.75 < shift3) { loader3.Seek(1); }

    sw.WriteLine(string.Join('\t',
        i, shift1, shift2, shift3));
}

 シンボルの等化を行なうことでFFT窓のズレも把握できるので、閾値を超えた場合はシークを行うことでフィードバックします。今回は閾値で適当なヒステリシス特性を与え、シークが頻発しないようにしています。

 切り出し窓のズレをグラフにすると以下のようになります。

image.png

 ズレが1サンプル未満に収まるようにフィードバックされています。受信機ごとにクロックがフリーランしているので、受信機ごとに切り出し窓のズレも異なります。ここでも青と橙・灰の2グループ(購入時期の違い)で明確な差が見られます。

 コンスタレーションは以下のようになります。

image.png

 1シンボル単位(2048ポイント)のコンスタレーションなので統計的に見ることができず、雑多な感じが残っていますが。

フレーム同期 (1)

 フレームの同期にはいくつかの手法が考えられますが、今回はTMCCの同期信号をアナログ的に加算して判定する手法を用います。DBPSKの復調後に同期ビット列を検出する方法の場合は、クロックエラーの補正やビットエラー対策などの課題があります。アナログ的な判定であればビットエラーをある程度許容した上で、クロックエラーの補正に必要な情報も得られるという一石二鳥の方式です(シンボル等化と同様、クロックエラー補正は本来不要な処理ですが)。

フレームの同期 (1)
SyncFrame syncFrame1 = new(_TMCCFullSeg, _wi);
SyncFrame syncFrame2 = new(_TMCCFullSeg, _wi);
SyncFrame syncFrame3 = new(_TMCCFullSeg, _wi);

for (int i = 0; i < 500; i++)
{
    var shift1 = LoadSymbol(symbols1, loader1, cfo1);
    var shift2 = LoadSymbol(symbols2, loader2, cfo2);
    var shift3 = LoadSymbol(symbols3, loader3, cfo3);

    if (shift1 < -0.75) { loader1.Seek(-1); } else if (0.75 < shift1) { loader1.Seek(1); }
    if (shift2 < -0.75) { loader2.Seek(-1); } else if (0.75 < shift2) { loader2.Seek(1); }
    if (shift3 < -0.75) { loader3.Seek(-1); } else if (0.75 < shift3) { loader3.Seek(1); }

    for (int j = 0; j < 4; j++)
    {
        sw.WriteLine(string.Join('\t',
            i * 4 + j,
            syncFrame1.Process(symbols1[j]),
            syncFrame2.Process(symbols2[j]),
            syncFrame3.Process(symbols3[j])));
    }
}

{
    var a = new Complex32[500];
    var b = new Complex32[500];
    var c = new Complex32[500];

    syncFrame1.CopyTMCC(500, a);
    syncFrame2.CopyTMCC(500, b);
    syncFrame3.CopyTMCC(500, c);

    for (int i = 0; i < a.Length; i++)
    {
        sw.WriteLine(string.Join('\t',
            i, "", "", "",
            a[i].Magnitude,
            b[i].Magnitude,
            c[i].Magnitude,
            a[i].Phase / float.Pi * 180,
            b[i].Phase / float.Pi * 180,
            c[i].Phase / float.Pi * 180));
    }
}

partial class SyncFrame;
partial class SyncFrame(int[] _TMCCFullSeg, Complex32[] wi)
{
    private readonly (int leftmost, Complex32[] carriers)[] _symbols = new (int, Complex32[])[512];
    private readonly Complex32[] _tmccs = Enumerable.Repeat(Complex32.NaN, 512).ToArray();
    private int _symbolsIndex = 0;

    public float Process((int leftmost, Complex32[] carriers) symbol)
    {
        AddSymbol(symbol);

        var sync = TakeSyncRotate();

        return sync.Magnitude * float.Sign(sync.Real);
    }

    private void AddSymbol((int, Complex32[]) symbol)
    {
        var (symbols, symbolsIndex) = (_symbols, _symbolsIndex);
        var (leftPrev, carrPrev) = symbols[symbolsIndex++];
        symbols[_symbolsIndex = symbolsIndex %= symbols.Length] = symbol;

        if (carrPrev is null)
        {
            _tmccs[symbolsIndex] = Complex32.NaN;
        }
        else
        {
            var (leftCrnt, carrCrnt) = symbol;
            var accum = Complex32.Zero;
            foreach (var i in _TMCCFullSeg)
            {
                var j = i - leftCrnt;
                var k = i - leftPrev;
                if (0 <= j && j < carrCrnt.Length &&
                    0 <= k && k < carrPrev.Length)
                {
                    accum +=
                        carrCrnt[j] *
                        carrPrev[k].Conjugate();
                }
            }
            _tmccs[symbolsIndex] = accum;
        }
    }

    private Complex32 TakeSyncRotate()
    {
        Span<Complex32> a = stackalloc Complex32[16];
        Span<Complex32> b = stackalloc Complex32[16];

        CopyTMCC(16, a);
        CopyTMCC(220, b);

        for (int i = 0; i < 16; i++)
        {
            a[i] /= a[i].Magnitude;
            b[i] /= b[i].Magnitude;
        }

        var sum =
             a[0] + a[1] + a[4] + a[6] + a[11] + a[15] -
            (a[2] + a[3] + a[5] + a[7] + a[8] + a[9] + a[10] + a[12] + a[13] + a[14]) -

            (b[0] + b[1] + b[4] + b[6] + b[11] + b[15]) +
            (b[2] + b[3] + b[5] + b[7] + b[8] + b[9] + b[10] + b[12] + b[13] + b[14]);

        return
            sum.IsNaN()
            ? Complex32.Zero
            : sum;
    }

    public void CopyTMCC(int index, Span<Complex32> dst)
     => _tmccs.CopyFromRingBuffer(dst, _symbolsIndex - index);
}

static class MyExt
{
    public static void CopyFromRingBuffer<T>(this T[] src, Span<T> dst, int index)
     => CopyFromRingBuffer((ReadOnlySpan<T>)src, dst, index);

    public static void CopyFromRingBuffer<T>(this ReadOnlySpan<T> src, Span<T> dst, int index)
    {
        index = (index + src.Length) % src.Length;
        if (index + dst.Length <= src.Length)
        {
            src.Slice(index, dst.Length).CopyTo(dst);
        }
        else
        {
            var n = src.Length - index;
            var m = dst.Length - n;
            src[index..].CopyTo(dst);
            src[..m].CopyTo(dst[n..]);
        }
    }
}

 2箇所の同期信号を加算していき、そのMagnitudeが閾値を超えたら同期信号を見つけたことにします。アナログ判定なので適当な閾値を選ぶことで多少のビットエラーも許容できます。閾値が低すぎると誤ったフレーム同期を起こしますが、その場合は後続の誤り訂正で訂正できないことで検出できることを期待します。
 加算結果をグラフ化すると以下のようになります。

image.png

 グラフは204シンボル(1フレーム)でグリッドを入れてあります。1フレーム毎に位相が反転して強さが約32のピークが出ています。サイドローブレベルは十分に低く、誤ったフレーム同期の可能性はかなり低そうですね。

 TMCCの生データ(DBPSKから差動を除去したBPSK)は以下のようになります(左がMagnitude、右がPhase)。

image.png

 安定した強さで、位相は2箇所に出ています。ただしこの時点では受信機のクロックエラーに起因するシンボル間での周波数軸上の回転が残っているため、本来は0度、180度の位置に出るはずのコンスタレーションが別の場所に出ています。
 固定値でBPSK復調した場合に灰色がビット反転しそうな気配や、フレーム間で60ms程度の時間差があることも推定できます。

フレームの同期 (2)

 同期信号を加算した結果にはシンボル間でのコンステレーションの回転の情報が含まれます。ただし、同期信号はビット反転した2種類が使用されているため、シンボル間の回転には180度の曖昧さが含まれます。これを除くため、まずシンボル間の回転を仮定した上でTMCCの復調を行い、続いてTMCCの誤り訂正を試みます。シンボル間の回転が正しければ誤り訂正で正しく修正されますが、ビット反転している場合は正しく誤り訂正が行えません。そのため、取り出したTMCCをビット反転したものでも誤り訂正を試みて、誤りが少ない方を採用し、またそれに合わせてシンボル間回転の曖昧さも除きます。
 シンボル間回転の値を得たら、同期信号の適当な1bitを取り出して、それをフレームの極性信号として使用します。1フレームごとに正/負が切り替わるため、複数のドングルで受信したフレームをこの正負情報で同期します。

フレームの同期 (2)
void LoadFrame(
    Span<(int, Complex32[])> symbols,
    ContinuousOFDMSymbolLoader loader,
    CoarseFrequencyOffset cfo,
    SyncFrame syncFrame,
    ref (int, BigInteger) frameInfo)
{
    var shift = LoadSymbol(symbols, loader, cfo);

    if (shift < -0.75)
    {
        loader.Seek(-1);
    }
    else if (0.75 < shift)
    {
        loader.Seek(1);
    }

    for (int i = 0; i < symbols.Length; i++)
    {
        if (syncFrame.CheckSync(symbols[i], out var sync))
        {
            frameInfo = sync;
        }
    }
}

(int syncPhase, BigInteger TMCC) frame1 = default;
(int syncPhase, BigInteger TMCC) frame2 = default;
(int syncPhase, BigInteger TMCC) frame3 = default;

for (int i = 0; i < 500; i++)
{
    LoadFrame(symbols1, loader1, cfo1, syncFrame1, ref frame1);
    LoadFrame(symbols2, loader2, cfo2, syncFrame2, ref frame2);
    LoadFrame(symbols3, loader3, cfo3, syncFrame3, ref frame3);

    if (frame1.syncPhase != 0 &&
        frame1.syncPhase == frame2.syncPhase &&
        frame1.syncPhase == frame3.syncPhase)
    {
        Console.WriteLine(string.Join(' ',
            $"{frame1.syncPhase,2}",
            frame1.TMCC.ToString("X46")[^46..],
            $"{frame1.TMCC == frame2.TMCC && frame1.TMCC == frame3.TMCC,5}"));

        frame1.syncPhase = frame2.syncPhase = frame3.syncPhase = 0;
    }
}

partial class SyncFrame
{
    public bool CheckSync(
        (int leftmost, Complex32[] carriers) symbol,
        out (int syncPhase, BigInteger TMCC) result)
    {
        AddSymbol(symbol);

        var syncRotate = TakeSyncRotate();

        if (25 < syncRotate.Magnitude)
        {
            Span<Complex32> a = stackalloc Complex32[184];
            CopyTMCC(201, a);
            var tmcc1 = DecodeBpsk(a, syncRotate.Conjugate());
            var tmcc2 = tmcc1 ^ (BigInteger.One << 184) - 1;

            var errorbits1 = DifferenceSetCyclicCode.Instance_273_191.Decode(tmcc1);
            var errorbits2 = DifferenceSetCyclicCode.Instance_273_191.Decode(tmcc2);

            var pop1 = (int)BigInteger.PopCount(errorbits1);
            var pop2 = (int)BigInteger.PopCount(errorbits2);

            if (pop1 <= 7 ||
                pop2 <= 7)
            {
                BigInteger tmcc;
                (tmcc, syncRotate) =
                    pop1 < pop2
                    ? (tmcc1 ^ errorbits1, syncRotate)
                    : (tmcc2 ^ errorbits2, syncRotate * -Complex32.One);

                CopyTMCC(220, a[..1]);
                var phase = float.Sign((a[0] * syncRotate.Conjugate()).Real);
                // 同期信号の最初のビットを取ってきてフレームのPos/Negを判定

                result = (phase, tmcc);
                return true;
            }
        }

        result = default;
        return false;
    }

    private static BigInteger DecodeBpsk(ReadOnlySpan<Complex32> src, Complex32 rotate)
    {
        var result = BigInteger.Zero;
        for (int i = 0; i < src.Length; i++)
        {
            if ((src[i] * rotate).Real < 0)
            {
                result |= BigInteger.One << src.Length - i - 1;
            }
        }
        return result;
    }
}

class DifferenceSetCyclicCode
{
    private readonly (UInt128, UInt128, int, UInt128[]) _params;
    private readonly int _k, _m;

    private static DifferenceSetCyclicCode? _instance_21_11;
    public static DifferenceSetCyclicCode Instance_21_11 => _instance_21_11 ??= new(
        21, 11,
        [10, 7, 6, 4, 2, 0],
        [[9], [1], [4, 6], [0, 5, 7], [2, 3, 8]]);

    private static DifferenceSetCyclicCode? _instance_273_191;
    public static DifferenceSetCyclicCode Instance_273_191 => _instance_273_191 ??= new(
        273, 191,
        [82, 77, 76, 71, 67, 66, 56, 52, 48, 40, 36, 34, 24, 22, 18, 10, 4, 0],
        [
            [71, 76],
            [17],
            [5, 23],
            [21, 27, 45],
            [3, 25, 31, 49],
            [16, 40, 42, 66],
            [35, 52, 56, 78],
            [8, 44, 61, 65],
            [2, 11, 47, 64, 68],
            [10, 13, 22, 58, 75, 79],
            [1, 12, 15, 24, 60, 77, 81],
            [30, 32, 43, 46, 55],
            [6, 37, 39, 50, 53, 62],
            [0, 7, 38, 40, 51, 54, 63],
            [18, 19, 26, 57, 59, 70, 73],
            [9, 28, 29, 36, 67, 69, 80],
            [4, 14, 33, 34, 41, 72, 74],
        ]);

    private DifferenceSetCyclicCode(int m, int k, int[] delta, int[][] bitsum)
     => (_m, _k, _params) = (m, k, (
            BitsOr(delta),
            UInt128.One << delta.Max(),
            (bitsum.Length + 1) / 2,
            bitsum.Select(a => BitsOr(a)).ToArray()));

    private static UInt128 BitsOr(ReadOnlySpan<int> a)
    {
        var b = UInt128.Zero;
        foreach (var v in a) { b |= UInt128.One << v; }
        return b;
    }

    public UInt128 Encode(in BigInteger data)
    {
        var (xor, MSB, _, _) = _params;
        var (reg, bitPos1) = (UInt128.Zero, BigInteger.One << _k - 1);

        for (; !bitPos1.IsZero; bitPos1 >>= 1)
        {
            reg <<= 1;
            var bit1 = (data & bitPos1).IsZero;
            var bit2 = (reg & MSB) == 0U;
            if (bit1 != bit2) { reg ^= xor; }
        }

        return reg & (MSB - 1);
    }

    public BigInteger Decode(in BigInteger data)
    {
        var (xor, MSB, thr, As) = _params;
        var (reg, bitPos1) = (UInt128.Zero, BigInteger.One << _m - 1);
        var bitPos2 = bitPos1;

        for (; !bitPos1.IsZero; bitPos1 >>= 1)
        {
            reg <<= 1;
            var bit1 = (data & bitPos1).IsZero;
            var bit2 = (reg & MSB) == 0U;
            if (bit1 != bit2) { reg ^= 1; }
            if (!bit2) { reg ^= xor ^ 1; }
        }

        var result = BigInteger.Zero;
        for (; !bitPos2.IsZero; bitPos2 >>= 1)
        {
            var errors = 0;
            foreach (var A in As)
            {
                if (((int)UInt128.PopCount(reg & A) & 1) != 0)
                { errors++; }
            }
            if (thr <= errors) { result |= bitPos2; }

            reg <<= 1;
            var bit2 = (reg & MSB) == 0U;
            if (!bit2) { reg ^= xor; }
        }

        return result;
    }
}

 ISDB-Tの誤り訂正には(273,191)の差集合巡回符号(それを短縮化した(184,102)短縮化差集合巡回符号)が使用されています。これはアナログテレビの字幕放送から、FM放送やそれを使用したカーナビのシステムなど、放送分野(特にNHKが関わる範囲)で幅広く使われています(他にも放送機器を扱っていたメーカーが開発した産業機器向けリモコン製品でも使われているようです)。
 ただ、使用範囲が広い割に情報があまり出てきません。今回はhttps://www.cqpub.co.jp/dwm/contest/2002/dwm48154.pdfおよびhttps://www.jstage.jst.go.jp/article/itej1978/45/8/45_8_970/_pdfを参考にしましたが、実装には自信がありません。どこか間違っている気がしますが、とりあえず動いているので当面は良しとします。

 実行すると以下のような結果になるはずです。

 1 3D258B4B3FFFFFFFFFFFFFFFFE5E183084EEC4D84AC6F5  True
-1 3D258B4B3FFFFFFFFFFFFFFFFE5E183084EEC4D84AC6F5  True
 1 3D258B4B3FFFFFFFFFFFFFFFFE5E183084EEC4D84AC6F5  True
-1 3D258B4B3FFFFFFFFFFFFFFFFE5E183084EEC4D84AC6F5  True
 1 3D258B4B3FFFFFFFFFFFFFFFFE5E183084EEC4D84AC6F5  True
-1 3D258B4B3FFFFFFFFFFFFFFFFE5E183084EEC4D84AC6F5  True
 1 3D258B4B3FFFFFFFFFFFFFFFFE5E183084EEC4D84AC6F5  True
-1 3D258B4B3FFFFFFFFFFFFFFFFE5E183084EEC4D84AC6F5  True
 1 3D258B4B3FFFFFFFFFFFFFFFFE5E183084EEC4D84AC6F5  True

 フレームごとに同期信号の位相が反転し、TMCCは常に同じ値が得られ、3個の受信機から得たTMCCが同じ値となります。

 なお、TMCCから情報を取り出すと以下のようになります。

{
  "SystemIdentification": "DigitalTerestrialTelevisionBroadcastingSystem",
  "IndicatorOfTransmissionParameterSwitching": "NormalValue",
  "StartupControlSignal": "NoStartupControl",
  "CurrentInformation": {
    "PartialReceptionFlag": "PartialReceptionAvailable",
    "HierarchicalLayerA": {
      "CarrierModulationMappingScheme": "QPSK",
      "ConvolutionCodingRate": "2/3",
      "TimeInterleavingLength": "16/8/4",
      "NumberOfSegments": "1Segment"
    },
    "HierarchicalLayerB": {
      "CarrierModulationMappingScheme": "64QAM",
      "ConvolutionCodingRate": "3/4",
      "TimeInterleavingLength": "8/4/2",
      "NumberOfSegments": "12Segments"
    },
    "HierarchicalLayerC": {
      "CarrierModulationMappingScheme": "UnusedHierarchicalLayer",
      "ConvolutionCodingRate": "UnusedHierarchicalLayer",
      "TimeInterleavingLength": "UnusedHierarchicalLayer",
      "NumberOfSegments": "UnusedHierarchicalLayer"
    }
  },
  "NextInformation": {
    "PartialReceptionFlag": "PartialReceptionAvailable",
    "HierarchicalLayerA": {
      "CarrierModulationMappingScheme": "UnusedHierarchicalLayer",
      "ConvolutionCodingRate": "UnusedHierarchicalLayer",
      "TimeInterleavingLength": "UnusedHierarchicalLayer",
      "NumberOfSegments": "UnusedHierarchicalLayer"
    },
    "HierarchicalLayerB": {
      "CarrierModulationMappingScheme": "UnusedHierarchicalLayer",
      "ConvolutionCodingRate": "UnusedHierarchicalLayer",
      "TimeInterleavingLength": "UnusedHierarchicalLayer",
      "NumberOfSegments": "UnusedHierarchicalLayer"
    },
    "HierarchicalLayerC": {
      "CarrierModulationMappingScheme": "UnusedHierarchicalLayer",
      "ConvolutionCodingRate": "UnusedHierarchicalLayer",
      "TimeInterleavingLength": "UnusedHierarchicalLayer",
      "NumberOfSegments": "UnusedHierarchicalLayer"
    }
  }
}

 ここで重要なのはCurrentInformationのPartialReceptionAvailable(ワンセグ放送有効)、LayerAがQPSK, 2/3, 16/8/4, 1Seg、LayerBが64QAM, 3/4, 8/4/2, 12Segs、という部分です。ここが異なる場合は以降の説明と異なる処理を組む必要になります。
 変調方式が変わるとTMCCも変わりますが、今回はこのTMCCに対応したデコードロジックを説明していきます。

TMCCの分解
Console.WriteLine(
    JsonSerializer.Serialize(
        new TMCCInformation(frame1.TMCC),
        new JsonSerializerOptions
        {
            WriteIndented = true,
            Converters = { new JsonStringEnumConverter() }
        })
    .Replace("\": \"_", "\": \"")
    .Replace('_', '/'));
    
public enum SystemIdentification
{
    _DigitalTerestrialTelevisionBroadcastingSystem,
    _DigitalTerestrialSoundBroadcastingSystem,
    _Reserved1,
    _Reserved2,
}

public enum IndicatorOfTransmissionParameterSwitching
{
    _1FramePriorToSwitching,
    _2FramesPriorToSwitching,
    _3FramesPriorToSwitching,
    _4FramesPriorToSwitching,
    _5FramesPriorToSwitching,
    _6FramesPriorToSwitching,
    _7FramesPriorToSwitching,
    _8FramesPriorToSwitching,
    _9FramesPriorToSwitching,
    _10FramesPriorToSwitching,
    _11FramesPriorToSwitching,
    _12FramesPriorToSwitching,
    _13FramesPriorToSwitching,
    _14FramesPriorToSwitching,
    _15FramesPriorToSwitching,
    _NormalValue,
}

public enum StartupControlSignal
{
    _NoStartupControl,
    _StartupControlAvailable,
}

public enum PartialReceptionFlag
{
    _NoPartialReception,
    _PartialReceptionAvailable,
}

public enum CarrierModulationMappingScheme
{
    _DQPSK,
    _QPSK,
    _16QAM,
    _64QAM,
    _Reserved1,
    _Reserved2,
    _Reserved3,
    _UnusedHierarchicalLayer,
}

public enum ConvolutionCodingRate
{
    _1_2,
    _2_3,
    _3_4,
    _5_6,
    _7_8,
    _Reserved1,
    _Reserved2,
    _UnusedHierarchicalLayer,
}

public enum TimeInterleavingLength
{
    _0_0_0,
    _4_2_1,
    _8_4_2,
    _16_8_4,
    _NotUsed,
    _Reserved1,
    _Reserved2,
    _UnusedHierarchicalLayer,
}

public enum NumberOfSegments
{
    _Reserved1,
    _1Segment,
    _2Segments,
    _3Segments,
    _4Segments,
    _5Segments,
    _6Segments,
    _7Segments,
    _8Segments,
    _9Segments,
    _10Segments,
    _11Segments,
    _12Segments,
    _13Segments,
    _Reserved2,
    _UnusedHierarchicalLayer,
}

public readonly record struct TransmissionParameterInformation(
    CarrierModulationMappingScheme CarrierModulationMappingScheme,
    ConvolutionCodingRate ConvolutionCodingRate,
    TimeInterleavingLength TimeInterleavingLength,
    NumberOfSegments NumberOfSegments)
{
    public TransmissionParameterInformation(int rawValue)
        : this(
            (CarrierModulationMappingScheme)(rawValue >> 10 & 0x7),
            (ConvolutionCodingRate)(rawValue >> 7 & 0x7),
            (TimeInterleavingLength)(rawValue >> 4 & 0x7),
            (NumberOfSegments)(rawValue >> 0 & 0xF))
    { }
}

public readonly record struct Information(
    PartialReceptionFlag PartialReceptionFlag,
    TransmissionParameterInformation HierarchicalLayerA,
    TransmissionParameterInformation HierarchicalLayerB,
    TransmissionParameterInformation HierarchicalLayerC);

public readonly record struct TMCCInformation(
    SystemIdentification SystemIdentification,
    IndicatorOfTransmissionParameterSwitching IndicatorOfTransmissionParameterSwitching,
    StartupControlSignal StartupControlSignal,
    Information CurrentInformation,
    Information NextInformation)
{
    public TMCCInformation(BigInteger rawValue) : this(
        (SystemIdentification)(int)(rawValue >> 204 - 21 - 1 & 0x3),
        (IndicatorOfTransmissionParameterSwitching)(int)(rawValue >> 204 - 25 - 1 & 0xF),
        (StartupControlSignal)(int)(rawValue >> 204 - 26 - 1 & 1),
        new((PartialReceptionFlag)(int)(rawValue >> 204 - 27 - 1 & 0x1),
            new TransmissionParameterInformation((int)(rawValue >> 204 - 40 - 1 & 0x1FFF)),
            new TransmissionParameterInformation((int)(rawValue >> 204 - 53 - 1 & 0x1FFF)),
            new TransmissionParameterInformation((int)(rawValue >> 204 - 66 - 1 & 0x1FFF))),
        new((PartialReceptionFlag)(int)(rawValue >> 204 - 67 - 1 & 0x1),
            new TransmissionParameterInformation((int)(rawValue >> 204 - 80 - 1 & 0x1FFF)),
            new TransmissionParameterInformation((int)(rawValue >> 204 - 93 - 1 & 0x1FFF)),
            new TransmissionParameterInformation((int)(rawValue >> 204 - 106 - 1 & 0x1FFF))))
    { }
}

フレームの結合

 受信機ごとに受信したセグメントの等化処理を行った上で、すべてのキャリアを結合し、一つのフレームとしてまとめます。等化処理はセグメントごとに変調モードを読んで処理すべきですが、面倒なのでコヒーレント変調専用に実装しています。

フレームの結合
(int leftmost, Complex32[] carriers)[] equalizedFrame1 = Enumerable.Range(0, 204).Select(_ => (0, (Complex32[])[])).ToArray();
(int leftmost, Complex32[] carriers)[] equalizedFrame2 = Enumerable.Range(0, 204).Select(_ => (0, (Complex32[])[])).ToArray();
(int leftmost, Complex32[] carriers)[] equalizedFrame3 = Enumerable.Range(0, 204).Select(_ => (0, (Complex32[])[])).ToArray();

void LoadFrame(
    Span<(int, Complex32[])> symbols,
    ContinuousOFDMSymbolLoader loader,
    CoarseFrequencyOffset cfo,
    SyncFrame syncFrame,
    ref (int, BigInteger) frameInfo,
    (int, Complex32[])[] frameBuff)
{
    var shift = LoadSymbol(symbols, loader, cfo);

    if (shift < -0.75)
    {
        loader.Seek(-1);
    }
    else if (0.75 < shift)
    {
        loader.Seek(1);
    }

    for (int i = 0; i < symbols.Length; i++)
    {
        if (syncFrame.CheckSync(symbols[i], out var sync))
        {
            frameInfo = sync;
            syncFrame.TakeFrame(frameBuff);
        }
    }
}

Complex32[][] majorFrame = Enumerable.Range(0, 204).Select(_ => new Complex32[5616]).ToArray();

void CombineFrame()
{
    var frame1 = equalizedFrame1;
    var frame2 = equalizedFrame2;
    var frame3 = equalizedFrame3;

    for (int i = 0; i < majorFrame.Length; i++)
    {
        var n = 0;
        int left;
        Complex32[] src;
        var dst = majorFrame[i];

        (left, src) = frame1[i];
        src.AsSpan(n - left, 1872).CopyTo(dst.AsSpan(n));
        n += 1872;

        (left, src) = frame2[i];
        src.AsSpan(n - left, 1872).CopyTo(dst.AsSpan(n));
        n += 1872;

        (left, src) = frame3[i];
        src.AsSpan(n - left, 1872).CopyTo(dst.AsSpan(n));
        n += 1872;
    }
}

for (int i = 0, frameNo = 0; i < 5000 && frameNo < 5; i++)
{
    try
    {
        LoadFrame(symbols1, loader1, cfo1, syncFrame1, ref frame1, equalizedFrame1);
        LoadFrame(symbols2, loader2, cfo2, syncFrame2, ref frame2, equalizedFrame2);
        LoadFrame(symbols3, loader3, cfo3, syncFrame3, ref frame3, equalizedFrame3);
    }
    catch (IOException) { break; }

    if (frame1.syncPhase != 0 &&
        frame1.syncPhase == frame2.syncPhase &&
        frame1.syncPhase == frame3.syncPhase)
    {
        frameNo++;
        frame1.syncPhase = frame2.syncPhase = frame3.syncPhase = 0;

        CombineFrame();
    }
}

partial class SyncFrame
{
    public void TakeFrame((int leftmost, Complex32[] carriers)[] frame)
    {
        _symbols.CopyFromRingBuffer(frame, _symbolsIndex - 221);
        Equalize(frame);
    }

    private void CopyFrame((int leftmost, Complex32[] carriers)[] frame)
     => _symbols.CopyFromRingBuffer(frame, _symbolsIndex - 221);

    private void Equalize((int leftmost, Complex32[] carriers)[] frame)
    {
        for (int symbolNo = 0; symbolNo < frame.Length; symbolNo++)
        {
            var (leftmost, carriers) = frame[symbolNo];

            for (int i = 0; i < 5616; i += 12)
            {
                var j = i - leftmost;
                if (0 <= j && j + 12 < carriers.Length)
                {
                    var a = (
                        carriers[j + symbolNo % 4 * 3] *
                        wi[i + symbolNo % 4 * 3]).Reciprocal();
                    for (int k = 0; k < 12; k++)
                    {
                        carriers[j + k] *= a;
                    }
                }
            }
        }
    }
}

 コンスタレーションを画像化すると以下のようになります。

image.png

 左から順に低周波側、中間付近、高周波付近のそれぞれで受信したコンスタレーションと、すべてのキャリアを結合した際のコンスタレーションです。
 星が綺麗ですね。
 DBPSK、QPSK、64QAMが綺麗に並んでいて、QAMは中央からの距離に応じて膨らんでいる様子も見えます。

蛇足:WAVファイルへ保存

 フルセグメントの全キャリアが得られたので、IFFTやガードインターバル付与を行ってWAVに保存するとフルセグ相当のIQファイルをSDR#等で読み込むことができるようになります。

image.png

 まあ、見て面白いものではありませんが。
 心の目で見るとSeg.No.0のQPSKが他と違うように見えるような気がします。

 もちろんこのIQファイルを使用してフルセグのTSを抜くことも可能です。送信波形を直接保存しているのでサンプリングクロックのエラーも無く、非常に処理しやすいです。

データキャリアの抽出、周波数インターリーブの解除

 ISDB-Tにはパイロット信号やAC/TMCC等補助的な情報が1/9の割合で挿入されています。これらの補助キャリアを除去してデータキャリアの取り出しを行います。合わせて周波数方向のインターリーブの解除も行います。
 都度ロジックで計算しようとするとかなり大変なので、予めルックアップテーブルを作成しておき、それを使用してデータキャリアの抽出やインターリーブ解除を行います。
 パイロット信号や補助信号の除去はコンスタレーションを見れば正しく動いているか判断できますが、周波数インターリーブの解除はコンスタレーションを見ても正しいか否かを判断することができません。自前で実装する場合はまずB31に従って正インターリーブを実装し、そのデータを正しく戻せる逆インターリーブを実装するような流れで行います。そしてビタビ復号まで実装し、正しく動けばめでたく一件落着、動かなければ怪しい場所を片っ端から手当たり次第に虱潰しに探す、みたいな流れになります。

データキャリアの抽出、周波数インターリーブの解除
int[][] GenerateDataCarrierLUT()
{
    var LUT = Enumerable.Range(0, 4992).ToArray();

    InterSegmentCarrierInterleaving(LUT, [1, 12]);
    IntraSegmentCarrierInterleaving(LUT);
    IntraSegmentRandomizing(LUT);
    LogicalToPhysical(LUT);
    Inverse(LUT);

    return
        Enumerable.Range(0, 4)
        .Select(i => SkipPilotAcAndTmcc(LUT, i, _ACandTMCCFullSeg))
        .ToArray();

    ; static void InterSegmentCarrierInterleaving(Span<int> LUT, ReadOnlySpan<int> segments)
    {
        using var dispose = ArrayPoolWrapper.Rent(LUT.Length, out Span<int> tmp);

        LUT.CopyTo(tmp);

        foreach (var n in segments)
        {
            var src = tmp[..(n * 384)];
            var dst = LUT[..(n * 384)];

            for (int i = 0, j = 0, k = 0; i < src.Length; i++, j += n)
            {
                if (src.Length <= j)
                {
                    j = ++k;
                }

                dst[i] = src[j];
            }

            tmp = tmp[src.Length..];
            LUT = LUT[dst.Length..];
        }
    }

    ; static void IntraSegmentCarrierInterleaving(Span<int> LUT)
    {
        using var dispose = ArrayPoolWrapper.Rent(LUT.Length, out Span<int> tmp);

        LUT.CopyTo(tmp);

        for (int i = 0, j = 0; j < LUT.Length; i++, j += 384)
        {
            var src = tmp.Slice(j, 384);
            var dst = LUT.Slice(j, 384);

            src[i..].CopyTo(dst);
            src[..i].CopyTo(dst[^i..]);
        }
    }

    ; static void IntraSegmentRandomizing(Span<int> LUT)
    {
        int[] intraSegmentCarrierRandomizing = {
             62,  13, 371,  11, 285, 336, 365, 220, 226,  92,  56,  46, 120, 175, 298, 352, 172, 235,  53, 164, 368, 187, 125,  82,
              5,  45, 173, 258, 135, 182, 141, 273, 126, 264, 286,  88, 233,  61, 249, 367, 310, 179, 155,  57, 123, 208,  14, 227,
            100, 311, 205,  79, 184, 185, 328,  77, 115, 277, 112,  20, 199, 178, 143, 152, 215, 204, 139, 234, 358, 192, 309, 183,
             81, 129, 256, 314, 101,  43,  97, 324, 142, 157,  90, 214, 102,  29, 303, 363, 261,  31,  22,  52, 305, 301, 293, 177,
            116, 296,  85, 196, 191, 114,  58, 198,  16, 167, 145, 119, 245, 113, 295, 193, 232,  17, 108, 283, 246,  64, 237, 189,
            128, 373, 302, 320, 239, 335, 356,  39, 347, 351,  73, 158, 276, 243,  99,  38, 287,   3, 330, 153, 315, 117, 289, 213,
            210, 149, 383, 337, 339, 151, 241, 321, 217,  30, 334, 161, 322,  49, 176, 359,  12, 346,  60,  28, 229, 265, 288, 225,
            382,  59, 181, 170, 319, 341,  86, 251, 133, 344, 361, 109,  44, 369, 268, 257, 323,  55, 317, 381, 121, 360, 260, 275,
            190,  19,  63,  18, 248,   9, 240, 211, 150, 230, 332, 231,  71, 255, 350, 355,  83,  87, 154, 218, 138, 269, 348, 130,
            160, 278, 377, 216, 236, 308, 223, 254,  25,  98, 300, 201, 137, 219,  36, 325, 124,  66, 353, 169,  21,  35, 107,  50,
            106, 333, 326, 262, 252, 271, 263, 372, 136,   0, 366, 206, 159, 122, 188,   6, 284,  96,  26, 200, 197, 186, 345, 340,
            349, 103,  84, 228, 212,   2,  67, 318,   1,  74, 342, 166, 194,  33,  68, 267, 111, 118, 140, 195, 105, 202, 291, 259,
             23, 171,  65, 281,  24, 165,   8,  94, 222, 331,  34, 238, 364, 376, 266,  89,  80, 253, 163, 280, 247,   4, 362, 379,
            290, 279,  54,  78, 180,  72, 316, 282, 131, 207, 343, 370, 306, 221, 132,   7, 148, 299, 168, 224,  48,  47, 357, 313,
             75, 104,  70, 147,  40, 110, 374,  69, 146,  37, 375, 354, 174,  41,  32, 304, 307, 312,  15, 272, 134, 242, 203, 209,
            380, 162, 297, 327,  10,  93,  42, 250, 156, 338, 292, 144, 378, 294, 329, 127, 270,  76,  95,  91, 244, 274,  27,  51,
        };

        using var dispose = ArrayPoolWrapper.Rent(LUT.Length, out Span<int> tmp);

        LUT.CopyTo(tmp);

        for (int i = 0; i < LUT.Length; i += 384)
        {
            var src = tmp.Slice(i, 384);
            var dst = LUT.Slice(i, 384);

            for (int j = 0; j < 384; j++)
            {
                dst[intraSegmentCarrierRandomizing[j]] = src[j];
            }
        }
    }

    ; static void LogicalToPhysical(Span<int> LUT)
    {
        using var dispose = ArrayPoolWrapper.Rent(LUT.Length, out Span<int> tmp);

        LUT.CopyTo(tmp);

        ReadOnlySpan<int> foo = [11, 9, 7, 5, 3, 1, 0, 2, 4, 6, 8, 10, 12];

        for (int i = 0; i < foo.Length; i++)
        {
            tmp.Slice(foo[i] * 384, 384).CopyTo(LUT.Slice(i * 384, 384));
        }
    }

    ; static void Inverse(Span<int> items)
    {
        using var dispose = ArrayPoolWrapper.Rent(items.Length, out Span<int> tmp);
        for (int i = 0; i < tmp.Length; i++) { tmp[i] = i; }

        items.Sort(tmp);

        tmp.CopyTo(items);
    }

    ; static int[] SkipPilotAcAndTmcc(ReadOnlySpan<int> LUT, int symbolN, ReadOnlySpan<int> skips)
    {
        var sp = symbolN % 4 * 3;
        using var dispose = ArrayPoolWrapper.Rent(LUT.Length, out Span<int> tmp);

        for (int i = 0, j = 0; i < LUT.Length; i++, j++)
        {
            while (j % 12 == sp || skips.Contains(j))
            {
                j++;
            }

            tmp[i] = j;
        }

        var result = new int[LUT.Length];

        for (int i = 0; i < result.Length; i++)
        {
            result[i] = tmp[LUT[i]];
        }

        return result;
    }
}

var DataCarrierLUT = GenerateDataCarrierLUT();

Complex32[][] dataCarrierFrame = Enumerable.Range(0, 204).Select(_ => new Complex32[4992]).ToArray();

void TakeDataCarrier()
{
    var src = majorFrame;
    var dst = dataCarrierFrame;
    var LUT = DataCarrierLUT;

    for (int i = 0; i < src.Length; i++)
    {
        var foo = src[i];
        var bar = dst[i];
        var hoge = LUT[i % LUT.Length];

        for (int j = 0; j < bar.Length; j++)
        {
            bar[j] = foo[hoge[j]];
        }
    }
}

for (int frameNo = 0; frameNo < 8;)
{
    try
    {
        LoadFrame(symbols1, loader1, cfo1, syncFrame1, ref frame1, equalizedFrame1);
        LoadFrame(symbols2, loader2, cfo2, syncFrame2, ref frame2, equalizedFrame2);
        LoadFrame(symbols3, loader3, cfo3, syncFrame3, ref frame3, equalizedFrame3);
    }
    catch (IOException) { break; }

    if (frame1.syncPhase == 0 ||
        frame1.syncPhase != frame2.syncPhase ||
        frame1.syncPhase != frame3.syncPhase)
    { continue; }

    frameNo++;
    frame1.syncPhase = frame2.syncPhase = frame3.syncPhase = 0;

    CombineFrame();

    TakeDataCarrier();
}

 データキャリアを抽出したコンスタレーションは以下のようになります(1フレーム分毎に8フレーム分)。

image.png

 パイロット信号、AC1、TMCCのBPSKが除去され、QPSK/64QAMだけが見えています。BPSKが除去されたからと言ってデインターリーブも正しく動いているとは限りませんが、とりあえず正しく動いているはずだと期待して次に進みます。

時間インターリーブ解除、階層分割、デマッピング、デパンクチャ、ビタビ復調

 時間インターリーブ解除からビタビ復調までは途中で動作確認が難しいので、一気に進めます(だいぶ長い)。
 時間デインターリーブ・階層分割も周波数デインターリーブと同様に、B31に従って正変換を実装してから逆変換を実装する、というような流れで実装します(今回はすでに長くなりすぎているので、正しく動いていると考えられる逆変換のコードしか掲載していませんが)。

時間インターリーブ解除、階層分割、デマッピング、デパンクチャ、ビタビ復調
#region 時間デインターリーブ

Queue<Complex32>[] timeDeInterleavingQueues;
{
    ; static IEnumerable<Queue<Complex32>> F(int I, int n)
     => Enumerable.Range(0, n)
        .SelectMany(_ =>
            Enumerable.Range(0, 384)
            .Select(i => new Queue<Complex32>(new Complex32[I * (95 - i * 5 % 96)])));

    timeDeInterleavingQueues = [
        .. F(4, 1),
        .. F(2, 12),
    ];
}

void TimeDeInterleaving()
{
    var queues = timeDeInterleavingQueues;
    foreach (var tmp in dataCarrierFrame)
    {
        for (int i = 0; i < tmp.Length; i++)
        {
            var q = queues[i];
            q.Enqueue(tmp[i]);
            tmp[i] = q.Dequeue();
        }
    }
}

#endregion

#region 階層分割

var layerACarriers = new Complex32[204 * 384 * 1];
var layerBCarriers = new Complex32[204 * 384 * 12];

void LayerDivision()
{
    var dstA = layerACarriers;
    var dstB = layerBCarriers;

    var dstAPointer = 0;
    var dstBPointer = 0;

    foreach (var src in dataCarrierFrame)
    {
        ; static void Copy(ReadOnlySpan<Complex32> src, Span<Complex32> dst, ref int srcPointer, ref int dstPointer, int N)
        {
            src.Slice(srcPointer, N).CopyTo(dst[dstPointer..]);
            srcPointer += N;
            dstPointer += N;
        }

        int srcPointer = 0;
        Copy(src, dstA, ref srcPointer, ref dstAPointer, 384 * 1);
        Copy(src, dstB, ref srcPointer, ref dstBPointer, 384 * 12);
    }
}

#endregion

#region デマッピング

var bitDeInterleavingA = new Queue<sbyte>(new sbyte[120]);
var bitDeInterleavingB = (
    new Queue<sbyte>(new sbyte[120]),
    new Queue<sbyte>(new sbyte[96]),
    new Queue<sbyte>(new sbyte[72]),
    new Queue<sbyte>(new sbyte[48]),
    new Queue<sbyte>(new sbyte[24]));

var layerAPuncturedBits = new sbyte[layerACarriers.Length * 2];
var layerBPuncturedBits = new sbyte[layerBCarriers.Length * 6];

void DemappingA()
{
    var src = layerACarriers;
    var dst = layerAPuncturedBits;
    var d0 = bitDeInterleavingA;
    const sbyte zer = 0;
    const sbyte one = 2;
    for (int i = 0, j = 0; i < src.Length; i++)
    {
        var a = src[i];
        var re = a.Real;
        var im = a.Imaginary;

        var b0 = 0 < re ? zer : one;
        var b1 = 0 < im ? zer : one;

        d0.Enqueue(b0); b0 = d0.Dequeue();

        dst[j++] = b0;
        dst[j++] = b1;
    }
}

void DemappingB()
{
    var src = layerBCarriers;
    var dst = layerBPuncturedBits;
    var (d0, d1, d2, d3, d4) = bitDeInterleavingB;
    const sbyte zer = 0;
    const sbyte one = 2;
    var scale = float.Sqrt(42);
    for (int i = 0, j = 0; i < src.Length; i++)
    {
        var a = src[i] * scale;
        var re = a.Real;
        var im = a.Imaginary;
        sbyte b0, b1, b2, b3, b4, b5;

        (b0, b2, b4) =
            re < -6 ? (one, zer, zer) :
            re < -4 ? (one, zer, one) :
            re < -2 ? (one, one, one) :
            re < -0 ? (one, one, zer) :
            re < +2 ? (zer, one, zer) :
            re < +4 ? (zer, one, one) :
            re < +6 ? (zer, zer, one) :
                      (zer, zer, zer);

        (b1, b3, b5) =
            im < -6 ? (one, zer, zer) :
            im < -4 ? (one, zer, one) :
            im < -2 ? (one, one, one) :
            im < -0 ? (one, one, zer) :
            im < +2 ? (zer, one, zer) :
            im < +4 ? (zer, one, one) :
            im < +6 ? (zer, zer, one) :
                      (zer, zer, zer);

        d0.Enqueue(b0); b0 = d0.Dequeue();
        d1.Enqueue(b1); b1 = d1.Dequeue();
        d2.Enqueue(b2); b2 = d2.Dequeue();
        d3.Enqueue(b3); b3 = d3.Dequeue();
        d4.Enqueue(b4); b4 = d4.Dequeue();

        dst[j++] = b0;
        dst[j++] = b1;
        dst[j++] = b2;
        dst[j++] = b3;
        dst[j++] = b4;
        dst[j++] = b5;
    }
}

#endregion

#region デパンクチャ

var layerADepuncturedBits = new (sbyte, sbyte)[layerAPuncturedBits.Length * 2 / 3];
var layerBDepuncturedBits = new (sbyte, sbyte)[layerBPuncturedBits.Length * 3 / 4];

void DepunctureA()
{
    var src = layerAPuncturedBits;
    var dst = layerADepuncturedBits;
    sbyte X1, Y1, X2, Y2;
    X2 = 1;
    for (int i = 0, j = 0; i < src.Length;)
    {
        X1 = src[i++];
        Y1 = src[i++];
        Y2 = src[i++];

        dst[j++] = (X1, Y1);
        dst[j++] = (X2, Y2);
    }
}

void DepunctureB()
{
    var src = layerBPuncturedBits;
    var dst = layerBDepuncturedBits;
    sbyte X1, Y1, X2, Y2, X3, Y3;
    X2 = Y3 = 1;
    for (int i = 0, j = 0; i < src.Length;)
    {
        X1 = src[i++];
        Y1 = src[i++];
        Y2 = src[i++];
        X3 = src[i++];

        dst[j++] = (X1, Y1);
        dst[j++] = (X2, Y2);
        dst[j++] = (X3, Y3);
    }
}

#endregion

#region 畳み込み符号、バイナリを16進で保存

var bytedata = new byte[layerBDepuncturedBits.Length / 8];
Convolution convolution = new("171", "133", 0, 2);

void DumpHex204byteBlock(ReadOnlySpan<byte> data, int blockSize)
{
    for (int i = 0; i < data.Length;)
    {
        for (int j = 0; j < blockSize; i++, j++)
        {
            sw.Write($"{data[i]:X2} ");
        }
        sw.WriteLine();
    }
}

#endregion

for (int frameNo = 0; frameNo < 5;)
{
    try
    {
        LoadFrame(symbols1, loader1, cfo1, syncFrame1, ref frame1, equalizedFrame1);
        LoadFrame(symbols2, loader2, cfo2, syncFrame2, ref frame2, equalizedFrame2);
        LoadFrame(symbols3, loader3, cfo3, syncFrame3, ref frame3, equalizedFrame3);
    }
    catch (IOException) { break; }

    if (frame1.syncPhase == 0 ||
        frame1.syncPhase != frame2.syncPhase ||
        frame1.syncPhase != frame3.syncPhase)
    { continue; }

    Console.Write(++frameNo);
    frame1.syncPhase = frame2.syncPhase = frame3.syncPhase = 0;

    CombineFrame();
    TakeDataCarrier();
    TimeDeInterleaving();
    LayerDivision();

    DemappingA();
    DemappingB();

    DepunctureA();
    DepunctureB();

    List<int> distance = [];
    var dst = convolution.Viterbi(layerADepuncturedBits, bytedata, 204, a => { distance.Add(a[56]); return 56; });
    sw.WriteLine($"Layer A {distance.Average():0.00}");
    DumpHex204byteBlock(dst, 204);

    distance.Clear();
    dst = convolution.Viterbi(layerBDepuncturedBits, bytedata, 204, a => { distance.Add(a[56]); return 56; });
    sw.WriteLine($"Layer B {distance.Average():0.00}");
    DumpHex204byteBlock(dst, 204);

    Console.WriteLine();
}

class Convolution
{
    public (sbyte Zero, sbyte One) Bits { get; init; }
    public int K { get; init; }

    private readonly uint _G1, _G2, _OR;
    private readonly (sbyte X, sbyte Y)[] _table;
    private readonly int[] _pathmetrics1, _pathmetrics2;
    private int[][] _pointers = [];

    #region コンストラクタ

    public Convolution(uint G1, uint G2, sbyte bitZero, sbyte bitOne)
    {
        var log2 = BitOperations.Log2(G1 | G2);
        var OR = 1U << log2;

        (Bits, K, _G1, _G2, _OR) = ((bitZero, bitOne), log2 + 1, G1, G2, OR);
        _table =
            Enumerable.Range(0, 1 << K)
            .Select(i => (
                (BitOperations.PopCount((uint)i & G1) & 1) == 0 ? bitZero : bitOne,
                (BitOperations.PopCount((uint)i & G2) & 1) == 0 ? bitZero : bitOne))
            .ToArray();
        _pathmetrics1 = new int[OR];
        _pathmetrics2 = new int[OR];
    }

    public Convolution(string G1, string G2, sbyte bitZero, sbyte bitOne)
        : this(Convert.ToUInt32(G1, 8), Convert.ToUInt32(G2, 8), bitZero, bitOne) { }

    #endregion

    #region エンコーダ

    public (uint reg, (sbyte X, sbyte Y)[]) Encode(in ReadOnlySpan<byte> src, uint reg = 0)
    {
        var dst = new (sbyte, sbyte)[src.Length * 8];
        for (int iSrc = 0, iDst = 0; iSrc < src.Length;)
        {
            for (int a = 0x80, b = src[iSrc++]; a != 0; a >>= 1)
            {
                if ((a & b) != 0)
                {
                    reg |= _OR;
                }
                dst[iDst++] = _table[reg];
                reg >>= 1;
            }
        }
        return (reg, dst);
    }

    #endregion

    #region デコーダ

    public Span<byte> Viterbi(
        in ReadOnlySpan<(sbyte X, sbyte Y)> src,
        in Span<byte> dst,
        in int blockSize,
        in Func<int[], int>? pathSelector = null)
    {
        int i, j;
        for (i = j = 0; i < src.Length; i += blockSize * 8, j += blockSize)
        {
            Viterbi(
                src.Slice(i, blockSize * 8),
                dst.Slice(j, blockSize),
                pathSelector);
        }

        return dst[..j];
    }

    public void Viterbi(
        in ReadOnlySpan<(sbyte X, sbyte Y)> src,
        in Span<byte> dst,
        in Func<int[], int>? pathSelector = null)
    {
        if (dst.Length * 8 != src.Length)
        {
            throw new InvalidOperationException();
        }

        var pointers = _pointers;
        var OR = (int)_OR;

        if (pointers.Length < src.Length)
        {
            _pointers = pointers =
                Enumerable.Range(0, src.Length)
                .Select(_ => new int[OR])
                .ToArray();
        }

        {
            var (mask, table, pathmetrics1, pathmetrics2) =
                (OR - 1, _table, _pathmetrics1, _pathmetrics2);

            Array.Clear(pathmetrics1);

            for (int i = 0; i < src.Length; i++)
            {
                var (X, Y) = src[i];
                var branches = pointers[i];
                for (int j = 0; j < pathmetrics2.Length; j++)
                {
                    var a = j << 1;
                    var b = j << 1 | 1;

                    var (aX, aY) = table[a];
                    var (bX, bY) = table[b];

                    a &= mask;
                    b &= mask;

                    var pm1 = int.Abs(X - aX) + int.Abs(Y - aY) + pathmetrics1[a];
                    var pm2 = int.Abs(X - bX) + int.Abs(Y - bY) + pathmetrics1[b];

                    (pathmetrics2[j], branches[j]) =
                        pm1 <= pm2
                        ? (pm1, a)
                        : (pm2, b);
                }

                (pathmetrics1, pathmetrics2) = (pathmetrics2, pathmetrics1);
            }
        }

        {
            var pointer = pathSelector is null ? 0 : pathSelector(_pathmetrics1);
            var thr = OR / 2;
            for (int iSrc = src.Length, iDst = dst.Length; 0 < iSrc;)
            {
                var tmp = 0;
                for (int i = 1; i < 0x100; i <<= 1)
                {
                    if (thr <= pointer) { tmp |= i; }
                    pointer = pointers[--iSrc][pointer];
                }
                dst[--iDst] = (byte)tmp;
            }
        }
    }

    #endregion
}

 ビタビ復号は1bit毎にかなりの量の計算を行うので、CPUで実装するとめちゃくちゃ遅くなります。ISDB-T TSPは204バイト単位で処理できるので、適当にパラレルに処理すると数倍程度には早くなりますが、それでもリアルタイムにはだいぶ足りません。

 出力されたHexダンプの末尾付近を抜き出すと以下のようになります。

.................................
... EA 80 FE 2B 8E 4F 70 9F FB 47 
... 8A CB 74 36 CA 06 F9 69 B4 47 
... 54 C2 C2 46 CF 32 F8 DD DD 47 
... 03 8F D5 77 8B 60 B2 3D 51 47 
... 37 FB 23 DD 9E 46 21 F1 AF 47 
... 3C 58 8F 36 24 30 BF 3A FB 47 
... 7C B3 8E 16 94 FF B8 6B 63 47 
... 89 83 19 91 DF 3F FE BE 23 47 
... 85 96 7E FE A6 AD A4 77 76 47 
... FD 0F 53 3F 19 A6 85 D4 06 47 

... 32 00 C3 1C EE B6 7B F9 C0 47 
... 4B 69 D2 6E FB 5A D1 36 3D 47 
... 6C 5A AD EC F0 49 42 F1 85 47 
... E6 25 24 37 96 88 A3 BF 2B 47 
... 7B E0 FA 10 D8 DD 2D 31 D7 47 
... 5E FA 4E AF E5 FF B6 A6 A5 47 
... CC 37 3A 5E 1A B0 D6 56 D2 47 
... 2A 1A 80 E5 19 02 AC 10 E8 47 
... 82 A1 3A B9 48 CA A1 D8 31 47 
... 8E 45 5A 2F CD D3 D3 60 1E 47 
... DF 83 DF 6F 99 DF 3E B5 58 47 
... 8D 5E 2E F3 0B 01 C3 B0 F0 47 
... D4 15 51 31 E1 69 AB 6A D3 47 
... 0A BA C4 B6 DC C7 54 6A 71 47 
... 4A 4C 69 1F E0 ED B4 D6 64 47 
... 62 43 87 EE A4 56 C8 1A 40 47 
... 9F 8C 14 DE 71 77 BC 8E AC 47 
... 40 B7 88 79 D9 85 2F 98 C4 47 
... B3 E5 EA E4 32 25 D8 00 14 47 
... F8 53 98 C6 16 2C 96 73 B0 47 
... 83 4E 0E 90 7E D3 A6 9F 4E 47 
... 42 5C 4E 04 98 1E 05 FC CC 47 
... 7C 9B 77 84 22 57 3B D9 94 47 
... 77 C0 37 99 CE EF 6F CE 64 47 
... 0C BD 08 E4 49 41 4B 4E 37 47 
... F9 D1 6A 1A C4 9C D2 80 92 47 
... 1E 4E 92 D5 B0 6B 94 CD 5D 47 
... A4 26 85 B7 17 7B 68 25 4B 47 
... B9 AD F5 D4 51 59 07 30 D5 47 
... 3B FE 49 72 19 F2 DF EA D5 47 
... 9E E8 C9 9C DF 18 8B 90 81 47 
... 54 9F 6A A1 B0 BD AB 60 22 47 
... 1F 5C 3D AD 06 AB 5C 3C EE 47 
... E1 34 80 E2 44 C6 5E D4 65 47 
... 76 98 D0 8F 36 9E 95 94 7C 47 
... A9 5A D7 C9 1F CF BD 46 58 47 
... DE 81 23 21 E7 61 09 DF DE 47 
... CB F4 47 DE F7 54 25 B9 CC 47 
... 2E 5E 94 E2 E6 24 AE 3F 44 47 
... C6 95 CA 7F F0 5F CF 94 47 47 
... 56 F1 E9 BC 75 86 3F B5 78 47 
... B2 05 F9 F9 C4 38 83 C9 3E 47 
... 5F 35 F3 01 52 69 97 B7 B8 47 
... E5 F9 34 D0 41 26 94 F0 8B 47 
... 46 45 52 4F 10 A7 9B 7E 87 47 
... 87 41 38 64 6D B8 E2 B3 18 47 
... D9 FE 07 31 E8 ED CE 3D F3 47 
... 3C 6E B4 F5 54 BA F3 B8 C1 47 
... E3 61 DC BD 15 F9 8E 41 04 47 
... 02 AC 72 FD F4 DE 56 F0 F7 47 
... C4 B2 C8 80 BF 6E 81 C1 B9 47 
... E2 2D A6 AE 6A 8C AB A8 0E 47 
... 53 AC D1 DF 8B 67 89 09 9A 47 
... D5 B3 DD DD 79 F0 11 E5 4C 47 
... BE E0 F7 99 65 C3 C4 2A 10 47 
... BA 88 6E 08 C8 48 86 78 AE 47 
... C6 7A 60 32 C5 B4 B6 B2 E0 47 
... D5 08 3C FC 61 C1 4E 90 06 47 
... A2 71 2E 64 B0 4D 58 D6 F8 47 
... 98 67 96 01 A6 38 BA 14 39 47 
... 8E 83 97 56 C1 7C 20 C2 3A 47 
... F1 D0 C9 DC 11 53 C4 19 01 47 
... 46 F2 ED A3 1F CC E7 55 B9 47 
... 59 A1 00 64 9E 97 2C 6D F7 47 

... 4D B3 2E 68 F4 E8 2F 98 0D 47 
... 60 3E 92 46 5E F4 75 69 C0 47 
... 45 4D F8 C2 21 61 A9 1B 06 47 
... 06 68 B6 26 9A BF 33 89 79 47 
... 32 1D 70 5A 75 A8 BE 3F 71 47 
... B0 ED 7C 88 0E 63 8B 88 16 47 
... C0 15 56 A4 F0 6B 50 77 3D 47 
... 53 3B 65 A9 4F 4C BE 6F E4 47 
... A8 23 79 C1 23 C7 2F DD 50 47 
... 50 1B 5E 60 36 31 8B 32 3C 47 
.................................

 最初の10行が前のフレームのLayer B、続く64行が現在のLayer A、続く10行が現在のLayer Bです。ISDB-Tでは204バイトを1パケットとして先頭の同期バイト(47h)を末尾に配置しています(実際は1バイトずらしている)。末尾が47hなのはビタビ複号のためですが、ビタビ復号後に末尾に47hが固まっていればここまでの処理は正しく動いていると考えられます。ただし今回の処理の特性上、末尾の下位4bitが7になることが多いため、確認するときは要注意です。また、インターリーブの関係上、最初の数フレームは無効なデータが出力されるため、5フレーム目あたりで確認を行います。

 また、Layer AやLayer Bに対して、以下のような情報を出力しています。

...
Layer A 816.00
...
Layer B 1131.25
...
Layer A 816.00
...
Layer B 1131.35
...

 これは各レイヤ毎に1パケットあたりのビタビ復調の際のハミング距離の平均値を表しています。
 例えばLayer Aの場合、畳み込み符号化率2/3で、これはデータ入力2bitを畳み込んで4bitの畳み込み符号を得て、そのうちの1bitを破棄して3bitを送出している、という意味です。受信する際は3bitと合わせて1bitの中間値を挿入し4bitに戻してからビタビ復号によって2bitのデータへ戻しています。ビット演算はゼロイチ(0/1)で処理し、中間値として値0.5を挿入する、というような説明が一般的に行われていますが、今回はビットを0と2で表し、中間値として1を挿入しています。受信したビットは0か2に振り分けられますが、4bitの内1bitは0/2から距離が1の中間値を挿入しているわけです。
 ISDB-Tでは204バイト単位で畳み込み符号の処理を行いますが、畳み込み符号化を行った3264bitのデータを復号に使用します。その内の1/4がハミング距離1ですから、理論的に204バイトあたり距離816の誤りがパンクチャド処理によって発生します。
 Layer Aのハミング距離は816であり、理論値が得られていることから、コンスタレーションの復号で誤りが発生していないことがわかります。
 Layer Bは符号化率3/4なので、6bit中2bitが挿入ビットで、理論値は1088となります。実際には1131.3程度の値で、デマッピングで誤った場合は1bitあたりハミング距離2の誤りになりますから、2176bitあたり22bit程度の誤り(BER 1.33x10-2程度)が発生していることになります(ただしビタビ復号の結果にも依然誤りが含まれており、出力されている数値(BER)は、この誤った出力と入力のハミング距離です)。

 また、ビタビ復号で56というマジックナンバーが出てきますが、これは0x47を畳み込んだ際のレジスタの値です。通常、畳み込み符号は畳み込んだ後にレジスタがゼロになるようにテールビットと呼ばれる数bitの0を末尾に追加します。テールビットはレジスタの値(ビタビ復号の際に優先して使用するパス)を既知にすることで復号処理の精度を上げるために使用するものです。MPEG-2 TSの場合、一定の周期(204バイト毎)で既知のビット列(同期信号0x47)が現れるため、これをテールビットのかわりとして使用しており、そのときのレジスタの値が56となるわけです(MPEG-2 TSをISDB-Tで伝送する際に1バイトずらして204バイトブロックの最後に0x47を置いているのはこのためです)。

QAMのマッピング

 ARIB STD-B31の64QAMのマッピングを見てみると、一見ランダムにビットが割り当てられているように見えるかもしれません。とはいえ、これは規則的に配置されています。
 64QAMでは64(26)箇所に対して6bitが割り当てられていますが、実際にはI/Q軸で独立して8箇所3bitが割り当てられているに過ぎません。また、8箇所の配置も、グレイコードで並べられています。グレイコードに並んでいるおかげで、隣のコンスタレーションと誤認してデマッピングしても1bitの誤りで済み、誤り訂正能力を高めることができます。
 今回は64QAMのデマッピングはベタ書きしていますが、ロジックでテーブルを作ることも可能です。今回は硬判定ビタビ復号を使用していますが、ロジックでテーブルを作れば多値(例えば8値)の軟判定ビタビ復号も可能です。軟判定の閾値(分割数n)とBER(ビタビ復号のハミング距離ではない。後述)の一例を以下に示します。

image.png

 条件(受信強度とか)やデータによっても変わると思いますが、3分割程度でも硬判定と比べてかなり高い符号化利得が得られます(比較的早い段階でBERが下げ止まっている理由は不明。実装ミスの可能性あり。あるいは受信機のスカートに引っかかっているキャリアが支配的になって高止まりしている可能性)。

畳み込み符号・ビタビ復号

 畳み込み符号やビタビ復号に関してはインターネット上を始めとして多くの情報源があるので、詳しくはそちらを参照してください。

 畳み込み符号の説明では、拘束長K=3でG1, G2が5,7の畳み込み符号が良く使用されると思います。
 これに関してはConvolution conv = new(5, 7, 0, 1);のようにインスタンスを確保します。

 エンコードは

var a = conv.Encode([0b01010000]).Item2;
Console.WriteLine(string.Join("", a.Select(a => $"{a.X}{a.Y}")));

 のように行います。

 先に示した畳み込み符号・ビタビのプログラムは簡素化のために1バイト(8bit)単位での処理を基本としています。そのため、4bitのデータ列0101にテールビット00をつけた6bitのデータに対して、さらに2bitのパディングビットを付与して0b01010000を入力に与えています。
 これにより0011010001110000という結果が得られ、パディングビットを除去した001101000111010100のエンコード結果となります。

 デコードでは2bitの誤りを追加した001001100111というデータに対して

var src = "0010011001110000".Chunk(2).Select(a => ((sbyte)(a[0] - '0'), (sbyte)(a[1] - '0'))).ToArray();
var dst = new byte[1];
var distance = 0;
conv.Viterbi(src, dst, a => { distance = a.Min(); return a.ToList().IndexOf(distance); });

Console.WriteLine(distance + " : " + string.Join(' ', dst.Select(a => $"{a:b8}")));

 のように行います。これも同じく8bit単位で処理するためにパディングビットを追加しています。
 これにより2 : 01010000という結果が得られます。2がハミング距離を示し、この場合は2bitエラーを意味します。また01010000がデコードされた値(テールやパディングを含む)であり、誤った情報から正しい情報を得ることができています。

バイトデインターリーブ、エネルギー逆拡散、RS復調

バイトデインターリーブ、エネルギー逆拡散、RS復調
#region バイトデインターリーブ

Queue<byte>[] layerAByteDeInterleaving;
Queue<byte>[] layerBByteDeInterleaving;
{
    layerAByteDeInterleaving = F();
    layerBByteDeInterleaving = F();
    Queue<byte>[] F()
     => Enumerable.Range(0, 12)
        .Select(i => new Queue<byte>(new byte[i * 17]))
        .Reverse()
        .ToArray();
}

void ByteDeInterleaving(Span<byte> data, Queue<byte>[] buff)
{
    for (int i = 0; i < data.Length;)
    {
        foreach (var q in buff)
        {
            q.Enqueue(data[i]);
            data[i++] = q.Dequeue();
        }
    }
}

#endregion

#region エネルギー拡散

byte[] energyDispersalPRBS;

{
    energyDispersalPRBS = GenerateEnergyDispersalPRBS(bytedata.Length);
    static byte[] GenerateEnergyDispersalPRBS(int n)
    {
        var prbs = new byte[n];

        for (int i = 0, reg = 0b100101010000000; i < prbs.Length; i++)
        {
            var tmp = 0;
            for (int j = 0x80; 0 != j; j >>= 1, reg >>= 1)
            {
                switch (reg & 0x3)
                {
                    case 0x1:
                    case 0x2:
                        reg |= 0x8000;
                        tmp |= j;
                        break;
                }
            }
            prbs[i] = (byte)tmp;
        }

        for (int i = 203; i < prbs.Length; i += 204)
        {
            prbs[i] = 0;
        }

        return prbs;
    }
}

void EnergyDispersal(Span<byte> data)
{
    var tmp = energyDispersalPRBS;

    for (int i = 0; i < data.Length; i++)
    {
        data[i] ^= tmp[i];
    }
}

#endregion

#region RS復調

ReedSolomonDecoder _rsd = new(new(285, 256, 0)); // NuGet: ZXing.Net
int[] _rsBuff = new int[204];

Span<byte> RSDecode(Span<byte> data, out double BER, int dstBlockSize = 204)
{
    var (rsd, buff, bitError, dstN) = (_rsd, _rsBuff, 0, 0);
    for (int i = 0; i < data.Length;)
    {
        for (int j = 1; j < 204; j++)
        {
            buff[j] = data[i++];
        }
        buff[0] = data[i++];

        var decodeState = rsd.decode(buff, 16);

        i -= 204;
        var error = 0;
        for (int j = 1; j < 204; j++)
        {
            error += int.PopCount(buff[j] ^ data[i++]);
        }
        error += int.PopCount(buff[0] ^ data[i++]);

        if (decodeState)
        {
            bitError += error;
        }
        else
        {
            bitError += dstBlockSize * 8 / 2;
            buff[0] = 0x47;
            buff[1] |= 0x80;
        }

        for (int j = 0; j < dstBlockSize; j++)
        {
            data[dstN++] = (byte)buff[j];
        }
    }

    BER = (double)bitError / (data.Length * 8);

    return data[..dstN];
}

#endregion

using FileStream dstFs = new("log.ts", FileMode.Create, FileAccess.Write);

var loop = true;
Console.CancelKeyPress += (object? _, ConsoleCancelEventArgs e) =>
{
    if (loop)
    {
        loop = false;
        e.Cancel = true;
    }
};

var frameNo = 0;
while (loop)
{
    try
    {
        LoadFrame(symbols1, loader1, cfo1, syncFrame1, ref frame1, equalizedFrame1);
        LoadFrame(symbols2, loader2, cfo2, syncFrame2, ref frame2, equalizedFrame2);
        LoadFrame(symbols3, loader3, cfo3, syncFrame3, ref frame3, equalizedFrame3);
    }
    catch (IOException) { break; }

    if (frame1.syncPhase == 0 ||
        frame1.syncPhase != frame2.syncPhase ||
        frame1.syncPhase != frame3.syncPhase)
    { continue; }

    Console.Write(++frameNo);
    frame1.syncPhase = frame2.syncPhase = frame3.syncPhase = 0;

    CombineFrame();
    TakeDataCarrier();
    TimeDeInterleaving();
    LayerDivision();

    DemappingA();
    DemappingB();

    DepunctureA();
    DepunctureB();

    var dstBlockSize = 188;
    {
        List<int> distance = [];
        var byteSpan = convolution.Viterbi(layerADepuncturedBits, bytedata, 204, a => { distance.Add(a[56]); return 56; });
        ByteDeInterleaving(byteSpan, layerAByteDeInterleaving);
        EnergyDispersal(byteSpan);
        byteSpan = RSDecode(byteSpan, out var BER, dstBlockSize);

        sw.WriteLine($"Layer A {distance.Average():0.00} {BER:0.00E-00;0;0.00E+00}");
        if (frameNo <= 5)
        {
            DumpHex204byteBlock(byteSpan, dstBlockSize);
        }
        dstFs.Write(byteSpan);
    }
    {
        List<int> distance = [];
        var byteSpan = convolution.Viterbi(layerBDepuncturedBits, bytedata, 204, a => { distance.Add(a[56]); return 56; });
        ByteDeInterleaving(byteSpan, layerBByteDeInterleaving);
        EnergyDispersal(byteSpan);
        byteSpan = RSDecode(byteSpan, out var BER, dstBlockSize);

        sw.WriteLine($"Layer B {distance.Average():0.00} {BER:0.00E-00;0;0.00E+00}");
        if (frameNo <= 5)
        {
            DumpHex204byteBlock(byteSpan, dstBlockSize);
        }
        dstFs.Write(byteSpan);
    }

    Console.WriteLine();
}

 与えられたIQファイルの最後まで処理するので、ctrl-cで適当なところで止めてください。動作確認だけなら5フレーム程度で十分です。

 Hexダンプを抜き出すと以下のようになります。

...................................................
47 01 00 D4 E5 35 05 08 1B 9B 4F EE 4D CA B2 98 ...
47 41 F0 14 00 02 B0 CF 30 00 F1 00 00 E1 FF F0 ...
47 01 00 D5 4C 46 95 9A 77 FC CD 28 B1 69 7B 8B ...
47 01 00 D6 29 3A 1E 14 67 69 51 5E 21 F9 E0 69 ...
47 01 00 D7 05 AA DD F9 24 82 56 D1 9A 81 E2 C3 ...
47 01 00 D8 EC 47 8A 8B 2E 30 EC 61 53 87 E4 56 ...
47 01 00 D9 A1 23 D0 38 14 C9 6B 44 B6 E2 30 74 ...
47 01 00 DA DD 51 61 DE CB D4 47 2F BE 29 87 6A ...
47 01 00 DB B7 19 14 2B 79 73 E7 FD CD 61 CA 34 ...
47 01 00 DC 7D DA F9 BC 89 62 AE 15 BF 08 1B 6A ...
Layer A 816.00 0.00E+00
47 05 FF 20 B7 10 9C 7D F8 70 7E FD FF FF FF FF ...
47 05 80 1F 11 9D 4E E7 E7 3D 0C 82 20 AB 33 33 ...
47 05 81 16 2D 0E 33 8D 44 2D 74 B1 D1 09 81 BB ...
47 1F FF 10 FF FF FF FF FF FF FF FF FF FF FF FF ...
47 05 81 17 B6 B2 EC A1 E2 B0 99 03 1A 7F C0 F5 ...
47 05 83 16 2D 90 03 89 0F E5 53 A2 31 89 71 E4 ...
47 05 81 18 30 12 08 21 DF 48 24 6B 9A B4 BB B7 ...
47 05 8B 30 04 00 FF FF FF A9 47 51 6F 8F 8D F7 ...
47 05 81 19 08 79 3A C7 62 C0 6F 35 E1 C7 6E 54 ...
47 05 80 10 28 EB E0 C1 83 2B 57 AE 2C 2D 2D 8D ...
47 05 81 1A DC 82 EB FE EC 6A D9 7C 8E 17 98 97 ...
47 1F FF 10 FF FF FF FF FF FF FF FF FF FF FF FF ...
47 05 81 1B 79 EA AB AA A8 BF 48 24 0B 63 3A B4 ...
47 45 83 17 00 00 01 C0 02 DA 80 80 07 29 E3 F1 ...
47 45 81 1C 00 00 01 E0 00 00 84 80 05 29 E3 FD ...
47 1F FF 10 FF FF FF FF FF FF FF FF FF FF FF FF ...
47 05 81 1D 46 82 A7 94 F4 B7 F1 14 5D EE 1D 08 ...
47 05 80 11 17 AA 94 D7 9A 65 87 93 33 9B F7 53 ...
47 05 81 1E 79 1C 3D 39 74 2E C7 5C E4 3E AB 6C ...
47 1F FF 10 FF FF FF FF FF FF FF FF FF FF FF FF ...
47 05 81 1F DB 09 50 D4 DC C1 D3 59 A2 34 B6 41 ...
47 1F FF 10 FF FF FF FF FF FF FF FF FF FF FF FF ...
47 05 83 18 E6 C0 B9 A7 59 F5 3D 9E BF F8 B4 3C ...
47 45 8B 11 00 3C B7 87 01 01 EB 00 00 11 03 10 ...
47 05 81 10 36 07 12 3A 2A 7E AB 7F 31 9D FE E5 ...
47 05 81 11 57 18 FA 12 0B E6 18 57 BB BD 09 DC ...
47 05 80 12 B2 AB 56 EC 69 6C E3 DD CE 71 2A 27 ...
47 5F C8 1E 00 02 B0 60 31 80 E3 00 00 E5 FF F0 ...
47 05 81 12 52 CD 76 FC CA C6 EF 59 E1 19 38 6E ...
47 05 81 13 CC 48 E9 7D C8 84 F4 F5 EC 20 B3 16 ...
47 05 83 19 B9 B4 24 63 DD 5C CC BF 56 1D 0A 76 ...
47 05 81 14 BB DB BD AE 28 24 5C BD F1 A1 93 CB ...
47 05 80 13 02 6E 54 0A 6F F3 BB F4 DD ED 5D 21 ...
47 05 81 15 1C 5D 81 C8 35 94 87 D2 7F 91 77 09 ...
47 1F FF 10 FF FF FF FF FF FF FF FF FF FF FF FF ...
47 05 81 16 CB 2F 75 F8 67 58 CF 06 B6 8F 94 AB ...
47 1F FF 10 FF FF FF FF FF FF FF FF FF FF FF FF ...
47 05 81 17 05 5C 9C 22 04 5D FE BC 0A 3D CE A6 ...
47 05 83 1A 85 68 1D C8 12 87 AD A3 AE 4B 3A 5D ...
47 05 8B 12 C7 EE 5C EB 69 F1 BC 50 DF E4 F3 AC ...
47 05 81 18 BF 65 EE FE 73 2E 34 68 FC B4 CE 75 ...
47 1F FF 10 FF FF FF FF FF FF FF FF FF FF FF FF ...
47 1F FF 10 FF FF FF FF FF FF FF FF FF FF FF FF ...
47 05 81 19 BF 93 31 FE BA 08 5C F8 F4 9C 4A 62 ...
47 05 80 14 95 C4 D9 77 F6 3D 6B 09 F8 E5 B4 3A ...
47 05 81 1A 3F 43 3A 8C CB F4 8A 52 B1 1D 4C 7D ...
47 05 81 1B F1 0C D1 88 E5 25 C3 08 C2 64 71 67 ...
47 45 83 1B 00 00 01 C0 02 DA 80 80 07 29 E3 F1 ...
47 05 80 15 BE 64 28 34 A4 6B 84 EF B8 21 D5 F8 ...
47 05 81 1C 14 CE FD BD 58 D3 00 69 E3 F1 95 24 ...
47 05 81 1D 78 FD 31 E4 A1 22 2D 37 BB 1A 03 95 ...
47 1F FF 10 FF FF FF FF FF FF FF FF FF FF FF FF ...
47 05 81 1E DB 7A 47 B1 67 2E 5F AD 69 36 23 6D ...
47 1F FF 10 FF FF FF FF FF FF FF FF FF FF FF FF ...
47 05 81 1F 6E E8 A7 49 7E A2 32 33 33 E0 77 8E ...
47 05 83 1C 22 4C 0D 9C 2A 1C 01 55 33 78 BB AE ...
47 05 80 36 86 00 FF FF FF FF FF FF FF FF FF FF ...
47 05 81 10 69 6C 89 60 D2 ED 27 4F 40 5A 8E 3E ...
47 05 81 11 56 81 77 9D BA E3 8F C8 CD 45 10 67 ...
47 05 8B 13 5C A3 3C 1D A2 1F 68 B0 6A 5C FA 53 ...
47 1F FF 10 FF FF FF FF FF FF FF FF FF FF FF FF ...
47 05 81 12 09 E9 B4 51 24 04 7D 2B 98 DD A7 A1 ...
47 1F FF 10 FF FF FF FF FF FF FF FF FF FF FF FF ...
47 05 81 13 43 DD 85 77 B9 29 04 EA 8E 52 0D A3 ...
Layer B 1131.35 1.34E-03
47 01 FF 20 B7 10 9C 7E 20 B7 7E 1B FF FF FF FF ...
47 01 00 DD 40 62 B9 AE CE CC D5 CD 75 22 CC BC ...
47 01 00 DE CF 20 E7 01 0A AB E9 7A 6D 49 A2 BC ...
47 01 60 D7 DD 23 76 7B 09 C3 99 92 77 6A 75 FC ...
47 01 00 DF A7 56 02 46 93 73 0D 17 13 10 E8 85 ...
47 01 00 D0 05 28 15 F3 CC B2 56 BB AC 32 D6 FB ...
47 01 00 D1 D2 89 AF 87 71 81 72 B6 99 13 05 41 ...
47 01 00 D2 FB 9E B8 E4 ED 5F A8 1E 94 CA C7 B6 ...
47 01 00 D3 10 29 15 A4 2A 65 C5 2B 5F 7B DF C6 ...
47 01 00 D4 55 6D 4D 81 BB FE 06 57 2C F3 C6 76 ...
...................................................

 先頭の4バイトがトランスポートストリームのヘッダなので、薄目で見ると縦方向に一定のパターンが見えます。またNullパケット(PID 0x1FFF、FFh埋め)による水平方向のパターンも何箇所か見られます。

 Layer AやLayer Bには以下のような情報を出力します。

Layer A 816.00 0.00E+00
...
Layer B 1131.44 1.42E-03
...
Layer A 816.00 0.00E+00
...
Layer B 1131.41 1.37E-03

 最初の数字がビタビ復号でのハミング距離、続く数字がリードソロモンで訂正した誤り(BER)です。BERの計算には、リードソロモン符号の前後でxorを取り、PopCountを総ビット数で割っています。
 Layer Aはビタビ復号でエラーフリーなので、そこから出力されるデータもエラーフリーであり、RS誤り訂正で変化したビットはありません。
 Layer Bはビタビ復号後のデータに1.4x10-3程度の誤りが発生しています。
 リードソロモン誤り訂正で訂正できなかったパケットに対しては、先頭に47hを付与し、続くバイトに0x80をORしています。これによって、1) トランスポートストリームの受信機のパケットへのロックを維持し、2) 誤りが含まれているパケットであることを通知する、ということを行っています。また、この場合はBERの計算に対して1/2が誤っているとして出力しています。

 リードソロモン誤り訂正が成功し、NULLパケットのFF埋めのように妥当な結果が得られたと考えられる場合、それ以前の処理はおそらく正しく動いているはずです。もしリードソロモン誤り訂正に失敗した場合は上流に戻って怪しいところを探す必要があります。テストデータがない場合、ISDB-Tでは周波数デインターリーブからエネルギー拡散までの長い間のどこで間違っているかわからないので、怪しい場所を1箇所1箇所潰していく必要があります。
 ワンセグの受信とフルセグの受信では処理の難易度が明らかに違います。周波数インターリーブやマッピングなどが主な違いですが、ワンセグ放送はだいぶ復調しやすいです(あまりに簡単すぎてデジタル復調の練習には物足りないくらいです。だからフルセグ放送の復調を試みているわけです)。自前で処理を実装する場合は、まずワンセグ放送だけの復調を実装してみてください。ワンセグ放送の復調が正しく動いているなら、ビタビ復調やバイトインターリーブ、エネルギー拡散等の処理は正しく動いているはずですから、フルセグ受信固有の箇所(周波数デインターリーブやデマッピングなど)に集中して怪しい場所を探します。

 ちなみに、30秒のIQファイルは1個120MB程度、3個で350MB程度であり、そこから得られたTSファイル(フルセグ)は57MB程度で、IQファイルからTSファイルへ変換することでファイルサイズは6分の1以下まで小さくなりました。

復調結果をffprobeで覗いてみる

 十分な長さをファイル(拡張子*.ts)へ書き出してffprobeに入れると以下のような出力が得られます(様々なエラーや警告は除いてある)。

Input #0, mpegts, from 'log.ts':
 Duration: 00:00:23.06, start: 63198.879500, bitrate: 15938 kb/s
 Program 12288 
   Metadata:
     service_name    : ?NHK?Am9g?1 ?00@n
     service_provider: 
 Stream #0:0[0x100]: Video: mpeg2video ([2][0][0][0] / 0x0002), none, 90k tbn
 Stream #0:1[0x110]: Audio: aac ([15][0][0][0] / 0x000F), 0 channels
 Stream #0:2[0x130]: Subtitle: arib_caption (Profile A) ([6][0][0][0] / 0x0006)
 Stream #0:3[0x138]: Data: bin_data ([6][0][0][0] / 0x0006)
 Stream #0:4[0x140]: Unknown: none ([13][0][0][0] / 0x000D)
 Stream #0:5[0x160]: Unknown: none ([13][0][0][0] / 0x000D)
 Stream #0:6[0x161]: Unknown: none ([13][0][0][0] / 0x000D)
 Stream #0:7[0x162]: Unknown: none ([13][0][0][0] / 0x000D)
 Stream #0:8[0x170]: Unknown: none ([13][0][0][0] / 0x000D)
 Stream #0:9[0x171]: Unknown: none ([13][0][0][0] / 0x000D)
 Stream #0:10[0x172]: Unknown: none ([13][0][0][0] / 0x000D)
 Program 12289 
   Metadata:
     service_name    : ?NHK?Am9g?2 ?00@n
     service_provider: 
 Stream #0:0[0x100]: Video: mpeg2video ([2][0][0][0] / 0x0002), none, 90k tbn
 Stream #0:1[0x110]: Audio: aac ([15][0][0][0] / 0x000F), 0 channels
 Stream #0:2[0x130]: Subtitle: arib_caption (Profile A) ([6][0][0][0] / 0x0006)
 Stream #0:3[0x138]: Data: bin_data ([6][0][0][0] / 0x0006)
 Stream #0:4[0x140]: Unknown: none ([13][0][0][0] / 0x000D)
 Stream #0:5[0x160]: Unknown: none ([13][0][0][0] / 0x000D)
 Stream #0:6[0x161]: Unknown: none ([13][0][0][0] / 0x000D)
 Stream #0:7[0x162]: Unknown: none ([13][0][0][0] / 0x000D)
 Stream #0:8[0x170]: Unknown: none ([13][0][0][0] / 0x000D)
 Stream #0:9[0x171]: Unknown: none ([13][0][0][0] / 0x000D)
 Stream #0:10[0x172]: Unknown: none ([13][0][0][0] / 0x000D)
 Program 12672 
   Metadata:
     service_name    : ?NHK?7HBS?G ?00@n
     service_provider: 
 Stream #0:12[0x580]: Unknown: none ([13][0][0][0] / 0x000D)
 Stream #0:13[0x581]: Video: h264 (Constrained Baseline) ([27][0][0][0] / 0x001B), yuv420p(progressive), 320x180, 14.99 fps, 14.99 tbr, 90k tbn
 Stream #0:14[0x583]: Audio: aac (HE-AACv2) ([15][0][0][0] / 0x000F), 48000 Hz, stereo, fltp, 47 kb/s
 Stream #0:15[0x587]: Subtitle: arib_caption (Profile C) ([6][0][0][0] / 0x0006)
 Stream #0:16[0x589]: Unknown: none ([13][0][0][0] / 0x000D)
 Stream #0:17[0x58a]: Unknown: none ([13][0][0][0] / 0x000D)
 Stream #0:18[0x58b]: Unknown: none ([13][0][0][0] / 0x000D)
 Program 65520 
 Stream #0:19[0x1c71]: Unknown: none ([13][0][0][0] / 0x000D)
 Stream #0:20[0x1c72]: Unknown: none ([13][0][0][0] / 0x000D)
 Stream #0:21[0x1c73]: Unknown: none ([13][0][0][0] / 0x000D)
 Stream #0:22[0x1c74]: Unknown: none ([13][0][0][0] / 0x000D)
 Stream #0:23[0x1c75]: Unknown: none ([13][0][0][0] / 0x000D)
 Stream #0:24[0x1c76]: Unknown: none ([13][0][0][0] / 0x000D)
 Stream #0:25[0x1c77]: Unknown: none ([13][0][0][0] / 0x000D)
 Stream #0:26[0x1c78]: Unknown: none ([13][0][0][0] / 0x000D)
 Stream #0:27[0x1c60]: Unknown: none ([13][0][0][0] / 0x000D)
 Stream #0:28[0x1c61]: Unknown: none ([13][0][0][0] / 0x000D)
 Stream #0:29[0x1c62]: Unknown: none ([13][0][0][0] / 0x000D)
 Stream #0:30[0x1c63]: Unknown: none ([13][0][0][0] / 0x000D)
 Stream #0:11[0x12]: Data: epg
 Stream #0:31[0x238]: Unknown: none

 サービス名が文字化けしていますが、3つのサービス(4つのプログラム)が格納されており、その内2つはVideoがmpeg2video, noneとなっており、最後の一つはh264 yuv420p 320x180, 14.99fps というようなパラメータが読み取れます。
 最初の2つがnoneになっているのはスクランブルが施されているからであり、最後のプログラムはスクランブルが施されていないワンセグのため映像や音声のパラメータが確認できます。
 TSファイルをffplayに入れればワンセグ画質で再生できますし、ffmpegでmp4などへ変換することもできます。

 RS誤り訂正後のHEXダンプを再掲します。

...................................................
47 01 00 D4 E5 35 05 08 1B 9B 4F EE 4D CA B2 98 ...
47 41 F0 14 00 02 B0 CF 30 00 F1 00 00 E1 FF F0 ...
47 01 00 D5 4C 46 95 9A 77 FC CD 28 B1 69 7B 8B ...
47 01 00 D6 29 3A 1E 14 67 69 51 5E 21 F9 E0 69 ...
47 01 00 D7 05 AA DD F9 24 82 56 D1 9A 81 E2 C3 ...
47 01 00 D8 EC 47 8A 8B 2E 30 EC 61 53 87 E4 56 ...
47 01 00 D9 A1 23 D0 38 14 C9 6B 44 B6 E2 30 74 ...
47 01 00 DA DD 51 61 DE CB D4 47 2F BE 29 87 6A ...
47 01 00 DB B7 19 14 2B 79 73 E7 FD CD 61 CA 34 ...
47 01 00 DC 7D DA F9 BC 89 62 AE 15 BF 08 1B 6A ...
Layer A 816.00 0.00E+00
47 05 FF 20 B7 10 9C 7D F8 70 7E FD FF FF FF FF ...
47 05 80 1F 11 9D 4E E7 E7 3D 0C 82 20 AB 33 33 ...
47 05 81 16 2D 0E 33 8D 44 2D 74 B1 D1 09 81 BB ...
47 1F FF 10 FF FF FF FF FF FF FF FF FF FF FF FF ...
47 05 81 17 B6 B2 EC A1 E2 B0 99 03 1A 7F C0 F5 ...
47 05 83 16 2D 90 03 89 0F E5 53 A2 31 89 71 E4 ...
47 05 81 18 30 12 08 21 DF 48 24 6B 9A B4 BB B7 ...
47 05 8B 30 04 00 FF FF FF A9 47 51 6F 8F 8D F7 ...
47 05 81 19 08 79 3A C7 62 C0 6F 35 E1 C7 6E 54 ...
47 05 80 10 28 EB E0 C1 83 2B 57 AE 2C 2D 2D 8D ...
...................................................

 上の10行がLayer B(フルセグ放送)、下の10行がLayer A(ワンセグ放送)のトランスポートストリームのヘッダ付近です。TSヘッダは4バイトの長さですが、最後の1バイトの上位2bitがスクランブルのフラグです。Layer B(フル(12)セグ放送)は4以上の値(D)が設定されており、スクランブル化されていることがわかります。Layer A(ワンセグ放送)は1などの値であり、スクランブル化されていないことがわかります。

 NHKは大規模な災害が発生した場合にスクランブルを解除して(B-CASカードが抜かれたテレビ、あるいはスクランブル解除部が壊れたテレビでも視聴できる状態で)緊急放送を行う場合があります。先日の能登半島地震の際に受信したTMCCおよびTSの一部を以下に示します。

TMCC
{
  "SystemIdentification": "DigitalTerestrialTelevisionBroadcastingSystem",
  "IndicatorOfTransmissionParameterSwitching": "NormalValue",
  "StartupControlSignal": "StartupControlAvailable",
  "CurrentInformation": {
    "PartialReceptionFlag": "PartialReceptionAvailable",
    "HierarchicalLayerA": {
      "CarrierModulationMappingScheme": "QPSK",
      "ConvolutionCodingRate": "2/3",
      "TimeInterleavingLength": "16/8/4",
      "NumberOfSegments": "1Segment"
    },
    "HierarchicalLayerB": {
      "CarrierModulationMappingScheme": "64QAM",
      "ConvolutionCodingRate": "3/4",
      "TimeInterleavingLength": "8/4/2",
      "NumberOfSegments": "12Segments"
    },
    "HierarchicalLayerC": {
      "CarrierModulationMappingScheme": "UnusedHierarchicalLayer",
      "ConvolutionCodingRate": "UnusedHierarchicalLayer",
      "TimeInterleavingLength": "UnusedHierarchicalLayer",
      "NumberOfSegments": "UnusedHierarchicalLayer"
    }
  },
  "NextInformation": {
    "PartialReceptionFlag": "PartialReceptionAvailable",
    "HierarchicalLayerA": {
      "CarrierModulationMappingScheme": "UnusedHierarchicalLayer",
      "ConvolutionCodingRate": "UnusedHierarchicalLayer",
      "TimeInterleavingLength": "UnusedHierarchicalLayer",
      "NumberOfSegments": "UnusedHierarchicalLayer"
    },
    "HierarchicalLayerB": {
      "CarrierModulationMappingScheme": "UnusedHierarchicalLayer",
      "ConvolutionCodingRate": "UnusedHierarchicalLayer",
      "TimeInterleavingLength": "UnusedHierarchicalLayer",
      "NumberOfSegments": "UnusedHierarchicalLayer"
    },
    "HierarchicalLayerC": {
      "CarrierModulationMappingScheme": "UnusedHierarchicalLayer",
      "ConvolutionCodingRate": "UnusedHierarchicalLayer",
      "TimeInterleavingLength": "UnusedHierarchicalLayer",
      "NumberOfSegments": "UnusedHierarchicalLayer"
    }
  }
}
// "650883F258B4B3FFFFFFFFFFFFFFFFCC4CB986447039E7BC4FB"
// "1AF703F258B4B3FFFFFFFFFFFFFFFFCC4CB986447039E7BC4FB"
...................................................
47 01 00 3F 01 20 0E 40 60 00 8B 06 00 66 1A 9C ...
47 00 12 14 19 4B 47 77 19 6B 0E 50 52 FA 00 4E ...
47 01 00 30 01 20 52 81 A5 E0 1B 00 5E 00 F8 30 ...
47 01 00 31 01 20 66 2F 66 CA 40 69 34 01 40 03 ...
47 01 00 32 01 20 3D B0 0A 09 80 30 0C 02 81 A1 ...
47 01 00 33 01 20 C1 04 9E 1A 1A 58 69 69 34 07 ... 
47 01 00 34 01 20 11 3F DC 34 04 24 D4 02 48 01 ... 
47 01 00 35 01 20 10 0E 80 76 03 12 60 14 02 A1 ... 
47 01 00 36 01 20 04 E0 05 04 D2 60 03 90 13 00 ... 
47 01 00 37 01 20 44 D0 C0 06 60 19 10 D1 92 18 ... 
Layer A 816.00 0.00E+00
47 05 FF 20 B7 10 81 93 AE 21 7E 93 FF FF FF FF ...
47 05 81 1F AB F0 8D 0E 30 F0 F2 5A 0D A4 58 97 ...
47 1F FF 10 FF FF FF FF FF FF FF FF FF FF FF FF ...
47 05 81 10 01 8D E1 3C 83 67 0C A7 84 A6 53 C3 ...
47 05 80 15 C1 72 A9 E8 8F 43 A4 41 27 F6 DA 1E ...
47 05 81 11 4D 70 46 5A 08 3F AD 70 4D C6 58 09 ...
47 05 83 10 C0 14 16 DC 9E 62 B8 C1 40 0C 44 E3 ...
47 1F FF 10 FF FF FF FF FF FF FF FF FF FF FF FF ...
47 05 81 12 D3 FD 70 AC 38 CF 95 F0 CA FF 1F 7C ...
47 05 81 33 B5 00 FF FF FF FF FF FF FF FF FF FF ...
...................................................

 TMCCのStartupControlSignalがStartupControlAvailableへ変化しており、起動信号(対応したテレビは自動的に電源がONになる)が送出されています(受信地点(北海道)が震源地から遠いからか、放送パラメータ自体に変化は無く、AC1も通常通りでした)。
 TSを見てみると、Layer B(フルセグ)、Layer A(ワンセグ)ともに4バイト目の上位2bitがクリアされており、スクランブルされていないことがわかります。また、通常時のフルセグ放送ではヘッダの4バイト目の上位4bitはDで、スクランブル有り、アダプテーションフィールド無しで放送されていますが、緊急放送の際は上位4bitが3に設定されており、スクランブル無し、アダプテーションフィールド有りへ変化しています。
 アダプテーションフィールドが存在する場合はTSヘッダの直後(5バイト目以降)に長さ(1バイト)とフラグ(1バイト)、ペイロード(可変長)が続きます。今回の場合はアダプテーションフィールドの長さが1バイト(つまりフラグのみ)で、フラグの内容は20hです。これはストリームの優先度が高く設定されている(優先的に処理すべき映像や音声を持ったパケットである)ことを示しています。

 また、このときのTSファイルをffprobeへ与えた際の結果を以下に示します。

Input #0, mpegts, from 'log.ts':
  Duration: 00:01:00.21, start: 48309.397533, bitrate: 17183 kb/s
  Program 12288
    Metadata:
      service_name    : ?NHK?Am9g?1�?00@n
      service_provider:
  Stream #0:0[0x100]: Video: mpeg2video (Main) ([2][0][0][0] / 0x0002), yuv420p(tv, bt709, top first), 1440x1080 [SAR 4:3 DAR 16:9], 29.97 fps, 29.97 tbr, 90k tbn
    Side data:
      cpb: bitrate max/min/avg: 20000000/0/0 buffer size: 9781248 vbv_delay: N/A
  Stream #0:1[0x110]: Audio: aac (LC) ([15][0][0][0] / 0x000F), 48000 Hz, stereo, fltp, 143 kb/s
  Stream #0:2[0x130]: Subtitle: arib_caption (Profile A) ([6][0][0][0] / 0x0006)
  Stream #0:3[0x138]: Data: bin_data ([6][0][0][0] / 0x0006)
  Stream #0:4[0x140]: Unknown: none ([13][0][0][0] / 0x000D)
  Stream #0:5[0x160]: Unknown: none ([13][0][0][0] / 0x000D)
  Stream #0:6[0x161]: Unknown: none ([13][0][0][0] / 0x000D)
  Stream #0:7[0x162]: Unknown: none ([13][0][0][0] / 0x000D)
  Stream #0:8[0x170]: Unknown: none ([13][0][0][0] / 0x000D)
  Stream #0:9[0x171]: Unknown: none ([13][0][0][0] / 0x000D)
  Stream #0:10[0x172]: Unknown: none ([13][0][0][0] / 0x000D)
  Program 12289
    Metadata:
      service_name    : ?NHK?Am9g?2�?00@n
      service_provider:
  Stream #0:0[0x100]: Video: mpeg2video (Main) ([2][0][0][0] / 0x0002), yuv420p(tv, bt709, top first), 1440x1080 [SAR 4:3 DAR 16:9], 29.97 fps, 29.97 tbr, 90k tbn
    Side data:
      cpb: bitrate max/min/avg: 20000000/0/0 buffer size: 9781248 vbv_delay: N/A
  Stream #0:1[0x110]: Audio: aac (LC) ([15][0][0][0] / 0x000F), 48000 Hz, stereo, fltp, 143 kb/s
  Stream #0:2[0x130]: Subtitle: arib_caption (Profile A) ([6][0][0][0] / 0x0006)
  Stream #0:3[0x138]: Data: bin_data ([6][0][0][0] / 0x0006)
  Stream #0:4[0x140]: Unknown: none ([13][0][0][0] / 0x000D)
  Stream #0:5[0x160]: Unknown: none ([13][0][0][0] / 0x000D)
  Stream #0:6[0x161]: Unknown: none ([13][0][0][0] / 0x000D)
  Stream #0:7[0x162]: Unknown: none ([13][0][0][0] / 0x000D)
  Stream #0:8[0x170]: Unknown: none ([13][0][0][0] / 0x000D)
  Stream #0:9[0x171]: Unknown: none ([13][0][0][0] / 0x000D)
  Stream #0:10[0x172]: Unknown: none ([13][0][0][0] / 0x000D)
  Program 12672
    Metadata:
      service_name    : ?NHK?7HBS?G�?00@n
      service_provider:
  Stream #0:26[0x580]: Unknown: none ([13][0][0][0] / 0x000D)
  Stream #0:27[0x581]: Video: h264 (Constrained Baseline) ([27][0][0][0] / 0x001B), yuv420p(progressive), 320x180, 14.99 fps, 14.99 tbr, 90k tbn
  Stream #0:12[0x583]: Audio: aac (HE-AAC) ([15][0][0][0] / 0x000F), 48000 Hz, stereo, fltp, 47 kb/s
  Stream #0:28[0x587]: Subtitle: arib_caption (Profile C) ([6][0][0][0] / 0x0006)
  Stream #0:29[0x589]: Unknown: none ([13][0][0][0] / 0x000D)
  Stream #0:30[0x58a]: Unknown: none ([13][0][0][0] / 0x000D)
  Stream #0:31[0x58b]: Unknown: none ([13][0][0][0] / 0x000D)
  Program 65520
  Stream #0:14[0x1c71]: Unknown: none ([13][0][0][0] / 0x000D)
  Stream #0:15[0x1c72]: Unknown: none ([13][0][0][0] / 0x000D)
  Stream #0:16[0x1c73]: Unknown: none ([13][0][0][0] / 0x000D)
  Stream #0:17[0x1c74]: Unknown: none ([13][0][0][0] / 0x000D)
  Stream #0:18[0x1c75]: Unknown: none ([13][0][0][0] / 0x000D)
  Stream #0:19[0x1c76]: Unknown: none ([13][0][0][0] / 0x000D)
  Stream #0:20[0x1c77]: Unknown: none ([13][0][0][0] / 0x000D)
  Stream #0:21[0x1c78]: Unknown: none ([13][0][0][0] / 0x000D)
  Stream #0:22[0x1c60]: Unknown: none ([13][0][0][0] / 0x000D)
  Stream #0:23[0x1c61]: Unknown: none ([13][0][0][0] / 0x000D)
  Stream #0:24[0x1c62]: Unknown: none ([13][0][0][0] / 0x000D)
  Stream #0:25[0x1c63]: Unknown: none ([13][0][0][0] / 0x000D)
  Stream #0:11[0x238]: Unknown: none
  Stream #0:13[0x12]: Data: epg

 平時にはスクランブル化されていて見えなかった、フルセグ放送のVideoやAudioなどのパラメータが確認できます。ffplayに与えればHD(1440x1080/Iを引き伸ばした1920x1080)で再生できます。

 平時の(スクランブルされている)13セグTSをffplayに与えると、ワンセグ画質で再生されます。
 スクランブルされていない13セグTSをffplayに与えるとHDで再生され、vキーを押すたびに12セグ画質とワンセグ画質を切り替えることができます(ffplayのバグか、映像が停止しますが、右クリックでシークさせて読み込ませれば画質が切り替わります)。
 スクランブルされていない13セグの内、ワンセグを除いた12セグから復調したTSをffprobeで見るとワンセグのProgramの中身がなくなった状態で、ffplayで再生するとvキーを押しても変化しません。13セグの受信でも、12セグの受信でも、1セグの受信でも、トランスポートストリームとして破綻がないようなデータが放送されています。

最低限のパケットの解析

 当初の目的であるデジタル復調の勉強からは逸れますが、簡単にPATやPMT等を解析してみたいと思います。簡単に、と言うのは簡単ですが、分割されて送られてくるパケットを結合してデコードするのは結構大変です。

 トランスポートストリームの解析にはARIB STD-B10を参照します。また、Wikipediaの記事でも詳しく説明されているので、これらをベースに解析していきます。

標準規格の入手について(STD-B10)|一般社団法人 電波産業会
MPEG-2システム - Wikipedia
MPEG transport stream - Wikipedia
Program-specific information - Wikipedia

PAT, PMT, NIT, TOTやDescriptorのデコード

using StreamWriter sw = new("log.txt");

using FileStream fs = new("log.ts", FileMode.Open, FileAccess.Read);

var packetBuffer = new byte[188];

var continuityCounters = new int[8192];

ReadOnlySpan<int> fixedPSIPIDs = [0, 0x14];
List<int> PATPIDs = [];

var PSIsections = new (int PSIpacketLength, int receivedLength, byte[] buffer)?[8192];

Descriptors descriptor = new(sw);

for (int counter = 0; counter < 100000; counter++)
{
    if (fs.Read(packetBuffer) != packetBuffer.Length)
    {
        break;
    }

    var packet = packetBuffer.AsSpan();

    if (packet[0] != 0x47)
    {
        throw new FormatException("SyncByte");
    }

    if ((packet[1] & 0x80) != 0)
    { // 破損パケット
        continue;
    }

    var PUSI = (packet[1] & 0x40) != 0;
    var PID = BinaryPrimitives.ReadUInt16BigEndian(packet[1..]) & 0x1FFF;
    var continuityCounter = packet[3] & 0xF;

    packet = packet[4..];

    {
        var prev = continuityCounters[PID];
        continuityCounters[PID] = continuityCounter;

        if ((prev + 1 & 0xF) != continuityCounter)
        { // パケット不連続
            if (!PUSI &&
                PSIsections[PID] is (int, int, byte[]) tmp)
            { // 受信途中のPSIセクションが有る場合は捨てる
                PSIsections[PID] = (0, 0, tmp.buffer);
            }
            continue;
        }
    }

    if (fixedPSIPIDs.Contains(PID) ||
        PATPIDs.Contains(PID))
    {
        var (PSIlen, rxLen, buff) = PSIsections[PID] ??= new(0, 0, new byte[1024]);

        if (PUSI)
        { // 新しいセクション
            if (packet[0] != 0)
            { // 新しいセクションの前に前回のセクションが続いている
                throw new NotImplementedException();
            }

            packet = packet[1..];

            rxLen = 0;
            PSIlen = 3 + BinaryPrimitives.ReadUInt16BigEndian(packet[1..]) & 0xFFF;
        }

        {
            var remaining = int.Min(PSIlen - rxLen, packet.Length);
            packet[..remaining].CopyTo(buff.AsSpan(rxLen));
            packet = packet[remaining..];
            rxLen += remaining;
        }

        if (PSIlen < rxLen)
        {
            throw new Exception("たぶんどっかバグってる");
        }

        if (0 < PSIlen &&
            rxLen == PSIlen)
        {
            PSIlen = 0;

            var section = buff.AsSpan(0, rxLen);
            if (0 != CRC32MPEG2.Calc(section))
            { // CRCエラー
              // 読み飛ばす
            }
            else
            {
                var tableId = section[0];
                section = section[3..^4];

                ParsePSIPacket(PID, tableId, section);
            }

            if (0 < packet.Length &&
                packet[0] != 0xFF)
            { // 次のセクション
                throw new NotImplementedException();
            }
        }

        PSIsections[PID] = (PSIlen, rxLen, buff);
    }
}

void ParsePSIPacket(int PID, int tableId, ReadOnlySpan<byte> section)
{
    sw.WriteLine();

    switch (tableId)
    {
        case 0:
            {
                section = section[5..];

                sw.Write("PAT");
                PATPIDs.Clear();
                while (0 < section.Length)
                {
                    var programNumber = BinaryPrimitives.ReadUInt16BigEndian(section);
                    var programMapPID = BinaryPrimitives.ReadUInt16BigEndian(section[2..]) & 0x1FFF;

                    PATPIDs.Add(programMapPID);

                    sw.Write($"[{programMapPID:X},{programNumber}]");

                    section = section[4..];
                }
                sw.WriteLine();
            }
            break;

        case 0x2:
            {
                sw.WriteLine($"PMT {PID:X}");

                var programInfoLength = BinaryPrimitives.ReadUInt16BigEndian(section[7..]) & 0xFFF;

                var elementaryStream = section[(9 + programInfoLength)..];

                {
                    var desc = section.Slice(9, programInfoLength);
                    descriptor.Descriptor(1, desc);
                }

                while (0 < elementaryStream.Length)
                {
                    var streamType = elementaryStream[0];
                    var elementaryPID = BinaryPrimitives.ReadUInt16BigEndian(elementaryStream[1..]) & 0x1FFF;
                    var esInfoLength = BinaryPrimitives.ReadUInt16BigEndian(elementaryStream[3..]) & 0xFFF;

                    var streamTypeDescription =
                        streamType switch
                        {
                            0x2 => "H262",
                            0x6 => "PES",
                            0xF => "ADTS",
                            0x1B => "H264",
                            _ => "unknown"
                        };

                    sw.WriteLine($"{streamType,2:X} {elementaryPID,4:X} {streamTypeDescription}");

                    var desc = elementaryStream.Slice(5, esInfoLength);
                    descriptor.Descriptor(2, desc);

                    elementaryStream = elementaryStream[(5 + esInfoLength)..];
                }
            }
            break;

        case 0x40:
            {
                sw.WriteLine($"NIT");

                var networkId = BinaryPrimitives.ReadUInt16BigEndian(section);

                sw.WriteLine(networkId);

                var networkDescriptorsLength = BinaryPrimitives.ReadUInt16BigEndian(section[5..]) & 0xFFF;
                section = section[7..];
                var networkDescriptors = section[..networkDescriptorsLength];
                section = section[networkDescriptorsLength..];

                descriptor.Descriptor(1, networkDescriptors);

                var transportStreamLoopLength = BinaryPrimitives.ReadUInt16BigEndian(section) & 0xFFF;
                section = section[2..];
                var transportStreamLoop = section[..transportStreamLoopLength];
                section = section[transportStreamLoopLength..];

                while (0 < transportStreamLoop.Length)
                {
                    var transportStreamId = BinaryPrimitives.ReadUInt16BigEndian(transportStreamLoop);
                    var originalnetworkId = BinaryPrimitives.ReadUInt16BigEndian(transportStreamLoop[2..]);
                    var transportDescriptorsLength = BinaryPrimitives.ReadUInt16BigEndian(transportStreamLoop[4..]) & 0xFFF;
                    transportStreamLoop = transportStreamLoop[6..];
                    var transportDescriptors = transportStreamLoop[..transportDescriptorsLength];
                    transportStreamLoop = transportStreamLoop[transportDescriptorsLength..];

                    sw.WriteLine($"{transportStreamId} {originalnetworkId}");
                    descriptor.Descriptor(2, transportDescriptors);
                }
            }
            break;

        case 0x73:
            {
                var JST =
                    new DateTime(1858, 11, 17)
                    .AddDays(BinaryPrimitives.ReadUInt16BigEndian(section))
                    + TimeSpan.Parse($"{section[2]:X2}:{section[3]:X2}:{section[4]:X2}");

                sw.WriteLine($"TOT {tableId:X} {JST:yyyy-MM-ddTHH:mm:ss}");

                var desc = section.Slice(7, BinaryPrimitives.ReadUInt16BigEndian(section[5..]) & 0xFFF);
                descriptor.Descriptor(1, desc);
            }
            break;

        default:
            sw.WriteLine($"unknown section (PID: {PID:X}, table_id: {tableId:X})");
            break;
    }
}

class Descriptors(TextWriter dst)
{
    private static readonly IReadOnlyDictionary<int, string> _areaDict = new Dictionary<int, string>(){
        { 0b001101001101, "Local common code" },
        { 0b010110100101, "Wide area of Kanto" },
        { 0b011100101010, "Wide area of Chukyo" },
        { 0b100011010101, "Wide area of Kinki" },
        { 0b011010011001, "Tottori, Shimane area" },
        { 0b010101010011, "Okayama, Kagawa area" },
        { 0b000101101011, "Hokkaido" },
        { 0b010001100111, "Aomori" },
        { 0b010111010100, "Iwate" },
        { 0b011101011000, "Miyagi" },
        { 0b101011000110, "Akita" },
        { 0b111001001100, "Yamagata" },
        { 0b000110101110, "Fukushima" },
        { 0b110001101001, "Ibaraki" },
        { 0b111000111000, "Tochigi" },
        { 0b100110001011, "Gunma" },
        { 0b011001001011, "Saitama" },
        { 0b000111000111, "Chiba" },
        { 0b101010101100, "Tokyo" },
        { 0b010101101100, "Kanagawa" },
        { 0b010011001110, "Niigata" },
        { 0b010100111001, "Toyama" },
        { 0b011010100110, "Ishikawa" },
        { 0b100100101101, "Fukui" },
        { 0b110101001010, "Yamanashi" },
        { 0b100111010010, "Nagano" },
        { 0b101001100101, "Gifu" },
        { 0b101001011010, "Shizuoka" },
        { 0b100101100110, "Aichi" },
        { 0b001011011100, "Mie" },
        { 0b110011100100, "Shiga" },
        { 0b010110011010, "Kyoto" },
        { 0b110010110010, "Osaka" },
        { 0b011001110100, "Hyogo" },
        { 0b101010010011, "Nara" },
        { 0b001110010110, "Wakayama" },
        { 0b110100100011, "Tottori" },
        { 0b001100011011, "Shimane" },
        { 0b001010110101, "Okayama" },
        { 0b101100110001, "Hiroshima" },
        { 0b101110011000, "Yamaguchi" },
        { 0b111001100010, "Tokushima" },
        { 0b100110110100, "Kagawa" },
        { 0b000110011101, "Ehime" },
        { 0b001011100011, "Kochi" },
        { 0b011000101101, "Fukuoka" },
        { 0b100101011001, "Saga" },
        { 0b101000101011, "Nagasaki" },
        { 0b100010100111, "Kumamoto" },
        { 0b110010001101, "Oita" },
        { 0b110100011100, "Miyazaki" },
        { 0b110101000101, "Kagoshima" },
        { 0b001101110010, "Okinawa" },
    }.AsReadOnly();

    private readonly TextWriter _dst = dst;

    public void Descriptor(int tabIndex, ReadOnlySpan<byte> descriptor)
    {
        var dst = _dst;
        var tab = "".PadLeft(tabIndex * 2);

        while (0 < descriptor.Length)
        {
            var slice = descriptor[..(2 + descriptor[1])];
            descriptor = descriptor[slice.Length..];

            const int titleWidth = 25;

            DescriptorBase? desc = null;
            var descriptorType = "unknown description";

            switch (slice[0])
            {
                case 0xC8:
                    desc = VideoDecodeControlDescriptor.Parse(slice);
                    break;

                case 0xFA:
                    desc = TerrestrialDeliverySystemDescriptro.Parse(slice);
                    break;

                case 0xFC:
                    desc = EmergencyInformationDescriptor.Parse(slice);
                    break;

                default:
                    descriptorType = slice[0] switch
                    {
                        0x09 => "conditional access",
                        0x40 => "network name",
                        0x41 => "service list",
                        0x52 => "stream identifier",
                        0xC1 => "digital copy control",
                        0xCD => "TS information",
                        0xF6 => "access control",
                        0xFA => "terrestrial deliv.sys.",
                        0xFB => "partial reception",
                        0xFD => "data component",
                        0xFE => "system management",
                        _ => descriptorType
                    };
                    break;
            }

            if (desc is not null)
            {
                dst.WriteLine($"{tab}* {desc.GetType().Name}");
                dst.WriteLine($"{tab}    {desc}");
            }
            else
            {
                dst.WriteLine($"{tab}* {$"{descriptorType}'",titleWidth} : {ConvertToHex(slice, false, slice.Length)}");
            }
        }

        ; static string ConvertToHex(ReadOnlySpan<byte> data, bool address = true, int width = 32, int offset = 0)
         => string.Join("\n",
                data
                .ToArray()
                .Select((a, i) => (i, a))
                .GroupBy(a => (a.i + offset) / width, a => a.a)
                .Select((a, i) => string.Join("",
                    address ? $"{a.Key * width:X8} " : "",
                    $"{"".PadLeft(i == 0 ? offset % width * 3 : 0)}{string.Join(' ', a.Select(a => $"{a:X2}"))}".PadRight(width * 3),
                    " ",
                    $"{"".PadLeft(i == 0 ? offset % width * 1 : 0)}{string.Join("", a.Select(a => ' ' <= a && a <= '~' ? (char)a : '.'))}")));
    }

    record DescriptorBase;

    record VideoDecodeControlDescriptor(
        bool StillPicture,
        bool SequenceEndCode,
        byte VideoEncodeFormat,
        byte TransferCharacteristics)
        : DescriptorBase
    {
        public static VideoDecodeControlDescriptor Parse(ReadOnlySpan<byte> data)
        {
            if (data[0] != 0xC8 ||
                data[1] + 2 != data.Length)
            { throw new FormatException(); }

            var stillPicture = (data[2] & 0x80) != 0;
            var sequenceEndCode = (data[2] & 0x40) != 0;

            var videoEncodeFormat = data[2];
            videoEncodeFormat &= 0x3C;
            videoEncodeFormat >>= 2;

            var transferCharacteristics = data[2];
            transferCharacteristics &= 0x3;

            return new(stillPicture, sequenceEndCode, videoEncodeFormat, transferCharacteristics);
        }

        public override string ToString()
         => string.Join(", ",
                StillPicture ? "StillPicture" : "MovingPicture",
                SequenceEndCode ? "has a sequence end code" : "not have a sequence end code",
                VideoEncodeFormat switch
                {
                    0b0000 => "1080/P",
                    0b0001 => "1080/I",
                    0b0010 => "720/P",
                    0b0011 => "480/P",
                    0b0100 => "480/I",
                    0b0101 => "240/P",
                    0b0110 => "120/P",
                    0b0111 => "2160/60/P",
                    0b1000 => "180/P",
                    0b1001 => "2160/120/P",
                    0b1010 => "4320/60/P",
                    0b1011 => "4320/120/P",
                    _ => "For extension of video encode format"
                },
                TransferCharacteristics switch
                {
                    0b00 => "1, 11 or 14",
                    0b01 => "16",
                    0b10 => "18",
                    _ => "Undefined",
                });
    }

    record TerrestrialDeliverySystemDescriptro(
        ushort AreaCode, byte GuardInterval, byte TransmissionMode, ushort[] Frequencies)
        : DescriptorBase
    {
        public static TerrestrialDeliverySystemDescriptro Parse(ReadOnlySpan<byte> data)
        {
            if (data[0] != 0xFA ||
                data[1] + 2 != data.Length)
            { throw new FormatException(); }

            ushort areaCode = BinaryPrimitives.ReadUInt16BigEndian(data[2..]);
            areaCode &= 0xFFF0;
            areaCode >>= 4;

            var guardInterval = data[3];
            guardInterval &= 0xC;
            guardInterval >>= 2;

            var transmissionMode = data[3];
            transmissionMode &= 3;

            data = data[4..];
            List<ushort> freqs = new(data.Length / 2);
            while (0 < data.Length)
            {
                freqs.Add(BinaryPrimitives.ReadUInt16BigEndian(data));
                data = data[2..];
            }

            return new(areaCode, guardInterval, transmissionMode, freqs.ToArray());
        }

        public override string ToString()
         => string.Join(", ",
                _areaDict.TryGetValue(AreaCode, out var areaName) ? areaName : $"{AreaCode:b12}",
                GuardInterval switch
                {
                    0 => "1/32",
                    1 => "1/16",
                    2 => "1/8",
                    3 => "1/4",
                    _ => throw new InvalidOperationException()
                },
                TransmissionMode switch
                {
                    0 => "Mode 1",
                    1 => "Mode 2",
                    2 => "Mode 3",
                    3 => "Undefined",
                    _ => throw new InvalidOperationException()
                },
                $"[ {string.Join(", ", Frequencies.Select(a => $"{a / 7f:0.000}"))} ]");
    }

    record EmergencyInformationDescriptor(
        (ushort serviceId, bool startEndFlag, bool serviceLevel, ushort[] areaCodes)[] List)
        : DescriptorBase
    {
        public static EmergencyInformationDescriptor Parse(ReadOnlySpan<byte> data)
        {
            if (data[0] != 0xFC ||
                data[1] + 2 != data.Length)
            { throw new FormatException(); }
            data = data[2..];

            List<(ushort, bool, bool, ushort[])> list1 = [];
            while (0 < data.Length)
            {
                var serviceId = BinaryPrimitives.ReadUInt16BigEndian(data);
                var startEndFlag = (data[2] & 0x80) != 0;
                var signalLevel = (data[2] & 0x40) != 0;
                var areaCodeLength = data[3];
                data = data[4..];
                var areas = data[..areaCodeLength];
                data = data[areaCodeLength..];

                List<ushort> list2 = new(areas.Length / 2);
                while (0 < areas.Length)
                {
                    var areaCode = BinaryPrimitives.ReadUInt16BigEndian(areas);
                    areaCode >>= 4;

                    list2.Add(areaCode);

                    areas = areas[2..];
                }

                list1.Add((serviceId, startEndFlag, signalLevel, list2.ToArray()));
            }

            return new(list1.ToArray());
        }

        public override string ToString()
         => $"{{{string.Join(", ", List.Select(a =>
                string.Join(", ",
                    a.serviceId,
                    a.startEndFlag,
                    a.serviceLevel ? "Type 2" : "Type 1",
                    $"[ {string.Join(", ",
                            a.areaCodes
                            .Select(a =>
                                _areaDict.TryGetValue(a, out var area)
                                    ? area
                                    : $"{a:b12}"))} ]")))}}}";
    }
}

class CRC32MPEG2
{
    private static readonly uint[] _table = {
        0x00000000, 0x04C11DB7, 0x09823B6E, 0x0D4326D9, 0x130476DC, 0x17C56B6B, 0x1A864DB2, 0x1E475005,
        0x2608EDB8, 0x22C9F00F, 0x2F8AD6D6, 0x2B4BCB61, 0x350C9B64, 0x31CD86D3, 0x3C8EA00A, 0x384FBDBD,
        0x4C11DB70, 0x48D0C6C7, 0x4593E01E, 0x4152FDA9, 0x5F15ADAC, 0x5BD4B01B, 0x569796C2, 0x52568B75,
        0x6A1936C8, 0x6ED82B7F, 0x639B0DA6, 0x675A1011, 0x791D4014, 0x7DDC5DA3, 0x709F7B7A, 0x745E66CD,
        0x9823B6E0, 0x9CE2AB57, 0x91A18D8E, 0x95609039, 0x8B27C03C, 0x8FE6DD8B, 0x82A5FB52, 0x8664E6E5,
        0xBE2B5B58, 0xBAEA46EF, 0xB7A96036, 0xB3687D81, 0xAD2F2D84, 0xA9EE3033, 0xA4AD16EA, 0xA06C0B5D,
        0xD4326D90, 0xD0F37027, 0xDDB056FE, 0xD9714B49, 0xC7361B4C, 0xC3F706FB, 0xCEB42022, 0xCA753D95,
        0xF23A8028, 0xF6FB9D9F, 0xFBB8BB46, 0xFF79A6F1, 0xE13EF6F4, 0xE5FFEB43, 0xE8BCCD9A, 0xEC7DD02D,
        0x34867077, 0x30476DC0, 0x3D044B19, 0x39C556AE, 0x278206AB, 0x23431B1C, 0x2E003DC5, 0x2AC12072,
        0x128E9DCF, 0x164F8078, 0x1B0CA6A1, 0x1FCDBB16, 0x018AEB13, 0x054BF6A4, 0x0808D07D, 0x0CC9CDCA,
        0x7897AB07, 0x7C56B6B0, 0x71159069, 0x75D48DDE, 0x6B93DDDB, 0x6F52C06C, 0x6211E6B5, 0x66D0FB02,
        0x5E9F46BF, 0x5A5E5B08, 0x571D7DD1, 0x53DC6066, 0x4D9B3063, 0x495A2DD4, 0x44190B0D, 0x40D816BA,
        0xACA5C697, 0xA864DB20, 0xA527FDF9, 0xA1E6E04E, 0xBFA1B04B, 0xBB60ADFC, 0xB6238B25, 0xB2E29692,
        0x8AAD2B2F, 0x8E6C3698, 0x832F1041, 0x87EE0DF6, 0x99A95DF3, 0x9D684044, 0x902B669D, 0x94EA7B2A,
        0xE0B41DE7, 0xE4750050, 0xE9362689, 0xEDF73B3E, 0xF3B06B3B, 0xF771768C, 0xFA325055, 0xFEF34DE2,
        0xC6BCF05F, 0xC27DEDE8, 0xCF3ECB31, 0xCBFFD686, 0xD5B88683, 0xD1799B34, 0xDC3ABDED, 0xD8FBA05A,
        0x690CE0EE, 0x6DCDFD59, 0x608EDB80, 0x644FC637, 0x7A089632, 0x7EC98B85, 0x738AAD5C, 0x774BB0EB,
        0x4F040D56, 0x4BC510E1, 0x46863638, 0x42472B8F, 0x5C007B8A, 0x58C1663D, 0x558240E4, 0x51435D53,
        0x251D3B9E, 0x21DC2629, 0x2C9F00F0, 0x285E1D47, 0x36194D42, 0x32D850F5, 0x3F9B762C, 0x3B5A6B9B,
        0x0315D626, 0x07D4CB91, 0x0A97ED48, 0x0E56F0FF, 0x1011A0FA, 0x14D0BD4D, 0x19939B94, 0x1D528623,
        0xF12F560E, 0xF5EE4BB9, 0xF8AD6D60, 0xFC6C70D7, 0xE22B20D2, 0xE6EA3D65, 0xEBA91BBC, 0xEF68060B,
        0xD727BBB6, 0xD3E6A601, 0xDEA580D8, 0xDA649D6F, 0xC423CD6A, 0xC0E2D0DD, 0xCDA1F604, 0xC960EBB3,
        0xBD3E8D7E, 0xB9FF90C9, 0xB4BCB610, 0xB07DABA7, 0xAE3AFBA2, 0xAAFBE615, 0xA7B8C0CC, 0xA379DD7B,
        0x9B3660C6, 0x9FF77D71, 0x92B45BA8, 0x9675461F, 0x8832161A, 0x8CF30BAD, 0x81B02D74, 0x857130C3,
        0x5D8A9099, 0x594B8D2E, 0x5408ABF7, 0x50C9B640, 0x4E8EE645, 0x4A4FFBF2, 0x470CDD2B, 0x43CDC09C,
        0x7B827D21, 0x7F436096, 0x7200464F, 0x76C15BF8, 0x68860BFD, 0x6C47164A, 0x61043093, 0x65C52D24,
        0x119B4BE9, 0x155A565E, 0x18197087, 0x1CD86D30, 0x029F3D35, 0x065E2082, 0x0B1D065B, 0x0FDC1BEC,
        0x3793A651, 0x3352BBE6, 0x3E119D3F, 0x3AD08088, 0x2497D08D, 0x2056CD3A, 0x2D15EBE3, 0x29D4F654,
        0xC5A92679, 0xC1683BCE, 0xCC2B1D17, 0xC8EA00A0, 0xD6AD50A5, 0xD26C4D12, 0xDF2F6BCB, 0xDBEE767C,
        0xE3A1CBC1, 0xE760D676, 0xEA23F0AF, 0xEEE2ED18, 0xF0A5BD1D, 0xF464A0AA, 0xF9278673, 0xFDE69BC4,
        0x89B8FD09, 0x8D79E0BE, 0x803AC667, 0x84FBDBD0, 0x9ABC8BD5, 0x9E7D9662, 0x933EB0BB, 0x97FFAD0C,
        0xAFB010B1, 0xAB710D06, 0xA6322BDF, 0xA2F33668, 0xBCB4666D, 0xB8757BDA, 0xB5365D03, 0xB1F740B4,
    };

    public static uint Calc(ReadOnlySpan<byte> data)
    {
        var LUT = _table;

        uint c = 0xFFFFFFFF;
        foreach (var a in data)
        {
            c = (c << 8) ^ LUT[((c >> 24) ^ a) & 0xFF];
        }

        return c;
    }
}

 結果の一部を以下に示します。

PAT[10,0][1F0,12288][3F0,12289][1FC8,12672][1CF0,65520]

PMT 1F0
  *       conditional access' : 09 04 00 05 E9 01  ......
  *           access control' : F6 04 00 0E E9 02  ......
  *     digital copy control' : C1 01 84  ...
  * EmergencyInformationDescriptor
      {12288, True, Type 2, [ Local common code ]}
 2  100 H262
    *        stream identifier' : 52 01 00  R..
    * VideoDecodeControlDescriptor
        MovingPicture, has a sequence end code, 1080/I, Undefined
 F  110 ADTS
    *        stream identifier' : 52 01 10  R..
 6  130 PES
    *       conditional access' : 09 04 00 05 FF FF  ......
    *           access control' : F6 04 00 0E FF FF  ......
    *        stream identifier' : 52 01 30  R.0
    *           data component' : FD 03 00 08 3D  ....=
 6  138 PES
    *       conditional access' : 09 04 00 05 FF FF  ......
    *           access control' : F6 04 00 0E FF FF  ......
    *        stream identifier' : 52 01 38  R.8
    *           data component' : FD 03 00 08 3C  ....<
 D  140 unknown
    *        stream identifier' : 52 01 40  R.@
    *           data component' : FD 0A 00 0C 33 3F 00 03 00 00 FF BF  ....3?......
 D  160 unknown
    *        stream identifier' : 52 01 60  R.`
    *           data component' : FD 05 00 0C 1F FF BF  .......
 D  161 unknown
    *        stream identifier' : 52 01 61  R.a
    *           data component' : FD 05 00 0C 1F FF BF  .......
 D  162 unknown
    *        stream identifier' : 52 01 62  R.b
    *           data component' : FD 05 00 0C 1F FF BF  .......
 D  170 unknown
    *        stream identifier' : 52 01 70  R.p
    *           data component' : FD 05 00 0C 1F FF BF  .......
 D  171 unknown
    *        stream identifier' : 52 01 71  R.q
    *           data component' : FD 05 00 0C 1F FF BF  .......
 D  172 unknown
    *        stream identifier' : 52 01 72  R.r
    *           data component' : FD 05 00 0C 1F FF BF  .......

PMT 3F0
  *       conditional access' : 09 04 00 05 E9 01  ......
  *           access control' : F6 04 00 0E E9 02  ......
  *     digital copy control' : C1 01 84  ...
  * EmergencyInformationDescriptor
      {12289, True, Type 2, [ Local common code ]}
 2  100 H262
    *        stream identifier' : 52 01 00  R..
    * VideoDecodeControlDescriptor
        MovingPicture, has a sequence end code, 1080/I, Undefined
 F  110 ADTS
    *        stream identifier' : 52 01 10  R..
 6  130 PES
    *       conditional access' : 09 04 00 05 FF FF  ......
    *           access control' : F6 04 00 0E FF FF  ......
    *        stream identifier' : 52 01 30  R.0
    *           data component' : FD 03 00 08 3D  ....=
 6  138 PES
    *       conditional access' : 09 04 00 05 FF FF  ......
    *           access control' : F6 04 00 0E FF FF  ......
    *        stream identifier' : 52 01 38  R.8
    *           data component' : FD 03 00 08 3C  ....<
 D  140 unknown
    *        stream identifier' : 52 01 40  R.@
    *           data component' : FD 0A 00 0C 33 3F 00 03 00 00 FF BF  ....3?......
 D  160 unknown
    *        stream identifier' : 52 01 60  R.`
    *           data component' : FD 05 00 0C 1F FF BF  .......
 D  161 unknown
    *        stream identifier' : 52 01 61  R.a
    *           data component' : FD 05 00 0C 1F FF BF  .......
 D  162 unknown
    *        stream identifier' : 52 01 62  R.b
    *           data component' : FD 05 00 0C 1F FF BF  .......
 D  170 unknown
    *        stream identifier' : 52 01 70  R.p
    *           data component' : FD 05 00 0C 1F FF BF  .......
 D  171 unknown
    *        stream identifier' : 52 01 71  R.q
    *           data component' : FD 05 00 0C 1F FF BF  .......
 D  172 unknown
    *        stream identifier' : 52 01 72  R.r
    *           data component' : FD 05 00 0C 1F FF BF  .......

TOT 73 2024-01-01T16:31:35

NIT
32560
  *             network name' : 40 06 30 30 40 6E 23 30  @.00@n#0
  *        system management' : FE 02 03 01  ....
32560 32560
    *             service list' : 41 0C 30 00 01 30 01 01 31 80 C0 FF F0 A4  A.0..0..1.....
    * TerrestrialDeliverySystemDescriptro
        Hokkaido, 1/8, Mode 3, [ 485.143, 503.143, 539.143, 557.143, 563.143, 581.143, 623.143, 635.143, 659.143, 665.143, 677.143, 683.143 ]
    *        partial reception' : FB 02 31 80  ..1.
    *           TS information' : CD 1E 03 42 23 4E 23 48 23 4B 41 6D 39 67 21 26 30 30 40 6E 0F 03 30 00 30 01 FF F0 AF 01 31 80  ...B#N#H#KAm9g!&00@n..0.0.....1.

 パーサーも長ければ結果も長いですね。ただ、基本的にはffprobeで見た内容と同様です。ffprobeの場合は主に映像関係のパラメータを深くまで掘り込んで解析しているのと、こちらはパケットの浅い層を重点的に出力している点で違いがありますが。ワンセグはPMTの出力頻度が低く、ここには含まれていません。
 緊急放送を行う場合(TMCCの起動制御が有効な場合)はPMTにEmergencyInformationDescriptorが追加されます。今回はstart_end_flagがTrueで種別がType 2、地域が全国の情報が出されています。タイプ1は大規模地震対策特別措置法または災害対策基本法によって放送され、タイプ2は気象業務法(特に潮位変化など、沿岸部や海に近い河川付近の受信機を対象にした場合)によって放送されます。当時受信地点である北海道では日本海側の沿岸部に津波注意報が発令されていたため、Type 2として警報が出されていました。また、日本海側の広い範囲に津波注意報以上が出されていたため、エリアコードは全国が指定されていました。
 TOTにはJST(日本標準時)が入っていて、これによると受信時刻は2024年1月1日16時31分35秒頃ということがわかります。
 NITにはそのエリアで放送に使用している周波数のリストが含まれています。今回は563.14MHzを受信しましたが、実際、リストにはその値が含まれています。このリストは主にワンセグや車載テレビなど移動受信で使用する情報で、サービスエリアの外に出る際に同じ局の別の周波数を探す目的で使用されます。

緊急警報放送試験放送

 おおむね毎月1日の11時59分にNHKでは緊急警報放送の試験放送を行っています。その際のPMTは以下のようになります。

PMT 1FC8
  *     digital copy control' : C1 01 88  ...
  * EmergencyInformationDescriptor
      {12672, False, Type 1, [ Hokkaido ]}
1B  581 H264
    *        stream identifier' : 52 01 81  R..
 F  583 ADTS
    *        stream identifier' : 52 01 83  R..
 D  580 unknown
    *        stream identifier' : 52 01 80  R..
    *           data component' : FD 0A 00 0D 3F 2F 00 0C 00 00 FF BF  ....?/......
 D  58B unknown
    *        stream identifier' : 52 01 8B  R..
    *           data component' : FD 05 00 0D 1F FF BF  .......
 D  589 unknown
    *        stream identifier' : 52 01 89  R..
 D  58A unknown
    *        stream identifier' : 52 01 8A  R..

TOT 73 2024-02-01T11:59:10

// フルセグのPMT等は省略

 また、このときのTMCCは以下のようになります。

650883F258B4B3FFFFFFFFFFFFFFFFCC4CB986447039E7BC4FB
1AF703F258B4B3FFFFFFFFFFFFFFFFCC4CB986447039E7BC4FB

 StartupControlSignalがStartupControlAvailableに変化し(AC1は通常通り)、PMTにemergency_information_descriptorが追加されます。この際、start_end_flagはfalse、signal_levelはType1、area_codeは都道府県単位が指定されています。
 TSファイルの中身までは確認していませんが、ffplayではワンセグ画質でしか見れなかったので、フルセグ放送は通常通りスクラブル化して放送しているようです。

最後に

 地上デジタル放送は放送開始からすでに20年を超え、規格が検討されていた頃からは四半世紀以上が経過しています。当時の電子技術(ASIC)で復調できるように作られた方式は、現代のPCを使えばソフトウェア実装でも(最適化無しではリアルタイムに及ばないとはいえ)多少の待ち時間で復調できるようになりました。
 Wi-FiやBluetoothを受信するには2.5GHzあたりまで受信でき、かつ帯域幅が数十MHzとか数百MHzというような受信機が必要であり、このようなRFシグナルを処理できるSDR受信機は非常に高価です。一方、UHF帯を使い、BST-OFDMを使用する地デジ放送は安価なドングルでも24時間365日ほとんど好きなときにサンプリングでき、日本全国の幅広い地域で受信することができます。あらゆる帯域の電波資源は尽く使用されていますが、デジタル復調の学習を目的とした場合に、地デジ放送ほど広い範囲で使用でき、かつ変調方式が公開され、簡単すぎず難しすぎもしない方式は他に類を見ないと思います。
 とはいえ、地デジ(ISDB-T)放送も永遠に続くわけではありません。すでに高度化方式(次世代地デジ)の基本仕様が確定しており、数年以内とはいわずとも、近い将来にはISDB-Tの放送は終了するはずです。高度化方式ではOFDMマッピングが複雑化したり、畳み込み符号とリードソロモン符号の代わりにLDPCとBCH符号が使われたりするなど、高度化というだけあって変復調も複雑になっています。デジタル変調信号の復調を試してみたいという人は、ぜひ今のうちに、ISDB-Tの放送が行われている間に試してみてください。

 地デジ放送(ISDB-T、ワンセグおよびフルセグ)はAM放送やFM放送と比べれば段違いに難しい変調方式ですが、とはいえある程度自分でコードを書いて復調部を作ることは不可能ではないということは示せたと思います。今回はFFTやリードソロモン、映像デコーダ周りは既存のライブラリを使用しましたが、このあたりもググれば色々情報が出てくると思います。
 他の遊び方として、フルセグ放送はデータ量が多くスクランブル化されているため映像化が難しいですが、ワンセグ放送はデータ量が少なく(なんたって20年前の携帯電話で信号処理する前提ですから)かつスクランブル化もされていませんから、FPGA等を使えば小さなハードウェアでワンセグチューナーを自作することもできるはずです。興味がある人はぜひ試してみてください。

 以上、非常に端折ったISDB-Tの受信方法を説明してきました。もし「テレビを自分で受信してみたい」と思っている人の参考になったのであれば幸いです。

 最後になりましたが、くれぐれも悪用はしないようにお願いします。あくまでもデジタル復調の勉強が目的です。

55
23
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
55
23

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?