chenpi 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
chenpi/USAGE.md ADDED
@@ -0,0 +1,135 @@
1
+ # chenpi の使い方 (このライブラリを使うコードを書く人・エージェント向け)
2
+
3
+ chenpi は DS / 3DS 用キャプチャ基板の映像・音声を numpy / OpenCV の画像として読む **Windows 専用** のライブラリ。
4
+ `cv2.VideoCapture` と同じ形 (`read()` → `(ok, frame)`, `isOpened()`, `release()`) で使える。
5
+ このファイルはインストール先 (`site-packages/chenpi/USAGE.md`) にも入っている。
6
+
7
+ ## 対応機種
8
+
9
+ | kind | 機種 | クラス | 画面 |
10
+ |---|---|---|---|
11
+ | `"ds"` | 新型 Loopy DS キャプチャ (USB Type-C 版, FTDI) | `LoopyDSCapture` | 上下とも 256x192 |
12
+ | `"3ds"` | Optimize 3DS キャプチャ Old 3DS 用 (Cypress) | `Optimize3DSCapture` | 上 400x240、下 320x240 |
13
+
14
+ どちらも約 60fps、音声はステレオ int16 / `SAMPLE_RATE` (≒ 32728.5 Hz)。
15
+ 未対応: Optimize の New 3DS 用 / 2DS 用、RGB888、3D 表示、Loopy 製 3DS キャプチャ。
16
+
17
+ ## 最小の例
18
+
19
+ ```python
20
+ import cv2
21
+ from chenpi import open_capture
22
+
23
+ cv2.setNumThreads(1) # 毎フレーム OpenCV で処理するなら推奨 (下の「注意」参照)
24
+ with open_capture("auto") as cap: # "ds" / "3ds" も可。見つからなければ RuntimeError など
25
+ while True:
26
+ ok, frame = cap.read() # BGR uint8 の numpy 配列
27
+ if not ok:
28
+ if not cap.isOpened(): # 切断・受信エラー
29
+ break
30
+ continue # 1 秒以内に新しいフレームが来なかった (本体の電源が切れている等)
31
+ cv2.imshow("chenpi", frame)
32
+ if cv2.waitKey(1) & 0xFF == ord("q"):
33
+ break
34
+ ```
35
+
36
+ ## 公開 API
37
+
38
+ `from chenpi import ...` で使えるもの。これ以外 (先頭が `_` のもの、各モジュール内の関数) は内部用。
39
+
40
+ ### 開く
41
+
42
+ - `open_capture(device="auto", key=None, **kwargs) -> LoopyDSCapture | Optimize3DSCapture`
43
+ - `device`: `"auto"` (DS → 3DS の順に、空いている機器を探す) / `"ds"` / `"3ds"`。`DEVICES` にこの 3 つが入っている。
44
+ - `key`: Optimize 3DS のプロダクトキー (後述)。DS では使わない。
45
+ - `kwargs`: `layout`, `swap_rb`, `firmware_dir` (両クラス共通の引数)。
46
+ - `list_devices() -> list[DeviceInfo]`: 接続中の機器の一覧。**機器の状態を変えない** (ファーム書き込み等をしない) ので何度呼んでもよい。
47
+ - `open_device(info, key=None, **kwargs)`: `list_devices()` で選んだ 1 台を開く。GUI のコンボボックス向け。
48
+ - `DeviceInfo` (frozen dataclass): `kind` ("ds"/"3ds"), `name`, `id`, `path`, `status`, `needs_key`, `available` (property)。
49
+ - `status`: `"ready"` / `"in_use"` (他のプログラムが開いている) / `"replug"` (Optimize を公式ソフトで使った後。USB の抜き差しが必要)。
50
+ - `id`: DS は FTDI のシリアル。**Optimize は開くまで分からないので None** (開いた後の `cap.serial` がデバイス ID)。
51
+ - `str(info)` が表示用の文字列 (例 `"Optimize Old 3DS [使用中]"`)。
52
+
53
+ ### クラスを直接使う
54
+
55
+ - `LoopyDSCapture(serial=None, layout="vertical", firmware_dir=None, swap_rb=False, dll_path="ftd2xx.dll")`
56
+ - `serial` を指定するとその機器だけを開く (見つからなければ FTError。別の機器にはフォールバックしない)。
57
+ - `Optimize3DSCapture(key=None, path=None, layout="vertical", firmware_dir=None, swap_rb=False)`
58
+ - `key` 省略時は環境変数 `OPTIMIZE_3DS_KEY`。**キーが無いと開けない** (キーなしだと約 4 分で転送が止まる機器のため)。
59
+ - `key` は文字列 (カンマ・セミコロン・空白区切りで複数可) か文字列のリスト。デバイス ID が合うものが使われる。
60
+ - `path` 省略時は、空いていてキーの合う 1 台を探して開く。
61
+
62
+ ### 開いた後 (両クラス共通)
63
+
64
+ | メンバー | 内容 |
65
+ |---|---|
66
+ | `read(timeout=1.0) -> (bool, ndarray \| None)` | **次の新しいフレーム** を待って返す。同じフレームを二度は返さない。timeout 秒来なければ `(False, None)` |
67
+ | `read_audio() -> ndarray` | 前回の呼び出し以降の音声。int16, shape `(N, 2)` [L, R]。待たない (無ければ N=0)。約 2 秒分を超えて読まれない分は古い順に捨てる |
68
+ | `isOpened() -> bool` | 受信中か。切断・エラーで False |
69
+ | `release()` | 閉じる。`with` 文でも閉じる |
70
+ | `volume` (float, 既定 1.0) | `read_audio()` にかかる倍率。0 で無音、1 超で増幅 (int16 の範囲で頭打ち)。いつ変えてもよい |
71
+ | `layout` | `"vertical"` / `"horizontal"` / `"top"` / `"bottom"` |
72
+ | `serial` | DS: FTDI シリアル。3DS: デバイス ID (16 桁の 16 進) |
73
+ | `description` | 機種名 (`"NDS.2"` / `"Optimize Old 3DS"` など) |
74
+
75
+ `read()` が返す frame の shape (高さ, 幅, 3):
76
+
77
+ | layout | DS | 3DS |
78
+ |---|---|---|
79
+ | vertical (上に上画面) | (384, 256, 3) | (480, 400, 3) 下画面は左右に 40px の黒帯 |
80
+ | horizontal (左に上画面) | (192, 512, 3) | (240, 720, 3) |
81
+ | top | (192, 256, 3) | (240, 400, 3) |
82
+ | bottom | (192, 256, 3) | (240, 320, 3) |
83
+
84
+ ### ファームウェア
85
+
86
+ 基板に書き込むファームウェアは **著作権の都合で同梱していない**。初回は cc3dsfs から取得する必要がある。
87
+
88
+ - 無い状態で開くと `FirmwareMissing` (`FileNotFoundError` のサブクラス)。`e.names` に足りないファイル名。
89
+ - `download_firmware(names=None, dest=None, progress=print) -> Path`: cc3dsfs の固定コミットから取得し、SHA-256 を照合して保存。
90
+ 既定の保存先は `user_firmware_dir()` (`%LOCALAPPDATA%\chenpi\firmware`)。
91
+ - `missing_firmware(kind=None) -> list[str]`: 足りないファイル名 (`kind` は "ds" / "3ds" / None)。
92
+ - **利用者に確認せずにダウンロードしないこと** (第三者のファイルをネットから取得するため)。例:
93
+
94
+ ```python
95
+ from chenpi import FirmwareMissing, download_firmware, open_capture
96
+
97
+ try:
98
+ cap = open_capture("auto", key=keys)
99
+ except FirmwareMissing as e:
100
+ if ask_user(f"{e.names} を cc3dsfs から取得しますか?"):
101
+ download_firmware()
102
+ cap = open_capture("auto", key=keys)
103
+ ```
104
+
105
+ ### 例外
106
+
107
+ | 例外 | 主な原因 | 対処 |
108
+ |---|---|---|
109
+ | `FirmwareMissing` | ファームウェアが無い | 利用者に確認して `download_firmware()` |
110
+ | `OptimizeError` | 3DS: キーが無い・合わない、機器が無い、使用中、要抜き差し、切断 | メッセージを利用者に見せる |
111
+ | `FTError` | DS: 機器が無い、使用中、FPGA 設定の失敗 | 同上 |
112
+ | `RuntimeError` | `open_capture("auto")` でどの機器も開けなかった (各機種の理由をまとめたメッセージ) | 同上 |
113
+ | `OSError` | DS 用の `ftd2xx.dll` (FTDI ドライバ) が無い | ドライバの導入を案内 |
114
+
115
+ 受信中の切断は例外にならない。`read()` が `(False, None)` を返し、`isOpened()` が False になる。
116
+
117
+ ### 低レベル
118
+
119
+ - `SAMPLE_RATE`: 音声のサンプルレート (DS / 3DS 共通)。
120
+ - `parse_frame(raw, swap_rb=False)` / `parse_audio(raw)`: Loopy DS の生データ 1 フレームの解析 (テスト・解析用)。
121
+
122
+ ## 注意 (よくある間違い)
123
+
124
+ - **`cv2.setNumThreads(1)`**: 毎フレーム `cv2.resize` / `imshow` などをするアプリでは、最初に呼ぶと CPU 使用率が大きく下がる
125
+ (OpenCV のスレッドが空回りするため。1 コア約 9 割 → 約 3 割)。プロセス全体の設定なのでライブラリは変えていない。
126
+ - **スレッド**: `read()` は新しいフレームまで待つ (最大 `timeout` 秒)。GUI のメインスレッドで呼ばず、別スレッドで回す。
127
+ `volume` の変更や `read_audio()` は他のスレッドからでよい。
128
+ - **同時に 1 プログラムだけ**: 同じ機器を 2 つのプロセスで開くことはできない (公式ソフト・cc3dsfs とも)。機器を切り替えるときは先に `release()`。
129
+ - **Optimize 3DS**:
130
+ - 公式ソフトを使った後は USB の抜き差しが必要 (`status == "replug"`)。
131
+ - 3DS 本体のサウンド設定は「ステレオ」に。サラウンドだと PC で聞くとこもる。本体の音量スライダーはキャプチャの音に効かない (`volume` で調整)。
132
+ - **フレームの時刻**: `read()` の戻り値は `(ok, frame)` から変えない方針 (cv2 互換)。受信時刻が必要なら将来 `get(cv2.CAP_PROP_POS_MSEC)` で提供する予定 (未実装)。
133
+ - **音声の再生**: 音声は約 16.7ms (1 フレーム) ごとにまとめて届く。sounddevice などで鳴らすなら、25ms 程度溜めてから再生を始めないとプツプツ途切れる
134
+ (実装例: このパッケージの `__main__.py` の `_play_audio`)。
135
+ - ビューア `chenpi-view` (`python -m chenpi`) と仮想カメラ `chenpi-virtualcam` はコマンドとして入る。`--help` で使い方。
chenpi/__init__.py ADDED
@@ -0,0 +1,21 @@
1
+ """chenpi: DS / 3DS 用キャプチャ基板を cv2.VideoCapture と同じ形で読む Windows 専用ライブラリ。
2
+
3
+ 詳しい使い方 (API の一覧・frame の大きさ・例外・注意点) は、このファイルと同じフォルダの USAGE.md を読むこと。
4
+
5
+ from chenpi import open_capture
6
+ with open_capture("auto") as cap: # "ds" (Loopy DS) / "3ds" (Optimize Old 3DS, キー必須)
7
+ ok, frame = cap.read() # 次の新しいフレームを待つ。BGR uint8
8
+ audio = cap.read_audio() # int16 (N, 2) [L, R], SAMPLE_RATE Hz
9
+
10
+ - 機器の一覧から選ぶなら list_devices() → open_device(info)。
11
+ - ファームウェアは同梱していない。無ければ FirmwareMissing になるので、利用者に確認してから download_firmware()。
12
+ """
13
+
14
+ from .capture import SAMPLE_RATE, FTError, LoopyDSCapture, parse_audio, parse_frame
15
+ from .devices import DEVICES, DeviceInfo, list_devices, open_capture, open_device
16
+ from .firmware import FirmwareMissing, download_firmware, missing_firmware, user_firmware_dir
17
+ from .optimize3ds import Optimize3DSCapture, OptimizeError
18
+
19
+ __all__ = ["LoopyDSCapture", "Optimize3DSCapture", "DeviceInfo", "list_devices", "open_device", "open_capture",
20
+ "DEVICES", "FTError", "OptimizeError", "FirmwareMissing", "download_firmware", "missing_firmware",
21
+ "user_firmware_dir", "SAMPLE_RATE", "parse_audio", "parse_frame"]
chenpi/__main__.py ADDED
@@ -0,0 +1,148 @@
1
+ """`uv run chenpi-view` / `python -m chenpi` で起動する確認用ビューア。"""
2
+
3
+ import argparse
4
+ import threading
5
+ import time
6
+
7
+
8
+ def _play_audio(cap, stop, latency=0.05, prefill=0.025, stats=None):
9
+ """受信した音声を既定の出力デバイスに流す。映像とずれないよう、未再生分は少しだけ持つ。
10
+
11
+ 音声は 1 フレーム (約 16.7ms) ごとにまとめて届くので、到着のゆらぎで手元が空になると無音が挟まって
12
+ プツッと鳴る。prefill 秒ぶん溜めてから鳴らし始め、空になったらまた溜め直す。
13
+ stats に dict を渡すと、無音で埋めた回数 (starved) と古い分を捨てた回数 (dropped) を数える (診断用)。
14
+ """
15
+ import numpy as np
16
+ import sounddevice as sd
17
+
18
+ from .capture import SAMPLE_RATE
19
+
20
+ target = int(SAMPLE_RATE * prefill)
21
+ max_pending = int(SAMPLE_RATE * 0.08) # これを超えたら古い分を捨てる (遅延の積み上がり・時計ずれ対策)
22
+ lock = threading.Lock()
23
+ pending = np.zeros((0, 2), np.int16)
24
+ playing = False
25
+ if stats is not None:
26
+ stats.update(starved=0, dropped=0)
27
+
28
+ def callback(outdata, frames, _time, _status):
29
+ nonlocal pending, playing
30
+ with lock:
31
+ if not playing and len(pending) >= target:
32
+ playing = True
33
+ n = min(frames, len(pending)) if playing else 0
34
+ outdata[:n] = pending[:n]
35
+ pending = pending[n:]
36
+ if playing and n < frames: # 底をついた: 無音で埋めて、また溜め直す
37
+ playing = False
38
+ if stats is not None:
39
+ stats["starved"] += 1
40
+ outdata[n:] = 0
41
+
42
+ with sd.OutputStream(samplerate=round(SAMPLE_RATE), channels=2, dtype="int16",
43
+ latency=latency, callback=callback):
44
+ while not stop.is_set() and cap.isOpened():
45
+ audio = cap.read_audio()
46
+ if len(audio):
47
+ with lock:
48
+ pending = np.concatenate((pending, audio))
49
+ if len(pending) > max_pending:
50
+ pending = pending[-target:]
51
+ if stats is not None:
52
+ stats["dropped"] += 1
53
+ time.sleep(0.002)
54
+
55
+
56
+ def _open_or_explain(opener):
57
+ """機器を開く。ファームウェアが無ければ取得してよいか尋ね、想定内のエラーはメッセージだけ出して終わる。"""
58
+ from . import FirmwareMissing, FTError, OptimizeError, download_firmware
59
+
60
+ while True:
61
+ try:
62
+ return opener()
63
+ except FirmwareMissing as e:
64
+ print(f"ファームウェアが見つかりません: {', '.join(e.names)}")
65
+ print("これらは cc3dsfs (https://github.com/Lorenzooone/cc3dsfs) で公開されているファイルです"
66
+ " (著作権は各メーカー)。")
67
+ if input("cc3dsfs からダウンロードしますか? [y/N] ").strip().lower() != "y":
68
+ raise SystemExit(1)
69
+ download_firmware()
70
+ except (FTError, OptimizeError, RuntimeError) as e:
71
+ raise SystemExit(f"エラー: {e}")
72
+
73
+
74
+ def main() -> None:
75
+ import cv2
76
+
77
+ from . import DEVICES, list_devices, open_capture
78
+
79
+ # OpenCV は小さな処理も複数スレッドに分け、終わった後もスレッドが空回りする。毎フレームの拡大・表示では
80
+ # これだけで 1 コアの 6 割ほどを食っていた (実機で計測) ので、1 スレッドにする
81
+ cv2.setNumThreads(1)
82
+
83
+ ap = argparse.ArgumentParser(description="chenpi viewer")
84
+ ap.add_argument("--list", action="store_true", help="接続されている機器の一覧を表示して終了")
85
+ ap.add_argument("--device", default="auto", choices=DEVICES, help="ds: Loopy DS, 3ds: Optimize Old 3DS")
86
+ ap.add_argument("--key", help="Optimize 3DS のプロダクトキー (省略時は環境変数 OPTIMIZE_3DS_KEY)")
87
+ ap.add_argument("--layout", default="vertical", choices=["vertical", "horizontal", "top", "bottom"])
88
+ ap.add_argument("--scale", type=int, default=2)
89
+ ap.add_argument("--swap-rb", action="store_true", help="赤と青が入れ替わって見えるとき")
90
+ ap.add_argument("--audio", action="store_true", help="音声も再生する (uv sync --extra audio が必要)")
91
+ ap.add_argument("--audio-latency", type=float, default=0.05,
92
+ help="音声出力バッファ (秒)。音が途切れるなら大きく、映像より遅れるなら小さく")
93
+ ap.add_argument("--volume", type=float, default=1.0, help="音量の倍率 (再生中も + / - キーで変えられる)")
94
+ args = ap.parse_args()
95
+
96
+ if args.list:
97
+ infos = list_devices()
98
+ for info in infos:
99
+ print(f"{info.kind:3} {info}")
100
+ if not infos:
101
+ print("キャプチャ機器が見つかりません")
102
+ return
103
+
104
+ cap = _open_or_explain(lambda: open_capture(args.device, key=args.key, layout=args.layout,
105
+ swap_rb=args.swap_rb))
106
+ with cap:
107
+ print(f"接続: {cap.description} (serial {cap.serial}) q で終了"
108
+ + ("、+ / - で音量" if args.audio else ""))
109
+ cap.volume = args.volume
110
+ stop = threading.Event()
111
+ if args.audio:
112
+ threading.Thread(target=_play_audio, args=(cap, stop, args.audio_latency), daemon=True).start()
113
+
114
+ def handle_key():
115
+ """押されたキーを処理する。終了なら True。"""
116
+ key = cv2.waitKey(1) & 0xFF
117
+ if key in (ord("+"), ord("="), ord(";")): # JIS 配列では + は Shift+; なので ; も受け付ける
118
+ cap.volume = round(min(cap.volume + 0.1, 4.0), 2)
119
+ print(f"音量 {cap.volume:.1f}")
120
+ elif key == ord("-"):
121
+ cap.volume = round(max(cap.volume - 0.1, 0.0), 2)
122
+ print(f"音量 {cap.volume:.1f}")
123
+ return key == ord("q")
124
+
125
+ waiting = False
126
+ while True:
127
+ ok, frame = cap.read()
128
+ if not ok:
129
+ if not cap.isOpened():
130
+ print(f"受信エラー: {cap._error}")
131
+ break
132
+ if not waiting:
133
+ print("信号待ち... (本体の電源が入っているか確認してください)")
134
+ waiting = True
135
+ if handle_key():
136
+ break
137
+ continue
138
+ waiting = False
139
+ cv2.imshow("chenpi", cv2.resize(frame, None, fx=args.scale, fy=args.scale,
140
+ interpolation=cv2.INTER_NEAREST))
141
+ if handle_key():
142
+ break
143
+ stop.set()
144
+ cv2.destroyAllWindows()
145
+
146
+
147
+ if __name__ == "__main__":
148
+ main()