revoxx 1.1.0__tar.gz → 1.1.2__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (142) hide show
  1. {revoxx-1.1.0/revoxx.egg-info → revoxx-1.1.2}/PKG-INFO +4 -2
  2. {revoxx-1.1.0 → revoxx-1.1.2}/README.md +1 -0
  3. {revoxx-1.1.0 → revoxx-1.1.2}/pyproject.toml +3 -1
  4. {revoxx-1.1.0 → revoxx-1.1.2}/revoxx/app.py +20 -0
  5. revoxx-1.1.2/revoxx/audio/edit_commands.py +338 -0
  6. revoxx-1.1.2/revoxx/audio/undo_stack.py +170 -0
  7. {revoxx-1.1.0 → revoxx-1.1.2}/revoxx/constants.py +11 -2
  8. {revoxx-1.1.0 → revoxx-1.1.2}/revoxx/controllers/edit_controller.py +209 -20
  9. {revoxx-1.1.0 → revoxx-1.1.2}/revoxx/controllers/file_operations_controller.py +12 -0
  10. {revoxx-1.1.0 → revoxx-1.1.2}/revoxx/controllers/navigation_controller.py +6 -0
  11. {revoxx-1.1.0 → revoxx-1.1.2}/revoxx/doc/USER_GUIDE.md +24 -12
  12. {revoxx-1.1.0 → revoxx-1.1.2}/revoxx/resources/keyboard_shortcuts.txt +2 -0
  13. {revoxx-1.1.0 → revoxx-1.1.2}/revoxx/ui/dialogs/import_text_dialog.py +2 -1
  14. {revoxx-1.1.0 → revoxx-1.1.2}/revoxx/ui/dialogs/open_session_dialog.py +2 -1
  15. {revoxx-1.1.0 → revoxx-1.1.2}/revoxx/ui/dialogs/user_guide_dialog.py +114 -15
  16. {revoxx-1.1.0 → revoxx-1.1.2}/revoxx/ui/dialogs/utterance_list_base.py +2 -1
  17. {revoxx-1.1.0 → revoxx-1.1.2}/revoxx/ui/menus/application_menu.py +29 -0
  18. {revoxx-1.1.0 → revoxx-1.1.2}/revoxx/ui/spectrogram/controllers/selection_visualizer.py +50 -15
  19. {revoxx-1.1.0 → revoxx-1.1.2}/revoxx/ui/spectrogram/playback_handler.py +11 -4
  20. revoxx-1.1.2/revoxx/ui/spectrogram/selection_interaction.py +408 -0
  21. {revoxx-1.1.0 → revoxx-1.1.2}/revoxx/ui/spectrogram/widget.py +143 -415
  22. revoxx-1.1.2/revoxx/utils/adaptive_frame_rate.py +100 -0
  23. {revoxx-1.1.0 → revoxx-1.1.2}/revoxx/utils/file_manager.py +57 -0
  24. revoxx-1.1.2/revoxx/utils/tk_compat.py +43 -0
  25. {revoxx-1.1.0 → revoxx-1.1.2/revoxx.egg-info}/PKG-INFO +4 -2
  26. {revoxx-1.1.0 → revoxx-1.1.2}/revoxx.egg-info/SOURCES.txt +5 -0
  27. {revoxx-1.1.0 → revoxx-1.1.2}/revoxx.egg-info/requires.txt +3 -1
  28. {revoxx-1.1.0 → revoxx-1.1.2}/LICENSE +0 -0
  29. {revoxx-1.1.0 → revoxx-1.1.2}/MANIFEST.in +0 -0
  30. {revoxx-1.1.0 → revoxx-1.1.2}/doc/import_raw_text.png +0 -0
  31. {revoxx-1.1.0 → revoxx-1.1.2}/doc/screenshot1.png +0 -0
  32. {revoxx-1.1.0 → revoxx-1.1.2}/revoxx/__init__.py +0 -0
  33. {revoxx-1.1.0 → revoxx-1.1.2}/revoxx/__main__.py +0 -0
  34. {revoxx-1.1.0 → revoxx-1.1.2}/revoxx/audio/__init__.py +0 -0
  35. {revoxx-1.1.0 → revoxx-1.1.2}/revoxx/audio/audio_buffer.py +0 -0
  36. {revoxx-1.1.0 → revoxx-1.1.2}/revoxx/audio/audio_queue_processor.py +0 -0
  37. {revoxx-1.1.0 → revoxx-1.1.2}/revoxx/audio/buffer_manager.py +0 -0
  38. {revoxx-1.1.0 → revoxx-1.1.2}/revoxx/audio/editor.py +0 -0
  39. {revoxx-1.1.0 → revoxx-1.1.2}/revoxx/audio/level_calculator.py +0 -0
  40. {revoxx-1.1.0 → revoxx-1.1.2}/revoxx/audio/player.py +0 -0
  41. {revoxx-1.1.0 → revoxx-1.1.2}/revoxx/audio/processors/__init__.py +0 -0
  42. {revoxx-1.1.0 → revoxx-1.1.2}/revoxx/audio/processors/clipping_detector.py +0 -0
  43. {revoxx-1.1.0 → revoxx-1.1.2}/revoxx/audio/processors/mel_spectrogram.py +0 -0
  44. {revoxx-1.1.0 → revoxx-1.1.2}/revoxx/audio/processors/processor_base.py +0 -0
  45. {revoxx-1.1.0 → revoxx-1.1.2}/revoxx/audio/queue_manager.py +0 -0
  46. {revoxx-1.1.0 → revoxx-1.1.2}/revoxx/audio/recorder.py +0 -0
  47. {revoxx-1.1.0 → revoxx-1.1.2}/revoxx/audio/shared_state.py +0 -0
  48. {revoxx-1.1.0 → revoxx-1.1.2}/revoxx/audio/worker_state.py +0 -0
  49. {revoxx-1.1.0 → revoxx-1.1.2}/revoxx/controllers/__init__.py +0 -0
  50. {revoxx-1.1.0 → revoxx-1.1.2}/revoxx/controllers/audio_controller.py +0 -0
  51. {revoxx-1.1.0 → revoxx-1.1.2}/revoxx/controllers/device_controller.py +0 -0
  52. {revoxx-1.1.0 → revoxx-1.1.2}/revoxx/controllers/dialog_controller.py +0 -0
  53. {revoxx-1.1.0 → revoxx-1.1.2}/revoxx/controllers/display_controller.py +0 -0
  54. {revoxx-1.1.0 → revoxx-1.1.2}/revoxx/controllers/process_manager.py +0 -0
  55. {revoxx-1.1.0 → revoxx-1.1.2}/revoxx/controllers/session_controller.py +0 -0
  56. {revoxx-1.1.0 → revoxx-1.1.2}/revoxx/dataset/__init__.py +0 -0
  57. {revoxx-1.1.0 → revoxx-1.1.2}/revoxx/dataset/exporter.py +0 -0
  58. {revoxx-1.1.0 → revoxx-1.1.2}/revoxx/resources/microphone.png +0 -0
  59. {revoxx-1.1.0 → revoxx-1.1.2}/revoxx/resources/templates/dataset_readme.txt +0 -0
  60. {revoxx-1.1.0 → revoxx-1.1.2}/revoxx/resources/templates/index_format_with_intensity.txt +0 -0
  61. {revoxx-1.1.0 → revoxx-1.1.2}/revoxx/resources/templates/index_format_without_intensity.txt +0 -0
  62. {revoxx-1.1.0 → revoxx-1.1.2}/revoxx/session/__init__.py +0 -0
  63. {revoxx-1.1.0 → revoxx-1.1.2}/revoxx/session/inspector.py +0 -0
  64. {revoxx-1.1.0 → revoxx-1.1.2}/revoxx/session/manager.py +0 -0
  65. {revoxx-1.1.0 → revoxx-1.1.2}/revoxx/session/models.py +0 -0
  66. {revoxx-1.1.0 → revoxx-1.1.2}/revoxx/session/script_parser.py +0 -0
  67. {revoxx-1.1.0 → revoxx-1.1.2}/revoxx/ui/__init__.py +0 -0
  68. {revoxx-1.1.0 → revoxx-1.1.2}/revoxx/ui/dialogs/__init__.py +0 -0
  69. {revoxx-1.1.0 → revoxx-1.1.2}/revoxx/ui/dialogs/dataset_dialog.py +0 -0
  70. {revoxx-1.1.0 → revoxx-1.1.2}/revoxx/ui/dialogs/dialog_utils.py +0 -0
  71. {revoxx-1.1.0 → revoxx-1.1.2}/revoxx/ui/dialogs/find_dialog.py +0 -0
  72. {revoxx-1.1.0 → revoxx-1.1.2}/revoxx/ui/dialogs/help_dialog.py +0 -0
  73. {revoxx-1.1.0 → revoxx-1.1.2}/revoxx/ui/dialogs/new_session_dialog.py +0 -0
  74. {revoxx-1.1.0 → revoxx-1.1.2}/revoxx/ui/dialogs/progress_dialog.py +0 -0
  75. {revoxx-1.1.0 → revoxx-1.1.2}/revoxx/ui/dialogs/session_settings_dialog.py +0 -0
  76. {revoxx-1.1.0 → revoxx-1.1.2}/revoxx/ui/dialogs/utterance_order_dialog.py +0 -0
  77. {revoxx-1.1.0 → revoxx-1.1.2}/revoxx/ui/emotion_indicator.py +0 -0
  78. {revoxx-1.1.0 → revoxx-1.1.2}/revoxx/ui/font_manager.py +0 -0
  79. {revoxx-1.1.0 → revoxx-1.1.2}/revoxx/ui/frequency_axis.py +0 -0
  80. {revoxx-1.1.0 → revoxx-1.1.2}/revoxx/ui/icon.py +0 -0
  81. {revoxx-1.1.0 → revoxx-1.1.2}/revoxx/ui/info_overlay.py +0 -0
  82. {revoxx-1.1.0 → revoxx-1.1.2}/revoxx/ui/level_meter/__init__.py +0 -0
  83. {revoxx-1.1.0 → revoxx-1.1.2}/revoxx/ui/level_meter/config.py +0 -0
  84. {revoxx-1.1.0 → revoxx-1.1.2}/revoxx/ui/level_meter/led_level_meter.py +0 -0
  85. {revoxx-1.1.0 → revoxx-1.1.2}/revoxx/ui/menus/audio_devices.py +0 -0
  86. {revoxx-1.1.0 → revoxx-1.1.2}/revoxx/ui/recording_display_state.py +0 -0
  87. {revoxx-1.1.0 → revoxx-1.1.2}/revoxx/ui/spectrogram/__init__.py +0 -0
  88. {revoxx-1.1.0 → revoxx-1.1.2}/revoxx/ui/spectrogram/controllers/__init__.py +0 -0
  89. {revoxx-1.1.0 → revoxx-1.1.2}/revoxx/ui/spectrogram/controllers/clipping_visualizer.py +0 -0
  90. {revoxx-1.1.0 → revoxx-1.1.2}/revoxx/ui/spectrogram/controllers/edge_indicator.py +0 -0
  91. {revoxx-1.1.0 → revoxx-1.1.2}/revoxx/ui/spectrogram/controllers/playback_controller.py +0 -0
  92. {revoxx-1.1.0 → revoxx-1.1.2}/revoxx/ui/spectrogram/controllers/zoom_controller.py +0 -0
  93. {revoxx-1.1.0 → revoxx-1.1.2}/revoxx/ui/spectrogram/display_base.py +0 -0
  94. {revoxx-1.1.0 → revoxx-1.1.2}/revoxx/ui/spectrogram/display_utils.py +0 -0
  95. {revoxx-1.1.0 → revoxx-1.1.2}/revoxx/ui/spectrogram/mel_processor_manager.py +0 -0
  96. {revoxx-1.1.0 → revoxx-1.1.2}/revoxx/ui/spectrogram/recording_display.py +0 -0
  97. {revoxx-1.1.0 → revoxx-1.1.2}/revoxx/ui/spectrogram/recording_handler.py +0 -0
  98. {revoxx-1.1.0 → revoxx-1.1.2}/revoxx/ui/spectrogram/selection_state.py +0 -0
  99. {revoxx-1.1.0 → revoxx-1.1.2}/revoxx/ui/spectrogram/view_context.py +0 -0
  100. {revoxx-1.1.0 → revoxx-1.1.2}/revoxx/ui/style_config.py +0 -0
  101. {revoxx-1.1.0 → revoxx-1.1.2}/revoxx/ui/themes.py +0 -0
  102. {revoxx-1.1.0 → revoxx-1.1.2}/revoxx/ui/widget_initializer.py +0 -0
  103. {revoxx-1.1.0 → revoxx-1.1.2}/revoxx/ui/window_base.py +0 -0
  104. {revoxx-1.1.0 → revoxx-1.1.2}/revoxx/ui/window_factory.py +0 -0
  105. {revoxx-1.1.0 → revoxx-1.1.2}/revoxx/ui/window_manager.py +0 -0
  106. {revoxx-1.1.0 → revoxx-1.1.2}/revoxx/utils/__init__.py +0 -0
  107. {revoxx-1.1.0 → revoxx-1.1.2}/revoxx/utils/active_recordings.py +0 -0
  108. {revoxx-1.1.0 → revoxx-1.1.2}/revoxx/utils/audio_utils.py +0 -0
  109. {revoxx-1.1.0 → revoxx-1.1.2}/revoxx/utils/config.py +0 -0
  110. {revoxx-1.1.0 → revoxx-1.1.2}/revoxx/utils/device_manager.py +0 -0
  111. {revoxx-1.1.0 → revoxx-1.1.2}/revoxx/utils/process_cleanup.py +0 -0
  112. {revoxx-1.1.0 → revoxx-1.1.2}/revoxx/utils/settings_manager.py +0 -0
  113. {revoxx-1.1.0 → revoxx-1.1.2}/revoxx/utils/spectrogram_utils.py +0 -0
  114. {revoxx-1.1.0 → revoxx-1.1.2}/revoxx/utils/state.py +0 -0
  115. {revoxx-1.1.0 → revoxx-1.1.2}/revoxx/utils/text_importer.py +0 -0
  116. {revoxx-1.1.0 → revoxx-1.1.2}/revoxx/utils/text_utils.py +0 -0
  117. {revoxx-1.1.0 → revoxx-1.1.2}/revoxx.egg-info/dependency_links.txt +0 -0
  118. {revoxx-1.1.0 → revoxx-1.1.2}/revoxx.egg-info/entry_points.txt +0 -0
  119. {revoxx-1.1.0 → revoxx-1.1.2}/revoxx.egg-info/top_level.txt +0 -0
  120. {revoxx-1.1.0 → revoxx-1.1.2}/scripts_module/__init__.py +0 -0
  121. {revoxx-1.1.0 → revoxx-1.1.2}/scripts_module/export.py +0 -0
  122. {revoxx-1.1.0 → revoxx-1.1.2}/scripts_module/vadiate.py +0 -0
  123. {revoxx-1.1.0 → revoxx-1.1.2}/setup.cfg +0 -0
  124. {revoxx-1.1.0 → revoxx-1.1.2}/tests/test_active_recordings.py +0 -0
  125. {revoxx-1.1.0 → revoxx-1.1.2}/tests/test_audio_controller.py +0 -0
  126. {revoxx-1.1.0 → revoxx-1.1.2}/tests/test_audio_queue_manager.py +0 -0
  127. {revoxx-1.1.0 → revoxx-1.1.2}/tests/test_config.py +0 -0
  128. {revoxx-1.1.0 → revoxx-1.1.2}/tests/test_dataset_exporter.py +0 -0
  129. {revoxx-1.1.0 → revoxx-1.1.2}/tests/test_device_controller.py +0 -0
  130. {revoxx-1.1.0 → revoxx-1.1.2}/tests/test_dialog_controller.py +0 -0
  131. {revoxx-1.1.0 → revoxx-1.1.2}/tests/test_display_controller.py +0 -0
  132. {revoxx-1.1.0 → revoxx-1.1.2}/tests/test_file_manager.py +0 -0
  133. {revoxx-1.1.0 → revoxx-1.1.2}/tests/test_file_operations_controller.py +0 -0
  134. {revoxx-1.1.0 → revoxx-1.1.2}/tests/test_ipc_communication.py +0 -0
  135. {revoxx-1.1.0 → revoxx-1.1.2}/tests/test_navigation_controller.py +0 -0
  136. {revoxx-1.1.0 → revoxx-1.1.2}/tests/test_new_session_dialog.py +0 -0
  137. {revoxx-1.1.0 → revoxx-1.1.2}/tests/test_process_manager.py +0 -0
  138. {revoxx-1.1.0 → revoxx-1.1.2}/tests/test_session_controller.py +0 -0
  139. {revoxx-1.1.0 → revoxx-1.1.2}/tests/test_session_manager.py +0 -0
  140. {revoxx-1.1.0 → revoxx-1.1.2}/tests/test_session_models.py +0 -0
  141. {revoxx-1.1.0 → revoxx-1.1.2}/tests/test_stable_sorting.py +0 -0
  142. {revoxx-1.1.0 → revoxx-1.1.2}/tests/test_utterance_list_dialog_sorting.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: revoxx
3
- Version: 1.1.0
3
+ Version: 1.1.2
4
4
  Summary: Speech recording application for creating high-quality speech datasets
5
5
  Author-email: Grammatek ehf <info@grammatek.com>
6
6
  Maintainer-email: Grammatek ehf <info@grammatek.com>
@@ -32,7 +32,8 @@ Requires-Dist: sounddevice>=0.5.1
32
32
  Requires-Dist: soundfile>=0.12.0
33
33
  Requires-Dist: tqdm>=4.65.0
34
34
  Requires-Dist: markdown2>2.5.1
35
- Requires-Dist: tkinterweb>4.4.1
35
+ Provides-Extra: html
36
+ Requires-Dist: tkinterweb>4.4.1; extra == "html"
36
37
  Provides-Extra: vad
37
38
  Requires-Dist: torch>=2.0.0; extra == "vad"
38
39
  Requires-Dist: silero-vad>=5.0; extra == "vad"
@@ -120,6 +121,7 @@ the Icelandic emotional speech dataset, and created this tool to minimize hassle
120
121
  - Delete ranges with automatic crossfade
121
122
  - Insert new audio at marker position
122
123
  - Replace selected ranges with new recordings
124
+ - Multiple undo/redo for all editing operations
123
125
  - **Multi-Screen Support**
124
126
  - You can use multiple monitors to **separate recording view from speaker view**
125
127
  - We support Apple's [Sidecar](https://support.apple.com/en-us/102597) feature for a **convenient dual screen setup with an external iPad**
@@ -72,6 +72,7 @@ the Icelandic emotional speech dataset, and created this tool to minimize hassle
72
72
  - Delete ranges with automatic crossfade
73
73
  - Insert new audio at marker position
74
74
  - Replace selected ranges with new recordings
75
+ - Multiple undo/redo for all editing operations
75
76
  - **Multi-Screen Support**
76
77
  - You can use multiple monitors to **separate recording view from speaker view**
77
78
  - We support Apple's [Sidecar](https://support.apple.com/en-us/102597) feature for a **convenient dual screen setup with an external iPad**
@@ -38,10 +38,12 @@ dependencies = [
38
38
  "soundfile>=0.12.0",
39
39
  "tqdm>=4.65.0",
40
40
  "markdown2>2.5.1", # User guide dependencies
41
- "tkinterweb>4.4.1"
42
41
  ]
43
42
 
44
43
  [project.optional-dependencies]
44
+ html = [
45
+ "tkinterweb>4.4.1" # Requires Tkhtml - may need manual compilation for Tcl/Tk 9
46
+ ]
45
47
  vad = [
46
48
  "torch>=2.0.0",
47
49
  "silero-vad>=5.0",
@@ -445,12 +445,22 @@ class Revoxx:
445
445
  self.window.window.bind("<Command-Q>", lambda e: self._handle_cmd_q())
446
446
  # Also try to catch it with createcommand
447
447
  self.window.window.createcommand("::tk::mac::Quit", self._handle_cmd_q)
448
+ # Undo/Redo
449
+ self.window.window.bind("<Command-z>", lambda e: self._undo())
450
+ self.window.window.bind("<Command-Z>", lambda e: self._undo())
451
+ self.window.window.bind("<Shift-Command-z>", lambda e: self._redo())
452
+ self.window.window.bind("<Shift-Command-Z>", lambda e: self._redo())
448
453
 
449
454
  # Session keys
450
455
  self.window.window.bind("<Control-n>", lambda e: self._new_session())
451
456
  self.window.window.bind("<Control-N>", lambda e: self._new_session())
452
457
  self.window.window.bind("<Control-o>", lambda e: self._open_session())
453
458
  self.window.window.bind("<Control-O>", lambda e: self._open_session())
459
+ # Undo/Redo (Control for Linux/Windows)
460
+ self.window.window.bind("<Control-z>", lambda e: self._undo())
461
+ self.window.window.bind("<Control-Z>", lambda e: self._undo())
462
+ self.window.window.bind("<Shift-Control-z>", lambda e: self._redo())
463
+ self.window.window.bind("<Shift-Control-Z>", lambda e: self._redo())
454
464
 
455
465
  # Window close event
456
466
  self.window.window.protocol("WM_DELETE_WINDOW", self._quit)
@@ -534,6 +544,16 @@ class Revoxx:
534
544
  self._quit()
535
545
  return "break" # Prevent default handling
536
546
 
547
+ def _undo(self):
548
+ """Handle undo keyboard shortcut."""
549
+ self.edit_controller.undo()
550
+ return "break"
551
+
552
+ def _redo(self):
553
+ """Handle redo keyboard shortcut."""
554
+ self.edit_controller.redo()
555
+ return "break"
556
+
537
557
  def _perform_cleanup(self):
538
558
  """Perform cleanup when signals are received or on emergency exit."""
539
559
  # Only do critical cleanup - no UI interactions
@@ -0,0 +1,338 @@
1
+ """Edit commands for undo/redo support.
2
+
3
+ This module provides command classes that encapsulate audio editing operations.
4
+ Each command stores the data needed to undo the operation.
5
+ """
6
+
7
+ from abc import ABC, abstractmethod
8
+ from pathlib import Path
9
+ from typing import TYPE_CHECKING, Optional
10
+
11
+ import numpy as np
12
+
13
+ if TYPE_CHECKING:
14
+ from ..files.file_manager import FileManager
15
+
16
+
17
+ class EditCommand(ABC):
18
+ """Abstract base class for edit commands.
19
+
20
+ Each command represents an audio editing operation that can be
21
+ undone and redone. Commands store the data needed to reverse
22
+ the operation.
23
+ """
24
+
25
+ def __init__(self, filepath: Path, sample_rate: int, subtype: Optional[str] = None):
26
+ """Initialize the command.
27
+
28
+ Args:
29
+ filepath: Path to the audio file being edited
30
+ sample_rate: Sample rate of the audio
31
+ subtype: Audio subtype for saving (e.g., "PCM_16")
32
+ """
33
+ self.filepath = filepath
34
+ self.sample_rate = sample_rate
35
+ self.subtype = subtype
36
+
37
+ @abstractmethod
38
+ def execute(self, file_manager: "FileManager") -> bool:
39
+ """Execute the command.
40
+
41
+ Args:
42
+ file_manager: FileManager instance for audio I/O
43
+
44
+ Returns:
45
+ True if successful, False otherwise
46
+ """
47
+ pass
48
+
49
+ @abstractmethod
50
+ def inverse(self) -> "EditCommand":
51
+ """Create the inverse command for undo.
52
+
53
+ Returns:
54
+ A command that reverses this operation
55
+ """
56
+ pass
57
+
58
+ @abstractmethod
59
+ def description(self) -> str:
60
+ """Get a human-readable description of this command.
61
+
62
+ Returns:
63
+ Description string (e.g., "Delete Range")
64
+ """
65
+ pass
66
+
67
+
68
+ class AudioSnapshotCommand(EditCommand):
69
+ """Command that stores complete audio snapshots for undo/redo.
70
+
71
+ This approach ensures exact restoration without re-applying cross-fade.
72
+ Stores both the audio before and after the operation.
73
+ """
74
+
75
+ def __init__(
76
+ self,
77
+ filepath: Path,
78
+ sample_rate: int,
79
+ audio_before: np.ndarray,
80
+ audio_after: np.ndarray,
81
+ subtype: Optional[str] = None,
82
+ selection_start_time: Optional[float] = None,
83
+ selection_end_time: Optional[float] = None,
84
+ marker_after_edit: Optional[float] = None,
85
+ operation_description: str = "Edit",
86
+ ):
87
+ """Initialize audio snapshot command.
88
+
89
+ Args:
90
+ filepath: Path to the audio file
91
+ sample_rate: Sample rate of the audio
92
+ audio_before: Complete audio data before the operation
93
+ audio_after: Complete audio data after the operation
94
+ subtype: Audio subtype for saving
95
+ selection_start_time: Selection to restore on undo
96
+ selection_end_time: Selection to restore on undo
97
+ marker_after_edit: Marker to set after redo
98
+ operation_description: Description of the operation
99
+ """
100
+ super().__init__(filepath, sample_rate, subtype)
101
+ self.audio_before = audio_before
102
+ self.audio_after = audio_after
103
+ self.selection_start_time = selection_start_time
104
+ self.selection_end_time = selection_end_time
105
+ self.marker_after_edit = marker_after_edit
106
+ self.operation_description = operation_description
107
+
108
+ def execute(self, file_manager: "FileManager") -> bool:
109
+ """Write audio_after to file (for redo)."""
110
+ try:
111
+ file_manager.save_audio(
112
+ self.filepath, self.audio_after, self.sample_rate, self.subtype
113
+ )
114
+ return True
115
+ except (OSError, ValueError):
116
+ return False
117
+
118
+ def inverse(self) -> "AudioSnapshotCommand":
119
+ """Create inverse command that restores audio_before."""
120
+ return AudioSnapshotCommand(
121
+ filepath=self.filepath,
122
+ sample_rate=self.sample_rate,
123
+ audio_before=self.audio_after,
124
+ audio_after=self.audio_before,
125
+ subtype=self.subtype,
126
+ selection_start_time=self.selection_start_time,
127
+ selection_end_time=self.selection_end_time,
128
+ marker_after_edit=None,
129
+ operation_description=self.operation_description,
130
+ )
131
+
132
+ def description(self) -> str:
133
+ """Get description of this command."""
134
+ return self.operation_description
135
+
136
+
137
+ # Convenience aliases for specific operations (all use AudioSnapshotCommand internally)
138
+ DeleteRangeCommand = AudioSnapshotCommand
139
+ InsertCommand = AudioSnapshotCommand
140
+ ReplaceRangeCommand = AudioSnapshotCommand
141
+
142
+
143
+ class TrashClipCommand(EditCommand):
144
+ """Command for moving a clip to trash.
145
+
146
+ Uses the existing trash system (move_to_trash/restore_from_trash).
147
+ """
148
+
149
+ def __init__(
150
+ self,
151
+ filepath: Path,
152
+ sample_rate: int,
153
+ label: str,
154
+ take: int,
155
+ ):
156
+ """Initialize trash clip command.
157
+
158
+ Args:
159
+ filepath: Path to the audio file
160
+ sample_rate: Sample rate of the audio
161
+ label: Script label/ID for the utterance
162
+ take: Take number
163
+ """
164
+ super().__init__(filepath, sample_rate, None)
165
+ self.label = label
166
+ self.take = take
167
+
168
+ def execute(self, file_manager: "FileManager") -> bool:
169
+ """Move the clip to trash."""
170
+ return file_manager.move_to_trash(self.label, self.take)
171
+
172
+ def inverse(self) -> "RestoreFromTrashCommand":
173
+ """Create a restore command to undo this deletion."""
174
+ return RestoreFromTrashCommand(
175
+ filepath=self.filepath,
176
+ sample_rate=self.sample_rate,
177
+ label=self.label,
178
+ take=self.take,
179
+ )
180
+
181
+ def description(self) -> str:
182
+ """Get description of this command."""
183
+ return f"Delete Clip (take {self.take})"
184
+
185
+
186
+ class RestoreFromTrashCommand(EditCommand):
187
+ """Command for restoring a clip from trash."""
188
+
189
+ def __init__(
190
+ self,
191
+ filepath: Path,
192
+ sample_rate: int,
193
+ label: str,
194
+ take: int,
195
+ ):
196
+ """Initialize restore from trash command."""
197
+ super().__init__(filepath, sample_rate, None)
198
+ self.label = label
199
+ self.take = take
200
+
201
+ def execute(self, file_manager: "FileManager") -> bool:
202
+ """Restore the clip from trash."""
203
+ return file_manager.restore_from_trash(self.label, self.take)
204
+
205
+ def inverse(self) -> "TrashClipCommand":
206
+ """Create a trash command to undo this restoration."""
207
+ return TrashClipCommand(
208
+ filepath=self.filepath,
209
+ sample_rate=self.sample_rate,
210
+ label=self.label,
211
+ take=self.take,
212
+ )
213
+
214
+ def description(self) -> str:
215
+ """Get description of this command."""
216
+ return f"Restore Clip (take {self.take})"
217
+
218
+
219
+ class DeleteClipCommand(EditCommand):
220
+ """Command for deleting an entire audio clip.
221
+
222
+ Stores the complete audio data so it can be restored on undo.
223
+ Note: For trash-based deletion, use TrashClipCommand instead.
224
+ """
225
+
226
+ def __init__(
227
+ self,
228
+ filepath: Path,
229
+ sample_rate: int,
230
+ audio_data: np.ndarray,
231
+ subtype: Optional[str] = None,
232
+ ):
233
+ """Initialize delete clip command.
234
+
235
+ Args:
236
+ filepath: Path to the audio file
237
+ sample_rate: Sample rate of the audio
238
+ audio_data: The complete audio data of the deleted clip
239
+ subtype: Audio subtype for saving (e.g., "PCM_16")
240
+ """
241
+ super().__init__(filepath, sample_rate)
242
+ self.audio_data = audio_data
243
+ self.subtype = subtype
244
+
245
+ def execute(self, file_manager: "FileManager") -> bool:
246
+ """Execute the delete clip operation (delete the file).
247
+
248
+ Args:
249
+ file_manager: FileManager instance for audio I/O
250
+
251
+ Returns:
252
+ True if successful, False otherwise
253
+ """
254
+ try:
255
+ if self.filepath.exists():
256
+ self.filepath.unlink()
257
+ return True
258
+ except OSError:
259
+ return False
260
+
261
+ def inverse(self) -> "RestoreClipCommand":
262
+ """Create a restore command to undo this deletion.
263
+
264
+ Returns:
265
+ RestoreClipCommand that recreates the deleted file
266
+ """
267
+ return RestoreClipCommand(
268
+ filepath=self.filepath,
269
+ sample_rate=self.sample_rate,
270
+ audio_data=self.audio_data,
271
+ subtype=self.subtype,
272
+ )
273
+
274
+ def description(self) -> str:
275
+ """Get description of this command."""
276
+ duration = len(self.audio_data) / self.sample_rate
277
+ return f"Delete Clip ({duration:.2f}s)"
278
+
279
+
280
+ class RestoreClipCommand(EditCommand):
281
+ """Command for restoring a deleted audio clip.
282
+
283
+ Used as the inverse of DeleteClipCommand.
284
+ """
285
+
286
+ def __init__(
287
+ self,
288
+ filepath: Path,
289
+ sample_rate: int,
290
+ audio_data: np.ndarray,
291
+ subtype: Optional[str] = None,
292
+ ):
293
+ """Initialize restore clip command.
294
+
295
+ Args:
296
+ filepath: Path to the audio file
297
+ sample_rate: Sample rate of the audio
298
+ audio_data: The audio data to restore
299
+ subtype: Audio subtype for saving (e.g., "PCM_16")
300
+ """
301
+ super().__init__(filepath, sample_rate)
302
+ self.audio_data = audio_data
303
+ self.subtype = subtype
304
+
305
+ def execute(self, file_manager: "FileManager") -> bool:
306
+ """Execute the restore operation (recreate the file).
307
+
308
+ Args:
309
+ file_manager: FileManager instance for audio I/O
310
+
311
+ Returns:
312
+ True if successful, False otherwise
313
+ """
314
+ try:
315
+ file_manager.save_audio(
316
+ self.filepath, self.audio_data, self.sample_rate, self.subtype
317
+ )
318
+ return True
319
+ except (OSError, ValueError):
320
+ return False
321
+
322
+ def inverse(self) -> "DeleteClipCommand":
323
+ """Create a delete command to undo this restoration.
324
+
325
+ Returns:
326
+ DeleteClipCommand that deletes the file again
327
+ """
328
+ return DeleteClipCommand(
329
+ filepath=self.filepath,
330
+ sample_rate=self.sample_rate,
331
+ audio_data=self.audio_data,
332
+ subtype=self.subtype,
333
+ )
334
+
335
+ def description(self) -> str:
336
+ """Get description of this command."""
337
+ duration = len(self.audio_data) / self.sample_rate
338
+ return f"Restore Clip ({duration:.2f}s)"
@@ -0,0 +1,170 @@
1
+ """Undo/redo stack for audio editing operations.
2
+
3
+ This module provides the UndoStack class that manages undo and redo
4
+ operations using the command pattern.
5
+ """
6
+
7
+ from typing import TYPE_CHECKING, List, Optional
8
+
9
+ from .edit_commands import EditCommand
10
+
11
+ if TYPE_CHECKING:
12
+ from ..files.file_manager import FileManager
13
+
14
+
15
+ class UndoStack:
16
+ """Manages undo and redo stacks for edit commands.
17
+
18
+ Uses two stacks:
19
+ - Undo stack: Commands that can be undone (most recent on top)
20
+ - Redo stack: Commands that were undone and can be redone
21
+
22
+ When a new command is pushed, the redo stack is cleared.
23
+ """
24
+
25
+ def __init__(self, max_size: int = 50):
26
+ """Initialize the undo stack.
27
+
28
+ Args:
29
+ max_size: Maximum number of commands to keep in the undo stack.
30
+ Older commands are discarded when the limit is reached.
31
+ """
32
+ self._max_size = max_size
33
+ self._undo_stack: List[EditCommand] = []
34
+ self._redo_stack: List[EditCommand] = []
35
+
36
+ def push(self, command: EditCommand) -> None:
37
+ """Push a new command onto the undo stack.
38
+
39
+ This clears the redo stack since a new action invalidates
40
+ any previously undone commands.
41
+
42
+ Args:
43
+ command: The command that was just executed
44
+ """
45
+ self._undo_stack.append(command)
46
+ self._redo_stack.clear()
47
+
48
+ if len(self._undo_stack) > self._max_size:
49
+ self._undo_stack.pop(0)
50
+
51
+ def undo(self, file_manager: "FileManager") -> Optional[EditCommand]:
52
+ """Undo the most recent command.
53
+
54
+ Pops the command from the undo stack, executes its inverse,
55
+ and pushes the original command to the redo stack.
56
+
57
+ Args:
58
+ file_manager: FileManager instance for audio I/O
59
+
60
+ Returns:
61
+ The command that was undone, or None if undo failed or stack is empty
62
+ """
63
+ if not self._undo_stack:
64
+ return None
65
+
66
+ command = self._undo_stack.pop()
67
+ inverse_command = command.inverse()
68
+
69
+ if inverse_command.execute(file_manager):
70
+ self._redo_stack.append(command)
71
+ return command
72
+
73
+ self._undo_stack.append(command)
74
+ return None
75
+
76
+ def redo(self, file_manager: "FileManager") -> Optional[EditCommand]:
77
+ """Redo the most recently undone command.
78
+
79
+ Pops the command from the redo stack, re-executes it,
80
+ and pushes it back to the undo stack.
81
+
82
+ Args:
83
+ file_manager: FileManager instance for audio I/O
84
+
85
+ Returns:
86
+ The command that was redone, or None if redo failed or stack is empty
87
+ """
88
+ if not self._redo_stack:
89
+ return None
90
+
91
+ command = self._redo_stack.pop()
92
+
93
+ if command.execute(file_manager):
94
+ self._undo_stack.append(command)
95
+ return command
96
+
97
+ self._redo_stack.append(command)
98
+ return None
99
+
100
+ def can_undo(self) -> bool:
101
+ """Check if there are commands that can be undone.
102
+
103
+ Returns:
104
+ True if undo is available, False otherwise
105
+ """
106
+ return len(self._undo_stack) > 0
107
+
108
+ def can_redo(self) -> bool:
109
+ """Check if there are commands that can be redone.
110
+
111
+ Returns:
112
+ True if redo is available, False otherwise
113
+ """
114
+ return len(self._redo_stack) > 0
115
+
116
+ def clear(self) -> None:
117
+ """Clear both undo and redo stacks.
118
+
119
+ Call this when switching to a different audio file.
120
+ """
121
+ self._undo_stack.clear()
122
+ self._redo_stack.clear()
123
+
124
+ def peek_undo(self) -> Optional[EditCommand]:
125
+ """Get the next command that would be undone without removing it.
126
+
127
+ Returns:
128
+ The command at the top of the undo stack, or None if empty
129
+ """
130
+ if self._undo_stack:
131
+ return self._undo_stack[-1]
132
+ return None
133
+
134
+ def peek_redo(self) -> Optional[EditCommand]:
135
+ """Get the next command that would be redone without removing it.
136
+
137
+ Returns:
138
+ The command at the top of the redo stack, or None if empty
139
+ """
140
+ if self._redo_stack:
141
+ return self._redo_stack[-1]
142
+ return None
143
+
144
+ def undo_description(self) -> Optional[str]:
145
+ """Get description of the next undo operation.
146
+
147
+ Returns:
148
+ Description string or None if undo stack is empty
149
+ """
150
+ cmd = self.peek_undo()
151
+ return cmd.description() if cmd else None
152
+
153
+ def redo_description(self) -> Optional[str]:
154
+ """Get description of the next redo operation.
155
+
156
+ Returns:
157
+ Description string or None if redo stack is empty
158
+ """
159
+ cmd = self.peek_redo()
160
+ return cmd.description() if cmd else None
161
+
162
+ @property
163
+ def undo_count(self) -> int:
164
+ """Get the number of commands in the undo stack."""
165
+ return len(self._undo_stack)
166
+
167
+ @property
168
+ def redo_count(self) -> int:
169
+ """Get the number of commands in the redo stack."""
170
+ return len(self._redo_stack)
@@ -162,7 +162,9 @@ class UIConstants:
162
162
  # Playback display
163
163
  PLAYBACK_LINE_WIDTH = 2
164
164
  PLAYBACK_LINE_ALPHA = 0.8
165
- PLAYBACK_UPDATE_MS = 10
165
+ PLAYBACK_UPDATE_MS = (
166
+ 16 # Target ~60fps (adaptive frame rate adjusts for slower systems)
167
+ )
166
168
  PLAYBACK_INITIAL_CHECK_MS = 50
167
169
  PLAYBACK_IDLE_RETRY_MS = 20
168
170
  PLAYBACK_WATCHDOG_NEAR_END_RATIO = 0.95
@@ -175,17 +177,24 @@ class UIConstants:
175
177
  EDGE_INDICATOR_ALPHA = 0.8
176
178
  EDGE_INDICATOR_TIMEOUT_MS = 350
177
179
 
180
+ # Tkinter event types
181
+ TK_EVENT_BUTTON_PRESS = "4"
182
+ TK_EVENT_SCROLL_UP = 4 # Linux mouse wheel scroll up
183
+ TK_EVENT_SCROLL_DOWN = 5 # Linux mouse wheel scroll down
184
+
178
185
  # Selection and marker display
179
186
  COLOR_POSITION_MARKER = "#00FFFF" # Cyan
180
187
  COLOR_SELECTION_FILL = (1.0, 1.0, 1.0, 0.15) # Semi-transparent white (RGBA)
181
188
  COLOR_SELECTION_BORDER = "#FFFFFF" # White
182
189
  SELECTION_BORDER_WIDTH = 1
183
190
  POSITION_MARKER_WIDTH = 2
191
+ SELECTION_LINE_OFFSET = 0.5 # spec_frames offset for edge visibility
192
+ POSITION_MARKER_OFFSET = 1.0 # spec_frames offset for thicker marker
184
193
  SELECTION_DRAG_THRESHOLD = 5 # Pixels
185
194
  MARKER_HOVER_THRESHOLD = 8 # Pixels - distance for resize cursor activation
186
195
 
187
196
  # Timing (milliseconds)
188
- ANIMATION_UPDATE_MS = 20
197
+ ANIMATION_UPDATE_MS = 100 # ~10fps (TkAgg + Tcl/Tk 9 needs ~100ms per frame on x86)
189
198
  PLAYBACK_CHECK_MS = 50
190
199
  PLAYBACK_STOP_DELAY_MS = 50 # Delay after stopping playback
191
200
  FOCUS_DELAY_MS = 100