First commit
This commit is contained in:
commit
9bc5ed5462
8 files changed
+462
No files matched your search
@@ -0,0 +1,6 @@
|
||||
# 継続作業
|
||||
|
||||
最初に README.md と docs/STATUS.md を読むこと。
|
||||
ユーザー要件: HamGeek Pluto Nano、76–108 MHz可変、音声ファイルの逐次送信、非WAVEはFFmpegで逐次変換、ファームウェア変更は最小限。ステレオFMも対応(--stereo)。
|
||||
ファームウェア変更を自動化しない。実機未検証の項目を検証済みと記載しない。
|
||||
変更後は関連する検証を実行し、docs/STATUS.mdに結果と残課題を残す。
|
||||
@@ -0,0 +1,75 @@
|
||||
# Pluto互換ボード FMトランスミッター
|
||||
|
||||
HamGeek Pluto Nano向けのホスト側CLI。既存ファームウェアのlibiio経由で音声を逐次送信します。WAVE・MP3・AAC・FLACなどFFmpeg対応ファイルをPCMに変換しながらモノラルまたはステレオFMに変調するため、ファイル全体をメモリに読み込みません。標準ではステレオ入力をモノラルにミックスします。`--stereo`指定時は左右を保持してステレオFMを生成します。RDSは未対応です。
|
||||
|
||||
## インストール(Ubuntu/Debian)
|
||||
|
||||
```sh
|
||||
sudo apt install python3-venv ffmpeg libiio0 libiio-utils libad9361-0
|
||||
python3 -m venv .venv
|
||||
.venv/bin/pip install -e .
|
||||
```
|
||||
|
||||
Python 3.10以上。ディストリビューションによりlibiioパッケージ名が異なります。Pythonの`iio`はpyadi-iio依存のpylibiioを利用します。
|
||||
|
||||
## 使用
|
||||
|
||||
```sh
|
||||
# 接続確認(標準PlutoのIP例。Nanoの実際のIPに合わせる)
|
||||
iio_info -u ip:192.168.2.1
|
||||
|
||||
# 再生開始。Ctrl+Cで停止
|
||||
.venv/bin/pluto-fm audio.wav --frequency 80.0 --uri ip:192.168.2.1
|
||||
.venv/bin/pluto-fm music.mp3 --frequency 90.5 --gain -40 --loop
|
||||
|
||||
# ステレオFM
|
||||
.venv/bin/pluto-fm music.wav --frequency 80.0 --stereo --loop
|
||||
|
||||
# RF送信せず、変調済みIQを保存して確認
|
||||
.venv/bin/pluto-fm audio.wav --frequency 80 --dry-run /tmp/audio.cf32
|
||||
```
|
||||
|
||||
`--frequency`は76.0〜108.0 MHz、小数指定可。周波数変更は停止して別の周波数で再起動します。`--gain`は-89.75〜0 dB(初期値-40)、`--volume`は0〜1(初期値0.8)、`--deviation`は最大偏移Hz(初期値75000)、`--pre-emphasis`は0/50/75 µs(初期値50)。高域の強い音源で歪む場合はvolumeを下げてください。gainは校正済み出力電力ではありません。
|
||||
|
||||
使用する送信ポートは第1チャネル/TX1です。許可された条件下で、まずダミーロードや減衰器を使用して確認してください。
|
||||
|
||||
## ファームウェアと76 MHz対応
|
||||
|
||||
このプログラムはファームウェア更新・SSH操作・U-Boot環境変更を行いません。既存のIIOドライバが指定LOを受け付ければそのまま動作します。起動時にLO(許容差10 Hz)と960 kS/sの読み戻しを検証し、指定を拒否された場合は送信開始せず終了します。
|
||||
|
||||
標準ADALM-Plutoは通常325 MHz以上で、ADIはAD9364互換設定で70 MHz以上に拡張する方法を公開しています。ただしHamGeek Pluto Nano固有の公式仕様・搭載チップ・設定方式は未確認です。標準Pluto用の設定をNanoへ無条件に適用しないでください。76 MHzを拒否する個体ではメーカーの資料と現在の設定を確認し、必要なら既存ファームウェアの周波数範囲設定だけを変更する方針です。ファームウェアの焼き直しは前提にしていません。
|
||||
|
||||
参照: [ADI周波数拡張設定](https://wiki.analog.com/university/tools/pluto/users/customizing)、[ADI Python導入](https://developer.analog.com/docs/system-level/solutions/platforms/pluto/software/python_install.html)、[pyadi-iio TX実装](https://github.com/analogdevicesinc/pyadi-iio/blob/main/adi/rx_tx.py)。
|
||||
|
||||
## ストリーミング方式と制約
|
||||
|
||||
FFmpegから48 kHz float32モノラルPCMを100 ms単位で読み、プリエンファシス・15 kHz低域フィルタ・20倍の状態保持ポリフェーズFIR補間・位相連続FMを行います。IQは960 kS/s、complex64、非循環バッファです。転送ペースはlibiioのブロッキング送信で決まり、FFmpegに`-re`は付けません。IQ転送量はおよそ3.84 MB/s(16 bit I/Q)です。乾式出力は7.68 MB/sで、実時間待ちを行いません。最終ブロックはゼロ埋めするため最大100 msの無音が付きます。
|
||||
|
||||
Ctrl+C・通常終了・処理エラーではTXバッファを破棄しゲインを最小化します。ハードウェアのRF muteを保証するものではありません。接続個体ではAD9364互換設定後、76/80/108 MHzで3秒のバッファ送信と終了時ゲイン最小化を確認済みです。受信音質・アンダーラン・停止時RFは未検証です。USB/ネットワークやホスト負荷によるアンダーランを完全には検出できません。
|
||||
|
||||
## 検証とセッション継続
|
||||
|
||||
```sh
|
||||
PYTHONPATH=src python3 -m unittest discover -s tests -v
|
||||
```
|
||||
|
||||
[作業状況](docs/STATUS.md)に検証結果と次の実機確認を記録しています。次回のセッションではREADME、AGENTS.md、STATUS.mdを読んで続けられます。
|
||||
|
||||
## 接続個体のAD9364互換設定
|
||||
|
||||
既存FW v0.39-dirtyで、SSHから次のU-Boot環境設定だけを変更しました。ファームウェアイメージの更新は行っていません。
|
||||
|
||||
```sh
|
||||
fw_setenv attr_name compatible
|
||||
fw_setenv attr_val ad9364
|
||||
fw_setenv compatible ad9364
|
||||
reboot
|
||||
```
|
||||
|
||||
変更前は3変数とも未定義でした。この個体を元に戻す場合は、各`fw_setenv`を変数名だけで実行して削除し、再起動します。他の個体では元の値を確認してから変更してください。設定後のTX LO公称範囲は46.875001〜6000 MHzでした。CLIの指定範囲は76〜108 MHzのままです。
|
||||
|
||||
## ステレオFM
|
||||
|
||||
`--stereo`は左右48 kHz音声を個別にプリエンファシス・帯域制限して補間し、`0.9 × ((L+R)/2 + (L−R)/2 × sin(38 kHz)) + 0.1 × sin(19 kHz)`のMPX信号でFM変調します。19 kHzパイロットと38 kHz副搬送波は同一サンプル時計から生成します。ステレオ音声の副搬送波側波帯は23〜53 kHzです。TX RF帯域は300 kHzに設定します。参考: [ITU-R BS.450-4](https://www.itu.int/dms_pubrec/itu-r/rec/bs/R-REC-BS.450-4-201910-I%21%21PDF-E.pdf)。
|
||||
|
||||
ステレオモードではFFmpegから2チャネルPCMを受け取り、モノラル入力もFFmpegで2チャネル化します。モノラル受信機ではL+Rを聴けます。音量が高すぎるとリミッターで歪むため、必要に応じて`--volume`を下げてください。受信機のステレオ表示と左右音声は実際の受信環境で確認してください。
|
||||
@@ -0,0 +1,68 @@
|
||||
# 作業状況 (2026-10-07)
|
||||
|
||||
## 実装済み
|
||||
|
||||
- Python CLI: `pluto-fm`。76–108 MHz指定、URI、ゲイン、音量、偏移、プリエンファシス、ループ、乾式IQ出力。
|
||||
- WAVEを含む入力をFFmpegでストリーミングPCM化。
|
||||
- モノラルFM: 48 kHz音声、15 kHz LPF、状態保持20倍補間、960 kS/s IQ、位相連続。
|
||||
- pyadi-iio/libiioの非循環TX。LO/sample rate読み戻し、終了時バッファ破棄と減衰。
|
||||
- ファームウェア・ブート環境への変更なし。
|
||||
|
||||
## 検証結果
|
||||
|
||||
- `PYTHONPATH=src python3 -m unittest discover -s tests -v`: 2件成功。ブロック分割による位相連続性、偏移上限、定振幅、復調トーン周波数、末尾ゼロ埋めを確認。
|
||||
- FFmpeg合成1秒WAVEとMP3から各960,000 complex64 IQ生成成功。有限値を確認。各約0.30秒で処理(このホスト)。
|
||||
- 不正音声で終了コード1。
|
||||
- RF実機は未接続。pyadi-iioによるNanoの送信は未検証。
|
||||
|
||||
## 次の実機確認
|
||||
|
||||
1. `iio_info -u <URI>` でボード、IIOデバイス、LO範囲を確認。IPと接続方法を確定。
|
||||
2. ダミーロード/減衰器で80 MHz、低ゲイン、既知のWAVEを送信。受信音質と停止時RFを確認。
|
||||
3. 76 MHzと108 MHzのLO設定、実周波数、長時間連続送信・ループ境界を確認。
|
||||
4. 指定LOが拒否された場合はメーカー資料と既存設定を確認。標準Pluto用U-Boot設定を無条件に適用しない。
|
||||
5. 必要なら実機を使ったTXアンダーラン観測・音質改善・ステレオ対応を検討。
|
||||
|
||||
資料はREADME内のADI公式リンクを参照。Nano固有の公式仕様は確認できていない。
|
||||
|
||||
## 実機接続確認 (2026-10-07)
|
||||
|
||||
- USB VID:PID `0456:b673`、USBネットワーク `192.168.2.10/24`、IIO URI `ip:192.168.2.1` で接続成功。
|
||||
- ボード申告: `Analog Devices PlutoSDR Rev.B (Z7010-AD9363A)`、model `ad9363a`、FW `v0.39-dirty`、kernel `6.1.0-271883-gf3da30df6004-dirty`。これはIIO申告値であり、Nanoの実際の搭載チップを確定したものではない。
|
||||
- TX LO available `[325000000 1 3800000000]`。TX LO powerdown中に76/80/108 MHzの書き込みを試し、すべてEINVAL。LOは2450 MHzのまま。元のpowerdown=0に復元。IQ送信は行っていない。
|
||||
- pyadi-iio 0.0.21、pylibiio 0.25、native libiio 0.26。`.venv`に依存関係を導入済み。OSのensurepip欠落によりget-pipを使用。
|
||||
- TX LO powerdown中にpyadi-iio経由で960000 S/sの設定・読み戻し成功。サンプルレートを30720000に戻し、FIR有効フラグも元の0に復元。低レート設定時にpyadi-iioがRAM内FIR係数をロードしたがファームウェア・永続設定の変更なし。
|
||||
- CLIにLO範囲の事前確認を追加。80 MHz実行で終了コード1、LO 2450 MHz、gain -10 dB、sample rate 30720000が維持されることを確認。
|
||||
- 修正後のDSPテスト2件成功。
|
||||
- 未実施: FM帯での実際の送信、音質、RF周波数精度、停止時RF、連続送信。
|
||||
- ユーザーへメーカーの周波数拡張手順またはSSH認証方法を照会中。範囲制限のため送信テストは保留。既存の周波数設定を確認してから最小限の変更方法を決める。
|
||||
|
||||
## AD9364互換設定への変更
|
||||
|
||||
ユーザーのAD9364互換設定の提案に従い、ADI公式手順を確認。SSH rootで接続成功。
|
||||
変更前: `attr_name`、`attr_val`、`compatible` はすべて未定義。
|
||||
変更コマンド: `fw_setenv attr_name compatible`、`fw_setenv attr_val ad9364`、`fw_setenv compatible ad9364`、再起動。
|
||||
ファームウェアイメージの書き換えなし。U-Boot環境の3項目のみ変更。
|
||||
復元はSSHで `fw_setenv attr_name`、`fw_setenv attr_val`、`fw_setenv compatible` を実行して再起動(元が未定義のため削除)。
|
||||
|
||||
### AD9364設定後の実機結果
|
||||
|
||||
- 再起動後model `ad9364`。TX frequency_available `[46875001 1 6000000000]`。
|
||||
- LO停止中に76/80/108 MHz設定成功。108 MHz読み戻し107999998 Hz(2 Hz丸め)。LO判定を許容差10 Hzへ修正。
|
||||
- 1 kHz、3秒のテスト音をgain -80 dBで送信: 76 MHz/WAVE、80 MHz/MP3、108 MHz/WAVE。全てCLI終了コード0。libiioへの非循環TXバッファ転送が成功。
|
||||
- 各通常終了後のgain -89.75 dBを確認。処理時間は3.90/3.30/3.32秒(接続/設定時間を含む)。RF受信による音質・連続性は確認していない。
|
||||
- DSPテスト2件成功。実RF周波数、RF停止、スプリアス、アンダーランは未検証。
|
||||
- 80 MHz/gain -80 dBで1秒WAVEを10秒間ループ送信後SIGINT。終了コード0、停止後gain -89.75 dB。長時間耐久と受信したループ境界の音質は未確認。
|
||||
- 次の確認: ユーザーの受信機で80 MHzの音声を確認し、必要なgain/volumeを調整。現在プロセスは停止済み。
|
||||
|
||||
## ステレオFM対応 (2026-10-07)
|
||||
|
||||
- `--stereo`追加。FFmpeg出力を2チャネルとして左右を保持。省略時は既存のモノラル。
|
||||
- MPXは0.9×((L+R)/2+(L−R)/2×sin(38 kHz))+0.1×sin(19 kHz)。パイロット・副搬送波は位相同期したサンプル時計で生成。左右それぞれプリエンファシス、15 kHz帯域制限と補間。ステレオRF帯域300 kHz。
|
||||
- FIR補間をポリフェーズ+overlap-addに変更し、ブロック間の状態を保持。ステレオでは急峻な補間フィルタで19 kHz付近を保護。
|
||||
- ソフトウェアテスト5件成功: 従来モノラル、ステレオの位相連続、末尾パディング、FM復調したMPXのパイロット振幅0.1、38 kHz抑圧、左1 kHz/右2.3 kHzの分離を確認。不要側チャネルのトーン振幅<0.001、目的側>0.17。
|
||||
- 高レベル音声で周波数偏移<=75001 Hzを確認。モノラルWAVEをステレオIQに変換し、0.15秒の入力が最終ブロック埋めで0.2秒になることを確認。
|
||||
- 実機80 MHz/gain -80 dB: 左1 kHz・右2.3 kHzの3秒WAVEおよびMP3のバッファ転送成功。終了コード0。RF帯域読み戻し300000 Hz、停止後gain -89.75 dB。
|
||||
- ステレオループを7秒実行後SIGINTで終了コード0、gain -89.75 dB。
|
||||
- 現在送信停止済み。追加のファームウェア・U-Boot変更なし。
|
||||
- 未確認: 受信機のステレオ表示、実RFでの左右分離/音質、スペクトル・長時間アンダーラン。次回は`--stereo`付きで音声を送信して受信機で確認。
|
||||
@@ -0,0 +1,16 @@
|
||||
[build-system]
|
||||
requires = ["setuptools>=68"]
|
||||
build-backend = "setuptools.build_meta"
|
||||
|
||||
[project]
|
||||
name = "pluto-fm-transmitter"
|
||||
version = "0.1.0"
|
||||
description = "Streaming mono FM transmitter for Pluto-compatible SDRs"
|
||||
requires-python = ">=3.10"
|
||||
dependencies = ["numpy>=1.24", "scipy>=1.10", "pyadi-iio>=0.0.17,<0.1"]
|
||||
|
||||
[project.scripts]
|
||||
pluto-fm = "pluto_fm.cli:main"
|
||||
|
||||
[tool.setuptools.packages.find]
|
||||
where = ["src"]
|
||||
@@ -0,0 +1 @@
|
||||
"""Host-side streaming FM transmitter."""
|
||||
@@ -0,0 +1,166 @@
|
||||
import argparse
|
||||
import contextlib
|
||||
import pathlib
|
||||
import shutil
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
import time
|
||||
|
||||
import numpy as np
|
||||
|
||||
from .dsp import AUDIO_RATE, IQ_RATE, BLOCK, Modulator
|
||||
|
||||
|
||||
def parser():
|
||||
p = argparse.ArgumentParser(description="Pluto互換SDR モノラル/ステレオFM送信 (76–108 MHz)")
|
||||
p.add_argument("input", type=pathlib.Path, help="WAVEまたはFFmpeg対応音声ファイル")
|
||||
p.add_argument("--frequency", type=float, required=True, help="MHz (76.0–108.0)")
|
||||
p.add_argument("--uri", default="ip:192.168.2.1", help="libiio URI")
|
||||
p.add_argument("--gain", type=float, default=-40, help="TX gain dB (-89.75–0)")
|
||||
p.add_argument("--volume", type=float, default=0.8, help="音声倍率 (0–1)")
|
||||
p.add_argument("--deviation", type=float, default=75000, help="最大周波数偏移 Hz (1–75000)")
|
||||
p.add_argument("--pre-emphasis", type=int, choices=[0, 50, 75], default=50, help="µs")
|
||||
p.add_argument("--stereo", action="store_true", help="19 kHzパイロット付きステレオFMを送信")
|
||||
p.add_argument("--loop", action="store_true")
|
||||
p.add_argument("--dry-run", type=pathlib.Path, help="送信せずcomplex64 IQファイルに保存")
|
||||
return p
|
||||
|
||||
|
||||
@contextlib.contextmanager
|
||||
def decode(path, loop, channels=1):
|
||||
# Decode every format with the same streaming path, including WAVE.
|
||||
with tempfile.TemporaryFile() as errors:
|
||||
command = ["ffmpeg", "-nostdin", "-hide_banner", "-loglevel", "error"]
|
||||
if loop:
|
||||
command += ["-stream_loop", "-1"]
|
||||
command += ["-i", str(path.resolve()), "-vn", "-ac", str(channels), "-ar", str(AUDIO_RATE),
|
||||
"-f", "f32le", "-acodec", "pcm_f32le", "pipe:1"]
|
||||
proc = subprocess.Popen(command, stdout=subprocess.PIPE, stderr=errors)
|
||||
try:
|
||||
yield proc.stdout
|
||||
code = proc.wait()
|
||||
if code:
|
||||
errors.seek(0)
|
||||
raise RuntimeError("FFmpeg変換失敗: " + errors.read().decode(errors="replace")[-4000:])
|
||||
finally:
|
||||
proc.stdout.close()
|
||||
if proc.poll() is None:
|
||||
proc.terminate()
|
||||
try:
|
||||
proc.wait(timeout=3)
|
||||
except subprocess.TimeoutExpired:
|
||||
proc.kill()
|
||||
proc.wait()
|
||||
|
||||
|
||||
def blocks(stream, channels=1):
|
||||
size = BLOCK * 4 * channels
|
||||
while True:
|
||||
data = bytearray()
|
||||
while len(data) < size:
|
||||
part = stream.read(size - len(data))
|
||||
if not part:
|
||||
break
|
||||
data.extend(part)
|
||||
if not data:
|
||||
return
|
||||
if len(data) % (4 * channels):
|
||||
raise RuntimeError("不完全なPCMサンプル")
|
||||
samples = np.frombuffer(data, dtype="<f4")
|
||||
# Constant TX buffer length, including the final partial block.
|
||||
if channels == 1:
|
||||
yield np.pad(samples, (0, BLOCK - len(samples)))
|
||||
else:
|
||||
samples = samples.reshape(-1, channels)
|
||||
yield np.pad(samples, ((0, BLOCK - len(samples)), (0, 0)))
|
||||
|
||||
|
||||
def connect(args):
|
||||
import adi
|
||||
sdr = adi.Pluto(uri=args.uri)
|
||||
# Reject unsupported LO before changing gain, filters or sample rate.
|
||||
requested = round(args.frequency * 1e6)
|
||||
lo = sdr._ctx.find_device("ad9361-phy").find_channel("altvoltage1", True)
|
||||
available = lo.attrs.get("frequency_available")
|
||||
if available is not None:
|
||||
values = available.value.strip("[] ").split()
|
||||
if len(values) == 3:
|
||||
minimum, step, maximum = map(int, values)
|
||||
if not minimum <= requested <= maximum:
|
||||
raise RuntimeError(
|
||||
f"指定周波数 {args.frequency:g} MHz はボードのTX LO範囲 "
|
||||
f"{minimum / 1e6:g}–{maximum / 1e6:g} MHz外です。"
|
||||
"既存の周波数拡張設定を確認してください。ボード設定は変更していません。"
|
||||
)
|
||||
try:
|
||||
sdr.tx_enabled_channels = [0]
|
||||
sdr.tx_cyclic_buffer = False
|
||||
sdr.tx_hardwaregain_chan0 = -89.75
|
||||
sdr.sample_rate = IQ_RATE
|
||||
sdr.tx_rf_bandwidth = 300000 if args.stereo else 250000
|
||||
requested = round(args.frequency * 1e6)
|
||||
try:
|
||||
sdr.tx_lo = requested
|
||||
except Exception as error:
|
||||
raise RuntimeError("指定周波数を設定できません。ボードのLO範囲・既存の周波数拡張設定を確認してください。ファームウェアは変更していません。") from error
|
||||
if abs(int(sdr.tx_lo) - requested) > 10 or int(sdr.sample_rate) != IQ_RATE:
|
||||
raise RuntimeError("LOの読み戻しが10 Hzの許容差を超えるか、サンプルレートが指定値と一致しません")
|
||||
sdr.tx_hardwaregain_chan0 = args.gain
|
||||
return sdr
|
||||
except BaseException:
|
||||
sdr.tx_destroy_buffer()
|
||||
sdr.tx_hardwaregain_chan0 = -89.75
|
||||
raise
|
||||
|
||||
|
||||
def main():
|
||||
p = parser()
|
||||
args = p.parse_args()
|
||||
for valid, message in [
|
||||
(76 <= args.frequency <= 108, "周波数は76–108 MHzです"),
|
||||
(-89.75 <= args.gain <= 0, "gainは-89.75–0 dBです"),
|
||||
(0 <= args.volume <= 1, "volumeは0–1です"),
|
||||
(1 <= args.deviation <= 75000, "deviationは1–75000 Hzです"),
|
||||
(args.input.is_file(), "入力ファイルがありません"),
|
||||
(shutil.which("ffmpeg") is not None, "FFmpegをインストールしてください"),
|
||||
(args.dry_run is None or args.dry_run.resolve() != args.input.resolve(), "入力ファイルを上書きできません"),
|
||||
]:
|
||||
if not valid:
|
||||
p.error(message)
|
||||
sdr = None
|
||||
try:
|
||||
channels = 2 if args.stereo else 1
|
||||
mod = Modulator(args.deviation, args.volume, args.pre_emphasis, stereo=args.stereo)
|
||||
with decode(args.input, args.loop, channels) as stream, contextlib.ExitStack() as stack:
|
||||
output = stack.enter_context(args.dry_run.open("wb")) if args.dry_run else None
|
||||
count = 0
|
||||
start = time.monotonic()
|
||||
for audio in blocks(stream, channels):
|
||||
iq = mod.process(audio)
|
||||
if output:
|
||||
output.write(iq.astype("<c8", copy=False).tobytes())
|
||||
else:
|
||||
if sdr is None:
|
||||
sdr = connect(args)
|
||||
print(f"TX: {args.frequency:.6f} MHz, {args.gain} dB, {'stereo' if args.stereo else 'mono'}", file=sys.stderr)
|
||||
# Blocking libiio buffer pushes pace TX at the device sample rate.
|
||||
sdr.tx(iq)
|
||||
count += len(iq)
|
||||
print(f"完了: IQ {count / IQ_RATE:.2f}秒 / 処理 {time.monotonic() - start:.2f}秒", file=sys.stderr)
|
||||
except KeyboardInterrupt:
|
||||
print("停止しました", file=sys.stderr)
|
||||
except Exception as error:
|
||||
print(f"エラー: {error}", file=sys.stderr)
|
||||
return 1
|
||||
finally:
|
||||
if sdr is not None:
|
||||
try:
|
||||
sdr.tx_destroy_buffer()
|
||||
finally:
|
||||
sdr.tx_hardwaregain_chan0 = -89.75
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
@@ -0,0 +1,62 @@
|
||||
import numpy as np
|
||||
from scipy import signal
|
||||
|
||||
AUDIO_RATE = 48000
|
||||
IQ_RATE = 960000
|
||||
BLOCK = 4800
|
||||
INTERPOLATION = IQ_RATE // AUDIO_RATE
|
||||
|
||||
|
||||
class Modulator:
|
||||
"""Stateful mono or pilot-tone stereo FM encoder."""
|
||||
|
||||
def __init__(self, deviation=75000, volume=0.8, emphasis=50, stereo=False):
|
||||
self.deviation = deviation
|
||||
self.volume = volume
|
||||
self.stereo = stereo
|
||||
self.channels = 2 if stereo else 1
|
||||
self.phase = 0.0
|
||||
self.sample_count = 0
|
||||
self.previous = np.zeros(self.channels)
|
||||
self.emphasis = emphasis * 1e-6 * AUDIO_RATE
|
||||
self.sos = signal.butter(6, 15000, fs=AUDIO_RATE, output="sos")
|
||||
self.audio_state = np.zeros((len(self.sos), 2, self.channels))
|
||||
# Stereo needs a steep transition to protect the 19 kHz pilot.
|
||||
self.fir = (signal.firwin(1601, 16500, fs=IQ_RATE, window=("kaiser", 8))
|
||||
if stereo else signal.firwin(401, 18000, fs=IQ_RATE)) * INTERPOLATION
|
||||
self.tail = np.zeros((len(self.fir) - INTERPOLATION, self.channels))
|
||||
|
||||
def multiplex(self, audio):
|
||||
audio = np.asarray(audio, dtype=np.float64)
|
||||
if audio.ndim == 1:
|
||||
audio = audio[:, None]
|
||||
if audio.ndim != 2 or audio.shape[1] != self.channels or not len(audio):
|
||||
raise ValueError(f"音声は空でない (frames, {self.channels}) 配列が必要です")
|
||||
audio = np.nan_to_num(audio) * self.volume
|
||||
previous = np.concatenate((self.previous[None, :], audio[:-1]), axis=0)
|
||||
self.previous = audio[-1].copy()
|
||||
audio = audio + self.emphasis * (audio - previous)
|
||||
audio, self.audio_state = signal.sosfilt(self.sos, audio, axis=0, zi=self.audio_state)
|
||||
audio = np.clip(audio, -1, 1)
|
||||
# Polyphase interpolation plus overlap-add preserves the FIR across blocks.
|
||||
interpolated = signal.upfirdn(self.fir, audio, up=INTERPOLATION, axis=0)
|
||||
interpolated[:len(self.tail)] += self.tail
|
||||
count = len(audio) * INTERPOLATION
|
||||
self.tail = interpolated[count:].copy()
|
||||
interpolated = np.clip(interpolated[:count], -1, 1)
|
||||
if self.stereo:
|
||||
left, right = interpolated.T
|
||||
# Integer sample clock keeps pilot and subcarrier locked across blocks.
|
||||
angle = 2 * np.pi * ((np.arange(count) + self.sample_count) % IQ_RATE) * 19000 / IQ_RATE
|
||||
baseband = (0.9 * ((left + right) / 2 + (left - right) / 2 * np.sin(2 * angle))
|
||||
+ 0.1 * np.sin(angle))
|
||||
else:
|
||||
baseband = interpolated[:, 0]
|
||||
self.sample_count = (self.sample_count + count) % IQ_RATE
|
||||
return np.clip(baseband, -1, 1)
|
||||
|
||||
def process(self, audio):
|
||||
baseband = self.multiplex(audio)
|
||||
phases = self.phase + np.cumsum(baseband * (2 * np.pi * self.deviation / IQ_RATE))
|
||||
self.phase = phases[-1] % (2 * np.pi)
|
||||
return (np.exp(1j * phases) * (0.8 * 2**14)).astype(np.complex64)
|
||||
@@ -0,0 +1,68 @@
|
||||
import io
|
||||
import unittest
|
||||
import numpy as np
|
||||
from pluto_fm.dsp import Modulator, AUDIO_RATE, IQ_RATE
|
||||
from pluto_fm.cli import blocks
|
||||
|
||||
|
||||
class StreamingTests(unittest.TestCase):
|
||||
def test_chunk_continuity_and_deviation(self):
|
||||
audio = 0.01 * np.sin(2 * np.pi * 1000 * np.arange(9600) / AUDIO_RATE)
|
||||
whole = Modulator().process(audio)
|
||||
mod = Modulator()
|
||||
split = np.concatenate([mod.process(audio[:4800]), mod.process(audio[4800:])])
|
||||
np.testing.assert_allclose(whole, split, atol=0.002)
|
||||
frequency = np.angle(split[1:] * split[:-1].conj()) * IQ_RATE / (2 * np.pi)
|
||||
self.assertLessEqual(np.max(np.abs(frequency)), 75001)
|
||||
np.testing.assert_allclose(np.abs(split), 0.8 * 2**14, atol=0.003)
|
||||
# Demodulated tone has the requested frequency, after filter settling.
|
||||
spectrum = np.abs(np.fft.rfft(frequency[10000:]))
|
||||
peak = np.argmax(spectrum) * IQ_RATE / len(frequency[10000:])
|
||||
self.assertLess(abs(peak - 1000), 10)
|
||||
|
||||
def test_stereo_separation_and_pilot(self):
|
||||
from scipy import signal
|
||||
t = np.arange(AUDIO_RATE) / AUDIO_RATE
|
||||
audio = np.column_stack((0.2 * np.sin(2 * np.pi * 1000 * t),
|
||||
0.2 * np.sin(2 * np.pi * 2300 * t)))
|
||||
iq = Modulator(volume=1, emphasis=0, stereo=True).process(audio)
|
||||
mpx = np.angle(iq[1:] * iq[:-1].conj()) * IQ_RATE / (2 * np.pi * 75000)
|
||||
clock = np.arange(1, len(iq)) / IQ_RATE
|
||||
lowpass = signal.butter(8, 15000, fs=IQ_RATE, output='sos')
|
||||
summed = signal.sosfiltfilt(lowpass, mpx)
|
||||
difference = signal.sosfiltfilt(lowpass, 2 * mpx * np.sin(2 * np.pi * 38000 * clock))
|
||||
left, right = summed + difference, summed - difference
|
||||
section = slice(IQ_RATE // 10, -IQ_RATE // 10)
|
||||
def amplitude(samples, frequency):
|
||||
return abs(2 * np.mean(samples[section] * np.exp(-2j * np.pi * frequency * clock[section])))
|
||||
self.assertAlmostEqual(amplitude(mpx, 19000), 0.1, places=4)
|
||||
self.assertLess(amplitude(mpx, 38000), 0.001)
|
||||
self.assertGreater(amplitude(left, 1000), 0.17)
|
||||
self.assertGreater(amplitude(right, 2300), 0.17)
|
||||
self.assertLess(amplitude(left, 2300), 0.001)
|
||||
self.assertLess(amplitude(right, 1000), 0.001)
|
||||
self.assertLessEqual(np.max(np.abs(mpx)), 1.0001)
|
||||
|
||||
def test_stereo_chunk_continuity(self):
|
||||
audio = np.random.default_rng(3).normal(0, 0.05, (9600, 2))
|
||||
expected = Modulator(stereo=True).process(audio)
|
||||
mod = Modulator(stereo=True)
|
||||
actual = np.concatenate([mod.process(audio[:4799]), mod.process(audio[4799:])])
|
||||
np.testing.assert_allclose(actual, expected, atol=0.002)
|
||||
|
||||
def test_stereo_final_padding(self):
|
||||
audio = np.array([[1, 2], [3, 4]], dtype='<f4')
|
||||
block = next(blocks(io.BytesIO(audio.tobytes()), channels=2))
|
||||
self.assertEqual(block.shape, (4800, 2))
|
||||
np.testing.assert_array_equal(block[:2], audio)
|
||||
self.assertEqual(np.count_nonzero(block[2:]), 0)
|
||||
|
||||
def test_final_block_padding(self):
|
||||
data = list(blocks(io.BytesIO(np.ones(4801, dtype='<f4').tobytes())))
|
||||
self.assertEqual(len(data), 2)
|
||||
self.assertEqual(data[1][0], 1)
|
||||
self.assertEqual(np.count_nonzero(data[1]), 1)
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
unittest.main()
|
||||
Reference in new issue
Block a user