diff --git a/src/auracast/server/multicast_frontend.py b/src/auracast/server/multicast_frontend.py index 0554d26..21482fe 100644 --- a/src/auracast/server/multicast_frontend.py +++ b/src/auracast/server/multicast_frontend.py @@ -111,6 +111,20 @@ is_streaming = bool(saved_settings.get("is_streaming", False)) secondary_status = saved_settings.get("secondary") or {} secondary_is_streaming = bool(saved_settings.get("secondary_is_streaming", secondary_status.get("is_streaming", False))) +def validate_unique_input_devices(radio1_streams, radio2_streams): + """Validate that input devices are unique across all streams within each radio.""" + # Check Radio 1 devices + r1_devices = [s['input_device'] for s in radio1_streams if s.get('input_device')] + if len(r1_devices) != len(set(r1_devices)): + return False, "Duplicate input devices detected in Radio 1 streams. Each stream must use a unique input device." + + # Check Radio 2 devices + r2_devices = [s['input_device'] for s in radio2_streams if s.get('input_device')] + if len(r2_devices) != len(set(r2_devices)): + return False, "Duplicate input devices detected in Radio 2 streams. Each stream must use a unique input device." + + return True, "" + st.title("Auracast Audio Mode Control") def render_stream_controls(status_streaming: bool, start_label: str, stop_label: str, mode_label: str): @@ -174,6 +188,15 @@ if audio_mode == "Analog": else: running_mode = backend_mode_mapped if (is_streaming and backend_mode_mapped) else audio_mode +# Start/Stop buttons and status (moved to top) +if audio_mode != "Demo": + start_stream, stop_stream = render_stream_controls(is_streaming, "Start Auracast", "Stop Auracast", running_mode) +else: + start_stream, stop_stream = False, False + +# Placeholder for validation errors (will be filled in later) +validation_error_placeholder = st.empty() + is_started = False is_stopped = False @@ -368,6 +391,11 @@ else: # --- Radio 1 controls --- st.subheader("Radio 1") + # Use analog-specific defaults (not from saved settings which may have Dante values) + default_name = "Analog_Radio_1" + default_program_info = "Analog Radio Broadcast" + default_lang = "deu" + quality_options = list(QUALITY_MAP.keys()) default_quality = "Medium (24kHz)" if "Medium (24kHz)" in quality_options else quality_options[0] quality1 = st.selectbox( @@ -492,6 +520,11 @@ else: ) if radio2_enabled: + # Use analog-specific defaults for Radio 2 + default_name_r2 = "Analog_Radio_2" + default_program_info_r2 = "Analog Radio Broadcast" + default_lang_r2 = "deu" + quality2 = st.selectbox( "Stream Quality (Radio 2)", quality_options, @@ -537,18 +570,18 @@ else: with col_r2_name: stream_name2 = st.text_input( "Channel Name (Radio 2)", - value=f"{default_name}_2", + value=default_name_r2, help="Name for the second analog radio (Radio 2)." ) with col_r2_lang: language2 = st.text_input( "Language (ISO 639-3) (Radio 2)", - value=default_lang, + value=default_lang_r2, help="Language code for Radio 2." ) program_info2 = st.text_input( "Program Info (Radio 2)", - value=default_program_info, + value=default_program_info_r2, help="Program information for Radio 2." ) @@ -600,7 +633,437 @@ else: 'qos_preset': qos_preset1, } - else: + if audio_mode == "Network - Dante": + # --- Network - Dante mode with Radio 1 and Radio 2 categories --- + + # Define stream configuration options + dante_stream_options = { + "1 × 48kHz": {"streams": 1, "quality": "High (48kHz)"}, + "2 × 24kHz": {"streams": 2, "quality": "Medium (24kHz)"}, + "3 × 16kHz": {"streams": 3, "quality": "Fair (16kHz)"} + } + + # Get available Dante devices + if not is_streaming: + try: + resp = requests.get(f"{BACKEND_URL}/audio_inputs_dante") + device_list = resp.json().get('inputs', []) + except Exception as e: + st.error(f"Failed to fetch Dante devices: {e}") + device_list = [] + + 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] + else: + input_options = [] + option_name_map = {} + device_names = [] + + # --- Radio 1 Section --- + st.subheader("Radio 1") + + # Stream count dropdown for Radio 1 + r1_stream_options = list(dante_stream_options.keys()) + saved_r1_config = saved_settings.get('dante_radio1', {}) + saved_r1_streams = saved_r1_config.get('stream_config', '1x48') + default_r1_idx = r1_stream_options.index(saved_r1_streams) if saved_r1_streams in r1_stream_options else 0 + + r1_stream_config = st.selectbox( + "Stream Configuration (Radio 1)", + r1_stream_options, + index=default_r1_idx, + help="Select the number and quality of streams for Radio 1" + ) + + r1_num_streams = dante_stream_options[r1_stream_config]["streams"] + r1_quality = dante_stream_options[r1_stream_config]["quality"] + + # Stream quality (moved directly under stream configuration) + r1_max_quality = r1_quality + r1_available_qualities = [] + for quality in ["High (48kHz)", "Good (32kHz)", "Medium (24kHz)", "Fair (16kHz)"]: + # Check if this quality is equal to or lower than the max + if (r1_max_quality == "High (48kHz)" or + (r1_max_quality == "Medium (24kHz)" and quality in ["Medium (24kHz)", "Fair (16kHz)"]) or + (r1_max_quality == "Fair (16kHz)" and quality == "Fair (16kHz)")): + r1_available_qualities.append(quality) + + saved_r1_quality = saved_r1_config.get('radio_quality', r1_max_quality) + if saved_r1_quality not in r1_available_qualities: + saved_r1_quality = r1_max_quality + + r1_radio_quality = st.selectbox( + "Stream Quality (Radio 1)", + r1_available_qualities, + index=r1_available_qualities.index(saved_r1_quality), + help=f"Select stream quality for Radio 1. Maximum quality based on configuration: {r1_max_quality}" + ) + + # Radio-level settings for Radio 1 + # First row: Assistive listening, immediate rendering, presentation delay, QoS + col_r1_flags1, col_r1_flags2, col_r1_pdelay, col_r1_qos = st.columns([1, 1, 0.7, 0.6], gap="small") + + with col_r1_flags1: + r1_assisted_listening = st.checkbox( + "Assistive (R1)", + value=bool(saved_r1_config.get('assisted_listening', False)), + help="Assistive listening stream" + ) + + with col_r1_flags2: + r1_immediate_rendering = st.checkbox( + "Immediate (R1)", + value=bool(saved_r1_config.get('immediate_rendering', False)), + help="Ignore presentation delay" + ) + + with col_r1_pdelay: + default_pdelay = int(saved_r1_config.get('presentation_delay_us', 40000) or 40000) + default_pdelay_ms = max(10, min(200, default_pdelay // 1000)) + r1_presentation_delay_ms = st.number_input( + "Delay (ms, R1)", + min_value=10, max_value=200, step=5, value=default_pdelay_ms, + help="Presentation delay for Radio 1" + ) + + with col_r1_qos: + qos_options = list(QOS_PRESET_MAP.keys()) + saved_qos = saved_r1_config.get('qos_preset', 'Fast') + default_qos_idx = qos_options.index(saved_qos) if saved_qos in qos_options else 0 + r1_qos_preset = st.selectbox( + "QoS (R1)", options=qos_options, index=default_qos_idx, + help="Quality of Service preset for Radio 1" + ) + + # Per-stream configuration for Radio 1 + st.write("**Stream Configuration (Radio 1)**") + r1_streams = [] + + for i in range(r1_num_streams): + with st.expander(f"Stream {i+1} - Radio 1", expanded=True): + saved_streams = saved_r1_config.get('streams', []) + saved_stream = saved_streams[i] if i < len(saved_streams) else {} + + # First row: Channel name and language + col_name, col_lang = st.columns([2, 1]) + + with col_name: + stream_name = st.text_input( + f"Channel Name", + value=saved_stream.get('name', f'Dante_R1_S{i+1}'), + key=f"r1_stream_{i}_name" + ) + + with col_lang: + stream_password = st.text_input( + f"Stream Password", + value=saved_stream.get('stream_password', ''), + type="password", + key=f"r1_stream_{i}_password", + help="Optional: Set a broadcast code for this stream" + ) + + # Second row: Program info and language + col_prog, col_lang_code = st.columns([2, 1]) + + with col_prog: + program_info = st.text_input( + f"Program Info", + value=saved_stream.get('program_info', f'Dante Radio 1 Stream {i+1}'), + key=f"r1_stream_{i}_program" + ) + + with col_lang_code: + language = st.text_input( + f"Language", + value=saved_stream.get('language', 'eng'), + key=f"r1_stream_{i}_lang", + help="ISO 639-3 language code" + ) + + # Third row: Input device + col_device = st.columns([1])[0] + + with col_device: + # Session state key for persisting the selection + device_session_key = f"r1_stream_{i}_device_saved" + + if not is_streaming and input_options: + # Get default from session state first, then from saved settings + default_input_name = st.session_state.get(device_session_key, saved_stream.get('input_device')) + default_input_label = None + for label, name in option_name_map.items(): + if name == default_input_name: + default_input_label = label + break + if default_input_label not in input_options and input_options: + default_input_label = input_options[0] + + selected_option = st.selectbox( + f"Input Device", + input_options, + index=input_options.index(default_input_label) if default_input_label in input_options else 0, + key=f"r1_stream_{i}_device" + ) + input_device = option_name_map.get(selected_option) + # Save to session state for persistence + st.session_state[device_session_key] = input_device + else: + # When streaming, get the device from session state + current_device = st.session_state.get(device_session_key, saved_stream.get('input_device', 'No device')) + + # Convert internal name to display label + display_label = current_device + for label, name in option_name_map.items(): + if name == current_device: + display_label = label + break + + st.selectbox( + f"Input Device", + [display_label if display_label else 'No device'], + index=0, + disabled=True, + key=f"r1_stream_{i}_device_disabled" + ) + input_device = current_device + + r1_streams.append({ + 'name': stream_name, + 'program_info': program_info, + 'language': language, + 'input_device': input_device, + 'stream_password': stream_password + }) + + # --- Radio 2 Section --- + st.subheader("Radio 2") + + # Stream count dropdown for Radio 2 (includes "off" option) + r2_stream_options = ["off"] + r1_stream_options + saved_r2_config = saved_settings.get('dante_radio2', {}) + saved_r2_streams = saved_r2_config.get('stream_config', 'off') + default_r2_idx = r2_stream_options.index(saved_r2_streams) if saved_r2_streams in r2_stream_options else 0 + + r2_stream_config = st.selectbox( + "Stream Configuration (Radio 2)", + r2_stream_options, + index=default_r2_idx, + help="Select the number and quality of streams for Radio 2" + ) + + radio2_enabled = r2_stream_config != "off" + + if radio2_enabled: + r2_num_streams = dante_stream_options[r2_stream_config]["streams"] + r2_quality = dante_stream_options[r2_stream_config]["quality"] + + # Stream quality (moved directly under stream configuration) + r2_max_quality = r2_quality + r2_available_qualities = [] + for quality in ["High (48kHz)", "Good (32kHz)", "Medium (24kHz)", "Fair (16kHz)"]: + # Check if this quality is equal to or lower than the max + if (r2_max_quality == "High (48kHz)" or + (r2_max_quality == "Medium (24kHz)" and quality in ["Medium (24kHz)", "Fair (16kHz)"]) or + (r2_max_quality == "Fair (16kHz)" and quality == "Fair (16kHz)")): + r2_available_qualities.append(quality) + + saved_r2_quality = saved_r2_config.get('radio_quality', r2_max_quality) + if saved_r2_quality not in r2_available_qualities: + saved_r2_quality = r2_max_quality + + r2_radio_quality = st.selectbox( + "Stream Quality (Radio 2)", + r2_available_qualities, + index=r2_available_qualities.index(saved_r2_quality), + help=f"Select stream quality for Radio 2. Maximum quality based on configuration: {r2_max_quality}" + ) + + # Radio-level settings for Radio 2 + # First row: Assistive listening, immediate rendering, presentation delay, QoS + col_r2_flags1, col_r2_flags2, col_r2_pdelay, col_r2_qos = st.columns([1, 1, 0.7, 0.6], gap="small") + + with col_r2_flags1: + r2_assisted_listening = st.checkbox( + "Assistive (R2)", + value=bool(saved_r2_config.get('assisted_listening', False)), + help="Assistive listening stream" + ) + + with col_r2_flags2: + r2_immediate_rendering = st.checkbox( + "Immediate (R2)", + value=bool(saved_r2_config.get('immediate_rendering', False)), + help="Ignore presentation delay" + ) + + with col_r2_pdelay: + default_pdelay = int(saved_r2_config.get('presentation_delay_us', 40000) or 40000) + default_pdelay_ms = max(10, min(200, default_pdelay // 1000)) + r2_presentation_delay_ms = st.number_input( + "Delay (ms, R2)", + min_value=10, max_value=200, step=5, value=default_pdelay_ms, + help="Presentation delay for Radio 2" + ) + + with col_r2_qos: + qos_options = list(QOS_PRESET_MAP.keys()) + saved_qos = saved_r2_config.get('qos_preset', 'Fast') + default_qos_idx = qos_options.index(saved_qos) if saved_qos in qos_options else 0 + r2_qos_preset = st.selectbox( + "QoS (R2)", options=qos_options, index=default_qos_idx, + help="Quality of Service preset for Radio 2" + ) + + # Per-stream configuration for Radio 2 + st.write("**Stream Configuration (Radio 2)**") + r2_streams = [] + + for i in range(r2_num_streams): + with st.expander(f"Stream {i+1} - Radio 2", expanded=True): + saved_streams = saved_r2_config.get('streams', []) + saved_stream = saved_streams[i] if i < len(saved_streams) else {} + + # First row: Channel name and password + col_name, col_pwd = st.columns([2, 1]) + + with col_name: + stream_name = st.text_input( + f"Channel Name", + value=saved_stream.get('name', f'Dante_R2_S{i+1}'), + key=f"r2_stream_{i}_name" + ) + + with col_pwd: + stream_password = st.text_input( + f"Stream Password", + value=saved_stream.get('stream_password', ''), + type="password", + key=f"r2_stream_{i}_password", + help="Optional: Set a broadcast code for this stream" + ) + + # Second row: Program info and language + col_prog, col_lang = st.columns([2, 1]) + + with col_prog: + program_info = st.text_input( + f"Program Info", + value=saved_stream.get('program_info', f'Dante Radio 2 Stream {i+1}'), + key=f"r2_stream_{i}_program" + ) + + with col_lang: + language = st.text_input( + f"Language", + value=saved_stream.get('language', 'eng'), + key=f"r2_stream_{i}_lang", + help="ISO 639-3 language code" + ) + + # Third row: Input device + col_device = st.columns([1])[0] + + with col_device: + # Session state key for persisting the selection + device_session_key = f"r2_stream_{i}_device_saved" + + if not is_streaming and input_options: + # Get default from session state first, then from saved settings + default_input_name = st.session_state.get(device_session_key, saved_stream.get('input_device')) + default_input_label = None + for label, name in option_name_map.items(): + if name == default_input_name: + default_input_label = label + break + if default_input_label not in input_options and input_options: + default_input_label = input_options[0] + + selected_option = st.selectbox( + f"Input Device", + input_options, + index=input_options.index(default_input_label) if default_input_label in input_options else 0, + key=f"r2_stream_{i}_device" + ) + input_device = option_name_map.get(selected_option) + # Save to session state for persistence + st.session_state[device_session_key] = input_device + else: + # When streaming, get the device from session state + current_device = st.session_state.get(device_session_key, saved_stream.get('input_device', 'No device')) + + # Convert internal name to display label + display_label = current_device + for label, name in option_name_map.items(): + if name == current_device: + display_label = label + break + + st.selectbox( + f"Input Device", + [display_label if display_label else 'No device'], + index=0, + disabled=True, + key=f"r2_stream_{i}_device_disabled" + ) + input_device = current_device + + r2_streams.append({ + 'name': stream_name, + 'program_info': program_info, + 'language': language, + 'input_device': input_device, + 'stream_password': stream_password + }) + else: + r2_streams = [] + + # Validate unique input devices for Network - Dante mode + if audio_mode == "Network - Dante": + is_valid, error_msg = validate_unique_input_devices(r1_streams, r2_streams) + if not is_valid: + # Display error in the placeholder at the top + validation_error_placeholder.error(error_msg) + + # Show device refresh button if no devices found + if not input_options and not is_streaming: + st.warning("No Dante inputs found. Check asound.conf configuration.") + if st.button("Refresh", key="refresh_dante_devices", disabled=is_streaming): + try: + r = requests.post(f"{BACKEND_URL}/refresh_audio_devices", timeout=8) + if not r.ok: + st.error(f"Failed to refresh: {r.text}") + except Exception as e: + st.error(f"Failed to refresh devices: {e}") + st.rerun() + + # Store radio configurations for backend + radio1_cfg = { + 'stream_config': r1_stream_config, + 'max_quality': r1_quality, # Original max quality from stream config + 'radio_quality': r1_radio_quality, # User-selected quality (may be lower) + 'quality': r1_radio_quality, # Use selected quality for backend + 'streams': r1_streams, + 'assisted_listening': r1_assisted_listening, + 'immediate_rendering': r1_immediate_rendering, + 'presentation_delay_ms': r1_presentation_delay_ms, + 'qos_preset': r1_qos_preset, + } + + radio2_cfg = { + 'stream_config': r2_stream_config, + 'max_quality': r2_quality if radio2_enabled else None, # Original max quality from stream config + 'radio_quality': r2_radio_quality if radio2_enabled else None, # User-selected quality + 'quality': r2_radio_quality if radio2_enabled else None, # Use selected quality for backend + 'streams': r2_streams, + 'assisted_listening': r2_assisted_listening if radio2_enabled else False, + 'immediate_rendering': r2_immediate_rendering if radio2_enabled else False, + 'presentation_delay_ms': r2_presentation_delay_ms if radio2_enabled else 40000, + 'qos_preset': r2_qos_preset if radio2_enabled else 'Fast', + } if radio2_enabled else None + + if audio_mode in ("USB", "Network"): # USB/Network: single set of controls shared with the single channel quality_options = list(QUALITY_MAP.keys()) default_quality = "Medium (24kHz)" if "Medium (24kHz)" in quality_options else quality_options[0] @@ -664,15 +1127,13 @@ else: 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" ) - if audio_mode in ("USB", "Network", "Network - Dante"): + if audio_mode in ("USB", "Network"): if not is_streaming: try: if audio_mode == "USB": endpoint = "/audio_inputs_pw_usb" elif audio_mode == "Network": endpoint = "/audio_inputs_pw_network" - else: # Network - Dante - endpoint = "/audio_inputs_dante" resp = requests.get(f"{BACKEND_URL}{endpoint}") device_list = resp.json().get('inputs', []) @@ -688,6 +1149,7 @@ else: device_names = [d['name'] for d in device_list] default_input_name = saved_settings.get('input_device') + # If saved device isn't in current mode's device list, use first available if default_input_name not in device_names and device_names: default_input_name = device_names[0] default_input_label = None @@ -700,11 +1162,9 @@ else: "No USB audio input devices found. Connect a USB input and click Refresh." if audio_mode == "USB" else "No AES67/Network inputs found." - if audio_mode == "Network" else - "No Dante inputs found. Check asound.conf configuration." ) st.warning(warn_text) - refresh_key = "refresh_usb" if audio_mode == "USB" else "refresh_aes67" if audio_mode == "Network" else "refresh_dante" + refresh_key = "refresh_usb" if audio_mode == "USB" else "refresh_aes67" if st.button("Refresh", key=refresh_key, disabled=is_streaming): try: r = requests.post(f"{BACKEND_URL}/refresh_audio_devices", timeout=8) @@ -723,7 +1183,7 @@ else: index=input_options.index(default_input_label) if default_input_label in input_options else 0 ) with col2: - refresh_key = "refresh_inputs" if audio_mode == "USB" else "refresh_inputs_net" if audio_mode == "Network" else "refresh_inputs_dante" + refresh_key = "refresh_inputs" if audio_mode == "USB" else "refresh_inputs_net" if st.button("Refresh", key=refresh_key, disabled=is_streaming): try: r = requests.post(f"{BACKEND_URL}/refresh_audio_devices", timeout=8) @@ -746,111 +1206,173 @@ else: else: input_device = None - start_stream, stop_stream = render_stream_controls(is_streaming, "Start Auracast", "Stop Auracast", running_mode) - - if stop_stream: - st.session_state['stream_started'] = False - try: - r = requests.post(f"{BACKEND_URL}/stop_audio").json() - if r['was_running']: - is_stopped = True - except Exception as e: - st.error(f"Error: {e}") +if stop_stream: + st.session_state['stream_started'] = False + try: + r = requests.post(f"{BACKEND_URL}/stop_audio").json() + if r['was_running']: + is_stopped = True + except Exception as e: + st.error(f"Error: {e}") - 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() - # Small pause lets backend fully release audio devices before re-init - time.sleep(1) +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() + # Small pause lets backend fully release audio devices before re-init + time.sleep(1) - if audio_mode == "Analog": - # Build separate configs per radio, each with its own quality and QoS parameters. - is_started = False + if audio_mode == "Analog": + # Build separate configs per radio, each with its own quality and QoS parameters. + is_started = False - def _build_group_from_radio(cfg: dict) -> auracast_config.AuracastConfigGroup | None: - if not cfg or not cfg.get('input_device'): - return None - q = QUALITY_MAP[cfg['quality']] - return auracast_config.AuracastConfigGroup( - auracast_sampling_rate_hz=q['rate'], - octets_per_frame=q['octets'], - transport='', # is set in backend - assisted_listening_stream=bool(cfg['assisted_listening']), - immediate_rendering=bool(cfg['immediate_rendering']), - presentation_delay_us=int(cfg['presentation_delay_ms'] * 1000), - qos_config=QOS_PRESET_MAP[cfg['qos_preset']], - bigs=[ - auracast_config.AuracastBigConfig( - id=cfg.get('id', 123456), - code=(cfg['stream_passwort'].strip() or None), - name=cfg['name'], - program_info=cfg['program_info'], - language=cfg['language'], - audio_source=f"device:{cfg['input_device']}", - input_format=f"int16le,{q['rate']},1", - iso_que_len=1, - sampling_frequency=q['rate'], - octets_per_frame=q['octets'], - ) - ], - ) - - # Radio 1 (always active if a device is selected) - config1 = _build_group_from_radio(radio1_cfg) - # Radio 2 (optional) - config2 = _build_group_from_radio(radio2_cfg) if radio2_enabled else None - - try: - if config1 is not None: - r1 = requests.post(f"{BACKEND_URL}/init", json=config1.model_dump()) - if r1.status_code == 200: - is_started = True - else: - st.error(f"Failed to initialize Radio 1: {r1.text}") - else: - st.error("Radio 1 has no valid input device configured.") - - if config2 is not None: - r2 = requests.post(f"{BACKEND_URL}/init2", json=config2.model_dump()) - if r2.status_code != 200: - st.error(f"Failed to initialize Radio 2: {r2.text}") - except Exception as e: - st.error(f"Error while starting Analog radios: {e}") - else: - # USB/Network: single config as before, using shared controls - q = QUALITY_MAP[quality] - config = auracast_config.AuracastConfigGroup( + def _build_group_from_radio(cfg: dict) -> auracast_config.AuracastConfigGroup | None: + if not cfg or not cfg.get('input_device'): + return None + q = QUALITY_MAP[cfg['quality']] + return auracast_config.AuracastConfigGroup( auracast_sampling_rate_hz=q['rate'], octets_per_frame=q['octets'], - transport='', # is set in backend - assisted_listening_stream=assisted_listening, - immediate_rendering=immediate_rendering, - presentation_delay_us=int(presentation_delay_ms * 1000), - qos_config=QOS_PRESET_MAP[qos_preset], + transport='', # is set in backend + assisted_listening_stream=bool(cfg['assisted_listening']), + immediate_rendering=bool(cfg['immediate_rendering']), + presentation_delay_us=int(cfg['presentation_delay_ms'] * 1000), + qos_config=QOS_PRESET_MAP[cfg['qos_preset']], bigs=[ auracast_config.AuracastBigConfig( - code=(stream_passwort.strip() or None), - name=stream_name, - program_info=program_info, - language=language, - audio_source=(f"device:{input_device}"), - input_format=(f"int16le,{q['rate']},1"), + id=cfg.get('id', 123456), + code=(cfg['stream_passwort'].strip() or None), + name=cfg['name'], + program_info=cfg['program_info'], + language=cfg['language'], + audio_source=f"device:{cfg['input_device']}", + input_format=f"int16le,{q['rate']},1", 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: + # Radio 1 (always active if a device is selected) + config1 = _build_group_from_radio(radio1_cfg) + # Radio 2 (optional) + config2 = _build_group_from_radio(radio2_cfg) if radio2_enabled else None + + try: + if config1 is not None: + r1 = requests.post(f"{BACKEND_URL}/init", json=config1.model_dump()) + if r1.status_code == 200: is_started = True else: - st.error(f"Failed to initialize: {r.text}") - except Exception as e: - st.error(f"Error: {e}") + st.error(f"Failed to initialize Radio 1: {r1.text}") + else: + st.error("Radio 1 has no valid input device configured.") + + if config2 is not None: + r2 = requests.post(f"{BACKEND_URL}/init2", json=config2.model_dump()) + if r2.status_code != 200: + st.error(f"Failed to initialize Radio 2: {r2.text}") + except Exception as e: + st.error(f"Error while starting Analog radios: {e}") + elif audio_mode == "Network - Dante": + # Build multi-stream configs for Dante radios + is_started = False + + def _build_dante_radio_config(radio_cfg: dict, radio_id: int) -> auracast_config.AuracastConfigGroup | None: + if not radio_cfg or not radio_cfg.get('streams'): + return None + + q = QUALITY_MAP[radio_cfg['quality']] + bigs = [] + + for i, stream in enumerate(radio_cfg['streams']): + if not stream.get('input_device'): + continue + + stream_id = radio_id * 1000 + i + 1 # Unique ID per stream + bigs.append(auracast_config.AuracastBigConfig( + id=stream_id, + code=(stream.get('stream_password', '').strip() or None), + name=stream['name'], + program_info=stream['program_info'], + language=stream['language'], + audio_source=f"device:{stream['input_device']}", + input_format=f"int16le,{q['rate']},1", + iso_que_len=1, + sampling_frequency=q['rate'], + octets_per_frame=q['octets'], + )) + + if not bigs: + return None + + return auracast_config.AuracastConfigGroup( + auracast_sampling_rate_hz=q['rate'], + octets_per_frame=q['octets'], + transport='', # is set in backend + assisted_listening_stream=bool(radio_cfg['assisted_listening']), + immediate_rendering=bool(radio_cfg['immediate_rendering']), + presentation_delay_us=int(radio_cfg['presentation_delay_ms'] * 1000), + qos_config=QOS_PRESET_MAP[radio_cfg['qos_preset']], + bigs=bigs + ) + + # Radio 1 config + config1 = _build_dante_radio_config(radio1_cfg, 1) + # Radio 2 config (optional) + config2 = _build_dante_radio_config(radio2_cfg, 2) if radio2_cfg else None + + try: + if config1 is not None: + r1 = requests.post(f"{BACKEND_URL}/init", json=config1.model_dump()) + if r1.status_code == 200: + is_started = True + else: + st.error(f"Failed to initialize Dante Radio 1: {r1.text}") + else: + st.error("Dante Radio 1 has no valid input devices configured.") + + if config2 is not None: + r2 = requests.post(f"{BACKEND_URL}/init2", json=config2.model_dump()) + if r2.status_code != 200: + st.error(f"Failed to initialize Dante Radio 2: {r2.text}") + except Exception as e: + st.error(f"Error while starting Dante radios: {e}") + else: + # USB/Network: single config as before, using shared controls + q = QUALITY_MAP[quality] + config = auracast_config.AuracastConfigGroup( + auracast_sampling_rate_hz=q['rate'], + octets_per_frame=q['octets'], + transport='', # is set in backend + assisted_listening_stream=assisted_listening, + immediate_rendering=immediate_rendering, + presentation_delay_us=int(presentation_delay_ms * 1000), + qos_config=QOS_PRESET_MAP[qos_preset], + bigs=[ + auracast_config.AuracastBigConfig( + code=(stream_passwort.strip() or None), + name=stream_name, + program_info=program_info, + language=language, + audio_source=(f"device:{input_device}"), + input_format=(f"int16le,{q['rate']},1"), + 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: + is_started = True + else: + st.error(f"Failed to initialize: {r.text}") + except Exception as e: + st.error(f"Error: {e}") # Centralized rerun based on start/stop outcomes if is_started or is_stopped: diff --git a/src/auracast/server/multicast_server.py b/src/auracast/server/multicast_server.py index ff5d4ab..2531a4c 100644 --- a/src/auracast/server/multicast_server.py +++ b/src/auracast/server/multicast_server.py @@ -369,23 +369,56 @@ async def init_radio(transport: str, conf: auracast_config.AuracastConfigGroup, big.audio_source = f'device:{device_index}' # Configure input format based on device type + # IMPORTANT: All hardware devices (Analog ch1/ch2, Dante, USB, Network) only support 48kHz + # We always capture at 48kHz and let the LC3 encoder handle downsampling to target rates if input_device_name in dante_channels: # For Dante channels, use mono (1 channel) from shared device max_in = 1 channels = 1 + # Always use 48kHz for hardware capture, regardless of target quality + hardware_capture_rate = 48000 + for big in conf.bigs: + big.input_format = f"int16le,{hardware_capture_rate},{channels}" + elif input_device_name in ('ch1', 'ch2'): + # For Analog channels, use mono (1 channel) + max_in = 1 + channels = 1 + # Always use 48kHz for hardware capture, regardless of target quality + hardware_capture_rate = 48000 + for big in conf.bigs: + big.input_format = f"int16le,{hardware_capture_rate},{channels}" else: + # For USB/Network devices, check device capabilities devinfo = sd.query_devices(device_index) max_in = int(devinfo.get('max_input_channels') or 1) channels = max(1, min(2, max_in)) - - for big in conf.bigs: - big.input_format = f"int16le,{48000},{channels}" + # Always use 48kHz for hardware capture, regardless of target quality + hardware_capture_rate = 48000 + for big in conf.bigs: + big.input_format = f"int16le,{hardware_capture_rate},{channels}" - # For Dante channels, force 48000 Hz sampling rate - if input_device_name in dante_channels: - conf.auracast_sampling_rate_hz = 48000 - # Also update octets per frame for 48000 Hz - conf.octets_per_frame = 120 # 48000 Hz setting + # The config group keeps the target sampling rate for LC3 encoder + # The audio input will capture at 48kHz and LC3 encoder will downsample + target_sampling_rate = getattr(conf, 'auracast_sampling_rate_hz', None) + if target_sampling_rate is None and conf.bigs: + target_sampling_rate = getattr(conf.bigs[0], 'sampling_frequency', 48000) + if target_sampling_rate is None: + target_sampling_rate = 48000 + + # Keep the config group sampling rate as set by frontend + conf.auracast_sampling_rate_hz = target_sampling_rate + + # Ensure octets_per_frame matches the target sampling rate + if target_sampling_rate == 48000: + conf.octets_per_frame = 120 + elif target_sampling_rate == 32000: + conf.octets_per_frame = 80 + elif target_sampling_rate == 24000: + conf.octets_per_frame = 60 + elif target_sampling_rate == 16000: + conf.octets_per_frame = 40 + else: + conf.octets_per_frame = 120 # default to 48000 setting conf.qos_config.max_transport_latency_ms = int(conf.qos_config.number_of_retransmissions) * 10 + 3