From 64e6f09b54e041479de91be73fcc3560641c8a18 Mon Sep 17 00:00:00 2001 From: phoenixray2000 Date: Mon, 18 May 2026 14:48:17 +0800 Subject: [PATCH 1/5] 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/5] 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/5] 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/5] 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/5] 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!