davinci-resolve-mcp 2.50.0 → 2.52.0

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.
package/CHANGELOG.md CHANGED
@@ -2,6 +2,45 @@
2
2
 
3
3
  Release history for the DaVinci Resolve MCP Server. The latest release is summarized in the root README; older entries live here to keep the README focused.
4
4
 
5
+ ## What's New in v2.52.0
6
+
7
+ `edit_engine` tighten now carries audio. Previously `execute_tighten`
8
+ assembled a video-only variant — a speech-driven dead-air cut came out
9
+ silent, with nothing in the preview or readback to warn you (issue #67).
10
+
11
+ - **Fixed** `plan_tighten` mirrors every kept video range onto its linked
12
+ audio track(s) with identical source frames, so the assembled variant
13
+ stays frame-locked and audible. Audio targets the item's detected linked
14
+ audio tracks (same `GetLinkedItems` matching `execute_swap` uses), falling
15
+ back to audio track 1 where a single linked A/V clip's audio lives.
16
+ - **Added** `include_audio` parameter to `plan_tighten` (default `true`).
17
+ Pass `include_audio=false` for the prior video-only assembly.
18
+ - **Added** an `audio_accounting` block to the `execute_tighten` confirm
19
+ preview and readback (planned vs. actual audio/video item counts), so a
20
+ silent variant can no longer ship unnoticed. Old video-only plans
21
+ re-executed against this build still work and are now loudly flagged as
22
+ silent.
23
+
24
+ ## What's New in v2.51.0
25
+
26
+ CLAP audio embeddings — the final phase of the post-program improvements.
27
+ Detection-based like every other backend (never auto-installed); local
28
+ compute, so nothing touches the caps ledger.
29
+
30
+ - **Added** audio embedding backend detection: CLAP via `transformers`
31
+ (laion/clap-htsat-unfused, preferred) or the `laion_clap` package, plus
32
+ torch + ffmpeg. `capabilities` gains an `audio` block with install
33
+ guidance and a `clap_audio` Tools-page entry.
34
+ - **Added** `build_embeddings(kinds=["audio"])`: one CLAP window per shot
35
+ (center-cropped to ~10 s, piped from the source media as raw PCM —
36
+ read-only on source media, no temp files) plus a clip-level mean vector,
37
+ stored as `embedding_kind="audio"` rows. Idempotent via content hashes;
38
+ offline media is reported in `skipped_missing_media`; a missing backend
39
+ is a graceful skip with install guidance.
40
+ - **Added** `find_similar(kind="audio")`: shot/clip queries over the audio
41
+ vectors, and free-text queries ("engine revving") via the CLAP text
42
+ encoder.
43
+
5
44
  ## What's New in v2.50.0
6
45
 
7
46
  The last JSON-fed readers now source from the DB-canonical analysis store
package/README.md CHANGED
@@ -1,6 +1,6 @@
1
1
  # DaVinci Resolve MCP Server
2
2
 
3
- [![Version](https://img.shields.io/badge/version-2.50.0-blue.svg)](https://github.com/samuelgursky/davinci-resolve-mcp/releases)
3
+ [![Version](https://img.shields.io/badge/version-2.52.0-blue.svg)](https://github.com/samuelgursky/davinci-resolve-mcp/releases)
4
4
  [![npm](https://img.shields.io/npm/v/davinci-resolve-mcp.svg?label=npm&color=CB3837)](https://www.npmjs.com/package/davinci-resolve-mcp)
5
5
  [![API Coverage](https://img.shields.io/badge/API%20Coverage-100%25-brightgreen.svg)](docs/reference/api-coverage.md)
6
6
  [![Tools](https://img.shields.io/badge/MCP%20Tools-34%20(341%20full)-blue.svg)](#server-modes)
package/docs/SKILL.md CHANGED
@@ -547,15 +547,23 @@ no editorial suggestions): `same_setup_as` / `alt_take_of` (symmetric) and
547
547
  vendor tokens, so nothing here touches the caps ledger. Backends are
548
548
  detected, never installed (capabilities lists them with install guidance):
549
549
  text = ollama serving `nomic-embed-text` or sentence-transformers; visual =
550
- open_clip (ViT-B-32, needs torch).
551
- - `build_embeddings(kinds=["text","visual"]?, clip_id?)` idempotent;
552
- embeds clip summaries, shot descriptions (+ deep field groups), transcript
553
- segments, and sampled frames (per-shot visual vector = mean of its
554
- frames'). Only re-embeds entities whose content changed.
555
- - `find_similar(text=… | clip_id=… | clip_id+shot_index, kind="text"|"visual",
556
- entity_types?, limit?)` brute-force cosine over the project's vectors.
557
- Free-text visual queries use the CLIP text encoder ("cracked windshield"
558
- finds the frame). Results carry scores plus clip/shot/segment context.
550
+ open_clip (ViT-B-32, needs torch); audio (v2.51.0+) = CLAP via
551
+ `transformers` (laion/clap-htsat-unfused, preferred) or the `laion_clap`
552
+ package needs torch + ffmpeg.
553
+ - `build_embeddings(kinds=["text","visual","audio"]?, clip_id?)`
554
+ idempotent; embeds clip summaries, shot descriptions (+ deep field
555
+ groups), transcript segments, and sampled frames (per-shot visual vector
556
+ = mean of its frames'). `kinds=["audio"]` embeds one CLAP window per shot
557
+ (center-cropped to ~10s, piped from the source media as raw PCM —
558
+ read-only, no temp files) plus a clip-level mean vector; clips whose
559
+ media is offline are reported in `skipped_missing_media`. Only re-embeds
560
+ entities whose content changed.
561
+ - `find_similar(text=… | clip_id=… | clip_id+shot_index,
562
+ kind="text"|"visual"|"audio", entity_types?, limit?)` — brute-force
563
+ cosine over the project's vectors. Free-text visual queries use the CLIP
564
+ text encoder ("cracked windshield" finds the frame); free-text audio
565
+ queries use the CLAP text encoder ("engine revving" finds the shot).
566
+ Results carry scores plus clip/shot/segment context.
559
567
  The panel search box gains a `Semantic` toggle when a text backend is
560
568
  detected. Vectors live in the per-project DB (schema v10).
561
569
 
@@ -714,11 +722,15 @@ clip-count readback plus `brain_edits` rationale rows.
714
722
  `execute_selects(plan_id)` creates a NEW selects timeline from the plan's
715
723
  per-shot source ranges — additive; nothing existing is touched.
716
724
  - `plan_tighten(timeline_name?, target_ratio?, min_pause_seconds?,
717
- handle_seconds?)` — dead-air lifts from transcript-gap evidence for each
718
- timeline item (items without transcripts are reported in `skipped`, never
719
- silently trimmed). `execute_tighten(plan_id)` assembles a tightened
720
- VARIANT timeline from the plan's keep ranges — true partial trims; the
721
- original timeline is never mutated.
725
+ handle_seconds?, include_audio?)` — dead-air lifts from transcript-gap
726
+ evidence for each timeline item (items without transcripts are reported in
727
+ `skipped`, never silently trimmed). `execute_tighten(plan_id)` assembles a
728
+ tightened VARIANT timeline from the plan's keep ranges — true partial
729
+ trims; the original timeline is never mutated. v2.52.0+: kept ranges mirror
730
+ onto each item's linked audio track(s) so the variant is audible (a
731
+ speech-driven cut was previously silent — #67); pass
732
+ `include_audio=false` for a video-only assembly, and the
733
+ `execute_tighten` readback carries an `audio_accounting` block.
722
734
  - `plan_swap(timeline_start_frame | item_name, kind="visual"|"text",
723
735
  limit?)` — alternates for one timeline item via the similarity index,
724
736
  filtered to shots long enough to fill the slot exactly.
package/install.py CHANGED
@@ -35,7 +35,7 @@ from src.utils.update_check import (
35
35
 
36
36
  # ─── Version ──────────────────────────────────────────────────────────────────
37
37
 
38
- VERSION = "2.50.0"
38
+ VERSION = "2.52.0"
39
39
  # Only hard floor: mcp[cli] requires Python 3.10+. There is no upper bound —
40
40
  # Resolve's scripting bridge loads into newer interpreters on recent builds
41
41
  # (Python 3.14 verified against Resolve Studio 20.3.2). Older Resolve builds
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "davinci-resolve-mcp",
3
- "version": "2.50.0",
3
+ "version": "2.52.0",
4
4
  "description": "NPM bootstrapper for the DaVinci Resolve MCP Server.",
5
5
  "license": "MIT",
6
6
  "author": "Samuel Gursky <samgursky@gmail.com>",
@@ -80,7 +80,7 @@ if not logging.getLogger().handlers:
80
80
  handlers=[logging.StreamHandler()],
81
81
  )
82
82
 
83
- VERSION = "2.50.0"
83
+ VERSION = "2.52.0"
84
84
  logger = logging.getLogger("davinci-resolve-mcp")
85
85
  logger.info(f"Starting DaVinci Resolve MCP Server v{VERSION}")
86
86
  logger.info(f"Detected platform: {get_platform()}")
package/src/server.py CHANGED
@@ -11,7 +11,7 @@ Usage:
11
11
  python src/server.py --full # Start the 341-tool granular server instead
12
12
  """
13
13
 
14
- VERSION = "2.50.0"
14
+ VERSION = "2.52.0"
15
15
 
16
16
  import base64
17
17
  import os
@@ -16592,6 +16592,15 @@ def _edit_engine_collect_items(tl, *, track_index: Optional[int] = None) -> List
16592
16592
  pass
16593
16593
  except Exception:
16594
16594
  pass
16595
+ # Linked audio track indices so the planner can mirror kept ranges
16596
+ # onto sound (see #67 — tighten variants were coming out silent).
16597
+ try:
16598
+ audio_indices, _audio_note = _edit_engine_linked_audio_tracks(
16599
+ tl, item, row["timeline_start_frame"], row["timeline_end_frame"]
16600
+ )
16601
+ row["audio_track_indices"] = audio_indices
16602
+ except Exception:
16603
+ row["audio_track_indices"] = []
16595
16604
  rows.append(row)
16596
16605
  return rows
16597
16606
 
@@ -16732,10 +16741,13 @@ def edit_engine(action: str, params: Optional[Dict[str, Any]] = None) -> Dict[st
16732
16741
  - execute_selects(plan_id, confirm_token?) — creates a NEW selects timeline
16733
16742
  from the plan's clip in/out ranges. Nothing existing is touched.
16734
16743
  - plan_tighten(timeline_name?, target_ratio?, min_pause_seconds?,
16735
- handle_seconds?) — dead-air lifts from transcript-gap evidence for the
16736
- current (or named) timeline.
16737
- - execute_tighten(plan_id, confirm_token?) duplicates the timeline and
16738
- applies the lifts (ripple) to the DUPLICATE, never the original.
16744
+ handle_seconds?, include_audio?) — dead-air lifts from transcript-gap
16745
+ evidence for the current (or named) timeline. Kept ranges mirror onto the
16746
+ items' linked audio tracks by default so the variant is audible; pass
16747
+ include_audio=false for a video-only (silent) assembly.
16748
+ - execute_tighten(plan_id, confirm_token?) — assembles a tightened VARIANT
16749
+ timeline from the plan's keep ranges (video + mirrored audio), never
16750
+ mutating the original. Readback includes an audio_accounting block.
16739
16751
  - plan_swap(track_index?, timeline_start_frame | item_name, kind?, limit?)
16740
16752
  — alternates for one timeline item via the similarity index.
16741
16753
  - execute_swap(plan_id, alternate_index, confirm_token?) — replaces the
@@ -16807,6 +16819,7 @@ def edit_engine(action: str, params: Optional[Dict[str, Any]] = None) -> Dict[st
16807
16819
  target_ratio=p.get("target_ratio") or p.get("targetRatio"),
16808
16820
  min_pause_seconds=float(p.get("min_pause_seconds") or p.get("minPauseSeconds") or _edit_engine_mod.DEFAULT_MIN_PAUSE_SECONDS),
16809
16821
  handle_seconds=float(p.get("handle_seconds") or p.get("handleSeconds") or _edit_engine_mod.DEFAULT_HANDLE_SECONDS),
16822
+ include_audio=str(p.get("include_audio", p.get("includeAudio", True))).strip().lower() not in {"false", "0", "no", "none", "off"},
16810
16823
  )
16811
16824
 
16812
16825
  if action == "plan_swap":
@@ -16951,6 +16964,8 @@ def edit_engine(action: str, params: Optional[Dict[str, Any]] = None) -> Dict[st
16951
16964
  keep_ranges = plan.get("keep_ranges") or []
16952
16965
  if not keep_ranges:
16953
16966
  return _err("plan has no keep_ranges — re-plan with this version")
16967
+ audio_keep_ranges = sum(1 for r in keep_ranges if str(r.get("track_type", "video")).lower() == "audio")
16968
+ video_keep_ranges = len(keep_ranges) - audio_keep_ranges
16954
16969
  if "confirm_token" not in p and "confirmToken" not in p and _confirm_token_required():
16955
16970
  return _issue_confirm_token(
16956
16971
  action="edit_engine.execute_tighten", params=p,
@@ -16963,6 +16978,13 @@ def edit_engine(action: str, params: Optional[Dict[str, Any]] = None) -> Dict[st
16963
16978
  "timeline_name": plan.get("timeline_name"),
16964
16979
  "lift_count": len(lifts),
16965
16980
  "keep_range_count": len(keep_ranges),
16981
+ "video_keep_range_count": video_keep_ranges,
16982
+ "audio_keep_range_count": audio_keep_ranges,
16983
+ "audio_note": (
16984
+ f"Variant carries audio ({audio_keep_ranges} audio ranges mirror the video cuts)."
16985
+ if audio_keep_ranges
16986
+ else "WARNING: variant is VIDEO-ONLY (silent) — plan has no audio ranges."
16987
+ ),
16966
16988
  "estimated_removed_seconds": sum(l.get("duration_seconds") or 0 for l in lifts),
16967
16989
  },
16968
16990
  )
@@ -17039,6 +17061,23 @@ def edit_engine(action: str, params: Optional[Dict[str, Any]] = None) -> Dict[st
17039
17061
  else None
17040
17062
  ),
17041
17063
  "structural_diff": structural_diff,
17064
+ "audio_accounting": {
17065
+ "planned_audio_ranges": audio_keep_ranges,
17066
+ "planned_video_ranges": video_keep_ranges,
17067
+ "variant_audio_items": sum(
17068
+ 1 for it in (variant.get("items") or [])
17069
+ if (it.get("range") or {}).get("media_type") == 2
17070
+ ),
17071
+ "variant_video_items": sum(
17072
+ 1 for it in (variant.get("items") or [])
17073
+ if (it.get("range") or {}).get("media_type") == 1
17074
+ ),
17075
+ "note": (
17076
+ "Variant carries audio mirrored from the video cuts."
17077
+ if audio_keep_ranges
17078
+ else "Variant is VIDEO-ONLY (silent) — re-plan with include_audio=True for sound."
17079
+ ),
17080
+ },
17042
17081
  },
17043
17082
  "plan_id": plan.get("plan_id"),
17044
17083
  }
@@ -348,13 +348,23 @@ def plan_tighten(
348
348
  target_ratio: Optional[float] = None,
349
349
  min_pause_seconds: float = DEFAULT_MIN_PAUSE_SECONDS,
350
350
  handle_seconds: float = DEFAULT_HANDLE_SECONDS,
351
+ include_audio: bool = True,
351
352
  ) -> Dict[str, Any]:
352
353
  """Propose dead-air lifts for a timeline.
353
354
 
354
355
  `items` rows come from the server (Resolve read): each needs
355
356
  {timeline_start_frame, timeline_end_frame, source_start_frame,
356
- media_ref (clip id / path / hash), item_name?}. Lifts are returned in
357
- timeline frames, latest-first ready.
357
+ media_ref (clip id / path / hash), item_name?}. Optionally each row may
358
+ carry {audio_track_indices: [int, ...]} naming the audio tracks that hold
359
+ the item's linked audio. Lifts are returned in timeline frames, latest-first
360
+ ready.
361
+
362
+ When ``include_audio`` (the default), each kept video range is mirrored onto
363
+ matching audio range(s) so the assembled variant carries sound — a
364
+ speech-driven cut would otherwise come out silent (see #67). Audio is
365
+ mirrored to the item's detected ``audio_track_indices``; absent that, it
366
+ falls back to audio track 1, which is where a single linked A/V clip's audio
367
+ lives.
358
368
  """
359
369
  if not items:
360
370
  return {"success": False, "error": "No timeline items supplied"}
@@ -475,6 +485,11 @@ def plan_tighten(
475
485
  cursor = max(cursor, lift_end_sec)
476
486
  if spec["src_end_sec"] - cursor > 0.05:
477
487
  segments.append((cursor, spec["src_end_sec"]))
488
+ audio_indices: List[int] = []
489
+ if include_audio:
490
+ audio_indices = [int(i) for i in (item.get("audio_track_indices") or []) if int(i) > 0]
491
+ if not audio_indices:
492
+ audio_indices = [1]
478
493
  for seg_start, seg_end in segments:
479
494
  start_frame = int(round(seg_start * clip_fps))
480
495
  end_frame = max(start_frame + 1, int(round(seg_end * clip_fps)) - 1)
@@ -485,23 +500,40 @@ def plan_tighten(
485
500
  "track_type": "video",
486
501
  "track_index": int(item.get("track_index") or 1),
487
502
  })
503
+ # Mirror each kept video range onto its linked audio track(s) with
504
+ # identical source frames so the variant stays frame-locked and
505
+ # audible. mediaType 2 pulls the same media-pool item's audio.
506
+ for audio_index in audio_indices:
507
+ keep_ranges.append({
508
+ "clip_id": spec["resolve_clip_id"],
509
+ "start_frame": start_frame,
510
+ "end_frame": end_frame,
511
+ "track_type": "audio",
512
+ "media_type": 2,
513
+ "track_index": audio_index,
514
+ })
488
515
 
489
516
  removed_frames = sum(l["timeline_end_frame"] - l["timeline_start_frame"] for l in lifts)
517
+ audio_keep_range_count = sum(1 for r in keep_ranges if r.get("track_type") == "audio")
518
+ video_keep_range_count = len(keep_ranges) - audio_keep_range_count
490
519
  plan = save_plan(project_root, {
491
520
  "kind": "tighten",
492
521
  "timeline_name": timeline_name,
493
522
  "timeline_fps": fps,
494
523
  "lifts": lifts,
495
524
  "keep_ranges": keep_ranges,
525
+ "include_audio": bool(include_audio),
496
526
  "skipped": skipped,
497
527
  "summary": (
498
528
  f"{len(lifts)} dead-air lifts, ~{round(removed_frames / fps, 1)}s removed "
499
- f"from '{timeline_name}' (assembled as a tightened variant)"
529
+ f"from '{timeline_name}' (assembled as a tightened variant"
530
+ f"{', video + audio' if include_audio else ', video only'})"
500
531
  ),
501
532
  "settings": {
502
533
  "target_ratio": target_ratio,
503
534
  "min_pause_seconds": min_pause_seconds,
504
535
  "handle_seconds": handle_seconds,
536
+ "include_audio": bool(include_audio),
505
537
  },
506
538
  })
507
539
  return {
@@ -514,11 +546,20 @@ def plan_tighten(
514
546
  "estimated_removed_seconds": round(removed_frames / fps, 2),
515
547
  "lifts": lifts,
516
548
  "keep_range_count": len(keep_ranges),
549
+ "video_keep_range_count": video_keep_range_count,
550
+ "audio_keep_range_count": audio_keep_range_count,
551
+ "include_audio": bool(include_audio),
517
552
  "skipped": skipped,
518
553
  "note": (
519
554
  "Dry-run plan. Execute with edit_engine(action='execute_tighten', "
520
555
  "params={plan_id}) — a tightened VARIANT timeline is assembled from "
521
- "the keep ranges; the original timeline is never mutated."
556
+ "the keep ranges; the original timeline is never mutated. "
557
+ + (
558
+ f"Audio is mirrored onto matching tracks ({audio_keep_range_count} "
559
+ "audio ranges) so the variant is audible."
560
+ if include_audio
561
+ else "include_audio=False: the variant will be VIDEO-ONLY (silent)."
562
+ )
522
563
  ),
523
564
  }
524
565
 
@@ -1,12 +1,15 @@
1
1
  """Embeddings + similarity search (Phase C of the analysis program).
2
2
 
3
3
  Text vectors over clip/shot summaries and transcript segments, CLIP image
4
- vectors over sampled frames. Backends are detected, never installed (the
5
- whisper pattern):
4
+ vectors over sampled frames, CLAP audio vectors over per-shot audio windows.
5
+ Backends are detected, never installed (the whisper pattern):
6
6
 
7
7
  - text: ollama serving ``nomic-embed-text`` (preferred — works on Apple
8
8
  Silicon via Metal), or ``sentence_transformers`` when installed.
9
9
  - visual: ``open_clip_torch`` (ViT-B-32) when installed alongside torch.
10
+ - audio: ``transformers`` ClapModel (laion/clap-htsat-unfused; preferred) or
11
+ ``laion_clap`` when installed alongside torch; needs ffmpeg. Audio windows
12
+ are piped from the source media as raw PCM — read-only, no temp files.
10
13
 
11
14
  Vectors live in the per-project DB (schema v10 ``embeddings`` table) as
12
15
  float32 BLOBs, one row per (entity, kind, model). Similarity is brute-force
@@ -37,12 +40,17 @@ OLLAMA_TEXT_MODEL = os.environ.get("DAVINCI_RESOLVE_MCP_EMBED_MODEL", "nomic-emb
37
40
  SENTENCE_TRANSFORMERS_MODEL = "all-MiniLM-L6-v2"
38
41
  OPEN_CLIP_MODEL = ("ViT-B-32", "laion2b_s34b_b79k")
39
42
 
43
+ CLAP_HF_MODEL = "laion/clap-htsat-unfused"
44
+ CLAP_SAMPLE_RATE = 48000
45
+ AUDIO_WINDOW_SECONDS = 10.0 # CLAP works on ~10s windows; longer shots are center-cropped
46
+
40
47
  _PROBE_TIMEOUT_SECONDS = 2.0
41
48
  _EMBED_TIMEOUT_SECONDS = 120.0
42
49
 
43
50
  # Lazy singletons for heavyweight local models.
44
51
  _ST_MODEL = None
45
52
  _CLIP_STATE: Optional[Tuple[Any, Any, Any]] = None # (model, preprocess, torch)
53
+ _CLAP_STATE: Optional[Tuple[str, Any]] = None # (backend, state)
46
54
 
47
55
 
48
56
  def _now() -> str:
@@ -140,6 +148,26 @@ def detect_embedding_capabilities() -> Dict[str, Any]:
140
148
  else "pip install torch open_clip_torch"
141
149
  )
142
150
 
151
+ transformers_available = importlib.util.find_spec("transformers") is not None
152
+ laion_clap_available = importlib.util.find_spec("laion_clap") is not None
153
+ ffmpeg_available = bool(shutil.which("ffmpeg"))
154
+ audio_backends: List[str] = []
155
+ if torch_available and ffmpeg_available:
156
+ if transformers_available:
157
+ audio_backends.append("transformers_clap")
158
+ if laion_clap_available:
159
+ audio_backends.append("laion_clap")
160
+ if not audio_backends:
161
+ if not ffmpeg_available:
162
+ guidance["audio"] = "Install ffmpeg (audio windows are extracted with it)."
163
+ else:
164
+ guidance["audio"] = (
165
+ "pip install transformers (CLAP via laion/clap-htsat-unfused), "
166
+ "or pip install laion_clap"
167
+ if torch_available
168
+ else "pip install torch transformers"
169
+ )
170
+
143
171
  return {
144
172
  "success": True,
145
173
  "no_auto_install": True,
@@ -156,6 +184,12 @@ def detect_embedding_capabilities() -> Dict[str, Any]:
156
184
  "backends": ["open_clip"] if visual_available else [],
157
185
  "model": "-".join(OPEN_CLIP_MODEL) if visual_available else None,
158
186
  },
187
+ "audio": {
188
+ "available": bool(audio_backends),
189
+ "backends": audio_backends,
190
+ "model": CLAP_HF_MODEL if audio_backends else None,
191
+ "ffmpeg": ffmpeg_available,
192
+ },
159
193
  "install_guidance": guidance,
160
194
  }
161
195
 
@@ -279,6 +313,148 @@ def embed_text_for_visual_query(text: str) -> Dict[str, Any]:
279
313
  return {"success": False, "error": f"{type(exc).__name__}: {exc}"}
280
314
 
281
315
 
316
+ # ── audio (CLAP) ─────────────────────────────────────────────────────────────
317
+
318
+
319
+ def _clap_state() -> Tuple[str, Any]:
320
+ """Lazy-load the preferred CLAP backend: transformers (HF-cached, kept
321
+ current) over the laion_clap package."""
322
+ global _CLAP_STATE
323
+ if _CLAP_STATE is None:
324
+ caps = detect_embedding_capabilities()["audio"]
325
+ backends = caps["backends"]
326
+ if "transformers_clap" in backends:
327
+ from transformers import ClapModel, ClapProcessor
328
+
329
+ model = ClapModel.from_pretrained(CLAP_HF_MODEL)
330
+ model.eval()
331
+ processor = ClapProcessor.from_pretrained(CLAP_HF_MODEL)
332
+ _CLAP_STATE = ("transformers_clap", (model, processor))
333
+ elif "laion_clap" in backends:
334
+ import laion_clap
335
+
336
+ module = laion_clap.CLAP_Module(enable_fusion=False)
337
+ module.load_ckpt() # default pretrained checkpoint
338
+ _CLAP_STATE = ("laion_clap", module)
339
+ else:
340
+ raise RuntimeError("No audio-embedding backend available")
341
+ return _CLAP_STATE
342
+
343
+
344
+ def _extract_audio_window(file_path: str, start_seconds: float, duration_seconds: float):
345
+ """Mono 48 kHz float32 samples piped straight from ffmpeg — read-only on
346
+ the source media, no temp files. Returns None when the window is empty
347
+ (e.g. video-only media)."""
348
+ import subprocess
349
+
350
+ import numpy as np
351
+
352
+ command = [
353
+ "ffmpeg", "-v", "error",
354
+ "-ss", f"{max(0.0, float(start_seconds)):.3f}",
355
+ "-t", f"{max(0.1, float(duration_seconds)):.3f}",
356
+ "-i", file_path,
357
+ "-vn", "-ac", "1", "-ar", str(CLAP_SAMPLE_RATE),
358
+ "-f", "f32le", "-",
359
+ ]
360
+ try:
361
+ proc = subprocess.run(command, capture_output=True, timeout=120, check=False)
362
+ except (OSError, subprocess.TimeoutExpired):
363
+ return None
364
+ if proc.returncode != 0 or not proc.stdout:
365
+ return None
366
+ samples = np.frombuffer(proc.stdout, dtype=np.float32)
367
+ return samples if samples.size else None
368
+
369
+
370
+ def _clap_audio_vectors(waveforms: List[Any]) -> Tuple[List[List[float]], str]:
371
+ backend, state = _clap_state()
372
+ if backend == "transformers_clap":
373
+ import torch
374
+
375
+ model, processor = state
376
+ inputs = processor(audios=waveforms, sampling_rate=CLAP_SAMPLE_RATE, return_tensors="pt", padding=True)
377
+ with torch.no_grad():
378
+ features = model.get_audio_features(**inputs)
379
+ features = features / features.norm(dim=-1, keepdim=True)
380
+ return [[float(x) for x in row.tolist()] for row in features], f"transformers_clap:{CLAP_HF_MODEL}"
381
+ import numpy as np
382
+
383
+ module = state
384
+ vectors = module.get_audio_embedding_from_data(x=np.stack(waveforms), use_tensor=False)
385
+ return [[float(x) for x in row] for row in vectors], "laion_clap:default"
386
+
387
+
388
+ def embed_audio_windows(file_path: str, windows: List[Tuple[float, float]]) -> Dict[str, Any]:
389
+ """Embed (start_seconds, end_seconds) windows of one media file with CLAP.
390
+
391
+ Windows longer than AUDIO_WINDOW_SECONDS are center-cropped. Vector slots
392
+ are None for windows with no decodable audio.
393
+ """
394
+ caps = detect_embedding_capabilities()
395
+ if not caps["audio"]["available"]:
396
+ return {
397
+ "success": False,
398
+ "error": "No audio-embedding backend available",
399
+ "install_guidance": caps["install_guidance"].get("audio"),
400
+ }
401
+ if not os.path.isfile(file_path):
402
+ return {"success": False, "error": f"media not found: {file_path}"}
403
+ waveforms: List[Any] = []
404
+ slots: List[Optional[int]] = []
405
+ for start, end in windows:
406
+ duration = max(0.1, float(end) - float(start))
407
+ if duration > AUDIO_WINDOW_SECONDS:
408
+ start = float(start) + (duration - AUDIO_WINDOW_SECONDS) / 2.0
409
+ duration = AUDIO_WINDOW_SECONDS
410
+ samples = _extract_audio_window(file_path, float(start), duration)
411
+ if samples is None:
412
+ slots.append(None)
413
+ continue
414
+ slots.append(len(waveforms))
415
+ waveforms.append(samples)
416
+ if not waveforms:
417
+ return {"success": True, "vectors": [None] * len(windows), "model": None,
418
+ "note": "no decodable audio in any window"}
419
+ try:
420
+ vectors, model = _clap_audio_vectors(waveforms)
421
+ except Exception as exc: # noqa: BLE001 — backend failures surface as data
422
+ return {"success": False, "error": f"{type(exc).__name__}: {exc}"}
423
+ return {
424
+ "success": True,
425
+ "vectors": [vectors[slot] if slot is not None else None for slot in slots],
426
+ "model": model,
427
+ }
428
+
429
+
430
+ def embed_text_for_audio_query(text: str) -> Dict[str, Any]:
431
+ """CLAP text encoder — lets a free-text query ('engine revving') search
432
+ audio vectors."""
433
+ caps = detect_embedding_capabilities()
434
+ if not caps["audio"]["available"]:
435
+ return {
436
+ "success": False,
437
+ "error": "No audio-embedding backend available",
438
+ "install_guidance": caps["install_guidance"].get("audio"),
439
+ }
440
+ try:
441
+ backend, state = _clap_state()
442
+ if backend == "transformers_clap":
443
+ import torch
444
+
445
+ model, processor = state
446
+ inputs = processor(text=[str(text)], return_tensors="pt", padding=True)
447
+ with torch.no_grad():
448
+ features = model.get_text_features(**inputs)
449
+ features = features / features.norm(dim=-1, keepdim=True)
450
+ return {"success": True, "vector": [float(x) for x in features[0].tolist()],
451
+ "model": f"transformers_clap:{CLAP_HF_MODEL}"}
452
+ vectors = state.get_text_embedding([str(text), ""], use_tensor=False)
453
+ return {"success": True, "vector": [float(x) for x in vectors[0]], "model": "laion_clap:default"}
454
+ except Exception as exc: # noqa: BLE001
455
+ return {"success": False, "error": f"{type(exc).__name__}: {exc}"}
456
+
457
+
282
458
  # ── content builders ─────────────────────────────────────────────────────────
283
459
 
284
460
 
@@ -497,6 +673,78 @@ def build_embeddings(
497
673
  else:
498
674
  result["visual"] = {"success": True, "embedded": 0, "note": "all up to date"}
499
675
 
676
+ if "audio" in kinds:
677
+ caps = detect_embedding_capabilities()
678
+ if not caps["audio"]["available"]:
679
+ result["audio"] = {
680
+ "success": False,
681
+ "skipped": True,
682
+ "error": "No audio-embedding backend available",
683
+ "install_guidance": caps["install_guidance"].get("audio"),
684
+ }
685
+ result["success"] = False
686
+ return result
687
+ existing = _existing_rows(conn, "audio")
688
+ embedded_count = 0
689
+ missing_media: List[str] = []
690
+ audio_model: Optional[str] = None
691
+ for clip in conn.execute(f"SELECT * FROM clips{where}", args).fetchall():
692
+ clip = dict(clip)
693
+ file_path = str(clip.get("file_path") or "")
694
+ if not file_path or not os.path.isfile(file_path):
695
+ missing_media.append(str(clip.get("clip_name") or clip["clip_uuid"]))
696
+ continue
697
+ shots = [dict(s) for s in conn.execute(
698
+ "SELECT shot_uuid, time_seconds_start, time_seconds_end FROM shots "
699
+ "WHERE clip_uuid = ? ORDER BY shot_index",
700
+ (clip["clip_uuid"],),
701
+ ).fetchall()]
702
+ windows: List[Tuple[float, float]] = []
703
+ window_meta: List[Tuple[str, str, str]] = []
704
+ for shot in shots:
705
+ start, end = shot.get("time_seconds_start"), shot.get("time_seconds_end")
706
+ if start is None or end is None or float(end) - float(start) < 0.2:
707
+ continue
708
+ h = _content_hash(f"{file_path}|{start}|{end}")
709
+ if existing.get(("shot", str(shot["shot_uuid"]))) == h:
710
+ continue
711
+ windows.append((float(start), float(end)))
712
+ window_meta.append(("shot", str(shot["shot_uuid"]), h))
713
+ if not windows:
714
+ continue
715
+ embedded = embed_audio_windows(file_path, windows)
716
+ if not embedded.get("success"):
717
+ result["audio"] = embedded
718
+ result["success"] = False
719
+ return result
720
+ if not embedded.get("model"):
721
+ continue # no decodable audio in this clip (e.g. video-only)
722
+ audio_model = str(embedded["model"])
723
+ items: List[Tuple[str, str, str, List[float]]] = []
724
+ clip_vectors: List[List[float]] = []
725
+ for m, vector in zip(window_meta, embedded["vectors"]):
726
+ if vector is None:
727
+ continue
728
+ items.append((m[0], m[1], m[2], vector))
729
+ clip_vectors.append(vector)
730
+ # Clip-level audio vector = mean of its shot windows' (the
731
+ # per-shot-visual pattern).
732
+ if clip_vectors:
733
+ dim = len(clip_vectors[0])
734
+ mean = [sum(v[i] for v in clip_vectors) / len(clip_vectors) for i in range(dim)]
735
+ items.append((
736
+ "clip", str(clip["clip_uuid"]),
737
+ _content_hash(f"{file_path}|clip|{len(clip_vectors)}"), mean,
738
+ ))
739
+ embedded_count += _store_vectors(project_root, "audio", audio_model, items)
740
+ result["audio"] = {
741
+ "success": True,
742
+ "embedded": embedded_count,
743
+ "model": audio_model,
744
+ **({"skipped_missing_media": missing_media} if missing_media else {}),
745
+ **({"note": "all up to date"} if not embedded_count and not missing_media else {}),
746
+ }
747
+
500
748
  result["wall_clock_ms"] = int((time.time() - started) * 1000)
501
749
  counts = conn.execute(
502
750
  "SELECT embedding_kind, COUNT(*) AS n FROM embeddings GROUP BY embedding_kind"
@@ -569,8 +817,8 @@ def find_similar(
569
817
 
570
818
  conn = timeline_brain_db.connect(project_root)
571
819
  kind = (kind or "text").strip().lower()
572
- if kind not in ("text", "visual"):
573
- return {"success": False, "error": f"kind must be 'text' or 'visual', got {kind!r}"}
820
+ if kind not in ("text", "visual", "audio"):
821
+ return {"success": False, "error": f"kind must be 'text', 'visual', or 'audio', got {kind!r}"}
574
822
 
575
823
  exclude: Optional[Tuple[str, str]] = None
576
824
  query_vector: Optional[List[float]] = None
@@ -583,6 +831,12 @@ def find_similar(
583
831
  return embedded
584
832
  query_vector = embedded["vectors"][0]
585
833
  query_model = str(embedded["model"])
834
+ elif kind == "audio":
835
+ encoded = embed_text_for_audio_query(str(text))
836
+ if not encoded.get("success"):
837
+ return encoded
838
+ query_vector = encoded["vector"]
839
+ query_model = str(encoded["model"])
586
840
  else:
587
841
  encoded = embed_text_for_visual_query(str(text))
588
842
  if not encoded.get("success"):
@@ -1458,6 +1458,20 @@ TOOL_INSTALL: Dict[str, Dict[str, Any]] = {
1458
1458
  "verify": "python -c \"import open_clip\"",
1459
1459
  "notes": "Needs torch. Model weights (~350 MB) download on first use.",
1460
1460
  },
1461
+ "clap_audio": {
1462
+ "label": "CLAP (audio embeddings)",
1463
+ "bundle": "embeddings",
1464
+ "required_for": ["audio similarity (find_similar kind=audio)"],
1465
+ "commands": {
1466
+ "all": "pip install transformers",
1467
+ },
1468
+ "verify": "python -c \"import transformers\"",
1469
+ "notes": (
1470
+ "Needs torch + ffmpeg. Uses laion/clap-htsat-unfused (~600 MB, "
1471
+ "downloads on first use); the laion_clap package works as an "
1472
+ "alternative backend."
1473
+ ),
1474
+ },
1461
1475
  "whisper_cpp": {
1462
1476
  "label": "whisper.cpp",
1463
1477
  "bundle": "transcription",
@@ -1650,6 +1664,11 @@ def detect_capabilities(env: Optional[Dict[str, str]] = None) -> Dict[str, Any]:
1650
1664
  bool(embedding_caps.get("visual", {}).get("available")),
1651
1665
  {"python_module": "open_clip"},
1652
1666
  ),
1667
+ "clap_audio": _tool_entry(
1668
+ "clap_audio",
1669
+ bool(embedding_caps.get("audio", {}).get("available")),
1670
+ {"backends": embedding_caps.get("audio", {}).get("backends", [])},
1671
+ ),
1653
1672
  },
1654
1673
  "embeddings": embedding_caps,
1655
1674
  "transcription": {