restructure and add a multicast_frontend

This commit is contained in:
2025-06-06 15:13:39 +02:00
parent c778681d4c
commit 83c7fcb596
4 changed files with 1353 additions and 16 deletions

1215
poetry.lock generated

File diff suppressed because it is too large Load Diff

View File

@@ -13,7 +13,9 @@ dependencies = [
"aiohttp==3.9.3", "aiohttp==3.9.3",
"sounddevice (>=0.5.1,<0.6.0)", "sounddevice (>=0.5.1,<0.6.0)",
"aioconsole (>=0.8.1,<0.9.0)", "aioconsole (>=0.8.1,<0.9.0)",
"numpy (>=2.2.6,<3.0.0)" "numpy (>=2.2.6,<3.0.0)",
"streamlit (>=1.45.1,<2.0.0)",
"aiortc (>=1.13.0,<2.0.0)"
] ]
[project.optional-dependencies] [project.optional-dependencies]

View File

@@ -0,0 +1,52 @@
# frontend/app.py
import streamlit as st
import requests
# Global: desired packetization time in ms for Opus (should match backend)
PTIME = 40
BACKEND_URL = "http://localhost:5000"
st.title("🎙️ WebRTC mic → backend demo")
st.markdown("Click start and speak; watch your backend logs to see incoming RTP.")
component = f"""
<button id='go'>Start microphone</button>
<script>
const go = document.getElementById('go');
go.onclick = async () => {{
go.disabled = true;
const pc = new RTCPeerConnection(); // No STUN needed for localhost
const stream = await navigator.mediaDevices.getUserMedia({{audio:true}});
stream.getTracks().forEach(t => pc.addTrack(t, stream));
// --- WebRTC offer/answer exchange ---
const offer = await pc.createOffer()
// Patch SDP offer to include a=ptime using global PTIME
let sdp = offer.sdp;
const ptime_line = 'a=ptime:{PTIME}';
const maxptime_line = 'a=maxptime:{PTIME}';
if (sdp.includes('a=sendrecv')) {{
sdp = sdp.replace('a=sendrecv', 'a=sendrecv\\n' + ptime_line + '\\n' + maxptime_line);
}} else {{
sdp += '\\n' + ptime_line + '\\n' + maxptime_line;
}}
const patched_offer = new RTCSessionDescription({{sdp, type: offer.type}})
await pc.setLocalDescription(patched_offer)
// Send offer to backend
const response = await fetch(
"{BACKEND_URL}/offer",
{{
method: 'POST',
headers: {{'Content-Type':'application/json'}},
body: JSON.stringify({{sdp: pc.localDescription.sdp, type: pc.localDescription.type}})
}}
)
const answer = await response.json()
await pc.setRemoteDescription(new RTCSessionDescription({{sdp: answer.sdp, type: answer.type}}))
}};
</script>
"""
st.components.v1.html(component, height=80)

View File

@@ -1,10 +1,27 @@
import glob import glob
import logging as log import logging as log
import uuid
from fastapi import FastAPI, HTTPException from fastapi import FastAPI, HTTPException
from fastapi.middleware.cors import CORSMiddleware
from auracast import multicast_control, auracast_config from auracast import multicast_control, auracast_config
from aiortc import RTCPeerConnection, RTCSessionDescription, MediaStreamTrack
import av
import av.audio.layout
from typing import List, Set
from pydantic import BaseModel
app = FastAPI() app = FastAPI()
# Allow CORS for frontend on localhost
app.add_middleware(
CORSMiddleware,
allow_origins=["*"], # You can restrict this to ["http://localhost:8501"] if you want
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
)
# Initialize global configuration # Initialize global configuration
global_config_group = auracast_config.AuracastConfigGroup() global_config_group = auracast_config.AuracastConfigGroup()
@@ -47,7 +64,7 @@ async def initialize(conf: auracast_config.AuracastConfigGroup):
@app.post("/stream_lc3") @app.post("/stream_lc3")
async def send_audio(audio_data: dict[str, str]): async def send_audio(audio_data: dict[str, str]):
"""Streams pre-coded LC3 audio.""" """Sends a block of pre-coded LC3 audio."""
if multicaster is None: if multicaster is None:
raise HTTPException(status_code=500, detail='Auracast endpoint was never intialized') raise HTTPException(status_code=500, detail='Auracast endpoint was never intialized')
try: try:
@@ -63,16 +80,6 @@ async def send_audio(audio_data: dict[str, str]):
raise HTTPException(status_code=500, detail=str(e)) raise HTTPException(status_code=500, detail=str(e))
@app.post("/shutdown")
async def shutdown():
"""Stops broadcasting."""
try:
await multicaster.reset()
return {"status": "stopped"}
except Exception as e:
raise HTTPException(status_code=500, detail=str(e))
@app.post("/stop_audio") @app.post("/stop_audio")
async def stop_audio(): async def stop_audio():
"""Stops streaming.""" """Stops streaming."""
@@ -95,6 +102,75 @@ async def get_status():
} }
PTIME = 40
pcs: Set[RTCPeerConnection] = set() # keep refs so they dont GC early
class Offer(BaseModel):
sdp: str
type: str
@app.post("/offer")
async def offer(offer: Offer):
log.info("/offer endpoint called")
pc = RTCPeerConnection() # No STUN needed for localhost
pcs.add(pc)
id_ = uuid.uuid4().hex[:8]
log.info(f"{id_}: new PeerConnection")
@pc.on("track")
async def on_track(track: MediaStreamTrack):
log.info(f"{id_}: track {track.kind} received")
try:
first = True
while True:
frame: av.audio.frame.AudioFrame = await track.recv() # RTP audio frame (already decrypted)
if first:
log.info(f"{id_}: frame layout={frame.layout}")
log.info(f"{id_}: frame format={frame.format}")
log.info(
f"{id_}: frame sample_rate={frame.sample_rate}, samples_per_channel={frame.samples}, planes={frame.planes}"
)
first = False
array = frame.to_ndarray()
log.info(f"array.shape{array.shape}")
log.info(f"array.dtype{array.dtype}")
log.info(f"frame.to_ndarray(){array}")
# TODO: write to file, pipe to ASR, etc.
except Exception as e:
log.error(f"{id_}: Exception in on_track: {e}")
# --- SDP negotiation ---
log.info(f"{id_}: setting remote description")
await pc.setRemoteDescription(RTCSessionDescription(**offer.model_dump()))
log.info(f"{id_}: creating answer")
answer = await pc.createAnswer()
sdp = answer.sdp
# Insert a=ptime using the global PTIME variable
ptime_line = f"a=ptime:{PTIME}"
if "a=sendrecv" in sdp:
sdp = sdp.replace("a=sendrecv", f"a=sendrecv\n{ptime_line}")
else:
sdp += f"\n{ptime_line}"
new_answer = RTCSessionDescription(sdp=sdp, type=answer.type)
await pc.setLocalDescription(new_answer)
log.info(f"{id_}: sending answer with {ptime_line}")
return {"sdp": pc.localDescription.sdp,
"type": pc.localDescription.type}
@app.post("/shutdown")
async def shutdown():
"""Stops broadcasting."""
try:
await multicaster.reset()
return {"status": "stopped"}
except Exception as e:
raise HTTPException(status_code=500, detail=str(e))
if __name__ == '__main__': if __name__ == '__main__':
import uvicorn import uvicorn
log.basicConfig( log.basicConfig(