Upload 4 files
Browse files- Dockerfile +12 -0
- README.md +27 -6
- app.py +109 -0
- requirements.txt +2 -0
Dockerfile
ADDED
|
@@ -0,0 +1,12 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
FROM python:3.11-slim
|
| 2 |
+
|
| 3 |
+
WORKDIR /app
|
| 4 |
+
|
| 5 |
+
COPY requirements.txt .
|
| 6 |
+
RUN pip install --no-cache-dir -r requirements.txt
|
| 7 |
+
|
| 8 |
+
COPY app.py .
|
| 9 |
+
|
| 10 |
+
EXPOSE 7860
|
| 11 |
+
|
| 12 |
+
CMD ["uvicorn", "app:app", "--host", "0.0.0.0", "--port", "7860"]
|
README.md
CHANGED
|
@@ -1,11 +1,32 @@
|
|
| 1 |
---
|
| 2 |
-
title:
|
| 3 |
-
emoji:
|
| 4 |
-
colorFrom:
|
| 5 |
-
colorTo:
|
| 6 |
sdk: docker
|
|
|
|
| 7 |
pinned: false
|
| 8 |
-
short_description: p2p(TCP)
|
| 9 |
---
|
| 10 |
|
| 11 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
---
|
| 2 |
+
title: Sound Transport Relay
|
| 3 |
+
emoji: "🔊"
|
| 4 |
+
colorFrom: blue
|
| 5 |
+
colorTo: green
|
| 6 |
sdk: docker
|
| 7 |
+
app_port: 7860
|
| 8 |
pinned: false
|
|
|
|
| 9 |
---
|
| 10 |
|
| 11 |
+
# Sound Transport Relay (HF)
|
| 12 |
+
|
| 13 |
+
This Space runs a simple WebSocket relay at `/ws`. Clients should connect with:
|
| 14 |
+
|
| 15 |
+
```json
|
| 16 |
+
{"role":"sender","code":"123456789"}
|
| 17 |
+
```
|
| 18 |
+
|
| 19 |
+
or
|
| 20 |
+
|
| 21 |
+
```json
|
| 22 |
+
{"role":"receiver","code":"123456789"}
|
| 23 |
+
```
|
| 24 |
+
|
| 25 |
+
After both sides connect with the same code, the relay forwards all text/binary
|
| 26 |
+
messages both directions.
|
| 27 |
+
|
| 28 |
+
Notes
|
| 29 |
+
- HF free Spaces can sleep when idle; the first connection after sleep will have
|
| 30 |
+
a cold-start delay.
|
| 31 |
+
- This relay does not authenticate or validate passwords. Your app should handle
|
| 32 |
+
authentication and encryption end-to-end.
|
app.py
ADDED
|
@@ -0,0 +1,109 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
#!/usr/bin/env python3
|
| 2 |
+
import asyncio
|
| 3 |
+
import json
|
| 4 |
+
import re
|
| 5 |
+
|
| 6 |
+
from fastapi import FastAPI, WebSocket, WebSocketDisconnect
|
| 7 |
+
from fastapi.responses import PlainTextResponse
|
| 8 |
+
|
| 9 |
+
app = FastAPI()
|
| 10 |
+
CODE_RE = re.compile(r"^\d{9}$")
|
| 11 |
+
|
| 12 |
+
# pending[code] = {"sender": ws|None, "receiver": ws|None, "event": asyncio.Event()}
|
| 13 |
+
PENDING = {}
|
| 14 |
+
PENDING_LOCK = asyncio.Lock()
|
| 15 |
+
|
| 16 |
+
|
| 17 |
+
@app.get("/")
|
| 18 |
+
async def root():
|
| 19 |
+
return PlainTextResponse("ok")
|
| 20 |
+
|
| 21 |
+
|
| 22 |
+
async def register(code, role, ws):
|
| 23 |
+
async with PENDING_LOCK:
|
| 24 |
+
entry = PENDING.get(code)
|
| 25 |
+
if entry is None:
|
| 26 |
+
entry = {"sender": None, "receiver": None, "event": asyncio.Event()}
|
| 27 |
+
PENDING[code] = entry
|
| 28 |
+
if entry.get(role) is not None:
|
| 29 |
+
return None, "role already connected"
|
| 30 |
+
entry[role] = ws
|
| 31 |
+
if entry.get("sender") is not None and entry.get("receiver") is not None:
|
| 32 |
+
entry["event"].set()
|
| 33 |
+
return entry, None
|
| 34 |
+
|
| 35 |
+
|
| 36 |
+
async def unregister(code, role, ws):
|
| 37 |
+
async with PENDING_LOCK:
|
| 38 |
+
entry = PENDING.get(code)
|
| 39 |
+
if entry is None:
|
| 40 |
+
return
|
| 41 |
+
if entry.get(role) is ws:
|
| 42 |
+
entry[role] = None
|
| 43 |
+
if entry.get("sender") is None and entry.get("receiver") is None:
|
| 44 |
+
PENDING.pop(code, None)
|
| 45 |
+
|
| 46 |
+
|
| 47 |
+
async def forward(src, dst):
|
| 48 |
+
try:
|
| 49 |
+
while True:
|
| 50 |
+
msg = await src.receive()
|
| 51 |
+
if msg.get("type") == "websocket.disconnect":
|
| 52 |
+
break
|
| 53 |
+
if msg.get("bytes") is not None:
|
| 54 |
+
await dst.send_bytes(msg["bytes"])
|
| 55 |
+
elif msg.get("text") is not None:
|
| 56 |
+
await dst.send_text(msg["text"])
|
| 57 |
+
except WebSocketDisconnect:
|
| 58 |
+
pass
|
| 59 |
+
except Exception:
|
| 60 |
+
pass
|
| 61 |
+
|
| 62 |
+
|
| 63 |
+
@app.websocket("/ws")
|
| 64 |
+
async def ws_relay(ws: WebSocket):
|
| 65 |
+
await ws.accept()
|
| 66 |
+
code = None
|
| 67 |
+
role = None
|
| 68 |
+
entry = None
|
| 69 |
+
try:
|
| 70 |
+
raw = await ws.receive_text()
|
| 71 |
+
try:
|
| 72 |
+
payload = json.loads(raw)
|
| 73 |
+
except json.JSONDecodeError:
|
| 74 |
+
await ws.send_text(json.dumps({"error": "invalid json"}))
|
| 75 |
+
return
|
| 76 |
+
role = payload.get("role")
|
| 77 |
+
code = payload.get("code")
|
| 78 |
+
if role not in ("sender", "receiver"):
|
| 79 |
+
await ws.send_text(json.dumps({"error": "invalid role"}))
|
| 80 |
+
return
|
| 81 |
+
if not isinstance(code, str) or not CODE_RE.match(code):
|
| 82 |
+
await ws.send_text(json.dumps({"error": "invalid code"}))
|
| 83 |
+
return
|
| 84 |
+
entry, err = await register(code, role, ws)
|
| 85 |
+
if err:
|
| 86 |
+
await ws.send_text(json.dumps({"error": err}))
|
| 87 |
+
return
|
| 88 |
+
await ws.send_text(json.dumps({"status": "waiting"}))
|
| 89 |
+
await entry["event"].wait()
|
| 90 |
+
other = entry["receiver"] if role == "sender" else entry["sender"]
|
| 91 |
+
if other is None:
|
| 92 |
+
await ws.send_text(json.dumps({"error": "peer missing"}))
|
| 93 |
+
return
|
| 94 |
+
await ws.send_text(json.dumps({"status": "paired"}))
|
| 95 |
+
await other.send_text(json.dumps({"status": "paired"}))
|
| 96 |
+
task_a = asyncio.create_task(forward(ws, other))
|
| 97 |
+
task_b = asyncio.create_task(forward(other, ws))
|
| 98 |
+
done, pending = await asyncio.wait(
|
| 99 |
+
{task_a, task_b}, return_when=asyncio.FIRST_COMPLETED
|
| 100 |
+
)
|
| 101 |
+
for task in pending:
|
| 102 |
+
task.cancel()
|
| 103 |
+
finally:
|
| 104 |
+
if code and role:
|
| 105 |
+
await unregister(code, role, ws)
|
| 106 |
+
try:
|
| 107 |
+
await ws.close()
|
| 108 |
+
except Exception:
|
| 109 |
+
pass
|
requirements.txt
ADDED
|
@@ -0,0 +1,2 @@
|
|
|
|
|
|
|
|
|
|
| 1 |
+
fastapi==0.110.0
|
| 2 |
+
uvicorn[standard]==0.29.0
|