Skip to content

Commit 920f68f

Browse files
first commit
0 parents  commit 920f68f

16 files changed

Lines changed: 685 additions & 0 deletions

.env.example

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
MATRIX_HS_URL=https://matrix.example.org
2+
MATRIX_USER_ID=@transcriptbot:example.org
3+
MATRIX_ACCESS_TOKEN=syt_...
4+
5+
# Optional
6+
LOCALE=en
7+
ASR_MODEL_NAME=nvidia/parakeet-tdt-0.6b-v2
8+
MAX_AUDIO_BYTES=26214400

.github/workflows/lint.yml

Lines changed: 78 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,78 @@
1+
name: Lint & Security
2+
3+
on:
4+
push:
5+
branches: ["main", "master"]
6+
pull_request:
7+
branches: ["main", "master"]
8+
9+
jobs:
10+
ruff:
11+
name: Ruff (lint + format)
12+
runs-on: ubuntu-latest
13+
steps:
14+
- uses: actions/checkout@v4
15+
16+
- name: Set up Python
17+
uses: actions/setup-python@v5
18+
with:
19+
python-version: "3.11"
20+
21+
- name: Install ruff
22+
run: pip install ruff
23+
24+
- name: Lint
25+
run: ruff check src/
26+
27+
- name: Format check
28+
run: ruff format --check src/
29+
30+
bandit:
31+
name: Bandit (security)
32+
runs-on: ubuntu-latest
33+
steps:
34+
- uses: actions/checkout@v4
35+
36+
- name: Set up Python
37+
uses: actions/setup-python@v5
38+
with:
39+
python-version: "3.11"
40+
41+
- name: Install bandit
42+
run: pip install "bandit[toml]"
43+
44+
- name: Run bandit
45+
run: bandit -r src/ -c pyproject.toml --format json -o bandit-report.json --exit-zero
46+
47+
- name: Print bandit report
48+
if: always()
49+
run: |
50+
if [ -f bandit-report.json ]; then
51+
python - <<'EOF'
52+
import json, sys
53+
with open("bandit-report.json") as f:
54+
r = json.load(f)
55+
issues = r.get("results", [])
56+
if not issues:
57+
print("No security issues found.")
58+
sys.exit(0)
59+
for i in issues:
60+
sev = i["issue_severity"]
61+
conf = i["issue_confidence"]
62+
text = i["issue_text"]
63+
loc = f"{i['filename']}:{i['line_number']}"
64+
print(f"[{sev}/{conf}] {loc}: {text}")
65+
high = [i for i in issues if i["issue_severity"] == "HIGH"]
66+
if high:
67+
print(f"\n{len(high)} HIGH severity issue(s) found — failing.")
68+
sys.exit(1)
69+
EOF
70+
fi
71+
72+
- name: Upload bandit report
73+
if: always()
74+
uses: actions/upload-artifact@v4
75+
with:
76+
name: bandit-report
77+
path: bandit-report.json
78+
if-no-files-found: ignore

Dockerfile

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
FROM python:3.11-slim
2+
3+
# ffmpeg is required by pydub for audio decoding/conversion
4+
RUN apt-get update && apt-get install -y --no-install-recommends \
5+
ffmpeg \
6+
&& rm -rf /var/lib/apt/lists/*
7+
8+
WORKDIR /app
9+
10+
# Install CPU-only PyTorch first as a separate layer — saves ~2 GB vs CUDA wheels
11+
RUN pip install --no-cache-dir \
12+
torch \
13+
torchaudio \
14+
--extra-index-url https://download.pytorch.org/whl/cpu
15+
16+
COPY requirements.txt .
17+
RUN pip install --no-cache-dir -r requirements.txt
18+
19+
COPY src ./src
20+
21+
# Mount a volume here to cache the 2.4 GB Parakeet checkpoint across container restarts
22+
ENV NEMO_CACHE_DIR=/models
23+
ENV PYTHONUNBUFFERED=1
24+
25+
CMD ["python", "-m", "src.main"]

docker-compose.yml

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
services:
2+
bot:
3+
build: .
4+
restart: unless-stopped
5+
env_file: .env
6+
volumes:
7+
- ./models:/models

pyproject.toml

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
[tool.ruff]
2+
target-version = "py311"
3+
line-length = 100
4+
5+
[tool.ruff.lint]
6+
select = [
7+
"E", "W", "F", "I", "UP", "B", "C4", "SIM", "S", "T20", "RUF",
8+
]
9+
ignore = [
10+
"S101",
11+
"S104",
12+
"S311",
13+
]
14+
15+
[tool.ruff.lint.per-file-ignores]
16+
"src/__init__.py" = ["F401"]
17+
"src/strings.py" = ["RUF001"]
18+
19+
[tool.ruff.lint.isort]
20+
known-first-party = ["src"]
21+
22+
[tool.ruff.format]
23+
quote-style = "double"
24+
indent-style = "space"
25+
26+
[tool.bandit]
27+
targets = ["src"]
28+
severity = "medium"
29+
confidence = "medium"
30+
skips = ["B101", "B311"]

requirements-dev.txt

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
ruff>=0.4.0
2+
bandit[toml]>=1.7.0

requirements.txt

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
matrix-nio>=0.24.0,<0.26
2+
pydantic-settings>=2.2.0
3+
aiohttp>=3.9.0
4+
nemo_toolkit[asr]>=2.0.0
5+
torch>=2.0.0
6+
torchaudio>=2.0.0
7+
pydub>=0.25.1
8+
soundfile>=0.12.1

src/__init__.py

Whitespace-only changes.

src/audio_converter.py

Lines changed: 112 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,112 @@
1+
from __future__ import annotations
2+
3+
import io
4+
import logging
5+
import tempfile
6+
from urllib.parse import quote
7+
8+
import aiohttp
9+
from nio import AsyncClient
10+
11+
logger = logging.getLogger(__name__)
12+
13+
_MIME_TO_PYDUB: dict[str, str] = {
14+
"audio/ogg": "ogg",
15+
"audio/opus": "ogg",
16+
"application/ogg": "ogg",
17+
"audio/webm": "webm",
18+
"audio/mp4": "mp4",
19+
"audio/m4a": "mp4",
20+
"audio/x-m4a": "mp4",
21+
"audio/aac": "aac",
22+
"audio/flac": "flac",
23+
"audio/x-flac": "flac",
24+
"audio/mpeg": "mp3",
25+
"audio/mp3": "mp3",
26+
"audio/wav": "wav",
27+
"audio/x-wav": "wav",
28+
"audio/wave": "wav",
29+
}
30+
31+
32+
def _pydub_format(mime: str) -> str:
33+
return _MIME_TO_PYDUB.get(mime.lower().split(";")[0].strip(), "ogg")
34+
35+
36+
def _parse_mxc(mxc_url: str) -> tuple[str, str] | None:
37+
if not mxc_url.startswith("mxc://"):
38+
return None
39+
parts = mxc_url[len("mxc://"):].split("/", 1)
40+
if len(parts) != 2 or not parts[0] or not parts[1]:
41+
return None
42+
return parts[0], parts[1]
43+
44+
45+
class AudioConverter:
46+
"""Downloads Matrix audio content and converts it to 16 kHz mono WAV."""
47+
48+
def __init__(self, matrix: AsyncClient, max_bytes: int) -> None:
49+
self._matrix = matrix
50+
self._max_bytes = max_bytes
51+
52+
async def download_mxc(self, mxc_url: str) -> bytes | None:
53+
"""Download mxc:// URL via authenticated v1 endpoint with legacy fallback."""
54+
parsed = _parse_mxc(mxc_url)
55+
if parsed is None:
56+
logger.warning("Invalid mxc URL: %s", mxc_url)
57+
return None
58+
server, media_id = parsed
59+
60+
homeserver = self._matrix.homeserver.rstrip("/")
61+
token = self._matrix.access_token
62+
63+
urls = [
64+
f"{homeserver}/_matrix/client/v1/media/download/{quote(server)}/{quote(media_id)}",
65+
f"{homeserver}/_matrix/media/v3/download/{quote(server)}/{quote(media_id)}",
66+
f"{homeserver}/_matrix/media/r0/download/{quote(server)}/{quote(media_id)}",
67+
]
68+
69+
headers = {"Authorization": f"Bearer {token}"} if token else {}
70+
71+
async with aiohttp.ClientSession() as session:
72+
for url in urls:
73+
try:
74+
async with session.get(url, headers=headers) as resp:
75+
if resp.status != 200:
76+
continue
77+
content_length = resp.content_length
78+
if content_length and content_length > self._max_bytes:
79+
logger.warning("Audio too large: %d bytes", content_length)
80+
return None
81+
data = await resp.content.read(self._max_bytes + 1)
82+
if len(data) > self._max_bytes:
83+
logger.warning("Audio exceeds max bytes after download")
84+
return None
85+
return data
86+
except aiohttp.ClientError:
87+
logger.exception("Download failed for %s", url)
88+
continue
89+
90+
logger.error("All download URLs failed for mxc: %s", mxc_url)
91+
return None
92+
93+
def convert_to_wav(self, audio_bytes: bytes, mime: str) -> str | None:
94+
"""Convert raw audio bytes to a temporary 16 kHz mono WAV file.
95+
96+
Returns the temp file path, or None on failure.
97+
Caller must delete the file (e.g. via os.unlink in a finally block).
98+
Blocking — must be called via run_in_executor.
99+
"""
100+
from pydub import AudioSegment # noqa: PLC0415
101+
102+
fmt = _pydub_format(mime)
103+
try:
104+
seg = AudioSegment.from_file(io.BytesIO(audio_bytes), format=fmt)
105+
seg = seg.set_frame_rate(16000).set_channels(1).set_sample_width(2)
106+
tmp = tempfile.NamedTemporaryFile(suffix=".wav", delete=False)
107+
seg.export(tmp.name, format="wav")
108+
tmp.close()
109+
return tmp.name
110+
except Exception:
111+
logger.exception("Audio conversion failed (mime=%s)", mime)
112+
return None

0 commit comments

Comments
 (0)