revoxx 1.0.0.dev22__py3-none-any.whl → 1.0.1__py3-none-any.whl

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.
revoxx/__init__.py CHANGED
@@ -1,6 +1,14 @@
1
1
  """Revoxx Recorder - A tool for recording emotional speech."""
2
2
 
3
- __version__ = "1.0.0"
3
+ try:
4
+ # Try to use versioningit for dynamic version detection
5
+ from versioningit import get_version
6
+
7
+ __version__ = get_version(root="../", config={})
8
+ except (ImportError, Exception):
9
+ # Fallback if versioningit is not installed or fails
10
+ __version__ = "1.0.0+dev"
11
+
4
12
  __author__ = "Grammatek"
5
13
 
6
14
  # Only import main entry point to avoid circular imports
revoxx/app.py CHANGED
@@ -688,6 +688,12 @@ class Revoxx:
688
688
  # Tkinter might have changed it during setup
689
689
  self.cleanup_manager.refresh_sigint_handler()
690
690
 
691
+ # Show user guide dialog if configured
692
+ if self.settings_manager.get_setting("show_user_guide_at_startup", True):
693
+ from .ui.dialogs.user_guide_dialog import UserGuideDialog
694
+
695
+ UserGuideDialog(self.window.window, self.settings_manager)
696
+
691
697
  self.window.focus_window()
692
698
  self.window.window.mainloop()
693
699
 
@@ -220,6 +220,36 @@ class DisplayController:
220
220
  """Reset the level meter display."""
221
221
  self.reset_level_meters()
222
222
 
223
+ def format_take_status(self, label: str) -> str:
224
+ """Format the take status display string for a given label.
225
+
226
+ This returns current take information in the status bar.
227
+
228
+ Args:
229
+ label: The utterance label (e.g., "utterance_001")
230
+
231
+ Returns:
232
+ - Empty string if label is None or empty
233
+ - Just the label if no active_recordings exist
234
+ - Just the label if no takes exist for this utterance
235
+ - "label - Take X/Y" if takes exist, where X is the position of the
236
+ current take in the list and Y is the total number of takes
237
+ """
238
+ if not label:
239
+ return ""
240
+
241
+ if not self.app.active_recordings:
242
+ return label
243
+
244
+ current_take = self.app.state.recording.get_current_take(label)
245
+ existing_takes = self.app.active_recordings.get_existing_takes(label)
246
+
247
+ if existing_takes and current_take in existing_takes:
248
+ position = existing_takes.index(current_take) + 1
249
+ return f"{label} - Take {position}/{len(existing_takes)}"
250
+
251
+ return label
252
+
223
253
  def set_status(self, status: str, msg_type: MsgType = MsgType.TEMPORARY) -> None:
224
254
  """Set the status bar text.
225
255
 
@@ -2,7 +2,7 @@
2
2
 
3
3
  from typing import TYPE_CHECKING
4
4
 
5
- from ..constants import FileConstants
5
+ from ..constants import FileConstants, MsgType
6
6
 
7
7
  if TYPE_CHECKING:
8
8
  from ..app import Revoxx
@@ -134,10 +134,6 @@ class NavigationController:
134
134
  # Update info overlay if visible
135
135
  if self.app.window.info_panel_visible:
136
136
  self.app.display_controller.update_info_panel()
137
- else:
138
- # No more takes in that direction
139
- direction_text = "forward" if direction > 0 else "backward"
140
- self.app.display_controller.set_status(f"No more takes {direction_text}")
141
137
 
142
138
  def find_utterance(self, index: int) -> None:
143
139
  """Navigate directly to a specific utterance by index.
@@ -252,15 +248,8 @@ class NavigationController:
252
248
  if not current_label:
253
249
  return
254
250
 
255
- current_take = self.app.state.recording.get_current_take(current_label)
256
- if not self.app.active_recordings:
257
- existing_takes = []
258
- else:
259
- existing_takes = self.app.active_recordings.get_existing_takes(
260
- current_label
261
- )
262
-
263
251
  # Update label with filename if we have a recording
252
+ current_take = self.app.state.recording.get_current_take(current_label)
264
253
  if current_take > 0:
265
254
  filename = f"take_{current_take:03d}{FileConstants.AUDIO_FILE_EXTENSION}"
266
255
  self.app.window.update_label_with_filename(current_label, filename)
@@ -277,18 +266,8 @@ class NavigationController:
277
266
  if second:
278
267
  second.update_label_with_filename(current_label)
279
268
 
280
- if existing_takes and current_take in existing_takes:
281
- # Find position in the list
282
- position = existing_takes.index(current_take) + 1
283
- total = len(existing_takes)
284
- self.app.display_controller.set_status(
285
- f"{current_label} - Take {position}/{total}"
286
- )
287
- elif not existing_takes:
288
- # Show label even without recordings
289
- self.app.display_controller.set_status(f"{current_label}")
290
- else:
291
- self.app.display_controller.set_status(f"{current_label}")
269
+ status_text = self.app.display_controller.format_take_status(current_label)
270
+ self.app.display_controller.set_status(status_text, MsgType.DEFAULT)
292
271
 
293
272
  def after_recording_saved(self, label: str) -> None:
294
273
  """Called after a recording has been saved to disk.
@@ -77,6 +77,9 @@ class ProcessManager:
77
77
  self.set_audio_queue_active(False)
78
78
  self.set_save_path(None)
79
79
 
80
+ # Check for VAD availability
81
+ self._check_vad_availability()
82
+
80
83
  def start_processes(self) -> None:
81
84
  """Start background recording and playback processes."""
82
85
  if self.app.debug:
@@ -322,3 +325,34 @@ class ProcessManager:
322
325
  and self.playback_process is not None
323
326
  and self.playback_process.is_alive()
324
327
  )
328
+
329
+ def _check_vad_availability(self) -> None:
330
+ """Check if VAD support is available and store in manager_dict."""
331
+ try:
332
+ # Try to import the VAD module from scripts_module
333
+ from scripts_module import vadiate # noqa: F401
334
+ from silero_vad import load_silero_vad # noqa: F401
335
+
336
+ vad_available = True
337
+ if self.app.debug:
338
+ print("[ProcessManager] VAD support is available")
339
+ except ImportError:
340
+ vad_available = False
341
+ if self.app.debug:
342
+ print("[ProcessManager] VAD support is not available")
343
+
344
+ if self.manager_dict is not None:
345
+ self.manager_dict["vad_available"] = vad_available
346
+
347
+ def is_vad_available(self) -> bool:
348
+ """Check if VAD support is available.
349
+
350
+ Returns:
351
+ True if VAD is available
352
+ """
353
+ if self.manager_dict:
354
+ try:
355
+ return self.manager_dict.get("vad_available", False)
356
+ except (AttributeError, KeyError):
357
+ return False
358
+ return False
@@ -147,10 +147,7 @@ class SessionController:
147
147
  self.reload_script_and_recordings()
148
148
 
149
149
  # Then apply saved sort settings from session (after data is loaded)
150
- if session:
151
- self.app.active_recordings.set_sort(
152
- session.sort_column, session.sort_reverse
153
- )
150
+ self.app.active_recordings.set_sort(session.sort_column, session.sort_reverse)
154
151
 
155
152
  self.app.window.window.title(f"Revoxx - {session.name}")
156
153
  self.app.menu.update_recent_sessions()
@@ -1,6 +1,7 @@
1
1
  """Dataset exporter for converting Revoxx sessions to Talrómur 3 format."""
2
2
 
3
3
  import shutil
4
+ import json
4
5
  from pathlib import Path
5
6
  from typing import List, Dict, Tuple, Optional, Any
6
7
  from collections import Counter
@@ -30,6 +31,7 @@ class DatasetExporter:
30
31
  audio_format: str = "flac",
31
32
  zero_intensity_emotions: List[str] = None,
32
33
  include_intensity: bool = True,
34
+ include_vad: bool = False,
33
35
  ):
34
36
  """Initialize dataset exporter.
35
37
 
@@ -38,11 +40,13 @@ class DatasetExporter:
38
40
  audio_format: Output audio format ('wav' or 'flac')
39
41
  zero_intensity_emotions: List of emotions to set intensity to 0
40
42
  include_intensity: Whether to include intensity column in index.tsv
43
+ include_vad: Whether to run VAD analysis on the exported dataset
41
44
  """
42
45
  self.output_dir = Path(output_dir)
43
46
  self.format = audio_format.lower()
44
47
  self.zero_intensity_emotions = zero_intensity_emotions or ["neutral"]
45
48
  self.include_intensity = include_intensity
49
+ self.include_vad = include_vad
46
50
 
47
51
  def _group_sessions_by_speaker(self, session_paths: List[Path]) -> Dict:
48
52
  """Group sessions by speaker name.
@@ -172,6 +176,11 @@ class DatasetExporter:
172
176
  }
173
177
  )
174
178
 
179
+ # Run VAD processing if requested
180
+ if self.include_vad:
181
+ vad_stats = self._run_vad_processing(all_datasets, progress_callback)
182
+ total_statistics["vad_statistics"] = vad_stats
183
+
175
184
  return all_datasets, total_statistics
176
185
 
177
186
  def _process_emotion_group(
@@ -387,3 +396,115 @@ class DatasetExporter:
387
396
  readme_path = dataset_dir / "README.txt"
388
397
  with open(readme_path, "w", encoding="utf-8") as f:
389
398
  f.write(readme_content)
399
+
400
+ def _run_vad_processing(
401
+ self, dataset_paths: List[Path], progress_callback=None
402
+ ) -> Dict:
403
+ """Run VAD processing on exported datasets using multiprocessing.
404
+
405
+ Args:
406
+ dataset_paths: List of dataset directories to process
407
+ progress_callback: Optional progress callback (count, message)
408
+
409
+ Returns:
410
+ Dictionary with total files processed and warnings
411
+ """
412
+ try:
413
+ from scripts_module.vadiate import get_audio_files
414
+ import multiprocessing as mp
415
+ from concurrent.futures import ProcessPoolExecutor, as_completed
416
+ except ImportError:
417
+ return {} # VAD not available
418
+
419
+ # Count total files for progress
420
+ total_files = sum(len(get_audio_files(str(d))) for d in dataset_paths)
421
+ if total_files == 0:
422
+ return {}
423
+
424
+ processed = 0
425
+ vad_statistics = {"total_files": total_files, "warnings": []}
426
+
427
+ # Use process pool for parallel processing
428
+ # Each process handles VAD analysis for one complete dataset (speaker)
429
+ # This means if we export 3 speakers, we use up to 3 processes
430
+ # Each process analyzes all audio files within its assigned speaker's dataset
431
+ num_workers = min(mp.cpu_count(), len(dataset_paths))
432
+
433
+ with ProcessPoolExecutor(max_workers=num_workers) as executor:
434
+ # Submit one VAD processing task per dataset (per speaker)
435
+ # Each task processes all audio files in that speaker's dataset directory
436
+ future_to_dataset = {
437
+ executor.submit(self._process_dataset_vad, dataset_path): dataset_path
438
+ for dataset_path in dataset_paths
439
+ }
440
+
441
+ # Process completed tasks
442
+ for future in as_completed(future_to_dataset):
443
+ dataset_path = future_to_dataset[future]
444
+ try:
445
+ result = future.result()
446
+ processed += result["files_processed"]
447
+ vad_statistics["warnings"].extend(result["warnings"])
448
+ if progress_callback:
449
+ progress_callback(
450
+ processed, f"VAD analysis: {processed}/{total_files}"
451
+ )
452
+ except Exception as e:
453
+ vad_statistics["warnings"].append(
454
+ f"VAD processing error for {dataset_path}: {e}"
455
+ )
456
+
457
+ return vad_statistics
458
+
459
+ @staticmethod
460
+ def _process_dataset_vad(dataset_path: Path) -> Dict:
461
+ """Process VAD for a single dataset (one speaker's complete dataset).
462
+
463
+ This method runs in a separate process and handles all audio files
464
+ for one speaker. If multiple speakers were exported, each speaker's
465
+ dataset is processed by a different process in parallel.
466
+
467
+ Args:
468
+ dataset_path: Path to the dataset directory for one speaker
469
+
470
+ Returns:
471
+ Dictionary with files processed and warnings
472
+ """
473
+ from scripts_module.vadiate import (
474
+ get_audio_files,
475
+ process_audio,
476
+ load_silero_vad,
477
+ )
478
+
479
+ vad_output = dataset_path / "vad.json"
480
+ audio_files = get_audio_files(str(dataset_path))
481
+
482
+ result_info = {"files_processed": 0, "warnings": []}
483
+
484
+ if not audio_files:
485
+ return result_info
486
+
487
+ # Load model for this process
488
+ model = load_silero_vad()
489
+ results = {}
490
+
491
+ for file_path in audio_files:
492
+ try:
493
+ rel_path, result, warnings = process_audio(
494
+ file_path,
495
+ model,
496
+ str(dataset_path),
497
+ use_dynamic_threshold=True,
498
+ collect_warnings=True,
499
+ )
500
+ results[rel_path] = result
501
+ result_info["warnings"].extend(warnings)
502
+ result_info["files_processed"] += 1
503
+ except Exception as e:
504
+ result_info["warnings"].append(f"VAD error for {file_path}: {e}")
505
+
506
+ # Save results
507
+ with open(vad_output, "w") as f:
508
+ json.dump(results, f, indent=2)
509
+
510
+ return result_info
@@ -41,16 +41,24 @@ class DatasetDialog:
41
41
  # Entry field widths
42
42
  ENTRY_WIDTH_STANDARD = 40
43
43
 
44
- def __init__(self, parent, base_dir: Path, settings_manager: SettingsManager):
44
+ def __init__(
45
+ self,
46
+ parent,
47
+ base_dir: Path,
48
+ settings_manager: SettingsManager,
49
+ process_manager=None,
50
+ ):
45
51
  """Initialize dataset creation dialog.
46
52
 
47
53
  Args:
48
54
  parent: Parent window
49
55
  base_dir: Base directory containing sessions
50
56
  settings_manager: Shared SettingsManager instance
57
+ process_manager: Optional ProcessManager instance for VAD check
51
58
  """
52
59
  self.parent = parent
53
60
  self.settings_manager = settings_manager
61
+ self.process_manager = process_manager
54
62
  self.result = None
55
63
 
56
64
  # Use provided base_dir
@@ -229,11 +237,45 @@ class DatasetDialog:
229
237
  self.settings_manager.settings, "export_include_intensity", True
230
238
  )
231
239
  )
240
+ options_frame = ttk.Frame(output_frame)
241
+ options_frame.grid(row=3, column=1, columnspan=2, sticky=tk.W, pady=2)
242
+
232
243
  ttk.Checkbutton(
233
- output_frame,
244
+ options_frame,
234
245
  text="Include intensity levels in index.tsv",
235
246
  variable=self.include_intensity_var,
236
- ).grid(row=3, column=1, sticky=tk.W, pady=2)
247
+ ).pack(anchor=tk.W)
248
+
249
+ # VAD support checkbox
250
+ self.include_vad_var = tk.BooleanVar(
251
+ value=getattr(self.settings_manager.settings, "export_include_vad", False)
252
+ )
253
+ self.vad_checkbox = ttk.Checkbutton(
254
+ options_frame,
255
+ text="Include VAD analysis",
256
+ variable=self.include_vad_var,
257
+ )
258
+ self.vad_checkbox.pack(anchor=tk.W, pady=(2, 0))
259
+
260
+ # Enable/disable VAD checkbox based on availability
261
+ vad_available = (
262
+ self.process_manager.is_vad_available() if self.process_manager else False
263
+ )
264
+ if vad_available:
265
+ self.vad_checkbox.configure(state="normal")
266
+ # Add tooltip
267
+ self._create_tooltip(
268
+ self.vad_checkbox,
269
+ "Voice Activity Detection provides speech segment timestamps",
270
+ )
271
+ else:
272
+ self.vad_checkbox.configure(state="disabled")
273
+ self.include_vad_var.set(False)
274
+ # Add different tooltip for disabled state
275
+ self._create_tooltip(
276
+ self.vad_checkbox,
277
+ "VAD not available - install Revoxx with '[vad]' option to enable",
278
+ )
237
279
 
238
280
  output_frame.columnconfigure(1, weight=1)
239
281
 
@@ -577,6 +619,9 @@ class DatasetDialog:
577
619
  self.settings_manager.update_setting(
578
620
  "export_include_intensity", self.include_intensity_var.get()
579
621
  )
622
+ self.settings_manager.update_setting(
623
+ "export_include_vad", self.include_vad_var.get()
624
+ )
580
625
 
581
626
  def _run_export(
582
627
  self, session_paths: List[Path], output_dir: Path, dataset_name: Optional[str]
@@ -592,15 +637,24 @@ class DatasetDialog:
592
637
 
593
638
  try:
594
639
  # Create exporter
640
+ vad_enabled = self.include_vad_var.get() and (
641
+ self.process_manager.is_vad_available()
642
+ if self.process_manager
643
+ else False
644
+ )
595
645
  exporter = DatasetExporter(
596
646
  output_dir=output_dir,
597
647
  audio_format=self.format_var.get(),
598
648
  include_intensity=self.include_intensity_var.get(),
649
+ include_vad=vad_enabled,
599
650
  )
600
651
 
601
652
  # Export sessions
602
- def progress_callback(count):
603
- progress_dialog.update(count, f"Processing utterance {count}")
653
+ def progress_callback(count, message=None):
654
+ if message:
655
+ progress_dialog.update(count, message)
656
+ else:
657
+ progress_dialog.update(count, f"Processing utterance {count}")
604
658
 
605
659
  dataset_paths, statistics = exporter.export_sessions(
606
660
  session_paths,
@@ -684,6 +738,20 @@ class DatasetDialog:
684
738
  summary += "\n" + "-" * 50 + "\n"
685
739
  summary += f"⚠ Warning: {statistics['missing_recordings']} recordings were missing\n"
686
740
 
741
+ # Add VAD statistics if available
742
+ if "vad_statistics" in statistics and statistics["vad_statistics"]:
743
+ vad_stats = statistics["vad_statistics"]
744
+ summary += "\n" + "-" * 50 + "\n"
745
+ summary += (
746
+ f"VAD Analysis: {vad_stats.get('total_files', 0)} files processed\n"
747
+ )
748
+
749
+ # Add warnings if any
750
+ if vad_stats.get("warnings"):
751
+ summary += "\nWarnings:\n"
752
+ for warning in vad_stats["warnings"]:
753
+ summary += f"{warning}\n"
754
+
687
755
  # Insert text and make read-only
688
756
  text_widget.insert("1.0", summary)
689
757
  text_widget.configure(state="disabled")
@@ -714,6 +782,41 @@ class DatasetDialog:
714
782
  """Cancel dialog."""
715
783
  self.dialog.destroy()
716
784
 
785
+ @staticmethod
786
+ def _create_tooltip(widget, text) -> None:
787
+ """Create a tooltip for a widget.
788
+
789
+ Args:
790
+ widget: The widget to attach the tooltip to
791
+ text: The tooltip text
792
+ """
793
+ tooltip = None
794
+
795
+ def on_enter(event):
796
+ nonlocal tooltip
797
+ tooltip = tk.Toplevel()
798
+ tooltip.wm_overrideredirect(True)
799
+ tooltip.wm_geometry(f"+{event.x_root+10}+{event.y_root+10}")
800
+ label = ttk.Label(
801
+ tooltip,
802
+ text=text,
803
+ justify=tk.LEFT,
804
+ background="#ffffe0",
805
+ relief=tk.SOLID,
806
+ borderwidth=1,
807
+ font=("TkDefaultFont", "9", "normal"),
808
+ )
809
+ label.pack()
810
+
811
+ def on_leave(event):
812
+ nonlocal tooltip
813
+ if tooltip:
814
+ tooltip.destroy()
815
+ tooltip = None
816
+
817
+ widget.bind("<Enter>", on_enter)
818
+ widget.bind("<Leave>", on_leave)
819
+
717
820
  def show(self) -> Optional[Path]:
718
821
  """Show dialog and return result.
719
822
 
@@ -173,7 +173,9 @@ class OpenSessionDialog:
173
173
  )
174
174
 
175
175
  if new_dir:
176
- self._load_directory(Path(new_dir))
176
+ new_path = Path(new_dir)
177
+ # Always load the directory, even if it's a .revoxx session
178
+ self._load_directory(new_path)
177
179
 
178
180
  def _go_to_parent(self):
179
181
  """Navigate to parent directory."""
@@ -201,6 +203,27 @@ class OpenSessionDialog:
201
203
  self.info_label.config(text="Directory does not exist")
202
204
  return
203
205
 
206
+ # Check if current directory itself is a .revoxx session
207
+ if self._is_valid_session(self.current_dir):
208
+ session_info = self._get_session_info(self.current_dir)
209
+ if session_info:
210
+ # Show this directory as the only session
211
+ self.tree.insert(
212
+ "",
213
+ "end",
214
+ text=self.current_dir.name,
215
+ values=(
216
+ "Session",
217
+ session_info.get("speaker", ""),
218
+ session_info.get("emotion", ""),
219
+ session_info.get("utterances", ""),
220
+ session_info.get("recordings", ""),
221
+ ),
222
+ tags=("session", "current"),
223
+ )
224
+ self.info_label.config(text="Current directory is a Revoxx session")
225
+ return
226
+
204
227
  # Find sessions and subdirectories
205
228
  sessions = []
206
229
  subdirs = []
@@ -374,10 +397,31 @@ class OpenSessionDialog:
374
397
  # Open the session
375
398
  self._on_open()
376
399
 
400
+ def _is_valid_session(self, path: Path) -> bool:
401
+ """Check if a path is a valid Revoxx session.
402
+
403
+ Args:
404
+ path: Path to check
405
+
406
+ Returns:
407
+ True if the path is a valid session, False otherwise
408
+ """
409
+ return (
410
+ path.suffix == ".revoxx"
411
+ and path.is_dir()
412
+ and (path / "session.json").exists()
413
+ )
414
+
377
415
  def _on_open(self):
378
416
  """Handle Open button click."""
379
417
  selection = self.tree.selection()
418
+
419
+ # If no selection, check if current directory is a .revoxx session
380
420
  if not selection:
421
+ if self._is_valid_session(self.current_dir):
422
+ self.result = self.current_dir
423
+ self.dialog.destroy()
424
+ return
381
425
  messagebox.showwarning(
382
426
  "No Selection", "Please select a session to open.", parent=self.dialog
383
427
  )
@@ -385,6 +429,7 @@ class OpenSessionDialog:
385
429
 
386
430
  item = self.tree.item(selection[0])
387
431
  item_type = item["values"][0] if item["values"] else ""
432
+ item_tags = item.get("tags", [])
388
433
 
389
434
  if item_type != "Session":
390
435
  messagebox.showwarning(
@@ -394,12 +439,15 @@ class OpenSessionDialog:
394
439
  )
395
440
  return
396
441
 
397
- # Get the selected session path
398
- session_name = item["text"]
399
- self.result = self.current_dir / session_name
442
+ # Determine the session path
443
+ if "current" in item_tags:
444
+ self.result = self.current_dir
445
+ else:
446
+ session_name = item["text"]
447
+ self.result = self.current_dir / session_name
400
448
 
401
- # Verify it's a valid session
402
- if not self.result.exists() or not self.result.suffix == ".revoxx":
449
+ # Final validation
450
+ if not self._is_valid_session(self.result):
403
451
  messagebox.showerror(
404
452
  "Invalid Session",
405
453
  "The selected directory is not a valid Revoxx session.",