diff --git a/utils/__pycache__/hotkey_manager.cpython-312.pyc b/utils/__pycache__/hotkey_manager.cpython-312.pyc index 2fe0ce5..af38859 100644 Binary files a/utils/__pycache__/hotkey_manager.cpython-312.pyc and b/utils/__pycache__/hotkey_manager.cpython-312.pyc differ diff --git a/utils/__pycache__/text_to_mic.cpython-312.pyc b/utils/__pycache__/text_to_mic.cpython-312.pyc index b771725..3f3d06f 100644 Binary files a/utils/__pycache__/text_to_mic.cpython-312.pyc and b/utils/__pycache__/text_to_mic.cpython-312.pyc differ diff --git a/utils/hotkey_manager.py b/utils/hotkey_manager.py index 357bdc5..c16ff35 100644 --- a/utils/hotkey_manager.py +++ b/utils/hotkey_manager.py @@ -40,6 +40,12 @@ class HotkeyManager: lambda: self.hotkey_play_last_audio_trigger() )) + # Add cancel operation hotkey + self.hotkeys.append(keyboard.add_hotkey( + self.format_shortcut(hotkey_settings["cancel_operation"]), + lambda: self.hotkey_cancel_operation_trigger() + )) + return True except Exception as e: print(f"Error registering hotkeys: {e}") @@ -181,6 +187,48 @@ class HotkeyManager: else: self.app.stop_recording(auto_play=True) + def hotkey_cancel_operation_trigger(self): + """Cancel current operation (recording or playback).""" + print("Cancel operation hotkey triggered") + + # Play feedback sound first + self.app.play_sound('assets/pop.wav') + + # For recording cancellation + if self.app.recording: + print("Canceling recording operation") + # Schedule the stop_recording call on the main thread to avoid threading issues + self.app.after(100, lambda: self._safe_cancel_recording()) + return + + # For playback cancellation + if hasattr(self.app, 'is_playing') and self.app.is_playing: + print("Canceling playback operation") + # Schedule the stop_playback call on the main thread to avoid threading issues + self.app.after(100, lambda: self._safe_cancel_playback()) + return + + print("No active operation to cancel") + + def _safe_cancel_recording(self): + """Safely cancel recording on the main thread.""" + try: + if self.app.recording: + self.app.stop_recording(cancel_save=True) + self.app.recording = False + print("Recording cancelled successfully") + except Exception as e: + print(f"Error canceling recording: {e}") + + def _safe_cancel_playback(self): + """Safely cancel playback on the main thread.""" + try: + if hasattr(self.app, 'stop_playback') and callable(self.app.stop_playback): + self.app.stop_playback() + print("Playback cancelled successfully") + except Exception as e: + print(f"Error canceling playback: {e}") + @staticmethod def hotkey_settings_dialog(app): """Show the hotkey settings dialog with interactive key capture.""" @@ -712,7 +760,8 @@ class HotkeyManager: default_shortcuts = { "record_start_stop": ["ctrl", "shift", "0"], "stop_recording": ["ctrl", "shift", "9"], - "play_last_audio": ["ctrl", "shift", "8"] + "play_last_audio": ["ctrl", "shift", "8"], + "cancel_operation": ["ctrl", "shift", "1"] } # Update settings @@ -763,6 +812,7 @@ class HotkeyManager: record_shortcut = hotkey_manager.format_shortcut(settings["hotkeys"]["record_start_stop"]) play_shortcut = hotkey_manager.format_shortcut(settings["hotkeys"]["play_last_audio"]) stop_shortcut = hotkey_manager.format_shortcut(settings["hotkeys"]["stop_recording"]) + cancel_shortcut = hotkey_manager.format_shortcut(settings["hotkeys"]["cancel_operation"]) instructions = f"""Available Hotkeys: @@ -775,6 +825,9 @@ class HotkeyManager: 3. {stop_shortcut} - Stop Recording Immediately stops any active recording. +4. {cancel_shortcut} - Cancel Operation + Cancels the current operation (recording or playback) without saving or processing. + These hotkeys work globally across your system, even when the app is minimized. You can customize these hotkeys in Settings → Hotkey Settings. diff --git a/utils/text_to_mic.py b/utils/text_to_mic.py index d429c08..4118fb4 100644 --- a/utils/text_to_mic.py +++ b/utils/text_to_mic.py @@ -7,6 +7,7 @@ import wave import webbrowser import json import sys +import time from pystray import Icon as icon, MenuItem as item, Menu as menu from PIL import Image, ImageDraw, ImageTk @@ -684,14 +685,30 @@ Please also make sure you read the Terms of use and licence statement before usi return resampled_file_path def play_audio_multiplexed(self, file_paths, device_indices): - - p = pyaudio.PyAudio() - streams = [] + """Play audio files to multiple devices with better cancellation handling.""" + # Stop any existing playback first + if hasattr(self, 'is_playing') and self.is_playing: + self.stop_playback() + # Add a small delay to ensure previous resources are cleaned up + time.sleep(0.2) + + # Make p and streams accessible for stop_playback + try: + self.current_playback_p = pyaudio.PyAudio() + self.current_playback_streams = [] + self.is_playing = True + except Exception as e: + print(f"Error initializing PyAudio: {e}") + messagebox.showerror("Audio Error", f"Failed to initialize audio system: {e}") + return try: # Open all files and start all streams for file_path, device_index in zip(file_paths, device_indices): - + if not self.is_playing: + print("Playback canceled during initialization") + break + try: # Ensure the file_path is a string when opening the file wf = wave.open(str(file_path), 'rb') @@ -703,7 +720,6 @@ Please also make sure you read the Terms of use and licence statement before usi continue try: - # Ensure output audio sample rate matches that of the selected device device_info = self.get_device_info(device_index) sample_rate = int(device_info['defaultSampleRate']) # Fetch default sample rate from device info @@ -718,46 +734,139 @@ Please also make sure you read the Terms of use and licence statement before usi # Make the audio file sample rate match the device output sample rate # if there is a mismatch (prevents playback speed issues or crashes) if sample_rate != wf_frame_rate: - #if mismatch, make a new resampled version that matches the output device + # If mismatch, make a new resampled version that matches the output device resampled_file_path = self.resample_audio(str(file_path), sample_rate) - #update the playback file to the new resampled file + # Update the playback file to the new resampled file file_path = resampled_file_path - #re-open the new file for processing + # Re-open the new file for processing + wf.close() # Close the original file first wf = wave.open(str(file_path), 'rb') - #Create a stream from our file - stream = p.open(format=p.get_format_from_width(wf.getsampwidth()), - channels=wf.getnchannels(), - rate=sample_rate, - output=True, - output_device_index=int(device_index)) + # Create a stream from our file + stream = self.current_playback_p.open( + format=self.current_playback_p.get_format_from_width(wf.getsampwidth()), + channels=wf.getnchannels(), + rate=sample_rate, + output=True, + output_device_index=int(device_index) + ) except Exception as e: messagebox.showerror("Stream Creation Error", f"Failed to create audio stream for device index {device_index}: {str(e)}") wf.close() continue - streams.append((stream, wf)) + self.current_playback_streams.append((stream, wf)) - # Play interleaved - active_streams = len(streams) - while active_streams > 0: - for stream, wf in streams: + # Play interleaved using a more robust approach + self._play_audio_streams() + + except Exception as e: + print(f"Playback setup error: {e}") + messagebox.showerror("Playback Error", f"Error setting up playback: {e}") + self.stop_playback() + + def _play_audio_streams(self): + """Handle the actual audio playback in chunks, with better error handling.""" + if not hasattr(self, 'current_playback_streams') or not self.current_playback_streams: + print("No streams to play") + self.stop_playback() + return + + try: + finished_streams = [] + + # Process one chunk from each stream + for stream, wf in self.current_playback_streams: + if not self.is_playing: + print("Playback canceled during streaming") + break + + try: data = wf.readframes(1024) if data: stream.write(data) else: - stream.stop_stream() - stream.close() - wf.close() - streams.remove((stream, wf)) - active_streams -= 1 - + # Mark this stream as finished + finished_streams.append((stream, wf)) + except Exception as e: + print(f"Error during stream playback: {e}") + # Add to finished streams if there's an error + finished_streams.append((stream, wf)) + + # Remove finished streams + for stream_pair in finished_streams: + try: + stream, wf = stream_pair + stream.stop_stream() + stream.close() + wf.close() + self.current_playback_streams.remove(stream_pair) + except Exception as e: + print(f"Error cleaning up finished stream: {e}") + + # If we still have streams and playback is active, schedule the next chunk + if self.current_playback_streams and self.is_playing: + self.after(1, self._play_audio_streams) # Schedule next chunk processing + else: + # All done or canceled, clean up + self.stop_playback() + except Exception as e: - messagebox.showerror("Playback Error", f"Error during multiplexed playback: {e}") - finally: - p.terminate() - + print(f"Error in _play_audio_streams: {e}") + self.stop_playback() + + def stop_playback(self): + """Stop any active audio playback.""" + print("Attempting to stop playback") + + # Set flag first to exit any playback loops + self.is_playing = False + + try: + # Close any active streams + if hasattr(self, 'current_playback_streams'): + # Make a copy of the list to safely iterate while potentially modifying + streams_to_close = list(self.current_playback_streams) + + for stream, wf in streams_to_close: + try: + # Check if stream exists and is active before attempting to stop it + if stream and stream.is_active(): + stream.stop_stream() + if stream: + stream.close() + if wf: + wf.close() + except Exception as e: + print(f"Error closing stream: {e}") + + # Clear the list after processing all streams + self.current_playback_streams = [] + + # Terminate PyAudio instance - do this last and carefully + if hasattr(self, 'current_playback_p') and self.current_playback_p: + try: + # Add a small delay to ensure streams are properly closed before terminating + self.after(100, self._complete_playback_termination) + except Exception as e: + print(f"Error scheduling PyAudio termination: {e}") + + except Exception as e: + print(f"Error in stop_playback: {e}") + # Don't reraise - we want to prevent crashes + + def _complete_playback_termination(self): + """Complete the termination of PyAudio in a separate step to avoid crashes.""" + try: + if hasattr(self, 'current_playback_p') and self.current_playback_p: + self.current_playback_p.terminate() + self.current_playback_p = None + print("PyAudio terminated successfully") + except Exception as e: + print(f"Error terminating PyAudio: {e}") + # Still clear the reference even if termination fails + self.current_playback_p = None def play_last_audio(self): @@ -1045,6 +1154,12 @@ Please also make sure you read the Terms of use and licence statement before usi try: with open(settings_file, "r") as f: settings = json.load(f) + + # Check if the new cancel_operation hotkey exists + if "hotkeys" in settings and "cancel_operation" not in settings["hotkeys"]: + settings["hotkeys"]["cancel_operation"] = ["ctrl", "shift", "1"] + self.save_settings_to_JSON(settings) + except FileNotFoundError: # Default settings settings = { @@ -1057,7 +1172,8 @@ Please also make sure you read the Terms of use and licence statement before usi "hotkeys": { "record_start_stop": ["ctrl", "shift", "0"], "stop_recording": ["ctrl", "shift", "9"], - "play_last_audio": ["ctrl", "shift", "8"] + "play_last_audio": ["ctrl", "shift", "8"], + "cancel_operation": ["ctrl", "shift", "1"] } } self.save_settings_to_JSON(settings)