From 9452b07c5c4548ffc25ef58a50cd69b2f89106fa Mon Sep 17 00:00:00 2001 From: tcpsyn Date: Fri, 6 Feb 2026 22:35:07 -0700 Subject: [PATCH] Ads play once on channel 11, separate from music - Add dedicated ad playback system (no loop, own channel) - Ad channel defaults to 11, saved/loaded with audio settings - Separate play_ad/stop_ad methods and API endpoints - Frontend stop button now calls /api/ads/stop instead of stopMusic Co-Authored-By: Claude Opus 4.6 --- backend/main.py | 12 ++++-- backend/services/audio.py | 91 ++++++++++++++++++++++++++++++++++++++- frontend/js/app.js | 6 ++- 3 files changed, 104 insertions(+), 5 deletions(-) diff --git a/backend/main.py b/backend/main.py index 509e595..522f4df 100644 --- a/backend/main.py +++ b/backend/main.py @@ -1298,16 +1298,22 @@ async def get_ads(): @app.post("/api/ads/play") async def play_ad(request: MusicRequest): - """Play an ad on the music channel""" + """Play an ad once on the ad channel (ch 11)""" ad_path = settings.ads_dir / request.track if not ad_path.exists(): raise HTTPException(404, "Ad not found") - audio_service.load_music(str(ad_path)) - audio_service.play_music() + audio_service.play_ad(str(ad_path)) return {"status": "playing", "track": request.track} +@app.post("/api/ads/stop") +async def stop_ad(): + """Stop ad playback""" + audio_service.stop_ad() + return {"status": "stopped"} + + # --- LLM Settings Endpoints --- @app.get("/api/settings") diff --git a/backend/services/audio.py b/backend/services/audio.py index 55cf705..2fd6985 100644 --- a/backend/services/audio.py +++ b/backend/services/audio.py @@ -27,8 +27,16 @@ class AudioService: self.live_caller_channel: int = 9 # Channel for live caller audio self.music_channel: int = 2 # Channel for music self.sfx_channel: int = 3 # Channel for SFX + self.ad_channel: int = 11 # Channel for ads self.phone_filter: bool = False # Phone filter on caller voices + # Ad playback state + self._ad_stream: Optional[sd.OutputStream] = None + self._ad_data: Optional[np.ndarray] = None + self._ad_resampled: Optional[np.ndarray] = None + self._ad_position: int = 0 + self._ad_playing: bool = False + # Recording state self._recording = False self._record_thread: Optional[threading.Thread] = None @@ -78,8 +86,9 @@ class AudioService: self.live_caller_channel = data.get("live_caller_channel", 4) 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.phone_filter = data.get("phone_filter", False) - print(f"Loaded audio settings: output={self.output_device}, channels={self.caller_channel}/{self.live_caller_channel}/{self.music_channel}/{self.sfx_channel}, phone_filter={self.phone_filter}") + print(f"Loaded audio settings: output={self.output_device}, channels={self.caller_channel}/{self.live_caller_channel}/{self.music_channel}/{self.sfx_channel}/ad:{self.ad_channel}, phone_filter={self.phone_filter}") except Exception as e: print(f"Failed to load audio settings: {e}") @@ -94,6 +103,7 @@ class AudioService: "live_caller_channel": self.live_caller_channel, "music_channel": self.music_channel, "sfx_channel": self.sfx_channel, + "ad_channel": self.ad_channel, "phone_filter": self.phone_filter, } with open(SETTINGS_FILE, "w") as f: @@ -655,6 +665,85 @@ class AudioService: self._music_position = 0 print("Music stopped") + def play_ad(self, file_path: str): + """Load and play an ad file once (no loop) on the ad channel""" + import librosa + + path = Path(file_path) + if not path.exists(): + print(f"Ad file not found: {file_path}") + return + + self.stop_ad() + + try: + audio, sr = librosa.load(str(path), sr=self.output_sample_rate, mono=True) + self._ad_data = audio.astype(np.float32) + except Exception as e: + print(f"Failed to load ad: {e}") + return + + self._ad_playing = True + self._ad_position = 0 + + if self.output_device is None: + num_channels = 2 + device = None + device_sr = self.output_sample_rate + channel_idx = 0 + 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 + + if self.output_sample_rate != device_sr: + self._ad_resampled = librosa.resample( + self._ad_data, orig_sr=self.output_sample_rate, target_sr=device_sr + ).astype(np.float32) + else: + self._ad_resampled = self._ad_data + + def callback(outdata, frames, time_info, status): + outdata[:] = 0 + if not self._ad_playing or self._ad_resampled is None: + return + + remaining = len(self._ad_resampled) - self._ad_position + if remaining >= frames: + outdata[:, channel_idx] = self._ad_resampled[self._ad_position:self._ad_position + frames] + self._ad_position += frames + else: + if remaining > 0: + outdata[:remaining, channel_idx] = self._ad_resampled[self._ad_position:] + # Ad finished — no loop + self._ad_playing = False + + try: + self._ad_stream = sd.OutputStream( + device=device, + channels=num_channels, + samplerate=device_sr, + dtype=np.float32, + callback=callback, + blocksize=2048 + ) + self._ad_stream.start() + print(f"Ad playback started on ch {self.ad_channel} @ {device_sr}Hz") + except Exception as e: + print(f"Ad playback error: {e}") + self._ad_playing = False + + def stop_ad(self): + """Stop ad playback""" + self._ad_playing = False + if self._ad_stream: + self._ad_stream.stop() + self._ad_stream.close() + self._ad_stream = None + self._ad_position = 0 + def set_music_volume(self, volume: float): """Set music volume (0.0 to 1.0)""" self._music_volume = max(0.0, min(1.0, volume)) diff --git a/frontend/js/app.js b/frontend/js/app.js index aab3713..11f29b3 100644 --- a/frontend/js/app.js +++ b/frontend/js/app.js @@ -131,7 +131,7 @@ function initEventListeners() { // Ads document.getElementById('ad-play-btn')?.addEventListener('click', playAd); - document.getElementById('ad-stop-btn')?.addEventListener('click', stopMusic); + document.getElementById('ad-stop-btn')?.addEventListener('click', stopAd); // Settings document.getElementById('settings-btn')?.addEventListener('click', async () => { @@ -686,6 +686,10 @@ async function playAd() { }); } +async function stopAd() { + await fetch('/api/ads/stop', { method: 'POST' }); +} + // --- Sound Effects (Server-Side) --- async function loadSounds() {