From 08a35bddeb5f8d8fab87f5753b3f4fe8b44973d2 Mon Sep 17 00:00:00 2001 From: tcpsyn Date: Mon, 23 Feb 2026 22:28:26 -0700 Subject: [PATCH] Play idents in stereo on channels 15/16 with configurable ident_channel setting Co-Authored-By: Claude Opus 4.6 --- backend/main.py | 2 ++ backend/services/audio.py | 46 +++++++++++++++++++++++++++------------ frontend/index.html | 1 + frontend/js/app.js | 4 ++++ 4 files changed, 39 insertions(+), 14 deletions(-) diff --git a/backend/main.py b/backend/main.py index 61088e0..711b1ec 100644 --- a/backend/main.py +++ b/backend/main.py @@ -3677,6 +3677,7 @@ class AudioDeviceSettings(BaseModel): music_channel: Optional[int] = None sfx_channel: Optional[int] = None ad_channel: Optional[int] = None + ident_channel: Optional[int] = None monitor_device: Optional[int] = None monitor_channel: Optional[int] = None phone_filter: Optional[bool] = None @@ -3716,6 +3717,7 @@ async def set_audio_settings(settings: AudioDeviceSettings): music_channel=settings.music_channel, sfx_channel=settings.sfx_channel, ad_channel=settings.ad_channel, + ident_channel=settings.ident_channel, monitor_device=settings.monitor_device, monitor_channel=settings.monitor_channel, phone_filter=settings.phone_filter diff --git a/backend/services/audio.py b/backend/services/audio.py index d3afeda..d5b8762 100644 --- a/backend/services/audio.py +++ b/backend/services/audio.py @@ -28,6 +28,7 @@ class AudioService: self.music_channel: int = 5 # Channel for music self.sfx_channel: int = 3 # Channel for SFX self.ad_channel: int = 11 # Channel for ads + self.ident_channel: int = 15 # Channel for idents (stereo: ch 15+16) self.monitor_device: Optional[int] = 14 # Babyface Pro (headphone monitoring) self.monitor_channel: int = 1 # Channel for mic monitoring on monitor device self.phone_filter: bool = False # Phone filter on caller voices @@ -112,6 +113,7 @@ class AudioService: self.music_channel = data.get("music_channel", 2) self.sfx_channel = data.get("sfx_channel", 3) self.ad_channel = data.get("ad_channel", 11) + self.ident_channel = data.get("ident_channel", 15) self.monitor_device = data.get("monitor_device") self.monitor_channel = data.get("monitor_channel", 1) self.phone_filter = data.get("phone_filter", False) @@ -131,6 +133,7 @@ class AudioService: "music_channel": self.music_channel, "sfx_channel": self.sfx_channel, "ad_channel": self.ad_channel, + "ident_channel": self.ident_channel, "monitor_device": self.monitor_device, "monitor_channel": self.monitor_channel, "phone_filter": self.phone_filter, @@ -165,6 +168,7 @@ class AudioService: music_channel: Optional[int] = None, sfx_channel: Optional[int] = None, ad_channel: Optional[int] = None, + ident_channel: Optional[int] = None, monitor_device: Optional[int] = None, monitor_channel: Optional[int] = None, phone_filter: Optional[bool] = None @@ -186,6 +190,8 @@ class AudioService: self.sfx_channel = sfx_channel if ad_channel is not None: self.ad_channel = ad_channel + if ident_channel is not None: + self.ident_channel = ident_channel if monitor_device is not None: self.monitor_device = monitor_device if monitor_channel is not None: @@ -207,6 +213,7 @@ class AudioService: "music_channel": self.music_channel, "sfx_channel": self.sfx_channel, "ad_channel": self.ad_channel, + "ident_channel": self.ident_channel, "monitor_device": self.monitor_device, "monitor_channel": self.monitor_channel, "phone_filter": self.phone_filter, @@ -1014,7 +1021,7 @@ class AudioService: self._ad_position = 0 def play_ident(self, file_path: str): - """Load and play an ident file once (no loop) on the ad channel""" + """Load and play an ident file once (no loop) in stereo on ident_channel/ident_channel+1""" import librosa path = Path(file_path) @@ -1026,8 +1033,11 @@ class AudioService: self.stop_ad() try: - audio, sr = librosa.load(str(path), sr=self.output_sample_rate, mono=True) - self._ident_data = audio.astype(np.float32) + audio, sr = librosa.load(str(path), sr=self.output_sample_rate, mono=False) + if audio.ndim == 1: + # Mono file — duplicate to stereo + audio = np.stack([audio, audio]) + self._ident_data = audio.astype(np.float32) # shape: (2, samples) except Exception as e: print(f"Failed to load ident: {e}") return @@ -1039,18 +1049,21 @@ class AudioService: num_channels = 2 device = None device_sr = self.output_sample_rate - channel_idx = 0 + ch_l = 0 + ch_r = 1 else: device_info = sd.query_devices(self.output_device) num_channels = device_info['max_output_channels'] device_sr = int(device_info['default_samplerate']) device = self.output_device - channel_idx = min(self.ad_channel, num_channels) - 1 + ch_l = min(self.ident_channel, num_channels) - 1 + ch_r = min(self.ident_channel + 1, num_channels) - 1 if self.output_sample_rate != device_sr: - self._ident_resampled = librosa.resample( - self._ident_data, orig_sr=self.output_sample_rate, target_sr=device_sr - ).astype(np.float32) + self._ident_resampled = np.stack([ + librosa.resample(self._ident_data[0], orig_sr=self.output_sample_rate, target_sr=device_sr), + librosa.resample(self._ident_data[1], orig_sr=self.output_sample_rate, target_sr=device_sr), + ]).astype(np.float32) else: self._ident_resampled = self._ident_data @@ -1059,16 +1072,21 @@ class AudioService: if not self._ident_playing or self._ident_resampled is None: return - remaining = len(self._ident_resampled) - self._ident_position + n_samples = self._ident_resampled.shape[1] + remaining = n_samples - self._ident_position if remaining >= frames: - chunk = self._ident_resampled[self._ident_position:self._ident_position + frames] - outdata[:, channel_idx] = chunk + chunk_l = self._ident_resampled[0, self._ident_position:self._ident_position + frames] + chunk_r = self._ident_resampled[1, self._ident_position:self._ident_position + frames] + outdata[:, ch_l] = chunk_l + outdata[:, ch_r] = chunk_r if self.stem_recorder: - self.stem_recorder.write_sporadic("idents", chunk.copy(), device_sr) + mono_mix = (chunk_l + chunk_r) * 0.5 + self.stem_recorder.write_sporadic("idents", mono_mix.copy(), device_sr) self._ident_position += frames else: if remaining > 0: - outdata[:remaining, channel_idx] = self._ident_resampled[self._ident_position:] + outdata[:remaining, ch_l] = self._ident_resampled[0, self._ident_position:] + outdata[:remaining, ch_r] = self._ident_resampled[1, self._ident_position:] self._ident_playing = False try: @@ -1081,7 +1099,7 @@ class AudioService: blocksize=2048 ) self._ident_stream.start() - print(f"Ident playback started on ch {self.ad_channel} @ {device_sr}Hz") + print(f"Ident playback started on ch {self.ident_channel}/{self.ident_channel + 1} @ {device_sr}Hz") except Exception as e: print(f"Ident playback error: {e}") self._ident_playing = False diff --git a/frontend/index.html b/frontend/index.html index f125b9d..14e9bfe 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -174,6 +174,7 @@ + diff --git a/frontend/js/app.js b/frontend/js/app.js index 94914e1..5635cc1 100644 --- a/frontend/js/app.js +++ b/frontend/js/app.js @@ -343,6 +343,7 @@ async function loadAudioDevices() { const musicCh = document.getElementById('music-channel'); const sfxCh = document.getElementById('sfx-channel'); const adCh = document.getElementById('ad-channel'); + const identCh = document.getElementById('ident-channel'); if (inputCh) inputCh.value = settings.input_channel || 1; if (callerCh) callerCh.value = settings.caller_channel || 1; @@ -350,6 +351,7 @@ async function loadAudioDevices() { if (musicCh) musicCh.value = settings.music_channel || 2; if (sfxCh) sfxCh.value = settings.sfx_channel || 3; if (adCh) adCh.value = settings.ad_channel || 11; + if (identCh) identCh.value = settings.ident_channel || 15; // Phone filter setting const phoneFilterEl = document.getElementById('phone-filter'); @@ -374,6 +376,7 @@ async function saveAudioDevices() { const musicChannel = document.getElementById('music-channel')?.value; const sfxChannel = document.getElementById('sfx-channel')?.value; const adChannel = document.getElementById('ad-channel')?.value; + const identChannel = document.getElementById('ident-channel')?.value; const phoneFilterChecked = document.getElementById('phone-filter')?.checked ?? false; await fetch('/api/audio/settings', { @@ -388,6 +391,7 @@ async function saveAudioDevices() { music_channel: musicChannel ? parseInt(musicChannel) : 2, sfx_channel: sfxChannel ? parseInt(sfxChannel) : 3, ad_channel: adChannel ? parseInt(adChannel) : 11, + ident_channel: identChannel ? parseInt(identChannel) : 15, phone_filter: phoneFilterChecked }) });