diff --git a/core/ffmpeg.py b/core/ffmpeg.py index 4827b31..ae5995d 100644 --- a/core/ffmpeg.py +++ b/core/ffmpeg.py @@ -173,6 +173,36 @@ def build_audio_extract_command(input_path: str, start: float, sequence_dir: str ] +# Audio codec chosen per output extension for the manual "Extract audio area" +# tool. Empty list -> let ffmpeg pick a default encoder from the extension. +_AUDIO_CODEC_BY_EXT: dict[str, list[str]] = { + ".wav": ["-c:a", "pcm_s16le"], + ".flac": ["-c:a", "flac"], + ".mp3": ["-c:a", "libmp3lame", "-q:a", "2"], + ".m4a": ["-c:a", "aac", "-b:a", "256k"], + ".aac": ["-c:a", "aac", "-b:a", "256k"], + ".ogg": ["-c:a", "libvorbis", "-q:a", "5"], + ".opus": ["-c:a", "libopus", "-b:a", "192k"], +} + + +def build_audio_clip_command(input_path: str, start: float, duration: float, + out_path: str) -> list[str]: + """ffmpeg command to extract exactly *duration* seconds of audio starting + at *start*, re-encoded per *out_path*'s extension (wav/mp3/flac/…).""" + ext = os.path.splitext(out_path)[1].lower() + codec = _AUDIO_CODEC_BY_EXT.get(ext, []) + return [ + _bin("ffmpeg"), "-y", + "-ss", str(start), + "-i", input_path, + "-t", str(duration), + "-vn", + *codec, + out_path, + ] + + def detect_hw_encoders() -> list[str]: """Probe ffmpeg for available H.264 hardware encoders. diff --git a/main.py b/main.py index 2794a8c..79af109 100755 --- a/main.py +++ b/main.py @@ -35,7 +35,8 @@ import mpv from core.paths import _bin, _log, build_export_path, build_sequence_dir, format_time from core.ffmpeg import ( _RATIOS, resolve_keyframe, apply_keyframes_to_jobs, - build_ffmpeg_command, build_audio_extract_command, detect_hw_encoders, + build_ffmpeg_command, build_audio_extract_command, build_audio_clip_command, + detect_hw_encoders, ) from core.db import ProcessedDB from core.annotations import remove_clip_annotation, upsert_clip_annotation @@ -1896,6 +1897,9 @@ class TimelineWidget(QWidget): self._scan_regions: list[tuple[float, float, float, float, float]] = [] self._scan_neg_times: set[float] = set() self._active_scan_region: tuple[float, float] | None = None + # Manual "Extract audio area" band (start, end) — drawn as a distinct + # teal dashed region so it reads apart from the blue clip selection. + self._audio_region: tuple[float, float] | None = None # View window for zoom/pan. When _view_span <= 0 the full duration is shown. self._view_start: float = 0.0 @@ -2058,6 +2062,17 @@ class TimelineWidget(QWidget): self._active_scan_region = None self.update() + def set_audio_region(self, start: float, end: float) -> None: + region = (start, end) + if region != self._audio_region: + self._audio_region = region + self.update() + + def clear_audio_region(self) -> None: + if self._audio_region is not None: + self._audio_region = None + self.update() + def set_play_position(self, t: float | None) -> None: # In lock mode, ignore mpv position updates while the user is dragging # — the async seek hasn't caught up yet, so mpv reports stale values. @@ -2286,6 +2301,18 @@ class TimelineWidget(QWidget): p.drawLine(x_start, rh, x_start, h) p.drawLine(x_end, rh, x_end, h) + # ── audio-extract area (exact length from the playhead) ─────────── + if (not self._scan_mode and self._audio_region is not None + and self._duration > 0): + a0, a1 = self._audio_region + ax1 = int(self._time_to_x(a0)) + ax2 = int(self._time_to_x(min(a1, self._duration))) + aw = max(ax2 - ax1, 1) + p.fillRect(ax1, rh, aw, th, QColor(0, 200, 180, 45)) + p.setBrush(Qt.BrushStyle.NoBrush) + p.setPen(QPen(QColor(0, 220, 190), 1, Qt.PenStyle.DashLine)) + p.drawRect(ax1, rh + 1, aw, th - 2) + # ── ghost of the previous cursor position (undo-by-eye) ────────── if (not self._scan_mode and self._ghost_cursor is not None and abs(self._ghost_cursor - self._cursor) > 0.05): @@ -4407,6 +4434,28 @@ class MainWindow(QMainWindow): transport_row.addWidget(self._btn_export) transport_row.addWidget(self._btn_cancel) transport_row.addWidget(self._btn_delete) + + # Extract audio area — an exact-length audio slice from the playhead, + # saved via a Save As dialog (format follows the chosen extension). + transport_row.addSpacing(12) + self._spn_audio_len = QDoubleSpinBox() + self._spn_audio_len.setRange(0.10, 120.0) + self._spn_audio_len.setDecimals(2) + self._spn_audio_len.setSingleStep(0.10) + self._spn_audio_len.setSuffix(" s") + self._spn_audio_len.setFixedWidth(78) + self._spn_audio_len.setToolTip("Audio area length, measured from the playhead") + self._spn_audio_len.setValue( + float(self._settings.value("audio_extract_len", 3.0))) + self._spn_audio_len.valueChanged.connect(self._on_audio_len_changed) + self._btn_extract_audio = QPushButton("♪ Extract audio") + self._btn_extract_audio.setFocusPolicy(Qt.FocusPolicy.NoFocus) + self._btn_extract_audio.setToolTip( + "Extract this exact length of audio from the playhead and save it") + self._btn_extract_audio.setEnabled(False) + self._btn_extract_audio.clicked.connect(self._on_extract_audio) + transport_row.addWidget(self._spn_audio_len) + transport_row.addWidget(self._btn_extract_audio) self._transport_row = transport_row # Row 1b — subcategory (subprofile) export buttons live on their own @@ -5789,6 +5838,8 @@ class MainWindow(QMainWindow): self._btn_play.setEnabled(True) self._btn_pause.setEnabled(True) self._btn_export.setEnabled(True) + self._btn_extract_audio.setEnabled(True) + self._update_audio_region() self._set_subprofile_btns_enabled(True) # Reset stale state from previous file self._overwrite_path = "" @@ -6321,6 +6372,7 @@ class MainWindow(QMainWindow): self._cursor = t dur = self._mpv.get_duration() self._lbl_time.setText(f"{format_time(t)} / {format_time(dur)}") + self._update_audio_region() self._preview_timer.start() if self._timeline._scan_mode: self._scan_panel.highlight_time(t) @@ -6330,6 +6382,65 @@ class MainWindow(QMainWindow): else: self._mpv.seek(t) + def _on_audio_len_changed(self, value: float) -> None: + self._settings.setValue("audio_extract_len", value) + self._update_audio_region() + + def _update_audio_region(self) -> None: + """Keep the timeline's audio-area band in sync with the playhead and + the audio-length control.""" + if not self._file_path: + self._timeline.clear_audio_region() + return + start = self._cursor + self._timeline.set_audio_region(start, start + self._spn_audio_len.value()) + + def _on_extract_audio(self) -> None: + """Extract an exact-length audio slice starting at the playhead and + prompt for where to save it (format follows the chosen extension).""" + if not self._file_path: + self._show_status("Load a video first", 3000) + return + start = self._cursor + dur = self._spn_audio_len.value() + if start + dur > self._timeline._duration + 0.05: + dur = max(0.05, self._timeline._duration - start) + stem = os.path.splitext(os.path.basename(self._file_path))[0] + default_name = f"{stem}_{start:.2f}-{start + dur:.2f}s.wav" + default_dir = (self._settings.value("audio_extract_dir", "") + or self._tab_export_folder() + or os.path.dirname(self._file_path)) + path, _sel = QFileDialog.getSaveFileName( + self, "Save audio clip", os.path.join(default_dir, default_name), + "WAV (*.wav);;MP3 (*.mp3);;FLAC (*.flac);;All files (*)") + if not path: + return + if not os.path.splitext(path)[1]: + path += ".wav" + os.makedirs(os.path.dirname(path) or ".", exist_ok=True) + cmd = build_audio_clip_command(self._file_path, start, dur, path) + self._btn_extract_audio.setEnabled(False) + QApplication.setOverrideCursor(Qt.CursorShape.WaitCursor) + self._show_status(f"Extracting {dur:.2f}s of audio…") + try: + proc = subprocess.run(cmd, capture_output=True, text=True, timeout=120) + except Exception as e: + proc = None + err = str(e) + finally: + QApplication.restoreOverrideCursor() + self._btn_extract_audio.setEnabled(True) + if proc is not None and proc.returncode == 0 and os.path.exists(path): + self._settings.setValue("audio_extract_dir", os.path.dirname(path)) + self._show_status(f"Saved audio: {os.path.basename(path)}", 5000) + _log(f"Audio extracted: {path} ({dur:.2f}s @ {start:.2f}s)") + else: + err = (proc.stderr.strip().splitlines()[-1] if proc and proc.stderr + else (err if proc is None else "ffmpeg failed")) + self._show_status("Audio extract failed", 5000) + QMessageBox.warning(self, "Audio extract failed", + f"Could not extract audio:\n\n{err}") + def _toggle_play(self): if not self._file_path: return diff --git a/tests/test_ui_structure.py b/tests/test_ui_structure.py index 8f83e61..8431856 100644 --- a/tests/test_ui_structure.py +++ b/tests/test_ui_structure.py @@ -243,3 +243,28 @@ def test_subprofile_button_visibility_exact_match(win): win._apply_subcat_visibility() assert btns["blowjob"].isHidden() assert not btns["clap"].isHidden() + + +def test_extract_audio_controls_exist(win): + from PyQt6.QtWidgets import QPushButton, QDoubleSpinBox + assert isinstance(win._btn_extract_audio, QPushButton) + assert isinstance(win._spn_audio_len, QDoubleSpinBox) + # Disabled until a file is loaded. + assert not win._btn_extract_audio.isEnabled() + + +def test_audio_region_tracks_cursor_and_length(win): + # The teal audio band spans [cursor, cursor + length]; changing the length + # or moving the cursor moves the band. Fake a loaded file so the guard in + # _update_audio_region passes. + win._file_path = "/x/video.mp4" + win._cursor = 10.0 + win._spn_audio_len.setValue(4.0) # fires _on_audio_len_changed + assert win._timeline._audio_region == (10.0, 14.0) + win._cursor = 20.0 + win._update_audio_region() + assert win._timeline._audio_region == (20.0, 24.0) + # No file -> band cleared. + win._file_path = "" + win._update_audio_region() + assert win._timeline._audio_region is None diff --git a/tests/test_utils.py b/tests/test_utils.py index b451151..48b3c7b 100644 --- a/tests/test_utils.py +++ b/tests/test_utils.py @@ -1,5 +1,6 @@ import tempfile, os, json from main import build_export_path, format_time, build_ffmpeg_command, build_sequence_dir, build_audio_extract_command, resolve_keyframe, apply_keyframes_to_jobs +from core.ffmpeg import build_audio_clip_command from core.annotations import build_annotation_json_path, upsert_clip_annotation from main import ProcessedDB @@ -54,6 +55,27 @@ def test_ffmpeg_command_with_resize(): assert cmd[-1] == "/out/clip_001.mp4" +def test_audio_clip_command_exact_length(): + cmd = build_audio_clip_command("/in/video.mp4", 12.5, 3.2, "/out/clip.wav") + assert cmd[0] == "ffmpeg" + # fast seek before input, exact duration, no video + assert cmd[cmd.index("-ss") + 1] == "12.5" + assert cmd[cmd.index("-t") + 1] == "3.2" + assert cmd.index("-ss") < cmd.index("-i") + assert "-vn" in cmd + assert cmd[-1] == "/out/clip.wav" + +def test_audio_clip_command_codec_by_extension(): + assert "pcm_s16le" in build_audio_clip_command("/in.mp4", 0, 1, "/o/a.wav") + assert "libmp3lame" in build_audio_clip_command("/in.mp4", 0, 1, "/o/a.mp3") + assert "flac" in build_audio_clip_command("/in.mp4", 0, 1, "/o/a.flac") + # Unknown extension -> no explicit -c:a, let ffmpeg pick from the container. + assert "-c:a" not in build_audio_clip_command("/in.mp4", 0, 1, "/o/a.xyz") + +def test_audio_clip_command_extension_case_insensitive(): + assert "flac" in build_audio_clip_command("/in.mp4", 0, 1, "/o/A.FLAC") + + # --- ProcessedDB --- def test_db_add_and_get_markers():