nextcloud-talk-capture 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.
- nextcloud_talk_capture-0.1.0/PKG-INFO +88 -0
- nextcloud_talk_capture-0.1.0/README.md +62 -0
- nextcloud_talk_capture-0.1.0/pyproject.toml +43 -0
- nextcloud_talk_capture-0.1.0/setup.cfg +4 -0
- nextcloud_talk_capture-0.1.0/src/nextcloud_talk_capture.egg-info/PKG-INFO +88 -0
- nextcloud_talk_capture-0.1.0/src/nextcloud_talk_capture.egg-info/SOURCES.txt +17 -0
- nextcloud_talk_capture-0.1.0/src/nextcloud_talk_capture.egg-info/dependency_links.txt +1 -0
- nextcloud_talk_capture-0.1.0/src/nextcloud_talk_capture.egg-info/requires.txt +6 -0
- nextcloud_talk_capture-0.1.0/src/nextcloud_talk_capture.egg-info/top_level.txt +1 -0
- nextcloud_talk_capture-0.1.0/src/talk_capture/__init__.py +12 -0
- nextcloud_talk_capture-0.1.0/src/talk_capture/aioice_patch.py +39 -0
- nextcloud_talk_capture-0.1.0/src/talk_capture/chat_commands.py +170 -0
- nextcloud_talk_capture-0.1.0/src/talk_capture/config.py +124 -0
- nextcloud_talk_capture-0.1.0/src/talk_capture/contracts.py +52 -0
- nextcloud_talk_capture-0.1.0/src/talk_capture/monitor.py +206 -0
- nextcloud_talk_capture-0.1.0/src/talk_capture/presence.py +84 -0
- nextcloud_talk_capture-0.1.0/src/talk_capture/resolve.py +112 -0
- nextcloud_talk_capture-0.1.0/src/talk_capture/spreed_client.py +1444 -0
- nextcloud_talk_capture-0.1.0/src/talk_capture/transcriber_backend.py +382 -0
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: nextcloud-talk-capture
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Capture Nextcloud Talk calls: signalling client, per-speaker audio, call monitoring
|
|
5
|
+
Author: Done
|
|
6
|
+
License: MIT
|
|
7
|
+
Project-URL: Homepage, https://github.com/devsoftmus/done-transcription-app
|
|
8
|
+
Project-URL: Issues, https://github.com/devsoftmus/done-transcription-app/issues
|
|
9
|
+
Keywords: nextcloud,nextcloud-talk,webrtc,transcription,audio-capture
|
|
10
|
+
Classifier: Development Status :: 4 - Beta
|
|
11
|
+
Classifier: Intended Audience :: Developers
|
|
12
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
13
|
+
Classifier: Programming Language :: Python :: 3
|
|
14
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
15
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
16
|
+
Classifier: Topic :: Communications :: Conferencing
|
|
17
|
+
Classifier: Topic :: Multimedia :: Sound/Audio :: Capture/Recording
|
|
18
|
+
Requires-Python: >=3.11
|
|
19
|
+
Description-Content-Type: text/markdown
|
|
20
|
+
Requires-Dist: aiohttp>=3.9.0
|
|
21
|
+
Requires-Dist: websockets>=12.0
|
|
22
|
+
Requires-Dist: aiortc>=1.9.0
|
|
23
|
+
Requires-Dist: pymysql>=1.1.0
|
|
24
|
+
Requires-Dist: sshtunnel>=0.4.0
|
|
25
|
+
Requires-Dist: paramiko<4.0,>=3.0
|
|
26
|
+
|
|
27
|
+
# nextcloud-talk-capture
|
|
28
|
+
|
|
29
|
+
Capture **Nextcloud Talk** calls: join as a signalling client, receive one audio
|
|
30
|
+
stream per speaker, and hand over the finished call as plain data.
|
|
31
|
+
|
|
32
|
+
Import name is `talk_capture`.
|
|
33
|
+
|
|
34
|
+
```python
|
|
35
|
+
from talk_capture import CaptureConfig
|
|
36
|
+
from talk_capture.monitor import CallMonitor
|
|
37
|
+
from talk_capture.spreed_client import SpreedClient
|
|
38
|
+
```
|
|
39
|
+
|
|
40
|
+
## What it does — and what it deliberately does not
|
|
41
|
+
|
|
42
|
+
This is the capture half of a transcription pipeline: it gets the audio and
|
|
43
|
+
tells you who was speaking. What happens afterwards — speech recognition,
|
|
44
|
+
summaries, publishing, storage — is not its business, and it holds no dependency
|
|
45
|
+
on any of it.
|
|
46
|
+
|
|
47
|
+
That separation is the whole point. The same package runs inside a Nextcloud
|
|
48
|
+
ExApp, inside a standalone capture service, or inside an application that does
|
|
49
|
+
everything, without dragging a product's worth of code along.
|
|
50
|
+
|
|
51
|
+
| Module | |
|
|
52
|
+
|---|---|
|
|
53
|
+
| `spreed_client` | signalling client: HPB + aiortc, per-speaker audio |
|
|
54
|
+
| `monitor` | active-call detection (polls the Nextcloud DB, optionally over an SSH tunnel) |
|
|
55
|
+
| `presence` | join/leave annotations (pure functions, no I/O) |
|
|
56
|
+
| `chat_commands` | opt-out commands posted in the room chat, honoured mid-call |
|
|
57
|
+
| `resolve` | assembles a `FinishedCall` when the call ends |
|
|
58
|
+
| `transcriber_backend` | sends audio to an engine, in-process or over gRPC |
|
|
59
|
+
| `contracts` | `FinishedCall`, `SpeakerStatInput` — the payload handed downstream |
|
|
60
|
+
| `config` | `CaptureConfig` — what capture needs, nothing more |
|
|
61
|
+
| `aioice_patch` | works around an aioice race; import it before aiortc |
|
|
62
|
+
|
|
63
|
+
## Requirements
|
|
64
|
+
|
|
65
|
+
- Nextcloud Talk with a **High Performance Backend**. Call media lives in the
|
|
66
|
+
signalling server, not in the PHP layer, so an HPB is required.
|
|
67
|
+
- Python 3.11+
|
|
68
|
+
|
|
69
|
+
## Configuration
|
|
70
|
+
|
|
71
|
+
`CaptureConfig` carries what capture needs: Nextcloud credentials, the
|
|
72
|
+
signalling server and its secret, call-detection database access, the room
|
|
73
|
+
filter, and where to send the audio. Build it from the environment with
|
|
74
|
+
`CaptureConfig.from_env()`, or pass any object exposing the same attributes —
|
|
75
|
+
config is taken duck-typed, so a larger application config works unchanged.
|
|
76
|
+
|
|
77
|
+
## Contributing
|
|
78
|
+
|
|
79
|
+
Two rules keep the package shippable on its own, both enforced by tests in the
|
|
80
|
+
consuming repository:
|
|
81
|
+
|
|
82
|
+
- nothing here may import the downstream half (publishing, analysis, storage);
|
|
83
|
+
- a config field must keep its name, type and default in sync with any larger
|
|
84
|
+
config that is passed in — a mismatch fails mid-call, not at import.
|
|
85
|
+
|
|
86
|
+
## Licence
|
|
87
|
+
|
|
88
|
+
MIT.
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
# nextcloud-talk-capture
|
|
2
|
+
|
|
3
|
+
Capture **Nextcloud Talk** calls: join as a signalling client, receive one audio
|
|
4
|
+
stream per speaker, and hand over the finished call as plain data.
|
|
5
|
+
|
|
6
|
+
Import name is `talk_capture`.
|
|
7
|
+
|
|
8
|
+
```python
|
|
9
|
+
from talk_capture import CaptureConfig
|
|
10
|
+
from talk_capture.monitor import CallMonitor
|
|
11
|
+
from talk_capture.spreed_client import SpreedClient
|
|
12
|
+
```
|
|
13
|
+
|
|
14
|
+
## What it does — and what it deliberately does not
|
|
15
|
+
|
|
16
|
+
This is the capture half of a transcription pipeline: it gets the audio and
|
|
17
|
+
tells you who was speaking. What happens afterwards — speech recognition,
|
|
18
|
+
summaries, publishing, storage — is not its business, and it holds no dependency
|
|
19
|
+
on any of it.
|
|
20
|
+
|
|
21
|
+
That separation is the whole point. The same package runs inside a Nextcloud
|
|
22
|
+
ExApp, inside a standalone capture service, or inside an application that does
|
|
23
|
+
everything, without dragging a product's worth of code along.
|
|
24
|
+
|
|
25
|
+
| Module | |
|
|
26
|
+
|---|---|
|
|
27
|
+
| `spreed_client` | signalling client: HPB + aiortc, per-speaker audio |
|
|
28
|
+
| `monitor` | active-call detection (polls the Nextcloud DB, optionally over an SSH tunnel) |
|
|
29
|
+
| `presence` | join/leave annotations (pure functions, no I/O) |
|
|
30
|
+
| `chat_commands` | opt-out commands posted in the room chat, honoured mid-call |
|
|
31
|
+
| `resolve` | assembles a `FinishedCall` when the call ends |
|
|
32
|
+
| `transcriber_backend` | sends audio to an engine, in-process or over gRPC |
|
|
33
|
+
| `contracts` | `FinishedCall`, `SpeakerStatInput` — the payload handed downstream |
|
|
34
|
+
| `config` | `CaptureConfig` — what capture needs, nothing more |
|
|
35
|
+
| `aioice_patch` | works around an aioice race; import it before aiortc |
|
|
36
|
+
|
|
37
|
+
## Requirements
|
|
38
|
+
|
|
39
|
+
- Nextcloud Talk with a **High Performance Backend**. Call media lives in the
|
|
40
|
+
signalling server, not in the PHP layer, so an HPB is required.
|
|
41
|
+
- Python 3.11+
|
|
42
|
+
|
|
43
|
+
## Configuration
|
|
44
|
+
|
|
45
|
+
`CaptureConfig` carries what capture needs: Nextcloud credentials, the
|
|
46
|
+
signalling server and its secret, call-detection database access, the room
|
|
47
|
+
filter, and where to send the audio. Build it from the environment with
|
|
48
|
+
`CaptureConfig.from_env()`, or pass any object exposing the same attributes —
|
|
49
|
+
config is taken duck-typed, so a larger application config works unchanged.
|
|
50
|
+
|
|
51
|
+
## Contributing
|
|
52
|
+
|
|
53
|
+
Two rules keep the package shippable on its own, both enforced by tests in the
|
|
54
|
+
consuming repository:
|
|
55
|
+
|
|
56
|
+
- nothing here may import the downstream half (publishing, analysis, storage);
|
|
57
|
+
- a config field must keep its name, type and default in sync with any larger
|
|
58
|
+
config that is passed in — a mismatch fails mid-call, not at import.
|
|
59
|
+
|
|
60
|
+
## Licence
|
|
61
|
+
|
|
62
|
+
MIT.
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["setuptools>=68"]
|
|
3
|
+
build-backend = "setuptools.build_meta"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
# Distribution name is explicit about the domain (PyPI is a global namespace);
|
|
7
|
+
# the import name stays `talk_capture`.
|
|
8
|
+
name = "nextcloud-talk-capture"
|
|
9
|
+
version = "0.1.0"
|
|
10
|
+
description = "Capture Nextcloud Talk calls: signalling client, per-speaker audio, call monitoring"
|
|
11
|
+
readme = "README.md"
|
|
12
|
+
requires-python = ">=3.11"
|
|
13
|
+
# MIT on purpose, not by default: this package is consumed both by an AGPL
|
|
14
|
+
# Nextcloud app and by a proprietary backend. Copyleft here would reach through
|
|
15
|
+
# into the latter; MIT is compatible in both directions.
|
|
16
|
+
license = { text = "MIT" }
|
|
17
|
+
authors = [{ name = "Done" }]
|
|
18
|
+
keywords = ["nextcloud", "nextcloud-talk", "webrtc", "transcription", "audio-capture"]
|
|
19
|
+
classifiers = [
|
|
20
|
+
"Development Status :: 4 - Beta",
|
|
21
|
+
"Intended Audience :: Developers",
|
|
22
|
+
"License :: OSI Approved :: MIT License",
|
|
23
|
+
"Programming Language :: Python :: 3",
|
|
24
|
+
"Programming Language :: Python :: 3.11",
|
|
25
|
+
"Programming Language :: Python :: 3.12",
|
|
26
|
+
"Topic :: Communications :: Conferencing",
|
|
27
|
+
"Topic :: Multimedia :: Sound/Audio :: Capture/Recording",
|
|
28
|
+
]
|
|
29
|
+
dependencies = [
|
|
30
|
+
"aiohttp>=3.9.0",
|
|
31
|
+
"websockets>=12.0",
|
|
32
|
+
"aiortc>=1.9.0",
|
|
33
|
+
"pymysql>=1.1.0",
|
|
34
|
+
"sshtunnel>=0.4.0",
|
|
35
|
+
"paramiko>=3.0,<4.0",
|
|
36
|
+
]
|
|
37
|
+
|
|
38
|
+
[project.urls]
|
|
39
|
+
Homepage = "https://github.com/devsoftmus/done-transcription-app"
|
|
40
|
+
Issues = "https://github.com/devsoftmus/done-transcription-app/issues"
|
|
41
|
+
|
|
42
|
+
[tool.setuptools.packages.find]
|
|
43
|
+
where = ["src"]
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: nextcloud-talk-capture
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Capture Nextcloud Talk calls: signalling client, per-speaker audio, call monitoring
|
|
5
|
+
Author: Done
|
|
6
|
+
License: MIT
|
|
7
|
+
Project-URL: Homepage, https://github.com/devsoftmus/done-transcription-app
|
|
8
|
+
Project-URL: Issues, https://github.com/devsoftmus/done-transcription-app/issues
|
|
9
|
+
Keywords: nextcloud,nextcloud-talk,webrtc,transcription,audio-capture
|
|
10
|
+
Classifier: Development Status :: 4 - Beta
|
|
11
|
+
Classifier: Intended Audience :: Developers
|
|
12
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
13
|
+
Classifier: Programming Language :: Python :: 3
|
|
14
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
15
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
16
|
+
Classifier: Topic :: Communications :: Conferencing
|
|
17
|
+
Classifier: Topic :: Multimedia :: Sound/Audio :: Capture/Recording
|
|
18
|
+
Requires-Python: >=3.11
|
|
19
|
+
Description-Content-Type: text/markdown
|
|
20
|
+
Requires-Dist: aiohttp>=3.9.0
|
|
21
|
+
Requires-Dist: websockets>=12.0
|
|
22
|
+
Requires-Dist: aiortc>=1.9.0
|
|
23
|
+
Requires-Dist: pymysql>=1.1.0
|
|
24
|
+
Requires-Dist: sshtunnel>=0.4.0
|
|
25
|
+
Requires-Dist: paramiko<4.0,>=3.0
|
|
26
|
+
|
|
27
|
+
# nextcloud-talk-capture
|
|
28
|
+
|
|
29
|
+
Capture **Nextcloud Talk** calls: join as a signalling client, receive one audio
|
|
30
|
+
stream per speaker, and hand over the finished call as plain data.
|
|
31
|
+
|
|
32
|
+
Import name is `talk_capture`.
|
|
33
|
+
|
|
34
|
+
```python
|
|
35
|
+
from talk_capture import CaptureConfig
|
|
36
|
+
from talk_capture.monitor import CallMonitor
|
|
37
|
+
from talk_capture.spreed_client import SpreedClient
|
|
38
|
+
```
|
|
39
|
+
|
|
40
|
+
## What it does — and what it deliberately does not
|
|
41
|
+
|
|
42
|
+
This is the capture half of a transcription pipeline: it gets the audio and
|
|
43
|
+
tells you who was speaking. What happens afterwards — speech recognition,
|
|
44
|
+
summaries, publishing, storage — is not its business, and it holds no dependency
|
|
45
|
+
on any of it.
|
|
46
|
+
|
|
47
|
+
That separation is the whole point. The same package runs inside a Nextcloud
|
|
48
|
+
ExApp, inside a standalone capture service, or inside an application that does
|
|
49
|
+
everything, without dragging a product's worth of code along.
|
|
50
|
+
|
|
51
|
+
| Module | |
|
|
52
|
+
|---|---|
|
|
53
|
+
| `spreed_client` | signalling client: HPB + aiortc, per-speaker audio |
|
|
54
|
+
| `monitor` | active-call detection (polls the Nextcloud DB, optionally over an SSH tunnel) |
|
|
55
|
+
| `presence` | join/leave annotations (pure functions, no I/O) |
|
|
56
|
+
| `chat_commands` | opt-out commands posted in the room chat, honoured mid-call |
|
|
57
|
+
| `resolve` | assembles a `FinishedCall` when the call ends |
|
|
58
|
+
| `transcriber_backend` | sends audio to an engine, in-process or over gRPC |
|
|
59
|
+
| `contracts` | `FinishedCall`, `SpeakerStatInput` — the payload handed downstream |
|
|
60
|
+
| `config` | `CaptureConfig` — what capture needs, nothing more |
|
|
61
|
+
| `aioice_patch` | works around an aioice race; import it before aiortc |
|
|
62
|
+
|
|
63
|
+
## Requirements
|
|
64
|
+
|
|
65
|
+
- Nextcloud Talk with a **High Performance Backend**. Call media lives in the
|
|
66
|
+
signalling server, not in the PHP layer, so an HPB is required.
|
|
67
|
+
- Python 3.11+
|
|
68
|
+
|
|
69
|
+
## Configuration
|
|
70
|
+
|
|
71
|
+
`CaptureConfig` carries what capture needs: Nextcloud credentials, the
|
|
72
|
+
signalling server and its secret, call-detection database access, the room
|
|
73
|
+
filter, and where to send the audio. Build it from the environment with
|
|
74
|
+
`CaptureConfig.from_env()`, or pass any object exposing the same attributes —
|
|
75
|
+
config is taken duck-typed, so a larger application config works unchanged.
|
|
76
|
+
|
|
77
|
+
## Contributing
|
|
78
|
+
|
|
79
|
+
Two rules keep the package shippable on its own, both enforced by tests in the
|
|
80
|
+
consuming repository:
|
|
81
|
+
|
|
82
|
+
- nothing here may import the downstream half (publishing, analysis, storage);
|
|
83
|
+
- a config field must keep its name, type and default in sync with any larger
|
|
84
|
+
config that is passed in — a mismatch fails mid-call, not at import.
|
|
85
|
+
|
|
86
|
+
## Licence
|
|
87
|
+
|
|
88
|
+
MIT.
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
README.md
|
|
2
|
+
pyproject.toml
|
|
3
|
+
src/nextcloud_talk_capture.egg-info/PKG-INFO
|
|
4
|
+
src/nextcloud_talk_capture.egg-info/SOURCES.txt
|
|
5
|
+
src/nextcloud_talk_capture.egg-info/dependency_links.txt
|
|
6
|
+
src/nextcloud_talk_capture.egg-info/requires.txt
|
|
7
|
+
src/nextcloud_talk_capture.egg-info/top_level.txt
|
|
8
|
+
src/talk_capture/__init__.py
|
|
9
|
+
src/talk_capture/aioice_patch.py
|
|
10
|
+
src/talk_capture/chat_commands.py
|
|
11
|
+
src/talk_capture/config.py
|
|
12
|
+
src/talk_capture/contracts.py
|
|
13
|
+
src/talk_capture/monitor.py
|
|
14
|
+
src/talk_capture/presence.py
|
|
15
|
+
src/talk_capture/resolve.py
|
|
16
|
+
src/talk_capture/spreed_client.py
|
|
17
|
+
src/talk_capture/transcriber_backend.py
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
talk_capture
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
"""Nextcloud Talk call capture — signalling, monitoring, and the brain contract.
|
|
2
|
+
|
|
3
|
+
Everything needed to join a call, receive per-speaker audio, and hand over a
|
|
4
|
+
FinishedCall. Deliberately knows nothing about what happens next: publishing,
|
|
5
|
+
analysis and storage belong to whatever consumes this package, and nothing
|
|
6
|
+
here may import them.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from .config import CaptureConfig
|
|
10
|
+
from .contracts import FinishedCall, SpeakerStatInput
|
|
11
|
+
|
|
12
|
+
__all__ = ["CaptureConfig", "FinishedCall", "SpeakerStatInput"]
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
"""Monkey-patch for aioice Transaction.__retry race condition.
|
|
2
|
+
|
|
3
|
+
Bug: aioice/stun.py Transaction.__retry() calls
|
|
4
|
+
self.__future.set_exception(TransactionTimeout())
|
|
5
|
+
without checking if the future is already done.
|
|
6
|
+
|
|
7
|
+
If a response arrives at the same moment as the retry timeout,
|
|
8
|
+
response_received() sets the future first (it HAS the done() check),
|
|
9
|
+
then __retry() tries to set_exception on an already-done future
|
|
10
|
+
→ asyncio.InvalidStateError → process crash.
|
|
11
|
+
|
|
12
|
+
Fix: Add `if not self.__future.done()` guard, same as response_received().
|
|
13
|
+
|
|
14
|
+
See: https://github.com/aiortc/aioice/blob/main/src/aioice/stun.py
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
import asyncio
|
|
18
|
+
from aioice import stun
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def _patched_retry(self):
|
|
22
|
+
if self._Transaction__tries >= self._Transaction__tries_max:
|
|
23
|
+
if not self._Transaction__future.done():
|
|
24
|
+
self._Transaction__future.set_exception(stun.TransactionTimeout())
|
|
25
|
+
return
|
|
26
|
+
|
|
27
|
+
self._Transaction__protocol.send_stun(
|
|
28
|
+
self._Transaction__request, self._Transaction__addr
|
|
29
|
+
)
|
|
30
|
+
|
|
31
|
+
loop = asyncio.get_event_loop()
|
|
32
|
+
self._Transaction__timeout_handle = loop.call_later(
|
|
33
|
+
self._Transaction__timeout_delay, self._Transaction__retry
|
|
34
|
+
)
|
|
35
|
+
self._Transaction__timeout_delay *= 2
|
|
36
|
+
self._Transaction__tries += 1
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
stun.Transaction._Transaction__retry = _patched_retry
|
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
"""Opt-out chat commands and the one-to-one join gate.
|
|
2
|
+
|
|
3
|
+
Three behaviours that decide whether a call is recorded at all:
|
|
4
|
+
|
|
5
|
+
* the opt-out command posted before a call starts (checked once, at join),
|
|
6
|
+
* the same command posted mid-call, which pauses and resumes recording,
|
|
7
|
+
* one-to-one calls, where capture waits for the second participant so a
|
|
8
|
+
ringing call is never recorded.
|
|
9
|
+
|
|
10
|
+
Consent lives here rather than in the signalling client on purpose: it must hold
|
|
11
|
+
regardless of how the audio is obtained.
|
|
12
|
+
"""
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
import asyncio
|
|
16
|
+
import logging
|
|
17
|
+
import time
|
|
18
|
+
from typing import Awaitable, Callable
|
|
19
|
+
|
|
20
|
+
import aiohttp
|
|
21
|
+
|
|
22
|
+
logger = logging.getLogger(__name__)
|
|
23
|
+
|
|
24
|
+
CMD_STOP = frozenset(("/без-записи", "/no-record", "/no-transcribe"))
|
|
25
|
+
CMD_START = frozenset(("/запись", "/record", "/transcribe"))
|
|
26
|
+
CHAT_POLL_INTERVAL = 5 # seconds between chat polls
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def _ocs_auth(config):
|
|
30
|
+
return aiohttp.BasicAuth(config.nc_user, config.nc_password)
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
_OCS_HEADERS = {"OCS-APIRequest": "true", "Accept": "application/json"}
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
async def check_no_transcribe(config, room_token: str) -> bool:
|
|
37
|
+
"""True if someone posted /без-записи in the room in the last 5 minutes —
|
|
38
|
+
transcription should be skipped for this call."""
|
|
39
|
+
url = (
|
|
40
|
+
f"{config.nc_url}/ocs/v2.php/apps/spreed/api/v1"
|
|
41
|
+
f"/chat/{room_token}?lookIntoFuture=0&limit=10"
|
|
42
|
+
)
|
|
43
|
+
try:
|
|
44
|
+
async with aiohttp.ClientSession(
|
|
45
|
+
auth=_ocs_auth(config), headers=_OCS_HEADERS,
|
|
46
|
+
) as session:
|
|
47
|
+
async with session.get(url) as resp:
|
|
48
|
+
if resp.status != 200:
|
|
49
|
+
return False
|
|
50
|
+
data = await resp.json()
|
|
51
|
+
messages = data.get("ocs", {}).get("data", [])
|
|
52
|
+
now = time.time()
|
|
53
|
+
for msg in messages:
|
|
54
|
+
text = (msg.get("message") or "").strip().lower()
|
|
55
|
+
if now - msg.get("timestamp", 0) > 300:
|
|
56
|
+
continue
|
|
57
|
+
if text in CMD_STOP:
|
|
58
|
+
logger.info(
|
|
59
|
+
"Transcription disabled for %s by %s",
|
|
60
|
+
room_token, msg.get("actorDisplayName", "?"),
|
|
61
|
+
)
|
|
62
|
+
return True
|
|
63
|
+
except Exception:
|
|
64
|
+
logger.exception("Failed to check no-transcribe for %s", room_token)
|
|
65
|
+
return False
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
async def wait_for_second_participant(
|
|
69
|
+
monitor, room_token: str, is_active: Callable[[], bool],
|
|
70
|
+
*, max_wait: int = 60, poll: int = 3,
|
|
71
|
+
) -> bool:
|
|
72
|
+
"""Wait until 2+ participants are in a 1:1 call before connecting (the bot
|
|
73
|
+
must NOT join while ringing — internal clients auto-set inCall, which stops
|
|
74
|
+
the ring). Returns True once 2+ present, False on timeout / call-ended.
|
|
75
|
+
`is_active()` reports whether the call is still pending (e.g. token still in
|
|
76
|
+
the session map)."""
|
|
77
|
+
loop = asyncio.get_running_loop()
|
|
78
|
+
logger.info("1:1 call %s — waiting for second participant", room_token)
|
|
79
|
+
for _ in range(0, max_wait, poll):
|
|
80
|
+
if not is_active():
|
|
81
|
+
logger.info("1:1 call %s — ended while waiting", room_token)
|
|
82
|
+
return False
|
|
83
|
+
try:
|
|
84
|
+
count = await loop.run_in_executor(
|
|
85
|
+
None, monitor._count_incall_participants, room_token,
|
|
86
|
+
)
|
|
87
|
+
if count >= 2:
|
|
88
|
+
logger.info("1:1 call %s — %d in call, joining", room_token, count)
|
|
89
|
+
return True
|
|
90
|
+
except Exception:
|
|
91
|
+
logger.debug("1:1 participant poll error for %s", room_token,
|
|
92
|
+
exc_info=True)
|
|
93
|
+
await asyncio.sleep(poll)
|
|
94
|
+
logger.info("1:1 call %s — second participant never joined", room_token)
|
|
95
|
+
return False
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
class ChatCommandMonitor:
|
|
99
|
+
"""Polls a room's chat for /без-записи (pause) and /запись (resume) and
|
|
100
|
+
flips `self.recording`. The capture session checks `recording` before
|
|
101
|
+
feeding frames. start() launches the loop; stop() cancels it."""
|
|
102
|
+
|
|
103
|
+
def __init__(self, config, room_token: str):
|
|
104
|
+
self._config = config
|
|
105
|
+
self._token = room_token
|
|
106
|
+
self.recording = True
|
|
107
|
+
self._last_id = 0
|
|
108
|
+
self._task: asyncio.Task | None = None
|
|
109
|
+
|
|
110
|
+
def start(self) -> None:
|
|
111
|
+
self._task = asyncio.create_task(self._loop())
|
|
112
|
+
|
|
113
|
+
async def stop(self) -> None:
|
|
114
|
+
if self._task:
|
|
115
|
+
self._task.cancel()
|
|
116
|
+
try:
|
|
117
|
+
await self._task
|
|
118
|
+
except asyncio.CancelledError:
|
|
119
|
+
pass
|
|
120
|
+
|
|
121
|
+
def apply(self, text: str, author: str = "?") -> None:
|
|
122
|
+
"""Apply one chat message's command (pure; used by the loop + tests)."""
|
|
123
|
+
text = (text or "").strip().lower()
|
|
124
|
+
if text in CMD_STOP and self.recording:
|
|
125
|
+
self.recording = False
|
|
126
|
+
logger.info("Recording PAUSED for %s by %s", self._token, author)
|
|
127
|
+
elif text in CMD_START and not self.recording:
|
|
128
|
+
self.recording = True
|
|
129
|
+
logger.info("Recording RESUMED for %s by %s", self._token, author)
|
|
130
|
+
|
|
131
|
+
async def _loop(self) -> None:
|
|
132
|
+
base = (
|
|
133
|
+
f"{self._config.nc_url}/ocs/v2.php/apps/spreed/api/v1"
|
|
134
|
+
f"/chat/{self._token}"
|
|
135
|
+
)
|
|
136
|
+
try:
|
|
137
|
+
async with aiohttp.ClientSession(
|
|
138
|
+
auth=_ocs_auth(self._config), headers=_OCS_HEADERS,
|
|
139
|
+
) as session:
|
|
140
|
+
async with session.get(f"{base}?lookIntoFuture=0&limit=1") as resp:
|
|
141
|
+
if resp.status == 200:
|
|
142
|
+
msgs = (await resp.json()).get("ocs", {}).get("data", [])
|
|
143
|
+
if msgs:
|
|
144
|
+
self._last_id = msgs[0].get("id", 0)
|
|
145
|
+
while True:
|
|
146
|
+
await asyncio.sleep(CHAT_POLL_INTERVAL)
|
|
147
|
+
try:
|
|
148
|
+
async with session.get(
|
|
149
|
+
f"{base}?lookIntoFuture=1"
|
|
150
|
+
f"&lastKnownMessageId={self._last_id}"
|
|
151
|
+
f"&limit=20&timeout=0"
|
|
152
|
+
) as resp:
|
|
153
|
+
if resp.status != 200:
|
|
154
|
+
continue
|
|
155
|
+
for msg in (await resp.json()).get(
|
|
156
|
+
"ocs", {}).get("data", []):
|
|
157
|
+
mid = msg.get("id", 0)
|
|
158
|
+
if mid > self._last_id:
|
|
159
|
+
self._last_id = mid
|
|
160
|
+
self.apply(msg.get("message", ""),
|
|
161
|
+
msg.get("actorDisplayName", "?"))
|
|
162
|
+
except asyncio.CancelledError:
|
|
163
|
+
raise
|
|
164
|
+
except Exception:
|
|
165
|
+
logger.debug("Chat poll error for %s", self._token,
|
|
166
|
+
exc_info=True)
|
|
167
|
+
except asyncio.CancelledError:
|
|
168
|
+
pass
|
|
169
|
+
except Exception:
|
|
170
|
+
logger.exception("Chat monitor failed for %s", self._token)
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
"""Configuration the capture path needs — and nothing else.
|
|
2
|
+
|
|
3
|
+
An application that also transcribes, analyses and stores calls has a much
|
|
4
|
+
larger configuration; capture needs about thirty values out of it.
|
|
5
|
+
|
|
6
|
+
CaptureConfig declares exactly those. Capture modules take config duck-typed, so
|
|
7
|
+
a larger application config with the same field names works unchanged — and a
|
|
8
|
+
capture-only deployment can build one from the environment instead.
|
|
9
|
+
"""
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import os
|
|
13
|
+
from dataclasses import dataclass
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def _csv(name: str) -> tuple:
|
|
17
|
+
return tuple(t.strip() for t in os.environ.get(name, "").split(",") if t.strip())
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
@dataclass
|
|
21
|
+
class CaptureConfig:
|
|
22
|
+
"""What it takes to detect a call, join it, and receive per-speaker audio."""
|
|
23
|
+
|
|
24
|
+
# ── Nextcloud (OCS API: room metadata, chat commands, participants) ──
|
|
25
|
+
nc_url: str = ""
|
|
26
|
+
nc_user: str = ""
|
|
27
|
+
nc_password: str = ""
|
|
28
|
+
|
|
29
|
+
# ── Signalling (HPB) — the media path ──
|
|
30
|
+
hpb_url: str = ""
|
|
31
|
+
hpb_secret: str = ""
|
|
32
|
+
|
|
33
|
+
# ── Call detection: Nextcloud MySQL, optionally over an SSH tunnel ──
|
|
34
|
+
db_host: str = "127.0.0.1"
|
|
35
|
+
db_port: int = 3306
|
|
36
|
+
db_user: str = ""
|
|
37
|
+
db_password: str = ""
|
|
38
|
+
db_name: str = "nextcloud"
|
|
39
|
+
ssh_host: str = "" # empty = direct MySQL, no tunnel
|
|
40
|
+
ssh_port: int = 22
|
|
41
|
+
ssh_user: str = "root"
|
|
42
|
+
ssh_key_path: str = "/root/.ssh/id_rsa"
|
|
43
|
+
poll_interval: int = 5
|
|
44
|
+
|
|
45
|
+
# ── Which rooms to capture ──
|
|
46
|
+
# allowlist wins when set (pilot rollout / a single test room); the
|
|
47
|
+
# include/exclude pair is the older per-room filter.
|
|
48
|
+
room_allowlist: tuple = ()
|
|
49
|
+
included_rooms: tuple = ()
|
|
50
|
+
excluded_rooms: tuple = ()
|
|
51
|
+
|
|
52
|
+
# ── Where the audio goes ──
|
|
53
|
+
transcriber_backend: str = "in_process" # in_process | grpc
|
|
54
|
+
transcriber_grpc_target: str = "transcriber:7100"
|
|
55
|
+
transcriber_api_token: str = ""
|
|
56
|
+
|
|
57
|
+
# ── Result delivery (capture-only services POST S2 to the brain) ──
|
|
58
|
+
# Where a capture-only deployment POSTs the finished call. No default: it
|
|
59
|
+
# is deployment-specific, and silently posting nowhere is worse than failing.
|
|
60
|
+
nc_capture_s2_url: str = ""
|
|
61
|
+
s2_intake_token: str = ""
|
|
62
|
+
nc_capture_outbox_db: str = "/var/nc_capture_outbox/outbox.db"
|
|
63
|
+
nc_capture_replay_interval: float = 60.0
|
|
64
|
+
|
|
65
|
+
# ── Transcript presentation (travels with the finished call) ──
|
|
66
|
+
transcript_language: str = "ru"
|
|
67
|
+
daily_report_timezone: str = "Europe/Moscow"
|
|
68
|
+
|
|
69
|
+
# ── Voice-sample collection (optional; off unless enabled) ──
|
|
70
|
+
enrollment_collection_enabled: bool = False
|
|
71
|
+
enrollment_store_path: str = "/var/enrollment_store"
|
|
72
|
+
enrollment_min_rms: float = 0.003
|
|
73
|
+
enrollment_chunk_seconds: int = 30
|
|
74
|
+
enrollment_max_chunks_per_call: int = 10
|
|
75
|
+
|
|
76
|
+
diagnostic_logging: bool = False
|
|
77
|
+
|
|
78
|
+
@classmethod
|
|
79
|
+
def from_env(cls) -> "CaptureConfig":
|
|
80
|
+
"""Build from the environment — for services that capture only."""
|
|
81
|
+
return cls(
|
|
82
|
+
nc_url=os.environ.get("NEXTCLOUD_URL", "").rstrip("/"),
|
|
83
|
+
nc_user=os.environ.get("NC_USER", ""),
|
|
84
|
+
nc_password=os.environ.get("NC_PASSWORD", ""),
|
|
85
|
+
hpb_url=os.environ.get("HPB_URL", ""),
|
|
86
|
+
hpb_secret=os.environ.get("HPB_SECRET", ""),
|
|
87
|
+
db_host=os.environ.get("DB_HOST", "127.0.0.1"),
|
|
88
|
+
db_port=int(os.environ.get("DB_PORT", "3306")),
|
|
89
|
+
db_user=os.environ.get("DB_USER", ""),
|
|
90
|
+
db_password=os.environ.get("DB_PASSWORD", ""),
|
|
91
|
+
db_name=os.environ.get("DB_NAME", "nextcloud"),
|
|
92
|
+
ssh_host=os.environ.get("SSH_HOST", ""),
|
|
93
|
+
ssh_port=int(os.environ.get("SSH_PORT", "22")),
|
|
94
|
+
ssh_user=os.environ.get("SSH_USER", "root"),
|
|
95
|
+
ssh_key_path=os.environ.get("SSH_KEY_PATH", "/root/.ssh/id_rsa"),
|
|
96
|
+
poll_interval=int(os.environ.get("POLL_INTERVAL", "5")),
|
|
97
|
+
room_allowlist=_csv("ROOM_ALLOWLIST"),
|
|
98
|
+
included_rooms=_csv("INCLUDED_ROOMS"),
|
|
99
|
+
excluded_rooms=_csv("EXCLUDED_ROOMS"),
|
|
100
|
+
transcriber_backend=os.environ.get("TRANSCRIBER_BACKEND", "in_process"),
|
|
101
|
+
transcriber_grpc_target=os.environ.get(
|
|
102
|
+
"TRANSCRIBER_GRPC_TARGET", "transcriber:7100"),
|
|
103
|
+
transcriber_api_token=os.environ.get("TRANSCRIBER_API_TOKEN", ""),
|
|
104
|
+
nc_capture_s2_url=os.environ.get("NC_CAPTURE_S2_URL", ""),
|
|
105
|
+
s2_intake_token=os.environ.get("S2_INTAKE_TOKEN", ""),
|
|
106
|
+
nc_capture_outbox_db=os.environ.get(
|
|
107
|
+
"NC_CAPTURE_OUTBOX_DB", "/var/nc_capture_outbox/outbox.db"),
|
|
108
|
+
nc_capture_replay_interval=float(
|
|
109
|
+
os.environ.get("NC_CAPTURE_REPLAY_INTERVAL", "60")),
|
|
110
|
+
transcript_language=os.environ.get("TRANSCRIPT_LANGUAGE", "ru"),
|
|
111
|
+
daily_report_timezone=os.environ.get(
|
|
112
|
+
"DAILY_REPORT_TIMEZONE", "Europe/Moscow"),
|
|
113
|
+
enrollment_collection_enabled=os.environ.get(
|
|
114
|
+
"ENROLLMENT_COLLECTION_ENABLED", "false").lower() == "true",
|
|
115
|
+
enrollment_store_path=os.environ.get(
|
|
116
|
+
"ENROLLMENT_STORE_PATH", "/var/enrollment_store"),
|
|
117
|
+
enrollment_min_rms=float(os.environ.get("ENROLLMENT_MIN_RMS", "0.003")),
|
|
118
|
+
enrollment_chunk_seconds=int(
|
|
119
|
+
os.environ.get("ENROLLMENT_CHUNK_SECONDS", "30")),
|
|
120
|
+
enrollment_max_chunks_per_call=int(
|
|
121
|
+
os.environ.get("ENROLLMENT_MAX_CHUNKS_PER_CALL", "10")),
|
|
122
|
+
diagnostic_logging=os.environ.get(
|
|
123
|
+
"DIAGNOSTIC_LOGGING", "false").lower() == "true",
|
|
124
|
+
)
|