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",
|
||||
"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]
|
||||
|
||||
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 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 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__':
|
||||
import uvicorn
|
||||
log.basicConfig(
|
||||
Reference in New Issue
Block a user