diff --git a/src/auracast/server/multicast_frontend.py b/src/auracast/server/multicast_frontend.py index 5a784a8..be22113 100644 --- a/src/auracast/server/multicast_frontend.py +++ b/src/auracast/server/multicast_frontend.py @@ -725,18 +725,74 @@ else: help="Radio 1 is always enabled, Radio 2 can be turned on or off." ) - # Stream count dropdown for Radio 1 - r1_stream_options = list(dante_stream_options.keys()) + # Dante stereo mode toggle saved_r1_config = saved_settings.get('dante_radio1', {}) + dante_stereo_enabled = st.checkbox( + "🎧 Stereo Mode", + value=bool(saved_r1_config.get('dante_stereo_mode', False)), + help="Enable stereo streaming for Dante inputs. Select left and right channels from ASRC channels 1-6. Radio 2 and multi-stream configurations will be disabled in stereo mode.", + disabled=is_streaming + ) + + # Dante stereo channel selectors + dante_left_channel = None + dante_right_channel = None + if dante_stereo_enabled: + dante_channel_options = ["dante_asrc_ch1", "dante_asrc_ch2", "dante_asrc_ch3", + "dante_asrc_ch4", "dante_asrc_ch5", "dante_asrc_ch6"] + dante_channel_labels = ["CH1", "CH2", "CH3", "CH4", "CH5", "CH6"] + + col_left, col_right = st.columns(2) + with col_left: + saved_left = saved_r1_config.get('dante_stereo_left', 'dante_asrc_ch1') + left_idx = dante_channel_options.index(saved_left) if saved_left in dante_channel_options else 0 + dante_left_channel = st.selectbox( + "Left Channel", + dante_channel_options, + index=left_idx, + format_func=lambda x: f"ASRC {dante_channel_labels[dante_channel_options.index(x)]}", + disabled=is_streaming, + help="Select the Dante ASRC channel for the left stereo channel" + ) + with col_right: + saved_right = saved_r1_config.get('dante_stereo_right', 'dante_asrc_ch2') + right_idx = dante_channel_options.index(saved_right) if saved_right in dante_channel_options else 1 + dante_right_channel = st.selectbox( + "Right Channel", + dante_channel_options, + index=right_idx, + format_func=lambda x: f"ASRC {dante_channel_labels[dante_channel_options.index(x)]}", + disabled=is_streaming, + help="Select the Dante ASRC channel for the right stereo channel" + ) + + if dante_left_channel == dante_right_channel: + st.warning("⚠️ Left and right channels are the same. Select different channels for true stereo.") + else: + st.info(f"🎧 Stereo mode: {dante_channel_labels[dante_channel_options.index(dante_left_channel)]} (Left) + {dante_channel_labels[dante_channel_options.index(dante_right_channel)]} (Right)") + + # Stream count dropdown for Radio 1 (disabled in stereo mode - forced to 1 stream at 48kHz) + r1_stream_options = list(dante_stream_options.keys()) 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" - ) + if dante_stereo_enabled: + # Stereo mode: force 1 stream at 48kHz + r1_stream_config = "1 × 48kHz" + st.selectbox( + "Stream Configuration (Radio 1)", + ["1 × 48kHz (Stereo)"], + index=0, + disabled=True, + help="In stereo mode, only 1 stream at 48kHz is supported" + ) + else: + 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"] @@ -799,30 +855,34 @@ else: ) # Per-stream configuration for Radio 1 - st.write("**Stream Configuration (Radio 1)**") + if dante_stereo_enabled: + st.write("**Stereo Stream Configuration (Radio 1)**") + else: + 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): + if dante_stereo_enabled: + # Stereo mode: single stream with combined L+R channels + with st.expander("Stereo Stream - Radio 1", expanded=True): saved_streams = saved_r1_config.get('streams', []) - saved_stream = saved_streams[i] if i < len(saved_streams) else {} + saved_stream = saved_streams[0] if saved_streams else {} - # First row: Channel name and language - col_name, col_lang = st.columns([2, 1]) + # 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_R1_S{i+1}'), - key=f"r1_stream_{i}_name" + "Channel Name", + value=saved_stream.get('name', 'Dante_Stereo'), + key="r1_stereo_name" ) - with col_lang: + with col_pwd: stream_password = st.text_input( - f"Stream Password", + "Stream Password", value=saved_stream.get('stream_password', ''), type="password", - key=f"r1_stream_{i}_password", + key="r1_stereo_password", help="Optional: Set a broadcast code for this stream" ) @@ -831,86 +891,161 @@ else: 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" + "Program Info", + value=saved_stream.get('program_info', 'Dante Stereo Broadcast'), + key="r1_stereo_program" ) with col_lang_code: language = st.text_input( - f"Language", + "Language", value=saved_stream.get('language', 'eng'), - key=f"r1_stream_{i}_lang", + key="r1_stereo_lang", help="ISO 639-3 language code" ) - # Third row: Input device - col_device = st.columns([1])[0] + # Build stereo device name from selected channels + # Extract channel numbers from dante_asrc_chX + left_ch_num = dante_left_channel.replace('dante_asrc_ch', '') if dante_left_channel else '1' + right_ch_num = dante_right_channel.replace('dante_asrc_ch', '') if dante_right_channel else '2' + # Use the device name that matches the user's L/R selection + stereo_device_name = f"dante_stereo_{left_ch_num}_{right_ch_num}" - 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 + # Show stereo device info + st.selectbox( + "Input Device (Stereo)", + [f"Stereo: CH{left_ch_num} (L) + CH{right_ch_num} (R)"], + index=0, + disabled=True, + help="Stereo input from the selected Dante ASRC channels" + ) r1_streams.append({ 'name': stream_name, 'program_info': program_info, 'language': language, - 'input_device': input_device, - 'stream_password': stream_password + 'input_device': stereo_device_name, + 'stream_password': stream_password, + 'dante_stereo_mode': True, + 'dante_stereo_left': dante_left_channel, + 'dante_stereo_right': dante_right_channel, }) + else: + # Normal mono mode: multiple streams with individual channels + 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 --- with st.container(border=True): st.subheader("Radio 2") - # Enable/disable checkbox for Radio 2 + # Disable Radio 2 in stereo mode saved_r2_config = saved_settings.get('dante_radio2', {}) - radio2_enabled_default = secondary_is_streaming - radio2_enabled = st.checkbox( - "Enable Radio 2", - value=radio2_enabled_default, - help="Activate a second Dante radio with its own quality and timing settings." - ) + if dante_stereo_enabled: + st.info("🎧 Radio 2 is automatically disabled in stereo mode") + radio2_enabled = False + else: + # Enable/disable checkbox for Radio 2 + radio2_enabled_default = secondary_is_streaming + radio2_enabled = st.checkbox( + "Enable Radio 2", + value=radio2_enabled_default, + help="Activate a second Dante radio with its own quality and timing settings." + ) if radio2_enabled: # Stream count dropdown for Radio 2 @@ -1086,6 +1221,14 @@ else: }) else: r2_streams = [] + # Set default values for r2_* variables when radio2 is disabled + r2_stream_config = '1 × 48kHz' + r2_quality = 'High (48kHz)' + r2_radio_quality = 'High (48kHz)' + r2_assisted_listening = False + r2_immediate_rendering = False + r2_presentation_delay_ms = 40 + r2_qos_preset = 'Fast' # Validate unique input devices for Network - Dante mode if audio_mode == "Network - Dante": @@ -1117,6 +1260,9 @@ else: 'immediate_rendering': r1_immediate_rendering, 'presentation_delay_ms': r1_presentation_delay_ms, 'qos_preset': r1_qos_preset, + 'dante_stereo_mode': dante_stereo_enabled, + 'dante_stereo_left': dante_left_channel, + 'dante_stereo_right': dante_right_channel, } radio2_cfg = { @@ -1360,22 +1506,33 @@ if start_stream: q = QUALITY_MAP[radio_cfg['quality']] bigs = [] + # Check if stereo mode is enabled for this radio + is_stereo_mode = bool(radio_cfg.get('dante_stereo_mode', False)) + 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 + + # Check if this specific stream uses stereo (dante_stereo_X_Y device) + input_device = stream['input_device'] + stream_is_stereo = is_stereo_mode or input_device.startswith('dante_stereo_') + num_bis = 2 if stream_is_stereo else 1 + num_channels = 2 if stream_is_stereo else 1 + 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", + audio_source=f"device:{input_device}", + input_format=f"int16le,{q['rate']},{num_channels}", iso_que_len=1, sampling_frequency=q['rate'], octets_per_frame=q['octets'], + num_bis=num_bis, )) if not bigs: diff --git a/src/auracast/server/multicast_server.py b/src/auracast/server/multicast_server.py index 0a0aa6c..63a5050 100644 --- a/src/auracast/server/multicast_server.py +++ b/src/auracast/server/multicast_server.py @@ -357,6 +357,21 @@ async def init_radio(transport: str, conf: auracast_config.AuracastConfigGroup, big.audio_source = f'alsa:{sel}' big.input_format = f"int16le,{hardware_capture_rate},1" continue + + # Dante stereo devices: dante_stereo_X_Y (e.g., dante_stereo_1_2) + if sel and sel.startswith('dante_stereo_'): + is_stereo = getattr(big, 'num_bis', 1) == 2 + if is_stereo: + # Stereo mode: use the stereo ALSA device with 2 channels + big.audio_source = f'alsa:{sel}' + big.input_format = f"int16le,{hardware_capture_rate},2" + log.info("Configured Dante stereo input: using ALSA %s with 2 channels", sel) + else: + # Fallback to mono if num_bis != 2 (shouldn't happen) + big.audio_source = f'alsa:{sel}' + big.input_format = f"int16le,{hardware_capture_rate},2" + log.warning("Dante stereo device %s used but num_bis=%d, capturing as stereo anyway", sel, getattr(big, 'num_bis', 1)) + continue if sel in ('ch1', 'ch2'): # Analog channels: check if this should be stereo based on num_bis diff --git a/src/misc/asound.conf b/src/misc/asound.conf index 3154417..b9351ad 100644 --- a/src/misc/asound.conf +++ b/src/misc/asound.conf @@ -99,3 +99,248 @@ pcm.dante_asrc_ch6 { ttable.0.5 1 hint { show on ; description "DEP RX CH6" } } + +# ---- Stereo devices for Dante (combine any two channels as L+R) ---- +# These devices route selected source channels to stereo output +# Format: dante_stereo__ + +pcm.dante_stereo_1_2 { + type route + slave { pcm "dante_asrc_shared6"; channels 6; } + ttable.0.0 1 # Left channel from ch1 + ttable.1.1 1 # Right channel from ch2 + hint { show on ; description "DEP RX Stereo CH1+CH2" } +} + +pcm.dante_stereo_1_3 { + type route + slave { pcm "dante_asrc_shared6"; channels 6; } + ttable.0.0 1 + ttable.1.2 1 + hint { show on ; description "DEP RX Stereo CH1+CH3" } +} + +pcm.dante_stereo_1_4 { + type route + slave { pcm "dante_asrc_shared6"; channels 6; } + ttable.0.0 1 + ttable.1.3 1 + hint { show on ; description "DEP RX Stereo CH1+CH4" } +} + +pcm.dante_stereo_1_5 { + type route + slave { pcm "dante_asrc_shared6"; channels 6; } + ttable.0.0 1 + ttable.1.4 1 + hint { show on ; description "DEP RX Stereo CH1+CH5" } +} + +pcm.dante_stereo_1_6 { + type route + slave { pcm "dante_asrc_shared6"; channels 6; } + ttable.0.0 1 + ttable.1.5 1 + hint { show on ; description "DEP RX Stereo CH1+CH6" } +} + +pcm.dante_stereo_2_3 { + type route + slave { pcm "dante_asrc_shared6"; channels 6; } + ttable.0.1 1 + ttable.1.2 1 + hint { show on ; description "DEP RX Stereo CH2+CH3" } +} + +pcm.dante_stereo_2_4 { + type route + slave { pcm "dante_asrc_shared6"; channels 6; } + ttable.0.1 1 + ttable.1.3 1 + hint { show on ; description "DEP RX Stereo CH2+CH4" } +} + +pcm.dante_stereo_2_5 { + type route + slave { pcm "dante_asrc_shared6"; channels 6; } + ttable.0.1 1 + ttable.1.4 1 + hint { show on ; description "DEP RX Stereo CH2+CH5" } +} + +pcm.dante_stereo_2_6 { + type route + slave { pcm "dante_asrc_shared6"; channels 6; } + ttable.0.1 1 + ttable.1.5 1 + hint { show on ; description "DEP RX Stereo CH2+CH6" } +} + +pcm.dante_stereo_3_4 { + type route + slave { pcm "dante_asrc_shared6"; channels 6; } + ttable.0.2 1 + ttable.1.3 1 + hint { show on ; description "DEP RX Stereo CH3+CH4" } +} + +pcm.dante_stereo_3_5 { + type route + slave { pcm "dante_asrc_shared6"; channels 6; } + ttable.0.2 1 + ttable.1.4 1 + hint { show on ; description "DEP RX Stereo CH3+CH5" } +} + +pcm.dante_stereo_3_6 { + type route + slave { pcm "dante_asrc_shared6"; channels 6; } + ttable.0.2 1 + ttable.1.5 1 + hint { show on ; description "DEP RX Stereo CH3+CH6" } +} + +pcm.dante_stereo_4_5 { + type route + slave { pcm "dante_asrc_shared6"; channels 6; } + ttable.0.3 1 + ttable.1.4 1 + hint { show on ; description "DEP RX Stereo CH4+CH5" } +} + +pcm.dante_stereo_4_6 { + type route + slave { pcm "dante_asrc_shared6"; channels 6; } + ttable.0.3 1 + ttable.1.5 1 + hint { show on ; description "DEP RX Stereo CH4+CH6" } +} + +pcm.dante_stereo_5_6 { + type route + slave { pcm "dante_asrc_shared6"; channels 6; } + ttable.0.4 1 + ttable.1.5 1 + hint { show on ; description "DEP RX Stereo CH5+CH6" } +} + +# ---- Reverse stereo devices (for when left channel > right channel) ---- +pcm.dante_stereo_2_1 { + type route + slave { pcm "dante_asrc_shared6"; channels 6; } + ttable.0.1 1 # Left from ch2 + ttable.1.0 1 # Right from ch1 + hint { show on ; description "DEP RX Stereo CH2+CH1" } +} + +pcm.dante_stereo_3_1 { + type route + slave { pcm "dante_asrc_shared6"; channels 6; } + ttable.0.2 1 + ttable.1.0 1 + hint { show on ; description "DEP RX Stereo CH3+CH1" } +} + +pcm.dante_stereo_3_2 { + type route + slave { pcm "dante_asrc_shared6"; channels 6; } + ttable.0.2 1 + ttable.1.1 1 + hint { show on ; description "DEP RX Stereo CH3+CH2" } +} + +pcm.dante_stereo_4_1 { + type route + slave { pcm "dante_asrc_shared6"; channels 6; } + ttable.0.3 1 + ttable.1.0 1 + hint { show on ; description "DEP RX Stereo CH4+CH1" } +} + +pcm.dante_stereo_4_2 { + type route + slave { pcm "dante_asrc_shared6"; channels 6; } + ttable.0.3 1 + ttable.1.1 1 + hint { show on ; description "DEP RX Stereo CH4+CH2" } +} + +pcm.dante_stereo_4_3 { + type route + slave { pcm "dante_asrc_shared6"; channels 6; } + ttable.0.3 1 + ttable.1.2 1 + hint { show on ; description "DEP RX Stereo CH4+CH3" } +} + +pcm.dante_stereo_5_1 { + type route + slave { pcm "dante_asrc_shared6"; channels 6; } + ttable.0.4 1 + ttable.1.0 1 + hint { show on ; description "DEP RX Stereo CH5+CH1" } +} + +pcm.dante_stereo_5_2 { + type route + slave { pcm "dante_asrc_shared6"; channels 6; } + ttable.0.4 1 + ttable.1.1 1 + hint { show on ; description "DEP RX Stereo CH5+CH2" } +} + +pcm.dante_stereo_5_3 { + type route + slave { pcm "dante_asrc_shared6"; channels 6; } + ttable.0.4 1 + ttable.1.2 1 + hint { show on ; description "DEP RX Stereo CH5+CH3" } +} + +pcm.dante_stereo_5_4 { + type route + slave { pcm "dante_asrc_shared6"; channels 6; } + ttable.0.4 1 + ttable.1.3 1 + hint { show on ; description "DEP RX Stereo CH5+CH4" } +} + +pcm.dante_stereo_6_1 { + type route + slave { pcm "dante_asrc_shared6"; channels 6; } + ttable.0.5 1 + ttable.1.0 1 + hint { show on ; description "DEP RX Stereo CH6+CH1" } +} + +pcm.dante_stereo_6_2 { + type route + slave { pcm "dante_asrc_shared6"; channels 6; } + ttable.0.5 1 + ttable.1.1 1 + hint { show on ; description "DEP RX Stereo CH6+CH2" } +} + +pcm.dante_stereo_6_3 { + type route + slave { pcm "dante_asrc_shared6"; channels 6; } + ttable.0.5 1 + ttable.1.2 1 + hint { show on ; description "DEP RX Stereo CH6+CH3" } +} + +pcm.dante_stereo_6_4 { + type route + slave { pcm "dante_asrc_shared6"; channels 6; } + ttable.0.5 1 + ttable.1.3 1 + hint { show on ; description "DEP RX Stereo CH6+CH4" } +} + +pcm.dante_stereo_6_5 { + type route + slave { pcm "dante_asrc_shared6"; channels 6; } + ttable.0.5 1 + ttable.1.4 1 + hint { show on ; description "DEP RX Stereo CH6+CH5" } +}