diff --git a/.gitignore b/.gitignore index 291ecb537d..403fc5b28d 100644 --- a/.gitignore +++ b/.gitignore @@ -13,6 +13,9 @@ coverage.xml venv/ .claude/ +# Swift build artifacts +buzz/screen_capture/.build/ + # whisper_cpp whisper_cpp *.exe diff --git a/buzz/audio_source.py b/buzz/audio_source.py new file mode 100644 index 0000000000..376c101b06 --- /dev/null +++ b/buzz/audio_source.py @@ -0,0 +1,156 @@ +import abc +import logging +import os +import subprocess +import threading +from dataclasses import dataclass +from typing import Callable, Optional + +import numpy as np + +from buzz.assets import APP_BASE_DIR + + +SCREEN_AUDIO_HELPER_NAME = "buzz-screen-audio" + + +def get_screen_audio_helper_path() -> Optional[str]: + """Locate the buzz-screen-audio helper binary. + + Follows the same two-level resolution pattern used for whisper-cli + in recording_transcriber.py and whisper_cpp.py. + """ + path = os.path.join(APP_BASE_DIR, "whisper_cpp", SCREEN_AUDIO_HELPER_NAME) + if os.path.isfile(path): + return path + path = os.path.join(APP_BASE_DIR, "buzz", "whisper_cpp", SCREEN_AUDIO_HELPER_NAME) + if os.path.isfile(path): + return path + return None + + +@dataclass +class AudioSourceConfig: + """Describes which audio source a RecordingTranscriber should use. + + Passed from the widget to the transcriber so that the actual AudioSource + is created inside ``RecordingTranscriber.start()`` where + ``stream_callback`` is available. + """ + + source_type: str # "sounddevice" or "screen_capture" + device_index: Optional[int] = None # For sounddevice + helper_path: Optional[str] = None # For screen_capture + + +class AudioSource(abc.ABC): + """Abstract audio source providing a context-manager interface. + + Matches how ``sounddevice.InputStream`` is used in + ``RecordingTranscriber.start()`` — enter starts audio, exit stops it. + While active, the source calls *callback(in_data, frame_count, + time_info, status)* with numpy arrays in the same shape that + sounddevice produces: ``(N, 1)`` float32. + """ + + @abc.abstractmethod + def __enter__(self): + pass + + @abc.abstractmethod + def __exit__(self, exc_type, exc_val, exc_tb): + pass + + +class SoundDeviceAudioSource(AudioSource): + """Thin wrapper around ``sounddevice.InputStream``. + + Drop-in replacement for the previous inline construction so + existing mic recording behaviour is unchanged. + """ + + def __init__(self, sounddevice_module, samplerate: int, + device: Optional[int], dtype: str, channels: int, + callback: Callable): + self._stream = sounddevice_module.InputStream( + samplerate=samplerate, + device=device, + dtype=dtype, + channels=channels, + callback=callback, + ) + + def __enter__(self): + self._stream.__enter__() + return self + + def __exit__(self, exc_type, exc_val, exc_tb): + return self._stream.__exit__(exc_type, exc_val, exc_tb) + + +class ScreenCaptureAudioSource(AudioSource): + """Reads raw float32 mono 16 kHz PCM from the Swift helper's stdout. + + The helper binary (``buzz-screen-audio``) uses ScreenCaptureKit to + capture system audio, downsamples to 16 kHz mono float32, and writes + raw samples to stdout. This class spawns the helper, reads its + output in a daemon thread, and feeds chunks to the same *callback* + signature that ``sounddevice.InputStream`` uses. + """ + + def __init__(self, helper_path: str, sample_rate: int, + callback: Callable): + self._helper_path = helper_path + self._sample_rate = sample_rate + self._callback = callback + self._process: Optional[subprocess.Popen] = None + self._reader_thread: Optional[threading.Thread] = None + self._stop_event = threading.Event() + + def __enter__(self): + self._stop_event.clear() + self._process = subprocess.Popen( + [self._helper_path, "--sample-rate", str(self._sample_rate)], + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + bufsize=0, + ) + self._reader_thread = threading.Thread( + target=self._read_loop, daemon=True, name="screen-capture-reader" + ) + self._reader_thread.start() + return self + + def _read_loop(self): + """Continuously read raw PCM from the helper and invoke the callback.""" + # Read in ~100 ms chunks (matches typical sounddevice callback cadence) + chunk_samples = self._sample_rate // 10 + chunk_bytes = chunk_samples * 4 # float32 = 4 bytes per sample + + try: + while not self._stop_event.is_set(): + data = self._process.stdout.read(chunk_bytes) + if not data: + break + # Pad the last partial chunk with silence if needed + if len(data) < chunk_bytes: + data = data + b"\x00" * (chunk_bytes - len(data)) + samples = np.frombuffer(data, dtype=np.float32).copy() + # Reshape to (N, 1) to match sounddevice convention + samples = samples.reshape(-1, 1) + self._callback(samples, len(samples), None, None) + except Exception: + logging.exception("Error reading from screen capture helper") + + def __exit__(self, exc_type, exc_val, exc_tb): + self._stop_event.set() + if self._process is not None and self._process.poll() is None: + self._process.terminate() + try: + self._process.wait(timeout=5) + except subprocess.TimeoutExpired: + self._process.kill() + self._process.wait(timeout=2) + if self._reader_thread is not None: + self._reader_thread.join(timeout=5) + return False diff --git a/buzz/screen_capture/Package.swift b/buzz/screen_capture/Package.swift new file mode 100644 index 0000000000..879047e31d --- /dev/null +++ b/buzz/screen_capture/Package.swift @@ -0,0 +1,20 @@ +// swift-tools-version: 5.9 +import PackageDescription + +let package = Package( + name: "BuzzScreenAudio", + platforms: [.macOS(.v12)], + targets: [ + .executableTarget( + name: "buzz-screen-audio", + path: "Sources", + linkerSettings: [ + .linkedFramework("ScreenCaptureKit"), + .linkedFramework("CoreMedia"), + .linkedFramework("CoreAudio"), + .linkedFramework("Accelerate"), + .linkedFramework("AVFoundation"), + ] + ) + ] +) diff --git a/buzz/screen_capture/Sources/main.swift b/buzz/screen_capture/Sources/main.swift new file mode 100644 index 0000000000..d8dd60fc00 --- /dev/null +++ b/buzz/screen_capture/Sources/main.swift @@ -0,0 +1,315 @@ +import Accelerate +import CoreMedia +import Foundation +import ScreenCaptureKit + +// MARK: - Configuration + +let targetSampleRate: Double = 16000 +let nativeChannels: Int = 1 + +// MARK: - Argument Parsing + +func parseArgs() -> (checkPermission: Bool, sampleRate: Int) { + let args = CommandLine.arguments + var checkPermission = false + var sampleRate = 16000 + + var i = 1 + while i < args.count { + switch args[i] { + case "--check-permission": + checkPermission = true + case "--sample-rate": + if i + 1 < args.count, let rate = Int(args[i + 1]) { + sampleRate = rate + i += 1 + } + default: + break + } + i += 1 + } + return (checkPermission, sampleRate) +} + +// MARK: - Error Reporting (JSON to stderr) + +func reportError(_ message: String) { + let json = "{\"error\": \"\(message)\"}\n" + if let data = json.data(using: .utf8) { + FileHandle.standardError.write(data) + } +} + +func reportStatus(_ message: String) { + let json = "{\"status\": \"\(message)\"}\n" + if let data = json.data(using: .utf8) { + FileHandle.standardError.write(data) + } +} + +// MARK: - Resampler + +/// Downsample audio from a source sample rate to the target rate using vDSP. +func resample( + input: UnsafeBufferPointer, from sourceSampleRate: Double, + to destSampleRate: Double +) -> [Float] { + guard sourceSampleRate > destSampleRate else { + return Array(input) + } + + let ratio = sourceSampleRate / destSampleRate + let outputCount = Int(Double(input.count) / ratio) + guard outputCount > 0 else { return [] } + + var output = [Float](repeating: 0, count: outputCount) + + // Generate the indices into the source buffer for each output sample + var control = [Float](repeating: 0, count: outputCount) + var start = Float(0) + var step = Float(ratio) + vDSP_vramp(&start, &step, &control, 1, vDSP_Length(outputCount)) + + // Ensure we don't read past the end of the source buffer + let maxIndex = Float(input.count - 2) + for i in 0...size, + blockBufferAllocator: nil, + blockBufferMemoryAllocator: nil, + flags: kCMSampleBufferFlag_AudioBufferList_Assure16ByteAlignment, + blockBufferOut: &blockBuffer + ) + + guard status == noErr else { return } + + let buffer = audioBufferList.mBuffers + guard let data = buffer.mData else { return } + let frameCount = + Int(buffer.mDataByteSize) / MemoryLayout.size / sourceChannels + + let floatPtr = data.bindMemory( + to: Float.self, capacity: frameCount * sourceChannels) + + // Mix down to mono if multichannel + var monoSamples: [Float] + if sourceChannels > 1 { + monoSamples = [Float](repeating: 0, count: frameCount) + for frame in 0.. 1.0 { + outputSamples = monoSamples.withUnsafeBufferPointer { buf in + resample(input: buf, from: sourceSampleRate, to: targetRate) + } + } else { + outputSamples = monoSamples + } + + // Write raw float32 to stdout + outputSamples.withUnsafeBytes { rawBuf in + let data = Data(rawBuf) + stdoutHandle.write(data) + } + } +} + +// MARK: - Permission Check + +@available(macOS 12.3, *) +func checkPermission() { + let semaphore = DispatchSemaphore(value: 0) + var permissionGranted = false + + SCShareableContent.getExcludingDesktopWindows( + false, onScreenWindowsOnly: false + ) { content, error in + if error != nil { + permissionGranted = false + } else { + permissionGranted = content != nil + } + semaphore.signal() + } + + let result = semaphore.wait(timeout: .now() + 10) + if result == .timedOut { + reportError("permission_check_timeout") + exit(1) + } + + if permissionGranted { + reportStatus("permission_granted") + exit(0) + } else { + reportError("screen_recording_permission_denied") + exit(1) + } +} + +// MARK: - Main Capture Loop + +@available(macOS 13.0, *) +func startCapture(sampleRate: Int) { + // Get shareable content to create a filter + SCShareableContent.getExcludingDesktopWindows( + false, onScreenWindowsOnly: false + ) { content, error in + if let error = error { + reportError( + "failed_to_get_content: \(error.localizedDescription)") + exit(1) + } + + guard let content = content else { + reportError("no_shareable_content") + exit(1) + } + + // Use the first display as the capture target (required even for audio-only) + guard let display = content.displays.first else { + reportError("no_display_found") + exit(1) + } + + // Create filter excluding current process audio + let filter = SCContentFilter( + display: display, + excludingApplications: content.applications.filter { + $0.bundleIdentifier == Bundle.main.bundleIdentifier + }, + exceptingWindows: [] + ) + + // Configure for audio-only capture + let config = SCStreamConfiguration() + config.capturesAudio = true + config.excludesCurrentProcessAudio = true + config.channelCount = 1 + config.sampleRate = sampleRate + + // Minimize video overhead — we only want audio + config.width = 2 + config.height = 2 + config.minimumFrameInterval = CMTime(value: 1, timescale: 1) + + let delegate = AudioCaptureDelegate(targetRate: Double(sampleRate)) + + do { + let stream = SCStream( + filter: filter, configuration: config, delegate: nil) + try stream.addStreamOutput( + delegate, type: .audio, + sampleHandlerQueue: DispatchQueue( + label: "buzz.screen-audio", qos: .userInteractive)) + + stream.startCapture { error in + if let error = error { + reportError( + "capture_start_failed: \(error.localizedDescription)") + exit(1) + } + reportStatus("capturing") + } + } catch { + reportError( + "stream_setup_failed: \(error.localizedDescription)") + exit(1) + } + } + + // Handle SIGTERM and SIGINT for clean shutdown + signal(SIGTERM) { _ in + reportStatus("stopped") + exit(0) + } + signal(SIGINT) { _ in + reportStatus("stopped") + exit(0) + } + + // Keep the process alive + dispatchMain() +} + +// MARK: - Entry Point + +let (checkPerm, sampleRate) = parseArgs() + +if #available(macOS 13.0, *) { + if checkPerm { + checkPermission() + } else { + startCapture(sampleRate: sampleRate) + } +} else if #available(macOS 12.3, *) { + if checkPerm { + checkPermission() + } else { + reportError("macos_13_0_required_for_audio_capture") + exit(1) + } +} else { + reportError("macos_12_3_required") + exit(1) +} diff --git a/buzz/settings/settings.py b/buzz/settings/settings.py index 7ec768fd61..6b99ab66ee 100644 --- a/buzz/settings/settings.py +++ b/buzz/settings/settings.py @@ -25,6 +25,7 @@ class Key(enum.Enum): RECORDING_TRANSCRIBER_EXPORT_ENABLED = "recording-transcriber/export-enabled" RECORDING_TRANSCRIBER_EXPORT_FOLDER = "recording-transcriber/export-folder" RECORDING_TRANSCRIBER_MODE = "recording-transcriber/mode" + RECORDING_TRANSCRIBER_AUDIO_SOURCE = "recording-transcriber/audio-source" PRESENTATION_WINDOW_TEXT_COLOR = "presentation-window/text-color" PRESENTATION_WINDOW_BACKGROUND_COLOR = "presentation-window/background-color" diff --git a/buzz/transcriber/recording_transcriber.py b/buzz/transcriber/recording_transcriber.py index fda4d56e91..cf98aa3e59 100644 --- a/buzz/transcriber/recording_transcriber.py +++ b/buzz/transcriber/recording_transcriber.py @@ -24,6 +24,7 @@ from buzz import whisper_audio from buzz.locale import _ from buzz.assets import APP_BASE_DIR +from buzz.audio_source import AudioSourceConfig, SoundDeviceAudioSource, ScreenCaptureAudioSource from buzz.model_loader import ModelType, map_language_to_mms from buzz.settings.settings import Settings from buzz.transcriber.transcriber import TranscriptionOptions, Task @@ -49,6 +50,8 @@ def __init__( sample_rate: int, model_path: str, sounddevice: sounddevice, + audio_source_config: Optional[AudioSourceConfig] = None, + shared_openai_client: Optional["OpenAI"] = None, parent: Optional[QObject] = None, ) -> None: super().__init__(parent) @@ -70,7 +73,8 @@ def __init__( self.queue = np.ndarray([], dtype=np.float32) self.mutex = threading.Lock() self.sounddevice = sounddevice - self.openai_client = None + self.audio_source_config = audio_source_config + self.openai_client = shared_openai_client self.whisper_api_model = self.settings.value( key=Settings.Key.OPENAI_API_MODEL, default_value="whisper-1" ) @@ -92,7 +96,9 @@ def start(self): device = "cuda" if use_cuda else "cpu" model = whisper.load_model(model_path, device=device) elif self.transcription_options.model.model_type == ModelType.WHISPER_CPP: - self.start_local_whisper_server() + # Skip server start if a shared client was provided (dual-stream mode) + if self.openai_client is None: + self.start_local_whisper_server() if self.openai_client is None: if not self.is_running: self.finished.emit() @@ -164,13 +170,25 @@ def start(self): ) try: - with self.sounddevice.InputStream( - samplerate=self.sample_rate, - device=self.input_device_index, - dtype="float32", - channels=1, - callback=self.stream_callback, - ): + # Build the audio source based on config + if (self.audio_source_config is not None + and self.audio_source_config.source_type == "screen_capture"): + source = ScreenCaptureAudioSource( + helper_path=self.audio_source_config.helper_path, + sample_rate=self.sample_rate, + callback=self.stream_callback, + ) + else: + source = SoundDeviceAudioSource( + sounddevice_module=self.sounddevice, + samplerate=self.sample_rate, + device=self.input_device_index, + dtype="float32", + channels=1, + callback=self.stream_callback, + ) + + with source: while self.is_running: if self.queue.size >= self.n_batch_samples: self.mutex.acquire() @@ -313,7 +331,7 @@ def start(self): else: time.sleep(0.5) - except PortAudioError as exc: + except (PortAudioError, OSError) as exc: self.error.emit(str(exc)) logging.exception("") return diff --git a/buzz/widgets/recording_transcriber_widget.py b/buzz/widgets/recording_transcriber_widget.py index 26cb92c051..f7653277e7 100644 --- a/buzz/widgets/recording_transcriber_widget.py +++ b/buzz/widgets/recording_transcriber_widget.py @@ -1,5 +1,6 @@ import os import re +import sys import enum import requests import logging @@ -24,6 +25,7 @@ QColorDialog ) +from buzz.audio_source import AudioSourceConfig, get_screen_audio_helper_path from buzz.dialogs import show_model_download_error_dialog from buzz.locale import _ from buzz.model_loader import ( @@ -155,6 +157,27 @@ def __init__( self.audio_devices_combo_box.device_changed.connect(self.on_device_changed) self.selected_device_id = self.audio_devices_combo_box.get_default_device_id() + # Audio source selector (Microphone / Screen Audio) + self.audio_source_combo_box = QComboBox(self) + self.audio_source_combo_box.addItem(_("Microphone"), "sounddevice") + self._screen_audio_helper_path: Optional[str] = None + if sys.platform == "darwin": + helper_path = get_screen_audio_helper_path() + if helper_path is not None: + self._screen_audio_helper_path = helper_path + self.audio_source_combo_box.addItem(_("Screen Audio"), "screen_capture") + saved_audio_source = self.settings.value( + key=Settings.Key.RECORDING_TRANSCRIBER_AUDIO_SOURCE, + default_value="sounddevice", + ) + source_index = self.audio_source_combo_box.findData(saved_audio_source) + if source_index >= 0: + self.audio_source_combo_box.setCurrentIndex(source_index) + self.audio_source_combo_box.currentIndexChanged.connect( + self.on_audio_source_changed + ) + self._apply_audio_source_visibility() + self.record_button = RecordButton(self) self.record_button.clicked.connect(self.on_record_button_clicked) self.reset_transcriber_controls() @@ -175,7 +198,9 @@ def __init__( ) recording_options_layout = QFormLayout() - recording_options_layout.addRow(_("Microphone:"), self.audio_devices_combo_box) + recording_options_layout.addRow(_("Audio Source:"), self.audio_source_combo_box) + self.microphone_label = QLabel(_("Microphone:")) + recording_options_layout.addRow(self.microphone_label, self.audio_devices_combo_box) self.audio_meter_widget = AudioMeterWidget(self) @@ -482,6 +507,18 @@ def reset_transcriber_controls(self): self.record_button.setEnabled(button_enabled) + def _get_selected_audio_source(self) -> str: + return self.audio_source_combo_box.currentData() or "sounddevice" + + def on_audio_source_changed(self, _index: int): + self._apply_audio_source_visibility() + self.reset_recording_amplitude_listener() + + def _apply_audio_source_visibility(self): + is_mic = self._get_selected_audio_source() == "sounddevice" + self.microphone_label.setVisible(is_mic) + self.audio_devices_combo_box.setVisible(is_mic) + def on_device_changed(self, device_id: int): self.selected_device_id = device_id self.reset_recording_amplitude_listener() @@ -490,6 +527,10 @@ def reset_recording_amplitude_listener(self): if self.recording_amplitude_listener is not None: self.recording_amplitude_listener.stop_recording() + # No amplitude preview for screen audio (no mic) + if self._get_selected_audio_source() == "screen_capture": + return + # Listening to audio will fail if there are no input devices if self.selected_device_id is None or self.selected_device_id == -1: return @@ -526,6 +567,7 @@ def on_record_button_clicked(self): self.record_button.set_recording() self.transcription_options_group_box.setEnabled(False) self.audio_devices_combo_box.setEnabled(False) + self.audio_source_combo_box.setEnabled(False) self.presentation_options_bar.show() self.copy_actions_bar.hide() @@ -567,12 +609,27 @@ def on_model_loaded(self, model_path: str): self.transcription_thread = QThread() + audio_source = self._get_selected_audio_source() + if audio_source == "screen_capture": + audio_source_config = AudioSourceConfig( + source_type="screen_capture", + helper_path=self._screen_audio_helper_path, + ) + sample_rate = 16_000 # helper outputs 16 kHz + else: + audio_source_config = AudioSourceConfig( + source_type="sounddevice", + device_index=self.selected_device_id, + ) + sample_rate = self.device_sample_rate + self.transcriber = RecordingTranscriber( input_device_index=self.selected_device_id, - sample_rate=self.device_sample_rate, + sample_rate=sample_rate, transcription_options=self.transcription_options, model_path=model_path, sounddevice=self.sounddevice, + audio_source_config=audio_source_config, ) self.transcriber.moveToThread(self.transcription_thread) @@ -644,6 +701,7 @@ def set_recording_status_stopped(self): self.current_status = self.RecordingStatus.STOPPED self.transcription_options_group_box.setEnabled(True) self.audio_devices_combo_box.setEnabled(True) + self.audio_source_combo_box.setEnabled(True) self.presentation_options_bar.hide() self.copy_actions_bar.show() #added this here @@ -914,6 +972,10 @@ def closeEvent(self, event: QCloseEvent) -> None: if not self.translation_thread.wait(45_000): logging.warning("Translation thread did not finish within timeout") + self.settings.set_value( + Settings.Key.RECORDING_TRANSCRIBER_AUDIO_SOURCE, + self._get_selected_audio_source(), + ) self.settings.set_value( Settings.Key.RECORDING_TRANSCRIBER_LANGUAGE, self.transcription_options.language, diff --git a/tests/test_audio_source.py b/tests/test_audio_source.py new file mode 100644 index 0000000000..91136dd76b --- /dev/null +++ b/tests/test_audio_source.py @@ -0,0 +1,221 @@ +import struct +import subprocess +import threading +import time +from unittest.mock import MagicMock, patch, PropertyMock + +import numpy as np +import pytest + +from buzz.audio_source import ( + AudioSourceConfig, + ScreenCaptureAudioSource, + SoundDeviceAudioSource, + get_screen_audio_helper_path, +) + + +class TestAudioSourceConfig: + def test_sounddevice_config(self): + config = AudioSourceConfig(source_type="sounddevice", device_index=3) + assert config.source_type == "sounddevice" + assert config.device_index == 3 + assert config.helper_path is None + + def test_screen_capture_config(self): + config = AudioSourceConfig( + source_type="screen_capture", + helper_path="/usr/local/bin/buzz-screen-audio", + ) + assert config.source_type == "screen_capture" + assert config.helper_path == "/usr/local/bin/buzz-screen-audio" + assert config.device_index is None + + +class TestSoundDeviceAudioSource: + def test_delegates_to_sounddevice_input_stream(self): + mock_sd = MagicMock() + mock_stream = MagicMock() + mock_sd.InputStream.return_value = mock_stream + callback = MagicMock() + + source = SoundDeviceAudioSource( + sounddevice_module=mock_sd, + samplerate=16000, + device=3, + dtype="float32", + channels=1, + callback=callback, + ) + + mock_sd.InputStream.assert_called_once_with( + samplerate=16000, + device=3, + dtype="float32", + channels=1, + callback=callback, + ) + + def test_context_manager_delegates(self): + mock_sd = MagicMock() + mock_stream = MagicMock() + mock_sd.InputStream.return_value = mock_stream + callback = MagicMock() + + source = SoundDeviceAudioSource( + sounddevice_module=mock_sd, + samplerate=16000, + device=3, + dtype="float32", + channels=1, + callback=callback, + ) + + with source: + mock_stream.__enter__.assert_called_once() + + mock_stream.__exit__.assert_called_once() + + +class TestScreenCaptureAudioSource: + def _make_pcm_data(self, num_samples: int) -> bytes: + """Create raw float32 PCM bytes.""" + samples = np.random.uniform(-1.0, 1.0, num_samples).astype(np.float32) + return samples.tobytes() + + @patch("buzz.audio_source.subprocess.Popen") + def test_spawns_helper_and_reads_audio(self, mock_popen): + sample_rate = 16000 + chunk_samples = sample_rate // 10 # 1600 samples per chunk + num_chunks = 3 + + # Prepare canned PCM data: enough for num_chunks reads then EOF + pcm_data = self._make_pcm_data(chunk_samples * num_chunks) + + mock_process = MagicMock() + mock_process.poll.return_value = None + + # Mock stdout.read to return chunks then empty bytes (EOF) + read_calls = [] + offset = 0 + chunk_bytes = chunk_samples * 4 + while offset < len(pcm_data): + read_calls.append(pcm_data[offset : offset + chunk_bytes]) + offset += chunk_bytes + read_calls.append(b"") # EOF + + mock_process.stdout.read = MagicMock(side_effect=read_calls) + mock_process.stderr = MagicMock() + mock_popen.return_value = mock_process + + received_chunks = [] + callback = MagicMock(side_effect=lambda in_data, *args: received_chunks.append(in_data.copy())) + + source = ScreenCaptureAudioSource( + helper_path="/fake/buzz-screen-audio", + sample_rate=sample_rate, + callback=callback, + ) + + with source: + # Wait for reader thread to process all chunks + time.sleep(0.5) + + assert len(received_chunks) == num_chunks + for chunk in received_chunks: + assert chunk.dtype == np.float32 + assert chunk.shape == (chunk_samples, 1) + + @patch("buzz.audio_source.subprocess.Popen") + def test_terminates_process_on_exit(self, mock_popen): + mock_process = MagicMock() + mock_process.poll.return_value = None + # stdout.read blocks until stop + mock_process.stdout.read = MagicMock(side_effect=lambda _: b"") + mock_process.stderr = MagicMock() + mock_popen.return_value = mock_process + + source = ScreenCaptureAudioSource( + helper_path="/fake/buzz-screen-audio", + sample_rate=16000, + callback=MagicMock(), + ) + + with source: + pass + + mock_process.terminate.assert_called_once() + + @patch("buzz.audio_source.subprocess.Popen") + def test_passes_sample_rate_arg(self, mock_popen): + mock_process = MagicMock() + mock_process.poll.return_value = None + mock_process.stdout.read = MagicMock(return_value=b"") + mock_process.stderr = MagicMock() + mock_popen.return_value = mock_process + + source = ScreenCaptureAudioSource( + helper_path="/fake/buzz-screen-audio", + sample_rate=16000, + callback=MagicMock(), + ) + + with source: + pass + + mock_popen.assert_called_once_with( + ["/fake/buzz-screen-audio", "--sample-rate", "16000"], + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + bufsize=0, + ) + + @patch("buzz.audio_source.subprocess.Popen") + def test_handles_partial_chunk(self, mock_popen): + """Verify partial reads are zero-padded to full chunk size.""" + sample_rate = 16000 + chunk_samples = sample_rate // 10 # 1600 + chunk_bytes = chunk_samples * 4 + + # Send a partial chunk (half a chunk) then EOF + partial_data = self._make_pcm_data(chunk_samples // 2) + mock_process = MagicMock() + mock_process.poll.return_value = None + mock_process.stdout.read = MagicMock(side_effect=[partial_data, b""]) + mock_process.stderr = MagicMock() + mock_popen.return_value = mock_process + + received = [] + callback = MagicMock(side_effect=lambda in_data, *args: received.append(in_data.copy())) + + source = ScreenCaptureAudioSource( + helper_path="/fake/buzz-screen-audio", + sample_rate=sample_rate, + callback=callback, + ) + + with source: + time.sleep(0.3) + + # Should get one padded chunk + assert len(received) == 1 + assert received[0].shape == (chunk_samples, 1) + + +class TestGetScreenAudioHelperPath: + @patch("buzz.audio_source.os.path.isfile") + @patch("buzz.audio_source.APP_BASE_DIR", "/app") + def test_finds_primary_path(self, mock_isfile): + mock_isfile.side_effect = lambda p: p == "/app/whisper_cpp/buzz-screen-audio" + assert get_screen_audio_helper_path() == "/app/whisper_cpp/buzz-screen-audio" + + @patch("buzz.audio_source.os.path.isfile") + @patch("buzz.audio_source.APP_BASE_DIR", "/app") + def test_finds_fallback_path(self, mock_isfile): + mock_isfile.side_effect = lambda p: p == "/app/buzz/whisper_cpp/buzz-screen-audio" + assert get_screen_audio_helper_path() == "/app/buzz/whisper_cpp/buzz-screen-audio" + + @patch("buzz.audio_source.os.path.isfile", return_value=False) + @patch("buzz.audio_source.APP_BASE_DIR", "/app") + def test_returns_none_when_not_found(self, mock_isfile): + assert get_screen_audio_helper_path() is None