tonable 0.5.2__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,300 @@
1
+ """find_in_library and load_device. Main thread only.
2
+
3
+ The browser tree is walked once and kept (stage 0: 60 115 items in 5.2 s); `refresh`
4
+ walks it again. Places are left out: Live 12.4 does not show them to scripts
5
+ (see docs/plan.md), so presets live in the User Library, Packs and the Core Library.
6
+ """
7
+
8
+ import os
9
+ import time
10
+
11
+ from . import objects, reading
12
+ from .server import CommandError
13
+ from .song import undo_step
14
+
15
+ try:
16
+ import Live
17
+ except ImportError: # imported by the tests, outside Live
18
+ Live = None
19
+
20
+ # Browser attribute -> the name used in paths and answers.
21
+ ROOTS = (
22
+ ("instruments", "Instruments"),
23
+ ("audio_effects", "Audio Effects"),
24
+ ("midi_effects", "MIDI Effects"),
25
+ ("sounds", "Sounds"),
26
+ ("drums", "Drums"),
27
+ ("max_for_live", "Max for Live"),
28
+ ("plugins", "Plug-ins"),
29
+ ("packs", "Packs"),
30
+ ("user_library", "User Library"),
31
+ ("current_project", "Current Project"),
32
+ )
33
+ KINDS = {
34
+ "instrument": "Instruments",
35
+ "audio_effect": "Audio Effects",
36
+ "midi_effect": "MIDI Effects",
37
+ "sound": "Sounds",
38
+ "drum": "Drums",
39
+ "max_for_live": "Max for Live",
40
+ "plugin": "Plug-ins",
41
+ "pack": "Packs",
42
+ "user_library": "User Library",
43
+ }
44
+ SUFFIXES = (".adg", ".adv", ".amxd", ".aupreset", ".fxp")
45
+ # Live Sets in packs (demo songs) are loadable but not onto a track.
46
+ SETS = (".als",)
47
+ # This file is <User Library>/Remote Scripts/Tonable/library.py once installed.
48
+ _HERE = os.path.dirname(os.path.abspath(__file__))
49
+ USER_LIBRARY = os.path.dirname(os.path.dirname(_HERE))
50
+
51
+ _cache = {"items": None, "built": None, "seconds": None}
52
+
53
+
54
+ def browser():
55
+ return Live.Application.get_application().browser
56
+
57
+
58
+ def index(refresh=False):
59
+ """[(root name, path, item)] for every loadable item, walked once."""
60
+ if _cache["items"] is None or refresh:
61
+ started = time.perf_counter()
62
+ items = []
63
+ b = browser()
64
+ for attr, label in ROOTS:
65
+ root = getattr(b, attr, None)
66
+ if root is not None:
67
+ _walk(root, label, items)
68
+ _cache.update(
69
+ items=items,
70
+ built=time.strftime("%H:%M:%S"),
71
+ seconds=round(time.perf_counter() - started, 1),
72
+ )
73
+ return _cache["items"]
74
+
75
+
76
+ def _walk(item, path, out):
77
+ for child in item.children:
78
+ child_path = f"{path}/{child.name}"
79
+ if child.is_loadable and not plain(child.name).endswith(SETS):
80
+ out.append((path.split("/", 1)[0], child_path, child))
81
+ # Folders hold items, and a device's children are its presets.
82
+ _walk(child, child_path, out)
83
+
84
+
85
+ def plain(name):
86
+ text = str(name).strip().casefold()
87
+ for suffix in SUFFIXES:
88
+ if text.endswith(suffix):
89
+ return text[: -len(suffix)]
90
+ return text
91
+
92
+
93
+ def find_in_library(p):
94
+ query = str(p.get("query") or "").strip()
95
+ if not query:
96
+ raise CommandError("bad_params", "query is required, e.g. Electric Bass")
97
+ limit = p.get("limit")
98
+ try:
99
+ limit = int(limit) if limit not in (None, "") else 0
100
+ except (TypeError, ValueError):
101
+ raise CommandError("bad_params", "limit must be a whole number") from None
102
+ if limit < 0:
103
+ raise CommandError("bad_params", "limit must be 0 (all) or more")
104
+ # No limit (empty or 0) returns everything found.
105
+ limit = limit or None
106
+ kind = p.get("kind") or "any"
107
+ if kind != "any" and kind not in KINDS:
108
+ raise CommandError(
109
+ "bad_params", "kind must be any or one of: " + ", ".join(KINDS)
110
+ )
111
+ words = plain(query).split()
112
+ hits = []
113
+ seen = set()
114
+ for root, path, item in index(bool(p.get("refresh"))):
115
+ if kind != "any" and root != KINDS[kind]:
116
+ continue
117
+ name = plain(item.name)
118
+ if not all(w in name for w in words):
119
+ continue
120
+ key = _identity(item, path)
121
+ if key in seen:
122
+ continue
123
+ seen.add(key)
124
+ rank = 0 if name == plain(query) else 1 if name.startswith(words[0]) else 2
125
+ hits.append((rank, len(path), path, item))
126
+ hits.sort(key=lambda h: (h[0], h[1], h[2]))
127
+ shown = [
128
+ {"name": item.name, "path": path, "kind": _item_kind(item)}
129
+ for _, _, path, item in hits[:limit]
130
+ ]
131
+ if p.get("show_uri"):
132
+ for entry, (_, _, _, item) in zip(shown, hits, strict=False):
133
+ entry["uri"] = getattr(item, "uri", None)
134
+ out = {"query": query, "found": len(hits), "results": shown}
135
+ if limit is not None and len(hits) > limit:
136
+ out["more"] = len(hits) - limit
137
+ out["index"] = {"items": len(_cache["items"]), "built": _cache["built"]}
138
+ return out
139
+
140
+
141
+ def load_device(song, p):
142
+ """Load a native device by name, a browser item by path, or a preset file."""
143
+ item_ref = str(p.get("item") or "").strip()
144
+ if not item_ref:
145
+ raise CommandError(
146
+ "bad_params", "item is required: Operator, a browser path or a file"
147
+ )
148
+ kind, holder, path = objects.resolve(song, p.get("target") or "")
149
+ if kind not in ("track", "chain"):
150
+ raise CommandError(
151
+ "bad_target", f"{path} is a {kind}: load onto a track or chain"
152
+ )
153
+ position = p.get("index")
154
+ before = list(holder.devices)
155
+ before_names = [(d.name, d.class_name) for d in before]
156
+ position = len(before) if position in (None, "", -1) else int(position)
157
+ if not 0 <= position <= len(before):
158
+ raise CommandError("bad_params", f"index must be 0-{len(before)}")
159
+
160
+ with undo_step(song):
161
+ how = _insert_native(holder, item_ref, position)
162
+ if how is None:
163
+ if kind != "track":
164
+ raise CommandError(
165
+ "bad_target",
166
+ "Live loads presets onto tracks only; load it on the track, then "
167
+ "move it into the chain with update_device move_to",
168
+ )
169
+ item, how = _browser_item(item_ref, bool(p.get("refresh")))
170
+ song.view.selected_track = holder
171
+ browser().load_item(item)
172
+
173
+ after = list(holder.devices)
174
+ after_names = [(d.name, d.class_name) for d in after]
175
+ new = [i for i, d in enumerate(after) if all(d != old for old in before)]
176
+ replaced = None
177
+ if not new and len(after) == len(before):
178
+ # A track has one instrument: loading another one replaces it in place.
179
+ changed = [i for i, n in enumerate(after_names) if n != before_names[i]]
180
+ if changed:
181
+ new = changed
182
+ replaced = before_names[changed[0]][0]
183
+ if not new:
184
+ return {
185
+ "loaded": item_ref,
186
+ "how": how,
187
+ "warning": "Live has not shown the device yet; check with read_track",
188
+ }
189
+ device_path = f"{path}/d{new[0]}"
190
+ out = {"loaded": after[new[0]].name, "how": how, "path": device_path}
191
+ if replaced:
192
+ out["replaced"] = replaced
193
+ info = reading.read_device(song, device_path)
194
+ if "macros" in info:
195
+ out["macros"] = [m["name"] for m in info["macros"]]
196
+ out["note"] = "Live selects the track it loads onto"
197
+ return out
198
+
199
+
200
+ def _insert_native(holder, name, position):
201
+ """Insert a built-in device by name; None if Live doesn't know the name."""
202
+ if "/" in name or plain(name) != name.strip().casefold():
203
+ return None
204
+ insert = getattr(holder, "insert_device", None)
205
+ if insert is None:
206
+ return None
207
+ try:
208
+ insert(name, position)
209
+ except Exception:
210
+ return None
211
+ return "native device"
212
+
213
+
214
+ def _browser_item(ref, refresh):
215
+ """The browser item for a file path, a browser path, or a unique name."""
216
+ if ref.startswith(("/", "~")):
217
+ return _by_file(os.path.expanduser(ref), refresh), "file"
218
+ items = index(refresh)
219
+ if "/" in ref:
220
+ wanted = [plain(s) for s in ref.split("/") if s]
221
+ for _, path, item in items:
222
+ if [plain(s) for s in path.split("/")] == wanted:
223
+ return item, "browser path"
224
+ raise CommandError(
225
+ "not_found",
226
+ f"no browser item at {ref!r}; use find_in_library to get its path",
227
+ )
228
+ return _unique(items, plain(ref), ref), "name"
229
+
230
+
231
+ def _by_file(file_path, refresh):
232
+ if not os.path.isfile(file_path):
233
+ raise CommandError("not_found", f"no file {file_path}")
234
+ items = index(refresh)
235
+ segments = [s for s in os.path.realpath(file_path).split(os.sep) if s]
236
+ library = [s for s in os.path.realpath(USER_LIBRARY).split(os.sep) if s]
237
+ if segments[: len(library)] == library:
238
+ wanted = ["user library"] + [plain(s) for s in segments[len(library) :]]
239
+ for _, path, item in items:
240
+ if [plain(s) for s in path.split("/")] == wanted:
241
+ return item
242
+ for _, path, item in items:
243
+ parts = [plain(s) for s in path.split("/")]
244
+ if parts[0] == "packs":
245
+ pack = parts[1]
246
+ if pack in [plain(s) for s in segments]:
247
+ tail = [
248
+ plain(s)
249
+ for s in segments[[plain(s) for s in segments].index(pack) + 1 :]
250
+ ]
251
+ if parts[2:] == tail:
252
+ return item
253
+ # The Core Library and anything else: by file name, if only one file has it.
254
+ try:
255
+ return _unique(items, plain(segments[-1]), file_path)
256
+ except CommandError as err:
257
+ if err.code != "not_found":
258
+ raise
259
+ raise CommandError(
260
+ "outside_library",
261
+ f"{file_path} is not in a folder Live's browser shows. Move it into the User "
262
+ f"Library ({USER_LIBRARY}/Presets/...) and try again.",
263
+ )
264
+
265
+
266
+ def _unique(items, name, shown):
267
+ matches = {}
268
+ for _, path, item in items:
269
+ if plain(item.name) == name:
270
+ matches.setdefault(_identity(item, path), (path, item))
271
+ if not matches:
272
+ raise CommandError("not_found", f"nothing named {shown!r} in the library")
273
+ # The category views (Instruments, Sounds...) list the Core Library and the
274
+ # packs again, with a FileId in the uri. One FileId item plus same-named items
275
+ # under Packs is one file seen twice.
276
+ filed = [m for key, m in matches.items() if key.startswith("FileId_")]
277
+ rest = [m for key, m in matches.items() if not key.startswith("FileId_")]
278
+ if len(filed) == 1 and all(path.startswith("Packs/") for path, _ in rest):
279
+ return filed[0][1]
280
+ if len(matches) > 1:
281
+ paths = sorted(path for path, _ in matches.values())
282
+ raise CommandError(
283
+ "ambiguous", f"{len(paths)} items are named {shown!r}; give a path: {paths}"
284
+ )
285
+ return next(iter(matches.values()))[1]
286
+
287
+
288
+ def _identity(item, path):
289
+ """The same file shows up in several folders (Instrument Rack/Bass and
290
+ Simpler/Bass); its uri ends with the same FileId."""
291
+ uri = getattr(item, "uri", "") or ""
292
+ if "FileId_" in uri:
293
+ return uri[uri.rindex("FileId_") :]
294
+ return uri or path
295
+
296
+
297
+ def _item_kind(item):
298
+ if item.is_device:
299
+ return "device"
300
+ return "preset" if plain(item.name) != item.name.strip().casefold() else "item"
@@ -0,0 +1,134 @@
1
+ """Addresses for Live objects (see docs/tools.md). Main thread only.
2
+
3
+ t0 first track (numbers from 0)
4
+ rA return track A
5
+ m master track
6
+ t0/d1 second device on t0
7
+ t0/d0/c1/d0 first device in the rack's second chain
8
+ t0/d0/pC1 the Drum Rack pad on note C1 (C3 = MIDI 60, as Live shows it)
9
+ """
10
+
11
+ import re
12
+
13
+ from .server import CommandError
14
+
15
+ NOTE_NAMES = {"C": 0, "D": 2, "E": 4, "F": 5, "G": 7, "A": 9, "B": 11}
16
+ _NOTE = re.compile(r"([A-Ga-g])([#b]?)(-?\d+)")
17
+
18
+
19
+ def note_number(name):
20
+ """MIDI note for a name like C1, F#-1 or Bb3, in Live's octave numbering."""
21
+ match = _NOTE.fullmatch(name)
22
+ if not match:
23
+ raise CommandError("bad_address", f"{name!r} is not a note name like C1 or F#2")
24
+ letter, accidental, octave = match.groups()
25
+ number = (int(octave) + 2) * 12 + NOTE_NAMES[letter.upper()]
26
+ number += {"#": 1, "b": -1, "": 0}[accidental]
27
+ if not 0 <= number <= 127:
28
+ raise CommandError("bad_address", f"note {name} is outside MIDI notes 0-127")
29
+ return number
30
+
31
+
32
+ def note_name(number):
33
+ """Live's name for a MIDI note: 36 -> C1, 61 -> C#3."""
34
+ names = ["C", "C#", "D", "D#", "E", "F", "F#", "G", "G#", "A", "A#", "B"]
35
+ return f"{names[number % 12]}{number // 12 - 2}"
36
+
37
+
38
+ def return_letter(index):
39
+ return chr(ord("A") + index)
40
+
41
+
42
+ def track_path(song, track):
43
+ """The address of a track object: t3, rB or m."""
44
+ for i, t in enumerate(song.tracks):
45
+ if t == track:
46
+ return f"t{i}"
47
+ for i, t in enumerate(song.return_tracks):
48
+ if t == track:
49
+ return "r" + return_letter(i)
50
+ return "m"
51
+
52
+
53
+ def track(song, ref):
54
+ """The track for `t0`, `rA` or `m`."""
55
+ ref = str(ref).strip()
56
+ if ref == "m":
57
+ return song.master_track
58
+ match = re.fullmatch(r"t(\d+)", ref)
59
+ if match:
60
+ tracks = list(song.tracks)
61
+ i = int(match.group(1))
62
+ if i >= len(tracks):
63
+ raise CommandError("not_found", f"no {ref}: {_range('t', len(tracks))}")
64
+ return tracks[i]
65
+ match = re.fullmatch(r"r([A-Za-z])", ref)
66
+ if match:
67
+ returns = list(song.return_tracks)
68
+ i = ord(match.group(1).upper()) - ord("A")
69
+ if i >= len(returns):
70
+ letters = [return_letter(n) for n in range(len(returns))]
71
+ have = "returns " + ", ".join(letters) if letters else "no return tracks"
72
+ raise CommandError("not_found", f"no {ref}: the Set has {have}")
73
+ return returns[i]
74
+ raise CommandError(
75
+ "bad_address", f"{ref!r} is not a track address: use t0, rA or m"
76
+ )
77
+
78
+
79
+ def resolve(song, path):
80
+ """The object at `path` and the path in canonical form.
81
+
82
+ Returns (kind, obj, path) where kind is track, device, chain or pad.
83
+ """
84
+ parts = [p for p in str(path).strip().split("/") if p]
85
+ if not parts:
86
+ raise CommandError("bad_address", "empty address")
87
+ obj = track(song, parts[0])
88
+ kind = "track"
89
+ walked = [parts[0] if parts[0] == "m" else track_path(song, obj)]
90
+ for part in parts[1:]:
91
+ kind, obj = _step(kind, obj, part, "/".join(walked))
92
+ walked.append(part if kind != "pad" else "p" + note_name(obj.note))
93
+ return kind, obj, "/".join(walked)
94
+
95
+
96
+ def _step(kind, obj, part, where):
97
+ if kind in ("track", "chain") and re.fullmatch(r"d\d+", part):
98
+ return "device", _index(list(obj.devices), int(part[1:]), part, where, "d")
99
+ if kind in ("device", "pad") and re.fullmatch(r"c\d+", part):
100
+ chains = list(getattr(obj, "chains", []) or [])
101
+ if kind == "device" and not getattr(obj, "can_have_chains", False):
102
+ raise CommandError("not_found", f"{where} is not a rack: it has no chains")
103
+ return "chain", _index(chains, int(part[1:]), part, where, "c")
104
+ if kind == "device" and part.startswith("p"):
105
+ if not getattr(obj, "can_have_drum_pads", False):
106
+ raise CommandError(
107
+ "not_found", f"{where} is not a Drum Rack: it has no pads"
108
+ )
109
+ number = note_number(part[1:])
110
+ for pad in obj.drum_pads:
111
+ if pad.note == number:
112
+ return "pad", pad
113
+ raise CommandError("not_found", f"{where} has no pad {part}")
114
+ raise CommandError(
115
+ "bad_address",
116
+ f"{part!r} after {where} is not valid: tracks and chains hold devices (d0), "
117
+ "racks hold chains (c0), Drum Racks hold pads (pC1)",
118
+ )
119
+
120
+
121
+ def _index(items, i, part, where, letter):
122
+ if i >= len(items):
123
+ raise CommandError(
124
+ "not_found", f"no {part} in {where}: {_range(letter, len(items))}"
125
+ )
126
+ return items[i]
127
+
128
+
129
+ def _range(letter, count):
130
+ if count == 0:
131
+ return "there are none"
132
+ if count == 1:
133
+ return f"only {letter}0"
134
+ return f"{letter}0-{letter}{count - 1}"
@@ -0,0 +1,170 @@
1
+ """Device parameters as the model should see them: name, display text, raw value."""
2
+
3
+ import math
4
+ import re
5
+
6
+ from .server import CommandError
7
+
8
+ # DeviceParameter.automation_state
9
+ AUTOMATION_STATES = {0: None, 1: "automated", 2: "overridden"}
10
+
11
+
12
+ def describe(param, index=None):
13
+ """One parameter: display text first, then the raw value and its range."""
14
+ out = {}
15
+ if index is not None:
16
+ out["index"] = index
17
+ out["name"] = param.name
18
+ out["display"] = display(param)
19
+ out["value"] = param.value
20
+ if param.is_quantized:
21
+ out["options"] = [str(item) for item in param.value_items]
22
+ else:
23
+ out["min"] = param.min
24
+ out["max"] = param.max
25
+ if not param.is_enabled:
26
+ out["enabled"] = False
27
+ state = AUTOMATION_STATES.get(int(getattr(param, "automation_state", 0)))
28
+ if state:
29
+ out["automation"] = state
30
+ return out
31
+
32
+
33
+ def display(param):
34
+ """The text Live shows for the current value ("262 ms", "-6.0 dB", "On")."""
35
+ try:
36
+ return param.str_for_value(param.value)
37
+ except Exception:
38
+ return str(param)
39
+
40
+
41
+ # --- display text -> raw value ------------------------------------------------
42
+
43
+ # Base unit for each unit Live shows, and the factor to get there.
44
+ UNITS = {
45
+ "hz": ("hz", 1.0),
46
+ "khz": ("hz", 1000.0),
47
+ "ms": ("ms", 1.0),
48
+ "s": ("ms", 1000.0),
49
+ "db": ("db", 1.0),
50
+ "%": ("%", 1.0),
51
+ "": ("", 1.0),
52
+ }
53
+ _NUMBER = re.compile(r"\s*([+-]?(?:\d+(?:\.\d*)?|\.\d+)|-?inf)\s*([a-zA-Z%]*)\s*")
54
+ _PAN = re.compile(r"\s*(\d+(?:\.\d*)?)\s*([LR])\s*|\s*(C)\s*", re.I)
55
+
56
+
57
+ def parse_display(text):
58
+ """("number", base unit) for display text like "22.0 kHz", "-inf dB", "25L", "C".
59
+
60
+ Pan is a number from left (negative) to right. Returns None if not numeric.
61
+ """
62
+ text = str(text)
63
+ pan = _PAN.fullmatch(text)
64
+ if pan:
65
+ if pan.group(3):
66
+ return 0.0, "pan"
67
+ sign = -1.0 if pan.group(2).upper() == "L" else 1.0
68
+ return sign * float(pan.group(1)), "pan"
69
+ match = _NUMBER.fullmatch(text)
70
+ if not match:
71
+ return None
72
+ number = match.group(1).lower()
73
+ unit = match.group(2).lower()
74
+ if unit not in UNITS:
75
+ return None
76
+ base, factor = UNITS[unit]
77
+ value = float("-inf") if number == "-inf" else float(number) * factor
78
+ return value, base
79
+
80
+
81
+ def value_for(param, requested):
82
+ """(raw value, note or None) for a JSON number (raw) or display text.
83
+
84
+ Text is matched against what Live shows: an option name for switches
85
+ ("On", "Sine"), or a number with or without its unit ("400 ms", "-6", "25L").
86
+ Nothing is written here; the caller writes and reads the result back.
87
+ """
88
+ if isinstance(requested, bool):
89
+ if param.is_quantized and len(list(param.value_items)) == 2:
90
+ return (param.max if requested else param.min), None
91
+ requested = "On" if requested else "Off"
92
+ if isinstance(requested, (int, float)):
93
+ raw = float(requested)
94
+ if not math.isfinite(raw):
95
+ raise CommandError("bad_value", "the value must be finite")
96
+ if param.min <= raw <= param.max:
97
+ return raw, None
98
+ # Out of the raw range: most likely meant as shown (-6 for -6 dB, 400 for
99
+ # 400 ms, 30 for 30R), so try it as display text before clamping.
100
+ if not param.is_quantized:
101
+ try:
102
+ return _search(param, f"{raw:g}")
103
+ except CommandError:
104
+ pass
105
+ clamped = min(max(raw, param.min), param.max)
106
+ return clamped, "clamped to the range"
107
+ text = str(requested).strip()
108
+ if param.is_quantized:
109
+ return _option(param, text), None
110
+ return _search(param, text)
111
+
112
+
113
+ def _option(param, text):
114
+ items = [str(item) for item in param.value_items]
115
+ for i, item in enumerate(items):
116
+ if item.strip().casefold() == text.casefold():
117
+ return param.min + i
118
+ if text.lstrip("-").isdigit() and param.min <= int(text) <= param.max:
119
+ return float(int(text))
120
+ raise CommandError("bad_value", f"{text!r} is not an option; options: {items}")
121
+
122
+
123
+ def _search(param, text):
124
+ target = parse_display(text)
125
+ if target is None:
126
+ raise CommandError(
127
+ "bad_value",
128
+ f"{text!r} is not a value like 400 ms, -6 dB, 50 %, 25L or a number",
129
+ )
130
+ want, unit = target
131
+ lo_shown = parse_display(param.str_for_value(param.min))
132
+ hi_shown = parse_display(param.str_for_value(param.max))
133
+ if lo_shown is None or hi_shown is None:
134
+ raise CommandError(
135
+ "bad_value",
136
+ f"{param.name} shows {display(param)!r}; give a raw number from "
137
+ f"{param.min:g} to {param.max:g}",
138
+ )
139
+ shown_unit = lo_shown[1]
140
+ if unit and shown_unit and unit != shown_unit:
141
+ raise CommandError(
142
+ "bad_value",
143
+ f"{param.name} is in {shown_unit or 'plain numbers'}, not {unit}",
144
+ )
145
+ rising = hi_shown[0] >= lo_shown[0]
146
+ lo, hi = param.min, param.max
147
+ low_end, high_end = sorted((lo_shown[0], hi_shown[0]))
148
+ if want <= low_end:
149
+ return (lo if rising else hi), _edge_note(want, low_end)
150
+ if want >= high_end:
151
+ return (hi if rising else lo), _edge_note(want, high_end)
152
+ # Bisect until the floats run out; keep the closest display seen.
153
+ best = (math.inf, param.value)
154
+ while True:
155
+ mid = (lo + hi) / 2
156
+ if mid in (lo, hi):
157
+ break
158
+ shown = parse_display(param.str_for_value(mid))
159
+ if shown is None:
160
+ break
161
+ best = min(best, (abs(shown[0] - want), mid))
162
+ if (shown[0] < want) == rising:
163
+ lo = mid
164
+ else:
165
+ hi = mid
166
+ return best[1], None
167
+
168
+
169
+ def _edge_note(want, edge):
170
+ return None if want == edge else "outside the range: set to the nearest end"