diff --git a/buzz/settings/settings.py b/buzz/settings/settings.py index ac4c3603..4e722495 100644 --- a/buzz/settings/settings.py +++ b/buzz/settings/settings.py @@ -27,6 +27,9 @@ class Settings: RECORDING_TRANSCRIBER_MODE = "recording-transcriber/mode" RECORDING_TRANSCRIBER_SILENCE_THRESHOLD = "recording-transcriber/silence-threshold" RECORDING_TRANSCRIBER_LINE_SEPARATOR = "recording-transcriber/line-separator" + RECORDING_TRANSCRIBER_EXPORT_FILE_TYPE = "recording-transcriber/export-file-type" + RECORDING_TRANSCRIBER_EXPORT_MAX_ENTRIES = "recording-transcriber/export-max-entries" + RECORDING_TRANSCRIBER_EXPORT_FILE_NAME = "recording-transcriber/export-file-name" PRESENTATION_WINDOW_TEXT_COLOR = "presentation-window/text-color" PRESENTATION_WINDOW_BACKGROUND_COLOR = "presentation-window/background-color" diff --git a/buzz/transcriber/recording_transcriber.py b/buzz/transcriber/recording_transcriber.py index 18c7b456..23ecf54c 100644 --- a/buzz/transcriber/recording_transcriber.py +++ b/buzz/transcriber/recording_transcriber.py @@ -406,8 +406,6 @@ class RecordingTranscriber(QObject): "--threads", str(os.getenv("BUZZ_WHISPERCPP_N_THREADS", (os.cpu_count() or 8) // 2)), "--model", self.model_path, "--no-timestamps", - # on Windows context causes duplications of last message - "--no-context", # Protections against hallucinated repetition. Seems to be problem on macOS # https://github.com/ggml-org/whisper.cpp/issues/1507 "--max-context", "64", diff --git a/buzz/widgets/preferences_dialog/general_preferences_widget.py b/buzz/widgets/preferences_dialog/general_preferences_widget.py index af569091..2d9c7f46 100644 --- a/buzz/widgets/preferences_dialog/general_preferences_widget.py +++ b/buzz/widgets/preferences_dialog/general_preferences_widget.py @@ -188,6 +188,13 @@ class GeneralPreferencesWidget(QWidget): layout.addRow(_("Live recording mode"), self.recording_transcriber_mode) + export_note_label = QLabel( + _("Note: Live recording export settings will be moved to the Advanced Settings in the Live Recording screen in a future version."), + self, + ) + export_note_label.setWordWrap(True) + layout.addRow("", export_note_label) + self.reduce_gpu_memory_enabled = self.settings.value( key=Settings.Key.REDUCE_GPU_MEMORY, default_value=False ) diff --git a/buzz/widgets/recording_transcriber_widget.py b/buzz/widgets/recording_transcriber_widget.py index c68a9d2f..4011c46f 100644 --- a/buzz/widgets/recording_transcriber_widget.py +++ b/buzz/widgets/recording_transcriber_widget.py @@ -1,3 +1,5 @@ +import csv +import io import os import re import enum @@ -451,7 +453,17 @@ class RecordingTranscriberWidget(QWidget): date_time_now = datetime.datetime.now().strftime("%d-%b-%Y %H-%M-%S") - export_file_name_template = Settings().get_default_export_file_template() + custom_template = self.settings.value( + key=Settings.Key.RECORDING_TRANSCRIBER_EXPORT_FILE_NAME, + default_value="", + ) + export_file_name_template = custom_template if custom_template else Settings().get_default_export_file_template() + + export_file_type = self.settings.value( + key=Settings.Key.RECORDING_TRANSCRIBER_EXPORT_FILE_TYPE, + default_value="txt", + ) + ext = ".csv" if export_file_type == "csv" else ".txt" export_file_name = ( export_file_name_template.replace("{{ input_file_name }}", "live recording") @@ -460,14 +472,21 @@ class RecordingTranscriberWidget(QWidget): .replace("{{ model_type }}", self.transcription_options.model.model_type.value) .replace("{{ model_size }}", self.transcription_options.model.whisper_model_size or "") .replace("{{ date_time }}", date_time_now) - + ".txt" + + ext ) + translated_ext = ".translated" + ext + if not os.path.isdir(export_folder): self.export_enabled = False self.transcript_export_file = os.path.join(export_folder, export_file_name) - self.translation_export_file = self.transcript_export_file.replace(".txt", ".translated.txt") + self.translation_export_file = self.transcript_export_file.replace(ext, translated_ext) + + # Clear export files at the start of each recording session + for path in (self.transcript_export_file, self.translation_export_file): + if os.path.isfile(path): + self.write_to_export_file(path, "", mode="w") def on_transcription_options_changed( self, transcription_options: TranscriptionOptions @@ -706,6 +725,64 @@ class RecordingTranscriberWidget(QWidget): logging.warning("Export write failed: %s", e) return + @staticmethod + def write_csv_export(file_path: str, text: str, max_entries: int): + """Append a new column to a single-row CSV export file, applying max_entries limit.""" + existing_columns = [] + if os.path.isfile(file_path): + raw = RecordingTranscriberWidget.read_export_file(file_path) + if raw.strip(): + reader = csv.reader(io.StringIO(raw)) + for row in reader: + existing_columns = row + break + existing_columns.append(text) + if max_entries > 0: + existing_columns = existing_columns[-max_entries:] + buf = io.StringIO() + writer = csv.writer(buf) + writer.writerow(existing_columns) + for attempt in range(5): + try: + with open(file_path, "w", encoding='utf-8-sig') as f: + f.write(buf.getvalue()) + return + except PermissionError: + if attempt < 4: + time.sleep(0.2) + else: + logging.warning("CSV export write failed after retries: %s", file_path) + except OSError as e: + logging.warning("CSV export write failed: %s", e) + return + + @staticmethod + def write_txt_export(file_path: str, text: str, mode: str, max_entries: int, line_separator: str): + """Write to a TXT export file, applying max_entries limit when needed.""" + if mode == "a": + RecordingTranscriberWidget.write_to_export_file(file_path, text + line_separator) + if max_entries > 0 and os.path.isfile(file_path): + raw = RecordingTranscriberWidget.read_export_file(file_path) + parts = [p for p in raw.split(line_separator) if p] + if len(parts) > max_entries: + parts = parts[-max_entries:] + RecordingTranscriberWidget.write_to_export_file( + file_path, line_separator.join(parts) + line_separator, mode="w" + ) + elif mode == "prepend": + existing_content = "" + if os.path.isfile(file_path): + existing_content = RecordingTranscriberWidget.read_export_file(file_path) + new_content = text + line_separator + existing_content + if max_entries > 0: + parts = [p for p in new_content.split(line_separator) if p] + if len(parts) > max_entries: + parts = parts[:max_entries] + new_content = line_separator.join(parts) + line_separator + RecordingTranscriberWidget.write_to_export_file(file_path, new_content, mode="w") + else: + RecordingTranscriberWidget.write_to_export_file(file_path, text, mode=mode) + @staticmethod def read_export_file(file_path: str, retries: int = 5, delay: float = 0.2) -> str: """Read an export file with retry logic for Windows file locking.""" @@ -777,7 +854,15 @@ class RecordingTranscriberWidget(QWidget): text_box.moveCursor(QTextCursor.MoveOperation.End) if self.export_enabled and export_file: - self.write_to_export_file(export_file, merged_texts, mode="w") + export_file_type = self.settings.value( + Settings.Key.RECORDING_TRANSCRIBER_EXPORT_FILE_TYPE, "txt" + ) + if export_file_type == "csv": + # For APPEND_AND_CORRECT mode, rewrite the whole CSV with all merged text as a single entry + self.write_to_export_file(export_file, "", mode="w") + self.write_csv_export(export_file, merged_texts, 0) + else: + self.write_to_export_file(export_file, merged_texts, mode="w") def on_next_transcription(self, text: str): text = self.filter_text(text) @@ -788,6 +873,13 @@ class RecordingTranscriberWidget(QWidget): if self.translator is not None: self.translator.enqueue(text) + export_file_type = self.settings.value( + Settings.Key.RECORDING_TRANSCRIBER_EXPORT_FILE_TYPE, "txt" + ) + max_entries = self.settings.value( + Settings.Key.RECORDING_TRANSCRIBER_EXPORT_MAX_ENTRIES, 0, int + ) + if self.transcriber_mode == RecordingTranscriberMode.APPEND_BELOW: self.transcription_text_box.moveCursor(QTextCursor.MoveOperation.End) if len(self.transcription_text_box.toPlainText()) > 0: @@ -796,7 +888,10 @@ class RecordingTranscriberWidget(QWidget): self.transcription_text_box.moveCursor(QTextCursor.MoveOperation.End) if self.export_enabled and self.transcript_export_file: - self.write_to_export_file(self.transcript_export_file, text + self.transcription_options.line_separator) + if export_file_type == "csv": + self.write_csv_export(self.transcript_export_file, text, max_entries) + else: + self.write_txt_export(self.transcript_export_file, text, "a", max_entries, self.transcription_options.line_separator) elif self.transcriber_mode == RecordingTranscriberMode.APPEND_ABOVE: self.transcription_text_box.moveCursor(QTextCursor.MoveOperation.Start) @@ -805,11 +900,25 @@ class RecordingTranscriberWidget(QWidget): self.transcription_text_box.moveCursor(QTextCursor.MoveOperation.Start) if self.export_enabled and self.transcript_export_file: - existing_content = "" - if os.path.isfile(self.transcript_export_file): - existing_content = self.read_export_file(self.transcript_export_file) - new_content = text + self.transcription_options.line_separator + existing_content - self.write_to_export_file(self.transcript_export_file, new_content, mode="w") + if export_file_type == "csv": + # For APPEND_ABOVE, prepend in CSV means inserting at beginning of columns + existing_columns = [] + if os.path.isfile(self.transcript_export_file): + raw = self.read_export_file(self.transcript_export_file) + if raw.strip(): + reader = csv.reader(io.StringIO(raw)) + for row in reader: + existing_columns = row + break + new_columns = [text] + existing_columns + if max_entries > 0: + new_columns = new_columns[:max_entries] + buf = io.StringIO() + writer = csv.writer(buf) + writer.writerow(new_columns) + self.write_to_export_file(self.transcript_export_file, buf.getvalue(), mode="w") + else: + self.write_txt_export(self.transcript_export_file, text, "prepend", max_entries, self.transcription_options.line_separator) elif self.transcriber_mode == RecordingTranscriberMode.APPEND_AND_CORRECT: self.process_transcription_merge(text, self.transcripts, self.transcription_text_box, self.transcript_export_file) @@ -836,6 +945,13 @@ class RecordingTranscriberWidget(QWidget): if len(text) == 0: return + export_file_type = self.settings.value( + Settings.Key.RECORDING_TRANSCRIBER_EXPORT_FILE_TYPE, "txt" + ) + max_entries = self.settings.value( + Settings.Key.RECORDING_TRANSCRIBER_EXPORT_MAX_ENTRIES, 0, int + ) + if self.transcriber_mode == RecordingTranscriberMode.APPEND_BELOW: self.translation_text_box.moveCursor(QTextCursor.MoveOperation.End) if len(self.translation_text_box.toPlainText()) > 0: @@ -844,7 +960,10 @@ class RecordingTranscriberWidget(QWidget): self.translation_text_box.moveCursor(QTextCursor.MoveOperation.End) if self.export_enabled and self.translation_export_file: - self.write_to_export_file(self.translation_export_file, text + self.transcription_options.line_separator) + if export_file_type == "csv": + self.write_csv_export(self.translation_export_file, text, max_entries) + else: + self.write_txt_export(self.translation_export_file, text, "a", max_entries, self.transcription_options.line_separator) elif self.transcriber_mode == RecordingTranscriberMode.APPEND_ABOVE: self.translation_text_box.moveCursor(QTextCursor.MoveOperation.Start) @@ -853,11 +972,24 @@ class RecordingTranscriberWidget(QWidget): self.translation_text_box.moveCursor(QTextCursor.MoveOperation.Start) if self.export_enabled and self.translation_export_file: - existing_content = "" - if os.path.isfile(self.translation_export_file): - existing_content = self.read_export_file(self.translation_export_file) - new_content = text + self.transcription_options.line_separator + existing_content - self.write_to_export_file(self.translation_export_file, new_content, mode="w") + if export_file_type == "csv": + existing_columns = [] + if os.path.isfile(self.translation_export_file): + raw = self.read_export_file(self.translation_export_file) + if raw.strip(): + reader = csv.reader(io.StringIO(raw)) + for row in reader: + existing_columns = row + break + new_columns = [text] + existing_columns + if max_entries > 0: + new_columns = new_columns[:max_entries] + buf = io.StringIO() + writer = csv.writer(buf) + writer.writerow(new_columns) + self.write_to_export_file(self.translation_export_file, buf.getvalue(), mode="w") + else: + self.write_txt_export(self.translation_export_file, text, "prepend", max_entries, self.transcription_options.line_separator) elif self.transcriber_mode == RecordingTranscriberMode.APPEND_AND_CORRECT: self.process_transcription_merge(text, self.translations, self.translation_text_box, self.translation_export_file) diff --git a/buzz/widgets/transcriber/advanced_settings_dialog.py b/buzz/widgets/transcriber/advanced_settings_dialog.py index b8ce84e5..2a10f76f 100644 --- a/buzz/widgets/transcriber/advanced_settings_dialog.py +++ b/buzz/widgets/transcriber/advanced_settings_dialog.py @@ -9,12 +9,18 @@ from PyQt6.QtWidgets import ( QLabel, QDoubleSpinBox, QLineEdit, + QComboBox, + QHBoxLayout, + QPushButton, + QSpinBox, + QFileDialog, ) from buzz.locale import _ from buzz.model_loader import ModelType from buzz.transcriber.transcriber import TranscriptionOptions from buzz.settings.settings import Settings +from buzz.settings.recording_transcriber_mode import RecordingTranscriberMode from buzz.widgets.line_edit import LineEdit from buzz.widgets.transcriber.initial_prompt_text_edit import InitialPromptTextEdit from buzz.widgets.transcriber.temperature_validator import TemperatureValidator @@ -94,7 +100,7 @@ class AdvancedSettingsDialog(QDialog): self.llm_prompt_text_edit = QPlainTextEdit(default_llm_prompt) self.llm_prompt_text_edit.setEnabled(self.transcription_options.enable_llm_translation) self.llm_prompt_text_edit.setMinimumWidth(170) - self.llm_prompt_text_edit.setFixedHeight(115) + self.llm_prompt_text_edit.setFixedHeight(80) self.llm_prompt_text_edit.textChanged.connect(self.on_llm_prompt_changed) layout.addRow(_("Instructions for AI:"), self.llm_prompt_text_edit) @@ -117,6 +123,74 @@ class AdvancedSettingsDialog(QDialog): self.line_separator_line_edit.textChanged.connect(self.on_line_separator_changed) layout.addRow(_("Line separator:"), self.line_separator_line_edit) + # Live recording mode + self.recording_mode_combo = QComboBox(self) + for mode in RecordingTranscriberMode: + self.recording_mode_combo.addItem(mode.value) + self.recording_mode_combo.setCurrentIndex( + self.settings.value(Settings.Key.RECORDING_TRANSCRIBER_MODE, 0) + ) + self.recording_mode_combo.currentIndexChanged.connect(self.on_recording_mode_changed) + layout.addRow(_("Live recording mode:"), self.recording_mode_combo) + + # Export enabled checkbox + self._export_enabled = self.settings.value( + Settings.Key.RECORDING_TRANSCRIBER_EXPORT_ENABLED, False + ) + self.export_enabled_checkbox = QCheckBox(_("Enable live recording export")) + self.export_enabled_checkbox.setChecked(self._export_enabled) + self.export_enabled_checkbox.stateChanged.connect(self.on_export_enabled_changed) + layout.addRow("", self.export_enabled_checkbox) + + # Export folder + export_folder = self.settings.value( + Settings.Key.RECORDING_TRANSCRIBER_EXPORT_FOLDER, "" + ) + self.export_folder_line_edit = LineEdit(export_folder, self) + self.export_folder_line_edit.setEnabled(self._export_enabled) + self.export_folder_line_edit.textChanged.connect(self.on_export_folder_changed) + self.export_folder_browse_button = QPushButton(_("Browse"), self) + self.export_folder_browse_button.setEnabled(self._export_enabled) + self.export_folder_browse_button.clicked.connect(self.on_browse_export_folder) + export_folder_row = QHBoxLayout() + export_folder_row.addWidget(self.export_folder_line_edit) + export_folder_row.addWidget(self.export_folder_browse_button) + layout.addRow(_("Export folder:"), export_folder_row) + + # Export file name template + export_file_name = self.settings.value( + Settings.Key.RECORDING_TRANSCRIBER_EXPORT_FILE_NAME, "" + ) + self.export_file_name_line_edit = LineEdit(export_file_name, self) + self.export_file_name_line_edit.setEnabled(self._export_enabled) + self.export_file_name_line_edit.textChanged.connect(self.on_export_file_name_changed) + layout.addRow(_("Export file name:"), self.export_file_name_line_edit) + + # Export file type + self.export_file_type_combo = QComboBox(self) + self.export_file_type_combo.addItem(_("Text file (.txt)"), "txt") + self.export_file_type_combo.addItem(_("CSV (.csv)"), "csv") + current_type = self.settings.value( + Settings.Key.RECORDING_TRANSCRIBER_EXPORT_FILE_TYPE, "txt" + ) + type_index = self.export_file_type_combo.findData(current_type) + if type_index >= 0: + self.export_file_type_combo.setCurrentIndex(type_index) + self.export_file_type_combo.setEnabled(self._export_enabled) + self.export_file_type_combo.currentIndexChanged.connect(self.on_export_file_type_changed) + layout.addRow(_("Export file type:"), self.export_file_type_combo) + + # Max entries + max_entries = self.settings.value( + Settings.Key.RECORDING_TRANSCRIBER_EXPORT_MAX_ENTRIES, 0, int + ) + self.export_max_entries_spin = QSpinBox(self) + self.export_max_entries_spin.setRange(0, 99) + self.export_max_entries_spin.setValue(max_entries) + self.export_max_entries_spin.setEnabled(self._export_enabled) + self.export_max_entries_spin.valueChanged.connect(self.on_export_max_entries_changed) + layout.addRow(_("Limit export entries\n(0 = export all):"), self.export_max_entries_spin) + button_box = QDialogButtonBox( QDialogButtonBox.StandardButton(QDialogButtonBox.StandardButton.Ok), self ) @@ -168,3 +242,33 @@ class AdvancedSettingsDialog(QDialog): except UnicodeDecodeError: return self.transcription_options_changed.emit(self.transcription_options) + + def on_recording_mode_changed(self, index: int): + self.settings.set_value(Settings.Key.RECORDING_TRANSCRIBER_MODE, index) + + def on_export_enabled_changed(self, state: int): + self._export_enabled = state == 2 + self.settings.set_value(Settings.Key.RECORDING_TRANSCRIBER_EXPORT_ENABLED, self._export_enabled) + self.export_folder_line_edit.setEnabled(self._export_enabled) + self.export_folder_browse_button.setEnabled(self._export_enabled) + self.export_file_name_line_edit.setEnabled(self._export_enabled) + self.export_file_type_combo.setEnabled(self._export_enabled) + self.export_max_entries_spin.setEnabled(self._export_enabled) + + def on_export_folder_changed(self, text: str): + self.settings.set_value(Settings.Key.RECORDING_TRANSCRIBER_EXPORT_FOLDER, text) + + def on_browse_export_folder(self): + folder = QFileDialog.getExistingDirectory(self, _("Select Export Folder")) + if folder: + self.export_folder_line_edit.setText(folder) + + def on_export_file_name_changed(self, text: str): + self.settings.set_value(Settings.Key.RECORDING_TRANSCRIBER_EXPORT_FILE_NAME, text) + + def on_export_file_type_changed(self, index: int): + file_type = self.export_file_type_combo.itemData(index) + self.settings.set_value(Settings.Key.RECORDING_TRANSCRIBER_EXPORT_FILE_TYPE, file_type) + + def on_export_max_entries_changed(self, value: int): + self.settings.set_value(Settings.Key.RECORDING_TRANSCRIBER_EXPORT_MAX_ENTRIES, value) diff --git a/buzz/widgets/transcriber/initial_prompt_text_edit.py b/buzz/widgets/transcriber/initial_prompt_text_edit.py index 26959f4c..618c2273 100644 --- a/buzz/widgets/transcriber/initial_prompt_text_edit.py +++ b/buzz/widgets/transcriber/initial_prompt_text_edit.py @@ -10,4 +10,4 @@ class InitialPromptTextEdit(QPlainTextEdit): self.setPlaceholderText(_("Enter prompt...")) self.setEnabled(model_type.supports_initial_prompt) self.setMinimumWidth(350) - self.setFixedHeight(115) + self.setFixedHeight(80)