diff --git a/src/controllers/main_controller.py b/src/controllers/main_controller.py index 46d9232..3d341ac 100644 --- a/src/controllers/main_controller.py +++ b/src/controllers/main_controller.py @@ -26,6 +26,8 @@ def __init__(self, transcription: Transcription, view): # type: ignore[no-untyp self._whisperx_handler = WhisperXHandler() + self._bind_views() + # PUBLIC METHODS def select_file(self) -> None: @@ -53,7 +55,9 @@ def select_directory(self) -> None: :return: None """ - dir_path = filedialog.askdirectory() + dir_path = filedialog.askdirectory( + initialdir=self.transcription.audio_source_path + ) if dir_path: self.view.on_select_path_success(dir_path) @@ -75,9 +79,12 @@ def prepare_for_transcription(self, transcription: Transcription) -> None: "No output file types selected. Please select at least one." ) + # TMP UNTIL REFACTOR + transcription.output_path = self.transcription.output_path self.transcription = transcription if transcription.audio_source == AudioSource.FILE: + assert transcription.audio_source_path self._prepare_for_file_transcription(transcription.audio_source_path) elif transcription.audio_source == AudioSource.MIC: threading.Thread(target=self._start_recording_from_mic).start() @@ -112,13 +119,16 @@ def stop_recording_from_mic(self) -> None: self.view.on_stop_recording_from_mic() def save_transcription( - self, file_path: Path, should_autosave: bool, should_overwrite: bool + self, + output_path: Path, + should_autosave: bool, + should_overwrite: bool, ) -> None: """ Saves the transcription to a text file and optionally generate subtitles. - :param file_path: The path where the text file will be saved. - :type file_path: Path + :param output_path: The path where the text file will be saved. + :type output_path: Path :param should_autosave: Indicates whether the text file should be saved automatically without showing a file dialog. :type should_autosave: bool @@ -127,7 +137,7 @@ def save_transcription( :type should_overwrite: bool :return: None """ - save_file_path = self._get_save_path(file_path, should_autosave) + save_file_path = self._get_save_path(output_path, should_autosave) if not save_file_path: return @@ -166,6 +176,31 @@ def save_transcription( # PRIVATE METHODS + def _bind_views(self) -> None: + """ + Bind the widgets in the view to the methods of the controller. + + :return: None + """ + self.view.bind_btn_output_path_file_explorer(self._select_output_path) + + def _select_output_path(self) -> None: + """ + Opens a directory selection dialog for the user to choose an output path, + and updates the transcription object's output path and view if a valid directory + is selected. + + :return: None + """ + output_path = filedialog.askdirectory( + initialdir=self.transcription.output_path + or self.transcription.audio_source_path + ) + + if output_path: + self.transcription.output_path = Path(output_path) + self.view.display_output_path(output_path) + def _prepare_for_file_transcription(self, file_path: Path) -> None: """ Prepares the system for transcription from a file by verifying if the file @@ -216,6 +251,8 @@ async def _handle_transcription_process(self) -> None: :return: None """ + assert self.transcription.audio_source_path + try: if self.transcription.audio_source == AudioSource.DIRECTORY: await self._transcribe_directory(self.transcription.audio_source_path) @@ -243,7 +280,10 @@ async def _transcribe_directory(self, dir_path: Path) -> None: # Run all tasks concurrently await asyncio.gather(*tasks) - self.view.display_text(f"Files from '{dir_path}' successfully transcribed.") + self.view.display_text( + f"Files from '{dir_path}' successfully transcribed and stored in " + f"{self.transcription.output_path}." + ) else: raise ValueError( "Error: The directory path is invalid or doesn't contain valid " @@ -265,6 +305,15 @@ async def _transcribe_file(self, file_path: Path) -> None: transcription = self.transcription transcription.audio_source_path = file_path + output_path = file_path + if self.transcription.should_autosave: + if (path := self.transcription.output_path) and path.exists(): + output_path = path / f"{file_path.stem}{file_path.suffix}" + else: + raise ValueError( + "The specified output path is invalid. Please enter a valid one." + ) + if self.transcription.method == TranscriptionMethod.GOOGLE_API: self.transcription.text = AudioHandler.get_transcription( transcription=transcription, @@ -283,6 +332,7 @@ async def _transcribe_file(self, file_path: Path) -> None: ) if self.transcription.audio_source in [AudioSource.MIC, AudioSource.YOUTUBE]: + assert self.transcription.audio_source_path self.transcription.audio_source_path.unlink() # Remove tmp file if self.transcription.audio_source != AudioSource.DIRECTORY: @@ -290,7 +340,7 @@ async def _transcribe_file(self, file_path: Path) -> None: if self.transcription.should_autosave: self.save_transcription( - file_path, + output_path, should_autosave=True, should_overwrite=self.transcription.should_overwrite, ) @@ -302,6 +352,8 @@ def _get_files_to_transcribe_from_directory(self) -> list[Path]: :return: A list of file paths to transcribe in the directory. :rtype: list[Path] """ + assert self.transcription.audio_source_path + if not self.transcription.output_file_types: raise ValueError( "No output file types selected. Please select at least one." @@ -394,7 +446,14 @@ def _get_save_path(self, file_path: Path, should_autosave: bool) -> Path: initial_file_name += f".{file_type}" if should_autosave: - return file_dir / initial_file_name + if self.transcription.output_path: + return self.transcription.output_path / initial_file_name + + raise ValueError( + "Something went wrong during autosave. Please report the issue in the " + "project GitHub " + "(https://github.com/HenestrosaDev/audiotext/issues/new/choose)" + ) else: default_extension = ( f".{file_type}" if self.transcription.output_file_types else None diff --git a/src/handlers/audio_handler.py b/src/handlers/audio_handler.py index 1cfc9b8..0fe5453 100644 --- a/src/handlers/audio_handler.py +++ b/src/handlers/audio_handler.py @@ -43,6 +43,8 @@ def get_transcription( chunks_directory.mkdir(exist_ok=True) try: + assert transcription.audio_source_path + audio = AudioHandler.load_audio_file( transcription.audio_source_path, chunks_directory ) diff --git a/src/models/transcription.py b/src/models/transcription.py index e504653..552e439 100644 --- a/src/models/transcription.py +++ b/src/models/transcription.py @@ -10,9 +10,10 @@ class Transcription: text: Optional[str] = None language_code: Optional[str] = None audio_source: Optional[AudioSource] = None - audio_source_path: Path = Path("/") + audio_source_path: Optional[Path] = None method: Optional[TranscriptionMethod] = None output_file_types: Optional[list[str]] = None + output_path: Optional[Path] = None should_translate: bool = False should_autosave: bool = False should_overwrite: bool = False diff --git a/src/views/main_window.py b/src/views/main_window.py index 7603e44..aeeb7a9 100644 --- a/src/views/main_window.py +++ b/src/views/main_window.py @@ -56,6 +56,10 @@ def __init__( self._config_whisper_api = config_whisper_api self._config_whisperx = config_whisperx + # State + self._audio_source = AudioSource(self._config_transcription.audio_source) + self._is_transcribing_from_mic = False + # Init the controller self._controller: Union[MainController, None] = None @@ -66,10 +70,6 @@ def __init__( # Update the state of the UI based on the configuration after setup self._on_audio_source_change(self._config_transcription.audio_source) - # State - self._audio_source = AudioSource(self._config_transcription.audio_source) - self._is_transcribing_from_mic = False - # To handle debouncing self._after_id = None # To store the `after()` method ID @@ -695,30 +695,81 @@ def _init_main_content(self) -> None: self.frm_main_entry.grid(row=0, column=1, padx=20, pady=(20, 0), sticky=ctk.EW) self.frm_main_entry.grid_columnconfigure(1, weight=1) - ## 'Path' entry - self.lbl_path = ctk.CTkLabel( + ## 'Input path' entry + self.lbl_input_path = ctk.CTkLabel( master=self.frm_main_entry, - text="Path", + text="Input path", font=ctk.CTkFont(size=14, weight="bold"), ) - self.lbl_path.grid(row=0, column=0, padx=(0, 15), sticky=ctk.W) + self.lbl_input_path.grid(row=0, column=0, padx=(0, 15), sticky=ctk.W) - self.ent_path = ctk.CTkEntry(master=self.frm_main_entry) - self.ent_path.grid(row=0, column=1, padx=0, sticky=ctk.EW) + self.ent_input_path = ctk.CTkEntry(master=self.frm_main_entry) + self.ent_input_path.grid(row=0, column=1, padx=0, sticky=ctk.EW) - ## File explorer image button + ## Input file explorer image button self.img_file_explorer = ctk.CTkImage( Image.open(ph.ROOT_PATH / ph.IMG_RELATIVE_PATH / "file-explorer.png"), size=(24, 24), ) - self.btn_file_explorer = ctk.CTkButton( + self.btn_input_path_file_explorer = ctk.CTkButton( self.frm_main_entry, image=self.img_file_explorer, text="", width=32, command=self._on_select_path, ) - self.btn_file_explorer.grid(row=0, column=2, padx=(15, 0), sticky=ctk.E) + self.btn_input_path_file_explorer.grid( + row=0, + column=2, + padx=(15, 0), + sticky=ctk.E, + ) + + ## 'Output path' entry + pady_output_path = (10, 0) + + self.lbl_output_path = ctk.CTkLabel( + master=self.frm_main_entry, + text="Output path", + font=ctk.CTkFont(size=14, weight="bold"), + ) + self.lbl_output_path.grid( + row=1, + column=0, + padx=(0, 15), + pady=pady_output_path, + sticky=ctk.W, + ) + if not self._config_transcription.autosave: + self.lbl_output_path.grid_remove() + + self.ent_output_path = ctk.CTkEntry(master=self.frm_main_entry) + self.ent_output_path.grid( + row=1, + column=1, + padx=0, + pady=pady_output_path, + sticky=ctk.EW, + ) + if not self._config_transcription.autosave: + self.ent_output_path.grid_remove() + + ## Output file explorer image button + self.btn_output_path_file_explorer = ctk.CTkButton( + self.frm_main_entry, + image=self.img_file_explorer, + text="", + width=32, + ) + self.btn_output_path_file_explorer.grid( + row=1, + column=2, + padx=(15, 0), + pady=pady_output_path, + sticky=ctk.E, + ) + if not self._config_transcription.autosave: + self.btn_output_path_file_explorer.grid_remove() ## Textbox self.tbx_transcription = ctk.CTkTextbox(master=self, wrap=ctk.WORD) @@ -775,6 +826,11 @@ def _init_main_content(self) -> None: # PUBLIC METHODS (called by the controller) + ## Bindings + + def bind_btn_output_path_file_explorer(self, callback: Callable[..., Any]) -> None: + self.btn_output_path_file_explorer.configure(command=callback) + def on_select_path_success(self, path: str) -> None: """ Handles the successful selection of a file or directory path by updating the @@ -784,7 +840,7 @@ def on_select_path_success(self, path: str) -> None: :type path: str :return: None """ - self.ent_path.configure(textvariable=ctk.StringVar(self, path)) + self.ent_input_path.configure(textvariable=ctk.StringVar(self, path)) def on_processed_transcription(self) -> None: """ @@ -792,7 +848,7 @@ def on_processed_transcription(self) -> None: :return: None """ - self.ent_path.configure(state=ctk.NORMAL) + self.ent_input_path.configure(state=ctk.NORMAL) self.omn_transcription_language.configure(state=ctk.NORMAL) self.omn_audio_source.configure(state=ctk.NORMAL) self.omn_transcription_method.configure(state=ctk.NORMAL) @@ -833,6 +889,9 @@ def display_text(self, text: str) -> None: # PRIVATE METHODS + def display_output_path(self, path: str) -> None: + self.ent_output_path.configure(textvariable=ctk.StringVar(self, path)) + def _get_transcription_properties(self) -> dict[str, Any]: """ Checks the current state of user interface elements to determine the @@ -970,44 +1029,52 @@ def _on_audio_source_change(self, option: str) -> None: :type option: str """ self._audio_source = AudioSource(option) - self.ent_path.configure(textvariable=ctk.StringVar(self, "")) + self.ent_input_path.configure(textvariable=ctk.StringVar(self, "")) self._on_config_change( section=ConfigTranscription.Key.SECTION, key=ConfigTranscription.Key.AUDIO_SOURCE, new_value=option, ) + # `frm_main_entry` might be hidden if autosave is not activated + if self._audio_source != AudioSource.MIC: + self._toggle_input_path_fields(should_show=True) + self.frm_main_entry.grid() + if self._audio_source != AudioSource.DIRECTORY: self.chk_autosave.configure(state=ctk.NORMAL) self.btn_save.configure(state=ctk.NORMAL) if self._audio_source in [AudioSource.FILE, AudioSource.DIRECTORY]: self.btn_main_action.configure(text="Generate transcription") - self.lbl_path.configure(text="Path") - self.btn_file_explorer.grid() - self.frm_main_entry.grid() + self.lbl_input_path.configure(text="Input path") + self.btn_input_path_file_explorer.grid() if self._audio_source == AudioSource.DIRECTORY: self.chk_autosave.select() self._on_autosave_change() self.chk_autosave.configure(state=ctk.DISABLED) - self.chk_overwrite_files.configure(state=ctk.NORMAL) self.btn_save.configure(state=ctk.DISABLED) elif self._audio_source == AudioSource.MIC: self.btn_main_action.configure(text="Start recording") - self.frm_main_entry.grid_remove() + self._toggle_input_path_fields(should_show=False) + + if self.chk_autosave.get(): + self._toggle_output_path_fields(should_show=True) + self.frm_main_entry.grid() + else: + self.frm_main_entry.grid_remove() elif self._audio_source == AudioSource.YOUTUBE: self.btn_main_action.configure(text="Generate transcription") - self.lbl_path.configure(text="YouTube video URL") - self.btn_file_explorer.grid_remove() - self.frm_main_entry.grid() + self.lbl_input_path.configure(text="YouTube video URL") + self.btn_input_path_file_explorer.grid_remove() def _on_select_path(self) -> None: """ - Triggers when `btn_file_explorer` is clicked to select the path of the file or - directory to transcribe. + Triggers when `btn_input_path_file_explorer` or `btn_output_file_explorer` is + clicked to select the path of the file or directory to transcribe. :return: None """ @@ -1073,7 +1140,7 @@ def _prepare_ui_for_transcription(self) -> None: :return: None """ - self.ent_path.configure(state=ctk.DISABLED) + self.ent_input_path.configure(state=ctk.DISABLED) self.omn_transcription_language.configure(state=ctk.DISABLED) self.omn_audio_source.configure(state=ctk.DISABLED) self.omn_transcription_method.configure(state=ctk.DISABLED) @@ -1103,7 +1170,7 @@ def _on_main_action(self) -> None: transcription = Transcription(**self._get_transcription_properties()) if self._audio_source in [AudioSource.FILE, AudioSource.DIRECTORY]: - transcription.audio_source_path = Path(self.ent_path.get()) + transcription.audio_source_path = Path(self.ent_input_path.get()) elif self._audio_source == AudioSource.MIC: if self._is_transcribing_from_mic: self._controller.stop_recording_from_mic() @@ -1111,7 +1178,7 @@ def _on_main_action(self) -> None: else: self._on_start_recording_from_mic() elif self._audio_source == AudioSource.YOUTUBE: - transcription.youtube_url = self.ent_path.get() + transcription.youtube_url = self.ent_input_path.get() self._controller.prepare_for_transcription(transcription) @@ -1125,7 +1192,7 @@ def _on_save_transcription(self) -> None: assert self._controller self._controller.save_transcription( - file_path=Path(self.ent_path.get()), + output_path=Path(self.ent_input_path.get()), should_autosave=False, should_overwrite=False, ) @@ -1241,6 +1308,8 @@ def _on_autosave_change(self) -> None: if self.chk_autosave.get(): self.chk_overwrite_files.configure(state=ctk.NORMAL) + self._toggle_output_path_fields(should_show=True) + self.frm_main_entry.grid() # in case self._audio_source is MIC else: if self.chk_overwrite_files.get(): self._on_config_change( @@ -1249,9 +1318,60 @@ def _on_autosave_change(self) -> None: new_value="False", ) + self._toggle_output_path_fields(should_show=False) + + if self._audio_source == AudioSource.MIC: + self.frm_main_entry.grid_remove() + self.chk_overwrite_files.deselect() self.chk_overwrite_files.configure(state=ctk.DISABLED) + def _toggle_input_path_fields(self, should_show: bool) -> None: + """ + Toggles the visibility of the input path fields based on the provided flag. + + This function controls the display of the input path-related widgets + (`ent_input_path`, `lbl_input_path`, `btn_input_path_file_explorer`) by either + placing them on the grid or removing them, depending on the value of + `should_show`. + + :param should_show: If True, the input path fields will be displayed. If False, + the fields will be hidden from the layout. + :type should_show: bool + :return: None + """ + if should_show: + self.ent_input_path.grid() + self.lbl_input_path.grid() + self.btn_input_path_file_explorer.grid() + else: + self.lbl_input_path.grid_remove() + self.ent_input_path.grid_remove() + self.btn_input_path_file_explorer.grid_remove() + + def _toggle_output_path_fields(self, should_show: bool) -> None: + """ + Toggles the visibility of the output path fields based on the provided flag. + + This function controls the display of the input path-related widgets + (`ent_output_path`, `lbl_output_path`, `btn_output_path_file_explorer`) by + either placing them on the grid or removing them, depending on the value of + `should_show`. + + :param should_show: If True, the input path fields will be displayed. If False, + the fields will be hidden from the layout. + :type should_show: bool + :return: None + """ + if should_show: + self.ent_output_path.grid() + self.lbl_output_path.grid() + self.btn_output_path_file_explorer.grid() + else: + self.lbl_output_path.grid_remove() + self.ent_output_path.grid_remove() + self.btn_output_path_file_explorer.grid_remove() + def _on_overwrite_files_change(self) -> None: new_value = "True" if self.chk_overwrite_files.get() else "False"