From 64e6f09b54e041479de91be73fcc3560641c8a18 Mon Sep 17 00:00:00 2001 From: phoenixray2000 Date: Mon, 18 May 2026 14:48:17 +0800 Subject: [PATCH 1/7] Add audio output profile configuration --- audio_recorder.py | 72 ++++++++++++++++++++++++++ tests/test_audio_output_profile.py | 81 ++++++++++++++++++++++++++++++ 2 files changed, 153 insertions(+) create mode 100644 tests/test_audio_output_profile.py diff --git a/audio_recorder.py b/audio_recorder.py index a7c67b4..5f84a90 100644 --- a/audio_recorder.py +++ b/audio_recorder.py @@ -8,6 +8,78 @@ import tempfile import shutil +FORMAT_CONFIG = { + "wav": { + "label": "WAV", + "extension": ".wav", + "encoder": "soundfile", + "format": "WAV", + }, + "flac": { + "label": "FLAC", + "extension": ".flac", + "encoder": "soundfile", + "format": "FLAC", + }, + "mp3": { + "label": "MP3", + "extension": ".mp3", + "encoder": "lameenc", + }, +} + +QUALITY_CONFIG = { + "balanced": { + "label": "Balanced", + "sample_rate": 16000, + "subtype": "PCM_16", + "mp3_bitrate_kbps": 64, + }, + "high": { + "label": "High Quality", + "sample_rate": 48000, + "subtype": "PCM_24", + "mp3_bitrate_kbps": 128, + }, +} + + +def build_output_profile(fmt, quality, stereo): + fmt_key = str(fmt or "").strip().lower() + quality_key = str(quality or "").strip().lower() + + if fmt_key not in FORMAT_CONFIG: + raise ValueError(f"Unsupported output format: {fmt}") + if quality_key not in QUALITY_CONFIG: + raise ValueError(f"Unsupported output quality: {quality}") + + format_config = FORMAT_CONFIG[fmt_key] + quality_config = QUALITY_CONFIG[quality_key] + channels = 2 if stereo else 1 + return { + **quality_config, + **format_config, + "label": format_config["label"], + "format_label": format_config["label"], + "quality_label": quality_config["label"], + "format_key": fmt_key, + "quality_key": quality_key, + "channels": channels, + } + + +def describe_output_profile(fmt, quality, stereo): + profile = build_output_profile(fmt, quality, stereo) + rate_khz = profile["sample_rate"] // 1000 + channels = "stereo" if profile["channels"] == 2 else "mono" + encoding = ( + f"{profile['mp3_bitrate_kbps']} kbps" + if profile["encoder"] == "lameenc" + else profile["subtype"] + ) + return f"{profile['format_label']} / {rate_khz} kHz / {channels} / {encoding}" + + class RawRecorder(threading.Thread): """ Helper thread to record a single device to a WAV file. diff --git a/tests/test_audio_output_profile.py b/tests/test_audio_output_profile.py new file mode 100644 index 0000000..84c4b01 --- /dev/null +++ b/tests/test_audio_output_profile.py @@ -0,0 +1,81 @@ +import unittest + +from audio_recorder import ( + FORMAT_CONFIG, + QUALITY_CONFIG, + build_output_profile, + describe_output_profile, +) + + +class OutputProfileTests(unittest.TestCase): + def test_format_config_contains_supported_outputs(self): + self.assertEqual(set(FORMAT_CONFIG), {"wav", "flac", "mp3"}) + self.assertEqual(FORMAT_CONFIG["wav"]["label"], "WAV") + self.assertEqual(FORMAT_CONFIG["wav"]["extension"], ".wav") + self.assertEqual(FORMAT_CONFIG["wav"]["encoder"], "soundfile") + self.assertEqual(FORMAT_CONFIG["wav"]["format"], "WAV") + self.assertEqual(FORMAT_CONFIG["flac"]["label"], "FLAC") + self.assertEqual(FORMAT_CONFIG["flac"]["extension"], ".flac") + self.assertEqual(FORMAT_CONFIG["flac"]["encoder"], "soundfile") + self.assertEqual(FORMAT_CONFIG["flac"]["format"], "FLAC") + self.assertEqual(FORMAT_CONFIG["mp3"]["label"], "MP3") + self.assertEqual(FORMAT_CONFIG["mp3"]["extension"], ".mp3") + self.assertEqual(FORMAT_CONFIG["mp3"]["encoder"], "lameenc") + + def test_quality_config_matches_required_mapping(self): + self.assertEqual(set(QUALITY_CONFIG), {"balanced", "high"}) + self.assertEqual(QUALITY_CONFIG["balanced"]["label"], "Balanced") + self.assertEqual(QUALITY_CONFIG["balanced"]["sample_rate"], 16000) + self.assertEqual(QUALITY_CONFIG["balanced"]["subtype"], "PCM_16") + self.assertEqual(QUALITY_CONFIG["balanced"]["mp3_bitrate_kbps"], 64) + self.assertEqual(QUALITY_CONFIG["high"]["label"], "High Quality") + self.assertEqual(QUALITY_CONFIG["high"]["sample_rate"], 48000) + self.assertEqual(QUALITY_CONFIG["high"]["subtype"], "PCM_24") + self.assertEqual(QUALITY_CONFIG["high"]["mp3_bitrate_kbps"], 128) + + def test_build_output_profile_applies_mono_channels(self): + profile = build_output_profile(" FLAC ", " BALANCED ", stereo=False) + + self.assertEqual(profile["label"], "FLAC") + self.assertEqual(profile["format_label"], "FLAC") + self.assertEqual(profile["quality_label"], "Balanced") + self.assertEqual(profile["extension"], ".flac") + self.assertEqual(profile["sample_rate"], 16000) + self.assertEqual(profile["subtype"], "PCM_16") + self.assertEqual(profile["channels"], 1) + self.assertEqual(profile["format_key"], "flac") + self.assertEqual(profile["quality_key"], "balanced") + + def test_build_output_profile_applies_stereo_channels(self): + profile = build_output_profile("mp3", "high", stereo=True) + + self.assertEqual(profile["label"], "MP3") + self.assertEqual(profile["format_label"], "MP3") + self.assertEqual(profile["quality_label"], "High Quality") + self.assertEqual(profile["sample_rate"], 48000) + self.assertEqual(profile["mp3_bitrate_kbps"], 128) + self.assertEqual(profile["channels"], 2) + self.assertEqual(profile["format_key"], "mp3") + self.assertEqual(profile["quality_key"], "high") + self.assertNotIn("format", profile) + + def test_describe_output_profile_uses_english_preview_text(self): + self.assertEqual( + describe_output_profile("flac", "balanced", stereo=False), + "FLAC / 16 kHz / mono / PCM_16", + ) + self.assertEqual( + describe_output_profile("mp3", "high", stereo=True), + "MP3 / 48 kHz / stereo / 128 kbps", + ) + + def test_invalid_profile_keys_raise_value_error(self): + with self.assertRaises(ValueError): + build_output_profile("ogg", "balanced", stereo=False) + with self.assertRaises(ValueError): + build_output_profile("flac", "studio", stereo=False) + + +if __name__ == "__main__": + unittest.main() From 5d9771f132685a36bae8b688e5b470fe98dc614a Mon Sep 17 00:00:00 2001 From: phoenixray2000 Date: Mon, 18 May 2026 14:58:01 +0800 Subject: [PATCH 2/7] Encode selected audio output formats --- audio_recorder.py | 100 ++++++++++----- tests/test_audio_output_profile.py | 191 +++++++++++++++++++++++++++++ 2 files changed, 263 insertions(+), 28 deletions(-) diff --git a/audio_recorder.py b/audio_recorder.py index 5f84a90..6ae6990 100644 --- a/audio_recorder.py +++ b/audio_recorder.py @@ -6,7 +6,6 @@ import lameenc import numpy as np import tempfile -import shutil FORMAT_CONFIG = { "wav": { @@ -84,18 +83,26 @@ class RawRecorder(threading.Thread): """ Helper thread to record a single device to a WAV file. """ - def __init__(self, device, filepath, samplerate=44100, channels=2): + def __init__(self, device, filepath, samplerate=44100, channels=2, subtype="PCM_16"): super().__init__() self.device = device self.filepath = filepath self.samplerate = samplerate self.channels = channels + self.subtype = subtype self.stop_event = threading.Event() self.error = None def run(self): try: - with sf.SoundFile(self.filepath, mode='w', samplerate=self.samplerate, channels=self.channels) as f_wav: + with sf.SoundFile( + self.filepath, + mode="w", + samplerate=self.samplerate, + channels=self.channels, + format="WAV", + subtype=self.subtype, + ) as f_wav: with self.device.recorder(samplerate=self.samplerate, channels=self.channels) as mic: while not self.stop_event.is_set(): data = mic.record(numframes=2048) @@ -111,13 +118,25 @@ 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): + def __init__( + self, + mic_id, + source_mode, + output_folder, + output_format="flac", + quality="balanced", + stereo=False, + normalize=False, + 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.output_format = str(output_format or "flac").strip().lower() + self.quality = str(quality or "balanced").strip().lower() + self.stereo = bool(stereo) + self.profile = build_output_profile(self.output_format, self.quality, self.stereo) self.normalize = normalize self.callback = on_finish_callback @@ -154,6 +173,10 @@ def run(self): self.recorders = [] try: + samplerate = self.profile["sample_rate"] + channels = self.profile["channels"] + subtype = self.profile["subtype"] + # 1. Setup Recorders if self.source_mode == "both": # Need two recorders @@ -164,20 +187,20 @@ def run(self): 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=samplerate, channels=channels, subtype=subtype)) + self.recorders.append(RawRecorder(dev_loop, t2, samplerate=samplerate, channels=channels, subtype=subtype)) 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)) + self.recorders.append(RawRecorder(dev, t1, samplerate=samplerate, channels=channels, subtype=subtype)) 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=samplerate, channels=channels, subtype=subtype)) print(f"Starting recording mode: {self.source_mode}") @@ -197,7 +220,7 @@ def run(self): # 4. Mix/Process if len(self.temp_files) == 2: mixed_wav = tempfile.NamedTemporaryFile(suffix=".wav", delete=False).name - self._mix_audio(self.temp_files[0], self.temp_files[1], mixed_wav) + self._mix_audio(self.temp_files[0], self.temp_files[1], mixed_wav, subtype) # Use mixed file as source for next steps source_wav = mixed_wav self.temp_files.append(mixed_wav) # Mark for cleanup @@ -213,13 +236,9 @@ def run(self): os.makedirs(self.output_folder) timestamp = time.strftime("%Y%m%d_%H%M%S") - filename = f"Recording_{timestamp}.{self.output_format}" + filename = f"Recording_{timestamp}{self.profile['extension']}" self.final_filepath = os.path.join(self.output_folder, filename) - - if self.output_format == "mp3": - self._convert_to_mp3(source_wav, self.final_filepath) - else: - shutil.copy2(source_wav, self.final_filepath) + self._write_final_output(source_wav, self.final_filepath) except Exception as e: self.error_message = str(e) @@ -239,9 +258,14 @@ def run(self): def stop(self): self.stop_event.set() - def _mix_audio(self, file1, file2, out_file): - d1, sr1 = sf.read(file1) - d2, sr2 = sf.read(file2) + def _mix_audio(self, file1, file2, out_file, subtype): + d1, sr1 = sf.read(file1, always_2d=True) + d2, sr2 = sf.read(file2, always_2d=True) + + if sr1 != sr2: + raise ValueError("Cannot mix audio with different sample rates.") + if d1.shape[1] != d2.shape[1]: + raise ValueError("Cannot mix audio with different channel counts.") # Ensure same length max_len = max(len(d1), len(d2)) @@ -250,13 +274,13 @@ def _mix_audio(self, file1, file2, out_file): if len(d1) < max_len: pad_width = max_len - len(d1) # handle mono/stereo padding - shape = (pad_width, d1.shape[1]) if d1.ndim > 1 else (pad_width,) + shape = (pad_width, d1.shape[1]) d1 = np.concatenate((d1, np.zeros(shape, dtype=d1.dtype))) # Pad d2 if len(d2) < max_len: pad_width = max_len - len(d2) - shape = (pad_width, d2.shape[1]) if d2.ndim > 1 else (pad_width,) + shape = (pad_width, d2.shape[1]) d2 = np.concatenate((d2, np.zeros(shape, dtype=d2.dtype))) # Mix (Sum) @@ -264,28 +288,48 @@ def _mix_audio(self, file1, file2, out_file): # Clip mixed = np.clip(mixed, -1.0, 1.0) - sf.write(out_file, mixed, sr1) # Assume sr1 == sr2 = 44100 + sf.write(out_file, mixed, sr1, format="WAV", subtype=subtype) def _normalize_audio(self, filepath): try: + info = sf.info(filepath) data, sr = sf.read(filepath) max_val = np.max(np.abs(data)) if max_val > 0: target_peak = 0.99 factor = target_peak / max_val data = data * factor - sf.write(filepath, data, sr) + sf.write(filepath, data, sr, format=info.format, subtype=info.subtype) except Exception as e: print(f"Normalization failed: {e}") - def _convert_to_mp3(self, src_wav, dst_mp3): - data, sr = sf.read(src_wav) - channels = data.shape[1] if data.ndim > 1 else 1 + def _write_final_output(self, source_wav, final_filepath): + if self.profile["encoder"] == "lameenc": + self._convert_to_mp3(source_wav, final_filepath, self.profile["mp3_bitrate_kbps"]) + return + + data, sr = sf.read(source_wav, always_2d=True) + if self.profile["channels"] == 1 and data.shape[1] > 1: + data = np.mean(data, axis=1, keepdims=True) + + sf.write( + final_filepath, + data, + sr, + format=self.profile["format"], + subtype=self.profile["subtype"], + ) + + def _convert_to_mp3(self, src_wav, dst_mp3, bitrate_kbps): + data, sr = sf.read(src_wav, always_2d=True) + if self.profile["channels"] == 1 and data.shape[1] > 1: + data = np.mean(data, axis=1, keepdims=True) + channels = data.shape[1] pcm_data = (data * 32767).clip(-32768, 32767).astype(np.int16) encoder = lameenc.Encoder() - encoder.set_bit_rate(192) + encoder.set_bit_rate(bitrate_kbps) encoder.set_in_sample_rate(sr) encoder.set_channels(channels) encoder.set_quality(2) diff --git a/tests/test_audio_output_profile.py b/tests/test_audio_output_profile.py index 84c4b01..6595c87 100644 --- a/tests/test_audio_output_profile.py +++ b/tests/test_audio_output_profile.py @@ -70,12 +70,203 @@ def test_describe_output_profile_uses_english_preview_text(self): "MP3 / 48 kHz / stereo / 128 kbps", ) + def test_write_final_output_creates_real_flac_file(self): + import os + import tempfile + + import numpy as np + import soundfile as sf + + with tempfile.TemporaryDirectory() as temp_dir: + source_wav = os.path.join(temp_dir, "source.wav") + final_flac = os.path.join(temp_dir, "final.flac") + data = np.zeros((160, 1), dtype=np.float32) + sf.write(source_wav, data, 16000, format="WAV", subtype="PCM_16") + + recorder = self._make_recorder("flac", "balanced", stereo=False) + recorder._write_final_output(source_wav, final_flac) + + info = sf.info(final_flac) + self.assertEqual(info.format, "FLAC") + self.assertEqual(info.samplerate, 16000) + self.assertEqual(info.channels, 1) + self.assertEqual(info.subtype, "PCM_16") + + def test_write_final_output_creates_high_quality_wav_file(self): + import os + import tempfile + + import numpy as np + import soundfile as sf + + with tempfile.TemporaryDirectory() as temp_dir: + source_wav = os.path.join(temp_dir, "source.wav") + final_wav = os.path.join(temp_dir, "final.wav") + data = np.zeros((480, 2), dtype=np.float32) + sf.write(source_wav, data, 48000, format="WAV", subtype="PCM_24") + + recorder = self._make_recorder("wav", "high", stereo=True) + recorder._write_final_output(source_wav, final_wav) + + info = sf.info(final_wav) + self.assertEqual(info.format, "WAV") + self.assertEqual(info.samplerate, 48000) + self.assertEqual(info.channels, 2) + self.assertEqual(info.subtype, "PCM_24") + + def test_write_final_output_uses_profile_mp3_bitrate(self): + import os + import tempfile + from unittest.mock import patch + + import numpy as np + import soundfile as sf + + with tempfile.TemporaryDirectory() as temp_dir: + source_wav = os.path.join(temp_dir, "source.wav") + final_mp3 = os.path.join(temp_dir, "final.mp3") + data = np.zeros((160, 1), dtype=np.float32) + sf.write(source_wav, data, 16000, format="WAV", subtype="PCM_16") + + recorder = self._make_recorder("mp3", "balanced", stereo=False) + with patch.object(recorder, "_convert_to_mp3") as convert_to_mp3: + recorder._write_final_output(source_wav, final_mp3) + + convert_to_mp3.assert_called_once_with(source_wav, final_mp3, 64) + + def test_convert_to_mp3_configures_encoder_and_writes_interleaved_pcm(self): + import os + import tempfile + from unittest.mock import patch + + import numpy as np + import soundfile as sf + + with tempfile.TemporaryDirectory() as temp_dir: + source_wav = os.path.join(temp_dir, "source.wav") + final_mp3 = os.path.join(temp_dir, "final.mp3") + data = np.array([[0.5, -0.5], [0.25, -0.25]], dtype=np.float32) + sf.write(source_wav, data, 48000, format="WAV", subtype="FLOAT") + + recorder = self._make_recorder("mp3", "high", stereo=True) + with patch("audio_recorder.lameenc.Encoder") as encoder_cls: + encoder = encoder_cls.return_value + encoder.encode.return_value = b"encoded" + encoder.flush.return_value = b"flush" + + recorder._convert_to_mp3(source_wav, final_mp3, 128) + + encoder.set_bit_rate.assert_called_once_with(128) + encoder.set_in_sample_rate.assert_called_once_with(48000) + encoder.set_channels.assert_called_once_with(2) + encoder.set_quality.assert_called_once_with(2) + pcm_arg = encoder.encode.call_args.args[0] + expected_pcm = (data * 32767).clip(-32768, 32767).astype(np.int16) + self.assertEqual( + np.frombuffer(pcm_arg, dtype=np.int16).tolist(), + expected_pcm.reshape(-1).tolist(), + ) + with open(final_mp3, "rb") as f_mp3: + self.assertEqual(f_mp3.read(), b"encodedflush") + + def test_mix_audio_preserves_wav_subtype(self): + import os + import tempfile + + import numpy as np + import soundfile as sf + + with tempfile.TemporaryDirectory() as temp_dir: + file1 = os.path.join(temp_dir, "file1.wav") + file2 = os.path.join(temp_dir, "file2.wav") + out_file = os.path.join(temp_dir, "mixed.wav") + data1 = np.array([[0.25, -0.25], [0.5, -0.5]], dtype=np.float32) + data2 = np.array([[0.25, 0.25], [-0.5, 0.5]], dtype=np.float32) + sf.write(file1, data1, 48000, format="WAV", subtype="PCM_24") + sf.write(file2, data2, 48000, format="WAV", subtype="PCM_24") + + recorder = self._make_recorder("wav", "high", stereo=True) + recorder._mix_audio(file1, file2, out_file, "PCM_24") + + info = sf.info(out_file) + self.assertEqual(info.format, "WAV") + self.assertEqual(info.samplerate, 48000) + self.assertEqual(info.channels, 2) + self.assertEqual(info.subtype, "PCM_24") + + def test_mix_audio_rejects_different_sample_rates(self): + import os + import tempfile + + import numpy as np + import soundfile as sf + + with tempfile.TemporaryDirectory() as temp_dir: + file1 = os.path.join(temp_dir, "file1.wav") + file2 = os.path.join(temp_dir, "file2.wav") + out_file = os.path.join(temp_dir, "mixed.wav") + sf.write(file1, np.zeros((2, 2), dtype=np.float32), 48000, format="WAV", subtype="PCM_24") + sf.write(file2, np.zeros((2, 2), dtype=np.float32), 16000, format="WAV", subtype="PCM_24") + + recorder = self._make_recorder("wav", "high", stereo=True) + with self.assertRaisesRegex(ValueError, "Cannot mix audio with different sample rates."): + recorder._mix_audio(file1, file2, out_file, "PCM_24") + + def test_mix_audio_rejects_different_channel_counts(self): + import os + import tempfile + + import numpy as np + import soundfile as sf + + with tempfile.TemporaryDirectory() as temp_dir: + file1 = os.path.join(temp_dir, "file1.wav") + file2 = os.path.join(temp_dir, "file2.wav") + out_file = os.path.join(temp_dir, "mixed.wav") + sf.write(file1, np.zeros((2, 2), dtype=np.float32), 48000, format="WAV", subtype="PCM_24") + sf.write(file2, np.zeros((2, 1), dtype=np.float32), 48000, format="WAV", subtype="PCM_24") + + recorder = self._make_recorder("wav", "high", stereo=True) + with self.assertRaisesRegex(ValueError, "Cannot mix audio with different channel counts."): + recorder._mix_audio(file1, file2, out_file, "PCM_24") + + def test_normalize_audio_preserves_format_and_subtype(self): + import os + import tempfile + + import numpy as np + import soundfile as sf + + with tempfile.TemporaryDirectory() as temp_dir: + filepath = os.path.join(temp_dir, "source.flac") + data = np.array([[0.25], [-0.5], [0.75]], dtype=np.float32) + sf.write(filepath, data, 48000, format="FLAC", subtype="PCM_24") + + recorder = self._make_recorder("flac", "high", stereo=False) + recorder._normalize_audio(filepath) + + info = sf.info(filepath) + self.assertEqual(info.format, "FLAC") + self.assertEqual(info.subtype, "PCM_24") + def test_invalid_profile_keys_raise_value_error(self): with self.assertRaises(ValueError): build_output_profile("ogg", "balanced", stereo=False) with self.assertRaises(ValueError): build_output_profile("flac", "studio", stereo=False) + def _make_recorder(self, fmt, quality, stereo): + from audio_recorder import AudioRecorder + + return AudioRecorder( + mic_id="mic1", + source_mode="mic", + output_folder=".", + output_format=fmt, + quality=quality, + stereo=stereo, + ) + if __name__ == "__main__": unittest.main() From f65240a746919fe01e01383e499ceceaf6867653 Mon Sep 17 00:00:00 2001 From: phoenixray2000 Date: Mon, 18 May 2026 15:07:24 +0800 Subject: [PATCH 3/7] Add output profile settings UI --- gui.py | 109 +++++++++++++++++++++++++++++++++++++++++++-------------- 1 file changed, 83 insertions(+), 26 deletions(-) diff --git a/gui.py b/gui.py index 5c0b485..2b5bd58 100644 --- a/gui.py +++ b/gui.py @@ -11,7 +11,13 @@ from PyQt6.QtCore import pyqtSignal, QObject, Qt, QUrl, QMimeData, QDir import soundcard as sc import keyboard -from audio_recorder import AudioRecorder, get_devices +from audio_recorder import ( + AudioRecorder, + FORMAT_CONFIG, + QUALITY_CONFIG, + describe_output_profile, + get_devices, +) from clipboard_utils import copy_file_to_clipboard CONFIG_FILE = "settings.json" @@ -131,10 +137,26 @@ def init_ui(self): layout_folder_inner.addWidget(btn_browse) self.combo_fmt = QComboBox() - self.combo_fmt.addItems(["MP3", "WAV"]) + for key, config in FORMAT_CONFIG.items(): + self.combo_fmt.addItem(config["label"], key) + + self.combo_quality = QComboBox() + for key, config in QUALITY_CONFIG.items(): + self.combo_quality.addItem(config["label"], key) + + self.chk_stereo = QCheckBox("Keep Stereo") + self.chk_stereo.setChecked(False) + self.lbl_preview = QLabel() + + self.combo_fmt.currentIndexChanged.connect(self.update_output_preview) + self.combo_quality.currentIndexChanged.connect(self.update_output_preview) + self.chk_stereo.toggled.connect(self.update_output_preview) layout_out.addRow("Folder:", layout_folder_inner) layout_out.addRow("Format:", self.combo_fmt) + layout_out.addRow("Quality:", self.combo_quality) + layout_out.addRow("Stereo:", self.chk_stereo) + layout_out.addRow("Preview:", self.lbl_preview) group_out.setLayout(layout_out) layout.addWidget(group_out) @@ -202,37 +224,70 @@ def browse_folder(self): if folder: self.lbl_folder.setText(folder) + def _set_combo_by_data(self, combo, value, default_value): + normalized = str(value or default_value).strip().lower() + default_normalized = str(default_value or "").strip().lower() + idx = combo.findData(normalized) + if idx < 0: + idx = combo.findData(default_normalized) + if idx >= 0: + combo.setCurrentIndex(idx) + + def _parse_bool_setting(self, value): + if isinstance(value, bool): + return value + if value is None: + return False + if isinstance(value, str): + normalized = value.strip().lower() + if normalized in ("true", "1", "yes", "on"): + return True + if normalized in ("false", "0", "no", "off", ""): + return False + return False + + def update_output_preview(self): + fmt = self.combo_fmt.currentData() or "flac" + quality = self.combo_quality.currentData() or "balanced" + stereo = self.chk_stereo.isChecked() + self.lbl_preview.setText(describe_output_profile(fmt, quality, stereo)) + def load_settings(self): + data = {} if os.path.exists(CONFIG_FILE): try: with open(CONFIG_FILE, 'r') as f: data = json.load(f) - - self.lbl_folder.setText(data.get("output_folder", os.getcwd())) - fmt_idx = self.combo_fmt.findText(data.get("format", "MP3")) - if fmt_idx >= 0: self.combo_fmt.setCurrentIndex(fmt_idx) - - saved_id = data.get("device_id") - if saved_id: - idx = self.combo_mic.findData(saved_id) - if idx >= 0: self.combo_mic.setCurrentIndex(idx) - - mode = data.get("tray_click_mode", "Last Used") - mode_idx = self.combo_left_click.findText(mode) - if mode_idx >= 0: self.combo_left_click.setCurrentIndex(mode_idx) - - self.chk_normalize.setChecked(data.get("normalize", False)) - self.chk_clipboard.setChecked(data.get("clipboard", False)) - self.chk_delete.setChecked(data.get("delete_after", False)) - self.chk_delete.setEnabled(self.chk_clipboard.isChecked()) - - self.hk_mic.setText(data.get("hk_mic", "")) - self.hk_loop.setText(data.get("hk_loop", "")) - self.hk_both.setText(data.get("hk_both", "")) - self.hk_stop.setText(data.get("hk_stop", "")) + if not isinstance(data, dict): + data = {} except Exception as e: print(f"Error loading settings: {e}") + self.lbl_folder.setText(data.get("output_folder", os.getcwd())) + self._set_combo_by_data(self.combo_fmt, data.get("format"), "flac") + self._set_combo_by_data(self.combo_quality, data.get("quality"), "balanced") + self.chk_stereo.setChecked(self._parse_bool_setting(data.get("stereo"))) + + saved_id = data.get("device_id") + if saved_id: + idx = self.combo_mic.findData(saved_id) + if idx >= 0: self.combo_mic.setCurrentIndex(idx) + + mode = data.get("tray_click_mode", "Last Used") + mode_idx = self.combo_left_click.findText(mode) + if mode_idx >= 0: self.combo_left_click.setCurrentIndex(mode_idx) + + self.chk_normalize.setChecked(data.get("normalize", False)) + self.chk_clipboard.setChecked(data.get("clipboard", False)) + self.chk_delete.setChecked(data.get("delete_after", False)) + self.chk_delete.setEnabled(self.chk_clipboard.isChecked()) + + self.hk_mic.setText(data.get("hk_mic", "")) + self.hk_loop.setText(data.get("hk_loop", "")) + self.hk_both.setText(data.get("hk_both", "")) + self.hk_stop.setText(data.get("hk_stop", "")) + self.update_output_preview() + def save_settings(self): data = self.get_settings() try: @@ -247,7 +302,9 @@ def get_settings(self): return { "device_id": self.combo_mic.currentData(), "output_folder": self.lbl_folder.text(), - "format": self.combo_fmt.currentText(), + "format": self.combo_fmt.currentData(), + "quality": self.combo_quality.currentData(), + "stereo": self.chk_stereo.isChecked(), "tray_click_mode": self.combo_left_click.currentText(), "normalize": self.chk_normalize.isChecked(), "clipboard": self.chk_clipboard.isChecked(), From fc0d99155d674c5a7a56c9309e9cb7f3711d7418 Mon Sep 17 00:00:00 2001 From: phoenixray2000 Date: Mon, 18 May 2026 15:12:59 +0800 Subject: [PATCH 4/7] Pass output profile into recordings --- gui.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/gui.py b/gui.py index 2b5bd58..04f24a0 100644 --- a/gui.py +++ b/gui.py @@ -439,6 +439,8 @@ def finish_callback(path, error): source_mode=mode, output_folder=settings['output_folder'], output_format=settings['format'], + quality=settings['quality'], + stereo=settings['stereo'], normalize=settings['normalize'], on_finish_callback=finish_callback ) From cb67149333ed6717e3bd4d7edcfcd8ec9a68f853 Mon Sep 17 00:00:00 2001 From: phoenixray2000 Date: Mon, 18 May 2026 15:15:01 +0800 Subject: [PATCH 5/7] Document output profile options --- README.md | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index 4c972b7..4153b7c 100644 --- a/README.md +++ b/README.md @@ -17,6 +17,10 @@ It sits quietly in your system tray and is always ready with a single click or g - 🎤 **Microphone:** Record your voice. - 🔊 **System Audio:** Record what you hear (Loopback). - 🎙️+🔊 **Both:** Record both tracks simultaneously (mixed). +- **Output Profiles:** + - **Format:** Save recordings as WAV, FLAC, or MP3. + - **Quality:** Choose Balanced for compact 16 kHz output or High Quality for 48 kHz output. + - **Stereo:** Keep stereo channels when needed, or leave it off for mono recordings. - **Post-Processing:** - **Auto-Normalize:** Automatically adjusts volume to optimal levels after recording. - **Clipboard Integration:** Automatically copies the file (or file path) to your clipboard. @@ -35,7 +39,7 @@ 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**. +2. Select your **Microphone**, **Output Folder**, **Format**, **Quality**, and **Stereo** preference. 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! From 67383068d879b0d15e2ed37c4dba2dda3183f404 Mon Sep 17 00:00:00 2001 From: phoenixray2000 Date: Mon, 18 May 2026 16:11:03 +0800 Subject: [PATCH 6/7] Improve audio normalization for voice clarity --- README.md | 2 +- audio_recorder.py | 88 ++++++++++++++++++++++-------- tests/test_audio_output_profile.py | 72 ++++++++++++++++++++++++ 3 files changed, 138 insertions(+), 24 deletions(-) diff --git a/README.md b/README.md index 4153b7c..cf12313 100644 --- a/README.md +++ b/README.md @@ -22,7 +22,7 @@ It sits quietly in your system tray and is always ready with a single click or g - **Quality:** Choose Balanced for compact 16 kHz output or High Quality for 48 kHz output. - **Stereo:** Keep stereo channels when needed, or leave it off for mono recordings. - **Post-Processing:** - - **Auto-Normalize:** Automatically adjusts volume to optimal levels after recording. + - **Auto-Normalize:** Lifts the main voice/body of each source before mixing and limits sharp peaks so brief spikes do not bury the recording. - **Clipboard Integration:** Automatically copies the file (or file path) to your clipboard. - **Clean Workflow:** Option to move the file to a temp folder and copy it, keeping your desktop clean. - **Control:** diff --git a/audio_recorder.py b/audio_recorder.py index 6ae6990..61b8e88 100644 --- a/audio_recorder.py +++ b/audio_recorder.py @@ -42,6 +42,12 @@ }, } +NORMALIZE_ACTIVE_FLOOR = 0.001 +NORMALIZE_TARGET_LEVEL = 0.12 +NORMALIZE_MAX_GAIN = 8.0 +NORMALIZE_REFERENCE_PERCENTILE = 95 +NORMALIZE_LIMIT = 0.98 + def build_output_profile(fmt, quality, stereo): fmt_key = str(fmt or "").strip().lower() @@ -218,18 +224,7 @@ def run(self): raise Exception(f"Recorder error: {r.error}") # 4. Mix/Process - if len(self.temp_files) == 2: - mixed_wav = tempfile.NamedTemporaryFile(suffix=".wav", delete=False).name - self._mix_audio(self.temp_files[0], self.temp_files[1], mixed_wav, subtype) - # Use mixed file as source for next steps - source_wav = mixed_wav - self.temp_files.append(mixed_wav) # Mark for cleanup - else: - source_wav = self.temp_files[0] - - # 5. Normalization - if self.normalize: - self._normalize_audio(source_wav) + source_wav = self._prepare_source_wav(subtype) # 6. Finalize if not os.path.exists(self.output_folder): @@ -258,7 +253,33 @@ def run(self): def stop(self): self.stop_event.set() - def _mix_audio(self, file1, file2, out_file, subtype): + def _prepare_source_wav(self, subtype): + if len(self.temp_files) == 2: + if self.normalize: + self._normalize_audio(self.temp_files[0]) + self._normalize_audio(self.temp_files[1]) + + mixed_wav = tempfile.NamedTemporaryFile(suffix=".wav", delete=False).name + self._mix_audio( + self.temp_files[0], + self.temp_files[1], + mixed_wav, + subtype, + limit_output=self.normalize, + ) + self.temp_files.append(mixed_wav) # Mark for cleanup + + if self.normalize: + self._limit_audio(mixed_wav) + + return mixed_wav + + source_wav = self.temp_files[0] + if self.normalize: + self._normalize_audio(source_wav) + return source_wav + + def _mix_audio(self, file1, file2, out_file, subtype, limit_output=False): d1, sr1 = sf.read(file1, always_2d=True) d2, sr2 = sf.read(file2, always_2d=True) @@ -283,26 +304,47 @@ def _mix_audio(self, file1, file2, out_file, subtype): shape = (pad_width, d2.shape[1]) d2 = np.concatenate((d2, np.zeros(shape, dtype=d2.dtype))) - # Mix (Sum) mixed = d1 + d2 - # Clip - mixed = np.clip(mixed, -1.0, 1.0) + if limit_output: + mixed = self._apply_limiter(mixed) + else: + mixed = np.clip(mixed, -1.0, 1.0) sf.write(out_file, mixed, sr1, format="WAV", subtype=subtype) def _normalize_audio(self, filepath): try: info = sf.info(filepath) - data, sr = sf.read(filepath) - max_val = np.max(np.abs(data)) - if max_val > 0: - target_peak = 0.99 - factor = target_peak / max_val - data = data * factor - sf.write(filepath, data, sr, format=info.format, subtype=info.subtype) + data, sr = sf.read(filepath, always_2d=True) + data = self._normalize_audio_data(data) + sf.write(filepath, data, sr, format=info.format, subtype=info.subtype) except Exception as e: print(f"Normalization failed: {e}") + def _normalize_audio_data(self, data): + active = np.abs(data) + active = active[active >= NORMALIZE_ACTIVE_FLOOR] + if active.size == 0: + return self._apply_limiter(data) + + rms = float(np.sqrt(np.mean(active ** 2))) + percentile = float(np.percentile(active, NORMALIZE_REFERENCE_PERCENTILE)) + reference_level = max(rms, percentile) + if not np.isfinite(reference_level) or reference_level <= 0: + return self._apply_limiter(data) + + gain = min(NORMALIZE_TARGET_LEVEL / reference_level, NORMALIZE_MAX_GAIN) + return self._apply_limiter(data * gain) + + def _limit_audio(self, filepath): + info = sf.info(filepath) + data, sr = sf.read(filepath, always_2d=True) + data = self._apply_limiter(data) + sf.write(filepath, data, sr, format=info.format, subtype=info.subtype) + + def _apply_limiter(self, data): + return np.clip(data, -NORMALIZE_LIMIT, NORMALIZE_LIMIT) + def _write_final_output(self, source_wav, final_filepath): if self.profile["encoder"] == "lameenc": self._convert_to_mp3(source_wav, final_filepath, self.profile["mp3_bitrate_kbps"]) diff --git a/tests/test_audio_output_profile.py b/tests/test_audio_output_profile.py index 6595c87..c2db337 100644 --- a/tests/test_audio_output_profile.py +++ b/tests/test_audio_output_profile.py @@ -249,6 +249,78 @@ def test_normalize_audio_preserves_format_and_subtype(self): self.assertEqual(info.format, "FLAC") self.assertEqual(info.subtype, "PCM_24") + def test_normalize_audio_raises_main_voice_despite_single_spike(self): + import os + import tempfile + + import numpy as np + import soundfile as sf + + with tempfile.TemporaryDirectory() as temp_dir: + filepath = os.path.join(temp_dir, "source.wav") + data = np.full((16000, 1), 0.02, dtype=np.float32) + data[4000, 0] = 1.0 + sf.write(filepath, data, 16000, format="WAV", subtype="FLOAT") + + recorder = self._make_recorder("wav", "balanced", stereo=False) + recorder._normalize_audio(filepath) + + normalized, _ = sf.read(filepath, always_2d=True) + body = np.delete(normalized[:, 0], 4000) + self.assertGreater(np.median(np.abs(body)), 0.10) + self.assertLessEqual(np.max(np.abs(normalized)), 0.9801) + + def test_prepare_source_wav_normalizes_both_sources_before_mixing(self): + import os + import tempfile + + import numpy as np + import soundfile as sf + + with tempfile.TemporaryDirectory() as temp_dir: + mic_file = os.path.join(temp_dir, "mic.wav") + loop_file = os.path.join(temp_dir, "loop.wav") + mic = np.full((16000, 1), 0.02, dtype=np.float32) + mic[4000, 0] = 1.0 + loopback = np.full((16000, 1), 0.01, dtype=np.float32) + sf.write(mic_file, mic, 16000, format="WAV", subtype="FLOAT") + sf.write(loop_file, loopback, 16000, format="WAV", subtype="FLOAT") + + recorder = self._make_recorder("wav", "balanced", stereo=False) + recorder.normalize = True + recorder.temp_files = [mic_file, loop_file] + + mixed_file = recorder._prepare_source_wav("FLOAT") + mixed, _ = sf.read(mixed_file, always_2d=True) + + body = np.delete(mixed[:, 0], 4000) + self.assertGreater(np.median(np.abs(body)), 0.15) + self.assertLessEqual(np.max(np.abs(mixed)), 0.9801) + self.assertIn(mixed_file, recorder.temp_files) + + def test_normalize_audio_preserves_stereo_channel_balance(self): + import os + import tempfile + + import numpy as np + import soundfile as sf + + with tempfile.TemporaryDirectory() as temp_dir: + filepath = os.path.join(temp_dir, "source.wav") + data = np.tile(np.array([[0.04, 0.02]], dtype=np.float32), (16000, 1)) + data[4000] = [1.0, 0.5] + sf.write(filepath, data, 16000, format="WAV", subtype="FLOAT") + + recorder = self._make_recorder("wav", "balanced", stereo=True) + recorder._normalize_audio(filepath) + + normalized, _ = sf.read(filepath, always_2d=True) + self.assertAlmostEqual( + normalized[1000, 0] / normalized[1000, 1], + 2.0, + places=5, + ) + def test_invalid_profile_keys_raise_value_error(self): with self.assertRaises(ValueError): build_output_profile("ogg", "balanced", stereo=False) From 67250b6a7a020887531547dbb6fdb4b26f93cf2f Mon Sep 17 00:00:00 2001 From: phoenixray2000 Date: Mon, 18 May 2026 20:33:23 +0800 Subject: [PATCH 7/7] Add floating recording indicator actions --- README.md | 7 +- gui.py | 263 +++++++++++++++++++++++++++++- tests/test_gui_hotkeys.py | 330 ++++++++++++++++++++++++++++++++++++++ 3 files changed, 596 insertions(+), 4 deletions(-) create mode 100644 tests/test_gui_hotkeys.py diff --git a/README.md b/README.md index cf12313..1251f8e 100644 --- a/README.md +++ b/README.md @@ -27,8 +27,8 @@ It sits quietly in your system tray and is always ready with a single click or g - **Clean Workflow:** Option to move the file to a temp folder and copy it, keeping your desktop clean. - **Control:** - **Global Hotkeys:** Start/Stop recording from anywhere (e.g., `Ctrl+Alt+R`). - - **Tray Icon:** Left-click to toggle recording immediately. - - **Visual Feedback:** Tray icon changes color when recording. + - **Tray Icon:** Left-click to toggle recording immediately; right-click to open the recordings folder, settings, or exit. + - **Visual Feedback:** Tray icon changes color when recording, and an optional always-on-top floating timer shows recording status with the same right-click menu. ## Installation @@ -41,8 +41,9 @@ It sits quietly in your system tray and is always ready with a single click or g 1. **Right-click** the tray icon to open **Settings**. 2. Select your **Microphone**, **Output Folder**, **Format**, **Quality**, and **Stereo** preference. 3. Set your **Hotkeys** (optional). + - Disable **Show floating recording timer** if you do not want the compact always-on-top recording indicator. 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! +5. Click the tray icon again, use a stop hotkey, or click the floating timer to stop. The floating timer changes state immediately, then hides after 5 seconds; click it again before it hides to open the recordings folder. ## Development diff --git a/gui.py b/gui.py index 04f24a0..0ecfd64 100644 --- a/gui.py +++ b/gui.py @@ -3,12 +3,13 @@ import json import shutil import tempfile +import subprocess from PyQt6.QtWidgets import (QApplication, QSystemTrayIcon, QMenu, QMainWindow, QWidget, QVBoxLayout, QHBoxLayout, QLabel, QComboBox, QPushButton, QFileDialog, QMessageBox, QGroupBox, QLineEdit, QFormLayout, QCheckBox) from PyQt6.QtGui import QIcon, QAction, QColor, QPixmap, QPainter, QBrush, QKeySequence -from PyQt6.QtCore import pyqtSignal, QObject, Qt, QUrl, QMimeData, QDir +from PyQt6.QtCore import pyqtSignal, QObject, Qt, QUrl, QMimeData, QDir, QTimer import soundcard as sc import keyboard from audio_recorder import ( @@ -32,6 +33,208 @@ def resource_path(relative_path): class SignalManager(QObject): recording_finished = pyqtSignal(str, str) + +class RecordingIndicator(QWidget): + FINISHED_HIDE_DELAY_MS = 5000 + + stop_requested = pyqtSignal() + open_folder_requested = pyqtSignal() + + def __init__(self, parent=None): + super().__init__( + parent, + Qt.WindowType.FramelessWindowHint + | Qt.WindowType.Tool + | Qt.WindowType.WindowStaysOnTopHint, + ) + self.elapsed_seconds = 0 + self._drag_offset = None + self._press_global_pos = None + self._dragged = False + self.is_finishing = False + self.context_menu = None + + self.setAttribute(Qt.WidgetAttribute.WA_TranslucentBackground) + self.setFixedSize(92, 34) + self.setCursor(Qt.CursorShape.PointingHandCursor) + self.setWindowTitle("Recording") + + layout = QHBoxLayout(self) + layout.setContentsMargins(0, 0, 0, 0) + + self.container = QWidget(self) + self.container.setObjectName("recordingIndicatorContainer") + self.container.setStyleSheet( + """ + QWidget#recordingIndicatorContainer { + background-color: rgba(24, 24, 24, 220); + border-radius: 17px; + } + """ + ) + + inner_layout = QHBoxLayout(self.container) + inner_layout.setContentsMargins(13, 0, 13, 0) + inner_layout.setSpacing(8) + + self.dot = QLabel(self.container) + self.dot.setFixedSize(9, 9) + + self.timer_label = QLabel("00:00", self.container) + self.timer_label.setStyleSheet( + "color: white; font-size: 13px; font-weight: 600;" + ) + self.timer_label.setAlignment(Qt.AlignmentFlag.AlignVCenter) + + inner_layout.addWidget(self.dot) + inner_layout.addWidget(self.timer_label) + layout.addWidget(self.container) + + self.timer = QTimer(self) + self.timer.setInterval(1000) + self.timer.timeout.connect(self.update_elapsed) + self.finished_hide_timer = QTimer(self) + self.finished_hide_timer.setSingleShot(True) + self.finished_hide_timer.timeout.connect(self.hide_recording) + self.set_recording_style() + + def set_recording_style(self): + self.dot.setStyleSheet("background-color: #ff2d2d; border-radius: 4px;") + self.container.setStyleSheet( + """ + QWidget#recordingIndicatorContainer { + background-color: rgba(24, 24, 24, 220); + border-radius: 17px; + } + """ + ) + + def set_finished_style(self): + self.dot.setStyleSheet("background-color: #8a8a8a; border-radius: 4px;") + self.container.setStyleSheet( + """ + QWidget#recordingIndicatorContainer { + background-color: rgba(24, 24, 24, 190); + border-radius: 17px; + } + """ + ) + + @staticmethod + def format_elapsed(seconds): + seconds = max(0, int(seconds)) + minutes, remaining_seconds = divmod(seconds, 60) + return f"{minutes:02d}:{remaining_seconds:02d}" + + def show_recording(self): + self.finished_hide_timer.stop() + self.is_finishing = False + self.setCursor(Qt.CursorShape.PointingHandCursor) + self.elapsed_seconds = 0 + self.update_timer_text() + self.set_recording_style() + self.position_near_taskbar() + self.show() + self.raise_() + self.timer.start() + + def show_finished(self, hide_after_ms=None): + self.timer.stop() + self.is_finishing = True + self.setCursor(Qt.CursorShape.PointingHandCursor) + self.set_finished_style() + self.raise_() + delay_ms = self.FINISHED_HIDE_DELAY_MS if hide_after_ms is None else hide_after_ms + self.finished_hide_timer.start(delay_ms) + + def hide_recording(self): + self.timer.stop() + self.finished_hide_timer.stop() + self.is_finishing = False + self.hide() + + def update_elapsed(self): + self.elapsed_seconds += 1 + self.update_timer_text() + + def update_timer_text(self): + self.timer_label.setText(self.format_elapsed(self.elapsed_seconds)) + + def position_near_taskbar(self): + screen = QApplication.primaryScreen() + if not screen: + return + geometry = screen.availableGeometry() + x = geometry.right() - self.width() - 16 + y = geometry.bottom() - self.height() - 16 + self.move(x, y) + + def request_stop(self): + if self.is_finishing: + return + self.stop_requested.emit() + + def request_open_folder(self): + if self.is_finishing: + self.open_folder_requested.emit() + + def setContextMenu(self, menu): + self.context_menu = menu + + def contextMenuEvent(self, event): + if self.context_menu: + self.context_menu.exec(event.globalPos()) + event.accept() + return + super().contextMenuEvent(event) + + def mousePressEvent(self, event): + if self.is_finishing: + event.accept() + return + if event.button() == Qt.MouseButton.LeftButton: + global_pos = event.globalPosition().toPoint() + self._press_global_pos = global_pos + self._drag_offset = global_pos - self.frameGeometry().topLeft() + self._dragged = False + event.accept() + return + super().mousePressEvent(event) + + def mouseMoveEvent(self, event): + if self.is_finishing: + event.accept() + return + if self._drag_offset is not None: + global_pos = event.globalPosition().toPoint() + if self._press_global_pos is not None: + delta = global_pos - self._press_global_pos + self._dragged = self._dragged or abs(delta.x()) > 3 or abs(delta.y()) > 3 + self.move(global_pos - self._drag_offset) + event.accept() + return + super().mouseMoveEvent(event) + + def mouseReleaseEvent(self, event): + if self.is_finishing: + if event.button() == Qt.MouseButton.LeftButton: + self.request_open_folder() + self._drag_offset = None + self._press_global_pos = None + self._dragged = False + event.accept() + return + if event.button() == Qt.MouseButton.LeftButton: + if not self._dragged: + self.request_stop() + self._drag_offset = None + self._press_global_pos = None + self._dragged = False + event.accept() + return + super().mouseReleaseEvent(event) + + class HotkeyEdit(QLineEdit): """ Custom widget to capture hotkeys by pressing them. @@ -169,6 +372,15 @@ def init_ui(self): group_tray.setLayout(layout_tray) layout.addWidget(group_tray) + # Notifications + group_notifications = QGroupBox("Notifications") + layout_notifications = QVBoxLayout() + self.chk_recording_indicator = QCheckBox("Show floating recording timer") + self.chk_recording_indicator.setChecked(True) + layout_notifications.addWidget(self.chk_recording_indicator) + group_notifications.setLayout(layout_notifications) + layout.addWidget(group_notifications) + # Post-Processing group_post = QGroupBox("Post-Processing & Clipboard") layout_post = QVBoxLayout() @@ -281,6 +493,12 @@ def load_settings(self): self.chk_clipboard.setChecked(data.get("clipboard", False)) self.chk_delete.setChecked(data.get("delete_after", False)) self.chk_delete.setEnabled(self.chk_clipboard.isChecked()) + if "show_recording_indicator" in data: + self.chk_recording_indicator.setChecked( + self._parse_bool_setting(data.get("show_recording_indicator")) + ) + else: + self.chk_recording_indicator.setChecked(True) self.hk_mic.setText(data.get("hk_mic", "")) self.hk_loop.setText(data.get("hk_loop", "")) @@ -306,6 +524,7 @@ def get_settings(self): "quality": self.combo_quality.currentData(), "stereo": self.chk_stereo.isChecked(), "tray_click_mode": self.combo_left_click.currentText(), + "show_recording_indicator": self.chk_recording_indicator.isChecked(), "normalize": self.chk_normalize.isChecked(), "clipboard": self.chk_clipboard.isChecked(), "delete_after": self.chk_delete.isChecked(), @@ -332,6 +551,9 @@ def __init__(self, app): self.tray_icon = QSystemTrayIcon(QIcon(self.icon_idle_path), self.app) self.tray_icon.setToolTip("Simple Audio Recorder (Idle)") self.tray_icon.activated.connect(self.on_tray_activated) + self.recording_indicator = RecordingIndicator() + self.recording_indicator.stop_requested.connect(self.stop_recording) + self.recording_indicator.open_folder_requested.connect(self.open_recordings_folder) self.build_menu() self.tray_icon.show() @@ -380,6 +602,8 @@ def build_menu(self): self.action_stop.setEnabled(False) self.action_settings = QAction("Settings", self) self.action_settings.triggered.connect(self.open_settings) + self.action_open_folder = QAction("Open Recordings Folder", self) + self.action_open_folder.triggered.connect(self.open_recordings_folder) self.action_exit = QAction("Exit", self) self.action_exit.triggered.connect(self.exit_app) @@ -388,9 +612,13 @@ def build_menu(self): self.menu.addAction(self.action_record_both) self.menu.addAction(self.action_stop) self.menu.addSeparator() + self.menu.addAction(self.action_open_folder) self.menu.addAction(self.action_settings) self.menu.addAction(self.action_exit) self.tray_icon.setContextMenu(self.menu) + recording_indicator = getattr(self, "recording_indicator", None) + if recording_indicator: + recording_indicator.setContextMenu(self.menu) def register_hotkeys(self): try: keyboard.unhook_all_hotkeys() # Ensure no old hotkeys are active @@ -420,6 +648,26 @@ def on_tray_activated(self, reason): elif click_mode == "Both": target_mode = "both" self.start_recording(target_mode) + def open_recordings_folder(self): + try: + folder = self.settings_window.get_settings().get("output_folder") or os.getcwd() + folder = os.path.abspath(folder) + if not os.path.exists(folder): + os.makedirs(folder, exist_ok=True) + if sys.platform == "win32": + os.startfile(folder) + elif sys.platform == "darwin": + subprocess.Popen(["open", folder]) + else: + subprocess.Popen(["xdg-open", folder]) + except Exception as e: + self.tray_icon.showMessage( + "Error", + f"Failed to open recordings folder: {e}", + QSystemTrayIcon.MessageIcon.Critical, + 4000, + ) + def open_settings(self): self.settings_window.show() self.settings_window.raise_() @@ -451,9 +699,16 @@ def finish_callback(path, error): self.action_stop.setEnabled(True) self.tray_icon.setIcon(QIcon(self.icon_rec_path)) self.tray_icon.setToolTip(f"Recording ({mode})...") + if settings.get("show_recording_indicator", True): + recording_indicator = getattr(self, "recording_indicator", None) + if recording_indicator: + recording_indicator.show_recording() self.tray_icon.showMessage("Started", f"Recording {mode}", QSystemTrayIcon.MessageIcon.NoIcon, 1000) def stop_recording(self): + recording_indicator = getattr(self, "recording_indicator", None) + if recording_indicator and recording_indicator.isVisible(): + recording_indicator.show_finished(RecordingIndicator.FINISHED_HIDE_DELAY_MS) if self.recorder: self.recorder.stop() def on_recording_finished(self, path, error): @@ -463,6 +718,9 @@ def on_recording_finished(self, path, error): self.action_stop.setEnabled(False) self.tray_icon.setIcon(QIcon(self.icon_idle_path)) self.tray_icon.setToolTip("Simple Audio Recorder (Idle)") + recording_indicator = getattr(self, "recording_indicator", None) + if recording_indicator and not getattr(recording_indicator, "is_finishing", False): + recording_indicator.hide_recording() self.recorder = None if error: @@ -503,4 +761,7 @@ def on_recording_finished(self, path, error): def exit_app(self): if self.recorder: self.recorder.stop() + recording_indicator = getattr(self, "recording_indicator", None) + if recording_indicator: + recording_indicator.hide_recording() self.app.quit() diff --git a/tests/test_gui_hotkeys.py b/tests/test_gui_hotkeys.py new file mode 100644 index 0000000..7760825 --- /dev/null +++ b/tests/test_gui_hotkeys.py @@ -0,0 +1,330 @@ +import json +import os +import tempfile +import unittest +from types import SimpleNamespace +from unittest.mock import patch + +os.environ.setdefault("QT_QPA_PLATFORM", "offscreen") + +from PyQt6.QtCore import QObject, Qt +from PyQt6.QtWidgets import QApplication, QMenu + +from gui import RecordingIndicator, SettingsWindow, TrayApplication + + +class FakeSettingsWindow: + def __init__(self, settings): + self.settings = settings + + def get_settings(self): + return self.settings + + +class FakeRecordingIndicator: + def __init__(self): + self.show_count = 0 + self.hide_count = 0 + self.finished_count = 0 + self.finished_hide_delay_ms = None + self.is_finishing = False + self.context_menu = None + self.visible = True + + def show_recording(self): + self.show_count += 1 + self.is_finishing = False + + def hide_recording(self): + self.hide_count += 1 + self.is_finishing = False + self.visible = False + + def show_finished(self, hide_after_ms=None): + self.finished_count += 1 + self.finished_hide_delay_ms = hide_after_ms + self.is_finishing = True + + def setContextMenu(self, menu): + self.context_menu = menu + + def isVisible(self): + return self.visible + + +class FakeContextMenu: + def __init__(self): + self.exec_count = 0 + + def exec(self, position): + self.exec_count += 1 + + +class FakeContextMenuEvent: + def __init__(self): + self.accepted = False + + def globalPos(self): + return None + + def accept(self): + self.accepted = True + + +class FakeMouseEvent: + def __init__(self, button=Qt.MouseButton.LeftButton): + self._button = button + self.accepted = False + + def button(self): + return self._button + + def accept(self): + self.accepted = True + + +class FakeTrayIcon: + def __init__(self): + self.messages = [] + self.context_menu = None + self.tooltip = None + + def setContextMenu(self, menu): + self.context_menu = menu + + def setIcon(self, icon): + self.icon = icon + + def setToolTip(self, text): + self.tooltip = text + + def showMessage(self, title, message, icon=None, duration=0): + self.messages.append((title, message, icon, duration)) + + +class SettingsWindowRecordingIndicatorTests(unittest.TestCase): + @classmethod + def setUpClass(cls): + cls.app = QApplication.instance() or QApplication([]) + + def make_window(self, data): + temp_dir = tempfile.TemporaryDirectory() + self.addCleanup(temp_dir.cleanup) + config_path = os.path.join(temp_dir.name, "settings.json") + with open(config_path, "w", encoding="utf-8") as fh: + json.dump(data, fh) + + patcher = patch("gui.CONFIG_FILE", config_path) + patcher.start() + self.addCleanup(patcher.stop) + + window = SettingsWindow() + self.addCleanup(window.close) + return window + + def test_recording_indicator_setting_defaults_on(self): + window = self.make_window({}) + + settings = window.get_settings() + + self.assertIs(settings["show_recording_indicator"], True) + + def test_recording_indicator_setting_can_be_disabled(self): + window = self.make_window({"show_recording_indicator": False}) + + settings = window.get_settings() + + self.assertIs(settings["show_recording_indicator"], False) + + +class TrayApplicationMenuTests(unittest.TestCase): + @classmethod + def setUpClass(cls): + cls.app = QApplication.instance() or QApplication([]) + + def make_subject(self): + subject = QObject() + subject.tray_icon = FakeTrayIcon() + subject.recording_indicator = FakeRecordingIndicator() + subject.start_recording = lambda mode: None + subject.stop_recording = lambda: None + subject.open_settings = lambda: None + subject.exit_app = lambda: None + subject.open_recordings_folder = lambda: None + return subject + + def test_build_menu_places_open_folder_before_settings(self): + subject = self.make_subject() + + TrayApplication.build_menu(subject) + + action_texts = [ + action.text() + for action in subject.menu.actions() + if not action.isSeparator() + ] + self.assertLess( + action_texts.index("Open Recordings Folder"), + action_texts.index("Settings"), + ) + + def test_build_menu_shares_context_menu_with_recording_indicator(self): + subject = self.make_subject() + + TrayApplication.build_menu(subject) + + self.assertIs(subject.recording_indicator.context_menu, subject.menu) + + +class RecordingIndicatorTests(unittest.TestCase): + @classmethod + def setUpClass(cls): + cls.app = QApplication.instance() or QApplication([]) + + def test_formats_elapsed_time_as_mm_ss(self): + self.assertEqual(RecordingIndicator.format_elapsed(0), "00:00") + self.assertEqual(RecordingIndicator.format_elapsed(59), "00:59") + self.assertEqual(RecordingIndicator.format_elapsed(60), "01:00") + + def test_click_request_emits_stop_signal(self): + indicator = RecordingIndicator() + self.addCleanup(indicator.close) + calls = [] + indicator.stop_requested.connect(lambda: calls.append("stop")) + + indicator.request_stop() + + self.assertEqual(calls, ["stop"]) + + def test_finished_state_left_click_opens_recordings_folder(self): + indicator = RecordingIndicator() + self.addCleanup(indicator.close) + stop_calls = [] + open_calls = [] + indicator.stop_requested.connect(lambda: stop_calls.append("stop")) + indicator.open_folder_requested.connect(lambda: open_calls.append("open")) + event = FakeMouseEvent() + + indicator.show_finished() + indicator.mouseReleaseEvent(event) + + self.assertEqual(stop_calls, []) + self.assertEqual(open_calls, ["open"]) + self.assertTrue(event.accepted) + + def test_finished_state_keeps_context_menu_available(self): + indicator = RecordingIndicator() + self.addCleanup(indicator.close) + menu = FakeContextMenu() + event = FakeContextMenuEvent() + + indicator.setContextMenu(menu) + indicator.show_finished() + indicator.contextMenuEvent(event) + + self.assertEqual(menu.exec_count, 1) + self.assertTrue(event.accepted) + + def test_finished_state_hides_after_five_seconds(self): + indicator = RecordingIndicator() + self.addCleanup(indicator.close) + + indicator.show_recording() + indicator.show_finished() + + self.assertTrue(indicator.is_finishing) + self.assertTrue(indicator.finished_hide_timer.isActive()) + self.assertEqual(indicator.finished_hide_timer.interval(), 5000) + + def test_new_recording_cancels_pending_finished_hide(self): + indicator = RecordingIndicator() + self.addCleanup(indicator.close) + + indicator.show_finished() + indicator.show_recording() + + self.assertFalse(indicator.is_finishing) + self.assertFalse(indicator.finished_hide_timer.isActive()) + + +class TrayApplicationRecordingIndicatorTests(unittest.TestCase): + def make_subject(self, show_indicator=True): + indicator = FakeRecordingIndicator() + subject = SimpleNamespace( + recorder=None, + last_mode=None, + settings_window=FakeSettingsWindow( + { + "device_id": "mic1", + "output_folder": "D:/recordings", + "format": "flac", + "quality": "balanced", + "stereo": False, + "normalize": True, + "show_recording_indicator": show_indicator, + "clipboard": False, + } + ), + signals=SimpleNamespace( + recording_finished=SimpleNamespace(emit=lambda path, error: None) + ), + action_record_mic=SimpleNamespace(setEnabled=lambda enabled: None), + action_record_loop=SimpleNamespace(setEnabled=lambda enabled: None), + action_record_both=SimpleNamespace(setEnabled=lambda enabled: None), + action_stop=SimpleNamespace(setEnabled=lambda enabled: None), + tray_icon=FakeTrayIcon(), + icon_rec_path="recording.ico", + icon_idle_path="idle.ico", + recording_indicator=indicator, + ) + return subject, indicator + + def test_start_recording_shows_indicator_when_enabled(self): + subject, indicator = self.make_subject(show_indicator=True) + + with patch("gui.QIcon"), patch("gui.AudioRecorder") as AudioRecorder: + TrayApplication.start_recording(subject, "mic") + + AudioRecorder.return_value.start.assert_called_once_with() + self.assertEqual(indicator.show_count, 1) + + def test_start_recording_skips_indicator_when_disabled(self): + subject, indicator = self.make_subject(show_indicator=False) + + with patch("gui.QIcon"), patch("gui.AudioRecorder"): + TrayApplication.start_recording(subject, "mic") + + self.assertEqual(indicator.show_count, 0) + + def test_recording_finished_hides_indicator(self): + subject, indicator = self.make_subject(show_indicator=True) + + with patch("gui.QIcon"): + TrayApplication.on_recording_finished(subject, "D:/recordings/test.flac", "") + + self.assertEqual(indicator.hide_count, 1) + + def test_recording_finished_keeps_finished_indicator_until_delay_expires(self): + subject, indicator = self.make_subject(show_indicator=True) + indicator.is_finishing = True + + with patch("gui.QIcon"): + TrayApplication.on_recording_finished(subject, "D:/recordings/test.flac", "") + + self.assertEqual(indicator.hide_count, 0) + + def test_stop_recording_marks_indicator_finished_immediately(self): + subject, indicator = self.make_subject(show_indicator=True) + subject.recorder = SimpleNamespace(stop=lambda: setattr(subject, "stopped", True)) + subject.stopped = False + + TrayApplication.stop_recording(subject) + + self.assertTrue(subject.stopped) + self.assertEqual(indicator.finished_count, 1) + self.assertEqual(indicator.finished_hide_delay_ms, 5000) + self.assertEqual(indicator.hide_count, 0) + + +if __name__ == "__main__": + unittest.main()