feat: add gain control for USB and webapp microphone inputs with persistence - to be tested
This commit is contained in:
@@ -452,97 +452,135 @@ class Streamer():
|
||||
lc3_frames = itertools.cycle(lc3_frames)
|
||||
big['lc3_frames'] = lc3_frames
|
||||
|
||||
# anything else, e.g. realtime stream from device (bumble)
|
||||
# anything else, e.g. realtime stream from device (bumble) or non-precoded file
|
||||
else:
|
||||
audio_input = await audio_io.create_audio_input(audio_source, input_format)
|
||||
# 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
|
||||
current_big_config = self.big_config[i]
|
||||
audio_source_str = str(current_big_config.audio_source) # Ensure string type
|
||||
input_format_str = current_big_config.input_format
|
||||
input_gain_val = current_big_config.input_gain
|
||||
|
||||
audio_filter_for_create = None
|
||||
effective_audio_source_for_create = audio_source_str
|
||||
|
||||
if audio_source_str.startswith('device:'):
|
||||
parts = audio_source_str.split(':', 1)
|
||||
if len(parts) > 1:
|
||||
device_specifier_with_potential_gain = parts[1]
|
||||
pure_device_name = device_specifier_with_potential_gain.split(',', 1)[0]
|
||||
effective_audio_source_for_create = f"device:{pure_device_name}"
|
||||
|
||||
gain_to_apply = input_gain_val if input_gain_val is not None else 1.0
|
||||
if abs(gain_to_apply - 1.0) > 0.01:
|
||||
audio_filter_for_create = f"volume={gain_to_apply:.2f}"
|
||||
logger.info(f"Applying FFmpeg volume filter for {effective_audio_source_for_create}: {audio_filter_for_create}")
|
||||
elif audio_source_str.startswith('file:'):
|
||||
gain_to_apply = input_gain_val if input_gain_val is not None else 1.0
|
||||
if abs(gain_to_apply - 1.0) > 0.01:
|
||||
audio_filter_for_create = f"volume={gain_to_apply:.2f}"
|
||||
logger.info(f"Applying FFmpeg volume filter for {audio_source_str}: {audio_filter_for_create}")
|
||||
|
||||
# Prepare the source string, potentially with an FFmpeg filter
|
||||
final_audio_source_spec = effective_audio_source_for_create
|
||||
if current_big_config.input_gain is not None and input_format_str == 'ffmpeg': # Apply gain only if ffmpeg is used
|
||||
audio_filter_value = f"volume={current_big_config.input_gain:.2f}"
|
||||
logging.info(f"Applying FFmpeg volume filter for {effective_audio_source_for_create}: {audio_filter_value}")
|
||||
# Append 'af' (audio filter) option to the source spec for FFmpeg
|
||||
if '?' in final_audio_source_spec: # if there are already ffmpeg options (e.g. sample_rate)
|
||||
final_audio_source_spec = f"{final_audio_source_spec}&af={audio_filter_value}"
|
||||
else: # if this is the first ffmpeg option
|
||||
final_audio_source_spec = f"{final_audio_source_spec},af={audio_filter_value}"
|
||||
|
||||
# Initial creation of audio_input
|
||||
audio_input = await audio_io.create_audio_input(
|
||||
final_audio_source_spec,
|
||||
input_format=input_format_str
|
||||
)
|
||||
big['audio_input'] = audio_input # Store early for potential cleanup
|
||||
|
||||
if hasattr(audio_input, "rewind"):
|
||||
audio_input.rewind = current_big_config.loop
|
||||
|
||||
# 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
|
||||
pcm_format = None # Initialize pcm_format
|
||||
for attempt in range(1, max_attempts + 1):
|
||||
try:
|
||||
logging.info(f"Attempting to open audio input: {effective_audio_source_for_create} (attempt {attempt})")
|
||||
pcm_format = await audio_input.open()
|
||||
logging.info(f"Successfully opened audio input: {effective_audio_source_for_create}, PCM Format: {pcm_format}")
|
||||
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
|
||||
logging.error('Could not open audio device %s with error %s (attempt %d/%d)', effective_audio_source_for_create, err, attempt, max_attempts)
|
||||
code = getattr(err, 'errno', None) or (err.args[1] if len(err.args) > 1 and isinstance(err.args[1], int) else None)
|
||||
if code == -9985 and attempt < max_attempts: # paDeviceUnavailable
|
||||
backoff_ms = (2 ** (attempt - 1)) * 100 # exponential backoff
|
||||
logging.warning("PortAudio device busy. Retrying in %.1f ms…", backoff_ms)
|
||||
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
|
||||
if hasattr(audio_input, "aclose"): await audio_input.aclose()
|
||||
elif hasattr(audio_input, "close"): audio_input.close()
|
||||
except Exception as close_err: logging.debug(f"Error closing audio_input during retry: {close_err}")
|
||||
if hasattr(_sd, "_terminate"): # sounddevice specific cleanup
|
||||
try: _sd._terminate()
|
||||
except Exception as term_err: logging.debug(f"Error terminating PortAudio: {term_err}")
|
||||
await asyncio.sleep(0.1)
|
||||
if hasattr(_sd, "_initialize"):
|
||||
try:
|
||||
_sd._initialize()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# Back-off before next attempt
|
||||
if hasattr(_sd, "_initialize"): # sounddevice specific reinit
|
||||
try: _sd._initialize()
|
||||
except Exception as init_err: logging.debug(f"Error initializing PortAudio: {init_err}")
|
||||
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)
|
||||
# Recreate audio_input for next attempt, using the potentially modified source spec
|
||||
audio_input = await audio_io.create_audio_input(
|
||||
final_audio_source_spec, # Use the spec that includes the filter if applicable
|
||||
input_format=input_format_str
|
||||
)
|
||||
big['audio_input'] = audio_input # Update stored reference
|
||||
if hasattr(audio_input, "rewind"):
|
||||
audio_input.rewind = current_big_config.loop
|
||||
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)
|
||||
raise # Re-raise if not paDeviceUnavailable or max_attempts reached
|
||||
except Exception as e:
|
||||
logging.error(f"Unexpected error opening audio device {effective_audio_source_for_create}: {e}")
|
||||
raise # Re-raise other unexpected errors
|
||||
else: # else for 'for' loop: if loop finished without break
|
||||
logging.error("Unable to open audio device '%s' after %d attempts – giving up.", effective_audio_source_for_create, max_attempts)
|
||||
return # Or handle error more gracefully, e.g. mark BIG as inactive
|
||||
|
||||
# Proceed with encoder setup if pcm_format was obtained
|
||||
if not pcm_format:
|
||||
logging.error(f"Failed to obtain PCM format for {effective_audio_source_for_create}. Cannot set up encoder.")
|
||||
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:
|
||||
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
|
||||
logging.info("Input device '%s' provides %d channels – will down-mix to mono for LC3", effective_audio_source_for_create, pcm_format.channels)
|
||||
# Downmixing is typically handled by FFmpeg if channels > 1 and output is mono
|
||||
# For LC3, we always want mono, so this is informational.
|
||||
|
||||
# Determine pcm_bit_depth for encoder based on pcm_format.sample_type
|
||||
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 # LC3 encoder can handle float32 directly
|
||||
else:
|
||||
logging.error("Unsupported PCM sample type: %s for %s. Only INT16 and FLOAT32 are supported.", pcm_format.sample_type, effective_audio_source_for_create)
|
||||
return
|
||||
|
||||
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
|
||||
encoder = lc3.Encoder(
|
||||
frame_duration_us=self.global_config.frame_duration_us,
|
||||
sample_rate_hz=self.global_config.auracast_sampling_rate_hz,
|
||||
num_channels=1, # LC3 is mono
|
||||
input_sample_rate_hz=pcm_format.sample_rate,
|
||||
)
|
||||
lc3_frame_samples = encoder.get_frame_samples()
|
||||
big['pcm_bit_depth'] = pcm_bit_depth
|
||||
big['lc3_frame_samples'] = lc3_frame_samples
|
||||
big['lc3_bytes_per_frame'] = self.global_config.octets_per_frame
|
||||
big['encoder'] = encoder
|
||||
big['precoded'] = False
|
||||
|
||||
|
||||
logging.info("Streaming audio...")
|
||||
bigs = self.bigs
|
||||
self.is_streaming = True
|
||||
logging.info("Entering main streaming loop...")
|
||||
# One streamer fits all
|
||||
while self.is_streaming:
|
||||
stream_finished = [False for _ in range(len(bigs))]
|
||||
@@ -557,7 +595,9 @@ class Streamer():
|
||||
stream_finished[i] = True
|
||||
continue
|
||||
else: # code lc3 on the fly
|
||||
logging.debug(f"BIG {i} ({big.get('name', 'N/A')}): Attempting to read pcm_frame.")
|
||||
pcm_frame = await anext(big['audio_input'].frames(big['lc3_frame_samples']), None)
|
||||
logging.debug(f"BIG {i} ({big.get('name', 'N/A')}): Read pcm_frame: {'None' if pcm_frame is None else f'type {type(pcm_frame)}, len {len(pcm_frame)} bytes' if isinstance(pcm_frame, bytes) else f'type {type(pcm_frame)}, shape {pcm_frame.shape}' if hasattr(pcm_frame, 'shape') else f'type {type(pcm_frame)}'}")
|
||||
|
||||
if pcm_frame is None: # Not all streams may stop at the same time
|
||||
stream_finished[i] = True
|
||||
|
||||
Reference in New Issue
Block a user