Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -24,3 +24,7 @@ settings.json
# IDE
.vscode/
.idea/
*.mp3
*.wav
*.png
debug_com.py
27 changes: 19 additions & 8 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,10 +13,13 @@ It sits quietly in your system tray and is always ready with a single click or g
<img width="575" height="724" alt="image" src="https://github.com/user-attachments/assets/b35131bc-1ff8-41e1-87b5-1e472f9da981" />


- **Capture Targets:**
- 🖥️ **Hardware Output Device:** Record system-wide audio directly from your chosen physical speakers.
- 🎯 **Specific Application:** Isolate and record audio *only* from a selected application (e.g., Discord or Firefox) without capturing background system noises, powered by WASAPI Process Loopback.
- **Modes:**
- 🎤 **Microphone:** Record your voice.
- 🔊 **System Audio:** Record what you hear (Loopback).
- 🎙️+🔊 **Both:** Record both tracks simultaneously (mixed).
- 🔊 **Loopback:** Record what you hear (or what the app is playing).
- 🎙️+🔊 **Both:** Record both tracks simultaneously (mixed into one file).
- **Post-Processing:**
- **Auto-Normalize:** Automatically adjusts volume to optimal levels after recording.
- **Clipboard Integration:** Automatically copies the file (or file path) to your clipboard.
Expand All @@ -34,17 +37,25 @@ It sits quietly in your system tray and is always ready with a single click or g

## Usage

1. **Right-click** the tray icon to open **Settings**.
2. Select your **Microphone** and **Output Folder**.
3. Set your **Hotkeys** (optional).
4. **Left-click** the tray icon or use a hotkey to start recording.
5. Click again to stop. The file is saved and ready to use!
1. The **Settings** window opens immediately upon launch.
2. Choose your **Capture Target** (Hardware Output or Specific Application).
3. Select your **Microphone** and **Output Folder**.
4. Set your **Hotkeys** and **Tray Icon Behavior** (optional).
5. Close the settings window to minimize to the tray.
6. **Left-click** the tray icon or use your configured hotkey to start recording. Click again to stop.

## Development

### Requirements
- Python 3.12+
- `pip install PyQt6 soundcard soundfile numpy lameenc keyboard`
- Install dependencies: `pip install -r requirements.txt`
- Core libraries include `soundcard`, `soundfile`, `proctap` (for WASAPI loopback isolation), `psutil`, and `PyQt6`.

### Testing
This project follows Test-Driven Development (TDD) for core audio logic. To run the test suite:
```bash
pytest tests/
```

### Build from Source
To create the standalone executable:
Expand Down
127 changes: 110 additions & 17 deletions audio_recorder.py
Original file line number Diff line number Diff line change
Expand Up @@ -35,18 +35,71 @@ def stop(self):
self.stop_event.set()
self.join()

class ProcTapRecorder(threading.Thread):
"""
Helper thread to record a specific application's audio to a WAV file using ProcTap.
"""
def __init__(self, pid, filepath):
super().__init__()
self.pid = pid
self.filepath = filepath
self.samplerate = 48000
self.channels = 2
self.stop_event = threading.Event()
self.error = None
self.max_amp_seen = 0.0
self.chunks_read = 0

def run(self):
try:
import proctap
import soundfile as sf
import numpy as np

print(f"[ProcTapRecorder] Starting capture for PID {self.pid}...")
capture = proctap.ProcessAudioCapture(self.pid)
capture.start()

with sf.SoundFile(self.filepath, mode='w', samplerate=self.samplerate, channels=self.channels) as f_wav:
while not self.stop_event.is_set():
data = capture.read(timeout=0.1)
if data:
np_data = np.frombuffer(data, dtype=np.float32).reshape(-1, self.channels)
f_wav.write(np_data)

# Debugging amplitude
amp = np.max(np.abs(np_data))
if amp > self.max_amp_seen:
self.max_amp_seen = amp
self.chunks_read += 1

capture.stop()
capture.close()
print(f"[ProcTapRecorder] Finished PID {self.pid}. Chunks: {self.chunks_read}, Max Amp: {self.max_amp_seen:.4f}")
if self.max_amp_seen == 0.0 and self.chunks_read > 0:
print(f"[ProcTapRecorder] WARNING: All captured chunks were perfect silence (0.0). Application may be bypassing WASAPI loopback.")
except Exception as e:
print(f"[ProcTapRecorder] Error: {e}")
self.error = str(e)

def stop(self):
self.stop_event.set()
self.join()

class AudioRecorder(threading.Thread):
"""
Orchestrates recording from Microphone, Loopback, or Both.
"""
def __init__(self, mic_id, source_mode, output_folder, output_format="mp3",
normalize=False, on_finish_callback=None):
normalize=False, target_pid=None, speaker_id=None, on_finish_callback=None):
super().__init__()
self.mic_id = mic_id
self.source_mode = source_mode # "mic", "loopback", "both"
self.output_folder = output_folder
self.output_format = output_format.lower()
self.normalize = normalize
self.target_pid = target_pid
self.speaker_id = speaker_id
self.callback = on_finish_callback

self.recording = False
Expand All @@ -58,19 +111,27 @@ def __init__(self, mic_id, source_mode, output_folder, output_format="mp3",
self.temp_files = []
self.recorders = []

def _get_device(self, is_loopback):
def _get_device(self, is_loopback=False):
if is_loopback:
# For loopback, we try to find the default speaker's loopback
default_speaker = sc.default_speaker()
target_speaker_name = None
if self.speaker_id:
for speaker in sc.all_speakers():
if speaker.id == self.speaker_id:
target_speaker_name = speaker.name
break

if target_speaker_name is None:
target_speaker_name = sc.default_speaker().name

mics = sc.all_microphones(include_loopback=True)
# Try exact name match
loopback_mic = next((m for m in mics if m.name == default_speaker.name), None)
loopback_mic = next((m for m in mics if m.name == target_speaker_name and m.isloopback), None)
# Try fuzzy match
if not loopback_mic:
loopback_mic = next((m for m in mics if default_speaker.name in m.name), None)
loopback_mic = next((m for m in mics if target_speaker_name in m.name and m.isloopback), None)

if not loopback_mic:
raise Exception("Could not detect System Audio loopback device.")
raise Exception(f"Could not detect System Audio loopback device for '{target_speaker_name}'.")
return loopback_mic
else:
return sc.get_microphone(self.mic_id, include_loopback=False)
Expand All @@ -83,44 +144,76 @@ def run(self):

try:
# 1. Setup Recorders
is_per_app = self.target_pid is not None

# Use root PID for the target to ensure we capture the whole tree (browser sandboxes)
actual_pid = self.target_pid
if is_per_app:
try:
from process_utils import get_root_pid
actual_pid = get_root_pid(self.target_pid)
print(f"Targeting root PID {actual_pid} (derived from selected {self.target_pid})")
except Exception as e:
print(f"Error resolving root PID: {e}")

# If using ProcTap, its fixed sample rate is 48000.
# We must match this for hardware mics to avoid mixing different sample rates.
target_sr = 48000 if is_per_app else 44100

if self.source_mode == "both":
# Need two recorders
dev_mic = self._get_device(is_loopback=False)
dev_loop = self._get_device(is_loopback=True)

t1 = tempfile.NamedTemporaryFile(suffix=".wav", delete=False).name
t2 = tempfile.NamedTemporaryFile(suffix=".wav", delete=False).name
self.temp_files = [t1, t2]

self.recorders.append(RawRecorder(dev_mic, t1))
self.recorders.append(RawRecorder(dev_loop, t2))
self.recorders.append(RawRecorder(dev_mic, t1, samplerate=target_sr))

if is_per_app:
self.recorders.append(ProcTapRecorder(actual_pid, t2))
else:
dev_loop = self._get_device(is_loopback=True)
self.recorders.append(RawRecorder(dev_loop, t2, samplerate=target_sr))

elif self.source_mode == "loopback":
dev = self._get_device(is_loopback=True)
t1 = tempfile.NamedTemporaryFile(suffix=".wav", delete=False).name
self.temp_files = [t1]
self.recorders.append(RawRecorder(dev, t1))

if is_per_app:
self.recorders.append(ProcTapRecorder(actual_pid, t1))
else:
dev = self._get_device(is_loopback=True)
self.recorders.append(RawRecorder(dev, t1, samplerate=target_sr))

else: # mic
dev = self._get_device(is_loopback=False)
t1 = tempfile.NamedTemporaryFile(suffix=".wav", delete=False).name
self.temp_files = [t1]
self.recorders.append(RawRecorder(dev, t1))
self.recorders.append(RawRecorder(dev, t1, samplerate=target_sr))

print(f"Starting recording mode: {self.source_mode}")

# 2. Start Recording
for r in self.recorders:
r.start()

# Wait for stop signal
self.stop_event.wait()
# Wait for stop signal or for a recorder to crash
while not self.stop_event.is_set():
for r in self.recorders:
if not r.is_alive():
# A recorder thread died prematurely
self.stop_event.set()
if r.error:
self.error_message = f"Recording stopped because the application closed. ({r.error})"
break
self.stop_event.wait(0.5)

# 3. Stop Recording
for r in self.recorders:
r.stop()
if r.error:
raise Exception(f"Recorder error: {r.error}")
if r.error and not self.error_message:
self.error_message = f"Recording stopped because the application closed. ({r.error})"

# 4. Mix/Process
if len(self.temp_files) == 2:
Expand Down
Loading