livepilot 1.10.8 → 1.12.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (49) hide show
  1. package/CHANGELOG.md +373 -0
  2. package/README.md +16 -16
  3. package/m4l_device/LivePilot_Analyzer.amxd +0 -0
  4. package/m4l_device/livepilot_bridge.js +1 -1
  5. package/mcp_server/__init__.py +1 -1
  6. package/mcp_server/evaluation/fabric.py +62 -1
  7. package/mcp_server/m4l_bridge.py +503 -18
  8. package/mcp_server/project_brain/automation_graph.py +23 -1
  9. package/mcp_server/project_brain/builder.py +2 -0
  10. package/mcp_server/project_brain/models.py +20 -1
  11. package/mcp_server/project_brain/tools.py +10 -3
  12. package/mcp_server/runtime/execution_router.py +7 -0
  13. package/mcp_server/runtime/mcp_dispatch.py +32 -0
  14. package/mcp_server/runtime/remote_commands.py +54 -0
  15. package/mcp_server/sample_engine/slice_classifier.py +169 -0
  16. package/mcp_server/semantic_moves/tools.py +139 -31
  17. package/mcp_server/server.py +151 -17
  18. package/mcp_server/session_continuity/models.py +13 -0
  19. package/mcp_server/session_continuity/tools.py +2 -0
  20. package/mcp_server/session_continuity/tracker.py +93 -0
  21. package/mcp_server/tools/_analyzer_engine/__init__.py +39 -0
  22. package/mcp_server/tools/_analyzer_engine/context.py +103 -0
  23. package/mcp_server/tools/_analyzer_engine/flucoma.py +23 -0
  24. package/mcp_server/tools/_analyzer_engine/sample.py +122 -0
  25. package/mcp_server/tools/_motif_engine.py +19 -4
  26. package/mcp_server/tools/analyzer.py +204 -180
  27. package/mcp_server/tools/clips.py +304 -1
  28. package/mcp_server/tools/devices.py +517 -5
  29. package/mcp_server/tools/diagnostics.py +42 -0
  30. package/mcp_server/tools/follow_actions.py +202 -0
  31. package/mcp_server/tools/grooves.py +142 -0
  32. package/mcp_server/tools/miditool.py +280 -0
  33. package/mcp_server/tools/scales.py +126 -0
  34. package/mcp_server/tools/take_lanes.py +135 -0
  35. package/mcp_server/tools/tracks.py +46 -3
  36. package/mcp_server/tools/transport.py +120 -4
  37. package/package.json +2 -2
  38. package/remote_script/LivePilot/__init__.py +15 -4
  39. package/remote_script/LivePilot/clips.py +62 -0
  40. package/remote_script/LivePilot/devices.py +444 -0
  41. package/remote_script/LivePilot/diagnostics.py +52 -1
  42. package/remote_script/LivePilot/follow_actions.py +235 -0
  43. package/remote_script/LivePilot/grooves.py +185 -0
  44. package/remote_script/LivePilot/scales.py +138 -0
  45. package/remote_script/LivePilot/take_lanes.py +175 -0
  46. package/remote_script/LivePilot/tracks.py +59 -1
  47. package/remote_script/LivePilot/transport.py +90 -1
  48. package/remote_script/LivePilot/version_detect.py +9 -0
  49. package/server.json +3 -3
@@ -201,24 +201,39 @@ def detect_motifs(
201
201
  salience = _score_salience(pattern, len(occurrences), total_note_count)
202
202
  fatigue = _score_fatigue(len(occurrences), total_bars)
203
203
 
204
- # Get representative pitches from first occurrence
204
+ # Get representative pitches + inter-onset intervals from first occurrence.
205
+ # Rhythm is the list of start_time deltas between successive notes in
206
+ # the pattern window; until v1.10.9 this field was left empty with a
207
+ # "TODO: Phase 3" marker, which is what forced Hook Hunter's rhythm
208
+ # side to fall back to drum-track-name regex. Populating it here lets
209
+ # downstream code actually reason about rhythmic distinctiveness.
205
210
  first_occ = occurrences[0] if occurrences else {}
206
211
  first_track = first_occ.get("track", 0)
207
212
  first_pos = first_occ.get("start_position", 0)
208
- rep_pitches = []
213
+ rep_pitches: list[int] = []
214
+ rhythm_intervals: list[float] = []
209
215
  if first_track in notes_by_track:
210
216
  sorted_notes = sorted(notes_by_track[first_track],
211
217
  key=lambda n: n.get("start_time", 0))
218
+ span = min(len(pattern) + 1, len(sorted_notes) - first_pos)
212
219
  rep_pitches = [
213
220
  sorted_notes[first_pos + j].get("pitch", 60)
214
- for j in range(min(len(pattern) + 1, len(sorted_notes) - first_pos))
221
+ for j in range(span)
222
+ ]
223
+ rhythm_intervals = [
224
+ round(
225
+ float(sorted_notes[first_pos + j + 1].get("start_time", 0.0))
226
+ - float(sorted_notes[first_pos + j].get("start_time", 0.0)),
227
+ 4,
228
+ )
229
+ for j in range(span - 1)
215
230
  ]
216
231
 
217
232
  motif = MotifUnit(
218
233
  motif_id=f"motif_{len(motifs):03d}",
219
234
  kind="melodic" if any(abs(i) > 0 for i in pattern) else "rhythmic",
220
235
  intervals=list(pattern),
221
- rhythm=[], # TODO: rhythm detection in Phase 3
236
+ rhythm=rhythm_intervals,
222
237
  representative_pitches=rep_pitches,
223
238
  occurrences=occurrences,
224
239
  salience=salience,
@@ -2,88 +2,66 @@
2
2
 
3
3
  30 tools requiring the LivePilot Analyzer M4L device on the master track.
4
4
  These tools are optional — all core tools work without the device.
5
+
6
+ Helpers live in ``_analyzer_engine/`` (context accessors, Simpler
7
+ post-load hygiene, FluCoMa hint formatting). This file contains the
8
+ ``@mcp.tool()`` surface only — keeping decorator order stable was
9
+ important for BUG-C1's refactor.
5
10
  """
6
11
 
7
12
  from __future__ import annotations
8
13
 
9
14
  import logging
10
15
  import os
11
- import re # used below in filename parsing helpers
12
16
  from typing import Optional
13
17
 
14
18
  from fastmcp import Context
15
19
 
16
20
  from ..server import mcp, _identify_port_holder
21
+ from ._analyzer_engine import (
22
+ PITCH_NAMES,
23
+ _filename_stem,
24
+ _flucoma_hint,
25
+ _get_m4l,
26
+ _get_spectral,
27
+ _is_warped_loop,
28
+ _require_analyzer,
29
+ _simpler_post_load_hygiene,
30
+ )
17
31
 
18
- # Logger must be defined before any helper that uses it — _require_analyzer
19
- # below calls logger.debug on an exception path, so defining the logger later
20
- # in the file risked NameError under unusual import orderings.
21
32
  logger = logging.getLogger(__name__)
22
33
 
23
34
  CAPTURE_DIR = os.path.expanduser("~/Documents/LivePilot/captures")
24
35
 
25
36
 
26
- def _get_spectral(ctx: Context):
27
- """Get SpectralCache from lifespan context."""
28
- cache = ctx.lifespan_context.get("spectral")
29
- if not cache:
30
- raise ValueError("Spectral cache not initialized — restart the MCP server")
31
- # Keep the active request context attached so analyzer error paths can
32
- # distinguish "device missing" from "bridge disconnected".
33
- setattr(cache, "_livepilot_ctx", ctx)
34
- return cache
35
-
37
+ # Live 12 Simpler Slice mode maps slice N to MIDI pitch 36+N (C1 base).
38
+ # This is NOT exposed by the Remote Script API and is a common source of
39
+ # silent audio bugs (BUG-F2). See feedback_analyze_slices_before_programming
40
+ # memory for context.
41
+ SIMPLER_SLICE_BASE_PITCH = 36
36
42
 
37
- def _get_m4l(ctx: Context):
38
- """Get M4LBridge from lifespan context."""
39
- bridge = ctx.lifespan_context.get("m4l")
40
- if not bridge:
41
- raise ValueError("M4L bridge not initialized — restart the MCP server")
42
- return bridge
43
43
 
44
+ def _enrich_slice_response(response: Optional[dict]) -> Optional[dict]:
45
+ """Add base_midi_pitch field + per-slice midi_pitch to bridge response (BUG-F2).
44
46
 
45
- def _require_analyzer(cache) -> None:
46
- """Raise a helpful error if the analyzer is not connected."""
47
- if not cache.is_connected:
48
- ctx = getattr(cache, "_livepilot_ctx", None)
49
- try:
50
- track = (
51
- ctx.lifespan_context["ableton"].send_command("get_master_track")
52
- if ctx else {}
53
- )
54
- except Exception as exc:
55
- logger.debug("_require_analyzer failed: %s", exc)
56
- track = {}
57
-
58
- devices = track.get("devices", []) if isinstance(track, dict) else []
59
- analyzer_loaded = False
60
- for device in devices:
61
- normalized = " ".join(
62
- str(device.get("name") or "").replace("_", " ").replace("-", " ").lower().split()
63
- )
64
- if normalized == "livepilot analyzer":
65
- analyzer_loaded = True
66
- break
67
-
68
- if analyzer_loaded:
69
- holder = _identify_port_holder(9880)
70
- detail = (
71
- "LivePilot Analyzer is loaded on the master track, but its UDP bridge is not connected. "
72
- )
73
- if holder:
74
- detail += (
75
- "UDP port 9880 is currently held by another LivePilot instance "
76
- f"({holder}). Close the other client/server, then retry."
77
- )
78
- else:
79
- detail += "Reload the analyzer device or restart the MCP server."
80
- raise ValueError(detail)
81
-
82
- raise ValueError(
83
- "LivePilot Analyzer not detected. "
84
- "Drag 'LivePilot Analyzer' onto the master track from "
85
- "Audio Effects > Max Audio Effect."
86
- )
47
+ The Remote Script returns slice indices only. Users then have to know
48
+ that slice N plays at MIDI pitch 36+N a fact that's undocumented in
49
+ both Ableton's and LivePilot's public API. This enrichment makes the
50
+ mapping explicit so MIDI pattern generation doesn't silently produce
51
+ out-of-range notes.
52
+ """
53
+ if response is None:
54
+ return None
55
+ enriched = dict(response)
56
+ enriched["base_midi_pitch"] = SIMPLER_SLICE_BASE_PITCH
57
+ slices = enriched.get("slices") or []
58
+ # BUG-audit-M2: fall back to positional index when the bridge response
59
+ # omits the `index` field (protects against bridge version skew).
60
+ enriched["slices"] = [
61
+ {**s, "midi_pitch": SIMPLER_SLICE_BASE_PITCH + s.get("index", i)}
62
+ for i, s in enumerate(slices)
63
+ ]
64
+ return enriched
87
65
 
88
66
 
89
67
  @mcp.tool()
@@ -149,11 +127,36 @@ def get_master_spectrum(ctx: Context) -> dict:
149
127
  return result
150
128
 
151
129
 
130
+ def _sanitize_pitch(pitch: Optional[dict]) -> Optional[dict]:
131
+ """Validate a pitch reading from the M4L analyzer (BUG-F1).
132
+
133
+ The polyphonic pitch detector can emit out-of-range MIDI notes
134
+ (e.g., 319, -50, 128+) when it can't latch onto a single
135
+ fundamental — typical for dense mixes. The amplitude field is the
136
+ reliable confidence signal: if the detector was sure of its
137
+ reading, amplitude is non-zero.
138
+
139
+ Returns the original dict if the reading is usable, None otherwise.
140
+ """
141
+ if not pitch:
142
+ return None
143
+ amplitude = pitch.get("amplitude")
144
+ midi_note = pitch.get("midi_note")
145
+ if amplitude is None or amplitude <= 0:
146
+ return None
147
+ if midi_note is None or midi_note < 0 or midi_note > 127:
148
+ return None
149
+ return pitch
150
+
151
+
152
152
  @mcp.tool()
153
153
  def get_master_rms(ctx: Context) -> dict:
154
154
  """Get real-time RMS and peak levels from the master bus.
155
155
 
156
156
  More accurate than LOM meters — includes true RMS (not just peak hold).
157
+ Pitch readings are validated: the field is only present when the
158
+ polyphonic pitch detector produced a reading with non-zero
159
+ amplitude and a MIDI note in [0, 127] (BUG-F1).
157
160
  Requires LivePilot Analyzer on master track.
158
161
  """
159
162
  cache = _get_spectral(ctx)
@@ -169,9 +172,11 @@ def get_master_rms(ctx: Context) -> dict:
169
172
  if peak:
170
173
  result["peak"] = peak["value"]
171
174
 
172
- pitch = cache.get("pitch")
173
- if pitch:
174
- result["pitch"] = pitch["value"]
175
+ pitch_entry = cache.get("pitch")
176
+ if pitch_entry:
177
+ clean = _sanitize_pitch(pitch_entry.get("value"))
178
+ if clean is not None:
179
+ result["pitch"] = clean
175
180
 
176
181
  return result
177
182
 
@@ -305,102 +310,10 @@ async def get_clip_file_path(
305
310
  # S Start=0, S Length=1, S Loop On=1 so the full loop plays in its
306
311
  # musical phrasing. For ONE-SHOTS, leave defaults alone.
307
312
 
308
- _BPM_IN_FILENAME_RE = re.compile(r"(\d{2,3})\s*bpm", re.IGNORECASE)
309
-
310
-
311
- def _is_warped_loop(file_path: str) -> bool:
312
- """Return True if the filename contains a BPM marker (likely a tempo-locked loop)."""
313
- stem = os.path.splitext(os.path.basename(file_path))[0]
314
- return bool(_BPM_IN_FILENAME_RE.search(stem))
315
-
316
-
317
- def _filename_stem(file_path: str) -> str:
318
- return os.path.splitext(os.path.basename(file_path))[0]
319
-
320
-
321
- async def _simpler_post_load_hygiene(
322
- bridge,
323
- ableton,
324
- track_index: int,
325
- device_index: int,
326
- file_path: str,
327
- ) -> dict:
328
- """Apply post-load hygiene to a newly loaded Simpler and verify success.
329
-
330
- Steps:
331
- 1. Read track info to verify the device's actual name matches the
332
- expected sample stem. If it doesn't, return an error.
333
- 2. Set Snap=0 (Off) — required so sample playback works.
334
- 3. If filename indicates a warped loop, set S Start=0, S Length=1,
335
- S Loop On=1 so the loop plays fully instead of being cropped.
336
- 4. Return a verified response dict.
337
- """
338
- expected_stem = _filename_stem(file_path)
339
-
340
- # Step 1: verify device name matches expected file
341
- try:
342
- track_info = ableton.send_command(
343
- "get_track_info", {"track_index": track_index}
344
- )
345
- except Exception as exc:
346
- return {"error": f"Verification read failed: {exc}"}
347
-
348
- devices = track_info.get("devices", []) or []
349
- if device_index < 0 or device_index >= len(devices):
350
- return {
351
- "error": (
352
- f"Device index {device_index} out of range after load "
353
- f"(track has {len(devices)} devices)"
354
- ),
355
- "verified": False,
356
- }
357
- device = devices[device_index]
358
- actual_name = str(device.get("name") or "")
359
- verified = expected_stem in actual_name or actual_name in expected_stem
360
- if not verified:
361
- return {
362
- "error": (
363
- f"Sample verification FAILED — Simpler name '{actual_name}' "
364
- f"does not match requested file '{expected_stem}'. The bridge "
365
- f"reported success but the actual sample is different. "
366
- f"Try `load_browser_item` with a user_library URI instead."
367
- ),
368
- "verified": False,
369
- "actual_device_name": actual_name,
370
- "expected_stem": expected_stem,
371
- }
372
-
373
- # Step 2: turn Snap OFF — required for reliable playback after replace
374
- hygiene_params: list[dict] = [
375
- {"name_or_index": "Snap", "value": 0},
376
- ]
377
-
378
- # Step 3: smart defaults for warped loops
379
- if _is_warped_loop(file_path):
380
- hygiene_params.extend([
381
- {"name_or_index": "S Start", "value": 0.0},
382
- {"name_or_index": "S Length", "value": 1.0},
383
- {"name_or_index": "S Loop On", "value": 1},
384
- ])
385
-
386
- try:
387
- ableton.send_command("batch_set_parameters", {
388
- "track_index": track_index,
389
- "device_index": device_index,
390
- "parameters": hygiene_params,
391
- })
392
- except Exception as exc:
393
- logger.debug("_simpler_post_load_hygiene failed: %s", exc)
394
- # non-fatal — verification already succeeded
395
- pass
396
-
397
- return {
398
- "verified": True,
399
- "device_name": actual_name,
400
- "track_index": track_index,
401
- "device_index": device_index,
402
- "warped_loop_defaults_applied": _is_warped_loop(file_path),
403
- }
313
+ # _BPM_IN_FILENAME_RE, _is_warped_loop, _filename_stem, and the
314
+ # _simpler_post_load_hygiene coroutine now live in
315
+ # ``_analyzer_engine/sample.py`` — re-exported via this module's imports
316
+ # at the top of the file so tests importing them by name still resolve.
404
317
 
405
318
 
406
319
  @mcp.tool()
@@ -558,15 +471,138 @@ async def get_simpler_slices(
558
471
  ) -> dict:
559
472
  """Get slice point positions from a Simpler device.
560
473
 
561
- Returns each slice's position in frames and seconds, plus sample metadata
562
- (sample rate, length, playback mode). Use this to understand the rhythmic
563
- structure of a sliced sample and program MIDI patterns targeting slices.
474
+ Returns each slice's position in frames and seconds, the MIDI pitch
475
+ that triggers it (slice 0 = C1 / MIDI 36, slice 1 = C#1 / MIDI 37, etc.
476
+ per BUG-F2), plus sample metadata (sample rate, length, playback mode).
477
+
478
+ **Always use the returned `midi_pitch` when programming MIDI notes to
479
+ trigger slices.** The Live 12 Simpler Slice-mode base note is C1,
480
+ NOT C3 — writing notes at pitch 60+ on a sample with <24 slices
481
+ triggers nothing and produces silent output.
482
+
483
+ Use this to understand the rhythmic structure of a sliced sample
484
+ and program MIDI patterns targeting slices. Requires LivePilot
485
+ Analyzer on master track.
486
+ """
487
+ cache = _get_spectral(ctx)
488
+ _require_analyzer(cache)
489
+ bridge = _get_m4l(ctx)
490
+ raw = await bridge.send_command("get_simpler_slices", track_index, device_index)
491
+ return _enrich_slice_response(raw)
492
+
493
+
494
+ @mcp.tool()
495
+ async def classify_simpler_slices(
496
+ ctx: Context,
497
+ track_index: int,
498
+ device_index: int = 0,
499
+ file_path: Optional[str] = None,
500
+ ) -> dict:
501
+ """Classify each Simpler slice as KICK / SNARE / HAT / ghost via FFT analysis.
502
+
503
+ Reads slice positions via ``get_simpler_slices``, loads the backing
504
+ WAV file, and runs 4-band spectral classification on each segment.
505
+ Returns the enriched slice list with a ``label`` field per entry
506
+ plus feature breakdown (peak, rms, sub_pct, low_pct, mid_pct,
507
+ high_pct).
508
+
509
+ **Always run this before programming drum patterns on a sliced
510
+ break.** Slice content depends on transient detection order in the
511
+ source audio — slice 0 is NOT guaranteed to be a kick. Assuming
512
+ drum-rack convention produces wrong grooves that take iterations to
513
+ diagnose (see 2026-04-18 creative session for the canonical case).
514
+
515
+ Classification rules (validated on "Break Ghosts 90 bpm"):
516
+ - KICK: sub+low >= 45%, high < 40%
517
+ - HAT: high >= 70% AND mid < 25% (thin metal disc = no drum body)
518
+ - SNARE: mid >= 25% AND high >= 40% AND peak >= 0.6 (broadband loud)
519
+ - ghost: peak < 0.35
520
+
521
+ Parameters:
522
+ track_index, device_index: the Simpler to analyze
523
+ file_path: (optional) explicit WAV path. If omitted, attempts
524
+ lookup via the bridge. Bridge-native resolution is limited in
525
+ v1.11 — when the sample lives in the Core Library, pass the
526
+ absolute path explicitly.
527
+
528
+ Returns: dict with ``slices`` list. Each slice entry has:
529
+ index, frame, seconds, midi_pitch (36+index), label, peak, rms,
530
+ sub_pct, low_pct, mid_pct, high_pct.
531
+
564
532
  Requires LivePilot Analyzer on master track.
565
533
  """
534
+ import soundfile as sf
535
+
536
+ from ..sample_engine.slice_classifier import classify_slices
537
+
566
538
  cache = _get_spectral(ctx)
567
539
  _require_analyzer(cache)
568
540
  bridge = _get_m4l(ctx)
569
- return await bridge.send_command("get_simpler_slices", track_index, device_index)
541
+
542
+ # 1. Get slice positions
543
+ raw_slices = await bridge.send_command(
544
+ "get_simpler_slices", track_index, device_index
545
+ )
546
+ enriched = _enrich_slice_response(raw_slices)
547
+ if enriched is None:
548
+ return {"error": "Bridge returned no slice data"}
549
+
550
+ # 2. Resolve file path
551
+ wav_path = file_path
552
+ if not wav_path:
553
+ try:
554
+ file_info = await bridge.send_command(
555
+ "get_simpler_file_path", track_index, device_index
556
+ )
557
+ if isinstance(file_info, dict):
558
+ wav_path = file_info.get("file_path")
559
+ except Exception: # noqa: BLE001 — bridge command may not exist yet
560
+ wav_path = None
561
+
562
+ if not wav_path:
563
+ return {
564
+ **enriched,
565
+ "error": (
566
+ "No file_path available — pass file_path= explicitly. "
567
+ "Bridge-based lookup for Simpler sample paths is a v1.12 "
568
+ "follow-up."
569
+ ),
570
+ }
571
+
572
+ # 3. Load WAV and build frame boundaries
573
+ try:
574
+ audio, sr = sf.read(wav_path)
575
+ except (sf.LibsndfileError, sf.SoundFileError, RuntimeError, OSError) as exc:
576
+ # BUG-audit-C3: corrupt / missing / non-audio files must return a
577
+ # structured error dict instead of raising through the MCP framework
578
+ # (inconsistent with every other tool in this module).
579
+ return {
580
+ **enriched,
581
+ "error": f"Could not load WAV at {wav_path!r}: {exc}",
582
+ }
583
+ slices = enriched["slices"]
584
+ frame_boundaries = [s["frame"] for s in slices] + [len(audio)]
585
+
586
+ # 4. Classify
587
+ classifications = classify_slices(audio, sr, frame_boundaries)
588
+
589
+ # 5. Merge classification into each slice entry
590
+ merged_slices = []
591
+ for slice_entry, features in zip(slices, classifications):
592
+ merged_slices.append({
593
+ **slice_entry,
594
+ "label": features["label"],
595
+ "peak": features["peak"],
596
+ "rms": features["rms"],
597
+ "sub_pct": features["sub_pct"],
598
+ "low_pct": features["low_pct"],
599
+ "mid_pct": features["mid_pct"],
600
+ "high_pct": features["high_pct"],
601
+ })
602
+
603
+ enriched["slices"] = merged_slices
604
+ enriched["classifier_version"] = "v1.0"
605
+ return enriched
570
606
 
571
607
 
572
608
  @mcp.tool()
@@ -847,21 +883,9 @@ async def capture_stop(ctx: Context) -> dict:
847
883
  return await bridge.send_command("capture_stop")
848
884
 
849
885
  # ── Phase 4: FluCoMa Real-Time ───────────────────────────────────────────
850
-
851
- PITCH_NAMES = ["C", "C#", "D", "D#", "E", "F", "F#", "G", "G#", "A", "A#", "B"]
852
-
853
-
854
- def _flucoma_hint(cache) -> str:
855
- """Return an error hint if no FluCoMa data has arrived.
856
-
857
- If ANY stream has data, FluCoMa is working and the specific stream just
858
- hasn't updated yet — return a 'play audio' hint. If NO streams have data,
859
- FluCoMa may not be installed — return an install hint.
860
- """
861
- for key in ("spectral_shape", "mel_bands", "chroma", "loudness"):
862
- if cache.get(key):
863
- return "play some audio"
864
- return "FluCoMa may not be installed. Install via: npx livepilot --setup-flucoma"
886
+ #
887
+ # PITCH_NAMES + _flucoma_hint now live in ``_analyzer_engine/flucoma.py``
888
+ # and are re-exported via the top-of-file imports for tests/subclassers.
865
889
 
866
890
 
867
891
  @mcp.tool()