refactor: move multicast streaming to dedicated worker thread with async loop
This commit is contained in:
@@ -106,7 +106,12 @@ st.title("Auracast Audio Mode Control")
|
||||
|
||||
# Audio mode selection with persisted default
|
||||
# Note: backend persists 'USB' for any device:<name> source (including AES67). We default to 'USB' in that case.
|
||||
options = ["Demo", "USB", "AES67", "Webapp"]
|
||||
options = [
|
||||
"Demo",
|
||||
"USB",
|
||||
"AES67",
|
||||
# "Webapp"
|
||||
]
|
||||
saved_audio_mode = saved_settings.get("audio_mode", "Demo")
|
||||
if saved_audio_mode not in options:
|
||||
# Map legacy/unknown modes to closest
|
||||
|
||||
@@ -7,6 +7,8 @@ import logging as log
|
||||
import uuid
|
||||
import json
|
||||
import sys
|
||||
import threading
|
||||
from concurrent.futures import Future
|
||||
from datetime import datetime
|
||||
import time
|
||||
import asyncio
|
||||
@@ -113,112 +115,203 @@ app.add_middleware(
|
||||
# Initialize global configuration
|
||||
global_config_group = auracast_config.AuracastConfigGroup()
|
||||
|
||||
# Create multicast controller
|
||||
multicaster1: multicast_control.Multicaster | None = None
|
||||
multicaster2: multicast_control.Multicaster | None = None
|
||||
_stream_lock = asyncio.Lock() # serialize initialize/stop_audio
|
||||
class StreamerWorker:
|
||||
"""Owns multicaster(s) on a dedicated asyncio loop in a background thread."""
|
||||
|
||||
def __init__(self) -> None:
|
||||
self._thread: threading.Thread | None = None
|
||||
self._loop: asyncio.AbstractEventLoop | None = None
|
||||
# These live only on the worker loop
|
||||
self._multicaster1: multicast_control.Multicaster | None = None
|
||||
self._multicaster2: multicast_control.Multicaster | None = None
|
||||
self._started = threading.Event()
|
||||
|
||||
# ---------- Thread/loop management ----------
|
||||
def start(self) -> None:
|
||||
if self._thread and self._thread.is_alive():
|
||||
return
|
||||
self._thread = threading.Thread(target=self._run, name="StreamerWorker", daemon=True)
|
||||
self._thread.start()
|
||||
self._started.wait(timeout=5)
|
||||
|
||||
def _run(self) -> None:
|
||||
loop = asyncio.new_event_loop()
|
||||
self._loop = loop
|
||||
asyncio.set_event_loop(loop)
|
||||
self._started.set()
|
||||
try:
|
||||
loop.run_forever()
|
||||
finally:
|
||||
try:
|
||||
pending = asyncio.all_tasks(loop)
|
||||
for t in pending:
|
||||
t.cancel()
|
||||
loop.run_until_complete(asyncio.gather(*pending, return_exceptions=True))
|
||||
except Exception:
|
||||
pass
|
||||
loop.close()
|
||||
|
||||
def _ensure_loop(self) -> asyncio.AbstractEventLoop:
|
||||
if not self._loop:
|
||||
raise RuntimeError("StreamerWorker loop not started")
|
||||
return self._loop
|
||||
|
||||
async def call(self, coro_func, *args, **kwargs):
|
||||
"""Schedule a coroutine on the worker loop and await its result from the API loop."""
|
||||
loop = self._ensure_loop()
|
||||
fut: Future = asyncio.run_coroutine_threadsafe(coro_func(*args, **kwargs), loop)
|
||||
return await asyncio.wrap_future(fut)
|
||||
|
||||
# ---------- Worker-loop coroutines ----------
|
||||
async def _w_init_primary(self, conf: auracast_config.AuracastConfigGroup) -> dict:
|
||||
# Clean any previous
|
||||
if self._multicaster1 is not None:
|
||||
try:
|
||||
await self._multicaster1.shutdown()
|
||||
except Exception:
|
||||
pass
|
||||
self._multicaster1 = None
|
||||
|
||||
conf.transport = TRANSPORT1
|
||||
# Derive device name and input mode
|
||||
first_source = conf.bigs[0].audio_source if conf.bigs else ''
|
||||
input_device_name = None
|
||||
audio_mode_persist = 'Demo'
|
||||
if first_source.startswith('device:'):
|
||||
input_device_name = first_source.split(':', 1)[1] if ':' in first_source else None
|
||||
try:
|
||||
usb_names = {d.get('name') for _, d in get_usb_pw_inputs()}
|
||||
net_names = {d.get('name') for _, d in get_network_pw_inputs()}
|
||||
except Exception:
|
||||
usb_names, net_names = set(), set()
|
||||
audio_mode_persist = 'AES67' if (input_device_name in net_names) else 'USB'
|
||||
|
||||
# Map device name to index and configure input_format
|
||||
device_index = int(input_device_name) if (input_device_name and input_device_name.isdigit()) else get_device_index_by_name(input_device_name or '')
|
||||
if device_index is None:
|
||||
raise HTTPException(status_code=400, detail=f"Audio device '{input_device_name}' not found.")
|
||||
for big in conf.bigs:
|
||||
if big.audio_source.startswith('device:'):
|
||||
big.audio_source = f'device:{device_index}'
|
||||
devinfo = sd.query_devices(device_index)
|
||||
capture_rate = int(devinfo.get('default_samplerate') or 48000)
|
||||
max_in = int(devinfo.get('max_input_channels') or 1)
|
||||
channels = max(1, min(2, max_in))
|
||||
for big in conf.bigs:
|
||||
big.input_format = f"int16le,{capture_rate},{channels}"
|
||||
|
||||
# Create and init multicaster1
|
||||
self._multicaster1 = multicast_control.Multicaster(conf, conf.bigs)
|
||||
await reset_nrf54l(1)
|
||||
await self._multicaster1.init_broadcast()
|
||||
auto_started = False
|
||||
if any(big.audio_source.startswith("device:") or big.audio_source.startswith("file:") for big in conf.bigs):
|
||||
await self._multicaster1.start_streaming()
|
||||
auto_started = True
|
||||
|
||||
# Return proposed settings to persist on API side
|
||||
return {
|
||||
'channel_names': [big.name for big in conf.bigs],
|
||||
'languages': [big.language for big in conf.bigs],
|
||||
'audio_mode': audio_mode_persist,
|
||||
'input_device': input_device_name,
|
||||
'program_info': [getattr(big, 'program_info', None) for big in conf.bigs],
|
||||
'gain': [getattr(big, 'input_gain', 1.0) for big in conf.bigs],
|
||||
'auracast_sampling_rate_hz': conf.auracast_sampling_rate_hz,
|
||||
'octets_per_frame': conf.octets_per_frame,
|
||||
'immediate_rendering': getattr(conf, 'immediate_rendering', False),
|
||||
'assisted_listening_stream': getattr(conf, 'assisted_listening_stream', False),
|
||||
'stream_password': (conf.bigs[0].code if conf.bigs and getattr(conf.bigs[0], 'code', None) else None),
|
||||
'is_streaming': auto_started,
|
||||
}
|
||||
|
||||
async def _w_init_secondary(self, conf: auracast_config.AuracastConfigGroup) -> None:
|
||||
if self._multicaster2 is not None:
|
||||
try:
|
||||
await self._multicaster2.shutdown()
|
||||
except Exception:
|
||||
pass
|
||||
self._multicaster2 = None
|
||||
|
||||
conf.transport = TRANSPORT2
|
||||
for big in conf.bigs:
|
||||
if big.audio_source.startswith('device:'):
|
||||
device_name = big.audio_source.split(':', 1)[1]
|
||||
device_index = get_device_index_by_name(device_name)
|
||||
if device_index is None:
|
||||
raise HTTPException(status_code=400, detail=f"Audio device '{device_name}' not found.")
|
||||
big.audio_source = f'device:{device_index}'
|
||||
self._multicaster2 = multicast_control.Multicaster(conf, conf.bigs)
|
||||
await reset_nrf54l(0)
|
||||
await self._multicaster2.init_broadcast()
|
||||
if any(big.audio_source.startswith("device:") or big.audio_source.startswith("file:") for big in conf.bigs):
|
||||
await self._multicaster2.start_streaming()
|
||||
|
||||
async def _w_stop_all(self) -> bool:
|
||||
was_running = False
|
||||
if self._multicaster1 is not None:
|
||||
try:
|
||||
await self._multicaster1.stop_streaming()
|
||||
await self._multicaster1.shutdown()
|
||||
was_running = True
|
||||
finally:
|
||||
self._multicaster1 = None
|
||||
if self._multicaster2 is not None:
|
||||
try:
|
||||
await self._multicaster2.stop_streaming()
|
||||
await self._multicaster2.shutdown()
|
||||
was_running = True
|
||||
finally:
|
||||
self._multicaster2 = None
|
||||
return was_running
|
||||
|
||||
async def _w_status_primary(self) -> dict:
|
||||
if self._multicaster1 is None:
|
||||
return {'is_initialized': False, 'is_streaming': False}
|
||||
try:
|
||||
return self._multicaster1.get_status()
|
||||
except Exception:
|
||||
return {'is_initialized': True, 'is_streaming': False}
|
||||
|
||||
async def _w_stream_lc3(self, audio_data: dict[str, str], bigs_template: list) -> None:
|
||||
if self._multicaster1 is None:
|
||||
raise HTTPException(status_code=500, detail='Auracast endpoint was never intialized')
|
||||
# Update bigs audio_source with provided bytes and start
|
||||
for big in bigs_template:
|
||||
if big.language not in audio_data:
|
||||
raise HTTPException(status_code=500, detail='language len missmatch')
|
||||
big.audio_source = audio_data[big.language].encode('latin-1')
|
||||
self._multicaster1.big_conf = bigs_template
|
||||
await self._multicaster1.start_streaming()
|
||||
|
||||
|
||||
# Create the worker singleton and a route-level lock
|
||||
streamer = StreamerWorker()
|
||||
# multicaster1: multicast_control.Multicaster | None = None # kept for legacy references, do not use on API loop
|
||||
# multicaster2: multicast_control.Multicaster | None = None
|
||||
_stream_lock = asyncio.Lock() # serialize initialize/stop_audio on API side
|
||||
@app.post("/init")
|
||||
async def initialize(conf: auracast_config.AuracastConfigGroup):
|
||||
"""Initializes the primary broadcaster (multicaster1)."""
|
||||
"""Initializes the primary broadcaster on the streamer thread."""
|
||||
global global_config_group
|
||||
global multicaster1
|
||||
async with _stream_lock:
|
||||
try:
|
||||
# Cleanly stop any existing instance to avoid lingering PipeWire streams
|
||||
if multicaster1 is not None:
|
||||
log.info("Shutting down existing multicaster instance before re-initializing.")
|
||||
await multicaster1.shutdown()
|
||||
multicaster1 = None
|
||||
|
||||
conf.transport = TRANSPORT1
|
||||
# Derive audio_mode and input_device from first BIG audio_source
|
||||
first_source = conf.bigs[0].audio_source if conf.bigs else ''
|
||||
if first_source.startswith('device:'):
|
||||
input_device_name = first_source.split(':', 1)[1] if ':' in first_source else None
|
||||
# Determine if the device is a USB or Network(AES67) PipeWire input
|
||||
try:
|
||||
usb_names = {d.get('name') for _, d in get_usb_pw_inputs()}
|
||||
net_names = {d.get('name') for _, d in get_network_pw_inputs()}
|
||||
except Exception:
|
||||
usb_names, net_names = set(), set()
|
||||
audio_mode_persist = 'AES67' if input_device_name in net_names else 'USB'
|
||||
|
||||
# Resolve to device index and set input_format to avoid PipeWire resampling
|
||||
device_index = None
|
||||
if input_device_name:
|
||||
device_index = int(input_device_name) if input_device_name.isdigit() else get_device_index_by_name(input_device_name)
|
||||
if device_index is None:
|
||||
log.error(f"Device name '{input_device_name}' not found in current device list.")
|
||||
raise HTTPException(status_code=400, detail=f"Audio device '{input_device_name}' not found.")
|
||||
|
||||
for big in conf.bigs:
|
||||
if big.audio_source.startswith('device:'):
|
||||
big.audio_source = f'device:{device_index}'
|
||||
devinfo = sd.query_devices(device_index)
|
||||
capture_rate = int(devinfo.get('default_samplerate') or 48000)
|
||||
max_in = int(devinfo.get('max_input_channels') or 1)
|
||||
channels = max(1, min(2, max_in))
|
||||
for big in conf.bigs:
|
||||
big.input_format = f"int16le,{capture_rate},{channels}"
|
||||
save_stream_settings({
|
||||
'channel_names': [big.name for big in conf.bigs],
|
||||
'languages': [big.language for big in conf.bigs],
|
||||
'audio_mode': audio_mode_persist,
|
||||
'input_device': input_device_name,
|
||||
'program_info': [getattr(big, 'program_info', None) for big in conf.bigs],
|
||||
'gain': [getattr(big, 'input_gain', 1.0) for big in conf.bigs],
|
||||
'auracast_sampling_rate_hz': conf.auracast_sampling_rate_hz,
|
||||
'octets_per_frame': conf.octets_per_frame,
|
||||
'immediate_rendering': getattr(conf, 'immediate_rendering', False),
|
||||
'assisted_listening_stream': getattr(conf, 'assisted_listening_stream', False),
|
||||
'stream_password': (conf.bigs[0].code if conf.bigs and getattr(conf.bigs[0], 'code', None) else None),
|
||||
'is_streaming': False, # will be set to True below if we actually start
|
||||
'timestamp': datetime.utcnow().isoformat()
|
||||
})
|
||||
|
||||
# Proceed with initialization and optional auto-start
|
||||
global_config_group = conf
|
||||
log.info('Initializing multicaster1 with config:\n %s', conf.model_dump_json(indent=2))
|
||||
multicaster1 = multicast_control.Multicaster(conf, conf.bigs)
|
||||
# Ensure target is reset before initializing broadcast
|
||||
await reset_nrf54l(1)
|
||||
await multicaster1.init_broadcast()
|
||||
if any(big.audio_source.startswith("device:") or big.audio_source.startswith("file:") for big in conf.bigs):
|
||||
log.info("Auto-starting streaming on multicaster1")
|
||||
await multicaster1.start_streaming()
|
||||
# Mark persisted state as streaming
|
||||
settings = load_stream_settings() or {}
|
||||
settings['is_streaming'] = True
|
||||
settings['timestamp'] = datetime.utcnow().isoformat()
|
||||
save_stream_settings(settings)
|
||||
global_config_group = conf
|
||||
log.info('Initializing multicaster1 with config:\n %s', conf.model_dump_json(indent=2))
|
||||
persisted = await streamer.call(streamer._w_init_primary, conf)
|
||||
# Persist returned settings (avoid touching from worker thread)
|
||||
persisted['timestamp'] = datetime.utcnow().isoformat()
|
||||
save_stream_settings(persisted)
|
||||
except Exception as e:
|
||||
log.error("Exception in /init: %s", traceback.format_exc())
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
@app.post("/init2")
|
||||
async def initialize2(conf: auracast_config.AuracastConfigGroup):
|
||||
"""Initializes the secondary broadcaster (multicaster2). Does NOT persist stream settings."""
|
||||
global multicaster2
|
||||
"""Initializes the secondary broadcaster on the streamer thread."""
|
||||
try:
|
||||
conf.transport = TRANSPORT2
|
||||
# Patch device name to index for sounddevice
|
||||
for big in conf.bigs:
|
||||
if big.audio_source.startswith('device:'):
|
||||
device_name = big.audio_source.split(':', 1)[1]
|
||||
device_index = get_device_index_by_name(device_name)
|
||||
if device_index is not None:
|
||||
big.audio_source = f'device:{device_index}'
|
||||
else:
|
||||
log.error(f"Device name '{device_name}' not found in current device list.")
|
||||
raise HTTPException(status_code=400, detail=f"Audio device '{device_name}' not found.")
|
||||
log.info('Initializing multicaster2 with config:\n %s', conf.model_dump_json(indent=2))
|
||||
multicaster2 = multicast_control.Multicaster(conf, conf.bigs)
|
||||
# Ensure target is reset before initializing broadcast
|
||||
await reset_nrf54l(0)
|
||||
await multicaster2.init_broadcast()
|
||||
if any(big.audio_source.startswith("device:") or big.audio_source.startswith("file:") for big in conf.bigs):
|
||||
log.info("Auto-starting streaming on multicaster2")
|
||||
await multicaster2.start_streaming()
|
||||
await streamer.call(streamer._w_init_secondary, conf)
|
||||
except Exception as e:
|
||||
log.error("Exception in /init2: %s", traceback.format_exc())
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
@@ -228,27 +321,15 @@ async def initialize2(conf: auracast_config.AuracastConfigGroup):
|
||||
|
||||
@app.post("/stop_audio")
|
||||
async def stop_audio():
|
||||
"""Stops streaming on both multicaster1 and multicaster2."""
|
||||
"""Stops streaming on both multicaster1 and multicaster2 (worker thread)."""
|
||||
try:
|
||||
# First close any active WebRTC peer connections so their track loops finish cleanly
|
||||
# Close any active PeerConnections
|
||||
close_tasks = [pc.close() for pc in list(pcs)]
|
||||
pcs.clear()
|
||||
if close_tasks:
|
||||
await asyncio.gather(*close_tasks, return_exceptions=True)
|
||||
|
||||
# Now shut down both multicasters and release audio devices
|
||||
global multicaster1, multicaster2
|
||||
was_running = False
|
||||
if multicaster1 is not None:
|
||||
await multicaster1.stop_streaming()
|
||||
await multicaster1.shutdown()
|
||||
multicaster1 = None
|
||||
was_running = True
|
||||
if multicaster2 is not None:
|
||||
await multicaster2.stop_streaming()
|
||||
await multicaster2.shutdown()
|
||||
multicaster2 = None
|
||||
was_running = True
|
||||
was_running = await streamer.call(streamer._w_stop_all)
|
||||
|
||||
# Persist is_streaming=False
|
||||
try:
|
||||
@@ -260,9 +341,7 @@ async def stop_audio():
|
||||
except Exception:
|
||||
log.warning("Failed to persist is_streaming=False during stop_audio", exc_info=True)
|
||||
|
||||
# Grace period: allow PipeWire/PortAudio to fully drop capture nodes
|
||||
await asyncio.sleep(0.2)
|
||||
|
||||
return {"status": "stopped", "was_running": was_running}
|
||||
except Exception as e:
|
||||
log.error("Exception in /stop_audio: %s", traceback.format_exc())
|
||||
@@ -271,17 +350,9 @@ async def stop_audio():
|
||||
|
||||
@app.post("/stream_lc3")
|
||||
async def send_audio(audio_data: dict[str, str]):
|
||||
"""Sends a block of pre-coded LC3 audio."""
|
||||
if multicaster1 is None:
|
||||
raise HTTPException(status_code=500, detail='Auracast endpoint was never intialized')
|
||||
"""Sends a block of pre-coded LC3 audio via the worker."""
|
||||
try:
|
||||
for big in global_config_group.bigs:
|
||||
assert big.language in audio_data, HTTPException(status_code=500, detail='language len missmatch')
|
||||
log.info('Received a send audio request for %s', big.language)
|
||||
big.audio_source = audio_data[big.language].encode('latin-1') # TODO: use base64 encoding
|
||||
|
||||
multicaster1.big_conf = global_config_group.bigs
|
||||
await multicaster1.start_streaming()
|
||||
await streamer.call(streamer._w_stream_lc3, audio_data, list(global_config_group.bigs))
|
||||
return {"status": "audio_sent"}
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
@@ -289,11 +360,8 @@ async def send_audio(audio_data: dict[str, str]):
|
||||
|
||||
@app.get("/status")
|
||||
async def get_status():
|
||||
"""Gets the current status of the multicaster together with persisted stream info."""
|
||||
status = multicaster1.get_status() if multicaster1 else {
|
||||
'is_initialized': False,
|
||||
'is_streaming': False,
|
||||
}
|
||||
"""Gets current status (worker) merged with persisted settings cache."""
|
||||
status = await streamer.call(streamer._w_status_primary)
|
||||
status.update(load_stream_settings())
|
||||
return status
|
||||
|
||||
@@ -330,12 +398,14 @@ async def _autostart_from_settings():
|
||||
return
|
||||
|
||||
# Avoid duplicate start if already streaming
|
||||
if multicaster1 and multicaster1.get_status().get('is_streaming'):
|
||||
current = await streamer.call(streamer._w_status_primary)
|
||||
if current.get('is_streaming'):
|
||||
return
|
||||
|
||||
while True:
|
||||
# Do not interfere if user started a stream manually in the meantime
|
||||
if multicaster1 and multicaster1.get_status().get('is_streaming'):
|
||||
current = await streamer.call(streamer._w_status_primary)
|
||||
if current.get('is_streaming'):
|
||||
return
|
||||
# Abort if saved settings changed to a different target while we were polling
|
||||
current_settings = load_stream_settings() or {}
|
||||
@@ -389,6 +459,8 @@ async def _startup_autostart_event():
|
||||
# Hydrate settings cache once to avoid disk I/O during /status
|
||||
_hydrate_settings_cache_from_disk()
|
||||
refresh_pw_cache()
|
||||
# Start the streamer worker thread
|
||||
streamer.start()
|
||||
asyncio.create_task(_autostart_from_settings())
|
||||
|
||||
|
||||
@@ -425,9 +497,8 @@ async def refresh_audio_devices():
|
||||
"""Triggers a re-scan of audio devices, but only if no stream is active."""
|
||||
streaming = False
|
||||
try:
|
||||
if multicaster1 is not None:
|
||||
status = multicaster1.get_status()
|
||||
streaming = bool(status.get('is_streaming'))
|
||||
status = await streamer.call(streamer._w_status_primary)
|
||||
streaming = bool(status.get('is_streaming'))
|
||||
except Exception:
|
||||
pass # Ignore errors, default to not refreshing
|
||||
|
||||
@@ -444,143 +515,143 @@ async def refresh_audio_devices():
|
||||
raise HTTPException(status_code=500, detail=f"Failed to refresh devices: {e}")
|
||||
|
||||
|
||||
@app.post("/offer")
|
||||
async def offer(offer: Offer):
|
||||
log.info("/offer endpoint called")
|
||||
# async def offer(offer: Offer):
|
||||
# @app.post("/offer") #webrtc endpoint
|
||||
# log.info("/offer endpoint called")
|
||||
|
||||
# If a previous PeerConnection is still alive, close it so we only ever keep one active.
|
||||
if pcs:
|
||||
log.info("Closing %d existing PeerConnection(s) before creating a new one", len(pcs))
|
||||
close_tasks = [p.close() for p in list(pcs)]
|
||||
await asyncio.gather(*close_tasks, return_exceptions=True)
|
||||
pcs.clear()
|
||||
# # If a previous PeerConnection is still alive, close it so we only ever keep one active.
|
||||
# if pcs:
|
||||
# log.info("Closing %d existing PeerConnection(s) before creating a new one", len(pcs))
|
||||
# close_tasks = [p.close() for p in list(pcs)]
|
||||
# await asyncio.gather(*close_tasks, return_exceptions=True)
|
||||
# pcs.clear()
|
||||
|
||||
pc = RTCPeerConnection() # No STUN needed for localhost
|
||||
pcs.add(pc)
|
||||
id_ = uuid.uuid4().hex[:8]
|
||||
log.info(f"{id_}: new PeerConnection")
|
||||
# pc = RTCPeerConnection() # No STUN needed for localhost
|
||||
# pcs.add(pc)
|
||||
# id_ = uuid.uuid4().hex[:8]
|
||||
# log.info(f"{id_}: new PeerConnection")
|
||||
|
||||
# create directory for records - only for testing
|
||||
os.makedirs("./records", exist_ok=True)
|
||||
# # create directory for records - only for testing
|
||||
# os.makedirs("./records", exist_ok=True)
|
||||
|
||||
# Do NOT start the streamer yet – we'll start it lazily once we actually
|
||||
# receive the first audio frame, ensuring WebRTCAudioInput is ready and
|
||||
# avoiding race-conditions on restarts.
|
||||
@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}"
|
||||
)
|
||||
# Lazily start the streamer now that we know a track exists.
|
||||
if multicaster1.streamer is None:
|
||||
await multicaster1.start_streaming()
|
||||
# Yield control so the Streamer coroutine has a chance to
|
||||
# create the WebRTCAudioInput before we push samples.
|
||||
await asyncio.sleep(0)
|
||||
# Persist is_streaming=True for Webapp mode
|
||||
try:
|
||||
settings = load_stream_settings() or {}
|
||||
settings['is_streaming'] = True
|
||||
settings['timestamp'] = datetime.utcnow().isoformat()
|
||||
save_stream_settings(settings)
|
||||
except Exception:
|
||||
log.warning("Failed to persist is_streaming=True on WebRTC start", exc_info=True)
|
||||
first = False
|
||||
# in stereo case this is interleaved data format
|
||||
frame_array = frame.to_ndarray()
|
||||
log.info(f"array.shape{frame_array.shape}")
|
||||
log.info(f"array.dtype{frame_array.dtype}")
|
||||
log.info(f"frame.to_ndarray(){frame_array}")
|
||||
# # Do NOT start the streamer yet – we'll start it lazily once we actually
|
||||
# # receive the first audio frame, ensuring WebRTCAudioInput is ready and
|
||||
# # avoiding race-conditions on restarts.
|
||||
# @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}"
|
||||
# )
|
||||
# # Lazily start the streamer now that we know a track exists.
|
||||
# if multicaster1.streamer is None:
|
||||
# await multicaster1.start_streaming()
|
||||
# # Yield control so the Streamer coroutine has a chance to
|
||||
# # create the WebRTCAudioInput before we push samples.
|
||||
# await asyncio.sleep(0)
|
||||
# # Persist is_streaming=True for Webapp mode
|
||||
# try:
|
||||
# settings = load_stream_settings() or {}
|
||||
# settings['is_streaming'] = True
|
||||
# settings['timestamp'] = datetime.utcnow().isoformat()
|
||||
# save_stream_settings(settings)
|
||||
# except Exception:
|
||||
# log.warning("Failed to persist is_streaming=True on WebRTC start", exc_info=True)
|
||||
# first = False
|
||||
# # in stereo case this is interleaved data format
|
||||
# frame_array = frame.to_ndarray()
|
||||
# log.info(f"array.shape{frame_array.shape}")
|
||||
# log.info(f"array.dtype{frame_array.dtype}")
|
||||
# log.info(f"frame.to_ndarray(){frame_array}")
|
||||
|
||||
samples = frame_array.reshape(-1)
|
||||
log.info(f"samples.shape: {samples.shape}")
|
||||
# samples = frame_array.reshape(-1)
|
||||
# log.info(f"samples.shape: {samples.shape}")
|
||||
|
||||
if frame.layout.name == 'stereo':
|
||||
# Interleaved stereo: [L0, R0, L1, R1, ...]
|
||||
mono_array = samples[::2] # Take left channel
|
||||
else:
|
||||
mono_array = samples
|
||||
# if frame.layout.name == 'stereo':
|
||||
# # Interleaved stereo: [L0, R0, L1, R1, ...]
|
||||
# mono_array = samples[::2] # Take left channel
|
||||
# else:
|
||||
# mono_array = samples
|
||||
|
||||
log.info(f"mono_array.shape: {mono_array.shape}")
|
||||
# log.info(f"mono_array.shape: {mono_array.shape}")
|
||||
|
||||
|
||||
frame_array = frame.to_ndarray()
|
||||
# frame_array = frame.to_ndarray()
|
||||
|
||||
# Flatten in case it's (1, N) or (N,)
|
||||
samples = frame_array.reshape(-1)
|
||||
# # Flatten in case it's (1, N) or (N,)
|
||||
# samples = frame_array.reshape(-1)
|
||||
|
||||
if frame.layout.name == 'stereo':
|
||||
# Interleaved stereo: [L0, R0, L1, R1, ...]
|
||||
mono_array = samples[::2] # Take left channel
|
||||
else:
|
||||
mono_array = samples
|
||||
# if frame.layout.name == 'stereo':
|
||||
# # Interleaved stereo: [L0, R0, L1, R1, ...]
|
||||
# mono_array = samples[::2] # Take left channel
|
||||
# else:
|
||||
# mono_array = samples
|
||||
|
||||
# Get current WebRTC audio input (streamer may have been restarted)
|
||||
big0 = list(multicaster1.bigs.values())[0]
|
||||
audio_input = big0.get('audio_input')
|
||||
# Wait until the streamer has instantiated the WebRTCAudioInput
|
||||
if audio_input is None or getattr(audio_input, 'closed', False):
|
||||
continue
|
||||
# Feed mono PCM samples to the global WebRTC audio input
|
||||
await audio_input.put_samples(mono_array.astype(np.int16))
|
||||
# # Get current WebRTC audio input (streamer may have been restarted)
|
||||
# big0 = list(multicaster1.bigs.values())[0]
|
||||
# audio_input = big0.get('audio_input')
|
||||
# # Wait until the streamer has instantiated the WebRTCAudioInput
|
||||
# if audio_input is None or getattr(audio_input, 'closed', False):
|
||||
# continue
|
||||
# # Feed mono PCM samples to the global WebRTC audio input
|
||||
# await audio_input.put_samples(mono_array.astype(np.int16))
|
||||
|
||||
# Save to WAV file - only for testing
|
||||
# if not hasattr(pc, 'wav_writer'):
|
||||
# import wave
|
||||
# wav_path = f"./records/auracast_{id_}.wav"
|
||||
# pc.wav_writer = wave.open(wav_path, "wb")
|
||||
# pc.wav_writer.setnchannels(1) # mono
|
||||
# pc.wav_writer.setsampwidth(2) # 16-bit PCM
|
||||
# pc.wav_writer.setframerate(frame.sample_rate)
|
||||
# # Save to WAV file - only for testing
|
||||
# # if not hasattr(pc, 'wav_writer'):
|
||||
# # import wave
|
||||
# # wav_path = f"./records/auracast_{id_}.wav"
|
||||
# # pc.wav_writer = wave.open(wav_path, "wb")
|
||||
# # pc.wav_writer.setnchannels(1) # mono
|
||||
# # pc.wav_writer.setsampwidth(2) # 16-bit PCM
|
||||
# # pc.wav_writer.setframerate(frame.sample_rate)
|
||||
|
||||
# pcm_data = mono_array.astype(np.int16).tobytes()
|
||||
# pc.wav_writer.writeframes(pcm_data)
|
||||
# # pcm_data = mono_array.astype(np.int16).tobytes()
|
||||
# # pc.wav_writer.writeframes(pcm_data)
|
||||
|
||||
|
||||
except Exception as e:
|
||||
log.error(f"{id_}: Exception in on_track: {e}")
|
||||
finally:
|
||||
# Always close the wav file when the track ends or on error
|
||||
if hasattr(pc, 'wav_writer'):
|
||||
try:
|
||||
pc.wav_writer.close()
|
||||
except Exception:
|
||||
pass
|
||||
del pc.wav_writer
|
||||
# except Exception as e:
|
||||
# log.error(f"{id_}: Exception in on_track: {e}")
|
||||
# finally:
|
||||
# # Always close the wav file when the track ends or on error
|
||||
# if hasattr(pc, 'wav_writer'):
|
||||
# try:
|
||||
# pc.wav_writer.close()
|
||||
# except Exception:
|
||||
# pass
|
||||
# del pc.wav_writer
|
||||
|
||||
# --- SDP negotiation ---
|
||||
log.info(f"{id_}: setting remote description")
|
||||
await pc.setRemoteDescription(RTCSessionDescription(**offer.model_dump()))
|
||||
# # --- 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}
|
||||
# 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 and releases all audio/Bluetooth resources."""
|
||||
try:
|
||||
await multicaster1.shutdown()
|
||||
await streamer.call(streamer._w_stop_all)
|
||||
return {"status": "stopped"}
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
@@ -601,13 +672,11 @@ async def system_reboot():
|
||||
if close_tasks:
|
||||
await asyncio.gather(*close_tasks, return_exceptions=True)
|
||||
|
||||
# Stop streaming on multicasters but DO NOT touch stream_settings.json
|
||||
if multicaster1 is not None:
|
||||
await multicaster1.stop_streaming()
|
||||
await multicaster1.shutdown()
|
||||
if multicaster2 is not None:
|
||||
await multicaster2.stop_streaming()
|
||||
await multicaster2.shutdown()
|
||||
# Stop streaming on worker but DO NOT touch stream_settings.json
|
||||
try:
|
||||
await streamer.call(streamer._w_stop_all)
|
||||
except Exception:
|
||||
pass
|
||||
except Exception:
|
||||
log.warning("Non-fatal: failed to stop streams before reboot", exc_info=True)
|
||||
|
||||
|
||||
Reference in New Issue
Block a user