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",
"sounddevice (>=0.5.1,<0.6.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]

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 logging as log
import uuid
from fastapi import FastAPI, HTTPException
from fastapi.middleware.cors import CORSMiddleware
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()
# 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
global_config_group = auracast_config.AuracastConfigGroup()
@@ -47,7 +64,7 @@ async def initialize(conf: auracast_config.AuracastConfigGroup):
@app.post("/stream_lc3")
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:
raise HTTPException(status_code=500, detail='Auracast endpoint was never intialized')
try:
@@ -63,16 +80,6 @@ async def send_audio(audio_data: dict[str, str]):
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")
async def stop_audio():
"""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__':
import uvicorn
log.basicConfig(