入門 · レッスン 10 / 10

IQファイルの記録と読み込み

生のIQサンプル形式(cf32からcu8まで)、SigMFメタデータ、numpyによる合成キャプチャの書き込みと読み出し、スペクトログラムでのバースト検出、SoapySDRによる実際のSDRからの記録

入門45 分更新 2026年11月15日無料期間 2027年1月31日

このレッスンで学ぶこと

  • 一般的な生IQ形式(インターリーブされたfloat32、int16、int8、オフセット付きuint8)を挙げ、複素1サンプルあたりのバイト数を示し、それぞれをnumpyでcomplex64に変換する
  • 必須のglobal・captures・annotationsオブジェクトを持つSigMF記録を書き込み、メタデータだけを使って読み戻す
  • 16 ビットおよび8 ビットでの保存がキャプチャに加える量子化雑音を測定し、スペクトラム上で確認する
  • スペクトログラムでバーストの位置を特定し、その時間と周波数の範囲をSigMFアノテーションとして保存する
  • SoapySDRでSDRからサンプルを記録し、SigMF記録として保存する

前提レッスン: 初めてのGNU Radioフローグラフ

ソフトウェア無線(SDR)はどれも、いずれはサンプルをディスクに書き込みます。現場でキャプチャを記録し、机で解析し、同僚と共有し、あるいはデバッグ中の受信機に再生して入力します。生のサンプルのファイルは、3つの事実がなければ役に立ちません。各サンプルの格納方法、サンプルレート、中心周波数です。このレッスンは、レッスン9のフローグラフを動かし、無線機が出力したものを保存したい読者を対象としています。よく出会う生の形式、欠けている事実を記録するメタデータ規格SigMF、numpyで書き込んで読み戻す合成キャプチャ、そしてSoapySDRによる実際の記録を扱います。

生のIQファイルの中身

IQファイルは複素ベースバンドサンプルの並びで、それ以外は何も含みません。ヘッダも、サンプルレートも、周波数もありません。各サンプルは同相(I)と直交(Q)の2つの数値で、順に格納されます。普遍的な慣例はIが先、次にQです。I0、Q0、I1、Q1という具合です。異なるのは数値の型です。

形式 一般的な名称 複素1サンプルあたりのバイト数 値 備考
32 ビット浮動小数点 cf32、SigMF cf32_le、SoapySDR CF32、numpy complex64 8 フルスケールで約 ±1.0 量子化を気にする必要がない。ファイルが大きい
16 ビット符号付き整数 cs16、SigMF ci16_le、SoapySDR CS16 4 −32768〜32767 12 ビット・14 ビットの変換器には十分
8 ビット符号付き整数 cs8、SigMF ci8、SoapySDR CS8 2 −128〜127 ファイルが小さい。量子化雑音はフルスケールの約 50 dB 下
8 ビット符号なし(オフセットバイナリ) cu8、SigMF cu8、SoapySDR CU8 2 0〜255、ゼロは 127.5 使用前にオフセットを引く

つまずきやすい点が3つあります。第一に、numpyは複素数型をペア全体の幅で命名するため、complex64は2つのfloat32値です。SigMFとSoapySDRは各成分の幅で命名するため、cf32が同じものを指します。SigMFの仕様はこの違いを明示的に指摘しています。第二に、マルチバイト形式にはエンディアンがあります。使う機会のあるほぼすべてのマシンはリトルエンディアンで、SigMFは型名の中でそれを明記します(_le)。第三に、整数形式にはフルスケールの定義がありません。32767が1.0を意味するか0.5を意味するかは、ファイルを書いたソフトウェアの慣例です。以下のコードでは最大の整数を1.0に対応させます。これが一般的な選択です。

cf32_leI0: float32、4 バイトQ0: float32、4 バイトI1: float32Q1: float32ci16_leI0 int16Q0 int16I1 int16Q1 int161サンプルあたり 4 バイトcu8I0Q0I1Q11サンプルあたり 2 バイト、符号なし、ゼロは 127.5ファイルオフセットは右へ増加(1マスの幅 = cf32で4 バイト、ci16で2、cu8で1)
3つの生IQ形式における、最初の2つの複素サンプルのバイト配置です。どの形式もIとQを、同相成分を先にしてインターリーブします。変わるのは各数値の幅と型だけです。

SigMF:ファイルを使えるものにするメタデータ

信号メタデータ形式(Signal Metadata Format、SigMF)は、GitHubで管理されているオープンな仕様です。記録を、同じベース名を持つ2つのファイルとして保存します。拡張子.sigmf-dataのデータセットファイルは、生のサンプルだけを保持します。拡張子.sigmf-metaのメタデータファイルはJSONです。本稿執筆時点で、仕様の最新リリースはv1.2.6です。

メタデータには3つのトップレベルオブジェクトがあり、いずれもスキーマで必須です。

  • globalは記録全体を記述します。core:datatype(例:cf32_le)とcore:version(使用したSigMFのバージョン)を含む必要があります。通常は1秒あたりのサンプル数で表したcore:sample_rateを含み、core:description、core:author、ハードウェアを表すcore:hw、ソフトウェアを表すcore:recorderを加えることもできます。
  • capturesはセグメントのリストです。各セグメントには、そのセグメントが始まるサンプル番号core:sample_startが必要です。最初のセグメントには通常、Hz単位の中心周波数core:frequencyと、Zで終わるUTCのRFC 3339タイムスタンプcore:datetimeを加えます。
  • annotationsは注目する領域を示すリストで、空でもかまいません。各要素にはcore:sample_startが必要で、通常はcore:sample_count、core:freq_lower_edge、core:freq_upper_edge、core:labelを持ちます。2つの周波数端は必ず組で指定します。また仕様では、中心周波数がわかっている場合はRF上の値で指定するよう求めています。

データ型の文字列は小さな文法に従います。複素数か実数かを表すcまたはr、続いてf32、f64、i32、i16、u32、u16のいずれかと_leまたは_be、あるいはエンディアンを持たないバイト型i8・u8のいずれかです。したがってcf32_le、ci16_le、cu8はいずれも有効です。一方、SoapySDRが同じ配置にCS16を使っていても、cs16はSigMFの名前ではありません。

合成キャプチャを3通りの形式で

このブロックは、2 MHzの複素キャプチャを65.5 ms分作ります。内容は+300 kHzのトーン、キャプチャの30%から70%の区間でオンになる−500 kHzのQPSKバースト、低い雑音フロアです。同じ信号をcf32_le、ci16_le、cu8の3形式で、それぞれ専用のメタデータファイルとともに書き込みます。読み出し側はデータ型とサンプルレートをメタデータだけから取得し、complex64に変換してスペクトラムを推定します。

import json
from pathlib import Path

import numpy as np
from scipy import signal

rng = np.random.default_rng(10)
fs = 2.0e6                       # sample rate, 2 MHz complex
fc = 100.0e6                     # centre frequency we record in the metadata (the signal is synthetic)
n = 2**17                        # 131 072 samples, 65.5 ms
t = np.arange(n) / fs

# A synthetic capture (complex128 while we build it): a tone, a QPSK burst and a little noise.
tone = 0.3 * np.exp(2j * np.pi * 300e3 * t)
sps = 20                                                     # 100 kbaud at 2 MHz
n_sym = -(-n // sps)                                         # enough symbols to cover n samples
syms = (rng.choice([-1, 1], n_sym) + 1j * rng.choice([-1, 1], n_sym)) / np.sqrt(2)
lowpass = signal.firwin(401, 70e3, window=("kaiser", 10.0), fs=fs)     # sharp, low-sidelobe filter
shaped = signal.lfilter(lowpass, 1.0, np.repeat(syms, sps)[:n])
burst = 0.4 * shaped / np.std(shaped) * np.exp(-2j * np.pi * 500e3 * t)
gate = np.zeros(n)
gate[int(0.3 * n) : int(0.7 * n)] = 1.0                      # burst on from 30 % to 70 % of the capture
burst *= np.convolve(gate, np.hanning(401) / np.hanning(401).sum(), mode="same")   # soft edges
noise = 1e-4 * (rng.normal(size=n) + 1j * rng.normal(size=n)) / np.sqrt(2)
x = tone + burst + noise
print(f"peak |I| or |Q|: {max(np.abs(x.real).max(), np.abs(x.imag).max()):.3f} (full scale is 1.0)")

# How each SigMF datatype is stored: numpy dtype per rail, scale to full scale, offset of zero.
FORMATS = {
    "cf32_le": ("<f4", 1.0, 0.0),
    "ci16_le": ("<i2", 32767.0, 0.0),
    "cu8": ("u1", 127.5, 127.5),
}

def write_sigmf(base: str, z: np.ndarray, datatype: str) -> None:
    dt, scale, offset = FORMATS[datatype]
    iq = np.empty(2 * z.size)
    iq[0::2], iq[1::2] = z.real, z.imag                      # interleave, in-phase first
    raw = iq * scale + offset
    if dt != "<f4":
        info = np.iinfo(np.dtype(dt))
        raw = np.clip(np.round(raw), info.min, info.max)
    raw.astype(dt).tofile(f"{base}.sigmf-data")
    meta = {
        "global": {"core:datatype": datatype, "core:sample_rate": fs, "core:version": "1.2.6",
                   "core:description": "Synthetic tone and QPSK burst for Wireless Field Guide"},
        "captures": [{"core:sample_start": 0, "core:frequency": fc, "core:datetime": "2026-11-15T09:00:00Z"}],
        "annotations": [],
    }
    Path(f"{base}.sigmf-meta").write_text(json.dumps(meta, indent=2))

def read_sigmf(base: str) -> tuple[np.ndarray, dict]:
    meta = json.loads(Path(f"{base}.sigmf-meta").read_text())
    dt, scale, offset = FORMATS[meta["global"]["core:datatype"]]
    raw = np.fromfile(f"{base}.sigmf-data", dtype=dt).astype(np.float32)
    iq = (raw - offset) / scale
    return (iq[0::2] + 1j * iq[1::2]).astype(np.complex64), meta

psd = {}
for dtype in FORMATS:
    base = f"capture_{dtype}"
    write_sigmf(base, x, dtype)
    y, meta = read_sigmf(base)
    size_kib = Path(f"{base}.sigmf-data").stat().st_size / 1024
    f, p = signal.welch(y, fs=meta["global"]["core:sample_rate"], window="blackmanharris", nperseg=1250, detrend=False, return_onesided=False,
                        scaling="spectrum")
    f, p = np.fft.fftshift(f), np.fft.fftshift(p)
    psd[dtype] = 10 * np.log10(p.reshape(50, 25).mean(axis=1))   # 50 points, 40 kHz apart
    quiet = (f > -200e3) & (f < 100e3)                         # a band with nothing in it
    err = 20 * np.log10(np.sqrt(np.mean(np.abs(y - x) ** 2)))
    print(f"{dtype:8s} {size_kib:7.0f} KiB, rms error vs original {err:6.1f} dB, "
          f"mean level, -200 to +100 kHz: {10 * np.log10(np.mean(p[quiet])):6.1f} dB")

fc_bins = f.reshape(50, 25).mean(axis=1)
print("freq kHz, cf32_le dB, ci16_le dB, cu8 dB")
for i in range(50):
    print(f"{fc_bins[i] / 1e3:.0f}, {psd['cf32_le'][i]:.1f}, {psd['ci16_le'][i]:.1f}, {psd['cu8'][i]:.1f}")
peak |I| or |Q|: 0.712 (full scale is 1.0)
cf32_le     1024 KiB, rms error vs original -159.6 dB, mean level, -200 to +100 kHz: -108.0 dB
ci16_le      512 KiB, rms error vs original  -98.1 dB, mean level, -200 to +100 kHz: -107.9 dB
cu8          256 KiB, rms error vs original  -49.6 dB, mean level, -200 to +100 kHz:  -76.3 dB
freq kHz, cf32_le dB, ci16_le dB, cu8 dB
-981, -108.0, -107.9, -80.0
-941, -108.0, -107.9, -80.3
-901, -108.2, -108.1, -67.8
-861, -108.0, -108.0, -80.1
-821, -107.9, -107.8, -80.2
-781, -107.8, -107.7, -80.1
-741, -107.9, -107.8, -80.2
-701, -107.9, -107.8, -80.1
-661, -107.8, -107.7, -80.2
-621, -107.9, -107.8, -80.0
-581, -41.6, -41.6, -41.6
-541, -28.8, -28.8, -28.8
-501, -26.5, -26.5, -26.5
-461, -28.5, -28.5, -28.5
-421, -40.2, -40.2, -40.2
-381, -107.7, -107.7, -80.1
-341, -107.8, -107.8, -80.2
-301, -107.8, -107.8, -80.3
-261, -107.8, -107.8, -80.3
-221, -108.1, -108.0, -80.2
-181, -108.0, -107.9, -80.1
-141, -107.9, -107.8, -80.4
-101, -107.9, -107.8, -69.5
-61, -108.0, -108.0, -80.1
-21, -108.2, -108.1, -80.3
19, -107.9, -107.9, -80.2
59, -107.9, -107.8, -80.0
99, -107.8, -107.8, -80.1
139, -108.1, -108.0, -80.1
179, -107.8, -107.7, -80.2
219, -107.9, -107.8, -80.2
259, -107.9, -107.9, -80.3
299, -21.4, -21.4, -21.4
339, -107.8, -107.7, -80.3
379, -107.9, -107.8, -80.1
419, -107.9, -107.8, -80.2
459, -107.8, -107.8, -80.2
499, -107.8, -107.7, -80.3
539, -108.1, -108.1, -80.1
579, -107.9, -107.9, -80.3
619, -107.8, -107.8, -80.0
659, -108.0, -107.9, -80.3
699, -107.8, -107.7, -69.6
739, -108.0, -107.9, -80.2
779, -107.9, -107.8, -80.1
819, -107.8, -107.7, -80.1
859, -107.9, -107.9, -80.2
899, -107.9, -107.9, -80.1
939, -108.1, -108.1, -80.2
979, -108.0, -107.9, -80.0

ファイルサイズは1段階下げるごとに半分になります。131 072サンプルで1024 KiB、512 KiB、256 KiBです。代償は量子化雑音です。ステップΔの一様量子化器は、各レールに分散Δ²/12の雑音を加えます。±1にスケーリングした16 ビットでは、これはキャプチャ内の他のどの成分よりもはるかに小さくなります。int16のコピーと原信号の差は−98.1 dBで、そのスペクトラムは浮動小数点版と区別がつきません。8 ビットでは誤差は−49.6 dBです。帯域内の何もない部分、−200 kHzから+100 kHzの間では、平均レベルが−108.0 dBから−76.3 dBに上昇します。

-100-80-60-40-20-5000500cf32_leci16_lecu8ビンあたりの電力(dB)周波数オフセット(kHz)
cf32_le、ci16_le、cu8として保存し、SigMFメタデータを通して読み戻した2 MHz合成キャプチャのスペクトラムです。各点は40 kHzにわたる平均値です。+300 kHzのトーンと−500 kHz付近のQPSKバーストは3つとも同じです。cu8のコピーは雑音フロアが−80 dB付近にあり、他の2つにはないスプリアスがあります。値は1つ目のコードブロックによるものです。

cu8のトレースが示すのは、フロアの上昇だけではありません。−901 kHz、−101 kHz、+699 kHz付近にスプリアスが現れ、最大−67.8 dBに達します。256段階への丸めは非線形な操作であり、入力に強いトーンがあると誤差は純粋なランダムではなくなります。誤差には信号の高調波や相互変調積が含まれます。実際の8 ビット受信機では、これらのスプリアスを本物の信号と見誤りやすくなります。役立つ習慣が2つあります。1つは、最も強い信号がクリップせずにレンジの大部分を使うようにゲインを保つことです。もう1つは、スプリアスが現れたらゲインやチューニングを変え、本物の信号と同じように動くかを確かめることです。

バーストを見つけてアノテーションを付ける

スペクトログラムは、時間スライスごとの短いスペクトラムを並べたものです。信号がいつ存在するかを見る最も手早い方法です。次のブロックは同じキャプチャを作り直し(当サイトの各ブロックは単独で実行されます)、cf32_leとして書き込みます。続いて256点のスライスでスペクトログラムを計算し、中心より400 kHzから600 kHz下の帯域の電力を合計して、それが立ち上がる位置を見つけます。最後に、キャプチャのメタデータに中心周波数があるので、周波数端をRF上の値として、結果をSigMFアノテーションに記録します。

import json
from pathlib import Path

import numpy as np
from scipy import signal

# Rebuild the same capture as before and save it as cf32_le SigMF.
rng = np.random.default_rng(10)
fs, fc, n = 2.0e6, 100.0e6, 2**17
t = np.arange(n) / fs
sps = 20
n_sym = -(-n // sps)
syms = (rng.choice([-1, 1], n_sym) + 1j * rng.choice([-1, 1], n_sym)) / np.sqrt(2)
lowpass = signal.firwin(401, 70e3, window=("kaiser", 10.0), fs=fs)
shaped = signal.lfilter(lowpass, 1.0, np.repeat(syms, sps)[:n])
gate = np.zeros(n)
gate[int(0.3 * n) : int(0.7 * n)] = 1.0
gate = np.convolve(gate, np.hanning(401) / np.hanning(401).sum(), mode="same")
x = (0.3 * np.exp(2j * np.pi * 300e3 * t)
     + 0.4 * shaped / np.std(shaped) * np.exp(-2j * np.pi * 500e3 * t) * gate
     + 1e-4 * (rng.normal(size=n) + 1j * rng.normal(size=n)) / np.sqrt(2))
x.astype(np.complex64).tofile("burst.sigmf-data")           # complex64 is interleaved float32, I first
meta = {
    "global": {"core:datatype": "cf32_le", "core:sample_rate": fs, "core:version": "1.2.6"},
    "captures": [{"core:sample_start": 0, "core:frequency": fc}],
    "annotations": [],
}

# Read it back the way any tool would: datatype and sample rate come from the metadata.
y = np.fromfile("burst.sigmf-data", dtype=np.complex64)
fs_r = meta["global"]["core:sample_rate"]
f, tt, s = signal.spectrogram(y, fs=fs_r, window="blackmanharris", nperseg=256, noverlap=0,
                              detrend=False, return_onesided=False, scaling="spectrum")
f = np.fft.fftshift(f)
s = np.fft.fftshift(s, axes=0)
print(f"spectrogram: {f.size} frequency bins of {fs_r / f.size / 1e3:.2f} kHz, {tt.size} time slices of {256 / fs_r * 1e6:.0f} us")

# Find the burst: power in the 400-600 kHz band below the centre, slice by slice.
band = (f >= -600e3) & (f <= -400e3)
p_band = 10 * np.log10(s[band].sum(axis=0))
on = p_band > p_band.max() - 20                              # within 20 dB of the strongest slice
first, last = np.argmax(on), on.size - 1 - np.argmax(on[::-1])
start = int(first * 256)
count = int((last + 1) * 256) - start
meta["annotations"].append({
    "core:sample_start": start,
    "core:sample_count": count,
    "core:freq_lower_edge": fc - 600e3,                       # at RF, because the capture has a known centre
    "core:freq_upper_edge": fc - 400e3,
    "core:label": "QPSK burst",
})
Path("burst.sigmf-meta").write_text(json.dumps(meta, indent=2))
print(f"burst found from sample {start} to {start + count} ({start / fs_r * 1e3:.2f} ms to {(start + count) / fs_r * 1e3:.2f} ms)")
print(f"true burst edges: {0.3 * n / fs_r * 1e3:.2f} ms to {0.7 * n / fs_r * 1e3:.2f} ms")
print("band power over time (every 10th slice): t ms, dB")
for k in range(0, tt.size, 10):
    print(f"{tt[k] * 1e3:.2f}, {p_band[k]:.1f}")
print(Path("burst.sigmf-meta").read_text())
spectrogram: 256 frequency bins of 7.81 kHz, 512 time slices of 128 us
burst found from sample 39168 to 91904 (19.58 ms to 45.95 ms)
true burst edges: 19.66 ms to 45.88 ms
band power over time (every 10th slice): t ms, dB
0.06, -87.7
1.34, -88.7
2.62, -86.3
3.90, -87.5
5.18, -86.1
6.46, -86.2
7.74, -89.3
9.02, -87.9
10.30, -86.5
11.58, -88.7
12.86, -87.2
14.14, -85.0
15.42, -86.0
16.70, -87.6
17.98, -86.0
19.26, -87.2
20.54, -5.3
21.82, -5.1
23.10, -5.1
24.38, -5.0
25.66, -4.8
26.94, -4.7
28.22, -4.7
29.50, -4.9
30.78, -4.5
32.06, -5.0
33.34, -5.1
34.62, -5.1
35.90, -4.8
37.18, -4.8
38.46, -4.9
39.74, -5.0
41.02, -5.1
42.30, -5.0
43.58, -4.8
44.86, -5.2
46.14, -88.0
47.42, -91.3
48.70, -87.1
49.98, -86.9
51.26, -86.0
52.54, -87.7
53.82, -86.8
55.10, -88.9
56.38, -86.1
57.66, -86.8
58.94, -87.8
60.22, -88.6
61.50, -86.9
62.78, -86.9
64.06, -88.0
65.34, -86.3
{
  "global": {
    "core:datatype": "cf32_le",
    "core:sample_rate": 2000000.0,
    "core:version": "1.2.6"
  },
  "captures": [
    {
      "core:sample_start": 0,
      "core:frequency": 100000000.0
    }
  ],
  "annotations": [
    {
      "core:sample_start": 39168,
      "core:sample_count": 52736,
      "core:freq_lower_edge": 99400000.0,
      "core:freq_upper_edge": 99600000.0,
      "core:label": "QPSK burst"
    }
  ]
}

検出器は、バーストを19.58 msから45.95 msの区間に見つけます。適用したソフトなランプを除いた真のバーストの端は、19.66 msと45.88 msです。検出領域がわずかに広いのは、しきい値がピークより20 dB下にあり、128 µsのスライス単位で外側に丸められるためです。アノテーションには、これがサンプル番号39 168、サンプル数52 736として、周波数端は99.4 MHzと99.6 MHzとして記録されます。

-80-60-40-20204060帯域電力(dB)時間(ms)
中心より400 kHzから600 kHz下の帯域の電力を、128 µsのスペクトログラムスライスごとに示したものです(10スライスごとに表示)。QPSKバーストにより、20 msから46 msの間で帯域電力が約80 dB上昇します。値は2つ目のコードブロックによるものです。

共有された記録を役立つものにするのがアノテーションです。同僚やスクリプトは、ファイル全体を走査せずに、ラベル付きのバーストへ直接移動できます。

実際の無線機から記録する

最後のブロックは、SoapySDRを通じてSDRから記録します。SoapySDRは自らを、ベンダーやプラットフォームに依存しないSDRサポートライブラリと説明しています。Pythonバインディングを持ち、無線機の各ファミリーにはそれぞれ専用のSoapySDRサポートモジュールを通じてアクセスします。このブロックには、コンピュータに接続したSDRと、その無線機用のSoapySDRモジュールのインストールが必要です。これらがなければ実行できません。そのため、このブロックは# needs-hardwareで始まり、当サイトのチェッカーは構文だけを検査します。driverを、使用する無線機のSoapySDRドライバキーに変更してください。当サイトのハードウェア表に、一般的な受信機とその周波数範囲・変換器のビット幅を掲載しています。

呼び出しは、SoapySDRのPythonサンプルに従っています。デバイスを開き、受信チャネル0のサンプルレートと中心周波数を設定し、選んだサンプル形式でストリームをセットアップして有効化し、バッファを渡してreadStreamを繰り返し呼び出します。getStreamFormatsは、ドライバが出力できる形式を列挙します。このブロックはインターリーブされたint16であるCS16を要求し、サンプルをSigMFのci16_leとして、開始時刻をcore:datetimeに入れて保存します。

# needs-hardware
import json
from datetime import datetime, timezone
from pathlib import Path

import numpy as np
import SoapySDR
from SoapySDR import SOAPY_SDR_RX, SOAPY_SDR_CS16

driver = "rtlsdr"                 # change to the SoapySDR driver key of your radio
fs = 2.0e6                        # sample rate, Hz
fc = 100.0e6                      # centre frequency, Hz (pick a band you may legally receive)
n = 2**18                         # samples to record

sdr = SoapySDR.Device(dict(driver=driver))
print("formats offered:", sdr.getStreamFormats(SOAPY_SDR_RX, 0))
sdr.setSampleRate(SOAPY_SDR_RX, 0, fs)
sdr.setFrequency(SOAPY_SDR_RX, 0, fc)
stream = sdr.setupStream(SOAPY_SDR_RX, SOAPY_SDR_CS16)       # interleaved int16, I first
start_time = datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%S.%fZ")
sdr.activateStream(stream)

buf = np.empty(2 * n, dtype=np.int16)                        # two int16 values per complex sample
got = 0
while got < n:
    chunk = np.empty(2 * 8192, dtype=np.int16)
    sr = sdr.readStream(stream, [chunk], 8192)               # count is in complex samples
    if sr.ret > 0:
        k = min(sr.ret, n - got)
        buf[2 * got : 2 * (got + k)] = chunk[: 2 * k]
        got += k
    elif sr.ret < 0:
        print(f"readStream error {sr.ret}")
        break
sdr.deactivateStream(stream)
sdr.closeStream(stream)

buf[: 2 * got].astype("<i2").tofile("radio.sigmf-data")
meta = {
    "global": {"core:datatype": "ci16_le", "core:sample_rate": fs, "core:version": "1.2.6",
               "core:hw": f"SoapySDR driver {driver}"},
    "captures": [{"core:sample_start": 0, "core:frequency": fc, "core:datetime": start_time}],
    "annotations": [],
}
Path("radio.sigmf-meta").write_text(json.dumps(meta, indent=2))
print(f"recorded {got} samples at {fs / 1e6:.1f} MHz around {fc / 1e6:.3f} MHz")

実用上の注意をいくつか挙げます。readStreamは要求より少ないサンプルを返すことがあるため、ループで個数を数えています。負の戻り値はエラーコードです。SOAPY_SDR_OVERFLOW(−4)は、ホストの読み出しが間に合わず、サンプルが欠落したことを意味します。オーバーフローは記録に欠落を残します。高速なローカルディスクに記録し、サンプルレートは必要以上に上げないでください。受信は多くの地域・多くの帯域で合法ですが、どこでも、何についても合法というわけではありません。他者の送信を記録する前に、自国の規則を確認してください。GNU Radio FM受信機プロジェクトでは、これを発展させてライブ受信機を作ります。

演習

  1. 1つ目のブロックにci8形式(符号付き8 ビット、スケール127、オフセット0)を追加してください。誤差とフロアをcu8と比較し、両者がほぼ同じになる理由を説明してください。
  2. cu8を書き込む前に、1つ目のブロックのすべての信号の振幅を半分にしてください。トーンに対して、スプリアスとフロアはどう変化しますか。次に振幅を2倍にして、クリッピングを探してください。
  3. 2つ目のブロックで、キャプチャ全体にわたる+300 kHzのトーンに対して、2つ目のアノテーションを追加してください。メタデータを読み戻し、すべてのアノテーションのラベルと、ミリ秒単位の継続時間を表示してください。

まとめ

  • 生のIQファイルは、ヘッダを持たず、IとQを同相成分を先にしてインターリーブしたサンプルの並びです。一般的な型はcf32、cs16(SigMFのci16_le)、cs8、cu8で、複素1サンプルあたりそれぞれ8、4、2、2バイトです。
  • SigMFは、.sigmf-dataファイルと、global・captures・annotationsを保持するJSONの.sigmf-metaファイルを組にします。core:datatypeとcore:versionは必須で、core:sample_rateとcore:frequencyがあることでファイルが使えるものになります。
  • 当サイトのテストでは、16 ビットでの保存が加えた誤差は−98.1 dBで、スペクトラム上では見えませんでした。8 ビットでの保存は−49.6 dBの誤差を加え、フロアを約30 dB押し上げ、スプリアスを生じさせました。
  • スペクトログラムと帯域電力のしきい値により、128 µsのスライス1つ以内の精度でバーストを見つけました。SigMFアノテーションには、それを時間とRF周波数で記録しました。
  • 無線機を開くコードには、ハードウェアの接続が必要です。SoapySDRは、多くの無線機に対して1つのAPIを提供します。

さらに読む