Skip to content

Referencia UI

Documentación autogenerada de los componentes de interfaz.

Main Window - Application container.

Central widget that hosts all other UI components. Based on Section 9.2 layout specification.

MainWindow

Bases: QMainWindow

Main application window.

Layout: ┌────────────────────────────────────────────────────────┐ │ VocalParam v1.0.0-proto [Archivo][Proyecto] [Ayuda]│ ├────────────────────────────────────────────────────────┤ │ ┌──────────┐ ┌────────────────────────────────────┐ │ │ │ RECLIST │ │ RECORDER / EDITOR SEQ │ │ │ │ [70 ln] │ │ ┌──────────────────────────────┐ │ │ │ │ │ │ │ Visual Editor │ │ │ │ │ │ │ ├──────────────────────────────┤ │ │ │ │ │ │ │ Parameter Table │ │ │ │ └──────────┘ └────────────────────────────────────┘ │ ├────────────────────────────────────────────────────────┤ │ Status: Ready | BPM: 120 | Project: [None] │ └────────────────────────────────────────────────────────┘

Source code in src/ui/main_window.py
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
class MainWindow(QMainWindow):
    """Main application window.

    Layout:
    ┌────────────────────────────────────────────────────────┐
    │ VocalParam v1.0.0-proto    [Archivo] [Proyecto] [Ayuda]│
    ├────────────────────────────────────────────────────────┤
    │  ┌──────────┐  ┌────────────────────────────────────┐  │
    │  │ RECLIST  │  │   RECORDER / EDITOR SEQ            │  │
    │  │ [70 ln]  │  │  ┌──────────────────────────────┐  │  │
    │  │          │  │  │       Visual Editor          │  │  │
    │  │          │  │  ├──────────────────────────────┤  │  │
    │  │          │  │  │      Parameter Table         │  │  │
    │  └──────────┘  └────────────────────────────────────┘  │
    ├────────────────────────────────────────────────────────┤
    │ Status: Ready | BPM: 120 | Project: [None]            │
    └────────────────────────────────────────────────────────┘
    """

    def __init__(self):
        super().__init__()
        self.setWindowTitle("VocalParam v1.0.0-prototype")
        self.setMinimumSize(1200, 800)

        self._current_project: ProjectData = None
        self._current_project_path = None
        self._current_line: PhoneticLine = None
        self._current_bpm = 120
        self.audio_engine = AudioEngine()
        self.resource_manager = ResourceManager()
        self.oto_generator = OtoGenerator(self._current_bpm)

        # Initialize Database
        try:
            self.db = AppDatabase()
            self._load_recent_projects()
        except Exception as e:
            logger.error(f"Failed to initialize database: {e}")
            self.db = None

        self._setup_ui()

        # Initialize Controller
        self.editor_controller = EditorController(self.editor_widget, self.parameter_table)

        self._setup_menu()
        self._setup_statusbar()
        self._setup_connections()
        self._apply_dark_theme()

        logger.info("MainWindow initialized")

    def _setup_ui(self):
        """Setup main UI layout."""
        central_widget = QWidget()
        self.setCentralWidget(central_widget)

        layout = QHBoxLayout(central_widget)
        layout.setContentsMargins(10, 10, 10, 10)
        layout.setSpacing(10)

        # Create splitter for resizable panels
        splitter = QSplitter(Qt.Orientation.Horizontal)

        # Left panel: Reclist
        self.reclist_widget = ReclistWidget()
        splitter.addWidget(self.reclist_widget)

        # Right panel: Content area using QStackedWidget
        self.content_stack = QStackedWidget()

        # 0: Placeholder
        self.placeholder_widget = QWidget()
        placeholder_layout = QVBoxLayout(self.placeholder_widget)
        placeholder_label = QLabel("Cargue una reclist para comenzar")
        placeholder_label.setAlignment(Qt.AlignmentFlag.AlignCenter)
        placeholder_label.setStyleSheet(f"color: {COLORS['text_secondary']}; font-size: 18px;")
        placeholder_layout.addWidget(placeholder_label)
        self.content_stack.addWidget(self.placeholder_widget)

        # 1: Recorder
        self.recorder_widget = RecorderWidget(self.audio_engine)
        self.content_stack.addWidget(self.recorder_widget)

        # 2: Editor Split View (Visual + Table)
        editor_container = QWidget()
        editor_layout = QVBoxLayout(editor_container)
        editor_layout.setContentsMargins(0, 0, 0, 0)

        editor_splitter = QSplitter(Qt.Orientation.Vertical)

        self.editor_widget = EditorWidget()
        self.parameter_table = ParameterTableWidget()

        editor_splitter.addWidget(self.editor_widget)
        editor_splitter.addWidget(self.parameter_table)
        editor_splitter.setSizes([500, 200]) # Favor visual editor

        editor_layout.addWidget(editor_splitter)
        self.content_stack.addWidget(editor_container)

        splitter.addWidget(self.content_stack)

        # Set initial sizes (25% / 75%)
        splitter.setSizes([300, 900])

        layout.addWidget(splitter)

    def _setup_connections(self):
        """Connect signals and slots."""
        self.reclist_widget.line_selected.connect(self._on_line_selected)
        self.recorder_widget.recording_stopped.connect(self._on_recording_stopped)

        # Connection from Editor Table to loading audio
        self.parameter_table.row_selected.connect(self._on_editor_row_selected)

        # New: Auto-save on edits (non-explicit)
        self.editor_controller.project_updated.connect(lambda: self._on_save_project(explicit=False))

        # New: Direct Editor Access
        self.btn_goto_editor = QPushButton("✏ Editor Global")
        self.btn_goto_editor.setStyleSheet(f"background-color: {COLORS['accent_primary']}; font-weight: bold; padding: 5px;")
        self.btn_goto_editor.clicked.connect(self._on_goto_editor)
        self.reclist_widget.layout().addWidget(self.btn_goto_editor)

    def _on_line_selected(self, index, line):
        """Handle line selection from reclist."""
        logger.info(f"Line selected: {line.raw_text}")
        self._current_line = line
        self.recorder_widget.set_line(line)
        self.recorder_widget.set_bpm(self._current_bpm)

        # Set default output path for recorded audio if project exists
        if self._current_project:
            project_dir = self._current_project_path.parent if self._current_project_path else Path(".")
            output_dir = Path(self._current_project.output_directory)
            if not output_dir.is_absolute():
                output_dir = project_dir / output_dir
            self.recorder_widget.path_edit.setText(str(output_dir))

        self.content_stack.setCurrentIndex(1)  # Show recorder

    def _on_recording_stopped(self, audio_data):
        """Handle recording completion."""
        if audio_data is not None and self._current_line:
             logger.info(f"Received audio data for line: {self._current_line.raw_text}")

             # 1. Check for Project
             if not self._current_project:
                 res = QMessageBox.question(self, "Proyecto Requerido", 
                     "La grabación se ha guardado localmente, pero no hay un proyecto abierto para organizar la metadata.\n\n"
                     "¿Desea crear un proyecto nuevo ahora?",
                     QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No)
                 if res == QMessageBox.StandardButton.Yes:
                     self._on_new_project()

             # 2. Update Project (if it was created or already existed)
             recording = None
             if self._current_project:
                 recording = self._update_project_recording(self._current_line, audio_data)

             # 2. Generate Initial OTO
             # Use the first segment as the alias for now (prototype limitation)
             # TODO: Handle multi-segment logic
             alias = self._current_line.segments[0] if self._current_line.segments else self._current_line.raw_text

             entry = self.oto_generator.generate_oto(
                 filename=f"{self._current_line.raw_text}.wav",
                 audio_data=audio_data,
                 alias=alias,
                 count_in_beats=self.recorder_widget.COUNT_IN_BEATS
             )

             # Link entry to recording for persistence
             if recording:
                 # Check if this alias already exists in this recording
                 existing_entry = next((e for e in recording.oto_entries if e.alias == entry.alias), None)
                 if existing_entry:
                     # Update existing
                     existing_entry.offset = entry.offset
                     existing_entry.consonant = entry.consonant
                     existing_entry.cutoff = entry.cutoff
                     existing_entry.preutter = entry.preutter
                     existing_entry.overlap = entry.overlap
                     entry = existing_entry # Use the reference in the project
                 else:
                     recording.oto_entries.append(entry)

             # 3. Load into Editor
             self.editor_controller.load_entry(
                 entry, 
                 audio_data, 
                 self.audio_engine._sample_rate
             )

             # 4. Switch View
             self.content_stack.setCurrentIndex(2) # Show Editor Container

             # Refresh the GLOBAL table to show all recorded samples
             self._on_goto_editor() 

             self.statusbar.showMessage(f"Grabación '{alias}' lista para editar.", 3000)

    def _on_goto_editor(self):
        """Switch to editor view manually."""
        # Load all project recordings into the table if project exists
        if self._current_project:
            all_entries = []
            for rec in self._current_project.recordings:
                all_entries.extend(rec.oto_entries)
            self.parameter_table.set_entries(all_entries)
            self.statusbar.showMessage("Cargadas todas las grabaciones en el Editor.", 3000)
        else:
            self.parameter_table.set_entries([])
            self.statusbar.showMessage("Abierto Editor (Sin Proyecto)", 3000)

        self.content_stack.setCurrentIndex(2)

    def _on_editor_row_selected(self, entry: OtoEntry):
        """Handle selection of a row in the global parameter table."""
        if not self._current_project:
            return

        # Find the recording that contains this entry
        recording = next((r for r in self._current_project.recordings if entry in r.oto_entries), None)
        if not recording:
            logger.warning(f"No recording found for alias: {entry.alias}")
            return

        # Load audio file
        project_dir = self._current_project_path.parent if self._current_project_path else Path(".")
        output_dir = Path(self._current_project.output_directory)
        if not output_dir.is_absolute():
            output_dir = project_dir / output_dir

        wav_path = output_dir / recording.filename

        if wav_path.exists():
            try:
                audio_data, sr = self.audio_engine.load_wav(str(wav_path))
                self.editor_controller.load_entry(entry, audio_data, sr)
                self.statusbar.showMessage(f"Cargado: {entry.alias}", 2000)
            except Exception as e:
                logger.error(f"Failed to load audio for editor: {e}")
                self.statusbar.showMessage(f"Error al cargar audio: {recording.filename}", 3000)
        else:
            self.statusbar.showMessage(f"Archivo no encontrado: {recording.filename}", 3000)

    def _update_project_recording(self, line, audio_data):
        """Update or add a recording entry to the current project."""
        if not self._current_project:
            return

        wav_name = f"{line.raw_text}.wav"
        save_path = Path(self.recorder_widget.path_edit.text()) / wav_name

        # Calculate hash for integrity
        try:
            # We hash the file after it's saved by RecorderWidget
            # (Wait, actually RecorderWidget._on_accept saves it)
            # Let's verify it exists
            if save_path.exists():
                file_hash = self.resource_manager.calculate_checksum(save_path)

                from core.models import Recording, RecordingStatus
                # Find existing or create new
                existing = next((r for r in self._current_project.recordings if r.line_index == line.index), None)

                if existing:
                    existing.filename = wav_name
                    existing.status = RecordingStatus.RECORDED
                    existing.duration_ms = len(audio_data) / self.audio_engine._active_sr * 1000
                    existing.hash = file_hash
                else:
                    new_rec = Recording(
                        line_index=line.index,
                        filename=wav_name,
                        status=RecordingStatus.RECORDED,
                        duration_ms=len(audio_data) / self.audio_engine._active_sr * 1000,
                        hash=file_hash
                    )
                    self._current_project.recordings.append(new_rec)

                # Update Reclist UI
                self.reclist_widget.set_line_status(line.index, RecordingStatus.RECORDED)

                logger.info(f"Project updated with recording: {wav_name} (Hash: {file_hash[:8]}...)")
                return existing or new_rec
        except Exception as e:
            logger.error(f"Failed to update project recording: {e}")
        return None

    def _setup_menu(self):
        """Setup menu bar."""
        menubar = self.menuBar()

        # File menu
        file_menu = menubar.addMenu("&Archivo")

        new_action = QAction("&Nuevo Proyecto", self)
        new_action.setShortcut(QKeySequence.StandardKey.New)
        new_action.triggered.connect(self._on_new_project)
        file_menu.addAction(new_action)

        open_action = QAction("&Abrir Proyecto", self)
        open_action.setShortcut(QKeySequence.StandardKey.Open)
        open_action.triggered.connect(self._on_open_project)
        file_menu.addAction(open_action)

        file_menu.addSeparator()

        load_reclist = QAction("Cargar &Reclist...", self)
        load_reclist.setShortcut("Ctrl+R")
        load_reclist.triggered.connect(self._on_load_reclist)
        file_menu.addAction(load_reclist)

        file_menu.addSeparator()

        save_action = QAction("&Guardar", self)
        save_action.setShortcut(QKeySequence.StandardKey.Save)
        save_action.triggered.connect(self._on_save_project)
        file_menu.addAction(save_action)

        export_action = QAction("&Exportar Voicebank...", self)
        export_action.setShortcut("Ctrl+E")
        export_action.triggered.connect(self._on_export)
        file_menu.addAction(export_action)

        file_menu.addSeparator()

        exit_action = QAction("&Salir", self)
        exit_action.setShortcut(QKeySequence.StandardKey.Quit)
        exit_action.triggered.connect(self.close)
        file_menu.addAction(exit_action)

        # Project menu
        project_menu = menubar.addMenu("&Proyecto")

        generate_oto = QAction("&Generar oto.ini", self)
        generate_oto.setShortcut("Ctrl+G")
        generate_oto.triggered.connect(self._on_generate_oto)
        project_menu.addAction(generate_oto)

        project_menu.addSeparator()

        audio_setup = QAction("⚙ Configuración de Audio", self)
        audio_setup.triggered.connect(self._on_audio_settings)
        project_menu.addAction(audio_setup)

        # Help menu
        help_menu = menubar.addMenu("A&yuda")

        about_action = QAction("&Acerca de", self)
        about_action.triggered.connect(self._on_about)
        help_menu.addAction(about_action)

    def _setup_statusbar(self):
        """Setup status bar."""
        self.statusbar = QStatusBar()
        self.setStatusBar(self.statusbar)

        self.status_label = QLabel("Listo")
        self.bpm_label = QLabel(f"BPM: {self._current_bpm}")
        self.project_label = QLabel("Proyecto: [Ninguno]")

        self.statusbar.addWidget(self.status_label, 1)
        self.statusbar.addPermanentWidget(self.bpm_label)
        self.statusbar.addPermanentWidget(self.project_label)

    def _apply_dark_theme(self):
        """Apply dark mode theme from design spec."""
        self.setStyleSheet(f"""
            QMainWindow {{
                background-color: {COLORS['background']};
            }}
            QWidget {{
                background-color: {COLORS['background']};
                color: {COLORS['text_primary']};
            }}
            QMenuBar {{
                background-color: #2D2D2D;
                color: {COLORS['text_primary']};
            }}
            QMenuBar::item:selected {{
                background-color: #3D3D3D;
            }}
            QMenu {{
                background-color: #2D2D2D;
                color: {COLORS['text_primary']};
            }}
            QMenu::item:selected {{
                background-color: #3D3D3D;
            }}
            QStatusBar {{
                background-color: #2D2D2D;
                color: {COLORS['text_secondary']};
            }}
            QSplitter::handle {{
                background-color: #3D3D3D;
            }}
        """)

    def _on_new_project(self):
        """Handle new project action."""
        dialog = ProjectDialog(self)
        if dialog.exec() == ProjectDialog.DialogCode.Accepted:
            data = dialog.get_data()
            if not data: return

            # Create Project Model
            project = ProjectData(
                project_name=data["name"],
                bpm=data["bpm"],
                reclist_path=data["reclist"],
                output_directory=data["output"]
            )

            try:
                # Save immediately
                ProjectRepository.save_project(project, data["save_path"])
                self._current_project_path = Path(data["save_path"])
                self._load_project_ui(project)

                # Setup Resource Manager
                self.resource_manager.set_project_root(self._current_project_path.parent)

                QMessageBox.information(self, "Proyecto Creado", f"El proyecto '{project.project_name}' ha sido creado exitosamente.")
            except Exception as e:
                logger.error(f"Failed to create project: {e}")
                QMessageBox.critical(self, "Error", f"No se pudo crear el proyecto:\n{e}")

    def _on_open_project(self):
        """Handle open project action."""
        filepath, _ = QFileDialog.getOpenFileName(
            self, "Abrir Proyecto", "",
            "Proyectos VocalParam (*.vocalproj);;Todos los archivos (*)"
        )
        if filepath:
            filepath = Path(filepath)
            logger.info(f"Opening project: {filepath}")

            # Check locking
            if not self.resource_manager.create_lock_file(filepath):
                QMessageBox.warning(self, "Proyecto Bloqueado", 
                                  "El proyecto ya parece estar abierto en otra instancia o está bloqueado.")
                return

            try:
                project = ProjectRepository.load_project(filepath)
                self._current_project_path = filepath

                # Verify resources and Integrity
                self._verify_project_resources(project, filepath.parent)
                self._load_project_ui(project)

                # Start background integrity checks
                self.resource_manager.set_project_root(filepath.parent)
                self.resource_manager.start_background_scrubbing()

                self.statusbar.showMessage(f"Proyecto cargado: {project.project_name}", 3000)
            except PersistenceError as e:
                self.resource_manager.release_lock(filepath)
                QMessageBox.critical(self, "Error al abrir", str(e))
                logger.error(f"Error opening project: {e}")

    def _on_load_reclist(self):
        """Handle load reclist action."""
        filepath, _ = QFileDialog.getOpenFileName(
            self, "Cargar Reclist", "",
            "Archivos de texto (*.txt);;Todos los archivos (*)"
        )
        if filepath:
            logger.info(f"Loading reclist: {filepath}")
            self.reclist_widget.load_reclist(filepath)
            self.statusbar.showMessage(f"Reclist cargada: {filepath}", 3000)

    def _load_recent_projects(self):
        """Update recent projects menu (placeholder for full implementation)."""
        if not self.db:
            return
        # In a real app, this would populate a submenu. 
        # For now, we just log it to verify DB connectivity.
        recent = self.db.get_recent_projects(limit=5)
        logger.info(f"Loaded {len(recent)} recent projects from DB")

    def _on_save_project(self, explicit=True):
        """Handle save project action.

        Args:
            explicit: If True, show dialogs and errors. If False (auto-save), be silent 
                     unless a critical error occurs and only if a path is already set.
        """
        if not self._current_project:
            if explicit:
                QMessageBox.warning(self, "Guardar", "Primero debe crear o abrir un proyecto.")
            return

        # Get existing path
        filepath = self._current_project_path

        # If no path and user clicked Save, ask for one
        if not filepath and explicit:
            filepath, _ = QFileDialog.getSaveFileName(
                self, "Guardar Proyecto", "",
                "Proyectos VocalParam (*.vocalproj)"
            )
            if filepath:
                self._current_project_path = Path(filepath)
            else:
                return # User cancelled

        # Only proceed if we have a path
        if self._current_project_path:
            try:
                # Update metadata
                self._current_project.last_modified = datetime.now()

                ProjectRepository.save_project(self._current_project, self._current_project_path)

                if explicit:
                    self.statusbar.showMessage("Proyecto guardado correctamente", 3000)
                else:
                    self.statusbar.showMessage("Proyecto auto-guardado", 1000)
            except PersistenceError as e:
                logger.error(f"Error saving project: {e}")
                if explicit:
                    QMessageBox.critical(self, "Error al guardar", str(e))

    def _verify_project_resources(self, project: ProjectData, project_dir: Path):
        """Check if all recordings exist and verify integrity."""
        missing = []
        corrupted = []

        output_dir = Path(project.output_directory)
        if not output_dir.is_absolute():
            output_dir = project_dir / output_dir

        for rec in project.recordings:
            wav_path = output_dir / rec.filename
            if not wav_path.exists():
                # Try smart relinking
                recovered = self.resource_manager.find_missing_resource(wav_path, [project_dir])
                if recovered:
                    rec.filename = os.path.relpath(recovered, output_dir)
                    logger.info(f"Relinked {rec.filename} to {recovered}")
                else:
                    missing.append(rec.filename)
            elif rec.hash:
                # Verify integrity
                current_hash = self.resource_manager.calculate_checksum(wav_path)
                if current_hash != rec.hash:
                    corrupted.append(rec.filename)

        if missing or corrupted:
            msg = "Problemas detectados en los recursos:\n"
            if missing:
                msg += f"\nFaltantes ({len(missing)}):\n" + "\n".join(missing[:5])
                if len(missing) > 5: msg += "\n..."
            if corrupted:
                msg += f"\nCorruptos/Modificados ({len(corrupted)}):\n" + "\n".join(corrupted[:5])
                if len(corrupted) > 5: msg += "\n..."

            QMessageBox.warning(self, "Integridad de Recursos", msg)

    def _load_project_ui(self, project: ProjectData):
        """Update UI with project data."""
        self._current_project = project
        # Note: self._current_project_path must be set before calling this 
        # as ProjectData doesn't store its own file path.
        self._current_bpm = project.bpm

        self.project_label.setText(f"Proyecto: {project.project_name}")
        self.bpm_label.setText(f"BPM: {project.bpm}")

        # Load reclist if path exists
        if project.reclist_path:
             self.reclist_widget.load_reclist(project.reclist_path)

        # Load recording statuses into Reclist UI
        from core.models import RecordingStatus
        for rec in project.recordings:
            self.reclist_widget.set_line_status(rec.line_index, rec.status)

        self.statusbar.showMessage(f"Proyecto {project.project_name} cargado", 3000)
        """Load recent projects specific logic (placeholder for menu update)."""
        # This would update the "Open Recent" menu
        pass

    def _on_export(self):
        """Handle export action."""
        folder = QFileDialog.getExistingDirectory(
            self, "Seleccionar carpeta de exportación"
        )
        if folder:
            logger.info(f"Exporting to: {folder}")
            # TODO: Implement export
            self.statusbar.showMessage(f"Exportado a: {folder}", 3000)

    def _on_generate_oto(self):
        """Handle generate oto.ini action."""
        logger.info("Generate oto.ini requested")
        # TODO: Implement oto generation
        self.statusbar.showMessage("Generando oto.ini...", 3000)

    def _on_audio_settings(self):
        """Show audio hardware configuration and apply settings."""
        dialog = AudioSettingsDialog(self.audio_engine, self)
        if dialog.exec() == AudioSettingsDialog.DialogCode.Accepted:
            input_idx, output_idx, sr = dialog.get_selected_devices()
            self.audio_engine.set_devices(input_idx, output_idx)
            self.audio_engine.set_sample_rate(sr) # This also regenerates clicks
            self.audio_engine.save_config() 
            self.statusbar.showMessage(f"Configuración actualizada: {sr}Hz", 3000)

    def closeEvent(self, event):
        """Cleanup on close."""
        self.resource_manager.stop_background_scrubbing()
        if self._current_project_path:
            self.resource_manager.release_lock(Path(self._current_project_path))
        if hasattr(self, 'db') and self.db:
            self.db.close()
        super().closeEvent(event)

    def _on_about(self):
        """Show about dialog."""
        QMessageBox.about(
            self,
            "Acerca de VocalParam",
            "<h2>VocalParam v1.0.0-prototype</h2>"
            "<p>Sistema Unificado de Grabación y Configuración de Voicebanks</p>"
            "<p>Licencia: MIT Open Source</p>"
            "<p><a href='https://github.com/org/vocalparam'>GitHub</a></p>"
        )

closeEvent(event)

Cleanup on close.

Source code in src/ui/main_window.py
648
649
650
651
652
653
654
655
def closeEvent(self, event):
    """Cleanup on close."""
    self.resource_manager.stop_background_scrubbing()
    if self._current_project_path:
        self.resource_manager.release_lock(Path(self._current_project_path))
    if hasattr(self, 'db') and self.db:
        self.db.close()
    super().closeEvent(event)

Reclist Widget - List of phonetic lines to record.

Displays all lines from the loaded reclist with status indicators. Based on Section 9.2 ReclistWidget specification.

ReclistWidget

Bases: QWidget

Widget displaying list of reclist lines with status.

Signals

line_selected: Emitted when a line is selected (index, PhoneticLine)

Source code in src/ui/reclist_widget.py
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
class ReclistWidget(QWidget):
    """Widget displaying list of reclist lines with status.

    Signals:
        line_selected: Emitted when a line is selected (index, PhoneticLine)
    """

    line_selected = pyqtSignal(int, object)  # index, PhoneticLine

    def __init__(self, parent=None):
        super().__init__(parent)
        self.parser = ReclistParser()
        self._lines: List[PhoneticLine] = []
        self._statuses: dict[int, RecordingStatus] = {}

        self._setup_ui()

    def _setup_ui(self):
        """Setup widget UI."""
        layout = QVBoxLayout(self)
        layout.setContentsMargins(5, 5, 5, 5)
        layout.setSpacing(5)

        # Header
        header = QLabel("RECLIST")
        header.setStyleSheet(f"""
            font-weight: bold;
            font-size: 14px;
            color: {COLORS['text_primary']};
            padding: 5px;
        """)
        layout.addWidget(header)

        # Line count label
        self.count_label = QLabel("0 líneas")
        self.count_label.setStyleSheet(f"color: {COLORS['text_secondary']};")
        layout.addWidget(self.count_label)

        # List widget
        self.list_widget = QListWidget()
        self.list_widget.setStyleSheet(f"""
            QListWidget {{
                background-color: #252525;
                border: 1px solid #3D3D3D;
                border-radius: 4px;
            }}
            QListWidget::item {{
                padding: 8px;
                border-bottom: 1px solid #3D3D3D;
            }}
            QListWidget::item:selected {{
                background-color: #3D3D3D;
            }}
            QListWidget::item:hover {{
                background-color: #353535;
            }}
        """)
        self.list_widget.itemClicked.connect(self._on_item_clicked)
        self.list_widget.itemDoubleClicked.connect(self._on_item_double_clicked)
        layout.addWidget(self.list_widget)

        # Buttons
        button_layout = QHBoxLayout()

        self.load_btn = QPushButton("Cargar...")
        self.load_btn.setStyleSheet(self._button_style())
        self.load_btn.clicked.connect(self._on_load_clicked)
        button_layout.addWidget(self.load_btn)

        layout.addLayout(button_layout)

    def _button_style(self) -> str:
        """Return button stylesheet."""
        return f"""
            QPushButton {{
                background-color: #3D3D3D;
                color: {COLORS['text_primary']};
                border: none;
                border-radius: 4px;
                padding: 8px 16px;
            }}
            QPushButton:hover {{
                background-color: #4D4D4D;
            }}
            QPushButton:pressed {{
                background-color: #2D2D2D;
            }}
        """

    def load_reclist(self, filepath: str) -> bool:
        """Load and parse a reclist file.

        Args:
            filepath: Path to reclist .txt file

        Returns:
            True if loaded successfully
        """
        try:
            self._lines = self.parser.parse_file(filepath)
            self._statuses = {line.index: RecordingStatus.PENDING for line in self._lines}
            self._populate_list()
            logger.info(f"Loaded reclist with {len(self._lines)} lines")
            return True
        except ReclistParseError as e:
            logger.error(f"Failed to parse reclist: {e}")
            return False
        except FileNotFoundError as e:
            logger.error(f"Reclist file not found: {e}")
            return False

    def _populate_list(self):
        """Populate list widget with loaded lines."""
        self.list_widget.clear()

        for line in self._lines:
            status = self._statuses.get(line.index, RecordingStatus.PENDING)
            item = QListWidgetItem(self._format_line(line, status))
            item.setData(Qt.ItemDataRole.UserRole, line.index)

            # Color based on status
            if status == RecordingStatus.RECORDED:
                item.setForeground(QBrush(QColor(COLORS['success'])))
            elif status == RecordingStatus.VALIDATED:
                item.setForeground(QBrush(QColor("#50FA7B")))  # Brighter green
            else:
                item.setForeground(QBrush(QColor(COLORS['text_secondary'])))

            self.list_widget.addItem(item)

        self.count_label.setText(f"{len(self._lines)} líneas")

    def _format_line(self, line: PhoneticLine, status: RecordingStatus) -> str:
        """Format line for display."""
        status_icon = {
            RecordingStatus.PENDING: "☐",
            RecordingStatus.RECORDED: "☑",
            RecordingStatus.VALIDATED: "✓"
        }.get(status, "☐")

        return f"{status_icon} {line.index:03d} {line.raw_text}"

    def set_line_status(self, index: int, status: RecordingStatus):
        """Update status of a specific line.

        Args:
            index: Line index
            status: New status
        """
        self._statuses[index] = status
        self._populate_list()  # Refresh display

    def get_line(self, index: int) -> Optional[PhoneticLine]:
        """Get PhoneticLine by index."""
        for line in self._lines:
            if line.index == index:
                return line
        return None

    def _on_item_clicked(self, item: QListWidgetItem):
        """Handle item click."""
        index = item.data(Qt.ItemDataRole.UserRole)
        line = self.get_line(index)
        if line:
            self.line_selected.emit(index, line)

    def _on_item_double_clicked(self, item: QListWidgetItem):
        """Handle item double click - start recording."""
        # TODO: Trigger recording mode
        pass

    def _on_load_clicked(self):
        """Handle load button click."""
        from PyQt6.QtWidgets import QFileDialog
        filepath, _ = QFileDialog.getOpenFileName(
            self, "Cargar Reclist", "",
            "Archivos de texto (*.txt);;Todos los archivos (*)"
        )
        if filepath:
            self.load_reclist(filepath)

get_line(index)

Get PhoneticLine by index.

Source code in src/ui/reclist_widget.py
177
178
179
180
181
182
def get_line(self, index: int) -> Optional[PhoneticLine]:
    """Get PhoneticLine by index."""
    for line in self._lines:
        if line.index == index:
            return line
    return None

load_reclist(filepath)

Load and parse a reclist file.

Parameters:

Name Type Description Default
filepath str

Path to reclist .txt file

required

Returns:

Type Description
bool

True if loaded successfully

Source code in src/ui/reclist_widget.py
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
def load_reclist(self, filepath: str) -> bool:
    """Load and parse a reclist file.

    Args:
        filepath: Path to reclist .txt file

    Returns:
        True if loaded successfully
    """
    try:
        self._lines = self.parser.parse_file(filepath)
        self._statuses = {line.index: RecordingStatus.PENDING for line in self._lines}
        self._populate_list()
        logger.info(f"Loaded reclist with {len(self._lines)} lines")
        return True
    except ReclistParseError as e:
        logger.error(f"Failed to parse reclist: {e}")
        return False
    except FileNotFoundError as e:
        logger.error(f"Reclist file not found: {e}")
        return False

set_line_status(index, status)

Update status of a specific line.

Parameters:

Name Type Description Default
index int

Line index

required
status RecordingStatus

New status

required
Source code in src/ui/reclist_widget.py
167
168
169
170
171
172
173
174
175
def set_line_status(self, index: int, status: RecordingStatus):
    """Update status of a specific line.

    Args:
        index: Line index
        status: New status
    """
    self._statuses[index] = status
    self._populate_list()  # Refresh display

Recorder Widget - Recording interface with metronome.

Visual and audio metronome for synchronized recording. Based on Section 9.3 RecorderWidget specification.

MoraBox

Bases: QWidget

Single mora indicator box.

Source code in src/ui/recorder_widget.py
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
class MoraBox(QWidget):
    """Single mora indicator box."""

    def __init__(self, text: str, parent=None):
        super().__init__(parent)
        self.text = text
        self._active = False
        self.setMinimumSize(60, 60)
        self._setup_ui()

    def _setup_ui(self):
        layout = QVBoxLayout(self)
        layout.setContentsMargins(5, 5, 5, 5)

        self.label = QLabel(self.text)
        self.label.setAlignment(Qt.AlignmentFlag.AlignCenter)
        self.label.setStyleSheet(f"""
            font-size: 16px;
            font-weight: bold;
            color: {COLORS['text_primary']};
        """)
        layout.addWidget(self.label)

        self._update_style()

    def set_active(self, active: bool):
        """Set whether this mora is currently active."""
        self._active = active
        self._update_style()

    def _update_style(self):
        """Update visual style based on state."""
        if self._active:
            self.setStyleSheet(f"""
                QWidget {{
                    background-color: {COLORS['accent_recording']};
                    border: 2px solid {COLORS['accent_recording']};
                    border-radius: 8px;
                }}
            """)
        else:
            self.setStyleSheet(f"""
                QWidget {{
                    background-color: #2D2D2D;
                    border: 2px solid #3D3D3D;
                    border-radius: 8px;
                }}
            """)

set_active(active)

Set whether this mora is currently active.

Source code in src/ui/recorder_widget.py
50
51
52
53
def set_active(self, active: bool):
    """Set whether this mora is currently active."""
    self._active = active
    self._update_style()

RecorderWidget

Bases: QWidget

Recording interface with visual metronome.

Displays 7 mora boxes that highlight in sequence during recording.

Signals

recording_started: Emitted when recording begins recording_stopped: Emitted when recording ends (with audio data) recording_cancelled: Emitted when recording is cancelled

Source code in src/ui/recorder_widget.py
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
class RecorderWidget(QWidget):
    """Recording interface with visual metronome.

    Displays 7 mora boxes that highlight in sequence during recording.

    Signals:
        recording_started: Emitted when recording begins
        recording_stopped: Emitted when recording ends (with audio data)
        recording_cancelled: Emitted when recording is cancelled
    """

    recording_started = pyqtSignal()
    recording_stopped = pyqtSignal(object)  # audio data
    recording_cancelled = pyqtSignal()

    MIN_RECORDING_DURATION_MS = 4000
    COUNT_IN_BEATS = 3

    def __init__(self, audio_engine: AudioEngine, parent=None):
        super().__init__(parent)
        self.engine = audio_engine
        self._bpm = DEFAULT_BPM
        self._current_line: PhoneticLine = None
        self._current_mora = 0
        self._count_in_counter = 0 # Negative during count-in
        self._is_recording = False
        self._last_audio = None

        # Determine default save path
        project_root = Path(__file__).parent.parent.parent
        self._dest_path = project_root / "recordings" / "test_samples"
        self._dest_path.mkdir(parents=True, exist_ok=True)

        self._setup_ui()
        self._setup_timers()

    def _setup_ui(self):
        """Setup widget UI."""
        layout = QVBoxLayout(self)
        layout.setContentsMargins(20, 20, 20, 20)
        layout.setSpacing(20)

        # Title
        self.title_label = QLabel("GRABANDO")
        self.title_label.setStyleSheet(f"""
            font-size: 24px;
            font-weight: bold;
            color: {COLORS['text_secondary']};
        """)
        self.title_label.setAlignment(Qt.AlignmentFlag.AlignCenter)
        layout.addWidget(self.title_label)

        # Line text
        self.line_label = QLabel("Seleccione una línea para grabar")
        self.line_label.setStyleSheet(f"""
            font-size: 18px;
            color: {COLORS['text_secondary']};
        """)
        self.line_label.setAlignment(Qt.AlignmentFlag.AlignCenter)
        layout.addWidget(self.line_label)

        # Instruction
        instruction = QLabel("Pronuncia cada sílaba al ritmo del metrónomo:")
        instruction.setStyleSheet(f"color: {COLORS['text_secondary']};")
        instruction.setAlignment(Qt.AlignmentFlag.AlignCenter)
        layout.addWidget(instruction)

        # Path Destination Selector
        path_layout = QHBoxLayout()
        path_label = QLabel("Destino:")
        path_label.setFixedWidth(50)
        path_layout.addWidget(path_label)

        self.path_edit = QLineEdit(str(self._dest_path))
        self.path_edit.setStyleSheet(f"""
            QLineEdit {{
                background-color: #252525;
                color: {COLORS['text_primary']};
                border: 1px solid #3D3D3D;
                border-radius: 4px;
                padding: 5px;
            }}
        """)
        self.path_edit.setReadOnly(True)
        path_layout.addWidget(self.path_edit)

        self.browse_btn = QPushButton("...")
        self.browse_btn.setFixedWidth(40)
        self.browse_btn.clicked.connect(self._on_browse_destination)
        self.browse_btn.setStyleSheet(self._button_style("#6272A4"))
        path_layout.addWidget(self.browse_btn)

        layout.addLayout(path_layout)

        # Mora boxes container
        mora_container = QHBoxLayout()
        mora_container.setSpacing(10)

        self.mora_boxes: list[MoraBox] = []
        for i in range(MORAS_PER_LINE):
            box = MoraBox(f"Mora {i+1}")
            self.mora_boxes.append(box)
            mora_container.addWidget(box)

        layout.addLayout(mora_container)

        # Oscilloscope / Waveform
        self.wave_scope = WaveformScope()
        self.wave_scope.setFixedHeight(120)
        layout.addWidget(self.wave_scope)

        # Progress bar
        self.progress_bar = QProgressBar()
        self.progress_bar.setMaximum(100)
        self.progress_bar.setValue(0)
        self.progress_bar.setStyleSheet(f"""
            QProgressBar {{
                border: 2px solid #3D3D3D;
                border-radius: 5px;
                text-align: center;
                background-color: #252525;
            }}
            QProgressBar::chunk {{
                background-color: {COLORS['accent_recording']};
            }}
        """)
        layout.addWidget(self.progress_bar)

        # Time label
        self.time_label = QLabel("0.0s / 0.0s")
        self.time_label.setAlignment(Qt.AlignmentFlag.AlignCenter)
        self.time_label.setStyleSheet(f"color: {COLORS['text_secondary']};")
        layout.addWidget(self.time_label)

        # Control buttons
        button_layout = QHBoxLayout()
        button_layout.setSpacing(20)

        self.rerecord_btn = QPushButton("Iniciar/Re-grabar (Espacio/R)")
        self.rerecord_btn.setShortcut("Space")
        self.rerecord_btn.clicked.connect(self._on_rerecord)
        self.rerecord_btn.setStyleSheet(self._button_style("#FFB86C"))
        button_layout.addWidget(self.rerecord_btn)

        self.accept_btn = QPushButton("Aceptar (Enter)")
        self.accept_btn.setShortcut("Return")
        self.accept_btn.clicked.connect(self._on_accept)
        self.accept_btn.setStyleSheet(self._button_style(COLORS['success']))
        button_layout.addWidget(self.accept_btn)

        self.cancel_btn = QPushButton("Cancelar (Esc)")
        self.cancel_btn.setShortcut("Escape")
        self.cancel_btn.clicked.connect(self._on_cancel)
        self.cancel_btn.setStyleSheet(self._button_style(COLORS['error']))
        button_layout.addWidget(self.cancel_btn)

        layout.addLayout(button_layout)

        # Listen Button (Extra controls)
        extra_layout = QHBoxLayout()
        self.listen_btn = QPushButton("▶ Escuchar / Listen")
        self.listen_btn.clicked.connect(self._on_listen_clicked)
        self.listen_btn.setStyleSheet(self._button_style("#BD93F9"))
        self.listen_btn.setEnabled(False)
        self.listen_btn.setMinimumWidth(200)
        extra_layout.addStretch()
        extra_layout.addWidget(self.listen_btn)
        extra_layout.addStretch()
        layout.addLayout(extra_layout)

        # Spacer
        layout.addStretch()

    def _button_style(self, color: str) -> str:
        """Generate button style with given accent color."""
        return f"""
            QPushButton {{
                background-color: #3D3D3D;
                color: {COLORS['text_primary']};
                border: 2px solid {color};
                border-radius: 8px;
                padding: 12px 24px;
                font-size: 14px;
            }}
            QPushButton:hover {{
                background-color: {color};
                color: #1E1E1E;
            }}
            QPushButton:pressed {{
                background-color: #2D2D2D;
            }}
        """

    def _setup_timers(self):
        """Setup timing system."""
        self.metronome_timer = QTimer()
        self.metronome_timer.timeout.connect(self._on_metronome_tick)

        self.progress_timer = QTimer()
        self.progress_timer.timeout.connect(self._update_progress)

        self.scope_timer = QTimer()
        self.scope_timer.timeout.connect(self._update_scope)
        # High-frequency updates for 60 FPS smoothness
        self.progress_interval = 16 
        self.scope_interval = 16

    def set_line(self, line: PhoneticLine):
        """Set the current line to record.

        Args:
            line: PhoneticLine to record
        """
        self._current_line = line
        self.line_label.setText(line.raw_text)

        # Update mora boxes with segment text
        for i, box in enumerate(self.mora_boxes):
            if i < len(line.segments):
                box.label.setText(line.segments[i].upper())
                box.setVisible(True)
            else:
                box.setVisible(False)

        self._reset_state()

    def set_bpm(self, bpm: int):
        """Set BPM for metronome.

        Args:
            bpm: Beats per minute
        """
        self._bpm = bpm

    def start_recording(self):
        """Start recording with metronome."""
        if not self._current_line:
            logger.warning("No line selected for recording")
            return

        self._is_recording = True
        self._count_in_counter = -self.COUNT_IN_BEATS # Start at -3
        self._current_mora = 0
        self._elapsed_ms = 0
        self._start_time = time.time()
        self._last_audio = None
        self.listen_btn.setEnabled(False)
        self._update_recording_status(True, "PREPARAR")

        # Calculate interval and duration
        ms_per_beat = int(60000 / self._bpm)
        self.tail_beats = 1

        # Total duration must cover count-in + segments + tail, AND satisfy min duration
        # Actually count-in is part of the recorded file, so it counts towards duration?
        # User said: "Grabación se inicia desde el segundo 1... tiempo muerto para 3 clicks"
        # So yes, count-in is recorded.

        notes_beats = len(self._current_line.segments) + self.tail_beats
        notes_duration = notes_beats * ms_per_beat
        countin_duration = self.COUNT_IN_BEATS * ms_per_beat

        # Ensure total duration is at least MIN_RECORDING_DURATION_MS
        calculated_total = countin_duration + notes_duration
        self._target_duration_ms = max(self.MIN_RECORDING_DURATION_MS, calculated_total)

        self.progress_bar.setMaximum(self._target_duration_ms)

        # Start persistent output stream for metronome FIRST
        self.engine.start_output_stream()

        # Start hardware recording
        try:
            self.engine.start_recording()
        except Exception as e:
            from PyQt6.QtWidgets import QMessageBox
            QMessageBox.critical(self, "Error de Audio", 
                               f"No se pudo iniciar la grabación:\n{str(e)}\n\n"
                               "Verifica tu configuración de audio en Proyecto > Configuración.")
            self._reset_state()
            self._is_recording = False
            self.engine.stop_output_stream()
            return

        # Start timers
        self.metronome_timer.start(ms_per_beat)
        self.progress_timer.start(self.progress_interval)
        self.scope_timer.start(self.scope_interval)

        # Prepare WaveformScope for fixed timeline
        self.wave_scope.set_mode('fixed', self._target_duration_ms)
        self.wave_scope.setup_mora_regions(
            self._bpm, 
            len(self._current_line.segments), 
            self.COUNT_IN_BEATS
        )

        # Play first count-in click immediately
        self._play_count_in()

        self.recording_started.emit()
        logger.info(f"Recording sequence started: {self._current_line.raw_text}")

    def _play_count_in(self):
        """Play count-in logic."""
        self.engine.play_click(countin=True)
        # Visual feedback for count-in
        count = abs(self._count_in_counter)
        if count > 0:
            self._update_recording_status(True, f"PREPARAR: {count}...")

    def _reset_state(self):
        """Reset recording state."""
        self._current_mora = 0
        self._count_in_counter = 0
        self._elapsed_ms = 0
        self.progress_bar.setValue(0)
        self.time_label.setText("0.0s / 0.0s")
        self.listen_btn.setEnabled(False)
        self._last_audio = None
        self.wave_scope.set_mode('scrolling')
        self.wave_scope.clear()

        for box in self.mora_boxes:
            box.set_active(False)

        self._update_recording_status(False)

    def _update_recording_status(self, is_recording: bool, text_override: str = None):
        """Update the recording title style."""
        if is_recording:
            color = COLORS['accent_recording']
            text = text_override if text_override else "GRABANDO..."
        else:
            color = COLORS['text_secondary']
            text = "GRABANDO"

        self.title_label.setText(text)
        self.title_label.setStyleSheet(f"""
            font-size: 24px;
            font-weight: bold;
            color: {color};
        """)

    def _on_metronome_tick(self):
        """Handle metronome tick."""
        # Handle Count-in phase
        if self._count_in_counter < 0:
            self._count_in_counter += 1
            if self._count_in_counter < 0:
                 self._play_count_in()
                 return
            else:
                 # Count-in finished, start actual recording metrics
                 self._update_recording_status(True, "GRABANDO...")
                 # Start normal loop below (fall through to index 0)

        # --- Normal Metronome Logic ---

        # Deactivate previous mora visually
        if self._current_mora > 0 and self._current_mora - 1 < len(self.mora_boxes):
             self.mora_boxes[self._current_mora - 1].set_active(False)

        # Check if we should stop (based on TIME, not just beats, to enforce min duration)
        # But we align stopping with beats to be rhythmic.
        # We stop if we are past the target duration.

        ms_per_beat = int(60000 / self._bpm)
        current_beat_time = (self._current_mora + self.COUNT_IN_BEATS) * ms_per_beat

        if self._elapsed_ms >= self._target_duration_ms:
            logger.info("Target duration reached, stopping.")
            self.stop_recording()
            return

        # Determine if we are in "mora" phase or "tail/padding" phase
        num_segments = len(self._current_line.segments) if self._current_line else 0

        if self._current_mora < num_segments:
             # Activate box
             if self._current_mora < len(self.mora_boxes):
                 self.mora_boxes[self._current_mora].set_active(True)
             # Play accent/normal click
             is_first = (self._current_mora == 0)
             self.engine.play_click(accent=is_first)
        else:
             # Tail/Padding phase
             self.engine.play_click(accent=False)

        self._current_mora += 1

    def _update_scope(self):
        """Update the scrolling waveform plot (DSP)."""
        if self._is_recording:
            # During recording, if in fixed mode, we don't necessarily update the data 
            # unless we implement a real-time waveform builder.
            # For now, we'll keep it simple and just update the playhead.
            # But we could optionally keep scrolling the scope if wanted.
            pass
        else:
            data = self.engine.get_scope_data()
            self.wave_scope.update_data(data)

    def _update_progress(self):
        """Update progress bar, time display and playhead."""
        if not self._is_recording and not self.engine.is_playing():
            if not self.engine.is_playing() and self.progress_timer.isActive():
                self.progress_timer.stop()
            return

        if self._is_recording:
            # Real-time sync during recording
            elapsed_real = (time.time() - self._start_time) * 1000
            self._elapsed_ms = int(elapsed_real)
        else:
            # Sync with playback engine
            self._elapsed_ms = int(self.engine.get_playback_progress())

        self.progress_bar.setValue(self._elapsed_ms)
        self.wave_scope.set_playhead(self._elapsed_ms)

        elapsed_s = self._elapsed_ms / 1000
        total_s = self._target_duration_ms / 1000 if self._is_recording else (len(self._last_audio) / self.engine._active_sr if self._last_audio is not None else 0)

        self.time_label.setText(f"{elapsed_s:.1f}s / {total_s:.1f}s")

    def _on_rerecord(self):
        """Handle re-record button."""
        self.stop_recording()
        self._reset_state()
        self.start_recording()

    def _on_accept(self):
        """Handle accept button."""
        # Fix 44-byte bug (M5.1): Don't stop if already stopped, just save what we have
        if self._is_recording:
            self.stop_recording()

        # Save audio if a line is selected and we have a path
        if self._current_line and self._last_audio is not None and len(self._last_audio) > 0:
             wav_name = f"{self._current_line.raw_text}.wav"
             save_file = Path(self.path_edit.text()) / wav_name
             try:
                 self.engine.save_wav(self._last_audio, str(save_file))
                 logger.info(f"Auto-saved recording to: {save_file}")
             except Exception as e:
                 logger.error(f"Failed to auto-save recording: {e}")

        # Return recorded audio
        self.recording_stopped.emit(self._last_audio)

    def _on_cancel(self):
        """Handle cancel button."""
        self.stop_recording()
        self._reset_state()
        self.recording_cancelled.emit()

    def _on_browse_destination(self):
        """Open dialog to select destination folder."""
        folder = QFileDialog.getExistingDirectory(
            self, "Seleccionar carpeta de destino", str(self._dest_path)
        )
        if folder:
            self._dest_path = Path(folder)
            self.path_edit.setText(folder)
            logger.info(f"Destination path changed to: {folder}")

    def _on_listen_clicked(self):
        """Play back the last recorded audio with visual sync."""
        if self._last_audio is not None:
            # Prepare UI for playback
            duration_ms = (len(self._last_audio) / self.engine._active_sr) * 1000
            self.progress_bar.setMaximum(int(duration_ms))

            self.wave_scope.set_mode('fixed', duration_ms)
            self.wave_scope.set_waveform(self._last_audio, self.engine._active_sr)
            # Re-draw regions for playback (no count-in offset here because we play recorded audio only)
            # BUT the recorded audio INCLUDES the count-in silence! 
            # So we keep the same region setup.
            self.wave_scope.setup_mora_regions(
                self._bpm, 
                len(self._current_line.segments), 
                self.COUNT_IN_BEATS
            )

            self.engine.play_audio(self._last_audio)
            self.progress_timer.start(50) # Faster updates for smooth playhead
        else:
            logger.warning("No audio to play")

    def stop_recording(self):
        """Stop recording."""
        self._is_recording = False
        self.metronome_timer.stop()
        self.progress_timer.stop()
        self.scope_timer.stop()

        # Stop hardware recording AND output stream
        self._last_audio = self.engine.stop_recording()
        self.engine.stop_output_stream()

        if self._last_audio is not None and len(self._last_audio) > 0:
            self.listen_btn.setEnabled(True)

        self._update_recording_status(False)
        logger.info("Recording stopped")

set_bpm(bpm)

Set BPM for metronome.

Parameters:

Name Type Description Default
bpm int

Beats per minute

required
Source code in src/ui/recorder_widget.py
301
302
303
304
305
306
307
def set_bpm(self, bpm: int):
    """Set BPM for metronome.

    Args:
        bpm: Beats per minute
    """
    self._bpm = bpm

set_line(line)

Set the current line to record.

Parameters:

Name Type Description Default
line PhoneticLine

PhoneticLine to record

required
Source code in src/ui/recorder_widget.py
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
def set_line(self, line: PhoneticLine):
    """Set the current line to record.

    Args:
        line: PhoneticLine to record
    """
    self._current_line = line
    self.line_label.setText(line.raw_text)

    # Update mora boxes with segment text
    for i, box in enumerate(self.mora_boxes):
        if i < len(line.segments):
            box.label.setText(line.segments[i].upper())
            box.setVisible(True)
        else:
            box.setVisible(False)

    self._reset_state()

start_recording()

Start recording with metronome.

Source code in src/ui/recorder_widget.py
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
def start_recording(self):
    """Start recording with metronome."""
    if not self._current_line:
        logger.warning("No line selected for recording")
        return

    self._is_recording = True
    self._count_in_counter = -self.COUNT_IN_BEATS # Start at -3
    self._current_mora = 0
    self._elapsed_ms = 0
    self._start_time = time.time()
    self._last_audio = None
    self.listen_btn.setEnabled(False)
    self._update_recording_status(True, "PREPARAR")

    # Calculate interval and duration
    ms_per_beat = int(60000 / self._bpm)
    self.tail_beats = 1

    # Total duration must cover count-in + segments + tail, AND satisfy min duration
    # Actually count-in is part of the recorded file, so it counts towards duration?
    # User said: "Grabación se inicia desde el segundo 1... tiempo muerto para 3 clicks"
    # So yes, count-in is recorded.

    notes_beats = len(self._current_line.segments) + self.tail_beats
    notes_duration = notes_beats * ms_per_beat
    countin_duration = self.COUNT_IN_BEATS * ms_per_beat

    # Ensure total duration is at least MIN_RECORDING_DURATION_MS
    calculated_total = countin_duration + notes_duration
    self._target_duration_ms = max(self.MIN_RECORDING_DURATION_MS, calculated_total)

    self.progress_bar.setMaximum(self._target_duration_ms)

    # Start persistent output stream for metronome FIRST
    self.engine.start_output_stream()

    # Start hardware recording
    try:
        self.engine.start_recording()
    except Exception as e:
        from PyQt6.QtWidgets import QMessageBox
        QMessageBox.critical(self, "Error de Audio", 
                           f"No se pudo iniciar la grabación:\n{str(e)}\n\n"
                           "Verifica tu configuración de audio en Proyecto > Configuración.")
        self._reset_state()
        self._is_recording = False
        self.engine.stop_output_stream()
        return

    # Start timers
    self.metronome_timer.start(ms_per_beat)
    self.progress_timer.start(self.progress_interval)
    self.scope_timer.start(self.scope_interval)

    # Prepare WaveformScope for fixed timeline
    self.wave_scope.set_mode('fixed', self._target_duration_ms)
    self.wave_scope.setup_mora_regions(
        self._bpm, 
        len(self._current_line.segments), 
        self.COUNT_IN_BEATS
    )

    # Play first count-in click immediately
    self._play_count_in()

    self.recording_started.emit()
    logger.info(f"Recording sequence started: {self._current_line.raw_text}")

stop_recording()

Stop recording.

Source code in src/ui/recorder_widget.py
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
def stop_recording(self):
    """Stop recording."""
    self._is_recording = False
    self.metronome_timer.stop()
    self.progress_timer.stop()
    self.scope_timer.stop()

    # Stop hardware recording AND output stream
    self._last_audio = self.engine.stop_recording()
    self.engine.stop_output_stream()

    if self._last_audio is not None and len(self._last_audio) > 0:
        self.listen_btn.setEnabled(True)

    self._update_recording_status(False)
    logger.info("Recording stopped")

Editor Widget - Visual parameter editor wrapper.

Holds the WaveformCanvas and navigation controls.

EditorWidget

Bases: QWidget

Visual editor container.

Source code in src/ui/editor_widget.py
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
class EditorWidget(QWidget):
    """Visual editor container."""

    # Re-emit signals from canvas or nav
    marker_moved = pyqtSignal(str, float)
    play_requested = pyqtSignal()
    next_requested = pyqtSignal()
    prev_requested = pyqtSignal()

    # Request to set marker at current playhead or mouse
    marker_set_requested = pyqtSignal(str) # param_name

    def __init__(self, parent=None):
        super().__init__(parent)
        self._current_entry: Optional[OtoEntry] = None

        # Enable focus for keyboard shortcuts
        self.setFocusPolicy(Qt.FocusPolicy.StrongFocus)
        self._setup_ui()

    def _setup_ui(self):
        """Setup widget UI."""
        layout = QVBoxLayout(self)
        layout.setContentsMargins(10, 10, 10, 10)

        # Title/Header
        self.label_alias = QLabel("No Alias Selected")
        self.label_alias.setStyleSheet(f"""
            font-size: 18px; 
            font-weight: bold; 
            color: {COLORS['text_primary']};
        """)

        header_layout = QHBoxLayout()
        header_layout.addWidget(self.label_alias)
        header_layout.addStretch()

        self.search_bar = QLineEdit()
        self.search_bar.setPlaceholderText("🔍 Buscar grabación...")
        self.search_bar.setFixedWidth(200)
        self.search_bar.setStyleSheet(f"""
            background-color: #2D2D2D;
            color: white;
            border: 1px solid #3D3D3D;
            border-radius: 4px;
            padding: 4px;
        """)
        header_layout.addWidget(self.search_bar)

        layout.addLayout(header_layout)

        # Canvas
        self.canvas = WaveformCanvas()
        self.canvas.marker_moved.connect(self.marker_moved.emit)
        layout.addWidget(self.canvas, stretch=1)

        # Navigation
        nav_layout = QHBoxLayout()

        self.btn_play = QPushButton("▶ Play (Space)")
        self.btn_play.setShortcut("Space")
        self.btn_play.clicked.connect(self.play_requested.emit)
        nav_layout.addWidget(self.btn_play)

        self.btn_prev = QPushButton("◀ Prev")
        self.btn_prev.clicked.connect(self.prev_requested.emit)
        nav_layout.addWidget(self.btn_prev)

        self.btn_next = QPushButton("Next ▶")
        self.btn_next.clicked.connect(self.next_requested.emit)
        nav_layout.addWidget(self.btn_next)

        layout.addLayout(nav_layout)

    def set_entry(self, entry: OtoEntry):
        """Update view with new entry."""
        self._current_entry = entry
        if entry:
            self.label_alias.setText(f"Alias: {entry.alias}")
            self.canvas.set_markers(entry)
        else:
            self.label_alias.setText("No Selection")

    def set_audio_data(self, audio: np.ndarray, sr: int, spectrogram: np.ndarray = None, rms: np.ndarray = None):
        """Pass audio data to canvas."""
        self.canvas.set_audio_data(audio, sr, spectrogram, rms)

    def keyPressEvent(self, event):
        """Handle global editor shortcuts."""
        key = event.key()

        if key == Qt.Key.Key_F1:
            self.marker_set_requested.emit('left_blank')
        elif key == Qt.Key.Key_F2:
            self.marker_set_requested.emit('overlap')
        elif key == Qt.Key.Key_F3:
            self.marker_set_requested.emit('preutter')
        elif key == Qt.Key.Key_F4:
            self.marker_set_requested.emit('fixed')
        elif key == Qt.Key.Key_F5:
            self.marker_set_requested.emit('right_blank')
        else:
            super().keyPressEvent(event)

keyPressEvent(event)

Handle global editor shortcuts.

Source code in src/ui/editor_widget.py
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
def keyPressEvent(self, event):
    """Handle global editor shortcuts."""
    key = event.key()

    if key == Qt.Key.Key_F1:
        self.marker_set_requested.emit('left_blank')
    elif key == Qt.Key.Key_F2:
        self.marker_set_requested.emit('overlap')
    elif key == Qt.Key.Key_F3:
        self.marker_set_requested.emit('preutter')
    elif key == Qt.Key.Key_F4:
        self.marker_set_requested.emit('fixed')
    elif key == Qt.Key.Key_F5:
        self.marker_set_requested.emit('right_blank')
    else:
        super().keyPressEvent(event)

set_audio_data(audio, sr, spectrogram=None, rms=None)

Pass audio data to canvas.

Source code in src/ui/editor_widget.py
105
106
107
def set_audio_data(self, audio: np.ndarray, sr: int, spectrogram: np.ndarray = None, rms: np.ndarray = None):
    """Pass audio data to canvas."""
    self.canvas.set_audio_data(audio, sr, spectrogram, rms)

set_entry(entry)

Update view with new entry.

Source code in src/ui/editor_widget.py
 96
 97
 98
 99
100
101
102
103
def set_entry(self, entry: OtoEntry):
    """Update view with new entry."""
    self._current_entry = entry
    if entry:
        self.label_alias.setText(f"Alias: {entry.alias}")
        self.canvas.set_markers(entry)
    else:
        self.label_alias.setText("No Selection")