feature/network_audio (#6)
- make the device work standalone with webui - add support for aes67 - may more things Co-authored-by: pstruebi <struebin.patrick.com> Reviewed-on: https://gitea.pstruebi.xyz/auracaster/bumble-auracast/pulls/6
This commit was merged in pull request #6.
This commit is contained in:
@@ -35,6 +35,10 @@ class AuracastGlobalConfig(BaseModel):
|
||||
presentation_delay_us: int = 40000
|
||||
# TODO:pydantic does not support bytes serialization - use .hex and np.fromhex()
|
||||
manufacturer_data: tuple[int, bytes] | tuple[None, None] = (None, None)
|
||||
# LE Audio: Broadcast Audio Immediate Rendering (metadata type 0x09)
|
||||
# When true, include a zero-length LTV with type 0x09 in the subgroup metadata
|
||||
# so receivers may render earlier than the presentation delay for lower latency.
|
||||
immediate_rendering: bool = False
|
||||
|
||||
# "Audio input. "
|
||||
# "'device' -> use the host's default sound input device, "
|
||||
@@ -58,42 +62,52 @@ class AuracastBigConfig(BaseModel):
|
||||
class AuracastBigConfigDeu(AuracastBigConfig):
|
||||
id: int = 12
|
||||
random_address: str = 'F1:F1:F2:F3:F4:F5'
|
||||
name: str = 'Broadcast0'
|
||||
name: str = 'Hörsaal A'
|
||||
language: str ='deu'
|
||||
program_info: str = 'Announcements German'
|
||||
audio_source: str = 'file:./testdata/announcement_de.wav'
|
||||
program_info: str = 'Vorlesung DE'
|
||||
audio_source: str = 'file:./testdata/wave_particle_5min_de.wav'
|
||||
|
||||
class AuracastBigConfigEng(AuracastBigConfig):
|
||||
id: int = 123
|
||||
random_address: str = 'F2:F1:F2:F3:F4:F5'
|
||||
name: str = 'Broadcast1'
|
||||
name: str = 'Lecture Hall A'
|
||||
language: str ='eng'
|
||||
program_info: str = 'Announcements English'
|
||||
audio_source: str = 'file:./testdata/announcement_en.wav'
|
||||
program_info: str = 'Lecture EN'
|
||||
audio_source: str = 'file:./testdata/wave_particle_5min_en.wav'
|
||||
|
||||
class AuracastBigConfigFra(AuracastBigConfig):
|
||||
id: int = 1234
|
||||
random_address: str = 'F3:F1:F2:F3:F4:F5'
|
||||
name: str = 'Broadcast2'
|
||||
# French
|
||||
name: str = 'Auditoire A'
|
||||
language: str ='fra'
|
||||
program_info: str = 'Announcements French'
|
||||
audio_source: str = 'file:./testdata/announcement_fr.wav'
|
||||
program_info: str = 'Auditoire FR'
|
||||
audio_source: str = 'file:./testdata/wave_particle_5min_fr.wav'
|
||||
|
||||
class AuracastBigConfigSpa(AuracastBigConfig):
|
||||
id: int =12345
|
||||
random_address: str = 'F4:F1:F2:F3:F4:F5'
|
||||
name: str = 'Broadcast3'
|
||||
name: str = 'Auditorio A'
|
||||
language: str ='spa'
|
||||
program_info: str = 'Announcements Spanish'
|
||||
audio_source: str = 'file:./testdata/announcement_es.wav'
|
||||
program_info: str = 'Auditorio ES'
|
||||
audio_source: str = 'file:./testdata/wave_particle_5min_es.wav'
|
||||
|
||||
class AuracastBigConfigIta(AuracastBigConfig):
|
||||
id: int =1234567
|
||||
random_address: str = 'F5:F1:F2:F3:F4:F5'
|
||||
name: str = 'Broadcast4'
|
||||
name: str = 'Aula A'
|
||||
language: str ='ita'
|
||||
program_info: str = 'Announcements Italian'
|
||||
audio_source: str = 'file:./testdata/announcement_it.wav'
|
||||
program_info: str = 'Aula IT'
|
||||
audio_source: str = 'file:./testdata/wave_particle_5min_it.wav'
|
||||
|
||||
|
||||
class AuracastBigConfigPol(AuracastBigConfig):
|
||||
id: int =12345678
|
||||
random_address: str = 'F6:F1:F2:F3:F4:F5'
|
||||
name: str = 'Sala Wykładowa'
|
||||
language: str ='pol'
|
||||
program_info: str = 'Sala Wykładowa PL'
|
||||
audio_source: str = 'file:./testdata/wave_particle_5min_pl.wav'
|
||||
|
||||
|
||||
class AuracastConfigGroup(AuracastGlobalConfig):
|
||||
|
||||
@@ -44,13 +44,18 @@ from bumble.profiles import bass
|
||||
import bumble.device
|
||||
import bumble.transport
|
||||
import bumble.utils
|
||||
import numpy as np # for audio down-mix
|
||||
from bumble.device import Host, BIGInfoAdvertisement, AdvertisingChannelMap
|
||||
from bumble.audio import io as audio_io
|
||||
|
||||
from auracast import auracast_config
|
||||
from auracast.utils.read_lc3_file import read_lc3_file
|
||||
from auracast.utils.network_audio_receiver import NetworkAudioReceiverUncoded
|
||||
from auracast.utils.webrtc_audio_input import WebRTCAudioInput
|
||||
|
||||
|
||||
# Instantiate WebRTC audio input for streaming (can be used per-BIG or globally)
|
||||
|
||||
# modified from bumble
|
||||
class ModWaveAudioInput(audio_io.ThreadedAudioInput):
|
||||
"""Audio input that reads PCM samples from a .wav file."""
|
||||
@@ -148,6 +153,30 @@ async def init_broadcast(
|
||||
bap_sampling_freq = getattr(bap.SamplingFrequency, f"FREQ_{global_config.auracast_sampling_rate_hz}")
|
||||
bigs = {}
|
||||
for i, conf in enumerate(big_config):
|
||||
metadata=le_audio.Metadata(
|
||||
[
|
||||
le_audio.Metadata.Entry(
|
||||
tag=le_audio.Metadata.Tag.LANGUAGE, data=conf.language.encode()
|
||||
),
|
||||
le_audio.Metadata.Entry(
|
||||
tag=le_audio.Metadata.Tag.PROGRAM_INFO, data=conf.program_info.encode()
|
||||
),
|
||||
le_audio.Metadata.Entry(
|
||||
tag=le_audio.Metadata.Tag.BROADCAST_NAME, data=conf.name.encode()
|
||||
),
|
||||
]
|
||||
+ (
|
||||
[
|
||||
# Broadcast Audio Immediate Rendering flag (type 0x09), zero-length value
|
||||
le_audio.Metadata.Entry(tag = le_audio.Metadata.Tag.BROADCAST_AUDIO_IMMEDIATE_RENDERING_FLAG, data=b"")
|
||||
]
|
||||
if global_config.immediate_rendering #TODO: verify this
|
||||
else []
|
||||
)
|
||||
)
|
||||
logging.info(
|
||||
metadata.pretty_print("\n")
|
||||
)
|
||||
bigs[f'big{i}'] = {}
|
||||
# Config advertising set
|
||||
bigs[f'big{i}']['basic_audio_announcement'] = bap.BasicAudioAnnouncement(
|
||||
@@ -160,19 +189,7 @@ async def init_broadcast(
|
||||
frame_duration=bap.FrameDuration.DURATION_10000_US,
|
||||
octets_per_codec_frame=global_config.octets_per_frame,
|
||||
),
|
||||
metadata=le_audio.Metadata(
|
||||
[
|
||||
le_audio.Metadata.Entry(
|
||||
tag=le_audio.Metadata.Tag.LANGUAGE, data=conf.language.encode()
|
||||
),
|
||||
le_audio.Metadata.Entry(
|
||||
tag=le_audio.Metadata.Tag.PROGRAM_INFO, data=conf.program_info.encode()
|
||||
),
|
||||
le_audio.Metadata.Entry(
|
||||
tag=le_audio.Metadata.Tag.BROADCAST_NAME, data=conf.name.encode()
|
||||
),
|
||||
]
|
||||
),
|
||||
metadata=metadata,
|
||||
bis=[
|
||||
bap.BasicAudioAnnouncement.BIS(
|
||||
index=1,
|
||||
@@ -211,7 +228,7 @@ async def init_broadcast(
|
||||
primary_advertising_interval_max=200,
|
||||
advertising_sid=i,
|
||||
primary_advertising_phy=hci.Phy.LE_1M, # 2m phy config throws error - because for primary advertising channels, 1mbit is only supported
|
||||
secondary_advertising_phy=hci.Phy.LE_2M, # this is the secondary advertising beeing send on non advertising channels (extendend advertising)
|
||||
secondary_advertising_phy=hci.Phy.LE_1M, # this is the secondary advertising beeing send on non advertising channels (extendend advertising)
|
||||
#advertising_tx_power= # tx power in dbm (max 20)
|
||||
#secondary_advertising_max_skip=10,
|
||||
),
|
||||
@@ -272,7 +289,7 @@ async def init_broadcast(
|
||||
|
||||
logging.debug(f'big{i} parameters are:')
|
||||
logging.debug('%s', pprint.pformat(vars(big)))
|
||||
logging.debug(f'Finished setup of big{i}.')
|
||||
logging.info(f'Finished setup of big{i}.')
|
||||
|
||||
await asyncio.sleep(i+1) # Wait for advertising to set up
|
||||
|
||||
@@ -322,26 +339,75 @@ class Streamer():
|
||||
else:
|
||||
logging.warning('Streamer is already running')
|
||||
|
||||
def stop_streaming(self):
|
||||
"""Stops the background task if running."""
|
||||
if self.is_streaming:
|
||||
self.is_streaming = False
|
||||
if self.task:
|
||||
self.task.cancel() # Cancel the task safely
|
||||
self.task = None
|
||||
async def stop_streaming(self):
|
||||
"""Gracefully stop streaming and release audio devices."""
|
||||
if not self.is_streaming and self.task is None:
|
||||
return
|
||||
|
||||
# Ask the streaming loop to finish
|
||||
self.is_streaming = False
|
||||
if self.task is not None:
|
||||
self.task.cancel()
|
||||
|
||||
self.task = None
|
||||
|
||||
# Close audio inputs (await to ensure ALSA devices are released)
|
||||
close_tasks = []
|
||||
for big in self.bigs.values():
|
||||
ai = big.get("audio_input")
|
||||
if ai and hasattr(ai, "close"):
|
||||
close_tasks.append(ai.close())
|
||||
# Remove reference so a fresh one is created next time
|
||||
big.pop("audio_input", None)
|
||||
if close_tasks:
|
||||
await asyncio.gather(*close_tasks, return_exceptions=True)
|
||||
|
||||
async def stream(self):
|
||||
|
||||
bigs = self.bigs
|
||||
big_config = self.big_config
|
||||
global_config = self.global_config
|
||||
# init
|
||||
for i, big in enumerate(bigs.values()):
|
||||
audio_source = big_config[i].audio_source
|
||||
input_format = big_config[i].input_format
|
||||
|
||||
# --- New: network_uncoded mode using NetworkAudioReceiver ---
|
||||
if isinstance(audio_source, NetworkAudioReceiverUncoded):
|
||||
# Start the UDP receiver coroutine so packets are actually received
|
||||
asyncio.create_task(audio_source.receive())
|
||||
encoder = lc3.Encoder(
|
||||
frame_duration_us=global_config.frame_duration_us,
|
||||
sample_rate_hz=global_config.auracast_sampling_rate_hz,
|
||||
num_channels=1,
|
||||
input_sample_rate_hz=audio_source.samplerate,
|
||||
)
|
||||
lc3_frame_samples = encoder.get_frame_samples()
|
||||
big['pcm_bit_depth'] = 16
|
||||
big['lc3_frame_samples'] = lc3_frame_samples
|
||||
big['lc3_bytes_per_frame'] = global_config.octets_per_frame
|
||||
big['audio_input'] = audio_source
|
||||
big['encoder'] = encoder
|
||||
big['precoded'] = False
|
||||
|
||||
elif audio_source == 'webrtc':
|
||||
big['audio_input'] = WebRTCAudioInput()
|
||||
encoder = lc3.Encoder(
|
||||
frame_duration_us=global_config.frame_duration_us,
|
||||
sample_rate_hz=global_config.auracast_sampling_rate_hz,
|
||||
num_channels=1,
|
||||
input_sample_rate_hz=48000, # TODO: get samplerate from webrtc
|
||||
)
|
||||
lc3_frame_samples = encoder.get_frame_samples()
|
||||
big['pcm_bit_depth'] = 16
|
||||
big['lc3_frame_samples'] = lc3_frame_samples
|
||||
big['lc3_bytes_per_frame'] = global_config.octets_per_frame
|
||||
big['encoder'] = encoder
|
||||
big['precoded'] = False
|
||||
|
||||
# precoded lc3 from ram
|
||||
if isinstance(big_config[i].audio_source, bytes):
|
||||
elif isinstance(big_config[i].audio_source, bytes):
|
||||
big['precoded'] = True
|
||||
big['lc3_bytes_per_frame'] = global_config.octets_per_frame
|
||||
|
||||
lc3_frames = iter(big_config[i].audio_source)
|
||||
|
||||
@@ -352,6 +418,7 @@ class Streamer():
|
||||
# precoded lc3 file
|
||||
elif big_config[i].audio_source.endswith('.lc3'):
|
||||
big['precoded'] = True
|
||||
big['lc3_bytes_per_frame'] = global_config.octets_per_frame
|
||||
filename = big_config[i].audio_source.replace('file:', '')
|
||||
|
||||
lc3_bytes = read_lc3_file(filename)
|
||||
@@ -363,21 +430,23 @@ class Streamer():
|
||||
|
||||
# use wav files and code them entirely before streaming
|
||||
elif big_config[i].precode_wav and big_config[i].audio_source.endswith('.wav'):
|
||||
logging.info('Precoding wav file: %s, this may take a while', big_config[i].audio_source)
|
||||
big['precoded'] = True
|
||||
big['lc3_bytes_per_frame'] = global_config.octets_per_frame
|
||||
|
||||
audio_input = await audio_io.create_audio_input(audio_source, input_format)
|
||||
audio_input.rewind = False
|
||||
pcm_format = await audio_input.open()
|
||||
|
||||
if pcm_format.channels != 1:
|
||||
print("Only 1 channels PCM configurations are supported")
|
||||
logging.error("Only 1 channels PCM configurations are supported")
|
||||
return
|
||||
if pcm_format.sample_type == audio_io.PcmFormat.SampleType.INT16:
|
||||
pcm_bit_depth = 16
|
||||
elif pcm_format.sample_type == audio_io.PcmFormat.SampleType.FLOAT32:
|
||||
pcm_bit_depth = None
|
||||
else:
|
||||
print("Only INT16 and FLOAT32 sample types are supported")
|
||||
logging.error("Only INT16 and FLOAT32 sample types are supported")
|
||||
return
|
||||
encoder = lc3.Encoder(
|
||||
frame_duration_us=global_config.frame_duration_us,
|
||||
@@ -402,40 +471,92 @@ class Streamer():
|
||||
# anything else, e.g. realtime stream from device (bumble)
|
||||
else:
|
||||
audio_input = await audio_io.create_audio_input(audio_source, input_format)
|
||||
audio_input.rewind = big_config[i].loop
|
||||
pcm_format = await audio_input.open()
|
||||
# Store early so stop_streaming can close even if open() fails
|
||||
big['audio_input'] = audio_input
|
||||
# SoundDeviceAudioInput (used for `mic:<device>` captures) has no `.rewind`.
|
||||
if hasattr(audio_input, "rewind"):
|
||||
audio_input.rewind = big_config[i].loop
|
||||
|
||||
#try:
|
||||
if pcm_format.channels != 1:
|
||||
print("Only 1 channels PCM configurations are supported")
|
||||
# Retry logic – ALSA sometimes keeps the device busy for a short time after the
|
||||
# previous stream has closed. Handle PortAudioError -9985 with back-off retries.
|
||||
import sounddevice as _sd
|
||||
max_attempts = 3
|
||||
for attempt in range(1, max_attempts + 1):
|
||||
try:
|
||||
pcm_format = await audio_input.open()
|
||||
break # success
|
||||
except _sd.PortAudioError as err:
|
||||
# -9985 == paDeviceUnavailable
|
||||
logging.error('Could not open audio device %s with error %s', audio_source, err)
|
||||
code = None
|
||||
if hasattr(err, 'errno'):
|
||||
code = err.errno
|
||||
elif len(err.args) > 1 and isinstance(err.args[1], int):
|
||||
code = err.args[1]
|
||||
if code == -9985 and attempt < max_attempts:
|
||||
backoff_ms = 200 * attempt
|
||||
logging.warning("PortAudio device busy (attempt %d/%d). Retrying in %.1f ms…", attempt, max_attempts, backoff_ms)
|
||||
# ensure device handle and PortAudio context are closed before retrying
|
||||
try:
|
||||
if hasattr(audio_input, "aclose"):
|
||||
await audio_input.aclose()
|
||||
elif hasattr(audio_input, "close"):
|
||||
audio_input.close()
|
||||
except Exception:
|
||||
pass
|
||||
# Fully terminate PortAudio to drop lingering handles (sounddevice quirk)
|
||||
if hasattr(_sd, "_terminate"):
|
||||
try:
|
||||
_sd._terminate()
|
||||
except Exception:
|
||||
pass
|
||||
# Small pause then re-initialize PortAudio
|
||||
await asyncio.sleep(0.1)
|
||||
if hasattr(_sd, "_initialize"):
|
||||
try:
|
||||
_sd._initialize()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# Back-off before next attempt
|
||||
await asyncio.sleep(backoff_ms / 1000)
|
||||
# Recreate audio_input fresh for next attempt
|
||||
audio_input = await audio_io.create_audio_input(audio_source, input_format)
|
||||
continue
|
||||
# Other errors or final attempt – re-raise so caller can abort gracefully
|
||||
raise
|
||||
else:
|
||||
# Loop exhausted without break
|
||||
logging.error("Unable to open audio device after %d attempts – giving up", max_attempts)
|
||||
return
|
||||
|
||||
if pcm_format.channels != 1:
|
||||
logging.info("Input device provides %d channels – will down-mix to mono for LC3", pcm_format.channels)
|
||||
if pcm_format.sample_type == audio_io.PcmFormat.SampleType.INT16:
|
||||
pcm_bit_depth = 16
|
||||
elif pcm_format.sample_type == audio_io.PcmFormat.SampleType.FLOAT32:
|
||||
pcm_bit_depth = None
|
||||
else:
|
||||
print("Only INT16 and FLOAT32 sample types are supported")
|
||||
logging.error("Only INT16 and FLOAT32 sample types are supported")
|
||||
return
|
||||
|
||||
encoder = lc3.Encoder(
|
||||
frame_duration_us=global_config.frame_duration_us,
|
||||
sample_rate_hz=global_config.auracast_sampling_rate_hz,
|
||||
num_channels=1,
|
||||
input_sample_rate_hz=pcm_format.sample_rate,
|
||||
)
|
||||
|
||||
lc3_frame_samples = encoder.get_frame_samples() # number of the pcm samples per lc3 frame
|
||||
|
||||
big['pcm_bit_depth'] = pcm_bit_depth
|
||||
big['channels'] = pcm_format.channels
|
||||
big['lc3_frame_samples'] = lc3_frame_samples
|
||||
big['lc3_bytes_per_frame'] = global_config.octets_per_frame
|
||||
big['audio_input'] = audio_input
|
||||
big['encoder'] = encoder
|
||||
big['precoded'] = False
|
||||
|
||||
# Need for coded an uncoded audio
|
||||
lc3_frame_size = global_config.octets_per_frame #encoder.get_frame_bytes(bitrate)
|
||||
lc3_bytes_per_frame = lc3_frame_size #* 2 #multiplied by number of channels
|
||||
big['lc3_bytes_per_frame'] = lc3_bytes_per_frame
|
||||
|
||||
# TODO: Maybe do some pre buffering so the stream is stable from the beginning. One half iso queue would be appropriate
|
||||
logging.info("Streaming audio...")
|
||||
bigs = self.bigs
|
||||
self.is_streaming = True
|
||||
@@ -443,7 +564,6 @@ class Streamer():
|
||||
while self.is_streaming:
|
||||
stream_finished = [False for _ in range(len(bigs))]
|
||||
for i, big in enumerate(bigs.values()):
|
||||
|
||||
if big['precoded']:# everything was already lc3 coded beforehand
|
||||
lc3_frame = bytes(
|
||||
itertools.islice(big['lc3_frames'], big['lc3_bytes_per_frame'])
|
||||
@@ -452,13 +572,26 @@ class Streamer():
|
||||
if lc3_frame == b'': # Not all streams may stop at the same time
|
||||
stream_finished[i] = True
|
||||
continue
|
||||
else:
|
||||
else: # code lc3 on the fly
|
||||
pcm_frame = await anext(big['audio_input'].frames(big['lc3_frame_samples']), None)
|
||||
|
||||
if pcm_frame is None: # Not all streams may stop at the same time
|
||||
stream_finished[i] = True
|
||||
continue
|
||||
|
||||
# Down-mix multi-channel PCM to mono for LC3 encoder if needed
|
||||
if big.get('channels', 1) > 1:
|
||||
if isinstance(pcm_frame, np.ndarray):
|
||||
if pcm_frame.ndim > 1:
|
||||
mono = pcm_frame.mean(axis=1).astype(pcm_frame.dtype)
|
||||
pcm_frame = mono
|
||||
else:
|
||||
# Convert raw bytes to numpy, average channels, convert back
|
||||
dtype = np.int16 if big['pcm_bit_depth'] == 16 else np.float32
|
||||
samples = np.frombuffer(pcm_frame, dtype=dtype)
|
||||
samples = samples.reshape(-1, big['channels']).mean(axis=1)
|
||||
pcm_frame = samples.astype(dtype).tobytes()
|
||||
|
||||
lc3_frame = big['encoder'].encode(
|
||||
pcm_frame, num_bytes=big['lc3_bytes_per_frame'], bit_depth=big['pcm_bit_depth']
|
||||
)
|
||||
@@ -511,13 +644,12 @@ async def broadcast(global_conf: auracast_config.AuracastGlobalConfig, big_conf:
|
||||
if __name__ == "__main__":
|
||||
import os
|
||||
|
||||
logging.basicConfig( #export LOG_LEVEL=INFO
|
||||
level=os.environ.get('LOG_LEVEL', logging.DEBUG),
|
||||
logging.basicConfig( #export LOG_LEVEL=DEBUG
|
||||
level=os.environ.get('LOG_LEVEL', logging.INFO),
|
||||
format='%(module)s.py:%(lineno)d %(levelname)s: %(message)s'
|
||||
)
|
||||
os.chdir(os.path.dirname(__file__))
|
||||
|
||||
|
||||
config = auracast_config.AuracastConfigGroup(
|
||||
bigs = [
|
||||
auracast_config.AuracastBigConfigDeu(),
|
||||
@@ -537,15 +669,19 @@ if __name__ == "__main__":
|
||||
#config.transport='serial:/dev/serial/by-id/usb-ZEPHYR_Zephyr_HCI_UART_sample_95A087EADB030B24-if00,115200,rtscts' #nrf52dongle hci_uart usb cdc
|
||||
#config.transport='usb:2fe3:000b' #nrf52dongle hci_usb # TODO: iso packet over usb not supported
|
||||
#config.transport= 'auto'
|
||||
config.transport='serial:/dev/ttyAMA2,1000000,rtscts' # transport for raspberry pi
|
||||
config.transport='serial:/dev/ttyAMA4,1000000,rtscts' # transport for raspberry pi
|
||||
|
||||
# TODO: encrypted streams are not working
|
||||
|
||||
for big in config.bigs: # TODO: encrypted streams are not working
|
||||
for big in config.bigs:
|
||||
#big.code = 'ff'*16 # returns hci/HCI_ENCRYPTION_MODE_NOT_ACCEPTABLE_ERROR
|
||||
#big.code = '78 e5 dc f1 34 ab 42 bf c1 92 ef dd 3a fd 67 ae'
|
||||
big.precode_wav = True
|
||||
big.audio_source = big.audio_source.replace('.wav', '_10_16_32.lc3') #lc3 precoded files
|
||||
big.audio_source = read_lc3_file(big.audio_source) # load files in advance
|
||||
big.precode_wav = False
|
||||
#big.audio_source = big.audio_source.replace('.wav', '_10_16_32.lc3') #lc3 precoded files
|
||||
#big.audio_source = read_lc3_file(big.audio_source) # load files in advance
|
||||
|
||||
# --- Network_uncoded mode using NetworkAudioReceiver ---
|
||||
#big.audio_source = NetworkAudioReceiverUncoded(port=50007, samplerate=16000, channels=1, chunk_size=1024)
|
||||
|
||||
# 16kHz works reliably with 3 streams
|
||||
# 24kHz is only working with 2 streams - probably airtime constraint
|
||||
|
||||
@@ -52,13 +52,19 @@ class Multicaster:
|
||||
self.device = device
|
||||
self.is_auracast_init = True
|
||||
|
||||
def start_streaming(self):
|
||||
async def start_streaming(self):
|
||||
"""Start streaming; if an old stream is running, stop it first to release audio devices."""
|
||||
if self.streamer is not None:
|
||||
await self.stop_streaming()
|
||||
# Brief pause to ensure ALSA/PortAudio fully releases the input device
|
||||
await asyncio.sleep(0.5)
|
||||
self.streamer = multicast.Streamer(self.bigs, self.global_conf, self.big_conf)
|
||||
self.streamer.start_streaming()
|
||||
|
||||
def stop_streaming(self):
|
||||
|
||||
async def stop_streaming(self):
|
||||
if self.streamer is not None:
|
||||
self.streamer.stop_streaming()
|
||||
await self.streamer.stop_streaming()
|
||||
self.streamer = None
|
||||
|
||||
async def reset(self):
|
||||
@@ -66,18 +72,28 @@ class Multicaster:
|
||||
self.__init__(self.global_conf, self.big_conf)
|
||||
|
||||
async def shutdown(self):
|
||||
# Ensure streaming is fully stopped before tearing down Bluetooth resources
|
||||
if self.streamer is not None:
|
||||
await self.stop_streaming()
|
||||
|
||||
self.is_auracast_init = False
|
||||
self. is_audio_init = False
|
||||
self.is_audio_init = False
|
||||
|
||||
if self.bigs:
|
||||
for big in self.bigs.values():
|
||||
if big.get('audio_input'):
|
||||
if hasattr(big['audio_input'], 'aclose'):
|
||||
await big['audio_input'].aclose()
|
||||
|
||||
if self.device:
|
||||
await self.device.stop_advertising()
|
||||
if self.bigs:
|
||||
for big in self.bigs.values():
|
||||
if big['advertising_set']:
|
||||
if big.get('advertising_set'):
|
||||
await big['advertising_set'].stop()
|
||||
await self.device_acm.__aexit__(None, None, None) # Manually triggering teardown
|
||||
|
||||
|
||||
|
||||
# example commandline ui
|
||||
async def command_line_ui(caster: Multicaster):
|
||||
while True:
|
||||
|
||||
155
src/auracast/multicast_script.py
Normal file
155
src/auracast/multicast_script.py
Normal file
@@ -0,0 +1,155 @@
|
||||
"""
|
||||
multicast_script
|
||||
=================
|
||||
|
||||
Loads environment variables from a .env file located next to this script
|
||||
and configures the multicast broadcast. Only UPPERCASE keys are read.
|
||||
|
||||
Environment variables
|
||||
---------------------
|
||||
- LOG_LEVEL: Logging level for the script.
|
||||
Default: INFO. Examples: DEBUG, INFO, WARNING, ERROR.
|
||||
|
||||
- INPUT: Select audio capture source.
|
||||
Values:
|
||||
- "usb" (default): first available USB input device.
|
||||
- "aes67": select AES67 inputs. Two forms:
|
||||
* INPUT=aes67 -> first available AES67 input.
|
||||
* INPUT=aes67,<substr> -> case-insensitive substring match against
|
||||
the device name, e.g. INPUT=aes67,8f6326.
|
||||
|
||||
- BROADCAST_NAME: Name of the broadcast (Auracast BIG name).
|
||||
Default: "Broadcast0".
|
||||
|
||||
- PROGRAM_INFO: Free-text program/broadcast info.
|
||||
Default: "Some Announcements".
|
||||
|
||||
- LANGUATE: ISO 639-3 language code used by config (intentional key name).
|
||||
Default: "deu".
|
||||
|
||||
- PULSE_LATENCY_MSEC: Pulse/PipeWire latency hint in milliseconds.
|
||||
Default: 3.
|
||||
|
||||
Examples (.env)
|
||||
---------------
|
||||
LOG_LEVEL=DEBUG
|
||||
INPUT=aes67,8f6326
|
||||
BROADCAST_NAME=MyBroadcast
|
||||
PROGRAM_INFO="Live announcements"
|
||||
LANGUATE=deu
|
||||
"""
|
||||
import logging
|
||||
import os
|
||||
import time
|
||||
from dotenv import load_dotenv
|
||||
from auracast import multicast
|
||||
from auracast import auracast_config
|
||||
from auracast.utils.sounddevice_utils import list_usb_pw_inputs, list_network_pw_inputs
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
|
||||
logging.basicConfig( #export LOG_LEVEL=DEBUG
|
||||
level=os.environ.get('LOG_LEVEL', logging.INFO),
|
||||
format='%(module)s.py:%(lineno)d %(levelname)s: %(message)s'
|
||||
)
|
||||
os.chdir(os.path.dirname(__file__))
|
||||
# Load .env located next to this script (only uppercase keys will be referenced)
|
||||
load_dotenv(dotenv_path='.env')
|
||||
|
||||
os.environ.setdefault("PULSE_LATENCY_MSEC", "3")
|
||||
|
||||
usb_inputs = list_usb_pw_inputs()
|
||||
logging.info("USB pw inputs:")
|
||||
for i, d in usb_inputs:
|
||||
logging.info(f"{i}: {d['name']} in={d['max_input_channels']}")
|
||||
|
||||
aes67_inputs = list_network_pw_inputs()
|
||||
logging.info("AES67 pw inputs:")
|
||||
for i, d in aes67_inputs:
|
||||
logging.info(f"{i}: {d['name']} in={d['max_input_channels']}")
|
||||
|
||||
# Input selection (usb | aes67). Default to usb.
|
||||
# Allows specifying an AES67 device by substring: INPUT=aes67,<substring>
|
||||
# Example: INPUT=aes67,8f6326 will match a device name containing "8f6326".
|
||||
input_env = os.environ.get('INPUT', 'usb') or 'usb'
|
||||
parts = [p.strip() for p in input_env.split(',', 1)]
|
||||
input_mode = (parts[0] or 'usb').lower()
|
||||
iface_substr = (parts[1].lower() if len(parts) > 1 and parts[1] else None)
|
||||
|
||||
selected_dev = None
|
||||
if input_mode == 'aes67':
|
||||
if not aes67_inputs and not iface_substr:
|
||||
# No AES67 inputs and no specific target -> fail fast
|
||||
raise RuntimeError("No AES67 audio inputs found.")
|
||||
if iface_substr:
|
||||
# Loop until a matching AES67 input becomes available
|
||||
while True:
|
||||
current = list_network_pw_inputs()
|
||||
sel = next(((i, d) for i, d in current if iface_substr in (d.get('name','').lower())), None)
|
||||
if sel:
|
||||
input_sel = sel[0]
|
||||
selected_dev = sel[1]
|
||||
logging.info(f"Selected AES67 input by match '{iface_substr}': index={input_sel}")
|
||||
break
|
||||
logging.info(f"Waiting for AES67 input matching '{iface_substr}'... retrying in 2s")
|
||||
time.sleep(2)
|
||||
else:
|
||||
input_sel, selected_dev = aes67_inputs[0]
|
||||
logging.info(f"Selected first AES67 input: index={input_sel}, device={selected_dev['name']}")
|
||||
else:
|
||||
if usb_inputs:
|
||||
input_sel, selected_dev = usb_inputs[0]
|
||||
logging.info(f"Selected first USB input: index={input_sel}, device={selected_dev['name']}")
|
||||
else:
|
||||
raise RuntimeError("No USB audio inputs found.")
|
||||
|
||||
TRANSPORT1 = 'serial:/dev/ttyAMA3,1000000,rtscts' # transport for raspberry pi gpio header
|
||||
TRANSPORT2 = 'serial:/dev/ttyAMA4,1000000,rtscts' # transport for raspberry pi gpio header
|
||||
# Capture at 48 kHz to avoid PipeWire resampler latency; encode LC3 at 24 kHz
|
||||
CAPTURE_SRATE = 48000
|
||||
LC3_SRATE = 24000
|
||||
OCTETS_PER_FRAME=60
|
||||
|
||||
# Read uppercase-only settings from environment/.env
|
||||
broadcast_name = os.environ.get('BROADCAST_NAME', 'Broadcast0')
|
||||
program_info = os.environ.get('PROGRAM_INFO', 'Some Announcements')
|
||||
# Note: 'LANGUATE' (typo) is intentionally used as requested, maps to config.language
|
||||
language = os.environ.get('LANGUATE', 'deu')
|
||||
|
||||
# Determine capture channel count based on selected device (prefer up to 2)
|
||||
try:
|
||||
max_in = int((selected_dev or {}).get('max_input_channels', 1))
|
||||
except Exception:
|
||||
max_in = 1
|
||||
channels = max(1, min(2, max_in))
|
||||
|
||||
config = auracast_config.AuracastConfigGroup(
|
||||
bigs = [
|
||||
auracast_config.AuracastBigConfig(
|
||||
name=broadcast_name,
|
||||
program_info=program_info,
|
||||
language=language,
|
||||
iso_que_len=1,
|
||||
audio_source=f'device:{input_sel}',
|
||||
input_format=f"int16le,{CAPTURE_SRATE},{channels}",
|
||||
sampling_frequency=LC3_SRATE,
|
||||
octets_per_frame=OCTETS_PER_FRAME,
|
||||
),
|
||||
#auracast_config.AuracastBigConfigEng(),
|
||||
],
|
||||
immediate_rendering=True,
|
||||
presentation_delay_us=40000,
|
||||
qos_config=auracast_config.AuracastQosHigh(),
|
||||
auracast_sampling_rate_hz = LC3_SRATE,
|
||||
octets_per_frame = OCTETS_PER_FRAME, # 32kbps@16kHz
|
||||
transport=TRANSPORT1
|
||||
)
|
||||
#config.debug = True
|
||||
|
||||
multicast.run_async(
|
||||
multicast.broadcast(
|
||||
config,
|
||||
config.bigs
|
||||
)
|
||||
)
|
||||
@@ -1,104 +0,0 @@
|
||||
import glob
|
||||
import logging as log
|
||||
from fastapi import FastAPI, HTTPException
|
||||
from auracast import multicast_control, auracast_config
|
||||
|
||||
app = FastAPI()
|
||||
|
||||
# Initialize global configuration
|
||||
global_config_group = auracast_config.AuracastConfigGroup()
|
||||
|
||||
# Create multicast controller
|
||||
multicaster: multicast_control.Multicaster | None = None
|
||||
|
||||
|
||||
@app.post("/init")
|
||||
async def initialize(conf: auracast_config.AuracastConfigGroup):
|
||||
"""Initializes the broadcasters."""
|
||||
global global_config_group
|
||||
global multicaster
|
||||
try:
|
||||
if conf.transport == 'auto':
|
||||
serial_devices = glob.glob('/dev/serial/by-id/*')
|
||||
log.info('Found serial devices: %s', serial_devices)
|
||||
for device in serial_devices:
|
||||
if 'usb-ZEPHYR_Zephyr_HCI_UART_sample' in device:
|
||||
log.info('Using: %s', device)
|
||||
conf.transport = f'serial:{device},115200,rtscts'
|
||||
break
|
||||
|
||||
# check again if transport is still auto
|
||||
if conf.transport == 'auto':
|
||||
HTTPException(status_code=500, detail='No suitable transport found.')
|
||||
|
||||
# initialize the streams dict
|
||||
global_config_group = conf
|
||||
log.info(
|
||||
'Initializing multicaster with config:\n %s', conf.model_dump_json(indent=2)
|
||||
)
|
||||
multicaster = multicast_control.Multicaster(
|
||||
conf,
|
||||
conf.bigs,
|
||||
)
|
||||
await multicaster.init_broadcast()
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
|
||||
@app.post("/stream_lc3")
|
||||
async def send_audio(audio_data: dict[str, str]):
|
||||
"""Streams pre-coded LC3 audio."""
|
||||
if multicaster is None:
|
||||
raise HTTPException(status_code=500, detail='Auracast endpoint was never intialized')
|
||||
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
|
||||
|
||||
multicaster.big_conf = global_config_group.bigs
|
||||
multicaster.start_streaming()
|
||||
return {"status": "audio_sent"}
|
||||
except Exception as 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")
|
||||
async def stop_audio():
|
||||
"""Stops streaming."""
|
||||
try:
|
||||
multicaster.stop_streaming()
|
||||
return {"status": "stopped"}
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
|
||||
@app.get("/status")
|
||||
async def get_status():
|
||||
"""Gets the current status of the multicaster."""
|
||||
if multicaster:
|
||||
return multicaster.get_status()
|
||||
else:
|
||||
return {
|
||||
'is_initialized': False,
|
||||
'is_streaming': False,
|
||||
}
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
import uvicorn
|
||||
log.basicConfig(
|
||||
level=log.INFO,
|
||||
format='%(module)s.py:%(lineno)d %(levelname)s: %(message)s'
|
||||
)
|
||||
uvicorn.run(app, host="0.0.0.0", port=5000)
|
||||
30
src/auracast/server/certs/ca/ca_cert.crt
Normal file
30
src/auracast/server/certs/ca/ca_cert.crt
Normal file
@@ -0,0 +1,30 @@
|
||||
-----BEGIN CERTIFICATE-----
|
||||
MIIFDzCCAvegAwIBAgIUJkOMN61fArjxyeFLR1u2PnoUCogwDQYJKoZIhvcNAQEL
|
||||
BQAwFzEVMBMGA1UEAwwMU3VtbWl0V2F2ZUNBMB4XDTI1MDYyOTE0MzMyOVoXDTQ1
|
||||
MDYyNDE0MzMyOVowFzEVMBMGA1UEAwwMU3VtbWl0V2F2ZUNBMIICIjANBgkqhkiG
|
||||
9w0BAQEFAAOCAg8AMIICCgKCAgEAnU1k3Yasc2mFCHNdBzf76Y7NTUIy50577fJL
|
||||
kVVjwxUsUzimj6hkeTd16o5EsmTpW19/N8o/JJ1j3ne37EB8vm0q9H7yyN0fx+Gy
|
||||
uJujmqu5ZG9+ow+kbqpxJUbRMmDkZqsF6/XHfNMUQLK5vVH219xmW/hgxdEB4o50
|
||||
jF25+jhUuolVYybhLT9AGtXhpqExmCn/o78I97+GtYNdkY8cwCt/khftM4DRDeEA
|
||||
NdyVUWHG2sWqgx0BpgyL9gH/YwfeqBrjFmhh1VbgPCdgypwRV6YHVUqPtmSL7H7q
|
||||
CmX8/ccyS6Cif9z/rsb1KwSeOgNKqV3D5DN3Qrboy9NmbWKXmhnF3Pl0EQ5f2/WS
|
||||
xN+NKo8LNyZErQ27jZ6Xn9rVBRQ4rTw5oVf5hi6bOZcW2GNIQhQomQy83ohwFDnW
|
||||
6aLsBag4/lGJFS+QpRAwIvFY4R559Ki3xndUQpvbt0KHIUNTlWddACm1tkcgXEGF
|
||||
GJRZMBcKlyNdM5cRjhMtuZljoY2nHdfouiy4SETHgFFVvIZ2uOZLikljkL7cnWqF
|
||||
0DZh9MxIZqZEoffSDRCRdlhmPITwuacGTFBNAmiGqg463rNmzcyc5JOoPUQrcSy1
|
||||
0F5Ig16tiGjpgNtqyBen0r0udEoU1bBF/kxhAQCbam/IqpTtR+ouRnnbE4ST2zV6
|
||||
IXc4mPcCAwEAAaNTMFEwHQYDVR0OBBYEFOsaKvMh7Lr+/O620X2uzHxlnvmzMB8G
|
||||
A1UdIwQYMBaAFOsaKvMh7Lr+/O620X2uzHxlnvmzMA8GA1UdEwEB/wQFMAMBAf8w
|
||||
DQYJKoZIhvcNAQELBQADggIBAAxq7hy9xgDCxwZOQX8Z5iBIK3agXUads7m91TcH
|
||||
/EzFfJsUDOpDsSi58wIXuCmiJ+qe2S+hJxghLNsqm01DosPuNLNI0gCDg+glx5z5
|
||||
ADtY0EJb7mRH+xuFC1GBdP7ve3REvfi7WC9snrqBUji/xL4VycaOyTDGOxWaHlyZ
|
||||
u876I6/+xkj5hkhM1bsbEcGZ81QnTaJyeVtHTRYaORPAb2FP2V65MTn18Pu08i4T
|
||||
bzh0KAsoDkwKvoEK24T5xFEUCuLexQ+6fabYXGro3It9VmAbrtkSyX8Z1eO7rVCu
|
||||
hsUrA6UDzTerX1pWafeftpKiH7YiOaYYOAVcqDn+WKwYq3MPafNJp8x8HV1eeWYD
|
||||
dx9HBKuvlOsoxnjJMnYusmQZyJk1EJR03najrV7HH8cyU2gfNyBwfsr6nU+FnDOX
|
||||
qL2P0nWDjBkfjQRvmG59YLDVZYhw30+lishpmMLGZGwRFCjMCHD7rAdQTB3dtCP6
|
||||
NqaGogwitIdIITBtyV1ZABoE3vQuUAKZChU+DsSKniyFitKDQrXP+rwcX5Y5/pS1
|
||||
S1s6ITgllbErKqAoeelEVkJyiWykEtrtdcD0DXTr/QY4GzXeMi9u+dMXUOt95Md2
|
||||
lQVAaFIX8QxbmHXen6GsXeHhPpPw8sXtC6rh7aqSCqqB6EDS77mjrGHXbSeBS5aq
|
||||
MklC
|
||||
-----END CERTIFICATE-----
|
||||
BIN
src/auracast/server/certs/ca/ca_cert.der
Normal file
BIN
src/auracast/server/certs/ca/ca_cert.der
Normal file
Binary file not shown.
30
src/auracast/server/certs/ca/ca_cert.pem
Normal file
30
src/auracast/server/certs/ca/ca_cert.pem
Normal file
@@ -0,0 +1,30 @@
|
||||
-----BEGIN CERTIFICATE-----
|
||||
MIIFDzCCAvegAwIBAgIUJkOMN61fArjxyeFLR1u2PnoUCogwDQYJKoZIhvcNAQEL
|
||||
BQAwFzEVMBMGA1UEAwwMU3VtbWl0V2F2ZUNBMB4XDTI1MDYyOTE0MzMyOVoXDTQ1
|
||||
MDYyNDE0MzMyOVowFzEVMBMGA1UEAwwMU3VtbWl0V2F2ZUNBMIICIjANBgkqhkiG
|
||||
9w0BAQEFAAOCAg8AMIICCgKCAgEAnU1k3Yasc2mFCHNdBzf76Y7NTUIy50577fJL
|
||||
kVVjwxUsUzimj6hkeTd16o5EsmTpW19/N8o/JJ1j3ne37EB8vm0q9H7yyN0fx+Gy
|
||||
uJujmqu5ZG9+ow+kbqpxJUbRMmDkZqsF6/XHfNMUQLK5vVH219xmW/hgxdEB4o50
|
||||
jF25+jhUuolVYybhLT9AGtXhpqExmCn/o78I97+GtYNdkY8cwCt/khftM4DRDeEA
|
||||
NdyVUWHG2sWqgx0BpgyL9gH/YwfeqBrjFmhh1VbgPCdgypwRV6YHVUqPtmSL7H7q
|
||||
CmX8/ccyS6Cif9z/rsb1KwSeOgNKqV3D5DN3Qrboy9NmbWKXmhnF3Pl0EQ5f2/WS
|
||||
xN+NKo8LNyZErQ27jZ6Xn9rVBRQ4rTw5oVf5hi6bOZcW2GNIQhQomQy83ohwFDnW
|
||||
6aLsBag4/lGJFS+QpRAwIvFY4R559Ki3xndUQpvbt0KHIUNTlWddACm1tkcgXEGF
|
||||
GJRZMBcKlyNdM5cRjhMtuZljoY2nHdfouiy4SETHgFFVvIZ2uOZLikljkL7cnWqF
|
||||
0DZh9MxIZqZEoffSDRCRdlhmPITwuacGTFBNAmiGqg463rNmzcyc5JOoPUQrcSy1
|
||||
0F5Ig16tiGjpgNtqyBen0r0udEoU1bBF/kxhAQCbam/IqpTtR+ouRnnbE4ST2zV6
|
||||
IXc4mPcCAwEAAaNTMFEwHQYDVR0OBBYEFOsaKvMh7Lr+/O620X2uzHxlnvmzMB8G
|
||||
A1UdIwQYMBaAFOsaKvMh7Lr+/O620X2uzHxlnvmzMA8GA1UdEwEB/wQFMAMBAf8w
|
||||
DQYJKoZIhvcNAQELBQADggIBAAxq7hy9xgDCxwZOQX8Z5iBIK3agXUads7m91TcH
|
||||
/EzFfJsUDOpDsSi58wIXuCmiJ+qe2S+hJxghLNsqm01DosPuNLNI0gCDg+glx5z5
|
||||
ADtY0EJb7mRH+xuFC1GBdP7ve3REvfi7WC9snrqBUji/xL4VycaOyTDGOxWaHlyZ
|
||||
u876I6/+xkj5hkhM1bsbEcGZ81QnTaJyeVtHTRYaORPAb2FP2V65MTn18Pu08i4T
|
||||
bzh0KAsoDkwKvoEK24T5xFEUCuLexQ+6fabYXGro3It9VmAbrtkSyX8Z1eO7rVCu
|
||||
hsUrA6UDzTerX1pWafeftpKiH7YiOaYYOAVcqDn+WKwYq3MPafNJp8x8HV1eeWYD
|
||||
dx9HBKuvlOsoxnjJMnYusmQZyJk1EJR03najrV7HH8cyU2gfNyBwfsr6nU+FnDOX
|
||||
qL2P0nWDjBkfjQRvmG59YLDVZYhw30+lishpmMLGZGwRFCjMCHD7rAdQTB3dtCP6
|
||||
NqaGogwitIdIITBtyV1ZABoE3vQuUAKZChU+DsSKniyFitKDQrXP+rwcX5Y5/pS1
|
||||
S1s6ITgllbErKqAoeelEVkJyiWykEtrtdcD0DXTr/QY4GzXeMi9u+dMXUOt95Md2
|
||||
lQVAaFIX8QxbmHXen6GsXeHhPpPw8sXtC6rh7aqSCqqB6EDS77mjrGHXbSeBS5aq
|
||||
MklC
|
||||
-----END CERTIFICATE-----
|
||||
1
src/auracast/server/certs/ca/ca_cert.srl
Normal file
1
src/auracast/server/certs/ca/ca_cert.srl
Normal file
@@ -0,0 +1 @@
|
||||
5078804E6FBCF893D5537715FD928E46AD576ECA
|
||||
52
src/auracast/server/certs/ca/ca_key.pem
Normal file
52
src/auracast/server/certs/ca/ca_key.pem
Normal file
@@ -0,0 +1,52 @@
|
||||
-----BEGIN PRIVATE KEY-----
|
||||
MIIJQQIBADANBgkqhkiG9w0BAQEFAASCCSswggknAgEAAoICAQCdTWTdhqxzaYUI
|
||||
c10HN/vpjs1NQjLnTnvt8kuRVWPDFSxTOKaPqGR5N3XqjkSyZOlbX383yj8knWPe
|
||||
d7fsQHy+bSr0fvLI3R/H4bK4m6Oaq7lkb36jD6RuqnElRtEyYORmqwXr9cd80xRA
|
||||
srm9UfbX3GZb+GDF0QHijnSMXbn6OFS6iVVjJuEtP0Aa1eGmoTGYKf+jvwj3v4a1
|
||||
g12RjxzAK3+SF+0zgNEN4QA13JVRYcbaxaqDHQGmDIv2Af9jB96oGuMWaGHVVuA8
|
||||
J2DKnBFXpgdVSo+2ZIvsfuoKZfz9xzJLoKJ/3P+uxvUrBJ46A0qpXcPkM3dCtujL
|
||||
02ZtYpeaGcXc+XQRDl/b9ZLE340qjws3JkStDbuNnpef2tUFFDitPDmhV/mGLps5
|
||||
lxbYY0hCFCiZDLzeiHAUOdbpouwFqDj+UYkVL5ClEDAi8VjhHnn0qLfGd1RCm9u3
|
||||
QochQ1OVZ10AKbW2RyBcQYUYlFkwFwqXI10zlxGOEy25mWOhjacd1+i6LLhIRMeA
|
||||
UVW8hna45kuKSWOQvtydaoXQNmH0zEhmpkSh99INEJF2WGY8hPC5pwZMUE0CaIaq
|
||||
Djres2bNzJzkk6g9RCtxLLXQXkiDXq2IaOmA22rIF6fSvS50ShTVsEX+TGEBAJtq
|
||||
b8iqlO1H6i5GedsThJPbNXohdziY9wIDAQABAoICAER+VSuyfve4HCGsXfcNNQcj
|
||||
U5jO+OxH++WFqcrsJAbnesf39Gq8N5eigxkxfo8xKn1LbVElIu52C+zsMy1PfSHL
|
||||
1jbk6iF1S2fVCmWg+5GXMaAefkVRQ9eeJqtFFUU69GkSEf+HIyhinsB3MjJR9MpU
|
||||
YUutsLGiCxCT2ALgsuDV02rv7rrATK9PicHFnL5aFQa9Tt+FiMmb33O88iq15p50
|
||||
slUyTuosrpq8/ML3PBtWGGjdRhxWLogXkX/6qbH81MJdBsGUjPkAnZ4DxX0jjNed
|
||||
5zaHw2D3kgfV0WHau9ji+i79EJTdbYW0gz+KgL0g/ssVlX0Rvd3SWDacY87AbeMQ
|
||||
b1Tl3iOXqt6nqHupxgWthAnrc81bz0NrabmKCnWCQLlYiuvJ+hN945H4uzjVh5Tx
|
||||
PS0Nf17zTZsrWQgkz/ei4SIQtg/3lBm70BSsSpu+JtFJ8P+SB64maqAhhaF4mlEk
|
||||
SA5cNaY+TKTO9up3aUWnYi/GFV2R3l+wTuNiC4QDmFZRWA4RrM0EK1HrhE+5fnxJ
|
||||
cPBU48QB+IrZOI0qoqd/8XxHyEe/qzJ7Ml7wLBMzPOyr9ST6PSmoDQrT4mxeHAVE
|
||||
ogfjJ5LjaY4kyJp/u5LsvhzF6sS5InvME2YnXXAb4nvxohPFFKY9iWDZ3W+jN6xD
|
||||
zQ40bdQDVZW6fXC+HbLBAoIBAQDQkmZYb6JhrZzSQbXoQOFevPi2odeqGCqgwLAD
|
||||
fp7ZMQisQYpcXmZtyBOWX8ZO+1O5KtXEFsdf+97rwOqMWVDmd2Q2VMSmW++Ni4U8
|
||||
HZvV2gfYZISds2PXtWVLF9UNuXZ+a+HPPDpqKenyaLJtMvr1xX2kBRsi1CMk6yLI
|
||||
tCIwh4rnDiYJYHrmIggP/w1YllCkM5k33OeFuzPnW2rY0z+Q260Cxr3ouktWJ4tz
|
||||
U7vssrZh3LtvWXvkSh7mbotON6YUXpeX2WV/E/7Kh/bm8uLZGuYVhHctvjUmYpA2
|
||||
LFk6i3Mulh0OHab3WcOQV+Dpcut6QBvS6aJsxYh/tWIsn3M3AoIBAQDBEnAzEZ2S
|
||||
cpOoXSKOaYpoQ7wnwRONckJ2FKYKA7anRX4FTW6d3E2Jb/+CmXqzcRWWSvNt7ab/
|
||||
N+fXVLi1Nc2fC5BI0hFEVvPwp9mnMH8HCG7QcHQAhjYaKS1QeCEyLCudzcNBXoR9
|
||||
OuKTQcJd9tX0oJj6GNuY76gmxH3Smgwim2fPsHX0A2kekpyqVS3zHo47oeUO0N/Q
|
||||
WWNcQ49+9T2KZXF116rjL1TDZkUHvGi6p1wSAc/J5ixQ6EagfJ72PujGBkpRTTiR
|
||||
Fl/Qp4Ldy7S7AzOeiP3/w/0j5qL0NN0ZjUnoOr8u+1WaUyxTxN4+TZG3ThIYIAK1
|
||||
UTs6VLz2gmhBAoIBABx2Dc89lIv9s+uhGeCSke5qnQnW9eX5HEAJazte2PBMV6Gh
|
||||
4+6M1y9d4QZhFV+LvjYDWV5DuXsolJfZIGh8e6SnYB5l3NvSqdLH2iuE4tIAyZdG
|
||||
yC3438P8tdDUdLdFupyvvgWYc2QvSgRRMx/hmAtXorhyFezfw9fy2jFHG29B37t9
|
||||
28TlzH+A31bHeBvBj0mI3PyZgWJnVELa366szPzIbUh2tE2Atm0QQmA/aeJ31Jlw
|
||||
FIeyT0ysrKDHLu1CfMBE1CzddpMruFYMza1gMYJswD7pb5XnYbtWMdWioZ5yjwop
|
||||
Y9ecRj90mVImG8PfcbCh9OoIBakQH3tF1hq+u2sCggEATdST/FJGlgmwMnfQ/V3Y
|
||||
WK2thM0Vh7ieyCEMyg6zK/0cjyCmzeZIL3ZBpzEdwIZ+sEZomVDrOAkeYbSafRpC
|
||||
WLH9qQ1dvpHa5pGTcQ1gt8ITgd1DNg7kcmlVBhJXN3WM46FV690hRaZePgSNSPm/
|
||||
SE0RPgiVRbKes3oUSrik2bKSB6xX8FULpDJwC04pJs+TgMCDqRRUlRXjswbdKs3L
|
||||
0CWStnGJRuoGnnp0q2itQ0lCGVQ3omkyRi9MgVebcSLtDR7uCJY7jmlZmLBeVfDP
|
||||
W3Av9+G7msY0HqvT1uQUmT9WotJDzbmtyXdr8Bz1hmIYsq87JhSJYvRrDtmoDyuE
|
||||
wQKCAQBYY7G1HIhLEeS07UZ1yMxSCLZM3rLqEfpckzy68aA48HeiockwbtShky/8
|
||||
D4pvSwZnTF9VWxXKaK46FuIULSUXc8eMSx2pCShQ5nFHa04j4ZjPwsWRrUc98AiO
|
||||
pkbSgfxmKwAHpRBlimMleG+kXz6Urr5CJVQyWMP1hXTpGR1HME1z9ZbaACwvfMJk
|
||||
0xCytMv3/m7JYiCfHRsc09sjHZQZtou0JpRczkxustxXL2wylvAjI4hNwYIl7Oj8
|
||||
yzhhDzoqUGOA8uhyXZtG6NfPMr5pBo0J/pskaHco8UNV+gjOwewHrwd7K2NZmQQj
|
||||
sKOYrVeRKuwd/MuNfkJTA8MOwLM4
|
||||
-----END PRIVATE KEY-----
|
||||
30
src/auracast/server/generate_ca_cert.sh
Normal file
30
src/auracast/server/generate_ca_cert.sh
Normal file
@@ -0,0 +1,30 @@
|
||||
#!/bin/bash
|
||||
# Script to generate a CA cert/key and a device/server cert signed by this CA
|
||||
# Outputs: ca_cert.pem, ca_key.pem, device_cert.pem, device_key.pem
|
||||
|
||||
CA_DIR=certs/ca
|
||||
mkdir -p "$CA_DIR"
|
||||
CA_CERT=$CA_DIR/ca_cert.pem
|
||||
CA_KEY=$CA_DIR/ca_key.pem
|
||||
|
||||
# Generate CA key and cert (20 year expiry)
|
||||
echo "Generating CA key and certificate (20 year expiry)..."
|
||||
openssl req -x509 -newkey rsa:4096 -days 7300 -nodes -subj "/CN=SummitWaveCA" -keyout "$CA_KEY" -out "$CA_CERT"
|
||||
|
||||
# PEM version (for most browsers)
|
||||
cp "$CA_CERT" "$CA_DIR/ca_cert.crt"
|
||||
# DER version (for Windows)
|
||||
openssl x509 -in "$CA_CERT" -outform der -out "$CA_DIR/ca_cert.der"
|
||||
|
||||
# Output summary
|
||||
echo "CA cert: $CA_CERT"
|
||||
echo "CA cert (CRT for browser import): $CA_DIR/ca_cert.crt"
|
||||
echo "CA key: $CA_KEY"
|
||||
echo "Distribute $CA_CERT or $CA_DIR/ca_cert.crt to clients to trust this device."
|
||||
echo "Keep $CA_KEY secret and offline except when signing device CSRs."
|
||||
echo "CA cert: $CA_CERT"
|
||||
echo "CA cert (CRT for browser import): $CERT_DIR/ca_cert.crt"
|
||||
echo "CA key: $CA_KEY"
|
||||
echo "Device cert: $DEVICE_CERT"
|
||||
echo "Device key: $DEVICE_KEY"
|
||||
echo "Distribute $CA_CERT or $CERT_DIR/ca_cert.crt to clients to trust this device."
|
||||
399
src/auracast/server/multicast_frontend.py
Normal file
399
src/auracast/server/multicast_frontend.py
Normal file
@@ -0,0 +1,399 @@
|
||||
# frontend/app.py
|
||||
import os
|
||||
import time
|
||||
import streamlit as st
|
||||
import requests
|
||||
from auracast import auracast_config
|
||||
import logging as log
|
||||
|
||||
# Track whether WebRTC stream is active across Streamlit reruns
|
||||
if 'stream_started' not in st.session_state:
|
||||
st.session_state['stream_started'] = False
|
||||
|
||||
# Global: desired packetization time in ms for Opus (should match backend)
|
||||
PTIME = 40
|
||||
BACKEND_URL = "http://localhost:5000"
|
||||
#TRANSPORT1 = "serial:/dev/serial/by-id/usb-ZEPHYR_Zephyr_HCI_UART_sample_B53C372677E14460-if00,115200,rtscts"
|
||||
#TRANSPORT2 = "serial:/dev/serial/by-id/usb-ZEPHYR_Zephyr_HCI_UART_sample_CC69A2912F84AE5E-if00,115200,rtscts"
|
||||
|
||||
TRANSPORT1 = 'serial:/dev/ttyAMA3,1000000,rtscts' # transport for raspberry pi gpio header
|
||||
TRANSPORT2 = 'serial:/dev/ttyAMA4,1000000,rtscts' # transport for raspberry pi gpio header
|
||||
QUALITY_MAP = {
|
||||
"High (48kHz)": {"rate": 48000, "octets": 120},
|
||||
"Good (32kHz)": {"rate": 32000, "octets": 80},
|
||||
"Medium (24kHz)": {"rate": 24000, "octets": 60},
|
||||
"Fair (16kHz)": {"rate": 16000, "octets": 40},
|
||||
}
|
||||
|
||||
# Try loading persisted settings from backend
|
||||
saved_settings = {}
|
||||
try:
|
||||
resp = requests.get(f"{BACKEND_URL}/status", timeout=1)
|
||||
if resp.status_code == 200:
|
||||
saved_settings = resp.json()
|
||||
except Exception:
|
||||
saved_settings = {}
|
||||
|
||||
st.title("🎙️ Auracast Audio Mode Control")
|
||||
|
||||
# Audio mode selection with persisted default
|
||||
options = ["Webapp", "USB/Network", "Demo"]
|
||||
saved_audio_mode = saved_settings.get("audio_mode", "Webapp")
|
||||
if saved_audio_mode not in options:
|
||||
saved_audio_mode = "Webapp"
|
||||
|
||||
audio_mode = st.selectbox(
|
||||
"Audio Mode",
|
||||
options,
|
||||
index=options.index(saved_audio_mode),
|
||||
help="Select the audio input source. Choose 'Webapp' for browser microphone, 'USB/Network' for a connected hardware device, or 'Demo' for a simulated stream."
|
||||
)
|
||||
|
||||
if audio_mode == "Demo":
|
||||
demo_stream_map = {
|
||||
"1 × 48kHz": {"quality": "High (48kHz)", "streams": 1},
|
||||
"2 × 24kHz": {"quality": "Medium (24kHz)", "streams": 2},
|
||||
"3 × 16kHz": {"quality": "Fair (16kHz)", "streams": 3},
|
||||
"2 × 48kHz": {"quality": "High (48kHz)", "streams": 2},
|
||||
"4 × 24kHz": {"quality": "Medium (24kHz)", "streams": 4},
|
||||
"6 × 16kHz": {"quality": "Fair (16kHz)", "streams": 6},
|
||||
}
|
||||
demo_options = list(demo_stream_map.keys())
|
||||
default_demo = demo_options[0]
|
||||
demo_selected = st.selectbox(
|
||||
"Demo Stream Type",
|
||||
demo_options,
|
||||
index=0,
|
||||
help="Select the demo stream configuration."
|
||||
)
|
||||
#st.info(f"Demo mode selected: {demo_selected} (Streams: {demo_stream_map[demo_selected]['streams']}, Rate: {demo_stream_map[demo_selected]['rate']} Hz)")
|
||||
# Start/Stop buttons for demo mode
|
||||
if 'demo_stream_started' not in st.session_state:
|
||||
st.session_state['demo_stream_started'] = False
|
||||
col1, col2 = st.columns(2)
|
||||
with col1:
|
||||
start_demo = st.button("Start Demo Stream")
|
||||
with col2:
|
||||
stop_demo = st.button("Stop Demo Stream")
|
||||
if start_demo:
|
||||
# Always stop any running stream for clean state
|
||||
try:
|
||||
requests.post(f"{BACKEND_URL}/stop_audio").json()
|
||||
except Exception:
|
||||
pass
|
||||
time.sleep(1)
|
||||
demo_cfg = demo_stream_map[demo_selected]
|
||||
# Octets per frame logic matches quality_map
|
||||
q = QUALITY_MAP[demo_cfg['quality']]
|
||||
|
||||
# Language configs and test files
|
||||
lang_cfgs = [
|
||||
(auracast_config.AuracastBigConfigDeu, 'de'),
|
||||
(auracast_config.AuracastBigConfigEng, 'en'),
|
||||
(auracast_config.AuracastBigConfigFra, 'fr'),
|
||||
(auracast_config.AuracastBigConfigSpa, 'es'),
|
||||
(auracast_config.AuracastBigConfigIta, 'it'),
|
||||
(auracast_config.AuracastBigConfigPol, 'pl'),
|
||||
]
|
||||
bigs1 = []
|
||||
for i in range(demo_cfg['streams']):
|
||||
cfg_cls, lang = lang_cfgs[i % len(lang_cfgs)]
|
||||
bigs1.append(cfg_cls(
|
||||
audio_source=f'file:../testdata/wave_particle_5min_{lang}_{int(q["rate"]/1000)}kHz_mono.wav',
|
||||
iso_que_len=32,
|
||||
sampling_frequency=q['rate'],
|
||||
octets_per_frame=q['octets'],
|
||||
))
|
||||
|
||||
# Split bigs into two configs if needed
|
||||
max_per_mc = {48000: 1, 24000: 2, 16000: 3}
|
||||
max_streams = max_per_mc.get(q['rate'], 3)
|
||||
bigs2 = []
|
||||
if len(bigs1) > max_streams:
|
||||
bigs2 = bigs1[max_streams:]
|
||||
bigs1 = bigs1[:max_streams]
|
||||
config1 = auracast_config.AuracastConfigGroup(
|
||||
auracast_sampling_rate_hz=q['rate'],
|
||||
octets_per_frame=q['octets'],
|
||||
transport=TRANSPORT1,
|
||||
bigs=bigs1
|
||||
)
|
||||
config2 = None
|
||||
if bigs2:
|
||||
config2 = auracast_config.AuracastConfigGroup(
|
||||
auracast_sampling_rate_hz=q['rate'],
|
||||
octets_per_frame=q['octets'],
|
||||
transport=TRANSPORT2,
|
||||
bigs=bigs2
|
||||
)
|
||||
# Call /init and /init2
|
||||
try:
|
||||
r1 = requests.post(f"{BACKEND_URL}/init", json=config1.model_dump())
|
||||
if r1.status_code == 200:
|
||||
msg = f"Demo stream started on multicaster 1 ({len(bigs1)} streams)"
|
||||
st.session_state['demo_stream_started'] = True
|
||||
st.success(msg)
|
||||
else:
|
||||
st.session_state['demo_stream_started'] = False
|
||||
st.error(f"Failed to initialize multicaster 1: {r1.text}")
|
||||
if config2:
|
||||
r2 = requests.post(f"{BACKEND_URL}/init2", json=config2.model_dump())
|
||||
if r2.status_code == 200:
|
||||
st.success(f"Demo stream started on multicaster 2 ({len(bigs2)} streams)")
|
||||
else:
|
||||
st.error(f"Failed to initialize multicaster 2: {r2.text}")
|
||||
except Exception as e:
|
||||
st.session_state['demo_stream_started'] = False
|
||||
st.error(f"Error: {e}")
|
||||
elif stop_demo:
|
||||
try:
|
||||
r = requests.post(f"{BACKEND_URL}/stop_audio").json()
|
||||
st.session_state['demo_stream_started'] = False
|
||||
if r.get('was_running'):
|
||||
st.info("Demo stream stopped.")
|
||||
else:
|
||||
st.info("Demo stream was not running.")
|
||||
except Exception as e:
|
||||
st.error(f"Error: {e}")
|
||||
elif st.session_state['demo_stream_started']:
|
||||
st.success(f"Demo stream running: {demo_selected}")
|
||||
else:
|
||||
st.info("Demo stream not running.")
|
||||
quality = None # Not used in demo mode
|
||||
else:
|
||||
# Stream quality selection (now enabled)
|
||||
|
||||
quality_options = list(QUALITY_MAP.keys())
|
||||
default_quality = "Medium (24kHz)" if "Medium (24kHz)" in quality_options else quality_options[0]
|
||||
quality = st.selectbox(
|
||||
"Stream Quality (Sampling Rate)",
|
||||
quality_options,
|
||||
index=quality_options.index(default_quality),
|
||||
help="Select the audio sampling rate for the stream. Lower rates may improve compatibility."
|
||||
)
|
||||
default_name = saved_settings.get('channel_names', ["Broadcast0"])[0]
|
||||
default_lang = saved_settings.get('languages', ["deu"])[0]
|
||||
default_input = saved_settings.get('input_device') or 'default'
|
||||
stream_name = st.text_input(
|
||||
"Channel Name",
|
||||
value=default_name,
|
||||
help="The primary name for your broadcast. Like the SSID of a WLAN, it identifies your stream for receivers."
|
||||
)
|
||||
raw_program_info = saved_settings.get('program_info', default_name)
|
||||
if isinstance(raw_program_info, list) and raw_program_info:
|
||||
default_program_info = raw_program_info[0]
|
||||
else:
|
||||
default_program_info = raw_program_info
|
||||
program_info = st.text_input(
|
||||
"Program Info",
|
||||
value=default_program_info,
|
||||
help="Additional details about the broadcast program, such as its content or purpose. Shown to receivers for more context."
|
||||
)
|
||||
language = st.text_input(
|
||||
"Language (ISO 639-3)",
|
||||
value=default_lang,
|
||||
help="Three-letter language code (e.g., 'eng' for English, 'deu' for German). Used by receivers to display the language of the stream. See: https://en.wikipedia.org/wiki/List_of_ISO_639-3_codes"
|
||||
)
|
||||
# Gain slider for Webapp mode
|
||||
if audio_mode == "Webapp":
|
||||
mic_gain = st.slider("Microphone Gain", 0.0, 2.0, 1.0, 0.1, help="Adjust microphone volume sent to Auracast")
|
||||
else:
|
||||
mic_gain = 1.0
|
||||
|
||||
# Input device selection for USB mode
|
||||
if audio_mode == "USB/Network":
|
||||
resp = requests.get(f"{BACKEND_URL}/audio_inputs")
|
||||
device_list = resp.json().get('inputs', [])
|
||||
# Display "name [id]" but use name as value
|
||||
input_options = [f"{d['name']} [{d['id']}]" for d in device_list]
|
||||
option_name_map = {f"{d['name']} [{d['id']}]": d['name'] for d in device_list}
|
||||
device_names = [d['name'] for d in device_list]
|
||||
|
||||
# Determine default input by name
|
||||
default_input_name = saved_settings.get('input_device')
|
||||
if default_input_name not in device_names and device_names:
|
||||
default_input_name = device_names[0]
|
||||
default_input_label = None
|
||||
for label, name in option_name_map.items():
|
||||
if name == default_input_name:
|
||||
default_input_label = label
|
||||
break
|
||||
if not input_options:
|
||||
st.warning("No hardware audio input devices found. Plug in a USB input device and click Refresh.")
|
||||
if st.button("Refresh"):
|
||||
try:
|
||||
requests.post(f"{BACKEND_URL}/refresh_audio_inputs", timeout=3)
|
||||
except Exception as e:
|
||||
st.error(f"Failed to refresh devices: {e}")
|
||||
st.rerun()
|
||||
input_device = None
|
||||
else:
|
||||
col1, col2 = st.columns([3, 1], vertical_alignment="bottom")
|
||||
with col1:
|
||||
selected_option = st.selectbox(
|
||||
"Input Device",
|
||||
input_options,
|
||||
index=input_options.index(default_input_label) if default_input_label in input_options else 0
|
||||
)
|
||||
with col2:
|
||||
if st.button("Refresh"):
|
||||
try:
|
||||
requests.post(f"{BACKEND_URL}/refresh_audio_inputs", timeout=3)
|
||||
except Exception as e:
|
||||
st.error(f"Failed to refresh devices: {e}")
|
||||
st.rerun()
|
||||
# Send only the device name to backend
|
||||
input_device = option_name_map[selected_option] if selected_option in option_name_map else None
|
||||
else:
|
||||
input_device = None
|
||||
|
||||
start_stream = st.button("Start Auracast")
|
||||
stop_stream = st.button("Stop Auracast")
|
||||
|
||||
# If gain slider moved while streaming, send update to JS without restarting
|
||||
if audio_mode == "Webapp" and st.session_state.get('stream_started'):
|
||||
update_js = f"""
|
||||
<script>
|
||||
if (window.gainNode) {{ window.gainNode.gain.value = {mic_gain}; }}
|
||||
</script>
|
||||
"""
|
||||
st.components.v1.html(update_js, height=0)
|
||||
|
||||
if stop_stream:
|
||||
st.session_state['stream_started'] = False
|
||||
try:
|
||||
r = requests.post(f"{BACKEND_URL}/stop_audio").json()
|
||||
if r['was_running']:
|
||||
st.success("Stream Stopped!")
|
||||
else:
|
||||
st.success("Stream was not running.")
|
||||
except Exception as e:
|
||||
st.error(f"Error: {e}")
|
||||
# Ensure existing WebRTC connection is fully closed so that a fresh
|
||||
# connection is created the next time we start the stream.
|
||||
if audio_mode == "Webapp":
|
||||
cleanup_js = """
|
||||
<script>
|
||||
if (window.webrtc_pc) {
|
||||
window.webrtc_pc.getSenders().forEach(s => s.track.stop());
|
||||
window.webrtc_pc.close();
|
||||
window.webrtc_pc = null;
|
||||
}
|
||||
window.webrtc_started = false;
|
||||
</script>
|
||||
"""
|
||||
st.components.v1.html(cleanup_js, height=0)
|
||||
|
||||
if start_stream:
|
||||
# Always send stop to ensure backend is in a clean state, regardless of current status
|
||||
r = requests.post(f"{BACKEND_URL}/stop_audio").json()
|
||||
if r['was_running']:
|
||||
st.success("Stream Stopped!")
|
||||
|
||||
# Small pause lets backend fully release audio devices before re-init
|
||||
time.sleep(1)
|
||||
# Prepare config using the model (do NOT send qos_config, only relevant fields)
|
||||
q = QUALITY_MAP[quality]
|
||||
config = auracast_config.AuracastConfigGroup(
|
||||
auracast_sampling_rate_hz=q['rate'],
|
||||
octets_per_frame=q['octets'],
|
||||
transport=TRANSPORT1, # transport for raspberry pi gpio header
|
||||
bigs = [
|
||||
auracast_config.AuracastBigConfig(
|
||||
name=stream_name,
|
||||
program_info=program_info,
|
||||
language=language,
|
||||
audio_source=(
|
||||
f"device:{input_device}" if audio_mode == "USB/Network" else (
|
||||
"webrtc" if audio_mode == "Webapp" else "network"
|
||||
)
|
||||
),
|
||||
input_format=(f"int16le,{q['rate']},1" if audio_mode == "USB/Network" else "auto"),
|
||||
iso_que_len=1,
|
||||
sampling_frequency=q['rate'],
|
||||
octets_per_frame=q['octets'],
|
||||
),
|
||||
]
|
||||
)
|
||||
|
||||
try:
|
||||
r = requests.post(f"{BACKEND_URL}/init", json=config.model_dump())
|
||||
if r.status_code == 200:
|
||||
st.success("Stream Started!")
|
||||
else:
|
||||
st.error(f"Failed to initialize: {r.text}")
|
||||
except Exception as e:
|
||||
st.error(f"Error: {e}")
|
||||
|
||||
# Render / maintain WebRTC component
|
||||
if audio_mode == "Webapp" and (start_stream or st.session_state.get('stream_started')):
|
||||
st.markdown("Starting microphone; allow access if prompted and speak.")
|
||||
component = f"""
|
||||
<script>
|
||||
(async () => {{
|
||||
// Clean up any previous WebRTC connection before starting a new one
|
||||
if (window.webrtc_pc) {{
|
||||
window.webrtc_pc.getSenders().forEach(s => s.track.stop());
|
||||
window.webrtc_pc.close();
|
||||
}}
|
||||
const GAIN_VALUE = {mic_gain};
|
||||
const pc = new RTCPeerConnection(); // No STUN needed for localhost
|
||||
window.webrtc_pc = pc;
|
||||
window.webrtc_started = true;
|
||||
const micStream = await navigator.mediaDevices.getUserMedia({{audio:true}});
|
||||
// Create Web Audio gain processing
|
||||
const audioCtx = new (window.AudioContext || window.webkitAudioContext)();
|
||||
const source = audioCtx.createMediaStreamSource(micStream);
|
||||
const gainNode = audioCtx.createGain();
|
||||
gainNode.gain.value = GAIN_VALUE;
|
||||
// Expose for later adjustments
|
||||
window.gainNode = gainNode;
|
||||
const dest = audioCtx.createMediaStreamDestination();
|
||||
source.connect(gainNode).connect(dest);
|
||||
// Add processed tracks to WebRTC
|
||||
dest.stream.getTracks().forEach(t => pc.addTrack(t, dest.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=0)
|
||||
st.session_state['stream_started'] = True
|
||||
#else:
|
||||
# st.header("Advertised Streams (Cloud Announcements)")
|
||||
# st.info("This feature requires backend support to list advertised streams.")
|
||||
# Placeholder for future implementation
|
||||
# Example: r = requests.get(f"{BACKEND_URL}/advertised_streams")
|
||||
# if r.status_code == 200:
|
||||
# streams = r.json()
|
||||
# for s in streams:
|
||||
# st.write(s)
|
||||
# else:
|
||||
# st.error("Could not fetch advertised streams.")
|
||||
|
||||
log.basicConfig(
|
||||
level=os.environ.get('LOG_LEVEL', log.DEBUG),
|
||||
format='%(module)s.py:%(lineno)d %(levelname)s: %(message)s'
|
||||
)
|
||||
421
src/auracast/server/multicast_server.py
Normal file
421
src/auracast/server/multicast_server.py
Normal file
@@ -0,0 +1,421 @@
|
||||
import glob
|
||||
import os
|
||||
import logging as log
|
||||
import uuid
|
||||
import json
|
||||
import sys
|
||||
from datetime import datetime
|
||||
import asyncio
|
||||
import numpy as np
|
||||
from pydantic import BaseModel
|
||||
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
|
||||
import sounddevice as sd # type: ignore
|
||||
from typing import Set, List, Dict, Any
|
||||
import traceback
|
||||
|
||||
|
||||
PTIME = 40 # TODO: seems to have no effect at all
|
||||
pcs: Set[RTCPeerConnection] = set() # keep refs so they don’t GC early
|
||||
AUDIO_INPUT_DEVICES_CACHE: List[Dict[str, Any]] = []
|
||||
|
||||
class Offer(BaseModel):
|
||||
sdp: str
|
||||
type: str
|
||||
|
||||
def get_device_index_by_name(name: str):
|
||||
"""Return the device index for a given device name, or None if not found."""
|
||||
for d in AUDIO_INPUT_DEVICES_CACHE:
|
||||
if d["name"] == name:
|
||||
return d["id"]
|
||||
return None
|
||||
|
||||
|
||||
# Path to persist stream settings
|
||||
STREAM_SETTINGS_FILE = os.path.join(os.path.dirname(__file__), 'stream_settings.json')
|
||||
|
||||
def load_stream_settings() -> dict:
|
||||
"""Load persisted stream settings if available."""
|
||||
if os.path.exists(STREAM_SETTINGS_FILE):
|
||||
try:
|
||||
with open(STREAM_SETTINGS_FILE, 'r', encoding='utf-8') as f:
|
||||
return json.load(f)
|
||||
except Exception:
|
||||
return {}
|
||||
return {}
|
||||
|
||||
def save_stream_settings(settings: dict):
|
||||
"""Save stream settings to disk."""
|
||||
try:
|
||||
with open(STREAM_SETTINGS_FILE, 'w', encoding='utf-8') as f:
|
||||
json.dump(settings, f, indent=2)
|
||||
except Exception as e:
|
||||
log.error('Unable to persist stream settings: %s', e)
|
||||
|
||||
|
||||
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()
|
||||
|
||||
# Create multicast controller
|
||||
multicaster1: multicast_control.Multicaster | None = None
|
||||
multicaster2: multicast_control.Multicaster | None = None
|
||||
|
||||
@app.post("/init")
|
||||
async def initialize(conf: auracast_config.AuracastConfigGroup):
|
||||
"""Initializes the primary broadcaster (multicaster1)."""
|
||||
global global_config_group
|
||||
global multicaster1
|
||||
try:
|
||||
if conf.transport == 'auto':
|
||||
serial_devices = glob.glob('/dev/serial/by-id/*')
|
||||
log.info('Found serial devices: %s', serial_devices)
|
||||
for device in serial_devices:
|
||||
if 'usb-ZEPHYR_Zephyr_HCI_UART_sample' in device:
|
||||
log.info('Using: %s', device)
|
||||
conf.transport = f'serial:{device},115200,rtscts'
|
||||
break
|
||||
if conf.transport == 'auto':
|
||||
raise HTTPException(status_code=500, detail='No suitable transport found.')
|
||||
# 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:'):
|
||||
audio_mode_persist = 'USB'
|
||||
input_device_name = first_source.split(':', 1)[1] if ':' in first_source else None
|
||||
# Map device name to current index for use with sounddevice
|
||||
device_index = get_device_index_by_name(input_device_name) if input_device_name else None
|
||||
# Patch config to use index for sounddevice (but persist name)
|
||||
if device_index is not None:
|
||||
for big in conf.bigs:
|
||||
if big.audio_source.startswith('device:'):
|
||||
big.audio_source = f'device:{device_index}'
|
||||
else:
|
||||
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.")
|
||||
elif first_source == 'webrtc':
|
||||
audio_mode_persist = 'Webapp'
|
||||
input_device_name = None
|
||||
elif first_source.startswith('file:'):
|
||||
audio_mode_persist = 'Demo'
|
||||
input_device_name = None
|
||||
else:
|
||||
audio_mode_persist = 'Network'
|
||||
input_device_name = None
|
||||
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],
|
||||
'timestamp': datetime.utcnow().isoformat()
|
||||
})
|
||||
global_config_group = conf
|
||||
if multicaster1 is not None:
|
||||
try:
|
||||
await multicaster1.shutdown()
|
||||
except Exception:
|
||||
log.warning("Failed to shutdown previous multicaster", exc_info=True)
|
||||
log.info('Initializing multicaster1 with config:\n %s', conf.model_dump_json(indent=2))
|
||||
multicaster1 = multicast_control.Multicaster(conf, conf.bigs)
|
||||
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()
|
||||
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
|
||||
try:
|
||||
if conf.transport == 'auto':
|
||||
serial_devices = glob.glob('/dev/serial/by-id/*')
|
||||
log.info('Found serial devices: %s', serial_devices)
|
||||
for device in serial_devices:
|
||||
if 'usb-ZEPHYR_Zephyr_HCI_UART_sample' in device:
|
||||
log.info('Using: %s', device)
|
||||
conf.transport = f'serial:{device},115200,rtscts'
|
||||
break
|
||||
if conf.transport == 'auto':
|
||||
raise HTTPException(status_code=500, detail='No suitable transport found.')
|
||||
# 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)
|
||||
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()
|
||||
except Exception as e:
|
||||
log.error("Exception in /init2: %s", traceback.format_exc())
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
|
||||
@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')
|
||||
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()
|
||||
return {"status": "audio_sent"}
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
|
||||
@app.post("/stop_audio")
|
||||
async def stop_audio():
|
||||
"""Stops streaming on both multicaster1 and multicaster2."""
|
||||
try:
|
||||
# First close any active WebRTC peer connections so their track loops finish cleanly
|
||||
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
|
||||
running = False
|
||||
if multicaster1 is not None:
|
||||
await multicaster1.stop_streaming()
|
||||
await multicaster1.reset() # Fully reset controller and advertising
|
||||
running = True
|
||||
if multicaster2 is not None:
|
||||
await multicaster2.stop_streaming()
|
||||
await multicaster2.reset() # Fully reset controller and advertising
|
||||
running = True
|
||||
|
||||
return {"status": "stopped", "was_running": running}
|
||||
except Exception as e:
|
||||
log.error("Exception in /stop_audio: %s", traceback.format_exc())
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
|
||||
@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,
|
||||
}
|
||||
status.update(load_stream_settings())
|
||||
return status
|
||||
|
||||
|
||||
async def scan_audio_devices():
|
||||
"""Scans for available audio devices and updates the cache."""
|
||||
global AUDIO_INPUT_DEVICES_CACHE
|
||||
log.info("Scanning for audio input devices...")
|
||||
try:
|
||||
if sys.platform == 'linux':
|
||||
log.info("Re-initializing sounddevice to scan for new devices")
|
||||
sd._terminate()
|
||||
sd._initialize()
|
||||
|
||||
devs = sd.query_devices()
|
||||
inputs = [
|
||||
dict(d, id=idx)
|
||||
for idx, d in enumerate(devs)
|
||||
if d.get("max_input_channels", 0) > 0
|
||||
]
|
||||
log.info('Found %d audio input devices: %s', len(inputs), inputs)
|
||||
AUDIO_INPUT_DEVICES_CACHE = inputs
|
||||
except Exception:
|
||||
log.error("Exception while scanning audio devices:", exc_info=True)
|
||||
# Do not clear cache on error, keep the last known good list
|
||||
|
||||
|
||||
@app.on_event("startup")
|
||||
async def startup_event():
|
||||
"""Pre-scans audio devices on startup."""
|
||||
await scan_audio_devices()
|
||||
|
||||
|
||||
@app.get("/audio_inputs")
|
||||
async def list_audio_inputs():
|
||||
"""Return available hardware audio input devices from cache (by name, for selection)."""
|
||||
# Only expose name and id for frontend
|
||||
return {"inputs": AUDIO_INPUT_DEVICES_CACHE}
|
||||
|
||||
|
||||
@app.post("/refresh_audio_inputs")
|
||||
async def refresh_audio_inputs():
|
||||
"""Triggers a re-scan of audio devices."""
|
||||
await scan_audio_devices()
|
||||
return {"status": "ok", "inputs": AUDIO_INPUT_DEVICES_CACHE}
|
||||
|
||||
|
||||
@app.post("/offer")
|
||||
async def offer(offer: Offer):
|
||||
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()
|
||||
|
||||
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)
|
||||
|
||||
# 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)
|
||||
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}")
|
||||
|
||||
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}")
|
||||
|
||||
|
||||
frame_array = frame.to_ndarray()
|
||||
|
||||
# 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
|
||||
|
||||
# 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)
|
||||
|
||||
# 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
|
||||
|
||||
# --- 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 and releases all audio/Bluetooth resources."""
|
||||
try:
|
||||
await multicaster1.shutdown()
|
||||
return {"status": "stopped"}
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
import os
|
||||
os.chdir(os.path.dirname(__file__))
|
||||
import uvicorn
|
||||
log.basicConfig( # for debug log level export LOG_LEVEL=DEBUG
|
||||
level=os.environ.get('LOG_LEVEL', log.INFO),
|
||||
format='%(module)s.py:%(lineno)d %(levelname)s: %(message)s'
|
||||
)
|
||||
# Bind to localhost only for security: prevents network access, only frontend on same machine can connect
|
||||
uvicorn.run(app, host="127.0.0.1", port=5000)
|
||||
89
src/auracast/server/provision_domain_hostname.sh
Normal file
89
src/auracast/server/provision_domain_hostname.sh
Normal file
@@ -0,0 +1,89 @@
|
||||
#!/bin/bash
|
||||
# change_domain_hostname.sh
|
||||
# Safely change the system hostname and Avahi mDNS domain name, update /etc/hosts, restart Avahi,
|
||||
# and generate a per-device certificate signed by the CA.
|
||||
# Usage: sudo ./change_domain_hostname.sh <new_hostname> <new_domain> [--force]
|
||||
|
||||
set -e
|
||||
|
||||
if [ "$EUID" -ne 0 ]; then
|
||||
echo "Please run as root."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [ $# -lt 2 ]; then
|
||||
echo "Usage: sudo $0 <new_hostname> <new_domain> [--force]"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
NEW_HOSTNAME="$1"
|
||||
NEW_DOMAIN="$2"
|
||||
FORCE=0
|
||||
if [ "$3" == "--force" ]; then
|
||||
FORCE=1
|
||||
fi
|
||||
|
||||
# Validate hostname: single label, no dots
|
||||
if [[ "$NEW_HOSTNAME" == *.* ]]; then
|
||||
echo "ERROR: Hostname must not contain dots."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Set system hostname
|
||||
hostnamectl set-hostname "$NEW_HOSTNAME"
|
||||
echo "/etc/hostname set to $NEW_HOSTNAME."
|
||||
|
||||
# Update /etc/hosts
|
||||
if grep -q '^127.0.1.1' /etc/hosts; then
|
||||
sed -i "s/^127.0.1.1.*/127.0.1.1 $NEW_HOSTNAME/" /etc/hosts
|
||||
else
|
||||
echo "127.0.1.1 $NEW_HOSTNAME" >> /etc/hosts
|
||||
fi
|
||||
echo "/etc/hosts updated."
|
||||
|
||||
# Set Avahi domain name
|
||||
AVAHI_CONF="/etc/avahi/avahi-daemon.conf"
|
||||
sed -i "/^\[server\]/,/^\s*\[/{s/^\s*domain-name\s*=.*/domain-name=$NEW_DOMAIN/}" "$AVAHI_CONF"
|
||||
echo "Set Avahi domain name to $NEW_DOMAIN."
|
||||
|
||||
# Restart Avahi
|
||||
echo "Restarting avahi-daemon..."
|
||||
systemctl restart avahi-daemon
|
||||
|
||||
echo "Done. Hostname: $NEW_HOSTNAME, Avahi domain: $NEW_DOMAIN"
|
||||
|
||||
# --- Per-device certificate logic ---
|
||||
CA_DIR="$(dirname "$0")/certs/ca"
|
||||
PER_DEVICE_DIR="$(dirname "$0")/certs/per_device/$NEW_HOSTNAME.$NEW_DOMAIN"
|
||||
mkdir -p "$PER_DEVICE_DIR"
|
||||
CA_CERT="$CA_DIR/ca_cert.pem"
|
||||
CA_KEY="$CA_DIR/ca_key.pem"
|
||||
DEVICE_CERT="$PER_DEVICE_DIR/device_cert.pem"
|
||||
DEVICE_KEY="$PER_DEVICE_DIR/device_key.pem"
|
||||
DEVICE_CSR="$PER_DEVICE_DIR/device.csr"
|
||||
SAN_CNF="$PER_DEVICE_DIR/san.cnf"
|
||||
|
||||
if [ -f "$DEVICE_CERT" ] && [ $FORCE -eq 0 ]; then
|
||||
echo "Per-device certificate already exists at $DEVICE_CERT. Use --force to regenerate."
|
||||
else
|
||||
echo "Generating per-device key/cert for $NEW_HOSTNAME.$NEW_DOMAIN..."
|
||||
openssl genrsa -out "$DEVICE_KEY" 4096
|
||||
cat > "$SAN_CNF" <<EOF
|
||||
[req]
|
||||
distinguished_name = req_distinguished_name
|
||||
req_extensions = v3_req
|
||||
prompt = no
|
||||
|
||||
[req_distinguished_name]
|
||||
CN = $NEW_HOSTNAME.$NEW_DOMAIN
|
||||
|
||||
[v3_req]
|
||||
subjectAltName = @alt_names
|
||||
|
||||
[alt_names]
|
||||
DNS.1 = $NEW_HOSTNAME.$NEW_DOMAIN
|
||||
EOF
|
||||
openssl req -new -key "$DEVICE_KEY" -out "$DEVICE_CSR" -config "$SAN_CNF"
|
||||
openssl x509 -req -in "$DEVICE_CSR" -CA "$CA_CERT" -CAkey "$CA_KEY" -CAcreateserial -out "$DEVICE_CERT" -days 7300 -extensions v3_req -extfile "$SAN_CNF"
|
||||
echo "Per-device certificate generated at $DEVICE_CERT."
|
||||
fi
|
||||
2
src/auracast/server/start_frontend_http.sh
Normal file
2
src/auracast/server/start_frontend_http.sh
Normal file
@@ -0,0 +1,2 @@
|
||||
# Start Streamlit HTTP server (port 8500)
|
||||
poetry run streamlit run multicast_frontend.py --server.port 8500 --server.enableCORS false --server.enableXsrfProtection false --server.headless true --browser.gatherUsageStats false
|
||||
37
src/auracast/server/start_frontend_https.sh
Executable file
37
src/auracast/server/start_frontend_https.sh
Executable file
@@ -0,0 +1,37 @@
|
||||
#!/bin/bash
|
||||
# to bind this to 443, root privileges are required. start this like
|
||||
# sudo -E PATH="$PATH" bash ./start_frontend_https.sh
|
||||
# Unified startup script: generates certs if needed, starts HTTPS Streamlit and HTTP->HTTPS redirector
|
||||
|
||||
# Dynamically select per-device cert and key based on hostname and Avahi domain
|
||||
DEVICE_HOSTNAME=$(hostname)
|
||||
AVAHI_CONF="/etc/avahi/avahi-daemon.conf"
|
||||
AVAHI_DOMAIN=$(awk -F= '/^\s*domain-name\s*=/{gsub(/ /, "", $2); print $2}' "$AVAHI_CONF")
|
||||
if [ -z "$AVAHI_DOMAIN" ]; then
|
||||
AVAHI_DOMAIN=local
|
||||
fi
|
||||
CERT_DIR="certs/per_device/${DEVICE_HOSTNAME}.${AVAHI_DOMAIN}"
|
||||
CERT="$CERT_DIR/device_cert.pem"
|
||||
KEY="$CERT_DIR/device_key.pem"
|
||||
CA_CERT="certs/ca/ca_cert.pem"
|
||||
|
||||
if [ ! -f "$CERT" ] || [ ! -f "$KEY" ]; then
|
||||
echo "ERROR: Device certificate or key not found in $CERT_DIR. Run provision_domain_hostname.sh first."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [ ! -f "$CA_CERT" ]; then
|
||||
echo "WARNING: CA certificate not found at $CA_CERT. HTTPS will work, but clients may not be able to import the CA."
|
||||
fi
|
||||
|
||||
echo "CA cert: $CA_CERT"
|
||||
echo "Device cert: $CERT"
|
||||
echo "Device key: $KEY"
|
||||
echo "Using hostname: $DEVICE_HOSTNAME"
|
||||
echo "Using Avahi domain: $AVAHI_DOMAIN"
|
||||
|
||||
# Path to poetry binary
|
||||
POETRY_BIN="/home/caster/.local/bin/poetry"
|
||||
|
||||
# Start Streamlit HTTPS server (port 443)
|
||||
$POETRY_BIN run streamlit run multicast_frontend.py --server.port 443 --server.enableCORS false --server.enableXsrfProtection false --server.headless true --server.sslCertFile "$CERT" --server.sslKeyFile "$KEY" --browser.gatherUsageStats false
|
||||
26
src/auracast/server/start_mdns.sh
Executable file
26
src/auracast/server/start_mdns.sh
Executable file
@@ -0,0 +1,26 @@
|
||||
#!/bin/bash
|
||||
|
||||
# Script to advertise the local device via mDNS for an HTTPS service.
|
||||
# This allows other clients on the network to discover this device
|
||||
# using its mDNS hostname (e.g., your-hostname.local) on the specified port.
|
||||
|
||||
# Update: Advertise HTTPS service on port 443 (default)
|
||||
SERVICE_NAME="Auracast HTTPS Service" # You can customize this name
|
||||
SERVICE_TYPE="_https._tcp" # Standard type for HTTPS services
|
||||
SERVICE_PORT="443" # Port must match your HTTPS server (default 443)
|
||||
|
||||
echo "Starting mDNS advertisement..."
|
||||
echo "Command: avahi-publish-service -v \"$SERVICE_NAME\" \"$SERVICE_TYPE\" \"$SERVICE_PORT\""
|
||||
|
||||
avahi-publish-service -v "$SERVICE_NAME" "$SERVICE_TYPE" "$SERVICE_PORT"
|
||||
EXIT_STATUS=$?
|
||||
|
||||
# This part will be reached if avahi-publish-service exits.
|
||||
if [ $EXIT_STATUS -eq 0 ]; then
|
||||
echo "mDNS advertisement command finished with status 0."
|
||||
echo "This might indicate an issue connecting to the avahi-daemon or a configuration problem."
|
||||
echo "Please check for any messages above from avahi-publish-service itself."
|
||||
else
|
||||
echo "mDNS advertisement command exited with status $EXIT_STATUS."
|
||||
echo "This might be due to an error, or if you pressed Ctrl+C (which typically results in a non-zero status from signal termination)."
|
||||
fi
|
||||
BIN
src/auracast/testdata/wave_particle_5min_de.wav
vendored
Normal file
BIN
src/auracast/testdata/wave_particle_5min_de.wav
vendored
Normal file
Binary file not shown.
BIN
src/auracast/testdata/wave_particle_5min_de_16kHz_mono.wav
vendored
Normal file
BIN
src/auracast/testdata/wave_particle_5min_de_16kHz_mono.wav
vendored
Normal file
Binary file not shown.
BIN
src/auracast/testdata/wave_particle_5min_de_24kHz_mono.wav
vendored
Normal file
BIN
src/auracast/testdata/wave_particle_5min_de_24kHz_mono.wav
vendored
Normal file
Binary file not shown.
BIN
src/auracast/testdata/wave_particle_5min_de_48kHz_mono.wav
vendored
Normal file
BIN
src/auracast/testdata/wave_particle_5min_de_48kHz_mono.wav
vendored
Normal file
Binary file not shown.
BIN
src/auracast/testdata/wave_particle_5min_en.wav
vendored
Normal file
BIN
src/auracast/testdata/wave_particle_5min_en.wav
vendored
Normal file
Binary file not shown.
BIN
src/auracast/testdata/wave_particle_5min_en_16kHz_mono.wav
vendored
Normal file
BIN
src/auracast/testdata/wave_particle_5min_en_16kHz_mono.wav
vendored
Normal file
Binary file not shown.
BIN
src/auracast/testdata/wave_particle_5min_en_24kHz_mono.wav
vendored
Normal file
BIN
src/auracast/testdata/wave_particle_5min_en_24kHz_mono.wav
vendored
Normal file
Binary file not shown.
BIN
src/auracast/testdata/wave_particle_5min_en_48kHz_mono.wav
vendored
Normal file
BIN
src/auracast/testdata/wave_particle_5min_en_48kHz_mono.wav
vendored
Normal file
Binary file not shown.
BIN
src/auracast/testdata/wave_particle_5min_es.wav
vendored
Normal file
BIN
src/auracast/testdata/wave_particle_5min_es.wav
vendored
Normal file
Binary file not shown.
BIN
src/auracast/testdata/wave_particle_5min_es_16kHz_mono.wav
vendored
Normal file
BIN
src/auracast/testdata/wave_particle_5min_es_16kHz_mono.wav
vendored
Normal file
Binary file not shown.
BIN
src/auracast/testdata/wave_particle_5min_es_24kHz_mono.wav
vendored
Normal file
BIN
src/auracast/testdata/wave_particle_5min_es_24kHz_mono.wav
vendored
Normal file
Binary file not shown.
BIN
src/auracast/testdata/wave_particle_5min_es_48kHz_mono.wav
vendored
Normal file
BIN
src/auracast/testdata/wave_particle_5min_es_48kHz_mono.wav
vendored
Normal file
Binary file not shown.
BIN
src/auracast/testdata/wave_particle_5min_fr.wav
vendored
Normal file
BIN
src/auracast/testdata/wave_particle_5min_fr.wav
vendored
Normal file
Binary file not shown.
BIN
src/auracast/testdata/wave_particle_5min_fr_16kHz_mono.wav
vendored
Normal file
BIN
src/auracast/testdata/wave_particle_5min_fr_16kHz_mono.wav
vendored
Normal file
Binary file not shown.
BIN
src/auracast/testdata/wave_particle_5min_fr_24kHz_mono.wav
vendored
Normal file
BIN
src/auracast/testdata/wave_particle_5min_fr_24kHz_mono.wav
vendored
Normal file
Binary file not shown.
BIN
src/auracast/testdata/wave_particle_5min_fr_48kHz_mono.wav
vendored
Normal file
BIN
src/auracast/testdata/wave_particle_5min_fr_48kHz_mono.wav
vendored
Normal file
Binary file not shown.
BIN
src/auracast/testdata/wave_particle_5min_it.wav
vendored
Normal file
BIN
src/auracast/testdata/wave_particle_5min_it.wav
vendored
Normal file
Binary file not shown.
BIN
src/auracast/testdata/wave_particle_5min_it_16kHz_mono.wav
vendored
Normal file
BIN
src/auracast/testdata/wave_particle_5min_it_16kHz_mono.wav
vendored
Normal file
Binary file not shown.
BIN
src/auracast/testdata/wave_particle_5min_it_24kHz_mono.wav
vendored
Normal file
BIN
src/auracast/testdata/wave_particle_5min_it_24kHz_mono.wav
vendored
Normal file
Binary file not shown.
BIN
src/auracast/testdata/wave_particle_5min_it_48kHz_mono.wav
vendored
Normal file
BIN
src/auracast/testdata/wave_particle_5min_it_48kHz_mono.wav
vendored
Normal file
Binary file not shown.
BIN
src/auracast/testdata/wave_particle_5min_pl_16kHz_mono.wav
vendored
Normal file
BIN
src/auracast/testdata/wave_particle_5min_pl_16kHz_mono.wav
vendored
Normal file
Binary file not shown.
BIN
src/auracast/testdata/wave_particle_5min_pl_24kHz_mono.wav
vendored
Normal file
BIN
src/auracast/testdata/wave_particle_5min_pl_24kHz_mono.wav
vendored
Normal file
Binary file not shown.
BIN
src/auracast/testdata/wave_particle_5min_pl_48kHz_mono.wav
vendored
Normal file
BIN
src/auracast/testdata/wave_particle_5min_pl_48kHz_mono.wav
vendored
Normal file
Binary file not shown.
67
src/auracast/utils/network_audio_receiver.py
Normal file
67
src/auracast/utils/network_audio_receiver.py
Normal file
@@ -0,0 +1,67 @@
|
||||
import asyncio
|
||||
import socket
|
||||
import logging
|
||||
import numpy as np
|
||||
from typing import AsyncGenerator
|
||||
|
||||
class NetworkAudioReceiverUncoded:
|
||||
"""
|
||||
Receives PCM audio over UDP and provides an async generator interface for uncoded PCM frames.
|
||||
Combines network receiving and input logic for use with Auracast streamer.
|
||||
"""
|
||||
def __init__(self, port: int = 50007, samplerate: int = 16000, channels: int = 1, chunk_size: int = 1024):
|
||||
self.port = port
|
||||
self.samplerate = samplerate
|
||||
self.channels = channels
|
||||
self.chunk_size = chunk_size
|
||||
self.sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
|
||||
self.sock.bind(('0.0.0.0', self.port))
|
||||
self.sock.setblocking(False)
|
||||
self._running = False
|
||||
# Reduce queue size for lower latency (less buffering)
|
||||
self._queue = asyncio.Queue(maxsize=2) # Was 20
|
||||
|
||||
async def receive(self):
|
||||
self._running = True
|
||||
logging.info(f"NetworkAudioReceiver listening on UDP port {self.port}")
|
||||
try:
|
||||
while self._running:
|
||||
try:
|
||||
data, _ = await asyncio.get_event_loop().sock_recvfrom(self.sock, self.chunk_size * 2)
|
||||
await self._queue.put(data)
|
||||
except Exception:
|
||||
await asyncio.sleep(0.01)
|
||||
finally:
|
||||
self.sock.close()
|
||||
logging.info("NetworkAudioReceiver stopped.")
|
||||
|
||||
def stop(self):
|
||||
self._running = False
|
||||
|
||||
async def open(self):
|
||||
# Dummy PCM format object
|
||||
class PCMFormat:
|
||||
channels = self.channels
|
||||
sample_type = 'int16'
|
||||
sample_rate = self.samplerate
|
||||
return PCMFormat()
|
||||
|
||||
def rewind(self):
|
||||
pass # Not supported for live network input
|
||||
|
||||
async def frames(self, samples_per_frame: int) -> AsyncGenerator[np.ndarray, None]:
|
||||
bytes_per_frame = samples_per_frame * 2 * self.channels # 2 bytes for int16
|
||||
buf = bytearray()
|
||||
while True:
|
||||
data = await self._queue.get()
|
||||
# Optional: log queue size for latency debugging
|
||||
# logging.debug(f'NetworkAudioReceiver queue size: {self._queue.qsize()}')
|
||||
if data is None:
|
||||
break
|
||||
buf.extend(data)
|
||||
while len(buf) >= bytes_per_frame:
|
||||
frame = np.frombuffer(buf[:bytes_per_frame], dtype=np.int16).reshape(-1, self.channels)
|
||||
# Optional: log when a frame is yielded
|
||||
# logging.debug(f'Yielding frame of shape {frame.shape}')
|
||||
yield frame
|
||||
buf = buf[bytes_per_frame:]
|
||||
141
src/auracast/utils/sounddevice_utils.py
Normal file
141
src/auracast/utils/sounddevice_utils.py
Normal file
@@ -0,0 +1,141 @@
|
||||
import sounddevice as sd
|
||||
import os, re, json, subprocess
|
||||
|
||||
def devices_by_backend(backend_name: str):
|
||||
hostapis = sd.query_hostapis() # list of host APIs
|
||||
# find the host API index by (case-insensitive) name match
|
||||
try:
|
||||
hostapi_idx = next(
|
||||
i for i, ha in enumerate(hostapis)
|
||||
if backend_name.lower() in ha['name'].lower()
|
||||
)
|
||||
except StopIteration:
|
||||
raise ValueError(f"No host API matching {backend_name!r}. "
|
||||
f"Available: {[ha['name'] for ha in hostapis]}")
|
||||
# return (global_index, device_dict) pairs filtered by that host API
|
||||
return [(i, d) for i, d in enumerate(sd.query_devices())
|
||||
if d['hostapi'] == hostapi_idx]
|
||||
|
||||
def _pa_like_hostapi_index():
|
||||
for i, ha in enumerate(sd.query_hostapis()):
|
||||
if any(k in ha["name"] for k in ("PipeWire", "PulseAudio")):
|
||||
return i
|
||||
raise RuntimeError("PipeWire/PulseAudio host API not present in PortAudio.")
|
||||
|
||||
def _pw_dump():
|
||||
return json.loads(subprocess.check_output(["pw-dump"]))
|
||||
|
||||
def _sd_refresh():
|
||||
"""Force PortAudio to re-enumerate devices on next query.
|
||||
|
||||
sounddevice/PortAudio keeps a static device list after initialization.
|
||||
Terminating here ensures that subsequent sd.query_* calls re-initialize
|
||||
and see newly added devices (e.g., AES67 nodes created after start).
|
||||
"""
|
||||
sd._terminate() # private API, acceptable for runtime refresh
|
||||
sd._initialize()
|
||||
|
||||
def _sd_matches_from_names(pa_idx, names):
|
||||
names_l = {n.lower() for n in names if n}
|
||||
out = []
|
||||
for i, d in enumerate(sd.query_devices()):
|
||||
if d["hostapi"] != pa_idx or d["max_input_channels"] <= 0:
|
||||
continue
|
||||
dn = d["name"].lower()
|
||||
if any(n in dn for n in names_l):
|
||||
out.append((i, d))
|
||||
return out
|
||||
|
||||
def list_usb_pw_inputs():
|
||||
"""
|
||||
Return [(device_index, device_dict), ...] for PipeWire **input** nodes
|
||||
backed by **USB** devices (excludes monitor sources).
|
||||
"""
|
||||
# Refresh PortAudio so we see newly added nodes before mapping
|
||||
_sd_refresh()
|
||||
pa_idx = _pa_like_hostapi_index()
|
||||
pw = _pw_dump()
|
||||
|
||||
# Map device.id -> device.bus ("usb"/"pci"/"platform"/"network"/...)
|
||||
device_bus = {}
|
||||
for obj in pw:
|
||||
if obj.get("type") == "PipeWire:Interface:Device":
|
||||
props = (obj.get("info") or {}).get("props") or {}
|
||||
device_bus[obj["id"]] = (props.get("device.bus") or "").lower()
|
||||
|
||||
# Collect names/descriptions of USB input nodes
|
||||
usb_input_names = set()
|
||||
for obj in pw:
|
||||
if obj.get("type") != "PipeWire:Interface:Node":
|
||||
continue
|
||||
props = (obj.get("info") or {}).get("props") or {}
|
||||
media = (props.get("media.class") or "").lower()
|
||||
if "source" not in media and "stream/input" not in media:
|
||||
continue
|
||||
# skip monitor sources ("Monitor of ..." or *.monitor)
|
||||
nname = (props.get("node.name") or "").lower()
|
||||
ndesc = (props.get("node.description") or "").lower()
|
||||
if ".monitor" in nname or "monitor" in ndesc:
|
||||
continue
|
||||
bus = (props.get("device.bus") or device_bus.get(props.get("device.id")) or "").lower()
|
||||
if bus == "usb":
|
||||
usb_input_names.add(props.get("node.description") or props.get("node.name"))
|
||||
|
||||
# Map to sounddevice devices on PipeWire host API
|
||||
return _sd_matches_from_names(pa_idx, usb_input_names)
|
||||
|
||||
def list_network_pw_inputs():
|
||||
"""
|
||||
Return [(device_index, device_dict), ...] for PipeWire **input** nodes that
|
||||
look like network/AES67/RTP sources (excludes monitor sources).
|
||||
"""
|
||||
# Refresh PortAudio so we see newly added nodes before mapping
|
||||
_sd_refresh()
|
||||
pa_idx = _pa_like_hostapi_index()
|
||||
pw = _pw_dump()
|
||||
|
||||
network_input_names = set()
|
||||
for obj in pw:
|
||||
if obj.get("type") != "PipeWire:Interface:Node":
|
||||
continue
|
||||
props = (obj.get("info") or {}).get("props") or {}
|
||||
media = (props.get("media.class") or "").lower()
|
||||
if "source" not in media and "stream/input" not in media:
|
||||
continue
|
||||
nname = (props.get("node.name") or "")
|
||||
ndesc = (props.get("node.description") or "")
|
||||
# skip monitor sources
|
||||
if ".monitor" in nname.lower() or "monitor" in ndesc.lower():
|
||||
continue
|
||||
|
||||
# Heuristics for network/AES67/RTP
|
||||
text = (nname + " " + ndesc).lower()
|
||||
media_name = (props.get("media.name") or "").lower()
|
||||
node_group = (props.get("node.group") or "").lower()
|
||||
# Presence flags/keys that strongly indicate network RTP/AES67 sources
|
||||
node_network_flag = bool(props.get("node.network"))
|
||||
has_rtp_keys = any(k in props for k in (
|
||||
"rtp.session", "rtp.source.ip", "rtp.source.port", "rtp.fmtp", "rtp.rate"
|
||||
))
|
||||
has_sess_keys = any(k in props for k in (
|
||||
"sess.name", "sess.media", "sess.latency.msec"
|
||||
))
|
||||
is_network = (
|
||||
(props.get("device.bus") or "").lower() == "network" or
|
||||
node_network_flag or
|
||||
"rtp" in media_name or
|
||||
any(k in text for k in ("rtp", "sap", "aes67", "network", "raop", "airplay")) or
|
||||
has_rtp_keys or
|
||||
has_sess_keys or
|
||||
("pipewire.ptp" in node_group)
|
||||
)
|
||||
if is_network:
|
||||
network_input_names.add(ndesc or nname)
|
||||
|
||||
return _sd_matches_from_names(pa_idx, network_input_names)
|
||||
|
||||
# Example usage:
|
||||
# for i, d in list_usb_pw_inputs():
|
||||
# print(f"USB IN {i}: {d['name']} in={d['max_input_channels']}")
|
||||
# for i, d in list_network_pw_inputs():
|
||||
# print(f"NET IN {i}: {d['name']} in={d['max_input_channels']}")
|
||||
42
src/auracast/utils/webrtc_audio_input.py
Normal file
42
src/auracast/utils/webrtc_audio_input.py
Normal file
@@ -0,0 +1,42 @@
|
||||
import asyncio
|
||||
import numpy as np
|
||||
import logging
|
||||
|
||||
class WebRTCAudioInput:
|
||||
"""
|
||||
Buffer PCM samples from WebRTC and provide an async generator interface for chunked frames.
|
||||
"""
|
||||
def __init__(self):
|
||||
self.buffer = np.array([], dtype=np.int16)
|
||||
self.lock = asyncio.Lock()
|
||||
self.data_available = asyncio.Event()
|
||||
self.closed = False
|
||||
|
||||
async def frames(self, frame_size: int):
|
||||
"""
|
||||
Async generator yielding exactly frame_size samples as numpy arrays.
|
||||
"""
|
||||
while not self.closed:
|
||||
async with self.lock:
|
||||
if len(self.buffer) >= frame_size:
|
||||
chunk = self.buffer[:frame_size]
|
||||
self.buffer = self.buffer[frame_size:]
|
||||
logging.debug(f"WebRTCAudioInput: Yielding {frame_size} samples, buffer now has {len(self.buffer)} samples remaining.")
|
||||
yield chunk
|
||||
else:
|
||||
self.data_available.clear()
|
||||
await self.data_available.wait()
|
||||
|
||||
async def put_samples(self, samples: np.ndarray):
|
||||
"""
|
||||
Add new PCM samples (1D np.int16 array, mono) to the buffer.
|
||||
"""
|
||||
async with self.lock:
|
||||
self.buffer = np.concatenate([self.buffer, samples])
|
||||
logging.debug(f"WebRTCAudioInput: Added {len(samples)} samples, buffer now has {len(self.buffer)} samples.")
|
||||
self.data_available.set()
|
||||
|
||||
async def close(self):
|
||||
"""Mark the input closed so frames() stops yielding."""
|
||||
self.closed = True
|
||||
self.data_available.set()
|
||||
Reference in New Issue
Block a user