deepctl-cmd-debug-probe 0.0.2__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.
- deepctl_cmd_debug_probe-0.0.2/PKG-INFO +68 -0
- deepctl_cmd_debug_probe-0.0.2/README.md +45 -0
- deepctl_cmd_debug_probe-0.0.2/pyproject.toml +43 -0
- deepctl_cmd_debug_probe-0.0.2/setup.cfg +4 -0
- deepctl_cmd_debug_probe-0.0.2/src/deepctl_cmd_debug_probe/__init__.py +5 -0
- deepctl_cmd_debug_probe-0.0.2/src/deepctl_cmd_debug_probe/command.py +207 -0
- deepctl_cmd_debug_probe-0.0.2/src/deepctl_cmd_debug_probe/models.py +42 -0
- deepctl_cmd_debug_probe-0.0.2/src/deepctl_cmd_debug_probe/proxy.py +278 -0
- deepctl_cmd_debug_probe-0.0.2/src/deepctl_cmd_debug_probe.egg-info/PKG-INFO +68 -0
- deepctl_cmd_debug_probe-0.0.2/src/deepctl_cmd_debug_probe.egg-info/SOURCES.txt +12 -0
- deepctl_cmd_debug_probe-0.0.2/src/deepctl_cmd_debug_probe.egg-info/dependency_links.txt +1 -0
- deepctl_cmd_debug_probe-0.0.2/src/deepctl_cmd_debug_probe.egg-info/entry_points.txt +2 -0
- deepctl_cmd_debug_probe-0.0.2/src/deepctl_cmd_debug_probe.egg-info/requires.txt +6 -0
- deepctl_cmd_debug_probe-0.0.2/src/deepctl_cmd_debug_probe.egg-info/top_level.txt +1 -0
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: deepctl-cmd-debug-probe
|
|
3
|
+
Version: 0.0.2
|
|
4
|
+
Summary: Debug probe subcommand for deepctl — live ffprobe analysis during streaming
|
|
5
|
+
Author-email: Deepgram <devrel@deepgram.com>
|
|
6
|
+
Maintainer-email: Deepgram <devrel@deepgram.com>
|
|
7
|
+
License-Expression: MIT
|
|
8
|
+
Keywords: deepgram,cli,debug,probe,ffprobe,websocket
|
|
9
|
+
Classifier: Development Status :: 3 - Alpha
|
|
10
|
+
Classifier: Intended Audience :: Developers
|
|
11
|
+
Classifier: Programming Language :: Python :: 3
|
|
12
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
13
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
14
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
15
|
+
Requires-Python: >=3.10
|
|
16
|
+
Description-Content-Type: text/markdown
|
|
17
|
+
Requires-Dist: deepctl-core>=0.1.10
|
|
18
|
+
Requires-Dist: deepctl-shared-utils>=0.1.10
|
|
19
|
+
Requires-Dist: click>=8.0.0
|
|
20
|
+
Requires-Dist: rich>=13.0.0
|
|
21
|
+
Requires-Dist: pydantic>=2.0.0
|
|
22
|
+
Requires-Dist: aiohttp>=3.8.0
|
|
23
|
+
|
|
24
|
+
# deepctl-cmd-debug-probe
|
|
25
|
+
|
|
26
|
+
> Part of [deepctl](https://github.com/deepgram/cli) — Official Deepgram CLI
|
|
27
|
+
|
|
28
|
+
Debug probe subcommand for deepctl — live ffprobe analysis during streaming
|
|
29
|
+
|
|
30
|
+
This is a subcommand of `deepctl debug`.
|
|
31
|
+
|
|
32
|
+
## Installation
|
|
33
|
+
|
|
34
|
+
This package is included with deepctl and does not need to be installed separately.
|
|
35
|
+
|
|
36
|
+
### Install deepctl
|
|
37
|
+
|
|
38
|
+
```bash
|
|
39
|
+
# Install with pip
|
|
40
|
+
pip install deepctl
|
|
41
|
+
|
|
42
|
+
# Or install with uv
|
|
43
|
+
uv tool install deepctl
|
|
44
|
+
|
|
45
|
+
# Or install with pipx
|
|
46
|
+
pipx install deepctl
|
|
47
|
+
|
|
48
|
+
# Or run without installing
|
|
49
|
+
uvx deepctl --help
|
|
50
|
+
pipx run deepctl --help
|
|
51
|
+
```
|
|
52
|
+
|
|
53
|
+
## Commands
|
|
54
|
+
|
|
55
|
+
| Command | Entry Point |
|
|
56
|
+
|---------|-------------|
|
|
57
|
+
| `deepctl debug probe` | `deepctl_cmd_debug_probe.command:ProbeCommand` |
|
|
58
|
+
|
|
59
|
+
## Dependencies
|
|
60
|
+
|
|
61
|
+
- `click>=8.0.0`
|
|
62
|
+
- `rich>=13.0.0`
|
|
63
|
+
- `pydantic>=2.0.0`
|
|
64
|
+
- `aiohttp>=3.8.0`
|
|
65
|
+
|
|
66
|
+
## License
|
|
67
|
+
|
|
68
|
+
MIT — see [LICENSE](../../LICENSE)
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
# deepctl-cmd-debug-probe
|
|
2
|
+
|
|
3
|
+
> Part of [deepctl](https://github.com/deepgram/cli) — Official Deepgram CLI
|
|
4
|
+
|
|
5
|
+
Debug probe subcommand for deepctl — live ffprobe analysis during streaming
|
|
6
|
+
|
|
7
|
+
This is a subcommand of `deepctl debug`.
|
|
8
|
+
|
|
9
|
+
## Installation
|
|
10
|
+
|
|
11
|
+
This package is included with deepctl and does not need to be installed separately.
|
|
12
|
+
|
|
13
|
+
### Install deepctl
|
|
14
|
+
|
|
15
|
+
```bash
|
|
16
|
+
# Install with pip
|
|
17
|
+
pip install deepctl
|
|
18
|
+
|
|
19
|
+
# Or install with uv
|
|
20
|
+
uv tool install deepctl
|
|
21
|
+
|
|
22
|
+
# Or install with pipx
|
|
23
|
+
pipx install deepctl
|
|
24
|
+
|
|
25
|
+
# Or run without installing
|
|
26
|
+
uvx deepctl --help
|
|
27
|
+
pipx run deepctl --help
|
|
28
|
+
```
|
|
29
|
+
|
|
30
|
+
## Commands
|
|
31
|
+
|
|
32
|
+
| Command | Entry Point |
|
|
33
|
+
|---------|-------------|
|
|
34
|
+
| `deepctl debug probe` | `deepctl_cmd_debug_probe.command:ProbeCommand` |
|
|
35
|
+
|
|
36
|
+
## Dependencies
|
|
37
|
+
|
|
38
|
+
- `click>=8.0.0`
|
|
39
|
+
- `rich>=13.0.0`
|
|
40
|
+
- `pydantic>=2.0.0`
|
|
41
|
+
- `aiohttp>=3.8.0`
|
|
42
|
+
|
|
43
|
+
## License
|
|
44
|
+
|
|
45
|
+
MIT — see [LICENSE](../../LICENSE)
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["setuptools>=61.0", "wheel"]
|
|
3
|
+
build-backend = "setuptools.build_meta"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "deepctl-cmd-debug-probe"
|
|
7
|
+
version = "0.0.2" # x-release-please-version
|
|
8
|
+
description = "Debug probe subcommand for deepctl — live ffprobe analysis during streaming"
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
license = "MIT"
|
|
11
|
+
authors = [{ name = "Deepgram", email = "devrel@deepgram.com" }]
|
|
12
|
+
maintainers = [{ name = "Deepgram", email = "devrel@deepgram.com" }]
|
|
13
|
+
classifiers = [
|
|
14
|
+
"Development Status :: 3 - Alpha",
|
|
15
|
+
"Intended Audience :: Developers",
|
|
16
|
+
"Programming Language :: Python :: 3",
|
|
17
|
+
"Programming Language :: Python :: 3.10",
|
|
18
|
+
"Programming Language :: Python :: 3.11",
|
|
19
|
+
"Programming Language :: Python :: 3.12",
|
|
20
|
+
]
|
|
21
|
+
keywords = ["deepgram", "cli", "debug", "probe", "ffprobe", "websocket"]
|
|
22
|
+
requires-python = ">=3.10"
|
|
23
|
+
dependencies = [
|
|
24
|
+
"deepctl-core>=0.1.10",
|
|
25
|
+
"deepctl-shared-utils>=0.1.10",
|
|
26
|
+
"click>=8.0.0",
|
|
27
|
+
"rich>=13.0.0",
|
|
28
|
+
"pydantic>=2.0.0",
|
|
29
|
+
"aiohttp>=3.8.0",
|
|
30
|
+
]
|
|
31
|
+
|
|
32
|
+
[project.scripts]
|
|
33
|
+
# Internal commands not meant to be called directly
|
|
34
|
+
|
|
35
|
+
[project.entry-points."deepctl.subcommands.debug"]
|
|
36
|
+
probe = "deepctl_cmd_debug_probe.command:ProbeCommand"
|
|
37
|
+
|
|
38
|
+
[tool.setuptools]
|
|
39
|
+
package-dir = { "" = "src" }
|
|
40
|
+
|
|
41
|
+
[tool.setuptools.packages.find]
|
|
42
|
+
where = ["src"]
|
|
43
|
+
include = ["deepctl_cmd_debug_probe*"]
|
|
@@ -0,0 +1,207 @@
|
|
|
1
|
+
"""Debug probe command for deepctl — WebSocket proxy with live ffprobe analysis."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import asyncio
|
|
6
|
+
import socket
|
|
7
|
+
import time
|
|
8
|
+
from typing import Any
|
|
9
|
+
|
|
10
|
+
from aiohttp import web
|
|
11
|
+
from deepctl_core import AuthManager, BaseCommand, Config, DeepgramClient
|
|
12
|
+
from deepctl_shared_utils import require_ffprobe
|
|
13
|
+
from rich.console import Console
|
|
14
|
+
from rich.panel import Panel
|
|
15
|
+
|
|
16
|
+
from .models import ProbeDebugResult
|
|
17
|
+
from .proxy import ProbeProxy
|
|
18
|
+
|
|
19
|
+
console = Console()
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def _find_available_port(start: int = 3100, end: int = 3199) -> int | None:
|
|
23
|
+
"""Find an available port in the given range."""
|
|
24
|
+
for port in range(start, end + 1):
|
|
25
|
+
try:
|
|
26
|
+
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
|
|
27
|
+
s.bind(("127.0.0.1", port))
|
|
28
|
+
return port
|
|
29
|
+
except OSError:
|
|
30
|
+
continue
|
|
31
|
+
return None
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
class ProbeCommand(BaseCommand):
|
|
35
|
+
"""WebSocket proxy with periodic ffprobe analysis of streaming audio."""
|
|
36
|
+
|
|
37
|
+
name = "probe"
|
|
38
|
+
help = (
|
|
39
|
+
"Launch a local WebSocket proxy that periodically probes "
|
|
40
|
+
"streaming audio with ffprobe while forwarding to Deepgram."
|
|
41
|
+
)
|
|
42
|
+
short_help = "Stream probe proxy"
|
|
43
|
+
|
|
44
|
+
requires_auth = True
|
|
45
|
+
requires_project = False
|
|
46
|
+
ci_friendly = False
|
|
47
|
+
|
|
48
|
+
examples = [
|
|
49
|
+
"dg debug probe",
|
|
50
|
+
"dg debug probe --port 3100",
|
|
51
|
+
"dg debug probe --probe-interval-bytes 131072",
|
|
52
|
+
"dg debug probe --probe-interval-seconds 10",
|
|
53
|
+
]
|
|
54
|
+
agent_help = (
|
|
55
|
+
"Launch a local WebSocket proxy that sits between your application "
|
|
56
|
+
"and Deepgram's streaming API. Periodically runs ffprobe on the "
|
|
57
|
+
"accumulated audio buffer to show codec, sample rate, and channel "
|
|
58
|
+
"information alongside real-time transcripts."
|
|
59
|
+
)
|
|
60
|
+
|
|
61
|
+
def get_arguments(self) -> list[dict[str, Any]]:
|
|
62
|
+
"""Get command arguments and options."""
|
|
63
|
+
return [
|
|
64
|
+
{
|
|
65
|
+
"names": ["--port", "-p"],
|
|
66
|
+
"help": "Local proxy port (default: auto-select 3100-3199)",
|
|
67
|
+
"type": int,
|
|
68
|
+
"is_option": True,
|
|
69
|
+
},
|
|
70
|
+
{
|
|
71
|
+
"names": ["--probe-interval-bytes"],
|
|
72
|
+
"help": "Probe every N bytes of audio (default: 131072)",
|
|
73
|
+
"type": int,
|
|
74
|
+
"default": 131072,
|
|
75
|
+
"is_option": True,
|
|
76
|
+
},
|
|
77
|
+
{
|
|
78
|
+
"names": ["--probe-interval-seconds"],
|
|
79
|
+
"help": "Probe every N seconds (default: 10)",
|
|
80
|
+
"type": float,
|
|
81
|
+
"default": 10.0,
|
|
82
|
+
"is_option": True,
|
|
83
|
+
},
|
|
84
|
+
{
|
|
85
|
+
"names": ["--verbose", "-v"],
|
|
86
|
+
"help": "Show per-frame details",
|
|
87
|
+
"is_flag": True,
|
|
88
|
+
"is_option": True,
|
|
89
|
+
},
|
|
90
|
+
]
|
|
91
|
+
|
|
92
|
+
def handle(
|
|
93
|
+
self,
|
|
94
|
+
config: Config,
|
|
95
|
+
auth_manager: AuthManager,
|
|
96
|
+
client: DeepgramClient,
|
|
97
|
+
**kwargs: Any,
|
|
98
|
+
) -> Any:
|
|
99
|
+
"""Handle debug probe command execution."""
|
|
100
|
+
port = kwargs.get("port")
|
|
101
|
+
probe_interval_bytes = kwargs.get("probe_interval_bytes") or 131072
|
|
102
|
+
probe_interval_seconds = kwargs.get("probe_interval_seconds") or 10.0
|
|
103
|
+
verbose = kwargs.get("verbose", False)
|
|
104
|
+
|
|
105
|
+
# Check ffprobe availability
|
|
106
|
+
if not require_ffprobe(config):
|
|
107
|
+
return ProbeDebugResult(
|
|
108
|
+
status="error",
|
|
109
|
+
message="ffprobe is required but not found",
|
|
110
|
+
)
|
|
111
|
+
|
|
112
|
+
# Get API key
|
|
113
|
+
api_key = auth_manager.get_api_key()
|
|
114
|
+
if not api_key:
|
|
115
|
+
console.print("[red]No API key found. Run 'dg login' first.[/red]")
|
|
116
|
+
return ProbeDebugResult(
|
|
117
|
+
status="error",
|
|
118
|
+
message="No API key found",
|
|
119
|
+
)
|
|
120
|
+
|
|
121
|
+
# Find available port
|
|
122
|
+
if port is None:
|
|
123
|
+
port = _find_available_port()
|
|
124
|
+
if port is None:
|
|
125
|
+
console.print("[red]No available ports in range 3100-3199[/red]")
|
|
126
|
+
return ProbeDebugResult(
|
|
127
|
+
status="error",
|
|
128
|
+
message="No available ports",
|
|
129
|
+
)
|
|
130
|
+
else:
|
|
131
|
+
try:
|
|
132
|
+
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
|
|
133
|
+
s.bind(("127.0.0.1", port))
|
|
134
|
+
except OSError:
|
|
135
|
+
console.print(f"[red]Port {port} is already in use[/red]")
|
|
136
|
+
return ProbeDebugResult(
|
|
137
|
+
status="error",
|
|
138
|
+
message=f"Port {port} is in use",
|
|
139
|
+
port=port,
|
|
140
|
+
)
|
|
141
|
+
|
|
142
|
+
upstream_host = "api.deepgram.com"
|
|
143
|
+
|
|
144
|
+
# Create proxy
|
|
145
|
+
proxy = ProbeProxy(
|
|
146
|
+
api_key=api_key,
|
|
147
|
+
config=config,
|
|
148
|
+
upstream_host=upstream_host,
|
|
149
|
+
probe_interval_bytes=probe_interval_bytes,
|
|
150
|
+
probe_interval_seconds=probe_interval_seconds,
|
|
151
|
+
verbose=verbose,
|
|
152
|
+
)
|
|
153
|
+
|
|
154
|
+
# Print instructions
|
|
155
|
+
console.print(
|
|
156
|
+
Panel(
|
|
157
|
+
f"[bold cyan]WebSocket Probe Proxy[/bold cyan]\n\n"
|
|
158
|
+
f"Listening on: [green]ws://localhost:{port}[/green]\n"
|
|
159
|
+
f"Upstream: [yellow]wss://{upstream_host}[/yellow]\n"
|
|
160
|
+
f"API key: [dim]****{api_key[-4:]}[/dim]\n\n"
|
|
161
|
+
f"Probe every: {probe_interval_bytes:,} bytes "
|
|
162
|
+
f"or {probe_interval_seconds}s\n\n"
|
|
163
|
+
f"[bold]Point your app at the proxy URL:[/bold]\n"
|
|
164
|
+
f" ws://localhost:{port}/v1/listen?encoding=...&sample_rate=...\n\n"
|
|
165
|
+
f"[dim]Press Ctrl+C to stop[/dim]",
|
|
166
|
+
title="Debug Probe Proxy",
|
|
167
|
+
border_style="cyan",
|
|
168
|
+
)
|
|
169
|
+
)
|
|
170
|
+
|
|
171
|
+
# Run the proxy server
|
|
172
|
+
start_time = time.time()
|
|
173
|
+
app = web.Application()
|
|
174
|
+
app.router.add_route("GET", "/{path:.*}", proxy.handle_connection)
|
|
175
|
+
|
|
176
|
+
try:
|
|
177
|
+
loop = asyncio.new_event_loop()
|
|
178
|
+
asyncio.set_event_loop(loop)
|
|
179
|
+
|
|
180
|
+
runner = web.AppRunner(app)
|
|
181
|
+
loop.run_until_complete(runner.setup())
|
|
182
|
+
|
|
183
|
+
site = web.TCPSite(runner, "127.0.0.1", port)
|
|
184
|
+
loop.run_until_complete(site.start())
|
|
185
|
+
|
|
186
|
+
# Run until Ctrl+C
|
|
187
|
+
loop.run_forever()
|
|
188
|
+
|
|
189
|
+
except KeyboardInterrupt:
|
|
190
|
+
console.print("\n[yellow]Shutting down probe proxy...[/yellow]")
|
|
191
|
+
finally:
|
|
192
|
+
loop.run_until_complete(runner.cleanup())
|
|
193
|
+
loop.close()
|
|
194
|
+
|
|
195
|
+
duration = time.time() - start_time
|
|
196
|
+
|
|
197
|
+
console.print(f"\n[green]Probe proxy stopped after {duration:.1f}s[/green]")
|
|
198
|
+
console.print(f"Total connections: {len(proxy.connections)}")
|
|
199
|
+
|
|
200
|
+
return ProbeDebugResult(
|
|
201
|
+
status="success",
|
|
202
|
+
message="Probe proxy session completed",
|
|
203
|
+
port=port,
|
|
204
|
+
upstream_host=upstream_host,
|
|
205
|
+
connections=proxy.connections,
|
|
206
|
+
duration_seconds=duration,
|
|
207
|
+
)
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
"""Data models for debug probe command."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from deepctl_core import BaseResult
|
|
6
|
+
from deepctl_shared_utils.ffprobe_models import AudioProbeResult # noqa: TC002
|
|
7
|
+
from pydantic import BaseModel, Field
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class ProbeSnapshot(BaseModel):
|
|
11
|
+
"""A single ffprobe snapshot taken during streaming."""
|
|
12
|
+
|
|
13
|
+
timestamp: float = 0.0
|
|
14
|
+
bytes_at_probe: int = 0
|
|
15
|
+
result: AudioProbeResult | None = None
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class ProbeConnectionStats(BaseModel):
|
|
19
|
+
"""Statistics for a single probe proxy connection."""
|
|
20
|
+
|
|
21
|
+
connection_id: str
|
|
22
|
+
stream_type: str = "unknown"
|
|
23
|
+
path: str = ""
|
|
24
|
+
bytes_sent: int = 0
|
|
25
|
+
bytes_received: int = 0
|
|
26
|
+
frames_sent: int = 0
|
|
27
|
+
frames_received: int = 0
|
|
28
|
+
text_frames_sent: int = 0
|
|
29
|
+
text_frames_received: int = 0
|
|
30
|
+
duration_seconds: float | None = None
|
|
31
|
+
snapshots: list[ProbeSnapshot] = Field(default_factory=list)
|
|
32
|
+
transcripts: list[str] = Field(default_factory=list)
|
|
33
|
+
audio_buffer: bytes = Field(default=b"", exclude=True, repr=False)
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
class ProbeDebugResult(BaseResult):
|
|
37
|
+
"""Result from debug probe command execution."""
|
|
38
|
+
|
|
39
|
+
port: int = 0
|
|
40
|
+
upstream_host: str = ""
|
|
41
|
+
connections: list[ProbeConnectionStats] = Field(default_factory=list)
|
|
42
|
+
duration_seconds: float | None = None
|
|
@@ -0,0 +1,278 @@
|
|
|
1
|
+
"""WebSocket proxy with periodic ffprobe analysis."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import asyncio
|
|
6
|
+
import json
|
|
7
|
+
import time
|
|
8
|
+
import uuid
|
|
9
|
+
from typing import TYPE_CHECKING
|
|
10
|
+
|
|
11
|
+
import aiohttp
|
|
12
|
+
from aiohttp import web
|
|
13
|
+
from deepctl_shared_utils import probe_buffer
|
|
14
|
+
from rich.console import Console
|
|
15
|
+
|
|
16
|
+
from .models import ProbeConnectionStats, ProbeSnapshot
|
|
17
|
+
|
|
18
|
+
if TYPE_CHECKING:
|
|
19
|
+
from deepctl_core import Config
|
|
20
|
+
|
|
21
|
+
console = Console()
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class ProbeProxy:
|
|
25
|
+
"""WebSocket proxy that probes audio at configurable intervals."""
|
|
26
|
+
|
|
27
|
+
# Maximum audio buffer size (1 MB). Only the most recent bytes are kept
|
|
28
|
+
# to prevent unbounded memory growth on long-running streams.
|
|
29
|
+
MAX_BUFFER_BYTES = 1_048_576
|
|
30
|
+
|
|
31
|
+
def __init__(
|
|
32
|
+
self,
|
|
33
|
+
api_key: str,
|
|
34
|
+
config: Config | None = None,
|
|
35
|
+
upstream_host: str = "api.deepgram.com",
|
|
36
|
+
probe_interval_bytes: int = 131072,
|
|
37
|
+
probe_interval_seconds: float = 10.0,
|
|
38
|
+
verbose: bool = False,
|
|
39
|
+
) -> None:
|
|
40
|
+
self.api_key = api_key
|
|
41
|
+
self.config = config
|
|
42
|
+
self.upstream_host = upstream_host
|
|
43
|
+
self.probe_interval_bytes = probe_interval_bytes
|
|
44
|
+
self.probe_interval_seconds = probe_interval_seconds
|
|
45
|
+
self.verbose = verbose
|
|
46
|
+
self.connections: list[ProbeConnectionStats] = []
|
|
47
|
+
|
|
48
|
+
def _detect_stream_type(self, path: str) -> str:
|
|
49
|
+
"""Detect stream type from the request path."""
|
|
50
|
+
if "/v1/listen" in path:
|
|
51
|
+
return "stt"
|
|
52
|
+
elif "/v1/speak" in path:
|
|
53
|
+
return "tts"
|
|
54
|
+
elif "/agent" in path:
|
|
55
|
+
return "agent"
|
|
56
|
+
return "unknown"
|
|
57
|
+
|
|
58
|
+
async def handle_connection(self, request: web.Request) -> web.WebSocketResponse:
|
|
59
|
+
"""Handle an incoming WebSocket connection."""
|
|
60
|
+
ws_client = web.WebSocketResponse()
|
|
61
|
+
await ws_client.prepare(request)
|
|
62
|
+
|
|
63
|
+
conn_id = str(uuid.uuid4())[:8]
|
|
64
|
+
path = request.path
|
|
65
|
+
query_string = request.query_string
|
|
66
|
+
stream_type = self._detect_stream_type(path)
|
|
67
|
+
|
|
68
|
+
stats = ProbeConnectionStats(
|
|
69
|
+
connection_id=conn_id,
|
|
70
|
+
stream_type=stream_type,
|
|
71
|
+
path=path,
|
|
72
|
+
)
|
|
73
|
+
self.connections.append(stats)
|
|
74
|
+
|
|
75
|
+
upstream_url = f"wss://{self.upstream_host}{path}"
|
|
76
|
+
if query_string:
|
|
77
|
+
upstream_url = f"{upstream_url}?{query_string}"
|
|
78
|
+
|
|
79
|
+
upstream_headers = {
|
|
80
|
+
"Authorization": f"Token {self.api_key}",
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
console.print(
|
|
84
|
+
f"\n[green]New connection[/green] [{conn_id}] "
|
|
85
|
+
f"type={stream_type} path={path}"
|
|
86
|
+
)
|
|
87
|
+
|
|
88
|
+
start_time = time.time()
|
|
89
|
+
last_probe_time = start_time
|
|
90
|
+
last_probe_bytes = 0
|
|
91
|
+
|
|
92
|
+
try:
|
|
93
|
+
async with (
|
|
94
|
+
aiohttp.ClientSession() as session,
|
|
95
|
+
session.ws_connect(
|
|
96
|
+
upstream_url,
|
|
97
|
+
headers=upstream_headers,
|
|
98
|
+
timeout=aiohttp.ClientWSTimeout(ws_close=30.0),
|
|
99
|
+
) as ws_upstream,
|
|
100
|
+
):
|
|
101
|
+
|
|
102
|
+
async def forward_client_to_upstream() -> None:
|
|
103
|
+
nonlocal last_probe_time, last_probe_bytes
|
|
104
|
+
|
|
105
|
+
async for msg in ws_client:
|
|
106
|
+
if msg.type == aiohttp.WSMsgType.BINARY:
|
|
107
|
+
stats.bytes_sent += len(msg.data)
|
|
108
|
+
stats.frames_sent += 1
|
|
109
|
+
|
|
110
|
+
# Keep only the tail of the buffer to bound memory
|
|
111
|
+
stats.audio_buffer += msg.data
|
|
112
|
+
if len(stats.audio_buffer) > self.MAX_BUFFER_BYTES:
|
|
113
|
+
stats.audio_buffer = stats.audio_buffer[
|
|
114
|
+
-self.MAX_BUFFER_BYTES :
|
|
115
|
+
]
|
|
116
|
+
|
|
117
|
+
await ws_upstream.send_bytes(msg.data)
|
|
118
|
+
|
|
119
|
+
if self.verbose:
|
|
120
|
+
console.print(
|
|
121
|
+
f" [dim]→ binary {len(msg.data)} bytes[/dim]"
|
|
122
|
+
)
|
|
123
|
+
|
|
124
|
+
# Check probe thresholds
|
|
125
|
+
now = time.time()
|
|
126
|
+
bytes_since = stats.bytes_sent - last_probe_bytes
|
|
127
|
+
time_since = now - last_probe_time
|
|
128
|
+
|
|
129
|
+
if (
|
|
130
|
+
bytes_since >= self.probe_interval_bytes
|
|
131
|
+
or time_since >= self.probe_interval_seconds
|
|
132
|
+
):
|
|
133
|
+
self._run_probe_snapshot(stats, now)
|
|
134
|
+
last_probe_time = now
|
|
135
|
+
last_probe_bytes = stats.bytes_sent
|
|
136
|
+
|
|
137
|
+
elif msg.type == aiohttp.WSMsgType.TEXT:
|
|
138
|
+
stats.text_frames_sent += 1
|
|
139
|
+
await ws_upstream.send_str(msg.data)
|
|
140
|
+
elif msg.type in (
|
|
141
|
+
aiohttp.WSMsgType.CLOSE,
|
|
142
|
+
aiohttp.WSMsgType.ERROR,
|
|
143
|
+
):
|
|
144
|
+
break
|
|
145
|
+
|
|
146
|
+
async def forward_upstream_to_client() -> None:
|
|
147
|
+
async for msg in ws_upstream:
|
|
148
|
+
if ws_client.closed:
|
|
149
|
+
break
|
|
150
|
+
|
|
151
|
+
if msg.type == aiohttp.WSMsgType.BINARY:
|
|
152
|
+
stats.bytes_received += len(msg.data)
|
|
153
|
+
stats.frames_received += 1
|
|
154
|
+
await ws_client.send_bytes(msg.data)
|
|
155
|
+
elif msg.type == aiohttp.WSMsgType.TEXT:
|
|
156
|
+
stats.text_frames_received += 1
|
|
157
|
+
await ws_client.send_str(msg.data)
|
|
158
|
+
|
|
159
|
+
# Parse transcript from Deepgram response
|
|
160
|
+
self._parse_transcript(msg.data, stats)
|
|
161
|
+
elif msg.type in (
|
|
162
|
+
aiohttp.WSMsgType.CLOSE,
|
|
163
|
+
aiohttp.WSMsgType.ERROR,
|
|
164
|
+
):
|
|
165
|
+
break
|
|
166
|
+
|
|
167
|
+
# Run both forwarders concurrently. If one fails,
|
|
168
|
+
# cancel the other instead of letting it dangle.
|
|
169
|
+
task_c2u = asyncio.ensure_future(forward_client_to_upstream())
|
|
170
|
+
task_u2c = asyncio.ensure_future(forward_upstream_to_client())
|
|
171
|
+
try:
|
|
172
|
+
await asyncio.gather(task_c2u, task_u2c)
|
|
173
|
+
except Exception:
|
|
174
|
+
pass
|
|
175
|
+
finally:
|
|
176
|
+
for task in (task_c2u, task_u2c):
|
|
177
|
+
if not task.done():
|
|
178
|
+
task.cancel()
|
|
179
|
+
# Suppress CancelledError from the cancelled task
|
|
180
|
+
await asyncio.gather(task_c2u, task_u2c, return_exceptions=True)
|
|
181
|
+
|
|
182
|
+
except aiohttp.ClientError as e:
|
|
183
|
+
console.print(f"[red]Upstream connection failed[/red] [{conn_id}]: {e}")
|
|
184
|
+
except Exception as e:
|
|
185
|
+
console.print(f"[red]Connection error[/red] [{conn_id}]: {e}")
|
|
186
|
+
finally:
|
|
187
|
+
stats.duration_seconds = time.time() - start_time
|
|
188
|
+
|
|
189
|
+
# Final probe
|
|
190
|
+
if stats.audio_buffer:
|
|
191
|
+
console.print(f"\n[bold]Final probe[/bold] [{conn_id}]:")
|
|
192
|
+
self._run_probe_snapshot(stats, time.time())
|
|
193
|
+
|
|
194
|
+
self._print_connection_summary(stats)
|
|
195
|
+
|
|
196
|
+
if not ws_client.closed:
|
|
197
|
+
await ws_client.close()
|
|
198
|
+
|
|
199
|
+
return ws_client
|
|
200
|
+
|
|
201
|
+
def _run_probe_snapshot(
|
|
202
|
+
self, stats: ProbeConnectionStats, timestamp: float
|
|
203
|
+
) -> None:
|
|
204
|
+
"""Run a probe snapshot on the accumulated audio buffer."""
|
|
205
|
+
result = probe_buffer(stats.audio_buffer, "probe", self.config)
|
|
206
|
+
snapshot = ProbeSnapshot(
|
|
207
|
+
timestamp=timestamp,
|
|
208
|
+
bytes_at_probe=stats.bytes_sent,
|
|
209
|
+
result=result,
|
|
210
|
+
)
|
|
211
|
+
stats.snapshots.append(snapshot)
|
|
212
|
+
|
|
213
|
+
if result and result.streams:
|
|
214
|
+
stream = result.streams[0]
|
|
215
|
+
parts = []
|
|
216
|
+
if stream.codec_name:
|
|
217
|
+
parts.append(f"codec={stream.codec_name}")
|
|
218
|
+
if stream.sample_rate:
|
|
219
|
+
parts.append(f"rate={stream.sample_rate}Hz")
|
|
220
|
+
if stream.channels is not None:
|
|
221
|
+
parts.append(f"ch={stream.channels}")
|
|
222
|
+
info = " ".join(parts)
|
|
223
|
+
console.print(
|
|
224
|
+
f" [cyan]probe[/cyan] [{stats.connection_id}] "
|
|
225
|
+
f"@{stats.bytes_sent:,}B: {info}"
|
|
226
|
+
)
|
|
227
|
+
elif result and result.format:
|
|
228
|
+
console.print(
|
|
229
|
+
f" [cyan]probe[/cyan] [{stats.connection_id}] "
|
|
230
|
+
f"@{stats.bytes_sent:,}B: format={result.format.format_name}"
|
|
231
|
+
)
|
|
232
|
+
else:
|
|
233
|
+
console.print(
|
|
234
|
+
f" [dim]probe[/dim] [{stats.connection_id}] "
|
|
235
|
+
f"@{stats.bytes_sent:,}B: could not determine format"
|
|
236
|
+
)
|
|
237
|
+
|
|
238
|
+
def _parse_transcript(self, text: str, stats: ProbeConnectionStats) -> None:
|
|
239
|
+
"""Parse Deepgram transcript from upstream TEXT frame."""
|
|
240
|
+
try:
|
|
241
|
+
data = json.loads(text)
|
|
242
|
+
# Deepgram STT responses have channel.alternatives[].transcript
|
|
243
|
+
channel = data.get("channel", {})
|
|
244
|
+
alternatives = channel.get("alternatives", [])
|
|
245
|
+
if alternatives:
|
|
246
|
+
transcript = alternatives[0].get("transcript", "")
|
|
247
|
+
is_final = data.get("is_final", False)
|
|
248
|
+
if transcript and is_final:
|
|
249
|
+
stats.transcripts.append(transcript)
|
|
250
|
+
console.print(
|
|
251
|
+
f" [green]transcript[/green] [{stats.connection_id}]: "
|
|
252
|
+
f"{transcript[:120]}"
|
|
253
|
+
)
|
|
254
|
+
except (json.JSONDecodeError, AttributeError):
|
|
255
|
+
pass
|
|
256
|
+
|
|
257
|
+
def _print_connection_summary(self, stats: ProbeConnectionStats) -> None:
|
|
258
|
+
"""Print summary for a completed connection."""
|
|
259
|
+
console.print(f"\n[blue]Connection closed[/blue] [{stats.connection_id}]")
|
|
260
|
+
console.print(f" Type: {stats.stream_type}")
|
|
261
|
+
console.print(
|
|
262
|
+
f" Duration: {stats.duration_seconds:.1f}s"
|
|
263
|
+
if stats.duration_seconds
|
|
264
|
+
else " Duration: <1s"
|
|
265
|
+
)
|
|
266
|
+
console.print(
|
|
267
|
+
f" Sent: {stats.bytes_sent:,} bytes "
|
|
268
|
+
f"({stats.frames_sent} binary, "
|
|
269
|
+
f"{stats.text_frames_sent} text frames)"
|
|
270
|
+
)
|
|
271
|
+
console.print(
|
|
272
|
+
f" Received: {stats.bytes_received:,} bytes "
|
|
273
|
+
f"({stats.frames_received} binary, "
|
|
274
|
+
f"{stats.text_frames_received} text frames)"
|
|
275
|
+
)
|
|
276
|
+
console.print(f" Probe snapshots: {len(stats.snapshots)}")
|
|
277
|
+
if stats.transcripts:
|
|
278
|
+
console.print(f" Transcripts received: {len(stats.transcripts)}")
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: deepctl-cmd-debug-probe
|
|
3
|
+
Version: 0.0.2
|
|
4
|
+
Summary: Debug probe subcommand for deepctl — live ffprobe analysis during streaming
|
|
5
|
+
Author-email: Deepgram <devrel@deepgram.com>
|
|
6
|
+
Maintainer-email: Deepgram <devrel@deepgram.com>
|
|
7
|
+
License-Expression: MIT
|
|
8
|
+
Keywords: deepgram,cli,debug,probe,ffprobe,websocket
|
|
9
|
+
Classifier: Development Status :: 3 - Alpha
|
|
10
|
+
Classifier: Intended Audience :: Developers
|
|
11
|
+
Classifier: Programming Language :: Python :: 3
|
|
12
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
13
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
14
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
15
|
+
Requires-Python: >=3.10
|
|
16
|
+
Description-Content-Type: text/markdown
|
|
17
|
+
Requires-Dist: deepctl-core>=0.1.10
|
|
18
|
+
Requires-Dist: deepctl-shared-utils>=0.1.10
|
|
19
|
+
Requires-Dist: click>=8.0.0
|
|
20
|
+
Requires-Dist: rich>=13.0.0
|
|
21
|
+
Requires-Dist: pydantic>=2.0.0
|
|
22
|
+
Requires-Dist: aiohttp>=3.8.0
|
|
23
|
+
|
|
24
|
+
# deepctl-cmd-debug-probe
|
|
25
|
+
|
|
26
|
+
> Part of [deepctl](https://github.com/deepgram/cli) — Official Deepgram CLI
|
|
27
|
+
|
|
28
|
+
Debug probe subcommand for deepctl — live ffprobe analysis during streaming
|
|
29
|
+
|
|
30
|
+
This is a subcommand of `deepctl debug`.
|
|
31
|
+
|
|
32
|
+
## Installation
|
|
33
|
+
|
|
34
|
+
This package is included with deepctl and does not need to be installed separately.
|
|
35
|
+
|
|
36
|
+
### Install deepctl
|
|
37
|
+
|
|
38
|
+
```bash
|
|
39
|
+
# Install with pip
|
|
40
|
+
pip install deepctl
|
|
41
|
+
|
|
42
|
+
# Or install with uv
|
|
43
|
+
uv tool install deepctl
|
|
44
|
+
|
|
45
|
+
# Or install with pipx
|
|
46
|
+
pipx install deepctl
|
|
47
|
+
|
|
48
|
+
# Or run without installing
|
|
49
|
+
uvx deepctl --help
|
|
50
|
+
pipx run deepctl --help
|
|
51
|
+
```
|
|
52
|
+
|
|
53
|
+
## Commands
|
|
54
|
+
|
|
55
|
+
| Command | Entry Point |
|
|
56
|
+
|---------|-------------|
|
|
57
|
+
| `deepctl debug probe` | `deepctl_cmd_debug_probe.command:ProbeCommand` |
|
|
58
|
+
|
|
59
|
+
## Dependencies
|
|
60
|
+
|
|
61
|
+
- `click>=8.0.0`
|
|
62
|
+
- `rich>=13.0.0`
|
|
63
|
+
- `pydantic>=2.0.0`
|
|
64
|
+
- `aiohttp>=3.8.0`
|
|
65
|
+
|
|
66
|
+
## License
|
|
67
|
+
|
|
68
|
+
MIT — see [LICENSE](../../LICENSE)
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
README.md
|
|
2
|
+
pyproject.toml
|
|
3
|
+
src/deepctl_cmd_debug_probe/__init__.py
|
|
4
|
+
src/deepctl_cmd_debug_probe/command.py
|
|
5
|
+
src/deepctl_cmd_debug_probe/models.py
|
|
6
|
+
src/deepctl_cmd_debug_probe/proxy.py
|
|
7
|
+
src/deepctl_cmd_debug_probe.egg-info/PKG-INFO
|
|
8
|
+
src/deepctl_cmd_debug_probe.egg-info/SOURCES.txt
|
|
9
|
+
src/deepctl_cmd_debug_probe.egg-info/dependency_links.txt
|
|
10
|
+
src/deepctl_cmd_debug_probe.egg-info/entry_points.txt
|
|
11
|
+
src/deepctl_cmd_debug_probe.egg-info/requires.txt
|
|
12
|
+
src/deepctl_cmd_debug_probe.egg-info/top_level.txt
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
deepctl_cmd_debug_probe
|