swap-cli 0.1.1__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.
swap_cli/config.py ADDED
@@ -0,0 +1,152 @@
1
+ """Local config: Decart API key + license key, stored in user config dir."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import hashlib
6
+ import os
7
+ import platform
8
+ import tomllib
9
+ import uuid
10
+ from dataclasses import dataclass
11
+ from pathlib import Path
12
+ from typing import Any
13
+
14
+ from platformdirs import user_config_dir
15
+
16
+ APP_NAME = "swap-cli"
17
+ CONFIG_FILENAME = "config.toml"
18
+
19
+
20
+ @dataclass(frozen=True)
21
+ class Config:
22
+ """User configuration. Read-only at runtime."""
23
+
24
+ license_key: str | None
25
+ decart_api_key: str | None
26
+ license_cached_at: int | None # unix seconds; for offline grace
27
+ license_cached_valid_until: int | None # unix seconds
28
+ # Voice cloning preferences (sprint 13). All optional — older config files
29
+ # without these fields load fine. voice_enabled is the sticky toggle state.
30
+ voice_enabled: bool = False
31
+ last_voice_id: str | None = None
32
+ last_microphone: int | None = None
33
+ last_voice_output: int | None = None
34
+ # Sprint 14e: voice path is RVC-only. Field kept for forward
35
+ # compatibility (future engines: Applio, GPT-SoVITS).
36
+ voice_engine: str = "rvc"
37
+ # Sprint 14i: when True, the streaming engine sets index_rate=0
38
+ # (skip Faiss retrieval). Trades timbre quality for big speedup —
39
+ # essential on weak GPUs or when using voices with huge .index files.
40
+ voice_fast: bool = False
41
+
42
+ @property
43
+ def is_complete(self) -> bool:
44
+ return bool(self.license_key) and bool(self.decart_api_key)
45
+
46
+
47
+ def config_path() -> Path:
48
+ return Path(user_config_dir(APP_NAME)) / CONFIG_FILENAME
49
+
50
+
51
+ def load() -> Config:
52
+ """Load config from disk. Returns an empty config if file is missing."""
53
+ path = config_path()
54
+ if not path.exists():
55
+ return Config(None, None, None, None)
56
+ try:
57
+ data = tomllib.loads(path.read_text("utf-8"))
58
+ except (tomllib.TOMLDecodeError, OSError):
59
+ return Config(None, None, None, None)
60
+
61
+ return Config(
62
+ license_key=_clean(data.get("license_key")),
63
+ decart_api_key=_clean(data.get("decart_api_key")),
64
+ license_cached_at=_int_or_none(data.get("license_cached_at")),
65
+ license_cached_valid_until=_int_or_none(data.get("license_cached_valid_until")),
66
+ voice_enabled=bool(data.get("voice_enabled", False)),
67
+ last_voice_id=_clean(data.get("last_voice_id")),
68
+ last_microphone=_int_or_none(data.get("last_microphone")),
69
+ last_voice_output=_int_or_none(data.get("last_voice_output")),
70
+ voice_engine=_clean(data.get("voice_engine")) or "rvc",
71
+ voice_fast=bool(data.get("voice_fast", False)),
72
+ )
73
+
74
+
75
+ def save(cfg: Config) -> Path:
76
+ """Write config atomically with restrictive perms."""
77
+ path = config_path()
78
+ path.parent.mkdir(parents=True, exist_ok=True)
79
+
80
+ body: list[str] = []
81
+ if cfg.license_key:
82
+ body.append(f'license_key = "{_escape(cfg.license_key)}"')
83
+ if cfg.decart_api_key:
84
+ body.append(f'decart_api_key = "{_escape(cfg.decart_api_key)}"')
85
+ if cfg.license_cached_at is not None:
86
+ body.append(f"license_cached_at = {cfg.license_cached_at}")
87
+ if cfg.license_cached_valid_until is not None:
88
+ body.append(f"license_cached_valid_until = {cfg.license_cached_valid_until}")
89
+ if cfg.voice_enabled:
90
+ body.append("voice_enabled = true")
91
+ if cfg.last_voice_id:
92
+ body.append(f'last_voice_id = "{_escape(cfg.last_voice_id)}"')
93
+ if cfg.last_microphone is not None:
94
+ body.append(f"last_microphone = {cfg.last_microphone}")
95
+ if cfg.last_voice_output is not None:
96
+ body.append(f"last_voice_output = {cfg.last_voice_output}")
97
+ if cfg.voice_engine and cfg.voice_engine != "rvc":
98
+ body.append(f'voice_engine = "{_escape(cfg.voice_engine)}"')
99
+ if cfg.voice_fast:
100
+ body.append("voice_fast = true")
101
+
102
+ text = "\n".join(body) + "\n"
103
+ tmp = path.with_suffix(path.suffix + ".tmp")
104
+ tmp.write_text(text, encoding="utf-8")
105
+ if os.name != "nt":
106
+ os.chmod(tmp, 0o600)
107
+ tmp.replace(path)
108
+ return path
109
+
110
+
111
+ def update(**kwargs: Any) -> Config:
112
+ """Patch the on-disk config with new values."""
113
+ current = load()
114
+ merged = Config(
115
+ license_key=kwargs.get("license_key", current.license_key),
116
+ decart_api_key=kwargs.get("decart_api_key", current.decart_api_key),
117
+ license_cached_at=kwargs.get("license_cached_at", current.license_cached_at),
118
+ license_cached_valid_until=kwargs.get(
119
+ "license_cached_valid_until", current.license_cached_valid_until
120
+ ),
121
+ voice_enabled=kwargs.get("voice_enabled", current.voice_enabled),
122
+ last_voice_id=kwargs.get("last_voice_id", current.last_voice_id),
123
+ last_microphone=kwargs.get("last_microphone", current.last_microphone),
124
+ last_voice_output=kwargs.get("last_voice_output", current.last_voice_output),
125
+ voice_engine=kwargs.get("voice_engine", current.voice_engine),
126
+ voice_fast=kwargs.get("voice_fast", current.voice_fast),
127
+ )
128
+ save(merged)
129
+ return merged
130
+
131
+
132
+ def machine_id() -> str:
133
+ """Stable, hashed device identifier. Never sends raw MAC/serial off-device."""
134
+ raw = f"{uuid.getnode()}|{platform.node()}|{platform.machine()}|{platform.system()}"
135
+ return hashlib.sha256(raw.encode("utf-8")).hexdigest()[:32]
136
+
137
+
138
+ def _clean(value: Any) -> str | None:
139
+ if not isinstance(value, str):
140
+ return None
141
+ stripped = value.strip()
142
+ return stripped or None
143
+
144
+
145
+ def _int_or_none(value: Any) -> int | None:
146
+ if isinstance(value, int):
147
+ return value
148
+ return None
149
+
150
+
151
+ def _escape(value: str) -> str:
152
+ return value.replace("\\", "\\\\").replace('"', '\\"')
swap_cli/devices.py ADDED
@@ -0,0 +1,216 @@
1
+ """Cross-platform camera enumeration.
2
+
3
+ We probe up to MAX_CAMERAS by opening each index in a SUBPROCESS so a
4
+ native crash (common on Alienware/IR cameras with OpenCV) only kills
5
+ the probe — not the GUI.
6
+
7
+ Friendly names:
8
+ - Windows: pygrabber's DirectShow filter list ("Logitech BRIO", etc.)
9
+ - Linux: /sys/class/video4linux/videoN/name
10
+ - macOS: AVFoundation gives us no easy name list, fall back to generic
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ import platform
16
+ import subprocess
17
+ import sys
18
+ from dataclasses import dataclass
19
+ from pathlib import Path
20
+
21
+ MAX_CAMERAS = 6
22
+ PROBE_TIMEOUT_S = 3.0
23
+
24
+
25
+ @dataclass(frozen=True)
26
+ class CameraDevice:
27
+ index: int
28
+ label: str
29
+ # Sprint 14o: True when the device's name matches a virtual-camera
30
+ # driver (OBS / Snap / ManyCam / DroidCam / XSplit / generic). The
31
+ # GUI deprioritises these in auto-pick to avoid the feedback loop:
32
+ # swap reading from OBS Virtual Camera while also writing to it.
33
+ virtual: bool = False
34
+
35
+
36
+ # Virtual-camera deny list. Sprint 14o: user hit a feedback loop where
37
+ # swap auto-picked OBS Virtual Camera (because the real webcam probe
38
+ # timed out) and then also wrote its output to it — Lucy was consuming
39
+ # its own previous frames. Mirror voice_router's Sound Mapper pattern.
40
+ _VIRTUAL_CAMERA_NEEDLES = (
41
+ "obs virtual",
42
+ "virtual camera",
43
+ "virtual webcam",
44
+ "snap camera",
45
+ "manycam",
46
+ "xsplit",
47
+ "droidcam",
48
+ "e2esoft",
49
+ "iriun",
50
+ )
51
+
52
+
53
+ def is_virtual_camera(name: str) -> bool:
54
+ """True iff the device name matches a known virtual-camera driver."""
55
+ n = (name or "").lower()
56
+ return any(needle in n for needle in _VIRTUAL_CAMERA_NEEDLES)
57
+
58
+
59
+ # Inline probe script — runs in a child Python process so an OpenCV
60
+ # native crash (access violation on e.g. Alienware IR cameras) only kills
61
+ # the child, not the GUI.
62
+ _PROBE_SCRIPT = """
63
+ import sys
64
+ import cv2
65
+ idx = int(sys.argv[1])
66
+ backend = int(sys.argv[2])
67
+ cap = cv2.VideoCapture(idx, backend)
68
+ try:
69
+ if not cap.isOpened():
70
+ sys.exit(2)
71
+ ok, _ = cap.read()
72
+ sys.exit(0 if ok else 3)
73
+ finally:
74
+ cap.release()
75
+ """
76
+
77
+
78
+ def _backend_for_platform() -> int:
79
+ """Pick the most stable cv2 capture backend for the current OS."""
80
+ import cv2
81
+
82
+ if sys.platform == "win32":
83
+ return cv2.CAP_DSHOW
84
+ if sys.platform == "darwin":
85
+ return cv2.CAP_AVFOUNDATION
86
+ return cv2.CAP_V4L2
87
+
88
+
89
+ def _probe_one(index: int, backend: int) -> tuple[bool, str]:
90
+ """Probe a single camera index in an isolated subprocess.
91
+
92
+ Returns (ok, reason). reason is one of:
93
+ "" - probe succeeded
94
+ "timeout" - cv2.VideoCapture blocked > PROBE_TIMEOUT_S; most
95
+ common cause is another app holding the camera
96
+ (Zoom/Teams/Discord/browsers)
97
+ "not_present" - probe returned non-zero exit; the index doesn't
98
+ map to a real device
99
+ "error:<msg>" - subprocess raised something unexpected
100
+ """
101
+ try:
102
+ proc = subprocess.run(
103
+ [sys.executable, "-c", _PROBE_SCRIPT, str(index), str(backend)],
104
+ capture_output=True,
105
+ timeout=PROBE_TIMEOUT_S,
106
+ )
107
+ if proc.returncode == 0:
108
+ return True, ""
109
+ return False, "not_present"
110
+ except subprocess.TimeoutExpired:
111
+ return False, "timeout"
112
+ except Exception as err: # noqa: BLE001
113
+ return False, f"error:{err}"
114
+
115
+
116
+ def _windows_friendly_names() -> dict[int, str]:
117
+ """Return {index: friendly name} from DirectShow's filter list.
118
+
119
+ pygrabber wraps the same DirectShow API OBS / Discord / Zoom use, so
120
+ the names match what users see in those apps. Returns {} if the
121
+ library is missing or anything goes sideways.
122
+ """
123
+ try:
124
+ from pygrabber.dshow_graph import FilterGraph # type: ignore[import-not-found]
125
+
126
+ graph = FilterGraph()
127
+ names = graph.get_input_devices()
128
+ return {i: name for i, name in enumerate(names)}
129
+ except Exception as err: # noqa: BLE001
130
+ print(f"[devices] friendly-name lookup unavailable: {err}", flush=True)
131
+ return {}
132
+
133
+
134
+ def _linux_friendly_names() -> dict[int, str]:
135
+ """Read /sys/class/video4linux/videoN/name on Linux. Empty dict on failure."""
136
+ out: dict[int, str] = {}
137
+ base = Path("/sys/class/video4linux")
138
+ if not base.exists():
139
+ return out
140
+ try:
141
+ for entry in base.glob("video*"):
142
+ stem = entry.name.removeprefix("video")
143
+ if not stem.isdigit():
144
+ continue
145
+ idx = int(stem)
146
+ name_file = entry / "name"
147
+ if name_file.exists():
148
+ out[idx] = name_file.read_text(encoding="utf-8", errors="replace").strip()
149
+ except Exception as err: # noqa: BLE001
150
+ print(f"[devices] /sys readout failed: {err}", flush=True)
151
+ return out
152
+
153
+
154
+ def _friendly_names() -> dict[int, str]:
155
+ if sys.platform == "win32":
156
+ return _windows_friendly_names()
157
+ if sys.platform.startswith("linux"):
158
+ return _linux_friendly_names()
159
+ return {}
160
+
161
+
162
+ def enumerate_cameras() -> list[CameraDevice]:
163
+ """Return working cameras with their friendly names where possible.
164
+
165
+ Sprint 14o: each device now carries a `virtual` flag so the GUI can
166
+ deprioritise OBS/Snap/etc. when picking the default input. Probe
167
+ failures distinguish timeout (camera held by another app) from
168
+ not_present (no device at that index).
169
+ """
170
+ backend = _backend_for_platform()
171
+ names = _friendly_names()
172
+ if names:
173
+ print(f"[devices] friendly names: {names}", flush=True)
174
+
175
+ devices: list[CameraDevice] = []
176
+ for i in range(MAX_CAMERAS):
177
+ print(f"[devices] probing index {i} (subprocess, backend={backend})", flush=True)
178
+ ok, reason = _probe_one(i, backend)
179
+ if ok:
180
+ friendly = names.get(i)
181
+ label = _label_for(i, friendly)
182
+ virtual = is_virtual_camera(friendly or "")
183
+ tag = " [virtual]" if virtual else ""
184
+ print(f"[devices] index {i} ok — {label}{tag}", flush=True)
185
+ devices.append(CameraDevice(index=i, label=label, virtual=virtual))
186
+ elif reason == "timeout":
187
+ print(
188
+ f"[devices] index {i} probe timed out — another app may be holding "
189
+ "this camera (Zoom/Teams/Discord/browsers). Close it and re-launch.",
190
+ flush=True,
191
+ )
192
+ elif reason.startswith("error:"):
193
+ print(f"[devices] index {i} probe error: {reason[6:]}", flush=True)
194
+ else:
195
+ print(f"[devices] index {i} unavailable", flush=True)
196
+ real = sum(1 for d in devices if not d.virtual)
197
+ virtual = sum(1 for d in devices if d.virtual)
198
+ print(
199
+ f"[devices] {len(devices)} camera(s) found ({real} real, {virtual} virtual)",
200
+ flush=True,
201
+ )
202
+ return devices
203
+
204
+
205
+ def _label_for(index: int, friendly: str | None = None) -> str:
206
+ if friendly:
207
+ return f"{friendly} (#{index})"
208
+ system = platform.system()
209
+ suffix = {
210
+ "Darwin": "FaceTime / iSight",
211
+ "Windows": "DirectShow",
212
+ "Linux": "/dev/video",
213
+ }.get(system, "")
214
+ if index == 0:
215
+ return f"Camera 0 (default){' · ' + suffix if suffix else ''}"
216
+ return f"Camera {index}"
swap_cli/display.py ADDED
@@ -0,0 +1,177 @@
1
+ """Render an aiortc remote video track in a cv2.imshow window.
2
+
3
+ Also handles snapshot-on-keypress, optional MP4 recording, and
4
+ (Sprint 14k) optional output to a virtual camera device so apps like
5
+ Zoom/Meet/Discord see the deepfake stream directly.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import asyncio
11
+ import time
12
+ from contextlib import suppress
13
+ from pathlib import Path
14
+ from typing import TYPE_CHECKING, Any
15
+
16
+ import cv2
17
+ import numpy as np
18
+
19
+ if TYPE_CHECKING:
20
+ from aiortc.mediastreams import MediaStreamTrack
21
+
22
+
23
+ WINDOW_TITLE = "swap — Lucy 2 live"
24
+
25
+
26
+ class Display:
27
+ """Pulls frames from a remote MediaStreamTrack and renders them.
28
+
29
+ Press Q in the window or call `stop()` to terminate the loop.
30
+ """
31
+
32
+ def __init__(
33
+ self,
34
+ track: MediaStreamTrack,
35
+ *,
36
+ record_path: Path | None = None,
37
+ on_quit: callable = lambda: None, # type: ignore[assignment]
38
+ virtual_camera: bool = False,
39
+ ) -> None:
40
+ self._track = track
41
+ self._record_path = record_path
42
+ self._on_quit = on_quit
43
+ self._virtual_camera = virtual_camera
44
+ self._writer: cv2.VideoWriter | None = None
45
+ # pyvirtualcam.Camera — lazy-init on first frame so we know the
46
+ # actual width/height from Decart's stream rather than guessing.
47
+ self._vcam: Any = None
48
+ self._task: asyncio.Task[None] | None = None
49
+ self._stopped = asyncio.Event()
50
+ self._latest_bgr: np.ndarray | None = None
51
+
52
+ def start(self) -> None:
53
+ self._task = asyncio.create_task(self._loop())
54
+
55
+ async def stop(self) -> None:
56
+ self._stopped.set()
57
+ if self._task:
58
+ self._task.cancel()
59
+ with suppress(asyncio.CancelledError):
60
+ await self._task
61
+ if self._writer is not None:
62
+ self._writer.release()
63
+ if self._vcam is not None:
64
+ with suppress(Exception):
65
+ self._vcam.close()
66
+ self._vcam = None
67
+ cv2.destroyAllWindows()
68
+
69
+ def snapshot(self, dest: Path) -> bool:
70
+ """Save the most recent rendered frame as JPEG. Returns success."""
71
+ if self._latest_bgr is None:
72
+ return False
73
+ dest.parent.mkdir(parents=True, exist_ok=True)
74
+ return cv2.imwrite(str(dest), self._latest_bgr)
75
+
76
+ async def _loop(self) -> None:
77
+ cv2.namedWindow(WINDOW_TITLE, cv2.WINDOW_NORMAL)
78
+ cv2.resizeWindow(WINDOW_TITLE, 960, 540)
79
+ first_frame = True
80
+ try:
81
+ while not self._stopped.is_set():
82
+ frame = await self._track.recv()
83
+ bgr = frame.to_ndarray(format="bgr24")
84
+ self._latest_bgr = bgr
85
+ self._maybe_init_writer(bgr.shape, fps_guess=20)
86
+ if self._writer is not None:
87
+ self._writer.write(bgr)
88
+ cv2.imshow(WINDOW_TITLE, bgr)
89
+ # Sprint 14k: also push the frame to the OBS Virtual Camera
90
+ # driver so Zoom/Meet/Discord pick it up as a real camera.
91
+ # pyvirtualcam expects RGB.
92
+ if self._virtual_camera:
93
+ self._maybe_init_vcam(bgr.shape, fps_guess=20)
94
+ if self._vcam is not None:
95
+ try:
96
+ rgb = cv2.cvtColor(bgr, cv2.COLOR_BGR2RGB)
97
+ self._vcam.send(rgb)
98
+ self._vcam.sleep_until_next_frame()
99
+ except Exception as err: # noqa: BLE001
100
+ print(f"[display] vcam send error: {err}", flush=True)
101
+ # Don't tear the driver down on a single bad frame.
102
+ if first_frame:
103
+ # Flash topmost so the cv2 window pops above the tk GUI on
104
+ # Windows. We don't want it pinned forever — just one beat.
105
+ with suppress(Exception):
106
+ cv2.setWindowProperty(WINDOW_TITLE, cv2.WND_PROP_TOPMOST, 1)
107
+ cv2.waitKey(1)
108
+ cv2.setWindowProperty(WINDOW_TITLE, cv2.WND_PROP_TOPMOST, 0)
109
+ first_frame = False
110
+ key = cv2.waitKey(1) & 0xFF
111
+ if key in (ord("q"), ord("Q"), 27): # q or ESC
112
+ self._on_quit()
113
+ self._stopped.set()
114
+ break
115
+ except asyncio.CancelledError:
116
+ raise
117
+ except Exception as err: # noqa: BLE001 — show + exit cleanly
118
+ print(f"[display] error: {err}")
119
+ finally:
120
+ cv2.destroyAllWindows()
121
+
122
+ def _maybe_init_writer(self, shape: tuple[int, ...], fps_guess: int) -> None:
123
+ if self._record_path is None or self._writer is not None:
124
+ return
125
+ h, w = shape[:2]
126
+ fourcc = cv2.VideoWriter_fourcc(*"mp4v") # type: ignore[attr-defined]
127
+ self._record_path.parent.mkdir(parents=True, exist_ok=True)
128
+ self._writer = cv2.VideoWriter(str(self._record_path), fourcc, fps_guess, (w, h))
129
+
130
+ def _maybe_init_vcam(self, shape: tuple[int, ...], fps_guess: int) -> None:
131
+ """Open pyvirtualcam.Camera on the first frame so we use the
132
+ actual stream resolution. Silently no-ops if pyvirtualcam isn't
133
+ installed or no virtual camera driver is registered — preview
134
+ window keeps working in that case."""
135
+ if self._vcam is not None:
136
+ return
137
+ try:
138
+ import pyvirtualcam # type: ignore[import-not-found]
139
+ except ImportError:
140
+ # Pure-Python wrapper missing; ship hint via the doctor row.
141
+ self._virtual_camera = False
142
+ print(
143
+ "[display] vcam: pyvirtualcam not installed — `pip install pyvirtualcam`",
144
+ flush=True,
145
+ )
146
+ return
147
+ h, w = shape[:2]
148
+ try:
149
+ # backend=None lets pyvirtualcam pick the right one per OS:
150
+ # Windows → OBS Virtual Camera, macOS → OBS, Linux → v4l2loopback.
151
+ self._vcam = pyvirtualcam.Camera(width=w, height=h, fps=fps_guess)
152
+ print(
153
+ f"[display] vcam ready: '{self._vcam.device}' "
154
+ f"{w}x{h}@{fps_guess}fps — Zoom/Meet/Discord can pick it now.",
155
+ flush=True,
156
+ )
157
+ except Exception as err: # noqa: BLE001
158
+ # Driver not installed, busy, or fps unsupported. Disable vcam
159
+ # for this session and keep the preview window healthy.
160
+ self._virtual_camera = False
161
+ self._vcam = None
162
+ print(
163
+ f"[display] vcam unavailable: {err}\n"
164
+ "[display] Install OBS Studio for the OBS Virtual Camera driver: "
165
+ "https://obsproject.com/download",
166
+ flush=True,
167
+ )
168
+
169
+
170
+ def default_snapshot_path() -> Path:
171
+ ts = time.strftime("%Y%m%d-%H%M%S")
172
+ return Path.cwd() / "snapshots" / f"swap-{ts}.jpg"
173
+
174
+
175
+ def default_recording_path() -> Path:
176
+ ts = time.strftime("%Y%m%d-%H%M%S")
177
+ return Path.cwd() / "recordings" / f"swap-{ts}.mp4"