restructure and add a multicast_frontend
This commit is contained in:
1215
poetry.lock
generated
1215
poetry.lock
generated
File diff suppressed because it is too large
Load Diff
@@ -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]
|
||||||
|
|||||||
52
src/auracast/server/multicast_frontend.py
Normal file
52
src/auracast/server/multicast_frontend.py
Normal 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)
|
||||||
|
|
||||||
@@ -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 don’t 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(
|
||||||
Reference in New Issue
Block a user