VoIP 0.1.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.
- voip-0.1.0/LICENSE +24 -0
- voip-0.1.0/PKG-INFO +131 -0
- voip-0.1.0/README.md +84 -0
- voip-0.1.0/pyproject.toml +113 -0
- voip-0.1.0/voip/__init__.py +6 -0
- voip-0.1.0/voip/__main__.py +292 -0
- voip-0.1.0/voip/_version.py +34 -0
- voip-0.1.0/voip/audio.py +459 -0
- voip-0.1.0/voip/call.py +117 -0
- voip-0.1.0/voip/rtp.py +186 -0
- voip-0.1.0/voip/sdp/__init__.py +5 -0
- voip-0.1.0/voip/sdp/lexers.py +55 -0
- voip-0.1.0/voip/sdp/messages.py +150 -0
- voip-0.1.0/voip/sdp/types.py +419 -0
- voip-0.1.0/voip/sip/__init__.py +1 -0
- voip-0.1.0/voip/sip/lexers.py +57 -0
- voip-0.1.0/voip/sip/messages.py +103 -0
- voip-0.1.0/voip/sip/protocol.py +925 -0
- voip-0.1.0/voip/sip/types.py +120 -0
- voip-0.1.0/voip/srtp.py +242 -0
- voip-0.1.0/voip/stun.py +228 -0
- voip-0.1.0/voip/types.py +24 -0
voip-0.1.0/LICENSE
ADDED
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
BSD 2-Clause License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026, Johannes Maron
|
|
4
|
+
|
|
5
|
+
Redistribution and use in source and binary forms, with or without
|
|
6
|
+
modification, are permitted provided that the following conditions are met:
|
|
7
|
+
|
|
8
|
+
1. Redistributions of source code must retain the above copyright notice, this
|
|
9
|
+
list of conditions and the following disclaimer.
|
|
10
|
+
|
|
11
|
+
2. Redistributions in binary form must reproduce the above copyright notice,
|
|
12
|
+
this list of conditions and the following disclaimer in the documentation
|
|
13
|
+
and/or other materials provided with the distribution.
|
|
14
|
+
|
|
15
|
+
THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
|
|
16
|
+
AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
|
|
17
|
+
IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
|
|
18
|
+
DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE
|
|
19
|
+
FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
|
|
20
|
+
DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR
|
|
21
|
+
SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER
|
|
22
|
+
CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY,
|
|
23
|
+
OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
|
|
24
|
+
OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
|
voip-0.1.0/PKG-INFO
ADDED
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: VoIP
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Python asyncio library for VoIP calls.
|
|
5
|
+
Keywords: VoIP,SIP,RTP,WebRTC,STUN,asyncio
|
|
6
|
+
Author-email: Johannes Maron <johannes@maron.family>
|
|
7
|
+
Requires-Python: >=3.13
|
|
8
|
+
Description-Content-Type: text/markdown
|
|
9
|
+
Classifier: Development Status :: 3 - Alpha
|
|
10
|
+
Classifier: Environment :: Console
|
|
11
|
+
Classifier: Intended Audience :: Developers
|
|
12
|
+
Classifier: Intended Audience :: Information Technology
|
|
13
|
+
Classifier: Intended Audience :: Telecommunications Industry
|
|
14
|
+
Classifier: License :: OSI Approved :: BSD License
|
|
15
|
+
Classifier: Operating System :: OS Independent
|
|
16
|
+
Classifier: Programming Language :: Python
|
|
17
|
+
Classifier: Programming Language :: Python :: 3
|
|
18
|
+
Classifier: Programming Language :: Python :: 3 :: Only
|
|
19
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
20
|
+
Classifier: Programming Language :: Python :: 3.14
|
|
21
|
+
Classifier: Framework :: AsyncIO
|
|
22
|
+
Classifier: Topic :: Communications :: Internet Phone
|
|
23
|
+
Classifier: Topic :: Communications :: Telephony
|
|
24
|
+
Classifier: Topic :: Multimedia :: Sound/Audio
|
|
25
|
+
Classifier: Topic :: Multimedia :: Sound/Audio :: Speech
|
|
26
|
+
Classifier: Topic :: Scientific/Engineering :: Artificial Intelligence
|
|
27
|
+
Classifier: Topic :: Software Development :: Libraries
|
|
28
|
+
Classifier: Topic :: System :: Networking
|
|
29
|
+
Classifier: Topic :: Home Automation
|
|
30
|
+
License-File: LICENSE
|
|
31
|
+
Requires-Dist: cryptography
|
|
32
|
+
Requires-Dist: faster-whisper ; extra == "audio"
|
|
33
|
+
Requires-Dist: numpy ; extra == "audio"
|
|
34
|
+
Requires-Dist: av ; extra == "audio"
|
|
35
|
+
Requires-Dist: click ; extra == "cli"
|
|
36
|
+
Requires-Dist: pygments ; extra == "cli"
|
|
37
|
+
Requires-Dist: faster-whisper ; extra == "cli"
|
|
38
|
+
Requires-Dist: numpy ; extra == "cli"
|
|
39
|
+
Requires-Dist: av ; extra == "cli"
|
|
40
|
+
Requires-Dist: Pygments ; extra == "pygments"
|
|
41
|
+
Project-URL: Changelog, https://github.com/codingjoe/VoIP/releases
|
|
42
|
+
Project-URL: Project-URL, https://github.com/codingjoe/VoIP
|
|
43
|
+
Provides-Extra: audio
|
|
44
|
+
Provides-Extra: cli
|
|
45
|
+
Provides-Extra: pygments
|
|
46
|
+
|
|
47
|
+
<p align="center">
|
|
48
|
+
<picture>
|
|
49
|
+
<source media="(prefers-color-scheme: dark)" srcset="https://github.com/codingjoe/VoIP/raw/main/docs/images/logo-dark.svg">
|
|
50
|
+
<source media="(prefers-color-scheme: light)" srcset="https://github.com/codingjoe/VoIP/raw/main/docs/images/logo-light.svg">
|
|
51
|
+
<img alt="Python VoIP" src="https://github.com/codingjoe/VoIP/raw/main/docs/images/logo-light.svg">
|
|
52
|
+
</picture>
|
|
53
|
+
<br>
|
|
54
|
+
<a href="https://github.com/codingjoe">Documentation</a> |
|
|
55
|
+
<a href="https://github.com/codingjoe/VoIP/issues/new/choose">Issues</a> |
|
|
56
|
+
<a href="https://github.com/codingjoe/VoIP/releases">Changelog</a> |
|
|
57
|
+
<a href="https://github.com/sponsors/codingjoe">Funding</a> ๐
|
|
58
|
+
</p>
|
|
59
|
+
|
|
60
|
+
# Python VoIP library
|
|
61
|
+
|
|
62
|
+
> [!WARNING]
|
|
63
|
+
> This library is in early development and may contain breaking changes. Use with caution.
|
|
64
|
+
|
|
65
|
+
Python asyncio library for SIP telephony ([RFC 3261](https://tools.ietf.org/html/rfc3261)).
|
|
66
|
+
|
|
67
|
+
All signalling uses **SIP over TLS** (SIPS, RFC 3261 ยง26) and all media is
|
|
68
|
+
protected with **SRTP** ([RFC 3711](https://tools.ietf.org/html/rfc3711))
|
|
69
|
+
using the `AES_CM_128_HMAC_SHA1_80` cipher suite with SDES key exchange
|
|
70
|
+
([RFC 4568](https://tools.ietf.org/html/rfc4568)).
|
|
71
|
+
|
|
72
|
+
## Setup
|
|
73
|
+
|
|
74
|
+
```console
|
|
75
|
+
pip install voip[audio,cli,pygments]
|
|
76
|
+
```
|
|
77
|
+
|
|
78
|
+
## Usage
|
|
79
|
+
|
|
80
|
+
### CLI
|
|
81
|
+
|
|
82
|
+
Answer calls and transcribe them live from the terminal:
|
|
83
|
+
|
|
84
|
+
```console
|
|
85
|
+
voip sip transcribe sips:alice@sip.example.com --password secret
|
|
86
|
+
```
|
|
87
|
+
|
|
88
|
+
### Python API
|
|
89
|
+
|
|
90
|
+
Subclass `WhisperCall` and override `transcription_received` to handle results.
|
|
91
|
+
Pass it as `call_class` when answering an incoming call:
|
|
92
|
+
|
|
93
|
+
```python
|
|
94
|
+
import asyncio
|
|
95
|
+
import ssl
|
|
96
|
+
from voip.audio import WhisperCall
|
|
97
|
+
from voip.sip.protocol import SIP
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
class MyCall(WhisperCall):
|
|
101
|
+
def transcription_received(self, text: str) -> None:
|
|
102
|
+
print(f"[{self.caller}] {text}")
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
class MySession(SIP):
|
|
106
|
+
def call_received(self, request) -> None:
|
|
107
|
+
asyncio.create_task(self.answer(request=request, call_class=MyCall))
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
async def main():
|
|
111
|
+
loop = asyncio.get_running_loop()
|
|
112
|
+
ssl_context = ssl.create_default_context()
|
|
113
|
+
await loop.create_connection(
|
|
114
|
+
lambda: MySession(
|
|
115
|
+
aor="sips:alice@example.com",
|
|
116
|
+
username="alice",
|
|
117
|
+
password="secret",
|
|
118
|
+
),
|
|
119
|
+
host="sip.example.com",
|
|
120
|
+
port=5061,
|
|
121
|
+
ssl=ssl_context,
|
|
122
|
+
)
|
|
123
|
+
await asyncio.Future()
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
asyncio.run(main())
|
|
127
|
+
```
|
|
128
|
+
|
|
129
|
+
For raw audio access without transcription, subclass `AudioCall` and override
|
|
130
|
+
`audio_received(self, audio: np.ndarray)` instead.
|
|
131
|
+
|
voip-0.1.0/README.md
ADDED
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
<p align="center">
|
|
2
|
+
<picture>
|
|
3
|
+
<source media="(prefers-color-scheme: dark)" srcset="https://github.com/codingjoe/VoIP/raw/main/docs/images/logo-dark.svg">
|
|
4
|
+
<source media="(prefers-color-scheme: light)" srcset="https://github.com/codingjoe/VoIP/raw/main/docs/images/logo-light.svg">
|
|
5
|
+
<img alt="Python VoIP" src="https://github.com/codingjoe/VoIP/raw/main/docs/images/logo-light.svg">
|
|
6
|
+
</picture>
|
|
7
|
+
<br>
|
|
8
|
+
<a href="https://github.com/codingjoe">Documentation</a> |
|
|
9
|
+
<a href="https://github.com/codingjoe/VoIP/issues/new/choose">Issues</a> |
|
|
10
|
+
<a href="https://github.com/codingjoe/VoIP/releases">Changelog</a> |
|
|
11
|
+
<a href="https://github.com/sponsors/codingjoe">Funding</a> ๐
|
|
12
|
+
</p>
|
|
13
|
+
|
|
14
|
+
# Python VoIP library
|
|
15
|
+
|
|
16
|
+
> [!WARNING]
|
|
17
|
+
> This library is in early development and may contain breaking changes. Use with caution.
|
|
18
|
+
|
|
19
|
+
Python asyncio library for SIP telephony ([RFC 3261](https://tools.ietf.org/html/rfc3261)).
|
|
20
|
+
|
|
21
|
+
All signalling uses **SIP over TLS** (SIPS, RFC 3261 ยง26) and all media is
|
|
22
|
+
protected with **SRTP** ([RFC 3711](https://tools.ietf.org/html/rfc3711))
|
|
23
|
+
using the `AES_CM_128_HMAC_SHA1_80` cipher suite with SDES key exchange
|
|
24
|
+
([RFC 4568](https://tools.ietf.org/html/rfc4568)).
|
|
25
|
+
|
|
26
|
+
## Setup
|
|
27
|
+
|
|
28
|
+
```console
|
|
29
|
+
pip install voip[audio,cli,pygments]
|
|
30
|
+
```
|
|
31
|
+
|
|
32
|
+
## Usage
|
|
33
|
+
|
|
34
|
+
### CLI
|
|
35
|
+
|
|
36
|
+
Answer calls and transcribe them live from the terminal:
|
|
37
|
+
|
|
38
|
+
```console
|
|
39
|
+
voip sip transcribe sips:alice@sip.example.com --password secret
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
### Python API
|
|
43
|
+
|
|
44
|
+
Subclass `WhisperCall` and override `transcription_received` to handle results.
|
|
45
|
+
Pass it as `call_class` when answering an incoming call:
|
|
46
|
+
|
|
47
|
+
```python
|
|
48
|
+
import asyncio
|
|
49
|
+
import ssl
|
|
50
|
+
from voip.audio import WhisperCall
|
|
51
|
+
from voip.sip.protocol import SIP
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
class MyCall(WhisperCall):
|
|
55
|
+
def transcription_received(self, text: str) -> None:
|
|
56
|
+
print(f"[{self.caller}] {text}")
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
class MySession(SIP):
|
|
60
|
+
def call_received(self, request) -> None:
|
|
61
|
+
asyncio.create_task(self.answer(request=request, call_class=MyCall))
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
async def main():
|
|
65
|
+
loop = asyncio.get_running_loop()
|
|
66
|
+
ssl_context = ssl.create_default_context()
|
|
67
|
+
await loop.create_connection(
|
|
68
|
+
lambda: MySession(
|
|
69
|
+
aor="sips:alice@example.com",
|
|
70
|
+
username="alice",
|
|
71
|
+
password="secret",
|
|
72
|
+
),
|
|
73
|
+
host="sip.example.com",
|
|
74
|
+
port=5061,
|
|
75
|
+
ssl=ssl_context,
|
|
76
|
+
)
|
|
77
|
+
await asyncio.Future()
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
asyncio.run(main())
|
|
81
|
+
```
|
|
82
|
+
|
|
83
|
+
For raw audio access without transcription, subclass `AudioCall` and override
|
|
84
|
+
`audio_received(self, audio: np.ndarray)` instead.
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["flit_core>=3.2", "flit_scm", "wheel"]
|
|
3
|
+
build-backend = "flit_scm:buildapi"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "VoIP"
|
|
7
|
+
authors = [
|
|
8
|
+
{ name = "Johannes Maron", email = "johannes@maron.family" },
|
|
9
|
+
]
|
|
10
|
+
readme = "README.md"
|
|
11
|
+
license = { file = "LICENSE" }
|
|
12
|
+
keywords = ["VoIP", "SIP", "RTP", "WebRTC", "STUN", "asyncio"]
|
|
13
|
+
dynamic = ["version", "description"]
|
|
14
|
+
classifiers = [
|
|
15
|
+
# https://pypi.org/classifiers/
|
|
16
|
+
"Development Status :: 3 - Alpha",
|
|
17
|
+
"Environment :: Console",
|
|
18
|
+
"Intended Audience :: Developers",
|
|
19
|
+
"Intended Audience :: Information Technology",
|
|
20
|
+
"Intended Audience :: Telecommunications Industry",
|
|
21
|
+
"License :: OSI Approved :: BSD License",
|
|
22
|
+
"Operating System :: OS Independent",
|
|
23
|
+
"Programming Language :: Python",
|
|
24
|
+
"Programming Language :: Python :: 3",
|
|
25
|
+
"Programming Language :: Python :: 3 :: Only",
|
|
26
|
+
"Programming Language :: Python :: 3.13",
|
|
27
|
+
"Programming Language :: Python :: 3.14",
|
|
28
|
+
"Framework :: AsyncIO",
|
|
29
|
+
"Topic :: Communications :: Internet Phone",
|
|
30
|
+
"Topic :: Communications :: Telephony",
|
|
31
|
+
"Topic :: Multimedia :: Sound/Audio",
|
|
32
|
+
"Topic :: Multimedia :: Sound/Audio :: Speech",
|
|
33
|
+
"Topic :: Scientific/Engineering :: Artificial Intelligence",
|
|
34
|
+
"Topic :: Software Development :: Libraries",
|
|
35
|
+
"Topic :: System :: Networking",
|
|
36
|
+
"Topic :: Home Automation",
|
|
37
|
+
]
|
|
38
|
+
requires-python = ">=3.13"
|
|
39
|
+
dependencies = ["cryptography"]
|
|
40
|
+
|
|
41
|
+
[project.optional-dependencies]
|
|
42
|
+
audio = ["faster-whisper", "numpy", "av"]
|
|
43
|
+
cli = ["click", "pygments", "faster-whisper", "numpy", "av"]
|
|
44
|
+
pygments = ["Pygments"]
|
|
45
|
+
|
|
46
|
+
[project.scripts]
|
|
47
|
+
voip = "voip.__main__:main"
|
|
48
|
+
|
|
49
|
+
[project.entry-points."pygments.lexers"]
|
|
50
|
+
sip = "voip.sip.lexers:SIPLexer"
|
|
51
|
+
sdp = "voip.sdp.lexers:SDPLexer"
|
|
52
|
+
|
|
53
|
+
[tool.flit.module]
|
|
54
|
+
name = "voip"
|
|
55
|
+
|
|
56
|
+
[dependency-groups]
|
|
57
|
+
dev = [
|
|
58
|
+
{ include-group = "test" },
|
|
59
|
+
]
|
|
60
|
+
test = [
|
|
61
|
+
"pytest",
|
|
62
|
+
"pytest-cov",
|
|
63
|
+
"pytest-asyncio>=1.3.0",
|
|
64
|
+
]
|
|
65
|
+
|
|
66
|
+
[project.urls]
|
|
67
|
+
Project-URL = "https://github.com/codingjoe/VoIP"
|
|
68
|
+
Changelog = "https://github.com/codingjoe/VoIP/releases"
|
|
69
|
+
|
|
70
|
+
[tool.setuptools_scm]
|
|
71
|
+
write_to = "voip/_version.py"
|
|
72
|
+
|
|
73
|
+
[tool.pytest.ini_options]
|
|
74
|
+
minversion = "6.0"
|
|
75
|
+
addopts = "--cov --strict-markers --cov-report=xml --cov-report=term"
|
|
76
|
+
asyncio_mode = "auto"
|
|
77
|
+
testpaths = ["tests"]
|
|
78
|
+
markers = [
|
|
79
|
+
"integration: marks tests as integration tests requiring network access (deselect with '-m \"not integration\"')",
|
|
80
|
+
]
|
|
81
|
+
|
|
82
|
+
[tool.coverage.run]
|
|
83
|
+
source = ["voip"]
|
|
84
|
+
|
|
85
|
+
[tool.coverage.report]
|
|
86
|
+
show_missing = true
|
|
87
|
+
|
|
88
|
+
[tool.ruff]
|
|
89
|
+
src = ["voip", "tests"]
|
|
90
|
+
|
|
91
|
+
[tool.ruff.lint]
|
|
92
|
+
select = [
|
|
93
|
+
"E", # pycodestyle errors
|
|
94
|
+
"W", # pycodestyle warnings
|
|
95
|
+
"F", # pyflakes
|
|
96
|
+
"I", # isort
|
|
97
|
+
"S", # flake8-bandit
|
|
98
|
+
"D", # pydocstyle
|
|
99
|
+
"UP", # pyupgrade
|
|
100
|
+
"B", # flake8-bugbear
|
|
101
|
+
"C", # flake8-comprehensions
|
|
102
|
+
]
|
|
103
|
+
|
|
104
|
+
ignore = ["B904", "D1", "E501", "S101"]
|
|
105
|
+
|
|
106
|
+
[tool.ruff.lint.isort]
|
|
107
|
+
combine-as-imports = true
|
|
108
|
+
split-on-trailing-comma = true
|
|
109
|
+
section-order = ["future", "standard-library", "third-party", "first-party", "local-folder"]
|
|
110
|
+
force-wrap-aliases = true
|
|
111
|
+
|
|
112
|
+
[tool.ruff.lint.pydocstyle]
|
|
113
|
+
convention = "pep257"
|
|
@@ -0,0 +1,292 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
import asyncio
|
|
3
|
+
import dataclasses
|
|
4
|
+
import logging
|
|
5
|
+
import ssl
|
|
6
|
+
import time
|
|
7
|
+
|
|
8
|
+
from voip.sip import messages
|
|
9
|
+
|
|
10
|
+
try:
|
|
11
|
+
import click
|
|
12
|
+
from pygments import formatters, highlight
|
|
13
|
+
|
|
14
|
+
from voip.sip.lexers import SIPLexer
|
|
15
|
+
except ImportError as e:
|
|
16
|
+
raise ImportError(
|
|
17
|
+
"The VoIP CLI requires extra dependencies. Install via `pip install voip[cli]`."
|
|
18
|
+
) from e
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class ConsoleMessageProcessor:
|
|
22
|
+
"""Protocol mixin that prints messages to stdout."""
|
|
23
|
+
|
|
24
|
+
def request_received(self, request: messages.Request, addr: tuple[str, int]):
|
|
25
|
+
self.pprint(request)
|
|
26
|
+
super().request_received(request, addr)
|
|
27
|
+
|
|
28
|
+
def response_received(self, response: messages.Response, addr: tuple[str, int]):
|
|
29
|
+
self.pprint(response)
|
|
30
|
+
super().response_received(response, addr)
|
|
31
|
+
|
|
32
|
+
def send(self, message) -> None:
|
|
33
|
+
"""Send a message and print it to stdout."""
|
|
34
|
+
self.pprint(message)
|
|
35
|
+
super().send(message)
|
|
36
|
+
|
|
37
|
+
def pprint(self, msg):
|
|
38
|
+
"""Pretty print the message."""
|
|
39
|
+
transport = getattr(self, "transport", None)
|
|
40
|
+
addr = transport.get_extra_info("peername") if transport else None
|
|
41
|
+
if addr:
|
|
42
|
+
host = f"[{addr[0]}]" if ":" in addr[0] else addr[0]
|
|
43
|
+
host = click.style(host, fg="green", bold=True)
|
|
44
|
+
port = click.style(str(addr[1]), fg="yellow", bold=True)
|
|
45
|
+
prefix = f"{host}:{port} - - [{time.asctime()}]"
|
|
46
|
+
else:
|
|
47
|
+
prefix = f"[unknown] - - [{time.asctime()}]"
|
|
48
|
+
pretty_msg = highlight(str(msg), SIPLexer(), formatters.TerminalFormatter())
|
|
49
|
+
click.echo(f"{prefix} {pretty_msg}")
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
@click.group()
|
|
53
|
+
@click.option("-v", "--verbose", count=True, help="Increase verbosity.")
|
|
54
|
+
@click.pass_context
|
|
55
|
+
def voip(ctx, verbose):
|
|
56
|
+
"""VoIP command line interface."""
|
|
57
|
+
ctx.ensure_object(dict)
|
|
58
|
+
ctx.obj["verbose"] = verbose
|
|
59
|
+
logging.basicConfig(
|
|
60
|
+
level=max(10, 10 * (4 - verbose)),
|
|
61
|
+
format="%(levelname)s: [%(asctime)s] (%(name)s) %(message)s",
|
|
62
|
+
handlers=[logging.StreamHandler()],
|
|
63
|
+
)
|
|
64
|
+
logging.getLogger("voip").setLevel(max(10, 10 * (3 - verbose)))
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
@voip.group()
|
|
68
|
+
def sip():
|
|
69
|
+
"""Session Initiation Protocol (SIP)."""
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
main = voip
|
|
73
|
+
|
|
74
|
+
#: Standard SIP/TCP port โ plain text, no TLS (RFC 3261 ยง18.2).
|
|
75
|
+
SIP_TCP_PORT = 5060
|
|
76
|
+
#: Standard SIP/TLS port (RFC 3261 ยง26.2.2).
|
|
77
|
+
SIP_TLS_PORT = 5061
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def _parse_aor(value: str) -> tuple[str, str, str, int | None]:
|
|
81
|
+
"""Parse a SIP URI into ``(scheme, user, host, port)``.
|
|
82
|
+
|
|
83
|
+
The port is ``None`` when not present in the URI.
|
|
84
|
+
|
|
85
|
+
Examples::
|
|
86
|
+
|
|
87
|
+
>>> _parse_aor("sip:alice@example.com")
|
|
88
|
+
('sip', 'alice', 'example.com', None)
|
|
89
|
+
>>> _parse_aor("sips:+15551234567@carrier.com:5061")
|
|
90
|
+
('sips', '+15551234567', 'carrier.com', 5061)
|
|
91
|
+
"""
|
|
92
|
+
scheme, _, rest = value.partition(":")
|
|
93
|
+
if not scheme or not rest:
|
|
94
|
+
raise click.BadParameter(
|
|
95
|
+
f"Invalid SIP URI: {value!r}. Expected sip[s]:user@host[:port]."
|
|
96
|
+
)
|
|
97
|
+
user_part, _, hostport = rest.partition("@")
|
|
98
|
+
if not hostport:
|
|
99
|
+
raise click.BadParameter(f"Invalid SIP URI: {value!r}. Missing user@host part.")
|
|
100
|
+
host, _, port_str = hostport.partition(":")
|
|
101
|
+
if not host:
|
|
102
|
+
raise click.BadParameter(f"Invalid SIP URI: {value!r}. Missing host.")
|
|
103
|
+
port: int | None = int(port_str) if port_str else None
|
|
104
|
+
return scheme, user_part, host, port
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def _parse_hostport(
|
|
108
|
+
ctx, param, value: str, default_port: int = 5061
|
|
109
|
+
) -> tuple[str, int]:
|
|
110
|
+
"""Parse ``HOST[:PORT]`` into a ``(host, port)`` tuple."""
|
|
111
|
+
host, _, port_str = value.rpartition(":")
|
|
112
|
+
if not host:
|
|
113
|
+
return value, default_port
|
|
114
|
+
try:
|
|
115
|
+
return host, int(port_str)
|
|
116
|
+
except ValueError:
|
|
117
|
+
raise click.BadParameter(f"Invalid port in {value!r}.", param=param) from None
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def _parse_stun_server(ctx, param, value: str | None) -> tuple[str, int] | None:
|
|
121
|
+
"""Parse the --stun-server option; return None when the value is 'none'."""
|
|
122
|
+
if value is None or value.lower() == "none":
|
|
123
|
+
return None
|
|
124
|
+
return _parse_hostport(ctx, param, value, default_port=3478)
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
# Keep the old name as an alias so existing internal callers still work.
|
|
128
|
+
_parse_server = _parse_hostport
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
@sip.command()
|
|
132
|
+
@click.argument("aor", metavar="AOR", envvar="SIP_AOR")
|
|
133
|
+
@click.option(
|
|
134
|
+
"--model",
|
|
135
|
+
default="base",
|
|
136
|
+
envvar="WHISPER_MODEL",
|
|
137
|
+
show_default=True,
|
|
138
|
+
help="Whisper model size.",
|
|
139
|
+
)
|
|
140
|
+
@click.option(
|
|
141
|
+
"--password",
|
|
142
|
+
envvar="SIP_PASSWORD",
|
|
143
|
+
required=True,
|
|
144
|
+
help="SIP password (not parsed from AOR for security).",
|
|
145
|
+
)
|
|
146
|
+
@click.option(
|
|
147
|
+
"--username",
|
|
148
|
+
envvar="SIP_USERNAME",
|
|
149
|
+
default=None,
|
|
150
|
+
help="Override SIP username (defaults to user part of AOR).",
|
|
151
|
+
)
|
|
152
|
+
@click.option(
|
|
153
|
+
"--proxy",
|
|
154
|
+
envvar="SIP_PROXY",
|
|
155
|
+
default=None,
|
|
156
|
+
metavar="HOST[:PORT]",
|
|
157
|
+
help=(
|
|
158
|
+
"Outbound proxy address (RFC 3261 ยง8.1.2). "
|
|
159
|
+
"Defaults to the host and port from AOR. "
|
|
160
|
+
"Use this when the proxy differs from the registrar domain."
|
|
161
|
+
),
|
|
162
|
+
)
|
|
163
|
+
@click.option(
|
|
164
|
+
"--stun-server",
|
|
165
|
+
envvar="STUN_SERVER",
|
|
166
|
+
default="stun.cloudflare.com:3478",
|
|
167
|
+
show_default=True,
|
|
168
|
+
metavar="HOST[:PORT]",
|
|
169
|
+
callback=_parse_stun_server,
|
|
170
|
+
is_eager=False,
|
|
171
|
+
help="STUN server for RTP NAT traversal (use 'none' to disable).",
|
|
172
|
+
)
|
|
173
|
+
@click.option(
|
|
174
|
+
"--no-tls",
|
|
175
|
+
is_flag=True,
|
|
176
|
+
default=False,
|
|
177
|
+
help=(
|
|
178
|
+
"Force plain TCP โ skips TLS. "
|
|
179
|
+
"Auto-selected when port 5060 is used; explicit flag overrides any port."
|
|
180
|
+
),
|
|
181
|
+
)
|
|
182
|
+
@click.option(
|
|
183
|
+
"--no-verify-tls",
|
|
184
|
+
is_flag=True,
|
|
185
|
+
default=False,
|
|
186
|
+
help="Disable TLS certificate verification (insecure; for testing only).",
|
|
187
|
+
)
|
|
188
|
+
@click.pass_context
|
|
189
|
+
def transcribe(
|
|
190
|
+
ctx, aor, model, password, username, proxy, stun_server, no_tls, no_verify_tls
|
|
191
|
+
):
|
|
192
|
+
r"""Register with a SIP carrier and transcribe incoming calls via Whisper.
|
|
193
|
+
|
|
194
|
+
AOR is a SIP Address of Record URI identifying the account to register,
|
|
195
|
+
e.g. ``sips:alice@carrier.example.com`` or ``sip:alice@carrier.example.com:5060``.
|
|
196
|
+
|
|
197
|
+
\b
|
|
198
|
+
Transport selection (overridable with --no-tls):
|
|
199
|
+
sips: URI or port 5061 โ TLS (default)
|
|
200
|
+
sip: URI or port 5060 โ plain TCP
|
|
201
|
+
|
|
202
|
+
\b
|
|
203
|
+
Examples:
|
|
204
|
+
voip sip transcribe sips:alice@sip.example.com --password secret
|
|
205
|
+
voip sip transcribe sip:alice@sip.example.com:5060 --password secret
|
|
206
|
+
voip sip transcribe sips:alice@carrier.com --proxy proxy.carrier.com --password secret
|
|
207
|
+
"""
|
|
208
|
+
from voip.sip.protocol import SIP
|
|
209
|
+
|
|
210
|
+
from .audio import WhisperCall # noqa: PLC0415
|
|
211
|
+
|
|
212
|
+
try:
|
|
213
|
+
scheme, aor_user, aor_host, aor_port = _parse_aor(aor)
|
|
214
|
+
except click.BadParameter as exc:
|
|
215
|
+
raise click.BadParameter(str(exc), param_hint="AOR") from exc
|
|
216
|
+
|
|
217
|
+
effective_username = username or aor_user
|
|
218
|
+
|
|
219
|
+
# Determine outbound proxy address: --proxy overrides, otherwise use AOR host.
|
|
220
|
+
if proxy is not None:
|
|
221
|
+
proxy_addr = _parse_hostport(ctx, None, proxy)
|
|
222
|
+
else:
|
|
223
|
+
# Default port: SIP_TCP_PORT for sip scheme, SIP_TLS_PORT for sips.
|
|
224
|
+
default_port = SIP_TCP_PORT if scheme == "sip" else SIP_TLS_PORT
|
|
225
|
+
port = aor_port if aor_port is not None else default_port
|
|
226
|
+
proxy_addr = (aor_host, port)
|
|
227
|
+
|
|
228
|
+
# Transport: port 5060 (SIP_TCP_PORT) โ plain TCP; any other port โ TLS.
|
|
229
|
+
# --no-tls always overrides this auto-detection.
|
|
230
|
+
use_tls = not no_tls and proxy_addr[1] != SIP_TCP_PORT
|
|
231
|
+
|
|
232
|
+
# The AOR stored in the protocol must NOT include the port
|
|
233
|
+
# (AOR is scheme:user@host per RFC 3261 ยง10).
|
|
234
|
+
normalized_aor = f"{scheme}:{effective_username}@{aor_host}"
|
|
235
|
+
|
|
236
|
+
verbose = ctx.obj.get("verbose", 0)
|
|
237
|
+
|
|
238
|
+
# Capture the CLI model arg as the dataclass field default so that the
|
|
239
|
+
# class can still be passed as a plain type to SIP.answer().
|
|
240
|
+
_model = model
|
|
241
|
+
|
|
242
|
+
@dataclasses.dataclass
|
|
243
|
+
class TranscribingCall(WhisperCall):
|
|
244
|
+
"""WhisperCall with the CLI-selected model and console output."""
|
|
245
|
+
|
|
246
|
+
model: str = _model
|
|
247
|
+
|
|
248
|
+
def transcription_received(self, text: str) -> None:
|
|
249
|
+
click.echo(click.style(text, fg="green", bold=True))
|
|
250
|
+
|
|
251
|
+
# Mix in ConsoleMessageProcessor only at maximum verbosity (-vvv) so that
|
|
252
|
+
# normal operation is not flooded with protocol-level message dumps.
|
|
253
|
+
bases = (ConsoleMessageProcessor, SIP) if verbose >= 3 else (SIP,)
|
|
254
|
+
|
|
255
|
+
class TranscribeSession(*bases):
|
|
256
|
+
def call_received(self, request) -> None:
|
|
257
|
+
self.ringing(request=request)
|
|
258
|
+
asyncio.create_task(
|
|
259
|
+
self.answer(request=request, call_class=TranscribingCall)
|
|
260
|
+
)
|
|
261
|
+
|
|
262
|
+
async def run():
|
|
263
|
+
loop = asyncio.get_running_loop()
|
|
264
|
+
if use_tls:
|
|
265
|
+
ssl_context = ssl.create_default_context()
|
|
266
|
+
if no_verify_tls:
|
|
267
|
+
ssl_context.check_hostname = False
|
|
268
|
+
ssl_context.verify_mode = ssl.CERT_NONE
|
|
269
|
+
else:
|
|
270
|
+
ssl_context = None
|
|
271
|
+
await loop.create_connection(
|
|
272
|
+
lambda: TranscribeSession(
|
|
273
|
+
outbound_proxy=proxy_addr,
|
|
274
|
+
aor=normalized_aor,
|
|
275
|
+
username=effective_username,
|
|
276
|
+
password=password,
|
|
277
|
+
rtp_stun_server_address=stun_server,
|
|
278
|
+
),
|
|
279
|
+
host=proxy_addr[0],
|
|
280
|
+
port=proxy_addr[1],
|
|
281
|
+
ssl=ssl_context,
|
|
282
|
+
)
|
|
283
|
+
await asyncio.Future()
|
|
284
|
+
|
|
285
|
+
try:
|
|
286
|
+
asyncio.run(run())
|
|
287
|
+
except KeyboardInterrupt:
|
|
288
|
+
pass
|
|
289
|
+
|
|
290
|
+
|
|
291
|
+
if __name__ == "__main__": # pragma: no cover
|
|
292
|
+
main()
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
# file generated by setuptools-scm
|
|
2
|
+
# don't change, don't track in version control
|
|
3
|
+
|
|
4
|
+
__all__ = [
|
|
5
|
+
"__version__",
|
|
6
|
+
"__version_tuple__",
|
|
7
|
+
"version",
|
|
8
|
+
"version_tuple",
|
|
9
|
+
"__commit_id__",
|
|
10
|
+
"commit_id",
|
|
11
|
+
]
|
|
12
|
+
|
|
13
|
+
TYPE_CHECKING = False
|
|
14
|
+
if TYPE_CHECKING:
|
|
15
|
+
from typing import Tuple
|
|
16
|
+
from typing import Union
|
|
17
|
+
|
|
18
|
+
VERSION_TUPLE = Tuple[Union[int, str], ...]
|
|
19
|
+
COMMIT_ID = Union[str, None]
|
|
20
|
+
else:
|
|
21
|
+
VERSION_TUPLE = object
|
|
22
|
+
COMMIT_ID = object
|
|
23
|
+
|
|
24
|
+
version: str
|
|
25
|
+
__version__: str
|
|
26
|
+
__version_tuple__: VERSION_TUPLE
|
|
27
|
+
version_tuple: VERSION_TUPLE
|
|
28
|
+
commit_id: COMMIT_ID
|
|
29
|
+
__commit_id__: COMMIT_ID
|
|
30
|
+
|
|
31
|
+
__version__ = version = '0.1.0'
|
|
32
|
+
__version_tuple__ = version_tuple = (0, 1, 0)
|
|
33
|
+
|
|
34
|
+
__commit_id__ = commit_id = 'g1a0b5c6d1'
|