authorgravatar for git@paperclover.netclover caruso <git@paperclover.net> 2026-07-04 21:57:24-07:00
committergravatar for git@paperclover.netclover caruso <git@paperclover.net> 2026-07-05 00:39:04-07:00
logcd9bcc4e3bf81e6d9045629d00f3f033d67874af
treedc1d7fdcbaa27bdf958b8b5278e7e197be5e1bb9
parenteb53fbaf3fc1988fc111b1e36755f557c537ba2d
signaturebadge-check Signed by SSH key SHA256:xbd+BjjhyBfwk7GVoURf9Yx0gzDerHbvYv7SddNWmAs

chore: restructure into creative-toolkit (control/recorder/sequencer)


153 files changed, 30738 insertions(+), 17962 deletions(-)

.gitignore+3
...@@ -1,2 +1,5 @@...@@ -1,2 +1,5 @@
1node_modules1node_modules
2.build2.build
3dist
4*.app
5.DS_Store
bin/exr_flip_z.py deleted-66
...@@ -1,66 +0,0 @@
1# Fusion uses negative Z values for the depth buffer, while Blender denotes
2# this with positive values. For "Depth Merge" and other nodes to work
3# correctly, Blender's output must be flipped. I am unaware of how to do
4# this in Blender itself, hence this simple post processor.
5import OpenEXR
6import Imath
7import numpy as np
8import argparse
9import os
10
11Z_FLIPPED_METADATA_KEY = "zBufferFlipped"
12
13def invert_z_buffer(exr_input_path):
14 exr_file = OpenEXR.InputFile(exr_input_path)
15
16 header = exr_file.header()
17 channels = header['channels'];
18 part_names = channels.keys()
19
20 if Z_FLIPPED_METADATA_KEY in header:
21 print(f"Skipping {exr_input_path}")
22 exr_file.close()
23 return
24
25 processed_parts = {}
26
27 for part_name in part_names:
28 pixel_type = header['channels'][part_name].type
29 if pixel_type == Imath.PixelType(Imath.PixelType.HALF):
30 dtype = np.float16
31 elif pixel_type == Imath.PixelType(Imath.PixelType.FLOAT):
32 dtype = np.float32
33 else:
34 raise ValueError(f"Unsupported pixel type {pixel_type} for channel {part_name}.")
35
36 channel_data = exr_file.channel(part_name, pixel_type)
37 channel_data_array = np.frombuffer(channel_data, dtype=dtype)
38
39 if "Depth.Z" in part_name:
40 channel_data_array = -channel_data_array
41
42 processed_parts[part_name] = channel_data_array.tobytes()
43
44 header[Z_FLIPPED_METADATA_KEY] = 1
45
46 exr_output = OpenEXR.OutputFile(exr_input_path, header)
47
48 exr_output.writePixels(processed_parts)
49
50 exr_file.close()
51 exr_output.close()
52
53 print(f"Processed: {exr_input_path}")
54
55if __name__ == "__main__":
56 parser = argparse.ArgumentParser(description="Invert the Z-buffer in multipart EXR files.")
57
58 parser.add_argument('exr_files', nargs='+', help="List of EXR files to process.")
59
60 args = parser.parse_args()
61
62 for exr_file in args.exr_files:
63 if os.path.exists(exr_file):
64 invert_z_buffer(exr_file)
65 else:
66 print(f"File not found: {exr_file}")
bin/import_quicktime_to_fusion.py deleted-142
...@@ -1,142 +0,0 @@
1#!/usr/bin/env python3
2import subprocess
3import os
4import sys
5import re
6import glob
7import pyperclip
8import time
9
10def run_command(cmd, shell=False):
11 """Run a command and return its output, exit on failure"""
12 try:
13 if shell:
14 result = subprocess.run(cmd, shell=True, check=True, text=True, capture_output=True)
15 else:
16 result = subprocess.run(cmd, check=True, text=True, capture_output=True)
17 return result.stdout.strip()
18 except subprocess.CalledProcessError as e:
19 print(f"Error executing command: {cmd}")
20 print(f"Error message: {e.stderr}")
21 sys.exit(1)
22 except Exception as e:
23 print(f"Unexpected error running command: {e}")
24 sys.exit(1)
25
26def find_file(base_name):
27 """Find a file with the given base name in the specified directory structure"""
28 search_path = "/Volumes/Project/*/Film/**/*"
29
30 try:
31 # Expand the glob pattern to find all matching files
32 matching_files = []
33 for project_dir in glob.glob("/Volumes/Project/*/"):
34 for root, dirs, files in os.walk(os.path.join(project_dir, "Film")):
35 # Skip hidden directories
36 dirs[:] = [d for d in dirs if not d.startswith('.')]
37 for file in files:
38 if file == base_name and not file.startswith('.'):
39 matching_files.append(os.path.join(root, file))
40
41 if not matching_files:
42 print(f"Error: Could not find file '{base_name}' in {search_path}")
43 sys.exit(1)
44 elif len(matching_files) > 1:
45 print(f"Warning: Found multiple matches for '{base_name}'. Using the first one.")
46
47 return matching_files[0]
48 except Exception as e:
49 print(f"Error searching for file: {e}")
50 sys.exit(1)
51
52def activate_app(app_name):
53 """Activate an application by name"""
54 try:
55 script = f'tell application "{app_name}" to activate'
56 subprocess.run(["osascript", "-e", script], check=True)
57 except Exception as e:
58 print(f"Error activating {app_name}: {e}")
59 sys.exit(1)
60
61def main():
62 # Step 1: Run Apple Script to get frame and name from QuickTime Player
63 print("Step 1: Getting frame and name from QuickTime Player...")
64 applescript = '''
65 tell application "QuickTime Player" to tell document 1
66 set t to current time
67 step forward
68 set k to current time
69 set r to 1 / (k - t)
70 step backward
71 return "" & (round (r * t) rounding down) & ":" & name
72 end tell
73 '''
74
75 try:
76 result = subprocess.run(["osascript", "-e", applescript],
77 check=True, text=True, capture_output=True)
78 frame_and_name = result.stdout.strip()
79
80 if not frame_and_name or ":" not in frame_and_name:
81 print("Error: AppleScript did not return expected output")
82 sys.exit(1)
83
84 target_frame, name = frame_and_name.split(":", 1)
85 target_frame = int(target_frame)
86
87 print(f"Target frame: {target_frame}")
88 print(f"File name: {name}")
89 except Exception as e:
90 print(f"Error running AppleScript: {e}")
91 sys.exit(1)
92
93 # Step 2: Find the file on disk
94 print("\nStep 2: Finding file on disk...")
95 file_path = find_file(name)
96 print(f"Found file at: {file_path}")
97
98 # Step 3: Run Fusion script to get current frame
99 print("\nStep 3: Getting current frame from Fusion...")
100 fusion_script_cmd = "'/Applications/Blackmagic Fusion 19/Fusion.app/Contents/Libraries/fuscript' -x 'print(\"[[\"..Fusion().CurrentComp.CurrentTime..\"]]\")'"
101 fusion_output = run_command(fusion_script_cmd, shell=True)
102
103 # Extract the frame number from the output
104 match = re.search(r'\[\[(\d+)\]\]', fusion_output)
105 if not match:
106 print(f"Error: Could not parse frame number from Fusion output: {fusion_output}")
107 sys.exit(1)
108
109 current_frame = int(match.group(1))
110 print(f"Current frame: {current_frame}")
111
112 # Step 4: Compute TRIM_IN and EXTEND_FIRST
113 print("\nStep 4: Computing TRIM_IN and EXTEND_FIRST...")
114 trim_in = 0
115 extend_first = 0
116
117 if target_frame > current_frame:
118 trim_in = target_frame - current_frame
119 print(f"Target frame is AFTER current frame. Setting TRIM_IN to {trim_in}")
120 else:
121 extend_first = current_frame - target_frame
122 print(f"Target frame is BEFORE current frame. Setting EXTEND_FIRST to {extend_first}")
123
124 # Step 5: Create the Fusion loader text and copy to clipboard
125 print("\nStep 5: Creating Fusion loader text and copying to clipboard...")
126 fusion_text = f'''{{Tools = ordered() {{Loader = Loader {{Clips = {{Clip {{ID = "Clip1",Filename = "{file_path}",FormatID = "QuickTimeMovies",Length = 9999999,Multiframe = true,TrimIn = {trim_in},TrimOut = 9999999,ExtendFirst = {extend_first},ExtendLast = 0,Loop = 1,AspectMode = 0,Depth = 0,TimeCode = 0,GlobalStart = 0,GlobalEnd = 9999999}}}},CtrlWZoom = false,Inputs = {{["Gamut.SLogVersion"] = Input {{ Value = FuID {{ "SLog2" }}, }}}},}}}},ActiveTool = "Loader"}}'''
127
128 try:
129 pyperclip.copy(fusion_text)
130 print("Text copied to clipboard:")
131 print(fusion_text)
132 except Exception as e:
133 print(f"Error copying to clipboard: {e}")
134 sys.exit(1)
135
136 # Finally, activate Fusion but don't paste
137 print("\nActivating Fusion...")
138 activate_app("Fusion")
139 print("Script completed successfully!")
140
141if __name__ == "__main__":
142 main()
config/reaper.ts deleted-71
...@@ -1,71 +0,0 @@
1import * as config from "#config";
2import { lucide, mdi, txt } from "@clo/creative-control/icons";
3import { Reaper } from "@clo/creative-control/Reaper";
4
5export default config.forApp(
6 "com.cockos.reaper",
7 ({ keypad, dialpad, mac }) => {
8 const reaper = new Reaper();
9
10 const addInstrument = keypad.menu((menu) => {
11 menu.key("up-left", txt("KK"), () => {
12 mac.toast("Add Komplete Kontrol");
13 reaper.runScript("insert_komplete_kontrol_track");
14 keypad.back();
15 });
16 menu.key("up", txt("AD2"), () => {
17 mac.toast("Add Addictive Drums");
18 reaper.runScript("insert_addictive_drums_track");
19 keypad.back();
20 });
21 menu.key("up-right", txt("Blank"), () => {
22 mac.toast("Blank Track");
23 reaper.runScript("insert_blank_track");
24 keypad.back();
25 });
26 });
27
28 const recording = keypad.overlay((overlay) => {
29 overlay.key("down-left", lucide("Save").fg("green"), () => {
30 reaper.runAction("transport-stop-save-all-recorded-media");
31 });
32 overlay.key("down", lucide("Redo").fg("green"), () => {
33 reaper.runAction("transport-stop-save-all-recorded-media");
34 reaper.runAction("transport-record");
35 });
36 overlay.key("down-right", lucide("Trash").fg("#ff6b6b"), () => {
37 reaper.runAction("transport-stop-delete-all-recorded-media");
38 });
39 });
40
41 keypad.key("up-left", mdi("metronome"), () => {
42 reaper.runAction("options-toggle-metronome");
43 });
44 keypad.key("up", lucide("Clock"), () => reaper.runAction("file-project-settings"));
45 keypad.key("up-right", txt("BPM"), () => {
46 reaper.runAction("tempo-increase-current-project-tempo-01-bpm");
47 });
48 keypad.key("left", lucide("Plus"), () => {
49 addInstrument.open();
50 });
51 keypad.key("down-left", lucide("Mic").fg("red"), () => {
52 reaper.runAction("transport-record");
53 });
54 keypad.key("down-right", lucide("Play"), () => {
55 reaper.runAction("transport-play-stop");
56 });
57
58 reaper.on("transport", (transport) => {
59 recording.active = transport.recording;
60 });
61
62 dialpad.on("rotate", (delta) => {
63 console.log({delta})
64 reaper.runAction(
65 delta > 0
66 ? "tempo-increase-current-project-tempo-0-1-bpm"
67 : "tempo-decrease-current-project-tempo-0-1-bpm",
68 );
69 });
70 },
71);
config/reaper/insert_track.lua deleted-90
...@@ -1,90 +0,0 @@
1local function get_insert_index()
2 local selected = reaper.GetSelectedTrack(0, 0)
3 if not selected then
4 return reaper.CountTracks(0)
5 end
6
7 local track_number = reaper.GetMediaTrackInfo_Value(selected, "IP_TRACKNUMBER")
8 return math.floor(track_number)
9end
10
11local function focus_new_track(track)
12 reaper.SetOnlyTrackSelected(track)
13 reaper.SetMixerScroll(track)
14 reaper.Main_OnCommand(40913, 0) -- Vertical scroll selected tracks into view.
15end
16
17local function set_track_name(track, name)
18 reaper.GetSetMediaTrackInfo_String(track, "P_NAME", name, true)
19end
20
21local function get_action_name(options)
22 if options.action_name and options.action_name ~= "" then
23 return options.action_name
24 end
25
26 if options.track_name and options.track_name ~= "" then
27 return "Add " .. options.track_name
28 end
29
30 return "Add track"
31end
32
33local function insert_track(options)
34 local action_name = get_action_name(options)
35 local fx_name = options.fx_name
36 local needs_midi_input = options.needs_midi_input
37
38 if needs_midi_input == nil then
39 needs_midi_input = fx_name ~= nil and fx_name ~= ""
40 end
41
42 reaper.Undo_BeginBlock()
43 reaper.PreventUIRefresh(1)
44
45 local insert_index = get_insert_index()
46 reaper.InsertTrackAtIndex(insert_index, true)
47
48 local track = reaper.GetTrack(0, insert_index)
49 local fx_index = -1
50 local requested_fx = fx_name ~= nil and fx_name ~= ""
51
52 if track then
53 reaper.SetMediaTrackInfo_Value(track, "I_RECMON", 1)
54
55 if needs_midi_input then
56 reaper.SetMediaTrackInfo_Value(track, "I_RECINPUT", 4096 + 63)
57 end
58
59 if options.track_name and options.track_name ~= "" then
60 set_track_name(track, options.track_name)
61 end
62
63 if requested_fx then
64 fx_index = reaper.TrackFX_AddByName(track, fx_name, false, -1000)
65 if fx_index >= 0 then
66 reaper.TrackFX_Show(track, fx_index, 1)
67 end
68 end
69
70 focus_new_track(track)
71 reaper.TrackList_AdjustWindows(false)
72 reaper.UpdateArrange()
73 end
74
75 reaper.PreventUIRefresh(-1)
76
77 if requested_fx and fx_index < 0 then
78 reaper.Undo_EndBlock(action_name .. " (FX not found)", -1)
79 reaper.ShowMessageBox(
80 'Could not find FX named "' .. fx_name .. '". The track was still created.',
81 action_name,
82 0
83 )
84 return
85 end
86
87 reaper.Undo_EndBlock(action_name, -1)
88end
89
90return insert_track
config/reaper/register_scripts.lua deleted-70
...@@ -1,70 +0,0 @@
1local function current_script_dir()
2 local source = debug.getinfo(1, "S").source
3 local script_path = source:match("^@(.+)$")
4 return script_path:match("^(.*)[/\\].-$")
5end
6
7local function list_action_scripts(path)
8 local files = {}
9 local index = 0
10
11 while true do
12 local file_name = reaper.EnumerateFiles(path, index)
13 if file_name == nil then
14 break
15 end
16
17 if file_name:match("%.lua$") then
18 files[#files + 1] = file_name
19 end
20
21 index = index + 1
22 end
23
24 table.sort(files)
25 return files
26end
27
28local function add_script(path, commit)
29 local command_id = reaper.AddRemoveReaScript(true, 0, path, commit)
30 if command_id == 0 then
31 error("failed to register " .. path)
32 end
33
34 local named = reaper.ReverseNamedCommandLookup(command_id)
35 if named ~= nil and named ~= "" then
36 return "_" .. named
37 end
38
39 return tostring(command_id)
40end
41
42local function remove_script(path, commit)
43 reaper.AddRemoveReaScript(false, 0, path, commit)
44end
45
46local function ext_state_key_for_file(file_name)
47 local stem = file_name:match("^(.*)%.lua$")
48 if stem == nil or stem == "" then
49 error("invalid action file name: " .. file_name)
50 end
51
52 return stem .. "_command_id"
53end
54
55local script_dir = current_script_dir()
56local actions_dir = script_dir .. "/scripts"
57local files = list_action_scripts(actions_dir)
58
59if #files == 0 then
60 error("no action scripts found in " .. actions_dir)
61end
62
63for _, file_name in ipairs(files) do
64 remove_script(actions_dir .. "/" .. file_name, false)
65end
66
67for index, file_name in ipairs(files) do
68 local command_id = add_script(actions_dir .. "/" .. file_name, index == #files)
69 reaper.SetExtState("meow", ext_state_key_for_file(file_name), command_id, true)
70end
config/reaper/scripts/clover_feedback.lua deleted-54
...@@ -1,54 +0,0 @@
1-- Clover live feedback: continuously write the project's tempo and time
2-- signature to state.json (next to this script) so the Clover Node process can
3-- watch the file and update the keypad. Re-schedules itself via reaper.defer,
4-- writing only when a value actually changes.
5--
6-- REAPER's OSC has a tempo token but no time-signature feedback, so we read both
7-- here in one place (reaper.TimeMap_GetTimeSigAtTime returns num, denom, tempo).
8
9-- Avoid stacking multiple defer loops if the script gets launched again (e.g. a
10-- Clover restart while REAPER keeps running).
11if reaper.GetExtState("clover", "feedback") == "1" then
12 return
13end
14reaper.SetExtState("clover", "feedback", "1", false)
15reaper.atexit(function()
16 reaper.SetExtState("clover", "feedback", "", false)
17end)
18
19local source = debug.getinfo(1, "S").source
20local script_path = source:match("^@(.+)$")
21local script_dir = script_path:match("^(.*)[/\\].-$")
22local sep = package.config:sub(1, 1)
23local state_path = script_dir .. sep .. "state.json"
24
25local last = nil
26
27local function snapshot()
28 local position
29 if reaper.GetPlayState() > 0 then
30 position = reaper.GetPlayPosition()
31 else
32 position = reaper.GetCursorPosition()
33 end
34
35 local num, denom, tempo = reaper.TimeMap_GetTimeSigAtTime(0, position)
36 num = math.floor(num + 0.5)
37 denom = math.floor(denom + 0.5)
38 return string.format('{"tempo":%.3f,"timesig":"%d/%d"}', tempo, num, denom)
39end
40
41local function poll()
42 local snap = snapshot()
43 if snap ~= last then
44 last = snap
45 local file = io.open(state_path, "w")
46 if file then
47 file:write(snap)
48 file:close()
49 end
50 end
51 reaper.defer(poll)
52end
53
54poll()
config/reaper/scripts/generate_recorder_template.lua deleted-34
...@@ -1,34 +0,0 @@
1-- Generate the Clover Recorder session template.
2--
3-- Creates a fresh project with a single record-armed MIDI track listening to
4-- "All MIDI Inputs / All Channels" (so any keyboard works), then saves it to
5-- the template path. The recorder copies this per session. Open it in REAPER
6-- afterwards to add your instrument / tweak settings and re-save — it's yours.
7--
8-- Run via: REAPER -nonewinst generate_recorder_template.lua
9
10local template_path = "/Volumes/Documents/Recorder Template.rpp"
11
12-- Work in a fresh project tab so we never disturb whatever is already open.
13reaper.Main_OnCommand(40859, 0) -- New project tab
14
15reaper.InsertTrackAtIndex(0, false)
16local track = reaper.GetTrack(0, 0)
17reaper.GetSetMediaTrackInfo_String(track, "P_NAME", "Improv MIDI", true)
18reaper.SetMediaTrackInfo_Value(track, "I_RECARM", 1)
19-- MIDI record input: 4096 + (device<<5) + channel; device 62 = all MIDI inputs,
20-- channel 0 = omni (all channels). See REAPER API docs for I_RECINPUT.
21reaper.SetMediaTrackInfo_Value(track, "I_RECINPUT", 4096 + (62 << 5))
22reaper.SetMediaTrackInfo_Value(track, "I_RECMON", 1) -- input monitoring on
23
24reaper.Main_SaveProjectEx(0, template_path, 0)
25
26local readback = reaper.GetMediaTrackInfo_Value(track, "I_RECINPUT")
27local log = io.open("/tmp/reaper-template.log", "w")
28if log then
29 log:write(string.format("saved=%s I_RECINPUT=%d\n", template_path, readback))
30 log:close()
31end
32
33-- Close the template tab; leave REAPER as it was.
34reaper.Main_OnCommand(40860, 0) -- Close current project tab
config/reaper/scripts/insert_addictive_drums_track.lua deleted-11
...@@ -1,11 +0,0 @@
1local source = debug.getinfo(1, "S").source
2local script_path = source:match("^@(.+)$")
3local script_dir = script_path:match("^(.*)[/\\].-$")
4local reaper_dir = script_dir:match("^(.*)[/\\].-$")
5
6local insert_track = dofile(reaper_dir .. "/insert_track.lua")
7
8insert_track({
9 fx_name = "AUi: Addictive Drums 2 (XLN Audio)",
10 track_name = "Addictive Drums 2",
11})
config/reaper/scripts/insert_blank_track.lua deleted-11
...@@ -1,11 +0,0 @@
1local source = debug.getinfo(1, "S").source
2local script_path = source:match("^@(.+)$")
3local script_dir = script_path:match("^(.*)[/\\].-$")
4local reaper_dir = script_dir:match("^(.*)[/\\].-$")
5
6local insert_track = dofile(reaper_dir .. "/insert_track.lua")
7
8insert_track({
9 action_name = "Add blank track",
10 needs_midi_input = false,
11})
config/reaper/scripts/insert_komplete_kontrol_track.lua deleted-11
...@@ -1,11 +0,0 @@
1local source = debug.getinfo(1, "S").source
2local script_path = source:match("^@(.+)$")
3local script_dir = script_path:match("^(.*)[/\\].-$")
4local reaper_dir = script_dir:match("^(.*)[/\\].-$")
5
6local insert_track = dofile(reaper_dir .. "/insert_track.lua")
7
8insert_track({
9 fx_name = "AUi: Komplete Kontrol (Native Instruments)",
10 track_name = "Komplete Kontrol",
11})
control/config/reaper.ts created+82
...@@ -0,0 +1,82 @@
1import * as config from "#config";
2import { lucide, mdi, stack, timeSignature, txt } from "@clo/clover-control/icons";
3import { Reaper } from "@clo/clover-control/Reaper";
4import { signal } from "@clo/clover-control/signals";
5
6export default config.forApp(
7 "com.cockos.reaper",
8 ({ keypad, dialpad, mac }) => {
9 const reaper = new Reaper();
10
11 // Live project state — faces that read these re-render themselves on change.
12 const bpm = signal(120);
13 const timeSig = signal("4/4");
14 reaper.on("transport", (t) => {
15 bpm.set(Math.round(t.tempo));
16 timeSig.set(t.timeSignature);
17 });
18
19 const addInstrument = keypad.menu((menu) => {
20 menu.key("up-left", txt("KK"), () => {
21 mac.toast("Add Komplete Kontrol");
22 reaper.runScript("insert_komplete_kontrol_track");
23 keypad.back();
24 });
25 menu.key("up", txt("AD2"), () => {
26 mac.toast("Add Addictive Drums");
27 reaper.runScript("insert_addictive_drums_track");
28 keypad.back();
29 });
30 menu.key("up-right", txt("Blank"), () => {
31 mac.toast("Blank Track");
32 reaper.runScript("insert_blank_track");
33 keypad.back();
34 });
35 });
36
37 const recording = keypad.overlay((overlay) => {
38 overlay.key("down-left", lucide("Save").fg("green"), () => {
39 reaper.runAction("transport-stop-save-all-recorded-media");
40 });
41 overlay.key("down", lucide("Redo").fg("green"), () => {
42 reaper.runAction("transport-stop-save-all-recorded-media");
43 reaper.runAction("transport-record");
44 });
45 overlay.key("down-right", lucide("Trash").fg("#ff6b6b"), () => {
46 reaper.runAction("transport-stop-delete-all-recorded-media");
47 });
48 });
49
50 keypad.key("up-left", mdi("metronome"), () => {
51 reaper.runAction("options-toggle-metronome");
52 });
53 keypad.key("up", () => timeSignature(timeSig()), () => {
54 reaper.runAction("file-project-settings");
55 });
56 keypad.key("up-right", () => stack(bpm(), "BPM"), () => {
57 reaper.runAction("tempo-increase-current-project-tempo-01-bpm");
58 });
59 keypad.key("left", lucide("Plus"), () => {
60 addInstrument.open();
61 });
62 keypad.key("down-left", lucide("Mic").fg("red"), () => {
63 reaper.runAction("transport-record");
64 });
65 keypad.key("down-right", lucide("Play"), () => {
66 reaper.runAction("transport-play-stop");
67 });
68
69 reaper.on("transport", (transport) => {
70 recording.active = transport.recording;
71 });
72
73 dialpad.on("rotate", (delta) => {
74 console.log({delta})
75 reaper.runAction(
76 delta > 0
77 ? "tempo-increase-current-project-tempo-0-1-bpm"
78 : "tempo-decrease-current-project-tempo-0-1-bpm",
79 );
80 });
81 },
82);
control/config/reaper/insert_track.lua created+90
...@@ -0,0 +1,90 @@
1local function get_insert_index()
2 local selected = reaper.GetSelectedTrack(0, 0)
3 if not selected then
4 return reaper.CountTracks(0)
5 end
6
7 local track_number = reaper.GetMediaTrackInfo_Value(selected, "IP_TRACKNUMBER")
8 return math.floor(track_number)
9end
10
11local function focus_new_track(track)
12 reaper.SetOnlyTrackSelected(track)
13 reaper.SetMixerScroll(track)
14 reaper.Main_OnCommand(40913, 0) -- Vertical scroll selected tracks into view.
15end
16
17local function set_track_name(track, name)
18 reaper.GetSetMediaTrackInfo_String(track, "P_NAME", name, true)
19end
20
21local function get_action_name(options)
22 if options.action_name and options.action_name ~= "" then
23 return options.action_name
24 end
25
26 if options.track_name and options.track_name ~= "" then
27 return "Add " .. options.track_name
28 end
29
30 return "Add track"
31end
32
33local function insert_track(options)
34 local action_name = get_action_name(options)
35 local fx_name = options.fx_name
36 local needs_midi_input = options.needs_midi_input
37
38 if needs_midi_input == nil then
39 needs_midi_input = fx_name ~= nil and fx_name ~= ""
40 end
41
42 reaper.Undo_BeginBlock()
43 reaper.PreventUIRefresh(1)
44
45 local insert_index = get_insert_index()
46 reaper.InsertTrackAtIndex(insert_index, true)
47
48 local track = reaper.GetTrack(0, insert_index)
49 local fx_index = -1
50 local requested_fx = fx_name ~= nil and fx_name ~= ""
51
52 if track then
53 reaper.SetMediaTrackInfo_Value(track, "I_RECMON", 1)
54
55 if needs_midi_input then
56 reaper.SetMediaTrackInfo_Value(track, "I_RECINPUT", 4096 + 63)
57 end
58
59 if options.track_name and options.track_name ~= "" then
60 set_track_name(track, options.track_name)
61 end
62
63 if requested_fx then
64 fx_index = reaper.TrackFX_AddByName(track, fx_name, false, -1000)
65 if fx_index >= 0 then
66 reaper.TrackFX_Show(track, fx_index, 1)
67 end
68 end
69
70 focus_new_track(track)
71 reaper.TrackList_AdjustWindows(false)
72 reaper.UpdateArrange()
73 end
74
75 reaper.PreventUIRefresh(-1)
76
77 if requested_fx and fx_index < 0 then
78 reaper.Undo_EndBlock(action_name .. " (FX not found)", -1)
79 reaper.ShowMessageBox(
80 'Could not find FX named "' .. fx_name .. '". The track was still created.',
81 action_name,
82 0
83 )
84 return
85 end
86
87 reaper.Undo_EndBlock(action_name, -1)
88end
89
90return insert_track
control/config/reaper/register_scripts.lua created+70
...@@ -0,0 +1,70 @@
1local function current_script_dir()
2 local source = debug.getinfo(1, "S").source
3 local script_path = source:match("^@(.+)$")
4 return script_path:match("^(.*)[/\\].-$")
5end
6
7local function list_action_scripts(path)
8 local files = {}
9 local index = 0
10
11 while true do
12 local file_name = reaper.EnumerateFiles(path, index)
13 if file_name == nil then
14 break
15 end
16
17 if file_name:match("%.lua$") then
18 files[#files + 1] = file_name
19 end
20
21 index = index + 1
22 end
23
24 table.sort(files)
25 return files
26end
27
28local function add_script(path, commit)
29 local command_id = reaper.AddRemoveReaScript(true, 0, path, commit)
30 if command_id == 0 then
31 error("failed to register " .. path)
32 end
33
34 local named = reaper.ReverseNamedCommandLookup(command_id)
35 if named ~= nil and named ~= "" then
36 return "_" .. named
37 end
38
39 return tostring(command_id)
40end
41
42local function remove_script(path, commit)
43 reaper.AddRemoveReaScript(false, 0, path, commit)
44end
45
46local function ext_state_key_for_file(file_name)
47 local stem = file_name:match("^(.*)%.lua$")
48 if stem == nil or stem == "" then
49 error("invalid action file name: " .. file_name)
50 end
51
52 return stem .. "_command_id"
53end
54
55local script_dir = current_script_dir()
56local actions_dir = script_dir .. "/scripts"
57local files = list_action_scripts(actions_dir)
58
59if #files == 0 then
60 error("no action scripts found in " .. actions_dir)
61end
62
63for _, file_name in ipairs(files) do
64 remove_script(actions_dir .. "/" .. file_name, false)
65end
66
67for index, file_name in ipairs(files) do
68 local command_id = add_script(actions_dir .. "/" .. file_name, index == #files)
69 reaper.SetExtState("meow", ext_state_key_for_file(file_name), command_id, true)
70end
control/config/reaper/scripts/clover_feedback.lua created+54
...@@ -0,0 +1,54 @@
1-- Clover live feedback: continuously write the project's tempo and time
2-- signature to state.json (next to this script) so the Clover Node process can
3-- watch the file and update the keypad. Re-schedules itself via reaper.defer,
4-- writing only when a value actually changes.
5--
6-- REAPER's OSC has a tempo token but no time-signature feedback, so we read both
7-- here in one place (reaper.TimeMap_GetTimeSigAtTime returns num, denom, tempo).
8
9-- Avoid stacking multiple defer loops if the script gets launched again (e.g. a
10-- Clover restart while REAPER keeps running).
11if reaper.GetExtState("clover", "feedback") == "1" then
12 return
13end
14reaper.SetExtState("clover", "feedback", "1", false)
15reaper.atexit(function()
16 reaper.SetExtState("clover", "feedback", "", false)
17end)
18
19local source = debug.getinfo(1, "S").source
20local script_path = source:match("^@(.+)$")
21local script_dir = script_path:match("^(.*)[/\\].-$")
22local sep = package.config:sub(1, 1)
23local state_path = script_dir .. sep .. "state.json"
24
25local last = nil
26
27local function snapshot()
28 local position
29 if reaper.GetPlayState() > 0 then
30 position = reaper.GetPlayPosition()
31 else
32 position = reaper.GetCursorPosition()
33 end
34
35 local num, denom, tempo = reaper.TimeMap_GetTimeSigAtTime(0, position)
36 num = math.floor(num + 0.5)
37 denom = math.floor(denom + 0.5)
38 return string.format('{"tempo":%.3f,"timesig":"%d/%d"}', tempo, num, denom)
39end
40
41local function poll()
42 local snap = snapshot()
43 if snap ~= last then
44 last = snap
45 local file = io.open(state_path, "w")
46 if file then
47 file:write(snap)
48 file:close()
49 end
50 end
51 reaper.defer(poll)
52end
53
54poll()
control/config/reaper/scripts/generate_recorder_template.lua created+34
...@@ -0,0 +1,34 @@
1-- Generate the Clover Recorder session template.
2--
3-- Creates a fresh project with a single record-armed MIDI track listening to
4-- "All MIDI Inputs / All Channels" (so any keyboard works), then saves it to
5-- the template path. The recorder copies this per session. Open it in REAPER
6-- afterwards to add your instrument / tweak settings and re-save — it's yours.
7--
8-- Run via: REAPER -nonewinst generate_recorder_template.lua
9
10local template_path = "/Volumes/Documents/Recorder Template.rpp"
11
12-- Work in a fresh project tab so we never disturb whatever is already open.
13reaper.Main_OnCommand(40859, 0) -- New project tab
14
15reaper.InsertTrackAtIndex(0, false)
16local track = reaper.GetTrack(0, 0)
17reaper.GetSetMediaTrackInfo_String(track, "P_NAME", "Improv MIDI", true)
18reaper.SetMediaTrackInfo_Value(track, "I_RECARM", 1)
19-- MIDI record input: 4096 + (device<<5) + channel; device 62 = all MIDI inputs,
20-- channel 0 = omni (all channels). See REAPER API docs for I_RECINPUT.
21reaper.SetMediaTrackInfo_Value(track, "I_RECINPUT", 4096 + (62 << 5))
22reaper.SetMediaTrackInfo_Value(track, "I_RECMON", 1) -- input monitoring on
23
24reaper.Main_SaveProjectEx(0, template_path, 0)
25
26local readback = reaper.GetMediaTrackInfo_Value(track, "I_RECINPUT")
27local log = io.open("/tmp/reaper-template.log", "w")
28if log then
29 log:write(string.format("saved=%s I_RECINPUT=%d\n", template_path, readback))
30 log:close()
31end
32
33-- Close the template tab; leave REAPER as it was.
34reaper.Main_OnCommand(40860, 0) -- Close current project tab
control/config/reaper/scripts/insert_addictive_drums_track.lua created+11
...@@ -0,0 +1,11 @@
1local source = debug.getinfo(1, "S").source
2local script_path = source:match("^@(.+)$")
3local script_dir = script_path:match("^(.*)[/\\].-$")
4local reaper_dir = script_dir:match("^(.*)[/\\].-$")
5
6local insert_track = dofile(reaper_dir .. "/insert_track.lua")
7
8insert_track({
9 fx_name = "AUi: Addictive Drums 2 (XLN Audio)",
10 track_name = "Addictive Drums 2",
11})
control/config/reaper/scripts/insert_blank_track.lua created+11
...@@ -0,0 +1,11 @@
1local source = debug.getinfo(1, "S").source
2local script_path = source:match("^@(.+)$")
3local script_dir = script_path:match("^(.*)[/\\].-$")
4local reaper_dir = script_dir:match("^(.*)[/\\].-$")
5
6local insert_track = dofile(reaper_dir .. "/insert_track.lua")
7
8insert_track({
9 action_name = "Add blank track",
10 needs_midi_input = false,
11})
control/config/reaper/scripts/insert_komplete_kontrol_track.lua created+11
...@@ -0,0 +1,11 @@
1local source = debug.getinfo(1, "S").source
2local script_path = source:match("^@(.+)$")
3local script_dir = script_path:match("^(.*)[/\\].-$")
4local reaper_dir = script_dir:match("^(.*)[/\\].-$")
5
6local insert_track = dofile(reaper_dir .. "/insert_track.lua")
7
8insert_track({
9 fx_name = "AUi: Komplete Kontrol (Native Instruments)",
10 track_name = "Komplete Kontrol",
11})
control/docs/speed-editor.jpg
Binary files /dev/null and b/control/docs/speed-editor.jpg differ
control/examples/dialpad.ts created+14
...@@ -0,0 +1,14 @@
1// Listen to the dialpad. NOTE: until the OS-seize phase, turning the dial also
2// scrolls macOS and the buttons act as mouse buttons.
3import { Dialpad } from "../src/Dialpad.ts";
4
5const dialpad = await Dialpad.open();
6console.info(dialpad.connected ? "Dialpad connected" : "Waiting for dialpad…");
7
8dialpad.on("connect", () => console.info("connect"));
9dialpad.on("disconnect", () => console.info("disconnect"));
10dialpad.on("rotate", (delta) => console.info("rotate", delta));
11dialpad.on("spin", (delta) => console.info("spin", delta));
12dialpad.on("keydown", (button) => console.info("down", button));
13dialpad.on("keyup", (button) => console.info("up", button));
14dialpad.onPress("circle", () => console.info("circle pressed!"));
control/examples/enumerate-hid.ts created+43
...@@ -0,0 +1,43 @@
1// Phase 0: list HID devices so we can identify the MX Creative Console halves.
2// Keypad is expected as Elgato (0x0fd9); the Bluetooth dialpad is likely
3// Logitech (0x046d) speaking HID++.
4const { devices } = await import("node-hid");
5
6const VENDOR_NAMES: Record<number, string> = {
7 0x046d: "Logitech",
8 0x0fd9: "Elgato",
9 0x05ac: "Apple",
10};
11
12const hex = (n: number | undefined, width = 4) => "0x" + (n ?? 0).toString(16).padStart(width, "0");
13
14const all = devices();
15
16const format = (d: import("node-hid").Device) =>
17 [
18 `${hex(d.vendorId)}:${hex(d.productId)}`,
19 (VENDOR_NAMES[d.vendorId] ?? "?").padEnd(8),
20 `usage=${hex(d.usagePage)}/${hex(d.usage)}`,
21 `iface=${d.interface}`,
22 `| ${d.manufacturer ?? ""} ${d.product ?? ""}`.trim(),
23 d.path ? `\n path=${d.path}` : "",
24 ].join(" ");
25
26const interesting = all.filter(
27 (d) => d.vendorId === 0x046d || d.vendorId === 0x0fd9,
28);
29
30console.info(`Total HID devices: ${all.length}`);
31console.info(`\n=== Logitech (0x046d) + Elgato (0x0fd9) ===`);
32if (interesting.length === 0) {
33 console.info(" (none found — dialpad may not surface as a HID device)");
34} else {
35 for (const d of interesting) console.info(" " + format(d));
36}
37
38console.info(`\n=== All vendors present ===`);
39const byVendor = new Map<number, number>();
40for (const d of all) byVendor.set(d.vendorId, (byVendor.get(d.vendorId) ?? 0) + 1);
41for (const [vid, count] of [...byVendor].sort((a, b) => b[1] - a[1])) {
42 console.info(` ${hex(vid)} ${(VENDOR_NAMES[vid] ?? "").padEnd(8)} ${count}`);
43}
control/examples/event-listener.ts created+24
...@@ -0,0 +1,24 @@
1import { Mac } from "../src/Mac.ts";
2import { SpeedEditor } from "../src/SpeedEditor.ts";
3
4const editor = await SpeedEditor.open();
5const mac = await Mac.open();
6
7editor.on("jog", (ev) => {
8 console.info(ev);
9});
10editor.on("keypress", (key) => {
11 console.info(`Press ${key}`);
12
13 if (key === "smartInsert") {
14 mac.focusApp("com.google.Chrome");
15 }
16});
17editor.onDoublePress("transition", () => {
18 console.info("(double press) \"Title\"");
19});
20
21console.info("init");
22mac.on("app-change", (bundle) => {
23 console.info(`Switch to ${bundle}`);
24});
control/examples/face-preview.ts created+26
...@@ -0,0 +1,26 @@
1// Render key faces to PNGs (no hardware needed) so you can eyeball them before
2// pushing to the device. Writes to /tmp/face-preview/.
3import { mkdirSync, writeFileSync } from "node:fs";
4import sharp from "sharp";
5import { blank, lucide, mdi, txt } from "../src/icons.ts";
6
7const faces: Record<string, string> = {
8 blank: blank.svg,
9 metronome: mdi("metronome").svg,
10 "time-sig": lucide("Clock").svg,
11 bpm: txt("BPM").svg,
12 "add-instrument": lucide("Plus").svg,
13 record: lucide("Disc").fg("red").svg,
14 play: lucide("Play").svg,
15 save: lucide("Save").fg("green").svg,
16};
17
18const outDir = "/tmp/face-preview";
19mkdirSync(outDir, { recursive: true });
20
21for (const [name, face] of Object.entries(faces)) {
22 const png = await sharp(Buffer.from(face)).resize(118, 118).png().toBuffer();
23 const file = `${outDir}/${name}.png`;
24 writeFileSync(file, png);
25 console.info(`${name.padEnd(16)} ${png.length} bytes ${file}`);
26}
control/examples/hid-sniff.ts created+67
...@@ -0,0 +1,67 @@
1// Phase 0 sniffer: open an MX Creative Console half by product-name match and
2// log every HID input report. Operate the control you want to map and watch the
3// report id + bytes. Logitech HID++ events arrive as report id 0x10 (short, 7B)
4// or 0x11 (long, 20B). Default mouse/consumer reports use other ids.
5//
6// node examples/hid-sniff.ts [name-substring] [seconds]
7// node examples/hid-sniff.ts dialpad 25
8const { devices, HID } = await import("node-hid");
9
10const match = (process.argv[2] ?? "dialpad").toLowerCase();
11const durationSec = Number(process.argv[3] ?? 0);
12
13const hex = (n: number, w = 2) => n.toString(16).padStart(w, "0");
14
15const dev = devices().find(
16 (d) => (d.product ?? "").toLowerCase().includes(match) && d.path,
17);
18if (!dev?.path) {
19 console.error(
20 `No HID device matching "${match}". Run examples/enumerate-hid.ts to list.`,
21 );
22 process.exit(1);
23}
24
25console.info(
26 `Opening ${dev.product} 0x${hex(dev.vendorId, 4)}:0x${hex(dev.productId, 4)}\n path=${dev.path}`,
27);
28
29let device: import("node-hid").HID;
30try {
31 device = new HID(dev.path);
32} catch (error) {
33 console.error(
34 "Failed to open device. On macOS, grant the terminal/node Input Monitoring\n"
35 + "(System Settings > Privacy & Security > Input Monitoring), then retry.\n",
36 error,
37 );
38 process.exit(1);
39}
40
41const t0 = Date.now();
42let count = 0;
43device.on("data", (buf: Buffer) => {
44 const bytes = [...buf];
45 const id = bytes[0];
46 const kind = id === 0x11
47 ? "hid++ long "
48 : id === 0x10
49 ? "hid++ short"
50 : "report ";
51 const ms = String(Date.now() - t0).padStart(6);
52 console.info(
53 `+${ms}ms ${kind} id=0x${hex(id)} ${bytes.map((b) => hex(b)).join(" ")}`,
54 );
55 count += 1;
56});
57device.on("error", (error) => console.error("device error:", error));
58
59console.info("Listening — operate the dial / knob / buttons. Ctrl-C to stop.\n");
60
61if (durationSec > 0) {
62 setTimeout(() => {
63 console.info(`\nCaptured ${count} reports in ${durationSec}s. Closing.`);
64 device.close();
65 process.exit(0);
66 }, durationSec * 1000);
67}
control/examples/keypad-demo.ts created+30
...@@ -0,0 +1,30 @@
1// Push the Reaper root layout to the physical keypad as one atomic panel write
2// and exit (faces persist on the device). Validates the panel image protocol.
3import { Keypad } from "../src/Keypad.ts";
4import { composePanel, type Face } from "../src/KeypadUI.ts";
5import { blank, lucide, txt } from "../src/icons.ts";
6
7const keypad = await Keypad.open();
8if (!keypad.connected) {
9 console.error("Keypad not connected.");
10 process.exit(1);
11}
12
13keypad.setBrightness(0.85);
14
15// Faces in grid order: up-left, up, up-right, left, center, right, down-*.
16const faces: Face[] = [
17 lucide("AlarmClock"),
18 lucide("Clock"),
19 txt("BPM"),
20 lucide("Plus"),
21 blank,
22 blank,
23 lucide("Disc").fg("red"),
24 blank,
25 lucide("Play"),
26];
27
28keypad.setPanel(await composePanel(faces));
29console.info("Pushed the panel to the keypad — look at the device.");
30process.exit(0);
control/examples/toast.ts created+25
...@@ -0,0 +1,25 @@
1import { Mac } from "../src/Mac.ts";
2
3const mac = await Mac.open();
4mac.on("error", (error) => {
5 console.error(error);
6});
7
8mac.toast("Insert AD2 Track", {
9 detail: "Loading Addictive Drums 2...",
10 durationMs: 900,
11});
12
13await new Promise((resolve) => {
14 setTimeout(resolve, 1300);
15});
16
17mac.toast("Insert Blank Track", {
18 durationMs: 800,
19});
20
21await new Promise((resolve) => {
22 setTimeout(resolve, 1200);
23});
24
25mac.close();
control/package.json created+41
...@@ -0,0 +1,41 @@
1{
2 "name": "@clo/clover-control",
3 "version": "1.0.0",
4 "type": "module",
5 "license": "ISC",
6 "packageManager": "pnpm@10.26.1",
7 "scripts": {
8 "start": "node --watch src/main.ts",
9 "generate:reaper-actions": "node src/Reaper/generate-actions.ts"
10 },
11 "dependencies": {
12 "@clo/lib": "jsr:^3.0.0",
13 "@mdi/svg": "^7.4.47",
14 "@types/node": "^25.5.0",
15 "lucide-static": "^1.21.0",
16 "mdi-ts": "^1.0.3",
17 "node-hid": "^3.3.0",
18 "sharp": "^0.35.2",
19 "usb": "^2.17.0"
20 },
21 "imports": {
22 "#config": "./src/config.ts"
23 },
24 "exports": {
25 "./Mac": "./src/Mac.ts",
26 "./SpeedEditor": "./src/SpeedEditor.ts",
27 "./Keypad": "./src/Keypad.ts",
28 "./KeypadUI": "./src/KeypadUI.ts",
29 "./icons": "./src/icons.ts",
30 "./signals": "./src/signals.ts",
31 "./Dialpad": "./src/Dialpad.ts",
32 "./Reaper": "./src/Reaper.ts",
33 "./Reaper/actions": "./src/Reaper/actions.ts"
34 },
35 "pnpm": {
36 "onlyBuiltDependencies": [
37 "node-hid",
38 "usb"
39 ]
40 }
41}
control/pnpm-lock.yaml created+563
...@@ -0,0 +1,563 @@
1lockfileVersion: '9.0'
2
3settings:
4 autoInstallPeers: true
5 excludeLinksFromLockfile: false
6
7importers:
8
9 .:
10 dependencies:
11 '@clo/lib':
12 specifier: jsr:^3.0.0
13 version: '@jsr/clo__lib@3.0.0'
14 '@mdi/svg':
15 specifier: ^7.4.47
16 version: 7.4.47
17 '@types/node':
18 specifier: ^25.5.0
19 version: 25.5.0
20 lucide-static:
21 specifier: ^1.21.0
22 version: 1.21.0
23 mdi-ts:
24 specifier: ^1.0.3
25 version: 1.0.3
26 node-hid:
27 specifier: ^3.3.0
28 version: 3.3.0
29 sharp:
30 specifier: ^0.35.2
31 version: 0.35.2
32 usb:
33 specifier: ^2.17.0
34 version: 2.17.0
35
36packages:
37
38 '@emnapi/runtime@1.11.1':
39 resolution: {integrity: sha512-vgj7R3y3Wgx24IQaGPA/R6YFXLHVMOZ0uVEyIQPaWs+rd1AzfEMXlAC22FYwO1XkKR6NPsq7mUandH8oIRdZFw==}
40
41 '@img/colour@1.1.0':
42 resolution: {integrity: sha512-Td76q7j57o/tLVdgS746cYARfSyxk8iEfRxewL9h4OMzYhbW4TAcppl0mT4eyqXddh6L/jwoM75mo7ixa/pCeQ==}
43 engines: {node: '>=18'}
44
45 '@img/sharp-darwin-arm64@0.35.2':
46 resolution: {integrity: sha512-eEieHsMksAW4IiO5NzauESRl2D2qz3J/kwUxUrSfV06A93eEaRfMpHXyUb1mAqrR7i8U9A0GRqE9pjn6u1Jjpg==}
47 engines: {node: '>=20.9.0'}
48 cpu: [arm64]
49 os: [darwin]
50
51 '@img/sharp-darwin-x64@0.35.2':
52 resolution: {integrity: sha512-BaktuGPCeHJMARpodR8jK4uKiZrPAy9WrfQW0sdI37clracq8Bp01AYS3SZgi5FS/y5twa9t4+LIuuxQjqRrWw==}
53 engines: {node: '>=20.9.0'}
54 cpu: [x64]
55 os: [darwin]
56
57 '@img/sharp-freebsd-wasm32@0.35.2':
58 resolution: {integrity: sha512-YoAxdnd8hPUkvLHd3bWY+YA8nw3xM/RyRopYucNsWHVSan8NLVM3X2volsfoRDcXdUJPg6tXahSd7HXPK7lRnw==}
59 engines: {node: '>=20.9.0'}
60 os: [freebsd]
61
62 '@img/sharp-libvips-darwin-arm64@1.3.1':
63 resolution: {integrity: sha512-4V/M3roRMTYjiwZY9IOVQOE8OyeCxFAkYmyZDrZl51uOKjibm3oeEJ4WAmLxutAfzFbC9jqUiPs2gbnGflH+7g==}
64 cpu: [arm64]
65 os: [darwin]
66
67 '@img/sharp-libvips-darwin-x64@1.3.1':
68 resolution: {integrity: sha512-c0/DxItpJv2+dGhgycJBBgotdqruGYDvA79drdh0MD1dFpy7JzJ/PlXwi1H4rFf0eTy8tgbI91aHDnZIceY3jQ==}
69 cpu: [x64]
70 os: [darwin]
71
72 '@img/sharp-libvips-linux-arm64@1.3.1':
73 resolution: {integrity: sha512-JznefmcK9j1JKPz8AkQDh89kjojubyfOasWBPKfzMIhPwsgDy9evpE/naJTXXXmghS1iFwR8u/kTwh/I2/+GCw==}
74 cpu: [arm64]
75 os: [linux]
76
77 '@img/sharp-libvips-linux-arm@1.3.1':
78 resolution: {integrity: sha512-aGGy9aWzXgHBG7HNyQPWorZthlp7+x6fDRoPAQbGO3ThcttuTyKIx3NuSHb6zb4gBNq6/yNn9f1cy9nFKS/Vmg==}
79 cpu: [arm]
80 os: [linux]
81
82 '@img/sharp-libvips-linux-ppc64@1.3.1':
83 resolution: {integrity: sha512-1EkwGNCZk6iWNCMWqrvdJ+r1j0PT1zIz60CNPhYnJlK/zyeWqlsPZIe+ocBVqPF8k/Ssee/NCk+tE9Ryrko6ng==}
84 cpu: [ppc64]
85 os: [linux]
86
87 '@img/sharp-libvips-linux-riscv64@1.3.1':
88 resolution: {integrity: sha512-Ilays+w2bXdnxzxtQdmXR62u8o8GYa3eL4+Gr+1KiE4xperMZUslRaVPJwwPkzlHEjGfXAfRVAa/7CYCtSqsBw==}
89 cpu: [riscv64]
90 os: [linux]
91
92 '@img/sharp-libvips-linux-s390x@1.3.1':
93 resolution: {integrity: sha512-VfBwVHQTbRoj4XlpA/KLZ7ltgMpz+4WSejFzQ+GnoImjo1PtEJ59QB2qR1xQEeRPYIkNrPIm2L4cICMvz4C2ew==}
94 cpu: [s390x]
95 os: [linux]
96
97 '@img/sharp-libvips-linux-x64@1.3.1':
98 resolution: {integrity: sha512-+c8ukgwU62DS54nCAjw7keOfHUkmr0B5QHEdcOqRnodF/MNXJbVI8Eopoj4B/0H8Asr65I+A4Amrn7a85/md6A==}
99 cpu: [x64]
100 os: [linux]
101
102 '@img/sharp-libvips-linuxmusl-arm64@1.3.1':
103 resolution: {integrity: sha512-qlKb/pwbkAi1WMsJrYHk7CuDrd12s27U2QnRhFYUoJNrRCmkosMTttuRFat/DDB3IlDm5qE1TJgZ4JDnHX8Ldw==}
104 cpu: [arm64]
105 os: [linux]
106
107 '@img/sharp-libvips-linuxmusl-x64@1.3.1':
108 resolution: {integrity: sha512-yO21HwoUVLN8Qa+/SBjQLMYwBWAVJjeGPNe+hc0OUeMeifEtJqu5a1c4HayE1nNpDih9y3/KkoltfkDodmKAlg==}
109 cpu: [x64]
110 os: [linux]
111
112 '@img/sharp-linux-arm64@0.35.2':
113 resolution: {integrity: sha512-af12Pnd0ZGu2HfP8NayB0kk6eC/lrfbQE6HlR4jD+34wdJ1Vw9TF6TMn6ZvffT+WgqVsl0hRbmNvz2u/23VmwA==}
114 engines: {node: '>=20.9.0'}
115 cpu: [arm64]
116 os: [linux]
117
118 '@img/sharp-linux-arm@0.35.2':
119 resolution: {integrity: sha512-SE4kzF2mepn6z+6E7L6lsV8FzuLL6IPQdyX8ZiwROAG/G8td+hP/m7FsFPwidtrF19gvajuC9l6TxAVcsA4S7A==}
120 engines: {node: '>=20.9.0'}
121 cpu: [arm]
122 os: [linux]
123
124 '@img/sharp-linux-ppc64@0.35.2':
125 resolution: {integrity: sha512-hYSBm7zcNtDCozCxQHYZJiu63b/bXsgRZuOxCIBZsStMM9Vap47iFHdbX4kCvQsblPB/k+clhELpdQJHQLSHvg==}
126 engines: {node: '>=20.9.0'}
127 cpu: [ppc64]
128 os: [linux]
129
130 '@img/sharp-linux-riscv64@0.35.2':
131 resolution: {integrity: sha512-qQt0Kc13+Hoan/Awq/qMSQw3L+RI1NCRPgD5cUJ/1WSSmIoysLOc72jlRM3E0OHN9Yr313jgeQ2T+zW+F03QFA==}
132 engines: {node: '>=20.9.0'}
133 cpu: [riscv64]
134 os: [linux]
135
136 '@img/sharp-linux-s390x@0.35.2':
137 resolution: {integrity: sha512-E4fLLfRPzDLlEeDaTzI98OFLcv++WL5ChLLMwPoVd0CIoZQqupBSNbOisPL5am9XsbQ9T84+iiMpUvbFtkunbA==}
138 engines: {node: '>=20.9.0'}
139 cpu: [s390x]
140 os: [linux]
141
142 '@img/sharp-linux-x64@0.35.2':
143 resolution: {integrity: sha512-gi0zFJJRLswfCZmHtJdikXPOc5u7qamSOS3NHedLqLd4W8Q0NqjdBr6TTRIgsfFjqfTsHFgdfvJ9LwqSgcHiAA==}
144 engines: {node: '>=20.9.0'}
145 cpu: [x64]
146 os: [linux]
147
148 '@img/sharp-linuxmusl-arm64@0.35.2':
149 resolution: {integrity: sha512-siWbOW1u6HFnFLrp0waKyW7VEf7jYvcDWdrXEFa8AkdAQgEvuu5Fz8/Y70w9EeqAdwDtfU012BhEHHaDqvQNzg==}
150 engines: {node: '>=20.9.0'}
151 cpu: [arm64]
152 os: [linux]
153
154 '@img/sharp-linuxmusl-x64@0.35.2':
155 resolution: {integrity: sha512-YBqMMcjDi4QGYiSn4vNOYBhmlC4z5AXqkOUUqI2e0AFA4urNv4ESgOgwNl3K+4etQhha0twXlzeF20bbULm9Yg==}
156 engines: {node: '>=20.9.0'}
157 cpu: [x64]
158 os: [linux]
159
160 '@img/sharp-wasm32@0.35.2':
161 resolution: {integrity: sha512-Mrv4JQNYVQ94xH+jzZ9r+gowleN8mv2FTgKT+PI6bx5C0G8TdNYndu161pg2i7uoBwxy2ImPMHrJOM2LZef7Bw==}
162 engines: {node: '>=20.9.0'}
163
164 '@img/sharp-webcontainers-wasm32@0.35.2':
165 resolution: {integrity: sha512-QNV27pxs9wpApEiCfvHM1RDoP1w1+2KrUWWDPEhEwg+latvOrfuhWrHWZKwdSFwU6jh3myjw/yOCRsUIuOft3g==}
166 engines: {node: '>=20.9.0'}
167 cpu: [wasm32]
168
169 '@img/sharp-win32-arm64@0.35.2':
170 resolution: {integrity: sha512-BiVRYc/t6/Vl3e1hBx0hugG4oN9Pydf4fgMSpxTQJmwGUg/YoXTWHiFeRymHfCZzifxu4F4rpk/I67D0LQ20wQ==}
171 engines: {node: '>=20.9.0'}
172 cpu: [arm64]
173 os: [win32]
174
175 '@img/sharp-win32-ia32@0.35.2':
176 resolution: {integrity: sha512-YYEhx9PImCC7T0tI8JDMi4DB9LwLCXCU5OWNYEXAxh5Q1ShKkyC6byxzoBJ3gEFDnH2lQckWuDe70G7mB2XJog==}
177 engines: {node: ^20.9.0}
178 cpu: [ia32]
179 os: [win32]
180
181 '@img/sharp-win32-x64@0.35.2':
182 resolution: {integrity: sha512-imoOyBcoM/iiUr4J6VPpCNjPnjvP/Gks95898yB8YqoGGYmHYbOyCuNv9FMhFgtaiHFGbHW8bxKqRV6VjtXThQ==}
183 engines: {node: '>=20.9.0'}
184 cpu: [x64]
185 os: [win32]
186
187 '@jsr/clo__lib@3.0.0':
188 resolution: {integrity: sha512-oseZwHCAcXNPbqnGZ37l7+wAoj6ikIXE1VM0s6eD6fz4DcgM030Slf0T7Lgtn7fIdas5hlfx4JF54TR+vo4THw==, tarball: https://npm.jsr.io/~/11/@jsr/clo__lib/3.0.0.tgz}
189
190 '@mdi/svg@7.4.47':
191 resolution: {integrity: sha512-WQ2gDll12T9WD34fdRFgQVgO8bag3gavrAgJ0frN4phlwdJARpE6gO1YvLEMJR0KKgoc+/Ea/A0Pp11I00xBvw==}
192
193 '@types/node@25.5.0':
194 resolution: {integrity: sha512-jp2P3tQMSxWugkCUKLRPVUpGaL5MVFwF8RDuSRztfwgN1wmqJeMSbKlnEtQqU8UrhTmzEmZdu2I6v2dpp7XIxw==}
195
196 '@types/w3c-web-usb@1.0.13':
197 resolution: {integrity: sha512-N2nSl3Xsx8mRHZBvMSdNGtzMyeleTvtlEw+ujujgXalPqOjIA6UtrqcB6OzyUjkTbDm3J7P1RNK1lgoO7jxtsw==}
198
199 ansi-regex@5.0.1:
200 resolution: {integrity: sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==}
201 engines: {node: '>=8'}
202
203 ansi-styles@4.3.0:
204 resolution: {integrity: sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==}
205 engines: {node: '>=8'}
206
207 cliui@8.0.1:
208 resolution: {integrity: sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==}
209 engines: {node: '>=12'}
210
211 color-convert@2.0.1:
212 resolution: {integrity: sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==}
213 engines: {node: '>=7.0.0'}
214
215 color-name@1.1.4:
216 resolution: {integrity: sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==}
217
218 detect-libc@2.1.2:
219 resolution: {integrity: sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==}
220 engines: {node: '>=8'}
221
222 emoji-regex@8.0.0:
223 resolution: {integrity: sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==}
224
225 escalade@3.2.0:
226 resolution: {integrity: sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==}
227 engines: {node: '>=6'}
228
229 get-caller-file@2.0.5:
230 resolution: {integrity: sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==}
231 engines: {node: 6.* || 8.* || >= 10.*}
232
233 is-fullwidth-code-point@3.0.0:
234 resolution: {integrity: sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==}
235 engines: {node: '>=8'}
236
237 linq@4.0.3:
238 resolution: {integrity: sha512-dP0w2ERJXfVUk6VmmAK+Tz/SxFHwyY7VM6Mrq4fnJmeQf9JNEYFH6qJfV6Qn0N91mfwz2GEE/4S+RDkmDNyUJw==}
239
240 lucide-static@1.21.0:
241 resolution: {integrity: sha512-6248z2/4sEyKkYAPPUYxOPiB2RCfMmLdMHuoOhsTFnoD40ixAoHmTVhOPux8ADa1NTBmzpEKF7WNePm+Ms503Q==}
242
243 mdi-ts@1.0.3:
244 resolution: {integrity: sha512-wtVNYoCkvyYuTJ8osV5af6jfsE5o+UT1sAnaPVjAZiIXHVFpP2l66JzdAdNClnLfCk7g5wu8cWHrr/9ru1V8vQ==}
245
246 node-addon-api@3.2.1:
247 resolution: {integrity: sha512-mmcei9JghVNDYydghQmeDX8KoAm0FAiYyIcUt/N4nhyAipB17pllZQDOJD2fotxABnt4Mdz+dKTO7eftLg4d0A==}
248
249 node-addon-api@8.6.0:
250 resolution: {integrity: sha512-gBVjCaqDlRUk0EwoPNKzIr9KkS9041G/q31IBShPs1Xz6UTA+EXdZADbzqAJQrpDRq71CIMnOP5VMut3SL0z5Q==}
251 engines: {node: ^18 || ^20 || >= 21}
252
253 node-gyp-build@4.8.4:
254 resolution: {integrity: sha512-LA4ZjwlnUblHVgq0oBF3Jl/6h/Nvs5fzBLwdEF4nuxnFdsfajde4WfxtJr3CaiH+F6ewcIB/q4jQ4UzPyid+CQ==}
255 hasBin: true
256
257 node-hid@3.3.0:
258 resolution: {integrity: sha512-j+dFgJLRAE0nufQKXk3IfS6T6YuHhCgMvz4TrG0sgtb6DSCdYpfJ1etcdmeCmPQjUgO+yo32ktVrRliNs/+fmg==}
259 engines: {node: '>=10.16'}
260 hasBin: true
261
262 pkg-prebuilds@1.0.0:
263 resolution: {integrity: sha512-D9wlkXZCmjxj2kBHTw3fGSyjoahr33breGBoJcoezpi7ouYS59DJVOHMZ+dgqacSrZiJo4qtkXxLQTE+BqXJmQ==}
264 engines: {node: '>= 14.15.0'}
265 hasBin: true
266
267 require-directory@2.1.1:
268 resolution: {integrity: sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==}
269 engines: {node: '>=0.10.0'}
270
271 semver@7.8.5:
272 resolution: {integrity: sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==}
273 engines: {node: '>=10'}
274 hasBin: true
275
276 sharp@0.35.2:
277 resolution: {integrity: sha512-FVtFjtBCMiJS6yb5CX7Sop45WFMpeGw6oRKuJnXYgf/f1ms/D7LE/ZUSNxnW7rZ/dbslQWYkoqFHGPaDBtaK4w==}
278 engines: {node: '>=20.9.0'}
279
280 string-width@4.2.3:
281 resolution: {integrity: sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==}
282 engines: {node: '>=8'}
283
284 strip-ansi@6.0.1:
285 resolution: {integrity: sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==}
286 engines: {node: '>=8'}
287
288 tslib@2.8.1:
289 resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==}
290
291 undici-types@7.18.2:
292 resolution: {integrity: sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w==}
293
294 usb@2.17.0:
295 resolution: {integrity: sha512-UuFgrlglgDn5ll6d5l7kl3nDb2Yx43qLUGcDq+7UNLZLtbNug0HZBb2Xodhgx2JZB1LqvU+dOGqLEeYUeZqsHg==}
296 engines: {node: '>=12.22.0 <13.0 || >=14.17.0'}
297
298 wrap-ansi@7.0.0:
299 resolution: {integrity: sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==}
300 engines: {node: '>=10'}
301
302 y18n@5.0.8:
303 resolution: {integrity: sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==}
304 engines: {node: '>=10'}
305
306 yargs-parser@21.1.1:
307 resolution: {integrity: sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==}
308 engines: {node: '>=12'}
309
310 yargs@17.7.2:
311 resolution: {integrity: sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w==}
312 engines: {node: '>=12'}
313
314snapshots:
315
316 '@emnapi/runtime@1.11.1':
317 dependencies:
318 tslib: 2.8.1
319 optional: true
320
321 '@img/colour@1.1.0': {}
322
323 '@img/sharp-darwin-arm64@0.35.2':
324 optionalDependencies:
325 '@img/sharp-libvips-darwin-arm64': 1.3.1
326 optional: true
327
328 '@img/sharp-darwin-x64@0.35.2':
329 optionalDependencies:
330 '@img/sharp-libvips-darwin-x64': 1.3.1
331 optional: true
332
333 '@img/sharp-freebsd-wasm32@0.35.2':
334 dependencies:
335 '@img/sharp-wasm32': 0.35.2
336 optional: true
337
338 '@img/sharp-libvips-darwin-arm64@1.3.1':
339 optional: true
340
341 '@img/sharp-libvips-darwin-x64@1.3.1':
342 optional: true
343
344 '@img/sharp-libvips-linux-arm64@1.3.1':
345 optional: true
346
347 '@img/sharp-libvips-linux-arm@1.3.1':
348 optional: true
349
350 '@img/sharp-libvips-linux-ppc64@1.3.1':
351 optional: true
352
353 '@img/sharp-libvips-linux-riscv64@1.3.1':
354 optional: true
355
356 '@img/sharp-libvips-linux-s390x@1.3.1':
357 optional: true
358
359 '@img/sharp-libvips-linux-x64@1.3.1':
360 optional: true
361
362 '@img/sharp-libvips-linuxmusl-arm64@1.3.1':
363 optional: true
364
365 '@img/sharp-libvips-linuxmusl-x64@1.3.1':
366 optional: true
367
368 '@img/sharp-linux-arm64@0.35.2':
369 optionalDependencies:
370 '@img/sharp-libvips-linux-arm64': 1.3.1
371 optional: true
372
373 '@img/sharp-linux-arm@0.35.2':
374 optionalDependencies:
375 '@img/sharp-libvips-linux-arm': 1.3.1
376 optional: true
377
378 '@img/sharp-linux-ppc64@0.35.2':
379 optionalDependencies:
380 '@img/sharp-libvips-linux-ppc64': 1.3.1
381 optional: true
382
383 '@img/sharp-linux-riscv64@0.35.2':
384 optionalDependencies:
385 '@img/sharp-libvips-linux-riscv64': 1.3.1
386 optional: true
387
388 '@img/sharp-linux-s390x@0.35.2':
389 optionalDependencies:
390 '@img/sharp-libvips-linux-s390x': 1.3.1
391 optional: true
392
393 '@img/sharp-linux-x64@0.35.2':
394 optionalDependencies:
395 '@img/sharp-libvips-linux-x64': 1.3.1
396 optional: true
397
398 '@img/sharp-linuxmusl-arm64@0.35.2':
399 optionalDependencies:
400 '@img/sharp-libvips-linuxmusl-arm64': 1.3.1
401 optional: true
402
403 '@img/sharp-linuxmusl-x64@0.35.2':
404 optionalDependencies:
405 '@img/sharp-libvips-linuxmusl-x64': 1.3.1
406 optional: true
407
408 '@img/sharp-wasm32@0.35.2':
409 dependencies:
410 '@emnapi/runtime': 1.11.1
411 optional: true
412
413 '@img/sharp-webcontainers-wasm32@0.35.2':
414 dependencies:
415 '@img/sharp-wasm32': 0.35.2
416 optional: true
417
418 '@img/sharp-win32-arm64@0.35.2':
419 optional: true
420
421 '@img/sharp-win32-ia32@0.35.2':
422 optional: true
423
424 '@img/sharp-win32-x64@0.35.2':
425 optional: true
426
427 '@jsr/clo__lib@3.0.0': {}
428
429 '@mdi/svg@7.4.47': {}
430
431 '@types/node@25.5.0':
432 dependencies:
433 undici-types: 7.18.2
434
435 '@types/w3c-web-usb@1.0.13': {}
436
437 ansi-regex@5.0.1: {}
438
439 ansi-styles@4.3.0:
440 dependencies:
441 color-convert: 2.0.1
442
443 cliui@8.0.1:
444 dependencies:
445 string-width: 4.2.3
446 strip-ansi: 6.0.1
447 wrap-ansi: 7.0.0
448
449 color-convert@2.0.1:
450 dependencies:
451 color-name: 1.1.4
452
453 color-name@1.1.4: {}
454
455 detect-libc@2.1.2: {}
456
457 emoji-regex@8.0.0: {}
458
459 escalade@3.2.0: {}
460
461 get-caller-file@2.0.5: {}
462
463 is-fullwidth-code-point@3.0.0: {}
464
465 linq@4.0.3: {}
466
467 lucide-static@1.21.0: {}
468
469 mdi-ts@1.0.3:
470 dependencies:
471 linq: 4.0.3
472
473 node-addon-api@3.2.1: {}
474
475 node-addon-api@8.6.0: {}
476
477 node-gyp-build@4.8.4: {}
478
479 node-hid@3.3.0:
480 dependencies:
481 node-addon-api: 3.2.1
482 pkg-prebuilds: 1.0.0
483
484 pkg-prebuilds@1.0.0:
485 dependencies:
486 yargs: 17.7.2
487
488 require-directory@2.1.1: {}
489
490 semver@7.8.5: {}
491
492 sharp@0.35.2:
493 dependencies:
494 '@img/colour': 1.1.0
495 detect-libc: 2.1.2
496 semver: 7.8.5
497 optionalDependencies:
498 '@img/sharp-darwin-arm64': 0.35.2
499 '@img/sharp-darwin-x64': 0.35.2
500 '@img/sharp-freebsd-wasm32': 0.35.2
501 '@img/sharp-libvips-darwin-arm64': 1.3.1
502 '@img/sharp-libvips-darwin-x64': 1.3.1
503 '@img/sharp-libvips-linux-arm': 1.3.1
504 '@img/sharp-libvips-linux-arm64': 1.3.1
505 '@img/sharp-libvips-linux-ppc64': 1.3.1
506 '@img/sharp-libvips-linux-riscv64': 1.3.1
507 '@img/sharp-libvips-linux-s390x': 1.3.1
508 '@img/sharp-libvips-linux-x64': 1.3.1
509 '@img/sharp-libvips-linuxmusl-arm64': 1.3.1
510 '@img/sharp-libvips-linuxmusl-x64': 1.3.1
511 '@img/sharp-linux-arm': 0.35.2
512 '@img/sharp-linux-arm64': 0.35.2
513 '@img/sharp-linux-ppc64': 0.35.2
514 '@img/sharp-linux-riscv64': 0.35.2
515 '@img/sharp-linux-s390x': 0.35.2
516 '@img/sharp-linux-x64': 0.35.2
517 '@img/sharp-linuxmusl-arm64': 0.35.2
518 '@img/sharp-linuxmusl-x64': 0.35.2
519 '@img/sharp-webcontainers-wasm32': 0.35.2
520 '@img/sharp-win32-arm64': 0.35.2
521 '@img/sharp-win32-ia32': 0.35.2
522 '@img/sharp-win32-x64': 0.35.2
523
524 string-width@4.2.3:
525 dependencies:
526 emoji-regex: 8.0.0
527 is-fullwidth-code-point: 3.0.0
528 strip-ansi: 6.0.1
529
530 strip-ansi@6.0.1:
531 dependencies:
532 ansi-regex: 5.0.1
533
534 tslib@2.8.1:
535 optional: true
536
537 undici-types@7.18.2: {}
538
539 usb@2.17.0:
540 dependencies:
541 '@types/w3c-web-usb': 1.0.13
542 node-addon-api: 8.6.0
543 node-gyp-build: 4.8.4
544
545 wrap-ansi@7.0.0:
546 dependencies:
547 ansi-styles: 4.3.0
548 string-width: 4.2.3
549 strip-ansi: 6.0.1
550
551 y18n@5.0.8: {}
552
553 yargs-parser@21.1.1: {}
554
555 yargs@17.7.2:
556 dependencies:
557 cliui: 8.0.1
558 escalade: 3.2.0
559 get-caller-file: 2.0.5
560 require-directory: 2.1.1
561 string-width: 4.2.3
562 y18n: 5.0.8
563 yargs-parser: 21.1.1
control/pnpm-workspace.yaml created+2
...@@ -0,0 +1,2 @@
1onlyBuiltDependencies:
2 - node-hid
control/readme.md created+116
...@@ -0,0 +1,116 @@
1# Clover Control
2
3This is a set of tools to let additional hardware devices integrate with
4creative applications on a Mac device. Additionally, this repo contains a lot of
5my own tools and scripts I use in the Music/Video creative processes.
6
7In addition to the primary keybinding system and personal software
8configurations, this project can be used as a library to use the control
9primitives directly (either to build your own hardware integrations, or to
10control the software). This can be done by installing this repo as a `pnpm` git
11dependency in your project.
12
13**NOTE**: These tools only work on macOS. I don't have interest in maintaining
14other configurations.
15
16## Hardware
17
18### DaVinci Resolve Speed Editor
19
20A $200 dual-hardware system.
21
22### DaVinci Resolve Speed Editor
23
24A $300 bundle containing Fusion Studio and the control surface, it's a great
25deal. There is a large, high resolution knob, as well as many keys with some
26having lights. This repo includes an SDK to reprogram it to be useful in any
27program. **Note**: It is unused as of 2026-06-24.
28
29![DaVinci Resolve Speed Editor](./docs/speed-editor.jpg)
30
31## Software
32
33TODO:
34
35- Fusion
36- Blender?
37- Krita?
38- The Finder
39- QuickTime player
40
41### macOS (`Mac.ts`)
42
43Bind to the Mac desktop interface.
44
45```ts
46const mac = await Mac.open();
47
48mac.on("app-change", (bundle) => {
49 console.info("Current App: " + bundle);
50});
51```
52
53### REAPER
54
55With the help of an OSC extension, REAPER can be controlled with TypeScript.
56
57```ts
58import { Reaper } from "@clo/clover-control/Reaper";
59
60const reaper = new Reaper();
61reaper.on("transport", (transport) => {
62 console.info(
63 transport.recording
64 ? "You are recording"
65 : transport.playing
66 ? "Playing"
67 : "Stopped",
68 );
69});
70```
71
72Setup:
73
74- Start up Clover Control / `new Reaper()`
75- Navigate to REAPER Settings (`Cmd+,`)
76- Click `Control/OSC/web` on the left side panel
77- Press `Add`
78 - Control surface mode: `OSC (Open Sound Control)`
79 - Device name: `Clover Automation`
80 - Pattern config: `CloverAutomation`
81 - Mode: `Configure device IP+local port`
82 - Device port: `58001`
83 - Device IP: `127.0.0.1`
84 - Local listen port: `58000`
85 - Local IP: (default)
86 - Allow binding messages to REAPER actions and FX learn
87
88## Config Format
89
90The main entrypoint loads config files from `./config`, which each apply to one
91application. In this example, it configures Reaper to integrate with the Speed
92Editor. The provided instance of hardware devices are wrapper objects that apply
93the binds only when the program is active. This way, there aren't situations
94with multiple readers conflicting.
95
96```ts
97import * as config from "#config";
98import { Reaper } from "@clo/clover-control/Reaper";
99
100export default config.forApp("com.cockos.reaper", ({ speededitor, mac }) => {
101 const reaper = new Reaper();
102
103 // Sync state to LEDs
104 reaper.on("transport", (transport) => {
105 speededitor.leds.audioOnly = transport.recording;
106 });
107
108 // Keyboard Actions
109 speededitor.onPress("stopPlay", () => {
110 reaper.runAction("transport-play-stop");
111 });
112 speededitor.onPress("audioOnly", () => {
113 reaper.runAction("transport-record");
114 });
115});
116```
control/src/Dialpad.ts created+208
...@@ -0,0 +1,208 @@
1import { Events } from "@clo/lib/Events.ts";
2import type { Dispose } from "@clo/lib/ts.ts";
3
4// The MX Creative Console Dialpad pairs over Bluetooth as a Logitech HID++
5// device. It does NOT need HID++ feature access: it streams a single 8-byte
6// input report (id 0x02) that decodes cleanly. Discovered by sniffing — see
7// examples/hid-sniff.ts.
8//
9// 02 buttons -- -- -- -- spin rotate
10// b1 b6 b7
11//
12// `rotate` (main dial) and `spin` (knob) are signed int8 deltas; `buttons` is a
13// bitmask. NOTE: until the OS-seize phase, macOS also consumes these reports
14// (the dial scrolls, the buttons act as mouse buttons).
15const LOGITECH_VENDOR_ID = 0x046d;
16const DIALPAD_PRODUCT_ID = 0xbc00;
17
18const REPORT_ID = 0x02;
19const BUTTON_BYTE = 1;
20const SPIN_BYTE = 6;
21const ROTATE_BYTE = 7;
22
23const buttonIds = ["circle", "triangle", "square", "cross"] as const;
24
25const BUTTON_BIT_BY_ID = new Map<Dialpad.Button, number>([
26 ["square", 0x08],
27 ["cross", 0x10],
28 ["circle", 0x20],
29 ["triangle", 0x40],
30]);
31
32const RECONNECT_INTERVAL_MS = 1000;
33
34/**
35 * Node.js bindings for the Logitech MX Creative Console Dialpad (Bluetooth).
36 */
37export class Dialpad extends Events<Dialpad.EventMap> {
38 static buttons = buttonIds;
39
40 #options: Required<Dialpad.Options>;
41 #device: import("node-hid").HID | null = null;
42 #closed = false;
43 #ready = false;
44 #reconnectTimer: ReturnType<typeof setInterval> | null = null;
45 #activeButtons = new Set<Dialpad.Button>();
46 #lastButtonMask = 0;
47
48 private constructor(options: Dialpad.Options = {}) {
49 super();
50 this.#options = {
51 vendorId: options.vendorId ?? LOGITECH_VENDOR_ID,
52 productId: options.productId ?? DIALPAD_PRODUCT_ID,
53 path: options.path ?? null,
54 };
55 }
56
57 static async open(options: Dialpad.Options = {}) {
58 const dialpad = new Dialpad(options);
59 await dialpad.#start();
60 return dialpad;
61 }
62
63 get connected(): boolean {
64 return this.#ready;
65 }
66
67 onPress(button: Dialpad.Button, listener: () => void): Dispose {
68 return this.on("keypress", (code) => {
69 if (button === code) listener();
70 });
71 }
72
73 close() {
74 if (this.#closed) return;
75 this.#closed = true;
76 if (this.#reconnectTimer) clearInterval(this.#reconnectTimer);
77 this.#reconnectTimer = null;
78 this.#disconnect(false);
79 this.emit("close");
80 }
81
82 async #start() {
83 await this.#connect();
84 // Bluetooth devices don't raise `usb` hotplug events, so poll instead.
85 this.#reconnectTimer = setInterval(() => {
86 if (!this.#device && !this.#closed) void this.#connect();
87 }, RECONNECT_INTERVAL_MS);
88 this.#reconnectTimer.unref?.();
89 }
90
91 async #connect() {
92 if (this.#device || this.#closed) return;
93
94 const { devices, HID } = await import("node-hid");
95 const match = devices().find(
96 (device) =>
97 device.vendorId === this.#options.vendorId
98 && device.productId === this.#options.productId
99 && (this.#options.path ? device.path === this.#options.path : true)
100 && Boolean(device.path),
101 );
102 if (!match?.path) return;
103
104 try {
105 const device = new HID(match.path);
106 this.#device = device;
107 device.on("data", (report) => {
108 if (this.#device === device) this.#handleReport(report);
109 });
110 device.on("error", (error) => {
111 if (this.#device === device) this.#handleDeviceError(error);
112 });
113 this.#ready = true;
114 this.emit("connect");
115 } catch {
116 this.#device = null;
117 // Will retry on the next poll tick.
118 }
119 }
120
121 #handleReport(report: Buffer | number[]) {
122 const bytes = Uint8Array.from(report);
123 if (bytes[0] !== REPORT_ID) return;
124
125 const rotate = toInt8(bytes[ROTATE_BYTE] ?? 0);
126 if (rotate !== 0) this.emit("rotate", rotate);
127
128 const spin = toInt8(bytes[SPIN_BYTE] ?? 0);
129 if (spin !== 0) this.emit("spin", spin);
130
131 const mask = bytes[BUTTON_BYTE] ?? 0;
132 if (mask !== this.#lastButtonMask) {
133 this.#lastButtonMask = mask;
134 this.#applyButtonState(mask);
135 }
136 }
137
138 #applyButtonState(mask: number) {
139 const next = new Set<Dialpad.Button>();
140 for (const [button, bit] of BUTTON_BIT_BY_ID) {
141 if (mask & bit) next.add(button);
142 }
143
144 for (const button of this.#activeButtons) {
145 if (!next.has(button)) this.emit("keyup", button);
146 }
147 for (const button of next) {
148 if (!this.#activeButtons.has(button)) {
149 this.emit("keydown", button);
150 this.emit("keypress", button);
151 }
152 }
153
154 this.#activeButtons = next;
155 this.emit("key", [...next]);
156 }
157
158 #handleDeviceError(_error: unknown) {
159 this.#disconnect(true);
160 }
161
162 #disconnect(emitEvent: boolean) {
163 const device = this.#device;
164 this.#device = null;
165 this.#ready = false;
166 this.#activeButtons.clear();
167 this.#lastButtonMask = 0;
168 if (device) {
169 device.removeAllListeners("data");
170 device.removeAllListeners("error");
171 try {
172 device.close();
173 } catch {
174 // Ignore close races when the device disappears mid-reconnect.
175 }
176 }
177 if (emitEvent) this.emit("disconnect");
178 }
179}
180
181function toInt8(byte: number): number {
182 return byte > 127 ? byte - 256 : byte;
183}
184
185export declare namespace Dialpad {
186 export type Button = typeof buttonIds[number];
187
188 export interface Options {
189 vendorId?: number;
190 productId?: number;
191 path?: string | null;
192 }
193
194 export type EventMap = {
195 "connect": [];
196 "disconnect": [];
197 "close": [];
198 "error": [error: unknown];
199 /** Main dial delta (signed, clockwise positive). */
200 "rotate": [delta: number];
201 /** Up/down knob delta (signed, up positive). */
202 "spin": [delta: number];
203 "key": [activeButtons: ReadonlyArray<Button>];
204 "keydown": [button: Button];
205 "keyup": [button: Button];
206 "keypress": [button: Button];
207 };
208}
control/src/Keypad.ts created+397
...@@ -0,0 +1,397 @@
1import { Events } from "@clo/lib/Events.ts";
2import type { Dispose } from "@clo/lib/ts.ts";
3
4// Node.js bindings for the Logitech MX Creative Keypad — the 3x3 LCD grid plus
5// the two screenless buttons below it. Talks the device's HID protocol directly
6// (no vendor SDK). Protocol cross-referenced from the Stream-Deck-style wire
7// format the hardware uses:
8// - input report 0x13: grid buttons (hidId = index + 1, int8 list from off 5)
9// - input report 0x11: back/forward (hidId 0x01a1/0x01a2, uint16 BE from off 3)
10// - output report 0x14: image data, 4095-byte packets with a positioned header
11// - output report 0x11: brightness (0x11 ff 0f 2b 00 <pct>)
12// - feature report 0x03: reset to logo
13const LOGITECH_VENDOR_ID = 0x046d;
14const KEYPAD_PRODUCT_ID = 0xc354;
15
16const KEY_SIZE = 118;
17// Each grid key writes a sub-rect of the panel framebuffer. Positions are
18// offset (23, 6) with a 158px pitch (118px key + 40px gap).
19const GRID_OFFSET = { x: 23, y: 6 };
20const GRID_PITCH = KEY_SIZE + 40;
21
22const NAME_BY_INDEX = [
23 "up-left",
24 "up",
25 "up-right",
26 "left",
27 "center",
28 "right",
29 "down-left",
30 "down",
31 "down-right",
32 "back",
33 "forward",
34] as const;
35
36const LCD_KEYS = NAME_BY_INDEX.slice(0, 9) as readonly Keypad.Key[];
37const INDEX_BY_NAME = new Map<Keypad.Key, number>(
38 NAME_BY_INDEX.map((name, index) => [name, index]),
39);
40
41const KEY_POSITION = LCD_KEYS.map((_, index) => ({
42 x: GRID_OFFSET.x + (index % 3) * GRID_PITCH,
43 y: GRID_OFFSET.y + Math.floor(index / 3) * GRID_PITCH,
44}));
45
46const PANEL_SIZE = 480;
47
48/** Grid key (x,y) positions within the 480x480 panel framebuffer, row-major. */
49export const KEY_POSITIONS: ReadonlyArray<{ x: number; y: number }> = KEY_POSITION;
50/** Full panel pixel size (square). */
51export const PANEL_SIZE_PX = PANEL_SIZE;
52
53// Input hidId -> key name. Grid keys use hidId = index + 1; the two page buttons
54// report 16-bit ids.
55const NAME_BY_HID = new Map<number, Keypad.Key>(
56 LCD_KEYS.map((name, index) => [index + 1, name]),
57);
58NAME_BY_HID.set(0x01a1, "back");
59NAME_BY_HID.set(0x01a2, "forward");
60
61// Sent on connect so the back/forward buttons emit raw HID events.
62const INIT_WRITES = [0x01a1, 0x01a2].map((hidId) => {
63 const buffer = Buffer.alloc(20);
64 buffer.set([0x11, 0xff, 0x0b, 0x3b, (hidId >> 8) & 0xff, hidId & 0xff, 0x03]);
65 return buffer;
66});
67
68const IMAGE_REPORT_ID = 0x14;
69const MAX_PACKET_SIZE = 4095;
70const PACKET1_HEADER = 20;
71const PACKETN_HEADER = 5;
72
73const RECONNECT_INTERVAL_MS = 1000;
74
75export class Keypad extends Events<Keypad.EventMap> {
76 static keys = NAME_BY_INDEX;
77 static lcdKeys = LCD_KEYS;
78
79 #device: import("node-hid").HID | null = null;
80 #closed = false;
81 #ready = false;
82 #reconnectTimer: ReturnType<typeof setInterval> | null = null;
83 #images = new Map<Keypad.Key, Uint8Array>();
84 #shown = new Map<Keypad.Key, Uint8Array>();
85 #panel: Uint8Array | null = null;
86 #shownPanel: Uint8Array | null = null;
87 #gridDown = new Set<Keypad.Key>();
88 #pageDown = new Set<Keypad.Key>();
89
90 private constructor() {
91 super();
92 }
93
94 static async open() {
95 const keypad = new Keypad();
96 await keypad.#start();
97 return keypad;
98 }
99
100 get connected(): boolean {
101 return this.#ready;
102 }
103
104 onPress(key: Keypad.Key, listener: () => void): Dispose {
105 return this.on("keypress", (code) => {
106 if (key === code) listener();
107 });
108 }
109
110 /**
111 * Show a pre-encoded JPEG on a grid key. The caller owns encoding (see
112 * KeypadUI); identical buffers are deduped so unchanged keys never re-send.
113 */
114 setImage(key: Keypad.Key, image: Uint8Array) {
115 this.#images.set(key, image);
116 this.#panel = null; // a per-key image supersedes any full-panel image
117 if (this.#shown.get(key) === image) return; // already on screen — cached
118 if (this.#writeImage(key, image)) {
119 this.#shown.set(key, image);
120 this.#shownPanel = null;
121 }
122 }
123
124 /**
125 * Show one composed image across the whole 480x480 panel as a single
126 * image-write. The device repaints every key in one refresh — no per-key
127 * cascade. Identical buffers are deduped.
128 */
129 setPanel(image: Uint8Array) {
130 this.#panel = image;
131 this.#images.clear(); // a full-panel image supersedes per-key images
132 if (this.#shownPanel === image) return; // already on screen — cached
133 if (this.#writeRegion(0, 0, PANEL_SIZE, PANEL_SIZE, image)) {
134 this.#shownPanel = image;
135 this.#shown.clear();
136 }
137 }
138
139 /** Brightness as 0..1. */
140 setBrightness(level: number) {
141 const percentage = Math.max(
142 1,
143 Math.min(100, Math.round(Math.max(0, Math.min(1, level)) * 100)),
144 );
145 const command = Buffer.alloc(20);
146 command.set([0x11, 0xff, 0x0f, 0x2b, 0x00, percentage]);
147 this.#write(command);
148 }
149
150 /** Reset all screens to the startup logo. */
151 reset() {
152 this.#shown.clear();
153 this.#shownPanel = null;
154 const command = Buffer.alloc(32);
155 command.set([0x03, 0x02]);
156 try {
157 this.#device?.sendFeatureReport(command);
158 } catch {
159 // Ignore if the device vanished.
160 }
161 }
162
163 close() {
164 if (this.#closed) return;
165 this.#closed = true;
166 if (this.#reconnectTimer) clearInterval(this.#reconnectTimer);
167 this.#reconnectTimer = null;
168 this.#disconnect(false);
169 this.emit("close");
170 }
171
172 async #start() {
173 await this.#connect();
174 // The keypad pairs over Bluetooth too, where `usb` hotplug is silent — poll.
175 this.#reconnectTimer = setInterval(() => {
176 if (!this.#device && !this.#closed) void this.#connect();
177 }, RECONNECT_INTERVAL_MS);
178 this.#reconnectTimer.unref?.();
179 }
180
181 async #connect() {
182 if (this.#device || this.#closed) return;
183
184 const { devices, HID } = await import("node-hid");
185 const match = devices().find(
186 (device) =>
187 device.vendorId === LOGITECH_VENDOR_ID &&
188 device.productId === KEYPAD_PRODUCT_ID &&
189 Boolean(device.path),
190 );
191 if (!match?.path) return;
192
193 try {
194 const device = new HID(match.path);
195 this.#device = device;
196 device.on("data", (report) => {
197 if (this.#device === device) this.#handleReport(report);
198 });
199 device.on("error", () => {
200 if (this.#device === device) this.#handleDeviceError();
201 });
202
203 for (const write of INIT_WRITES) device.write(write);
204 this.#ready = true;
205 this.emit("connect");
206 this.#reapplyImages();
207 } catch {
208 this.#device = null;
209 // Retry on the next poll tick.
210 }
211 }
212
213 #handleReport(report: Buffer | number[]) {
214 const buffer = Buffer.isBuffer(report) ? report : Buffer.from(report);
215 const reportId = buffer[0];
216 const data = buffer.subarray(1);
217 if (data[2] === 0x2b) return; // ack to a drawing write
218
219 if (reportId === 0x13) this.#handleGridInput(data);
220 else if (reportId === 0x11) this.#handlePageInput(data);
221 }
222
223 #handleGridInput(data: Buffer) {
224 if (data[0] !== 0xff || data[1] !== 0x02 || data[2] !== 0x00 || data[4] !== 0x01) {
225 return;
226 }
227 const pressed = new Set<Keypad.Key>();
228 for (let i = 5; i < data.length; i += 1) {
229 const value = data.readInt8(i);
230 if (value === 0) break;
231 const key = NAME_BY_HID.get(value);
232 if (key) pressed.add(key);
233 }
234 this.#applyPressed(pressed, this.#gridDown);
235 }
236
237 #handlePageInput(data: Buffer) {
238 if (data[0] !== 0xff || data[1] !== 0x0b || data[2] !== 0x00) return;
239 const pressed = new Set<Keypad.Key>();
240 for (let i = 3; i + 1 < data.length; i += 2) {
241 const value = data.readUInt16BE(i);
242 if (value === 0) break;
243 const key = NAME_BY_HID.get(value);
244 if (key) pressed.add(key);
245 }
246 this.#applyPressed(pressed, this.#pageDown);
247 }
248
249 #applyPressed(pressed: Set<Keypad.Key>, downSet: Set<Keypad.Key>) {
250 for (const key of downSet) {
251 if (!pressed.has(key)) {
252 downSet.delete(key);
253 this.emit("keyup", key);
254 }
255 }
256 for (const key of pressed) {
257 if (!downSet.has(key)) {
258 downSet.add(key);
259 this.emit("keydown", key);
260 this.emit("keypress", key);
261 }
262 }
263 }
264
265 #writeImage(key: Keypad.Key, image: Uint8Array): boolean {
266 const index = INDEX_BY_NAME.get(key);
267 if (index === undefined || index >= LCD_KEYS.length) return false;
268 const position = KEY_POSITION[index];
269 return this.#writeRegion(position.x, position.y, KEY_SIZE, KEY_SIZE, image);
270 }
271
272 #writeRegion(
273 x: number,
274 y: number,
275 width: number,
276 height: number,
277 image: Uint8Array,
278 ): boolean {
279 if (!this.#device) return false;
280 try {
281 for (const packet of packetizeImage(x, y, width, height, image)) {
282 this.#device.write(packet);
283 }
284 return true;
285 } catch {
286 return false;
287 }
288 }
289
290 #reapplyImages() {
291 this.#shown.clear();
292 this.#shownPanel = null;
293 if (this.#panel) {
294 if (this.#writeRegion(0, 0, PANEL_SIZE, PANEL_SIZE, this.#panel)) {
295 this.#shownPanel = this.#panel;
296 }
297 return;
298 }
299 for (const [key, image] of this.#images) {
300 if (this.#writeImage(key, image)) this.#shown.set(key, image);
301 }
302 }
303
304 #write(buffer: Buffer) {
305 try {
306 this.#device?.write(buffer);
307 } catch {
308 // Ignore if the device vanished.
309 }
310 }
311
312 #handleDeviceError() {
313 this.#disconnect(true);
314 }
315
316 #disconnect(emitEvent: boolean) {
317 const device = this.#device;
318 this.#device = null;
319 this.#ready = false;
320 this.#shown.clear();
321 this.#shownPanel = null;
322 this.#gridDown.clear();
323 this.#pageDown.clear();
324 if (device) {
325 device.removeAllListeners("data");
326 device.removeAllListeners("error");
327 try {
328 device.close();
329 } catch {
330 // Ignore close races when the device disappears mid-reconnect.
331 }
332 }
333 if (emitEvent) this.emit("disconnect");
334 }
335}
336
337/** Split a JPEG into the keypad's positioned image-write packets. */
338function packetizeImage(
339 x: number,
340 y: number,
341 width: number,
342 height: number,
343 jpeg: Uint8Array,
344): Buffer[] {
345 const packets: Buffer[] = [];
346 const total = jpeg.length;
347
348 const first = Buffer.alloc(MAX_PACKET_SIZE);
349 const firstBytes = Math.min(total, MAX_PACKET_SIZE - PACKET1_HEADER);
350 first.set([IMAGE_REPORT_ID, 0xff, 0x02, 0x2b]);
351 first[4] = packetByte(1, true, firstBytes >= total);
352 first.writeUInt16BE(0x0100, 5);
353 first.writeUInt16BE(0x0100, 7);
354 first.writeUInt16BE(x, 9);
355 first.writeUInt16BE(y, 11);
356 first.writeUInt16BE(width, 13);
357 first.writeUInt16BE(height, 15);
358 first.writeUInt16BE(total, 18);
359 first.set(jpeg.subarray(0, firstBytes), PACKET1_HEADER);
360 packets.push(first);
361
362 let remaining = total - firstBytes;
363 let part = 2;
364 while (remaining > 0) {
365 const packet = Buffer.alloc(MAX_PACKET_SIZE);
366 const bytes = Math.min(remaining, MAX_PACKET_SIZE - PACKETN_HEADER);
367 const offset = total - remaining;
368 packet.set([IMAGE_REPORT_ID, 0xff, 0x02, 0x2b]);
369 packet[4] = packetByte(part, false, remaining - bytes === 0);
370 packet.set(jpeg.subarray(offset, offset + bytes), PACKETN_HEADER);
371 packets.push(packet);
372 remaining -= bytes;
373 part += 1;
374 }
375 return packets;
376}
377
378function packetByte(index: number, isFirst: boolean, isLast: boolean): number {
379 let value = index | 0b0010_0000;
380 if (isFirst) value |= 0b1000_0000;
381 if (isLast) value |= 0b0100_0000;
382 return value;
383}
384
385export declare namespace Keypad {
386 export type Key = typeof NAME_BY_INDEX[number];
387
388 export type EventMap = {
389 "connect": [];
390 "disconnect": [];
391 "close": [];
392 "error": [error: unknown];
393 "keydown": [key: Key];
394 "keyup": [key: Key];
395 "keypress": [key: Key];
396 };
397}
control/src/KeypadUI.ts
Binary files /dev/null and b/control/src/KeypadUI.ts differ
control/src/Mac.ts created+1107
...@@ -0,0 +1,1107 @@
1import { Events } from "@clo/lib/Events.ts";
2import type { ChildProcessWithoutNullStreams } from "node:child_process";
3import { execFile, spawn } from "node:child_process";
4import { mkdir, stat } from "node:fs/promises";
5import { tmpdir } from "node:os";
6import { dirname, join } from "node:path";
7import { fileURLToPath } from "node:url";
8import { promisify } from "node:util";
9
10const execFileAsync = promisify(execFile);
11
12const APP_MONITOR_START_TIMEOUT_MS = 1000;
13const BUNDLE_ID_PATTERN = /\b[A-Za-z0-9-]+(?:\.[A-Za-z0-9-]+)+\b/;
14const MODULE_DIR = dirname(fileURLToPath(import.meta.url));
15const FRONTMOST_APP_HELPER_SOURCE_PATH = join(
16 MODULE_DIR,
17 "Mac/frontmost_app_helper.m",
18);
19const HELPER_BUILD_DIR = join(tmpdir(), "meow");
20const FRONTMOST_APP_HELPER_BINARY_PATH = join(
21 HELPER_BUILD_DIR,
22 "mac-frontmost-app-helper",
23);
24const TOAST_HELPER_SOURCE_PATH = join(MODULE_DIR, "Mac/toast_helper.m");
25const TOAST_HELPER_BINARY_PATH = join(HELPER_BUILD_DIR, "mac-toast-helper");
26const FRONTMOST_HELPER_READ_ONCE_FLAG = "--once";
27const FRONTMOST_HELPER_FOCUS_WINDOW_FLAG = "--focus-window";
28const FRONTMOST_HELPER_FOCUS_MAIN_WINDOW_FLAG = "--focus-main-window";
29const DEFAULT_OBJECTIVE_C_FRAMEWORKS = ["AppKit", "Foundation"] as const;
30const FRONTMOST_APP_HELPER_FRAMEWORKS = [
31 ...DEFAULT_OBJECTIVE_C_FRAMEWORKS,
32 "ApplicationServices",
33] as const;
34const KEYBOARD_EVENT_SCRIPT = [
35 "ObjC.import(\"ApplicationServices\");",
36 "function run(argv) {",
37 " const payload = JSON.parse(argv[0] ?? '{}');",
38 " const actions = Array.isArray(payload.actions) ? payload.actions : [];",
39 " for (const action of actions) {",
40 " if (!Number.isInteger(action.keyCode)) {",
41 " throw new Error(`Invalid key code: ${JSON.stringify(action)}`);",
42 " }",
43 " const event = $.CGEventCreateKeyboardEvent(",
44 " null,",
45 " action.keyCode,",
46 " Boolean(action.isDown),",
47 " );",
48 " if (!event) {",
49 " throw new Error(`Failed to create keyboard event for key code ${action.keyCode}`);",
50 " }",
51 " $.CGEventPost($.kCGHIDEventTap, event);",
52 " $.CFRelease(event);",
53 " }",
54 " return \"\";",
55 "}",
56].join("\n");
57let frontmostAppHelperBinaryPromise: Promise<string> | null = null;
58let toastHelperBinaryPromise: Promise<string> | null = null;
59
60// Key codes from Carbon/HIToolbox Events.h (virtual key codes on ANSI/US layouts).
61const KEY_CODES = Object.freeze(
62 {
63 a: 0x00,
64 s: 0x01,
65 d: 0x02,
66 f: 0x03,
67 h: 0x04,
68 g: 0x05,
69 z: 0x06,
70 x: 0x07,
71 c: 0x08,
72 v: 0x09,
73 isoSection: 0x0A,
74 b: 0x0B,
75 q: 0x0C,
76 w: 0x0D,
77 e: 0x0E,
78 r: 0x0F,
79 y: 0x10,
80 t: 0x11,
81 "1": 0x12,
82 "2": 0x13,
83 "3": 0x14,
84 "4": 0x15,
85 "6": 0x16,
86 "5": 0x17,
87 equal: 0x18,
88 "9": 0x19,
89 "7": 0x1A,
90 minus: 0x1B,
91 "8": 0x1C,
92 "0": 0x1D,
93 rightBracket: 0x1E,
94 o: 0x1F,
95 u: 0x20,
96 leftBracket: 0x21,
97 i: 0x22,
98 p: 0x23,
99 return: 0x24,
100 l: 0x25,
101 j: 0x26,
102 quote: 0x27,
103 k: 0x28,
104 semicolon: 0x29,
105 backslash: 0x2A,
106 comma: 0x2B,
107 slash: 0x2C,
108 n: 0x2D,
109 m: 0x2E,
110 period: 0x2F,
111 tab: 0x30,
112 space: 0x31,
113 grave: 0x32,
114 delete: 0x33,
115 escape: 0x35,
116 rightCommand: 0x36,
117 command: 0x37,
118 shift: 0x38,
119 capsLock: 0x39,
120 option: 0x3A,
121 control: 0x3B,
122 rightShift: 0x3C,
123 rightOption: 0x3D,
124 rightControl: 0x3E,
125 function: 0x3F,
126 f17: 0x40,
127 numpadDecimal: 0x41,
128 numpadMultiply: 0x43,
129 numpadPlus: 0x45,
130 numpadClear: 0x47,
131 volumeUp: 0x48,
132 volumeDown: 0x49,
133 mute: 0x4A,
134 numpadDivide: 0x4B,
135 numpadEnter: 0x4C,
136 numpadMinus: 0x4E,
137 f18: 0x4F,
138 f19: 0x50,
139 numpadEquals: 0x51,
140 numpad0: 0x52,
141 numpad1: 0x53,
142 numpad2: 0x54,
143 numpad3: 0x55,
144 numpad4: 0x56,
145 numpad5: 0x57,
146 numpad6: 0x58,
147 numpad7: 0x59,
148 f20: 0x5A,
149 numpad8: 0x5B,
150 numpad9: 0x5C,
151 jisYen: 0x5D,
152 jisUnderscore: 0x5E,
153 jisKeypadComma: 0x5F,
154 f5: 0x60,
155 f6: 0x61,
156 f7: 0x62,
157 f3: 0x63,
158 f8: 0x64,
159 f9: 0x65,
160 jisEisu: 0x66,
161 f11: 0x67,
162 jisKana: 0x68,
163 f13: 0x69,
164 f16: 0x6A,
165 f14: 0x6B,
166 f10: 0x6D,
167 f12: 0x6F,
168 f15: 0x71,
169 help: 0x72,
170 home: 0x73,
171 pageUp: 0x74,
172 forwardDelete: 0x75,
173 f4: 0x76,
174 end: 0x77,
175 f2: 0x78,
176 pageDown: 0x79,
177 f1: 0x7A,
178 leftArrow: 0x7B,
179 rightArrow: 0x7C,
180 downArrow: 0x7D,
181 upArrow: 0x7E,
182 } as const,
183);
184type MacKeyName = keyof typeof KEY_CODES;
185
186const KEY_NAMES = Object.freeze(Object.keys(KEY_CODES) as MacKeyName[]);
187const KEY_CODE_LOOKUP = createKeyCodeLookup(KEY_CODES, {
188 backslash: ["\\"],
189 capsLock: ["caps"],
190 comma: [","],
191 command: ["cmd", "leftCommand", "leftCmd", "meta", "super"],
192 control: ["ctrl", "leftControl", "leftCtrl"],
193 delete: ["backspace"],
194 downArrow: ["down", "arrowDown"],
195 equal: ["="],
196 escape: ["esc"],
197 forwardDelete: ["deleteForward", "forwardDel"],
198 function: ["fn"],
199 grave: ["`", "backtick"],
200 leftArrow: ["left", "arrowLeft"],
201 leftBracket: ["[", "openBracket"],
202 minus: ["-"],
203 numpadClear: ["keypadClear"],
204 numpadDecimal: [
205 "keypadDecimal",
206 "numpadDot",
207 "keypadDot",
208 "numpadPeriod",
209 "keypadPeriod",
210 ],
211 numpadDivide: ["keypadDivide", "keypadSlash"],
212 numpadEnter: ["keypadEnter", "keypadReturn"],
213 numpadEquals: ["keypadEquals"],
214 numpadMinus: ["keypadMinus"],
215 numpadMultiply: ["keypadMultiply", "keypadAsterisk"],
216 numpadPlus: ["keypadPlus"],
217 option: ["alt", "opt", "leftOption", "leftAlt"],
218 pageDown: ["pgdn"],
219 pageUp: ["pgup"],
220 period: ["."],
221 quote: ["'", "apostrophe"],
222 return: ["enter", "mainEnter"],
223 rightArrow: ["right", "arrowRight"],
224 rightBracket: ["]", "closeBracket"],
225 rightCommand: ["rightCmd"],
226 rightControl: ["rightCtrl"],
227 rightOption: ["rightAlt"],
228 rightShift: ["rightShift"],
229 semicolon: [";"],
230 shift: ["leftShift"],
231 slash: ["/"],
232 space: ["spacebar"],
233 upArrow: ["up", "arrowUp"],
234});
235
236type KeyboardAction = {
237 keyCode: number;
238 isDown: boolean;
239};
240
241type FrontmostState = {
242 bundleId: string | null;
243 windows: readonly Mac.Window[];
244};
245
246const EMPTY_WINDOWS: readonly Mac.Window[] = Object.freeze([]);
247
248export class Mac extends Events<Mac.EventMap> {
249 static readonly keyCodes = KEY_CODES;
250 static readonly keyNames = KEY_NAMES;
251
252 #closed = false;
253 #started = false;
254 #appMonitor: ChildProcessWithoutNullStreams | null = null;
255 #appMonitorBuffer = "";
256 #appMonitorStartup: Promise<void> | null = null;
257 #currentApp: string | null = null;
258 #windows: readonly Mac.Window[] = EMPTY_WINDOWS;
259 #keyboardQueue: Promise<void> = Promise.resolve();
260
261 private constructor(_options: Mac.Options = {}) {
262 super();
263 }
264
265 static async open(options: Mac.Options = {}) {
266 const mac = new Mac(options);
267 await mac.start();
268 return mac;
269 }
270
271 static resolveKeyCode(key: Mac.Key) {
272 return resolveKeyCode(key);
273 }
274
275 get currentApp(): string | null {
276 return this.#currentApp;
277 }
278
279 get windows(): readonly Mac.Window[] {
280 return this.#windows;
281 }
282
283 get window(): Mac.Window | null {
284 return getFocusedWindow(this.#windows);
285 }
286
287 get mainWindow(): Mac.Window | null {
288 return getMainWindow(this.#windows);
289 }
290
291 async start() {
292 if (this.#closed) {
293 throw new Error("Cannot start a closed Mac instance");
294 }
295 if (this.#started) {
296 return this.#currentApp;
297 }
298
299 this.#started = true;
300 try {
301 await this.#startAppMonitor();
302 } catch (error) {
303 this.#started = false;
304 throw error;
305 }
306
307 return this.#currentApp;
308 }
309
310 close() {
311 if (this.#closed) {
312 return;
313 }
314
315 this.#closed = true;
316 this.#started = false;
317 this.#stopAppMonitor();
318
319 this.emit("close");
320 }
321
322 async focusApp(bundleId: string) {
323 this.#assertOpen("Cannot focus an app from a closed Mac instance");
324
325 await execFileAsync("/usr/bin/open", ["-b", bundleId]);
326 await this.#syncFrontmostState();
327 }
328
329 async focusWindow(window: Mac.Window | number) {
330 this.#assertOpen("Cannot focus a window from a closed Mac instance");
331
332 const helperPath = await ensureFrontmostAppHelperBinary();
333 const windowId = resolveWindowId(window);
334 try {
335 await execFileAsync(helperPath, [
336 FRONTMOST_HELPER_FOCUS_WINDOW_FLAG,
337 String(windowId),
338 ]);
339 } catch (error) {
340 throw new Error(formatWindowFocusError(error, `window ${windowId}`));
341 }
342
343 await this.#syncFrontmostState();
344 }
345
346 async focusMainWindow() {
347 this.#assertOpen("Cannot focus the main window from a closed Mac instance");
348
349 const helperPath = await ensureFrontmostAppHelperBinary();
350 try {
351 await execFileAsync(helperPath, [
352 FRONTMOST_HELPER_FOCUS_MAIN_WINDOW_FLAG,
353 ]);
354 } catch (error) {
355 throw new Error(formatWindowFocusError(error, "the main window"));
356 }
357
358 await this.#syncFrontmostState();
359 }
360
361 async pressKey(key: Mac.Key, options: Mac.KeyPressOptions = {}) {
362 const keyCode = resolveKeyCode(key);
363 const modifierCodes = uniqueKeyCodes(options.modifiers ?? []);
364 const holdMs = normalizeDelayMs(options.holdMs ?? 0, "holdMs");
365 const keyDownActions = [
366 ...modifierCodes.map((modifierKeyCode) => ({
367 keyCode: modifierKeyCode,
368 isDown: true,
369 })),
370 { keyCode, isDown: true },
371 ];
372 const keyUpActions = [
373 { keyCode, isDown: false },
374 ...modifierCodes
375 .slice()
376 .reverse()
377 .map((modifierKeyCode) => ({
378 keyCode: modifierKeyCode,
379 isDown: false,
380 })),
381 ];
382
383 return this.#enqueueKeyboardOperation(async () => {
384 this.#assertOpen("Cannot send key events from a closed Mac instance");
385 await this.#runKeyboardActions(keyDownActions);
386 try {
387 if (holdMs > 0) {
388 await sleep(holdMs);
389 }
390 } finally {
391 await this.#runKeyboardActions(keyUpActions);
392 }
393 });
394 }
395
396 async keyDown(key: Mac.Key) {
397 const keyCode = resolveKeyCode(key);
398 return this.#enqueueKeyboardOperation(async () => {
399 this.#assertOpen("Cannot send key events from a closed Mac instance");
400 await this.#runKeyboardActions([{ keyCode, isDown: true }]);
401 });
402 }
403
404 async keyUp(key: Mac.Key) {
405 const keyCode = resolveKeyCode(key);
406 return this.#enqueueKeyboardOperation(async () => {
407 this.#assertOpen("Cannot send key events from a closed Mac instance");
408 await this.#runKeyboardActions([{ keyCode, isDown: false }]);
409 });
410 }
411
412 toast(message: string, options: Mac.ToastOptions = {}) {
413 this.#assertOpen("Cannot show a toast from a closed Mac instance");
414 void dispatchToast(message, options).catch((error) => {
415 queueMicrotask(() => {
416 this.emit("error", error);
417 });
418 });
419 }
420
421 async #startAppMonitor() {
422 if (this.#appMonitor) {
423 return;
424 }
425 if (this.#appMonitorStartup) {
426 return this.#appMonitorStartup;
427 }
428
429 this.#appMonitorStartup = this.#spawnAppMonitor().finally(() => {
430 this.#appMonitorStartup = null;
431 });
432
433 return this.#appMonitorStartup;
434 }
435
436 #stopAppMonitor() {
437 const appMonitor = this.#appMonitor;
438 this.#appMonitor = null;
439 this.#appMonitorBuffer = "";
440 if (!appMonitor) {
441 return;
442 }
443
444 appMonitor.removeAllListeners();
445 appMonitor.stdout.removeAllListeners();
446 appMonitor.stderr.removeAllListeners();
447 appMonitor.kill();
448 }
449
450 async #spawnAppMonitor() {
451 const helperPath = await ensureFrontmostAppHelperBinary();
452
453 await new Promise<void>((resolve, reject) => {
454 let stderr = "";
455 let startupFinished = false;
456 let startupSucceeded = false;
457
458 const finishStartup = (
459 result: { ok: true } | { ok: false; error: Error },
460 ) => {
461 if (startupFinished) {
462 return false;
463 }
464 startupFinished = true;
465 clearTimeout(startupTimeout);
466 if (result.ok) {
467 startupSucceeded = true;
468 resolve();
469 } else {
470 reject(result.error);
471 }
472 return true;
473 };
474
475 const appMonitor = spawn(helperPath, [], {
476 stdio: ["ignore", "pipe", "pipe"],
477 });
478
479 this.#appMonitor = appMonitor;
480 this.#appMonitorBuffer = "";
481 appMonitor.stdout.setEncoding("utf8");
482 appMonitor.stderr.setEncoding("utf8");
483
484 const startupTimeout = setTimeout(() => {
485 const error = new Error(
486 "Timed out waiting for the macOS app monitor to start.",
487 );
488 if (finishStartup({ ok: false, error })) {
489 this.#stopAppMonitor();
490 }
491 }, APP_MONITOR_START_TIMEOUT_MS);
492
493 appMonitor.stdout.on("data", (chunk: string) => {
494 const sawLine = this.#handleAppMonitorOutput(chunk);
495 if (sawLine) {
496 finishStartup({ ok: true });
497 }
498 });
499 appMonitor.stderr.on("data", (chunk: string) => {
500 stderr += chunk;
501 });
502 appMonitor.once("error", (error) => {
503 this.#appMonitorExited(appMonitor);
504 const monitorError = formatAppMonitorError(
505 "The macOS app monitor process failed.",
506 error,
507 );
508 if (
509 !finishStartup({ ok: false, error: monitorError }) && startupSucceeded
510 ) {
511 this.#emitMonitorError(monitorError);
512 }
513 });
514 appMonitor.once("exit", (code, signal) => {
515 this.#appMonitorExited(appMonitor);
516 const monitorError = formatAppMonitorError(
517 formatAppMonitorExitMessage(code, signal),
518 stderr,
519 );
520 if (
521 !finishStartup({ ok: false, error: monitorError }) && startupSucceeded
522 ) {
523 this.#emitMonitorError(monitorError);
524 }
525 });
526 });
527 }
528
529 #appMonitorExited(appMonitor: ChildProcessWithoutNullStreams) {
530 if (this.#appMonitor === appMonitor) {
531 this.#appMonitor = null;
532 }
533 this.#appMonitorBuffer = "";
534 }
535
536 #handleAppMonitorOutput(chunk: string) {
537 this.#appMonitorBuffer += chunk;
538 let sawLine = false;
539
540 while (true) {
541 const newlineIndex = this.#appMonitorBuffer.indexOf("\n");
542 if (newlineIndex === -1) {
543 return sawLine;
544 }
545
546 const line = this.#appMonitorBuffer
547 .slice(0, newlineIndex)
548 .replace(/\r$/, "");
549 this.#appMonitorBuffer = this.#appMonitorBuffer.slice(newlineIndex + 1);
550 sawLine = true;
551 this.#applyFrontmostState(parseFrontmostStateLine(line));
552 }
553 }
554
555 async #syncFrontmostState() {
556 try {
557 return this.#applyFrontmostState(await this.#readFrontmostState());
558 } catch (error) {
559 this.#emitMonitorError(error);
560 return { bundleId: this.#currentApp, windows: this.#windows };
561 }
562 }
563
564 #applyFrontmostState(state: FrontmostState) {
565 this.#setCurrentApp(state.bundleId);
566 this.#setWindows(state.windows);
567 return state;
568 }
569
570 #setCurrentApp(bundleId: string | null) {
571 if (this.#closed) {
572 return bundleId;
573 }
574 if (!bundleId) {
575 this.#currentApp = null;
576 return bundleId;
577 }
578 if (bundleId !== this.#currentApp) {
579 this.#currentApp = bundleId;
580 this.emit("app-change", bundleId);
581 }
582 return bundleId;
583 }
584
585 #setWindows(windows: readonly Mac.Window[]) {
586 if (this.#closed) {
587 return windows;
588 }
589 if (windowsEqual(this.#windows, windows)) {
590 return windows;
591 }
592
593 const previousWindow = getFocusedWindow(this.#windows);
594 this.#windows = windows;
595 this.emit("windows", windows);
596
597 const nextWindow = getFocusedWindow(windows);
598 if (!windowEquals(previousWindow, nextWindow)) {
599 this.emit("window", nextWindow);
600 }
601
602 return windows;
603 }
604
605 async #readFrontmostState() {
606 const helperPath = await ensureFrontmostAppHelperBinary();
607 const { stdout } = await execFileAsync(helperPath, [
608 FRONTMOST_HELPER_READ_ONCE_FLAG,
609 ]);
610 return parseFrontmostState(stdout);
611 }
612
613 async #enqueueKeyboardOperation<T>(operation: () => Promise<T>) {
614 const next = this.#keyboardQueue.then(operation, operation);
615 this.#keyboardQueue = next.then(
616 () => undefined,
617 () => undefined,
618 );
619 return next;
620 }
621
622 async #runKeyboardActions(actions: ReadonlyArray<KeyboardAction>) {
623 if (actions.length === 0) {
624 return;
625 }
626
627 try {
628 await execFileAsync("/usr/bin/osascript", [
629 "-l",
630 "JavaScript",
631 "-e",
632 KEYBOARD_EVENT_SCRIPT,
633 JSON.stringify({ actions }),
634 ]);
635 } catch (error) {
636 throw new Error(formatKeyboardDispatchError(error));
637 }
638 }
639
640 #assertOpen(message: string) {
641 if (this.#closed) {
642 throw new Error(message);
643 }
644 }
645
646 #emitMonitorError(error: unknown) {
647 queueMicrotask(() => {
648 this.emit("error", error);
649 });
650 }
651}
652
653export declare namespace Mac {
654 export interface Options {
655 /** Deprecated: app-change is now event-driven and no longer polls. */
656 pollIntervalMs?: number;
657 }
658
659 export interface KeyCode {
660 keyCode: number;
661 }
662
663 export type KeyName = MacKeyName;
664 export type Key = KeyName | number | KeyCode;
665
666 export interface KeyPressOptions {
667 holdMs?: number;
668 modifiers?: readonly Key[];
669 }
670
671 export interface ToastOptions {
672 detail?: string;
673 durationMs?: number;
674 }
675
676 export interface Window {
677 readonly id: number;
678 readonly title: string;
679 readonly main: boolean;
680 readonly focused: boolean;
681 }
682
683 export type EventMap = {
684 "app-change": [bundleId: string];
685 "close": [];
686 "error": [error: unknown];
687 "window": [window: Window | null];
688 "windows": [windows: readonly Window[]];
689 };
690
691 export type BundleId =
692 | (string & {})
693 | "com.apple.Chess"
694 | "com.apple.MobileSMS"
695 | "com.apple.Music"
696 | "com.apple.Notes"
697 | "com.apple.Preview"
698 | "com.apple.QuickTimePlayerX"
699 | "com.apple.Safari"
700 | "com.apple.finder"
701 | "com.apple.iCal"
702 | "com.blackmagic-design.fusion"
703 | "com.cockos.reaper"
704 | "com.google.Chrome"
705 | "com.google.Chrome"
706 | "com.mitchellh.ghostty"
707 | "org.mozilla.firefox"
708 | "org.whispersystems.signal-desktop";
709}
710
711function parseFrontmostState(stdout: string): FrontmostState {
712 const line = stdout.split(/\r?\n/u).find((candidate) => candidate.trim() !== "");
713 return parseFrontmostStateLine(line ?? "");
714}
715
716function parseFrontmostStateLine(line: string): FrontmostState {
717 const trimmed = line.trim();
718 if (trimmed === "") {
719 return { bundleId: null, windows: EMPTY_WINDOWS };
720 }
721
722 try {
723 const parsed = JSON.parse(trimmed);
724 if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) {
725 const record = parsed as { bundleId?: unknown; windows?: unknown };
726 return {
727 bundleId: normalizeBundleId(record.bundleId),
728 windows: normalizeWindows(record.windows),
729 };
730 }
731 } catch {
732 // Fall back to the older helper output if a stale binary is still running.
733 }
734
735 return {
736 bundleId: normalizeBundleId(trimmed),
737 windows: EMPTY_WINDOWS,
738 };
739}
740
741function normalizeBundleId(value: unknown) {
742 const directBundleId = typeof value === "string" ? value.trim() : "";
743 const bundleId = BUNDLE_ID_PATTERN.exec(directBundleId)?.[0]
744 ?? directBundleId;
745 if (
746 bundleId === ""
747 || bundleId === "undefined"
748 || bundleId === "[id nil]"
749 ) {
750 return null;
751 }
752 return bundleId;
753}
754
755function normalizeWindows(value: unknown): readonly Mac.Window[] {
756 if (!Array.isArray(value) || value.length === 0) {
757 return EMPTY_WINDOWS;
758 }
759
760 const windows: Mac.Window[] = [];
761 for (const candidate of value) {
762 const window = normalizeWindow(candidate);
763 if (window) {
764 windows.push(window);
765 }
766 }
767
768 return windows.length === 0 ? EMPTY_WINDOWS : Object.freeze(windows);
769}
770
771function normalizeWindow(value: unknown): Mac.Window | null {
772 if (!value || typeof value !== "object" || Array.isArray(value)) {
773 return null;
774 }
775
776 const record = value as {
777 id?: unknown;
778 title?: unknown;
779 main?: unknown;
780 focused?: unknown;
781 };
782 const id = normalizeWindowId(record.id);
783 if (id === null) {
784 return null;
785 }
786
787 return Object.freeze({
788 id,
789 title: typeof record.title === "string" ? record.title : "",
790 main: Boolean(record.main),
791 focused: Boolean(record.focused),
792 });
793}
794
795function normalizeWindowId(value: unknown) {
796 if (!Number.isSafeInteger(value)) {
797 return null;
798 }
799 return value;
800}
801
802function resolveWindowId(window: Mac.Window | number) {
803 const windowId = typeof window === "number" ? window : window?.id;
804 const normalizedId = normalizeWindowId(windowId);
805 if (normalizedId === null) {
806 throw new Error(
807 "Mac.focusWindow expects a window object returned by mac.windows or a numeric window id.",
808 );
809 }
810 return normalizedId;
811}
812
813function getFocusedWindow(windows: readonly Mac.Window[]) {
814 return windows.find((window) => window.focused) ?? null;
815}
816
817function getMainWindow(windows: readonly Mac.Window[]) {
818 return windows.find((window) => window.main) ?? null;
819}
820
821function windowsEqual(
822 left: readonly Mac.Window[],
823 right: readonly Mac.Window[],
824) {
825 if (left === right) {
826 return true;
827 }
828 if (left.length !== right.length) {
829 return false;
830 }
831
832 for (let index = 0; index < left.length; index += 1) {
833 if (!windowEquals(left[index], right[index])) {
834 return false;
835 }
836 }
837
838 return true;
839}
840
841function windowEquals(left: Mac.Window | null, right: Mac.Window | null) {
842 if (left === right) {
843 return true;
844 }
845 if (!left || !right) {
846 return left === right;
847 }
848 return left.id === right.id
849 && left.title === right.title
850 && left.main === right.main
851 && left.focused === right.focused;
852}
853
854function normalizeDelayMs(value: number, name: string) {
855 if (!Number.isFinite(value) || value < 0) {
856 throw new Error(`${name} must be a non-negative number`);
857 }
858 return Math.round(value);
859}
860
861function normalizeDurationMs(
862 value: number,
863 name: string,
864 min: number,
865 max: number,
866) {
867 const rounded = normalizeDelayMs(value, name);
868 return Math.min(max, Math.max(min, rounded));
869}
870
871function resolveKeyCode(key: Mac.Key): number {
872 if (typeof key === "number") {
873 return normalizeKeyCode(key);
874 }
875
876 if (typeof key === "string") {
877 const keyCode = KEY_CODE_LOOKUP.get(normalizeKeyName(key));
878 if (keyCode !== undefined) {
879 return keyCode;
880 }
881 throw new Error(
882 `Unknown Mac key "${key}". Use Mac.keyCodes for named keys or pass a numeric keyCode.`,
883 );
884 }
885
886 if (key && typeof key === "object" && "keyCode" in key) {
887 return normalizeKeyCode(key.keyCode);
888 }
889
890 throw new Error(`Unsupported Mac key: ${String(key)}`);
891}
892
893function normalizeKeyCode(keyCode: number) {
894 if (
895 !Number.isInteger(keyCode)
896 || keyCode < 0
897 || keyCode > 0xFFFF
898 ) {
899 throw new Error(`Mac keyCode must be an integer between 0 and 65535`);
900 }
901 return keyCode;
902}
903
904function uniqueKeyCodes(keys: readonly Mac.Key[]) {
905 const seen = new Set<number>();
906 const keyCodes: number[] = [];
907
908 for (const key of keys) {
909 const keyCode = resolveKeyCode(key);
910 if (seen.has(keyCode)) {
911 continue;
912 }
913 seen.add(keyCode);
914 keyCodes.push(keyCode);
915 }
916
917 return keyCodes;
918}
919
920function normalizeKeyName(key: string) {
921 return key.trim().toLowerCase().replace(/[\s_-]+/g, "");
922}
923
924function createKeyCodeLookup(
925 keyCodes: Record<string, number>,
926 aliases: Partial<Record<MacKeyName, readonly string[]>>,
927) {
928 const lookup = new Map<string, number>();
929
930 for (const [name, keyCode] of Object.entries(keyCodes)) {
931 lookup.set(normalizeKeyName(name), keyCode);
932 if (name.startsWith("numpad")) {
933 lookup.set(
934 normalizeKeyName(`keypad${name.slice("numpad".length)}`),
935 keyCode,
936 );
937 }
938 }
939
940 for (const [canonicalName, names] of Object.entries(aliases)) {
941 const keyCode = keyCodes[canonicalName];
942 if (keyCode === undefined) {
943 continue;
944 }
945 for (const name of names ?? []) {
946 lookup.set(normalizeKeyName(name), keyCode);
947 }
948 }
949
950 return lookup;
951}
952
953function formatKeyboardDispatchError(error: unknown) {
954 const details = extractCommandErrorOutput(error);
955 const suffix = details ? ` ${details}` : "";
956 return (
957 "Failed to send a macOS keyboard event via osascript. "
958 + "Make sure this process is allowed in System Settings > Privacy & Security > Accessibility."
959 + suffix
960 );
961}
962
963async function dispatchToast(message: string, options: Mac.ToastOptions) {
964 const payload = {
965 message: normalizeToastText(message, "message"),
966 detail: normalizeToastDetail(options.detail),
967 durationMs: normalizeDurationMs(
968 options.durationMs ?? 1000,
969 "toast durationMs",
970 250,
971 4000,
972 ),
973 };
974
975 try {
976 const helperPath = await ensureToastHelperBinary();
977 await execFileAsync(helperPath, [
978 payload.message,
979 payload.detail,
980 String(payload.durationMs / 1000),
981 ]);
982 } catch (error) {
983 throw new Error(formatToastDispatchError(error));
984 }
985}
986
987async function ensureToastHelperBinary() {
988 if (!toastHelperBinaryPromise) {
989 toastHelperBinaryPromise = buildObjectiveCHelperBinary(
990 TOAST_HELPER_SOURCE_PATH,
991 TOAST_HELPER_BINARY_PATH,
992 DEFAULT_OBJECTIVE_C_FRAMEWORKS,
993 ).catch((error) => {
994 toastHelperBinaryPromise = null;
995 throw error;
996 });
997 }
998
999 return toastHelperBinaryPromise;
1000}
1001
1002async function ensureFrontmostAppHelperBinary() {
1003 if (!frontmostAppHelperBinaryPromise) {
1004 frontmostAppHelperBinaryPromise = buildObjectiveCHelperBinary(
1005 FRONTMOST_APP_HELPER_SOURCE_PATH,
1006 FRONTMOST_APP_HELPER_BINARY_PATH,
1007 FRONTMOST_APP_HELPER_FRAMEWORKS,
1008 ).catch((error) => {
1009 frontmostAppHelperBinaryPromise = null;
1010 throw error;
1011 });
1012 }
1013
1014 return frontmostAppHelperBinaryPromise;
1015}
1016
1017async function buildObjectiveCHelperBinary(
1018 sourcePath: string,
1019 binaryPath: string,
1020 frameworks: readonly string[],
1021) {
1022 await mkdir(HELPER_BUILD_DIR, { recursive: true });
1023
1024 const [sourceStats, binaryStats] = await Promise.all([
1025 stat(sourcePath),
1026 stat(binaryPath).catch(() => null),
1027 ]);
1028
1029 if (!binaryStats || binaryStats.mtimeMs < sourceStats.mtimeMs) {
1030 const args = ["-fobjc-arc"];
1031 for (const framework of frameworks) {
1032 args.push("-framework", framework);
1033 }
1034 args.push(sourcePath, "-o", binaryPath);
1035 await execFileAsync("/usr/bin/clang", args);
1036 }
1037
1038 return binaryPath;
1039}
1040
1041function extractCommandErrorOutput(error: unknown) {
1042 if (!error || typeof error !== "object") {
1043 return "";
1044 }
1045
1046 const stdout = "stdout" in error && typeof error.stdout === "string"
1047 ? error.stdout.trim()
1048 : "";
1049 const stderr = "stderr" in error && typeof error.stderr === "string"
1050 ? error.stderr.trim()
1051 : "";
1052 const message = error instanceof Error ? error.message.trim() : "";
1053
1054 return [stderr, stdout, message].find((value) => value !== "") ?? "";
1055}
1056
1057function formatToastDispatchError(error: unknown) {
1058 const details = extractCommandErrorOutput(error);
1059 const suffix = details ? ` ${details}` : "";
1060 return `Failed to show a macOS toast.${suffix}`;
1061}
1062
1063function formatWindowFocusError(error: unknown, target: string) {
1064 const details = extractCommandErrorOutput(error);
1065 const suffix = details ? ` ${details}` : "";
1066 return `Failed to focus ${target}.${suffix}`;
1067}
1068
1069function formatAppMonitorExitMessage(
1070 code: number | null,
1071 signal: NodeJS.Signals | null,
1072) {
1073 if (signal) {
1074 return `The macOS app monitor stopped after receiving ${signal}.`;
1075 }
1076 if (code === null || code === 0) {
1077 return "The macOS app monitor stopped unexpectedly.";
1078 }
1079 return `The macOS app monitor exited with code ${code}.`;
1080}
1081
1082function formatAppMonitorError(summary: string, error: unknown) {
1083 const details = extractCommandErrorOutput(error);
1084 const suffix = details ? ` ${details}` : "";
1085 return new Error(`${summary}${suffix}`);
1086}
1087
1088function normalizeToastText(value: string, name: string) {
1089 const text = value.trim();
1090 if (text === "") {
1091 throw new Error(`Mac.toast ${name} must be a non-empty string`);
1092 }
1093 return text;
1094}
1095
1096function normalizeToastDetail(detail: string | undefined) {
1097 if (detail === undefined) {
1098 return "";
1099 }
1100 return detail.trim();
1101}
1102
1103function sleep(ms: number) {
1104 return new Promise<void>((resolve) => {
1105 setTimeout(resolve, ms);
1106 });
1107}
control/src/Mac/frontmost_app_helper.m created+562
...@@ -0,0 +1,562 @@
1#import <AppKit/AppKit.h>
2#import <ApplicationServices/ApplicationServices.h>
3#import <Foundation/Foundation.h>
4
5static CFStringRef const kCloverAXWindowNumberAttribute = CFSTR("AXWindowNumber");
6
7static NSArray<id> *CopyWindowElements(AXUIElementRef applicationElement) {
8 if (!applicationElement) {
9 return @[];
10 }
11
12 CFTypeRef value = NULL;
13 AXError error = AXUIElementCopyAttributeValue(
14 applicationElement,
15 kAXWindowsAttribute,
16 &value
17 );
18 if (error != kAXErrorSuccess || !value) {
19 if (value) {
20 CFRelease(value);
21 }
22 return @[];
23 }
24 if (CFGetTypeID(value) != CFArrayGetTypeID()) {
25 CFRelease(value);
26 return @[];
27 }
28
29 return CFBridgingRelease(value);
30}
31
32static NSString *CopyStringAttribute(AXUIElementRef element, CFStringRef attribute) {
33 if (!element) {
34 return nil;
35 }
36
37 CFTypeRef value = NULL;
38 AXError error = AXUIElementCopyAttributeValue(element, attribute, &value);
39 if (error != kAXErrorSuccess || !value) {
40 if (value) {
41 CFRelease(value);
42 }
43 return nil;
44 }
45 if (CFGetTypeID(value) != CFStringGetTypeID()) {
46 CFRelease(value);
47 return nil;
48 }
49
50 return CFBridgingRelease(value);
51}
52
53static NSNumber *CopyNumberAttribute(AXUIElementRef element, CFStringRef attribute) {
54 if (!element) {
55 return nil;
56 }
57
58 CFTypeRef value = NULL;
59 AXError error = AXUIElementCopyAttributeValue(element, attribute, &value);
60 if (error != kAXErrorSuccess || !value) {
61 if (value) {
62 CFRelease(value);
63 }
64 return nil;
65 }
66 if (CFGetTypeID(value) != CFNumberGetTypeID()) {
67 CFRelease(value);
68 return nil;
69 }
70
71 return CFBridgingRelease(value);
72}
73
74static BOOL CopyBoolAttribute(
75 AXUIElementRef element,
76 CFStringRef attribute,
77 BOOL fallback
78) {
79 if (!element) {
80 return fallback;
81 }
82
83 CFTypeRef value = NULL;
84 AXError error = AXUIElementCopyAttributeValue(element, attribute, &value);
85 if (error != kAXErrorSuccess || !value) {
86 if (value) {
87 CFRelease(value);
88 }
89 return fallback;
90 }
91
92 BOOL result = fallback;
93 CFTypeID typeId = CFGetTypeID(value);
94 if (typeId == CFBooleanGetTypeID()) {
95 result = CFBooleanGetValue((CFBooleanRef)value);
96 } else if (typeId == CFNumberGetTypeID()) {
97 int numericValue = 0;
98 if (CFNumberGetValue((CFNumberRef)value, kCFNumberIntType, &numericValue)) {
99 result = numericValue != 0;
100 }
101 }
102
103 CFRelease(value);
104 return result;
105}
106
107static NSNumber *WindowIdentifierForElement(AXUIElementRef windowElement, NSInteger index) {
108 NSNumber *windowNumber = CopyNumberAttribute(windowElement, kCloverAXWindowNumberAttribute);
109 return windowNumber ?: @(-(index + 1));
110}
111
112static NSDictionary<NSString *, id> *SnapshotWindow(
113 AXUIElementRef windowElement,
114 NSInteger index
115) {
116 if (!windowElement) {
117 return nil;
118 }
119
120 return @{
121 @"id": WindowIdentifierForElement(windowElement, index),
122 @"title": CopyStringAttribute(windowElement, kAXTitleAttribute) ?: @"",
123 @"main": @(CopyBoolAttribute(windowElement, kAXMainAttribute, NO)),
124 @"focused": @(CopyBoolAttribute(windowElement, kAXFocusedAttribute, NO)),
125 };
126}
127
128static NSArray<NSDictionary<NSString *, id> *> *SnapshotWindowsForApplicationElement(
129 AXUIElementRef applicationElement
130) {
131 NSArray<id> *windowElements = CopyWindowElements(applicationElement);
132 NSMutableArray<NSDictionary<NSString *, id> *> *snapshots = [NSMutableArray arrayWithCapacity:windowElements.count];
133
134 for (NSUInteger index = 0; index < windowElements.count; index++) {
135 AXUIElementRef windowElement = (__bridge AXUIElementRef)windowElements[index];
136 NSDictionary<NSString *, id> *snapshot = SnapshotWindow(windowElement, index);
137 if (snapshot) {
138 [snapshots addObject:snapshot];
139 }
140 }
141
142 return snapshots;
143}
144
145static void PrintState(
146 NSRunningApplication *application,
147 NSArray<NSDictionary<NSString *, id> *> *windows
148) {
149 NSDictionary<NSString *, id> *payload = @{
150 @"bundleId": application.bundleIdentifier ?: [NSNull null],
151 @"windows": windows ?: @[],
152 };
153
154 NSError *error = nil;
155 NSData *json = [NSJSONSerialization dataWithJSONObject:payload options:0 error:&error];
156 if (!json || error) {
157 const char *message = error.localizedDescription.UTF8String ?: "Failed to encode state";
158 fprintf(stderr, "%s\n", message);
159 return;
160 }
161
162 fwrite(json.bytes, 1, json.length, stdout);
163 fputc('\n', stdout);
164 fflush(stdout);
165}
166
167static void PrintCurrentState(void) {
168 NSRunningApplication *application = NSWorkspace.sharedWorkspace.frontmostApplication;
169 id applicationElement = application
170 ? CFBridgingRelease(AXUIElementCreateApplication(application.processIdentifier))
171 : nil;
172 PrintState(
173 application,
174 SnapshotWindowsForApplicationElement((__bridge AXUIElementRef)applicationElement)
175 );
176}
177
178static AXUIElementRef CopyWindowElementForIdentifier(
179 AXUIElementRef applicationElement,
180 long long targetIdentifier
181) {
182 NSArray<id> *windowElements = CopyWindowElements(applicationElement);
183 for (NSUInteger index = 0; index < windowElements.count; index++) {
184 AXUIElementRef windowElement = (__bridge AXUIElementRef)windowElements[index];
185 if (WindowIdentifierForElement(windowElement, index).longLongValue == targetIdentifier) {
186 return (AXUIElementRef)CFRetain(windowElement);
187 }
188 }
189 return NULL;
190}
191
192static AXUIElementRef CopyMainWindowElement(AXUIElementRef applicationElement) {
193 if (!applicationElement) {
194 return NULL;
195 }
196
197 CFTypeRef value = NULL;
198 AXError error = AXUIElementCopyAttributeValue(
199 applicationElement,
200 kAXMainWindowAttribute,
201 &value
202 );
203 if (error == kAXErrorSuccess && value) {
204 if (CFGetTypeID(value) == AXUIElementGetTypeID()) {
205 return (AXUIElementRef)value;
206 }
207 CFRelease(value);
208 }
209
210 NSArray<id> *windowElements = CopyWindowElements(applicationElement);
211 for (NSUInteger index = 0; index < windowElements.count; index++) {
212 AXUIElementRef windowElement = (__bridge AXUIElementRef)windowElements[index];
213 if (CopyBoolAttribute(windowElement, kAXMainAttribute, NO)) {
214 return (AXUIElementRef)CFRetain(windowElement);
215 }
216 }
217
218 return NULL;
219}
220
221static BOOL FocusWindowElement(
222 NSRunningApplication *application,
223 AXUIElementRef windowElement,
224 NSString **failure
225) {
226 if (!application || !windowElement) {
227 if (failure) {
228 *failure = @"No window is available to focus.";
229 }
230 return NO;
231 }
232
233 [application activateWithOptions:NSApplicationActivateAllWindows];
234
235 AXError unminimizeError = AXUIElementSetAttributeValue(
236 windowElement,
237 kAXMinimizedAttribute,
238 kCFBooleanFalse
239 );
240 AXError raiseError = AXUIElementPerformAction(windowElement, kAXRaiseAction);
241 AXError mainError = AXUIElementSetAttributeValue(
242 windowElement,
243 kAXMainAttribute,
244 kCFBooleanTrue
245 );
246 AXError focusedError = AXUIElementSetAttributeValue(
247 windowElement,
248 kAXFocusedAttribute,
249 kCFBooleanTrue
250 );
251
252 BOOL succeeded =
253 unminimizeError == kAXErrorSuccess ||
254 raiseError == kAXErrorSuccess ||
255 mainError == kAXErrorSuccess ||
256 focusedError == kAXErrorSuccess;
257 if (succeeded) {
258 return YES;
259 }
260
261 if (failure) {
262 *failure = [NSString stringWithFormat:
263 @"Could not focus the requested window (unminimize=%d raise=%d main=%d focused=%d).",
264 (int)unminimizeError,
265 (int)raiseError,
266 (int)mainError,
267 (int)focusedError
268 ];
269 }
270 return NO;
271}
272
273static BOOL FocusWindowWithIdentifier(long long targetIdentifier) {
274 if (!AXIsProcessTrusted()) {
275 fprintf(stderr, "%s\n", "Accessibility access is required to focus windows.");
276 return NO;
277 }
278
279 NSRunningApplication *application = NSWorkspace.sharedWorkspace.frontmostApplication;
280 if (!application) {
281 fprintf(stderr, "%s\n", "No frontmost application is available.");
282 return NO;
283 }
284
285 id applicationElement = CFBridgingRelease(AXUIElementCreateApplication(application.processIdentifier));
286 AXUIElementRef windowElement = CopyWindowElementForIdentifier(
287 (__bridge AXUIElementRef)applicationElement,
288 targetIdentifier
289 );
290 if (!windowElement) {
291 fprintf(stderr, "Window %lld was not found.\n", targetIdentifier);
292 return NO;
293 }
294
295 NSString *failure = nil;
296 BOOL focused = FocusWindowElement(application, windowElement, &failure);
297 CFRelease(windowElement);
298 if (!focused) {
299 fprintf(stderr, "%s\n", failure.UTF8String ?: "Failed to focus the requested window.");
300 }
301 return focused;
302}
303
304static BOOL FocusMainWindow(void) {
305 if (!AXIsProcessTrusted()) {
306 fprintf(stderr, "%s\n", "Accessibility access is required to focus windows.");
307 return NO;
308 }
309
310 NSRunningApplication *application = NSWorkspace.sharedWorkspace.frontmostApplication;
311 if (!application) {
312 fprintf(stderr, "%s\n", "No frontmost application is available.");
313 return NO;
314 }
315
316 id applicationElement = CFBridgingRelease(AXUIElementCreateApplication(application.processIdentifier));
317 AXUIElementRef windowElement = CopyMainWindowElement((__bridge AXUIElementRef)applicationElement);
318 if (!windowElement) {
319 fprintf(stderr, "%s\n", "The frontmost application does not report a main window.");
320 return NO;
321 }
322
323 NSString *failure = nil;
324 BOOL focused = FocusWindowElement(application, windowElement, &failure);
325 CFRelease(windowElement);
326 if (!focused) {
327 fprintf(stderr, "%s\n", failure.UTF8String ?: "Failed to focus the main window.");
328 }
329 return focused;
330}
331
332@interface FrontmostAppObserver : NSObject {
333 @private
334 id _accessibilityObserver;
335 id _applicationElement;
336 NSMutableArray<id> *_windowElements;
337}
338- (void)start;
339- (void)handleActivation:(NSNotification *)notification;
340- (void)handleAccessibilityNotification:(NSString *)notification;
341@end
342
343static void FrontmostAccessibilityCallback(
344 AXObserverRef observer,
345 AXUIElementRef element,
346 CFStringRef notification,
347 void *context
348) {
349 @autoreleasepool {
350 FrontmostAppObserver *frontmostObserver = (__bridge FrontmostAppObserver *)context;
351 [frontmostObserver handleAccessibilityNotification:(__bridge NSString *)notification];
352 }
353}
354
355@implementation FrontmostAppObserver
356
357- (instancetype)init {
358 self = [super init];
359 if (self) {
360 _windowElements = [NSMutableArray array];
361 }
362 return self;
363}
364
365- (void)dealloc {
366 [NSWorkspace.sharedWorkspace.notificationCenter removeObserver:self];
367 [self clearObservedApplication];
368}
369
370- (AXObserverRef)observerRef {
371 return (__bridge AXObserverRef)_accessibilityObserver;
372}
373
374- (AXUIElementRef)applicationElementRef {
375 return (__bridge AXUIElementRef)_applicationElement;
376}
377
378- (void)start {
379 NSWorkspace *workspace = NSWorkspace.sharedWorkspace;
380 [workspace.notificationCenter addObserver:self
381 selector:@selector(handleActivation:)
382 name:NSWorkspaceDidActivateApplicationNotification
383 object:nil];
384 [self observeApplication:workspace.frontmostApplication];
385}
386
387- (void)handleActivation:(NSNotification *)notification {
388 NSRunningApplication *application = notification.userInfo[NSWorkspaceApplicationKey];
389 [self observeApplication:application];
390}
391
392- (void)handleAccessibilityNotification:(NSString *)notification {
393 (void)notification;
394 [self refreshWindowsAndEmit];
395}
396
397- (void)observeApplication:(NSRunningApplication *)application {
398 [self clearObservedApplication];
399 if (!application) {
400 PrintState(nil, @[]);
401 return;
402 }
403
404 _applicationElement = CFBridgingRelease(AXUIElementCreateApplication(application.processIdentifier));
405
406 AXObserverRef observer = NULL;
407 AXError observerError = AXObserverCreate(
408 application.processIdentifier,
409 FrontmostAccessibilityCallback,
410 &observer
411 );
412 if (observerError == kAXErrorSuccess && observer) {
413 _accessibilityObserver = CFBridgingRelease(observer);
414 CFRunLoopAddSource(
415 CFRunLoopGetCurrent(),
416 AXObserverGetRunLoopSource(self.observerRef),
417 kCFRunLoopDefaultMode
418 );
419
420 [self addApplicationNotification:kAXFocusedWindowChangedNotification];
421 [self addApplicationNotification:kAXMainWindowChangedNotification];
422 [self addApplicationNotification:kAXWindowCreatedNotification];
423 }
424
425 [self refreshWindowsAndEmit];
426}
427
428- (void)clearObservedApplication {
429 [self clearWindowNotifications];
430
431 AXObserverRef observer = self.observerRef;
432 AXUIElementRef applicationElement = self.applicationElementRef;
433 if (observer && applicationElement) {
434 AXObserverRemoveNotification(observer, applicationElement, kAXFocusedWindowChangedNotification);
435 AXObserverRemoveNotification(observer, applicationElement, kAXMainWindowChangedNotification);
436 AXObserverRemoveNotification(observer, applicationElement, kAXWindowCreatedNotification);
437 }
438 if (observer) {
439 CFRunLoopRemoveSource(
440 CFRunLoopGetCurrent(),
441 AXObserverGetRunLoopSource(observer),
442 kCFRunLoopDefaultMode
443 );
444 }
445
446 _accessibilityObserver = nil;
447 _applicationElement = nil;
448}
449
450- (void)clearWindowNotifications {
451 AXObserverRef observer = self.observerRef;
452 if (observer) {
453 for (id windowObject in _windowElements) {
454 AXUIElementRef windowElement = (__bridge AXUIElementRef)windowObject;
455 AXObserverRemoveNotification(observer, windowElement, kAXTitleChangedNotification);
456 AXObserverRemoveNotification(observer, windowElement, kAXUIElementDestroyedNotification);
457 }
458 }
459
460 [_windowElements removeAllObjects];
461}
462
463- (void)addApplicationNotification:(CFStringRef)notification {
464 AXObserverRef observer = self.observerRef;
465 AXUIElementRef applicationElement = self.applicationElementRef;
466 if (!observer || !applicationElement) {
467 return;
468 }
469
470 AXObserverAddNotification(
471 observer,
472 applicationElement,
473 notification,
474 (__bridge void *)self
475 );
476}
477
478- (void)addWindowNotification:(CFStringRef)notification element:(AXUIElementRef)windowElement {
479 AXObserverRef observer = self.observerRef;
480 if (!observer || !windowElement) {
481 return;
482 }
483
484 AXObserverAddNotification(
485 observer,
486 windowElement,
487 notification,
488 (__bridge void *)self
489 );
490}
491
492- (NSArray<NSDictionary<NSString *, id> *> *)refreshObservedWindows {
493 [self clearWindowNotifications];
494
495 NSArray<id> *windowElements = CopyWindowElements(self.applicationElementRef);
496 NSMutableArray<NSDictionary<NSString *, id> *> *windows = [NSMutableArray arrayWithCapacity:windowElements.count];
497
498 for (NSUInteger index = 0; index < windowElements.count; index++) {
499 id windowObject = windowElements[index];
500 AXUIElementRef windowElement = (__bridge AXUIElementRef)windowObject;
501 NSDictionary<NSString *, id> *snapshot = SnapshotWindow(windowElement, index);
502 if (!snapshot) {
503 continue;
504 }
505
506 [_windowElements addObject:windowObject];
507 [self addWindowNotification:kAXTitleChangedNotification element:windowElement];
508 [self addWindowNotification:kAXUIElementDestroyedNotification element:windowElement];
509 [windows addObject:snapshot];
510 }
511
512 return windows;
513}
514
515- (void)refreshWindowsAndEmit {
516 PrintState(
517 NSWorkspace.sharedWorkspace.frontmostApplication,
518 [self refreshObservedWindows]
519 );
520}
521
522@end
523
524int main(int argc, const char *argv[]) {
525 @autoreleasepool {
526 [NSApplication sharedApplication];
527 [NSApp setActivationPolicy:NSApplicationActivationPolicyProhibited];
528
529 if (argc > 1) {
530 NSString *command = [NSString stringWithUTF8String:argv[1]];
531 if ([command isEqualToString:@"--once"]) {
532 PrintCurrentState();
533 return 0;
534 }
535 if ([command isEqualToString:@"--focus-window"]) {
536 if (argc < 3) {
537 fprintf(stderr, "%s\n", "Missing window identifier.");
538 return 1;
539 }
540 char *end = NULL;
541 long long windowIdentifier = strtoll(argv[2], &end, 10);
542 if (end == argv[2] || (end && *end != '\0')) {
543 fprintf(stderr, "%s\n", "Window identifier must be an integer.");
544 return 1;
545 }
546 return FocusWindowWithIdentifier(windowIdentifier) ? 0 : 1;
547 }
548 if ([command isEqualToString:@"--focus-main-window"]) {
549 return FocusMainWindow() ? 0 : 1;
550 }
551
552 fprintf(stderr, "Unknown argument: %s\n", argv[1]);
553 return 1;
554 }
555
556 FrontmostAppObserver *observer = [FrontmostAppObserver new];
557 [observer start];
558 [[NSRunLoop currentRunLoop] run];
559 }
560
561 return 0;
562}
control/src/Mac/toast_helper.m created+98
...@@ -0,0 +1,98 @@
1#import <AppKit/AppKit.h>
2#import <Foundation/Foundation.h>
3
4static NSTextField *MakeLabel(NSRect frame, NSString *text, NSFont *font, NSColor *color) {
5 NSTextField *label = [[NSTextField alloc] initWithFrame:frame];
6 [label setStringValue:text ?: @""];
7 [label setBezeled:NO];
8 [label setBordered:NO];
9 [label setDrawsBackground:NO];
10 [label setEditable:NO];
11 [label setSelectable:NO];
12 [label setAlignment:NSTextAlignmentCenter];
13 [label setTextColor:color];
14 [label setFont:font];
15 [label setLineBreakMode:NSLineBreakByTruncatingTail];
16 [label setUsesSingleLineMode:YES];
17 return label;
18}
19
20int main(int argc, const char *argv[]) {
21 @autoreleasepool {
22 NSString *message = argc > 1 ? [NSString stringWithUTF8String:argv[1]] : @"Toast";
23 NSString *detail = argc > 2 ? [NSString stringWithUTF8String:argv[2]] : @"";
24 double durationSeconds = argc > 3 ? strtod(argv[3], NULL) : 1.0;
25 if (durationSeconds < 0.25) {
26 durationSeconds = 0.25;
27 }
28 if (durationSeconds > 4.0) {
29 durationSeconds = 4.0;
30 }
31
32 [NSApplication sharedApplication];
33 [NSApp setActivationPolicy:NSApplicationActivationPolicyAccessory];
34
35 CGFloat width = MIN(420.0, MAX(220.0, (CGFloat)message.length * 9.0 + 72.0));
36 CGFloat height = detail.length > 0 ? 72.0 : 52.0;
37 NSRect screenFrame = NSScreen.mainScreen ? NSScreen.mainScreen.visibleFrame : NSMakeRect(0, 0, 1440, 900);
38 CGFloat x = NSMidX(screenFrame) - (width / 2.0);
39 CGFloat y = NSMinY(screenFrame) + 72.0;
40
41 NSPanel *window = [[NSPanel alloc]
42 initWithContentRect:NSMakeRect(x, y, width, height)
43 styleMask:NSWindowStyleMaskBorderless | NSWindowStyleMaskNonactivatingPanel
44 backing:NSBackingStoreBuffered
45 defer:NO];
46 [window setOpaque:NO];
47 [window setBackgroundColor:NSColor.clearColor];
48 [window setHasShadow:YES];
49 [window setIgnoresMouseEvents:YES];
50 [window setFloatingPanel:YES];
51 [window setHidesOnDeactivate:NO];
52 [window setLevel:NSStatusWindowLevel];
53 [window setCollectionBehavior:NSWindowCollectionBehaviorCanJoinAllSpaces |
54 NSWindowCollectionBehaviorFullScreenAuxiliary];
55
56 NSView *contentView = window.contentView;
57 contentView.wantsLayer = YES;
58 contentView.layer.backgroundColor = [[NSColor colorWithCalibratedWhite:0.08 alpha:0.92] CGColor];
59 contentView.layer.cornerRadius = 14.0;
60 contentView.layer.masksToBounds = YES;
61 contentView.layer.borderWidth = 1.0;
62 contentView.layer.borderColor = [[NSColor colorWithCalibratedWhite:1.0 alpha:0.12] CGColor];
63
64 CGFloat titleY = detail.length > 0 ? 34.0 : 15.0;
65 [contentView addSubview:MakeLabel(
66 NSMakeRect(18, titleY, width - 36, 20),
67 message,
68 [NSFont boldSystemFontOfSize:13.0],
69 NSColor.whiteColor)];
70
71 if (detail.length > 0) {
72 [contentView addSubview:MakeLabel(
73 NSMakeRect(18, 14, width - 36, 16),
74 detail,
75 [NSFont systemFontOfSize:11.0],
76 [NSColor colorWithCalibratedWhite:1.0 alpha:0.72])];
77 }
78
79 [window setAlphaValue:0.0];
80 [window orderFrontRegardless];
81
82 for (NSInteger step = 1; step <= 5; step += 1) {
83 [window setAlphaValue:(CGFloat)step / 5.0];
84 [[NSRunLoop currentRunLoop] runUntilDate:[NSDate dateWithTimeIntervalSinceNow:0.02]];
85 }
86
87 [[NSRunLoop currentRunLoop] runUntilDate:[NSDate dateWithTimeIntervalSinceNow:durationSeconds]];
88
89 for (NSInteger step = 4; step >= 0; step -= 1) {
90 [window setAlphaValue:(CGFloat)step / 5.0];
91 [[NSRunLoop currentRunLoop] runUntilDate:[NSDate dateWithTimeIntervalSinceNow:0.02]];
92 }
93
94 [window orderOut:nil];
95 }
96
97 return 0;
98}
control/src/Reaper.ts created+973
...@@ -0,0 +1,973 @@
1import { Events } from "@clo/lib/Events.ts";
2import * as log from "@clo/lib/log.ts";
3import { execFile } from "node:child_process";
4import { createSocket, type Socket } from "node:dgram";
5import { type FSWatcher, readFileSync, watch } from "node:fs";
6import { cp, mkdir, readFile, writeFile } from "node:fs/promises";
7import { basename, dirname, join } from "node:path";
8import process from "node:process";
9import { fileURLToPath } from "node:url";
10import { promisify } from "node:util";
11import { REAPER_ACTIONS, type ReaperActionId } from "./Reaper/actions.ts";
12const console = log.scoped("reaper");
13
14export type { ReaperActionId } from "./Reaper/actions.ts";
15
16type ReaperCommandId = number;
17
18export interface ReaperOptions {
19 oscHost?: string;
20 oscPort?: number;
21 oscBindPort?: number;
22}
23
24export interface ReaperTransportState {
25 playing: boolean;
26 paused: boolean;
27 recording: boolean;
28 repeatOn: boolean;
29 positionSeconds: number;
30 positionString: string;
31 positionBeatsString: string;
32 /** Project tempo in BPM (live, from the feedback script). */
33 tempo: number;
34 /** Project time signature as "num/denom" (live, from the feedback script). */
35 timeSignature: string;
36 readAtMs: number;
37 source: "osc" | "optimistic";
38}
39
40type ReaperScriptName = string;
41
42type OscScalar = number | string | boolean;
43type OscMessage = {
44 address: string;
45 args: OscScalar[];
46};
47type ReaperTransportPatch = Partial<ReaperTransportState>;
48
49const DEFAULT_REAPER_BIN = "/Applications/REAPER.app/Contents/MacOS/REAPER";
50const DEFAULT_REAPER_OSC_HOST = process.env.REAPER_OSC_HOST ?? "127.0.0.1";
51const DEFAULT_REAPER_OSC_PORT = readNumberEnv(
52 ["REAPER_OSC_PORT", "REAPER_OSC_TARGET_PORT"],
53 58_000,
54);
55const DEFAULT_REAPER_OSC_BIND_PORT = readNumberEnv(
56 ["REAPER_OSC_BIND_PORT", "REAPER_OSC_FEEDBACK_PORT"],
57 58_001,
58);
59const SCRUB_FLUSH_MS = 25;
60const SCRUB_OSC_VALUE_PER_TICK = readNumberEnv(
61 [
62 "REAPER_SCRUB_OSC_VALUE_PER_TICK",
63 "REAPER_SCRUB_SECONDS_PER_TICK",
64 "REAPER_JOG_SECONDS_PER_TICK",
65 ],
66 0.1,
67);
68const MAX_SCRUB_OSC_VALUE = readNumberEnv(
69 [
70 "REAPER_MAX_SCRUB_OSC_VALUE",
71 "REAPER_MAX_SCRUB_STEP_SECONDS",
72 "REAPER_MAX_JOG_STEP_SECONDS",
73 ],
74 5,
75);
76const REAPER_SUPPORT_SOURCE_DIR = join(
77 dirname(fileURLToPath(import.meta.url)),
78 "..",
79 "config",
80 "reaper",
81);
82const DEFAULT_REAPER_RESOURCE_DIR = join(
83 process.env.HOME ?? "",
84 "Library",
85 "Application Support",
86 "REAPER",
87);
88const DEFAULT_REAPER_CONFIG_PATH = join(
89 DEFAULT_REAPER_RESOURCE_DIR,
90 "reaper.ini",
91);
92const DEFAULT_REAPER_SCRIPT_TARGET_DIR = join(
93 DEFAULT_REAPER_RESOURCE_DIR,
94 "Scripts",
95 "clover",
96);
97const DEFAULT_REAPER_OSC_TARGET_DIR = join(
98 DEFAULT_REAPER_RESOURCE_DIR,
99 "OSC",
100);
101const REAPER_SCRIPT_TARGET_DIR = process.env.REAPER_SCRIPTS_DIR
102 ?? DEFAULT_REAPER_SCRIPT_TARGET_DIR;
103// The feedback script writes live tempo/time-signature here (next to itself);
104// we watch the file for changes. See config/reaper/scripts/clover_feedback.lua.
105const REAPER_FEEDBACK_DIR = join(REAPER_SCRIPT_TARGET_DIR, "scripts");
106const REAPER_FEEDBACK_FILE = "state.json";
107const REAPER_FEEDBACK_STATE_PATH = join(REAPER_FEEDBACK_DIR, REAPER_FEEDBACK_FILE);
108const REAPER_FEEDBACK_SCRIPT = "clover_feedback";
109const REAPER_OSC_TARGET_DIR = process.env.REAPER_OSC_DIR
110 ?? DEFAULT_REAPER_OSC_TARGET_DIR;
111const OSC_PATTERN_FILE = "CloverAutomation.ReaperOSC";
112const OSC_PATTERN_NAME = stripReaperOscExtension(OSC_PATTERN_FILE);
113const OSC_PATTERN_CONFIG = `# OSC pattern config file for Clover Creative Control's REAPER integration.
114DEVICE_TRACK_COUNT 1
115DEVICE_SEND_COUNT 0
116DEVICE_RECEIVE_COUNT 0
117DEVICE_FX_COUNT 0
118DEVICE_FX_PARAM_COUNT 0
119DEVICE_FX_INST_PARAM_COUNT 0
120DEVICE_MARKER_COUNT 0
121DEVICE_REGION_COUNT 0
122
123REAPER_TRACK_FOLLOWS REAPER
124DEVICE_TRACK_FOLLOWS DEVICE
125DEVICE_TRACK_BANK_FOLLOWS DEVICE
126DEVICE_FX_FOLLOWS DEVICE
127DEVICE_ROTARY_CENTER 0
128
129# ----------------------------------------------------------------
130
131RECORD t/clover/record
132STOP t/clover/stop
133PLAY t/clover/play
134PAUSE t/clover/pause
135SCRUB r/clover/scrub
136
137ACTION i/clover/action s/clover/action/str t/clover/action/@`;
138const OSC_ADDRESS = {
139 action: "/clover/action",
140 actionString: "/clover/action/str",
141 play: "/clover/play",
142 stop: "/clover/stop",
143 pause: "/clover/pause",
144 record: "/clover/record",
145 scrub: "/clover/scrub",
146} as const;
147const execFileAsync = promisify(execFile);
148
149export class Reaper extends Events<Reaper.EventMap> {
150 #transport = blankTransportState();
151 #pendingScrubDelta = 0;
152 #scrubTimer: ReturnType<typeof setTimeout> | null = null;
153 #warnedOscSocket = false;
154 #managedScriptsPromise: Promise<void> | null = null;
155 #oscHost: string;
156 #oscPort: number;
157 #oscBindPort: number;
158 #receivedOscFeedback = false;
159 #receiveSocket: Socket;
160 #sendSocket: Socket;
161 #closed = false;
162 #feedbackWatcher: FSWatcher | null = null;
163 #feedbackLaunched = false;
164
165 constructor(options: ReaperOptions = {}) {
166 super();
167 this.#oscHost = options.oscHost ?? DEFAULT_REAPER_OSC_HOST;
168 this.#oscPort = options.oscPort ?? DEFAULT_REAPER_OSC_PORT;
169 this.#oscBindPort = options.oscBindPort ?? DEFAULT_REAPER_OSC_BIND_PORT;
170 this.#receiveSocket = createSocket("udp4");
171 this.#sendSocket = createSocket("udp4");
172 this.#setupOscSockets();
173 this.#warnIfOscSurfaceMissing();
174 void this.#installOscPatternConfig().catch((error) => {
175 this.#logOscPatternInstallError(error);
176 });
177 void this.#startFeedback();
178 }
179
180 get transport(): ReaperTransportState {
181 return { ...this.#transport };
182 }
183
184 #warnIfOscSurfaceMissing() {
185 if (hasManagedOscSurface(readReaperConfig())) {
186 return;
187 }
188
189 console.info(
190 `${OSC_PATTERN_FILE} is not registered yet. `
191 + `Add an OSC control surface manually using the "${OSC_PATTERN_NAME}" pattern, set REAPER's local listen port to `
192 + `${this.#oscPort}, and send to ${this.#oscHost}:${this.#oscBindPort}. `
193 + "The REAPER web interface is not required for this config.",
194 );
195 }
196
197 close() {
198 if (this.#closed) {
199 return;
200 }
201
202 this.#closed = true;
203 this.#clearScrubTimer();
204
205 this.#feedbackWatcher?.close();
206 this.#feedbackWatcher = null;
207
208 this.#receiveSocket.removeAllListeners();
209 this.#sendSocket.removeAllListeners();
210 closeSocket(this.#receiveSocket);
211 closeSocket(this.#sendSocket);
212 }
213
214 async runAction(actionId: ReaperActionId) {
215 const sent = await this.#sendCommand(REAPER_ACTIONS[actionId]);
216 if (!sent) {
217 return sent;
218 }
219
220 const patch = optimisticTransportPatchForAction(actionId, this.#transport);
221 if (patch) {
222 this.#updateTransport(patch, "optimistic");
223 }
224
225 return sent;
226 }
227
228 async #sendCommand(commandId: ReaperCommandId) {
229 return this.#sendLoggedOscMessage(
230 OSC_ADDRESS.action,
231 [commandId],
232 `run action ${commandId}`,
233 );
234 }
235
236 async #ensureManagedScripts() {
237 if (!this.#managedScriptsPromise) {
238 this.#managedScriptsPromise = this.#installManagedScripts()
239 .catch((error) => {
240 this.#managedScriptsPromise = null;
241 this.#logInstallError(error);
242 throw error;
243 });
244 }
245
246 await this.#managedScriptsPromise;
247 }
248
249 async runScript(name: ReaperScriptName) {
250 try {
251 await this.#ensureManagedScripts();
252 await this.#runManagedScript(name);
253 return true;
254 } catch (error) {
255 this.#logScriptError(name, error);
256 return false;
257 }
258 }
259
260 scrub(value: number) {
261 this.#queueScrubDelta(value * SCRUB_OSC_VALUE_PER_TICK);
262 }
263
264 async #flushScrub() {
265 const delta = clamp(
266 this.#pendingScrubDelta,
267 -MAX_SCRUB_OSC_VALUE,
268 MAX_SCRUB_OSC_VALUE,
269 );
270 this.#pendingScrubDelta = 0;
271
272 if (delta === 0) {
273 return;
274 }
275
276 await this.#sendLoggedOscMessage(
277 OSC_ADDRESS.scrub,
278 [delta],
279 "scrub playhead",
280 );
281 }
282
283 async #installManagedScripts() {
284 await mkdir(REAPER_SCRIPT_TARGET_DIR, { recursive: true });
285 await cp(REAPER_SUPPORT_SOURCE_DIR, REAPER_SCRIPT_TARGET_DIR, {
286 force: true,
287 recursive: true,
288 });
289 }
290
291 async #installOscPatternConfig() {
292 await mkdir(REAPER_OSC_TARGET_DIR, { recursive: true });
293 await writeFile(
294 join(REAPER_OSC_TARGET_DIR, OSC_PATTERN_FILE),
295 OSC_PATTERN_CONFIG,
296 );
297 }
298
299 // Live tempo + time signature come from a deferred REAPER Lua script that
300 // writes them to a JSON file whenever they change; we watch that file. This
301 // covers what OSC can't (REAPER has no time-signature feedback token).
302 async #startFeedback() {
303 try {
304 await this.#ensureManagedScripts();
305 await this.#readFeedbackState();
306 this.#watchFeedbackState();
307 await this.#launchFeedbackScript();
308 } catch (error) {
309 this.#logFeedbackError(error);
310 }
311 }
312
313 #watchFeedbackState() {
314 if (this.#feedbackWatcher || this.#closed) return;
315 try {
316 const watcher = watch(REAPER_FEEDBACK_DIR, (_event, filename) => {
317 if (!filename || filename === REAPER_FEEDBACK_FILE) {
318 void this.#readFeedbackState();
319 }
320 });
321 watcher.on("error", () => {});
322 watcher.unref?.();
323 this.#feedbackWatcher = watcher;
324 } catch {
325 // Directory may not be watchable; the periodic writes still land via reads.
326 }
327 }
328
329 async #readFeedbackState() {
330 let raw: string;
331 try {
332 raw = await readFile(REAPER_FEEDBACK_STATE_PATH, "utf8");
333 } catch {
334 return; // not written yet
335 }
336 const patch = parseFeedbackState(raw);
337 if (patch) {
338 this.#updateTransport(patch, "osc");
339 }
340 }
341
342 async #launchFeedbackScript() {
343 if (this.#feedbackLaunched || this.#closed) return;
344 // Only start it once REAPER is actually up, so we don't log a spurious error.
345 if (!await isProcessRunning(reaperProcessName())) return;
346 this.#feedbackLaunched = true;
347 await this.runScript(REAPER_FEEDBACK_SCRIPT);
348 }
349
350 async #runManagedScript(name: ReaperScriptName) {
351 if (!await isProcessRunning(reaperProcessName())) {
352 throw new Error(
353 "REAPER is not running, so Clover skipped launching the script instead of opening it automatically.",
354 );
355 }
356
357 await execFileAsync(process.env.REAPER_BIN ?? DEFAULT_REAPER_BIN, [
358 "-nonewinst",
359 join(REAPER_SCRIPT_TARGET_DIR, "scripts", `${name}.lua`),
360 ]);
361 }
362
363 #setupOscSockets() {
364 this.#receiveSocket.on("message", (packet) => {
365 this.#handleOscPacket(packet);
366 });
367
368 this.#receiveSocket.on("error", (error) => {
369 this.#logOscSocketError(error);
370 });
371
372 this.#sendSocket.on("error", (error) => {
373 this.#logOscSocketError(error);
374 });
375
376 this.#receiveSocket.bind(this.#oscBindPort);
377 }
378
379 #handleOscPacket(packet: Buffer) {
380 const patch = transportPatchForOscPacket(packet);
381 if (!patch) {
382 return;
383 }
384
385 if (!this.#receivedOscFeedback) {
386 this.#receivedOscFeedback = true;
387 this.emit("osc-feedback");
388 // REAPER is confirmed up — (re)start the feedback script if we hadn't yet.
389 void this.#launchFeedbackScript().catch((error) => {
390 this.#logFeedbackError(error);
391 });
392 }
393
394 this.#updateTransport(patch, "osc");
395 }
396
397 #updateTransport(
398 patch: ReaperTransportPatch,
399 source: ReaperTransportState["source"],
400 ) {
401 const previous = this.#transport;
402 const next = {
403 ...previous,
404 ...patch,
405 readAtMs: Date.now(),
406 source,
407 };
408 this.#transport = next;
409
410 if (!sameTransportState(previous, next)) {
411 this.emit("transport", { ...next });
412 }
413 }
414
415 #queueScrubDelta(delta: number) {
416 if (!Number.isFinite(delta) || delta === 0) {
417 return;
418 }
419
420 this.#pendingScrubDelta += clamp(
421 delta,
422 -MAX_SCRUB_OSC_VALUE,
423 MAX_SCRUB_OSC_VALUE,
424 );
425
426 if (this.#scrubTimer) {
427 return;
428 }
429
430 this.#scrubTimer = setTimeout(() => {
431 this.#scrubTimer = null;
432 void this.#flushScrub();
433 }, SCRUB_FLUSH_MS);
434 }
435
436 #clearScrubTimer() {
437 if (!this.#scrubTimer) {
438 return;
439 }
440
441 clearTimeout(this.#scrubTimer);
442 this.#scrubTimer = null;
443 }
444
445 async #sendLoggedOscMessage(
446 address: string,
447 args: OscScalar[],
448 action: string,
449 ) {
450 try {
451 await this.#sendOscMessage(address, args);
452 return true;
453 } catch (error) {
454 this.#logOscSendError(action, error);
455 return false;
456 }
457 }
458
459 async #sendOscMessage(address: string, args: OscScalar[] = []) {
460 const payload = encodeOscMessage(address, args);
461 await new Promise<void>((resolve, reject) => {
462 this.#sendSocket.send(
463 payload,
464 this.#oscPort,
465 this.#oscHost,
466 (error) => {
467 if (error) {
468 reject(error);
469 return;
470 }
471 resolve();
472 },
473 );
474 });
475 }
476
477 #logOscSendError(action: string, error: unknown) {
478 this.#logError(
479 `Failed to ${action} via OSC ${this.#oscHost}:${this.#oscPort}.`,
480 error,
481 );
482 }
483
484 #logScriptError(name: ReaperScriptName, error: unknown) {
485 this.#logError(
486 `Failed to run script "${name}" from ${REAPER_SCRIPT_TARGET_DIR}.`,
487 error,
488 );
489 }
490
491 #logOscSocketError(error: unknown) {
492 if (!this.#warnedOscSocket) {
493 this.#warnedOscSocket = true;
494 this.#logError(
495 `Failed to bind OSC feedback socket on ${this.#oscBindPort}.`,
496 error,
497 );
498 return;
499 }
500
501 console.error(`[REAPER] ${formatError(error)}`);
502 }
503
504 #logInstallError(error: unknown) {
505 this.#logError(
506 `Failed to install/update managed scripts in ${REAPER_SCRIPT_TARGET_DIR}.`,
507 error,
508 );
509 }
510
511 #logOscPatternInstallError(error: unknown) {
512 this.#logError(
513 `Failed to install/update ${OSC_PATTERN_FILE} in ${REAPER_OSC_TARGET_DIR}.`,
514 error,
515 );
516 }
517
518 #logFeedbackError(error: unknown) {
519 this.#logError(
520 `Failed to start live tempo/time-signature feedback (${REAPER_FEEDBACK_STATE_PATH}).`,
521 error,
522 );
523 }
524
525 #logError(message: string, error: unknown) {
526 console.error(`[REAPER] ${message}`);
527 console.error(`[REAPER] ${formatError(error)}`);
528 }
529}
530
531export declare namespace Reaper {
532 export type EventMap = {
533 "transport": [transport: ReaperTransportState];
534 "osc-feedback": [];
535 };
536}
537
538function blankTransportState(): ReaperTransportState {
539 return {
540 playing: false,
541 paused: false,
542 recording: false,
543 repeatOn: false,
544 positionSeconds: 0,
545 positionString: "",
546 positionBeatsString: "",
547 tempo: 120,
548 timeSignature: "4/4",
549 readAtMs: 0,
550 source: "optimistic",
551 };
552}
553
554function sameTransportState(
555 left: ReaperTransportState,
556 right: ReaperTransportState,
557) {
558 return left.playing === right.playing
559 && left.paused === right.paused
560 && left.recording === right.recording
561 && left.repeatOn === right.repeatOn
562 && left.positionSeconds === right.positionSeconds
563 && left.positionString === right.positionString
564 && left.positionBeatsString === right.positionBeatsString
565 && left.tempo === right.tempo
566 && left.timeSignature === right.timeSignature;
567}
568
569/** Parse the feedback script's `{ "tempo": <bpm>, "timesig": "n/d" }` payload. */
570function parseFeedbackState(raw: string): ReaperTransportPatch | null {
571 let data: { tempo?: unknown; timesig?: unknown };
572 try {
573 data = JSON.parse(raw);
574 } catch {
575 return null;
576 }
577 const patch: ReaperTransportPatch = {};
578 if (typeof data.tempo === "number" && Number.isFinite(data.tempo)) {
579 patch.tempo = data.tempo;
580 }
581 if (typeof data.timesig === "string" && data.timesig.length > 0) {
582 patch.timeSignature = data.timesig;
583 }
584 return Object.keys(patch).length > 0 ? patch : null;
585}
586
587function optimisticTransportPatchForAction(
588 actionId: ReaperActionId,
589 transport: ReaperTransportState,
590): ReaperTransportPatch | null {
591 switch (actionId) {
592 case "transport-play":
593 case "transport-play-skip-time-selection":
594 return { playing: true, paused: false };
595
596 case "transport-stop":
597 case "transport-stop-delete-all-recorded-media":
598 case "transport-stop-save-all-recorded-media":
599 return { playing: false, paused: false, recording: false };
600
601 case "transport-play-stop":
602 case "transport-play-stop-move-edit-cursor-on-stop":
603 return transport.playing || transport.paused || transport.recording
604 ? { playing: false, paused: false, recording: false }
605 : { playing: true, paused: false };
606
607 case "transport-record":
608 return transport.recording
609 ? { playing: false, paused: false, recording: false }
610 : { recording: true, playing: true, paused: false };
611
612 case "transport-pause":
613 if (transport.paused) {
614 return { paused: false, playing: true };
615 }
616 if (transport.playing || transport.recording) {
617 return { paused: true, playing: false };
618 }
619 return null;
620
621 case "transport-play-pause":
622 if (transport.paused) {
623 return { paused: false, playing: true };
624 }
625 if (transport.playing || transport.recording) {
626 return { paused: true, playing: false };
627 }
628 return { playing: true, paused: false };
629
630 case "transport-toggle-repeat":
631 return { repeatOn: !transport.repeatOn };
632
633 default:
634 return null;
635 }
636}
637
638function transportPatchForOscMessage(
639 message: OscMessage,
640): ReaperTransportPatch | null {
641 switch (message.address) {
642 case OSC_ADDRESS.record: {
643 const recording = readOscBoolean(message.args[0]);
644 if (recording === null) {
645 return null;
646 }
647
648 return recording
649 ? { recording, playing: true, paused: false }
650 : { recording };
651 }
652
653 case OSC_ADDRESS.play: {
654 const playing = readOscBoolean(message.args[0]);
655 if (playing === null) {
656 return null;
657 }
658
659 return playing ? { playing, paused: false } : { playing };
660 }
661
662 case OSC_ADDRESS.pause: {
663 const paused = readOscBoolean(message.args[0]);
664 if (paused === null) {
665 return null;
666 }
667
668 return paused ? { paused, playing: false } : { paused };
669 }
670
671 case OSC_ADDRESS.stop:
672 return readOscBoolean(message.args[0])
673 ? { playing: false, paused: false, recording: false }
674 : null;
675
676 default:
677 return null;
678 }
679}
680
681function transportPatchForOscPacket(
682 packet: Buffer,
683): ReaperTransportPatch | null {
684 const messages = parseOscPacket(packet);
685 if (!messages) {
686 return null;
687 }
688
689 let patch: ReaperTransportPatch | null = null;
690 for (const message of messages) {
691 const next = transportPatchForOscMessage(message);
692 if (!next) {
693 continue;
694 }
695
696 patch = patch ? { ...patch, ...next } : next;
697 }
698
699 return patch;
700}
701
702function hasManagedOscSurface(config: string) {
703 for (const line of config.split(/\r?\n/u)) {
704 const match = /^csurf_\d+=(.+)$/u.exec(line.trim());
705 if (!match) {
706 continue;
707 }
708
709 const tokens = match[1]?.match(/"[^"]*"|'[^']*'|[^ ]+/gu) ?? [];
710 if (tokens[0] !== "OSC") {
711 continue;
712 }
713
714 const normalizedTokens = tokens.map((token) => unquoteReaperToken(token));
715 if (
716 normalizedTokens.includes(OSC_PATTERN_FILE)
717 || normalizedTokens.includes(OSC_PATTERN_NAME)
718 ) {
719 return true;
720 }
721 }
722
723 return false;
724}
725
726function readReaperConfig() {
727 try {
728 return readFileSync(DEFAULT_REAPER_CONFIG_PATH, "utf8");
729 } catch {
730 return "";
731 }
732}
733
734function closeSocket(socket: Socket) {
735 try {
736 socket.close();
737 } catch {}
738}
739
740function formatError(error: unknown) {
741 return error instanceof Error ? error.message : String(error);
742}
743
744function unquoteReaperToken(token: string) {
745 if (
746 (token.startsWith("'") && token.endsWith("'"))
747 || (token.startsWith("\"") && token.endsWith("\""))
748 ) {
749 return token.slice(1, -1);
750 }
751 return token;
752}
753
754function stripReaperOscExtension(fileName: string) {
755 return fileName.endsWith(".ReaperOSC")
756 ? fileName.slice(0, -".ReaperOSC".length)
757 : fileName;
758}
759
760function readNumberEnv(names: ReadonlyArray<string>, fallback: number) {
761 for (const name of names) {
762 const numeric = Number(process.env[name]);
763 if (Number.isFinite(numeric)) {
764 return numeric;
765 }
766 }
767
768 return fallback;
769}
770
771function clamp(value: number, min: number, max: number) {
772 return Math.min(max, Math.max(min, value));
773}
774
775function reaperProcessName() {
776 return basename(process.env.REAPER_BIN ?? DEFAULT_REAPER_BIN);
777}
778
779async function isProcessRunning(processName: string) {
780 try {
781 await execFileAsync("pgrep", ["-x", processName]);
782 return true;
783 } catch {
784 return false;
785 }
786}
787
788function parseOscPacket(packet: Buffer): OscMessage[] | null {
789 if (isOscBundle(packet)) {
790 return parseOscBundle(packet);
791 }
792
793 const message = parseOscMessage(packet);
794 return message ? [message] : null;
795}
796
797function isOscBundle(packet: Buffer) {
798 return packet.subarray(0, 8).equals(Buffer.from("#bundle\0"));
799}
800
801function parseOscBundle(packet: Buffer): OscMessage[] | null {
802 const bundleHeader = readOscString(packet, 0);
803 if (!bundleHeader || bundleHeader.value !== "#bundle") {
804 return null;
805 }
806
807 let offset = nextOscOffset(bundleHeader.nextOffset);
808 if (offset + 8 > packet.length) {
809 return null;
810 }
811
812 offset += 8;
813
814 const messages: OscMessage[] = [];
815 while (offset < packet.length) {
816 if (offset + 4 > packet.length) {
817 return null;
818 }
819
820 const elementSize = packet.readInt32BE(offset);
821 offset += 4;
822
823 if (elementSize < 0 || offset + elementSize > packet.length) {
824 return null;
825 }
826
827 const element = packet.subarray(offset, offset + elementSize);
828 offset += elementSize;
829
830 const elementMessages = parseOscPacket(element);
831 if (!elementMessages) {
832 return null;
833 }
834
835 messages.push(...elementMessages);
836 }
837
838 return messages;
839}
840
841function parseOscMessage(packet: Buffer): OscMessage | null {
842 const address = readOscString(packet, 0);
843 if (!address) {
844 return null;
845 }
846
847 const typeTags = readOscString(packet, nextOscOffset(address.nextOffset));
848 if (!typeTags || !typeTags.value.startsWith(",")) {
849 return null;
850 }
851
852 const args: OscScalar[] = [];
853 let offset = nextOscOffset(typeTags.nextOffset);
854
855 for (const tag of typeTags.value.slice(1)) {
856 switch (tag) {
857 case "i":
858 if (offset + 4 > packet.length) return null;
859 args.push(packet.readInt32BE(offset));
860 offset += 4;
861 break;
862
863 case "f":
864 if (offset + 4 > packet.length) return null;
865 args.push(packet.readFloatBE(offset));
866 offset += 4;
867 break;
868
869 case "s": {
870 const value = readOscString(packet, offset);
871 if (!value) return null;
872 args.push(value.value);
873 offset = nextOscOffset(value.nextOffset);
874 break;
875 }
876
877 case "T":
878 args.push(true);
879 break;
880
881 case "F":
882 args.push(false);
883 break;
884
885 default:
886 return null;
887 }
888 }
889
890 return { address: address.value, args };
891}
892
893function encodeOscMessage(address: string, args: OscScalar[] = []) {
894 const parts = [encodeOscString(address)];
895 const typeTags = "," + args.map((arg) => oscTypeTag(arg)).join("");
896 parts.push(encodeOscString(typeTags));
897
898 for (const arg of args) {
899 parts.push(encodeOscArgument(arg));
900 }
901
902 return Buffer.concat(parts);
903}
904
905function oscTypeTag(value: OscScalar) {
906 if (typeof value === "number") {
907 return Number.isInteger(value) ? "i" : "f";
908 }
909 if (typeof value === "boolean") {
910 return value ? "T" : "F";
911 }
912 return "s";
913}
914
915function encodeOscArgument(value: OscScalar) {
916 if (typeof value === "number") {
917 const buffer = Buffer.alloc(4);
918 if (Number.isInteger(value)) {
919 buffer.writeInt32BE(value, 0);
920 } else {
921 buffer.writeFloatBE(value, 0);
922 }
923 return buffer;
924 }
925
926 if (typeof value === "boolean") {
927 return Buffer.alloc(0);
928 }
929
930 return encodeOscString(value);
931}
932
933function encodeOscString(value: string) {
934 const buffer = Buffer.from(value + "\0", "utf8");
935 const padding = (4 - (buffer.length % 4)) % 4;
936 return padding === 0
937 ? buffer
938 : Buffer.concat([buffer, Buffer.alloc(padding)]);
939}
940
941function readOscString(buffer: Buffer, offset: number) {
942 let end = offset;
943 while (end < buffer.length && buffer[end] !== 0) {
944 end += 1;
945 }
946
947 if (end >= buffer.length) {
948 return null;
949 }
950
951 return {
952 value: buffer.toString("utf8", offset, end),
953 nextOffset: end + 1,
954 };
955}
956
957function nextOscOffset(offset: number) {
958 return offset + ((4 - (offset % 4)) % 4);
959}
960
961function readOscBoolean(value: OscScalar | undefined) {
962 if (typeof value === "boolean") {
963 return value;
964 }
965 if (typeof value === "number") {
966 return value !== 0;
967 }
968 if (typeof value === "string") {
969 if (value === "0") return false;
970 if (value === "1") return true;
971 }
972 return null;
973}
control/src/Reaper/CloverAutomation.ReaperOSC created+25
...@@ -0,0 +1,25 @@
1# OSC pattern config file for Clover Creative Control's REAPER integration.
2DEVICE_TRACK_COUNT 1
3DEVICE_SEND_COUNT 0
4DEVICE_RECEIVE_COUNT 0
5DEVICE_FX_COUNT 0
6DEVICE_FX_PARAM_COUNT 0
7DEVICE_FX_INST_PARAM_COUNT 0
8DEVICE_MARKER_COUNT 0
9DEVICE_REGION_COUNT 0
10
11REAPER_TRACK_FOLLOWS REAPER
12DEVICE_TRACK_FOLLOWS DEVICE
13DEVICE_TRACK_BANK_FOLLOWS DEVICE
14DEVICE_FX_FOLLOWS DEVICE
15DEVICE_ROTARY_CENTER 0
16
17# ----------------------------------------------------------------
18
19RECORD t/clover/record
20STOP t/clover/stop
21PLAY t/clover/play
22PAUSE t/clover/pause
23SCRUB r/clover/scrub
24
25ACTION i/clover/action s/clover/action/str t/clover/action/@
\ No newline at end of file
control/src/Reaper/actions.ts created+6702
...@@ -0,0 +1,6702 @@
1// Generated by src/Reaper/generate-actions.ts
2// Source: REAPER main action section via kbd_enumerateActions()/kbd_getTextFromCmd().
3
4export const REAPER_ACTIONS = {
5 "action-arm-next-action": 2019,
6 "action-disarm-action": 2020,
7 "action-modify-midi-cc-mousewheel-0-5x": 2004,
8 "action-modify-midi-cc-mousewheel-10-percent": 2007,
9 "action-modify-midi-cc-mousewheel-2x": 2005,
10 "action-modify-midi-cc-mousewheel-negative": 2003,
11 "action-modify-midi-cc-mousewheel-plus-10-percent": 2006,
12 "action-momentarily-send-next-action-to-next-project-tab-1": 3061,
13 "action-momentarily-send-next-action-to-next-project-tab-2": 3062,
14 "action-momentarily-send-next-action-to-next-project-tab-3": 3063,
15 "action-momentarily-send-next-action-to-next-project-tab-4": 3064,
16 "action-momentarily-send-next-action-to-next-project-tab-5": 3065,
17 "action-momentarily-send-next-action-to-previous-project-tab-1": 3091,
18 "action-momentarily-send-next-action-to-previous-project-tab-2": 3092,
19 "action-momentarily-send-next-action-to-previous-project-tab-3": 3093,
20 "action-momentarily-send-next-action-to-previous-project-tab-4": 3094,
21 "action-momentarily-send-next-action-to-previous-project-tab-5": 3095,
22 "action-momentarily-send-next-action-to-previously-active-project-tab": 3120,
23 "action-momentarily-send-next-action-to-project-tab-1": 3002,
24 "action-momentarily-send-next-action-to-project-tab-10": 3011,
25 "action-momentarily-send-next-action-to-project-tab-2": 3003,
26 "action-momentarily-send-next-action-to-project-tab-3": 3004,
27 "action-momentarily-send-next-action-to-project-tab-4": 3005,
28 "action-momentarily-send-next-action-to-project-tab-5": 3006,
29 "action-momentarily-send-next-action-to-project-tab-6": 3007,
30 "action-momentarily-send-next-action-to-project-tab-7": 3008,
31 "action-momentarily-send-next-action-to-project-tab-8": 3009,
32 "action-momentarily-send-next-action-to-project-tab-9": 3010,
33 "action-momentarily-send-next-action-to-project-tab-n": 3032,
34 "action-momentarily-send-next-action-to-project-tab-n-1": 3033,
35 "action-momentarily-send-next-action-to-project-tab-n-2": 3034,
36 "action-momentarily-send-next-action-to-project-tab-n-3": 3035,
37 "action-momentarily-send-next-action-to-project-tab-n-4": 3036,
38 "action-momentarily-send-next-action-to-project-tab-n-5": 3037,
39 "action-momentarily-send-next-action-to-project-tab-n-6": 3038,
40 "action-momentarily-send-next-action-to-project-tab-n-7": 3039,
41 "action-momentarily-send-next-action-to-project-tab-n-8": 3040,
42 "action-momentarily-send-next-action-to-project-tab-n-9": 3041,
43 "action-prompt-to-continue-only-valid-within-custom-actions": 2000,
44 "action-prompt-to-go-to-action-loop-start-only-valid-within-custom-actions": 2002,
45 "action-repeat-the-action-prior-to-the-most-recent-action": 3000,
46 "action-repeat-the-most-recent-action": 2999,
47 "action-set-action-loop-start-only-valid-within-custom-actions": 2001,
48 "action-skip-next-action-if-cc-parameter-0-mid": 2013,
49 "action-skip-next-action-if-cc-parameter-0-mid-2014": 2014,
50 "action-skip-next-action-if-cc-parameter-0-mid-2015": 2015,
51 "action-skip-next-action-if-cc-parameter-0-mid-2016": 2016,
52 "action-skip-next-action-if-cc-parameter-0-mid-2017": 2017,
53 "action-skip-next-action-if-cc-parameter-0-mid-2018": 2018,
54 "action-skip-next-action-set-cc-parameter-to-relative-plus-1-if-action-armed-0-otherwise": 2023,
55 "action-skip-next-action-set-cc-parameter-to-relative-plus-1-if-action-toggle-state-enabled-1-if-disabled-0-if-toggle-state-unavailable":
56 2022,
57 "action-toggle-arm-of-next-action": 2021,
58 "action-wait-0-1-seconds-before-next-action": 2008,
59 "action-wait-0-5-seconds-before-next-action": 2009,
60 "action-wait-1-second-before-next-action": 2010,
61 "action-wait-10-seconds-before-next-action": 2012,
62 "action-wait-5-seconds-before-next-action": 2011,
63 "adjust-entire-tempo-envelope": 41805,
64 "adjust-last-touched-fx-parameter-midi-cc-osc-only": 973,
65 "adjust-solo-in-front-dim-midi-cc-mousewheel-only": 987,
66 "adjust-track-fx-parameter-01-midi-cc-osc-only": 950,
67 "adjust-track-fx-parameter-02-midi-cc-osc-only": 951,
68 "adjust-track-fx-parameter-03-midi-cc-osc-only": 952,
69 "adjust-track-fx-parameter-04-midi-cc-osc-only": 953,
70 "adjust-track-fx-parameter-05-midi-cc-osc-only": 954,
71 "adjust-track-fx-parameter-06-midi-cc-osc-only": 955,
72 "adjust-track-fx-parameter-07-midi-cc-osc-only": 956,
73 "adjust-track-fx-parameter-08-midi-cc-osc-only": 957,
74 "adjust-track-fx-parameter-09-midi-cc-osc-only": 958,
75 "adjust-track-fx-parameter-10-midi-cc-osc-only": 959,
76 "adjust-track-fx-parameter-11-midi-cc-osc-only": 960,
77 "adjust-track-fx-parameter-12-midi-cc-osc-only": 961,
78 "adjust-track-fx-parameter-13-midi-cc-osc-only": 962,
79 "adjust-track-fx-parameter-14-midi-cc-osc-only": 963,
80 "adjust-track-fx-parameter-15-midi-cc-osc-only": 964,
81 "adjust-track-fx-parameter-16-midi-cc-osc-only": 965,
82 "adjust-track-send-1-pan-midi-cc-osc-only": 911,
83 "adjust-track-send-1-volume-midi-cc-osc-only": 901,
84 "adjust-track-send-2-pan-midi-cc-osc-only": 912,
85 "adjust-track-send-2-volume-midi-cc-osc-only": 902,
86 "adjust-track-send-3-pan-midi-cc-osc-only": 913,
87 "adjust-track-send-3-volume-midi-cc-osc-only": 903,
88 "adjust-track-send-4-pan-midi-cc-osc-only": 914,
89 "adjust-track-send-4-volume-midi-cc-osc-only": 904,
90 "adjust-track-send-5-pan-midi-cc-osc-only": 915,
91 "adjust-track-send-5-volume-midi-cc-osc-only": 905,
92 "adjust-track-send-6-pan-midi-cc-osc-only": 916,
93 "adjust-track-send-6-volume-midi-cc-osc-only": 906,
94 "adjust-track-send-7-pan-midi-cc-osc-only": 917,
95 "adjust-track-send-7-volume-midi-cc-osc-only": 907,
96 "adjust-track-send-8-pan-midi-cc-osc-only": 918,
97 "adjust-track-send-8-volume-midi-cc-osc-only": 908,
98 "audio-device-configuration": 40099,
99 "automation-clear-all-saved-track-envelope-latches": 43568,
100 "automation-clear-all-track-envelope-latches": 42025,
101 "automation-clear-latch-preset-1": 50756,
102 "automation-clear-latch-preset-10": 50765,
103 "automation-clear-latch-preset-11": 50766,
104 "automation-clear-latch-preset-12": 50767,
105 "automation-clear-latch-preset-13": 50768,
106 "automation-clear-latch-preset-14": 50769,
107 "automation-clear-latch-preset-15": 50770,
108 "automation-clear-latch-preset-16": 50771,
109 "automation-clear-latch-preset-17": 50772,
110 "automation-clear-latch-preset-18": 50773,
111 "automation-clear-latch-preset-19": 50774,
112 "automation-clear-latch-preset-2": 50757,
113 "automation-clear-latch-preset-20": 50775,
114 "automation-clear-latch-preset-21": 50776,
115 "automation-clear-latch-preset-22": 50777,
116 "automation-clear-latch-preset-23": 50778,
117 "automation-clear-latch-preset-24": 50779,
118 "automation-clear-latch-preset-25": 50780,
119 "automation-clear-latch-preset-26": 50781,
120 "automation-clear-latch-preset-27": 50782,
121 "automation-clear-latch-preset-28": 50783,
122 "automation-clear-latch-preset-29": 50784,
123 "automation-clear-latch-preset-3": 50758,
124 "automation-clear-latch-preset-30": 50785,
125 "automation-clear-latch-preset-31": 50786,
126 "automation-clear-latch-preset-32": 50787,
127 "automation-clear-latch-preset-33": 50788,
128 "automation-clear-latch-preset-34": 50789,
129 "automation-clear-latch-preset-35": 50790,
130 "automation-clear-latch-preset-36": 50791,
131 "automation-clear-latch-preset-37": 50792,
132 "automation-clear-latch-preset-38": 50793,
133 "automation-clear-latch-preset-39": 50794,
134 "automation-clear-latch-preset-4": 50759,
135 "automation-clear-latch-preset-40": 50795,
136 "automation-clear-latch-preset-41": 50796,
137 "automation-clear-latch-preset-42": 50797,
138 "automation-clear-latch-preset-43": 50798,
139 "automation-clear-latch-preset-44": 50799,
140 "automation-clear-latch-preset-45": 50800,
141 "automation-clear-latch-preset-46": 50801,
142 "automation-clear-latch-preset-47": 50802,
143 "automation-clear-latch-preset-48": 50803,
144 "automation-clear-latch-preset-49": 50804,
145 "automation-clear-latch-preset-5": 50760,
146 "automation-clear-latch-preset-50": 50805,
147 "automation-clear-latch-preset-51": 50806,
148 "automation-clear-latch-preset-52": 50807,
149 "automation-clear-latch-preset-53": 50808,
150 "automation-clear-latch-preset-54": 50809,
151 "automation-clear-latch-preset-55": 50810,
152 "automation-clear-latch-preset-56": 50811,
153 "automation-clear-latch-preset-57": 50812,
154 "automation-clear-latch-preset-58": 50813,
155 "automation-clear-latch-preset-59": 50814,
156 "automation-clear-latch-preset-6": 50761,
157 "automation-clear-latch-preset-60": 50815,
158 "automation-clear-latch-preset-61": 50816,
159 "automation-clear-latch-preset-62": 50817,
160 "automation-clear-latch-preset-63": 50818,
161 "automation-clear-latch-preset-64": 50819,
162 "automation-clear-latch-preset-7": 50762,
163 "automation-clear-latch-preset-8": 50763,
164 "automation-clear-latch-preset-9": 50764,
165 "automation-clear-saved-track-envelope-latches": 43569,
166 "automation-clear-track-envelope-latches": 42026,
167 "automation-lane-decrease-active-fader-a-little-bit": 40858,
168 "automation-lane-decrease-active-fader-a-tiny-bit": 42384,
169 "automation-lane-increase-active-fader-a-little-bit": 40857,
170 "automation-lane-increase-active-fader-a-tiny-bit": 42383,
171 "automation-lane-set-active-fader-midi-cc-osc-only": 986,
172 "automation-load-latch-preset-1-for-all-tracks": 50564,
173 "automation-load-latch-preset-1-for-selected-tracks": 50692,
174 "automation-load-latch-preset-10-for-all-tracks": 50573,
175 "automation-load-latch-preset-10-for-selected-tracks": 50701,
176 "automation-load-latch-preset-11-for-all-tracks": 50574,
177 "automation-load-latch-preset-11-for-selected-tracks": 50702,
178 "automation-load-latch-preset-12-for-all-tracks": 50575,
179 "automation-load-latch-preset-12-for-selected-tracks": 50703,
180 "automation-load-latch-preset-13-for-all-tracks": 50576,
181 "automation-load-latch-preset-13-for-selected-tracks": 50704,
182 "automation-load-latch-preset-14-for-all-tracks": 50577,
183 "automation-load-latch-preset-14-for-selected-tracks": 50705,
184 "automation-load-latch-preset-15-for-all-tracks": 50578,
185 "automation-load-latch-preset-15-for-selected-tracks": 50706,
186 "automation-load-latch-preset-16-for-all-tracks": 50579,
187 "automation-load-latch-preset-16-for-selected-tracks": 50707,
188 "automation-load-latch-preset-17-for-all-tracks": 50580,
189 "automation-load-latch-preset-17-for-selected-tracks": 50708,
190 "automation-load-latch-preset-18-for-all-tracks": 50581,
191 "automation-load-latch-preset-18-for-selected-tracks": 50709,
192 "automation-load-latch-preset-19-for-all-tracks": 50582,
193 "automation-load-latch-preset-19-for-selected-tracks": 50710,
194 "automation-load-latch-preset-2-for-all-tracks": 50565,
195 "automation-load-latch-preset-2-for-selected-tracks": 50693,
196 "automation-load-latch-preset-20-for-all-tracks": 50583,
197 "automation-load-latch-preset-20-for-selected-tracks": 50711,
198 "automation-load-latch-preset-21-for-all-tracks": 50584,
199 "automation-load-latch-preset-21-for-selected-tracks": 50712,
200 "automation-load-latch-preset-22-for-all-tracks": 50585,
201 "automation-load-latch-preset-22-for-selected-tracks": 50713,
202 "automation-load-latch-preset-23-for-all-tracks": 50586,
203 "automation-load-latch-preset-23-for-selected-tracks": 50714,
204 "automation-load-latch-preset-24-for-all-tracks": 50587,
205 "automation-load-latch-preset-24-for-selected-tracks": 50715,
206 "automation-load-latch-preset-25-for-all-tracks": 50588,
207 "automation-load-latch-preset-25-for-selected-tracks": 50716,
208 "automation-load-latch-preset-26-for-all-tracks": 50589,
209 "automation-load-latch-preset-26-for-selected-tracks": 50717,
210 "automation-load-latch-preset-27-for-all-tracks": 50590,
211 "automation-load-latch-preset-27-for-selected-tracks": 50718,
212 "automation-load-latch-preset-28-for-all-tracks": 50591,
213 "automation-load-latch-preset-28-for-selected-tracks": 50719,
214 "automation-load-latch-preset-29-for-all-tracks": 50592,
215 "automation-load-latch-preset-29-for-selected-tracks": 50720,
216 "automation-load-latch-preset-3-for-all-tracks": 50566,
217 "automation-load-latch-preset-3-for-selected-tracks": 50694,
218 "automation-load-latch-preset-30-for-all-tracks": 50593,
219 "automation-load-latch-preset-30-for-selected-tracks": 50721,
220 "automation-load-latch-preset-31-for-all-tracks": 50594,
221 "automation-load-latch-preset-31-for-selected-tracks": 50722,
222 "automation-load-latch-preset-32-for-all-tracks": 50595,
223 "automation-load-latch-preset-32-for-selected-tracks": 50723,
224 "automation-load-latch-preset-33-for-all-tracks": 50596,
225 "automation-load-latch-preset-33-for-selected-tracks": 50724,
226 "automation-load-latch-preset-34-for-all-tracks": 50597,
227 "automation-load-latch-preset-34-for-selected-tracks": 50725,
228 "automation-load-latch-preset-35-for-all-tracks": 50598,
229 "automation-load-latch-preset-35-for-selected-tracks": 50726,
230 "automation-load-latch-preset-36-for-all-tracks": 50599,
231 "automation-load-latch-preset-36-for-selected-tracks": 50727,
232 "automation-load-latch-preset-37-for-all-tracks": 50600,
233 "automation-load-latch-preset-37-for-selected-tracks": 50728,
234 "automation-load-latch-preset-38-for-all-tracks": 50601,
235 "automation-load-latch-preset-38-for-selected-tracks": 50729,
236 "automation-load-latch-preset-39-for-all-tracks": 50602,
237 "automation-load-latch-preset-39-for-selected-tracks": 50730,
238 "automation-load-latch-preset-4-for-all-tracks": 50567,
239 "automation-load-latch-preset-4-for-selected-tracks": 50695,
240 "automation-load-latch-preset-40-for-all-tracks": 50603,
241 "automation-load-latch-preset-40-for-selected-tracks": 50731,
242 "automation-load-latch-preset-41-for-all-tracks": 50604,
243 "automation-load-latch-preset-41-for-selected-tracks": 50732,
244 "automation-load-latch-preset-42-for-all-tracks": 50605,
245 "automation-load-latch-preset-42-for-selected-tracks": 50733,
246 "automation-load-latch-preset-43-for-all-tracks": 50606,
247 "automation-load-latch-preset-43-for-selected-tracks": 50734,
248 "automation-load-latch-preset-44-for-all-tracks": 50607,
249 "automation-load-latch-preset-44-for-selected-tracks": 50735,
250 "automation-load-latch-preset-45-for-all-tracks": 50608,
251 "automation-load-latch-preset-45-for-selected-tracks": 50736,
252 "automation-load-latch-preset-46-for-all-tracks": 50609,
253 "automation-load-latch-preset-46-for-selected-tracks": 50737,
254 "automation-load-latch-preset-47-for-all-tracks": 50610,
255 "automation-load-latch-preset-47-for-selected-tracks": 50738,
256 "automation-load-latch-preset-48-for-all-tracks": 50611,
257 "automation-load-latch-preset-48-for-selected-tracks": 50739,
258 "automation-load-latch-preset-49-for-all-tracks": 50612,
259 "automation-load-latch-preset-49-for-selected-tracks": 50740,
260 "automation-load-latch-preset-5-for-all-tracks": 50568,
261 "automation-load-latch-preset-5-for-selected-tracks": 50696,
262 "automation-load-latch-preset-50-for-all-tracks": 50613,
263 "automation-load-latch-preset-50-for-selected-tracks": 50741,
264 "automation-load-latch-preset-51-for-all-tracks": 50614,
265 "automation-load-latch-preset-51-for-selected-tracks": 50742,
266 "automation-load-latch-preset-52-for-all-tracks": 50615,
267 "automation-load-latch-preset-52-for-selected-tracks": 50743,
268 "automation-load-latch-preset-53-for-all-tracks": 50616,
269 "automation-load-latch-preset-53-for-selected-tracks": 50744,
270 "automation-load-latch-preset-54-for-all-tracks": 50617,
271 "automation-load-latch-preset-54-for-selected-tracks": 50745,
272 "automation-load-latch-preset-55-for-all-tracks": 50618,
273 "automation-load-latch-preset-55-for-selected-tracks": 50746,
274 "automation-load-latch-preset-56-for-all-tracks": 50619,
275 "automation-load-latch-preset-56-for-selected-tracks": 50747,
276 "automation-load-latch-preset-57-for-all-tracks": 50620,
277 "automation-load-latch-preset-57-for-selected-tracks": 50748,
278 "automation-load-latch-preset-58-for-all-tracks": 50621,
279 "automation-load-latch-preset-58-for-selected-tracks": 50749,
280 "automation-load-latch-preset-59-for-all-tracks": 50622,
281 "automation-load-latch-preset-59-for-selected-tracks": 50750,
282 "automation-load-latch-preset-6-for-all-tracks": 50569,
283 "automation-load-latch-preset-6-for-selected-tracks": 50697,
284 "automation-load-latch-preset-60-for-all-tracks": 50623,
285 "automation-load-latch-preset-60-for-selected-tracks": 50751,
286 "automation-load-latch-preset-61-for-all-tracks": 50624,
287 "automation-load-latch-preset-61-for-selected-tracks": 50752,
288 "automation-load-latch-preset-62-for-all-tracks": 50625,
289 "automation-load-latch-preset-62-for-selected-tracks": 50753,
290 "automation-load-latch-preset-63-for-all-tracks": 50626,
291 "automation-load-latch-preset-63-for-selected-tracks": 50754,
292 "automation-load-latch-preset-64-for-all-tracks": 50627,
293 "automation-load-latch-preset-64-for-selected-tracks": 50755,
294 "automation-load-latch-preset-7-for-all-tracks": 50570,
295 "automation-load-latch-preset-7-for-selected-tracks": 50698,
296 "automation-load-latch-preset-8-for-all-tracks": 50571,
297 "automation-load-latch-preset-8-for-selected-tracks": 50699,
298 "automation-load-latch-preset-9-for-all-tracks": 50572,
299 "automation-load-latch-preset-9-for-selected-tracks": 50700,
300 "automation-restore-all-saved-track-envelope-latches": 43562,
301 "automation-restore-saved-track-envelope-latches": 43564,
302 "automation-save-and-clear-all-track-envelope-latches": 43561,
303 "automation-save-and-clear-all-track-envelope-latches-if-any-otherwise-restore-saved-latches": 43565,
304 "automation-save-and-clear-track-envelope-latches": 43563,
305 "automation-save-and-clear-track-envelope-latches-if-any-otherwise-restore-saved-latches": 43566,
306 "automation-save-latch-preset-1-for-all-tracks": 50500,
307 "automation-save-latch-preset-1-for-selected-tracks": 50628,
308 "automation-save-latch-preset-10-for-all-tracks": 50509,
309 "automation-save-latch-preset-10-for-selected-tracks": 50637,
310 "automation-save-latch-preset-11-for-all-tracks": 50510,
311 "automation-save-latch-preset-11-for-selected-tracks": 50638,
312 "automation-save-latch-preset-12-for-all-tracks": 50511,
313 "automation-save-latch-preset-12-for-selected-tracks": 50639,
314 "automation-save-latch-preset-13-for-all-tracks": 50512,
315 "automation-save-latch-preset-13-for-selected-tracks": 50640,
316 "automation-save-latch-preset-14-for-all-tracks": 50513,
317 "automation-save-latch-preset-14-for-selected-tracks": 50641,
318 "automation-save-latch-preset-15-for-all-tracks": 50514,
319 "automation-save-latch-preset-15-for-selected-tracks": 50642,
320 "automation-save-latch-preset-16-for-all-tracks": 50515,
321 "automation-save-latch-preset-16-for-selected-tracks": 50643,
322 "automation-save-latch-preset-17-for-all-tracks": 50516,
323 "automation-save-latch-preset-17-for-selected-tracks": 50644,
324 "automation-save-latch-preset-18-for-all-tracks": 50517,
325 "automation-save-latch-preset-18-for-selected-tracks": 50645,
326 "automation-save-latch-preset-19-for-all-tracks": 50518,
327 "automation-save-latch-preset-19-for-selected-tracks": 50646,
328 "automation-save-latch-preset-2-for-all-tracks": 50501,
329 "automation-save-latch-preset-2-for-selected-tracks": 50629,
330 "automation-save-latch-preset-20-for-all-tracks": 50519,
331 "automation-save-latch-preset-20-for-selected-tracks": 50647,
332 "automation-save-latch-preset-21-for-all-tracks": 50520,
333 "automation-save-latch-preset-21-for-selected-tracks": 50648,
334 "automation-save-latch-preset-22-for-all-tracks": 50521,
335 "automation-save-latch-preset-22-for-selected-tracks": 50649,
336 "automation-save-latch-preset-23-for-all-tracks": 50522,
337 "automation-save-latch-preset-23-for-selected-tracks": 50650,
338 "automation-save-latch-preset-24-for-all-tracks": 50523,
339 "automation-save-latch-preset-24-for-selected-tracks": 50651,
340 "automation-save-latch-preset-25-for-all-tracks": 50524,
341 "automation-save-latch-preset-25-for-selected-tracks": 50652,
342 "automation-save-latch-preset-26-for-all-tracks": 50525,
343 "automation-save-latch-preset-26-for-selected-tracks": 50653,
344 "automation-save-latch-preset-27-for-all-tracks": 50526,
345 "automation-save-latch-preset-27-for-selected-tracks": 50654,
346 "automation-save-latch-preset-28-for-all-tracks": 50527,
347 "automation-save-latch-preset-28-for-selected-tracks": 50655,
348 "automation-save-latch-preset-29-for-all-tracks": 50528,
349 "automation-save-latch-preset-29-for-selected-tracks": 50656,
350 "automation-save-latch-preset-3-for-all-tracks": 50502,
351 "automation-save-latch-preset-3-for-selected-tracks": 50630,
352 "automation-save-latch-preset-30-for-all-tracks": 50529,
353 "automation-save-latch-preset-30-for-selected-tracks": 50657,
354 "automation-save-latch-preset-31-for-all-tracks": 50530,
355 "automation-save-latch-preset-31-for-selected-tracks": 50658,
356 "automation-save-latch-preset-32-for-all-tracks": 50531,
357 "automation-save-latch-preset-32-for-selected-tracks": 50659,
358 "automation-save-latch-preset-33-for-all-tracks": 50532,
359 "automation-save-latch-preset-33-for-selected-tracks": 50660,
360 "automation-save-latch-preset-34-for-all-tracks": 50533,
361 "automation-save-latch-preset-34-for-selected-tracks": 50661,
362 "automation-save-latch-preset-35-for-all-tracks": 50534,
363 "automation-save-latch-preset-35-for-selected-tracks": 50662,
364 "automation-save-latch-preset-36-for-all-tracks": 50535,
365 "automation-save-latch-preset-36-for-selected-tracks": 50663,
366 "automation-save-latch-preset-37-for-all-tracks": 50536,
367 "automation-save-latch-preset-37-for-selected-tracks": 50664,
368 "automation-save-latch-preset-38-for-all-tracks": 50537,
369 "automation-save-latch-preset-38-for-selected-tracks": 50665,
370 "automation-save-latch-preset-39-for-all-tracks": 50538,
371 "automation-save-latch-preset-39-for-selected-tracks": 50666,
372 "automation-save-latch-preset-4-for-all-tracks": 50503,
373 "automation-save-latch-preset-4-for-selected-tracks": 50631,
374 "automation-save-latch-preset-40-for-all-tracks": 50539,
375 "automation-save-latch-preset-40-for-selected-tracks": 50667,
376 "automation-save-latch-preset-41-for-all-tracks": 50540,
377 "automation-save-latch-preset-41-for-selected-tracks": 50668,
378 "automation-save-latch-preset-42-for-all-tracks": 50541,
379 "automation-save-latch-preset-42-for-selected-tracks": 50669,
380 "automation-save-latch-preset-43-for-all-tracks": 50542,
381 "automation-save-latch-preset-43-for-selected-tracks": 50670,
382 "automation-save-latch-preset-44-for-all-tracks": 50543,
383 "automation-save-latch-preset-44-for-selected-tracks": 50671,
384 "automation-save-latch-preset-45-for-all-tracks": 50544,
385 "automation-save-latch-preset-45-for-selected-tracks": 50672,
386 "automation-save-latch-preset-46-for-all-tracks": 50545,
387 "automation-save-latch-preset-46-for-selected-tracks": 50673,
388 "automation-save-latch-preset-47-for-all-tracks": 50546,
389 "automation-save-latch-preset-47-for-selected-tracks": 50674,
390 "automation-save-latch-preset-48-for-all-tracks": 50547,
391 "automation-save-latch-preset-48-for-selected-tracks": 50675,
392 "automation-save-latch-preset-49-for-all-tracks": 50548,
393 "automation-save-latch-preset-49-for-selected-tracks": 50676,
394 "automation-save-latch-preset-5-for-all-tracks": 50504,
395 "automation-save-latch-preset-5-for-selected-tracks": 50632,
396 "automation-save-latch-preset-50-for-all-tracks": 50549,
397 "automation-save-latch-preset-50-for-selected-tracks": 50677,
398 "automation-save-latch-preset-51-for-all-tracks": 50550,
399 "automation-save-latch-preset-51-for-selected-tracks": 50678,
400 "automation-save-latch-preset-52-for-all-tracks": 50551,
401 "automation-save-latch-preset-52-for-selected-tracks": 50679,
402 "automation-save-latch-preset-53-for-all-tracks": 50552,
403 "automation-save-latch-preset-53-for-selected-tracks": 50680,
404 "automation-save-latch-preset-54-for-all-tracks": 50553,
405 "automation-save-latch-preset-54-for-selected-tracks": 50681,
406 "automation-save-latch-preset-55-for-all-tracks": 50554,
407 "automation-save-latch-preset-55-for-selected-tracks": 50682,
408 "automation-save-latch-preset-56-for-all-tracks": 50555,
409 "automation-save-latch-preset-56-for-selected-tracks": 50683,
410 "automation-save-latch-preset-57-for-all-tracks": 50556,
411 "automation-save-latch-preset-57-for-selected-tracks": 50684,
412 "automation-save-latch-preset-58-for-all-tracks": 50557,
413 "automation-save-latch-preset-58-for-selected-tracks": 50685,
414 "automation-save-latch-preset-59-for-all-tracks": 50558,
415 "automation-save-latch-preset-59-for-selected-tracks": 50686,
416 "automation-save-latch-preset-6-for-all-tracks": 50505,
417 "automation-save-latch-preset-6-for-selected-tracks": 50633,
418 "automation-save-latch-preset-60-for-all-tracks": 50559,
419 "automation-save-latch-preset-60-for-selected-tracks": 50687,
420 "automation-save-latch-preset-61-for-all-tracks": 50560,
421 "automation-save-latch-preset-61-for-selected-tracks": 50688,
422 "automation-save-latch-preset-62-for-all-tracks": 50561,
423 "automation-save-latch-preset-62-for-selected-tracks": 50689,
424 "automation-save-latch-preset-63-for-all-tracks": 50562,
425 "automation-save-latch-preset-63-for-selected-tracks": 50690,
426 "automation-save-latch-preset-64-for-all-tracks": 50563,
427 "automation-save-latch-preset-64-for-selected-tracks": 50691,
428 "automation-save-latch-preset-7-for-all-tracks": 50506,
429 "automation-save-latch-preset-7-for-selected-tracks": 50634,
430 "automation-save-latch-preset-8-for-all-tracks": 50507,
431 "automation-save-latch-preset-8-for-selected-tracks": 50635,
432 "automation-save-latch-preset-9-for-all-tracks": 50508,
433 "automation-save-latch-preset-9-for-selected-tracks": 50636,
434 "automation-set-all-tracks-automation-mode-to-latch": 40266,
435 "automation-set-all-tracks-automation-mode-to-latch-preview": 42024,
436 "automation-set-all-tracks-automation-mode-to-read": 40086,
437 "automation-set-all-tracks-automation-mode-to-touch": 40087,
438 "automation-set-all-tracks-automation-mode-to-trim-read": 40088,
439 "automation-set-all-tracks-automation-mode-to-write": 40090,
440 "automation-set-track-automation-mode-to-latch": 40404,
441 "automation-set-track-automation-mode-to-latch-preview": 42023,
442 "automation-set-track-automation-mode-to-read": 40401,
443 "automation-set-track-automation-mode-to-touch": 40402,
444 "automation-set-track-automation-mode-to-trim-read": 40400,
445 "automation-set-track-automation-mode-to-write": 40403,
446 "automation-toggle-track-between-touch-and-trim-read-modes": 41109,
447 "automation-unarm-all-envelopes": 41163,
448 "automation-write-current-values-for-actively-writing-envelopes-from-cursor-to-end-of-project": 42015,
449 "automation-write-current-values-for-actively-writing-envelopes-from-cursor-to-first-touch-position": 42016,
450 "automation-write-current-values-for-actively-writing-envelopes-from-cursor-to-start-of-project": 42014,
451 "automation-write-current-values-for-actively-writing-envelopes-to-entire-envelope": 42017,
452 "automation-write-current-values-for-actively-writing-envelopes-to-time-selection": 42013,
453 "automation-write-current-values-for-all-writing-envelopes-from-cursor-to-end-of-project": 41162,
454 "automation-write-current-values-for-all-writing-envelopes-from-cursor-to-start-of-project": 41161,
455 "automation-write-current-values-for-all-writing-envelopes-to-time-selection": 41160,
456 "big-clock-plus-extended-display-recording-pass-markers-etc": 1101,
457 "calculate-loudness-of-master-mix-via-dry-run-render": 42440,
458 "calculate-loudness-of-master-mix-within-time-selection-via-dry-run-render": 42441,
459 "calculate-loudness-of-selected-items-including-take-and-track-fx-and-settings-via-dry-run-render": 42437,
460 "calculate-loudness-of-selected-items-source-media-via-dry-run-render": 42468,
461 "calculate-loudness-of-selected-tracks-via-dry-run-render": 42438,
462 "calculate-loudness-of-selected-tracks-within-time-selection-via-dry-run-render": 42439,
463 "calculate-mono-loudness-of-selected-tracks-via-dry-run-render": 42447,
464 "calculate-mono-loudness-of-selected-tracks-within-time-selection-via-dry-run-render": 42448,
465 "calculate-transient-guides": 42028,
466 "calculate-transient-guides-for-visible-areas-in-items": 42029,
467 "clear-project-recording-tag-rectag-wildcard": 43465,
468 "clear-tempo-envelope": 42395,
469 "clear-transient-guides": 42027,
470 "close-all-projects-but-current": 41922,
471 "close-current-project-tab": 40860,
472 "colors-reset-random-color-generator": 41343,
473 "comp-takes-activate-next-comp": 41376,
474 "comp-takes-activate-previous-comp": 41375,
475 "comp-takes-choose-active-comp-for-item-under-mouse-and-all-other-items-in-the-comp": 41382,
476 "comp-takes-crop-list-to-active-comp": 41379,
477 "comp-takes-move-active-comp-to-top-lane": 41378,
478 "comp-takes-remove-active-comp-from-list": 41374,
479 "comp-takes-save-rename-active-comp": 41373,
480 "comp-takes-toggle-select-last-comp-a-b": 41377,
481 "control-surface-refresh-all-surfaces": 41743,
482 "convert-active-take-midi-to-in-project-midi-source-data": 40684,
483 "convert-active-take-midi-to-mid-file-reference": 40685,
484 "create-measure-from-time-selection-detect-tempo-detect-number-of-measures": 40338,
485 "create-measure-from-time-selection-detect-tempo-try-to-create-single-measure": 42407,
486 "create-measure-from-time-selection-new-time-signature": 40801,
487 "developer-debug-console": 41075,
488 "developer-write-c-plus-plus-api-functions-header": 41064,
489 "dock-undock-currently-focused-dockable-window-or-attach-unattach-focused-docker": 41172,
490 "docker-activate-next-tab": 41624,
491 "docker-activate-previous-tab": 41625,
492 "docker-show-in-bottom-of-main-window": 41598,
493 "docker-show-in-left-of-main-window": 41599,
494 "docker-show-in-right-of-main-window": 41601,
495 "docker-show-in-top-of-main-window": 41600,
496 "dockers-compact-when-small-and-single-tab": 41691,
497 "edit-copy-items": 40698,
498 "edit-copy-items-tracks-envelope-points-depending-on-focus-ignoring-time-selection": 40057,
499 "edit-copy-items-tracks-envelope-points-depending-on-focus-within-time-selection-if-any-smart-copy": 41383,
500 "edit-cut-items": 40699,
501 "edit-cut-items-tracks-envelope-points-depending-on-focus-ignoring-time-selection": 40059,
502 "edit-cut-items-tracks-envelope-points-depending-on-focus-within-time-selection-if-any-smart-cut": 41384,
503 "edit-delete-notes-of-less-than-1-128-note-in-length-in-selected-midi-items": 41738,
504 "edit-delete-notes-of-less-than-1-16-note-in-length-in-selected-midi-items": 41735,
505 "edit-delete-notes-of-less-than-1-256-note-in-length-in-selected-midi-items": 41739,
506 "edit-delete-notes-of-less-than-1-32-note-in-length-in-selected-midi-items": 41736,
507 "edit-delete-notes-of-less-than-1-64-note-in-length-in-selected-midi-items": 41737,
508 "edit-delete-notes-of-less-than-1-8-note-in-length-in-selected-midi-items": 41734,
509 "edit-delete-trailing-notes-of-less-than-1-128-note-in-length-in-selected-midi-items": 41732,
510 "edit-delete-trailing-notes-of-less-than-1-16-note-in-length-in-selected-midi-items": 41729,
511 "edit-delete-trailing-notes-of-less-than-1-256-note-in-length-in-selected-midi-items": 41733,
512 "edit-delete-trailing-notes-of-less-than-1-32-note-in-length-in-selected-midi-items": 41730,
513 "edit-delete-trailing-notes-of-less-than-1-64-note-in-length-in-selected-midi-items": 41731,
514 "edit-delete-trailing-notes-of-less-than-1-8-note-in-length-in-selected-midi-items": 41728,
515 "edit-dynamic-split-items": 40760,
516 "edit-dynamic-split-items-using-most-recent-settings": 42951,
517 "edit-redo": 40030,
518 "edit-undo": 40029,
519 "envelope-add-edge-points-to-automation-item": 42209,
520 "envelope-add-edit-envelope-point-value-at-cursor": 41987,
521 "envelope-add-edit-envelope-point-value-exactly-at-cursor": 40152,
522 "envelope-apply-all-vcas-from-selected-tracks-to-grouped-tracks-and-reset-volume-pan-mute": 41982,
523 "envelope-apply-all-vcas-to-selected-tracks-and-remove-from-vca-groups": 41981,
524 "envelope-automation-item-properties": 42090,
525 "envelope-automation-items-connect-to-the-underlying-envelope-on-both-sides": 42223,
526 "envelope-automation-items-connect-to-the-underlying-envelope-on-the-right-side": 42222,
527 "envelope-automation-items-do-not-connect-to-the-underlying-envelope": 42221,
528 "envelope-bypass-underlying-envelope-outside-of-automation-items": 42224,
529 "envelope-chase-non-fx-envelope-to-automation-items-when-underlying-envelope-is-bypassed": 42345,
530 "envelope-clear-or-remove-envelope": 40065,
531 "envelope-convert-all-project-automation-to-automation-items": 42207,
532 "envelope-copy-points-within-time-selection": 40324,
533 "envelope-copy-selected-points": 40335,
534 "envelope-cut-points-within-time-selection": 40325,
535 "envelope-cut-selected-points": 40336,
536 "envelope-decrease-bezier-tension-for-selected-points-by-25-percent": 41125,
537 "envelope-decrease-bezier-tension-for-selected-points-by-5-percent": 41123,
538 "envelope-delete-all-points-in-time-selection": 40089,
539 "envelope-delete-all-selected-points": 40333,
540 "envelope-delete-automation-items": 42086,
541 "envelope-delete-automation-items-preserve-points": 42088,
542 "envelope-duplicate-and-pool-automation-items": 42085,
543 "envelope-duplicate-automation-items": 42083,
544 "envelope-glue-automation-items": 42089,
545 "envelope-hide-all-envelopes-for-all-tracks": 41150,
546 "envelope-hide-all-envelopes-for-tracks": 40889,
547 "envelope-increase-bezier-tension-for-selected-points-by-25-percent": 41124,
548 "envelope-increase-bezier-tension-for-selected-points-by-5-percent": 41122,
549 "envelope-insert-4-envelope-points-at-time-selection": 40726,
550 "envelope-insert-automation-item": 42082,
551 "envelope-insert-new-point-at-current-position-do-not-remove-nearby-points": 40106,
552 "envelope-insert-new-point-at-current-position-remove-nearby-points": 40915,
553 "envelope-insert-new-point-at-current-position-to-all-visible-track-envelopes-do-not-remove-nearby-points": 40064,
554 "envelope-insert-new-point-at-current-position-to-all-visible-track-envelopes-remove-nearby-points": 41126,
555 "envelope-invert-selected-points": 40334,
556 "envelope-load-automation-item": 42093,
557 "envelope-mute-automation-items": 42211,
558 "envelope-obey-project-default-setting-to-bypass-underlying-envelope-outside-of-automation-items": 42215,
559 "envelope-reduce-number-of-points": 40887,
560 "envelope-reduce-number-of-points-by-half": 42199,
561 "envelope-reduce-number-of-points-by-half-within-time-selection": 42201,
562 "envelope-reduce-number-of-selected-points-by-half": 42208,
563 "envelope-remove-automation-items-from-pool-unpool": 42084,
564 "envelope-remove-unnecessary-points": 43588,
565 "envelope-remove-unnecessary-points-within-time-selection": 43589,
566 "envelope-remove-unnecessary-selected-points": 43590,
567 "envelope-rename-automation-item": 42091,
568 "envelope-reset-selected-points-to-zero-center": 40415,
569 "envelope-reverse-points": 42200,
570 "envelope-save-automation-item": 42092,
571 "envelope-select-all-points": 40332,
572 "envelope-select-points-in-time-selection": 40330,
573 "envelope-set-default-point-shape-to-bezier": 40681,
574 "envelope-set-default-point-shape-to-fast-end": 40431,
575 "envelope-set-default-point-shape-to-fast-start": 40430,
576 "envelope-set-default-point-shape-to-linear": 40187,
577 "envelope-set-default-point-shape-to-slow-start-end": 40425,
578 "envelope-set-default-point-shape-to-square": 40188,
579 "envelope-set-loop-points-to-automation-item": 42198,
580 "envelope-set-shape-of-selected-points-to-bezier": 40683,
581 "envelope-set-shape-of-selected-points-to-fast-end": 40429,
582 "envelope-set-shape-of-selected-points-to-fast-start": 40428,
583 "envelope-set-shape-of-selected-points-to-linear": 40189,
584 "envelope-set-shape-of-selected-points-to-slow-start-end": 40424,
585 "envelope-set-shape-of-selected-points-to-square": 40190,
586 "envelope-set-time-selection-to-automation-item": 42197,
587 "envelope-show-all-active-envelopes-for-tracks": 40888,
588 "envelope-show-all-envelopes-for-all-tracks": 41149,
589 "envelope-show-all-envelopes-for-tracks": 41148,
590 "envelope-split-automation-items": 42087,
591 "envelope-toggle-automation-item-loop": 42196,
592 "envelope-toggle-bypass-for-selected-envelope": 40883,
593 "envelope-toggle-display-all-visible-envelopes-in-lanes-for-tracks": 40891,
594 "envelope-toggle-display-in-separate-lane-for-selected-envelope": 40851,
595 "envelope-toggle-hide-display-selected-envelope": 40884,
596 "envelope-toggle-record-arm-for-selected-envelope": 40863,
597 "envelope-toggle-select-unselect-all-points": 41595,
598 "envelope-toggle-show-all-active-envelopes-for-all-tracks": 40926,
599 "envelope-toggle-show-all-active-envelopes-for-tracks": 40890,
600 "envelope-toggle-show-all-envelopes-for-all-tracks": 41152,
601 "envelope-toggle-show-all-envelopes-for-tracks": 41151,
602 "envelope-unselect-clear-selection-of-all-points": 40331,
603 "envelopes-move-selected-points-down-a-little-bit": 41181,
604 "envelopes-move-selected-points-down-a-tiny-bit": 42382,
605 "envelopes-move-selected-points-left-a-little-bit": 41176,
606 "envelopes-move-selected-points-left-by-grid": 41178,
607 "envelopes-move-selected-points-right-a-little-bit": 41177,
608 "envelopes-move-selected-points-right-by-grid": 41179,
609 "envelopes-move-selected-points-up-a-little-bit": 41180,
610 "envelopes-move-selected-points-up-a-tiny-bit": 42381,
611 "envelopes-view-envelopes-for-last-touched-track-item": 40019,
612 "export-track-lyrics": 42071,
613 "file-add-project-to-render-queue-using-the-most-recent-render-settings": 41823,
614 "file-batch-file-converter": 41076,
615 "file-choose-project-s-to-open": 43697,
616 "file-clean-current-project-directory": 40098,
617 "file-close-all-projects": 40886,
618 "file-consolidate-tracks": 40185,
619 "file-dry-run-render-project-using-the-most-recent-render-settings": 43349,
620 "file-duplicate-project-in-new-tab": 43645,
621 "file-export-configuration": 41568,
622 "file-export-project-midi": 40849,
623 "file-import-configuration": 41569,
624 "file-new-project": 40023,
625 "file-open-project": 40025,
626 "file-open-render-queue": 40929,
627 "file-open-render-statistics-charts-from-most-recent-render-in-web-browser": 42497,
628 "file-open-render-statistics-charts-from-most-recent-render-in-web-browser-hide-file-paths": 42510,
629 "file-open-render-statistics-charts-from-most-recent-render-in-web-browser-hide-file-paths-and-project-regions-markers":
630 43343,
631 "file-open-render-statistics-charts-from-most-recent-render-in-web-browser-hide-project-regions-markers": 43342,
632 "file-project-settings": 40021,
633 "file-quit-reaper": 40004,
634 "file-render-project-to-disk": 40015,
635 "file-render-project-using-the-most-recent-render-settings": 41824,
636 "file-render-project-using-the-most-recent-render-settings-auto-close-render-dialog": 42230,
637 "file-render-project-using-the-most-recent-render-settings-with-a-new-target-file-name": 41855,
638 "file-save-all-projects": 40897,
639 "file-save-copy-of-project-as-prompt-with-current-name": 43578,
640 "file-save-copy-of-project-as-prompt-with-incremented-project-name": 42347,
641 "file-save-copy-of-project-automatically-increment-project-name": 42346,
642 "file-save-live-output-to-disk-bounce": 40017,
643 "file-save-live-output-to-disk-bounce-using-the-most-recent-bounce-settings": 42317,
644 "file-save-new-version-of-project-automatically-increment-project-name": 41895,
645 "file-save-project": 40026,
646 "file-save-project-and-render-rpp-prox": 42332,
647 "file-save-project-as": 40022,
648 "file-save-project-as-template": 40394,
649 "file-show-project-render-metadata-window": 42397,
650 "file-spawn-new-instance-of-reaper": 40063,
651 "fixed-lane-comp-area-add-comp-area-at-time-selection-for-lane-at-mouse": 42657,
652 "fixed-lane-comp-area-add-comp-area-between-previous-and-next-comp-areas-for-lane-at-mouse": 42599,
653 "fixed-lane-comp-area-add-comp-area-from-mouse-position-to-next-area-or-end-of-media": 42613,
654 "fixed-lane-comp-area-delete-comp-area": 42642,
655 "fixed-lane-comp-area-delete-comp-area-at-mouse": 42643,
656 "fixed-lane-comp-area-delete-comp-area-at-mouse-but-not-media-items": 42644,
657 "fixed-lane-comp-area-delete-comp-area-but-not-media-items": 42473,
658 "fixed-lane-comp-area-delete-comp-area-edge": 42496,
659 "fixed-lane-comp-area-delete-comp-area-edge-at-mouse": 42595,
660 "fixed-lane-comp-area-move-comp-area-at-mouse-down": 42492,
661 "fixed-lane-comp-area-move-comp-area-at-mouse-to-lane-under-mouse": 42493,
662 "fixed-lane-comp-area-move-comp-area-at-mouse-up": 42491,
663 "fixed-lane-comp-area-move-comp-area-down-for-selected-items": 42708,
664 "fixed-lane-comp-area-move-comp-area-up-for-selected-items": 42707,
665 "fixed-lane-comp-area-move-down": 41083,
666 "fixed-lane-comp-area-move-up": 41082,
667 "fixed-lane-comp-area-set-loop-points-to-comp-area": 42495,
668 "fixed-lane-comp-area-set-loop-points-to-comp-area-at-mouse": 42504,
669 "fixed-lane-comp-area-set-loop-points-to-comp-area-at-mouse-half-second-preroll-postroll": 42711,
670 "fixed-lane-comp-area-set-loop-points-to-comp-area-at-mouse-one-second-preroll-postroll": 42712,
671 "fixed-lane-comp-area-split-comp-area-at-edit-cursor": 42600,
672 "fixed-lane-comp-area-split-comp-area-at-mouse-position": 42641,
673 "fixed-lane-comp-area-split-comp-area-at-mouse-position-ignoring-snap": 40146,
674 "fixed-lane-comp-area-split-media-items-at-comp-area-edges": 42602,
675 "fixed-lane-comp-area-split-media-items-at-edges-of-comp-area-at-mouse": 42603,
676 "fully-unload-unloaded-vsts": 41204,
677 "fx-activate-bypass-track-take-envelope-for-last-touched-fx-parameter": 41983,
678 "fx-arm-track-take-envelope-for-last-touched-fx-parameter": 41984,
679 "fx-auto-float-new-fx-windows": 41078,
680 "fx-clear-delta-solo-for-all-fx-on-selected-tracks": 42466,
681 "fx-clear-delta-solo-for-all-project-fx": 42467,
682 "fx-delete-all-track-fx-on-selected-tracks": 43698,
683 "fx-hide-all-fx-embedded-ui-in-tcp-selected-tracks": 42341,
684 "fx-set-alias-for-last-touched-fx-parameter": 41145,
685 "fx-set-all-fx-online-one-at-a-time": 43689,
686 "fx-set-midi-learn-for-last-touched-fx-parameter": 41144,
687 "fx-show-all-fx-embedded-ui-in-tcp-selected-tracks": 42340,
688 "fx-show-hide-track-control-for-last-touched-fx-parameter": 41141,
689 "fx-show-hide-track-take-envelope-for-last-touched-fx-parameter": 41142,
690 "fx-show-last-focused-fx-embedded-ui-in-mcp": 42372,
691 "fx-show-last-focused-fx-embedded-ui-in-tcp": 42335,
692 "fx-show-next-single-fx-embedded-ui-in-tcp-selected-tracks": 42342,
693 "fx-show-parameter-modulation-link-for-last-touched-fx-parameter": 41143,
694 "fx-show-previous-single-fx-embedded-ui-in-tcp-selected-tracks": 42343,
695 "fx-toggle-delta-solo-for-last-focused-fx": 42455,
696 "fx-toggle-map-to-container-parameter-for-last-touched-fx-parameter": 42650,
697 "fx-toggle-preference-auto-bypass-fx-that-require-pdc-on-record-arm-affected-tracks": 43150,
698 "global-automation-override-all-automation-in-latch-mode": 40881,
699 "global-automation-override-all-automation-in-latch-preview-mode": 42022,
700 "global-automation-override-all-automation-in-read-mode": 40879,
701 "global-automation-override-all-automation-in-touch-mode": 40880,
702 "global-automation-override-all-automation-in-trim-read-mode": 40878,
703 "global-automation-override-all-automation-in-write-mode": 40882,
704 "global-automation-override-bypass-all-automation": 40885,
705 "global-automation-override-no-override-set-automation-modes-per-track": 40876,
706 "global-automation-override-toggle-bypass-all-automation": 40908,
707 "go-to-end-of-loop": 40633,
708 "go-to-end-of-time-selection": 40631,
709 "go-to-start-of-loop": 40632,
710 "go-to-start-of-time-selection": 40630,
711 "grid-adjust-by-1-1-5": 40782,
712 "grid-adjust-by-1-2": 40783,
713 "grid-adjust-by-1-3": 40784,
714 "grid-adjust-by-1-5": 40785,
715 "grid-adjust-by-2": 40786,
716 "grid-adjust-by-3": 40787,
717 "grid-adjust-swing-grid-midi-cc-mousewheel-only": 969,
718 "grid-divide-arrange-view-vertically-by-measures": 42331,
719 "grid-set-framerate-grid": 40904,
720 "grid-set-measure-grid": 40923,
721 "grid-set-to-1": 40781,
722 "grid-set-to-1-10-1-8-quintuplet": 42002,
723 "grid-set-to-1-12-1-8-triplet": 40777,
724 "grid-set-to-1-128": 41047,
725 "grid-set-to-1-16": 40776,
726 "grid-set-to-1-18": 42001,
727 "grid-set-to-1-2": 40780,
728 "grid-set-to-1-24-1-16-triplet": 41213,
729 "grid-set-to-1-3-1-2-triplet": 42000,
730 "grid-set-to-1-32": 40775,
731 "grid-set-to-1-4": 40779,
732 "grid-set-to-1-48-1-32-triplet": 41212,
733 "grid-set-to-1-5-1-4-quintuplet": 42005,
734 "grid-set-to-1-6-1-4-triplet": 41214,
735 "grid-set-to-1-64": 40774,
736 "grid-set-to-1-7-1-4-septuplet": 42004,
737 "grid-set-to-1-8": 40778,
738 "grid-set-to-1-9": 42003,
739 "grid-set-to-2": 41210,
740 "grid-set-to-2-3-whole-note-triplet": 42007,
741 "grid-set-to-3": 42006,
742 "grid-set-to-4": 41211,
743 "grid-toggle-framerate-grid": 41885,
744 "grid-toggle-measure-grid": 40725,
745 "grid-toggle-swing-grid": 42304,
746 "grid-use-the-same-grid-division-in-arrange-view-and-midi-editor": 42010,
747 "group-enable-group-01": 42511,
748 "group-enable-group-02": 42512,
749 "group-enable-group-03": 42513,
750 "group-enable-group-04": 42514,
751 "group-enable-group-05": 42515,
752 "group-enable-group-06": 42516,
753 "group-enable-group-07": 42517,
754 "group-enable-group-08": 42518,
755 "group-enable-group-09": 42519,
756 "group-enable-group-10": 42520,
757 "group-enable-group-100": 43313,
758 "group-enable-group-101": 43314,
759 "group-enable-group-102": 43315,
760 "group-enable-group-103": 43316,
761 "group-enable-group-104": 43317,
762 "group-enable-group-105": 43318,
763 "group-enable-group-106": 43319,
764 "group-enable-group-107": 43320,
765 "group-enable-group-108": 43321,
766 "group-enable-group-109": 43322,
767 "group-enable-group-11": 42521,
768 "group-enable-group-110": 43323,
769 "group-enable-group-111": 43324,
770 "group-enable-group-112": 43325,
771 "group-enable-group-113": 43326,
772 "group-enable-group-114": 43327,
773 "group-enable-group-115": 43328,
774 "group-enable-group-116": 43329,
775 "group-enable-group-117": 43330,
776 "group-enable-group-118": 43331,
777 "group-enable-group-119": 43332,
778 "group-enable-group-12": 42522,
779 "group-enable-group-120": 43333,
780 "group-enable-group-121": 43334,
781 "group-enable-group-122": 43335,
782 "group-enable-group-123": 43336,
783 "group-enable-group-124": 43337,
784 "group-enable-group-125": 43338,
785 "group-enable-group-126": 43339,
786 "group-enable-group-127": 43340,
787 "group-enable-group-128": 43341,
788 "group-enable-group-13": 42523,
789 "group-enable-group-14": 42524,
790 "group-enable-group-15": 42525,
791 "group-enable-group-16": 42526,
792 "group-enable-group-17": 42527,
793 "group-enable-group-18": 42528,
794 "group-enable-group-19": 42529,
795 "group-enable-group-20": 42530,
796 "group-enable-group-21": 42531,
797 "group-enable-group-22": 42532,
798 "group-enable-group-23": 42533,
799 "group-enable-group-24": 42534,
800 "group-enable-group-25": 42535,
801 "group-enable-group-26": 42536,
802 "group-enable-group-27": 42537,
803 "group-enable-group-28": 42538,
804 "group-enable-group-29": 42539,
805 "group-enable-group-30": 42540,
806 "group-enable-group-31": 42541,
807 "group-enable-group-32": 42542,
808 "group-enable-group-33": 42543,
809 "group-enable-group-34": 42544,
810 "group-enable-group-35": 42545,
811 "group-enable-group-36": 42546,
812 "group-enable-group-37": 42547,
813 "group-enable-group-38": 42548,
814 "group-enable-group-39": 42549,
815 "group-enable-group-40": 42550,
816 "group-enable-group-41": 42551,
817 "group-enable-group-42": 42552,
818 "group-enable-group-43": 42553,
819 "group-enable-group-44": 42554,
820 "group-enable-group-45": 42555,
821 "group-enable-group-46": 42556,
822 "group-enable-group-47": 42557,
823 "group-enable-group-48": 42558,
824 "group-enable-group-49": 42559,
825 "group-enable-group-50": 42560,
826 "group-enable-group-51": 42561,
827 "group-enable-group-52": 42562,
828 "group-enable-group-53": 42563,
829 "group-enable-group-54": 42564,
830 "group-enable-group-55": 42565,
831 "group-enable-group-56": 42566,
832 "group-enable-group-57": 42567,
833 "group-enable-group-58": 42568,
834 "group-enable-group-59": 42569,
835 "group-enable-group-60": 42570,
836 "group-enable-group-61": 42571,
837 "group-enable-group-62": 42572,
838 "group-enable-group-63": 42573,
839 "group-enable-group-64": 42574,
840 "group-enable-group-65": 43278,
841 "group-enable-group-66": 43279,
842 "group-enable-group-67": 43280,
843 "group-enable-group-68": 43281,
844 "group-enable-group-69": 43282,
845 "group-enable-group-70": 43283,
846 "group-enable-group-71": 43284,
847 "group-enable-group-72": 43285,
848 "group-enable-group-73": 43286,
849 "group-enable-group-74": 43287,
850 "group-enable-group-75": 43288,
851 "group-enable-group-76": 43289,
852 "group-enable-group-77": 43290,
853 "group-enable-group-78": 43291,
854 "group-enable-group-79": 43292,
855 "group-enable-group-80": 43293,
856 "group-enable-group-81": 43294,
857 "group-enable-group-82": 43295,
858 "group-enable-group-83": 43296,
859 "group-enable-group-84": 43297,
860 "group-enable-group-85": 43298,
861 "group-enable-group-86": 43299,
862 "group-enable-group-87": 43300,
863 "group-enable-group-88": 43301,
864 "group-enable-group-89": 43302,
865 "group-enable-group-90": 43303,
866 "group-enable-group-91": 43304,
867 "group-enable-group-92": 43305,
868 "group-enable-group-93": 43306,
869 "group-enable-group-94": 43307,
870 "group-enable-group-95": 43308,
871 "group-enable-group-96": 43309,
872 "group-enable-group-97": 43310,
873 "group-enable-group-98": 43311,
874 "group-enable-group-99": 43312,
875 "group-select-all-tracks-in-group-01": 40804,
876 "group-select-all-tracks-in-group-02": 40805,
877 "group-select-all-tracks-in-group-03": 40806,
878 "group-select-all-tracks-in-group-04": 40807,
879 "group-select-all-tracks-in-group-05": 40808,
880 "group-select-all-tracks-in-group-06": 40809,
881 "group-select-all-tracks-in-group-07": 40810,
882 "group-select-all-tracks-in-group-08": 40811,
883 "group-select-all-tracks-in-group-09": 40812,
884 "group-select-all-tracks-in-group-10": 40813,
885 "group-select-all-tracks-in-group-100": 43249,
886 "group-select-all-tracks-in-group-101": 43250,
887 "group-select-all-tracks-in-group-102": 43251,
888 "group-select-all-tracks-in-group-103": 43252,
889 "group-select-all-tracks-in-group-104": 43253,
890 "group-select-all-tracks-in-group-105": 43254,
891 "group-select-all-tracks-in-group-106": 43255,
892 "group-select-all-tracks-in-group-107": 43256,
893 "group-select-all-tracks-in-group-108": 43257,
894 "group-select-all-tracks-in-group-109": 43258,
895 "group-select-all-tracks-in-group-11": 40814,
896 "group-select-all-tracks-in-group-110": 43259,
897 "group-select-all-tracks-in-group-111": 43260,
898 "group-select-all-tracks-in-group-112": 43261,
899 "group-select-all-tracks-in-group-113": 43262,
900 "group-select-all-tracks-in-group-114": 43263,
901 "group-select-all-tracks-in-group-115": 43264,
902 "group-select-all-tracks-in-group-116": 43265,
903 "group-select-all-tracks-in-group-117": 43266,
904 "group-select-all-tracks-in-group-118": 43267,
905 "group-select-all-tracks-in-group-119": 43268,
906 "group-select-all-tracks-in-group-12": 40815,
907 "group-select-all-tracks-in-group-120": 43269,
908 "group-select-all-tracks-in-group-121": 43270,
909 "group-select-all-tracks-in-group-122": 43271,
910 "group-select-all-tracks-in-group-123": 43272,
911 "group-select-all-tracks-in-group-124": 43273,
912 "group-select-all-tracks-in-group-125": 43274,
913 "group-select-all-tracks-in-group-126": 43275,
914 "group-select-all-tracks-in-group-127": 43276,
915 "group-select-all-tracks-in-group-128": 43277,
916 "group-select-all-tracks-in-group-13": 40816,
917 "group-select-all-tracks-in-group-14": 40817,
918 "group-select-all-tracks-in-group-15": 40818,
919 "group-select-all-tracks-in-group-16": 40819,
920 "group-select-all-tracks-in-group-17": 40820,
921 "group-select-all-tracks-in-group-18": 40821,
922 "group-select-all-tracks-in-group-19": 40822,
923 "group-select-all-tracks-in-group-20": 40823,
924 "group-select-all-tracks-in-group-21": 40824,
925 "group-select-all-tracks-in-group-22": 40825,
926 "group-select-all-tracks-in-group-23": 40826,
927 "group-select-all-tracks-in-group-24": 40827,
928 "group-select-all-tracks-in-group-25": 40828,
929 "group-select-all-tracks-in-group-26": 40829,
930 "group-select-all-tracks-in-group-27": 40830,
931 "group-select-all-tracks-in-group-28": 40831,
932 "group-select-all-tracks-in-group-29": 40832,
933 "group-select-all-tracks-in-group-30": 40833,
934 "group-select-all-tracks-in-group-31": 40834,
935 "group-select-all-tracks-in-group-32": 40835,
936 "group-select-all-tracks-in-group-33": 42237,
937 "group-select-all-tracks-in-group-34": 42238,
938 "group-select-all-tracks-in-group-35": 42239,
939 "group-select-all-tracks-in-group-36": 42240,
940 "group-select-all-tracks-in-group-37": 42241,
941 "group-select-all-tracks-in-group-38": 42242,
942 "group-select-all-tracks-in-group-39": 42243,
943 "group-select-all-tracks-in-group-40": 42244,
944 "group-select-all-tracks-in-group-41": 42245,
945 "group-select-all-tracks-in-group-42": 42246,
946 "group-select-all-tracks-in-group-43": 42247,
947 "group-select-all-tracks-in-group-44": 42248,
948 "group-select-all-tracks-in-group-45": 42249,
949 "group-select-all-tracks-in-group-46": 42250,
950 "group-select-all-tracks-in-group-47": 42251,
951 "group-select-all-tracks-in-group-48": 42252,
952 "group-select-all-tracks-in-group-49": 42253,
953 "group-select-all-tracks-in-group-50": 42254,
954 "group-select-all-tracks-in-group-51": 42255,
955 "group-select-all-tracks-in-group-52": 42256,
956 "group-select-all-tracks-in-group-53": 42257,
957 "group-select-all-tracks-in-group-54": 42258,
958 "group-select-all-tracks-in-group-55": 42259,
959 "group-select-all-tracks-in-group-56": 42260,
960 "group-select-all-tracks-in-group-57": 42261,
961 "group-select-all-tracks-in-group-58": 42262,
962 "group-select-all-tracks-in-group-59": 42263,
963 "group-select-all-tracks-in-group-60": 42264,
964 "group-select-all-tracks-in-group-61": 42265,
965 "group-select-all-tracks-in-group-62": 42266,
966 "group-select-all-tracks-in-group-63": 42267,
967 "group-select-all-tracks-in-group-64": 42268,
968 "group-select-all-tracks-in-group-65": 43214,
969 "group-select-all-tracks-in-group-66": 43215,
970 "group-select-all-tracks-in-group-67": 43216,
971 "group-select-all-tracks-in-group-68": 43217,
972 "group-select-all-tracks-in-group-69": 43218,
973 "group-select-all-tracks-in-group-70": 43219,
974 "group-select-all-tracks-in-group-71": 43220,
975 "group-select-all-tracks-in-group-72": 43221,
976 "group-select-all-tracks-in-group-73": 43222,
977 "group-select-all-tracks-in-group-74": 43223,
978 "group-select-all-tracks-in-group-75": 43224,
979 "group-select-all-tracks-in-group-76": 43225,
980 "group-select-all-tracks-in-group-77": 43226,
981 "group-select-all-tracks-in-group-78": 43227,
982 "group-select-all-tracks-in-group-79": 43228,
983 "group-select-all-tracks-in-group-80": 43229,
984 "group-select-all-tracks-in-group-81": 43230,
985 "group-select-all-tracks-in-group-82": 43231,
986 "group-select-all-tracks-in-group-83": 43232,
987 "group-select-all-tracks-in-group-84": 43233,
988 "group-select-all-tracks-in-group-85": 43234,
989 "group-select-all-tracks-in-group-86": 43235,
990 "group-select-all-tracks-in-group-87": 43236,
991 "group-select-all-tracks-in-group-88": 43237,
992 "group-select-all-tracks-in-group-89": 43238,
993 "group-select-all-tracks-in-group-90": 43239,
994 "group-select-all-tracks-in-group-91": 43240,
995 "group-select-all-tracks-in-group-92": 43241,
996 "group-select-all-tracks-in-group-93": 43242,
997 "group-select-all-tracks-in-group-94": 43243,
998 "group-select-all-tracks-in-group-95": 43244,
999 "group-select-all-tracks-in-group-96": 43245,
1000 "group-select-all-tracks-in-group-97": 43246,
1001 "group-select-all-tracks-in-group-98": 43247,
1002 "group-select-all-tracks-in-group-99": 43248,
1003 "help-about-reaper": 40007,
1004 "help-all-actions": 40845,
1005 "help-check-for-new-versions": 40442,
1006 "help-mouse-modifier-keys-and-action-shortcuts": 40308,
1007 "help-show-mouse-editing-help-in-the-area-beneath-the-track-control-panels": 41345,
1008 "i-o-dialog-close-window-on-enter-key": 41828,
1009 "import-track-lyrics": 42070,
1010 "insert-click-source": 40013,
1011 "insert-dedicated-video-processor-item": 41932,
1012 "insert-empty-item": 40142,
1013 "insert-fx-aui-komplete-kontrol-native-instruments": 55807,
1014 "insert-import-media-files": 40018,
1015 "insert-import-media-files-from-directory": 43673,
1016 "insert-new-midi-item": 40214,
1017 "insert-new-subproject": 41049,
1018 "insert-or-extend-midi-items-to-fill-time-selection": 42069,
1019 "insert-timecode-generator": 40208,
1020 "insert-virtual-instrument-on-new-track": 40701,
1021 "item-add-an-empty-take-after-the-active-take": 41352,
1022 "item-add-an-empty-take-before-the-active-take": 41351,
1023 "item-add-edit-take-marker-at-mouse-position": 42388,
1024 "item-add-edit-take-marker-at-play-position-or-edit-cursor": 42385,
1025 "item-add-edit-take-marker-at-time-selection": 43181,
1026 "item-add-stretch-marker-at-cursor": 41842,
1027 "item-add-stretch-marker-at-mouse-position": 41848,
1028 "item-add-stretch-markers-at-time-selection": 41843,
1029 "item-apply-first-take-fx-to-items": 42686,
1030 "item-apply-first-take-fx-to-items-mono-output": 42688,
1031 "item-apply-first-track-fx-to-items": 42685,
1032 "item-apply-first-track-fx-to-items-mono-output": 42687,
1033 "item-apply-track-take-fx-to-items": 40209,
1034 "item-apply-track-take-fx-to-items-midi-output": 40436,
1035 "item-apply-track-take-fx-to-items-mono-output": 40361,
1036 "item-apply-track-take-fx-to-items-multichannel-output": 41993,
1037 "item-auto-reposition-items-in-free-item-positioning-mode": 40645,
1038 "item-auto-trim-split-items-remove-silence": 40315,
1039 "item-choose-active-take-for-item-under-mouse": 41381,
1040 "item-clear-up-rank-down-rank-markers": 43161,
1041 "item-clear-up-rank-down-rank-markers-for-take-under-mouse": 43162,
1042 "item-clear-up-rank-down-rank-markers-within-time-selection": 43202,
1043 "item-close-item-inline-editors": 41887,
1044 "item-collapse-empty-take": 41747,
1045 "item-convert-embedded-source-transient-information-to-transient-guides": 42380,
1046 "item-copy-items-to-time-selection-trim-loop-to-fit": 41319,
1047 "item-copy-loop-of-selected-area-of-audio-items": 40014,
1048 "item-copy-selected-area-of-items": 40060,
1049 "item-create-chromatic-midi-from-items": 40773,
1050 "item-crossfade-any-overlapping-items": 41059,
1051 "item-crossfade-items-within-time-selection": 40916,
1052 "item-cut-selected-area-of-items": 40307,
1053 "item-cycle-through-crossfade-shapes": 41534,
1054 "item-cycle-through-fade-in-shapes": 41520,
1055 "item-cycle-through-fade-out-shapes": 41527,
1056 "item-cycle-through-up-rank-down-rank-levels-for-active-take-or-last-recording-pass": 43197,
1057 "item-cycle-through-up-rank-down-rank-levels-for-take-marker-at-mouse-position": 43200,
1058 "item-cycle-through-up-rank-down-rank-levels-for-take-marker-at-play-position-or-edit-cursor": 43199,
1059 "item-cycle-through-up-rank-down-rank-levels-for-take-marker-at-time-selection": 43201,
1060 "item-cycle-through-up-rank-down-rank-levels-for-take-or-comp-area-under-mouse": 43198,
1061 "item-delete-all-take-markers": 42387,
1062 "item-delete-take-marker-at-cursor": 42386,
1063 "item-delete-take-marker-at-mouse-position": 42389,
1064 "item-delete-take-markers-in-time-selection": 43182,
1065 "item-delete-takes-for-item-under-mouse-that-are-down-ranked-no-confirm": 43163,
1066 "item-delete-takes-for-item-under-mouse-that-are-not-up-ranked-no-confirm": 43164,
1067 "item-delete-takes-that-are-down-ranked-no-confirm": 42682,
1068 "item-delete-takes-that-are-not-up-ranked-no-confirm": 40229,
1069 "item-disable-default-fadein-fadeout": 41196,
1070 "item-down-rank-active-take-or-last-recording-pass": 42681,
1071 "item-down-rank-take-marker-at-1-second-before-play-position-or-at-edit-cursor-if-not-playing-back": 43175,
1072 "item-down-rank-take-marker-at-2-seconds-before-play-position-or-at-edit-cursor-if-not-playing-back": 43177,
1073 "item-down-rank-take-marker-at-mouse-position": 43160,
1074 "item-down-rank-take-marker-at-play-position-or-edit-cursor": 43158,
1075 "item-down-rank-take-marker-at-time-selection": 43184,
1076 "item-down-rank-take-or-comp-area-under-mouse": 43156,
1077 "item-duplicate-items": 41295,
1078 "item-duplicate-selected-area-of-items": 41296,
1079 "item-edit-close-nudge-set-dialog": 41227,
1080 "item-edit-disable-relative-grid-snap": 41053,
1081 "item-edit-enable-relative-grid-snap": 41052,
1082 "item-edit-grow-left-edge-of-items": 40225,
1083 "item-edit-grow-right-edge-of-items": 40228,
1084 "item-edit-move-contents-of-item-to-edit-cursor": 41308,
1085 "item-edit-move-contents-of-item-under-mouse-to-edit-cursor": 41303,
1086 "item-edit-move-contents-of-items-left": 40123,
1087 "item-edit-move-contents-of-items-right": 40124,
1088 "item-edit-move-duplicate-of-item-to-edit-cursor": 41309,
1089 "item-edit-move-duplicate-of-item-under-mouse-to-edit-cursor": 41304,
1090 "item-edit-move-items-envelope-points-down-one-track-a-bit": 40118,
1091 "item-edit-move-items-envelope-points-left": 40120,
1092 "item-edit-move-items-envelope-points-left-by-grid-size": 40793,
1093 "item-edit-move-items-envelope-points-right": 40119,
1094 "item-edit-move-items-envelope-points-right-by-grid-size": 40794,
1095 "item-edit-move-items-envelope-points-up-one-track-a-bit": 40117,
1096 "item-edit-move-items-left-preserving-timing-of-contents": 40121,
1097 "item-edit-move-items-right-preserving-timing-of-contents": 40122,
1098 "item-edit-move-left-edge-of-item-to-edit-cursor-preserving-item-right-edge": 41306,
1099 "item-edit-move-left-edge-of-item-under-mouse-to-edit-cursor-preserving-item-right-edge": 41301,
1100 "item-edit-move-position-of-item-to-edit-cursor": 41205,
1101 "item-edit-move-position-of-item-under-mouse-to-edit-cursor": 41299,
1102 "item-edit-move-right-edge-of-item-to-edit-cursor-preserving-item-length": 41307,
1103 "item-edit-move-right-edge-of-item-under-mouse-to-edit-cursor-preserving-item-length": 41302,
1104 "item-edit-nudge-left-by-last-nudge-dialog-settings": 41250,
1105 "item-edit-nudge-left-by-saved-nudge-dialog-settings-1": 41279,
1106 "item-edit-nudge-left-by-saved-nudge-dialog-settings-2": 41280,
1107 "item-edit-nudge-left-by-saved-nudge-dialog-settings-3": 41281,
1108 "item-edit-nudge-left-by-saved-nudge-dialog-settings-4": 41282,
1109 "item-edit-nudge-left-by-saved-nudge-dialog-settings-5": 41291,
1110 "item-edit-nudge-left-by-saved-nudge-dialog-settings-6": 41292,
1111 "item-edit-nudge-left-by-saved-nudge-dialog-settings-7": 41293,
1112 "item-edit-nudge-left-by-saved-nudge-dialog-settings-8": 41294,
1113 "item-edit-nudge-right-by-last-nudge-dialog-settings": 41249,
1114 "item-edit-nudge-right-by-saved-nudge-dialog-settings-1": 41275,
1115 "item-edit-nudge-right-by-saved-nudge-dialog-settings-2": 41276,
1116 "item-edit-nudge-right-by-saved-nudge-dialog-settings-3": 41277,
1117 "item-edit-nudge-right-by-saved-nudge-dialog-settings-4": 41278,
1118 "item-edit-nudge-right-by-saved-nudge-dialog-settings-5": 41287,
1119 "item-edit-nudge-right-by-saved-nudge-dialog-settings-6": 41288,
1120 "item-edit-nudge-right-by-saved-nudge-dialog-settings-7": 41289,
1121 "item-edit-nudge-right-by-saved-nudge-dialog-settings-8": 41290,
1122 "item-edit-nudge-set": 41226,
1123 "item-edit-phase-alignment": 43466,
1124 "item-edit-save-nudge-dialog-settings-1": 41271,
1125 "item-edit-save-nudge-dialog-settings-2": 41272,
1126 "item-edit-save-nudge-dialog-settings-3": 41273,
1127 "item-edit-save-nudge-dialog-settings-4": 41274,
1128 "item-edit-save-nudge-dialog-settings-5": 41283,
1129 "item-edit-save-nudge-dialog-settings-6": 41284,
1130 "item-edit-save-nudge-dialog-settings-7": 41285,
1131 "item-edit-save-nudge-dialog-settings-8": 41286,
1132 "item-edit-shrink-left-edge-of-items": 40226,
1133 "item-edit-shrink-right-edge-of-items": 40227,
1134 "item-edit-stretch-marker-at-cursor": 41988,
1135 "item-edit-toggle-nudge-set-dialog": 41228,
1136 "item-edit-toggle-relative-grid-snap": 41054,
1137 "item-edit-trim-left-edge-of-item-to-edit-cursor": 41305,
1138 "item-edit-trim-left-edge-of-item-under-mouse-to-edit-cursor": 41300,
1139 "item-edit-trim-right-edge-of-item-to-edit-cursor": 41311,
1140 "item-edit-trim-right-edge-of-item-under-mouse-to-edit-cursor": 41310,
1141 "item-enable-default-fadein-fadeout": 41195,
1142 "item-explode-midi-note-rows-pitch-to-new-items": 40920,
1143 "item-explode-multichannel-audio-or-midi-to-new-one-channel-items": 40894,
1144 "item-explode-rex-item-into-beat-slices": 41513,
1145 "item-fade-items-in-to-cursor": 40509,
1146 "item-fade-items-out-from-cursor": 40510,
1147 "item-fit-items-to-time-selection-looping-if-needed": 41386,
1148 "item-fit-items-to-time-selection-padding-with-silence-if-needed": 41385,
1149 "item-force-balanced-mode-for-stretch-markers": 42338,
1150 "item-force-no-pre-echo-reduction-mode-for-stretch-markers": 42339,
1151 "item-force-tonal-optimized-mode-for-stretch-markers": 41857,
1152 "item-force-transient-optimized-mode-for-stretch-markers": 42337,
1153 "item-glue-items-auto-increase-channel-count-with-take-fx": 42434,
1154 "item-glue-items-expanding-to-time-selection-if-any": 41588,
1155 "item-glue-items-expanding-to-time-selection-if-any-auto-increase-channel-count-with-take-fx": 42009,
1156 "item-glue-items-expanding-to-time-selection-if-any-including-leading-fade-in-and-trailing-fade-out": 40606,
1157 "item-glue-items-ignoring-time-selection": 40362,
1158 "item-glue-items-ignoring-time-selection-auto-increase-channel-count-with-take-fx": 42008,
1159 "item-glue-items-ignoring-time-selection-including-leading-fade-in-and-trailing-fade-out": 40257,
1160 "item-glue-items-including-leading-fade-in-and-trailing-fade-out": 42433,
1161 "item-glue-items-within-time-selection": 42432,
1162 "item-go-to-nearest-stretch-marker": 41862,
1163 "item-go-to-next-stretch-marker": 41860,
1164 "item-go-to-previous-stretch-marker": 41861,
1165 "item-grouping-group-items": 40032,
1166 "item-grouping-remove-items-from-group": 40033,
1167 "item-grouping-select-all-items-in-groups": 40034,
1168 "item-heal-splits-in-items": 40548,
1169 "item-implode-items-across-tracks-into-items-on-one-track": 40644,
1170 "item-import-item-media-cues-as-project-markers": 40692,
1171 "item-import-media-cues-as-take-markers": 43154,
1172 "item-insert-time-on-tracks-and-paste-items": 41748,
1173 "item-invert-selection": 41115,
1174 "item-maximize-height-of-selected-items-in-free-item-positioning-mode": 42655,
1175 "item-move-active-takes-to-top": 41380,
1176 "item-move-and-stretch-items-to-fit-time-selection": 41206,
1177 "item-move-items-to-subproject-non-destructive-glue": 41996,
1178 "item-move-items-to-time-selection-trim-loop-to-fit": 41320,
1179 "item-move-stretch-and-loop-items-to-fit-time-selection": 41069,
1180 "item-move-to-media-source-preferred-position-bwf-start-offset": 40299,
1181 "item-mute-active-take-of-multitake-item-within-time-selection": 40855,
1182 "item-navigation-move-cursor-left-to-edge-of-item": 40318,
1183 "item-navigation-move-cursor-left-to-nearest-item-edge": 41167,
1184 "item-navigation-move-cursor-right-to-edge-of-item": 40319,
1185 "item-navigation-move-cursor-right-to-nearest-item-edge": 41168,
1186 "item-navigation-move-cursor-to-end-of-items": 41174,
1187 "item-navigation-move-cursor-to-nearest-transient-in-items": 40836,
1188 "item-navigation-move-cursor-to-next-transient-in-items": 40375,
1189 "item-navigation-move-cursor-to-previous-transient-in-items": 40376,
1190 "item-navigation-move-cursor-to-start-of-items": 41173,
1191 "item-navigation-select-and-move-to-item-in-next-track": 40419,
1192 "item-navigation-select-and-move-to-item-in-next-track-without-changing-track-selection": 41140,
1193 "item-navigation-select-and-move-to-item-in-previous-track": 40418,
1194 "item-navigation-select-and-move-to-item-in-previous-track-without-changing-track-selection": 41139,
1195 "item-navigation-select-and-move-to-next-item": 40417,
1196 "item-navigation-select-and-move-to-previous-item": 40416,
1197 "item-nudge-items-volume-1db": 41924,
1198 "item-nudge-items-volume-plus-1db": 41925,
1199 "item-open-associated-project-in-new-tab": 41816,
1200 "item-open-in-built-in-midi-editor-set-default-behavior-in-preferences": 40153,
1201 "item-open-item-copies-in-primary-external-editor": 40132,
1202 "item-open-item-copies-in-secondary-external-editor": 40203,
1203 "item-open-item-inline-editors": 40847,
1204 "item-open-items-in-primary-external-editor": 40109,
1205 "item-open-items-in-secondary-external-editor": 40202,
1206 "item-paste-items-tracks": 42398,
1207 "item-paste-items-tracks-at-mouse-position": 41221,
1208 "item-paste-items-tracks-creating-pooled-ghost-midi-items-and-automation-items-regardless-of-preferences-media-midi-and-preferences-media-automation-settings":
1209 41072,
1210 "item-paste-items-tracks-old-style-handling-of-hidden-tracks": 40058,
1211 "item-propagate-to-all-similarly-named-items": 41979,
1212 "item-propagate-to-similarly-named-items-on-track": 41977,
1213 "item-properties-clear-take-preserve-pitch": 40796,
1214 "item-properties-decrease-item-rate-by-0-6-percent-10-cents": 40520,
1215 "item-properties-decrease-item-rate-by-0-6-percent-10-cents-clear-preserve-pitch": 40800,
1216 "item-properties-decrease-item-rate-by-6-percent-one-semitone": 40518,
1217 "item-properties-decrease-item-rate-by-6-percent-one-semitone-clear-preserve-pitch": 40798,
1218 "item-properties-display-item-beats-ruler-constant-time-signature": 42315,
1219 "item-properties-display-item-beats-ruler-minimal-constant-time-signature": 42359,
1220 "item-properties-display-item-source-time-ruler": 42313,
1221 "item-properties-display-item-source-time-ruler-apply-media-source-bwf-start-offset": 42419,
1222 "item-properties-display-item-source-time-ruler-in-h-m-s-f-format": 42358,
1223 "item-properties-display-item-source-time-ruler-in-h-m-s-f-format-apply-media-source-bwf-start-offset": 42420,
1224 "item-properties-display-item-time-ruler": 42312,
1225 "item-properties-display-item-time-ruler-in-h-m-s-f-format": 42314,
1226 "item-properties-increase-item-rate-by-0-6-percent-10-cents": 40519,
1227 "item-properties-increase-item-rate-by-0-6-percent-10-cents-clear-preserve-pitch": 40799,
1228 "item-properties-increase-item-rate-by-6-percent-one-semitone": 40517,
1229 "item-properties-increase-item-rate-by-6-percent-one-semitone-clear-preserve-pitch": 40797,
1230 "item-properties-item-ruler-settings": 42355,
1231 "item-properties-lock": 40688,
1232 "item-properties-lock-to-active-take-mouse-click-will-not-change-active-take": 41340,
1233 "item-properties-loop-item-source": 40636,
1234 "item-properties-loop-section-of-audio-item-source": 40547,
1235 "item-properties-mute": 40719,
1236 "item-properties-normalize-items-each-item-separately-to-plus-0db-peak": 40108,
1237 "item-properties-normalize-items-peak-rms-lufs": 42460,
1238 "item-properties-normalize-items-to-loudest-item-to-plus-0db-peak": 40254,
1239 "item-properties-normalize-items-to-loudest-item-to-plus-0db-peak-reset-to-unity-if-already-normalized": 40937,
1240 "item-properties-normalize-items-to-plus-0db-peak-reset-to-unity-if-already-normalized": 40936,
1241 "item-properties-normalize-items-using-most-recent-settings": 42461,
1242 "item-properties-normalize-items-using-most-recent-settings-force-normalize-as-if-one-long-item": 42463,
1243 "item-properties-normalize-items-using-most-recent-settings-force-normalize-each-item-separately": 42464,
1244 "item-properties-normalize-items-using-most-recent-settings-force-normalize-to-loudest-item": 43344,
1245 "item-properties-normalize-items-using-most-recent-settings-reset-to-unity-if-already-normalized": 42462,
1246 "item-properties-open-media-item-take-channel-mapper-for-selected-items": 42429,
1247 "item-properties-pitch-item-down-one-cent": 40207,
1248 "item-properties-pitch-item-down-one-octave": 40516,
1249 "item-properties-pitch-item-down-one-semitone": 40205,
1250 "item-properties-pitch-item-up-one-cent": 40206,
1251 "item-properties-pitch-item-up-one-octave": 40515,
1252 "item-properties-pitch-item-up-one-semitone": 40204,
1253 "item-properties-reset-item-pitch": 40653,
1254 "item-properties-reset-item-take-gain-to-plus-0db-un-normalize": 40938,
1255 "item-properties-set-item-rate-from-user-supplied-source-media-tempo-bpm": 42374,
1256 "item-properties-set-item-rate-to-1-0": 40652,
1257 "item-properties-set-item-timebase-to-beats-auto-stretch-at-tempo-changes": 42375,
1258 "item-properties-set-item-timebase-to-beats-position-length-rate": 40484,
1259 "item-properties-set-item-timebase-to-beats-position-only": 40485,
1260 "item-properties-set-item-timebase-to-project-track-default": 40380,
1261 "item-properties-set-item-timebase-to-time": 40433,
1262 "item-properties-set-midi-items-to-follow-project-tempo-changes-set-item-timebase-to-beats-position-length-rate":
1263 43095,
1264 "item-properties-set-midi-items-to-use-current-tempo-and-ignore-project-tempo-changes-set-item-timebase-to-beats-position-only":
1265 43096,
1266 "item-properties-set-midi-items-to-use-current-tempo-and-ignore-project-tempo-changes-set-item-timebase-to-time":
1267 43094,
1268 "item-properties-set-take-channel-mode-to-mono-channel-03": 41388,
1269 "item-properties-set-take-channel-mode-to-mono-channel-04": 41389,
1270 "item-properties-set-take-channel-mode-to-mono-channel-05": 41390,
1271 "item-properties-set-take-channel-mode-to-mono-channel-06": 41391,
1272 "item-properties-set-take-channel-mode-to-mono-channel-07": 41392,
1273 "item-properties-set-take-channel-mode-to-mono-channel-08": 41393,
1274 "item-properties-set-take-channel-mode-to-mono-channel-09": 41394,
1275 "item-properties-set-take-channel-mode-to-mono-channel-10": 41395,
1276 "item-properties-set-take-channel-mode-to-mono-channel-11": 41396,
1277 "item-properties-set-take-channel-mode-to-mono-channel-12": 41397,
1278 "item-properties-set-take-channel-mode-to-mono-channel-13": 41398,
1279 "item-properties-set-take-channel-mode-to-mono-channel-14": 41399,
1280 "item-properties-set-take-channel-mode-to-mono-channel-15": 41400,
1281 "item-properties-set-take-channel-mode-to-mono-channel-16": 41401,
1282 "item-properties-set-take-channel-mode-to-mono-channel-17": 41402,
1283 "item-properties-set-take-channel-mode-to-mono-channel-18": 41403,
1284 "item-properties-set-take-channel-mode-to-mono-channel-19": 41404,
1285 "item-properties-set-take-channel-mode-to-mono-channel-20": 41405,
1286 "item-properties-set-take-channel-mode-to-mono-channel-21": 41406,
1287 "item-properties-set-take-channel-mode-to-mono-channel-22": 41407,
1288 "item-properties-set-take-channel-mode-to-mono-channel-23": 41408,
1289 "item-properties-set-take-channel-mode-to-mono-channel-24": 41409,
1290 "item-properties-set-take-channel-mode-to-mono-channel-25": 41410,
1291 "item-properties-set-take-channel-mode-to-mono-channel-26": 41411,
1292 "item-properties-set-take-channel-mode-to-mono-channel-27": 41412,
1293 "item-properties-set-take-channel-mode-to-mono-channel-28": 41413,
1294 "item-properties-set-take-channel-mode-to-mono-channel-29": 41414,
1295 "item-properties-set-take-channel-mode-to-mono-channel-30": 41415,
1296 "item-properties-set-take-channel-mode-to-mono-channel-31": 41416,
1297 "item-properties-set-take-channel-mode-to-mono-channel-32": 41417,
1298 "item-properties-set-take-channel-mode-to-mono-channel-33": 41418,
1299 "item-properties-set-take-channel-mode-to-mono-channel-34": 41419,
1300 "item-properties-set-take-channel-mode-to-mono-channel-35": 41420,
1301 "item-properties-set-take-channel-mode-to-mono-channel-36": 41421,
1302 "item-properties-set-take-channel-mode-to-mono-channel-37": 41422,
1303 "item-properties-set-take-channel-mode-to-mono-channel-38": 41423,
1304 "item-properties-set-take-channel-mode-to-mono-channel-39": 41424,
1305 "item-properties-set-take-channel-mode-to-mono-channel-40": 41425,
1306 "item-properties-set-take-channel-mode-to-mono-channel-41": 41426,
1307 "item-properties-set-take-channel-mode-to-mono-channel-42": 41427,
1308 "item-properties-set-take-channel-mode-to-mono-channel-43": 41428,
1309 "item-properties-set-take-channel-mode-to-mono-channel-44": 41429,
1310 "item-properties-set-take-channel-mode-to-mono-channel-45": 41430,
1311 "item-properties-set-take-channel-mode-to-mono-channel-46": 41431,
1312 "item-properties-set-take-channel-mode-to-mono-channel-47": 41432,
1313 "item-properties-set-take-channel-mode-to-mono-channel-48": 41433,
1314 "item-properties-set-take-channel-mode-to-mono-channel-49": 41434,
1315 "item-properties-set-take-channel-mode-to-mono-channel-50": 41435,
1316 "item-properties-set-take-channel-mode-to-mono-channel-51": 41436,
1317 "item-properties-set-take-channel-mode-to-mono-channel-52": 41437,
1318 "item-properties-set-take-channel-mode-to-mono-channel-53": 41438,
1319 "item-properties-set-take-channel-mode-to-mono-channel-54": 41439,
1320 "item-properties-set-take-channel-mode-to-mono-channel-55": 41440,
1321 "item-properties-set-take-channel-mode-to-mono-channel-56": 41441,
1322 "item-properties-set-take-channel-mode-to-mono-channel-57": 41442,
1323 "item-properties-set-take-channel-mode-to-mono-channel-58": 41443,
1324 "item-properties-set-take-channel-mode-to-mono-channel-59": 41444,
1325 "item-properties-set-take-channel-mode-to-mono-channel-60": 41445,
1326 "item-properties-set-take-channel-mode-to-mono-channel-61": 41446,
1327 "item-properties-set-take-channel-mode-to-mono-channel-62": 41447,
1328 "item-properties-set-take-channel-mode-to-mono-channel-63": 41448,
1329 "item-properties-set-take-channel-mode-to-mono-channel-64": 41449,
1330 "item-properties-set-take-channel-mode-to-mono-downmix": 40178,
1331 "item-properties-set-take-channel-mode-to-mono-left": 40179,
1332 "item-properties-set-take-channel-mode-to-mono-right": 40180,
1333 "item-properties-set-take-channel-mode-to-normal": 40176,
1334 "item-properties-set-take-channel-mode-to-reverse-stereo": 40177,
1335 "item-properties-set-take-channel-mode-to-stereo-channels-01-02": 41450,
1336 "item-properties-set-take-channel-mode-to-stereo-channels-02-03": 41451,
1337 "item-properties-set-take-channel-mode-to-stereo-channels-03-04": 41452,
1338 "item-properties-set-take-channel-mode-to-stereo-channels-04-05": 41453,
1339 "item-properties-set-take-channel-mode-to-stereo-channels-05-06": 41454,
1340 "item-properties-set-take-channel-mode-to-stereo-channels-06-07": 41455,
1341 "item-properties-set-take-channel-mode-to-stereo-channels-07-08": 41456,
1342 "item-properties-set-take-channel-mode-to-stereo-channels-08-09": 41457,
1343 "item-properties-set-take-channel-mode-to-stereo-channels-09-10": 41458,
1344 "item-properties-set-take-channel-mode-to-stereo-channels-10-11": 41459,
1345 "item-properties-set-take-channel-mode-to-stereo-channels-11-12": 41460,
1346 "item-properties-set-take-channel-mode-to-stereo-channels-12-13": 41461,
1347 "item-properties-set-take-channel-mode-to-stereo-channels-13-14": 41462,
1348 "item-properties-set-take-channel-mode-to-stereo-channels-14-15": 41463,
1349 "item-properties-set-take-channel-mode-to-stereo-channels-15-16": 41464,
1350 "item-properties-set-take-channel-mode-to-stereo-channels-16-17": 41465,
1351 "item-properties-set-take-channel-mode-to-stereo-channels-17-18": 41466,
1352 "item-properties-set-take-channel-mode-to-stereo-channels-18-19": 41467,
1353 "item-properties-set-take-channel-mode-to-stereo-channels-19-20": 41468,
1354 "item-properties-set-take-channel-mode-to-stereo-channels-20-21": 41469,
1355 "item-properties-set-take-channel-mode-to-stereo-channels-21-22": 41470,
1356 "item-properties-set-take-channel-mode-to-stereo-channels-22-23": 41471,
1357 "item-properties-set-take-channel-mode-to-stereo-channels-23-24": 41472,
1358 "item-properties-set-take-channel-mode-to-stereo-channels-24-25": 41473,
1359 "item-properties-set-take-channel-mode-to-stereo-channels-25-26": 41474,
1360 "item-properties-set-take-channel-mode-to-stereo-channels-26-27": 41475,
1361 "item-properties-set-take-channel-mode-to-stereo-channels-27-28": 41476,
1362 "item-properties-set-take-channel-mode-to-stereo-channels-28-29": 41477,
1363 "item-properties-set-take-channel-mode-to-stereo-channels-29-30": 41478,
1364 "item-properties-set-take-channel-mode-to-stereo-channels-30-31": 41479,
1365 "item-properties-set-take-channel-mode-to-stereo-channels-31-32": 41480,
1366 "item-properties-set-take-channel-mode-to-stereo-channels-32-33": 41481,
1367 "item-properties-set-take-channel-mode-to-stereo-channels-33-34": 41482,
1368 "item-properties-set-take-channel-mode-to-stereo-channels-34-35": 41483,
1369 "item-properties-set-take-channel-mode-to-stereo-channels-35-36": 41484,
1370 "item-properties-set-take-channel-mode-to-stereo-channels-36-37": 41485,
1371 "item-properties-set-take-channel-mode-to-stereo-channels-37-38": 41486,
1372 "item-properties-set-take-channel-mode-to-stereo-channels-38-39": 41487,
1373 "item-properties-set-take-channel-mode-to-stereo-channels-39-40": 41488,
1374 "item-properties-set-take-channel-mode-to-stereo-channels-40-41": 41489,
1375 "item-properties-set-take-channel-mode-to-stereo-channels-41-42": 41490,
1376 "item-properties-set-take-channel-mode-to-stereo-channels-42-43": 41491,
1377 "item-properties-set-take-channel-mode-to-stereo-channels-43-44": 41492,
1378 "item-properties-set-take-channel-mode-to-stereo-channels-44-45": 41493,
1379 "item-properties-set-take-channel-mode-to-stereo-channels-45-46": 41494,
1380 "item-properties-set-take-channel-mode-to-stereo-channels-46-47": 41495,
1381 "item-properties-set-take-channel-mode-to-stereo-channels-47-48": 41496,
1382 "item-properties-set-take-channel-mode-to-stereo-channels-48-49": 41497,
1383 "item-properties-set-take-channel-mode-to-stereo-channels-49-50": 41498,
1384 "item-properties-set-take-channel-mode-to-stereo-channels-50-51": 41499,
1385 "item-properties-set-take-channel-mode-to-stereo-channels-51-52": 41500,
1386 "item-properties-set-take-channel-mode-to-stereo-channels-52-53": 41501,
1387 "item-properties-set-take-channel-mode-to-stereo-channels-53-54": 41502,
1388 "item-properties-set-take-channel-mode-to-stereo-channels-54-55": 41503,
1389 "item-properties-set-take-channel-mode-to-stereo-channels-55-56": 41504,
1390 "item-properties-set-take-channel-mode-to-stereo-channels-56-57": 41505,
1391 "item-properties-set-take-channel-mode-to-stereo-channels-57-58": 41506,
1392 "item-properties-set-take-channel-mode-to-stereo-channels-58-59": 41507,
1393 "item-properties-set-take-channel-mode-to-stereo-channels-59-60": 41508,
1394 "item-properties-set-take-channel-mode-to-stereo-channels-60-61": 41509,
1395 "item-properties-set-take-channel-mode-to-stereo-channels-61-62": 41510,
1396 "item-properties-set-take-channel-mode-to-stereo-channels-62-63": 41511,
1397 "item-properties-set-take-channel-mode-to-stereo-channels-63-64": 41512,
1398 "item-properties-set-take-preserve-pitch": 40795,
1399 "item-properties-show-media-item-source-properties": 40011,
1400 "item-properties-show-media-item-take-properties": 40009,
1401 "item-properties-solo": 41559,
1402 "item-properties-solo-exclusive": 41558,
1403 "item-properties-toggle-item-play-all-takes": 40437,
1404 "item-properties-toggle-items-tracks-mute-depending-on-focus": 40183,
1405 "item-properties-toggle-lock": 40687,
1406 "item-properties-toggle-lock-to-active-take-toggle-mouse-click-changes-active-take": 41339,
1407 "item-properties-toggle-mute": 40175,
1408 "item-properties-toggle-polarity-phase-for-active-take": 40181,
1409 "item-properties-toggle-show-media-item-take-properties": 41589,
1410 "item-properties-toggle-solo": 41557,
1411 "item-properties-toggle-solo-exclusive": 41561,
1412 "item-properties-toggle-take-preserve-pitch": 40566,
1413 "item-properties-toggle-take-reverse": 41051,
1414 "item-properties-unlock": 40689,
1415 "item-properties-unlock-takes-mouse-click-will-change-active-take": 41341,
1416 "item-properties-unmute": 40720,
1417 "item-properties-unmute-all-items": 40870,
1418 "item-properties-unsolo": 41560,
1419 "item-properties-unsolo-all": 41185,
1420 "item-quantize-item-positions-to-grid": 40316,
1421 "item-quick-add-take-marker-at-mouse-position": 42391,
1422 "item-quick-add-take-marker-at-play-position-or-edit-cursor": 42390,
1423 "item-remove-active-take-from-midi-source-data-pool-unpool": 41613,
1424 "item-remove-active-takes-from-ara-edit-pool-unpool": 42606,
1425 "item-remove-all-empty-takes": 41348,
1426 "item-remove-all-stretch-markers": 41844,
1427 "item-remove-all-stretch-markers-in-time-selection": 41845,
1428 "item-remove-content-trim-behind-items": 40930,
1429 "item-remove-fade-in": 41191,
1430 "item-remove-fade-in-and-fade-out": 41193,
1431 "item-remove-fade-out": 41192,
1432 "item-remove-fx-for-item-take": 40640,
1433 "item-remove-items": 40006,
1434 "item-remove-selected-area-of-items": 40312,
1435 "item-remove-stretch-marker-at-current-position": 41859,
1436 "item-remove-the-empty-take-after-the-active-take": 41350,
1437 "item-remove-the-empty-take-before-the-active-take": 41349,
1438 "item-render-items-to-new-take": 41999,
1439 "item-render-items-to-new-take-preserve-source-type": 40601,
1440 "item-reorder-adjacent-items-randomly": 41638,
1441 "item-reset-items-volume-to-plus-0db": 41923,
1442 "item-reset-stretch-marker-at-current-position": 41989,
1443 "item-return-active-takes-to-ara-edit-pool-re-pool": 42609,
1444 "item-reverse-items-to-new-take": 40270,
1445 "item-rotate-takes-backward": 41354,
1446 "item-rotate-takes-forward": 41353,
1447 "item-select-all-items": 40182,
1448 "item-select-all-items-in-current-time-selection": 40717,
1449 "item-select-all-items-in-track": 40421,
1450 "item-select-all-items-on-selected-tracks-in-current-time-selection": 40718,
1451 "item-select-all-other-media-items-that-share-pooled-ara-edits-with-selected-items": 42607,
1452 "item-select-all-other-media-items-that-share-pooled-midi-source-data-with-selected-items": 41611,
1453 "item-select-all-other-media-items-that-share-the-same-source-media-as-selected-items": 42608,
1454 "item-select-item-under-mouse-cursor": 40528,
1455 "item-select-item-under-mouse-cursor-leaving-other-items-selected": 40529,
1456 "item-select-next-adjacent-non-overlapping-item": 41127,
1457 "item-select-previous-adjacent-non-overlapping-item": 41128,
1458 "item-set-all-media-item-takes-that-share-the-same-source-media-to-the-same-random-color": 42693,
1459 "item-set-all-media-offline": 40100,
1460 "item-set-all-media-online": 40101,
1461 "item-set-crossfade-shape-to-type-1-linear-equal-gain": 41528,
1462 "item-set-crossfade-shape-to-type-2-equal-power": 41529,
1463 "item-set-crossfade-shape-to-type-3": 41530,
1464 "item-set-crossfade-shape-to-type-4": 41531,
1465 "item-set-crossfade-shape-to-type-5": 41532,
1466 "item-set-crossfade-shape-to-type-6": 41533,
1467 "item-set-crossfade-shape-to-type-7": 41838,
1468 "item-set-cursor-to-next-take-marker-in-selected-items": 42394,
1469 "item-set-cursor-to-previous-take-marker-in-selected-items": 42393,
1470 "item-set-fade-in-shape-to-type-1-linear": 41514,
1471 "item-set-fade-in-shape-to-type-2": 41515,
1472 "item-set-fade-in-shape-to-type-3": 41516,
1473 "item-set-fade-in-shape-to-type-4": 41517,
1474 "item-set-fade-in-shape-to-type-5": 41518,
1475 "item-set-fade-in-shape-to-type-6": 41519,
1476 "item-set-fade-in-shape-to-type-7": 41836,
1477 "item-set-fade-out-shape-to-type-1-linear": 41521,
1478 "item-set-fade-out-shape-to-type-2": 41522,
1479 "item-set-fade-out-shape-to-type-3": 41523,
1480 "item-set-fade-out-shape-to-type-4": 41524,
1481 "item-set-fade-out-shape-to-type-5": 41525,
1482 "item-set-fade-out-shape-to-type-6": 41526,
1483 "item-set-fade-out-shape-to-type-7": 41837,
1484 "item-set-focus-to-item-under-mouse-cursor": 40911,
1485 "item-set-item-end-to-cursor": 40611,
1486 "item-set-item-end-to-source-media-end": 40612,
1487 "item-set-item-ends-to-start-of-next-item": 41639,
1488 "item-set-item-mix-behavior-to-always-mix": 40919,
1489 "item-set-item-mix-behavior-to-always-replace": 40921,
1490 "item-set-item-mix-behavior-to-enclosed-items-replace-enclosing-items": 40918,
1491 "item-set-item-mix-behavior-to-project-default": 40922,
1492 "item-set-item-name-from-active-take-filename": 41858,
1493 "item-set-item-start-end-to-source-media-start-end": 42228,
1494 "item-set-item-start-to-source-media-start": 42229,
1495 "item-set-selected-media-online": 40439,
1496 "item-set-selected-media-temporarily-offline": 40440,
1497 "item-set-snap-offset-for-item-under-mouse-to-mouse-position": 42476,
1498 "item-set-snap-offset-to-cursor": 40541,
1499 "item-set-snap-offset-to-nearest-grid-line": 40542,
1500 "item-set-to-custom-color": 40704,
1501 "item-set-to-default-color": 40707,
1502 "item-set-to-one-random-color": 40706,
1503 "item-set-to-random-colors": 40705,
1504 "item-show-fx-chain-for-item-take": 40638,
1505 "item-show-notes-for-items": 40850,
1506 "item-shrink-to-first-and-last-media-cues": 40735,
1507 "item-shrink-to-first-media-cue": 40733,
1508 "item-shrink-to-last-media-cue": 40734,
1509 "item-snap-items-left": 41182,
1510 "item-snap-items-right": 41183,
1511 "item-snap-items-to-nearest-snap-point": 41184,
1512 "item-snap-stretch-markers-in-time-selection-to-grid": 41847,
1513 "item-snap-stretch-markers-to-grid": 41846,
1514 "item-solo-active-take-of-multitake-item-within-time-selection": 40856,
1515 "item-split-at-media-cues": 40732,
1516 "item-split-at-previous-zero-crossing": 40792,
1517 "item-split-at-take-markers-active-take-only": 43168,
1518 "item-split-at-take-markers-all-takes": 43171,
1519 "item-split-at-take-markers-for-all-items-on-track-active-takes-only": 43170,
1520 "item-split-at-take-markers-for-all-items-on-track-all-takes": 43173,
1521 "item-split-at-take-markers-for-item-under-mouse-active-take-only": 43169,
1522 "item-split-at-take-markers-for-item-under-mouse-all-takes": 43172,
1523 "item-split-item-under-mouse-cursor-ignore-grouping-no-change-selection": 40746,
1524 "item-split-item-under-mouse-cursor-ignore-grouping-select-left": 43180,
1525 "item-split-item-under-mouse-cursor-ignore-grouping-select-right": 40748,
1526 "item-split-item-under-mouse-cursor-no-change-selection": 42575,
1527 "item-split-item-under-mouse-cursor-select-left": 43179,
1528 "item-split-item-under-mouse-cursor-select-right": 42577,
1529 "item-split-items-at-edit-cursor-no-change-selection": 40757,
1530 "item-split-items-at-edit-cursor-select-left": 43178,
1531 "item-split-items-at-edit-cursor-select-right": 40759,
1532 "item-split-items-at-edit-or-play-cursor-ignore-grouping-select-right": 40186,
1533 "item-split-items-at-edit-or-play-cursor-select-right": 40012,
1534 "item-split-items-at-end-of-fade-in-unless-crossfaded": 41839,
1535 "item-split-items-at-play-cursor-select-right": 40196,
1536 "item-split-items-at-project-markers": 40931,
1537 "item-split-items-at-start-of-fade-out-unless-crossfaded": 41840,
1538 "item-split-items-at-time-selection-or-razor-edit": 40061,
1539 "item-split-items-at-timeline-grid": 40932,
1540 "item-toggle-enable-disable-default-fadein-fadeout": 41194,
1541 "item-toggle-force-inactive-take-media-offline": 42357,
1542 "item-toggle-force-media-offline": 42356,
1543 "item-toggle-selection-of-item-under-mouse-cursor": 40530,
1544 "item-trim-items-left-of-cursor": 40511,
1545 "item-trim-items-right-of-cursor": 40512,
1546 "item-trim-items-to-selected-area": 40508,
1547 "item-unselect-clear-selection-of-all-items": 40289,
1548 "item-up-rank-active-take-or-last-recording-pass": 42680,
1549 "item-up-rank-take-marker-at-1-second-before-play-position-or-at-edit-cursor-if-not-playing-back": 43174,
1550 "item-up-rank-take-marker-at-2-seconds-before-play-position-or-at-edit-cursor-if-not-playing-back": 43176,
1551 "item-up-rank-take-marker-at-mouse-position": 43159,
1552 "item-up-rank-take-marker-at-play-position-or-edit-cursor": 43157,
1553 "item-up-rank-take-marker-at-time-selection": 43183,
1554 "item-up-rank-take-or-comp-area-under-mouse": 43155,
1555 "items-set-all-take-fx-offline-for-selected-media-items": 42353,
1556 "items-set-all-take-fx-online-for-selected-media-items": 42354,
1557 "items-set-group-color-for-selected-items": 43654,
1558 "items-set-group-name-for-selected-items": 43656,
1559 "items-set-group-to-random-color-for-selected-items": 43655,
1560 "items-set-take-fx-offline-for-all-inactive-takes-in-the-project": 43691,
1561 "items-set-take-fx-online-for-all-active-takes-in-the-project": 43692,
1562 "items-set-take-fx-online-if-active-offline-if-inactive-for-all-takes-in-the-project": 43693,
1563 "layout-apply-custom-layout-number-01": 41696,
1564 "layout-apply-custom-layout-number-02": 41697,
1565 "layout-apply-custom-layout-number-03": 41698,
1566 "layout-apply-custom-layout-number-04": 41699,
1567 "layout-apply-custom-layout-number-05": 41700,
1568 "layout-apply-custom-layout-number-06": 41701,
1569 "layout-apply-custom-layout-number-07": 41702,
1570 "layout-apply-custom-layout-number-08": 41703,
1571 "layout-apply-custom-layout-number-09": 41704,
1572 "layout-apply-custom-layout-number-10": 41705,
1573 "layout-apply-custom-layout-number-11": 41706,
1574 "layout-apply-custom-layout-number-12": 41707,
1575 "layout-apply-custom-layout-number-13": 41708,
1576 "layout-apply-custom-layout-number-14": 41709,
1577 "layout-apply-custom-layout-number-15": 41710,
1578 "layout-apply-custom-layout-number-16": 41711,
1579 "layout-apply-custom-layout-number-17": 41712,
1580 "layout-apply-custom-layout-number-18": 41713,
1581 "layout-apply-custom-layout-number-19": 41714,
1582 "layout-apply-custom-layout-number-20": 41715,
1583 "layout-default-layout": 48500,
1584 "locking-clear-all-lock-modes": 40567,
1585 "locking-clear-full-item-locking-mode": 40575,
1586 "locking-clear-item-edges-locking-mode": 40596,
1587 "locking-clear-item-fade-volume-handles-locking-mode": 40599,
1588 "locking-clear-item-stretch-marker-locking-mode": 41853,
1589 "locking-clear-left-right-item-locking-mode": 40578,
1590 "locking-clear-loop-points-locking-mode": 40628,
1591 "locking-clear-marker-locking-mode": 40590,
1592 "locking-clear-region-locking-mode": 40587,
1593 "locking-clear-take-envelope-locking-mode": 41850,
1594 "locking-clear-time-selection-locking-mode": 40572,
1595 "locking-clear-time-signature-marker-locking-mode": 40593,
1596 "locking-clear-track-envelope-locking-mode": 40584,
1597 "locking-clear-up-down-item-locking-mode": 40581,
1598 "locking-disable-locking": 40570,
1599 "locking-enable-locking": 40569,
1600 "locking-set-all-lock-modes": 40568,
1601 "locking-set-full-item-locking-mode": 40574,
1602 "locking-set-item-edges-locking-mode": 40595,
1603 "locking-set-item-fade-volume-handles-locking-mode": 40598,
1604 "locking-set-item-stretch-marker-locking-mode": 41852,
1605 "locking-set-left-right-item-locking-mode": 40577,
1606 "locking-set-loop-points-locking-mode": 40627,
1607 "locking-set-marker-locking-mode": 40589,
1608 "locking-set-region-locking-mode": 40586,
1609 "locking-set-take-envelope-locking-mode": 41849,
1610 "locking-set-time-selection-locking-mode": 40571,
1611 "locking-set-time-signature-marker-locking-mode": 40592,
1612 "locking-set-track-envelope-locking-mode": 40583,
1613 "locking-set-up-down-item-locking-mode": 40580,
1614 "locking-toggle-full-item-locking-mode": 40576,
1615 "locking-toggle-item-edges-locking-mode": 40597,
1616 "locking-toggle-item-fade-volume-handles-locking-mode": 40600,
1617 "locking-toggle-item-stretch-marker-locking-mode": 41854,
1618 "locking-toggle-left-right-item-locking-mode": 40579,
1619 "locking-toggle-loop-points-locking-mode": 40629,
1620 "locking-toggle-marker-locking-mode": 40591,
1621 "locking-toggle-region-locking-mode": 40588,
1622 "locking-toggle-take-envelope-locking-mode": 41851,
1623 "locking-toggle-time-selection-locking-mode": 40573,
1624 "locking-toggle-time-signature-marker-locking-mode": 40594,
1625 "locking-toggle-track-envelope-locking-mode": 40585,
1626 "locking-toggle-up-down-item-locking-mode": 40582,
1627 "loop-points-double-loop-length": 40722,
1628 "loop-points-halve-loop-length": 40721,
1629 "loop-points-move-end-point-to-cursor-preserve-length": 43211,
1630 "loop-points-move-start-point-to-cursor-preserve-length": 43210,
1631 "loop-points-remove-unselect-loop-point-selection": 40634,
1632 "loop-points-remove-unselect-loop-points-if-not-linked-to-time-selection-ignoring-lock-state": 40624,
1633 "loop-points-set-end-point": 40223,
1634 "loop-points-set-loop-points-to-items": 41039,
1635 "loop-points-set-start-point": 40222,
1636 "main-action-section-clear-any-override": 24800,
1637 "main-action-section-momentarily-set-override-to-alt-1": 24853,
1638 "main-action-section-momentarily-set-override-to-alt-10": 24862,
1639 "main-action-section-momentarily-set-override-to-alt-11": 24863,
1640 "main-action-section-momentarily-set-override-to-alt-12": 24864,
1641 "main-action-section-momentarily-set-override-to-alt-13": 24865,
1642 "main-action-section-momentarily-set-override-to-alt-14": 24866,
1643 "main-action-section-momentarily-set-override-to-alt-15": 24867,
1644 "main-action-section-momentarily-set-override-to-alt-16": 24868,
1645 "main-action-section-momentarily-set-override-to-alt-2": 24854,
1646 "main-action-section-momentarily-set-override-to-alt-3": 24855,
1647 "main-action-section-momentarily-set-override-to-alt-4": 24856,
1648 "main-action-section-momentarily-set-override-to-alt-5": 24857,
1649 "main-action-section-momentarily-set-override-to-alt-6": 24858,
1650 "main-action-section-momentarily-set-override-to-alt-7": 24859,
1651 "main-action-section-momentarily-set-override-to-alt-8": 24860,
1652 "main-action-section-momentarily-set-override-to-alt-9": 24861,
1653 "main-action-section-momentarily-set-override-to-default": 24851,
1654 "main-action-section-momentarily-set-override-to-recording": 24852,
1655 "main-action-section-set-override-to-default": 24801,
1656 "main-action-section-toggle-override-to-alt-1": 24803,
1657 "main-action-section-toggle-override-to-alt-10": 24812,
1658 "main-action-section-toggle-override-to-alt-11": 24813,
1659 "main-action-section-toggle-override-to-alt-12": 24814,
1660 "main-action-section-toggle-override-to-alt-13": 24815,
1661 "main-action-section-toggle-override-to-alt-14": 24816,
1662 "main-action-section-toggle-override-to-alt-15": 24817,
1663 "main-action-section-toggle-override-to-alt-16": 24818,
1664 "main-action-section-toggle-override-to-alt-2": 24804,
1665 "main-action-section-toggle-override-to-alt-3": 24805,
1666 "main-action-section-toggle-override-to-alt-4": 24806,
1667 "main-action-section-toggle-override-to-alt-5": 24807,
1668 "main-action-section-toggle-override-to-alt-6": 24808,
1669 "main-action-section-toggle-override-to-alt-7": 24809,
1670 "main-action-section-toggle-override-to-alt-8": 24810,
1671 "main-action-section-toggle-override-to-alt-9": 24811,
1672 "main-action-section-toggle-override-to-recording": 24802,
1673 "markers-add-move-marker-1-to-play-edit-cursor": 40657,
1674 "markers-add-move-marker-10-to-play-edit-cursor": 40656,
1675 "markers-add-move-marker-2-to-play-edit-cursor": 40658,
1676 "markers-add-move-marker-3-to-play-edit-cursor": 40659,
1677 "markers-add-move-marker-4-to-play-edit-cursor": 40660,
1678 "markers-add-move-marker-5-to-play-edit-cursor": 40661,
1679 "markers-add-move-marker-6-to-play-edit-cursor": 40662,
1680 "markers-add-move-marker-7-to-play-edit-cursor": 40663,
1681 "markers-add-move-marker-8-to-play-edit-cursor": 40664,
1682 "markers-add-move-marker-9-to-play-edit-cursor": 40665,
1683 "markers-change-color-for-marker-near-cursor": 40304,
1684 "markers-change-color-for-region-near-cursor": 40305,
1685 "markers-delete-marker-near-cursor": 40613,
1686 "markers-delete-region-near-cursor": 40615,
1687 "markers-delete-time-signature-marker-near-cursor": 40617,
1688 "markers-edit-marker-near-cursor": 40614,
1689 "markers-edit-region-near-cursor": 40616,
1690 "markers-edit-time-signature-marker-near-cursor": 40618,
1691 "markers-go-to-marker-01": 40161,
1692 "markers-go-to-marker-02": 40162,
1693 "markers-go-to-marker-03": 40163,
1694 "markers-go-to-marker-04": 40164,
1695 "markers-go-to-marker-05": 40165,
1696 "markers-go-to-marker-06": 40166,
1697 "markers-go-to-marker-07": 40167,
1698 "markers-go-to-marker-08": 40168,
1699 "markers-go-to-marker-09": 40169,
1700 "markers-go-to-marker-10": 40160,
1701 "markers-go-to-marker-11": 41251,
1702 "markers-go-to-marker-12": 41252,
1703 "markers-go-to-marker-13": 41253,
1704 "markers-go-to-marker-14": 41254,
1705 "markers-go-to-marker-15": 41255,
1706 "markers-go-to-marker-16": 41256,
1707 "markers-go-to-marker-17": 41257,
1708 "markers-go-to-marker-18": 41258,
1709 "markers-go-to-marker-19": 41259,
1710 "markers-go-to-marker-20": 41260,
1711 "markers-go-to-marker-21": 41261,
1712 "markers-go-to-marker-22": 41262,
1713 "markers-go-to-marker-23": 41263,
1714 "markers-go-to-marker-24": 41264,
1715 "markers-go-to-marker-25": 41265,
1716 "markers-go-to-marker-26": 41266,
1717 "markers-go-to-marker-27": 41267,
1718 "markers-go-to-marker-28": 41268,
1719 "markers-go-to-marker-29": 41269,
1720 "markers-go-to-marker-30": 41270,
1721 "markers-go-to-next-marker-project-end": 40173,
1722 "markers-go-to-previous-marker-project-start": 40172,
1723 "markers-insert-and-or-edit-marker-at-current-position": 40171,
1724 "markers-insert-marker-at-current-position": 40157,
1725 "markers-insert-region-from-selected-items": 40348,
1726 "markers-insert-region-from-selected-items-and-edit": 40393,
1727 "markers-insert-region-from-time-selection": 40174,
1728 "markers-insert-region-from-time-selection-and-edit": 40306,
1729 "markers-insert-separate-regions-for-each-selected-item": 41664,
1730 "markers-quantize-tempo-markers-to-midi-resolution": 40925,
1731 "markers-regions-export-markers-regions-to-file": 41758,
1732 "markers-regions-import-markers-regions-from-file-merge-with-existing": 41760,
1733 "markers-regions-import-markers-regions-from-file-replace-existing": 41759,
1734 "markers-remove-all-markers-from-time-selection": 40420,
1735 "markers-renumber-all-markers-and-regions-in-timeline-order": 40898,
1736 "markers-set-marker-near-cursor-to-default-color": 41897,
1737 "markers-set-region-near-cursor-to-default-color": 41896,
1738 "master-track-toggle-stereo-mono-l-plus-r": 40917,
1739 "media-explorer-show-hide-media-explorer": 50124,
1740 "media-item-add-stretch-markers-at-project-tempo-changes": 42377,
1741 "media-item-clear-and-recalculate-auto-stretch-at-project-tempo-changes": 42376,
1742 "menu-customize": 1528,
1743 "midi-clear-retroactive-midi-history": 42378,
1744 "midi-insert-all-available-retroactively-recorded-midi-for-armed-and-selected-tracks": 40212,
1745 "midi-insert-all-available-retroactively-recorded-midi-for-armed-tracks": 40213,
1746 "midi-insert-recent-retroactively-recorded-midi-for-armed-and-selected-tracks": 40211,
1747 "midi-insert-recent-retroactively-recorded-midi-for-armed-tracks": 40686,
1748 "midi-reload-track-support-data-bank-program-files-notation-etc-for-all-midi-items-on-selected-tracks": 42465,
1749 "minimize-reaper": 41171,
1750 "mixer-clickable-icon-for-folder-tracks-to-show-hide-children": 41154,
1751 "mixer-group-fx-parameters-with-their-inserts": 41829,
1752 "mixer-group-sends-with-before-after-fx-inserts": 40267,
1753 "mixer-master-track-visible": 41209,
1754 "mixer-toggle-autoarrange": 41146,
1755 "mixer-toggle-docking-in-docker": 40083,
1756 "mixer-toggle-folder-tracks-grouping-to-left": 40081,
1757 "mixer-toggle-master-track-in-docked-window": 41610,
1758 "mixer-toggle-master-track-in-separate-window": 41636,
1759 "mixer-toggle-scroll-view-when-tracks-activated": 40221,
1760 "mixer-toggle-show-folder-tracks-in-mixer": 40080,
1761 "mixer-toggle-show-fx-inserts-when-size-permits": 40549,
1762 "mixer-toggle-show-fx-parameters-when-size-permits": 40910,
1763 "mixer-toggle-show-icons-for-the-last-track-in-a-folder": 41153,
1764 "mixer-toggle-show-master-track-on-right-side": 40389,
1765 "mixer-toggle-show-multiple-rows-even-when-space-to-fit-tracks-in-less-rows": 40372,
1766 "mixer-toggle-show-multiple-rows-when-space": 40371,
1767 "mixer-toggle-show-normal-top-level-tracks-in-mixer": 40082,
1768 "mixer-toggle-show-sends-when-size-permits": 40557,
1769 "mixer-toggle-show-track-icons-in-mixer": 40903,
1770 "mixer-toggle-show-tracks-in-folders-in-mixer": 40199,
1771 "mixer-toggle-tracks-with-receives-grouping-to-left": 40198,
1772 "mixer-toggle-tracks-with-receives-in-mixer": 40197,
1773 "monitoring-fx-toggle-bypass": 41884,
1774 "mouse-modifiers-clear-arrange-view-override-mouse-modifiers": 42621,
1775 "mouse-modifiers-set-arrange-view-override-mouse-modifiers-a": 42615,
1776 "mouse-modifiers-set-arrange-view-override-mouse-modifiers-a-until-next-mouseup": 42622,
1777 "mouse-modifiers-set-arrange-view-override-mouse-modifiers-b": 42617,
1778 "mouse-modifiers-set-arrange-view-override-mouse-modifiers-b-until-next-mouseup": 42623,
1779 "mouse-modifiers-set-arrange-view-override-mouse-modifiers-c": 42619,
1780 "mouse-modifiers-set-arrange-view-override-mouse-modifiers-c-until-next-mouseup": 42624,
1781 "mouse-modifiers-set-arrange-view-override-mouse-modifiers-d": 42632,
1782 "mouse-modifiers-set-arrange-view-override-mouse-modifiers-d-until-next-mouseup": 42634,
1783 "mouse-modifiers-swap-arrange-view-right-drag-modifiers-for-marquee-item-selection-and-select-razor-edit-area": 42401,
1784 "mouse-modifiers-toggle-arrange-view-override-mouse-modifiers-a": 42616,
1785 "mouse-modifiers-toggle-arrange-view-override-mouse-modifiers-b": 42618,
1786 "mouse-modifiers-toggle-arrange-view-override-mouse-modifiers-c": 42620,
1787 "mouse-modifiers-toggle-arrange-view-override-mouse-modifiers-d": 42633,
1788 "move-comp-area-at-mouse-down-or-switch-items-to-next-take": 42611,
1789 "move-comp-area-at-mouse-up-or-switch-items-to-previous-take": 42612,
1790 "move-edit-cursor-back-one-beat": 41045,
1791 "move-edit-cursor-back-one-beat-no-seek": 40842,
1792 "move-edit-cursor-back-one-measure": 41043,
1793 "move-edit-cursor-back-one-measure-no-seek": 40840,
1794 "move-edit-cursor-forward-one-beat": 41044,
1795 "move-edit-cursor-forward-one-beat-no-seek": 40841,
1796 "move-edit-cursor-forward-one-measure": 41042,
1797 "move-edit-cursor-forward-one-measure-no-seek": 40839,
1798 "move-edit-cursor-to-left-edge-of-visible-arrange-view": 42964,
1799 "move-edit-cursor-to-nearest-zero-crossing-in-items": 41995,
1800 "move-edit-cursor-to-next-cue-in-items": 40741,
1801 "move-edit-cursor-to-next-tempo-or-time-signature-change": 41821,
1802 "move-edit-cursor-to-next-zero-crossing-in-items": 40791,
1803 "move-edit-cursor-to-previous-cue-in-items": 40742,
1804 "move-edit-cursor-to-previous-tempo-or-time-signature-change": 41820,
1805 "move-edit-cursor-to-previous-zero-crossing-in-items": 40790,
1806 "move-edit-cursor-to-start-of-current-previous-beat": 40230,
1807 "move-edit-cursor-to-start-of-current-previous-measure": 41041,
1808 "move-edit-cursor-to-start-of-current-previous-measure-no-seek": 40838,
1809 "move-edit-cursor-to-start-of-next-beat": 40231,
1810 "move-edit-cursor-to-start-of-next-measure": 41040,
1811 "move-edit-cursor-to-start-of-next-measure-no-seek": 40837,
1812 "new-project-tab": 40859,
1813 "new-project-tab-ignore-default-template": 41929,
1814 "no-op-no-action": 65535,
1815 "offset-track-template-items-by-edit-cursor": 41722,
1816 "open-selected-item-source-media-in-explorer-finder": 42411,
1817 "open-selected-item-source-media-in-media-explorer": 41623,
1818 "options-add-edge-points-when-moving-envelope-points-with-items": 40648,
1819 "options-add-edge-points-when-moving-multiple-envelope-points": 42030,
1820 "options-add-envelope-edge-points-when-ripple-editing-or-inserting-time": 40649,
1821 "options-allow-drag-drop-media-import-to-target-the-top-part-of-a-track-to-insert-a-new-track-to-receive-the-media":
1822 42477,
1823 "options-allow-selecting-empty-takes": 41355,
1824 "options-always-record-to-automation-items": 42212,
1825 "options-always-trim-content-behind-razor-edits-otherwise-follow-media-item-editing-preferences": 42421,
1826 "options-auto-crossfade-media-items-when-editing": 40041,
1827 "options-automatically-insert-automation-item-when-activating-envelope-that-is-bypassed-outside-of-automation-items-and-not-displayed-in-envelope-lane":
1828 42220,
1829 "options-automation-item-baseline-amplitude-edits-affect-pooled-copies": 42194,
1830 "options-automation-items-connect-to-the-underlying-envelope-on-both-sides": 42205,
1831 "options-automation-items-connect-to-the-underlying-envelope-on-the-right-side": 42204,
1832 "options-automation-items-do-not-connect-to-the-underlying-envelope": 42203,
1833 "options-avoid-including-empty-track-space-in-comp-areas": 42795,
1834 "options-bypass-underlying-envelopes-outside-of-automation-items": 42213,
1835 "options-chase-midi-note-on-cc-pc-pitch-in-project-playback": 41992,
1836 "options-chase-non-fx-envelopes-to-automation-items-when-underlying-envelope-is-bypassed": 42344,
1837 "options-crossfade-center-when-splitting": 43193,
1838 "options-crossfade-left-when-splitting": 43191,
1839 "options-crossfade-right-when-splitting": 43192,
1840 "options-cycle-ripple-editing-mode": 1155,
1841 "options-cycle-split-options-crossfade-left-crossfade-center-crossfade-right-no-crossfade": 43196,
1842 "options-cycle-through-editing-modes-auto-crossfade-off-auto-crossfade-on-trim-content-behind-media-items": 41116,
1843 "options-disable-auto-crossfades": 41119,
1844 "options-disable-crossfade-on-split-disregard-toolbar-auto-crossfade-button": 40928,
1845 "options-disable-display-group-names-colors-in-arrange-view": 43653,
1846 "options-disable-metronome": 41746,
1847 "options-disable-trim-content-behind-media-items-when-editing": 41121,
1848 "options-do-not-change-comp-area-source-lane-when-clicking-empty-track-space": 42794,
1849 "options-do-not-display-tracks-in-folder-when-folder-is-fully-collapsed": 42699,
1850 "options-enable-auto-crossfades": 41118,
1851 "options-enable-crossfade-on-split-disregard-toolbar-auto-crossfade-button": 40927,
1852 "options-enable-display-group-names-colors-in-arrange-view": 43652,
1853 "options-enable-metronome": 41745,
1854 "options-enable-trim-content-behind-media-items-when-editing": 41120,
1855 "options-envelope-point-selection-follows-time-selection": 41576,
1856 "options-limit-media-item-edge-edits-to-source-media-content-for-unlooped-media-items": 42218,
1857 "options-loop-new-automation-items-by-default": 42195,
1858 "options-loop-recording-always-adds-takes": 40114,
1859 "options-mouse-modifier-preferences": 41356,
1860 "options-move-edit-cursor-to-end-of-recording-when-recording-ends": 40300,
1861 "options-move-edit-cursor-to-start-of-time-selection-when-time-selection-changes": 40276,
1862 "options-move-envelope-points-with-media-items": 40070,
1863 "options-move-take-envelope-points-when-moving-media-item-contents": 43636,
1864 "options-new-recording-adds-lanes-in-layers-multiple-lanes-play-at-once": 43151,
1865 "options-new-recording-adds-lanes-new-lanes-play-exclusively": 43152,
1866 "options-new-recording-adds-media-items-in-layers": 42677,
1867 "options-new-recording-does-not-add-lanes": 43153,
1868 "options-new-recording-splits-existing-items-and-adds-takes-default": 41330,
1869 "options-new-recording-trims-existing-items-tape-mode": 41186,
1870 "options-offset-overlapping-media-items-vertically": 40507,
1871 "options-pre-fader-track-metering": 42076,
1872 "options-preferences": 40016,
1873 "options-preserve-trailing-values-when-recording-automation": 42640,
1874 "options-prevent-mouse-edits-of-single-envelope-points-from-moving-past-other-envelope-points": 42202,
1875 "options-razor-edits-in-media-item-lane-affect-all-track-envelopes": 42459,
1876 "options-razor-edits-on-collapsed-fixed-lane-tracks-affect-all-lanes": 42601,
1877 "options-razor-edits-on-small-fixed-lane-tracks-affect-all-lanes": 43097,
1878 "options-reduce-envelope-data-when-recording-or-drawing-automation": 40650,
1879 "options-respect-toolbar-auto-crossfade-button-on-split": 43195,
1880 "options-ripple-all-tracks-when-ripple-is-enabled": 43469,
1881 "options-ripple-edit-all-affects-envelopes-on-all-tracks": 43567,
1882 "options-ripple-edit-all-affects-tempo-map": 42011,
1883 "options-ripple-edit-when-editing-media-item-edges": 43475,
1884 "options-ripple-per-track-affects-each-track-lane-separately": 43467,
1885 "options-ripple-per-track-when-ripple-is-enabled": 43468,
1886 "options-select-takes-for-all-selected-items-when-clicking-take": 40249,
1887 "options-selecting-one-grouped-item-selects-group": 41156,
1888 "options-set-loop-points-linked-to-time-selection": 40749,
1889 "options-set-metronome-speed-to-0-5x": 43703,
1890 "options-set-metronome-speed-to-1x": 42456,
1891 "options-set-metronome-speed-to-2x": 42457,
1892 "options-set-metronome-speed-to-4x": 42458,
1893 "options-set-metronome-volume-midi-cc-osc-only": 999,
1894 "options-show-all-takes-when-room": 40435,
1895 "options-show-empty-takes-align-takes-by-recording-pass": 41346,
1896 "options-show-fx-inserts-in-tcp-when-size-permits": 40302,
1897 "options-show-lock-settings": 40277,
1898 "options-show-metronome-pre-roll-settings": 40363,
1899 "options-show-peak-value-tooltips-on-media-items-and-loudness-if-peaks-are-configured-to-calculate-it": 43149,
1900 "options-show-sends-in-tcp-when-size-permits": 40677,
1901 "options-show-snap-grid-settings": 40071,
1902 "options-show-theme-adjuster": 42234,
1903 "options-show-theme-color-controls": 42392,
1904 "options-show-tooltips-on-media-items-and-envelopes": 41344,
1905 "options-solo-in-front": 40745,
1906 "options-solo-via-dedicated-solo-bus-preference-master-outputs-can-be-set-to-bypass-solo-bus": 43631,
1907 "options-switch-to-a-random-color-theme": 40383,
1908 "options-switch-to-next-color-theme": 40381,
1909 "options-switch-to-previous-color-theme": 40382,
1910 "options-toggle-always-on-top": 40239,
1911 "options-toggle-auto-fade-auto-crossfade-when-comping-in-fixed-lanes": 42631,
1912 "options-toggle-crossfade-on-split-disregard-toolbar-auto-crossfade-button": 40912,
1913 "options-toggle-display-group-names-colors-in-arrange-view": 43651,
1914 "options-toggle-editing-active-take-source-start-offset-slip-editing-adjusts-all-takes": 41338,
1915 "options-toggle-grid-lines": 40145,
1916 "options-toggle-item-grouping-and-track-media-razor-edit-grouping": 1156,
1917 "options-toggle-locking": 1135,
1918 "options-toggle-loop-points-linked-to-time-selection": 40621,
1919 "options-toggle-metronome": 40364,
1920 "options-toggle-new-recording-adds-lanes-in-layers-multiple-lanes-play-at-once": 41329,
1921 "options-toggle-new-recording-adds-lanes-new-lanes-play-exclusively": 42702,
1922 "options-toggle-pooled-ghost-midi-source-data-when-copying-media-items": 41071,
1923 "options-toggle-running-fx-when-playback-is-stopped": 41583,
1924 "options-toggle-smooth-seek-see-preferences-audio-seeking": 40390,
1925 "options-toggle-snapping": 1157,
1926 "options-track-media-razor-edit-grouping-affects-only-items-that-start-and-end-at-the-same-time": 42788,
1927 "options-trim-content-behind-automation-items-when-editing-or-writing-automation": 42206,
1928 "options-trim-content-behind-media-items-when-editing": 41117,
1929 "options-unset-loop-points-linked-to-time-selection": 40750,
1930 "options-when-auto-punch-recording-into-a-fixed-lane-track-add-the-whole-recording": 42793,
1931 "options-when-importing-copy-imported-media-to-project-media-directory": 40263,
1932 "peaks-build-any-missing-peaks": 40047,
1933 "peaks-build-any-missing-peaks-for-selected-items": 40245,
1934 "peaks-decrease-peaks-display-zoom-for-project": 40156,
1935 "peaks-force-mono-peaks": 42626,
1936 "peaks-increase-peaks-display-zoom-for-project": 40155,
1937 "peaks-load-spectral-peaks-preset-1": 42077,
1938 "peaks-load-spectral-peaks-preset-2": 42078,
1939 "peaks-load-spectral-peaks-preset-3": 42079,
1940 "peaks-load-spectral-peaks-preset-4": 42080,
1941 "peaks-load-spectral-peaks-preset-5": 42081,
1942 "peaks-load-spectrogram-preset-1": 42296,
1943 "peaks-load-spectrogram-preset-2": 42297,
1944 "peaks-load-spectrogram-preset-3": 42298,
1945 "peaks-load-spectrogram-preset-4": 42299,
1946 "peaks-load-spectrogram-preset-5": 42300,
1947 "peaks-rebuild-all-peaks": 40048,
1948 "peaks-rebuild-peaks-for-selected-items": 40441,
1949 "peaks-rectify-peaks": 42307,
1950 "peaks-remove-all-peak-cache-files": 40097,
1951 "peaks-reset-peaks-display-zoom-for-project": 42449,
1952 "peaks-scale-peaks-by-square-root-half-of-range-is-12db-rather-than-6db": 42306,
1953 "peaks-show-normal-peaks": 42301,
1954 "peaks-toggle-color-peaks-by-momentary-loudness-lufs-m": 43145,
1955 "peaks-toggle-color-peaks-by-short-term-loudness-lufs-s": 43147,
1956 "peaks-toggle-normal-peaks-plus-spectrogram": 42295,
1957 "peaks-toggle-show-graph-of-momentary-loudness-lufs-m": 43146,
1958 "peaks-toggle-show-graph-of-short-term-loudness-lufs-s": 43148,
1959 "peaks-toggle-show-spectral-peaks-and-graph-of-momentary-loudness-lufs-m": 43207,
1960 "peaks-toggle-show-spectral-peaks-and-graph-of-short-term-loudness-lufs-s": 43208,
1961 "peaks-toggle-spectral-peaks": 42073,
1962 "peaks-toggle-spectral-peaks-plus-spectrogram": 43209,
1963 "peaks-toggle-spectrogram": 42294,
1964 "performance-meter-reset-graph": 40602,
1965 "pre-roll-toggle-pre-roll-on-play": 41818,
1966 "pre-roll-toggle-pre-roll-on-record": 41819,
1967 "pre-roll-toggle-pre-roll-on-record-deprecated-duplicate": 41038,
1968 "project-bay-add-comment-for-items": 40195,
1969 "project-bay-force-refresh": 1582,
1970 "project-bay-insert-items-into-project": 41856,
1971 "project-bay-remove-items-from-project": 41586,
1972 "project-project-timebase-affects-midi-items": 43641,
1973 "project-recording-settings": 40934,
1974 "project-set-project-timebase-to-beats-auto-stretch-at-tempo-changes": 43640,
1975 "project-set-project-timebase-to-beats-position-length-rate": 43462,
1976 "project-set-project-timebase-to-beats-position-only": 43463,
1977 "project-set-project-timebase-to-time": 43461,
1978 "project-set-tempo-time-signature-envelope-timebase-to-beats": 43571,
1979 "project-set-tempo-time-signature-envelope-timebase-to-beats-for-time-signature-time-for-tempo": 43572,
1980 "project-set-tempo-time-signature-envelope-timebase-to-time": 43570,
1981 "project-tabs-always-show-project-tabs": 40874,
1982 "project-tabs-auto-offline-background-project-media": 40872,
1983 "project-tabs-defer-rendering-of-subprojects-render-on-tab-switch-rather-than-save": 41998,
1984 "project-tabs-display-video-from-background-projects-if-active-project-lacks-video": 42653,
1985 "project-tabs-do-not-automatically-render-subprojects-require-manual-render": 42333,
1986 "project-tabs-force-project-tabs-visible-when-monitoring-fx-in-use": 42072,
1987 "project-tabs-hide-all-background-project-windows": 40909,
1988 "project-tabs-leave-subproject-open-in-tab-after-automatic-open-and-render": 42012,
1989 "project-tabs-move-project-tab-left-by-one": 3242,
1990 "project-tabs-move-project-tab-right-by-one": 3243,
1991 "project-tabs-move-project-tab-to-position-1": 3182,
1992 "project-tabs-move-project-tab-to-position-10": 3191,
1993 "project-tabs-move-project-tab-to-position-2": 3183,
1994 "project-tabs-move-project-tab-to-position-3": 3184,
1995 "project-tabs-move-project-tab-to-position-4": 3185,
1996 "project-tabs-move-project-tab-to-position-5": 3186,
1997 "project-tabs-move-project-tab-to-position-6": 3187,
1998 "project-tabs-move-project-tab-to-position-7": 3188,
1999 "project-tabs-move-project-tab-to-position-8": 3189,
2000 "project-tabs-move-project-tab-to-position-9": 3190,
2001 "project-tabs-move-project-tab-to-position-n": 3212,
2002 "project-tabs-move-project-tab-to-position-n-1": 3213,
2003 "project-tabs-move-project-tab-to-position-n-2": 3214,
2004 "project-tabs-move-project-tab-to-position-n-3": 3215,
2005 "project-tabs-move-project-tab-to-position-n-4": 3216,
2006 "project-tabs-move-project-tab-to-position-n-5": 3217,
2007 "project-tabs-move-project-tab-to-position-n-6": 3218,
2008 "project-tabs-move-project-tab-to-position-n-7": 3219,
2009 "project-tabs-move-project-tab-to-position-n-8": 3220,
2010 "project-tabs-move-project-tab-to-position-n-9": 3221,
2011 "project-tabs-play-stopped-background-projects-with-active-project": 41062,
2012 "project-tabs-prompt-before-automatic-rerender-of-background-subprojects": 42334,
2013 "project-tabs-run-background-projects": 40871,
2014 "project-tabs-run-stopped-background-projects": 40873,
2015 "project-tabs-show-project-tabs-on-left-side-of-window": 41883,
2016 "project-tabs-switch-to-next-project-tab": 40861,
2017 "project-tabs-switch-to-previous-project-tab": 40862,
2018 "project-tabs-switch-to-previously-active-project-tab": 3121,
2019 "project-tabs-switch-to-project-tab-1": 3122,
2020 "project-tabs-switch-to-project-tab-10": 3131,
2021 "project-tabs-switch-to-project-tab-2": 3123,
2022 "project-tabs-switch-to-project-tab-3": 3124,
2023 "project-tabs-switch-to-project-tab-4": 3125,
2024 "project-tabs-switch-to-project-tab-5": 3126,
2025 "project-tabs-switch-to-project-tab-6": 3127,
2026 "project-tabs-switch-to-project-tab-7": 3128,
2027 "project-tabs-switch-to-project-tab-8": 3129,
2028 "project-tabs-switch-to-project-tab-9": 3130,
2029 "project-tabs-switch-to-project-tab-n": 3152,
2030 "project-tabs-switch-to-project-tab-n-1": 3153,
2031 "project-tabs-switch-to-project-tab-n-2": 3154,
2032 "project-tabs-switch-to-project-tab-n-3": 3155,
2033 "project-tabs-switch-to-project-tab-n-4": 3156,
2034 "project-tabs-switch-to-project-tab-n-5": 3157,
2035 "project-tabs-switch-to-project-tab-n-6": 3158,
2036 "project-tabs-switch-to-project-tab-n-7": 3159,
2037 "project-tabs-switch-to-project-tab-n-8": 3160,
2038 "project-tabs-switch-to-project-tab-n-9": 3161,
2039 "project-tabs-synchronize-any-parent-projects-when-playing-back-subproject": 41994,
2040 "project-tabs-synchronize-play-start-times-w-play-background-projects": 41063,
2041 "project-toggle-project-timebase-to-time": 43637,
2042 "razor-edit-clear-all-areas": 42406,
2043 "razor-edit-create-area-from-cursor-to-mouse": 42412,
2044 "razor-edit-create-fixed-lane-comp-area": 42475,
2045 "razor-edit-enclose-media-items": 42630,
2046 "razor-edit-enclose-media-items-including-space-between-items": 42409,
2047 "razor-edit-move-areas-backwards-without-contents": 42400,
2048 "razor-edit-move-areas-down-without-contents": 42403,
2049 "razor-edit-move-areas-forwards-without-contents": 42399,
2050 "razor-edit-move-areas-up-without-contents": 42402,
2051 "razor-edit-move-nearest-area-edge-to-edit-cursor": 42498,
2052 "razor-edit-select-media-items-within-razor-edit-area": 42957,
2053 "razor-edit-set-loop-points-to-razor-edit-area": 42474,
2054 "reascript-clear-contents-of-reascript-console": 42664,
2055 "reascript-close-all-running-reascripts": 41898,
2056 "reascript-edit-new-reascript-eel2-or-lua": 41935,
2057 "reascript-open-reascript-documentation-html": 41065,
2058 "reascript-run-edit-last-reascript-eel2-or-lua": 41931,
2059 "reascript-run-edit-reascript-eel2-or-lua": 41928,
2060 "reascript-run-last-reascript-eel2-or-lua": 41061,
2061 "reascript-run-reascript-eel2-or-lua": 41060,
2062 "reascript-show-reascript-console": 42663,
2063 "record-add-recorded-media-to-project": 40670,
2064 "record-remove-recorded-media-not-yet-in-project": 40669,
2065 "record-set-record-mode-to-normal": 40252,
2066 "record-set-record-mode-to-selected-item-auto-punch": 40253,
2067 "record-set-record-mode-to-time-selection-auto-punch": 40076,
2068 "record-start-new-files-during-recording": 40666,
2069 "region-render-matrix-add-selected-tracks-to-render-list-for-all-regions": 41893,
2070 "region-render-matrix-render-all-tracks-for-all-regions": 41891,
2071 "region-render-matrix-render-master-mix-for-all-regions": 41890,
2072 "region-render-matrix-render-only-selected-tracks-for-all-regions": 41892,
2073 "regions-go-to-next-region-after-current-region-finishes-playing-smooth-seek": 41802,
2074 "regions-go-to-previous-region-after-current-region-finishes-playing-smooth-seek": 41801,
2075 "regions-go-to-region-01-after-current-region-finishes-playing-smooth-seek": 41761,
2076 "regions-go-to-region-02-after-current-region-finishes-playing-smooth-seek": 41762,
2077 "regions-go-to-region-03-after-current-region-finishes-playing-smooth-seek": 41763,
2078 "regions-go-to-region-04-after-current-region-finishes-playing-smooth-seek": 41764,
2079 "regions-go-to-region-05-after-current-region-finishes-playing-smooth-seek": 41765,
2080 "regions-go-to-region-06-after-current-region-finishes-playing-smooth-seek": 41766,
2081 "regions-go-to-region-07-after-current-region-finishes-playing-smooth-seek": 41767,
2082 "regions-go-to-region-08-after-current-region-finishes-playing-smooth-seek": 41768,
2083 "regions-go-to-region-09-after-current-region-finishes-playing-smooth-seek": 41769,
2084 "regions-go-to-region-10-after-current-region-finishes-playing-smooth-seek": 41770,
2085 "regions-go-to-region-11-after-current-region-finishes-playing-smooth-seek": 41771,
2086 "regions-go-to-region-12-after-current-region-finishes-playing-smooth-seek": 41772,
2087 "regions-go-to-region-13-after-current-region-finishes-playing-smooth-seek": 41773,
2088 "regions-go-to-region-14-after-current-region-finishes-playing-smooth-seek": 41774,
2089 "regions-go-to-region-15-after-current-region-finishes-playing-smooth-seek": 41775,
2090 "regions-go-to-region-16-after-current-region-finishes-playing-smooth-seek": 41776,
2091 "regions-go-to-region-17-after-current-region-finishes-playing-smooth-seek": 41777,
2092 "regions-go-to-region-18-after-current-region-finishes-playing-smooth-seek": 41778,
2093 "regions-go-to-region-19-after-current-region-finishes-playing-smooth-seek": 41779,
2094 "regions-go-to-region-20-after-current-region-finishes-playing-smooth-seek": 41780,
2095 "regions-go-to-region-21-after-current-region-finishes-playing-smooth-seek": 41781,
2096 "regions-go-to-region-22-after-current-region-finishes-playing-smooth-seek": 41782,
2097 "regions-go-to-region-23-after-current-region-finishes-playing-smooth-seek": 41783,
2098 "regions-go-to-region-24-after-current-region-finishes-playing-smooth-seek": 41784,
2099 "regions-go-to-region-25-after-current-region-finishes-playing-smooth-seek": 41785,
2100 "regions-go-to-region-26-after-current-region-finishes-playing-smooth-seek": 41786,
2101 "regions-go-to-region-27-after-current-region-finishes-playing-smooth-seek": 41787,
2102 "regions-go-to-region-28-after-current-region-finishes-playing-smooth-seek": 41788,
2103 "regions-go-to-region-29-after-current-region-finishes-playing-smooth-seek": 41789,
2104 "regions-go-to-region-30-after-current-region-finishes-playing-smooth-seek": 41790,
2105 "regions-go-to-region-31-after-current-region-finishes-playing-smooth-seek": 41791,
2106 "regions-go-to-region-32-after-current-region-finishes-playing-smooth-seek": 41792,
2107 "regions-go-to-region-33-after-current-region-finishes-playing-smooth-seek": 41793,
2108 "regions-go-to-region-34-after-current-region-finishes-playing-smooth-seek": 41794,
2109 "regions-go-to-region-35-after-current-region-finishes-playing-smooth-seek": 41795,
2110 "regions-go-to-region-36-after-current-region-finishes-playing-smooth-seek": 41796,
2111 "regions-go-to-region-37-after-current-region-finishes-playing-smooth-seek": 41797,
2112 "regions-go-to-region-38-after-current-region-finishes-playing-smooth-seek": 41798,
2113 "regions-go-to-region-39-after-current-region-finishes-playing-smooth-seek": 41799,
2114 "regions-go-to-region-40-after-current-region-finishes-playing-smooth-seek": 41800,
2115 "regions-select-unselect-all-regions-for-rendering": 42679,
2116 "regions-set-loop-points-to-current-region": 43102,
2117 "regions-set-loop-points-to-next-region": 43144,
2118 "regions-set-loop-points-to-previous-region": 43103,
2119 "remove-items-tracks-envelope-points-depending-on-focus": 40697,
2120 "remove-items-tracks-envelope-points-depending-on-focus-no-prompting": 40184,
2121 "render-all-queued-renders": 41207,
2122 "reset-all-midi-control-surface-devices": 42348,
2123 "reset-all-midi-devices": 41175,
2124 "reset-position-cascade-all-floating-windows": 41155,
2125 "reset-project-recording-pass-counter-recpass-wildcard": 41048,
2126 "reset-soft-takeover-for-all-midi-controller-assignments": 41070,
2127 "ruler-display-project-regions-markers-as-gridlines-in-arrange-view": 42328,
2128 "ruler-display-region-number-even-if-region-is-named": 42435,
2129 "ruler-display-region-number-name-when-region-edge-is-not-visible": 42436,
2130 "ruler-display-selected-regions-over-unselected-regions-when-overlapping": 43206,
2131 "ruler-display-tempo-and-time-signature-changes-in-separate-lanes-when-size-permits": 42325,
2132 "ruler-display-tempo-changes": 42326,
2133 "ruler-display-time-signature-changes": 42327,
2134 "ruler-display-time-signature-changes-as-gridlines-in-arrange-view": 42329,
2135 "ruler-reset-project-start-measure": 43348,
2136 "ruler-reset-project-start-time": 43346,
2137 "ruler-set-0-00-to-current-edit-cursor": 43345,
2138 "ruler-set-measure-1-to-nearest-measure-to-current-edit-cursor": 43347,
2139 "ruler-set-to-default-height": 42320,
2140 "ruler-set-to-maximum-height": 42322,
2141 "ruler-set-to-minimum-height": 42321,
2142 "ruler-show-hide-all-project-markers": 43485,
2143 "ruler-show-hide-all-project-regions": 43487,
2144 "ruler-show-hide-all-project-regions-and-markers": 43489,
2145 "ruler-show-hide-selected-project-markers": 43484,
2146 "ruler-show-hide-selected-project-regions": 43486,
2147 "ruler-show-hide-selected-project-regions-and-markers": 43488,
2148 "screenset-load-track-view-number-01": 40444,
2149 "screenset-load-track-view-number-02": 40445,
2150 "screenset-load-track-view-number-03": 40446,
2151 "screenset-load-track-view-number-04": 40447,
2152 "screenset-load-track-view-number-05": 40448,
2153 "screenset-load-track-view-number-06": 40449,
2154 "screenset-load-track-view-number-07": 40450,
2155 "screenset-load-track-view-number-08": 40451,
2156 "screenset-load-track-view-number-09": 40452,
2157 "screenset-load-track-view-number-10": 40453,
2158 "screenset-load-window-set-number-01": 40454,
2159 "screenset-load-window-set-number-02": 40455,
2160 "screenset-load-window-set-number-03": 40456,
2161 "screenset-load-window-set-number-04": 40457,
2162 "screenset-load-window-set-number-05": 40458,
2163 "screenset-load-window-set-number-06": 40459,
2164 "screenset-load-window-set-number-07": 40460,
2165 "screenset-load-window-set-number-08": 40461,
2166 "screenset-load-window-set-number-09": 40462,
2167 "screenset-load-window-set-number-10": 40463,
2168 "screenset-save-track-view-number-01": 40464,
2169 "screenset-save-track-view-number-02": 40465,
2170 "screenset-save-track-view-number-03": 40466,
2171 "screenset-save-track-view-number-04": 40467,
2172 "screenset-save-track-view-number-05": 40468,
2173 "screenset-save-track-view-number-06": 40469,
2174 "screenset-save-track-view-number-07": 40470,
2175 "screenset-save-track-view-number-08": 40471,
2176 "screenset-save-track-view-number-09": 40472,
2177 "screenset-save-track-view-number-10": 40473,
2178 "screenset-save-window-set-number-01": 40474,
2179 "screenset-save-window-set-number-02": 40475,
2180 "screenset-save-window-set-number-03": 40476,
2181 "screenset-save-window-set-number-04": 40477,
2182 "screenset-save-window-set-number-05": 40478,
2183 "screenset-save-window-set-number-06": 40479,
2184 "screenset-save-window-set-number-07": 40480,
2185 "screenset-save-window-set-number-08": 40481,
2186 "screenset-save-window-set-number-09": 40482,
2187 "screenset-save-window-set-number-10": 40483,
2188 "script-default-6-0-theme-adjuster-lua": 55810,
2189 "script-default-7-0-theme-adjuster-lua": 55811,
2190 "script-insert-addictive-drums-track-lua": 55816,
2191 "script-insert-addictive-drums-track-lua-55818": 55818,
2192 "script-insert-addictive-drums-track-lua-55822": 55822,
2193 "script-insert-armed-track-lua": 55813,
2194 "script-insert-blank-track-lua": 55817,
2195 "script-insert-blank-track-lua-55819": 55819,
2196 "script-insert-blank-track-lua-55823": 55823,
2197 "script-insert-instrument-track-lua": 55814,
2198 "script-insert-komplete-kontrol-track-lua": 55815,
2199 "script-insert-komplete-kontrol-track-lua-55820": 55820,
2200 "script-insert-komplete-kontrol-track-lua-55824": 55824,
2201 "script-lyrics-lua": 55808,
2202 "scrub-disable-looped-segment-scrub-at-edit-cursor": 41189,
2203 "scrub-enable-looped-segment-scrub-at-edit-cursor": 41188,
2204 "scrub-invert-looped-segment-scrub-range": 43617,
2205 "scrub-play-one-one-shot-segment-scrub-at-edit-cursor": 43594,
2206 "scrub-prompt-to-edit-looped-segment-scrub-range": 43632,
2207 "scrub-toggle-looped-segment-scrub-at-edit-cursor": 41187,
2208 "scrub-toggle-preference-for-one-shot-segment-scrub-when-moving-edit-cursor": 43593,
2209 "select-all-items-tracks-envelope-points-depending-on-focus": 40035,
2210 "selection-set-load-set-number-01": 41239,
2211 "selection-set-load-set-number-02": 41240,
2212 "selection-set-load-set-number-03": 41241,
2213 "selection-set-load-set-number-04": 41242,
2214 "selection-set-load-set-number-05": 41243,
2215 "selection-set-load-set-number-06": 41244,
2216 "selection-set-load-set-number-07": 41245,
2217 "selection-set-load-set-number-08": 41246,
2218 "selection-set-load-set-number-09": 41247,
2219 "selection-set-load-set-number-10": 41248,
2220 "selection-set-save-set-number-01": 41229,
2221 "selection-set-save-set-number-02": 41230,
2222 "selection-set-save-set-number-03": 41231,
2223 "selection-set-save-set-number-04": 41232,
2224 "selection-set-save-set-number-05": 41233,
2225 "selection-set-save-set-number-06": 41234,
2226 "selection-set-save-set-number-07": 41235,
2227 "selection-set-save-set-number-08": 41236,
2228 "selection-set-save-set-number-09": 41237,
2229 "selection-set-save-set-number-10": 41238,
2230 "send-all-notes-off-and-all-sounds-off-to-all-midi-outputs-plug-ins": 40345,
2231 "send-mute-track-receive-number-1": 41365,
2232 "send-mute-track-receive-number-2": 41366,
2233 "send-mute-track-receive-number-3": 41367,
2234 "send-mute-track-receive-number-4": 41368,
2235 "send-mute-track-receive-number-5": 41369,
2236 "send-mute-track-receive-number-6": 41370,
2237 "send-mute-track-receive-number-7": 41371,
2238 "send-mute-track-receive-number-8": 41372,
2239 "send-mute-track-send-number-1": 41357,
2240 "send-mute-track-send-number-2": 41358,
2241 "send-mute-track-send-number-3": 41359,
2242 "send-mute-track-send-number-4": 41360,
2243 "send-mute-track-send-number-5": 41361,
2244 "send-mute-track-send-number-6": 41362,
2245 "send-mute-track-send-number-7": 41363,
2246 "send-mute-track-send-number-8": 41364,
2247 "set-project-recording-pass-counter-recpass-wildcard": 42032,
2248 "set-project-recording-tag-rectag-wildcard": 43464,
2249 "set-project-tempo-from-time-selection-detect-tempo": 41597,
2250 "set-project-tempo-from-time-selection-detect-tempo-align-items-and-loop-points-to-measure-start": 40002,
2251 "set-project-tempo-from-time-selection-new-time-signature": 40843,
2252 "set-ripple-editing-all-tracks": 40311,
2253 "set-ripple-editing-off": 40309,
2254 "set-ripple-editing-on": 1161,
2255 "set-ripple-editing-per-track": 40310,
2256 "set-tempo-coarse-latch-for-fine-midi-cc-osc-only": 983,
2257 "set-tempo-coarse-midi-cc-osc-only": 984,
2258 "set-tempo-fine-midi-cc-osc-only": 985,
2259 "show-action-list": 40605,
2260 "show-external-timecode-synchronization-settings": 40619,
2261 "show-reaper-resource-path-in-finder": 40027,
2262 "show-record-path-in-finder": 40024,
2263 "show-secondary-record-path-in-finder": 40028,
2264 "show-startup-splash-screen": 41535,
2265 "snapping-disable-snap": 40753,
2266 "snapping-enable-snap": 40754,
2267 "snapping-restore-snap-state": 40756,
2268 "snapping-save-snap-state": 40755,
2269 "spectrogram-add-spectral-edit-to-item": 42302,
2270 "spectrogram-adjust-brightness-midi-cc-mousewheel-osc-only": 24000,
2271 "spectrogram-adjust-color-curve-midi-cc-mousewheel-osc-only": 24002,
2272 "spectrogram-adjust-contrast-midi-cc-mousewheel-osc-only": 24001,
2273 "spectrogram-adjust-frequency-log-scaling-midi-cc-mousewheel-osc-only": 24003,
2274 "spectrogram-adjust-gain-of-selected-spectral-edits-by-0-5-db": 43683,
2275 "spectrogram-adjust-gain-of-selected-spectral-edits-by-3-db": 43685,
2276 "spectrogram-adjust-gain-of-selected-spectral-edits-by-plus-0-5-db": 43682,
2277 "spectrogram-adjust-gain-of-selected-spectral-edits-by-plus-3-db": 43684,
2278 "spectrogram-bypass-selected-spectral-edits": 43680,
2279 "spectrogram-delete-selected-spectral-edits": 43687,
2280 "spectrogram-reset-gain-of-selected-spectral-edits-to-plus-0-db": 43686,
2281 "spectrogram-show-high-resolution-spectrogram-when-zoomed-in": 43679,
2282 "spectrogram-show-selected-spectral-edit-configuration-menu": 43688,
2283 "spectrogram-solo-selected-spectral-edits": 43681,
2284 "spectrogram-toggle-always-show-spectrogram-for-selected-items": 42303,
2285 "sws-about": 53929,
2286 "sws-add-item-s-to-left-of-selected-item-s-to-selection": 53660,
2287 "sws-add-item-s-to-right-of-selected-item-s-to-selection": 53659,
2288 "sws-add-related-project-s": 53218,
2289 "sws-add-selected-track-s-to-all-snapshots": 53158,
2290 "sws-add-selected-track-s-to-current-snapshot": 53157,
2291 "sws-analyze-and-display-item-peak-and-rms-entire-item": 53567,
2292 "sws-apply-auto-coloring": 53006,
2293 "sws-aw-cascade-selected-track-inputs": 53559,
2294 "sws-aw-consolidate-selection": 53510,
2295 "sws-aw-disable-clear-loop-points-on-click-in-ruler": 53527,
2296 "sws-aw-disable-count-in-before-playback": 53518,
2297 "sws-aw-disable-count-in-before-recording": 53521,
2298 "sws-aw-disable-link-time-selection-and-edit-cursor": 53524,
2299 "sws-aw-disable-metronome-during-playback": 53512,
2300 "sws-aw-disable-metronome-during-recording": 53515,
2301 "sws-aw-enable-clear-loop-points-on-click-in-ruler": 53526,
2302 "sws-aw-enable-count-in-before-playback": 53517,
2303 "sws-aw-enable-count-in-before-recording": 53520,
2304 "sws-aw-enable-link-time-selection-and-edit-cursor": 53523,
2305 "sws-aw-enable-metronome-during-playback": 53511,
2306 "sws-aw-enable-metronome-during-recording": 53514,
2307 "sws-aw-fade-in-out-crossfade-selected-area-of-selected-items": 53506,
2308 "sws-aw-fill-gaps-between-selected-items-advanced": 53492,
2309 "sws-aw-fill-gaps-between-selected-items-advanced-use-last-settings": 53493,
2310 "sws-aw-fill-gaps-between-selected-items-quick-crossfade-using-default-fade-length": 53495,
2311 "sws-aw-fill-gaps-between-selected-items-quick-no-crossfade": 53494,
2312 "sws-aw-insert-click-track": 53555,
2313 "sws-aw-nf-toggle-assign-random-colors-if-auto-group-newly-recorded-items-is-enabled": 53504,
2314 "sws-aw-paste": 53553,
2315 "sws-aw-play-stop-automatically-group-simultaneously-recorded-items-deprecated": 53503,
2316 "sws-aw-record-automatically-group-simultaneously-recorded-items-deprecated": 53500,
2317 "sws-aw-record-conditional-normal-or-time-selection-only": 53497,
2318 "sws-aw-record-conditional-normal-or-time-selection-only-automatically-group-simultaneously-recorded-items-deprecated":
2319 53501,
2320 "sws-aw-record-conditional-normal-time-selection-item-selection-automatically-group-simultaneously-recorded-items-deprecated":
2321 53502,
2322 "sws-aw-record-conditional-normal-time-selection-or-item-selection": 53498,
2323 "sws-aw-remove-overlaps-in-selected-items-preserving-item-starts": 53496,
2324 "sws-aw-remove-tracks-items-env-obeying-time-selection-and-leaving-children": 53554,
2325 "sws-aw-render-tracks-to-mono-stem-tracks-obeying-time-selection": 53558,
2326 "sws-aw-render-tracks-to-stereo-stem-tracks-obeying-time-selection": 53557,
2327 "sws-aw-select-all-items-in-group-if-grouping-is-enabled": 53560,
2328 "sws-aw-select-from-cursor-to-end-of-project-items-and-time-selection": 53505,
2329 "sws-aw-set-grid-to-1-128-preserving-grid-type": 53552,
2330 "sws-aw-set-grid-to-1-16-preserving-grid-type": 53549,
2331 "sws-aw-set-grid-to-1-2-preserving-grid-type": 53546,
2332 "sws-aw-set-grid-to-1-32-preserving-grid-type": 53550,
2333 "sws-aw-set-grid-to-1-4-preserving-grid-type": 53547,
2334 "sws-aw-set-grid-to-1-64-preserving-grid-type": 53551,
2335 "sws-aw-set-grid-to-1-8-preserving-grid-type": 53548,
2336 "sws-aw-set-grid-to-1-preserving-grid-type": 53545,
2337 "sws-aw-set-grid-to-2-preserving-grid-type": 53544,
2338 "sws-aw-set-grid-to-4-preserving-grid-type": 53543,
2339 "sws-aw-set-project-timebase-to-beats-position-length-rate": 53531,
2340 "sws-aw-set-project-timebase-to-beats-position-only": 53530,
2341 "sws-aw-set-project-timebase-to-time": 53529,
2342 "sws-aw-set-selected-items-timebase-to-beats-auto-stretch-at-tempo-changes": 53566,
2343 "sws-aw-set-selected-items-timebase-to-beats-position-length-rate": 53539,
2344 "sws-aw-set-selected-items-timebase-to-beats-position-only": 53538,
2345 "sws-aw-set-selected-items-timebase-to-project-track-default": 53536,
2346 "sws-aw-set-selected-items-timebase-to-time": 53537,
2347 "sws-aw-set-selected-tracks-pan-mode-to-3-x-balance": 53563,
2348 "sws-aw-set-selected-tracks-pan-mode-to-dual-pan": 53565,
2349 "sws-aw-set-selected-tracks-pan-mode-to-stereo-balance": 53562,
2350 "sws-aw-set-selected-tracks-pan-mode-to-stereo-pan": 53564,
2351 "sws-aw-set-selected-tracks-timebase-to-beats-position-length-rate": 53535,
2352 "sws-aw-set-selected-tracks-timebase-to-beats-position-only": 53534,
2353 "sws-aw-set-selected-tracks-timebase-to-project-default": 53532,
2354 "sws-aw-set-selected-tracks-timebase-to-time": 53533,
2355 "sws-aw-split-selected-items-at-edit-cursor-w-crossfade-on-left": 53561,
2356 "sws-aw-stretch-selected-items-to-fill-selection": 53509,
2357 "sws-aw-toggle-auto-group-newly-recorded-items": 53499,
2358 "sws-aw-toggle-clear-loop-points-on-click-in-ruler": 53528,
2359 "sws-aw-toggle-click-track-mute": 53556,
2360 "sws-aw-toggle-count-in-before-playback": 53519,
2361 "sws-aw-toggle-count-in-before-recording": 53522,
2362 "sws-aw-toggle-dotted-grid": 53541,
2363 "sws-aw-toggle-link-time-selection-and-edit-cursor": 53525,
2364 "sws-aw-toggle-metronome-during-playback": 53513,
2365 "sws-aw-toggle-metronome-during-recording": 53516,
2366 "sws-aw-toggle-swing-grid": 53542,
2367 "sws-aw-toggle-triplet-grid": 53540,
2368 "sws-aw-trim-selected-items-to-fill-selection": 53508,
2369 "sws-aw-trim-selected-items-to-selection-or-cursor-crop": 53507,
2370 "sws-br-add-envelope-points-located-between-grid-to-existing-selection": 54041,
2371 "sws-br-add-envelope-points-located-between-grid-to-existing-selection-obey-time-selection-if-any": 54042,
2372 "sws-br-add-envelope-points-located-on-grid-to-existing-selection": 54039,
2373 "sws-br-add-envelope-points-located-on-grid-to-existing-selection-obey-time-selection-if-any": 54040,
2374 "sws-br-adjust-playrate-midi-cc-only": 54782,
2375 "sws-br-adjust-playrate-options": 54783,
2376 "sws-br-alter-slope-of-gradual-tempo-marker-decrease-0-001-bpm": 54826,
2377 "sws-br-alter-slope-of-gradual-tempo-marker-decrease-0-001-percent": 54834,
2378 "sws-br-alter-slope-of-gradual-tempo-marker-decrease-0-01-bpm": 54827,
2379 "sws-br-alter-slope-of-gradual-tempo-marker-decrease-0-01-percent": 54835,
2380 "sws-br-alter-slope-of-gradual-tempo-marker-decrease-0-1-bpm": 54828,
2381 "sws-br-alter-slope-of-gradual-tempo-marker-decrease-0-1-percent": 54836,
2382 "sws-br-alter-slope-of-gradual-tempo-marker-decrease-01-bpm": 54829,
2383 "sws-br-alter-slope-of-gradual-tempo-marker-decrease-01-percent": 54837,
2384 "sws-br-alter-slope-of-gradual-tempo-marker-increase-0-001-bpm": 54822,
2385 "sws-br-alter-slope-of-gradual-tempo-marker-increase-0-001-percent": 54830,
2386 "sws-br-alter-slope-of-gradual-tempo-marker-increase-0-01-bpm": 54823,
2387 "sws-br-alter-slope-of-gradual-tempo-marker-increase-0-01-percent": 54831,
2388 "sws-br-alter-slope-of-gradual-tempo-marker-increase-0-1-bpm": 54824,
2389 "sws-br-alter-slope-of-gradual-tempo-marker-increase-0-1-percent": 54832,
2390 "sws-br-alter-slope-of-gradual-tempo-marker-increase-01-bpm": 54825,
2391 "sws-br-alter-slope-of-gradual-tempo-marker-increase-01-percent": 54833,
2392 "sws-br-analyze-loudness": 54171,
2393 "sws-br-apply-next-action-to-all-visible-envelopes-in-selected-tracks": 54100,
2394 "sws-br-apply-next-action-to-all-visible-envelopes-in-selected-tracks-if-there-is-no-track-envelope-selected": 54102,
2395 "sws-br-apply-next-action-to-all-visible-record-armed-envelopes-in-selected-tracks": 54101,
2396 "sws-br-apply-next-action-to-all-visible-record-armed-envelopes-in-selected-tracks-if-there-is-no-track-envelope-selected":
2397 54103,
2398 "sws-br-check-for-new-sws-version": 55821,
2399 "sws-br-contextual-toolbars": 53938,
2400 "sws-br-convert-project-markers-to-tempo-markers": 54846,
2401 "sws-br-convert-selected-envelope-s-curve-in-time-selection-to-cc-events-in-last-clicked-cc-lane-in-last-active-midi-editor":
2402 54051,
2403 "sws-br-convert-selected-envelope-s-curve-in-time-selection-to-cc-events-in-last-clicked-cc-lane-in-last-active-midi-editor-clear-existing-events":
2404 54052,
2405 "sws-br-convert-selected-points-in-selected-envelope-to-cc-events-in-last-clicked-cc-lane-in-last-active-midi-editor":
2406 54049,
2407 "sws-br-convert-selected-points-in-selected-envelope-to-cc-events-in-last-clicked-cc-lane-in-last-active-midi-editor-clear-existing-events":
2408 54050,
2409 "sws-br-copy-points-in-time-selection-in-selected-envelope-to-all-visible-envelopes-in-selected-tracks": 54072,
2410 "sws-br-copy-points-in-time-selection-in-selected-envelope-to-all-visible-envelopes-in-selected-tracks-paste-at-edit-cursor":
2411 54076,
2412 "sws-br-copy-points-in-time-selection-in-selected-envelope-to-all-visible-record-armed-in-envelopes-of-selected-tracks":
2413 54073,
2414 "sws-br-copy-points-in-time-selection-in-selected-envelope-to-all-visible-record-armed-in-envelopes-of-selected-tracks-paste-at-edit-cursor":
2415 54077,
2416 "sws-br-copy-points-in-time-selection-in-selected-envelope-to-envelope-at-mouse-cursor": 54079,
2417 "sws-br-copy-points-in-time-selection-in-selected-envelope-to-envelope-at-mouse-cursor-paste-at-edit-cursor": 54081,
2418 "sws-br-copy-selected-points-in-selected-envelope-to-all-visible-envelopes-in-selected-tracks": 54070,
2419 "sws-br-copy-selected-points-in-selected-envelope-to-all-visible-envelopes-in-selected-tracks-paste-at-edit-cursor":
2420 54074,
2421 "sws-br-copy-selected-points-in-selected-envelope-to-all-visible-record-armed-envelopes-in-selected-tracks": 54071,
2422 "sws-br-copy-selected-points-in-selected-envelope-to-all-visible-record-armed-envelopes-in-selected-tracks-paste-at-edit-cursor":
2423 54075,
2424 "sws-br-copy-selected-points-in-selected-envelope-to-envelope-at-mouse-cursor": 54078,
2425 "sws-br-copy-selected-points-in-selected-envelope-to-to-envelope-at-mouse-cursor-paste-at-edit-cursor": 54080,
2426 "sws-br-copy-take-media-source-file-path-of-selected-items-to-clipboard": 54503,
2427 "sws-br-create-project-marker-at-mouse-cursor": 54472,
2428 "sws-br-create-project-marker-at-mouse-cursor-obey-snapping": 54473,
2429 "sws-br-create-project-markers-from-notes-in-selected-midi-items": 54470,
2430 "sws-br-create-project-markers-from-selected-items-name-by-item-s-notes": 54474,
2431 "sws-br-create-project-markers-from-selected-tempo-markers": 54464,
2432 "sws-br-create-project-markers-from-stretch-markers-in-selected-items": 54471,
2433 "sws-br-create-regions-from-selected-items-name-by-item-s-notes": 54475,
2434 "sws-br-create-tempo-markers-at-grid-after-every-selected-tempo-marker": 54841,
2435 "sws-br-decrease-selected-envelope-points-by-0-1-db-volume-envelope-only": 54089,
2436 "sws-br-decrease-selected-envelope-points-by-0-5-db-volume-envelope-only": 54090,
2437 "sws-br-decrease-selected-envelope-points-by-1-db-volume-envelope-only": 54091,
2438 "sws-br-decrease-selected-envelope-points-by-10-db-volume-envelope-only": 54093,
2439 "sws-br-decrease-selected-envelope-points-by-5-db-volume-envelope-only": 54092,
2440 "sws-br-decrease-tempo-marker-0-001-bpm-preserve-overall-tempo": 54810,
2441 "sws-br-decrease-tempo-marker-0-001-percent-preserve-overall-tempo": 54818,
2442 "sws-br-decrease-tempo-marker-0-01-bpm-preserve-overall-tempo": 54811,
2443 "sws-br-decrease-tempo-marker-0-01-percent-preserve-overall-tempo": 54819,
2444 "sws-br-decrease-tempo-marker-0-1-bpm-preserve-overall-tempo": 54812,
2445 "sws-br-decrease-tempo-marker-0-1-percent-preserve-overall-tempo": 54820,
2446 "sws-br-decrease-tempo-marker-01-bpm-preserve-overall-tempo": 54813,
2447 "sws-br-decrease-tempo-marker-01-percent-preserve-overall-tempo": 54821,
2448 "sws-br-delete-envelope-point-at-mouse-cursor": 54098,
2449 "sws-br-delete-envelope-point-at-mouse-cursor-selected-envelope-only": 54097,
2450 "sws-br-delete-envelope-points-between-grid": 54045,
2451 "sws-br-delete-envelope-points-between-grid-obey-time-selection-if-any": 54046,
2452 "sws-br-delete-envelope-points-on-grid": 54043,
2453 "sws-br-delete-envelope-points-on-grid-obey-time-selection-if-any": 54044,
2454 "sws-br-delete-take-under-mouse-cursor": 54504,
2455 "sws-br-delete-tempo-marker-and-preserve-position-and-length-of-items-including-midi-events": 54839,
2456 "sws-br-delete-tempo-marker-and-preserve-position-and-length-of-selected-items-including-midi-events": 54840,
2457 "sws-br-delete-tempo-marker-preserve-overall-tempo-and-positions-if-possible": 54838,
2458 "sws-br-disable-ignore-project-tempo-for-selected-midi-items": 54465,
2459 "sws-br-disable-ignore-project-tempo-for-selected-midi-items-preserving-time-position-of-midi-events": 54466,
2460 "sws-br-enable-ignore-project-tempo-for-selected-midi-items-preserving-time-position-of-midi-events-use-tempo-at-item-s-start":
2461 54468,
2462 "sws-br-enable-ignore-project-tempo-for-selected-midi-items-use-tempo-at-item-s-start": 54467,
2463 "sws-br-exclusive-toggle-contextual-toolbar-under-mouse-cursor-preset-1": 53979,
2464 "sws-br-exclusive-toggle-contextual-toolbar-under-mouse-cursor-preset-2": 53984,
2465 "sws-br-exclusive-toggle-contextual-toolbar-under-mouse-cursor-preset-3": 53989,
2466 "sws-br-exclusive-toggle-contextual-toolbar-under-mouse-cursor-preset-4": 53994,
2467 "sws-br-exclusive-toggle-contextual-toolbar-under-mouse-cursor-preset-5": 53999,
2468 "sws-br-exclusive-toggle-contextual-toolbar-under-mouse-cursor-preset-6": 54004,
2469 "sws-br-exclusive-toggle-contextual-toolbar-under-mouse-cursor-preset-7": 54009,
2470 "sws-br-exclusive-toggle-contextual-toolbar-under-mouse-cursor-preset-8": 54014,
2471 "sws-br-expand-envelope-point-selection-to-the-left": 54028,
2472 "sws-br-expand-envelope-point-selection-to-the-left-end-point-only": 54030,
2473 "sws-br-expand-envelope-point-selection-to-the-right": 54027,
2474 "sws-br-expand-envelope-point-selection-to-the-right-end-point-only": 54029,
2475 "sws-br-fit-selected-envelope-points-to-time-selection": 54082,
2476 "sws-br-focus-arrange": 54482,
2477 "sws-br-focus-tracks": 54483,
2478 "sws-br-freehand-draw-envelope-while-snapping-points-to-left-side-grid-line-perform-until-shortcut-released": 54854,
2479 "sws-br-global-loudness-preferences": 54170,
2480 "sws-br-hide-all-but-selected-track-envelope-for-all-tracks": 54136,
2481 "sws-br-hide-all-but-selected-track-envelope-for-all-tracks-except-envelopes-in-separate-lanes": 54138,
2482 "sws-br-hide-all-but-selected-track-envelope-for-all-tracks-except-envelopes-in-track-lanes": 54140,
2483 "sws-br-hide-all-but-selected-track-envelope-for-selected-tracks": 54137,
2484 "sws-br-hide-all-but-selected-track-envelope-for-selected-tracks-except-envelopes-in-separate-lanes": 54139,
2485 "sws-br-hide-all-but-selected-track-envelope-for-selected-tracks-except-envelopes-in-track-lanes": 54141,
2486 "sws-br-hide-all-fx-envelopes-for-selected-tracks": 54149,
2487 "sws-br-hide-all-send-envelopes-for-selected-tracks": 54166,
2488 "sws-br-hide-mute-send-envelopes-for-selected-tracks": 54169,
2489 "sws-br-hide-pan-send-envelopes-for-selected-tracks": 54168,
2490 "sws-br-hide-volume-send-envelopes-for-selected-tracks": 54167,
2491 "sws-br-increase-selected-envelope-points-by-0-1-db-volume-envelope-only": 54084,
2492 "sws-br-increase-selected-envelope-points-by-0-5-db-volume-envelope-only": 54085,
2493 "sws-br-increase-selected-envelope-points-by-1-db-volume-envelope-only": 54086,
2494 "sws-br-increase-selected-envelope-points-by-10-db-volume-envelope-only": 54088,
2495 "sws-br-increase-selected-envelope-points-by-5-db-volume-envelope-only": 54087,
2496 "sws-br-increase-tempo-marker-0-001-bpm-preserve-overall-tempo": 54806,
2497 "sws-br-increase-tempo-marker-0-001-percent-preserve-overall-tempo": 54814,
2498 "sws-br-increase-tempo-marker-0-01-bpm-preserve-overall-tempo": 54807,
2499 "sws-br-increase-tempo-marker-0-01-percent-preserve-overall-tempo": 54815,
2500 "sws-br-increase-tempo-marker-0-1-bpm-preserve-overall-tempo": 54808,
2501 "sws-br-increase-tempo-marker-0-1-percent-preserve-overall-tempo": 54816,
2502 "sws-br-increase-tempo-marker-01-bpm-preserve-overall-tempo": 54809,
2503 "sws-br-increase-tempo-marker-01-percent-preserve-overall-tempo": 54817,
2504 "sws-br-insert-2-envelope-points-at-time-selection": 54067,
2505 "sws-br-insert-2-envelope-points-at-time-selection-to-all-visible-track-envelopes": 54068,
2506 "sws-br-insert-2-envelope-points-at-time-selection-to-all-visible-track-envelopes-in-selected-tracks": 54069,
2507 "sws-br-insert-envelope-points-on-grid-using-shape-of-the-previous-point": 54047,
2508 "sws-br-insert-envelope-points-on-grid-using-shape-of-the-previous-point-obey-time-selection-if-any": 54048,
2509 "sws-br-insert-new-envelope-point-at-mouse-cursor-using-value-at-current-position-obey-snapping": 54083,
2510 "sws-br-move-active-floating-track-fx-window-to-mouse-cursor-horizontal-left-vertical-bottom": 54493,
2511 "sws-br-move-active-floating-track-fx-window-to-mouse-cursor-horizontal-left-vertical-middle": 54494,
2512 "sws-br-move-active-floating-track-fx-window-to-mouse-cursor-horizontal-left-vertical-top": 54495,
2513 "sws-br-move-active-floating-track-fx-window-to-mouse-cursor-horizontal-middle-vertical-bottom": 54496,
2514 "sws-br-move-active-floating-track-fx-window-to-mouse-cursor-horizontal-middle-vertical-middle": 54497,
2515 "sws-br-move-active-floating-track-fx-window-to-mouse-cursor-horizontal-middle-vertical-top": 54498,
2516 "sws-br-move-active-floating-track-fx-window-to-mouse-cursor-horizontal-right-vertical-bottom": 54499,
2517 "sws-br-move-active-floating-track-fx-window-to-mouse-cursor-horizontal-right-vertical-middle": 54500,
2518 "sws-br-move-active-floating-track-fx-window-to-mouse-cursor-horizontal-right-vertical-top": 54501,
2519 "sws-br-move-active-floating-window-to-mouse-cursor-horizontal-left-vertical-bottom": 54484,
2520 "sws-br-move-active-floating-window-to-mouse-cursor-horizontal-left-vertical-middle": 54485,
2521 "sws-br-move-active-floating-window-to-mouse-cursor-horizontal-left-vertical-top": 54486,
2522 "sws-br-move-active-floating-window-to-mouse-cursor-horizontal-middle-vertical-bottom": 54487,
2523 "sws-br-move-active-floating-window-to-mouse-cursor-horizontal-middle-vertical-middle": 54488,
2524 "sws-br-move-active-floating-window-to-mouse-cursor-horizontal-middle-vertical-top": 54489,
2525 "sws-br-move-active-floating-window-to-mouse-cursor-horizontal-right-vertical-bottom": 54490,
2526 "sws-br-move-active-floating-window-to-mouse-cursor-horizontal-right-vertical-middle": 54491,
2527 "sws-br-move-active-floating-window-to-mouse-cursor-horizontal-right-vertical-top": 54492,
2528 "sws-br-move-closest-envelope-point-to-edit-cursor": 54065,
2529 "sws-br-move-closest-grid-line-to-edit-cursor": 54787,
2530 "sws-br-move-closest-grid-line-to-mouse-cursor-perform-until-shortcut-released": 54871,
2531 "sws-br-move-closest-grid-line-to-play-cursor": 54788,
2532 "sws-br-move-closest-left-side-grid-line-to-edit-cursor": 54791,
2533 "sws-br-move-closest-measure-grid-line-to-edit-cursor": 54789,
2534 "sws-br-move-closest-measure-grid-line-to-mouse-cursor-perform-until-shortcut-released": 54872,
2535 "sws-br-move-closest-measure-grid-line-to-play-cursor": 54790,
2536 "sws-br-move-closest-project-marker-to-edit-cursor": 54477,
2537 "sws-br-move-closest-project-marker-to-edit-cursor-obey-snapping": 54480,
2538 "sws-br-move-closest-project-marker-to-mouse-cursor": 54478,
2539 "sws-br-move-closest-project-marker-to-mouse-cursor-obey-snapping": 54481,
2540 "sws-br-move-closest-project-marker-to-play-cursor": 54476,
2541 "sws-br-move-closest-project-marker-to-play-cursor-obey-snapping": 54479,
2542 "sws-br-move-closest-right-side-grid-line-to-edit-cursor": 54792,
2543 "sws-br-move-closest-selected-envelope-point-to-edit-cursor": 54066,
2544 "sws-br-move-closest-tempo-marker-to-edit-cursor": 54805,
2545 "sws-br-move-closest-tempo-marker-to-mouse-cursor-perform-until-shortcut-released": 54870,
2546 "sws-br-move-edit-cursor-to-next-envelope-point": 54019,
2547 "sws-br-move-edit-cursor-to-next-envelope-point-and-add-to-selection": 54021,
2548 "sws-br-move-edit-cursor-to-next-envelope-point-and-select-it": 54020,
2549 "sws-br-move-edit-cursor-to-previous-envelope-point": 54022,
2550 "sws-br-move-edit-cursor-to-previous-envelope-point-and-add-to-selection": 54024,
2551 "sws-br-move-edit-cursor-to-previous-envelope-point-and-select-it": 54023,
2552 "sws-br-move-tempo-marker-back": 54804,
2553 "sws-br-move-tempo-marker-back-0-1-ms": 54798,
2554 "sws-br-move-tempo-marker-back-1-ms": 54799,
2555 "sws-br-move-tempo-marker-back-10-ms": 54800,
2556 "sws-br-move-tempo-marker-back-100-ms": 54801,
2557 "sws-br-move-tempo-marker-back-1000-ms": 54802,
2558 "sws-br-move-tempo-marker-forward": 54803,
2559 "sws-br-move-tempo-marker-forward-0-1-ms": 54793,
2560 "sws-br-move-tempo-marker-forward-1-ms": 54794,
2561 "sws-br-move-tempo-marker-forward-10-ms": 54795,
2562 "sws-br-move-tempo-marker-forward-100-ms": 54796,
2563 "sws-br-move-tempo-marker-forward-1000-ms": 54797,
2564 "sws-br-nf-toggle-use-dual-mono-mode-for-mono-takes-channel-modes-for-loudness-analyzing": 54178,
2565 "sws-br-nf-toggle-use-high-precision-mode-for-loudness-analyzing": 54177,
2566 "sws-br-normalize-loudness-of-selected-items-to-0-lu": 54174,
2567 "sws-br-normalize-loudness-of-selected-items-to-23-lufs": 54173,
2568 "sws-br-normalize-loudness-of-selected-items-tracks": 54172,
2569 "sws-br-normalize-loudness-of-selected-tracks-to-0-lu": 54176,
2570 "sws-br-normalize-loudness-of-selected-tracks-to-23-lufs": 54175,
2571 "sws-br-open-close-contextual-toolbar-under-mouse-cursor-preset-1": 53939,
2572 "sws-br-open-close-contextual-toolbar-under-mouse-cursor-preset-2": 53944,
2573 "sws-br-open-close-contextual-toolbar-under-mouse-cursor-preset-3": 53949,
2574 "sws-br-open-close-contextual-toolbar-under-mouse-cursor-preset-4": 53954,
2575 "sws-br-open-close-contextual-toolbar-under-mouse-cursor-preset-5": 53959,
2576 "sws-br-open-close-contextual-toolbar-under-mouse-cursor-preset-6": 53964,
2577 "sws-br-open-close-contextual-toolbar-under-mouse-cursor-preset-7": 53969,
2578 "sws-br-open-close-contextual-toolbar-under-mouse-cursor-preset-8": 53974,
2579 "sws-br-options-automatically-insert-stretch-markers-when-inserting-tempo-markers-with-sws-actions": 54720,
2580 "sws-br-options-cycle-through-record-modes": 54721,
2581 "sws-br-options-set-apply-trim-when-adding-volume-pan-envelopes-to-always": 54685,
2582 "sws-br-options-set-apply-trim-when-adding-volume-pan-envelopes-to-in-read-write": 54686,
2583 "sws-br-options-set-apply-trim-when-adding-volume-pan-envelopes-to-never": 54687,
2584 "sws-br-options-set-grid-line-z-order-to-over-items": 54714,
2585 "sws-br-options-set-grid-line-z-order-to-through-items": 54715,
2586 "sws-br-options-set-grid-line-z-order-to-under-items": 54716,
2587 "sws-br-options-set-marker-line-z-order-to-over-items": 54717,
2588 "sws-br-options-set-marker-line-z-order-to-through-items": 54718,
2589 "sws-br-options-set-marker-line-z-order-to-under-items": 54719,
2590 "sws-br-options-set-run-fx-after-stopping-for-to-0-ms": 54696,
2591 "sws-br-options-set-run-fx-after-stopping-for-to-100-ms": 54697,
2592 "sws-br-options-set-run-fx-after-stopping-for-to-1000-ms": 54699,
2593 "sws-br-options-set-run-fx-after-stopping-for-to-10000-ms": 54708,
2594 "sws-br-options-set-run-fx-after-stopping-for-to-2000-ms": 54700,
2595 "sws-br-options-set-run-fx-after-stopping-for-to-3000-ms": 54701,
2596 "sws-br-options-set-run-fx-after-stopping-for-to-4000-ms": 54702,
2597 "sws-br-options-set-run-fx-after-stopping-for-to-500-ms": 54698,
2598 "sws-br-options-set-run-fx-after-stopping-for-to-5000-ms": 54703,
2599 "sws-br-options-set-run-fx-after-stopping-for-to-6000-ms": 54704,
2600 "sws-br-options-set-run-fx-after-stopping-for-to-7000-ms": 54705,
2601 "sws-br-options-set-run-fx-after-stopping-for-to-8000-ms": 54706,
2602 "sws-br-options-set-run-fx-after-stopping-for-to-9000-ms": 54707,
2603 "sws-br-options-toggle-display-media-item-gain-if-set": 54690,
2604 "sws-br-options-toggle-display-media-item-pitch-playrate-if-set": 54689,
2605 "sws-br-options-toggle-display-media-item-take-name": 54688,
2606 "sws-br-options-toggle-flush-fx-on-stop": 54694,
2607 "sws-br-options-toggle-flush-fx-when-looping": 54695,
2608 "sws-br-options-toggle-grid-snap-settings-follow-grid-visibility": 54683,
2609 "sws-br-options-toggle-move-edit-cursor-to-end-of-recorded-items-on-record-stop": 54711,
2610 "sws-br-options-toggle-move-edit-cursor-to-start-of-time-selection-on-time-selection-change": 54709,
2611 "sws-br-options-toggle-move-edit-cursor-when-pasting-inserting-media": 54710,
2612 "sws-br-options-toggle-playback-position-follows-project-timebase-when-changing-tempo": 54684,
2613 "sws-br-options-toggle-reset-cc-on-stop-play": 54693,
2614 "sws-br-options-toggle-reset-pitch-on-stop-play": 54692,
2615 "sws-br-options-toggle-scroll-view-to-edit-cursor-on-stop": 54713,
2616 "sws-br-options-toggle-send-all-notes-off-on-stop-play": 54691,
2617 "sws-br-options-toggle-stop-repeat-playback-at-end-of-project": 54712,
2618 "sws-br-play-from-edit-cursor-position-and-solo-item-and-track-under-mouse-for-the-duration-perform-until-shortcut-released":
2619 54863,
2620 "sws-br-play-from-edit-cursor-position-and-solo-track-under-mouse-for-the-duration-perform-until-shortcut-released":
2621 54862,
2622 "sws-br-play-from-edit-cursor-position-perform-until-shortcut-released": 54861,
2623 "sws-br-play-from-mouse-cursor-position": 54459,
2624 "sws-br-play-from-mouse-cursor-position-and-solo-item-and-track-under-mouse-for-the-duration-perform-until-shortcut-released":
2625 54860,
2626 "sws-br-play-from-mouse-cursor-position-and-solo-track-under-mouse-for-the-duration-perform-until-shortcut-released":
2627 54859,
2628 "sws-br-play-from-mouse-cursor-position-perform-until-shortcut-released": 54858,
2629 "sws-br-play-pause-from-mouse-cursor-position": 54460,
2630 "sws-br-play-stop-from-mouse-cursor-position": 54461,
2631 "sws-br-preview-media-item-under-mouse": 54722,
2632 "sws-br-preview-media-item-under-mouse-and-pause-during-preview": 54725,
2633 "sws-br-preview-media-item-under-mouse-and-pause-during-preview-start-from-mouse-cursor-position": 54726,
2634 "sws-br-preview-media-item-under-mouse-at-track-fader-volume": 54727,
2635 "sws-br-preview-media-item-under-mouse-at-track-fader-volume-and-pause-during-preview": 54730,
2636 "sws-br-preview-media-item-under-mouse-at-track-fader-volume-and-pause-during-preview-start-from-mouse-cursor-position":
2637 54731,
2638 "sws-br-preview-media-item-under-mouse-at-track-fader-volume-start-from-mouse-position": 54728,
2639 "sws-br-preview-media-item-under-mouse-at-track-fader-volume-sync-with-next-measure": 54729,
2640 "sws-br-preview-media-item-under-mouse-start-from-mouse-cursor-position": 54723,
2641 "sws-br-preview-media-item-under-mouse-sync-with-next-measure": 54724,
2642 "sws-br-preview-media-item-under-mouse-through-track": 54732,
2643 "sws-br-preview-media-item-under-mouse-through-track-and-pause-during-preview": 54735,
2644 "sws-br-preview-media-item-under-mouse-through-track-and-pause-during-preview-start-from-mouse-cursor-position":
2645 54736,
2646 "sws-br-preview-media-item-under-mouse-through-track-start-from-mouse-position": 54733,
2647 "sws-br-preview-media-item-under-mouse-through-track-sync-with-next-measure": 54734,
2648 "sws-br-preview-take-under-mouse": 54752,
2649 "sws-br-preview-take-under-mouse-and-pause-during-preview": 54755,
2650 "sws-br-preview-take-under-mouse-and-pause-during-preview-start-from-mouse-cursor-position": 54756,
2651 "sws-br-preview-take-under-mouse-at-track-fader-volume": 54757,
2652 "sws-br-preview-take-under-mouse-at-track-fader-volume-and-pause-during-preview": 54760,
2653 "sws-br-preview-take-under-mouse-at-track-fader-volume-and-pause-during-preview-start-from-mouse-cursor-position":
2654 54761,
2655 "sws-br-preview-take-under-mouse-at-track-fader-volume-start-from-mouse-position": 54758,
2656 "sws-br-preview-take-under-mouse-at-track-fader-volume-sync-with-next-measure": 54759,
2657 "sws-br-preview-take-under-mouse-start-from-mouse-cursor-position": 54753,
2658 "sws-br-preview-take-under-mouse-sync-with-next-measure": 54754,
2659 "sws-br-preview-take-under-mouse-through-track": 54762,
2660 "sws-br-preview-take-under-mouse-through-track-and-pause-during-preview": 54765,
2661 "sws-br-preview-take-under-mouse-through-track-and-pause-during-preview-start-from-mouse-cursor-position": 54766,
2662 "sws-br-preview-take-under-mouse-through-track-start-from-mouse-position": 54763,
2663 "sws-br-preview-take-under-mouse-through-track-sync-with-next-measure": 54764,
2664 "sws-br-project-track-selection-action-clear": 54786,
2665 "sws-br-project-track-selection-action-set": 54784,
2666 "sws-br-project-track-selection-action-show": 54785,
2667 "sws-br-randomize-tempo-markers": 54848,
2668 "sws-br-reset-position-of-selected-partial-time-signature-markers": 54843,
2669 "sws-br-restore-edit-cursor-position-slot-01": 54539,
2670 "sws-br-restore-edit-cursor-position-slot-02": 54540,
2671 "sws-br-restore-edit-cursor-position-slot-03": 54541,
2672 "sws-br-restore-edit-cursor-position-slot-04": 54542,
2673 "sws-br-restore-edit-cursor-position-slot-05": 54543,
2674 "sws-br-restore-edit-cursor-position-slot-06": 54544,
2675 "sws-br-restore-edit-cursor-position-slot-07": 54545,
2676 "sws-br-restore-edit-cursor-position-slot-08": 54546,
2677 "sws-br-restore-edit-cursor-position-slot-09": 54547,
2678 "sws-br-restore-edit-cursor-position-slot-10": 54548,
2679 "sws-br-restore-edit-cursor-position-slot-11": 54549,
2680 "sws-br-restore-edit-cursor-position-slot-12": 54550,
2681 "sws-br-restore-edit-cursor-position-slot-13": 54551,
2682 "sws-br-restore-edit-cursor-position-slot-14": 54552,
2683 "sws-br-restore-edit-cursor-position-slot-15": 54553,
2684 "sws-br-restore-edit-cursor-position-slot-16": 54554,
2685 "sws-br-restore-envelope-point-selection-slot-01": 54120,
2686 "sws-br-restore-envelope-point-selection-slot-02": 54121,
2687 "sws-br-restore-envelope-point-selection-slot-03": 54122,
2688 "sws-br-restore-envelope-point-selection-slot-04": 54123,
2689 "sws-br-restore-envelope-point-selection-slot-05": 54124,
2690 "sws-br-restore-envelope-point-selection-slot-06": 54125,
2691 "sws-br-restore-envelope-point-selection-slot-07": 54126,
2692 "sws-br-restore-envelope-point-selection-slot-08": 54127,
2693 "sws-br-restore-envelope-point-selection-slot-09": 54128,
2694 "sws-br-restore-envelope-point-selection-slot-10": 54129,
2695 "sws-br-restore-envelope-point-selection-slot-11": 54130,
2696 "sws-br-restore-envelope-point-selection-slot-12": 54131,
2697 "sws-br-restore-envelope-point-selection-slot-13": 54132,
2698 "sws-br-restore-envelope-point-selection-slot-14": 54133,
2699 "sws-br-restore-envelope-point-selection-slot-15": 54134,
2700 "sws-br-restore-envelope-point-selection-slot-16": 54135,
2701 "sws-br-restore-items-mute-state-to-all-items-slot-01": 54603,
2702 "sws-br-restore-items-mute-state-to-all-items-slot-02": 54604,
2703 "sws-br-restore-items-mute-state-to-all-items-slot-03": 54605,
2704 "sws-br-restore-items-mute-state-to-all-items-slot-04": 54606,
2705 "sws-br-restore-items-mute-state-to-all-items-slot-05": 54607,
2706 "sws-br-restore-items-mute-state-to-all-items-slot-06": 54608,
2707 "sws-br-restore-items-mute-state-to-all-items-slot-07": 54609,
2708 "sws-br-restore-items-mute-state-to-all-items-slot-08": 54610,
2709 "sws-br-restore-items-mute-state-to-all-items-slot-09": 54611,
2710 "sws-br-restore-items-mute-state-to-all-items-slot-10": 54612,
2711 "sws-br-restore-items-mute-state-to-all-items-slot-11": 54613,
2712 "sws-br-restore-items-mute-state-to-all-items-slot-12": 54614,
2713 "sws-br-restore-items-mute-state-to-all-items-slot-13": 54615,
2714 "sws-br-restore-items-mute-state-to-all-items-slot-14": 54616,
2715 "sws-br-restore-items-mute-state-to-all-items-slot-15": 54617,
2716 "sws-br-restore-items-mute-state-to-all-items-slot-16": 54618,
2717 "sws-br-restore-items-mute-state-to-selected-items-slot-01": 54587,
2718 "sws-br-restore-items-mute-state-to-selected-items-slot-02": 54588,
2719 "sws-br-restore-items-mute-state-to-selected-items-slot-03": 54589,
2720 "sws-br-restore-items-mute-state-to-selected-items-slot-04": 54590,
2721 "sws-br-restore-items-mute-state-to-selected-items-slot-05": 54591,
2722 "sws-br-restore-items-mute-state-to-selected-items-slot-06": 54592,
2723 "sws-br-restore-items-mute-state-to-selected-items-slot-07": 54593,
2724 "sws-br-restore-items-mute-state-to-selected-items-slot-08": 54594,
2725 "sws-br-restore-items-mute-state-to-selected-items-slot-09": 54595,
2726 "sws-br-restore-items-mute-state-to-selected-items-slot-10": 54596,
2727 "sws-br-restore-items-mute-state-to-selected-items-slot-11": 54597,
2728 "sws-br-restore-items-mute-state-to-selected-items-slot-12": 54598,
2729 "sws-br-restore-items-mute-state-to-selected-items-slot-13": 54599,
2730 "sws-br-restore-items-mute-state-to-selected-items-slot-14": 54600,
2731 "sws-br-restore-items-mute-state-to-selected-items-slot-15": 54601,
2732 "sws-br-restore-items-mute-state-to-selected-items-slot-16": 54602,
2733 "sws-br-restore-tracks-solo-and-mute-state-to-all-tracks-slot-01": 54667,
2734 "sws-br-restore-tracks-solo-and-mute-state-to-all-tracks-slot-02": 54668,
2735 "sws-br-restore-tracks-solo-and-mute-state-to-all-tracks-slot-03": 54669,
2736 "sws-br-restore-tracks-solo-and-mute-state-to-all-tracks-slot-04": 54670,
2737 "sws-br-restore-tracks-solo-and-mute-state-to-all-tracks-slot-05": 54671,
2738 "sws-br-restore-tracks-solo-and-mute-state-to-all-tracks-slot-06": 54672,
2739 "sws-br-restore-tracks-solo-and-mute-state-to-all-tracks-slot-07": 54673,
2740 "sws-br-restore-tracks-solo-and-mute-state-to-all-tracks-slot-08": 54674,
2741 "sws-br-restore-tracks-solo-and-mute-state-to-all-tracks-slot-09": 54675,
2742 "sws-br-restore-tracks-solo-and-mute-state-to-all-tracks-slot-10": 54676,
2743 "sws-br-restore-tracks-solo-and-mute-state-to-all-tracks-slot-11": 54677,
2744 "sws-br-restore-tracks-solo-and-mute-state-to-all-tracks-slot-12": 54678,
2745 "sws-br-restore-tracks-solo-and-mute-state-to-all-tracks-slot-13": 54679,
2746 "sws-br-restore-tracks-solo-and-mute-state-to-all-tracks-slot-14": 54680,
2747 "sws-br-restore-tracks-solo-and-mute-state-to-all-tracks-slot-15": 54681,
2748 "sws-br-restore-tracks-solo-and-mute-state-to-all-tracks-slot-16": 54682,
2749 "sws-br-restore-tracks-solo-and-mute-state-to-selected-tracks-slot-01": 54651,
2750 "sws-br-restore-tracks-solo-and-mute-state-to-selected-tracks-slot-02": 54652,
2751 "sws-br-restore-tracks-solo-and-mute-state-to-selected-tracks-slot-03": 54653,
2752 "sws-br-restore-tracks-solo-and-mute-state-to-selected-tracks-slot-04": 54654,
2753 "sws-br-restore-tracks-solo-and-mute-state-to-selected-tracks-slot-05": 54655,
2754 "sws-br-restore-tracks-solo-and-mute-state-to-selected-tracks-slot-06": 54656,
2755 "sws-br-restore-tracks-solo-and-mute-state-to-selected-tracks-slot-07": 54657,
2756 "sws-br-restore-tracks-solo-and-mute-state-to-selected-tracks-slot-08": 54658,
2757 "sws-br-restore-tracks-solo-and-mute-state-to-selected-tracks-slot-09": 54659,
2758 "sws-br-restore-tracks-solo-and-mute-state-to-selected-tracks-slot-10": 54660,
2759 "sws-br-restore-tracks-solo-and-mute-state-to-selected-tracks-slot-11": 54661,
2760 "sws-br-restore-tracks-solo-and-mute-state-to-selected-tracks-slot-12": 54662,
2761 "sws-br-restore-tracks-solo-and-mute-state-to-selected-tracks-slot-13": 54663,
2762 "sws-br-restore-tracks-solo-and-mute-state-to-selected-tracks-slot-14": 54664,
2763 "sws-br-restore-tracks-solo-and-mute-state-to-selected-tracks-slot-15": 54665,
2764 "sws-br-restore-tracks-solo-and-mute-state-to-selected-tracks-slot-16": 54666,
2765 "sws-br-save-all-items-mute-state-slot-01": 54571,
2766 "sws-br-save-all-items-mute-state-slot-02": 54572,
2767 "sws-br-save-all-items-mute-state-slot-03": 54573,
2768 "sws-br-save-all-items-mute-state-slot-04": 54574,
2769 "sws-br-save-all-items-mute-state-slot-05": 54575,
2770 "sws-br-save-all-items-mute-state-slot-06": 54576,
2771 "sws-br-save-all-items-mute-state-slot-07": 54577,
2772 "sws-br-save-all-items-mute-state-slot-08": 54578,
2773 "sws-br-save-all-items-mute-state-slot-09": 54579,
2774 "sws-br-save-all-items-mute-state-slot-10": 54580,
2775 "sws-br-save-all-items-mute-state-slot-11": 54581,
2776 "sws-br-save-all-items-mute-state-slot-12": 54582,
2777 "sws-br-save-all-items-mute-state-slot-13": 54583,
2778 "sws-br-save-all-items-mute-state-slot-14": 54584,
2779 "sws-br-save-all-items-mute-state-slot-15": 54585,
2780 "sws-br-save-all-items-mute-state-slot-16": 54586,
2781 "sws-br-save-all-tracks-solo-and-mute-state-slot-01": 54635,
2782 "sws-br-save-all-tracks-solo-and-mute-state-slot-02": 54636,
2783 "sws-br-save-all-tracks-solo-and-mute-state-slot-03": 54637,
2784 "sws-br-save-all-tracks-solo-and-mute-state-slot-04": 54638,
2785 "sws-br-save-all-tracks-solo-and-mute-state-slot-05": 54639,
2786 "sws-br-save-all-tracks-solo-and-mute-state-slot-06": 54640,
2787 "sws-br-save-all-tracks-solo-and-mute-state-slot-07": 54641,
2788 "sws-br-save-all-tracks-solo-and-mute-state-slot-08": 54642,
2789 "sws-br-save-all-tracks-solo-and-mute-state-slot-09": 54643,
2790 "sws-br-save-all-tracks-solo-and-mute-state-slot-10": 54644,
2791 "sws-br-save-all-tracks-solo-and-mute-state-slot-11": 54645,
2792 "sws-br-save-all-tracks-solo-and-mute-state-slot-12": 54646,
2793 "sws-br-save-all-tracks-solo-and-mute-state-slot-13": 54647,
2794 "sws-br-save-all-tracks-solo-and-mute-state-slot-14": 54648,
2795 "sws-br-save-all-tracks-solo-and-mute-state-slot-15": 54649,
2796 "sws-br-save-all-tracks-solo-and-mute-state-slot-16": 54650,
2797 "sws-br-save-edit-cursor-position-slot-01": 54523,
2798 "sws-br-save-edit-cursor-position-slot-02": 54524,
2799 "sws-br-save-edit-cursor-position-slot-03": 54525,
2800 "sws-br-save-edit-cursor-position-slot-04": 54526,
2801 "sws-br-save-edit-cursor-position-slot-05": 54527,
2802 "sws-br-save-edit-cursor-position-slot-06": 54528,
2803 "sws-br-save-edit-cursor-position-slot-07": 54529,
2804 "sws-br-save-edit-cursor-position-slot-08": 54530,
2805 "sws-br-save-edit-cursor-position-slot-09": 54531,
2806 "sws-br-save-edit-cursor-position-slot-10": 54532,
2807 "sws-br-save-edit-cursor-position-slot-11": 54533,
2808 "sws-br-save-edit-cursor-position-slot-12": 54534,
2809 "sws-br-save-edit-cursor-position-slot-13": 54535,
2810 "sws-br-save-edit-cursor-position-slot-14": 54536,
2811 "sws-br-save-edit-cursor-position-slot-15": 54537,
2812 "sws-br-save-edit-cursor-position-slot-16": 54538,
2813 "sws-br-save-envelope-point-selection-slot-01": 54104,
2814 "sws-br-save-envelope-point-selection-slot-02": 54105,
2815 "sws-br-save-envelope-point-selection-slot-03": 54106,
2816 "sws-br-save-envelope-point-selection-slot-04": 54107,
2817 "sws-br-save-envelope-point-selection-slot-05": 54108,
2818 "sws-br-save-envelope-point-selection-slot-06": 54109,
2819 "sws-br-save-envelope-point-selection-slot-07": 54110,
2820 "sws-br-save-envelope-point-selection-slot-08": 54111,
2821 "sws-br-save-envelope-point-selection-slot-09": 54112,
2822 "sws-br-save-envelope-point-selection-slot-10": 54113,
2823 "sws-br-save-envelope-point-selection-slot-11": 54114,
2824 "sws-br-save-envelope-point-selection-slot-12": 54115,
2825 "sws-br-save-envelope-point-selection-slot-13": 54116,
2826 "sws-br-save-envelope-point-selection-slot-14": 54117,
2827 "sws-br-save-envelope-point-selection-slot-15": 54118,
2828 "sws-br-save-envelope-point-selection-slot-16": 54119,
2829 "sws-br-save-selected-items-mute-state-slot-01": 54555,
2830 "sws-br-save-selected-items-mute-state-slot-02": 54556,
2831 "sws-br-save-selected-items-mute-state-slot-03": 54557,
2832 "sws-br-save-selected-items-mute-state-slot-04": 54558,
2833 "sws-br-save-selected-items-mute-state-slot-05": 54559,
2834 "sws-br-save-selected-items-mute-state-slot-06": 54560,
2835 "sws-br-save-selected-items-mute-state-slot-07": 54561,
2836 "sws-br-save-selected-items-mute-state-slot-08": 54562,
2837 "sws-br-save-selected-items-mute-state-slot-09": 54563,
2838 "sws-br-save-selected-items-mute-state-slot-10": 54564,
2839 "sws-br-save-selected-items-mute-state-slot-11": 54565,
2840 "sws-br-save-selected-items-mute-state-slot-12": 54566,
2841 "sws-br-save-selected-items-mute-state-slot-13": 54567,
2842 "sws-br-save-selected-items-mute-state-slot-14": 54568,
2843 "sws-br-save-selected-items-mute-state-slot-15": 54569,
2844 "sws-br-save-selected-items-mute-state-slot-16": 54570,
2845 "sws-br-save-selected-tracks-solo-and-mute-state-slot-01": 54619,
2846 "sws-br-save-selected-tracks-solo-and-mute-state-slot-02": 54620,
2847 "sws-br-save-selected-tracks-solo-and-mute-state-slot-03": 54621,
2848 "sws-br-save-selected-tracks-solo-and-mute-state-slot-04": 54622,
2849 "sws-br-save-selected-tracks-solo-and-mute-state-slot-05": 54623,
2850 "sws-br-save-selected-tracks-solo-and-mute-state-slot-06": 54624,
2851 "sws-br-save-selected-tracks-solo-and-mute-state-slot-07": 54625,
2852 "sws-br-save-selected-tracks-solo-and-mute-state-slot-08": 54626,
2853 "sws-br-save-selected-tracks-solo-and-mute-state-slot-09": 54627,
2854 "sws-br-save-selected-tracks-solo-and-mute-state-slot-10": 54628,
2855 "sws-br-save-selected-tracks-solo-and-mute-state-slot-11": 54629,
2856 "sws-br-save-selected-tracks-solo-and-mute-state-slot-12": 54630,
2857 "sws-br-save-selected-tracks-solo-and-mute-state-slot-13": 54631,
2858 "sws-br-save-selected-tracks-solo-and-mute-state-slot-14": 54632,
2859 "sws-br-save-selected-tracks-solo-and-mute-state-slot-15": 54633,
2860 "sws-br-save-selected-tracks-solo-and-mute-state-slot-16": 54634,
2861 "sws-br-select-all-audio-items": 54508,
2862 "sws-br-select-all-audio-items-obey-time-selection-if-any": 54516,
2863 "sws-br-select-all-click-source-items": 54511,
2864 "sws-br-select-all-click-source-items-obey-time-selection-if-any": 54519,
2865 "sws-br-select-all-empty-items": 54507,
2866 "sws-br-select-all-empty-items-obey-time-selection-if-any": 54515,
2867 "sws-br-select-all-midi-items": 54509,
2868 "sws-br-select-all-midi-items-obey-time-selection-if-any": 54517,
2869 "sws-br-select-all-partial-time-signature-markers": 54842,
2870 "sws-br-select-all-subproject-pip-items": 54513,
2871 "sws-br-select-all-subproject-pip-items-obey-time-selection-if-any": 54521,
2872 "sws-br-select-all-timecode-generator-items": 54512,
2873 "sws-br-select-all-timecode-items-obey-time-selection-if-any": 54520,
2874 "sws-br-select-all-video-items": 54510,
2875 "sws-br-select-all-video-items-obey-time-selection-if-any": 54518,
2876 "sws-br-select-all-video-processor-items": 54514,
2877 "sws-br-select-all-video-processor-items-obey-time-selection-if-any": 54522,
2878 "sws-br-select-and-adjust-tempo-markers": 54847,
2879 "sws-br-select-dips-in-envelope": 54058,
2880 "sws-br-select-dips-in-envelope-add-to-selection": 54057,
2881 "sws-br-select-envelope-at-mouse-cursor": 54094,
2882 "sws-br-select-envelope-at-mouse-cursor-and-freehand-draw-envelope-while-snapping-points-to-left-side-grid-line-perform-until-shortcut-released":
2883 54857,
2884 "sws-br-select-envelope-at-mouse-cursor-and-set-closest-envelope-point-s-value-to-mouse-cursor-perform-until-shortcut-released":
2885 54855,
2886 "sws-br-select-envelope-at-mouse-cursor-and-set-closest-left-side-envelope-point-s-value-to-mouse-cursor-perform-until-shortcut-released":
2887 54856,
2888 "sws-br-select-envelope-point-at-mouse-cursor": 54096,
2889 "sws-br-select-envelope-point-at-mouse-cursor-selected-envelope-only": 54095,
2890 "sws-br-select-envelope-points-between-grid": 54037,
2891 "sws-br-select-envelope-points-between-grid-obey-time-selection-if-any": 54038,
2892 "sws-br-select-envelope-points-on-grid": 54035,
2893 "sws-br-select-envelope-points-on-grid-obey-time-selection-if-any": 54036,
2894 "sws-br-select-mcp-track-under-mouse-cursor": 54506,
2895 "sws-br-select-next-envelope-point": 54025,
2896 "sws-br-select-peaks-in-envelope": 54056,
2897 "sws-br-select-peaks-in-envelope-add-to-selection": 54055,
2898 "sws-br-select-previous-envelope-point": 54026,
2899 "sws-br-select-tcp-track-under-mouse-cursor": 54505,
2900 "sws-br-set-closest-envelope-point-s-value-to-mouse-cursor-perform-until-shortcut-released": 54852,
2901 "sws-br-set-closest-left-side-envelope-point-s-value-to-mouse-cursor-perform-until-shortcut-released": 54853,
2902 "sws-br-set-selected-envelope-points-to-first-selected-point-s-value": 54064,
2903 "sws-br-set-selected-envelope-points-to-last-selected-point-s-value": 54063,
2904 "sws-br-set-selected-envelope-points-to-next-point-s-value": 54061,
2905 "sws-br-set-selected-envelope-points-to-previous-point-s-value": 54062,
2906 "sws-br-set-tempo-marker-shape-options": 54849,
2907 "sws-br-set-tempo-marker-shape-to-linear-preserve-positions": 54850,
2908 "sws-br-set-tempo-marker-shape-to-square-preserve-positions": 54851,
2909 "sws-br-shift-envelope-point-selection-left": 54053,
2910 "sws-br-shift-envelope-point-selection-right": 54054,
2911 "sws-br-show-active-mute-send-envelopes-for-selected-tracks": 54157,
2912 "sws-br-show-active-pan-send-envelopes-for-selected-tracks": 54156,
2913 "sws-br-show-active-volume-send-envelopes-for-selected-tracks": 54155,
2914 "sws-br-show-all-active-fx-envelopes-for-selected-tracks": 54147,
2915 "sws-br-show-all-active-send-envelopes-for-selected-tracks": 54154,
2916 "sws-br-show-all-fx-envelopes-for-selected-tracks": 54148,
2917 "sws-br-show-all-send-envelopes-for-selected-tracks": 54162,
2918 "sws-br-show-hide-pan-track-envelope-for-last-adjusted-send": 54144,
2919 "sws-br-show-hide-track-envelope-for-last-adjusted-send-volume-pan-only": 54142,
2920 "sws-br-show-hide-volume-track-envelope-for-last-adjusted-send": 54143,
2921 "sws-br-show-mute-send-envelopes-for-selected-tracks": 54165,
2922 "sws-br-show-pan-send-envelopes-for-selected-tracks": 54164,
2923 "sws-br-show-volume-send-envelopes-for-selected-tracks": 54163,
2924 "sws-br-shrink-envelope-point-selection-from-the-left": 54032,
2925 "sws-br-shrink-envelope-point-selection-from-the-left-end-point-only": 54034,
2926 "sws-br-shrink-envelope-point-selection-from-the-right": 54031,
2927 "sws-br-shrink-envelope-point-selection-from-the-right-end-point-only": 54033,
2928 "sws-br-snap-position-of-selected-partial-time-signature-markers-to-closest-grid-line": 54844,
2929 "sws-br-split-selected-items-at-stretch-markers": 54463,
2930 "sws-br-split-selected-items-at-tempo-markers": 54462,
2931 "sws-br-tempo-help": 54845,
2932 "sws-br-toggle-media-item-online-offline": 54502,
2933 "sws-br-toggle-play-from-edit-cursor-position-and-solo-item-and-track-under-mouse-for-the-duration": 54458,
2934 "sws-br-toggle-play-from-edit-cursor-position-and-solo-track-under-mouse-for-the-duration": 54457,
2935 "sws-br-toggle-play-from-mouse-cursor-position": 54454,
2936 "sws-br-toggle-play-from-mouse-cursor-position-and-solo-item-and-track-under-mouse-for-the-duration": 54456,
2937 "sws-br-toggle-play-from-mouse-cursor-position-and-solo-track-under-mouse-for-the-duration": 54455,
2938 "sws-br-toggle-preview-media-item-under-mouse": 54737,
2939 "sws-br-toggle-preview-media-item-under-mouse-and-pause-during-preview": 54740,
2940 "sws-br-toggle-preview-media-item-under-mouse-and-pause-during-preview-start-from-mouse-cursor-position": 54741,
2941 "sws-br-toggle-preview-media-item-under-mouse-at-track-fader-volume": 54742,
2942 "sws-br-toggle-preview-media-item-under-mouse-at-track-fader-volume-and-pause-during-preview": 54745,
2943 "sws-br-toggle-preview-media-item-under-mouse-at-track-fader-volume-and-pause-during-preview-start-from-mouse-cursor-position":
2944 54746,
2945 "sws-br-toggle-preview-media-item-under-mouse-at-track-fader-volume-start-from-mouse-position": 54743,
2946 "sws-br-toggle-preview-media-item-under-mouse-at-track-fader-volume-sync-with-next-measure": 54744,
2947 "sws-br-toggle-preview-media-item-under-mouse-start-from-mouse-position": 54738,
2948 "sws-br-toggle-preview-media-item-under-mouse-sync-with-next-measure": 54739,
2949 "sws-br-toggle-preview-media-item-under-mouse-through-track": 54747,
2950 "sws-br-toggle-preview-media-item-under-mouse-through-track-and-pause-during-preview": 54750,
2951 "sws-br-toggle-preview-media-item-under-mouse-through-track-and-pause-during-preview-start-from-mouse-cursor-position":
2952 54751,
2953 "sws-br-toggle-preview-media-item-under-mouse-through-track-start-from-mouse-position": 54748,
2954 "sws-br-toggle-preview-media-item-under-mouse-through-track-sync-with-next-measure": 54749,
2955 "sws-br-toggle-preview-take-under-mouse": 54767,
2956 "sws-br-toggle-preview-take-under-mouse-and-pause-during-preview": 54770,
2957 "sws-br-toggle-preview-take-under-mouse-and-pause-during-preview-start-from-mouse-cursor-position": 54771,
2958 "sws-br-toggle-preview-take-under-mouse-at-track-fader-volume": 54772,
2959 "sws-br-toggle-preview-take-under-mouse-at-track-fader-volume-and-pause-during-preview": 54775,
2960 "sws-br-toggle-preview-take-under-mouse-at-track-fader-volume-and-pause-during-preview-start-from-mouse-cursor-position":
2961 54776,
2962 "sws-br-toggle-preview-take-under-mouse-at-track-fader-volume-start-from-mouse-position": 54773,
2963 "sws-br-toggle-preview-take-under-mouse-at-track-fader-volume-sync-with-next-measure": 54774,
2964 "sws-br-toggle-preview-take-under-mouse-start-from-mouse-position": 54768,
2965 "sws-br-toggle-preview-take-under-mouse-sync-with-next-measure": 54769,
2966 "sws-br-toggle-preview-take-under-mouse-through-track": 54777,
2967 "sws-br-toggle-preview-take-under-mouse-through-track-and-pause-during-preview": 54780,
2968 "sws-br-toggle-preview-take-under-mouse-through-track-and-pause-during-preview-start-from-mouse-cursor-position":
2969 54781,
2970 "sws-br-toggle-preview-take-under-mouse-through-track-start-from-mouse-position": 54778,
2971 "sws-br-toggle-preview-take-under-mouse-through-track-sync-with-next-measure": 54779,
2972 "sws-br-toggle-show-active-mute-send-envelopes-for-selected-tracks": 54153,
2973 "sws-br-toggle-show-active-pan-send-envelopes-for-selected-tracks": 54152,
2974 "sws-br-toggle-show-active-volume-send-envelopes-for-selected-tracks": 54151,
2975 "sws-br-toggle-show-all-active-fx-envelopes-for-selected-tracks": 54145,
2976 "sws-br-toggle-show-all-active-send-envelopes-for-selected-tracks": 54150,
2977 "sws-br-toggle-show-all-fx-envelopes-for-selected-tracks": 54146,
2978 "sws-br-toggle-show-all-send-envelopes-for-selected-tracks": 54158,
2979 "sws-br-toggle-show-mute-send-envelopes-for-selected-tracks": 54161,
2980 "sws-br-toggle-show-pan-send-envelopes-for-selected-tracks": 54160,
2981 "sws-br-toggle-show-volume-send-envelopes-for-selected-tracks": 54159,
2982 "sws-br-trim-midi-item-to-active-content": 54469,
2983 "sws-br-unselect-envelope": 54099,
2984 "sws-br-unselect-envelope-points-in-time-selection": 54060,
2985 "sws-br-unselect-envelope-points-outside-time-selection": 54059,
2986 "sws-bypass-fx-on-selected-track-s": 53730,
2987 "sws-clear-all-snapshot-filter-options": 53190,
2988 "sws-clear-all-takes-preserve-pitch": 53624,
2989 "sws-clear-tracklist-filter": 53214,
2990 "sws-convert-markers-to-regions": 53098,
2991 "sws-convert-regions-to-markers": 53099,
2992 "sws-copy-current-snapshot": 53168,
2993 "sws-copy-items-tracks-env-obey-time-selection-razor-edit-areas": 53576,
2994 "sws-copy-marker-set-to-clipboard": 53086,
2995 "sws-copy-markers-in-time-selection-to-clipboard-relative-to-selection-start": 53087,
2996 "sws-copy-new-snapshot-all-track-s": 53170,
2997 "sws-copy-new-snapshot-selected-track-s": 53169,
2998 "sws-create-and-select-first-track": 53742,
2999 "sws-create-regions-from-selected-items-name-by-active-take": 53110,
3000 "sws-crossfade-adjacent-selected-items-move-edges-of-adjacent-items": 53633,
3001 "sws-cut-items-tracks-env-obey-time-selection-razor-edit-areas": 53577,
3002 "sws-decrease-item-rate-by-0-6-percent-10-cents-preserving-length-clear-preserve-pitch": 53635,
3003 "sws-decrease-item-rate-by-6-percent-one-semitone-preserving-length-clear-preserve-pitch": 53637,
3004 "sws-delete-all-items-on-selected-track-s": 53609,
3005 "sws-delete-all-markers": 53093,
3006 "sws-delete-all-regions": 53094,
3007 "sws-delete-all-snapshots": 53175,
3008 "sws-delete-current-snapshot": 53174,
3009 "sws-delete-marker-set": 53085,
3010 "sws-delete-related-project": 53219,
3011 "sws-delete-selected-track-s-from-all-snapshots": 53160,
3012 "sws-delete-selected-track-s-from-current-snapshot": 53159,
3013 "sws-delete-track-s-with-children-prompt": 53743,
3014 "sws-disable-checking-for-duplicate-inputs-when-recording": 53691,
3015 "sws-disable-marker-actions": 53105,
3016 "sws-disable-master-fx": 53735,
3017 "sws-disable-master-parent-send-on-selected-track-s": 53694,
3018 "sws-enable-checking-for-duplicate-inputs-when-recording": 53690,
3019 "sws-enable-marker-actions": 53104,
3020 "sws-enable-master-fx": 53734,
3021 "sws-enable-master-parent-send-on-selected-track-s": 53693,
3022 "sws-export-formatted-marker-list-to-clipboard": 53095,
3023 "sws-export-formatted-marker-list-to-file": 53096,
3024 "sws-exported-marker-list-format": 53097,
3025 "sws-fng-apply-groove-to-selected-media-items-within-16th": 53893,
3026 "sws-fng-apply-groove-to-selected-media-items-within-32nd": 53894,
3027 "sws-fng-apply-groove-to-selected-midi-notes-in-active-midi-editor-within-16th": 53895,
3028 "sws-fng-apply-groove-to-selected-midi-notes-in-active-midi-editor-within-32nd": 53896,
3029 "sws-fng-apply-midi-hardware-emulation-to-selected-midi-takes": 53886,
3030 "sws-fng-apply-selected-groove-use-curent-settings-from-opened-groove-tool": 53911,
3031 "sws-fng-clean-selected-overlapping-media-items-on-same-track": 53863,
3032 "sws-fng-compress-amplitude-of-selected-envelope-points-around-midpoint": 53853,
3033 "sws-fng-contract-selected-media-items": 53857,
3034 "sws-fng-contract-selected-media-items-by-1-2": 53862,
3035 "sws-fng-contract-selected-media-items-fine": 53859,
3036 "sws-fng-cycle-through-cc-lanes-in-active-midi-editor": 53912,
3037 "sws-fng-cycle-through-cc-lanes-in-active-midi-editor-keep-lane-heights-constant": 53913,
3038 "sws-fng-decrease-selected-midi-items-velocity-by-1": 53877,
3039 "sws-fng-decrease-selected-midi-items-velocity-by-10": 53879,
3040 "sws-fng-expand-amplitude-of-selected-envelope-points-around-midpoint": 53852,
3041 "sws-fng-expand-contract-selected-media-items-to-bar": 53860,
3042 "sws-fng-expand-selected-media-items": 53856,
3043 "sws-fng-expand-selected-media-items-by-2": 53861,
3044 "sws-fng-expand-selected-media-items-fine": 53858,
3045 "sws-fng-get-groove-from-selected-media-items": 53897,
3046 "sws-fng-get-groove-from-selected-midi-notes-in-active-midi-editor": 53898,
3047 "sws-fng-hide-unused-cc-lanes-in-active-midi-editor": 53915,
3048 "sws-fng-increase-selected-midi-items-velocity-by-1": 53876,
3049 "sws-fng-increase-selected-midi-items-velocity-by-10": 53878,
3050 "sws-fng-insert-midi-item-with-note-c4-of-size-32nd": 53870,
3051 "sws-fng-legato-selected-media-items-on-same-track": 53864,
3052 "sws-fng-legato-selected-media-items-on-same-track-change-rate": 53865,
3053 "sws-fng-load-groove-template-from-file": 53900,
3054 "sws-fng-midi-hardware-emulation-settings": 53887,
3055 "sws-fng-move-selected-envelope-points-down": 53847,
3056 "sws-fng-move-selected-envelope-points-left-16th": 53843,
3057 "sws-fng-move-selected-envelope-points-left-32nd": 53845,
3058 "sws-fng-move-selected-envelope-points-right-16th": 53842,
3059 "sws-fng-move-selected-envelope-points-right-32nd": 53844,
3060 "sws-fng-move-selected-envelope-points-up": 53846,
3061 "sws-fng-move-selected-items-to-edit-cursor": 53884,
3062 "sws-fng-quantize-item-positions-and-midi-note-positions-to-grid": 53889,
3063 "sws-fng-rotate-selected-media-items-positions": 53866,
3064 "sws-fng-rotate-selected-media-items-positions-and-lengths": 53867,
3065 "sws-fng-rotate-selected-media-items-positions-and-lengths-reverse": 53869,
3066 "sws-fng-rotate-selected-media-items-positions-reverse": 53868,
3067 "sws-fng-save-groove-template-to-file": 53899,
3068 "sws-fng-select-muted-midi-notes-in-active-midi-editor": 53888,
3069 "sws-fng-select-notes-nearest-edit-cursor-in-active-midi-editor": 53890,
3070 "sws-fng-set-groove-marker-start-to-current-bar": 53907,
3071 "sws-fng-set-groove-marker-start-to-edit-cursor": 53906,
3072 "sws-fng-set-selected-midi-items-name-to-first-note": 53875,
3073 "sws-fng-shift-selected-envelope-points-down-on-left": 53851,
3074 "sws-fng-shift-selected-envelope-points-down-on-right": 53849,
3075 "sws-fng-shift-selected-envelope-points-up-on-left": 53850,
3076 "sws-fng-shift-selected-envelope-points-up-on-right": 53848,
3077 "sws-fng-show-current-groove-template": 53901,
3078 "sws-fng-show-groove-tool": 53908,
3079 "sws-fng-show-only-top-cc-lane-in-active-midi-editor": 53916,
3080 "sws-fng-show-only-used-cc-lanes-in-active-midi-editor": 53914,
3081 "sws-fng-time-compress-selected-envelope-points": 53854,
3082 "sws-fng-time-compress-selected-items-by-1-2": 53881,
3083 "sws-fng-time-compress-selected-items-fine": 53883,
3084 "sws-fng-time-stretch-selected-envelope-points": 53855,
3085 "sws-fng-time-stretch-selected-items-by-2": 53880,
3086 "sws-fng-time-stretch-selected-items-fine": 53882,
3087 "sws-fng-toggle-groove-markers": 53902,
3088 "sws-fng-toggle-groove-markers-2x": 53903,
3089 "sws-fng-toggle-groove-markers-4x": 53904,
3090 "sws-fng-toggle-groove-markers-8x": 53905,
3091 "sws-fng-transpose-selected-midi-items-down-a-semitone": 53872,
3092 "sws-fng-transpose-selected-midi-items-down-an-octave": 53874,
3093 "sws-fng-transpose-selected-midi-items-up-a-semitone": 53871,
3094 "sws-fng-transpose-selected-midi-items-up-an-octave": 53873,
3095 "sws-fng-unselect-items-that-do-not-start-in-time-selection": 53885,
3096 "sws-go-to-end-of-project-including-markers-regions": 53100,
3097 "sws-go-to-time-select-next-marker-region": 53101,
3098 "sws-go-to-time-select-previous-marker-region": 53102,
3099 "sws-gofer-split-selected-items-at-mouse-cursor-obey-snapping": 55165,
3100 "sws-hide-all-tracks": 53209,
3101 "sws-hide-dockers": 53684,
3102 "sws-hide-master-track-in-track-control-panel": 53686,
3103 "sws-hide-selected-track-s": 53198,
3104 "sws-hide-selected-track-s-from-mcp": 53201,
3105 "sws-hide-selected-track-s-from-tcp": 53202,
3106 "sws-hide-unselected-track-s": 53213,
3107 "sws-horizontal-scroll-to-put-edit-cursor-at-10-percent": 53780,
3108 "sws-horizontal-scroll-to-put-edit-cursor-at-50-percent": 53781,
3109 "sws-horizontal-scroll-to-put-play-cursor-at-10-percent": 53782,
3110 "sws-horizontal-scroll-to-put-play-cursor-at-50-percent": 53783,
3111 "sws-horizontal-zoom-to-selected-items": 53792,
3112 "sws-ignore-next-marker-action": 53107,
3113 "sws-increase-item-rate-by-0-6-percent-10-cents-preserving-length-clear-preserve-pitch": 53634,
3114 "sws-increase-item-rate-by-6-percent-one-semitone-preserving-length-clear-preserve-pitch": 53636,
3115 "sws-indent-selected-track-s": 53603,
3116 "sws-insert-file-matching-selected-track-s-name": 53613,
3117 "sws-insert-track-above-selected-tracks": 53741,
3118 "sws-ix-import-m3u-pls-playlist": 53937,
3119 "sws-ix-label-processor": 53936,
3120 "sws-load-marker-set": 53083,
3121 "sws-loop-section-of-selected-item-s": 53610,
3122 "sws-make-folder-from-selected-tracks": 53602,
3123 "sws-metronome-disable": 53689,
3124 "sws-metronome-enable": 53688,
3125 "sws-minimize-selected-track-s": 53736,
3126 "sws-move-cursor-and-time-selection-left-to-grid": 53588,
3127 "sws-move-cursor-and-time-selection-right-to-grid": 53589,
3128 "sws-move-cursor-left-1-sample-on-grid": 53590,
3129 "sws-move-cursor-left-1ms": 53592,
3130 "sws-move-cursor-left-5ms": 53594,
3131 "sws-move-cursor-left-by-default-fade-length": 53596,
3132 "sws-move-cursor-right-1-sample-on-grid": 53591,
3133 "sws-move-cursor-right-1ms": 53593,
3134 "sws-move-cursor-right-5ms": 53595,
3135 "sws-move-cursor-right-by-default-fade-length": 53597,
3136 "sws-move-cursor-to-item-peak-sample": 53568,
3137 "sws-move-selected-item-s-left-edge-to-edit-cursor": 53611,
3138 "sws-move-selected-item-s-right-edge-to-edit-cursor": 53612,
3139 "sws-mute-all-receives-for-selected-track-s": 53725,
3140 "sws-mute-all-sends-from-selected-track-s": 53728,
3141 "sws-mute-children-of-selected-folder-s": 53598,
3142 "sws-new-snapshot-all-tracks": 53162,
3143 "sws-new-snapshot-and-edit-name": 53173,
3144 "sws-new-snapshot-selected-track-s": 53163,
3145 "sws-new-snapshot-with-current-settings": 53172,
3146 "sws-nf-bypass-fx-except-vsti-for-selected-tracks": 54923,
3147 "sws-nf-cycle-through-midi-recording-modes": 54931,
3148 "sws-nf-cycle-through-track-automation-modes": 54933,
3149 "sws-nf-disable-multichannel-metering-all-tracks": 54927,
3150 "sws-nf-disable-multichannel-metering-selected-tracks": 54928,
3151 "sws-nf-enable-multichannel-metering-all-tracks": 54929,
3152 "sws-nf-enable-multichannel-metering-selected-tracks": 54930,
3153 "sws-nf-eraser-tool-marquee-sel-items-and-time-cut-on-shortcut-release": 54874,
3154 "sws-nf-eraser-tool-marquee-sel-items-and-time-ignoring-snap-cut-on-shortcut-release": 54873,
3155 "sws-nf-play-stop-or-play-pause-obey-sws-nf-toggle-play-stop-or-play-pause-toggle-state": 54937,
3156 "sws-nf-toggle-obey-track-height-lock-in-vertical-zoom-and-track-height-actions": 54935,
3157 "sws-nf-toggle-play-stop-off-or-play-pause-on": 54936,
3158 "sws-nf-toggle-render-speed-apply-fx-render-stems-realtime-not-limited": 54934,
3159 "sws-normalize-item-s-to-peak-rms": 53573,
3160 "sws-normalize-items-to-overall-peak-rms": 53574,
3161 "sws-normalize-items-to-rms-entire-item": 53572,
3162 "sws-nudge-items-position-1-sample-left": 53263,
3163 "sws-nudge-items-position-1-sample-right": 53262,
3164 "sws-nudge-marker-under-cursor-left": 53108,
3165 "sws-nudge-marker-under-cursor-right": 53109,
3166 "sws-nudge-master-output-1-volume-1db": 53723,
3167 "sws-nudge-master-output-1-volume-plus-1db": 53722,
3168 "sws-open-auto-color-icon-layout-window": 53000,
3169 "sws-open-color-management-window": 53007,
3170 "sws-open-console": 53111,
3171 "sws-open-console-and-copy-keystroke": 53112,
3172 "sws-open-console-with-a-to-arm-track-s": 53116,
3173 "sws-open-console-with-b-to-prefix-track-s": 53120,
3174 "sws-open-console-with-c-to-color-track-s": 53122,
3175 "sws-open-console-with-f-to-toggle-fx-enable": 53118,
3176 "sws-open-console-with-h-to-flip-phase-on-track-s": 53123,
3177 "sws-open-console-with-i-to-set-track-s-input": 53119,
3178 "sws-open-console-with-l-to-set-track-s-number-channels": 53128,
3179 "sws-open-console-with-m-to-mute-track-s": 53117,
3180 "sws-open-console-with-n-to-name-track-s": 53114,
3181 "sws-open-console-with-o-to-solo-track-s": 53115,
3182 "sws-open-console-with-p-to-set-track-s-pan": 53125,
3183 "sws-open-console-with-p-to-trim-pan-on-track-s": 53127,
3184 "sws-open-console-with-s-to-select-track-s": 53113,
3185 "sws-open-console-with-to-add-action-marker": 53129,
3186 "sws-open-console-with-v-to-set-track-s-volume": 53124,
3187 "sws-open-console-with-v-to-trim-volume-on-track-s": 53126,
3188 "sws-open-console-with-z-to-suffix-track-s": 53121,
3189 "sws-open-last-project": 53222,
3190 "sws-open-marker-list": 53082,
3191 "sws-open-project-list": 53220,
3192 "sws-open-projects-from-list": 53217,
3193 "sws-open-related-project-1": 53221,
3194 "sws-open-snapshots-window": 53156,
3195 "sws-organize-items-by-peak": 53569,
3196 "sws-organize-items-by-peak-rms": 53571,
3197 "sws-organize-items-by-rms-entire-item": 53570,
3198 "sws-padre-envelope-lfo-generator": 53922,
3199 "sws-padre-envelope-processor": 53923,
3200 "sws-padre-shrink-selected-items-1024-samples": 53927,
3201 "sws-padre-shrink-selected-items-128-samples": 53924,
3202 "sws-padre-shrink-selected-items-2048-samples": 53928,
3203 "sws-padre-shrink-selected-items-256-samples": 53925,
3204 "sws-padre-shrink-selected-items-512-samples": 53926,
3205 "sws-paste-marker-set-from-clipboard": 53088,
3206 "sws-paste-snapshot": 53171,
3207 "sws-pitch-all-takes-down-one-cent": 53630,
3208 "sws-pitch-all-takes-down-one-octave": 53632,
3209 "sws-pitch-all-takes-down-one-semitone": 53631,
3210 "sws-pitch-all-takes-up-one-cent": 53627,
3211 "sws-pitch-all-takes-up-one-octave": 53629,
3212 "sws-pitch-all-takes-up-one-semitone": 53628,
3213 "sws-quantize-item-s-edges-to-grid-change-length": 53618,
3214 "sws-quantize-item-s-end-to-grid-change-length": 53617,
3215 "sws-quantize-item-s-end-to-grid-keep-length": 53616,
3216 "sws-quantize-item-s-start-to-grid-change-length": 53615,
3217 "sws-quantize-item-s-start-to-grid-keep-length": 53614,
3218 "sws-recall-current-snapshot": 53165,
3219 "sws-recall-next-snapshot": 53167,
3220 "sws-recall-previous-snapshot": 53166,
3221 "sws-recall-snapshot-1": 55731,
3222 "sws-recall-snapshot-10": 55740,
3223 "sws-recall-snapshot-11": 55741,
3224 "sws-recall-snapshot-12": 55742,
3225 "sws-recall-snapshot-2": 55732,
3226 "sws-recall-snapshot-3": 55733,
3227 "sws-recall-snapshot-4": 55734,
3228 "sws-recall-snapshot-5": 55735,
3229 "sws-recall-snapshot-6": 55736,
3230 "sws-recall-snapshot-7": 55737,
3231 "sws-recall-snapshot-8": 55738,
3232 "sws-recall-snapshot-9": 55739,
3233 "sws-redo-edit-cursor-move": 53587,
3234 "sws-redo-zoom": 53837,
3235 "sws-remove-items-tracks-env-obey-time-selection-razor-edit-areas": 53578,
3236 "sws-renumber-marker-ids": 53089,
3237 "sws-renumber-region-ids": 53090,
3238 "sws-reset-all-takes-pitch": 53626,
3239 "sws-reset-item-rate-preserving-length-clear-preserve-pitch": 53638,
3240 "sws-restore-active-takes-on-selected-track-s": 53133,
3241 "sws-restore-arrange-view-slot-1": 53831,
3242 "sws-restore-arrange-view-slot-2": 53832,
3243 "sws-restore-arrange-view-slot-3": 53833,
3244 "sws-restore-arrange-view-slot-4": 53834,
3245 "sws-restore-arrange-view-slot-5": 53835,
3246 "sws-restore-auto-crossfade-state": 53667,
3247 "sws-restore-last-item-selection-on-selected-track-s": 53144,
3248 "sws-restore-loop-selection-next-slot": 53150,
3249 "sws-restore-loop-selection-slot-1": 55802,
3250 "sws-restore-loop-selection-slot-2": 55803,
3251 "sws-restore-loop-selection-slot-3": 55804,
3252 "sws-restore-loop-selection-slot-4": 55805,
3253 "sws-restore-loop-selection-slot-5": 55806,
3254 "sws-restore-master-fx-enabled-state": 53733,
3255 "sws-restore-saved-selected-item-s": 53146,
3256 "sws-restore-saved-track-selection": 53750,
3257 "sws-restore-selected-track-s-items-states": 53152,
3258 "sws-restore-selected-track-s-mutes-plus-receives-children": 53148,
3259 "sws-restore-selected-track-s-selected-item-s-slot-1": 53139,
3260 "sws-restore-selected-track-s-selected-item-s-slot-2": 53140,
3261 "sws-restore-selected-track-s-selected-item-s-slot-3": 53141,
3262 "sws-restore-selected-track-s-selected-item-s-slot-4": 53142,
3263 "sws-restore-selected-track-s-selected-item-s-slot-5": 53143,
3264 "sws-restore-selected-track-s-selected-items-states": 53154,
3265 "sws-restore-snapshot-filter-options": 53192,
3266 "sws-restore-time-selection-next-slot": 53149,
3267 "sws-restore-time-selection-slot-1": 55792,
3268 "sws-restore-time-selection-slot-2": 55793,
3269 "sws-restore-time-selection-slot-3": 55794,
3270 "sws-restore-time-selection-slot-4": 55795,
3271 "sws-restore-time-selection-slot-5": 55796,
3272 "sws-restore-transport-repeat-state": 53680,
3273 "sws-run-action-marker-under-cursor": 53106,
3274 "sws-s-and-m-active-midi-editor-create-cc-lane": 55168,
3275 "sws-s-and-m-active-midi-editor-hide-all-cc-lanes": 55167,
3276 "sws-s-and-m-active-midi-editor-restore-displayed-cc-lanes-slot-1": 55515,
3277 "sws-s-and-m-active-midi-editor-restore-displayed-cc-lanes-slot-2": 55516,
3278 "sws-s-and-m-active-midi-editor-restore-displayed-cc-lanes-slot-3": 55517,
3279 "sws-s-and-m-active-midi-editor-restore-displayed-cc-lanes-slot-4": 55518,
3280 "sws-s-and-m-active-midi-editor-restore-displayed-cc-lanes-slot-5": 55519,
3281 "sws-s-and-m-active-midi-editor-restore-displayed-cc-lanes-slot-6": 55520,
3282 "sws-s-and-m-active-midi-editor-restore-displayed-cc-lanes-slot-7": 55521,
3283 "sws-s-and-m-active-midi-editor-restore-displayed-cc-lanes-slot-8": 55522,
3284 "sws-s-and-m-active-midi-editor-save-displayed-cc-lanes-slot-1": 55523,
3285 "sws-s-and-m-active-midi-editor-save-displayed-cc-lanes-slot-2": 55524,
3286 "sws-s-and-m-active-midi-editor-save-displayed-cc-lanes-slot-3": 55525,
3287 "sws-s-and-m-active-midi-editor-save-displayed-cc-lanes-slot-4": 55526,
3288 "sws-s-and-m-active-midi-editor-save-displayed-cc-lanes-slot-5": 55527,
3289 "sws-s-and-m-active-midi-editor-save-displayed-cc-lanes-slot-6": 55528,
3290 "sws-s-and-m-active-midi-editor-save-displayed-cc-lanes-slot-7": 55529,
3291 "sws-s-and-m-active-midi-editor-save-displayed-cc-lanes-slot-8": 55530,
3292 "sws-s-and-m-arm-all-active-envelopes-for-selected-tracks": 55209,
3293 "sws-s-and-m-bypass-all-fx-except-1-for-selected-tracks": 55355,
3294 "sws-s-and-m-bypass-all-fx-except-2-for-selected-tracks": 55356,
3295 "sws-s-and-m-bypass-all-fx-except-3-for-selected-tracks": 55357,
3296 "sws-s-and-m-bypass-all-fx-except-4-for-selected-tracks": 55358,
3297 "sws-s-and-m-bypass-all-fx-except-5-for-selected-tracks": 55359,
3298 "sws-s-and-m-bypass-all-fx-except-6-for-selected-tracks": 55360,
3299 "sws-s-and-m-bypass-all-fx-except-7-for-selected-tracks": 55361,
3300 "sws-s-and-m-bypass-all-fx-except-8-for-selected-tracks": 55362,
3301 "sws-s-and-m-bypass-all-fx-for-selected-tracks": 55004,
3302 "sws-s-and-m-bypass-all-take-fx-for-selected-items": 55012,
3303 "sws-s-and-m-bypass-fx-1-for-selected-tracks": 55323,
3304 "sws-s-and-m-bypass-fx-2-for-selected-tracks": 55324,
3305 "sws-s-and-m-bypass-fx-3-for-selected-tracks": 55325,
3306 "sws-s-and-m-bypass-fx-4-for-selected-tracks": 55326,
3307 "sws-s-and-m-bypass-fx-5-for-selected-tracks": 55327,
3308 "sws-s-and-m-bypass-fx-6-for-selected-tracks": 55328,
3309 "sws-s-and-m-bypass-fx-7-for-selected-tracks": 55329,
3310 "sws-s-and-m-bypass-fx-8-for-selected-tracks": 55330,
3311 "sws-s-and-m-bypass-last-fx-for-selected-tracks": 54999,
3312 "sws-s-and-m-bypass-selected-fx-for-selected-tracks": 55000,
3313 "sws-s-and-m-clear-fx-chain-for-selected-items": 55029,
3314 "sws-s-and-m-clear-fx-chain-for-selected-items-all-takes": 55030,
3315 "sws-s-and-m-clear-fx-chain-for-selected-tracks": 55031,
3316 "sws-s-and-m-clear-global-startup-action": 55046,
3317 "sws-s-and-m-clear-image-window": 55048,
3318 "sws-s-and-m-clear-input-fx-chain-for-selected-tracks": 55032,
3319 "sws-s-and-m-clear-project-startup-action": 55043,
3320 "sws-s-and-m-close-all-floating-fx-windows": 54961,
3321 "sws-s-and-m-close-all-floating-fx-windows-except-focused-one": 54964,
3322 "sws-s-and-m-close-all-floating-fx-windows-for-selected-tracks": 54963,
3323 "sws-s-and-m-close-all-fx-chain-windows": 54962,
3324 "sws-s-and-m-copy-active-takes": 55133,
3325 "sws-s-and-m-copy-fx-chain-depending-on-focus": 55033,
3326 "sws-s-and-m-copy-fx-chain-from-selected-item": 55015,
3327 "sws-s-and-m-copy-fx-chain-from-selected-track": 55019,
3328 "sws-s-and-m-copy-input-fx-chain-from-selected-track": 55025,
3329 "sws-s-and-m-copy-selected-track-grouping": 55171,
3330 "sws-s-and-m-copy-selected-tracks-receives": 54953,
3331 "sws-s-and-m-copy-selected-tracks-routings": 54947,
3332 "sws-s-and-m-copy-selected-tracks-sends": 54950,
3333 "sws-s-and-m-copy-selected-tracks-with-routing": 54944,
3334 "sws-s-and-m-create-cue-buss-from-track-selection-settings-1": 55291,
3335 "sws-s-and-m-create-cue-buss-from-track-selection-settings-2": 55292,
3336 "sws-s-and-m-create-cue-buss-from-track-selection-settings-3": 55293,
3337 "sws-s-and-m-create-cue-buss-from-track-selection-settings-4": 55294,
3338 "sws-s-and-m-create-cue-buss-from-track-selection-settings-5": 55295,
3339 "sws-s-and-m-create-cue-buss-from-track-selection-settings-6": 55296,
3340 "sws-s-and-m-create-cue-buss-from-track-selection-settings-7": 55297,
3341 "sws-s-and-m-create-cue-buss-from-track-selection-settings-8": 55298,
3342 "sws-s-and-m-create-cue-buss-from-track-selection-use-last-settings": 54939,
3343 "sws-s-and-m-cut-active-takes": 55134,
3344 "sws-s-and-m-cut-fx-chain-depending-on-focus": 55036,
3345 "sws-s-and-m-cut-fx-chain-from-selected-items": 55016,
3346 "sws-s-and-m-cut-fx-chain-from-selected-tracks": 55020,
3347 "sws-s-and-m-cut-input-fx-chain-from-selected-tracks": 55026,
3348 "sws-s-and-m-cut-selected-tracks-grouping": 55172,
3349 "sws-s-and-m-cut-selected-tracks-receives": 54955,
3350 "sws-s-and-m-cut-selected-tracks-routings": 54949,
3351 "sws-s-and-m-cut-selected-tracks-sends": 54952,
3352 "sws-s-and-m-cut-selected-tracks-with-routing": 54946,
3353 "sws-s-and-m-decrease-metronome-volume": 55262,
3354 "sws-s-and-m-delete-active-take-and-source-file-in-selected-items-no-undo": 55147,
3355 "sws-s-and-m-delete-active-take-and-source-file-in-selected-items-prompt-no-undo": 55146,
3356 "sws-s-and-m-delete-selected-items-takes-and-source-files-no-undo": 55145,
3357 "sws-s-and-m-delete-selected-items-takes-and-source-files-prompt-no-undo": 55144,
3358 "sws-s-and-m-disarm-all-active-envelopes-for-selected-tracks": 55210,
3359 "sws-s-and-m-dummy-toggle-1": 55675,
3360 "sws-s-and-m-dummy-toggle-2": 55676,
3361 "sws-s-and-m-dummy-toggle-3": 55677,
3362 "sws-s-and-m-dummy-toggle-4": 55678,
3363 "sws-s-and-m-dummy-toggle-5": 55679,
3364 "sws-s-and-m-dummy-toggle-6": 55680,
3365 "sws-s-and-m-dummy-toggle-7": 55681,
3366 "sws-s-and-m-dummy-toggle-8": 55682,
3367 "sws-s-and-m-dump-action-list-all-actions": 55271,
3368 "sws-s-and-m-dump-action-list-all-but-custom-actions": 55270,
3369 "sws-s-and-m-dump-action-list-custom-actions-only": 55269,
3370 "sws-s-and-m-dump-action-list-native-actions-only": 55267,
3371 "sws-s-and-m-dump-action-list-sws-actions-only": 55268,
3372 "sws-s-and-m-dump-alr-wiki-summary-native-actions-only": 55265,
3373 "sws-s-and-m-dump-alr-wiki-summary-sws-actions-only": 55266,
3374 "sws-s-and-m-exclusive-toggle-a1": 55683,
3375 "sws-s-and-m-exclusive-toggle-a2": 55684,
3376 "sws-s-and-m-exclusive-toggle-a3": 55685,
3377 "sws-s-and-m-exclusive-toggle-a4": 55686,
3378 "sws-s-and-m-exclusive-toggle-b1": 55687,
3379 "sws-s-and-m-exclusive-toggle-b2": 55688,
3380 "sws-s-and-m-exclusive-toggle-b3": 55689,
3381 "sws-s-and-m-exclusive-toggle-b4": 55690,
3382 "sws-s-and-m-exclusive-toggle-c1": 55691,
3383 "sws-s-and-m-exclusive-toggle-c2": 55692,
3384 "sws-s-and-m-exclusive-toggle-c3": 55693,
3385 "sws-s-and-m-exclusive-toggle-c4": 55694,
3386 "sws-s-and-m-exclusive-toggle-d1": 55695,
3387 "sws-s-and-m-exclusive-toggle-d2": 55696,
3388 "sws-s-and-m-exclusive-toggle-d3": 55697,
3389 "sws-s-and-m-exclusive-toggle-d4": 55698,
3390 "sws-s-and-m-find": 55227,
3391 "sws-s-and-m-find-next": 55228,
3392 "sws-s-and-m-find-previous": 55229,
3393 "sws-s-and-m-float-fx-1-for-selected-tracks": 55491,
3394 "sws-s-and-m-float-fx-2-for-selected-tracks": 55492,
3395 "sws-s-and-m-float-fx-3-for-selected-tracks": 55493,
3396 "sws-s-and-m-float-fx-4-for-selected-tracks": 55494,
3397 "sws-s-and-m-float-fx-5-for-selected-tracks": 55495,
3398 "sws-s-and-m-float-fx-6-for-selected-tracks": 55496,
3399 "sws-s-and-m-float-fx-7-for-selected-tracks": 55497,
3400 "sws-s-and-m-float-fx-8-for-selected-tracks": 55498,
3401 "sws-s-and-m-float-next-fx-and-close-others-for-selected-tracks": 54972,
3402 "sws-s-and-m-float-previous-fx-and-close-others-for-selected-tracks": 54971,
3403 "sws-s-and-m-float-selected-fx-for-selected-tracks": 54981,
3404 "sws-s-and-m-focus-main-window-only-valid-within-custom-actions": 54977,
3405 "sws-s-and-m-focus-next-floating-fx-cycle": 54976,
3406 "sws-s-and-m-focus-next-floating-fx-for-selected-tracks-cycle": 54974,
3407 "sws-s-and-m-focus-previous-floating-fx-cycle": 54975,
3408 "sws-s-and-m-focus-previous-floating-fx-for-selected-tracks-cycle": 54973,
3409 "sws-s-and-m-go-to-time-select-region-1-obeys-smooth-seek": 55671,
3410 "sws-s-and-m-go-to-time-select-region-2-obeys-smooth-seek": 55672,
3411 "sws-s-and-m-go-to-time-select-region-3-obeys-smooth-seek": 55673,
3412 "sws-s-and-m-go-to-time-select-region-4-obeys-smooth-seek": 55674,
3413 "sws-s-and-m-hide-and-bypass-take-mute-envelope": 55189,
3414 "sws-s-and-m-hide-and-bypass-take-pan-envelope": 55188,
3415 "sws-s-and-m-hide-and-bypass-take-pitch-envelope": 55200,
3416 "sws-s-and-m-hide-and-bypass-take-volume-envelope": 55187,
3417 "sws-s-and-m-hide-fx-chain-windows-for-selected-tracks": 54979,
3418 "sws-s-and-m-hide-take-mute-envelope": 55195,
3419 "sws-s-and-m-hide-take-pan-envelope": 55194,
3420 "sws-s-and-m-hide-take-pitch-envelope": 55202,
3421 "sws-s-and-m-hide-take-volume-envelope": 55193,
3422 "sws-s-and-m-increase-metronome-volume": 55261,
3423 "sws-s-and-m-insert-marker-at-edit-cursor": 55257,
3424 "sws-s-and-m-insert-marker-at-play-cursor": 55258,
3425 "sws-s-and-m-insert-silence-measures-beats": 55040,
3426 "sws-s-and-m-insert-silence-samples": 55041,
3427 "sws-s-and-m-insert-silence-seconds": 55039,
3428 "sws-s-and-m-live-config-number-1-apply-config-midi-osc-only": 55563,
3429 "sws-s-and-m-live-config-number-1-apply-next-config": 55579,
3430 "sws-s-and-m-live-config-number-1-apply-preloaded-config-swap-preload-current": 55611,
3431 "sws-s-and-m-live-config-number-1-apply-previous-config": 55587,
3432 "sws-s-and-m-live-config-number-1-open-close-monitoring-window": 55555,
3433 "sws-s-and-m-live-config-number-1-preload-config-midi-osc-only": 55571,
3434 "sws-s-and-m-live-config-number-1-preload-next-config": 55595,
3435 "sws-s-and-m-live-config-number-1-preload-previous-config": 55603,
3436 "sws-s-and-m-live-config-number-1-toggle-enable": 55619,
3437 "sws-s-and-m-live-config-number-1-toggle-enable-tiny-fades": 55659,
3438 "sws-s-and-m-live-config-number-1-toggle-option-disarm-all-but-active-track": 55643,
3439 "sws-s-and-m-live-config-number-1-toggle-option-mute-all-but-active-track": 55627,
3440 "sws-s-and-m-live-config-number-1-toggle-option-offline-all-but-active-preloaded-tracks": 55635,
3441 "sws-s-and-m-live-config-number-1-toggle-option-send-all-notes-off-when-switching-configs": 55651,
3442 "sws-s-and-m-live-config-number-2-apply-config-midi-osc-only": 55564,
3443 "sws-s-and-m-live-config-number-2-apply-next-config": 55580,
3444 "sws-s-and-m-live-config-number-2-apply-preloaded-config-swap-preload-current": 55612,
3445 "sws-s-and-m-live-config-number-2-apply-previous-config": 55588,
3446 "sws-s-and-m-live-config-number-2-open-close-monitoring-window": 55556,
3447 "sws-s-and-m-live-config-number-2-preload-config-midi-osc-only": 55572,
3448 "sws-s-and-m-live-config-number-2-preload-next-config": 55596,
3449 "sws-s-and-m-live-config-number-2-preload-previous-config": 55604,
3450 "sws-s-and-m-live-config-number-2-toggle-enable": 55620,
3451 "sws-s-and-m-live-config-number-2-toggle-enable-tiny-fades": 55660,
3452 "sws-s-and-m-live-config-number-2-toggle-option-disarm-all-but-active-track": 55644,
3453 "sws-s-and-m-live-config-number-2-toggle-option-mute-all-but-active-track": 55628,
3454 "sws-s-and-m-live-config-number-2-toggle-option-offline-all-but-active-preloaded-tracks": 55636,
3455 "sws-s-and-m-live-config-number-2-toggle-option-send-all-notes-off-when-switching-configs": 55652,
3456 "sws-s-and-m-live-config-number-3-apply-config-midi-osc-only": 55565,
3457 "sws-s-and-m-live-config-number-3-apply-next-config": 55581,
3458 "sws-s-and-m-live-config-number-3-apply-preloaded-config-swap-preload-current": 55613,
3459 "sws-s-and-m-live-config-number-3-apply-previous-config": 55589,
3460 "sws-s-and-m-live-config-number-3-open-close-monitoring-window": 55557,
3461 "sws-s-and-m-live-config-number-3-preload-config-midi-osc-only": 55573,
3462 "sws-s-and-m-live-config-number-3-preload-next-config": 55597,
3463 "sws-s-and-m-live-config-number-3-preload-previous-config": 55605,
3464 "sws-s-and-m-live-config-number-3-toggle-enable": 55621,
3465 "sws-s-and-m-live-config-number-3-toggle-enable-tiny-fades": 55661,
3466 "sws-s-and-m-live-config-number-3-toggle-option-disarm-all-but-active-track": 55645,
3467 "sws-s-and-m-live-config-number-3-toggle-option-mute-all-but-active-track": 55629,
3468 "sws-s-and-m-live-config-number-3-toggle-option-offline-all-but-active-preloaded-tracks": 55637,
3469 "sws-s-and-m-live-config-number-3-toggle-option-send-all-notes-off-when-switching-configs": 55653,
3470 "sws-s-and-m-live-config-number-4-apply-config-midi-osc-only": 55566,
3471 "sws-s-and-m-live-config-number-4-apply-next-config": 55582,
3472 "sws-s-and-m-live-config-number-4-apply-preloaded-config-swap-preload-current": 55614,
3473 "sws-s-and-m-live-config-number-4-apply-previous-config": 55590,
3474 "sws-s-and-m-live-config-number-4-open-close-monitoring-window": 55558,
3475 "sws-s-and-m-live-config-number-4-preload-config-midi-osc-only": 55574,
3476 "sws-s-and-m-live-config-number-4-preload-next-config": 55598,
3477 "sws-s-and-m-live-config-number-4-preload-previous-config": 55606,
3478 "sws-s-and-m-live-config-number-4-toggle-enable": 55622,
3479 "sws-s-and-m-live-config-number-4-toggle-enable-tiny-fades": 55662,
3480 "sws-s-and-m-live-config-number-4-toggle-option-disarm-all-but-active-track": 55646,
3481 "sws-s-and-m-live-config-number-4-toggle-option-mute-all-but-active-track": 55630,
3482 "sws-s-and-m-live-config-number-4-toggle-option-offline-all-but-active-preloaded-tracks": 55638,
3483 "sws-s-and-m-live-config-number-4-toggle-option-send-all-notes-off-when-switching-configs": 55654,
3484 "sws-s-and-m-live-config-number-5-apply-config-midi-osc-only": 55567,
3485 "sws-s-and-m-live-config-number-5-apply-next-config": 55583,
3486 "sws-s-and-m-live-config-number-5-apply-preloaded-config-swap-preload-current": 55615,
3487 "sws-s-and-m-live-config-number-5-apply-previous-config": 55591,
3488 "sws-s-and-m-live-config-number-5-open-close-monitoring-window": 55559,
3489 "sws-s-and-m-live-config-number-5-preload-config-midi-osc-only": 55575,
3490 "sws-s-and-m-live-config-number-5-preload-next-config": 55599,
3491 "sws-s-and-m-live-config-number-5-preload-previous-config": 55607,
3492 "sws-s-and-m-live-config-number-5-toggle-enable": 55623,
3493 "sws-s-and-m-live-config-number-5-toggle-enable-tiny-fades": 55663,
3494 "sws-s-and-m-live-config-number-5-toggle-option-disarm-all-but-active-track": 55647,
3495 "sws-s-and-m-live-config-number-5-toggle-option-mute-all-but-active-track": 55631,
3496 "sws-s-and-m-live-config-number-5-toggle-option-offline-all-but-active-preloaded-tracks": 55639,
3497 "sws-s-and-m-live-config-number-5-toggle-option-send-all-notes-off-when-switching-configs": 55655,
3498 "sws-s-and-m-live-config-number-6-apply-config-midi-osc-only": 55568,
3499 "sws-s-and-m-live-config-number-6-apply-next-config": 55584,
3500 "sws-s-and-m-live-config-number-6-apply-preloaded-config-swap-preload-current": 55616,
3501 "sws-s-and-m-live-config-number-6-apply-previous-config": 55592,
3502 "sws-s-and-m-live-config-number-6-open-close-monitoring-window": 55560,
3503 "sws-s-and-m-live-config-number-6-preload-config-midi-osc-only": 55576,
3504 "sws-s-and-m-live-config-number-6-preload-next-config": 55600,
3505 "sws-s-and-m-live-config-number-6-preload-previous-config": 55608,
3506 "sws-s-and-m-live-config-number-6-toggle-enable": 55624,
3507 "sws-s-and-m-live-config-number-6-toggle-enable-tiny-fades": 55664,
3508 "sws-s-and-m-live-config-number-6-toggle-option-disarm-all-but-active-track": 55648,
3509 "sws-s-and-m-live-config-number-6-toggle-option-mute-all-but-active-track": 55632,
3510 "sws-s-and-m-live-config-number-6-toggle-option-offline-all-but-active-preloaded-tracks": 55640,
3511 "sws-s-and-m-live-config-number-6-toggle-option-send-all-notes-off-when-switching-configs": 55656,
3512 "sws-s-and-m-live-config-number-7-apply-config-midi-osc-only": 55569,
3513 "sws-s-and-m-live-config-number-7-apply-next-config": 55585,
3514 "sws-s-and-m-live-config-number-7-apply-preloaded-config-swap-preload-current": 55617,
3515 "sws-s-and-m-live-config-number-7-apply-previous-config": 55593,
3516 "sws-s-and-m-live-config-number-7-open-close-monitoring-window": 55561,
3517 "sws-s-and-m-live-config-number-7-preload-config-midi-osc-only": 55577,
3518 "sws-s-and-m-live-config-number-7-preload-next-config": 55601,
3519 "sws-s-and-m-live-config-number-7-preload-previous-config": 55609,
3520 "sws-s-and-m-live-config-number-7-toggle-enable": 55625,
3521 "sws-s-and-m-live-config-number-7-toggle-enable-tiny-fades": 55665,
3522 "sws-s-and-m-live-config-number-7-toggle-option-disarm-all-but-active-track": 55649,
3523 "sws-s-and-m-live-config-number-7-toggle-option-mute-all-but-active-track": 55633,
3524 "sws-s-and-m-live-config-number-7-toggle-option-offline-all-but-active-preloaded-tracks": 55641,
3525 "sws-s-and-m-live-config-number-7-toggle-option-send-all-notes-off-when-switching-configs": 55657,
3526 "sws-s-and-m-live-config-number-8-apply-config-midi-osc-only": 55570,
3527 "sws-s-and-m-live-config-number-8-apply-next-config": 55586,
3528 "sws-s-and-m-live-config-number-8-apply-preloaded-config-swap-preload-current": 55618,
3529 "sws-s-and-m-live-config-number-8-apply-previous-config": 55594,
3530 "sws-s-and-m-live-config-number-8-open-close-monitoring-window": 55562,
3531 "sws-s-and-m-live-config-number-8-preload-config-midi-osc-only": 55578,
3532 "sws-s-and-m-live-config-number-8-preload-next-config": 55602,
3533 "sws-s-and-m-live-config-number-8-preload-previous-config": 55610,
3534 "sws-s-and-m-live-config-number-8-toggle-enable": 55626,
3535 "sws-s-and-m-live-config-number-8-toggle-enable-tiny-fades": 55666,
3536 "sws-s-and-m-live-config-number-8-toggle-option-disarm-all-but-active-track": 55650,
3537 "sws-s-and-m-live-config-number-8-toggle-option-mute-all-but-active-track": 55634,
3538 "sws-s-and-m-live-config-number-8-toggle-option-offline-all-but-active-preloaded-tracks": 55642,
3539 "sws-s-and-m-live-config-number-8-toggle-option-send-all-notes-off-when-switching-configs": 55658,
3540 "sws-s-and-m-map-selected-tracks-midi-input-to-channel-1": 55715,
3541 "sws-s-and-m-map-selected-tracks-midi-input-to-channel-10": 55724,
3542 "sws-s-and-m-map-selected-tracks-midi-input-to-channel-11": 55725,
3543 "sws-s-and-m-map-selected-tracks-midi-input-to-channel-12": 55726,
3544 "sws-s-and-m-map-selected-tracks-midi-input-to-channel-13": 55727,
3545 "sws-s-and-m-map-selected-tracks-midi-input-to-channel-14": 55728,
3546 "sws-s-and-m-map-selected-tracks-midi-input-to-channel-15": 55729,
3547 "sws-s-and-m-map-selected-tracks-midi-input-to-channel-16": 55730,
3548 "sws-s-and-m-map-selected-tracks-midi-input-to-channel-2": 55716,
3549 "sws-s-and-m-map-selected-tracks-midi-input-to-channel-3": 55717,
3550 "sws-s-and-m-map-selected-tracks-midi-input-to-channel-4": 55718,
3551 "sws-s-and-m-map-selected-tracks-midi-input-to-channel-5": 55719,
3552 "sws-s-and-m-map-selected-tracks-midi-input-to-channel-6": 55720,
3553 "sws-s-and-m-map-selected-tracks-midi-input-to-channel-7": 55721,
3554 "sws-s-and-m-map-selected-tracks-midi-input-to-channel-8": 55722,
3555 "sws-s-and-m-map-selected-tracks-midi-input-to-channel-9": 55723,
3556 "sws-s-and-m-map-selected-tracks-midi-input-to-source-channel": 55235,
3557 "sws-s-and-m-move-selected-fx-down-in-chain-for-selected-tracks": 54988,
3558 "sws-s-and-m-move-selected-fx-up-in-chain-for-selected-tracks": 54987,
3559 "sws-s-and-m-notes-export-subtitle-file": 55162,
3560 "sws-s-and-m-notes-import-subtitle-file": 55161,
3561 "sws-s-and-m-notes-toggle-lock": 55160,
3562 "sws-s-and-m-open-close-cue-buss-generator": 54940,
3563 "sws-s-and-m-open-close-cycle-action-editor": 55231,
3564 "sws-s-and-m-open-close-cycle-action-editor-event-list": 55232,
3565 "sws-s-and-m-open-close-cycle-action-editor-piano-roll": 55233,
3566 "sws-s-and-m-open-close-image-window": 55047,
3567 "sws-s-and-m-open-close-live-configs-window": 55230,
3568 "sws-s-and-m-open-close-notes-window": 55148,
3569 "sws-s-and-m-open-close-notes-window-extra-project-notes": 55150,
3570 "sws-s-and-m-open-close-notes-window-global-notes": 55151,
3571 "sws-s-and-m-open-close-notes-window-item-notes": 55152,
3572 "sws-s-and-m-open-close-notes-window-marker-names": 55154,
3573 "sws-s-and-m-open-close-notes-window-marker-region-names": 55156,
3574 "sws-s-and-m-open-close-notes-window-marker-region-subtitles": 55159,
3575 "sws-s-and-m-open-close-notes-window-marker-subtitles": 55157,
3576 "sws-s-and-m-open-close-notes-window-project-notes": 55149,
3577 "sws-s-and-m-open-close-notes-window-region-names": 55155,
3578 "sws-s-and-m-open-close-notes-window-region-subtitles": 55158,
3579 "sws-s-and-m-open-close-notes-window-track-notes": 55153,
3580 "sws-s-and-m-open-close-region-playlist-window": 55236,
3581 "sws-s-and-m-open-close-resources-window": 55049,
3582 "sws-s-and-m-open-close-resources-window-fx-chains": 55050,
3583 "sws-s-and-m-open-close-resources-window-images": 55104,
3584 "sws-s-and-m-open-close-resources-window-media-files": 55084,
3585 "sws-s-and-m-open-close-resources-window-projects": 55074,
3586 "sws-s-and-m-open-close-resources-window-themes": 55111,
3587 "sws-s-and-m-open-close-resources-window-track-templates": 55062,
3588 "sws-s-and-m-open-console-with-to-send-a-local-osc-message": 53131,
3589 "sws-s-and-m-open-console-with-x-to-add-track-fx": 53130,
3590 "sws-s-and-m-open-project-path-in-explorer-finder": 55037,
3591 "sws-s-and-m-open-selected-item-path-in-explorer-finder": 55123,
3592 "sws-s-and-m-pan-active-takes-of-selected-items-to-100-percent-left": 55124,
3593 "sws-s-and-m-pan-active-takes-of-selected-items-to-100-percent-right": 55132,
3594 "sws-s-and-m-pan-active-takes-of-selected-items-to-25-percent-left": 55127,
3595 "sws-s-and-m-pan-active-takes-of-selected-items-to-25-percent-right": 55129,
3596 "sws-s-and-m-pan-active-takes-of-selected-items-to-50-percent-left": 55126,
3597 "sws-s-and-m-pan-active-takes-of-selected-items-to-50-percent-right": 55130,
3598 "sws-s-and-m-pan-active-takes-of-selected-items-to-75-percent-left": 55125,
3599 "sws-s-and-m-pan-active-takes-of-selected-items-to-75-percent-right": 55131,
3600 "sws-s-and-m-pan-active-takes-of-selected-items-to-center": 55128,
3601 "sws-s-and-m-paste-fx-chain-depending-on-focus": 55034,
3602 "sws-s-and-m-paste-fx-chain-to-selected-items": 55022,
3603 "sws-s-and-m-paste-fx-chain-to-selected-items-all-takes": 55023,
3604 "sws-s-and-m-paste-fx-chain-to-selected-tracks": 55024,
3605 "sws-s-and-m-paste-grouping-to-selected-tracks": 55173,
3606 "sws-s-and-m-paste-input-fx-chain-to-selected-tracks": 55028,
3607 "sws-s-and-m-paste-receives-to-selected-tracks": 54954,
3608 "sws-s-and-m-paste-replace-fx-chain-depending-on-focus": 55035,
3609 "sws-s-and-m-paste-replace-fx-chain-to-selected-items": 55017,
3610 "sws-s-and-m-paste-replace-fx-chain-to-selected-items-all-takes": 55018,
3611 "sws-s-and-m-paste-replace-fx-chain-to-selected-tracks": 55021,
3612 "sws-s-and-m-paste-replace-input-fx-chain-to-selected-tracks": 55027,
3613 "sws-s-and-m-paste-routings-to-selected-tracks": 54948,
3614 "sws-s-and-m-paste-sends-to-selected-tracks": 54951,
3615 "sws-s-and-m-paste-takes": 55135,
3616 "sws-s-and-m-paste-takes-after-active-takes": 55136,
3617 "sws-s-and-m-paste-tracks-with-routing-or-items": 54945,
3618 "sws-s-and-m-reassign-midi-learned-channels-of-all-fx-for-selected-tracks-prompt": 55119,
3619 "sws-s-and-m-reassign-midi-learned-channels-of-all-fx-to-input-channel-for-selected-tracks": 55121,
3620 "sws-s-and-m-reassign-midi-learned-channels-of-selected-fx-for-selected-tracks-prompt": 55120,
3621 "sws-s-and-m-recall-default-track-send-preferences": 54957,
3622 "sws-s-and-m-region-playlist-add-all-regions-to-current-playlist": 55256,
3623 "sws-s-and-m-region-playlist-append-playlist-to-project": 55245,
3624 "sws-s-and-m-region-playlist-crop-project-to-playlist": 55243,
3625 "sws-s-and-m-region-playlist-crop-project-to-playlist-new-project-tab": 55244,
3626 "sws-s-and-m-region-playlist-number-1-play": 55667,
3627 "sws-s-and-m-region-playlist-number-2-play": 55668,
3628 "sws-s-and-m-region-playlist-number-3-play": 55669,
3629 "sws-s-and-m-region-playlist-number-4-play": 55670,
3630 "sws-s-and-m-region-playlist-options-disable-shuffle-only-in-region-playlist": 55254,
3631 "sws-s-and-m-region-playlist-options-disable-smooth-seek-only-in-region-playlist": 55251,
3632 "sws-s-and-m-region-playlist-options-enable-shuffle-only-in-region-playlist": 55253,
3633 "sws-s-and-m-region-playlist-options-enable-smooth-seek-only-in-region-playlist": 55250,
3634 "sws-s-and-m-region-playlist-options-toggle-shuffle-only-in-region-playlist": 55255,
3635 "sws-s-and-m-region-playlist-options-toggle-smooth-seek-only-in-region-playlist": 55252,
3636 "sws-s-and-m-region-playlist-paste-playlist-at-edit-cursor": 55246,
3637 "sws-s-and-m-region-playlist-play": 55238,
3638 "sws-s-and-m-region-playlist-play-next-region-based-on-current-playing-region": 55242,
3639 "sws-s-and-m-region-playlist-play-next-region-smooth-seek": 55240,
3640 "sws-s-and-m-region-playlist-play-previous-region-based-on-current-playing-region": 55241,
3641 "sws-s-and-m-region-playlist-play-previous-region-smooth-seek": 55239,
3642 "sws-s-and-m-region-playlist-set-repeat-off": 55247,
3643 "sws-s-and-m-region-playlist-set-repeat-on": 55248,
3644 "sws-s-and-m-region-playlist-toggle-monitoring-edition-mode": 55237,
3645 "sws-s-and-m-region-playlist-toggle-repeat": 55249,
3646 "sws-s-and-m-remove-all-envelopes-for-selected-tracks": 55207,
3647 "sws-s-and-m-remove-receives-from-selected-tracks": 54941,
3648 "sws-s-and-m-remove-routing-from-selected-tracks": 54943,
3649 "sws-s-and-m-remove-selected-fx-for-selected-tracks": 54989,
3650 "sws-s-and-m-remove-sends-from-selected-tracks": 54942,
3651 "sws-s-and-m-remove-track-grouping-for-selected-tracks": 55174,
3652 "sws-s-and-m-resources-add-media-file-to-current-track-last-slot": 55096,
3653 "sws-s-and-m-resources-add-media-file-to-current-track-slot-1": 55435,
3654 "sws-s-and-m-resources-add-media-file-to-current-track-slot-2": 55436,
3655 "sws-s-and-m-resources-add-media-file-to-current-track-slot-3": 55437,
3656 "sws-s-and-m-resources-add-media-file-to-current-track-slot-4": 55438,
3657 "sws-s-and-m-resources-add-media-file-to-new-track-last-slot": 55097,
3658 "sws-s-and-m-resources-add-media-file-to-new-track-slot-1": 55439,
3659 "sws-s-and-m-resources-add-media-file-to-new-track-slot-2": 55440,
3660 "sws-s-and-m-resources-add-media-file-to-new-track-slot-3": 55441,
3661 "sws-s-and-m-resources-add-media-file-to-new-track-slot-4": 55442,
3662 "sws-s-and-m-resources-add-media-file-to-selected-items-as-takes-last-slot": 55098,
3663 "sws-s-and-m-resources-add-media-file-to-selected-items-as-takes-slot-1": 55443,
3664 "sws-s-and-m-resources-add-media-file-to-selected-items-as-takes-slot-2": 55444,
3665 "sws-s-and-m-resources-add-media-file-to-selected-items-as-takes-slot-3": 55445,
3666 "sws-s-and-m-resources-add-media-file-to-selected-items-as-takes-slot-4": 55446,
3667 "sws-s-and-m-resources-apply-track-template-plus-envelopes-items-to-selected-tracks-last-slot": 55071,
3668 "sws-s-and-m-resources-apply-track-template-plus-envelopes-items-to-selected-tracks-prompt-for-slot": 55286,
3669 "sws-s-and-m-resources-apply-track-template-plus-envelopes-items-to-selected-tracks-slot-1": 55391,
3670 "sws-s-and-m-resources-apply-track-template-plus-envelopes-items-to-selected-tracks-slot-2": 55392,
3671 "sws-s-and-m-resources-apply-track-template-plus-envelopes-items-to-selected-tracks-slot-3": 55393,
3672 "sws-s-and-m-resources-apply-track-template-plus-envelopes-items-to-selected-tracks-slot-4": 55394,
3673 "sws-s-and-m-resources-apply-track-template-to-selected-tracks-last-slot": 55070,
3674 "sws-s-and-m-resources-apply-track-template-to-selected-tracks-prompt-for-slot": 55285,
3675 "sws-s-and-m-resources-apply-track-template-to-selected-tracks-slot-1": 55387,
3676 "sws-s-and-m-resources-apply-track-template-to-selected-tracks-slot-2": 55388,
3677 "sws-s-and-m-resources-apply-track-template-to-selected-tracks-slot-3": 55389,
3678 "sws-s-and-m-resources-apply-track-template-to-selected-tracks-slot-4": 55390,
3679 "sws-s-and-m-resources-auto-save-fx-chains-for-selected-items": 55054,
3680 "sws-s-and-m-resources-auto-save-fx-chains-for-selected-tracks": 55053,
3681 "sws-s-and-m-resources-auto-save-input-fx-chains-for-selected-tracks": 55055,
3682 "sws-s-and-m-resources-auto-save-media-files-for-selected-items": 55089,
3683 "sws-s-and-m-resources-auto-save-project": 55077,
3684 "sws-s-and-m-resources-auto-save-track-template": 55065,
3685 "sws-s-and-m-resources-auto-save-track-template-with-envelopes": 55067,
3686 "sws-s-and-m-resources-auto-save-track-template-with-items": 55068,
3687 "sws-s-and-m-resources-auto-save-track-template-with-items-envelopes": 55066,
3688 "sws-s-and-m-resources-clear-fx-chain-slot-prompt-for-slot": 55272,
3689 "sws-s-and-m-resources-clear-image-slot-prompt-for-slot": 55276,
3690 "sws-s-and-m-resources-clear-media-file-slot-prompt-for-slot": 55275,
3691 "sws-s-and-m-resources-clear-project-template-slot-prompt-for-slot": 55274,
3692 "sws-s-and-m-resources-clear-theme-slot-prompt-for-slot": 55277,
3693 "sws-s-and-m-resources-clear-track-template-slot-prompt-for-slot": 55273,
3694 "sws-s-and-m-resources-delete-all-fx-chain-slots": 55052,
3695 "sws-s-and-m-resources-delete-all-image-slots": 55108,
3696 "sws-s-and-m-resources-delete-all-media-file-slots": 55088,
3697 "sws-s-and-m-resources-delete-all-project-slots": 55076,
3698 "sws-s-and-m-resources-delete-all-theme-slots": 55113,
3699 "sws-s-and-m-resources-delete-all-track-template-slots": 55064,
3700 "sws-s-and-m-resources-delete-last-fx-chain-slot-file": 55051,
3701 "sws-s-and-m-resources-delete-last-image-slot-file": 55107,
3702 "sws-s-and-m-resources-delete-last-media-file-slot-file": 55087,
3703 "sws-s-and-m-resources-delete-last-project-slot-file": 55075,
3704 "sws-s-and-m-resources-delete-last-theme-slot-file": 55112,
3705 "sws-s-and-m-resources-delete-last-track-template-slot-file": 55063,
3706 "sws-s-and-m-resources-import-tracks-from-track-template-last-slot": 55069,
3707 "sws-s-and-m-resources-import-tracks-from-track-template-prompt-for-slot": 55284,
3708 "sws-s-and-m-resources-import-tracks-from-track-template-slot-1": 55395,
3709 "sws-s-and-m-resources-import-tracks-from-track-template-slot-2": 55396,
3710 "sws-s-and-m-resources-import-tracks-from-track-template-slot-3": 55397,
3711 "sws-s-and-m-resources-import-tracks-from-track-template-slot-4": 55398,
3712 "sws-s-and-m-resources-load-theme-last-slot": 55114,
3713 "sws-s-and-m-resources-load-theme-slot-1": 55455,
3714 "sws-s-and-m-resources-load-theme-slot-2": 55456,
3715 "sws-s-and-m-resources-load-theme-slot-3": 55457,
3716 "sws-s-and-m-resources-load-theme-slot-4": 55458,
3717 "sws-s-and-m-resources-loop-media-file-in-selected-tracks-last-slot": 55091,
3718 "sws-s-and-m-resources-loop-media-file-in-selected-tracks-slot-1": 55411,
3719 "sws-s-and-m-resources-loop-media-file-in-selected-tracks-slot-2": 55412,
3720 "sws-s-and-m-resources-loop-media-file-in-selected-tracks-slot-3": 55413,
3721 "sws-s-and-m-resources-loop-media-file-in-selected-tracks-slot-4": 55414,
3722 "sws-s-and-m-resources-loop-media-file-in-selected-tracks-sync-with-next-measure-slot-1": 55419,
3723 "sws-s-and-m-resources-loop-media-file-in-selected-tracks-sync-with-next-measure-slot-2": 55420,
3724 "sws-s-and-m-resources-loop-media-file-in-selected-tracks-sync-with-next-measure-slot-3": 55421,
3725 "sws-s-and-m-resources-loop-media-file-in-selected-tracks-sync-with-next-measure-slot-4": 55422,
3726 "sws-s-and-m-resources-loop-media-file-in-selected-tracks-toggle-last-slot": 55093,
3727 "sws-s-and-m-resources-loop-media-file-in-selected-tracks-toggle-pause-last-slot": 55095,
3728 "sws-s-and-m-resources-loop-media-file-in-selected-tracks-toggle-slot-1": 55427,
3729 "sws-s-and-m-resources-loop-media-file-in-selected-tracks-toggle-slot-2": 55428,
3730 "sws-s-and-m-resources-loop-media-file-in-selected-tracks-toggle-slot-3": 55429,
3731 "sws-s-and-m-resources-loop-media-file-in-selected-tracks-toggle-slot-4": 55430,
3732 "sws-s-and-m-resources-open-project-last-slot": 55078,
3733 "sws-s-and-m-resources-open-project-last-slot-new-tab": 55079,
3734 "sws-s-and-m-resources-open-project-next-slot-cycle": 55080,
3735 "sws-s-and-m-resources-open-project-next-slot-new-tab-cycle": 55082,
3736 "sws-s-and-m-resources-open-project-previous-slot-cycle": 55081,
3737 "sws-s-and-m-resources-open-project-previous-slot-new-tab-cycle": 55083,
3738 "sws-s-and-m-resources-open-project-prompt-for-slot": 55289,
3739 "sws-s-and-m-resources-open-project-prompt-for-slot-new-tab": 55290,
3740 "sws-s-and-m-resources-open-project-slot-1": 55399,
3741 "sws-s-and-m-resources-open-project-slot-1-new-tab": 55403,
3742 "sws-s-and-m-resources-open-project-slot-2": 55400,
3743 "sws-s-and-m-resources-open-project-slot-2-new-tab": 55404,
3744 "sws-s-and-m-resources-open-project-slot-3": 55401,
3745 "sws-s-and-m-resources-open-project-slot-3-new-tab": 55405,
3746 "sws-s-and-m-resources-open-project-slot-4": 55402,
3747 "sws-s-and-m-resources-open-project-slot-4-new-tab": 55406,
3748 "sws-s-and-m-resources-paste-fx-chain-to-selected-items-all-takes-last-slot": 55059,
3749 "sws-s-and-m-resources-paste-fx-chain-to-selected-items-all-takes-prompt-for-slot": 55281,
3750 "sws-s-and-m-resources-paste-fx-chain-to-selected-items-last-slot": 55058,
3751 "sws-s-and-m-resources-paste-fx-chain-to-selected-items-prompt-for-slot": 55280,
3752 "sws-s-and-m-resources-paste-fx-chain-to-selected-items-slot-1": 55367,
3753 "sws-s-and-m-resources-paste-fx-chain-to-selected-items-slot-2": 55368,
3754 "sws-s-and-m-resources-paste-fx-chain-to-selected-items-slot-3": 55369,
3755 "sws-s-and-m-resources-paste-fx-chain-to-selected-items-slot-4": 55370,
3756 "sws-s-and-m-resources-paste-fx-chain-to-selected-tracks-last-slot": 55061,
3757 "sws-s-and-m-resources-paste-fx-chain-to-selected-tracks-prompt-for-slot": 55283,
3758 "sws-s-and-m-resources-paste-fx-chain-to-selected-tracks-slot-1": 55375,
3759 "sws-s-and-m-resources-paste-fx-chain-to-selected-tracks-slot-2": 55376,
3760 "sws-s-and-m-resources-paste-fx-chain-to-selected-tracks-slot-3": 55377,
3761 "sws-s-and-m-resources-paste-fx-chain-to-selected-tracks-slot-4": 55378,
3762 "sws-s-and-m-resources-paste-replace-fx-chain-to-selected-items-all-takes-last-slot": 55057,
3763 "sws-s-and-m-resources-paste-replace-fx-chain-to-selected-items-all-takes-prompt-for-slot": 55279,
3764 "sws-s-and-m-resources-paste-replace-fx-chain-to-selected-items-last-slot": 55056,
3765 "sws-s-and-m-resources-paste-replace-fx-chain-to-selected-items-prompt-for-slot": 55278,
3766 "sws-s-and-m-resources-paste-replace-fx-chain-to-selected-items-slot-1": 55363,
3767 "sws-s-and-m-resources-paste-replace-fx-chain-to-selected-items-slot-2": 55364,
3768 "sws-s-and-m-resources-paste-replace-fx-chain-to-selected-items-slot-3": 55365,
3769 "sws-s-and-m-resources-paste-replace-fx-chain-to-selected-items-slot-4": 55366,
3770 "sws-s-and-m-resources-paste-replace-fx-chain-to-selected-tracks-last-slot": 55060,
3771 "sws-s-and-m-resources-paste-replace-fx-chain-to-selected-tracks-prompt-for-slot": 55282,
3772 "sws-s-and-m-resources-paste-replace-fx-chain-to-selected-tracks-slot-1": 55371,
3773 "sws-s-and-m-resources-paste-replace-fx-chain-to-selected-tracks-slot-2": 55372,
3774 "sws-s-and-m-resources-paste-replace-fx-chain-to-selected-tracks-slot-3": 55373,
3775 "sws-s-and-m-resources-paste-replace-fx-chain-to-selected-tracks-slot-4": 55374,
3776 "sws-s-and-m-resources-paste-replace-template-items-to-selected-tracks-last-slot": 55072,
3777 "sws-s-and-m-resources-paste-replace-template-items-to-selected-tracks-last-slot-55287": 55287,
3778 "sws-s-and-m-resources-paste-replace-template-items-to-selected-tracks-slot-1": 55379,
3779 "sws-s-and-m-resources-paste-replace-template-items-to-selected-tracks-slot-2": 55380,
3780 "sws-s-and-m-resources-paste-replace-template-items-to-selected-tracks-slot-3": 55381,
3781 "sws-s-and-m-resources-paste-replace-template-items-to-selected-tracks-slot-4": 55382,
3782 "sws-s-and-m-resources-paste-template-items-to-selected-tracks-last-slot": 55073,
3783 "sws-s-and-m-resources-paste-template-items-to-selected-tracks-last-slot-55288": 55288,
3784 "sws-s-and-m-resources-paste-template-items-to-selected-tracks-slot-1": 55383,
3785 "sws-s-and-m-resources-paste-template-items-to-selected-tracks-slot-2": 55384,
3786 "sws-s-and-m-resources-paste-template-items-to-selected-tracks-slot-3": 55385,
3787 "sws-s-and-m-resources-paste-template-items-to-selected-tracks-slot-4": 55386,
3788 "sws-s-and-m-resources-play-media-file-in-selected-tracks-last-slot": 55090,
3789 "sws-s-and-m-resources-play-media-file-in-selected-tracks-slot-1": 55407,
3790 "sws-s-and-m-resources-play-media-file-in-selected-tracks-slot-2": 55408,
3791 "sws-s-and-m-resources-play-media-file-in-selected-tracks-slot-3": 55409,
3792 "sws-s-and-m-resources-play-media-file-in-selected-tracks-slot-4": 55410,
3793 "sws-s-and-m-resources-play-media-file-in-selected-tracks-sync-with-next-measure-slot-1": 55415,
3794 "sws-s-and-m-resources-play-media-file-in-selected-tracks-sync-with-next-measure-slot-2": 55416,
3795 "sws-s-and-m-resources-play-media-file-in-selected-tracks-sync-with-next-measure-slot-3": 55417,
3796 "sws-s-and-m-resources-play-media-file-in-selected-tracks-sync-with-next-measure-slot-4": 55418,
3797 "sws-s-and-m-resources-play-media-file-in-selected-tracks-toggle-last-slot": 55092,
3798 "sws-s-and-m-resources-play-media-file-in-selected-tracks-toggle-pause-last-slot": 55094,
3799 "sws-s-and-m-resources-play-media-file-in-selected-tracks-toggle-pause-slot-1": 55431,
3800 "sws-s-and-m-resources-play-media-file-in-selected-tracks-toggle-pause-slot-2": 55432,
3801 "sws-s-and-m-resources-play-media-file-in-selected-tracks-toggle-pause-slot-3": 55433,
3802 "sws-s-and-m-resources-play-media-file-in-selected-tracks-toggle-pause-slot-4": 55434,
3803 "sws-s-and-m-resources-play-media-file-in-selected-tracks-toggle-slot-1": 55423,
3804 "sws-s-and-m-resources-play-media-file-in-selected-tracks-toggle-slot-2": 55424,
3805 "sws-s-and-m-resources-play-media-file-in-selected-tracks-toggle-slot-3": 55425,
3806 "sws-s-and-m-resources-play-media-file-in-selected-tracks-toggle-slot-4": 55426,
3807 "sws-s-and-m-resources-set-add-media-file-option-to-default": 55099,
3808 "sws-s-and-m-resources-set-add-media-file-option-to-stretch-loop-to-fit-time-sel": 55100,
3809 "sws-s-and-m-resources-set-add-media-file-option-to-try-to-match-tempo-0-5x": 55101,
3810 "sws-s-and-m-resources-set-add-media-file-option-to-try-to-match-tempo-1x": 55102,
3811 "sws-s-and-m-resources-set-add-media-file-option-to-try-to-match-tempo-2x": 55103,
3812 "sws-s-and-m-resources-set-track-icon-for-selected-tracks-last-slot": 55110,
3813 "sws-s-and-m-resources-set-track-icon-for-selected-tracks-slot-1": 55451,
3814 "sws-s-and-m-resources-set-track-icon-for-selected-tracks-slot-2": 55452,
3815 "sws-s-and-m-resources-set-track-icon-for-selected-tracks-slot-3": 55453,
3816 "sws-s-and-m-resources-set-track-icon-for-selected-tracks-slot-4": 55454,
3817 "sws-s-and-m-resources-show-image-last-slot": 55109,
3818 "sws-s-and-m-resources-show-image-slot-1": 55447,
3819 "sws-s-and-m-resources-show-image-slot-2": 55448,
3820 "sws-s-and-m-resources-show-image-slot-3": 55449,
3821 "sws-s-and-m-resources-show-image-slot-4": 55450,
3822 "sws-s-and-m-resources-show-next-image-slot": 55105,
3823 "sws-s-and-m-resources-show-previous-image-slot": 55106,
3824 "sws-s-and-m-resources-stop-all-playing-media-files": 55085,
3825 "sws-s-and-m-resources-stop-all-playing-media-files-in-selected-tracks": 55086,
3826 "sws-s-and-m-restore-selected-tracks-folder-compact-states": 55183,
3827 "sws-s-and-m-restore-selected-tracks-folder-states": 55177,
3828 "sws-s-and-m-save-default-track-send-preferences": 54956,
3829 "sws-s-and-m-save-selected-tracks-folder-compact-states": 55182,
3830 "sws-s-and-m-save-selected-tracks-folder-states": 55176,
3831 "sws-s-and-m-scroll-to-selected-item-no-undo": 55122,
3832 "sws-s-and-m-select-fx-1-for-selected-tracks": 55475,
3833 "sws-s-and-m-select-fx-2-for-selected-tracks": 55476,
3834 "sws-s-and-m-select-fx-3-for-selected-tracks": 55477,
3835 "sws-s-and-m-select-fx-4-for-selected-tracks": 55478,
3836 "sws-s-and-m-select-fx-5-for-selected-tracks": 55479,
3837 "sws-s-and-m-select-fx-6-for-selected-tracks": 55480,
3838 "sws-s-and-m-select-fx-7-for-selected-tracks": 55481,
3839 "sws-s-and-m-select-fx-8-for-selected-tracks": 55482,
3840 "sws-s-and-m-select-last-fx-for-selected-tracks": 54984,
3841 "sws-s-and-m-select-next-fx-cycling-for-selected-tracks": 54986,
3842 "sws-s-and-m-select-only-track-with-selected-envelope": 55218,
3843 "sws-s-and-m-select-previous-fx-cycling-for-selected-tracks": 54985,
3844 "sws-s-and-m-select-project-midi-osc-only": 55038,
3845 "sws-s-and-m-send-all-notes-off-to-selected-tracks": 55259,
3846 "sws-s-and-m-send-all-sounds-off-to-selected-tracks": 55260,
3847 "sws-s-and-m-set-active-take-pan-envelope-to-100-percent-left": 55197,
3848 "sws-s-and-m-set-active-take-pan-envelope-to-100-percent-right": 55196,
3849 "sws-s-and-m-set-active-take-pan-envelope-to-center": 55198,
3850 "sws-s-and-m-set-all-fx-except-1-offline-for-selected-tracks": 55347,
3851 "sws-s-and-m-set-all-fx-except-2-offline-for-selected-tracks": 55348,
3852 "sws-s-and-m-set-all-fx-except-3-offline-for-selected-tracks": 55349,
3853 "sws-s-and-m-set-all-fx-except-4-offline-for-selected-tracks": 55350,
3854 "sws-s-and-m-set-all-fx-except-5-offline-for-selected-tracks": 55351,
3855 "sws-s-and-m-set-all-fx-except-6-offline-for-selected-tracks": 55352,
3856 "sws-s-and-m-set-all-fx-except-7-offline-for-selected-tracks": 55353,
3857 "sws-s-and-m-set-all-fx-except-8-offline-for-selected-tracks": 55354,
3858 "sws-s-and-m-set-all-take-fx-offline-for-selected-items": 55009,
3859 "sws-s-and-m-set-all-take-fx-online-for-selected-items": 55010,
3860 "sws-s-and-m-set-default-track-sends-to-audio-and-midi": 54958,
3861 "sws-s-and-m-set-default-track-sends-to-audio-only": 54959,
3862 "sws-s-and-m-set-default-track-sends-to-midi-only": 54960,
3863 "sws-s-and-m-set-fx-1-offline-for-selected-tracks": 55307,
3864 "sws-s-and-m-set-fx-1-online-for-selected-tracks": 55299,
3865 "sws-s-and-m-set-fx-2-offline-for-selected-tracks": 55308,
3866 "sws-s-and-m-set-fx-2-online-for-selected-tracks": 55300,
3867 "sws-s-and-m-set-fx-3-offline-for-selected-tracks": 55309,
3868 "sws-s-and-m-set-fx-3-online-for-selected-tracks": 55301,
3869 "sws-s-and-m-set-fx-4-offline-for-selected-tracks": 55310,
3870 "sws-s-and-m-set-fx-4-online-for-selected-tracks": 55302,
3871 "sws-s-and-m-set-fx-5-offline-for-selected-tracks": 55311,
3872 "sws-s-and-m-set-fx-5-online-for-selected-tracks": 55303,
3873 "sws-s-and-m-set-fx-6-offline-for-selected-tracks": 55312,
3874 "sws-s-and-m-set-fx-6-online-for-selected-tracks": 55304,
3875 "sws-s-and-m-set-fx-7-offline-for-selected-tracks": 55313,
3876 "sws-s-and-m-set-fx-7-online-for-selected-tracks": 55305,
3877 "sws-s-and-m-set-fx-8-offline-for-selected-tracks": 55314,
3878 "sws-s-and-m-set-fx-8-online-for-selected-tracks": 55306,
3879 "sws-s-and-m-set-global-startup-action": 55045,
3880 "sws-s-and-m-set-last-fx-offline-for-selected-tracks": 54994,
3881 "sws-s-and-m-set-last-fx-online-for-selected-tracks": 54992,
3882 "sws-s-and-m-set-project-startup-action": 55042,
3883 "sws-s-and-m-set-selected-fx-offline-for-selected-tracks": 54995,
3884 "sws-s-and-m-set-selected-fx-online-for-selected-tracks": 54993,
3885 "sws-s-and-m-set-selected-tracks-folder-states-to-last-in-folder": 55179,
3886 "sws-s-and-m-set-selected-tracks-folder-states-to-last-of-all-folders": 55178,
3887 "sws-s-and-m-set-selected-tracks-folder-states-to-normal": 55181,
3888 "sws-s-and-m-set-selected-tracks-folder-states-to-parent": 55180,
3889 "sws-s-and-m-set-selected-tracks-midi-input-to-all-channels": 55234,
3890 "sws-s-and-m-set-selected-tracks-midi-input-to-channel-1": 55699,
3891 "sws-s-and-m-set-selected-tracks-midi-input-to-channel-10": 55708,
3892 "sws-s-and-m-set-selected-tracks-midi-input-to-channel-11": 55709,
3893 "sws-s-and-m-set-selected-tracks-midi-input-to-channel-12": 55710,
3894 "sws-s-and-m-set-selected-tracks-midi-input-to-channel-13": 55711,
3895 "sws-s-and-m-set-selected-tracks-midi-input-to-channel-14": 55712,
3896 "sws-s-and-m-set-selected-tracks-midi-input-to-channel-15": 55713,
3897 "sws-s-and-m-set-selected-tracks-midi-input-to-channel-16": 55714,
3898 "sws-s-and-m-set-selected-tracks-midi-input-to-channel-2": 55700,
3899 "sws-s-and-m-set-selected-tracks-midi-input-to-channel-3": 55701,
3900 "sws-s-and-m-set-selected-tracks-midi-input-to-channel-4": 55702,
3901 "sws-s-and-m-set-selected-tracks-midi-input-to-channel-5": 55703,
3902 "sws-s-and-m-set-selected-tracks-midi-input-to-channel-6": 55704,
3903 "sws-s-and-m-set-selected-tracks-midi-input-to-channel-7": 55705,
3904 "sws-s-and-m-set-selected-tracks-midi-input-to-channel-8": 55706,
3905 "sws-s-and-m-set-selected-tracks-midi-input-to-channel-9": 55707,
3906 "sws-s-and-m-set-selected-tracks-to-first-unused-group-default-flags": 55175,
3907 "sws-s-and-m-set-selected-tracks-to-group-1-default-flags": 55547,
3908 "sws-s-and-m-set-selected-tracks-to-group-2-default-flags": 55548,
3909 "sws-s-and-m-set-selected-tracks-to-group-3-default-flags": 55549,
3910 "sws-s-and-m-set-selected-tracks-to-group-4-default-flags": 55550,
3911 "sws-s-and-m-set-selected-tracks-to-group-5-default-flags": 55551,
3912 "sws-s-and-m-set-selected-tracks-to-group-6-default-flags": 55552,
3913 "sws-s-and-m-set-selected-tracks-to-group-7-default-flags": 55553,
3914 "sws-s-and-m-set-selected-tracks-to-group-8-default-flags": 55554,
3915 "sws-s-and-m-show-all-floating-fx-windows": 54965,
3916 "sws-s-and-m-show-all-floating-fx-windows-for-selected-tracks": 54967,
3917 "sws-s-and-m-show-all-fx-chain-windows": 54966,
3918 "sws-s-and-m-show-and-unbypass-take-mute-envelope": 55186,
3919 "sws-s-and-m-show-and-unbypass-take-pan-envelope": 55185,
3920 "sws-s-and-m-show-and-unbypass-take-pitch-envelope": 55199,
3921 "sws-s-and-m-show-and-unbypass-take-volume-envelope": 55184,
3922 "sws-s-and-m-show-fx-chain-for-selected-tracks-fx-1": 55483,
3923 "sws-s-and-m-show-fx-chain-for-selected-tracks-fx-2": 55484,
3924 "sws-s-and-m-show-fx-chain-for-selected-tracks-fx-3": 55485,
3925 "sws-s-and-m-show-fx-chain-for-selected-tracks-fx-4": 55486,
3926 "sws-s-and-m-show-fx-chain-for-selected-tracks-fx-5": 55487,
3927 "sws-s-and-m-show-fx-chain-for-selected-tracks-fx-6": 55488,
3928 "sws-s-and-m-show-fx-chain-for-selected-tracks-fx-7": 55489,
3929 "sws-s-and-m-show-fx-chain-for-selected-tracks-fx-8": 55490,
3930 "sws-s-and-m-show-fx-chain-for-selected-tracks-selected-fx": 54978,
3931 "sws-s-and-m-show-project-global-startup-actions": 55044,
3932 "sws-s-and-m-show-take-mute-envelope": 55192,
3933 "sws-s-and-m-show-take-pan-envelope": 55191,
3934 "sws-s-and-m-show-take-pitch-envelope": 55201,
3935 "sws-s-and-m-show-take-volume-envelope": 55190,
3936 "sws-s-and-m-show-theme-helper-all-tracks": 55263,
3937 "sws-s-and-m-show-theme-helper-selected-tracks": 55264,
3938 "sws-s-and-m-split-and-select-items-in-region-near-cursor": 55166,
3939 "sws-s-and-m-split-selected-items-at-edit-cursor-midi-or-prior-zero-crossing-audio": 55163,
3940 "sws-s-and-m-split-selected-items-at-time-selection-edit-cursor-midi-or-prior-zero-crossing-audio": 55164,
3941 "sws-s-and-m-takes-activate-lane-under-mouse-cursor": 55139,
3942 "sws-s-and-m-takes-activate-lanes-from-selected-items": 55138,
3943 "sws-s-and-m-takes-clear-active-takes-items": 55137,
3944 "sws-s-and-m-takes-move-active-down-cycling-in-selected-items": 55143,
3945 "sws-s-and-m-takes-move-active-up-cycling-in-selected-items": 55142,
3946 "sws-s-and-m-takes-remove-empty-midi-takes-items-among-selected-items": 55141,
3947 "sws-s-and-m-takes-remove-empty-takes-items-among-selected-items": 55140,
3948 "sws-s-and-m-toggle-all-fx-bypass-for-selected-tracks": 55003,
3949 "sws-s-and-m-toggle-all-fx-except-selected-bypass-for-selected-tracks": 55007,
3950 "sws-s-and-m-toggle-all-fx-except-selected-online-offline-for-selected-tracks": 55006,
3951 "sws-s-and-m-toggle-all-fx-online-offline-for-selected-tracks": 54996,
3952 "sws-s-and-m-toggle-all-take-fx-bypass-for-selected-items": 55011,
3953 "sws-s-and-m-toggle-all-take-fx-online-offline-for-selected-items": 55008,
3954 "sws-s-and-m-toggle-arming-of-all-active-envelopes-for-selected-tracks": 55208,
3955 "sws-s-and-m-toggle-arming-of-all-plugin-envelopes-for-selected-tracks": 55217,
3956 "sws-s-and-m-toggle-arming-of-all-receive-mute-envelopes-for-selected-tracks": 55216,
3957 "sws-s-and-m-toggle-arming-of-all-receive-pan-envelopes-for-selected-tracks": 55215,
3958 "sws-s-and-m-toggle-arming-of-all-receive-volume-envelopes-for-selected-tracks": 55214,
3959 "sws-s-and-m-toggle-arming-of-mute-envelope-for-selected-tracks": 55213,
3960 "sws-s-and-m-toggle-arming-of-pan-envelope-for-selected-tracks": 55212,
3961 "sws-s-and-m-toggle-arming-of-volume-envelope-for-selected-tracks": 55211,
3962 "sws-s-and-m-toggle-auto-marker-coloring-enable": 53002,
3963 "sws-s-and-m-toggle-auto-region-coloring-enable": 53003,
3964 "sws-s-and-m-toggle-auto-track-icon-enable": 53004,
3965 "sws-s-and-m-toggle-auto-track-layout-enable": 53005,
3966 "sws-s-and-m-toggle-float-fx-1-for-selected-tracks": 55507,
3967 "sws-s-and-m-toggle-float-fx-2-for-selected-tracks": 55508,
3968 "sws-s-and-m-toggle-float-fx-3-for-selected-tracks": 55509,
3969 "sws-s-and-m-toggle-float-fx-4-for-selected-tracks": 55510,
3970 "sws-s-and-m-toggle-float-fx-5-for-selected-tracks": 55511,
3971 "sws-s-and-m-toggle-float-fx-6-for-selected-tracks": 55512,
3972 "sws-s-and-m-toggle-float-fx-7-for-selected-tracks": 55513,
3973 "sws-s-and-m-toggle-float-fx-8-for-selected-tracks": 55514,
3974 "sws-s-and-m-toggle-float-selected-fx-for-selected-tracks": 54983,
3975 "sws-s-and-m-toggle-fx-1-bypass-for-selected-tracks": 55339,
3976 "sws-s-and-m-toggle-fx-1-online-offline-for-selected-tracks": 55315,
3977 "sws-s-and-m-toggle-fx-2-bypass-for-selected-tracks": 55340,
3978 "sws-s-and-m-toggle-fx-2-online-offline-for-selected-tracks": 55316,
3979 "sws-s-and-m-toggle-fx-3-bypass-for-selected-tracks": 55341,
3980 "sws-s-and-m-toggle-fx-3-online-offline-for-selected-tracks": 55317,
3981 "sws-s-and-m-toggle-fx-4-bypass-for-selected-tracks": 55342,
3982 "sws-s-and-m-toggle-fx-4-online-offline-for-selected-tracks": 55318,
3983 "sws-s-and-m-toggle-fx-5-bypass-for-selected-tracks": 55343,
3984 "sws-s-and-m-toggle-fx-5-online-offline-for-selected-tracks": 55319,
3985 "sws-s-and-m-toggle-fx-6-bypass-for-selected-tracks": 55344,
3986 "sws-s-and-m-toggle-fx-6-online-offline-for-selected-tracks": 55320,
3987 "sws-s-and-m-toggle-fx-7-bypass-for-selected-tracks": 55345,
3988 "sws-s-and-m-toggle-fx-7-online-offline-for-selected-tracks": 55321,
3989 "sws-s-and-m-toggle-fx-8-bypass-for-selected-tracks": 55346,
3990 "sws-s-and-m-toggle-fx-8-online-offline-for-selected-tracks": 55322,
3991 "sws-s-and-m-toggle-last-fx-bypass-for-selected-tracks": 54997,
3992 "sws-s-and-m-toggle-last-fx-online-offline-for-selected-tracks": 54990,
3993 "sws-s-and-m-toggle-selected-fx-bypass-for-selected-tracks": 54998,
3994 "sws-s-and-m-toggle-selected-fx-online-offline-for-selected-tracks": 54991,
3995 "sws-s-and-m-toggle-show-all-floating-fx": 54968,
3996 "sws-s-and-m-toggle-show-all-floating-fx-for-selected-tracks": 54970,
3997 "sws-s-and-m-toggle-show-all-fx-chain-windows": 54969,
3998 "sws-s-and-m-toggle-show-fx-chain-windows-for-selected-tracks": 54980,
3999 "sws-s-and-m-toggle-show-take-mute-envelope": 55205,
4000 "sws-s-and-m-toggle-show-take-pan-envelope": 55204,
4001 "sws-s-and-m-toggle-show-take-pitch-envelope": 55206,
4002 "sws-s-and-m-toggle-show-take-volume-envelope": 55203,
4003 "sws-s-and-m-toggle-toolbars-auto-refresh-enable": 55219,
4004 "sws-s-and-m-toolbar-toggle-offscreen-item-selection": 55225,
4005 "sws-s-and-m-toolbar-toggle-offscreen-item-selection-bottom": 55224,
4006 "sws-s-and-m-toolbar-toggle-offscreen-item-selection-left": 55221,
4007 "sws-s-and-m-toolbar-toggle-offscreen-item-selection-right": 55222,
4008 "sws-s-and-m-toolbar-toggle-offscreen-item-selection-top": 55223,
4009 "sws-s-and-m-toolbar-toggle-track-envelopes-in-touch-latch-latch-preview-write": 55220,
4010 "sws-s-and-m-trigger-next-preset-for-fx-1-of-selected-tracks": 55459,
4011 "sws-s-and-m-trigger-next-preset-for-fx-2-of-selected-tracks": 55460,
4012 "sws-s-and-m-trigger-next-preset-for-fx-3-of-selected-tracks": 55461,
4013 "sws-s-and-m-trigger-next-preset-for-fx-4-of-selected-tracks": 55462,
4014 "sws-s-and-m-trigger-next-preset-for-last-touched-fx": 55117,
4015 "sws-s-and-m-trigger-next-preset-for-selected-fx-of-selected-tracks": 55115,
4016 "sws-s-and-m-trigger-preset-for-fx-1-of-selected-track-midi-osc-only": 55467,
4017 "sws-s-and-m-trigger-preset-for-fx-2-of-selected-track-midi-osc-only": 55468,
4018 "sws-s-and-m-trigger-preset-for-fx-3-of-selected-track-midi-osc-only": 55469,
4019 "sws-s-and-m-trigger-preset-for-fx-4-of-selected-track-midi-osc-only": 55470,
4020 "sws-s-and-m-trigger-preset-for-fx-5-of-selected-track-midi-osc-only": 55471,
4021 "sws-s-and-m-trigger-preset-for-fx-6-of-selected-track-midi-osc-only": 55472,
4022 "sws-s-and-m-trigger-preset-for-fx-7-of-selected-track-midi-osc-only": 55473,
4023 "sws-s-and-m-trigger-preset-for-fx-8-of-selected-track-midi-osc-only": 55474,
4024 "sws-s-and-m-trigger-preset-for-selected-fx-of-selected-track-midi-osc-only": 55014,
4025 "sws-s-and-m-trigger-previous-preset-for-fx-1-of-selected-tracks": 55463,
4026 "sws-s-and-m-trigger-previous-preset-for-fx-2-of-selected-tracks": 55464,
4027 "sws-s-and-m-trigger-previous-preset-for-fx-3-of-selected-tracks": 55465,
4028 "sws-s-and-m-trigger-previous-preset-for-fx-4-of-selected-tracks": 55466,
4029 "sws-s-and-m-trigger-previous-preset-for-last-touched-fx": 55118,
4030 "sws-s-and-m-trigger-previous-preset-for-selected-fx-of-selected-tracks": 55116,
4031 "sws-s-and-m-unbypass-all-fx-for-selected-tracks": 55005,
4032 "sws-s-and-m-unbypass-all-take-fx-for-selected-items": 55013,
4033 "sws-s-and-m-unbypass-fx-1-for-selected-tracks": 55331,
4034 "sws-s-and-m-unbypass-fx-2-for-selected-tracks": 55332,
4035 "sws-s-and-m-unbypass-fx-3-for-selected-tracks": 55333,
4036 "sws-s-and-m-unbypass-fx-4-for-selected-tracks": 55334,
4037 "sws-s-and-m-unbypass-fx-5-for-selected-tracks": 55335,
4038 "sws-s-and-m-unbypass-fx-6-for-selected-tracks": 55336,
4039 "sws-s-and-m-unbypass-fx-7-for-selected-tracks": 55337,
4040 "sws-s-and-m-unbypass-fx-8-for-selected-tracks": 55338,
4041 "sws-s-and-m-unbypass-last-fx-for-selected-tracks": 55001,
4042 "sws-s-and-m-unbypass-selected-fx-for-selected-tracks": 55002,
4043 "sws-s-and-m-unfloat-fx-1-for-selected-tracks": 55499,
4044 "sws-s-and-m-unfloat-fx-2-for-selected-tracks": 55500,
4045 "sws-s-and-m-unfloat-fx-3-for-selected-tracks": 55501,
4046 "sws-s-and-m-unfloat-fx-4-for-selected-tracks": 55502,
4047 "sws-s-and-m-unfloat-fx-5-for-selected-tracks": 55503,
4048 "sws-s-and-m-unfloat-fx-6-for-selected-tracks": 55504,
4049 "sws-s-and-m-unfloat-fx-7-for-selected-tracks": 55505,
4050 "sws-s-and-m-unfloat-fx-8-for-selected-tracks": 55506,
4051 "sws-s-and-m-unfloat-selected-fx-for-selected-tracks": 54982,
4052 "sws-s-and-m-unselect-offscreen-items": 55226,
4053 "sws-s-and-m-what-s-new": 53930,
4054 "sws-save-active-takes-on-selected-track-s": 53132,
4055 "sws-save-as-snapshot-1": 55743,
4056 "sws-save-as-snapshot-10": 55752,
4057 "sws-save-as-snapshot-11": 55753,
4058 "sws-save-as-snapshot-12": 55754,
4059 "sws-save-as-snapshot-2": 55744,
4060 "sws-save-as-snapshot-3": 55745,
4061 "sws-save-as-snapshot-4": 55746,
4062 "sws-save-as-snapshot-5": 55747,
4063 "sws-save-as-snapshot-6": 55748,
4064 "sws-save-as-snapshot-7": 55749,
4065 "sws-save-as-snapshot-8": 55750,
4066 "sws-save-as-snapshot-9": 55751,
4067 "sws-save-auto-crossfade-state": 53666,
4068 "sws-save-current-arrange-view-slot-1": 53826,
4069 "sws-save-current-arrange-view-slot-2": 53827,
4070 "sws-save-current-arrange-view-slot-3": 53828,
4071 "sws-save-current-arrange-view-slot-4": 53829,
4072 "sws-save-current-arrange-view-slot-5": 53830,
4073 "sws-save-current-snapshot-filter-options": 53191,
4074 "sws-save-current-track-selection": 53749,
4075 "sws-save-list-of-open-projects": 53216,
4076 "sws-save-loop-selection-slot-1": 55797,
4077 "sws-save-loop-selection-slot-2": 55798,
4078 "sws-save-loop-selection-slot-3": 55799,
4079 "sws-save-loop-selection-slot-4": 55800,
4080 "sws-save-loop-selection-slot-5": 55801,
4081 "sws-save-marker-set": 53084,
4082 "sws-save-master-fx-enabled-state": 53732,
4083 "sws-save-over-current-snapshot": 53164,
4084 "sws-save-selected-item-s": 53145,
4085 "sws-save-selected-track-s-items-states": 53151,
4086 "sws-save-selected-track-s-mutes-plus-receives-children": 53147,
4087 "sws-save-selected-track-s-selected-item-s-slot-1": 53134,
4088 "sws-save-selected-track-s-selected-item-s-slot-2": 53135,
4089 "sws-save-selected-track-s-selected-item-s-slot-3": 53136,
4090 "sws-save-selected-track-s-selected-item-s-slot-4": 53137,
4091 "sws-save-selected-track-s-selected-item-s-slot-5": 53138,
4092 "sws-save-selected-track-s-selected-items-states": 53153,
4093 "sws-save-time-selection-slot-1": 55787,
4094 "sws-save-time-selection-slot-2": 55788,
4095 "sws-save-time-selection-slot-3": 55789,
4096 "sws-save-time-selection-slot-4": 55790,
4097 "sws-save-time-selection-slot-5": 55791,
4098 "sws-save-transport-repeat-state": 53679,
4099 "sws-scroll-left-1-percent": 53824,
4100 "sws-scroll-left-10-percent": 53822,
4101 "sws-scroll-right-1-percent": 53825,
4102 "sws-scroll-right-10-percent": 53823,
4103 "sws-select-all-folder-start-tracks": 53762,
4104 "sws-select-all-folders-parents-only": 53761,
4105 "sws-select-all-non-folders": 53763,
4106 "sws-select-armed-tracks": 53772,
4107 "sws-select-children-of-selected-folder-track-s": 53756,
4108 "sws-select-current-snapshot-track-s": 53161,
4109 "sws-select-item-s-with-saved-state-on-selected-track-s": 53155,
4110 "sws-select-locked-items": 53657,
4111 "sws-select-locked-items-on-selected-track-s": 53658,
4112 "sws-select-lower-leftmost-item-on-selected-track-s": 53645,
4113 "sws-select-master-track": 53776,
4114 "sws-select-muted-items": 53649,
4115 "sws-select-muted-items-on-selected-track-s": 53651,
4116 "sws-select-muted-tracks": 53768,
4117 "sws-select-nearest-next-folder": 53766,
4118 "sws-select-nearest-previous-folder": 53767,
4119 "sws-select-next-folder": 53764,
4120 "sws-select-next-item-across-tracks": 53653,
4121 "sws-select-next-item-keeping-current-selection-across-tracks": 53655,
4122 "sws-select-only-children-of-selected-folders": 53755,
4123 "sws-select-only-parent-s-of-selected-folder-track-s": 53757,
4124 "sws-select-only-rec-armed-track-s": 53774,
4125 "sws-select-only-track-1": 55755,
4126 "sws-select-only-track-10": 55764,
4127 "sws-select-only-track-11": 55765,
4128 "sws-select-only-track-12": 55766,
4129 "sws-select-only-track-13": 55767,
4130 "sws-select-only-track-14": 55768,
4131 "sws-select-only-track-15": 55769,
4132 "sws-select-only-track-16": 55770,
4133 "sws-select-only-track-17": 55771,
4134 "sws-select-only-track-18": 55772,
4135 "sws-select-only-track-19": 55773,
4136 "sws-select-only-track-2": 55756,
4137 "sws-select-only-track-20": 55774,
4138 "sws-select-only-track-21": 55775,
4139 "sws-select-only-track-22": 55776,
4140 "sws-select-only-track-23": 55777,
4141 "sws-select-only-track-24": 55778,
4142 "sws-select-only-track-25": 55779,
4143 "sws-select-only-track-26": 55780,
4144 "sws-select-only-track-27": 55781,
4145 "sws-select-only-track-28": 55782,
4146 "sws-select-only-track-29": 55783,
4147 "sws-select-only-track-3": 55757,
4148 "sws-select-only-track-30": 55784,
4149 "sws-select-only-track-31": 55785,
4150 "sws-select-only-track-32": 55786,
4151 "sws-select-only-track-4": 55758,
4152 "sws-select-only-track-5": 55759,
4153 "sws-select-only-track-6": 55760,
4154 "sws-select-only-track-7": 55761,
4155 "sws-select-only-track-8": 55762,
4156 "sws-select-only-track-9": 55763,
4157 "sws-select-only-track-s-with-selected-item-s": 53753,
4158 "sws-select-parent-s-of-selected-folder-track-s": 53758,
4159 "sws-select-previous-folder": 53765,
4160 "sws-select-previous-item-across-tracks": 53654,
4161 "sws-select-previous-item-keeping-current-selection-across-tracks": 53656,
4162 "sws-select-soloed-tracks": 53770,
4163 "sws-select-tracks-with-active-routing-to-selected-track-s": 53773,
4164 "sws-select-tracks-with-flipped-phase": 53771,
4165 "sws-select-unmuted-items": 53650,
4166 "sws-select-unmuted-items-on-selected-track-s": 53652,
4167 "sws-select-unmuted-tracks": 53769,
4168 "sws-select-upper-leftmost-item-on-selected-track-s": 53646,
4169 "sws-set-all-master-track-outputs-muted": 53721,
4170 "sws-set-all-master-track-outputs-unmuted": 53720,
4171 "sws-set-all-selected-tracks-inputs-to-match-first-selected-track": 53747,
4172 "sws-set-all-takes-channel-mode-to-mono-downmix": 53621,
4173 "sws-set-all-takes-channel-mode-to-mono-left": 53622,
4174 "sws-set-all-takes-channel-mode-to-mono-right": 53623,
4175 "sws-set-all-takes-channel-mode-to-normal": 53619,
4176 "sws-set-all-takes-channel-mode-to-reverse-stereo": 53620,
4177 "sws-set-all-takes-preserve-pitch": 53625,
4178 "sws-set-all-takes-to-next-mono-channel-mode": 53639,
4179 "sws-set-all-takes-to-next-stereo-channel-mode": 53641,
4180 "sws-set-all-takes-to-prev-mono-channel-mode": 53640,
4181 "sws-set-all-takes-to-prev-stereo-channel-mode": 53642,
4182 "sws-set-auto-crossfade-off": 53669,
4183 "sws-set-auto-crossfade-on": 53668,
4184 "sws-set-item-fades-to-crossfade-lengths": 53385,
4185 "sws-set-item-fades-to-default-length": 53386,
4186 "sws-set-last-touched-track-to-match-track-selection-deprecated": 53754,
4187 "sws-set-master-mono": 53696,
4188 "sws-set-master-output-1-volume-to-0db": 53724,
4189 "sws-set-master-stereo": 53697,
4190 "sws-set-master-track-output-1-muted": 53710,
4191 "sws-set-master-track-output-1-unmuted": 53715,
4192 "sws-set-master-track-output-2-muted": 53711,
4193 "sws-set-master-track-output-2-unmuted": 53716,
4194 "sws-set-master-track-output-3-muted": 53712,
4195 "sws-set-master-track-output-3-unmuted": 53717,
4196 "sws-set-master-track-output-4-muted": 53713,
4197 "sws-set-master-track-output-4-unmuted": 53718,
4198 "sws-set-master-track-output-5-muted": 53714,
4199 "sws-set-master-track-output-5-unmuted": 53719,
4200 "sws-set-move-envelope-points-with-items-off": 53671,
4201 "sws-set-move-envelope-points-with-items-on": 53670,
4202 "sws-set-reaper-window-size-to-reaper-ini-setwndsize": 53779,
4203 "sws-set-rms-analysis-normalize-options": 53575,
4204 "sws-set-selected-folder-s-collapsed": 53605,
4205 "sws-set-selected-folder-s-small": 53607,
4206 "sws-set-selected-folder-s-uncollapsed": 53606,
4207 "sws-set-selected-item-s-to-color-black": 53036,
4208 "sws-set-selected-item-s-to-color-gradient": 53041,
4209 "sws-set-selected-item-s-to-color-gradient-per-track": 53040,
4210 "sws-set-selected-item-s-to-color-white": 53035,
4211 "sws-set-selected-item-s-to-custom-color-1": 53045,
4212 "sws-set-selected-item-s-to-custom-color-10": 53054,
4213 "sws-set-selected-item-s-to-custom-color-11": 53055,
4214 "sws-set-selected-item-s-to-custom-color-12": 53056,
4215 "sws-set-selected-item-s-to-custom-color-13": 53057,
4216 "sws-set-selected-item-s-to-custom-color-14": 53058,
4217 "sws-set-selected-item-s-to-custom-color-15": 53059,
4218 "sws-set-selected-item-s-to-custom-color-16": 53060,
4219 "sws-set-selected-item-s-to-custom-color-2": 53046,
4220 "sws-set-selected-item-s-to-custom-color-3": 53047,
4221 "sws-set-selected-item-s-to-custom-color-4": 53048,
4222 "sws-set-selected-item-s-to-custom-color-5": 53049,
4223 "sws-set-selected-item-s-to-custom-color-6": 53050,
4224 "sws-set-selected-item-s-to-custom-color-7": 53051,
4225 "sws-set-selected-item-s-to-custom-color-8": 53052,
4226 "sws-set-selected-item-s-to-custom-color-9": 53053,
4227 "sws-set-selected-item-s-to-next-custom-color": 53037,
4228 "sws-set-selected-item-s-to-one-random-custom-color": 53038,
4229 "sws-set-selected-item-s-to-ordered-custom-colors": 53043,
4230 "sws-set-selected-item-s-to-ordered-custom-colors-per-track": 53042,
4231 "sws-set-selected-item-s-to-random-custom-color-s": 53039,
4232 "sws-set-selected-item-s-to-respective-track-color": 53044,
4233 "sws-set-selected-items-length": 53643,
4234 "sws-set-selected-take-s-to-custom-color-1": 53066,
4235 "sws-set-selected-take-s-to-custom-color-10": 53075,
4236 "sws-set-selected-take-s-to-custom-color-11": 53076,
4237 "sws-set-selected-take-s-to-custom-color-12": 53077,
4238 "sws-set-selected-take-s-to-custom-color-13": 53078,
4239 "sws-set-selected-take-s-to-custom-color-14": 53079,
4240 "sws-set-selected-take-s-to-custom-color-15": 53080,
4241 "sws-set-selected-take-s-to-custom-color-16": 53081,
4242 "sws-set-selected-take-s-to-custom-color-2": 53067,
4243 "sws-set-selected-take-s-to-custom-color-3": 53068,
4244 "sws-set-selected-take-s-to-custom-color-4": 53069,
4245 "sws-set-selected-take-s-to-custom-color-5": 53070,
4246 "sws-set-selected-take-s-to-custom-color-6": 53071,
4247 "sws-set-selected-take-s-to-custom-color-7": 53072,
4248 "sws-set-selected-take-s-to-custom-color-8": 53073,
4249 "sws-set-selected-take-s-to-custom-color-9": 53074,
4250 "sws-set-selected-track-s-children-to-same-color": 53018,
4251 "sws-set-selected-track-s-item-s-to-custom-color": 53062,
4252 "sws-set-selected-track-s-item-s-to-one-random-color": 53061,
4253 "sws-set-selected-track-s-monitor-track-media-while-recording": 53738,
4254 "sws-set-selected-track-s-record-output-mode-based-on-items": 53737,
4255 "sws-set-selected-track-s-to-color-black": 53010,
4256 "sws-set-selected-track-s-to-color-white": 53009,
4257 "sws-set-selected-track-s-to-custom-color-1": 53019,
4258 "sws-set-selected-track-s-to-custom-color-10": 53028,
4259 "sws-set-selected-track-s-to-custom-color-11": 53029,
4260 "sws-set-selected-track-s-to-custom-color-12": 53030,
4261 "sws-set-selected-track-s-to-custom-color-13": 53031,
4262 "sws-set-selected-track-s-to-custom-color-14": 53032,
4263 "sws-set-selected-track-s-to-custom-color-15": 53033,
4264 "sws-set-selected-track-s-to-custom-color-16": 53034,
4265 "sws-set-selected-track-s-to-custom-color-2": 53020,
4266 "sws-set-selected-track-s-to-custom-color-3": 53021,
4267 "sws-set-selected-track-s-to-custom-color-4": 53022,
4268 "sws-set-selected-track-s-to-custom-color-5": 53023,
4269 "sws-set-selected-track-s-to-custom-color-6": 53024,
4270 "sws-set-selected-track-s-to-custom-color-7": 53025,
4271 "sws-set-selected-track-s-to-custom-color-8": 53026,
4272 "sws-set-selected-track-s-to-custom-color-9": 53027,
4273 "sws-set-selected-track-s-to-next-custom-color": 53013,
4274 "sws-set-selected-track-s-to-next-track-s-color": 53012,
4275 "sws-set-selected-track-s-to-one-random-custom-color": 53014,
4276 "sws-set-selected-track-s-to-ordered-custom-colors": 53017,
4277 "sws-set-selected-track-s-to-previous-track-s-color": 53011,
4278 "sws-set-selected-track-s-to-random-custom-color-s": 53015,
4279 "sws-set-selected-track-s-to-same-folder-as-previous-track": 53601,
4280 "sws-set-selected-tracks-pan-law-to-0-0-db": 53313,
4281 "sws-set-selected-tracks-pan-law-to-2-5-db": 53317,
4282 "sws-set-selected-tracks-pan-law-to-2-5-db-53321": 53321,
4283 "sws-set-selected-tracks-pan-law-to-3-0-db": 53316,
4284 "sws-set-selected-tracks-pan-law-to-3-0-db-53320": 53320,
4285 "sws-set-selected-tracks-pan-law-to-4-5-db": 53315,
4286 "sws-set-selected-tracks-pan-law-to-4-5-db-53319": 53319,
4287 "sws-set-selected-tracks-pan-law-to-6-0-db": 53314,
4288 "sws-set-selected-tracks-pan-law-to-6-0-db-53318": 53318,
4289 "sws-set-selected-tracks-pan-law-to-default": 53312,
4290 "sws-set-selected-tracks-to-color-gradient": 53016,
4291 "sws-set-snapshots-to-mix-mode": 53176,
4292 "sws-set-snapshots-to-visibility-mode": 53177,
4293 "sws-set-takes-in-selected-item-s-to-color-gradient": 53064,
4294 "sws-set-takes-in-selected-item-s-to-ordered-custom-colors": 53065,
4295 "sws-set-takes-in-selected-item-s-to-random-custom-color-s": 53063,
4296 "sws-set-time-selection-to-selected-items-skip-if-time-selection-exists": 53585,
4297 "sws-set-track-name-from-first-selected-item-in-project": 53745,
4298 "sws-set-track-name-from-first-selected-item-on-track": 53744,
4299 "sws-set-transport-repeat-state": 53681,
4300 "sws-shane-autorender-edit-project-metadata": 53932,
4301 "sws-shane-autorender-global-preferences": 53935,
4302 "sws-shane-autorender-open-render-path": 53933,
4303 "sws-shane-autorender-show-instructions": 53934,
4304 "sws-shane-batch-render-regions": 53931,
4305 "sws-show-all-tracks": 53206,
4306 "sws-show-all-tracks-in-mcp": 53207,
4307 "sws-show-all-tracks-in-tcp": 53208,
4308 "sws-show-dockers": 53683,
4309 "sws-show-master-track-in-track-control-panel": 53685,
4310 "sws-show-selected-track-s-hide-others": 53212,
4311 "sws-show-selected-track-s-in-mcp": 53199,
4312 "sws-show-selected-track-s-in-mcp-hide-others": 53210,
4313 "sws-show-selected-track-s-in-mcp-only": 53195,
4314 "sws-show-selected-track-s-in-tcp": 53200,
4315 "sws-show-selected-track-s-in-tcp-and-mcp": 53197,
4316 "sws-show-selected-track-s-in-tcp-hide-others": 53211,
4317 "sws-show-selected-track-s-in-tcp-only": 53196,
4318 "sws-show-tracklist": 53193,
4319 "sws-show-tracklist-with-filter-focused": 53194,
4320 "sws-sn-focus-midi-editor": 54938,
4321 "sws-snapshot-current-track-visibility": 53215,
4322 "sws-split-items-at-time-selection-razor-edit-areas-edit-cursor-also-during-playback-or-mouse-cursor": 53582,
4323 "sws-split-items-at-time-selection-razor-edit-areas-edit-cursor-play-cursor-during-playback-or-mouse-cursor": 53581,
4324 "sws-split-items-at-time-selection-razor-edit-areas-if-exists-else-at-edit-cursor-also-during-playback": 53580,
4325 "sws-split-items-at-time-selection-razor-edit-areas-if-exists-play-cursor-during-playback-else-at-edit-cursor": 53579,
4326 "sws-switch-to-last-project-tab": 53223,
4327 "sws-switch-to-project-tab-1": 53224,
4328 "sws-switch-to-project-tab-10": 53233,
4329 "sws-switch-to-project-tab-2": 53225,
4330 "sws-switch-to-project-tab-3": 53226,
4331 "sws-switch-to-project-tab-4": 53227,
4332 "sws-switch-to-project-tab-5": 53228,
4333 "sws-switch-to-project-tab-6": 53229,
4334 "sws-switch-to-project-tab-7": 53230,
4335 "sws-switch-to-project-tab-8": 53231,
4336 "sws-switch-to-project-tab-9": 53232,
4337 "sws-time-select-next-region": 53091,
4338 "sws-time-select-previous-region": 53092,
4339 "sws-toggle-auto-add-envelopes-when-tweaking-in-write-mode": 53675,
4340 "sws-toggle-auto-track-coloring-enable": 53001,
4341 "sws-toggle-between-current-and-saved-track-selection": 53751,
4342 "sws-toggle-checking-for-duplicate-inputs-when-recording": 53692,
4343 "sws-toggle-default-fade-time-to-zero": 53687,
4344 "sws-toggle-drag-zoom-enable-ruler-bottom-half": 53840,
4345 "sws-toggle-drag-zoom-enable-ruler-top-half": 53841,
4346 "sws-toggle-grid-lines-over-under-items": 53676,
4347 "sws-toggle-horizontal-zoom-to-selected-items": 53820,
4348 "sws-toggle-horizontal-zoom-to-selected-items-or-time-selection": 53819,
4349 "sws-toggle-horizontal-zoom-to-time-selection": 53821,
4350 "sws-toggle-invert-track-selection": 53752,
4351 "sws-toggle-marker-actions-enable": 53103,
4352 "sws-toggle-master-parent-send-on-selected-track-s": 53695,
4353 "sws-toggle-master-track-output-1-mute": 53698,
4354 "sws-toggle-master-track-output-10-mute": 53707,
4355 "sws-toggle-master-track-output-11-mute": 53708,
4356 "sws-toggle-master-track-output-12-mute": 53709,
4357 "sws-toggle-master-track-output-2-mute": 53699,
4358 "sws-toggle-master-track-output-3-mute": 53700,
4359 "sws-toggle-master-track-output-4-mute": 53701,
4360 "sws-toggle-master-track-output-5-mute": 53702,
4361 "sws-toggle-master-track-output-6-mute": 53703,
4362 "sws-toggle-master-track-output-7-mute": 53704,
4363 "sws-toggle-master-track-output-8-mute": 53705,
4364 "sws-toggle-master-track-output-9-mute": 53706,
4365 "sws-toggle-master-track-select": 53778,
4366 "sws-toggle-move-cursor-to-end-of-recorded-media-on-stop": 53672,
4367 "sws-toggle-mute-of-children-of-selected-folder-s": 53600,
4368 "sws-toggle-mute-of-items-on-selected-track-s": 53608,
4369 "sws-toggle-mute-on-receives-for-selected-track-s": 53727,
4370 "sws-toggle-ruler-red-while-recording": 53008,
4371 "sws-toggle-seek-playback-on-item-move-size": 53673,
4372 "sws-toggle-seek-playback-on-loop-point-change": 53674,
4373 "sws-toggle-selected-track-s-fully-visible-hidden": 53205,
4374 "sws-toggle-selected-track-s-visible-in-mcp": 53203,
4375 "sws-toggle-selected-track-s-visible-in-tcp": 53204,
4376 "sws-toggle-selecting-one-grouped-item-selects-group": 53677,
4377 "sws-toggle-selection-of-items-on-selected-track-s": 53648,
4378 "sws-toggle-snapshot-apply-filter-to-recall": 53188,
4379 "sws-toggle-snapshot-fx": 53183,
4380 "sws-toggle-snapshot-mute": 53178,
4381 "sws-toggle-snapshot-pan": 53180,
4382 "sws-toggle-snapshot-selected-only-on-recall": 53187,
4383 "sws-toggle-snapshot-selected-only-on-save": 53186,
4384 "sws-toggle-snapshot-selection": 53185,
4385 "sws-toggle-snapshot-sends": 53182,
4386 "sws-toggle-snapshot-show-only-for-selected-tracks": 53189,
4387 "sws-toggle-snapshot-solo": 53179,
4388 "sws-toggle-snapshot-visibility": 53184,
4389 "sws-toggle-snapshot-vol": 53181,
4390 "sws-toggle-zoom-to-selected-items": 53807,
4391 "sws-toggle-zoom-to-selected-items-hide-other-tracks": 53809,
4392 "sws-toggle-zoom-to-selected-items-hide-other-tracks-ignore-last-track-s-envelope-lanes": 53818,
4393 "sws-toggle-zoom-to-selected-items-ignore-last-track-s-envelope-lanes": 53816,
4394 "sws-toggle-zoom-to-selected-items-minimize-other-tracks": 53808,
4395 "sws-toggle-zoom-to-selected-items-minimize-other-tracks-ignore-last-track-s-envelope-lanes": 53817,
4396 "sws-toggle-zoom-to-selected-items-or-time-selection": 53804,
4397 "sws-toggle-zoom-to-selected-items-or-time-selection-hide-other-tracks": 53806,
4398 "sws-toggle-zoom-to-selected-items-or-time-selection-hide-other-tracks-ignore-last-track-s-envelope-lanes": 53815,
4399 "sws-toggle-zoom-to-selected-items-or-time-selection-ignore-last-track-s-envelope-lanes": 53813,
4400 "sws-toggle-zoom-to-selected-items-or-time-selection-minimize-other-tracks": 53805,
4401 "sws-toggle-zoom-to-selected-items-or-time-selection-minimize-other-tracks-ignore-last-track-s-envelope-lanes": 53814,
4402 "sws-toggle-zoom-to-selected-tracks-and-time-selection": 53801,
4403 "sws-toggle-zoom-to-selected-tracks-and-time-selection-hide-others": 53803,
4404 "sws-toggle-zoom-to-selected-tracks-and-time-selection-hide-others-ignore-last-track-s-envelope-lanes": 53812,
4405 "sws-toggle-zoom-to-selected-tracks-and-time-selection-ignore-last-track-s-envelope-lanes": 53810,
4406 "sws-toggle-zoom-to-selected-tracks-and-time-selection-minimize-others": 53802,
4407 "sws-toggle-zoom-to-selected-tracks-and-time-selection-minimize-others-ignore-last-track-s-envelope-lanes": 53811,
4408 "sws-toolbar-arm-toggle": 53740,
4409 "sws-toolbar-mute-toggle": 53748,
4410 "sws-toolbar-solo-toggle": 53746,
4411 "sws-transport-record-stop": 53678,
4412 "sws-unbypass-fx-on-selected-track-s": 53731,
4413 "sws-undo-edit-cursor-move": 53586,
4414 "sws-undo-zoom": 53836,
4415 "sws-unindent-selected-track-s": 53604,
4416 "sws-unmute-all-receives-for-selected-track-s": 53726,
4417 "sws-unmute-all-sends-from-selected-track-s": 53729,
4418 "sws-unmute-children-of-selected-folder-s": 53599,
4419 "sws-unselect-all-items-on-selected-track-s": 53644,
4420 "sws-unselect-all-items-tracks-env-points": 53584,
4421 "sws-unselect-all-items-tracks-env-points-depending-on-focus": 53583,
4422 "sws-unselect-children-of-selected-folder-track-s": 53760,
4423 "sws-unselect-items-without-render-in-source-filename": 53662,
4424 "sws-unselect-items-without-stems-in-source-filename": 53661,
4425 "sws-unselect-master-track": 53777,
4426 "sws-unselect-parent-s-of-selected-folder-track-s": 53759,
4427 "sws-unselect-rec-armed-track-s": 53775,
4428 "sws-unselect-upper-leftmost-item-on-selected-track-s": 53647,
4429 "sws-unset-selected-track-s-monitor-track-media-while-recording": 53739,
4430 "sws-unset-transport-repeat-state": 53682,
4431 "sws-vertical-zoom-to-selected-items": 53786,
4432 "sws-vertical-zoom-to-selected-items-ignore-last-track-s-envelope-lanes": 53790,
4433 "sws-vertical-zoom-to-selected-items-minimize-others": 53787,
4434 "sws-vertical-zoom-to-selected-items-minimize-others-ignore-last-track-s-envelope-lanes": 53791,
4435 "sws-vertical-zoom-to-selected-tracks": 53784,
4436 "sws-vertical-zoom-to-selected-tracks-ignore-last-track-s-envelope-lanes": 53788,
4437 "sws-vertical-zoom-to-selected-tracks-minimize-others": 53785,
4438 "sws-vertical-zoom-to-selected-tracks-minimize-others-ignore-last-track-s-envelope-lanes": 53789,
4439 "sws-wait-for-next-bar-if-playing": 53663,
4440 "sws-wait-for-next-beat-if-playing": 53664,
4441 "sws-wait-until-end-of-loop-if-playing": 53665,
4442 "sws-wol-adjust-envelope-or-track-height-under-mouse-cursor-midi-cc-relative-mousewheel": 54892,
4443 "sws-wol-adjust-envelope-or-track-height-under-mouse-cursor-zoom-center-to-mouse-cursor-midi-cc-relative-mousewheel":
4444 54893,
4445 "sws-wol-adjust-selected-envelope-height-midi-cc-relative-mousewheel": 54886,
4446 "sws-wol-adjust-selected-envelope-height-zoom-center-to-middle-arrange-midi-cc-relative-mousewheel": 54887,
4447 "sws-wol-adjust-selected-envelope-height-zoom-center-to-mouse-cursor-midi-cc-relative-mousewheel": 54888,
4448 "sws-wol-adjust-selected-envelope-or-last-touched-track-height-midi-cc-relative-mousewheel": 54889,
4449 "sws-wol-adjust-selected-envelope-or-last-touched-track-height-zoom-center-to-middle-arrange-midi-cc-relative-mousewheel":
4450 54890,
4451 "sws-wol-adjust-selected-envelope-or-last-touched-track-height-zoom-center-to-mouse-cursor-midi-cc-relative-mousewheel":
4452 54891,
4453 "sws-wol-apply-height-to-selected-envelope-slot-1": 54912,
4454 "sws-wol-apply-height-to-selected-envelope-slot-2": 54913,
4455 "sws-wol-apply-height-to-selected-envelope-slot-3": 54914,
4456 "sws-wol-apply-height-to-selected-envelope-slot-4": 54915,
4457 "sws-wol-apply-height-to-selected-envelope-slot-5": 54916,
4458 "sws-wol-apply-height-to-selected-envelope-slot-6": 54917,
4459 "sws-wol-apply-height-to-selected-envelope-slot-7": 54918,
4460 "sws-wol-apply-height-to-selected-envelope-slot-8": 54919,
4461 "sws-wol-force-overlap-for-selected-envelope-in-track-lane-in-its-track-height": 54896,
4462 "sws-wol-full-zoom-selected-envelope-in-media-lane-only-to-lower-half-in-time-selection": 54901,
4463 "sws-wol-full-zoom-selected-envelope-in-media-lane-only-to-upper-half-in-time-selection": 54900,
4464 "sws-wol-full-zoom-selected-envelope-in-time-selection": 54899,
4465 "sws-wol-horizontal-zoom-selected-envelope-in-time-selection": 54898,
4466 "sws-wol-options-set-horizontal-zoom-center-to-center-of-view": 54881,
4467 "sws-wol-options-set-horizontal-zoom-center-to-edit-cursor": 54880,
4468 "sws-wol-options-set-horizontal-zoom-center-to-edit-cursor-or-play-cursor-default": 54879,
4469 "sws-wol-options-set-horizontal-zoom-center-to-mouse-cursor": 54882,
4470 "sws-wol-options-set-vertical-zoom-center-to-last-selected-track": 54877,
4471 "sws-wol-options-set-vertical-zoom-center-to-top-visible-track": 54876,
4472 "sws-wol-options-set-vertical-zoom-center-to-track-at-center-of-view": 54875,
4473 "sws-wol-options-set-vertical-zoom-center-to-track-under-mouse-cursor": 54878,
4474 "sws-wol-put-selected-envelope-in-envelope-lane": 54922,
4475 "sws-wol-put-selected-envelope-in-media-lane": 54921,
4476 "sws-wol-restore-previous-envelope-overlap-settings": 54897,
4477 "sws-wol-save-height-of-selected-envelope-slot-1": 54904,
4478 "sws-wol-save-height-of-selected-envelope-slot-2": 54905,
4479 "sws-wol-save-height-of-selected-envelope-slot-3": 54906,
4480 "sws-wol-save-height-of-selected-envelope-slot-4": 54907,
4481 "sws-wol-save-height-of-selected-envelope-slot-5": 54908,
4482 "sws-wol-save-height-of-selected-envelope-slot-6": 54909,
4483 "sws-wol-save-height-of-selected-envelope-slot-7": 54910,
4484 "sws-wol-save-height-of-selected-envelope-slot-8": 54911,
4485 "sws-wol-select-all-tracks-except-folder-parents": 54920,
4486 "sws-wol-set-selected-envelope-height-to-default": 54883,
4487 "sws-wol-set-selected-envelope-height-to-maximum": 54885,
4488 "sws-wol-set-selected-envelope-height-to-minimum": 54884,
4489 "sws-wol-toggle-enable-envelope-overlap-for-envelopes-in-track-lane": 54895,
4490 "sws-wol-toggle-enable-extended-zoom-for-envelopes-in-track-lane": 54894,
4491 "sws-wol-vertical-zoom-selected-envelope-in-media-lane-only-to-lower-half": 54903,
4492 "sws-wol-vertical-zoom-selected-envelope-in-media-lane-only-to-upper-half": 54902,
4493 "sws-zoom-preferences": 53839,
4494 "sws-zoom-to-selected-items": 53793,
4495 "sws-zoom-to-selected-items-ignore-last-track-s-envelope-lanes": 53797,
4496 "sws-zoom-to-selected-items-minimize-others": 53794,
4497 "sws-zoom-to-selected-items-minimize-others-ignore-last-track-s-envelope-lanes": 53798,
4498 "sws-zoom-to-selected-items-or-time-selection": 53795,
4499 "sws-zoom-to-selected-items-or-time-selection-ignore-last-track-s-envelope-lanes": 53799,
4500 "sws-zoom-to-selected-items-or-time-selection-minimize-others": 53796,
4501 "sws-zoom-to-selected-items-or-time-selection-minimize-others-ignore-last-track-s-envelope-lanes": 53800,
4502 "sws-zoom-tool-marquee": 53838,
4503 "take-activate-take-under-mouse": 41342,
4504 "take-crop-to-active-take-in-items": 40131,
4505 "take-cycle-items-to-next-take": 42349,
4506 "take-cycle-items-to-previous-take": 42350,
4507 "take-delete-active-take-from-items": 40129,
4508 "take-delete-active-take-from-items-prompt-to-confirm": 40130,
4509 "take-duplicate-active-take": 40639,
4510 "take-explode-takes-of-items-across-tracks": 40224,
4511 "take-explode-takes-of-items-in-order": 40643,
4512 "take-explode-takes-of-items-in-place": 40642,
4513 "take-explode-takes-on-selected-tracks-to-fixed-lanes": 42635,
4514 "take-explode-takes-on-selected-tracks-to-fixed-lanes-and-add-comp-areas-from-active-takes": 42636,
4515 "take-implode-items-across-tracks-into-takes": 40438,
4516 "take-implode-items-on-same-track-into-takes": 40543,
4517 "take-implode-selected-fixed-lane-tracks-to-takes-using-best-efforts-overlapping-items-may-be-shortened": 42637,
4518 "take-nudge-active-takes-volume-1db": 41926,
4519 "take-nudge-active-takes-volume-plus-1db": 41927,
4520 "take-paste-as-takes-in-items": 40603,
4521 "take-propagate-to-all-similarly-named-active-takes": 41978,
4522 "take-propagate-to-similarly-named-active-takes-on-track": 41976,
4523 "take-set-1st-take-active": 45000,
4524 "take-set-2nd-take-active": 45001,
4525 "take-set-3rd-take-active": 45002,
4526 "take-set-4th-take-active": 45003,
4527 "take-set-5th-take-active": 45004,
4528 "take-set-6th-take-active": 45005,
4529 "take-set-7th-take-active": 45006,
4530 "take-set-8th-take-active": 45007,
4531 "take-set-9th-take-active": 45008,
4532 "take-set-active-take-to-custom-color": 41331,
4533 "take-set-active-take-to-default-color": 41333,
4534 "take-set-active-take-to-one-random-color": 41332,
4535 "take-set-all-takes-created-in-the-same-recording-pass-to-custom-color": 41334,
4536 "take-set-all-takes-created-in-the-same-recording-pass-to-default-color": 41336,
4537 "take-set-all-takes-created-in-the-same-recording-pass-to-one-random-color": 41335,
4538 "take-set-all-takes-of-selected-items-to-default-color": 41337,
4539 "take-switch-items-to-next-take": 40125,
4540 "take-switch-items-to-previous-take": 40126,
4541 "take-toggle-take-mute-envelope": 40695,
4542 "take-toggle-take-pan-envelope": 40694,
4543 "take-toggle-take-pitch-envelope": 41612,
4544 "take-toggle-take-volume-envelope": 40693,
4545 "take-view-take-envelopes-for-last-touched-first-selected-item": 41974,
4546 "tempo-decrease-current-project-tempo-0-1-bpm": 41138,
4547 "tempo-decrease-current-project-tempo-01-bpm": 41130,
4548 "tempo-decrease-current-project-tempo-10-bpm": 41136,
4549 "tempo-decrease-current-project-tempo-10-percent": 41132,
4550 "tempo-decrease-current-project-tempo-50-percent-half": 41134,
4551 "tempo-envelope-decrease-all-tempo-markers-0-001-bpm": 41807,
4552 "tempo-envelope-decrease-all-tempo-markers-0-001-percent": 41815,
4553 "tempo-envelope-decrease-all-tempo-markers-0-01-bpm": 41220,
4554 "tempo-envelope-decrease-all-tempo-markers-0-01-percent": 41813,
4555 "tempo-envelope-decrease-all-tempo-markers-0-1-bpm": 41218,
4556 "tempo-envelope-decrease-all-tempo-markers-0-1-percent": 41811,
4557 "tempo-envelope-decrease-all-tempo-markers-01-bpm": 41216,
4558 "tempo-envelope-decrease-all-tempo-markers-01-percent": 41809,
4559 "tempo-envelope-increase-all-tempo-markers-0-001-bpm": 41806,
4560 "tempo-envelope-increase-all-tempo-markers-0-001-percent": 41814,
4561 "tempo-envelope-increase-all-tempo-markers-0-01-bpm": 41219,
4562 "tempo-envelope-increase-all-tempo-markers-0-01-percent": 41812,
4563 "tempo-envelope-increase-all-tempo-markers-0-1-bpm": 41217,
4564 "tempo-envelope-increase-all-tempo-markers-0-1-percent": 41810,
4565 "tempo-envelope-increase-all-tempo-markers-01-bpm": 41215,
4566 "tempo-envelope-increase-all-tempo-markers-01-percent": 41808,
4567 "tempo-envelope-insert-tempo-marker-at-edit-cursor-without-opening-tempo-edit-dialog": 42330,
4568 "tempo-envelope-insert-tempo-time-signature-change-marker-at-edit-cursor": 40256,
4569 "tempo-envelope-set-display-range": 40933,
4570 "tempo-envelope-set-display-range-to-current-project-min-max-bpm": 41804,
4571 "tempo-increase-current-project-tempo-0-1-bpm": 41137,
4572 "tempo-increase-current-project-tempo-01-bpm": 41129,
4573 "tempo-increase-current-project-tempo-10-bpm": 41135,
4574 "tempo-increase-current-project-tempo-10-percent": 41131,
4575 "tempo-increase-current-project-tempo-100-percent-double": 41133,
4576 "theme-development-show-theme-element-finder": 40690,
4577 "theme-development-show-theme-tweak-configuration-window": 41930,
4578 "time-selection-copy-contents-of-time-selection-to-edit-cursor-moving-later-items": 40397,
4579 "time-selection-crop-project-to-time-selection": 40049,
4580 "time-selection-extend-time-selection-to-next-transient-in-items": 40802,
4581 "time-selection-insert-empty-space-at-time-selection-moving-later-items": 40200,
4582 "time-selection-move-contents-of-time-selection-to-edit-cursor-moving-later-items": 40396,
4583 "time-selection-move-cursor-left-creating-time-selection": 40102,
4584 "time-selection-move-cursor-right-creating-time-selection": 40103,
4585 "time-selection-move-end-point-to-cursor-preserve-length": 43213,
4586 "time-selection-move-loop-points-to-time-selection": 40622,
4587 "time-selection-move-start-point-to-cursor-preserve-length": 43212,
4588 "time-selection-move-time-selection-to-loop-points": 40623,
4589 "time-selection-nudge-left": 40039,
4590 "time-selection-nudge-left-edge-left": 40320,
4591 "time-selection-nudge-left-edge-right": 40321,
4592 "time-selection-nudge-right": 40040,
4593 "time-selection-nudge-right-edge-left": 40322,
4594 "time-selection-nudge-right-edge-right": 40323,
4595 "time-selection-remove-contents-of-time-selection-moving-later-items": 40201,
4596 "time-selection-remove-unselect-time-selection": 40635,
4597 "time-selection-remove-unselect-time-selection-and-loop-points": 40020,
4598 "time-selection-set-end-point": 40626,
4599 "time-selection-set-start-point": 40625,
4600 "time-selection-set-time-selection-to-items": 40290,
4601 "time-selection-shift-left-by-time-selection-length": 40037,
4602 "time-selection-shift-right-by-time-selection-length": 40038,
4603 "time-selection-swap-left-edge-of-time-selection-to-next-transient-in-items": 40803,
4604 "toggle-external-timecode-synchronization": 40620,
4605 "toggle-fullscreen": 40346,
4606 "toggle-ripple-editing-all-tracks": 41991,
4607 "toggle-ripple-editing-on-off": 1162,
4608 "toggle-ripple-editing-per-track": 41990,
4609 "toggle-show-all-floating-windows": 41074,
4610 "toggle-show-all-floating-windows-except-mixer": 41077,
4611 "toggle-show-all-floating-windows-except-mixer-and-unattached-docker": 41080,
4612 "toggle-show-all-floating-windows-except-unattached-docker": 41079,
4613 "toggle-show-master-tempo-envelope": 41046,
4614 "toggle-show-master-track-and-tempo-envelope": 41050,
4615 "toolbar-customize-empty-tcp-area-toolbar": 43676,
4616 "toolbar-open-close-main-toolbar": 41651,
4617 "toolbar-open-close-media-explorer-toolbar": 42404,
4618 "toolbar-open-close-midi-piano-roll-toolbar": 41676,
4619 "toolbar-open-close-midi-toolbar-1": 41687,
4620 "toolbar-open-close-midi-toolbar-10": 42746,
4621 "toolbar-open-close-midi-toolbar-11": 42747,
4622 "toolbar-open-close-midi-toolbar-12": 42748,
4623 "toolbar-open-close-midi-toolbar-13": 42749,
4624 "toolbar-open-close-midi-toolbar-14": 42750,
4625 "toolbar-open-close-midi-toolbar-15": 42751,
4626 "toolbar-open-close-midi-toolbar-16": 42752,
4627 "toolbar-open-close-midi-toolbar-2": 41688,
4628 "toolbar-open-close-midi-toolbar-3": 41689,
4629 "toolbar-open-close-midi-toolbar-4": 41690,
4630 "toolbar-open-close-midi-toolbar-5": 41944,
4631 "toolbar-open-close-midi-toolbar-6": 41945,
4632 "toolbar-open-close-midi-toolbar-7": 41946,
4633 "toolbar-open-close-midi-toolbar-8": 41947,
4634 "toolbar-open-close-midi-toolbar-9": 42745,
4635 "toolbar-open-close-toolbar-1": 41679,
4636 "toolbar-open-close-toolbar-10": 41937,
4637 "toolbar-open-close-toolbar-11": 41938,
4638 "toolbar-open-close-toolbar-12": 41939,
4639 "toolbar-open-close-toolbar-13": 41940,
4640 "toolbar-open-close-toolbar-14": 41941,
4641 "toolbar-open-close-toolbar-15": 41942,
4642 "toolbar-open-close-toolbar-16": 41943,
4643 "toolbar-open-close-toolbar-17": 42713,
4644 "toolbar-open-close-toolbar-18": 42714,
4645 "toolbar-open-close-toolbar-19": 42715,
4646 "toolbar-open-close-toolbar-2": 41680,
4647 "toolbar-open-close-toolbar-20": 42716,
4648 "toolbar-open-close-toolbar-21": 42717,
4649 "toolbar-open-close-toolbar-22": 42718,
4650 "toolbar-open-close-toolbar-23": 42719,
4651 "toolbar-open-close-toolbar-24": 42720,
4652 "toolbar-open-close-toolbar-25": 42721,
4653 "toolbar-open-close-toolbar-26": 42722,
4654 "toolbar-open-close-toolbar-27": 42723,
4655 "toolbar-open-close-toolbar-28": 42724,
4656 "toolbar-open-close-toolbar-29": 42725,
4657 "toolbar-open-close-toolbar-3": 41681,
4658 "toolbar-open-close-toolbar-30": 42726,
4659 "toolbar-open-close-toolbar-31": 42727,
4660 "toolbar-open-close-toolbar-32": 42728,
4661 "toolbar-open-close-toolbar-4": 41682,
4662 "toolbar-open-close-toolbar-5": 41683,
4663 "toolbar-open-close-toolbar-6": 41684,
4664 "toolbar-open-close-toolbar-7": 41685,
4665 "toolbar-open-close-toolbar-8": 41686,
4666 "toolbar-open-close-toolbar-9": 41936,
4667 "toolbar-open-midi-toolbar-1-at-mouse-cursor": 41640,
4668 "toolbar-open-midi-toolbar-10-at-mouse-cursor": 42778,
4669 "toolbar-open-midi-toolbar-11-at-mouse-cursor": 42779,
4670 "toolbar-open-midi-toolbar-12-at-mouse-cursor": 42780,
4671 "toolbar-open-midi-toolbar-13-at-mouse-cursor": 42781,
4672 "toolbar-open-midi-toolbar-14-at-mouse-cursor": 42782,
4673 "toolbar-open-midi-toolbar-15-at-mouse-cursor": 42783,
4674 "toolbar-open-midi-toolbar-16-at-mouse-cursor": 42784,
4675 "toolbar-open-midi-toolbar-2-at-mouse-cursor": 41641,
4676 "toolbar-open-midi-toolbar-3-at-mouse-cursor": 41642,
4677 "toolbar-open-midi-toolbar-4-at-mouse-cursor": 41643,
4678 "toolbar-open-midi-toolbar-5-at-mouse-cursor": 41968,
4679 "toolbar-open-midi-toolbar-6-at-mouse-cursor": 41969,
4680 "toolbar-open-midi-toolbar-7-at-mouse-cursor": 41970,
4681 "toolbar-open-midi-toolbar-8-at-mouse-cursor": 41971,
4682 "toolbar-open-midi-toolbar-9-at-mouse-cursor": 42777,
4683 "toolbar-open-toolbar-1-at-mouse-cursor": 41111,
4684 "toolbar-open-toolbar-10-at-mouse-cursor": 41961,
4685 "toolbar-open-toolbar-11-at-mouse-cursor": 41962,
4686 "toolbar-open-toolbar-12-at-mouse-cursor": 41963,
4687 "toolbar-open-toolbar-13-at-mouse-cursor": 41964,
4688 "toolbar-open-toolbar-14-at-mouse-cursor": 41965,
4689 "toolbar-open-toolbar-15-at-mouse-cursor": 41966,
4690 "toolbar-open-toolbar-16-at-mouse-cursor": 41967,
4691 "toolbar-open-toolbar-17-at-mouse-cursor": 42761,
4692 "toolbar-open-toolbar-18-at-mouse-cursor": 42762,
4693 "toolbar-open-toolbar-19-at-mouse-cursor": 42763,
4694 "toolbar-open-toolbar-2-at-mouse-cursor": 41112,
4695 "toolbar-open-toolbar-20-at-mouse-cursor": 42764,
4696 "toolbar-open-toolbar-21-at-mouse-cursor": 42765,
4697 "toolbar-open-toolbar-22-at-mouse-cursor": 42766,
4698 "toolbar-open-toolbar-23-at-mouse-cursor": 42767,
4699 "toolbar-open-toolbar-24-at-mouse-cursor": 42768,
4700 "toolbar-open-toolbar-25-at-mouse-cursor": 42769,
4701 "toolbar-open-toolbar-26-at-mouse-cursor": 42770,
4702 "toolbar-open-toolbar-27-at-mouse-cursor": 42771,
4703 "toolbar-open-toolbar-28-at-mouse-cursor": 42772,
4704 "toolbar-open-toolbar-29-at-mouse-cursor": 42773,
4705 "toolbar-open-toolbar-3-at-mouse-cursor": 41113,
4706 "toolbar-open-toolbar-30-at-mouse-cursor": 42774,
4707 "toolbar-open-toolbar-31-at-mouse-cursor": 42775,
4708 "toolbar-open-toolbar-32-at-mouse-cursor": 42776,
4709 "toolbar-open-toolbar-4-at-mouse-cursor": 41114,
4710 "toolbar-open-toolbar-5-at-mouse-cursor": 41655,
4711 "toolbar-open-toolbar-6-at-mouse-cursor": 41656,
4712 "toolbar-open-toolbar-7-at-mouse-cursor": 41657,
4713 "toolbar-open-toolbar-8-at-mouse-cursor": 41658,
4714 "toolbar-open-toolbar-9-at-mouse-cursor": 41960,
4715 "toolbar-press-active-toolbar-button-01": 41085,
4716 "toolbar-press-active-toolbar-button-02": 41086,
4717 "toolbar-press-active-toolbar-button-03": 41087,
4718 "toolbar-press-active-toolbar-button-04": 41088,
4719 "toolbar-press-active-toolbar-button-05": 41089,
4720 "toolbar-press-active-toolbar-button-06": 41090,
4721 "toolbar-press-active-toolbar-button-07": 41091,
4722 "toolbar-press-active-toolbar-button-08": 41092,
4723 "toolbar-press-active-toolbar-button-09": 41093,
4724 "toolbar-press-active-toolbar-button-10": 41094,
4725 "toolbar-press-active-toolbar-button-11": 41095,
4726 "toolbar-press-active-toolbar-button-12": 41096,
4727 "toolbar-press-active-toolbar-button-13": 41097,
4728 "toolbar-press-active-toolbar-button-14": 41098,
4729 "toolbar-press-active-toolbar-button-15": 41099,
4730 "toolbar-press-active-toolbar-button-16": 41100,
4731 "toolbar-show-hide-toolbar-at-top-of-main-window": 41297,
4732 "toolbar-show-hide-toolbar-docker": 41084,
4733 "toolbars-customize": 40905,
4734 "toolbars-show-midi-toolbar-1-as-menu": 43433,
4735 "toolbars-show-midi-toolbar-10-as-menu": 43442,
4736 "toolbars-show-midi-toolbar-11-as-menu": 43443,
4737 "toolbars-show-midi-toolbar-12-as-menu": 43444,
4738 "toolbars-show-midi-toolbar-13-as-menu": 43445,
4739 "toolbars-show-midi-toolbar-14-as-menu": 43446,
4740 "toolbars-show-midi-toolbar-15-as-menu": 43447,
4741 "toolbars-show-midi-toolbar-16-as-menu": 43448,
4742 "toolbars-show-midi-toolbar-2-as-menu": 43434,
4743 "toolbars-show-midi-toolbar-3-as-menu": 43435,
4744 "toolbars-show-midi-toolbar-4-as-menu": 43436,
4745 "toolbars-show-midi-toolbar-5-as-menu": 43437,
4746 "toolbars-show-midi-toolbar-6-as-menu": 43438,
4747 "toolbars-show-midi-toolbar-7-as-menu": 43439,
4748 "toolbars-show-midi-toolbar-8-as-menu": 43440,
4749 "toolbars-show-midi-toolbar-9-as-menu": 43441,
4750 "toolbars-show-toolbar-1-as-menu": 43401,
4751 "toolbars-show-toolbar-10-as-menu": 43410,
4752 "toolbars-show-toolbar-11-as-menu": 43411,
4753 "toolbars-show-toolbar-12-as-menu": 43412,
4754 "toolbars-show-toolbar-13-as-menu": 43413,
4755 "toolbars-show-toolbar-14-as-menu": 43414,
4756 "toolbars-show-toolbar-15-as-menu": 43415,
4757 "toolbars-show-toolbar-16-as-menu": 43416,
4758 "toolbars-show-toolbar-17-as-menu": 43417,
4759 "toolbars-show-toolbar-18-as-menu": 43418,
4760 "toolbars-show-toolbar-19-as-menu": 43419,
4761 "toolbars-show-toolbar-2-as-menu": 43402,
4762 "toolbars-show-toolbar-20-as-menu": 43420,
4763 "toolbars-show-toolbar-21-as-menu": 43421,
4764 "toolbars-show-toolbar-22-as-menu": 43422,
4765 "toolbars-show-toolbar-23-as-menu": 43423,
4766 "toolbars-show-toolbar-24-as-menu": 43424,
4767 "toolbars-show-toolbar-25-as-menu": 43425,
4768 "toolbars-show-toolbar-26-as-menu": 43426,
4769 "toolbars-show-toolbar-27-as-menu": 43427,
4770 "toolbars-show-toolbar-28-as-menu": 43428,
4771 "toolbars-show-toolbar-29-as-menu": 43429,
4772 "toolbars-show-toolbar-3-as-menu": 43403,
4773 "toolbars-show-toolbar-30-as-menu": 43430,
4774 "toolbars-show-toolbar-31-as-menu": 43431,
4775 "toolbars-show-toolbar-32-as-menu": 43432,
4776 "toolbars-show-toolbar-4-as-menu": 43404,
4777 "toolbars-show-toolbar-5-as-menu": 43405,
4778 "toolbars-show-toolbar-6-as-menu": 43406,
4779 "toolbars-show-toolbar-7-as-menu": 43407,
4780 "toolbars-show-toolbar-8-as-menu": 43408,
4781 "toolbars-show-toolbar-9-as-menu": 43409,
4782 "toolbars-switch-to-main-toolbar": 41646,
4783 "toolbars-switch-to-media-explorer-toolbar": 42405,
4784 "toolbars-switch-to-midi-piano-roll-toolbar": 40303,
4785 "toolbars-switch-to-midi-toolbar-1": 41659,
4786 "toolbars-switch-to-midi-toolbar-10": 42754,
4787 "toolbars-switch-to-midi-toolbar-11": 42755,
4788 "toolbars-switch-to-midi-toolbar-12": 42756,
4789 "toolbars-switch-to-midi-toolbar-13": 42757,
4790 "toolbars-switch-to-midi-toolbar-14": 42758,
4791 "toolbars-switch-to-midi-toolbar-15": 42759,
4792 "toolbars-switch-to-midi-toolbar-16": 42760,
4793 "toolbars-switch-to-midi-toolbar-2": 41660,
4794 "toolbars-switch-to-midi-toolbar-3": 41661,
4795 "toolbars-switch-to-midi-toolbar-4": 41662,
4796 "toolbars-switch-to-midi-toolbar-5": 41956,
4797 "toolbars-switch-to-midi-toolbar-6": 41957,
4798 "toolbars-switch-to-midi-toolbar-7": 41958,
4799 "toolbars-switch-to-midi-toolbar-8": 41959,
4800 "toolbars-switch-to-midi-toolbar-9": 42753,
4801 "toolbars-switch-to-toolbar-1": 41105,
4802 "toolbars-switch-to-toolbar-10": 41949,
4803 "toolbars-switch-to-toolbar-11": 41950,
4804 "toolbars-switch-to-toolbar-12": 41951,
4805 "toolbars-switch-to-toolbar-13": 41952,
4806 "toolbars-switch-to-toolbar-14": 41953,
4807 "toolbars-switch-to-toolbar-15": 41954,
4808 "toolbars-switch-to-toolbar-16": 41955,
4809 "toolbars-switch-to-toolbar-17": 42729,
4810 "toolbars-switch-to-toolbar-18": 42730,
4811 "toolbars-switch-to-toolbar-19": 42731,
4812 "toolbars-switch-to-toolbar-2": 41106,
4813 "toolbars-switch-to-toolbar-20": 42732,
4814 "toolbars-switch-to-toolbar-21": 42733,
4815 "toolbars-switch-to-toolbar-22": 42734,
4816 "toolbars-switch-to-toolbar-23": 42735,
4817 "toolbars-switch-to-toolbar-24": 42736,
4818 "toolbars-switch-to-toolbar-25": 42737,
4819 "toolbars-switch-to-toolbar-26": 42738,
4820 "toolbars-switch-to-toolbar-27": 42739,
4821 "toolbars-switch-to-toolbar-28": 42740,
4822 "toolbars-switch-to-toolbar-29": 42741,
4823 "toolbars-switch-to-toolbar-3": 41107,
4824 "toolbars-switch-to-toolbar-30": 42742,
4825 "toolbars-switch-to-toolbar-31": 42743,
4826 "toolbars-switch-to-toolbar-32": 42744,
4827 "toolbars-switch-to-toolbar-4": 41108,
4828 "toolbars-switch-to-toolbar-5": 41647,
4829 "toolbars-switch-to-toolbar-6": 41648,
4830 "toolbars-switch-to-toolbar-7": 41649,
4831 "toolbars-switch-to-toolbar-8": 41650,
4832 "toolbars-switch-to-toolbar-9": 41948,
4833 "track-2nd-pass-render-selected-area-of-tracks-to-mono-post-fader-stem-tracks-and-mute-originals": 42415,
4834 "track-2nd-pass-render-selected-area-of-tracks-to-mono-stem-tracks-and-mute-originals": 42418,
4835 "track-2nd-pass-render-selected-area-of-tracks-to-multichannel-parent-send-only-post-fader-stem-tracks-and-mute-originals":
4836 42593,
4837 "track-2nd-pass-render-selected-area-of-tracks-to-multichannel-parent-send-only-stem-tracks-and-mute-originals":
4838 42594,
4839 "track-2nd-pass-render-selected-area-of-tracks-to-multichannel-post-fader-stem-tracks-and-mute-originals": 42414,
4840 "track-2nd-pass-render-selected-area-of-tracks-to-multichannel-stem-tracks-and-mute-originals": 42417,
4841 "track-2nd-pass-render-selected-area-of-tracks-to-stereo-post-fader-stem-tracks-and-mute-originals": 42413,
4842 "track-2nd-pass-render-selected-area-of-tracks-to-stereo-stem-tracks-and-mute-originals": 42416,
4843 "track-allow-track-anticipative-fx": 40609,
4844 "track-allow-track-media-buffering": 40607,
4845 "track-apply-media-playback-offset-to-receive-source-tracks": 42233,
4846 "track-apply-trim-envelope-to-volume-envelope-clear-trim-envelope": 42018,
4847 "track-apply-volume-envelope-to-trim-envelope-clear-volume-envelope": 42019,
4848 "track-arm-all-tracks-for-recording": 40490,
4849 "track-automatically-group-all-tracks-for-media-razor-editing": 42580,
4850 "track-automatically-group-selected-tracks-for-media-razor-editing": 42581,
4851 "track-bypass-fx-on-all-tracks": 40342,
4852 "track-bypass-media-playback-offset": 42232,
4853 "track-clear-automatic-record-arm": 40738,
4854 "track-clear-up-rank-down-rank-markers-for-all-items-on-track": 43165,
4855 "track-copy-playing-media-items-to-new-track": 42947,
4856 "track-copy-playing-media-items-to-new-track-for-track-under-mouse": 42946,
4857 "track-copy-tracks": 40210,
4858 "track-create-new-track-media-razor-editing-group-for-selected-tracks": 42578,
4859 "track-crop-to-playing-media-items": 42943,
4860 "track-crop-to-playing-media-items-for-track-under-mouse": 42942,
4861 "track-crop-to-playing-media-items-preserving-fixed-lanes": 42945,
4862 "track-crop-to-playing-media-items-preserving-fixed-lanes-for-track-under-mouse": 42944,
4863 "track-cut-tracks": 40337,
4864 "track-cycle-folder-collapsed-state": 1042,
4865 "track-cycle-track-folder-state": 1041,
4866 "track-cycle-track-record-monitor": 40495,
4867 "track-delete-takes-for-all-items-on-track-that-are-down-ranked-no-confirm": 43167,
4868 "track-delete-takes-for-all-items-on-track-that-are-not-up-ranked-no-confirm": 43166,
4869 "track-disable-midi-input-quantize-for-all-tracks": 42066,
4870 "track-disable-midi-input-quantize-for-last-touched-track": 42068,
4871 "track-disable-midi-input-quantize-for-selected-tracks": 42064,
4872 "track-display-gain-reduction-in-track-meters-for-plug-ins-that-support-it": 42705,
4873 "track-do-not-link-track-volume-pan-controls-to-midi-volume-pan": 41538,
4874 "track-duplicate-tracks": 40062,
4875 "track-enable-midi-input-quantize-for-all-tracks": 42065,
4876 "track-enable-midi-input-quantize-for-last-touched-track": 42067,
4877 "track-enable-midi-input-quantize-for-selected-tracks": 42063,
4878 "track-exit-fixed-lane-view-for-all-fixed-lane-tracks-in-the-project": 42960,
4879 "track-freeze-to-mono-render-pre-fader-save-remove-items-and-online-fx": 40901,
4880 "track-freeze-to-multichannel-render-pre-fader-save-remove-items-and-online-fx": 40877,
4881 "track-freeze-to-stereo-render-pre-fader-save-remove-items-and-online-fx": 41223,
4882 "track-go-to-next-track": 40285,
4883 "track-go-to-next-track-leaving-other-tracks-selected": 40287,
4884 "track-go-to-previous-track": 40286,
4885 "track-go-to-previous-track-leaving-other-tracks-selected": 40288,
4886 "track-hide-envelope-display-next-envelope-on-same-track-cycle": 41825,
4887 "track-hide-envelope-display-previous-envelope-on-same-track-cycle": 43672,
4888 "track-hide-tracks-in-tcp-and-mixer": 41593,
4889 "track-insert-display-reasurroundpan-in-mcp": 42426,
4890 "track-insert-display-reasurroundpan-in-tcp": 42425,
4891 "track-insert-multiple-new-tracks": 41067,
4892 "track-insert-new-5-1-surround-track-embed-reasurroundpan-in-tcp": 41584,
4893 "track-insert-new-7-1-2-surround-track-embed-reasurroundpan-in-tcp": 42423,
4894 "track-insert-new-7-1-4-surround-track-embed-reasurroundpan-in-tcp": 42424,
4895 "track-insert-new-7-1-surround-track-embed-reasurroundpan-in-tcp": 42422,
4896 "track-insert-new-surround-track-using-selected-tracks-as-source-audio": 41585,
4897 "track-insert-new-track": 40001,
4898 "track-insert-new-track-as-first-track": 43093,
4899 "track-insert-new-track-at-end-of-mixer": 41147,
4900 "track-insert-new-track-at-end-of-track-list": 40702,
4901 "track-insert-show-reacontrolmidi-midi-track-control": 40907,
4902 "track-insert-show-reaeq-track-eq": 41757,
4903 "track-insert-track-from-template": 46000,
4904 "track-insert-visual-spacer-after-last-touched-track": 42672,
4905 "track-insert-visual-spacer-after-tracks": 42666,
4906 "track-insert-visual-spacer-before-and-after-tracks": 42669,
4907 "track-insert-visual-spacer-before-last-touched-track": 42671,
4908 "track-insert-visual-spacer-before-tracks": 42665,
4909 "track-invert-track-polarity-phase": 40282,
4910 "track-lanes-add-comp-areas-for-selected-items": 42652,
4911 "track-lanes-add-empty-lane-at-bottom-of-track": 42647,
4912 "track-lanes-comp-into-a-new-copy-of-lane-under-mouse-for-track-under-mouse": 42487,
4913 "track-lanes-comp-into-lane-under-mouse-for-track-under-mouse": 42499,
4914 "track-lanes-comp-into-new-empty-lane": 42797,
4915 "track-lanes-comp-into-new-empty-lane-automatically-creating-comp-areas": 42798,
4916 "track-lanes-comp-into-new-empty-lane-for-track-under-mouse": 42486,
4917 "track-lanes-comp-into-new-empty-lane-for-track-under-mouse-automatically-creating-comp-areas": 42649,
4918 "track-lanes-copy-edited-media-item-back-to-source-lane-and-re-comp-for-track-under-mouse": 42949,
4919 "track-lanes-copy-edited-media-items-to-new-lane-and-re-comp-for-track-under-mouse": 42654,
4920 "track-lanes-copy-edited-media-items-with-no-matching-source-lane-to-new-lane-and-re-comp": 42802,
4921 "track-lanes-delete-all-lanes-except-lane-under-mouse-for-track-under-mouse-including-media-items": 42933,
4922 "track-lanes-delete-all-lanes-including-media-items": 42796,
4923 "track-lanes-delete-comp-areas": 42955,
4924 "track-lanes-delete-comp-areas-for-track-under-mouse": 42789,
4925 "track-lanes-delete-comp-areas-including-source-media": 42956,
4926 "track-lanes-delete-comp-areas-including-source-media-for-track-under-mouse": 42683,
4927 "track-lanes-delete-empty-comp-areas": 42954,
4928 "track-lanes-delete-empty-comp-areas-for-track-under-mouse": 42953,
4929 "track-lanes-delete-empty-lanes-with-no-media-items": 42689,
4930 "track-lanes-delete-lane-at-bottom-of-track-including-media-items": 42648,
4931 "track-lanes-delete-lane-at-top-of-track-including-media-items": 42501,
4932 "track-lanes-delete-lane-under-mouse-including-media-items": 42676,
4933 "track-lanes-delete-lanes-including-media-items-that-are-not-playing": 42691,
4934 "track-lanes-delete-lanes-including-media-items-with-no-comp-areas": 42690,
4935 "track-lanes-delete-source-media-within-comp-areas-and-re-comp": 42629,
4936 "track-lanes-delete-source-media-within-comp-areas-and-re-comp-for-track-under-mouse": 42684,
4937 "track-lanes-discard-media-item-edits-and-re-comp-from-source-lane-for-track-under-mouse": 42950,
4938 "track-lanes-duplicate-items-from-playing-lanes-to-new-lanes": 42505,
4939 "track-lanes-insert-empty-lane-at-top-of-track": 42500,
4940 "track-lanes-media-items-in-higher-numbered-lanes-mask-playback-of-lower-lanes": 42941,
4941 "track-lanes-media-items-in-higher-numbered-lanes-mask-playback-of-lower-lanes-for-track-under-mouse": 42940,
4942 "track-lanes-move-items-down-one-lane": 40107,
4943 "track-lanes-move-items-down-to-first-available-lane-add-lane-if-needed": 42787,
4944 "track-lanes-move-items-to-bottom-lane": 42588,
4945 "track-lanes-move-items-to-top-lane": 42587,
4946 "track-lanes-move-items-up-if-possible-to-minimize-lane-usage": 42959,
4947 "track-lanes-move-items-up-if-possible-to-minimize-lane-usage-preserve-relative-lane-positions": 42938,
4948 "track-lanes-move-items-up-one-lane": 40068,
4949 "track-lanes-play-all-lanes": 42799,
4950 "track-lanes-play-all-lanes-for-track-under-mouse": 42479,
4951 "track-lanes-play-no-lanes": 42800,
4952 "track-lanes-play-no-lanes-for-track-under-mouse": 42490,
4953 "track-lanes-play-only-first-lane": 42790,
4954 "track-lanes-play-only-first-lane-for-track-under-mouse": 42791,
4955 "track-lanes-play-only-lane-under-mouse": 42478,
4956 "track-lanes-play-only-most-recently-playing-lane": 43701,
4957 "track-lanes-play-only-most-recently-playing-lane-for-track-under-mouse": 43702,
4958 "track-lanes-play-only-next-lane": 42482,
4959 "track-lanes-play-only-next-lane-for-track-under-mouse": 42484,
4960 "track-lanes-play-only-previous-lane": 42481,
4961 "track-lanes-play-only-previous-lane-for-track-under-mouse": 42483,
4962 "track-lanes-record-into-lane-under-mouse": 42471,
4963 "track-lanes-refresh-out-of-sync-comp-areas-for-track-under-mouse": 42952,
4964 "track-lanes-rename-lane-under-mouse": 42472,
4965 "track-lanes-reset-all-lane-names": 42801,
4966 "track-lanes-reset-all-lane-names-for-track-under-mouse": 42703,
4967 "track-lanes-select-items-in-lane-under-mouse": 42469,
4968 "track-lanes-toggle-playing-lane-under-mouse": 42480,
4969 "track-lanes-turn-off-comping": 42692,
4970 "track-lanes-turn-off-comping-for-track-under-mouse": 42506,
4971 "track-link-track-volume-pan-controls-to-midi-volume-pan-on-all-channels": 41555,
4972 "track-link-track-volume-pan-controls-to-midi-volume-pan-on-channel-1": 41539,
4973 "track-link-track-volume-pan-controls-to-midi-volume-pan-on-channel-10": 41548,
4974 "track-link-track-volume-pan-controls-to-midi-volume-pan-on-channel-11": 41549,
4975 "track-link-track-volume-pan-controls-to-midi-volume-pan-on-channel-12": 41550,
4976 "track-link-track-volume-pan-controls-to-midi-volume-pan-on-channel-13": 41551,
4977 "track-link-track-volume-pan-controls-to-midi-volume-pan-on-channel-14": 41552,
4978 "track-link-track-volume-pan-controls-to-midi-volume-pan-on-channel-15": 41553,
4979 "track-link-track-volume-pan-controls-to-midi-volume-pan-on-channel-16": 41554,
4980 "track-link-track-volume-pan-controls-to-midi-volume-pan-on-channel-2": 41540,
4981 "track-link-track-volume-pan-controls-to-midi-volume-pan-on-channel-3": 41541,
4982 "track-link-track-volume-pan-controls-to-midi-volume-pan-on-channel-4": 41542,
4983 "track-link-track-volume-pan-controls-to-midi-volume-pan-on-channel-5": 41543,
4984 "track-link-track-volume-pan-controls-to-midi-volume-pan-on-channel-6": 41544,
4985 "track-link-track-volume-pan-controls-to-midi-volume-pan-on-channel-7": 41545,
4986 "track-link-track-volume-pan-controls-to-midi-volume-pan-on-channel-8": 41546,
4987 "track-link-track-volume-pan-controls-to-midi-volume-pan-on-channel-9": 41547,
4988 "track-lock-track-controls": 41312,
4989 "track-lock-unlock-track-height": 42336,
4990 "track-lufs-measures-first-two-channels-only": 42452,
4991 "track-make-all-tracks-visible-in-tcp-and-mixer": 41594,
4992 "track-move-all-media-items-from-all-hidden-child-tracks": 42454,
4993 "track-move-all-media-items-to-new-hidden-child-track": 42453,
4994 "track-move-tracks-down": 43648,
4995 "track-move-tracks-to-folder": 42786,
4996 "track-move-tracks-to-new-folder": 42785,
4997 "track-move-tracks-to-subproject": 41997,
4998 "track-move-tracks-up": 43647,
4999 "track-mute-all-tracks": 40341,
5000 "track-mute-tracks": 40730,
5001 "track-mute-unmute-tracks": 40280,
5002 "track-nudge-master-track-volume-down": 40744,
5003 "track-nudge-master-track-volume-up": 40743,
5004 "track-nudge-track-pan-left": 40283,
5005 "track-nudge-track-pan-right": 40284,
5006 "track-nudge-track-volume-down": 40116,
5007 "track-nudge-track-volume-up": 40115,
5008 "track-open-close-ui-for-fx-number-1-on-last-touched-track": 41749,
5009 "track-open-close-ui-for-fx-number-2-on-last-touched-track": 41750,
5010 "track-open-close-ui-for-fx-number-3-on-last-touched-track": 41751,
5011 "track-open-close-ui-for-fx-number-4-on-last-touched-track": 41752,
5012 "track-open-close-ui-for-fx-number-5-on-last-touched-track": 41753,
5013 "track-open-close-ui-for-fx-number-6-on-last-touched-track": 41754,
5014 "track-open-close-ui-for-fx-number-7-on-last-touched-track": 41755,
5015 "track-open-close-ui-for-fx-number-8-on-last-touched-track": 41756,
5016 "track-override-show-all-hidden-tracks-in-tcp": 43574,
5017 "track-override-unpin-all-pinned-tracks-in-tcp": 43573,
5018 "track-pin-tracks-to-top-of-arrange-view": 40000,
5019 "track-pin-tracks-to-top-of-arrange-view-unpin-all-other-tracks-except-master": 40008,
5020 "track-prevent-spectral-peaks-spectrogram": 42075,
5021 "track-prevent-track-anticipative-fx": 40610,
5022 "track-prevent-track-media-buffering": 40608,
5023 "track-properties-free-item-positioning": 40641,
5024 "track-properties-hide-fixed-lane-buttons": 40092,
5025 "track-properties-make-fixed-item-lanes-big": 43101,
5026 "track-properties-make-fixed-item-lanes-small": 43100,
5027 "track-properties-set-fixed-item-lanes": 42431,
5028 "track-properties-set-fixed-lanes-convert-takes-to-lanes": 42661,
5029 "track-properties-set-free-item-positioning": 40751,
5030 "track-properties-set-track-timebase-to-beats-position-length-rate": 40488,
5031 "track-properties-set-track-timebase-to-beats-position-only": 40489,
5032 "track-properties-set-track-timebase-to-project-default": 40486,
5033 "track-properties-set-track-timebase-to-time": 40487,
5034 "track-properties-show-fixed-lane-buttons": 40091,
5035 "track-properties-show-hide-fixed-lane-buttons": 42610,
5036 "track-properties-show-play-all-fixed-item-lanes": 43099,
5037 "track-properties-show-play-only-one-fixed-item-lane": 43098,
5038 "track-properties-toggle-fixed-item-lanes": 42430,
5039 "track-properties-toggle-fixed-item-lanes-big-small": 42704,
5040 "track-properties-toggle-fixed-item-lanes-convert-takes-to-lanes": 42660,
5041 "track-properties-toggle-show-play-only-one-fixed-item-lane": 42638,
5042 "track-properties-unset-free-item-positioning-fixed-item-lanes": 40752,
5043 "track-properties-unset-free-item-positioning-fixed-item-lanes-convert-fixed-lanes-to-takes": 42662,
5044 "track-remove-selected-tracks-from-all-track-media-razor-editing-groups": 42579,
5045 "track-remove-track-icon": 40900,
5046 "track-remove-track-spacers": 42670,
5047 "track-remove-tracks": 40005,
5048 "track-remove-visual-spacer-after-last-touched-track": 42674,
5049 "track-remove-visual-spacer-after-tracks": 42668,
5050 "track-remove-visual-spacer-before-last-touched-track": 42673,
5051 "track-remove-visual-spacer-before-tracks": 42667,
5052 "track-rename-last-touched-track": 40696,
5053 "track-render-selected-area-of-tracks-to-mono-post-fader-stem-tracks-and-mute-originals": 41718,
5054 "track-render-selected-area-of-tracks-to-mono-stem-tracks-and-mute-originals": 41721,
5055 "track-render-selected-area-of-tracks-to-multichannel-parent-send-only-post-fader-stem-tracks-and-mute-originals":
5056 42591,
5057 "track-render-selected-area-of-tracks-to-multichannel-parent-send-only-stem-tracks-and-mute-originals": 42592,
5058 "track-render-selected-area-of-tracks-to-multichannel-post-fader-stem-tracks-and-mute-originals": 41717,
5059 "track-render-selected-area-of-tracks-to-multichannel-stem-tracks-and-mute-originals": 41720,
5060 "track-render-selected-area-of-tracks-to-stereo-post-fader-stem-tracks-and-mute-originals": 41716,
5061 "track-render-selected-area-of-tracks-to-stereo-stem-tracks-and-mute-originals": 41719,
5062 "track-render-tracks-to-mono-post-fader-stem-tracks-and-mute-originals": 40537,
5063 "track-render-tracks-to-mono-stem-tracks-and-mute-originals": 40789,
5064 "track-render-tracks-to-multichannel-parent-send-only-post-fader-stem-tracks-and-mute-originals": 42589,
5065 "track-render-tracks-to-multichannel-parent-send-only-stem-tracks-and-mute-originals": 42590,
5066 "track-render-tracks-to-multichannel-post-fader-stem-tracks-and-mute-originals": 40892,
5067 "track-render-tracks-to-multichannel-stem-tracks-and-mute-originals": 40893,
5068 "track-render-tracks-to-stereo-post-fader-stem-tracks-and-mute-originals": 40405,
5069 "track-render-tracks-to-stereo-stem-tracks-and-mute-originals": 40788,
5070 "track-save-tracks-as-track-template": 40392,
5071 "track-select-all-top-level-tracks": 41803,
5072 "track-select-all-tracks": 40296,
5073 "track-select-all-tracks-that-have-controls-locked": 41324,
5074 "track-select-fx-envelope-01": 41872,
5075 "track-select-fx-envelope-02": 41873,
5076 "track-select-fx-envelope-03": 41874,
5077 "track-select-fx-envelope-04": 41875,
5078 "track-select-fx-envelope-05": 41876,
5079 "track-select-fx-envelope-06": 41877,
5080 "track-select-fx-envelope-07": 41878,
5081 "track-select-fx-envelope-08": 41879,
5082 "track-select-fx-envelope-09": 41880,
5083 "track-select-fx-envelope-10": 41881,
5084 "track-select-last-touched-track": 40505,
5085 "track-select-mute-envelope": 41871,
5086 "track-select-next-envelope": 41864,
5087 "track-select-pan-envelope": 41868,
5088 "track-select-pre-fx-pan-envelope": 41867,
5089 "track-select-pre-fx-volume-envelope": 41865,
5090 "track-select-pre-fx-width-envelope": 41869,
5091 "track-select-previous-envelope": 41863,
5092 "track-select-track-01": 40939,
5093 "track-select-track-02": 40940,
5094 "track-select-track-03": 40941,
5095 "track-select-track-04": 40942,
5096 "track-select-track-05": 40943,
5097 "track-select-track-06": 40944,
5098 "track-select-track-07": 40945,
5099 "track-select-track-08": 40946,
5100 "track-select-track-09": 40947,
5101 "track-select-track-10": 40948,
5102 "track-select-track-11": 40949,
5103 "track-select-track-12": 40950,
5104 "track-select-track-13": 40951,
5105 "track-select-track-14": 40952,
5106 "track-select-track-15": 40953,
5107 "track-select-track-16": 40954,
5108 "track-select-track-17": 40955,
5109 "track-select-track-18": 40956,
5110 "track-select-track-19": 40957,
5111 "track-select-track-20": 40958,
5112 "track-select-track-21": 40959,
5113 "track-select-track-22": 40960,
5114 "track-select-track-23": 40961,
5115 "track-select-track-24": 40962,
5116 "track-select-track-25": 40963,
5117 "track-select-track-26": 40964,
5118 "track-select-track-27": 40965,
5119 "track-select-track-28": 40966,
5120 "track-select-track-29": 40967,
5121 "track-select-track-30": 40968,
5122 "track-select-track-31": 40969,
5123 "track-select-track-32": 40970,
5124 "track-select-track-33": 40971,
5125 "track-select-track-34": 40972,
5126 "track-select-track-35": 40973,
5127 "track-select-track-36": 40974,
5128 "track-select-track-37": 40975,
5129 "track-select-track-38": 40976,
5130 "track-select-track-39": 40977,
5131 "track-select-track-40": 40978,
5132 "track-select-track-41": 40979,
5133 "track-select-track-42": 40980,
5134 "track-select-track-43": 40981,
5135 "track-select-track-44": 40982,
5136 "track-select-track-45": 40983,
5137 "track-select-track-46": 40984,
5138 "track-select-track-47": 40985,
5139 "track-select-track-48": 40986,
5140 "track-select-track-49": 40987,
5141 "track-select-track-50": 40988,
5142 "track-select-track-51": 40989,
5143 "track-select-track-52": 40990,
5144 "track-select-track-53": 40991,
5145 "track-select-track-54": 40992,
5146 "track-select-track-55": 40993,
5147 "track-select-track-56": 40994,
5148 "track-select-track-57": 40995,
5149 "track-select-track-58": 40996,
5150 "track-select-track-59": 40997,
5151 "track-select-track-60": 40998,
5152 "track-select-track-61": 40999,
5153 "track-select-track-62": 41000,
5154 "track-select-track-63": 41001,
5155 "track-select-track-64": 41002,
5156 "track-select-track-65": 41003,
5157 "track-select-track-66": 41004,
5158 "track-select-track-67": 41005,
5159 "track-select-track-68": 41006,
5160 "track-select-track-69": 41007,
5161 "track-select-track-70": 41008,
5162 "track-select-track-71": 41009,
5163 "track-select-track-72": 41010,
5164 "track-select-track-73": 41011,
5165 "track-select-track-74": 41012,
5166 "track-select-track-75": 41013,
5167 "track-select-track-76": 41014,
5168 "track-select-track-77": 41015,
5169 "track-select-track-78": 41016,
5170 "track-select-track-79": 41017,
5171 "track-select-track-80": 41018,
5172 "track-select-track-81": 41019,
5173 "track-select-track-82": 41020,
5174 "track-select-track-83": 41021,
5175 "track-select-track-84": 41022,
5176 "track-select-track-85": 41023,
5177 "track-select-track-86": 41024,
5178 "track-select-track-87": 41025,
5179 "track-select-track-88": 41026,
5180 "track-select-track-89": 41027,
5181 "track-select-track-90": 41028,
5182 "track-select-track-91": 41029,
5183 "track-select-track-92": 41030,
5184 "track-select-track-93": 41031,
5185 "track-select-track-94": 41032,
5186 "track-select-track-95": 41033,
5187 "track-select-track-96": 41034,
5188 "track-select-track-97": 41035,
5189 "track-select-track-98": 41036,
5190 "track-select-track-99": 41037,
5191 "track-select-track-under-mouse": 41110,
5192 "track-select-volume-envelope": 41866,
5193 "track-select-width-envelope": 41870,
5194 "track-set-all-fx-offline-for-selected-tracks": 40535,
5195 "track-set-all-fx-online-for-selected-tracks": 40536,
5196 "track-set-automatic-record-arm-when-track-selected": 40737,
5197 "track-set-big-fixed-lanes-for-all-fixed-lane-tracks-in-the-project": 42963,
5198 "track-set-clear-all-tracks-automatic-record-arm": 40740,
5199 "track-set-first-selected-track-as-last-touched-track": 40914,
5200 "track-set-meters-to-combined-rms": 42443,
5201 "track-set-meters-to-lufs-m-momentary-loudness": 42444,
5202 "track-set-meters-to-lufs-s-short-term-loudness-readout-current": 42451,
5203 "track-set-meters-to-lufs-s-short-term-loudness-readout-max": 42445,
5204 "track-set-meters-to-multichannel-peaks": 42450,
5205 "track-set-meters-to-stereo-peaks": 42442,
5206 "track-set-meters-to-stereo-rms": 42446,
5207 "track-set-midi-input-quantize-to-1-16-for-all-tracks": 42047,
5208 "track-set-midi-input-quantize-to-1-16-for-last-touched-track": 42055,
5209 "track-set-midi-input-quantize-to-1-16-for-selected-tracks": 42039,
5210 "track-set-midi-input-quantize-to-1-16-triplet-for-all-tracks": 42046,
5211 "track-set-midi-input-quantize-to-1-16-triplet-for-last-touched-track": 42054,
5212 "track-set-midi-input-quantize-to-1-16-triplet-for-selected-tracks": 42038,
5213 "track-set-midi-input-quantize-to-1-32-for-all-tracks": 42045,
5214 "track-set-midi-input-quantize-to-1-32-for-last-touched-track": 42053,
5215 "track-set-midi-input-quantize-to-1-32-for-selected-tracks": 42037,
5216 "track-set-midi-input-quantize-to-1-4-for-all-tracks": 42051,
5217 "track-set-midi-input-quantize-to-1-4-for-last-touched-track": 42059,
5218 "track-set-midi-input-quantize-to-1-4-for-selected-tracks": 42043,
5219 "track-set-midi-input-quantize-to-1-4-triplet-for-all-tracks": 42050,
5220 "track-set-midi-input-quantize-to-1-4-triplet-for-last-touched-track": 42058,
5221 "track-set-midi-input-quantize-to-1-4-triplet-for-selected-tracks": 42042,
5222 "track-set-midi-input-quantize-to-1-64-for-all-tracks": 42044,
5223 "track-set-midi-input-quantize-to-1-64-for-last-touched-track": 42052,
5224 "track-set-midi-input-quantize-to-1-64-for-selected-tracks": 42036,
5225 "track-set-midi-input-quantize-to-1-8-for-all-tracks": 42049,
5226 "track-set-midi-input-quantize-to-1-8-for-last-touched-track": 42057,
5227 "track-set-midi-input-quantize-to-1-8-for-selected-tracks": 42041,
5228 "track-set-midi-input-quantize-to-1-8-triplet-for-all-tracks": 42048,
5229 "track-set-midi-input-quantize-to-1-8-triplet-for-last-touched-track": 42056,
5230 "track-set-midi-input-quantize-to-1-8-triplet-for-selected-tracks": 42040,
5231 "track-set-midi-input-quantize-to-grid-for-all-tracks": 42061,
5232 "track-set-midi-input-quantize-to-grid-for-last-touched-track": 42062,
5233 "track-set-midi-input-quantize-to-grid-for-selected-tracks": 42060,
5234 "track-set-mute-for-last-touched-track-midi-cc-osc-only": 818,
5235 "track-set-mute-for-master-track-midi-cc-osc-only": 18,
5236 "track-set-mute-for-selected-tracks-midi-cc-osc-only": 10,
5237 "track-set-mute-for-track-01-midi-cc-osc-only": 26,
5238 "track-set-mute-for-track-02-midi-cc-osc-only": 34,
5239 "track-set-mute-for-track-03-midi-cc-osc-only": 42,
5240 "track-set-mute-for-track-04-midi-cc-osc-only": 50,
5241 "track-set-mute-for-track-05-midi-cc-osc-only": 58,
5242 "track-set-mute-for-track-06-midi-cc-osc-only": 66,
5243 "track-set-mute-for-track-07-midi-cc-osc-only": 74,
5244 "track-set-mute-for-track-08-midi-cc-osc-only": 82,
5245 "track-set-mute-for-track-09-midi-cc-osc-only": 90,
5246 "track-set-mute-for-track-10-midi-cc-osc-only": 98,
5247 "track-set-mute-for-track-11-midi-cc-osc-only": 106,
5248 "track-set-mute-for-track-12-midi-cc-osc-only": 114,
5249 "track-set-mute-for-track-13-midi-cc-osc-only": 122,
5250 "track-set-mute-for-track-14-midi-cc-osc-only": 130,
5251 "track-set-mute-for-track-15-midi-cc-osc-only": 138,
5252 "track-set-mute-for-track-16-midi-cc-osc-only": 146,
5253 "track-set-mute-for-track-17-midi-cc-osc-only": 154,
5254 "track-set-mute-for-track-18-midi-cc-osc-only": 162,
5255 "track-set-mute-for-track-19-midi-cc-osc-only": 170,
5256 "track-set-mute-for-track-20-midi-cc-osc-only": 178,
5257 "track-set-mute-for-track-21-midi-cc-osc-only": 186,
5258 "track-set-mute-for-track-22-midi-cc-osc-only": 194,
5259 "track-set-mute-for-track-23-midi-cc-osc-only": 202,
5260 "track-set-mute-for-track-24-midi-cc-osc-only": 210,
5261 "track-set-mute-for-track-25-midi-cc-osc-only": 218,
5262 "track-set-mute-for-track-26-midi-cc-osc-only": 226,
5263 "track-set-mute-for-track-27-midi-cc-osc-only": 234,
5264 "track-set-mute-for-track-28-midi-cc-osc-only": 242,
5265 "track-set-mute-for-track-29-midi-cc-osc-only": 250,
5266 "track-set-mute-for-track-30-midi-cc-osc-only": 258,
5267 "track-set-mute-for-track-31-midi-cc-osc-only": 266,
5268 "track-set-mute-for-track-32-midi-cc-osc-only": 274,
5269 "track-set-mute-for-track-33-midi-cc-osc-only": 282,
5270 "track-set-mute-for-track-34-midi-cc-osc-only": 290,
5271 "track-set-mute-for-track-35-midi-cc-osc-only": 298,
5272 "track-set-mute-for-track-36-midi-cc-osc-only": 306,
5273 "track-set-mute-for-track-37-midi-cc-osc-only": 314,
5274 "track-set-mute-for-track-38-midi-cc-osc-only": 322,
5275 "track-set-mute-for-track-39-midi-cc-osc-only": 330,
5276 "track-set-mute-for-track-40-midi-cc-osc-only": 338,
5277 "track-set-mute-for-track-41-midi-cc-osc-only": 346,
5278 "track-set-mute-for-track-42-midi-cc-osc-only": 354,
5279 "track-set-mute-for-track-43-midi-cc-osc-only": 362,
5280 "track-set-mute-for-track-44-midi-cc-osc-only": 370,
5281 "track-set-mute-for-track-45-midi-cc-osc-only": 378,
5282 "track-set-mute-for-track-46-midi-cc-osc-only": 386,
5283 "track-set-mute-for-track-47-midi-cc-osc-only": 394,
5284 "track-set-mute-for-track-48-midi-cc-osc-only": 402,
5285 "track-set-mute-for-track-49-midi-cc-osc-only": 410,
5286 "track-set-mute-for-track-50-midi-cc-osc-only": 418,
5287 "track-set-mute-for-track-51-midi-cc-osc-only": 426,
5288 "track-set-mute-for-track-52-midi-cc-osc-only": 434,
5289 "track-set-mute-for-track-53-midi-cc-osc-only": 442,
5290 "track-set-mute-for-track-54-midi-cc-osc-only": 450,
5291 "track-set-mute-for-track-55-midi-cc-osc-only": 458,
5292 "track-set-mute-for-track-56-midi-cc-osc-only": 466,
5293 "track-set-mute-for-track-57-midi-cc-osc-only": 474,
5294 "track-set-mute-for-track-58-midi-cc-osc-only": 482,
5295 "track-set-mute-for-track-59-midi-cc-osc-only": 490,
5296 "track-set-mute-for-track-60-midi-cc-osc-only": 498,
5297 "track-set-mute-for-track-61-midi-cc-osc-only": 506,
5298 "track-set-mute-for-track-62-midi-cc-osc-only": 514,
5299 "track-set-mute-for-track-63-midi-cc-osc-only": 522,
5300 "track-set-mute-for-track-64-midi-cc-osc-only": 530,
5301 "track-set-mute-for-track-65-midi-cc-osc-only": 538,
5302 "track-set-mute-for-track-66-midi-cc-osc-only": 546,
5303 "track-set-mute-for-track-67-midi-cc-osc-only": 554,
5304 "track-set-mute-for-track-68-midi-cc-osc-only": 562,
5305 "track-set-mute-for-track-69-midi-cc-osc-only": 570,
5306 "track-set-mute-for-track-70-midi-cc-osc-only": 578,
5307 "track-set-mute-for-track-71-midi-cc-osc-only": 586,
5308 "track-set-mute-for-track-72-midi-cc-osc-only": 594,
5309 "track-set-mute-for-track-73-midi-cc-osc-only": 602,
5310 "track-set-mute-for-track-74-midi-cc-osc-only": 610,
5311 "track-set-mute-for-track-75-midi-cc-osc-only": 618,
5312 "track-set-mute-for-track-76-midi-cc-osc-only": 626,
5313 "track-set-mute-for-track-77-midi-cc-osc-only": 634,
5314 "track-set-mute-for-track-78-midi-cc-osc-only": 642,
5315 "track-set-mute-for-track-79-midi-cc-osc-only": 650,
5316 "track-set-mute-for-track-80-midi-cc-osc-only": 658,
5317 "track-set-mute-for-track-81-midi-cc-osc-only": 666,
5318 "track-set-mute-for-track-82-midi-cc-osc-only": 674,
5319 "track-set-mute-for-track-83-midi-cc-osc-only": 682,
5320 "track-set-mute-for-track-84-midi-cc-osc-only": 690,
5321 "track-set-mute-for-track-85-midi-cc-osc-only": 698,
5322 "track-set-mute-for-track-86-midi-cc-osc-only": 706,
5323 "track-set-mute-for-track-87-midi-cc-osc-only": 714,
5324 "track-set-mute-for-track-88-midi-cc-osc-only": 722,
5325 "track-set-mute-for-track-89-midi-cc-osc-only": 730,
5326 "track-set-mute-for-track-90-midi-cc-osc-only": 738,
5327 "track-set-mute-for-track-91-midi-cc-osc-only": 746,
5328 "track-set-mute-for-track-92-midi-cc-osc-only": 754,
5329 "track-set-mute-for-track-93-midi-cc-osc-only": 762,
5330 "track-set-mute-for-track-94-midi-cc-osc-only": 770,
5331 "track-set-mute-for-track-95-midi-cc-osc-only": 778,
5332 "track-set-mute-for-track-96-midi-cc-osc-only": 786,
5333 "track-set-mute-for-track-97-midi-cc-osc-only": 794,
5334 "track-set-mute-for-track-98-midi-cc-osc-only": 802,
5335 "track-set-mute-for-track-99-midi-cc-osc-only": 810,
5336 "track-set-pan-for-last-touched-track-midi-cc-osc-only": 813,
5337 "track-set-pan-for-master-track-midi-cc-osc-only": 13,
5338 "track-set-pan-for-selected-tracks-midi-cc-osc-only": 5,
5339 "track-set-pan-for-track-01-midi-cc-osc-only": 21,
5340 "track-set-pan-for-track-02-midi-cc-osc-only": 29,
5341 "track-set-pan-for-track-03-midi-cc-osc-only": 37,
5342 "track-set-pan-for-track-04-midi-cc-osc-only": 45,
5343 "track-set-pan-for-track-05-midi-cc-osc-only": 53,
5344 "track-set-pan-for-track-06-midi-cc-osc-only": 61,
5345 "track-set-pan-for-track-07-midi-cc-osc-only": 69,
5346 "track-set-pan-for-track-08-midi-cc-osc-only": 77,
5347 "track-set-pan-for-track-09-midi-cc-osc-only": 85,
5348 "track-set-pan-for-track-10-midi-cc-osc-only": 93,
5349 "track-set-pan-for-track-11-midi-cc-osc-only": 101,
5350 "track-set-pan-for-track-12-midi-cc-osc-only": 109,
5351 "track-set-pan-for-track-13-midi-cc-osc-only": 117,
5352 "track-set-pan-for-track-14-midi-cc-osc-only": 125,
5353 "track-set-pan-for-track-15-midi-cc-osc-only": 133,
5354 "track-set-pan-for-track-16-midi-cc-osc-only": 141,
5355 "track-set-pan-for-track-17-midi-cc-osc-only": 149,
5356 "track-set-pan-for-track-18-midi-cc-osc-only": 157,
5357 "track-set-pan-for-track-19-midi-cc-osc-only": 165,
5358 "track-set-pan-for-track-20-midi-cc-osc-only": 173,
5359 "track-set-pan-for-track-21-midi-cc-osc-only": 181,
5360 "track-set-pan-for-track-22-midi-cc-osc-only": 189,
5361 "track-set-pan-for-track-23-midi-cc-osc-only": 197,
5362 "track-set-pan-for-track-24-midi-cc-osc-only": 205,
5363 "track-set-pan-for-track-25-midi-cc-osc-only": 213,
5364 "track-set-pan-for-track-26-midi-cc-osc-only": 221,
5365 "track-set-pan-for-track-27-midi-cc-osc-only": 229,
5366 "track-set-pan-for-track-28-midi-cc-osc-only": 237,
5367 "track-set-pan-for-track-29-midi-cc-osc-only": 245,
5368 "track-set-pan-for-track-30-midi-cc-osc-only": 253,
5369 "track-set-pan-for-track-31-midi-cc-osc-only": 261,
5370 "track-set-pan-for-track-32-midi-cc-osc-only": 269,
5371 "track-set-pan-for-track-33-midi-cc-osc-only": 277,
5372 "track-set-pan-for-track-34-midi-cc-osc-only": 285,
5373 "track-set-pan-for-track-35-midi-cc-osc-only": 293,
5374 "track-set-pan-for-track-36-midi-cc-osc-only": 301,
5375 "track-set-pan-for-track-37-midi-cc-osc-only": 309,
5376 "track-set-pan-for-track-38-midi-cc-osc-only": 317,
5377 "track-set-pan-for-track-39-midi-cc-osc-only": 325,
5378 "track-set-pan-for-track-40-midi-cc-osc-only": 333,
5379 "track-set-pan-for-track-41-midi-cc-osc-only": 341,
5380 "track-set-pan-for-track-42-midi-cc-osc-only": 349,
5381 "track-set-pan-for-track-43-midi-cc-osc-only": 357,
5382 "track-set-pan-for-track-44-midi-cc-osc-only": 365,
5383 "track-set-pan-for-track-45-midi-cc-osc-only": 373,
5384 "track-set-pan-for-track-46-midi-cc-osc-only": 381,
5385 "track-set-pan-for-track-47-midi-cc-osc-only": 389,
5386 "track-set-pan-for-track-48-midi-cc-osc-only": 397,
5387 "track-set-pan-for-track-49-midi-cc-osc-only": 405,
5388 "track-set-pan-for-track-50-midi-cc-osc-only": 413,
5389 "track-set-pan-for-track-51-midi-cc-osc-only": 421,
5390 "track-set-pan-for-track-52-midi-cc-osc-only": 429,
5391 "track-set-pan-for-track-53-midi-cc-osc-only": 437,
5392 "track-set-pan-for-track-54-midi-cc-osc-only": 445,
5393 "track-set-pan-for-track-55-midi-cc-osc-only": 453,
5394 "track-set-pan-for-track-56-midi-cc-osc-only": 461,
5395 "track-set-pan-for-track-57-midi-cc-osc-only": 469,
5396 "track-set-pan-for-track-58-midi-cc-osc-only": 477,
5397 "track-set-pan-for-track-59-midi-cc-osc-only": 485,
5398 "track-set-pan-for-track-60-midi-cc-osc-only": 493,
5399 "track-set-pan-for-track-61-midi-cc-osc-only": 501,
5400 "track-set-pan-for-track-62-midi-cc-osc-only": 509,
5401 "track-set-pan-for-track-63-midi-cc-osc-only": 517,
5402 "track-set-pan-for-track-64-midi-cc-osc-only": 525,
5403 "track-set-pan-for-track-65-midi-cc-osc-only": 533,
5404 "track-set-pan-for-track-66-midi-cc-osc-only": 541,
5405 "track-set-pan-for-track-67-midi-cc-osc-only": 549,
5406 "track-set-pan-for-track-68-midi-cc-osc-only": 557,
5407 "track-set-pan-for-track-69-midi-cc-osc-only": 565,
5408 "track-set-pan-for-track-70-midi-cc-osc-only": 573,
5409 "track-set-pan-for-track-71-midi-cc-osc-only": 581,
5410 "track-set-pan-for-track-72-midi-cc-osc-only": 589,
5411 "track-set-pan-for-track-73-midi-cc-osc-only": 597,
5412 "track-set-pan-for-track-74-midi-cc-osc-only": 605,
5413 "track-set-pan-for-track-75-midi-cc-osc-only": 613,
5414 "track-set-pan-for-track-76-midi-cc-osc-only": 621,
5415 "track-set-pan-for-track-77-midi-cc-osc-only": 629,
5416 "track-set-pan-for-track-78-midi-cc-osc-only": 637,
5417 "track-set-pan-for-track-79-midi-cc-osc-only": 645,
5418 "track-set-pan-for-track-80-midi-cc-osc-only": 653,
5419 "track-set-pan-for-track-81-midi-cc-osc-only": 661,
5420 "track-set-pan-for-track-82-midi-cc-osc-only": 669,
5421 "track-set-pan-for-track-83-midi-cc-osc-only": 677,
5422 "track-set-pan-for-track-84-midi-cc-osc-only": 685,
5423 "track-set-pan-for-track-85-midi-cc-osc-only": 693,
5424 "track-set-pan-for-track-86-midi-cc-osc-only": 701,
5425 "track-set-pan-for-track-87-midi-cc-osc-only": 709,
5426 "track-set-pan-for-track-88-midi-cc-osc-only": 717,
5427 "track-set-pan-for-track-89-midi-cc-osc-only": 725,
5428 "track-set-pan-for-track-90-midi-cc-osc-only": 733,
5429 "track-set-pan-for-track-91-midi-cc-osc-only": 741,
5430 "track-set-pan-for-track-92-midi-cc-osc-only": 749,
5431 "track-set-pan-for-track-93-midi-cc-osc-only": 757,
5432 "track-set-pan-for-track-94-midi-cc-osc-only": 765,
5433 "track-set-pan-for-track-95-midi-cc-osc-only": 773,
5434 "track-set-pan-for-track-96-midi-cc-osc-only": 781,
5435 "track-set-pan-for-track-97-midi-cc-osc-only": 789,
5436 "track-set-pan-for-track-98-midi-cc-osc-only": 797,
5437 "track-set-pan-for-track-99-midi-cc-osc-only": 805,
5438 "track-set-preserve-pdc-delayed-monitoring-in-recorded-items": 41921,
5439 "track-set-record-path-to-primary": 41321,
5440 "track-set-record-path-to-primary-plus-secondary": 41323,
5441 "track-set-record-path-to-secondary": 41322,
5442 "track-set-small-fixed-lanes-for-all-fixed-lane-tracks-in-the-project": 42962,
5443 "track-set-solo-for-last-touched-track-midi-cc-osc-only": 819,
5444 "track-set-solo-for-master-track-midi-cc-osc-only": 19,
5445 "track-set-solo-for-selected-tracks-midi-cc-osc-only": 11,
5446 "track-set-solo-for-track-01-midi-cc-osc-only": 27,
5447 "track-set-solo-for-track-02-midi-cc-osc-only": 35,
5448 "track-set-solo-for-track-03-midi-cc-osc-only": 43,
5449 "track-set-solo-for-track-04-midi-cc-osc-only": 51,
5450 "track-set-solo-for-track-05-midi-cc-osc-only": 59,
5451 "track-set-solo-for-track-06-midi-cc-osc-only": 67,
5452 "track-set-solo-for-track-07-midi-cc-osc-only": 75,
5453 "track-set-solo-for-track-08-midi-cc-osc-only": 83,
5454 "track-set-solo-for-track-09-midi-cc-osc-only": 91,
5455 "track-set-solo-for-track-10-midi-cc-osc-only": 99,
5456 "track-set-solo-for-track-11-midi-cc-osc-only": 107,
5457 "track-set-solo-for-track-12-midi-cc-osc-only": 115,
5458 "track-set-solo-for-track-13-midi-cc-osc-only": 123,
5459 "track-set-solo-for-track-14-midi-cc-osc-only": 131,
5460 "track-set-solo-for-track-15-midi-cc-osc-only": 139,
5461 "track-set-solo-for-track-16-midi-cc-osc-only": 147,
5462 "track-set-solo-for-track-17-midi-cc-osc-only": 155,
5463 "track-set-solo-for-track-18-midi-cc-osc-only": 163,
5464 "track-set-solo-for-track-19-midi-cc-osc-only": 171,
5465 "track-set-solo-for-track-20-midi-cc-osc-only": 179,
5466 "track-set-solo-for-track-21-midi-cc-osc-only": 187,
5467 "track-set-solo-for-track-22-midi-cc-osc-only": 195,
5468 "track-set-solo-for-track-23-midi-cc-osc-only": 203,
5469 "track-set-solo-for-track-24-midi-cc-osc-only": 211,
5470 "track-set-solo-for-track-25-midi-cc-osc-only": 219,
5471 "track-set-solo-for-track-26-midi-cc-osc-only": 227,
5472 "track-set-solo-for-track-27-midi-cc-osc-only": 235,
5473 "track-set-solo-for-track-28-midi-cc-osc-only": 243,
5474 "track-set-solo-for-track-29-midi-cc-osc-only": 251,
5475 "track-set-solo-for-track-30-midi-cc-osc-only": 259,
5476 "track-set-solo-for-track-31-midi-cc-osc-only": 267,
5477 "track-set-solo-for-track-32-midi-cc-osc-only": 275,
5478 "track-set-solo-for-track-33-midi-cc-osc-only": 283,
5479 "track-set-solo-for-track-34-midi-cc-osc-only": 291,
5480 "track-set-solo-for-track-35-midi-cc-osc-only": 299,
5481 "track-set-solo-for-track-36-midi-cc-osc-only": 307,
5482 "track-set-solo-for-track-37-midi-cc-osc-only": 315,
5483 "track-set-solo-for-track-38-midi-cc-osc-only": 323,
5484 "track-set-solo-for-track-39-midi-cc-osc-only": 331,
5485 "track-set-solo-for-track-40-midi-cc-osc-only": 339,
5486 "track-set-solo-for-track-41-midi-cc-osc-only": 347,
5487 "track-set-solo-for-track-42-midi-cc-osc-only": 355,
5488 "track-set-solo-for-track-43-midi-cc-osc-only": 363,
5489 "track-set-solo-for-track-44-midi-cc-osc-only": 371,
5490 "track-set-solo-for-track-45-midi-cc-osc-only": 379,
5491 "track-set-solo-for-track-46-midi-cc-osc-only": 387,
5492 "track-set-solo-for-track-47-midi-cc-osc-only": 395,
5493 "track-set-solo-for-track-48-midi-cc-osc-only": 403,
5494 "track-set-solo-for-track-49-midi-cc-osc-only": 411,
5495 "track-set-solo-for-track-50-midi-cc-osc-only": 419,
5496 "track-set-solo-for-track-51-midi-cc-osc-only": 427,
5497 "track-set-solo-for-track-52-midi-cc-osc-only": 435,
5498 "track-set-solo-for-track-53-midi-cc-osc-only": 443,
5499 "track-set-solo-for-track-54-midi-cc-osc-only": 451,
5500 "track-set-solo-for-track-55-midi-cc-osc-only": 459,
5501 "track-set-solo-for-track-56-midi-cc-osc-only": 467,
5502 "track-set-solo-for-track-57-midi-cc-osc-only": 475,
5503 "track-set-solo-for-track-58-midi-cc-osc-only": 483,
5504 "track-set-solo-for-track-59-midi-cc-osc-only": 491,
5505 "track-set-solo-for-track-60-midi-cc-osc-only": 499,
5506 "track-set-solo-for-track-61-midi-cc-osc-only": 507,
5507 "track-set-solo-for-track-62-midi-cc-osc-only": 515,
5508 "track-set-solo-for-track-63-midi-cc-osc-only": 523,
5509 "track-set-solo-for-track-64-midi-cc-osc-only": 531,
5510 "track-set-solo-for-track-65-midi-cc-osc-only": 539,
5511 "track-set-solo-for-track-66-midi-cc-osc-only": 547,
5512 "track-set-solo-for-track-67-midi-cc-osc-only": 555,
5513 "track-set-solo-for-track-68-midi-cc-osc-only": 563,
5514 "track-set-solo-for-track-69-midi-cc-osc-only": 571,
5515 "track-set-solo-for-track-70-midi-cc-osc-only": 579,
5516 "track-set-solo-for-track-71-midi-cc-osc-only": 587,
5517 "track-set-solo-for-track-72-midi-cc-osc-only": 595,
5518 "track-set-solo-for-track-73-midi-cc-osc-only": 603,
5519 "track-set-solo-for-track-74-midi-cc-osc-only": 611,
5520 "track-set-solo-for-track-75-midi-cc-osc-only": 619,
5521 "track-set-solo-for-track-76-midi-cc-osc-only": 627,
5522 "track-set-solo-for-track-77-midi-cc-osc-only": 635,
5523 "track-set-solo-for-track-78-midi-cc-osc-only": 643,
5524 "track-set-solo-for-track-79-midi-cc-osc-only": 651,
5525 "track-set-solo-for-track-80-midi-cc-osc-only": 659,
5526 "track-set-solo-for-track-81-midi-cc-osc-only": 667,
5527 "track-set-solo-for-track-82-midi-cc-osc-only": 675,
5528 "track-set-solo-for-track-83-midi-cc-osc-only": 683,
5529 "track-set-solo-for-track-84-midi-cc-osc-only": 691,
5530 "track-set-solo-for-track-85-midi-cc-osc-only": 699,
5531 "track-set-solo-for-track-86-midi-cc-osc-only": 707,
5532 "track-set-solo-for-track-87-midi-cc-osc-only": 715,
5533 "track-set-solo-for-track-88-midi-cc-osc-only": 723,
5534 "track-set-solo-for-track-89-midi-cc-osc-only": 731,
5535 "track-set-solo-for-track-90-midi-cc-osc-only": 739,
5536 "track-set-solo-for-track-91-midi-cc-osc-only": 747,
5537 "track-set-solo-for-track-92-midi-cc-osc-only": 755,
5538 "track-set-solo-for-track-93-midi-cc-osc-only": 763,
5539 "track-set-solo-for-track-94-midi-cc-osc-only": 771,
5540 "track-set-solo-for-track-95-midi-cc-osc-only": 779,
5541 "track-set-solo-for-track-96-midi-cc-osc-only": 787,
5542 "track-set-solo-for-track-97-midi-cc-osc-only": 795,
5543 "track-set-solo-for-track-98-midi-cc-osc-only": 803,
5544 "track-set-solo-for-track-99-midi-cc-osc-only": 811,
5545 "track-set-stereo-width-or-right-channel-pan-for-last-touched-track-midi-cc-osc-only": 30101,
5546 "track-set-stereo-width-or-right-channel-pan-for-master-track-midi-cc-osc-only": 30001,
5547 "track-set-stereo-width-or-right-channel-pan-for-selected-tracks-midi-cc-osc-only": 30000,
5548 "track-set-stereo-width-or-right-channel-pan-for-track-01-midi-cc-osc-only": 30002,
5549 "track-set-stereo-width-or-right-channel-pan-for-track-02-midi-cc-osc-only": 30003,
5550 "track-set-stereo-width-or-right-channel-pan-for-track-03-midi-cc-osc-only": 30004,
5551 "track-set-stereo-width-or-right-channel-pan-for-track-04-midi-cc-osc-only": 30005,
5552 "track-set-stereo-width-or-right-channel-pan-for-track-05-midi-cc-osc-only": 30006,
5553 "track-set-stereo-width-or-right-channel-pan-for-track-06-midi-cc-osc-only": 30007,
5554 "track-set-stereo-width-or-right-channel-pan-for-track-07-midi-cc-osc-only": 30008,
5555 "track-set-stereo-width-or-right-channel-pan-for-track-08-midi-cc-osc-only": 30009,
5556 "track-set-stereo-width-or-right-channel-pan-for-track-09-midi-cc-osc-only": 30010,
5557 "track-set-stereo-width-or-right-channel-pan-for-track-10-midi-cc-osc-only": 30011,
5558 "track-set-stereo-width-or-right-channel-pan-for-track-11-midi-cc-osc-only": 30012,
5559 "track-set-stereo-width-or-right-channel-pan-for-track-12-midi-cc-osc-only": 30013,
5560 "track-set-stereo-width-or-right-channel-pan-for-track-13-midi-cc-osc-only": 30014,
5561 "track-set-stereo-width-or-right-channel-pan-for-track-14-midi-cc-osc-only": 30015,
5562 "track-set-stereo-width-or-right-channel-pan-for-track-15-midi-cc-osc-only": 30016,
5563 "track-set-stereo-width-or-right-channel-pan-for-track-16-midi-cc-osc-only": 30017,
5564 "track-set-stereo-width-or-right-channel-pan-for-track-17-midi-cc-osc-only": 30018,
5565 "track-set-stereo-width-or-right-channel-pan-for-track-18-midi-cc-osc-only": 30019,
5566 "track-set-stereo-width-or-right-channel-pan-for-track-19-midi-cc-osc-only": 30020,
5567 "track-set-stereo-width-or-right-channel-pan-for-track-20-midi-cc-osc-only": 30021,
5568 "track-set-stereo-width-or-right-channel-pan-for-track-21-midi-cc-osc-only": 30022,
5569 "track-set-stereo-width-or-right-channel-pan-for-track-22-midi-cc-osc-only": 30023,
5570 "track-set-stereo-width-or-right-channel-pan-for-track-23-midi-cc-osc-only": 30024,
5571 "track-set-stereo-width-or-right-channel-pan-for-track-24-midi-cc-osc-only": 30025,
5572 "track-set-stereo-width-or-right-channel-pan-for-track-25-midi-cc-osc-only": 30026,
5573 "track-set-stereo-width-or-right-channel-pan-for-track-26-midi-cc-osc-only": 30027,
5574 "track-set-stereo-width-or-right-channel-pan-for-track-27-midi-cc-osc-only": 30028,
5575 "track-set-stereo-width-or-right-channel-pan-for-track-28-midi-cc-osc-only": 30029,
5576 "track-set-stereo-width-or-right-channel-pan-for-track-29-midi-cc-osc-only": 30030,
5577 "track-set-stereo-width-or-right-channel-pan-for-track-30-midi-cc-osc-only": 30031,
5578 "track-set-stereo-width-or-right-channel-pan-for-track-31-midi-cc-osc-only": 30032,
5579 "track-set-stereo-width-or-right-channel-pan-for-track-32-midi-cc-osc-only": 30033,
5580 "track-set-stereo-width-or-right-channel-pan-for-track-33-midi-cc-osc-only": 30034,
5581 "track-set-stereo-width-or-right-channel-pan-for-track-34-midi-cc-osc-only": 30035,
5582 "track-set-stereo-width-or-right-channel-pan-for-track-35-midi-cc-osc-only": 30036,
5583 "track-set-stereo-width-or-right-channel-pan-for-track-36-midi-cc-osc-only": 30037,
5584 "track-set-stereo-width-or-right-channel-pan-for-track-37-midi-cc-osc-only": 30038,
5585 "track-set-stereo-width-or-right-channel-pan-for-track-38-midi-cc-osc-only": 30039,
5586 "track-set-stereo-width-or-right-channel-pan-for-track-39-midi-cc-osc-only": 30040,
5587 "track-set-stereo-width-or-right-channel-pan-for-track-40-midi-cc-osc-only": 30041,
5588 "track-set-stereo-width-or-right-channel-pan-for-track-41-midi-cc-osc-only": 30042,
5589 "track-set-stereo-width-or-right-channel-pan-for-track-42-midi-cc-osc-only": 30043,
5590 "track-set-stereo-width-or-right-channel-pan-for-track-43-midi-cc-osc-only": 30044,
5591 "track-set-stereo-width-or-right-channel-pan-for-track-44-midi-cc-osc-only": 30045,
5592 "track-set-stereo-width-or-right-channel-pan-for-track-45-midi-cc-osc-only": 30046,
5593 "track-set-stereo-width-or-right-channel-pan-for-track-46-midi-cc-osc-only": 30047,
5594 "track-set-stereo-width-or-right-channel-pan-for-track-47-midi-cc-osc-only": 30048,
5595 "track-set-stereo-width-or-right-channel-pan-for-track-48-midi-cc-osc-only": 30049,
5596 "track-set-stereo-width-or-right-channel-pan-for-track-49-midi-cc-osc-only": 30050,
5597 "track-set-stereo-width-or-right-channel-pan-for-track-50-midi-cc-osc-only": 30051,
5598 "track-set-stereo-width-or-right-channel-pan-for-track-51-midi-cc-osc-only": 30052,
5599 "track-set-stereo-width-or-right-channel-pan-for-track-52-midi-cc-osc-only": 30053,
5600 "track-set-stereo-width-or-right-channel-pan-for-track-53-midi-cc-osc-only": 30054,
5601 "track-set-stereo-width-or-right-channel-pan-for-track-54-midi-cc-osc-only": 30055,
5602 "track-set-stereo-width-or-right-channel-pan-for-track-55-midi-cc-osc-only": 30056,
5603 "track-set-stereo-width-or-right-channel-pan-for-track-56-midi-cc-osc-only": 30057,
5604 "track-set-stereo-width-or-right-channel-pan-for-track-57-midi-cc-osc-only": 30058,
5605 "track-set-stereo-width-or-right-channel-pan-for-track-58-midi-cc-osc-only": 30059,
5606 "track-set-stereo-width-or-right-channel-pan-for-track-59-midi-cc-osc-only": 30060,
5607 "track-set-stereo-width-or-right-channel-pan-for-track-60-midi-cc-osc-only": 30061,
5608 "track-set-stereo-width-or-right-channel-pan-for-track-61-midi-cc-osc-only": 30062,
5609 "track-set-stereo-width-or-right-channel-pan-for-track-62-midi-cc-osc-only": 30063,
5610 "track-set-stereo-width-or-right-channel-pan-for-track-63-midi-cc-osc-only": 30064,
5611 "track-set-stereo-width-or-right-channel-pan-for-track-64-midi-cc-osc-only": 30065,
5612 "track-set-stereo-width-or-right-channel-pan-for-track-65-midi-cc-osc-only": 30066,
5613 "track-set-stereo-width-or-right-channel-pan-for-track-66-midi-cc-osc-only": 30067,
5614 "track-set-stereo-width-or-right-channel-pan-for-track-67-midi-cc-osc-only": 30068,
5615 "track-set-stereo-width-or-right-channel-pan-for-track-68-midi-cc-osc-only": 30069,
5616 "track-set-stereo-width-or-right-channel-pan-for-track-69-midi-cc-osc-only": 30070,
5617 "track-set-stereo-width-or-right-channel-pan-for-track-70-midi-cc-osc-only": 30071,
5618 "track-set-stereo-width-or-right-channel-pan-for-track-71-midi-cc-osc-only": 30072,
5619 "track-set-stereo-width-or-right-channel-pan-for-track-72-midi-cc-osc-only": 30073,
5620 "track-set-stereo-width-or-right-channel-pan-for-track-73-midi-cc-osc-only": 30074,
5621 "track-set-stereo-width-or-right-channel-pan-for-track-74-midi-cc-osc-only": 30075,
5622 "track-set-stereo-width-or-right-channel-pan-for-track-75-midi-cc-osc-only": 30076,
5623 "track-set-stereo-width-or-right-channel-pan-for-track-76-midi-cc-osc-only": 30077,
5624 "track-set-stereo-width-or-right-channel-pan-for-track-77-midi-cc-osc-only": 30078,
5625 "track-set-stereo-width-or-right-channel-pan-for-track-78-midi-cc-osc-only": 30079,
5626 "track-set-stereo-width-or-right-channel-pan-for-track-79-midi-cc-osc-only": 30080,
5627 "track-set-stereo-width-or-right-channel-pan-for-track-80-midi-cc-osc-only": 30081,
5628 "track-set-stereo-width-or-right-channel-pan-for-track-81-midi-cc-osc-only": 30082,
5629 "track-set-stereo-width-or-right-channel-pan-for-track-82-midi-cc-osc-only": 30083,
5630 "track-set-stereo-width-or-right-channel-pan-for-track-83-midi-cc-osc-only": 30084,
5631 "track-set-stereo-width-or-right-channel-pan-for-track-84-midi-cc-osc-only": 30085,
5632 "track-set-stereo-width-or-right-channel-pan-for-track-85-midi-cc-osc-only": 30086,
5633 "track-set-stereo-width-or-right-channel-pan-for-track-86-midi-cc-osc-only": 30087,
5634 "track-set-stereo-width-or-right-channel-pan-for-track-87-midi-cc-osc-only": 30088,
5635 "track-set-stereo-width-or-right-channel-pan-for-track-88-midi-cc-osc-only": 30089,
5636 "track-set-stereo-width-or-right-channel-pan-for-track-89-midi-cc-osc-only": 30090,
5637 "track-set-stereo-width-or-right-channel-pan-for-track-90-midi-cc-osc-only": 30091,
5638 "track-set-stereo-width-or-right-channel-pan-for-track-91-midi-cc-osc-only": 30092,
5639 "track-set-stereo-width-or-right-channel-pan-for-track-92-midi-cc-osc-only": 30093,
5640 "track-set-stereo-width-or-right-channel-pan-for-track-93-midi-cc-osc-only": 30094,
5641 "track-set-stereo-width-or-right-channel-pan-for-track-94-midi-cc-osc-only": 30095,
5642 "track-set-stereo-width-or-right-channel-pan-for-track-95-midi-cc-osc-only": 30096,
5643 "track-set-stereo-width-or-right-channel-pan-for-track-96-midi-cc-osc-only": 30097,
5644 "track-set-stereo-width-or-right-channel-pan-for-track-97-midi-cc-osc-only": 30098,
5645 "track-set-stereo-width-or-right-channel-pan-for-track-98-midi-cc-osc-only": 30099,
5646 "track-set-stereo-width-or-right-channel-pan-for-track-99-midi-cc-osc-only": 30100,
5647 "track-set-to-custom-color": 40357,
5648 "track-set-to-default-color": 40359,
5649 "track-set-to-one-random-color": 40360,
5650 "track-set-to-random-colors": 40358,
5651 "track-set-track-grouping-parameters": 40772,
5652 "track-set-track-icon": 40899,
5653 "track-set-track-record-mode-to-input": 40496,
5654 "track-set-track-record-mode-to-midi-latch-replace": 41727,
5655 "track-set-track-record-mode-to-midi-output": 40500,
5656 "track-set-track-record-mode-to-midi-overdub": 40503,
5657 "track-set-track-record-mode-to-midi-replace": 40504,
5658 "track-set-track-record-mode-to-midi-touch-replace": 40852,
5659 "track-set-track-record-mode-to-none-monitoring-only": 40498,
5660 "track-set-track-record-mode-to-output-full-multichannel": 40895,
5661 "track-set-track-record-mode-to-output-full-multichannel-compensated": 40896,
5662 "track-set-track-record-mode-to-output-mono": 40501,
5663 "track-set-track-record-mode-to-output-mono-latency-compensated": 40502,
5664 "track-set-track-record-mode-to-output-stereo": 40497,
5665 "track-set-track-record-mode-to-output-stereo-latency-compensated": 40499,
5666 "track-set-track-record-monitor-to-auto-tape": 40494,
5667 "track-set-track-record-monitor-to-off": 40492,
5668 "track-set-track-record-monitor-to-on": 40493,
5669 "track-set-track-record-output-mode-to-post-fader": 42225,
5670 "track-set-track-record-output-mode-to-post-fx-pre-fader": 42227,
5671 "track-set-track-record-output-mode-to-pre-fx": 42226,
5672 "track-set-track-solo-defeat": 41197,
5673 "track-set-volume-for-last-touched-track-midi-cc-osc-only": 812,
5674 "track-set-volume-for-master-track-midi-cc-osc-only": 12,
5675 "track-set-volume-for-selected-tracks-midi-cc-osc-only": 4,
5676 "track-set-volume-for-track-01-midi-cc-osc-only": 20,
5677 "track-set-volume-for-track-02-midi-cc-osc-only": 28,
5678 "track-set-volume-for-track-03-midi-cc-osc-only": 36,
5679 "track-set-volume-for-track-04-midi-cc-osc-only": 44,
5680 "track-set-volume-for-track-05-midi-cc-osc-only": 52,
5681 "track-set-volume-for-track-06-midi-cc-osc-only": 60,
5682 "track-set-volume-for-track-07-midi-cc-osc-only": 68,
5683 "track-set-volume-for-track-08-midi-cc-osc-only": 76,
5684 "track-set-volume-for-track-09-midi-cc-osc-only": 84,
5685 "track-set-volume-for-track-10-midi-cc-osc-only": 92,
5686 "track-set-volume-for-track-11-midi-cc-osc-only": 100,
5687 "track-set-volume-for-track-12-midi-cc-osc-only": 108,
5688 "track-set-volume-for-track-13-midi-cc-osc-only": 116,
5689 "track-set-volume-for-track-14-midi-cc-osc-only": 124,
5690 "track-set-volume-for-track-15-midi-cc-osc-only": 132,
5691 "track-set-volume-for-track-16-midi-cc-osc-only": 140,
5692 "track-set-volume-for-track-17-midi-cc-osc-only": 148,
5693 "track-set-volume-for-track-18-midi-cc-osc-only": 156,
5694 "track-set-volume-for-track-19-midi-cc-osc-only": 164,
5695 "track-set-volume-for-track-20-midi-cc-osc-only": 172,
5696 "track-set-volume-for-track-21-midi-cc-osc-only": 180,
5697 "track-set-volume-for-track-22-midi-cc-osc-only": 188,
5698 "track-set-volume-for-track-23-midi-cc-osc-only": 196,
5699 "track-set-volume-for-track-24-midi-cc-osc-only": 204,
5700 "track-set-volume-for-track-25-midi-cc-osc-only": 212,
5701 "track-set-volume-for-track-26-midi-cc-osc-only": 220,
5702 "track-set-volume-for-track-27-midi-cc-osc-only": 228,
5703 "track-set-volume-for-track-28-midi-cc-osc-only": 236,
5704 "track-set-volume-for-track-29-midi-cc-osc-only": 244,
5705 "track-set-volume-for-track-30-midi-cc-osc-only": 252,
5706 "track-set-volume-for-track-31-midi-cc-osc-only": 260,
5707 "track-set-volume-for-track-32-midi-cc-osc-only": 268,
5708 "track-set-volume-for-track-33-midi-cc-osc-only": 276,
5709 "track-set-volume-for-track-34-midi-cc-osc-only": 284,
5710 "track-set-volume-for-track-35-midi-cc-osc-only": 292,
5711 "track-set-volume-for-track-36-midi-cc-osc-only": 300,
5712 "track-set-volume-for-track-37-midi-cc-osc-only": 308,
5713 "track-set-volume-for-track-38-midi-cc-osc-only": 316,
5714 "track-set-volume-for-track-39-midi-cc-osc-only": 324,
5715 "track-set-volume-for-track-40-midi-cc-osc-only": 332,
5716 "track-set-volume-for-track-41-midi-cc-osc-only": 340,
5717 "track-set-volume-for-track-42-midi-cc-osc-only": 348,
5718 "track-set-volume-for-track-43-midi-cc-osc-only": 356,
5719 "track-set-volume-for-track-44-midi-cc-osc-only": 364,
5720 "track-set-volume-for-track-45-midi-cc-osc-only": 372,
5721 "track-set-volume-for-track-46-midi-cc-osc-only": 380,
5722 "track-set-volume-for-track-47-midi-cc-osc-only": 388,
5723 "track-set-volume-for-track-48-midi-cc-osc-only": 396,
5724 "track-set-volume-for-track-49-midi-cc-osc-only": 404,
5725 "track-set-volume-for-track-50-midi-cc-osc-only": 412,
5726 "track-set-volume-for-track-51-midi-cc-osc-only": 420,
5727 "track-set-volume-for-track-52-midi-cc-osc-only": 428,
5728 "track-set-volume-for-track-53-midi-cc-osc-only": 436,
5729 "track-set-volume-for-track-54-midi-cc-osc-only": 444,
5730 "track-set-volume-for-track-55-midi-cc-osc-only": 452,
5731 "track-set-volume-for-track-56-midi-cc-osc-only": 460,
5732 "track-set-volume-for-track-57-midi-cc-osc-only": 468,
5733 "track-set-volume-for-track-58-midi-cc-osc-only": 476,
5734 "track-set-volume-for-track-59-midi-cc-osc-only": 484,
5735 "track-set-volume-for-track-60-midi-cc-osc-only": 492,
5736 "track-set-volume-for-track-61-midi-cc-osc-only": 500,
5737 "track-set-volume-for-track-62-midi-cc-osc-only": 508,
5738 "track-set-volume-for-track-63-midi-cc-osc-only": 516,
5739 "track-set-volume-for-track-64-midi-cc-osc-only": 524,
5740 "track-set-volume-for-track-65-midi-cc-osc-only": 532,
5741 "track-set-volume-for-track-66-midi-cc-osc-only": 540,
5742 "track-set-volume-for-track-67-midi-cc-osc-only": 548,
5743 "track-set-volume-for-track-68-midi-cc-osc-only": 556,
5744 "track-set-volume-for-track-69-midi-cc-osc-only": 564,
5745 "track-set-volume-for-track-70-midi-cc-osc-only": 572,
5746 "track-set-volume-for-track-71-midi-cc-osc-only": 580,
5747 "track-set-volume-for-track-72-midi-cc-osc-only": 588,
5748 "track-set-volume-for-track-73-midi-cc-osc-only": 596,
5749 "track-set-volume-for-track-74-midi-cc-osc-only": 604,
5750 "track-set-volume-for-track-75-midi-cc-osc-only": 612,
5751 "track-set-volume-for-track-76-midi-cc-osc-only": 620,
5752 "track-set-volume-for-track-77-midi-cc-osc-only": 628,
5753 "track-set-volume-for-track-78-midi-cc-osc-only": 636,
5754 "track-set-volume-for-track-79-midi-cc-osc-only": 644,
5755 "track-set-volume-for-track-80-midi-cc-osc-only": 652,
5756 "track-set-volume-for-track-81-midi-cc-osc-only": 660,
5757 "track-set-volume-for-track-82-midi-cc-osc-only": 668,
5758 "track-set-volume-for-track-83-midi-cc-osc-only": 676,
5759 "track-set-volume-for-track-84-midi-cc-osc-only": 684,
5760 "track-set-volume-for-track-85-midi-cc-osc-only": 692,
5761 "track-set-volume-for-track-86-midi-cc-osc-only": 700,
5762 "track-set-volume-for-track-87-midi-cc-osc-only": 708,
5763 "track-set-volume-for-track-88-midi-cc-osc-only": 716,
5764 "track-set-volume-for-track-89-midi-cc-osc-only": 724,
5765 "track-set-volume-for-track-90-midi-cc-osc-only": 732,
5766 "track-set-volume-for-track-91-midi-cc-osc-only": 740,
5767 "track-set-volume-for-track-92-midi-cc-osc-only": 748,
5768 "track-set-volume-for-track-93-midi-cc-osc-only": 756,
5769 "track-set-volume-for-track-94-midi-cc-osc-only": 764,
5770 "track-set-volume-for-track-95-midi-cc-osc-only": 772,
5771 "track-set-volume-for-track-96-midi-cc-osc-only": 780,
5772 "track-set-volume-for-track-97-midi-cc-osc-only": 788,
5773 "track-set-volume-for-track-98-midi-cc-osc-only": 796,
5774 "track-set-volume-for-track-99-midi-cc-osc-only": 804,
5775 "track-show-hide-all-pinned-tracks-in-tcp": 43575,
5776 "track-show-hide-all-pinned-tracks-in-tcp-ignore-master": 43576,
5777 "track-show-hide-children-of-selected-folder-tracks-in-mixer": 41665,
5778 "track-show-hide-children-of-selected-folder-tracks-in-tcp": 42696,
5779 "track-show-only-one-lane-for-all-fixed-lane-tracks-in-the-project-that-have-only-one-lane-playing": 42961,
5780 "track-solo-tracks": 40728,
5781 "track-solo-unsolo-tracks": 40281,
5782 "track-swap-volume-envelope-and-trim-envelope": 42021,
5783 "track-toggle-all-track-grouping-enabled": 40771,
5784 "track-toggle-allow-editing-media-items-while-comping": 42597,
5785 "track-toggle-allow-editing-media-items-while-comping-for-track-at-mouse": 42598,
5786 "track-toggle-automatic-record-arm-when-track-selected": 40736,
5787 "track-toggle-automatically-creating-comp-areas-for-new-recording-while-comping": 42675,
5788 "track-toggle-automatically-delete-empty-fixed-lanes-at-bottom-of-track": 42659,
5789 "track-toggle-comping": 42645,
5790 "track-toggle-comping-for-track-at-mouse": 42646,
5791 "track-toggle-full-multichannel-metering": 41726,
5792 "track-toggle-fx-bypass-for-current-last-touched-track": 40298,
5793 "track-toggle-fx-bypass-for-last-touched-track": 816,
5794 "track-toggle-fx-bypass-for-master-track": 16,
5795 "track-toggle-fx-bypass-for-selected-tracks": 8,
5796 "track-toggle-fx-bypass-for-track-01": 24,
5797 "track-toggle-fx-bypass-for-track-02": 32,
5798 "track-toggle-fx-bypass-for-track-03": 40,
5799 "track-toggle-fx-bypass-for-track-04": 48,
5800 "track-toggle-fx-bypass-for-track-05": 56,
5801 "track-toggle-fx-bypass-for-track-06": 64,
5802 "track-toggle-fx-bypass-for-track-07": 72,
5803 "track-toggle-fx-bypass-for-track-08": 80,
5804 "track-toggle-fx-bypass-for-track-09": 88,
5805 "track-toggle-fx-bypass-for-track-10": 96,
5806 "track-toggle-fx-bypass-for-track-11": 104,
5807 "track-toggle-fx-bypass-for-track-12": 112,
5808 "track-toggle-fx-bypass-for-track-13": 120,
5809 "track-toggle-fx-bypass-for-track-14": 128,
5810 "track-toggle-fx-bypass-for-track-15": 136,
5811 "track-toggle-fx-bypass-for-track-16": 144,
5812 "track-toggle-fx-bypass-for-track-17": 152,
5813 "track-toggle-fx-bypass-for-track-18": 160,
5814 "track-toggle-fx-bypass-for-track-19": 168,
5815 "track-toggle-fx-bypass-for-track-20": 176,
5816 "track-toggle-fx-bypass-for-track-21": 184,
5817 "track-toggle-fx-bypass-for-track-22": 192,
5818 "track-toggle-fx-bypass-for-track-23": 200,
5819 "track-toggle-fx-bypass-for-track-24": 208,
5820 "track-toggle-fx-bypass-for-track-25": 216,
5821 "track-toggle-fx-bypass-for-track-26": 224,
5822 "track-toggle-fx-bypass-for-track-27": 232,
5823 "track-toggle-fx-bypass-for-track-28": 240,
5824 "track-toggle-fx-bypass-for-track-29": 248,
5825 "track-toggle-fx-bypass-for-track-30": 256,
5826 "track-toggle-fx-bypass-for-track-31": 264,
5827 "track-toggle-fx-bypass-for-track-32": 272,
5828 "track-toggle-fx-bypass-for-track-33": 280,
5829 "track-toggle-fx-bypass-for-track-34": 288,
5830 "track-toggle-fx-bypass-for-track-35": 296,
5831 "track-toggle-fx-bypass-for-track-36": 304,
5832 "track-toggle-fx-bypass-for-track-37": 312,
5833 "track-toggle-fx-bypass-for-track-38": 320,
5834 "track-toggle-fx-bypass-for-track-39": 328,
5835 "track-toggle-fx-bypass-for-track-40": 336,
5836 "track-toggle-fx-bypass-for-track-41": 344,
5837 "track-toggle-fx-bypass-for-track-42": 352,
5838 "track-toggle-fx-bypass-for-track-43": 360,
5839 "track-toggle-fx-bypass-for-track-44": 368,
5840 "track-toggle-fx-bypass-for-track-45": 376,
5841 "track-toggle-fx-bypass-for-track-46": 384,
5842 "track-toggle-fx-bypass-for-track-47": 392,
5843 "track-toggle-fx-bypass-for-track-48": 400,
5844 "track-toggle-fx-bypass-for-track-49": 408,
5845 "track-toggle-fx-bypass-for-track-50": 416,
5846 "track-toggle-fx-bypass-for-track-51": 424,
5847 "track-toggle-fx-bypass-for-track-52": 432,
5848 "track-toggle-fx-bypass-for-track-53": 440,
5849 "track-toggle-fx-bypass-for-track-54": 448,
5850 "track-toggle-fx-bypass-for-track-55": 456,
5851 "track-toggle-fx-bypass-for-track-56": 464,
5852 "track-toggle-fx-bypass-for-track-57": 472,
5853 "track-toggle-fx-bypass-for-track-58": 480,
5854 "track-toggle-fx-bypass-for-track-59": 488,
5855 "track-toggle-fx-bypass-for-track-60": 496,
5856 "track-toggle-fx-bypass-for-track-61": 504,
5857 "track-toggle-fx-bypass-for-track-62": 512,
5858 "track-toggle-fx-bypass-for-track-63": 520,
5859 "track-toggle-fx-bypass-for-track-64": 528,
5860 "track-toggle-fx-bypass-for-track-65": 536,
5861 "track-toggle-fx-bypass-for-track-66": 544,
5862 "track-toggle-fx-bypass-for-track-67": 552,
5863 "track-toggle-fx-bypass-for-track-68": 560,
5864 "track-toggle-fx-bypass-for-track-69": 568,
5865 "track-toggle-fx-bypass-for-track-70": 576,
5866 "track-toggle-fx-bypass-for-track-71": 584,
5867 "track-toggle-fx-bypass-for-track-72": 592,
5868 "track-toggle-fx-bypass-for-track-73": 600,
5869 "track-toggle-fx-bypass-for-track-74": 608,
5870 "track-toggle-fx-bypass-for-track-75": 616,
5871 "track-toggle-fx-bypass-for-track-76": 624,
5872 "track-toggle-fx-bypass-for-track-77": 632,
5873 "track-toggle-fx-bypass-for-track-78": 640,
5874 "track-toggle-fx-bypass-for-track-79": 648,
5875 "track-toggle-fx-bypass-for-track-80": 656,
5876 "track-toggle-fx-bypass-for-track-81": 664,
5877 "track-toggle-fx-bypass-for-track-82": 672,
5878 "track-toggle-fx-bypass-for-track-83": 680,
5879 "track-toggle-fx-bypass-for-track-84": 688,
5880 "track-toggle-fx-bypass-for-track-85": 696,
5881 "track-toggle-fx-bypass-for-track-86": 704,
5882 "track-toggle-fx-bypass-for-track-87": 712,
5883 "track-toggle-fx-bypass-for-track-88": 720,
5884 "track-toggle-fx-bypass-for-track-89": 728,
5885 "track-toggle-fx-bypass-for-track-90": 736,
5886 "track-toggle-fx-bypass-for-track-91": 744,
5887 "track-toggle-fx-bypass-for-track-92": 752,
5888 "track-toggle-fx-bypass-for-track-93": 760,
5889 "track-toggle-fx-bypass-for-track-94": 768,
5890 "track-toggle-fx-bypass-for-track-95": 776,
5891 "track-toggle-fx-bypass-for-track-96": 784,
5892 "track-toggle-fx-bypass-for-track-97": 792,
5893 "track-toggle-fx-bypass-for-track-98": 800,
5894 "track-toggle-fx-bypass-for-track-99": 808,
5895 "track-toggle-fx-bypass-on-all-tracks": 40344,
5896 "track-toggle-link-unlink-track-volume-pan-controls-to-midi-volume-pan-on-all-channels": 41556,
5897 "track-toggle-lock-unlock-track-controls": 41314,
5898 "track-toggle-midi-input-quantize-for-all-tracks": 42034,
5899 "track-toggle-midi-input-quantize-for-last-touched-track": 42035,
5900 "track-toggle-midi-input-quantize-for-selected-tracks": 42033,
5901 "track-toggle-mute-for-last-touched-track": 814,
5902 "track-toggle-mute-for-master-track": 14,
5903 "track-toggle-mute-for-selected-tracks": 6,
5904 "track-toggle-mute-for-track-01": 22,
5905 "track-toggle-mute-for-track-02": 30,
5906 "track-toggle-mute-for-track-03": 38,
5907 "track-toggle-mute-for-track-04": 46,
5908 "track-toggle-mute-for-track-05": 54,
5909 "track-toggle-mute-for-track-06": 62,
5910 "track-toggle-mute-for-track-07": 70,
5911 "track-toggle-mute-for-track-08": 78,
5912 "track-toggle-mute-for-track-09": 86,
5913 "track-toggle-mute-for-track-10": 94,
5914 "track-toggle-mute-for-track-11": 102,
5915 "track-toggle-mute-for-track-12": 110,
5916 "track-toggle-mute-for-track-13": 118,
5917 "track-toggle-mute-for-track-14": 126,
5918 "track-toggle-mute-for-track-15": 134,
5919 "track-toggle-mute-for-track-16": 142,
5920 "track-toggle-mute-for-track-17": 150,
5921 "track-toggle-mute-for-track-18": 158,
5922 "track-toggle-mute-for-track-19": 166,
5923 "track-toggle-mute-for-track-20": 174,
5924 "track-toggle-mute-for-track-21": 182,
5925 "track-toggle-mute-for-track-22": 190,
5926 "track-toggle-mute-for-track-23": 198,
5927 "track-toggle-mute-for-track-24": 206,
5928 "track-toggle-mute-for-track-25": 214,
5929 "track-toggle-mute-for-track-26": 222,
5930 "track-toggle-mute-for-track-27": 230,
5931 "track-toggle-mute-for-track-28": 238,
5932 "track-toggle-mute-for-track-29": 246,
5933 "track-toggle-mute-for-track-30": 254,
5934 "track-toggle-mute-for-track-31": 262,
5935 "track-toggle-mute-for-track-32": 270,
5936 "track-toggle-mute-for-track-33": 278,
5937 "track-toggle-mute-for-track-34": 286,
5938 "track-toggle-mute-for-track-35": 294,
5939 "track-toggle-mute-for-track-36": 302,
5940 "track-toggle-mute-for-track-37": 310,
5941 "track-toggle-mute-for-track-38": 318,
5942 "track-toggle-mute-for-track-39": 326,
5943 "track-toggle-mute-for-track-40": 334,
5944 "track-toggle-mute-for-track-41": 342,
5945 "track-toggle-mute-for-track-42": 350,
5946 "track-toggle-mute-for-track-43": 358,
5947 "track-toggle-mute-for-track-44": 366,
5948 "track-toggle-mute-for-track-45": 374,
5949 "track-toggle-mute-for-track-46": 382,
5950 "track-toggle-mute-for-track-47": 390,
5951 "track-toggle-mute-for-track-48": 398,
5952 "track-toggle-mute-for-track-49": 406,
5953 "track-toggle-mute-for-track-50": 414,
5954 "track-toggle-mute-for-track-51": 422,
5955 "track-toggle-mute-for-track-52": 430,
5956 "track-toggle-mute-for-track-53": 438,
5957 "track-toggle-mute-for-track-54": 446,
5958 "track-toggle-mute-for-track-55": 454,
5959 "track-toggle-mute-for-track-56": 462,
5960 "track-toggle-mute-for-track-57": 470,
5961 "track-toggle-mute-for-track-58": 478,
5962 "track-toggle-mute-for-track-59": 486,
5963 "track-toggle-mute-for-track-60": 494,
5964 "track-toggle-mute-for-track-61": 502,
5965 "track-toggle-mute-for-track-62": 510,
5966 "track-toggle-mute-for-track-63": 518,
5967 "track-toggle-mute-for-track-64": 526,
5968 "track-toggle-mute-for-track-65": 534,
5969 "track-toggle-mute-for-track-66": 542,
5970 "track-toggle-mute-for-track-67": 550,
5971 "track-toggle-mute-for-track-68": 558,
5972 "track-toggle-mute-for-track-69": 566,
5973 "track-toggle-mute-for-track-70": 574,
5974 "track-toggle-mute-for-track-71": 582,
5975 "track-toggle-mute-for-track-72": 590,
5976 "track-toggle-mute-for-track-73": 598,
5977 "track-toggle-mute-for-track-74": 606,
5978 "track-toggle-mute-for-track-75": 614,
5979 "track-toggle-mute-for-track-76": 622,
5980 "track-toggle-mute-for-track-77": 630,
5981 "track-toggle-mute-for-track-78": 638,
5982 "track-toggle-mute-for-track-79": 646,
5983 "track-toggle-mute-for-track-80": 654,
5984 "track-toggle-mute-for-track-81": 662,
5985 "track-toggle-mute-for-track-82": 670,
5986 "track-toggle-mute-for-track-83": 678,
5987 "track-toggle-mute-for-track-84": 686,
5988 "track-toggle-mute-for-track-85": 694,
5989 "track-toggle-mute-for-track-86": 702,
5990 "track-toggle-mute-for-track-87": 710,
5991 "track-toggle-mute-for-track-88": 718,
5992 "track-toggle-mute-for-track-89": 726,
5993 "track-toggle-mute-for-track-90": 734,
5994 "track-toggle-mute-for-track-91": 742,
5995 "track-toggle-mute-for-track-92": 750,
5996 "track-toggle-mute-for-track-93": 758,
5997 "track-toggle-mute-for-track-94": 766,
5998 "track-toggle-mute-for-track-95": 774,
5999 "track-toggle-mute-for-track-96": 782,
6000 "track-toggle-mute-for-track-97": 790,
6001 "track-toggle-mute-for-track-98": 798,
6002 "track-toggle-mute-for-track-99": 806,
6003 "track-toggle-preserve-pdc-delayed-monitoring-in-recorded-items": 41919,
6004 "track-toggle-record-arm-for-last-touched-track": 817,
6005 "track-toggle-record-arm-for-selected-tracks": 9,
6006 "track-toggle-record-arm-for-track-01": 25,
6007 "track-toggle-record-arm-for-track-02": 33,
6008 "track-toggle-record-arm-for-track-03": 41,
6009 "track-toggle-record-arm-for-track-04": 49,
6010 "track-toggle-record-arm-for-track-05": 57,
6011 "track-toggle-record-arm-for-track-06": 65,
6012 "track-toggle-record-arm-for-track-07": 73,
6013 "track-toggle-record-arm-for-track-08": 81,
6014 "track-toggle-record-arm-for-track-09": 89,
6015 "track-toggle-record-arm-for-track-10": 97,
6016 "track-toggle-record-arm-for-track-11": 105,
6017 "track-toggle-record-arm-for-track-12": 113,
6018 "track-toggle-record-arm-for-track-13": 121,
6019 "track-toggle-record-arm-for-track-14": 129,
6020 "track-toggle-record-arm-for-track-15": 137,
6021 "track-toggle-record-arm-for-track-16": 145,
6022 "track-toggle-record-arm-for-track-17": 153,
6023 "track-toggle-record-arm-for-track-18": 161,
6024 "track-toggle-record-arm-for-track-19": 169,
6025 "track-toggle-record-arm-for-track-20": 177,
6026 "track-toggle-record-arm-for-track-21": 185,
6027 "track-toggle-record-arm-for-track-22": 193,
6028 "track-toggle-record-arm-for-track-23": 201,
6029 "track-toggle-record-arm-for-track-24": 209,
6030 "track-toggle-record-arm-for-track-25": 217,
6031 "track-toggle-record-arm-for-track-26": 225,
6032 "track-toggle-record-arm-for-track-27": 233,
6033 "track-toggle-record-arm-for-track-28": 241,
6034 "track-toggle-record-arm-for-track-29": 249,
6035 "track-toggle-record-arm-for-track-30": 257,
6036 "track-toggle-record-arm-for-track-31": 265,
6037 "track-toggle-record-arm-for-track-32": 273,
6038 "track-toggle-record-arm-for-track-33": 281,
6039 "track-toggle-record-arm-for-track-34": 289,
6040 "track-toggle-record-arm-for-track-35": 297,
6041 "track-toggle-record-arm-for-track-36": 305,
6042 "track-toggle-record-arm-for-track-37": 313,
6043 "track-toggle-record-arm-for-track-38": 321,
6044 "track-toggle-record-arm-for-track-39": 329,
6045 "track-toggle-record-arm-for-track-40": 337,
6046 "track-toggle-record-arm-for-track-41": 345,
6047 "track-toggle-record-arm-for-track-42": 353,
6048 "track-toggle-record-arm-for-track-43": 361,
6049 "track-toggle-record-arm-for-track-44": 369,
6050 "track-toggle-record-arm-for-track-45": 377,
6051 "track-toggle-record-arm-for-track-46": 385,
6052 "track-toggle-record-arm-for-track-47": 393,
6053 "track-toggle-record-arm-for-track-48": 401,
6054 "track-toggle-record-arm-for-track-49": 409,
6055 "track-toggle-record-arm-for-track-50": 417,
6056 "track-toggle-record-arm-for-track-51": 425,
6057 "track-toggle-record-arm-for-track-52": 433,
6058 "track-toggle-record-arm-for-track-53": 441,
6059 "track-toggle-record-arm-for-track-54": 449,
6060 "track-toggle-record-arm-for-track-55": 457,
6061 "track-toggle-record-arm-for-track-56": 465,
6062 "track-toggle-record-arm-for-track-57": 473,
6063 "track-toggle-record-arm-for-track-58": 481,
6064 "track-toggle-record-arm-for-track-59": 489,
6065 "track-toggle-record-arm-for-track-60": 497,
6066 "track-toggle-record-arm-for-track-61": 505,
6067 "track-toggle-record-arm-for-track-62": 513,
6068 "track-toggle-record-arm-for-track-63": 521,
6069 "track-toggle-record-arm-for-track-64": 529,
6070 "track-toggle-record-arm-for-track-65": 537,
6071 "track-toggle-record-arm-for-track-66": 545,
6072 "track-toggle-record-arm-for-track-67": 553,
6073 "track-toggle-record-arm-for-track-68": 561,
6074 "track-toggle-record-arm-for-track-69": 569,
6075 "track-toggle-record-arm-for-track-70": 577,
6076 "track-toggle-record-arm-for-track-71": 585,
6077 "track-toggle-record-arm-for-track-72": 593,
6078 "track-toggle-record-arm-for-track-73": 601,
6079 "track-toggle-record-arm-for-track-74": 609,
6080 "track-toggle-record-arm-for-track-75": 617,
6081 "track-toggle-record-arm-for-track-76": 625,
6082 "track-toggle-record-arm-for-track-77": 633,
6083 "track-toggle-record-arm-for-track-78": 641,
6084 "track-toggle-record-arm-for-track-79": 649,
6085 "track-toggle-record-arm-for-track-80": 657,
6086 "track-toggle-record-arm-for-track-81": 665,
6087 "track-toggle-record-arm-for-track-82": 673,
6088 "track-toggle-record-arm-for-track-83": 681,
6089 "track-toggle-record-arm-for-track-84": 689,
6090 "track-toggle-record-arm-for-track-85": 697,
6091 "track-toggle-record-arm-for-track-86": 705,
6092 "track-toggle-record-arm-for-track-87": 713,
6093 "track-toggle-record-arm-for-track-88": 721,
6094 "track-toggle-record-arm-for-track-89": 729,
6095 "track-toggle-record-arm-for-track-90": 737,
6096 "track-toggle-record-arm-for-track-91": 745,
6097 "track-toggle-record-arm-for-track-92": 753,
6098 "track-toggle-record-arm-for-track-93": 761,
6099 "track-toggle-record-arm-for-track-94": 769,
6100 "track-toggle-record-arm-for-track-95": 777,
6101 "track-toggle-record-arm-for-track-96": 785,
6102 "track-toggle-record-arm-for-track-97": 793,
6103 "track-toggle-record-arm-for-track-98": 801,
6104 "track-toggle-record-arm-for-track-99": 809,
6105 "track-toggle-record-arming-for-current-last-touched-track": 40294,
6106 "track-toggle-show-hide-in-mixer": 40250,
6107 "track-toggle-show-hide-in-tcp": 40853,
6108 "track-toggle-solo-for-last-touched-track": 815,
6109 "track-toggle-solo-for-master-track": 15,
6110 "track-toggle-solo-for-selected-tracks": 7,
6111 "track-toggle-solo-for-track-01": 23,
6112 "track-toggle-solo-for-track-02": 31,
6113 "track-toggle-solo-for-track-03": 39,
6114 "track-toggle-solo-for-track-04": 47,
6115 "track-toggle-solo-for-track-05": 55,
6116 "track-toggle-solo-for-track-06": 63,
6117 "track-toggle-solo-for-track-07": 71,
6118 "track-toggle-solo-for-track-08": 79,
6119 "track-toggle-solo-for-track-09": 87,
6120 "track-toggle-solo-for-track-10": 95,
6121 "track-toggle-solo-for-track-11": 103,
6122 "track-toggle-solo-for-track-12": 111,
6123 "track-toggle-solo-for-track-13": 119,
6124 "track-toggle-solo-for-track-14": 127,
6125 "track-toggle-solo-for-track-15": 135,
6126 "track-toggle-solo-for-track-16": 143,
6127 "track-toggle-solo-for-track-17": 151,
6128 "track-toggle-solo-for-track-18": 159,
6129 "track-toggle-solo-for-track-19": 167,
6130 "track-toggle-solo-for-track-20": 175,
6131 "track-toggle-solo-for-track-21": 183,
6132 "track-toggle-solo-for-track-22": 191,
6133 "track-toggle-solo-for-track-23": 199,
6134 "track-toggle-solo-for-track-24": 207,
6135 "track-toggle-solo-for-track-25": 215,
6136 "track-toggle-solo-for-track-26": 223,
6137 "track-toggle-solo-for-track-27": 231,
6138 "track-toggle-solo-for-track-28": 239,
6139 "track-toggle-solo-for-track-29": 247,
6140 "track-toggle-solo-for-track-30": 255,
6141 "track-toggle-solo-for-track-31": 263,
6142 "track-toggle-solo-for-track-32": 271,
6143 "track-toggle-solo-for-track-33": 279,
6144 "track-toggle-solo-for-track-34": 287,
6145 "track-toggle-solo-for-track-35": 295,
6146 "track-toggle-solo-for-track-36": 303,
6147 "track-toggle-solo-for-track-37": 311,
6148 "track-toggle-solo-for-track-38": 319,
6149 "track-toggle-solo-for-track-39": 327,
6150 "track-toggle-solo-for-track-40": 335,
6151 "track-toggle-solo-for-track-41": 343,
6152 "track-toggle-solo-for-track-42": 351,
6153 "track-toggle-solo-for-track-43": 359,
6154 "track-toggle-solo-for-track-44": 367,
6155 "track-toggle-solo-for-track-45": 375,
6156 "track-toggle-solo-for-track-46": 383,
6157 "track-toggle-solo-for-track-47": 391,
6158 "track-toggle-solo-for-track-48": 399,
6159 "track-toggle-solo-for-track-49": 407,
6160 "track-toggle-solo-for-track-50": 415,
6161 "track-toggle-solo-for-track-51": 423,
6162 "track-toggle-solo-for-track-52": 431,
6163 "track-toggle-solo-for-track-53": 439,
6164 "track-toggle-solo-for-track-54": 447,
6165 "track-toggle-solo-for-track-55": 455,
6166 "track-toggle-solo-for-track-56": 463,
6167 "track-toggle-solo-for-track-57": 471,
6168 "track-toggle-solo-for-track-58": 479,
6169 "track-toggle-solo-for-track-59": 487,
6170 "track-toggle-solo-for-track-60": 495,
6171 "track-toggle-solo-for-track-61": 503,
6172 "track-toggle-solo-for-track-62": 511,
6173 "track-toggle-solo-for-track-63": 519,
6174 "track-toggle-solo-for-track-64": 527,
6175 "track-toggle-solo-for-track-65": 535,
6176 "track-toggle-solo-for-track-66": 543,
6177 "track-toggle-solo-for-track-67": 551,
6178 "track-toggle-solo-for-track-68": 559,
6179 "track-toggle-solo-for-track-69": 567,
6180 "track-toggle-solo-for-track-70": 575,
6181 "track-toggle-solo-for-track-71": 583,
6182 "track-toggle-solo-for-track-72": 591,
6183 "track-toggle-solo-for-track-73": 599,
6184 "track-toggle-solo-for-track-74": 607,
6185 "track-toggle-solo-for-track-75": 615,
6186 "track-toggle-solo-for-track-76": 623,
6187 "track-toggle-solo-for-track-77": 631,
6188 "track-toggle-solo-for-track-78": 639,
6189 "track-toggle-solo-for-track-79": 647,
6190 "track-toggle-solo-for-track-80": 655,
6191 "track-toggle-solo-for-track-81": 663,
6192 "track-toggle-solo-for-track-82": 671,
6193 "track-toggle-solo-for-track-83": 679,
6194 "track-toggle-solo-for-track-84": 687,
6195 "track-toggle-solo-for-track-85": 695,
6196 "track-toggle-solo-for-track-86": 703,
6197 "track-toggle-solo-for-track-87": 711,
6198 "track-toggle-solo-for-track-88": 719,
6199 "track-toggle-solo-for-track-89": 727,
6200 "track-toggle-solo-for-track-90": 735,
6201 "track-toggle-solo-for-track-91": 743,
6202 "track-toggle-solo-for-track-92": 751,
6203 "track-toggle-solo-for-track-93": 759,
6204 "track-toggle-solo-for-track-94": 767,
6205 "track-toggle-solo-for-track-95": 775,
6206 "track-toggle-solo-for-track-96": 783,
6207 "track-toggle-solo-for-track-97": 791,
6208 "track-toggle-solo-for-track-98": 799,
6209 "track-toggle-solo-for-track-99": 807,
6210 "track-toggle-track-metering": 41744,
6211 "track-toggle-track-mute-envelope-active": 40866,
6212 "track-toggle-track-mute-envelope-visible": 40867,
6213 "track-toggle-track-pan-envelope-active": 40053,
6214 "track-toggle-track-pan-envelope-visible": 40407,
6215 "track-toggle-track-pre-fx-pan-envelope-active": 40051,
6216 "track-toggle-track-pre-fx-pan-envelope-visible": 40409,
6217 "track-toggle-track-pre-fx-volume-envelope-active": 40050,
6218 "track-toggle-track-pre-fx-volume-envelope-visible": 40408,
6219 "track-toggle-track-solo-defeat": 41199,
6220 "track-toggle-track-trim-envelope-visible": 42020,
6221 "track-toggle-track-volume-envelope-active": 40052,
6222 "track-toggle-track-volume-envelope-visible": 40406,
6223 "track-turn-off-automatic-track-grouping": 42585,
6224 "track-unarm-all-tracks-for-recording": 40491,
6225 "track-unbypass-fx-on-all-tracks": 40343,
6226 "track-unfreeze-tracks-restore-previously-saved-items-and-fx": 41644,
6227 "track-unlock-track-controls": 41313,
6228 "track-unmute-all-tracks": 40339,
6229 "track-unmute-tracks": 40731,
6230 "track-unselect-clear-selection-of-all-tracks": 40297,
6231 "track-unset-preserve-pdc-delayed-monitoring-in-recorded-items": 41920,
6232 "track-unset-track-solo-defeat": 41198,
6233 "track-unset-track-solo-defeat-all-tracks": 40770,
6234 "track-unsolo-all-tracks": 40340,
6235 "track-unsolo-tracks": 40729,
6236 "track-vertical-scroll-selected-tracks-into-view": 40913,
6237 "track-view-envelopes-for-current-last-touched-track": 40292,
6238 "track-view-envelopes-for-current-last-touched-track-at-mouse-cursor": 41975,
6239 "track-view-fx-chain-for-current-last-touched-track": 40291,
6240 "track-view-fx-chain-for-master-track": 40846,
6241 "track-view-input-fx-chain-for-current-last-touched-track": 40844,
6242 "track-view-routing-and-i-o-for-current-last-touched-track": 40293,
6243 "track-view-routing-and-i-o-for-master-track": 42235,
6244 "track-view-track-recording-settings-midi-quantize-file-format-path-for-last-touched-track": 40604,
6245 "tracks-copy-items-on-currently-playing-lanes-on-selected-fixed-lane-tracks-to-one-new-track-per-lane": 42694,
6246 "tracks-explode-items-on-selected-fixed-lane-tracks-to-one-new-track-per-lane": 42695,
6247 "tracks-explode-selected-items-on-fixed-lane-tracks-to-one-new-track-per-lane": 42639,
6248 "tracks-implode-selected-items-across-tracks-to-one-fixed-lane-track": 42596,
6249 "transient-detection-sensitivity-adjust-midi-cc-mousewheel-only": 967,
6250 "transient-detection-sensitivity-decrease": 41537,
6251 "transient-detection-sensitivity-increase": 41536,
6252 "transient-detection-sensitivity-threshold-adjust": 41208,
6253 "transient-detection-threshold-adjust-midi-cc-mousewheel-only": 968,
6254 "transient-detection-threshold-decrease": 40219,
6255 "transient-detection-threshold-increase": 40218,
6256 "transport-apply-playrate-to-current-bpm": 40672,
6257 "transport-apply-playrate-to-current-bpm-no-reset-playrate": 40526,
6258 "transport-center-transport-controls": 40533,
6259 "transport-decrease-playrate-by-0-6-percent-10-cents": 40525,
6260 "transport-decrease-playrate-by-6-percent-one-semitone": 40523,
6261 "transport-fast-forward-a-little-bit": 40085,
6262 "transport-flash-transport-yellow-on-possible-audio-device-underrun": 42305,
6263 "transport-go-to-end-of-project": 40043,
6264 "transport-go-to-start-of-project": 40042,
6265 "transport-increase-playrate-by-0-6-percent-10-cents": 40524,
6266 "transport-increase-playrate-by-6-percent-one-semitone": 40522,
6267 "transport-pause": 1008,
6268 "transport-play": 1007,
6269 "transport-play-pause": 40073,
6270 "transport-play-skip-time-selection": 40317,
6271 "transport-play-stop": 40044,
6272 "transport-play-stop-move-edit-cursor-on-stop": 40328,
6273 "transport-record": 1013,
6274 "transport-rewind-a-little-bit": 40084,
6275 "transport-scrub-jog-fine-control-midi-cc-relative-only": 974,
6276 "transport-scrub-jog-midi-cc-relative-absolute-only": 992,
6277 "transport-secondary-time-unit-absolute-frames": 42371,
6278 "transport-secondary-time-unit-hours-minutes-seconds-frames": 42370,
6279 "transport-secondary-time-unit-measures-beats": 42792,
6280 "transport-secondary-time-unit-minutes-seconds": 42367,
6281 "transport-secondary-time-unit-none": 42366,
6282 "transport-secondary-time-unit-samples": 42369,
6283 "transport-secondary-time-unit-seconds": 42368,
6284 "transport-set-playrate-to-1-0": 40521,
6285 "transport-show-play-state-as-text": 40532,
6286 "transport-show-playrate-control": 40531,
6287 "transport-show-time-signature": 40680,
6288 "transport-show-transport-docked-above-ruler": 41604,
6289 "transport-show-transport-docked-below-arrange": 41603,
6290 "transport-show-transport-docked-to-bottom-of-main-window": 41605,
6291 "transport-show-transport-docked-to-top-of-main-window": 41606,
6292 "transport-show-transport-in-docker": 41608,
6293 "transport-start-stop-recording-after-2-beats": 40067,
6294 "transport-start-stop-recording-at-edit-cursor": 40046,
6295 "transport-start-stop-recording-at-next-beat": 40045,
6296 "transport-start-stop-recording-at-next-measure": 40003,
6297 "transport-start-stop-recording-at-next-project-marker": 40056,
6298 "transport-stop": 1016,
6299 "transport-stop-delete-all-recorded-media": 40668,
6300 "transport-stop-save-all-recorded-media": 40667,
6301 "transport-tap-tempo": 1134,
6302 "transport-time-unit-absolute-frames": 41972,
6303 "transport-time-unit-hours-minutes-seconds-frames": 40414,
6304 "transport-time-unit-measures-beats": 40411,
6305 "transport-time-unit-measures-beats-minutes-seconds": 40534,
6306 "transport-time-unit-minutes-seconds": 40410,
6307 "transport-time-unit-samples": 40413,
6308 "transport-time-unit-seconds": 40412,
6309 "transport-time-unit-to-ruler": 40379,
6310 "transport-toggle-preserve-pitch-in-audio-items-when-changing-master-playrate": 40671,
6311 "transport-toggle-repeat": 1068,
6312 "transport-toggle-stop-playback-at-end-of-loop-if-repeat-is-disabled": 41834,
6313 "transport-toggle-transport-docked-to-main-window": 40260,
6314 "transport-toggle-transport-home-end-marker-navigation": 40868,
6315 "unselect-clear-selection-of-all-tracks-items-envelope-points": 40769,
6316 "video-clear-video-cache-re-render-frames": 50123,
6317 "video-fullscreen": 50122,
6318 "video-show-hide-video-window": 50125,
6319 "view-adjust-horizontal-scroll-midi-cc-osc-only-relative-recommended": 997,
6320 "view-adjust-horizontal-zoom-midi-cc-osc-only": 998,
6321 "view-adjust-selected-track-heights-a-little-bit-midi-cc-relative-mousewheel": 970,
6322 "view-adjust-selected-track-heights-midi-cc-osc-only": 971,
6323 "view-adjust-selected-track-heights-midi-cc-relative-mousewheel": 972,
6324 "view-adjust-vertical-scroll-midi-cc-osc-only": 995,
6325 "view-adjust-vertical-zoom-midi-cc-osc-only": 994,
6326 "view-attach-unattach-docker-to-from-main-window": 40313,
6327 "view-clear-all-peak-indicators": 40527,
6328 "view-continuous-scrolling-during-playback": 41817,
6329 "view-cycle-track-zoom-between-minimum-default-and-maximum-height-even-if-over-100-percent-of-arrange-view": 42701,
6330 "view-cycle-track-zoom-between-minimum-default-and-maximum-height-limit-to-100-percent-of-arrange-view": 42698,
6331 "view-decrease-selected-track-heights": 41326,
6332 "view-decrease-selected-track-heights-a-little-bit": 41328,
6333 "view-expand-selected-track-height-minimize-others": 40723,
6334 "view-go-to-edit-cursor": 40151,
6335 "view-go-to-play-cursor-position": 40150,
6336 "view-go-to-track-midi-cc-osc-only": 993,
6337 "view-hide-item-labels": 40708,
6338 "view-if-displayed-toggle-mouse-position-indicator-vertical-line-respects-toolbar-snap-button": 43203,
6339 "view-increase-selected-track-heights": 41325,
6340 "view-increase-selected-track-heights-a-little-bit": 41327,
6341 "view-jump-go-to-time-window": 40069,
6342 "view-minimize-all-tracks": 40727,
6343 "view-move-cursor-left-8-pixels": 41666,
6344 "view-move-cursor-left-by-grid-division": 43614,
6345 "view-move-cursor-left-one-pixel": 40104,
6346 "view-move-cursor-left-to-grid-division": 40646,
6347 "view-move-cursor-right-8-pixels": 41667,
6348 "view-move-cursor-right-by-grid-division": 43615,
6349 "view-move-cursor-right-one-pixel": 40105,
6350 "view-move-cursor-right-to-grid-division": 40647,
6351 "view-move-edit-cursor-midi-cc-osc-only-relative-recommended": 996,
6352 "view-move-edit-cursor-to-mouse-cursor": 40513,
6353 "view-move-edit-cursor-to-mouse-cursor-no-snapping": 40514,
6354 "view-move-edit-cursor-to-play-cursor": 40434,
6355 "view-restore-next-zoom-level": 40875,
6356 "view-restore-next-zoom-scroll-position": 40762,
6357 "view-restore-previous-zoom-level": 40869,
6358 "view-restore-previous-zoom-scroll-position": 40848,
6359 "view-scale-finder-window": 40301,
6360 "view-scroll-horizontally-midi-cc-relative-mousewheel": 988,
6361 "view-scroll-horizontally-reversed-midi-cc-relative-mousewheel": 977,
6362 "view-scroll-vertically-midi-cc-relative-mousewheel": 989,
6363 "view-scroll-vertically-reversed-midi-cc-relative-mousewheel": 978,
6364 "view-scroll-view-down": 40139,
6365 "view-scroll-view-horizontally-one-page-midi-cc-relative-mousewheel": 981,
6366 "view-scroll-view-horizontally-one-page-reversed-midi-cc-relative-mousewheel": 975,
6367 "view-scroll-view-left": 40140,
6368 "view-scroll-view-right": 40141,
6369 "view-scroll-view-up": 40138,
6370 "view-scroll-view-vertically-one-page-midi-cc-relative-mousewheel": 976,
6371 "view-scroll-view-vertically-one-page-reversed-midi-cc-relative-mousewheel": 982,
6372 "view-secondary-time-unit-for-ruler-absolute-frames": 42365,
6373 "view-secondary-time-unit-for-ruler-hours-minutes-seconds-frames": 42364,
6374 "view-secondary-time-unit-for-ruler-minutes-seconds": 42361,
6375 "view-secondary-time-unit-for-ruler-minutes-seconds-minimal": 43705,
6376 "view-secondary-time-unit-for-ruler-none": 42360,
6377 "view-secondary-time-unit-for-ruler-samples": 42363,
6378 "view-secondary-time-unit-for-ruler-seconds": 42362,
6379 "view-set-horizontal-zoom-to-default-project-setting": 41190,
6380 "view-show-big-clock-plus-window": 40378,
6381 "view-show-crossfade-editor-window": 41827,
6382 "view-show-docker": 40279,
6383 "view-show-envelope-manager-window": 42678,
6384 "view-show-fx-browser-window": 40271,
6385 "view-show-item-labels": 40703,
6386 "view-show-monitoring-fx-chain": 41882,
6387 "view-show-navigator-window": 40268,
6388 "view-show-peaks-display-settings": 42074,
6389 "view-show-performance-meter-window": 40240,
6390 "view-show-project-bay-window": 41157,
6391 "view-show-project-bay-window-2": 41628,
6392 "view-show-project-bay-window-3": 41629,
6393 "view-show-project-bay-window-4": 41630,
6394 "view-show-project-bay-window-5": 41631,
6395 "view-show-project-bay-window-6": 41632,
6396 "view-show-project-bay-window-7": 41633,
6397 "view-show-project-bay-window-8": 41634,
6398 "view-show-region-marker-manager-window": 40326,
6399 "view-show-region-render-matrix-window": 41888,
6400 "view-show-routing-matrix-window": 40251,
6401 "view-show-screensets-layouts-window": 40422,
6402 "view-show-tcp-on-right-side-of-arrange": 42373,
6403 "view-show-track-freeze-details": 41654,
6404 "view-show-track-group-manager-window": 40327,
6405 "view-show-track-grouping-matrix-window": 40768,
6406 "view-show-track-manager-window": 40906,
6407 "view-show-track-wiring-diagram": 42031,
6408 "view-show-undo-history-window": 40072,
6409 "view-show-virtual-midi-keyboard": 40377,
6410 "view-time-unit-for-ruler-absolute-frames": 41973,
6411 "view-time-unit-for-ruler-hours-minutes-seconds-frames": 40370,
6412 "view-time-unit-for-ruler-measures-beats": 40367,
6413 "view-time-unit-for-ruler-measures-beats-minimal": 41916,
6414 "view-time-unit-for-ruler-measures-beats-minimal-minutes-seconds": 41918,
6415 "view-time-unit-for-ruler-measures-beats-minutes-seconds": 40366,
6416 "view-time-unit-for-ruler-measures-fractions": 43205,
6417 "view-time-unit-for-ruler-minutes-seconds": 40365,
6418 "view-time-unit-for-ruler-minutes-seconds-minimal": 43204,
6419 "view-time-unit-for-ruler-samples": 40369,
6420 "view-time-unit-for-ruler-seconds": 40368,
6421 "view-toggle-auto-view-scroll-during-playback": 40036,
6422 "view-toggle-auto-view-scroll-while-recording": 40262,
6423 "view-toggle-display-mouse-position-indicator-vertical-line-in-arrange-view": 43194,
6424 "view-toggle-displaying-labels-above-within-media-items": 40258,
6425 "view-toggle-master-track-in-separate-docked-window": 41609,
6426 "view-toggle-master-track-visible": 40075,
6427 "view-toggle-mixer-visible": 40078,
6428 "view-toggle-show-hide-item-labels": 40651,
6429 "view-toggle-show-hide-media-item-timebase-buttons": 43642,
6430 "view-toggle-show-hide-media-item-timebase-buttons-if-overridden-for-the-track-or-item": 43643,
6431 "view-toggle-show-media-cues-in-items": 40691,
6432 "view-toggle-show-midi-editor-windows": 40716,
6433 "view-toggle-show-tcp-area": 43185,
6434 "view-toggle-to-alternate-tcp-area-width-alternate-is-zero-by-default": 43188,
6435 "view-toggle-track-zoom-to-default-height": 42697,
6436 "view-toggle-track-zoom-to-default-height-ignore-pinned-tracks": 43678,
6437 "view-toggle-track-zoom-to-maximum-height-even-if-over-100-percent-of-arrange-view": 42700,
6438 "view-toggle-track-zoom-to-maximum-height-limit-to-100-percent-of-arrange-view": 40113,
6439 "view-toggle-track-zoom-to-minimum-height": 40110,
6440 "view-toggle-track-zoom-to-minimum-height-ignore-pinned-tracks": 43677,
6441 "view-toggle-transport-visible-play-record-stop": 40259,
6442 "view-toggle-zoom-to-selected-items": 41622,
6443 "view-zoom-horizontally-midi-cc-relative-mousewheel": 990,
6444 "view-zoom-horizontally-reversed-midi-cc-relative-mousewheel": 979,
6445 "view-zoom-in-horizontal": 1012,
6446 "view-zoom-in-vertical": 40111,
6447 "view-zoom-out-horizontal": 1011,
6448 "view-zoom-out-project": 40295,
6449 "view-zoom-out-vertical": 40112,
6450 "view-zoom-time-selection": 40031,
6451 "view-zoom-vertically-midi-cc-relative-mousewheel": 1000,
6452 "view-zoom-vertically-reversed-midi-cc-relative-mousewheel": 1001,
6453 "view-zoom-vertically-reversed-snap-to-theme-defined-sizes-midi-cc-relative-mousewheel": 980,
6454 "view-zoom-vertically-snap-to-theme-defined-sizes-midi-cc-relative-mousewheel": 991,
6455 "virtual-midi-keyboard-send-all-input-to-vkb": 40637,
6456 "xenakios-sws-apply-track-fx-to-items-and-reset-volume": 53300,
6457 "xenakios-sws-apply-track-fx-to-items-mono-and-reset-volume": 53301,
6458 "xenakios-sws-auto-rename-selected-takes": 53271,
6459 "xenakios-sws-bypass-fx-of-selected-tracks": 53328,
6460 "xenakios-sws-choose-files-for-random-insert": 53254,
6461 "xenakios-sws-choose-new-source-file-for-selected-takes": 53273,
6462 "xenakios-sws-command-parameters": 53491,
6463 "xenakios-sws-create-markers-from-selected-items-name-by-take-source-file-name": 53452,
6464 "xenakios-sws-deprecated-create-new-tracks": 53441,
6465 "xenakios-sws-deprecated-delete-active-take-of-item-and-send-source-media-to-recycle-bin": 53380,
6466 "xenakios-sws-deprecated-delete-active-take-of-item-and-take-source-media-immediately": 53379,
6467 "xenakios-sws-deprecated-delete-selected-item-and-send-active-take-s-source-media-to-recycle-bin": 53378,
6468 "xenakios-sws-deprecated-delete-selected-item-and-source-media-immediately": 53377,
6469 "xenakios-sws-deprecated-load-project-template-01": 53459,
6470 "xenakios-sws-deprecated-load-project-template-02": 53460,
6471 "xenakios-sws-deprecated-load-project-template-03": 53461,
6472 "xenakios-sws-deprecated-load-project-template-04": 53462,
6473 "xenakios-sws-deprecated-load-project-template-05": 53463,
6474 "xenakios-sws-deprecated-load-project-template-06": 53464,
6475 "xenakios-sws-deprecated-load-project-template-07": 53465,
6476 "xenakios-sws-deprecated-load-project-template-08": 53466,
6477 "xenakios-sws-deprecated-load-project-template-09": 53467,
6478 "xenakios-sws-deprecated-load-project-template-10": 53468,
6479 "xenakios-sws-deprecated-load-track-template-01": 53394,
6480 "xenakios-sws-deprecated-load-track-template-02": 53395,
6481 "xenakios-sws-deprecated-load-track-template-03": 53396,
6482 "xenakios-sws-deprecated-load-track-template-04": 53397,
6483 "xenakios-sws-deprecated-load-track-template-05": 53398,
6484 "xenakios-sws-deprecated-load-track-template-06": 53399,
6485 "xenakios-sws-deprecated-load-track-template-07": 53400,
6486 "xenakios-sws-deprecated-load-track-template-08": 53401,
6487 "xenakios-sws-deprecated-load-track-template-09": 53402,
6488 "xenakios-sws-deprecated-load-track-template-10": 53403,
6489 "xenakios-sws-deprecated-search-takes": 53356,
6490 "xenakios-sws-deprecated-toggle-stop-playback-at-end-of-loop": 53480,
6491 "xenakios-sws-disk-space-calculator": 53490,
6492 "xenakios-sws-dismantle-selected-folder": 53337,
6493 "xenakios-sws-erase-from-item-beat-based": 53355,
6494 "xenakios-sws-erase-from-item-time-based": 53354,
6495 "xenakios-sws-explode-selected-items-to-new-tracks-keeping-positions": 53393,
6496 "xenakios-sws-find-missing-media-for-project-s-takes": 53389,
6497 "xenakios-sws-give-tracks-default-label": 53408,
6498 "xenakios-sws-implode-items-to-takes-and-pan-symmetrically": 53298,
6499 "xenakios-sws-implode-selected-items-in-place": 53417,
6500 "xenakios-sws-insert-media-file-from-clipboard-deprecated": 53353,
6501 "xenakios-sws-insert-new-track-at-the-top-of-track-list": 53407,
6502 "xenakios-sws-insert-prefix-to-track-labels": 53409,
6503 "xenakios-sws-insert-random-file": 53255,
6504 "xenakios-sws-insert-random-file-at-time-selection": 53412,
6505 "xenakios-sws-insert-random-file-at-time-selection-randomize-offset": 53414,
6506 "xenakios-sws-insert-random-file-randomize-length": 53411,
6507 "xenakios-sws-insert-random-file-randomize-start-offset": 53413,
6508 "xenakios-sws-insert-shuffled-random-file": 53256,
6509 "xenakios-sws-insert-suffix-to-track-labels": 53410,
6510 "xenakios-sws-invert-item-selection": 53234,
6511 "xenakios-sws-item-property-interpolator": 53422,
6512 "xenakios-sws-jump-edit-cursor-by-random-amount-exp-distribution": 53346,
6513 "xenakios-sws-launch-external-tool-1": 53344,
6514 "xenakios-sws-launch-external-tool-2": 53345,
6515 "xenakios-sws-loop-and-play-selected-items": 53347,
6516 "xenakios-sws-maximize-selected-tracks-fx-panel-height-in-mixer": 53342,
6517 "xenakios-sws-minimize-selected-tracks-send-and-fx-panel-height-in-mixer": 53341,
6518 "xenakios-sws-minimize-selected-tracks-send-panel-height-in-mixer": 53340,
6519 "xenakios-sws-move-cursor-left-10-pixels": 53358,
6520 "xenakios-sws-move-cursor-left-10-pixels-creating-time-selection": 53359,
6521 "xenakios-sws-move-cursor-left-configured-pixels": 53369,
6522 "xenakios-sws-move-cursor-left-configured-pixels-creating-time-selection": 53371,
6523 "xenakios-sws-move-cursor-left-configured-seconds": 53373,
6524 "xenakios-sws-move-cursor-right-10-pixels": 53357,
6525 "xenakios-sws-move-cursor-right-10-pixels-creating-time-selection": 53360,
6526 "xenakios-sws-move-cursor-right-configured-pixels": 53368,
6527 "xenakios-sws-move-cursor-right-configured-pixels-creating-time-selection": 53370,
6528 "xenakios-sws-move-cursor-right-configured-seconds": 53374,
6529 "xenakios-sws-move-cursor-to-next-transient-minus-default-fade-time": 53302,
6530 "xenakios-sws-move-cursor-to-previous-transient-minus-default-fade-time": 53303,
6531 "xenakios-sws-move-edit-cursor-32nd-note-left": 53486,
6532 "xenakios-sws-move-edit-cursor-32nd-note-right": 53487,
6533 "xenakios-sws-move-edit-cursor-64th-note-left": 53488,
6534 "xenakios-sws-move-edit-cursor-64th-note-right": 53489,
6535 "xenakios-sws-move-selected-items-left-by-item-length": 53265,
6536 "xenakios-sws-move-selected-items-to-edit-cursor": 53264,
6537 "xenakios-sws-normalize-selected-takes-to-db-value": 53458,
6538 "xenakios-sws-nudge-active-take-volume-down": 53286,
6539 "xenakios-sws-nudge-active-take-volume-up": 53287,
6540 "xenakios-sws-nudge-item-contents-1-sample-left": 53433,
6541 "xenakios-sws-nudge-item-contents-1-sample-right": 53434,
6542 "xenakios-sws-nudge-item-pitch-down": 53280,
6543 "xenakios-sws-nudge-item-pitch-down-b": 53282,
6544 "xenakios-sws-nudge-item-pitch-down-resampled-a": 53277,
6545 "xenakios-sws-nudge-item-pitch-down-resampled-b": 53279,
6546 "xenakios-sws-nudge-item-pitch-up": 53281,
6547 "xenakios-sws-nudge-item-pitch-up-b": 53283,
6548 "xenakios-sws-nudge-item-pitch-up-resampled-a": 53276,
6549 "xenakios-sws-nudge-item-pitch-up-resampled-b": 53278,
6550 "xenakios-sws-nudge-item-positions-left-beat-based": 53260,
6551 "xenakios-sws-nudge-item-positions-left-time-based": 53258,
6552 "xenakios-sws-nudge-item-positions-right-beat-based": 53261,
6553 "xenakios-sws-nudge-item-positions-right-time-based": 53259,
6554 "xenakios-sws-nudge-item-volume-down": 53284,
6555 "xenakios-sws-nudge-item-volume-up": 53285,
6556 "xenakios-sws-nudge-master-volume-1-db-down": 53471,
6557 "xenakios-sws-nudge-master-volume-1-db-up": 53470,
6558 "xenakios-sws-nudge-section-loop-length-longer": 53423,
6559 "xenakios-sws-nudge-section-loop-length-shorter": 53424,
6560 "xenakios-sws-nudge-section-loop-overlap-longer": 53427,
6561 "xenakios-sws-nudge-section-loop-overlap-shorter": 53428,
6562 "xenakios-sws-nudge-section-loop-start-earlier": 53426,
6563 "xenakios-sws-nudge-section-loop-start-later": 53425,
6564 "xenakios-sws-nudge-volume-of-selected-tracks-down": 53474,
6565 "xenakios-sws-nudge-volume-of-selected-tracks-up": 53473,
6566 "xenakios-sws-open-associated-reaper-project-of-item": 53351,
6567 "xenakios-sws-open-audio-take-in-external-editor-3": 53420,
6568 "xenakios-sws-open-audio-take-in-external-editor-4": 53421,
6569 "xenakios-sws-open-reaper-project-in-item-bwav-info-autosearch-for-rpp-if-necessary": 53404,
6570 "xenakios-sws-pan-selected-tracks-randomly": 53308,
6571 "xenakios-sws-pan-selected-tracks-symmetrically-left-to-right": 53306,
6572 "xenakios-sws-pan-selected-tracks-symmetrically-right-to-left": 53307,
6573 "xenakios-sws-pan-selected-tracks-to-center": 53309,
6574 "xenakios-sws-pan-selected-tracks-to-left": 53310,
6575 "xenakios-sws-pan-selected-tracks-to-right": 53311,
6576 "xenakios-sws-pan-takes-of-item-symmetrically": 53297,
6577 "xenakios-sws-play-selected-items-once": 53348,
6578 "xenakios-sws-preview-selected-media-item": 53362,
6579 "xenakios-sws-preview-selected-media-item-at-track-fader-volume": 53364,
6580 "xenakios-sws-preview-selected-media-item-at-track-fader-volume-toggle": 53365,
6581 "xenakios-sws-preview-selected-media-item-through-track": 53366,
6582 "xenakios-sws-preview-selected-media-item-through-track-toggle": 53367,
6583 "xenakios-sws-preview-selected-media-item-toggle": 53363,
6584 "xenakios-sws-randomize-item-positions": 53257,
6585 "xenakios-sws-recall-edit-cursor-position": 53376,
6586 "xenakios-sws-recall-render-speed": 53438,
6587 "xenakios-sws-recall-selected-takes": 53252,
6588 "xenakios-sws-recall-selected-tracks-heights": 53333,
6589 "xenakios-sws-remap-item-positions": 53372,
6590 "xenakios-sws-remove-muted-items": 53469,
6591 "xenakios-sws-remove-time-selection-leave-loop-selection": 53415,
6592 "xenakios-sws-rename-project-markers-with-ascending-numbers": 53481,
6593 "xenakios-sws-rename-selected-takes-deprecated": 53270,
6594 "xenakios-sws-rename-selected-takes-with-bwav-description": 53272,
6595 "xenakios-sws-rename-selected-tracks": 53339,
6596 "xenakios-sws-rename-take-source-files-no-undo": 53453,
6597 "xenakios-sws-rename-takes": 53455,
6598 "xenakios-sws-rename-takes-and-source-files-no-undo": 53454,
6599 "xenakios-sws-rename-takes-with-same-name": 53456,
6600 "xenakios-sws-render-item-to-new-take-with-tail": 53350,
6601 "xenakios-sws-render-receives-of-selected-track-as-stems": 53442,
6602 "xenakios-sws-repeat-paste": 53253,
6603 "xenakios-sws-reposition-selected-items": 53269,
6604 "xenakios-sws-resample-pitch-shift-item-one-semitone-down": 53274,
6605 "xenakios-sws-resample-pitch-shift-item-one-semitone-up": 53275,
6606 "xenakios-sws-reset-active-take-volume-to-0-0-db": 53289,
6607 "xenakios-sws-reset-item-length-and-media-offset": 53475,
6608 "xenakios-sws-reset-item-volume-to-0-0-db": 53288,
6609 "xenakios-sws-reset-volume-and-pan-of-selected-tracks": 53304,
6610 "xenakios-sws-reverse-order-of-selected-items": 53449,
6611 "xenakios-sws-save-item-as-audio-file": 53457,
6612 "xenakios-sws-save-project-markers-as-text": 53343,
6613 "xenakios-sws-scale-item-positions-lengths-by-percentage": 53268,
6614 "xenakios-sws-scroll-track-view-down-page": 53429,
6615 "xenakios-sws-scroll-track-view-to-end": 53432,
6616 "xenakios-sws-scroll-track-view-to-home": 53431,
6617 "xenakios-sws-scroll-track-view-up-page": 53430,
6618 "xenakios-sws-select-first-items-of-selected-tracks": 53239,
6619 "xenakios-sws-select-first-of-selected-tracks": 53405,
6620 "xenakios-sws-select-first-take-in-selected-items": 53247,
6621 "xenakios-sws-select-items-to-end-of-track": 53238,
6622 "xenakios-sws-select-items-to-start-of-track": 53237,
6623 "xenakios-sws-select-items-under-edit-cursor-on-selected-tracks": 53439,
6624 "xenakios-sws-select-last-of-selected-tracks": 53406,
6625 "xenakios-sws-select-last-take-in-selected-items": 53248,
6626 "xenakios-sws-select-next-tracks": 53324,
6627 "xenakios-sws-select-next-tracks-keeping-current-selection": 53326,
6628 "xenakios-sws-select-previous-tracks": 53325,
6629 "xenakios-sws-select-previous-tracks-keeping-current-selection": 53327,
6630 "xenakios-sws-select-takes-in-selected-items-shuffled-random": 53249,
6631 "xenakios-sws-select-takes-of-selected-items-cyclically": 53250,
6632 "xenakios-sws-select-tracks-with-buss-in-name": 53335,
6633 "xenakios-sws-select-tracks-with-no-items": 53334,
6634 "xenakios-sws-set-fades-of-selected-items-to-0-0": 53294,
6635 "xenakios-sws-set-fades-of-selected-items-to-configuration-a": 53295,
6636 "xenakios-sws-set-fades-of-selected-items-to-configuration-b": 53296,
6637 "xenakios-sws-set-fades-of-selected-items-to-configuration-c": 53482,
6638 "xenakios-sws-set-fades-of-selected-items-to-configuration-d": 53483,
6639 "xenakios-sws-set-fades-of-selected-items-to-configuration-e": 53484,
6640 "xenakios-sws-set-fades-of-selected-items-to-configuration-f": 53485,
6641 "xenakios-sws-set-item-pitch-based-on-item-playrate": 53388,
6642 "xenakios-sws-set-item-playrate-based-on-item-pitch-and-reset-pitch": 53387,
6643 "xenakios-sws-set-item-rate-to-1-0-and-pitch-to-0-0": 53290,
6644 "xenakios-sws-set-master-volume-to-0-db": 53472,
6645 "xenakios-sws-set-next-fade-in-shape-for-items": 53381,
6646 "xenakios-sws-set-next-fade-out-shape-for-items": 53383,
6647 "xenakios-sws-set-previous-fade-in-shape-for-items": 53382,
6648 "xenakios-sws-set-previous-fade-out-shape-for-items": 53384,
6649 "xenakios-sws-set-render-speed-to-not-limited": 53436,
6650 "xenakios-sws-set-render-speed-to-realtime": 53435,
6651 "xenakios-sws-set-selected-track-as-reference-track": 53477,
6652 "xenakios-sws-set-selected-tracks-as-folder": 53338,
6653 "xenakios-sws-set-selected-tracks-heights-to-a": 53330,
6654 "xenakios-sws-set-selected-tracks-heights-to-b": 53331,
6655 "xenakios-sws-set-selected-tracks-record-armed": 53322,
6656 "xenakios-sws-set-selected-tracks-record-unarmed": 53323,
6657 "xenakios-sws-set-volume-and-pan-of-selected-takes": 53291,
6658 "xenakios-sws-set-volume-of-selected-items": 53292,
6659 "xenakios-sws-set-volume-of-selected-tracks-to-0-0-db": 53305,
6660 "xenakios-sws-shift-all-points-in-selected-envelope-to-left-by-1-second": 53479,
6661 "xenakios-sws-shift-all-points-in-selected-envelope-to-right-by-1-second": 53478,
6662 "xenakios-sws-show-hide-floating-item-track-info": 53390,
6663 "xenakios-sws-shuffle-order-of-selected-items": 53451,
6664 "xenakios-sws-shuffle-order-of-selected-items-keep-relative-positions": 53450,
6665 "xenakios-sws-skip-select-items-from-selected-items": 53236,
6666 "xenakios-sws-skip-select-items-in-selected-tracks": 53235,
6667 "xenakios-sws-split-items-at-transients": 53299,
6668 "xenakios-sws-spread-selected-items-over-4-tracks": 53391,
6669 "xenakios-sws-spread-selected-items-over-tracks": 53392,
6670 "xenakios-sws-stop-current-media-item-take-preview": 53361,
6671 "xenakios-sws-store-current-selected-takes": 53251,
6672 "xenakios-sws-store-edit-cursor-position": 53375,
6673 "xenakios-sws-store-render-speed": 53437,
6674 "xenakios-sws-store-selected-tracks-heights": 53332,
6675 "xenakios-sws-swing-item-positions": 53443,
6676 "xenakios-sws-switch-item-contents-to-first-cue": 53244,
6677 "xenakios-sws-switch-item-contents-to-next-cue": 53240,
6678 "xenakios-sws-switch-item-contents-to-next-cue-preserve-item-length": 53241,
6679 "xenakios-sws-switch-item-contents-to-previous-cue": 53242,
6680 "xenakios-sws-switch-item-contents-to-previous-cue-preserve-item-length": 53243,
6681 "xenakios-sws-switch-item-contents-to-random-cue": 53245,
6682 "xenakios-sws-switch-item-contents-to-random-cue-preserve-item-length": 53246,
6683 "xenakios-sws-switch-item-source-file-to-next-in-folder": 53444,
6684 "xenakios-sws-switch-item-source-file-to-next-rpp-in-folder": 53447,
6685 "xenakios-sws-switch-item-source-file-to-previous-in-folder": 53445,
6686 "xenakios-sws-switch-item-source-file-to-previous-rpp-in-folder": 53448,
6687 "xenakios-sws-switch-item-source-file-to-random-in-folder": 53446,
6688 "xenakios-sws-take-mixer": 53352,
6689 "xenakios-sws-time-selection-adaptive-delete": 53440,
6690 "xenakios-sws-toggle-reference-track": 53476,
6691 "xenakios-sws-toggle-ripple-edit-all-tracks-on-off": 53419,
6692 "xenakios-sws-toggle-ripple-edit-one-track-on-off": 53418,
6693 "xenakios-sws-toggle-selected-items-selected-randomly": 53349,
6694 "xenakios-sws-toggle-selected-takes-normalized-unity-gain": 53293,
6695 "xenakios-sws-toggle-selected-tracks-height-a-b": 53416,
6696 "xenakios-sws-trim-untrim-item-left-edge-to-edit-cursor": 53266,
6697 "xenakios-sws-trim-untrim-item-right-edge-to-edit-cursor": 53267,
6698 "xenakios-sws-unbypass-fx-of-selected-tracks": 53329,
6699 "xenakios-sws-unselect-tracks-with-buss-in-name": 53336,
6700} as const satisfies Record<string, number>;
6701
6702export type ReaperActionId = keyof typeof REAPER_ACTIONS;
control/src/Reaper/enumerate_actions.lua created+69
...@@ -0,0 +1,69 @@
1local output_path = OUTPUT_PATH or os.getenv("REAPER_ACTIONS_OUTPUT_PATH")
2if not output_path or output_path == "" then
3 error("REAPER_ACTIONS_OUTPUT_PATH is not set")
4end
5
6local section = reaper.SectionFromUniqueID(0)
7if not section then
8 error("Failed to resolve the main action section")
9end
10
11local function json_escape(value)
12 return value:gsub('[%z\1-\31\\"]', function(char)
13 if char == "\\" then
14 return "\\\\"
15 end
16 if char == "\"" then
17 return "\\\""
18 end
19 if char == "\b" then
20 return "\\b"
21 end
22 if char == "\f" then
23 return "\\f"
24 end
25 if char == "\n" then
26 return "\\n"
27 end
28 if char == "\r" then
29 return "\\r"
30 end
31 if char == "\t" then
32 return "\\t"
33 end
34
35 return string.format("\\u%04x", char:byte())
36 end)
37end
38
39local handle = assert(io.open(output_path, "wb"))
40handle:write("[\n")
41
42local first = true
43local index = 0
44
45while true do
46 local command_id, name = reaper.kbd_enumerateActions(section, index)
47 if command_id == 0 then
48 break
49 end
50
51 if name and name ~= "" then
52 if not first then
53 handle:write(",\n")
54 end
55 first = false
56 handle:write(
57 string.format(
58 ' {"commandId":%d,"name":"%s"}',
59 command_id,
60 json_escape(name)
61 )
62 )
63 end
64
65 index = index + 1
66end
67
68handle:write("\n]\n")
69handle:close()
control/src/Reaper/generate-actions.ts created+176
...@@ -0,0 +1,176 @@
1import { execFile } from "node:child_process";
2import { access, mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
3import { tmpdir } from "node:os";
4import { dirname, join, resolve } from "node:path";
5import process from "node:process";
6import { fileURLToPath } from "node:url";
7import { promisify } from "node:util";
8
9type RawReaperAction = {
10 commandId: number;
11 name: string;
12};
13
14type GeneratedReaperAction = RawReaperAction & {
15 actionId: string;
16};
17
18const DEFAULT_REAPER_BIN = "/Applications/REAPER.app/Contents/MacOS/REAPER";
19const srcDir = dirname(fileURLToPath(import.meta.url));
20const repoRoot = resolve(srcDir, "..", "..");
21const enumeratorPath = join(
22 repoRoot,
23 "config",
24 "scripts",
25 "reaper",
26 "enumerate_actions.lua",
27);
28const outputPath = join(srcDir, "actions.ts");
29const execFileAsync = promisify(execFile);
30
31async function main() {
32 const tempDir = await mkdtemp(join(tmpdir(), "reaper-actions-"));
33 const dumpPath = join(tempDir, "actions.json");
34 const runnerPath = join(tempDir, "run_enumerator.lua");
35
36 try {
37 await writeFile(runnerPath, buildRunnerScript(dumpPath), "utf8");
38 await execFileAsync(
39 process.env.REAPER_BIN ?? DEFAULT_REAPER_BIN,
40 ["-nonewinst", runnerPath],
41 );
42 await waitForFile(dumpPath);
43
44 const rawActions = parseRawActions(
45 JSON.parse(await readFile(dumpPath, "utf8")) as unknown,
46 );
47 const actions = buildActionMap(rawActions);
48 await mkdir(dirname(outputPath), { recursive: true });
49 await writeFile(outputPath, renderActionsFile(actions), "utf8");
50
51 console.info(
52 `Generated ${actions.length} REAPER actions in src/Reaper/actions.ts`,
53 );
54 } finally {
55 await rm(tempDir, { force: true, recursive: true });
56 }
57}
58
59function buildRunnerScript(dumpPath: string) {
60 return [
61 `OUTPUT_PATH = ${JSON.stringify(dumpPath)}`,
62 `dofile(${JSON.stringify(enumeratorPath)})`,
63 "",
64 ].join("\n");
65}
66
67function parseRawActions(value: unknown): RawReaperAction[] {
68 if (!Array.isArray(value)) {
69 throw new Error("Expected REAPER action dump to be an array.");
70 }
71
72 return value.flatMap((entry) => {
73 if (
74 !entry
75 || typeof entry !== "object"
76 || !("commandId" in entry)
77 || !("name" in entry)
78 ) {
79 return [];
80 }
81
82 const commandId = Number(entry.commandId);
83 const name = String(entry.name ?? "").trim();
84 if (!Number.isInteger(commandId) || name === "") {
85 return [];
86 }
87
88 return [{ commandId, name }];
89 });
90}
91
92function buildActionMap(actions: ReadonlyArray<RawReaperAction>) {
93 const usedIds = new Set<string>();
94
95 return [...actions]
96 .map((action) => ({
97 ...action,
98 actionId: createUniqueActionId(action.name, action.commandId, usedIds),
99 }))
100 .sort((left, right) => left.actionId.localeCompare(right.actionId));
101}
102
103function createUniqueActionId(
104 name: string,
105 commandId: number,
106 usedIds: Set<string>,
107) {
108 const baseId = toKebabCase(name) || `action-${commandId}`;
109 let actionId = baseId;
110 let duplicateIndex = 2;
111
112 while (usedIds.has(actionId)) {
113 actionId = `${baseId}-${commandId}`;
114 if (!usedIds.has(actionId)) {
115 break;
116 }
117 actionId = `${baseId}-${commandId}-${duplicateIndex}`;
118 duplicateIndex += 1;
119 }
120
121 usedIds.add(actionId);
122 return actionId;
123}
124
125function toKebabCase(value: string) {
126 return value
127 .normalize("NFKD")
128 .replace(/[\u0300-\u036f]/gu, "")
129 .toLowerCase()
130 .replace(/&/gu, " and ")
131 .replace(/\+/gu, " plus ")
132 .replace(/#/gu, " number ")
133 .replace(/%/gu, " percent ")
134 .replace(/[^a-z0-9]+/gu, "-")
135 .replace(/^-+|-+$/gu, "")
136 .replace(/-{2,}/gu, "-");
137}
138
139function renderActionsFile(actions: ReadonlyArray<GeneratedReaperAction>) {
140 const lines = [
141 "// Generated by src/Reaper/generate-actions.ts",
142 "// Source: REAPER main action section via kbd_enumerateActions()/kbd_getTextFromCmd().",
143 "",
144 "export const REAPER_ACTIONS = {",
145 ...actions.map((action) => ` ${JSON.stringify(action.actionId)}: ${action.commandId},`),
146 "} as const satisfies Record<string, number>;",
147 "",
148 "export type ReaperActionId = keyof typeof REAPER_ACTIONS;",
149 "",
150 ];
151
152 return lines.join("\n");
153}
154
155async function waitForFile(filePath: string, timeoutMs = 30_000) {
156 const startedAt = Date.now();
157
158 while (Date.now() - startedAt < timeoutMs) {
159 try {
160 await access(filePath);
161 return;
162 } catch {}
163
164 await sleep(200);
165 }
166
167 throw new Error(`Timed out waiting for ${filePath}`);
168}
169
170function sleep(ms: number) {
171 return new Promise<void>((resolve) => {
172 setTimeout(resolve, ms);
173 });
174}
175
176await main();
control/src/SpeedEditor.ts created+994
...@@ -0,0 +1,994 @@
1import { Events } from "@clo/lib/Events.ts";
2import * as log from "@clo/lib/log.ts";
3import { defer } from "@clo/lib/ts.ts";
4import type { Dispose, Timer } from "@clo/lib/ts.ts";
5import type { Mac } from "./Mac";
6
7const SPEED_EDITOR_VENDOR_ID = 0x1edb;
8const SPEED_EDITOR_PRODUCT_ID = 0xda0e;
9
10const keyIds = [
11 "smartInsert",
12 "append",
13 "rippleOverwrite",
14 "closeUp",
15 "placeOnTop",
16 "sourceOverwrite",
17 "in",
18 "out",
19 "trimIn",
20 "trimOut",
21 "roll",
22 "slipSource",
23 "slipDestination",
24 "transitionDuration",
25 "cut",
26 "dissolve",
27 "smoothCut",
28 "source",
29 "timeline",
30 "shuttle",
31 "jog",
32 "scroll",
33 "escape",
34 "syncBin",
35 "audioLevel",
36 "fullView",
37 "transition",
38 "split",
39 "snap",
40 "rippleDelete",
41 "cam1",
42 "cam2",
43 "cam3",
44 "cam4",
45 "cam5",
46 "cam6",
47 "cam7",
48 "cam8",
49 "cam9",
50 "liveOverwrite",
51 "videoOnly",
52 "audioOnly",
53 "stopPlay",
54] as const;
55
56const ledIds = [
57 "closeUp",
58 "cut",
59 "dissolve",
60 "smoothCut",
61 "transition",
62 "snap",
63 "cam7",
64 "cam8",
65 "cam9",
66 "liveOverwrite",
67 "cam4",
68 "cam5",
69 "cam6",
70 "videoOnly",
71 "cam1",
72 "cam2",
73 "cam3",
74 "audioOnly",
75] as const;
76
77const jogLeds = ["jog", "shuttle", "scroll"] as const;
78
79const jogModes = [
80 "relative",
81 "relative-normalized",
82 "absolute-continuous",
83 "absolute-deadzero",
84] as const;
85
86/**
87 * Node.js Bindings for DaVinci Resolve Speed Editor.
88 */
89export class SpeedEditor extends Events<SpeedEditor.EventMap> {
90 static keys = keyIds;
91 static leds = ledIds;
92 static jogLeds = jogLeds;
93 static jogModes = jogModes;
94
95 #options: Required<SpeedEditor.Options>;
96 #device: import("node-hid").HID | null = null;
97 #deviceInfo: SpeedEditor.DeviceInfo | null = null;
98 #ready = false;
99 #closed = false;
100 #stopHotplugMonitoring: (() => void) | null = null;
101 #refreshing: Promise<void> | null = null;
102 #refreshRequested = false;
103 #leds = new Set<SpeedEditor.Led>();
104 #jogLeds = new Set<SpeedEditor.JogLed>();
105 #jogMode: SpeedEditor.JogMode = "relative";
106 #normalizedJogActive = false;
107 #normalizedJogBufferedDistance = 0;
108 #normalizedJogBufferedValue = 0;
109 #normalizedJogResetTimer: Timer | null = null;
110 #activeKeys = new Set<SpeedEditor.Key>();
111 #pendingKeypressTimers = new Map<SpeedEditor.Key, Timer>();
112 #doublePressActiveKeys = new Set<SpeedEditor.Key>();
113 #doublePressListeners = new Map<SpeedEditor.Key, Set<() => void>>();
114
115 private constructor(options: SpeedEditor.Options = {}) {
116 super();
117 this.#options = {
118 vendorId: options.vendorId ?? SPEED_EDITOR_VENDOR_ID,
119 productId: options.productId ?? SPEED_EDITOR_PRODUCT_ID,
120 path: options.path ?? null,
121 nonExclusive: options.nonExclusive ?? false,
122 };
123 }
124
125 static async open(options: SpeedEditor.Options = {}) {
126 const editor = new SpeedEditor(options);
127 await editor.#start();
128 return editor;
129 }
130
131 static async listDevices(
132 options: Pick<SpeedEditor.Options, "vendorId" | "productId"> = {},
133 ): Promise<SpeedEditor.DeviceInfo[]> {
134 const { devices } = await import("node-hid");
135 const vendorId = options.vendorId ?? SPEED_EDITOR_VENDOR_ID;
136 const productId = options.productId ?? SPEED_EDITOR_PRODUCT_ID;
137
138 return devices(vendorId, productId).map((device) => ({ ...device }));
139 }
140
141 #authenticate(device: import("node-hid").HID): number {
142 return authenticateDevice(device);
143 }
144
145 setLeds(states: Iterable<SpeedEditor.Led>) {
146 this.#leds = new Set(states);
147 if (this.#device) {
148 writeLedState(this.#device, this.#leds);
149 }
150 }
151
152 setJogLeds(states: Iterable<SpeedEditor.JogLed>) {
153 this.#jogLeds = new Set(states);
154 if (this.#device) {
155 writeJogLedState(this.#device, this.#jogLeds);
156 }
157 }
158
159 setJogMode(mode: SpeedEditor.JogMode) {
160 if (mode !== this.#jogMode) {
161 this.#resetNormalizedJogTracking();
162 }
163 this.#jogMode = mode;
164 if (this.#device) {
165 writeJogMode(this.#device, mode);
166 }
167 }
168
169 leds = new Proxy({} as Record<SpeedEditor.Led, boolean>, {
170 get: (_, key) => {
171 return this.#leds.has(key as SpeedEditor.Led);
172 },
173 set: (_, key, value) => {
174 if (value) this.#leds.add(key as SpeedEditor.Led);
175 else this.#leds.delete(key as SpeedEditor.Led);
176 this.setLeds(Array.from(this.#leds));
177 return true;
178 },
179 });
180
181 jogLeds = new Proxy({} as Record<SpeedEditor.JogLed, boolean>, {
182 get: (_, key) => {
183 return this.#jogLeds.has(key as SpeedEditor.JogLed);
184 },
185 set: (_, key, value) => {
186 return true;
187 },
188 });
189
190 keys = new Proxy({} as Record<SpeedEditor.Key, boolean>, {
191 get: (_, key) => {
192 return this.#activeKeys.has(key as SpeedEditor.Key);
193 },
194 set: (_, key, value) => {
195 return true;
196 },
197 });
198
199 get jogMode(): SpeedEditor.JogMode {
200 return this.#jogMode;
201 }
202
203 get connected(): boolean {
204 return this.#ready;
205 }
206
207 get deviceInfo(): SpeedEditor.DeviceInfo | null {
208 return this.#deviceInfo && { ...this.#deviceInfo };
209 }
210
211 onPress(key: SpeedEditor.Key, listener: () => void): Dispose {
212 return this.on("keypress", (code) => {
213 if (key === code) listener();
214 });
215 }
216
217 onDoublePress(key: SpeedEditor.Key, listener: () => void): Dispose {
218 const listeners = this.#doublePressListeners.get(key)
219 ?? new Set<() => void>();
220 listeners.add(listener);
221 this.#doublePressListeners.set(key, listeners);
222
223 return defer(() => {
224 const current = this.#doublePressListeners.get(key);
225 if (!current) return;
226 current.delete(listener);
227 if (current.size === 0) {
228 this.#doublePressListeners.delete(key);
229 }
230 });
231 }
232
233 close() {
234 if (this.#closed) return;
235 this.#closed = true;
236 this.#ready = false;
237 this.#refreshRequested = false;
238 this.#stopHotplugMonitoring?.();
239 this.#stopHotplugMonitoring = null;
240 this.#disconnect(false);
241 this.emit("close");
242 }
243
244 async #start() {
245 await this.#startHotplugMonitoring();
246 await this.#refreshConnection();
247 }
248
249 async #startHotplugMonitoring() {
250 const { usb } = await import("usb");
251 const onHotplug = (device: import("usb").Device) => {
252 if (!this.#matchesHotplugDevice(device)) {
253 return;
254 }
255 void this.#refreshConnection();
256 };
257
258 usb.on("attach", onHotplug);
259 usb.on("detach", onHotplug);
260 usb.unrefHotplugEvents();
261
262 this.#stopHotplugMonitoring = () => {
263 usb.off("attach", onHotplug);
264 usb.off("detach", onHotplug);
265 };
266 }
267
268 #matchesHotplugDevice(device: import("usb").Device) {
269 const descriptor = device.deviceDescriptor;
270 return descriptor.idVendor === this.#options.vendorId
271 && descriptor.idProduct === this.#options.productId;
272 }
273
274 async #refreshConnection() {
275 if (this.#closed) {
276 return;
277 }
278
279 this.#refreshRequested = true;
280 if (this.#refreshing) {
281 await this.#refreshing;
282 return;
283 }
284
285 this.#refreshing = (async () => {
286 while (this.#refreshRequested && !this.#closed) {
287 this.#refreshRequested = false;
288
289 try {
290 const devices = await SpeedEditor.listDevices({
291 vendorId: this.#options.vendorId,
292 productId: this.#options.productId,
293 });
294 await this.#reconcileConnection(devices);
295 } catch (error) {
296 this.#emitAsyncError(error);
297 }
298 }
299 })().finally(() => {
300 this.#refreshing = null;
301 });
302
303 await this.#refreshing;
304 }
305
306 async #reconcileConnection(devices: ReadonlyArray<SpeedEditor.DeviceInfo>) {
307 if (this.#closed) {
308 return;
309 }
310
311 const currentKey = getDeviceKey(this.#deviceInfo);
312 const currentStillPresent = currentKey !== null
313 && devices.some((device) => getDeviceKey(device) === currentKey);
314
315 if (this.#device && !currentStillPresent) {
316 this.#disconnect(true);
317 }
318
319 if (this.#device) {
320 return;
321 }
322
323 const nextDevice = this.#selectDevice(devices);
324 if (!nextDevice) {
325 return;
326 }
327
328 await this.#connect(nextDevice);
329 }
330
331 #selectDevice(devices: ReadonlyArray<SpeedEditor.DeviceInfo>) {
332 if (this.#options.path) {
333 return devices.find((device) => device.path === this.#options.path)
334 ?? null;
335 }
336
337 return devices[0] ?? null;
338 }
339
340 async #connect(
341 deviceInfo: SpeedEditor.DeviceInfo,
342 ): Promise<SpeedEditor.DeviceInfo | null> {
343 const { HID } = await import("node-hid");
344 let device: import("node-hid").HID | null = null;
345
346 try {
347 device = this.#options.path
348 ? new HID(this.#options.path, {
349 nonExclusive: this.#options.nonExclusive,
350 })
351 : new HID(deviceInfo.vendorId, deviceInfo.productId, {
352 nonExclusive: this.#options.nonExclusive,
353 });
354
355 this.#device = device;
356 this.#authenticate(device);
357 if (this.#closed || this.#device !== device) {
358 closeDeviceHandle(device);
359 return null;
360 }
361
362 device.on("data", (report) => {
363 if (this.#device === device) {
364 this.#handleReport(report);
365 }
366 });
367 device.on("error", (error) => {
368 if (this.#device === device) {
369 this.#handleDeviceError(error);
370 }
371 });
372
373 writeLedState(device, this.#leds);
374 writeJogLedState(device, this.#jogLeds);
375 writeJogMode(device, this.#jogMode);
376
377 const info = copyDeviceInfo(device.getDeviceInfo());
378 this.#deviceInfo = info;
379 this.#ready = true;
380 this.emit("connect", info);
381 return info;
382 } catch (error) {
383 if (this.#device === device) {
384 this.#disconnect(false);
385 } else if (device) {
386 closeDeviceHandle(device);
387 }
388
389 if (!isRecoverableDeviceError(error)) {
390 this.#emitAsyncError(error);
391 }
392 return null;
393 }
394 }
395
396 #handleReport(report: number[] | Buffer) {
397 const bytes = Uint8Array.from(report);
398 const reportId = bytes[0];
399
400 if (reportId === 0x03) {
401 const modeCode = bytes[1];
402 const mode = JOG_MODE_BY_CODE.get(modeCode);
403 if (!mode) return;
404 const rawValue = getInt32LE(bytes, 2);
405 const value = mode === "relative" ? rawValue / 360 : rawValue;
406 if (mode === "relative" && this.#jogMode === "relative-normalized") {
407 this.#handleNormalizedRelativeJog(value);
408 return;
409 }
410 this.emit("jog", { mode, value });
411 return;
412 }
413
414 if (reportId === 0x04) {
415 const keys: SpeedEditor.Key[] = [];
416 for (let index = 0; index < 6; index += 1) {
417 const code = getUint16LE(bytes, 1 + index * 2);
418 if (code === 0) {
419 continue;
420 }
421 const key = KEY_BY_CODE.get(code);
422 if (key) keys.push(key);
423 }
424 this.#applyKeyState(keys);
425 this.emit("key", keys);
426 return;
427 }
428
429 if (reportId === 0x07) {
430 const percent = bytes[2] ?? 0;
431 this.emit("battery", {
432 charging: (bytes[1] ?? 0) === 1,
433 level: Math.max(0, Math.min(percent, 100)) / 100,
434 percent,
435 });
436 return;
437 }
438 }
439
440 #handleDeviceError(error: unknown) {
441 this.#disconnect(true);
442 if (!isRecoverableDeviceError(error)) {
443 this.#emitAsyncError(error);
444 }
445 void this.#refreshConnection();
446 }
447
448 #emitAsyncError(error: unknown) {
449 queueMicrotask(() => {
450 this.emit("error", error);
451 });
452 }
453
454 #disconnect(emitEvent: boolean) {
455 const device = this.#device;
456 const info = this.#deviceInfo && { ...this.#deviceInfo };
457
458 this.#device = null;
459 this.#deviceInfo = null;
460 this.#ready = false;
461 if (device) {
462 closeDeviceHandle(device);
463 }
464 this.#resetNormalizedJogTracking();
465 this.#resetKeyTracking();
466
467 if (emitEvent && info) {
468 this.emit("disconnect", info);
469 }
470 }
471
472 #handleNormalizedRelativeJog(value: number) {
473 if (value === 0) {
474 return;
475 }
476
477 if (this.#normalizedJogActive) {
478 this.#scheduleNormalizedJogReset();
479 this.emit("jog", { mode: "relative-normalized", value });
480 return;
481 }
482
483 if (!this.#normalizedJogResetTimer) {
484 this.#scheduleNormalizedJogReset();
485 }
486 this.#normalizedJogBufferedValue += value;
487 this.#normalizedJogBufferedDistance += Math.abs(value);
488 if (this.#normalizedJogBufferedDistance < NORMALIZED_JOG_THRESHOLD) {
489 return;
490 }
491
492 this.#normalizedJogActive = true;
493 const bufferedValue = this.#normalizedJogBufferedValue;
494 this.#normalizedJogBufferedValue = 0;
495 this.#normalizedJogBufferedDistance = 0;
496 this.#scheduleNormalizedJogReset();
497 this.emit("jog", { mode: "relative-normalized", value: bufferedValue });
498 }
499
500 #scheduleNormalizedJogReset() {
501 if (this.#normalizedJogResetTimer) {
502 clearTimeout(this.#normalizedJogResetTimer);
503 }
504
505 this.#normalizedJogResetTimer = setTimeout(() => {
506 this.#normalizedJogResetTimer = null;
507 this.#normalizedJogActive = false;
508 this.#normalizedJogBufferedDistance = 0;
509 this.#normalizedJogBufferedValue = 0;
510 }, NORMALIZED_JOG_IDLE_RESET_MS);
511 }
512
513 #resetNormalizedJogTracking() {
514 if (this.#normalizedJogResetTimer) {
515 clearTimeout(this.#normalizedJogResetTimer);
516 this.#normalizedJogResetTimer = null;
517 }
518 this.#normalizedJogActive = false;
519 this.#normalizedJogBufferedDistance = 0;
520 this.#normalizedJogBufferedValue = 0;
521 }
522
523 #applyKeyState(keys: ReadonlyArray<SpeedEditor.Key>) {
524 const nextKeys = new Set(keys);
525 const releasedKeys: SpeedEditor.Key[] = [];
526 const pressedKeys: SpeedEditor.Key[] = [];
527
528 for (const key of this.#activeKeys) {
529 if (!nextKeys.has(key)) {
530 releasedKeys.push(key);
531 }
532 }
533
534 for (const key of nextKeys) {
535 if (!this.#activeKeys.has(key)) {
536 pressedKeys.push(key);
537 }
538 }
539
540 this.#activeKeys = nextKeys;
541
542 for (const key of releasedKeys) {
543 this.emit("keyup", key);
544 this.#handleKeyRelease(key);
545 }
546
547 for (const key of pressedKeys) {
548 this.emit("keydown", key);
549 this.#handleKeyPressStart(key);
550 }
551 }
552
553 #handleKeyPressStart(key: SpeedEditor.Key) {
554 if (!this.#isDoublePressTracked(key)) {
555 this.emit("keypress", key);
556 return;
557 }
558
559 const pendingKeypress = this.#pendingKeypressTimers.get(key);
560 if (!pendingKeypress) {
561 return;
562 }
563
564 clearTimeout(pendingKeypress);
565 this.#pendingKeypressTimers.delete(key);
566 this.#doublePressActiveKeys.add(key);
567
568 const listeners = this.#doublePressListeners.get(key);
569 if (!listeners) {
570 return;
571 }
572 for (const listener of listeners) {
573 listener();
574 }
575 }
576
577 #handleKeyRelease(key: SpeedEditor.Key) {
578 if (!this.#isDoublePressTracked(key)) {
579 return;
580 }
581
582 if (this.#doublePressActiveKeys.delete(key)) {
583 return;
584 }
585
586 const pendingKeypress = this.#pendingKeypressTimers.get(key);
587 if (pendingKeypress) {
588 clearTimeout(pendingKeypress);
589 }
590
591 this.#pendingKeypressTimers.set(
592 key,
593 setTimeout(() => {
594 this.#pendingKeypressTimers.delete(key);
595 this.emit("keypress", key);
596 }, DOUBLE_PRESS_WINDOW_MS),
597 );
598 }
599
600 #isDoublePressTracked(key: SpeedEditor.Key) {
601 return (this.#doublePressListeners.get(key)?.size ?? 0) > 0;
602 }
603
604 #resetKeyTracking() {
605 for (const timer of this.#pendingKeypressTimers.values()) {
606 clearTimeout(timer);
607 }
608 this.#pendingKeypressTimers.clear();
609 this.#doublePressActiveKeys.clear();
610 this.#activeKeys.clear();
611 }
612
613 static camNumbersToNumpad(
614 speededitor: Pick<SpeedEditor, "onPress">,
615 mac: Pick<Mac, "pressKey">,
616 ) {
617 speededitor.onPress("cam1", () => mac.pressKey("numpad1"));
618 speededitor.onPress("cam2", () => mac.pressKey("numpad2"));
619 speededitor.onPress("cam3", () => mac.pressKey("numpad3"));
620 speededitor.onPress("cam4", () => mac.pressKey("numpad4"));
621 speededitor.onPress("cam5", () => mac.pressKey("numpad5"));
622 speededitor.onPress("cam6", () => mac.pressKey("numpad6"));
623 speededitor.onPress("cam7", () => mac.pressKey("numpad7"));
624 speededitor.onPress("cam8", () => mac.pressKey("numpad8"));
625 speededitor.onPress("cam9", () => mac.pressKey("numpad9"));
626 speededitor.onPress("liveOverwrite", () => mac.pressKey("numpad0"));
627 }
628}
629
630export declare namespace SpeedEditor {
631 export type JogMode = typeof jogModes[number];
632 export type Key = typeof keyIds[number];
633 export type Led = typeof ledIds[number];
634 export type JogLed = typeof jogLeds[number];
635 export type JogKey = Extract<Key, JogLed>;
636 export type DeviceInfo = import("node-hid").Device;
637
638 export interface Options {
639 vendorId?: number;
640 productId?: number;
641 path?: string | null;
642 nonExclusive?: boolean;
643 }
644
645 export type EventMap = {
646 "close": [];
647 "connect": [deviceInfo: DeviceInfo];
648 "disconnect": [deviceInfo: DeviceInfo];
649 "error": [error: unknown];
650 "jog": [event: { mode: JogMode; value?: number }];
651 "key": [activeKeys: ReadonlyArray<Key>];
652 "keydown": [key: Key];
653 "keyup": [key: Key];
654 "keypress": [key: Key];
655 "battery": [event: {
656 charging: boolean;
657 /** Zero to one. */
658 level: number;
659 /** Zero to one hundred. */
660 percent: number;
661 }];
662 };
663}
664
665const UINT64_MASK = 0xffff_ffff_ffff_ffffn;
666const AUTH_MASK = 0xa79a63f585d37bf0n;
667const AUTH_EVEN_TABLE = [
668 0x3ae1206f97c10bc8n,
669 0x2a9ab32bebf244c6n,
670 0x20a6f8b8df9adf0an,
671 0xaf80ece52cfc1719n,
672 0xec2ee2f7414fd151n,
673 0xb055adfd73344a15n,
674 0xa63d2e3059001187n,
675 0x751bf623f42e0dden,
676] as const;
677const AUTH_ODD_TABLE = [
678 0x3e22b34f502e7fden,
679 0x24656b981875ab1cn,
680 0xa17f3456df7bf8c3n,
681 0x6df72e1941aef698n,
682 0x72226f011e66ab94n,
683 0x3831a3c606296b42n,
684 0xfd7ff81881332c89n,
685 0x61a3f6474ff236c6n,
686] as const;
687
688const KEY_BY_CODE = new Map<number, SpeedEditor.Key>([
689 [0x01, "smartInsert"],
690 [0x02, "append"],
691 [0x03, "rippleOverwrite"],
692 [0x04, "closeUp"],
693 [0x05, "placeOnTop"],
694 [0x06, "sourceOverwrite"],
695 [0x07, "in"],
696 [0x08, "out"],
697 [0x09, "trimIn"],
698 [0x0a, "trimOut"],
699 [0x0b, "roll"],
700 [0x0c, "slipSource"],
701 [0x0d, "slipDestination"],
702 [0x0e, "transitionDuration"],
703 [0x0f, "cut"],
704 [0x10, "dissolve"],
705 [0x11, "smoothCut"],
706 [0x1a, "source"],
707 [0x1b, "timeline"],
708 [0x1c, "shuttle"],
709 [0x1d, "jog"],
710 [0x1e, "scroll"],
711 [0x1f, "syncBin"],
712 [0x22, "transition"],
713 [0x25, "videoOnly"],
714 [0x26, "audioOnly"],
715 [0x2b, "rippleDelete"],
716 [0x2c, "audioLevel"],
717 [0x2d, "fullView"],
718 [0x2e, "snap"],
719 [0x2f, "split"],
720 [0x30, "liveOverwrite"],
721 [0x31, "escape"],
722 [0x33, "cam1"],
723 [0x34, "cam2"],
724 [0x35, "cam3"],
725 [0x36, "cam4"],
726 [0x37, "cam5"],
727 [0x38, "cam6"],
728 [0x39, "cam7"],
729 [0x3a, "cam8"],
730 [0x3b, "cam9"],
731 [0x3c, "stopPlay"],
732]);
733
734const LED_BIT_BY_LED = new Map<SpeedEditor.Led, number>([
735 ["closeUp", 1 << 0],
736 ["cut", 1 << 1],
737 ["dissolve", 1 << 2],
738 ["smoothCut", 1 << 3],
739 ["transition", 1 << 4],
740 ["snap", 1 << 5],
741 ["cam7", 1 << 6],
742 ["cam8", 1 << 7],
743 ["cam9", 1 << 8],
744 ["liveOverwrite", 1 << 9],
745 ["cam4", 1 << 10],
746 ["cam5", 1 << 11],
747 ["cam6", 1 << 12],
748 ["videoOnly", 1 << 13],
749 ["cam1", 1 << 14],
750 ["cam2", 1 << 15],
751 ["cam3", 1 << 16],
752 ["audioOnly", 1 << 17],
753]);
754
755const JOG_LED_BIT_BY_LED = new Map<SpeedEditor.JogLed, number>([
756 ["jog", 1 << 0],
757 ["shuttle", 1 << 1],
758 ["scroll", 1 << 2],
759]);
760
761const JOG_MODE_CODE_BY_MODE = new Map<SpeedEditor.JogMode, number>([
762 ["relative", 2],
763 ["relative-normalized", 2],
764 ["absolute-continuous", 1],
765 ["absolute-deadzero", 3],
766]);
767
768const JOG_MODE_BY_CODE = new Map<number, SpeedEditor.JogMode>([
769 [0, "relative"],
770 [1, "absolute-continuous"],
771 [2, "relative"],
772 [3, "absolute-deadzero"],
773]);
774
775const DOUBLE_PRESS_WINDOW_MS = 100;
776const NORMALIZED_JOG_THRESHOLD = 5;
777const NORMALIZED_JOG_IDLE_RESET_MS = 1_000;
778
779function authenticateDevice(device: import("node-hid").HID) {
780 sendFeatureReport(device, [
781 0x06,
782 0x00,
783 0x00,
784 0x00,
785 0x00,
786 0x00,
787 0x00,
788 0x00,
789 0x00,
790 0x00,
791 ]);
792
793 const challengeReport = getFeatureReport(device, 6, 10);
794 assertFeatureStage(challengeReport, 0x00, "get_kbd_challenge");
795 const challenge = getUint64LE(challengeReport, 2);
796
797 sendFeatureReport(device, [
798 0x06,
799 0x01,
800 0x00,
801 0x00,
802 0x00,
803 0x00,
804 0x00,
805 0x00,
806 0x00,
807 0x00,
808 ]);
809
810 const responseReport = getFeatureReport(device, 6, 10);
811 assertFeatureStage(responseReport, 0x02, "get_kbd_response");
812
813 sendFeatureReport(device, [
814 0x06,
815 0x03,
816 ...toLittleEndianBytes(bmdKeyboardAuth(challenge), 8),
817 ]);
818
819 const statusReport = getFeatureReport(device, 6, 10);
820 assertFeatureStage(statusReport, 0x04, "get_kbd_status");
821
822 return getUint16LE(statusReport, 2);
823}
824
825function sendFeatureReport(device: import("node-hid").HID, values: number[]) {
826 const written = device.sendFeatureReport(values);
827 if (written <= 0) {
828 throw new Error("Failed to send Speed Editor feature report");
829 }
830}
831
832function getFeatureReport(
833 device: import("node-hid").HID,
834 reportId: number,
835 length: number,
836) {
837 return Uint8Array.from(device.getFeatureReport(reportId, length));
838}
839
840function assertFeatureStage(report: Uint8Array, stage: number, name: string) {
841 if (report[0] !== 0x06 || report[1] !== stage) {
842 throw new Error(`Failed authentication ${name}`);
843 }
844}
845
846function writeLedState(
847 device: import("node-hid").HID,
848 leds: Iterable<SpeedEditor.Led>,
849) {
850 let bitfield = 0;
851 for (const led of leds) {
852 bitfield |= LED_BIT_BY_LED.get(led) ?? 0;
853 }
854 const bytes = [0x02, ...toLittleEndianBytes(BigInt(bitfield >>> 0), 4)];
855 device.write(bytes);
856}
857
858function writeJogLedState(
859 device: import("node-hid").HID,
860 leds: Iterable<SpeedEditor.JogLed>,
861) {
862 let bitfield = 0;
863 for (const led of leds) {
864 bitfield |= JOG_LED_BIT_BY_LED.get(led) ?? 0;
865 }
866 device.write([0x04, bitfield]);
867}
868
869function writeJogMode(
870 device: import("node-hid").HID,
871 mode: SpeedEditor.JogMode,
872) {
873 const code = JOG_MODE_CODE_BY_MODE.get(mode);
874 if (code === undefined) {
875 throw new Error(`Unsupported jog mode: ${mode}`);
876 }
877 device.write([0x03, code, 0x00, 0x00, 0x00, 0x00, 0xff]);
878}
879
880function copyDeviceInfo(
881 device: import("node-hid").Device,
882): SpeedEditor.DeviceInfo {
883 return { ...device };
884}
885
886function formatDeviceLogLabel(device: SpeedEditor.DeviceInfo) {
887 const name = device.product ?? device.manufacturer ?? "Speed Editor";
888 const details = [
889 device.serialNumber && `serial=${device.serialNumber}`,
890 device.path && `path=${device.path}`,
891 ].filter(Boolean);
892
893 if (details.length === 0) {
894 return name;
895 }
896
897 return `${name} (${details.join(", ")})`;
898}
899
900function closeDeviceHandle(device: import("node-hid").HID) {
901 device.removeAllListeners("data");
902 device.removeAllListeners("error");
903 try {
904 device.close();
905 } catch {
906 // Ignore close races when the device disappears while reconnecting.
907 }
908}
909
910function getDeviceKey(device: SpeedEditor.DeviceInfo | null) {
911 if (!device) {
912 return null;
913 }
914
915 return device.path
916 ?? [
917 device.vendorId,
918 device.productId,
919 device.serialNumber ?? "",
920 device.interface,
921 device.release,
922 device.usagePage ?? "",
923 device.usage ?? "",
924 ].join(":");
925}
926
927function isRecoverableDeviceError(error: unknown) {
928 if (!(error instanceof Error)) {
929 return false;
930 }
931
932 const message = error.message.toLowerCase();
933 return [
934 "cannot open device",
935 "cannot access closed device",
936 "cannot write to closed device",
937 "cannot write to hid device",
938 "could not read data from device",
939 "could not get feature report from device",
940 "could not send feature report to device",
941 "unable to get device info",
942 "device not found",
943 "no such device",
944 ].some((pattern) => message.includes(pattern));
945}
946
947function rol8(value: bigint) {
948 return ((value << 56n) | (value >> 8n)) & UINT64_MASK;
949}
950
951function rol8n(value: bigint, count: bigint) {
952 let next = value;
953 for (let index = 0n; index < count; index += 1n) {
954 next = rol8(next);
955 }
956 return next;
957}
958
959function bmdKeyboardAuth(challenge: bigint) {
960 const index = Number(challenge & 0x7n);
961 let value = rol8n(challenge, BigInt(index));
962
963 if ((value & 0x1n) === BigInt((0x78 >> index) & 0x1)) {
964 return value ^ (rol8(value) & AUTH_MASK) ^ AUTH_EVEN_TABLE[index];
965 }
966
967 value = value ^ rol8(value);
968 return value ^ (rol8(value) & AUTH_MASK) ^ AUTH_ODD_TABLE[index];
969}
970
971function getUint16LE(bytes: Uint8Array, offset: number) {
972 return (bytes[offset] ?? 0) | ((bytes[offset + 1] ?? 0) << 8);
973}
974
975function getInt32LE(bytes: Uint8Array, offset: number) {
976 return new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength)
977 .getInt32(offset, true);
978}
979
980function getUint64LE(bytes: Uint8Array, offset: number) {
981 let value = 0n;
982 for (let index = 0; index < 8; index += 1) {
983 value |= BigInt(bytes[offset + index] ?? 0) << (BigInt(index) * 8n);
984 }
985 return value;
986}
987
988function toLittleEndianBytes(value: bigint, width: number) {
989 const bytes: number[] = [];
990 for (let index = 0; index < width; index += 1) {
991 bytes.push(Number((value >> (BigInt(index) * 8n)) & 0xffn));
992 }
993 return bytes;
994}
control/src/config.ts created+224
...@@ -0,0 +1,224 @@
1import { Events } from "@clo/lib/Events.ts";
2import * as console from "@clo/lib/log.ts";
3import type { Dispose } from "@clo/lib/ts.ts";
4import * as fs from "node:fs/promises";
5import * as path from "node:path";
6import * as url from "node:url";
7import { Dialpad } from "./Dialpad.ts";
8import { Keypad } from "./Keypad.ts";
9import { KeypadSurface } from "./KeypadUI.ts";
10import { Mac } from "./Mac.ts";
11
12export interface Configure {
13 /** 9 LCD keys + back/forward. Events/screens are only active when the app is focused. */
14 keypad: KeypadSurface;
15 /** One dial, one knob, four buttons. Events are only active when the app is focused. */
16 dialpad: AppDialpad;
17 /** Not intended to listen for events. */
18 mac: Mac;
19 /** Control the application itself */
20 app: Events<AppEvents>;
21}
22
23export interface AppConfig {
24 bundle: Mac.BundleId;
25 configure: (x: Configure) => void;
26}
27
28interface AppInstance {
29 bundle: Mac.BundleId;
30 keypad: KeypadSurface;
31 dialpad: AppDialpad;
32 app: Events<AppEvents>;
33}
34
35interface AppDefinition {
36 bundle: Mac.BundleId;
37 load: () => Promise<AppConfig>;
38}
39
40export type AppEvents = {
41 "focus": [];
42 "blur": [];
43};
44
45export function forApp(
46 bundle: Mac.BundleId,
47 configure: AppConfig["configure"],
48): AppConfig {
49 return { bundle, configure };
50}
51
52/** Per-app proxy that only forwards Dialpad events while its app is focused. */
53export class AppDialpad extends Events<Dialpad.EventMap> {
54 #source: Dialpad;
55 #enabled = false;
56
57 constructor(source: Dialpad) {
58 super();
59 this.#source = source;
60 source.onAny((event, args) => {
61 if (this.#enabled) {
62 this.emit(
63 event,
64 ...args as Dialpad.EventMap[keyof Dialpad.EventMap],
65 );
66 }
67 });
68 }
69
70 get enabled() {
71 return this.#enabled;
72 }
73
74 set enabled(enabled: boolean) {
75 this.#enabled = enabled;
76 }
77
78 onPress(button: Dialpad.Button, listener: () => void): Dispose {
79 return this.on("keypress", (code) => {
80 if (button === code) listener();
81 });
82 }
83}
84
85async function loadConfigs(configDir: string) {
86 const entries = await fs.readdir(configDir, { withFileTypes: true });
87 const modules = entries
88 .filter((entry) => entry.isFile())
89 .filter((entry) => path.extname(entry.name) === ".ts")
90 .sort((left, right) => left.name.localeCompare(right.name));
91
92 const definitions: AppDefinition[] = [];
93
94 for (const entry of modules) {
95 const filePath = path.join(configDir, entry.name);
96 const moduleUrl = url.pathToFileURL(filePath).href;
97 let configPromise: Promise<AppConfig> | null = null;
98 const load = () => {
99 configPromise ??= loadConfig(moduleUrl);
100 return configPromise;
101 };
102
103 const source = await fs.readFile(filePath, "utf8");
104 const bundle = readBundleId(source) ?? (await load()).bundle;
105 definitions.push({ bundle, load });
106 }
107
108 return definitions;
109}
110
111export async function runConfigs(dir: string) {
112 const definitions = await loadConfigs(dir);
113
114 const [mac, keypad, dialpad] = await Promise.all([
115 Mac.open(),
116 Keypad.open(),
117 Dialpad.open(),
118 ]);
119
120 if (!keypad.connected) {
121 console.warn("MX Creative Keypad is not connected (connect it over USB-C)");
122 }
123 keypad.on("connect", () => console.info("Keypad connected"));
124 keypad.on("disconnect", () => console.warn("Keypad lost connection"));
125 if (!dialpad.connected) {
126 console.warn("MX Creative Dialpad is not connected");
127 }
128 dialpad.on("connect", () => console.info("Dialpad connected"));
129 dialpad.on("disconnect", () => console.warn("Dialpad lost connection"));
130
131 const instances = new Map<Mac.BundleId, AppInstance>();
132 const pendingInstances = new Map<Mac.BundleId, Promise<AppInstance | null>>();
133 let currentInstance: AppInstance | null = null;
134 let switchVersion = 0;
135
136 async function getInstance(
137 bundle: Mac.BundleId,
138 ): Promise<AppInstance | null> {
139 const existing = instances.get(bundle);
140 if (existing) return existing;
141
142 const pending = pendingInstances.get(bundle);
143 if (pending) return pending;
144
145 const definition = definitions.find((x) => x.bundle === bundle);
146 if (!definition) return null;
147
148 const next = definition.load().then((config) => {
149 if (config.bundle !== bundle) {
150 throw new Error(
151 `Config bundle mismatch: expected "${bundle}", got "${config.bundle}"`,
152 );
153 }
154
155 const instance: AppInstance = {
156 bundle,
157 keypad: new KeypadSurface(keypad),
158 dialpad: new AppDialpad(dialpad),
159 app: new Events<AppEvents>(),
160 };
161 config.configure({
162 keypad: instance.keypad,
163 dialpad: instance.dialpad,
164 mac,
165 app: instance.app,
166 });
167
168 instances.set(bundle, instance);
169 return instance;
170 }).finally(() => {
171 pendingInstances.delete(bundle);
172 });
173
174 pendingInstances.set(bundle, next);
175 return next;
176 }
177
178 async function switchApp(bundle: Mac.BundleId) {
179 const version = ++switchVersion;
180
181 if (currentInstance) {
182 currentInstance.keypad.setActive(false);
183 currentInstance.dialpad.enabled = false;
184 currentInstance.app.emit("blur");
185 currentInstance = null;
186 }
187
188 const nextInstance = await getInstance(bundle);
189 if (version !== switchVersion) return;
190
191 currentInstance = nextInstance;
192
193 if (currentInstance) {
194 currentInstance.keypad.setActive(true);
195 currentInstance.dialpad.enabled = true;
196 currentInstance.app.emit("focus");
197 } else {
198 keypad.reset();
199 }
200 }
201
202 mac.on("app-change", (id) => {
203 void switchApp(id).catch((error) => {
204 queueMicrotask(() => {
205 throw error;
206 });
207 });
208 });
209 if (mac.currentApp) await switchApp(mac.currentApp);
210}
211
212async function loadConfig(moduleUrl: string): Promise<AppConfig> {
213 const imported = await import(moduleUrl);
214 const config = imported.default as AppConfig | undefined;
215 if (!config) {
216 throw new Error(`Config module ${moduleUrl} has no default export`);
217 }
218 return config;
219}
220
221function readBundleId(source: string): Mac.BundleId | null {
222 const match = source.match(/\bforApp\(\s*(["'`])([^"'`]+)\1/);
223 return (match?.[2] as Mac.BundleId | undefined) ?? null;
224}
control/src/icons.ts created+387
...@@ -0,0 +1,387 @@
1// Build-free key "faces" for the MX Creative Keypad: a fluent icon builder that
2// renders to a key-sized SVG document. Three sources — Lucide (stroke icons,
3// typed from `lucide-static`), Material Design Icons (filled icons, typed from
4// `mdi-ts`), and plain text — with chainable `.bg()`/`.fg()`/`.size()`.
5//
6// lucide("Play") // a white play glyph on the default bg
7// mdi("metronome").fg("red") // an MDI metronome, tinted red
8// txt("BPM").bg("#101010") // a centered text label
9//
10// The result's `.svg` is what KeypadUI rasterizes to JPEG. Everything here is
11// pure string building — no build step, no runtime SVG parsing beyond a trim.
12import type { MdiIcon } from "mdi-ts";
13import * as lucideIcons from "lucide-static";
14import { readFileSync } from "node:fs";
15import { createRequire } from "node:module";
16import { dirname, join } from "node:path";
17
18/** The pixel size of a single key face (square) — the SVG viewBox. */
19const KEY_SIZE = 118;
20
21const DEFAULT_BG = "#1b1b1b";
22const DEFAULT_FG = "#ffffff";
23const DEFAULT_ICON_SIZE = 56;
24const DEFAULT_TEXT_SIZE = 34;
25
26// The "unassigned" face: a near-black key with a small grey dot.
27const BLANK_BG = "#141414";
28const BLANK_DOT = "#555";
29
30/** Named palette. Extend freely — any unknown name falls through as a raw color. */
31const colorMap = {
32 black: "#000000",
33 white: "#ffffff",
34 grey: "#8a8a8a",
35 gray: "#8a8a8a",
36 red: "#ff5a36",
37 orange: "#ff9f43",
38 amber: "#ffbf47",
39 yellow: "#ffd23f",
40 lime: "#b6f36b",
41 green: "#7cfc9b",
42 teal: "#2dd4bf",
43 cyan: "#3ad6e8",
44 blue: "#4aa8ff",
45 indigo: "#6c7bff",
46 violet: "#9b6bff",
47 purple: "#b47cff",
48 magenta: "#ff5ccd",
49 pink: "#ff6bd6",
50} as const;
51
52/** Hex string, a name from the palette, or any other CSS color. */
53export type Color = `#${string}` | keyof typeof colorMap | (string & {});
54
55function resolveColor(color: Color): string {
56 return (colorMap as Record<string, string>)[color] ?? color;
57}
58
59/** Anything with SVG markup for a single key. */
60export interface Svg {
61 readonly svg: string;
62}
63
64/** Lucide icon names (PascalCase), typed from `lucide-static`. */
65export type LucideIconName = keyof typeof lucideIcons;
66
67/** MDI icon names (kebab-case, without the `mdi-` prefix), typed from `mdi-ts`. */
68export type MdiIconName = `${MdiIcon}` extends `mdi-${infer Name}` ? Name : never;
69
70/** One line of a {@link stack}: bare text, or text with its own size/color. */
71export type StackLine =
72 | string
73 | number
74 | { text: string | number; size?: number; color?: Color };
75
76interface StackSpec {
77 text: string;
78 size?: number;
79 color?: Color;
80}
81
82type IconSource =
83 | { readonly kind: "lucide"; readonly name: string }
84 | { readonly kind: "mdi"; readonly name: string }
85 | { readonly kind: "text"; readonly text: string }
86 | { readonly kind: "stack"; readonly lines: readonly StackSpec[] }
87 | { readonly kind: "timesig"; readonly numerator: string; readonly denominator: string }
88 | { readonly kind: "blank" };
89
90/**
91 * An immutable, lazily-rendered key face. `.bg()`/`.fg()`/`.size()` each return
92 * a new `Icon`, so definitions compose without mutating shared instances.
93 */
94export class Icon implements Svg {
95 readonly #source: IconSource;
96 readonly #bg: Color;
97 readonly #fg: Color;
98 readonly #size: number | null;
99 #rendered: string | null = null;
100
101 constructor(
102 source: IconSource,
103 bg: Color = DEFAULT_BG,
104 fg: Color = DEFAULT_FG,
105 size: number | null = null,
106 ) {
107 this.#source = source;
108 this.#bg = bg;
109 this.#fg = fg;
110 this.#size = size;
111 }
112
113 /** A copy with a different background color. */
114 bg(color: Color): Icon {
115 return new Icon(this.#source, color, this.#fg, this.#size);
116 }
117
118 /** A copy with a different foreground (stroke/fill/text) color. */
119 fg(color: Color): Icon {
120 return new Icon(this.#source, this.#bg, color, this.#size);
121 }
122
123 /** A copy with a different glyph size in key pixels (icon or text height). */
124 size(px: number): Icon {
125 return new Icon(this.#source, this.#bg, this.#fg, px);
126 }
127
128 /** The full key-sized SVG document. Rendered once, then cached. */
129 get svg(): string {
130 return this.#rendered ??= wrapSvg(this.#inner(), resolveColor(this.#bg));
131 }
132
133 #inner(): string {
134 const fg = resolveColor(this.#fg);
135 switch (this.#source.kind) {
136 case "lucide":
137 return glyph(lucideInner(this.#source.name), this.#glyphSize(), {
138 fill: "none",
139 stroke: fg,
140 extra:
141 `stroke-width="2" stroke-linecap="round" stroke-linejoin="round"`,
142 });
143 case "mdi":
144 return glyph(mdiInner(this.#source.name), this.#glyphSize(), {
145 fill: fg,
146 stroke: "none",
147 });
148 case "text":
149 return text(this.#source.text, fg, this.#size ?? DEFAULT_TEXT_SIZE);
150 case "stack":
151 return stackInner(this.#source.lines, fg);
152 case "timesig":
153 return timeSignatureInner(
154 this.#source.numerator,
155 this.#source.denominator,
156 fg,
157 );
158 case "blank":
159 return `<circle cx="${KEY_SIZE / 2}" cy="${KEY_SIZE / 2}" r="7" ` +
160 `fill="${BLANK_DOT}"/>`;
161 }
162 }
163
164 #glyphSize(): number {
165 return this.#size ?? DEFAULT_ICON_SIZE;
166 }
167}
168
169/** A Lucide (stroke) icon. `key` is the PascalCase name, e.g. `"AlarmClock"`. */
170export function lucide(key: LucideIconName): Icon {
171 return new Icon({ kind: "lucide", name: key });
172}
173
174/** A Material Design (filled) icon. `key` is the kebab name, e.g. `"metronome"`. */
175export function mdi(key: MdiIconName): Icon {
176 return new Icon({ kind: "mdi", name: key });
177}
178
179/** A short, centered text label. */
180export function txt(label: string): Icon {
181 return new Icon({ kind: "text", text: label });
182}
183
184/**
185 * A vertical stack of text lines, e.g. `stack(130, "BPM")` — the first line is
186 * emphasized (larger), the rest are secondary. Pass `{ text, size, color }` to
187 * override a line. Great for live readouts: `() => stack(bpm(), "BPM")`.
188 */
189export function stack(...lines: StackLine[]): Icon {
190 return new Icon({ kind: "stack", lines: lines.map(normalizeStackLine) });
191}
192
193function normalizeStackLine(line: StackLine): StackSpec {
194 if (typeof line === "string" || typeof line === "number") {
195 return { text: String(line) };
196 }
197 return { text: String(line.text), size: line.size, color: line.color };
198}
199
200/**
201 * A musical time signature: serif numerals stacked over a set of staff lines.
202 * `timeSignature("4/4")` or `timeSignature(6, 8)`.
203 */
204export function timeSignature(signature: string): Icon;
205export function timeSignature(
206 numerator: number | string,
207 denominator: number | string,
208): Icon;
209export function timeSignature(
210 a: number | string,
211 b?: number | string,
212): Icon {
213 let numerator: string;
214 let denominator: string;
215 if (b === undefined) {
216 const [top, bottom] = String(a).split("/");
217 numerator = (top ?? "4").trim();
218 denominator = (bottom ?? "4").trim();
219 } else {
220 numerator = String(a);
221 denominator = String(b);
222 }
223 return new Icon({ kind: "timesig", numerator, denominator });
224}
225
226/** The "unassigned" face: a near-black key with a small grey dot. */
227export const blank: Icon = new Icon({ kind: "blank" }, BLANK_BG);
228
229// ---------------------------------------------------------------------------
230// SVG building — icons live in a 24x24 viewBox; scale + center them in the key.
231// ---------------------------------------------------------------------------
232
233/** Wrap inner markup in a full key-sized document with a solid background. */
234function wrapSvg(inner: string, bg: string): string {
235 return `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 ${KEY_SIZE} ${KEY_SIZE}">` +
236 `<rect width="${KEY_SIZE}" height="${KEY_SIZE}" fill="${bg}"/>${inner}</svg>`;
237}
238
239interface GlyphStyle {
240 fill: string;
241 stroke: string;
242 extra?: string;
243}
244
245/** Place a 24x24 glyph, scaled to `size` and centered, with the given paint. */
246function glyph(inner: string, size: number, style: GlyphStyle): string {
247 const offset = (KEY_SIZE - size) / 2;
248 const scale = size / 24;
249 const extra = style.extra ? ` ${style.extra}` : "";
250 return `<g transform="translate(${offset} ${offset}) scale(${scale})" ` +
251 `fill="${style.fill}" stroke="${style.stroke}"${extra}>${inner}</g>`;
252}
253
254const SANS_FONT = "Helvetica, Arial, sans-serif";
255const SERIF_FONT = "Georgia, 'Times New Roman', Times, serif";
256
257interface TextStyle {
258 family?: string;
259 weight?: number;
260}
261
262/** A single centered `<text>` at (x, y), sized in key pixels. */
263function textAt(
264 x: number,
265 y: number,
266 label: string,
267 fg: string,
268 size: number,
269 style: TextStyle = {},
270): string {
271 const family = style.family ?? SANS_FONT;
272 const weight = style.weight ?? 600;
273 return `<text x="${x}" y="${y}" fill="${fg}" font-family="${family}" ` +
274 `font-size="${size}" font-weight="${weight}" text-anchor="middle" ` +
275 `dominant-baseline="central">${escapeXml(label)}</text>`;
276}
277
278function text(label: string, fg: string, size: number): string {
279 return textAt(KEY_SIZE / 2, KEY_SIZE / 2, label, fg, size);
280}
281
282const STACK_PRIMARY = 42;
283const STACK_SECONDARY = 22;
284const STACK_GAP = 6;
285
286/** Lay text lines out vertically, centered as a group. */
287function stackInner(lines: readonly StackSpec[], fg: string): string {
288 const sizes = lines.map((line, index) =>
289 line.size ?? (index === 0 ? STACK_PRIMARY : STACK_SECONDARY)
290 );
291 const height = sizes.reduce((sum, size) => sum + size, 0) +
292 STACK_GAP * Math.max(0, lines.length - 1);
293 let top = (KEY_SIZE - height) / 2;
294
295 return lines
296 .map((line, index) => {
297 const size = sizes[index];
298 const centerY = top + size / 2;
299 top += size + STACK_GAP;
300 const color = line.color ? resolveColor(line.color) : fg;
301 return textAt(KEY_SIZE / 2, centerY, line.text, color, size);
302 })
303 .join("");
304}
305
306// Four horizontal staff lines with the serif numerals stacked across them —
307// numerator in the upper half, denominator in the lower, like real sheet music.
308const STAFF_LINES = 4;
309const STAFF_GAP = 12;
310const STAFF_INSET = 16;
311const TIMESIG_GLYPH = 40;
312
313function timeSignatureInner(
314 numerator: string,
315 denominator: string,
316 fg: string,
317): string {
318 const span = STAFF_GAP * (STAFF_LINES - 1);
319 const top = (KEY_SIZE - span) / 2;
320 let staff = "";
321 for (let i = 0; i < STAFF_LINES; i += 1) {
322 const y = top + i * STAFF_GAP;
323 staff += `<line x1="${STAFF_INSET}" y1="${y}" x2="${KEY_SIZE - STAFF_INSET}" ` +
324 `y2="${y}" stroke="${fg}" stroke-width="1.5" opacity="0.4"/>`;
325 }
326 const style: TextStyle = { family: SERIF_FONT, weight: 700 };
327 const numeral = TIMESIG_GLYPH;
328 const glyphs =
329 textAt(KEY_SIZE / 2, KEY_SIZE / 2 - 16, numerator, fg, numeral, style) +
330 textAt(KEY_SIZE / 2, KEY_SIZE / 2 + 16, denominator, fg, numeral, style);
331 return staff + glyphs;
332}
333
334function escapeXml(value: string): string {
335 return value.replace(/[<>&"']/g, (char) =>
336 char === "<"
337 ? "&lt;"
338 : char === ">"
339 ? "&gt;"
340 : char === "&"
341 ? "&amp;"
342 : char === '"'
343 ? "&quot;"
344 : "&apos;");
345}
346
347/** Strip the outer `<svg>` wrapper (and any leading comment) from icon markup. */
348function extractInner(markup: string): string {
349 return markup
350 .replace(/^[\s\S]*?<svg[^>]*>/, "")
351 .replace(/<\/svg>[\s\S]*$/, "")
352 .trim();
353}
354
355const require = createRequire(import.meta.url);
356const MDI_DIR = join(dirname(require.resolve("@mdi/svg/package.json")), "svg");
357
358const lucideCache = new Map<string, string>();
359const mdiCache = new Map<string, string>();
360
361function lucideInner(name: string): string {
362 let inner = lucideCache.get(name);
363 if (inner === undefined) {
364 const raw = (lucideIcons as Record<string, unknown>)[name];
365 if (typeof raw !== "string") {
366 throw new Error(`Unknown Lucide icon: "${name}"`);
367 }
368 inner = extractInner(raw);
369 lucideCache.set(name, inner);
370 }
371 return inner;
372}
373
374function mdiInner(name: string): string {
375 let inner = mdiCache.get(name);
376 if (inner === undefined) {
377 let raw: string;
378 try {
379 raw = readFileSync(join(MDI_DIR, `${name}.svg`), "utf8");
380 } catch {
381 throw new Error(`Unknown MDI icon: "${name}"`);
382 }
383 inner = extractInner(raw);
384 mdiCache.set(name, inner);
385 }
386 return inner;
387}
control/src/main.ts created+3
...@@ -0,0 +1,3 @@
1import { runConfigs } from "./config.ts";
2
3await runConfigs("config");
control/src/signals.ts created+104
...@@ -0,0 +1,104 @@
1// Minimal fine-grained reactive signals — no dependencies. A `signal` holds a
2// value; reading it inside a tracking scope (an `effect` or `computed`)
3// subscribes that scope, and `set` re-runs the scopes that read it. Just enough
4// reactivity to let keypad faces re-render themselves when their data changes,
5// keeping app configs declarative:
6//
7// const bpm = signal(120);
8// reaper.on("transport", (t) => bpm.set(Math.round(t.tempo)));
9// keypad.key("up-right", () => stack(bpm(), "BPM"), increaseTempo);
10//
11// Reads during a scope are tracked as dependencies and cleared on each re-run,
12// so conditional reads don't leave stale subscriptions behind.
13
14export type Cleanup = () => void;
15
16interface Reaction {
17 run: () => void;
18 deps: Set<Set<Reaction>>;
19}
20
21let activeReaction: Reaction | null = null;
22
23export interface ReadonlySignal<T> {
24 (): T;
25 /** Read without subscribing the current scope. */
26 peek(): T;
27}
28
29export interface Signal<T> extends ReadonlySignal<T> {
30 set(value: T): void;
31 update(fn: (previous: T) => T): void;
32}
33
34/** A reactive value. Call it to read (and subscribe); `.set()` to write. */
35export function signal<T>(initial: T): Signal<T> {
36 let value = initial;
37 const subscribers = new Set<Reaction>();
38
39 const read = (() => {
40 if (activeReaction) {
41 subscribers.add(activeReaction);
42 activeReaction.deps.add(subscribers);
43 }
44 return value;
45 }) as Signal<T>;
46
47 read.peek = () => value;
48 read.set = (next: T) => {
49 if (Object.is(next, value)) return;
50 value = next;
51 // Copy first: a reaction may resubscribe (or unsubscribe) while running.
52 for (const reaction of [...subscribers]) reaction.run();
53 };
54 read.update = (fn) => read.set(fn(value));
55
56 return read;
57}
58
59function runReaction(reaction: Reaction, body: () => void) {
60 // Drop the previous run's subscriptions so stale dependencies don't linger.
61 for (const dep of reaction.deps) dep.delete(reaction);
62 reaction.deps.clear();
63 const previous = activeReaction;
64 activeReaction = reaction;
65 try {
66 body();
67 } finally {
68 activeReaction = previous;
69 }
70}
71
72/** Run `fn` now, and again whenever a signal it read changes. Returns a disposer. */
73export function effect(fn: () => void): Cleanup {
74 const reaction: Reaction = {
75 run: () => runReaction(reaction, fn),
76 deps: new Set(),
77 };
78 reaction.run();
79 return () => {
80 for (const dep of reaction.deps) dep.delete(reaction);
81 reaction.deps.clear();
82 };
83}
84
85/** A memoized derived value that recomputes when its dependencies change. */
86export function computed<T>(compute: () => T): ReadonlySignal<T> {
87 const holder = signal<T>(undefined as T);
88 let started = false;
89 const reaction: Reaction = {
90 run: () => runReaction(reaction, () => holder.set(compute())),
91 deps: new Set(),
92 };
93
94 const read = (() => {
95 if (!started) {
96 started = true;
97 reaction.run();
98 }
99 return holder();
100 }) as ReadonlySignal<T>;
101 read.peek = () => holder.peek();
102
103 return read;
104}
docs/speed-editor.jpg
Binary files a/docs/speed-editor.jpg and /dev/null differ
examples/dialpad.ts deleted-14
...@@ -1,14 +0,0 @@
1// Listen to the dialpad. NOTE: until the OS-seize phase, turning the dial also
2// scrolls macOS and the buttons act as mouse buttons.
3import { Dialpad } from "../src/Dialpad.ts";
4
5const dialpad = await Dialpad.open();
6console.info(dialpad.connected ? "Dialpad connected" : "Waiting for dialpad…");
7
8dialpad.on("connect", () => console.info("connect"));
9dialpad.on("disconnect", () => console.info("disconnect"));
10dialpad.on("rotate", (delta) => console.info("rotate", delta));
11dialpad.on("spin", (delta) => console.info("spin", delta));
12dialpad.on("keydown", (button) => console.info("down", button));
13dialpad.on("keyup", (button) => console.info("up", button));
14dialpad.onPress("circle", () => console.info("circle pressed!"));
examples/enumerate-hid.ts deleted-43
...@@ -1,43 +0,0 @@
1// Phase 0: list HID devices so we can identify the MX Creative Console halves.
2// Keypad is expected as Elgato (0x0fd9); the Bluetooth dialpad is likely
3// Logitech (0x046d) speaking HID++.
4const { devices } = await import("node-hid");
5
6const VENDOR_NAMES: Record<number, string> = {
7 0x046d: "Logitech",
8 0x0fd9: "Elgato",
9 0x05ac: "Apple",
10};
11
12const hex = (n: number | undefined, width = 4) => "0x" + (n ?? 0).toString(16).padStart(width, "0");
13
14const all = devices();
15
16const format = (d: import("node-hid").Device) =>
17 [
18 `${hex(d.vendorId)}:${hex(d.productId)}`,
19 (VENDOR_NAMES[d.vendorId] ?? "?").padEnd(8),
20 `usage=${hex(d.usagePage)}/${hex(d.usage)}`,
21 `iface=${d.interface}`,
22 `| ${d.manufacturer ?? ""} ${d.product ?? ""}`.trim(),
23 d.path ? `\n path=${d.path}` : "",
24 ].join(" ");
25
26const interesting = all.filter(
27 (d) => d.vendorId === 0x046d || d.vendorId === 0x0fd9,
28);
29
30console.info(`Total HID devices: ${all.length}`);
31console.info(`\n=== Logitech (0x046d) + Elgato (0x0fd9) ===`);
32if (interesting.length === 0) {
33 console.info(" (none found — dialpad may not surface as a HID device)");
34} else {
35 for (const d of interesting) console.info(" " + format(d));
36}
37
38console.info(`\n=== All vendors present ===`);
39const byVendor = new Map<number, number>();
40for (const d of all) byVendor.set(d.vendorId, (byVendor.get(d.vendorId) ?? 0) + 1);
41for (const [vid, count] of [...byVendor].sort((a, b) => b[1] - a[1])) {
42 console.info(` ${hex(vid)} ${(VENDOR_NAMES[vid] ?? "").padEnd(8)} ${count}`);
43}
examples/event-listener.ts deleted-24
...@@ -1,24 +0,0 @@
1import { Mac } from "../src/Mac.ts";
2import { SpeedEditor } from "../src/SpeedEditor.ts";
3
4const editor = await SpeedEditor.open();
5const mac = await Mac.open();
6
7editor.on("jog", (ev) => {
8 console.info(ev);
9});
10editor.on("keypress", (key) => {
11 console.info(`Press ${key}`);
12
13 if (key === "smartInsert") {
14 mac.focusApp("com.google.Chrome");
15 }
16});
17editor.onDoublePress("transition", () => {
18 console.info("(double press) \"Title\"");
19});
20
21console.info("init");
22mac.on("app-change", (bundle) => {
23 console.info(`Switch to ${bundle}`);
24});
examples/face-preview.ts deleted-26
...@@ -1,26 +0,0 @@
1// Render key faces to PNGs (no hardware needed) so you can eyeball them before
2// pushing to the device. Writes to /tmp/face-preview/.
3import { mkdirSync, writeFileSync } from "node:fs";
4import sharp from "sharp";
5import { blank, lucide, mdi, txt } from "../src/icons.ts";
6
7const faces: Record<string, string> = {
8 blank: blank.svg,
9 metronome: mdi("metronome").svg,
10 "time-sig": lucide("Clock").svg,
11 bpm: txt("BPM").svg,
12 "add-instrument": lucide("Plus").svg,
13 record: lucide("Disc").fg("red").svg,
14 play: lucide("Play").svg,
15 save: lucide("Save").fg("green").svg,
16};
17
18const outDir = "/tmp/face-preview";
19mkdirSync(outDir, { recursive: true });
20
21for (const [name, face] of Object.entries(faces)) {
22 const png = await sharp(Buffer.from(face)).resize(118, 118).png().toBuffer();
23 const file = `${outDir}/${name}.png`;
24 writeFileSync(file, png);
25 console.info(`${name.padEnd(16)} ${png.length} bytes ${file}`);
26}
examples/hid-sniff.ts deleted-67
...@@ -1,67 +0,0 @@
1// Phase 0 sniffer: open an MX Creative Console half by product-name match and
2// log every HID input report. Operate the control you want to map and watch the
3// report id + bytes. Logitech HID++ events arrive as report id 0x10 (short, 7B)
4// or 0x11 (long, 20B). Default mouse/consumer reports use other ids.
5//
6// node examples/hid-sniff.ts [name-substring] [seconds]
7// node examples/hid-sniff.ts dialpad 25
8const { devices, HID } = await import("node-hid");
9
10const match = (process.argv[2] ?? "dialpad").toLowerCase();
11const durationSec = Number(process.argv[3] ?? 0);
12
13const hex = (n: number, w = 2) => n.toString(16).padStart(w, "0");
14
15const dev = devices().find(
16 (d) => (d.product ?? "").toLowerCase().includes(match) && d.path,
17);
18if (!dev?.path) {
19 console.error(
20 `No HID device matching "${match}". Run examples/enumerate-hid.ts to list.`,
21 );
22 process.exit(1);
23}
24
25console.info(
26 `Opening ${dev.product} 0x${hex(dev.vendorId, 4)}:0x${hex(dev.productId, 4)}\n path=${dev.path}`,
27);
28
29let device: import("node-hid").HID;
30try {
31 device = new HID(dev.path);
32} catch (error) {
33 console.error(
34 "Failed to open device. On macOS, grant the terminal/node Input Monitoring\n"
35 + "(System Settings > Privacy & Security > Input Monitoring), then retry.\n",
36 error,
37 );
38 process.exit(1);
39}
40
41const t0 = Date.now();
42let count = 0;
43device.on("data", (buf: Buffer) => {
44 const bytes = [...buf];
45 const id = bytes[0];
46 const kind = id === 0x11
47 ? "hid++ long "
48 : id === 0x10
49 ? "hid++ short"
50 : "report ";
51 const ms = String(Date.now() - t0).padStart(6);
52 console.info(
53 `+${ms}ms ${kind} id=0x${hex(id)} ${bytes.map((b) => hex(b)).join(" ")}`,
54 );
55 count += 1;
56});
57device.on("error", (error) => console.error("device error:", error));
58
59console.info("Listening — operate the dial / knob / buttons. Ctrl-C to stop.\n");
60
61if (durationSec > 0) {
62 setTimeout(() => {
63 console.info(`\nCaptured ${count} reports in ${durationSec}s. Closing.`);
64 device.close();
65 process.exit(0);
66 }, durationSec * 1000);
67}
examples/keypad-demo.ts deleted-30
...@@ -1,30 +0,0 @@
1// Push the Reaper root layout to the physical keypad as one atomic panel write
2// and exit (faces persist on the device). Validates the panel image protocol.
3import { Keypad } from "../src/Keypad.ts";
4import { composePanel, type Face } from "../src/KeypadUI.ts";
5import { blank, lucide, txt } from "../src/icons.ts";
6
7const keypad = await Keypad.open();
8if (!keypad.connected) {
9 console.error("Keypad not connected.");
10 process.exit(1);
11}
12
13keypad.setBrightness(0.85);
14
15// Faces in grid order: up-left, up, up-right, left, center, right, down-*.
16const faces: Face[] = [
17 lucide("AlarmClock"),
18 lucide("Clock"),
19 txt("BPM"),
20 lucide("Plus"),
21 blank,
22 blank,
23 lucide("Disc").fg("red"),
24 blank,
25 lucide("Play"),
26];
27
28keypad.setPanel(await composePanel(faces));
29console.info("Pushed the panel to the keypad — look at the device.");
30process.exit(0);
examples/toast.ts deleted-25
...@@ -1,25 +0,0 @@
1import { Mac } from "../src/Mac.ts";
2
3const mac = await Mac.open();
4mac.on("error", (error) => {
5 console.error(error);
6});
7
8mac.toast("Insert AD2 Track", {
9 detail: "Loading Addictive Drums 2...",
10 durationMs: 900,
11});
12
13await new Promise((resolve) => {
14 setTimeout(resolve, 1300);
15});
16
17mac.toast("Insert Blank Track", {
18 durationMs: 800,
19});
20
21await new Promise((resolve) => {
22 setTimeout(resolve, 1200);
23});
24
25mac.close();
package.json deleted-41
...@@ -1,41 +0,0 @@
1{
2 "name": "@clo/creative-control",
3 "version": "1.0.0",
4 "type": "module",
5 "license": "ISC",
6 "packageManager": "pnpm@10.26.1",
7 "scripts": {
8 "start": "node --watch src/main.ts",
9 "generate:reaper-actions": "node src/Reaper/generate-actions.ts"
10 },
11 "dependencies": {
12 "@clo/lib": "jsr:^3.0.0",
13 "@mdi/svg": "^7.4.47",
14 "@types/node": "^25.5.0",
15 "lucide-static": "^1.21.0",
16 "mdi-ts": "^1.0.3",
17 "node-hid": "^3.3.0",
18 "sharp": "^0.35.2",
19 "usb": "^2.17.0"
20 },
21 "imports": {
22 "#config": "./src/config.ts"
23 },
24 "exports": {
25 "./Mac": "./src/Mac.ts",
26 "./SpeedEditor": "./src/SpeedEditor.ts",
27 "./Keypad": "./src/Keypad.ts",
28 "./KeypadUI": "./src/KeypadUI.ts",
29 "./icons": "./src/icons.ts",
30 "./signals": "./src/signals.ts",
31 "./Dialpad": "./src/Dialpad.ts",
32 "./Reaper": "./src/Reaper.ts",
33 "./Reaper/actions": "./src/Reaper/actions.ts"
34 },
35 "pnpm": {
36 "onlyBuiltDependencies": [
37 "node-hid",
38 "usb"
39 ]
40 }
41}
pnpm-lock.yaml deleted-563
...@@ -1,563 +0,0 @@
1lockfileVersion: '9.0'
2
3settings:
4 autoInstallPeers: true
5 excludeLinksFromLockfile: false
6
7importers:
8
9 .:
10 dependencies:
11 '@clo/lib':
12 specifier: jsr:^3.0.0
13 version: '@jsr/clo__lib@3.0.0'
14 '@mdi/svg':
15 specifier: ^7.4.47
16 version: 7.4.47
17 '@types/node':
18 specifier: ^25.5.0
19 version: 25.5.0
20 lucide-static:
21 specifier: ^1.21.0
22 version: 1.21.0
23 mdi-ts:
24 specifier: ^1.0.3
25 version: 1.0.3
26 node-hid:
27 specifier: ^3.3.0
28 version: 3.3.0
29 sharp:
30 specifier: ^0.35.2
31 version: 0.35.2
32 usb:
33 specifier: ^2.17.0
34 version: 2.17.0
35
36packages:
37
38 '@emnapi/runtime@1.11.1':
39 resolution: {integrity: sha512-vgj7R3y3Wgx24IQaGPA/R6YFXLHVMOZ0uVEyIQPaWs+rd1AzfEMXlAC22FYwO1XkKR6NPsq7mUandH8oIRdZFw==}
40
41 '@img/colour@1.1.0':
42 resolution: {integrity: sha512-Td76q7j57o/tLVdgS746cYARfSyxk8iEfRxewL9h4OMzYhbW4TAcppl0mT4eyqXddh6L/jwoM75mo7ixa/pCeQ==}
43 engines: {node: '>=18'}
44
45 '@img/sharp-darwin-arm64@0.35.2':
46 resolution: {integrity: sha512-eEieHsMksAW4IiO5NzauESRl2D2qz3J/kwUxUrSfV06A93eEaRfMpHXyUb1mAqrR7i8U9A0GRqE9pjn6u1Jjpg==}
47 engines: {node: '>=20.9.0'}
48 cpu: [arm64]
49 os: [darwin]
50
51 '@img/sharp-darwin-x64@0.35.2':
52 resolution: {integrity: sha512-BaktuGPCeHJMARpodR8jK4uKiZrPAy9WrfQW0sdI37clracq8Bp01AYS3SZgi5FS/y5twa9t4+LIuuxQjqRrWw==}
53 engines: {node: '>=20.9.0'}
54 cpu: [x64]
55 os: [darwin]
56
57 '@img/sharp-freebsd-wasm32@0.35.2':
58 resolution: {integrity: sha512-YoAxdnd8hPUkvLHd3bWY+YA8nw3xM/RyRopYucNsWHVSan8NLVM3X2volsfoRDcXdUJPg6tXahSd7HXPK7lRnw==}
59 engines: {node: '>=20.9.0'}
60 os: [freebsd]
61
62 '@img/sharp-libvips-darwin-arm64@1.3.1':
63 resolution: {integrity: sha512-4V/M3roRMTYjiwZY9IOVQOE8OyeCxFAkYmyZDrZl51uOKjibm3oeEJ4WAmLxutAfzFbC9jqUiPs2gbnGflH+7g==}
64 cpu: [arm64]
65 os: [darwin]
66
67 '@img/sharp-libvips-darwin-x64@1.3.1':
68 resolution: {integrity: sha512-c0/DxItpJv2+dGhgycJBBgotdqruGYDvA79drdh0MD1dFpy7JzJ/PlXwi1H4rFf0eTy8tgbI91aHDnZIceY3jQ==}
69 cpu: [x64]
70 os: [darwin]
71
72 '@img/sharp-libvips-linux-arm64@1.3.1':
73 resolution: {integrity: sha512-JznefmcK9j1JKPz8AkQDh89kjojubyfOasWBPKfzMIhPwsgDy9evpE/naJTXXXmghS1iFwR8u/kTwh/I2/+GCw==}
74 cpu: [arm64]
75 os: [linux]
76
77 '@img/sharp-libvips-linux-arm@1.3.1':
78 resolution: {integrity: sha512-aGGy9aWzXgHBG7HNyQPWorZthlp7+x6fDRoPAQbGO3ThcttuTyKIx3NuSHb6zb4gBNq6/yNn9f1cy9nFKS/Vmg==}
79 cpu: [arm]
80 os: [linux]
81
82 '@img/sharp-libvips-linux-ppc64@1.3.1':
83 resolution: {integrity: sha512-1EkwGNCZk6iWNCMWqrvdJ+r1j0PT1zIz60CNPhYnJlK/zyeWqlsPZIe+ocBVqPF8k/Ssee/NCk+tE9Ryrko6ng==}
84 cpu: [ppc64]
85 os: [linux]
86
87 '@img/sharp-libvips-linux-riscv64@1.3.1':
88 resolution: {integrity: sha512-Ilays+w2bXdnxzxtQdmXR62u8o8GYa3eL4+Gr+1KiE4xperMZUslRaVPJwwPkzlHEjGfXAfRVAa/7CYCtSqsBw==}
89 cpu: [riscv64]
90 os: [linux]
91
92 '@img/sharp-libvips-linux-s390x@1.3.1':
93 resolution: {integrity: sha512-VfBwVHQTbRoj4XlpA/KLZ7ltgMpz+4WSejFzQ+GnoImjo1PtEJ59QB2qR1xQEeRPYIkNrPIm2L4cICMvz4C2ew==}
94 cpu: [s390x]
95 os: [linux]
96
97 '@img/sharp-libvips-linux-x64@1.3.1':
98 resolution: {integrity: sha512-+c8ukgwU62DS54nCAjw7keOfHUkmr0B5QHEdcOqRnodF/MNXJbVI8Eopoj4B/0H8Asr65I+A4Amrn7a85/md6A==}
99 cpu: [x64]
100 os: [linux]
101
102 '@img/sharp-libvips-linuxmusl-arm64@1.3.1':
103 resolution: {integrity: sha512-qlKb/pwbkAi1WMsJrYHk7CuDrd12s27U2QnRhFYUoJNrRCmkosMTttuRFat/DDB3IlDm5qE1TJgZ4JDnHX8Ldw==}
104 cpu: [arm64]
105 os: [linux]
106
107 '@img/sharp-libvips-linuxmusl-x64@1.3.1':
108 resolution: {integrity: sha512-yO21HwoUVLN8Qa+/SBjQLMYwBWAVJjeGPNe+hc0OUeMeifEtJqu5a1c4HayE1nNpDih9y3/KkoltfkDodmKAlg==}
109 cpu: [x64]
110 os: [linux]
111
112 '@img/sharp-linux-arm64@0.35.2':
113 resolution: {integrity: sha512-af12Pnd0ZGu2HfP8NayB0kk6eC/lrfbQE6HlR4jD+34wdJ1Vw9TF6TMn6ZvffT+WgqVsl0hRbmNvz2u/23VmwA==}
114 engines: {node: '>=20.9.0'}
115 cpu: [arm64]
116 os: [linux]
117
118 '@img/sharp-linux-arm@0.35.2':
119 resolution: {integrity: sha512-SE4kzF2mepn6z+6E7L6lsV8FzuLL6IPQdyX8ZiwROAG/G8td+hP/m7FsFPwidtrF19gvajuC9l6TxAVcsA4S7A==}
120 engines: {node: '>=20.9.0'}
121 cpu: [arm]
122 os: [linux]
123
124 '@img/sharp-linux-ppc64@0.35.2':
125 resolution: {integrity: sha512-hYSBm7zcNtDCozCxQHYZJiu63b/bXsgRZuOxCIBZsStMM9Vap47iFHdbX4kCvQsblPB/k+clhELpdQJHQLSHvg==}
126 engines: {node: '>=20.9.0'}
127 cpu: [ppc64]
128 os: [linux]
129
130 '@img/sharp-linux-riscv64@0.35.2':
131 resolution: {integrity: sha512-qQt0Kc13+Hoan/Awq/qMSQw3L+RI1NCRPgD5cUJ/1WSSmIoysLOc72jlRM3E0OHN9Yr313jgeQ2T+zW+F03QFA==}
132 engines: {node: '>=20.9.0'}
133 cpu: [riscv64]
134 os: [linux]
135
136 '@img/sharp-linux-s390x@0.35.2':
137 resolution: {integrity: sha512-E4fLLfRPzDLlEeDaTzI98OFLcv++WL5ChLLMwPoVd0CIoZQqupBSNbOisPL5am9XsbQ9T84+iiMpUvbFtkunbA==}
138 engines: {node: '>=20.9.0'}
139 cpu: [s390x]
140 os: [linux]
141
142 '@img/sharp-linux-x64@0.35.2':
143 resolution: {integrity: sha512-gi0zFJJRLswfCZmHtJdikXPOc5u7qamSOS3NHedLqLd4W8Q0NqjdBr6TTRIgsfFjqfTsHFgdfvJ9LwqSgcHiAA==}
144 engines: {node: '>=20.9.0'}
145 cpu: [x64]
146 os: [linux]
147
148 '@img/sharp-linuxmusl-arm64@0.35.2':
149 resolution: {integrity: sha512-siWbOW1u6HFnFLrp0waKyW7VEf7jYvcDWdrXEFa8AkdAQgEvuu5Fz8/Y70w9EeqAdwDtfU012BhEHHaDqvQNzg==}
150 engines: {node: '>=20.9.0'}
151 cpu: [arm64]
152 os: [linux]
153
154 '@img/sharp-linuxmusl-x64@0.35.2':
155 resolution: {integrity: sha512-YBqMMcjDi4QGYiSn4vNOYBhmlC4z5AXqkOUUqI2e0AFA4urNv4ESgOgwNl3K+4etQhha0twXlzeF20bbULm9Yg==}
156 engines: {node: '>=20.9.0'}
157 cpu: [x64]
158 os: [linux]
159
160 '@img/sharp-wasm32@0.35.2':
161 resolution: {integrity: sha512-Mrv4JQNYVQ94xH+jzZ9r+gowleN8mv2FTgKT+PI6bx5C0G8TdNYndu161pg2i7uoBwxy2ImPMHrJOM2LZef7Bw==}
162 engines: {node: '>=20.9.0'}
163
164 '@img/sharp-webcontainers-wasm32@0.35.2':
165 resolution: {integrity: sha512-QNV27pxs9wpApEiCfvHM1RDoP1w1+2KrUWWDPEhEwg+latvOrfuhWrHWZKwdSFwU6jh3myjw/yOCRsUIuOft3g==}
166 engines: {node: '>=20.9.0'}
167 cpu: [wasm32]
168
169 '@img/sharp-win32-arm64@0.35.2':
170 resolution: {integrity: sha512-BiVRYc/t6/Vl3e1hBx0hugG4oN9Pydf4fgMSpxTQJmwGUg/YoXTWHiFeRymHfCZzifxu4F4rpk/I67D0LQ20wQ==}
171 engines: {node: '>=20.9.0'}
172 cpu: [arm64]
173 os: [win32]
174
175 '@img/sharp-win32-ia32@0.35.2':
176 resolution: {integrity: sha512-YYEhx9PImCC7T0tI8JDMi4DB9LwLCXCU5OWNYEXAxh5Q1ShKkyC6byxzoBJ3gEFDnH2lQckWuDe70G7mB2XJog==}
177 engines: {node: ^20.9.0}
178 cpu: [ia32]
179 os: [win32]
180
181 '@img/sharp-win32-x64@0.35.2':
182 resolution: {integrity: sha512-imoOyBcoM/iiUr4J6VPpCNjPnjvP/Gks95898yB8YqoGGYmHYbOyCuNv9FMhFgtaiHFGbHW8bxKqRV6VjtXThQ==}
183 engines: {node: '>=20.9.0'}
184 cpu: [x64]
185 os: [win32]
186
187 '@jsr/clo__lib@3.0.0':
188 resolution: {integrity: sha512-oseZwHCAcXNPbqnGZ37l7+wAoj6ikIXE1VM0s6eD6fz4DcgM030Slf0T7Lgtn7fIdas5hlfx4JF54TR+vo4THw==, tarball: https://npm.jsr.io/~/11/@jsr/clo__lib/3.0.0.tgz}
189
190 '@mdi/svg@7.4.47':
191 resolution: {integrity: sha512-WQ2gDll12T9WD34fdRFgQVgO8bag3gavrAgJ0frN4phlwdJARpE6gO1YvLEMJR0KKgoc+/Ea/A0Pp11I00xBvw==}
192
193 '@types/node@25.5.0':
194 resolution: {integrity: sha512-jp2P3tQMSxWugkCUKLRPVUpGaL5MVFwF8RDuSRztfwgN1wmqJeMSbKlnEtQqU8UrhTmzEmZdu2I6v2dpp7XIxw==}
195
196 '@types/w3c-web-usb@1.0.13':
197 resolution: {integrity: sha512-N2nSl3Xsx8mRHZBvMSdNGtzMyeleTvtlEw+ujujgXalPqOjIA6UtrqcB6OzyUjkTbDm3J7P1RNK1lgoO7jxtsw==}
198
199 ansi-regex@5.0.1:
200 resolution: {integrity: sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==}
201 engines: {node: '>=8'}
202
203 ansi-styles@4.3.0:
204 resolution: {integrity: sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==}
205 engines: {node: '>=8'}
206
207 cliui@8.0.1:
208 resolution: {integrity: sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==}
209 engines: {node: '>=12'}
210
211 color-convert@2.0.1:
212 resolution: {integrity: sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==}
213 engines: {node: '>=7.0.0'}
214
215 color-name@1.1.4:
216 resolution: {integrity: sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==}
217
218 detect-libc@2.1.2:
219 resolution: {integrity: sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==}
220 engines: {node: '>=8'}
221
222 emoji-regex@8.0.0:
223 resolution: {integrity: sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==}
224
225 escalade@3.2.0:
226 resolution: {integrity: sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==}
227 engines: {node: '>=6'}
228
229 get-caller-file@2.0.5:
230 resolution: {integrity: sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==}
231 engines: {node: 6.* || 8.* || >= 10.*}
232
233 is-fullwidth-code-point@3.0.0:
234 resolution: {integrity: sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==}
235 engines: {node: '>=8'}
236
237 linq@4.0.3:
238 resolution: {integrity: sha512-dP0w2ERJXfVUk6VmmAK+Tz/SxFHwyY7VM6Mrq4fnJmeQf9JNEYFH6qJfV6Qn0N91mfwz2GEE/4S+RDkmDNyUJw==}
239
240 lucide-static@1.21.0:
241 resolution: {integrity: sha512-6248z2/4sEyKkYAPPUYxOPiB2RCfMmLdMHuoOhsTFnoD40ixAoHmTVhOPux8ADa1NTBmzpEKF7WNePm+Ms503Q==}
242
243 mdi-ts@1.0.3:
244 resolution: {integrity: sha512-wtVNYoCkvyYuTJ8osV5af6jfsE5o+UT1sAnaPVjAZiIXHVFpP2l66JzdAdNClnLfCk7g5wu8cWHrr/9ru1V8vQ==}
245
246 node-addon-api@3.2.1:
247 resolution: {integrity: sha512-mmcei9JghVNDYydghQmeDX8KoAm0FAiYyIcUt/N4nhyAipB17pllZQDOJD2fotxABnt4Mdz+dKTO7eftLg4d0A==}
248
249 node-addon-api@8.6.0:
250 resolution: {integrity: sha512-gBVjCaqDlRUk0EwoPNKzIr9KkS9041G/q31IBShPs1Xz6UTA+EXdZADbzqAJQrpDRq71CIMnOP5VMut3SL0z5Q==}
251 engines: {node: ^18 || ^20 || >= 21}
252
253 node-gyp-build@4.8.4:
254 resolution: {integrity: sha512-LA4ZjwlnUblHVgq0oBF3Jl/6h/Nvs5fzBLwdEF4nuxnFdsfajde4WfxtJr3CaiH+F6ewcIB/q4jQ4UzPyid+CQ==}
255 hasBin: true
256
257 node-hid@3.3.0:
258 resolution: {integrity: sha512-j+dFgJLRAE0nufQKXk3IfS6T6YuHhCgMvz4TrG0sgtb6DSCdYpfJ1etcdmeCmPQjUgO+yo32ktVrRliNs/+fmg==}
259 engines: {node: '>=10.16'}
260 hasBin: true
261
262 pkg-prebuilds@1.0.0:
263 resolution: {integrity: sha512-D9wlkXZCmjxj2kBHTw3fGSyjoahr33breGBoJcoezpi7ouYS59DJVOHMZ+dgqacSrZiJo4qtkXxLQTE+BqXJmQ==}
264 engines: {node: '>= 14.15.0'}
265 hasBin: true
266
267 require-directory@2.1.1:
268 resolution: {integrity: sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==}
269 engines: {node: '>=0.10.0'}
270
271 semver@7.8.5:
272 resolution: {integrity: sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==}
273 engines: {node: '>=10'}
274 hasBin: true
275
276 sharp@0.35.2:
277 resolution: {integrity: sha512-FVtFjtBCMiJS6yb5CX7Sop45WFMpeGw6oRKuJnXYgf/f1ms/D7LE/ZUSNxnW7rZ/dbslQWYkoqFHGPaDBtaK4w==}
278 engines: {node: '>=20.9.0'}
279
280 string-width@4.2.3:
281 resolution: {integrity: sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==}
282 engines: {node: '>=8'}
283
284 strip-ansi@6.0.1:
285 resolution: {integrity: sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==}
286 engines: {node: '>=8'}
287
288 tslib@2.8.1:
289 resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==}
290
291 undici-types@7.18.2:
292 resolution: {integrity: sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w==}
293
294 usb@2.17.0:
295 resolution: {integrity: sha512-UuFgrlglgDn5ll6d5l7kl3nDb2Yx43qLUGcDq+7UNLZLtbNug0HZBb2Xodhgx2JZB1LqvU+dOGqLEeYUeZqsHg==}
296 engines: {node: '>=12.22.0 <13.0 || >=14.17.0'}
297
298 wrap-ansi@7.0.0:
299 resolution: {integrity: sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==}
300 engines: {node: '>=10'}
301
302 y18n@5.0.8:
303 resolution: {integrity: sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==}
304 engines: {node: '>=10'}
305
306 yargs-parser@21.1.1:
307 resolution: {integrity: sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==}
308 engines: {node: '>=12'}
309
310 yargs@17.7.2:
311 resolution: {integrity: sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w==}
312 engines: {node: '>=12'}
313
314snapshots:
315
316 '@emnapi/runtime@1.11.1':
317 dependencies:
318 tslib: 2.8.1
319 optional: true
320
321 '@img/colour@1.1.0': {}
322
323 '@img/sharp-darwin-arm64@0.35.2':
324 optionalDependencies:
325 '@img/sharp-libvips-darwin-arm64': 1.3.1
326 optional: true
327
328 '@img/sharp-darwin-x64@0.35.2':
329 optionalDependencies:
330 '@img/sharp-libvips-darwin-x64': 1.3.1
331 optional: true
332
333 '@img/sharp-freebsd-wasm32@0.35.2':
334 dependencies:
335 '@img/sharp-wasm32': 0.35.2
336 optional: true
337
338 '@img/sharp-libvips-darwin-arm64@1.3.1':
339 optional: true
340
341 '@img/sharp-libvips-darwin-x64@1.3.1':
342 optional: true
343
344 '@img/sharp-libvips-linux-arm64@1.3.1':
345 optional: true
346
347 '@img/sharp-libvips-linux-arm@1.3.1':
348 optional: true
349
350 '@img/sharp-libvips-linux-ppc64@1.3.1':
351 optional: true
352
353 '@img/sharp-libvips-linux-riscv64@1.3.1':
354 optional: true
355
356 '@img/sharp-libvips-linux-s390x@1.3.1':
357 optional: true
358
359 '@img/sharp-libvips-linux-x64@1.3.1':
360 optional: true
361
362 '@img/sharp-libvips-linuxmusl-arm64@1.3.1':
363 optional: true
364
365 '@img/sharp-libvips-linuxmusl-x64@1.3.1':
366 optional: true
367
368 '@img/sharp-linux-arm64@0.35.2':
369 optionalDependencies:
370 '@img/sharp-libvips-linux-arm64': 1.3.1
371 optional: true
372
373 '@img/sharp-linux-arm@0.35.2':
374 optionalDependencies:
375 '@img/sharp-libvips-linux-arm': 1.3.1
376 optional: true
377
378 '@img/sharp-linux-ppc64@0.35.2':
379 optionalDependencies:
380 '@img/sharp-libvips-linux-ppc64': 1.3.1
381 optional: true
382
383 '@img/sharp-linux-riscv64@0.35.2':
384 optionalDependencies:
385 '@img/sharp-libvips-linux-riscv64': 1.3.1
386 optional: true
387
388 '@img/sharp-linux-s390x@0.35.2':
389 optionalDependencies:
390 '@img/sharp-libvips-linux-s390x': 1.3.1
391 optional: true
392
393 '@img/sharp-linux-x64@0.35.2':
394 optionalDependencies:
395 '@img/sharp-libvips-linux-x64': 1.3.1
396 optional: true
397
398 '@img/sharp-linuxmusl-arm64@0.35.2':
399 optionalDependencies:
400 '@img/sharp-libvips-linuxmusl-arm64': 1.3.1
401 optional: true
402
403 '@img/sharp-linuxmusl-x64@0.35.2':
404 optionalDependencies:
405 '@img/sharp-libvips-linuxmusl-x64': 1.3.1
406 optional: true
407
408 '@img/sharp-wasm32@0.35.2':
409 dependencies:
410 '@emnapi/runtime': 1.11.1
411 optional: true
412
413 '@img/sharp-webcontainers-wasm32@0.35.2':
414 dependencies:
415 '@img/sharp-wasm32': 0.35.2
416 optional: true
417
418 '@img/sharp-win32-arm64@0.35.2':
419 optional: true
420
421 '@img/sharp-win32-ia32@0.35.2':
422 optional: true
423
424 '@img/sharp-win32-x64@0.35.2':
425 optional: true
426
427 '@jsr/clo__lib@3.0.0': {}
428
429 '@mdi/svg@7.4.47': {}
430
431 '@types/node@25.5.0':
432 dependencies:
433 undici-types: 7.18.2
434
435 '@types/w3c-web-usb@1.0.13': {}
436
437 ansi-regex@5.0.1: {}
438
439 ansi-styles@4.3.0:
440 dependencies:
441 color-convert: 2.0.1
442
443 cliui@8.0.1:
444 dependencies:
445 string-width: 4.2.3
446 strip-ansi: 6.0.1
447 wrap-ansi: 7.0.0
448
449 color-convert@2.0.1:
450 dependencies:
451 color-name: 1.1.4
452
453 color-name@1.1.4: {}
454
455 detect-libc@2.1.2: {}
456
457 emoji-regex@8.0.0: {}
458
459 escalade@3.2.0: {}
460
461 get-caller-file@2.0.5: {}
462
463 is-fullwidth-code-point@3.0.0: {}
464
465 linq@4.0.3: {}
466
467 lucide-static@1.21.0: {}
468
469 mdi-ts@1.0.3:
470 dependencies:
471 linq: 4.0.3
472
473 node-addon-api@3.2.1: {}
474
475 node-addon-api@8.6.0: {}
476
477 node-gyp-build@4.8.4: {}
478
479 node-hid@3.3.0:
480 dependencies:
481 node-addon-api: 3.2.1
482 pkg-prebuilds: 1.0.0
483
484 pkg-prebuilds@1.0.0:
485 dependencies:
486 yargs: 17.7.2
487
488 require-directory@2.1.1: {}
489
490 semver@7.8.5: {}
491
492 sharp@0.35.2:
493 dependencies:
494 '@img/colour': 1.1.0
495 detect-libc: 2.1.2
496 semver: 7.8.5
497 optionalDependencies:
498 '@img/sharp-darwin-arm64': 0.35.2
499 '@img/sharp-darwin-x64': 0.35.2
500 '@img/sharp-freebsd-wasm32': 0.35.2
501 '@img/sharp-libvips-darwin-arm64': 1.3.1
502 '@img/sharp-libvips-darwin-x64': 1.3.1
503 '@img/sharp-libvips-linux-arm': 1.3.1
504 '@img/sharp-libvips-linux-arm64': 1.3.1
505 '@img/sharp-libvips-linux-ppc64': 1.3.1
506 '@img/sharp-libvips-linux-riscv64': 1.3.1
507 '@img/sharp-libvips-linux-s390x': 1.3.1
508 '@img/sharp-libvips-linux-x64': 1.3.1
509 '@img/sharp-libvips-linuxmusl-arm64': 1.3.1
510 '@img/sharp-libvips-linuxmusl-x64': 1.3.1
511 '@img/sharp-linux-arm': 0.35.2
512 '@img/sharp-linux-arm64': 0.35.2
513 '@img/sharp-linux-ppc64': 0.35.2
514 '@img/sharp-linux-riscv64': 0.35.2
515 '@img/sharp-linux-s390x': 0.35.2
516 '@img/sharp-linux-x64': 0.35.2
517 '@img/sharp-linuxmusl-arm64': 0.35.2
518 '@img/sharp-linuxmusl-x64': 0.35.2
519 '@img/sharp-webcontainers-wasm32': 0.35.2
520 '@img/sharp-win32-arm64': 0.35.2
521 '@img/sharp-win32-ia32': 0.35.2
522 '@img/sharp-win32-x64': 0.35.2
523
524 string-width@4.2.3:
525 dependencies:
526 emoji-regex: 8.0.0
527 is-fullwidth-code-point: 3.0.0
528 strip-ansi: 6.0.1
529
530 strip-ansi@6.0.1:
531 dependencies:
532 ansi-regex: 5.0.1
533
534 tslib@2.8.1:
535 optional: true
536
537 undici-types@7.18.2: {}
538
539 usb@2.17.0:
540 dependencies:
541 '@types/w3c-web-usb': 1.0.13
542 node-addon-api: 8.6.0
543 node-gyp-build: 4.8.4
544
545 wrap-ansi@7.0.0:
546 dependencies:
547 ansi-styles: 4.3.0
548 string-width: 4.2.3
549 strip-ansi: 6.0.1
550
551 y18n@5.0.8: {}
552
553 yargs-parser@21.1.1: {}
554
555 yargs@17.7.2:
556 dependencies:
557 cliui: 8.0.1
558 escalade: 3.2.0
559 get-caller-file: 2.0.5
560 require-directory: 2.1.1
561 string-width: 4.2.3
562 y18n: 5.0.8
563 yargs-parser: 21.1.1
pnpm-workspace.yaml deleted-2
...@@ -1,2 +0,0 @@
1onlyBuiltDependencies:
2 - node-hid
readme.md+6-113
...@@ -1,116 +1,9 @@...@@ -1,116 +1,9 @@
1# Clover's Creative Control1# Creative Toolkit
22
3This is a set of tools to let additional hardware devices integrate with3Clover's **Creative Toolkit** is collection of macOS software that she use to create the projects seen on [paper clover](https://paperclover.net). Note that these are designed around the workflow and environment Clover uses, and most details are not documented. Since a large majority of this code is [vibe-coded](https://en.wikipedia.org/wiki/Vibe_coding), which is okay as the software is not intended to be used by anyone other than Clover.
4creative applications on a Mac device. Additionally, this repo contains a lot of
5my own tools and scripts I use in the Music/Video creative processes.
64
7In addition to the primary keybinding system and personal software5The projects are independant, yet related:
8configurations, this project can be used as a library to use the control
9primitives directly (either to build your own hardware integrations, or to
10control the software). This can be done by installing this repo as a `pnpm` git
11dependency in your project.
126
13**NOTE**: These tools only work on macOS. I don't have interest in maintaining7- [**Clover Control**](./control): Glue external input devices to creative software.
14other configurations.8- [**Clover Recorder**](./recorder): Capture multi-screen recordings.
159- [**Clover Sequencer**](./sequencer): Multi-track video synchronization.
16## Hardware
17
18### DaVinci Resolve Speed Editor
19
20A $200 dual-hardware system.
21
22### DaVinci Resolve Speed Editor
23
24A $300 bundle containing Fusion Studio and the control surface, it's a great
25deal. There is a large, high resolution knob, as well as many keys with some
26having lights. This repo includes an SDK to reprogram it to be useful in any
27program. **Note**: It is unused as of 2026-06-24.
28
29![DaVinci Resolve Speed Editor](docs/speed-editor.jpg)
30
31## Software
32
33TODO:
34
35- Fusion
36- Blender?
37- Krita?
38- The Finder
39- QuickTime player
40
41### macOS (`Mac.ts`)
42
43Bind to the Mac desktop interface.
44
45```ts
46const mac = await Mac.open();
47
48mac.on("app-change", (bundle) => {
49 console.info("Current App: " + bundle);
50});
51```
52
53### REAPER
54
55With the help of an OSC extension, REAPER can be controlled with TypeScript.
56
57```ts
58import { Reaper } from "@clo/creative-control/Reaper";
59
60const reaper = new Reaper();
61reaper.on("transport", (transport) => {
62 console.info(
63 transport.recording
64 ? "You are recording"
65 : transport.playing
66 ? "Playing"
67 : "Stopped",
68 );
69});
70```
71
72Setup:
73
74- Start up Clover Creative Control / `new Reaper()`
75- Navigate to REAPER Settings (`Cmd+,`)
76- Click `Control/OSC/web` on the left side panel
77- Press `Add`
78 - Control surface mode: `OSC (Open Sound Control)`
79 - Device name: `Clover Automation`
80 - Pattern config: `CloverAutomation`
81 - Mode: `Configure device IP+local port`
82 - Device port: `58001`
83 - Device IP: `127.0.0.1`
84 - Local listen port: `58000`
85 - Local IP: (default)
86 - Allow binding messages to REAPER actions and FX learn
87
88## Config Format
89
90The main entrypoint loads config files from `./config`, which each apply to one
91application. In this example, it configures Reaper to integrate with the Speed
92Editor. The provided instance of hardware devices are wrapper objects that apply
93the binds only when the program is active. This way, there aren't situations
94with multiple readers conflicting.
95
96```ts
97import * as config from "#config";
98import { Reaper } from "@clo/creative-control/Reaper";
99
100export default config.forApp("com.cockos.reaper", ({ speededitor, mac }) => {
101 const reaper = new Reaper();
102
103 // Sync state to LEDs
104 reaper.on("transport", (transport) => {
105 speededitor.leds.audioOnly = transport.recording;
106 });
107
108 // Keyboard Actions
109 speededitor.onPress("stopPlay", () => {
110 reaper.runAction("transport-play-stop");
111 });
112 speededitor.onPress("audioOnly", () => {
113 reaper.runAction("transport-record");
114 });
115});
116```
readme.new.md deleted-3
...@@ -1,3 +0,0 @@
1# Creative Toolkit
2
3Clover's **Creative Toolkit** is collection of macOS software that she use to create the projects seen on [paper clover](https://paperclover.net).
\ No newline at end of file
recorder/build.sh created+69
...@@ -0,0 +1,69 @@
1#!/usr/bin/env bash
2# Build the Clover Recorder capture engine and wrap it in a minimal .app bundle.
3#
4# macOS only grants and reliably lists *app bundles* (stable bundle id) under
5# Privacy → Screen Recording, so even the headless capture core ships as an app.
6#
7# Usage: ./build.sh (run on the Mac that will do the recording)
8set -euo pipefail
9
10HERE="$(cd "$(dirname "$0")" && pwd)"
11ENGINE="$HERE/engine"
12DIST="$HERE/dist"
13APP="$DIST/Clover Recorder.app"
14BUNDLE_ID="org.clover.recorder"
15VERSION="0.1.0"
16
17echo "==> swift build (release)"
18( cd "$ENGINE" && swift build -c release )
19BIN="$ENGINE/.build/release/recorder"
20
21echo "==> assembling $APP"
22rm -rf "$APP"
23mkdir -p "$APP/Contents/MacOS"
24cp "$BIN" "$APP/Contents/MacOS/recorder"
25
26echo "==> compiling uvc-powerline helper"
27clang -O2 -o "$APP/Contents/MacOS/uvc-powerline" "$HERE/uvc/uvc-powerline.c" \
28 -framework IOKit -framework CoreFoundation
29
30echo "==> bundling dictation scripts"
31mkdir -p "$APP/Contents/Resources"
32cp "$HERE"/dictation/*.py "$APP/Contents/Resources/"
33
34cat > "$APP/Contents/Info.plist" <<EOF
35<?xml version="1.0" encoding="UTF-8"?>
36<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
37<plist version="1.0"><dict>
38 <key>CFBundleIdentifier</key><string>$BUNDLE_ID</string>
39 <key>CFBundleName</key><string>Clover Recorder</string>
40 <key>CFBundleExecutable</key><string>recorder</string>
41 <key>CFBundlePackageType</key><string>APPL</string>
42 <key>CFBundleShortVersionString</key><string>$VERSION</string>
43 <key>CFBundleVersion</key><string>$VERSION</string>
44 <key>LSMinimumSystemVersion</key><string>14.0</string>
45 <key>LSUIElement</key><true/>
46 <key>NSMicrophoneUsageDescription</key><string>Clover Recorder records your microphone for journaling and improv sessions.</string>
47 <key>NSCameraUsageDescription</key><string>Clover Recorder records your webcam for journaling and improv sessions.</string>
48</dict></plist>
49EOF
50
51# Sign with the stable self-signed identity from the dedicated Clover keychain
52# (see setup-signing.sh). This keeps the same designated requirement across
53# rebuilds, so the Screen Recording grant survives. Falls back to ad-hoc.
54CN="Clover Code Signing"
55KC="$HOME/Library/Keychains/clover-signing.keychain-db"
56KCPW="${CLOVER_KEYCHAIN_PW:-clover}"
57
58if [[ -f "$KC" ]] && security find-identity -p codesigning "$KC" 2>/dev/null | grep -q "$CN"; then
59 security unlock-keychain -p "$KCPW" "$KC" 2>/dev/null || true
60 echo "==> codesign with stable identity '$CN'"
61 codesign --force --keychain "$KC" --sign "$CN" --timestamp=none "$APP"
62else
63 echo "==> codesign ad-hoc (run setup-signing.sh once for a stable identity)"
64 codesign --force --sign - "$APP"
65fi
66
67codesign -dv "$APP" 2>&1 | sed -n '1,4p' || true
68echo "==> built: $APP"
69echo " binary: $APP/Contents/MacOS/recorder"
recorder/dictation/diarize.py created+37
...@@ -0,0 +1,37 @@
1#!/usr/bin/env python3
2"""Speaker diarization (pyannote) — prints turn boundaries as JSON.
3
4Runs in the dedicated ~/.clover-diarize venv (pinned deps). session_transcript
5calls this to get precise "who spoke when" boundaries; the main venv then names
6the speakers (ECAPA) and splits the forced-aligned words at the turn edges.
7
8 python diarize.py <audio> -> [{"start":..,"end":..,"speaker":"SPEAKER_00"}, ...]
9"""
10import json
11import subprocess
12import sys
13import warnings
14
15warnings.filterwarnings("ignore")
16
17import numpy as np
18import torch
19from pyannote.audio import Pipeline
20
21audio = sys.argv[1]
22pipe = Pipeline.from_pretrained("pyannote/speaker-diarization-3.1")
23if torch.backends.mps.is_available():
24 pipe.to(torch.device("mps"))
25
26raw = subprocess.run(
27 ["ffmpeg", "-nostdin", "-i", audio, "-f", "f32le", "-ac", "1", "-ar", "16000", "-"],
28 capture_output=True,
29).stdout
30wav = torch.from_numpy(np.frombuffer(raw, np.float32).copy()).unsqueeze(0)
31
32dia = pipe({"waveform": wav, "sample_rate": 16000})
33turns = [
34 {"start": float(t.start), "end": float(t.end), "speaker": spk}
35 for t, _, spk in dia.itertracks(yield_label=True)
36]
37print(json.dumps(turns))
recorder/dictation/enroll.py created+15
...@@ -0,0 +1,15 @@
1#!/usr/bin/env python3
2"""Enroll a named voice into the library from a recording.
3
4 python enroll.py <audio> [name] # default name: You
5"""
6import os
7import sys
8
9sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
10from speaker_id import embed_file, upsert_voice
11
12audio = sys.argv[1]
13name = sys.argv[2] if len(sys.argv) > 2 else "You"
14upsert_voice(name, embed_file(audio))
15print(f"enrolled {name}")
recorder/dictation/forced_align.py created+70
...@@ -0,0 +1,70 @@
1"""Word-level forced alignment (torchaudio MMS_FA, the modern wav2vec2-CTC
2approach WhisperX popularized) — precise per-word start/end times, no token.
3
4Aligned per Whisper segment (small, fast) and offset back to absolute time.
5"""
6import re
7import warnings
8
9warnings.filterwarnings("ignore")
10
11import numpy as np
12import torch
13from torchaudio.pipelines import MMS_FA as B
14
15_model = _tok = _aligner = None
16
17
18def _load():
19 global _model, _tok, _aligner
20 if _model is None:
21 _model, _tok, _aligner = B.get_model(), B.get_tokenizer(), B.get_aligner()
22 return _model, _tok, _aligner
23
24
25def _norm(word):
26 return re.sub(r"[^a-z']", "", word.lower())
27
28
29def align_segment(data, sr, start, end, text):
30 """Return [{word, start, end}] for the words in this segment, in absolute
31 seconds. Words that can't be aligned (pure numbers/symbols) get times
32 interpolated from their neighbours."""
33 a, b = int(start * sr), int(end * sr)
34 seg = data[a:b]
35 raw = text.split()
36 if len(seg) < int(0.2 * sr) or not raw:
37 return [{"word": w, "start": start, "end": end} for w in raw]
38
39 norm = [_norm(w) for w in raw]
40 idx = [i for i, n in enumerate(norm) if n]
41 if not idx:
42 return [{"word": w, "start": start, "end": end} for w in raw]
43
44 model, tok, aligner = _load()
45 wav = torch.from_numpy(np.ascontiguousarray(seg)).unsqueeze(0)
46 with torch.inference_mode():
47 emit, _ = model(wav)
48 try:
49 spans = aligner(emit[0], tok([norm[i] for i in idx]))
50 except Exception:
51 return [{"word": w, "start": start, "end": end} for w in raw]
52
53 ratio = wav.shape[1] / emit.shape[1] / sr
54 times = {}
55 for k, i in enumerate(idx):
56 s = spans[k]
57 times[i] = (round(start + s[0].start * ratio, 3), round(start + s[-1].end * ratio, 3))
58
59 out = [{"word": w, "start": None, "end": None} for w in raw]
60 for i, t in times.items():
61 out[i]["start"], out[i]["end"] = t
62 # Interpolate unaligned words from neighbours.
63 last_end = start
64 for i, o in enumerate(out):
65 if o["start"] is None:
66 o["start"] = last_end
67 nxt = next((out[j]["start"] for j in range(i + 1, len(out)) if out[j]["start"]), end)
68 o["end"] = nxt
69 last_end = o["end"]
70 return out
recorder/dictation/relabel.py created+49
...@@ -0,0 +1,49 @@
1#!/usr/bin/env python3
2"""Apply speaker names to a session: add newly-named voices to the library and
3re-render transcript.md — no re-transcription.
4
5 python relabel.py <session_dir> <mapping.json>
6
7mapping.json maps the auto-labels to names, e.g. {"Speaker 2": "Maya"}.
8"""
9import json
10import os
11import sys
12
13sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
14import speaker_id as sid
15from transcript_render import render
16
17session_dir, mapping_path = sys.argv[1], sys.argv[2]
18mapping = json.load(open(mapping_path))
19
20transcript = json.load(open(os.path.join(session_dir, "transcript.json")))
21spk_path = os.path.join(session_dir, "speakers.json")
22speakers = json.load(open(spk_path)) if os.path.exists(spk_path) else {"speakers": []}
23
24# Teach the library each newly-named unknown voice.
25for sp in speakers.get("speakers", []):
26 new = mapping.get(sp["label"])
27 if new and new != sp["label"] and sp.get("unknown") and sp.get("centroid"):
28 sid.upsert_voice(new, sp["centroid"])
29
30# Re-map labels and re-render.
31markers = []
32mp = os.path.join(session_dir, "markers.json")
33if os.path.exists(mp):
34 for mk in json.load(open(mp)).get("markers", []):
35 markers.append((float(mk.get("offsetSeconds", 0)), mk.get("text")))
36markers.sort(key=lambda x: x[0])
37
38segments = transcript["segments"]
39for s in segments:
40 s["label"] = mapping.get(s.get("label"), s.get("label"))
41render(transcript.get("title", "Session"), segments,
42 [s.get("label") for s in segments], markers,
43 os.path.join(session_dir, "transcript.md"))
44
45json.dump(transcript, open(os.path.join(session_dir, "transcript.json"), "w"))
46for sp in speakers.get("speakers", []):
47 sp["label"] = mapping.get(sp["label"], sp["label"])
48json.dump(speakers, open(spk_path, "w"))
49print("relabeled")
recorder/dictation/session_transcript.py created+235
...@@ -0,0 +1,235 @@
1#!/usr/bin/env python3
2"""Transcribe a session's mic audio into a Markdown transcript with speakers.
3
4 python session_transcript.py <audio> <markers.json|none> <out.md> <title>
5
6Each segment is matched against the enrolled voice library (You + named guests);
7unmatched voices are clustered into distinct Speaker 2/3/… Writes:
8 - <out.md> meeting-minutes transcript, paragraphs labelled by speaker
9 - transcript.json segments + labels (for fast re-labelling after naming)
10 - speakers.json detected speakers + a sample clip + centroid (for the UI)
11"""
12import json
13import os
14import subprocess
15import sys
16
17import numpy as np
18import mlx_whisper
19
20sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
21import speaker_id as sid
22from transcript_render import PAUSE_SPLIT, render
23
24DIARIZE_PY = os.path.expanduser("~/.clover-diarize/.venv/bin/python")
25
26
27def assign_speakers(segments, data, sr):
28 """Cluster segments into voices, then match each *cluster* to the library —
29 far more robust than per-segment matching, so one person stays one speaker.
30
31 Returns (labels, speakers): labels[i] is the speaker for segment i; speakers
32 is per-cluster metadata for the review UI."""
33 from scipy.cluster.hierarchy import fcluster, linkage
34
35 library = [(v["name"], np.asarray(v["centroid"], dtype=np.float32)) for v in sid.load_library()]
36 embs = [sid.embed_segment(data, sr, float(s["start"]), float(s["end"])) for s in segments]
37 valid = [i for i, e in enumerate(embs) if e is not None]
38 if not valid:
39 return [None] * len(segments), []
40
41 # Agglomerative clustering on cosine distance (average linkage).
42 if len(valid) == 1:
43 cids = [1]
44 else:
45 Z = linkage(np.stack([embs[i] for i in valid]), method="average", metric="cosine")
46 cids = fcluster(Z, t=sid.CLUSTER_DIST, criterion="distance")
47 # Cap runaway fragmentation: if acoustic distance alone invents too many
48 # voices, collapse to at most MAX_SPEAKERS by cutting the tree higher.
49 if len(set(cids)) > sid.MAX_SPEAKERS:
50 cids = fcluster(Z, t=sid.MAX_SPEAKERS, criterion="maxclust")
51
52 members = {}
53 for pos, cid in enumerate(cids):
54 members.setdefault(int(cid), []).append(valid[pos])
55
56 # Label each cluster: known name if its centroid matches the library, else
57 # Speaker N (numbered by first appearance).
58 cluster_label, cluster_centroid, unknown_n = {}, {}, 2
59 for cid, idxs in sorted(members.items(), key=lambda kv: min(kv[1])):
60 cen = np.mean([embs[i] for i in idxs], axis=0)
61 cen /= np.linalg.norm(cen)
62 cluster_centroid[cid] = cen
63 name, sim = None, -1.0
64 for nm, c in library:
65 d = float(cen @ c)
66 if d > sim:
67 sim, name = d, nm
68 if sim >= sid.MATCH_THRESHOLD:
69 cluster_label[cid] = name
70 else:
71 cluster_label[cid] = f"Speaker {unknown_n}"
72 unknown_n += 1
73
74 labels = [None] * len(segments)
75 for cid, idxs in members.items():
76 for i in idxs:
77 labels[i] = cluster_label[cid]
78 prev = None # short (un-embedded) segments inherit the previous speaker
79 for i in range(len(labels)):
80 if labels[i] is None:
81 labels[i] = prev
82 else:
83 prev = labels[i]
84
85 speakers = []
86 for cid, idxs in sorted(members.items(), key=lambda kv: min(kv[1])):
87 lab = cluster_label[cid]
88 unknown = lab.startswith("Speaker ")
89 s = segments[idxs[0]]
90 speakers.append({
91 "label": lab, "unknown": unknown,
92 "sample": {"start": s["start"], "end": s["end"]},
93 "centroid": cluster_centroid[cid].tolist() if unknown else None,
94 })
95 return labels, speakers
96
97
98def run_diarization(audio):
99 """Precise speaker turns via the pyannote venv, or None if unavailable."""
100 if not os.path.exists(DIARIZE_PY):
101 return None
102 script = os.path.join(os.path.dirname(os.path.abspath(__file__)), "diarize.py")
103 try:
104 out = subprocess.run([DIARIZE_PY, script, audio], capture_output=True, text=True, timeout=3600)
105 turns = json.loads(out.stdout.strip().splitlines()[-1])
106 return turns or None
107 except Exception:
108 return None
109
110
111def name_speakers(turns, data, sr):
112 """Name each diarized speaker against the voice library (ECAPA)."""
113 library = [(v["name"], np.asarray(v["centroid"], dtype=np.float32)) for v in sid.load_library()]
114 by_spk, sample = {}, {}
115 for tr in turns:
116 e = sid.embed_segment(data, sr, tr["start"], tr["end"])
117 if e is not None:
118 by_spk.setdefault(tr["speaker"], []).append(e)
119 sample.setdefault(tr["speaker"], tr)
120
121 names, meta, unknown_n = {}, [], 2
122 for spk in sorted(sample, key=lambda s: sample[s]["start"]):
123 cen = None
124 if by_spk.get(spk):
125 cen = np.mean(by_spk[spk], axis=0)
126 cen /= np.linalg.norm(cen)
127 name, sim = None, -1.0
128 if cen is not None:
129 for nm, c in library:
130 d = float(cen @ c)
131 if d > sim:
132 sim, name = d, nm
133 if cen is not None and sim >= sid.MATCH_THRESHOLD:
134 names[spk], unknown = name, False
135 else:
136 names[spk], unknown = f"Speaker {unknown_n}", True
137 unknown_n += 1
138 meta.append({
139 "label": names[spk], "unknown": unknown,
140 "sample": {"start": sample[spk]["start"], "end": sample[spk]["end"]},
141 "centroid": cen.tolist() if (unknown and cen is not None) else None,
142 })
143 return names, meta
144
145
146def _speaker_at(turns, t):
147 for tr in turns:
148 if tr["start"] <= t < tr["end"]:
149 return tr["speaker"]
150 return min(turns, key=lambda tr: min(abs(tr["start"] - t), abs(tr["end"] - t)))["speaker"]
151
152
153def build_from_diarization(segments, turns, data, sr):
154 """Split forced-aligned words at diarization boundaries (precise) and label
155 each by the named speaker. Returns (final_segments, labels, speakers)."""
156 names, meta = name_speakers(turns, data, sr)
157 words = [dict(w) for s in segments for w in s.get("words", [])]
158 if not words:
159 return None
160
161 final = []
162 for w in words:
163 name = names.get(_speaker_at(turns, (float(w["start"]) + float(w["end"])) / 2))
164 if (final and final[-1]["label"] == name
165 and float(w["start"]) - final[-1]["end"] <= PAUSE_SPLIT):
166 final[-1]["end"] = float(w["end"])
167 final[-1]["text"] += " " + w["word"]
168 final[-1]["words"].append(w)
169 else:
170 final.append({
171 "start": float(w["start"]), "end": float(w["end"]),
172 "text": w["word"], "label": name, "words": [w],
173 })
174 return final, [s["label"] for s in final], meta
175
176
177def main():
178 audio, markers_path, out_path, title = sys.argv[1:5]
179 # "solo" (default): one speaker, no diarization — right for journaling and
180 # improv, where clustering just shatters your voice into fake speakers.
181 # "multi": diarize + name, for the occasional session with other people.
182 mode = sys.argv[5] if len(sys.argv) > 5 else "solo"
183 folder = os.path.dirname(out_path)
184
185 segments = mlx_whisper.transcribe(
186 audio, path_or_hf_repo="mlx-community/whisper-large-v3-turbo"
187 ).get("segments", [])
188 segments = [{"start": s["start"], "end": s["end"], "text": s["text"]} for s in segments]
189
190 data, sr = sid.load_audio(audio)
191 try:
192 import forced_align as fa
193
194 for s in segments:
195 s["words"] = fa.align_segment(data, sr, float(s["start"]), float(s["end"]), s["text"])
196 except Exception:
197 for s in segments:
198 s["words"] = []
199
200 if mode != "multi":
201 # Solo: everyone is "You". render() hides the single label, and the
202 # non-unknown speaker keeps the Speakers-review popup from firing.
203 labels = ["You"] * len(segments)
204 final_segments = [dict(s, label="You") for s in segments]
205 speakers = [{"label": "You", "unknown": False,
206 "sample": {"start": 0.0, "end": 0.0}, "centroid": None}]
207 else:
208 # Precise mode (pyannote) when available, else cluster-then-match fallback.
209 diarized = None
210 turns = run_diarization(audio)
211 if turns:
212 diarized = build_from_diarization(segments, turns, data, sr)
213 if diarized:
214 final_segments, labels, speakers = diarized
215 else:
216 try:
217 labels, speakers = assign_speakers(segments, data, sr)
218 except Exception:
219 labels, speakers = [None] * len(segments), []
220 final_segments = [dict(s, label=l) for s, l in zip(segments, labels)]
221
222 markers = []
223 if markers_path and markers_path != "none" and os.path.exists(markers_path):
224 for mk in json.load(open(markers_path)).get("markers", []):
225 markers.append((float(mk.get("offsetSeconds", 0)), mk.get("text")))
226 markers.sort(key=lambda x: x[0])
227
228 render(title, final_segments, labels, markers, out_path)
229 json.dump({"title": title, "segments": final_segments},
230 open(os.path.join(folder, "transcript.json"), "w"))
231 json.dump({"speakers": speakers}, open(os.path.join(folder, "speakers.json"), "w"))
232
233
234if __name__ == "__main__":
235 main()
recorder/dictation/speaker_id.py created+114
...@@ -0,0 +1,114 @@
1"""Speaker embeddings via SpeechBrain ECAPA-TDNN (no HF token needed).
2
3Used to tell the user's voice from others in a session: enroll a voiceprint
4once, then score each transcript segment against it by cosine similarity.
5"""
6import json
7import os
8import subprocess
9import warnings
10
11warnings.filterwarnings("ignore")
12
13import numpy as np
14import torch
15import torchaudio
16
17LIBRARY = os.path.expanduser("~/.clover-whisper/voices.json")
18# Cosine above MATCH_THRESHOLD => a cluster is that library voice. CLUSTER_DIST
19# is the cosine *distance* below which segments merge into one speaker (so your
20# own voice stays a single cluster instead of fragmenting). Tunable.
21MATCH_THRESHOLD = 0.45
22CLUSTER_DIST = 0.55
23# Hard cap on distinct voices the fallback clustering may invent. Without it,
24# short/noisy segments and (in improv) character voices fragment one person into
25# dozens of "speakers". Real sessions here have a handful of people at most.
26MAX_SPEAKERS = 6
27
28_model = None
29
30
31def load_library():
32 if os.path.exists(LIBRARY):
33 return json.load(open(LIBRARY)).get("voices", [])
34 return []
35
36
37def save_library(voices):
38 os.makedirs(os.path.dirname(LIBRARY), exist_ok=True)
39 json.dump({"voices": voices}, open(LIBRARY, "w"))
40
41
42def upsert_voice(name, centroid, count=1):
43 """Add a named voice, or blend into an existing one (running average)."""
44 centroid = np.asarray(centroid, dtype=np.float32)
45 voices = load_library()
46 for v in voices:
47 if v["name"] == name:
48 c, n = np.asarray(v["centroid"], dtype=np.float32), v.get("count", 1)
49 blended = (c * n + centroid * count) / (n + count)
50 blended /= np.linalg.norm(blended)
51 v["centroid"], v["count"] = blended.tolist(), n + count
52 save_library(voices)
53 return
54 voices.append({"name": name, "centroid": centroid.tolist(), "count": count})
55 save_library(voices)
56
57
58def model():
59 global _model
60 if _model is None:
61 from speechbrain.inference.speaker import EncoderClassifier
62
63 _model = EncoderClassifier.from_hparams(
64 source="speechbrain/spkrec-ecapa-voxceleb", run_opts={"device": "cpu"}
65 )
66 return _model
67
68
69def _ffmpeg():
70 user = os.environ.get("USER", "")
71 for c in (
72 f"/etc/profiles/per-user/{user}/bin/ffmpeg",
73 "/run/current-system/sw/bin/ffmpeg",
74 "/opt/homebrew/bin/ffmpeg",
75 "/usr/local/bin/ffmpeg",
76 ):
77 if os.path.exists(c):
78 return c
79 return "ffmpeg"
80
81
82def load_audio(path, target_sr=16000):
83 """Decode any format (m4a/wav/aiff/…) to mono float32 via ffmpeg."""
84 out = subprocess.run(
85 [_ffmpeg(), "-nostdin", "-i", path, "-f", "f32le", "-ac", "1", "-ar", str(target_sr), "-"],
86 capture_output=True,
87 ).stdout
88 return np.frombuffer(out, dtype=np.float32).copy(), target_sr
89
90
91def embed_array(data, sr):
92 sig = torch.from_numpy(np.ascontiguousarray(data)).unsqueeze(0)
93 if sr != 16000:
94 sig = torchaudio.functional.resample(sig, sr, 16000)
95 e = model().encode_batch(sig).squeeze()
96 e = e / e.norm()
97 return e.detach().cpu().numpy()
98
99
100def embed_file(path):
101 data, sr = load_audio(path)
102 return embed_array(data, sr)
103
104
105def embed_segment(data, sr, start, end):
106 a, b = int(start * sr), int(end * sr)
107 seg = data[a:b]
108 if len(seg) < int(0.8 * sr): # too short to be reliable
109 return None
110 return embed_array(seg, sr)
111
112
113def cosine(a, b):
114 return float(np.dot(a, b))
recorder/dictation/transcribe.py created+20
...@@ -0,0 +1,20 @@
1#!/usr/bin/env python3
2"""Transcribe an audio file with MLX Whisper (large-v3-turbo) and print the text.
3
4Used by Clover Recorder's marker overlay for voice notes. Runs against the venv
5created by setup-dictation.sh (~/.clover-whisper/.venv).
6
7 python transcribe.py <audio-file>
8"""
9import sys
10
11import mlx_whisper
12
13if len(sys.argv) < 2:
14 sys.exit("usage: transcribe.py <audio-file>")
15
16result = mlx_whisper.transcribe(
17 sys.argv[1],
18 path_or_hf_repo="mlx-community/whisper-large-v3-turbo",
19)
20print(result["text"].strip())
recorder/dictation/transcript_render.py created+47
...@@ -0,0 +1,47 @@
1"""Render a Markdown transcript from segments + speaker labels + markers.
2
3Shared by session_transcript.py (first pass) and relabel.py (after naming).
4"""
5PAUSE_SPLIT = 2.0 # seconds of silence that starts a new paragraph
6
7
8def fmt(seconds):
9 s = int(seconds)
10 h, m, sec = s // 3600, (s % 3600) // 60, s % 60
11 return f"{h}:{m:02d}:{sec:02d}" if h else f"{m:02d}:{sec:02d}"
12
13
14def render(title, segments, labels, markers, out_path):
15 lines = [f"# {title}", ""]
16 para, p_start, p_spk, prev_end, mi = [], 0.0, None, 0.0, 0
17 # Only show speaker names when there's actually more than one speaker.
18 show_speakers = len({l for l in labels if l}) > 1
19
20 def flush():
21 if para:
22 who = f"{p_spk} · " if (show_speakers and p_spk) else ""
23 lines.append(f"**{who}[{fmt(p_start)}]** " + " ".join(para).strip())
24 lines.append("")
25 para.clear()
26
27 def emit_marker(t, text):
28 flush()
29 lines.append(f"**{text.strip()}**" if text and text.strip() else f"**◆ [{fmt(t)}]**")
30 lines.append("")
31
32 for seg, spk in zip(segments, labels):
33 start = float(seg["start"])
34 while mi < len(markers) and markers[mi][0] <= start:
35 emit_marker(*markers[mi])
36 mi += 1
37 if para and (start - prev_end > PAUSE_SPLIT or spk != p_spk):
38 flush()
39 if not para:
40 p_start, p_spk = start, spk
41 para.append(seg["text"].strip())
42 prev_end = float(seg["end"])
43 flush()
44 while mi < len(markers):
45 emit_marker(*markers[mi])
46 mi += 1
47 open(out_path, "w").write("\n".join(lines) + "\n")
recorder/engine/Package.swift created+22
...@@ -0,0 +1,22 @@
1// swift-tools-version: 6.0
2import PackageDescription
3
4// Capture core for Clover Recorder.
5//
6// A small ScreenCaptureKit + AVFoundation command-line engine that records any
7// combination of displays, the system-audio mix, the microphone, and (later) a
8// webcam into one session folder, each stream as its own file, all timestamped
9// against the shared mach host clock so the streams can be re-aligned exactly.
10//
11// No external dependencies, so it builds fully offline.
12let package = Package(
13 name: "recorder",
14 platforms: [.macOS(.v14)],
15 targets: [
16 .executableTarget(
17 name: "recorder",
18 path: "Sources/recorder",
19 swiftSettings: [.swiftLanguageMode(.v5)]
20 )
21 ]
22)
recorder/engine/Sources/recorder/CameraPreview.swift created+46
...@@ -0,0 +1,46 @@
1import AVFoundation
2import AppKit
3import SwiftUI
4
5/// Live camera preview for the popover, backed by an AVCaptureVideoPreviewLayer.
6struct CameraPreview: NSViewRepresentable {
7 let session: AVCaptureSession?
8
9 func makeNSView(context: Context) -> PreviewNSView { PreviewNSView() }
10
11 func updateNSView(_ nsView: PreviewNSView, context: Context) {
12 if nsView.previewLayer.session !== session {
13 nsView.previewLayer.session = session
14 }
15 }
16}
17
18/// Detachable camera window — stays live during recording for mid-stream framing.
19struct CameraPopoutView: View {
20 @ObservedObject var controller: AppController
21 var body: some View {
22 CameraPreview(session: controller.previewSession)
23 .frame(maxWidth: .infinity, maxHeight: .infinity)
24 .background(Color.black)
25 }
26}
27
28final class PreviewNSView: NSView {
29 let previewLayer = AVCaptureVideoPreviewLayer()
30
31 override init(frame frameRect: NSRect) {
32 super.init(frame: frameRect)
33 wantsLayer = true
34 layer = CALayer()
35 layer?.backgroundColor = NSColor.black.cgColor
36 previewLayer.videoGravity = .resizeAspect
37 layer?.addSublayer(previewLayer)
38 }
39
40 required init?(coder: NSCoder) { fatalError("init(coder:) unused") }
41
42 override func layout() {
43 super.layout()
44 previewLayer.frame = bounds
45 }
46}
recorder/engine/Sources/recorder/CaptureEngine.swift created+444
...@@ -0,0 +1,444 @@
1import AVFoundation
2import CoreGraphics
3import CoreMedia
4import Foundation
5import ScreenCaptureKit
6
7struct RecordConfig {
8 var outDir: URL
9 var label: String
10 var displayIDs: [CGDirectDisplayID] // empty = all
11 var systemAudio: Bool
12 var micUID: String?
13 var cameraUID: String?
14 var fps: Int
15 var maxWidth: Int // clamp longest side (HiDPI backing buffers are huge)
16 var bitsPerPixel: Double
17 var duration: Double? // auto-stop after N seconds; nil = until signal
18 var logPath: String?
19 var safeDir: URL? // internal-SSD home for audio + rollover parts (nil = outDir)
20 var cameraHeight: Int = 1080 // 720 / 1080 / …
21 var cameraFps: Int = 30
22}
23
24final class CaptureEngine {
25 private let cfg: RecordConfig
26 private var sinks: [RecordingStream] = []
27 private var cfrWriters: [CFRVideoWriter] = []
28 private var streams: [SCStream] = []
29 private var scOutputs: [SCOutput] = []
30 private var captureSessions: [AVCaptureSession] = []
31 private var avOutputs: [AVOutput] = []
32 private var sessionObservers: [NSObjectProtocol] = []
33
34 private var createdEpoch: Double = 0
35 private var hostClockAtStart: Double = 0
36
37 /// Problem reports for the UI (device silent, disk vanished, stream lost…).
38 /// Each distinct condition fires once. Called on a background queue.
39 var onEvent: ((String) -> Void)?
40 private var watchdog: DispatchSourceTimer?
41 private var reportedEvents = Set<String>()
42 private let reportLock = NSLock()
43
44 /// Where audio and rescued streams live: the most reliable disk we have.
45 private var safeRoot: URL { cfg.safeDir ?? cfg.outDir }
46
47 init(_ cfg: RecordConfig) { self.cfg = cfg }
48
49 func start() async throws {
50 if let logPath = cfg.logPath { openLogFile(logPath) }
51 try FileManager.default.createDirectory(at: cfg.outDir, withIntermediateDirectories: true)
52 try? FileManager.default.createDirectory(at: safeRoot, withIntermediateDirectories: true)
53 createdEpoch = Date().timeIntervalSince1970
54 hostClockAtStart = hostSeconds()
55
56 let content = try await SCShareableContent.excludingDesktopWindows(
57 false, onScreenWindowsOnly: false)
58 let allDisplays = content.displays.sorted { $0.frame.origin.x < $1.frame.origin.x }
59
60 let chosen: [SCDisplay]
61 if cfg.displayIDs.isEmpty {
62 chosen = allDisplays
63 } else {
64 chosen = cfg.displayIDs.compactMap { id in allDisplays.first { $0.displayID == id } }
65 }
66 if chosen.isEmpty { throw RecorderError("no matching displays to capture") }
67
68 // Capture system audio piggy-backed on the first display's stream.
69 var systemAudioWriter: StreamWriter?
70 if cfg.systemAudio {
71 systemAudioWriter = try makeAudioWriter(name: "desktop", kind: "system-audio")
72 sinks.append(systemAudioWriter!)
73 }
74
75 for (index, display) in chosen.enumerated() {
76 let name = "screen-\(index + 1)"
77 let (outW, outH) = outputSize(for: display)
78
79 let writer = try CFRVideoWriter(
80 url: cfg.outDir.appendingPathComponent("\(name).mov"),
81 name: name, width: outW, height: outH, fps: cfg.fps, bitrate: screenBitrate(outW, outH),
82 fallbackDir: safeRoot)
83 writer.displayID = display.displayID
84 sinks.append(writer)
85 cfrWriters.append(writer)
86
87 let cfgSC = SCStreamConfiguration()
88 cfgSC.minimumFrameInterval = CMTime(value: 1, timescale: CMTimeScale(cfg.fps))
89 cfgSC.queueDepth = 8
90 cfgSC.showsCursor = true
91 cfgSC.pixelFormat = kCVPixelFormatType_420YpCbCr8BiPlanarVideoRange
92 cfgSC.width = outW
93 cfgSC.height = outH
94
95 let attachAudioHere = (index == 0) && (systemAudioWriter != nil)
96 if attachAudioHere {
97 cfgSC.capturesAudio = true
98 cfgSC.sampleRate = 48_000
99 cfgSC.channelCount = 2
100 }
101
102 let filter = SCContentFilter(display: display, excludingWindows: [])
103 let output = SCOutput(
104 label: name,
105 onScreen: { [weak writer] sb in writer?.update(sb) },
106 onAudio: attachAudioHere
107 ? { [weak systemAudioWriter] sb in systemAudioWriter?.append(sb) } : nil)
108 output.onStreamError = { [weak self] message in
109 self?.report("scstop-\(name)", message + " (stop and restart the session)")
110 }
111 scOutputs.append(output)
112
113 let stream = SCStream(filter: filter, configuration: cfgSC, delegate: output)
114 try stream.addStreamOutput(
115 output, type: .screen,
116 sampleHandlerQueue: DispatchQueue(label: "clover.sc.screen.\(name)"))
117 if attachAudioHere {
118 try stream.addStreamOutput(
119 output, type: .audio,
120 sampleHandlerQueue: DispatchQueue(label: "clover.sc.audio"))
121 }
122 streams.append(stream)
123 }
124
125 if let micUID = cfg.micUID {
126 try startAVCapture(audioUID: micUID)
127 }
128 if let cameraUID = cfg.cameraUID {
129 try startAVCapture(videoUID: cameraUID)
130 }
131
132 for stream in streams {
133 try await stream.startCapture()
134 }
135 // Begin emitting constant-rate frames now that capture is live.
136 for writer in cfrWriters {
137 writer.start()
138 }
139 startWatchdog()
140 logInfo(
141 "recording \(chosen.count) screen(s)"
142 + (cfg.systemAudio ? " + desktop" : "")
143 + (cfg.micUID != nil ? " + mic" : "")
144 + (cfg.cameraUID != nil ? " + camera" : ""))
145 }
146
147 @discardableResult
148 func stop() async -> [StreamManifest] {
149 watchdog?.cancel()
150 watchdog = nil
151 for observer in sessionObservers { NotificationCenter.default.removeObserver(observer) }
152 sessionObservers.removeAll()
153 for output in scOutputs {
154 logInfo(
155 "SC \(output.label): screen seen \(output.screenSeen) complete \(output.screenComplete)"
156 + (output.audioSeen > 0 ? " audio \(output.audioSeen)" : ""))
157 }
158 for stream in streams {
159 try? await stream.stopCapture()
160 }
161 for session in captureSessions {
162 session.stopRunning()
163 }
164 // Give in-flight buffers a moment to drain before finalizing.
165 try? await Task.sleep(nanoseconds: 200_000_000)
166 for sink in sinks {
167 await sink.finish()
168 }
169 return writeManifest()
170 }
171
172 // MARK: Watchdog
173
174 /// Every couple of seconds: is every stream still producing data, and is the
175 /// scratch volume still there? Problems surface immediately (so a dead mic is
176 /// caught seconds in, not discovered after a 4-hour session) and disk loss
177 /// triggers a live rollover onto the internal SSD.
178 private func startWatchdog() {
179 let startedAt = hostSeconds()
180 let t = DispatchSource.makeTimerSource(queue: DispatchQueue(label: "clover.watchdog"))
181 t.schedule(deadline: .now() + 2, repeating: 2)
182 t.setEventHandler { [weak self] in self?.checkHealth(startedAt: startedAt) }
183 watchdog = t
184 t.resume()
185 }
186
187 private func report(_ key: String, _ message: String) {
188 reportLock.lock()
189 let fresh = reportedEvents.insert(key).inserted
190 reportLock.unlock()
191 guard fresh else { return }
192 logErr(message)
193 onEvent?(message)
194 }
195
196 private func checkHealth(startedAt: Double) {
197 // Scratch volume gone? Move every writer still pointing at it onto the
198 // safe (internal) disk before more data piles up in doomed buffers.
199 if let safe = cfg.safeDir, safe.path != cfg.outDir.path,
200 !FileManager.default.fileExists(atPath: cfg.outDir.path)
201 {
202 report(
203 "outdir-gone",
204 "Recording drive disappeared — video continues on the internal SSD. "
205 + "Earlier video is recoverable from the drive once it's reconnected.")
206 for sink in sinks { sink.rollover(to: safe) }
207 }
208
209 let now = hostSeconds()
210 for sink in sinks {
211 let h = sink.health()
212 if h.dead {
213 report(
214 "dead-\(h.name)",
215 "\(h.name): recording stopped after repeated write failures — "
216 + "stop the session and check the disks.")
217 } else if !h.started, h.lastAppendHost.isNaN, now - startedAt > 10 {
218 report(
219 "silent-\(h.name)",
220 "\(h.name) has produced no data — check the device "
221 + "(it may be disconnected or in use by another app).")
222 } else if h.started, !h.lastAppendHost.isNaN, now - h.lastAppendHost > 8 {
223 report(
224 "stall-\(h.name)",
225 "\(h.name) stopped producing data — check the device and disks.")
226 }
227 }
228 }
229
230 // MARK: writers
231
232 private func screenBitrate(_ width: Int, _ height: Int) -> Int {
233 max(2_000_000, Int(Double(width * height * cfg.fps) * cfg.bitsPerPixel))
234 }
235
236 private func cameraPreset(forHeight height: Int) -> (AVCaptureSession.Preset, Int, Int) {
237 switch height {
238 case ...480: return (.vga640x480, 640, 480)
239 case 481...720: return (.hd1280x720, 1280, 720)
240 default: return (.hd1920x1080, 1920, 1080)
241 }
242 }
243
244 /// Best-effort frame-rate lock, clamped to what the camera supports (old
245 /// webcams are often 30-only, so 60 just stays 30).
246 private func configureCameraFrameRate(_ device: AVCaptureDevice) {
247 guard let range = device.activeFormat.videoSupportedFrameRateRanges.first else { return }
248 do {
249 try device.lockForConfiguration()
250 let wanted = CMTime(value: 1, timescale: Int32(cfg.cameraFps))
251 let dur = CMTimeMaximum(range.minFrameDuration, CMTimeMinimum(wanted, range.maxFrameDuration))
252 device.activeVideoMinFrameDuration = dur
253 device.activeVideoMaxFrameDuration = dur
254 device.unlockForConfiguration()
255 } catch {
256 logErr("camera fps: \(error.localizedDescription)")
257 }
258 }
259
260 private func makeVideoWriter(name: String, kind: String, width: Int, height: Int) throws
261 -> StreamWriter
262 {
263 let settings: [String: Any] = [
264 AVVideoCodecKey: AVVideoCodecType.hevc,
265 AVVideoWidthKey: width,
266 AVVideoHeightKey: height,
267 AVVideoCompressionPropertiesKey: [
268 AVVideoAverageBitRateKey: screenBitrate(width, height),
269 AVVideoExpectedSourceFrameRateKey: cfg.fps,
270 AVVideoMaxKeyFrameIntervalKey: cfg.fps * 2,
271 ],
272 ]
273 return try StreamWriter(
274 url: cfg.outDir.appendingPathComponent("\(name).mov"),
275 name: name, kind: kind, fileType: .mov, settings: settings, mediaType: .video,
276 fallbackDir: safeRoot)
277 }
278
279 private func makeAudioWriter(name: String, kind: String) throws -> StreamWriter {
280 // AAC in .m4a: ~256 kbps stereo, transparent for voice/desktop and tiny next
281 // to the uncompressed PCM we used to write. Audio is the irreplaceable
282 // stream and costs ~115 MB/hour, so it records to the safe (internal) disk
283 // rather than the removable scratch drive.
284 let settings: [String: Any] = [
285 AVFormatIDKey: kAudioFormatMPEG4AAC,
286 AVSampleRateKey: 48_000,
287 AVNumberOfChannelsKey: 2,
288 AVEncoderBitRateKey: 256_000,
289 ]
290 return try StreamWriter(
291 url: safeRoot.appendingPathComponent("\(name).m4a"),
292 name: name, kind: kind, fileType: .m4a, settings: settings, mediaType: .audio,
293 fallbackDir: safeRoot)
294 }
295
296 // MARK: AVCapture (mic + camera)
297
298 private func startAVCapture(audioUID: String? = nil, videoUID: String? = nil) throws {
299 let session = AVCaptureSession()
300 session.beginConfiguration()
301
302 if let audioUID {
303 guard let device = Devices.audioDevice(matching: audioUID) else {
304 throw RecorderError("audio device not found: \(audioUID)")
305 }
306 let input = try AVCaptureDeviceInput(device: device)
307 guard session.canAddInput(input) else { throw RecorderError("cannot add mic input") }
308 session.addInput(input)
309
310 let writer = try makeAudioWriter(name: "mic", kind: "mic")
311 writer.deviceUID = device.uniqueID
312 sinks.append(writer)
313
314 let out = AVCaptureAudioDataOutput()
315 let delegate = AVOutput { [weak writer] sb in writer?.append(sb) }
316 avOutputs.append(delegate)
317 out.setSampleBufferDelegate(delegate, queue: DispatchQueue(label: "clover.av.mic"))
318 guard session.canAddOutput(out) else { throw RecorderError("cannot add mic output") }
319 session.addOutput(out)
320 }
321
322 if let videoUID {
323 guard let device = Devices.videoDevice(matching: videoUID) else {
324 throw RecorderError("camera not found: \(videoUID)")
325 }
326 let input = try AVCaptureDeviceInput(device: device)
327 guard session.canAddInput(input) else { throw RecorderError("cannot add camera input") }
328 session.addInput(input)
329
330 // Match the writer to the preset's true dimensions so a 4:3 mode (480p =
331 // 640×480) isn't pillarboxed into 16:9.
332 let (preset, camW, camH) = cameraPreset(forHeight: cfg.cameraHeight)
333 if session.canSetSessionPreset(preset) { session.sessionPreset = preset }
334 configureCameraFrameRate(device)
335
336 let camWriter = try makeVideoWriter(name: "cam", kind: "camera", width: camW, height: camH)
337 camWriter.deviceUID = device.uniqueID
338 sinks.append(camWriter)
339
340 let out = AVCaptureVideoDataOutput()
341 let delegate = AVOutput { [weak camWriter] sb in camWriter?.append(sb) }
342 avOutputs.append(delegate)
343 out.setSampleBufferDelegate(delegate, queue: DispatchQueue(label: "clover.av.cam"))
344 guard session.canAddOutput(out) else { throw RecorderError("cannot add camera output") }
345 session.addOutput(out)
346 }
347
348 session.commitConfiguration()
349 // Runtime errors (device wedged, media services reset) otherwise vanish
350 // silently — the watchdog would notice the stall, but this names the cause.
351 let observer = NotificationCenter.default.addObserver(
352 forName: .AVCaptureSessionRuntimeError, object: session, queue: nil
353 ) { [weak self] note in
354 let reason =
355 (note.userInfo?[AVCaptureSessionErrorKey] as? NSError)?.localizedDescription ?? "unknown"
356 self?.report("avsession-\(reason)", "Mic/camera capture error: \(reason)")
357 }
358 sessionObservers.append(observer)
359 session.startRunning()
360 captureSessions.append(session)
361 }
362
363 // MARK: helpers
364
365 private func outputSize(for display: SCDisplay) -> (Int, Int) {
366 // Prefer the true framebuffer pixel size; fall back to the points frame.
367 var pxW = display.width
368 var pxH = display.height
369 if let mode = CGDisplayCopyDisplayMode(display.displayID) {
370 pxW = mode.pixelWidth
371 pxH = mode.pixelHeight
372 }
373 let longest = max(pxW, pxH)
374 guard longest > cfg.maxWidth, cfg.maxWidth > 0 else { return (even(pxW), even(pxH)) }
375 let scale = Double(cfg.maxWidth) / Double(longest)
376 return (even(Int(Double(pxW) * scale)), even(Int(Double(pxH) * scale)))
377 }
378
379 private func even(_ v: Int) -> Int { v - (v % 2) }
380
381 @discardableResult
382 private func writeManifest() -> [StreamManifest] {
383 let all = sinks.flatMap { $0.manifests() }
384 let tStart = all.map { $0.firstSampleHostSeconds }.filter { !$0.isNaN }.min()
385 ?? hostClockAtStart
386
387 var streamManifests = all.map { m -> StreamManifest in
388 var m = m
389 m.offsetSeconds = m.firstSampleHostSeconds.isNaN ? 0 : (m.firstSampleHostSeconds - tStart)
390 return m
391 }
392 streamManifests.sort { $0.offsetSeconds < $1.offsetSeconds }
393
394 let manifest = SessionManifest(
395 recorderVersion: recorderVersion,
396 label: cfg.label,
397 createdEpoch: createdEpoch,
398 hostClockAtStart: hostClockAtStart,
399 tStartHostSeconds: tStart,
400 streams: streamManifests)
401
402 // Write to both roots (they may be different volumes); losing one disk
403 // must not cost the alignment data for the surviving streams.
404 var targets = [cfg.outDir]
405 if safeRoot.path != cfg.outDir.path { targets.append(safeRoot) }
406 var wrote = false
407 do {
408 let encoder = JSONEncoder()
409 encoder.outputFormatting = [.prettyPrinted, .sortedKeys]
410 let data = try encoder.encode(manifest)
411 for dir in targets {
412 do {
413 try data.write(to: dir.appendingPathComponent("sync.json"))
414 wrote = true
415 } catch {
416 logErr("failed to write manifest to \(dir.path): \(error)")
417 }
418 }
419 } catch {
420 logErr("failed to encode manifest: \(error)")
421 }
422 if !wrote { report("manifest", "Couldn't save sync.json — stream alignment data was lost.") }
423
424 // Human-readable summary to stderr.
425 logInfo("session: \(cfg.outDir.path)")
426 for m in streamManifests {
427 let size = fileSize(cfg.outDir.appendingPathComponent(m.file))
428 logInfo(
429 String(
430 format: " %-13@ %6.2fs %5d frames drop %-3d rep %-4d off %+0.3fs %@",
431 m.name as NSString, m.durationSeconds, m.frames, m.dropped, m.repeated,
432 m.offsetSeconds, size as NSString))
433 }
434 return streamManifests
435 }
436
437 private func fileSize(_ url: URL) -> String {
438 guard let attrs = try? FileManager.default.attributesOfItem(atPath: url.path),
439 let bytes = attrs[.size] as? Int64
440 else { return "—" }
441 let mb = Double(bytes) / 1_048_576
442 return String(format: "%.1f MB", mb)
443 }
444}
recorder/engine/Sources/recorder/MarkerOverlay.swift created+232
...@@ -0,0 +1,232 @@
1import AVFoundation
2import AppKit
3import Carbon.HIToolbox
4import SwiftUI
5
6// MARK: - Global hotkey
7
8/// A system-wide hotkey via the Carbon Event Manager. Unlike an NSEvent global
9/// monitor this needs no Accessibility/Input-Monitoring grant, and it consumes
10/// the key so nothing else sees it. Fires on the main thread.
11private func hotKeyEventHandler(
12 _ next: EventHandlerCallRef?, _ event: EventRef?, _ userData: UnsafeMutableRawPointer?
13) -> OSStatus {
14 guard let userData else { return noErr }
15 Unmanaged<HotKey>.fromOpaque(userData).takeUnretainedValue().onPress()
16 return noErr
17}
18
19final class HotKey {
20 let onPress: () -> Void
21 private var hotKeyRef: EventHotKeyRef?
22 private var eventHandler: EventHandlerRef?
23
24 init?(keyCode: UInt32, modifiers: UInt32 = 0, onPress: @escaping () -> Void) {
25 self.onPress = onPress
26 var spec = EventTypeSpec(
27 eventClass: OSType(kEventClassKeyboard), eventKind: UInt32(kEventHotKeyPressed))
28 guard
29 InstallEventHandler(
30 GetApplicationEventTarget(), hotKeyEventHandler, 1, &spec,
31 Unmanaged.passUnretained(self).toOpaque(), &eventHandler) == noErr
32 else { return nil }
33
34 let id = EventHotKeyID(signature: OSType(0x434C_5652), id: 1) // 'CLVR'
35 guard
36 RegisterEventHotKey(keyCode, modifiers, id, GetApplicationEventTarget(), 0, &hotKeyRef)
37 == noErr
38 else { return nil }
39 }
40
41 deinit {
42 if let hotKeyRef { UnregisterEventHotKey(hotKeyRef) }
43 if let eventHandler { RemoveEventHandler(eventHandler) }
44 }
45}
46
47// MARK: - Marker model
48
49struct Marker: Codable {
50 let hostSeconds: Double // mach host clock — align against sync.json
51 let offsetSeconds: Double // from session start, for humans
52 let text: String?
53 let wallClock: String
54}
55
56// MARK: - Overlay panel
57
58/// Borderless floating panel that can take key focus so you can type into it.
59final class MarkerPanel: NSPanel {
60 override var canBecomeKey: Bool { true }
61}
62
63// MARK: - Identify overlay
64
65/// Big number flashed on a physical display so you can see which is screen 1/2.
66struct IdentifyView: View {
67 let number: Int
68 var body: some View {
69 Text("\(number)")
70 .font(.system(size: 150, weight: .bold, design: .rounded))
71 .foregroundStyle(.white)
72 .frame(width: 230, height: 230)
73 .background(.blue.opacity(0.85), in: RoundedRectangle(cornerRadius: 30))
74 }
75}
76
77// MARK: - Voice dictation (local Whisper)
78
79/// Records the mic while the marker overlay is open and transcribes it on demand
80/// via on-device MLX Whisper (large-v3-turbo) — ~1.5 s per note on this machine.
81@MainActor
82final class DictationModel: ObservableObject {
83 enum Phase { case idle, listening, transcribing }
84
85 @Published var text = ""
86 @Published var phase: Phase = .idle
87
88 private var recorder: AVAudioRecorder?
89 private let audioURL = FileManager.default.temporaryDirectory
90 .appendingPathComponent("clover-marker.wav")
91
92 static let pythonPath = NSHomeDirectory() + "/.clover-whisper/.venv/bin/python"
93 static var scriptPath: String {
94 Bundle.main.url(forResource: "transcribe", withExtension: "py")?.path ?? ""
95 }
96 var available: Bool {
97 FileManager.default.isExecutableFile(atPath: Self.pythonPath) && !Self.scriptPath.isEmpty
98 }
99
100 func startListening() {
101 guard available else { return }
102 let settings: [String: Any] = [
103 AVFormatIDKey: kAudioFormatLinearPCM,
104 AVSampleRateKey: 16_000,
105 AVNumberOfChannelsKey: 1,
106 AVLinearPCMBitDepthKey: 16,
107 AVLinearPCMIsFloatKey: false,
108 AVLinearPCMIsBigEndianKey: false,
109 ]
110 do {
111 let rec = try AVAudioRecorder(url: audioURL, settings: settings)
112 rec.record()
113 recorder = rec
114 phase = .listening
115 } catch {
116 phase = .idle
117 }
118 }
119
120 func stopAndTranscribe() async {
121 guard phase == .listening else { return }
122 recorder?.stop()
123 recorder = nil
124 phase = .transcribing
125 let result = await Self.run(Self.pythonPath, [Self.scriptPath, audioURL.path])
126 if !result.isEmpty { text = result }
127 phase = .idle
128 }
129
130 func cancel() {
131 recorder?.stop()
132 recorder = nil
133 phase = .idle
134 }
135
136 private static func run(_ path: String, _ args: [String]) async -> String {
137 await withCheckedContinuation { (cont: CheckedContinuation<String, Never>) in
138 DispatchQueue.global(qos: .userInitiated).async {
139 let p = Process()
140 p.executableURL = URL(fileURLWithPath: path)
141 p.arguments = args
142 p.environment = cloverToolEnvironment()
143 let pipe = Pipe()
144 p.standardOutput = pipe
145 p.standardError = Pipe()
146 do { try p.run() } catch {
147 cont.resume(returning: "")
148 return
149 }
150 let data = pipe.fileHandleForReading.readDataToEndOfFile()
151 p.waitUntilExit()
152 cont.resume(
153 returning: String(decoding: data, as: UTF8.self)
154 .trimmingCharacters(in: .whitespacesAndNewlines))
155 }
156 }
157 }
158}
159
160struct MarkerOverlayView: View {
161 let offsetText: String
162 let onSubmit: (String) -> Void
163 let onCancel: () -> Void
164
165 @StateObject private var dictation = DictationModel()
166 @FocusState private var focused: Bool
167
168 var body: some View {
169 HStack(spacing: 10) {
170 Image(systemName: icon).foregroundStyle(iconColor).font(.title3)
171 Text(offsetText)
172 .font(.system(.callout, design: .monospaced)).foregroundStyle(.secondary)
173 TextField(placeholder, text: $dictation.text)
174 .textFieldStyle(.plain)
175 .focused($focused)
176 .onSubmit { handleEnter() }
177 .frame(width: 320)
178 // Typing while listening switches to manual entry.
179 .onChange(of: dictation.text) { _, newValue in
180 if dictation.phase == .listening && !newValue.isEmpty { dictation.cancel() }
181 }
182 }
183 .padding(.horizontal, 16)
184 .padding(.vertical, 12)
185 .background(.regularMaterial, in: RoundedRectangle(cornerRadius: 14))
186 .overlay(RoundedRectangle(cornerRadius: 14).strokeBorder(iconColor.opacity(0.5), lineWidth: 1))
187 .onAppear {
188 focused = true
189 dictation.startListening()
190 }
191 .onKeyPress(.escape) {
192 dictation.cancel()
193 onCancel()
194 return .handled
195 }
196 }
197
198 private func handleEnter() {
199 switch dictation.phase {
200 case .listening:
201 Task { await dictation.stopAndTranscribe() }
202 case .transcribing:
203 break
204 case .idle:
205 onSubmit(dictation.text)
206 }
207 }
208
209 private var icon: String {
210 switch dictation.phase {
211 case .listening: return "mic.fill"
212 case .transcribing: return "waveform"
213 case .idle: return "mappin.circle.fill"
214 }
215 }
216 private var iconColor: Color {
217 switch dictation.phase {
218 case .listening: return .red
219 case .transcribing: return .blue
220 case .idle: return .orange
221 }
222 }
223 private var placeholder: String {
224 switch dictation.phase {
225 case .listening: return "Listening… speak, then Enter to transcribe (or just type)"
226 case .transcribing: return "Transcribing…"
227 case .idle:
228 return dictation.available
229 ? "Enter to drop · Esc to cancel" : "Marker note (optional) — Enter to drop"
230 }
231 }
232}
recorder/engine/Sources/recorder/MenubarApp.swift created+1569
...@@ -0,0 +1,1569 @@
1import AppKit
2import AVFoundation
3import Carbon.HIToolbox
4import CoreMedia
5import Foundation
6import SwiftUI
7import UserNotifications
8
9// MARK: - Entry
10
11/// Environment with the Nix/Homebrew bins on PATH so spawned Python tools can
12/// find ffmpeg (a GUI app launched via Finder/`open` has a bare PATH otherwise).
13func cloverToolEnvironment() -> [String: String] {
14 var env = ProcessInfo.processInfo.environment
15 let dirs = [
16 "/etc/profiles/per-user/\(NSUserName())/bin", "/run/current-system/sw/bin",
17 "/opt/homebrew/bin", "/usr/local/bin",
18 ]
19 env["PATH"] = dirs.joined(separator: ":") + ":" + (env["PATH"] ?? "/usr/bin:/bin")
20 return env
21}
22
23@MainActor
24func runMenubar() {
25 let app = NSApplication.shared
26 let controller = AppController()
27 app.delegate = controller
28 app.setActivationPolicy(.accessory) // menubar only, no Dock icon
29 app.run()
30}
31
32// MARK: - Controller
33
34@MainActor
35final class AppController: NSObject, NSApplicationDelegate, NSPopoverDelegate, NSWindowDelegate,
36 ObservableObject
37{
38 // Settings (persisted)
39 @Published var destination: String = UserDefaults.standard.string(forKey: "destination") ?? "Sessions"
40 {
41 didSet {
42 // Sessions default to recording REAPER; Journal defaults to not. You can
43 // still override the checkbox afterwards.
44 guard destination != oldValue else { return }
45 reaperMidi = (destination == "Sessions")
46 }
47 }
48 @Published var includeDesktop = UserDefaults.standard.object(forKey: "desktop") as? Bool ?? true
49 @Published var includeMic = UserDefaults.standard.object(forKey: "mic") as? Bool ?? true
50 @Published var reaperMidi = UserDefaults.standard.object(forKey: "reaper") as? Bool ?? false
51 // Off by default: solo journaling/improv gets one clean voice. On only for the
52 // occasional session with other people (diarize + name via the Speakers review).
53 @Published var detectSpeakers = UserDefaults.standard.object(forKey: "detectSpeakers") as? Bool ?? false
54 @Published var reverseScreens = UserDefaults.standard.object(forKey: "reverseScreens") as? Bool ?? false
55 @Published var enabledDisplays: Set<UInt32> = []
56
57 @Published var includeCamera = UserDefaults.standard.object(forKey: "camera") as? Bool ?? false {
58 didSet { updatePreview() }
59 }
60 @Published var cameraUID: String? = UserDefaults.standard.string(forKey: "cameraUID") {
61 didSet { if oldValue != cameraUID { restartPreview() } }
62 }
63 @Published var cameraHeight = UserDefaults.standard.object(forKey: "cameraHeight") as? Int ?? 1080
64 {
65 didSet { if oldValue != cameraHeight { restartPreview() } } // re-apply aspect/format
66 }
67 @Published var cameraFps = UserDefaults.standard.object(forKey: "cameraFps") as? Int ?? 30
68 @Published var cameraAntiFlickerHz: Int =
69 UserDefaults.standard.object(forKey: "antiFlicker") as? Int ?? 60
70 {
71 didSet { if includeCamera { applyAntiFlicker() } }
72 }
73 @Published var cameras: [DeviceInfo] = []
74 @Published var previewSession: AVCaptureSession?
75 /// Actual width/height ratio the camera delivers, observed from its active
76 /// format — the preview frame uses this so the feed never letterboxes.
77 @Published var observedCameraAspect: CGFloat?
78 var hasCamera: Bool { !cameras.isEmpty }
79
80 /// Displays in screen-number order (left→right, or reversed if you flip it).
81 var orderedDisplays: [DisplayInfo] {
82 reverseScreens ? displays.reversed() : displays
83 }
84
85 // Discovered hardware
86 @Published var displays: [DisplayInfo] = []
87 @Published var audioInputs: [DeviceInfo] = [] // real mics only (webcam mic filtered out)
88 @Published var micUID: String? = UserDefaults.standard.string(forKey: "micUID") {
89 didSet { if !settingMicProgrammatically { micExplicit = true } }
90 }
91 /// True once the user picks a mic by hand — lets refreshDevices re-default away
92 /// from undesirable mics (webcam/Continuity) without clobbering a deliberate pick.
93 private var micExplicit = UserDefaults.standard.bool(forKey: "micExplicit")
94 private var settingMicProgrammatically = false
95 @Published var permissionOK = true
96 var hasMic: Bool { !audioInputs.isEmpty }
97
98 // Live state
99 @Published var isRecording = false
100 @Published var elapsed: TimeInterval = 0
101 @Published var markerCount = 0
102 @Published var lastSession: String?
103 @Published var compressProgress: Double? // 0…1 while re-encoding, else nil
104 @Published var errorMessage: String? {
105 didSet { updateIcon() } // problem state shows in the menubar, not just here
106 }
107 @Published var voiceEnrolled = FileManager.default.fileExists(
108 atPath: NSHomeDirectory() + "/.clover-whisper/voices.json")
109 @Published var enrolling = false
110 @Published var enrollSecondsLeft = 0
111 private var lastSessionURL: URL?
112 private var enrollRecorder: AVAudioRecorder?
113 private var enrollTimer: Timer?
114 private var speakersWindow: NSWindow?
115 private var cameraPopout: NSWindow?
116
117 private var statusItem: NSStatusItem?
118 private var popover: NSPopover?
119 private var engine: CaptureEngine?
120 private var starting = false
121 private var startHost: Double = 0
122 private var tickTimer: Timer?
123 private var currentSessionDir: URL?
124 private var currentSafeDir: URL?
125 private var zenithDir: URL?
126 private var postProcessing = false
127 private var reconciling = false
128 private var reconcileTimer: Timer?
129 private var hotKey: HotKey?
130 private var markers: [Marker] = []
131 private var markerPanel: MarkerPanel?
132 private var identifyWindows: [NSWindow] = []
133
134 // Fast local scratch for video; the NAS archive is the final home. Audio and
135 // anything rescued mid-session live under recoveryRoot on the internal SSD,
136 // which can't disconnect the way an external volume can.
137 let tempRoot = URL(fileURLWithPath: "/Volumes/Documents/Temp")
138 let recoveryRoot = URL(fileURLWithPath: NSHomeDirectory() + "/Movies/Clover Recovery")
139 let archiveRoot = URL(fileURLWithPath: "/Volumes/clover/Archive")
140 let reaperTemplate = URL(fileURLWithPath: "/Volumes/Documents/Recorder Template.rpp")
141 @Published var statusText: String?
142
143 func applicationDidFinishLaunching(_ notification: Notification) {
144 let item = NSStatusBar.system.statusItem(withLength: NSStatusItem.variableLength)
145 item.button?.image = NSImage(
146 systemSymbolName: "record.circle", accessibilityDescription: "Clover Recorder")
147 item.button?.action = #selector(togglePopover)
148 item.button?.target = self
149 statusItem = item
150
151 let pop = NSPopover()
152 pop.behavior = .transient
153 pop.delegate = self
154 pop.contentViewController = NSHostingController(rootView: ContentView(controller: self))
155 popover = pop
156
157 // F14 anywhere drops a session marker (Carbon hotkey — no extra permission).
158 hotKey = HotKey(keyCode: UInt32(kVK_F14)) { [weak self] in
159 MainActor.assumeIsolated { self?.markerPressed() }
160 }
161
162 setupNotifications()
163 Task { await refreshDevices() }
164
165 // Sessions stranded locally (zenith was down, a crash, a failed archive)
166 // are retried at launch and periodically while idle.
167 Task { await reconcilePending() }
168 reconcileTimer = Timer.scheduledTimer(withTimeInterval: 900, repeats: true) { [weak self] _ in
169 Task { @MainActor in await self?.reconcilePending() }
170 }
171 }
172
173 // MARK: Problem reporting
174
175 /// One path for anything going wrong: inline message in the popover, an
176 /// orange warning in the menubar, and a system notification (recordings run
177 /// unattended — a problem must not wait for the popover to be opened).
178 func reportProblem(_ message: String) {
179 errorMessage = message
180 notify(message)
181 }
182
183 private func setupNotifications() {
184 guard Bundle.main.bundleIdentifier != nil else { return }
185 UNUserNotificationCenter.current().requestAuthorization(options: [.alert, .sound]) { _, _ in }
186 }
187
188 private func notify(_ body: String, title: String = "Clover Recorder") {
189 guard Bundle.main.bundleIdentifier != nil else { return }
190 let content = UNMutableNotificationContent()
191 content.title = title
192 content.body = body
193 UNUserNotificationCenter.current().add(
194 UNNotificationRequest(identifier: UUID().uuidString, content: content, trigger: nil))
195 }
196
197 // MARK: Markers
198
199 func markerPressed() {
200 guard isRecording, let dir = currentSessionDir, markerPanel == nil else { return }
201 let host = hostSeconds()
202 showMarkerOverlay(host: host, offset: host - startHost, dir: dir)
203 }
204
205 private func showMarkerOverlay(host: Double, offset: Double, dir: URL) {
206 let panel = MarkerPanel(
207 contentRect: NSRect(x: 0, y: 0, width: 420, height: 56),
208 styleMask: [.borderless], backing: .buffered, defer: false)
209 panel.level = .floating
210 panel.isOpaque = false
211 panel.backgroundColor = .clear
212 panel.hasShadow = true
213 panel.isMovableByWindowBackground = true
214 panel.contentViewController = NSHostingController(
215 rootView: MarkerOverlayView(
216 offsetText: clockString(offset),
217 onSubmit: { [weak self] text in
218 self?.commitMarker(host: host, offset: offset, text: text, dir: dir)
219 },
220 onCancel: { [weak self] in self?.dismissMarkerOverlay() }))
221 if let screen = NSScreen.main {
222 let f = screen.frame
223 panel.setFrameOrigin(NSPoint(x: f.midX - panel.frame.width / 2, y: f.maxY - 220))
224 }
225 markerPanel = panel
226 NSApp.activate(ignoringOtherApps: true)
227 panel.makeKeyAndOrderFront(nil)
228 }
229
230 private func commitMarker(host: Double, offset: Double, text: String, dir: URL) {
231 let trimmed = text.trimmingCharacters(in: .whitespacesAndNewlines)
232 markers.append(
233 Marker(
234 hostSeconds: host, offsetSeconds: offset,
235 text: trimmed.isEmpty ? nil : trimmed,
236 wallClock: ISO8601DateFormatter().string(from: Date())))
237 markerCount = markers.count
238 writeMarkers(to: dir)
239 dismissMarkerOverlay()
240 }
241
242 private func dismissMarkerOverlay() {
243 markerPanel?.orderOut(nil)
244 markerPanel = nil
245 }
246
247 private func writeMarkers(to dir: URL) {
248 let encoder = JSONEncoder()
249 encoder.outputFormatting = [.prettyPrinted]
250 guard let data = try? encoder.encode(MarkersFile(markers: markers)) else { return }
251 // Written to the scratch AND the internal recovery dir, so markers survive
252 // either volume disappearing mid-session.
253 var targets = [dir]
254 if let safe = currentSafeDir, safe != dir { targets.append(safe) }
255 var ok = false
256 for t in targets {
257 if (try? data.write(to: t.appendingPathComponent("markers.json"))) != nil { ok = true }
258 }
259 if !ok { reportProblem("Couldn't save markers — check the recording disks.") }
260 }
261
262 private func clockString(_ t: TimeInterval) -> String {
263 let s = Int(t)
264 return String(format: "%02d:%02d", s / 60, s % 60)
265 }
266
267 @objc private func togglePopover() {
268 guard let button = statusItem?.button, let popover else { return }
269 if popover.isShown {
270 popover.performClose(nil)
271 } else {
272 Task {
273 await refreshDevices() // re-check displays + mic + cameras each time it opens
274 updatePreview()
275 }
276 popover.show(relativeTo: button.bounds, of: button, preferredEdge: .minY)
277 popover.contentViewController?.view.window?.makeKey()
278 }
279 }
280
281 func refreshDevices() async {
282 do {
283 let devices = try await Devices.discover()
284 displays = devices.displays
285 cameras = devices.cameras
286 audioInputs = devices.audioInputs // all selectable
287
288 // Never DEFAULT to a webcam mic (shares a camera's name) or a Continuity
289 // (iPhone/iPad) mic — but leave them pickable. The engine flags Continuity
290 // mics via transport type; also treat any mic named like a camera as one.
291 let camNames = Set(devices.cameras.map { $0.name })
292 func deprioritized(_ d: DeviceInfo) -> Bool { d.continuity || camNames.contains(d.name) }
293 // Re-default if unset, gone, or auto-pointing at a deprioritized device. A
294 // mic the user picked by hand is left alone even if it's a webcam/Continuity.
295 let current = audioInputs.first { $0.uid == micUID }
296 if current == nil || (!micExplicit && deprioritized(current!)) {
297 settingMicProgrammatically = true
298 micUID = (audioInputs.first { !deprioritized($0) } ?? audioInputs.first)?.uid
299 settingMicProgrammatically = false
300 }
301 if cameraUID == nil || !cameras.contains(where: { $0.uid == cameraUID }) {
302 cameraUID = (cameras.first { !$0.continuity } ?? cameras.first)?.uid
303 }
304 permissionOK = true
305 // Display IDs change across sleep/wake, so drop stale selections and fall
306 // back to all current displays if nothing valid remains.
307 let currentIDs = Set(devices.displays.map { $0.id })
308 enabledDisplays.formIntersection(currentIDs)
309 if enabledDisplays.isEmpty { enabledDisplays = currentIDs }
310 } catch {
311 permissionOK = false
312 errorMessage = "Screen Recording permission needed."
313 }
314 }
315
316 func setDisplay(_ id: UInt32, on: Bool) {
317 if on { enabledDisplays.insert(id) } else { enabledDisplays.remove(id) }
318 }
319
320 /// Flash the screen number on each physical display, in recorder order, so you
321 /// can see which monitor is screen-1 / screen-2.
322 func identifyScreens() {
323 dismissIdentify()
324 for (idx, d) in orderedDisplays.enumerated() {
325 guard let screen = NSScreen.screens.first(where: { screenNumber($0) == d.id }) else {
326 continue
327 }
328 let size = NSSize(width: 230, height: 230)
329 let frame = NSRect(
330 x: screen.frame.midX - size.width / 2, y: screen.frame.midY - size.height / 2,
331 width: size.width, height: size.height)
332 let win = NSPanel(
333 contentRect: frame, styleMask: [.borderless, .nonactivatingPanel], backing: .buffered,
334 defer: false)
335 win.level = .screenSaver
336 win.isOpaque = false
337 win.backgroundColor = .clear
338 win.hasShadow = false
339 win.ignoresMouseEvents = true
340 win.collectionBehavior = [.canJoinAllSpaces, .stationary]
341 win.contentViewController = NSHostingController(rootView: IdentifyView(number: idx + 1))
342 win.orderFrontRegardless()
343 identifyWindows.append(win)
344 }
345 DispatchQueue.main.asyncAfter(deadline: .now() + 2.5) { [weak self] in self?.dismissIdentify() }
346 }
347
348 private func dismissIdentify() {
349 identifyWindows.forEach { $0.orderOut(nil) }
350 identifyWindows.removeAll()
351 }
352
353 private func screenNumber(_ screen: NSScreen) -> UInt32? {
354 (screen.deviceDescription[NSDeviceDescriptionKey("NSScreenNumber")] as? NSNumber)?.uint32Value
355 }
356
357 // MARK: Camera preview
358
359 /// Run the preview whenever the camera's on and somewhere is showing it (the
360 /// popover or the pop-out window) — including during recording (macOS shares
361 /// the camera across sessions).
362 func updatePreview() {
363 let want = includeCamera && hasCamera && (popover?.isShown == true || cameraPopout != nil)
364 if want && previewSession == nil {
365 startPreviewSession()
366 } else if !want && previewSession != nil {
367 stopPreview()
368 }
369 }
370
371 func restartPreview() {
372 if previewSession != nil {
373 stopPreview()
374 updatePreview()
375 }
376 }
377
378 /// Preset matching the selected recording quality, so the preview's aspect/FOV
379 /// equals the output's (480p = 4:3, 720p/1080p = 16:9).
380 func cameraPreset() -> AVCaptureSession.Preset {
381 switch cameraHeight {
382 case ...480: return .vga640x480
383 case 481...720: return .hd1280x720
384 default: return .hd1920x1080
385 }
386 }
387
388 /// Real aspect once we've seen a frame's format; otherwise the preset's guess.
389 var cameraAspect: CGFloat {
390 observedCameraAspect ?? (cameraHeight <= 480 ? 4.0 / 3.0 : 16.0 / 9.0)
391 }
392
393 private func startPreviewSession() {
394 guard let uid = cameraUID, let device = Devices.videoDevice(matching: uid),
395 let input = try? AVCaptureDeviceInput(device: device)
396 else { return }
397 let session = AVCaptureSession()
398 session.sessionPreset = cameraPreset()
399 guard session.canAddInput(input) else { return }
400 session.addInput(input)
401 // Size the preview to what the camera actually outputs, not the preset's
402 // nominal aspect — many webcams ignore a 4:3 preset and stay 16:9.
403 let dims = CMVideoFormatDescriptionGetDimensions(device.activeFormat.formatDescription)
404 observedCameraAspect =
405 dims.width > 0 && dims.height > 0 ? CGFloat(dims.width) / CGFloat(dims.height) : nil
406 previewSession = session
407 DispatchQueue.global(qos: .userInitiated).async { session.startRunning() }
408 DispatchQueue.main.asyncAfter(deadline: .now() + 0.6) { [weak self] in self?.applyAntiFlicker() }
409 }
410
411 func stopPreview() {
412 if let session = previewSession {
413 DispatchQueue.global(qos: .userInitiated).async { session.stopRunning() }
414 }
415 previewSession = nil
416 }
417
418 func toggleCameraPopout() {
419 if let win = cameraPopout {
420 win.close() // windowWillClose clears the ref and re-evaluates the preview
421 return
422 }
423 let win = NSWindow(
424 contentRect: NSRect(x: 0, y: 0, width: 480, height: 480 / cameraAspect),
425 styleMask: [.titled, .closable, .resizable], backing: .buffered, defer: false)
426 win.title = "Camera"
427 win.contentAspectRatio = NSSize(width: cameraAspect, height: 1)
428 win.contentViewController = NSHostingController(rootView: CameraPopoutView(controller: self))
429 win.isReleasedWhenClosed = false
430 win.level = .floating
431 win.delegate = self
432 win.center()
433 cameraPopout = win
434 updatePreview()
435 NSApp.activate(ignoringOtherApps: true)
436 win.makeKeyAndOrderFront(nil)
437 }
438
439 func windowWillClose(_ notification: Notification) {
440 if (notification.object as? NSWindow) === cameraPopout {
441 cameraPopout = nil
442 updatePreview()
443 }
444 }
445
446 /// Push the UVC Power Line Frequency to the camera (kills mains flicker). Best
447 /// applied once the camera is already streaming; safe to call repeatedly.
448 func applyAntiFlicker() {
449 let helper = Bundle.main.bundleURL.appendingPathComponent("Contents/MacOS/uvc-powerline")
450 guard FileManager.default.isExecutableFile(atPath: helper.path) else { return }
451 let arg: String
452 switch cameraAntiFlickerHz {
453 case 50: arg = "1"
454 case 60: arg = "2"
455 default: arg = "0"
456 }
457 let process = Process()
458 process.executableURL = helper
459 process.arguments = [arg]
460 DispatchQueue.global(qos: .utility).async { try? process.run() }
461 }
462
463 func popoverDidClose(_ notification: Notification) {
464 updatePreview() // keep running if the pop-out window is up
465 }
466
467 private func persist() {
468 let d = UserDefaults.standard
469 d.set(destination, forKey: "destination")
470 d.set(includeDesktop, forKey: "desktop")
471 d.set(includeMic, forKey: "mic")
472 d.set(micUID, forKey: "micUID")
473 d.set(micExplicit, forKey: "micExplicit")
474 d.set(reaperMidi, forKey: "reaper")
475 d.set(detectSpeakers, forKey: "detectSpeakers")
476 d.set(reverseScreens, forKey: "reverseScreens")
477 d.set(includeCamera, forKey: "camera")
478 d.set(cameraUID, forKey: "cameraUID")
479 d.set(cameraHeight, forKey: "cameraHeight")
480 d.set(cameraFps, forKey: "cameraFps")
481 d.set(cameraAntiFlickerHz, forKey: "antiFlicker")
482 }
483
484 // MARK: recording
485
486 func start() {
487 guard !isRecording, !starting else { return }
488 starting = true
489 persist()
490 errorMessage = nil
491 statusText = "Checking zenith…"
492 Task {
493 // zenith being down must never block a recording — archive later instead.
494 let zenithOK = await self.ensureZenithMounted()
495 if !zenithOK {
496 self.notify("zenith isn't mounted — recording locally; it will archive when zenith is back.")
497 }
498 await self.refreshDevices() // display IDs shift across sleep/wake
499 await self.beginRecording(zenithOK: zenithOK)
500 self.starting = false
501 }
502 }
503
504 private func beginRecording(zenithOK: Bool) async {
505 let name = resolveSessionName()
506 // Big video goes to the scratch drive; if that's missing, everything
507 // records to the internal recovery dir rather than blocking the session.
508 var temp = tempRoot.appendingPathComponent(name)
509 let safe = recoveryRoot.appendingPathComponent(name)
510 do {
511 try FileManager.default.createDirectory(at: temp, withIntermediateDirectories: true)
512 } catch {
513 temp = safe
514 reportProblem("Scratch drive unavailable — recording to the internal SSD instead.")
515 }
516 do {
517 try FileManager.default.createDirectory(at: safe, withIntermediateDirectories: true)
518 } catch {
519 statusText = nil
520 reportProblem("Can't create \(safe.path) (\(error.localizedDescription)) — not recording.")
521 return
522 }
523
524 // The final NAS home — created now so REAPER can record straight into it.
525 var zenithReady: URL? = nil
526 if zenithOK {
527 let zenith = zenithSessionDir(name)
528 do {
529 try FileManager.default.createDirectory(at: zenith, withIntermediateDirectories: true)
530 zenithReady = zenith
531 } catch {
532 reportProblem(
533 "Archive folder unavailable (\(error.localizedDescription)); will keep a local copy.")
534 }
535 }
536 writeSessionStamp(to: temp, name: name)
537 if safe != temp { writeSessionStamp(to: safe, name: name) }
538
539 // Record only currently-available displays, in screen-number order so the
540 // files are deterministic (screen-1, screen-2, …) rather than Set order.
541 let current = Set(displays.map { $0.id })
542 var enabled = enabledDisplays.intersection(current)
543 if enabled.isEmpty { enabled = current }
544 let displayIDs = orderedDisplays.map { $0.id }.filter { enabled.contains($0) }
545
546 let cfg = RecordConfig(
547 outDir: temp,
548 label: name,
549 displayIDs: displayIDs,
550 systemAudio: includeDesktop,
551 micUID: (includeMic && hasMic) ? micUID : nil,
552 cameraUID: (includeCamera && hasCamera) ? cameraUID : nil,
553 fps: 30,
554 maxWidth: 3840,
555 bitsPerPixel: 0.04,
556 duration: nil,
557 logPath: safe.appendingPathComponent("recorder.log").path,
558 safeDir: safe,
559 cameraHeight: cameraHeight, cameraFps: cameraFps)
560
561 // Start capture FIRST; only wire up REAPER + UI once it's confirmed live, so
562 // a failure cleans up instead of leaving empty session folders / a stray
563 // REAPER project behind.
564 let engine = CaptureEngine(cfg)
565 engine.onEvent = { [weak self] message in
566 Task { @MainActor in self?.reportProblem(message) }
567 }
568 statusText = "Starting…"
569 do {
570 try await engine.start()
571 } catch {
572 statusText = nil
573 reportProblem(error.localizedDescription)
574 if "\(error)".contains("declined") { permissionOK = false }
575 try? FileManager.default.removeItem(at: temp)
576 try? FileManager.default.removeItem(at: safe)
577 if let z = zenithReady { try? FileManager.default.removeItem(at: z) }
578 return
579 }
580
581 self.engine = engine
582 currentSessionDir = temp
583 currentSafeDir = safe
584 zenithDir = zenithReady
585 if reaperMidi { setupReaper(name: name) }
586 if includeCamera && hasCamera {
587 DispatchQueue.main.asyncAfter(deadline: .now() + 0.8) { [weak self] in self?.applyAntiFlicker() }
588 }
589 markers = []
590 markerCount = 0
591 isRecording = true
592 elapsed = 0
593 startHost = hostSeconds()
594 statusText = "Recording…"
595 updateIcon()
596 startTick()
597 }
598
599 func stop() {
600 guard isRecording, let engine else { return }
601 isRecording = false
602 stopTick()
603 dismissMarkerOverlay()
604 updateIcon()
605 statusText = "Finalizing…"
606 let temp = currentSessionDir
607 let safe = currentSafeDir
608 let zenith = zenithDir
609 currentSessionDir = nil
610 currentSafeDir = nil
611 zenithDir = nil
612 postProcessing = true
613 Task {
614 defer { self.postProcessing = false }
615 let manifests = await engine.stop()
616 self.engine = nil
617 guard let temp else { return }
618 let name = temp.lastPathComponent
619
620 // A stream with zero frames means a device silently recorded nothing
621 // (e.g. a mic that never delivered a sample) — say so loudly.
622 let empty = manifests.filter { $0.frames == 0 }.map { $0.name }
623 if !empty.isEmpty {
624 self.reportProblem(
625 "No data was recorded from: \(empty.joined(separator: ", ")). "
626 + "Check the device before the next session.")
627 }
628
629 // The session may span two local dirs (scratch video + internal audio),
630 // and either may have vanished mid-recording.
631 var dirs = [temp]
632 if let safe, safe != temp { dirs.append(safe) }
633 dirs = dirs.filter { FileManager.default.fileExists(atPath: $0.path) }
634 guard !dirs.isEmpty else {
635 self.statusText = nil
636 self.reportProblem("Session \(name): no local files survived — nothing to archive.")
637 return
638 }
639
640 self.statusText = "Compressing video…"
641 for dir in dirs { await self.compress(dir) }
642
643 let mounted = await self.ensureZenithMounted()
644 var target = zenith
645 if target == nil, mounted {
646 // zenith wasn't there at start but is now — archive after all.
647 let z = self.zenithSessionDir(name)
648 if (try? FileManager.default.createDirectory(at: z, withIntermediateDirectories: true))
649 != nil
650 {
651 target = z
652 }
653 }
654 guard mounted, let zenith = target else {
655 self.keepLocal(dirs: dirs, name: name, why: "zenith is offline")
656 return
657 }
658
659 self.statusText = "Archiving to zenith…"
660 var allOK = true
661 for dir in dirs {
662 if !(await self.archiveVerified(from: dir, to: zenith)) { allOK = false }
663 }
664 if allOK {
665 for dir in dirs { try? FileManager.default.removeItem(at: dir) }
666 self.writeSequence(in: zenith)
667 self.lastSession = zenith.lastPathComponent
668 self.lastSessionURL = zenith
669 // Transcript runs after archiving (into the final folder) so "Saved"
670 // isn't held up by a long transcription.
671 self.statusText = "Transcribing session…"
672 await self.transcribeSession(zenith)
673 self.statusText = nil
674 if self.unknownSpeakers(in: zenith) { self.openSpeakersReview() }
675 } else {
676 self.keepLocal(dirs: dirs, name: name, why: "copying to zenith kept failing")
677 }
678 }
679 }
680
681 /// Archive failed: consolidate the session into one local folder and leave it
682 /// for the reconciler, which retries whenever zenith comes back.
683 private func keepLocal(dirs: [URL], name: String, why: String) {
684 let home = dirs[0]
685 for dir in dirs.dropFirst() { mergeDir(dir, into: home) }
686 lastSession = name
687 lastSessionURL = home
688 statusText = "Saved locally — will archive when zenith returns"
689 reportProblem(
690 "Session \(name) saved locally (\(why)). It will archive automatically once "
691 + "zenith is reachable; files: \(home.path)")
692 }
693
694 private func mergeDir(_ src: URL, into dst: URL) {
695 guard
696 let items = try? FileManager.default.contentsOfDirectory(
697 at: src, includingPropertiesForKeys: nil)
698 else { return }
699 // These exist in both dirs by design; either copy is fine.
700 let duplicates: Set<String> = ["session.json", "sync.json", "markers.json", "recorder.log"]
701 var allMoved = true
702 for item in items {
703 let to = dst.appendingPathComponent(item.lastPathComponent)
704 if FileManager.default.fileExists(atPath: to.path) {
705 if duplicates.contains(item.lastPathComponent) {
706 try? FileManager.default.removeItem(at: item)
707 } else {
708 allMoved = false
709 }
710 continue
711 }
712 do { try FileManager.default.moveItem(at: item, to: to) } catch { allMoved = false }
713 }
714 if allMoved { try? FileManager.default.removeItem(at: src) }
715 }
716
717 func toggle() { isRecording ? stop() : start() }
718
719 private func startTick() {
720 tickTimer = Timer.scheduledTimer(withTimeInterval: 1, repeats: true) { [weak self] _ in
721 Task { @MainActor in self?.elapsed = hostSeconds() - (self?.startHost ?? 0) }
722 }
723 }
724 private func stopTick() {
725 tickTimer?.invalidate()
726 tickTimer = nil
727 }
728
729 private func updateIcon() {
730 let name: String
731 if errorMessage != nil {
732 name = isRecording ? "stop.circle.fill" : "exclamationmark.triangle.fill"
733 } else {
734 name = isRecording ? "stop.circle.fill" : "record.circle"
735 }
736 let image = NSImage(systemSymbolName: name, accessibilityDescription: "Clover Recorder")
737 if errorMessage != nil {
738 image?.isTemplate = false
739 statusItem?.button?.contentTintColor = .systemOrange
740 } else if isRecording {
741 image?.isTemplate = false
742 statusItem?.button?.contentTintColor = .systemRed
743 } else {
744 statusItem?.button?.contentTintColor = nil
745 }
746 statusItem?.button?.image = image
747 }
748
749 /// Session reference is the start time, `YYYY-MM-DD_HH.MM`. If one already
750 /// exists for this minute (locally or on the NAS), bump forward a minute until
751 /// it's unique.
752 private func resolveSessionName() -> String {
753 var date = Date()
754 for _ in 0..<240 {
755 let name = stamp("yyyy-MM-dd_HH.mm", date)
756 let taken =
757 FileManager.default.fileExists(atPath: tempRoot.appendingPathComponent(name).path)
758 || FileManager.default.fileExists(atPath: recoveryRoot.appendingPathComponent(name).path)
759 || (zenithMounted() && FileManager.default.fileExists(atPath: zenithSessionDir(name).path))
760 if !taken { return name }
761 date = date.addingTimeInterval(60)
762 }
763 return stamp("yyyy-MM-dd_HH.mm.ss") // fallback, should never hit
764 }
765
766 /// Dropped into every local session dir so a stranded session can be routed
767 /// to its archive home later (the folder name alone doesn't say Sessions vs
768 /// Journal). Its presence is also what marks a folder as reconcilable.
769 private struct SessionStamp: Codable {
770 let name: String
771 let destination: String
772 let created: String
773 }
774
775 private func writeSessionStamp(to dir: URL, name: String) {
776 let stamp = SessionStamp(
777 name: name, destination: destination,
778 created: ISO8601DateFormatter().string(from: Date()))
779 if let data = try? JSONEncoder().encode(stamp) {
780 try? data.write(to: dir.appendingPathComponent("session.json"))
781 }
782 }
783
784 func copyReference() {
785 guard let ref = lastSession else { return }
786 NSPasteboard.general.clearContents()
787 NSPasteboard.general.setString(ref, forType: .string)
788 statusText = "Copied \(ref)"
789 }
790
791 private func zenithSessionDir(_ name: String, destination: String? = nil) -> URL {
792 // Year comes from the session name, not the clock — a session archived (or
793 // reconciled) after midnight or months later still lands in its own year.
794 archiveRoot.appendingPathComponent(String(name.prefix(4)))
795 .appendingPathComponent(destination ?? self.destination)
796 .appendingPathComponent(name)
797 }
798
799 // MARK: REAPER
800
801 private func setupReaper(name: String) {
802 guard let zenith = zenithDir else {
803 errorMessage = "Can't set up REAPER without the archive folder."
804 return
805 }
806 guard FileManager.default.fileExists(atPath: reaperTemplate.path) else {
807 errorMessage = "REAPER template not found at \(reaperTemplate.path)"
808 return
809 }
810 let reaperDir = zenith.appendingPathComponent("reaper")
811 let project = reaperDir.appendingPathComponent("\(name).rpp")
812 do {
813 try FileManager.default.createDirectory(at: reaperDir, withIntermediateDirectories: true)
814 try FileManager.default.copyItem(at: reaperTemplate, to: project)
815 } catch {
816 errorMessage = "REAPER project setup failed: \(error.localizedDescription)"
817 return
818 }
819 // Open it; you drive transport. REAPER records its media next to the project
820 // (the final NAS location), so nothing needs repathing afterwards.
821 let open = Process()
822 open.executableURL = URL(fileURLWithPath: "/usr/bin/open")
823 open.arguments = ["-a", "REAPER", project.path]
824 try? open.run()
825 }
826
827 // MARK: Archive
828
829 /// rsync a local session dir into its NAS home with retries, then verify
830 /// every file actually arrived (same size) before the caller deletes
831 /// anything. Never deletes the source itself.
832 private func archiveVerified(from dir: URL, to zenith: URL) async -> Bool {
833 for attempt in 1...3 {
834 if attempt > 1 {
835 statusText = "Archiving to zenith… (retry \(attempt))"
836 _ = await ensureZenithMounted()
837 try? await Task.sleep(nanoseconds: UInt64(attempt) * 2_000_000_000)
838 }
839 let status = await runProcess(
840 "/usr/bin/rsync",
841 ["-a", "--partial", "--timeout=120", dir.path + "/", zenith.path + "/"])
842 if status == 0, verifyCopied(from: dir, to: zenith) { return true }
843 }
844 return false
845 }
846
847 /// Every regular file under `dir` exists on zenith with the same byte size.
848 /// rsync's exit code alone once let a 0-byte file pass as "archived".
849 private func verifyCopied(from dir: URL, to zenith: URL) -> Bool {
850 let fm = FileManager.default
851 guard let walker = fm.enumerator(at: dir, includingPropertiesForKeys: [.isRegularFileKey])
852 else { return false }
853 for case let file as URL in walker {
854 guard (try? file.resourceValues(forKeys: [.isRegularFileKey]))?.isRegularFile == true else {
855 continue
856 }
857 let rel = String(file.path.dropFirst(dir.path.count))
858 let src = (try? fm.attributesOfItem(atPath: file.path))?[.size] as? Int64
859 let dst = (try? fm.attributesOfItem(atPath: zenith.path + rel))?[.size] as? Int64
860 if src == nil || dst == nil || src != dst { return false }
861 }
862 return true
863 }
864
865 /// Run a command off the main actor, returning its exit status (-1 if it
866 /// couldn't launch).
867 private func runProcess(_ path: String, _ args: [String]) async -> Int32 {
868 await withCheckedContinuation { (cont: CheckedContinuation<Int32, Never>) in
869 DispatchQueue.global(qos: .utility).async {
870 let p = Process()
871 p.executableURL = URL(fileURLWithPath: path)
872 p.arguments = args
873 p.standardOutput = Pipe()
874 p.standardError = Pipe()
875 do {
876 try p.run()
877 p.waitUntilExit()
878 } catch {
879 cont.resume(returning: -1)
880 return
881 }
882 cont.resume(returning: p.terminationStatus)
883 }
884 }
885 }
886
887 // MARK: Sequencer project (.sq)
888
889 /// Write `<session>/<session>.sq` — a Clover Sequencer project (see the
890 /// top-level `writeSequenceFile`).
891 private func writeSequence(in dir: URL) {
892 if let err = writeSequenceFile(in: dir) { logErr(err) }
893 }
894
895 // MARK: Stranded-session reconciler
896
897 /// Archive any local session folders left behind by a crash, an offline
898 /// zenith, or a failed copy. Runs at launch and every 15 minutes while idle;
899 /// only folders carrying a session.json stamp are touched.
900 private func reconcilePending() async {
901 guard !isRecording, !postProcessing, !reconciling else { return }
902 reconciling = true
903 defer { reconciling = false }
904
905 var pending: [URL] = []
906 for base in [tempRoot, recoveryRoot] {
907 let dirs =
908 (try? FileManager.default.contentsOfDirectory(at: base, includingPropertiesForKeys: nil))
909 ?? []
910 pending += dirs.filter {
911 FileManager.default.fileExists(atPath: $0.appendingPathComponent("session.json").path)
912 }
913 }
914 guard !pending.isEmpty else { return }
915 guard await ensureZenithMounted() else { return } // try again next pass
916
917 for dir in pending {
918 guard !isRecording, !postProcessing else { return }
919 guard let data = try? Data(contentsOf: dir.appendingPathComponent("session.json")),
920 let stamp = try? JSONDecoder().decode(SessionStamp.self, from: data)
921 else { continue }
922
923 // No sync.json means the recorder died mid-session: remux the fragmented
924 // media so everything downstream can read it.
925 let crashed = !FileManager.default.fileExists(
926 atPath: dir.appendingPathComponent("sync.json").path)
927 if crashed { await finalizeCrashedMedia(in: dir) }
928
929 let zenith = zenithSessionDir(stamp.name, destination: stamp.destination)
930 try? FileManager.default.createDirectory(at: zenith, withIntermediateDirectories: true)
931 statusText = "Archiving recovered session \(stamp.name)…"
932 if await archiveVerified(from: dir, to: zenith) {
933 try? FileManager.default.removeItem(at: dir)
934 writeSequence(in: zenith)
935 statusText = nil
936 notify(
937 "Recovered session \(stamp.name)\(crashed ? " (interrupted)" : "") archived to zenith.")
938 if !FileManager.default.fileExists(
939 atPath: zenith.appendingPathComponent("transcript.md").path)
940 {
941 await transcribeSession(zenith)
942 }
943 } else {
944 statusText = nil
945 reportProblem(
946 "Couldn't archive recovered session \(stamp.name) — files remain at \(dir.path)")
947 return // zenith is flaky; retry the rest next pass instead of hammering
948 }
949 }
950 }
951
952 /// A crash leaves media ending in movie fragments with no final index; a
953 /// stream-copy remux rebuilds one so any player/tool can read the file.
954 private func finalizeCrashedMedia(in dir: URL) async {
955 guard let ffmpeg = ffmpegPath() else { return }
956 let media =
957 ((try? FileManager.default.contentsOfDirectory(at: dir, includingPropertiesForKeys: nil))
958 ?? [])
959 .filter { ["mov", "m4a"].contains($0.pathExtension) }
960 for file in media {
961 let fixed = file.deletingPathExtension().appendingPathExtension("fixed")
962 .appendingPathExtension(file.pathExtension)
963 let status = await runProcess(
964 ffmpeg,
965 ["-y", "-nostdin", "-loglevel", "error", "-i", file.path, "-c", "copy", fixed.path])
966 let size =
967 ((try? FileManager.default.attributesOfItem(atPath: fixed.path))?[.size] as? Int64) ?? 0
968 if status == 0, size > 1024 {
969 try? FileManager.default.removeItem(at: file)
970 try? FileManager.default.moveItem(at: fixed, to: file)
971 } else {
972 try? FileManager.default.removeItem(at: fixed)
973 }
974 }
975 }
976
977 // MARK: Compression
978
979 /// Re-encode the session's screen videos with x265 (CRF) before archiving.
980 /// Realtime hardware capture trades size for speed; this offline pass (the
981 /// Studio is idle between sessions) shrinks them several-fold while preserving
982 /// exact frame timing, so alignment is unaffected. Falls back to the original
983 /// if ffmpeg is missing or the re-encode looks wrong.
984 private func compress(_ dir: URL) async {
985 guard let ffmpeg = ffmpegPath() else { return }
986 let movs =
987 ((try? FileManager.default.contentsOfDirectory(at: dir, includingPropertiesForKeys: nil))
988 ?? [])
989 .filter { $0.pathExtension == "mov" }
990 .sorted { $0.lastPathComponent < $1.lastPathComponent }
991 if movs.isEmpty { return }
992
993 let durations = streamDurations(in: dir)
994 let total = movs.reduce(0.0) { $0 + (durations[$1.lastPathComponent] ?? 0) }
995 var done = 0.0
996 compressProgress = total > 0 ? 0 : nil
997
998 for mov in movs {
999 let dur = durations[mov.lastPathComponent] ?? 0
1000 let out = mov.deletingPathExtension().appendingPathExtension("x265.mov")
1001 // Light denoise on the webcam only (sensor noise is costly to encode);
1002 // screens are clean and would just lose text crispness.
1003 let filter = mov.lastPathComponent == "cam.mov" ? "hqdn3d=1.5:1.5:6:6" : nil
1004 let ok = await encode(ffmpeg, input: mov, output: out, filter: filter) { secs in
1005 if total > 0 { self.compressProgress = min(1, (done + min(secs, dur)) / total) }
1006 }
1007 done += dur
1008 if total > 0 { compressProgress = min(1, done / total) }
1009
1010 let attrs = try? FileManager.default.attributesOfItem(atPath: out.path)
1011 let outSize = (attrs?[.size] as? Int64) ?? 0
1012 if ok, outSize > 1024 {
1013 try? FileManager.default.removeItem(at: mov)
1014 try? FileManager.default.moveItem(at: out, to: mov)
1015 } else {
1016 try? FileManager.default.removeItem(at: out) // keep the original
1017 }
1018 }
1019 compressProgress = nil
1020 }
1021
1022 /// Per-stream durations from sync.json, so the progress bar knows the total.
1023 private func streamDurations(in dir: URL) -> [String: Double] {
1024 guard let data = try? Data(contentsOf: dir.appendingPathComponent("sync.json")),
1025 let obj = try? JSONSerialization.jsonObject(with: data) as? [String: Any],
1026 let streams = obj["streams"] as? [[String: Any]]
1027 else { return [:] }
1028 var map: [String: Double] = [:]
1029 for s in streams {
1030 if let file = s["file"] as? String, let d = s["durationSeconds"] as? Double { map[file] = d }
1031 }
1032 return map
1033 }
1034
1035 /// Transcribe the session's mic audio into transcript.md (meeting-minutes
1036 /// style, markers interleaved). Skips if there's no mic audio; anything else
1037 /// going wrong is reported, not swallowed.
1038 private func transcribeSession(_ dir: URL) async {
1039 let python = NSHomeDirectory() + "/.clover-whisper/.venv/bin/python"
1040 let mic = dir.appendingPathComponent("mic.m4a")
1041 guard FileManager.default.fileExists(atPath: mic.path) else { return }
1042 guard FileManager.default.isExecutableFile(atPath: python),
1043 let script = Bundle.main.url(forResource: "session_transcript", withExtension: "py")?.path
1044 else {
1045 reportProblem("Transcription skipped — whisper env missing (run setup-dictation.sh).")
1046 return
1047 }
1048 let markers = dir.appendingPathComponent("markers.json")
1049 let markersArg = FileManager.default.fileExists(atPath: markers.path) ? markers.path : "none"
1050 let out = dir.appendingPathComponent("transcript.md").path
1051 let mode = detectSpeakers ? "multi" : "solo"
1052 let result = await runTool(python, [script, mic.path, markersArg, out, dir.lastPathComponent, mode])
1053 if result.status != 0 {
1054 reportProblem(
1055 "Transcription failed for \(dir.lastPathComponent): "
1056 + (result.errorTail.isEmpty ? "exit \(result.status)" : result.errorTail))
1057 }
1058 }
1059
1060 /// Run a command to completion off the main actor, capturing its exit status
1061 /// and the tail of stderr so failures can be reported instead of vanishing.
1062 @discardableResult
1063 private func runTool(_ path: String, _ args: [String]) async -> (status: Int32, errorTail: String)
1064 {
1065 await withCheckedContinuation {
1066 (cont: CheckedContinuation<(status: Int32, errorTail: String), Never>) in
1067 DispatchQueue.global(qos: .utility).async {
1068 let p = Process()
1069 p.executableURL = URL(fileURLWithPath: path)
1070 p.arguments = args
1071 p.environment = cloverToolEnvironment()
1072 p.standardOutput = Pipe()
1073 let err = Pipe()
1074 p.standardError = err
1075 var tail = Data()
1076 err.fileHandleForReading.readabilityHandler = { fh in
1077 tail.append(fh.availableData)
1078 if tail.count > 8192 { tail = tail.suffix(4096) }
1079 }
1080 do {
1081 try p.run()
1082 p.waitUntilExit()
1083 } catch {
1084 err.fileHandleForReading.readabilityHandler = nil
1085 cont.resume(returning: (-1, error.localizedDescription))
1086 return
1087 }
1088 err.fileHandleForReading.readabilityHandler = nil
1089 if let rest = try? err.fileHandleForReading.readToEnd() { tail.append(rest) }
1090 let text = String(decoding: tail, as: UTF8.self)
1091 .trimmingCharacters(in: .whitespacesAndNewlines)
1092 cont.resume(returning: (p.terminationStatus, String(text.suffix(300))))
1093 }
1094 }
1095 }
1096
1097 private func ffmpegPath() -> String? {
1098 let candidates = [
1099 "/etc/profiles/per-user/\(NSUserName())/bin/ffmpeg",
1100 "/run/current-system/sw/bin/ffmpeg",
1101 "/opt/homebrew/bin/ffmpeg",
1102 "/usr/local/bin/ffmpeg",
1103 ]
1104 return candidates.first { FileManager.default.isExecutableFile(atPath: $0) }
1105 }
1106
1107 /// Run one x265 encode, reporting encoded-seconds via ffmpeg's `-progress`.
1108 private func encode(
1109 _ ffmpeg: String, input: URL, output: URL, filter: String? = nil,
1110 onProgress: @escaping (Double) -> Void
1111 ) async -> Bool {
1112 await withCheckedContinuation { (cont: CheckedContinuation<Bool, Never>) in
1113 DispatchQueue.global(qos: .utility).async {
1114 let p = Process()
1115 p.executableURL = URL(fileURLWithPath: ffmpeg)
1116 var args = [
1117 "-y", "-nostats", "-loglevel", "error", "-i", input.path,
1118 "-an", "-c:v", "libx265", "-crf", "24", "-preset", "fast",
1119 "-tag:v", "hvc1", "-fps_mode", "passthrough", "-progress", "pipe:1",
1120 ]
1121 if let filter { args += ["-vf", filter] }
1122 args.append(output.path)
1123 p.arguments = args
1124 let pipe = Pipe()
1125 p.standardOutput = pipe
1126 pipe.fileHandleForReading.readabilityHandler = { fh in
1127 let text = String(decoding: fh.availableData, as: UTF8.self)
1128 for line in text.split(separator: "\n") where line.hasPrefix("out_time_us=") {
1129 if let us = Double(line.dropFirst("out_time_us=".count)) {
1130 DispatchQueue.main.async { onProgress(us / 1_000_000) }
1131 }
1132 }
1133 }
1134 do {
1135 try p.run()
1136 p.waitUntilExit()
1137 } catch {
1138 cont.resume(returning: false)
1139 return
1140 }
1141 pipe.fileHandleForReading.readabilityHandler = nil
1142 cont.resume(returning: p.terminationStatus == 0)
1143 }
1144 }
1145 }
1146
1147 // MARK: Speaker review
1148
1149 var lastSessionHasSpeakers: Bool {
1150 guard let dir = lastSessionURL else { return false }
1151 // Only surface review when there's actually an unnamed voice — solo sessions
1152 // write a single known "You" speaker, which needs no review.
1153 return unknownSpeakers(in: dir)
1154 }
1155
1156 private func unknownSpeakers(in dir: URL) -> Bool {
1157 guard let data = try? Data(contentsOf: dir.appendingPathComponent("speakers.json")),
1158 let file = try? JSONDecoder().decode(SpeakersFile.self, from: data)
1159 else { return false }
1160 return file.speakers.contains { $0.unknown }
1161 }
1162
1163 func openSpeakersReview() {
1164 guard let dir = lastSessionURL,
1165 let data = try? Data(contentsOf: dir.appendingPathComponent("speakers.json")),
1166 let file = try? JSONDecoder().decode(SpeakersFile.self, from: data),
1167 !file.speakers.isEmpty
1168 else { return }
1169
1170 let model = SpeakersModel(
1171 speakers: file.speakers, libraryNames: libraryNames(),
1172 audioURL: dir.appendingPathComponent("mic.m4a"))
1173 let view = SpeakersView(
1174 model: model,
1175 onSave: { [weak self] mapping in self?.applySpeakerNames(dir: dir, mapping: mapping) },
1176 onCancel: { [weak self] in self?.closeSpeakersWindow() })
1177
1178 let win = NSWindow(
1179 contentRect: NSRect(x: 0, y: 0, width: 380, height: 300),
1180 styleMask: [.titled, .closable], backing: .buffered, defer: false)
1181 win.title = "Speakers · \(dir.lastPathComponent)"
1182 win.contentViewController = NSHostingController(rootView: view)
1183 win.isReleasedWhenClosed = false
1184 win.center()
1185 speakersWindow = win
1186 NSApp.activate(ignoringOtherApps: true)
1187 win.makeKeyAndOrderFront(nil)
1188 }
1189
1190 private func closeSpeakersWindow() {
1191 speakersWindow?.close()
1192 speakersWindow = nil
1193 }
1194
1195 private func libraryNames() -> [String] {
1196 let lib = NSHomeDirectory() + "/.clover-whisper/voices.json"
1197 guard let data = try? Data(contentsOf: URL(fileURLWithPath: lib)),
1198 let obj = try? JSONSerialization.jsonObject(with: data) as? [String: Any],
1199 let voices = obj["voices"] as? [[String: Any]]
1200 else { return [] }
1201 return voices.compactMap { $0["name"] as? String }
1202 }
1203
1204 private func applySpeakerNames(dir: URL, mapping: [String: String]) {
1205 closeSpeakersWindow()
1206 guard !mapping.isEmpty,
1207 let script = Bundle.main.url(forResource: "relabel", withExtension: "py")?.path
1208 else { return }
1209 let python = NSHomeDirectory() + "/.clover-whisper/.venv/bin/python"
1210 let mapURL = dir.appendingPathComponent("_speaker_mapping.json")
1211 guard let mdata = try? JSONSerialization.data(withJSONObject: mapping) else { return }
1212 try? mdata.write(to: mapURL)
1213 statusText = "Updating speakers…"
1214 Task {
1215 let result = await runTool(python, [script, dir.path, mapURL.path])
1216 try? FileManager.default.removeItem(at: mapURL)
1217 if result.status == 0 {
1218 self.statusText = "Speakers updated ✓"
1219 } else {
1220 self.statusText = nil
1221 self.reportProblem(
1222 "Speaker update failed: "
1223 + (result.errorTail.isEmpty ? "exit \(result.status)" : result.errorTail))
1224 }
1225 }
1226 }
1227
1228 // MARK: Voice enrollment
1229
1230 /// Record ~12 s of your voice and store a voiceprint so transcripts can tell
1231 /// you from other speakers.
1232 func enrollVoice() {
1233 guard !enrolling, !isRecording else { return }
1234 let python = NSHomeDirectory() + "/.clover-whisper/.venv/bin/python"
1235 guard FileManager.default.isExecutableFile(atPath: python) else {
1236 errorMessage = "Dictation env not set up (run setup-dictation.sh)."
1237 return
1238 }
1239 let url = FileManager.default.temporaryDirectory.appendingPathComponent("clover-enroll.wav")
1240 let settings: [String: Any] = [
1241 AVFormatIDKey: kAudioFormatLinearPCM, AVSampleRateKey: 16_000,
1242 AVNumberOfChannelsKey: 1, AVLinearPCMBitDepthKey: 16,
1243 AVLinearPCMIsFloatKey: false, AVLinearPCMIsBigEndianKey: false,
1244 ]
1245 do {
1246 let rec = try AVAudioRecorder(url: url, settings: settings)
1247 rec.record()
1248 enrollRecorder = rec
1249 } catch {
1250 errorMessage = "Couldn't open the mic for enrollment."
1251 return
1252 }
1253 errorMessage = nil
1254 statusText = nil
1255 enrolling = true
1256 enrollSecondsLeft = 12
1257 enrollTimer = Timer.scheduledTimer(withTimeInterval: 1, repeats: true) { [weak self] _ in
1258 Task { @MainActor in
1259 guard let self else { return }
1260 self.enrollSecondsLeft -= 1
1261 if self.enrollSecondsLeft <= 0 { self.finishEnroll(url: url, python: python) }
1262 }
1263 }
1264 }
1265
1266 private func finishEnroll(url: URL, python: String) {
1267 enrollTimer?.invalidate()
1268 enrollTimer = nil
1269 enrollRecorder?.stop()
1270 enrollRecorder = nil
1271 enrolling = false
1272 statusText = "Processing voice…"
1273 guard let script = Bundle.main.url(forResource: "enroll", withExtension: "py")?.path else {
1274 return
1275 }
1276 let library = NSHomeDirectory() + "/.clover-whisper/voices.json"
1277 Task {
1278 await runTool(python, [script, url.path, "Clover"])
1279 self.voiceEnrolled = FileManager.default.fileExists(atPath: library)
1280 self.statusText = self.voiceEnrolled ? "Voice enrolled ✓" : "Enrollment failed"
1281 }
1282 }
1283
1284 // MARK: Reveal / copy folder
1285
1286 func openFolder() {
1287 guard let url = lastSessionURL else { return }
1288 NSWorkspace.shared.open(url)
1289 }
1290
1291 func copyPath() {
1292 guard let url = lastSessionURL else { return }
1293 NSPasteboard.general.clearContents()
1294 NSPasteboard.general.setString(url.path, forType: .string)
1295 statusText = "Copied path"
1296 }
1297
1298 // MARK: zenith mount
1299
1300 private func zenithMounted() -> Bool {
1301 let vols = FileManager.default.mountedVolumeURLs(
1302 includingResourceValuesForKeys: nil, options: [.skipHiddenVolumes]) ?? []
1303 return vols.contains { $0.path == "/Volumes/clover" }
1304 && FileManager.default.fileExists(atPath: archiveRoot.path)
1305 }
1306
1307 /// Ensure the zenith SMB share is mounted, attempting to mount it with saved
1308 /// keychain credentials and polling briefly if it wasn't.
1309 private func ensureZenithMounted() async -> Bool {
1310 if zenithMounted() { return true }
1311 await withCheckedContinuation { (cont: CheckedContinuation<Void, Never>) in
1312 DispatchQueue.global(qos: .utility).async {
1313 let p = Process()
1314 p.executableURL = URL(fileURLWithPath: "/usr/bin/osascript")
1315 p.arguments = ["-e", "mount volume \"smb://clo@zenith.local/clover\""]
1316 try? p.run()
1317 p.waitUntilExit()
1318 cont.resume()
1319 }
1320 }
1321 for _ in 0..<12 {
1322 if zenithMounted() { return true }
1323 try? await Task.sleep(nanoseconds: 500_000_000)
1324 }
1325 return zenithMounted()
1326 }
1327
1328 private func stamp(_ format: String, _ date: Date = Date()) -> String {
1329 let f = DateFormatter()
1330 f.dateFormat = format
1331 return f.string(from: date)
1332 }
1333}
1334
1335// MARK: - View
1336
1337struct ContentView: View {
1338 @ObservedObject var controller: AppController
1339
1340 var body: some View {
1341 VStack(alignment: .leading, spacing: 12) {
1342 HStack {
1343 Text("Clover Recorder").font(.headline)
1344 Spacer()
1345 if controller.isRecording {
1346 Text(timeString(controller.elapsed))
1347 .font(.system(.body, design: .monospaced)).foregroundStyle(.red)
1348 }
1349 }
1350
1351 if controller.isRecording {
1352 Label(
1353 "F14 to drop a marker" + (controller.markerCount > 0 ? " · \(controller.markerCount)" : ""),
1354 systemImage: "mappin.and.ellipse"
1355 ).font(.caption2).foregroundStyle(.secondary)
1356 }
1357
1358 if !controller.permissionOK {
1359 Label("Screen Recording permission needed", systemImage: "exclamationmark.triangle")
1360 .font(.caption).foregroundStyle(.orange)
1361 }
1362
1363 Picker("", selection: $controller.destination) {
1364 Text("Sessions").tag("Sessions")
1365 Text("Journal").tag("Journal")
1366 }
1367 .pickerStyle(.segmented)
1368 .disabled(controller.isRecording)
1369
1370 Divider()
1371
1372 VStack(alignment: .leading, spacing: 6) {
1373 Text("Capture").font(.caption).foregroundStyle(.secondary)
1374 ForEach(Array(controller.orderedDisplays.enumerated()), id: \.element.id) { idx, d in
1375 Toggle(
1376 displayLabel(idx + 1, d),
1377 isOn: Binding(
1378 get: { controller.enabledDisplays.contains(d.id) },
1379 set: { controller.setDisplay(d.id, on: $0) })
1380 ).disabled(controller.isRecording)
1381 }
1382 if controller.displays.count > 1 {
1383 HStack {
1384 Toggle("Reverse screen order", isOn: $controller.reverseScreens)
1385 .disabled(controller.isRecording)
1386 Spacer()
1387 Button("Identify") { controller.identifyScreens() }
1388 .font(.caption)
1389 }
1390 }
1391 Toggle("Desktop audio", isOn: $controller.includeDesktop).disabled(controller.isRecording)
1392 deviceRow(
1393 "Microphone", isOn: $controller.includeMic, selection: $controller.micUID,
1394 options: controller.audioInputs, available: controller.hasMic)
1395 deviceRow(
1396 "Camera", isOn: $controller.includeCamera, selection: $controller.cameraUID,
1397 options: controller.cameras, available: controller.hasCamera)
1398 Toggle("REAPER (MIDI)", isOn: $controller.reaperMidi).disabled(controller.isRecording)
1399 Toggle("Detect multiple speakers", isOn: $controller.detectSpeakers)
1400 .disabled(controller.isRecording)
1401 .help("Off: transcribe as one voice. On: separate and name speakers (for sessions with other people).")
1402 }
1403 .toggleStyle(.checkbox)
1404
1405 if controller.includeCamera && controller.hasCamera {
1406 VStack(alignment: .leading, spacing: 6) {
1407 CameraPreview(session: controller.previewSession)
1408 .frame(width: 290, height: 290 / controller.cameraAspect)
1409 .background(Color.black)
1410 .clipShape(RoundedRectangle(cornerRadius: 8))
1411 HStack(spacing: 10) {
1412 Picker("Quality", selection: $controller.cameraHeight) {
1413 Text("1080p").tag(1080)
1414 Text("720p").tag(720)
1415 Text("480p").tag(480)
1416 }
1417 .disabled(controller.isRecording)
1418 Picker("FPS", selection: $controller.cameraFps) {
1419 Text("30").tag(30)
1420 Text("24").tag(24)
1421 Text("15").tag(15)
1422 Text("60").tag(60)
1423 }
1424 .disabled(controller.isRecording)
1425 Spacer()
1426 Button { controller.toggleCameraPopout() } label: {
1427 Image(systemName: "rectangle.on.rectangle")
1428 }
1429 .help("Pop out camera (stays up while recording)")
1430 }
1431 .font(.caption)
1432
1433 Picker("Anti-flicker", selection: $controller.cameraAntiFlickerHz) {
1434 Text("Off").tag(0)
1435 Text("60 Hz").tag(60)
1436 Text("50 Hz").tag(50)
1437 }
1438 .pickerStyle(.segmented)
1439 .font(.caption)
1440 }
1441 }
1442
1443 HStack(spacing: 6) {
1444 Image(
1445 systemName: controller.voiceEnrolled
1446 ? "person.fill.checkmark" : "person.crop.circle.badge.plus"
1447 )
1448 .foregroundStyle(controller.voiceEnrolled ? .green : .secondary)
1449 if controller.enrolling {
1450 Text("Recording voice… \(controller.enrollSecondsLeft)s — keep talking")
1451 .foregroundStyle(.red)
1452 } else {
1453 Text(controller.voiceEnrolled ? "Voice enrolled" : "Enroll voice for speaker labels")
1454 .foregroundStyle(.secondary)
1455 Spacer()
1456 Button(controller.voiceEnrolled ? "Re-enroll" : "Enroll") { controller.enrollVoice() }
1457 .disabled(controller.isRecording)
1458 }
1459 }
1460 .font(.caption)
1461
1462 Spacer()
1463
1464 if let p = controller.compressProgress {
1465 VStack(alignment: .leading, spacing: 3) {
1466 Text("Compressing video… \(Int(p * 100))%")
1467 .font(.caption).foregroundStyle(.secondary)
1468 ProgressView(value: p)
1469 }
1470 } else if let status = controller.statusText {
1471 Text(status).font(.caption).foregroundStyle(.secondary).lineLimit(1)
1472 }
1473
1474 if let last = controller.lastSession, !controller.isRecording {
1475 HStack(spacing: 8) {
1476 Button(action: { controller.copyReference() }) {
1477 HStack(spacing: 4) {
1478 Image(systemName: "checkmark.circle.fill").foregroundStyle(.green)
1479 Text(last).font(.system(.caption, design: .monospaced))
1480 }
1481 }
1482 .buttonStyle(.borderless)
1483 .help("Copy session reference")
1484 Spacer()
1485 if controller.lastSessionHasSpeakers {
1486 Button("Speakers") { controller.openSpeakersReview() }
1487 }
1488 Button("Open") { controller.openFolder() }
1489 Button("Copy path") { controller.copyPath() }
1490 }
1491 .font(.caption)
1492 }
1493
1494 if let err = controller.errorMessage {
1495 HStack(alignment: .top, spacing: 6) {
1496 Text(err).font(.caption).foregroundStyle(.red).lineLimit(4)
1497 Spacer()
1498 Button {
1499 controller.errorMessage = nil
1500 } label: {
1501 Image(systemName: "xmark.circle.fill").foregroundStyle(.secondary)
1502 }
1503 .buttonStyle(.borderless)
1504 .help("Dismiss")
1505 }
1506 }
1507
1508 VStack(spacing: 4) {
1509 Button(action: { controller.toggle() }) {
1510 HStack(spacing: 6) {
1511 if !controller.isRecording && !controller.hasMic {
1512 Image(systemName: "exclamationmark.triangle.fill")
1513 }
1514 Text(controller.isRecording ? "Stop" : "Start Recording")
1515 }
1516 .frame(maxWidth: .infinity)
1517 }
1518 .controlSize(.large)
1519 .tint(controller.isRecording ? .red : (controller.hasMic ? .accentColor : .yellow))
1520 .disabled(!controller.permissionOK && !controller.isRecording)
1521
1522 if !controller.isRecording && !controller.hasMic {
1523 Text("Will record without microphone")
1524 .font(.caption2).foregroundStyle(.yellow)
1525 }
1526 }
1527
1528 HStack {
1529 Button("Quit") { NSApp.terminate(nil) }.font(.caption)
1530 Spacer()
1531 }
1532 }
1533 .padding(14)
1534 .frame(width: 320)
1535 }
1536
1537 /// A capture toggle with an inline device dropdown, so all the checkboxes line
1538 /// up in a column.
1539 @ViewBuilder
1540 private func deviceRow(
1541 _ label: String, isOn: Binding<Bool>, selection: Binding<String?>,
1542 options: [DeviceInfo], available: Bool
1543 ) -> some View {
1544 HStack(spacing: 6) {
1545 Toggle(label, isOn: isOn).disabled(controller.isRecording || !available)
1546 Spacer()
1547 if isOn.wrappedValue && available && options.count > 1 {
1548 Picker("", selection: selection) {
1549 ForEach(options, id: \.uid) { Text($0.name).tag(Optional($0.uid)) }
1550 }
1551 .labelsHidden().font(.caption).frame(maxWidth: 150).disabled(controller.isRecording)
1552 }
1553 }
1554 }
1555
1556 private func displayLabel(_ number: Int, _ d: DisplayInfo) -> String {
1557 let xs = controller.displays.map { $0.x }
1558 var pos = ""
1559 if controller.displays.count > 1 {
1560 if d.x == xs.min() { pos = " · left" } else if d.x == xs.max() { pos = " · right" }
1561 }
1562 return "Screen \(number)\(pos) — \(d.width)×\(d.height)"
1563 }
1564
1565 private func timeString(_ t: TimeInterval) -> String {
1566 let s = Int(t)
1567 return String(format: "%02d:%02d", s / 60, s % 60)
1568 }
1569}
recorder/engine/Sources/recorder/Recorder.swift created+733
...@@ -0,0 +1,733 @@
1import AVFoundation
2import CoreGraphics
3import CoreMedia
4import Foundation
5import ScreenCaptureKit
6
7let recorderVersion = "0.1.0"
8
9// MARK: - Logging
10
11// Optional log file so diagnostics survive launch methods that discard
12// stdout/stderr (e.g. `open` / LaunchServices).
13var logFile: FileHandle?
14
15func openLogFile(_ path: String) {
16 FileManager.default.createFile(atPath: path, contents: nil)
17 logFile = FileHandle(forWritingAtPath: path)
18}
19
20private func emit(_ message: String) {
21 let line = "[recorder] " + message + "\n"
22 FileHandle.standardError.write(Data(line.utf8))
23 if let logFile {
24 // Throwing variant: the legacy write() raises an ObjC exception if the log's
25 // volume vanishes mid-recording, which would kill the whole process.
26 try? logFile.write(contentsOf: Data(line.utf8))
27 }
28}
29
30func logErr(_ message: String) { emit(message) }
31func logInfo(_ message: String) { emit(message) }
32
33// MARK: - Host clock
34
35/// Seconds on the mach host clock — the same clock ScreenCaptureKit and
36/// AVCapture stamp their sample buffers with, so values are directly comparable
37/// across every stream.
38func hostSeconds() -> Double {
39 CMTimeGetSeconds(CMClockGetTime(CMClockGetHostTimeClock()))
40}
41
42// MARK: - Device discovery
43
44struct DisplayInfo: Codable {
45 let id: UInt32
46 let width: Int
47 let height: Int
48 let x: Int
49 let y: Int
50}
51
52struct DeviceInfo: Codable {
53 let uid: String
54 let name: String
55 var continuity: Bool = false // iPhone/iPad Continuity device
56}
57
58struct DiscoveredDevices: Codable {
59 let displays: [DisplayInfo]
60 let cameras: [DeviceInfo]
61 let audioInputs: [DeviceInfo]
62}
63
64enum Devices {
65 static func discover() async throws -> DiscoveredDevices {
66 let content = try await SCShareableContent.excludingDesktopWindows(
67 false, onScreenWindowsOnly: false)
68
69 let displays =
70 content.displays
71 .sorted { $0.frame.origin.x < $1.frame.origin.x }
72 .map {
73 DisplayInfo(
74 id: $0.displayID,
75 width: Int($0.frame.width),
76 height: Int($0.frame.height),
77 x: Int($0.frame.origin.x),
78 y: Int($0.frame.origin.y))
79 }
80
81 let cameras = AVCaptureDevice.DiscoverySession(
82 deviceTypes: [.builtInWideAngleCamera, .external, .continuityCamera],
83 mediaType: .video, position: .unspecified
84 ).devices.map {
85 DeviceInfo(
86 uid: $0.uniqueID, name: $0.localizedName, continuity: $0.deviceType == .continuityCamera)
87 }
88
89 // Base names of Continuity cameras (e.g. "small phone, for small girl") so we
90 // can flag the matching iPhone/iPad mic even when its camera isn't active.
91 let continuityCamBases = cameras
92 .filter { $0.continuity }
93 .map { $0.name.replacingOccurrences(of: " Camera", with: "") }
94 .filter { !$0.isEmpty }
95 let audioInputs = AVCaptureDevice.DiscoverySession(
96 deviceTypes: [.microphone, .external],
97 mediaType: .audio, position: .unspecified
98 ).devices.map { dev -> DeviceInfo in
99 // Continuity Capture audio transport types: 'ccwd' (wired) / 'ccwl' (wireless).
100 let tt = dev.transportType
101 let byTransport = tt == 0x6363_7764 || tt == 0x6363_776C
102 let byName = continuityCamBases.contains { dev.localizedName.hasPrefix($0) }
103 return DeviceInfo(
104 uid: dev.uniqueID, name: dev.localizedName, continuity: byTransport || byName)
105 }
106
107 return DiscoveredDevices(displays: displays, cameras: cameras, audioInputs: audioInputs)
108 }
109
110 static func audioDevice(matching wanted: String) -> AVCaptureDevice? {
111 let devices = AVCaptureDevice.DiscoverySession(
112 deviceTypes: [.microphone, .external],
113 mediaType: .audio, position: .unspecified
114 ).devices
115 if wanted == "default" {
116 return AVCaptureDevice.default(for: .audio) ?? devices.first
117 }
118 return devices.first { $0.uniqueID == wanted || $0.localizedName == wanted }
119 }
120
121 static func videoDevice(matching wanted: String) -> AVCaptureDevice? {
122 let devices = AVCaptureDevice.DiscoverySession(
123 deviceTypes: [.builtInWideAngleCamera, .external, .continuityCamera],
124 mediaType: .video, position: .unspecified
125 ).devices
126 if wanted == "default" {
127 return AVCaptureDevice.default(for: .video) ?? devices.first
128 }
129 return devices.first { $0.uniqueID == wanted || $0.localizedName == wanted }
130 }
131}
132
133// MARK: - Manifest
134
135struct StreamManifest: Codable {
136 let name: String
137 let file: String
138 let kind: String
139 var displayID: UInt32?
140 var deviceUID: String?
141 var width: Int?
142 var height: Int?
143 var fps: Int?
144 let firstSampleHostSeconds: Double
145 let lastSampleHostSeconds: Double
146 let durationSeconds: Double
147 let frames: Int
148 let dropped: Int // real drops (input not ready / append failed)
149 let repeated: Int // CFR frames re-emitted to hold the rate on a static screen
150 var offsetSeconds: Double
151}
152
153struct SessionManifest: Codable {
154 let recorderVersion: String
155 let label: String
156 let createdEpoch: Double
157 let hostClockAtStart: Double
158 let tStartHostSeconds: Double
159 let streams: [StreamManifest]
160}
161
162// MARK: - Stream sink protocol
163
164/// Point-in-time view of a stream for the engine's watchdog.
165struct StreamHealth {
166 let name: String
167 let started: Bool // has produced at least one sample in the current part
168 let dead: Bool // rollover exhausted; the stream is permanently lost
169 let lastAppendHost: Double // host seconds of the last successful append (NaN if none)
170}
171
172/// How often movie fragments are flushed. A crash, power cut, or vanishing
173/// volume loses at most this much media instead of the entire (index-less) file.
174let fragmentSeconds = 5.0
175
176/// Give up on a stream after this many rollover attempts — a target that keeps
177/// failing writers instantly would otherwise spray part-files forever.
178let maxParts = 5
179
180/// Anything that records one stream to one file (or, after failures, a series
181/// of part files) and reports where each sat on the shared host clock, so the
182/// engine can align and summarize them uniformly.
183protocol RecordingStream: AnyObject {
184 func finish() async
185 /// One manifest entry per written part, in order.
186 func manifests() -> [StreamManifest]
187 func health() -> StreamHealth
188 /// Abandon the current file and continue into a fresh part inside `dir`
189 /// (used when the volume under the current file disappears). No-op if the
190 /// current file already lives there.
191 func rollover(to dir: URL)
192}
193
194// MARK: - Stream writer (passthrough: audio + camera)
195
196/// Owns one AVAssetWriter + input and turns a flow of CMSampleBuffers into one
197/// file, lazily starting the writer session on the first buffer and recording
198/// that buffer's host-clock timestamp for later alignment.
199final class StreamWriter: RecordingStream {
200 let name: String
201 let kind: String
202 private(set) var url: URL
203 var displayID: UInt32?
204 var deviceUID: String?
205 var width: Int?
206 var height: Int?
207 var fps: Int?
208
209 private let fileType: AVFileType
210 private let settings: [String: Any]
211 private let mediaType: AVMediaType
212 private var writer: AVAssetWriter
213 private var input: AVAssetWriterInput
214 private let lock = NSLock()
215
216 private var partFirstPTS: Double = .nan
217 private var partLastPTS: Double = .nan
218 private var partFrames = 0
219 private var partDropped = 0
220 private var lastAppendHost: Double = .nan
221 private var started = false
222 private var dead = false
223 private var partIndex = 1
224 private var doneParts: [StreamManifest] = []
225 private var rolloverDir: URL?
226
227 init(
228 url: URL, name: String, kind: String, fileType: AVFileType,
229 settings: [String: Any], mediaType: AVMediaType, fallbackDir: URL? = nil
230 ) throws {
231 self.url = url
232 self.name = name
233 self.kind = kind
234 self.fileType = fileType
235 self.settings = settings
236 self.mediaType = mediaType
237 self.rolloverDir = fallbackDir
238 (self.writer, self.input) = try Self.makeWriter(
239 url: url, fileType: fileType, settings: settings, mediaType: mediaType, name: name)
240 }
241
242 private static func makeWriter(
243 url: URL, fileType: AVFileType, settings: [String: Any], mediaType: AVMediaType, name: String
244 ) throws -> (AVAssetWriter, AVAssetWriterInput) {
245 let w = try AVAssetWriter(outputURL: url, fileType: fileType)
246 // Periodic fragments keep the file readable up to the last flush even if
247 // we never get to finalize it (crash, power loss, disk disconnect).
248 w.movieFragmentInterval = CMTime(seconds: fragmentSeconds, preferredTimescale: 600)
249 let i = AVAssetWriterInput(mediaType: mediaType, outputSettings: settings)
250 i.expectsMediaDataInRealTime = true
251 guard w.canAdd(i) else {
252 throw RecorderError("cannot add \(mediaType.rawValue) input for \(name)")
253 }
254 w.add(i)
255 return (w, i)
256 }
257
258 func append(_ sb: CMSampleBuffer) {
259 lock.lock()
260 defer { lock.unlock() }
261 if dead { return }
262
263 let pts = CMTimeGetSeconds(CMSampleBufferGetPresentationTimeStamp(sb))
264
265 if !started {
266 guard writer.startWriting() else {
267 logErr("\(name): startWriting failed: \(writer.error?.localizedDescription ?? "?")")
268 rolloverLocked()
269 return
270 }
271 writer.startSession(atSourceTime: CMSampleBufferGetPresentationTimeStamp(sb))
272 partFirstPTS = pts
273 started = true
274 }
275
276 if input.isReadyForMoreMediaData {
277 if input.append(sb) {
278 partFrames += 1
279 partLastPTS = pts
280 lastAppendHost = hostSeconds()
281 } else {
282 partDropped += 1
283 if writer.status == .failed {
284 logErr("\(name): append failed: \(writer.error?.localizedDescription ?? "?")")
285 rolloverLocked()
286 }
287 }
288 } else {
289 partDropped += 1
290 }
291 }
292
293 /// Abandon the current writer and continue into a fresh part file. The old
294 /// file keeps whatever fragments reached disk (recoverable when the volume
295 /// returns). Caller must hold `lock`.
296 private func rolloverLocked() {
297 if started { doneParts.append(partManifestLocked()) }
298 if writer.status == .writing {
299 // Proactive roll (volume vanished from under a healthy writer): try to
300 // finalize in the background; we don't wait on a possibly-dead disk.
301 input.markAsFinished()
302 writer.finishWriting {}
303 }
304 started = false
305 partFirstPTS = .nan
306 partLastPTS = .nan
307 partFrames = 0
308 partDropped = 0
309 partIndex += 1
310 guard partIndex <= maxParts, let dir = rolloverDir else {
311 dead = true
312 logErr("\(name): stream lost (no rollover target or too many failures)")
313 return
314 }
315 let next = dir.appendingPathComponent("\(name)-\(partIndex).\(url.pathExtension)")
316 do {
317 try FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true)
318 (writer, input) = try Self.makeWriter(
319 url: next, fileType: fileType, settings: settings, mediaType: mediaType, name: name)
320 url = next
321 logErr("\(name): rolled over to \(next.path)")
322 } catch {
323 dead = true
324 logErr("\(name): rollover failed (\(error.localizedDescription)) — stream lost")
325 }
326 }
327
328 func rollover(to dir: URL) {
329 lock.lock()
330 defer { lock.unlock() }
331 guard !dead, !url.deletingLastPathComponent().path.hasPrefix(dir.path) else { return }
332 rolloverDir = dir
333 rolloverLocked()
334 }
335
336 func health() -> StreamHealth {
337 lock.lock()
338 defer { lock.unlock() }
339 return StreamHealth(name: name, started: started, dead: dead, lastAppendHost: lastAppendHost)
340 }
341
342 func finish() async {
343 lock.lock()
344 let w = writer
345 let i = input
346 let finalize = started && w.status == .writing
347 if finalize { i.markAsFinished() }
348 lock.unlock()
349
350 guard finalize else {
351 if doneParts.isEmpty { logErr("\(name): no samples captured, nothing written") }
352 return
353 }
354 await withCheckedContinuation { (cont: CheckedContinuation<Void, Never>) in
355 w.finishWriting { cont.resume() }
356 }
357 if w.status == .failed {
358 logErr("\(name): finishWriting failed: \(w.error?.localizedDescription ?? "?")")
359 }
360 }
361
362 /// Caller must hold `lock`.
363 private func partManifestLocked() -> StreamManifest {
364 StreamManifest(
365 name: partIndex == 1 ? name : "\(name)-\(partIndex)",
366 file: url.lastPathComponent,
367 kind: kind,
368 displayID: displayID,
369 deviceUID: deviceUID,
370 width: width,
371 height: height,
372 fps: fps,
373 firstSampleHostSeconds: partFirstPTS,
374 lastSampleHostSeconds: partLastPTS,
375 durationSeconds: (partFirstPTS.isNaN || partLastPTS.isNaN) ? 0 : (partLastPTS - partFirstPTS),
376 frames: partFrames,
377 dropped: partDropped,
378 repeated: 0,
379 offsetSeconds: 0)
380 }
381
382 func manifests() -> [StreamManifest] {
383 lock.lock()
384 defer { lock.unlock() }
385 var all = doneParts
386 if started || all.isEmpty { all.append(partManifestLocked()) }
387 return all
388 }
389}
390
391struct RecorderError: Error, CustomStringConvertible, LocalizedError {
392 let description: String
393 init(_ description: String) { self.description = description }
394 var errorDescription: String? { description }
395}
396
397// MARK: - Constant-frame-rate video writer (screens)
398
399/// ScreenCaptureKit only delivers a frame when the screen changes, so a raw
400/// passthrough yields a variable, sparse frame rate whose duration ends at the
401/// last change rather than at the stop time — which would desync against audio.
402///
403/// This writer decouples capture from encoding: SCOutput hands every fresh
404/// frame to `update(_:)`, and an independent timer emits the most-recent frame
405/// at the target rate, timestamped on the host clock. The result stays dense,
406/// its duration matches wall-clock, and it never drifts against the audio.
407final class CFRVideoWriter: RecordingStream {
408 let name: String
409 let kind = "screen"
410 private(set) var url: URL
411 var displayID: UInt32?
412 let width: Int
413 let height: Int
414 let fps: Int
415
416 private let settings: [String: Any]
417 private var writer: AVAssetWriter
418 private var input: AVAssetWriterInput
419 private var adaptor: AVAssetWriterInputPixelBufferAdaptor
420 private let queue = DispatchQueue(label: "clover.cfr")
421 private let lock = NSLock()
422
423 private var latest: CVPixelBuffer?
424 private var lastEmitted: CVPixelBuffer?
425 private var timer: DispatchSourceTimer?
426
427 private var partFirstPTS = Double.nan
428 private var lastPTS = Double.nan
429 private var lastEmitHost = Double.nan
430 private var frames = 0
431 private var repeated = 0
432 private var dropped = 0
433 private var started = false
434 private var dead = false
435 private var partIndex = 1
436 private var doneParts: [StreamManifest] = []
437 private var rolloverDir: URL?
438
439 /// Longest gap between emitted frames while the screen is static. Real changes
440 /// emit immediately at full rate; this just keeps the timeline progressing so
441 /// a frozen screen costs ~1 fps instead of 30.
442 private let keepAliveSeconds = 1.0
443
444 init(url: URL, name: String, width: Int, height: Int, fps: Int, bitrate: Int, fallbackDir: URL? = nil)
445 throws
446 {
447 self.url = url
448 self.name = name
449 self.width = width
450 self.height = height
451 self.fps = fps
452 self.rolloverDir = fallbackDir
453
454 // Screen content is highly compressible: a long keyframe interval lets a
455 // nearly-static screen cost almost nothing (the periodic keyframes were the
456 // bulk of the size before), and frame reordering (B-frames) tightens it
457 // further. Average bitrate is just a ceiling for busy moments.
458 self.settings = [
459 AVVideoCodecKey: AVVideoCodecType.hevc,
460 AVVideoWidthKey: width,
461 AVVideoHeightKey: height,
462 AVVideoCompressionPropertiesKey: [
463 AVVideoAverageBitRateKey: bitrate,
464 AVVideoExpectedSourceFrameRateKey: fps,
465 AVVideoMaxKeyFrameIntervalKey: fps * 10,
466 AVVideoMaxKeyFrameIntervalDurationKey: 10.0,
467 AVVideoAllowFrameReorderingKey: true,
468 ],
469 ]
470 (self.writer, self.input, self.adaptor) = try Self.makeWriter(
471 url: url, settings: settings, width: width, height: height, name: name)
472 }
473
474 private static func makeWriter(
475 url: URL, settings: [String: Any], width: Int, height: Int, name: String
476 ) throws -> (AVAssetWriter, AVAssetWriterInput, AVAssetWriterInputPixelBufferAdaptor) {
477 let w = try AVAssetWriter(outputURL: url, fileType: .mov)
478 // Fragments bound the loss from a crash/power cut/vanishing disk to seconds.
479 w.movieFragmentInterval = CMTime(seconds: fragmentSeconds, preferredTimescale: 600)
480 let i = AVAssetWriterInput(mediaType: .video, outputSettings: settings)
481 i.expectsMediaDataInRealTime = true
482 let a = AVAssetWriterInputPixelBufferAdaptor(
483 assetWriterInput: i,
484 sourcePixelBufferAttributes: [
485 kCVPixelBufferPixelFormatTypeKey as String: kCVPixelFormatType_420YpCbCr8BiPlanarVideoRange,
486 kCVPixelBufferWidthKey as String: width,
487 kCVPixelBufferHeightKey as String: height,
488 ])
489 guard w.canAdd(i) else { throw RecorderError("cannot add video input for \(name)") }
490 w.add(i)
491 return (w, i, a)
492 }
493
494 /// Latest frame from ScreenCaptureKit; retained until replaced (SCK won't
495 /// recycle a buffer we still hold, so no copy is needed).
496 func update(_ sb: CMSampleBuffer) {
497 guard let pb = CMSampleBufferGetImageBuffer(sb) else { return }
498 lock.lock()
499 latest = pb
500 lock.unlock()
501 }
502
503 func start() {
504 let interval = 1.0 / Double(fps)
505 let t = DispatchSource.makeTimerSource(queue: queue)
506 t.schedule(deadline: .now() + interval, repeating: interval, leeway: .milliseconds(2))
507 t.setEventHandler { [weak self] in self?.tick() }
508 timer = t
509 t.resume()
510 }
511
512 private func tick() {
513 lock.lock()
514 let pb = latest
515 lock.unlock()
516 guard let pb, !dead else { return }
517
518 let now = hostSeconds()
519 let changed = pb !== lastEmitted
520
521 // Once running, drop identical frames unless it's time for a keep-alive.
522 if started, !changed, !lastEmitHost.isNaN, now - lastEmitHost < keepAliveSeconds {
523 return
524 }
525
526 if !started {
527 guard writer.startWriting() else {
528 logErr("\(name): startWriting failed: \(writer.error?.localizedDescription ?? "?")")
529 rolloverOnQueue()
530 return
531 }
532 writer.startSession(atSourceTime: CMTime(seconds: now, preferredTimescale: 1_000_000))
533 partFirstPTS = now
534 started = true
535 }
536 guard input.isReadyForMoreMediaData else {
537 dropped += 1
538 return
539 }
540 let time = CMTime(seconds: now, preferredTimescale: 1_000_000)
541 if adaptor.append(pb, withPresentationTime: time) {
542 frames += 1
543 lastPTS = now
544 lastEmitHost = now
545 if !changed { repeated += 1 }
546 lastEmitted = pb
547 } else {
548 dropped += 1
549 if writer.status == .failed {
550 logErr("\(name): append failed: \(writer.error?.localizedDescription ?? "?")")
551 rolloverOnQueue()
552 }
553 }
554 }
555
556 /// Abandon the current writer and continue into a fresh part file. Must run
557 /// on `queue`. The retained `latest` frame lives in memory, so the new part
558 /// picks up on the very next tick.
559 private func rolloverOnQueue() {
560 if started { doneParts.append(partManifestOnQueue()) }
561 if writer.status == .writing {
562 input.markAsFinished()
563 writer.finishWriting {} // fire-and-forget: the volume may be gone
564 }
565 started = false
566 partFirstPTS = .nan
567 lastPTS = .nan
568 frames = 0
569 repeated = 0
570 dropped = 0
571 lastEmitted = nil // force the next tick to emit a frame immediately
572 partIndex += 1
573 guard partIndex <= maxParts, let dir = rolloverDir else {
574 dead = true
575 logErr("\(name): stream lost (no rollover target or too many failures)")
576 return
577 }
578 let next = dir.appendingPathComponent("\(name)-\(partIndex).mov")
579 do {
580 try FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true)
581 (writer, input, adaptor) = try Self.makeWriter(
582 url: next, settings: settings, width: width, height: height, name: name)
583 url = next
584 logErr("\(name): rolled over to \(next.path)")
585 } catch {
586 dead = true
587 logErr("\(name): rollover failed (\(error.localizedDescription)) — stream lost")
588 }
589 }
590
591 func rollover(to dir: URL) {
592 queue.async { [weak self] in
593 guard let self, !self.dead,
594 !self.url.deletingLastPathComponent().path.hasPrefix(dir.path)
595 else { return }
596 self.rolloverDir = dir
597 self.rolloverOnQueue()
598 }
599 }
600
601 func health() -> StreamHealth {
602 queue.sync {
603 StreamHealth(name: name, started: started, dead: dead, lastAppendHost: lastEmitHost)
604 }
605 }
606
607 func finish() async {
608 timer?.cancel()
609 timer = nil
610 // Drain the timer queue so no tick races with finalization.
611 await withCheckedContinuation { (cont: CheckedContinuation<Void, Never>) in
612 queue.async { cont.resume() }
613 }
614 guard started, writer.status == .writing else {
615 if doneParts.isEmpty { logErr("\(name): no frames captured, nothing written") }
616 return
617 }
618 input.markAsFinished()
619 await withCheckedContinuation { (cont: CheckedContinuation<Void, Never>) in
620 writer.finishWriting { cont.resume() }
621 }
622 if writer.status == .failed {
623 logErr("\(name): finishWriting failed: \(writer.error?.localizedDescription ?? "?")")
624 }
625 }
626
627 /// Must run on `queue` (or after it is drained).
628 private func partManifestOnQueue() -> StreamManifest {
629 StreamManifest(
630 name: partIndex == 1 ? name : "\(name)-\(partIndex)",
631 file: url.lastPathComponent,
632 kind: kind,
633 displayID: displayID,
634 deviceUID: nil,
635 width: width,
636 height: height,
637 fps: fps,
638 firstSampleHostSeconds: partFirstPTS,
639 lastSampleHostSeconds: lastPTS,
640 durationSeconds: (partFirstPTS.isNaN || lastPTS.isNaN) ? 0 : (lastPTS - partFirstPTS),
641 frames: frames,
642 dropped: dropped,
643 repeated: repeated,
644 offsetSeconds: 0)
645 }
646
647 func manifests() -> [StreamManifest] {
648 queue.sync {
649 var all = doneParts
650 if started || all.isEmpty { all.append(partManifestOnQueue()) }
651 return all
652 }
653 }
654}
655
656// MARK: - Sample delegates
657
658final class SCOutput: NSObject, SCStreamOutput, SCStreamDelegate {
659 let label: String
660 var onStreamError: ((String) -> Void)?
661 private let onScreen: (CMSampleBuffer) -> Void
662 private let onAudio: ((CMSampleBuffer) -> Void)?
663
664 private let counterLock = NSLock()
665 private(set) var screenSeen = 0
666 private(set) var screenComplete = 0
667 private(set) var audioSeen = 0
668
669 init(
670 label: String, onScreen: @escaping (CMSampleBuffer) -> Void,
671 onAudio: ((CMSampleBuffer) -> Void)?
672 ) {
673 self.label = label
674 self.onScreen = onScreen
675 self.onAudio = onAudio
676 }
677
678 func stream(
679 _ stream: SCStream, didOutputSampleBuffer sampleBuffer: CMSampleBuffer,
680 of type: SCStreamOutputType
681 ) {
682 switch type {
683 case .screen:
684 counterLock.lock()
685 screenSeen += 1
686 counterLock.unlock()
687 guard CMSampleBufferGetImageBuffer(sampleBuffer) != nil, Self.isComplete(sampleBuffer) else {
688 return
689 }
690 counterLock.lock()
691 screenComplete += 1
692 counterLock.unlock()
693 onScreen(sampleBuffer)
694 case .audio:
695 counterLock.lock()
696 audioSeen += 1
697 counterLock.unlock()
698 onAudio?(sampleBuffer)
699 default:
700 break
701 }
702 }
703
704 func stream(_ stream: SCStream, didStopWithError error: Error) {
705 logErr("SCStream stopped with error: \(error.localizedDescription)")
706 onStreamError?("\(label): screen capture stopped — \(error.localizedDescription)")
707 }
708
709 static func isComplete(_ sb: CMSampleBuffer) -> Bool {
710 guard
711 let arr = CMSampleBufferGetSampleAttachmentsArray(sb, createIfNecessary: false)
712 as? [[SCStreamFrameInfo: Any]],
713 let info = arr.first,
714 let raw = info[.status] as? Int,
715 let status = SCFrameStatus(rawValue: raw)
716 else { return false }
717 return status == .complete
718 }
719}
720
721final class AVOutput: NSObject, AVCaptureVideoDataOutputSampleBufferDelegate,
722 AVCaptureAudioDataOutputSampleBufferDelegate
723{
724 private let cb: (CMSampleBuffer) -> Void
725 init(_ cb: @escaping (CMSampleBuffer) -> Void) { self.cb = cb }
726
727 func captureOutput(
728 _ output: AVCaptureOutput, didOutput sampleBuffer: CMSampleBuffer,
729 from connection: AVCaptureConnection
730 ) {
731 cb(sampleBuffer)
732 }
733}
recorder/engine/Sources/recorder/SequenceExport.swift created+189
...@@ -0,0 +1,189 @@
1import CryptoKit
2import Foundation
3
4// Emits a Clover Sequencer project (`.sq`) from a finished session folder — a
5// convenience duplicate of sync.json + markers.json that lays every recorded
6// stream on its own time-aligned track so a session opens for review in one
7// double-click. The Sequencer's on-disk model (sequencer/Model.swift) decodes
8// tolerantly, so emitting just the fields below is enough; the rest default.
9// Times are seconds throughout, matching the Sequencer model.
10
11struct MarkersFile: Codable { let markers: [Marker] }
12
13// v2 envelope: { formatVersion, project, view }. `view` is portable UI state we
14// leave at defaults (empty object → the Sequencer's ViewState defaults).
15private struct SeqDocument: Codable {
16 let formatVersion: Int
17 let project: SeqProject
18 let view: SeqView
19}
20private struct SeqView: Codable {}
21private struct SeqProject: Codable {
22 let fps: Double
23 let boardWidth: Int
24 let boardHeight: Int
25 let media: [SeqMedia]
26 let tracks: [SeqTrack]
27 let clips: [SeqClip]
28 let markers: [SeqMarker]
29 let preferredTakes: [String]
30}
31private struct SeqMedia: Codable {
32 let id: String
33 let path: String
34 let duration: Double
35 let fps: Double
36 let width: Int
37 let height: Int
38 let hasAudio: Bool
39 let isAudio: Bool
40 let cacheKey: String
41}
42// A track is just a hue now — its index in `tracks` is its number.
43private struct SeqTrack: Codable {
44 let hue: Double
45}
46private struct SeqClip: Codable {
47 let id: String
48 let kind: String
49 let mediaId: String
50 let track: String // TrackRef wire form: "v0", "v1", … (video lane index)
51 let start: Double
52 let srcIn: Double
53 let duration: Double
54 let speed: Double
55 let muted: Bool
56 let fadeIn: Double
57 let fadeOut: Double
58 let linkId: String
59 let newShot: Bool
60}
61private struct SeqMarker: Codable {
62 let id: String
63 let time: Double
64 let label: String
65}
66
67private func isAudioKind(_ k: String) -> Bool { k == "mic" || k == "system-audio" }
68
69/// Sequencer's content-addressed cache key — MUST match MediaPipeline.cacheKey
70/// (sequencer/MediaPipeline.swift): SHA256("path|size|mtime").hex.prefix(16).
71/// On project load Sequencer trusts this stored key rather than recomputing, so
72/// a wrong/empty value collides every clip onto one media's proxy. Computed from
73/// the same file the app will read, so it also lands as a cache hit.
74private func sequencerCacheKey(for url: URL) -> String {
75 let attrs = try? FileManager.default.attributesOfItem(atPath: url.path)
76 let size = (attrs?[.size] as? NSNumber)?.int64Value ?? 0
77 let mtime = (attrs?[.modificationDate] as? Date)?.timeIntervalSince1970 ?? 0
78 let seed = "\(url.path)|\(size)|\(Int(mtime))"
79 let digest = SHA256.hash(data: Data(seed.utf8))
80 return String(digest.map { String(format: "%02x", $0) }.joined().prefix(16))
81}
82
83/// Write `<dir>/<dirname>.sq`. Returns nil on success (or a silent skip when
84/// there's no sync.json — e.g. a crashed session that was never finalized), or
85/// an error message string on a real write failure.
86@discardableResult
87func writeSequenceFile(in dir: URL) -> String? {
88 let out = dir.appendingPathComponent("\(dir.lastPathComponent).sq")
89 guard let data = try? Data(contentsOf: dir.appendingPathComponent("sync.json")),
90 let manifest = try? JSONDecoder().decode(SessionManifest.self, from: data)
91 else { return nil }
92
93 // A 0-frame stream is a dead device (e.g. the 0-byte mic) — skip it rather
94 // than add unplayable media.
95 let streams = manifest.streams.filter { $0.frames > 0 && $0.durationSeconds > 0.01 }
96 guard !streams.isEmpty else { return nil }
97
98 // Rollover parts of one source share a displayID (screens) or deviceUID
99 // (mic/camera), so grouping by that keeps a part on its source's track rather
100 // than spawning a new lane. Desktop audio has neither and is the only one of
101 // its kind, so the kind alone is a stable key.
102 func laneKey(_ s: StreamManifest) -> String {
103 if let d = s.displayID { return "d\(d)" }
104 if let u = s.deviceUID { return "u\(u)" }
105 return s.kind
106 }
107 // Lanes ordered camera → screens → mic → desktop, so the viewer grid reads
108 // top-down the way you'd expect.
109 func priority(_ k: String) -> Int {
110 switch k {
111 case "camera": return 0
112 case "screen": return 1
113 case "mic": return 2
114 default: return 3 // system-audio
115 }
116 }
117
118 var laneOrder: [String] = []
119 var laneStreams: [String: [StreamManifest]] = [:]
120 for s in streams {
121 let key = laneKey(s)
122 if laneStreams[key] == nil { laneOrder.append(key) }
123 laneStreams[key, default: []].append(s)
124 }
125 laneOrder.sort { a, b in
126 let ka = laneStreams[a]![0], kb = laneStreams[b]![0]
127 if priority(ka.kind) != priority(kb.kind) { return priority(ka.kind) < priority(kb.kind) }
128 return ka.name < kb.name
129 }
130
131 let linkId = UUID().uuidString // whole session moves together (multicam)
132 var media: [SeqMedia] = []
133 var tracks: [SeqTrack] = []
134 var clips: [SeqClip] = []
135
136 for (index, key) in laneOrder.enumerated() {
137 // Golden-ratio hue spacing (matches ProjectModel.defaultHue) — adjacent
138 // lanes stay visibly distinct.
139 let hue = (Double(index) * 0.6180339887498949).truncatingRemainder(dividingBy: 1)
140 tracks.append(SeqTrack(hue: hue))
141 let trackRef = "v\(index)" // clips reference the lane by index
142 for s in laneStreams[key]! {
143 let audio = isAudioKind(s.kind)
144 let mediaId = UUID().uuidString
145 let fileURL = dir.appendingPathComponent(s.file)
146 media.append(
147 SeqMedia(
148 id: mediaId, path: fileURL.path,
149 duration: s.durationSeconds, fps: Double(s.fps ?? 30),
150 width: s.width ?? 0, height: s.height ?? 0,
151 hasAudio: audio, isAudio: audio, cacheKey: sequencerCacheKey(for: fileURL)))
152 clips.append(
153 SeqClip(
154 id: UUID().uuidString, kind: audio ? "audio" : "video", mediaId: mediaId,
155 track: trackRef, start: max(0, s.offsetSeconds), srcIn: 0,
156 duration: s.durationSeconds, speed: 1,
157 muted: s.kind == "system-audio", // desktop off by default; mic is the voice
158 fadeIn: 0, fadeOut: 0, linkId: linkId, newShot: false))
159 }
160 }
161
162 // Markers: hostSeconds is directly comparable to sync's tStartHostSeconds,
163 // placing each note at its exact spot on the same timeline as the clips.
164 var markers: [SeqMarker] = []
165 if let mdata = try? Data(contentsOf: dir.appendingPathComponent("markers.json")),
166 let file = try? JSONDecoder().decode(MarkersFile.self, from: mdata)
167 {
168 for m in file.markers {
169 markers.append(
170 SeqMarker(
171 id: UUID().uuidString, time: max(0, m.hostSeconds - manifest.tStartHostSeconds),
172 label: m.text ?? ""))
173 }
174 }
175
176 let fps = streams.first { !isAudioKind($0.kind) }?.fps ?? 30
177 let project = SeqProject(
178 fps: Double(fps), boardWidth: 1920, boardHeight: 1080, media: media, tracks: tracks,
179 clips: clips, markers: markers, preferredTakes: [])
180 let doc = SeqDocument(formatVersion: 2, project: project, view: SeqView())
181 do {
182 let encoder = JSONEncoder()
183 encoder.outputFormatting = [.prettyPrinted, .sortedKeys]
184 try encoder.encode(doc).write(to: out)
185 return nil
186 } catch {
187 return "failed to write \(out.lastPathComponent): \(error.localizedDescription)"
188 }
189}
recorder/engine/Sources/recorder/SpeakersReview.swift created+124
...@@ -0,0 +1,124 @@
1import AVFoundation
2import AppKit
3import SwiftUI
4
5// MARK: - speakers.json model
6
7struct SpeakerInfo: Decodable {
8 let label: String
9 let unknown: Bool
10 let sample: Sample
11 struct Sample: Decodable {
12 let start: Double
13 let end: Double
14 }
15}
16
17struct SpeakersFile: Decodable {
18 let speakers: [SpeakerInfo]
19}
20
21// MARK: - Review model
22
23@MainActor
24final class SpeakersModel: ObservableObject {
25 struct Row: Identifiable {
26 let id = UUID()
27 let original: String
28 let unknown: Bool
29 let start: Double
30 let end: Double
31 var name: String
32 }
33
34 @Published var rows: [Row]
35 let libraryNames: [String]
36 let audioURL: URL
37
38 private var player: AVAudioPlayer?
39 private var stopWork: DispatchWorkItem?
40
41 init(speakers: [SpeakerInfo], libraryNames: [String], audioURL: URL) {
42 rows = speakers.map {
43 Row(original: $0.label, unknown: $0.unknown, start: $0.sample.start, end: $0.sample.end,
44 name: $0.label)
45 }
46 self.libraryNames = libraryNames
47 self.audioURL = audioURL
48 }
49
50 func play(_ row: Row) {
51 stopWork?.cancel()
52 guard let p = try? AVAudioPlayer(contentsOf: audioURL) else { return }
53 p.prepareToPlay()
54 p.currentTime = row.start
55 p.play()
56 player = p
57 let work = DispatchWorkItem { [weak self] in self?.player?.stop() }
58 stopWork = work
59 DispatchQueue.main.asyncAfter(deadline: .now() + max(0.6, row.end - row.start), execute: work)
60 }
61
62 /// Only the speakers whose name was actually changed.
63 func mapping() -> [String: String] {
64 var m: [String: String] = [:]
65 for r in rows {
66 let n = r.name.trimmingCharacters(in: .whitespaces)
67 if !n.isEmpty && n != r.original { m[r.original] = n }
68 }
69 return m
70 }
71}
72
73// MARK: - Review view
74
75struct SpeakersView: View {
76 @ObservedObject var model: SpeakersModel
77 let onSave: ([String: String]) -> Void
78 let onCancel: () -> Void
79
80 var body: some View {
81 VStack(alignment: .leading, spacing: 12) {
82 Text("Who's speaking?").font(.headline)
83 Text("Name each voice — anyone you name is remembered and auto-labelled next time.")
84 .font(.caption).foregroundStyle(.secondary)
85
86 ForEach($model.rows) { $row in
87 HStack(spacing: 10) {
88 Button { model.play(row) } label: {
89 Image(systemName: "play.circle.fill").font(.title2)
90 }
91 .buttonStyle(.borderless)
92 .help("Hear this voice")
93
94 VStack(alignment: .leading, spacing: 1) {
95 Text(row.original).font(.caption2).foregroundStyle(.secondary)
96 TextField("Name", text: $row.name)
97 .textFieldStyle(.roundedBorder).frame(width: 200)
98 }
99
100 if !model.libraryNames.isEmpty {
101 Menu {
102 ForEach(model.libraryNames, id: \.self) { name in
103 Button(name) { row.name = name }
104 }
105 } label: {
106 Image(systemName: "person.crop.circle")
107 }
108 .menuStyle(.borderlessButton).frame(width: 28)
109 .help("Pick a known person")
110 }
111 }
112 }
113
114 HStack {
115 Button("Cancel") { onCancel() }
116 Spacer()
117 Button("Save") { onSave(model.mapping()) }.keyboardShortcut(.defaultAction)
118 }
119 .padding(.top, 6)
120 }
121 .padding(16)
122 .frame(width: 380)
123 }
124}
recorder/engine/Sources/recorder/main.swift created+188
...@@ -0,0 +1,188 @@
1import AVFoundation
2import CoreGraphics
3import Dispatch
4import Foundation
5
6// Clover Recorder capture core.
7//
8// recorder list
9// recorder record --out DIR [options]
10//
11// record options:
12// --label NAME session label (default "session")
13// --screen ID capture this display (repeatable; default: all)
14// --system-audio capture the system-audio mix into desktop.m4a
15// --mic UID|default capture this audio input into mic.m4a
16// --camera UID|default capture this camera into cam.mov
17// --fps N frame rate (default 30)
18// --max-width N clamp the longest screen side, px (default 3840; 0 = native)
19// --bpp F HEVC bits per pixel-frame (default 0.05)
20// --duration S auto-stop after S seconds (otherwise runs until SIGINT/SIGTERM)
21// --safe DIR internal-SSD dir for audio + rescued streams (default: --out DIR)
22
23func fail(_ message: String) -> Never {
24 logErr(message)
25 exit(1)
26}
27
28func runList() {
29 let sem = DispatchSemaphore(value: 0)
30 var exitCode: Int32 = 0
31 Task {
32 do {
33 let devices = try await Devices.discover()
34 let encoder = JSONEncoder()
35 encoder.outputFormatting = [.prettyPrinted, .sortedKeys]
36 let data = try encoder.encode(devices)
37 FileHandle.standardOutput.write(data)
38 FileHandle.standardOutput.write(Data("\n".utf8))
39 } catch {
40 logErr("list failed: \(error.localizedDescription)")
41 logErr("(Screen Recording permission may not be granted to this binary yet.)")
42 exitCode = 1
43 }
44 sem.signal()
45 }
46 sem.wait()
47 exit(exitCode)
48}
49
50func parseRecordConfig(_ args: [String]) -> RecordConfig {
51 var outDir: URL?
52 var label = "session"
53 var displayIDs: [CGDirectDisplayID] = []
54 var systemAudio = false
55 var micUID: String?
56 var cameraUID: String?
57 var fps = 30
58 var maxWidth = 3840
59 var bpp = 0.05
60 var duration: Double?
61 var logPath: String?
62 var safeDir: URL?
63 var cameraHeight = 1080
64 var cameraFps = 30
65
66 var i = 0
67 func next(_ flag: String) -> String {
68 i += 1
69 guard i < args.count else { fail("missing value for \(flag)") }
70 return args[i]
71 }
72
73 while i < args.count {
74 let arg = args[i]
75 switch arg {
76 case "--out": outDir = URL(fileURLWithPath: next(arg))
77 case "--label": label = next(arg)
78 case "--screen":
79 guard let id = UInt32(next(arg)) else { fail("--screen expects a numeric display id") }
80 displayIDs.append(id)
81 case "--system-audio": systemAudio = true
82 case "--mic": micUID = next(arg)
83 case "--camera": cameraUID = next(arg)
84 case "--fps": fps = Int(next(arg)) ?? 30
85 case "--max-width": maxWidth = Int(next(arg)) ?? 3840
86 case "--bpp": bpp = Double(next(arg)) ?? 0.05
87 case "--duration": duration = Double(next(arg))
88 case "--log": logPath = next(arg)
89 case "--safe": safeDir = URL(fileURLWithPath: next(arg))
90 case "--camera-height": cameraHeight = Int(next(arg)) ?? 1080
91 case "--camera-fps": cameraFps = Int(next(arg)) ?? 30
92 default: fail("unknown option: \(arg)")
93 }
94 i += 1
95 }
96
97 guard let outDir else { fail("--out DIR is required") }
98 return RecordConfig(
99 outDir: outDir, label: label, displayIDs: displayIDs, systemAudio: systemAudio,
100 micUID: micUID, cameraUID: cameraUID, fps: fps, maxWidth: maxWidth, bitsPerPixel: bpp,
101 duration: duration, logPath: logPath, safeDir: safeDir,
102 cameraHeight: cameraHeight, cameraFps: cameraFps)
103}
104
105func runRecord(_ args: [String]) {
106 let cfg = parseRecordConfig(args)
107 if let logPath = cfg.logPath { openLogFile(logPath) }
108 logInfo("recorder \(recorderVersion) starting: \(cfg.outDir.path)")
109 let engine = CaptureEngine(cfg)
110
111 // Never block the main thread: ScreenCaptureKit delivers start/stop
112 // completions on the main queue, so we keep main free via dispatchMain()
113 // and drive everything from background queues / Tasks.
114 let stopGuard = StopOnce()
115 func triggerStop() {
116 guard stopGuard.begin() else { return }
117 logInfo("stopping…")
118 Task {
119 await engine.stop()
120 exit(0)
121 }
122 }
123
124 Task {
125 do {
126 try await engine.start()
127 } catch {
128 logErr("start failed: \(error.localizedDescription)")
129 exit(1)
130 }
131 }
132
133 // Stop on SIGINT / SIGTERM, or after --duration.
134 let sigQueue = DispatchQueue(label: "clover.signals")
135 var sources: [DispatchSourceSignal] = []
136 for sig in [SIGINT, SIGTERM] {
137 signal(sig, SIG_IGN)
138 let src = DispatchSource.makeSignalSource(signal: sig, queue: sigQueue)
139 src.setEventHandler { triggerStop() }
140 src.resume()
141 sources.append(src)
142 }
143 if let duration = cfg.duration {
144 sigQueue.asyncAfter(deadline: .now() + duration) { triggerStop() }
145 }
146 signalSources = sources // keep alive
147
148 dispatchMain()
149}
150
151/// One-shot guard so duration + signal can't both run finalize.
152final class StopOnce {
153 private let lock = NSLock()
154 private var started = false
155 func begin() -> Bool {
156 lock.lock()
157 defer { lock.unlock() }
158 if started { return false }
159 started = true
160 return true
161 }
162}
163
164var signalSources: [DispatchSourceSignal] = []
165
166// Entry point. With no args (or `menubar`) it runs as a menubar app; with
167// `record`/`list` it runs headless for SSH/scripting. Same signed binary either
168// way, so the one Screen Recording grant covers both.
169let argv = Array(CommandLine.arguments.dropFirst())
170switch argv.first {
171case "list": runList()
172case "record": runRecord(Array(argv.dropFirst()))
173case "sequence":
174 // recorder sequence <session-dir> — (re)generate <name>.sq from sync.json.
175 guard argv.count > 1 else { fail("usage: recorder sequence <session-dir>") }
176 let dir = URL(fileURLWithPath: argv[1])
177 if let err = writeSequenceFile(in: dir) { fail(err) }
178 if FileManager.default.fileExists(
179 atPath: dir.appendingPathComponent("\(dir.lastPathComponent).sq").path)
180 {
181 logInfo("wrote \(dir.lastPathComponent).sq")
182 } else {
183 logInfo("skipped: no usable sync.json in \(dir.path)")
184 }
185 exit(0)
186case nil, "menubar": MainActor.assumeIsolated { runMenubar() }
187default: fail("unknown command: \(argv.first ?? "")")
188}
recorder/setup-diarization.sh created+31
...@@ -0,0 +1,31 @@
1#!/usr/bin/env bash
2# Optional "precise mode": pyannote speaker diarization for exact turn
3# boundaries (so mid-sentence interjections are attributed correctly).
4#
5# It lives in its OWN venv because pyannote 3.x needs older torch/torchaudio/
6# huggingface_hub than the main transcription venv — isolating it avoids
7# breaking Whisper/forced-alignment. session_transcript.py auto-detects this
8# venv and uses it; without it, it falls back to cluster-then-match labeling.
9#
10# Prerequisites (one-time, free):
11# 1. A HuggingFace token — log in once so it's cached:
12# ~/.clover-diarize/.venv/bin/huggingface-cli login (or set HF_TOKEN)
13# 2. Accept the model terms (click "Agree") at:
14# https://huggingface.co/pyannote/speaker-diarization-3.1
15# https://huggingface.co/pyannote/segmentation-3.0
16#
17# bash ~/devel/creative-toolkit/recorder/setup-diarization.sh
18set -euo pipefail
19
20DIR="$HOME/.clover-diarize"
21echo "==> creating diarization venv at $DIR"
22mkdir -p "$DIR"
23cd "$DIR"
24uv venv
25# Pinned, mutually-compatible set (torch 2.4 keeps numpy 2; torchaudio 2.4 still
26# exposes AudioMetaData; hf_hub 0.25 still has use_auth_token).
27uv pip install \
28 "pyannote.audio==3.3.2" "torch==2.4.1" "torchaudio==2.4.1" \
29 "huggingface_hub==0.25.2" matplotlib
30
31echo "✅ precise mode ready (ensure the token is logged in and model terms accepted)"
recorder/setup-dictation.sh created+26
...@@ -0,0 +1,26 @@
1#!/usr/bin/env bash
2# One-time: set up local Whisper for the marker overlay's voice dictation.
3#
4# Creates a venv at ~/.clover-whisper with MLX Whisper (Apple-Silicon optimized)
5# and pre-downloads large-v3-turbo (~1.5 GB). Transcription then runs fully
6# on-device in ~1.5 s per note on this machine.
7#
8# bash ~/devel/creative-toolkit/recorder/setup-dictation.sh
9set -euo pipefail
10
11DIR="$HOME/.clover-whisper"
12echo "==> creating venv at $DIR"
13mkdir -p "$DIR"
14cd "$DIR"
15uv venv
16# mlx-whisper: transcription · speechbrain/torchaudio/scipy: speaker ID + clustering
17uv pip install mlx-whisper speechbrain torchaudio scipy
18
19echo "==> pre-downloading whisper-large-v3-turbo (one-time)"
20say -o /tmp/clover-dictation-warm.aiff "Clover dictation is ready." 2>/dev/null || true
21"$DIR/.venv/bin/python" - /tmp/clover-dictation-warm.aiff <<'PY' || true
22import sys, mlx_whisper
23mlx_whisper.transcribe(sys.argv[1], path_or_hf_repo="mlx-community/whisper-large-v3-turbo")
24PY
25
26echo "✅ dictation ready ($DIR/.venv/bin/python)"
recorder/setup-signing.sh created+82
...@@ -0,0 +1,82 @@
1#!/usr/bin/env bash
2# One-time: create a stable self-signed code-signing identity in a dedicated
3# keychain so the Screen Recording grant survives rebuilds.
4#
5# Why a dedicated keychain (not login): it can be created, unlocked, and
6# imported into entirely over SSH with a known password — no GUI, no touching
7# your login keychain. TCC keys the Screen Recording grant on the app's
8# *designated requirement* (bundle id + cert leaf), which stays identical across
9# rebuilds, so you grant once and never get re-prompted.
10#
11# The cert is self-signed and untrusted; that's fine — Gatekeeper is bypassed
12# for locally-built, non-quarantined apps, and TCC matching doesn't need trust.
13#
14# Safe to re-run; it's idempotent. The keychain password is local-only and has
15# nothing to do with your macOS login password.
16set -euo pipefail
17
18CN="Clover Code Signing"
19KC="$HOME/Library/Keychains/clover-signing.keychain-db"
20KCPW="${CLOVER_KEYCHAIN_PW:-clover}"
21P12="$HOME/.clover-code-signing.p12" # backup so the identity survives keychain loss
22
23ensure_searchlist() {
24 local existing
25 existing=$(security list-keychains -d user | sed -e 's/^ *//' -e 's/"//g')
26 case "$existing" in
27 *clover-signing*) ;;
28 *) security list-keychains -d user -s "$KC" $existing ;;
29 esac
30}
31
32if [[ -f "$KC" ]] && security find-identity -p codesigning "$KC" 2>/dev/null | grep -q "$CN"; then
33 security unlock-keychain -p "$KCPW" "$KC" 2>/dev/null || true
34 ensure_searchlist
35 echo "✅ '$CN' already present in $KC"
36 exit 0
37fi
38
39TMP="$(mktemp -d)"
40trap 'rm -rf "$TMP"' EXIT
41
42if [[ -f "$P12" ]]; then
43 echo "==> reusing saved identity from $P12 (keeps the same TCC requirement)"
44 cp "$P12" "$TMP/cs.p12"
45else
46 echo "==> generating new self-signed code-signing certificate"
47 cat > "$TMP/cs.conf" <<EOF
48[ req ]
49distinguished_name = dn
50x509_extensions = v3
51prompt = no
52[ dn ]
53CN = $CN
54[ v3 ]
55keyUsage = critical, digitalSignature
56extendedKeyUsage = critical, codeSigning
57basicConstraints = critical, CA:false
58EOF
59 openssl req -x509 -newkey rsa:2048 -keyout "$TMP/cs.key" -out "$TMP/cs.crt" \
60 -days 3650 -nodes -config "$TMP/cs.conf" >/dev/null 2>&1
61 openssl pkcs12 -export -inkey "$TMP/cs.key" -in "$TMP/cs.crt" -out "$TMP/cs.p12" \
62 -passout pass:clover -name "$CN" >/dev/null 2>&1
63 cp "$TMP/cs.p12" "$P12"
64 chmod 600 "$P12"
65fi
66
67echo "==> (re)creating dedicated keychain $KC"
68security delete-keychain "$KC" 2>/dev/null || true
69security create-keychain -p "$KCPW" "$KC"
70security set-keychain-settings "$KC" # no auto-lock timeout
71security unlock-keychain -p "$KCPW" "$KC"
72security import "$TMP/cs.p12" -k "$KC" -P clover -A -T /usr/bin/codesign
73security set-key-partition-list -S apple-tool:,apple:,unsigned: -s -k "$KCPW" "$KC" >/dev/null 2>&1 || true
74ensure_searchlist
75
76echo
77if security find-identity -p codesigning "$KC" | grep -q "$CN"; then
78 echo "✅ '$CN' ready in $KC. build.sh will sign with it automatically."
79else
80 echo "⚠️ identity not found after setup — check the output above."
81 exit 1
82fi
recorder/uvc/uvc-powerline.c created+80
...@@ -0,0 +1,80 @@
1// Set a UVC webcam's "Power Line Frequency" (anti-flicker) control over USB.
2//
3// macOS's camera API (AVFoundation) doesn't expose this control, but the camera
4// accepts a UVC SET_CUR on its default control pipe via IOKit even while the
5// system UVC driver is streaming — which cancels mains-flicker banding in
6// hardware (verified: the scrolling bars disappear the instant it's set).
7//
8// uvc-powerline <value> [vidHex] [pidHex]
9// value: 0 = off, 1 = 50 Hz, 2 = 60 Hz
10//
11// Defaults to the ZS CAMERA (VID 0x328f / PID 0x0072). Exits 0 if the control
12// was accepted by at least one processing-unit entity.
13#include <CoreFoundation/CoreFoundation.h>
14#include <IOKit/IOCFPlugIn.h>
15#include <IOKit/IOKitLib.h>
16#include <IOKit/usb/IOUSBLib.h>
17#include <stdio.h>
18#include <stdlib.h>
19
20#define UVC_SET_CUR 0x01
21#define PU_POWER_LINE_FREQUENCY_CONTROL 0x05
22
23int main(int argc, char** argv) {
24 int value = (argc > 1) ? atoi(argv[1]) : 2;
25 SInt32 vid = (argc > 2) ? (SInt32)strtol(argv[2], NULL, 16) : 0x328f;
26 SInt32 pid = (argc > 3) ? (SInt32)strtol(argv[3], NULL, 16) : 0x0072;
27
28 CFMutableDictionaryRef match = IOServiceMatching(kIOUSBDeviceClassName);
29 CFNumberRef vr = CFNumberCreate(NULL, kCFNumberSInt32Type, &vid);
30 CFNumberRef pr = CFNumberCreate(NULL, kCFNumberSInt32Type, &pid);
31 CFDictionarySetValue(match, CFSTR(kUSBVendorID), vr);
32 CFDictionarySetValue(match, CFSTR(kUSBProductID), pr);
33 CFRelease(vr);
34 CFRelease(pr);
35
36 io_service_t svc = IOServiceGetMatchingService(kIOMainPortDefault, match);
37 if (!svc) {
38 fprintf(stderr, "uvc-powerline: camera %04x:%04x not found\n", vid, pid);
39 return 1;
40 }
41
42 IOCFPlugInInterface** plug = NULL;
43 SInt32 score;
44 IOCreatePlugInInterfaceForService(
45 svc, kIOUSBDeviceUserClientTypeID, kIOCFPlugInInterfaceID, &plug, &score);
46 IOObjectRelease(svc);
47 if (!plug) return 1;
48
49 IOUSBDeviceInterface** dev = NULL;
50 (*plug)->QueryInterface(plug, CFUUIDGetUUIDBytes(kIOUSBDeviceInterfaceID), (LPVOID*)&dev);
51 (*plug)->Release(plug);
52 if (!dev) return 1;
53
54 IOReturn r = (*dev)->USBDeviceOpen(dev);
55 if (r != kIOReturnSuccess) {
56 r = (*dev)->USBDeviceOpenSeize(dev);
57 if (r != kIOReturnSuccess) {
58 fprintf(stderr, "uvc-powerline: cannot open device (0x%x)\n", r);
59 (*dev)->Release(dev);
60 return 2;
61 }
62 }
63
64 UInt8 val = (UInt8)value;
65 int ok = 0;
66 for (int entity = 1; entity <= 6; entity++) {
67 IOUSBDevRequest req;
68 req.bmRequestType = USBmakebmRequestType(kUSBOut, kUSBClass, kUSBInterface);
69 req.bRequest = UVC_SET_CUR;
70 req.wValue = (PU_POWER_LINE_FREQUENCY_CONTROL << 8);
71 req.wIndex = (entity << 8) | 0; // VideoControl interface 0
72 req.wLength = 1;
73 req.pData = &val;
74 if ((*dev)->DeviceRequest(dev, &req) == kIOReturnSuccess) ok = 1;
75 }
76
77 (*dev)->USBDeviceClose(dev);
78 (*dev)->Release(dev);
79 return ok ? 0 : 3;
80}
scripts/exr_flip_z.py created+66
...@@ -0,0 +1,66 @@
1# Fusion uses negative Z values for the depth buffer, while Blender denotes
2# this with positive values. For "Depth Merge" and other nodes to work
3# correctly, Blender's output must be flipped. I am unaware of how to do
4# this in Blender itself, hence this simple post processor.
5import OpenEXR
6import Imath
7import numpy as np
8import argparse
9import os
10
11Z_FLIPPED_METADATA_KEY = "zBufferFlipped"
12
13def invert_z_buffer(exr_input_path):
14 exr_file = OpenEXR.InputFile(exr_input_path)
15
16 header = exr_file.header()
17 channels = header['channels'];
18 part_names = channels.keys()
19
20 if Z_FLIPPED_METADATA_KEY in header:
21 print(f"Skipping {exr_input_path}")
22 exr_file.close()
23 return
24
25 processed_parts = {}
26
27 for part_name in part_names:
28 pixel_type = header['channels'][part_name].type
29 if pixel_type == Imath.PixelType(Imath.PixelType.HALF):
30 dtype = np.float16
31 elif pixel_type == Imath.PixelType(Imath.PixelType.FLOAT):
32 dtype = np.float32
33 else:
34 raise ValueError(f"Unsupported pixel type {pixel_type} for channel {part_name}.")
35
36 channel_data = exr_file.channel(part_name, pixel_type)
37 channel_data_array = np.frombuffer(channel_data, dtype=dtype)
38
39 if "Depth.Z" in part_name:
40 channel_data_array = -channel_data_array
41
42 processed_parts[part_name] = channel_data_array.tobytes()
43
44 header[Z_FLIPPED_METADATA_KEY] = 1
45
46 exr_output = OpenEXR.OutputFile(exr_input_path, header)
47
48 exr_output.writePixels(processed_parts)
49
50 exr_file.close()
51 exr_output.close()
52
53 print(f"Processed: {exr_input_path}")
54
55if __name__ == "__main__":
56 parser = argparse.ArgumentParser(description="Invert the Z-buffer in multipart EXR files.")
57
58 parser.add_argument('exr_files', nargs='+', help="List of EXR files to process.")
59
60 args = parser.parse_args()
61
62 for exr_file in args.exr_files:
63 if os.path.exists(exr_file):
64 invert_z_buffer(exr_file)
65 else:
66 print(f"File not found: {exr_file}")
scripts/import_quicktime_to_fusion.py created+142
...@@ -0,0 +1,142 @@
1#!/usr/bin/env python3
2import subprocess
3import os
4import sys
5import re
6import glob
7import pyperclip
8import time
9
10def run_command(cmd, shell=False):
11 """Run a command and return its output, exit on failure"""
12 try:
13 if shell:
14 result = subprocess.run(cmd, shell=True, check=True, text=True, capture_output=True)
15 else:
16 result = subprocess.run(cmd, check=True, text=True, capture_output=True)
17 return result.stdout.strip()
18 except subprocess.CalledProcessError as e:
19 print(f"Error executing command: {cmd}")
20 print(f"Error message: {e.stderr}")
21 sys.exit(1)
22 except Exception as e:
23 print(f"Unexpected error running command: {e}")
24 sys.exit(1)
25
26def find_file(base_name):
27 """Find a file with the given base name in the specified directory structure"""
28 search_path = "/Volumes/Project/*/Film/**/*"
29
30 try:
31 # Expand the glob pattern to find all matching files
32 matching_files = []
33 for project_dir in glob.glob("/Volumes/Project/*/"):
34 for root, dirs, files in os.walk(os.path.join(project_dir, "Film")):
35 # Skip hidden directories
36 dirs[:] = [d for d in dirs if not d.startswith('.')]
37 for file in files:
38 if file == base_name and not file.startswith('.'):
39 matching_files.append(os.path.join(root, file))
40
41 if not matching_files:
42 print(f"Error: Could not find file '{base_name}' in {search_path}")
43 sys.exit(1)
44 elif len(matching_files) > 1:
45 print(f"Warning: Found multiple matches for '{base_name}'. Using the first one.")
46
47 return matching_files[0]
48 except Exception as e:
49 print(f"Error searching for file: {e}")
50 sys.exit(1)
51
52def activate_app(app_name):
53 """Activate an application by name"""
54 try:
55 script = f'tell application "{app_name}" to activate'
56 subprocess.run(["osascript", "-e", script], check=True)
57 except Exception as e:
58 print(f"Error activating {app_name}: {e}")
59 sys.exit(1)
60
61def main():
62 # Step 1: Run Apple Script to get frame and name from QuickTime Player
63 print("Step 1: Getting frame and name from QuickTime Player...")
64 applescript = '''
65 tell application "QuickTime Player" to tell document 1
66 set t to current time
67 step forward
68 set k to current time
69 set r to 1 / (k - t)
70 step backward
71 return "" & (round (r * t) rounding down) & ":" & name
72 end tell
73 '''
74
75 try:
76 result = subprocess.run(["osascript", "-e", applescript],
77 check=True, text=True, capture_output=True)
78 frame_and_name = result.stdout.strip()
79
80 if not frame_and_name or ":" not in frame_and_name:
81 print("Error: AppleScript did not return expected output")
82 sys.exit(1)
83
84 target_frame, name = frame_and_name.split(":", 1)
85 target_frame = int(target_frame)
86
87 print(f"Target frame: {target_frame}")
88 print(f"File name: {name}")
89 except Exception as e:
90 print(f"Error running AppleScript: {e}")
91 sys.exit(1)
92
93 # Step 2: Find the file on disk
94 print("\nStep 2: Finding file on disk...")
95 file_path = find_file(name)
96 print(f"Found file at: {file_path}")
97
98 # Step 3: Run Fusion script to get current frame
99 print("\nStep 3: Getting current frame from Fusion...")
100 fusion_script_cmd = "'/Applications/Blackmagic Fusion 19/Fusion.app/Contents/Libraries/fuscript' -x 'print(\"[[\"..Fusion().CurrentComp.CurrentTime..\"]]\")'"
101 fusion_output = run_command(fusion_script_cmd, shell=True)
102
103 # Extract the frame number from the output
104 match = re.search(r'\[\[(\d+)\]\]', fusion_output)
105 if not match:
106 print(f"Error: Could not parse frame number from Fusion output: {fusion_output}")
107 sys.exit(1)
108
109 current_frame = int(match.group(1))
110 print(f"Current frame: {current_frame}")
111
112 # Step 4: Compute TRIM_IN and EXTEND_FIRST
113 print("\nStep 4: Computing TRIM_IN and EXTEND_FIRST...")
114 trim_in = 0
115 extend_first = 0
116
117 if target_frame > current_frame:
118 trim_in = target_frame - current_frame
119 print(f"Target frame is AFTER current frame. Setting TRIM_IN to {trim_in}")
120 else:
121 extend_first = current_frame - target_frame
122 print(f"Target frame is BEFORE current frame. Setting EXTEND_FIRST to {extend_first}")
123
124 # Step 5: Create the Fusion loader text and copy to clipboard
125 print("\nStep 5: Creating Fusion loader text and copying to clipboard...")
126 fusion_text = f'''{{Tools = ordered() {{Loader = Loader {{Clips = {{Clip {{ID = "Clip1",Filename = "{file_path}",FormatID = "QuickTimeMovies",Length = 9999999,Multiframe = true,TrimIn = {trim_in},TrimOut = 9999999,ExtendFirst = {extend_first},ExtendLast = 0,Loop = 1,AspectMode = 0,Depth = 0,TimeCode = 0,GlobalStart = 0,GlobalEnd = 9999999}}}},CtrlWZoom = false,Inputs = {{["Gamut.SLogVersion"] = Input {{ Value = FuID {{ "SLog2" }}, }}}},}}}},ActiveTool = "Loader"}}'''
127
128 try:
129 pyperclip.copy(fusion_text)
130 print("Text copied to clipboard:")
131 print(fusion_text)
132 except Exception as e:
133 print(f"Error copying to clipboard: {e}")
134 sys.exit(1)
135
136 # Finally, activate Fusion but don't paste
137 print("\nActivating Fusion...")
138 activate_app("Fusion")
139 print("Script completed successfully!")
140
141if __name__ == "__main__":
142 main()
sequencer/CLAUDE.md created+84
...@@ -0,0 +1,84 @@
1## What this is
2
3Sequencer ("Clover Sequencer") is a native macOS app, not a video editor. It helps storyboard videos, sync multicam clips in time, and review multicam recordings. It's built to feed Blackmagic Fusion Studio: the workflow is arrange/trim media in Sequencer, then copy-paste the selected clips into Fusion as Loader nodes. It is not useful standalone from Fusion's perspective.
4
5## Build & run
6
7```sh
8swift build # compile
9./run.sh # build, kill running instance, copy binary into Sequencer.app, relaunch
10```
11
12`run.sh` is the standard dev loop — it copies `.build/debug/Sequencer` into `Sequencer.app/Contents/MacOS/Sequencer` and reopens the app bundle (needed for a proper app identity/menu bar, not just a bare executable).
13
14There is no test target in Package.swift. Verification instead happens through two CLI-flag-driven harnesses baked into `main.swift`:
15
16```sh
17swift run Sequencer --selftest <mediafile> # headless pipeline check (see Selftest.swift)
18swift run Sequencer --uitest # offscreen TimelineView harness (see UITest.swift)
19```
20
21- `--selftest` exercises the real media pipeline against a file you pass in: ffprobe, filmstrip/waveform generation, chunk-proxy building, AVFoundation playability of the built proxy, and prints sample Fusion Lua output. Useful when touching `MediaPipeline.swift` or `ChunkedProxy.swift`.
22- `--uitest` hosts the real `TimelineView` in an offscreen window and drives it with synthetic `NSEvent`s (move, trim, slip, stretch, box select, split, links, storyboard split, comp parsing, fades, plus file-format migration/round-trip — ~100 assertions, PASS/FAIL printer). Useful when touching timeline gesture code.
23
24Requires `ffmpeg`/`ffprobe` on PATH for anything touching media (probing, filmstrips, proxies).
25
26## Architecture
27
28**AppKit only — no SwiftUI, no Combine.** Views are `NSView` subclasses that redraw imperatively: state changes post to `NotificationCenter`, views observe and set `needsDisplay = true`, AppKit calls `draw(_:)` on the next pass. There is no data-binding layer to reach for; if you're adding reactive UI, follow this same notify-and-redraw pattern.
29
30### Data model & mutation (`Model.swift`, `Store.swift`)
31
32`ProjectModel` is a plain value type (`Codable`, `Equatable`): media items, tracks, clips, markers, storyboard boards. All custom `Codable` inits decode tolerantly (`decodeIfPresent` with defaults) so older saved projects keep opening — preserve this pattern when adding fields.
33
34**Tracks are numbered, not identified.** A `Track` is *just a hue*; its index in `ProjectModel.tracks` is its number (displayed as index+1). A clip references its lane via `Clip.track: TrackRef` — `.video(Int)`, `.storyboard`, or `.fusion`. The storyboard lane isn't stored in `tracks` (it's implied by the presence of `.storyboard` clips, `hasStoryboard`); the Fusion band holds no clips. `laneRefs` yields the display order (storyboard on top, then video tracks). Deleting a video track (`removeTrack(at:)`) **renumbers** the clips on higher lanes down — the work UUIDs used to make free.
35
36**The `.sq` file is a document package** (a bundle Finder shows as one file): `project.json` (a `SequencerDocument` envelope `{ formatVersion, project, view }`) plus a `Storyboard/NN.png` per storyboard panel. `ProjectDocument` (`Document.swift`, an `NSDocument`) reads/writes it via `read(from url:)` / `fileWrapper(ofType:)`. Legacy **flat** `.sq` JSON files still open (`read` detects file-vs-directory and pulls rasters from a sibling `Storyboard/` folder); the first save rewrites them as a package. Legacy bare-`ProjectModel` files (v1, UUID-keyed tracks) are also detected and migrated on read — keep both paths working. Media `cacheKey`s are self-healed on load and sanitized at the cache-path boundary (`MediaPipeline.isValidCacheKey`/`normalizedCacheKey`) so a blank or malformed key can never collide or escape the cache root.
37
38**This is a multi-document app** (see "Per-document architecture" below): every per-project service is an instance on a `DocumentContext`, not a global singleton. `Store` (one per open document, reached as `ctx.store`) owns the model, selection, and undo/redo; persistence and autosave belong to the owning `NSDocument` (`Store.changed()` bridges dirty state via `ctx.document?.updateChangeCount`). Undo is a snapshot stack of the whole (small) model — no diffing. Three mutation modes matter:
39- `mutate { }` — one discrete undoable edit.
40- `beginGesture()` / `updateGesture { }` / `endGesture()` — continuous drags collapse into a single undo step; `updateGesture` recomputes from the gesture-start snapshot each call so there's no drift.
41- `preview { }` / `commitPreview(from:)` — live non-undoable edits (e.g. the color picker) that commit as one step when done.
42
43Every mutation runs `normalizeStoryboards()` before committing — storyboard panels are "start-only" (duration is implicit, derived from the next panel's start), so this keeps stored durations consistent. Notifications posted after mutation: `.projectChanged`, `.selectionChanged`, `.documentStateChanged`, `.mediaStatusChanged`, `.viewerNeedsRefresh`.
44
45Non-undoable session/UI state (track hide/focus, pane heights, laneScale, snapping, current tool, draw color) lives on the per-window `SessionState` (`ctx.session`), outside `Store` — do not put it in `ProjectModel` (undo must never toggle visibility). `UI` (in `AppDelegate.swift`) now holds only constants (SF Symbols, extension sets). The *portable* subset (hide/focus/heights keyed by `TrackRef`, zoom, snapping, previews-on-left, priority pane, Fusion band) is serialized into the `.sq` envelope's `view` block via `SessionState.captureViewState()` / `apply(_:)` — so a project reopens looking the way it was left, without entering the undo model.
46
47### Per-document architecture (`DocumentContext.swift`, `Document.swift`, `WindowController.swift`)
48
49Each open project is a `ProjectDocument: NSDocument` owning a `DocumentContext` — the per-document service bag. Everything per-project is an instance on it: `ctx.store`, `ctx.playback`, `ctx.players`, `ctx.chunks`, `ctx.comps`, `ctx.boards`, `ctx.session`. Each service holds an `unowned var ctx` back-reference, so service-to-service calls go through `ctx.*`. Views reach their state through a stored `var ctx` injected at construction (`SequencerWindowController` sets `timeline/viewer/transport`'s `ctx`; the grid injects its cells; the storyboard editor is re-targeted per `open(clipId:ctx:)`). **Do not reach for a global `.shared` for per-project state** — only `MediaPipeline` (content-addressed media cache) and `Theme` are genuinely global. `DocumentContext.current` resolves the front document's context for app-level actions (Settings/Export/cache eviction); `DocumentContext.headless` backs the `--uitest`/`--selftest` harnesses.
50
51`SequencerWindowController` (one per window) owns the split layout, previews pop-out, and every per-document menu action (`@objc func`s targeting the first responder). `AppDelegate` is now slim: it builds the menu bar and handles app-level actions only (New/Open route to `NSDocumentController`; Save/Save As/Close to `NSDocument`).
52
53**Notifications:** the 60 Hz `.playheadChanged` runs on a per-document bus (`ctx.notify`) so a playing window only redraws itself. Every other notification is posted app-wide on `.default` — deliberately: each handler reads its own `ctx`, so a broadcast is correct (never cross-contaminates state) and the extra redraw is cheap. When adding a hot-path (high-frequency) signal, put it on `ctx.notify` and bind the observer against the injected `ctx` (see the `ctx.didSet` re-bind in the three main views); everything else can stay on `.default`. `MediaPipeline` (global) protects and evicts across **all** open documents via `DocumentContext.allLive`.
54
55### Media & proxy playback pipeline
56
57This is the most complex subsystem — a three-stage pipeline. `ChunkManager` and `PlaybackController`/`PlayerManager` are per-document (`ctx.chunks`, `ctx.playback`, `ctx.players`); `MediaPipeline` is the one global (a shared content-addressed cache):
58
591. **`MediaPipeline`** (`MediaPipeline.swift`) — wraps ffprobe/ffmpeg. Probes media, generates filmstrips (thumbnail strips) and waveforms async, LRU content-addressed cache (default 50GB). Cache key = `SHA256(path|size|mtime)`, so moved/remounted files with identical content still hit cache.
602. **`ChunkManager`** (`ChunkedProxy.swift`) — demand-driven **30-second ProRes Proxy chunks** instead of whole-file transcodes. Builds chunks around the playhead and on-timeline clip ranges, three priority queues (urgent/background/failed). Adaptive quality (4 resolution/fps tiers) steps down/up based on measured encode wall-time vs. realtime ratio, and distinguishes network vs. encode bottlenecks. Composes ready chunks + original-file fallback into an `AVComposition`, versioned so playback only swaps when it's a strict upgrade (avoids black-frame flashes).
613. **`PlaybackController`** + `PlayerManager` (`PlaybackController.swift`) — master clock anchored to `CACurrentMediaTime()`; all players chase this one authoritative time at 60Hz. Supports reverse and J/K/L shuttle speeds (±1/2/4/8/16/32/64). One `AVPlayer` per video track, one per overlapping audio clip (audio gets looser sync tolerance since originals live on NAS). Seeks coalesce while one is in-flight.
62
63When working on playback bugs, the mental model is: `MediaPipeline` produces cached derived assets → `ChunkManager` decides what to build and assembles compositions → `PlaybackController` drives players against those compositions on a shared clock.
64
65### Fusion integration (`FusionExport.swift`, `FusionComps.swift`)
66
67- **Export direction (Sequencer → Fusion):** `FusionExport.copySelectedClips()` generates a Lua table describing one Loader node per clip (original media path — never the proxy — with source in/out trims and timeline position), pasted directly into Fusion Studio's Flow view.
68- **Import direction (Fusion → Sequencer):** `FusionComps` scans a comps folder for `.comp` files, parsing the filename prefix for frame range/title (e.g. `0200-0681_intro.comp`) and parsing the `.comp` Lua text for Saver nodes to find the rendered image sequence. These render as a band in the timeline/viewer, like an extra track, with the same hide/focus (F/H) controls as regular tracks.
69
70### Storyboard (`Storyboard.swift`, `StoryboardEditor.swift`)
71
72Each `Board` has two layers: a shape (vector) layer stored in the model (`BoardShape` — rect/oval/triangle/star/n-gon/text/image-ref) and a raster (drawing) layer stored as a PNG on disk keyed by board ID. `revision`/version counters invalidate the composite cache independent of model-mutation equality (raster edits don't go through `Store.mutate`). Panels are "start-only" in the model — see `normalizeStoryboards()` above.
73
74### Views
75
76- `TimelineView.swift` — largest file (~2700 lines); all editing gestures (move/trim/slip/stretch/box-select/split/blade) live here, each gesture wrapped in one `Store` undo step.
77- `ViewerGridView.swift` — multicam grid, one cell per visible track plus a Fusion comps cell.
78- `TransportBar.swift`, `ExportDialog.swift`, `ColorPicker.swift`, `Theme.swift` (light/dark, follows system appearance, no manual toggle), `Tools.swift` (tool enum + radial quick-picker).
79
80### Cross-cutting conventions
81
82- Per-project services (`Store`, `ChunkManager`, `PlaybackController`, `PlayerManager`, `FusionComps`, `BoardStore`, `SessionState`) are **instances on `DocumentContext`**, reached as `ctx.*` — one set per open document. Only `MediaPipeline` and `Theme` are global (`.shared` / static enum). Reach per-project state through the view's injected `ctx`, or `DocumentContext.current` at an app-level entry point — do not add a new global singleton.
83- All I/O (ffmpeg/ffprobe, directory scans, chunk building, comp scanning) runs off-main and lands back via `DispatchQueue.main.async`; don't block the main thread with media work.
84- Time units: everywhere in the model is **seconds (Double)**; frame numbers only appear at the Fusion export boundary and timecode display, converted via the project's `fps`.
sequencer/Package.swift created+13
...@@ -0,0 +1,13 @@
1// swift-tools-version:5.10
2import PackageDescription
3
4let package = Package(
5 name: "Sequencer",
6 platforms: [.macOS(.v14)],
7 targets: [
8 .executableTarget(
9 name: "Sequencer",
10 path: "Sources/Sequencer"
11 )
12 ]
13)
sequencer/Sources/Sequencer/AppDelegate.swift created+318
...@@ -0,0 +1,318 @@
1import AppKit
2import UniformTypeIdentifiers
3
4extension Notification.Name {
5 static let viewOptionsChanged = Notification.Name("viewOptionsChanged")
6 static let revealClip = Notification.Name("revealClip") // userInfo["clipId"]
7}
8
9/// App-wide UI constants. Mutable per-window session state (hide/focus/zoom/
10/// tool/color) lives on `SessionState` (`ctx.session`), not here.
11enum UI {
12 /// SF Symbols for the preview/header toggles: focus = fullscreen-expand,
13 /// hide = eye with a slash.
14 static let focusSymbol = "arrow.up.left.and.arrow.down.right"
15 static let hideSymbol = "eye.slash"
16 /// Lane ref standing in for the Fusion band in pane-keyed collections.
17 static let fusionPaneKey: TrackRef = .fusion
18
19 static let videoExtensions: Set<String> =
20 ["mov", "mp4", "m4v", "mkv", "avi", "mxf", "mts", "m2ts", "webm", "mpg", "mpeg"]
21 static let audioExtensions: Set<String> =
22 ["wav", "aiff", "aif", "mp3", "m4a", "aac", "flac", "caf"]
23 static var importableExtensions: Set<String> { videoExtensions.union(audioExtensions) }
24}
25
26final class SeqApplication: NSApplication {}
27
28/// Main menu that lets unmodified key equivalents (S, N, M, …) reach text
29/// fields: while a text view has focus, plain keys are typing, not commands.
30final class AppMenu: NSMenu {
31 override func performKeyEquivalent(with event: NSEvent) -> Bool {
32 if event.modifierFlags.intersection([.command, .control]).isEmpty,
33 let window = NSApp.keyWindow {
34 // Typing beats plain-key commands.
35 if window.firstResponder is NSText { return false }
36 // The storyboard editor owns its plain keys (tools, delete, …).
37 if window.identifier == StoryboardEditor.windowID { return false }
38 }
39 return super.performKeyEquivalent(with: event)
40 }
41}
42
43final class AppDelegate: NSObject, NSApplicationDelegate {
44 func applicationDidFinishLaunching(_ notification: Notification) {
45 Theme.startObserving()
46 registerBoardFonts()
47 buildMenu()
48 NSApp.activate(ignoringOtherApps: true)
49 }
50
51 /// Closing the last project window quits, matching the app's single-purpose
52 /// (Fusion-feeding) workflow.
53 func applicationShouldTerminateAfterLastWindowClosed(_ sender: NSApplication) -> Bool { true }
54
55 /// On launch, reopen the most recent project instead of a blank untitled
56 /// one; fall back to a fresh untitled document when there's no history.
57 func applicationOpenUntitledFile(_ sender: NSApplication) -> Bool {
58 let dc = NSDocumentController.shared
59 if let url = dc.recentDocumentURLs.first {
60 dc.openDocument(withContentsOf: url, display: true) { _, _, _ in }
61 return true
62 }
63 return false // → AppKit opens a fresh untitled document
64 }
65
66 // MARK: - App-level actions
67
68 @objc func showSettings() { SettingsWindow.shared.show() }
69 @objc func revealCache() {
70 NSWorkspace.shared.activateFileViewerSelecting([MediaPipeline.shared.cacheRoot])
71 }
72
73 // MARK: - Menu
74
75 private func item(_ title: String, _ action: Selector, key: String = "",
76 mods: NSEvent.ModifierFlags? = nil,
77 target: AnyObject? = nil) -> NSMenuItem {
78 let mi = NSMenuItem(title: title, action: action, keyEquivalent: key)
79 // Default target is nil — the action routes up the responder chain to
80 // the key window's controller (per-document commands) or the app
81 // delegate (app-level ones). Pass `target` only to pin an item.
82 mi.target = target
83 // nil mods = default ⌘ for non-empty keys; [] = plain key, shown bare.
84 if let mods { mi.keyEquivalentModifierMask = mods }
85 return mi
86 }
87
88 /// First-responder-targeted item (text fields get ⌘C/⌘V/⌘A when editing;
89 /// the timeline implements the same selectors for clips/panels).
90 private func responderItem(_ title: String, _ action: Selector, key: String) -> NSMenuItem {
91 let mi = NSMenuItem(title: title, action: action, keyEquivalent: key)
92 mi.target = nil
93 return mi
94 }
95
96 private func submenu(_ menu: NSMenu, title: String) -> NSMenuItem {
97 let mi = NSMenuItem(title: title, action: nil, keyEquivalent: "")
98 mi.submenu = menu
99 return mi
100 }
101
102 private func fkey(_ code: Int) -> String { String(UnicodeScalar(code)!) }
103
104 private func buildMenu() {
105 let main = AppMenu()
106
107 let appMenu = NSMenu()
108 appMenu.addItem(withTitle: "About Sequencer", action: nil, keyEquivalent: "")
109 appMenu.addItem(.separator())
110 appMenu.addItem(item("Settings…", #selector(showSettings), key: ","))
111 appMenu.addItem(.separator())
112 appMenu.addItem(withTitle: "Quit Sequencer",
113 action: #selector(NSApplication.terminate(_:)), keyEquivalent: "q")
114 main.addItem(submenu(appMenu, title: "Sequencer"))
115
116 let file = NSMenu(title: "File")
117 file.addItem(item("New Project", #selector(NSDocumentController.newDocument(_:)), key: "n"))
118 file.addItem(item("Open Project…", #selector(NSDocumentController.openDocument(_:)), key: "o"))
119 // AppKit auto-populates this submenu from NSDocument's recent-documents
120 // list so long as it holds the standard "Clear Menu" item.
121 let openRecent = NSMenu(title: "Open Recent")
122 let clearRecent = NSMenuItem(
123 title: "Clear Menu",
124 action: #selector(NSDocumentController.clearRecentDocuments(_:)),
125 keyEquivalent: "")
126 clearRecent.target = nil
127 openRecent.addItem(clearRecent)
128 file.addItem(submenu(openRecent, title: "Open Recent"))
129 file.addItem(.separator())
130 file.addItem(item("Save", #selector(NSDocument.save(_:)), key: "s"))
131 file.addItem(item("Save As…", #selector(NSDocument.saveAs(_:)), key: "S"))
132 file.addItem(item("Duplicate", #selector(NSDocument.duplicate(_:)), key: "S", mods: [.command, .option]))
133 file.addItem(item("Revert to Saved", #selector(NSDocument.revertToSaved(_:))))
134 file.addItem(responderItem("Close", #selector(NSWindow.performClose(_:)), key: "w"))
135 file.addItem(.separator())
136 file.addItem(item("Import Media…", #selector(SequencerWindowController.importMedia), key: "i"))
137 file.addItem(.separator())
138 file.addItem(item("Export…", #selector(SequencerWindowController.showExport), key: "e"))
139 file.addItem(.separator())
140 file.addItem(item("Reveal Cache in Finder", #selector(revealCache), target: self))
141 file.addItem(item("Reveal Project in Finder", #selector(SequencerWindowController.revealProject)))
142 main.addItem(submenu(file, title: "File"))
143
144 let edit = NSMenu(title: "Edit")
145 edit.addItem(item("Undo", #selector(SequencerWindowController.undo), key: "z"))
146 edit.addItem(item("Redo", #selector(SequencerWindowController.redo), key: "Z"))
147 edit.addItem(.separator())
148 edit.addItem(responderItem("Cut", #selector(NSText.cut(_:)), key: "x"))
149 edit.addItem(responderItem("Copy", #selector(NSText.copy(_:)), key: "c"))
150 edit.addItem(responderItem("Paste", #selector(NSText.paste(_:)), key: "v"))
151 edit.addItem(.separator())
152 edit.addItem(responderItem("Select All", #selector(NSResponder.selectAll(_:)), key: "a"))
153 edit.addItem(item("Deselect All", #selector(SequencerWindowController.deselectAll), key: "\u{1b}", mods: []))
154 main.addItem(submenu(edit, title: "Edit"))
155
156 let clip = NSMenu(title: "Clip")
157 clip.addItem(item("Split", #selector(SequencerWindowController.split(_:)), key: "s", mods: []))
158 clip.addItem(item("Move Overlaps to Separate Tracks", #selector(SequencerWindowController.moveOverlaps),
159 key: "o", mods: [.option]))
160 clip.addItem(item("Mute", #selector(SequencerWindowController.muteClips), key: "m", mods: [.option]))
161 clip.addItem(.separator())
162 clip.addItem(item("Nudge Left 1 Frame", #selector(SequencerWindowController.nudgeLeft),
163 key: fkey(NSLeftArrowFunctionKey), mods: []))
164 clip.addItem(item("Nudge Right 1 Frame", #selector(SequencerWindowController.nudgeRight),
165 key: fkey(NSRightArrowFunctionKey), mods: []))
166 clip.addItem(item("Nudge Left 1 Second", #selector(SequencerWindowController.nudgeLeftSecond),
167 key: fkey(NSLeftArrowFunctionKey), mods: [.shift]))
168 clip.addItem(item("Nudge Right 1 Second", #selector(SequencerWindowController.nudgeRightSecond),
169 key: fkey(NSRightArrowFunctionKey), mods: [.shift]))
170 clip.addItem(.separator())
171 clip.addItem(item("Link Clips", #selector(SequencerWindowController.linkClips), key: "g", mods: []))
172 clip.addItem(item("Unlink Clips", #selector(SequencerWindowController.unlinkClips), key: "g", mods: [.option]))
173 clip.addItem(.separator())
174 clip.addItem(item("Delete", #selector(SequencerWindowController.deleteSelected), key: "\u{8}", mods: []))
175 clip.addItem(item("Ripple Delete", #selector(SequencerWindowController.rippleDeleteSelected),
176 key: "\u{8}", mods: [.option]))
177 clip.addItem(item("Close Gap at Playhead", #selector(SequencerWindowController.closeGapAtPlayhead)))
178 clip.addItem(.separator())
179 clip.addItem(item("Ripple Trim Start to Playhead", #selector(SequencerWindowController.rippleTrimLeft),
180 key: fkey(NSLeftArrowFunctionKey), mods: [.option]))
181 clip.addItem(item("Ripple Trim End to Playhead", #selector(SequencerWindowController.rippleTrimRight),
182 key: fkey(NSRightArrowFunctionKey), mods: [.option]))
183 main.addItem(submenu(clip, title: "Clip"))
184
185 // Every storyboard command lives together here, whether the underlying
186 // op is a clip split, a panel add, or timeline navigation.
187 let storyboard = NSMenu(title: "Storyboard")
188 // "New Panel" splits the panel under the playhead in two — that's how a
189 // new panel is born, so there's no separate "add panel" command.
190 storyboard.addItem(item("New Panel", #selector(SequencerWindowController.splitStoryboard), key: "b", mods: []))
191 storyboard.addItem(item("New Shot", #selector(SequencerWindowController.splitStoryboardNewShot),
192 key: "B", mods: [.shift]))
193 storyboard.addItem(item("Mark as New Shot", #selector(SequencerWindowController.toggleNewShot), key: "n", mods: []))
194 storyboard.addItem(.separator())
195 storyboard.addItem(item("Next Panel", #selector(SequencerWindowController.nextStoryboardPanel),
196 key: "]", mods: [.command]))
197 storyboard.addItem(item("Previous Panel", #selector(SequencerWindowController.prevStoryboardPanel),
198 key: "[", mods: [.command]))
199 storyboard.addItem(.separator())
200 storyboard.addItem(item("Open Storyboard Editor…", #selector(SequencerWindowController.openStoryboardEditor)))
201 main.addItem(submenu(storyboard, title: "Storyboard"))
202
203 let track = NSMenu(title: "Track")
204 track.addItem(item("Delete Empty Tracks", #selector(SequencerWindowController.deleteEmptyTracks)))
205 track.addItem(item("Show All Tracks (Reset Hide & Focus)",
206 #selector(SequencerWindowController.resetTrackVisibility)))
207 track.addItem(.separator())
208 track.addItem(item("Set Preferred Take", #selector(SequencerWindowController.setPreferredTake), key: "t", mods: []))
209 main.addItem(submenu(track, title: "Track"))
210
211 let view = NSMenu(title: "View")
212 view.addItem(item("Snapping", #selector(SequencerWindowController.toggleSnapping), key: "y", mods: []))
213 let strips = item("Show Clip Thumbnails", #selector(SequencerWindowController.toggleFilmstrips),
214 key: "f", mods: [.option, .command])
215 view.addItem(strips)
216 view.addItem(.separator())
217 view.addItem(item("Zoom to Fit", #selector(SequencerWindowController.zoomFit), key: "f", mods: [.command]))
218 view.addItem(item("Zoom In", #selector(SequencerWindowController.zoomIn), key: "="))
219 view.addItem(item("Zoom Out", #selector(SequencerWindowController.zoomOut), key: "-"))
220 view.addItem(.separator())
221 view.addItem(item("Taller Tracks", #selector(SequencerWindowController.tallerTracks),
222 key: "=", mods: [.option, .command]))
223 view.addItem(item("Shorter Tracks", #selector(SequencerWindowController.shorterTracks),
224 key: "-", mods: [.option, .command]))
225 view.addItem(item("Reset Track Heights", #selector(SequencerWindowController.resetTrackHeights),
226 key: "0", mods: [.option, .command]))
227 view.addItem(.separator())
228 view.addItem(item("Previews on Left", #selector(SequencerWindowController.togglePreviewsLeft),
229 key: "l", mods: [.option, .command]))
230 view.addItem(item("Pop Out Previews", #selector(SequencerWindowController.togglePopout), key: "P"))
231 main.addItem(submenu(view, title: "View"))
232
233 let play = NSMenu(title: "Playback")
234 play.addItem(item("Play/Pause", #selector(SequencerWindowController.playPause), key: " ", mods: []))
235 play.addItem(item("Stop", #selector(SequencerWindowController.stopPlayback), key: "k", mods: []))
236 play.addItem(item("Shuttle Forward", #selector(SequencerWindowController.shuttleForward), key: "l", mods: []))
237 play.addItem(item("Shuttle Reverse", #selector(SequencerWindowController.shuttleReverse), key: "j", mods: []))
238 play.addItem(.separator())
239 play.addItem(item("Step Forward", #selector(SequencerWindowController.stepForward), key: "]", mods: []))
240 play.addItem(item("Step Backward", #selector(SequencerWindowController.stepBackward), key: "[", mods: []))
241 play.addItem(item("Step Forward 1 Second", #selector(SequencerWindowController.stepForwardSecond),
242 key: "]", mods: [.shift]))
243 play.addItem(item("Step Backward 1 Second", #selector(SequencerWindowController.stepBackwardSecond),
244 key: "[", mods: [.shift]))
245 play.addItem(.separator())
246 play.addItem(item("Go to Start", #selector(SequencerWindowController.goToStart),
247 key: fkey(NSHomeFunctionKey), mods: []))
248 play.addItem(item("Go to End", #selector(SequencerWindowController.goToEnd),
249 key: fkey(NSEndFunctionKey), mods: []))
250 play.addItem(.separator())
251 play.addItem(item("Set In Point", #selector(SequencerWindowController.setInPoint), key: "i", mods: []))
252 play.addItem(item("Set Out Point", #selector(SequencerWindowController.setOutPoint), key: "o", mods: []))
253 play.addItem(item("Loop (Cycle) In → Out", #selector(SequencerWindowController.toggleLoop), key: "c", mods: []))
254 play.addItem(item("Clear In / Out", #selector(SequencerWindowController.clearInOut)))
255 play.addItem(.separator())
256 play.addItem(item("Add / Remove Marker", #selector(SequencerWindowController.toggleMarker), key: "m", mods: []))
257 play.addItem(item("Previous Marker", #selector(SequencerWindowController.prevMarker), key: "[", mods: [.option]))
258 play.addItem(item("Next Marker", #selector(SequencerWindowController.nextMarker), key: "]", mods: [.option]))
259 play.addItem(item("Clear All Markers", #selector(SequencerWindowController.clearMarkers)))
260 play.addItem(.separator())
261 // Checked = proxy chunks build continuously in the background; unchecked =
262 // only build what the playhead needs while playing. Mirrors the click-to-
263 // pause control on the transport bar's chunk-progress readout.
264 play.addItem(item("Background Optimization", #selector(SequencerWindowController.toggleBackgroundOptimization)))
265 main.addItem(submenu(play, title: "Playback"))
266
267 // Project configuration — frame rate, storyboard aspect, comps folder —
268 // lives in Settings (⌘,), so there's no Project menu. See SettingsWindow.
269
270 // Standard Window menu; AppKit fills in the window list and checkmarks.
271 // Minimize/Zoom target the key window through the responder chain.
272 let windowMenu = NSMenu(title: "Window")
273 windowMenu.addItem(responderItem("Minimize",
274 #selector(NSWindow.performMiniaturize(_:)), key: "m"))
275 let zoom = NSMenuItem(title: "Zoom",
276 action: #selector(NSWindow.performZoom(_:)), keyEquivalent: "")
277 zoom.target = nil
278 windowMenu.addItem(zoom)
279 windowMenu.addItem(.separator())
280 windowMenu.addItem(withTitle: "Bring All to Front",
281 action: #selector(NSApplication.arrangeInFront(_:)), keyEquivalent: "")
282 main.addItem(submenu(windowMenu, title: "Window"))
283 NSApp.windowsMenu = windowMenu
284
285 // Naming a menu the app's helpMenu gives macOS its standard searchable
286 // Help field (⌘? focuses it) that finds any menu command by name.
287 let help = NSMenu(title: "Help")
288 help.addItem(item("Sequencer Help", #selector(showHelp), key: "?"))
289 main.addItem(submenu(help, title: "Help"))
290 NSApp.helpMenu = help
291
292 NSApp.mainMenu = main
293 }
294
295 @objc func showHelp() {
296 // Open the README that ships beside the app; fall back to a pointer at
297 // the searchable Help field.
298 let readme = Bundle.main.bundleURL
299 .deletingLastPathComponent().appendingPathComponent("README.md")
300 if FileManager.default.fileExists(atPath: readme.path) {
301 NSWorkspace.shared.open(readme)
302 return
303 }
304 let a = NSAlert()
305 a.messageText = "Sequencer Help"
306 a.informativeText = "Every command lives in the menu bar. Use the Help "
307 + "menu's search field to find any of them by name — the keyboard "
308 + "shortcut is shown next to each item."
309 a.runModal()
310 }
311
312 static let frameRates: [(String, Double)] = [
313 ("23.976 fps", 24000.0 / 1001.0), ("24 fps", 24), ("25 fps", 25),
314 ("29.97 fps", 30000.0 / 1001.0), ("30 fps", 30), ("50 fps", 50),
315 ("59.94 fps", 60000.0 / 1001.0), ("60 fps", 60),
316 ]
317
318}
sequencer/Sources/Sequencer/ChunkedProxy.swift created+465
...@@ -0,0 +1,465 @@
1import Foundation
2import AVFoundation
3
4/// Demand-driven proxy generation in 30-second chunks.
5///
6/// Instead of transcoding whole files up front, each media gets ProRes Proxy
7/// chunks rendered around where the user is actually viewing: the chunk under
8/// the playhead (and the next one) jump the queue; the ranges used by clips
9/// on the timeline fill in behind. Playback runs off a per-media
10/// AVComposition that stitches ready chunks together, falling back to the
11/// original file for not-yet-rendered ranges (or showing nothing + a
12/// "processing…" badge when the original isn't AVFoundation-playable, e.g.
13/// DNx). Legacy whole-file `proxy.mov` caches are still used when present.
14final class ChunkManager {
15 /// The document context that owns this manager. Set at construction.
16 unowned var ctx: DocumentContext!
17 static let chunkSeconds: Double = 30
18
19 /// Adaptive proxy quality. Proxies are encoded at the highest quality the
20 /// machine can still produce FASTER than real time, so playback never
21 /// outruns the render queue. Each step trades resolution (and, lower down,
22 /// frame rate) for encode speed. Level 0 is the original full quality.
23 struct Quality { let maxWidth: Int; let fpsDivisor: Int }
24 static let qualities: [Quality] = [
25 Quality(maxWidth: 960, fpsDivisor: 1), // full — matches the old fixed proxy
26 Quality(maxWidth: 640, fpsDivisor: 1), // reduced
27 Quality(maxWidth: 480, fpsDivisor: 2), // low
28 Quality(maxWidth: 320, fpsDivisor: 2), // minimum
29 ]
30
31 private struct MediaState {
32 var built: Set<Int> = []
33 var inFlight: Set<Int> = []
34 var failed: Set<Int> = []
35 var urgent: [Int] = []
36 var background: [Int] = []
37 var version = 0
38 var scanned = false
39 var composition: AVComposition?
40 var compositionVersion = -1
41 var originalPlayable: Bool?
42 // Adaptive-quality controller state (see decideQuality).
43 var qualityIndex = 0
44 var normWall: [Int: Double] = [:] // EMA of wall/realtime at each level
45 var networkLimited = false
46 var fastStreak = 0
47 }
48
49 // Main-thread only.
50 private var states: [String: MediaState] = [:]
51 private var mediaByKey: [String: MediaItem] = [:]
52 private var activeBuilds = 0
53 private let maxBuilds = 2
54
55 /// When paused, no NEW chunk builds start; in-flight ones finish and the
56 /// queue is retained, resuming where it left off. Main-thread only.
57 private(set) var isPaused = false
58
59 /// Toggle proxy optimization on/off (driven by the status-bar readout).
60 func setPaused(_ paused: Bool) {
61 guard paused != isPaused else { return }
62 isPaused = paused
63 if !paused { pump() }
64 NotificationCenter.default.post(name: .mediaStatusChanged, object: nil)
65 }
66
67 /// Playback started: while paused, resume building the chunks it needs.
68 func playbackDidStart() { pump() }
69
70 init() {
71 NotificationCenter.default.addObserver(
72 forName: .projectChanged, object: nil, queue: .main) { [weak self] _ in
73 guard let self else { return }
74 self.ensure(for: self.ctx.store.project)
75 }
76 }
77
78 // MARK: - Paths
79
80 private func chunksDir(_ key: String) -> URL {
81 // Same sanitization as MediaPipeline's cache paths: a blank/garbage key
82 // must not resolve to the cache root or escape it.
83 let safe = MediaPipeline.isValidCacheKey(key) ? key
84 : MediaPipeline.hashedKey("invalid|\(key)")
85 return MediaPipeline.shared.cacheRoot
86 .appendingPathComponent(safe, isDirectory: true)
87 .appendingPathComponent("chunks", isDirectory: true)
88 }
89 private func chunkURL(key: String, index: Int) -> URL {
90 chunksDir(key).appendingPathComponent(String(format: "c%06d.mov", index))
91 }
92 static func chunkIndex(forSource t: Double) -> Int { max(0, Int(t / chunkSeconds)) }
93 static func chunkCount(duration: Double) -> Int {
94 max(1, Int(ceil(duration / chunkSeconds)))
95 }
96
97 private func state(for media: MediaItem) -> MediaState {
98 mediaByKey[media.cacheKey] = media
99 var s = states[media.cacheKey] ?? MediaState()
100 if !s.scanned {
101 s.scanned = true
102 if let names = try? FileManager.default
103 .contentsOfDirectory(atPath: chunksDir(media.cacheKey).path) {
104 for n in names where n.hasPrefix("c") && n.hasSuffix(".mov") {
105 if let i = Int(n.dropFirst().dropLast(4)) { s.built.insert(i) }
106 }
107 }
108 states[media.cacheKey] = s
109 }
110 return s
111 }
112
113 private func hasFullProxy(_ media: MediaItem) -> Bool {
114 MediaPipeline.shared.status(for: media).proxyReady
115 }
116
117 // MARK: - Demand
118
119 /// Called continuously from playback/scrub with the source time each
120 /// track is showing. Marks the covering chunk (and the next) urgent.
121 func want(media: MediaItem, sourceTime: Double) {
122 guard media.duration > 0, !media.isAudio, !hasFullProxy(media) else { return }
123 var s = state(for: media)
124 let n = Self.chunkCount(duration: media.duration)
125 let i = min(n - 1, Self.chunkIndex(forSource: sourceTime))
126 let wanted = [i, i + 1].filter {
127 $0 < n && !s.built.contains($0) && !s.inFlight.contains($0) && !s.failed.contains($0)
128 }
129 guard s.urgent != wanted else { return }
130 s.urgent = wanted
131 states[media.cacheKey] = s
132 pump()
133 }
134
135 /// Rebuild the background fill queue from the project: every chunk in
136 /// every clip's used source range, in order.
137 func ensure(for project: ProjectModel) {
138 for media in project.media {
139 guard media.duration > 0, !media.isAudio, !hasFullProxy(media) else { continue }
140 var s = state(for: media)
141 let n = Self.chunkCount(duration: media.duration)
142 var order: [Int] = []
143 for clip in project.clips where clip.mediaId == media.id {
144 let a = min(n - 1, Self.chunkIndex(forSource: clip.srcIn))
145 let b = min(n - 1, Self.chunkIndex(forSource: clip.srcIn + clip.duration - 0.001))
146 for i in a...max(a, b) where !order.contains(i) { order.append(i) }
147 }
148 s.background = order
149 states[media.cacheKey] = s
150 }
151 pump()
152 }
153
154 // MARK: - Status queries
155
156 func isCovered(media: MediaItem, sourceTime: Double) -> Bool {
157 if media.isAudio { return true } // audio plays the original directly
158 if hasFullProxy(media) { return true }
159 let s = state(for: media)
160 return s.built.contains(Self.chunkIndex(forSource: sourceTime))
161 }
162
163 /// Last known answer; unknown kicks the async composition build (which
164 /// determines it) and reports false meanwhile. Never blocks on media I/O.
165 func originalPlayable(media: MediaItem) -> Bool {
166 if media.isAudio { return true }
167 if let known = states[media.cacheKey]?.originalPlayable { return known }
168 kickCompositionBuild(media: media)
169 return false
170 }
171
172 /// (building now, waiting in queue) across all media — for the status bar.
173 func queueSummary() -> (building: Int, queued: Int) {
174 var building = 0, queued = 0
175 for s in states.values {
176 building += s.inFlight.count
177 let pending = Set(s.urgent + s.background)
178 .subtracting(s.built).subtracting(s.inFlight).subtracting(s.failed)
179 queued += pending.count
180 }
181 return (building, queued)
182 }
183
184 /// The proxy-backed chunk set right now (players record this at item-swap
185 /// time to judge whether a later swap upgrades anything).
186 func builtChunks(media: MediaItem) -> Set<Int> {
187 state(for: media).built
188 }
189
190 func builtChunkURL(media: MediaItem, index: Int) -> URL? {
191 state(for: media).built.contains(index)
192 ? chunkURL(key: media.cacheKey, index: index) : nil
193 }
194
195 // MARK: - Build queue
196
197 private func nextJob() -> (MediaItem, Int)? {
198 for pass in 0..<2 {
199 for (key, s) in states {
200 guard let media = mediaByKey[key] else { continue }
201 let list = pass == 0 ? s.urgent : s.background
202 for i in list where !s.built.contains(i) && !s.inFlight.contains(i)
203 && !s.failed.contains(i) {
204 return (media, i)
205 }
206 }
207 }
208 return nil
209 }
210
211 private func pump() {
212 // Paused stops idle background fill, but playback still optimizes the
213 // chunks it's about to need.
214 while (!isPaused || ctx.playback.isPlaying),
215 activeBuilds < maxBuilds, let (media, index) = nextJob() {
216 states[media.cacheKey]?.inFlight.insert(index)
217 let level = states[media.cacheKey]?.qualityIndex ?? 0
218 activeBuilds += 1
219 DispatchQueue.global(qos: .userInitiated).async { [self] in
220 let r = buildChunk(media: media, index: index, level: level)
221 DispatchQueue.main.async {
222 self.activeBuilds -= 1
223 var s = self.states[media.cacheKey] ?? MediaState()
224 s.inFlight.remove(index)
225 if r.ok {
226 s.built.insert(index)
227 s.version += 1
228 } else {
229 s.failed.insert(index)
230 }
231 self.states[media.cacheKey] = s
232 if r.ok {
233 self.adaptQuality(key: media.cacheKey, level: level,
234 wall: r.wall, dur: r.dur, isNetwork: r.isNetwork)
235 }
236 NotificationCenter.default.post(name: .mediaStatusChanged, object: nil)
237 MediaPipeline.shared.evictIfNeeded()
238 self.pump()
239 }
240 }
241 }
242 }
243
244 struct BuildResult { var ok: Bool; var wall: Double; var dur: Double; var isNetwork: Bool }
245
246 private func buildChunk(media: MediaItem, index: Int, level: Int) -> BuildResult {
247 let isNet = Self.isNetworkPath(media.path)
248 func fail(_ wall: Double = 0, _ dur: Double = 0) -> BuildResult {
249 BuildResult(ok: false, wall: wall, dur: dur, isNetwork: isNet)
250 }
251 guard let ffmpeg = MediaPipeline.findExecutable("ffmpeg") else { return fail() }
252 let dir = chunksDir(media.cacheKey)
253 try? FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true)
254 let final = chunkURL(key: media.cacheKey, index: index)
255 let tmp = dir.appendingPathComponent(String(format: ".c%06d.partial.mov", index))
256 try? FileManager.default.removeItem(at: tmp)
257 let start = Double(index) * Self.chunkSeconds
258 let dur = min(Self.chunkSeconds, media.duration - start)
259 guard dur > 0.01 else { return fail() }
260 let q = Self.qualities[min(max(0, level), Self.qualities.count - 1)]
261 let fps = max(1, Int((media.fps / Double(q.fpsDivisor)).rounded()))
262
263 func args(encoder: String) -> [String] {
264 var a = ["-y", "-hwaccel", "videotoolbox",
265 "-ss", String(format: "%.3f", start),
266 "-i", media.path,
267 "-t", String(format: "%.3f", dur),
268 "-map", "0:v:0",
269 "-vf", "scale='min(\(q.maxWidth),iw)':-2,fps=\(fps)",
270 "-c:v", encoder, "-profile:v", "proxy"]
271 if media.hasAudio { a += ["-map", "0:a:0", "-c:a", "pcm_s16le"] }
272 a.append(tmp.path)
273 return a
274 }
275 let t0 = Date()
276 var res = MediaPipeline.run(ffmpeg, args(encoder: "prores_videotoolbox"))
277 if res.exitCode != 0 {
278 res = MediaPipeline.run(ffmpeg, args(encoder: "prores_ks"))
279 }
280 let wall = Date().timeIntervalSince(t0)
281 if res.exitCode == 0 {
282 try? FileManager.default.removeItem(at: final)
283 do { try FileManager.default.moveItem(at: tmp, to: final) }
284 catch { return fail(wall, dur) }
285 return BuildResult(ok: true, wall: wall, dur: dur, isNetwork: isNet)
286 }
287 try? FileManager.default.removeItem(at: tmp)
288 return fail(wall, dur)
289 }
290
291 /// Whether a path lives on a network mount (SMB/NFS NAS) rather than a
292 /// local disk — a fast, data-free `statfs`. Used to decide whether a slow
293 /// build can honestly be blamed on network I/O.
294 static func isNetworkPath(_ path: String) -> Bool {
295 var st = statfs()
296 guard statfs(path, &st) == 0 else { return false }
297 return (st.f_flags & UInt32(MNT_LOCAL)) == 0
298 }
299
300 // MARK: - Adaptive quality
301
302 /// Any media currently held back by a network read the box can't keep up
303 /// with (drives the toolbar warning).
304 var isNetworkLimited: Bool { states.values.contains { $0.networkLimited } }
305
306 /// Fold one finished build's timing into the controller and pick the
307 /// quality for this media's NEXT chunk.
308 private func adaptQuality(key: String, level: Int, wall: Double, dur: Double,
309 isNetwork: Bool) {
310 guard dur >= 5 else { return } // tail chunks are too short to time reliably
311 var s = states[key] ?? MediaState()
312 let norm = wall / dur
313 s.normWall[level] = s.normWall[level].map { $0 * 0.5 + norm * 0.5 } ?? norm
314 let d = Self.decideQuality(level: level, norm: s.normWall[level]!,
315 normByLevel: s.normWall, fastStreak: s.fastStreak,
316 sourceIsNetwork: isNetwork,
317 levelCount: Self.qualities.count)
318 s.qualityIndex = d.nextIndex
319 s.networkLimited = d.networkLimited
320 s.fastStreak = d.fastStreak
321 states[key] = s
322 }
323
324 struct QualityDecision: Equatable { var nextIndex: Int; var networkLimited: Bool; var fastStreak: Int }
325
326 /// Pure adaptive-quality decision (so it's deterministic + unit-testable).
327 /// `norm` is the just-built chunk's wall-time ÷ its real-time duration:
328 /// < 1 means we encoded faster than the footage plays. Given the ratios
329 /// measured at neighbouring levels, decide the level for the next chunk.
330 ///
331 /// The core trick for telling a slow ENCODE apart from a slow READ: when a
332 /// build is struggling, check whether stepping down from the next-higher
333 /// quality actually made the encode faster. If it barely moved, the encode
334 /// wasn't the bottleneck — the source read is — so degrading further is
335 /// futile: we stop degrading (restoring the wasted quality) and, when the
336 /// source is on a network mount, flag it as network-limited.
337 static func decideQuality(level: Int, norm: Double, normByLevel: [Int: Double],
338 fastStreak: Int, sourceIsNetwork: Bool,
339 levelCount: Int) -> QualityDecision {
340 let struggling = 0.8 // wall > 0.8× realtime → at risk of not keeping up
341 let comfy = 0.45 // wall < 0.45× realtime → safe to restore quality
342 var next = level
343 var network = false
344 var streak = fastStreak
345
346 if norm > struggling {
347 streak = 0
348 // level-1 is the next-higher quality; if it was ~as fast as this
349 // (lower-quality) build, dropping quality isn't buying speed.
350 let higher = normByLevel[level - 1]
351 let degradeHelps = higher.map { ($0 - norm) / $0 >= 0.15 } ?? true
352 if degradeHelps && level < levelCount - 1 {
353 next = level + 1 // faster encode
354 } else {
355 network = sourceIsNetwork // read-bound (or at the floor)
356 if !degradeHelps && level > 0 { next = level - 1 } // stop wasting quality
357 }
358 } else if norm < comfy {
359 streak += 1
360 if streak >= 2 && level > 0 { next = level - 1; streak = 0 } // recover quality
361 }
362 return QualityDecision(nextIndex: next, networkLimited: network, fastStreak: streak)
363 }
364
365 /// Drop cached state for evicted cache keys.
366 func forget(keys: [String]) {
367 for k in keys {
368 states.removeValue(forKey: k)
369 mediaByKey.removeValue(forKey: k)
370 }
371 }
372
373 // MARK: - Playback composition
374
375 private var compBuilding: Set<String> = []
376
377 /// Stitched asset: ready chunks as ProRes, missing ranges from the
378 /// original (when playable) or empty. `version` changes whenever a chunk
379 /// lands so players know to swap items. NEVER blocks on media I/O — a
380 /// stale (or empty, version -2) composition is returned while the fresh
381 /// one assembles on a background queue; .mediaStatusChanged fires when
382 /// it's ready. (Synchronous AVAsset loading on the main thread hangs the
383 /// whole app if an SMB mount stalls.)
384 func composition(for media: MediaItem) -> (asset: AVAsset, version: Int) {
385 let s = state(for: media)
386 if s.composition == nil || s.compositionVersion != s.version {
387 kickCompositionBuild(media: media)
388 }
389 if let comp = states[media.cacheKey]?.composition {
390 return (comp, states[media.cacheKey]?.compositionVersion ?? -2)
391 }
392 return (AVMutableComposition(), -2)
393 }
394
395 private func kickCompositionBuild(media: MediaItem) {
396 let key = media.cacheKey
397 guard !compBuilding.contains(key) else { return }
398 compBuilding.insert(key)
399 let s = state(for: media)
400 let version = s.version
401 let chunkURLs = Dictionary(uniqueKeysWithValues:
402 s.built.map { ($0, chunkURL(key: key, index: $0)) })
403 Task.detached(priority: .userInitiated) {
404 let (comp, playable) = await Self.assemble(media: media, chunkURLs: chunkURLs)
405 await MainActor.run { [self] in
406 self.compBuilding.remove(key)
407 var s = self.states[key] ?? MediaState()
408 s.composition = comp
409 s.compositionVersion = version
410 s.originalPlayable = playable
411 self.states[key] = s
412 NotificationCenter.default.post(name: .mediaStatusChanged, object: nil)
413 if s.version != version { self.kickCompositionBuild(media: media) }
414 }
415 }
416 }
417
418 private static func assemble(media: MediaItem,
419 chunkURLs: [Int: URL]) async -> (AVComposition, Bool) {
420 let comp = AVMutableComposition()
421 guard let vTrack = comp.addMutableTrack(
422 withMediaType: .video, preferredTrackID: kCMPersistentTrackID_Invalid)
423 else { return (comp, false) }
424 let aTrack = media.hasAudio ? comp.addMutableTrack(
425 withMediaType: .audio, preferredTrackID: kCMPersistentTrackID_Invalid) : nil
426
427 let original = AVURLAsset(url: media.url)
428 let origV = try? await original.loadTracks(withMediaType: .video).first
429 let origA = try? await original.loadTracks(withMediaType: .audio).first
430
431 let n = Self.chunkCount(duration: media.duration)
432 for i in 0..<n {
433 let startSec = Double(i) * Self.chunkSeconds
434 let durSec = min(Self.chunkSeconds, media.duration - startSec)
435 guard durSec > 0.001 else { break }
436 let at = CMTime(seconds: startSec, preferredTimescale: 600)
437 let dur = CMTime(seconds: durSec, preferredTimescale: 600)
438 var inserted = false
439 if let url = chunkURLs[i] {
440 let chunk = AVURLAsset(url: url)
441 if let v = try? await chunk.loadTracks(withMediaType: .video).first {
442 let chunkDuration = (try? await chunk.load(.duration)) ?? .zero
443 let r = CMTimeRange(start: .zero, duration: min(dur, chunkDuration))
444 try? vTrack.insertTimeRange(r, of: v, at: at)
445 if let aTrack, let a = try? await chunk.loadTracks(withMediaType: .audio).first {
446 try? aTrack.insertTimeRange(r, of: a, at: at)
447 }
448 inserted = true
449 }
450 }
451 if !inserted {
452 if let origV {
453 let r = CMTimeRange(start: at, duration: dur)
454 try? vTrack.insertTimeRange(r, of: origV, at: at)
455 if let aTrack, let origA {
456 try? aTrack.insertTimeRange(r, of: origA, at: at)
457 }
458 } else {
459 vTrack.insertEmptyTimeRange(CMTimeRange(start: at, duration: dur))
460 }
461 }
462 }
463 return (comp, origV != nil)
464 }
465}
sequencer/Sources/Sequencer/ColorPicker.swift created+340
...@@ -0,0 +1,340 @@
1import AppKit
2
3/// The shared 8-color quick palette (toolbar picker, radial picker).
4enum Palette {
5 static let colors: [NSColor] = [.black, .white, .systemRed, .systemOrange,
6 .systemYellow, .systemGreen, .systemBlue,
7 .systemPurple]
8}
9
10/// Krita-style picker: a hue ring around a saturation/value triangle, with
11/// the quick palette along the bottom. It pops out of the toolbar's color
12/// swatch on HOVER so changing color is one smooth gesture, and closes when
13/// the mouse wanders off.
14final class ColorPickerPanel: NSPanel {
15 private static var current: ColorPickerPanel?
16 private var anchorView: NSView?
17 private var watchTimer: Timer?
18 private var onClose: (() -> Void)?
19
20 static func show(under anchor: NSView, color: NSColor,
21 onChange: @escaping (NSColor) -> Void,
22 onClose: (() -> Void)? = nil) {
23 if let cur = current, cur.isVisible, cur.anchorView === anchor { return }
24 current?.dismiss()
25 guard let window = anchor.window else { return }
26 let size = NSSize(width: 232, height: 268)
27 let anchorRect = window.convertToScreen(anchor.convert(anchor.bounds, to: nil))
28 // Opens ABOVE the swatch (presets sit at the bottom, nearest the
29 // mouse); falls back to below when there's no room.
30 var origin = NSPoint(x: anchorRect.midX - size.width / 2,
31 y: anchorRect.maxY + 6)
32 if let screen = window.screen {
33 origin.x = min(max(origin.x, screen.visibleFrame.minX + 8),
34 screen.visibleFrame.maxX - size.width - 8)
35 if origin.y + size.height > screen.visibleFrame.maxY {
36 origin.y = anchorRect.minY - size.height - 6
37 }
38 }
39 let panel = ColorPickerPanel(
40 contentRect: NSRect(origin: origin, size: size),
41 styleMask: [.borderless, .nonactivatingPanel],
42 backing: .buffered, defer: false)
43 panel.isOpaque = false
44 panel.backgroundColor = .clear
45 panel.level = .popUpMenu
46 panel.isReleasedWhenClosed = false
47 panel.hidesOnDeactivate = true
48 panel.anchorView = anchor
49 panel.onClose = onClose
50 let view = ColorPickerView(frame: NSRect(origin: .zero, size: size))
51 view.setColor(color)
52 view.onChange = onChange
53 panel.contentView = view
54 panel.orderFront(nil)
55 current = panel
56 panel.startWatchingMouse()
57 }
58
59 /// Close the moment the pointer is neither on the panel, the anchor
60 /// swatch, nor the small corridor between them.
61 private func startWatchingMouse() {
62 watchTimer = Timer.scheduledTimer(withTimeInterval: 0.05, repeats: true) { [weak self] _ in
63 guard let self else { return }
64 let mouse = NSEvent.mouseLocation
65 var keep = self.frame.insetBy(dx: -3, dy: -3).contains(mouse)
66 if let anchor = self.anchorView, let win = anchor.window {
67 let r = win.convertToScreen(anchor.convert(anchor.bounds, to: nil))
68 keep = keep || r.insetBy(dx: -3, dy: -3).contains(mouse)
69 // Corridor between the swatch and the panel.
70 let lo = min(r.maxY, self.frame.minY), hi = max(r.minY, self.frame.maxY)
71 if mouse.x >= r.minX - 3, mouse.x <= r.maxX + 3,
72 mouse.y >= lo, mouse.y <= hi {
73 keep = true
74 }
75 }
76 if !keep { self.dismiss() }
77 }
78 }
79
80 /// Hovering any OTHER toolbar button kills the picker instantly.
81 static func close(unlessAnchor view: NSView?) {
82 if let cur = current, cur.anchorView !== view { cur.dismiss() }
83 }
84
85 fileprivate func dismiss() {
86 watchTimer?.invalidate()
87 watchTimer = nil
88 close()
89 if Self.current === self { Self.current = nil }
90 let cb = onClose
91 onClose = nil
92 cb?()
93 }
94
95 override func cancelOperation(_ sender: Any?) { dismiss() }
96}
97
98final class ColorPickerView: NSView {
99 var onChange: ((NSColor) -> Void)?
100
101 // Model: hue 0..1, plus barycentric coords in the SV triangle:
102 // (pure-hue weight, white weight, black weight).
103 private var hue: CGFloat = 0
104 private var wHue: CGFloat = 1
105 private var wWhite: CGFloat = 0
106
107 private var triangleImage: NSImage?
108 private var triangleHue: CGFloat = -1
109
110 private let outerR: CGFloat = 106
111 private let ringWidth: CGFloat = 22
112 private var innerR: CGFloat { outerR - ringWidth }
113 private var wheelCenter: NSPoint { NSPoint(x: bounds.midX, y: bounds.midY + 16) }
114
115 private enum DragTarget { case none, ring, triangle }
116 private var dragging: DragTarget = .none
117
118 func setColor(_ c: NSColor) {
119 let hsb = (c.usingColorSpace(.deviceRGB) ?? c)
120 hue = hsb.hueComponent
121 let b = hsb.brightnessComponent, s = hsb.saturationComponent
122 wHue = b * s
123 wWhite = b * (1 - s)
124 triangleHue = -1
125 needsDisplay = true
126 }
127
128 private var currentColor: NSColor {
129 let pure = NSColor(calibratedHue: hue, saturation: 1, brightness: 1, alpha: 1)
130 return NSColor(calibratedRed: min(1, pure.redComponent * wHue + wWhite),
131 green: min(1, pure.greenComponent * wHue + wWhite),
132 blue: min(1, pure.blueComponent * wHue + wWhite),
133 alpha: 1)
134 }
135
136 // Triangle vertices: pure hue at the top, white lower-left, black lower-right.
137 private var triVerts: [NSPoint] {
138 let r = innerR - 6
139 let c = wheelCenter
140 func pt(_ deg: CGFloat) -> NSPoint {
141 NSPoint(x: c.x + r * cos(deg * .pi / 180), y: c.y + r * sin(deg * .pi / 180))
142 }
143 return [pt(90), pt(210), pt(330)] // hue, white, black
144 }
145
146 override func draw(_ dirtyRect: NSRect) {
147 // Backing card
148 let card = NSBezierPath(roundedRect: bounds.insetBy(dx: 1, dy: 1),
149 xRadius: 10, yRadius: 10)
150 (Theme.light ? NSColor(calibratedWhite: 0.97, alpha: 0.98)
151 : NSColor(calibratedWhite: 0.14, alpha: 0.97)).setFill()
152 card.fill()
153 (Theme.light ? NSColor(calibratedWhite: 0.7, alpha: 1)
154 : NSColor(calibratedWhite: 0.35, alpha: 1)).setStroke()
155 card.lineWidth = 1
156 card.stroke()
157
158 drawHueRing()
159 drawTriangle()
160 drawIndicators()
161 drawPresets()
162 }
163
164 private func drawHueRing() {
165 let c = wheelCenter
166 let midR = (outerR + innerR) / 2
167 let steps = 180
168 for i in 0..<steps {
169 let a0 = CGFloat(i) / CGFloat(steps) * 360
170 let a1 = CGFloat(i + 1) / CGFloat(steps) * 360 + 0.8
171 let path = NSBezierPath()
172 path.appendArc(withCenter: c, radius: midR, startAngle: a0, endAngle: a1)
173 path.lineWidth = ringWidth
174 NSColor(calibratedHue: a0 / 360, saturation: 1, brightness: 1, alpha: 1).setStroke()
175 path.stroke()
176 }
177 }
178
179 private func triangleBitmap(for hue: CGFloat) -> NSImage {
180 let verts = triVerts
181 let minX = verts.map(\.x).min()!, maxX = verts.map(\.x).max()!
182 let minY = verts.map(\.y).min()!, maxY = verts.map(\.y).max()!
183 let w = Int(ceil(maxX - minX)), h = Int(ceil(maxY - minY))
184 let rep = NSBitmapImageRep(bitmapDataPlanes: nil, pixelsWide: w, pixelsHigh: h,
185 bitsPerSample: 8, samplesPerPixel: 4, hasAlpha: true,
186 isPlanar: false, colorSpaceName: .calibratedRGB,
187 bytesPerRow: w * 4, bitsPerPixel: 32)!
188 let pure = NSColor(calibratedHue: hue, saturation: 1, brightness: 1, alpha: 1)
189 let pr = pure.redComponent, pg = pure.greenComponent, pb = pure.blueComponent
190 // Local (bitmap) vertex coords; bitmap y grows downward.
191 let v = verts.map { CGPoint(x: $0.x - minX, y: maxY - $0.y) }
192 let denom = (v[1].y - v[2].y) * (v[0].x - v[2].x)
193 + (v[2].x - v[1].x) * (v[0].y - v[2].y)
194 guard let data = rep.bitmapData, abs(denom) > 0.0001 else { return NSImage() }
195 for py in 0..<h {
196 for px in 0..<w {
197 let p = CGPoint(x: CGFloat(px) + 0.5, y: CGFloat(py) + 0.5)
198 var a = ((v[1].y - v[2].y) * (p.x - v[2].x)
199 + (v[2].x - v[1].x) * (p.y - v[2].y)) / denom
200 var b = ((v[2].y - v[0].y) * (p.x - v[2].x)
201 + (v[0].x - v[2].x) * (p.y - v[2].y)) / denom
202 var cc = 1 - a - b
203 let inside = a >= -0.02 && b >= -0.02 && cc >= -0.02
204 let o = (py * w + px) * 4
205 if inside {
206 a = max(0, a); b = max(0, b); cc = max(0, cc)
207 let sum = a + b + cc
208 a /= sum; b /= sum
209 data[o] = UInt8(min(255, (pr * a + b) * 255))
210 data[o + 1] = UInt8(min(255, (pg * a + b) * 255))
211 data[o + 2] = UInt8(min(255, (pb * a + b) * 255))
212 data[o + 3] = 255
213 } else {
214 data[o] = 0; data[o + 1] = 0; data[o + 2] = 0; data[o + 3] = 0
215 }
216 }
217 }
218 let img = NSImage(size: NSSize(width: w, height: h))
219 img.addRepresentation(rep)
220 return img
221 }
222
223 private func drawTriangle() {
224 if triangleHue != hue {
225 triangleImage = triangleBitmap(for: hue)
226 triangleHue = hue
227 }
228 guard let img = triangleImage else { return }
229 let verts = triVerts
230 let minX = verts.map(\.x).min()!, maxY = verts.map(\.y).max()!
231 img.draw(at: NSPoint(x: minX, y: maxY - img.size.height),
232 from: .zero, operation: .sourceOver, fraction: 1)
233 }
234
235 private func drawIndicators() {
236 // Hue marker on the ring
237 let c = wheelCenter
238 let a = hue * 2 * .pi
239 let midR = (outerR + innerR) / 2
240 let hp = NSPoint(x: c.x + midR * cos(a), y: c.y + midR * sin(a))
241 let ring = NSBezierPath(ovalIn: NSRect(x: hp.x - 6, y: hp.y - 6, width: 12, height: 12))
242 NSColor.white.setStroke()
243 ring.lineWidth = 2.5
244 ring.stroke()
245 NSColor.black.withAlphaComponent(0.6).setStroke()
246 let ring2 = NSBezierPath(ovalIn: NSRect(x: hp.x - 7.5, y: hp.y - 7.5, width: 15, height: 15))
247 ring2.lineWidth = 1
248 ring2.stroke()
249
250 // SV marker in the triangle
251 let v = triVerts
252 let wBlack = max(0, 1 - wHue - wWhite)
253 let p = NSPoint(x: v[0].x * wHue + v[1].x * wWhite + v[2].x * wBlack,
254 y: v[0].y * wHue + v[1].y * wWhite + v[2].y * wBlack)
255 let dot = NSBezierPath(ovalIn: NSRect(x: p.x - 5, y: p.y - 5, width: 10, height: 10))
256 currentColor.setFill()
257 dot.fill()
258 NSColor.white.setStroke()
259 dot.lineWidth = 2
260 dot.stroke()
261 }
262
263 private func presetRect(_ i: Int) -> NSRect {
264 let n = Palette.colors.count
265 let w: CGFloat = 20, gap: CGFloat = 6
266 let total = CGFloat(n) * w + CGFloat(n - 1) * gap
267 let x0 = bounds.midX - total / 2
268 return NSRect(x: x0 + CGFloat(i) * (w + gap), y: 12, width: w, height: 20)
269 }
270
271 private func drawPresets() {
272 for (i, c) in Palette.colors.enumerated() {
273 let r = presetRect(i)
274 c.setFill()
275 let p = NSBezierPath(roundedRect: r, xRadius: 5, yRadius: 5)
276 p.fill()
277 NSColor(calibratedWhite: 0.5, alpha: 0.8).setStroke()
278 p.lineWidth = 1
279 p.stroke()
280 }
281 }
282
283 // MARK: Interaction
284
285 private func hitTarget(_ p: NSPoint) -> DragTarget {
286 let c = wheelCenter
287 let d = hypot(p.x - c.x, p.y - c.y)
288 if d >= innerR - 2, d <= outerR + 4 { return .ring }
289 if d < innerR { return .triangle }
290 return .none
291 }
292
293 override func mouseDown(with event: NSEvent) {
294 let p = convert(event.locationInWindow, from: nil)
295 for (i, c) in Palette.colors.enumerated()
296 where presetRect(i).insetBy(dx: -2, dy: -2).contains(p) {
297 setColor(c)
298 onChange?(currentColor)
299 needsDisplay = true
300 return
301 }
302 dragging = hitTarget(p)
303 apply(p)
304 }
305
306 override func mouseDragged(with event: NSEvent) {
307 apply(convert(event.locationInWindow, from: nil))
308 }
309
310 override func mouseUp(with event: NSEvent) { dragging = .none }
311
312 private func apply(_ p: NSPoint) {
313 switch dragging {
314 case .ring:
315 let c = wheelCenter
316 var a = atan2(p.y - c.y, p.x - c.x)
317 if a < 0 { a += 2 * .pi }
318 hue = a / (2 * .pi)
319 case .triangle:
320 let v = triVerts
321 let denom = (v[1].y - v[2].y) * (v[0].x - v[2].x)
322 + (v[2].x - v[1].x) * (v[0].y - v[2].y)
323 guard abs(denom) > 0.0001 else { return }
324 var a = ((v[1].y - v[2].y) * (p.x - v[2].x)
325 + (v[2].x - v[1].x) * (p.y - v[2].y)) / denom
326 var b = ((v[2].y - v[0].y) * (p.x - v[2].x)
327 + (v[0].x - v[2].x) * (p.y - v[2].y)) / denom
328 a = min(max(a, 0), 1)
329 b = min(max(b, 0), 1)
330 let cc = max(0, 1 - a - b)
331 let sum = a + b + cc
332 wHue = a / sum
333 wWhite = b / sum
334 case .none:
335 return
336 }
337 onChange?(currentColor)
338 needsDisplay = true
339 }
340}
sequencer/Sources/Sequencer/Document.swift created+80
...@@ -0,0 +1,80 @@
1import AppKit
2
3/// One open `.sq` project. The on-disk format is a **document package** (a
4/// directory Finder shows as one file):
5/// ```
6/// MyProject.sq/
7/// ├─ project.json (the SequencerDocument envelope: model + view state)
8/// └─ Storyboard/NN.png (per-panel drawing layers, by storyboard order)
9/// ```
10/// Legacy flat `.sq` JSON files (with a sibling `Storyboard/` folder) still
11/// open; the first save rewrites them as a package.
12final class ProjectDocument: NSDocument {
13 let ctx = DocumentContext()
14
15 override init() {
16 super.init()
17 ctx.document = self
18 }
19
20 /// Autosave in place: silent background saves, Versions, and crash recovery,
21 /// and the standard "save where?" prompt only on an untitled document's
22 /// first explicit save.
23 override class var autosavesInPlace: Bool { true }
24
25 override func makeWindowControllers() {
26 let wc = SequencerWindowController(ctx: ctx)
27 addWindowController(wc)
28 // A brand-new untitled document starts with one empty track.
29 if fileURL == nil, ctx.store.project.tracks.isEmpty {
30 ctx.store.adopt(ProjectModel())
31 }
32 wc.startDocumentServices()
33 }
34
35 // MARK: - Read
36
37 override func read(from url: URL, ofType typeName: String) throws {
38 let fm = FileManager.default
39 var isDir: ObjCBool = false
40 fm.fileExists(atPath: url.path, isDirectory: &isDir)
41 let jsonURL = isDir.boolValue ? url.appendingPathComponent("project.json") : url
42 let data = try Data(contentsOf: jsonURL)
43 let doc = try JSONDecoder().decode(SequencerDocument.self, from: data)
44 // Restore portable view state BEFORE adopting the model: adopt posts
45 // .projectChanged, which reconciles hide/focus against the live tracks.
46 ctx.session.apply(doc.view)
47 ctx.store.adopt(doc.project)
48 // Rasters live in the package's Storyboard/ dir; for a legacy flat file
49 // fall back to the sibling folder (best effort — its ordinals were
50 // shared across projects, see the migration note in the plan).
51 let storyboardDir = isDir.boolValue
52 ? url.appendingPathComponent("Storyboard", isDirectory: true)
53 : url.deletingLastPathComponent().appendingPathComponent("Storyboard", isDirectory: true)
54 ctx.boards.loadRasters(fromDirectory: storyboardDir, project: ctx.store.project)
55 }
56
57 // MARK: - Write (document package)
58
59 override func fileWrapper(ofType typeName: String) throws -> FileWrapper {
60 let enc = JSONEncoder()
61 enc.outputFormatting = [.prettyPrinted, .sortedKeys]
62 let envelope = SequencerDocument(project: ctx.store.project,
63 view: ctx.session.captureViewState())
64 let json = try enc.encode(envelope)
65 let root = FileWrapper(directoryWithFileWrappers: [
66 "project.json": FileWrapper(regularFileWithContents: json),
67 ])
68 let pngs = ctx.boards.rasterPNGs(of: ctx.store.project)
69 if !pngs.isEmpty {
70 var wrappers: [String: FileWrapper] = [:]
71 for (name, data) in pngs {
72 wrappers[name] = FileWrapper(regularFileWithContents: data)
73 }
74 let storyboard = FileWrapper(directoryWithFileWrappers: wrappers)
75 storyboard.preferredFilename = "Storyboard"
76 root.addFileWrapper(storyboard)
77 }
78 return root
79 }
80}
sequencer/Sources/Sequencer/DocumentContext.swift created+98
...@@ -0,0 +1,98 @@
1import AppKit
2
3/// Per-document service bag. Everything that is per-project — the model store,
4/// playback clock, players, proxy builder, comps scanner, storyboard rasters,
5/// and the view/session state — hangs off one of these. Views reach their
6/// state through `ctx.*`; each open `.sq` document owns exactly one context.
7final class DocumentContext {
8 /// The document that owns this context (nil for the headless harness
9 /// context). Undo/dirty flow back through it via `updateChangeCount`.
10 weak var document: ProjectDocument?
11
12 /// Per-document bus for the high-frequency `.playheadChanged` signal, so a
13 /// window playing at 60 Hz only redraws its OWN timeline/viewer/transport,
14 /// not every other open project's. (Lower-frequency notifications stay on
15 /// `.default`: they're correct app-wide because every handler reads its own
16 /// `ctx`, and the redundant redraw is cheap.)
17 let notify = NotificationCenter()
18
19 let store = Store()
20 let playback = PlaybackController()
21 let players = PlayerManager()
22 let chunks = ChunkManager()
23 let comps = FusionComps()
24 let boards = BoardStore()
25 /// Per-window view/session state (hide/focus/zoom/tool/color).
26 let session = SessionState()
27
28 private var reconcileObserver: NSObjectProtocol?
29
30 init() {
31 // Wire each service's back-reference to this context. Deferred work
32 // (timers, observers) reads `ctx.*`, so this must run before any fires.
33 store.ctx = self
34 playback.ctx = self
35 players.ctx = self
36 chunks.ctx = self
37 comps.ctx = self
38 boards.ctx = self
39 session.ctx = self
40 // Keep this document's per-track session state (focus/hide/height) in
41 // sync with its model, so deleting a focused track unfocuses it instead
42 // of blanking every surviving lane.
43 reconcileObserver = NotificationCenter.default.addObserver(
44 forName: .projectChanged, object: nil, queue: .main) { [weak self] _ in
45 guard let self else { return }
46 self.session.reconcileTracks(self.store.project)
47 }
48 }
49
50 deinit {
51 if let reconcileObserver { NotificationCenter.default.removeObserver(reconcileObserver) }
52 comps.stopWatching()
53 }
54
55 /// Start the per-document services (playback clock, comps folder watch,
56 /// derived-asset warmup). Called once by the window controller after load.
57 func startServices() {
58 MediaPipeline.shared.ensureDerivedAssets(for: store.project)
59 chunks.ensure(for: store.project)
60 comps.rescan()
61 comps.startWatching()
62 playback.start()
63 players.sync(force: true)
64 }
65
66 // MARK: - Resolving the "current" context
67
68 /// The front document's context — for app-level actions and singletons
69 /// (Settings, Export, cache eviction) that operate on whichever project is
70 /// frontmost. Falls back to the headless context when nothing is open.
71 static var current: DocumentContext {
72 if let wc = NSApp.keyWindow?.windowController as? SequencerWindowController {
73 return wc.ctx
74 }
75 if let wc = NSApp.mainWindow?.windowController as? SequencerWindowController {
76 return wc.ctx
77 }
78 if let doc = NSDocumentController.shared.currentDocument as? ProjectDocument {
79 return doc.ctx
80 }
81 return headless
82 }
83
84 /// Fallback context for the `--uitest`/`--selftest` harnesses and any
85 /// moment with no open document.
86 static let headless = DocumentContext()
87
88 /// Every live context: one per open document, plus the headless one. Used
89 /// by the global `MediaPipeline` cache so eviction considers all windows'
90 /// media, not just the front document's.
91 static var allLive: [DocumentContext] {
92 var ctxs = NSDocumentController.shared.documents.compactMap {
93 ($0 as? ProjectDocument)?.ctx
94 }
95 ctxs.append(headless)
96 return ctxs
97 }
98}
sequencer/Sources/Sequencer/Export.swift created+522
...@@ -0,0 +1,522 @@
1import AppKit
2import AVFoundation
3import ImageIO
4
5// MARK: - Formats
6
7/// The four output presets. Video formats carry a picture; audio formats are
8/// sound only (the resolution picker is disabled for them).
9enum ExportFormat: String, CaseIterable {
10 case h264mp4, webm, mp3, wav
11
12 var title: String {
13 switch self {
14 case .h264mp4: return "H.264 MP4"
15 case .webm: return "WebM (VP9)"
16 case .mp3: return "MP3 audio"
17 case .wav: return "WAV audio"
18 }
19 }
20 var ext: String {
21 switch self {
22 case .h264mp4: return "mp4"
23 case .webm: return "webm"
24 case .mp3: return "mp3"
25 case .wav: return "wav"
26 }
27 }
28 var isVideo: Bool { self == .h264mp4 || self == .webm }
29
30 /// ffmpeg codec/quality flags (no -vf; the caller prepends the scale).
31 var encoderArgs: [String] {
32 switch self {
33 case .h264mp4:
34 return ["-c:v", "libx264", "-preset", "medium", "-crf", "18",
35 "-pix_fmt", "yuv420p", "-c:a", "aac", "-b:a", "192k"]
36 case .webm:
37 return ["-c:v", "libvpx-vp9", "-b:v", "0", "-crf", "30", "-row-mt", "1",
38 "-pix_fmt", "yuv420p", "-c:a", "libopus", "-b:a", "160k"]
39 case .mp3:
40 return ["-vn", "-c:a", "libmp3lame", "-q:a", "2"]
41 case .wav:
42 return ["-vn", "-c:a", "pcm_s16le"]
43 }
44 }
45}
46
47/// Output size, expressed as a target height (width follows the source aspect,
48/// kept even). `.source` keeps native size, only forcing even dimensions.
49enum ExportResolution: CaseIterable {
50 case source, uhd, hd1080, hd720, sd480
51
52 var title: String {
53 switch self {
54 case .source: return "Source"
55 case .uhd: return "2160p (4K)"
56 case .hd1080: return "1080p"
57 case .hd720: return "720p"
58 case .sd480: return "480p"
59 }
60 }
61 var height: Int? {
62 switch self {
63 case .source: return nil
64 case .uhd: return 2160
65 case .hd1080: return 1080
66 case .hd720: return 720
67 case .sd480: return 480
68 }
69 }
70 /// ffmpeg scale filter. Never upscales past the source (min(ih, H)); the
71 /// comma inside min() is escaped so ffmpeg's filter parser keeps it whole.
72 var scaleFilter: String {
73 if let h = height { return "scale=-2:min(ih\\,\(h))" }
74 return "scale=trunc(iw/2)*2:trunc(ih/2)*2"
75 }
76}
77
78// MARK: - Pure planning logic (headless-testable)
79
80/// A single flattened output segment: one source clip's range, laid at a
81/// timeline position with no overlap. Adjacent same-clip pieces are coalesced.
82struct FlatSegment: Equatable {
83 var clipId: UUID
84 var mediaId: UUID
85 var start: Double // timeline seconds
86 var duration: Double // timeline seconds
87 var srcIn: Double // source seconds
88 var speed: Double
89 var end: Double { start + duration }
90}
91
92enum ExportPlan {
93
94 /// Flatten the selected tracks into one video layer: at every instant the
95 /// TOPMOST selected track (smallest index, passed first in `trackRefs`) that
96 /// has a clip there wins — no crossfades, just pick-top.
97 static func flattenTopmost(project: ProjectModel, trackRefs: [TrackRef]) -> [FlatSegment] {
98 let priority = Dictionary(uniqueKeysWithValues: trackRefs.enumerated().map { ($0.element, $0.offset) })
99 let clips = project.clips.filter {
100 priority[$0.track] != nil && $0.kind == .video && $0.mediaId != nil
101 }
102 guard !clips.isEmpty else { return [] }
103
104 // Cut points: every clip edge across the selected tracks.
105 var bounds = Set<Double>()
106 for c in clips { bounds.insert(c.start); bounds.insert(c.end) }
107 let cuts = bounds.sorted()
108 guard cuts.count >= 2 else { return [] }
109
110 var raw: [FlatSegment] = []
111 for i in 0..<(cuts.count - 1) {
112 let a = cuts[i], b = cuts[i + 1]
113 guard b - a > 1e-9 else { continue }
114 let mid = (a + b) / 2
115 // Topmost covering clip: lowest track priority, then latest start
116 // (the "most recent cut" on a track, matching clipAt()).
117 let win = clips.filter { $0.start <= mid && mid < $0.end }.min {
118 let pa = priority[$0.track]!, pb = priority[$1.track]!
119 if pa != pb { return pa < pb }
120 return $0.start > $1.start
121 }
122 guard let win else { continue }
123 raw.append(FlatSegment(clipId: win.id, mediaId: win.mediaId!,
124 start: a, duration: b - a,
125 srcIn: win.sourceTime(at: a), speed: win.speed))
126 }
127
128 // Coalesce contiguous pieces of the same clip back into one segment.
129 var out: [FlatSegment] = []
130 for s in raw {
131 if var last = out.last, last.clipId == s.clipId,
132 abs(last.end - s.start) < 1e-6 {
133 last.duration += s.duration
134 out[out.count - 1] = last
135 } else {
136 out.append(s)
137 }
138 }
139 return out
140 }
141
142 /// Every audio-bearing, unmuted clip on the selected tracks. Audio layers
143 /// freely (per the app's design), so these all mix together on export.
144 static func audioClips(project: ProjectModel, trackRefs: Set<TrackRef>) -> [Clip] {
145 project.clips.filter {
146 trackRefs.contains($0.track) && $0.kind != .storyboard && !$0.muted
147 && (project.media($0.mediaId)?.hasAudio ?? false)
148 }
149 }
150
151 /// Frame ranges the Fusion comps FAIL to cover across [min,max] — empty
152 /// means gapless. Each returned pair is an inclusive missing range.
153 static func fusionCoverageGaps(_ comps: [FusionComp]) -> [(Int, Int)] {
154 guard !comps.isEmpty else { return [] }
155 let ranges = comps.map { ($0.startFrame, $0.endFrame) }.sorted { $0.0 < $1.0 }
156 var gaps: [(Int, Int)] = []
157 var coveredTo = ranges[0].0 - 1 // last frame covered so far
158 for (s, e) in ranges {
159 if s > coveredTo + 1 { gaps.append((coveredTo + 1, s - 1)) }
160 coveredTo = max(coveredTo, e)
161 }
162 return gaps
163 }
164}
165
166// MARK: - Job description
167
168struct ExportJob {
169 enum Source {
170 case tracks(video: [TrackRef], audio: Set<TrackRef>) // video lanes in priority order
171 case fusion
172 case storyboard
173 }
174 var source: Source
175 var format: ExportFormat
176 var resolution: ExportResolution
177 var fps: Double
178 var dest: URL
179}
180
181// MARK: - Exporter
182
183enum ExportError: Error, LocalizedError {
184 case noFfmpeg
185 case nothingToExport(String)
186 case fusion(String)
187 case intermediateFailed
188 case encodeFailed(String)
189
190 var errorDescription: String? {
191 switch self {
192 case .noFfmpeg: return "ffmpeg was not found. Install it (e.g. `brew install ffmpeg`)."
193 case .nothingToExport(let s): return s
194 case .fusion(let s): return s
195 case .intermediateFailed: return "Could not render the timeline composition."
196 case .encodeFailed(let s): return "ffmpeg failed to encode the output.\n\n\(s)"
197 }
198 }
199}
200
201/// Runs an ExportJob off the main thread. `progress` (0…1) and `completion`
202/// are always delivered on the main thread.
203enum Exporter {
204
205 static func run(_ job: ExportJob,
206 progress: @escaping (Double) -> Void,
207 completion: @escaping (Result<URL, Error>) -> Void) {
208 DispatchQueue.global(qos: .userInitiated).async {
209 let result: Result<URL, Error>
210 do {
211 switch job.source {
212 case let .tracks(video, audio):
213 try exportTracks(job, video: video, audio: audio, progress: progress)
214 case .fusion:
215 try exportFusion(job, progress: progress)
216 case .storyboard:
217 try exportStoryboard(job, progress: progress)
218 }
219 result = .success(job.dest)
220 } catch {
221 result = .failure(error)
222 }
223 DispatchQueue.main.async { completion(result) }
224 }
225 }
226
227 // MARK: Track path (AVComposition intermediate → ffmpeg)
228
229 private static func exportTracks(_ job: ExportJob, video: [TrackRef], audio: Set<TrackRef>,
230 progress: @escaping (Double) -> Void) throws {
231 let project = DocumentContext.current.store.project
232 let segments = ExportPlan.flattenTopmost(project: project, trackRefs: video)
233 let audioClips = ExportPlan.audioClips(project: project, trackRefs: audio)
234
235 if job.format.isVideo && segments.isEmpty {
236 throw ExportError.nothingToExport(
237 "No video clips on the selected tracks for a video format.")
238 }
239 if !job.format.isVideo && audioClips.isEmpty {
240 throw ExportError.nothingToExport(
241 "No audio on the selected tracks for an audio-only format.")
242 }
243
244 let comp = AVMutableComposition()
245 let wantVideo = job.format.isVideo && !segments.isEmpty
246
247 // Video: one track, segments appended left-to-right with empty gaps.
248 if wantVideo, let vTrack = comp.addMutableTrack(
249 withMediaType: .video, preferredTrackID: kCMPersistentTrackID_Invalid) {
250 var cursor = 0.0
251 for seg in segments {
252 guard let media = project.media(seg.mediaId) else { continue }
253 if seg.start > cursor + 1e-6 {
254 vTrack.insertEmptyTimeRange(cmRange(cursor, seg.start - cursor))
255 cursor = seg.start
256 }
257 let asset = AVURLAsset(url: media.url)
258 guard let src = loadTracksSync(asset, mediaType: .video).first else {
259 vTrack.insertEmptyTimeRange(cmRange(cursor, seg.duration)); cursor += seg.duration; continue
260 }
261 let srcDur = seg.duration * seg.speed
262 let range = cmRange(seg.srcIn, srcDur)
263 let at = cm(cursor)
264 try? vTrack.insertTimeRange(range, of: src, at: at)
265 if abs(seg.speed - 1) > 1e-6 {
266 // Nothing has been appended after `at` yet, so scaling this
267 // range back to timeline duration is safe.
268 vTrack.scaleTimeRange(cmRange(cursor, srcDur), toDuration: cm(seg.duration))
269 }
270 cursor += seg.duration
271 }
272 }
273
274 // Audio: one composition track per clip so they mix; fades become
275 // volume ramps in the audio mix.
276 var mixParams: [AVMutableAudioMixInputParameters] = []
277 for clip in audioClips {
278 guard let media = project.media(clip.mediaId),
279 let aTrack = comp.addMutableTrack(
280 withMediaType: .audio, preferredTrackID: kCMPersistentTrackID_Invalid)
281 else { continue }
282 let asset = AVURLAsset(url: media.url)
283 guard let src = loadTracksSync(asset, mediaType: .audio).first else { continue }
284 let srcDur = clip.duration * clip.speed
285 try? aTrack.insertTimeRange(cmRange(clip.srcIn, srcDur), of: src, at: cm(clip.start))
286 if abs(clip.speed - 1) > 1e-6 {
287 aTrack.scaleTimeRange(cmRange(clip.start, srcDur), toDuration: cm(clip.duration))
288 }
289 let p = AVMutableAudioMixInputParameters(track: aTrack)
290 if clip.fadeIn > 0.001 {
291 p.setVolumeRamp(fromStartVolume: 0, toEndVolume: 1,
292 timeRange: cmRange(clip.start, clip.fadeIn))
293 }
294 if clip.fadeOut > 0.001 {
295 p.setVolumeRamp(fromStartVolume: 1, toEndVolume: 0,
296 timeRange: cmRange(clip.end - clip.fadeOut, clip.fadeOut))
297 }
298 mixParams.append(p)
299 }
300
301 // Render the composition to an intermediate the encoder can read.
302 let tmp = tempDir()
303 defer { try? FileManager.default.removeItem(at: tmp) }
304 let interExt = wantVideo ? "mov" : "m4a"
305 let intermediate = tmp.appendingPathComponent("intermediate.\(interExt)")
306 let preset = wantVideo ? AVAssetExportPresetHighestQuality : AVAssetExportPresetAppleM4A
307 guard let session = AVAssetExportSession(asset: comp, presetName: preset) else {
308 throw ExportError.intermediateFailed
309 }
310 session.outputURL = intermediate
311 session.outputFileType = wantVideo ? .mov : .m4a
312 if !mixParams.isEmpty {
313 let mix = AVMutableAudioMix(); mix.inputParameters = mixParams
314 session.audioMix = mix
315 }
316
317 let sema = DispatchSemaphore(value: 0)
318 // AV export is the first half of the progress bar; poll it until done.
319 let polling = AtomicFlag(true)
320 DispatchQueue.global(qos: .utility).async {
321 while polling.value {
322 DispatchQueue.main.async { progress(0.5 * Double(session.progress)) }
323 usleep(200_000)
324 }
325 }
326 session.exportAsynchronously { sema.signal() }
327 sema.wait()
328 polling.value = false
329 guard session.status == .completed else { throw ExportError.intermediateFailed }
330
331 try encodeWithFfmpeg(input: intermediate, job: job, base: 0.5, span: 0.5,
332 progress: progress)
333 }
334
335 // MARK: Fusion path (image sequence → ffmpeg)
336
337 private static func exportFusion(_ job: ExportJob, progress: @escaping (Double) -> Void) throws {
338 let comps = DocumentContext.current.comps.comps
339 guard !comps.isEmpty else { throw ExportError.fusion("No Fusion comps in this project.") }
340
341 let gaps = ExportPlan.fusionCoverageGaps(comps)
342 if let g = gaps.first {
343 throw ExportError.fusion("The Fusion comps have a gap at frames \(g.0)–\(g.1). "
344 + "Export needs a gapless range.")
345 }
346 let start = comps.map(\.startFrame).min()!
347 let end = comps.map(\.endFrame).max()!
348
349 // Resolve every frame to a file, checking size uniformity as we go.
350 var frames: [(url: URL, duration: Double)] = []
351 var size: (Int, Int)?
352 let frameDur = 1.0 / max(1, job.fps)
353 for f in start...end {
354 guard let url = DocumentContext.current.comps.renderedFrameURL(atFrame: f) else {
355 throw ExportError.fusion("Frame \(f) has not been rendered yet.")
356 }
357 if let s = imagePixelSize(url) {
358 if let known = size, known != s {
359 throw ExportError.fusion(
360 "Frame \(f) is \(s.0)×\(s.1) but earlier frames are \(known.0)×\(known.1). "
361 + "All comps must render at the same resolution.")
362 }
363 size = size ?? s
364 }
365 frames.append((url, frameDur))
366 }
367 try encodeSlideshow(frames: frames, job: job, progress: progress)
368 }
369
370 // MARK: Storyboard path (panel composites → ffmpeg)
371
372 private static func exportStoryboard(_ job: ExportJob, progress: @escaping (Double) -> Void) throws {
373 let project = DocumentContext.current.store.project
374 guard project.hasStoryboard else {
375 throw ExportError.nothingToExport("There is no storyboard in this project.")
376 }
377 let panels = project.clips(on: .storyboard)
378 .filter { $0.kind == .storyboard && $0.board != nil }
379 guard !panels.isEmpty else {
380 throw ExportError.nothingToExport("The storyboard has no panels.")
381 }
382
383 let tmp = tempDir()
384 defer { try? FileManager.default.removeItem(at: tmp) }
385 var frames: [(url: URL, duration: Double)] = []
386 for (i, panel) in panels.enumerated() {
387 let img = DocumentContext.current.boards.composite(for: panel.board!)
388 let url = tmp.appendingPathComponent(String(format: "panel%04d.png", i))
389 guard writePNG(img, to: url) else {
390 throw ExportError.nothingToExport("Could not render storyboard panel \(i + 1).")
391 }
392 frames.append((url, max(1.0 / max(1, job.fps), panel.duration)))
393 }
394 try encodeSlideshow(frames: frames, job: job, progress: progress)
395 }
396
397 // MARK: - ffmpeg back ends
398
399 /// Encode a variable-duration still-image sequence via the concat demuxer.
400 private static func encodeSlideshow(frames: [(url: URL, duration: Double)],
401 job: ExportJob,
402 progress: @escaping (Double) -> Void) throws {
403 guard let ffmpeg = MediaPipeline.findExecutable("ffmpeg") else { throw ExportError.noFfmpeg }
404 guard !frames.isEmpty else { throw ExportError.nothingToExport("Nothing to render.") }
405 if !job.format.isVideo {
406 throw ExportError.nothingToExport("A storyboard or Fusion export has no audio for \(job.format.title).")
407 }
408 let tmp = tempDir()
409 defer { try? FileManager.default.removeItem(at: tmp) }
410 let list = tmp.appendingPathComponent("frames.txt")
411 var text = "ffconcat version 1.0\n"
412 for fr in frames {
413 text += "file '\(escapeConcat(fr.url.path))'\nduration \(String(format: "%.5f", fr.duration))\n"
414 }
415 // The concat demuxer drops the final entry's duration unless the last
416 // file is repeated.
417 if let last = frames.last { text += "file '\(escapeConcat(last.url.path))'\n" }
418 try? text.write(to: list, atomically: true, encoding: .utf8)
419
420 var args = ["-y", "-f", "concat", "-safe", "0", "-i", list.path,
421 "-vf", job.resolution.scaleFilter, "-r", String(format: "%.5f", job.fps)]
422 args += job.format.encoderArgs
423 args += ["-progress", "pipe:1", "-nostats", job.dest.path]
424 let total = frames.reduce(0) { $0 + $1.duration }
425 let res = MediaPipeline.run(ffmpeg, args, duration: total) { p in
426 DispatchQueue.main.async { progress(p) }
427 }
428 if res.exitCode != 0 { throw ExportError.encodeFailed(res.stdout) }
429 }
430
431 /// Transcode an intermediate (mov/m4a) into the chosen delivery format.
432 private static func encodeWithFfmpeg(input: URL, job: ExportJob,
433 base: Double, span: Double,
434 progress: @escaping (Double) -> Void) throws {
435 guard let ffmpeg = MediaPipeline.findExecutable("ffmpeg") else { throw ExportError.noFfmpeg }
436 var args = ["-y", "-i", input.path]
437 if job.format.isVideo { args += ["-vf", job.resolution.scaleFilter] }
438 args += job.format.encoderArgs
439 args += ["-progress", "pipe:1", "-nostats", job.dest.path]
440 let dur = assetDuration(input)
441 let res = MediaPipeline.run(ffmpeg, args, duration: dur) { p in
442 DispatchQueue.main.async { progress(base + span * p) }
443 }
444 if res.exitCode != 0 { throw ExportError.encodeFailed(res.stdout) }
445 }
446
447 // MARK: - Helpers
448
449 private static func cm(_ s: Double) -> CMTime { CMTime(seconds: s, preferredTimescale: 600) }
450 private static func cmRange(_ start: Double, _ dur: Double) -> CMTimeRange {
451 CMTimeRange(start: cm(start), duration: cm(max(0, dur)))
452 }
453
454 private static func tempDir() -> URL {
455 let dir = URL(fileURLWithPath: NSTemporaryDirectory())
456 .appendingPathComponent("SequencerExport-\(UUID().uuidString)", isDirectory: true)
457 try? FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true)
458 return dir
459 }
460
461 private static func assetDuration(_ url: URL) -> Double {
462 CMTimeGetSeconds(loadDurationSync(AVURLAsset(url: url)))
463 }
464
465 /// Blocks the calling (background) thread until the async track load
466 /// completes. Safe here because callers always run off the main thread.
467 private static func loadTracksSync(_ asset: AVURLAsset, mediaType: AVMediaType) -> [AVAssetTrack] {
468 let sem = DispatchSemaphore(value: 0)
469 var tracks: [AVAssetTrack] = []
470 Task {
471 tracks = (try? await asset.loadTracks(withMediaType: mediaType)) ?? []
472 sem.signal()
473 }
474 sem.wait()
475 return tracks
476 }
477
478 /// Blocks the calling (background) thread until the async duration load
479 /// completes. Safe here because callers always run off the main thread.
480 private static func loadDurationSync(_ asset: AVURLAsset) -> CMTime {
481 let sem = DispatchSemaphore(value: 0)
482 var duration = CMTime.zero
483 Task {
484 duration = (try? await asset.load(.duration)) ?? .zero
485 sem.signal()
486 }
487 sem.wait()
488 return duration
489 }
490
491 static func imagePixelSize(_ url: URL) -> (Int, Int)? {
492 guard let src = CGImageSourceCreateWithURL(url as CFURL, nil),
493 let props = CGImageSourceCopyPropertiesAtIndex(src, 0, nil) as? [CFString: Any],
494 let w = props[kCGImagePropertyPixelWidth] as? Int,
495 let h = props[kCGImagePropertyPixelHeight] as? Int else { return nil }
496 return (w, h)
497 }
498
499 private static func writePNG(_ image: NSImage, to url: URL) -> Bool {
500 guard let tiff = image.tiffRepresentation,
501 let rep = NSBitmapImageRep(data: tiff),
502 let png = rep.representation(using: .png, properties: [:]) else { return false }
503 return (try? png.write(to: url)) != nil
504 }
505
506 /// Escape a path for a concat-demuxer `file '…'` line.
507 private static func escapeConcat(_ path: String) -> String {
508 path.replacingOccurrences(of: "'", with: "'\\''")
509 }
510}
511
512/// Minimal lock-guarded boolean shared between the export thread and its
513/// progress-poll thread.
514private final class AtomicFlag {
515 private let lock = NSLock()
516 private var _value: Bool
517 init(_ v: Bool) { _value = v }
518 var value: Bool {
519 get { lock.lock(); defer { lock.unlock() }; return _value }
520 set { lock.lock(); _value = newValue; lock.unlock() }
521 }
522}
sequencer/Sources/Sequencer/ExportDialog.swift created+289
...@@ -0,0 +1,289 @@
1import AppKit
2import UniformTypeIdentifiers
3
4/// The Export sheet: pick a format + resolution, tick the tracks to flatten,
5/// or exclusively pick the whole Fusion render / the whole storyboard.
6final class ExportDialog: NSObject {
7 static let shared = ExportDialog()
8
9 private var window: NSWindow?
10 private var trackRows: [(index: Int, checkbox: NSButton)] = []
11 private var fusionCheckbox: NSButton?
12 private var storyboardCheckbox: NSButton?
13 private let formatPopup = NSPopUpButton()
14 private let resolutionPopup = NSPopUpButton()
15 private let hud = ExportProgressHUD()
16
17 private var project: ProjectModel { DocumentContext.current.store.project }
18
19 func show() {
20 // Rebuilt every time — the track list depends on the current project.
21 buildWindow()
22 window?.center()
23 window?.makeKeyAndOrderFront(nil)
24 NSApp.activate(ignoringOtherApps: true)
25 }
26
27 // MARK: - Build
28
29 private func buildWindow() {
30 trackRows = []
31 fusionCheckbox = nil
32 storyboardCheckbox = nil
33
34 let w = NSWindow(contentRect: NSRect(x: 0, y: 0, width: 420, height: 400),
35 styleMask: [.titled, .closable], backing: .buffered, defer: false)
36 w.title = "Export"
37 w.isReleasedWhenClosed = false
38
39 let root = NSStackView()
40 root.orientation = .vertical
41 root.alignment = .leading
42 root.spacing = 12
43 root.edgeInsets = NSEdgeInsets(top: 16, left: 18, bottom: 16, right: 18)
44 root.translatesAutoresizingMaskIntoConstraints = false
45
46 // Format + resolution row.
47 formatPopup.removeAllItems()
48 for f in ExportFormat.allCases { formatPopup.addItem(withTitle: f.title) }
49 formatPopup.target = self
50 formatPopup.action = #selector(formatChanged)
51
52 resolutionPopup.removeAllItems()
53 for r in ExportResolution.allCases { resolutionPopup.addItem(withTitle: r.title) }
54 resolutionPopup.selectItem(at: 2) // 1080p default
55
56 let formatRow = NSStackView(views: [
57 NSTextField(labelWithString: "Format"), formatPopup,
58 NSTextField(labelWithString: "Size"), resolutionPopup,
59 ])
60 formatRow.spacing = 8
61 root.addArrangedSubview(formatRow)
62
63 let sep = NSBox(); sep.boxType = .separator
64 sep.translatesAutoresizingMaskIntoConstraints = false
65 root.addArrangedSubview(sep)
66 sep.widthAnchor.constraint(equalTo: root.widthAnchor, constant: -36).isActive = true
67
68 root.addArrangedSubview(NSTextField(labelWithString: "Tracks to export:"))
69
70 // Special exclusive rows on top.
71 if !DocumentContext.current.comps.comps.isEmpty {
72 let cb = makeExclusiveRow(title: "Fusion (all comps)", color: FusionComps.yellow, into: root)
73 fusionCheckbox = cb
74 }
75 if project.hasStoryboard {
76 let cb = makeExclusiveRow(title: "Storyboard",
77 color: color(hue: project.hue(for: .storyboard)), into: root)
78 storyboardCheckbox = cb
79 }
80
81 // Regular track rows: only tracks that actually hold clips.
82 for index in project.tracks.indices {
83 let clips = project.clips(onVideo: index)
84 guard !clips.isEmpty else { continue }
85 let cb = NSButton(checkboxWithTitle: trackName(index), target: self,
86 action: #selector(trackToggled))
87 cb.state = isAudioOnly(index) ? .on : .off // audio beds start ticked
88 let swatch = colorSwatch(color(hue: project.hue(for: .video(index))))
89 let row = NSStackView(views: [swatch, cb])
90 row.spacing = 6
91 root.addArrangedSubview(row)
92 trackRows.append((index, cb))
93 }
94
95 // Buttons.
96 let cancel = NSButton(title: "Cancel", target: self, action: #selector(closeWindow))
97 cancel.keyEquivalent = "\u{1b}"
98 let export = NSButton(title: "Export…", target: self, action: #selector(startExport))
99 export.keyEquivalent = "\r"
100 let spacer = NSView()
101 spacer.setContentHuggingPriority(.init(1), for: .horizontal)
102 let buttons = NSStackView(views: [spacer, cancel, export])
103 buttons.spacing = 8
104 buttons.translatesAutoresizingMaskIntoConstraints = false
105
106 let container = NSView()
107 container.addSubview(root)
108 container.addSubview(buttons)
109 NSLayoutConstraint.activate([
110 root.topAnchor.constraint(equalTo: container.topAnchor),
111 root.leadingAnchor.constraint(equalTo: container.leadingAnchor),
112 root.trailingAnchor.constraint(equalTo: container.trailingAnchor),
113 buttons.topAnchor.constraint(greaterThanOrEqualTo: root.bottomAnchor, constant: 8),
114 buttons.leadingAnchor.constraint(equalTo: container.leadingAnchor, constant: 18),
115 buttons.trailingAnchor.constraint(equalTo: container.trailingAnchor, constant: -18),
116 buttons.bottomAnchor.constraint(equalTo: container.bottomAnchor, constant: -14),
117 ])
118 w.contentView = container
119 w.setContentSize(container.fittingSize)
120 window = w
121 refreshEnablement()
122 }
123
124 private func makeExclusiveRow(title: String, color: NSColor, into root: NSStackView) -> NSButton {
125 let cb = NSButton(checkboxWithTitle: title, target: self, action: #selector(exclusiveToggled))
126 let row = NSStackView(views: [colorSwatch(color), cb])
127 row.spacing = 6
128 root.addArrangedSubview(row)
129 return cb
130 }
131
132 private func colorSwatch(_ c: NSColor) -> NSView {
133 let v = NSView()
134 v.wantsLayer = true
135 v.layer?.backgroundColor = c.cgColor
136 v.layer?.cornerRadius = 3
137 v.translatesAutoresizingMaskIntoConstraints = false
138 v.widthAnchor.constraint(equalToConstant: 14).isActive = true
139 v.heightAnchor.constraint(equalToConstant: 14).isActive = true
140 return v
141 }
142
143 private func color(hue: Double) -> NSColor {
144 NSColor(calibratedHue: hue, saturation: 0.55, brightness: 0.85, alpha: 1)
145 }
146
147 private func trackName(_ index: Int) -> String {
148 let clips = project.clips(onVideo: index)
149 if let first = clips.first, let m = project.media(first.mediaId) { return m.displayName }
150 return "Track \(index + 1)"
151 }
152
153 private func isAudioOnly(_ index: Int) -> Bool {
154 let clips = project.clips(onVideo: index)
155 return !clips.isEmpty && clips.allSatisfy {
156 $0.kind == .audio || (project.media($0.mediaId)?.isAudio ?? false)
157 }
158 }
159
160 // MARK: - Actions
161
162 @objc private func formatChanged() {
163 let f = ExportFormat.allCases[formatPopup.indexOfSelectedItem]
164 resolutionPopup.isEnabled = f.isVideo
165 }
166
167 @objc private func trackToggled() {}
168
169 @objc private func exclusiveToggled(_ sender: NSButton) {
170 // Fusion and storyboard are mutually exclusive with each other too.
171 if sender === fusionCheckbox, sender.state == .on { storyboardCheckbox?.state = .off }
172 if sender === storyboardCheckbox, sender.state == .on { fusionCheckbox?.state = .off }
173 refreshEnablement()
174 }
175
176 /// Ticking Fusion or Storyboard disables and clears everything else.
177 private func refreshEnablement() {
178 let fusionOn = fusionCheckbox?.state == .on
179 let storyOn = storyboardCheckbox?.state == .on
180 let special = fusionOn || storyOn
181 for (_, cb) in trackRows {
182 cb.isEnabled = !special
183 if special { cb.state = .off }
184 }
185 fusionCheckbox?.isEnabled = !storyOn
186 storyboardCheckbox?.isEnabled = !fusionOn
187 let f = ExportFormat.allCases[formatPopup.indexOfSelectedItem]
188 resolutionPopup.isEnabled = f.isVideo
189 }
190
191 @objc private func closeWindow() { window?.close() }
192
193 @objc private func startExport() {
194 let format = ExportFormat.allCases[formatPopup.indexOfSelectedItem]
195 let resolution = ExportResolution.allCases[resolutionPopup.indexOfSelectedItem]
196
197 let source: ExportJob.Source
198 if fusionCheckbox?.state == .on {
199 source = .fusion
200 } else if storyboardCheckbox?.state == .on {
201 source = .storyboard
202 } else {
203 let checked = trackRows.filter { $0.checkbox.state == .on }.map(\.index)
204 guard !checked.isEmpty else {
205 alert("Select at least one track to export."); return
206 }
207 let ordered = checked.sorted().map { TrackRef.video($0) }
208 source = .tracks(video: ordered, audio: Set(ordered))
209 }
210
211 let save = NSSavePanel()
212 if let t = UTType(filenameExtension: format.ext) { save.allowedContentTypes = [t] }
213 save.nameFieldStringValue = "\(defaultBaseName()).\(format.ext)"
214 save.canCreateDirectories = true
215 guard save.runModal() == .OK, let dest = save.url else { return }
216
217 let job = ExportJob(source: source, format: format, resolution: resolution,
218 fps: project.fps, dest: dest)
219 window?.close()
220 hud.begin(title: "Exporting \(dest.lastPathComponent)")
221 Exporter.run(job, progress: { [hud] f in hud.setFraction(f) }) { [hud] result in
222 hud.end()
223 switch result {
224 case .success(let url):
225 NotificationCenter.default.post(name: .transientStatus, object: nil,
226 userInfo: ["text": "Exported \(url.lastPathComponent)"])
227 NSWorkspace.shared.activateFileViewerSelecting([url])
228 case .failure(let err):
229 let a = NSAlert()
230 a.messageText = "Export failed"
231 a.informativeText = (err as? LocalizedError)?.errorDescription ?? "\(err)"
232 a.alertStyle = .warning
233 a.runModal()
234 }
235 }
236 }
237
238 private func defaultBaseName() -> String {
239 if let url = DocumentContext.current.document?.fileURL {
240 return url.deletingPathExtension().lastPathComponent
241 }
242 return "Export"
243 }
244
245 private func alert(_ text: String) {
246 let a = NSAlert(); a.messageText = text; a.runModal()
247 }
248}
249
250/// Small always-on-top determinate progress panel shown during an export.
251final class ExportProgressHUD {
252 private var panel: NSPanel?
253 private let bar = NSProgressIndicator()
254 private let label = NSTextField(labelWithString: "")
255
256 func begin(title: String) {
257 label.stringValue = title
258 bar.isIndeterminate = false
259 bar.minValue = 0; bar.maxValue = 1; bar.doubleValue = 0
260 bar.style = .bar
261
262 let p = NSPanel(contentRect: NSRect(x: 0, y: 0, width: 340, height: 90),
263 styleMask: [.titled], backing: .buffered, defer: false)
264 p.title = "Export"
265 let stack = NSStackView(views: [label, bar])
266 stack.orientation = .vertical
267 stack.alignment = .leading
268 stack.spacing = 12
269 stack.edgeInsets = NSEdgeInsets(top: 18, left: 18, bottom: 18, right: 18)
270 stack.translatesAutoresizingMaskIntoConstraints = false
271 p.contentView?.addSubview(stack)
272 NSLayoutConstraint.activate([
273 stack.topAnchor.constraint(equalTo: p.contentView!.topAnchor),
274 stack.leadingAnchor.constraint(equalTo: p.contentView!.leadingAnchor),
275 stack.trailingAnchor.constraint(equalTo: p.contentView!.trailingAnchor),
276 bar.widthAnchor.constraint(equalTo: stack.widthAnchor, constant: -36),
277 ])
278 p.center()
279 p.makeKeyAndOrderFront(nil)
280 panel = p
281 }
282
283 func setFraction(_ f: Double) { bar.doubleValue = min(1, max(0, f)) }
284
285 func end() {
286 panel?.close()
287 panel = nil
288 }
289}
sequencer/Sources/Sequencer/FusionComps.swift created+380
...@@ -0,0 +1,380 @@
1import AppKit
2
3extension Notification.Name {
4 static let compsChanged = Notification.Name("compsChanged")
5}
6
7/// One Fusion composition discovered in the project's comps folder. The frame
8/// range comes from the filename prefix ("0200-0681_intro.comp"); the output
9/// image sequence comes from the comp's Saver (preferring one named
10/// MainOutput).
11struct FusionComp: Equatable, Identifiable {
12 var path: String
13 var name: String // filename, also the preferred-take key
14 var title: String // suffix after the range
15 var startFrame: Int
16 var endFrame: Int // inclusive
17 var mtime: Date = .distantPast
18 var saverPath: String? // Saver clip filename (image sequence base)
19
20 var id: String { path }
21 func startSeconds(fps: Double) -> Double { Double(startFrame) / fps }
22 func endSeconds(fps: Double) -> Double { Double(endFrame + 1) / fps }
23}
24
25/// Scans the comps folder, parses Savers, lays comps out into sub-lanes when
26/// ranges overlap, and resolves output frames to image-sequence files.
27/// All file I/O runs off-main (NAS!); results land on main.
28final class FusionComps {
29 /// The document context that owns this scanner. Set at construction.
30 unowned var ctx: DocumentContext!
31
32 /// The exact yellow of the Fusion app icon.
33 static let yellow = NSColor(calibratedRed: 1.0, green: 0.878, blue: 0.0, alpha: 1)
34 /// Synthetic id for the viewer cell.
35 static let viewerCellId = UUID(uuidString: "F0510000-0000-0000-0000-000000000001")!
36
37 private(set) var comps: [FusionComp] = []
38 var selectedCompPath: String?
39 private var scannedFolder: String?
40 private var scanning = false
41 // path → (frame → file URL) for each comp's rendered sequence.
42 // Lock-guarded: built on background queues (directory listing hits the NAS).
43 private var sequenceCache: [String: [Int: URL]] = [:]
44 private let sequenceLock = NSLock()
45 private let imageCache = NSCache<NSString, NSImage>()
46
47 var visible: Bool {
48 ctx.store.project.compsFolder != nil && !comps.isEmpty
49 }
50
51 init() {
52 imageCache.countLimit = 120
53 NotificationCenter.default.addObserver(
54 forName: .projectChanged, object: nil, queue: .main) { [weak self] _ in
55 guard let self else { return }
56 let folder = self.ctx.store.project.compsFolder
57 if folder != self.scannedFolder { self.rescan() }
58 }
59 }
60
61 // MARK: - Scanning
62
63 /// Filename prefix parse: "0200-0681_intro.comp" → (200, 681, "intro").
64 static func parseCompName(_ name: String) -> (start: Int, end: Int, title: String)? {
65 guard name.hasSuffix(".comp") else { return nil }
66 let stem = String(name.dropLast(5))
67 let pattern = #"^(\d+)-(\d+)[_\- ]?(.*)$"#
68 guard let re = try? NSRegularExpression(pattern: pattern),
69 let m = re.firstMatch(in: stem, range: NSRange(stem.startIndex..., in: stem)),
70 let r1 = Range(m.range(at: 1), in: stem),
71 let r2 = Range(m.range(at: 2), in: stem),
72 let a = Int(stem[r1]), let b = Int(stem[r2]), b >= a
73 else { return nil }
74 let title = Range(m.range(at: 3), in: stem).map { String(stem[$0]) } ?? ""
75 return (a, b, title)
76 }
77
78 /// Find the output Saver's clip filename. Prefers a tool named
79 /// MainOutput; falls back to the first Saver with a filename.
80 static func parseSaverPath(compText: String) -> String? {
81 let pattern = #"([A-Za-z0-9_]+)\s*=\s*Saver\s*\{"#
82 guard let re = try? NSRegularExpression(pattern: pattern) else { return nil }
83 let ns = compText as NSString
84 var candidates: [(name: String, filename: String)] = []
85 re.enumerateMatches(in: compText,
86 range: NSRange(location: 0, length: ns.length)) { m, _, _ in
87 guard let m else { return }
88 let name = ns.substring(with: m.range(at: 1))
89 // Search a window after the Saver header for its Clip Filename.
90 let searchStart = m.range.location + m.range.length
91 let window = NSRange(location: searchStart,
92 length: min(4000, ns.length - searchStart))
93 if let fre = try? NSRegularExpression(pattern: #"Filename\s*=\s*"([^"]*)""#),
94 let fm = fre.firstMatch(in: compText, range: window) {
95 candidates.append((name, ns.substring(with: fm.range(at: 1))))
96 }
97 }
98 let best = candidates.first { $0.name.localizedCaseInsensitiveContains("mainoutput") }
99 ?? candidates.first { !$0.filename.isEmpty }
100 return best?.filename.isEmpty == false ? best?.filename : nil
101 }
102
103 func rescan() {
104 let folder = ctx.store.project.compsFolder
105 scannedFolder = folder
106 guard let folder else {
107 comps = []
108 sequenceLock.lock(); sequenceCache = [:]; sequenceLock.unlock()
109 NotificationCenter.default.post(name: .compsChanged, object: nil)
110 return
111 }
112 guard !scanning else { return }
113 scanning = true
114 DispatchQueue.global(qos: .userInitiated).async { [weak self] in
115 let fm = FileManager.default
116 var found: [FusionComp] = []
117 let names = (try? fm.contentsOfDirectory(atPath: folder)) ?? []
118 for name in names.sorted() {
119 guard let (a, b, title) = Self.parseCompName(name) else { continue }
120 let path = (folder as NSString).appendingPathComponent(name)
121 var comp = FusionComp(path: path, name: name, title: title,
122 startFrame: a, endFrame: b)
123 let attrs = try? fm.attributesOfItem(atPath: path)
124 comp.mtime = (attrs?[.modificationDate] as? Date) ?? .distantPast
125 if let text = try? String(contentsOfFile: path, encoding: .utf8) {
126 comp.saverPath = Self.parseSaverPath(compText: text)
127 }
128 found.append(comp)
129 }
130 DispatchQueue.main.async {
131 guard let self else { return }
132 self.scanning = false
133 self.comps = found
134 self.sequenceLock.lock(); self.sequenceCache = [:]; self.sequenceLock.unlock()
135 self.imageCache.removeAllObjects()
136 NotificationCenter.default.post(name: .compsChanged, object: nil)
137 if self.scannedFolder != self.ctx.store.project.compsFolder { self.rescan() }
138 }
139 }
140 }
141
142 // MARK: - Layout & stacking
143
144 /// Sub-lane assignment: overlapping comps share the band, each at reduced
145 /// height. Returns (comp, lane, laneCount-in-its-cluster).
146 func stacked() -> [(comp: FusionComp, lane: Int, lanes: Int)] {
147 let sorted = comps.sorted { ($0.startFrame, $0.endFrame) < ($1.startFrame, $1.endFrame) }
148 var placed: [(comp: FusionComp, lane: Int, cluster: Int)] = []
149 var laneEnds: [Int] = [] // per-lane last endFrame, current cluster
150 var clusterOf: [Int] = [] // lane → cluster id
151 var clusterId = -1
152 var clusterMaxEnd = Int.min
153 for comp in sorted {
154 if comp.startFrame > clusterMaxEnd {
155 clusterId += 1
156 laneEnds = []
157 clusterOf = []
158 }
159 clusterMaxEnd = max(clusterMaxEnd, comp.endFrame)
160 var lane = laneEnds.firstIndex { $0 < comp.startFrame }
161 if lane == nil {
162 laneEnds.append(comp.endFrame)
163 clusterOf.append(clusterId)
164 lane = laneEnds.count - 1
165 } else {
166 laneEnds[lane!] = comp.endFrame
167 }
168 placed.append((comp, lane!, clusterId))
169 }
170 var clusterLanes: [Int: Int] = [:]
171 for p in placed {
172 clusterLanes[p.cluster] = max(clusterLanes[p.cluster] ?? 1, p.lane + 1)
173 }
174 return placed.map { ($0.comp, $0.lane, clusterLanes[$0.cluster] ?? 1) }
175 }
176
177 func comp(at path: String?) -> FusionComp? {
178 guard let path else { return nil }
179 return comps.first { $0.path == path }
180 }
181
182 /// Comps covering a frame, topmost first. Preferred takes win; ties go to
183 /// the most recently modified comp.
184 func comps(atFrame f: Int) -> [FusionComp] {
185 let preferred = Set(ctx.store.project.preferredTakes)
186 return comps
187 .filter { f >= $0.startFrame && f <= $0.endFrame }
188 .sorted {
189 let pa = preferred.contains($0.name), pb = preferred.contains($1.name)
190 if pa != pb { return pa }
191 return $0.mtime > $1.mtime
192 }
193 }
194
195 func topmost(atFrame f: Int) -> FusionComp? { comps(atFrame: f).first }
196
197 /// Exact rendered image file for the topmost comp at a global frame (no
198 /// nearest-frame fallback — export needs the real frame or nothing). File
199 /// I/O here (the directory listing) is cached after the first call.
200 func renderedFrameURL(atFrame f: Int) -> URL? {
201 guard let comp = topmost(atFrame: f) else { return nil }
202 return sequence(for: comp)[f]
203 }
204
205 // MARK: - Rendered output
206
207 /// Frame → file map for a comp's Saver sequence, built from a directory
208 /// listing (Fusion writes "<base><frame digits><ext>", frame numbers are
209 /// comp-global).
210 private func sequence(for comp: FusionComp) -> [Int: URL] {
211 sequenceLock.lock()
212 if let cached = sequenceCache[comp.path] {
213 sequenceLock.unlock()
214 return cached
215 }
216 sequenceLock.unlock()
217 var map: [Int: URL] = [:]
218 defer {
219 sequenceLock.lock()
220 sequenceCache[comp.path] = map
221 sequenceLock.unlock()
222 }
223 guard let saver = comp.saverPath else { return map }
224 let url = URL(fileURLWithPath: saver)
225 let dir = url.deletingLastPathComponent()
226 let base = url.deletingPathExtension().lastPathComponent
227 let ext = url.pathExtension.lowercased()
228 guard let names = try? FileManager.default
229 .contentsOfDirectory(atPath: dir.path) else { return map }
230 for n in names {
231 guard n.lowercased().hasSuffix(".\(ext)"), n.hasPrefix(base) else { continue }
232 let digits = n.dropFirst(base.count).dropLast(ext.count + 1)
233 guard !digits.isEmpty, digits.allSatisfy(\.isNumber),
234 let f = Int(digits) else { continue }
235 map[f] = dir.appendingPathComponent(n)
236 }
237 return map
238 }
239
240 /// Rendered image for the topmost comp at a timeline frame; nearest
241 /// available frame within the comp range fills gaps mid-render.
242 /// Loads async off-main and posts .compsChanged when the image lands.
243 func frameImage(atFrame f: Int) -> (comp: FusionComp, image: NSImage?)? {
244 guard let comp = topmost(atFrame: f) else { return nil }
245 let key = "\(comp.path)#\(f)" as NSString
246 if let img = imageCache.object(forKey: key) { return (comp, img) }
247 DispatchQueue.global(qos: .userInitiated).async { [weak self] in
248 guard let self else { return }
249 let seq = self.sequence(for: comp)
250 let url = seq[f] ?? seq.keys.sorted { abs($0 - f) < abs($1 - f) }.first
251 .flatMap { seq[$0] }
252 guard let url, let img = Self.downsampled(url: url, maxDim: 1280) else { return }
253 DispatchQueue.main.async {
254 self.imageCache.setObject(img, forKey: key)
255 NotificationCenter.default.post(name: .compsChanged, object: nil)
256 }
257 }
258 return (comp, nil)
259 }
260
261 static func downsampled(url: URL, maxDim: CGFloat) -> NSImage? {
262 guard let src = CGImageSourceCreateWithURL(url as CFURL, nil) else { return nil }
263 let opts: [CFString: Any] = [
264 kCGImageSourceCreateThumbnailFromImageAlways: true,
265 kCGImageSourceThumbnailMaxPixelSize: maxDim,
266 kCGImageSourceCreateThumbnailWithTransform: true,
267 ]
268 guard let cg = CGImageSourceCreateThumbnailAtIndex(src, 0, opts as CFDictionary)
269 else { return nil }
270 return NSImage(cgImage: cg, size: NSSize(width: cg.width, height: cg.height))
271 }
272
273 // MARK: - Actions
274
275 // MARK: - Live updates
276 // kqueue-style file events are unreliable on SMB mounts, so instead of
277 // polling on a timer, a background pass runs whenever the app regains
278 // focus (you were just in Fusion rendering — that's the moment new comps
279 // or frames exist). It fingerprints the comps folder (names + mtimes) and
280 // each Saver's render directory (file count + last name); any change
281 // rescans / refreshes previews.
282
283 private var polling = false
284 private var folderSignature: String?
285 private var renderSignatures: [String: String] = [:]
286 private var activeObserver: NSObjectProtocol?
287
288 func startWatching() {
289 activeObserver = NotificationCenter.default.addObserver(
290 forName: NSApplication.didBecomeActiveNotification, object: nil,
291 queue: .main) { [weak self] _ in
292 self?.poll()
293 }
294 }
295
296 /// Remove the app-active poll observer when the document closes (block-based
297 /// observers aren't auto-removed, so a closed window would keep polling).
298 func stopWatching() {
299 if let activeObserver {
300 NotificationCenter.default.removeObserver(activeObserver)
301 self.activeObserver = nil
302 }
303 }
304
305 private func poll() {
306 guard !polling, !scanning,
307 let folder = ctx.store.project.compsFolder else { return }
308 polling = true
309 let comps = self.comps
310 DispatchQueue.global(qos: .utility).async { [weak self] in
311 guard let self else { return }
312 let fm = FileManager.default
313 // Comps folder fingerprint
314 var parts: [String] = []
315 for name in ((try? fm.contentsOfDirectory(atPath: folder)) ?? []).sorted()
316 where name.hasSuffix(".comp") {
317 let path = (folder as NSString).appendingPathComponent(name)
318 let mtime = (try? fm.attributesOfItem(atPath: path))?[.modificationDate] as? Date
319 parts.append("\(name)@\(mtime?.timeIntervalSince1970 ?? 0)")
320 }
321 let folderSig = parts.joined(separator: "|")
322
323 // Render dir fingerprints (distinct Saver directories, capped)
324 var renderSigs: [String: String] = [:]
325 let dirs = Set(comps.compactMap { $0.saverPath }
326 .map { (($0 as NSString).deletingLastPathComponent) }).prefix(12)
327 for dir in dirs {
328 let names = (try? fm.contentsOfDirectory(atPath: dir)) ?? []
329 renderSigs[dir] = "\(names.count)#\(names.max() ?? "")"
330 }
331
332 DispatchQueue.main.async {
333 defer { self.polling = false }
334 var changed = false
335 if let old = self.folderSignature, old != folderSig {
336 self.rescan()
337 changed = true
338 }
339 self.folderSignature = folderSig
340 if !changed {
341 for (dir, sig) in renderSigs {
342 if let old = self.renderSignatures[dir], old != sig {
343 // New frames landed: refresh sequences + previews.
344 self.sequenceLock.lock()
345 self.sequenceCache = [:]
346 self.sequenceLock.unlock()
347 self.imageCache.removeAllObjects()
348 NotificationCenter.default.post(name: .compsChanged, object: nil)
349 break
350 }
351 }
352 }
353 self.renderSignatures = renderSigs
354 }
355 }
356 }
357
358 func togglePreferredTake() {
359 guard let comp = comp(at: selectedCompPath) else {
360 NotificationCenter.default.post(name: .transientStatus, object: nil,
361 userInfo: ["text": "Select a Fusion comp to set the preferred take"])
362 return
363 }
364 ctx.store.mutate { model in
365 if let i = model.preferredTakes.firstIndex(of: comp.name) {
366 model.preferredTakes.remove(at: i)
367 } else {
368 model.preferredTakes.append(comp.name)
369 }
370 }
371 let on = ctx.store.project.preferredTakes.contains(comp.name)
372 NotificationCenter.default.post(name: .transientStatus, object: nil,
373 userInfo: ["text": on ? "★ \(comp.name) is the preferred take"
374 : "\(comp.name) is no longer preferred"])
375 }
376
377 func openInFusion(_ comp: FusionComp) {
378 NSWorkspace.shared.open(URL(fileURLWithPath: comp.path))
379 }
380}
sequencer/Sources/Sequencer/FusionExport.swift created+101
...@@ -0,0 +1,101 @@
1import Foundation
2import AppKit
3
4/// Generates Fusion clipboard Lua: one Loader per clip, paste directly into
5/// the Flow view. References ORIGINAL media paths, never proxies.
6/// GlobalStart/End place the clip at its timeline position (project fps);
7/// TrimIn/Out select the source range (source fps).
8enum FusionExport {
9
10 static func copySelectedClips() {
11 let store = DocumentContext.current.store
12 let project = store.project
13 let clips = project.clips
14 .filter { store.selection.contains($0.id) }
15 .sorted { $0.start < $1.start }
16 guard !clips.isEmpty else {
17 return
18 }
19 let lua = loaderLua(for: clips, project: project)
20 let pb = NSPasteboard.general
21 pb.clearContents()
22 pb.setString(lua, forType: .string)
23 }
24
25 static func loaderLua(for clips: [Clip], project: ProjectModel) -> String {
26 var tools: [String] = []
27 var lastName = ""
28 var usedNames = Set<String>()
29 for (i, clip) in clips.enumerated() {
30 guard let media = project.media(clip.mediaId) else { continue }
31 var name = "Loader_" + sanitize(media.url.deletingPathExtension().lastPathComponent)
32 var n = 1
33 while usedNames.contains(name) { n += 1; name = name.replacingOccurrences(of: #"_\d+$"#, with: "", options: .regularExpression) + "_\(n)" }
34 usedNames.insert(name)
35 lastName = name
36
37 let srcFps = media.fps
38 let projFps = project.fps
39 // Source range (speed-aware: stretched clips still reference
40 // their true source frames — Loaders don't retime).
41 let trimIn = Int((clip.srcIn * srcFps).rounded())
42 let trimOut = max(trimIn, Int((clip.srcOut * srcFps).rounded()) - 1)
43 let globalStart = Int((clip.start * projFps).rounded())
44 let globalEnd = max(globalStart, globalStart + Int((clip.duration * projFps).rounded()) - 1)
45 let length = max(1, Int((media.duration * srcFps).rounded()))
46 let posX = Double(i % 5) * 130.0
47 let posY = Double(i / 5) * 50.0
48
49 tools.append("""
50 \(name) = Loader {
51 Clips = {
52 Clip {
53 ID = "Clip1",
54 Filename = "\(escapeLua(media.path))",
55 FormatID = "QuickTimeMovies",
56 Length = \(length),
57 Multiframe = true,
58 TrimIn = \(trimIn),
59 TrimOut = \(trimOut),
60 ExtendFirst = 0,
61 ExtendLast = 0,
62 Loop = 0,
63 AspectMode = 0,
64 Depth = 0,
65 TimeCode = 0,
66 GlobalStart = \(globalStart),
67 GlobalEnd = \(globalEnd)
68 }
69 },
70 CtrlWZoom = false,
71 ViewInfo = OperatorInfo { Pos = { \(posX), \(posY) } },
72 }
73""")
74 }
75 return """
76{
77 Tools = ordered() {
78\(tools.joined(separator: ",\n"))
79 },
80 ActiveTool = "\(lastName)"
81}
82"""
83 }
84
85 private static func sanitize(_ s: String) -> String {
86 var out = s.map { c -> Character in
87 (c.isLetter && c.isASCII) || (c.isNumber && c.isASCII) ? c : "_"
88 }
89 if let first = out.first, first.isNumber { out.insert("_", at: 0) }
90 return out.isEmpty ? "Clip" : String(out)
91 }
92
93 private static func escapeLua(_ s: String) -> String {
94 s.replacingOccurrences(of: "\\", with: "\\\\")
95 .replacingOccurrences(of: "\"", with: "\\\"")
96 }
97
98 private static func post(_ text: String) {
99 NotificationCenter.default.post(name: .transientStatus, object: nil, userInfo: ["text": text])
100 }
101}
sequencer/Sources/Sequencer/MediaPipeline.swift created+438
...@@ -0,0 +1,438 @@
1import Foundation
2import AppKit
3import CryptoKit
4
5struct MediaStatus {
6 var probing = false
7 var filmstripReady = false
8 var proxyReady = false
9 var proxyProgress: Double = 0 // 0..1 while generating
10 var failed: String? = nil
11}
12
13/// ffprobe/ffmpeg-based derived-media pipeline: probe, filmstrip, ProRes
14/// proxy. Cache is content-addressed and LRU-capped so NAS media gets a
15/// bounded local working set.
16final class MediaPipeline {
17 static let shared = MediaPipeline()
18
19 let cacheRoot: URL
20 /// LRU cap in bytes (default 50 GB). Override with `defaults write
21 /// com.sequencer maxCacheGB -int 100`.
22 var maxCacheBytes: Int64 {
23 let gb = UserDefaults.standard.integer(forKey: "maxCacheGB")
24 return Int64(gb > 0 ? gb : 50) * 1_000_000_000
25 }
26
27 private let ffmpeg: String?
28 private let ffprobe: String?
29 private let workQueue = OperationQueue()
30 private var statuses: [UUID: MediaStatus] = [:] // main-thread only
31 private let thumbCache = NSCache<NSString, NSImage>()
32 private var stripInfoCache: [String: (interval: Double, count: Int)] = [:]
33 private var lruTouched: [String: Date] = [:]
34
35 init() {
36 if let custom = UserDefaults.standard.string(forKey: "cacheDir") {
37 cacheRoot = URL(fileURLWithPath: (custom as NSString).expandingTildeInPath)
38 } else {
39 cacheRoot = FileManager.default.urls(for: .cachesDirectory, in: .userDomainMask)[0]
40 .appendingPathComponent("Sequencer", isDirectory: true)
41 }
42 try? FileManager.default.createDirectory(at: cacheRoot, withIntermediateDirectories: true)
43 ffmpeg = Self.findExecutable("ffmpeg")
44 ffprobe = Self.findExecutable("ffprobe")
45 workQueue.maxConcurrentOperationCount = 2
46 thumbCache.countLimit = 2000
47 }
48
49 static func findExecutable(_ name: String) -> String? {
50 var candidates = (ProcessInfo.processInfo.environment["PATH"] ?? "")
51 .split(separator: ":").map(String.init)
52 candidates += ["/opt/homebrew/bin", "/usr/local/bin", "/run/current-system/sw/bin",
53 "\(NSHomeDirectory())/.nix-profile/bin",
54 "/etc/profiles/per-user/\(NSUserName())/bin"]
55 for dir in candidates {
56 let p = "\(dir)/\(name)"
57 if FileManager.default.isExecutableFile(atPath: p) { return p }
58 }
59 return nil
60 }
61
62 func status(for media: MediaItem) -> MediaStatus {
63 if let s = statuses[media.id] { return s }
64 var s = MediaStatus()
65 s.filmstripReady = FileManager.default.fileExists(atPath: stripInfoURL(media.cacheKey).path)
66 s.proxyReady = FileManager.default.fileExists(atPath: proxyFileURL(media.cacheKey).path)
67 statuses[media.id] = s
68 return s
69 }
70
71 // MARK: - Cache paths
72
73 /// The shape `cacheKey(for:)` produces: exactly 16 lowercase hex chars. A
74 /// key from another tool (or a hand-edited `.sq`) that doesn't match is not
75 /// trusted as a directory name.
76 static func isValidCacheKey(_ key: String) -> Bool {
77 key.count == 16 && key.allSatisfy { $0.isHexDigit && !$0.isUppercase }
78 }
79
80 /// Content-hash a seed string into a valid 16-hex cache key.
81 static func hashedKey(_ seed: String) -> String {
82 let digest = SHA256.hash(data: Data(seed.utf8))
83 return digest.map { String(format: "%02x", $0) }.joined().prefix(16).lowercased()
84 }
85
86 /// Defensive backstop: never let a blank or malformed key resolve to
87 /// `cacheRoot` itself or escape it via `/` or `..`. A garbage key is folded
88 /// to a stable hashed stand-in so its derived assets stay contained.
89 private func sanitizedKey(_ key: String) -> String {
90 Self.isValidCacheKey(key) ? key : Self.hashedKey("invalid|\(key)")
91 }
92
93 private func keyDir(_ key: String) -> URL {
94 cacheRoot.appendingPathComponent(sanitizedKey(key), isDirectory: true)
95 }
96 private func proxyFileURL(_ key: String) -> URL { keyDir(key).appendingPathComponent("proxy.mov") }
97 private func stripDir(_ key: String) -> URL { keyDir(key).appendingPathComponent("strip", isDirectory: true) }
98 private func stripInfoURL(_ key: String) -> URL { stripDir(key).appendingPathComponent("info.json") }
99
100 static func cacheKey(for url: URL) -> String {
101 let attrs = try? FileManager.default.attributesOfItem(atPath: url.path)
102 let size = (attrs?[.size] as? NSNumber)?.int64Value ?? 0
103 let mtime = (attrs?[.modificationDate] as? Date)?.timeIntervalSince1970 ?? 0
104 return Self.hashedKey("\(url.path)|\(size)|\(Int(mtime))")
105 }
106
107 /// A trustworthy cache key for a media item loaded from disk: keep a valid
108 /// one, otherwise recompute from the file (its content hash), or — when the
109 /// file is missing — fall back to a stable hash of its path so it still
110 /// can't collide with keyless siblings or escape the cache root.
111 static func normalizedCacheKey(for media: MediaItem) -> String {
112 if isValidCacheKey(media.cacheKey) { return media.cacheKey }
113 if FileManager.default.fileExists(atPath: media.path) {
114 return cacheKey(for: media.url)
115 }
116 return Self.hashedKey("path|\(media.path)")
117 }
118
119 /// Proxy URL if the proxy exists (touches LRU).
120 func proxyURL(for media: MediaItem) -> URL? {
121 let url = proxyFileURL(media.cacheKey)
122 guard FileManager.default.fileExists(atPath: url.path) else { return nil }
123 touchLRU(media.cacheKey)
124 return url
125 }
126
127 // MARK: - Import
128
129 /// Probe a file and kick off background filmstrip + proxy generation.
130 func importFile(_ url: URL, completion: @escaping (MediaItem?) -> Void) {
131 guard let ffprobe else {
132 DispatchQueue.main.async {
133 NotificationCenter.default.post(name: .transientStatus, object: nil,
134 userInfo: ["text": "ffprobe not found — install ffmpeg"])
135 completion(nil)
136 }
137 return
138 }
139 DispatchQueue.global(qos: .userInitiated).async {
140 let out = Self.run(ffprobe, ["-v", "quiet", "-print_format", "json",
141 "-show_format", "-show_streams", url.path]).stdout
142 guard let data = out.data(using: .utf8),
143 let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any],
144 let format = json["format"] as? [String: Any],
145 let streams = json["streams"] as? [[String: Any]],
146 let duration = Double((format["duration"] as? String) ?? "")
147 else {
148 DispatchQueue.main.async { completion(nil) }
149 return
150 }
151 var item = MediaItem(path: url.path)
152 item.duration = duration
153 item.cacheKey = Self.cacheKey(for: url)
154 item.hasAudio = streams.contains { ($0["codec_type"] as? String) == "audio" }
155 item.isAudio = item.hasAudio && !streams.contains {
156 ($0["codec_type"] as? String) == "video"
157 // Album art shows up as a video stream; ignore it.
158 && ($0["disposition"] as? [String: Any])?["attached_pic"] as? Int != 1
159 }
160 if let v = streams.first(where: { ($0["codec_type"] as? String) == "video" }) {
161 item.width = v["width"] as? Int ?? 0
162 item.height = v["height"] as? Int ?? 0
163 if let r = v["r_frame_rate"] as? String {
164 let parts = r.split(separator: "/").compactMap { Double($0) }
165 if parts.count == 2, parts[1] > 0 {
166 item.fps = min(120, max(1, parts[0] / parts[1]))
167 }
168 }
169 }
170 DispatchQueue.main.async {
171 completion(item)
172 self.enqueueDerivedAssets(for: item)
173 }
174 }
175 }
176
177 /// In-memory duration cache for the drop preview (path → seconds).
178 private var durationCache: [String: Double] = [:]
179
180 /// Cheap duration-only probe for the drag-and-drop landing preview. Runs
181 /// ffprobe off-main and memoizes by path; the completion fires on main.
182 /// Directories / sync.json manifests report nil.
183 func probeDuration(_ url: URL, completion: @escaping (Double?) -> Void) {
184 if let cached = durationCache[url.path] { completion(cached); return }
185 guard let ffprobe,
186 (try? url.resourceValues(forKeys: [.isDirectoryKey]).isDirectory) != true else {
187 completion(nil); return
188 }
189 DispatchQueue.global(qos: .userInitiated).async {
190 let out = Self.run(ffprobe, ["-v", "quiet", "-show_entries", "format=duration",
191 "-of", "csv=p=0", url.path]).stdout
192 let d = Double(out.trimmingCharacters(in: .whitespacesAndNewlines))
193 DispatchQueue.main.async {
194 if let d, d > 0 { self.durationCache[url.path] = d }
195 completion(d)
196 }
197 }
198 }
199
200 /// Ensure filmstrip/proxy jobs exist for every media in the project
201 /// (e.g. after opening a project on a machine with a cold cache).
202 func ensureDerivedAssets(for project: ProjectModel) {
203 for m in project.media { enqueueDerivedAssets(for: m) }
204 }
205
206 private var enqueued: Set<String> = []
207
208 func enqueueDerivedAssets(for media: MediaItem) {
209 guard ffmpeg != nil, !enqueued.contains(media.cacheKey) else { return }
210 enqueued.insert(media.cacheKey)
211 if media.isAudio {
212 if !FileManager.default.fileExists(atPath: waveformURL(media.cacheKey).path) {
213 workQueue.addOperation { self.generateWaveform(media) }
214 }
215 return
216 }
217 let s = status(for: media)
218 if !s.filmstripReady { workQueue.addOperation { self.generateFilmstrip(media) } }
219 // Proxies are chunked and demand-driven — see ChunkManager. Whole-file
220 // proxy.mov caches from earlier versions keep being used when present.
221 }
222
223 // MARK: - Waveforms (audio clips)
224
225 private func waveformURL(_ key: String) -> URL {
226 keyDir(key).appendingPathComponent("waveform.png")
227 }
228
229 private func generateWaveform(_ media: MediaItem) {
230 guard let ffmpeg else { return }
231 try? FileManager.default.createDirectory(at: keyDir(media.cacheKey),
232 withIntermediateDirectories: true)
233 let res = Self.run(ffmpeg, [
234 "-y", "-i", media.path,
235 "-filter_complex",
236 "aformat=channel_layouts=mono,showwavespic=s=2048x200:colors=white",
237 "-frames:v", "1", waveformURL(media.cacheKey).path,
238 ])
239 DispatchQueue.main.async {
240 self.touchLRU(media.cacheKey)
241 if res.exitCode == 0 {
242 NotificationCenter.default.post(name: .mediaStatusChanged, object: nil)
243 }
244 }
245 }
246
247 private let waveformCache = NSCache<NSString, NSImage>()
248
249 func waveformImage(for media: MediaItem) -> NSImage? {
250 let key = media.cacheKey as NSString
251 if let img = waveformCache.object(forKey: key) { return img }
252 let url = waveformURL(media.cacheKey)
253 DispatchQueue.global(qos: .utility).async {
254 guard let img = NSImage(contentsOf: url) else { return }
255 DispatchQueue.main.async {
256 self.waveformCache.setObject(img, forKey: key)
257 self.notifyThumbsCoalesced()
258 }
259 }
260 return nil
261 }
262
263 // MARK: - Jobs
264
265 private func generateFilmstrip(_ media: MediaItem) {
266 guard let ffmpeg else { return }
267 let dir = stripDir(media.cacheKey)
268 try? FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true)
269 // Target ~600 thumbs max so hour-long recordings stay cheap.
270 let interval = max(0.5, media.duration / 600)
271 let res = Self.run(ffmpeg, ["-y", "-hwaccel", "videotoolbox", "-i", media.path,
272 "-vf", "fps=1/\(interval),scale=240:-2",
273 "-q:v", "7", dir.appendingPathComponent("%06d.jpg").path])
274 let count = (try? FileManager.default.contentsOfDirectory(atPath: dir.path))?
275 .filter { $0.hasSuffix(".jpg") }.count ?? 0
276 if res.exitCode == 0, count > 0 {
277 let info: [String: Any] = ["interval": interval, "count": count]
278 if let d = try? JSONSerialization.data(withJSONObject: info) {
279 try? d.write(to: stripInfoURL(media.cacheKey))
280 }
281 }
282 DispatchQueue.main.async {
283 var s = self.status(for: media)
284 s.filmstripReady = res.exitCode == 0 && count > 0
285 self.statuses[media.id] = s
286 self.touchLRU(media.cacheKey)
287 NotificationCenter.default.post(name: .mediaStatusChanged, object: nil)
288 }
289 }
290
291 // MARK: - Filmstrip access
292
293 func filmstripInfo(_ key: String) -> (interval: Double, count: Int)? {
294 if let c = stripInfoCache[key] { return c }
295 guard let data = try? Data(contentsOf: stripInfoURL(key)),
296 let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any],
297 let interval = json["interval"] as? Double,
298 let count = json["count"] as? Int else { return nil }
299 stripInfoCache[key] = (interval, count)
300 return (interval, count)
301 }
302
303 /// Cached thumbnail nearest to `seconds`; loads async and posts a single
304 /// coalesced .mediaStatusChanged when thumbs land (a post per thumb
305 /// cascades into an app-wide refresh storm while a filmstrip streams in).
306 func filmstripImage(for media: MediaItem, at seconds: Double) -> NSImage? {
307 guard let info = filmstripInfo(media.cacheKey) else { return nil }
308 let index = min(info.count, max(1, Int(seconds / info.interval) + 1))
309 let cacheId = "\(media.cacheKey)/\(index)" as NSString
310 if let img = thumbCache.object(forKey: cacheId) { return img }
311 let url = stripDir(media.cacheKey).appendingPathComponent(String(format: "%06d.jpg", index))
312 DispatchQueue.global(qos: .utility).async {
313 guard let img = NSImage(contentsOf: url) else { return }
314 DispatchQueue.main.async {
315 self.thumbCache.setObject(img, forKey: cacheId)
316 self.notifyThumbsCoalesced()
317 }
318 }
319 return nil
320 }
321
322 private var thumbNotifyPending = false
323 private func notifyThumbsCoalesced() {
324 guard !thumbNotifyPending else { return }
325 thumbNotifyPending = true
326 DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) {
327 self.thumbNotifyPending = false
328 NotificationCenter.default.post(name: .mediaStatusChanged, object: nil)
329 }
330 }
331
332 // MARK: - LRU eviction
333
334 private func touchLRU(_ key: String) {
335 let now = Date()
336 if let last = lruTouched[key], now.timeIntervalSince(last) < 60 { return }
337 lruTouched[key] = now
338 let url = keyDir(key).appendingPathComponent("lastUsed")
339 try? "\(now.timeIntervalSince1970)".write(to: url, atomically: true, encoding: .utf8)
340 }
341
342 private var evicting = false
343
344 /// LRU eviction under the byte cap. Size scan and deletion run off-main
345 /// (the cache walk is I/O); never touches the current project's media.
346 func evictIfNeeded() {
347 guard !evicting else { return }
348 evicting = true
349 // Protect the media of every open document (not just the front one) so
350 // eviction can't drop cache another window is still using.
351 let inUse = Set(DocumentContext.allLive.flatMap { $0.store.project.media.map(\.cacheKey) })
352 let root = cacheRoot
353 let cap = maxCacheBytes
354 DispatchQueue.global(qos: .utility).async {
355 let fm = FileManager.default
356 var evicted: [String] = []
357 defer {
358 DispatchQueue.main.async {
359 for k in evicted {
360 self.stripInfoCache[k] = nil
361 self.enqueued.remove(k)
362 }
363 for c in DocumentContext.allLive { c.chunks.forget(keys: evicted) }
364 self.evicting = false
365 }
366 }
367 guard let keys = try? fm.contentsOfDirectory(atPath: root.path) else { return }
368 var entries: [(key: String, bytes: Int64, lastUsed: Double)] = []
369 var total: Int64 = 0
370 for key in keys {
371 let dir = root.appendingPathComponent(key, isDirectory: true)
372 var isDir: ObjCBool = false
373 guard fm.fileExists(atPath: dir.path, isDirectory: &isDir), isDir.boolValue else { continue }
374 let bytes = Self.directorySize(dir)
375 let lastUsed = Double((try? String(contentsOf: dir.appendingPathComponent("lastUsed"), encoding: .utf8)) ?? "") ?? 0
376 total += bytes
377 entries.append((key, bytes, lastUsed))
378 }
379 guard total > cap else { return }
380 for e in entries.sorted(by: { $0.lastUsed < $1.lastUsed }) where !inUse.contains(e.key) {
381 try? fm.removeItem(at: root.appendingPathComponent(e.key, isDirectory: true))
382 evicted.append(e.key)
383 total -= e.bytes
384 if total <= cap { break }
385 }
386 }
387 }
388
389 private static func directorySize(_ url: URL) -> Int64 {
390 var total: Int64 = 0
391 if let en = FileManager.default.enumerator(at: url, includingPropertiesForKeys: [.fileSizeKey]) {
392 for case let f as URL in en {
393 total += Int64((try? f.resourceValues(forKeys: [.fileSizeKey]).fileSize) ?? 0)
394 }
395 }
396 return total
397 }
398
399 // MARK: - Process helper
400
401 struct RunResult { var exitCode: Int32; var stdout: String }
402
403 @discardableResult
404 static func run(_ path: String, _ args: [String],
405 duration: Double? = nil,
406 progress: ((Double) -> Void)? = nil) -> RunResult {
407 let p = Process()
408 p.executableURL = URL(fileURLWithPath: path)
409 p.arguments = args
410 let outPipe = Pipe()
411 p.standardOutput = outPipe
412 p.standardError = Pipe() // discard; keeps ffmpeg from blocking on tty
413 var collected = Data()
414 let wantsProgress = progress != nil && duration != nil && duration! > 0
415 outPipe.fileHandleForReading.readabilityHandler = { h in
416 let chunk = h.availableData
417 if chunk.isEmpty { return }
418 collected.append(chunk)
419 if wantsProgress, let text = String(data: chunk, encoding: .utf8) {
420 for line in text.split(separator: "\n") {
421 if line.hasPrefix("out_time_us="), let us = Double(line.dropFirst("out_time_us=".count)) {
422 progress!(min(1, (us / 1_000_000) / duration!))
423 }
424 }
425 }
426 }
427 do {
428 try p.run()
429 p.waitUntilExit()
430 } catch {
431 return RunResult(exitCode: -1, stdout: "")
432 }
433 outPipe.fileHandleForReading.readabilityHandler = nil
434 if let rest = try? outPipe.fileHandleForReading.readToEnd() { collected.append(rest) }
435 return RunResult(exitCode: p.terminationStatus,
436 stdout: String(data: collected, encoding: .utf8) ?? "")
437 }
438}
sequencer/Sources/Sequencer/Model.swift created+726
...@@ -0,0 +1,726 @@
1import Foundation
2import CoreGraphics
3
4// All times are seconds (Double). Frames only appear at Fusion export and
5// timecode display, converted with the relevant fps.
6
7enum ClipKind: String, Codable {
8 case video, audio, storyboard
9}
10
11/// Which lane a clip lives on. Video/audio clips reference a video track by
12/// index (0-based; displayed as index+1). The storyboard lane and the Fusion
13/// comps band are singular special cases — there is only ever one of each, so
14/// they need no index. `.fusion` holds no clips; it exists so view state
15/// (hide/focus/priority) can key the Fusion band the same way as a track.
16enum TrackRef: Hashable, Codable {
17 case video(Int)
18 case storyboard
19 case fusion
20
21 /// Compact, human-readable wire form: "v0", "v1", "storyboard", "fusion".
22 var wire: String {
23 switch self {
24 case .video(let i): return "v\(i)"
25 case .storyboard: return "storyboard"
26 case .fusion: return "fusion"
27 }
28 }
29 init?(wire: String) {
30 switch wire {
31 case "storyboard": self = .storyboard
32 case "fusion": self = .fusion
33 default:
34 guard wire.hasPrefix("v"), let i = Int(wire.dropFirst()), i >= 0 else { return nil }
35 self = .video(i)
36 }
37 }
38 var videoIndex: Int? { if case .video(let i) = self { return i }; return nil }
39
40 init(from decoder: Decoder) throws {
41 let s = try decoder.singleValueContainer().decode(String.self)
42 guard let ref = TrackRef(wire: s) else {
43 throw DecodingError.dataCorrupted(.init(codingPath: decoder.codingPath,
44 debugDescription: "bad TrackRef \(s)"))
45 }
46 self = ref
47 }
48 func encode(to encoder: Encoder) throws {
49 var c = encoder.singleValueContainer()
50 try c.encode(wire)
51 }
52}
53
54/// A video/audio lane. In the numbered model a track is *just a hue* — its
55/// index in `ProjectModel.tracks` is its number, so there is no id/order to
56/// keep in sync. The storyboard lane isn't stored here (it's implied by the
57/// presence of `.storyboard` clips) and uses a fixed hue.
58struct Track: Codable, Equatable {
59 var hue: Double = 0 // 0..1
60
61 init(hue: Double = 0) { self.hue = hue }
62
63 enum CodingKeys: String, CodingKey { case hue }
64 init(from decoder: Decoder) throws {
65 let c = try decoder.container(keyedBy: CodingKeys.self)
66 hue = try c.decodeIfPresent(Double.self, forKey: .hue) ?? 0
67 }
68}
69
70// Legacy (v1) track kind — retained only so pre-numbering `.sq` files still
71// decode during migration. New projects don't store this.
72enum TrackKind: String, Codable {
73 case video
74 case storyboard
75}
76
77struct ProjectModel: Codable, Equatable {
78 var fps: Double = 30.0
79 var media: [MediaItem] = []
80 var tracks: [Track] = [] // video lanes; array index == track number
81 var clips: [Clip] = []
82 var markers: [Marker] = [] // blue timeline markers (labelled or not)
83 var compsFolder: String? = nil // Fusion .comp folder for the comps band
84 var preferredTakes: [String] = [] // comp filenames marked as preferred
85 // Resolution of NEW storyboard panels, stored as concrete pixels rather
86 // than a float aspect ratio (which round-trips lossily and compares badly).
87 var boardWidth: Int = 1920
88 var boardHeight: Int = 1080
89
90 /// Fixed hue for the (singular) storyboard lane.
91 static let storyboardHue = 0.13
92
93 /// Aspect ratio derived from the stored resolution, for display/layout.
94 var boardAspect: Double { Double(boardWidth) / Double(max(1, boardHeight)) }
95
96 init() {}
97
98 enum CodingKeys: String, CodingKey {
99 case fps, media, tracks, clips, markers, compsFolder, preferredTakes,
100 boardWidth, boardHeight
101 }
102 // Tolerant decoding so projects saved by older builds keep opening. This
103 // decodes the NEW (numbered) schema; legacy UUID-keyed files are migrated
104 // up front in `SequencerDocument`. Encoding is synthesized (all keys are
105 // stored properties — `boardAspect` is computed and never written).
106 init(from decoder: Decoder) throws {
107 let c = try decoder.container(keyedBy: CodingKeys.self)
108 fps = try c.decodeIfPresent(Double.self, forKey: .fps) ?? 30
109 media = try c.decodeIfPresent([MediaItem].self, forKey: .media) ?? []
110 tracks = try c.decodeIfPresent([Track].self, forKey: .tracks) ?? []
111 clips = try c.decodeIfPresent([Clip].self, forKey: .clips) ?? []
112 markers = try c.decodeIfPresent([Marker].self, forKey: .markers) ?? []
113 compsFolder = try c.decodeIfPresent(String.self, forKey: .compsFolder)
114 preferredTakes = try c.decodeIfPresent([String].self, forKey: .preferredTakes) ?? []
115 if let w = try c.decodeIfPresent(Int.self, forKey: .boardWidth), w > 0 { boardWidth = w }
116 if let h = try c.decodeIfPresent(Int.self, forKey: .boardHeight), h > 0 { boardHeight = h }
117 }
118
119 /// New storyboard boards use the project's storyboard resolution.
120 func newBoard() -> Board {
121 var b = Board()
122 b.width = Double(max(16, boardWidth))
123 b.height = Double(max(16, boardHeight))
124 return b
125 }
126}
127
128// MARK: - Document envelope
129
130/// Portable, non-undoable view state saved alongside the model so a project
131/// opens looking the way it was left, on any machine. Deliberately kept OUT of
132/// `ProjectModel` so undo/redo never toggles visibility or zoom.
133struct TrackHeight: Codable, Equatable {
134 var track: TrackRef
135 var factor: Double
136}
137
138struct ViewState: Codable, Equatable {
139 var hiddenTracks: [TrackRef] = []
140 var focusedTracks: [TrackRef] = []
141 var trackHeights: [TrackHeight] = []
142 var laneScale: Double = 1
143 var snapping = true
144 var showFilmstrips = true
145 var previewsOnLeft = false
146 var priorityPane: TrackRef? = nil
147 var fusionHidden = false
148 var fusionFocus = false
149
150 init() {}
151
152 enum CodingKeys: String, CodingKey {
153 case hiddenTracks, focusedTracks, trackHeights, laneScale, snapping,
154 showFilmstrips, previewsOnLeft, priorityPane, fusionHidden, fusionFocus
155 }
156 init(from decoder: Decoder) throws {
157 let c = try decoder.container(keyedBy: CodingKeys.self)
158 hiddenTracks = try c.decodeIfPresent([TrackRef].self, forKey: .hiddenTracks) ?? []
159 focusedTracks = try c.decodeIfPresent([TrackRef].self, forKey: .focusedTracks) ?? []
160 trackHeights = try c.decodeIfPresent([TrackHeight].self, forKey: .trackHeights) ?? []
161 laneScale = try c.decodeIfPresent(Double.self, forKey: .laneScale) ?? 1
162 snapping = try c.decodeIfPresent(Bool.self, forKey: .snapping) ?? true
163 showFilmstrips = try c.decodeIfPresent(Bool.self, forKey: .showFilmstrips) ?? true
164 previewsOnLeft = try c.decodeIfPresent(Bool.self, forKey: .previewsOnLeft) ?? false
165 priorityPane = try c.decodeIfPresent(TrackRef.self, forKey: .priorityPane)
166 fusionHidden = try c.decodeIfPresent(Bool.self, forKey: .fusionHidden) ?? false
167 fusionFocus = try c.decodeIfPresent(Bool.self, forKey: .fusionFocus) ?? false
168 }
169}
170
171/// The on-disk `.sq` envelope: a versioned wrapper around the undoable model
172/// plus portable view state. Reads both this shape and the legacy bare
173/// `ProjectModel` (v1, no envelope) so old files keep opening.
174struct SequencerDocument: Codable {
175 var formatVersion: Int = 2
176 var project: ProjectModel
177 var view: ViewState
178
179 init(project: ProjectModel, view: ViewState) {
180 self.project = project
181 self.view = view
182 }
183
184 enum CodingKeys: String, CodingKey { case formatVersion, project, view }
185 init(from decoder: Decoder) throws {
186 let c = try decoder.container(keyedBy: CodingKeys.self)
187 if c.contains(.project) {
188 // v2 envelope.
189 formatVersion = try c.decodeIfPresent(Int.self, forKey: .formatVersion) ?? 2
190 project = try c.decode(ProjectModel.self, forKey: .project)
191 view = try c.decodeIfPresent(ViewState.self, forKey: .view) ?? ViewState()
192 } else {
193 // v1: a bare ProjectModel with UUID-keyed tracks at the top level.
194 let legacy = try LegacyProject(from: decoder)
195 formatVersion = 2
196 project = ProjectModel(legacy: legacy)
197 view = ViewState()
198 }
199 }
200 func encode(to encoder: Encoder) throws {
201 var c = encoder.container(keyedBy: CodingKeys.self)
202 try c.encode(formatVersion, forKey: .formatVersion)
203 try c.encode(project, forKey: .project)
204 try c.encode(view, forKey: .view)
205 }
206}
207
208// MARK: - Legacy (v1) migration
209
210/// Mirrors the pre-numbering top-level `.sq` shape just enough to migrate it.
211/// `MediaItem`, `Marker`, and `Board` are unchanged, so they reuse their own
212/// tolerant decoders.
213private struct LegacyProject: Decodable {
214 var fps: Double
215 var media: [MediaItem]
216 var tracks: [LegacyTrack]
217 var clips: [LegacyClip]
218 var markers: [Marker]
219 var compsFolder: String?
220 var preferredTakes: [String]
221 var boardAspect: Double
222
223 enum CodingKeys: String, CodingKey {
224 case fps, media, tracks, clips, markers, compsFolder, preferredTakes, boardAspect
225 }
226 init(from decoder: Decoder) throws {
227 let c = try decoder.container(keyedBy: CodingKeys.self)
228 fps = try c.decodeIfPresent(Double.self, forKey: .fps) ?? 30
229 media = try c.decodeIfPresent([MediaItem].self, forKey: .media) ?? []
230 tracks = try c.decodeIfPresent([LegacyTrack].self, forKey: .tracks) ?? []
231 clips = try c.decodeIfPresent([LegacyClip].self, forKey: .clips) ?? []
232 markers = try c.decodeIfPresent([Marker].self, forKey: .markers) ?? []
233 compsFolder = try c.decodeIfPresent(String.self, forKey: .compsFolder)
234 preferredTakes = try c.decodeIfPresent([String].self, forKey: .preferredTakes) ?? []
235 boardAspect = try c.decodeIfPresent(Double.self, forKey: .boardAspect) ?? 16.0 / 9.0
236 }
237}
238
239private struct LegacyTrack: Decodable {
240 var id: UUID
241 var order: Int
242 var hue: Double
243 var kind: TrackKind
244 enum CodingKeys: String, CodingKey { case id, order, hue, kind }
245 init(from decoder: Decoder) throws {
246 let c = try decoder.container(keyedBy: CodingKeys.self)
247 id = try c.decodeIfPresent(UUID.self, forKey: .id) ?? UUID()
248 order = try c.decodeIfPresent(Int.self, forKey: .order) ?? 0
249 hue = try c.decodeIfPresent(Double.self, forKey: .hue) ?? 0
250 kind = try c.decodeIfPresent(TrackKind.self, forKey: .kind) ?? .video
251 }
252}
253
254private struct LegacyClip: Decodable {
255 var id: UUID
256 var kind: ClipKind
257 var mediaId: UUID?
258 var trackId: UUID
259 var start: Double
260 var srcIn: Double
261 var duration: Double
262 var speed: Double
263 var muted: Bool
264 var fadeIn: Double
265 var fadeOut: Double
266 var linkId: UUID?
267 var board: Board?
268 var newShot: Bool
269 enum CodingKeys: String, CodingKey {
270 case id, kind, mediaId, trackId, start, srcIn, duration, speed,
271 muted, fadeIn, fadeOut, linkId, board, newShot
272 }
273 init(from decoder: Decoder) throws {
274 let c = try decoder.container(keyedBy: CodingKeys.self)
275 id = try c.decodeIfPresent(UUID.self, forKey: .id) ?? UUID()
276 kind = try c.decodeIfPresent(ClipKind.self, forKey: .kind) ?? .video
277 mediaId = try c.decodeIfPresent(UUID.self, forKey: .mediaId)
278 trackId = try c.decodeIfPresent(UUID.self, forKey: .trackId) ?? UUID()
279 start = try c.decodeIfPresent(Double.self, forKey: .start) ?? 0
280 srcIn = try c.decodeIfPresent(Double.self, forKey: .srcIn) ?? 0
281 duration = try c.decodeIfPresent(Double.self, forKey: .duration) ?? 1
282 speed = try c.decodeIfPresent(Double.self, forKey: .speed) ?? 1
283 muted = try c.decodeIfPresent(Bool.self, forKey: .muted) ?? false
284 fadeIn = try c.decodeIfPresent(Double.self, forKey: .fadeIn) ?? 0
285 fadeOut = try c.decodeIfPresent(Double.self, forKey: .fadeOut) ?? 0
286 linkId = try c.decodeIfPresent(UUID.self, forKey: .linkId)
287 board = try c.decodeIfPresent(Board.self, forKey: .board)
288 newShot = try c.decodeIfPresent(Bool.self, forKey: .newShot) ?? false
289 }
290}
291
292extension ProjectModel {
293 /// Migrate a v1 (UUID-keyed) project into the numbered model: video tracks
294 /// sorted by their old `order` become indices 0…N; each clip's `trackId`
295 /// resolves to `.video(i)` or `.storyboard`; the float aspect becomes a
296 /// concrete resolution.
297 fileprivate init(legacy: LegacyProject) {
298 self.init()
299 fps = legacy.fps
300 media = legacy.media
301 markers = legacy.markers
302 compsFolder = legacy.compsFolder
303 preferredTakes = legacy.preferredTakes
304 boardHeight = 1080
305 boardWidth = Int((1080.0 * max(0.2, legacy.boardAspect)).rounded())
306
307 let videoTracks = legacy.tracks.filter { $0.kind == .video }
308 .sorted { $0.order < $1.order }
309 var refByUUID: [UUID: TrackRef] = [:]
310 for (i, t) in videoTracks.enumerated() { refByUUID[t.id] = .video(i) }
311 for t in legacy.tracks where t.kind == .storyboard { refByUUID[t.id] = .storyboard }
312 tracks = videoTracks.map { Track(hue: $0.hue) }
313
314 clips = legacy.clips.map { lc in
315 // Storyboard panels are pinned to the storyboard lane regardless of
316 // whatever track they referenced.
317 let ref: TrackRef = lc.kind == .storyboard
318 ? .storyboard
319 : (refByUUID[lc.trackId] ?? .video(0))
320 var c = Clip(mediaId: lc.mediaId, track: ref, start: lc.start, srcIn: lc.srcIn,
321 duration: lc.duration, kind: lc.kind, linkId: lc.linkId, board: lc.board)
322 c.id = lc.id
323 c.speed = lc.speed
324 c.muted = lc.muted
325 c.fadeIn = lc.fadeIn
326 c.fadeOut = lc.fadeOut
327 c.newShot = lc.newShot
328 return c
329 }
330 }
331}
332
333struct MediaItem: Codable, Equatable, Identifiable {
334 var id: UUID = UUID()
335 var path: String
336 var duration: Double = 0
337 var fps: Double = 30
338 var width: Int = 0
339 var height: Int = 0
340 var hasAudio: Bool = false
341 var isAudio: Bool = false // audio-only file (no video stream)
342 var cacheKey: String = ""
343
344 var url: URL { URL(fileURLWithPath: path) }
345 var displayName: String { url.lastPathComponent }
346
347 init(path: String) { self.path = path }
348
349 enum CodingKeys: String, CodingKey {
350 case id, path, duration, fps, width, height, hasAudio, isAudio, cacheKey
351 }
352 init(from decoder: Decoder) throws {
353 let c = try decoder.container(keyedBy: CodingKeys.self)
354 id = try c.decodeIfPresent(UUID.self, forKey: .id) ?? UUID()
355 path = try c.decode(String.self, forKey: .path)
356 duration = try c.decodeIfPresent(Double.self, forKey: .duration) ?? 0
357 fps = try c.decodeIfPresent(Double.self, forKey: .fps) ?? 30
358 width = try c.decodeIfPresent(Int.self, forKey: .width) ?? 0
359 height = try c.decodeIfPresent(Int.self, forKey: .height) ?? 0
360 hasAudio = try c.decodeIfPresent(Bool.self, forKey: .hasAudio) ?? false
361 isAudio = try c.decodeIfPresent(Bool.self, forKey: .isAudio) ?? false
362 cacheKey = try c.decodeIfPresent(String.self, forKey: .cacheKey) ?? ""
363 }
364}
365
366// MARK: - Storyboard boards
367
368/// One vector object on a board's shape layer.
369struct BoardShape: Codable, Equatable, Identifiable {
370 enum Kind: String, Codable {
371 case rect, oval, triangle, star, ngon, text, image
372 }
373 var id: UUID = UUID()
374 var kind: Kind
375 var frame: CGRect
376 var color: [Double] = [0, 0, 0, 1] // rgba 0..1
377 var text: String = ""
378 var fontSize: Double = 48
379 var sides: Int = 5 // star points / ngon sides
380 var imagePath: String? = nil // image-reference shapes
381 var filled: Bool = true
382 var aboveRaster: Bool = false // "bring to top" puts it over the drawing layer
383}
384
385/// A storyboard panel: a shape (vector) layer that renders below a raster
386/// (drawing) layer. The raster lives on disk keyed by `id`; `revision` bumps
387/// whenever either layer changes so composite caches invalidate.
388struct Board: Codable, Equatable {
389 var id: UUID = UUID()
390 var revision: Int = 0
391 var shapes: [BoardShape] = []
392 var width: Double = 1600
393 var height: Double = 900
394 var size: CGSize { CGSize(width: width, height: height) }
395}
396
397struct Clip: Codable, Equatable, Identifiable {
398 var id: UUID = UUID()
399 var kind: ClipKind = .video
400 var mediaId: UUID? // nil for storyboard panels
401 var track: TrackRef // which lane this clip lives on
402 var start: Double // timeline seconds
403 var srcIn: Double // source seconds
404 var duration: Double // timeline seconds
405 var speed: Double = 1.0 // source seconds consumed per timeline second
406 var muted: Bool = false
407 var fadeIn: Double = 0 // audio fade lengths, timeline seconds
408 var fadeOut: Double = 0
409 var linkId: UUID? // clips sharing a linkId move/blade together (multicam)
410 var board: Board? // storyboard panel content
411 var newShot: Bool = false // storyboard: this panel starts a new shot number
412
413 var end: Double { start + duration }
414 /// Source seconds this clip consumes (constant under time stretch).
415 var sourceLength: Double { duration * speed }
416 var srcOut: Double { srcIn + sourceLength }
417
418 /// Source time for a timeline moment inside the clip.
419 func sourceTime(at t: Double) -> Double { srcIn + (t - start) * speed }
420
421 init(mediaId: UUID?, track: TrackRef, start: Double, srcIn: Double,
422 duration: Double, kind: ClipKind = .video, linkId: UUID? = nil,
423 board: Board? = nil) {
424 self.mediaId = mediaId
425 self.track = track
426 self.start = start
427 self.srcIn = srcIn
428 self.duration = duration
429 self.kind = kind
430 self.linkId = linkId
431 self.board = board
432 }
433
434 enum CodingKeys: String, CodingKey {
435 case id, kind, mediaId, track, start, srcIn, duration, speed,
436 muted, fadeIn, fadeOut, linkId, board, newShot
437 }
438 init(from decoder: Decoder) throws {
439 let c = try decoder.container(keyedBy: CodingKeys.self)
440 id = try c.decodeIfPresent(UUID.self, forKey: .id) ?? UUID()
441 kind = try c.decodeIfPresent(ClipKind.self, forKey: .kind) ?? .video
442 mediaId = try c.decodeIfPresent(UUID.self, forKey: .mediaId)
443 track = try c.decodeIfPresent(TrackRef.self, forKey: .track)
444 ?? (kind == .storyboard ? .storyboard : .video(0))
445 start = try c.decodeIfPresent(Double.self, forKey: .start) ?? 0
446 srcIn = try c.decodeIfPresent(Double.self, forKey: .srcIn) ?? 0
447 duration = try c.decodeIfPresent(Double.self, forKey: .duration) ?? 1
448 speed = try c.decodeIfPresent(Double.self, forKey: .speed) ?? 1
449 muted = try c.decodeIfPresent(Bool.self, forKey: .muted) ?? false
450 fadeIn = try c.decodeIfPresent(Double.self, forKey: .fadeIn) ?? 0
451 fadeOut = try c.decodeIfPresent(Double.self, forKey: .fadeOut) ?? 0
452 linkId = try c.decodeIfPresent(UUID.self, forKey: .linkId)
453 board = try c.decodeIfPresent(Board.self, forKey: .board)
454 newShot = try c.decodeIfPresent(Bool.self, forKey: .newShot) ?? false
455 }
456}
457
458/// A timeline marker: a point in time the user parks a blue playhead on, with
459/// an optional short label. Independent of clips/tracks — it lives on the
460/// timeline itself.
461struct Marker: Codable, Equatable, Identifiable {
462 var id: UUID = UUID()
463 var time: Double // timeline seconds
464 var label: String = ""
465
466 init(id: UUID = UUID(), time: Double, label: String = "") {
467 self.id = id
468 self.time = time
469 self.label = label
470 }
471
472 enum CodingKeys: String, CodingKey { case id, time, label }
473 init(from decoder: Decoder) throws {
474 let c = try decoder.container(keyedBy: CodingKeys.self)
475 id = try c.decodeIfPresent(UUID.self, forKey: .id) ?? UUID()
476 time = try c.decodeIfPresent(Double.self, forKey: .time) ?? 0
477 label = try c.decodeIfPresent(String.self, forKey: .label) ?? ""
478 }
479}
480
481/// A same-track overlap between two non-audio clips (audio is allowed to
482/// layer). `a` starts no later than `b`; the red range is [start, end).
483struct ClipOverlap: Equatable {
484 var a: Clip
485 var b: Clip
486 var track: TrackRef
487 var start: Double
488 var end: Double
489}
490
491extension ProjectModel {
492 func media(_ id: UUID?) -> MediaItem? {
493 guard let id else { return nil }
494 return media.first { $0.id == id }
495 }
496 func clip(_ id: UUID) -> Clip? { clips.first { $0.id == id } }
497
498 /// Hue for any lane, including the fixed storyboard hue.
499 func hue(for ref: TrackRef) -> Double {
500 switch ref {
501 case .video(let i): return tracks.indices.contains(i) ? tracks[i].hue : 0
502 case .storyboard: return Self.storyboardHue
503 case .fusion: return 0
504 }
505 }
506
507 var sortedMarkers: [Marker] { markers.sorted { $0.time < $1.time } }
508 /// Nearest marker strictly after `t` (for → navigation).
509 func nextMarker(after t: Double) -> Marker? {
510 sortedMarkers.first { $0.time > t + 1e-6 }
511 }
512 /// Nearest marker strictly before `t` (for ← navigation).
513 func prevMarker(before t: Double) -> Marker? {
514 sortedMarkers.last { $0.time < t - 1e-6 }
515 }
516
517 /// True when any storyboard panel exists (the storyboard lane is implied,
518 /// not stored in `tracks`).
519 var hasStoryboard: Bool { clips.contains { $0.kind == .storyboard } }
520
521 /// The lanes shown in the timeline/viewer, top to bottom: the storyboard
522 /// lane (if any panels exist) is pinned on top, then video tracks in index
523 /// order. Views index into this to map a row to a `TrackRef`.
524 var laneRefs: [TrackRef] {
525 var rows: [TrackRef] = []
526 if hasStoryboard { rows.append(.storyboard) }
527 for i in tracks.indices { rows.append(.video(i)) }
528 return rows
529 }
530 /// Row index of a lane in `laneRefs`, or nil if it isn't shown.
531 func row(of ref: TrackRef) -> Int? { laneRefs.firstIndex(of: ref) }
532
533 func clips(on ref: TrackRef) -> [Clip] {
534 clips.filter { $0.track == ref }.sorted { $0.start < $1.start }
535 }
536 /// Clips on video track `index`.
537 func clips(onVideo index: Int) -> [Clip] { clips(on: .video(index)) }
538
539 /// Expand a set of clip ids with all their link-mates.
540 func expandLinks(_ ids: Set<UUID>) -> Set<UUID> {
541 let linkIds = Set(clips.filter { ids.contains($0.id) }.compactMap(\.linkId))
542 guard !linkIds.isEmpty else { return ids }
543 return ids.union(clips.filter { $0.linkId.map(linkIds.contains) ?? false }.map(\.id))
544 }
545
546 /// Clip under a timeline moment on a lane. When clips overlap, the one
547 /// that starts latest wins (the "most recent cut").
548 func clipAt(track ref: TrackRef, time: Double, kind: ClipKind? = nil) -> Clip? {
549 clips
550 .filter {
551 $0.track == ref && time >= $0.start && time < $0.end
552 && (kind == nil || $0.kind == kind)
553 }
554 .max { $0.start < $1.start }
555 }
556
557 /// End of the meaningful content. Storyboard panels are start-only (the
558 /// last one extends "forever"), so only their starts count here.
559 var timelineDuration: Double {
560 let solid = clips.filter { $0.kind != .storyboard }.map(\.end).max() ?? 0
561 let lastPanel = clips.filter { $0.kind == .storyboard }.map(\.start).max()
562 .map { $0 + 5 } ?? 0
563 return max(solid, lastPanel)
564 }
565
566 /// Same-track overlaps between VIDEO clips (audio layers freely, and
567 /// storyboard panels are start-only points that can't overlap).
568 /// Overlap is the editing error the red highlight surfaces.
569 func overlaps(on ref: TrackRef? = nil) -> [ClipOverlap] {
570 var out: [ClipOverlap] = []
571 let grouped = Dictionary(grouping: clips.filter { $0.kind == .video },
572 by: \.track)
573 for (tref, arr) in grouped {
574 if let ref, tref != ref { continue }
575 let sorted = arr.sorted { ($0.start, $0.id.uuidString) < ($1.start, $1.id.uuidString) }
576 for i in 0..<sorted.count {
577 for j in (i + 1)..<sorted.count {
578 let a = sorted[i], b = sorted[j]
579 if b.start < a.end - 1e-9 {
580 out.append(ClipOverlap(a: a, b: b, track: tref,
581 start: b.start, end: min(a.end, b.end)))
582 }
583 }
584 }
585 }
586 return out.sorted { $0.start < $1.start }
587 }
588
589 /// Deterministic, well-spread default hue for the track at a given index.
590 /// Golden-ratio spacing keeps neighbouring lanes visibly distinct while
591 /// staying stable across sessions (no more random colors on every add).
592 static func defaultHue(forIndex index: Int) -> Double {
593 let h = (Double(index) * 0.6180339887498949).truncatingRemainder(dividingBy: 1)
594 return h < 0 ? h + 1 : h
595 }
596
597 /// Append a new video track; returns its index.
598 @discardableResult
599 mutating func addTrack() -> Int {
600 let i = tracks.count
601 tracks.append(Track(hue: Self.defaultHue(forIndex: i)))
602 return i
603 }
604
605 /// Remove video track `index`, renumbering the clips above it down by one
606 /// (numbered tracks means a delete shifts every higher lane — the work
607 /// UUIDs used to make free). Callers ensure the track is empty first.
608 mutating func removeTrack(at index: Int) {
609 guard tracks.indices.contains(index) else { return }
610 tracks.remove(at: index)
611 for i in clips.indices {
612 if case .video(let n) = clips[i].track, n > index {
613 clips[i].track = .video(n - 1)
614 }
615 }
616 }
617
618 /// Storyboard panels in canonical (stored) order — the same ordering
619 /// `normalizeStoryboards` uses, so a panel's index here is stable across
620 /// saves. Drives the on-disk raster filenames (`Storyboard/NN.png`).
621 func orderedStoryboardPanels() -> [Clip] {
622 clips
623 .filter { $0.kind == .storyboard }
624 .sorted { ($0.start, $0.id.uuidString) < ($1.start, $1.id.uuidString) }
625 }
626
627 /// Storyboard panels are start-only: each one lasts until the next panel
628 /// starts, and the last one extends past everything else ("forever").
629 /// Called after every mutation so stored durations always agree.
630 mutating func normalizeStoryboards() {
631 let fd = 1.0 / max(1, fps)
632 var panels = clips
633 .filter { $0.kind == .storyboard }
634 .sorted { ($0.start, $0.id.uuidString) < ($1.start, $1.id.uuidString) }
635 guard !panels.isEmpty else { return }
636 // The storyboard sequence is anchored to the very start: the first
637 // panel always begins at 0:00.
638 if panels[0].start != 0 {
639 if let idx0 = clips.firstIndex(where: { $0.id == panels[0].id }) {
640 clips[idx0].start = 0
641 }
642 panels[0].start = 0
643 }
644 let solidEnd = clips.filter { $0.kind != .storyboard }.map(\.end).max() ?? 0
645 for (i, p) in panels.enumerated() {
646 guard let idx = clips.firstIndex(where: { $0.id == p.id }) else { continue }
647 let end = i + 1 < panels.count
648 ? panels[i + 1].start
649 : max(solidEnd + 5, p.start + 10)
650 clips[idx].duration = max(fd, end - p.start)
651 clips[idx].srcIn = 0
652 clips[idx].track = .storyboard
653 }
654 }
655
656 /// Automatic panel names: "1A, 1B, 2A, …" — the number is the shot, the
657 /// letter is the frame within it. Panels are gapless, so a new shot is
658 /// purely metadata: the newShot flag (set by N-split or the context menu).
659 func panelNames() -> [UUID: String] {
660 let panels = orderedStoryboardPanels()
661 guard !panels.isEmpty else { return [:] }
662 var names: [UUID: String] = [:]
663 var shot = 0
664 var frame = 0
665 for p in panels {
666 if shot == 0 || p.newShot {
667 shot += 1
668 frame = 0
669 }
670 var letters = ""
671 var n = frame
672 repeat {
673 letters = String(UnicodeScalar(65 + n % 26)!) + letters
674 n = n / 26 - 1
675 } while n >= 0
676 names[p.id] = "\(shot)\(letters)"
677 frame += 1
678 }
679 return names
680 }
681
682 /// Drop every empty track (keeping at least one). Only invoked by the
683 /// explicit "Delete Empty Tracks" action — empty tracks are allowed.
684 mutating func pruneEmptyTracks(keepAtLeast: Int = 1) {
685 var removable = max(0, tracks.count - keepAtLeast)
686 // Remove high-to-low so earlier indices stay valid across removals.
687 for idx in tracks.indices.reversed()
688 where removable > 0 && tracks.count > keepAtLeast && clips(onVideo: idx).isEmpty {
689 removeTrack(at: idx)
690 removable -= 1
691 }
692 }
693
694 /// Trailing empty tracks (the ones BELOW the last track holding any clip)
695 /// are ephemeral drop targets: drop something and the lane becomes real;
696 /// move it away and the lane disappears again. Called after finalized
697 /// mutations that can empty a bottom track (drags, deletes). Interior empty
698 /// tracks are left alone, and at least one video track always survives — so
699 /// dragging a clip down two rows still makes two tracks (the middle one is
700 /// interior).
701 mutating func pruneTrailingEmptyTracks() {
702 guard let lastUsed = tracks.indices.last(where: { !clips(onVideo: $0).isEmpty })
703 else { return } // nothing placed yet — leave the lanes alone
704 var idx = tracks.count - 1
705 while idx > lastUsed && tracks.count > 1 {
706 if clips(onVideo: idx).isEmpty { removeTrack(at: idx) }
707 idx -= 1
708 }
709 }
710}
711
712/// Fade envelope gain for an audio clip at a timeline moment, 0..1.
713func audioGain(_ clip: Clip, at t: Double) -> Double {
714 guard t >= clip.start, t < clip.end else { return 0 }
715 var g = 1.0
716 if clip.fadeIn > 0.001 { g = min(g, (t - clip.start) / clip.fadeIn) }
717 if clip.fadeOut > 0.001 { g = min(g, (clip.end - t) / clip.fadeOut) }
718 return max(0, min(1, g))
719}
720
721func timecodeString(frame: Int, fps: Double) -> String {
722 let fpsI = max(1, Int(fps.rounded()))
723 let total = frame / fpsI
724 return String(format: "%02d:%02d:%02d:%02d",
725 total / 3600, (total / 60) % 60, total % 60, frame % fpsI)
726}
sequencer/Sources/Sequencer/PlaybackController.swift created+431
...@@ -0,0 +1,431 @@
1import Foundation
2import AVFoundation
3import QuartzCore
4
5/// Master timeline clock. Playhead is derived from a host-time anchor while
6/// playing, so all track players chase one authoritative time.
7final class PlaybackController {
8 /// The document context that owns this controller. Set at construction.
9 unowned var ctx: DocumentContext!
10
11 private(set) var rate: Double = 0
12 /// Last non-zero rate we played at, so Space (play/pause) resumes at the
13 /// speed you left off — including a J/K/L shuttle speed. J/L themselves
14 /// ignore this and always start from ±1x.
15 private var lastRate: Double = 1
16 private var anchorHost: Double = 0
17 private var anchorTime: Double = 0
18 private var pausedPlayhead: Double = 0
19 private var timer: Timer?
20
21 /// Loop (cycle) range. Non-undoable session state, so it lives here rather
22 /// than in the model. When `loops` is on, playback wraps within
23 /// `[inPoint ?? 0, outPoint ?? timelineDuration]`.
24 private(set) var inPoint: Double?
25 private(set) var outPoint: Double?
26 private(set) var loops = false
27
28 var isPlaying: Bool { rate != 0 }
29
30 var playhead: Double {
31 guard rate != 0 else { return pausedPlayhead }
32 return max(0, anchorTime + (CACurrentMediaTime() - anchorHost) * rate)
33 }
34
35 func start() {
36 let t = Timer(timeInterval: 1.0 / 60.0, repeats: true) { [weak self] _ in self?.tick() }
37 RunLoop.main.add(t, forMode: .common)
38 timer = t
39 }
40
41 /// Stop the clock when the document closes — otherwise a closed window's
42 /// 60 Hz timer keeps running for the app's lifetime.
43 deinit { timer?.invalidate() }
44
45 private func tick() {
46 // While paused nothing moves; edits and proxy completions push their
47 // own syncs, so idle costs nothing.
48 guard rate != 0 else { return }
49 if loops {
50 enforceLoop()
51 } else if rate < 0, playhead <= 0 {
52 setRate(0); seek(to: 0)
53 }
54 ctx.notify.post(name: .playheadChanged, object: nil)
55 ctx.players.sync()
56 }
57
58 /// Wrap the playhead back into the cycle range when it runs off the far
59 /// end (forward past out, or reverse before in). The seek re-anchors but
60 /// leaves `rate` untouched, so playback keeps rolling from the wrap point.
61 private func enforceLoop() {
62 let lo = inPoint ?? 0
63 let hi = outPoint ?? ctx.store.project.timelineDuration
64 guard hi > lo else { return }
65 if rate > 0, playhead >= hi { seek(to: lo) }
66 else if rate < 0, playhead <= lo { seek(to: hi) }
67 }
68
69 func setRate(_ newRate: Double) {
70 let now = playhead
71 rate = newRate
72 if newRate == 0 {
73 pausedPlayhead = now
74 } else {
75 lastRate = newRate
76 anchorHost = CACurrentMediaTime()
77 anchorTime = now
78 ctx.chunks.playbackDidStart()
79 }
80 ctx.notify.post(name: .playheadChanged, object: nil)
81 ctx.players.sync(force: true)
82 }
83
84 func togglePlay() { setRate(isPlaying ? 0 : lastRate) }
85
86 /// J/K/L: each press in the moving direction doubles the rate (capped at
87 /// 64x); pressing the opposite direction halves it until it stops.
88 func shuttle(_ direction: Double) {
89 if direction == 0 { setRate(0); return }
90 if rate == 0 {
91 setRate(direction)
92 } else if rate.sign == direction.sign {
93 setRate(max(-64, min(64, rate * 2)))
94 } else {
95 let slowed = rate / 2
96 setRate(abs(slowed) < 1 ? 0 : slowed)
97 }
98 }
99
100 func seek(to time: Double) {
101 let t = max(0, time)
102 pausedPlayhead = t
103 anchorHost = CACurrentMediaTime()
104 anchorTime = t
105 ctx.notify.post(name: .playheadChanged, object: nil)
106 ctx.players.sync(force: true)
107 }
108
109 func step(by seconds: Double) {
110 setRate(0)
111 seek(to: playhead + seconds)
112 }
113
114 // MARK: - Loop / cycle range (I / O / C)
115
116 /// Mark the in point at the playhead. A collapsed range (out ≤ in) drops
117 /// the stale out point. Pressing In again at the same spot clears it.
118 func setIn() {
119 let t = playhead
120 if let i = inPoint, abs(i - t) < 0.5 / max(1, ctx.store.project.fps) {
121 inPoint = nil
122 inOutChanged("Cleared in point")
123 return
124 }
125 inPoint = t
126 if let o = outPoint, o <= t { outPoint = nil }
127 inOutChanged("In point \(timecode(t))")
128 }
129
130 /// Mark the out point at the playhead, dropping a now-stale in point.
131 /// Pressing Out again at the same spot clears it.
132 func setOut() {
133 let t = playhead
134 if let o = outPoint, abs(o - t) < 0.5 / max(1, ctx.store.project.fps) {
135 outPoint = nil
136 inOutChanged("Cleared out point")
137 return
138 }
139 outPoint = t
140 if let i = inPoint, i >= t { inPoint = nil }
141 inOutChanged("Out point \(timecode(t))")
142 }
143
144 func clearInOut() {
145 inPoint = nil
146 outPoint = nil
147 inOutChanged("Cleared in / out")
148 }
149
150 func toggleLoop() {
151 loops.toggle()
152 inOutChanged(loops ? "Loop on" : "Loop off")
153 }
154
155 var hasInOut: Bool { inPoint != nil || outPoint != nil }
156
157 private func inOutChanged(_ status: String) {
158 // Redraws the timeline (it observes .playheadChanged) without moving
159 // the playhead, and shows a brief HUD note.
160 ctx.notify.post(name: .playheadChanged, object: nil)
161 NotificationCenter.default.post(name: .transientStatus, object: nil,
162 userInfo: ["text": status])
163 }
164
165 private func timecode(_ t: Double) -> String {
166 let fps = ctx.store.project.fps
167 let total = Int((t * fps).rounded())
168 let f = total % Int(fps.rounded())
169 let s = Int(t) % 60
170 let m = Int(t) / 60
171 return String(format: "%02d:%02d:%02d", m, s, f)
172 }
173}
174
175/// One AVPlayer per track, kept in sync with the master clock. Plays the
176/// proxy when it exists, falls back to the original file otherwise.
177final class TrackPlayer {
178 /// The document context (set by PlayerManager when the player is created).
179 unowned var ctx: DocumentContext!
180 let player = AVPlayer()
181 var currentClipId: UUID?
182 var currentSourceURL: URL?
183 /// Audio players set this: audible glitches from hard resyncs are much
184 /// worse than a few frames of drift, so they get wide thresholds and a
185 /// deep buffer (their originals live on the NAS).
186 var lenientSync = false
187 private var currentChunkVersion = -1
188 private var lastChunkSwap: Double = 0
189 var itemFailed = false
190 private var seekInFlight = false
191 private var pendingSeek: Double?
192 private var lastResync: Double = 0
193
194 init() {
195 player.automaticallyWaitsToMinimizeStalling = false
196 player.actionAtItemEnd = .pause
197 }
198
199 private var currentMediaKey: String?
200 /// Chunk indices that were proxy-backed in the CURRENT item's composition
201 /// (an item swap mid-playback is only worth its hiccup when it upgrades
202 /// the frames under the playhead).
203 private var itemChunks: Set<Int> = []
204
205 func setClip(_ clip: Clip?, media: MediaItem?, sourceTime: Double = 0) {
206 guard let clip, let media else {
207 if currentClipId != nil {
208 player.replaceCurrentItem(with: nil)
209 currentClipId = nil
210 currentSourceURL = nil
211 currentMediaKey = nil
212 currentChunkVersion = -1
213 }
214 return
215 }
216 // Audio files play their original directly (no chunked proxies).
217 if media.isAudio {
218 let url = media.url
219 if currentSourceURL != url || player.currentItem == nil {
220 replaceItem(AVPlayerItem(url: url), media: media, url: url, version: -1)
221 }
222 currentClipId = clip.id
223 if player.currentItem?.status == .failed { itemFailed = true }
224 player.isMuted = clip.muted
225 return
226 }
227 // Legacy whole-file proxy when present; otherwise the chunked
228 // composition. Crossing into another clip of the SAME media keeps the
229 // item — swapping it flashes black, and a seek is all that's needed.
230 if let proxy = MediaPipeline.shared.proxyURL(for: media) {
231 if currentSourceURL != proxy || player.currentItem == nil {
232 replaceItem(AVPlayerItem(url: proxy), media: media, url: proxy, version: -1)
233 }
234 } else {
235 let (asset, version) = ctx.chunks.composition(for: media)
236 let now = CACurrentMediaTime()
237 let sameAsset = currentMediaKey == media.cacheKey && currentSourceURL == nil
238 && player.currentItem != nil
239 var swap = !sameAsset
240 if !swap, currentChunkVersion != version {
241 if player.rate == 0 || currentChunkVersion == -2 {
242 // Paused/scrubbing, or still on the placeholder while the
243 // first real composition assembled: swap freely.
244 swap = true
245 } else {
246 // Mid-playback, only swap when it UPGRADES the frames
247 // under the playhead (original/empty → rendered proxy).
248 let idx = ChunkManager.chunkIndex(forSource: sourceTime)
249 swap = !itemChunks.contains(idx)
250 && ctx.chunks.isCovered(media: media, sourceTime: sourceTime)
251 && now - lastChunkSwap > 3
252 }
253 }
254 if itemFailed { swap = now - lastChunkSwap > 2 }
255 if swap {
256 replaceItem(AVPlayerItem(asset: asset), media: media, url: nil, version: version)
257 itemChunks = ctx.chunks.builtChunks(media: media)
258 lastChunkSwap = now
259 }
260 }
261 currentClipId = clip.id
262 if player.currentItem?.status == .failed { itemFailed = true }
263 player.isMuted = clip.muted || !media.hasAudio
264 }
265
266 private func replaceItem(_ item: AVPlayerItem, media: MediaItem, url: URL?, version: Int) {
267 item.preferredForwardBufferDuration = lenientSync ? 8 : 1
268 player.replaceCurrentItem(with: item)
269 currentSourceURL = url
270 currentMediaKey = media.cacheKey
271 currentChunkVersion = version
272 itemFailed = false
273 seekInFlight = false
274 pendingSeek = nil
275 }
276
277 func syncTime(expected: Double, rate: Double, force: Bool) {
278 guard player.currentItem != nil else { return }
279 if rate == 0 {
280 if player.rate != 0 { player.rate = 0 }
281 coalescedSeek(to: expected)
282 } else {
283 let actual = player.currentTime().seconds
284 let now = CACurrentMediaTime()
285 // A hard zero-tolerance seek is an audible/visible hiccup, so it
286 // only fires on real drift — and audio (lenient) tolerates much
287 // more drift before interrupting a smooth stream.
288 let drifted = abs(actual - expected) > (lenientSync ? 0.30 : 0.08)
289 let resyncGap = lenientSync ? 2.0 : 0.5
290 if force || player.rate != Float(rate) || (drifted && now - lastResync > resyncGap) {
291 lastResync = now
292 player.seek(to: time(expected), toleranceBefore: .zero, toleranceAfter: .zero) { [weak self] _ in
293 guard let self, ctx.playback.rate == rate else { return }
294 if rate > 0 {
295 self.player.playImmediately(atRate: Float(rate))
296 } else {
297 self.player.rate = Float(rate)
298 }
299 }
300 }
301 }
302 }
303
304 private func coalescedSeek(to t: Double) {
305 let current = player.currentTime().seconds
306 if abs(current - t) < 0.004 { return }
307 if seekInFlight { pendingSeek = t; return }
308 seekInFlight = true
309 player.seek(to: time(t), toleranceBefore: .zero, toleranceAfter: .zero) { [weak self] _ in
310 DispatchQueue.main.async {
311 guard let self else { return }
312 self.seekInFlight = false
313 if let p = self.pendingSeek {
314 self.pendingSeek = nil
315 self.coalescedSeek(to: p)
316 } else {
317 // Landed on the target frame. While paused nothing else
318 // ticks the viewer, so tell it to re-evaluate: the player
319 // is now showing the right frame and can replace the
320 // stand-in filmstrip.
321 NotificationCenter.default.post(name: .viewerNeedsRefresh, object: nil)
322 }
323 }
324 }
325 }
326
327 private func time(_ seconds: Double) -> CMTime {
328 CMTime(seconds: max(0, seconds), preferredTimescale: 60000)
329 }
330}
331
332final class PlayerManager {
333 /// The document context that owns this manager. Set at construction.
334 unowned var ctx: DocumentContext!
335 private(set) var players: [TrackRef: TrackPlayer] = [:]
336 /// Audio clips get one player per CLIP (not per track) so overlapping
337 /// audio layers all sound at once.
338 private(set) var audioPlayers: [UUID: TrackPlayer] = [:]
339 private var lastSyncedPlayhead: Double = -1
340
341 init() {
342 // Paused-state updates: clip edits move content under the playhead,
343 // and finished proxies should replace original/filmstrip playback.
344 NotificationCenter.default.addObserver(
345 forName: .projectChanged, object: nil, queue: .main) { [weak self] _ in
346 self?.sync(force: true)
347 }
348 NotificationCenter.default.addObserver(
349 forName: .mediaStatusChanged, object: nil, queue: .main) { [weak self] _ in
350 guard let self else { return }
351 self.sync(force: self.ctx.playback.rate == 0)
352 }
353 }
354
355 func player(for ref: TrackRef) -> TrackPlayer {
356 if let p = players[ref] { return p }
357 let p = TrackPlayer()
358 p.ctx = ctx
359 players[ref] = p
360 return p
361 }
362
363 func sync(force: Bool = false) {
364 let store = ctx.store
365 let pc = ctx.playback
366 let project = store.project
367 let playhead = pc.playhead
368 let rate = pc.rate
369
370 // Drop players for removed tracks.
371 let liveRefs = Set(project.tracks.indices.map { TrackRef.video($0) })
372 for (ref, p) in players where !liveRefs.contains(ref) {
373 p.player.replaceCurrentItem(with: nil)
374 players.removeValue(forKey: ref)
375 }
376
377 let playheadMoved = playhead != lastSyncedPlayhead
378 lastSyncedPlayhead = playhead
379
380 for i in project.tracks.indices {
381 let ref = TrackRef.video(i)
382 let tp = player(for: ref)
383 let clip = project.clipAt(track: ref, time: playhead, kind: .video)
384 let media = clip.flatMap { project.media($0.mediaId) }
385 tp.setClip(clip, media: media,
386 sourceTime: clip?.sourceTime(at: playhead) ?? 0)
387 guard let clip, let media else { continue }
388 let expected = clip.sourceTime(at: playhead)
389 ctx.chunks.want(media: media, sourceTime: expected)
390 if rate != 0 || playheadMoved || force {
391 // Time-stretched clips chase the clock at a scaled rate.
392 tp.syncTime(expected: expected, rate: rate * clip.speed, force: force)
393 }
394 }
395
396 syncAudio(project: project, playhead: playhead, rate: rate,
397 playheadMoved: playheadMoved, force: force)
398 }
399
400 /// Layered audio: every audio clip under the playhead plays through its
401 /// own player, with the fade envelope applied as volume.
402 private func syncAudio(project: ProjectModel, playhead: Double, rate: Double,
403 playheadMoved: Bool, force: Bool) {
404 let active = project.clips.filter {
405 $0.kind == .audio && playhead >= $0.start && playhead < $0.end
406 }
407 let activeIds = Set(active.map(\.id))
408 for (id, p) in audioPlayers where !activeIds.contains(id) {
409 p.player.replaceCurrentItem(with: nil)
410 audioPlayers.removeValue(forKey: id)
411 }
412 for clip in active {
413 guard let media = project.media(clip.mediaId) else { continue }
414 let ap: TrackPlayer
415 if let existing = audioPlayers[clip.id] {
416 ap = existing
417 } else {
418 ap = TrackPlayer()
419 ap.ctx = ctx
420 ap.lenientSync = true
421 audioPlayers[clip.id] = ap
422 }
423 let expected = clip.srcIn + (playhead - clip.start)
424 ap.setClip(clip, media: media, sourceTime: expected)
425 ap.player.volume = Float(audioGain(clip, at: playhead))
426 if rate != 0 || playheadMoved || force {
427 ap.syncTime(expected: expected, rate: rate, force: force)
428 }
429 }
430 }
431}
sequencer/Sources/Sequencer/Selftest.swift created+102
...@@ -0,0 +1,102 @@
1import Foundation
2import AVFoundation
3
4/// Headless pipeline check: `sequencer --selftest <mediafile>`.
5/// Verifies probe on the given file, then runs the full filmstrip/proxy
6/// pipeline on a short synthetic clip, and prints sample Fusion Lua.
7func runSelftest(path: String) {
8 func spin(until done: () -> Bool, timeout: TimeInterval) -> Bool {
9 let deadline = Date().addingTimeInterval(timeout)
10 while !done() {
11 if Date() > deadline { return false }
12 RunLoop.main.run(until: Date().addingTimeInterval(0.05))
13 }
14 return true
15 }
16
17 print("== Sequencer selftest ==")
18 print("ffmpeg: \(MediaPipeline.findExecutable("ffmpeg") ?? "NOT FOUND")")
19 print("ffprobe: \(MediaPipeline.findExecutable("ffprobe") ?? "NOT FOUND")")
20 print("cache: \(MediaPipeline.shared.cacheRoot.path)")
21
22 // 1. Probe the real file.
23 print("\n-- probe: \(path)")
24 var probed: MediaItem?
25 var probeDone = false
26 MediaPipeline.shared.importFile(URL(fileURLWithPath: path)) { item in
27 probed = item
28 probeDone = true
29 }
30 guard spin(until: { probeDone }, timeout: 60) else { print("FAIL: probe timed out"); return }
31 if let m = probed {
32 print(String(format: "OK: %.1fs %dx%d @ %.2ffps audio=%@ key=%@",
33 m.duration, m.width, m.height, m.fps, m.hasAudio ? "yes" : "no", m.cacheKey))
34 } else {
35 print("FAIL: could not probe \(path)")
36 }
37
38 // 2. Full pipeline on a short synthetic clip.
39 print("\n-- pipeline on 4s synthetic clip")
40 let tmpDir = FileManager.default.temporaryDirectory
41 .appendingPathComponent("sequencer-selftest", isDirectory: true)
42 try? FileManager.default.createDirectory(at: tmpDir, withIntermediateDirectories: true)
43 let testClip = tmpDir.appendingPathComponent("test.mov")
44 if let ffmpeg = MediaPipeline.findExecutable("ffmpeg") {
45 let p = Process()
46 p.executableURL = URL(fileURLWithPath: ffmpeg)
47 p.arguments = ["-y", "-f", "lavfi", "-i", "testsrc2=size=1280x720:rate=30:duration=4",
48 "-f", "lavfi", "-i", "sine=frequency=440:duration=4",
49 "-c:v", "h264_videotoolbox", "-c:a", "aac", "-shortest", testClip.path]
50 p.standardError = Pipe()
51 try? p.run()
52 p.waitUntilExit()
53 if p.terminationStatus != 0 { print("FAIL: could not generate synthetic clip") }
54 }
55
56 var testItem: MediaItem?
57 var testDone = false
58 MediaPipeline.shared.importFile(testClip) { item in
59 testItem = item
60 testDone = true
61 }
62 _ = spin(until: { testDone }, timeout: 30)
63 guard let item = testItem else { print("FAIL: probe of synthetic clip failed"); return }
64
65 DocumentContext.headless.chunks.want(media: item, sourceTime: 0)
66 _ = spin(until: {
67 MediaPipeline.shared.status(for: item).filmstripReady
68 && DocumentContext.headless.chunks.isCovered(media: item, sourceTime: 0)
69 }, timeout: 120)
70 print("filmstrip: \(MediaPipeline.shared.status(for: item).filmstripReady ? "OK" : "FAIL")")
71 print("chunk 0: \(DocumentContext.headless.chunks.isCovered(media: item, sourceTime: 0) ? "OK" : "FAIL")")
72
73 if let proxy = DocumentContext.headless.chunks.builtChunkURL(media: item, index: 0) {
74 let asset = AVURLAsset(url: proxy)
75 let sem = DispatchSemaphore(value: 0)
76 var playable = false
77 var codec = "?"
78 Task {
79 playable = (try? await asset.load(.isPlayable)) ?? false
80 if let track = try? await asset.loadTracks(withMediaType: .video).first,
81 let desc = try? await track.load(.formatDescriptions).first {
82 let sub = CMFormatDescriptionGetMediaSubType(desc)
83 codec = String(format: "%c%c%c%c",
84 (sub >> 24) & 255, (sub >> 16) & 255, (sub >> 8) & 255, sub & 255)
85 }
86 sem.signal()
87 }
88 _ = spin(until: { sem.wait(timeout: .now()) == .success }, timeout: 30)
89 print("proxy AVFoundation-playable: \(playable ? "OK" : "FAIL") (codec \(codec))")
90 }
91
92 // 3. Fusion Lua sample.
93 print("\n-- fusion lua for a 1.5s clip starting at 2.0s")
94 var model = ProjectModel()
95 model.fps = item.fps
96 model.media = [item]
97 model.tracks = [Track(hue: 0.5)]
98 let clip = Clip(mediaId: item.id, track: .video(0), start: 2.0, srcIn: 1.0, duration: 1.5)
99 model.clips = [clip]
100 print(FusionExport.loaderLua(for: [clip], project: model))
101 print("\n== selftest done ==")
102}
sequencer/Sources/Sequencer/SessionState.swift created+129
...@@ -0,0 +1,129 @@
1import AppKit
2
3/// Per-window view/session state: what's hidden, focused, zoomed, which tool is
4/// active, the draw color. Deliberately OUTSIDE `ProjectModel` so undo/redo
5/// never toggles visibility or zoom. Each open document owns one of these
6/// (`ctx.session`), so two windows are fully independent. The portable subset
7/// is saved into the `.sq` envelope as `ViewState` (see `captureViewState`).
8final class SessionState {
9 /// Back-reference to the owning context, for state that reads the model or
10 /// playhead (`panelUnderPlayhead`). Set right after construction.
11 unowned var ctx: DocumentContext!
12
13 var snapping = true { didSet { postViewOptions() } }
14 var showFilmstrips = true { didSet { postViewOptions() } }
15 /// Vertical zoom: multiplies the base lane height for all tracks.
16 var laneScale: CGFloat = 1 {
17 didSet {
18 laneScale = min(3, max(0.4, laneScale))
19 postViewOptions()
20 }
21 }
22 /// Per-lane height factor on top of laneScale (drag a lane boundary).
23 var trackHeights: [TrackRef: CGFloat] = [:] { didSet { postViewOptions() } }
24
25 var hiddenTracks: Set<TrackRef> = [] { didSet { postViewOptions() } }
26 var focusedTracks: Set<TrackRef> = [] { didSet { postViewOptions() } }
27 /// The Fusion comps band gets its own hide/focus.
28 var fusionHidden = false { didSet { postViewOptions() } }
29 var fusionFocus = false { didSet { postViewOptions() } }
30 /// The one pane (a lane, or `.fusion`) blown up large while the rest tile in
31 /// the leftover space — "Priority" mode, à la Google Meet's spotlight.
32 var priorityPane: TrackRef? = nil { didSet { postViewOptions() } }
33 var previewsOnLeft = false
34
35 // Editing tools (per window).
36 var mainTool: MainTool = .select { didSet { postViewOptions() } }
37 var drawColor: NSColor = .black { didSet { postViewOptions() } }
38 /// Armed by the toolbar's Shapes dropdown: the next click-drag on a
39 /// storyboard preview places this shape, then control returns to select.
40 var pendingShape: BoardShape.Kind? { didSet { postViewOptions() } }
41
42 /// Is there a storyboard panel under the playhead to draw on?
43 var panelUnderPlayhead: Clip? {
44 let p = ctx.store.project
45 let t = ctx.playback.playhead
46 return p.clipAt(track: .storyboard, time: t, kind: .storyboard)
47 }
48
49 // MARK: - Track visibility
50
51 /// Drop per-lane session state (hide, focus, custom height) for lanes that
52 /// no longer exist. Without this a deleted FOCUSED track leaves focusedTracks
53 /// non-empty, so the viewer treats focus as active and blanks every
54 /// surviving track. Called on every project change.
55 func reconcileTracks(_ p: ProjectModel) {
56 var live = Set(p.laneRefs)
57 live.insert(.fusion) // the Fusion band always survives
58 let prunedFocus = focusedTracks.intersection(live)
59 let prunedHidden = hiddenTracks.intersection(live)
60 let prunedHeights = trackHeights.filter { live.contains($0.key) }
61 if prunedFocus != focusedTracks { focusedTracks = prunedFocus }
62 if prunedHidden != hiddenTracks { hiddenTracks = prunedHidden }
63 if prunedHeights.count != trackHeights.count { trackHeights = prunedHeights }
64 if let pane = priorityPane, !live.contains(pane) { priorityPane = nil }
65 }
66
67 func toggleHidden(_ ref: TrackRef) {
68 if hiddenTracks.contains(ref) { hiddenTracks.remove(ref) }
69 else { hiddenTracks.insert(ref) }
70 }
71 func toggleFocus(_ ref: TrackRef) {
72 if focusedTracks.contains(ref) { focusedTracks.remove(ref) }
73 else { focusedTracks.insert(ref) }
74 }
75
76 /// Reveal every track/band: clear all hide, focus and priority state.
77 func showAll() {
78 hiddenTracks = []
79 focusedTracks = []
80 fusionHidden = false
81 fusionFocus = false
82 priorityPane = nil
83 }
84
85 /// Lanes whose previews show: focus wins; otherwise everything not hidden.
86 func visibleTracks(_ p: ProjectModel) -> [TrackRef] {
87 let ordered = p.laneRefs
88 let focused = ordered.filter { focusedTracks.contains($0) }
89 if !focused.isEmpty { return focused }
90 return ordered.filter { !hiddenTracks.contains($0) }
91 }
92
93 // MARK: - Portable view state (saved in the .sq envelope)
94
95 /// Snapshot the session view state for persistence.
96 func captureViewState() -> ViewState {
97 var v = ViewState()
98 v.hiddenTracks = Array(hiddenTracks)
99 v.focusedTracks = Array(focusedTracks)
100 v.trackHeights = trackHeights.map { TrackHeight(track: $0.key, factor: Double($0.value)) }
101 v.laneScale = Double(laneScale)
102 v.snapping = snapping
103 v.showFilmstrips = showFilmstrips
104 v.previewsOnLeft = previewsOnLeft
105 v.priorityPane = priorityPane
106 v.fusionHidden = fusionHidden
107 v.fusionFocus = fusionFocus
108 return v
109 }
110
111 /// Restore session view state from a loaded project.
112 func apply(_ v: ViewState) {
113 hiddenTracks = Set(v.hiddenTracks)
114 focusedTracks = Set(v.focusedTracks)
115 trackHeights = Dictionary(v.trackHeights.map { ($0.track, CGFloat($0.factor)) },
116 uniquingKeysWith: { a, _ in a })
117 laneScale = CGFloat(v.laneScale)
118 snapping = v.snapping
119 showFilmstrips = v.showFilmstrips
120 previewsOnLeft = v.previewsOnLeft
121 priorityPane = v.priorityPane
122 fusionHidden = v.fusionHidden
123 fusionFocus = v.fusionFocus
124 }
125
126 private func postViewOptions() {
127 NotificationCenter.default.post(name: .viewOptionsChanged, object: nil)
128 }
129}
sequencer/Sources/Sequencer/Store.swift created+197
...@@ -0,0 +1,197 @@
1import Foundation
2
3extension Notification.Name {
4 static let projectChanged = Notification.Name("projectChanged")
5 static let selectionChanged = Notification.Name("selectionChanged")
6 static let playheadChanged = Notification.Name("playheadChanged")
7 static let mediaStatusChanged = Notification.Name("mediaStatusChanged")
8 static let transientStatus = Notification.Name("transientStatus") // userInfo["text"]
9 /// The document's file location or saved/dirty state changed — the window
10 /// titlebar (proxy icon + edited dot) refreshes off this.
11 static let documentStateChanged = Notification.Name("documentStateChanged")
12 /// A track player finished seeking while paused — the frame it's showing
13 /// changed, so the viewer should re-evaluate what to display. Does NOT
14 /// re-run playback sync (avoids a seek feedback loop).
15 static let viewerNeedsRefresh = Notification.Name("viewerNeedsRefresh")
16}
17
18/// Owns the project model, selection, undo, and persistence.
19/// Perfect undo = snapshot stack of the (small, value-type) model.
20final class Store {
21 /// The document context that owns this store. Set at construction.
22 unowned var ctx: DocumentContext!
23
24 private(set) var project = ProjectModel()
25 var selection: Set<UUID> = [] {
26 didSet { if selection != oldValue { post(.selectionChanged) } }
27 }
28
29 private var undoStack: [ProjectModel] = []
30 private var redoStack: [ProjectModel] = []
31 private var gestureBase: ProjectModel?
32
33 var canUndo: Bool { !undoStack.isEmpty || gestureBase != nil }
34 var canRedo: Bool { !redoStack.isEmpty }
35
36 // MARK: - Mutation
37
38 /// One-shot undoable mutation.
39 func mutate(_ body: (inout ProjectModel) -> Void) {
40 precondition(gestureBase == nil, "mutate() during an open gesture")
41 var copy = project
42 body(&copy)
43 copy.normalizeStoryboards()
44 guard copy != project else { return }
45 pushUndo(project)
46 project = copy
47 pruneSelection()
48 changed()
49 }
50
51 /// Continuous-gesture mutations (drags): one undo entry for the whole
52 /// gesture, and each update recomputes from the gesture-start snapshot
53 /// so there is no accumulation error.
54 func beginGesture() {
55 precondition(gestureBase == nil)
56 gestureBase = project
57 }
58
59 var gestureBaseModel: ProjectModel? { gestureBase }
60
61 func updateGesture(_ body: (inout ProjectModel) -> Void) {
62 guard let base = gestureBase else { return }
63 var copy = base
64 body(&copy)
65 copy.normalizeStoryboards()
66 guard copy != project else { return }
67 project = copy
68 post(.projectChanged)
69 }
70
71 /// `finalize` (e.g. pruning emptied tracks) applies ON TOP of the current
72 /// mid-gesture state — unlike updateGesture, which recomputes from the
73 /// base snapshot and would discard the gesture's changes.
74 func endGesture(finalize: ((inout ProjectModel) -> Void)? = nil) {
75 guard let base = gestureBase else { return }
76 var copy = project
77 finalize?(&copy)
78 copy.normalizeStoryboards()
79 // A drag that vacated a bottom lane leaves it as an ephemeral drop
80 // target — collapse it (interior lanes and the clip's own lane stay).
81 copy.pruneTrailingEmptyTracks()
82 if copy != project {
83 project = copy
84 post(.projectChanged)
85 }
86 gestureBase = nil
87 if project != base {
88 pushUndo(base)
89 pruneSelection()
90 changed()
91 }
92 }
93
94 /// Live, non-undoable edit for a floating control that has no discrete
95 /// start/end (the colour picker). Unlike a gesture it holds no open state,
96 /// so timeline edits mid-preview can't trip the gesture precondition.
97 func preview(_ body: (inout ProjectModel) -> Void) {
98 body(&project)
99 post(.projectChanged)
100 }
101
102 /// Commit a finished preview as ONE undo step, given the pre-preview snapshot.
103 func commitPreview(from snapshot: ProjectModel) {
104 guard project != snapshot else { return }
105 pushUndo(snapshot)
106 pruneSelection()
107 changed()
108 }
109
110 func cancelGesture() {
111 guard let base = gestureBase else { return }
112 gestureBase = nil
113 project = base
114 post(.projectChanged)
115 }
116
117 func undo() {
118 if gestureBase != nil { cancelGesture(); return }
119 guard let prev = undoStack.popLast() else { return }
120 redoStack.append(project)
121 project = prev
122 pruneSelection()
123 changed()
124 }
125
126 func redo() {
127 guard let next = redoStack.popLast() else { return }
128 undoStack.append(project)
129 project = next
130 pruneSelection()
131 changed()
132 }
133
134 private func pushUndo(_ snapshot: ProjectModel) {
135 undoStack.append(snapshot)
136 if undoStack.count > 500 { undoStack.removeFirst() }
137 redoStack.removeAll()
138 }
139
140 private func pruneSelection() {
141 let ids = Set(project.clips.map(\.id))
142 selection = selection.filter { ids.contains($0) }
143 }
144
145 private func changed() {
146 post(.projectChanged)
147 post(.documentStateChanged)
148 // Mark the owning NSDocument dirty; it autosaves in place and drives the
149 // titlebar edited-dot. No-op for the headless (document-less) context.
150 ctx.document?.updateChangeCount(.changeDone)
151 }
152
153 private func post(_ name: Notification.Name) {
154 NotificationCenter.default.post(name: name, object: nil)
155 }
156
157 /// Test-only: swap in a model without touching disk or undo history.
158 func replaceForTest(_ model: ProjectModel) {
159 var model = model
160 model.normalizeStoryboards()
161 project = model
162 undoStack.removeAll(); redoStack.removeAll(); selection.removeAll()
163 post(.projectChanged)
164 }
165
166 // MARK: - Persistence
167
168 static var defaultProjectDir: URL {
169 FileManager.default.homeDirectoryForCurrentUser
170 .appendingPathComponent("Documents/Sequencer Projects", isDirectory: true)
171 }
172
173 /// Adopt a freshly-decoded project (from `ProjectDocument.read`). Heals
174 /// media cache keys, ensures a track exists, resets undo/selection, and
175 /// starts clean (not dirty). View state is applied separately by the
176 /// document from the `.sq` envelope.
177 func adopt(_ model: ProjectModel) {
178 var model = model
179 if model.tracks.isEmpty { _ = model.addTrack() }
180 for i in model.media.indices {
181 model.media[i].cacheKey = MediaPipeline.normalizedCacheKey(for: model.media[i])
182 }
183 model.normalizeStoryboards()
184 project = model
185 undoStack.removeAll(); redoStack.removeAll(); selection.removeAll()
186 post(.projectChanged)
187 post(.documentStateChanged)
188 }
189
190 /// Drawing strokes bypass `mutate`, so BoardStore calls this to mark the
191 /// owning document dirty (NSDocument then autosaves the raster into the
192 /// package). No-op for the headless context.
193 func noteRasterChanged() {
194 post(.documentStateChanged)
195 ctx.document?.updateChangeCount(.changeDone)
196 }
197}
sequencer/Sources/Sequencer/Storyboard.swift created+468
...@@ -0,0 +1,468 @@
1import AppKit
2import CoreText
3import UniformTypeIdentifiers
4
5/// Disk store + renderer for storyboard boards. The raster (drawing) layer of
6/// each board lives in memory during a session and flushes to a PNG beside the
7/// project file — `{project}/Storyboard/NN.png`, where NN is the panel's
8/// position in the storyboard. The shape layer lives in the project model.
9/// Composites are cached per revision.
10final class BoardStore {
11 /// The document context that owns this raster store. Set at construction.
12 unowned var ctx: DocumentContext!
13
14 /// Default board background — #e8e8e8, softer than pure white.
15 static let paper = NSColor(calibratedRed: 0xE8 / 255.0, green: 0xE8 / 255.0,
16 blue: 0xE8 / 255.0, alpha: 1)
17
18 /// Pre-2026 rasters keyed by board UUID under Application Support. Read on
19 /// demand so old projects keep their drawings; rewritten to the new
20 /// project-relative path on the next save.
21 private let legacyBoardsDir: URL
22 /// In-session source of truth for drawing layers, keyed by board id. Disk
23 /// is written only at save time (`flushRasters`), so unsaved projects keep
24 /// their drawings purely here.
25 private var rasterCache: [UUID: NSImage] = [:]
26 private var compositeCache = NSCache<NSString, NSImage>()
27 /// Bumped on every raster save so composites invalidate without touching
28 /// the model (strokes are editor-local, not undo entries).
29 private var rasterVersions: [UUID: Int] = [:]
30
31 init() {
32 let appSupport = FileManager.default.urls(for: .applicationSupportDirectory,
33 in: .userDomainMask)[0]
34 .appendingPathComponent("Sequencer", isDirectory: true)
35 legacyBoardsDir = appSupport.appendingPathComponent("Boards", isDirectory: true)
36 compositeCache.countLimit = 200
37 }
38
39 /// 1-based ordinal of a board among the project's storyboard panels, or nil
40 /// if it isn't attached to a panel yet (freshly created, mid-paste).
41 private func boardIndex(_ boardId: UUID, in model: ProjectModel) -> Int? {
42 let panels = model.orderedStoryboardPanels()
43 guard let i = panels.firstIndex(where: { $0.board?.id == boardId }) else { return nil }
44 return i + 1
45 }
46
47 func rasterImage(_ boardId: UUID) -> NSImage? {
48 if let img = rasterCache[boardId] { return img }
49 // Drawings are loaded into the cache from the document package when it
50 // opens (`loadRasters`); the only on-demand read left is the legacy
51 // pre-2026 store keyed by board UUID under Application Support.
52 let legacy = legacyBoardsDir.appendingPathComponent("\(boardId.uuidString).png")
53 if let img = NSImage(contentsOf: legacy) {
54 rasterCache[boardId] = img
55 return img
56 }
57 return nil
58 }
59
60 func saveRaster(_ image: NSImage?, boardId: UUID) {
61 rasterVersions[boardId, default: 0] += 1
62 if let image {
63 rasterCache[boardId] = image
64 } else {
65 rasterCache.removeValue(forKey: boardId)
66 }
67 // Persist lazily: strokes bypass `Store.mutate`, so nudge the document
68 // dirty and let autosave flush the raster to disk.
69 ctx.store.noteRasterChanged()
70 }
71
72 func duplicateRaster(from: UUID, to: UUID) {
73 rasterVersions[to, default: 0] += 1
74 if let img = rasterImage(from)?.copy() as? NSImage {
75 rasterCache[to] = img
76 } else {
77 rasterCache.removeValue(forKey: to)
78 }
79 ctx.store.noteRasterChanged()
80 }
81
82 /// Encode a board's drawing layer as PNG bytes (clipboard transfer).
83 func rasterPNGData(_ boardId: UUID) -> Data? {
84 guard let img = rasterImage(boardId),
85 let tiff = img.tiffRepresentation,
86 let rep = NSBitmapImageRep(data: tiff) else { return nil }
87 return rep.representation(using: .png, properties: [:])
88 }
89
90 /// Install a drawing layer from clipboard PNG bytes.
91 func setRaster(fromPNG data: Data, boardId: UUID) {
92 guard let img = NSImage(data: data) else { return }
93 saveRaster(img, boardId: boardId)
94 }
95
96 /// PNG data for each panel's drawing, keyed `NN.png` by storyboard order —
97 /// the contents of the document package's `Storyboard/` directory. Panels
98 /// with no drawing are omitted.
99 func rasterPNGs(of model: ProjectModel) -> [String: Data] {
100 var out: [String: Data] = [:]
101 for (i, panel) in model.orderedStoryboardPanels().enumerated() {
102 guard let board = panel.board,
103 let img = rasterImage(board.id),
104 let tiff = img.tiffRepresentation,
105 let rep = NSBitmapImageRep(data: tiff),
106 let png = rep.representation(using: .png, properties: [:]) else { continue }
107 out[String(format: "%02d.png", i + 1)] = png
108 }
109 return out
110 }
111
112 /// Load panel drawings from a `Storyboard/` directory (inside the package,
113 /// or the legacy sibling folder during migration) into the raster cache,
114 /// mapping `NN.png` back to the board at that storyboard position.
115 func loadRasters(fromDirectory dir: URL, project: ProjectModel) {
116 let panels = project.orderedStoryboardPanels()
117 for (i, panel) in panels.enumerated() {
118 guard let board = panel.board else { continue }
119 let url = dir.appendingPathComponent(String(format: "%02d.png", i + 1))
120 if let img = NSImage(contentsOf: url) {
121 rasterCache[board.id] = img
122 rasterVersions[board.id, default: 0] += 1
123 }
124 }
125 }
126
127 func invalidate(_ boardId: UUID) {
128 rasterVersions[boardId, default: 0] += 1
129 rasterCache.removeValue(forKey: boardId)
130 }
131
132 // MARK: - Stroke engine (shared by the editor canvas and viewer cells)
133
134 // Board coordinates everywhere: top-left origin, y down. The engine owns
135 // the y-flip into image space so callers never think about it.
136 private var workingRasters: [UUID: NSImage] = [:]
137 private var strokeUndo: [UUID: [NSImage?]] = [:]
138 /// Boards in the order strokes were committed (global ⌘Z routing).
139 private(set) var strokeHistory: [UUID] = []
140
141 /// Raster to DISPLAY: the in-progress stroke image when one is active.
142 func displayRaster(_ boardId: UUID) -> NSImage? {
143 workingRasters[boardId] ?? rasterImage(boardId)
144 }
145
146 private func blankRaster(size: CGSize) -> NSImage {
147 let img = NSImage(size: size)
148 img.lockFocus()
149 NSColor.clear.setFill()
150 NSRect(origin: .zero, size: size).fill()
151 img.unlockFocus()
152 return img
153 }
154
155 func beginStroke(board: Board) {
156 var stack = strokeUndo[board.id] ?? []
157 stack.append(rasterImage(board.id)?.copy() as? NSImage)
158 if stack.count > 24 { stack.removeFirst() }
159 strokeUndo[board.id] = stack
160 workingRasters[board.id] = (rasterImage(board.id)?.copy() as? NSImage)
161 ?? blankRaster(size: board.size)
162 }
163
164 /// Add a segment in board coords. `pressure` scales the width (tablets).
165 func strokeSegment(board: Board, from a: CGPoint, to b: CGPoint,
166 width: CGFloat, color: NSColor, erase: Bool,
167 alpha: CGFloat = 1, pressure: CGFloat = 0) {
168 guard let img = workingRasters[board.id] else { return }
169 let h = board.size.height
170 // Image focus is bottom-left; board coords are top-left.
171 let a2 = CGPoint(x: a.x, y: h - a.y)
172 let b2 = CGPoint(x: b.x, y: h - b.y)
173 img.lockFocus()
174 if let ctx = NSGraphicsContext.current {
175 ctx.compositingOperation = erase ? .destinationOut : .sourceOver
176 }
177 let path = NSBezierPath()
178 path.move(to: a2)
179 // Zero-length segments (clicks) still leave a dot.
180 path.line(to: a2 == b2 ? CGPoint(x: b2.x + 0.3, y: b2.y) : b2)
181 var w = width
182 if pressure > 0.01, pressure < 0.999 { w = width * (0.35 + 1.3 * pressure) }
183 path.lineWidth = w
184 path.lineCapStyle = .round
185 path.lineJoinStyle = .round
186 (erase ? NSColor.black : color).withAlphaComponent(alpha).setStroke()
187 path.stroke()
188 img.unlockFocus()
189 workingRasters[board.id] = img
190 }
191
192 func endStroke(board: Board) {
193 guard let img = workingRasters.removeValue(forKey: board.id) else { return }
194 saveRaster(img, boardId: board.id)
195 strokeHistory.append(board.id)
196 if strokeHistory.count > 48 { strokeHistory.removeFirst() }
197 NotificationCenter.default.post(name: .mediaStatusChanged, object: nil)
198 }
199
200 func canUndoStroke(_ boardId: UUID) -> Bool {
201 !(strokeUndo[boardId] ?? []).isEmpty
202 }
203 var canUndoAnyStroke: Bool {
204 strokeHistory.last.map(canUndoStroke) ?? false
205 }
206
207 @discardableResult
208 func undoStroke(_ boardId: UUID) -> Bool {
209 guard var stack = strokeUndo[boardId], let prev = stack.popLast() else { return false }
210 strokeUndo[boardId] = stack
211 workingRasters.removeValue(forKey: boardId)
212 saveRaster(prev, boardId: boardId)
213 if let i = strokeHistory.lastIndex(of: boardId) { strokeHistory.remove(at: i) }
214 NotificationCenter.default.post(name: .mediaStatusChanged, object: nil)
215 return true
216 }
217
218 /// Undo the most recent stroke on any board.
219 @discardableResult
220 func undoLastStroke() -> Bool {
221 guard let boardId = strokeHistory.last else { return false }
222 return undoStroke(boardId)
223 }
224
225 /// Wipe the drawing layer (undoable as one stroke).
226 func clearRaster(board: Board) {
227 beginStroke(board: board)
228 workingRasters[board.id] = blankRaster(size: board.size)
229 endStroke(board: board)
230 }
231
232 // MARK: - Rendering
233
234 /// Flattened panel: white paper, below-raster shapes, raster, above-raster
235 /// shapes. Cached by board id + revision.
236 func composite(for board: Board) -> NSImage {
237 let rv = rasterVersions[board.id] ?? 0
238 let key = "\(board.id.uuidString)/\(board.revision)/\(rv)" as NSString
239 // Mid-stroke: render fresh for live feedback, don't poison the cache.
240 let strokeInProgress = workingRasters[board.id] != nil
241 if !strokeInProgress, let img = compositeCache.object(forKey: key) { return img }
242 let img = NSImage(size: board.size, flipped: true) { rect in
243 Self.paper.setFill()
244 rect.fill()
245 for shape in board.shapes where !shape.aboveRaster {
246 Self.draw(shape)
247 }
248 if let raster = self.displayRaster(board.id) {
249 raster.draw(in: rect, from: .zero, operation: .sourceOver,
250 fraction: 1, respectFlipped: true, hints: nil)
251 }
252 for shape in board.shapes where shape.aboveRaster {
253 Self.draw(shape)
254 }
255 return true
256 }
257 if !strokeInProgress { compositeCache.setObject(img, forKey: key) }
258 return img
259 }
260
261 static func rgba(_ color: NSColor) -> [Double] {
262 let c = color.usingColorSpace(.sRGB) ?? color
263 return [Double(c.redComponent), Double(c.greenComponent),
264 Double(c.blueComponent), Double(c.alphaComponent)]
265 }
266
267 static func color(_ rgba: [Double]) -> NSColor {
268 NSColor(calibratedRed: rgba.count > 0 ? rgba[0] : 0,
269 green: rgba.count > 1 ? rgba[1] : 0,
270 blue: rgba.count > 2 ? rgba[2] : 0,
271 alpha: rgba.count > 3 ? rgba[3] : 1)
272 }
273
274 static func path(for shape: BoardShape) -> NSBezierPath {
275 let r = shape.frame
276 switch shape.kind {
277 case .rect, .text, .image:
278 return NSBezierPath(rect: r)
279 case .oval:
280 return NSBezierPath(ovalIn: r)
281 case .triangle:
282 let p = NSBezierPath()
283 p.move(to: NSPoint(x: r.midX, y: r.minY))
284 p.line(to: NSPoint(x: r.maxX, y: r.maxY))
285 p.line(to: NSPoint(x: r.minX, y: r.maxY))
286 p.close()
287 return p
288 case .star:
289 return starPath(in: r, points: max(3, shape.sides), innerRatio: 0.45)
290 case .ngon:
291 return polygonPath(in: r, sides: max(3, shape.sides))
292 }
293 }
294
295 static func polygonPath(in rect: CGRect, sides: Int) -> NSBezierPath {
296 let p = NSBezierPath()
297 let c = NSPoint(x: rect.midX, y: rect.midY)
298 let rx = rect.width / 2, ry = rect.height / 2
299 for i in 0..<sides {
300 let a = -Double.pi / 2 + Double(i) * 2 * .pi / Double(sides)
301 let pt = NSPoint(x: c.x + rx * CGFloat(cos(a)), y: c.y + ry * CGFloat(sin(a)))
302 i == 0 ? p.move(to: pt) : p.line(to: pt)
303 }
304 p.close()
305 return p
306 }
307
308 static func starPath(in rect: CGRect, points: Int, innerRatio: Double) -> NSBezierPath {
309 let p = NSBezierPath()
310 let c = NSPoint(x: rect.midX, y: rect.midY)
311 let rx = rect.width / 2, ry = rect.height / 2
312 for i in 0..<(points * 2) {
313 let a = -Double.pi / 2 + Double(i) * .pi / Double(points)
314 let f = i.isMultiple(of: 2) ? 1.0 : innerRatio
315 let pt = NSPoint(x: c.x + rx * CGFloat(f * cos(a)),
316 y: c.y + ry * CGFloat(f * sin(a)))
317 i == 0 ? p.move(to: pt) : p.line(to: pt)
318 }
319 p.close()
320 return p
321 }
322
323 static func boardFont(size: Double) -> NSFont {
324 NSFont(name: "AT Name Sans Standard", size: size)
325 ?? NSFont(name: "ATNameSansStandard-Regular", size: size)
326 ?? .systemFont(ofSize: size)
327 }
328
329 static func draw(_ shape: BoardShape) {
330 let color = Self.color(shape.color)
331 switch shape.kind {
332 case .text:
333 let attrs: [NSAttributedString.Key: Any] = [
334 .font: boardFont(size: shape.fontSize),
335 .foregroundColor: color,
336 ]
337 (shape.text.isEmpty ? "Text" : shape.text)
338 .draw(in: shape.frame, withAttributes: attrs)
339 case .image:
340 if let path = shape.imagePath, let img = NSImage(contentsOfFile: path) {
341 // Aspect-fit inside the frame.
342 let s = img.size
343 guard s.width > 0, s.height > 0 else { return }
344 let scale = min(shape.frame.width / s.width, shape.frame.height / s.height)
345 let w = s.width * scale, h = s.height * scale
346 let r = NSRect(x: shape.frame.midX - w / 2, y: shape.frame.midY - h / 2,
347 width: w, height: h)
348 // respectFlipped: image refs must not mirror in flipped contexts.
349 img.draw(in: r, from: .zero, operation: .sourceOver,
350 fraction: 1, respectFlipped: true, hints: nil)
351 } else {
352 color.withAlphaComponent(0.25).setFill()
353 NSBezierPath(rect: shape.frame).fill()
354 NSImage(systemSymbolName: "photo", accessibilityDescription: nil)?
355 .draw(in: shape.frame.insetBy(dx: shape.frame.width * 0.3,
356 dy: shape.frame.height * 0.3))
357 }
358 default:
359 let path = Self.path(for: shape)
360 if shape.filled {
361 color.setFill()
362 path.fill()
363 } else {
364 color.setStroke()
365 path.lineWidth = 4
366 path.stroke()
367 }
368 }
369 }
370
371 // MARK: - Board lifecycle helpers
372
373 /// Deep-copy a board (fresh id, copied raster) — used by S on a
374 /// storyboard clip, which splits by duplicating the panel.
375 func duplicate(_ board: Board) -> Board {
376 var copy = board
377 copy.id = UUID()
378 copy.revision = 0
379 duplicateRaster(from: board.id, to: copy.id)
380 return copy
381 }
382
383 // MARK: - Panel copy/paste
384
385 static let pasteboardType = NSPasteboard.PasteboardType("com.sequencer.storyboard-panel")
386
387 private struct PanelTransfer: Codable {
388 var board: Board
389 var rasterPNG: Data?
390 }
391
392 /// Copies both a flattened PNG (for other apps) and full panel metadata
393 /// (shapes + raster) for pasting into another panel.
394 func copyPanel(_ board: Board) {
395 let pb = NSPasteboard.general
396 pb.clearContents()
397 var transfer = PanelTransfer(board: board)
398 transfer.rasterPNG = rasterPNGData(board.id)
399 if let data = try? JSONEncoder().encode(transfer) {
400 pb.setData(data, forType: Self.pasteboardType)
401 }
402 let composite = composite(for: board)
403 if let tiff = composite.tiffRepresentation,
404 let rep = NSBitmapImageRep(data: tiff),
405 let png = rep.representation(using: .png, properties: [:]) {
406 pb.setData(png, forType: .png)
407 }
408 }
409
410 /// Returns a new board built from the pasteboard: full panel metadata
411 /// when present, else any image becomes the raster layer.
412 func panelFromPasteboard(size fallbackSize: CGSize) -> Board? {
413 let pb = NSPasteboard.general
414 if let data = pb.data(forType: Self.pasteboardType),
415 let transfer = try? JSONDecoder().decode(PanelTransfer.self, from: data) {
416 var board = transfer.board
417 board.id = UUID()
418 board.revision = 0
419 if let png = transfer.rasterPNG {
420 setRaster(fromPNG: png, boardId: board.id)
421 }
422 return board
423 }
424 if let img = NSImage(pasteboard: pb) {
425 var board = Board()
426 board.width = Double(fallbackSize.width)
427 board.height = Double(fallbackSize.height)
428 saveRaster(scaled(img, to: board.size), boardId: board.id)
429 return board
430 }
431 return nil
432 }
433
434 private func scaled(_ img: NSImage, to size: CGSize) -> NSImage {
435 NSImage(size: size, flipped: false) { rect in
436 let s = img.size
437 guard s.width > 0, s.height > 0 else { return true }
438 let scale = min(rect.width / s.width, rect.height / s.height)
439 let w = s.width * scale, h = s.height * scale
440 img.draw(in: NSRect(x: rect.midX - w / 2, y: rect.midY - h / 2,
441 width: w, height: h))
442 return true
443 }
444 }
445}
446
447/// AT Name Sans lives on the NAS; copy the needed weights into Application
448/// Support once so text keeps rendering with the share unmounted, then
449/// register from there.
450func registerBoardFonts() {
451 let fm = FileManager.default
452 let fontsDir = FileManager.default.urls(for: .applicationSupportDirectory,
453 in: .userDomainMask)[0]
454 .appendingPathComponent("Sequencer/Fonts", isDirectory: true)
455 try? fm.createDirectory(at: fontsDir, withIntermediateDirectories: true)
456 let source = URL(fileURLWithPath:
457 "/Volumes/clover/Documents/Font/ArrowType/AT Name Sans Standard")
458 for weight in ["Regular", "Medium", "Bold"] {
459 let name = "ATNameSansStandard-\(weight).otf"
460 let local = fontsDir.appendingPathComponent(name)
461 if !fm.fileExists(atPath: local.path) {
462 try? fm.copyItem(at: source.appendingPathComponent(name), to: local)
463 }
464 if fm.fileExists(atPath: local.path) {
465 CTFontManagerRegisterFontsForURL(local as CFURL, .process, nil)
466 }
467 }
468}
sequencer/Sources/Sequencer/StoryboardEditor.swift created+924
...@@ -0,0 +1,924 @@
1import AppKit
2
3/// Storyboard panel editor: a canvas you draw on. Sketch tools (pencil, pen,
4/// thick pen, eraser) paint the raster layer; the shape tool family (rect,
5/// oval, triangle, star, n-gon, text, image ref) adds editable objects to a
6/// vector layer that renders below the raster unless brought to top.
7enum BoardTool: CaseIterable {
8 case select, pencil, pen, thick, eraser,
9 rect, oval, triangle, star, ngon, text, image
10
11 var label: String {
12 switch self {
13 case .select: return "Select"
14 case .pencil: return "Pencil"
15 case .pen: return "Pen"
16 case .thick: return "Thick Pen"
17 case .eraser: return "Eraser"
18 case .rect: return "Rectangle"
19 case .oval: return "Oval"
20 case .triangle: return "Triangle"
21 case .star: return "Star"
22 case .ngon: return "N-gon"
23 case .text: return "Text"
24 case .image: return "Image"
25 }
26 }
27 var symbol: String {
28 switch self {
29 case .select: return "cursorarrow"
30 case .pencil: return "pencil"
31 case .pen: return "pencil.tip"
32 case .thick: return "paintbrush.pointed.fill"
33 case .eraser: return "eraser"
34 case .rect: return "rectangle"
35 case .oval: return "oval"
36 case .triangle: return "triangle"
37 case .star: return "star"
38 case .ngon: return "pentagon"
39 case .text: return "textformat"
40 case .image: return "photo"
41 }
42 }
43 var strokeWidth: CGFloat? {
44 switch self {
45 case .pencil: return 2
46 case .pen: return 4.5
47 case .thick: return 11
48 case .eraser: return 26
49 default: return nil
50 }
51 }
52 var isDraw: Bool { strokeWidth != nil }
53 var isShape: Bool {
54 [.rect, .oval, .triangle, .star, .ngon, .text, .image].contains(self)
55 }
56 var shapeKind: BoardShape.Kind? {
57 switch self {
58 case .rect: return .rect
59 case .oval: return .oval
60 case .triangle: return .triangle
61 case .star: return .star
62 case .ngon: return .ngon
63 case .text: return .text
64 case .image: return .image
65 default: return nil
66 }
67 }
68}
69
70/// Button that fires on mouse-down without a cell tracking loop. A tracking
71/// loop that loses its mouse-up (synthetic events, activation clicks) wedges
72/// and swallows every later click in the window; a palette button has no
73/// business tracking anyway.
74final class InstantButton: NSButton {
75 var togglesState = false
76 /// Instant themed tooltip text (no system hover delay).
77 var tipText: String? { didSet { updateTrackingAreas() } }
78 /// Fired on mouse-enter (the toolbar color swatch opens its picker here).
79 var onHover: (() -> Void)?
80
81 /// When set, the button reports a fixed NxN intrinsic size, so its bounds —
82 /// and the rounded highlight background that fills them — stay square no
83 /// matter the glyph's aspect ratio. A plain size constraint isn't enough:
84 /// the glyph-derived intrinsic size fights it and Auto Layout breaks the
85 /// constraint per-button, so wide symbols (film, scissors) render as
86 /// rectangles while square images (the magnet) look fine. Fixing the
87 /// intrinsic size removes the conflict at the source.
88 var squareSide: CGFloat? { didSet { invalidateIntrinsicContentSize() } }
89 override var intrinsicContentSize: NSSize {
90 if let s = squareSide { return NSSize(width: s, height: s) }
91 return super.intrinsicContentSize
92 }
93
94 override func mouseDown(with event: NSEvent) {
95 guard isEnabled else { return }
96 InstantTip.hide()
97 if togglesState { state = state == .on ? .off : .on }
98 if let action { NSApp.sendAction(action, to: target, from: self) }
99 }
100
101 override func updateTrackingAreas() {
102 super.updateTrackingAreas()
103 trackingAreas.filter { $0.owner === self }.forEach(removeTrackingArea)
104 if tipText != nil || onHover != nil {
105 addTrackingArea(NSTrackingArea(
106 rect: bounds,
107 options: [.mouseEnteredAndExited, .activeAlways, .inVisibleRect],
108 owner: self, userInfo: nil))
109 }
110 }
111
112 override func mouseEntered(with event: NSEvent) {
113 // Moving onto any other button dismisses an open color picker.
114 ColorPickerPanel.close(unlessAnchor: self)
115 if isEnabled, let tip = tipText { InstantTip.show(tip, for: self) }
116 if isEnabled { onHover?() }
117 }
118 override func mouseExited(with event: NSEvent) {
119 InstantTip.hide()
120 }
121}
122
123final class StoryboardEditor: NSObject, NSWindowDelegate {
124 static let shared = StoryboardEditor()
125 static let windowID = NSUserInterfaceItemIdentifier("StoryboardEditor")
126
127 private(set) var window: NSWindow?
128 private var canvas: BoardCanvas?
129 private var toolButtons: [BoardTool: NSButton] = [:]
130 private var sidesPopup: NSPopUpButton?
131 private var fillCheck: NSButton?
132 private var colorWell: NSColorWell?
133
134 var isKeyEditor: Bool { window != nil && NSApp.keyWindow === window }
135 var canUndoRaster: Bool { isKeyEditor && (canvas?.canUndoRaster ?? false) }
136
137 /// Raster strokes undo in their own lane while the editor is key; shape
138 /// edits ride the global Store undo like everything else.
139 func undoRasterIfKey() -> Bool {
140 guard canUndoRaster, let canvas else { return false }
141 canvas.undoRaster()
142 return true
143 }
144
145 /// Open on a panel belonging to `ctx`'s document. The single editor window
146 /// re-targets to whichever document asked for it.
147 func open(clipId: UUID, ctx: DocumentContext) {
148 buildWindowIfNeeded()
149 canvas?.ctx = ctx
150 canvas?.clipId = clipId
151 syncToolbar()
152 window?.makeKeyAndOrderFront(nil)
153 if let canvas { window?.makeFirstResponder(canvas) }
154 }
155
156 func windowWillClose(_ notification: Notification) {
157 canvas?.commitTextEditing()
158 }
159
160 // MARK: - UI construction
161
162 private func buildWindowIfNeeded() {
163 guard window == nil else { return }
164 let w = NSWindow(
165 contentRect: NSRect(x: 0, y: 0, width: 1120, height: 780),
166 styleMask: [.titled, .closable, .resizable],
167 backing: .buffered, defer: false)
168 w.title = "Storyboard"
169 w.identifier = Self.windowID
170 w.isReleasedWhenClosed = false
171 w.minSize = NSSize(width: 760, height: 520)
172 w.setFrameAutosaveName("StoryboardEditor")
173 w.delegate = self
174
175 let canvas = BoardCanvas()
176 self.canvas = canvas
177 canvas.onToolChanged = { [weak self] in self?.syncToolbar() }
178
179 let bar = buildToolbar()
180 let content = NSView()
181 bar.translatesAutoresizingMaskIntoConstraints = false
182 canvas.translatesAutoresizingMaskIntoConstraints = false
183 content.addSubview(bar)
184 content.addSubview(canvas)
185 NSLayoutConstraint.activate([
186 bar.topAnchor.constraint(equalTo: content.topAnchor),
187 bar.leadingAnchor.constraint(equalTo: content.leadingAnchor),
188 bar.trailingAnchor.constraint(equalTo: content.trailingAnchor),
189 bar.heightAnchor.constraint(equalToConstant: 38),
190 canvas.topAnchor.constraint(equalTo: bar.bottomAnchor),
191 canvas.leadingAnchor.constraint(equalTo: content.leadingAnchor),
192 canvas.trailingAnchor.constraint(equalTo: content.trailingAnchor),
193 canvas.bottomAnchor.constraint(equalTo: content.bottomAnchor),
194 ])
195 w.contentView = content
196 window = w
197 }
198
199 private func buildToolbar() -> NSView {
200 let bar = NSView()
201 bar.wantsLayer = true
202 bar.layer?.backgroundColor = Theme.barBg.cgColor
203 NotificationCenter.default.addObserver(forName: .themeChanged, object: nil,
204 queue: .main) { [weak bar] _ in
205 bar?.layer?.backgroundColor = Theme.barBg.cgColor
206 }
207
208 var views: [NSView] = []
209 for tool in BoardTool.allCases {
210 let b = InstantButton(image: NSImage(systemSymbolName: tool.symbol,
211 accessibilityDescription: tool.label)
212 ?? NSImage(),
213 target: self, action: #selector(pickTool(_:)))
214 b.isBordered = false
215 b.setButtonType(.momentaryChange)
216 b.wantsLayer = true
217 b.layer?.cornerRadius = 5
218 b.widthAnchor.constraint(equalToConstant: 26).isActive = true
219 b.heightAnchor.constraint(equalToConstant: 22).isActive = true
220 b.toolTip = tool.label
221 toolButtons[tool] = b
222 views.append(b)
223 if tool == .eraser {
224 let sep = NSBox()
225 sep.boxType = .separator
226 views.append(sep)
227 }
228 }
229
230 let sep2 = NSBox(); sep2.boxType = .separator
231 views.append(sep2)
232
233 // Basic palette + full picker.
234 let palette: [NSColor] = [.black, .white, .systemRed, .systemOrange,
235 .systemYellow, .systemGreen, .systemBlue, .systemPurple]
236 for c in palette {
237 let b = InstantButton(title: "", target: self, action: #selector(pickColor(_:)))
238 b.isBordered = false
239 b.wantsLayer = true
240 b.layer?.backgroundColor = c.cgColor
241 b.layer?.cornerRadius = 7
242 b.layer?.borderWidth = 1
243 b.layer?.borderColor = NSColor(calibratedWhite: 0.4, alpha: 1).cgColor
244 b.widthAnchor.constraint(equalToConstant: 15).isActive = true
245 b.heightAnchor.constraint(equalToConstant: 15).isActive = true
246 views.append(b)
247 }
248 let well = NSColorWell()
249 well.color = .black
250 well.target = self
251 well.action = #selector(wellChanged(_:))
252 well.widthAnchor.constraint(equalToConstant: 34).isActive = true
253 well.heightAnchor.constraint(equalToConstant: 20).isActive = true
254 colorWell = well
255 views.append(well)
256
257 let fill = InstantButton(checkboxWithTitle: "Fill", target: self,
258 action: #selector(fillToggled(_:)))
259 fill.togglesState = true
260 fill.state = .on
261 fill.controlSize = .small
262 fillCheck = fill
263 views.append(fill)
264
265 let sides = NSPopUpButton()
266 sides.controlSize = .small
267 for n in 3...12 { sides.addItem(withTitle: "\(n)") }
268 sides.selectItem(withTitle: "5")
269 sides.target = self
270 sides.action = #selector(sidesChanged(_:))
271 sides.toolTip = "Star points / n-gon sides"
272 sidesPopup = sides
273 views.append(sides)
274
275 let sep3 = NSBox(); sep3.boxType = .separator
276 views.append(sep3)
277
278 func zButton(_ title: String, _ action: Selector, tip: String) -> NSButton {
279 let b = InstantButton(title: title, target: self, action: action)
280 b.bezelStyle = .accessoryBarAction
281 b.controlSize = .small
282 b.toolTip = tip
283 return b
284 }
285 views.append(zButton("⬇︎", #selector(sendBackward), tip: "Send backward (⌘[)"))
286 views.append(zButton("⬆︎", #selector(bringForward), tip: "Bring forward (⌘])"))
287 views.append(zButton("To Top", #selector(toggleAboveRaster),
288 tip: "Bring above the drawing layer"))
289 views.append(zButton("Clear Drawing", #selector(clearRaster),
290 tip: "Erase the whole drawing layer"))
291
292 let stack = NSStackView(views: views)
293 stack.orientation = .horizontal
294 stack.spacing = 6
295 stack.edgeInsets = NSEdgeInsets(top: 0, left: 10, bottom: 0, right: 10)
296 stack.translatesAutoresizingMaskIntoConstraints = false
297 bar.addSubview(stack)
298 NSLayoutConstraint.activate([
299 stack.leadingAnchor.constraint(equalTo: bar.leadingAnchor),
300 stack.trailingAnchor.constraint(lessThanOrEqualTo: bar.trailingAnchor),
301 stack.topAnchor.constraint(equalTo: bar.topAnchor),
302 stack.bottomAnchor.constraint(equalTo: bar.bottomAnchor),
303 ])
304 return bar
305 }
306
307 private func syncToolbar() {
308 guard let canvas else { return }
309 for (tool, b) in toolButtons {
310 let active = tool == canvas.tool
311 b.layer?.backgroundColor = active
312 ? NSColor.controlAccentColor.withAlphaComponent(0.85).cgColor
313 : NSColor.clear.cgColor
314 b.contentTintColor = active ? .white : .secondaryLabelColor
315 }
316 fillCheck?.state = canvas.fillShapes ? .on : .off
317 sidesPopup?.selectItem(withTitle: "\(canvas.currentSides)")
318 }
319
320 // MARK: - Toolbar actions
321
322 @objc private func pickTool(_ sender: NSButton) {
323 guard let tool = toolButtons.first(where: { $0.value === sender })?.key else { return }
324 canvas?.tool = tool
325 syncToolbar()
326 }
327 @objc private func pickColor(_ sender: NSButton) {
328 guard let cg = sender.layer?.backgroundColor,
329 let color = NSColor(cgColor: cg) else { return }
330 colorWell?.color = color
331 canvas?.setColor(color)
332 }
333 @objc private func wellChanged(_ sender: NSColorWell) {
334 canvas?.setColor(sender.color)
335 }
336 @objc private func fillToggled(_ sender: NSButton) {
337 canvas?.setFilled(sender.state == .on)
338 }
339 @objc private func sidesChanged(_ sender: NSPopUpButton) {
340 canvas?.setSides(Int(sender.titleOfSelectedItem ?? "5") ?? 5)
341 }
342 @objc private func sendBackward() { canvas?.reorderSelected(by: -1) }
343 @objc private func bringForward() { canvas?.reorderSelected(by: 1) }
344 @objc private func toggleAboveRaster() { canvas?.toggleSelectedAboveRaster() }
345 @objc private func clearRaster() { canvas?.clearRaster() }
346}
347
348// MARK: - Canvas
349
350final class BoardCanvas: NSView {
351 /// Document context. Facade over the shared singletons for now; injected
352 /// per-document instance later (the editor is re-targeted per document).
353 var ctx: DocumentContext = .headless
354 private var store: Store { ctx.store }
355 private var project: ProjectModel { ctx.store.project }
356 private var boards: BoardStore { ctx.boards }
357
358 var clipId: UUID? {
359 didSet {
360 if clipId != oldValue {
361 commitTextEditing()
362 strokeActive = false
363 selectedShapeId = nil
364 }
365 needsDisplay = true
366 }
367 }
368 var tool: BoardTool = .pencil {
369 didSet {
370 commitTextEditing()
371 if tool != .select { selectedShapeId = nil }
372 needsDisplay = true
373 }
374 }
375 var onToolChanged: (() -> Void)?
376 private(set) var currentColor: NSColor = .black
377 private(set) var currentSides = 5
378 private(set) var fillShapes = true
379 private var selectedShapeId: UUID?
380
381 // Raster stroke state (pixels live in BoardStore's shared stroke engine)
382 private var strokeActive = false
383 private var lastStrokePoint: CGPoint?
384 var canUndoRaster: Bool {
385 board.map { boards.canUndoStroke($0.id) } ?? false
386 }
387
388 // Shape gesture state
389 private enum ShapeDrag { case none, create, move, resize }
390 private var shapeDrag: ShapeDrag = .none
391 private var dragShapeId: UUID?
392 private var dragOrigShape: BoardShape?
393 private var dragStartBoard = CGPoint.zero
394
395 private var textEditor: NSTextField?
396 private var editingShapeId: UUID?
397
398 override var isFlipped: Bool { true }
399 override var acceptsFirstResponder: Bool { true }
400
401 override init(frame: NSRect) {
402 super.init(frame: frame)
403 wantsLayer = true
404 layer?.backgroundColor = Theme.canvasBg.cgColor
405 NotificationCenter.default.addObserver(self, selector: #selector(modelChanged),
406 name: .projectChanged, object: nil)
407 NotificationCenter.default.addObserver(self, selector: #selector(themeChanged),
408 name: .themeChanged, object: nil)
409 }
410 required init?(coder: NSCoder) { fatalError() }
411
412 @objc private func themeChanged() {
413 layer?.backgroundColor = Theme.canvasBg.cgColor
414 needsDisplay = true
415 }
416
417 @objc private func modelChanged() {
418 // Clip removed (undo past creation, delete) → close gracefully.
419 if let clipId, store.project.clip(clipId) == nil {
420 window?.performClose(nil)
421 self.clipId = nil
422 }
423 needsDisplay = true
424 }
425
426 private var board: Board? {
427 clipId.flatMap { store.project.clip($0)?.board }
428 }
429
430 // MARK: Board mutation helpers
431
432 private func mutateBoard(_ body: (inout Board) -> Void) {
433 guard let clipId else { return }
434 store.mutate { model in
435 guard let i = model.clips.firstIndex(where: { $0.id == clipId }),
436 var b = model.clips[i].board else { return }
437 body(&b)
438 b.revision += 1
439 model.clips[i].board = b
440 }
441 }
442
443 private func updateBoardGesture(_ body: (inout Board) -> Void) {
444 guard let clipId else { return }
445 store.updateGesture { model in
446 guard let i = model.clips.firstIndex(where: { $0.id == clipId }),
447 var b = model.clips[i].board else { return }
448 body(&b)
449 b.revision += 1
450 model.clips[i].board = b
451 }
452 }
453
454 // MARK: Toolbar-driven state
455
456 func setColor(_ c: NSColor) {
457 currentColor = c
458 // With the select tool, recoloring applies to the selection.
459 if tool == .select, let id = selectedShapeId {
460 let rgba = rgbaComponents(c)
461 mutateBoard { b in
462 if let i = b.shapes.firstIndex(where: { $0.id == id }) {
463 b.shapes[i].color = rgba
464 }
465 }
466 }
467 }
468
469 func setFilled(_ f: Bool) {
470 fillShapes = f
471 if tool == .select, let id = selectedShapeId {
472 mutateBoard { b in
473 if let i = b.shapes.firstIndex(where: { $0.id == id }) {
474 b.shapes[i].filled = f
475 }
476 }
477 }
478 }
479
480 func setSides(_ n: Int) {
481 currentSides = n
482 if tool == .select, let id = selectedShapeId {
483 mutateBoard { b in
484 if let i = b.shapes.firstIndex(where: { $0.id == id }) {
485 b.shapes[i].sides = n
486 }
487 }
488 }
489 }
490
491 func reorderSelected(by delta: Int) {
492 guard let id = selectedShapeId else { return }
493 mutateBoard { b in
494 guard let i = b.shapes.firstIndex(where: { $0.id == id }) else { return }
495 let j = min(max(i + delta, 0), b.shapes.count - 1)
496 guard j != i else { return }
497 let s = b.shapes.remove(at: i)
498 b.shapes.insert(s, at: j)
499 }
500 }
501
502 func toggleSelectedAboveRaster() {
503 guard let id = selectedShapeId else { return }
504 mutateBoard { b in
505 guard let i = b.shapes.firstIndex(where: { $0.id == id }) else { return }
506 b.shapes[i].aboveRaster.toggle()
507 }
508 }
509
510 func clearRaster() {
511 guard let board else { return }
512 boards.clearRaster(board: board)
513 needsDisplay = true
514 }
515
516 // MARK: Coordinates
517
518 private var boardRect: NSRect {
519 guard let board else { return .zero }
520 let inset = bounds.insetBy(dx: 14, dy: 14)
521 guard inset.width > 10, inset.height > 10 else { return .zero }
522 let scale = min(inset.width / board.size.width, inset.height / board.size.height)
523 let w = board.size.width * scale, h = board.size.height * scale
524 return NSRect(x: inset.midX - w / 2, y: inset.midY - h / 2, width: w, height: h)
525 }
526 private var boardScale: CGFloat {
527 guard let board, board.width > 0 else { return 1 }
528 return boardRect.width / CGFloat(board.width)
529 }
530 private func toBoard(_ p: NSPoint) -> CGPoint {
531 let r = boardRect
532 let s = boardScale
533 guard s > 0 else { return .zero }
534 return CGPoint(x: (p.x - r.minX) / s, y: (p.y - r.minY) / s)
535 }
536 private func toView(_ rect: CGRect) -> NSRect {
537 let r = boardRect
538 let s = boardScale
539 return NSRect(x: r.minX + rect.minX * s, y: r.minY + rect.minY * s,
540 width: rect.width * s, height: rect.height * s)
541 }
542
543 // MARK: Drawing
544
545 override func draw(_ dirtyRect: NSRect) {
546 Theme.canvasBg.setFill()
547 bounds.fill()
548 guard let board else {
549 return
550 }
551 let r = boardRect
552 NSColor.black.withAlphaComponent(0.5).setFill()
553 NSRect(x: r.minX + 3, y: r.minY + 3, width: r.width, height: r.height).fill()
554 BoardStore.paper.setFill()
555 r.fill()
556
557 // Board-space rendering with a scaled transform.
558 NSGraphicsContext.current?.saveGraphicsState()
559 NSBezierPath(rect: r).addClip()
560 let transform = NSAffineTransform()
561 transform.translateX(by: r.minX, yBy: r.minY)
562 transform.scale(by: boardScale)
563 transform.concat()
564
565 for shape in board.shapes where !shape.aboveRaster { BoardStore.draw(shape) }
566 boards.displayRaster(board.id)?
567 .draw(in: CGRect(origin: .zero, size: board.size),
568 from: .zero, operation: .sourceOver, fraction: 1,
569 respectFlipped: true, hints: nil)
570 for shape in board.shapes where shape.aboveRaster { BoardStore.draw(shape) }
571 NSGraphicsContext.current?.restoreGraphicsState()
572
573 // Selection chrome (view space).
574 if let id = selectedShapeId,
575 let shape = board.shapes.first(where: { $0.id == id }) {
576 let vr = toView(shape.frame)
577 NSColor.controlAccentColor.setStroke()
578 let sel = NSBezierPath(rect: vr)
579 sel.lineWidth = 1.5
580 sel.setLineDash([4, 3], count: 2, phase: 0)
581 sel.stroke()
582 for corner in corners(of: vr) {
583 let h = NSRect(x: corner.x - 3.5, y: corner.y - 3.5, width: 7, height: 7)
584 NSColor.white.setFill()
585 NSBezierPath(ovalIn: h).fill()
586 NSColor.controlAccentColor.setStroke()
587 NSBezierPath(ovalIn: h).stroke()
588 }
589 }
590 }
591
592 private func corners(of r: NSRect) -> [NSPoint] {
593 [NSPoint(x: r.minX, y: r.minY), NSPoint(x: r.maxX, y: r.minY),
594 NSPoint(x: r.minX, y: r.maxY), NSPoint(x: r.maxX, y: r.maxY)]
595 }
596
597 // MARK: Raster strokes (BoardStore's engine does the pixel work)
598
599 func undoRaster() {
600 guard let board else { return }
601 boards.undoStroke(board.id)
602 needsDisplay = true
603 }
604
605 private func strokeSegment(from a: CGPoint, to b: CGPoint, pressure: CGFloat) {
606 guard let board, let width = tool.strokeWidth else { return }
607 boards.strokeSegment(
608 board: board, from: a, to: b, width: width, color: currentColor,
609 erase: tool == .eraser, alpha: tool == .pencil ? 0.85 : 1,
610 pressure: pressure)
611 }
612
613 // MARK: Mouse
614
615 private func shapeAt(_ bp: CGPoint) -> BoardShape? {
616 guard let board else { return nil }
617 // Topmost first: above-raster shapes beat below, later beats earlier.
618 let ordered = Array(board.shapes.filter(\.aboveRaster).reversed())
619 + Array(board.shapes.filter { !$0.aboveRaster }.reversed())
620 return ordered.first { $0.frame.insetBy(dx: -4, dy: -4).contains(bp) }
621 }
622
623 override func mouseDown(with event: NSEvent) {
624 window?.makeFirstResponder(self)
625 commitTextEditing()
626 guard let board else { return }
627 let p = convert(event.locationInWindow, from: nil)
628 let bp = toBoard(p)
629 lastStrokePoint = bp
630 shapeDrag = .none
631
632 if tool.isDraw {
633 strokeActive = true
634 boards.beginStroke(board: board)
635 strokeSegment(from: bp, to: bp, pressure: CGFloat(event.pressure))
636 needsDisplay = true
637 return
638 }
639
640 if tool == .select {
641 if let id = selectedShapeId,
642 let shape = board.shapes.first(where: { $0.id == id }) {
643 let vr = toView(shape.frame)
644 if corners(of: vr).contains(where: { hypot($0.x - p.x, $0.y - p.y) < 7 }) {
645 shapeDrag = .resize
646 dragShapeId = id
647 dragOrigShape = shape
648 dragStartBoard = bp
649 store.beginGesture()
650 return
651 }
652 }
653 if let hit = shapeAt(bp) {
654 selectedShapeId = hit.id
655 if event.clickCount == 2, hit.kind == .text {
656 beginTextEditing(hit)
657 return
658 }
659 shapeDrag = .move
660 dragShapeId = hit.id
661 dragOrigShape = hit
662 dragStartBoard = bp
663 store.beginGesture()
664 } else {
665 selectedShapeId = nil
666 }
667 needsDisplay = true
668 return
669 }
670
671 if tool == .image {
672 insertImageShape(at: bp)
673 return
674 }
675
676 if let kind = tool.shapeKind {
677 var shape = BoardShape(kind: kind,
678 frame: CGRect(x: bp.x, y: bp.y, width: 1, height: 1))
679 shape.color = rgbaComponents(currentColor)
680 shape.sides = kind == .star ? max(3, currentSides) : currentSides
681 shape.filled = fillShapes
682 if kind == .text {
683 // Text places at a fixed size and edits immediately — typing
684 // should never fall through to tool shortcuts.
685 shape.frame = CGRect(x: bp.x, y: bp.y - 35, width: 420, height: 70)
686 shape.fontSize = 48
687 let new = shape
688 mutateBoard { $0.shapes.append(new) }
689 selectedShapeId = new.id
690 needsDisplay = true
691 beginTextEditing(new)
692 return
693 }
694 // Click-drag sizes the other shapes.
695 shapeDrag = .create
696 dragShapeId = shape.id
697 dragOrigShape = shape
698 dragStartBoard = bp
699 selectedShapeId = shape.id
700 store.beginGesture()
701 let new = shape
702 updateBoardGesture { $0.shapes.append(new) }
703 }
704 }
705
706 override func mouseDragged(with event: NSEvent) {
707 let p = convert(event.locationInWindow, from: nil)
708 let bp = toBoard(p)
709
710 if tool.isDraw {
711 if strokeActive, let last = lastStrokePoint {
712 strokeSegment(from: last, to: bp, pressure: CGFloat(event.pressure))
713 }
714 lastStrokePoint = bp
715 needsDisplay = true
716 return
717 }
718
719 let square = event.modifierFlags.contains(.shift)
720 guard let id = dragShapeId, let orig = dragOrigShape else { return }
721 switch shapeDrag {
722 case .create:
723 var w = max(4, abs(bp.x - dragStartBoard.x))
724 var h = max(4, abs(bp.y - dragStartBoard.y))
725 if square { w = max(w, h); h = w } // ⇧ = square / circle / regular
726 let frame = CGRect(x: bp.x < dragStartBoard.x ? dragStartBoard.x - w : dragStartBoard.x,
727 y: bp.y < dragStartBoard.y ? dragStartBoard.y - h : dragStartBoard.y,
728 width: w, height: h)
729 var shape = orig
730 if orig.kind != .text { shape.frame = frame }
731 else { shape.frame.origin = CGPoint(x: bp.x, y: bp.y) }
732 updateBoardGesture { b in
733 if let i = b.shapes.firstIndex(where: { $0.id == id }) { b.shapes[i] = shape }
734 else { b.shapes.append(shape) }
735 }
736 case .move:
737 let dx = bp.x - dragStartBoard.x, dy = bp.y - dragStartBoard.y
738 updateBoardGesture { b in
739 guard let i = b.shapes.firstIndex(where: { $0.id == id }) else { return }
740 b.shapes[i].frame.origin = CGPoint(x: orig.frame.minX + dx,
741 y: orig.frame.minY + dy)
742 }
743 case .resize:
744 // Resize relative to the corner opposite the grabbed one.
745 let f = orig.frame
746 let anchors = [CGPoint(x: f.maxX, y: f.maxY), CGPoint(x: f.minX, y: f.maxY),
747 CGPoint(x: f.maxX, y: f.minY), CGPoint(x: f.minX, y: f.minY)]
748 let grabbed = [CGPoint(x: f.minX, y: f.minY), CGPoint(x: f.maxX, y: f.minY),
749 CGPoint(x: f.minX, y: f.maxY), CGPoint(x: f.maxX, y: f.maxY)]
750 let idx = grabbed.enumerated().min {
751 hypot($0.1.x - dragStartBoard.x, $0.1.y - dragStartBoard.y)
752 < hypot($1.1.x - dragStartBoard.x, $1.1.y - dragStartBoard.y)
753 }?.0 ?? 3
754 let anchor = anchors[idx]
755 var w = max(4, abs(bp.x - anchor.x))
756 var h = max(4, abs(bp.y - anchor.y))
757 if square, orig.frame.height > 0 {
758 // ⇧ = preserve the shape's aspect while resizing.
759 let aspect = orig.frame.width / orig.frame.height
760 if w / max(1, h) > aspect { h = w / aspect } else { w = h * aspect }
761 }
762 let frame = CGRect(x: bp.x < anchor.x ? anchor.x - w : anchor.x,
763 y: bp.y < anchor.y ? anchor.y - h : anchor.y,
764 width: w, height: h)
765 updateBoardGesture { b in
766 guard let i = b.shapes.firstIndex(where: { $0.id == id }) else { return }
767 b.shapes[i].frame = frame
768 if b.shapes[i].kind == .text {
769 b.shapes[i].fontSize = max(8, Double(frame.height) * 0.66)
770 }
771 }
772 case .none:
773 break
774 }
775 needsDisplay = true
776 }
777
778 override func mouseUp(with event: NSEvent) {
779 if tool.isDraw {
780 if strokeActive, let board { boards.endStroke(board: board) }
781 strokeActive = false
782 lastStrokePoint = nil
783 needsDisplay = true
784 return
785 }
786 if shapeDrag != .none {
787 let placed = shapeDrag == .create
788 store.endGesture()
789 shapeDrag = .none
790 dragShapeId = nil
791 dragOrigShape = nil
792 if placed {
793 // Placing a shape hands you the select tool to adjust it.
794 tool = .select
795 onToolChanged?()
796 }
797 needsDisplay = true
798 }
799 }
800
801 /// Right-click: radial quick picker (tools around the cursor, colors inside).
802 override func rightMouseDown(with event: NSEvent) {
803 let screenPoint = window?.convertPoint(toScreen: event.locationInWindow) ?? .zero
804 RadialPicker.show(at: screenPoint, currentTool: tool, currentColor: currentColor,
805 onTool: { [weak self] t in
806 self?.tool = t
807 self?.onToolChanged?()
808 },
809 onColor: { [weak self] c in
810 self?.setColor(c)
811 self?.onToolChanged?()
812 })
813 }
814
815 // MARK: Text editing
816
817 private func beginTextEditing(_ shape: BoardShape) {
818 commitTextEditing()
819 let field = NSTextField(string: shape.text.isEmpty ? "Text" : shape.text)
820 field.frame = toView(shape.frame)
821 field.font = BoardStore.boardFont(size: shape.fontSize * boardScale)
822 field.textColor = BoardStore.color(shape.color)
823 field.backgroundColor = NSColor.white.withAlphaComponent(0.85)
824 field.isBordered = true
825 field.focusRingType = .default
826 field.target = self
827 field.action = #selector(textCommitted)
828 addSubview(field)
829 window?.makeFirstResponder(field)
830 textEditor = field
831 editingShapeId = shape.id
832 }
833
834 @objc private func textCommitted() { commitTextEditing() }
835
836 func commitTextEditing() {
837 guard let field = textEditor, let id = editingShapeId else { return }
838 let text = field.stringValue
839 field.removeFromSuperview()
840 textEditor = nil
841 editingShapeId = nil
842 mutateBoard { b in
843 guard let i = b.shapes.firstIndex(where: { $0.id == id }) else { return }
844 b.shapes[i].text = text
845 }
846 window?.makeFirstResponder(self)
847 needsDisplay = true
848 }
849
850 private func insertImageShape(at bp: CGPoint) {
851 let panel = NSOpenPanel()
852 panel.allowedContentTypes = [.image]
853 guard panel.runModal() == .OK, let url = panel.url, let board else { return }
854 var w = board.width * 0.35
855 var h = w * 0.66
856 if let img = NSImage(contentsOf: url), img.size.width > 0 {
857 h = w * Double(img.size.height / img.size.width)
858 }
859 w = min(w, board.width); h = min(h, board.height)
860 var shape = BoardShape(kind: .image, frame:
861 CGRect(x: bp.x - w / 2, y: bp.y - h / 2, width: w, height: h))
862 shape.imagePath = url.path
863 shape.color = rgbaComponents(currentColor)
864 let new = shape
865 mutateBoard { $0.shapes.append(new) }
866 selectedShapeId = new.id
867 tool = .select
868 onToolChanged?()
869 }
870
871 // MARK: Keyboard
872
873 override func keyDown(with event: NSEvent) {
874 switch event.charactersIgnoringModifiers?.lowercased() {
875 case "v" where !event.modifierFlags.contains(.command): tool = .select
876 case "p" where !event.modifierFlags.contains(.command): tool = .pencil
877 case "e" where !event.modifierFlags.contains(.command): tool = .eraser
878 case "]" where event.modifierFlags.contains(.command): reorderSelected(by: 1)
879 case "[" where event.modifierFlags.contains(.command): reorderSelected(by: -1)
880 case "\u{1b}":
881 if textEditor != nil { commitTextEditing() } else { selectedShapeId = nil }
882 needsDisplay = true
883 default:
884 switch event.keyCode {
885 case 51, 117: // ⌫, ⌦ — delete selected shape
886 if let id = selectedShapeId {
887 mutateBoard { $0.shapes.removeAll { $0.id == id } }
888 selectedShapeId = nil
889 } else {
890 super.keyDown(with: event)
891 }
892 default: super.keyDown(with: event)
893 }
894 }
895 onToolChanged?()
896 }
897
898 // MARK: Panel copy/paste while the editor is key
899
900 @objc func copy(_ sender: Any?) {
901 guard let board else { return }
902 boards.copyPanel(board)
903 NotificationCenter.default.post(name: .transientStatus, object: nil,
904 userInfo: ["text": "Copied panel (image + layers)"])
905 }
906
907 @objc func paste(_ sender: Any?) {
908 guard let clipId, let board else { return }
909 guard let new = boards.panelFromPasteboard(size: board.size) else { return }
910 store.mutate { model in
911 guard let i = model.clips.firstIndex(where: { $0.id == clipId }) else { return }
912 model.clips[i].board = new
913 }
914 strokeActive = false
915 selectedShapeId = nil
916 needsDisplay = true
917 }
918}
919
920private func rgbaComponents(_ color: NSColor) -> [Double] {
921 let c = color.usingColorSpace(.sRGB) ?? color
922 return [Double(c.redComponent), Double(c.greenComponent),
923 Double(c.blueComponent), Double(c.alphaComponent)]
924}
sequencer/Sources/Sequencer/SyncImport.swift created+28
...@@ -0,0 +1,28 @@
1import Foundation
2
3/// A recorder `sync.json` manifest. Each stream names a file and carries the
4/// relative `offsetSeconds` (from the session's clock start) that keeps the
5/// streams aligned. Importing the manifest (or a folder holding it, or the
6/// media files that sit beside it) places each clip at its offset so the whole
7/// multicam session lands in sync.
8struct SyncManifest: Decodable {
9 struct Stream: Decodable {
10 var file: String
11 var offsetSeconds: Double?
12 }
13 var streams: [Stream]
14
15 /// Load a manifest only if `url` is a readable JSON with a streams array.
16 static func load(_ url: URL) -> SyncManifest? {
17 guard let data = try? Data(contentsOf: url),
18 let m = try? JSONDecoder().decode(SyncManifest.self, from: data),
19 !m.streams.isEmpty else { return nil }
20 return m
21 }
22
23 /// filename → offset seconds (missing offsets count as 0).
24 var offsetsByFile: [String: Double] {
25 Dictionary(streams.map { ($0.file, $0.offsetSeconds ?? 0) },
26 uniquingKeysWith: { first, _ in first })
27 }
28}
sequencer/Sources/Sequencer/Theme.swift created+87
...@@ -0,0 +1,87 @@
1import AppKit
2
3extension Notification.Name {
4 static let themeChanged = Notification.Name("themeChanged")
5}
6
7/// App-wide light/dark theme — follows the SYSTEM appearance (no setting).
8/// Custom views draw from these; system controls come along for free.
9enum Theme {
10 static var light: Bool {
11 NSApp.effectiveAppearance.bestMatch(from: [.aqua, .darkAqua]) == .aqua
12 }
13
14 private static var observation: NSKeyValueObservation?
15
16 /// Start following the system appearance; custom views redraw on change.
17 static func startObserving() {
18 observation = NSApp.observe(\.effectiveAppearance) { _, _ in
19 DispatchQueue.main.async {
20 NotificationCenter.default.post(name: .themeChanged, object: nil)
21 NotificationCenter.default.post(name: .viewOptionsChanged, object: nil)
22 }
23 }
24 }
25
26 private static func pick(_ dark: NSColor, _ lightC: NSColor) -> NSColor {
27 light ? lightC : dark
28 }
29
30 /// Selected-clip outline: white reads well on dark, the accent color on
31 /// light (white vanishes against light lanes).
32 static var selection: NSColor {
33 pick(.white, .controlAccentColor)
34 }
35
36 // Surfaces
37 static var timelineBg: NSColor {
38 pick(NSColor(calibratedWhite: 0.10, alpha: 1), NSColor(calibratedWhite: 0.91, alpha: 1))
39 }
40 static var laneBg: NSColor {
41 pick(NSColor(calibratedWhite: 0.145, alpha: 1), NSColor(calibratedWhite: 0.85, alpha: 1))
42 }
43 /// Storyboard track lane — a cool tint so it reads as its own strip apart
44 /// from the neutral video/audio lanes.
45 static var storyboardLaneBg: NSColor {
46 pick(NSColor(calibratedHue: 0.60, saturation: 0.28, brightness: 0.20, alpha: 1),
47 NSColor(calibratedHue: 0.60, saturation: 0.14, brightness: 0.78, alpha: 1))
48 }
49 static var rulerBg: NSColor {
50 pick(NSColor(calibratedWhite: 0.13, alpha: 1), NSColor(calibratedWhite: 0.88, alpha: 1))
51 }
52 static var barBg: NSColor {
53 pick(NSColor(calibratedWhite: 0.13, alpha: 1), NSColor(calibratedWhite: 0.93, alpha: 1))
54 }
55 static var viewerBg: NSColor {
56 pick(NSColor(calibratedWhite: 0.06, alpha: 1), NSColor(calibratedWhite: 0.80, alpha: 1))
57 }
58 static var canvasBg: NSColor {
59 pick(NSColor(calibratedWhite: 0.12, alpha: 1), NSColor(calibratedWhite: 0.82, alpha: 1))
60 }
61
62 // Lines & text
63 static var rulerLine: NSColor {
64 pick(NSColor(calibratedWhite: 0.22, alpha: 1), NSColor(calibratedWhite: 0.62, alpha: 1))
65 }
66 static var tickMajor: NSColor {
67 pick(NSColor(calibratedWhite: 0.35, alpha: 1), NSColor(calibratedWhite: 0.45, alpha: 1))
68 }
69 static var tickMinor: NSColor {
70 pick(NSColor(calibratedWhite: 0.24, alpha: 1), NSColor(calibratedWhite: 0.68, alpha: 1))
71 }
72 static var label: NSColor {
73 pick(NSColor(calibratedWhite: 0.9, alpha: 1), NSColor(calibratedWhite: 0.12, alpha: 1))
74 }
75 static var subtleLabel: NSColor {
76 pick(NSColor(calibratedWhite: 0.55, alpha: 1), NSColor(calibratedWhite: 0.40, alpha: 1))
77 }
78 static var faintLabel: NSColor {
79 pick(NSColor(calibratedWhite: 0.5, alpha: 1), NSColor(calibratedWhite: 0.45, alpha: 1))
80 }
81 static var clipTitle: NSColor {
82 NSColor(calibratedWhite: 0.92, alpha: 1) // titles sit on dark strips in both modes
83 }
84 static var dragHint: NSColor {
85 pick(NSColor(calibratedWhite: 0.3, alpha: 1), NSColor(calibratedWhite: 0.55, alpha: 1))
86 }
87}
sequencer/Sources/Sequencer/TimelineView.swift created+2951
...@@ -0,0 +1,2951 @@
1import AppKit
2
3/// The timeline: ruler, Fusion comps band, track lanes, clips, playhead.
4/// Tracks are unnamed and color-coded; new tracks appear dynamically when a
5/// clip is dragged below the last lane (two rows down = two new tracks).
6/// Empty tracks are allowed. All edits are frame-quantized and run through
7/// Store gestures so every drag is one undo step.
8final class TimelineView: NSView {
9
10 // View state
11 private var pxPerSecond: Double = 20
12 private var originSecond: Double = -1
13 private var scrollY: CGFloat = 0 // vertical track scroll offset
14 private let rulerH: CGFloat = 26
15 private let baseLaneH: CGFloat = 64
16 private let laneGap: CGFloat = 4
17 private let headerW: CGFloat = 26
18
19 /// The document context this view belongs to — its store, playback clock,
20 /// comps scanner and storyboard rasters. A thin facade over the shared
21 /// singletons for now; becomes an injected per-document instance later.
22 var ctx: DocumentContext = .headless {
23 didSet {
24 guard oldValue !== ctx else { return }
25 // `.playheadChanged` is per-document (on ctx.notify) — re-point it
26 // when the document context is injected.
27 oldValue.notify.removeObserver(self, name: .playheadChanged, object: nil)
28 ctx.notify.addObserver(self, selector: #selector(playheadMoved),
29 name: .playheadChanged, object: nil)
30 }
31 }
32 private var store: Store { ctx.store }
33 private var project: ProjectModel { ctx.store.project }
34 private var playback: PlaybackController { ctx.playback }
35 private var comps: FusionComps { ctx.comps }
36 private var boards: BoardStore { ctx.boards }
37 private var session: SessionState { ctx.session }
38
39 override var isFlipped: Bool { true }
40 override var acceptsFirstResponder: Bool { true }
41
42 // MARK: - Init
43
44 override init(frame: NSRect) {
45 super.init(frame: frame)
46 registerForDraggedTypes([.fileURL])
47 for name: Notification.Name in [.projectChanged, .selectionChanged,
48 .mediaStatusChanged, .viewOptionsChanged,
49 .compsChanged] {
50 NotificationCenter.default.addObserver(self, selector: #selector(redraw),
51 name: name, object: nil)
52 }
53 // Per-document: bound against the current (headless) ctx here, re-bound
54 // when a real ctx is injected (see `ctx.didSet`).
55 ctx.notify.addObserver(self, selector: #selector(playheadMoved),
56 name: .playheadChanged, object: nil)
57 NotificationCenter.default.addObserver(self, selector: #selector(revealClip(_:)),
58 name: .revealClip, object: nil)
59 }
60
61 required init?(coder: NSCoder) { fatalError() }
62
63 @objc private func redraw() { needsDisplay = true }
64
65 @objc private func playheadMoved() {
66 // Auto-follow while playing.
67 if playback.isPlaying {
68 let x = xFor(playback.playhead)
69 if x > bounds.width - 60 || x < headerW {
70 originSecond = playback.playhead
71 - 0.1 * Double(bounds.width) / pxPerSecond
72 }
73 }
74 needsDisplay = true
75 }
76
77 /// Scroll so a clip is on screen (viewer cells post this on click).
78 @objc private func revealClip(_ note: Notification) {
79 guard let id = note.userInfo?["clipId"] as? UUID,
80 let clip = project.clip(id) else { return }
81 let x0 = xFor(clip.start), x1 = xFor(clip.end)
82 if x1 < headerW + 20 || x0 > bounds.width - 20 {
83 originSecond = clip.start - 0.15 * Double(bounds.width) / pxPerSecond
84 }
85 needsDisplay = true
86 }
87
88 // MARK: - Coordinates
89
90 private func xFor(_ seconds: Double) -> CGFloat {
91 headerW + CGFloat((seconds - originSecond) * pxPerSecond)
92 }
93 private func secondsFor(_ x: CGFloat) -> Double {
94 originSecond + Double(x - headerW) / pxPerSecond
95 }
96 private func quantize(_ seconds: Double) -> Double {
97 let fps = project.fps
98 return (seconds * fps).rounded() / fps
99 }
100 private var frameDur: Double { 1.0 / project.fps }
101
102 private var fusionBandH: CGFloat { comps.visible ? 46 : 0 }
103 private var lanesTop: CGFloat { rulerH + fusionBandH }
104
105 private func laneHeight(_ ref: TrackRef) -> CGFloat {
106 max(24, baseLaneH * session.laneScale * (session.trackHeights[ref] ?? 1))
107 }
108 private var defaultLaneH: CGFloat { max(24, baseLaneH * session.laneScale) }
109
110 /// Total height of all lanes (for vertical scroll clamping).
111 private var lanesContentHeight: CGFloat {
112 project.laneRefs.reduce(laneGap) { $0 + laneHeight($1) + laneGap }
113 }
114 private var maxScrollY: CGFloat {
115 max(0, lanesContentHeight - (bounds.height - lanesTop) + defaultLaneH)
116 }
117
118 private func laneRect(row: Int) -> NSRect {
119 let rows = project.laneRefs
120 var y = lanesTop + laneGap - scrollY
121 for (i, ref) in rows.enumerated() {
122 let h = laneHeight(ref)
123 if i == row {
124 return NSRect(x: 0, y: y, width: bounds.width, height: h)
125 }
126 y += h + laneGap
127 }
128 let extra = CGFloat(row - rows.count)
129 return NSRect(x: 0, y: y + extra * (defaultLaneH + laneGap),
130 width: bounds.width, height: defaultLaneH)
131 }
132
133 private func rowAt(y: CGFloat) -> Int? {
134 guard y > lanesTop else { return nil }
135 var yy = lanesTop + laneGap - scrollY
136 let rows = project.laneRefs
137 for (i, ref) in rows.enumerated() {
138 let h = laneHeight(ref)
139 if y < yy + h + laneGap { return i }
140 yy += h + laneGap
141 }
142 return rows.count + max(0, Int((y - yy) / (defaultLaneH + laneGap)))
143 }
144
145 /// Row whose bottom edge is under the cursor (for track-height resizing).
146 private func trackBoundaryAt(y: CGFloat) -> Int? {
147 guard y > lanesTop else { return nil }
148 let rows = project.laneRefs
149 var yy = lanesTop + laneGap - scrollY
150 for (i, ref) in rows.enumerated() {
151 yy += laneHeight(ref)
152 if abs(y - yy) <= 4 { return i }
153 yy += laneGap
154 }
155 return nil
156 }
157
158 private func clipRect(_ clip: Clip, row: Int) -> NSRect {
159 let lane = laneRect(row: row)
160 let x0 = xFor(clip.start), x1 = xFor(clip.end)
161 return NSRect(x: x0, y: lane.minY, width: max(2, x1 - x0), height: lane.height)
162 }
163
164 /// The lane shown at a row, or nil past the last real lane (ghost rows).
165 private func laneRef(row: Int) -> TrackRef? {
166 let rows = project.laneRefs
167 return rows.indices.contains(row) ? rows[row] : nil
168 }
169
170 private func clipAt(point: NSPoint) -> (clip: Clip, row: Int)? {
171 guard let row = rowAt(y: point.y), let ref = laneRef(row: row) else { return nil }
172 // Later clips draw on top, so hit-test in reverse.
173 for clip in project.clips.filter({ $0.track == ref })
174 .sorted(by: { $0.start < $1.start }).reversed() {
175 if clipRect(clip, row: row).contains(point) { return (clip, row) }
176 }
177 return nil
178 }
179
180 private func overlapAt(point: NSPoint) -> ClipOverlap? {
181 guard let row = rowAt(y: point.y), let ref = laneRef(row: row) else { return nil }
182 for o in project.overlaps(on: ref) {
183 let lane = laneRect(row: row)
184 let r = NSRect(x: xFor(o.start), y: lane.minY,
185 width: max(2, xFor(o.end) - xFor(o.start)), height: lane.height)
186 if r.contains(point) { return o }
187 }
188 return nil
189 }
190
191 // MARK: - Drawing
192
193 private var panelNamesCache: [UUID: String] = [:]
194 private var linkedSelectionCache: Set<UUID> = []
195
196 override func draw(_ dirtyRect: NSRect) {
197 Theme.timelineBg.setFill()
198 bounds.fill()
199
200 panelNamesCache = project.panelNames()
201 // Link-mates of the selection get an aqua outline (they act selected).
202 linkedSelectionCache = project.expandLinks(store.selection)
203 .subtracting(store.selection)
204 scrollY = min(scrollY, maxScrollY)
205 let rows = project.laneRefs
206 for row in 0..<rows.count {
207 drawLane(row: row, ref: rows[row])
208 }
209 drawDragHintLanes()
210 drawFileDropPreview()
211 drawFusionBand()
212 // Opaque header column so clips never show behind the buttons.
213 Theme.timelineBg.setFill()
214 NSRect(x: 0, y: rulerH, width: headerW, height: bounds.height - rulerH).fill()
215 drawFusionHeader()
216 for row in 0..<rows.count {
217 drawTrackHeader(row: row, ref: rows[row], lane: laneRect(row: row))
218 }
219 drawRuler()
220 drawInOut()
221 drawMarkers()
222 drawSnapIndicator()
223 drawBoxSelect()
224 drawPlayhead()
225 drawScrollbars()
226 }
227
228 // MARK: - Scroll/zoom bars
229 // Floating bars: drag the middle to pan; drag either END to zoom —
230 // resizing the thumb literally resizes the viewport. Horizontal = time
231 // zoom, vertical = track height.
232
233 private let sbThick: CGFloat = 9
234
235 private var hBarRect: NSRect {
236 NSRect(x: headerW + 2, y: bounds.height - sbThick - 3,
237 width: max(10, bounds.width - headerW - sbThick - 10), height: sbThick)
238 }
239 private var vBarRect: NSRect {
240 NSRect(x: bounds.width - sbThick - 3, y: lanesTop + 2,
241 width: sbThick, height: max(10, bounds.height - lanesTop - sbThick - 10))
242 }
243
244 private func hDomain() -> (lo: Double, hi: Double) {
245 let viewSec = Double(bounds.width - headerW) / pxPerSecond
246 let lo = min(0, originSecond)
247 let hi = max(project.timelineDuration + 10, originSecond + viewSec)
248 return (lo, hi)
249 }
250
251 private func hThumbRect() -> NSRect {
252 let bar = hBarRect
253 let (lo, hi) = hDomain()
254 let viewSec = Double(bounds.width - headerW) / pxPerSecond
255 let span = max(0.001, hi - lo)
256 let x0 = bar.minX + CGFloat((originSecond - lo) / span) * bar.width
257 let w = min(bar.width, max(28, CGFloat(viewSec / span) * bar.width))
258 return NSRect(x: min(max(bar.minX, x0), bar.maxX - w), y: bar.minY,
259 width: w, height: bar.height)
260 }
261
262 private func vThumbRect() -> NSRect {
263 let bar = vBarRect
264 let contentH = max(lanesContentHeight, 1)
265 let viewH = max(1, bounds.height - lanesTop)
266 let f = min(1, viewH / contentH)
267 let y0 = bar.minY + (scrollY / contentH) * bar.height
268 let h = min(bar.height, max(24, f * bar.height))
269 return NSRect(x: bar.minX, y: min(max(bar.minY, y0), bar.maxY - h),
270 width: bar.width, height: h)
271 }
272
273 private func drawScrollbars() {
274 for (bar, thumb) in [(hBarRect, hThumbRect()), (vBarRect, vThumbRect())] {
275 guard bar.width > 20, bar.height > 4 else { continue }
276 Theme.label.withAlphaComponent(0.06).setFill()
277 NSBezierPath(roundedRect: bar, xRadius: sbThick / 2, yRadius: sbThick / 2).fill()
278 Theme.label.withAlphaComponent(0.25).setFill()
279 NSBezierPath(roundedRect: thumb, xRadius: sbThick / 2, yRadius: sbThick / 2).fill()
280 // End grips (the zoom handles)
281 Theme.label.withAlphaComponent(0.55).setFill()
282 if bar.width > bar.height {
283 NSBezierPath(ovalIn: NSRect(x: thumb.minX + 2.5, y: thumb.midY - 2,
284 width: 4, height: 4)).fill()
285 NSBezierPath(ovalIn: NSRect(x: thumb.maxX - 6.5, y: thumb.midY - 2,
286 width: 4, height: 4)).fill()
287 } else {
288 NSBezierPath(ovalIn: NSRect(x: thumb.midX - 2, y: thumb.minY + 2.5,
289 width: 4, height: 4)).fill()
290 NSBezierPath(ovalIn: NSRect(x: thumb.midX - 2, y: thumb.maxY - 6.5,
291 width: 4, height: 4)).fill()
292 }
293 }
294 }
295
296 private func scrollbarHit(_ p: NSPoint) -> DragMode? {
297 let hT = hThumbRect(), vT = vThumbRect()
298 if hBarRect.insetBy(dx: 0, dy: -3).contains(p) {
299 if abs(p.x - hT.minX) < 8 { return .hBarLeft }
300 if abs(p.x - hT.maxX) < 8 { return .hBarRight }
301 return .hBarPan
302 }
303 if vBarRect.insetBy(dx: -3, dy: 0).contains(p) {
304 if abs(p.y - vT.minY) < 8 { return .vBarTop }
305 if abs(p.y - vT.maxY) < 8 { return .vBarBottom }
306 return .vBarPan
307 }
308 return nil
309 }
310
311 private func drawLane(row: Int, ref: TrackRef) {
312 let lane = laneRect(row: row)
313 guard lane.minY < bounds.maxY, lane.maxY > lanesTop else { return }
314 (ref == .storyboard ? Theme.storyboardLaneBg : Theme.laneBg).setFill()
315 NSBezierPath(roundedRect: lane, xRadius: 4, yRadius: 4).fill()
316
317 let overlaps = project.overlaps(on: ref)
318 let overlappingIds = Set(overlaps.flatMap { [$0.a.id, $0.b.id] })
319
320 for clip in project.clips(on: ref) {
321 drawClip(clip, row: row, ref: ref, overlapping: overlappingIds.contains(clip.id))
322 }
323
324 // Bright red overlap ranges on top of the clip bodies.
325 for o in overlaps {
326 let r = NSRect(x: xFor(o.start), y: lane.minY + 1,
327 width: max(2, xFor(o.end) - xFor(o.start)), height: lane.height - 2)
328 NSColor.systemRed.withAlphaComponent(0.40).setFill()
329 r.fill()
330 NSColor.systemRed.setStroke()
331 let p = NSBezierPath(rect: r.insetBy(dx: 0.5, dy: 0.5))
332 p.lineWidth = 1.5
333 p.stroke()
334 }
335 }
336
337 /// Circular SF-Symbol button in the header column (hide preview / focus).
338 private func drawHeaderButton(_ symbolName: String, centerY: CGFloat, on: Bool) {
339 drawHeaderButton(symbolName, in: NSRect(x: headerW / 2 - 8.5, y: centerY - 8.5,
340 width: 17, height: 17), on: on)
341 }
342
343 private func drawHeaderButton(_ symbolName: String, in r: NSRect, on: Bool) {
344 (on ? NSColor.white : NSColor.black.withAlphaComponent(0.35)).setFill()
345 NSBezierPath(ovalIn: r).fill()
346 guard let base = NSImage(systemSymbolName: symbolName, accessibilityDescription: nil)?
347 .withSymbolConfiguration(.init(pointSize: 9, weight: .bold)) else { return }
348 let img = base.tinted(on ? .black : NSColor(calibratedWhite: 0.95, alpha: 0.9))
349 let s = img.size
350 img.draw(in: NSRect(x: r.midX - s.width / 2, y: r.midY - s.height / 2,
351 width: s.width, height: s.height),
352 from: .zero, operation: .sourceOver, fraction: 1)
353 }
354
355 /// A vivid swatch of the track's own colour — opens the picker on click.
356 private func drawHeaderColorSwatch(in r: NSRect, hue: Double) {
357 NSColor(calibratedHue: hue, saturation: 0.85, brightness: 1, alpha: 1).setFill()
358 NSBezierPath(ovalIn: r).fill()
359 NSColor.white.withAlphaComponent(0.95).setStroke()
360 let ring = NSBezierPath(ovalIn: r.insetBy(dx: 0.75, dy: 0.75))
361 ring.lineWidth = 1.5
362 ring.stroke()
363 NSColor.black.withAlphaComponent(0.5).setStroke()
364 let outer = NSBezierPath(ovalIn: r.insetBy(dx: -0.25, dy: -0.25))
365 outer.lineWidth = 0.75
366 outer.stroke()
367 }
368
369 /// The hide / focus (and, on tall enough lanes, colour) button rects for a
370 /// header lane. One source of truth for drawing AND hit-testing. `color` is
371 /// nil when the lane is too short to also fit the swatch.
372 func headerButtonRects(lane: NSRect) -> (hide: NSRect, focus: NSRect, color: NSRect?) {
373 func rect(_ cy: CGFloat, _ d: CGFloat = 17) -> NSRect {
374 NSRect(x: headerW / 2 - d / 2, y: cy - d / 2, width: d, height: d)
375 }
376 if lane.height >= Self.headerColorMinHeight {
377 return (rect(lane.minY + lane.height * 0.22),
378 rect(lane.minY + lane.height * 0.50),
379 rect(lane.minY + lane.height * 0.78, 15))
380 }
381 return (rect(lane.minY + lane.height * 0.28),
382 rect(lane.minY + lane.height * 0.72), nil)
383 }
384
385 private func drawTrackHeader(row: Int, ref: TrackRef, lane: NSRect) {
386 // Header column: track color strip with hide (eye.slash), focus
387 // (expand), and — when there's room — a colour swatch for the picker.
388 let hidden = session.hiddenTracks.contains(ref)
389 let focused = session.focusedTracks.contains(ref)
390 let strip = NSRect(x: 0, y: lane.minY, width: headerW, height: lane.height)
391 let color = hidden ? NSColor(calibratedWhite: 0.35, alpha: 1) : trackColor(ref)
392 color.setFill()
393 NSBezierPath(roundedRect: strip.insetBy(dx: 2, dy: 2), xRadius: 3, yRadius: 3).fill()
394 // Too short a lane can't fit the buttons without crowding — just show
395 // the colour strip (hide/focus/reset stay on the right-click menu).
396 guard lane.height >= Self.headerButtonsMinHeight else { return }
397 let rects = headerButtonRects(lane: lane)
398 drawHeaderButton(UI.hideSymbol, in: rects.hide, on: hidden)
399 drawHeaderButton(UI.focusSymbol, in: rects.focus, on: focused)
400 // The storyboard lane's hue is fixed — no colour picker for it.
401 if let c = rects.color, ref != .storyboard {
402 drawHeaderColorSwatch(in: c, hue: project.hue(for: ref))
403 }
404 }
405
406 static let headerButtonsMinHeight: CGFloat = 44
407 /// Above this lane height the header also shows the colour swatch.
408 static let headerColorMinHeight: CGFloat = 62
409
410 // The header swatch has no NSView to anchor the picker to, so drop an
411 // invisible one over it for the duration (reuses the picker's positioning
412 // + hover-corridor logic); it's removed when the picker closes.
413 private var headerColorAnchor: NSView?
414 private var headerColorSnapshot: ProjectModel?
415
416 private func openHeaderColorPicker(videoIndex: Int, swatchRect: NSRect) {
417 headerColorAnchor?.removeFromSuperview()
418 let anchor = NSView(frame: swatchRect)
419 addSubview(anchor)
420 headerColorAnchor = anchor
421 let hue = project.hue(for: .video(videoIndex))
422 let seed = NSColor(calibratedHue: hue, saturation: 0.7, brightness: 0.9, alpha: 1)
423 // Live, non-undoable preview; commit as ONE undo step on close. No held
424 // gesture, so editing the timeline mid-pick can't trip anything.
425 headerColorSnapshot = store.project
426 ColorPickerPanel.show(under: anchor, color: seed, onChange: { [weak self] c in
427 guard let self,
428 let h = c.usingColorSpace(.genericRGB)?.hueComponent else { return }
429 self.store.preview { model in
430 if model.tracks.indices.contains(videoIndex) {
431 model.tracks[videoIndex].hue = h
432 }
433 }
434 }, onClose: { [weak self] in
435 guard let self else { return }
436 if let snap = self.headerColorSnapshot {
437 self.headerColorSnapshot = nil
438 self.store.commitPreview(from: snap)
439 }
440 self.headerColorAnchor?.removeFromSuperview()
441 self.headerColorAnchor = nil
442 })
443 }
444
445 private func drawFusionHeader() {
446 guard fusionBandH > 0 else { return }
447 let band = NSRect(x: 0, y: rulerH, width: headerW, height: fusionBandH)
448 FusionComps.yellow.withAlphaComponent(session.fusionHidden ? 0.25 : 0.6).setFill()
449 NSBezierPath(roundedRect: band.insetBy(dx: 2, dy: 2), xRadius: 3, yRadius: 3).fill()
450 let attrs: [NSAttributedString.Key: Any] = [
451 .font: NSFont.systemFont(ofSize: 9, weight: .bold),
452 .foregroundColor: NSColor.black.withAlphaComponent(0.8),
453 ]
454 let fSize = "F".size(withAttributes: attrs)
455 "F".draw(at: NSPoint(x: headerW / 2 - fSize.width / 2, y: band.minY + 1),
456 withAttributes: attrs)
457 drawHeaderButton(UI.hideSymbol, centerY: band.minY + 19, on: session.fusionHidden)
458 drawHeaderButton(UI.focusSymbol, centerY: band.minY + 37, on: session.fusionFocus)
459 }
460
461 private func drawDragHintLanes() {
462 // While dragging a clip below the last lane, hint the rows that would
463 // become tracks. No resident placeholder lane. (Dropped FILES get their
464 // own landing preview in drawFileDropPreview.)
465 var rows: [Int] = []
466 let count = project.laneRefs.count
467 if drag.mode == .move, let row = dragHintRow, row >= count {
468 rows = Array(count...row)
469 }
470 for row in rows {
471 let lane = laneRect(row: row).insetBy(dx: 2, dy: 2)
472 guard lane.minY < bounds.maxY else { continue }
473 let path = NSBezierPath(roundedRect: lane, xRadius: 4, yRadius: 4)
474 path.setLineDash([4, 4], count: 2, phase: 0)
475 Theme.dragHint.withAlphaComponent(0.6).setStroke()
476 path.stroke()
477 Theme.dragHint.withAlphaComponent(0.25).setFill()
478 path.fill()
479 }
480 }
481
482 private func drawClip(_ clip: Clip, row: Int, ref: TrackRef, overlapping: Bool) {
483 let rect = clipRect(clip, row: row)
484 guard rect.maxX > headerW, rect.minX < bounds.width else { return }
485 let media = project.media(clip.mediaId)
486 let color = trackColor(ref)
487 let selected = store.selection.contains(clip.id)
488
489 // Storyboard panels tile edge-to-edge (they're gapless) so the track
490 // reads as one continuous filmstrip — square corners, no per-panel
491 // card, dividers drawn between shots below.
492 let storyboard = clip.kind == .storyboard
493 let bodyRect = storyboard
494 ? NSRect(x: rect.minX, y: rect.minY + 0.5, width: rect.width, height: rect.height - 1)
495 : rect.insetBy(dx: 0.5, dy: 0.5)
496 let bodyRadius: CGFloat = storyboard ? 0 : 3
497 let body = NSBezierPath(roundedRect: bodyRect, xRadius: bodyRadius, yRadius: bodyRadius)
498 switch clip.kind {
499 case .storyboard:
500 NSColor(calibratedWhite: 0.88, alpha: 1).setFill()
501 case .audio:
502 (color.blended(withFraction: 0.82, of: .black) ?? color).setFill()
503 case .video:
504 (media == nil ? NSColor(calibratedWhite: 0.25, alpha: 1)
505 : color.blended(withFraction: 0.75, of: .black) ?? color).setFill()
506 }
507 body.fill()
508
509 NSGraphicsContext.current?.saveGraphicsState()
510 body.addClip()
511 switch clip.kind {
512 case .video:
513 if let media, session.showFilmstrips { drawFilmstrip(clip, media: media, rect: rect) }
514 case .audio:
515 if let media { drawWaveform(clip, media: media, rect: rect, color: color) }
516 drawFades(clip, rect: rect, selected: selected)
517 case .storyboard:
518 drawBoardThumb(clip, rect: rect)
519 }
520 NSGraphicsContext.current?.restoreGraphicsState()
521
522 let linkedSel = !selected && linkedSelectionCache.contains(clip.id)
523
524 // Title strip
525 var title = media?.displayName
526 ?? (clip.kind == .storyboard
527 ? (panelNamesCache[clip.id] ?? "Panel") : "missing media")
528 if clip.kind != .storyboard && media == nil { title = "⚠︎ " + title }
529 if clip.kind == .audio { title = "♪ " + title }
530 let attrs: [NSAttributedString.Key: Any] = [
531 .font: NSFont.systemFont(ofSize: 9.5, weight: .medium),
532 .foregroundColor: NSColor(calibratedWhite: 0.92, alpha: 1),
533 ]
534 NSGraphicsContext.current?.saveGraphicsState()
535 body.addClip()
536 color.blended(withFraction: 0.5, of: .black)?.withAlphaComponent(0.85).setFill()
537 NSRect(x: rect.minX, y: rect.minY, width: rect.width, height: 13).fill()
538 title.draw(at: NSPoint(x: max(rect.minX, headerW) + 5, y: rect.minY + 1),
539 withAttributes: attrs)
540
541 var badgeX = rect.maxX - 16
542 if clip.speed != 1 {
543 let s = String(format: "×%.4g", clip.speed)
544 let sAttrs: [NSAttributedString.Key: Any] = [
545 .font: NSFont.monospacedDigitSystemFont(ofSize: 8.5, weight: .semibold),
546 .foregroundColor: FusionComps.yellow,
547 ]
548 let w = s.size(withAttributes: sAttrs).width
549 badgeX -= w
550 s.draw(at: NSPoint(x: badgeX, y: rect.minY + 1.5), withAttributes: sAttrs)
551 badgeX -= 5
552 }
553 if clip.muted, let img = NSImage(systemSymbolName: "speaker.slash.fill",
554 accessibilityDescription: "muted") {
555 img.tinted(.white).draw(
556 in: NSRect(x: badgeX, y: rect.minY + 2, width: 10, height: 10),
557 from: .zero, operation: .sourceOver, fraction: 0.9)
558 badgeX -= 13
559 }
560 if clip.linkId != nil, let img = NSImage(systemSymbolName: "link",
561 accessibilityDescription: "linked") {
562 img.tinted(.white).draw(
563 in: NSRect(x: badgeX, y: rect.minY + 2, width: 10, height: 10),
564 from: .zero, operation: .sourceOver, fraction: 0.7)
565 }
566 NSGraphicsContext.current?.restoreGraphicsState()
567
568 // Selection reads as a full-card tint, not just an outline. Link-mates
569 // of the selection (they act selected) tint aqua.
570 if selected || linkedSel {
571 NSGraphicsContext.current?.saveGraphicsState()
572 body.addClip()
573 (selected ? NSColor.controlAccentColor : NSColor.systemCyan)
574 .withAlphaComponent(selected ? 0.34 : 0.20).setFill()
575 rect.fill()
576 NSGraphicsContext.current?.restoreGraphicsState()
577 }
578
579 // Border LAST, on top of the tint: overlap = red, selection = accent
580 // (thick), link-mates = aqua. Storyboard panels skip the per-panel card
581 // border — they use the shot dividers below — unless they need a status
582 // outline (selected / linked / overlapping).
583 if !storyboard || selected || linkedSel || overlapping {
584 let radius: CGFloat = storyboard ? 0 : 3
585 let border = NSBezierPath(roundedRect: rect.insetBy(dx: 1.25, dy: 1.25),
586 xRadius: radius, yRadius: radius)
587 border.lineWidth = selected ? 3.5 : linkedSel ? 3 : (overlapping ? 2 : 1.5)
588 (selected ? Theme.selection
589 : linkedSel ? NSColor.systemCyan
590 : overlapping ? NSColor.systemRed : color).setStroke()
591 border.stroke()
592 }
593
594 // Shot divider: an opaque line sitting ON the boundary between two
595 // storyboard panels (they tile gaplessly). A new shot gets a bold
596 // orange bar; frames within a shot get a thin neutral line. The first
597 // panel of the track has no divider on its left.
598 if storyboard {
599 let hasPrev = project.clips.contains {
600 $0.id != clip.id && $0.kind == .storyboard
601 && $0.track == clip.track && $0.start < clip.start - 1e-6
602 }
603 if hasPrev {
604 if clip.newShot {
605 NSColor.systemOrange.setFill()
606 NSRect(x: rect.minX - 1.5, y: rect.minY, width: 3, height: rect.height).fill()
607 } else {
608 NSColor(calibratedWhite: Theme.light ? 0.45 : 0.30, alpha: 1).setFill()
609 NSRect(x: rect.minX - 0.5, y: rect.minY, width: 1, height: rect.height).fill()
610 }
611 }
612 }
613 }
614
615 private func drawFilmstrip(_ clip: Clip, media: MediaItem, rect: NSRect) {
616 let thumbH = rect.height - 14
617 guard thumbH > 6 else { return }
618 let mediaAspect = media.width > 0 && media.height > 0
619 ? CGFloat(media.width) / CGFloat(media.height) : 16.0 / 9.0
620 let thumbW = thumbH * mediaAspect
621 let visX0 = max(rect.minX, headerW), visX1 = min(rect.maxX, bounds.width)
622 var x = rect.minX + floor((visX0 - rect.minX) / thumbW) * thumbW
623 while x < visX1 {
624 let tlSec = secondsFor(x + thumbW / 2)
625 let srcSec = clip.sourceTime(at: tlSec)
626 if let img = MediaPipeline.shared.filmstripImage(for: media, at: max(0, srcSec)) {
627 img.draw(in: NSRect(x: x, y: rect.minY + 14, width: thumbW, height: thumbH),
628 from: .zero, operation: .sourceOver, fraction: 0.9)
629 }
630 x += thumbW
631 }
632 trackColorForClip(clip).withAlphaComponent(0.10).setFill()
633 rect.fill()
634 }
635
636 private func drawWaveform(_ clip: Clip, media: MediaItem, rect: NSRect, color: NSColor) {
637 // Center line
638 color.withAlphaComponent(0.35).setFill()
639 NSRect(x: rect.minX, y: rect.midY + 6, width: rect.width, height: 1).fill()
640 guard let img = MediaPipeline.shared.waveformImage(for: media), media.duration > 0
641 else { return }
642 let imgW = img.size.width
643 let fromX = CGFloat(clip.srcIn / media.duration) * imgW
644 let fromW = CGFloat(clip.duration / media.duration) * imgW
645 let dest = NSRect(x: rect.minX, y: rect.minY + 14,
646 width: rect.width, height: rect.height - 16)
647 img.draw(in: dest, from: NSRect(x: fromX, y: 0, width: max(1, fromW),
648 height: img.size.height),
649 operation: .sourceOver, fraction: 0.85)
650 }
651
652 private func drawFades(_ clip: Clip, rect: NSRect, selected: Bool) {
653 let top = rect.minY + 13, bottom = rect.maxY
654 func fadeShape(from x0: CGFloat, to x1: CGFloat, leading: Bool) {
655 guard abs(x1 - x0) > 0.5 else { return }
656 let path = NSBezierPath()
657 path.move(to: NSPoint(x: x0, y: bottom))
658 path.line(to: NSPoint(x: x1, y: top))
659 path.line(to: NSPoint(x: leading ? x0 : x1, y: top))
660 path.close()
661 NSColor.black.withAlphaComponent(0.45).setFill()
662 path.fill()
663 let line = NSBezierPath()
664 line.move(to: NSPoint(x: x0, y: bottom))
665 line.line(to: NSPoint(x: x1, y: top))
666 NSColor.white.withAlphaComponent(0.8).setStroke()
667 line.lineWidth = 1.2
668 line.stroke()
669 }
670 let xIn = xFor(clip.start + clip.fadeIn)
671 let xOut = xFor(clip.end - clip.fadeOut)
672 if clip.fadeIn > 0.001 { fadeShape(from: xFor(clip.start), to: xIn, leading: true) }
673 if clip.fadeOut > 0.001 { fadeShape(from: xFor(clip.end), to: xOut, leading: false) }
674 // Handles (always visible so fades stay discoverable).
675 for x in [xIn, xOut] {
676 let r = NSRect(x: x - 3.5, y: top - 3.5 + 4, width: 7, height: 7)
677 (selected ? NSColor.white : NSColor(calibratedWhite: 0.85, alpha: 0.9)).setFill()
678 NSBezierPath(ovalIn: r).fill()
679 }
680 }
681
682 private func drawBoardThumb(_ clip: Clip, rect: NSRect) {
683 guard let board = clip.board, rect.height > 20 else { return }
684 let img = boards.composite(for: board)
685 let h = rect.height - 15
686 let w = h * CGFloat(board.width / board.height)
687 var x = rect.minX + 1
688 while x < rect.maxX - 1 {
689 img.draw(in: NSRect(x: x, y: rect.minY + 14, width: min(w, rect.maxX - 1 - x),
690 height: h),
691 from: NSRect(x: 0, y: 0,
692 width: img.size.width * min(1, (rect.maxX - 1 - x) / w),
693 height: img.size.height),
694 operation: .sourceOver, fraction: 1)
695 x += w + 2
696 break // one panel image; boards are one still, no need to tile
697 }
698 NSImage(systemSymbolName: "pencil.and.outline", accessibilityDescription: nil)?
699 .tinted(NSColor(calibratedWhite: 0.2, alpha: 1))
700 .draw(in: NSRect(x: rect.minX + 4, y: rect.minY + 16, width: 11, height: 11),
701 from: .zero, operation: .sourceOver, fraction: 0.9)
702 }
703
704 // MARK: - Fusion comps band
705
706 private func drawFusionBand() {
707 guard fusionBandH > 0 else { return }
708 let band = NSRect(x: 0, y: rulerH, width: bounds.width, height: fusionBandH)
709 NSColor(calibratedRed: 0.16, green: 0.14, blue: 0.05, alpha: 1).setFill()
710 band.fill()
711 FusionComps.yellow.withAlphaComponent(0.5).setFill()
712 NSRect(x: 0, y: band.maxY - 1, width: bounds.width, height: 1).fill()
713
714 let fps = project.fps
715 let preferred = Set(project.preferredTakes)
716 for (comp, lane, lanes) in comps.stacked() {
717 let x0 = xFor(comp.startSeconds(fps: fps))
718 let x1 = xFor(comp.endSeconds(fps: fps))
719 guard x1 > headerW, x0 < bounds.width else { continue }
720 let subH = (band.height - 6) / CGFloat(lanes)
721 let r = NSRect(x: x0, y: band.minY + 3 + CGFloat(lane) * subH,
722 width: max(2, x1 - x0), height: subH - 1)
723 let isPreferred = preferred.contains(comp.name)
724 let selected = comps.selectedCompPath == comp.path
725 FusionComps.yellow.withAlphaComponent(isPreferred ? 0.95 : 0.55).setFill()
726 let p = NSBezierPath(roundedRect: r.insetBy(dx: 0.5, dy: 0.5), xRadius: 3, yRadius: 3)
727 p.fill()
728 if selected {
729 Theme.selection.setStroke()
730 let b = NSBezierPath(roundedRect: r.insetBy(dx: 1, dy: 1), xRadius: 3, yRadius: 3)
731 b.lineWidth = 2
732 b.stroke()
733 }
734 var label = comp.title.isEmpty ? comp.name : comp.title
735 if isPreferred { label = "★ " + label }
736 let attrs: [NSAttributedString.Key: Any] = [
737 .font: NSFont.systemFont(ofSize: min(10, subH - 4), weight: .semibold),
738 .foregroundColor: NSColor.black.withAlphaComponent(0.8),
739 ]
740 NSGraphicsContext.current?.saveGraphicsState()
741 p.addClip()
742 label.draw(at: NSPoint(x: max(x0, headerW) + 4,
743 y: r.midY - label.size(withAttributes: attrs).height / 2),
744 withAttributes: attrs)
745 NSGraphicsContext.current?.restoreGraphicsState()
746 }
747 }
748
749 private func compAt(point: NSPoint) -> FusionComp? {
750 guard fusionBandH > 0, point.y > rulerH, point.y < rulerH + fusionBandH
751 else { return nil }
752 let fps = project.fps
753 let band = NSRect(x: 0, y: rulerH, width: bounds.width, height: fusionBandH)
754 for (comp, lane, lanes) in comps.stacked() {
755 let x0 = xFor(comp.startSeconds(fps: fps))
756 let x1 = xFor(comp.endSeconds(fps: fps))
757 let subH = (band.height - 6) / CGFloat(lanes)
758 let r = NSRect(x: x0, y: band.minY + 3 + CGFloat(lane) * subH,
759 width: max(2, x1 - x0), height: subH - 1)
760 if r.contains(point) { return comp }
761 }
762 return nil
763 }
764
765 // MARK: - Ruler / playhead / indicators
766
767 private func drawRuler() {
768 Theme.rulerBg.setFill()
769 NSRect(x: 0, y: 0, width: bounds.width, height: rulerH).fill()
770 Theme.rulerLine.setFill()
771 NSRect(x: 0, y: rulerH - 1, width: bounds.width, height: 1).fill()
772
773 let steps: [Double] = [0.04, 0.1, 0.25, 0.5, 1, 2, 5, 10, 15, 30, 60, 120, 300, 600, 1800]
774 let major = steps.first { $0 * pxPerSecond >= 70 } ?? 3600
775 let labelAttrs: [NSAttributedString.Key: Any] = [
776 .font: NSFont.monospacedDigitSystemFont(ofSize: 9, weight: .regular),
777 .foregroundColor: Theme.subtleLabel,
778 ]
779 var s = (originSecond / major).rounded(.down) * major
780 while xFor(s) < bounds.width {
781 let x = xFor(s)
782 if x >= headerW, s >= 0 {
783 Theme.tickMajor.setFill()
784 NSRect(x: x, y: rulerH - 8, width: 1, height: 8).fill()
785 label(for: s).draw(at: NSPoint(x: x + 3, y: 3), withAttributes: labelAttrs)
786 // minor ticks
787 for m in 1..<5 {
788 let mx = xFor(s + major * Double(m) / 5)
789 Theme.tickMinor.setFill()
790 NSRect(x: mx, y: rulerH - 4, width: 1, height: 4).fill()
791 }
792 }
793 s += major
794 }
795 }
796
797 private func label(for seconds: Double) -> String {
798 let total = Int(seconds.rounded())
799 if seconds < 1 && seconds > 0 { return String(format: "%.2f", seconds) }
800 let h = total / 3600, m = (total / 60) % 60, sec = total % 60
801 return h > 0 ? String(format: "%d:%02d:%02d", h, m, sec) : String(format: "%d:%02d", m, sec)
802 }
803
804 /// The loop range. With both ends set, a tinted band over the lanes between
805 /// in and out plus `[` / `]` bracket handles in the ruler. A lone point
806 /// (only in, or only out) shows just an arrow flag in the ruler — like a
807 /// marker — rather than tinting the whole timeline. Green while cycling,
808 /// amber when set but looping is off. Drawn under markers and playhead.
809 private func drawInOut() {
810 let pc = playback
811 guard pc.hasInOut else { return }
812 let color = pc.loops ? NSColor.systemGreen : NSColor.systemOrange
813
814 // Lone point: draw a flag, no band.
815 guard let i0 = pc.inPoint, let o0 = pc.outPoint else {
816 if let i = pc.inPoint { drawInOutFlag(at: i, color: color, isIn: true) }
817 if let o = pc.outPoint { drawInOutFlag(at: o, color: color, isIn: false) }
818 return
819 }
820
821 // Full range: tinted band over the lanes.
822 let xLo = max(headerW, xFor(i0))
823 let xHi = min(bounds.width, xFor(o0))
824 if xHi > xLo {
825 color.withAlphaComponent(0.10).setFill()
826 NSRect(x: xLo, y: rulerH, width: xHi - xLo, height: bounds.height - rulerH).fill()
827 color.withAlphaComponent(0.85).setFill()
828 NSRect(x: xLo, y: rulerH - 3, width: xHi - xLo, height: 3).fill()
829 }
830 // In bracket: stem with feet pointing right (into the range).
831 let xi = xFor(i0)
832 if xi >= headerW - 2, xi <= bounds.width {
833 color.setFill()
834 NSRect(x: xi, y: 0, width: 2, height: rulerH).fill()
835 NSRect(x: xi, y: rulerH - 3, width: 7, height: 3).fill()
836 NSRect(x: xi, y: 0, width: 7, height: 3).fill()
837 }
838 // Out bracket: feet point left.
839 let xo = xFor(o0)
840 if xo >= headerW, xo <= bounds.width + 2 {
841 color.setFill()
842 NSRect(x: xo - 2, y: 0, width: 2, height: rulerH).fill()
843 NSRect(x: xo - 7, y: rulerH - 3, width: 7, height: 3).fill()
844 NSRect(x: xo - 7, y: 0, width: 7, height: 3).fill()
845 }
846 }
847
848 /// A lone in/out point reads as a triangular arrow flag in the ruler plus a
849 /// thin stem down the lanes, like a marker. The arrow points into the range:
850 /// right for an in point, left for an out point.
851 private func drawInOutFlag(at t: Double, color: NSColor, isIn: Bool) {
852 let x = xFor(t)
853 guard x >= headerW - 2, x <= bounds.width + 2 else { return }
854 let stemX = isIn ? x : x - 1
855 // Faint pole down the lanes.
856 color.withAlphaComponent(0.5).setFill()
857 NSRect(x: stemX, y: rulerH, width: 1, height: bounds.height - rulerH).fill()
858 // Triangular arrow in the ruler, anchored on the exact time.
859 let h: CGFloat = 13
860 let top = rulerH - h - 1
861 let beak: CGFloat = 8
862 color.setFill()
863 NSRect(x: stemX, y: top, width: 1, height: rulerH - top).fill()
864 let arrow = NSBezierPath()
865 arrow.move(to: NSPoint(x: x, y: top))
866 arrow.line(to: NSPoint(x: x + (isIn ? beak : -beak), y: top + h / 2))
867 arrow.line(to: NSPoint(x: x, y: top + h))
868 arrow.close()
869 arrow.fill()
870 }
871
872 private func drawPlayhead() {
873 let x = xFor(playback.playhead)
874 guard x >= headerW, x <= bounds.width else { return }
875 NSColor.systemRed.withAlphaComponent(0.9).setFill()
876 NSRect(x: x, y: 0, width: 1.5, height: bounds.height).fill()
877 let tri = NSBezierPath()
878 tri.move(to: NSPoint(x: x - 5, y: 0))
879 tri.line(to: NSPoint(x: x + 6.5, y: 0))
880 tri.line(to: NSPoint(x: x + 0.75, y: 8))
881 tri.close()
882 tri.fill()
883 }
884
885 private static let markerLabelAttrs: [NSAttributedString.Key: Any] = [
886 .font: NSFont.systemFont(ofSize: 9, weight: .semibold),
887 .foregroundColor: NSColor.white,
888 ]
889
890 /// Blue markers: a thin line down the lanes plus a clickable flag in the
891 /// ruler. A named marker carries its name inside the flag itself; an
892 /// unnamed one gets a small plain pennant. Drawn under the red playhead.
893 private func drawMarkers() {
894 let blue = NSColor.systemBlue
895 for m in project.sortedMarkers {
896 let x = xFor(m.time)
897 guard x >= headerW, x <= bounds.width else { continue }
898 // Flag hanging off the pole: a straight left edge on the pole and a
899 // triangular pennant point on the right. A named marker carries the
900 // name inside; an unnamed one is the same-height flag, just narrow.
901 let h: CGFloat = 13
902 let top = rulerH - h - 1
903 // Pole: a faint line down the lanes, plus a solid stem only as tall
904 // as the flag (not running up to the top of the ruler).
905 blue.withAlphaComponent(0.5).setFill()
906 NSRect(x: x, y: rulerH, width: 1, height: bounds.height - rulerH).fill()
907 blue.withAlphaComponent(0.95).setFill()
908 NSRect(x: x, y: top, width: 1, height: rulerH - top).fill()
909 let beak: CGFloat = 6
910 let body: CGFloat = m.label.isEmpty
911 ? 8
912 : (m.label as NSString).size(withAttributes: Self.markerLabelAttrs).width + 10
913 let flag = NSBezierPath()
914 flag.move(to: NSPoint(x: x + 1, y: top)) // top-left
915 flag.line(to: NSPoint(x: x + 1 + body, y: top)) // top-right
916 flag.line(to: NSPoint(x: x + 1 + body + beak, y: top + h / 2)) // point
917 flag.line(to: NSPoint(x: x + 1 + body, y: top + h)) // bottom-right
918 flag.line(to: NSPoint(x: x + 1, y: top + h)) // bottom-left
919 flag.close()
920 flag.fill()
921 if !m.label.isEmpty {
922 (m.label as NSString).draw(at: NSPoint(x: x + 6, y: top + 2),
923 withAttributes: Self.markerLabelAttrs)
924 }
925 }
926 }
927
928 /// Hit region for a marker's ruler flag (click to seek, double-click to
929 /// rename, right-click for its menu).
930 private func markerHandleRect(_ m: Marker) -> NSRect {
931 // Cover the whole flag: pole + body + beak (see drawMarkers).
932 let w: CGFloat = m.label.isEmpty
933 ? 20
934 : (m.label as NSString).size(withAttributes: Self.markerLabelAttrs).width + 21
935 return NSRect(x: xFor(m.time) - 4, y: 0, width: w, height: rulerH)
936 }
937 /// Topmost marker flag under a ruler point, if any.
938 private func markerAt(point p: NSPoint) -> Marker? {
939 guard p.y <= rulerH else { return nil }
940 return project.sortedMarkers.reversed().first { markerHandleRect($0).contains(p) }
941 }
942
943 private func drawSnapIndicator() {
944 guard let target = activeSnapTarget else { return }
945 let x = xFor(target)
946 NSColor.systemYellow.withAlphaComponent(0.8).setFill()
947 NSRect(x: x, y: 0, width: 1, height: bounds.height).fill()
948 }
949
950 private func drawBoxSelect() {
951 guard drag.mode == .box, drag.moved else { return }
952 let r = boxRect()
953 Theme.label.withAlphaComponent(0.08).setFill()
954 r.fill()
955 Theme.label.withAlphaComponent(0.6).setStroke()
956 let p = NSBezierPath(rect: r)
957 p.lineWidth = 1
958 p.stroke()
959 }
960
961 private func boxRect() -> NSRect {
962 NSRect(x: min(drag.startPoint.x, lastMousePoint.x),
963 y: min(drag.startPoint.y, lastMousePoint.y),
964 width: abs(lastMousePoint.x - drag.startPoint.x),
965 height: abs(lastMousePoint.y - drag.startPoint.y))
966 }
967
968 // MARK: - Mouse editing
969
970 private enum DragMode {
971 case none, scrub, move, trimIn, trimOut, rippleOut, slip,
972 stretchIn, stretchOut, fadeIn, fadeOut, box, resizeTrack,
973 hBarPan, hBarLeft, hBarRight, vBarPan, vBarTop, vBarBottom
974 }
975
976 private struct BarDrag {
977 var domainLo = 0.0, domainHi = 0.0
978 var origOrigin = 0.0
979 var origPps = 20.0
980 var origScrollY: CGFloat = 0
981 var origScale: CGFloat = 1
982 var origContentH: CGFloat = 1
983 var thumb = NSRect.zero
984 }
985 private var barDrag = BarDrag()
986 private struct DragState {
987 var mode: DragMode = .none
988 var clipId: UUID?
989 var startPoint = NSPoint.zero
990 var origClip: Clip?
991 var origSelection: [UUID: Clip] = [:]
992 var baseSelection: Set<UUID> = [] // box select: selection before drag
993 var resizeRow: Int?
994 var resizeOrigH: CGFloat = 0
995 var moved = false
996 var collapseTo: UUID? // click-on-selected: reduce to this clip if no drag
997 }
998 private var drag = DragState()
999 private var activeSnapTarget: Double?
1000 private var dragHintRow: Int?
1001 private var lastMousePoint = NSPoint.zero
1002
1003 override func mouseDown(with event: NSEvent) {
1004 window?.makeFirstResponder(self)
1005 let p = convert(event.locationInWindow, from: nil)
1006 lastMousePoint = p
1007 drag = DragState()
1008 drag.startPoint = p
1009
1010 if let barMode = scrollbarHit(p) {
1011 drag.mode = barMode
1012 let (lo, hi) = hDomain()
1013 barDrag = BarDrag(domainLo: lo, domainHi: hi,
1014 origOrigin: originSecond, origPps: pxPerSecond,
1015 origScrollY: scrollY, origScale: session.laneScale,
1016 origContentH: lanesContentHeight,
1017 thumb: [.vBarPan, .vBarTop, .vBarBottom].contains(barMode)
1018 ? vThumbRect() : hThumbRect())
1019 return
1020 }
1021
1022 // A marker flag in the ruler: click parks the playhead on it. Rename
1023 // via its right-click menu (name field is inline there). Checked
1024 // before the scrub fallthrough.
1025 if p.y < rulerH, let m = markerAt(point: p) {
1026 playback.setRate(0)
1027 playback.seek(to: m.time)
1028 return
1029 }
1030
1031 if p.y < rulerH {
1032 drag.mode = .scrub
1033 playback.setRate(0)
1034 playback.seek(to: max(0, quantize(secondsFor(p.x))))
1035 return
1036 }
1037
1038 // Fusion band header: hide preview (top) / focus (bottom)
1039 if fusionBandH > 0, p.x < headerW, p.y > rulerH, p.y < lanesTop {
1040 if p.y < rulerH + fusionBandH * 0.55 { session.fusionHidden.toggle() }
1041 else { session.fusionFocus.toggle() }
1042 needsDisplay = true
1043 return
1044 }
1045 // Fusion comps band
1046 if let comp = compAt(point: p) {
1047 comps.selectedCompPath = comp.path
1048 if event.clickCount == 2 { comps.openInFusion(comp) }
1049 needsDisplay = true
1050 return
1051 }
1052 if p.y < lanesTop { return }
1053
1054 // Track header buttons
1055 if p.x < headerW, let row = rowAt(y: p.y), let ref = laneRef(row: row) {
1056 let lane = laneRect(row: row)
1057 guard lane.height >= Self.headerButtonsMinHeight else { return } // buttons hidden
1058 let rects = headerButtonRects(lane: lane)
1059 if let c = rects.color, ref.videoIndex != nil, c.insetBy(dx: -2, dy: -2).contains(p) {
1060 openHeaderColorPicker(videoIndex: ref.videoIndex!, swatchRect: c)
1061 } else if p.y < lane.midY {
1062 session.toggleHidden(ref)
1063 } else {
1064 session.toggleFocus(ref)
1065 }
1066 needsDisplay = true
1067 return
1068 }
1069
1070 // Track height resize on lane boundaries
1071 if let row = trackBoundaryAt(y: p.y), let ref = laneRef(row: row) {
1072 drag.mode = .resizeTrack
1073 drag.resizeRow = row
1074 drag.resizeOrigH = laneHeight(ref)
1075 return
1076 }
1077
1078 // Clicking a red overlap selects both offenders (S then resolves it).
1079 if let o = overlapAt(point: p), clipAt(point: p) != nil {
1080 store.selection = [o.a.id, o.b.id]
1081 drag.mode = .move
1082 drag.clipId = o.b.id
1083 drag.origClip = o.b
1084 drag.origSelection = Dictionary(uniqueKeysWithValues:
1085 project.clips.filter { store.selection.contains($0.id) }.map { ($0.id, $0) })
1086 store.beginGesture()
1087 return
1088 }
1089
1090 guard let (clip, row) = clipAt(point: p) else {
1091 // Empty area: box select (click without drag = deselect).
1092 drag.mode = .box
1093 drag.baseSelection = event.modifierFlags.contains(.shift) ? store.selection : []
1094 store.selection = drag.baseSelection
1095 return
1096 }
1097
1098 if event.clickCount == 2, clip.kind == .storyboard {
1099 store.selection = [clip.id]
1100 StoryboardEditor.shared.open(clipId: clip.id, ctx: ctx)
1101 return
1102 }
1103
1104 // Blade tool: click a clip to split it right there.
1105 if session.mainTool == .blade {
1106 store.selection = [clip.id]
1107 bladeAtPlayhead(at: quantize(secondsFor(p.x)),
1108 ids: [clip.id],
1109 rightBoard: { boards.duplicate($0) })
1110 return
1111 }
1112
1113 // Selection
1114 if event.modifierFlags.contains(.shift) {
1115 if store.selection.contains(clip.id) { store.selection.remove(clip.id) }
1116 else { store.selection.insert(clip.id) }
1117 } else if !store.selection.contains(clip.id) {
1118 store.selection = [clip.id]
1119 } else if store.selection.count > 1 {
1120 // Clicking an already-selected clip in a multi-selection keeps the
1121 // group intact so a drag can move it, but a click without a drag
1122 // collapses to just this clip (deselecting the rest) on mouseUp.
1123 drag.collapseTo = clip.id
1124 }
1125
1126 let rect = clipRect(clip, row: row)
1127 let edge: CGFloat = 7
1128
1129 // Audio fade handles beat edge trims.
1130 if clip.kind == .audio {
1131 let handleY = rect.minY + 13 + 4
1132 let xIn = xFor(clip.start + clip.fadeIn)
1133 let xOut = xFor(clip.end - clip.fadeOut)
1134 if abs(p.x - xIn) < 7, abs(p.y - handleY) < 9 {
1135 drag.mode = .fadeIn
1136 } else if abs(p.x - xOut) < 7, abs(p.y - handleY) < 9 {
1137 drag.mode = .fadeOut
1138 }
1139 }
1140
1141 if drag.mode == .none {
1142 let stretch = event.modifierFlags.contains(.command) && clip.kind == .video
1143 let opt = event.modifierFlags.contains(.option)
1144 if opt, rect.maxX - p.x < edge {
1145 drag.mode = .rippleOut // ⌥-drag out edge: push everything after
1146 } else if opt || session.mainTool == .slide {
1147 drag.mode = .slip
1148 } else if p.x - rect.minX < edge {
1149 drag.mode = stretch ? .stretchIn : .trimIn
1150 } else if rect.maxX - p.x < edge {
1151 drag.mode = stretch ? .stretchOut : .trimOut
1152 } else {
1153 drag.mode = .move
1154 }
1155 }
1156
1157 // Storyboard panels are start-only: clicking one parks the playhead on
1158 // it, and the body can't be dragged — only the edges (and nudges) move
1159 // the start. Everything else still works (blade, trims, ripple).
1160 if clip.kind == .storyboard {
1161 if drag.mode == .move || drag.mode == .slip {
1162 playback.setRate(0)
1163 playback.seek(to: quantize(clip.start))
1164 drag = DragState()
1165 needsDisplay = true
1166 return
1167 }
1168 }
1169
1170 drag.clipId = clip.id
1171 drag.origClip = clip
1172 // Moves and edge trims carry the whole selection + link-mates: dragging
1173 // one edge resizes every selected/linked clip by the same amount.
1174 // (Slip/stretch/fade read drag.origClip only, so a wider set is inert.)
1175 var editSet = project.expandLinks(store.selection)
1176 editSet.insert(clip.id)
1177 drag.origSelection = Dictionary(uniqueKeysWithValues:
1178 project.clips.filter { editSet.contains($0.id) }.map { ($0.id, $0) })
1179 store.beginGesture()
1180 }
1181
1182 override func mouseDragged(with event: NSEvent) {
1183 let p = convert(event.locationInWindow, from: nil)
1184 lastMousePoint = p
1185 let dSec = Double(p.x - drag.startPoint.x) / pxPerSecond
1186 activeSnapTarget = nil
1187 dragHintRow = nil
1188
1189 // Dragging against the view edges pans the timeline (there's no
1190 // enclosing scroll view, so the playhead could never leave the screen).
1191 if [.scrub, .move, .trimIn, .trimOut, .rippleOut, .slip,
1192 .stretchIn, .stretchOut, .fadeIn, .fadeOut].contains(drag.mode) {
1193 if p.x > bounds.width - 30 {
1194 originSecond += Double(p.x - (bounds.width - 30)) * 0.12 / pxPerSecond
1195 } else if p.x < headerW + 20 {
1196 originSecond -= Double(headerW + 20 - p.x) * 0.12 / pxPerSecond
1197 }
1198 originSecond = clampOrigin(originSecond)
1199 }
1200
1201 switch drag.mode {
1202 case .none: return
1203 case .scrub:
1204 playback.seek(to: max(0, quantize(secondsFor(p.x))))
1205 case .move: dragMove(p: p, dSec: dSec)
1206 case .trimIn: dragTrimIn(dSec: dSec)
1207 case .trimOut: dragTrimOut(dSec: dSec)
1208 case .rippleOut: dragRippleOut(dSec: dSec)
1209 case .slip: dragSlip(dSec: dSec)
1210 case .stretchIn: dragStretch(dSec: dSec, fromStart: true)
1211 case .stretchOut: dragStretch(dSec: dSec, fromStart: false)
1212 case .fadeIn, .fadeOut: dragFade(p: p)
1213 case .hBarPan:
1214 let span = barDrag.domainHi - barDrag.domainLo
1215 let d = Double(p.x - drag.startPoint.x) / Double(max(1, hBarRect.width)) * span
1216 originSecond = clampOrigin(barDrag.origOrigin + d)
1217 case .hBarLeft, .hBarRight:
1218 let bar = hBarRect
1219 let span = barDrag.domainHi - barDrag.domainLo
1220 let t = barDrag.domainLo + Double((p.x - bar.minX) / max(1, bar.width)) * span
1221 let viewW = Double(bounds.width - headerW)
1222 if drag.mode == .hBarLeft {
1223 let t1 = barDrag.origOrigin + viewW / barDrag.origPps
1224 let newT0 = min(max(t, barDrag.domainLo), t1 - viewW / 4000)
1225 pxPerSecond = min(max(viewW / (t1 - newT0), 0.05), 4000)
1226 originSecond = t1 - viewW / pxPerSecond
1227 } else {
1228 let t0 = barDrag.origOrigin
1229 let newT1 = max(min(t, barDrag.domainHi), t0 + viewW / 4000)
1230 pxPerSecond = min(max(viewW / (newT1 - t0), 0.05), 4000)
1231 originSecond = t0
1232 }
1233 case .vBarPan:
1234 let d = (p.y - drag.startPoint.y) / max(1, vBarRect.height) * barDrag.origContentH
1235 scrollY = min(max(0, barDrag.origScrollY + d), maxScrollY)
1236 case .vBarTop, .vBarBottom:
1237 let bar = vBarRect
1238 let viewH = max(1, bounds.height - lanesTop)
1239 var top = barDrag.thumb.minY, bottom = barDrag.thumb.maxY
1240 if drag.mode == .vBarTop { top = min(max(bar.minY, p.y), bottom - 18) }
1241 else { bottom = max(min(bar.maxY, p.y), top + 18) }
1242 let f = (bottom - top) / max(1, bar.height)
1243 let newContentH = viewH / max(0.05, f)
1244 let k = newContentH / max(1, barDrag.origContentH)
1245 session.laneScale = barDrag.origScale * k
1246 if drag.mode == .vBarTop {
1247 scrollY = min(max(0, (barDrag.origScrollY + viewH) * k - viewH), maxScrollY)
1248 } else {
1249 scrollY = min(max(0, barDrag.origScrollY * k), maxScrollY)
1250 }
1251 case .box:
1252 let r = boxRect()
1253 var hit = drag.baseSelection
1254 for (row, ref) in project.laneRefs.enumerated() {
1255 for clip in project.clips(on: ref)
1256 where clipRect(clip, row: row).intersects(r) {
1257 hit.insert(clip.id)
1258 }
1259 }
1260 store.selection = hit
1261 case .resizeTrack:
1262 if let row = drag.resizeRow, let ref = laneRef(row: row) {
1263 let newH = drag.resizeOrigH + (p.y - drag.startPoint.y)
1264 let factor = newH / max(1, baseLaneH * session.laneScale)
1265 session.trackHeights[ref] = min(4, max(0.35, factor))
1266 }
1267 }
1268 drag.moved = true
1269 autoscroll(with: event)
1270 needsDisplay = true
1271 }
1272
1273 override func mouseUp(with event: NSEvent) {
1274 switch drag.mode {
1275 case .move, .trimIn, .trimOut, .rippleOut, .slip,
1276 .stretchIn, .stretchOut, .fadeIn, .fadeOut:
1277 store.endGesture()
1278 default:
1279 break
1280 }
1281 // Click (no drag) on an already-selected clip collapses the multi-
1282 // selection down to just that clip.
1283 if !drag.moved, let id = drag.collapseTo { store.selection = [id] }
1284 drag = DragState()
1285 activeSnapTarget = nil
1286 dragHintRow = nil
1287 needsDisplay = true
1288 }
1289
1290 private func dragMove(p: NSPoint, dSec: Double) {
1291 guard let orig = drag.origClip else { return }
1292 // Vertical retracking only for a lone unlinked clip.
1293 let multi = drag.origSelection.count > 1 || store.selection.count > 1
1294
1295 var delta = dSec
1296 if let adj = snapAdjust(start: orig.start + dSec, duration: orig.duration,
1297 excluding: Set(drag.origSelection.keys)) {
1298 delta += adj.adjust
1299 activeSnapTarget = adj.target
1300 }
1301 // Frame-quantize the moved edge, clamp to t >= 0 for all moved clips.
1302 delta = quantize(orig.start + delta) - orig.start
1303 let minStart = drag.origSelection.values.map(\.start).min() ?? 0
1304 if minStart + delta < 0 { delta = -minStart }
1305
1306 // Vertical: retarget track of the grabbed clip only (single-clip
1307 // drags). Rows resolve against the GESTURE BASE — the closure below
1308 // rebuilds from it, so live-project indices (which may include ghost
1309 // tracks added by an earlier update in this same drag) would be off.
1310 let baseModel = store.gestureBaseModel ?? project
1311 let isPanel = orig.kind == .storyboard
1312 var targetRef: TrackRef? = nil
1313 var neededNewTracks = 0
1314 var groupRowDelta = 0
1315 if let row = rowAt(y: p.y) {
1316 let rows = baseModel.laneRefs
1317 let baseCount = rows.count
1318 if !multi {
1319 if row >= baseCount {
1320 // Dragging N rows below the last lane creates N tracks at
1321 // once; the clip lands on the deepest one. Storyboard panels
1322 // stay on THE storyboard track.
1323 if !isPanel {
1324 neededNewTracks = min(8, row - baseCount + 1)
1325 dragHintRow = row
1326 }
1327 } else {
1328 // Storyboard panels are isolated to the storyboard lane and
1329 // other clips stay off it.
1330 let target = rows[row]
1331 if (target == .storyboard) == isPanel {
1332 targetRef = target
1333 }
1334 }
1335 } else if row < baseCount, let origRow = baseModel.row(of: orig.track) {
1336 // Vertical GROUP move: the whole selection (link groups
1337 // included) shifts by the same number of rows, when every
1338 // destination row exists and is a plain video track.
1339 let rowDelta = row - origRow
1340 if rowDelta != 0 {
1341 let ok = drag.origSelection.values.allSatisfy { c in
1342 guard c.kind != .storyboard,
1343 let r = baseModel.row(of: c.track),
1344 r + rowDelta >= 0, r + rowDelta < baseCount
1345 else { return false }
1346 return rows[r + rowDelta].videoIndex != nil
1347 }
1348 if ok { groupRowDelta = rowDelta }
1349 }
1350 }
1351 }
1352 if ProcessInfo.processInfo.environment["SEQ_DEBUG"] != nil {
1353 FileHandle.standardError.write(Data(
1354 ("dragMove p=\(p) row=\(rowAt(y: p.y).map(String.init) ?? "nil") " +
1355 "tracks=\(project.tracks.count) target=\(targetRef.map(\.wire) ?? "nil") " +
1356 "new=\(neededNewTracks) delta=\(delta)\n").utf8))
1357 }
1358
1359 store.updateGesture { model in
1360 for (id, o) in drag.origSelection {
1361 guard let i = model.clips.firstIndex(where: { $0.id == id }) else { continue }
1362 model.clips[i].start = o.start + delta
1363 }
1364 if groupRowDelta != 0 {
1365 let rows = model.laneRefs
1366 for (id, o) in drag.origSelection {
1367 guard let i = model.clips.firstIndex(where: { $0.id == id }),
1368 let r = model.row(of: o.track)
1369 else { continue }
1370 let nr = r + groupRowDelta
1371 if nr >= 0, nr < rows.count {
1372 model.clips[i].track = rows[nr]
1373 }
1374 }
1375 }
1376 guard let i = model.clips.firstIndex(where: { $0.id == drag.clipId }) else { return }
1377 if neededNewTracks > 0 {
1378 // Indices are deterministic, so appending N lanes needs no
1379 // stable identities — the clip lands on the deepest new one.
1380 for _ in 0..<neededNewTracks { model.addTrack() }
1381 model.clips[i].track = .video(model.tracks.count - 1)
1382 } else if let ref = targetRef {
1383 model.clips[i].track = ref
1384 }
1385 }
1386 }
1387
1388 /// Non-audio clips on the same track whose head would be swallowed by
1389 /// dragging `orig`'s out-edge to `newEnd` (audio layers freely, so it
1390 /// never gets pushed).
1391 private func pushVictimsOut(orig: Clip, newEnd: Double, model: ProjectModel) -> [Clip] {
1392 model.clips.filter {
1393 $0.id != orig.id && $0.track == orig.track && $0.kind != .audio
1394 && $0.start >= orig.start && $0.start < newEnd && $0.end > orig.end - 1e-9
1395 }
1396 }
1397
1398 private func dragTrimOut(dSec: Double) {
1399 guard let orig = drag.origClip else { return }
1400 // Storyboard panels are start-only: a panel "ends" where the next one
1401 // starts, so dragging the out edge really drags the NEXT panel's start.
1402 if orig.kind == .storyboard {
1403 let base = store.gestureBaseModel ?? project
1404 let panels = base.clips(on: orig.track).filter { $0.kind == .storyboard }
1405 guard let idx = panels.firstIndex(where: { $0.id == orig.id }),
1406 idx + 1 < panels.count else { return } // last panel: open-ended
1407 let next = panels[idx + 1]
1408 var desired = quantize(next.start + dSec)
1409 desired = max(desired, orig.start + frameDur)
1410 if idx + 2 < panels.count {
1411 desired = min(desired, panels[idx + 2].start - frameDur)
1412 }
1413 store.updateGesture { model in
1414 guard let i = model.clips.firstIndex(where: { $0.id == next.id }) else { return }
1415 model.clips[i].start = desired
1416 }
1417 return
1418 }
1419 let media = project.media(orig.mediaId)
1420 var desired = orig.end + dSec
1421 if let adj = snapAdjust(start: desired, duration: 0, excluding: [orig.id]) {
1422 desired += adj.adjust
1423 activeSnapTarget = adj.target
1424 }
1425 desired = quantize(desired)
1426 var maxEnd = Double.greatestFiniteMagnitude
1427 if let media {
1428 maxEnd = orig.start + (media.duration - orig.srcIn) / max(0.001, orig.speed)
1429 }
1430 let base = store.gestureBaseModel ?? project
1431 var newEnd = min(max(desired, orig.start + frameDur), maxEnd)
1432 // Trimming through a neighbor auto-trims the neighbor's head, but a
1433 // clip can never be swallowed past its last frame.
1434 let victims = pushVictimsOut(orig: orig, newEnd: newEnd, model: base)
1435 for v in victims { newEnd = min(newEnd, v.end - frameDur) }
1436 newEnd = max(newEnd, orig.start + frameDur)
1437 // The grabbed clip's clamped change is the delta applied to every
1438 // selected/linked clip.
1439 let delta = newEnd - orig.end
1440 let targets = drag.origSelection.values.filter { $0.kind != .storyboard }
1441 let single = targets.count <= 1
1442 store.updateGesture { model in
1443 for t in targets {
1444 guard let i = model.clips.firstIndex(where: { $0.id == t.id }) else { continue }
1445 var end = t.end + delta
1446 if let m = model.media(t.mediaId) {
1447 end = min(end, t.start + (m.duration - t.srcIn) / max(0.001, t.speed))
1448 }
1449 end = max(end, t.start + self.frameDur)
1450 model.clips[i].duration = end - t.start
1451 guard single else { continue } // neighbor auto-trim: single clip only
1452 for v in self.pushVictimsOut(orig: t, newEnd: end, model: model)
1453 where v.start < end {
1454 guard let j = model.clips.firstIndex(where: { $0.id == v.id }) else { continue }
1455 let shift = end - v.start
1456 model.clips[j].srcIn = v.srcIn + shift * v.speed
1457 model.clips[j].start = end
1458 model.clips[j].duration = v.duration - shift
1459 }
1460 }
1461 }
1462 }
1463
1464 private func dragTrimIn(dSec: Double) {
1465 guard let orig = drag.origClip else { return }
1466 var desired = orig.start + dSec
1467 if let adj = snapAdjust(start: desired, duration: 0, excluding: [orig.id]) {
1468 desired += adj.adjust
1469 activeSnapTarget = adj.target
1470 }
1471 desired = quantize(desired)
1472 // Storyboard panels are pure start positions (no source media), so the
1473 // in-edge just slides the panel's start either way — bounded only by the
1474 // previous panel (a frame of clearance) and this panel's own end.
1475 if orig.kind == .storyboard {
1476 let base = store.gestureBaseModel ?? project
1477 let panels = base.clips(on: orig.track)
1478 .filter { $0.kind == .storyboard }
1479 .sorted { $0.start < $1.start }
1480 guard let idx = panels.firstIndex(where: { $0.id == orig.id }) else { return }
1481 let lower = idx > 0 ? panels[idx - 1].start + frameDur : 0
1482 let newStart = min(max(desired, lower), orig.end - frameDur)
1483 store.updateGesture { model in
1484 guard let i = model.clips.firstIndex(where: { $0.id == orig.id }) else { return }
1485 model.clips[i].start = newStart
1486 model.clips[i].srcIn = 0
1487 model.clips[i].duration = orig.end - newStart
1488 }
1489 return
1490 }
1491 var minStart = orig.start - orig.srcIn / max(0.001, orig.speed)
1492 minStart = max(0, minStart)
1493 let maxStart = orig.end - frameDur
1494 let newStart = min(max(desired, minStart), maxStart)
1495 let base = store.gestureBaseModel ?? project
1496 // The grabbed clip's clamped change is the delta applied to every
1497 // selected/linked clip. A storyboard in-edge just moves that panel's
1498 // start (normalization re-derives its duration).
1499 let delta = newStart - orig.start
1500 let targets = Array(drag.origSelection.values.filter { $0.kind != .storyboard })
1501 let single = targets.count <= 1
1502 // Trimming back through the previous clip trims its tail (single only).
1503 let victims = base.clips.filter {
1504 $0.id != orig.id && $0.track == orig.track && $0.kind != .audio
1505 && $0.start < orig.start && $0.end > newStart
1506 }
1507 store.updateGesture { model in
1508 for t in targets {
1509 guard let i = model.clips.firstIndex(where: { $0.id == t.id }) else { continue }
1510 let tMin = max(0, t.start - t.srcIn / max(0.001, t.speed))
1511 let s = min(max(t.start + delta, tMin), t.end - self.frameDur)
1512 model.clips[i].srcIn = t.srcIn + (s - t.start) * t.speed
1513 model.clips[i].start = s
1514 model.clips[i].duration = t.end - s
1515 }
1516 guard single else { return }
1517 for v in victims where v.end > newStart {
1518 guard let j = model.clips.firstIndex(where: { $0.id == v.id }) else { continue }
1519 model.clips[j].duration = max(self.frameDur, newStart - v.start)
1520 }
1521 }
1522 }
1523
1524 /// ⌥-drag a clip's out edge: ripple resize. The edge trims/extends like a
1525 /// normal trim, and everything after it on the same track shifts by the
1526 /// same amount ("pushing the rest away"). On the storyboard track this
1527 /// resizes a panel's slot while keeping the later panels' spacing.
1528 private func dragRippleOut(dSec: Double) {
1529 guard let orig = drag.origClip else { return }
1530 let base = store.gestureBaseModel ?? project
1531 var desired = quantize(orig.end + dSec)
1532 if let adj = snapAdjust(start: desired, duration: 0, excluding: [orig.id]) {
1533 desired += adj.adjust
1534 activeSnapTarget = adj.target
1535 desired = quantize(desired)
1536 }
1537 var newEnd = max(desired, orig.start + frameDur)
1538 if orig.kind == .video, let media = base.media(orig.mediaId) {
1539 newEnd = min(newEnd, orig.start + (media.duration - orig.srcIn) / max(0.001, orig.speed))
1540 }
1541 let delta = newEnd - orig.end
1542 let followers = base.clips.filter {
1543 $0.id != orig.id && $0.track == orig.track && $0.start >= orig.end - 1e-9
1544 }
1545 // Never push anything below t = 0.
1546 let minStart = followers.map(\.start).min() ?? 0
1547 let clampedDelta = max(delta, -minStart)
1548 store.updateGesture { model in
1549 if orig.kind != .storyboard,
1550 let i = model.clips.firstIndex(where: { $0.id == orig.id }) {
1551 model.clips[i].duration = (orig.end + clampedDelta) - orig.start
1552 }
1553 for f in followers {
1554 guard let j = model.clips.firstIndex(where: { $0.id == f.id }) else { continue }
1555 model.clips[j].start = f.start + clampedDelta
1556 }
1557 }
1558 }
1559
1560 private func dragSlip(dSec: Double) {
1561 guard let orig = drag.origClip, let media = project.media(orig.mediaId) else { return }
1562 let maxIn = max(0, media.duration - orig.sourceLength)
1563 let newIn = min(max(orig.srcIn - dSec * orig.speed, 0), maxIn)
1564 store.updateGesture { model in
1565 guard let i = model.clips.firstIndex(where: { $0.id == drag.clipId }) else { return }
1566 model.clips[i].srcIn = newIn
1567 }
1568 }
1569
1570 /// Reaper-style time stretch: ⌘-drag a clip edge. The source range stays
1571 /// fixed; duration changes and speed compensates.
1572 private func dragStretch(dSec: Double, fromStart: Bool) {
1573 guard let orig = drag.origClip else { return }
1574 let srcLen = orig.sourceLength
1575 var newDur: Double
1576 var newStart = orig.start
1577 if fromStart {
1578 var desired = quantize(orig.start + dSec)
1579 desired = min(max(desired, 0), orig.end - frameDur)
1580 newStart = desired
1581 newDur = orig.end - desired
1582 } else {
1583 let desired = quantize(orig.end + dSec)
1584 newDur = max(frameDur, desired - orig.start)
1585 }
1586 newDur = min(max(newDur, srcLen / 50), srcLen * 50)
1587 if fromStart { newStart = orig.end - newDur }
1588 let speed = srcLen / newDur
1589 store.updateGesture { model in
1590 guard let i = model.clips.firstIndex(where: { $0.id == drag.clipId }) else { return }
1591 model.clips[i].start = newStart
1592 model.clips[i].duration = newDur
1593 model.clips[i].speed = speed
1594 }
1595 }
1596
1597 private func dragFade(p: NSPoint) {
1598 guard let orig = drag.origClip else { return }
1599 let sec = secondsFor(p.x)
1600 store.updateGesture { model in
1601 guard let i = model.clips.firstIndex(where: { $0.id == drag.clipId }) else { return }
1602 if drag.mode == .fadeIn {
1603 model.clips[i].fadeIn = min(max(sec - orig.start, 0), orig.duration)
1604 } else {
1605 model.clips[i].fadeOut = min(max(orig.end - sec, 0), orig.duration)
1606 }
1607 }
1608 }
1609
1610 private func snapAdjust(start: Double, duration: Double, excluding: Set<UUID>)
1611 -> (adjust: Double, target: Double)? {
1612 // Holding ⇧ mid-drag temporarily inverts snapping.
1613 let inverted = NSEvent.modifierFlags.contains(.shift)
1614 guard session.snapping != inverted else { return nil }
1615 let threshold = 8.0 / pxPerSecond
1616 var targets: [Double] = [0, playback.playhead]
1617 for c in project.clips where !excluding.contains(c.id) {
1618 targets.append(c.start)
1619 targets.append(c.end)
1620 }
1621 var best: (adjust: Double, target: Double)?
1622 let edges = duration > 0 ? [start, start + duration] : [start]
1623 for t in targets {
1624 for e in edges {
1625 let adj = t - e
1626 if abs(adj) < threshold, best == nil || abs(adj) < abs(best!.adjust) {
1627 best = (adj, t)
1628 }
1629 }
1630 }
1631 return best
1632 }
1633
1634 // MARK: - Cursor & mouse tracking
1635
1636 override func updateTrackingAreas() {
1637 super.updateTrackingAreas()
1638 trackingAreas.forEach(removeTrackingArea)
1639 addTrackingArea(NSTrackingArea(
1640 rect: bounds, options: [.mouseMoved, .activeInKeyWindow, .inVisibleRect],
1641 owner: self, userInfo: nil))
1642 }
1643
1644 override func mouseMoved(with event: NSEvent) {
1645 let p = convert(event.locationInWindow, from: nil)
1646 lastMousePoint = p
1647 var cursor = NSCursor.arrow
1648 if let barMode = scrollbarHit(p) {
1649 switch barMode {
1650 case .hBarLeft, .hBarRight: cursor = .resizeLeftRight
1651 case .vBarTop, .vBarBottom: cursor = .resizeUpDown
1652 default: break
1653 }
1654 } else if trackBoundaryAt(y: p.y) != nil, p.y > lanesTop {
1655 cursor = .resizeUpDown
1656 } else if let (clip, row) = clipAt(point: p) {
1657 if session.mainTool == .blade {
1658 cursor = .crosshair
1659 } else if session.mainTool == .slide {
1660 cursor = .openHand
1661 } else {
1662 let rect = clipRect(clip, row: row)
1663 if p.x - rect.minX < 7 || rect.maxX - p.x < 7 {
1664 cursor = .resizeLeftRight
1665 }
1666 }
1667 }
1668 cursor.set()
1669 }
1670
1671 // MARK: - Keyboard (fallbacks; the menu bar owns the canonical bindings)
1672
1673 override func keyDown(with event: NSEvent) {
1674 let pc = playback
1675 switch event.charactersIgnoringModifiers?.lowercased() {
1676 case " ": pc.togglePlay()
1677 case "j": pc.shuttle(-1)
1678 case "k": pc.setRate(0)
1679 case "l": pc.shuttle(1)
1680 case "s": split()
1681 case "n": toggleNewShot()
1682 case "i": pc.setIn()
1683 case "o":
1684 if event.modifierFlags.contains(.option) { moveOverlapsToSeparateTracks() }
1685 else { pc.setOut() }
1686 case "c": pc.toggleLoop()
1687 case "m":
1688 if event.modifierFlags.contains(.option) { toggleMute() }
1689 else { toggleMarkerAtPlayhead() }
1690 case "v": session.mainTool = .select
1691 case "y": session.snapping.toggle()
1692 case "g":
1693 if event.modifierFlags.contains(.option) { unlinkSelection() }
1694 else { linkSelection() }
1695 case "b": splitStoryboardAtPlayhead(newShot: event.modifierFlags.contains(.shift))
1696 case "[":
1697 if event.modifierFlags.contains(.option) { goToPrevMarker() }
1698 else if event.modifierFlags.contains(.command) { goToPrevStoryboardPanel() }
1699 else { pc.step(by: -frameDur) }
1700 case "]":
1701 if event.modifierFlags.contains(.option) { goToNextMarker() }
1702 else if event.modifierFlags.contains(.command) { goToNextStoryboardPanel() }
1703 else { pc.step(by: frameDur) }
1704 default:
1705 switch event.keyCode {
1706 case 123 where event.modifierFlags.contains(.option): // ⌥←
1707 rippleTrimToPlayhead(deleteLeft: true)
1708 case 124 where event.modifierFlags.contains(.option): // ⌥→
1709 rippleTrimToPlayhead(deleteLeft: false)
1710 case 123: nudgeSelection(by: event.modifierFlags.contains(.shift) ? -1 : -frameDur) // ←
1711 case 124: nudgeSelection(by: event.modifierFlags.contains(.shift) ? 1 : frameDur) // →
1712 case 53: cancelOperation(nil) // esc
1713 case 115: pc.seek(to: 0) // Home
1714 case 119: pc.seek(to: project.timelineDuration) // End
1715 case 51, 117: // ⌫, ⌦
1716 if store.selection.isEmpty {
1717 closeBlankSpace(at: quantize(pc.playhead)) // "delete the space"
1718 } else if event.modifierFlags.contains(.option) {
1719 rippleDelete()
1720 } else {
1721 deleteSelection()
1722 }
1723 default: super.keyDown(with: event)
1724 }
1725 }
1726 }
1727
1728 /// ←/→ nudge the selected clips by a frame (⇧ = 1 s); with nothing
1729 /// selected they move the playhead like [ and ].
1730 func nudgeSelection(by seconds: Double) {
1731 let sel = project.expandLinks(store.selection)
1732 guard !sel.isEmpty else {
1733 playback.step(by: seconds)
1734 return
1735 }
1736 store.mutate { model in
1737 let minStart = model.clips.filter { sel.contains($0.id) }.map(\.start).min() ?? 0
1738 let d = max(seconds, -minStart)
1739 for i in model.clips.indices where sel.contains(model.clips[i].id) {
1740 model.clips[i].start += d
1741 }
1742 }
1743 }
1744
1745 // MARK: - Clip actions
1746
1747 func toggleMute() {
1748 var ids = store.selection
1749 if ids.isEmpty, let (clip, _) = clipAt(point: lastMousePoint) { ids = [clip.id] }
1750 guard !ids.isEmpty else { return }
1751 store.mutate { model in
1752 let allMuted = model.clips.filter { ids.contains($0.id) }.allSatisfy(\.muted)
1753 for i in model.clips.indices where ids.contains(model.clips[i].id) {
1754 model.clips[i].muted = !allMuted
1755 }
1756 }
1757 }
1758
1759 /// S — Reaper-style split at the playhead. When the playhead sits INSIDE
1760 /// a red overlap involving the selection (or any overlap when nothing is
1761 /// selected), the split resolves it: the earlier clip's out point and the
1762 /// later clip's in point meet at the playhead. Anywhere else it's a
1763 /// normal split (overlap left alone). Storyboard panels split by
1764 /// duplicating the drawing (the split time becomes the new panel's time).
1765 func split() {
1766 let t = quantize(playback.playhead)
1767 let sel = store.selection
1768 let atPlayhead = project.overlaps().filter { o in
1769 t > o.start + 1e-9 && t < o.end - 1e-9
1770 && (sel.isEmpty || sel.contains(o.a.id) || sel.contains(o.b.id))
1771 }
1772 if !atPlayhead.isEmpty {
1773 let fd = frameDur
1774 store.mutate { model in
1775 for o in atPlayhead {
1776 if let i = model.clips.firstIndex(where: { $0.id == o.a.id }) {
1777 model.clips[i].duration = max(fd, t - model.clips[i].start)
1778 }
1779 if let i = model.clips.firstIndex(where: { $0.id == o.b.id }) {
1780 let shift = t - model.clips[i].start
1781 if shift > 0, shift < model.clips[i].duration - fd / 2 {
1782 model.clips[i].srcIn += shift * model.clips[i].speed
1783 model.clips[i].start = t
1784 model.clips[i].duration -= shift
1785 }
1786 }
1787 }
1788 }
1789 NotificationCenter.default.post(name: .transientStatus, object: nil,
1790 userInfo: ["text": "Split resolved \(atPlayhead.count) overlap\(atPlayhead.count == 1 ? "" : "s") at the playhead"])
1791 return
1792 }
1793 bladeAtPlayhead(rightBoard: { board in boards.duplicate(board) })
1794 }
1795
1796 /// B — split the storyboard panel under the playhead into two, regardless
1797 /// of the selection; the new (right) panel inherits the drawing and becomes
1798 /// the selection. ⇧B (newShot) also flags that new panel as a new shot.
1799 /// With no panel under the playhead there's nothing to cut, so a fresh one
1800 /// is dropped there instead — so B always advances the storyboard.
1801 func splitStoryboardAtPlayhead(newShot: Bool = false) {
1802 let t = quantize(playback.playhead)
1803 guard let panel = project.clipAt(track: .storyboard, time: t, kind: .storyboard),
1804 t > panel.start + frameDur / 2, t < panel.end - frameDur / 2
1805 else {
1806 addStoryboardPanel(at: t)
1807 if newShot { toggleNewShot() }
1808 return
1809 }
1810 let rightBoard = panel.board.map { boards.duplicate($0) }
1811 ?? project.newBoard()
1812 var newId: UUID?
1813 store.mutate { model in
1814 guard let i = model.clips.firstIndex(where: { $0.id == panel.id }) else { return }
1815 var right = panel
1816 right.id = UUID()
1817 right.start = t
1818 right.srcIn = 0
1819 right.duration = panel.end - t
1820 right.board = rightBoard
1821 right.newShot = newShot
1822 model.clips[i].duration = t - panel.start
1823 model.clips.append(right)
1824 newId = right.id
1825 }
1826 if let id = newId { store.selection = [id] }
1827 }
1828
1829 /// N — toggle the "new shot" marker on the targeted storyboard panel(s):
1830 /// the selection if it holds any panels, else the panel under the playhead.
1831 func toggleNewShot() {
1832 let ids = newShotTargets()
1833 guard !ids.isEmpty else { return }
1834 store.mutate { model in
1835 // If any target isn't a new shot yet, turn them all on; else clear.
1836 let turnOn = ids.contains { id in
1837 model.clips.first(where: { $0.id == id })?.newShot == false
1838 }
1839 for i in model.clips.indices where ids.contains(model.clips[i].id) {
1840 model.clips[i].newShot = turnOn
1841 }
1842 }
1843 }
1844
1845 /// Storyboard panels the new-shot toggle acts on: selected panels, or —
1846 /// when nothing storyboard is selected — the panel under the playhead.
1847 func newShotTargets() -> Set<UUID> {
1848 var ids = store.selection.filter { project.clip($0)?.kind == .storyboard }
1849 if ids.isEmpty,
1850 let panel = project.clipAt(track: .storyboard,
1851 time: playback.playhead,
1852 kind: .storyboard) {
1853 ids = [panel.id]
1854 }
1855 return ids
1856 }
1857
1858 /// For the menu checkbox: nil = no storyboard panel is targeted; otherwise
1859 /// whether every targeted panel is already flagged a new shot.
1860 var newShotMenuState: Bool? {
1861 let ids = newShotTargets()
1862 guard !ids.isEmpty else { return nil }
1863 return ids.allSatisfy { project.clip($0)?.newShot == true }
1864 }
1865
1866 func bladeAtPlayhead(at time: Double? = nil, ids explicitIds: Set<UUID>? = nil,
1867 onlyStoryboards: Bool = false, newShot: Bool = false,
1868 rightBoard: (Board) -> Board) {
1869 let t = time ?? quantize(playback.playhead)
1870 var ids = explicitIds ?? store.selection
1871 // With nothing selected, S blades everything the playhead crosses — but
1872 // NOT storyboard panels (those only split via an explicit selection or
1873 // the N key, which passes onlyStoryboards).
1874 if ids.isEmpty {
1875 ids = Set(project.clips
1876 .filter { onlyStoryboards || $0.kind != .storyboard }
1877 .map(\.id))
1878 }
1879 ids = project.expandLinks(ids)
1880 let victims = project.clips.filter {
1881 ids.contains($0.id) && t > $0.start + frameDur / 2 && t < $0.end - frameDur / 2
1882 && (!onlyStoryboards || $0.kind == .storyboard)
1883 }
1884 guard !victims.isEmpty else { return }
1885 // When the split acted on an actual selection, both resulting halves
1886 // stay selected. (The blade tool passes explicitIds and leaves the
1887 // selection alone.)
1888 let reselect = explicitIds == nil && !store.selection.isEmpty
1889 var newRightIds: [UUID] = []
1890 var rightLinkIds: [UUID: UUID] = [:] // old linkId → new right-half linkId
1891 // Board copies happen OUTSIDE mutate (they touch disk).
1892 var rightBoards: [UUID: Board] = [:]
1893 for v in victims where v.kind == .storyboard {
1894 if let board = v.board { rightBoards[v.id] = rightBoard(board) }
1895 }
1896 store.mutate { model in
1897 for v in victims {
1898 guard let i = model.clips.firstIndex(where: { $0.id == v.id }) else { continue }
1899 var right = v
1900 right.id = UUID()
1901 newRightIds.append(right.id)
1902 right.start = t
1903 right.srcIn = v.srcIn + (t - v.start) * v.speed
1904 right.duration = v.end - t
1905 if let link = v.linkId {
1906 if rightLinkIds[link] == nil { rightLinkIds[link] = UUID() }
1907 right.linkId = rightLinkIds[link]
1908 }
1909 if v.kind == .storyboard {
1910 right.srcIn = 0
1911 right.board = rightBoards[v.id]
1912 right.newShot = newShot
1913 }
1914 // Fades stay on their outer edges; the cut itself is clean.
1915 if v.kind == .audio {
1916 right.fadeIn = 0
1917 right.fadeOut = min(v.fadeOut, right.duration)
1918 }
1919 model.clips[i].duration = t - v.start
1920 if v.kind == .audio {
1921 model.clips[i].fadeOut = 0
1922 model.clips[i].fadeIn = min(v.fadeIn, t - v.start)
1923 }
1924 model.clips.append(right)
1925 }
1926 }
1927 if reselect {
1928 store.selection = Set(victims.map(\.id)).union(newRightIds)
1929 }
1930 }
1931
1932 /// O — move overlapping clips apart: the later clip of each overlap goes
1933 /// to another track with room (that's what separate tracks are for),
1934 /// creating one when needed. Splitting at the playhead (S) is the other
1935 /// way to resolve.
1936 func moveOverlapsToSeparateTracks() {
1937 let before = project.overlaps().count
1938 guard before > 0 else {
1939 NotificationCenter.default.post(name: .transientStatus, object: nil,
1940 userInfo: ["text": "No overlaps to move"])
1941 return
1942 }
1943 store.mutate { model in
1944 var guardCount = 0
1945 while guardCount < 100 {
1946 guardCount += 1
1947 guard let o = model.overlaps().first,
1948 let bi = model.clips.firstIndex(where: { $0.id == o.b.id })
1949 else { break }
1950 let b = model.clips[bi]
1951 if b.kind == .storyboard { // panels can't truly overlap; park on the lane
1952 model.clips[bi].track = .storyboard
1953 continue
1954 }
1955 // A video lane with no conflicting clip, else a fresh one.
1956 let targetIndex = model.tracks.indices.first(where: { idx in
1957 TrackRef.video(idx) != b.track
1958 && !model.clips.contains {
1959 $0.id != b.id && $0.track == .video(idx) && $0.kind != .audio
1960 && $0.start < b.end && $0.end > b.start
1961 }
1962 }) ?? model.addTrack()
1963 model.clips[bi].track = .video(targetIndex)
1964 }
1965 }
1966 NotificationCenter.default.post(name: .transientStatus, object: nil,
1967 userInfo: ["text": "Moved \(before) overlap\(before == 1 ? "" : "s") to separate tracks"])
1968 }
1969
1970 func linkSelection() {
1971 let ids = store.selection
1972 guard ids.count > 1 else { return }
1973 let link = UUID()
1974 store.mutate { model in
1975 for i in model.clips.indices where ids.contains(model.clips[i].id) {
1976 model.clips[i].linkId = link
1977 }
1978 }
1979 NotificationCenter.default.post(name: .transientStatus, object: nil,
1980 userInfo: ["text": "Linked \(ids.count) clips"])
1981 }
1982
1983 func unlinkSelection() {
1984 let ids = project.expandLinks(store.selection)
1985 guard !ids.isEmpty else { return }
1986 store.mutate { model in
1987 for i in model.clips.indices where ids.contains(model.clips[i].id) {
1988 model.clips[i].linkId = nil
1989 }
1990 }
1991 }
1992
1993 func deleteSelection() {
1994 // Deleting a linked clip deletes its whole link group.
1995 let sel = project.expandLinks(store.selection)
1996 guard !sel.isEmpty else { return }
1997 store.mutate { model in
1998 model.clips.removeAll { sel.contains($0.id) }
1999 model.pruneTrailingEmptyTracks()
2000 }
2001 }
2002
2003 /// ⌥⌫ — ripple delete: remove the selection and close the time gap it
2004 /// occupied, shifting everything later (on ALL tracks, so multicam sync
2005 /// holds) left by the gap.
2006 func rippleDelete() {
2007 let sel = project.expandLinks(store.selection)
2008 guard !sel.isEmpty else { return }
2009 let victims = project.clips.filter { sel.contains($0.id) }
2010 // Storyboard panels are start-only points; only solid clips define
2011 // the range that closes.
2012 let solid = victims.filter { $0.kind != .storyboard }
2013 let start = solid.map(\.start).min()
2014 let end = solid.map(\.end).max()
2015 store.mutate { model in
2016 model.clips.removeAll { sel.contains($0.id) }
2017 if let start, let end, end > start {
2018 let gap = end - start
2019 for i in model.clips.indices where model.clips[i].start >= end - 1e-9 {
2020 model.clips[i].start = max(0, model.clips[i].start - gap)
2021 }
2022 }
2023 model.pruneTrailingEmptyTracks()
2024 }
2025 // Park the playhead where the gap closed, so it follows the content
2026 // that just slid left instead of hanging over the removed span.
2027 if let start { playback.seek(to: start) }
2028 }
2029
2030 /// ⌥← / ⌥→ — split the clip(s) under the playhead and ripple-delete the
2031 /// side toward the arrow, closing the gap. With a selection it acts on the
2032 /// selected/linked clips the playhead crosses; with none, on every angle
2033 /// under the playhead (so multicam stays in sync).
2034 func rippleTrimToPlayhead(deleteLeft: Bool) {
2035 let t = quantize(playback.playhead)
2036 let intersects: (Clip) -> Bool = {
2037 $0.kind != .storyboard && $0.start + 1e-6 < t && $0.end - 1e-6 > t
2038 }
2039 let sel = project.expandLinks(store.selection)
2040 let hadSelection = !store.selection.isEmpty
2041 var targets = project.clips.filter { sel.contains($0.id) && intersects($0) }
2042 if targets.isEmpty { targets = project.clips.filter(intersects) }
2043 guard !targets.isEmpty else { return }
2044 let gapStart = deleteLeft ? targets.map(\.start).min()! : t
2045 let gapEnd = deleteLeft ? t : targets.map(\.end).max()!
2046 let gap = gapEnd - gapStart
2047 guard gap > 1e-6 else { return }
2048 let ids = Set(targets.map(\.id))
2049 store.mutate { model in
2050 for tgt in model.clips where ids.contains(tgt.id) {
2051 guard let i = model.clips.firstIndex(where: { $0.id == tgt.id }) else { continue }
2052 if deleteLeft {
2053 model.clips[i].srcIn = tgt.srcIn + (t - tgt.start) * tgt.speed
2054 model.clips[i].start = t
2055 model.clips[i].duration = tgt.end - t
2056 } else {
2057 model.clips[i].duration = t - tgt.start
2058 }
2059 }
2060 // Close the gap on EVERY track (the kept right pieces start at
2061 // gapEnd, so they ride left with everything else).
2062 for i in model.clips.indices where model.clips[i].start >= gapEnd - 1e-9 {
2063 model.clips[i].start = max(0, model.clips[i].start - gap)
2064 }
2065 model.pruneTrailingEmptyTracks()
2066 }
2067 // The kept piece keeps its id, so a trim that started from a selection
2068 // leaves that resulting clip selected.
2069 if hadSelection { store.selection = ids }
2070 playback.seek(to: gapStart)
2071 }
2072
2073 /// The closeable blank column at a moment: nothing under it on any track,
2074 /// bounded by the previous content end and the next content start.
2075 func blankGap(at time: Double) -> (start: Double, end: Double)? {
2076 let solids = project.clips.filter { $0.kind != .storyboard }
2077 guard !solids.contains(where: { $0.start - 1e-6 < time && $0.end - 1e-6 > time })
2078 else { return nil } // something is under the playhead — not blank
2079 let start = solids.filter { $0.end <= time + 1e-6 }.map(\.end).max() ?? 0
2080 guard let end = solids.filter({ $0.start > time - 1e-6 }).map(\.start).min(),
2081 end - start > 1e-6 else { return nil } // trailing/zero blank
2082 return (start, end)
2083 }
2084
2085 func closeBlankSpaceAtPlayhead() {
2086 closeBlankSpace(at: quantize(playback.playhead))
2087 }
2088
2089 /// "Delete the space": ripple the blank column at `time` closed on every
2090 /// track. Bound to ⌫/⌥⌫ with no selection, and the empty-lane menu.
2091 func closeBlankSpace(at time: Double) {
2092 guard let g = blankGap(at: time) else { return }
2093 let gap = g.end - g.start
2094 store.mutate { model in
2095 for i in model.clips.indices where model.clips[i].start >= g.end - 1e-9 {
2096 model.clips[i].start = max(0, model.clips[i].start - gap)
2097 }
2098 model.pruneTrailingEmptyTracks()
2099 }
2100 playback.seek(to: g.start)
2101 }
2102
2103 /// Esc — drop the selection and stop playback (cancels an open drag too).
2104 override func cancelOperation(_ sender: Any?) {
2105 if store.gestureBaseModel != nil { store.cancelGesture() }
2106 store.selection = []
2107 playback.setRate(0)
2108 needsDisplay = true
2109 }
2110
2111 // MARK: - Responder-chain edit commands (menu Cut/Copy/Paste/Select All)
2112
2113 override func selectAll(_ sender: Any?) {
2114 store.selection = Set(project.clips.map(\.id))
2115 }
2116
2117 private var selectedStoryboardClip: Clip? {
2118 project.clips.first { store.selection.contains($0.id) && $0.kind == .storyboard }
2119 }
2120
2121 static let clipsPasteboardType = NSPasteboard.PasteboardType("com.sequencer.clips")
2122
2123 private struct ClipsTransfer: Codable {
2124 var fps: Double
2125 var clips: [Clip]
2126 var media: [MediaItem]
2127 var rasters: [UUID: Data] // boardId → raster PNG for storyboard panels
2128 }
2129
2130 /// ⌘C — copying clips IS copying for Fusion: the text on the pasteboard
2131 /// is Loader Lua (paste straight into the Flow view). A full clip payload
2132 /// rides along for ⌘V back into Sequencer, and a lone storyboard panel
2133 /// also puts its flattened PNG up for other apps.
2134 @objc func copy(_ sender: Any?) {
2135 let sel = project.expandLinks(store.selection)
2136 let clips = project.clips.filter { sel.contains($0.id) }
2137 .sorted { $0.start < $1.start }
2138 guard !clips.isEmpty else {
2139 NotificationCenter.default.post(name: .transientStatus, object: nil,
2140 userInfo: ["text": "Nothing selected to copy"])
2141 return
2142 }
2143 let pb = NSPasteboard.general
2144 pb.clearContents()
2145 let loaders = clips.filter { $0.kind == .video && $0.mediaId != nil }
2146 if !loaders.isEmpty {
2147 pb.setString(FusionExport.loaderLua(for: loaders, project: project),
2148 forType: .string)
2149 }
2150 var rasters: [UUID: Data] = [:]
2151 for c in clips where c.kind == .storyboard {
2152 if let b = c.board, let png = boards.rasterPNGData(b.id) {
2153 rasters[b.id] = png
2154 }
2155 }
2156 let mediaIds = Set(clips.compactMap(\.mediaId))
2157 let transfer = ClipsTransfer(fps: project.fps, clips: clips,
2158 media: project.media.filter { mediaIds.contains($0.id) },
2159 rasters: rasters)
2160 if let data = try? JSONEncoder().encode(transfer) {
2161 pb.setData(data, forType: Self.clipsPasteboardType)
2162 }
2163 if clips.count == 1, clips[0].kind == .storyboard, let board = clips[0].board {
2164 let composite = boards.composite(for: board)
2165 if let tiff = composite.tiffRepresentation,
2166 let rep = NSBitmapImageRep(data: tiff),
2167 let png = rep.representation(using: .png, properties: [:]) {
2168 pb.setData(png, forType: .png)
2169 }
2170 }
2171 let what = "Copied \(clips.count) clip\(clips.count == 1 ? "" : "s")"
2172 NotificationCenter.default.post(name: .transientStatus, object: nil,
2173 userInfo: ["text": what])
2174 }
2175
2176 /// ⌘V — clips on the pasteboard land at the playhead (relative offsets
2177 /// kept, original tracks when they still exist). Otherwise an image or
2178 /// panel pastes INTO the selected storyboard panel as before.
2179 @objc func paste(_ sender: Any?) {
2180 let pb = NSPasteboard.general
2181 if let data = pb.data(forType: Self.clipsPasteboardType),
2182 let transfer = try? JSONDecoder().decode(ClipsTransfer.self, from: data) {
2183 pasteClips(transfer)
2184 return
2185 }
2186 guard let clip = selectedStoryboardClip else {
2187 NotificationCenter.default.post(name: .transientStatus, object: nil,
2188 userInfo: ["text": "Clipboard has no clips — select a storyboard panel to paste an image into"])
2189 return
2190 }
2191 let size = clip.board?.size ?? CGSize(width: 1600, height: 900)
2192 guard let board = boards.panelFromPasteboard(size: size) else {
2193 NotificationCenter.default.post(name: .transientStatus, object: nil,
2194 userInfo: ["text": "Clipboard has no panel or image"])
2195 return
2196 }
2197 store.mutate { model in
2198 guard let i = model.clips.firstIndex(where: { $0.id == clip.id }) else { return }
2199 model.clips[i].board = board
2200 }
2201 NotificationCenter.default.post(name: .transientStatus, object: nil,
2202 userInfo: ["text": "Pasted panel"])
2203 }
2204
2205 private func pasteClips(_ t: ClipsTransfer) {
2206 let t0 = t.clips.map(\.start).min() ?? 0
2207 let offset = quantize(playback.playhead - t0)
2208 var newIds: Set<UUID> = []
2209 store.mutate { model in
2210 var mediaMap: [UUID: UUID] = [:]
2211 for m in t.media {
2212 if let existing = model.media.first(where: {
2213 ($0.cacheKey == m.cacheKey && !m.cacheKey.isEmpty) || $0.id == m.id
2214 }) {
2215 mediaMap[m.id] = existing.id
2216 } else {
2217 model.media.append(m)
2218 mediaMap[m.id] = m.id
2219 }
2220 }
2221 var linkMap: [UUID: UUID] = [:]
2222 var trackMap: [Int: Int] = [:] // source video lane → lane in this project
2223 for var c in t.clips {
2224 let oldBoardId = c.board?.id
2225 c.id = UUID()
2226 c.start = max(0, c.start + offset)
2227 if let mid = c.mediaId { c.mediaId = mediaMap[mid] }
2228 if let l = c.linkId {
2229 if linkMap[l] == nil { linkMap[l] = UUID() }
2230 c.linkId = linkMap[l]
2231 }
2232 if c.kind == .storyboard {
2233 var b = c.board ?? model.newBoard()
2234 b.id = UUID()
2235 b.revision = 0
2236 if let old = oldBoardId, let png = t.rasters[old] {
2237 boards.setRaster(fromPNG: png, boardId: b.id)
2238 }
2239 c.board = b
2240 c.track = .storyboard
2241 } else if let vi = c.track.videoIndex, !model.tracks.indices.contains(vi) {
2242 // Source lane this project doesn't have — make one (deduped).
2243 if trackMap[vi] == nil { trackMap[vi] = model.addTrack() }
2244 c.track = .video(trackMap[vi]!)
2245 }
2246 newIds.insert(c.id)
2247 model.clips.append(c)
2248 }
2249 }
2250 store.selection = newIds
2251 NotificationCenter.default.post(name: .transientStatus, object: nil,
2252 userInfo: ["text": "Pasted \(newIds.count) clip\(newIds.count == 1 ? "" : "s") at the playhead"])
2253 }
2254
2255 @objc func cut(_ sender: Any?) {
2256 copy(sender)
2257 deleteSelection()
2258 }
2259
2260 // MARK: - Track actions
2261
2262 func resetTrackVisibility() {
2263 session.hiddenTracks = []
2264 session.focusedTracks = []
2265 session.fusionHidden = false
2266 session.fusionFocus = false
2267 session.priorityPane = nil
2268 NotificationCenter.default.post(name: .transientStatus, object: nil,
2269 userInfo: ["text": "All tracks visible"])
2270 }
2271
2272 func deleteEmptyTracks() {
2273 store.mutate { $0.pruneEmptyTracks() }
2274 }
2275
2276 /// New storyboard panel at the playhead on THE storyboard track
2277 /// (created on demand — panels are isolated to it).
2278 func addStoryboardPanel(at time: Double? = nil) {
2279 var t = quantize(time ?? playback.playhead)
2280 // Don't stack a new panel on top of one already starting here: bump it a
2281 // second into the future, or halfway to the next panel if one sits
2282 // within that second.
2283 do {
2284 let starts = project.clips
2285 .filter { $0.kind == .storyboard }
2286 .map(\.start)
2287 if starts.contains(where: { abs($0 - t) < frameDur / 2 }) {
2288 let next = starts.filter { $0 > t + frameDur / 2 }.min()
2289 // A panel exactly a second out still collides if we land on it,
2290 // so treat "within a second" inclusively and split the gap.
2291 if let next, next < t + 1 + frameDur / 2 {
2292 t = quantize((t + next) / 2)
2293 } else {
2294 t = quantize(t + 1)
2295 }
2296 }
2297 }
2298 var newClipId: UUID?
2299 store.mutate { model in
2300 let clip = Clip(mediaId: nil, track: .storyboard, start: t, srcIn: 0,
2301 duration: 3, kind: .storyboard, board: model.newBoard())
2302 newClipId = clip.id
2303 model.clips.append(clip)
2304 }
2305 if let id = newClipId {
2306 store.selection = [id]
2307 }
2308 // Move the playhead onto the new panel so mashing B keeps stepping
2309 // forward a panel at a time.
2310 playback.seek(to: t)
2311 }
2312
2313 /// B — new empty panel one second after the panel under the playhead (or
2314 /// the playhead itself), and the playhead jumps onto it: mash B to rough
2315 /// out shot timings a second apart before the real timings are known.
2316 func addPanelOneSecondLater() {
2317 let pc = playback
2318 var t = quantize(pc.playhead + 1)
2319 if let panel = project.clipAt(track: .storyboard, time: pc.playhead,
2320 kind: .storyboard) {
2321 t = quantize(panel.start + 1)
2322 }
2323 // addStoryboardPanel resolves any collision and seeks the playhead onto
2324 // the panel it actually created.
2325 addStoryboardPanel(at: t)
2326 }
2327
2328 // MARK: - Markers
2329
2330 /// Drop a marker at the playhead, or remove the one already sitting there
2331 /// (so the same key toggles). One undo step.
2332 func toggleMarkerAtPlayhead() {
2333 let t = quantize(playback.playhead)
2334 store.mutate { model in
2335 let half = 0.5 / max(1, model.fps)
2336 if let idx = model.markers.firstIndex(where: { abs($0.time - t) < half }) {
2337 model.markers.remove(at: idx)
2338 } else {
2339 model.markers.append(Marker(time: t))
2340 }
2341 }
2342 }
2343
2344 /// ⌘] / ⌘[ — jump the playhead to the next/previous storyboard panel start.
2345 func goToNextStoryboardPanel() {
2346 let t = playback.playhead
2347 guard let next = project.clips
2348 .filter({ $0.kind == .storyboard && $0.start > t + frameDur / 2 })
2349 .map(\.start).min()
2350 else { return }
2351 playback.seek(to: next)
2352 }
2353
2354 func goToPrevStoryboardPanel() {
2355 let t = playback.playhead
2356 guard let prev = project.clips
2357 .filter({ $0.kind == .storyboard && $0.start < t - frameDur / 2 })
2358 .map(\.start).max()
2359 else { return }
2360 playback.seek(to: prev)
2361 }
2362
2363 func goToNextMarker() {
2364 guard let m = project.nextMarker(after: playback.playhead) else { return }
2365 playback.seek(to: m.time)
2366 }
2367
2368 func goToPrevMarker() {
2369 guard let m = project.prevMarker(before: playback.playhead) else { return }
2370 playback.seek(to: m.time)
2371 }
2372
2373 func clearAllMarkers() {
2374 guard !project.markers.isEmpty else { return }
2375 store.mutate { $0.markers.removeAll() }
2376 }
2377
2378 private func deleteMarker(_ id: UUID) {
2379 store.mutate { $0.markers.removeAll { $0.id == id } }
2380 }
2381
2382 /// Set (or clear) a marker's label. Driven by the inline name field in the
2383 /// marker's right-click menu.
2384 private func setMarkerLabel(_ id: UUID, _ label: String) {
2385 store.mutate { model in
2386 if let i = model.markers.firstIndex(where: { $0.id == id }) {
2387 model.markers[i].label = label
2388 }
2389 }
2390 }
2391
2392 // MARK: - Context menus (right-click everywhere)
2393
2394 private var ctxTrack: TrackRef?
2395 private var ctxCompPath: String?
2396 private var ctxMarkerId: UUID?
2397 // The inline name field of the open marker menu, committed on menuDidClose.
2398 private weak var markerNameField: MarkerNameMenuField?
2399 private var markerNameOriginal = ""
2400
2401 override func menu(for event: NSEvent) -> NSMenu? {
2402 let p = convert(event.locationInWindow, from: nil)
2403 lastMousePoint = p
2404 let menu = NSMenu()
2405 func add(_ title: String, _ action: Selector, key: String = "",
2406 mods: NSEvent.ModifierFlags = []) {
2407 let mi = NSMenuItem(title: title, action: action, keyEquivalent: key)
2408 mi.target = self
2409 mi.keyEquivalentModifierMask = mods
2410 menu.addItem(mi)
2411 }
2412
2413 // Ruler: markers (rename/delete an existing flag, or add one here).
2414 if p.y < rulerH {
2415 if let m = markerAt(point: p) {
2416 ctxMarkerId = m.id
2417 // Name lives inline in the menu (like Frame Rate), not a popup.
2418 // Committed on menuDidClose so Return / click-away all persist.
2419 let nameField = MarkerNameMenuField(name: m.label)
2420 nameField.onReturn = { [weak menu] in menu?.cancelTracking() }
2421 markerNameField = nameField
2422 markerNameOriginal = m.label
2423 menu.delegate = self
2424 let nameItem = NSMenuItem()
2425 nameItem.view = nameField
2426 menu.addItem(nameItem)
2427 menu.addItem(.separator())
2428 add("Delete Marker", #selector(ctxDeleteMarker), key: "\u{8}")
2429 } else {
2430 add("Add Marker Here", #selector(ctxAddMarkerHere))
2431 }
2432 if !project.markers.isEmpty {
2433 menu.addItem(.separator())
2434 add("Clear All Markers", #selector(ctxClearMarkers))
2435 }
2436 return menu
2437 }
2438
2439 // Fusion comps
2440 if let comp = compAt(point: p) {
2441 comps.selectedCompPath = comp.path
2442 ctxCompPath = comp.path
2443 needsDisplay = true
2444 let preferred = project.preferredTakes.contains(comp.name)
2445 add(preferred ? "Unmark Preferred Take" : "Set as Preferred Take",
2446 #selector(ctxTogglePreferred), key: "t")
2447 add("Open in Fusion", #selector(ctxOpenComp))
2448 menu.addItem(.separator())
2449 add("Rescan Comps", #selector(ctxRescanComps))
2450 return menu
2451 }
2452 if fusionBandH > 0, p.y > rulerH, p.y < lanesTop {
2453 add(session.fusionHidden ? "Show Fusion Preview" : "Hide Fusion Preview",
2454 #selector(ctxToggleFusionHidden))
2455 add(session.fusionFocus ? "Unfocus Fusion" : "Focus Fusion",
2456 #selector(ctxToggleFusionFocus))
2457 add("Rescan Comps", #selector(ctxRescanComps))
2458 return menu
2459 }
2460
2461 // Track header
2462 if p.x < headerW, let row = rowAt(y: p.y), let ref = laneRef(row: row) {
2463 ctxTrack = ref
2464 add(session.hiddenTracks.contains(ref) ? "Show Preview" : "Hide Preview",
2465 #selector(ctxToggleHidden))
2466 add(session.focusedTracks.contains(ref) ? "Unfocus" : "Focus",
2467 #selector(ctxToggleFocus))
2468 add("Show All Tracks", #selector(ctxResetVisibility))
2469 menu.addItem(.separator())
2470 // Only real video lanes can be deleted (the storyboard lane clears
2471 // itself when its panels are gone).
2472 if ref.videoIndex != nil { add("Delete Track", #selector(ctxDeleteTrack)) }
2473 add("Delete Empty Tracks", #selector(ctxDeleteEmptyTracks))
2474 return menu
2475 }
2476
2477 // Clips
2478 if let (clip, _) = clipAt(point: p) {
2479 if !store.selection.contains(clip.id) { store.selection = [clip.id] }
2480 add("Split at Playhead", #selector(ctxSplit), key: "s")
2481 if clip.kind == .storyboard {
2482 add("Split Storyboard at Playhead", #selector(ctxSplitStoryboard), key: "b")
2483 add("Split Storyboard, New Shot", #selector(ctxSplitStoryboardNewShot),
2484 key: "B", mods: .shift)
2485 add("Is New Shot", #selector(ctxToggleNewShot), key: "n")
2486 menu.items.last?.state = clip.newShot ? .on : .off
2487 add("New Panel 1 s Later", #selector(ctxPanelLater))
2488 add("Open in Storyboard Window", #selector(ctxOpenBoard))
2489 }
2490 add(clip.muted ? "Unmute" : "Mute", #selector(ctxMute), key: "m")
2491 if clip.speed != 1 { add("Reset Speed (×1)", #selector(ctxResetSpeed)) }
2492 menu.addItem(.separator())
2493 if store.selection.count > 1 { add("Link Clips", #selector(ctxLink), key: "g") }
2494 if clip.linkId != nil { add("Unlink Clips", #selector(ctxUnlink)) }
2495 add("Copy", #selector(ctxCopy))
2496 menu.addItem(.separator())
2497 add("Delete", #selector(ctxDelete), key: "\u{8}")
2498 add("Ripple Delete", #selector(ctxRippleDelete), key: "\u{8}", mods: .option)
2499 return menu
2500 }
2501
2502 // Empty lane space
2503 if rowAt(y: p.y) != nil {
2504 if blankGap(at: quantize(secondsFor(p.x))) != nil {
2505 add("Delete the Space (Close Gap)", #selector(ctxCloseGap),
2506 key: "\u{8}")
2507 menu.addItem(.separator())
2508 }
2509 add("Add Storyboard Panel Here", #selector(ctxAddPanelHere))
2510 if !project.overlaps().isEmpty {
2511 add("Move Overlaps to Separate Tracks", #selector(ctxMoveOverlaps),
2512 key: "o", mods: .option)
2513 }
2514 add("Paste Panel", #selector(ctxPaste))
2515 return menu
2516 }
2517 return nil
2518 }
2519
2520 @objc private func ctxTogglePreferred() { comps.togglePreferredTake() }
2521 @objc private func ctxOpenComp() {
2522 if let comp = comps.comp(at: ctxCompPath) {
2523 comps.openInFusion(comp)
2524 }
2525 }
2526 @objc private func ctxRescanComps() { comps.rescan() }
2527 @objc private func ctxDeleteMarker() { if let id = ctxMarkerId { deleteMarker(id) } }
2528 @objc private func ctxClearMarkers() { clearAllMarkers() }
2529 @objc private func ctxAddMarkerHere() {
2530 let t = max(0, quantize(secondsFor(lastMousePoint.x)))
2531 store.mutate { model in
2532 let half = 0.5 / max(1, model.fps)
2533 if !model.markers.contains(where: { abs($0.time - t) < half }) {
2534 model.markers.append(Marker(time: t))
2535 }
2536 }
2537 }
2538 @objc private func ctxToggleFusionHidden() { session.fusionHidden.toggle(); needsDisplay = true }
2539 @objc private func ctxToggleFusionFocus() { session.fusionFocus.toggle(); needsDisplay = true }
2540 @objc private func ctxToggleHidden() {
2541 if let ref = ctxTrack { session.toggleHidden(ref); needsDisplay = true }
2542 }
2543 @objc private func ctxToggleFocus() {
2544 if let ref = ctxTrack { session.toggleFocus(ref); needsDisplay = true }
2545 }
2546 @objc private func ctxResetVisibility() { resetTrackVisibility() }
2547 @objc private func ctxDeleteTrack() {
2548 guard let vi = ctxTrack?.videoIndex else { return }
2549 store.mutate { model in
2550 guard model.tracks.count > 1 else { return }
2551 model.clips.removeAll { $0.track == .video(vi) }
2552 model.removeTrack(at: vi) // renumbers the lanes above it
2553 }
2554 }
2555 @objc private func ctxDeleteEmptyTracks() { deleteEmptyTracks() }
2556 @objc private func ctxSplit() { split() }
2557 @objc private func ctxSplitStoryboard() { splitStoryboardAtPlayhead() }
2558 @objc private func ctxSplitStoryboardNewShot() { splitStoryboardAtPlayhead(newShot: true) }
2559 @objc private func ctxOpenBoard() {
2560 if let id = store.selection.first,
2561 project.clip(id)?.kind == .storyboard {
2562 StoryboardEditor.shared.open(clipId: id, ctx: ctx)
2563 }
2564 }
2565 @objc private func ctxMute() { toggleMute() }
2566 @objc private func ctxResetSpeed() {
2567 let sel = store.selection
2568 store.mutate { model in
2569 for i in model.clips.indices where sel.contains(model.clips[i].id) {
2570 model.clips[i].speed = 1
2571 }
2572 }
2573 }
2574 @objc private func ctxLink() { linkSelection() }
2575 @objc private func ctxUnlink() { unlinkSelection() }
2576 @objc private func ctxCopy() { copy(nil) }
2577 @objc private func ctxToggleNewShot() { toggleNewShot() }
2578 @objc private func ctxPanelLater() { addPanelOneSecondLater() }
2579 @objc private func ctxDelete() { deleteSelection() }
2580 @objc private func ctxRippleDelete() { rippleDelete() }
2581 @objc private func ctxMoveOverlaps() { moveOverlapsToSeparateTracks() }
2582 @objc private func ctxPaste() { paste(nil) }
2583 @objc private func ctxCloseGap() { closeBlankSpace(at: quantize(secondsFor(lastMousePoint.x))) }
2584 @objc private func ctxAddPanelHere() {
2585 playback.seek(to: max(0, quantize(secondsFor(lastMousePoint.x))))
2586 addStoryboardPanel()
2587 }
2588
2589 // MARK: - Zoom & pan
2590
2591 /// Overscroll: ~600 px of empty room before 0 and a couple of minutes
2592 /// past the last clip (the playhead may live out there), but bounded.
2593 private func clampOrigin(_ o: Double) -> Double {
2594 let overscroll = 600.0 / pxPerSecond
2595 return min(max(o, -overscroll), project.timelineDuration + 120)
2596 }
2597
2598 override func scrollWheel(with event: NSEvent) {
2599 if event.modifierFlags.contains(.command) {
2600 zoom(by: 1 + event.scrollingDeltaY * 0.01, anchorX: convert(event.locationInWindow, from: nil).x)
2601 } else {
2602 let dy = event.scrollingDeltaY
2603 // Vertical scrolling moves through the tracks when they overflow;
2604 // otherwise (and for the horizontal axis) it pans time.
2605 if maxScrollY > 0, abs(dy) > abs(event.scrollingDeltaX) {
2606 scrollY = min(max(0, scrollY - dy), maxScrollY)
2607 } else {
2608 let dx = event.scrollingDeltaX != 0 ? event.scrollingDeltaX : dy
2609 originSecond = clampOrigin(originSecond - Double(dx) / pxPerSecond)
2610 }
2611 }
2612 needsDisplay = true
2613 }
2614
2615 override func magnify(with event: NSEvent) {
2616 zoom(by: 1 + event.magnification, anchorX: convert(event.locationInWindow, from: nil).x)
2617 }
2618
2619 private func zoom(by factor: CGFloat, anchorX: CGFloat) {
2620 let anchorSec = secondsFor(anchorX)
2621 pxPerSecond = min(max(pxPerSecond * Double(factor), 0.05), 4000)
2622 originSecond = anchorSec - Double(anchorX - headerW) / pxPerSecond
2623 needsDisplay = true
2624 }
2625
2626 func zoomToFit() {
2627 let dur = max(10, project.timelineDuration)
2628 pxPerSecond = Double(bounds.width - headerW) * 0.92 / dur
2629 originSecond = -0.04 * dur
2630 needsDisplay = true
2631 }
2632
2633 // MARK: - File drop import
2634
2635 /// Live preview of where a hovering file drop will land: the resolved
2636 /// streams, their shared anchor, the drop time, and the first row.
2637 private struct FileDropPreview {
2638 var streams: [DropStream]
2639 var minOffset: Double
2640 var dropSec: Double
2641 var baseRow: Int
2642 }
2643 private var fileDropPreview: FileDropPreview?
2644 private var dropDurations: [String: Double] = [:] // path → probed seconds
2645 private var dropProbing: Set<String> = []
2646
2647 private func droppableFiles(from sender: NSDraggingInfo) -> [URL] {
2648 guard let urls = sender.draggingPasteboard
2649 .readObjects(forClasses: [NSURL.self]) as? [URL] else { return [] }
2650 return urls.filter {
2651 UI.importableExtensions.contains($0.pathExtension.lowercased())
2652 || $0.lastPathComponent == "sync.json"
2653 || (try? $0.resourceValues(forKeys: [.isDirectoryKey]).isDirectory) == true
2654 }
2655 }
2656
2657 /// Probe (once) each stream's duration so the preview rect has real width.
2658 private func ensureDropDurations(_ streams: [DropStream]) {
2659 for s in streams where dropDurations[s.url.path] == nil
2660 && !dropProbing.contains(s.url.path) {
2661 dropProbing.insert(s.url.path)
2662 MediaPipeline.shared.probeDuration(s.url) { [weak self] d in
2663 guard let self else { return }
2664 self.dropProbing.remove(s.url.path)
2665 if let d, d > 0 { self.dropDurations[s.url.path] = d; self.needsDisplay = true }
2666 }
2667 }
2668 }
2669
2670 private func updateFileDropPreview(_ sender: NSDraggingInfo) {
2671 let streams = expandDropStreams(droppableFiles(from: sender))
2672 guard !streams.isEmpty else { fileDropPreview = nil; needsDisplay = true; return }
2673 let p = convert(sender.draggingLocation, from: nil)
2674 let count = project.laneRefs.count
2675 fileDropPreview = FileDropPreview(
2676 streams: streams,
2677 minOffset: streams.map(\.offset).min() ?? 0,
2678 dropSec: max(0, quantize(secondsFor(p.x))),
2679 baseRow: rowAt(y: p.y).map { min($0, count) } ?? count)
2680 ensureDropDurations(streams)
2681 needsDisplay = true
2682 }
2683
2684 override func draggingEntered(_ sender: NSDraggingInfo) -> NSDragOperation {
2685 updateFileDropPreview(sender)
2686 return .copy
2687 }
2688
2689 override func draggingUpdated(_ sender: NSDraggingInfo) -> NSDragOperation {
2690 updateFileDropPreview(sender)
2691 return .copy
2692 }
2693
2694 override func draggingExited(_ sender: NSDraggingInfo?) {
2695 fileDropPreview = nil
2696 needsDisplay = true
2697 }
2698
2699 override func performDragOperation(_ sender: NSDraggingInfo) -> Bool {
2700 fileDropPreview = nil
2701 needsDisplay = true
2702 let p = convert(sender.draggingLocation, from: nil)
2703 let files = droppableFiles(from: sender)
2704 guard !files.isEmpty else { return false }
2705 let dropSec = max(0, quantize(secondsFor(p.x)))
2706 let row = rowAt(y: p.y).flatMap { $0 < project.laneRefs.count ? $0 : nil }
2707 importFiles(files, atSecond: dropSec, targetRow: row)
2708 return true
2709 }
2710
2711 /// Draw each hovering stream where it will actually land — real start time,
2712 /// probed width, one row per stream — creating ghost lanes below the last
2713 /// track exactly the way dragging an existing clip down does.
2714 private func drawFileDropPreview() {
2715 guard let dp = fileDropPreview else { return }
2716 let count = project.laneRefs.count
2717 for (i, s) in dp.streams.enumerated() {
2718 let row = dp.baseRow + i
2719 let lane = laneRect(row: row)
2720 guard lane.minY < bounds.maxY, lane.maxY > lanesTop else { continue }
2721 // Ghost lane outline for rows that don't exist yet.
2722 if row >= count {
2723 let outline = NSBezierPath(roundedRect: lane.insetBy(dx: 2, dy: 2),
2724 xRadius: 4, yRadius: 4)
2725 outline.setLineDash([4, 4], count: 2, phase: 0)
2726 Theme.dragHint.withAlphaComponent(0.5).setStroke()
2727 outline.stroke()
2728 }
2729 let start = max(0, dp.dropSec + (s.offset - dp.minOffset))
2730 let known = dropDurations[s.url.path]
2731 let x0 = xFor(start)
2732 let w = max(3, CGFloat(known ?? 2) * CGFloat(pxPerSecond))
2733 let rect = NSRect(x: x0, y: lane.minY + 1, width: w, height: lane.height - 2)
2734 guard rect.maxX > headerW, rect.minX < bounds.width else { continue }
2735 let body = NSBezierPath(roundedRect: rect, xRadius: 3, yRadius: 3)
2736 Theme.dragHint.withAlphaComponent(known != nil ? 0.35 : 0.18).setFill()
2737 body.fill()
2738 body.lineWidth = 1.5
2739 if known == nil { body.setLineDash([3, 3], count: 2, phase: 0) } // still probing
2740 Theme.dragHint.withAlphaComponent(0.9).setStroke()
2741 body.stroke()
2742 }
2743 }
2744
2745 /// One stream that a drop resolves to: a media file and its relative
2746 /// timeline offset (from a sync.json manifest, else 0).
2747 struct DropStream { let url: URL; let offset: Double }
2748
2749 /// Expand a raw drop (files, folders, sync.json manifests) into the ordered,
2750 /// de-duplicated list of media streams it represents — the single source of
2751 /// truth shared by the landing preview and the actual import.
2752 func expandDropStreams(_ rawURLs: [URL]) -> [DropStream] {
2753 var out: [DropStream] = []
2754 var manifestByDir: [String: SyncManifest?] = [:]
2755 func manifest(inDir dir: URL) -> SyncManifest? {
2756 if let cached = manifestByDir[dir.path] { return cached }
2757 let m = SyncManifest.load(dir.appendingPathComponent("sync.json"))
2758 manifestByDir[dir.path] = m
2759 return m
2760 }
2761 func addStreams(_ m: SyncManifest, dir: URL) {
2762 for (file, off) in m.offsetsByFile {
2763 let f = dir.appendingPathComponent(file)
2764 guard FileManager.default.fileExists(atPath: f.path) else { continue }
2765 out.append(DropStream(url: f, offset: off))
2766 }
2767 }
2768 for u in rawURLs {
2769 let isDir = (try? u.resourceValues(forKeys: [.isDirectoryKey]).isDirectory) == true
2770 if u.lastPathComponent == "sync.json", let m = SyncManifest.load(u) {
2771 addStreams(m, dir: u.deletingLastPathComponent())
2772 } else if isDir, let m = SyncManifest.load(u.appendingPathComponent("sync.json")) {
2773 addStreams(m, dir: u)
2774 } else if isDir {
2775 continue // a plain folder with no manifest — don't dump its contents
2776 } else {
2777 // A media file dropped alongside a sync.json inherits its offset.
2778 let off = manifest(inDir: u.deletingLastPathComponent())?
2779 .offsetsByFile[u.lastPathComponent] ?? 0
2780 out.append(DropStream(url: u, offset: off))
2781 }
2782 }
2783 var seen = Set<String>()
2784 return out.filter { seen.insert($0.url.path).inserted }
2785 }
2786
2787 /// Probes files off-main, then lands them in one undoable mutation.
2788 /// Files fill lanes downward from `targetRow`, reusing existing tracks
2789 /// before adding new ones (a dropped multicam set fills the lanes below).
2790 /// A recorder `sync.json` (dropped directly, as a folder, or sitting next
2791 /// to the media) offsets each stream so the session lands in sync.
2792 func importFiles(_ rawURLs: [URL], atSecond: Double, targetRow: Int?) {
2793 let streams = expandDropStreams(rawURLs)
2794 guard !streams.isEmpty else { return }
2795 let urls = streams.map(\.url)
2796 let offsetByPath = Dictionary(streams.map { ($0.url.path, $0.offset) },
2797 uniquingKeysWith: { a, _ in a })
2798 // Anchor the earliest stream at the drop point; the rest keep their
2799 // relative spacing.
2800 let minOffset = streams.map(\.offset).min() ?? 0
2801
2802 NotificationCenter.default.post(name: .transientStatus, object: nil,
2803 userInfo: ["text": "Importing \(urls.count) file\(urls.count == 1 ? "" : "s")…"])
2804 let group = DispatchGroup()
2805 var items: [MediaItem?] = Array(repeating: nil, count: urls.count)
2806 for (i, url) in urls.enumerated() {
2807 group.enter()
2808 MediaPipeline.shared.importFile(url) { item in
2809 items[i] = item
2810 group.leave()
2811 }
2812 }
2813 group.notify(queue: .main) { [weak self] in
2814 guard let self else { return }
2815 let ok = items.compactMap { $0 }
2816 guard !ok.isEmpty else {
2817 NotificationCenter.default.post(name: .transientStatus, object: nil,
2818 userInfo: ["text": "Import failed: Could not probe media"])
2819 return
2820 }
2821 self.store.mutate { model in
2822 if model.clips.isEmpty, let first = ok.first(where: { !$0.isAudio }),
2823 first.fps > 0 {
2824 model.fps = first.fps
2825 }
2826 // Files imported together are a multicam session: link them.
2827 let sessionLink: UUID? = ok.count > 1 ? UUID() : nil
2828 for (i, var item) in ok.enumerated() {
2829 // Re-import of an identical file reuses the existing entry.
2830 if let existing = model.media.first(where: { $0.cacheKey == item.cacheKey }) {
2831 item = existing
2832 } else {
2833 model.media.append(item)
2834 }
2835 // Files land on consecutive rows starting at the drop:
2836 // reuse the existing lanes below the target before making
2837 // new ones (new tracks append at the bottom, so the next
2838 // wanted row keeps matching as we go).
2839 let trackIndex: Int
2840 let rows = model.laneRefs
2841 let wantRow = targetRow.map { $0 + i }
2842 if let row = wantRow, rows.indices.contains(row),
2843 let vi = rows[row].videoIndex {
2844 trackIndex = vi
2845 } else {
2846 trackIndex = model.addTrack()
2847 }
2848 let off = (offsetByPath[item.path] ?? minOffset) - minOffset
2849 model.clips.append(Clip(
2850 mediaId: item.id, track: .video(trackIndex),
2851 start: atSecond + off, srcIn: 0, duration: item.duration,
2852 kind: item.isAudio ? .audio : .video,
2853 linkId: sessionLink))
2854 }
2855 }
2856 NotificationCenter.default.post(name: .transientStatus, object: nil,
2857 userInfo: ["text": "Imported \(ok.count) file\(ok.count == 1 ? "" : "s")"])
2858 }
2859 }
2860
2861 func zoomIn() { zoom(by: 1.4, anchorX: bounds.midX) }
2862 func zoomOut() { zoom(by: 1 / 1.4, anchorX: bounds.midX) }
2863
2864 // MARK: - Test hooks
2865
2866 func testXFor(_ seconds: Double) -> CGFloat { xFor(seconds) }
2867 var testPxPerSecond: Double { pxPerSecond }
2868 func testHThumb() -> NSRect { hThumbRect() }
2869 func testVThumb() -> NSRect { vThumbRect() }
2870
2871 // MARK: - Colors
2872
2873 private func trackColor(_ ref: TrackRef) -> NSColor {
2874 NSColor(calibratedHue: project.hue(for: ref), saturation: 0.55, brightness: 0.85, alpha: 1)
2875 }
2876 private func trackColorForClip(_ clip: Clip) -> NSColor {
2877 trackColor(clip.track)
2878 }
2879}
2880
2881extension TimelineView: NSMenuDelegate {
2882 /// Commit the marker's inline name when its right-click menu closes — by
2883 /// Return, click-away, or picking another item. One undo step, and none at
2884 /// all if the name is unchanged.
2885 func menuDidClose(_ menu: NSMenu) {
2886 guard let field = markerNameField, let id = ctxMarkerId else { return }
2887 markerNameField = nil
2888 let value = field.text.trimmingCharacters(in: .whitespacesAndNewlines)
2889 if value != markerNameOriginal { setMarkerLabel(id, value) }
2890 }
2891}
2892
2893extension NSImage {
2894 func tinted(_ color: NSColor) -> NSImage {
2895 let img = NSImage(size: size, flipped: false) { rect in
2896 color.set()
2897 rect.fill()
2898 self.draw(in: rect, from: .zero, operation: .destinationIn, fraction: 1)
2899 return true
2900 }
2901 return img
2902 }
2903}
2904
2905/// An inline, editable marker-name row hosted inside the marker's right-click
2906/// menu — type a name and press Return to commit, the way Frame Rate lives in
2907/// the menu instead of a separate dialog. Replaces the old rename popup.
2908/// The menu swallows Return before the field's action fires, so we mirror
2909/// every keystroke into `text` and let `TimelineView.menuDidClose` commit it.
2910final class MarkerNameMenuField: NSView, NSTextFieldDelegate {
2911 private let field = NSTextField()
2912 /// Live copy of what's typed, kept current so the commit doesn't depend on
2913 /// the field editor still being attached as the menu tears down.
2914 private(set) var text: String
2915 /// Set by the menu builder so Return dismisses the menu (which commits).
2916 var onReturn: (() -> Void)?
2917
2918 init(name: String) {
2919 self.text = name
2920 super.init(frame: NSRect(x: 0, y: 0, width: 208, height: 26))
2921 let caption = NSTextField(labelWithString: "Name")
2922 caption.font = .menuFont(ofSize: 0)
2923 caption.textColor = .secondaryLabelColor
2924 caption.frame = NSRect(x: 14, y: 5, width: 38, height: 16)
2925 addSubview(caption)
2926 field.frame = NSRect(x: 52, y: 3, width: 142, height: 20)
2927 field.stringValue = name
2928 field.placeholderString = "Marker name"
2929 field.font = .menuFont(ofSize: 0)
2930 field.focusRingType = .none
2931 field.delegate = self
2932 field.target = self
2933 field.action = #selector(returnPressed)
2934 addSubview(field)
2935 }
2936 required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") }
2937
2938 override func viewDidMoveToWindow() {
2939 super.viewDidMoveToWindow()
2940 guard window != nil else { return }
2941 // The menu hosts us in its own window; grab focus so keystrokes land in
2942 // the field rather than the menu's key-equivalent matcher.
2943 DispatchQueue.main.async { [weak self] in
2944 guard let self else { return }
2945 self.window?.makeFirstResponder(self.field)
2946 }
2947 }
2948
2949 func controlTextDidChange(_ obj: Notification) { text = field.stringValue }
2950 @objc private func returnPressed() { onReturn?() }
2951}
sequencer/Sources/Sequencer/Tools.swift created+401
...@@ -0,0 +1,401 @@
1import AppKit
2
3/// Tools for the MAIN window's toolbar: editing tools work on the timeline,
4/// drawing tools work directly on storyboard preview cells.
5enum MainTool: CaseIterable {
6 case select, blade, slide, pencil, pen, thick, eraser
7
8 var label: String {
9 switch self {
10 case .select: return "Select"
11 case .blade: return "Blade"
12 case .slide: return "Slide"
13 case .pencil: return "Pencil"
14 case .pen: return "Pen"
15 case .thick: return "Thick Pen"
16 case .eraser: return "Eraser"
17 }
18 }
19 /// Single-key shortcut shown in the toolbar tooltip (nil = no binding).
20 var shortcut: String? {
21 switch self {
22 case .select: return "V"
23 default: return nil
24 }
25 }
26 /// Tooltip label with the shortcut appended when there is one.
27 var tip: String { shortcut.map { "\(label) (\($0))" } ?? label }
28
29 var symbol: String {
30 switch self {
31 case .select: return "cursorarrow"
32 case .blade: return "scissors"
33 case .slide: return "arrow.left.and.right.square"
34 case .pencil: return "pencil"
35 case .pen: return "pencil.tip"
36 case .thick: return "paintbrush.pointed.fill"
37 case .eraser: return "eraser"
38 }
39 }
40 var strokeWidth: CGFloat? {
41 switch self {
42 case .pencil: return 2
43 case .pen: return 4.5
44 case .thick: return 11
45 case .eraser: return 26
46 default: return nil
47 }
48 }
49 var isDraw: Bool { strokeWidth != nil }
50}
51
52// `mainTool`, `drawColor`, `pendingShape`, and `panelUnderPlayhead` moved to
53// `SessionState` (per-window). See SessionState.swift.
54
55// MARK: - Radial quick picker (right-click on a drawing canvas)
56
57final class RadialPicker: NSPanel {
58 private static var current: RadialPicker?
59
60 static func show(at screenPoint: NSPoint, currentTool: BoardTool,
61 currentColor: NSColor,
62 onTool: @escaping (BoardTool) -> Void,
63 onColor: @escaping (NSColor) -> Void) {
64 current?.close()
65 let size: CGFloat = 230
66 let panel = RadialPicker(
67 contentRect: NSRect(x: screenPoint.x - size / 2,
68 y: screenPoint.y - size / 2,
69 width: size, height: size),
70 styleMask: [.borderless, .nonactivatingPanel],
71 backing: .buffered, defer: false)
72 panel.isOpaque = false
73 panel.backgroundColor = .clear
74 panel.level = .popUpMenu
75 panel.hidesOnDeactivate = true
76 panel.isReleasedWhenClosed = false
77 let view = RadialView(frame: NSRect(x: 0, y: 0, width: size, height: size))
78 view.currentTool = currentTool
79 view.currentColor = currentColor
80 view.onPick = { tool, color in
81 if let tool { onTool(tool) }
82 if let color { onColor(color) }
83 panel.close()
84 current = nil
85 }
86 view.onDismiss = { panel.close(); current = nil }
87 panel.contentView = view
88 panel.makeKeyAndOrderFront(nil)
89 current = panel
90 }
91
92 override var canBecomeKey: Bool { true }
93 override func resignKey() {
94 super.resignKey()
95 close()
96 }
97 override func cancelOperation(_ sender: Any?) { close() }
98}
99
100final class RadialView: NSView {
101 var currentTool: BoardTool = .pencil
102 var currentColor: NSColor = .black
103 var onPick: ((BoardTool?, NSColor?) -> Void)?
104 var onDismiss: (() -> Void)?
105 private var hoverIndex: (ring: Int, index: Int)? // ring 0 = tools, 1 = colors
106
107 static let tools: [BoardTool] = [.select, .pencil, .pen, .thick, .eraser,
108 .rect, .oval, .triangle, .star, .ngon,
109 .text, .image]
110
111 private var center: NSPoint { NSPoint(x: bounds.midX, y: bounds.midY) }
112 private let toolRadius: CGFloat = 88
113 private let colorRadius: CGFloat = 46
114
115 override init(frame: NSRect) {
116 super.init(frame: frame)
117 addTrackingArea(NSTrackingArea(
118 rect: frame, options: [.mouseMoved, .activeAlways, .inVisibleRect],
119 owner: self, userInfo: nil))
120 }
121 required init?(coder: NSCoder) { fatalError() }
122
123 private func toolPoint(_ i: Int) -> NSPoint {
124 let a = -CGFloat.pi / 2 + CGFloat(i) * 2 * .pi / CGFloat(Self.tools.count)
125 return NSPoint(x: center.x + toolRadius * cos(a), y: center.y + toolRadius * sin(a))
126 }
127 private func colorPoint(_ i: Int) -> NSPoint {
128 let a = -CGFloat.pi / 2 + CGFloat(i) * 2 * .pi / CGFloat(Palette.colors.count)
129 return NSPoint(x: center.x + colorRadius * cos(a), y: center.y + colorRadius * sin(a))
130 }
131
132 private func hit(_ p: NSPoint) -> (ring: Int, index: Int)? {
133 for (i, _) in Self.tools.enumerated()
134 where hypot(toolPoint(i).x - p.x, toolPoint(i).y - p.y) < 15 {
135 return (0, i)
136 }
137 for (i, _) in Palette.colors.enumerated()
138 where hypot(colorPoint(i).x - p.x, colorPoint(i).y - p.y) < 11 {
139 return (1, i)
140 }
141 return nil
142 }
143
144 override func draw(_ dirtyRect: NSRect) {
145 // Backing disc
146 NSColor(calibratedWhite: Theme.light ? 0.95 : 0.14, alpha: 0.94).setFill()
147 let disc = NSBezierPath(ovalIn: NSRect(x: center.x - 112, y: center.y - 112,
148 width: 224, height: 224))
149 disc.fill()
150 NSColor(calibratedWhite: Theme.light ? 0.6 : 0.35, alpha: 1).setStroke()
151 disc.lineWidth = 1
152 disc.stroke()
153
154 for (i, tool) in Self.tools.enumerated() {
155 let p = toolPoint(i)
156 let selected = tool == currentTool
157 let hovered = hoverIndex?.ring == 0 && hoverIndex?.index == i
158 if selected || hovered {
159 (selected ? NSColor.controlAccentColor
160 : NSColor(calibratedWhite: 0.35, alpha: 1))
161 .withAlphaComponent(0.9).setFill()
162 NSBezierPath(ovalIn: NSRect(x: p.x - 14, y: p.y - 14,
163 width: 28, height: 28)).fill()
164 }
165 if let img = NSImage(systemSymbolName: tool.symbol,
166 accessibilityDescription: tool.label) {
167 img.tinted(selected || hovered ? .white
168 : NSColor(calibratedWhite: Theme.light ? 0.25 : 0.8, alpha: 1))
169 .draw(in: NSRect(x: p.x - 8, y: p.y - 8, width: 16, height: 16),
170 from: .zero, operation: .sourceOver, fraction: 1,
171 respectFlipped: true, hints: nil)
172 }
173 }
174 for (i, color) in Palette.colors.enumerated() {
175 let p = colorPoint(i)
176 let hovered = hoverIndex?.ring == 1 && hoverIndex?.index == i
177 color.setFill()
178 let r: CGFloat = hovered ? 10 : 8
179 NSBezierPath(ovalIn: NSRect(x: p.x - r, y: p.y - r,
180 width: r * 2, height: r * 2)).fill()
181 if color == currentColor || hovered {
182 NSColor.white.setStroke()
183 let ring = NSBezierPath(ovalIn: NSRect(x: p.x - r - 1.5, y: p.y - r - 1.5,
184 width: r * 2 + 3, height: r * 2 + 3))
185 ring.lineWidth = 1.5
186 ring.stroke()
187 }
188 }
189 }
190
191 override func mouseMoved(with event: NSEvent) {
192 hoverIndex = hit(convert(event.locationInWindow, from: nil))
193 needsDisplay = true
194 }
195
196 override func mouseDown(with event: NSEvent) {
197 let p = convert(event.locationInWindow, from: nil)
198 guard let h = hit(p) else {
199 onDismiss?()
200 return
201 }
202 if h.ring == 0 { onPick?(Self.tools[h.index], nil) }
203 else { onPick?(nil, Palette.colors[h.index]) }
204 }
205
206 override func keyDown(with event: NSEvent) {
207 if event.keyCode == 53 { onDismiss?() } // esc
208 }
209}
210
211// MARK: - Settings (⌘,) — Project tab + Global tab
212
213final class SettingsWindow: NSObject {
214 static let shared = SettingsWindow()
215 private var window: NSWindow?
216
217 // Project tab
218 private let fpsPopup = NSPopUpButton()
219 private let aspectPopup = NSPopUpButton()
220 private let compsLabel = NSTextField(labelWithString: "—")
221 // Global tab
222 private let cacheField = NSTextField(string: "")
223 private let cacheLabel = NSTextField(labelWithString: "")
224
225 // Aspect label → concrete storyboard resolution (stored in the model as
226 // pixels, shown here as a ratio).
227 static let boardAspects: [(label: String, width: Int, height: Int)] = [
228 ("16 : 9", 1920, 1080), ("4 : 3", 1440, 1080), ("1.85 : 1", 1998, 1080),
229 ("2.39 : 1", 2048, 858), ("1 : 1", 1080, 1080), ("9 : 16", 1080, 1920),
230 ]
231
232 func show() {
233 buildIfNeeded()
234 sync()
235 window?.makeKeyAndOrderFront(nil)
236 NSApp.activate(ignoringOtherApps: true)
237 }
238
239 private func label(_ s: String) -> NSTextField {
240 let l = NSTextField(labelWithString: s)
241 l.alignment = .right
242 return l
243 }
244
245 private func buildIfNeeded() {
246 guard window == nil else { return }
247 let w = NSWindow(
248 contentRect: NSRect(x: 0, y: 0, width: 560, height: 300),
249 styleMask: [.titled, .closable],
250 backing: .buffered, defer: false)
251 w.title = "Settings"
252 w.isReleasedWhenClosed = false
253 w.center()
254
255 let tabs = NSTabView()
256 tabs.translatesAutoresizingMaskIntoConstraints = false
257
258 // ---- Project tab (travels with the .sq file) ----
259 fpsPopup.target = self
260 fpsPopup.action = #selector(fpsChanged)
261
262 aspectPopup.removeAllItems()
263 for a in Self.boardAspects { aspectPopup.addItem(withTitle: a.label) }
264 aspectPopup.target = self
265 aspectPopup.action = #selector(aspectChanged)
266
267 compsLabel.lineBreakMode = .byTruncatingMiddle
268 compsLabel.textColor = .secondaryLabelColor
269 let choose = NSButton(title: "Choose…", target: self, action: #selector(chooseComps))
270 choose.controlSize = .small
271 let clear = NSButton(title: "Clear", target: self, action: #selector(clearComps))
272 clear.controlSize = .small
273 let rescan = NSButton(title: "Rescan", target: self, action: #selector(rescanComps))
274 rescan.controlSize = .small
275 let compsRow = NSStackView(views: [choose, clear, rescan])
276 compsRow.spacing = 6
277
278 let aspectNote = NSTextField(labelWithString: "New storyboard panels use this shape.")
279 aspectNote.textColor = .secondaryLabelColor
280 aspectNote.font = .systemFont(ofSize: 11)
281
282 let projectGrid = NSGridView(views: [
283 [label("Frame rate"), fpsPopup],
284 [label("Storyboard aspect"), aspectPopup],
285 [NSView(), aspectNote],
286 [label("Comps folder"), compsLabel],
287 [NSView(), compsRow],
288 ])
289 projectGrid.rowSpacing = 10
290 projectGrid.column(at: 0).xPlacement = .trailing
291 projectGrid.column(at: 0).width = 140
292 let projectTab = NSTabViewItem(identifier: "project")
293 projectTab.label = "Project"
294 projectTab.view = wrap(projectGrid)
295 tabs.addTabViewItem(projectTab)
296
297 // ---- Global tab (this Mac, every project) ----
298 cacheField.target = self
299 cacheField.action = #selector(cacheChanged)
300 cacheField.widthAnchor.constraint(equalToConstant: 60).isActive = true
301 let cacheRow = NSStackView(views: [cacheField, NSTextField(labelWithString: "GB")])
302 cacheRow.spacing = 4
303 let reveal = NSButton(title: "Reveal Cache", target: self, action: #selector(revealCache))
304 reveal.controlSize = .small
305 cacheLabel.textColor = .secondaryLabelColor
306 cacheLabel.font = .systemFont(ofSize: 11)
307
308 let globalGrid = NSGridView(views: [
309 [label("Proxy cache limit"), cacheRow],
310 [NSView(), reveal],
311 [NSView(), cacheLabel],
312 ])
313 globalGrid.rowSpacing = 10
314 globalGrid.column(at: 0).xPlacement = .trailing
315 globalGrid.column(at: 0).width = 140
316 let globalTab = NSTabViewItem(identifier: "global")
317 globalTab.label = "Global"
318 globalTab.view = wrap(globalGrid)
319 tabs.addTabViewItem(globalTab)
320
321 w.contentView?.addSubview(tabs)
322 NSLayoutConstraint.activate([
323 tabs.topAnchor.constraint(equalTo: w.contentView!.topAnchor, constant: 12),
324 tabs.leadingAnchor.constraint(equalTo: w.contentView!.leadingAnchor, constant: 12),
325 tabs.trailingAnchor.constraint(equalTo: w.contentView!.trailingAnchor, constant: -12),
326 tabs.bottomAnchor.constraint(equalTo: w.contentView!.bottomAnchor, constant: -12),
327 ])
328 window = w
329 NotificationCenter.default.addObserver(self, selector: #selector(sync),
330 name: .projectChanged, object: nil)
331 }
332
333 private func wrap(_ grid: NSGridView) -> NSView {
334 let v = NSView()
335 grid.translatesAutoresizingMaskIntoConstraints = false
336 v.addSubview(grid)
337 NSLayoutConstraint.activate([
338 grid.topAnchor.constraint(equalTo: v.topAnchor, constant: 18),
339 grid.leadingAnchor.constraint(equalTo: v.leadingAnchor, constant: 18),
340 grid.trailingAnchor.constraint(lessThanOrEqualTo: v.trailingAnchor, constant: -18),
341 ])
342 return v
343 }
344
345 @objc private func sync() {
346 guard window != nil else { return }
347 let project = DocumentContext.current.store.project
348 // Rebuild the fps popup: presets plus the project's own rate when it's
349 // not a preset (e.g. 29.50 fps probed from a screen recording).
350 fpsPopup.removeAllItems()
351 for (title, _) in AppDelegate.frameRates { fpsPopup.addItem(withTitle: title) }
352 if let i = AppDelegate.frameRates.firstIndex(where: { abs($0.1 - project.fps) < 0.01 }) {
353 fpsPopup.selectItem(at: i)
354 } else {
355 fpsPopup.addItem(withTitle: String(format: "%.4g fps (current)", project.fps))
356 fpsPopup.selectItem(at: fpsPopup.numberOfItems - 1)
357 }
358 if let i = Self.boardAspects.firstIndex(where: {
359 abs(Double($0.width) / Double($0.height) - project.boardAspect) < 0.01
360 }) {
361 aspectPopup.selectItem(at: i)
362 }
363 compsLabel.stringValue = project.compsFolder ?? "not set"
364 let gb = UserDefaults.standard.integer(forKey: "maxCacheGB")
365 cacheField.stringValue = "\(gb > 0 ? gb : 50)"
366 cacheLabel.stringValue = "Cache: \(MediaPipeline.shared.cacheRoot.path)"
367 }
368
369 @objc private func fpsChanged() {
370 let i = fpsPopup.indexOfSelectedItem
371 guard i >= 0, i < AppDelegate.frameRates.count else { return }
372 DocumentContext.current.store.mutate { $0.fps = AppDelegate.frameRates[i].1 }
373 }
374 @objc private func aspectChanged() {
375 let i = aspectPopup.indexOfSelectedItem
376 guard i >= 0, i < Self.boardAspects.count else { return }
377 let a = Self.boardAspects[i]
378 DocumentContext.current.store.mutate { $0.boardWidth = a.width; $0.boardHeight = a.height }
379 }
380 @objc private func chooseComps() {
381 let panel = NSOpenPanel()
382 panel.canChooseDirectories = true
383 panel.canChooseFiles = false
384 panel.prompt = "Use as Comps Folder"
385 guard panel.runModal() == .OK, let url = panel.url else { return }
386 DocumentContext.current.store.mutate { $0.compsFolder = url.path }
387 DocumentContext.current.comps.rescan()
388 }
389 @objc private func clearComps() {
390 DocumentContext.current.store.mutate { $0.compsFolder = nil }
391 }
392 @objc private func rescanComps() { DocumentContext.current.comps.rescan() }
393 @objc private func cacheChanged() {
394 let gb = Int(cacheField.stringValue) ?? 50
395 UserDefaults.standard.set(max(1, gb), forKey: "maxCacheGB")
396 MediaPipeline.shared.evictIfNeeded()
397 }
398 @objc private func revealCache() {
399 NSWorkspace.shared.activateFileViewerSelecting([MediaPipeline.shared.cacheRoot])
400 }
401}
sequencer/Sources/Sequencer/TransportBar.swift created+630
...@@ -0,0 +1,630 @@
1import AppKit
2
3/// Instant tooltip — no system hover delay. Shown ABOVE toolbar buttons in
4/// the accent color the moment the pointer arrives.
5enum InstantTip {
6 private static var panel: NSPanel?
7
8 static func show(_ text: String, for view: NSView) {
9 hide()
10 guard !text.isEmpty, let window = view.window else { return }
11 let field = NSTextField(labelWithString: text)
12 field.font = .systemFont(ofSize: 11, weight: .medium)
13 field.textColor = .white
14 // Round the measured size UP — a fractional intrinsic width was clipping
15 // the last glyph (e.g. "Pencil" showing as "Penci").
16 field.sizeToFit()
17 let w = ceil(field.intrinsicContentSize.width) + 1
18 let h = ceil(field.intrinsicContentSize.height)
19 let container = NSView(frame: NSRect(x: 0, y: 0, width: w + 14, height: h + 8))
20 container.wantsLayer = true
21 container.layer?.backgroundColor = NSColor.controlAccentColor.cgColor
22 container.layer?.cornerRadius = 5
23 field.frame = NSRect(x: 7, y: 4, width: w, height: h)
24 container.addSubview(field)
25 let r = window.convertToScreen(view.convert(view.bounds, to: nil))
26 var origin = NSPoint(x: r.midX - container.frame.width / 2,
27 y: r.maxY + 4)
28 if let screen = window.screen {
29 let vis = screen.visibleFrame
30 origin.x = min(max(origin.x, vis.minX + 4),
31 vis.maxX - container.frame.width - 4)
32 if origin.y + container.frame.height > vis.maxY {
33 origin.y = r.minY - container.frame.height - 4
34 }
35 }
36 let p = NSPanel(
37 contentRect: NSRect(origin: origin, size: container.frame.size),
38 styleMask: [.borderless, .nonactivatingPanel],
39 backing: .buffered, defer: false)
40 p.isOpaque = false
41 p.backgroundColor = .clear
42 p.level = .popUpMenu
43 p.ignoresMouseEvents = true
44 p.isReleasedWhenClosed = false
45 p.contentView = container
46 p.orderFront(nil)
47 panel = p
48 }
49
50 static func hide() {
51 panel?.orderOut(nil)
52 panel = nil
53 }
54}
55
56/// THE toolbar: tools on the left, timecode (and clickable frame rate) in the
57/// center, toggle states and view controls on the right. Sits between viewer
58/// and timeline in the stacked layout, across the whole top in side-by-side.
59final class TransportBar: NSView {
60 /// Document context. Facade over the shared singletons for now; injected
61 /// per-document instance later.
62 var ctx: DocumentContext = .headless {
63 didSet {
64 guard oldValue !== ctx else { return }
65 oldValue.notify.removeObserver(self, name: .playheadChanged, object: nil)
66 ctx.notify.addObserver(self, selector: #selector(tick),
67 name: .playheadChanged, object: nil)
68 }
69 }
70 private var store: Store { ctx.store }
71 private var project: ProjectModel { ctx.store.project }
72 private var playback: PlaybackController { ctx.playback }
73 private var chunks: ChunkManager { ctx.chunks }
74 private var session: SessionState { ctx.session }
75
76 private let timecode = NSTextField(labelWithString: "00:00:00:00")
77 private let fpsButton = InstantButton(title: "", target: nil, action: nil)
78 private let customFpsField = NSTextField()
79 private weak var fpsMenu: NSMenu?
80 private let rateField = NSTextField(labelWithString: "⏸")
81 private let status = NSTextField(labelWithString: "")
82 private let jobs = NSTextField(labelWithString: "")
83 private let netWarn = NSTextField(labelWithString: "")
84 private let heightSlider = NSSlider()
85 private var statusClearTimer: Timer?
86
87 private var toolButtons: [MainTool: InstantButton] = [:]
88 private let shapesButton = InstantButton(title: "", target: nil, action: nil)
89 private let colorSwatch = InstantButton(title: "", target: nil, action: nil)
90 private let snapButton = InstantButton(title: "", target: nil, action: nil)
91 private let filmstripButton = InstantButton(title: "", target: nil, action: nil)
92 private let viewerButton = InstantButton(title: "", target: nil, action: nil)
93
94 /// SF Symbols has no magnet — draw a horseshoe magnet (template image, so
95 /// contentTintColor applies). Poles point up with banded tips, the way a
96 /// magnet is universally drawn (🧲), so it reads at a glance.
97 private static func magnetImage() -> NSImage {
98 let img = NSImage(size: NSSize(width: 15, height: 15), flipped: false) { rect in
99 let cx = rect.midX
100 let cyArc: CGFloat = 5.6 // center of the bottom bend
101 let R: CGFloat = 3.7 // centerline radius of the U
102 let top: CGFloat = 11.6 // y of the pole tips
103 let lw: CGFloat = 3.2
104
105 // Horseshoe body: two arms rising from a bottom semicircle.
106 let body = NSBezierPath()
107 body.move(to: NSPoint(x: cx - R, y: top))
108 body.line(to: NSPoint(x: cx - R, y: cyArc))
109 body.appendArc(withCenter: NSPoint(x: cx, y: cyArc), radius: R,
110 startAngle: 180, endAngle: 360, clockwise: false)
111 body.line(to: NSPoint(x: cx + R, y: top))
112 body.lineWidth = lw
113 body.lineCapStyle = .butt
114 NSColor.black.setStroke()
115 body.stroke()
116
117 // Banded pole tips, a touch lighter so the poles read as pole pieces.
118 NSColor.black.withAlphaComponent(0.55).setFill()
119 let bandH: CGFloat = 2.4
120 NSRect(x: cx - R - lw/2, y: top - bandH, width: lw, height: bandH).fill()
121 NSRect(x: cx + R - lw/2, y: top - bandH, width: lw, height: bandH).fill()
122 return true
123 }
124 img.isTemplate = true
125 return img
126 }
127
128 /// Rasterize an image (typically an SF Symbol) into a plain template image,
129 /// aspect-fit inside `box`. This strips the symbol-ness so NSButton draws it
130 /// through its cell — no NSButtonImageView subview — keeping the button square.
131 private static func flattenIcon(_ image: NSImage, box: NSSize) -> NSImage {
132 let src = image.size
133 let scale = min(box.width / src.width, box.height / src.height)
134 let sz = NSSize(width: (src.width * scale).rounded(),
135 height: (src.height * scale).rounded())
136 let out = NSImage(size: sz, flipped: false) { rect in
137 image.draw(in: rect, from: .zero, operation: .sourceOver, fraction: 1)
138 return true
139 }
140 out.isTemplate = true
141 return out
142 }
143
144 override init(frame: NSRect) {
145 super.init(frame: frame)
146 wantsLayer = true
147 layer?.backgroundColor = Theme.barBg.cgColor
148
149 // ---- Left: tools + color ----
150 var leftViews: [NSView] = []
151 for tool in MainTool.allCases {
152 let b = InstantButton(image: NSImage(systemSymbolName: tool.symbol,
153 accessibilityDescription: tool.label)
154 ?? NSImage(),
155 target: self, action: #selector(pickTool(_:)))
156 styleIconButton(b, tip: tool.tip)
157 toolButtons[tool] = b
158 leftViews.append(b)
159 if tool == .slide {
160 let sep = NSBox(); sep.boxType = .separator
161 sep.heightAnchor.constraint(equalToConstant: 18).isActive = true
162 leftViews.append(sep)
163 }
164 }
165 shapesButton.image = NSImage(systemSymbolName: "square.on.circle",
166 accessibilityDescription: "shapes")
167 shapesButton.target = self
168 shapesButton.action = #selector(shapesClicked)
169 styleIconButton(shapesButton, tip: "Shapes")
170 leftViews.append(shapesButton)
171
172 colorSwatch.target = self
173 colorSwatch.action = #selector(swatchClicked)
174 colorSwatch.isBordered = false
175 colorSwatch.wantsLayer = true
176 colorSwatch.layer?.backgroundColor = session.drawColor.cgColor
177 colorSwatch.layer?.cornerRadius = 4
178 colorSwatch.layer?.borderWidth = 1
179 colorSwatch.layer?.borderColor = NSColor(calibratedWhite: 0.5, alpha: 0.8).cgColor
180 colorSwatch.widthAnchor.constraint(equalToConstant: 17).isActive = true
181 colorSwatch.heightAnchor.constraint(equalToConstant: 17).isActive = true
182 colorSwatch.onHover = { [weak self] in
183 guard let self else { return }
184 ColorPickerPanel.show(under: self.colorSwatch, color: self.session.drawColor) { [weak self] c in
185 self?.session.drawColor = c
186 }
187 }
188 leftViews.append(colorSwatch)
189
190 let leftStack = NSStackView(views: leftViews)
191 leftStack.orientation = .horizontal
192 leftStack.spacing = 2
193
194 // ---- Center: timecode + frame rate ----
195 timecode.font = .monospacedDigitSystemFont(ofSize: 14, weight: .medium)
196 timecode.textColor = Theme.label
197 fpsButton.target = self
198 fpsButton.action = #selector(fpsClicked)
199 fpsButton.isBordered = false
200 fpsButton.font = .monospacedDigitSystemFont(ofSize: 10, weight: .regular)
201 fpsButton.contentTintColor = Theme.subtleLabel
202 fpsButton.tipText = "Frame Rate"
203 rateField.font = .monospacedDigitSystemFont(ofSize: 11, weight: .regular)
204 rateField.textColor = Theme.subtleLabel
205 let centerStack = NSStackView(views: [timecode, fpsButton, rateField])
206 centerStack.orientation = .horizontal
207 centerStack.spacing = 8
208
209 // ---- Right: status, jobs, toggles, view controls ----
210 status.font = .systemFont(ofSize: 11)
211 status.textColor = Theme.subtleLabel
212 status.lineBreakMode = .byTruncatingTail
213 jobs.font = .systemFont(ofSize: 11)
214 jobs.textColor = .systemOrange
215 // Clicking the readout pauses/resumes proxy optimization.
216 jobs.addGestureRecognizer(
217 NSClickGestureRecognizer(target: self, action: #selector(toggleOptimizePause)))
218
219 // Shown next to the optimization readout when a network-mounted source
220 // is too slow to build full-quality proxies in real time — the one case
221 // where quality DOESN'T degrade (degrading can't beat the read).
222 netWarn.font = .systemFont(ofSize: 11, weight: .semibold)
223 netWarn.textColor = .systemYellow
224 netWarn.lineBreakMode = .byTruncatingTail
225
226 snapButton.image = Self.magnetImage()
227 snapButton.target = self
228 snapButton.action = #selector(toggleSnap)
229 styleIconButton(snapButton, tip: "Snap (Y)")
230 filmstripButton.image = NSImage(systemSymbolName: "film",
231 accessibilityDescription: "clip thumbnails")
232 filmstripButton.target = self
233 filmstripButton.action = #selector(toggleFilmstrips)
234 styleIconButton(filmstripButton, tip: "Thumbnails (⌥⌘F)")
235 viewerButton.image = NSImage(systemSymbolName: "square.grid.2x2",
236 accessibilityDescription: "viewer layout")
237 viewerButton.target = self
238 viewerButton.action = #selector(viewerClicked)
239 styleIconButton(viewerButton, tip: "Viewer")
240
241 heightSlider.minValue = 0.4
242 heightSlider.maxValue = 2.5
243 heightSlider.doubleValue = Double(session.laneScale)
244 heightSlider.controlSize = .small
245 heightSlider.target = self
246 heightSlider.action = #selector(heightChanged)
247 heightSlider.toolTip = "Track height (⌥⌘= / ⌥⌘- / ⌥⌘0)"
248 heightSlider.widthAnchor.constraint(equalToConstant: 80).isActive = true
249
250 let rightStack = NSStackView(views: [netWarn, jobs, snapButton, filmstripButton,
251 viewerButton, heightSlider])
252 rightStack.orientation = .horizontal
253 rightStack.spacing = 4
254 rightStack.setCustomSpacing(8, after: netWarn)
255 rightStack.setCustomSpacing(10, after: jobs)
256
257 for v in [leftStack, centerStack, rightStack, status] {
258 v.translatesAutoresizingMaskIntoConstraints = false
259 addSubview(v)
260 }
261 NSLayoutConstraint.activate([
262 leftStack.leadingAnchor.constraint(equalTo: leadingAnchor, constant: 10),
263 leftStack.centerYAnchor.constraint(equalTo: centerYAnchor),
264 centerStack.centerXAnchor.constraint(equalTo: centerXAnchor),
265 centerStack.centerYAnchor.constraint(equalTo: centerYAnchor),
266 rightStack.trailingAnchor.constraint(equalTo: trailingAnchor, constant: -10),
267 rightStack.centerYAnchor.constraint(equalTo: centerYAnchor),
268 status.leadingAnchor.constraint(equalTo: leftStack.trailingAnchor, constant: 12),
269 status.trailingAnchor.constraint(lessThanOrEqualTo: centerStack.leadingAnchor,
270 constant: -8),
271 status.centerYAnchor.constraint(equalTo: centerYAnchor),
272 ])
273 status.setContentHuggingPriority(.defaultLow, for: .horizontal)
274 status.setContentCompressionResistancePriority(.defaultLow, for: .horizontal)
275
276 ctx.notify.addObserver(self, selector: #selector(tick),
277 name: .playheadChanged, object: nil)
278 NotificationCenter.default.addObserver(self, selector: #selector(updateJobs),
279 name: .mediaStatusChanged, object: nil)
280 NotificationCenter.default.addObserver(self, selector: #selector(updateJobs),
281 name: .projectChanged, object: nil)
282 NotificationCenter.default.addObserver(self, selector: #selector(transient(_:)),
283 name: .transientStatus, object: nil)
284 NotificationCenter.default.addObserver(self, selector: #selector(viewOptionsChanged),
285 name: .viewOptionsChanged, object: nil)
286 NotificationCenter.default.addObserver(self, selector: #selector(themeChanged),
287 name: .themeChanged, object: nil)
288 tick()
289 updateJobs()
290 syncButtons()
291 }
292 required init?(coder: NSCoder) { fatalError() }
293
294 private func styleIconButton(_ b: InstantButton, tip: String) {
295 b.isBordered = false
296 b.setButtonType(.momentaryChange)
297 b.imageScaling = .scaleProportionallyDown
298 b.imagePosition = .imageOnly
299 b.wantsLayer = true
300 b.layer?.cornerRadius = 5
301 b.tipText = tip
302 // Cap the glyph to a small square. SF Symbols carry a large natural
303 // cell height (~27pt, driven by the symbol's point size), which was
304 // breaking the required height constraint below and leaving these
305 // buttons taller than wide — while the custom 15x15 magnet image sat
306 // happily at 22x22. Shrinking every glyph to the magnet's footprint
307 // makes the cell fit inside 22x22, so the constraints actually hold.
308 // Flatten the glyph into a plain raster template image. An SF Symbol set
309 // directly on an NSButton renders through an internal NSButtonImageView
310 // subview whose sizing inflates the button to ~22x27 — the magnet stays a
311 // clean 22x22 precisely because it's a plain template image the cell draws
312 // itself. Rasterizing every symbol the same way removes the subview and
313 // makes all these buttons behave identically.
314 if let img = b.image {
315 let sized = img.withSymbolConfiguration(
316 NSImage.SymbolConfiguration(pointSize: 13, weight: .regular)) ?? img
317 b.image = Self.flattenIcon(sized, box: NSSize(width: 16, height: 15))
318 }
319 // Force a square box (glyph aspect ratios vary; the button and its
320 // rounded highlight background must stay square). squareSide also makes
321 // the button's *intrinsic* size square as a backstop.
322 let side: CGFloat = 22
323 b.squareSide = side
324 b.setContentHuggingPriority(.required, for: .horizontal)
325 b.setContentHuggingPriority(.required, for: .vertical)
326 b.setContentCompressionResistancePriority(.required, for: .horizontal)
327 b.setContentCompressionResistancePriority(.required, for: .vertical)
328 let w = b.widthAnchor.constraint(equalToConstant: side)
329 let h = b.heightAnchor.constraint(equalToConstant: side)
330 for c in [w, h] { c.priority = .required; c.isActive = true }
331 }
332
333 // MARK: - Actions
334
335 @objc private func pickTool(_ sender: NSButton) {
336 guard let tool = toolButtons.first(where: { $0.value === sender })?.key else { return }
337 session.mainTool = tool
338 }
339
340 private static let shapeKinds: [(String, BoardShape.Kind)] = [
341 ("Rectangle", .rect), ("Oval", .oval), ("Triangle", .triangle),
342 ("Star", .star), ("N-gon", .ngon), ("Text", .text), ("Image…", .image),
343 ]
344
345 @objc private func shapesClicked() {
346 let menu = NSMenu()
347 for (title, kind) in Self.shapeKinds {
348 let mi = NSMenuItem(title: title, action: #selector(shapePicked(_:)),
349 keyEquivalent: "")
350 mi.target = self
351 mi.representedObject = kind.rawValue
352 mi.state = session.pendingShape == kind ? .on : .off
353 menu.addItem(mi)
354 }
355 if session.pendingShape != nil {
356 menu.addItem(.separator())
357 let mi = NSMenuItem(title: "Cancel Placement",
358 action: #selector(shapeCancelled), keyEquivalent: "")
359 mi.target = self
360 menu.addItem(mi)
361 }
362 menu.popUp(positioning: nil,
363 at: NSPoint(x: 0, y: shapesButton.bounds.maxY + 4), in: shapesButton)
364 }
365
366 @objc private func shapePicked(_ sender: NSMenuItem) {
367 guard let raw = sender.representedObject as? String,
368 let kind = BoardShape.Kind(rawValue: raw) else { return }
369 session.pendingShape = kind
370 NotificationCenter.default.post(name: .transientStatus, object: nil,
371 userInfo: ["text": "Drag on the storyboard preview to place the \(sender.title.lowercased())"])
372 }
373
374 @objc private func shapeCancelled() { session.pendingShape = nil }
375
376 @objc private func swatchClicked() {
377 ColorPickerPanel.show(under: colorSwatch, color: session.drawColor) { [weak self] c in
378 self?.session.drawColor = c
379 }
380 }
381
382 @objc private func toggleSnap() { session.snapping.toggle() }
383 @objc private func toggleFilmstrips() { session.showFilmstrips.toggle() }
384
385 @objc private func viewerClicked() {
386 let menu = NSMenu()
387 // Show the same key equivalents the View menu uses (⌥⌘L / ⇧⌘P).
388 let side = NSMenuItem(title: "Previews on Left", action: #selector(toggleSide),
389 keyEquivalent: "l")
390 side.keyEquivalentModifierMask = [.option, .command]
391 side.target = self
392 side.state = session.previewsOnLeft ? .on : .off
393 menu.addItem(side)
394 let pop = NSMenuItem(title: "Pop Out Previews", action: #selector(togglePopout),
395 keyEquivalent: "P")
396 pop.keyEquivalentModifierMask = [.command, .shift]
397 pop.target = self
398 pop.state = ((window?.windowController as? SequencerWindowController)?.previewsArePopped ?? false) ? .on : .off
399 menu.addItem(pop)
400 menu.popUp(positioning: nil,
401 at: NSPoint(x: 0, y: viewerButton.bounds.maxY + 4), in: viewerButton)
402 }
403
404 @objc private func toggleSide() {
405 (window?.windowController as? SequencerWindowController)?.togglePreviewsLeft()
406 }
407 @objc private func togglePopout() {
408 (window?.windowController as? SequencerWindowController)?.togglePopout()
409 }
410
411 @objc private func fpsClicked() {
412 let menu = NSMenu()
413 menu.delegate = self
414 fpsMenu = menu
415 let fps = store.project.fps
416
417 // Editable entry, first in the list — auto-focused so the user can just
418 // start typing a custom rate. Prefilled with the current value, all
419 // selected, so typing replaces it.
420 customFpsField.stringValue = fps == fps.rounded()
421 ? String(format: "%.0f", fps) : String(format: "%g", fps)
422 menu.addItem(makeCustomFpsItem())
423 menu.addItem(.separator())
424
425 for (title, v) in AppDelegate.frameRates {
426 let mi = NSMenuItem(title: title, action: #selector(fpsPicked(_:)),
427 keyEquivalent: "")
428 mi.target = self
429 mi.representedObject = v
430 mi.state = abs(fps - v) < 0.01 ? .on : .off
431 menu.addItem(mi)
432 }
433
434 // Drop the menu so the field lands roughly over the frame-rate readout.
435 menu.popUp(positioning: menu.items.first,
436 at: NSPoint(x: -12, y: fpsButton.bounds.maxY + 9), in: fpsButton)
437 }
438
439 /// A menu item hosting the editable fps field, laid out to line up with the
440 /// preset titles below it.
441 private func makeCustomFpsItem() -> NSMenuItem {
442 let item = NSMenuItem()
443 let field = customFpsField
444 field.isEditable = true
445 field.isBordered = true
446 field.bezelStyle = .roundedBezel
447 field.font = .monospacedDigitSystemFont(ofSize: 13, weight: .regular)
448 field.alignment = .left
449 field.placeholderString = "Custom fps"
450 field.target = self
451 field.action = #selector(customFpsEntered(_:))
452 field.delegate = self
453 field.translatesAutoresizingMaskIntoConstraints = false
454
455 let container = NSView(frame: NSRect(x: 0, y: 0, width: 210, height: 28))
456 container.addSubview(field)
457 NSLayoutConstraint.activate([
458 field.leadingAnchor.constraint(equalTo: container.leadingAnchor, constant: 11),
459 field.trailingAnchor.constraint(equalTo: container.trailingAnchor, constant: -11),
460 field.centerYAnchor.constraint(equalTo: container.centerYAnchor),
461 ])
462 item.view = container
463 return item
464 }
465
466 @objc private func fpsPicked(_ sender: NSMenuItem) {
467 guard let v = sender.representedObject as? Double else { return }
468 store.mutate { $0.fps = v }
469 NotificationCenter.default.post(name: .transientStatus, object: nil,
470 userInfo: ["text": "Project frame rate: \(sender.title)"])
471 }
472
473 @objc private func customFpsEntered(_ sender: NSTextField) {
474 let raw = sender.stringValue.trimmingCharacters(in: .whitespaces)
475 // Accept a bare number or a "24 fps"-style string.
476 let scanned = raw.split(separator: " ").first.map(String.init) ?? raw
477 guard let v = Double(scanned), v >= 1, v <= 240 else {
478 NSSound.beep()
479 return
480 }
481 fpsMenu?.cancelTracking()
482 store.mutate { $0.fps = v }
483 let text = v == v.rounded() ? String(format: "%.0f fps", v)
484 : String(format: "%g fps", v)
485 NotificationCenter.default.post(name: .transientStatus, object: nil,
486 userInfo: ["text": "Project frame rate: \(text)"])
487 }
488
489 @objc private func heightChanged() {
490 session.laneScale = CGFloat(heightSlider.doubleValue)
491 }
492
493 // MARK: - State sync
494
495 @objc private func viewOptionsChanged() {
496 if abs(heightSlider.doubleValue - Double(session.laneScale)) > 0.001 {
497 heightSlider.doubleValue = Double(session.laneScale)
498 }
499 syncButtons()
500 tick()
501 }
502
503 @objc private func themeChanged() {
504 layer?.backgroundColor = Theme.barBg.cgColor
505 timecode.textColor = Theme.label
506 rateField.textColor = Theme.subtleLabel
507 status.textColor = Theme.subtleLabel
508 fpsButton.contentTintColor = Theme.subtleLabel
509 syncButtons()
510 }
511
512 private func highlight(_ b: NSButton, _ on: Bool) {
513 b.layer?.backgroundColor = on
514 ? NSColor.controlAccentColor.withAlphaComponent(0.85).cgColor
515 : NSColor.clear.cgColor
516 b.contentTintColor = on ? .white : .secondaryLabelColor
517 }
518
519 private func syncButtons() {
520 let canDraw = session.panelUnderPlayhead != nil
521 for (tool, b) in toolButtons {
522 highlight(b, tool == session.mainTool)
523 if tool.isDraw {
524 b.isEnabled = canDraw
525 b.alphaValue = canDraw ? 1 : 0.3
526 }
527 }
528 // A draw tool with no panel under the playhead falls back to select.
529 if !canDraw, session.mainTool.isDraw { session.mainTool = .select }
530 if !canDraw, session.pendingShape != nil { session.pendingShape = nil }
531 highlight(shapesButton, session.pendingShape != nil)
532 shapesButton.isEnabled = canDraw
533 shapesButton.alphaValue = canDraw ? 1 : 0.3
534 colorSwatch.layer?.backgroundColor = session.drawColor.cgColor
535 highlight(snapButton, session.snapping)
536 highlight(filmstripButton, session.showFilmstrips)
537 highlight(viewerButton, session.previewsOnLeft
538 || ((window?.windowController as? SequencerWindowController)?.previewsArePopped ?? false))
539 }
540
541 // MARK: - Readouts
542
543 @objc private func tick() {
544 let pc = playback
545 let fps = store.project.fps
546 let frame = Int((pc.playhead * fps).rounded())
547 timecode.stringValue = timecodeString(frame: frame, fps: fps)
548 let fpsText = fps == fps.rounded()
549 ? String(format: "%.0f fps", fps) : String(format: "%.2f fps", fps)
550 fpsButton.attributedTitle = NSAttributedString(
551 string: fpsText,
552 attributes: [.font: NSFont.monospacedDigitSystemFont(ofSize: 10, weight: .regular),
553 .foregroundColor: Theme.subtleLabel])
554 rateField.stringValue = pc.rate == 0 ? "⏸"
555 : String(format: "%@%.0fx", pc.rate < 0 ? "◀︎ " : "▶︎ ", abs(pc.rate))
556 }
557
558 @objc private func updateJobs() {
559 tick() // project fps may have changed with the model
560 syncButtons()
561 if chunks.isNetworkLimited {
562 netWarn.stringValue = "⚠︎ Network I/O limiting quality"
563 netWarn.toolTip = "The media is on a network volume that can't be read fast "
564 + "enough to build full-quality proxies in real time. Reducing quality "
565 + "won't help — it's the network read, not this Mac — so playback keeps "
566 + "source quality and may stutter or fall back to the originals."
567 } else {
568 netWarn.stringValue = ""
569 netWarn.toolTip = nil
570 }
571 let (building, queued) = chunks.queueSummary()
572 let total = building + queued
573 if total == 0 {
574 jobs.stringValue = ""
575 jobs.toolTip = nil
576 } else if chunks.isPaused {
577 jobs.textColor = Theme.subtleLabel
578 jobs.stringValue = "⏸ \(total) chunk\(total == 1 ? "" : "s")"
579 jobs.toolTip = "When paused, clip optimization happens only during playback."
580 } else if queued == 0 {
581 jobs.textColor = .systemOrange
582 jobs.stringValue = "optimizing \(building) chunk\(building == 1 ? "" : "s")"
583 jobs.toolTip = "Click to disable background optimization."
584 } else {
585 jobs.textColor = .systemOrange
586 jobs.stringValue = "optimizing \(building) chunk\(building == 1 ? "" : "s") (+\(queued) queued)"
587 jobs.toolTip = "Click to disable background optimization."
588 }
589 }
590
591 @objc private func toggleOptimizePause() {
592 chunks.setPaused(!chunks.isPaused)
593 updateJobs()
594 }
595
596 @objc private func transient(_ note: Notification) {
597 status.stringValue = (note.userInfo?["text"] as? String) ?? ""
598 statusClearTimer?.invalidate()
599 statusClearTimer = Timer.scheduledTimer(withTimeInterval: 5, repeats: false) { [weak self] _ in
600 self?.status.stringValue = ""
601 }
602 }
603}
604
605extension TransportBar: NSTextFieldDelegate {
606 func control(_ control: NSControl, textView: NSTextView,
607 doCommandBy selector: Selector) -> Bool {
608 // Up/down would jump the insertion point to the line ends; swallow them
609 // so the cursor stays put in the custom-fps field.
610 if selector == #selector(NSResponder.moveUp(_:))
611 || selector == #selector(NSResponder.moveDown(_:)) {
612 return true
613 }
614 return false
615 }
616}
617
618extension TransportBar: NSMenuDelegate {
619 func menuWillOpen(_ menu: NSMenu) {
620 guard menu === fpsMenu else { return }
621 // The menu runs its own modal tracking loop, so focus has to be handed
622 // to the field in that run-loop mode — a plain async dispatch would sit
623 // idle until the menu closed.
624 RunLoop.current.perform(inModes: [.eventTracking]) { [weak self] in
625 guard let self, let window = self.customFpsField.window else { return }
626 window.makeFirstResponder(self.customFpsField)
627 self.customFpsField.currentEditor()?.selectAll(nil)
628 }
629 }
630}
sequencer/Sources/Sequencer/UITest.swift created+860
...@@ -0,0 +1,860 @@
1import AppKit
2
3/// Headless interaction test: `sequencer --uitest`.
4/// Hosts the real TimelineView in an offscreen window and drives the actual
5/// mouseDown/mouseDragged/mouseUp handlers with synthetic events, asserting
6/// against the model. Covers move, trim (incl. push-through), slip, stretch,
7/// vertical move, dynamic track creation, box select, overlaps + resolution,
8/// split (S), links, storyboard split semantics, comp parsing, fades.
9@MainActor
10func runUITest() {
11 var failures = 0
12 func check(_ cond: Bool, _ label: String) {
13 print("\(cond ? "PASS" : "FAIL") \(label)")
14 if !cond { failures += 1 }
15 }
16
17 let store = DocumentContext.headless.store
18
19 // Seed: 2 tracks, one 60s clip each at t=10 and t=30, 100s media.
20 var model = ProjectModel()
21 model.fps = 30
22 var media = MediaItem(path: "/tmp/fake.mov")
23 media.duration = 100
24 media.fps = 30
25 model.media = [media]
26 model.tracks = [Track(hue: 0.1), Track(hue: 0.5)]
27 let c0 = Clip(mediaId: media.id, track: .video(0), start: 10, srcIn: 20, duration: 60)
28 let c1 = Clip(mediaId: media.id, track: .video(1), start: 30, srcIn: 0, duration: 40)
29 model.clips = [c0, c1]
30 store.replaceForTest(model)
31
32 let timeline = TimelineView(frame: NSRect(x: 0, y: 0, width: 1400, height: 400))
33 let window = NSWindow(contentRect: NSRect(x: 0, y: 0, width: 1400, height: 400),
34 styleMask: [.borderless], backing: .buffered, defer: false)
35 window.contentView = timeline
36 timeline.zoomToFit()
37
38 // Coordinate helpers mirroring the view's layout constants.
39 func x(_ sec: Double) -> CGFloat { timeline.testXFor(sec) }
40 func laneY(_ row: Int) -> CGFloat { 26 + CGFloat(row) * (64 + 4) + 4 + 32 } // lane mid
41 // NSEvent locationInWindow is bottom-left origin; view is flipped & fills window.
42 func winPoint(_ vx: CGFloat, _ vy: CGFloat) -> NSPoint { NSPoint(x: vx, y: 400 - vy) }
43
44 func mouse(_ type: NSEvent.EventType, _ p: NSPoint, flags: NSEvent.ModifierFlags = []) -> NSEvent {
45 NSEvent.mouseEvent(with: type, location: p, modifierFlags: flags, timestamp: 0,
46 windowNumber: window.windowNumber, context: nil,
47 eventNumber: 0, clickCount: 1, pressure: 1)!
48 }
49 func drag(from: NSPoint, to: NSPoint, flags: NSEvent.ModifierFlags = [], steps: Int = 8) {
50 timeline.mouseDown(with: mouse(.leftMouseDown, from, flags: flags))
51 for i in 1...steps {
52 let f = CGFloat(i) / CGFloat(steps)
53 let p = NSPoint(x: from.x + (to.x - from.x) * f, y: from.y + (to.y - from.y) * f)
54 timeline.mouseDragged(with: mouse(.leftMouseDragged, p, flags: flags))
55 }
56 timeline.mouseUp(with: mouse(.leftMouseUp, to, flags: flags))
57 }
58 func clip(_ id: UUID) -> Clip? { store.project.clip(id) }
59
60 // 1. Move clip c0 right by ~20s.
61 DocumentContext.headless.session.snapping = false
62 drag(from: winPoint(x(40), laneY(0)), to: winPoint(x(60), laneY(0)))
63 let moved = clip(c0.id)!
64 check(abs(moved.start - 30) < 0.5, "move right: start 10 → ~30 (got \(moved.start))")
65 check(moved.track == .video(0), "move right: stays on track")
66
67 // 2. Undo restores.
68 store.undo()
69 check(abs(clip(c0.id)!.start - 10) < 0.001, "undo restores start=10")
70
71 // 3. Vertical move to track 1.
72 drag(from: winPoint(x(40), laneY(0)), to: winPoint(x(40), laneY(1)))
73 check(clip(c0.id)!.track == .video(1), "vertical move: c0 now on track 1")
74 store.undo()
75
76 // 4. Drag one row below the last lane: a new track appears and the
77 // emptied source track SURVIVES (empty tracks are allowed now).
78 let before4 = store.project.tracks.count
79 drag(from: winPoint(x(40), laneY(0)), to: winPoint(x(40), laneY(2)))
80 check(store.project.tracks.count == before4 + 1,
81 "ghost lane: +1 track, empty source track kept")
82 check(clip(c0.id)!.track == .video(store.project.tracks.count - 1),
83 "ghost lane: c0 on the new track")
84 store.undo()
85 check(store.project.tracks.count == before4, "undo removes the new track")
86
87 // 5. Drag TWO rows below: two tracks at once, clip on the deepest.
88 drag(from: winPoint(x(40), laneY(0)), to: winPoint(x(40), laneY(3)))
89 check(store.project.tracks.count == before4 + 2,
90 "two rows down: +2 tracks (got \(store.project.tracks.count))")
91 check(clip(c0.id)!.track == .video(store.project.tracks.count - 1)
92 && store.project.clips(onVideo: 2).isEmpty,
93 "two rows down: clip on deepest, middle track empty")
94 store.undo()
95
96 // 6. Trim out edge of c1 (end 70 → ~60).
97 drag(from: winPoint(x(70) - 3, laneY(1)), to: winPoint(x(60), laneY(1)))
98 let trimmed = clip(c1.id)!
99 check(abs(trimmed.end - 60) < 0.5, "trim out: end 70 → ~60 (got \(trimmed.end))")
100 store.undo()
101
102 // 7. Trim in edge of c1 (start 30 → ~40, srcIn 0 → ~10).
103 drag(from: winPoint(x(30) + 3, laneY(1)), to: winPoint(x(40), laneY(1)))
104 let trimmedIn = clip(c1.id)!
105 check(abs(trimmedIn.start - 40) < 0.5 && abs(trimmedIn.srcIn - 10) < 0.5,
106 "trim in: start→~40 srcIn→~10 (got \(trimmedIn.start), \(trimmedIn.srcIn))")
107 store.undo()
108
109 // 8. Slip (option-drag) c0: srcIn 20 → ~10 when dragging right.
110 drag(from: winPoint(x(40), laneY(0)), to: winPoint(x(50), laneY(0)), flags: [.option])
111 let slipped = clip(c0.id)!
112 check(abs(slipped.srcIn - 10) < 0.5 && abs(slipped.start - 10) < 0.001,
113 "slip: srcIn 20 → ~10, start unchanged (got \(slipped.srcIn), \(slipped.start))")
114 store.undo()
115
116 // 9. Time stretch (⌘-drag out edge): duration grows, speed drops,
117 // source range constant.
118 let srcLenBefore = clip(c0.id)!.sourceLength
119 drag(from: winPoint(x(70) - 3, laneY(0)), to: winPoint(x(72.8), laneY(0)),
120 flags: [.command])
121 let stretched = clip(c0.id)!
122 check(stretched.duration > 61 && stretched.speed < 1
123 && abs(stretched.sourceLength - srcLenBefore) < 0.2,
124 "stretch: dur \(String(format: "%.1f", stretched.duration)) " +
125 "speed \(String(format: "%.3f", stretched.speed)) srcLen constant")
126 store.undo()
127 check(clip(c0.id)!.speed == 1, "undo restores speed 1")
128
129 // 10. Snapping pulls a near-miss to a clip edge.
130 DocumentContext.headless.session.snapping = true
131 drag(from: winPoint(x(40), laneY(0)), to: winPoint(x(59.7), laneY(0)))
132 check(abs(clip(c0.id)!.start - 30) < 0.001,
133 "snapping: start snaps to 30 (got \(clip(c0.id)!.start))")
134 store.undo()
135 DocumentContext.headless.session.snapping = false
136
137 // 11. Selection click.
138 timeline.mouseDown(with: mouse(.leftMouseDown, winPoint(x(40), laneY(0))))
139 timeline.mouseUp(with: mouse(.leftMouseUp, winPoint(x(40), laneY(0))))
140 check(store.selection == [c0.id], "click selects clip")
141
142 // 12. Box select from empty space over both clips.
143 store.selection = []
144 drag(from: winPoint(x(80), laneY(2) + 20), to: winPoint(x(15), laneY(0)))
145 check(store.selection == Set([c0.id, c1.id]),
146 "box select grabs both clips (got \(store.selection.count))")
147 store.selection = []
148
149 // 13. Split (S) selected at playhead.
150 store.selection = [c0.id]
151 DocumentContext.headless.playback.seek(to: 40)
152 timeline.split()
153 let onT0 = store.project.clips(onVideo: 0)
154 check(onT0.count == 2 && abs(onT0[0].end - 40) < 0.001 && abs(onT0[1].start - 40) < 0.001,
155 "split (S) cuts c0 at 40")
156 store.undo()
157 check(store.project.clips(onVideo: 0).count == 1, "undo unsplits")
158
159 // 14. Linked move: link c0+c1, drag c0, c1 follows.
160 store.selection = [c0.id, c1.id]
161 timeline.linkSelection()
162 check(clip(c0.id)!.linkId != nil && clip(c0.id)!.linkId == clip(c1.id)!.linkId,
163 "link assigns shared linkId")
164 store.selection = []
165 drag(from: winPoint(x(40), laneY(0)), to: winPoint(x(50), laneY(0)))
166 check(abs(clip(c0.id)!.start - 20) < 0.5 && abs(clip(c1.id)!.start - 40) < 0.5,
167 "linked move: both clips shift +10 (got \(clip(c0.id)!.start), \(clip(c1.id)!.start))")
168
169 // 15. Linked split: cutting c0 at 45 also cuts c1; right halves share a new link.
170 DocumentContext.headless.playback.seek(to: 45)
171 store.selection = [c0.id]
172 timeline.split()
173 check(store.project.clips.count == 4, "linked split cuts both clips")
174 let rights = store.project.clips.filter { abs($0.start - 45) < 0.001 }
175 check(rights.count == 2 && rights[0].linkId != nil && rights[0].linkId == rights[1].linkId
176 && rights[0].linkId != clip(c0.id)!.linkId,
177 "right halves share a fresh linkId")
178
179 // ---- Fresh model: overlaps, push-trim, storyboard, empty tracks ----
180 var m2 = ProjectModel()
181 m2.fps = 30
182 m2.media = [media]
183 m2.tracks = [Track(hue: 0.3)]
184 let a = Clip(mediaId: media.id, track: .video(0), start: 10, srcIn: 0, duration: 30) // [10,40)
185 let b = Clip(mediaId: media.id, track: .video(0), start: 40, srcIn: 0, duration: 30) // [40,70)
186 m2.clips = [a, b]
187 store.replaceForTest(m2)
188 timeline.zoomToFit()
189
190 // 16. Trim-through push: dragging a's out edge to ~55 trims b's head.
191 drag(from: winPoint(x(40) - 3, laneY(0)), to: winPoint(x(55), laneY(0)))
192 let a16 = clip(a.id)!, b16 = clip(b.id)!
193 check(abs(a16.end - 55) < 0.5 && abs(b16.start - a16.end) < 0.001
194 && abs(b16.srcIn - (b16.start - 40)) < 0.01 && abs(b16.end - 70) < 0.001,
195 "push trim: a.end→\(String(format: "%.1f", a16.end)), b follows, b.end fixed")
196 check(store.project.overlaps().isEmpty, "push trim leaves no overlap")
197 store.undo()
198
199 // 17. Moving a clip onto another creates the overlap error.
200 drag(from: winPoint(x(25), laneY(0)), to: winPoint(x(50), laneY(0))) // a → [35,65)
201 let ovs = store.project.overlaps()
202 check(ovs.count == 1 && abs(ovs[0].start - 40) < 0.5 && abs(ovs[0].end - 65) < 0.5,
203 "move onto clip: overlap [\(String(format: "%.1f", ovs.first?.start ?? -1)), " +
204 "\(String(format: "%.1f", ovs.first?.end ?? -1))) detected")
205
206 // 18. Clicking inside the red overlap selects both clips.
207 store.selection = []
208 let ovMidX = (x(ovs[0].start) + x(ovs[0].end)) / 2
209 timeline.mouseDown(with: mouse(.leftMouseDown, winPoint(ovMidX, laneY(0))))
210 timeline.mouseUp(with: mouse(.leftMouseUp, winPoint(ovMidX, laneY(0))))
211 check(store.selection == Set([a.id, b.id]), "overlap click selects both clips")
212
213 // 19. S with the playhead INSIDE the overlap resolves it there.
214 DocumentContext.headless.playback.seek(to: 50)
215 timeline.split()
216 let a19 = clip(a.id)!, b19 = clip(b.id)!
217 check(store.project.overlaps().isEmpty
218 && abs(a19.end - 50) < 0.001 && abs(b19.start - 50) < 0.001,
219 "S at playhead in overlap: out/in meet at 50")
220 store.undo() // resolution
221
222 // 20. S with the playhead OUTSIDE the overlap splits normally, keeping it.
223 DocumentContext.headless.playback.seek(to: 38) // inside a=[35,65), before overlap [40,65)
224 let clipsBefore20 = store.project.clips.count
225 timeline.split()
226 check(store.project.clips.count == clipsBefore20 + 1
227 && !store.project.overlaps().isEmpty,
228 "S outside overlap: normal split, overlap kept")
229 store.undo() // split
230
231 // 21. O moves the overlapping clip to a separate track.
232 let tracksBefore21 = store.project.tracks.count
233 timeline.moveOverlapsToSeparateTracks()
234 check(store.project.overlaps().isEmpty
235 && clip(b.id)!.track != clip(a.id)!.track
236 && store.project.tracks.count == tracksBefore21 + 1,
237 "O moves overlap to a new separate track")
238 store.undo() // move to track
239 store.undo() // the drag that made the overlap
240
241 // 21. Deleting all clips keeps the (now empty) tracks.
242 store.selection = Set(store.project.clips.map(\.id))
243 timeline.deleteSelection()
244 check(store.project.tracks.count == 1 && store.project.clips.isEmpty,
245 "empty tracks survive deletion")
246
247 // ---- Storyboard split semantics ----
248 var m3 = ProjectModel()
249 m3.fps = 30
250 m3.tracks = [] // the storyboard lane is implied by its panels, not stored
251 var board = Board()
252 board.shapes = [BoardShape(kind: .rect, frame: CGRect(x: 10, y: 10, width: 100, height: 80))]
253 let sb = Clip(mediaId: nil, track: .storyboard, start: 0, srcIn: 0, duration: 6,
254 kind: .storyboard, board: board)
255 m3.clips = [sb]
256 store.replaceForTest(m3)
257
258 // 22. S duplicates the panel: both halves keep the drawing (fresh id).
259 store.selection = [sb.id]
260 DocumentContext.headless.playback.seek(to: 2)
261 timeline.split()
262 let panels = store.project.clips.sorted { $0.start < $1.start }
263 check(panels.count == 2
264 && panels[0].board?.shapes == panels[1].board?.shapes
265 && panels[0].board?.id != panels[1].board?.id,
266 "storyboard S: duplicate panel, same shapes, new board id")
267
268 // 23. ⇧B splits the panel under the playhead (ignoring the selection),
269 // duplicates the drawing, flags the new right half a NEW SHOT, and moves
270 // the selection onto it — so the names come out "1A, 1B, 2A".
271 store.selection = []
272 DocumentContext.headless.playback.seek(to: 4)
273 timeline.splitStoryboardAtPlayhead(newShot: true)
274 let panels23 = store.project.clips.sorted { $0.start < $1.start }
275 check(panels23.count == 3
276 && panels23[2].newShot == true
277 && panels23[2].board?.shapes == panels23[1].board?.shapes
278 && panels23[2].board?.id != panels23[1].board?.id,
279 "storyboard ⇧B: duplicate drawing, right half flagged new shot")
280 check(store.selection == [panels23[2].id],
281 "storyboard ⇧B: selection moves to the new panel")
282 let names = store.project.panelNames()
283 check(names[panels23[0].id] == "1A" && names[panels23[1].id] == "1B"
284 && names[panels23[2].id] == "2A",
285 "panel names: 1A, 1B, 2A (got \(panels23.compactMap { names[$0.id] }))")
286
287 // 23b. New shots are pure metadata now (panels are gapless): flagging a
288 // panel with newShot bumps the shot number.
289 store.mutate { m in
290 m.clips.append(Clip(mediaId: nil, track: .storyboard, start: 8,
291 srcIn: 0, duration: 2, kind: .storyboard, board: Board()))
292 }
293 let newest = store.project.clips.sorted { $0.start < $1.start }.last!
294 store.mutate { m in
295 if let i = m.clips.firstIndex(where: { $0.id == newest.id }) {
296 m.clips[i].newShot = true
297 }
298 }
299 let names23b = store.project.panelNames()
300 check(names23b[newest.id] == "3A",
301 "newShot metadata starts shot 3 (got \(names23b[newest.id] ?? "nil"))")
302
303 // 23c. Start-only panels: durations are DERIVED — each panel lasts until
304 // the next one, and the last extends past everything ("forever").
305 let derived = store.project.clips(on: .storyboard).sorted { $0.start < $1.start }
306 check(abs(derived[0].end - derived[1].start) < 1e-9
307 && abs(derived[1].end - derived[2].start) < 1e-9
308 && abs(derived[2].end - derived[3].start) < 1e-9
309 && derived[3].duration >= 10,
310 "storyboard durations derive from next starts; last is open-ended")
311
312 // 23d. Dragging a panel's OUT edge moves the NEXT panel's start.
313 timeline.zoomToFit()
314 let p23 = store.project.clips(on: .storyboard).sorted { $0.start < $1.start }
315 let secondStart = p23[1].start
316 drag(from: winPoint(x(p23[0].end) - 2, laneY(0)),
317 to: winPoint(x(p23[0].end + 1.0), laneY(0)))
318 let p23after = store.project.clips(on: .storyboard).sorted { $0.start < $1.start }
319 check(abs(p23after[1].start - (secondStart + 1)) < 0.35
320 && abs(p23after[0].end - p23after[1].start) < 1e-9,
321 "panel out-edge drag moves the next panel's start (got \(p23after[1].start))")
322 store.undo()
323
324 // 23e. Dragging a panel's BODY does nothing but park the playhead on it.
325 let bodyBefore = store.project.clips(on: .storyboard).sorted { $0.start < $1.start }
326 drag(from: winPoint((x(bodyBefore[1].start) + x(bodyBefore[1].end)) / 2, laneY(0)),
327 to: winPoint((x(bodyBefore[1].start) + x(bodyBefore[1].end)) / 2 + 120, laneY(0)))
328 let bodyAfter = store.project.clips(on: .storyboard).sorted { $0.start < $1.start }
329 check(abs(bodyAfter[1].start - bodyBefore[1].start) < 1e-9,
330 "panel body drag never moves it")
331 check(abs(DocumentContext.headless.playback.playhead - bodyBefore[1].start) < 0.05,
332 "clicking a panel parks the playhead on it")
333
334 // 23f. ⌥-drag a panel's out edge = ripple resize: later panels shift as
335 // one, spacing kept.
336 let rp = store.project.clips(on: .storyboard).sorted { $0.start < $1.start }
337 let gapBefore = rp[3].start - rp[2].start
338 drag(from: winPoint(x(rp[1].end) - 2, laneY(0)),
339 to: winPoint(x(rp[1].end + 1.0), laneY(0)), flags: [.option])
340 let rpAfter = store.project.clips(on: .storyboard).sorted { $0.start < $1.start }
341 check(rpAfter[2].start > rp[2].start + 0.5
342 && abs((rpAfter[3].start - rpAfter[2].start) - gapBefore) < 1e-9,
343 "⌥ out-edge ripple pushes later panels, spacing kept")
344 store.undo()
345
346 // 23g. B splits the panel under the playhead regardless of the selection,
347 // duplicates the drawing, does NOT flag a new shot, and selects the right
348 // half.
349 store.selection = []
350 let firstPanel = store.project.clips(on: .storyboard).sorted { $0.start < $1.start }[0]
351 DocumentContext.headless.playback.seek(to: firstPanel.start + 1)
352 let countBeforeB = store.project.clips.count
353 timeline.splitStoryboardAtPlayhead()
354 let afterB = store.project.clips.sorted { $0.start < $1.start }
355 let newPanel = afterB.first { abs($0.start - (firstPanel.start + 1)) < 0.05 }
356 check(store.project.clips.count == countBeforeB + 1
357 && newPanel?.newShot == false
358 && newPanel.map { store.selection == [$0.id] } == true,
359 "B splits under the playhead, selects the new panel, no new-shot flag")
360 store.undo()
361
362 // 23h. New Panel 1 s Later (menu action) adds a panel, playhead follows.
363 DocumentContext.headless.playback.seek(to: store.project.clips(on: .storyboard)
364 .sorted { $0.start < $1.start }[0].start)
365 let countBeforeLater = store.project.clips.count
366 timeline.addPanelOneSecondLater()
367 check(store.project.clips.count == countBeforeLater + 1
368 && abs(DocumentContext.headless.playback.playhead - 1) < 0.05,
369 "New Panel 1 s Later adds a panel and parks the playhead on it")
370 store.undo()
371
372 // 23i. N toggles the new-shot marker on the selected panel (no split).
373 let togglePanel = store.project.clips(on: .storyboard).sorted { $0.start < $1.start }[1]
374 store.selection = [togglePanel.id]
375 let wasNewShot = togglePanel.newShot
376 let countBeforeToggle = store.project.clips.count
377 timeline.toggleNewShot()
378 check(store.project.clip(togglePanel.id)?.newShot == !wasNewShot
379 && store.project.clips.count == countBeforeToggle,
380 "N toggles new-shot without splitting")
381 timeline.toggleNewShot()
382 check(store.project.clip(togglePanel.id)?.newShot == wasNewShot,
383 "N toggles new-shot back")
384
385 // 24. N never touches video clips.
386 var m4 = ProjectModel()
387 m4.fps = 30
388 m4.media = [media]
389 m4.tracks = [Track(hue: 0.3)]
390 m4.clips = [Clip(mediaId: media.id, track: .video(0), start: 0, srcIn: 0, duration: 10)]
391 store.replaceForTest(m4)
392 store.selection = [m4.clips[0].id]
393 DocumentContext.headless.playback.seek(to: 5)
394 timeline.toggleNewShot()
395 check(store.project.clips.count == 1
396 && store.project.clip(m4.clips[0].id)?.newShot == false,
397 "N ignores video clips")
398
399 // ---- Pure logic ----
400
401 // 25. Comp filename parsing.
402 let p1 = FusionComps.parseCompName("0200-0681_intro.comp")
403 let p2 = FusionComps.parseCompName("1779-2100_walking_in_space001.comp")
404 check(p1?.start == 200 && p1?.end == 681 && p1?.title == "intro",
405 "comp name parse: range + title")
406 check(p2?.start == 1779 && p2?.end == 2100, "comp name parse: second sample")
407 check(FusionComps.parseCompName("notes.comp") == nil
408 && FusionComps.parseCompName("0100-0200_x.autocomp") == nil,
409 "comp name parse rejects non-ranged/autocomp")
410
411 // 26. Saver parsing prefers MainOutput.
412 let compText = """
413 Tools = ordered() {
414 Saver1 = Saver {
415 Inputs = { Clip = Input { Value = Clip {
416 Filename = "/renders/alt/seq.png", FormatID = "PNGFormat", }, }, },
417 },
418 MainOutput = Saver {
419 Inputs = { Clip = Input { Value = Clip {
420 Filename = "/renders/main/seq.png", FormatID = "PNGFormat", }, }, },
421 },
422 }
423 """
424 check(FusionComps.parseSaverPath(compText: compText) == "/renders/main/seq.png",
425 "saver parse prefers MainOutput")
426
427 // 27. Audio fade envelope.
428 var ac = Clip(mediaId: media.id, track: .video(0), start: 0, srcIn: 0, duration: 10,
429 kind: .audio)
430 ac.fadeIn = 2
431 ac.fadeOut = 4
432 check(abs(audioGain(ac, at: 1) - 0.5) < 0.001
433 && abs(audioGain(ac, at: 5) - 1.0) < 0.001
434 && abs(audioGain(ac, at: 8) - 0.5) < 0.001
435 && audioGain(ac, at: 11) == 0,
436 "audio fade envelope")
437
438 // 27b. Shuttle keeps doubling; the opposite key halves down to a stop.
439 let pc = DocumentContext.headless.playback
440 pc.setRate(0)
441 pc.shuttle(1); pc.shuttle(1); pc.shuttle(1); pc.shuttle(1) // 1,2,4,8
442 check(pc.rate == 8, "shuttle keeps doubling (got \(pc.rate))")
443 pc.shuttle(1)
444 check(pc.rate == 16, "shuttle passes 8x (got \(pc.rate))")
445 pc.shuttle(-1)
446 check(pc.rate == 8, "opposite key halves (got \(pc.rate))")
447 pc.shuttle(-1); pc.shuttle(-1); pc.shuttle(-1) // 4, 2, 1
448 pc.shuttle(-1)
449 check(pc.rate == 0, "opposite key slows to a stop (got \(pc.rate))")
450
451 // 27c. Nudge: ← / → move the selection by one frame.
452 var m6 = ProjectModel()
453 m6.fps = 30
454 m6.media = [media]
455 m6.tracks = [Track(hue: 0.3)]
456 let nc = Clip(mediaId: media.id, track: .video(0), start: 10, srcIn: 0, duration: 5)
457 m6.clips = [nc]
458 store.replaceForTest(m6)
459 store.selection = [nc.id]
460 timeline.nudgeSelection(by: 1.0 / 30)
461 check(abs(store.project.clip(nc.id)!.start - (10 + 1.0 / 30)) < 1e-9,
462 "nudge right moves one frame")
463 timeline.nudgeSelection(by: -1.0 / 30)
464 check(abs(store.project.clip(nc.id)!.start - 10) < 1e-9, "nudge left returns")
465
466 // 27d. Drawing-layer orientation: a stroke near the TOP of the board
467 // must composite near the TOP (y must not invert anywhere in the chain).
468 var board27 = Board()
469 board27.width = 100
470 board27.height = 100
471 DocumentContext.headless.boards.beginStroke(board: board27)
472 DocumentContext.headless.boards.strokeSegment(board: board27,
473 from: CGPoint(x: 20, y: 15),
474 to: CGPoint(x: 80, y: 15),
475 width: 12, color: .black, erase: false)
476 DocumentContext.headless.boards.endStroke(board: board27)
477 let comp27 = DocumentContext.headless.boards.composite(for: board27)
478 func lum(_ img: NSImage, _ fx: CGFloat, _ fy: CGFloat) -> CGFloat { // fy: 0 = top
479 guard let tiff = img.tiffRepresentation,
480 let rep = NSBitmapImageRep(data: tiff),
481 let c = rep.colorAt(x: Int(fx * CGFloat(rep.pixelsWide - 1)),
482 y: Int(fy * CGFloat(rep.pixelsHigh - 1)))?
483 .usingColorSpace(.deviceRGB)
484 else { return -1 }
485 return c.brightnessComponent
486 }
487 let top = lum(comp27, 0.5, 0.15), bottom = lum(comp27, 0.5, 0.85)
488 check(top >= 0 && top < 0.5 && bottom > 0.9,
489 "stroke drawn at top STAYS at top (top=\(top), bottom=\(bottom))")
490 DocumentContext.headless.boards.saveRaster(nil, boardId: board27.id)
491
492 // 28. Overlaps ignore audio (layering is allowed).
493 var m5 = ProjectModel()
494 m5.fps = 30
495 m5.media = [media]
496 m5.tracks = [Track(hue: 0.3)]
497 m5.clips = [
498 Clip(mediaId: media.id, track: .video(0), start: 0, srcIn: 0, duration: 10, kind: .audio),
499 Clip(mediaId: media.id, track: .video(0), start: 5, srcIn: 0, duration: 10, kind: .audio),
500 ]
501 check(m5.overlaps().isEmpty, "audio clips layer without overlap errors")
502
503 // ---- Wave 4: linked semantics, ripple delete, clipboard ----
504
505 // 29. Deleting one linked clip deletes the whole group.
506 var m7 = ProjectModel()
507 m7.fps = 30
508 m7.media = [media]
509 m7.tracks = [Track(hue: 0.2), Track(hue: 0.5), Track(hue: 0.8)]
510 let link = UUID()
511 let la = Clip(mediaId: media.id, track: .video(0), start: 10, srcIn: 0, duration: 20,
512 kind: .video, linkId: link)
513 let lb = Clip(mediaId: media.id, track: .video(1), start: 10, srcIn: 0, duration: 20,
514 kind: .video, linkId: link)
515 let solo = Clip(mediaId: media.id, track: .video(2), start: 40, srcIn: 0, duration: 10)
516 m7.clips = [la, lb, solo]
517 store.replaceForTest(m7)
518 store.selection = [la.id]
519 timeline.deleteSelection()
520 check(store.project.clips.count == 1 && store.project.clip(solo.id) != nil,
521 "deleting one linked clip deletes its link-mates")
522 store.undo()
523
524 // 30. Vertical GROUP move: dragging one linked clip a row down shifts the
525 // whole group a row down.
526 timeline.zoomToFit()
527 store.selection = []
528 drag(from: winPoint(x(20), laneY(0)), to: winPoint(x(20), laneY(1)))
529 check(store.project.clip(la.id)!.track == .video(1)
530 && store.project.clip(lb.id)!.track == .video(2),
531 "linked group moves vertically as one")
532 store.undo()
533
534 // 31. Ripple delete closes the gap on every track.
535 store.selection = [la.id] // linked pair [10,30) — ripple shifts solo 40→20
536 DocumentContext.headless.playback.seek(to: 80)
537 timeline.rippleDelete()
538 check(store.project.clips.count == 1
539 && abs(store.project.clip(solo.id)!.start - 20) < 1e-9,
540 "ripple delete closes the gap across tracks (got \(store.project.clip(solo.id)?.start ?? -1))")
541 check(abs(DocumentContext.headless.playback.playhead - 10) < 1e-6,
542 "ripple delete parks the playhead at the closed gap (got \(DocumentContext.headless.playback.playhead))")
543 store.undo()
544
545 // 31b. Trailing empty tracks collapse to the last used lane; interior and
546 // top empties stay (so dragging a clip down two rows still makes two).
547 var mp = ProjectModel(); mp.fps = 30; mp.media = [media]
548 mp.tracks = [Track(hue: 0.1), Track(hue: 0.2), Track(hue: 0.3)]
549 mp.clips = [Clip(mediaId: media.id, track: .video(0), start: 0, srcIn: 0, duration: 10)]
550 mp.pruneTrailingEmptyTracks()
551 check(mp.tracks.count == 1,
552 "trailing empty tracks collapse to the last used one")
553 var mi = ProjectModel(); mi.fps = 30; mi.media = [media]
554 mi.tracks = [Track(hue: 0.1), Track(hue: 0.2), Track(hue: 0.3)] // clip on the BOTTOM lane
555 mi.clips = [Clip(mediaId: media.id, track: .video(2), start: 0, srcIn: 0, duration: 10)]
556 mi.pruneTrailingEmptyTracks()
557 check(mi.tracks.count == 3,
558 "empty lanes above the used one are kept (drag-down-two-rows survives)")
559
560 // 31c. ⌥→ ripple-trims the RIGHT side to the playhead and closes the gap.
561 var mr = ProjectModel(); mr.fps = 30; mr.media = [media]
562 mr.tracks = [Track(hue: 0.3)]
563 mr.clips = [Clip(mediaId: media.id, track: .video(0), start: 0, srcIn: 0, duration: 20),
564 Clip(mediaId: media.id, track: .video(0), start: 20, srcIn: 0, duration: 20)]
565 store.replaceForTest(mr)
566 store.selection = []
567 DocumentContext.headless.playback.seek(to: 10)
568 timeline.rippleTrimToPlayhead(deleteLeft: false)
569 let rcs = store.project.clips.sorted { $0.start < $1.start }
570 check(rcs.count == 2 && abs(rcs[0].duration - 10) < 1e-6
571 && abs(rcs[1].start - 10) < 1e-6 && abs(rcs[1].end - 30) < 1e-6,
572 "⌥→ ripple-trims the right side to the playhead")
573
574 // 31d. ⌥← ripple-trims the LEFT side of the clip under the playhead.
575 store.replaceForTest(mr)
576 store.selection = []
577 DocumentContext.headless.playback.seek(to: 25) // inside the second clip [20,40)
578 timeline.rippleTrimToPlayhead(deleteLeft: true)
579 let lcs = store.project.clips.sorted { $0.start < $1.start }
580 check(lcs.count == 2 && abs(lcs[1].start - 20) < 1e-6 && abs(lcs[1].duration - 15) < 1e-6,
581 "⌥← ripple-trims the left side to the playhead")
582
583 // 31e. Delete-the-space closes a blank gap at the playhead.
584 var mb = ProjectModel(); mb.fps = 30; mb.media = [media]
585 mb.tracks = [Track(hue: 0.4)]
586 mb.clips = [Clip(mediaId: media.id, track: .video(0), start: 0, srcIn: 0, duration: 10),
587 Clip(mediaId: media.id, track: .video(0), start: 25, srcIn: 0, duration: 10)]
588 store.replaceForTest(mb)
589 store.selection = []
590 timeline.closeBlankSpace(at: 15) // playhead sits in the [10,25) gap
591 let bcs = store.project.clips.sorted { $0.start < $1.start }
592 check(bcs.count == 2 && abs(bcs[1].start - 10) < 1e-6,
593 "deleting the space closes the blank gap (got \(bcs[1].start))")
594
595 // 31f. Dragging one selected clip's out edge resizes ALL selected clips.
596 DocumentContext.headless.session.laneScale = 1
597 var mm = ProjectModel(); mm.fps = 30; mm.media = [media]
598 mm.tracks = [Track(hue: 0.1), Track(hue: 0.5)]
599 let ec0 = Clip(mediaId: media.id, track: .video(0), start: 0, srcIn: 0, duration: 20)
600 let ec1 = Clip(mediaId: media.id, track: .video(1), start: 0, srcIn: 0, duration: 20)
601 mm.clips = [ec0, ec1]
602 store.replaceForTest(mm)
603 timeline.zoomToFit()
604 store.selection = [ec0.id, ec1.id]
605 let wasSnapping = DocumentContext.headless.session.snapping; DocumentContext.headless.session.snapping = false
606 // Grab a few px INSIDE the right edge (maxX is exclusive for hit-testing).
607 drag(from: winPoint(x(20) - 4, laneY(0)), to: winPoint(x(30) - 4, laneY(0)))
608 DocumentContext.headless.session.snapping = wasSnapping
609 check(abs(clip(ec0.id)!.duration - 30) < 0.3 && abs(clip(ec1.id)!.duration - 30) < 0.3,
610 "dragging one selected clip's out edge resizes all selected (got \(clip(ec1.id)!.duration))")
611
612 // 31g. The first storyboard panel is anchored to 0:00.
613 var ma = ProjectModel(); ma.fps = 30; ma.media = [media]
614 ma.tracks = [] // storyboard lane is implied by its panels
615 ma.clips = [Clip(mediaId: nil, track: .storyboard, start: 7, srcIn: 0, duration: 3,
616 kind: .storyboard)]
617 ma.normalizeStoryboards()
618 check(abs(ma.clips[0].start) < 1e-6, "first storyboard panel is anchored to 0:00")
619
620 // 31g'. Deleting every storyboard panel removes the storyboard lane.
621 ma.clips.removeAll { $0.kind == .storyboard }
622 ma.normalizeStoryboards()
623 check(!ma.hasStoryboard, "emptying the storyboard lane hides it")
624
625 // 31h. sync.json parses per-stream offsets (missing offset counts as 0).
626 let syncData = Data("""
627 {"streams":[{"file":"mic.m4a","offsetSeconds":0},
628 {"file":"cam.mov","offsetSeconds":0.5},
629 {"file":"screen.mov"}]}
630 """.utf8)
631 let man = try? JSONDecoder().decode(SyncManifest.self, from: syncData)
632 let offs = man?.offsetsByFile ?? [:]
633 check(offs["mic.m4a"] == 0 && abs((offs["cam.mov"] ?? -1) - 0.5) < 1e-9
634 && offs["screen.mov"] == 0,
635 "sync.json parses per-stream offsets (missing = 0)")
636
637 // Restore the m7 fixture for the clipboard tests that follow.
638 store.replaceForTest(m7)
639 store.selection = []
640
641 // 32. ⌘C puts Fusion Loader Lua on the pasteboard as TEXT, and ⌘V pastes
642 // the clips back at the playhead.
643 store.selection = [solo.id]
644 timeline.copy(nil)
645 let pbString = NSPasteboard.general.string(forType: .string) ?? ""
646 check(pbString.contains("Loader") && pbString.contains("TrimIn"),
647 "⌘C text is Fusion Loader Lua")
648 let clipCount32 = store.project.clips.count
649 DocumentContext.headless.playback.seek(to: 100)
650 timeline.paste(nil)
651 let pasted = store.project.clips.filter { abs($0.start - 100) < 1e-6 }
652 check(store.project.clips.count == clipCount32 + 1 && pasted.count == 1,
653 "⌘V pastes the copied clip at the playhead")
654 store.undo()
655
656 // 33. timelineDuration treats storyboard panels as start-only.
657 var m8 = ProjectModel()
658 m8.fps = 30
659 m8.media = [media]
660 m8.tracks = [Track(hue: 0.1)]
661 m8.clips = [Clip(mediaId: media.id, track: .video(0), start: 0, srcIn: 0, duration: 50)]
662 var m8b = m8
663 m8b.clips.append(Clip(mediaId: nil, track: .storyboard, start: 0, srcIn: 0,
664 duration: 3, kind: .storyboard, board: Board()))
665 check(m8.timelineDuration == 50, "timelineDuration from solid clips")
666 check(m8b.laneRefs.first == .storyboard,
667 "storyboard lane sits first (below the Fusion band)")
668
669 // 34. Scroll-zoom bars: dragging the horizontal thumb's right END left
670 // shrinks the visible span (zooms in).
671 store.replaceForTest(m8)
672 timeline.zoomToFit()
673 let pps0 = timeline.testPxPerSecond
674 let ht = timeline.testHThumb()
675 drag(from: winPoint(ht.maxX - 1, ht.midY), to: winPoint(ht.maxX - 401, ht.midY))
676 check(timeline.testPxPerSecond > pps0 * 1.15,
677 "h-bar end drag zooms in (pps \(String(format: "%.1f→%.1f", pps0, timeline.testPxPerSecond)))")
678
679 // 35. Vertical bar: dragging the thumb's bottom END up zooms the lanes.
680 DocumentContext.headless.session.laneScale = 1
681 let vt = timeline.testVThumb()
682 drag(from: winPoint(vt.midX, vt.maxY - 1), to: winPoint(vt.midX, vt.maxY - 120))
683 check(DocumentContext.headless.session.laneScale > 1.1,
684 "v-bar end drag scales lanes (got \(String(format: "%.2f", DocumentContext.headless.session.laneScale)))")
685 DocumentContext.headless.session.laneScale = 1
686
687 // 36. Adaptive proxy quality controller (pure decision function).
688 let nLevels = ChunkManager.qualities.count
689 // Fast build at full quality → hold (stay at 0).
690 check(ChunkManager.decideQuality(level: 0, norm: 0.3, normByLevel: [0: 0.3],
691 fastStreak: 0, sourceIsNetwork: true,
692 levelCount: nLevels).nextIndex == 0,
693 "adaptive: comfortable full-quality build holds")
694 // Slow build at full quality → degrade to level 1 (no network blame yet).
695 let d1 = ChunkManager.decideQuality(level: 0, norm: 1.2, normByLevel: [0: 1.2],
696 fastStreak: 0, sourceIsNetwork: true, levelCount: nLevels)
697 check(d1.nextIndex == 1 && !d1.networkLimited, "adaptive: slow build degrades quality")
698 // Degrading helped (level 1 much faster than level 0) but still slow → degrade again.
699 let d2 = ChunkManager.decideQuality(level: 1, norm: 0.9, normByLevel: [0: 1.2, 1: 0.9],
700 fastStreak: 0, sourceIsNetwork: true, levelCount: nLevels)
701 check(d2.nextIndex == 2 && !d2.networkLimited, "adaptive: still-slow useful degrade continues")
702 // Degrading DIDN'T help (level 1 ≈ level 0) + network source → blame network, restore quality.
703 let d3 = ChunkManager.decideQuality(level: 1, norm: 1.15, normByLevel: [0: 1.2, 1: 1.15],
704 fastStreak: 0, sourceIsNetwork: true, levelCount: nLevels)
705 check(d3.networkLimited && d3.nextIndex == 0, "adaptive: futile degrade blames network + restores")
706 // Same futile degrade but LOCAL source → never blame the network.
707 check(!ChunkManager.decideQuality(level: 1, norm: 1.15, normByLevel: [0: 1.2, 1: 1.15],
708 fastStreak: 0, sourceIsNetwork: false,
709 levelCount: nLevels).networkLimited,
710 "adaptive: local slow source is never flagged network-limited")
711 // Comfortable again after degrading: two fast builds recover one level.
712 let up = ChunkManager.decideQuality(level: 2, norm: 0.3, normByLevel: [2: 0.3],
713 fastStreak: 1, sourceIsNetwork: true, levelCount: nLevels)
714 check(up.nextIndex == 1 && !up.networkLimited, "adaptive: sustained fast builds recover quality")
715
716 // 37. Markers: toggle at the playhead, navigate, click-to-seek, clear.
717 store.replaceForTest(m8)
718 timeline.zoomToFit()
719 pc.seek(to: 20)
720 timeline.toggleMarkerAtPlayhead()
721 pc.seek(to: 40)
722 timeline.toggleMarkerAtPlayhead()
723 check(store.project.markers.count == 2, "⇧M drops a marker at the playhead")
724 check(store.project.markers.contains { abs($0.time - 20) < 1e-6 }
725 && store.project.markers.contains { abs($0.time - 40) < 1e-6 },
726 "markers land on the playhead frame")
727 timeline.goToPrevMarker()
728 check(abs(pc.playhead - 20) < 1e-6, "⌥[ jumps to the previous marker")
729 timeline.goToNextMarker()
730 check(abs(pc.playhead - 40) < 1e-6, "⌥] jumps to the next marker")
731 // Toggling on an existing marker removes it.
732 timeline.toggleMarkerAtPlayhead()
733 check(store.project.markers.count == 1
734 && store.project.markers.first.map { abs($0.time - 20) < 1e-6 } == true,
735 "⇧M on a marker removes it")
736 // Click the ruler flag to park the playhead there.
737 pc.seek(to: 0)
738 timeline.mouseDown(with: mouse(.leftMouseDown, winPoint(x(20) + 3, 4)))
739 check(abs(pc.playhead - 20) < 1e-6, "clicking a marker flag seeks to it")
740 timeline.clearAllMarkers()
741 check(store.project.markers.isEmpty, "Clear All Markers empties them")
742 // Markers survive a save/load round-trip.
743 var mk = m8
744 mk.markers = [Marker(time: 12.5, label: "cut"), Marker(time: 30)]
745 let mkData = try! JSONEncoder().encode(mk)
746 let mkBack = try! JSONDecoder().decode(ProjectModel.self, from: mkData)
747 check(mkBack.markers.count == 2
748 && mkBack.markers.contains { $0.label == "cut" && abs($0.time - 12.5) < 1e-6 },
749 "markers round-trip through Codable")
750
751 // 38. Export planning: flatten-topmost, audio auto-check, fusion gaps.
752 do {
753 var em = ProjectModel(); em.fps = 30
754 var vid = MediaItem(path: "/tmp/v.mov"); vid.duration = 100; vid.fps = 30
755 vid.width = 1920; vid.height = 1080; vid.hasAudio = false
756 var aud = MediaItem(path: "/tmp/a.wav"); aud.duration = 100; aud.fps = 30
757 aud.hasAudio = true; aud.isAudio = true
758 em.media = [vid, aud]
759 // Two overlapping video tracks (top = order 0) + one audio track.
760 em.tracks = [Track(hue: 0.1), Track(hue: 0.3), Track(hue: 0.6)]
761 // tv0: [0,10). tv1: [5,20) — overlap [5,10) goes to video 0 (topmost).
762 let cv0 = Clip(mediaId: vid.id, track: .video(0), start: 0, srcIn: 0, duration: 10)
763 let cv1 = Clip(mediaId: vid.id, track: .video(1), start: 5, srcIn: 50, duration: 15)
764 let ca = Clip(mediaId: aud.id, track: .video(2), start: 0, srcIn: 0,
765 duration: 20, kind: .audio)
766 em.clips = [cv0, cv1, ca]
767 store.replaceForTest(em)
768
769 let refs: [TrackRef] = [.video(0), .video(1), .video(2)]
770 let flat = ExportPlan.flattenTopmost(project: store.project, trackRefs: refs)
771 // Expect: tv0 covers [0,10), then tv1 covers [10,20).
772 check(flat.count == 2, "flatten yields two segments across the overlap")
773 check(flat.first.map { abs($0.start) < 1e-6 && abs($0.end - 10) < 1e-6 } == true,
774 "topmost track wins the overlap region")
775 check(flat.last.map { abs($0.start - 10) < 1e-6 && abs($0.end - 20) < 1e-6
776 && abs($0.srcIn - 55) < 1e-6 } == true,
777 "lower track fills only where the top has no clip, src offset carried")
778
779 let audioClips = ExportPlan.audioClips(project: store.project, trackRefs: Set(refs))
780 check(audioClips.count == 1 && audioClips.first?.id == ca.id,
781 "audioClips picks only the audio-bearing clip")
782
783 // Fusion gap detection.
784 func fc(_ a: Int, _ b: Int) -> FusionComp {
785 FusionComp(path: "/c\(a).comp", name: "c\(a)", title: "", startFrame: a, endFrame: b)
786 }
787 check(ExportPlan.fusionCoverageGaps([fc(0, 99), fc(100, 199)]).isEmpty,
788 "contiguous comps report no gap")
789 check(ExportPlan.fusionCoverageGaps([fc(0, 99), fc(101, 199)]).first.map { $0 == (100, 100) } == true,
790 "a one-frame hole is detected")
791 check(ExportPlan.fusionCoverageGaps([fc(0, 50), fc(20, 199)]).isEmpty,
792 "overlapping comps still count as gapless")
793 }
794
795 // ---- Wave 6: file-format hardening ----
796
797 // 40. A legacy (v1, UUID-keyed) .sq migrates to the numbered model: video
798 // tracks sorted by `order` become indices; clips resolve to .video(i) or
799 // .storyboard; the float aspect becomes a concrete resolution.
800 let legacyJSON = """
801 {
802 "fps": 24,
803 "boardAspect": 1.7777777777777777,
804 "tracks": [
805 {"id":"00000000-0000-0000-0000-0000000000B1","order":1,"hue":0.5,"kind":"video"},
806 {"id":"00000000-0000-0000-0000-0000000000A0","order":0,"hue":0.2,"kind":"video"},
807 {"id":"00000000-0000-0000-0000-00000000005B","order":-1,"hue":0.13,"kind":"storyboard"}
808 ],
809 "clips": [
810 {"id":"00000000-0000-0000-0000-0000000000C0","kind":"video","trackId":"00000000-0000-0000-0000-0000000000A0","start":0,"srcIn":0,"duration":10},
811 {"id":"00000000-0000-0000-0000-0000000000C1","kind":"video","trackId":"00000000-0000-0000-0000-0000000000B1","start":0,"srcIn":0,"duration":5},
812 {"id":"00000000-0000-0000-0000-0000000000C2","kind":"storyboard","trackId":"00000000-0000-0000-0000-00000000005B","start":0,"srcIn":0,"duration":3}
813 ],
814 "media": []
815 }
816 """
817 if let doc = try? JSONDecoder().decode(SequencerDocument.self,
818 from: Data(legacyJSON.utf8)) {
819 let p = doc.project
820 let c0ref = p.clips.first { $0.id.uuidString.hasSuffix("C0") }?.track
821 let c1ref = p.clips.first { $0.id.uuidString.hasSuffix("C1") }?.track
822 let sbref = p.clips.first { $0.id.uuidString.hasSuffix("C2") }?.track
823 check(p.tracks.count == 2 && abs(p.tracks[0].hue - 0.2) < 1e-9,
824 "legacy migrate: video tracks numbered by old order")
825 check(c0ref == .video(0) && c1ref == .video(1) && sbref == .storyboard,
826 "legacy migrate: clips resolve to numbered lanes / storyboard")
827 check(p.boardHeight == 1080 && p.boardWidth == 1920,
828 "legacy migrate: float aspect → concrete resolution")
829 } else {
830 check(false, "legacy .sq decodes")
831 }
832
833 // 41. A v2 envelope round-trips project + portable view state.
834 var vdoc = SequencerDocument(project: m8, view: ViewState())
835 vdoc.view.hiddenTracks = [.video(1)]
836 vdoc.view.previewsOnLeft = true
837 if let data = try? JSONEncoder().encode(vdoc),
838 let back = try? JSONDecoder().decode(SequencerDocument.self, from: data) {
839 check(back.formatVersion == 2 && back.project.tracks.count == m8.tracks.count
840 && back.view.hiddenTracks == [.video(1)] && back.view.previewsOnLeft,
841 "v2 envelope round-trips project + view state")
842 } else {
843 check(false, "v2 envelope round-trips")
844 }
845
846 // 42. cacheKey self-heals: an empty/garbage key for a missing file becomes a
847 // stable, valid 16-hex key (never blank, never a traversal).
848 var badMedia = MediaItem(path: "/tmp/does-not-exist-\(failures).mov")
849 badMedia.cacheKey = ""
850 let healed = MediaPipeline.normalizedCacheKey(for: badMedia)
851 var traversal = badMedia
852 traversal.cacheKey = "../../etc"
853 let healed2 = MediaPipeline.normalizedCacheKey(for: traversal)
854 check(MediaPipeline.isValidCacheKey(healed) && MediaPipeline.isValidCacheKey(healed2)
855 && healed == MediaPipeline.normalizedCacheKey(for: badMedia),
856 "cacheKey self-heals to a stable valid key")
857
858 print(failures == 0 ? "\nALL PASS" : "\n\(failures) FAILURES")
859 exit(failures == 0 ? 0 : 1)
860}
sequencer/Sources/Sequencer/ViewerGridView.swift created+1331
...@@ -0,0 +1,1331 @@
1import AppKit
2import AVFoundation
3
4/// Multicam-style preview: one cell per track WITH something under the
5/// playhead, side by side. There is no combined view — tracks are angles,
6/// not layers. Each cell is outlined in its track's color. Hidden tracks
7/// drop out; focus shows only focused tracks (the Fusion band has its own
8/// focus/hide). Cells reflow with a short animation when the active set changes.
9final class ViewerGridView: NSView {
10 /// The document context (store, playback, comps, players), injected by the
11 /// window controller when this view is placed in a document window.
12 var ctx: DocumentContext = .headless {
13 didSet {
14 guard oldValue !== ctx else { return }
15 oldValue.notify.removeObserver(self, name: .playheadChanged, object: nil)
16 ctx.notify.addObserver(self, selector: #selector(sync),
17 name: .playheadChanged, object: nil)
18 }
19 }
20 private var store: Store { ctx.store }
21 private var project: ProjectModel { ctx.store.project }
22 private var playback: PlaybackController { ctx.playback }
23 private var players: PlayerManager { ctx.players }
24 private var chunks: ChunkManager { ctx.chunks }
25 private var comps: FusionComps { ctx.comps }
26 private var boards: BoardStore { ctx.boards }
27 private var session: SessionState { ctx.session }
28
29 private var cells: [TrackRef: ViewerCell] = [:]
30 private var fusionCell: FusionViewerCell?
31 private var paneKey: [TrackRef] = [] // current pane order (fusion = .fusion)
32
33 /// The cell under the mouse right now — target of the F/H hover-shortcuts.
34 /// Set by ViewerCellBase on mouse enter/exit (weak: dying cells self-clear).
35 weak var hoveredCell: ViewerCellBase?
36 private var keyMonitor: Any?
37
38 private static let fusionKey = UI.fusionPaneKey
39
40 /// Empty-state prompt: why nothing is on screen (and, for hidden tracks, a
41 /// one-click way back). Only shown when there are no panes at all.
42 private let placeholder = ViewerPlaceholder()
43
44 override init(frame: NSRect) {
45 super.init(frame: frame)
46 wantsLayer = true
47 layer?.backgroundColor = Theme.viewerBg.cgColor
48 for name: Notification.Name in [.projectChanged, .viewOptionsChanged, .compsChanged,
49 .mediaStatusChanged, .viewerNeedsRefresh] {
50 NotificationCenter.default.addObserver(self, selector: #selector(sync),
51 name: name, object: nil)
52 }
53 // Per-document: bound against the current (headless) ctx here, re-bound
54 // when a real ctx is injected (see `ctx.didSet`).
55 ctx.notify.addObserver(self, selector: #selector(sync),
56 name: .playheadChanged, object: nil)
57 NotificationCenter.default.addObserver(self, selector: #selector(themeChanged),
58 name: .themeChanged, object: nil)
59
60 placeholder.onUnhide = { [weak self] in
61 guard let self else { return }
62 self.session.hiddenTracks = []
63 self.session.focusedTracks = []
64 self.session.fusionHidden = false
65 self.session.fusionFocus = false
66 }
67 addSubview(placeholder)
68 NSLayoutConstraint.activate([
69 placeholder.centerXAnchor.constraint(equalTo: centerXAnchor),
70 placeholder.centerYAnchor.constraint(equalTo: centerYAnchor),
71 placeholder.leadingAnchor.constraint(greaterThanOrEqualTo: leadingAnchor, constant: 16),
72 placeholder.trailingAnchor.constraint(lessThanOrEqualTo: trailingAnchor, constant: -16),
73 ])
74 // Dropping media anywhere in the empty viewer imports it (delegating to
75 // the timeline's importer), so the "Drag files…" prompt is real.
76 registerForDraggedTypes([.fileURL])
77 }
78 required init?(coder: NSCoder) { fatalError() }
79
80 /// F / H act on the preview cell under the mouse — focus / hide the track
81 /// (or the Fusion band) you're pointing at, press again to toggle back.
82 /// A local monitor (not keyDown) so it fires wherever keyboard focus sits,
83 /// yet only bites while a cell is actually hovered in THIS window.
84 override func viewDidMoveToWindow() {
85 super.viewDidMoveToWindow()
86 if let keyMonitor { NSEvent.removeMonitor(keyMonitor); self.keyMonitor = nil }
87 guard window != nil else { return }
88 keyMonitor = NSEvent.addLocalMonitorForEvents(matching: .keyDown) { [weak self] event in
89 guard let self, event.window === self.window else { return event }
90 // Don't steal plain keys from text editing, and leave ⌘/⌥/⌃ combos
91 // alone (⌘F is Zoom to Fit). Shift is ours — ⇧F is Priority.
92 if self.window?.firstResponder is NSText { return event }
93 if !event.modifierFlags.intersection([.command, .option, .control]).isEmpty {
94 return event
95 }
96 let shift = event.modifierFlags.contains(.shift)
97 // ⇧H reveals every track — global, so it works even with nothing
98 // hovered (i.e. when everything is hidden).
99 if shift, event.charactersIgnoringModifiers?.lowercased() == "h" {
100 self.session.showAll(); return nil
101 }
102 guard let cell = self.hoveredCell else { return event }
103 switch event.charactersIgnoringModifiers?.lowercased() {
104 case "f": shift ? cell.togglePriority() : cell.focusButton.onClick?(); return nil
105 case "h" where !shift: cell.hideButton.onClick?(); return nil
106 default: return event
107 }
108 }
109 }
110
111 deinit { if let keyMonitor { NSEvent.removeMonitor(keyMonitor) } }
112
113 @objc private func themeChanged() {
114 layer?.backgroundColor = Theme.viewerBg.cgColor
115 placeholder.refreshColors()
116 }
117
118 override var isFlipped: Bool { true }
119
120 /// Tracks whose previews should show right now: visible (hide/focus) AND
121 /// something VISUAL is under the playhead (audio clips never get a cell).
122 private func activeTracks() -> [TrackRef] {
123 let project = store.project
124 let playhead = playback.playhead
125 let focusActive = !session.focusedTracks.isEmpty || session.fusionFocus
126 return project.laneRefs.filter { ref in
127 let visible = focusActive ? session.focusedTracks.contains(ref)
128 : !session.hiddenTracks.contains(ref)
129 guard visible else { return false }
130 return project.clips.contains {
131 $0.track == ref && $0.kind != .audio
132 && playhead >= $0.start && playhead < $0.end
133 }
134 }
135 }
136
137 /// The aspect ratio a lane's cell should have RIGHT NOW (the media's own
138 /// AR — videos are never inward-cropped).
139 private func paneAspect(_ ref: TrackRef) -> CGFloat {
140 if ref == Self.fusionKey { return 16.0 / 9.0 }
141 let project = store.project
142 let t = playback.playhead
143 if let clip = project.clipAt(track: ref, time: t, kind: .storyboard),
144 let b = clip.board, b.height > 0 {
145 return CGFloat(b.width / b.height)
146 }
147 if let clip = project.clipAt(track: ref, time: t, kind: .video),
148 let m = project.media(clip.mediaId), m.width > 0, m.height > 0 {
149 return CGFloat(m.width) / CGFloat(m.height)
150 }
151 return 16.0 / 9.0
152 }
153
154 private func fusionActive() -> Bool {
155 guard comps.visible else { return false }
156 let focusActive = !session.focusedTracks.isEmpty || session.fusionFocus
157 let visible = focusActive ? session.fusionFocus : !session.fusionHidden
158 guard visible else { return false }
159 let fps = store.project.fps
160 let frame = Int((playback.playhead * fps).rounded())
161 return comps.topmost(atFrame: frame) != nil
162 }
163
164 /// One entry point for every notification: reconcile the pane set (with
165 /// a short reflow animation when it changes) and refresh cell contents.
166 @objc private func sync() {
167 let project = store.project
168 let tracks = activeTracks()
169 let fusion = fusionActive()
170 var key = tracks
171 if fusion { key.append(Self.fusionKey) }
172 let aspects = key.map { paneAspect($0) }
173
174 if key != paneKey {
175 let wasEmpty = cells.isEmpty && fusionCell == nil
176 paneKey = key
177 lastAspects = aspects
178 let ids = Set(tracks)
179 for (ref, cell) in cells where !ids.contains(ref) {
180 let dying = cell
181 // Fade out UNDER the settled grid — a cell shrinking/fading in
182 // place otherwise clips the neighbours expanding over its slot.
183 dying.layer?.zPosition = -1
184 NSAnimationContext.runAnimationGroup({ ctx in
185 ctx.duration = 0.16
186 dying.animator().alphaValue = 0
187 }, completionHandler: { dying.removeFromSuperview() })
188 cells.removeValue(forKey: ref)
189 }
190 var newPanes: [NSView] = []
191 for ref in tracks where cells[ref] == nil {
192 let cell = ViewerCell(ref: ref)
193 cell.alphaValue = 0
194 cells[ref] = cell
195 // Below the settled grid so it grows in UNDER its neighbours.
196 addSubview(cell, positioned: .below, relativeTo: nil)
197 newPanes.append(cell)
198 }
199 if fusion, fusionCell == nil {
200 let cell = FusionViewerCell()
201 cell.alphaValue = 0
202 fusionCell = cell
203 addSubview(cell, positioned: .below, relativeTo: nil)
204 newPanes.append(cell)
205 } else if !fusion, let fc = fusionCell {
206 fusionCell = nil
207 NSAnimationContext.runAnimationGroup({ ctx in
208 ctx.duration = 0.16
209 fc.animator().alphaValue = 0
210 }, completionHandler: { fc.removeFromSuperview() })
211 }
212 // A cell arriving into an empty viewer just snaps on; cells
213 // joining an existing grid scale up in place (90% → 100%).
214 applyFrames(animated: !wasEmpty, appearing: Set(newPanes.map(\.hash)))
215 } else if aspects != lastAspects || session.priorityPane != lastPriority {
216 // Same panes, but the clip changed shape OR Priority toggled — both
217 // reshuffle the geometry, so reflow with the animation.
218 lastAspects = aspects
219 applyFrames(animated: true, appearing: [])
220 }
221 lastPriority = session.priorityPane
222
223 for ref in tracks { cells[ref]?.apply(hue: project.hue(for: ref)) }
224 for cell in cells.values { cell.update() }
225 fusionCell?.update()
226
227 updatePlaceholder()
228 }
229
230 /// Choose the empty-state prompt (or hide it when panes are present).
231 private func updatePlaceholder() {
232 guard paneKey.isEmpty else { placeholder.kind = nil; return }
233 // A pane is empty only because hide/focus filtered it out iff the same
234 // clip/comp IS under the playhead once visibility is ignored.
235 if mediaAtPlayheadIgnoringVisibility() {
236 placeholder.kind = .unhide
237 } else if hasAnyVisualContent() {
238 placeholder.kind = .noMedia
239 } else {
240 placeholder.kind = .importMedia
241 }
242 }
243
244 /// Is there any non-audio clip or Fusion comp under the playhead, ignoring
245 /// hide/focus? (activeTracks/fusionActive apply the same test WITH the
246 /// visibility filter, so a mismatch means "hidden, not absent".)
247 private func mediaAtPlayheadIgnoringVisibility() -> Bool {
248 let project = store.project
249 let playhead = playback.playhead
250 let hit = project.clips.contains {
251 $0.kind != .audio && playhead >= $0.start && playhead < $0.end
252 }
253 if hit { return true }
254 guard comps.visible else { return false }
255 let frame = Int((playhead * project.fps).rounded())
256 return comps.topmost(atFrame: frame) != nil
257 }
258
259 /// Does the project hold any visual media at all (so "nothing here" means
260 /// "not at THIS playhead" rather than "import something")?
261 private func hasAnyVisualContent() -> Bool {
262 store.project.clips.contains { $0.kind != .audio }
263 || !comps.comps.isEmpty
264 }
265
266 // MARK: Drag-to-import
267
268 private func droppableFiles(from sender: NSDraggingInfo) -> [URL] {
269 guard let urls = sender.draggingPasteboard
270 .readObjects(forClasses: [NSURL.self]) as? [URL] else { return [] }
271 return urls.filter {
272 UI.importableExtensions.contains($0.pathExtension.lowercased())
273 || $0.lastPathComponent == "sync.json"
274 || (try? $0.resourceValues(forKeys: [.isDirectoryKey]).isDirectory) == true
275 }
276 }
277
278 override func draggingEntered(_ sender: NSDraggingInfo) -> NSDragOperation {
279 droppableFiles(from: sender).isEmpty ? [] : .copy
280 }
281 override func draggingUpdated(_ sender: NSDraggingInfo) -> NSDragOperation {
282 droppableFiles(from: sender).isEmpty ? [] : .copy
283 }
284 override func performDragOperation(_ sender: NSDraggingInfo) -> Bool {
285 let files = droppableFiles(from: sender)
286 guard !files.isEmpty,
287 let timeline = (window?.windowController as? SequencerWindowController)?.timeline else { return false }
288 // No spatial target in the viewer: land at the playhead, new lanes below.
289 timeline.importFiles(files, atSecond: max(0, playback.playhead),
290 targetRow: nil)
291 return true
292 }
293
294 private var lastAspects: [CGFloat] = []
295 private var lastPriority: TrackRef?
296
297 override func layout() {
298 super.layout()
299 applyFrames(animated: false, appearing: [])
300 }
301
302 /// Lay out the panes and animate them into place. Normally a justified-rows
303 /// grid; in Priority mode one pane blows up large and the rest tile in a
304 /// filmstrip along the leftover edge.
305 private func applyFrames(animated: Bool, appearing: Set<Int>) {
306 var panes: [NSView] = []
307 var aspects: [CGFloat] = []
308 var ids: [TrackRef] = []
309 for (i, ref) in paneKey.enumerated() {
310 let pane: NSView? = ref == Self.fusionKey ? fusionCell : cells[ref]
311 if let pane {
312 panes.append(pane)
313 aspects.append(i < lastAspects.count ? lastAspects[i] : 16.0 / 9.0)
314 ids.append(ref)
315 }
316 }
317 guard !panes.isEmpty, bounds.width > 40, bounds.height > 40 else { return }
318
319 // Priority only kicks in when the chosen pane is actually on screen
320 // AND has company to shrink; otherwise fall back to the even grid.
321 let frames: [NSRect]
322 if let pri = session.priorityPane, let p = ids.firstIndex(of: pri), panes.count > 1 {
323 frames = priorityFrames(aspects: aspects, priority: p, in: bounds)
324 } else {
325 frames = justifiedFrames(aspects: aspects, in: bounds)
326 }
327
328 NSAnimationContext.runAnimationGroup({ ctx in
329 ctx.duration = animated ? 0.16 : 0
330 ctx.allowsImplicitAnimation = animated
331 // One shared curve for the frame AND the content layers so they
332 // move in lockstep (see beginAnimatedContentLayout).
333 ctx.timingFunction = CAMediaTimingFunction(name: .easeInEaseOut)
334 for (i, pane) in panes.enumerated() {
335 let frame = frames[i]
336 if animated {
337 if appearing.contains(pane.hash) {
338 // New cell: grow into place, 90% → 100%, fading in —
339 // UNDER the settled grid (kept there by inserting it at
340 // the bottom of the subview stack in sync(), and belt-
341 // and-suspenders zPosition) so it never clips a neighbour
342 // while it scales.
343 pane.layer?.zPosition = -1
344 let start = NSRect(x: frame.midX - frame.width * 0.45,
345 y: frame.midY - frame.height * 0.45,
346 width: frame.width * 0.9,
347 height: frame.height * 0.9)
348 pane.frame = start
349 // Pin the content layers to that 90% box with NO
350 // animation, so the animated layout below has a matching
351 // frame to grow FROM.
352 (pane as? ViewerCellBase)?
353 .layoutContentLayers(in: CGRect(origin: .zero, size: start.size))
354 pane.layoutSubtreeIfNeeded()
355 }
356 pane.animator().frame = frame
357 pane.animator().alphaValue = 1
358 // Drive the content layers to the FINAL bounds in THIS same
359 // context so the picture tracks the box on one shared curve —
360 // for reflowing cells AND appearing cells scaling up. We pass
361 // the final size explicitly: after animator().frame the view's
362 // own `bounds` still reports the START box, so reading it would
363 // set the sublayers to their current size (no animation) and
364 // the picture would drift instead of scale.
365 (pane as? ViewerCellBase)?
366 .beginAnimatedContentLayout(to: CGRect(origin: .zero, size: frame.size))
367 } else {
368 pane.frame = frame
369 pane.alphaValue = 1
370 }
371 }
372 }, completionHandler: {
373 for pane in panes {
374 pane.layer?.zPosition = 0
375 (pane as? ViewerCellBase)?.isAnimatingContent = false
376 }
377 })
378 }
379
380 /// Justified-rows layout within `rect`: panes are packed into rows where
381 /// every pane in a row shares the row's height and keeps its OWN aspect
382 /// ratio, sitting edge to edge. The row count that maximizes total pane
383 /// area wins. Returns one frame per input aspect, in the same order.
384 private func justifiedFrames(aspects: [CGFloat], in rect: NSRect) -> [NSRect] {
385 let n = aspects.count
386 guard n > 0 else { return [] }
387
388 func rowsFor(_ rowCount: Int) -> [[Int]] {
389 // Even split by index, front rows take the remainder.
390 var rows: [[Int]] = []
391 let base = n / rowCount, extra = n % rowCount
392 var i = 0
393 for r in 0..<rowCount {
394 let count = base + (r < extra ? 1 : 0)
395 guard count > 0 else { continue }
396 rows.append(Array(i..<(i + count)))
397 i += count
398 }
399 return rows
400 }
401
402 var best: (area: CGFloat, heights: [CGFloat], rows: [[Int]]) = (0, [], [])
403 for rowCount in 1...n {
404 let rows = rowsFor(rowCount)
405 let availH = rect.height / CGFloat(rows.count)
406 var heights: [CGFloat] = []
407 var area: CGFloat = 0
408 for row in rows {
409 let sumA = row.reduce(CGFloat(0)) { $0 + aspects[$1] }
410 let h = min(availH, rect.width / sumA)
411 heights.append(h)
412 area += h * h * sumA
413 }
414 if area > best.area { best = (area, heights, rows) }
415 }
416
417 var frames = [NSRect](repeating: .zero, count: n)
418 let totalH = best.heights.reduce(0, +)
419 var y = rect.minY + (rect.height - totalH) / 2
420 for (r, row) in best.rows.enumerated() {
421 let h = best.heights[r]
422 let rowW = row.reduce(CGFloat(0)) { $0 + aspects[$1] * h }
423 var x = rect.minX + (rect.width - rowW) / 2
424 for i in row {
425 frames[i] = NSRect(x: x, y: y, width: aspects[i] * h, height: h)
426 x += aspects[i] * h
427 }
428 y += h
429 }
430 return frames
431 }
432
433 /// The largest aspect-correct box that fits centred inside `rect`.
434 private func aspectFit(_ aspect: CGFloat, in rect: NSRect) -> NSRect {
435 let w = min(rect.width, rect.height * aspect)
436 let h = w / aspect
437 return NSRect(x: rect.midX - w / 2, y: rect.midY - h / 2, width: w, height: h)
438 }
439
440 /// Priority layout: scale the priority pane as large as it will go inside the
441 /// whole container — by definition it ends up touching either both side edges
442 /// or top+bottom — then run the normal justified layout in whatever space is
443 /// left over. The strip's axis is dictated by that maximization, not chosen:
444 /// a full-width priority leaves a band underneath, a full-height one leaves a
445 /// band to the side. Because the two regions tile `rect` exactly and each
446 /// centres its own contents, the composite reads as centred.
447 ///
448 /// The lone knob is a 100pt floor on the strip (clamped on tiny viewers): when
449 /// the priority's aspect nearly matches the container's, the natural leftover
450 /// collapses to a sliver, so we reserve enough for the secondaries to stay
451 /// legible and let the priority give back that room.
452 private func priorityFrames(aspects: [CGFloat], priority p: Int,
453 in rect: NSRect) -> [NSRect] {
454 let n = aspects.count
455 var frames = [NSRect](repeating: .zero, count: n)
456 let others = (0..<n).filter { $0 != p }
457
458 // Largest box of the priority's aspect that fits the whole container.
459 let full = aspectFit(aspects[p], in: rect)
460 // Exactly one of these is ~0 (the axis the priority fills); the other is
461 // the free space. The bigger leftover is where the strip goes.
462 let freeBelow = rect.height - full.height // priority is full-width
463 let freeSide = rect.width - full.width // priority is full-height
464
465 let priRegion: NSRect, strip: NSRect
466 if freeBelow >= freeSide {
467 let t = min(max(freeBelow, 100), rect.height * 0.5)
468 priRegion = NSRect(x: rect.minX, y: rect.minY,
469 width: rect.width, height: rect.height - t)
470 strip = NSRect(x: rect.minX, y: rect.maxY - t,
471 width: rect.width, height: t)
472 } else {
473 let t = min(max(freeSide, 100), rect.width * 0.5)
474 priRegion = NSRect(x: rect.minX, y: rect.minY,
475 width: rect.width - t, height: rect.height)
476 strip = NSRect(x: rect.maxX - t, y: rect.minY,
477 width: t, height: rect.height)
478 }
479 frames[p] = aspectFit(aspects[p], in: priRegion)
480 let otherFrames = justifiedFrames(aspects: others.map { aspects[$0] }, in: strip)
481 for (k, i) in others.enumerated() { frames[i] = otherFrames[k] }
482 return frames
483 }
484}
485
486/// The centred prompt shown when the viewer has no panes: says why the screen
487/// is empty and, for the hidden-tracks case, acts as a button to bring the
488/// previews back. Hidden entirely (kind == nil) whenever panes are present.
489final class ViewerPlaceholder: NSView {
490 enum Kind: Equatable { case unhide, noMedia, importMedia }
491
492 private let icon = NSImageView()
493 private let label = NSTextField(labelWithString: "")
494 /// Invoked when the (clickable) unhide prompt is clicked.
495 var onUnhide: (() -> Void)?
496
497 var kind: Kind? {
498 didSet {
499 guard kind != oldValue else { return }
500 isHidden = kind == nil
501 guard let kind else { return }
502 let (symbol, text): (String, String)
503 switch kind {
504 case .unhide: (symbol, text) = ("eye", "Click to unhide all tracks")
505 case .noMedia: (symbol, text) = ("film", "No media at playhead")
506 case .importMedia: (symbol, text) = ("tray.and.arrow.down",
507 "Drag files to import media")
508 }
509 icon.image = NSImage(systemSymbolName: symbol, accessibilityDescription: nil)?
510 .withSymbolConfiguration(.init(pointSize: 22, weight: .regular))
511 label.stringValue = text
512 refreshColors()
513 window?.invalidateCursorRects(for: self)
514 }
515 }
516
517 init() {
518 super.init(frame: .zero)
519 translatesAutoresizingMaskIntoConstraints = false
520 icon.translatesAutoresizingMaskIntoConstraints = false
521 label.font = .systemFont(ofSize: 13, weight: .medium)
522 label.alignment = .center
523 let stack = NSStackView(views: [icon, label])
524 stack.orientation = .vertical
525 stack.spacing = 9
526 stack.alignment = .centerX
527 stack.translatesAutoresizingMaskIntoConstraints = false
528 addSubview(stack)
529 NSLayoutConstraint.activate([
530 stack.leadingAnchor.constraint(equalTo: leadingAnchor),
531 stack.trailingAnchor.constraint(equalTo: trailingAnchor),
532 stack.topAnchor.constraint(equalTo: topAnchor),
533 stack.bottomAnchor.constraint(equalTo: bottomAnchor),
534 ])
535 isHidden = true
536 }
537 required init?(coder: NSCoder) { fatalError() }
538
539 /// Only the unhide prompt is interactive; the others are informational and
540 /// drawn a touch fainter.
541 func refreshColors() {
542 let clickable = kind == .unhide
543 let color = clickable ? Theme.subtleLabel : Theme.faintLabel
544 icon.contentTintColor = color
545 label.textColor = color
546 }
547
548 override func resetCursorRects() {
549 if kind == .unhide { addCursorRect(bounds, cursor: .pointingHand) }
550 }
551
552 override func mouseDown(with event: NSEvent) {
553 if kind == .unhide { onUnhide?() }
554 }
555}
556
557/// A corner tab that sits flush against a cell edge with a single rounded
558/// INTERIOR corner, filled with the clip's own colour. `corner` is that
559/// interior corner (cells aren't flipped, so layer geometry is y-up).
560final class TightChip: NSView {
561 init(corner: CACornerMask) {
562 super.init(frame: .zero)
563 wantsLayer = true
564 layer?.cornerRadius = 7
565 layer?.maskedCorners = corner
566 layer?.masksToBounds = true
567 translatesAutoresizingMaskIntoConstraints = false
568 }
569 required init?(coder: NSCoder) { fatalError() }
570 var fill: NSColor = .black { didSet { layer?.backgroundColor = fill.cgColor } }
571}
572
573/// Tight corner toggle (an SF Symbol — focus / hide — or a colour swatch) shown
574/// in a cell's top-right on hover. Snug to its glyph so the tab hugs the buttons.
575final class ViewerHoverButton: NSView {
576 private let symbol: NSImage?
577 var onClick: (() -> Void)?
578 var active = false { didSet { needsDisplay = true } }
579 /// When set, the button shows a colour swatch instead of a symbol.
580 var swatch: NSColor? { didSet { needsDisplay = true } }
581
582 /// Pass an SF Symbol name, or "" for a swatch-only button (the colour tab).
583 init(symbol name: String) {
584 symbol = name.isEmpty ? nil
585 : NSImage(systemSymbolName: name, accessibilityDescription: nil)?
586 .withSymbolConfiguration(.init(pointSize: 9, weight: .semibold))
587 super.init(frame: .zero)
588 translatesAutoresizingMaskIntoConstraints = false
589 widthAnchor.constraint(equalToConstant: 16).isActive = true
590 heightAnchor.constraint(equalToConstant: 15).isActive = true
591 }
592 required init?(coder: NSCoder) { fatalError() }
593
594 override func draw(_ dirty: NSRect) {
595 if let swatch {
596 let r = bounds.insetBy(dx: 2.5, dy: 3)
597 let p = NSBezierPath(roundedRect: r, xRadius: 2.5, yRadius: 2.5)
598 swatch.setFill(); p.fill()
599 NSColor(calibratedWhite: 0.97, alpha: 0.9).setStroke()
600 p.lineWidth = 1; p.stroke()
601 return
602 }
603 if active {
604 NSColor.white.withAlphaComponent(0.92).setFill()
605 NSBezierPath(roundedRect: bounds.insetBy(dx: 1.5, dy: 1.5),
606 xRadius: 3, yRadius: 3).fill()
607 }
608 guard let symbol else { return }
609 let tint = active ? NSColor.black.withAlphaComponent(0.85)
610 : NSColor(calibratedWhite: 0.97, alpha: 1)
611 let img = symbol.tinted(tint)
612 let sz = img.size
613 img.draw(in: NSRect(x: (bounds.width - sz.width) / 2,
614 y: (bounds.height - sz.height) / 2,
615 width: sz.width, height: sz.height),
616 from: .zero, operation: .sourceOver, fraction: 1)
617 }
618 override func mouseDown(with event: NSEvent) { onClick?() }
619}
620
621/// Shared chrome for preview cells: corner tabs in the clip's own colour that
622/// sit flush on the edge with one rounded interior corner. Top-left = filename
623/// (video, on hover) or panel name (storyboard, always shown); bottom-left =
624/// processing status; top-right = focus/hide, on hover. Flat cells otherwise —
625/// border only, no rounding, no padding.
626class ViewerCellBase: NSView {
627 /// Document context, inherited by ViewerCell and FusionViewerCell. Facade
628 /// over the shared singletons for now; injected per-document instance later.
629 var ctx: DocumentContext = .headless
630 var store: Store { ctx.store }
631 var project: ProjectModel { ctx.store.project }
632 var playback: PlaybackController { ctx.playback }
633 var players: PlayerManager { ctx.players }
634 var chunks: ChunkManager { ctx.chunks }
635 var comps: FusionComps { ctx.comps }
636 var boards: BoardStore { ctx.boards }
637 var session: SessionState { ctx.session }
638
639 private let topLeftChip = TightChip(corner: .layerMaxXMinYCorner)
640 private let topLeftField = NSTextField(labelWithString: "")
641 private let statusChip = TightChip(corner: .layerMaxXMaxYCorner)
642 private let statusField = NSTextField(labelWithString: "")
643 private let buttonChip = TightChip(corner: .layerMinXMinYCorner)
644 let focusButton = ViewerHoverButton(symbol: UI.focusSymbol)
645 let hideButton = ViewerHoverButton(symbol: UI.hideSymbol)
646 let colorButton = ViewerHoverButton(symbol: "")
647
648 private(set) var hovering = false
649 private var topLeftText = ""
650 private var topLeftHoverOnly = true
651 private var statusText = ""
652
653 /// The clip's colour — fills every corner tab.
654 var chipColor: NSColor = NSColor.black.withAlphaComponent(0.65) {
655 didSet { for c in [topLeftChip, statusChip, buttonChip] { c.fill = chipColor } }
656 }
657
658 override init(frame: NSRect) {
659 super.init(frame: frame)
660 wantsLayer = true
661 layer?.backgroundColor = NSColor.black.cgColor
662 layer?.borderWidth = 2.5
663 layer?.masksToBounds = true
664
665 func styleField(_ f: NSTextField) {
666 f.font = .systemFont(ofSize: 10, weight: .medium)
667 f.textColor = NSColor(calibratedWhite: 0.97, alpha: 1)
668 f.backgroundColor = .clear
669 f.lineBreakMode = .byTruncatingMiddle
670 f.translatesAutoresizingMaskIntoConstraints = false
671 }
672 styleField(topLeftField)
673 styleField(statusField)
674 for c in [topLeftChip, statusChip, buttonChip] { c.fill = chipColor }
675
676 addSubview(topLeftChip); topLeftChip.addSubview(topLeftField)
677 addSubview(statusChip); statusChip.addSubview(statusField)
678 addSubview(buttonChip)
679 focusButton.toolTip = "Focus (F)"
680 hideButton.toolTip = "Hide (H)"
681 let bstack = NSStackView(views: [focusButton, hideButton, colorButton])
682 bstack.orientation = .horizontal
683 bstack.spacing = 0
684 bstack.translatesAutoresizingMaskIntoConstraints = false
685 buttonChip.addSubview(bstack)
686
687 NSLayoutConstraint.activate([
688 topLeftChip.leadingAnchor.constraint(equalTo: leadingAnchor),
689 topLeftChip.topAnchor.constraint(equalTo: topAnchor),
690 topLeftChip.trailingAnchor.constraint(
691 lessThanOrEqualTo: buttonChip.leadingAnchor, constant: -4),
692 topLeftField.leadingAnchor.constraint(equalTo: topLeftChip.leadingAnchor, constant: 7),
693 topLeftField.trailingAnchor.constraint(equalTo: topLeftChip.trailingAnchor, constant: -9),
694 topLeftField.topAnchor.constraint(equalTo: topLeftChip.topAnchor, constant: 3),
695 topLeftField.bottomAnchor.constraint(equalTo: topLeftChip.bottomAnchor, constant: -3),
696
697 statusChip.leadingAnchor.constraint(equalTo: leadingAnchor),
698 statusChip.bottomAnchor.constraint(equalTo: bottomAnchor),
699 statusChip.trailingAnchor.constraint(lessThanOrEqualTo: trailingAnchor, constant: -9),
700 statusField.leadingAnchor.constraint(equalTo: statusChip.leadingAnchor, constant: 7),
701 statusField.trailingAnchor.constraint(equalTo: statusChip.trailingAnchor, constant: -9),
702 statusField.topAnchor.constraint(equalTo: statusChip.topAnchor, constant: 3),
703 statusField.bottomAnchor.constraint(equalTo: statusChip.bottomAnchor, constant: -3),
704
705 buttonChip.trailingAnchor.constraint(equalTo: trailingAnchor),
706 buttonChip.topAnchor.constraint(equalTo: topAnchor),
707 bstack.leadingAnchor.constraint(equalTo: buttonChip.leadingAnchor, constant: 2),
708 bstack.trailingAnchor.constraint(equalTo: buttonChip.trailingAnchor, constant: -2),
709 bstack.topAnchor.constraint(equalTo: buttonChip.topAnchor, constant: 1),
710 bstack.bottomAnchor.constraint(equalTo: buttonChip.bottomAnchor, constant: -1),
711 ])
712 topLeftChip.isHidden = true
713 statusChip.isHidden = true
714 buttonChip.isHidden = true
715 }
716 required init?(coder: NSCoder) { fatalError() }
717
718 /// Top-left tab. `hoverOnly` for filenames (video); storyboard names pass
719 /// false so they stay visible like the "1B" badge did.
720 func setTopLeft(_ text: String, hoverOnly: Bool) {
721 topLeftText = text
722 topLeftHoverOnly = hoverOnly
723 topLeftField.stringValue = text
724 toolTip = text.isEmpty ? nil : text
725 refreshChrome()
726 }
727 func setStatus(_ text: String) {
728 statusText = text
729 statusField.stringValue = text
730 refreshChrome()
731 }
732
733 private func refreshChrome() {
734 topLeftChip.isHidden = topLeftText.isEmpty || (topLeftHoverOnly && !hovering)
735 statusChip.isHidden = statusText.isEmpty
736 // The focus/hide/colour buttons only make sense when the cell is big
737 // enough to host them without swamping the picture.
738 let bigEnough = bounds.width >= 108 && bounds.height >= 66
739 buttonChip.isHidden = !hovering || !bigEnough
740 }
741
742 /// Subclasses size their AVPlayer / image layers to `target` here. The
743 /// caller passes the bounds explicitly (rather than the subclass reading
744 /// `self.bounds`) because during an animated frame change `bounds` still
745 /// reports the START box — reading it would set the sublayers to their
746 /// current size, so they'd never animate and would drift as the parent
747 /// layer grows around them.
748 func layoutContentLayers(in target: CGRect) {}
749
750 /// True while applyFrames is animating this cell's frame AND driving its
751 /// content layers in the same NSAnimationContext — layout() must not snap
752 /// them out from under that animation.
753 var isAnimatingContent = false
754
755 /// Animate the content layers to `target` using the AMBIENT animation
756 /// context. Call INSIDE applyFrames' NSAnimationContext
757 /// (allowsImplicitAnimation) so the sublayers inherit the frame animation's
758 /// exact duration AND timing curve — a separate CATransaction drifts out of
759 /// phase and the two curves visibly disagree. `target` is the FINAL cell
760 /// bounds (origin .zero, final size), not `self.bounds` (see above).
761 func beginAnimatedContentLayout(to target: CGRect) {
762 isAnimatingContent = true
763 layoutContentLayers(in: target)
764 }
765
766 override func layout() {
767 super.layout()
768 refreshChrome() // re-evaluate the hide-when-small threshold on resize
769 guard !isAnimatingContent else { return } // ambient animation owns the sublayers
770 CATransaction.begin()
771 CATransaction.setDisableActions(true)
772 layoutContentLayers(in: bounds)
773 CATransaction.commit()
774 }
775
776 override func updateTrackingAreas() {
777 super.updateTrackingAreas()
778 trackingAreas.forEach(removeTrackingArea)
779 addTrackingArea(NSTrackingArea(
780 rect: bounds,
781 options: [.mouseEnteredAndExited, .activeInKeyWindow, .inVisibleRect],
782 owner: self, userInfo: nil))
783 }
784
785 override func mouseEntered(with event: NSEvent) {
786 hovering = true; refreshChrome()
787 // Report to the grid so the F/H hover-shortcuts target this cell.
788 (superview as? ViewerGridView)?.hoveredCell = self
789 }
790 override func mouseExited(with event: NSEvent) {
791 hovering = false; refreshChrome()
792 let grid = superview as? ViewerGridView
793 if grid?.hoveredCell === self { grid?.hoveredCell = nil }
794 }
795
796 /// Toggle Priority (⇧F) for this pane. Subclasses map it to their identity.
797 func togglePriority() {}
798}
799
800final class ViewerCell: ViewerCellBase {
801 let ref: TrackRef
802 private let playerLayer = AVPlayerLayer()
803 private let imageLayer = CALayer()
804 private var readyObs: NSKeyValueObservation?
805
806 // "Loading Media…" overlay — a clear, frame-scaled not-rendered state
807 // shown when there's neither live video NOR a filmstrip stand-in to show
808 // (otherwise the cell would just sit near-black with tiny corner text).
809 private let overlayBg = CALayer()
810 private let spinnerLayer = CAShapeLayer()
811 private let loadingText = CATextLayer()
812 private var overlayVisible = false
813 private static let overlayBgColor =
814 NSColor(srgbRed: 0x6b / 255, green: 0x6b / 255, blue: 0x6b / 255, alpha: 1)
815 private static let overlayFgColor =
816 NSColor(srgbRed: 0xba / 255, green: 0xba / 255, blue: 0xba / 255, alpha: 1)
817
818 init(ref: TrackRef) {
819 self.ref = ref
820 super.init(frame: .zero)
821
822 // The cell is sized to the media's own aspect ratio, so aspect-fit
823 // fills it edge to edge WITHOUT the inward crop fill would risk.
824 imageLayer.contentsGravity = .resizeAspect
825 playerLayer.videoGravity = .resizeAspect
826 layer?.insertSublayer(imageLayer, at: 0)
827 layer?.insertSublayer(playerLayer, above: imageLayer)
828
829 // Overlay sits above the video/image layers (but below the chrome
830 // subviews). Hidden until update() decides there's nothing to show.
831 overlayBg.backgroundColor = Self.overlayBgColor.cgColor
832 overlayBg.isHidden = true
833 spinnerLayer.fillColor = NSColor.clear.cgColor
834 spinnerLayer.strokeColor = Self.overlayFgColor.cgColor
835 spinnerLayer.lineCap = .round
836 loadingText.string = "Loading Media…"
837 loadingText.alignmentMode = .center
838 loadingText.truncationMode = .end
839 loadingText.foregroundColor = Self.overlayFgColor.cgColor
840 loadingText.contentsScale = NSScreen.main?.backingScaleFactor ?? 2
841 overlayBg.addSublayer(spinnerLayer)
842 overlayBg.addSublayer(loadingText)
843 layer?.addSublayer(overlayBg)
844 // The player layer is hidden until it can actually show a frame
845 // (item swaps otherwise flash black); update when that flips.
846 readyObs = playerLayer.observe(\.isReadyForDisplay) { [weak self] _, _ in
847 DispatchQueue.main.async { self?.update() }
848 }
849 focusButton.onClick = { [weak self] in guard let self else { return }
850 session.toggleFocus(self.ref) }
851 hideButton.onClick = { [weak self] in guard let self else { return }
852 session.toggleHidden(self.ref) }
853 colorButton.onClick = { [weak self] in self?.openColorPicker() }
854 }
855
856 override func togglePriority() {
857 session.priorityPane = session.priorityPane == ref ? nil : ref
858 }
859 required init?(coder: NSCoder) { fatalError() }
860
861 func apply(hue: Double) {
862 let full = NSColor(calibratedHue: hue, saturation: 0.55, brightness: 0.85, alpha: 1)
863 layer?.borderColor = full.cgColor
864 chipColor = NSColor(
865 calibratedHue: hue, saturation: 0.55, brightness: 0.5, alpha: 0.92)
866 colorButton.swatch = full
867 }
868
869 /// Recolour this track via the shared picker. Edits preview live and land
870 /// as ONE undo step when the picker closes (no held gesture, so timeline
871 /// edits mid-pick can't trip anything). The storyboard lane's hue is fixed.
872 private var colorSnapshot: ProjectModel?
873 private func openColorPicker() {
874 guard let vi = ref.videoIndex else { return }
875 let hue = store.project.hue(for: ref)
876 let seed = NSColor(calibratedHue: hue, saturation: 0.7, brightness: 0.9, alpha: 1)
877 colorSnapshot = store.project
878 ColorPickerPanel.show(under: colorButton, color: seed, onChange: { [weak self] c in
879 guard let self,
880 let h = c.usingColorSpace(.genericRGB)?.hueComponent else { return }
881 self.store.preview { model in
882 if model.tracks.indices.contains(vi) {
883 model.tracks[vi].hue = h
884 }
885 }
886 }, onClose: { [weak self] in
887 guard let self, let snap = self.colorSnapshot else { return }
888 self.colorSnapshot = nil
889 self.store.commitPreview(from: snap)
890 })
891 }
892
893 override func layoutContentLayers(in target: CGRect) {
894 playerLayer.frame = target
895 imageLayer.frame = target
896 layoutOverlay(in: target)
897 }
898
899 /// Size + place the loading spinner and text relative to the cell so they
900 /// scale with the frame. Cells are NOT flipped → y-up (larger y = higher).
901 private func layoutOverlay(in bounds: CGRect) {
902 overlayBg.frame = bounds
903 let dim = min(bounds.width, bounds.height)
904 let diameter = max(16, dim * 0.22)
905 let cx = bounds.width / 2, cy = bounds.height * 0.57
906 spinnerLayer.frame = CGRect(x: cx - diameter / 2, y: cy - diameter / 2,
907 width: diameter, height: diameter)
908 let lw = max(1.5, diameter * 0.09)
909 spinnerLayer.lineWidth = lw
910 let r = diameter / 2 - lw
911 let path = CGMutablePath()
912 // A ~300° arc (leaves a gap so the rotation reads as spinning).
913 path.addArc(center: CGPoint(x: diameter / 2, y: diameter / 2), radius: r,
914 startAngle: .pi / 2, endAngle: .pi / 2 - .pi * 1.7, clockwise: true)
915 spinnerLayer.path = path
916 let fontSize = max(9, dim * 0.1)
917 loadingText.font = NSFont.systemFont(ofSize: fontSize, weight: .medium)
918 loadingText.fontSize = fontSize
919 let textH = fontSize * 1.3
920 loadingText.frame = CGRect(x: 4, y: spinnerLayer.frame.minY - textH - fontSize * 0.35,
921 width: bounds.width - 8, height: textH)
922 }
923
924 private func showLoadingOverlay(_ show: Bool) {
925 if show { layoutOverlay(in: bounds) }
926 guard overlayVisible != show else { return }
927 overlayVisible = show
928 overlayBg.isHidden = !show
929 if show {
930 if spinnerLayer.animation(forKey: "spin") == nil {
931 let a = CABasicAnimation(keyPath: "transform.rotation.z")
932 a.fromValue = 0
933 a.toValue = -Double.pi * 2 // clockwise
934 a.duration = 0.9
935 a.repeatCount = .infinity
936 spinnerLayer.add(a, forKey: "spin")
937 }
938 } else {
939 spinnerLayer.removeAnimation(forKey: "spin")
940 }
941 }
942
943 private var currentClipId: UUID?
944
945 // MARK: Drawing directly on storyboard previews
946
947 private var strokeBoard: Board?
948 private var lastStrokePoint: CGPoint?
949
950 /// The storyboard panel this cell is currently showing, if any.
951 private var panelClip: Clip? {
952 let project = store.project
953 return project.clipAt(track: ref,
954 time: playback.playhead,
955 kind: .storyboard)
956 }
957
958 /// View point → board coords through the aspect-fill crop.
959 private func boardPoint(_ p: NSPoint, board: Board) -> CGPoint {
960 let bw = CGFloat(board.width), bh = CGFloat(board.height)
961 guard bw > 0, bh > 0, bounds.width > 0 else { return .zero }
962 let s = max(bounds.width / bw, bounds.height / bh)
963 let offX = (bounds.width - bw * s) / 2
964 let offY = (bounds.height - bh * s) / 2
965 // Cell views are NOT flipped: view y is up, board y is down.
966 return CGPoint(x: (p.x - offX) / s,
967 y: bh - (p.y - offY) / s)
968 }
969
970 // Shape placement armed by the toolbar's Shapes dropdown.
971 private var shapeDragId: UUID?
972 private var shapeDragClipId: UUID?
973 private var shapeStart: CGPoint?
974
975 override func mouseDown(with event: NSEvent) {
976 // Shape placement (from the toolbar dropdown) on a storyboard preview.
977 if let kind = session.pendingShape, let clip = panelClip, let board = clip.board {
978 let bp = boardPoint(convert(event.locationInWindow, from: nil), board: board)
979 beginShapePlacement(kind: kind, at: bp, clip: clip)
980 return
981 }
982 // Draw tools paint straight onto the storyboard preview.
983 if session.mainTool.isDraw, let clip = panelClip, let board = clip.board {
984 strokeBoard = board
985 let bp = boardPoint(convert(event.locationInWindow, from: nil), board: board)
986 boards.beginStroke(board: board)
987 strokeSegment(from: bp, to: bp, board: board, pressure: CGFloat(event.pressure))
988 lastStrokePoint = bp
989 update()
990 return
991 }
992 // Otherwise: click selects the clip and reveals it in the timeline.
993 guard let id = currentClipId else { return }
994 store.selection = [id]
995 NotificationCenter.default.post(name: .revealClip, object: nil,
996 userInfo: ["clipId": id])
997 }
998
999 override func mouseDragged(with event: NSEvent) {
1000 if let id = shapeDragId, let clipId = shapeDragClipId, let start = shapeStart,
1001 let clip = store.project.clip(clipId), let board = clip.board {
1002 let bp = boardPoint(convert(event.locationInWindow, from: nil), board: board)
1003 var w = max(4, abs(bp.x - start.x))
1004 var h = max(4, abs(bp.y - start.y))
1005 if event.modifierFlags.contains(.shift) { w = max(w, h); h = w }
1006 let frame = CGRect(x: bp.x < start.x ? start.x - w : start.x,
1007 y: bp.y < start.y ? start.y - h : start.y,
1008 width: w, height: h)
1009 store.updateGesture { model in
1010 guard let i = model.clips.firstIndex(where: { $0.id == clipId }),
1011 var b = model.clips[i].board else { return }
1012 if let j = b.shapes.firstIndex(where: { $0.id == id }) {
1013 b.shapes[j].frame = frame
1014 }
1015 b.revision += 1
1016 model.clips[i].board = b
1017 }
1018 update()
1019 return
1020 }
1021 guard let board = strokeBoard else { return }
1022 let bp = boardPoint(convert(event.locationInWindow, from: nil), board: board)
1023 if let last = lastStrokePoint {
1024 strokeSegment(from: last, to: bp, board: board, pressure: CGFloat(event.pressure))
1025 }
1026 lastStrokePoint = bp
1027 update()
1028 }
1029
1030 override func mouseUp(with event: NSEvent) {
1031 if shapeDragId != nil {
1032 store.endGesture()
1033 shapeDragId = nil
1034 shapeDragClipId = nil
1035 shapeStart = nil
1036 session.pendingShape = nil // placing a shape hands back the select tool
1037 update()
1038 return
1039 }
1040 if let board = strokeBoard {
1041 boards.endStroke(board: board)
1042 strokeBoard = nil
1043 lastStrokePoint = nil
1044 update()
1045 }
1046 }
1047
1048 private func beginShapePlacement(kind: BoardShape.Kind, at bp: CGPoint, clip: Clip) {
1049 if kind == .image {
1050 session.pendingShape = nil
1051 let panel = NSOpenPanel()
1052 panel.allowedContentTypes = [.image]
1053 guard panel.runModal() == .OK, let url = panel.url,
1054 let board = clip.board else { return }
1055 let w = board.width * 0.35
1056 var h = w * 0.66
1057 if let img = NSImage(contentsOf: url), img.size.width > 0 {
1058 h = w * Double(img.size.height / img.size.width)
1059 }
1060 var shape = BoardShape(kind: .image, frame:
1061 CGRect(x: bp.x - w / 2, y: bp.y - h / 2, width: w, height: h))
1062 shape.imagePath = url.path
1063 let new = shape
1064 store.mutate { model in
1065 guard let i = model.clips.firstIndex(where: { $0.id == clip.id }),
1066 var b = model.clips[i].board else { return }
1067 b.shapes.append(new)
1068 b.revision += 1
1069 model.clips[i].board = b
1070 }
1071 return
1072 }
1073 var shape = BoardShape(kind: kind,
1074 frame: CGRect(x: bp.x, y: bp.y, width: 1, height: 1))
1075 shape.color = BoardStore.rgba(session.drawColor)
1076 if kind == .text {
1077 shape.frame = CGRect(x: bp.x, y: bp.y - 35, width: 420, height: 70)
1078 shape.fontSize = 48
1079 shape.text = "Text"
1080 let new = shape
1081 store.mutate { model in
1082 guard let i = model.clips.firstIndex(where: { $0.id == clip.id }),
1083 var b = model.clips[i].board else { return }
1084 b.shapes.append(new)
1085 b.revision += 1
1086 model.clips[i].board = b
1087 }
1088 session.pendingShape = nil
1089 // Typing happens in the full editor (double-click the text there).
1090 StoryboardEditor.shared.open(clipId: clip.id, ctx: ctx)
1091 return
1092 }
1093 shapeDragId = shape.id
1094 shapeDragClipId = clip.id
1095 shapeStart = bp
1096 let new = shape
1097 store.beginGesture()
1098 store.updateGesture { model in
1099 guard let i = model.clips.firstIndex(where: { $0.id == clip.id }),
1100 var b = model.clips[i].board else { return }
1101 b.shapes.append(new)
1102 b.revision += 1
1103 model.clips[i].board = b
1104 }
1105 update()
1106 }
1107
1108 private func strokeSegment(from a: CGPoint, to b: CGPoint, board: Board,
1109 pressure: CGFloat) {
1110 guard let width = session.mainTool.strokeWidth else { return }
1111 boards.strokeSegment(
1112 board: board, from: a, to: b, width: width, color: session.drawColor,
1113 erase: session.mainTool == .eraser,
1114 alpha: session.mainTool == .pencil ? 0.85 : 1, pressure: pressure)
1115 }
1116
1117 // MARK: Context menu
1118
1119 override func menu(for event: NSEvent) -> NSMenu? {
1120 let menu = NSMenu()
1121 func add(_ title: String, _ action: Selector,
1122 key: String = "", mods: NSEvent.ModifierFlags = []) {
1123 let mi = NSMenuItem(title: title, action: action, keyEquivalent: key)
1124 mi.keyEquivalentModifierMask = mods
1125 mi.target = self
1126 menu.addItem(mi)
1127 }
1128 add(session.focusedTracks.contains(ref) ? "Unfocus Track" : "Focus Track",
1129 #selector(ctxFocus), key: "f")
1130 add(session.priorityPane == ref ? "Remove Priority" : "Prioritize",
1131 #selector(ctxPriority), key: "f", mods: .shift)
1132 add(session.hiddenTracks.contains(ref) ? "Show Preview" : "Hide Preview",
1133 #selector(ctxHide), key: "h")
1134 add("Show All Tracks", #selector(ctxShowAll))
1135 if currentClipId != nil {
1136 menu.addItem(.separator())
1137 add("Reveal Clip in Timeline", #selector(ctxReveal))
1138 }
1139 if panelClip != nil {
1140 add("Open in Storyboard Window", #selector(ctxOpenBoard))
1141 }
1142 return menu
1143 }
1144 @objc private func ctxHide() { session.toggleHidden(ref) }
1145 @objc private func ctxFocus() { session.toggleFocus(ref) }
1146 @objc private func ctxPriority() { togglePriority() }
1147 @objc private func ctxShowAll() { session.showAll() }
1148 @objc private func ctxReveal() {
1149 guard let id = currentClipId else { return }
1150 store.selection = [id]
1151 NotificationCenter.default.post(name: .revealClip, object: nil,
1152 userInfo: ["clipId": id])
1153 }
1154 @objc private func ctxOpenBoard() {
1155 if let clip = panelClip { StoryboardEditor.shared.open(clipId: clip.id, ctx: ctx) }
1156 }
1157
1158 // MARK: Content
1159
1160 func update() {
1161 // Every layer mutation below (isHidden toggles, contents swaps) would
1162 // otherwise fire CALayer's default fade — which dips through the black
1163 // cell background as you skip. Snap instead: no fade-from-black.
1164 CATransaction.begin()
1165 CATransaction.setDisableActions(true)
1166 defer { CATransaction.commit() }
1167 let project = store.project
1168 let playhead = playback.playhead
1169 let tp = players.player(for: ref)
1170 if playerLayer.player !== tp.player { playerLayer.player = tp.player }
1171 focusButton.active = session.focusedTracks.contains(ref)
1172 hideButton.active = session.hiddenTracks.contains(ref)
1173
1174 // Storyboard panels preview their composite (and take strokes). Their
1175 // "1B" name lives top-left like a filename, and stays visible.
1176 if let panel = project.clipAt(track: ref, time: playhead, kind: .storyboard),
1177 let board = panel.board {
1178 currentClipId = panel.id
1179 playerLayer.isHidden = true
1180 imageLayer.isHidden = false
1181 imageLayer.contents = boards.composite(for: board)
1182 setTopLeft(project.panelNames()[panel.id] ?? "", hoverOnly: false)
1183 setStatus("")
1184 showLoadingOverlay(false)
1185 return
1186 }
1187
1188 // Audio never shows here — cells exist only for visual tracks.
1189 guard let clip = project.clipAt(track: ref, time: playhead, kind: .video),
1190 let media = project.media(clip.mediaId) else {
1191 currentClipId = nil
1192 playerLayer.isHidden = true
1193 imageLayer.isHidden = true
1194 imageLayer.contents = nil
1195 setTopLeft("", hoverOnly: true)
1196 setStatus("")
1197 showLoadingOverlay(false)
1198 return
1199 }
1200 currentClipId = clip.id
1201 setTopLeft(media.displayName, hoverOnly: true)
1202
1203 let src = max(0, clip.sourceTime(at: playhead))
1204 let covered = chunks.isCovered(media: media, sourceTime: src)
1205 // isReadyForDisplay only means SOME frame is decoded — right after an
1206 // item swap or before a seek lands that frame is time 0 (black), not
1207 // the playhead's frame. Reveal the player only once it's actually
1208 // parked near the expected source time; otherwise the filmstrip (a
1209 // real frame at this moment) stands in — so the transition is
1210 // filmstrip → video, never black.
1211 let cur = tp.player.currentTime().seconds
1212 let onTime = cur.isFinite && abs(cur - src) < 0.5
1213 let itemOK = tp.player.currentItem != nil && !tp.itemFailed
1214 && tp.player.currentItem?.status != .failed
1215 && (covered || chunks.originalPlayable(media: media))
1216 && playerLayer.isReadyForDisplay
1217 && onTime
1218 playerLayer.isHidden = !itemOK
1219 imageLayer.isHidden = itemOK
1220
1221 var status = covered ? "" : "processing…"
1222 if itemOK {
1223 showLoadingOverlay(false)
1224 } else {
1225 let strip = MediaPipeline.shared.filmstripImage(for: media, at: src)
1226 imageLayer.contents = strip
1227 // No live video AND no thumbnail stand-in: make "not rendered"
1228 // unmistakable with the framed spinner + "Loading Media…" instead
1229 // of a near-black cell. A filmstrip, when present, is a real frame
1230 // for this moment, so it still stands in.
1231 if strip == nil {
1232 showLoadingOverlay(true)
1233 status = ""
1234 } else {
1235 showLoadingOverlay(false)
1236 }
1237 }
1238 setStatus(status)
1239 }
1240}
1241
1242/// Preview of the topmost Fusion comp's rendered output at the playhead.
1243final class FusionViewerCell: ViewerCellBase {
1244 private let imageLayer = CALayer()
1245
1246 override init(frame: NSRect) {
1247 super.init(frame: frame)
1248 imageLayer.contentsGravity = .resizeAspect
1249 layer?.insertSublayer(imageLayer, at: 0)
1250 layer?.borderColor = FusionComps.yellow.cgColor
1251 // Bright Fusion yellow is unreadable behind white chip text — the tab
1252 // uses a dark amber instead.
1253 chipColor = NSColor(calibratedHue: 0.13, saturation: 0.9, brightness: 0.5, alpha: 0.92)
1254 focusButton.onClick = { [weak self] in self?.session.fusionFocus.toggle() }
1255 hideButton.onClick = { [weak self] in self?.session.fusionHidden = true }
1256 colorButton.isHidden = true // the Fusion band's colour is fixed
1257 }
1258 required init?(coder: NSCoder) { fatalError() }
1259
1260 override func togglePriority() {
1261 session.priorityPane = session.priorityPane == UI.fusionPaneKey ? nil : UI.fusionPaneKey
1262 }
1263
1264 override func layoutContentLayers(in target: CGRect) {
1265 imageLayer.frame = target
1266 }
1267
1268 private var currentFrame: Int {
1269 let fps = store.project.fps
1270 return Int((playback.playhead * fps).rounded())
1271 }
1272
1273 override func mouseDown(with event: NSEvent) {
1274 if let comp = comps.topmost(atFrame: currentFrame) {
1275 comps.selectedCompPath = comp.path
1276 NotificationCenter.default.post(name: .compsChanged, object: nil)
1277 }
1278 }
1279
1280 override func menu(for event: NSEvent) -> NSMenu? {
1281 guard let comp = comps.topmost(atFrame: currentFrame) else { return nil }
1282 comps.selectedCompPath = comp.path
1283 let menu = NSMenu()
1284 func add(_ title: String, _ action: Selector,
1285 key: String = "", mods: NSEvent.ModifierFlags = []) {
1286 let mi = NSMenuItem(title: title, action: action, keyEquivalent: key)
1287 mi.keyEquivalentModifierMask = mods
1288 mi.target = self
1289 menu.addItem(mi)
1290 }
1291 let preferred = store.project.preferredTakes.contains(comp.name)
1292 add(preferred ? "Unmark Preferred Take" : "Set as Preferred Take", #selector(ctxTake))
1293 add("Open in Fusion", #selector(ctxOpen))
1294 menu.addItem(.separator())
1295 add(session.fusionFocus ? "Unfocus Fusion" : "Focus Fusion", #selector(ctxFocus), key: "f")
1296 add(session.priorityPane == UI.fusionPaneKey ? "Remove Priority" : "Prioritize",
1297 #selector(ctxPriority), key: "f", mods: .shift)
1298 add("Hide Fusion Preview", #selector(ctxHide), key: "h")
1299 return menu
1300 }
1301 @objc private func ctxTake() { comps.togglePreferredTake() }
1302 @objc private func ctxOpen() {
1303 if let comp = comps.topmost(atFrame: currentFrame) {
1304 comps.openInFusion(comp)
1305 }
1306 }
1307 @objc private func ctxFocus() { session.fusionFocus.toggle() }
1308 @objc private func ctxPriority() { togglePriority() }
1309 @objc private func ctxHide() { session.fusionHidden = true }
1310
1311 func update() {
1312 CATransaction.begin()
1313 CATransaction.setDisableActions(true)
1314 defer { CATransaction.commit() }
1315 focusButton.active = session.fusionFocus
1316 hideButton.active = session.fusionHidden
1317 guard let (comp, image) = comps.frameImage(atFrame: currentFrame) else {
1318 imageLayer.contents = nil
1319 setTopLeft("", hoverOnly: true)
1320 setStatus("")
1321 return
1322 }
1323 setTopLeft(comp.name, hoverOnly: true)
1324 if let image {
1325 imageLayer.contents = image
1326 setStatus("")
1327 } else {
1328 setStatus("\(comp.title.isEmpty ? comp.name : comp.title) — no render yet")
1329 }
1330 }
1331}
sequencer/Sources/Sequencer/WindowController.swift created+330
...@@ -0,0 +1,330 @@
1import AppKit
2import UniformTypeIdentifiers
3
4/// One window per open project. Owns the timeline / viewer / transport views
5/// (all bound to this document's `ctx`), the split layout, and every
6/// per-document menu action. Menu items for these actions target the first
7/// responder, so the key window's controller handles them.
8final class SequencerWindowController: NSWindowController, NSWindowDelegate,
9 NSSplitViewDelegate, NSMenuItemValidation {
10 let ctx: DocumentContext
11 let timeline = TimelineView()
12 let viewer = ViewerGridView()
13 let transport = TransportBar()
14 private let split = NSSplitView()
15 private let container = NSView()
16 private let timelinePane = NSView()
17 private var popoutWindow: NSWindow?
18 private var previewsPopped = false
19 var previewsArePopped: Bool { previewsPopped }
20 private var layoutConstraints: [NSLayoutConstraint] = []
21
22 private var session: SessionState { ctx.session }
23 private var store: Store { ctx.store }
24 private var fps: Double { ctx.store.project.fps }
25
26 init(ctx: DocumentContext) {
27 self.ctx = ctx
28 let window = NSWindow(
29 contentRect: NSRect(x: 0, y: 0, width: 1500, height: 950),
30 styleMask: [.titled, .closable, .miniaturizable, .resizable],
31 backing: .buffered, defer: false)
32 window.minSize = NSSize(width: 900, height: 600)
33 super.init(window: window)
34 window.delegate = self
35 shouldCascadeWindows = true
36
37 // Bind the three views to this document's context before they draw.
38 timeline.ctx = ctx
39 viewer.ctx = ctx
40 transport.ctx = ctx
41
42 buildContent()
43 window.makeFirstResponder(timeline)
44 }
45 required init?(coder: NSCoder) { fatalError() }
46
47 /// Warm up derived assets and start the playback clock once the document is
48 /// loaded and its window shown.
49 func startDocumentServices() {
50 ctx.startServices()
51 }
52
53 // MARK: - Window & layout
54
55 private func buildContent() {
56 transport.translatesAutoresizingMaskIntoConstraints = false
57 timeline.translatesAutoresizingMaskIntoConstraints = false
58 split.dividerStyle = .thin
59 split.delegate = self
60 split.translatesAutoresizingMaskIntoConstraints = false
61
62 timelinePane.addSubview(timeline)
63 container.addSubview(split)
64 NSLayoutConstraint.activate([
65 timeline.leadingAnchor.constraint(equalTo: timelinePane.leadingAnchor),
66 timeline.trailingAnchor.constraint(equalTo: timelinePane.trailingAnchor),
67 timeline.bottomAnchor.constraint(equalTo: timelinePane.bottomAnchor),
68 split.leadingAnchor.constraint(equalTo: container.leadingAnchor),
69 split.trailingAnchor.constraint(equalTo: container.trailingAnchor),
70 split.bottomAnchor.constraint(equalTo: container.bottomAnchor),
71 ])
72 window?.contentView = container
73 applyLayout()
74 }
75
76 // Neither pane may collapse: previews and timeline both stay usable.
77 func splitView(_ splitView: NSSplitView, constrainMinCoordinate proposed: CGFloat,
78 ofSubviewAt dividerIndex: Int) -> CGFloat {
79 max(proposed, 200)
80 }
81 func splitView(_ splitView: NSSplitView, constrainMaxCoordinate proposed: CGFloat,
82 ofSubviewAt dividerIndex: Int) -> CGFloat {
83 let total = splitView.isVertical ? splitView.bounds.width : splitView.bounds.height
84 return min(proposed, total - 240)
85 }
86
87 /// Rebuild the split for the current layout mode: previews above (default),
88 /// previews on the left, or previews popped out into their own window.
89 private func applyLayout() {
90 guard let window else { return }
91 viewer.removeFromSuperview()
92 for v in split.arrangedSubviews {
93 split.removeArrangedSubview(v)
94 v.removeFromSuperview()
95 }
96 NSLayoutConstraint.deactivate(layoutConstraints)
97 transport.removeFromSuperview()
98
99 let toolbarOnTop = previewsPopped || session.previewsOnLeft
100 if toolbarOnTop {
101 container.addSubview(transport)
102 layoutConstraints = [
103 transport.topAnchor.constraint(equalTo: container.topAnchor),
104 transport.leadingAnchor.constraint(equalTo: container.leadingAnchor),
105 transport.trailingAnchor.constraint(equalTo: container.trailingAnchor),
106 transport.heightAnchor.constraint(equalToConstant: 27),
107 split.topAnchor.constraint(equalTo: transport.bottomAnchor),
108 timeline.topAnchor.constraint(equalTo: timelinePane.topAnchor),
109 ]
110 } else {
111 timelinePane.addSubview(transport)
112 layoutConstraints = [
113 transport.topAnchor.constraint(equalTo: timelinePane.topAnchor),
114 transport.leadingAnchor.constraint(equalTo: timelinePane.leadingAnchor),
115 transport.trailingAnchor.constraint(equalTo: timelinePane.trailingAnchor),
116 transport.heightAnchor.constraint(equalToConstant: 27),
117 split.topAnchor.constraint(equalTo: container.topAnchor),
118 timeline.topAnchor.constraint(equalTo: transport.bottomAnchor),
119 ]
120 }
121 NSLayoutConstraint.activate(layoutConstraints)
122
123 if previewsPopped {
124 let pw = popoutWindow ?? {
125 let w = NSWindow(
126 contentRect: NSRect(x: 0, y: 0, width: 960, height: 560),
127 styleMask: [.titled, .closable, .resizable],
128 backing: .buffered, defer: false)
129 w.title = "Previews"
130 w.isReleasedWhenClosed = false
131 w.delegate = self
132 popoutWindow = w
133 return w
134 }()
135 pw.contentView = viewer
136 pw.makeKeyAndOrderFront(nil)
137 split.isVertical = false
138 split.addArrangedSubview(timelinePane)
139 window.makeKeyAndOrderFront(nil)
140 } else {
141 if let pw = popoutWindow {
142 pw.delegate = nil
143 pw.contentView = NSView()
144 pw.orderOut(nil)
145 pw.delegate = self
146 }
147 split.isVertical = session.previewsOnLeft
148 split.addArrangedSubview(viewer)
149 split.addArrangedSubview(timelinePane)
150 split.setHoldingPriority(.defaultLow, forSubviewAt: 0)
151 split.setHoldingPriority(.defaultHigh, forSubviewAt: 1)
152 DispatchQueue.main.async { [self] in
153 if session.previewsOnLeft {
154 split.setPosition(window.frame.width * 0.44, ofDividerAt: 0)
155 } else {
156 split.setPosition(window.frame.height * 0.62, ofDividerAt: 0)
157 }
158 }
159 }
160 window.makeFirstResponder(timeline)
161 }
162
163 func windowWillClose(_ notification: Notification) {
164 if (notification.object as? NSWindow) === popoutWindow, previewsPopped {
165 previewsPopped = false
166 applyLayout()
167 }
168 }
169
170 // MARK: - Menu validation (per-document items)
171
172 func validateMenuItem(_ menuItem: NSMenuItem) -> Bool {
173 switch menuItem.action {
174 case #selector(undo): return store.canUndo || StoryboardEditor.shared.canUndoRaster
175 case #selector(redo): return store.canRedo
176 case #selector(deleteSelected), #selector(rippleDeleteSelected):
177 return !store.selection.isEmpty
178 case #selector(deselectAll):
179 return true // Esc also stops playback, so it stays live
180 case #selector(toggleNewShot):
181 menuItem.state = (timeline.newShotMenuState ?? false) ? .on : .off
182 return true
183 case #selector(toggleSnapping):
184 menuItem.state = session.snapping ? .on : .off
185 return true
186 case #selector(toggleFilmstrips):
187 menuItem.state = session.showFilmstrips ? .on : .off
188 return true
189 case #selector(togglePreviewsLeft):
190 menuItem.state = session.previewsOnLeft ? .on : .off
191 return !previewsPopped
192 case #selector(togglePopout):
193 menuItem.state = previewsPopped ? .on : .off
194 return true
195 case #selector(openStoryboardEditor):
196 return session.panelUnderPlayhead != nil ||
197 store.selection.contains { store.project.clip($0)?.kind == .storyboard }
198 case #selector(toggleBackgroundOptimization):
199 menuItem.state = ctx.chunks.isPaused ? .off : .on
200 return true
201 case #selector(prevMarker), #selector(nextMarker), #selector(clearMarkers):
202 return !store.project.markers.isEmpty
203 case #selector(toggleLoop):
204 menuItem.state = ctx.playback.loops ? .on : .off
205 return true
206 case #selector(clearInOut):
207 return ctx.playback.hasInOut
208 default: return true
209 }
210 }
211
212 // MARK: - Edit / Clip actions
213
214 @objc func undo() {
215 if StoryboardEditor.shared.undoRasterIfKey() { return }
216 if session.mainTool.isDraw, ctx.boards.undoLastStroke() { return }
217 store.undo()
218 }
219 @objc func redo() { store.redo() }
220 @objc func deselectAll() {
221 store.selection = []
222 ctx.playback.setRate(0)
223 }
224 @objc func rippleTrimLeft() { timeline.rippleTrimToPlayhead(deleteLeft: true) }
225 @objc func rippleTrimRight() { timeline.rippleTrimToPlayhead(deleteLeft: false) }
226 @objc func closeGapAtPlayhead() { timeline.closeBlankSpaceAtPlayhead() }
227 @objc func split(_ sender: Any?) { timeline.split() }
228 @objc func splitStoryboard() { timeline.splitStoryboardAtPlayhead() }
229 @objc func splitStoryboardNewShot() { timeline.splitStoryboardAtPlayhead(newShot: true) }
230 @objc func toggleNewShot() { timeline.toggleNewShot() }
231 @objc func moveOverlaps() { timeline.moveOverlapsToSeparateTracks() }
232 @objc func nudgeLeft() { timeline.nudgeSelection(by: -1 / fps) }
233 @objc func nudgeRight() { timeline.nudgeSelection(by: 1 / fps) }
234 @objc func nudgeLeftSecond() { timeline.nudgeSelection(by: -1) }
235 @objc func nudgeRightSecond() { timeline.nudgeSelection(by: 1) }
236 @objc func showExport() { ExportDialog.shared.show() }
237 @objc func muteClips() { timeline.toggleMute() }
238 @objc func linkClips() { timeline.linkSelection() }
239 @objc func unlinkClips() { timeline.unlinkSelection() }
240 @objc func deleteSelected() { timeline.deleteSelection() }
241 @objc func rippleDeleteSelected() { timeline.rippleDelete() }
242
243 @objc func importMedia() {
244 let panel = NSOpenPanel()
245 panel.allowsMultipleSelection = true
246 panel.canChooseDirectories = true
247 guard panel.runModal() == .OK else { return }
248 let urls = panel.urls.filter {
249 UI.importableExtensions.contains($0.pathExtension.lowercased())
250 || $0.lastPathComponent == "sync.json"
251 || (try? $0.resourceValues(forKeys: [.isDirectoryKey]).isDirectory) == true
252 }
253 guard !urls.isEmpty else { return }
254 timeline.importFiles(urls, atSecond: ctx.playback.playhead, targetRow: nil)
255 }
256
257 @objc func revealProject() {
258 if let url = ctx.document?.fileURL {
259 NSWorkspace.shared.activateFileViewerSelecting([url])
260 }
261 }
262
263 // MARK: - Track actions
264
265 @objc func deleteEmptyTracks() { timeline.deleteEmptyTracks() }
266 @objc func resetTrackVisibility() { timeline.resetTrackVisibility() }
267
268 // MARK: - View actions
269
270 @objc func toggleSnapping() { session.snapping.toggle() }
271 @objc func toggleFilmstrips() { session.showFilmstrips.toggle() }
272 @objc func zoomFit() { timeline.zoomToFit() }
273 @objc func zoomIn() { timeline.zoomIn() }
274 @objc func zoomOut() { timeline.zoomOut() }
275 @objc func tallerTracks() { session.laneScale *= 1.2 }
276 @objc func shorterTracks() { session.laneScale /= 1.2 }
277 @objc func resetTrackHeights() {
278 session.laneScale = 1
279 session.trackHeights = [:]
280 }
281 @objc func togglePreviewsLeft() {
282 session.previewsOnLeft.toggle()
283 applyLayout()
284 }
285 @objc func togglePopout() {
286 previewsPopped.toggle()
287 applyLayout()
288 }
289
290 // MARK: - Playback actions
291
292 @objc func playPause() { ctx.playback.togglePlay() }
293 @objc func stopPlayback() { ctx.playback.setRate(0) }
294 @objc func shuttleForward() { ctx.playback.shuttle(1) }
295 @objc func shuttleReverse() { ctx.playback.shuttle(-1) }
296 @objc func stepForward() { ctx.playback.step(by: 1 / fps) }
297 @objc func stepBackward() { ctx.playback.step(by: -1 / fps) }
298 @objc func stepForwardSecond() { ctx.playback.step(by: 1) }
299 @objc func stepBackwardSecond() { ctx.playback.step(by: -1) }
300 @objc func goToStart() { ctx.playback.seek(to: 0) }
301 @objc func goToEnd() { ctx.playback.seek(to: store.project.timelineDuration) }
302 @objc func setInPoint() { ctx.playback.setIn() }
303 @objc func setOutPoint() { ctx.playback.setOut() }
304 @objc func toggleLoop() { ctx.playback.toggleLoop() }
305 @objc func clearInOut() { ctx.playback.clearInOut() }
306 @objc func toggleMarker() { timeline.toggleMarkerAtPlayhead() }
307 @objc func prevMarker() { timeline.goToPrevMarker() }
308 @objc func nextMarker() { timeline.goToNextMarker() }
309 @objc func prevStoryboardPanel() { timeline.goToPrevStoryboardPanel() }
310 @objc func nextStoryboardPanel() { timeline.goToNextStoryboardPanel() }
311 @objc func clearMarkers() { timeline.clearAllMarkers() }
312
313 @objc func toggleBackgroundOptimization() {
314 ctx.chunks.setPaused(!ctx.chunks.isPaused)
315 }
316
317 // MARK: - Comps & storyboard actions
318
319 @objc func setPreferredTake() { ctx.comps.togglePreferredTake() }
320
321 @objc func openStoryboardEditor() {
322 let selected = store.selection.first { store.project.clip($0)?.kind == .storyboard }
323 if let id = selected ?? session.panelUnderPlayhead?.id {
324 StoryboardEditor.shared.open(clipId: id, ctx: ctx)
325 } else {
326 NotificationCenter.default.post(name: .transientStatus, object: nil,
327 userInfo: ["text": "Move the playhead over a storyboard panel to edit it"])
328 }
329 }
330}
sequencer/Sources/Sequencer/main.swift created+18
...@@ -0,0 +1,18 @@
1import AppKit
2
3// Headless pipeline test: sequencer --selftest <mediafile>
4if CommandLine.arguments.count >= 3, CommandLine.arguments[1] == "--selftest" {
5 runSelftest(path: CommandLine.arguments[2])
6 exit(0)
7}
8
9if CommandLine.arguments.contains("--uitest") {
10 _ = NSApplication.shared // AppKit needs an app instance for views/windows
11 MainActor.assumeIsolated { runUITest() }
12}
13
14let app = SeqApplication.shared
15let delegate = AppDelegate()
16app.delegate = delegate
17app.setActivationPolicy(.regular)
18app.run()
sequencer/build.sh created+4
...@@ -0,0 +1,4 @@
1#!/bin/sh
2set -e
3cd "$(dirname "$0")"
4swift build -c release
sequencer/readme.md created+3
...@@ -0,0 +1,3 @@
1# Clover Sequencer
2
3Not a video editor. Sequencer helps storyboard videos, sync clips in time, and review multi-cam recorings. This software is written with Blackmagic Fusion Studio in mind and is not useful on its own. The workflow is to use Sequencer to arrange and trim media, and then copy and paste the clips into fusion as Loaders.
\ No newline at end of file
sequencer/run.sh created+8
...@@ -0,0 +1,8 @@
1#!/bin/sh
2set -e
3cd "$(dirname "$0")"
4swift build
5pkill -x Sequencer 2>/dev/null || true
6cp .build/debug/Sequencer Sequencer.app/Contents/MacOS/Sequencer
7codesign --force --deep --sign "Sequencer Dev" Sequencer.app
8open Sequencer.app
src/Dialpad.ts deleted-208
...@@ -1,208 +0,0 @@
1import { Events } from "@clo/lib/Events.ts";
2import type { Dispose } from "@clo/lib/ts.ts";
3
4// The MX Creative Console Dialpad pairs over Bluetooth as a Logitech HID++
5// device. It does NOT need HID++ feature access: it streams a single 8-byte
6// input report (id 0x02) that decodes cleanly. Discovered by sniffing — see
7// examples/hid-sniff.ts.
8//
9// 02 buttons -- -- -- -- spin rotate
10// b1 b6 b7
11//
12// `rotate` (main dial) and `spin` (knob) are signed int8 deltas; `buttons` is a
13// bitmask. NOTE: until the OS-seize phase, macOS also consumes these reports
14// (the dial scrolls, the buttons act as mouse buttons).
15const LOGITECH_VENDOR_ID = 0x046d;
16const DIALPAD_PRODUCT_ID = 0xbc00;
17
18const REPORT_ID = 0x02;
19const BUTTON_BYTE = 1;
20const SPIN_BYTE = 6;
21const ROTATE_BYTE = 7;
22
23const buttonIds = ["circle", "triangle", "square", "cross"] as const;
24
25const BUTTON_BIT_BY_ID = new Map<Dialpad.Button, number>([
26 ["square", 0x08],
27 ["cross", 0x10],
28 ["circle", 0x20],
29 ["triangle", 0x40],
30]);
31
32const RECONNECT_INTERVAL_MS = 1000;
33
34/**
35 * Node.js bindings for the Logitech MX Creative Console Dialpad (Bluetooth).
36 */
37export class Dialpad extends Events<Dialpad.EventMap> {
38 static buttons = buttonIds;
39
40 #options: Required<Dialpad.Options>;
41 #device: import("node-hid").HID | null = null;
42 #closed = false;
43 #ready = false;
44 #reconnectTimer: ReturnType<typeof setInterval> | null = null;
45 #activeButtons = new Set<Dialpad.Button>();
46 #lastButtonMask = 0;
47
48 private constructor(options: Dialpad.Options = {}) {
49 super();
50 this.#options = {
51 vendorId: options.vendorId ?? LOGITECH_VENDOR_ID,
52 productId: options.productId ?? DIALPAD_PRODUCT_ID,
53 path: options.path ?? null,
54 };
55 }
56
57 static async open(options: Dialpad.Options = {}) {
58 const dialpad = new Dialpad(options);
59 await dialpad.#start();
60 return dialpad;
61 }
62
63 get connected(): boolean {
64 return this.#ready;
65 }
66
67 onPress(button: Dialpad.Button, listener: () => void): Dispose {
68 return this.on("keypress", (code) => {
69 if (button === code) listener();
70 });
71 }
72
73 close() {
74 if (this.#closed) return;
75 this.#closed = true;
76 if (this.#reconnectTimer) clearInterval(this.#reconnectTimer);
77 this.#reconnectTimer = null;
78 this.#disconnect(false);
79 this.emit("close");
80 }
81
82 async #start() {
83 await this.#connect();
84 // Bluetooth devices don't raise `usb` hotplug events, so poll instead.
85 this.#reconnectTimer = setInterval(() => {
86 if (!this.#device && !this.#closed) void this.#connect();
87 }, RECONNECT_INTERVAL_MS);
88 this.#reconnectTimer.unref?.();
89 }
90
91 async #connect() {
92 if (this.#device || this.#closed) return;
93
94 const { devices, HID } = await import("node-hid");
95 const match = devices().find(
96 (device) =>
97 device.vendorId === this.#options.vendorId
98 && device.productId === this.#options.productId
99 && (this.#options.path ? device.path === this.#options.path : true)
100 && Boolean(device.path),
101 );
102 if (!match?.path) return;
103
104 try {
105 const device = new HID(match.path);
106 this.#device = device;
107 device.on("data", (report) => {
108 if (this.#device === device) this.#handleReport(report);
109 });
110 device.on("error", (error) => {
111 if (this.#device === device) this.#handleDeviceError(error);
112 });
113 this.#ready = true;
114 this.emit("connect");
115 } catch {
116 this.#device = null;
117 // Will retry on the next poll tick.
118 }
119 }
120
121 #handleReport(report: Buffer | number[]) {
122 const bytes = Uint8Array.from(report);
123 if (bytes[0] !== REPORT_ID) return;
124
125 const rotate = toInt8(bytes[ROTATE_BYTE] ?? 0);
126 if (rotate !== 0) this.emit("rotate", rotate);
127
128 const spin = toInt8(bytes[SPIN_BYTE] ?? 0);
129 if (spin !== 0) this.emit("spin", spin);
130
131 const mask = bytes[BUTTON_BYTE] ?? 0;
132 if (mask !== this.#lastButtonMask) {
133 this.#lastButtonMask = mask;
134 this.#applyButtonState(mask);
135 }
136 }
137
138 #applyButtonState(mask: number) {
139 const next = new Set<Dialpad.Button>();
140 for (const [button, bit] of BUTTON_BIT_BY_ID) {
141 if (mask & bit) next.add(button);
142 }
143
144 for (const button of this.#activeButtons) {
145 if (!next.has(button)) this.emit("keyup", button);
146 }
147 for (const button of next) {
148 if (!this.#activeButtons.has(button)) {
149 this.emit("keydown", button);
150 this.emit("keypress", button);
151 }
152 }
153
154 this.#activeButtons = next;
155 this.emit("key", [...next]);
156 }
157
158 #handleDeviceError(_error: unknown) {
159 this.#disconnect(true);
160 }
161
162 #disconnect(emitEvent: boolean) {
163 const device = this.#device;
164 this.#device = null;
165 this.#ready = false;
166 this.#activeButtons.clear();
167 this.#lastButtonMask = 0;
168 if (device) {
169 device.removeAllListeners("data");
170 device.removeAllListeners("error");
171 try {
172 device.close();
173 } catch {
174 // Ignore close races when the device disappears mid-reconnect.
175 }
176 }
177 if (emitEvent) this.emit("disconnect");
178 }
179}
180
181function toInt8(byte: number): number {
182 return byte > 127 ? byte - 256 : byte;
183}
184
185export declare namespace Dialpad {
186 export type Button = typeof buttonIds[number];
187
188 export interface Options {
189 vendorId?: number;
190 productId?: number;
191 path?: string | null;
192 }
193
194 export type EventMap = {
195 "connect": [];
196 "disconnect": [];
197 "close": [];
198 "error": [error: unknown];
199 /** Main dial delta (signed, clockwise positive). */
200 "rotate": [delta: number];
201 /** Up/down knob delta (signed, up positive). */
202 "spin": [delta: number];
203 "key": [activeButtons: ReadonlyArray<Button>];
204 "keydown": [button: Button];
205 "keyup": [button: Button];
206 "keypress": [button: Button];
207 };
208}
src/Keypad.ts deleted-397
...@@ -1,397 +0,0 @@
1import { Events } from "@clo/lib/Events.ts";
2import type { Dispose } from "@clo/lib/ts.ts";
3
4// Node.js bindings for the Logitech MX Creative Keypad — the 3x3 LCD grid plus
5// the two screenless buttons below it. Talks the device's HID protocol directly
6// (no vendor SDK). Protocol cross-referenced from the Stream-Deck-style wire
7// format the hardware uses:
8// - input report 0x13: grid buttons (hidId = index + 1, int8 list from off 5)
9// - input report 0x11: back/forward (hidId 0x01a1/0x01a2, uint16 BE from off 3)
10// - output report 0x14: image data, 4095-byte packets with a positioned header
11// - output report 0x11: brightness (0x11 ff 0f 2b 00 <pct>)
12// - feature report 0x03: reset to logo
13const LOGITECH_VENDOR_ID = 0x046d;
14const KEYPAD_PRODUCT_ID = 0xc354;
15
16const KEY_SIZE = 118;
17// Each grid key writes a sub-rect of the panel framebuffer. Positions are
18// offset (23, 6) with a 158px pitch (118px key + 40px gap).
19const GRID_OFFSET = { x: 23, y: 6 };
20const GRID_PITCH = KEY_SIZE + 40;
21
22const NAME_BY_INDEX = [
23 "up-left",
24 "up",
25 "up-right",
26 "left",
27 "center",
28 "right",
29 "down-left",
30 "down",
31 "down-right",
32 "back",
33 "forward",
34] as const;
35
36const LCD_KEYS = NAME_BY_INDEX.slice(0, 9) as readonly Keypad.Key[];
37const INDEX_BY_NAME = new Map<Keypad.Key, number>(
38 NAME_BY_INDEX.map((name, index) => [name, index]),
39);
40
41const KEY_POSITION = LCD_KEYS.map((_, index) => ({
42 x: GRID_OFFSET.x + (index % 3) * GRID_PITCH,
43 y: GRID_OFFSET.y + Math.floor(index / 3) * GRID_PITCH,
44}));
45
46const PANEL_SIZE = 480;
47
48/** Grid key (x,y) positions within the 480x480 panel framebuffer, row-major. */
49export const KEY_POSITIONS: ReadonlyArray<{ x: number; y: number }> = KEY_POSITION;
50/** Full panel pixel size (square). */
51export const PANEL_SIZE_PX = PANEL_SIZE;
52
53// Input hidId -> key name. Grid keys use hidId = index + 1; the two page buttons
54// report 16-bit ids.
55const NAME_BY_HID = new Map<number, Keypad.Key>(
56 LCD_KEYS.map((name, index) => [index + 1, name]),
57);
58NAME_BY_HID.set(0x01a1, "back");
59NAME_BY_HID.set(0x01a2, "forward");
60
61// Sent on connect so the back/forward buttons emit raw HID events.
62const INIT_WRITES = [0x01a1, 0x01a2].map((hidId) => {
63 const buffer = Buffer.alloc(20);
64 buffer.set([0x11, 0xff, 0x0b, 0x3b, (hidId >> 8) & 0xff, hidId & 0xff, 0x03]);
65 return buffer;
66});
67
68const IMAGE_REPORT_ID = 0x14;
69const MAX_PACKET_SIZE = 4095;
70const PACKET1_HEADER = 20;
71const PACKETN_HEADER = 5;
72
73const RECONNECT_INTERVAL_MS = 1000;
74
75export class Keypad extends Events<Keypad.EventMap> {
76 static keys = NAME_BY_INDEX;
77 static lcdKeys = LCD_KEYS;
78
79 #device: import("node-hid").HID | null = null;
80 #closed = false;
81 #ready = false;
82 #reconnectTimer: ReturnType<typeof setInterval> | null = null;
83 #images = new Map<Keypad.Key, Uint8Array>();
84 #shown = new Map<Keypad.Key, Uint8Array>();
85 #panel: Uint8Array | null = null;
86 #shownPanel: Uint8Array | null = null;
87 #gridDown = new Set<Keypad.Key>();
88 #pageDown = new Set<Keypad.Key>();
89
90 private constructor() {
91 super();
92 }
93
94 static async open() {
95 const keypad = new Keypad();
96 await keypad.#start();
97 return keypad;
98 }
99
100 get connected(): boolean {
101 return this.#ready;
102 }
103
104 onPress(key: Keypad.Key, listener: () => void): Dispose {
105 return this.on("keypress", (code) => {
106 if (key === code) listener();
107 });
108 }
109
110 /**
111 * Show a pre-encoded JPEG on a grid key. The caller owns encoding (see
112 * KeypadUI); identical buffers are deduped so unchanged keys never re-send.
113 */
114 setImage(key: Keypad.Key, image: Uint8Array) {
115 this.#images.set(key, image);
116 this.#panel = null; // a per-key image supersedes any full-panel image
117 if (this.#shown.get(key) === image) return; // already on screen — cached
118 if (this.#writeImage(key, image)) {
119 this.#shown.set(key, image);
120 this.#shownPanel = null;
121 }
122 }
123
124 /**
125 * Show one composed image across the whole 480x480 panel as a single
126 * image-write. The device repaints every key in one refresh — no per-key
127 * cascade. Identical buffers are deduped.
128 */
129 setPanel(image: Uint8Array) {
130 this.#panel = image;
131 this.#images.clear(); // a full-panel image supersedes per-key images
132 if (this.#shownPanel === image) return; // already on screen — cached
133 if (this.#writeRegion(0, 0, PANEL_SIZE, PANEL_SIZE, image)) {
134 this.#shownPanel = image;
135 this.#shown.clear();
136 }
137 }
138
139 /** Brightness as 0..1. */
140 setBrightness(level: number) {
141 const percentage = Math.max(
142 1,
143 Math.min(100, Math.round(Math.max(0, Math.min(1, level)) * 100)),
144 );
145 const command = Buffer.alloc(20);
146 command.set([0x11, 0xff, 0x0f, 0x2b, 0x00, percentage]);
147 this.#write(command);
148 }
149
150 /** Reset all screens to the startup logo. */
151 reset() {
152 this.#shown.clear();
153 this.#shownPanel = null;
154 const command = Buffer.alloc(32);
155 command.set([0x03, 0x02]);
156 try {
157 this.#device?.sendFeatureReport(command);
158 } catch {
159 // Ignore if the device vanished.
160 }
161 }
162
163 close() {
164 if (this.#closed) return;
165 this.#closed = true;
166 if (this.#reconnectTimer) clearInterval(this.#reconnectTimer);
167 this.#reconnectTimer = null;
168 this.#disconnect(false);
169 this.emit("close");
170 }
171
172 async #start() {
173 await this.#connect();
174 // The keypad pairs over Bluetooth too, where `usb` hotplug is silent — poll.
175 this.#reconnectTimer = setInterval(() => {
176 if (!this.#device && !this.#closed) void this.#connect();
177 }, RECONNECT_INTERVAL_MS);
178 this.#reconnectTimer.unref?.();
179 }
180
181 async #connect() {
182 if (this.#device || this.#closed) return;
183
184 const { devices, HID } = await import("node-hid");
185 const match = devices().find(
186 (device) =>
187 device.vendorId === LOGITECH_VENDOR_ID &&
188 device.productId === KEYPAD_PRODUCT_ID &&
189 Boolean(device.path),
190 );
191 if (!match?.path) return;
192
193 try {
194 const device = new HID(match.path);
195 this.#device = device;
196 device.on("data", (report) => {
197 if (this.#device === device) this.#handleReport(report);
198 });
199 device.on("error", () => {
200 if (this.#device === device) this.#handleDeviceError();
201 });
202
203 for (const write of INIT_WRITES) device.write(write);
204 this.#ready = true;
205 this.emit("connect");
206 this.#reapplyImages();
207 } catch {
208 this.#device = null;
209 // Retry on the next poll tick.
210 }
211 }
212
213 #handleReport(report: Buffer | number[]) {
214 const buffer = Buffer.isBuffer(report) ? report : Buffer.from(report);
215 const reportId = buffer[0];
216 const data = buffer.subarray(1);
217 if (data[2] === 0x2b) return; // ack to a drawing write
218
219 if (reportId === 0x13) this.#handleGridInput(data);
220 else if (reportId === 0x11) this.#handlePageInput(data);
221 }
222
223 #handleGridInput(data: Buffer) {
224 if (data[0] !== 0xff || data[1] !== 0x02 || data[2] !== 0x00 || data[4] !== 0x01) {
225 return;
226 }
227 const pressed = new Set<Keypad.Key>();
228 for (let i = 5; i < data.length; i += 1) {
229 const value = data.readInt8(i);
230 if (value === 0) break;
231 const key = NAME_BY_HID.get(value);
232 if (key) pressed.add(key);
233 }
234 this.#applyPressed(pressed, this.#gridDown);
235 }
236
237 #handlePageInput(data: Buffer) {
238 if (data[0] !== 0xff || data[1] !== 0x0b || data[2] !== 0x00) return;
239 const pressed = new Set<Keypad.Key>();
240 for (let i = 3; i + 1 < data.length; i += 2) {
241 const value = data.readUInt16BE(i);
242 if (value === 0) break;
243 const key = NAME_BY_HID.get(value);
244 if (key) pressed.add(key);
245 }
246 this.#applyPressed(pressed, this.#pageDown);
247 }
248
249 #applyPressed(pressed: Set<Keypad.Key>, downSet: Set<Keypad.Key>) {
250 for (const key of downSet) {
251 if (!pressed.has(key)) {
252 downSet.delete(key);
253 this.emit("keyup", key);
254 }
255 }
256 for (const key of pressed) {
257 if (!downSet.has(key)) {
258 downSet.add(key);
259 this.emit("keydown", key);
260 this.emit("keypress", key);
261 }
262 }
263 }
264
265 #writeImage(key: Keypad.Key, image: Uint8Array): boolean {
266 const index = INDEX_BY_NAME.get(key);
267 if (index === undefined || index >= LCD_KEYS.length) return false;
268 const position = KEY_POSITION[index];
269 return this.#writeRegion(position.x, position.y, KEY_SIZE, KEY_SIZE, image);
270 }
271
272 #writeRegion(
273 x: number,
274 y: number,
275 width: number,
276 height: number,
277 image: Uint8Array,
278 ): boolean {
279 if (!this.#device) return false;
280 try {
281 for (const packet of packetizeImage(x, y, width, height, image)) {
282 this.#device.write(packet);
283 }
284 return true;
285 } catch {
286 return false;
287 }
288 }
289
290 #reapplyImages() {
291 this.#shown.clear();
292 this.#shownPanel = null;
293 if (this.#panel) {
294 if (this.#writeRegion(0, 0, PANEL_SIZE, PANEL_SIZE, this.#panel)) {
295 this.#shownPanel = this.#panel;
296 }
297 return;
298 }
299 for (const [key, image] of this.#images) {
300 if (this.#writeImage(key, image)) this.#shown.set(key, image);
301 }
302 }
303
304 #write(buffer: Buffer) {
305 try {
306 this.#device?.write(buffer);
307 } catch {
308 // Ignore if the device vanished.
309 }
310 }
311
312 #handleDeviceError() {
313 this.#disconnect(true);
314 }
315
316 #disconnect(emitEvent: boolean) {
317 const device = this.#device;
318 this.#device = null;
319 this.#ready = false;
320 this.#shown.clear();
321 this.#shownPanel = null;
322 this.#gridDown.clear();
323 this.#pageDown.clear();
324 if (device) {
325 device.removeAllListeners("data");
326 device.removeAllListeners("error");
327 try {
328 device.close();
329 } catch {
330 // Ignore close races when the device disappears mid-reconnect.
331 }
332 }
333 if (emitEvent) this.emit("disconnect");
334 }
335}
336
337/** Split a JPEG into the keypad's positioned image-write packets. */
338function packetizeImage(
339 x: number,
340 y: number,
341 width: number,
342 height: number,
343 jpeg: Uint8Array,
344): Buffer[] {
345 const packets: Buffer[] = [];
346 const total = jpeg.length;
347
348 const first = Buffer.alloc(MAX_PACKET_SIZE);
349 const firstBytes = Math.min(total, MAX_PACKET_SIZE - PACKET1_HEADER);
350 first.set([IMAGE_REPORT_ID, 0xff, 0x02, 0x2b]);
351 first[4] = packetByte(1, true, firstBytes >= total);
352 first.writeUInt16BE(0x0100, 5);
353 first.writeUInt16BE(0x0100, 7);
354 first.writeUInt16BE(x, 9);
355 first.writeUInt16BE(y, 11);
356 first.writeUInt16BE(width, 13);
357 first.writeUInt16BE(height, 15);
358 first.writeUInt16BE(total, 18);
359 first.set(jpeg.subarray(0, firstBytes), PACKET1_HEADER);
360 packets.push(first);
361
362 let remaining = total - firstBytes;
363 let part = 2;
364 while (remaining > 0) {
365 const packet = Buffer.alloc(MAX_PACKET_SIZE);
366 const bytes = Math.min(remaining, MAX_PACKET_SIZE - PACKETN_HEADER);
367 const offset = total - remaining;
368 packet.set([IMAGE_REPORT_ID, 0xff, 0x02, 0x2b]);
369 packet[4] = packetByte(part, false, remaining - bytes === 0);
370 packet.set(jpeg.subarray(offset, offset + bytes), PACKETN_HEADER);
371 packets.push(packet);
372 remaining -= bytes;
373 part += 1;
374 }
375 return packets;
376}
377
378function packetByte(index: number, isFirst: boolean, isLast: boolean): number {
379 let value = index | 0b0010_0000;
380 if (isFirst) value |= 0b1000_0000;
381 if (isLast) value |= 0b0100_0000;
382 return value;
383}
384
385export declare namespace Keypad {
386 export type Key = typeof NAME_BY_INDEX[number];
387
388 export type EventMap = {
389 "connect": [];
390 "disconnect": [];
391 "close": [];
392 "error": [error: unknown];
393 "keydown": [key: Key];
394 "keyup": [key: Key];
395 "keypress": [key: Key];
396 };
397}
src/KeypadUI.ts
Binary files a/src/KeypadUI.ts and /dev/null differ
src/Mac.ts deleted-1107
...@@ -1,1107 +0,0 @@
1import { Events } from "@clo/lib/Events.ts";
2import type { ChildProcessWithoutNullStreams } from "node:child_process";
3import { execFile, spawn } from "node:child_process";
4import { mkdir, stat } from "node:fs/promises";
5import { tmpdir } from "node:os";
6import { dirname, join } from "node:path";
7import { fileURLToPath } from "node:url";
8import { promisify } from "node:util";
9
10const execFileAsync = promisify(execFile);
11
12const APP_MONITOR_START_TIMEOUT_MS = 1000;
13const BUNDLE_ID_PATTERN = /\b[A-Za-z0-9-]+(?:\.[A-Za-z0-9-]+)+\b/;
14const MODULE_DIR = dirname(fileURLToPath(import.meta.url));
15const FRONTMOST_APP_HELPER_SOURCE_PATH = join(
16 MODULE_DIR,
17 "Mac/frontmost_app_helper.m",
18);
19const HELPER_BUILD_DIR = join(tmpdir(), "meow");
20const FRONTMOST_APP_HELPER_BINARY_PATH = join(
21 HELPER_BUILD_DIR,
22 "mac-frontmost-app-helper",
23);
24const TOAST_HELPER_SOURCE_PATH = join(MODULE_DIR, "Mac/toast_helper.m");
25const TOAST_HELPER_BINARY_PATH = join(HELPER_BUILD_DIR, "mac-toast-helper");
26const FRONTMOST_HELPER_READ_ONCE_FLAG = "--once";
27const FRONTMOST_HELPER_FOCUS_WINDOW_FLAG = "--focus-window";
28const FRONTMOST_HELPER_FOCUS_MAIN_WINDOW_FLAG = "--focus-main-window";
29const DEFAULT_OBJECTIVE_C_FRAMEWORKS = ["AppKit", "Foundation"] as const;
30const FRONTMOST_APP_HELPER_FRAMEWORKS = [
31 ...DEFAULT_OBJECTIVE_C_FRAMEWORKS,
32 "ApplicationServices",
33] as const;
34const KEYBOARD_EVENT_SCRIPT = [
35 "ObjC.import(\"ApplicationServices\");",
36 "function run(argv) {",
37 " const payload = JSON.parse(argv[0] ?? '{}');",
38 " const actions = Array.isArray(payload.actions) ? payload.actions : [];",
39 " for (const action of actions) {",
40 " if (!Number.isInteger(action.keyCode)) {",
41 " throw new Error(`Invalid key code: ${JSON.stringify(action)}`);",
42 " }",
43 " const event = $.CGEventCreateKeyboardEvent(",
44 " null,",
45 " action.keyCode,",
46 " Boolean(action.isDown),",
47 " );",
48 " if (!event) {",
49 " throw new Error(`Failed to create keyboard event for key code ${action.keyCode}`);",
50 " }",
51 " $.CGEventPost($.kCGHIDEventTap, event);",
52 " $.CFRelease(event);",
53 " }",
54 " return \"\";",
55 "}",
56].join("\n");
57let frontmostAppHelperBinaryPromise: Promise<string> | null = null;
58let toastHelperBinaryPromise: Promise<string> | null = null;
59
60// Key codes from Carbon/HIToolbox Events.h (virtual key codes on ANSI/US layouts).
61const KEY_CODES = Object.freeze(
62 {
63 a: 0x00,
64 s: 0x01,
65 d: 0x02,
66 f: 0x03,
67 h: 0x04,
68 g: 0x05,
69 z: 0x06,
70 x: 0x07,
71 c: 0x08,
72 v: 0x09,
73 isoSection: 0x0A,
74 b: 0x0B,
75 q: 0x0C,
76 w: 0x0D,
77 e: 0x0E,
78 r: 0x0F,
79 y: 0x10,
80 t: 0x11,
81 "1": 0x12,
82 "2": 0x13,
83 "3": 0x14,
84 "4": 0x15,
85 "6": 0x16,
86 "5": 0x17,
87 equal: 0x18,
88 "9": 0x19,
89 "7": 0x1A,
90 minus: 0x1B,
91 "8": 0x1C,
92 "0": 0x1D,
93 rightBracket: 0x1E,
94 o: 0x1F,
95 u: 0x20,
96 leftBracket: 0x21,
97 i: 0x22,
98 p: 0x23,
99 return: 0x24,
100 l: 0x25,
101 j: 0x26,
102 quote: 0x27,
103 k: 0x28,
104 semicolon: 0x29,
105 backslash: 0x2A,
106 comma: 0x2B,
107 slash: 0x2C,
108 n: 0x2D,
109 m: 0x2E,
110 period: 0x2F,
111 tab: 0x30,
112 space: 0x31,
113 grave: 0x32,
114 delete: 0x33,
115 escape: 0x35,
116 rightCommand: 0x36,
117 command: 0x37,
118 shift: 0x38,
119 capsLock: 0x39,
120 option: 0x3A,
121 control: 0x3B,
122 rightShift: 0x3C,
123 rightOption: 0x3D,
124 rightControl: 0x3E,
125 function: 0x3F,
126 f17: 0x40,
127 numpadDecimal: 0x41,
128 numpadMultiply: 0x43,
129 numpadPlus: 0x45,
130 numpadClear: 0x47,
131 volumeUp: 0x48,
132 volumeDown: 0x49,
133 mute: 0x4A,
134 numpadDivide: 0x4B,
135 numpadEnter: 0x4C,
136 numpadMinus: 0x4E,
137 f18: 0x4F,
138 f19: 0x50,
139 numpadEquals: 0x51,
140 numpad0: 0x52,
141 numpad1: 0x53,
142 numpad2: 0x54,
143 numpad3: 0x55,
144 numpad4: 0x56,
145 numpad5: 0x57,
146 numpad6: 0x58,
147 numpad7: 0x59,
148 f20: 0x5A,
149 numpad8: 0x5B,
150 numpad9: 0x5C,
151 jisYen: 0x5D,
152 jisUnderscore: 0x5E,
153 jisKeypadComma: 0x5F,
154 f5: 0x60,
155 f6: 0x61,
156 f7: 0x62,
157 f3: 0x63,
158 f8: 0x64,
159 f9: 0x65,
160 jisEisu: 0x66,
161 f11: 0x67,
162 jisKana: 0x68,
163 f13: 0x69,
164 f16: 0x6A,
165 f14: 0x6B,
166 f10: 0x6D,
167 f12: 0x6F,
168 f15: 0x71,
169 help: 0x72,
170 home: 0x73,
171 pageUp: 0x74,
172 forwardDelete: 0x75,
173 f4: 0x76,
174 end: 0x77,
175 f2: 0x78,
176 pageDown: 0x79,
177 f1: 0x7A,
178 leftArrow: 0x7B,
179 rightArrow: 0x7C,
180 downArrow: 0x7D,
181 upArrow: 0x7E,
182 } as const,
183);
184type MacKeyName = keyof typeof KEY_CODES;
185
186const KEY_NAMES = Object.freeze(Object.keys(KEY_CODES) as MacKeyName[]);
187const KEY_CODE_LOOKUP = createKeyCodeLookup(KEY_CODES, {
188 backslash: ["\\"],
189 capsLock: ["caps"],
190 comma: [","],
191 command: ["cmd", "leftCommand", "leftCmd", "meta", "super"],
192 control: ["ctrl", "leftControl", "leftCtrl"],
193 delete: ["backspace"],
194 downArrow: ["down", "arrowDown"],
195 equal: ["="],
196 escape: ["esc"],
197 forwardDelete: ["deleteForward", "forwardDel"],
198 function: ["fn"],
199 grave: ["`", "backtick"],
200 leftArrow: ["left", "arrowLeft"],
201 leftBracket: ["[", "openBracket"],
202 minus: ["-"],
203 numpadClear: ["keypadClear"],
204 numpadDecimal: [
205 "keypadDecimal",
206 "numpadDot",
207 "keypadDot",
208 "numpadPeriod",
209 "keypadPeriod",
210 ],
211 numpadDivide: ["keypadDivide", "keypadSlash"],
212 numpadEnter: ["keypadEnter", "keypadReturn"],
213 numpadEquals: ["keypadEquals"],
214 numpadMinus: ["keypadMinus"],
215 numpadMultiply: ["keypadMultiply", "keypadAsterisk"],
216 numpadPlus: ["keypadPlus"],
217 option: ["alt", "opt", "leftOption", "leftAlt"],
218 pageDown: ["pgdn"],
219 pageUp: ["pgup"],
220 period: ["."],
221 quote: ["'", "apostrophe"],
222 return: ["enter", "mainEnter"],
223 rightArrow: ["right", "arrowRight"],
224 rightBracket: ["]", "closeBracket"],
225 rightCommand: ["rightCmd"],
226 rightControl: ["rightCtrl"],
227 rightOption: ["rightAlt"],
228 rightShift: ["rightShift"],
229 semicolon: [";"],
230 shift: ["leftShift"],
231 slash: ["/"],
232 space: ["spacebar"],
233 upArrow: ["up", "arrowUp"],
234});
235
236type KeyboardAction = {
237 keyCode: number;
238 isDown: boolean;
239};
240
241type FrontmostState = {
242 bundleId: string | null;
243 windows: readonly Mac.Window[];
244};
245
246const EMPTY_WINDOWS: readonly Mac.Window[] = Object.freeze([]);
247
248export class Mac extends Events<Mac.EventMap> {
249 static readonly keyCodes = KEY_CODES;
250 static readonly keyNames = KEY_NAMES;
251
252 #closed = false;
253 #started = false;
254 #appMonitor: ChildProcessWithoutNullStreams | null = null;
255 #appMonitorBuffer = "";
256 #appMonitorStartup: Promise<void> | null = null;
257 #currentApp: string | null = null;
258 #windows: readonly Mac.Window[] = EMPTY_WINDOWS;
259 #keyboardQueue: Promise<void> = Promise.resolve();
260
261 private constructor(_options: Mac.Options = {}) {
262 super();
263 }
264
265 static async open(options: Mac.Options = {}) {
266 const mac = new Mac(options);
267 await mac.start();
268 return mac;
269 }
270
271 static resolveKeyCode(key: Mac.Key) {
272 return resolveKeyCode(key);
273 }
274
275 get currentApp(): string | null {
276 return this.#currentApp;
277 }
278
279 get windows(): readonly Mac.Window[] {
280 return this.#windows;
281 }
282
283 get window(): Mac.Window | null {
284 return getFocusedWindow(this.#windows);
285 }
286
287 get mainWindow(): Mac.Window | null {
288 return getMainWindow(this.#windows);
289 }
290
291 async start() {
292 if (this.#closed) {
293 throw new Error("Cannot start a closed Mac instance");
294 }
295 if (this.#started) {
296 return this.#currentApp;
297 }
298
299 this.#started = true;
300 try {
301 await this.#startAppMonitor();
302 } catch (error) {
303 this.#started = false;
304 throw error;
305 }
306
307 return this.#currentApp;
308 }
309
310 close() {
311 if (this.#closed) {
312 return;
313 }
314
315 this.#closed = true;
316 this.#started = false;
317 this.#stopAppMonitor();
318
319 this.emit("close");
320 }
321
322 async focusApp(bundleId: string) {
323 this.#assertOpen("Cannot focus an app from a closed Mac instance");
324
325 await execFileAsync("/usr/bin/open", ["-b", bundleId]);
326 await this.#syncFrontmostState();
327 }
328
329 async focusWindow(window: Mac.Window | number) {
330 this.#assertOpen("Cannot focus a window from a closed Mac instance");
331
332 const helperPath = await ensureFrontmostAppHelperBinary();
333 const windowId = resolveWindowId(window);
334 try {
335 await execFileAsync(helperPath, [
336 FRONTMOST_HELPER_FOCUS_WINDOW_FLAG,
337 String(windowId),
338 ]);
339 } catch (error) {
340 throw new Error(formatWindowFocusError(error, `window ${windowId}`));
341 }
342
343 await this.#syncFrontmostState();
344 }
345
346 async focusMainWindow() {
347 this.#assertOpen("Cannot focus the main window from a closed Mac instance");
348
349 const helperPath = await ensureFrontmostAppHelperBinary();
350 try {
351 await execFileAsync(helperPath, [
352 FRONTMOST_HELPER_FOCUS_MAIN_WINDOW_FLAG,
353 ]);
354 } catch (error) {
355 throw new Error(formatWindowFocusError(error, "the main window"));
356 }
357
358 await this.#syncFrontmostState();
359 }
360
361 async pressKey(key: Mac.Key, options: Mac.KeyPressOptions = {}) {
362 const keyCode = resolveKeyCode(key);
363 const modifierCodes = uniqueKeyCodes(options.modifiers ?? []);
364 const holdMs = normalizeDelayMs(options.holdMs ?? 0, "holdMs");
365 const keyDownActions = [
366 ...modifierCodes.map((modifierKeyCode) => ({
367 keyCode: modifierKeyCode,
368 isDown: true,
369 })),
370 { keyCode, isDown: true },
371 ];
372 const keyUpActions = [
373 { keyCode, isDown: false },
374 ...modifierCodes
375 .slice()
376 .reverse()
377 .map((modifierKeyCode) => ({
378 keyCode: modifierKeyCode,
379 isDown: false,
380 })),
381 ];
382
383 return this.#enqueueKeyboardOperation(async () => {
384 this.#assertOpen("Cannot send key events from a closed Mac instance");
385 await this.#runKeyboardActions(keyDownActions);
386 try {
387 if (holdMs > 0) {
388 await sleep(holdMs);
389 }
390 } finally {
391 await this.#runKeyboardActions(keyUpActions);
392 }
393 });
394 }
395
396 async keyDown(key: Mac.Key) {
397 const keyCode = resolveKeyCode(key);
398 return this.#enqueueKeyboardOperation(async () => {
399 this.#assertOpen("Cannot send key events from a closed Mac instance");
400 await this.#runKeyboardActions([{ keyCode, isDown: true }]);
401 });
402 }
403
404 async keyUp(key: Mac.Key) {
405 const keyCode = resolveKeyCode(key);
406 return this.#enqueueKeyboardOperation(async () => {
407 this.#assertOpen("Cannot send key events from a closed Mac instance");
408 await this.#runKeyboardActions([{ keyCode, isDown: false }]);
409 });
410 }
411
412 toast(message: string, options: Mac.ToastOptions = {}) {
413 this.#assertOpen("Cannot show a toast from a closed Mac instance");
414 void dispatchToast(message, options).catch((error) => {
415 queueMicrotask(() => {
416 this.emit("error", error);
417 });
418 });
419 }
420
421 async #startAppMonitor() {
422 if (this.#appMonitor) {
423 return;
424 }
425 if (this.#appMonitorStartup) {
426 return this.#appMonitorStartup;
427 }
428
429 this.#appMonitorStartup = this.#spawnAppMonitor().finally(() => {
430 this.#appMonitorStartup = null;
431 });
432
433 return this.#appMonitorStartup;
434 }
435
436 #stopAppMonitor() {
437 const appMonitor = this.#appMonitor;
438 this.#appMonitor = null;
439 this.#appMonitorBuffer = "";
440 if (!appMonitor) {
441 return;
442 }
443
444 appMonitor.removeAllListeners();
445 appMonitor.stdout.removeAllListeners();
446 appMonitor.stderr.removeAllListeners();
447 appMonitor.kill();
448 }
449
450 async #spawnAppMonitor() {
451 const helperPath = await ensureFrontmostAppHelperBinary();
452
453 await new Promise<void>((resolve, reject) => {
454 let stderr = "";
455 let startupFinished = false;
456 let startupSucceeded = false;
457
458 const finishStartup = (
459 result: { ok: true } | { ok: false; error: Error },
460 ) => {
461 if (startupFinished) {
462 return false;
463 }
464 startupFinished = true;
465 clearTimeout(startupTimeout);
466 if (result.ok) {
467 startupSucceeded = true;
468 resolve();
469 } else {
470 reject(result.error);
471 }
472 return true;
473 };
474
475 const appMonitor = spawn(helperPath, [], {
476 stdio: ["ignore", "pipe", "pipe"],
477 });
478
479 this.#appMonitor = appMonitor;
480 this.#appMonitorBuffer = "";
481 appMonitor.stdout.setEncoding("utf8");
482 appMonitor.stderr.setEncoding("utf8");
483
484 const startupTimeout = setTimeout(() => {
485 const error = new Error(
486 "Timed out waiting for the macOS app monitor to start.",
487 );
488 if (finishStartup({ ok: false, error })) {
489 this.#stopAppMonitor();
490 }
491 }, APP_MONITOR_START_TIMEOUT_MS);
492
493 appMonitor.stdout.on("data", (chunk: string) => {
494 const sawLine = this.#handleAppMonitorOutput(chunk);
495 if (sawLine) {
496 finishStartup({ ok: true });
497 }
498 });
499 appMonitor.stderr.on("data", (chunk: string) => {
500 stderr += chunk;
501 });
502 appMonitor.once("error", (error) => {
503 this.#appMonitorExited(appMonitor);
504 const monitorError = formatAppMonitorError(
505 "The macOS app monitor process failed.",
506 error,
507 );
508 if (
509 !finishStartup({ ok: false, error: monitorError }) && startupSucceeded
510 ) {
511 this.#emitMonitorError(monitorError);
512 }
513 });
514 appMonitor.once("exit", (code, signal) => {
515 this.#appMonitorExited(appMonitor);
516 const monitorError = formatAppMonitorError(
517 formatAppMonitorExitMessage(code, signal),
518 stderr,
519 );
520 if (
521 !finishStartup({ ok: false, error: monitorError }) && startupSucceeded
522 ) {
523 this.#emitMonitorError(monitorError);
524 }
525 });
526 });
527 }
528
529 #appMonitorExited(appMonitor: ChildProcessWithoutNullStreams) {
530 if (this.#appMonitor === appMonitor) {
531 this.#appMonitor = null;
532 }
533 this.#appMonitorBuffer = "";
534 }
535
536 #handleAppMonitorOutput(chunk: string) {
537 this.#appMonitorBuffer += chunk;
538 let sawLine = false;
539
540 while (true) {
541 const newlineIndex = this.#appMonitorBuffer.indexOf("\n");
542 if (newlineIndex === -1) {
543 return sawLine;
544 }
545
546 const line = this.#appMonitorBuffer
547 .slice(0, newlineIndex)
548 .replace(/\r$/, "");
549 this.#appMonitorBuffer = this.#appMonitorBuffer.slice(newlineIndex + 1);
550 sawLine = true;
551 this.#applyFrontmostState(parseFrontmostStateLine(line));
552 }
553 }
554
555 async #syncFrontmostState() {
556 try {
557 return this.#applyFrontmostState(await this.#readFrontmostState());
558 } catch (error) {
559 this.#emitMonitorError(error);
560 return { bundleId: this.#currentApp, windows: this.#windows };
561 }
562 }
563
564 #applyFrontmostState(state: FrontmostState) {
565 this.#setCurrentApp(state.bundleId);
566 this.#setWindows(state.windows);
567 return state;
568 }
569
570 #setCurrentApp(bundleId: string | null) {
571 if (this.#closed) {
572 return bundleId;
573 }
574 if (!bundleId) {
575 this.#currentApp = null;
576 return bundleId;
577 }
578 if (bundleId !== this.#currentApp) {
579 this.#currentApp = bundleId;
580 this.emit("app-change", bundleId);
581 }
582 return bundleId;
583 }
584
585 #setWindows(windows: readonly Mac.Window[]) {
586 if (this.#closed) {
587 return windows;
588 }
589 if (windowsEqual(this.#windows, windows)) {
590 return windows;
591 }
592
593 const previousWindow = getFocusedWindow(this.#windows);
594 this.#windows = windows;
595 this.emit("windows", windows);
596
597 const nextWindow = getFocusedWindow(windows);
598 if (!windowEquals(previousWindow, nextWindow)) {
599 this.emit("window", nextWindow);
600 }
601
602 return windows;
603 }
604
605 async #readFrontmostState() {
606 const helperPath = await ensureFrontmostAppHelperBinary();
607 const { stdout } = await execFileAsync(helperPath, [
608 FRONTMOST_HELPER_READ_ONCE_FLAG,
609 ]);
610 return parseFrontmostState(stdout);
611 }
612
613 async #enqueueKeyboardOperation<T>(operation: () => Promise<T>) {
614 const next = this.#keyboardQueue.then(operation, operation);
615 this.#keyboardQueue = next.then(
616 () => undefined,
617 () => undefined,
618 );
619 return next;
620 }
621
622 async #runKeyboardActions(actions: ReadonlyArray<KeyboardAction>) {
623 if (actions.length === 0) {
624 return;
625 }
626
627 try {
628 await execFileAsync("/usr/bin/osascript", [
629 "-l",
630 "JavaScript",
631 "-e",
632 KEYBOARD_EVENT_SCRIPT,
633 JSON.stringify({ actions }),
634 ]);
635 } catch (error) {
636 throw new Error(formatKeyboardDispatchError(error));
637 }
638 }
639
640 #assertOpen(message: string) {
641 if (this.#closed) {
642 throw new Error(message);
643 }
644 }
645
646 #emitMonitorError(error: unknown) {
647 queueMicrotask(() => {
648 this.emit("error", error);
649 });
650 }
651}
652
653export declare namespace Mac {
654 export interface Options {
655 /** Deprecated: app-change is now event-driven and no longer polls. */
656 pollIntervalMs?: number;
657 }
658
659 export interface KeyCode {
660 keyCode: number;
661 }
662
663 export type KeyName = MacKeyName;
664 export type Key = KeyName | number | KeyCode;
665
666 export interface KeyPressOptions {
667 holdMs?: number;
668 modifiers?: readonly Key[];
669 }
670
671 export interface ToastOptions {
672 detail?: string;
673 durationMs?: number;
674 }
675
676 export interface Window {
677 readonly id: number;
678 readonly title: string;
679 readonly main: boolean;
680 readonly focused: boolean;
681 }
682
683 export type EventMap = {
684 "app-change": [bundleId: string];
685 "close": [];
686 "error": [error: unknown];
687 "window": [window: Window | null];
688 "windows": [windows: readonly Window[]];
689 };
690
691 export type BundleId =
692 | (string & {})
693 | "com.apple.Chess"
694 | "com.apple.MobileSMS"
695 | "com.apple.Music"
696 | "com.apple.Notes"
697 | "com.apple.Preview"
698 | "com.apple.QuickTimePlayerX"
699 | "com.apple.Safari"
700 | "com.apple.finder"
701 | "com.apple.iCal"
702 | "com.blackmagic-design.fusion"
703 | "com.cockos.reaper"
704 | "com.google.Chrome"
705 | "com.google.Chrome"
706 | "com.mitchellh.ghostty"
707 | "org.mozilla.firefox"
708 | "org.whispersystems.signal-desktop";
709}
710
711function parseFrontmostState(stdout: string): FrontmostState {
712 const line = stdout.split(/\r?\n/u).find((candidate) => candidate.trim() !== "");
713 return parseFrontmostStateLine(line ?? "");
714}
715
716function parseFrontmostStateLine(line: string): FrontmostState {
717 const trimmed = line.trim();
718 if (trimmed === "") {
719 return { bundleId: null, windows: EMPTY_WINDOWS };
720 }
721
722 try {
723 const parsed = JSON.parse(trimmed);
724 if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) {
725 const record = parsed as { bundleId?: unknown; windows?: unknown };
726 return {
727 bundleId: normalizeBundleId(record.bundleId),
728 windows: normalizeWindows(record.windows),
729 };
730 }
731 } catch {
732 // Fall back to the older helper output if a stale binary is still running.
733 }
734
735 return {
736 bundleId: normalizeBundleId(trimmed),
737 windows: EMPTY_WINDOWS,
738 };
739}
740
741function normalizeBundleId(value: unknown) {
742 const directBundleId = typeof value === "string" ? value.trim() : "";
743 const bundleId = BUNDLE_ID_PATTERN.exec(directBundleId)?.[0]
744 ?? directBundleId;
745 if (
746 bundleId === ""
747 || bundleId === "undefined"
748 || bundleId === "[id nil]"
749 ) {
750 return null;
751 }
752 return bundleId;
753}
754
755function normalizeWindows(value: unknown): readonly Mac.Window[] {
756 if (!Array.isArray(value) || value.length === 0) {
757 return EMPTY_WINDOWS;
758 }
759
760 const windows: Mac.Window[] = [];
761 for (const candidate of value) {
762 const window = normalizeWindow(candidate);
763 if (window) {
764 windows.push(window);
765 }
766 }
767
768 return windows.length === 0 ? EMPTY_WINDOWS : Object.freeze(windows);
769}
770
771function normalizeWindow(value: unknown): Mac.Window | null {
772 if (!value || typeof value !== "object" || Array.isArray(value)) {
773 return null;
774 }
775
776 const record = value as {
777 id?: unknown;
778 title?: unknown;
779 main?: unknown;
780 focused?: unknown;
781 };
782 const id = normalizeWindowId(record.id);
783 if (id === null) {
784 return null;
785 }
786
787 return Object.freeze({
788 id,
789 title: typeof record.title === "string" ? record.title : "",
790 main: Boolean(record.main),
791 focused: Boolean(record.focused),
792 });
793}
794
795function normalizeWindowId(value: unknown) {
796 if (!Number.isSafeInteger(value)) {
797 return null;
798 }
799 return value;
800}
801
802function resolveWindowId(window: Mac.Window | number) {
803 const windowId = typeof window === "number" ? window : window?.id;
804 const normalizedId = normalizeWindowId(windowId);
805 if (normalizedId === null) {
806 throw new Error(
807 "Mac.focusWindow expects a window object returned by mac.windows or a numeric window id.",
808 );
809 }
810 return normalizedId;
811}
812
813function getFocusedWindow(windows: readonly Mac.Window[]) {
814 return windows.find((window) => window.focused) ?? null;
815}
816
817function getMainWindow(windows: readonly Mac.Window[]) {
818 return windows.find((window) => window.main) ?? null;
819}
820
821function windowsEqual(
822 left: readonly Mac.Window[],
823 right: readonly Mac.Window[],
824) {
825 if (left === right) {
826 return true;
827 }
828 if (left.length !== right.length) {
829 return false;
830 }
831
832 for (let index = 0; index < left.length; index += 1) {
833 if (!windowEquals(left[index], right[index])) {
834 return false;
835 }
836 }
837
838 return true;
839}
840
841function windowEquals(left: Mac.Window | null, right: Mac.Window | null) {
842 if (left === right) {
843 return true;
844 }
845 if (!left || !right) {
846 return left === right;
847 }
848 return left.id === right.id
849 && left.title === right.title
850 && left.main === right.main
851 && left.focused === right.focused;
852}
853
854function normalizeDelayMs(value: number, name: string) {
855 if (!Number.isFinite(value) || value < 0) {
856 throw new Error(`${name} must be a non-negative number`);
857 }
858 return Math.round(value);
859}
860
861function normalizeDurationMs(
862 value: number,
863 name: string,
864 min: number,
865 max: number,
866) {
867 const rounded = normalizeDelayMs(value, name);
868 return Math.min(max, Math.max(min, rounded));
869}
870
871function resolveKeyCode(key: Mac.Key): number {
872 if (typeof key === "number") {
873 return normalizeKeyCode(key);
874 }
875
876 if (typeof key === "string") {
877 const keyCode = KEY_CODE_LOOKUP.get(normalizeKeyName(key));
878 if (keyCode !== undefined) {
879 return keyCode;
880 }
881 throw new Error(
882 `Unknown Mac key "${key}". Use Mac.keyCodes for named keys or pass a numeric keyCode.`,
883 );
884 }
885
886 if (key && typeof key === "object" && "keyCode" in key) {
887 return normalizeKeyCode(key.keyCode);
888 }
889
890 throw new Error(`Unsupported Mac key: ${String(key)}`);
891}
892
893function normalizeKeyCode(keyCode: number) {
894 if (
895 !Number.isInteger(keyCode)
896 || keyCode < 0
897 || keyCode > 0xFFFF
898 ) {
899 throw new Error(`Mac keyCode must be an integer between 0 and 65535`);
900 }
901 return keyCode;
902}
903
904function uniqueKeyCodes(keys: readonly Mac.Key[]) {
905 const seen = new Set<number>();
906 const keyCodes: number[] = [];
907
908 for (const key of keys) {
909 const keyCode = resolveKeyCode(key);
910 if (seen.has(keyCode)) {
911 continue;
912 }
913 seen.add(keyCode);
914 keyCodes.push(keyCode);
915 }
916
917 return keyCodes;
918}
919
920function normalizeKeyName(key: string) {
921 return key.trim().toLowerCase().replace(/[\s_-]+/g, "");
922}
923
924function createKeyCodeLookup(
925 keyCodes: Record<string, number>,
926 aliases: Partial<Record<MacKeyName, readonly string[]>>,
927) {
928 const lookup = new Map<string, number>();
929
930 for (const [name, keyCode] of Object.entries(keyCodes)) {
931 lookup.set(normalizeKeyName(name), keyCode);
932 if (name.startsWith("numpad")) {
933 lookup.set(
934 normalizeKeyName(`keypad${name.slice("numpad".length)}`),
935 keyCode,
936 );
937 }
938 }
939
940 for (const [canonicalName, names] of Object.entries(aliases)) {
941 const keyCode = keyCodes[canonicalName];
942 if (keyCode === undefined) {
943 continue;
944 }
945 for (const name of names ?? []) {
946 lookup.set(normalizeKeyName(name), keyCode);
947 }
948 }
949
950 return lookup;
951}
952
953function formatKeyboardDispatchError(error: unknown) {
954 const details = extractCommandErrorOutput(error);
955 const suffix = details ? ` ${details}` : "";
956 return (
957 "Failed to send a macOS keyboard event via osascript. "
958 + "Make sure this process is allowed in System Settings > Privacy & Security > Accessibility."
959 + suffix
960 );
961}
962
963async function dispatchToast(message: string, options: Mac.ToastOptions) {
964 const payload = {
965 message: normalizeToastText(message, "message"),
966 detail: normalizeToastDetail(options.detail),
967 durationMs: normalizeDurationMs(
968 options.durationMs ?? 1000,
969 "toast durationMs",
970 250,
971 4000,
972 ),
973 };
974
975 try {
976 const helperPath = await ensureToastHelperBinary();
977 await execFileAsync(helperPath, [
978 payload.message,
979 payload.detail,
980 String(payload.durationMs / 1000),
981 ]);
982 } catch (error) {
983 throw new Error(formatToastDispatchError(error));
984 }
985}
986
987async function ensureToastHelperBinary() {
988 if (!toastHelperBinaryPromise) {
989 toastHelperBinaryPromise = buildObjectiveCHelperBinary(
990 TOAST_HELPER_SOURCE_PATH,
991 TOAST_HELPER_BINARY_PATH,
992 DEFAULT_OBJECTIVE_C_FRAMEWORKS,
993 ).catch((error) => {
994 toastHelperBinaryPromise = null;
995 throw error;
996 });
997 }
998
999 return toastHelperBinaryPromise;
1000}
1001
1002async function ensureFrontmostAppHelperBinary() {
1003 if (!frontmostAppHelperBinaryPromise) {
1004 frontmostAppHelperBinaryPromise = buildObjectiveCHelperBinary(
1005 FRONTMOST_APP_HELPER_SOURCE_PATH,
1006 FRONTMOST_APP_HELPER_BINARY_PATH,
1007 FRONTMOST_APP_HELPER_FRAMEWORKS,
1008 ).catch((error) => {
1009 frontmostAppHelperBinaryPromise = null;
1010 throw error;
1011 });
1012 }
1013
1014 return frontmostAppHelperBinaryPromise;
1015}
1016
1017async function buildObjectiveCHelperBinary(
1018 sourcePath: string,
1019 binaryPath: string,
1020 frameworks: readonly string[],
1021) {
1022 await mkdir(HELPER_BUILD_DIR, { recursive: true });
1023
1024 const [sourceStats, binaryStats] = await Promise.all([
1025 stat(sourcePath),
1026 stat(binaryPath).catch(() => null),
1027 ]);
1028
1029 if (!binaryStats || binaryStats.mtimeMs < sourceStats.mtimeMs) {
1030 const args = ["-fobjc-arc"];
1031 for (const framework of frameworks) {
1032 args.push("-framework", framework);
1033 }
1034 args.push(sourcePath, "-o", binaryPath);
1035 await execFileAsync("/usr/bin/clang", args);
1036 }
1037
1038 return binaryPath;
1039}
1040
1041function extractCommandErrorOutput(error: unknown) {
1042 if (!error || typeof error !== "object") {
1043 return "";
1044 }
1045
1046 const stdout = "stdout" in error && typeof error.stdout === "string"
1047 ? error.stdout.trim()
1048 : "";
1049 const stderr = "stderr" in error && typeof error.stderr === "string"
1050 ? error.stderr.trim()
1051 : "";
1052 const message = error instanceof Error ? error.message.trim() : "";
1053
1054 return [stderr, stdout, message].find((value) => value !== "") ?? "";
1055}
1056
1057function formatToastDispatchError(error: unknown) {
1058 const details = extractCommandErrorOutput(error);
1059 const suffix = details ? ` ${details}` : "";
1060 return `Failed to show a macOS toast.${suffix}`;
1061}
1062
1063function formatWindowFocusError(error: unknown, target: string) {
1064 const details = extractCommandErrorOutput(error);
1065 const suffix = details ? ` ${details}` : "";
1066 return `Failed to focus ${target}.${suffix}`;
1067}
1068
1069function formatAppMonitorExitMessage(
1070 code: number | null,
1071 signal: NodeJS.Signals | null,
1072) {
1073 if (signal) {
1074 return `The macOS app monitor stopped after receiving ${signal}.`;
1075 }
1076 if (code === null || code === 0) {
1077 return "The macOS app monitor stopped unexpectedly.";
1078 }
1079 return `The macOS app monitor exited with code ${code}.`;
1080}
1081
1082function formatAppMonitorError(summary: string, error: unknown) {
1083 const details = extractCommandErrorOutput(error);
1084 const suffix = details ? ` ${details}` : "";
1085 return new Error(`${summary}${suffix}`);
1086}
1087
1088function normalizeToastText(value: string, name: string) {
1089 const text = value.trim();
1090 if (text === "") {
1091 throw new Error(`Mac.toast ${name} must be a non-empty string`);
1092 }
1093 return text;
1094}
1095
1096function normalizeToastDetail(detail: string | undefined) {
1097 if (detail === undefined) {
1098 return "";
1099 }
1100 return detail.trim();
1101}
1102
1103function sleep(ms: number) {
1104 return new Promise<void>((resolve) => {
1105 setTimeout(resolve, ms);
1106 });
1107}
src/Mac/frontmost_app_helper.m deleted-562
...@@ -1,562 +0,0 @@
1#import <AppKit/AppKit.h>
2#import <ApplicationServices/ApplicationServices.h>
3#import <Foundation/Foundation.h>
4
5static CFStringRef const kCloverAXWindowNumberAttribute = CFSTR("AXWindowNumber");
6
7static NSArray<id> *CopyWindowElements(AXUIElementRef applicationElement) {
8 if (!applicationElement) {
9 return @[];
10 }
11
12 CFTypeRef value = NULL;
13 AXError error = AXUIElementCopyAttributeValue(
14 applicationElement,
15 kAXWindowsAttribute,
16 &value
17 );
18 if (error != kAXErrorSuccess || !value) {
19 if (value) {
20 CFRelease(value);
21 }
22 return @[];
23 }
24 if (CFGetTypeID(value) != CFArrayGetTypeID()) {
25 CFRelease(value);
26 return @[];
27 }
28
29 return CFBridgingRelease(value);
30}
31
32static NSString *CopyStringAttribute(AXUIElementRef element, CFStringRef attribute) {
33 if (!element) {
34 return nil;
35 }
36
37 CFTypeRef value = NULL;
38 AXError error = AXUIElementCopyAttributeValue(element, attribute, &value);
39 if (error != kAXErrorSuccess || !value) {
40 if (value) {
41 CFRelease(value);
42 }
43 return nil;
44 }
45 if (CFGetTypeID(value) != CFStringGetTypeID()) {
46 CFRelease(value);
47 return nil;
48 }
49
50 return CFBridgingRelease(value);
51}
52
53static NSNumber *CopyNumberAttribute(AXUIElementRef element, CFStringRef attribute) {
54 if (!element) {
55 return nil;
56 }
57
58 CFTypeRef value = NULL;
59 AXError error = AXUIElementCopyAttributeValue(element, attribute, &value);
60 if (error != kAXErrorSuccess || !value) {
61 if (value) {
62 CFRelease(value);
63 }
64 return nil;
65 }
66 if (CFGetTypeID(value) != CFNumberGetTypeID()) {
67 CFRelease(value);
68 return nil;
69 }
70
71 return CFBridgingRelease(value);
72}
73
74static BOOL CopyBoolAttribute(
75 AXUIElementRef element,
76 CFStringRef attribute,
77 BOOL fallback
78) {
79 if (!element) {
80 return fallback;
81 }
82
83 CFTypeRef value = NULL;
84 AXError error = AXUIElementCopyAttributeValue(element, attribute, &value);
85 if (error != kAXErrorSuccess || !value) {
86 if (value) {
87 CFRelease(value);
88 }
89 return fallback;
90 }
91
92 BOOL result = fallback;
93 CFTypeID typeId = CFGetTypeID(value);
94 if (typeId == CFBooleanGetTypeID()) {
95 result = CFBooleanGetValue((CFBooleanRef)value);
96 } else if (typeId == CFNumberGetTypeID()) {
97 int numericValue = 0;
98 if (CFNumberGetValue((CFNumberRef)value, kCFNumberIntType, &numericValue)) {
99 result = numericValue != 0;
100 }
101 }
102
103 CFRelease(value);
104 return result;
105}
106
107static NSNumber *WindowIdentifierForElement(AXUIElementRef windowElement, NSInteger index) {
108 NSNumber *windowNumber = CopyNumberAttribute(windowElement, kCloverAXWindowNumberAttribute);
109 return windowNumber ?: @(-(index + 1));
110}
111
112static NSDictionary<NSString *, id> *SnapshotWindow(
113 AXUIElementRef windowElement,
114 NSInteger index
115) {
116 if (!windowElement) {
117 return nil;
118 }
119
120 return @{
121 @"id": WindowIdentifierForElement(windowElement, index),
122 @"title": CopyStringAttribute(windowElement, kAXTitleAttribute) ?: @"",
123 @"main": @(CopyBoolAttribute(windowElement, kAXMainAttribute, NO)),
124 @"focused": @(CopyBoolAttribute(windowElement, kAXFocusedAttribute, NO)),
125 };
126}
127
128static NSArray<NSDictionary<NSString *, id> *> *SnapshotWindowsForApplicationElement(
129 AXUIElementRef applicationElement
130) {
131 NSArray<id> *windowElements = CopyWindowElements(applicationElement);
132 NSMutableArray<NSDictionary<NSString *, id> *> *snapshots = [NSMutableArray arrayWithCapacity:windowElements.count];
133
134 for (NSUInteger index = 0; index < windowElements.count; index++) {
135 AXUIElementRef windowElement = (__bridge AXUIElementRef)windowElements[index];
136 NSDictionary<NSString *, id> *snapshot = SnapshotWindow(windowElement, index);
137 if (snapshot) {
138 [snapshots addObject:snapshot];
139 }
140 }
141
142 return snapshots;
143}
144
145static void PrintState(
146 NSRunningApplication *application,
147 NSArray<NSDictionary<NSString *, id> *> *windows
148) {
149 NSDictionary<NSString *, id> *payload = @{
150 @"bundleId": application.bundleIdentifier ?: [NSNull null],
151 @"windows": windows ?: @[],
152 };
153
154 NSError *error = nil;
155 NSData *json = [NSJSONSerialization dataWithJSONObject:payload options:0 error:&error];
156 if (!json || error) {
157 const char *message = error.localizedDescription.UTF8String ?: "Failed to encode state";
158 fprintf(stderr, "%s\n", message);
159 return;
160 }
161
162 fwrite(json.bytes, 1, json.length, stdout);
163 fputc('\n', stdout);
164 fflush(stdout);
165}
166
167static void PrintCurrentState(void) {
168 NSRunningApplication *application = NSWorkspace.sharedWorkspace.frontmostApplication;
169 id applicationElement = application
170 ? CFBridgingRelease(AXUIElementCreateApplication(application.processIdentifier))
171 : nil;
172 PrintState(
173 application,
174 SnapshotWindowsForApplicationElement((__bridge AXUIElementRef)applicationElement)
175 );
176}
177
178static AXUIElementRef CopyWindowElementForIdentifier(
179 AXUIElementRef applicationElement,
180 long long targetIdentifier
181) {
182 NSArray<id> *windowElements = CopyWindowElements(applicationElement);
183 for (NSUInteger index = 0; index < windowElements.count; index++) {
184 AXUIElementRef windowElement = (__bridge AXUIElementRef)windowElements[index];
185 if (WindowIdentifierForElement(windowElement, index).longLongValue == targetIdentifier) {
186 return (AXUIElementRef)CFRetain(windowElement);
187 }
188 }
189 return NULL;
190}
191
192static AXUIElementRef CopyMainWindowElement(AXUIElementRef applicationElement) {
193 if (!applicationElement) {
194 return NULL;
195 }
196
197 CFTypeRef value = NULL;
198 AXError error = AXUIElementCopyAttributeValue(
199 applicationElement,
200 kAXMainWindowAttribute,
201 &value
202 );
203 if (error == kAXErrorSuccess && value) {
204 if (CFGetTypeID(value) == AXUIElementGetTypeID()) {
205 return (AXUIElementRef)value;
206 }
207 CFRelease(value);
208 }
209
210 NSArray<id> *windowElements = CopyWindowElements(applicationElement);
211 for (NSUInteger index = 0; index < windowElements.count; index++) {
212 AXUIElementRef windowElement = (__bridge AXUIElementRef)windowElements[index];
213 if (CopyBoolAttribute(windowElement, kAXMainAttribute, NO)) {
214 return (AXUIElementRef)CFRetain(windowElement);
215 }
216 }
217
218 return NULL;
219}
220
221static BOOL FocusWindowElement(
222 NSRunningApplication *application,
223 AXUIElementRef windowElement,
224 NSString **failure
225) {
226 if (!application || !windowElement) {
227 if (failure) {
228 *failure = @"No window is available to focus.";
229 }
230 return NO;
231 }
232
233 [application activateWithOptions:NSApplicationActivateAllWindows];
234
235 AXError unminimizeError = AXUIElementSetAttributeValue(
236 windowElement,
237 kAXMinimizedAttribute,
238 kCFBooleanFalse
239 );
240 AXError raiseError = AXUIElementPerformAction(windowElement, kAXRaiseAction);
241 AXError mainError = AXUIElementSetAttributeValue(
242 windowElement,
243 kAXMainAttribute,
244 kCFBooleanTrue
245 );
246 AXError focusedError = AXUIElementSetAttributeValue(
247 windowElement,
248 kAXFocusedAttribute,
249 kCFBooleanTrue
250 );
251
252 BOOL succeeded =
253 unminimizeError == kAXErrorSuccess ||
254 raiseError == kAXErrorSuccess ||
255 mainError == kAXErrorSuccess ||
256 focusedError == kAXErrorSuccess;
257 if (succeeded) {
258 return YES;
259 }
260
261 if (failure) {
262 *failure = [NSString stringWithFormat:
263 @"Could not focus the requested window (unminimize=%d raise=%d main=%d focused=%d).",
264 (int)unminimizeError,
265 (int)raiseError,
266 (int)mainError,
267 (int)focusedError
268 ];
269 }
270 return NO;
271}
272
273static BOOL FocusWindowWithIdentifier(long long targetIdentifier) {
274 if (!AXIsProcessTrusted()) {
275 fprintf(stderr, "%s\n", "Accessibility access is required to focus windows.");
276 return NO;
277 }
278
279 NSRunningApplication *application = NSWorkspace.sharedWorkspace.frontmostApplication;
280 if (!application) {
281 fprintf(stderr, "%s\n", "No frontmost application is available.");
282 return NO;
283 }
284
285 id applicationElement = CFBridgingRelease(AXUIElementCreateApplication(application.processIdentifier));
286 AXUIElementRef windowElement = CopyWindowElementForIdentifier(
287 (__bridge AXUIElementRef)applicationElement,
288 targetIdentifier
289 );
290 if (!windowElement) {
291 fprintf(stderr, "Window %lld was not found.\n", targetIdentifier);
292 return NO;
293 }
294
295 NSString *failure = nil;
296 BOOL focused = FocusWindowElement(application, windowElement, &failure);
297 CFRelease(windowElement);
298 if (!focused) {
299 fprintf(stderr, "%s\n", failure.UTF8String ?: "Failed to focus the requested window.");
300 }
301 return focused;
302}
303
304static BOOL FocusMainWindow(void) {
305 if (!AXIsProcessTrusted()) {
306 fprintf(stderr, "%s\n", "Accessibility access is required to focus windows.");
307 return NO;
308 }
309
310 NSRunningApplication *application = NSWorkspace.sharedWorkspace.frontmostApplication;
311 if (!application) {
312 fprintf(stderr, "%s\n", "No frontmost application is available.");
313 return NO;
314 }
315
316 id applicationElement = CFBridgingRelease(AXUIElementCreateApplication(application.processIdentifier));
317 AXUIElementRef windowElement = CopyMainWindowElement((__bridge AXUIElementRef)applicationElement);
318 if (!windowElement) {
319 fprintf(stderr, "%s\n", "The frontmost application does not report a main window.");
320 return NO;
321 }
322
323 NSString *failure = nil;
324 BOOL focused = FocusWindowElement(application, windowElement, &failure);
325 CFRelease(windowElement);
326 if (!focused) {
327 fprintf(stderr, "%s\n", failure.UTF8String ?: "Failed to focus the main window.");
328 }
329 return focused;
330}
331
332@interface FrontmostAppObserver : NSObject {
333 @private
334 id _accessibilityObserver;
335 id _applicationElement;
336 NSMutableArray<id> *_windowElements;
337}
338- (void)start;
339- (void)handleActivation:(NSNotification *)notification;
340- (void)handleAccessibilityNotification:(NSString *)notification;
341@end
342
343static void FrontmostAccessibilityCallback(
344 AXObserverRef observer,
345 AXUIElementRef element,
346 CFStringRef notification,
347 void *context
348) {
349 @autoreleasepool {
350 FrontmostAppObserver *frontmostObserver = (__bridge FrontmostAppObserver *)context;
351 [frontmostObserver handleAccessibilityNotification:(__bridge NSString *)notification];
352 }
353}
354
355@implementation FrontmostAppObserver
356
357- (instancetype)init {
358 self = [super init];
359 if (self) {
360 _windowElements = [NSMutableArray array];
361 }
362 return self;
363}
364
365- (void)dealloc {
366 [NSWorkspace.sharedWorkspace.notificationCenter removeObserver:self];
367 [self clearObservedApplication];
368}
369
370- (AXObserverRef)observerRef {
371 return (__bridge AXObserverRef)_accessibilityObserver;
372}
373
374- (AXUIElementRef)applicationElementRef {
375 return (__bridge AXUIElementRef)_applicationElement;
376}
377
378- (void)start {
379 NSWorkspace *workspace = NSWorkspace.sharedWorkspace;
380 [workspace.notificationCenter addObserver:self
381 selector:@selector(handleActivation:)
382 name:NSWorkspaceDidActivateApplicationNotification
383 object:nil];
384 [self observeApplication:workspace.frontmostApplication];
385}
386
387- (void)handleActivation:(NSNotification *)notification {
388 NSRunningApplication *application = notification.userInfo[NSWorkspaceApplicationKey];
389 [self observeApplication:application];
390}
391
392- (void)handleAccessibilityNotification:(NSString *)notification {
393 (void)notification;
394 [self refreshWindowsAndEmit];
395}
396
397- (void)observeApplication:(NSRunningApplication *)application {
398 [self clearObservedApplication];
399 if (!application) {
400 PrintState(nil, @[]);
401 return;
402 }
403
404 _applicationElement = CFBridgingRelease(AXUIElementCreateApplication(application.processIdentifier));
405
406 AXObserverRef observer = NULL;
407 AXError observerError = AXObserverCreate(
408 application.processIdentifier,
409 FrontmostAccessibilityCallback,
410 &observer
411 );
412 if (observerError == kAXErrorSuccess && observer) {
413 _accessibilityObserver = CFBridgingRelease(observer);
414 CFRunLoopAddSource(
415 CFRunLoopGetCurrent(),
416 AXObserverGetRunLoopSource(self.observerRef),
417 kCFRunLoopDefaultMode
418 );
419
420 [self addApplicationNotification:kAXFocusedWindowChangedNotification];
421 [self addApplicationNotification:kAXMainWindowChangedNotification];
422 [self addApplicationNotification:kAXWindowCreatedNotification];
423 }
424
425 [self refreshWindowsAndEmit];
426}
427
428- (void)clearObservedApplication {
429 [self clearWindowNotifications];
430
431 AXObserverRef observer = self.observerRef;
432 AXUIElementRef applicationElement = self.applicationElementRef;
433 if (observer && applicationElement) {
434 AXObserverRemoveNotification(observer, applicationElement, kAXFocusedWindowChangedNotification);
435 AXObserverRemoveNotification(observer, applicationElement, kAXMainWindowChangedNotification);
436 AXObserverRemoveNotification(observer, applicationElement, kAXWindowCreatedNotification);
437 }
438 if (observer) {
439 CFRunLoopRemoveSource(
440 CFRunLoopGetCurrent(),
441 AXObserverGetRunLoopSource(observer),
442 kCFRunLoopDefaultMode
443 );
444 }
445
446 _accessibilityObserver = nil;
447 _applicationElement = nil;
448}
449
450- (void)clearWindowNotifications {
451 AXObserverRef observer = self.observerRef;
452 if (observer) {
453 for (id windowObject in _windowElements) {
454 AXUIElementRef windowElement = (__bridge AXUIElementRef)windowObject;
455 AXObserverRemoveNotification(observer, windowElement, kAXTitleChangedNotification);
456 AXObserverRemoveNotification(observer, windowElement, kAXUIElementDestroyedNotification);
457 }
458 }
459
460 [_windowElements removeAllObjects];
461}
462
463- (void)addApplicationNotification:(CFStringRef)notification {
464 AXObserverRef observer = self.observerRef;
465 AXUIElementRef applicationElement = self.applicationElementRef;
466 if (!observer || !applicationElement) {
467 return;
468 }
469
470 AXObserverAddNotification(
471 observer,
472 applicationElement,
473 notification,
474 (__bridge void *)self
475 );
476}
477
478- (void)addWindowNotification:(CFStringRef)notification element:(AXUIElementRef)windowElement {
479 AXObserverRef observer = self.observerRef;
480 if (!observer || !windowElement) {
481 return;
482 }
483
484 AXObserverAddNotification(
485 observer,
486 windowElement,
487 notification,
488 (__bridge void *)self
489 );
490}
491
492- (NSArray<NSDictionary<NSString *, id> *> *)refreshObservedWindows {
493 [self clearWindowNotifications];
494
495 NSArray<id> *windowElements = CopyWindowElements(self.applicationElementRef);
496 NSMutableArray<NSDictionary<NSString *, id> *> *windows = [NSMutableArray arrayWithCapacity:windowElements.count];
497
498 for (NSUInteger index = 0; index < windowElements.count; index++) {
499 id windowObject = windowElements[index];
500 AXUIElementRef windowElement = (__bridge AXUIElementRef)windowObject;
501 NSDictionary<NSString *, id> *snapshot = SnapshotWindow(windowElement, index);
502 if (!snapshot) {
503 continue;
504 }
505
506 [_windowElements addObject:windowObject];
507 [self addWindowNotification:kAXTitleChangedNotification element:windowElement];
508 [self addWindowNotification:kAXUIElementDestroyedNotification element:windowElement];
509 [windows addObject:snapshot];
510 }
511
512 return windows;
513}
514
515- (void)refreshWindowsAndEmit {
516 PrintState(
517 NSWorkspace.sharedWorkspace.frontmostApplication,
518 [self refreshObservedWindows]
519 );
520}
521
522@end
523
524int main(int argc, const char *argv[]) {
525 @autoreleasepool {
526 [NSApplication sharedApplication];
527 [NSApp setActivationPolicy:NSApplicationActivationPolicyProhibited];
528
529 if (argc > 1) {
530 NSString *command = [NSString stringWithUTF8String:argv[1]];
531 if ([command isEqualToString:@"--once"]) {
532 PrintCurrentState();
533 return 0;
534 }
535 if ([command isEqualToString:@"--focus-window"]) {
536 if (argc < 3) {
537 fprintf(stderr, "%s\n", "Missing window identifier.");
538 return 1;
539 }
540 char *end = NULL;
541 long long windowIdentifier = strtoll(argv[2], &end, 10);
542 if (end == argv[2] || (end && *end != '\0')) {
543 fprintf(stderr, "%s\n", "Window identifier must be an integer.");
544 return 1;
545 }
546 return FocusWindowWithIdentifier(windowIdentifier) ? 0 : 1;
547 }
548 if ([command isEqualToString:@"--focus-main-window"]) {
549 return FocusMainWindow() ? 0 : 1;
550 }
551
552 fprintf(stderr, "Unknown argument: %s\n", argv[1]);
553 return 1;
554 }
555
556 FrontmostAppObserver *observer = [FrontmostAppObserver new];
557 [observer start];
558 [[NSRunLoop currentRunLoop] run];
559 }
560
561 return 0;
562}
src/Mac/toast_helper.m deleted-98
...@@ -1,98 +0,0 @@
1#import <AppKit/AppKit.h>
2#import <Foundation/Foundation.h>
3
4static NSTextField *MakeLabel(NSRect frame, NSString *text, NSFont *font, NSColor *color) {
5 NSTextField *label = [[NSTextField alloc] initWithFrame:frame];
6 [label setStringValue:text ?: @""];
7 [label setBezeled:NO];
8 [label setBordered:NO];
9 [label setDrawsBackground:NO];
10 [label setEditable:NO];
11 [label setSelectable:NO];
12 [label setAlignment:NSTextAlignmentCenter];
13 [label setTextColor:color];
14 [label setFont:font];
15 [label setLineBreakMode:NSLineBreakByTruncatingTail];
16 [label setUsesSingleLineMode:YES];
17 return label;
18}
19
20int main(int argc, const char *argv[]) {
21 @autoreleasepool {
22 NSString *message = argc > 1 ? [NSString stringWithUTF8String:argv[1]] : @"Toast";
23 NSString *detail = argc > 2 ? [NSString stringWithUTF8String:argv[2]] : @"";
24 double durationSeconds = argc > 3 ? strtod(argv[3], NULL) : 1.0;
25 if (durationSeconds < 0.25) {
26 durationSeconds = 0.25;
27 }
28 if (durationSeconds > 4.0) {
29 durationSeconds = 4.0;
30 }
31
32 [NSApplication sharedApplication];
33 [NSApp setActivationPolicy:NSApplicationActivationPolicyAccessory];
34
35 CGFloat width = MIN(420.0, MAX(220.0, (CGFloat)message.length * 9.0 + 72.0));
36 CGFloat height = detail.length > 0 ? 72.0 : 52.0;
37 NSRect screenFrame = NSScreen.mainScreen ? NSScreen.mainScreen.visibleFrame : NSMakeRect(0, 0, 1440, 900);
38 CGFloat x = NSMidX(screenFrame) - (width / 2.0);
39 CGFloat y = NSMinY(screenFrame) + 72.0;
40
41 NSPanel *window = [[NSPanel alloc]
42 initWithContentRect:NSMakeRect(x, y, width, height)
43 styleMask:NSWindowStyleMaskBorderless | NSWindowStyleMaskNonactivatingPanel
44 backing:NSBackingStoreBuffered
45 defer:NO];
46 [window setOpaque:NO];
47 [window setBackgroundColor:NSColor.clearColor];
48 [window setHasShadow:YES];
49 [window setIgnoresMouseEvents:YES];
50 [window setFloatingPanel:YES];
51 [window setHidesOnDeactivate:NO];
52 [window setLevel:NSStatusWindowLevel];
53 [window setCollectionBehavior:NSWindowCollectionBehaviorCanJoinAllSpaces |
54 NSWindowCollectionBehaviorFullScreenAuxiliary];
55
56 NSView *contentView = window.contentView;
57 contentView.wantsLayer = YES;
58 contentView.layer.backgroundColor = [[NSColor colorWithCalibratedWhite:0.08 alpha:0.92] CGColor];
59 contentView.layer.cornerRadius = 14.0;
60 contentView.layer.masksToBounds = YES;
61 contentView.layer.borderWidth = 1.0;
62 contentView.layer.borderColor = [[NSColor colorWithCalibratedWhite:1.0 alpha:0.12] CGColor];
63
64 CGFloat titleY = detail.length > 0 ? 34.0 : 15.0;
65 [contentView addSubview:MakeLabel(
66 NSMakeRect(18, titleY, width - 36, 20),
67 message,
68 [NSFont boldSystemFontOfSize:13.0],
69 NSColor.whiteColor)];
70
71 if (detail.length > 0) {
72 [contentView addSubview:MakeLabel(
73 NSMakeRect(18, 14, width - 36, 16),
74 detail,
75 [NSFont systemFontOfSize:11.0],
76 [NSColor colorWithCalibratedWhite:1.0 alpha:0.72])];
77 }
78
79 [window setAlphaValue:0.0];
80 [window orderFrontRegardless];
81
82 for (NSInteger step = 1; step <= 5; step += 1) {
83 [window setAlphaValue:(CGFloat)step / 5.0];
84 [[NSRunLoop currentRunLoop] runUntilDate:[NSDate dateWithTimeIntervalSinceNow:0.02]];
85 }
86
87 [[NSRunLoop currentRunLoop] runUntilDate:[NSDate dateWithTimeIntervalSinceNow:durationSeconds]];
88
89 for (NSInteger step = 4; step >= 0; step -= 1) {
90 [window setAlphaValue:(CGFloat)step / 5.0];
91 [[NSRunLoop currentRunLoop] runUntilDate:[NSDate dateWithTimeIntervalSinceNow:0.02]];
92 }
93
94 [window orderOut:nil];
95 }
96
97 return 0;
98}
src/Reaper.ts deleted-973
...@@ -1,973 +0,0 @@
1import { Events } from "@clo/lib/Events.ts";
2import * as log from "@clo/lib/log.ts";
3import { execFile } from "node:child_process";
4import { createSocket, type Socket } from "node:dgram";
5import { type FSWatcher, readFileSync, watch } from "node:fs";
6import { cp, mkdir, readFile, writeFile } from "node:fs/promises";
7import { basename, dirname, join } from "node:path";
8import process from "node:process";
9import { fileURLToPath } from "node:url";
10import { promisify } from "node:util";
11import { REAPER_ACTIONS, type ReaperActionId } from "./Reaper/actions.ts";
12const console = log.scoped("reaper");
13
14export type { ReaperActionId } from "./Reaper/actions.ts";
15
16type ReaperCommandId = number;
17
18export interface ReaperOptions {
19 oscHost?: string;
20 oscPort?: number;
21 oscBindPort?: number;
22}
23
24export interface ReaperTransportState {
25 playing: boolean;
26 paused: boolean;
27 recording: boolean;
28 repeatOn: boolean;
29 positionSeconds: number;
30 positionString: string;
31 positionBeatsString: string;
32 /** Project tempo in BPM (live, from the feedback script). */
33 tempo: number;
34 /** Project time signature as "num/denom" (live, from the feedback script). */
35 timeSignature: string;
36 readAtMs: number;
37 source: "osc" | "optimistic";
38}
39
40type ReaperScriptName = string;
41
42type OscScalar = number | string | boolean;
43type OscMessage = {
44 address: string;
45 args: OscScalar[];
46};
47type ReaperTransportPatch = Partial<ReaperTransportState>;
48
49const DEFAULT_REAPER_BIN = "/Applications/REAPER.app/Contents/MacOS/REAPER";
50const DEFAULT_REAPER_OSC_HOST = process.env.REAPER_OSC_HOST ?? "127.0.0.1";
51const DEFAULT_REAPER_OSC_PORT = readNumberEnv(
52 ["REAPER_OSC_PORT", "REAPER_OSC_TARGET_PORT"],
53 58_000,
54);
55const DEFAULT_REAPER_OSC_BIND_PORT = readNumberEnv(
56 ["REAPER_OSC_BIND_PORT", "REAPER_OSC_FEEDBACK_PORT"],
57 58_001,
58);
59const SCRUB_FLUSH_MS = 25;
60const SCRUB_OSC_VALUE_PER_TICK = readNumberEnv(
61 [
62 "REAPER_SCRUB_OSC_VALUE_PER_TICK",
63 "REAPER_SCRUB_SECONDS_PER_TICK",
64 "REAPER_JOG_SECONDS_PER_TICK",
65 ],
66 0.1,
67);
68const MAX_SCRUB_OSC_VALUE = readNumberEnv(
69 [
70 "REAPER_MAX_SCRUB_OSC_VALUE",
71 "REAPER_MAX_SCRUB_STEP_SECONDS",
72 "REAPER_MAX_JOG_STEP_SECONDS",
73 ],
74 5,
75);
76const REAPER_SUPPORT_SOURCE_DIR = join(
77 dirname(fileURLToPath(import.meta.url)),
78 "..",
79 "config",
80 "reaper",
81);
82const DEFAULT_REAPER_RESOURCE_DIR = join(
83 process.env.HOME ?? "",
84 "Library",
85 "Application Support",
86 "REAPER",
87);
88const DEFAULT_REAPER_CONFIG_PATH = join(
89 DEFAULT_REAPER_RESOURCE_DIR,
90 "reaper.ini",
91);
92const DEFAULT_REAPER_SCRIPT_TARGET_DIR = join(
93 DEFAULT_REAPER_RESOURCE_DIR,
94 "Scripts",
95 "clover",
96);
97const DEFAULT_REAPER_OSC_TARGET_DIR = join(
98 DEFAULT_REAPER_RESOURCE_DIR,
99 "OSC",
100);
101const REAPER_SCRIPT_TARGET_DIR = process.env.REAPER_SCRIPTS_DIR
102 ?? DEFAULT_REAPER_SCRIPT_TARGET_DIR;
103// The feedback script writes live tempo/time-signature here (next to itself);
104// we watch the file for changes. See config/reaper/scripts/clover_feedback.lua.
105const REAPER_FEEDBACK_DIR = join(REAPER_SCRIPT_TARGET_DIR, "scripts");
106const REAPER_FEEDBACK_FILE = "state.json";
107const REAPER_FEEDBACK_STATE_PATH = join(REAPER_FEEDBACK_DIR, REAPER_FEEDBACK_FILE);
108const REAPER_FEEDBACK_SCRIPT = "clover_feedback";
109const REAPER_OSC_TARGET_DIR = process.env.REAPER_OSC_DIR
110 ?? DEFAULT_REAPER_OSC_TARGET_DIR;
111const OSC_PATTERN_FILE = "CloverAutomation.ReaperOSC";
112const OSC_PATTERN_NAME = stripReaperOscExtension(OSC_PATTERN_FILE);
113const OSC_PATTERN_CONFIG = `# OSC pattern config file for Clover Creative Control's REAPER integration.
114DEVICE_TRACK_COUNT 1
115DEVICE_SEND_COUNT 0
116DEVICE_RECEIVE_COUNT 0
117DEVICE_FX_COUNT 0
118DEVICE_FX_PARAM_COUNT 0
119DEVICE_FX_INST_PARAM_COUNT 0
120DEVICE_MARKER_COUNT 0
121DEVICE_REGION_COUNT 0
122
123REAPER_TRACK_FOLLOWS REAPER
124DEVICE_TRACK_FOLLOWS DEVICE
125DEVICE_TRACK_BANK_FOLLOWS DEVICE
126DEVICE_FX_FOLLOWS DEVICE
127DEVICE_ROTARY_CENTER 0
128
129# ----------------------------------------------------------------
130
131RECORD t/clover/record
132STOP t/clover/stop
133PLAY t/clover/play
134PAUSE t/clover/pause
135SCRUB r/clover/scrub
136
137ACTION i/clover/action s/clover/action/str t/clover/action/@`;
138const OSC_ADDRESS = {
139 action: "/clover/action",
140 actionString: "/clover/action/str",
141 play: "/clover/play",
142 stop: "/clover/stop",
143 pause: "/clover/pause",
144 record: "/clover/record",
145 scrub: "/clover/scrub",
146} as const;
147const execFileAsync = promisify(execFile);
148
149export class Reaper extends Events<Reaper.EventMap> {
150 #transport = blankTransportState();
151 #pendingScrubDelta = 0;
152 #scrubTimer: ReturnType<typeof setTimeout> | null = null;
153 #warnedOscSocket = false;
154 #managedScriptsPromise: Promise<void> | null = null;
155 #oscHost: string;
156 #oscPort: number;
157 #oscBindPort: number;
158 #receivedOscFeedback = false;
159 #receiveSocket: Socket;
160 #sendSocket: Socket;
161 #closed = false;
162 #feedbackWatcher: FSWatcher | null = null;
163 #feedbackLaunched = false;
164
165 constructor(options: ReaperOptions = {}) {
166 super();
167 this.#oscHost = options.oscHost ?? DEFAULT_REAPER_OSC_HOST;
168 this.#oscPort = options.oscPort ?? DEFAULT_REAPER_OSC_PORT;
169 this.#oscBindPort = options.oscBindPort ?? DEFAULT_REAPER_OSC_BIND_PORT;
170 this.#receiveSocket = createSocket("udp4");
171 this.#sendSocket = createSocket("udp4");
172 this.#setupOscSockets();
173 this.#warnIfOscSurfaceMissing();
174 void this.#installOscPatternConfig().catch((error) => {
175 this.#logOscPatternInstallError(error);
176 });
177 void this.#startFeedback();
178 }
179
180 get transport(): ReaperTransportState {
181 return { ...this.#transport };
182 }
183
184 #warnIfOscSurfaceMissing() {
185 if (hasManagedOscSurface(readReaperConfig())) {
186 return;
187 }
188
189 console.info(
190 `${OSC_PATTERN_FILE} is not registered yet. `
191 + `Add an OSC control surface manually using the "${OSC_PATTERN_NAME}" pattern, set REAPER's local listen port to `
192 + `${this.#oscPort}, and send to ${this.#oscHost}:${this.#oscBindPort}. `
193 + "The REAPER web interface is not required for this config.",
194 );
195 }
196
197 close() {
198 if (this.#closed) {
199 return;
200 }
201
202 this.#closed = true;
203 this.#clearScrubTimer();
204
205 this.#feedbackWatcher?.close();
206 this.#feedbackWatcher = null;
207
208 this.#receiveSocket.removeAllListeners();
209 this.#sendSocket.removeAllListeners();
210 closeSocket(this.#receiveSocket);
211 closeSocket(this.#sendSocket);
212 }
213
214 async runAction(actionId: ReaperActionId) {
215 const sent = await this.#sendCommand(REAPER_ACTIONS[actionId]);
216 if (!sent) {
217 return sent;
218 }
219
220 const patch = optimisticTransportPatchForAction(actionId, this.#transport);
221 if (patch) {
222 this.#updateTransport(patch, "optimistic");
223 }
224
225 return sent;
226 }
227
228 async #sendCommand(commandId: ReaperCommandId) {
229 return this.#sendLoggedOscMessage(
230 OSC_ADDRESS.action,
231 [commandId],
232 `run action ${commandId}`,
233 );
234 }
235
236 async #ensureManagedScripts() {
237 if (!this.#managedScriptsPromise) {
238 this.#managedScriptsPromise = this.#installManagedScripts()
239 .catch((error) => {
240 this.#managedScriptsPromise = null;
241 this.#logInstallError(error);
242 throw error;
243 });
244 }
245
246 await this.#managedScriptsPromise;
247 }
248
249 async runScript(name: ReaperScriptName) {
250 try {
251 await this.#ensureManagedScripts();
252 await this.#runManagedScript(name);
253 return true;
254 } catch (error) {
255 this.#logScriptError(name, error);
256 return false;
257 }
258 }
259
260 scrub(value: number) {
261 this.#queueScrubDelta(value * SCRUB_OSC_VALUE_PER_TICK);
262 }
263
264 async #flushScrub() {
265 const delta = clamp(
266 this.#pendingScrubDelta,
267 -MAX_SCRUB_OSC_VALUE,
268 MAX_SCRUB_OSC_VALUE,
269 );
270 this.#pendingScrubDelta = 0;
271
272 if (delta === 0) {
273 return;
274 }
275
276 await this.#sendLoggedOscMessage(
277 OSC_ADDRESS.scrub,
278 [delta],
279 "scrub playhead",
280 );
281 }
282
283 async #installManagedScripts() {
284 await mkdir(REAPER_SCRIPT_TARGET_DIR, { recursive: true });
285 await cp(REAPER_SUPPORT_SOURCE_DIR, REAPER_SCRIPT_TARGET_DIR, {
286 force: true,
287 recursive: true,
288 });
289 }
290
291 async #installOscPatternConfig() {
292 await mkdir(REAPER_OSC_TARGET_DIR, { recursive: true });
293 await writeFile(
294 join(REAPER_OSC_TARGET_DIR, OSC_PATTERN_FILE),
295 OSC_PATTERN_CONFIG,
296 );
297 }
298
299 // Live tempo + time signature come from a deferred REAPER Lua script that
300 // writes them to a JSON file whenever they change; we watch that file. This
301 // covers what OSC can't (REAPER has no time-signature feedback token).
302 async #startFeedback() {
303 try {
304 await this.#ensureManagedScripts();
305 await this.#readFeedbackState();
306 this.#watchFeedbackState();
307 await this.#launchFeedbackScript();
308 } catch (error) {
309 this.#logFeedbackError(error);
310 }
311 }
312
313 #watchFeedbackState() {
314 if (this.#feedbackWatcher || this.#closed) return;
315 try {
316 const watcher = watch(REAPER_FEEDBACK_DIR, (_event, filename) => {
317 if (!filename || filename === REAPER_FEEDBACK_FILE) {
318 void this.#readFeedbackState();
319 }
320 });
321 watcher.on("error", () => {});
322 watcher.unref?.();
323 this.#feedbackWatcher = watcher;
324 } catch {
325 // Directory may not be watchable; the periodic writes still land via reads.
326 }
327 }
328
329 async #readFeedbackState() {
330 let raw: string;
331 try {
332 raw = await readFile(REAPER_FEEDBACK_STATE_PATH, "utf8");
333 } catch {
334 return; // not written yet
335 }
336 const patch = parseFeedbackState(raw);
337 if (patch) {
338 this.#updateTransport(patch, "osc");
339 }
340 }
341
342 async #launchFeedbackScript() {
343 if (this.#feedbackLaunched || this.#closed) return;
344 // Only start it once REAPER is actually up, so we don't log a spurious error.
345 if (!await isProcessRunning(reaperProcessName())) return;
346 this.#feedbackLaunched = true;
347 await this.runScript(REAPER_FEEDBACK_SCRIPT);
348 }
349
350 async #runManagedScript(name: ReaperScriptName) {
351 if (!await isProcessRunning(reaperProcessName())) {
352 throw new Error(
353 "REAPER is not running, so Clover skipped launching the script instead of opening it automatically.",
354 );
355 }
356
357 await execFileAsync(process.env.REAPER_BIN ?? DEFAULT_REAPER_BIN, [
358 "-nonewinst",
359 join(REAPER_SCRIPT_TARGET_DIR, "scripts", `${name}.lua`),
360 ]);
361 }
362
363 #setupOscSockets() {
364 this.#receiveSocket.on("message", (packet) => {
365 this.#handleOscPacket(packet);
366 });
367
368 this.#receiveSocket.on("error", (error) => {
369 this.#logOscSocketError(error);
370 });
371
372 this.#sendSocket.on("error", (error) => {
373 this.#logOscSocketError(error);
374 });
375
376 this.#receiveSocket.bind(this.#oscBindPort);
377 }
378
379 #handleOscPacket(packet: Buffer) {
380 const patch = transportPatchForOscPacket(packet);
381 if (!patch) {
382 return;
383 }
384
385 if (!this.#receivedOscFeedback) {
386 this.#receivedOscFeedback = true;
387 this.emit("osc-feedback");
388 // REAPER is confirmed up — (re)start the feedback script if we hadn't yet.
389 void this.#launchFeedbackScript().catch((error) => {
390 this.#logFeedbackError(error);
391 });
392 }
393
394 this.#updateTransport(patch, "osc");
395 }
396
397 #updateTransport(
398 patch: ReaperTransportPatch,
399 source: ReaperTransportState["source"],
400 ) {
401 const previous = this.#transport;
402 const next = {
403 ...previous,
404 ...patch,
405 readAtMs: Date.now(),
406 source,
407 };
408 this.#transport = next;
409
410 if (!sameTransportState(previous, next)) {
411 this.emit("transport", { ...next });
412 }
413 }
414
415 #queueScrubDelta(delta: number) {
416 if (!Number.isFinite(delta) || delta === 0) {
417 return;
418 }
419
420 this.#pendingScrubDelta += clamp(
421 delta,
422 -MAX_SCRUB_OSC_VALUE,
423 MAX_SCRUB_OSC_VALUE,
424 );
425
426 if (this.#scrubTimer) {
427 return;
428 }
429
430 this.#scrubTimer = setTimeout(() => {
431 this.#scrubTimer = null;
432 void this.#flushScrub();
433 }, SCRUB_FLUSH_MS);
434 }
435
436 #clearScrubTimer() {
437 if (!this.#scrubTimer) {
438 return;
439 }
440
441 clearTimeout(this.#scrubTimer);
442 this.#scrubTimer = null;
443 }
444
445 async #sendLoggedOscMessage(
446 address: string,
447 args: OscScalar[],
448 action: string,
449 ) {
450 try {
451 await this.#sendOscMessage(address, args);
452 return true;
453 } catch (error) {
454 this.#logOscSendError(action, error);
455 return false;
456 }
457 }
458
459 async #sendOscMessage(address: string, args: OscScalar[] = []) {
460 const payload = encodeOscMessage(address, args);
461 await new Promise<void>((resolve, reject) => {
462 this.#sendSocket.send(
463 payload,
464 this.#oscPort,
465 this.#oscHost,
466 (error) => {
467 if (error) {
468 reject(error);
469 return;
470 }
471 resolve();
472 },
473 );
474 });
475 }
476
477 #logOscSendError(action: string, error: unknown) {
478 this.#logError(
479 `Failed to ${action} via OSC ${this.#oscHost}:${this.#oscPort}.`,
480 error,
481 );
482 }
483
484 #logScriptError(name: ReaperScriptName, error: unknown) {
485 this.#logError(
486 `Failed to run script "${name}" from ${REAPER_SCRIPT_TARGET_DIR}.`,
487 error,
488 );
489 }
490
491 #logOscSocketError(error: unknown) {
492 if (!this.#warnedOscSocket) {
493 this.#warnedOscSocket = true;
494 this.#logError(
495 `Failed to bind OSC feedback socket on ${this.#oscBindPort}.`,
496 error,
497 );
498 return;
499 }
500
501 console.error(`[REAPER] ${formatError(error)}`);
502 }
503
504 #logInstallError(error: unknown) {
505 this.#logError(
506 `Failed to install/update managed scripts in ${REAPER_SCRIPT_TARGET_DIR}.`,
507 error,
508 );
509 }
510
511 #logOscPatternInstallError(error: unknown) {
512 this.#logError(
513 `Failed to install/update ${OSC_PATTERN_FILE} in ${REAPER_OSC_TARGET_DIR}.`,
514 error,
515 );
516 }
517
518 #logFeedbackError(error: unknown) {
519 this.#logError(
520 `Failed to start live tempo/time-signature feedback (${REAPER_FEEDBACK_STATE_PATH}).`,
521 error,
522 );
523 }
524
525 #logError(message: string, error: unknown) {
526 console.error(`[REAPER] ${message}`);
527 console.error(`[REAPER] ${formatError(error)}`);
528 }
529}
530
531export declare namespace Reaper {
532 export type EventMap = {
533 "transport": [transport: ReaperTransportState];
534 "osc-feedback": [];
535 };
536}
537
538function blankTransportState(): ReaperTransportState {
539 return {
540 playing: false,
541 paused: false,
542 recording: false,
543 repeatOn: false,
544 positionSeconds: 0,
545 positionString: "",
546 positionBeatsString: "",
547 tempo: 120,
548 timeSignature: "4/4",
549 readAtMs: 0,
550 source: "optimistic",
551 };
552}
553
554function sameTransportState(
555 left: ReaperTransportState,
556 right: ReaperTransportState,
557) {
558 return left.playing === right.playing
559 && left.paused === right.paused
560 && left.recording === right.recording
561 && left.repeatOn === right.repeatOn
562 && left.positionSeconds === right.positionSeconds
563 && left.positionString === right.positionString
564 && left.positionBeatsString === right.positionBeatsString
565 && left.tempo === right.tempo
566 && left.timeSignature === right.timeSignature;
567}
568
569/** Parse the feedback script's `{ "tempo": <bpm>, "timesig": "n/d" }` payload. */
570function parseFeedbackState(raw: string): ReaperTransportPatch | null {
571 let data: { tempo?: unknown; timesig?: unknown };
572 try {
573 data = JSON.parse(raw);
574 } catch {
575 return null;
576 }
577 const patch: ReaperTransportPatch = {};
578 if (typeof data.tempo === "number" && Number.isFinite(data.tempo)) {
579 patch.tempo = data.tempo;
580 }
581 if (typeof data.timesig === "string" && data.timesig.length > 0) {
582 patch.timeSignature = data.timesig;
583 }
584 return Object.keys(patch).length > 0 ? patch : null;
585}
586
587function optimisticTransportPatchForAction(
588 actionId: ReaperActionId,
589 transport: ReaperTransportState,
590): ReaperTransportPatch | null {
591 switch (actionId) {
592 case "transport-play":
593 case "transport-play-skip-time-selection":
594 return { playing: true, paused: false };
595
596 case "transport-stop":
597 case "transport-stop-delete-all-recorded-media":
598 case "transport-stop-save-all-recorded-media":
599 return { playing: false, paused: false, recording: false };
600
601 case "transport-play-stop":
602 case "transport-play-stop-move-edit-cursor-on-stop":
603 return transport.playing || transport.paused || transport.recording
604 ? { playing: false, paused: false, recording: false }
605 : { playing: true, paused: false };
606
607 case "transport-record":
608 return transport.recording
609 ? { playing: false, paused: false, recording: false }
610 : { recording: true, playing: true, paused: false };
611
612 case "transport-pause":
613 if (transport.paused) {
614 return { paused: false, playing: true };
615 }
616 if (transport.playing || transport.recording) {
617 return { paused: true, playing: false };
618 }
619 return null;
620
621 case "transport-play-pause":
622 if (transport.paused) {
623 return { paused: false, playing: true };
624 }
625 if (transport.playing || transport.recording) {
626 return { paused: true, playing: false };
627 }
628 return { playing: true, paused: false };
629
630 case "transport-toggle-repeat":
631 return { repeatOn: !transport.repeatOn };
632
633 default:
634 return null;
635 }
636}
637
638function transportPatchForOscMessage(
639 message: OscMessage,
640): ReaperTransportPatch | null {
641 switch (message.address) {
642 case OSC_ADDRESS.record: {
643 const recording = readOscBoolean(message.args[0]);
644 if (recording === null) {
645 return null;
646 }
647
648 return recording
649 ? { recording, playing: true, paused: false }
650 : { recording };
651 }
652
653 case OSC_ADDRESS.play: {
654 const playing = readOscBoolean(message.args[0]);
655 if (playing === null) {
656 return null;
657 }
658
659 return playing ? { playing, paused: false } : { playing };
660 }
661
662 case OSC_ADDRESS.pause: {
663 const paused = readOscBoolean(message.args[0]);
664 if (paused === null) {
665 return null;
666 }
667
668 return paused ? { paused, playing: false } : { paused };
669 }
670
671 case OSC_ADDRESS.stop:
672 return readOscBoolean(message.args[0])
673 ? { playing: false, paused: false, recording: false }
674 : null;
675
676 default:
677 return null;
678 }
679}
680
681function transportPatchForOscPacket(
682 packet: Buffer,
683): ReaperTransportPatch | null {
684 const messages = parseOscPacket(packet);
685 if (!messages) {
686 return null;
687 }
688
689 let patch: ReaperTransportPatch | null = null;
690 for (const message of messages) {
691 const next = transportPatchForOscMessage(message);
692 if (!next) {
693 continue;
694 }
695
696 patch = patch ? { ...patch, ...next } : next;
697 }
698
699 return patch;
700}
701
702function hasManagedOscSurface(config: string) {
703 for (const line of config.split(/\r?\n/u)) {
704 const match = /^csurf_\d+=(.+)$/u.exec(line.trim());
705 if (!match) {
706 continue;
707 }
708
709 const tokens = match[1]?.match(/"[^"]*"|'[^']*'|[^ ]+/gu) ?? [];
710 if (tokens[0] !== "OSC") {
711 continue;
712 }
713
714 const normalizedTokens = tokens.map((token) => unquoteReaperToken(token));
715 if (
716 normalizedTokens.includes(OSC_PATTERN_FILE)
717 || normalizedTokens.includes(OSC_PATTERN_NAME)
718 ) {
719 return true;
720 }
721 }
722
723 return false;
724}
725
726function readReaperConfig() {
727 try {
728 return readFileSync(DEFAULT_REAPER_CONFIG_PATH, "utf8");
729 } catch {
730 return "";
731 }
732}
733
734function closeSocket(socket: Socket) {
735 try {
736 socket.close();
737 } catch {}
738}
739
740function formatError(error: unknown) {
741 return error instanceof Error ? error.message : String(error);
742}
743
744function unquoteReaperToken(token: string) {
745 if (
746 (token.startsWith("'") && token.endsWith("'"))
747 || (token.startsWith("\"") && token.endsWith("\""))
748 ) {
749 return token.slice(1, -1);
750 }
751 return token;
752}
753
754function stripReaperOscExtension(fileName: string) {
755 return fileName.endsWith(".ReaperOSC")
756 ? fileName.slice(0, -".ReaperOSC".length)
757 : fileName;
758}
759
760function readNumberEnv(names: ReadonlyArray<string>, fallback: number) {
761 for (const name of names) {
762 const numeric = Number(process.env[name]);
763 if (Number.isFinite(numeric)) {
764 return numeric;
765 }
766 }
767
768 return fallback;
769}
770
771function clamp(value: number, min: number, max: number) {
772 return Math.min(max, Math.max(min, value));
773}
774
775function reaperProcessName() {
776 return basename(process.env.REAPER_BIN ?? DEFAULT_REAPER_BIN);
777}
778
779async function isProcessRunning(processName: string) {
780 try {
781 await execFileAsync("pgrep", ["-x", processName]);
782 return true;
783 } catch {
784 return false;
785 }
786}
787
788function parseOscPacket(packet: Buffer): OscMessage[] | null {
789 if (isOscBundle(packet)) {
790 return parseOscBundle(packet);
791 }
792
793 const message = parseOscMessage(packet);
794 return message ? [message] : null;
795}
796
797function isOscBundle(packet: Buffer) {
798 return packet.subarray(0, 8).equals(Buffer.from("#bundle\0"));
799}
800
801function parseOscBundle(packet: Buffer): OscMessage[] | null {
802 const bundleHeader = readOscString(packet, 0);
803 if (!bundleHeader || bundleHeader.value !== "#bundle") {
804 return null;
805 }
806
807 let offset = nextOscOffset(bundleHeader.nextOffset);
808 if (offset + 8 > packet.length) {
809 return null;
810 }
811
812 offset += 8;
813
814 const messages: OscMessage[] = [];
815 while (offset < packet.length) {
816 if (offset + 4 > packet.length) {
817 return null;
818 }
819
820 const elementSize = packet.readInt32BE(offset);
821 offset += 4;
822
823 if (elementSize < 0 || offset + elementSize > packet.length) {
824 return null;
825 }
826
827 const element = packet.subarray(offset, offset + elementSize);
828 offset += elementSize;
829
830 const elementMessages = parseOscPacket(element);
831 if (!elementMessages) {
832 return null;
833 }
834
835 messages.push(...elementMessages);
836 }
837
838 return messages;
839}
840
841function parseOscMessage(packet: Buffer): OscMessage | null {
842 const address = readOscString(packet, 0);
843 if (!address) {
844 return null;
845 }
846
847 const typeTags = readOscString(packet, nextOscOffset(address.nextOffset));
848 if (!typeTags || !typeTags.value.startsWith(",")) {
849 return null;
850 }
851
852 const args: OscScalar[] = [];
853 let offset = nextOscOffset(typeTags.nextOffset);
854
855 for (const tag of typeTags.value.slice(1)) {
856 switch (tag) {
857 case "i":
858 if (offset + 4 > packet.length) return null;
859 args.push(packet.readInt32BE(offset));
860 offset += 4;
861 break;
862
863 case "f":
864 if (offset + 4 > packet.length) return null;
865 args.push(packet.readFloatBE(offset));
866 offset += 4;
867 break;
868
869 case "s": {
870 const value = readOscString(packet, offset);
871 if (!value) return null;
872 args.push(value.value);
873 offset = nextOscOffset(value.nextOffset);
874 break;
875 }
876
877 case "T":
878 args.push(true);
879 break;
880
881 case "F":
882 args.push(false);
883 break;
884
885 default:
886 return null;
887 }
888 }
889
890 return { address: address.value, args };
891}
892
893function encodeOscMessage(address: string, args: OscScalar[] = []) {
894 const parts = [encodeOscString(address)];
895 const typeTags = "," + args.map((arg) => oscTypeTag(arg)).join("");
896 parts.push(encodeOscString(typeTags));
897
898 for (const arg of args) {
899 parts.push(encodeOscArgument(arg));
900 }
901
902 return Buffer.concat(parts);
903}
904
905function oscTypeTag(value: OscScalar) {
906 if (typeof value === "number") {
907 return Number.isInteger(value) ? "i" : "f";
908 }
909 if (typeof value === "boolean") {
910 return value ? "T" : "F";
911 }
912 return "s";
913}
914
915function encodeOscArgument(value: OscScalar) {
916 if (typeof value === "number") {
917 const buffer = Buffer.alloc(4);
918 if (Number.isInteger(value)) {
919 buffer.writeInt32BE(value, 0);
920 } else {
921 buffer.writeFloatBE(value, 0);
922 }
923 return buffer;
924 }
925
926 if (typeof value === "boolean") {
927 return Buffer.alloc(0);
928 }
929
930 return encodeOscString(value);
931}
932
933function encodeOscString(value: string) {
934 const buffer = Buffer.from(value + "\0", "utf8");
935 const padding = (4 - (buffer.length % 4)) % 4;
936 return padding === 0
937 ? buffer
938 : Buffer.concat([buffer, Buffer.alloc(padding)]);
939}
940
941function readOscString(buffer: Buffer, offset: number) {
942 let end = offset;
943 while (end < buffer.length && buffer[end] !== 0) {
944 end += 1;
945 }
946
947 if (end >= buffer.length) {
948 return null;
949 }
950
951 return {
952 value: buffer.toString("utf8", offset, end),
953 nextOffset: end + 1,
954 };
955}
956
957function nextOscOffset(offset: number) {
958 return offset + ((4 - (offset % 4)) % 4);
959}
960
961function readOscBoolean(value: OscScalar | undefined) {
962 if (typeof value === "boolean") {
963 return value;
964 }
965 if (typeof value === "number") {
966 return value !== 0;
967 }
968 if (typeof value === "string") {
969 if (value === "0") return false;
970 if (value === "1") return true;
971 }
972 return null;
973}
src/Reaper/CloverAutomation.ReaperOSC deleted-25
...@@ -1,25 +0,0 @@
1# OSC pattern config file for Clover Creative Control's REAPER integration.
2DEVICE_TRACK_COUNT 1
3DEVICE_SEND_COUNT 0
4DEVICE_RECEIVE_COUNT 0
5DEVICE_FX_COUNT 0
6DEVICE_FX_PARAM_COUNT 0
7DEVICE_FX_INST_PARAM_COUNT 0
8DEVICE_MARKER_COUNT 0
9DEVICE_REGION_COUNT 0
10
11REAPER_TRACK_FOLLOWS REAPER
12DEVICE_TRACK_FOLLOWS DEVICE
13DEVICE_TRACK_BANK_FOLLOWS DEVICE
14DEVICE_FX_FOLLOWS DEVICE
15DEVICE_ROTARY_CENTER 0
16
17# ----------------------------------------------------------------
18
19RECORD t/clover/record
20STOP t/clover/stop
21PLAY t/clover/play
22PAUSE t/clover/pause
23SCRUB r/clover/scrub
24
25ACTION i/clover/action s/clover/action/str t/clover/action/@
\ No newline at end of file
src/Reaper/actions.ts deleted-6702
...@@ -1,6702 +0,0 @@
1// Generated by src/Reaper/generate-actions.ts
2// Source: REAPER main action section via kbd_enumerateActions()/kbd_getTextFromCmd().
3
4export const REAPER_ACTIONS = {
5 "action-arm-next-action": 2019,
6 "action-disarm-action": 2020,
7 "action-modify-midi-cc-mousewheel-0-5x": 2004,
8 "action-modify-midi-cc-mousewheel-10-percent": 2007,
9 "action-modify-midi-cc-mousewheel-2x": 2005,
10 "action-modify-midi-cc-mousewheel-negative": 2003,
11 "action-modify-midi-cc-mousewheel-plus-10-percent": 2006,
12 "action-momentarily-send-next-action-to-next-project-tab-1": 3061,
13 "action-momentarily-send-next-action-to-next-project-tab-2": 3062,
14 "action-momentarily-send-next-action-to-next-project-tab-3": 3063,
15 "action-momentarily-send-next-action-to-next-project-tab-4": 3064,
16 "action-momentarily-send-next-action-to-next-project-tab-5": 3065,
17 "action-momentarily-send-next-action-to-previous-project-tab-1": 3091,
18 "action-momentarily-send-next-action-to-previous-project-tab-2": 3092,
19 "action-momentarily-send-next-action-to-previous-project-tab-3": 3093,
20 "action-momentarily-send-next-action-to-previous-project-tab-4": 3094,
21 "action-momentarily-send-next-action-to-previous-project-tab-5": 3095,
22 "action-momentarily-send-next-action-to-previously-active-project-tab": 3120,
23 "action-momentarily-send-next-action-to-project-tab-1": 3002,
24 "action-momentarily-send-next-action-to-project-tab-10": 3011,
25 "action-momentarily-send-next-action-to-project-tab-2": 3003,
26 "action-momentarily-send-next-action-to-project-tab-3": 3004,
27 "action-momentarily-send-next-action-to-project-tab-4": 3005,
28 "action-momentarily-send-next-action-to-project-tab-5": 3006,
29 "action-momentarily-send-next-action-to-project-tab-6": 3007,
30 "action-momentarily-send-next-action-to-project-tab-7": 3008,
31 "action-momentarily-send-next-action-to-project-tab-8": 3009,
32 "action-momentarily-send-next-action-to-project-tab-9": 3010,
33 "action-momentarily-send-next-action-to-project-tab-n": 3032,
34 "action-momentarily-send-next-action-to-project-tab-n-1": 3033,
35 "action-momentarily-send-next-action-to-project-tab-n-2": 3034,
36 "action-momentarily-send-next-action-to-project-tab-n-3": 3035,
37 "action-momentarily-send-next-action-to-project-tab-n-4": 3036,
38 "action-momentarily-send-next-action-to-project-tab-n-5": 3037,
39 "action-momentarily-send-next-action-to-project-tab-n-6": 3038,
40 "action-momentarily-send-next-action-to-project-tab-n-7": 3039,
41 "action-momentarily-send-next-action-to-project-tab-n-8": 3040,
42 "action-momentarily-send-next-action-to-project-tab-n-9": 3041,
43 "action-prompt-to-continue-only-valid-within-custom-actions": 2000,
44 "action-prompt-to-go-to-action-loop-start-only-valid-within-custom-actions": 2002,
45 "action-repeat-the-action-prior-to-the-most-recent-action": 3000,
46 "action-repeat-the-most-recent-action": 2999,
47 "action-set-action-loop-start-only-valid-within-custom-actions": 2001,
48 "action-skip-next-action-if-cc-parameter-0-mid": 2013,
49 "action-skip-next-action-if-cc-parameter-0-mid-2014": 2014,
50 "action-skip-next-action-if-cc-parameter-0-mid-2015": 2015,
51 "action-skip-next-action-if-cc-parameter-0-mid-2016": 2016,
52 "action-skip-next-action-if-cc-parameter-0-mid-2017": 2017,
53 "action-skip-next-action-if-cc-parameter-0-mid-2018": 2018,
54 "action-skip-next-action-set-cc-parameter-to-relative-plus-1-if-action-armed-0-otherwise": 2023,
55 "action-skip-next-action-set-cc-parameter-to-relative-plus-1-if-action-toggle-state-enabled-1-if-disabled-0-if-toggle-state-unavailable":
56 2022,
57 "action-toggle-arm-of-next-action": 2021,
58 "action-wait-0-1-seconds-before-next-action": 2008,
59 "action-wait-0-5-seconds-before-next-action": 2009,
60 "action-wait-1-second-before-next-action": 2010,
61 "action-wait-10-seconds-before-next-action": 2012,
62 "action-wait-5-seconds-before-next-action": 2011,
63 "adjust-entire-tempo-envelope": 41805,
64 "adjust-last-touched-fx-parameter-midi-cc-osc-only": 973,
65 "adjust-solo-in-front-dim-midi-cc-mousewheel-only": 987,
66 "adjust-track-fx-parameter-01-midi-cc-osc-only": 950,
67 "adjust-track-fx-parameter-02-midi-cc-osc-only": 951,
68 "adjust-track-fx-parameter-03-midi-cc-osc-only": 952,
69 "adjust-track-fx-parameter-04-midi-cc-osc-only": 953,
70 "adjust-track-fx-parameter-05-midi-cc-osc-only": 954,
71 "adjust-track-fx-parameter-06-midi-cc-osc-only": 955,
72 "adjust-track-fx-parameter-07-midi-cc-osc-only": 956,
73 "adjust-track-fx-parameter-08-midi-cc-osc-only": 957,
74 "adjust-track-fx-parameter-09-midi-cc-osc-only": 958,
75 "adjust-track-fx-parameter-10-midi-cc-osc-only": 959,
76 "adjust-track-fx-parameter-11-midi-cc-osc-only": 960,
77 "adjust-track-fx-parameter-12-midi-cc-osc-only": 961,
78 "adjust-track-fx-parameter-13-midi-cc-osc-only": 962,
79 "adjust-track-fx-parameter-14-midi-cc-osc-only": 963,
80 "adjust-track-fx-parameter-15-midi-cc-osc-only": 964,
81 "adjust-track-fx-parameter-16-midi-cc-osc-only": 965,
82 "adjust-track-send-1-pan-midi-cc-osc-only": 911,
83 "adjust-track-send-1-volume-midi-cc-osc-only": 901,
84 "adjust-track-send-2-pan-midi-cc-osc-only": 912,
85 "adjust-track-send-2-volume-midi-cc-osc-only": 902,
86 "adjust-track-send-3-pan-midi-cc-osc-only": 913,
87 "adjust-track-send-3-volume-midi-cc-osc-only": 903,
88 "adjust-track-send-4-pan-midi-cc-osc-only": 914,
89 "adjust-track-send-4-volume-midi-cc-osc-only": 904,
90 "adjust-track-send-5-pan-midi-cc-osc-only": 915,
91 "adjust-track-send-5-volume-midi-cc-osc-only": 905,
92 "adjust-track-send-6-pan-midi-cc-osc-only": 916,
93 "adjust-track-send-6-volume-midi-cc-osc-only": 906,
94 "adjust-track-send-7-pan-midi-cc-osc-only": 917,
95 "adjust-track-send-7-volume-midi-cc-osc-only": 907,
96 "adjust-track-send-8-pan-midi-cc-osc-only": 918,
97 "adjust-track-send-8-volume-midi-cc-osc-only": 908,
98 "audio-device-configuration": 40099,
99 "automation-clear-all-saved-track-envelope-latches": 43568,
100 "automation-clear-all-track-envelope-latches": 42025,
101 "automation-clear-latch-preset-1": 50756,
102 "automation-clear-latch-preset-10": 50765,
103 "automation-clear-latch-preset-11": 50766,
104 "automation-clear-latch-preset-12": 50767,
105 "automation-clear-latch-preset-13": 50768,
106 "automation-clear-latch-preset-14": 50769,
107 "automation-clear-latch-preset-15": 50770,
108 "automation-clear-latch-preset-16": 50771,
109 "automation-clear-latch-preset-17": 50772,
110 "automation-clear-latch-preset-18": 50773,
111 "automation-clear-latch-preset-19": 50774,
112 "automation-clear-latch-preset-2": 50757,
113 "automation-clear-latch-preset-20": 50775,
114 "automation-clear-latch-preset-21": 50776,
115 "automation-clear-latch-preset-22": 50777,
116 "automation-clear-latch-preset-23": 50778,
117 "automation-clear-latch-preset-24": 50779,
118 "automation-clear-latch-preset-25": 50780,
119 "automation-clear-latch-preset-26": 50781,
120 "automation-clear-latch-preset-27": 50782,
121 "automation-clear-latch-preset-28": 50783,
122 "automation-clear-latch-preset-29": 50784,
123 "automation-clear-latch-preset-3": 50758,
124 "automation-clear-latch-preset-30": 50785,
125 "automation-clear-latch-preset-31": 50786,
126 "automation-clear-latch-preset-32": 50787,
127 "automation-clear-latch-preset-33": 50788,
128 "automation-clear-latch-preset-34": 50789,
129 "automation-clear-latch-preset-35": 50790,
130 "automation-clear-latch-preset-36": 50791,
131 "automation-clear-latch-preset-37": 50792,
132 "automation-clear-latch-preset-38": 50793,
133 "automation-clear-latch-preset-39": 50794,
134 "automation-clear-latch-preset-4": 50759,
135 "automation-clear-latch-preset-40": 50795,
136 "automation-clear-latch-preset-41": 50796,
137 "automation-clear-latch-preset-42": 50797,
138 "automation-clear-latch-preset-43": 50798,
139 "automation-clear-latch-preset-44": 50799,
140 "automation-clear-latch-preset-45": 50800,
141 "automation-clear-latch-preset-46": 50801,
142 "automation-clear-latch-preset-47": 50802,
143 "automation-clear-latch-preset-48": 50803,
144 "automation-clear-latch-preset-49": 50804,
145 "automation-clear-latch-preset-5": 50760,
146 "automation-clear-latch-preset-50": 50805,
147 "automation-clear-latch-preset-51": 50806,
148 "automation-clear-latch-preset-52": 50807,
149 "automation-clear-latch-preset-53": 50808,
150 "automation-clear-latch-preset-54": 50809,
151 "automation-clear-latch-preset-55": 50810,
152 "automation-clear-latch-preset-56": 50811,
153 "automation-clear-latch-preset-57": 50812,
154 "automation-clear-latch-preset-58": 50813,
155 "automation-clear-latch-preset-59": 50814,
156 "automation-clear-latch-preset-6": 50761,
157 "automation-clear-latch-preset-60": 50815,
158 "automation-clear-latch-preset-61": 50816,
159 "automation-clear-latch-preset-62": 50817,
160 "automation-clear-latch-preset-63": 50818,
161 "automation-clear-latch-preset-64": 50819,
162 "automation-clear-latch-preset-7": 50762,
163 "automation-clear-latch-preset-8": 50763,
164 "automation-clear-latch-preset-9": 50764,
165 "automation-clear-saved-track-envelope-latches": 43569,
166 "automation-clear-track-envelope-latches": 42026,
167 "automation-lane-decrease-active-fader-a-little-bit": 40858,
168 "automation-lane-decrease-active-fader-a-tiny-bit": 42384,
169 "automation-lane-increase-active-fader-a-little-bit": 40857,
170 "automation-lane-increase-active-fader-a-tiny-bit": 42383,
171 "automation-lane-set-active-fader-midi-cc-osc-only": 986,
172 "automation-load-latch-preset-1-for-all-tracks": 50564,
173 "automation-load-latch-preset-1-for-selected-tracks": 50692,
174 "automation-load-latch-preset-10-for-all-tracks": 50573,
175 "automation-load-latch-preset-10-for-selected-tracks": 50701,
176 "automation-load-latch-preset-11-for-all-tracks": 50574,
177 "automation-load-latch-preset-11-for-selected-tracks": 50702,
178 "automation-load-latch-preset-12-for-all-tracks": 50575,
179 "automation-load-latch-preset-12-for-selected-tracks": 50703,
180 "automation-load-latch-preset-13-for-all-tracks": 50576,
181 "automation-load-latch-preset-13-for-selected-tracks": 50704,
182 "automation-load-latch-preset-14-for-all-tracks": 50577,
183 "automation-load-latch-preset-14-for-selected-tracks": 50705,
184 "automation-load-latch-preset-15-for-all-tracks": 50578,
185 "automation-load-latch-preset-15-for-selected-tracks": 50706,
186 "automation-load-latch-preset-16-for-all-tracks": 50579,
187 "automation-load-latch-preset-16-for-selected-tracks": 50707,
188 "automation-load-latch-preset-17-for-all-tracks": 50580,
189 "automation-load-latch-preset-17-for-selected-tracks": 50708,
190 "automation-load-latch-preset-18-for-all-tracks": 50581,
191 "automation-load-latch-preset-18-for-selected-tracks": 50709,
192 "automation-load-latch-preset-19-for-all-tracks": 50582,
193 "automation-load-latch-preset-19-for-selected-tracks": 50710,
194 "automation-load-latch-preset-2-for-all-tracks": 50565,
195 "automation-load-latch-preset-2-for-selected-tracks": 50693,
196 "automation-load-latch-preset-20-for-all-tracks": 50583,
197 "automation-load-latch-preset-20-for-selected-tracks": 50711,
198 "automation-load-latch-preset-21-for-all-tracks": 50584,
199 "automation-load-latch-preset-21-for-selected-tracks": 50712,
200 "automation-load-latch-preset-22-for-all-tracks": 50585,
201 "automation-load-latch-preset-22-for-selected-tracks": 50713,
202 "automation-load-latch-preset-23-for-all-tracks": 50586,
203 "automation-load-latch-preset-23-for-selected-tracks": 50714,
204 "automation-load-latch-preset-24-for-all-tracks": 50587,
205 "automation-load-latch-preset-24-for-selected-tracks": 50715,
206 "automation-load-latch-preset-25-for-all-tracks": 50588,
207 "automation-load-latch-preset-25-for-selected-tracks": 50716,
208 "automation-load-latch-preset-26-for-all-tracks": 50589,
209 "automation-load-latch-preset-26-for-selected-tracks": 50717,
210 "automation-load-latch-preset-27-for-all-tracks": 50590,
211 "automation-load-latch-preset-27-for-selected-tracks": 50718,
212 "automation-load-latch-preset-28-for-all-tracks": 50591,
213 "automation-load-latch-preset-28-for-selected-tracks": 50719,
214 "automation-load-latch-preset-29-for-all-tracks": 50592,
215 "automation-load-latch-preset-29-for-selected-tracks": 50720,
216 "automation-load-latch-preset-3-for-all-tracks": 50566,
217 "automation-load-latch-preset-3-for-selected-tracks": 50694,
218 "automation-load-latch-preset-30-for-all-tracks": 50593,
219 "automation-load-latch-preset-30-for-selected-tracks": 50721,
220 "automation-load-latch-preset-31-for-all-tracks": 50594,
221 "automation-load-latch-preset-31-for-selected-tracks": 50722,
222 "automation-load-latch-preset-32-for-all-tracks": 50595,
223 "automation-load-latch-preset-32-for-selected-tracks": 50723,
224 "automation-load-latch-preset-33-for-all-tracks": 50596,
225 "automation-load-latch-preset-33-for-selected-tracks": 50724,
226 "automation-load-latch-preset-34-for-all-tracks": 50597,
227 "automation-load-latch-preset-34-for-selected-tracks": 50725,
228 "automation-load-latch-preset-35-for-all-tracks": 50598,
229 "automation-load-latch-preset-35-for-selected-tracks": 50726,
230 "automation-load-latch-preset-36-for-all-tracks": 50599,
231 "automation-load-latch-preset-36-for-selected-tracks": 50727,
232 "automation-load-latch-preset-37-for-all-tracks": 50600,
233 "automation-load-latch-preset-37-for-selected-tracks": 50728,
234 "automation-load-latch-preset-38-for-all-tracks": 50601,
235 "automation-load-latch-preset-38-for-selected-tracks": 50729,
236 "automation-load-latch-preset-39-for-all-tracks": 50602,
237 "automation-load-latch-preset-39-for-selected-tracks": 50730,
238 "automation-load-latch-preset-4-for-all-tracks": 50567,
239 "automation-load-latch-preset-4-for-selected-tracks": 50695,
240 "automation-load-latch-preset-40-for-all-tracks": 50603,
241 "automation-load-latch-preset-40-for-selected-tracks": 50731,
242 "automation-load-latch-preset-41-for-all-tracks": 50604,
243 "automation-load-latch-preset-41-for-selected-tracks": 50732,
244 "automation-load-latch-preset-42-for-all-tracks": 50605,
245 "automation-load-latch-preset-42-for-selected-tracks": 50733,
246 "automation-load-latch-preset-43-for-all-tracks": 50606,
247 "automation-load-latch-preset-43-for-selected-tracks": 50734,
248 "automation-load-latch-preset-44-for-all-tracks": 50607,
249 "automation-load-latch-preset-44-for-selected-tracks": 50735,
250 "automation-load-latch-preset-45-for-all-tracks": 50608,
251 "automation-load-latch-preset-45-for-selected-tracks": 50736,
252 "automation-load-latch-preset-46-for-all-tracks": 50609,
253 "automation-load-latch-preset-46-for-selected-tracks": 50737,
254 "automation-load-latch-preset-47-for-all-tracks": 50610,
255 "automation-load-latch-preset-47-for-selected-tracks": 50738,
256 "automation-load-latch-preset-48-for-all-tracks": 50611,
257 "automation-load-latch-preset-48-for-selected-tracks": 50739,
258 "automation-load-latch-preset-49-for-all-tracks": 50612,
259 "automation-load-latch-preset-49-for-selected-tracks": 50740,
260 "automation-load-latch-preset-5-for-all-tracks": 50568,
261 "automation-load-latch-preset-5-for-selected-tracks": 50696,
262 "automation-load-latch-preset-50-for-all-tracks": 50613,
263 "automation-load-latch-preset-50-for-selected-tracks": 50741,
264 "automation-load-latch-preset-51-for-all-tracks": 50614,
265 "automation-load-latch-preset-51-for-selected-tracks": 50742,
266 "automation-load-latch-preset-52-for-all-tracks": 50615,
267 "automation-load-latch-preset-52-for-selected-tracks": 50743,
268 "automation-load-latch-preset-53-for-all-tracks": 50616,
269 "automation-load-latch-preset-53-for-selected-tracks": 50744,
270 "automation-load-latch-preset-54-for-all-tracks": 50617,
271 "automation-load-latch-preset-54-for-selected-tracks": 50745,
272 "automation-load-latch-preset-55-for-all-tracks": 50618,
273 "automation-load-latch-preset-55-for-selected-tracks": 50746,
274 "automation-load-latch-preset-56-for-all-tracks": 50619,
275 "automation-load-latch-preset-56-for-selected-tracks": 50747,
276 "automation-load-latch-preset-57-for-all-tracks": 50620,
277 "automation-load-latch-preset-57-for-selected-tracks": 50748,
278 "automation-load-latch-preset-58-for-all-tracks": 50621,
279 "automation-load-latch-preset-58-for-selected-tracks": 50749,
280 "automation-load-latch-preset-59-for-all-tracks": 50622,
281 "automation-load-latch-preset-59-for-selected-tracks": 50750,
282 "automation-load-latch-preset-6-for-all-tracks": 50569,
283 "automation-load-latch-preset-6-for-selected-tracks": 50697,
284 "automation-load-latch-preset-60-for-all-tracks": 50623,
285 "automation-load-latch-preset-60-for-selected-tracks": 50751,
286 "automation-load-latch-preset-61-for-all-tracks": 50624,
287 "automation-load-latch-preset-61-for-selected-tracks": 50752,
288 "automation-load-latch-preset-62-for-all-tracks": 50625,
289 "automation-load-latch-preset-62-for-selected-tracks": 50753,
290 "automation-load-latch-preset-63-for-all-tracks": 50626,
291 "automation-load-latch-preset-63-for-selected-tracks": 50754,
292 "automation-load-latch-preset-64-for-all-tracks": 50627,
293 "automation-load-latch-preset-64-for-selected-tracks": 50755,
294 "automation-load-latch-preset-7-for-all-tracks": 50570,
295 "automation-load-latch-preset-7-for-selected-tracks": 50698,
296 "automation-load-latch-preset-8-for-all-tracks": 50571,
297 "automation-load-latch-preset-8-for-selected-tracks": 50699,
298 "automation-load-latch-preset-9-for-all-tracks": 50572,
299 "automation-load-latch-preset-9-for-selected-tracks": 50700,
300 "automation-restore-all-saved-track-envelope-latches": 43562,
301 "automation-restore-saved-track-envelope-latches": 43564,
302 "automation-save-and-clear-all-track-envelope-latches": 43561,
303 "automation-save-and-clear-all-track-envelope-latches-if-any-otherwise-restore-saved-latches": 43565,
304 "automation-save-and-clear-track-envelope-latches": 43563,
305 "automation-save-and-clear-track-envelope-latches-if-any-otherwise-restore-saved-latches": 43566,
306 "automation-save-latch-preset-1-for-all-tracks": 50500,
307 "automation-save-latch-preset-1-for-selected-tracks": 50628,
308 "automation-save-latch-preset-10-for-all-tracks": 50509,
309 "automation-save-latch-preset-10-for-selected-tracks": 50637,
310 "automation-save-latch-preset-11-for-all-tracks": 50510,
311 "automation-save-latch-preset-11-for-selected-tracks": 50638,
312 "automation-save-latch-preset-12-for-all-tracks": 50511,
313 "automation-save-latch-preset-12-for-selected-tracks": 50639,
314 "automation-save-latch-preset-13-for-all-tracks": 50512,
315 "automation-save-latch-preset-13-for-selected-tracks": 50640,
316 "automation-save-latch-preset-14-for-all-tracks": 50513,
317 "automation-save-latch-preset-14-for-selected-tracks": 50641,
318 "automation-save-latch-preset-15-for-all-tracks": 50514,
319 "automation-save-latch-preset-15-for-selected-tracks": 50642,
320 "automation-save-latch-preset-16-for-all-tracks": 50515,
321 "automation-save-latch-preset-16-for-selected-tracks": 50643,
322 "automation-save-latch-preset-17-for-all-tracks": 50516,
323 "automation-save-latch-preset-17-for-selected-tracks": 50644,
324 "automation-save-latch-preset-18-for-all-tracks": 50517,
325 "automation-save-latch-preset-18-for-selected-tracks": 50645,
326 "automation-save-latch-preset-19-for-all-tracks": 50518,
327 "automation-save-latch-preset-19-for-selected-tracks": 50646,
328 "automation-save-latch-preset-2-for-all-tracks": 50501,
329 "automation-save-latch-preset-2-for-selected-tracks": 50629,
330 "automation-save-latch-preset-20-for-all-tracks": 50519,
331 "automation-save-latch-preset-20-for-selected-tracks": 50647,
332 "automation-save-latch-preset-21-for-all-tracks": 50520,
333 "automation-save-latch-preset-21-for-selected-tracks": 50648,
334 "automation-save-latch-preset-22-for-all-tracks": 50521,
335 "automation-save-latch-preset-22-for-selected-tracks": 50649,
336 "automation-save-latch-preset-23-for-all-tracks": 50522,
337 "automation-save-latch-preset-23-for-selected-tracks": 50650,
338 "automation-save-latch-preset-24-for-all-tracks": 50523,
339 "automation-save-latch-preset-24-for-selected-tracks": 50651,
340 "automation-save-latch-preset-25-for-all-tracks": 50524,
341 "automation-save-latch-preset-25-for-selected-tracks": 50652,
342 "automation-save-latch-preset-26-for-all-tracks": 50525,
343 "automation-save-latch-preset-26-for-selected-tracks": 50653,
344 "automation-save-latch-preset-27-for-all-tracks": 50526,
345 "automation-save-latch-preset-27-for-selected-tracks": 50654,
346 "automation-save-latch-preset-28-for-all-tracks": 50527,
347 "automation-save-latch-preset-28-for-selected-tracks": 50655,
348 "automation-save-latch-preset-29-for-all-tracks": 50528,
349 "automation-save-latch-preset-29-for-selected-tracks": 50656,
350 "automation-save-latch-preset-3-for-all-tracks": 50502,
351 "automation-save-latch-preset-3-for-selected-tracks": 50630,
352 "automation-save-latch-preset-30-for-all-tracks": 50529,
353 "automation-save-latch-preset-30-for-selected-tracks": 50657,
354 "automation-save-latch-preset-31-for-all-tracks": 50530,
355 "automation-save-latch-preset-31-for-selected-tracks": 50658,
356 "automation-save-latch-preset-32-for-all-tracks": 50531,
357 "automation-save-latch-preset-32-for-selected-tracks": 50659,
358 "automation-save-latch-preset-33-for-all-tracks": 50532,
359 "automation-save-latch-preset-33-for-selected-tracks": 50660,
360 "automation-save-latch-preset-34-for-all-tracks": 50533,
361 "automation-save-latch-preset-34-for-selected-tracks": 50661,
362 "automation-save-latch-preset-35-for-all-tracks": 50534,
363 "automation-save-latch-preset-35-for-selected-tracks": 50662,
364 "automation-save-latch-preset-36-for-all-tracks": 50535,
365 "automation-save-latch-preset-36-for-selected-tracks": 50663,
366 "automation-save-latch-preset-37-for-all-tracks": 50536,
367 "automation-save-latch-preset-37-for-selected-tracks": 50664,
368 "automation-save-latch-preset-38-for-all-tracks": 50537,
369 "automation-save-latch-preset-38-for-selected-tracks": 50665,
370 "automation-save-latch-preset-39-for-all-tracks": 50538,
371 "automation-save-latch-preset-39-for-selected-tracks": 50666,
372 "automation-save-latch-preset-4-for-all-tracks": 50503,
373 "automation-save-latch-preset-4-for-selected-tracks": 50631,
374 "automation-save-latch-preset-40-for-all-tracks": 50539,
375 "automation-save-latch-preset-40-for-selected-tracks": 50667,
376 "automation-save-latch-preset-41-for-all-tracks": 50540,
377 "automation-save-latch-preset-41-for-selected-tracks": 50668,
378 "automation-save-latch-preset-42-for-all-tracks": 50541,
379 "automation-save-latch-preset-42-for-selected-tracks": 50669,
380 "automation-save-latch-preset-43-for-all-tracks": 50542,
381 "automation-save-latch-preset-43-for-selected-tracks": 50670,
382 "automation-save-latch-preset-44-for-all-tracks": 50543,
383 "automation-save-latch-preset-44-for-selected-tracks": 50671,
384 "automation-save-latch-preset-45-for-all-tracks": 50544,
385 "automation-save-latch-preset-45-for-selected-tracks": 50672,
386 "automation-save-latch-preset-46-for-all-tracks": 50545,
387 "automation-save-latch-preset-46-for-selected-tracks": 50673,
388 "automation-save-latch-preset-47-for-all-tracks": 50546,
389 "automation-save-latch-preset-47-for-selected-tracks": 50674,
390 "automation-save-latch-preset-48-for-all-tracks": 50547,
391 "automation-save-latch-preset-48-for-selected-tracks": 50675,
392 "automation-save-latch-preset-49-for-all-tracks": 50548,
393 "automation-save-latch-preset-49-for-selected-tracks": 50676,
394 "automation-save-latch-preset-5-for-all-tracks": 50504,
395 "automation-save-latch-preset-5-for-selected-tracks": 50632,
396 "automation-save-latch-preset-50-for-all-tracks": 50549,
397 "automation-save-latch-preset-50-for-selected-tracks": 50677,
398 "automation-save-latch-preset-51-for-all-tracks": 50550,
399 "automation-save-latch-preset-51-for-selected-tracks": 50678,
400 "automation-save-latch-preset-52-for-all-tracks": 50551,
401 "automation-save-latch-preset-52-for-selected-tracks": 50679,
402 "automation-save-latch-preset-53-for-all-tracks": 50552,
403 "automation-save-latch-preset-53-for-selected-tracks": 50680,
404 "automation-save-latch-preset-54-for-all-tracks": 50553,
405 "automation-save-latch-preset-54-for-selected-tracks": 50681,
406 "automation-save-latch-preset-55-for-all-tracks": 50554,
407 "automation-save-latch-preset-55-for-selected-tracks": 50682,
408 "automation-save-latch-preset-56-for-all-tracks": 50555,
409 "automation-save-latch-preset-56-for-selected-tracks": 50683,
410 "automation-save-latch-preset-57-for-all-tracks": 50556,
411 "automation-save-latch-preset-57-for-selected-tracks": 50684,
412 "automation-save-latch-preset-58-for-all-tracks": 50557,
413 "automation-save-latch-preset-58-for-selected-tracks": 50685,
414 "automation-save-latch-preset-59-for-all-tracks": 50558,
415 "automation-save-latch-preset-59-for-selected-tracks": 50686,
416 "automation-save-latch-preset-6-for-all-tracks": 50505,
417 "automation-save-latch-preset-6-for-selected-tracks": 50633,
418 "automation-save-latch-preset-60-for-all-tracks": 50559,
419 "automation-save-latch-preset-60-for-selected-tracks": 50687,
420 "automation-save-latch-preset-61-for-all-tracks": 50560,
421 "automation-save-latch-preset-61-for-selected-tracks": 50688,
422 "automation-save-latch-preset-62-for-all-tracks": 50561,
423 "automation-save-latch-preset-62-for-selected-tracks": 50689,
424 "automation-save-latch-preset-63-for-all-tracks": 50562,
425 "automation-save-latch-preset-63-for-selected-tracks": 50690,
426 "automation-save-latch-preset-64-for-all-tracks": 50563,
427 "automation-save-latch-preset-64-for-selected-tracks": 50691,
428 "automation-save-latch-preset-7-for-all-tracks": 50506,
429 "automation-save-latch-preset-7-for-selected-tracks": 50634,
430 "automation-save-latch-preset-8-for-all-tracks": 50507,
431 "automation-save-latch-preset-8-for-selected-tracks": 50635,
432 "automation-save-latch-preset-9-for-all-tracks": 50508,
433 "automation-save-latch-preset-9-for-selected-tracks": 50636,
434 "automation-set-all-tracks-automation-mode-to-latch": 40266,
435 "automation-set-all-tracks-automation-mode-to-latch-preview": 42024,
436 "automation-set-all-tracks-automation-mode-to-read": 40086,
437 "automation-set-all-tracks-automation-mode-to-touch": 40087,
438 "automation-set-all-tracks-automation-mode-to-trim-read": 40088,
439 "automation-set-all-tracks-automation-mode-to-write": 40090,
440 "automation-set-track-automation-mode-to-latch": 40404,
441 "automation-set-track-automation-mode-to-latch-preview": 42023,
442 "automation-set-track-automation-mode-to-read": 40401,
443 "automation-set-track-automation-mode-to-touch": 40402,
444 "automation-set-track-automation-mode-to-trim-read": 40400,
445 "automation-set-track-automation-mode-to-write": 40403,
446 "automation-toggle-track-between-touch-and-trim-read-modes": 41109,
447 "automation-unarm-all-envelopes": 41163,
448 "automation-write-current-values-for-actively-writing-envelopes-from-cursor-to-end-of-project": 42015,
449 "automation-write-current-values-for-actively-writing-envelopes-from-cursor-to-first-touch-position": 42016,
450 "automation-write-current-values-for-actively-writing-envelopes-from-cursor-to-start-of-project": 42014,
451 "automation-write-current-values-for-actively-writing-envelopes-to-entire-envelope": 42017,
452 "automation-write-current-values-for-actively-writing-envelopes-to-time-selection": 42013,
453 "automation-write-current-values-for-all-writing-envelopes-from-cursor-to-end-of-project": 41162,
454 "automation-write-current-values-for-all-writing-envelopes-from-cursor-to-start-of-project": 41161,
455 "automation-write-current-values-for-all-writing-envelopes-to-time-selection": 41160,
456 "big-clock-plus-extended-display-recording-pass-markers-etc": 1101,
457 "calculate-loudness-of-master-mix-via-dry-run-render": 42440,
458 "calculate-loudness-of-master-mix-within-time-selection-via-dry-run-render": 42441,
459 "calculate-loudness-of-selected-items-including-take-and-track-fx-and-settings-via-dry-run-render": 42437,
460 "calculate-loudness-of-selected-items-source-media-via-dry-run-render": 42468,
461 "calculate-loudness-of-selected-tracks-via-dry-run-render": 42438,
462 "calculate-loudness-of-selected-tracks-within-time-selection-via-dry-run-render": 42439,
463 "calculate-mono-loudness-of-selected-tracks-via-dry-run-render": 42447,
464 "calculate-mono-loudness-of-selected-tracks-within-time-selection-via-dry-run-render": 42448,
465 "calculate-transient-guides": 42028,
466 "calculate-transient-guides-for-visible-areas-in-items": 42029,
467 "clear-project-recording-tag-rectag-wildcard": 43465,
468 "clear-tempo-envelope": 42395,
469 "clear-transient-guides": 42027,
470 "close-all-projects-but-current": 41922,
471 "close-current-project-tab": 40860,
472 "colors-reset-random-color-generator": 41343,
473 "comp-takes-activate-next-comp": 41376,
474 "comp-takes-activate-previous-comp": 41375,
475 "comp-takes-choose-active-comp-for-item-under-mouse-and-all-other-items-in-the-comp": 41382,
476 "comp-takes-crop-list-to-active-comp": 41379,
477 "comp-takes-move-active-comp-to-top-lane": 41378,
478 "comp-takes-remove-active-comp-from-list": 41374,
479 "comp-takes-save-rename-active-comp": 41373,
480 "comp-takes-toggle-select-last-comp-a-b": 41377,
481 "control-surface-refresh-all-surfaces": 41743,
482 "convert-active-take-midi-to-in-project-midi-source-data": 40684,
483 "convert-active-take-midi-to-mid-file-reference": 40685,
484 "create-measure-from-time-selection-detect-tempo-detect-number-of-measures": 40338,
485 "create-measure-from-time-selection-detect-tempo-try-to-create-single-measure": 42407,
486 "create-measure-from-time-selection-new-time-signature": 40801,
487 "developer-debug-console": 41075,
488 "developer-write-c-plus-plus-api-functions-header": 41064,
489 "dock-undock-currently-focused-dockable-window-or-attach-unattach-focused-docker": 41172,
490 "docker-activate-next-tab": 41624,
491 "docker-activate-previous-tab": 41625,
492 "docker-show-in-bottom-of-main-window": 41598,
493 "docker-show-in-left-of-main-window": 41599,
494 "docker-show-in-right-of-main-window": 41601,
495 "docker-show-in-top-of-main-window": 41600,
496 "dockers-compact-when-small-and-single-tab": 41691,
497 "edit-copy-items": 40698,
498 "edit-copy-items-tracks-envelope-points-depending-on-focus-ignoring-time-selection": 40057,
499 "edit-copy-items-tracks-envelope-points-depending-on-focus-within-time-selection-if-any-smart-copy": 41383,
500 "edit-cut-items": 40699,
501 "edit-cut-items-tracks-envelope-points-depending-on-focus-ignoring-time-selection": 40059,
502 "edit-cut-items-tracks-envelope-points-depending-on-focus-within-time-selection-if-any-smart-cut": 41384,
503 "edit-delete-notes-of-less-than-1-128-note-in-length-in-selected-midi-items": 41738,
504 "edit-delete-notes-of-less-than-1-16-note-in-length-in-selected-midi-items": 41735,
505 "edit-delete-notes-of-less-than-1-256-note-in-length-in-selected-midi-items": 41739,
506 "edit-delete-notes-of-less-than-1-32-note-in-length-in-selected-midi-items": 41736,
507 "edit-delete-notes-of-less-than-1-64-note-in-length-in-selected-midi-items": 41737,
508 "edit-delete-notes-of-less-than-1-8-note-in-length-in-selected-midi-items": 41734,
509 "edit-delete-trailing-notes-of-less-than-1-128-note-in-length-in-selected-midi-items": 41732,
510 "edit-delete-trailing-notes-of-less-than-1-16-note-in-length-in-selected-midi-items": 41729,
511 "edit-delete-trailing-notes-of-less-than-1-256-note-in-length-in-selected-midi-items": 41733,
512 "edit-delete-trailing-notes-of-less-than-1-32-note-in-length-in-selected-midi-items": 41730,
513 "edit-delete-trailing-notes-of-less-than-1-64-note-in-length-in-selected-midi-items": 41731,
514 "edit-delete-trailing-notes-of-less-than-1-8-note-in-length-in-selected-midi-items": 41728,
515 "edit-dynamic-split-items": 40760,
516 "edit-dynamic-split-items-using-most-recent-settings": 42951,
517 "edit-redo": 40030,
518 "edit-undo": 40029,
519 "envelope-add-edge-points-to-automation-item": 42209,
520 "envelope-add-edit-envelope-point-value-at-cursor": 41987,
521 "envelope-add-edit-envelope-point-value-exactly-at-cursor": 40152,
522 "envelope-apply-all-vcas-from-selected-tracks-to-grouped-tracks-and-reset-volume-pan-mute": 41982,
523 "envelope-apply-all-vcas-to-selected-tracks-and-remove-from-vca-groups": 41981,
524 "envelope-automation-item-properties": 42090,
525 "envelope-automation-items-connect-to-the-underlying-envelope-on-both-sides": 42223,
526 "envelope-automation-items-connect-to-the-underlying-envelope-on-the-right-side": 42222,
527 "envelope-automation-items-do-not-connect-to-the-underlying-envelope": 42221,
528 "envelope-bypass-underlying-envelope-outside-of-automation-items": 42224,
529 "envelope-chase-non-fx-envelope-to-automation-items-when-underlying-envelope-is-bypassed": 42345,
530 "envelope-clear-or-remove-envelope": 40065,
531 "envelope-convert-all-project-automation-to-automation-items": 42207,
532 "envelope-copy-points-within-time-selection": 40324,
533 "envelope-copy-selected-points": 40335,
534 "envelope-cut-points-within-time-selection": 40325,
535 "envelope-cut-selected-points": 40336,
536 "envelope-decrease-bezier-tension-for-selected-points-by-25-percent": 41125,
537 "envelope-decrease-bezier-tension-for-selected-points-by-5-percent": 41123,
538 "envelope-delete-all-points-in-time-selection": 40089,
539 "envelope-delete-all-selected-points": 40333,
540 "envelope-delete-automation-items": 42086,
541 "envelope-delete-automation-items-preserve-points": 42088,
542 "envelope-duplicate-and-pool-automation-items": 42085,
543 "envelope-duplicate-automation-items": 42083,
544 "envelope-glue-automation-items": 42089,
545 "envelope-hide-all-envelopes-for-all-tracks": 41150,
546 "envelope-hide-all-envelopes-for-tracks": 40889,
547 "envelope-increase-bezier-tension-for-selected-points-by-25-percent": 41124,
548 "envelope-increase-bezier-tension-for-selected-points-by-5-percent": 41122,
549 "envelope-insert-4-envelope-points-at-time-selection": 40726,
550 "envelope-insert-automation-item": 42082,
551 "envelope-insert-new-point-at-current-position-do-not-remove-nearby-points": 40106,
552 "envelope-insert-new-point-at-current-position-remove-nearby-points": 40915,
553 "envelope-insert-new-point-at-current-position-to-all-visible-track-envelopes-do-not-remove-nearby-points": 40064,
554 "envelope-insert-new-point-at-current-position-to-all-visible-track-envelopes-remove-nearby-points": 41126,
555 "envelope-invert-selected-points": 40334,
556 "envelope-load-automation-item": 42093,
557 "envelope-mute-automation-items": 42211,
558 "envelope-obey-project-default-setting-to-bypass-underlying-envelope-outside-of-automation-items": 42215,
559 "envelope-reduce-number-of-points": 40887,
560 "envelope-reduce-number-of-points-by-half": 42199,
561 "envelope-reduce-number-of-points-by-half-within-time-selection": 42201,
562 "envelope-reduce-number-of-selected-points-by-half": 42208,
563 "envelope-remove-automation-items-from-pool-unpool": 42084,
564 "envelope-remove-unnecessary-points": 43588,
565 "envelope-remove-unnecessary-points-within-time-selection": 43589,
566 "envelope-remove-unnecessary-selected-points": 43590,
567 "envelope-rename-automation-item": 42091,
568 "envelope-reset-selected-points-to-zero-center": 40415,
569 "envelope-reverse-points": 42200,
570 "envelope-save-automation-item": 42092,
571 "envelope-select-all-points": 40332,
572 "envelope-select-points-in-time-selection": 40330,
573 "envelope-set-default-point-shape-to-bezier": 40681,
574 "envelope-set-default-point-shape-to-fast-end": 40431,
575 "envelope-set-default-point-shape-to-fast-start": 40430,
576 "envelope-set-default-point-shape-to-linear": 40187,
577 "envelope-set-default-point-shape-to-slow-start-end": 40425,
578 "envelope-set-default-point-shape-to-square": 40188,
579 "envelope-set-loop-points-to-automation-item": 42198,
580 "envelope-set-shape-of-selected-points-to-bezier": 40683,
581 "envelope-set-shape-of-selected-points-to-fast-end": 40429,
582 "envelope-set-shape-of-selected-points-to-fast-start": 40428,
583 "envelope-set-shape-of-selected-points-to-linear": 40189,
584 "envelope-set-shape-of-selected-points-to-slow-start-end": 40424,
585 "envelope-set-shape-of-selected-points-to-square": 40190,
586 "envelope-set-time-selection-to-automation-item": 42197,
587 "envelope-show-all-active-envelopes-for-tracks": 40888,
588 "envelope-show-all-envelopes-for-all-tracks": 41149,
589 "envelope-show-all-envelopes-for-tracks": 41148,
590 "envelope-split-automation-items": 42087,
591 "envelope-toggle-automation-item-loop": 42196,
592 "envelope-toggle-bypass-for-selected-envelope": 40883,
593 "envelope-toggle-display-all-visible-envelopes-in-lanes-for-tracks": 40891,
594 "envelope-toggle-display-in-separate-lane-for-selected-envelope": 40851,
595 "envelope-toggle-hide-display-selected-envelope": 40884,
596 "envelope-toggle-record-arm-for-selected-envelope": 40863,
597 "envelope-toggle-select-unselect-all-points": 41595,
598 "envelope-toggle-show-all-active-envelopes-for-all-tracks": 40926,
599 "envelope-toggle-show-all-active-envelopes-for-tracks": 40890,
600 "envelope-toggle-show-all-envelopes-for-all-tracks": 41152,
601 "envelope-toggle-show-all-envelopes-for-tracks": 41151,
602 "envelope-unselect-clear-selection-of-all-points": 40331,
603 "envelopes-move-selected-points-down-a-little-bit": 41181,
604 "envelopes-move-selected-points-down-a-tiny-bit": 42382,
605 "envelopes-move-selected-points-left-a-little-bit": 41176,
606 "envelopes-move-selected-points-left-by-grid": 41178,
607 "envelopes-move-selected-points-right-a-little-bit": 41177,
608 "envelopes-move-selected-points-right-by-grid": 41179,
609 "envelopes-move-selected-points-up-a-little-bit": 41180,
610 "envelopes-move-selected-points-up-a-tiny-bit": 42381,
611 "envelopes-view-envelopes-for-last-touched-track-item": 40019,
612 "export-track-lyrics": 42071,
613 "file-add-project-to-render-queue-using-the-most-recent-render-settings": 41823,
614 "file-batch-file-converter": 41076,
615 "file-choose-project-s-to-open": 43697,
616 "file-clean-current-project-directory": 40098,
617 "file-close-all-projects": 40886,
618 "file-consolidate-tracks": 40185,
619 "file-dry-run-render-project-using-the-most-recent-render-settings": 43349,
620 "file-duplicate-project-in-new-tab": 43645,
621 "file-export-configuration": 41568,
622 "file-export-project-midi": 40849,
623 "file-import-configuration": 41569,
624 "file-new-project": 40023,
625 "file-open-project": 40025,
626 "file-open-render-queue": 40929,
627 "file-open-render-statistics-charts-from-most-recent-render-in-web-browser": 42497,
628 "file-open-render-statistics-charts-from-most-recent-render-in-web-browser-hide-file-paths": 42510,
629 "file-open-render-statistics-charts-from-most-recent-render-in-web-browser-hide-file-paths-and-project-regions-markers":
630 43343,
631 "file-open-render-statistics-charts-from-most-recent-render-in-web-browser-hide-project-regions-markers": 43342,
632 "file-project-settings": 40021,
633 "file-quit-reaper": 40004,
634 "file-render-project-to-disk": 40015,
635 "file-render-project-using-the-most-recent-render-settings": 41824,
636 "file-render-project-using-the-most-recent-render-settings-auto-close-render-dialog": 42230,
637 "file-render-project-using-the-most-recent-render-settings-with-a-new-target-file-name": 41855,
638 "file-save-all-projects": 40897,
639 "file-save-copy-of-project-as-prompt-with-current-name": 43578,
640 "file-save-copy-of-project-as-prompt-with-incremented-project-name": 42347,
641 "file-save-copy-of-project-automatically-increment-project-name": 42346,
642 "file-save-live-output-to-disk-bounce": 40017,
643 "file-save-live-output-to-disk-bounce-using-the-most-recent-bounce-settings": 42317,
644 "file-save-new-version-of-project-automatically-increment-project-name": 41895,
645 "file-save-project": 40026,
646 "file-save-project-and-render-rpp-prox": 42332,
647 "file-save-project-as": 40022,
648 "file-save-project-as-template": 40394,
649 "file-show-project-render-metadata-window": 42397,
650 "file-spawn-new-instance-of-reaper": 40063,
651 "fixed-lane-comp-area-add-comp-area-at-time-selection-for-lane-at-mouse": 42657,
652 "fixed-lane-comp-area-add-comp-area-between-previous-and-next-comp-areas-for-lane-at-mouse": 42599,
653 "fixed-lane-comp-area-add-comp-area-from-mouse-position-to-next-area-or-end-of-media": 42613,
654 "fixed-lane-comp-area-delete-comp-area": 42642,
655 "fixed-lane-comp-area-delete-comp-area-at-mouse": 42643,
656 "fixed-lane-comp-area-delete-comp-area-at-mouse-but-not-media-items": 42644,
657 "fixed-lane-comp-area-delete-comp-area-but-not-media-items": 42473,
658 "fixed-lane-comp-area-delete-comp-area-edge": 42496,
659 "fixed-lane-comp-area-delete-comp-area-edge-at-mouse": 42595,
660 "fixed-lane-comp-area-move-comp-area-at-mouse-down": 42492,
661 "fixed-lane-comp-area-move-comp-area-at-mouse-to-lane-under-mouse": 42493,
662 "fixed-lane-comp-area-move-comp-area-at-mouse-up": 42491,
663 "fixed-lane-comp-area-move-comp-area-down-for-selected-items": 42708,
664 "fixed-lane-comp-area-move-comp-area-up-for-selected-items": 42707,
665 "fixed-lane-comp-area-move-down": 41083,
666 "fixed-lane-comp-area-move-up": 41082,
667 "fixed-lane-comp-area-set-loop-points-to-comp-area": 42495,
668 "fixed-lane-comp-area-set-loop-points-to-comp-area-at-mouse": 42504,
669 "fixed-lane-comp-area-set-loop-points-to-comp-area-at-mouse-half-second-preroll-postroll": 42711,
670 "fixed-lane-comp-area-set-loop-points-to-comp-area-at-mouse-one-second-preroll-postroll": 42712,
671 "fixed-lane-comp-area-split-comp-area-at-edit-cursor": 42600,
672 "fixed-lane-comp-area-split-comp-area-at-mouse-position": 42641,
673 "fixed-lane-comp-area-split-comp-area-at-mouse-position-ignoring-snap": 40146,
674 "fixed-lane-comp-area-split-media-items-at-comp-area-edges": 42602,
675 "fixed-lane-comp-area-split-media-items-at-edges-of-comp-area-at-mouse": 42603,
676 "fully-unload-unloaded-vsts": 41204,
677 "fx-activate-bypass-track-take-envelope-for-last-touched-fx-parameter": 41983,
678 "fx-arm-track-take-envelope-for-last-touched-fx-parameter": 41984,
679 "fx-auto-float-new-fx-windows": 41078,
680 "fx-clear-delta-solo-for-all-fx-on-selected-tracks": 42466,
681 "fx-clear-delta-solo-for-all-project-fx": 42467,
682 "fx-delete-all-track-fx-on-selected-tracks": 43698,
683 "fx-hide-all-fx-embedded-ui-in-tcp-selected-tracks": 42341,
684 "fx-set-alias-for-last-touched-fx-parameter": 41145,
685 "fx-set-all-fx-online-one-at-a-time": 43689,
686 "fx-set-midi-learn-for-last-touched-fx-parameter": 41144,
687 "fx-show-all-fx-embedded-ui-in-tcp-selected-tracks": 42340,
688 "fx-show-hide-track-control-for-last-touched-fx-parameter": 41141,
689 "fx-show-hide-track-take-envelope-for-last-touched-fx-parameter": 41142,
690 "fx-show-last-focused-fx-embedded-ui-in-mcp": 42372,
691 "fx-show-last-focused-fx-embedded-ui-in-tcp": 42335,
692 "fx-show-next-single-fx-embedded-ui-in-tcp-selected-tracks": 42342,
693 "fx-show-parameter-modulation-link-for-last-touched-fx-parameter": 41143,
694 "fx-show-previous-single-fx-embedded-ui-in-tcp-selected-tracks": 42343,
695 "fx-toggle-delta-solo-for-last-focused-fx": 42455,
696 "fx-toggle-map-to-container-parameter-for-last-touched-fx-parameter": 42650,
697 "fx-toggle-preference-auto-bypass-fx-that-require-pdc-on-record-arm-affected-tracks": 43150,
698 "global-automation-override-all-automation-in-latch-mode": 40881,
699 "global-automation-override-all-automation-in-latch-preview-mode": 42022,
700 "global-automation-override-all-automation-in-read-mode": 40879,
701 "global-automation-override-all-automation-in-touch-mode": 40880,
702 "global-automation-override-all-automation-in-trim-read-mode": 40878,
703 "global-automation-override-all-automation-in-write-mode": 40882,
704 "global-automation-override-bypass-all-automation": 40885,
705 "global-automation-override-no-override-set-automation-modes-per-track": 40876,
706 "global-automation-override-toggle-bypass-all-automation": 40908,
707 "go-to-end-of-loop": 40633,
708 "go-to-end-of-time-selection": 40631,
709 "go-to-start-of-loop": 40632,
710 "go-to-start-of-time-selection": 40630,
711 "grid-adjust-by-1-1-5": 40782,
712 "grid-adjust-by-1-2": 40783,
713 "grid-adjust-by-1-3": 40784,
714 "grid-adjust-by-1-5": 40785,
715 "grid-adjust-by-2": 40786,
716 "grid-adjust-by-3": 40787,
717 "grid-adjust-swing-grid-midi-cc-mousewheel-only": 969,
718 "grid-divide-arrange-view-vertically-by-measures": 42331,
719 "grid-set-framerate-grid": 40904,
720 "grid-set-measure-grid": 40923,
721 "grid-set-to-1": 40781,
722 "grid-set-to-1-10-1-8-quintuplet": 42002,
723 "grid-set-to-1-12-1-8-triplet": 40777,
724 "grid-set-to-1-128": 41047,
725 "grid-set-to-1-16": 40776,
726 "grid-set-to-1-18": 42001,
727 "grid-set-to-1-2": 40780,
728 "grid-set-to-1-24-1-16-triplet": 41213,
729 "grid-set-to-1-3-1-2-triplet": 42000,
730 "grid-set-to-1-32": 40775,
731 "grid-set-to-1-4": 40779,
732 "grid-set-to-1-48-1-32-triplet": 41212,
733 "grid-set-to-1-5-1-4-quintuplet": 42005,
734 "grid-set-to-1-6-1-4-triplet": 41214,
735 "grid-set-to-1-64": 40774,
736 "grid-set-to-1-7-1-4-septuplet": 42004,
737 "grid-set-to-1-8": 40778,
738 "grid-set-to-1-9": 42003,
739 "grid-set-to-2": 41210,
740 "grid-set-to-2-3-whole-note-triplet": 42007,
741 "grid-set-to-3": 42006,
742 "grid-set-to-4": 41211,
743 "grid-toggle-framerate-grid": 41885,
744 "grid-toggle-measure-grid": 40725,
745 "grid-toggle-swing-grid": 42304,
746 "grid-use-the-same-grid-division-in-arrange-view-and-midi-editor": 42010,
747 "group-enable-group-01": 42511,
748 "group-enable-group-02": 42512,
749 "group-enable-group-03": 42513,
750 "group-enable-group-04": 42514,
751 "group-enable-group-05": 42515,
752 "group-enable-group-06": 42516,
753 "group-enable-group-07": 42517,
754 "group-enable-group-08": 42518,
755 "group-enable-group-09": 42519,
756 "group-enable-group-10": 42520,
757 "group-enable-group-100": 43313,
758 "group-enable-group-101": 43314,
759 "group-enable-group-102": 43315,
760 "group-enable-group-103": 43316,
761 "group-enable-group-104": 43317,
762 "group-enable-group-105": 43318,
763 "group-enable-group-106": 43319,
764 "group-enable-group-107": 43320,
765 "group-enable-group-108": 43321,
766 "group-enable-group-109": 43322,
767 "group-enable-group-11": 42521,
768 "group-enable-group-110": 43323,
769 "group-enable-group-111": 43324,
770 "group-enable-group-112": 43325,
771 "group-enable-group-113": 43326,
772 "group-enable-group-114": 43327,
773 "group-enable-group-115": 43328,
774 "group-enable-group-116": 43329,
775 "group-enable-group-117": 43330,
776 "group-enable-group-118": 43331,
777 "group-enable-group-119": 43332,
778 "group-enable-group-12": 42522,
779 "group-enable-group-120": 43333,
780 "group-enable-group-121": 43334,
781 "group-enable-group-122": 43335,
782 "group-enable-group-123": 43336,
783 "group-enable-group-124": 43337,
784 "group-enable-group-125": 43338,
785 "group-enable-group-126": 43339,
786 "group-enable-group-127": 43340,
787 "group-enable-group-128": 43341,
788 "group-enable-group-13": 42523,
789 "group-enable-group-14": 42524,
790 "group-enable-group-15": 42525,
791 "group-enable-group-16": 42526,
792 "group-enable-group-17": 42527,
793 "group-enable-group-18": 42528,
794 "group-enable-group-19": 42529,
795 "group-enable-group-20": 42530,
796 "group-enable-group-21": 42531,
797 "group-enable-group-22": 42532,
798 "group-enable-group-23": 42533,
799 "group-enable-group-24": 42534,
800 "group-enable-group-25": 42535,
801 "group-enable-group-26": 42536,
802 "group-enable-group-27": 42537,
803 "group-enable-group-28": 42538,
804 "group-enable-group-29": 42539,
805 "group-enable-group-30": 42540,
806 "group-enable-group-31": 42541,
807 "group-enable-group-32": 42542,
808 "group-enable-group-33": 42543,
809 "group-enable-group-34": 42544,
810 "group-enable-group-35": 42545,
811 "group-enable-group-36": 42546,
812 "group-enable-group-37": 42547,
813 "group-enable-group-38": 42548,
814 "group-enable-group-39": 42549,
815 "group-enable-group-40": 42550,
816 "group-enable-group-41": 42551,
817 "group-enable-group-42": 42552,
818 "group-enable-group-43": 42553,
819 "group-enable-group-44": 42554,
820 "group-enable-group-45": 42555,
821 "group-enable-group-46": 42556,
822 "group-enable-group-47": 42557,
823 "group-enable-group-48": 42558,
824 "group-enable-group-49": 42559,
825 "group-enable-group-50": 42560,
826 "group-enable-group-51": 42561,
827 "group-enable-group-52": 42562,
828 "group-enable-group-53": 42563,
829 "group-enable-group-54": 42564,
830 "group-enable-group-55": 42565,
831 "group-enable-group-56": 42566,
832 "group-enable-group-57": 42567,
833 "group-enable-group-58": 42568,
834 "group-enable-group-59": 42569,
835 "group-enable-group-60": 42570,
836 "group-enable-group-61": 42571,
837 "group-enable-group-62": 42572,
838 "group-enable-group-63": 42573,
839 "group-enable-group-64": 42574,
840 "group-enable-group-65": 43278,
841 "group-enable-group-66": 43279,
842 "group-enable-group-67": 43280,
843 "group-enable-group-68": 43281,
844 "group-enable-group-69": 43282,
845 "group-enable-group-70": 43283,
846 "group-enable-group-71": 43284,
847 "group-enable-group-72": 43285,
848 "group-enable-group-73": 43286,
849 "group-enable-group-74": 43287,
850 "group-enable-group-75": 43288,
851 "group-enable-group-76": 43289,
852 "group-enable-group-77": 43290,
853 "group-enable-group-78": 43291,
854 "group-enable-group-79": 43292,
855 "group-enable-group-80": 43293,
856 "group-enable-group-81": 43294,
857 "group-enable-group-82": 43295,
858 "group-enable-group-83": 43296,
859 "group-enable-group-84": 43297,
860 "group-enable-group-85": 43298,
861 "group-enable-group-86": 43299,
862 "group-enable-group-87": 43300,
863 "group-enable-group-88": 43301,
864 "group-enable-group-89": 43302,
865 "group-enable-group-90": 43303,
866 "group-enable-group-91": 43304,
867 "group-enable-group-92": 43305,
868 "group-enable-group-93": 43306,
869 "group-enable-group-94": 43307,
870 "group-enable-group-95": 43308,
871 "group-enable-group-96": 43309,
872 "group-enable-group-97": 43310,
873 "group-enable-group-98": 43311,
874 "group-enable-group-99": 43312,
875 "group-select-all-tracks-in-group-01": 40804,
876 "group-select-all-tracks-in-group-02": 40805,
877 "group-select-all-tracks-in-group-03": 40806,
878 "group-select-all-tracks-in-group-04": 40807,
879 "group-select-all-tracks-in-group-05": 40808,
880 "group-select-all-tracks-in-group-06": 40809,
881 "group-select-all-tracks-in-group-07": 40810,
882 "group-select-all-tracks-in-group-08": 40811,
883 "group-select-all-tracks-in-group-09": 40812,
884 "group-select-all-tracks-in-group-10": 40813,
885 "group-select-all-tracks-in-group-100": 43249,
886 "group-select-all-tracks-in-group-101": 43250,
887 "group-select-all-tracks-in-group-102": 43251,
888 "group-select-all-tracks-in-group-103": 43252,
889 "group-select-all-tracks-in-group-104": 43253,
890 "group-select-all-tracks-in-group-105": 43254,
891 "group-select-all-tracks-in-group-106": 43255,
892 "group-select-all-tracks-in-group-107": 43256,
893 "group-select-all-tracks-in-group-108": 43257,
894 "group-select-all-tracks-in-group-109": 43258,
895 "group-select-all-tracks-in-group-11": 40814,
896 "group-select-all-tracks-in-group-110": 43259,
897 "group-select-all-tracks-in-group-111": 43260,
898 "group-select-all-tracks-in-group-112": 43261,
899 "group-select-all-tracks-in-group-113": 43262,
900 "group-select-all-tracks-in-group-114": 43263,
901 "group-select-all-tracks-in-group-115": 43264,
902 "group-select-all-tracks-in-group-116": 43265,
903 "group-select-all-tracks-in-group-117": 43266,
904 "group-select-all-tracks-in-group-118": 43267,
905 "group-select-all-tracks-in-group-119": 43268,
906 "group-select-all-tracks-in-group-12": 40815,
907 "group-select-all-tracks-in-group-120": 43269,
908 "group-select-all-tracks-in-group-121": 43270,
909 "group-select-all-tracks-in-group-122": 43271,
910 "group-select-all-tracks-in-group-123": 43272,
911 "group-select-all-tracks-in-group-124": 43273,
912 "group-select-all-tracks-in-group-125": 43274,
913 "group-select-all-tracks-in-group-126": 43275,
914 "group-select-all-tracks-in-group-127": 43276,
915 "group-select-all-tracks-in-group-128": 43277,
916 "group-select-all-tracks-in-group-13": 40816,
917 "group-select-all-tracks-in-group-14": 40817,
918 "group-select-all-tracks-in-group-15": 40818,
919 "group-select-all-tracks-in-group-16": 40819,
920 "group-select-all-tracks-in-group-17": 40820,
921 "group-select-all-tracks-in-group-18": 40821,
922 "group-select-all-tracks-in-group-19": 40822,
923 "group-select-all-tracks-in-group-20": 40823,
924 "group-select-all-tracks-in-group-21": 40824,
925 "group-select-all-tracks-in-group-22": 40825,
926 "group-select-all-tracks-in-group-23": 40826,
927 "group-select-all-tracks-in-group-24": 40827,
928 "group-select-all-tracks-in-group-25": 40828,
929 "group-select-all-tracks-in-group-26": 40829,
930 "group-select-all-tracks-in-group-27": 40830,
931 "group-select-all-tracks-in-group-28": 40831,
932 "group-select-all-tracks-in-group-29": 40832,
933 "group-select-all-tracks-in-group-30": 40833,
934 "group-select-all-tracks-in-group-31": 40834,
935 "group-select-all-tracks-in-group-32": 40835,
936 "group-select-all-tracks-in-group-33": 42237,
937 "group-select-all-tracks-in-group-34": 42238,
938 "group-select-all-tracks-in-group-35": 42239,
939 "group-select-all-tracks-in-group-36": 42240,
940 "group-select-all-tracks-in-group-37": 42241,
941 "group-select-all-tracks-in-group-38": 42242,
942 "group-select-all-tracks-in-group-39": 42243,
943 "group-select-all-tracks-in-group-40": 42244,
944 "group-select-all-tracks-in-group-41": 42245,
945 "group-select-all-tracks-in-group-42": 42246,
946 "group-select-all-tracks-in-group-43": 42247,
947 "group-select-all-tracks-in-group-44": 42248,
948 "group-select-all-tracks-in-group-45": 42249,
949 "group-select-all-tracks-in-group-46": 42250,
950 "group-select-all-tracks-in-group-47": 42251,
951 "group-select-all-tracks-in-group-48": 42252,
952 "group-select-all-tracks-in-group-49": 42253,
953 "group-select-all-tracks-in-group-50": 42254,
954 "group-select-all-tracks-in-group-51": 42255,
955 "group-select-all-tracks-in-group-52": 42256,
956 "group-select-all-tracks-in-group-53": 42257,
957 "group-select-all-tracks-in-group-54": 42258,
958 "group-select-all-tracks-in-group-55": 42259,
959 "group-select-all-tracks-in-group-56": 42260,
960 "group-select-all-tracks-in-group-57": 42261,
961 "group-select-all-tracks-in-group-58": 42262,
962 "group-select-all-tracks-in-group-59": 42263,
963 "group-select-all-tracks-in-group-60": 42264,
964 "group-select-all-tracks-in-group-61": 42265,
965 "group-select-all-tracks-in-group-62": 42266,
966 "group-select-all-tracks-in-group-63": 42267,
967 "group-select-all-tracks-in-group-64": 42268,
968 "group-select-all-tracks-in-group-65": 43214,
969 "group-select-all-tracks-in-group-66": 43215,
970 "group-select-all-tracks-in-group-67": 43216,
971 "group-select-all-tracks-in-group-68": 43217,
972 "group-select-all-tracks-in-group-69": 43218,
973 "group-select-all-tracks-in-group-70": 43219,
974 "group-select-all-tracks-in-group-71": 43220,
975 "group-select-all-tracks-in-group-72": 43221,
976 "group-select-all-tracks-in-group-73": 43222,
977 "group-select-all-tracks-in-group-74": 43223,
978 "group-select-all-tracks-in-group-75": 43224,
979 "group-select-all-tracks-in-group-76": 43225,
980 "group-select-all-tracks-in-group-77": 43226,
981 "group-select-all-tracks-in-group-78": 43227,
982 "group-select-all-tracks-in-group-79": 43228,
983 "group-select-all-tracks-in-group-80": 43229,
984 "group-select-all-tracks-in-group-81": 43230,
985 "group-select-all-tracks-in-group-82": 43231,
986 "group-select-all-tracks-in-group-83": 43232,
987 "group-select-all-tracks-in-group-84": 43233,
988 "group-select-all-tracks-in-group-85": 43234,
989 "group-select-all-tracks-in-group-86": 43235,
990 "group-select-all-tracks-in-group-87": 43236,
991 "group-select-all-tracks-in-group-88": 43237,
992 "group-select-all-tracks-in-group-89": 43238,
993 "group-select-all-tracks-in-group-90": 43239,
994 "group-select-all-tracks-in-group-91": 43240,
995 "group-select-all-tracks-in-group-92": 43241,
996 "group-select-all-tracks-in-group-93": 43242,
997 "group-select-all-tracks-in-group-94": 43243,
998 "group-select-all-tracks-in-group-95": 43244,
999 "group-select-all-tracks-in-group-96": 43245,
1000 "group-select-all-tracks-in-group-97": 43246,
1001 "group-select-all-tracks-in-group-98": 43247,
1002 "group-select-all-tracks-in-group-99": 43248,
1003 "help-about-reaper": 40007,
1004 "help-all-actions": 40845,
1005 "help-check-for-new-versions": 40442,
1006 "help-mouse-modifier-keys-and-action-shortcuts": 40308,
1007 "help-show-mouse-editing-help-in-the-area-beneath-the-track-control-panels": 41345,
1008 "i-o-dialog-close-window-on-enter-key": 41828,
1009 "import-track-lyrics": 42070,
1010 "insert-click-source": 40013,
1011 "insert-dedicated-video-processor-item": 41932,
1012 "insert-empty-item": 40142,
1013 "insert-fx-aui-komplete-kontrol-native-instruments": 55807,
1014 "insert-import-media-files": 40018,
1015 "insert-import-media-files-from-directory": 43673,
1016 "insert-new-midi-item": 40214,
1017 "insert-new-subproject": 41049,
1018 "insert-or-extend-midi-items-to-fill-time-selection": 42069,
1019 "insert-timecode-generator": 40208,
1020 "insert-virtual-instrument-on-new-track": 40701,
1021 "item-add-an-empty-take-after-the-active-take": 41352,
1022 "item-add-an-empty-take-before-the-active-take": 41351,
1023 "item-add-edit-take-marker-at-mouse-position": 42388,
1024 "item-add-edit-take-marker-at-play-position-or-edit-cursor": 42385,
1025 "item-add-edit-take-marker-at-time-selection": 43181,
1026 "item-add-stretch-marker-at-cursor": 41842,
1027 "item-add-stretch-marker-at-mouse-position": 41848,
1028 "item-add-stretch-markers-at-time-selection": 41843,
1029 "item-apply-first-take-fx-to-items": 42686,
1030 "item-apply-first-take-fx-to-items-mono-output": 42688,
1031 "item-apply-first-track-fx-to-items": 42685,
1032 "item-apply-first-track-fx-to-items-mono-output": 42687,
1033 "item-apply-track-take-fx-to-items": 40209,
1034 "item-apply-track-take-fx-to-items-midi-output": 40436,
1035 "item-apply-track-take-fx-to-items-mono-output": 40361,
1036 "item-apply-track-take-fx-to-items-multichannel-output": 41993,
1037 "item-auto-reposition-items-in-free-item-positioning-mode": 40645,
1038 "item-auto-trim-split-items-remove-silence": 40315,
1039 "item-choose-active-take-for-item-under-mouse": 41381,
1040 "item-clear-up-rank-down-rank-markers": 43161,
1041 "item-clear-up-rank-down-rank-markers-for-take-under-mouse": 43162,
1042 "item-clear-up-rank-down-rank-markers-within-time-selection": 43202,
1043 "item-close-item-inline-editors": 41887,
1044 "item-collapse-empty-take": 41747,
1045 "item-convert-embedded-source-transient-information-to-transient-guides": 42380,
1046 "item-copy-items-to-time-selection-trim-loop-to-fit": 41319,
1047 "item-copy-loop-of-selected-area-of-audio-items": 40014,
1048 "item-copy-selected-area-of-items": 40060,
1049 "item-create-chromatic-midi-from-items": 40773,
1050 "item-crossfade-any-overlapping-items": 41059,
1051 "item-crossfade-items-within-time-selection": 40916,
1052 "item-cut-selected-area-of-items": 40307,
1053 "item-cycle-through-crossfade-shapes": 41534,
1054 "item-cycle-through-fade-in-shapes": 41520,
1055 "item-cycle-through-fade-out-shapes": 41527,
1056 "item-cycle-through-up-rank-down-rank-levels-for-active-take-or-last-recording-pass": 43197,
1057 "item-cycle-through-up-rank-down-rank-levels-for-take-marker-at-mouse-position": 43200,
1058 "item-cycle-through-up-rank-down-rank-levels-for-take-marker-at-play-position-or-edit-cursor": 43199,
1059 "item-cycle-through-up-rank-down-rank-levels-for-take-marker-at-time-selection": 43201,
1060 "item-cycle-through-up-rank-down-rank-levels-for-take-or-comp-area-under-mouse": 43198,
1061 "item-delete-all-take-markers": 42387,
1062 "item-delete-take-marker-at-cursor": 42386,
1063 "item-delete-take-marker-at-mouse-position": 42389,
1064 "item-delete-take-markers-in-time-selection": 43182,
1065 "item-delete-takes-for-item-under-mouse-that-are-down-ranked-no-confirm": 43163,
1066 "item-delete-takes-for-item-under-mouse-that-are-not-up-ranked-no-confirm": 43164,
1067 "item-delete-takes-that-are-down-ranked-no-confirm": 42682,
1068 "item-delete-takes-that-are-not-up-ranked-no-confirm": 40229,
1069 "item-disable-default-fadein-fadeout": 41196,
1070 "item-down-rank-active-take-or-last-recording-pass": 42681,
1071 "item-down-rank-take-marker-at-1-second-before-play-position-or-at-edit-cursor-if-not-playing-back": 43175,
1072 "item-down-rank-take-marker-at-2-seconds-before-play-position-or-at-edit-cursor-if-not-playing-back": 43177,
1073 "item-down-rank-take-marker-at-mouse-position": 43160,
1074 "item-down-rank-take-marker-at-play-position-or-edit-cursor": 43158,
1075 "item-down-rank-take-marker-at-time-selection": 43184,
1076 "item-down-rank-take-or-comp-area-under-mouse": 43156,
1077 "item-duplicate-items": 41295,
1078 "item-duplicate-selected-area-of-items": 41296,
1079 "item-edit-close-nudge-set-dialog": 41227,
1080 "item-edit-disable-relative-grid-snap": 41053,
1081 "item-edit-enable-relative-grid-snap": 41052,
1082 "item-edit-grow-left-edge-of-items": 40225,
1083 "item-edit-grow-right-edge-of-items": 40228,
1084 "item-edit-move-contents-of-item-to-edit-cursor": 41308,
1085 "item-edit-move-contents-of-item-under-mouse-to-edit-cursor": 41303,
1086 "item-edit-move-contents-of-items-left": 40123,
1087 "item-edit-move-contents-of-items-right": 40124,
1088 "item-edit-move-duplicate-of-item-to-edit-cursor": 41309,
1089 "item-edit-move-duplicate-of-item-under-mouse-to-edit-cursor": 41304,
1090 "item-edit-move-items-envelope-points-down-one-track-a-bit": 40118,
1091 "item-edit-move-items-envelope-points-left": 40120,
1092 "item-edit-move-items-envelope-points-left-by-grid-size": 40793,
1093 "item-edit-move-items-envelope-points-right": 40119,
1094 "item-edit-move-items-envelope-points-right-by-grid-size": 40794,
1095 "item-edit-move-items-envelope-points-up-one-track-a-bit": 40117,
1096 "item-edit-move-items-left-preserving-timing-of-contents": 40121,
1097 "item-edit-move-items-right-preserving-timing-of-contents": 40122,
1098 "item-edit-move-left-edge-of-item-to-edit-cursor-preserving-item-right-edge": 41306,
1099 "item-edit-move-left-edge-of-item-under-mouse-to-edit-cursor-preserving-item-right-edge": 41301,
1100 "item-edit-move-position-of-item-to-edit-cursor": 41205,
1101 "item-edit-move-position-of-item-under-mouse-to-edit-cursor": 41299,
1102 "item-edit-move-right-edge-of-item-to-edit-cursor-preserving-item-length": 41307,
1103 "item-edit-move-right-edge-of-item-under-mouse-to-edit-cursor-preserving-item-length": 41302,
1104 "item-edit-nudge-left-by-last-nudge-dialog-settings": 41250,
1105 "item-edit-nudge-left-by-saved-nudge-dialog-settings-1": 41279,
1106 "item-edit-nudge-left-by-saved-nudge-dialog-settings-2": 41280,
1107 "item-edit-nudge-left-by-saved-nudge-dialog-settings-3": 41281,
1108 "item-edit-nudge-left-by-saved-nudge-dialog-settings-4": 41282,
1109 "item-edit-nudge-left-by-saved-nudge-dialog-settings-5": 41291,
1110 "item-edit-nudge-left-by-saved-nudge-dialog-settings-6": 41292,
1111 "item-edit-nudge-left-by-saved-nudge-dialog-settings-7": 41293,
1112 "item-edit-nudge-left-by-saved-nudge-dialog-settings-8": 41294,
1113 "item-edit-nudge-right-by-last-nudge-dialog-settings": 41249,
1114 "item-edit-nudge-right-by-saved-nudge-dialog-settings-1": 41275,
1115 "item-edit-nudge-right-by-saved-nudge-dialog-settings-2": 41276,
1116 "item-edit-nudge-right-by-saved-nudge-dialog-settings-3": 41277,
1117 "item-edit-nudge-right-by-saved-nudge-dialog-settings-4": 41278,
1118 "item-edit-nudge-right-by-saved-nudge-dialog-settings-5": 41287,
1119 "item-edit-nudge-right-by-saved-nudge-dialog-settings-6": 41288,
1120 "item-edit-nudge-right-by-saved-nudge-dialog-settings-7": 41289,
1121 "item-edit-nudge-right-by-saved-nudge-dialog-settings-8": 41290,
1122 "item-edit-nudge-set": 41226,
1123 "item-edit-phase-alignment": 43466,
1124 "item-edit-save-nudge-dialog-settings-1": 41271,
1125 "item-edit-save-nudge-dialog-settings-2": 41272,
1126 "item-edit-save-nudge-dialog-settings-3": 41273,
1127 "item-edit-save-nudge-dialog-settings-4": 41274,
1128 "item-edit-save-nudge-dialog-settings-5": 41283,
1129 "item-edit-save-nudge-dialog-settings-6": 41284,
1130 "item-edit-save-nudge-dialog-settings-7": 41285,
1131 "item-edit-save-nudge-dialog-settings-8": 41286,
1132 "item-edit-shrink-left-edge-of-items": 40226,
1133 "item-edit-shrink-right-edge-of-items": 40227,
1134 "item-edit-stretch-marker-at-cursor": 41988,
1135 "item-edit-toggle-nudge-set-dialog": 41228,
1136 "item-edit-toggle-relative-grid-snap": 41054,
1137 "item-edit-trim-left-edge-of-item-to-edit-cursor": 41305,
1138 "item-edit-trim-left-edge-of-item-under-mouse-to-edit-cursor": 41300,
1139 "item-edit-trim-right-edge-of-item-to-edit-cursor": 41311,
1140 "item-edit-trim-right-edge-of-item-under-mouse-to-edit-cursor": 41310,
1141 "item-enable-default-fadein-fadeout": 41195,
1142 "item-explode-midi-note-rows-pitch-to-new-items": 40920,
1143 "item-explode-multichannel-audio-or-midi-to-new-one-channel-items": 40894,
1144 "item-explode-rex-item-into-beat-slices": 41513,
1145 "item-fade-items-in-to-cursor": 40509,
1146 "item-fade-items-out-from-cursor": 40510,
1147 "item-fit-items-to-time-selection-looping-if-needed": 41386,
1148 "item-fit-items-to-time-selection-padding-with-silence-if-needed": 41385,
1149 "item-force-balanced-mode-for-stretch-markers": 42338,
1150 "item-force-no-pre-echo-reduction-mode-for-stretch-markers": 42339,
1151 "item-force-tonal-optimized-mode-for-stretch-markers": 41857,
1152 "item-force-transient-optimized-mode-for-stretch-markers": 42337,
1153 "item-glue-items-auto-increase-channel-count-with-take-fx": 42434,
1154 "item-glue-items-expanding-to-time-selection-if-any": 41588,
1155 "item-glue-items-expanding-to-time-selection-if-any-auto-increase-channel-count-with-take-fx": 42009,
1156 "item-glue-items-expanding-to-time-selection-if-any-including-leading-fade-in-and-trailing-fade-out": 40606,
1157 "item-glue-items-ignoring-time-selection": 40362,
1158 "item-glue-items-ignoring-time-selection-auto-increase-channel-count-with-take-fx": 42008,
1159 "item-glue-items-ignoring-time-selection-including-leading-fade-in-and-trailing-fade-out": 40257,
1160 "item-glue-items-including-leading-fade-in-and-trailing-fade-out": 42433,
1161 "item-glue-items-within-time-selection": 42432,
1162 "item-go-to-nearest-stretch-marker": 41862,
1163 "item-go-to-next-stretch-marker": 41860,
1164 "item-go-to-previous-stretch-marker": 41861,
1165 "item-grouping-group-items": 40032,
1166 "item-grouping-remove-items-from-group": 40033,
1167 "item-grouping-select-all-items-in-groups": 40034,
1168 "item-heal-splits-in-items": 40548,
1169 "item-implode-items-across-tracks-into-items-on-one-track": 40644,
1170 "item-import-item-media-cues-as-project-markers": 40692,
1171 "item-import-media-cues-as-take-markers": 43154,
1172 "item-insert-time-on-tracks-and-paste-items": 41748,
1173 "item-invert-selection": 41115,
1174 "item-maximize-height-of-selected-items-in-free-item-positioning-mode": 42655,
1175 "item-move-active-takes-to-top": 41380,
1176 "item-move-and-stretch-items-to-fit-time-selection": 41206,
1177 "item-move-items-to-subproject-non-destructive-glue": 41996,
1178 "item-move-items-to-time-selection-trim-loop-to-fit": 41320,
1179 "item-move-stretch-and-loop-items-to-fit-time-selection": 41069,
1180 "item-move-to-media-source-preferred-position-bwf-start-offset": 40299,
1181 "item-mute-active-take-of-multitake-item-within-time-selection": 40855,
1182 "item-navigation-move-cursor-left-to-edge-of-item": 40318,
1183 "item-navigation-move-cursor-left-to-nearest-item-edge": 41167,
1184 "item-navigation-move-cursor-right-to-edge-of-item": 40319,
1185 "item-navigation-move-cursor-right-to-nearest-item-edge": 41168,
1186 "item-navigation-move-cursor-to-end-of-items": 41174,
1187 "item-navigation-move-cursor-to-nearest-transient-in-items": 40836,
1188 "item-navigation-move-cursor-to-next-transient-in-items": 40375,
1189 "item-navigation-move-cursor-to-previous-transient-in-items": 40376,
1190 "item-navigation-move-cursor-to-start-of-items": 41173,
1191 "item-navigation-select-and-move-to-item-in-next-track": 40419,
1192 "item-navigation-select-and-move-to-item-in-next-track-without-changing-track-selection": 41140,
1193 "item-navigation-select-and-move-to-item-in-previous-track": 40418,
1194 "item-navigation-select-and-move-to-item-in-previous-track-without-changing-track-selection": 41139,
1195 "item-navigation-select-and-move-to-next-item": 40417,
1196 "item-navigation-select-and-move-to-previous-item": 40416,
1197 "item-nudge-items-volume-1db": 41924,
1198 "item-nudge-items-volume-plus-1db": 41925,
1199 "item-open-associated-project-in-new-tab": 41816,
1200 "item-open-in-built-in-midi-editor-set-default-behavior-in-preferences": 40153,
1201 "item-open-item-copies-in-primary-external-editor": 40132,
1202 "item-open-item-copies-in-secondary-external-editor": 40203,
1203 "item-open-item-inline-editors": 40847,
1204 "item-open-items-in-primary-external-editor": 40109,
1205 "item-open-items-in-secondary-external-editor": 40202,
1206 "item-paste-items-tracks": 42398,
1207 "item-paste-items-tracks-at-mouse-position": 41221,
1208 "item-paste-items-tracks-creating-pooled-ghost-midi-items-and-automation-items-regardless-of-preferences-media-midi-and-preferences-media-automation-settings":
1209 41072,
1210 "item-paste-items-tracks-old-style-handling-of-hidden-tracks": 40058,
1211 "item-propagate-to-all-similarly-named-items": 41979,
1212 "item-propagate-to-similarly-named-items-on-track": 41977,
1213 "item-properties-clear-take-preserve-pitch": 40796,
1214 "item-properties-decrease-item-rate-by-0-6-percent-10-cents": 40520,
1215 "item-properties-decrease-item-rate-by-0-6-percent-10-cents-clear-preserve-pitch": 40800,
1216 "item-properties-decrease-item-rate-by-6-percent-one-semitone": 40518,
1217 "item-properties-decrease-item-rate-by-6-percent-one-semitone-clear-preserve-pitch": 40798,
1218 "item-properties-display-item-beats-ruler-constant-time-signature": 42315,
1219 "item-properties-display-item-beats-ruler-minimal-constant-time-signature": 42359,
1220 "item-properties-display-item-source-time-ruler": 42313,
1221 "item-properties-display-item-source-time-ruler-apply-media-source-bwf-start-offset": 42419,
1222 "item-properties-display-item-source-time-ruler-in-h-m-s-f-format": 42358,
1223 "item-properties-display-item-source-time-ruler-in-h-m-s-f-format-apply-media-source-bwf-start-offset": 42420,
1224 "item-properties-display-item-time-ruler": 42312,
1225 "item-properties-display-item-time-ruler-in-h-m-s-f-format": 42314,
1226 "item-properties-increase-item-rate-by-0-6-percent-10-cents": 40519,
1227 "item-properties-increase-item-rate-by-0-6-percent-10-cents-clear-preserve-pitch": 40799,
1228 "item-properties-increase-item-rate-by-6-percent-one-semitone": 40517,
1229 "item-properties-increase-item-rate-by-6-percent-one-semitone-clear-preserve-pitch": 40797,
1230 "item-properties-item-ruler-settings": 42355,
1231 "item-properties-lock": 40688,
1232 "item-properties-lock-to-active-take-mouse-click-will-not-change-active-take": 41340,
1233 "item-properties-loop-item-source": 40636,
1234 "item-properties-loop-section-of-audio-item-source": 40547,
1235 "item-properties-mute": 40719,
1236 "item-properties-normalize-items-each-item-separately-to-plus-0db-peak": 40108,
1237 "item-properties-normalize-items-peak-rms-lufs": 42460,
1238 "item-properties-normalize-items-to-loudest-item-to-plus-0db-peak": 40254,
1239 "item-properties-normalize-items-to-loudest-item-to-plus-0db-peak-reset-to-unity-if-already-normalized": 40937,
1240 "item-properties-normalize-items-to-plus-0db-peak-reset-to-unity-if-already-normalized": 40936,
1241 "item-properties-normalize-items-using-most-recent-settings": 42461,
1242 "item-properties-normalize-items-using-most-recent-settings-force-normalize-as-if-one-long-item": 42463,
1243 "item-properties-normalize-items-using-most-recent-settings-force-normalize-each-item-separately": 42464,
1244 "item-properties-normalize-items-using-most-recent-settings-force-normalize-to-loudest-item": 43344,
1245 "item-properties-normalize-items-using-most-recent-settings-reset-to-unity-if-already-normalized": 42462,
1246 "item-properties-open-media-item-take-channel-mapper-for-selected-items": 42429,
1247 "item-properties-pitch-item-down-one-cent": 40207,
1248 "item-properties-pitch-item-down-one-octave": 40516,
1249 "item-properties-pitch-item-down-one-semitone": 40205,
1250 "item-properties-pitch-item-up-one-cent": 40206,
1251 "item-properties-pitch-item-up-one-octave": 40515,
1252 "item-properties-pitch-item-up-one-semitone": 40204,
1253 "item-properties-reset-item-pitch": 40653,
1254 "item-properties-reset-item-take-gain-to-plus-0db-un-normalize": 40938,
1255 "item-properties-set-item-rate-from-user-supplied-source-media-tempo-bpm": 42374,
1256 "item-properties-set-item-rate-to-1-0": 40652,
1257 "item-properties-set-item-timebase-to-beats-auto-stretch-at-tempo-changes": 42375,
1258 "item-properties-set-item-timebase-to-beats-position-length-rate": 40484,
1259 "item-properties-set-item-timebase-to-beats-position-only": 40485,
1260 "item-properties-set-item-timebase-to-project-track-default": 40380,
1261 "item-properties-set-item-timebase-to-time": 40433,
1262 "item-properties-set-midi-items-to-follow-project-tempo-changes-set-item-timebase-to-beats-position-length-rate":
1263 43095,
1264 "item-properties-set-midi-items-to-use-current-tempo-and-ignore-project-tempo-changes-set-item-timebase-to-beats-position-only":
1265 43096,
1266 "item-properties-set-midi-items-to-use-current-tempo-and-ignore-project-tempo-changes-set-item-timebase-to-time":
1267 43094,
1268 "item-properties-set-take-channel-mode-to-mono-channel-03": 41388,
1269 "item-properties-set-take-channel-mode-to-mono-channel-04": 41389,
1270 "item-properties-set-take-channel-mode-to-mono-channel-05": 41390,
1271 "item-properties-set-take-channel-mode-to-mono-channel-06": 41391,
1272 "item-properties-set-take-channel-mode-to-mono-channel-07": 41392,
1273 "item-properties-set-take-channel-mode-to-mono-channel-08": 41393,
1274 "item-properties-set-take-channel-mode-to-mono-channel-09": 41394,
1275 "item-properties-set-take-channel-mode-to-mono-channel-10": 41395,
1276 "item-properties-set-take-channel-mode-to-mono-channel-11": 41396,
1277 "item-properties-set-take-channel-mode-to-mono-channel-12": 41397,
1278 "item-properties-set-take-channel-mode-to-mono-channel-13": 41398,
1279 "item-properties-set-take-channel-mode-to-mono-channel-14": 41399,
1280 "item-properties-set-take-channel-mode-to-mono-channel-15": 41400,
1281 "item-properties-set-take-channel-mode-to-mono-channel-16": 41401,
1282 "item-properties-set-take-channel-mode-to-mono-channel-17": 41402,
1283 "item-properties-set-take-channel-mode-to-mono-channel-18": 41403,
1284 "item-properties-set-take-channel-mode-to-mono-channel-19": 41404,
1285 "item-properties-set-take-channel-mode-to-mono-channel-20": 41405,
1286 "item-properties-set-take-channel-mode-to-mono-channel-21": 41406,
1287 "item-properties-set-take-channel-mode-to-mono-channel-22": 41407,
1288 "item-properties-set-take-channel-mode-to-mono-channel-23": 41408,
1289 "item-properties-set-take-channel-mode-to-mono-channel-24": 41409,
1290 "item-properties-set-take-channel-mode-to-mono-channel-25": 41410,
1291 "item-properties-set-take-channel-mode-to-mono-channel-26": 41411,
1292 "item-properties-set-take-channel-mode-to-mono-channel-27": 41412,
1293 "item-properties-set-take-channel-mode-to-mono-channel-28": 41413,
1294 "item-properties-set-take-channel-mode-to-mono-channel-29": 41414,
1295 "item-properties-set-take-channel-mode-to-mono-channel-30": 41415,
1296 "item-properties-set-take-channel-mode-to-mono-channel-31": 41416,
1297 "item-properties-set-take-channel-mode-to-mono-channel-32": 41417,
1298 "item-properties-set-take-channel-mode-to-mono-channel-33": 41418,
1299 "item-properties-set-take-channel-mode-to-mono-channel-34": 41419,
1300 "item-properties-set-take-channel-mode-to-mono-channel-35": 41420,
1301 "item-properties-set-take-channel-mode-to-mono-channel-36": 41421,
1302 "item-properties-set-take-channel-mode-to-mono-channel-37": 41422,
1303 "item-properties-set-take-channel-mode-to-mono-channel-38": 41423,
1304 "item-properties-set-take-channel-mode-to-mono-channel-39": 41424,
1305 "item-properties-set-take-channel-mode-to-mono-channel-40": 41425,
1306 "item-properties-set-take-channel-mode-to-mono-channel-41": 41426,
1307 "item-properties-set-take-channel-mode-to-mono-channel-42": 41427,
1308 "item-properties-set-take-channel-mode-to-mono-channel-43": 41428,
1309 "item-properties-set-take-channel-mode-to-mono-channel-44": 41429,
1310 "item-properties-set-take-channel-mode-to-mono-channel-45": 41430,
1311 "item-properties-set-take-channel-mode-to-mono-channel-46": 41431,
1312 "item-properties-set-take-channel-mode-to-mono-channel-47": 41432,
1313 "item-properties-set-take-channel-mode-to-mono-channel-48": 41433,
1314 "item-properties-set-take-channel-mode-to-mono-channel-49": 41434,
1315 "item-properties-set-take-channel-mode-to-mono-channel-50": 41435,
1316 "item-properties-set-take-channel-mode-to-mono-channel-51": 41436,
1317 "item-properties-set-take-channel-mode-to-mono-channel-52": 41437,
1318 "item-properties-set-take-channel-mode-to-mono-channel-53": 41438,
1319 "item-properties-set-take-channel-mode-to-mono-channel-54": 41439,
1320 "item-properties-set-take-channel-mode-to-mono-channel-55": 41440,
1321 "item-properties-set-take-channel-mode-to-mono-channel-56": 41441,
1322 "item-properties-set-take-channel-mode-to-mono-channel-57": 41442,
1323 "item-properties-set-take-channel-mode-to-mono-channel-58": 41443,
1324 "item-properties-set-take-channel-mode-to-mono-channel-59": 41444,
1325 "item-properties-set-take-channel-mode-to-mono-channel-60": 41445,
1326 "item-properties-set-take-channel-mode-to-mono-channel-61": 41446,
1327 "item-properties-set-take-channel-mode-to-mono-channel-62": 41447,
1328 "item-properties-set-take-channel-mode-to-mono-channel-63": 41448,
1329 "item-properties-set-take-channel-mode-to-mono-channel-64": 41449,
1330 "item-properties-set-take-channel-mode-to-mono-downmix": 40178,
1331 "item-properties-set-take-channel-mode-to-mono-left": 40179,
1332 "item-properties-set-take-channel-mode-to-mono-right": 40180,
1333 "item-properties-set-take-channel-mode-to-normal": 40176,
1334 "item-properties-set-take-channel-mode-to-reverse-stereo": 40177,
1335 "item-properties-set-take-channel-mode-to-stereo-channels-01-02": 41450,
1336 "item-properties-set-take-channel-mode-to-stereo-channels-02-03": 41451,
1337 "item-properties-set-take-channel-mode-to-stereo-channels-03-04": 41452,
1338 "item-properties-set-take-channel-mode-to-stereo-channels-04-05": 41453,
1339 "item-properties-set-take-channel-mode-to-stereo-channels-05-06": 41454,
1340 "item-properties-set-take-channel-mode-to-stereo-channels-06-07": 41455,
1341 "item-properties-set-take-channel-mode-to-stereo-channels-07-08": 41456,
1342 "item-properties-set-take-channel-mode-to-stereo-channels-08-09": 41457,
1343 "item-properties-set-take-channel-mode-to-stereo-channels-09-10": 41458,
1344 "item-properties-set-take-channel-mode-to-stereo-channels-10-11": 41459,
1345 "item-properties-set-take-channel-mode-to-stereo-channels-11-12": 41460,
1346 "item-properties-set-take-channel-mode-to-stereo-channels-12-13": 41461,
1347 "item-properties-set-take-channel-mode-to-stereo-channels-13-14": 41462,
1348 "item-properties-set-take-channel-mode-to-stereo-channels-14-15": 41463,
1349 "item-properties-set-take-channel-mode-to-stereo-channels-15-16": 41464,
1350 "item-properties-set-take-channel-mode-to-stereo-channels-16-17": 41465,
1351 "item-properties-set-take-channel-mode-to-stereo-channels-17-18": 41466,
1352 "item-properties-set-take-channel-mode-to-stereo-channels-18-19": 41467,
1353 "item-properties-set-take-channel-mode-to-stereo-channels-19-20": 41468,
1354 "item-properties-set-take-channel-mode-to-stereo-channels-20-21": 41469,
1355 "item-properties-set-take-channel-mode-to-stereo-channels-21-22": 41470,
1356 "item-properties-set-take-channel-mode-to-stereo-channels-22-23": 41471,
1357 "item-properties-set-take-channel-mode-to-stereo-channels-23-24": 41472,
1358 "item-properties-set-take-channel-mode-to-stereo-channels-24-25": 41473,
1359 "item-properties-set-take-channel-mode-to-stereo-channels-25-26": 41474,
1360 "item-properties-set-take-channel-mode-to-stereo-channels-26-27": 41475,
1361 "item-properties-set-take-channel-mode-to-stereo-channels-27-28": 41476,
1362 "item-properties-set-take-channel-mode-to-stereo-channels-28-29": 41477,
1363 "item-properties-set-take-channel-mode-to-stereo-channels-29-30": 41478,
1364 "item-properties-set-take-channel-mode-to-stereo-channels-30-31": 41479,
1365 "item-properties-set-take-channel-mode-to-stereo-channels-31-32": 41480,
1366 "item-properties-set-take-channel-mode-to-stereo-channels-32-33": 41481,
1367 "item-properties-set-take-channel-mode-to-stereo-channels-33-34": 41482,
1368 "item-properties-set-take-channel-mode-to-stereo-channels-34-35": 41483,
1369 "item-properties-set-take-channel-mode-to-stereo-channels-35-36": 41484,
1370 "item-properties-set-take-channel-mode-to-stereo-channels-36-37": 41485,
1371 "item-properties-set-take-channel-mode-to-stereo-channels-37-38": 41486,
1372 "item-properties-set-take-channel-mode-to-stereo-channels-38-39": 41487,
1373 "item-properties-set-take-channel-mode-to-stereo-channels-39-40": 41488,
1374 "item-properties-set-take-channel-mode-to-stereo-channels-40-41": 41489,
1375 "item-properties-set-take-channel-mode-to-stereo-channels-41-42": 41490,
1376 "item-properties-set-take-channel-mode-to-stereo-channels-42-43": 41491,
1377 "item-properties-set-take-channel-mode-to-stereo-channels-43-44": 41492,
1378 "item-properties-set-take-channel-mode-to-stereo-channels-44-45": 41493,
1379 "item-properties-set-take-channel-mode-to-stereo-channels-45-46": 41494,
1380 "item-properties-set-take-channel-mode-to-stereo-channels-46-47": 41495,
1381 "item-properties-set-take-channel-mode-to-stereo-channels-47-48": 41496,
1382 "item-properties-set-take-channel-mode-to-stereo-channels-48-49": 41497,
1383 "item-properties-set-take-channel-mode-to-stereo-channels-49-50": 41498,
1384 "item-properties-set-take-channel-mode-to-stereo-channels-50-51": 41499,
1385 "item-properties-set-take-channel-mode-to-stereo-channels-51-52": 41500,
1386 "item-properties-set-take-channel-mode-to-stereo-channels-52-53": 41501,
1387 "item-properties-set-take-channel-mode-to-stereo-channels-53-54": 41502,
1388 "item-properties-set-take-channel-mode-to-stereo-channels-54-55": 41503,
1389 "item-properties-set-take-channel-mode-to-stereo-channels-55-56": 41504,
1390 "item-properties-set-take-channel-mode-to-stereo-channels-56-57": 41505,
1391 "item-properties-set-take-channel-mode-to-stereo-channels-57-58": 41506,
1392 "item-properties-set-take-channel-mode-to-stereo-channels-58-59": 41507,
1393 "item-properties-set-take-channel-mode-to-stereo-channels-59-60": 41508,
1394 "item-properties-set-take-channel-mode-to-stereo-channels-60-61": 41509,
1395 "item-properties-set-take-channel-mode-to-stereo-channels-61-62": 41510,
1396 "item-properties-set-take-channel-mode-to-stereo-channels-62-63": 41511,
1397 "item-properties-set-take-channel-mode-to-stereo-channels-63-64": 41512,
1398 "item-properties-set-take-preserve-pitch": 40795,
1399 "item-properties-show-media-item-source-properties": 40011,
1400 "item-properties-show-media-item-take-properties": 40009,
1401 "item-properties-solo": 41559,
1402 "item-properties-solo-exclusive": 41558,
1403 "item-properties-toggle-item-play-all-takes": 40437,
1404 "item-properties-toggle-items-tracks-mute-depending-on-focus": 40183,
1405 "item-properties-toggle-lock": 40687,
1406 "item-properties-toggle-lock-to-active-take-toggle-mouse-click-changes-active-take": 41339,
1407 "item-properties-toggle-mute": 40175,
1408 "item-properties-toggle-polarity-phase-for-active-take": 40181,
1409 "item-properties-toggle-show-media-item-take-properties": 41589,
1410 "item-properties-toggle-solo": 41557,
1411 "item-properties-toggle-solo-exclusive": 41561,
1412 "item-properties-toggle-take-preserve-pitch": 40566,
1413 "item-properties-toggle-take-reverse": 41051,
1414 "item-properties-unlock": 40689,
1415 "item-properties-unlock-takes-mouse-click-will-change-active-take": 41341,
1416 "item-properties-unmute": 40720,
1417 "item-properties-unmute-all-items": 40870,
1418 "item-properties-unsolo": 41560,
1419 "item-properties-unsolo-all": 41185,
1420 "item-quantize-item-positions-to-grid": 40316,
1421 "item-quick-add-take-marker-at-mouse-position": 42391,
1422 "item-quick-add-take-marker-at-play-position-or-edit-cursor": 42390,
1423 "item-remove-active-take-from-midi-source-data-pool-unpool": 41613,
1424 "item-remove-active-takes-from-ara-edit-pool-unpool": 42606,
1425 "item-remove-all-empty-takes": 41348,
1426 "item-remove-all-stretch-markers": 41844,
1427 "item-remove-all-stretch-markers-in-time-selection": 41845,
1428 "item-remove-content-trim-behind-items": 40930,
1429 "item-remove-fade-in": 41191,
1430 "item-remove-fade-in-and-fade-out": 41193,
1431 "item-remove-fade-out": 41192,
1432 "item-remove-fx-for-item-take": 40640,
1433 "item-remove-items": 40006,
1434 "item-remove-selected-area-of-items": 40312,
1435 "item-remove-stretch-marker-at-current-position": 41859,
1436 "item-remove-the-empty-take-after-the-active-take": 41350,
1437 "item-remove-the-empty-take-before-the-active-take": 41349,
1438 "item-render-items-to-new-take": 41999,
1439 "item-render-items-to-new-take-preserve-source-type": 40601,
1440 "item-reorder-adjacent-items-randomly": 41638,
1441 "item-reset-items-volume-to-plus-0db": 41923,
1442 "item-reset-stretch-marker-at-current-position": 41989,
1443 "item-return-active-takes-to-ara-edit-pool-re-pool": 42609,
1444 "item-reverse-items-to-new-take": 40270,
1445 "item-rotate-takes-backward": 41354,
1446 "item-rotate-takes-forward": 41353,
1447 "item-select-all-items": 40182,
1448 "item-select-all-items-in-current-time-selection": 40717,
1449 "item-select-all-items-in-track": 40421,
1450 "item-select-all-items-on-selected-tracks-in-current-time-selection": 40718,
1451 "item-select-all-other-media-items-that-share-pooled-ara-edits-with-selected-items": 42607,
1452 "item-select-all-other-media-items-that-share-pooled-midi-source-data-with-selected-items": 41611,
1453 "item-select-all-other-media-items-that-share-the-same-source-media-as-selected-items": 42608,
1454 "item-select-item-under-mouse-cursor": 40528,
1455 "item-select-item-under-mouse-cursor-leaving-other-items-selected": 40529,
1456 "item-select-next-adjacent-non-overlapping-item": 41127,
1457 "item-select-previous-adjacent-non-overlapping-item": 41128,
1458 "item-set-all-media-item-takes-that-share-the-same-source-media-to-the-same-random-color": 42693,
1459 "item-set-all-media-offline": 40100,
1460 "item-set-all-media-online": 40101,
1461 "item-set-crossfade-shape-to-type-1-linear-equal-gain": 41528,
1462 "item-set-crossfade-shape-to-type-2-equal-power": 41529,
1463 "item-set-crossfade-shape-to-type-3": 41530,
1464 "item-set-crossfade-shape-to-type-4": 41531,
1465 "item-set-crossfade-shape-to-type-5": 41532,
1466 "item-set-crossfade-shape-to-type-6": 41533,
1467 "item-set-crossfade-shape-to-type-7": 41838,
1468 "item-set-cursor-to-next-take-marker-in-selected-items": 42394,
1469 "item-set-cursor-to-previous-take-marker-in-selected-items": 42393,
1470 "item-set-fade-in-shape-to-type-1-linear": 41514,
1471 "item-set-fade-in-shape-to-type-2": 41515,
1472 "item-set-fade-in-shape-to-type-3": 41516,
1473 "item-set-fade-in-shape-to-type-4": 41517,
1474 "item-set-fade-in-shape-to-type-5": 41518,
1475 "item-set-fade-in-shape-to-type-6": 41519,
1476 "item-set-fade-in-shape-to-type-7": 41836,
1477 "item-set-fade-out-shape-to-type-1-linear": 41521,
1478 "item-set-fade-out-shape-to-type-2": 41522,
1479 "item-set-fade-out-shape-to-type-3": 41523,
1480 "item-set-fade-out-shape-to-type-4": 41524,
1481 "item-set-fade-out-shape-to-type-5": 41525,
1482 "item-set-fade-out-shape-to-type-6": 41526,
1483 "item-set-fade-out-shape-to-type-7": 41837,
1484 "item-set-focus-to-item-under-mouse-cursor": 40911,
1485 "item-set-item-end-to-cursor": 40611,
1486 "item-set-item-end-to-source-media-end": 40612,
1487 "item-set-item-ends-to-start-of-next-item": 41639,
1488 "item-set-item-mix-behavior-to-always-mix": 40919,
1489 "item-set-item-mix-behavior-to-always-replace": 40921,
1490 "item-set-item-mix-behavior-to-enclosed-items-replace-enclosing-items": 40918,
1491 "item-set-item-mix-behavior-to-project-default": 40922,
1492 "item-set-item-name-from-active-take-filename": 41858,
1493 "item-set-item-start-end-to-source-media-start-end": 42228,
1494 "item-set-item-start-to-source-media-start": 42229,
1495 "item-set-selected-media-online": 40439,
1496 "item-set-selected-media-temporarily-offline": 40440,
1497 "item-set-snap-offset-for-item-under-mouse-to-mouse-position": 42476,
1498 "item-set-snap-offset-to-cursor": 40541,
1499 "item-set-snap-offset-to-nearest-grid-line": 40542,
1500 "item-set-to-custom-color": 40704,
1501 "item-set-to-default-color": 40707,
1502 "item-set-to-one-random-color": 40706,
1503 "item-set-to-random-colors": 40705,
1504 "item-show-fx-chain-for-item-take": 40638,
1505 "item-show-notes-for-items": 40850,
1506 "item-shrink-to-first-and-last-media-cues": 40735,
1507 "item-shrink-to-first-media-cue": 40733,
1508 "item-shrink-to-last-media-cue": 40734,
1509 "item-snap-items-left": 41182,
1510 "item-snap-items-right": 41183,
1511 "item-snap-items-to-nearest-snap-point": 41184,
1512 "item-snap-stretch-markers-in-time-selection-to-grid": 41847,
1513 "item-snap-stretch-markers-to-grid": 41846,
1514 "item-solo-active-take-of-multitake-item-within-time-selection": 40856,
1515 "item-split-at-media-cues": 40732,
1516 "item-split-at-previous-zero-crossing": 40792,
1517 "item-split-at-take-markers-active-take-only": 43168,
1518 "item-split-at-take-markers-all-takes": 43171,
1519 "item-split-at-take-markers-for-all-items-on-track-active-takes-only": 43170,
1520 "item-split-at-take-markers-for-all-items-on-track-all-takes": 43173,
1521 "item-split-at-take-markers-for-item-under-mouse-active-take-only": 43169,
1522 "item-split-at-take-markers-for-item-under-mouse-all-takes": 43172,
1523 "item-split-item-under-mouse-cursor-ignore-grouping-no-change-selection": 40746,
1524 "item-split-item-under-mouse-cursor-ignore-grouping-select-left": 43180,
1525 "item-split-item-under-mouse-cursor-ignore-grouping-select-right": 40748,
1526 "item-split-item-under-mouse-cursor-no-change-selection": 42575,
1527 "item-split-item-under-mouse-cursor-select-left": 43179,
1528 "item-split-item-under-mouse-cursor-select-right": 42577,
1529 "item-split-items-at-edit-cursor-no-change-selection": 40757,
1530 "item-split-items-at-edit-cursor-select-left": 43178,
1531 "item-split-items-at-edit-cursor-select-right": 40759,
1532 "item-split-items-at-edit-or-play-cursor-ignore-grouping-select-right": 40186,
1533 "item-split-items-at-edit-or-play-cursor-select-right": 40012,
1534 "item-split-items-at-end-of-fade-in-unless-crossfaded": 41839,
1535 "item-split-items-at-play-cursor-select-right": 40196,
1536 "item-split-items-at-project-markers": 40931,
1537 "item-split-items-at-start-of-fade-out-unless-crossfaded": 41840,
1538 "item-split-items-at-time-selection-or-razor-edit": 40061,
1539 "item-split-items-at-timeline-grid": 40932,
1540 "item-toggle-enable-disable-default-fadein-fadeout": 41194,
1541 "item-toggle-force-inactive-take-media-offline": 42357,
1542 "item-toggle-force-media-offline": 42356,
1543 "item-toggle-selection-of-item-under-mouse-cursor": 40530,
1544 "item-trim-items-left-of-cursor": 40511,
1545 "item-trim-items-right-of-cursor": 40512,
1546 "item-trim-items-to-selected-area": 40508,
1547 "item-unselect-clear-selection-of-all-items": 40289,
1548 "item-up-rank-active-take-or-last-recording-pass": 42680,
1549 "item-up-rank-take-marker-at-1-second-before-play-position-or-at-edit-cursor-if-not-playing-back": 43174,
1550 "item-up-rank-take-marker-at-2-seconds-before-play-position-or-at-edit-cursor-if-not-playing-back": 43176,
1551 "item-up-rank-take-marker-at-mouse-position": 43159,
1552 "item-up-rank-take-marker-at-play-position-or-edit-cursor": 43157,
1553 "item-up-rank-take-marker-at-time-selection": 43183,
1554 "item-up-rank-take-or-comp-area-under-mouse": 43155,
1555 "items-set-all-take-fx-offline-for-selected-media-items": 42353,
1556 "items-set-all-take-fx-online-for-selected-media-items": 42354,
1557 "items-set-group-color-for-selected-items": 43654,
1558 "items-set-group-name-for-selected-items": 43656,
1559 "items-set-group-to-random-color-for-selected-items": 43655,
1560 "items-set-take-fx-offline-for-all-inactive-takes-in-the-project": 43691,
1561 "items-set-take-fx-online-for-all-active-takes-in-the-project": 43692,
1562 "items-set-take-fx-online-if-active-offline-if-inactive-for-all-takes-in-the-project": 43693,
1563 "layout-apply-custom-layout-number-01": 41696,
1564 "layout-apply-custom-layout-number-02": 41697,
1565 "layout-apply-custom-layout-number-03": 41698,
1566 "layout-apply-custom-layout-number-04": 41699,
1567 "layout-apply-custom-layout-number-05": 41700,
1568 "layout-apply-custom-layout-number-06": 41701,
1569 "layout-apply-custom-layout-number-07": 41702,
1570 "layout-apply-custom-layout-number-08": 41703,
1571 "layout-apply-custom-layout-number-09": 41704,
1572 "layout-apply-custom-layout-number-10": 41705,
1573 "layout-apply-custom-layout-number-11": 41706,
1574 "layout-apply-custom-layout-number-12": 41707,
1575 "layout-apply-custom-layout-number-13": 41708,
1576 "layout-apply-custom-layout-number-14": 41709,
1577 "layout-apply-custom-layout-number-15": 41710,
1578 "layout-apply-custom-layout-number-16": 41711,
1579 "layout-apply-custom-layout-number-17": 41712,
1580 "layout-apply-custom-layout-number-18": 41713,
1581 "layout-apply-custom-layout-number-19": 41714,
1582 "layout-apply-custom-layout-number-20": 41715,
1583 "layout-default-layout": 48500,
1584 "locking-clear-all-lock-modes": 40567,
1585 "locking-clear-full-item-locking-mode": 40575,
1586 "locking-clear-item-edges-locking-mode": 40596,
1587 "locking-clear-item-fade-volume-handles-locking-mode": 40599,
1588 "locking-clear-item-stretch-marker-locking-mode": 41853,
1589 "locking-clear-left-right-item-locking-mode": 40578,
1590 "locking-clear-loop-points-locking-mode": 40628,
1591 "locking-clear-marker-locking-mode": 40590,
1592 "locking-clear-region-locking-mode": 40587,
1593 "locking-clear-take-envelope-locking-mode": 41850,
1594 "locking-clear-time-selection-locking-mode": 40572,
1595 "locking-clear-time-signature-marker-locking-mode": 40593,
1596 "locking-clear-track-envelope-locking-mode": 40584,
1597 "locking-clear-up-down-item-locking-mode": 40581,
1598 "locking-disable-locking": 40570,
1599 "locking-enable-locking": 40569,
1600 "locking-set-all-lock-modes": 40568,
1601 "locking-set-full-item-locking-mode": 40574,
1602 "locking-set-item-edges-locking-mode": 40595,
1603 "locking-set-item-fade-volume-handles-locking-mode": 40598,
1604 "locking-set-item-stretch-marker-locking-mode": 41852,
1605 "locking-set-left-right-item-locking-mode": 40577,
1606 "locking-set-loop-points-locking-mode": 40627,
1607 "locking-set-marker-locking-mode": 40589,
1608 "locking-set-region-locking-mode": 40586,
1609 "locking-set-take-envelope-locking-mode": 41849,
1610 "locking-set-time-selection-locking-mode": 40571,
1611 "locking-set-time-signature-marker-locking-mode": 40592,
1612 "locking-set-track-envelope-locking-mode": 40583,
1613 "locking-set-up-down-item-locking-mode": 40580,
1614 "locking-toggle-full-item-locking-mode": 40576,
1615 "locking-toggle-item-edges-locking-mode": 40597,
1616 "locking-toggle-item-fade-volume-handles-locking-mode": 40600,
1617 "locking-toggle-item-stretch-marker-locking-mode": 41854,
1618 "locking-toggle-left-right-item-locking-mode": 40579,
1619 "locking-toggle-loop-points-locking-mode": 40629,
1620 "locking-toggle-marker-locking-mode": 40591,
1621 "locking-toggle-region-locking-mode": 40588,
1622 "locking-toggle-take-envelope-locking-mode": 41851,
1623 "locking-toggle-time-selection-locking-mode": 40573,
1624 "locking-toggle-time-signature-marker-locking-mode": 40594,
1625 "locking-toggle-track-envelope-locking-mode": 40585,
1626 "locking-toggle-up-down-item-locking-mode": 40582,
1627 "loop-points-double-loop-length": 40722,
1628 "loop-points-halve-loop-length": 40721,
1629 "loop-points-move-end-point-to-cursor-preserve-length": 43211,
1630 "loop-points-move-start-point-to-cursor-preserve-length": 43210,
1631 "loop-points-remove-unselect-loop-point-selection": 40634,
1632 "loop-points-remove-unselect-loop-points-if-not-linked-to-time-selection-ignoring-lock-state": 40624,
1633 "loop-points-set-end-point": 40223,
1634 "loop-points-set-loop-points-to-items": 41039,
1635 "loop-points-set-start-point": 40222,
1636 "main-action-section-clear-any-override": 24800,
1637 "main-action-section-momentarily-set-override-to-alt-1": 24853,
1638 "main-action-section-momentarily-set-override-to-alt-10": 24862,
1639 "main-action-section-momentarily-set-override-to-alt-11": 24863,
1640 "main-action-section-momentarily-set-override-to-alt-12": 24864,
1641 "main-action-section-momentarily-set-override-to-alt-13": 24865,
1642 "main-action-section-momentarily-set-override-to-alt-14": 24866,
1643 "main-action-section-momentarily-set-override-to-alt-15": 24867,
1644 "main-action-section-momentarily-set-override-to-alt-16": 24868,
1645 "main-action-section-momentarily-set-override-to-alt-2": 24854,
1646 "main-action-section-momentarily-set-override-to-alt-3": 24855,
1647 "main-action-section-momentarily-set-override-to-alt-4": 24856,
1648 "main-action-section-momentarily-set-override-to-alt-5": 24857,
1649 "main-action-section-momentarily-set-override-to-alt-6": 24858,
1650 "main-action-section-momentarily-set-override-to-alt-7": 24859,
1651 "main-action-section-momentarily-set-override-to-alt-8": 24860,
1652 "main-action-section-momentarily-set-override-to-alt-9": 24861,
1653 "main-action-section-momentarily-set-override-to-default": 24851,
1654 "main-action-section-momentarily-set-override-to-recording": 24852,
1655 "main-action-section-set-override-to-default": 24801,
1656 "main-action-section-toggle-override-to-alt-1": 24803,
1657 "main-action-section-toggle-override-to-alt-10": 24812,
1658 "main-action-section-toggle-override-to-alt-11": 24813,
1659 "main-action-section-toggle-override-to-alt-12": 24814,
1660 "main-action-section-toggle-override-to-alt-13": 24815,
1661 "main-action-section-toggle-override-to-alt-14": 24816,
1662 "main-action-section-toggle-override-to-alt-15": 24817,
1663 "main-action-section-toggle-override-to-alt-16": 24818,
1664 "main-action-section-toggle-override-to-alt-2": 24804,
1665 "main-action-section-toggle-override-to-alt-3": 24805,
1666 "main-action-section-toggle-override-to-alt-4": 24806,
1667 "main-action-section-toggle-override-to-alt-5": 24807,
1668 "main-action-section-toggle-override-to-alt-6": 24808,
1669 "main-action-section-toggle-override-to-alt-7": 24809,
1670 "main-action-section-toggle-override-to-alt-8": 24810,
1671 "main-action-section-toggle-override-to-alt-9": 24811,
1672 "main-action-section-toggle-override-to-recording": 24802,
1673 "markers-add-move-marker-1-to-play-edit-cursor": 40657,
1674 "markers-add-move-marker-10-to-play-edit-cursor": 40656,
1675 "markers-add-move-marker-2-to-play-edit-cursor": 40658,
1676 "markers-add-move-marker-3-to-play-edit-cursor": 40659,
1677 "markers-add-move-marker-4-to-play-edit-cursor": 40660,
1678 "markers-add-move-marker-5-to-play-edit-cursor": 40661,
1679 "markers-add-move-marker-6-to-play-edit-cursor": 40662,
1680 "markers-add-move-marker-7-to-play-edit-cursor": 40663,
1681 "markers-add-move-marker-8-to-play-edit-cursor": 40664,
1682 "markers-add-move-marker-9-to-play-edit-cursor": 40665,
1683 "markers-change-color-for-marker-near-cursor": 40304,
1684 "markers-change-color-for-region-near-cursor": 40305,
1685 "markers-delete-marker-near-cursor": 40613,
1686 "markers-delete-region-near-cursor": 40615,
1687 "markers-delete-time-signature-marker-near-cursor": 40617,
1688 "markers-edit-marker-near-cursor": 40614,
1689 "markers-edit-region-near-cursor": 40616,
1690 "markers-edit-time-signature-marker-near-cursor": 40618,
1691 "markers-go-to-marker-01": 40161,
1692 "markers-go-to-marker-02": 40162,
1693 "markers-go-to-marker-03": 40163,
1694 "markers-go-to-marker-04": 40164,
1695 "markers-go-to-marker-05": 40165,
1696 "markers-go-to-marker-06": 40166,
1697 "markers-go-to-marker-07": 40167,
1698 "markers-go-to-marker-08": 40168,
1699 "markers-go-to-marker-09": 40169,
1700 "markers-go-to-marker-10": 40160,
1701 "markers-go-to-marker-11": 41251,
1702 "markers-go-to-marker-12": 41252,
1703 "markers-go-to-marker-13": 41253,
1704 "markers-go-to-marker-14": 41254,
1705 "markers-go-to-marker-15": 41255,
1706 "markers-go-to-marker-16": 41256,
1707 "markers-go-to-marker-17": 41257,
1708 "markers-go-to-marker-18": 41258,
1709 "markers-go-to-marker-19": 41259,
1710 "markers-go-to-marker-20": 41260,
1711 "markers-go-to-marker-21": 41261,
1712 "markers-go-to-marker-22": 41262,
1713 "markers-go-to-marker-23": 41263,
1714 "markers-go-to-marker-24": 41264,
1715 "markers-go-to-marker-25": 41265,
1716 "markers-go-to-marker-26": 41266,
1717 "markers-go-to-marker-27": 41267,
1718 "markers-go-to-marker-28": 41268,
1719 "markers-go-to-marker-29": 41269,
1720 "markers-go-to-marker-30": 41270,
1721 "markers-go-to-next-marker-project-end": 40173,
1722 "markers-go-to-previous-marker-project-start": 40172,
1723 "markers-insert-and-or-edit-marker-at-current-position": 40171,
1724 "markers-insert-marker-at-current-position": 40157,
1725 "markers-insert-region-from-selected-items": 40348,
1726 "markers-insert-region-from-selected-items-and-edit": 40393,
1727 "markers-insert-region-from-time-selection": 40174,
1728 "markers-insert-region-from-time-selection-and-edit": 40306,
1729 "markers-insert-separate-regions-for-each-selected-item": 41664,
1730 "markers-quantize-tempo-markers-to-midi-resolution": 40925,
1731 "markers-regions-export-markers-regions-to-file": 41758,
1732 "markers-regions-import-markers-regions-from-file-merge-with-existing": 41760,
1733 "markers-regions-import-markers-regions-from-file-replace-existing": 41759,
1734 "markers-remove-all-markers-from-time-selection": 40420,
1735 "markers-renumber-all-markers-and-regions-in-timeline-order": 40898,
1736 "markers-set-marker-near-cursor-to-default-color": 41897,
1737 "markers-set-region-near-cursor-to-default-color": 41896,
1738 "master-track-toggle-stereo-mono-l-plus-r": 40917,
1739 "media-explorer-show-hide-media-explorer": 50124,
1740 "media-item-add-stretch-markers-at-project-tempo-changes": 42377,
1741 "media-item-clear-and-recalculate-auto-stretch-at-project-tempo-changes": 42376,
1742 "menu-customize": 1528,
1743 "midi-clear-retroactive-midi-history": 42378,
1744 "midi-insert-all-available-retroactively-recorded-midi-for-armed-and-selected-tracks": 40212,
1745 "midi-insert-all-available-retroactively-recorded-midi-for-armed-tracks": 40213,
1746 "midi-insert-recent-retroactively-recorded-midi-for-armed-and-selected-tracks": 40211,
1747 "midi-insert-recent-retroactively-recorded-midi-for-armed-tracks": 40686,
1748 "midi-reload-track-support-data-bank-program-files-notation-etc-for-all-midi-items-on-selected-tracks": 42465,
1749 "minimize-reaper": 41171,
1750 "mixer-clickable-icon-for-folder-tracks-to-show-hide-children": 41154,
1751 "mixer-group-fx-parameters-with-their-inserts": 41829,
1752 "mixer-group-sends-with-before-after-fx-inserts": 40267,
1753 "mixer-master-track-visible": 41209,
1754 "mixer-toggle-autoarrange": 41146,
1755 "mixer-toggle-docking-in-docker": 40083,
1756 "mixer-toggle-folder-tracks-grouping-to-left": 40081,
1757 "mixer-toggle-master-track-in-docked-window": 41610,
1758 "mixer-toggle-master-track-in-separate-window": 41636,
1759 "mixer-toggle-scroll-view-when-tracks-activated": 40221,
1760 "mixer-toggle-show-folder-tracks-in-mixer": 40080,
1761 "mixer-toggle-show-fx-inserts-when-size-permits": 40549,
1762 "mixer-toggle-show-fx-parameters-when-size-permits": 40910,
1763 "mixer-toggle-show-icons-for-the-last-track-in-a-folder": 41153,
1764 "mixer-toggle-show-master-track-on-right-side": 40389,
1765 "mixer-toggle-show-multiple-rows-even-when-space-to-fit-tracks-in-less-rows": 40372,
1766 "mixer-toggle-show-multiple-rows-when-space": 40371,
1767 "mixer-toggle-show-normal-top-level-tracks-in-mixer": 40082,
1768 "mixer-toggle-show-sends-when-size-permits": 40557,
1769 "mixer-toggle-show-track-icons-in-mixer": 40903,
1770 "mixer-toggle-show-tracks-in-folders-in-mixer": 40199,
1771 "mixer-toggle-tracks-with-receives-grouping-to-left": 40198,
1772 "mixer-toggle-tracks-with-receives-in-mixer": 40197,
1773 "monitoring-fx-toggle-bypass": 41884,
1774 "mouse-modifiers-clear-arrange-view-override-mouse-modifiers": 42621,
1775 "mouse-modifiers-set-arrange-view-override-mouse-modifiers-a": 42615,
1776 "mouse-modifiers-set-arrange-view-override-mouse-modifiers-a-until-next-mouseup": 42622,
1777 "mouse-modifiers-set-arrange-view-override-mouse-modifiers-b": 42617,
1778 "mouse-modifiers-set-arrange-view-override-mouse-modifiers-b-until-next-mouseup": 42623,
1779 "mouse-modifiers-set-arrange-view-override-mouse-modifiers-c": 42619,
1780 "mouse-modifiers-set-arrange-view-override-mouse-modifiers-c-until-next-mouseup": 42624,
1781 "mouse-modifiers-set-arrange-view-override-mouse-modifiers-d": 42632,
1782 "mouse-modifiers-set-arrange-view-override-mouse-modifiers-d-until-next-mouseup": 42634,
1783 "mouse-modifiers-swap-arrange-view-right-drag-modifiers-for-marquee-item-selection-and-select-razor-edit-area": 42401,
1784 "mouse-modifiers-toggle-arrange-view-override-mouse-modifiers-a": 42616,
1785 "mouse-modifiers-toggle-arrange-view-override-mouse-modifiers-b": 42618,
1786 "mouse-modifiers-toggle-arrange-view-override-mouse-modifiers-c": 42620,
1787 "mouse-modifiers-toggle-arrange-view-override-mouse-modifiers-d": 42633,
1788 "move-comp-area-at-mouse-down-or-switch-items-to-next-take": 42611,
1789 "move-comp-area-at-mouse-up-or-switch-items-to-previous-take": 42612,
1790 "move-edit-cursor-back-one-beat": 41045,
1791 "move-edit-cursor-back-one-beat-no-seek": 40842,
1792 "move-edit-cursor-back-one-measure": 41043,
1793 "move-edit-cursor-back-one-measure-no-seek": 40840,
1794 "move-edit-cursor-forward-one-beat": 41044,
1795 "move-edit-cursor-forward-one-beat-no-seek": 40841,
1796 "move-edit-cursor-forward-one-measure": 41042,
1797 "move-edit-cursor-forward-one-measure-no-seek": 40839,
1798 "move-edit-cursor-to-left-edge-of-visible-arrange-view": 42964,
1799 "move-edit-cursor-to-nearest-zero-crossing-in-items": 41995,
1800 "move-edit-cursor-to-next-cue-in-items": 40741,
1801 "move-edit-cursor-to-next-tempo-or-time-signature-change": 41821,
1802 "move-edit-cursor-to-next-zero-crossing-in-items": 40791,
1803 "move-edit-cursor-to-previous-cue-in-items": 40742,
1804 "move-edit-cursor-to-previous-tempo-or-time-signature-change": 41820,
1805 "move-edit-cursor-to-previous-zero-crossing-in-items": 40790,
1806 "move-edit-cursor-to-start-of-current-previous-beat": 40230,
1807 "move-edit-cursor-to-start-of-current-previous-measure": 41041,
1808 "move-edit-cursor-to-start-of-current-previous-measure-no-seek": 40838,
1809 "move-edit-cursor-to-start-of-next-beat": 40231,
1810 "move-edit-cursor-to-start-of-next-measure": 41040,
1811 "move-edit-cursor-to-start-of-next-measure-no-seek": 40837,
1812 "new-project-tab": 40859,
1813 "new-project-tab-ignore-default-template": 41929,
1814 "no-op-no-action": 65535,
1815 "offset-track-template-items-by-edit-cursor": 41722,
1816 "open-selected-item-source-media-in-explorer-finder": 42411,
1817 "open-selected-item-source-media-in-media-explorer": 41623,
1818 "options-add-edge-points-when-moving-envelope-points-with-items": 40648,
1819 "options-add-edge-points-when-moving-multiple-envelope-points": 42030,
1820 "options-add-envelope-edge-points-when-ripple-editing-or-inserting-time": 40649,
1821 "options-allow-drag-drop-media-import-to-target-the-top-part-of-a-track-to-insert-a-new-track-to-receive-the-media":
1822 42477,
1823 "options-allow-selecting-empty-takes": 41355,
1824 "options-always-record-to-automation-items": 42212,
1825 "options-always-trim-content-behind-razor-edits-otherwise-follow-media-item-editing-preferences": 42421,
1826 "options-auto-crossfade-media-items-when-editing": 40041,
1827 "options-automatically-insert-automation-item-when-activating-envelope-that-is-bypassed-outside-of-automation-items-and-not-displayed-in-envelope-lane":
1828 42220,
1829 "options-automation-item-baseline-amplitude-edits-affect-pooled-copies": 42194,
1830 "options-automation-items-connect-to-the-underlying-envelope-on-both-sides": 42205,
1831 "options-automation-items-connect-to-the-underlying-envelope-on-the-right-side": 42204,
1832 "options-automation-items-do-not-connect-to-the-underlying-envelope": 42203,
1833 "options-avoid-including-empty-track-space-in-comp-areas": 42795,
1834 "options-bypass-underlying-envelopes-outside-of-automation-items": 42213,
1835 "options-chase-midi-note-on-cc-pc-pitch-in-project-playback": 41992,
1836 "options-chase-non-fx-envelopes-to-automation-items-when-underlying-envelope-is-bypassed": 42344,
1837 "options-crossfade-center-when-splitting": 43193,
1838 "options-crossfade-left-when-splitting": 43191,
1839 "options-crossfade-right-when-splitting": 43192,
1840 "options-cycle-ripple-editing-mode": 1155,
1841 "options-cycle-split-options-crossfade-left-crossfade-center-crossfade-right-no-crossfade": 43196,
1842 "options-cycle-through-editing-modes-auto-crossfade-off-auto-crossfade-on-trim-content-behind-media-items": 41116,
1843 "options-disable-auto-crossfades": 41119,
1844 "options-disable-crossfade-on-split-disregard-toolbar-auto-crossfade-button": 40928,
1845 "options-disable-display-group-names-colors-in-arrange-view": 43653,
1846 "options-disable-metronome": 41746,
1847 "options-disable-trim-content-behind-media-items-when-editing": 41121,
1848 "options-do-not-change-comp-area-source-lane-when-clicking-empty-track-space": 42794,
1849 "options-do-not-display-tracks-in-folder-when-folder-is-fully-collapsed": 42699,
1850 "options-enable-auto-crossfades": 41118,
1851 "options-enable-crossfade-on-split-disregard-toolbar-auto-crossfade-button": 40927,
1852 "options-enable-display-group-names-colors-in-arrange-view": 43652,
1853 "options-enable-metronome": 41745,
1854 "options-enable-trim-content-behind-media-items-when-editing": 41120,
1855 "options-envelope-point-selection-follows-time-selection": 41576,
1856 "options-limit-media-item-edge-edits-to-source-media-content-for-unlooped-media-items": 42218,
1857 "options-loop-new-automation-items-by-default": 42195,
1858 "options-loop-recording-always-adds-takes": 40114,
1859 "options-mouse-modifier-preferences": 41356,
1860 "options-move-edit-cursor-to-end-of-recording-when-recording-ends": 40300,
1861 "options-move-edit-cursor-to-start-of-time-selection-when-time-selection-changes": 40276,
1862 "options-move-envelope-points-with-media-items": 40070,
1863 "options-move-take-envelope-points-when-moving-media-item-contents": 43636,
1864 "options-new-recording-adds-lanes-in-layers-multiple-lanes-play-at-once": 43151,
1865 "options-new-recording-adds-lanes-new-lanes-play-exclusively": 43152,
1866 "options-new-recording-adds-media-items-in-layers": 42677,
1867 "options-new-recording-does-not-add-lanes": 43153,
1868 "options-new-recording-splits-existing-items-and-adds-takes-default": 41330,
1869 "options-new-recording-trims-existing-items-tape-mode": 41186,
1870 "options-offset-overlapping-media-items-vertically": 40507,
1871 "options-pre-fader-track-metering": 42076,
1872 "options-preferences": 40016,
1873 "options-preserve-trailing-values-when-recording-automation": 42640,
1874 "options-prevent-mouse-edits-of-single-envelope-points-from-moving-past-other-envelope-points": 42202,
1875 "options-razor-edits-in-media-item-lane-affect-all-track-envelopes": 42459,
1876 "options-razor-edits-on-collapsed-fixed-lane-tracks-affect-all-lanes": 42601,
1877 "options-razor-edits-on-small-fixed-lane-tracks-affect-all-lanes": 43097,
1878 "options-reduce-envelope-data-when-recording-or-drawing-automation": 40650,
1879 "options-respect-toolbar-auto-crossfade-button-on-split": 43195,
1880 "options-ripple-all-tracks-when-ripple-is-enabled": 43469,
1881 "options-ripple-edit-all-affects-envelopes-on-all-tracks": 43567,
1882 "options-ripple-edit-all-affects-tempo-map": 42011,
1883 "options-ripple-edit-when-editing-media-item-edges": 43475,
1884 "options-ripple-per-track-affects-each-track-lane-separately": 43467,
1885 "options-ripple-per-track-when-ripple-is-enabled": 43468,
1886 "options-select-takes-for-all-selected-items-when-clicking-take": 40249,
1887 "options-selecting-one-grouped-item-selects-group": 41156,
1888 "options-set-loop-points-linked-to-time-selection": 40749,
1889 "options-set-metronome-speed-to-0-5x": 43703,
1890 "options-set-metronome-speed-to-1x": 42456,
1891 "options-set-metronome-speed-to-2x": 42457,
1892 "options-set-metronome-speed-to-4x": 42458,
1893 "options-set-metronome-volume-midi-cc-osc-only": 999,
1894 "options-show-all-takes-when-room": 40435,
1895 "options-show-empty-takes-align-takes-by-recording-pass": 41346,
1896 "options-show-fx-inserts-in-tcp-when-size-permits": 40302,
1897 "options-show-lock-settings": 40277,
1898 "options-show-metronome-pre-roll-settings": 40363,
1899 "options-show-peak-value-tooltips-on-media-items-and-loudness-if-peaks-are-configured-to-calculate-it": 43149,
1900 "options-show-sends-in-tcp-when-size-permits": 40677,
1901 "options-show-snap-grid-settings": 40071,
1902 "options-show-theme-adjuster": 42234,
1903 "options-show-theme-color-controls": 42392,
1904 "options-show-tooltips-on-media-items-and-envelopes": 41344,
1905 "options-solo-in-front": 40745,
1906 "options-solo-via-dedicated-solo-bus-preference-master-outputs-can-be-set-to-bypass-solo-bus": 43631,
1907 "options-switch-to-a-random-color-theme": 40383,
1908 "options-switch-to-next-color-theme": 40381,
1909 "options-switch-to-previous-color-theme": 40382,
1910 "options-toggle-always-on-top": 40239,
1911 "options-toggle-auto-fade-auto-crossfade-when-comping-in-fixed-lanes": 42631,
1912 "options-toggle-crossfade-on-split-disregard-toolbar-auto-crossfade-button": 40912,
1913 "options-toggle-display-group-names-colors-in-arrange-view": 43651,
1914 "options-toggle-editing-active-take-source-start-offset-slip-editing-adjusts-all-takes": 41338,
1915 "options-toggle-grid-lines": 40145,
1916 "options-toggle-item-grouping-and-track-media-razor-edit-grouping": 1156,
1917 "options-toggle-locking": 1135,
1918 "options-toggle-loop-points-linked-to-time-selection": 40621,
1919 "options-toggle-metronome": 40364,
1920 "options-toggle-new-recording-adds-lanes-in-layers-multiple-lanes-play-at-once": 41329,
1921 "options-toggle-new-recording-adds-lanes-new-lanes-play-exclusively": 42702,
1922 "options-toggle-pooled-ghost-midi-source-data-when-copying-media-items": 41071,
1923 "options-toggle-running-fx-when-playback-is-stopped": 41583,
1924 "options-toggle-smooth-seek-see-preferences-audio-seeking": 40390,
1925 "options-toggle-snapping": 1157,
1926 "options-track-media-razor-edit-grouping-affects-only-items-that-start-and-end-at-the-same-time": 42788,
1927 "options-trim-content-behind-automation-items-when-editing-or-writing-automation": 42206,
1928 "options-trim-content-behind-media-items-when-editing": 41117,
1929 "options-unset-loop-points-linked-to-time-selection": 40750,
1930 "options-when-auto-punch-recording-into-a-fixed-lane-track-add-the-whole-recording": 42793,
1931 "options-when-importing-copy-imported-media-to-project-media-directory": 40263,
1932 "peaks-build-any-missing-peaks": 40047,
1933 "peaks-build-any-missing-peaks-for-selected-items": 40245,
1934 "peaks-decrease-peaks-display-zoom-for-project": 40156,
1935 "peaks-force-mono-peaks": 42626,
1936 "peaks-increase-peaks-display-zoom-for-project": 40155,
1937 "peaks-load-spectral-peaks-preset-1": 42077,
1938 "peaks-load-spectral-peaks-preset-2": 42078,
1939 "peaks-load-spectral-peaks-preset-3": 42079,
1940 "peaks-load-spectral-peaks-preset-4": 42080,
1941 "peaks-load-spectral-peaks-preset-5": 42081,
1942 "peaks-load-spectrogram-preset-1": 42296,
1943 "peaks-load-spectrogram-preset-2": 42297,
1944 "peaks-load-spectrogram-preset-3": 42298,
1945 "peaks-load-spectrogram-preset-4": 42299,
1946 "peaks-load-spectrogram-preset-5": 42300,
1947 "peaks-rebuild-all-peaks": 40048,
1948 "peaks-rebuild-peaks-for-selected-items": 40441,
1949 "peaks-rectify-peaks": 42307,
1950 "peaks-remove-all-peak-cache-files": 40097,
1951 "peaks-reset-peaks-display-zoom-for-project": 42449,
1952 "peaks-scale-peaks-by-square-root-half-of-range-is-12db-rather-than-6db": 42306,
1953 "peaks-show-normal-peaks": 42301,
1954 "peaks-toggle-color-peaks-by-momentary-loudness-lufs-m": 43145,
1955 "peaks-toggle-color-peaks-by-short-term-loudness-lufs-s": 43147,
1956 "peaks-toggle-normal-peaks-plus-spectrogram": 42295,
1957 "peaks-toggle-show-graph-of-momentary-loudness-lufs-m": 43146,
1958 "peaks-toggle-show-graph-of-short-term-loudness-lufs-s": 43148,
1959 "peaks-toggle-show-spectral-peaks-and-graph-of-momentary-loudness-lufs-m": 43207,
1960 "peaks-toggle-show-spectral-peaks-and-graph-of-short-term-loudness-lufs-s": 43208,
1961 "peaks-toggle-spectral-peaks": 42073,
1962 "peaks-toggle-spectral-peaks-plus-spectrogram": 43209,
1963 "peaks-toggle-spectrogram": 42294,
1964 "performance-meter-reset-graph": 40602,
1965 "pre-roll-toggle-pre-roll-on-play": 41818,
1966 "pre-roll-toggle-pre-roll-on-record": 41819,
1967 "pre-roll-toggle-pre-roll-on-record-deprecated-duplicate": 41038,
1968 "project-bay-add-comment-for-items": 40195,
1969 "project-bay-force-refresh": 1582,
1970 "project-bay-insert-items-into-project": 41856,
1971 "project-bay-remove-items-from-project": 41586,
1972 "project-project-timebase-affects-midi-items": 43641,
1973 "project-recording-settings": 40934,
1974 "project-set-project-timebase-to-beats-auto-stretch-at-tempo-changes": 43640,
1975 "project-set-project-timebase-to-beats-position-length-rate": 43462,
1976 "project-set-project-timebase-to-beats-position-only": 43463,
1977 "project-set-project-timebase-to-time": 43461,
1978 "project-set-tempo-time-signature-envelope-timebase-to-beats": 43571,
1979 "project-set-tempo-time-signature-envelope-timebase-to-beats-for-time-signature-time-for-tempo": 43572,
1980 "project-set-tempo-time-signature-envelope-timebase-to-time": 43570,
1981 "project-tabs-always-show-project-tabs": 40874,
1982 "project-tabs-auto-offline-background-project-media": 40872,
1983 "project-tabs-defer-rendering-of-subprojects-render-on-tab-switch-rather-than-save": 41998,
1984 "project-tabs-display-video-from-background-projects-if-active-project-lacks-video": 42653,
1985 "project-tabs-do-not-automatically-render-subprojects-require-manual-render": 42333,
1986 "project-tabs-force-project-tabs-visible-when-monitoring-fx-in-use": 42072,
1987 "project-tabs-hide-all-background-project-windows": 40909,
1988 "project-tabs-leave-subproject-open-in-tab-after-automatic-open-and-render": 42012,
1989 "project-tabs-move-project-tab-left-by-one": 3242,
1990 "project-tabs-move-project-tab-right-by-one": 3243,
1991 "project-tabs-move-project-tab-to-position-1": 3182,
1992 "project-tabs-move-project-tab-to-position-10": 3191,
1993 "project-tabs-move-project-tab-to-position-2": 3183,
1994 "project-tabs-move-project-tab-to-position-3": 3184,
1995 "project-tabs-move-project-tab-to-position-4": 3185,
1996 "project-tabs-move-project-tab-to-position-5": 3186,
1997 "project-tabs-move-project-tab-to-position-6": 3187,
1998 "project-tabs-move-project-tab-to-position-7": 3188,
1999 "project-tabs-move-project-tab-to-position-8": 3189,
2000 "project-tabs-move-project-tab-to-position-9": 3190,
2001 "project-tabs-move-project-tab-to-position-n": 3212,
2002 "project-tabs-move-project-tab-to-position-n-1": 3213,
2003 "project-tabs-move-project-tab-to-position-n-2": 3214,
2004 "project-tabs-move-project-tab-to-position-n-3": 3215,
2005 "project-tabs-move-project-tab-to-position-n-4": 3216,
2006 "project-tabs-move-project-tab-to-position-n-5": 3217,
2007 "project-tabs-move-project-tab-to-position-n-6": 3218,
2008 "project-tabs-move-project-tab-to-position-n-7": 3219,
2009 "project-tabs-move-project-tab-to-position-n-8": 3220,
2010 "project-tabs-move-project-tab-to-position-n-9": 3221,
2011 "project-tabs-play-stopped-background-projects-with-active-project": 41062,
2012 "project-tabs-prompt-before-automatic-rerender-of-background-subprojects": 42334,
2013 "project-tabs-run-background-projects": 40871,
2014 "project-tabs-run-stopped-background-projects": 40873,
2015 "project-tabs-show-project-tabs-on-left-side-of-window": 41883,
2016 "project-tabs-switch-to-next-project-tab": 40861,
2017 "project-tabs-switch-to-previous-project-tab": 40862,
2018 "project-tabs-switch-to-previously-active-project-tab": 3121,
2019 "project-tabs-switch-to-project-tab-1": 3122,
2020 "project-tabs-switch-to-project-tab-10": 3131,
2021 "project-tabs-switch-to-project-tab-2": 3123,
2022 "project-tabs-switch-to-project-tab-3": 3124,
2023 "project-tabs-switch-to-project-tab-4": 3125,
2024 "project-tabs-switch-to-project-tab-5": 3126,
2025 "project-tabs-switch-to-project-tab-6": 3127,
2026 "project-tabs-switch-to-project-tab-7": 3128,
2027 "project-tabs-switch-to-project-tab-8": 3129,
2028 "project-tabs-switch-to-project-tab-9": 3130,
2029 "project-tabs-switch-to-project-tab-n": 3152,
2030 "project-tabs-switch-to-project-tab-n-1": 3153,
2031 "project-tabs-switch-to-project-tab-n-2": 3154,
2032 "project-tabs-switch-to-project-tab-n-3": 3155,
2033 "project-tabs-switch-to-project-tab-n-4": 3156,
2034 "project-tabs-switch-to-project-tab-n-5": 3157,
2035 "project-tabs-switch-to-project-tab-n-6": 3158,
2036 "project-tabs-switch-to-project-tab-n-7": 3159,
2037 "project-tabs-switch-to-project-tab-n-8": 3160,
2038 "project-tabs-switch-to-project-tab-n-9": 3161,
2039 "project-tabs-synchronize-any-parent-projects-when-playing-back-subproject": 41994,
2040 "project-tabs-synchronize-play-start-times-w-play-background-projects": 41063,
2041 "project-toggle-project-timebase-to-time": 43637,
2042 "razor-edit-clear-all-areas": 42406,
2043 "razor-edit-create-area-from-cursor-to-mouse": 42412,
2044 "razor-edit-create-fixed-lane-comp-area": 42475,
2045 "razor-edit-enclose-media-items": 42630,
2046 "razor-edit-enclose-media-items-including-space-between-items": 42409,
2047 "razor-edit-move-areas-backwards-without-contents": 42400,
2048 "razor-edit-move-areas-down-without-contents": 42403,
2049 "razor-edit-move-areas-forwards-without-contents": 42399,
2050 "razor-edit-move-areas-up-without-contents": 42402,
2051 "razor-edit-move-nearest-area-edge-to-edit-cursor": 42498,
2052 "razor-edit-select-media-items-within-razor-edit-area": 42957,
2053 "razor-edit-set-loop-points-to-razor-edit-area": 42474,
2054 "reascript-clear-contents-of-reascript-console": 42664,
2055 "reascript-close-all-running-reascripts": 41898,
2056 "reascript-edit-new-reascript-eel2-or-lua": 41935,
2057 "reascript-open-reascript-documentation-html": 41065,
2058 "reascript-run-edit-last-reascript-eel2-or-lua": 41931,
2059 "reascript-run-edit-reascript-eel2-or-lua": 41928,
2060 "reascript-run-last-reascript-eel2-or-lua": 41061,
2061 "reascript-run-reascript-eel2-or-lua": 41060,
2062 "reascript-show-reascript-console": 42663,
2063 "record-add-recorded-media-to-project": 40670,
2064 "record-remove-recorded-media-not-yet-in-project": 40669,
2065 "record-set-record-mode-to-normal": 40252,
2066 "record-set-record-mode-to-selected-item-auto-punch": 40253,
2067 "record-set-record-mode-to-time-selection-auto-punch": 40076,
2068 "record-start-new-files-during-recording": 40666,
2069 "region-render-matrix-add-selected-tracks-to-render-list-for-all-regions": 41893,
2070 "region-render-matrix-render-all-tracks-for-all-regions": 41891,
2071 "region-render-matrix-render-master-mix-for-all-regions": 41890,
2072 "region-render-matrix-render-only-selected-tracks-for-all-regions": 41892,
2073 "regions-go-to-next-region-after-current-region-finishes-playing-smooth-seek": 41802,
2074 "regions-go-to-previous-region-after-current-region-finishes-playing-smooth-seek": 41801,
2075 "regions-go-to-region-01-after-current-region-finishes-playing-smooth-seek": 41761,
2076 "regions-go-to-region-02-after-current-region-finishes-playing-smooth-seek": 41762,
2077 "regions-go-to-region-03-after-current-region-finishes-playing-smooth-seek": 41763,
2078 "regions-go-to-region-04-after-current-region-finishes-playing-smooth-seek": 41764,
2079 "regions-go-to-region-05-after-current-region-finishes-playing-smooth-seek": 41765,
2080 "regions-go-to-region-06-after-current-region-finishes-playing-smooth-seek": 41766,
2081 "regions-go-to-region-07-after-current-region-finishes-playing-smooth-seek": 41767,
2082 "regions-go-to-region-08-after-current-region-finishes-playing-smooth-seek": 41768,
2083 "regions-go-to-region-09-after-current-region-finishes-playing-smooth-seek": 41769,
2084 "regions-go-to-region-10-after-current-region-finishes-playing-smooth-seek": 41770,
2085 "regions-go-to-region-11-after-current-region-finishes-playing-smooth-seek": 41771,
2086 "regions-go-to-region-12-after-current-region-finishes-playing-smooth-seek": 41772,
2087 "regions-go-to-region-13-after-current-region-finishes-playing-smooth-seek": 41773,
2088 "regions-go-to-region-14-after-current-region-finishes-playing-smooth-seek": 41774,
2089 "regions-go-to-region-15-after-current-region-finishes-playing-smooth-seek": 41775,
2090 "regions-go-to-region-16-after-current-region-finishes-playing-smooth-seek": 41776,
2091 "regions-go-to-region-17-after-current-region-finishes-playing-smooth-seek": 41777,
2092 "regions-go-to-region-18-after-current-region-finishes-playing-smooth-seek": 41778,
2093 "regions-go-to-region-19-after-current-region-finishes-playing-smooth-seek": 41779,
2094 "regions-go-to-region-20-after-current-region-finishes-playing-smooth-seek": 41780,
2095 "regions-go-to-region-21-after-current-region-finishes-playing-smooth-seek": 41781,
2096 "regions-go-to-region-22-after-current-region-finishes-playing-smooth-seek": 41782,
2097 "regions-go-to-region-23-after-current-region-finishes-playing-smooth-seek": 41783,
2098 "regions-go-to-region-24-after-current-region-finishes-playing-smooth-seek": 41784,
2099 "regions-go-to-region-25-after-current-region-finishes-playing-smooth-seek": 41785,
2100 "regions-go-to-region-26-after-current-region-finishes-playing-smooth-seek": 41786,
2101 "regions-go-to-region-27-after-current-region-finishes-playing-smooth-seek": 41787,
2102 "regions-go-to-region-28-after-current-region-finishes-playing-smooth-seek": 41788,
2103 "regions-go-to-region-29-after-current-region-finishes-playing-smooth-seek": 41789,
2104 "regions-go-to-region-30-after-current-region-finishes-playing-smooth-seek": 41790,
2105 "regions-go-to-region-31-after-current-region-finishes-playing-smooth-seek": 41791,
2106 "regions-go-to-region-32-after-current-region-finishes-playing-smooth-seek": 41792,
2107 "regions-go-to-region-33-after-current-region-finishes-playing-smooth-seek": 41793,
2108 "regions-go-to-region-34-after-current-region-finishes-playing-smooth-seek": 41794,
2109 "regions-go-to-region-35-after-current-region-finishes-playing-smooth-seek": 41795,
2110 "regions-go-to-region-36-after-current-region-finishes-playing-smooth-seek": 41796,
2111 "regions-go-to-region-37-after-current-region-finishes-playing-smooth-seek": 41797,
2112 "regions-go-to-region-38-after-current-region-finishes-playing-smooth-seek": 41798,
2113 "regions-go-to-region-39-after-current-region-finishes-playing-smooth-seek": 41799,
2114 "regions-go-to-region-40-after-current-region-finishes-playing-smooth-seek": 41800,
2115 "regions-select-unselect-all-regions-for-rendering": 42679,
2116 "regions-set-loop-points-to-current-region": 43102,
2117 "regions-set-loop-points-to-next-region": 43144,
2118 "regions-set-loop-points-to-previous-region": 43103,
2119 "remove-items-tracks-envelope-points-depending-on-focus": 40697,
2120 "remove-items-tracks-envelope-points-depending-on-focus-no-prompting": 40184,
2121 "render-all-queued-renders": 41207,
2122 "reset-all-midi-control-surface-devices": 42348,
2123 "reset-all-midi-devices": 41175,
2124 "reset-position-cascade-all-floating-windows": 41155,
2125 "reset-project-recording-pass-counter-recpass-wildcard": 41048,
2126 "reset-soft-takeover-for-all-midi-controller-assignments": 41070,
2127 "ruler-display-project-regions-markers-as-gridlines-in-arrange-view": 42328,
2128 "ruler-display-region-number-even-if-region-is-named": 42435,
2129 "ruler-display-region-number-name-when-region-edge-is-not-visible": 42436,
2130 "ruler-display-selected-regions-over-unselected-regions-when-overlapping": 43206,
2131 "ruler-display-tempo-and-time-signature-changes-in-separate-lanes-when-size-permits": 42325,
2132 "ruler-display-tempo-changes": 42326,
2133 "ruler-display-time-signature-changes": 42327,
2134 "ruler-display-time-signature-changes-as-gridlines-in-arrange-view": 42329,
2135 "ruler-reset-project-start-measure": 43348,
2136 "ruler-reset-project-start-time": 43346,
2137 "ruler-set-0-00-to-current-edit-cursor": 43345,
2138 "ruler-set-measure-1-to-nearest-measure-to-current-edit-cursor": 43347,
2139 "ruler-set-to-default-height": 42320,
2140 "ruler-set-to-maximum-height": 42322,
2141 "ruler-set-to-minimum-height": 42321,
2142 "ruler-show-hide-all-project-markers": 43485,
2143 "ruler-show-hide-all-project-regions": 43487,
2144 "ruler-show-hide-all-project-regions-and-markers": 43489,
2145 "ruler-show-hide-selected-project-markers": 43484,
2146 "ruler-show-hide-selected-project-regions": 43486,
2147 "ruler-show-hide-selected-project-regions-and-markers": 43488,
2148 "screenset-load-track-view-number-01": 40444,
2149 "screenset-load-track-view-number-02": 40445,
2150 "screenset-load-track-view-number-03": 40446,
2151 "screenset-load-track-view-number-04": 40447,
2152 "screenset-load-track-view-number-05": 40448,
2153 "screenset-load-track-view-number-06": 40449,
2154 "screenset-load-track-view-number-07": 40450,
2155 "screenset-load-track-view-number-08": 40451,
2156 "screenset-load-track-view-number-09": 40452,
2157 "screenset-load-track-view-number-10": 40453,
2158 "screenset-load-window-set-number-01": 40454,
2159 "screenset-load-window-set-number-02": 40455,
2160 "screenset-load-window-set-number-03": 40456,
2161 "screenset-load-window-set-number-04": 40457,
2162 "screenset-load-window-set-number-05": 40458,
2163 "screenset-load-window-set-number-06": 40459,
2164 "screenset-load-window-set-number-07": 40460,
2165 "screenset-load-window-set-number-08": 40461,
2166 "screenset-load-window-set-number-09": 40462,
2167 "screenset-load-window-set-number-10": 40463,
2168 "screenset-save-track-view-number-01": 40464,
2169 "screenset-save-track-view-number-02": 40465,
2170 "screenset-save-track-view-number-03": 40466,
2171 "screenset-save-track-view-number-04": 40467,
2172 "screenset-save-track-view-number-05": 40468,
2173 "screenset-save-track-view-number-06": 40469,
2174 "screenset-save-track-view-number-07": 40470,
2175 "screenset-save-track-view-number-08": 40471,
2176 "screenset-save-track-view-number-09": 40472,
2177 "screenset-save-track-view-number-10": 40473,
2178 "screenset-save-window-set-number-01": 40474,
2179 "screenset-save-window-set-number-02": 40475,
2180 "screenset-save-window-set-number-03": 40476,
2181 "screenset-save-window-set-number-04": 40477,
2182 "screenset-save-window-set-number-05": 40478,
2183 "screenset-save-window-set-number-06": 40479,
2184 "screenset-save-window-set-number-07": 40480,
2185 "screenset-save-window-set-number-08": 40481,
2186 "screenset-save-window-set-number-09": 40482,
2187 "screenset-save-window-set-number-10": 40483,
2188 "script-default-6-0-theme-adjuster-lua": 55810,
2189 "script-default-7-0-theme-adjuster-lua": 55811,
2190 "script-insert-addictive-drums-track-lua": 55816,
2191 "script-insert-addictive-drums-track-lua-55818": 55818,
2192 "script-insert-addictive-drums-track-lua-55822": 55822,
2193 "script-insert-armed-track-lua": 55813,
2194 "script-insert-blank-track-lua": 55817,
2195 "script-insert-blank-track-lua-55819": 55819,
2196 "script-insert-blank-track-lua-55823": 55823,
2197 "script-insert-instrument-track-lua": 55814,
2198 "script-insert-komplete-kontrol-track-lua": 55815,
2199 "script-insert-komplete-kontrol-track-lua-55820": 55820,
2200 "script-insert-komplete-kontrol-track-lua-55824": 55824,
2201 "script-lyrics-lua": 55808,
2202 "scrub-disable-looped-segment-scrub-at-edit-cursor": 41189,
2203 "scrub-enable-looped-segment-scrub-at-edit-cursor": 41188,
2204 "scrub-invert-looped-segment-scrub-range": 43617,
2205 "scrub-play-one-one-shot-segment-scrub-at-edit-cursor": 43594,
2206 "scrub-prompt-to-edit-looped-segment-scrub-range": 43632,
2207 "scrub-toggle-looped-segment-scrub-at-edit-cursor": 41187,
2208 "scrub-toggle-preference-for-one-shot-segment-scrub-when-moving-edit-cursor": 43593,
2209 "select-all-items-tracks-envelope-points-depending-on-focus": 40035,
2210 "selection-set-load-set-number-01": 41239,
2211 "selection-set-load-set-number-02": 41240,
2212 "selection-set-load-set-number-03": 41241,
2213 "selection-set-load-set-number-04": 41242,
2214 "selection-set-load-set-number-05": 41243,
2215 "selection-set-load-set-number-06": 41244,
2216 "selection-set-load-set-number-07": 41245,
2217 "selection-set-load-set-number-08": 41246,
2218 "selection-set-load-set-number-09": 41247,
2219 "selection-set-load-set-number-10": 41248,
2220 "selection-set-save-set-number-01": 41229,
2221 "selection-set-save-set-number-02": 41230,
2222 "selection-set-save-set-number-03": 41231,
2223 "selection-set-save-set-number-04": 41232,
2224 "selection-set-save-set-number-05": 41233,
2225 "selection-set-save-set-number-06": 41234,
2226 "selection-set-save-set-number-07": 41235,
2227 "selection-set-save-set-number-08": 41236,
2228 "selection-set-save-set-number-09": 41237,
2229 "selection-set-save-set-number-10": 41238,
2230 "send-all-notes-off-and-all-sounds-off-to-all-midi-outputs-plug-ins": 40345,
2231 "send-mute-track-receive-number-1": 41365,
2232 "send-mute-track-receive-number-2": 41366,
2233 "send-mute-track-receive-number-3": 41367,
2234 "send-mute-track-receive-number-4": 41368,
2235 "send-mute-track-receive-number-5": 41369,
2236 "send-mute-track-receive-number-6": 41370,
2237 "send-mute-track-receive-number-7": 41371,
2238 "send-mute-track-receive-number-8": 41372,
2239 "send-mute-track-send-number-1": 41357,
2240 "send-mute-track-send-number-2": 41358,
2241 "send-mute-track-send-number-3": 41359,
2242 "send-mute-track-send-number-4": 41360,
2243 "send-mute-track-send-number-5": 41361,
2244 "send-mute-track-send-number-6": 41362,
2245 "send-mute-track-send-number-7": 41363,
2246 "send-mute-track-send-number-8": 41364,
2247 "set-project-recording-pass-counter-recpass-wildcard": 42032,
2248 "set-project-recording-tag-rectag-wildcard": 43464,
2249 "set-project-tempo-from-time-selection-detect-tempo": 41597,
2250 "set-project-tempo-from-time-selection-detect-tempo-align-items-and-loop-points-to-measure-start": 40002,
2251 "set-project-tempo-from-time-selection-new-time-signature": 40843,
2252 "set-ripple-editing-all-tracks": 40311,
2253 "set-ripple-editing-off": 40309,
2254 "set-ripple-editing-on": 1161,
2255 "set-ripple-editing-per-track": 40310,
2256 "set-tempo-coarse-latch-for-fine-midi-cc-osc-only": 983,
2257 "set-tempo-coarse-midi-cc-osc-only": 984,
2258 "set-tempo-fine-midi-cc-osc-only": 985,
2259 "show-action-list": 40605,
2260 "show-external-timecode-synchronization-settings": 40619,
2261 "show-reaper-resource-path-in-finder": 40027,
2262 "show-record-path-in-finder": 40024,
2263 "show-secondary-record-path-in-finder": 40028,
2264 "show-startup-splash-screen": 41535,
2265 "snapping-disable-snap": 40753,
2266 "snapping-enable-snap": 40754,
2267 "snapping-restore-snap-state": 40756,
2268 "snapping-save-snap-state": 40755,
2269 "spectrogram-add-spectral-edit-to-item": 42302,
2270 "spectrogram-adjust-brightness-midi-cc-mousewheel-osc-only": 24000,
2271 "spectrogram-adjust-color-curve-midi-cc-mousewheel-osc-only": 24002,
2272 "spectrogram-adjust-contrast-midi-cc-mousewheel-osc-only": 24001,
2273 "spectrogram-adjust-frequency-log-scaling-midi-cc-mousewheel-osc-only": 24003,
2274 "spectrogram-adjust-gain-of-selected-spectral-edits-by-0-5-db": 43683,
2275 "spectrogram-adjust-gain-of-selected-spectral-edits-by-3-db": 43685,
2276 "spectrogram-adjust-gain-of-selected-spectral-edits-by-plus-0-5-db": 43682,
2277 "spectrogram-adjust-gain-of-selected-spectral-edits-by-plus-3-db": 43684,
2278 "spectrogram-bypass-selected-spectral-edits": 43680,
2279 "spectrogram-delete-selected-spectral-edits": 43687,
2280 "spectrogram-reset-gain-of-selected-spectral-edits-to-plus-0-db": 43686,
2281 "spectrogram-show-high-resolution-spectrogram-when-zoomed-in": 43679,
2282 "spectrogram-show-selected-spectral-edit-configuration-menu": 43688,
2283 "spectrogram-solo-selected-spectral-edits": 43681,
2284 "spectrogram-toggle-always-show-spectrogram-for-selected-items": 42303,
2285 "sws-about": 53929,
2286 "sws-add-item-s-to-left-of-selected-item-s-to-selection": 53660,
2287 "sws-add-item-s-to-right-of-selected-item-s-to-selection": 53659,
2288 "sws-add-related-project-s": 53218,
2289 "sws-add-selected-track-s-to-all-snapshots": 53158,
2290 "sws-add-selected-track-s-to-current-snapshot": 53157,
2291 "sws-analyze-and-display-item-peak-and-rms-entire-item": 53567,
2292 "sws-apply-auto-coloring": 53006,
2293 "sws-aw-cascade-selected-track-inputs": 53559,
2294 "sws-aw-consolidate-selection": 53510,
2295 "sws-aw-disable-clear-loop-points-on-click-in-ruler": 53527,
2296 "sws-aw-disable-count-in-before-playback": 53518,
2297 "sws-aw-disable-count-in-before-recording": 53521,
2298 "sws-aw-disable-link-time-selection-and-edit-cursor": 53524,
2299 "sws-aw-disable-metronome-during-playback": 53512,
2300 "sws-aw-disable-metronome-during-recording": 53515,
2301 "sws-aw-enable-clear-loop-points-on-click-in-ruler": 53526,
2302 "sws-aw-enable-count-in-before-playback": 53517,
2303 "sws-aw-enable-count-in-before-recording": 53520,
2304 "sws-aw-enable-link-time-selection-and-edit-cursor": 53523,
2305 "sws-aw-enable-metronome-during-playback": 53511,
2306 "sws-aw-enable-metronome-during-recording": 53514,
2307 "sws-aw-fade-in-out-crossfade-selected-area-of-selected-items": 53506,
2308 "sws-aw-fill-gaps-between-selected-items-advanced": 53492,
2309 "sws-aw-fill-gaps-between-selected-items-advanced-use-last-settings": 53493,
2310 "sws-aw-fill-gaps-between-selected-items-quick-crossfade-using-default-fade-length": 53495,
2311 "sws-aw-fill-gaps-between-selected-items-quick-no-crossfade": 53494,
2312 "sws-aw-insert-click-track": 53555,
2313 "sws-aw-nf-toggle-assign-random-colors-if-auto-group-newly-recorded-items-is-enabled": 53504,
2314 "sws-aw-paste": 53553,
2315 "sws-aw-play-stop-automatically-group-simultaneously-recorded-items-deprecated": 53503,
2316 "sws-aw-record-automatically-group-simultaneously-recorded-items-deprecated": 53500,
2317 "sws-aw-record-conditional-normal-or-time-selection-only": 53497,
2318 "sws-aw-record-conditional-normal-or-time-selection-only-automatically-group-simultaneously-recorded-items-deprecated":
2319 53501,
2320 "sws-aw-record-conditional-normal-time-selection-item-selection-automatically-group-simultaneously-recorded-items-deprecated":
2321 53502,
2322 "sws-aw-record-conditional-normal-time-selection-or-item-selection": 53498,
2323 "sws-aw-remove-overlaps-in-selected-items-preserving-item-starts": 53496,
2324 "sws-aw-remove-tracks-items-env-obeying-time-selection-and-leaving-children": 53554,
2325 "sws-aw-render-tracks-to-mono-stem-tracks-obeying-time-selection": 53558,
2326 "sws-aw-render-tracks-to-stereo-stem-tracks-obeying-time-selection": 53557,
2327 "sws-aw-select-all-items-in-group-if-grouping-is-enabled": 53560,
2328 "sws-aw-select-from-cursor-to-end-of-project-items-and-time-selection": 53505,
2329 "sws-aw-set-grid-to-1-128-preserving-grid-type": 53552,
2330 "sws-aw-set-grid-to-1-16-preserving-grid-type": 53549,
2331 "sws-aw-set-grid-to-1-2-preserving-grid-type": 53546,
2332 "sws-aw-set-grid-to-1-32-preserving-grid-type": 53550,
2333 "sws-aw-set-grid-to-1-4-preserving-grid-type": 53547,
2334 "sws-aw-set-grid-to-1-64-preserving-grid-type": 53551,
2335 "sws-aw-set-grid-to-1-8-preserving-grid-type": 53548,
2336 "sws-aw-set-grid-to-1-preserving-grid-type": 53545,
2337 "sws-aw-set-grid-to-2-preserving-grid-type": 53544,
2338 "sws-aw-set-grid-to-4-preserving-grid-type": 53543,
2339 "sws-aw-set-project-timebase-to-beats-position-length-rate": 53531,
2340 "sws-aw-set-project-timebase-to-beats-position-only": 53530,
2341 "sws-aw-set-project-timebase-to-time": 53529,
2342 "sws-aw-set-selected-items-timebase-to-beats-auto-stretch-at-tempo-changes": 53566,
2343 "sws-aw-set-selected-items-timebase-to-beats-position-length-rate": 53539,
2344 "sws-aw-set-selected-items-timebase-to-beats-position-only": 53538,
2345 "sws-aw-set-selected-items-timebase-to-project-track-default": 53536,
2346 "sws-aw-set-selected-items-timebase-to-time": 53537,
2347 "sws-aw-set-selected-tracks-pan-mode-to-3-x-balance": 53563,
2348 "sws-aw-set-selected-tracks-pan-mode-to-dual-pan": 53565,
2349 "sws-aw-set-selected-tracks-pan-mode-to-stereo-balance": 53562,
2350 "sws-aw-set-selected-tracks-pan-mode-to-stereo-pan": 53564,
2351 "sws-aw-set-selected-tracks-timebase-to-beats-position-length-rate": 53535,
2352 "sws-aw-set-selected-tracks-timebase-to-beats-position-only": 53534,
2353 "sws-aw-set-selected-tracks-timebase-to-project-default": 53532,
2354 "sws-aw-set-selected-tracks-timebase-to-time": 53533,
2355 "sws-aw-split-selected-items-at-edit-cursor-w-crossfade-on-left": 53561,
2356 "sws-aw-stretch-selected-items-to-fill-selection": 53509,
2357 "sws-aw-toggle-auto-group-newly-recorded-items": 53499,
2358 "sws-aw-toggle-clear-loop-points-on-click-in-ruler": 53528,
2359 "sws-aw-toggle-click-track-mute": 53556,
2360 "sws-aw-toggle-count-in-before-playback": 53519,
2361 "sws-aw-toggle-count-in-before-recording": 53522,
2362 "sws-aw-toggle-dotted-grid": 53541,
2363 "sws-aw-toggle-link-time-selection-and-edit-cursor": 53525,
2364 "sws-aw-toggle-metronome-during-playback": 53513,
2365 "sws-aw-toggle-metronome-during-recording": 53516,
2366 "sws-aw-toggle-swing-grid": 53542,
2367 "sws-aw-toggle-triplet-grid": 53540,
2368 "sws-aw-trim-selected-items-to-fill-selection": 53508,
2369 "sws-aw-trim-selected-items-to-selection-or-cursor-crop": 53507,
2370 "sws-br-add-envelope-points-located-between-grid-to-existing-selection": 54041,
2371 "sws-br-add-envelope-points-located-between-grid-to-existing-selection-obey-time-selection-if-any": 54042,
2372 "sws-br-add-envelope-points-located-on-grid-to-existing-selection": 54039,
2373 "sws-br-add-envelope-points-located-on-grid-to-existing-selection-obey-time-selection-if-any": 54040,
2374 "sws-br-adjust-playrate-midi-cc-only": 54782,
2375 "sws-br-adjust-playrate-options": 54783,
2376 "sws-br-alter-slope-of-gradual-tempo-marker-decrease-0-001-bpm": 54826,
2377 "sws-br-alter-slope-of-gradual-tempo-marker-decrease-0-001-percent": 54834,
2378 "sws-br-alter-slope-of-gradual-tempo-marker-decrease-0-01-bpm": 54827,
2379 "sws-br-alter-slope-of-gradual-tempo-marker-decrease-0-01-percent": 54835,
2380 "sws-br-alter-slope-of-gradual-tempo-marker-decrease-0-1-bpm": 54828,
2381 "sws-br-alter-slope-of-gradual-tempo-marker-decrease-0-1-percent": 54836,
2382 "sws-br-alter-slope-of-gradual-tempo-marker-decrease-01-bpm": 54829,
2383 "sws-br-alter-slope-of-gradual-tempo-marker-decrease-01-percent": 54837,
2384 "sws-br-alter-slope-of-gradual-tempo-marker-increase-0-001-bpm": 54822,
2385 "sws-br-alter-slope-of-gradual-tempo-marker-increase-0-001-percent": 54830,
2386 "sws-br-alter-slope-of-gradual-tempo-marker-increase-0-01-bpm": 54823,
2387 "sws-br-alter-slope-of-gradual-tempo-marker-increase-0-01-percent": 54831,
2388 "sws-br-alter-slope-of-gradual-tempo-marker-increase-0-1-bpm": 54824,
2389 "sws-br-alter-slope-of-gradual-tempo-marker-increase-0-1-percent": 54832,
2390 "sws-br-alter-slope-of-gradual-tempo-marker-increase-01-bpm": 54825,
2391 "sws-br-alter-slope-of-gradual-tempo-marker-increase-01-percent": 54833,
2392 "sws-br-analyze-loudness": 54171,
2393 "sws-br-apply-next-action-to-all-visible-envelopes-in-selected-tracks": 54100,
2394 "sws-br-apply-next-action-to-all-visible-envelopes-in-selected-tracks-if-there-is-no-track-envelope-selected": 54102,
2395 "sws-br-apply-next-action-to-all-visible-record-armed-envelopes-in-selected-tracks": 54101,
2396 "sws-br-apply-next-action-to-all-visible-record-armed-envelopes-in-selected-tracks-if-there-is-no-track-envelope-selected":
2397 54103,
2398 "sws-br-check-for-new-sws-version": 55821,
2399 "sws-br-contextual-toolbars": 53938,
2400 "sws-br-convert-project-markers-to-tempo-markers": 54846,
2401 "sws-br-convert-selected-envelope-s-curve-in-time-selection-to-cc-events-in-last-clicked-cc-lane-in-last-active-midi-editor":
2402 54051,
2403 "sws-br-convert-selected-envelope-s-curve-in-time-selection-to-cc-events-in-last-clicked-cc-lane-in-last-active-midi-editor-clear-existing-events":
2404 54052,
2405 "sws-br-convert-selected-points-in-selected-envelope-to-cc-events-in-last-clicked-cc-lane-in-last-active-midi-editor":
2406 54049,
2407 "sws-br-convert-selected-points-in-selected-envelope-to-cc-events-in-last-clicked-cc-lane-in-last-active-midi-editor-clear-existing-events":
2408 54050,
2409 "sws-br-copy-points-in-time-selection-in-selected-envelope-to-all-visible-envelopes-in-selected-tracks": 54072,
2410 "sws-br-copy-points-in-time-selection-in-selected-envelope-to-all-visible-envelopes-in-selected-tracks-paste-at-edit-cursor":
2411 54076,
2412 "sws-br-copy-points-in-time-selection-in-selected-envelope-to-all-visible-record-armed-in-envelopes-of-selected-tracks":
2413 54073,
2414 "sws-br-copy-points-in-time-selection-in-selected-envelope-to-all-visible-record-armed-in-envelopes-of-selected-tracks-paste-at-edit-cursor":
2415 54077,
2416 "sws-br-copy-points-in-time-selection-in-selected-envelope-to-envelope-at-mouse-cursor": 54079,
2417 "sws-br-copy-points-in-time-selection-in-selected-envelope-to-envelope-at-mouse-cursor-paste-at-edit-cursor": 54081,
2418 "sws-br-copy-selected-points-in-selected-envelope-to-all-visible-envelopes-in-selected-tracks": 54070,
2419 "sws-br-copy-selected-points-in-selected-envelope-to-all-visible-envelopes-in-selected-tracks-paste-at-edit-cursor":
2420 54074,
2421 "sws-br-copy-selected-points-in-selected-envelope-to-all-visible-record-armed-envelopes-in-selected-tracks": 54071,
2422 "sws-br-copy-selected-points-in-selected-envelope-to-all-visible-record-armed-envelopes-in-selected-tracks-paste-at-edit-cursor":
2423 54075,
2424 "sws-br-copy-selected-points-in-selected-envelope-to-envelope-at-mouse-cursor": 54078,
2425 "sws-br-copy-selected-points-in-selected-envelope-to-to-envelope-at-mouse-cursor-paste-at-edit-cursor": 54080,
2426 "sws-br-copy-take-media-source-file-path-of-selected-items-to-clipboard": 54503,
2427 "sws-br-create-project-marker-at-mouse-cursor": 54472,
2428 "sws-br-create-project-marker-at-mouse-cursor-obey-snapping": 54473,
2429 "sws-br-create-project-markers-from-notes-in-selected-midi-items": 54470,
2430 "sws-br-create-project-markers-from-selected-items-name-by-item-s-notes": 54474,
2431 "sws-br-create-project-markers-from-selected-tempo-markers": 54464,
2432 "sws-br-create-project-markers-from-stretch-markers-in-selected-items": 54471,
2433 "sws-br-create-regions-from-selected-items-name-by-item-s-notes": 54475,
2434 "sws-br-create-tempo-markers-at-grid-after-every-selected-tempo-marker": 54841,
2435 "sws-br-decrease-selected-envelope-points-by-0-1-db-volume-envelope-only": 54089,
2436 "sws-br-decrease-selected-envelope-points-by-0-5-db-volume-envelope-only": 54090,
2437 "sws-br-decrease-selected-envelope-points-by-1-db-volume-envelope-only": 54091,
2438 "sws-br-decrease-selected-envelope-points-by-10-db-volume-envelope-only": 54093,
2439 "sws-br-decrease-selected-envelope-points-by-5-db-volume-envelope-only": 54092,
2440 "sws-br-decrease-tempo-marker-0-001-bpm-preserve-overall-tempo": 54810,
2441 "sws-br-decrease-tempo-marker-0-001-percent-preserve-overall-tempo": 54818,
2442 "sws-br-decrease-tempo-marker-0-01-bpm-preserve-overall-tempo": 54811,
2443 "sws-br-decrease-tempo-marker-0-01-percent-preserve-overall-tempo": 54819,
2444 "sws-br-decrease-tempo-marker-0-1-bpm-preserve-overall-tempo": 54812,
2445 "sws-br-decrease-tempo-marker-0-1-percent-preserve-overall-tempo": 54820,
2446 "sws-br-decrease-tempo-marker-01-bpm-preserve-overall-tempo": 54813,
2447 "sws-br-decrease-tempo-marker-01-percent-preserve-overall-tempo": 54821,
2448 "sws-br-delete-envelope-point-at-mouse-cursor": 54098,
2449 "sws-br-delete-envelope-point-at-mouse-cursor-selected-envelope-only": 54097,
2450 "sws-br-delete-envelope-points-between-grid": 54045,
2451 "sws-br-delete-envelope-points-between-grid-obey-time-selection-if-any": 54046,
2452 "sws-br-delete-envelope-points-on-grid": 54043,
2453 "sws-br-delete-envelope-points-on-grid-obey-time-selection-if-any": 54044,
2454 "sws-br-delete-take-under-mouse-cursor": 54504,
2455 "sws-br-delete-tempo-marker-and-preserve-position-and-length-of-items-including-midi-events": 54839,
2456 "sws-br-delete-tempo-marker-and-preserve-position-and-length-of-selected-items-including-midi-events": 54840,
2457 "sws-br-delete-tempo-marker-preserve-overall-tempo-and-positions-if-possible": 54838,
2458 "sws-br-disable-ignore-project-tempo-for-selected-midi-items": 54465,
2459 "sws-br-disable-ignore-project-tempo-for-selected-midi-items-preserving-time-position-of-midi-events": 54466,
2460 "sws-br-enable-ignore-project-tempo-for-selected-midi-items-preserving-time-position-of-midi-events-use-tempo-at-item-s-start":
2461 54468,
2462 "sws-br-enable-ignore-project-tempo-for-selected-midi-items-use-tempo-at-item-s-start": 54467,
2463 "sws-br-exclusive-toggle-contextual-toolbar-under-mouse-cursor-preset-1": 53979,
2464 "sws-br-exclusive-toggle-contextual-toolbar-under-mouse-cursor-preset-2": 53984,
2465 "sws-br-exclusive-toggle-contextual-toolbar-under-mouse-cursor-preset-3": 53989,
2466 "sws-br-exclusive-toggle-contextual-toolbar-under-mouse-cursor-preset-4": 53994,
2467 "sws-br-exclusive-toggle-contextual-toolbar-under-mouse-cursor-preset-5": 53999,
2468 "sws-br-exclusive-toggle-contextual-toolbar-under-mouse-cursor-preset-6": 54004,
2469 "sws-br-exclusive-toggle-contextual-toolbar-under-mouse-cursor-preset-7": 54009,
2470 "sws-br-exclusive-toggle-contextual-toolbar-under-mouse-cursor-preset-8": 54014,
2471 "sws-br-expand-envelope-point-selection-to-the-left": 54028,
2472 "sws-br-expand-envelope-point-selection-to-the-left-end-point-only": 54030,
2473 "sws-br-expand-envelope-point-selection-to-the-right": 54027,
2474 "sws-br-expand-envelope-point-selection-to-the-right-end-point-only": 54029,
2475 "sws-br-fit-selected-envelope-points-to-time-selection": 54082,
2476 "sws-br-focus-arrange": 54482,
2477 "sws-br-focus-tracks": 54483,
2478 "sws-br-freehand-draw-envelope-while-snapping-points-to-left-side-grid-line-perform-until-shortcut-released": 54854,
2479 "sws-br-global-loudness-preferences": 54170,
2480 "sws-br-hide-all-but-selected-track-envelope-for-all-tracks": 54136,
2481 "sws-br-hide-all-but-selected-track-envelope-for-all-tracks-except-envelopes-in-separate-lanes": 54138,
2482 "sws-br-hide-all-but-selected-track-envelope-for-all-tracks-except-envelopes-in-track-lanes": 54140,
2483 "sws-br-hide-all-but-selected-track-envelope-for-selected-tracks": 54137,
2484 "sws-br-hide-all-but-selected-track-envelope-for-selected-tracks-except-envelopes-in-separate-lanes": 54139,
2485 "sws-br-hide-all-but-selected-track-envelope-for-selected-tracks-except-envelopes-in-track-lanes": 54141,
2486 "sws-br-hide-all-fx-envelopes-for-selected-tracks": 54149,
2487 "sws-br-hide-all-send-envelopes-for-selected-tracks": 54166,
2488 "sws-br-hide-mute-send-envelopes-for-selected-tracks": 54169,
2489 "sws-br-hide-pan-send-envelopes-for-selected-tracks": 54168,
2490 "sws-br-hide-volume-send-envelopes-for-selected-tracks": 54167,
2491 "sws-br-increase-selected-envelope-points-by-0-1-db-volume-envelope-only": 54084,
2492 "sws-br-increase-selected-envelope-points-by-0-5-db-volume-envelope-only": 54085,
2493 "sws-br-increase-selected-envelope-points-by-1-db-volume-envelope-only": 54086,
2494 "sws-br-increase-selected-envelope-points-by-10-db-volume-envelope-only": 54088,
2495 "sws-br-increase-selected-envelope-points-by-5-db-volume-envelope-only": 54087,
2496 "sws-br-increase-tempo-marker-0-001-bpm-preserve-overall-tempo": 54806,
2497 "sws-br-increase-tempo-marker-0-001-percent-preserve-overall-tempo": 54814,
2498 "sws-br-increase-tempo-marker-0-01-bpm-preserve-overall-tempo": 54807,
2499 "sws-br-increase-tempo-marker-0-01-percent-preserve-overall-tempo": 54815,
2500 "sws-br-increase-tempo-marker-0-1-bpm-preserve-overall-tempo": 54808,
2501 "sws-br-increase-tempo-marker-0-1-percent-preserve-overall-tempo": 54816,
2502 "sws-br-increase-tempo-marker-01-bpm-preserve-overall-tempo": 54809,
2503 "sws-br-increase-tempo-marker-01-percent-preserve-overall-tempo": 54817,
2504 "sws-br-insert-2-envelope-points-at-time-selection": 54067,
2505 "sws-br-insert-2-envelope-points-at-time-selection-to-all-visible-track-envelopes": 54068,
2506 "sws-br-insert-2-envelope-points-at-time-selection-to-all-visible-track-envelopes-in-selected-tracks": 54069,
2507 "sws-br-insert-envelope-points-on-grid-using-shape-of-the-previous-point": 54047,
2508 "sws-br-insert-envelope-points-on-grid-using-shape-of-the-previous-point-obey-time-selection-if-any": 54048,
2509 "sws-br-insert-new-envelope-point-at-mouse-cursor-using-value-at-current-position-obey-snapping": 54083,
2510 "sws-br-move-active-floating-track-fx-window-to-mouse-cursor-horizontal-left-vertical-bottom": 54493,
2511 "sws-br-move-active-floating-track-fx-window-to-mouse-cursor-horizontal-left-vertical-middle": 54494,
2512 "sws-br-move-active-floating-track-fx-window-to-mouse-cursor-horizontal-left-vertical-top": 54495,
2513 "sws-br-move-active-floating-track-fx-window-to-mouse-cursor-horizontal-middle-vertical-bottom": 54496,
2514 "sws-br-move-active-floating-track-fx-window-to-mouse-cursor-horizontal-middle-vertical-middle": 54497,
2515 "sws-br-move-active-floating-track-fx-window-to-mouse-cursor-horizontal-middle-vertical-top": 54498,
2516 "sws-br-move-active-floating-track-fx-window-to-mouse-cursor-horizontal-right-vertical-bottom": 54499,
2517 "sws-br-move-active-floating-track-fx-window-to-mouse-cursor-horizontal-right-vertical-middle": 54500,
2518 "sws-br-move-active-floating-track-fx-window-to-mouse-cursor-horizontal-right-vertical-top": 54501,
2519 "sws-br-move-active-floating-window-to-mouse-cursor-horizontal-left-vertical-bottom": 54484,
2520 "sws-br-move-active-floating-window-to-mouse-cursor-horizontal-left-vertical-middle": 54485,
2521 "sws-br-move-active-floating-window-to-mouse-cursor-horizontal-left-vertical-top": 54486,
2522 "sws-br-move-active-floating-window-to-mouse-cursor-horizontal-middle-vertical-bottom": 54487,
2523 "sws-br-move-active-floating-window-to-mouse-cursor-horizontal-middle-vertical-middle": 54488,
2524 "sws-br-move-active-floating-window-to-mouse-cursor-horizontal-middle-vertical-top": 54489,
2525 "sws-br-move-active-floating-window-to-mouse-cursor-horizontal-right-vertical-bottom": 54490,
2526 "sws-br-move-active-floating-window-to-mouse-cursor-horizontal-right-vertical-middle": 54491,
2527 "sws-br-move-active-floating-window-to-mouse-cursor-horizontal-right-vertical-top": 54492,
2528 "sws-br-move-closest-envelope-point-to-edit-cursor": 54065,
2529 "sws-br-move-closest-grid-line-to-edit-cursor": 54787,
2530 "sws-br-move-closest-grid-line-to-mouse-cursor-perform-until-shortcut-released": 54871,
2531 "sws-br-move-closest-grid-line-to-play-cursor": 54788,
2532 "sws-br-move-closest-left-side-grid-line-to-edit-cursor": 54791,
2533 "sws-br-move-closest-measure-grid-line-to-edit-cursor": 54789,
2534 "sws-br-move-closest-measure-grid-line-to-mouse-cursor-perform-until-shortcut-released": 54872,
2535 "sws-br-move-closest-measure-grid-line-to-play-cursor": 54790,
2536 "sws-br-move-closest-project-marker-to-edit-cursor": 54477,
2537 "sws-br-move-closest-project-marker-to-edit-cursor-obey-snapping": 54480,
2538 "sws-br-move-closest-project-marker-to-mouse-cursor": 54478,
2539 "sws-br-move-closest-project-marker-to-mouse-cursor-obey-snapping": 54481,
2540 "sws-br-move-closest-project-marker-to-play-cursor": 54476,
2541 "sws-br-move-closest-project-marker-to-play-cursor-obey-snapping": 54479,
2542 "sws-br-move-closest-right-side-grid-line-to-edit-cursor": 54792,
2543 "sws-br-move-closest-selected-envelope-point-to-edit-cursor": 54066,
2544 "sws-br-move-closest-tempo-marker-to-edit-cursor": 54805,
2545 "sws-br-move-closest-tempo-marker-to-mouse-cursor-perform-until-shortcut-released": 54870,
2546 "sws-br-move-edit-cursor-to-next-envelope-point": 54019,
2547 "sws-br-move-edit-cursor-to-next-envelope-point-and-add-to-selection": 54021,
2548 "sws-br-move-edit-cursor-to-next-envelope-point-and-select-it": 54020,
2549 "sws-br-move-edit-cursor-to-previous-envelope-point": 54022,
2550 "sws-br-move-edit-cursor-to-previous-envelope-point-and-add-to-selection": 54024,
2551 "sws-br-move-edit-cursor-to-previous-envelope-point-and-select-it": 54023,
2552 "sws-br-move-tempo-marker-back": 54804,
2553 "sws-br-move-tempo-marker-back-0-1-ms": 54798,
2554 "sws-br-move-tempo-marker-back-1-ms": 54799,
2555 "sws-br-move-tempo-marker-back-10-ms": 54800,
2556 "sws-br-move-tempo-marker-back-100-ms": 54801,
2557 "sws-br-move-tempo-marker-back-1000-ms": 54802,
2558 "sws-br-move-tempo-marker-forward": 54803,
2559 "sws-br-move-tempo-marker-forward-0-1-ms": 54793,
2560 "sws-br-move-tempo-marker-forward-1-ms": 54794,
2561 "sws-br-move-tempo-marker-forward-10-ms": 54795,
2562 "sws-br-move-tempo-marker-forward-100-ms": 54796,
2563 "sws-br-move-tempo-marker-forward-1000-ms": 54797,
2564 "sws-br-nf-toggle-use-dual-mono-mode-for-mono-takes-channel-modes-for-loudness-analyzing": 54178,
2565 "sws-br-nf-toggle-use-high-precision-mode-for-loudness-analyzing": 54177,
2566 "sws-br-normalize-loudness-of-selected-items-to-0-lu": 54174,
2567 "sws-br-normalize-loudness-of-selected-items-to-23-lufs": 54173,
2568 "sws-br-normalize-loudness-of-selected-items-tracks": 54172,
2569 "sws-br-normalize-loudness-of-selected-tracks-to-0-lu": 54176,
2570 "sws-br-normalize-loudness-of-selected-tracks-to-23-lufs": 54175,
2571 "sws-br-open-close-contextual-toolbar-under-mouse-cursor-preset-1": 53939,
2572 "sws-br-open-close-contextual-toolbar-under-mouse-cursor-preset-2": 53944,
2573 "sws-br-open-close-contextual-toolbar-under-mouse-cursor-preset-3": 53949,
2574 "sws-br-open-close-contextual-toolbar-under-mouse-cursor-preset-4": 53954,
2575 "sws-br-open-close-contextual-toolbar-under-mouse-cursor-preset-5": 53959,
2576 "sws-br-open-close-contextual-toolbar-under-mouse-cursor-preset-6": 53964,
2577 "sws-br-open-close-contextual-toolbar-under-mouse-cursor-preset-7": 53969,
2578 "sws-br-open-close-contextual-toolbar-under-mouse-cursor-preset-8": 53974,
2579 "sws-br-options-automatically-insert-stretch-markers-when-inserting-tempo-markers-with-sws-actions": 54720,
2580 "sws-br-options-cycle-through-record-modes": 54721,
2581 "sws-br-options-set-apply-trim-when-adding-volume-pan-envelopes-to-always": 54685,
2582 "sws-br-options-set-apply-trim-when-adding-volume-pan-envelopes-to-in-read-write": 54686,
2583 "sws-br-options-set-apply-trim-when-adding-volume-pan-envelopes-to-never": 54687,
2584 "sws-br-options-set-grid-line-z-order-to-over-items": 54714,
2585 "sws-br-options-set-grid-line-z-order-to-through-items": 54715,
2586 "sws-br-options-set-grid-line-z-order-to-under-items": 54716,
2587 "sws-br-options-set-marker-line-z-order-to-over-items": 54717,
2588 "sws-br-options-set-marker-line-z-order-to-through-items": 54718,
2589 "sws-br-options-set-marker-line-z-order-to-under-items": 54719,
2590 "sws-br-options-set-run-fx-after-stopping-for-to-0-ms": 54696,
2591 "sws-br-options-set-run-fx-after-stopping-for-to-100-ms": 54697,
2592 "sws-br-options-set-run-fx-after-stopping-for-to-1000-ms": 54699,
2593 "sws-br-options-set-run-fx-after-stopping-for-to-10000-ms": 54708,
2594 "sws-br-options-set-run-fx-after-stopping-for-to-2000-ms": 54700,
2595 "sws-br-options-set-run-fx-after-stopping-for-to-3000-ms": 54701,
2596 "sws-br-options-set-run-fx-after-stopping-for-to-4000-ms": 54702,
2597 "sws-br-options-set-run-fx-after-stopping-for-to-500-ms": 54698,
2598 "sws-br-options-set-run-fx-after-stopping-for-to-5000-ms": 54703,
2599 "sws-br-options-set-run-fx-after-stopping-for-to-6000-ms": 54704,
2600 "sws-br-options-set-run-fx-after-stopping-for-to-7000-ms": 54705,
2601 "sws-br-options-set-run-fx-after-stopping-for-to-8000-ms": 54706,
2602 "sws-br-options-set-run-fx-after-stopping-for-to-9000-ms": 54707,
2603 "sws-br-options-toggle-display-media-item-gain-if-set": 54690,
2604 "sws-br-options-toggle-display-media-item-pitch-playrate-if-set": 54689,
2605 "sws-br-options-toggle-display-media-item-take-name": 54688,
2606 "sws-br-options-toggle-flush-fx-on-stop": 54694,
2607 "sws-br-options-toggle-flush-fx-when-looping": 54695,
2608 "sws-br-options-toggle-grid-snap-settings-follow-grid-visibility": 54683,
2609 "sws-br-options-toggle-move-edit-cursor-to-end-of-recorded-items-on-record-stop": 54711,
2610 "sws-br-options-toggle-move-edit-cursor-to-start-of-time-selection-on-time-selection-change": 54709,
2611 "sws-br-options-toggle-move-edit-cursor-when-pasting-inserting-media": 54710,
2612 "sws-br-options-toggle-playback-position-follows-project-timebase-when-changing-tempo": 54684,
2613 "sws-br-options-toggle-reset-cc-on-stop-play": 54693,
2614 "sws-br-options-toggle-reset-pitch-on-stop-play": 54692,
2615 "sws-br-options-toggle-scroll-view-to-edit-cursor-on-stop": 54713,
2616 "sws-br-options-toggle-send-all-notes-off-on-stop-play": 54691,
2617 "sws-br-options-toggle-stop-repeat-playback-at-end-of-project": 54712,
2618 "sws-br-play-from-edit-cursor-position-and-solo-item-and-track-under-mouse-for-the-duration-perform-until-shortcut-released":
2619 54863,
2620 "sws-br-play-from-edit-cursor-position-and-solo-track-under-mouse-for-the-duration-perform-until-shortcut-released":
2621 54862,
2622 "sws-br-play-from-edit-cursor-position-perform-until-shortcut-released": 54861,
2623 "sws-br-play-from-mouse-cursor-position": 54459,
2624 "sws-br-play-from-mouse-cursor-position-and-solo-item-and-track-under-mouse-for-the-duration-perform-until-shortcut-released":
2625 54860,
2626 "sws-br-play-from-mouse-cursor-position-and-solo-track-under-mouse-for-the-duration-perform-until-shortcut-released":
2627 54859,
2628 "sws-br-play-from-mouse-cursor-position-perform-until-shortcut-released": 54858,
2629 "sws-br-play-pause-from-mouse-cursor-position": 54460,
2630 "sws-br-play-stop-from-mouse-cursor-position": 54461,
2631 "sws-br-preview-media-item-under-mouse": 54722,
2632 "sws-br-preview-media-item-under-mouse-and-pause-during-preview": 54725,
2633 "sws-br-preview-media-item-under-mouse-and-pause-during-preview-start-from-mouse-cursor-position": 54726,
2634 "sws-br-preview-media-item-under-mouse-at-track-fader-volume": 54727,
2635 "sws-br-preview-media-item-under-mouse-at-track-fader-volume-and-pause-during-preview": 54730,
2636 "sws-br-preview-media-item-under-mouse-at-track-fader-volume-and-pause-during-preview-start-from-mouse-cursor-position":
2637 54731,
2638 "sws-br-preview-media-item-under-mouse-at-track-fader-volume-start-from-mouse-position": 54728,
2639 "sws-br-preview-media-item-under-mouse-at-track-fader-volume-sync-with-next-measure": 54729,
2640 "sws-br-preview-media-item-under-mouse-start-from-mouse-cursor-position": 54723,
2641 "sws-br-preview-media-item-under-mouse-sync-with-next-measure": 54724,
2642 "sws-br-preview-media-item-under-mouse-through-track": 54732,
2643 "sws-br-preview-media-item-under-mouse-through-track-and-pause-during-preview": 54735,
2644 "sws-br-preview-media-item-under-mouse-through-track-and-pause-during-preview-start-from-mouse-cursor-position":
2645 54736,
2646 "sws-br-preview-media-item-under-mouse-through-track-start-from-mouse-position": 54733,
2647 "sws-br-preview-media-item-under-mouse-through-track-sync-with-next-measure": 54734,
2648 "sws-br-preview-take-under-mouse": 54752,
2649 "sws-br-preview-take-under-mouse-and-pause-during-preview": 54755,
2650 "sws-br-preview-take-under-mouse-and-pause-during-preview-start-from-mouse-cursor-position": 54756,
2651 "sws-br-preview-take-under-mouse-at-track-fader-volume": 54757,
2652 "sws-br-preview-take-under-mouse-at-track-fader-volume-and-pause-during-preview": 54760,
2653 "sws-br-preview-take-under-mouse-at-track-fader-volume-and-pause-during-preview-start-from-mouse-cursor-position":
2654 54761,
2655 "sws-br-preview-take-under-mouse-at-track-fader-volume-start-from-mouse-position": 54758,
2656 "sws-br-preview-take-under-mouse-at-track-fader-volume-sync-with-next-measure": 54759,
2657 "sws-br-preview-take-under-mouse-start-from-mouse-cursor-position": 54753,
2658 "sws-br-preview-take-under-mouse-sync-with-next-measure": 54754,
2659 "sws-br-preview-take-under-mouse-through-track": 54762,
2660 "sws-br-preview-take-under-mouse-through-track-and-pause-during-preview": 54765,
2661 "sws-br-preview-take-under-mouse-through-track-and-pause-during-preview-start-from-mouse-cursor-position": 54766,
2662 "sws-br-preview-take-under-mouse-through-track-start-from-mouse-position": 54763,
2663 "sws-br-preview-take-under-mouse-through-track-sync-with-next-measure": 54764,
2664 "sws-br-project-track-selection-action-clear": 54786,
2665 "sws-br-project-track-selection-action-set": 54784,
2666 "sws-br-project-track-selection-action-show": 54785,
2667 "sws-br-randomize-tempo-markers": 54848,
2668 "sws-br-reset-position-of-selected-partial-time-signature-markers": 54843,
2669 "sws-br-restore-edit-cursor-position-slot-01": 54539,
2670 "sws-br-restore-edit-cursor-position-slot-02": 54540,
2671 "sws-br-restore-edit-cursor-position-slot-03": 54541,
2672 "sws-br-restore-edit-cursor-position-slot-04": 54542,
2673 "sws-br-restore-edit-cursor-position-slot-05": 54543,
2674 "sws-br-restore-edit-cursor-position-slot-06": 54544,
2675 "sws-br-restore-edit-cursor-position-slot-07": 54545,
2676 "sws-br-restore-edit-cursor-position-slot-08": 54546,
2677 "sws-br-restore-edit-cursor-position-slot-09": 54547,
2678 "sws-br-restore-edit-cursor-position-slot-10": 54548,
2679 "sws-br-restore-edit-cursor-position-slot-11": 54549,
2680 "sws-br-restore-edit-cursor-position-slot-12": 54550,
2681 "sws-br-restore-edit-cursor-position-slot-13": 54551,
2682 "sws-br-restore-edit-cursor-position-slot-14": 54552,
2683 "sws-br-restore-edit-cursor-position-slot-15": 54553,
2684 "sws-br-restore-edit-cursor-position-slot-16": 54554,
2685 "sws-br-restore-envelope-point-selection-slot-01": 54120,
2686 "sws-br-restore-envelope-point-selection-slot-02": 54121,
2687 "sws-br-restore-envelope-point-selection-slot-03": 54122,
2688 "sws-br-restore-envelope-point-selection-slot-04": 54123,
2689 "sws-br-restore-envelope-point-selection-slot-05": 54124,
2690 "sws-br-restore-envelope-point-selection-slot-06": 54125,
2691 "sws-br-restore-envelope-point-selection-slot-07": 54126,
2692 "sws-br-restore-envelope-point-selection-slot-08": 54127,
2693 "sws-br-restore-envelope-point-selection-slot-09": 54128,
2694 "sws-br-restore-envelope-point-selection-slot-10": 54129,
2695 "sws-br-restore-envelope-point-selection-slot-11": 54130,
2696 "sws-br-restore-envelope-point-selection-slot-12": 54131,
2697 "sws-br-restore-envelope-point-selection-slot-13": 54132,
2698 "sws-br-restore-envelope-point-selection-slot-14": 54133,
2699 "sws-br-restore-envelope-point-selection-slot-15": 54134,
2700 "sws-br-restore-envelope-point-selection-slot-16": 54135,
2701 "sws-br-restore-items-mute-state-to-all-items-slot-01": 54603,
2702 "sws-br-restore-items-mute-state-to-all-items-slot-02": 54604,
2703 "sws-br-restore-items-mute-state-to-all-items-slot-03": 54605,
2704 "sws-br-restore-items-mute-state-to-all-items-slot-04": 54606,
2705 "sws-br-restore-items-mute-state-to-all-items-slot-05": 54607,
2706 "sws-br-restore-items-mute-state-to-all-items-slot-06": 54608,
2707 "sws-br-restore-items-mute-state-to-all-items-slot-07": 54609,
2708 "sws-br-restore-items-mute-state-to-all-items-slot-08": 54610,
2709 "sws-br-restore-items-mute-state-to-all-items-slot-09": 54611,
2710 "sws-br-restore-items-mute-state-to-all-items-slot-10": 54612,
2711 "sws-br-restore-items-mute-state-to-all-items-slot-11": 54613,
2712 "sws-br-restore-items-mute-state-to-all-items-slot-12": 54614,
2713 "sws-br-restore-items-mute-state-to-all-items-slot-13": 54615,
2714 "sws-br-restore-items-mute-state-to-all-items-slot-14": 54616,
2715 "sws-br-restore-items-mute-state-to-all-items-slot-15": 54617,
2716 "sws-br-restore-items-mute-state-to-all-items-slot-16": 54618,
2717 "sws-br-restore-items-mute-state-to-selected-items-slot-01": 54587,
2718 "sws-br-restore-items-mute-state-to-selected-items-slot-02": 54588,
2719 "sws-br-restore-items-mute-state-to-selected-items-slot-03": 54589,
2720 "sws-br-restore-items-mute-state-to-selected-items-slot-04": 54590,
2721 "sws-br-restore-items-mute-state-to-selected-items-slot-05": 54591,
2722 "sws-br-restore-items-mute-state-to-selected-items-slot-06": 54592,
2723 "sws-br-restore-items-mute-state-to-selected-items-slot-07": 54593,
2724 "sws-br-restore-items-mute-state-to-selected-items-slot-08": 54594,
2725 "sws-br-restore-items-mute-state-to-selected-items-slot-09": 54595,
2726 "sws-br-restore-items-mute-state-to-selected-items-slot-10": 54596,
2727 "sws-br-restore-items-mute-state-to-selected-items-slot-11": 54597,
2728 "sws-br-restore-items-mute-state-to-selected-items-slot-12": 54598,
2729 "sws-br-restore-items-mute-state-to-selected-items-slot-13": 54599,
2730 "sws-br-restore-items-mute-state-to-selected-items-slot-14": 54600,
2731 "sws-br-restore-items-mute-state-to-selected-items-slot-15": 54601,
2732 "sws-br-restore-items-mute-state-to-selected-items-slot-16": 54602,
2733 "sws-br-restore-tracks-solo-and-mute-state-to-all-tracks-slot-01": 54667,
2734 "sws-br-restore-tracks-solo-and-mute-state-to-all-tracks-slot-02": 54668,
2735 "sws-br-restore-tracks-solo-and-mute-state-to-all-tracks-slot-03": 54669,
2736 "sws-br-restore-tracks-solo-and-mute-state-to-all-tracks-slot-04": 54670,
2737 "sws-br-restore-tracks-solo-and-mute-state-to-all-tracks-slot-05": 54671,
2738 "sws-br-restore-tracks-solo-and-mute-state-to-all-tracks-slot-06": 54672,
2739 "sws-br-restore-tracks-solo-and-mute-state-to-all-tracks-slot-07": 54673,
2740 "sws-br-restore-tracks-solo-and-mute-state-to-all-tracks-slot-08": 54674,
2741 "sws-br-restore-tracks-solo-and-mute-state-to-all-tracks-slot-09": 54675,
2742 "sws-br-restore-tracks-solo-and-mute-state-to-all-tracks-slot-10": 54676,
2743 "sws-br-restore-tracks-solo-and-mute-state-to-all-tracks-slot-11": 54677,
2744 "sws-br-restore-tracks-solo-and-mute-state-to-all-tracks-slot-12": 54678,
2745 "sws-br-restore-tracks-solo-and-mute-state-to-all-tracks-slot-13": 54679,
2746 "sws-br-restore-tracks-solo-and-mute-state-to-all-tracks-slot-14": 54680,
2747 "sws-br-restore-tracks-solo-and-mute-state-to-all-tracks-slot-15": 54681,
2748 "sws-br-restore-tracks-solo-and-mute-state-to-all-tracks-slot-16": 54682,
2749 "sws-br-restore-tracks-solo-and-mute-state-to-selected-tracks-slot-01": 54651,
2750 "sws-br-restore-tracks-solo-and-mute-state-to-selected-tracks-slot-02": 54652,
2751 "sws-br-restore-tracks-solo-and-mute-state-to-selected-tracks-slot-03": 54653,
2752 "sws-br-restore-tracks-solo-and-mute-state-to-selected-tracks-slot-04": 54654,
2753 "sws-br-restore-tracks-solo-and-mute-state-to-selected-tracks-slot-05": 54655,
2754 "sws-br-restore-tracks-solo-and-mute-state-to-selected-tracks-slot-06": 54656,
2755 "sws-br-restore-tracks-solo-and-mute-state-to-selected-tracks-slot-07": 54657,
2756 "sws-br-restore-tracks-solo-and-mute-state-to-selected-tracks-slot-08": 54658,
2757 "sws-br-restore-tracks-solo-and-mute-state-to-selected-tracks-slot-09": 54659,
2758 "sws-br-restore-tracks-solo-and-mute-state-to-selected-tracks-slot-10": 54660,
2759 "sws-br-restore-tracks-solo-and-mute-state-to-selected-tracks-slot-11": 54661,
2760 "sws-br-restore-tracks-solo-and-mute-state-to-selected-tracks-slot-12": 54662,
2761 "sws-br-restore-tracks-solo-and-mute-state-to-selected-tracks-slot-13": 54663,
2762 "sws-br-restore-tracks-solo-and-mute-state-to-selected-tracks-slot-14": 54664,
2763 "sws-br-restore-tracks-solo-and-mute-state-to-selected-tracks-slot-15": 54665,
2764 "sws-br-restore-tracks-solo-and-mute-state-to-selected-tracks-slot-16": 54666,
2765 "sws-br-save-all-items-mute-state-slot-01": 54571,
2766 "sws-br-save-all-items-mute-state-slot-02": 54572,
2767 "sws-br-save-all-items-mute-state-slot-03": 54573,
2768 "sws-br-save-all-items-mute-state-slot-04": 54574,
2769 "sws-br-save-all-items-mute-state-slot-05": 54575,
2770 "sws-br-save-all-items-mute-state-slot-06": 54576,
2771 "sws-br-save-all-items-mute-state-slot-07": 54577,
2772 "sws-br-save-all-items-mute-state-slot-08": 54578,
2773 "sws-br-save-all-items-mute-state-slot-09": 54579,
2774 "sws-br-save-all-items-mute-state-slot-10": 54580,
2775 "sws-br-save-all-items-mute-state-slot-11": 54581,
2776 "sws-br-save-all-items-mute-state-slot-12": 54582,
2777 "sws-br-save-all-items-mute-state-slot-13": 54583,
2778 "sws-br-save-all-items-mute-state-slot-14": 54584,
2779 "sws-br-save-all-items-mute-state-slot-15": 54585,
2780 "sws-br-save-all-items-mute-state-slot-16": 54586,
2781 "sws-br-save-all-tracks-solo-and-mute-state-slot-01": 54635,
2782 "sws-br-save-all-tracks-solo-and-mute-state-slot-02": 54636,
2783 "sws-br-save-all-tracks-solo-and-mute-state-slot-03": 54637,
2784 "sws-br-save-all-tracks-solo-and-mute-state-slot-04": 54638,
2785 "sws-br-save-all-tracks-solo-and-mute-state-slot-05": 54639,
2786 "sws-br-save-all-tracks-solo-and-mute-state-slot-06": 54640,
2787 "sws-br-save-all-tracks-solo-and-mute-state-slot-07": 54641,
2788 "sws-br-save-all-tracks-solo-and-mute-state-slot-08": 54642,
2789 "sws-br-save-all-tracks-solo-and-mute-state-slot-09": 54643,
2790 "sws-br-save-all-tracks-solo-and-mute-state-slot-10": 54644,
2791 "sws-br-save-all-tracks-solo-and-mute-state-slot-11": 54645,
2792 "sws-br-save-all-tracks-solo-and-mute-state-slot-12": 54646,
2793 "sws-br-save-all-tracks-solo-and-mute-state-slot-13": 54647,
2794 "sws-br-save-all-tracks-solo-and-mute-state-slot-14": 54648,
2795 "sws-br-save-all-tracks-solo-and-mute-state-slot-15": 54649,
2796 "sws-br-save-all-tracks-solo-and-mute-state-slot-16": 54650,
2797 "sws-br-save-edit-cursor-position-slot-01": 54523,
2798 "sws-br-save-edit-cursor-position-slot-02": 54524,
2799 "sws-br-save-edit-cursor-position-slot-03": 54525,
2800 "sws-br-save-edit-cursor-position-slot-04": 54526,
2801 "sws-br-save-edit-cursor-position-slot-05": 54527,
2802 "sws-br-save-edit-cursor-position-slot-06": 54528,
2803 "sws-br-save-edit-cursor-position-slot-07": 54529,
2804 "sws-br-save-edit-cursor-position-slot-08": 54530,
2805 "sws-br-save-edit-cursor-position-slot-09": 54531,
2806 "sws-br-save-edit-cursor-position-slot-10": 54532,
2807 "sws-br-save-edit-cursor-position-slot-11": 54533,
2808 "sws-br-save-edit-cursor-position-slot-12": 54534,
2809 "sws-br-save-edit-cursor-position-slot-13": 54535,
2810 "sws-br-save-edit-cursor-position-slot-14": 54536,
2811 "sws-br-save-edit-cursor-position-slot-15": 54537,
2812 "sws-br-save-edit-cursor-position-slot-16": 54538,
2813 "sws-br-save-envelope-point-selection-slot-01": 54104,
2814 "sws-br-save-envelope-point-selection-slot-02": 54105,
2815 "sws-br-save-envelope-point-selection-slot-03": 54106,
2816 "sws-br-save-envelope-point-selection-slot-04": 54107,
2817 "sws-br-save-envelope-point-selection-slot-05": 54108,
2818 "sws-br-save-envelope-point-selection-slot-06": 54109,
2819 "sws-br-save-envelope-point-selection-slot-07": 54110,
2820 "sws-br-save-envelope-point-selection-slot-08": 54111,
2821 "sws-br-save-envelope-point-selection-slot-09": 54112,
2822 "sws-br-save-envelope-point-selection-slot-10": 54113,
2823 "sws-br-save-envelope-point-selection-slot-11": 54114,
2824 "sws-br-save-envelope-point-selection-slot-12": 54115,
2825 "sws-br-save-envelope-point-selection-slot-13": 54116,
2826 "sws-br-save-envelope-point-selection-slot-14": 54117,
2827 "sws-br-save-envelope-point-selection-slot-15": 54118,
2828 "sws-br-save-envelope-point-selection-slot-16": 54119,
2829 "sws-br-save-selected-items-mute-state-slot-01": 54555,
2830 "sws-br-save-selected-items-mute-state-slot-02": 54556,
2831 "sws-br-save-selected-items-mute-state-slot-03": 54557,
2832 "sws-br-save-selected-items-mute-state-slot-04": 54558,
2833 "sws-br-save-selected-items-mute-state-slot-05": 54559,
2834 "sws-br-save-selected-items-mute-state-slot-06": 54560,
2835 "sws-br-save-selected-items-mute-state-slot-07": 54561,
2836 "sws-br-save-selected-items-mute-state-slot-08": 54562,
2837 "sws-br-save-selected-items-mute-state-slot-09": 54563,
2838 "sws-br-save-selected-items-mute-state-slot-10": 54564,
2839 "sws-br-save-selected-items-mute-state-slot-11": 54565,
2840 "sws-br-save-selected-items-mute-state-slot-12": 54566,
2841 "sws-br-save-selected-items-mute-state-slot-13": 54567,
2842 "sws-br-save-selected-items-mute-state-slot-14": 54568,
2843 "sws-br-save-selected-items-mute-state-slot-15": 54569,
2844 "sws-br-save-selected-items-mute-state-slot-16": 54570,
2845 "sws-br-save-selected-tracks-solo-and-mute-state-slot-01": 54619,
2846 "sws-br-save-selected-tracks-solo-and-mute-state-slot-02": 54620,
2847 "sws-br-save-selected-tracks-solo-and-mute-state-slot-03": 54621,
2848 "sws-br-save-selected-tracks-solo-and-mute-state-slot-04": 54622,
2849 "sws-br-save-selected-tracks-solo-and-mute-state-slot-05": 54623,
2850 "sws-br-save-selected-tracks-solo-and-mute-state-slot-06": 54624,
2851 "sws-br-save-selected-tracks-solo-and-mute-state-slot-07": 54625,
2852 "sws-br-save-selected-tracks-solo-and-mute-state-slot-08": 54626,
2853 "sws-br-save-selected-tracks-solo-and-mute-state-slot-09": 54627,
2854 "sws-br-save-selected-tracks-solo-and-mute-state-slot-10": 54628,
2855 "sws-br-save-selected-tracks-solo-and-mute-state-slot-11": 54629,
2856 "sws-br-save-selected-tracks-solo-and-mute-state-slot-12": 54630,
2857 "sws-br-save-selected-tracks-solo-and-mute-state-slot-13": 54631,
2858 "sws-br-save-selected-tracks-solo-and-mute-state-slot-14": 54632,
2859 "sws-br-save-selected-tracks-solo-and-mute-state-slot-15": 54633,
2860 "sws-br-save-selected-tracks-solo-and-mute-state-slot-16": 54634,
2861 "sws-br-select-all-audio-items": 54508,
2862 "sws-br-select-all-audio-items-obey-time-selection-if-any": 54516,
2863 "sws-br-select-all-click-source-items": 54511,
2864 "sws-br-select-all-click-source-items-obey-time-selection-if-any": 54519,
2865 "sws-br-select-all-empty-items": 54507,
2866 "sws-br-select-all-empty-items-obey-time-selection-if-any": 54515,
2867 "sws-br-select-all-midi-items": 54509,
2868 "sws-br-select-all-midi-items-obey-time-selection-if-any": 54517,
2869 "sws-br-select-all-partial-time-signature-markers": 54842,
2870 "sws-br-select-all-subproject-pip-items": 54513,
2871 "sws-br-select-all-subproject-pip-items-obey-time-selection-if-any": 54521,
2872 "sws-br-select-all-timecode-generator-items": 54512,
2873 "sws-br-select-all-timecode-items-obey-time-selection-if-any": 54520,
2874 "sws-br-select-all-video-items": 54510,
2875 "sws-br-select-all-video-items-obey-time-selection-if-any": 54518,
2876 "sws-br-select-all-video-processor-items": 54514,
2877 "sws-br-select-all-video-processor-items-obey-time-selection-if-any": 54522,
2878 "sws-br-select-and-adjust-tempo-markers": 54847,
2879 "sws-br-select-dips-in-envelope": 54058,
2880 "sws-br-select-dips-in-envelope-add-to-selection": 54057,
2881 "sws-br-select-envelope-at-mouse-cursor": 54094,
2882 "sws-br-select-envelope-at-mouse-cursor-and-freehand-draw-envelope-while-snapping-points-to-left-side-grid-line-perform-until-shortcut-released":
2883 54857,
2884 "sws-br-select-envelope-at-mouse-cursor-and-set-closest-envelope-point-s-value-to-mouse-cursor-perform-until-shortcut-released":
2885 54855,
2886 "sws-br-select-envelope-at-mouse-cursor-and-set-closest-left-side-envelope-point-s-value-to-mouse-cursor-perform-until-shortcut-released":
2887 54856,
2888 "sws-br-select-envelope-point-at-mouse-cursor": 54096,
2889 "sws-br-select-envelope-point-at-mouse-cursor-selected-envelope-only": 54095,
2890 "sws-br-select-envelope-points-between-grid": 54037,
2891 "sws-br-select-envelope-points-between-grid-obey-time-selection-if-any": 54038,
2892 "sws-br-select-envelope-points-on-grid": 54035,
2893 "sws-br-select-envelope-points-on-grid-obey-time-selection-if-any": 54036,
2894 "sws-br-select-mcp-track-under-mouse-cursor": 54506,
2895 "sws-br-select-next-envelope-point": 54025,
2896 "sws-br-select-peaks-in-envelope": 54056,
2897 "sws-br-select-peaks-in-envelope-add-to-selection": 54055,
2898 "sws-br-select-previous-envelope-point": 54026,
2899 "sws-br-select-tcp-track-under-mouse-cursor": 54505,
2900 "sws-br-set-closest-envelope-point-s-value-to-mouse-cursor-perform-until-shortcut-released": 54852,
2901 "sws-br-set-closest-left-side-envelope-point-s-value-to-mouse-cursor-perform-until-shortcut-released": 54853,
2902 "sws-br-set-selected-envelope-points-to-first-selected-point-s-value": 54064,
2903 "sws-br-set-selected-envelope-points-to-last-selected-point-s-value": 54063,
2904 "sws-br-set-selected-envelope-points-to-next-point-s-value": 54061,
2905 "sws-br-set-selected-envelope-points-to-previous-point-s-value": 54062,
2906 "sws-br-set-tempo-marker-shape-options": 54849,
2907 "sws-br-set-tempo-marker-shape-to-linear-preserve-positions": 54850,
2908 "sws-br-set-tempo-marker-shape-to-square-preserve-positions": 54851,
2909 "sws-br-shift-envelope-point-selection-left": 54053,
2910 "sws-br-shift-envelope-point-selection-right": 54054,
2911 "sws-br-show-active-mute-send-envelopes-for-selected-tracks": 54157,
2912 "sws-br-show-active-pan-send-envelopes-for-selected-tracks": 54156,
2913 "sws-br-show-active-volume-send-envelopes-for-selected-tracks": 54155,
2914 "sws-br-show-all-active-fx-envelopes-for-selected-tracks": 54147,
2915 "sws-br-show-all-active-send-envelopes-for-selected-tracks": 54154,
2916 "sws-br-show-all-fx-envelopes-for-selected-tracks": 54148,
2917 "sws-br-show-all-send-envelopes-for-selected-tracks": 54162,
2918 "sws-br-show-hide-pan-track-envelope-for-last-adjusted-send": 54144,
2919 "sws-br-show-hide-track-envelope-for-last-adjusted-send-volume-pan-only": 54142,
2920 "sws-br-show-hide-volume-track-envelope-for-last-adjusted-send": 54143,
2921 "sws-br-show-mute-send-envelopes-for-selected-tracks": 54165,
2922 "sws-br-show-pan-send-envelopes-for-selected-tracks": 54164,
2923 "sws-br-show-volume-send-envelopes-for-selected-tracks": 54163,
2924 "sws-br-shrink-envelope-point-selection-from-the-left": 54032,
2925 "sws-br-shrink-envelope-point-selection-from-the-left-end-point-only": 54034,
2926 "sws-br-shrink-envelope-point-selection-from-the-right": 54031,
2927 "sws-br-shrink-envelope-point-selection-from-the-right-end-point-only": 54033,
2928 "sws-br-snap-position-of-selected-partial-time-signature-markers-to-closest-grid-line": 54844,
2929 "sws-br-split-selected-items-at-stretch-markers": 54463,
2930 "sws-br-split-selected-items-at-tempo-markers": 54462,
2931 "sws-br-tempo-help": 54845,
2932 "sws-br-toggle-media-item-online-offline": 54502,
2933 "sws-br-toggle-play-from-edit-cursor-position-and-solo-item-and-track-under-mouse-for-the-duration": 54458,
2934 "sws-br-toggle-play-from-edit-cursor-position-and-solo-track-under-mouse-for-the-duration": 54457,
2935 "sws-br-toggle-play-from-mouse-cursor-position": 54454,
2936 "sws-br-toggle-play-from-mouse-cursor-position-and-solo-item-and-track-under-mouse-for-the-duration": 54456,
2937 "sws-br-toggle-play-from-mouse-cursor-position-and-solo-track-under-mouse-for-the-duration": 54455,
2938 "sws-br-toggle-preview-media-item-under-mouse": 54737,
2939 "sws-br-toggle-preview-media-item-under-mouse-and-pause-during-preview": 54740,
2940 "sws-br-toggle-preview-media-item-under-mouse-and-pause-during-preview-start-from-mouse-cursor-position": 54741,
2941 "sws-br-toggle-preview-media-item-under-mouse-at-track-fader-volume": 54742,
2942 "sws-br-toggle-preview-media-item-under-mouse-at-track-fader-volume-and-pause-during-preview": 54745,
2943 "sws-br-toggle-preview-media-item-under-mouse-at-track-fader-volume-and-pause-during-preview-start-from-mouse-cursor-position":
2944 54746,
2945 "sws-br-toggle-preview-media-item-under-mouse-at-track-fader-volume-start-from-mouse-position": 54743,
2946 "sws-br-toggle-preview-media-item-under-mouse-at-track-fader-volume-sync-with-next-measure": 54744,
2947 "sws-br-toggle-preview-media-item-under-mouse-start-from-mouse-position": 54738,
2948 "sws-br-toggle-preview-media-item-under-mouse-sync-with-next-measure": 54739,
2949 "sws-br-toggle-preview-media-item-under-mouse-through-track": 54747,
2950 "sws-br-toggle-preview-media-item-under-mouse-through-track-and-pause-during-preview": 54750,
2951 "sws-br-toggle-preview-media-item-under-mouse-through-track-and-pause-during-preview-start-from-mouse-cursor-position":
2952 54751,
2953 "sws-br-toggle-preview-media-item-under-mouse-through-track-start-from-mouse-position": 54748,
2954 "sws-br-toggle-preview-media-item-under-mouse-through-track-sync-with-next-measure": 54749,
2955 "sws-br-toggle-preview-take-under-mouse": 54767,
2956 "sws-br-toggle-preview-take-under-mouse-and-pause-during-preview": 54770,
2957 "sws-br-toggle-preview-take-under-mouse-and-pause-during-preview-start-from-mouse-cursor-position": 54771,
2958 "sws-br-toggle-preview-take-under-mouse-at-track-fader-volume": 54772,
2959 "sws-br-toggle-preview-take-under-mouse-at-track-fader-volume-and-pause-during-preview": 54775,
2960 "sws-br-toggle-preview-take-under-mouse-at-track-fader-volume-and-pause-during-preview-start-from-mouse-cursor-position":
2961 54776,
2962 "sws-br-toggle-preview-take-under-mouse-at-track-fader-volume-start-from-mouse-position": 54773,
2963 "sws-br-toggle-preview-take-under-mouse-at-track-fader-volume-sync-with-next-measure": 54774,
2964 "sws-br-toggle-preview-take-under-mouse-start-from-mouse-position": 54768,
2965 "sws-br-toggle-preview-take-under-mouse-sync-with-next-measure": 54769,
2966 "sws-br-toggle-preview-take-under-mouse-through-track": 54777,
2967 "sws-br-toggle-preview-take-under-mouse-through-track-and-pause-during-preview": 54780,
2968 "sws-br-toggle-preview-take-under-mouse-through-track-and-pause-during-preview-start-from-mouse-cursor-position":
2969 54781,
2970 "sws-br-toggle-preview-take-under-mouse-through-track-start-from-mouse-position": 54778,
2971 "sws-br-toggle-preview-take-under-mouse-through-track-sync-with-next-measure": 54779,
2972 "sws-br-toggle-show-active-mute-send-envelopes-for-selected-tracks": 54153,
2973 "sws-br-toggle-show-active-pan-send-envelopes-for-selected-tracks": 54152,
2974 "sws-br-toggle-show-active-volume-send-envelopes-for-selected-tracks": 54151,
2975 "sws-br-toggle-show-all-active-fx-envelopes-for-selected-tracks": 54145,
2976 "sws-br-toggle-show-all-active-send-envelopes-for-selected-tracks": 54150,
2977 "sws-br-toggle-show-all-fx-envelopes-for-selected-tracks": 54146,
2978 "sws-br-toggle-show-all-send-envelopes-for-selected-tracks": 54158,
2979 "sws-br-toggle-show-mute-send-envelopes-for-selected-tracks": 54161,
2980 "sws-br-toggle-show-pan-send-envelopes-for-selected-tracks": 54160,
2981 "sws-br-toggle-show-volume-send-envelopes-for-selected-tracks": 54159,
2982 "sws-br-trim-midi-item-to-active-content": 54469,
2983 "sws-br-unselect-envelope": 54099,
2984 "sws-br-unselect-envelope-points-in-time-selection": 54060,
2985 "sws-br-unselect-envelope-points-outside-time-selection": 54059,
2986 "sws-bypass-fx-on-selected-track-s": 53730,
2987 "sws-clear-all-snapshot-filter-options": 53190,
2988 "sws-clear-all-takes-preserve-pitch": 53624,
2989 "sws-clear-tracklist-filter": 53214,
2990 "sws-convert-markers-to-regions": 53098,
2991 "sws-convert-regions-to-markers": 53099,
2992 "sws-copy-current-snapshot": 53168,
2993 "sws-copy-items-tracks-env-obey-time-selection-razor-edit-areas": 53576,
2994 "sws-copy-marker-set-to-clipboard": 53086,
2995 "sws-copy-markers-in-time-selection-to-clipboard-relative-to-selection-start": 53087,
2996 "sws-copy-new-snapshot-all-track-s": 53170,
2997 "sws-copy-new-snapshot-selected-track-s": 53169,
2998 "sws-create-and-select-first-track": 53742,
2999 "sws-create-regions-from-selected-items-name-by-active-take": 53110,
3000 "sws-crossfade-adjacent-selected-items-move-edges-of-adjacent-items": 53633,
3001 "sws-cut-items-tracks-env-obey-time-selection-razor-edit-areas": 53577,
3002 "sws-decrease-item-rate-by-0-6-percent-10-cents-preserving-length-clear-preserve-pitch": 53635,
3003 "sws-decrease-item-rate-by-6-percent-one-semitone-preserving-length-clear-preserve-pitch": 53637,
3004 "sws-delete-all-items-on-selected-track-s": 53609,
3005 "sws-delete-all-markers": 53093,
3006 "sws-delete-all-regions": 53094,
3007 "sws-delete-all-snapshots": 53175,
3008 "sws-delete-current-snapshot": 53174,
3009 "sws-delete-marker-set": 53085,
3010 "sws-delete-related-project": 53219,
3011 "sws-delete-selected-track-s-from-all-snapshots": 53160,
3012 "sws-delete-selected-track-s-from-current-snapshot": 53159,
3013 "sws-delete-track-s-with-children-prompt": 53743,
3014 "sws-disable-checking-for-duplicate-inputs-when-recording": 53691,
3015 "sws-disable-marker-actions": 53105,
3016 "sws-disable-master-fx": 53735,
3017 "sws-disable-master-parent-send-on-selected-track-s": 53694,
3018 "sws-enable-checking-for-duplicate-inputs-when-recording": 53690,
3019 "sws-enable-marker-actions": 53104,
3020 "sws-enable-master-fx": 53734,
3021 "sws-enable-master-parent-send-on-selected-track-s": 53693,
3022 "sws-export-formatted-marker-list-to-clipboard": 53095,
3023 "sws-export-formatted-marker-list-to-file": 53096,
3024 "sws-exported-marker-list-format": 53097,
3025 "sws-fng-apply-groove-to-selected-media-items-within-16th": 53893,
3026 "sws-fng-apply-groove-to-selected-media-items-within-32nd": 53894,
3027 "sws-fng-apply-groove-to-selected-midi-notes-in-active-midi-editor-within-16th": 53895,
3028 "sws-fng-apply-groove-to-selected-midi-notes-in-active-midi-editor-within-32nd": 53896,
3029 "sws-fng-apply-midi-hardware-emulation-to-selected-midi-takes": 53886,
3030 "sws-fng-apply-selected-groove-use-curent-settings-from-opened-groove-tool": 53911,
3031 "sws-fng-clean-selected-overlapping-media-items-on-same-track": 53863,
3032 "sws-fng-compress-amplitude-of-selected-envelope-points-around-midpoint": 53853,
3033 "sws-fng-contract-selected-media-items": 53857,
3034 "sws-fng-contract-selected-media-items-by-1-2": 53862,
3035 "sws-fng-contract-selected-media-items-fine": 53859,
3036 "sws-fng-cycle-through-cc-lanes-in-active-midi-editor": 53912,
3037 "sws-fng-cycle-through-cc-lanes-in-active-midi-editor-keep-lane-heights-constant": 53913,
3038 "sws-fng-decrease-selected-midi-items-velocity-by-1": 53877,
3039 "sws-fng-decrease-selected-midi-items-velocity-by-10": 53879,
3040 "sws-fng-expand-amplitude-of-selected-envelope-points-around-midpoint": 53852,
3041 "sws-fng-expand-contract-selected-media-items-to-bar": 53860,
3042 "sws-fng-expand-selected-media-items": 53856,
3043 "sws-fng-expand-selected-media-items-by-2": 53861,
3044 "sws-fng-expand-selected-media-items-fine": 53858,
3045 "sws-fng-get-groove-from-selected-media-items": 53897,
3046 "sws-fng-get-groove-from-selected-midi-notes-in-active-midi-editor": 53898,
3047 "sws-fng-hide-unused-cc-lanes-in-active-midi-editor": 53915,
3048 "sws-fng-increase-selected-midi-items-velocity-by-1": 53876,
3049 "sws-fng-increase-selected-midi-items-velocity-by-10": 53878,
3050 "sws-fng-insert-midi-item-with-note-c4-of-size-32nd": 53870,
3051 "sws-fng-legato-selected-media-items-on-same-track": 53864,
3052 "sws-fng-legato-selected-media-items-on-same-track-change-rate": 53865,
3053 "sws-fng-load-groove-template-from-file": 53900,
3054 "sws-fng-midi-hardware-emulation-settings": 53887,
3055 "sws-fng-move-selected-envelope-points-down": 53847,
3056 "sws-fng-move-selected-envelope-points-left-16th": 53843,
3057 "sws-fng-move-selected-envelope-points-left-32nd": 53845,
3058 "sws-fng-move-selected-envelope-points-right-16th": 53842,
3059 "sws-fng-move-selected-envelope-points-right-32nd": 53844,
3060 "sws-fng-move-selected-envelope-points-up": 53846,
3061 "sws-fng-move-selected-items-to-edit-cursor": 53884,
3062 "sws-fng-quantize-item-positions-and-midi-note-positions-to-grid": 53889,
3063 "sws-fng-rotate-selected-media-items-positions": 53866,
3064 "sws-fng-rotate-selected-media-items-positions-and-lengths": 53867,
3065 "sws-fng-rotate-selected-media-items-positions-and-lengths-reverse": 53869,
3066 "sws-fng-rotate-selected-media-items-positions-reverse": 53868,
3067 "sws-fng-save-groove-template-to-file": 53899,
3068 "sws-fng-select-muted-midi-notes-in-active-midi-editor": 53888,
3069 "sws-fng-select-notes-nearest-edit-cursor-in-active-midi-editor": 53890,
3070 "sws-fng-set-groove-marker-start-to-current-bar": 53907,
3071 "sws-fng-set-groove-marker-start-to-edit-cursor": 53906,
3072 "sws-fng-set-selected-midi-items-name-to-first-note": 53875,
3073 "sws-fng-shift-selected-envelope-points-down-on-left": 53851,
3074 "sws-fng-shift-selected-envelope-points-down-on-right": 53849,
3075 "sws-fng-shift-selected-envelope-points-up-on-left": 53850,
3076 "sws-fng-shift-selected-envelope-points-up-on-right": 53848,
3077 "sws-fng-show-current-groove-template": 53901,
3078 "sws-fng-show-groove-tool": 53908,
3079 "sws-fng-show-only-top-cc-lane-in-active-midi-editor": 53916,
3080 "sws-fng-show-only-used-cc-lanes-in-active-midi-editor": 53914,
3081 "sws-fng-time-compress-selected-envelope-points": 53854,
3082 "sws-fng-time-compress-selected-items-by-1-2": 53881,
3083 "sws-fng-time-compress-selected-items-fine": 53883,
3084 "sws-fng-time-stretch-selected-envelope-points": 53855,
3085 "sws-fng-time-stretch-selected-items-by-2": 53880,
3086 "sws-fng-time-stretch-selected-items-fine": 53882,
3087 "sws-fng-toggle-groove-markers": 53902,
3088 "sws-fng-toggle-groove-markers-2x": 53903,
3089 "sws-fng-toggle-groove-markers-4x": 53904,
3090 "sws-fng-toggle-groove-markers-8x": 53905,
3091 "sws-fng-transpose-selected-midi-items-down-a-semitone": 53872,
3092 "sws-fng-transpose-selected-midi-items-down-an-octave": 53874,
3093 "sws-fng-transpose-selected-midi-items-up-a-semitone": 53871,
3094 "sws-fng-transpose-selected-midi-items-up-an-octave": 53873,
3095 "sws-fng-unselect-items-that-do-not-start-in-time-selection": 53885,
3096 "sws-go-to-end-of-project-including-markers-regions": 53100,
3097 "sws-go-to-time-select-next-marker-region": 53101,
3098 "sws-go-to-time-select-previous-marker-region": 53102,
3099 "sws-gofer-split-selected-items-at-mouse-cursor-obey-snapping": 55165,
3100 "sws-hide-all-tracks": 53209,
3101 "sws-hide-dockers": 53684,
3102 "sws-hide-master-track-in-track-control-panel": 53686,
3103 "sws-hide-selected-track-s": 53198,
3104 "sws-hide-selected-track-s-from-mcp": 53201,
3105 "sws-hide-selected-track-s-from-tcp": 53202,
3106 "sws-hide-unselected-track-s": 53213,
3107 "sws-horizontal-scroll-to-put-edit-cursor-at-10-percent": 53780,
3108 "sws-horizontal-scroll-to-put-edit-cursor-at-50-percent": 53781,
3109 "sws-horizontal-scroll-to-put-play-cursor-at-10-percent": 53782,
3110 "sws-horizontal-scroll-to-put-play-cursor-at-50-percent": 53783,
3111 "sws-horizontal-zoom-to-selected-items": 53792,
3112 "sws-ignore-next-marker-action": 53107,
3113 "sws-increase-item-rate-by-0-6-percent-10-cents-preserving-length-clear-preserve-pitch": 53634,
3114 "sws-increase-item-rate-by-6-percent-one-semitone-preserving-length-clear-preserve-pitch": 53636,
3115 "sws-indent-selected-track-s": 53603,
3116 "sws-insert-file-matching-selected-track-s-name": 53613,
3117 "sws-insert-track-above-selected-tracks": 53741,
3118 "sws-ix-import-m3u-pls-playlist": 53937,
3119 "sws-ix-label-processor": 53936,
3120 "sws-load-marker-set": 53083,
3121 "sws-loop-section-of-selected-item-s": 53610,
3122 "sws-make-folder-from-selected-tracks": 53602,
3123 "sws-metronome-disable": 53689,
3124 "sws-metronome-enable": 53688,
3125 "sws-minimize-selected-track-s": 53736,
3126 "sws-move-cursor-and-time-selection-left-to-grid": 53588,
3127 "sws-move-cursor-and-time-selection-right-to-grid": 53589,
3128 "sws-move-cursor-left-1-sample-on-grid": 53590,
3129 "sws-move-cursor-left-1ms": 53592,
3130 "sws-move-cursor-left-5ms": 53594,
3131 "sws-move-cursor-left-by-default-fade-length": 53596,
3132 "sws-move-cursor-right-1-sample-on-grid": 53591,
3133 "sws-move-cursor-right-1ms": 53593,
3134 "sws-move-cursor-right-5ms": 53595,
3135 "sws-move-cursor-right-by-default-fade-length": 53597,
3136 "sws-move-cursor-to-item-peak-sample": 53568,
3137 "sws-move-selected-item-s-left-edge-to-edit-cursor": 53611,
3138 "sws-move-selected-item-s-right-edge-to-edit-cursor": 53612,
3139 "sws-mute-all-receives-for-selected-track-s": 53725,
3140 "sws-mute-all-sends-from-selected-track-s": 53728,
3141 "sws-mute-children-of-selected-folder-s": 53598,
3142 "sws-new-snapshot-all-tracks": 53162,
3143 "sws-new-snapshot-and-edit-name": 53173,
3144 "sws-new-snapshot-selected-track-s": 53163,
3145 "sws-new-snapshot-with-current-settings": 53172,
3146 "sws-nf-bypass-fx-except-vsti-for-selected-tracks": 54923,
3147 "sws-nf-cycle-through-midi-recording-modes": 54931,
3148 "sws-nf-cycle-through-track-automation-modes": 54933,
3149 "sws-nf-disable-multichannel-metering-all-tracks": 54927,
3150 "sws-nf-disable-multichannel-metering-selected-tracks": 54928,
3151 "sws-nf-enable-multichannel-metering-all-tracks": 54929,
3152 "sws-nf-enable-multichannel-metering-selected-tracks": 54930,
3153 "sws-nf-eraser-tool-marquee-sel-items-and-time-cut-on-shortcut-release": 54874,
3154 "sws-nf-eraser-tool-marquee-sel-items-and-time-ignoring-snap-cut-on-shortcut-release": 54873,
3155 "sws-nf-play-stop-or-play-pause-obey-sws-nf-toggle-play-stop-or-play-pause-toggle-state": 54937,
3156 "sws-nf-toggle-obey-track-height-lock-in-vertical-zoom-and-track-height-actions": 54935,
3157 "sws-nf-toggle-play-stop-off-or-play-pause-on": 54936,
3158 "sws-nf-toggle-render-speed-apply-fx-render-stems-realtime-not-limited": 54934,
3159 "sws-normalize-item-s-to-peak-rms": 53573,
3160 "sws-normalize-items-to-overall-peak-rms": 53574,
3161 "sws-normalize-items-to-rms-entire-item": 53572,
3162 "sws-nudge-items-position-1-sample-left": 53263,
3163 "sws-nudge-items-position-1-sample-right": 53262,
3164 "sws-nudge-marker-under-cursor-left": 53108,
3165 "sws-nudge-marker-under-cursor-right": 53109,
3166 "sws-nudge-master-output-1-volume-1db": 53723,
3167 "sws-nudge-master-output-1-volume-plus-1db": 53722,
3168 "sws-open-auto-color-icon-layout-window": 53000,
3169 "sws-open-color-management-window": 53007,
3170 "sws-open-console": 53111,
3171 "sws-open-console-and-copy-keystroke": 53112,
3172 "sws-open-console-with-a-to-arm-track-s": 53116,
3173 "sws-open-console-with-b-to-prefix-track-s": 53120,
3174 "sws-open-console-with-c-to-color-track-s": 53122,
3175 "sws-open-console-with-f-to-toggle-fx-enable": 53118,
3176 "sws-open-console-with-h-to-flip-phase-on-track-s": 53123,
3177 "sws-open-console-with-i-to-set-track-s-input": 53119,
3178 "sws-open-console-with-l-to-set-track-s-number-channels": 53128,
3179 "sws-open-console-with-m-to-mute-track-s": 53117,
3180 "sws-open-console-with-n-to-name-track-s": 53114,
3181 "sws-open-console-with-o-to-solo-track-s": 53115,
3182 "sws-open-console-with-p-to-set-track-s-pan": 53125,
3183 "sws-open-console-with-p-to-trim-pan-on-track-s": 53127,
3184 "sws-open-console-with-s-to-select-track-s": 53113,
3185 "sws-open-console-with-to-add-action-marker": 53129,
3186 "sws-open-console-with-v-to-set-track-s-volume": 53124,
3187 "sws-open-console-with-v-to-trim-volume-on-track-s": 53126,
3188 "sws-open-console-with-z-to-suffix-track-s": 53121,
3189 "sws-open-last-project": 53222,
3190 "sws-open-marker-list": 53082,
3191 "sws-open-project-list": 53220,
3192 "sws-open-projects-from-list": 53217,
3193 "sws-open-related-project-1": 53221,
3194 "sws-open-snapshots-window": 53156,
3195 "sws-organize-items-by-peak": 53569,
3196 "sws-organize-items-by-peak-rms": 53571,
3197 "sws-organize-items-by-rms-entire-item": 53570,
3198 "sws-padre-envelope-lfo-generator": 53922,
3199 "sws-padre-envelope-processor": 53923,
3200 "sws-padre-shrink-selected-items-1024-samples": 53927,
3201 "sws-padre-shrink-selected-items-128-samples": 53924,
3202 "sws-padre-shrink-selected-items-2048-samples": 53928,
3203 "sws-padre-shrink-selected-items-256-samples": 53925,
3204 "sws-padre-shrink-selected-items-512-samples": 53926,
3205 "sws-paste-marker-set-from-clipboard": 53088,
3206 "sws-paste-snapshot": 53171,
3207 "sws-pitch-all-takes-down-one-cent": 53630,
3208 "sws-pitch-all-takes-down-one-octave": 53632,
3209 "sws-pitch-all-takes-down-one-semitone": 53631,
3210 "sws-pitch-all-takes-up-one-cent": 53627,
3211 "sws-pitch-all-takes-up-one-octave": 53629,
3212 "sws-pitch-all-takes-up-one-semitone": 53628,
3213 "sws-quantize-item-s-edges-to-grid-change-length": 53618,
3214 "sws-quantize-item-s-end-to-grid-change-length": 53617,
3215 "sws-quantize-item-s-end-to-grid-keep-length": 53616,
3216 "sws-quantize-item-s-start-to-grid-change-length": 53615,
3217 "sws-quantize-item-s-start-to-grid-keep-length": 53614,
3218 "sws-recall-current-snapshot": 53165,
3219 "sws-recall-next-snapshot": 53167,
3220 "sws-recall-previous-snapshot": 53166,
3221 "sws-recall-snapshot-1": 55731,
3222 "sws-recall-snapshot-10": 55740,
3223 "sws-recall-snapshot-11": 55741,
3224 "sws-recall-snapshot-12": 55742,
3225 "sws-recall-snapshot-2": 55732,
3226 "sws-recall-snapshot-3": 55733,
3227 "sws-recall-snapshot-4": 55734,
3228 "sws-recall-snapshot-5": 55735,
3229 "sws-recall-snapshot-6": 55736,
3230 "sws-recall-snapshot-7": 55737,
3231 "sws-recall-snapshot-8": 55738,
3232 "sws-recall-snapshot-9": 55739,
3233 "sws-redo-edit-cursor-move": 53587,
3234 "sws-redo-zoom": 53837,
3235 "sws-remove-items-tracks-env-obey-time-selection-razor-edit-areas": 53578,
3236 "sws-renumber-marker-ids": 53089,
3237 "sws-renumber-region-ids": 53090,
3238 "sws-reset-all-takes-pitch": 53626,
3239 "sws-reset-item-rate-preserving-length-clear-preserve-pitch": 53638,
3240 "sws-restore-active-takes-on-selected-track-s": 53133,
3241 "sws-restore-arrange-view-slot-1": 53831,
3242 "sws-restore-arrange-view-slot-2": 53832,
3243 "sws-restore-arrange-view-slot-3": 53833,
3244 "sws-restore-arrange-view-slot-4": 53834,
3245 "sws-restore-arrange-view-slot-5": 53835,
3246 "sws-restore-auto-crossfade-state": 53667,
3247 "sws-restore-last-item-selection-on-selected-track-s": 53144,
3248 "sws-restore-loop-selection-next-slot": 53150,
3249 "sws-restore-loop-selection-slot-1": 55802,
3250 "sws-restore-loop-selection-slot-2": 55803,
3251 "sws-restore-loop-selection-slot-3": 55804,
3252 "sws-restore-loop-selection-slot-4": 55805,
3253 "sws-restore-loop-selection-slot-5": 55806,
3254 "sws-restore-master-fx-enabled-state": 53733,
3255 "sws-restore-saved-selected-item-s": 53146,
3256 "sws-restore-saved-track-selection": 53750,
3257 "sws-restore-selected-track-s-items-states": 53152,
3258 "sws-restore-selected-track-s-mutes-plus-receives-children": 53148,
3259 "sws-restore-selected-track-s-selected-item-s-slot-1": 53139,
3260 "sws-restore-selected-track-s-selected-item-s-slot-2": 53140,
3261 "sws-restore-selected-track-s-selected-item-s-slot-3": 53141,
3262 "sws-restore-selected-track-s-selected-item-s-slot-4": 53142,
3263 "sws-restore-selected-track-s-selected-item-s-slot-5": 53143,
3264 "sws-restore-selected-track-s-selected-items-states": 53154,
3265 "sws-restore-snapshot-filter-options": 53192,
3266 "sws-restore-time-selection-next-slot": 53149,
3267 "sws-restore-time-selection-slot-1": 55792,
3268 "sws-restore-time-selection-slot-2": 55793,
3269 "sws-restore-time-selection-slot-3": 55794,
3270 "sws-restore-time-selection-slot-4": 55795,
3271 "sws-restore-time-selection-slot-5": 55796,
3272 "sws-restore-transport-repeat-state": 53680,
3273 "sws-run-action-marker-under-cursor": 53106,
3274 "sws-s-and-m-active-midi-editor-create-cc-lane": 55168,
3275 "sws-s-and-m-active-midi-editor-hide-all-cc-lanes": 55167,
3276 "sws-s-and-m-active-midi-editor-restore-displayed-cc-lanes-slot-1": 55515,
3277 "sws-s-and-m-active-midi-editor-restore-displayed-cc-lanes-slot-2": 55516,
3278 "sws-s-and-m-active-midi-editor-restore-displayed-cc-lanes-slot-3": 55517,
3279 "sws-s-and-m-active-midi-editor-restore-displayed-cc-lanes-slot-4": 55518,
3280 "sws-s-and-m-active-midi-editor-restore-displayed-cc-lanes-slot-5": 55519,
3281 "sws-s-and-m-active-midi-editor-restore-displayed-cc-lanes-slot-6": 55520,
3282 "sws-s-and-m-active-midi-editor-restore-displayed-cc-lanes-slot-7": 55521,
3283 "sws-s-and-m-active-midi-editor-restore-displayed-cc-lanes-slot-8": 55522,
3284 "sws-s-and-m-active-midi-editor-save-displayed-cc-lanes-slot-1": 55523,
3285 "sws-s-and-m-active-midi-editor-save-displayed-cc-lanes-slot-2": 55524,
3286 "sws-s-and-m-active-midi-editor-save-displayed-cc-lanes-slot-3": 55525,
3287 "sws-s-and-m-active-midi-editor-save-displayed-cc-lanes-slot-4": 55526,
3288 "sws-s-and-m-active-midi-editor-save-displayed-cc-lanes-slot-5": 55527,
3289 "sws-s-and-m-active-midi-editor-save-displayed-cc-lanes-slot-6": 55528,
3290 "sws-s-and-m-active-midi-editor-save-displayed-cc-lanes-slot-7": 55529,
3291 "sws-s-and-m-active-midi-editor-save-displayed-cc-lanes-slot-8": 55530,
3292 "sws-s-and-m-arm-all-active-envelopes-for-selected-tracks": 55209,
3293 "sws-s-and-m-bypass-all-fx-except-1-for-selected-tracks": 55355,
3294 "sws-s-and-m-bypass-all-fx-except-2-for-selected-tracks": 55356,
3295 "sws-s-and-m-bypass-all-fx-except-3-for-selected-tracks": 55357,
3296 "sws-s-and-m-bypass-all-fx-except-4-for-selected-tracks": 55358,
3297 "sws-s-and-m-bypass-all-fx-except-5-for-selected-tracks": 55359,
3298 "sws-s-and-m-bypass-all-fx-except-6-for-selected-tracks": 55360,
3299 "sws-s-and-m-bypass-all-fx-except-7-for-selected-tracks": 55361,
3300 "sws-s-and-m-bypass-all-fx-except-8-for-selected-tracks": 55362,
3301 "sws-s-and-m-bypass-all-fx-for-selected-tracks": 55004,
3302 "sws-s-and-m-bypass-all-take-fx-for-selected-items": 55012,
3303 "sws-s-and-m-bypass-fx-1-for-selected-tracks": 55323,
3304 "sws-s-and-m-bypass-fx-2-for-selected-tracks": 55324,
3305 "sws-s-and-m-bypass-fx-3-for-selected-tracks": 55325,
3306 "sws-s-and-m-bypass-fx-4-for-selected-tracks": 55326,
3307 "sws-s-and-m-bypass-fx-5-for-selected-tracks": 55327,
3308 "sws-s-and-m-bypass-fx-6-for-selected-tracks": 55328,
3309 "sws-s-and-m-bypass-fx-7-for-selected-tracks": 55329,
3310 "sws-s-and-m-bypass-fx-8-for-selected-tracks": 55330,
3311 "sws-s-and-m-bypass-last-fx-for-selected-tracks": 54999,
3312 "sws-s-and-m-bypass-selected-fx-for-selected-tracks": 55000,
3313 "sws-s-and-m-clear-fx-chain-for-selected-items": 55029,
3314 "sws-s-and-m-clear-fx-chain-for-selected-items-all-takes": 55030,
3315 "sws-s-and-m-clear-fx-chain-for-selected-tracks": 55031,
3316 "sws-s-and-m-clear-global-startup-action": 55046,
3317 "sws-s-and-m-clear-image-window": 55048,
3318 "sws-s-and-m-clear-input-fx-chain-for-selected-tracks": 55032,
3319 "sws-s-and-m-clear-project-startup-action": 55043,
3320 "sws-s-and-m-close-all-floating-fx-windows": 54961,
3321 "sws-s-and-m-close-all-floating-fx-windows-except-focused-one": 54964,
3322 "sws-s-and-m-close-all-floating-fx-windows-for-selected-tracks": 54963,
3323 "sws-s-and-m-close-all-fx-chain-windows": 54962,
3324 "sws-s-and-m-copy-active-takes": 55133,
3325 "sws-s-and-m-copy-fx-chain-depending-on-focus": 55033,
3326 "sws-s-and-m-copy-fx-chain-from-selected-item": 55015,
3327 "sws-s-and-m-copy-fx-chain-from-selected-track": 55019,
3328 "sws-s-and-m-copy-input-fx-chain-from-selected-track": 55025,
3329 "sws-s-and-m-copy-selected-track-grouping": 55171,
3330 "sws-s-and-m-copy-selected-tracks-receives": 54953,
3331 "sws-s-and-m-copy-selected-tracks-routings": 54947,
3332 "sws-s-and-m-copy-selected-tracks-sends": 54950,
3333 "sws-s-and-m-copy-selected-tracks-with-routing": 54944,
3334 "sws-s-and-m-create-cue-buss-from-track-selection-settings-1": 55291,
3335 "sws-s-and-m-create-cue-buss-from-track-selection-settings-2": 55292,
3336 "sws-s-and-m-create-cue-buss-from-track-selection-settings-3": 55293,
3337 "sws-s-and-m-create-cue-buss-from-track-selection-settings-4": 55294,
3338 "sws-s-and-m-create-cue-buss-from-track-selection-settings-5": 55295,
3339 "sws-s-and-m-create-cue-buss-from-track-selection-settings-6": 55296,
3340 "sws-s-and-m-create-cue-buss-from-track-selection-settings-7": 55297,
3341 "sws-s-and-m-create-cue-buss-from-track-selection-settings-8": 55298,
3342 "sws-s-and-m-create-cue-buss-from-track-selection-use-last-settings": 54939,
3343 "sws-s-and-m-cut-active-takes": 55134,
3344 "sws-s-and-m-cut-fx-chain-depending-on-focus": 55036,
3345 "sws-s-and-m-cut-fx-chain-from-selected-items": 55016,
3346 "sws-s-and-m-cut-fx-chain-from-selected-tracks": 55020,
3347 "sws-s-and-m-cut-input-fx-chain-from-selected-tracks": 55026,
3348 "sws-s-and-m-cut-selected-tracks-grouping": 55172,
3349 "sws-s-and-m-cut-selected-tracks-receives": 54955,
3350 "sws-s-and-m-cut-selected-tracks-routings": 54949,
3351 "sws-s-and-m-cut-selected-tracks-sends": 54952,
3352 "sws-s-and-m-cut-selected-tracks-with-routing": 54946,
3353 "sws-s-and-m-decrease-metronome-volume": 55262,
3354 "sws-s-and-m-delete-active-take-and-source-file-in-selected-items-no-undo": 55147,
3355 "sws-s-and-m-delete-active-take-and-source-file-in-selected-items-prompt-no-undo": 55146,
3356 "sws-s-and-m-delete-selected-items-takes-and-source-files-no-undo": 55145,
3357 "sws-s-and-m-delete-selected-items-takes-and-source-files-prompt-no-undo": 55144,
3358 "sws-s-and-m-disarm-all-active-envelopes-for-selected-tracks": 55210,
3359 "sws-s-and-m-dummy-toggle-1": 55675,
3360 "sws-s-and-m-dummy-toggle-2": 55676,
3361 "sws-s-and-m-dummy-toggle-3": 55677,
3362 "sws-s-and-m-dummy-toggle-4": 55678,
3363 "sws-s-and-m-dummy-toggle-5": 55679,
3364 "sws-s-and-m-dummy-toggle-6": 55680,
3365 "sws-s-and-m-dummy-toggle-7": 55681,
3366 "sws-s-and-m-dummy-toggle-8": 55682,
3367 "sws-s-and-m-dump-action-list-all-actions": 55271,
3368 "sws-s-and-m-dump-action-list-all-but-custom-actions": 55270,
3369 "sws-s-and-m-dump-action-list-custom-actions-only": 55269,
3370 "sws-s-and-m-dump-action-list-native-actions-only": 55267,
3371 "sws-s-and-m-dump-action-list-sws-actions-only": 55268,
3372 "sws-s-and-m-dump-alr-wiki-summary-native-actions-only": 55265,
3373 "sws-s-and-m-dump-alr-wiki-summary-sws-actions-only": 55266,
3374 "sws-s-and-m-exclusive-toggle-a1": 55683,
3375 "sws-s-and-m-exclusive-toggle-a2": 55684,
3376 "sws-s-and-m-exclusive-toggle-a3": 55685,
3377 "sws-s-and-m-exclusive-toggle-a4": 55686,
3378 "sws-s-and-m-exclusive-toggle-b1": 55687,
3379 "sws-s-and-m-exclusive-toggle-b2": 55688,
3380 "sws-s-and-m-exclusive-toggle-b3": 55689,
3381 "sws-s-and-m-exclusive-toggle-b4": 55690,
3382 "sws-s-and-m-exclusive-toggle-c1": 55691,
3383 "sws-s-and-m-exclusive-toggle-c2": 55692,
3384 "sws-s-and-m-exclusive-toggle-c3": 55693,
3385 "sws-s-and-m-exclusive-toggle-c4": 55694,
3386 "sws-s-and-m-exclusive-toggle-d1": 55695,
3387 "sws-s-and-m-exclusive-toggle-d2": 55696,
3388 "sws-s-and-m-exclusive-toggle-d3": 55697,
3389 "sws-s-and-m-exclusive-toggle-d4": 55698,
3390 "sws-s-and-m-find": 55227,
3391 "sws-s-and-m-find-next": 55228,
3392 "sws-s-and-m-find-previous": 55229,
3393 "sws-s-and-m-float-fx-1-for-selected-tracks": 55491,
3394 "sws-s-and-m-float-fx-2-for-selected-tracks": 55492,
3395 "sws-s-and-m-float-fx-3-for-selected-tracks": 55493,
3396 "sws-s-and-m-float-fx-4-for-selected-tracks": 55494,
3397 "sws-s-and-m-float-fx-5-for-selected-tracks": 55495,
3398 "sws-s-and-m-float-fx-6-for-selected-tracks": 55496,
3399 "sws-s-and-m-float-fx-7-for-selected-tracks": 55497,
3400 "sws-s-and-m-float-fx-8-for-selected-tracks": 55498,
3401 "sws-s-and-m-float-next-fx-and-close-others-for-selected-tracks": 54972,
3402 "sws-s-and-m-float-previous-fx-and-close-others-for-selected-tracks": 54971,
3403 "sws-s-and-m-float-selected-fx-for-selected-tracks": 54981,
3404 "sws-s-and-m-focus-main-window-only-valid-within-custom-actions": 54977,
3405 "sws-s-and-m-focus-next-floating-fx-cycle": 54976,
3406 "sws-s-and-m-focus-next-floating-fx-for-selected-tracks-cycle": 54974,
3407 "sws-s-and-m-focus-previous-floating-fx-cycle": 54975,
3408 "sws-s-and-m-focus-previous-floating-fx-for-selected-tracks-cycle": 54973,
3409 "sws-s-and-m-go-to-time-select-region-1-obeys-smooth-seek": 55671,
3410 "sws-s-and-m-go-to-time-select-region-2-obeys-smooth-seek": 55672,
3411 "sws-s-and-m-go-to-time-select-region-3-obeys-smooth-seek": 55673,
3412 "sws-s-and-m-go-to-time-select-region-4-obeys-smooth-seek": 55674,
3413 "sws-s-and-m-hide-and-bypass-take-mute-envelope": 55189,
3414 "sws-s-and-m-hide-and-bypass-take-pan-envelope": 55188,
3415 "sws-s-and-m-hide-and-bypass-take-pitch-envelope": 55200,
3416 "sws-s-and-m-hide-and-bypass-take-volume-envelope": 55187,
3417 "sws-s-and-m-hide-fx-chain-windows-for-selected-tracks": 54979,
3418 "sws-s-and-m-hide-take-mute-envelope": 55195,
3419 "sws-s-and-m-hide-take-pan-envelope": 55194,
3420 "sws-s-and-m-hide-take-pitch-envelope": 55202,
3421 "sws-s-and-m-hide-take-volume-envelope": 55193,
3422 "sws-s-and-m-increase-metronome-volume": 55261,
3423 "sws-s-and-m-insert-marker-at-edit-cursor": 55257,
3424 "sws-s-and-m-insert-marker-at-play-cursor": 55258,
3425 "sws-s-and-m-insert-silence-measures-beats": 55040,
3426 "sws-s-and-m-insert-silence-samples": 55041,
3427 "sws-s-and-m-insert-silence-seconds": 55039,
3428 "sws-s-and-m-live-config-number-1-apply-config-midi-osc-only": 55563,
3429 "sws-s-and-m-live-config-number-1-apply-next-config": 55579,
3430 "sws-s-and-m-live-config-number-1-apply-preloaded-config-swap-preload-current": 55611,
3431 "sws-s-and-m-live-config-number-1-apply-previous-config": 55587,
3432 "sws-s-and-m-live-config-number-1-open-close-monitoring-window": 55555,
3433 "sws-s-and-m-live-config-number-1-preload-config-midi-osc-only": 55571,
3434 "sws-s-and-m-live-config-number-1-preload-next-config": 55595,
3435 "sws-s-and-m-live-config-number-1-preload-previous-config": 55603,
3436 "sws-s-and-m-live-config-number-1-toggle-enable": 55619,
3437 "sws-s-and-m-live-config-number-1-toggle-enable-tiny-fades": 55659,
3438 "sws-s-and-m-live-config-number-1-toggle-option-disarm-all-but-active-track": 55643,
3439 "sws-s-and-m-live-config-number-1-toggle-option-mute-all-but-active-track": 55627,
3440 "sws-s-and-m-live-config-number-1-toggle-option-offline-all-but-active-preloaded-tracks": 55635,
3441 "sws-s-and-m-live-config-number-1-toggle-option-send-all-notes-off-when-switching-configs": 55651,
3442 "sws-s-and-m-live-config-number-2-apply-config-midi-osc-only": 55564,
3443 "sws-s-and-m-live-config-number-2-apply-next-config": 55580,
3444 "sws-s-and-m-live-config-number-2-apply-preloaded-config-swap-preload-current": 55612,
3445 "sws-s-and-m-live-config-number-2-apply-previous-config": 55588,
3446 "sws-s-and-m-live-config-number-2-open-close-monitoring-window": 55556,
3447 "sws-s-and-m-live-config-number-2-preload-config-midi-osc-only": 55572,
3448 "sws-s-and-m-live-config-number-2-preload-next-config": 55596,
3449 "sws-s-and-m-live-config-number-2-preload-previous-config": 55604,
3450 "sws-s-and-m-live-config-number-2-toggle-enable": 55620,
3451 "sws-s-and-m-live-config-number-2-toggle-enable-tiny-fades": 55660,
3452 "sws-s-and-m-live-config-number-2-toggle-option-disarm-all-but-active-track": 55644,
3453 "sws-s-and-m-live-config-number-2-toggle-option-mute-all-but-active-track": 55628,
3454 "sws-s-and-m-live-config-number-2-toggle-option-offline-all-but-active-preloaded-tracks": 55636,
3455 "sws-s-and-m-live-config-number-2-toggle-option-send-all-notes-off-when-switching-configs": 55652,
3456 "sws-s-and-m-live-config-number-3-apply-config-midi-osc-only": 55565,
3457 "sws-s-and-m-live-config-number-3-apply-next-config": 55581,
3458 "sws-s-and-m-live-config-number-3-apply-preloaded-config-swap-preload-current": 55613,
3459 "sws-s-and-m-live-config-number-3-apply-previous-config": 55589,
3460 "sws-s-and-m-live-config-number-3-open-close-monitoring-window": 55557,
3461 "sws-s-and-m-live-config-number-3-preload-config-midi-osc-only": 55573,
3462 "sws-s-and-m-live-config-number-3-preload-next-config": 55597,
3463 "sws-s-and-m-live-config-number-3-preload-previous-config": 55605,
3464 "sws-s-and-m-live-config-number-3-toggle-enable": 55621,
3465 "sws-s-and-m-live-config-number-3-toggle-enable-tiny-fades": 55661,
3466 "sws-s-and-m-live-config-number-3-toggle-option-disarm-all-but-active-track": 55645,
3467 "sws-s-and-m-live-config-number-3-toggle-option-mute-all-but-active-track": 55629,
3468 "sws-s-and-m-live-config-number-3-toggle-option-offline-all-but-active-preloaded-tracks": 55637,
3469 "sws-s-and-m-live-config-number-3-toggle-option-send-all-notes-off-when-switching-configs": 55653,
3470 "sws-s-and-m-live-config-number-4-apply-config-midi-osc-only": 55566,
3471 "sws-s-and-m-live-config-number-4-apply-next-config": 55582,
3472 "sws-s-and-m-live-config-number-4-apply-preloaded-config-swap-preload-current": 55614,
3473 "sws-s-and-m-live-config-number-4-apply-previous-config": 55590,
3474 "sws-s-and-m-live-config-number-4-open-close-monitoring-window": 55558,
3475 "sws-s-and-m-live-config-number-4-preload-config-midi-osc-only": 55574,
3476 "sws-s-and-m-live-config-number-4-preload-next-config": 55598,
3477 "sws-s-and-m-live-config-number-4-preload-previous-config": 55606,
3478 "sws-s-and-m-live-config-number-4-toggle-enable": 55622,
3479 "sws-s-and-m-live-config-number-4-toggle-enable-tiny-fades": 55662,
3480 "sws-s-and-m-live-config-number-4-toggle-option-disarm-all-but-active-track": 55646,
3481 "sws-s-and-m-live-config-number-4-toggle-option-mute-all-but-active-track": 55630,
3482 "sws-s-and-m-live-config-number-4-toggle-option-offline-all-but-active-preloaded-tracks": 55638,
3483 "sws-s-and-m-live-config-number-4-toggle-option-send-all-notes-off-when-switching-configs": 55654,
3484 "sws-s-and-m-live-config-number-5-apply-config-midi-osc-only": 55567,
3485 "sws-s-and-m-live-config-number-5-apply-next-config": 55583,
3486 "sws-s-and-m-live-config-number-5-apply-preloaded-config-swap-preload-current": 55615,
3487 "sws-s-and-m-live-config-number-5-apply-previous-config": 55591,
3488 "sws-s-and-m-live-config-number-5-open-close-monitoring-window": 55559,
3489 "sws-s-and-m-live-config-number-5-preload-config-midi-osc-only": 55575,
3490 "sws-s-and-m-live-config-number-5-preload-next-config": 55599,
3491 "sws-s-and-m-live-config-number-5-preload-previous-config": 55607,
3492 "sws-s-and-m-live-config-number-5-toggle-enable": 55623,
3493 "sws-s-and-m-live-config-number-5-toggle-enable-tiny-fades": 55663,
3494 "sws-s-and-m-live-config-number-5-toggle-option-disarm-all-but-active-track": 55647,
3495 "sws-s-and-m-live-config-number-5-toggle-option-mute-all-but-active-track": 55631,
3496 "sws-s-and-m-live-config-number-5-toggle-option-offline-all-but-active-preloaded-tracks": 55639,
3497 "sws-s-and-m-live-config-number-5-toggle-option-send-all-notes-off-when-switching-configs": 55655,
3498 "sws-s-and-m-live-config-number-6-apply-config-midi-osc-only": 55568,
3499 "sws-s-and-m-live-config-number-6-apply-next-config": 55584,
3500 "sws-s-and-m-live-config-number-6-apply-preloaded-config-swap-preload-current": 55616,
3501 "sws-s-and-m-live-config-number-6-apply-previous-config": 55592,
3502 "sws-s-and-m-live-config-number-6-open-close-monitoring-window": 55560,
3503 "sws-s-and-m-live-config-number-6-preload-config-midi-osc-only": 55576,
3504 "sws-s-and-m-live-config-number-6-preload-next-config": 55600,
3505 "sws-s-and-m-live-config-number-6-preload-previous-config": 55608,
3506 "sws-s-and-m-live-config-number-6-toggle-enable": 55624,
3507 "sws-s-and-m-live-config-number-6-toggle-enable-tiny-fades": 55664,
3508 "sws-s-and-m-live-config-number-6-toggle-option-disarm-all-but-active-track": 55648,
3509 "sws-s-and-m-live-config-number-6-toggle-option-mute-all-but-active-track": 55632,
3510 "sws-s-and-m-live-config-number-6-toggle-option-offline-all-but-active-preloaded-tracks": 55640,
3511 "sws-s-and-m-live-config-number-6-toggle-option-send-all-notes-off-when-switching-configs": 55656,
3512 "sws-s-and-m-live-config-number-7-apply-config-midi-osc-only": 55569,
3513 "sws-s-and-m-live-config-number-7-apply-next-config": 55585,
3514 "sws-s-and-m-live-config-number-7-apply-preloaded-config-swap-preload-current": 55617,
3515 "sws-s-and-m-live-config-number-7-apply-previous-config": 55593,
3516 "sws-s-and-m-live-config-number-7-open-close-monitoring-window": 55561,
3517 "sws-s-and-m-live-config-number-7-preload-config-midi-osc-only": 55577,
3518 "sws-s-and-m-live-config-number-7-preload-next-config": 55601,
3519 "sws-s-and-m-live-config-number-7-preload-previous-config": 55609,
3520 "sws-s-and-m-live-config-number-7-toggle-enable": 55625,
3521 "sws-s-and-m-live-config-number-7-toggle-enable-tiny-fades": 55665,
3522 "sws-s-and-m-live-config-number-7-toggle-option-disarm-all-but-active-track": 55649,
3523 "sws-s-and-m-live-config-number-7-toggle-option-mute-all-but-active-track": 55633,
3524 "sws-s-and-m-live-config-number-7-toggle-option-offline-all-but-active-preloaded-tracks": 55641,
3525 "sws-s-and-m-live-config-number-7-toggle-option-send-all-notes-off-when-switching-configs": 55657,
3526 "sws-s-and-m-live-config-number-8-apply-config-midi-osc-only": 55570,
3527 "sws-s-and-m-live-config-number-8-apply-next-config": 55586,
3528 "sws-s-and-m-live-config-number-8-apply-preloaded-config-swap-preload-current": 55618,
3529 "sws-s-and-m-live-config-number-8-apply-previous-config": 55594,
3530 "sws-s-and-m-live-config-number-8-open-close-monitoring-window": 55562,
3531 "sws-s-and-m-live-config-number-8-preload-config-midi-osc-only": 55578,
3532 "sws-s-and-m-live-config-number-8-preload-next-config": 55602,
3533 "sws-s-and-m-live-config-number-8-preload-previous-config": 55610,
3534 "sws-s-and-m-live-config-number-8-toggle-enable": 55626,
3535 "sws-s-and-m-live-config-number-8-toggle-enable-tiny-fades": 55666,
3536 "sws-s-and-m-live-config-number-8-toggle-option-disarm-all-but-active-track": 55650,
3537 "sws-s-and-m-live-config-number-8-toggle-option-mute-all-but-active-track": 55634,
3538 "sws-s-and-m-live-config-number-8-toggle-option-offline-all-but-active-preloaded-tracks": 55642,
3539 "sws-s-and-m-live-config-number-8-toggle-option-send-all-notes-off-when-switching-configs": 55658,
3540 "sws-s-and-m-map-selected-tracks-midi-input-to-channel-1": 55715,
3541 "sws-s-and-m-map-selected-tracks-midi-input-to-channel-10": 55724,
3542 "sws-s-and-m-map-selected-tracks-midi-input-to-channel-11": 55725,
3543 "sws-s-and-m-map-selected-tracks-midi-input-to-channel-12": 55726,
3544 "sws-s-and-m-map-selected-tracks-midi-input-to-channel-13": 55727,
3545 "sws-s-and-m-map-selected-tracks-midi-input-to-channel-14": 55728,
3546 "sws-s-and-m-map-selected-tracks-midi-input-to-channel-15": 55729,
3547 "sws-s-and-m-map-selected-tracks-midi-input-to-channel-16": 55730,
3548 "sws-s-and-m-map-selected-tracks-midi-input-to-channel-2": 55716,
3549 "sws-s-and-m-map-selected-tracks-midi-input-to-channel-3": 55717,
3550 "sws-s-and-m-map-selected-tracks-midi-input-to-channel-4": 55718,
3551 "sws-s-and-m-map-selected-tracks-midi-input-to-channel-5": 55719,
3552 "sws-s-and-m-map-selected-tracks-midi-input-to-channel-6": 55720,
3553 "sws-s-and-m-map-selected-tracks-midi-input-to-channel-7": 55721,
3554 "sws-s-and-m-map-selected-tracks-midi-input-to-channel-8": 55722,
3555 "sws-s-and-m-map-selected-tracks-midi-input-to-channel-9": 55723,
3556 "sws-s-and-m-map-selected-tracks-midi-input-to-source-channel": 55235,
3557 "sws-s-and-m-move-selected-fx-down-in-chain-for-selected-tracks": 54988,
3558 "sws-s-and-m-move-selected-fx-up-in-chain-for-selected-tracks": 54987,
3559 "sws-s-and-m-notes-export-subtitle-file": 55162,
3560 "sws-s-and-m-notes-import-subtitle-file": 55161,
3561 "sws-s-and-m-notes-toggle-lock": 55160,
3562 "sws-s-and-m-open-close-cue-buss-generator": 54940,
3563 "sws-s-and-m-open-close-cycle-action-editor": 55231,
3564 "sws-s-and-m-open-close-cycle-action-editor-event-list": 55232,
3565 "sws-s-and-m-open-close-cycle-action-editor-piano-roll": 55233,
3566 "sws-s-and-m-open-close-image-window": 55047,
3567 "sws-s-and-m-open-close-live-configs-window": 55230,
3568 "sws-s-and-m-open-close-notes-window": 55148,
3569 "sws-s-and-m-open-close-notes-window-extra-project-notes": 55150,
3570 "sws-s-and-m-open-close-notes-window-global-notes": 55151,
3571 "sws-s-and-m-open-close-notes-window-item-notes": 55152,
3572 "sws-s-and-m-open-close-notes-window-marker-names": 55154,
3573 "sws-s-and-m-open-close-notes-window-marker-region-names": 55156,
3574 "sws-s-and-m-open-close-notes-window-marker-region-subtitles": 55159,
3575 "sws-s-and-m-open-close-notes-window-marker-subtitles": 55157,
3576 "sws-s-and-m-open-close-notes-window-project-notes": 55149,
3577 "sws-s-and-m-open-close-notes-window-region-names": 55155,
3578 "sws-s-and-m-open-close-notes-window-region-subtitles": 55158,
3579 "sws-s-and-m-open-close-notes-window-track-notes": 55153,
3580 "sws-s-and-m-open-close-region-playlist-window": 55236,
3581 "sws-s-and-m-open-close-resources-window": 55049,
3582 "sws-s-and-m-open-close-resources-window-fx-chains": 55050,
3583 "sws-s-and-m-open-close-resources-window-images": 55104,
3584 "sws-s-and-m-open-close-resources-window-media-files": 55084,
3585 "sws-s-and-m-open-close-resources-window-projects": 55074,
3586 "sws-s-and-m-open-close-resources-window-themes": 55111,
3587 "sws-s-and-m-open-close-resources-window-track-templates": 55062,
3588 "sws-s-and-m-open-console-with-to-send-a-local-osc-message": 53131,
3589 "sws-s-and-m-open-console-with-x-to-add-track-fx": 53130,
3590 "sws-s-and-m-open-project-path-in-explorer-finder": 55037,
3591 "sws-s-and-m-open-selected-item-path-in-explorer-finder": 55123,
3592 "sws-s-and-m-pan-active-takes-of-selected-items-to-100-percent-left": 55124,
3593 "sws-s-and-m-pan-active-takes-of-selected-items-to-100-percent-right": 55132,
3594 "sws-s-and-m-pan-active-takes-of-selected-items-to-25-percent-left": 55127,
3595 "sws-s-and-m-pan-active-takes-of-selected-items-to-25-percent-right": 55129,
3596 "sws-s-and-m-pan-active-takes-of-selected-items-to-50-percent-left": 55126,
3597 "sws-s-and-m-pan-active-takes-of-selected-items-to-50-percent-right": 55130,
3598 "sws-s-and-m-pan-active-takes-of-selected-items-to-75-percent-left": 55125,
3599 "sws-s-and-m-pan-active-takes-of-selected-items-to-75-percent-right": 55131,
3600 "sws-s-and-m-pan-active-takes-of-selected-items-to-center": 55128,
3601 "sws-s-and-m-paste-fx-chain-depending-on-focus": 55034,
3602 "sws-s-and-m-paste-fx-chain-to-selected-items": 55022,
3603 "sws-s-and-m-paste-fx-chain-to-selected-items-all-takes": 55023,
3604 "sws-s-and-m-paste-fx-chain-to-selected-tracks": 55024,
3605 "sws-s-and-m-paste-grouping-to-selected-tracks": 55173,
3606 "sws-s-and-m-paste-input-fx-chain-to-selected-tracks": 55028,
3607 "sws-s-and-m-paste-receives-to-selected-tracks": 54954,
3608 "sws-s-and-m-paste-replace-fx-chain-depending-on-focus": 55035,
3609 "sws-s-and-m-paste-replace-fx-chain-to-selected-items": 55017,
3610 "sws-s-and-m-paste-replace-fx-chain-to-selected-items-all-takes": 55018,
3611 "sws-s-and-m-paste-replace-fx-chain-to-selected-tracks": 55021,
3612 "sws-s-and-m-paste-replace-input-fx-chain-to-selected-tracks": 55027,
3613 "sws-s-and-m-paste-routings-to-selected-tracks": 54948,
3614 "sws-s-and-m-paste-sends-to-selected-tracks": 54951,
3615 "sws-s-and-m-paste-takes": 55135,
3616 "sws-s-and-m-paste-takes-after-active-takes": 55136,
3617 "sws-s-and-m-paste-tracks-with-routing-or-items": 54945,
3618 "sws-s-and-m-reassign-midi-learned-channels-of-all-fx-for-selected-tracks-prompt": 55119,
3619 "sws-s-and-m-reassign-midi-learned-channels-of-all-fx-to-input-channel-for-selected-tracks": 55121,
3620 "sws-s-and-m-reassign-midi-learned-channels-of-selected-fx-for-selected-tracks-prompt": 55120,
3621 "sws-s-and-m-recall-default-track-send-preferences": 54957,
3622 "sws-s-and-m-region-playlist-add-all-regions-to-current-playlist": 55256,
3623 "sws-s-and-m-region-playlist-append-playlist-to-project": 55245,
3624 "sws-s-and-m-region-playlist-crop-project-to-playlist": 55243,
3625 "sws-s-and-m-region-playlist-crop-project-to-playlist-new-project-tab": 55244,
3626 "sws-s-and-m-region-playlist-number-1-play": 55667,
3627 "sws-s-and-m-region-playlist-number-2-play": 55668,
3628 "sws-s-and-m-region-playlist-number-3-play": 55669,
3629 "sws-s-and-m-region-playlist-number-4-play": 55670,
3630 "sws-s-and-m-region-playlist-options-disable-shuffle-only-in-region-playlist": 55254,
3631 "sws-s-and-m-region-playlist-options-disable-smooth-seek-only-in-region-playlist": 55251,
3632 "sws-s-and-m-region-playlist-options-enable-shuffle-only-in-region-playlist": 55253,
3633 "sws-s-and-m-region-playlist-options-enable-smooth-seek-only-in-region-playlist": 55250,
3634 "sws-s-and-m-region-playlist-options-toggle-shuffle-only-in-region-playlist": 55255,
3635 "sws-s-and-m-region-playlist-options-toggle-smooth-seek-only-in-region-playlist": 55252,
3636 "sws-s-and-m-region-playlist-paste-playlist-at-edit-cursor": 55246,
3637 "sws-s-and-m-region-playlist-play": 55238,
3638 "sws-s-and-m-region-playlist-play-next-region-based-on-current-playing-region": 55242,
3639 "sws-s-and-m-region-playlist-play-next-region-smooth-seek": 55240,
3640 "sws-s-and-m-region-playlist-play-previous-region-based-on-current-playing-region": 55241,
3641 "sws-s-and-m-region-playlist-play-previous-region-smooth-seek": 55239,
3642 "sws-s-and-m-region-playlist-set-repeat-off": 55247,
3643 "sws-s-and-m-region-playlist-set-repeat-on": 55248,
3644 "sws-s-and-m-region-playlist-toggle-monitoring-edition-mode": 55237,
3645 "sws-s-and-m-region-playlist-toggle-repeat": 55249,
3646 "sws-s-and-m-remove-all-envelopes-for-selected-tracks": 55207,
3647 "sws-s-and-m-remove-receives-from-selected-tracks": 54941,
3648 "sws-s-and-m-remove-routing-from-selected-tracks": 54943,
3649 "sws-s-and-m-remove-selected-fx-for-selected-tracks": 54989,
3650 "sws-s-and-m-remove-sends-from-selected-tracks": 54942,
3651 "sws-s-and-m-remove-track-grouping-for-selected-tracks": 55174,
3652 "sws-s-and-m-resources-add-media-file-to-current-track-last-slot": 55096,
3653 "sws-s-and-m-resources-add-media-file-to-current-track-slot-1": 55435,
3654 "sws-s-and-m-resources-add-media-file-to-current-track-slot-2": 55436,
3655 "sws-s-and-m-resources-add-media-file-to-current-track-slot-3": 55437,
3656 "sws-s-and-m-resources-add-media-file-to-current-track-slot-4": 55438,
3657 "sws-s-and-m-resources-add-media-file-to-new-track-last-slot": 55097,
3658 "sws-s-and-m-resources-add-media-file-to-new-track-slot-1": 55439,
3659 "sws-s-and-m-resources-add-media-file-to-new-track-slot-2": 55440,
3660 "sws-s-and-m-resources-add-media-file-to-new-track-slot-3": 55441,
3661 "sws-s-and-m-resources-add-media-file-to-new-track-slot-4": 55442,
3662 "sws-s-and-m-resources-add-media-file-to-selected-items-as-takes-last-slot": 55098,
3663 "sws-s-and-m-resources-add-media-file-to-selected-items-as-takes-slot-1": 55443,
3664 "sws-s-and-m-resources-add-media-file-to-selected-items-as-takes-slot-2": 55444,
3665 "sws-s-and-m-resources-add-media-file-to-selected-items-as-takes-slot-3": 55445,
3666 "sws-s-and-m-resources-add-media-file-to-selected-items-as-takes-slot-4": 55446,
3667 "sws-s-and-m-resources-apply-track-template-plus-envelopes-items-to-selected-tracks-last-slot": 55071,
3668 "sws-s-and-m-resources-apply-track-template-plus-envelopes-items-to-selected-tracks-prompt-for-slot": 55286,
3669 "sws-s-and-m-resources-apply-track-template-plus-envelopes-items-to-selected-tracks-slot-1": 55391,
3670 "sws-s-and-m-resources-apply-track-template-plus-envelopes-items-to-selected-tracks-slot-2": 55392,
3671 "sws-s-and-m-resources-apply-track-template-plus-envelopes-items-to-selected-tracks-slot-3": 55393,
3672 "sws-s-and-m-resources-apply-track-template-plus-envelopes-items-to-selected-tracks-slot-4": 55394,
3673 "sws-s-and-m-resources-apply-track-template-to-selected-tracks-last-slot": 55070,
3674 "sws-s-and-m-resources-apply-track-template-to-selected-tracks-prompt-for-slot": 55285,
3675 "sws-s-and-m-resources-apply-track-template-to-selected-tracks-slot-1": 55387,
3676 "sws-s-and-m-resources-apply-track-template-to-selected-tracks-slot-2": 55388,
3677 "sws-s-and-m-resources-apply-track-template-to-selected-tracks-slot-3": 55389,
3678 "sws-s-and-m-resources-apply-track-template-to-selected-tracks-slot-4": 55390,
3679 "sws-s-and-m-resources-auto-save-fx-chains-for-selected-items": 55054,
3680 "sws-s-and-m-resources-auto-save-fx-chains-for-selected-tracks": 55053,
3681 "sws-s-and-m-resources-auto-save-input-fx-chains-for-selected-tracks": 55055,
3682 "sws-s-and-m-resources-auto-save-media-files-for-selected-items": 55089,
3683 "sws-s-and-m-resources-auto-save-project": 55077,
3684 "sws-s-and-m-resources-auto-save-track-template": 55065,
3685 "sws-s-and-m-resources-auto-save-track-template-with-envelopes": 55067,
3686 "sws-s-and-m-resources-auto-save-track-template-with-items": 55068,
3687 "sws-s-and-m-resources-auto-save-track-template-with-items-envelopes": 55066,
3688 "sws-s-and-m-resources-clear-fx-chain-slot-prompt-for-slot": 55272,
3689 "sws-s-and-m-resources-clear-image-slot-prompt-for-slot": 55276,
3690 "sws-s-and-m-resources-clear-media-file-slot-prompt-for-slot": 55275,
3691 "sws-s-and-m-resources-clear-project-template-slot-prompt-for-slot": 55274,
3692 "sws-s-and-m-resources-clear-theme-slot-prompt-for-slot": 55277,
3693 "sws-s-and-m-resources-clear-track-template-slot-prompt-for-slot": 55273,
3694 "sws-s-and-m-resources-delete-all-fx-chain-slots": 55052,
3695 "sws-s-and-m-resources-delete-all-image-slots": 55108,
3696 "sws-s-and-m-resources-delete-all-media-file-slots": 55088,
3697 "sws-s-and-m-resources-delete-all-project-slots": 55076,
3698 "sws-s-and-m-resources-delete-all-theme-slots": 55113,
3699 "sws-s-and-m-resources-delete-all-track-template-slots": 55064,
3700 "sws-s-and-m-resources-delete-last-fx-chain-slot-file": 55051,
3701 "sws-s-and-m-resources-delete-last-image-slot-file": 55107,
3702 "sws-s-and-m-resources-delete-last-media-file-slot-file": 55087,
3703 "sws-s-and-m-resources-delete-last-project-slot-file": 55075,
3704 "sws-s-and-m-resources-delete-last-theme-slot-file": 55112,
3705 "sws-s-and-m-resources-delete-last-track-template-slot-file": 55063,
3706 "sws-s-and-m-resources-import-tracks-from-track-template-last-slot": 55069,
3707 "sws-s-and-m-resources-import-tracks-from-track-template-prompt-for-slot": 55284,
3708 "sws-s-and-m-resources-import-tracks-from-track-template-slot-1": 55395,
3709 "sws-s-and-m-resources-import-tracks-from-track-template-slot-2": 55396,
3710 "sws-s-and-m-resources-import-tracks-from-track-template-slot-3": 55397,
3711 "sws-s-and-m-resources-import-tracks-from-track-template-slot-4": 55398,
3712 "sws-s-and-m-resources-load-theme-last-slot": 55114,
3713 "sws-s-and-m-resources-load-theme-slot-1": 55455,
3714 "sws-s-and-m-resources-load-theme-slot-2": 55456,
3715 "sws-s-and-m-resources-load-theme-slot-3": 55457,
3716 "sws-s-and-m-resources-load-theme-slot-4": 55458,
3717 "sws-s-and-m-resources-loop-media-file-in-selected-tracks-last-slot": 55091,
3718 "sws-s-and-m-resources-loop-media-file-in-selected-tracks-slot-1": 55411,
3719 "sws-s-and-m-resources-loop-media-file-in-selected-tracks-slot-2": 55412,
3720 "sws-s-and-m-resources-loop-media-file-in-selected-tracks-slot-3": 55413,
3721 "sws-s-and-m-resources-loop-media-file-in-selected-tracks-slot-4": 55414,
3722 "sws-s-and-m-resources-loop-media-file-in-selected-tracks-sync-with-next-measure-slot-1": 55419,
3723 "sws-s-and-m-resources-loop-media-file-in-selected-tracks-sync-with-next-measure-slot-2": 55420,
3724 "sws-s-and-m-resources-loop-media-file-in-selected-tracks-sync-with-next-measure-slot-3": 55421,
3725 "sws-s-and-m-resources-loop-media-file-in-selected-tracks-sync-with-next-measure-slot-4": 55422,
3726 "sws-s-and-m-resources-loop-media-file-in-selected-tracks-toggle-last-slot": 55093,
3727 "sws-s-and-m-resources-loop-media-file-in-selected-tracks-toggle-pause-last-slot": 55095,
3728 "sws-s-and-m-resources-loop-media-file-in-selected-tracks-toggle-slot-1": 55427,
3729 "sws-s-and-m-resources-loop-media-file-in-selected-tracks-toggle-slot-2": 55428,
3730 "sws-s-and-m-resources-loop-media-file-in-selected-tracks-toggle-slot-3": 55429,
3731 "sws-s-and-m-resources-loop-media-file-in-selected-tracks-toggle-slot-4": 55430,
3732 "sws-s-and-m-resources-open-project-last-slot": 55078,
3733 "sws-s-and-m-resources-open-project-last-slot-new-tab": 55079,
3734 "sws-s-and-m-resources-open-project-next-slot-cycle": 55080,
3735 "sws-s-and-m-resources-open-project-next-slot-new-tab-cycle": 55082,
3736 "sws-s-and-m-resources-open-project-previous-slot-cycle": 55081,
3737 "sws-s-and-m-resources-open-project-previous-slot-new-tab-cycle": 55083,
3738 "sws-s-and-m-resources-open-project-prompt-for-slot": 55289,
3739 "sws-s-and-m-resources-open-project-prompt-for-slot-new-tab": 55290,
3740 "sws-s-and-m-resources-open-project-slot-1": 55399,
3741 "sws-s-and-m-resources-open-project-slot-1-new-tab": 55403,
3742 "sws-s-and-m-resources-open-project-slot-2": 55400,
3743 "sws-s-and-m-resources-open-project-slot-2-new-tab": 55404,
3744 "sws-s-and-m-resources-open-project-slot-3": 55401,
3745 "sws-s-and-m-resources-open-project-slot-3-new-tab": 55405,
3746 "sws-s-and-m-resources-open-project-slot-4": 55402,
3747 "sws-s-and-m-resources-open-project-slot-4-new-tab": 55406,
3748 "sws-s-and-m-resources-paste-fx-chain-to-selected-items-all-takes-last-slot": 55059,
3749 "sws-s-and-m-resources-paste-fx-chain-to-selected-items-all-takes-prompt-for-slot": 55281,
3750 "sws-s-and-m-resources-paste-fx-chain-to-selected-items-last-slot": 55058,
3751 "sws-s-and-m-resources-paste-fx-chain-to-selected-items-prompt-for-slot": 55280,
3752 "sws-s-and-m-resources-paste-fx-chain-to-selected-items-slot-1": 55367,
3753 "sws-s-and-m-resources-paste-fx-chain-to-selected-items-slot-2": 55368,
3754 "sws-s-and-m-resources-paste-fx-chain-to-selected-items-slot-3": 55369,
3755 "sws-s-and-m-resources-paste-fx-chain-to-selected-items-slot-4": 55370,
3756 "sws-s-and-m-resources-paste-fx-chain-to-selected-tracks-last-slot": 55061,
3757 "sws-s-and-m-resources-paste-fx-chain-to-selected-tracks-prompt-for-slot": 55283,
3758 "sws-s-and-m-resources-paste-fx-chain-to-selected-tracks-slot-1": 55375,
3759 "sws-s-and-m-resources-paste-fx-chain-to-selected-tracks-slot-2": 55376,
3760 "sws-s-and-m-resources-paste-fx-chain-to-selected-tracks-slot-3": 55377,
3761 "sws-s-and-m-resources-paste-fx-chain-to-selected-tracks-slot-4": 55378,
3762 "sws-s-and-m-resources-paste-replace-fx-chain-to-selected-items-all-takes-last-slot": 55057,
3763 "sws-s-and-m-resources-paste-replace-fx-chain-to-selected-items-all-takes-prompt-for-slot": 55279,
3764 "sws-s-and-m-resources-paste-replace-fx-chain-to-selected-items-last-slot": 55056,
3765 "sws-s-and-m-resources-paste-replace-fx-chain-to-selected-items-prompt-for-slot": 55278,
3766 "sws-s-and-m-resources-paste-replace-fx-chain-to-selected-items-slot-1": 55363,
3767 "sws-s-and-m-resources-paste-replace-fx-chain-to-selected-items-slot-2": 55364,
3768 "sws-s-and-m-resources-paste-replace-fx-chain-to-selected-items-slot-3": 55365,
3769 "sws-s-and-m-resources-paste-replace-fx-chain-to-selected-items-slot-4": 55366,
3770 "sws-s-and-m-resources-paste-replace-fx-chain-to-selected-tracks-last-slot": 55060,
3771 "sws-s-and-m-resources-paste-replace-fx-chain-to-selected-tracks-prompt-for-slot": 55282,
3772 "sws-s-and-m-resources-paste-replace-fx-chain-to-selected-tracks-slot-1": 55371,
3773 "sws-s-and-m-resources-paste-replace-fx-chain-to-selected-tracks-slot-2": 55372,
3774 "sws-s-and-m-resources-paste-replace-fx-chain-to-selected-tracks-slot-3": 55373,
3775 "sws-s-and-m-resources-paste-replace-fx-chain-to-selected-tracks-slot-4": 55374,
3776 "sws-s-and-m-resources-paste-replace-template-items-to-selected-tracks-last-slot": 55072,
3777 "sws-s-and-m-resources-paste-replace-template-items-to-selected-tracks-last-slot-55287": 55287,
3778 "sws-s-and-m-resources-paste-replace-template-items-to-selected-tracks-slot-1": 55379,
3779 "sws-s-and-m-resources-paste-replace-template-items-to-selected-tracks-slot-2": 55380,
3780 "sws-s-and-m-resources-paste-replace-template-items-to-selected-tracks-slot-3": 55381,
3781 "sws-s-and-m-resources-paste-replace-template-items-to-selected-tracks-slot-4": 55382,
3782 "sws-s-and-m-resources-paste-template-items-to-selected-tracks-last-slot": 55073,
3783 "sws-s-and-m-resources-paste-template-items-to-selected-tracks-last-slot-55288": 55288,
3784 "sws-s-and-m-resources-paste-template-items-to-selected-tracks-slot-1": 55383,
3785 "sws-s-and-m-resources-paste-template-items-to-selected-tracks-slot-2": 55384,
3786 "sws-s-and-m-resources-paste-template-items-to-selected-tracks-slot-3": 55385,
3787 "sws-s-and-m-resources-paste-template-items-to-selected-tracks-slot-4": 55386,
3788 "sws-s-and-m-resources-play-media-file-in-selected-tracks-last-slot": 55090,
3789 "sws-s-and-m-resources-play-media-file-in-selected-tracks-slot-1": 55407,
3790 "sws-s-and-m-resources-play-media-file-in-selected-tracks-slot-2": 55408,
3791 "sws-s-and-m-resources-play-media-file-in-selected-tracks-slot-3": 55409,
3792 "sws-s-and-m-resources-play-media-file-in-selected-tracks-slot-4": 55410,
3793 "sws-s-and-m-resources-play-media-file-in-selected-tracks-sync-with-next-measure-slot-1": 55415,
3794 "sws-s-and-m-resources-play-media-file-in-selected-tracks-sync-with-next-measure-slot-2": 55416,
3795 "sws-s-and-m-resources-play-media-file-in-selected-tracks-sync-with-next-measure-slot-3": 55417,
3796 "sws-s-and-m-resources-play-media-file-in-selected-tracks-sync-with-next-measure-slot-4": 55418,
3797 "sws-s-and-m-resources-play-media-file-in-selected-tracks-toggle-last-slot": 55092,
3798 "sws-s-and-m-resources-play-media-file-in-selected-tracks-toggle-pause-last-slot": 55094,
3799 "sws-s-and-m-resources-play-media-file-in-selected-tracks-toggle-pause-slot-1": 55431,
3800 "sws-s-and-m-resources-play-media-file-in-selected-tracks-toggle-pause-slot-2": 55432,
3801 "sws-s-and-m-resources-play-media-file-in-selected-tracks-toggle-pause-slot-3": 55433,
3802 "sws-s-and-m-resources-play-media-file-in-selected-tracks-toggle-pause-slot-4": 55434,
3803 "sws-s-and-m-resources-play-media-file-in-selected-tracks-toggle-slot-1": 55423,
3804 "sws-s-and-m-resources-play-media-file-in-selected-tracks-toggle-slot-2": 55424,
3805 "sws-s-and-m-resources-play-media-file-in-selected-tracks-toggle-slot-3": 55425,
3806 "sws-s-and-m-resources-play-media-file-in-selected-tracks-toggle-slot-4": 55426,
3807 "sws-s-and-m-resources-set-add-media-file-option-to-default": 55099,
3808 "sws-s-and-m-resources-set-add-media-file-option-to-stretch-loop-to-fit-time-sel": 55100,
3809 "sws-s-and-m-resources-set-add-media-file-option-to-try-to-match-tempo-0-5x": 55101,
3810 "sws-s-and-m-resources-set-add-media-file-option-to-try-to-match-tempo-1x": 55102,
3811 "sws-s-and-m-resources-set-add-media-file-option-to-try-to-match-tempo-2x": 55103,
3812 "sws-s-and-m-resources-set-track-icon-for-selected-tracks-last-slot": 55110,
3813 "sws-s-and-m-resources-set-track-icon-for-selected-tracks-slot-1": 55451,
3814 "sws-s-and-m-resources-set-track-icon-for-selected-tracks-slot-2": 55452,
3815 "sws-s-and-m-resources-set-track-icon-for-selected-tracks-slot-3": 55453,
3816 "sws-s-and-m-resources-set-track-icon-for-selected-tracks-slot-4": 55454,
3817 "sws-s-and-m-resources-show-image-last-slot": 55109,
3818 "sws-s-and-m-resources-show-image-slot-1": 55447,
3819 "sws-s-and-m-resources-show-image-slot-2": 55448,
3820 "sws-s-and-m-resources-show-image-slot-3": 55449,
3821 "sws-s-and-m-resources-show-image-slot-4": 55450,
3822 "sws-s-and-m-resources-show-next-image-slot": 55105,
3823 "sws-s-and-m-resources-show-previous-image-slot": 55106,
3824 "sws-s-and-m-resources-stop-all-playing-media-files": 55085,
3825 "sws-s-and-m-resources-stop-all-playing-media-files-in-selected-tracks": 55086,
3826 "sws-s-and-m-restore-selected-tracks-folder-compact-states": 55183,
3827 "sws-s-and-m-restore-selected-tracks-folder-states": 55177,
3828 "sws-s-and-m-save-default-track-send-preferences": 54956,
3829 "sws-s-and-m-save-selected-tracks-folder-compact-states": 55182,
3830 "sws-s-and-m-save-selected-tracks-folder-states": 55176,
3831 "sws-s-and-m-scroll-to-selected-item-no-undo": 55122,
3832 "sws-s-and-m-select-fx-1-for-selected-tracks": 55475,
3833 "sws-s-and-m-select-fx-2-for-selected-tracks": 55476,
3834 "sws-s-and-m-select-fx-3-for-selected-tracks": 55477,
3835 "sws-s-and-m-select-fx-4-for-selected-tracks": 55478,
3836 "sws-s-and-m-select-fx-5-for-selected-tracks": 55479,
3837 "sws-s-and-m-select-fx-6-for-selected-tracks": 55480,
3838 "sws-s-and-m-select-fx-7-for-selected-tracks": 55481,
3839 "sws-s-and-m-select-fx-8-for-selected-tracks": 55482,
3840 "sws-s-and-m-select-last-fx-for-selected-tracks": 54984,
3841 "sws-s-and-m-select-next-fx-cycling-for-selected-tracks": 54986,
3842 "sws-s-and-m-select-only-track-with-selected-envelope": 55218,
3843 "sws-s-and-m-select-previous-fx-cycling-for-selected-tracks": 54985,
3844 "sws-s-and-m-select-project-midi-osc-only": 55038,
3845 "sws-s-and-m-send-all-notes-off-to-selected-tracks": 55259,
3846 "sws-s-and-m-send-all-sounds-off-to-selected-tracks": 55260,
3847 "sws-s-and-m-set-active-take-pan-envelope-to-100-percent-left": 55197,
3848 "sws-s-and-m-set-active-take-pan-envelope-to-100-percent-right": 55196,
3849 "sws-s-and-m-set-active-take-pan-envelope-to-center": 55198,
3850 "sws-s-and-m-set-all-fx-except-1-offline-for-selected-tracks": 55347,
3851 "sws-s-and-m-set-all-fx-except-2-offline-for-selected-tracks": 55348,
3852 "sws-s-and-m-set-all-fx-except-3-offline-for-selected-tracks": 55349,
3853 "sws-s-and-m-set-all-fx-except-4-offline-for-selected-tracks": 55350,
3854 "sws-s-and-m-set-all-fx-except-5-offline-for-selected-tracks": 55351,
3855 "sws-s-and-m-set-all-fx-except-6-offline-for-selected-tracks": 55352,
3856 "sws-s-and-m-set-all-fx-except-7-offline-for-selected-tracks": 55353,
3857 "sws-s-and-m-set-all-fx-except-8-offline-for-selected-tracks": 55354,
3858 "sws-s-and-m-set-all-take-fx-offline-for-selected-items": 55009,
3859 "sws-s-and-m-set-all-take-fx-online-for-selected-items": 55010,
3860 "sws-s-and-m-set-default-track-sends-to-audio-and-midi": 54958,
3861 "sws-s-and-m-set-default-track-sends-to-audio-only": 54959,
3862 "sws-s-and-m-set-default-track-sends-to-midi-only": 54960,
3863 "sws-s-and-m-set-fx-1-offline-for-selected-tracks": 55307,
3864 "sws-s-and-m-set-fx-1-online-for-selected-tracks": 55299,
3865 "sws-s-and-m-set-fx-2-offline-for-selected-tracks": 55308,
3866 "sws-s-and-m-set-fx-2-online-for-selected-tracks": 55300,
3867 "sws-s-and-m-set-fx-3-offline-for-selected-tracks": 55309,
3868 "sws-s-and-m-set-fx-3-online-for-selected-tracks": 55301,
3869 "sws-s-and-m-set-fx-4-offline-for-selected-tracks": 55310,
3870 "sws-s-and-m-set-fx-4-online-for-selected-tracks": 55302,
3871 "sws-s-and-m-set-fx-5-offline-for-selected-tracks": 55311,
3872 "sws-s-and-m-set-fx-5-online-for-selected-tracks": 55303,
3873 "sws-s-and-m-set-fx-6-offline-for-selected-tracks": 55312,
3874 "sws-s-and-m-set-fx-6-online-for-selected-tracks": 55304,
3875 "sws-s-and-m-set-fx-7-offline-for-selected-tracks": 55313,
3876 "sws-s-and-m-set-fx-7-online-for-selected-tracks": 55305,
3877 "sws-s-and-m-set-fx-8-offline-for-selected-tracks": 55314,
3878 "sws-s-and-m-set-fx-8-online-for-selected-tracks": 55306,
3879 "sws-s-and-m-set-global-startup-action": 55045,
3880 "sws-s-and-m-set-last-fx-offline-for-selected-tracks": 54994,
3881 "sws-s-and-m-set-last-fx-online-for-selected-tracks": 54992,
3882 "sws-s-and-m-set-project-startup-action": 55042,
3883 "sws-s-and-m-set-selected-fx-offline-for-selected-tracks": 54995,
3884 "sws-s-and-m-set-selected-fx-online-for-selected-tracks": 54993,
3885 "sws-s-and-m-set-selected-tracks-folder-states-to-last-in-folder": 55179,
3886 "sws-s-and-m-set-selected-tracks-folder-states-to-last-of-all-folders": 55178,
3887 "sws-s-and-m-set-selected-tracks-folder-states-to-normal": 55181,
3888 "sws-s-and-m-set-selected-tracks-folder-states-to-parent": 55180,
3889 "sws-s-and-m-set-selected-tracks-midi-input-to-all-channels": 55234,
3890 "sws-s-and-m-set-selected-tracks-midi-input-to-channel-1": 55699,
3891 "sws-s-and-m-set-selected-tracks-midi-input-to-channel-10": 55708,
3892 "sws-s-and-m-set-selected-tracks-midi-input-to-channel-11": 55709,
3893 "sws-s-and-m-set-selected-tracks-midi-input-to-channel-12": 55710,
3894 "sws-s-and-m-set-selected-tracks-midi-input-to-channel-13": 55711,
3895 "sws-s-and-m-set-selected-tracks-midi-input-to-channel-14": 55712,
3896 "sws-s-and-m-set-selected-tracks-midi-input-to-channel-15": 55713,
3897 "sws-s-and-m-set-selected-tracks-midi-input-to-channel-16": 55714,
3898 "sws-s-and-m-set-selected-tracks-midi-input-to-channel-2": 55700,
3899 "sws-s-and-m-set-selected-tracks-midi-input-to-channel-3": 55701,
3900 "sws-s-and-m-set-selected-tracks-midi-input-to-channel-4": 55702,
3901 "sws-s-and-m-set-selected-tracks-midi-input-to-channel-5": 55703,
3902 "sws-s-and-m-set-selected-tracks-midi-input-to-channel-6": 55704,
3903 "sws-s-and-m-set-selected-tracks-midi-input-to-channel-7": 55705,
3904 "sws-s-and-m-set-selected-tracks-midi-input-to-channel-8": 55706,
3905 "sws-s-and-m-set-selected-tracks-midi-input-to-channel-9": 55707,
3906 "sws-s-and-m-set-selected-tracks-to-first-unused-group-default-flags": 55175,
3907 "sws-s-and-m-set-selected-tracks-to-group-1-default-flags": 55547,
3908 "sws-s-and-m-set-selected-tracks-to-group-2-default-flags": 55548,
3909 "sws-s-and-m-set-selected-tracks-to-group-3-default-flags": 55549,
3910 "sws-s-and-m-set-selected-tracks-to-group-4-default-flags": 55550,
3911 "sws-s-and-m-set-selected-tracks-to-group-5-default-flags": 55551,
3912 "sws-s-and-m-set-selected-tracks-to-group-6-default-flags": 55552,
3913 "sws-s-and-m-set-selected-tracks-to-group-7-default-flags": 55553,
3914 "sws-s-and-m-set-selected-tracks-to-group-8-default-flags": 55554,
3915 "sws-s-and-m-show-all-floating-fx-windows": 54965,
3916 "sws-s-and-m-show-all-floating-fx-windows-for-selected-tracks": 54967,
3917 "sws-s-and-m-show-all-fx-chain-windows": 54966,
3918 "sws-s-and-m-show-and-unbypass-take-mute-envelope": 55186,
3919 "sws-s-and-m-show-and-unbypass-take-pan-envelope": 55185,
3920 "sws-s-and-m-show-and-unbypass-take-pitch-envelope": 55199,
3921 "sws-s-and-m-show-and-unbypass-take-volume-envelope": 55184,
3922 "sws-s-and-m-show-fx-chain-for-selected-tracks-fx-1": 55483,
3923 "sws-s-and-m-show-fx-chain-for-selected-tracks-fx-2": 55484,
3924 "sws-s-and-m-show-fx-chain-for-selected-tracks-fx-3": 55485,
3925 "sws-s-and-m-show-fx-chain-for-selected-tracks-fx-4": 55486,
3926 "sws-s-and-m-show-fx-chain-for-selected-tracks-fx-5": 55487,
3927 "sws-s-and-m-show-fx-chain-for-selected-tracks-fx-6": 55488,
3928 "sws-s-and-m-show-fx-chain-for-selected-tracks-fx-7": 55489,
3929 "sws-s-and-m-show-fx-chain-for-selected-tracks-fx-8": 55490,
3930 "sws-s-and-m-show-fx-chain-for-selected-tracks-selected-fx": 54978,
3931 "sws-s-and-m-show-project-global-startup-actions": 55044,
3932 "sws-s-and-m-show-take-mute-envelope": 55192,
3933 "sws-s-and-m-show-take-pan-envelope": 55191,
3934 "sws-s-and-m-show-take-pitch-envelope": 55201,
3935 "sws-s-and-m-show-take-volume-envelope": 55190,
3936 "sws-s-and-m-show-theme-helper-all-tracks": 55263,
3937 "sws-s-and-m-show-theme-helper-selected-tracks": 55264,
3938 "sws-s-and-m-split-and-select-items-in-region-near-cursor": 55166,
3939 "sws-s-and-m-split-selected-items-at-edit-cursor-midi-or-prior-zero-crossing-audio": 55163,
3940 "sws-s-and-m-split-selected-items-at-time-selection-edit-cursor-midi-or-prior-zero-crossing-audio": 55164,
3941 "sws-s-and-m-takes-activate-lane-under-mouse-cursor": 55139,
3942 "sws-s-and-m-takes-activate-lanes-from-selected-items": 55138,
3943 "sws-s-and-m-takes-clear-active-takes-items": 55137,
3944 "sws-s-and-m-takes-move-active-down-cycling-in-selected-items": 55143,
3945 "sws-s-and-m-takes-move-active-up-cycling-in-selected-items": 55142,
3946 "sws-s-and-m-takes-remove-empty-midi-takes-items-among-selected-items": 55141,
3947 "sws-s-and-m-takes-remove-empty-takes-items-among-selected-items": 55140,
3948 "sws-s-and-m-toggle-all-fx-bypass-for-selected-tracks": 55003,
3949 "sws-s-and-m-toggle-all-fx-except-selected-bypass-for-selected-tracks": 55007,
3950 "sws-s-and-m-toggle-all-fx-except-selected-online-offline-for-selected-tracks": 55006,
3951 "sws-s-and-m-toggle-all-fx-online-offline-for-selected-tracks": 54996,
3952 "sws-s-and-m-toggle-all-take-fx-bypass-for-selected-items": 55011,
3953 "sws-s-and-m-toggle-all-take-fx-online-offline-for-selected-items": 55008,
3954 "sws-s-and-m-toggle-arming-of-all-active-envelopes-for-selected-tracks": 55208,
3955 "sws-s-and-m-toggle-arming-of-all-plugin-envelopes-for-selected-tracks": 55217,
3956 "sws-s-and-m-toggle-arming-of-all-receive-mute-envelopes-for-selected-tracks": 55216,
3957 "sws-s-and-m-toggle-arming-of-all-receive-pan-envelopes-for-selected-tracks": 55215,
3958 "sws-s-and-m-toggle-arming-of-all-receive-volume-envelopes-for-selected-tracks": 55214,
3959 "sws-s-and-m-toggle-arming-of-mute-envelope-for-selected-tracks": 55213,
3960 "sws-s-and-m-toggle-arming-of-pan-envelope-for-selected-tracks": 55212,
3961 "sws-s-and-m-toggle-arming-of-volume-envelope-for-selected-tracks": 55211,
3962 "sws-s-and-m-toggle-auto-marker-coloring-enable": 53002,
3963 "sws-s-and-m-toggle-auto-region-coloring-enable": 53003,
3964 "sws-s-and-m-toggle-auto-track-icon-enable": 53004,
3965 "sws-s-and-m-toggle-auto-track-layout-enable": 53005,
3966 "sws-s-and-m-toggle-float-fx-1-for-selected-tracks": 55507,
3967 "sws-s-and-m-toggle-float-fx-2-for-selected-tracks": 55508,
3968 "sws-s-and-m-toggle-float-fx-3-for-selected-tracks": 55509,
3969 "sws-s-and-m-toggle-float-fx-4-for-selected-tracks": 55510,
3970 "sws-s-and-m-toggle-float-fx-5-for-selected-tracks": 55511,
3971 "sws-s-and-m-toggle-float-fx-6-for-selected-tracks": 55512,
3972 "sws-s-and-m-toggle-float-fx-7-for-selected-tracks": 55513,
3973 "sws-s-and-m-toggle-float-fx-8-for-selected-tracks": 55514,
3974 "sws-s-and-m-toggle-float-selected-fx-for-selected-tracks": 54983,
3975 "sws-s-and-m-toggle-fx-1-bypass-for-selected-tracks": 55339,
3976 "sws-s-and-m-toggle-fx-1-online-offline-for-selected-tracks": 55315,
3977 "sws-s-and-m-toggle-fx-2-bypass-for-selected-tracks": 55340,
3978 "sws-s-and-m-toggle-fx-2-online-offline-for-selected-tracks": 55316,
3979 "sws-s-and-m-toggle-fx-3-bypass-for-selected-tracks": 55341,
3980 "sws-s-and-m-toggle-fx-3-online-offline-for-selected-tracks": 55317,
3981 "sws-s-and-m-toggle-fx-4-bypass-for-selected-tracks": 55342,
3982 "sws-s-and-m-toggle-fx-4-online-offline-for-selected-tracks": 55318,
3983 "sws-s-and-m-toggle-fx-5-bypass-for-selected-tracks": 55343,
3984 "sws-s-and-m-toggle-fx-5-online-offline-for-selected-tracks": 55319,
3985 "sws-s-and-m-toggle-fx-6-bypass-for-selected-tracks": 55344,
3986 "sws-s-and-m-toggle-fx-6-online-offline-for-selected-tracks": 55320,
3987 "sws-s-and-m-toggle-fx-7-bypass-for-selected-tracks": 55345,
3988 "sws-s-and-m-toggle-fx-7-online-offline-for-selected-tracks": 55321,
3989 "sws-s-and-m-toggle-fx-8-bypass-for-selected-tracks": 55346,
3990 "sws-s-and-m-toggle-fx-8-online-offline-for-selected-tracks": 55322,
3991 "sws-s-and-m-toggle-last-fx-bypass-for-selected-tracks": 54997,
3992 "sws-s-and-m-toggle-last-fx-online-offline-for-selected-tracks": 54990,
3993 "sws-s-and-m-toggle-selected-fx-bypass-for-selected-tracks": 54998,
3994 "sws-s-and-m-toggle-selected-fx-online-offline-for-selected-tracks": 54991,
3995 "sws-s-and-m-toggle-show-all-floating-fx": 54968,
3996 "sws-s-and-m-toggle-show-all-floating-fx-for-selected-tracks": 54970,
3997 "sws-s-and-m-toggle-show-all-fx-chain-windows": 54969,
3998 "sws-s-and-m-toggle-show-fx-chain-windows-for-selected-tracks": 54980,
3999 "sws-s-and-m-toggle-show-take-mute-envelope": 55205,
4000 "sws-s-and-m-toggle-show-take-pan-envelope": 55204,
4001 "sws-s-and-m-toggle-show-take-pitch-envelope": 55206,
4002 "sws-s-and-m-toggle-show-take-volume-envelope": 55203,
4003 "sws-s-and-m-toggle-toolbars-auto-refresh-enable": 55219,
4004 "sws-s-and-m-toolbar-toggle-offscreen-item-selection": 55225,
4005 "sws-s-and-m-toolbar-toggle-offscreen-item-selection-bottom": 55224,
4006 "sws-s-and-m-toolbar-toggle-offscreen-item-selection-left": 55221,
4007 "sws-s-and-m-toolbar-toggle-offscreen-item-selection-right": 55222,
4008 "sws-s-and-m-toolbar-toggle-offscreen-item-selection-top": 55223,
4009 "sws-s-and-m-toolbar-toggle-track-envelopes-in-touch-latch-latch-preview-write": 55220,
4010 "sws-s-and-m-trigger-next-preset-for-fx-1-of-selected-tracks": 55459,
4011 "sws-s-and-m-trigger-next-preset-for-fx-2-of-selected-tracks": 55460,
4012 "sws-s-and-m-trigger-next-preset-for-fx-3-of-selected-tracks": 55461,
4013 "sws-s-and-m-trigger-next-preset-for-fx-4-of-selected-tracks": 55462,
4014 "sws-s-and-m-trigger-next-preset-for-last-touched-fx": 55117,
4015 "sws-s-and-m-trigger-next-preset-for-selected-fx-of-selected-tracks": 55115,
4016 "sws-s-and-m-trigger-preset-for-fx-1-of-selected-track-midi-osc-only": 55467,
4017 "sws-s-and-m-trigger-preset-for-fx-2-of-selected-track-midi-osc-only": 55468,
4018 "sws-s-and-m-trigger-preset-for-fx-3-of-selected-track-midi-osc-only": 55469,
4019 "sws-s-and-m-trigger-preset-for-fx-4-of-selected-track-midi-osc-only": 55470,
4020 "sws-s-and-m-trigger-preset-for-fx-5-of-selected-track-midi-osc-only": 55471,
4021 "sws-s-and-m-trigger-preset-for-fx-6-of-selected-track-midi-osc-only": 55472,
4022 "sws-s-and-m-trigger-preset-for-fx-7-of-selected-track-midi-osc-only": 55473,
4023 "sws-s-and-m-trigger-preset-for-fx-8-of-selected-track-midi-osc-only": 55474,
4024 "sws-s-and-m-trigger-preset-for-selected-fx-of-selected-track-midi-osc-only": 55014,
4025 "sws-s-and-m-trigger-previous-preset-for-fx-1-of-selected-tracks": 55463,
4026 "sws-s-and-m-trigger-previous-preset-for-fx-2-of-selected-tracks": 55464,
4027 "sws-s-and-m-trigger-previous-preset-for-fx-3-of-selected-tracks": 55465,
4028 "sws-s-and-m-trigger-previous-preset-for-fx-4-of-selected-tracks": 55466,
4029 "sws-s-and-m-trigger-previous-preset-for-last-touched-fx": 55118,
4030 "sws-s-and-m-trigger-previous-preset-for-selected-fx-of-selected-tracks": 55116,
4031 "sws-s-and-m-unbypass-all-fx-for-selected-tracks": 55005,
4032 "sws-s-and-m-unbypass-all-take-fx-for-selected-items": 55013,
4033 "sws-s-and-m-unbypass-fx-1-for-selected-tracks": 55331,
4034 "sws-s-and-m-unbypass-fx-2-for-selected-tracks": 55332,
4035 "sws-s-and-m-unbypass-fx-3-for-selected-tracks": 55333,
4036 "sws-s-and-m-unbypass-fx-4-for-selected-tracks": 55334,
4037 "sws-s-and-m-unbypass-fx-5-for-selected-tracks": 55335,
4038 "sws-s-and-m-unbypass-fx-6-for-selected-tracks": 55336,
4039 "sws-s-and-m-unbypass-fx-7-for-selected-tracks": 55337,
4040 "sws-s-and-m-unbypass-fx-8-for-selected-tracks": 55338,
4041 "sws-s-and-m-unbypass-last-fx-for-selected-tracks": 55001,
4042 "sws-s-and-m-unbypass-selected-fx-for-selected-tracks": 55002,
4043 "sws-s-and-m-unfloat-fx-1-for-selected-tracks": 55499,
4044 "sws-s-and-m-unfloat-fx-2-for-selected-tracks": 55500,
4045 "sws-s-and-m-unfloat-fx-3-for-selected-tracks": 55501,
4046 "sws-s-and-m-unfloat-fx-4-for-selected-tracks": 55502,
4047 "sws-s-and-m-unfloat-fx-5-for-selected-tracks": 55503,
4048 "sws-s-and-m-unfloat-fx-6-for-selected-tracks": 55504,
4049 "sws-s-and-m-unfloat-fx-7-for-selected-tracks": 55505,
4050 "sws-s-and-m-unfloat-fx-8-for-selected-tracks": 55506,
4051 "sws-s-and-m-unfloat-selected-fx-for-selected-tracks": 54982,
4052 "sws-s-and-m-unselect-offscreen-items": 55226,
4053 "sws-s-and-m-what-s-new": 53930,
4054 "sws-save-active-takes-on-selected-track-s": 53132,
4055 "sws-save-as-snapshot-1": 55743,
4056 "sws-save-as-snapshot-10": 55752,
4057 "sws-save-as-snapshot-11": 55753,
4058 "sws-save-as-snapshot-12": 55754,
4059 "sws-save-as-snapshot-2": 55744,
4060 "sws-save-as-snapshot-3": 55745,
4061 "sws-save-as-snapshot-4": 55746,
4062 "sws-save-as-snapshot-5": 55747,
4063 "sws-save-as-snapshot-6": 55748,
4064 "sws-save-as-snapshot-7": 55749,
4065 "sws-save-as-snapshot-8": 55750,
4066 "sws-save-as-snapshot-9": 55751,
4067 "sws-save-auto-crossfade-state": 53666,
4068 "sws-save-current-arrange-view-slot-1": 53826,
4069 "sws-save-current-arrange-view-slot-2": 53827,
4070 "sws-save-current-arrange-view-slot-3": 53828,
4071 "sws-save-current-arrange-view-slot-4": 53829,
4072 "sws-save-current-arrange-view-slot-5": 53830,
4073 "sws-save-current-snapshot-filter-options": 53191,
4074 "sws-save-current-track-selection": 53749,
4075 "sws-save-list-of-open-projects": 53216,
4076 "sws-save-loop-selection-slot-1": 55797,
4077 "sws-save-loop-selection-slot-2": 55798,
4078 "sws-save-loop-selection-slot-3": 55799,
4079 "sws-save-loop-selection-slot-4": 55800,
4080 "sws-save-loop-selection-slot-5": 55801,
4081 "sws-save-marker-set": 53084,
4082 "sws-save-master-fx-enabled-state": 53732,
4083 "sws-save-over-current-snapshot": 53164,
4084 "sws-save-selected-item-s": 53145,
4085 "sws-save-selected-track-s-items-states": 53151,
4086 "sws-save-selected-track-s-mutes-plus-receives-children": 53147,
4087 "sws-save-selected-track-s-selected-item-s-slot-1": 53134,
4088 "sws-save-selected-track-s-selected-item-s-slot-2": 53135,
4089 "sws-save-selected-track-s-selected-item-s-slot-3": 53136,
4090 "sws-save-selected-track-s-selected-item-s-slot-4": 53137,
4091 "sws-save-selected-track-s-selected-item-s-slot-5": 53138,
4092 "sws-save-selected-track-s-selected-items-states": 53153,
4093 "sws-save-time-selection-slot-1": 55787,
4094 "sws-save-time-selection-slot-2": 55788,
4095 "sws-save-time-selection-slot-3": 55789,
4096 "sws-save-time-selection-slot-4": 55790,
4097 "sws-save-time-selection-slot-5": 55791,
4098 "sws-save-transport-repeat-state": 53679,
4099 "sws-scroll-left-1-percent": 53824,
4100 "sws-scroll-left-10-percent": 53822,
4101 "sws-scroll-right-1-percent": 53825,
4102 "sws-scroll-right-10-percent": 53823,
4103 "sws-select-all-folder-start-tracks": 53762,
4104 "sws-select-all-folders-parents-only": 53761,
4105 "sws-select-all-non-folders": 53763,
4106 "sws-select-armed-tracks": 53772,
4107 "sws-select-children-of-selected-folder-track-s": 53756,
4108 "sws-select-current-snapshot-track-s": 53161,
4109 "sws-select-item-s-with-saved-state-on-selected-track-s": 53155,
4110 "sws-select-locked-items": 53657,
4111 "sws-select-locked-items-on-selected-track-s": 53658,
4112 "sws-select-lower-leftmost-item-on-selected-track-s": 53645,
4113 "sws-select-master-track": 53776,
4114 "sws-select-muted-items": 53649,
4115 "sws-select-muted-items-on-selected-track-s": 53651,
4116 "sws-select-muted-tracks": 53768,
4117 "sws-select-nearest-next-folder": 53766,
4118 "sws-select-nearest-previous-folder": 53767,
4119 "sws-select-next-folder": 53764,
4120 "sws-select-next-item-across-tracks": 53653,
4121 "sws-select-next-item-keeping-current-selection-across-tracks": 53655,
4122 "sws-select-only-children-of-selected-folders": 53755,
4123 "sws-select-only-parent-s-of-selected-folder-track-s": 53757,
4124 "sws-select-only-rec-armed-track-s": 53774,
4125 "sws-select-only-track-1": 55755,
4126 "sws-select-only-track-10": 55764,
4127 "sws-select-only-track-11": 55765,
4128 "sws-select-only-track-12": 55766,
4129 "sws-select-only-track-13": 55767,
4130 "sws-select-only-track-14": 55768,
4131 "sws-select-only-track-15": 55769,
4132 "sws-select-only-track-16": 55770,
4133 "sws-select-only-track-17": 55771,
4134 "sws-select-only-track-18": 55772,
4135 "sws-select-only-track-19": 55773,
4136 "sws-select-only-track-2": 55756,
4137 "sws-select-only-track-20": 55774,
4138 "sws-select-only-track-21": 55775,
4139 "sws-select-only-track-22": 55776,
4140 "sws-select-only-track-23": 55777,
4141 "sws-select-only-track-24": 55778,
4142 "sws-select-only-track-25": 55779,
4143 "sws-select-only-track-26": 55780,
4144 "sws-select-only-track-27": 55781,
4145 "sws-select-only-track-28": 55782,
4146 "sws-select-only-track-29": 55783,
4147 "sws-select-only-track-3": 55757,
4148 "sws-select-only-track-30": 55784,
4149 "sws-select-only-track-31": 55785,
4150 "sws-select-only-track-32": 55786,
4151 "sws-select-only-track-4": 55758,
4152 "sws-select-only-track-5": 55759,
4153 "sws-select-only-track-6": 55760,
4154 "sws-select-only-track-7": 55761,
4155 "sws-select-only-track-8": 55762,
4156 "sws-select-only-track-9": 55763,
4157 "sws-select-only-track-s-with-selected-item-s": 53753,
4158 "sws-select-parent-s-of-selected-folder-track-s": 53758,
4159 "sws-select-previous-folder": 53765,
4160 "sws-select-previous-item-across-tracks": 53654,
4161 "sws-select-previous-item-keeping-current-selection-across-tracks": 53656,
4162 "sws-select-soloed-tracks": 53770,
4163 "sws-select-tracks-with-active-routing-to-selected-track-s": 53773,
4164 "sws-select-tracks-with-flipped-phase": 53771,
4165 "sws-select-unmuted-items": 53650,
4166 "sws-select-unmuted-items-on-selected-track-s": 53652,
4167 "sws-select-unmuted-tracks": 53769,
4168 "sws-select-upper-leftmost-item-on-selected-track-s": 53646,
4169 "sws-set-all-master-track-outputs-muted": 53721,
4170 "sws-set-all-master-track-outputs-unmuted": 53720,
4171 "sws-set-all-selected-tracks-inputs-to-match-first-selected-track": 53747,
4172 "sws-set-all-takes-channel-mode-to-mono-downmix": 53621,
4173 "sws-set-all-takes-channel-mode-to-mono-left": 53622,
4174 "sws-set-all-takes-channel-mode-to-mono-right": 53623,
4175 "sws-set-all-takes-channel-mode-to-normal": 53619,
4176 "sws-set-all-takes-channel-mode-to-reverse-stereo": 53620,
4177 "sws-set-all-takes-preserve-pitch": 53625,
4178 "sws-set-all-takes-to-next-mono-channel-mode": 53639,
4179 "sws-set-all-takes-to-next-stereo-channel-mode": 53641,
4180 "sws-set-all-takes-to-prev-mono-channel-mode": 53640,
4181 "sws-set-all-takes-to-prev-stereo-channel-mode": 53642,
4182 "sws-set-auto-crossfade-off": 53669,
4183 "sws-set-auto-crossfade-on": 53668,
4184 "sws-set-item-fades-to-crossfade-lengths": 53385,
4185 "sws-set-item-fades-to-default-length": 53386,
4186 "sws-set-last-touched-track-to-match-track-selection-deprecated": 53754,
4187 "sws-set-master-mono": 53696,
4188 "sws-set-master-output-1-volume-to-0db": 53724,
4189 "sws-set-master-stereo": 53697,
4190 "sws-set-master-track-output-1-muted": 53710,
4191 "sws-set-master-track-output-1-unmuted": 53715,
4192 "sws-set-master-track-output-2-muted": 53711,
4193 "sws-set-master-track-output-2-unmuted": 53716,
4194 "sws-set-master-track-output-3-muted": 53712,
4195 "sws-set-master-track-output-3-unmuted": 53717,
4196 "sws-set-master-track-output-4-muted": 53713,
4197 "sws-set-master-track-output-4-unmuted": 53718,
4198 "sws-set-master-track-output-5-muted": 53714,
4199 "sws-set-master-track-output-5-unmuted": 53719,
4200 "sws-set-move-envelope-points-with-items-off": 53671,
4201 "sws-set-move-envelope-points-with-items-on": 53670,
4202 "sws-set-reaper-window-size-to-reaper-ini-setwndsize": 53779,
4203 "sws-set-rms-analysis-normalize-options": 53575,
4204 "sws-set-selected-folder-s-collapsed": 53605,
4205 "sws-set-selected-folder-s-small": 53607,
4206 "sws-set-selected-folder-s-uncollapsed": 53606,
4207 "sws-set-selected-item-s-to-color-black": 53036,
4208 "sws-set-selected-item-s-to-color-gradient": 53041,
4209 "sws-set-selected-item-s-to-color-gradient-per-track": 53040,
4210 "sws-set-selected-item-s-to-color-white": 53035,
4211 "sws-set-selected-item-s-to-custom-color-1": 53045,
4212 "sws-set-selected-item-s-to-custom-color-10": 53054,
4213 "sws-set-selected-item-s-to-custom-color-11": 53055,
4214 "sws-set-selected-item-s-to-custom-color-12": 53056,
4215 "sws-set-selected-item-s-to-custom-color-13": 53057,
4216 "sws-set-selected-item-s-to-custom-color-14": 53058,
4217 "sws-set-selected-item-s-to-custom-color-15": 53059,
4218 "sws-set-selected-item-s-to-custom-color-16": 53060,
4219 "sws-set-selected-item-s-to-custom-color-2": 53046,
4220 "sws-set-selected-item-s-to-custom-color-3": 53047,
4221 "sws-set-selected-item-s-to-custom-color-4": 53048,
4222 "sws-set-selected-item-s-to-custom-color-5": 53049,
4223 "sws-set-selected-item-s-to-custom-color-6": 53050,
4224 "sws-set-selected-item-s-to-custom-color-7": 53051,
4225 "sws-set-selected-item-s-to-custom-color-8": 53052,
4226 "sws-set-selected-item-s-to-custom-color-9": 53053,
4227 "sws-set-selected-item-s-to-next-custom-color": 53037,
4228 "sws-set-selected-item-s-to-one-random-custom-color": 53038,
4229 "sws-set-selected-item-s-to-ordered-custom-colors": 53043,
4230 "sws-set-selected-item-s-to-ordered-custom-colors-per-track": 53042,
4231 "sws-set-selected-item-s-to-random-custom-color-s": 53039,
4232 "sws-set-selected-item-s-to-respective-track-color": 53044,
4233 "sws-set-selected-items-length": 53643,
4234 "sws-set-selected-take-s-to-custom-color-1": 53066,
4235 "sws-set-selected-take-s-to-custom-color-10": 53075,
4236 "sws-set-selected-take-s-to-custom-color-11": 53076,
4237 "sws-set-selected-take-s-to-custom-color-12": 53077,
4238 "sws-set-selected-take-s-to-custom-color-13": 53078,
4239 "sws-set-selected-take-s-to-custom-color-14": 53079,
4240 "sws-set-selected-take-s-to-custom-color-15": 53080,
4241 "sws-set-selected-take-s-to-custom-color-16": 53081,
4242 "sws-set-selected-take-s-to-custom-color-2": 53067,
4243 "sws-set-selected-take-s-to-custom-color-3": 53068,
4244 "sws-set-selected-take-s-to-custom-color-4": 53069,
4245 "sws-set-selected-take-s-to-custom-color-5": 53070,
4246 "sws-set-selected-take-s-to-custom-color-6": 53071,
4247 "sws-set-selected-take-s-to-custom-color-7": 53072,
4248 "sws-set-selected-take-s-to-custom-color-8": 53073,
4249 "sws-set-selected-take-s-to-custom-color-9": 53074,
4250 "sws-set-selected-track-s-children-to-same-color": 53018,
4251 "sws-set-selected-track-s-item-s-to-custom-color": 53062,
4252 "sws-set-selected-track-s-item-s-to-one-random-color": 53061,
4253 "sws-set-selected-track-s-monitor-track-media-while-recording": 53738,
4254 "sws-set-selected-track-s-record-output-mode-based-on-items": 53737,
4255 "sws-set-selected-track-s-to-color-black": 53010,
4256 "sws-set-selected-track-s-to-color-white": 53009,
4257 "sws-set-selected-track-s-to-custom-color-1": 53019,
4258 "sws-set-selected-track-s-to-custom-color-10": 53028,
4259 "sws-set-selected-track-s-to-custom-color-11": 53029,
4260 "sws-set-selected-track-s-to-custom-color-12": 53030,
4261 "sws-set-selected-track-s-to-custom-color-13": 53031,
4262 "sws-set-selected-track-s-to-custom-color-14": 53032,
4263 "sws-set-selected-track-s-to-custom-color-15": 53033,
4264 "sws-set-selected-track-s-to-custom-color-16": 53034,
4265 "sws-set-selected-track-s-to-custom-color-2": 53020,
4266 "sws-set-selected-track-s-to-custom-color-3": 53021,
4267 "sws-set-selected-track-s-to-custom-color-4": 53022,
4268 "sws-set-selected-track-s-to-custom-color-5": 53023,
4269 "sws-set-selected-track-s-to-custom-color-6": 53024,
4270 "sws-set-selected-track-s-to-custom-color-7": 53025,
4271 "sws-set-selected-track-s-to-custom-color-8": 53026,
4272 "sws-set-selected-track-s-to-custom-color-9": 53027,
4273 "sws-set-selected-track-s-to-next-custom-color": 53013,
4274 "sws-set-selected-track-s-to-next-track-s-color": 53012,
4275 "sws-set-selected-track-s-to-one-random-custom-color": 53014,
4276 "sws-set-selected-track-s-to-ordered-custom-colors": 53017,
4277 "sws-set-selected-track-s-to-previous-track-s-color": 53011,
4278 "sws-set-selected-track-s-to-random-custom-color-s": 53015,
4279 "sws-set-selected-track-s-to-same-folder-as-previous-track": 53601,
4280 "sws-set-selected-tracks-pan-law-to-0-0-db": 53313,
4281 "sws-set-selected-tracks-pan-law-to-2-5-db": 53317,
4282 "sws-set-selected-tracks-pan-law-to-2-5-db-53321": 53321,
4283 "sws-set-selected-tracks-pan-law-to-3-0-db": 53316,
4284 "sws-set-selected-tracks-pan-law-to-3-0-db-53320": 53320,
4285 "sws-set-selected-tracks-pan-law-to-4-5-db": 53315,
4286 "sws-set-selected-tracks-pan-law-to-4-5-db-53319": 53319,
4287 "sws-set-selected-tracks-pan-law-to-6-0-db": 53314,
4288 "sws-set-selected-tracks-pan-law-to-6-0-db-53318": 53318,
4289 "sws-set-selected-tracks-pan-law-to-default": 53312,
4290 "sws-set-selected-tracks-to-color-gradient": 53016,
4291 "sws-set-snapshots-to-mix-mode": 53176,
4292 "sws-set-snapshots-to-visibility-mode": 53177,
4293 "sws-set-takes-in-selected-item-s-to-color-gradient": 53064,
4294 "sws-set-takes-in-selected-item-s-to-ordered-custom-colors": 53065,
4295 "sws-set-takes-in-selected-item-s-to-random-custom-color-s": 53063,
4296 "sws-set-time-selection-to-selected-items-skip-if-time-selection-exists": 53585,
4297 "sws-set-track-name-from-first-selected-item-in-project": 53745,
4298 "sws-set-track-name-from-first-selected-item-on-track": 53744,
4299 "sws-set-transport-repeat-state": 53681,
4300 "sws-shane-autorender-edit-project-metadata": 53932,
4301 "sws-shane-autorender-global-preferences": 53935,
4302 "sws-shane-autorender-open-render-path": 53933,
4303 "sws-shane-autorender-show-instructions": 53934,
4304 "sws-shane-batch-render-regions": 53931,
4305 "sws-show-all-tracks": 53206,
4306 "sws-show-all-tracks-in-mcp": 53207,
4307 "sws-show-all-tracks-in-tcp": 53208,
4308 "sws-show-dockers": 53683,
4309 "sws-show-master-track-in-track-control-panel": 53685,
4310 "sws-show-selected-track-s-hide-others": 53212,
4311 "sws-show-selected-track-s-in-mcp": 53199,
4312 "sws-show-selected-track-s-in-mcp-hide-others": 53210,
4313 "sws-show-selected-track-s-in-mcp-only": 53195,
4314 "sws-show-selected-track-s-in-tcp": 53200,
4315 "sws-show-selected-track-s-in-tcp-and-mcp": 53197,
4316 "sws-show-selected-track-s-in-tcp-hide-others": 53211,
4317 "sws-show-selected-track-s-in-tcp-only": 53196,
4318 "sws-show-tracklist": 53193,
4319 "sws-show-tracklist-with-filter-focused": 53194,
4320 "sws-sn-focus-midi-editor": 54938,
4321 "sws-snapshot-current-track-visibility": 53215,
4322 "sws-split-items-at-time-selection-razor-edit-areas-edit-cursor-also-during-playback-or-mouse-cursor": 53582,
4323 "sws-split-items-at-time-selection-razor-edit-areas-edit-cursor-play-cursor-during-playback-or-mouse-cursor": 53581,
4324 "sws-split-items-at-time-selection-razor-edit-areas-if-exists-else-at-edit-cursor-also-during-playback": 53580,
4325 "sws-split-items-at-time-selection-razor-edit-areas-if-exists-play-cursor-during-playback-else-at-edit-cursor": 53579,
4326 "sws-switch-to-last-project-tab": 53223,
4327 "sws-switch-to-project-tab-1": 53224,
4328 "sws-switch-to-project-tab-10": 53233,
4329 "sws-switch-to-project-tab-2": 53225,
4330 "sws-switch-to-project-tab-3": 53226,
4331 "sws-switch-to-project-tab-4": 53227,
4332 "sws-switch-to-project-tab-5": 53228,
4333 "sws-switch-to-project-tab-6": 53229,
4334 "sws-switch-to-project-tab-7": 53230,
4335 "sws-switch-to-project-tab-8": 53231,
4336 "sws-switch-to-project-tab-9": 53232,
4337 "sws-time-select-next-region": 53091,
4338 "sws-time-select-previous-region": 53092,
4339 "sws-toggle-auto-add-envelopes-when-tweaking-in-write-mode": 53675,
4340 "sws-toggle-auto-track-coloring-enable": 53001,
4341 "sws-toggle-between-current-and-saved-track-selection": 53751,
4342 "sws-toggle-checking-for-duplicate-inputs-when-recording": 53692,
4343 "sws-toggle-default-fade-time-to-zero": 53687,
4344 "sws-toggle-drag-zoom-enable-ruler-bottom-half": 53840,
4345 "sws-toggle-drag-zoom-enable-ruler-top-half": 53841,
4346 "sws-toggle-grid-lines-over-under-items": 53676,
4347 "sws-toggle-horizontal-zoom-to-selected-items": 53820,
4348 "sws-toggle-horizontal-zoom-to-selected-items-or-time-selection": 53819,
4349 "sws-toggle-horizontal-zoom-to-time-selection": 53821,
4350 "sws-toggle-invert-track-selection": 53752,
4351 "sws-toggle-marker-actions-enable": 53103,
4352 "sws-toggle-master-parent-send-on-selected-track-s": 53695,
4353 "sws-toggle-master-track-output-1-mute": 53698,
4354 "sws-toggle-master-track-output-10-mute": 53707,
4355 "sws-toggle-master-track-output-11-mute": 53708,
4356 "sws-toggle-master-track-output-12-mute": 53709,
4357 "sws-toggle-master-track-output-2-mute": 53699,
4358 "sws-toggle-master-track-output-3-mute": 53700,
4359 "sws-toggle-master-track-output-4-mute": 53701,
4360 "sws-toggle-master-track-output-5-mute": 53702,
4361 "sws-toggle-master-track-output-6-mute": 53703,
4362 "sws-toggle-master-track-output-7-mute": 53704,
4363 "sws-toggle-master-track-output-8-mute": 53705,
4364 "sws-toggle-master-track-output-9-mute": 53706,
4365 "sws-toggle-master-track-select": 53778,
4366 "sws-toggle-move-cursor-to-end-of-recorded-media-on-stop": 53672,
4367 "sws-toggle-mute-of-children-of-selected-folder-s": 53600,
4368 "sws-toggle-mute-of-items-on-selected-track-s": 53608,
4369 "sws-toggle-mute-on-receives-for-selected-track-s": 53727,
4370 "sws-toggle-ruler-red-while-recording": 53008,
4371 "sws-toggle-seek-playback-on-item-move-size": 53673,
4372 "sws-toggle-seek-playback-on-loop-point-change": 53674,
4373 "sws-toggle-selected-track-s-fully-visible-hidden": 53205,
4374 "sws-toggle-selected-track-s-visible-in-mcp": 53203,
4375 "sws-toggle-selected-track-s-visible-in-tcp": 53204,
4376 "sws-toggle-selecting-one-grouped-item-selects-group": 53677,
4377 "sws-toggle-selection-of-items-on-selected-track-s": 53648,
4378 "sws-toggle-snapshot-apply-filter-to-recall": 53188,
4379 "sws-toggle-snapshot-fx": 53183,
4380 "sws-toggle-snapshot-mute": 53178,
4381 "sws-toggle-snapshot-pan": 53180,
4382 "sws-toggle-snapshot-selected-only-on-recall": 53187,
4383 "sws-toggle-snapshot-selected-only-on-save": 53186,
4384 "sws-toggle-snapshot-selection": 53185,
4385 "sws-toggle-snapshot-sends": 53182,
4386 "sws-toggle-snapshot-show-only-for-selected-tracks": 53189,
4387 "sws-toggle-snapshot-solo": 53179,
4388 "sws-toggle-snapshot-visibility": 53184,
4389 "sws-toggle-snapshot-vol": 53181,
4390 "sws-toggle-zoom-to-selected-items": 53807,
4391 "sws-toggle-zoom-to-selected-items-hide-other-tracks": 53809,
4392 "sws-toggle-zoom-to-selected-items-hide-other-tracks-ignore-last-track-s-envelope-lanes": 53818,
4393 "sws-toggle-zoom-to-selected-items-ignore-last-track-s-envelope-lanes": 53816,
4394 "sws-toggle-zoom-to-selected-items-minimize-other-tracks": 53808,
4395 "sws-toggle-zoom-to-selected-items-minimize-other-tracks-ignore-last-track-s-envelope-lanes": 53817,
4396 "sws-toggle-zoom-to-selected-items-or-time-selection": 53804,
4397 "sws-toggle-zoom-to-selected-items-or-time-selection-hide-other-tracks": 53806,
4398 "sws-toggle-zoom-to-selected-items-or-time-selection-hide-other-tracks-ignore-last-track-s-envelope-lanes": 53815,
4399 "sws-toggle-zoom-to-selected-items-or-time-selection-ignore-last-track-s-envelope-lanes": 53813,
4400 "sws-toggle-zoom-to-selected-items-or-time-selection-minimize-other-tracks": 53805,
4401 "sws-toggle-zoom-to-selected-items-or-time-selection-minimize-other-tracks-ignore-last-track-s-envelope-lanes": 53814,
4402 "sws-toggle-zoom-to-selected-tracks-and-time-selection": 53801,
4403 "sws-toggle-zoom-to-selected-tracks-and-time-selection-hide-others": 53803,
4404 "sws-toggle-zoom-to-selected-tracks-and-time-selection-hide-others-ignore-last-track-s-envelope-lanes": 53812,
4405 "sws-toggle-zoom-to-selected-tracks-and-time-selection-ignore-last-track-s-envelope-lanes": 53810,
4406 "sws-toggle-zoom-to-selected-tracks-and-time-selection-minimize-others": 53802,
4407 "sws-toggle-zoom-to-selected-tracks-and-time-selection-minimize-others-ignore-last-track-s-envelope-lanes": 53811,
4408 "sws-toolbar-arm-toggle": 53740,
4409 "sws-toolbar-mute-toggle": 53748,
4410 "sws-toolbar-solo-toggle": 53746,
4411 "sws-transport-record-stop": 53678,
4412 "sws-unbypass-fx-on-selected-track-s": 53731,
4413 "sws-undo-edit-cursor-move": 53586,
4414 "sws-undo-zoom": 53836,
4415 "sws-unindent-selected-track-s": 53604,
4416 "sws-unmute-all-receives-for-selected-track-s": 53726,
4417 "sws-unmute-all-sends-from-selected-track-s": 53729,
4418 "sws-unmute-children-of-selected-folder-s": 53599,
4419 "sws-unselect-all-items-on-selected-track-s": 53644,
4420 "sws-unselect-all-items-tracks-env-points": 53584,
4421 "sws-unselect-all-items-tracks-env-points-depending-on-focus": 53583,
4422 "sws-unselect-children-of-selected-folder-track-s": 53760,
4423 "sws-unselect-items-without-render-in-source-filename": 53662,
4424 "sws-unselect-items-without-stems-in-source-filename": 53661,
4425 "sws-unselect-master-track": 53777,
4426 "sws-unselect-parent-s-of-selected-folder-track-s": 53759,
4427 "sws-unselect-rec-armed-track-s": 53775,
4428 "sws-unselect-upper-leftmost-item-on-selected-track-s": 53647,
4429 "sws-unset-selected-track-s-monitor-track-media-while-recording": 53739,
4430 "sws-unset-transport-repeat-state": 53682,
4431 "sws-vertical-zoom-to-selected-items": 53786,
4432 "sws-vertical-zoom-to-selected-items-ignore-last-track-s-envelope-lanes": 53790,
4433 "sws-vertical-zoom-to-selected-items-minimize-others": 53787,
4434 "sws-vertical-zoom-to-selected-items-minimize-others-ignore-last-track-s-envelope-lanes": 53791,
4435 "sws-vertical-zoom-to-selected-tracks": 53784,
4436 "sws-vertical-zoom-to-selected-tracks-ignore-last-track-s-envelope-lanes": 53788,
4437 "sws-vertical-zoom-to-selected-tracks-minimize-others": 53785,
4438 "sws-vertical-zoom-to-selected-tracks-minimize-others-ignore-last-track-s-envelope-lanes": 53789,
4439 "sws-wait-for-next-bar-if-playing": 53663,
4440 "sws-wait-for-next-beat-if-playing": 53664,
4441 "sws-wait-until-end-of-loop-if-playing": 53665,
4442 "sws-wol-adjust-envelope-or-track-height-under-mouse-cursor-midi-cc-relative-mousewheel": 54892,
4443 "sws-wol-adjust-envelope-or-track-height-under-mouse-cursor-zoom-center-to-mouse-cursor-midi-cc-relative-mousewheel":
4444 54893,
4445 "sws-wol-adjust-selected-envelope-height-midi-cc-relative-mousewheel": 54886,
4446 "sws-wol-adjust-selected-envelope-height-zoom-center-to-middle-arrange-midi-cc-relative-mousewheel": 54887,
4447 "sws-wol-adjust-selected-envelope-height-zoom-center-to-mouse-cursor-midi-cc-relative-mousewheel": 54888,
4448 "sws-wol-adjust-selected-envelope-or-last-touched-track-height-midi-cc-relative-mousewheel": 54889,
4449 "sws-wol-adjust-selected-envelope-or-last-touched-track-height-zoom-center-to-middle-arrange-midi-cc-relative-mousewheel":
4450 54890,
4451 "sws-wol-adjust-selected-envelope-or-last-touched-track-height-zoom-center-to-mouse-cursor-midi-cc-relative-mousewheel":
4452 54891,
4453 "sws-wol-apply-height-to-selected-envelope-slot-1": 54912,
4454 "sws-wol-apply-height-to-selected-envelope-slot-2": 54913,
4455 "sws-wol-apply-height-to-selected-envelope-slot-3": 54914,
4456 "sws-wol-apply-height-to-selected-envelope-slot-4": 54915,
4457 "sws-wol-apply-height-to-selected-envelope-slot-5": 54916,
4458 "sws-wol-apply-height-to-selected-envelope-slot-6": 54917,
4459 "sws-wol-apply-height-to-selected-envelope-slot-7": 54918,
4460 "sws-wol-apply-height-to-selected-envelope-slot-8": 54919,
4461 "sws-wol-force-overlap-for-selected-envelope-in-track-lane-in-its-track-height": 54896,
4462 "sws-wol-full-zoom-selected-envelope-in-media-lane-only-to-lower-half-in-time-selection": 54901,
4463 "sws-wol-full-zoom-selected-envelope-in-media-lane-only-to-upper-half-in-time-selection": 54900,
4464 "sws-wol-full-zoom-selected-envelope-in-time-selection": 54899,
4465 "sws-wol-horizontal-zoom-selected-envelope-in-time-selection": 54898,
4466 "sws-wol-options-set-horizontal-zoom-center-to-center-of-view": 54881,
4467 "sws-wol-options-set-horizontal-zoom-center-to-edit-cursor": 54880,
4468 "sws-wol-options-set-horizontal-zoom-center-to-edit-cursor-or-play-cursor-default": 54879,
4469 "sws-wol-options-set-horizontal-zoom-center-to-mouse-cursor": 54882,
4470 "sws-wol-options-set-vertical-zoom-center-to-last-selected-track": 54877,
4471 "sws-wol-options-set-vertical-zoom-center-to-top-visible-track": 54876,
4472 "sws-wol-options-set-vertical-zoom-center-to-track-at-center-of-view": 54875,
4473 "sws-wol-options-set-vertical-zoom-center-to-track-under-mouse-cursor": 54878,
4474 "sws-wol-put-selected-envelope-in-envelope-lane": 54922,
4475 "sws-wol-put-selected-envelope-in-media-lane": 54921,
4476 "sws-wol-restore-previous-envelope-overlap-settings": 54897,
4477 "sws-wol-save-height-of-selected-envelope-slot-1": 54904,
4478 "sws-wol-save-height-of-selected-envelope-slot-2": 54905,
4479 "sws-wol-save-height-of-selected-envelope-slot-3": 54906,
4480 "sws-wol-save-height-of-selected-envelope-slot-4": 54907,
4481 "sws-wol-save-height-of-selected-envelope-slot-5": 54908,
4482 "sws-wol-save-height-of-selected-envelope-slot-6": 54909,
4483 "sws-wol-save-height-of-selected-envelope-slot-7": 54910,
4484 "sws-wol-save-height-of-selected-envelope-slot-8": 54911,
4485 "sws-wol-select-all-tracks-except-folder-parents": 54920,
4486 "sws-wol-set-selected-envelope-height-to-default": 54883,
4487 "sws-wol-set-selected-envelope-height-to-maximum": 54885,
4488 "sws-wol-set-selected-envelope-height-to-minimum": 54884,
4489 "sws-wol-toggle-enable-envelope-overlap-for-envelopes-in-track-lane": 54895,
4490 "sws-wol-toggle-enable-extended-zoom-for-envelopes-in-track-lane": 54894,
4491 "sws-wol-vertical-zoom-selected-envelope-in-media-lane-only-to-lower-half": 54903,
4492 "sws-wol-vertical-zoom-selected-envelope-in-media-lane-only-to-upper-half": 54902,
4493 "sws-zoom-preferences": 53839,
4494 "sws-zoom-to-selected-items": 53793,
4495 "sws-zoom-to-selected-items-ignore-last-track-s-envelope-lanes": 53797,
4496 "sws-zoom-to-selected-items-minimize-others": 53794,
4497 "sws-zoom-to-selected-items-minimize-others-ignore-last-track-s-envelope-lanes": 53798,
4498 "sws-zoom-to-selected-items-or-time-selection": 53795,
4499 "sws-zoom-to-selected-items-or-time-selection-ignore-last-track-s-envelope-lanes": 53799,
4500 "sws-zoom-to-selected-items-or-time-selection-minimize-others": 53796,
4501 "sws-zoom-to-selected-items-or-time-selection-minimize-others-ignore-last-track-s-envelope-lanes": 53800,
4502 "sws-zoom-tool-marquee": 53838,
4503 "take-activate-take-under-mouse": 41342,
4504 "take-crop-to-active-take-in-items": 40131,
4505 "take-cycle-items-to-next-take": 42349,
4506 "take-cycle-items-to-previous-take": 42350,
4507 "take-delete-active-take-from-items": 40129,
4508 "take-delete-active-take-from-items-prompt-to-confirm": 40130,
4509 "take-duplicate-active-take": 40639,
4510 "take-explode-takes-of-items-across-tracks": 40224,
4511 "take-explode-takes-of-items-in-order": 40643,
4512 "take-explode-takes-of-items-in-place": 40642,
4513 "take-explode-takes-on-selected-tracks-to-fixed-lanes": 42635,
4514 "take-explode-takes-on-selected-tracks-to-fixed-lanes-and-add-comp-areas-from-active-takes": 42636,
4515 "take-implode-items-across-tracks-into-takes": 40438,
4516 "take-implode-items-on-same-track-into-takes": 40543,
4517 "take-implode-selected-fixed-lane-tracks-to-takes-using-best-efforts-overlapping-items-may-be-shortened": 42637,
4518 "take-nudge-active-takes-volume-1db": 41926,
4519 "take-nudge-active-takes-volume-plus-1db": 41927,
4520 "take-paste-as-takes-in-items": 40603,
4521 "take-propagate-to-all-similarly-named-active-takes": 41978,
4522 "take-propagate-to-similarly-named-active-takes-on-track": 41976,
4523 "take-set-1st-take-active": 45000,
4524 "take-set-2nd-take-active": 45001,
4525 "take-set-3rd-take-active": 45002,
4526 "take-set-4th-take-active": 45003,
4527 "take-set-5th-take-active": 45004,
4528 "take-set-6th-take-active": 45005,
4529 "take-set-7th-take-active": 45006,
4530 "take-set-8th-take-active": 45007,
4531 "take-set-9th-take-active": 45008,
4532 "take-set-active-take-to-custom-color": 41331,
4533 "take-set-active-take-to-default-color": 41333,
4534 "take-set-active-take-to-one-random-color": 41332,
4535 "take-set-all-takes-created-in-the-same-recording-pass-to-custom-color": 41334,
4536 "take-set-all-takes-created-in-the-same-recording-pass-to-default-color": 41336,
4537 "take-set-all-takes-created-in-the-same-recording-pass-to-one-random-color": 41335,
4538 "take-set-all-takes-of-selected-items-to-default-color": 41337,
4539 "take-switch-items-to-next-take": 40125,
4540 "take-switch-items-to-previous-take": 40126,
4541 "take-toggle-take-mute-envelope": 40695,
4542 "take-toggle-take-pan-envelope": 40694,
4543 "take-toggle-take-pitch-envelope": 41612,
4544 "take-toggle-take-volume-envelope": 40693,
4545 "take-view-take-envelopes-for-last-touched-first-selected-item": 41974,
4546 "tempo-decrease-current-project-tempo-0-1-bpm": 41138,
4547 "tempo-decrease-current-project-tempo-01-bpm": 41130,
4548 "tempo-decrease-current-project-tempo-10-bpm": 41136,
4549 "tempo-decrease-current-project-tempo-10-percent": 41132,
4550 "tempo-decrease-current-project-tempo-50-percent-half": 41134,
4551 "tempo-envelope-decrease-all-tempo-markers-0-001-bpm": 41807,
4552 "tempo-envelope-decrease-all-tempo-markers-0-001-percent": 41815,
4553 "tempo-envelope-decrease-all-tempo-markers-0-01-bpm": 41220,
4554 "tempo-envelope-decrease-all-tempo-markers-0-01-percent": 41813,
4555 "tempo-envelope-decrease-all-tempo-markers-0-1-bpm": 41218,
4556 "tempo-envelope-decrease-all-tempo-markers-0-1-percent": 41811,
4557 "tempo-envelope-decrease-all-tempo-markers-01-bpm": 41216,
4558 "tempo-envelope-decrease-all-tempo-markers-01-percent": 41809,
4559 "tempo-envelope-increase-all-tempo-markers-0-001-bpm": 41806,
4560 "tempo-envelope-increase-all-tempo-markers-0-001-percent": 41814,
4561 "tempo-envelope-increase-all-tempo-markers-0-01-bpm": 41219,
4562 "tempo-envelope-increase-all-tempo-markers-0-01-percent": 41812,
4563 "tempo-envelope-increase-all-tempo-markers-0-1-bpm": 41217,
4564 "tempo-envelope-increase-all-tempo-markers-0-1-percent": 41810,
4565 "tempo-envelope-increase-all-tempo-markers-01-bpm": 41215,
4566 "tempo-envelope-increase-all-tempo-markers-01-percent": 41808,
4567 "tempo-envelope-insert-tempo-marker-at-edit-cursor-without-opening-tempo-edit-dialog": 42330,
4568 "tempo-envelope-insert-tempo-time-signature-change-marker-at-edit-cursor": 40256,
4569 "tempo-envelope-set-display-range": 40933,
4570 "tempo-envelope-set-display-range-to-current-project-min-max-bpm": 41804,
4571 "tempo-increase-current-project-tempo-0-1-bpm": 41137,
4572 "tempo-increase-current-project-tempo-01-bpm": 41129,
4573 "tempo-increase-current-project-tempo-10-bpm": 41135,
4574 "tempo-increase-current-project-tempo-10-percent": 41131,
4575 "tempo-increase-current-project-tempo-100-percent-double": 41133,
4576 "theme-development-show-theme-element-finder": 40690,
4577 "theme-development-show-theme-tweak-configuration-window": 41930,
4578 "time-selection-copy-contents-of-time-selection-to-edit-cursor-moving-later-items": 40397,
4579 "time-selection-crop-project-to-time-selection": 40049,
4580 "time-selection-extend-time-selection-to-next-transient-in-items": 40802,
4581 "time-selection-insert-empty-space-at-time-selection-moving-later-items": 40200,
4582 "time-selection-move-contents-of-time-selection-to-edit-cursor-moving-later-items": 40396,
4583 "time-selection-move-cursor-left-creating-time-selection": 40102,
4584 "time-selection-move-cursor-right-creating-time-selection": 40103,
4585 "time-selection-move-end-point-to-cursor-preserve-length": 43213,
4586 "time-selection-move-loop-points-to-time-selection": 40622,
4587 "time-selection-move-start-point-to-cursor-preserve-length": 43212,
4588 "time-selection-move-time-selection-to-loop-points": 40623,
4589 "time-selection-nudge-left": 40039,
4590 "time-selection-nudge-left-edge-left": 40320,
4591 "time-selection-nudge-left-edge-right": 40321,
4592 "time-selection-nudge-right": 40040,
4593 "time-selection-nudge-right-edge-left": 40322,
4594 "time-selection-nudge-right-edge-right": 40323,
4595 "time-selection-remove-contents-of-time-selection-moving-later-items": 40201,
4596 "time-selection-remove-unselect-time-selection": 40635,
4597 "time-selection-remove-unselect-time-selection-and-loop-points": 40020,
4598 "time-selection-set-end-point": 40626,
4599 "time-selection-set-start-point": 40625,
4600 "time-selection-set-time-selection-to-items": 40290,
4601 "time-selection-shift-left-by-time-selection-length": 40037,
4602 "time-selection-shift-right-by-time-selection-length": 40038,
4603 "time-selection-swap-left-edge-of-time-selection-to-next-transient-in-items": 40803,
4604 "toggle-external-timecode-synchronization": 40620,
4605 "toggle-fullscreen": 40346,
4606 "toggle-ripple-editing-all-tracks": 41991,
4607 "toggle-ripple-editing-on-off": 1162,
4608 "toggle-ripple-editing-per-track": 41990,
4609 "toggle-show-all-floating-windows": 41074,
4610 "toggle-show-all-floating-windows-except-mixer": 41077,
4611 "toggle-show-all-floating-windows-except-mixer-and-unattached-docker": 41080,
4612 "toggle-show-all-floating-windows-except-unattached-docker": 41079,
4613 "toggle-show-master-tempo-envelope": 41046,
4614 "toggle-show-master-track-and-tempo-envelope": 41050,
4615 "toolbar-customize-empty-tcp-area-toolbar": 43676,
4616 "toolbar-open-close-main-toolbar": 41651,
4617 "toolbar-open-close-media-explorer-toolbar": 42404,
4618 "toolbar-open-close-midi-piano-roll-toolbar": 41676,
4619 "toolbar-open-close-midi-toolbar-1": 41687,
4620 "toolbar-open-close-midi-toolbar-10": 42746,
4621 "toolbar-open-close-midi-toolbar-11": 42747,
4622 "toolbar-open-close-midi-toolbar-12": 42748,
4623 "toolbar-open-close-midi-toolbar-13": 42749,
4624 "toolbar-open-close-midi-toolbar-14": 42750,
4625 "toolbar-open-close-midi-toolbar-15": 42751,
4626 "toolbar-open-close-midi-toolbar-16": 42752,
4627 "toolbar-open-close-midi-toolbar-2": 41688,
4628 "toolbar-open-close-midi-toolbar-3": 41689,
4629 "toolbar-open-close-midi-toolbar-4": 41690,
4630 "toolbar-open-close-midi-toolbar-5": 41944,
4631 "toolbar-open-close-midi-toolbar-6": 41945,
4632 "toolbar-open-close-midi-toolbar-7": 41946,
4633 "toolbar-open-close-midi-toolbar-8": 41947,
4634 "toolbar-open-close-midi-toolbar-9": 42745,
4635 "toolbar-open-close-toolbar-1": 41679,
4636 "toolbar-open-close-toolbar-10": 41937,
4637 "toolbar-open-close-toolbar-11": 41938,
4638 "toolbar-open-close-toolbar-12": 41939,
4639 "toolbar-open-close-toolbar-13": 41940,
4640 "toolbar-open-close-toolbar-14": 41941,
4641 "toolbar-open-close-toolbar-15": 41942,
4642 "toolbar-open-close-toolbar-16": 41943,
4643 "toolbar-open-close-toolbar-17": 42713,
4644 "toolbar-open-close-toolbar-18": 42714,
4645 "toolbar-open-close-toolbar-19": 42715,
4646 "toolbar-open-close-toolbar-2": 41680,
4647 "toolbar-open-close-toolbar-20": 42716,
4648 "toolbar-open-close-toolbar-21": 42717,
4649 "toolbar-open-close-toolbar-22": 42718,
4650 "toolbar-open-close-toolbar-23": 42719,
4651 "toolbar-open-close-toolbar-24": 42720,
4652 "toolbar-open-close-toolbar-25": 42721,
4653 "toolbar-open-close-toolbar-26": 42722,
4654 "toolbar-open-close-toolbar-27": 42723,
4655 "toolbar-open-close-toolbar-28": 42724,
4656 "toolbar-open-close-toolbar-29": 42725,
4657 "toolbar-open-close-toolbar-3": 41681,
4658 "toolbar-open-close-toolbar-30": 42726,
4659 "toolbar-open-close-toolbar-31": 42727,
4660 "toolbar-open-close-toolbar-32": 42728,
4661 "toolbar-open-close-toolbar-4": 41682,
4662 "toolbar-open-close-toolbar-5": 41683,
4663 "toolbar-open-close-toolbar-6": 41684,
4664 "toolbar-open-close-toolbar-7": 41685,
4665 "toolbar-open-close-toolbar-8": 41686,
4666 "toolbar-open-close-toolbar-9": 41936,
4667 "toolbar-open-midi-toolbar-1-at-mouse-cursor": 41640,
4668 "toolbar-open-midi-toolbar-10-at-mouse-cursor": 42778,
4669 "toolbar-open-midi-toolbar-11-at-mouse-cursor": 42779,
4670 "toolbar-open-midi-toolbar-12-at-mouse-cursor": 42780,
4671 "toolbar-open-midi-toolbar-13-at-mouse-cursor": 42781,
4672 "toolbar-open-midi-toolbar-14-at-mouse-cursor": 42782,
4673 "toolbar-open-midi-toolbar-15-at-mouse-cursor": 42783,
4674 "toolbar-open-midi-toolbar-16-at-mouse-cursor": 42784,
4675 "toolbar-open-midi-toolbar-2-at-mouse-cursor": 41641,
4676 "toolbar-open-midi-toolbar-3-at-mouse-cursor": 41642,
4677 "toolbar-open-midi-toolbar-4-at-mouse-cursor": 41643,
4678 "toolbar-open-midi-toolbar-5-at-mouse-cursor": 41968,
4679 "toolbar-open-midi-toolbar-6-at-mouse-cursor": 41969,
4680 "toolbar-open-midi-toolbar-7-at-mouse-cursor": 41970,
4681 "toolbar-open-midi-toolbar-8-at-mouse-cursor": 41971,
4682 "toolbar-open-midi-toolbar-9-at-mouse-cursor": 42777,
4683 "toolbar-open-toolbar-1-at-mouse-cursor": 41111,
4684 "toolbar-open-toolbar-10-at-mouse-cursor": 41961,
4685 "toolbar-open-toolbar-11-at-mouse-cursor": 41962,
4686 "toolbar-open-toolbar-12-at-mouse-cursor": 41963,
4687 "toolbar-open-toolbar-13-at-mouse-cursor": 41964,
4688 "toolbar-open-toolbar-14-at-mouse-cursor": 41965,
4689 "toolbar-open-toolbar-15-at-mouse-cursor": 41966,
4690 "toolbar-open-toolbar-16-at-mouse-cursor": 41967,
4691 "toolbar-open-toolbar-17-at-mouse-cursor": 42761,
4692 "toolbar-open-toolbar-18-at-mouse-cursor": 42762,
4693 "toolbar-open-toolbar-19-at-mouse-cursor": 42763,
4694 "toolbar-open-toolbar-2-at-mouse-cursor": 41112,
4695 "toolbar-open-toolbar-20-at-mouse-cursor": 42764,
4696 "toolbar-open-toolbar-21-at-mouse-cursor": 42765,
4697 "toolbar-open-toolbar-22-at-mouse-cursor": 42766,
4698 "toolbar-open-toolbar-23-at-mouse-cursor": 42767,
4699 "toolbar-open-toolbar-24-at-mouse-cursor": 42768,
4700 "toolbar-open-toolbar-25-at-mouse-cursor": 42769,
4701 "toolbar-open-toolbar-26-at-mouse-cursor": 42770,
4702 "toolbar-open-toolbar-27-at-mouse-cursor": 42771,
4703 "toolbar-open-toolbar-28-at-mouse-cursor": 42772,
4704 "toolbar-open-toolbar-29-at-mouse-cursor": 42773,
4705 "toolbar-open-toolbar-3-at-mouse-cursor": 41113,
4706 "toolbar-open-toolbar-30-at-mouse-cursor": 42774,
4707 "toolbar-open-toolbar-31-at-mouse-cursor": 42775,
4708 "toolbar-open-toolbar-32-at-mouse-cursor": 42776,
4709 "toolbar-open-toolbar-4-at-mouse-cursor": 41114,
4710 "toolbar-open-toolbar-5-at-mouse-cursor": 41655,
4711 "toolbar-open-toolbar-6-at-mouse-cursor": 41656,
4712 "toolbar-open-toolbar-7-at-mouse-cursor": 41657,
4713 "toolbar-open-toolbar-8-at-mouse-cursor": 41658,
4714 "toolbar-open-toolbar-9-at-mouse-cursor": 41960,
4715 "toolbar-press-active-toolbar-button-01": 41085,
4716 "toolbar-press-active-toolbar-button-02": 41086,
4717 "toolbar-press-active-toolbar-button-03": 41087,
4718 "toolbar-press-active-toolbar-button-04": 41088,
4719 "toolbar-press-active-toolbar-button-05": 41089,
4720 "toolbar-press-active-toolbar-button-06": 41090,
4721 "toolbar-press-active-toolbar-button-07": 41091,
4722 "toolbar-press-active-toolbar-button-08": 41092,
4723 "toolbar-press-active-toolbar-button-09": 41093,
4724 "toolbar-press-active-toolbar-button-10": 41094,
4725 "toolbar-press-active-toolbar-button-11": 41095,
4726 "toolbar-press-active-toolbar-button-12": 41096,
4727 "toolbar-press-active-toolbar-button-13": 41097,
4728 "toolbar-press-active-toolbar-button-14": 41098,
4729 "toolbar-press-active-toolbar-button-15": 41099,
4730 "toolbar-press-active-toolbar-button-16": 41100,
4731 "toolbar-show-hide-toolbar-at-top-of-main-window": 41297,
4732 "toolbar-show-hide-toolbar-docker": 41084,
4733 "toolbars-customize": 40905,
4734 "toolbars-show-midi-toolbar-1-as-menu": 43433,
4735 "toolbars-show-midi-toolbar-10-as-menu": 43442,
4736 "toolbars-show-midi-toolbar-11-as-menu": 43443,
4737 "toolbars-show-midi-toolbar-12-as-menu": 43444,
4738 "toolbars-show-midi-toolbar-13-as-menu": 43445,
4739 "toolbars-show-midi-toolbar-14-as-menu": 43446,
4740 "toolbars-show-midi-toolbar-15-as-menu": 43447,
4741 "toolbars-show-midi-toolbar-16-as-menu": 43448,
4742 "toolbars-show-midi-toolbar-2-as-menu": 43434,
4743 "toolbars-show-midi-toolbar-3-as-menu": 43435,
4744 "toolbars-show-midi-toolbar-4-as-menu": 43436,
4745 "toolbars-show-midi-toolbar-5-as-menu": 43437,
4746 "toolbars-show-midi-toolbar-6-as-menu": 43438,
4747 "toolbars-show-midi-toolbar-7-as-menu": 43439,
4748 "toolbars-show-midi-toolbar-8-as-menu": 43440,
4749 "toolbars-show-midi-toolbar-9-as-menu": 43441,
4750 "toolbars-show-toolbar-1-as-menu": 43401,
4751 "toolbars-show-toolbar-10-as-menu": 43410,
4752 "toolbars-show-toolbar-11-as-menu": 43411,
4753 "toolbars-show-toolbar-12-as-menu": 43412,
4754 "toolbars-show-toolbar-13-as-menu": 43413,
4755 "toolbars-show-toolbar-14-as-menu": 43414,
4756 "toolbars-show-toolbar-15-as-menu": 43415,
4757 "toolbars-show-toolbar-16-as-menu": 43416,
4758 "toolbars-show-toolbar-17-as-menu": 43417,
4759 "toolbars-show-toolbar-18-as-menu": 43418,
4760 "toolbars-show-toolbar-19-as-menu": 43419,
4761 "toolbars-show-toolbar-2-as-menu": 43402,
4762 "toolbars-show-toolbar-20-as-menu": 43420,
4763 "toolbars-show-toolbar-21-as-menu": 43421,
4764 "toolbars-show-toolbar-22-as-menu": 43422,
4765 "toolbars-show-toolbar-23-as-menu": 43423,
4766 "toolbars-show-toolbar-24-as-menu": 43424,
4767 "toolbars-show-toolbar-25-as-menu": 43425,
4768 "toolbars-show-toolbar-26-as-menu": 43426,
4769 "toolbars-show-toolbar-27-as-menu": 43427,
4770 "toolbars-show-toolbar-28-as-menu": 43428,
4771 "toolbars-show-toolbar-29-as-menu": 43429,
4772 "toolbars-show-toolbar-3-as-menu": 43403,
4773 "toolbars-show-toolbar-30-as-menu": 43430,
4774 "toolbars-show-toolbar-31-as-menu": 43431,
4775 "toolbars-show-toolbar-32-as-menu": 43432,
4776 "toolbars-show-toolbar-4-as-menu": 43404,
4777 "toolbars-show-toolbar-5-as-menu": 43405,
4778 "toolbars-show-toolbar-6-as-menu": 43406,
4779 "toolbars-show-toolbar-7-as-menu": 43407,
4780 "toolbars-show-toolbar-8-as-menu": 43408,
4781 "toolbars-show-toolbar-9-as-menu": 43409,
4782 "toolbars-switch-to-main-toolbar": 41646,
4783 "toolbars-switch-to-media-explorer-toolbar": 42405,
4784 "toolbars-switch-to-midi-piano-roll-toolbar": 40303,
4785 "toolbars-switch-to-midi-toolbar-1": 41659,
4786 "toolbars-switch-to-midi-toolbar-10": 42754,
4787 "toolbars-switch-to-midi-toolbar-11": 42755,
4788 "toolbars-switch-to-midi-toolbar-12": 42756,
4789 "toolbars-switch-to-midi-toolbar-13": 42757,
4790 "toolbars-switch-to-midi-toolbar-14": 42758,
4791 "toolbars-switch-to-midi-toolbar-15": 42759,
4792 "toolbars-switch-to-midi-toolbar-16": 42760,
4793 "toolbars-switch-to-midi-toolbar-2": 41660,
4794 "toolbars-switch-to-midi-toolbar-3": 41661,
4795 "toolbars-switch-to-midi-toolbar-4": 41662,
4796 "toolbars-switch-to-midi-toolbar-5": 41956,
4797 "toolbars-switch-to-midi-toolbar-6": 41957,
4798 "toolbars-switch-to-midi-toolbar-7": 41958,
4799 "toolbars-switch-to-midi-toolbar-8": 41959,
4800 "toolbars-switch-to-midi-toolbar-9": 42753,
4801 "toolbars-switch-to-toolbar-1": 41105,
4802 "toolbars-switch-to-toolbar-10": 41949,
4803 "toolbars-switch-to-toolbar-11": 41950,
4804 "toolbars-switch-to-toolbar-12": 41951,
4805 "toolbars-switch-to-toolbar-13": 41952,
4806 "toolbars-switch-to-toolbar-14": 41953,
4807 "toolbars-switch-to-toolbar-15": 41954,
4808 "toolbars-switch-to-toolbar-16": 41955,
4809 "toolbars-switch-to-toolbar-17": 42729,
4810 "toolbars-switch-to-toolbar-18": 42730,
4811 "toolbars-switch-to-toolbar-19": 42731,
4812 "toolbars-switch-to-toolbar-2": 41106,
4813 "toolbars-switch-to-toolbar-20": 42732,
4814 "toolbars-switch-to-toolbar-21": 42733,
4815 "toolbars-switch-to-toolbar-22": 42734,
4816 "toolbars-switch-to-toolbar-23": 42735,
4817 "toolbars-switch-to-toolbar-24": 42736,
4818 "toolbars-switch-to-toolbar-25": 42737,
4819 "toolbars-switch-to-toolbar-26": 42738,
4820 "toolbars-switch-to-toolbar-27": 42739,
4821 "toolbars-switch-to-toolbar-28": 42740,
4822 "toolbars-switch-to-toolbar-29": 42741,
4823 "toolbars-switch-to-toolbar-3": 41107,
4824 "toolbars-switch-to-toolbar-30": 42742,
4825 "toolbars-switch-to-toolbar-31": 42743,
4826 "toolbars-switch-to-toolbar-32": 42744,
4827 "toolbars-switch-to-toolbar-4": 41108,
4828 "toolbars-switch-to-toolbar-5": 41647,
4829 "toolbars-switch-to-toolbar-6": 41648,
4830 "toolbars-switch-to-toolbar-7": 41649,
4831 "toolbars-switch-to-toolbar-8": 41650,
4832 "toolbars-switch-to-toolbar-9": 41948,
4833 "track-2nd-pass-render-selected-area-of-tracks-to-mono-post-fader-stem-tracks-and-mute-originals": 42415,
4834 "track-2nd-pass-render-selected-area-of-tracks-to-mono-stem-tracks-and-mute-originals": 42418,
4835 "track-2nd-pass-render-selected-area-of-tracks-to-multichannel-parent-send-only-post-fader-stem-tracks-and-mute-originals":
4836 42593,
4837 "track-2nd-pass-render-selected-area-of-tracks-to-multichannel-parent-send-only-stem-tracks-and-mute-originals":
4838 42594,
4839 "track-2nd-pass-render-selected-area-of-tracks-to-multichannel-post-fader-stem-tracks-and-mute-originals": 42414,
4840 "track-2nd-pass-render-selected-area-of-tracks-to-multichannel-stem-tracks-and-mute-originals": 42417,
4841 "track-2nd-pass-render-selected-area-of-tracks-to-stereo-post-fader-stem-tracks-and-mute-originals": 42413,
4842 "track-2nd-pass-render-selected-area-of-tracks-to-stereo-stem-tracks-and-mute-originals": 42416,
4843 "track-allow-track-anticipative-fx": 40609,
4844 "track-allow-track-media-buffering": 40607,
4845 "track-apply-media-playback-offset-to-receive-source-tracks": 42233,
4846 "track-apply-trim-envelope-to-volume-envelope-clear-trim-envelope": 42018,
4847 "track-apply-volume-envelope-to-trim-envelope-clear-volume-envelope": 42019,
4848 "track-arm-all-tracks-for-recording": 40490,
4849 "track-automatically-group-all-tracks-for-media-razor-editing": 42580,
4850 "track-automatically-group-selected-tracks-for-media-razor-editing": 42581,
4851 "track-bypass-fx-on-all-tracks": 40342,
4852 "track-bypass-media-playback-offset": 42232,
4853 "track-clear-automatic-record-arm": 40738,
4854 "track-clear-up-rank-down-rank-markers-for-all-items-on-track": 43165,
4855 "track-copy-playing-media-items-to-new-track": 42947,
4856 "track-copy-playing-media-items-to-new-track-for-track-under-mouse": 42946,
4857 "track-copy-tracks": 40210,
4858 "track-create-new-track-media-razor-editing-group-for-selected-tracks": 42578,
4859 "track-crop-to-playing-media-items": 42943,
4860 "track-crop-to-playing-media-items-for-track-under-mouse": 42942,
4861 "track-crop-to-playing-media-items-preserving-fixed-lanes": 42945,
4862 "track-crop-to-playing-media-items-preserving-fixed-lanes-for-track-under-mouse": 42944,
4863 "track-cut-tracks": 40337,
4864 "track-cycle-folder-collapsed-state": 1042,
4865 "track-cycle-track-folder-state": 1041,
4866 "track-cycle-track-record-monitor": 40495,
4867 "track-delete-takes-for-all-items-on-track-that-are-down-ranked-no-confirm": 43167,
4868 "track-delete-takes-for-all-items-on-track-that-are-not-up-ranked-no-confirm": 43166,
4869 "track-disable-midi-input-quantize-for-all-tracks": 42066,
4870 "track-disable-midi-input-quantize-for-last-touched-track": 42068,
4871 "track-disable-midi-input-quantize-for-selected-tracks": 42064,
4872 "track-display-gain-reduction-in-track-meters-for-plug-ins-that-support-it": 42705,
4873 "track-do-not-link-track-volume-pan-controls-to-midi-volume-pan": 41538,
4874 "track-duplicate-tracks": 40062,
4875 "track-enable-midi-input-quantize-for-all-tracks": 42065,
4876 "track-enable-midi-input-quantize-for-last-touched-track": 42067,
4877 "track-enable-midi-input-quantize-for-selected-tracks": 42063,
4878 "track-exit-fixed-lane-view-for-all-fixed-lane-tracks-in-the-project": 42960,
4879 "track-freeze-to-mono-render-pre-fader-save-remove-items-and-online-fx": 40901,
4880 "track-freeze-to-multichannel-render-pre-fader-save-remove-items-and-online-fx": 40877,
4881 "track-freeze-to-stereo-render-pre-fader-save-remove-items-and-online-fx": 41223,
4882 "track-go-to-next-track": 40285,
4883 "track-go-to-next-track-leaving-other-tracks-selected": 40287,
4884 "track-go-to-previous-track": 40286,
4885 "track-go-to-previous-track-leaving-other-tracks-selected": 40288,
4886 "track-hide-envelope-display-next-envelope-on-same-track-cycle": 41825,
4887 "track-hide-envelope-display-previous-envelope-on-same-track-cycle": 43672,
4888 "track-hide-tracks-in-tcp-and-mixer": 41593,
4889 "track-insert-display-reasurroundpan-in-mcp": 42426,
4890 "track-insert-display-reasurroundpan-in-tcp": 42425,
4891 "track-insert-multiple-new-tracks": 41067,
4892 "track-insert-new-5-1-surround-track-embed-reasurroundpan-in-tcp": 41584,
4893 "track-insert-new-7-1-2-surround-track-embed-reasurroundpan-in-tcp": 42423,
4894 "track-insert-new-7-1-4-surround-track-embed-reasurroundpan-in-tcp": 42424,
4895 "track-insert-new-7-1-surround-track-embed-reasurroundpan-in-tcp": 42422,
4896 "track-insert-new-surround-track-using-selected-tracks-as-source-audio": 41585,
4897 "track-insert-new-track": 40001,
4898 "track-insert-new-track-as-first-track": 43093,
4899 "track-insert-new-track-at-end-of-mixer": 41147,
4900 "track-insert-new-track-at-end-of-track-list": 40702,
4901 "track-insert-show-reacontrolmidi-midi-track-control": 40907,
4902 "track-insert-show-reaeq-track-eq": 41757,
4903 "track-insert-track-from-template": 46000,
4904 "track-insert-visual-spacer-after-last-touched-track": 42672,
4905 "track-insert-visual-spacer-after-tracks": 42666,
4906 "track-insert-visual-spacer-before-and-after-tracks": 42669,
4907 "track-insert-visual-spacer-before-last-touched-track": 42671,
4908 "track-insert-visual-spacer-before-tracks": 42665,
4909 "track-invert-track-polarity-phase": 40282,
4910 "track-lanes-add-comp-areas-for-selected-items": 42652,
4911 "track-lanes-add-empty-lane-at-bottom-of-track": 42647,
4912 "track-lanes-comp-into-a-new-copy-of-lane-under-mouse-for-track-under-mouse": 42487,
4913 "track-lanes-comp-into-lane-under-mouse-for-track-under-mouse": 42499,
4914 "track-lanes-comp-into-new-empty-lane": 42797,
4915 "track-lanes-comp-into-new-empty-lane-automatically-creating-comp-areas": 42798,
4916 "track-lanes-comp-into-new-empty-lane-for-track-under-mouse": 42486,
4917 "track-lanes-comp-into-new-empty-lane-for-track-under-mouse-automatically-creating-comp-areas": 42649,
4918 "track-lanes-copy-edited-media-item-back-to-source-lane-and-re-comp-for-track-under-mouse": 42949,
4919 "track-lanes-copy-edited-media-items-to-new-lane-and-re-comp-for-track-under-mouse": 42654,
4920 "track-lanes-copy-edited-media-items-with-no-matching-source-lane-to-new-lane-and-re-comp": 42802,
4921 "track-lanes-delete-all-lanes-except-lane-under-mouse-for-track-under-mouse-including-media-items": 42933,
4922 "track-lanes-delete-all-lanes-including-media-items": 42796,
4923 "track-lanes-delete-comp-areas": 42955,
4924 "track-lanes-delete-comp-areas-for-track-under-mouse": 42789,
4925 "track-lanes-delete-comp-areas-including-source-media": 42956,
4926 "track-lanes-delete-comp-areas-including-source-media-for-track-under-mouse": 42683,
4927 "track-lanes-delete-empty-comp-areas": 42954,
4928 "track-lanes-delete-empty-comp-areas-for-track-under-mouse": 42953,
4929 "track-lanes-delete-empty-lanes-with-no-media-items": 42689,
4930 "track-lanes-delete-lane-at-bottom-of-track-including-media-items": 42648,
4931 "track-lanes-delete-lane-at-top-of-track-including-media-items": 42501,
4932 "track-lanes-delete-lane-under-mouse-including-media-items": 42676,
4933 "track-lanes-delete-lanes-including-media-items-that-are-not-playing": 42691,
4934 "track-lanes-delete-lanes-including-media-items-with-no-comp-areas": 42690,
4935 "track-lanes-delete-source-media-within-comp-areas-and-re-comp": 42629,
4936 "track-lanes-delete-source-media-within-comp-areas-and-re-comp-for-track-under-mouse": 42684,
4937 "track-lanes-discard-media-item-edits-and-re-comp-from-source-lane-for-track-under-mouse": 42950,
4938 "track-lanes-duplicate-items-from-playing-lanes-to-new-lanes": 42505,
4939 "track-lanes-insert-empty-lane-at-top-of-track": 42500,
4940 "track-lanes-media-items-in-higher-numbered-lanes-mask-playback-of-lower-lanes": 42941,
4941 "track-lanes-media-items-in-higher-numbered-lanes-mask-playback-of-lower-lanes-for-track-under-mouse": 42940,
4942 "track-lanes-move-items-down-one-lane": 40107,
4943 "track-lanes-move-items-down-to-first-available-lane-add-lane-if-needed": 42787,
4944 "track-lanes-move-items-to-bottom-lane": 42588,
4945 "track-lanes-move-items-to-top-lane": 42587,
4946 "track-lanes-move-items-up-if-possible-to-minimize-lane-usage": 42959,
4947 "track-lanes-move-items-up-if-possible-to-minimize-lane-usage-preserve-relative-lane-positions": 42938,
4948 "track-lanes-move-items-up-one-lane": 40068,
4949 "track-lanes-play-all-lanes": 42799,
4950 "track-lanes-play-all-lanes-for-track-under-mouse": 42479,
4951 "track-lanes-play-no-lanes": 42800,
4952 "track-lanes-play-no-lanes-for-track-under-mouse": 42490,
4953 "track-lanes-play-only-first-lane": 42790,
4954 "track-lanes-play-only-first-lane-for-track-under-mouse": 42791,
4955 "track-lanes-play-only-lane-under-mouse": 42478,
4956 "track-lanes-play-only-most-recently-playing-lane": 43701,
4957 "track-lanes-play-only-most-recently-playing-lane-for-track-under-mouse": 43702,
4958 "track-lanes-play-only-next-lane": 42482,
4959 "track-lanes-play-only-next-lane-for-track-under-mouse": 42484,
4960 "track-lanes-play-only-previous-lane": 42481,
4961 "track-lanes-play-only-previous-lane-for-track-under-mouse": 42483,
4962 "track-lanes-record-into-lane-under-mouse": 42471,
4963 "track-lanes-refresh-out-of-sync-comp-areas-for-track-under-mouse": 42952,
4964 "track-lanes-rename-lane-under-mouse": 42472,
4965 "track-lanes-reset-all-lane-names": 42801,
4966 "track-lanes-reset-all-lane-names-for-track-under-mouse": 42703,
4967 "track-lanes-select-items-in-lane-under-mouse": 42469,
4968 "track-lanes-toggle-playing-lane-under-mouse": 42480,
4969 "track-lanes-turn-off-comping": 42692,
4970 "track-lanes-turn-off-comping-for-track-under-mouse": 42506,
4971 "track-link-track-volume-pan-controls-to-midi-volume-pan-on-all-channels": 41555,
4972 "track-link-track-volume-pan-controls-to-midi-volume-pan-on-channel-1": 41539,
4973 "track-link-track-volume-pan-controls-to-midi-volume-pan-on-channel-10": 41548,
4974 "track-link-track-volume-pan-controls-to-midi-volume-pan-on-channel-11": 41549,
4975 "track-link-track-volume-pan-controls-to-midi-volume-pan-on-channel-12": 41550,
4976 "track-link-track-volume-pan-controls-to-midi-volume-pan-on-channel-13": 41551,
4977 "track-link-track-volume-pan-controls-to-midi-volume-pan-on-channel-14": 41552,
4978 "track-link-track-volume-pan-controls-to-midi-volume-pan-on-channel-15": 41553,
4979 "track-link-track-volume-pan-controls-to-midi-volume-pan-on-channel-16": 41554,
4980 "track-link-track-volume-pan-controls-to-midi-volume-pan-on-channel-2": 41540,
4981 "track-link-track-volume-pan-controls-to-midi-volume-pan-on-channel-3": 41541,
4982 "track-link-track-volume-pan-controls-to-midi-volume-pan-on-channel-4": 41542,
4983 "track-link-track-volume-pan-controls-to-midi-volume-pan-on-channel-5": 41543,
4984 "track-link-track-volume-pan-controls-to-midi-volume-pan-on-channel-6": 41544,
4985 "track-link-track-volume-pan-controls-to-midi-volume-pan-on-channel-7": 41545,
4986 "track-link-track-volume-pan-controls-to-midi-volume-pan-on-channel-8": 41546,
4987 "track-link-track-volume-pan-controls-to-midi-volume-pan-on-channel-9": 41547,
4988 "track-lock-track-controls": 41312,
4989 "track-lock-unlock-track-height": 42336,
4990 "track-lufs-measures-first-two-channels-only": 42452,
4991 "track-make-all-tracks-visible-in-tcp-and-mixer": 41594,
4992 "track-move-all-media-items-from-all-hidden-child-tracks": 42454,
4993 "track-move-all-media-items-to-new-hidden-child-track": 42453,
4994 "track-move-tracks-down": 43648,
4995 "track-move-tracks-to-folder": 42786,
4996 "track-move-tracks-to-new-folder": 42785,
4997 "track-move-tracks-to-subproject": 41997,
4998 "track-move-tracks-up": 43647,
4999 "track-mute-all-tracks": 40341,
5000 "track-mute-tracks": 40730,
5001 "track-mute-unmute-tracks": 40280,
5002 "track-nudge-master-track-volume-down": 40744,
5003 "track-nudge-master-track-volume-up": 40743,
5004 "track-nudge-track-pan-left": 40283,
5005 "track-nudge-track-pan-right": 40284,
5006 "track-nudge-track-volume-down": 40116,
5007 "track-nudge-track-volume-up": 40115,
5008 "track-open-close-ui-for-fx-number-1-on-last-touched-track": 41749,
5009 "track-open-close-ui-for-fx-number-2-on-last-touched-track": 41750,
5010 "track-open-close-ui-for-fx-number-3-on-last-touched-track": 41751,
5011 "track-open-close-ui-for-fx-number-4-on-last-touched-track": 41752,
5012 "track-open-close-ui-for-fx-number-5-on-last-touched-track": 41753,
5013 "track-open-close-ui-for-fx-number-6-on-last-touched-track": 41754,
5014 "track-open-close-ui-for-fx-number-7-on-last-touched-track": 41755,
5015 "track-open-close-ui-for-fx-number-8-on-last-touched-track": 41756,
5016 "track-override-show-all-hidden-tracks-in-tcp": 43574,
5017 "track-override-unpin-all-pinned-tracks-in-tcp": 43573,
5018 "track-pin-tracks-to-top-of-arrange-view": 40000,
5019 "track-pin-tracks-to-top-of-arrange-view-unpin-all-other-tracks-except-master": 40008,
5020 "track-prevent-spectral-peaks-spectrogram": 42075,
5021 "track-prevent-track-anticipative-fx": 40610,
5022 "track-prevent-track-media-buffering": 40608,
5023 "track-properties-free-item-positioning": 40641,
5024 "track-properties-hide-fixed-lane-buttons": 40092,
5025 "track-properties-make-fixed-item-lanes-big": 43101,
5026 "track-properties-make-fixed-item-lanes-small": 43100,
5027 "track-properties-set-fixed-item-lanes": 42431,
5028 "track-properties-set-fixed-lanes-convert-takes-to-lanes": 42661,
5029 "track-properties-set-free-item-positioning": 40751,
5030 "track-properties-set-track-timebase-to-beats-position-length-rate": 40488,
5031 "track-properties-set-track-timebase-to-beats-position-only": 40489,
5032 "track-properties-set-track-timebase-to-project-default": 40486,
5033 "track-properties-set-track-timebase-to-time": 40487,
5034 "track-properties-show-fixed-lane-buttons": 40091,
5035 "track-properties-show-hide-fixed-lane-buttons": 42610,
5036 "track-properties-show-play-all-fixed-item-lanes": 43099,
5037 "track-properties-show-play-only-one-fixed-item-lane": 43098,
5038 "track-properties-toggle-fixed-item-lanes": 42430,
5039 "track-properties-toggle-fixed-item-lanes-big-small": 42704,
5040 "track-properties-toggle-fixed-item-lanes-convert-takes-to-lanes": 42660,
5041 "track-properties-toggle-show-play-only-one-fixed-item-lane": 42638,
5042 "track-properties-unset-free-item-positioning-fixed-item-lanes": 40752,
5043 "track-properties-unset-free-item-positioning-fixed-item-lanes-convert-fixed-lanes-to-takes": 42662,
5044 "track-remove-selected-tracks-from-all-track-media-razor-editing-groups": 42579,
5045 "track-remove-track-icon": 40900,
5046 "track-remove-track-spacers": 42670,
5047 "track-remove-tracks": 40005,
5048 "track-remove-visual-spacer-after-last-touched-track": 42674,
5049 "track-remove-visual-spacer-after-tracks": 42668,
5050 "track-remove-visual-spacer-before-last-touched-track": 42673,
5051 "track-remove-visual-spacer-before-tracks": 42667,
5052 "track-rename-last-touched-track": 40696,
5053 "track-render-selected-area-of-tracks-to-mono-post-fader-stem-tracks-and-mute-originals": 41718,
5054 "track-render-selected-area-of-tracks-to-mono-stem-tracks-and-mute-originals": 41721,
5055 "track-render-selected-area-of-tracks-to-multichannel-parent-send-only-post-fader-stem-tracks-and-mute-originals":
5056 42591,
5057 "track-render-selected-area-of-tracks-to-multichannel-parent-send-only-stem-tracks-and-mute-originals": 42592,
5058 "track-render-selected-area-of-tracks-to-multichannel-post-fader-stem-tracks-and-mute-originals": 41717,
5059 "track-render-selected-area-of-tracks-to-multichannel-stem-tracks-and-mute-originals": 41720,
5060 "track-render-selected-area-of-tracks-to-stereo-post-fader-stem-tracks-and-mute-originals": 41716,
5061 "track-render-selected-area-of-tracks-to-stereo-stem-tracks-and-mute-originals": 41719,
5062 "track-render-tracks-to-mono-post-fader-stem-tracks-and-mute-originals": 40537,
5063 "track-render-tracks-to-mono-stem-tracks-and-mute-originals": 40789,
5064 "track-render-tracks-to-multichannel-parent-send-only-post-fader-stem-tracks-and-mute-originals": 42589,
5065 "track-render-tracks-to-multichannel-parent-send-only-stem-tracks-and-mute-originals": 42590,
5066 "track-render-tracks-to-multichannel-post-fader-stem-tracks-and-mute-originals": 40892,
5067 "track-render-tracks-to-multichannel-stem-tracks-and-mute-originals": 40893,
5068 "track-render-tracks-to-stereo-post-fader-stem-tracks-and-mute-originals": 40405,
5069 "track-render-tracks-to-stereo-stem-tracks-and-mute-originals": 40788,
5070 "track-save-tracks-as-track-template": 40392,
5071 "track-select-all-top-level-tracks": 41803,
5072 "track-select-all-tracks": 40296,
5073 "track-select-all-tracks-that-have-controls-locked": 41324,
5074 "track-select-fx-envelope-01": 41872,
5075 "track-select-fx-envelope-02": 41873,
5076 "track-select-fx-envelope-03": 41874,
5077 "track-select-fx-envelope-04": 41875,
5078 "track-select-fx-envelope-05": 41876,
5079 "track-select-fx-envelope-06": 41877,
5080 "track-select-fx-envelope-07": 41878,
5081 "track-select-fx-envelope-08": 41879,
5082 "track-select-fx-envelope-09": 41880,
5083 "track-select-fx-envelope-10": 41881,
5084 "track-select-last-touched-track": 40505,
5085 "track-select-mute-envelope": 41871,
5086 "track-select-next-envelope": 41864,
5087 "track-select-pan-envelope": 41868,
5088 "track-select-pre-fx-pan-envelope": 41867,
5089 "track-select-pre-fx-volume-envelope": 41865,
5090 "track-select-pre-fx-width-envelope": 41869,
5091 "track-select-previous-envelope": 41863,
5092 "track-select-track-01": 40939,
5093 "track-select-track-02": 40940,
5094 "track-select-track-03": 40941,
5095 "track-select-track-04": 40942,
5096 "track-select-track-05": 40943,
5097 "track-select-track-06": 40944,
5098 "track-select-track-07": 40945,
5099 "track-select-track-08": 40946,
5100 "track-select-track-09": 40947,
5101 "track-select-track-10": 40948,
5102 "track-select-track-11": 40949,
5103 "track-select-track-12": 40950,
5104 "track-select-track-13": 40951,
5105 "track-select-track-14": 40952,
5106 "track-select-track-15": 40953,
5107 "track-select-track-16": 40954,
5108 "track-select-track-17": 40955,
5109 "track-select-track-18": 40956,
5110 "track-select-track-19": 40957,
5111 "track-select-track-20": 40958,
5112 "track-select-track-21": 40959,
5113 "track-select-track-22": 40960,
5114 "track-select-track-23": 40961,
5115 "track-select-track-24": 40962,
5116 "track-select-track-25": 40963,
5117 "track-select-track-26": 40964,
5118 "track-select-track-27": 40965,
5119 "track-select-track-28": 40966,
5120 "track-select-track-29": 40967,
5121 "track-select-track-30": 40968,
5122 "track-select-track-31": 40969,
5123 "track-select-track-32": 40970,
5124 "track-select-track-33": 40971,
5125 "track-select-track-34": 40972,
5126 "track-select-track-35": 40973,
5127 "track-select-track-36": 40974,
5128 "track-select-track-37": 40975,
5129 "track-select-track-38": 40976,
5130 "track-select-track-39": 40977,
5131 "track-select-track-40": 40978,
5132 "track-select-track-41": 40979,
5133 "track-select-track-42": 40980,
5134 "track-select-track-43": 40981,
5135 "track-select-track-44": 40982,
5136 "track-select-track-45": 40983,
5137 "track-select-track-46": 40984,
5138 "track-select-track-47": 40985,
5139 "track-select-track-48": 40986,
5140 "track-select-track-49": 40987,
5141 "track-select-track-50": 40988,
5142 "track-select-track-51": 40989,
5143 "track-select-track-52": 40990,
5144 "track-select-track-53": 40991,
5145 "track-select-track-54": 40992,
5146 "track-select-track-55": 40993,
5147 "track-select-track-56": 40994,
5148 "track-select-track-57": 40995,
5149 "track-select-track-58": 40996,
5150 "track-select-track-59": 40997,
5151 "track-select-track-60": 40998,
5152 "track-select-track-61": 40999,
5153 "track-select-track-62": 41000,
5154 "track-select-track-63": 41001,
5155 "track-select-track-64": 41002,
5156 "track-select-track-65": 41003,
5157 "track-select-track-66": 41004,
5158 "track-select-track-67": 41005,
5159 "track-select-track-68": 41006,
5160 "track-select-track-69": 41007,
5161 "track-select-track-70": 41008,
5162 "track-select-track-71": 41009,
5163 "track-select-track-72": 41010,
5164 "track-select-track-73": 41011,
5165 "track-select-track-74": 41012,
5166 "track-select-track-75": 41013,
5167 "track-select-track-76": 41014,
5168 "track-select-track-77": 41015,
5169 "track-select-track-78": 41016,
5170 "track-select-track-79": 41017,
5171 "track-select-track-80": 41018,
5172 "track-select-track-81": 41019,
5173 "track-select-track-82": 41020,
5174 "track-select-track-83": 41021,
5175 "track-select-track-84": 41022,
5176 "track-select-track-85": 41023,
5177 "track-select-track-86": 41024,
5178 "track-select-track-87": 41025,
5179 "track-select-track-88": 41026,
5180 "track-select-track-89": 41027,
5181 "track-select-track-90": 41028,
5182 "track-select-track-91": 41029,
5183 "track-select-track-92": 41030,
5184 "track-select-track-93": 41031,
5185 "track-select-track-94": 41032,
5186 "track-select-track-95": 41033,
5187 "track-select-track-96": 41034,
5188 "track-select-track-97": 41035,
5189 "track-select-track-98": 41036,
5190 "track-select-track-99": 41037,
5191 "track-select-track-under-mouse": 41110,
5192 "track-select-volume-envelope": 41866,
5193 "track-select-width-envelope": 41870,
5194 "track-set-all-fx-offline-for-selected-tracks": 40535,
5195 "track-set-all-fx-online-for-selected-tracks": 40536,
5196 "track-set-automatic-record-arm-when-track-selected": 40737,
5197 "track-set-big-fixed-lanes-for-all-fixed-lane-tracks-in-the-project": 42963,
5198 "track-set-clear-all-tracks-automatic-record-arm": 40740,
5199 "track-set-first-selected-track-as-last-touched-track": 40914,
5200 "track-set-meters-to-combined-rms": 42443,
5201 "track-set-meters-to-lufs-m-momentary-loudness": 42444,
5202 "track-set-meters-to-lufs-s-short-term-loudness-readout-current": 42451,
5203 "track-set-meters-to-lufs-s-short-term-loudness-readout-max": 42445,
5204 "track-set-meters-to-multichannel-peaks": 42450,
5205 "track-set-meters-to-stereo-peaks": 42442,
5206 "track-set-meters-to-stereo-rms": 42446,
5207 "track-set-midi-input-quantize-to-1-16-for-all-tracks": 42047,
5208 "track-set-midi-input-quantize-to-1-16-for-last-touched-track": 42055,
5209 "track-set-midi-input-quantize-to-1-16-for-selected-tracks": 42039,
5210 "track-set-midi-input-quantize-to-1-16-triplet-for-all-tracks": 42046,
5211 "track-set-midi-input-quantize-to-1-16-triplet-for-last-touched-track": 42054,
5212 "track-set-midi-input-quantize-to-1-16-triplet-for-selected-tracks": 42038,
5213 "track-set-midi-input-quantize-to-1-32-for-all-tracks": 42045,
5214 "track-set-midi-input-quantize-to-1-32-for-last-touched-track": 42053,
5215 "track-set-midi-input-quantize-to-1-32-for-selected-tracks": 42037,
5216 "track-set-midi-input-quantize-to-1-4-for-all-tracks": 42051,
5217 "track-set-midi-input-quantize-to-1-4-for-last-touched-track": 42059,
5218 "track-set-midi-input-quantize-to-1-4-for-selected-tracks": 42043,
5219 "track-set-midi-input-quantize-to-1-4-triplet-for-all-tracks": 42050,
5220 "track-set-midi-input-quantize-to-1-4-triplet-for-last-touched-track": 42058,
5221 "track-set-midi-input-quantize-to-1-4-triplet-for-selected-tracks": 42042,
5222 "track-set-midi-input-quantize-to-1-64-for-all-tracks": 42044,
5223 "track-set-midi-input-quantize-to-1-64-for-last-touched-track": 42052,
5224 "track-set-midi-input-quantize-to-1-64-for-selected-tracks": 42036,
5225 "track-set-midi-input-quantize-to-1-8-for-all-tracks": 42049,
5226 "track-set-midi-input-quantize-to-1-8-for-last-touched-track": 42057,
5227 "track-set-midi-input-quantize-to-1-8-for-selected-tracks": 42041,
5228 "track-set-midi-input-quantize-to-1-8-triplet-for-all-tracks": 42048,
5229 "track-set-midi-input-quantize-to-1-8-triplet-for-last-touched-track": 42056,
5230 "track-set-midi-input-quantize-to-1-8-triplet-for-selected-tracks": 42040,
5231 "track-set-midi-input-quantize-to-grid-for-all-tracks": 42061,
5232 "track-set-midi-input-quantize-to-grid-for-last-touched-track": 42062,
5233 "track-set-midi-input-quantize-to-grid-for-selected-tracks": 42060,
5234 "track-set-mute-for-last-touched-track-midi-cc-osc-only": 818,
5235 "track-set-mute-for-master-track-midi-cc-osc-only": 18,
5236 "track-set-mute-for-selected-tracks-midi-cc-osc-only": 10,
5237 "track-set-mute-for-track-01-midi-cc-osc-only": 26,
5238 "track-set-mute-for-track-02-midi-cc-osc-only": 34,
5239 "track-set-mute-for-track-03-midi-cc-osc-only": 42,
5240 "track-set-mute-for-track-04-midi-cc-osc-only": 50,
5241 "track-set-mute-for-track-05-midi-cc-osc-only": 58,
5242 "track-set-mute-for-track-06-midi-cc-osc-only": 66,
5243 "track-set-mute-for-track-07-midi-cc-osc-only": 74,
5244 "track-set-mute-for-track-08-midi-cc-osc-only": 82,
5245 "track-set-mute-for-track-09-midi-cc-osc-only": 90,
5246 "track-set-mute-for-track-10-midi-cc-osc-only": 98,
5247 "track-set-mute-for-track-11-midi-cc-osc-only": 106,
5248 "track-set-mute-for-track-12-midi-cc-osc-only": 114,
5249 "track-set-mute-for-track-13-midi-cc-osc-only": 122,
5250 "track-set-mute-for-track-14-midi-cc-osc-only": 130,
5251 "track-set-mute-for-track-15-midi-cc-osc-only": 138,
5252 "track-set-mute-for-track-16-midi-cc-osc-only": 146,
5253 "track-set-mute-for-track-17-midi-cc-osc-only": 154,
5254 "track-set-mute-for-track-18-midi-cc-osc-only": 162,
5255 "track-set-mute-for-track-19-midi-cc-osc-only": 170,
5256 "track-set-mute-for-track-20-midi-cc-osc-only": 178,
5257 "track-set-mute-for-track-21-midi-cc-osc-only": 186,
5258 "track-set-mute-for-track-22-midi-cc-osc-only": 194,
5259 "track-set-mute-for-track-23-midi-cc-osc-only": 202,
5260 "track-set-mute-for-track-24-midi-cc-osc-only": 210,
5261 "track-set-mute-for-track-25-midi-cc-osc-only": 218,
5262 "track-set-mute-for-track-26-midi-cc-osc-only": 226,
5263 "track-set-mute-for-track-27-midi-cc-osc-only": 234,
5264 "track-set-mute-for-track-28-midi-cc-osc-only": 242,
5265 "track-set-mute-for-track-29-midi-cc-osc-only": 250,
5266 "track-set-mute-for-track-30-midi-cc-osc-only": 258,
5267 "track-set-mute-for-track-31-midi-cc-osc-only": 266,
5268 "track-set-mute-for-track-32-midi-cc-osc-only": 274,
5269 "track-set-mute-for-track-33-midi-cc-osc-only": 282,
5270 "track-set-mute-for-track-34-midi-cc-osc-only": 290,
5271 "track-set-mute-for-track-35-midi-cc-osc-only": 298,
5272 "track-set-mute-for-track-36-midi-cc-osc-only": 306,
5273 "track-set-mute-for-track-37-midi-cc-osc-only": 314,
5274 "track-set-mute-for-track-38-midi-cc-osc-only": 322,
5275 "track-set-mute-for-track-39-midi-cc-osc-only": 330,
5276 "track-set-mute-for-track-40-midi-cc-osc-only": 338,
5277 "track-set-mute-for-track-41-midi-cc-osc-only": 346,
5278 "track-set-mute-for-track-42-midi-cc-osc-only": 354,
5279 "track-set-mute-for-track-43-midi-cc-osc-only": 362,
5280 "track-set-mute-for-track-44-midi-cc-osc-only": 370,
5281 "track-set-mute-for-track-45-midi-cc-osc-only": 378,
5282 "track-set-mute-for-track-46-midi-cc-osc-only": 386,
5283 "track-set-mute-for-track-47-midi-cc-osc-only": 394,
5284 "track-set-mute-for-track-48-midi-cc-osc-only": 402,
5285 "track-set-mute-for-track-49-midi-cc-osc-only": 410,
5286 "track-set-mute-for-track-50-midi-cc-osc-only": 418,
5287 "track-set-mute-for-track-51-midi-cc-osc-only": 426,
5288 "track-set-mute-for-track-52-midi-cc-osc-only": 434,
5289 "track-set-mute-for-track-53-midi-cc-osc-only": 442,
5290 "track-set-mute-for-track-54-midi-cc-osc-only": 450,
5291 "track-set-mute-for-track-55-midi-cc-osc-only": 458,
5292 "track-set-mute-for-track-56-midi-cc-osc-only": 466,
5293 "track-set-mute-for-track-57-midi-cc-osc-only": 474,
5294 "track-set-mute-for-track-58-midi-cc-osc-only": 482,
5295 "track-set-mute-for-track-59-midi-cc-osc-only": 490,
5296 "track-set-mute-for-track-60-midi-cc-osc-only": 498,
5297 "track-set-mute-for-track-61-midi-cc-osc-only": 506,
5298 "track-set-mute-for-track-62-midi-cc-osc-only": 514,
5299 "track-set-mute-for-track-63-midi-cc-osc-only": 522,
5300 "track-set-mute-for-track-64-midi-cc-osc-only": 530,
5301 "track-set-mute-for-track-65-midi-cc-osc-only": 538,
5302 "track-set-mute-for-track-66-midi-cc-osc-only": 546,
5303 "track-set-mute-for-track-67-midi-cc-osc-only": 554,
5304 "track-set-mute-for-track-68-midi-cc-osc-only": 562,
5305 "track-set-mute-for-track-69-midi-cc-osc-only": 570,
5306 "track-set-mute-for-track-70-midi-cc-osc-only": 578,
5307 "track-set-mute-for-track-71-midi-cc-osc-only": 586,
5308 "track-set-mute-for-track-72-midi-cc-osc-only": 594,
5309 "track-set-mute-for-track-73-midi-cc-osc-only": 602,
5310 "track-set-mute-for-track-74-midi-cc-osc-only": 610,
5311 "track-set-mute-for-track-75-midi-cc-osc-only": 618,
5312 "track-set-mute-for-track-76-midi-cc-osc-only": 626,
5313 "track-set-mute-for-track-77-midi-cc-osc-only": 634,
5314 "track-set-mute-for-track-78-midi-cc-osc-only": 642,
5315 "track-set-mute-for-track-79-midi-cc-osc-only": 650,
5316 "track-set-mute-for-track-80-midi-cc-osc-only": 658,
5317 "track-set-mute-for-track-81-midi-cc-osc-only": 666,
5318 "track-set-mute-for-track-82-midi-cc-osc-only": 674,
5319 "track-set-mute-for-track-83-midi-cc-osc-only": 682,
5320 "track-set-mute-for-track-84-midi-cc-osc-only": 690,
5321 "track-set-mute-for-track-85-midi-cc-osc-only": 698,
5322 "track-set-mute-for-track-86-midi-cc-osc-only": 706,
5323 "track-set-mute-for-track-87-midi-cc-osc-only": 714,
5324 "track-set-mute-for-track-88-midi-cc-osc-only": 722,
5325 "track-set-mute-for-track-89-midi-cc-osc-only": 730,
5326 "track-set-mute-for-track-90-midi-cc-osc-only": 738,
5327 "track-set-mute-for-track-91-midi-cc-osc-only": 746,
5328 "track-set-mute-for-track-92-midi-cc-osc-only": 754,
5329 "track-set-mute-for-track-93-midi-cc-osc-only": 762,
5330 "track-set-mute-for-track-94-midi-cc-osc-only": 770,
5331 "track-set-mute-for-track-95-midi-cc-osc-only": 778,
5332 "track-set-mute-for-track-96-midi-cc-osc-only": 786,
5333 "track-set-mute-for-track-97-midi-cc-osc-only": 794,
5334 "track-set-mute-for-track-98-midi-cc-osc-only": 802,
5335 "track-set-mute-for-track-99-midi-cc-osc-only": 810,
5336 "track-set-pan-for-last-touched-track-midi-cc-osc-only": 813,
5337 "track-set-pan-for-master-track-midi-cc-osc-only": 13,
5338 "track-set-pan-for-selected-tracks-midi-cc-osc-only": 5,
5339 "track-set-pan-for-track-01-midi-cc-osc-only": 21,
5340 "track-set-pan-for-track-02-midi-cc-osc-only": 29,
5341 "track-set-pan-for-track-03-midi-cc-osc-only": 37,
5342 "track-set-pan-for-track-04-midi-cc-osc-only": 45,
5343 "track-set-pan-for-track-05-midi-cc-osc-only": 53,
5344 "track-set-pan-for-track-06-midi-cc-osc-only": 61,
5345 "track-set-pan-for-track-07-midi-cc-osc-only": 69,
5346 "track-set-pan-for-track-08-midi-cc-osc-only": 77,
5347 "track-set-pan-for-track-09-midi-cc-osc-only": 85,
5348 "track-set-pan-for-track-10-midi-cc-osc-only": 93,
5349 "track-set-pan-for-track-11-midi-cc-osc-only": 101,
5350 "track-set-pan-for-track-12-midi-cc-osc-only": 109,
5351 "track-set-pan-for-track-13-midi-cc-osc-only": 117,
5352 "track-set-pan-for-track-14-midi-cc-osc-only": 125,
5353 "track-set-pan-for-track-15-midi-cc-osc-only": 133,
5354 "track-set-pan-for-track-16-midi-cc-osc-only": 141,
5355 "track-set-pan-for-track-17-midi-cc-osc-only": 149,
5356 "track-set-pan-for-track-18-midi-cc-osc-only": 157,
5357 "track-set-pan-for-track-19-midi-cc-osc-only": 165,
5358 "track-set-pan-for-track-20-midi-cc-osc-only": 173,
5359 "track-set-pan-for-track-21-midi-cc-osc-only": 181,
5360 "track-set-pan-for-track-22-midi-cc-osc-only": 189,
5361 "track-set-pan-for-track-23-midi-cc-osc-only": 197,
5362 "track-set-pan-for-track-24-midi-cc-osc-only": 205,
5363 "track-set-pan-for-track-25-midi-cc-osc-only": 213,
5364 "track-set-pan-for-track-26-midi-cc-osc-only": 221,
5365 "track-set-pan-for-track-27-midi-cc-osc-only": 229,
5366 "track-set-pan-for-track-28-midi-cc-osc-only": 237,
5367 "track-set-pan-for-track-29-midi-cc-osc-only": 245,
5368 "track-set-pan-for-track-30-midi-cc-osc-only": 253,
5369 "track-set-pan-for-track-31-midi-cc-osc-only": 261,
5370 "track-set-pan-for-track-32-midi-cc-osc-only": 269,
5371 "track-set-pan-for-track-33-midi-cc-osc-only": 277,
5372 "track-set-pan-for-track-34-midi-cc-osc-only": 285,
5373 "track-set-pan-for-track-35-midi-cc-osc-only": 293,
5374 "track-set-pan-for-track-36-midi-cc-osc-only": 301,
5375 "track-set-pan-for-track-37-midi-cc-osc-only": 309,
5376 "track-set-pan-for-track-38-midi-cc-osc-only": 317,
5377 "track-set-pan-for-track-39-midi-cc-osc-only": 325,
5378 "track-set-pan-for-track-40-midi-cc-osc-only": 333,
5379 "track-set-pan-for-track-41-midi-cc-osc-only": 341,
5380 "track-set-pan-for-track-42-midi-cc-osc-only": 349,
5381 "track-set-pan-for-track-43-midi-cc-osc-only": 357,
5382 "track-set-pan-for-track-44-midi-cc-osc-only": 365,
5383 "track-set-pan-for-track-45-midi-cc-osc-only": 373,
5384 "track-set-pan-for-track-46-midi-cc-osc-only": 381,
5385 "track-set-pan-for-track-47-midi-cc-osc-only": 389,
5386 "track-set-pan-for-track-48-midi-cc-osc-only": 397,
5387 "track-set-pan-for-track-49-midi-cc-osc-only": 405,
5388 "track-set-pan-for-track-50-midi-cc-osc-only": 413,
5389 "track-set-pan-for-track-51-midi-cc-osc-only": 421,
5390 "track-set-pan-for-track-52-midi-cc-osc-only": 429,
5391 "track-set-pan-for-track-53-midi-cc-osc-only": 437,
5392 "track-set-pan-for-track-54-midi-cc-osc-only": 445,
5393 "track-set-pan-for-track-55-midi-cc-osc-only": 453,
5394 "track-set-pan-for-track-56-midi-cc-osc-only": 461,
5395 "track-set-pan-for-track-57-midi-cc-osc-only": 469,
5396 "track-set-pan-for-track-58-midi-cc-osc-only": 477,
5397 "track-set-pan-for-track-59-midi-cc-osc-only": 485,
5398 "track-set-pan-for-track-60-midi-cc-osc-only": 493,
5399 "track-set-pan-for-track-61-midi-cc-osc-only": 501,
5400 "track-set-pan-for-track-62-midi-cc-osc-only": 509,
5401 "track-set-pan-for-track-63-midi-cc-osc-only": 517,
5402 "track-set-pan-for-track-64-midi-cc-osc-only": 525,
5403 "track-set-pan-for-track-65-midi-cc-osc-only": 533,
5404 "track-set-pan-for-track-66-midi-cc-osc-only": 541,
5405 "track-set-pan-for-track-67-midi-cc-osc-only": 549,
5406 "track-set-pan-for-track-68-midi-cc-osc-only": 557,
5407 "track-set-pan-for-track-69-midi-cc-osc-only": 565,
5408 "track-set-pan-for-track-70-midi-cc-osc-only": 573,
5409 "track-set-pan-for-track-71-midi-cc-osc-only": 581,
5410 "track-set-pan-for-track-72-midi-cc-osc-only": 589,
5411 "track-set-pan-for-track-73-midi-cc-osc-only": 597,
5412 "track-set-pan-for-track-74-midi-cc-osc-only": 605,
5413 "track-set-pan-for-track-75-midi-cc-osc-only": 613,
5414 "track-set-pan-for-track-76-midi-cc-osc-only": 621,
5415 "track-set-pan-for-track-77-midi-cc-osc-only": 629,
5416 "track-set-pan-for-track-78-midi-cc-osc-only": 637,
5417 "track-set-pan-for-track-79-midi-cc-osc-only": 645,
5418 "track-set-pan-for-track-80-midi-cc-osc-only": 653,
5419 "track-set-pan-for-track-81-midi-cc-osc-only": 661,
5420 "track-set-pan-for-track-82-midi-cc-osc-only": 669,
5421 "track-set-pan-for-track-83-midi-cc-osc-only": 677,
5422 "track-set-pan-for-track-84-midi-cc-osc-only": 685,
5423 "track-set-pan-for-track-85-midi-cc-osc-only": 693,
5424 "track-set-pan-for-track-86-midi-cc-osc-only": 701,
5425 "track-set-pan-for-track-87-midi-cc-osc-only": 709,
5426 "track-set-pan-for-track-88-midi-cc-osc-only": 717,
5427 "track-set-pan-for-track-89-midi-cc-osc-only": 725,
5428 "track-set-pan-for-track-90-midi-cc-osc-only": 733,
5429 "track-set-pan-for-track-91-midi-cc-osc-only": 741,
5430 "track-set-pan-for-track-92-midi-cc-osc-only": 749,
5431 "track-set-pan-for-track-93-midi-cc-osc-only": 757,
5432 "track-set-pan-for-track-94-midi-cc-osc-only": 765,
5433 "track-set-pan-for-track-95-midi-cc-osc-only": 773,
5434 "track-set-pan-for-track-96-midi-cc-osc-only": 781,
5435 "track-set-pan-for-track-97-midi-cc-osc-only": 789,
5436 "track-set-pan-for-track-98-midi-cc-osc-only": 797,
5437 "track-set-pan-for-track-99-midi-cc-osc-only": 805,
5438 "track-set-preserve-pdc-delayed-monitoring-in-recorded-items": 41921,
5439 "track-set-record-path-to-primary": 41321,
5440 "track-set-record-path-to-primary-plus-secondary": 41323,
5441 "track-set-record-path-to-secondary": 41322,
5442 "track-set-small-fixed-lanes-for-all-fixed-lane-tracks-in-the-project": 42962,
5443 "track-set-solo-for-last-touched-track-midi-cc-osc-only": 819,
5444 "track-set-solo-for-master-track-midi-cc-osc-only": 19,
5445 "track-set-solo-for-selected-tracks-midi-cc-osc-only": 11,
5446 "track-set-solo-for-track-01-midi-cc-osc-only": 27,
5447 "track-set-solo-for-track-02-midi-cc-osc-only": 35,
5448 "track-set-solo-for-track-03-midi-cc-osc-only": 43,
5449 "track-set-solo-for-track-04-midi-cc-osc-only": 51,
5450 "track-set-solo-for-track-05-midi-cc-osc-only": 59,
5451 "track-set-solo-for-track-06-midi-cc-osc-only": 67,
5452 "track-set-solo-for-track-07-midi-cc-osc-only": 75,
5453 "track-set-solo-for-track-08-midi-cc-osc-only": 83,
5454 "track-set-solo-for-track-09-midi-cc-osc-only": 91,
5455 "track-set-solo-for-track-10-midi-cc-osc-only": 99,
5456 "track-set-solo-for-track-11-midi-cc-osc-only": 107,
5457 "track-set-solo-for-track-12-midi-cc-osc-only": 115,
5458 "track-set-solo-for-track-13-midi-cc-osc-only": 123,
5459 "track-set-solo-for-track-14-midi-cc-osc-only": 131,
5460 "track-set-solo-for-track-15-midi-cc-osc-only": 139,
5461 "track-set-solo-for-track-16-midi-cc-osc-only": 147,
5462 "track-set-solo-for-track-17-midi-cc-osc-only": 155,
5463 "track-set-solo-for-track-18-midi-cc-osc-only": 163,
5464 "track-set-solo-for-track-19-midi-cc-osc-only": 171,
5465 "track-set-solo-for-track-20-midi-cc-osc-only": 179,
5466 "track-set-solo-for-track-21-midi-cc-osc-only": 187,
5467 "track-set-solo-for-track-22-midi-cc-osc-only": 195,
5468 "track-set-solo-for-track-23-midi-cc-osc-only": 203,
5469 "track-set-solo-for-track-24-midi-cc-osc-only": 211,
5470 "track-set-solo-for-track-25-midi-cc-osc-only": 219,
5471 "track-set-solo-for-track-26-midi-cc-osc-only": 227,
5472 "track-set-solo-for-track-27-midi-cc-osc-only": 235,
5473 "track-set-solo-for-track-28-midi-cc-osc-only": 243,
5474 "track-set-solo-for-track-29-midi-cc-osc-only": 251,
5475 "track-set-solo-for-track-30-midi-cc-osc-only": 259,
5476 "track-set-solo-for-track-31-midi-cc-osc-only": 267,
5477 "track-set-solo-for-track-32-midi-cc-osc-only": 275,
5478 "track-set-solo-for-track-33-midi-cc-osc-only": 283,
5479 "track-set-solo-for-track-34-midi-cc-osc-only": 291,
5480 "track-set-solo-for-track-35-midi-cc-osc-only": 299,
5481 "track-set-solo-for-track-36-midi-cc-osc-only": 307,
5482 "track-set-solo-for-track-37-midi-cc-osc-only": 315,
5483 "track-set-solo-for-track-38-midi-cc-osc-only": 323,
5484 "track-set-solo-for-track-39-midi-cc-osc-only": 331,
5485 "track-set-solo-for-track-40-midi-cc-osc-only": 339,
5486 "track-set-solo-for-track-41-midi-cc-osc-only": 347,
5487 "track-set-solo-for-track-42-midi-cc-osc-only": 355,
5488 "track-set-solo-for-track-43-midi-cc-osc-only": 363,
5489 "track-set-solo-for-track-44-midi-cc-osc-only": 371,
5490 "track-set-solo-for-track-45-midi-cc-osc-only": 379,
5491 "track-set-solo-for-track-46-midi-cc-osc-only": 387,
5492 "track-set-solo-for-track-47-midi-cc-osc-only": 395,
5493 "track-set-solo-for-track-48-midi-cc-osc-only": 403,
5494 "track-set-solo-for-track-49-midi-cc-osc-only": 411,
5495 "track-set-solo-for-track-50-midi-cc-osc-only": 419,
5496 "track-set-solo-for-track-51-midi-cc-osc-only": 427,
5497 "track-set-solo-for-track-52-midi-cc-osc-only": 435,
5498 "track-set-solo-for-track-53-midi-cc-osc-only": 443,
5499 "track-set-solo-for-track-54-midi-cc-osc-only": 451,
5500 "track-set-solo-for-track-55-midi-cc-osc-only": 459,
5501 "track-set-solo-for-track-56-midi-cc-osc-only": 467,
5502 "track-set-solo-for-track-57-midi-cc-osc-only": 475,
5503 "track-set-solo-for-track-58-midi-cc-osc-only": 483,
5504 "track-set-solo-for-track-59-midi-cc-osc-only": 491,
5505 "track-set-solo-for-track-60-midi-cc-osc-only": 499,
5506 "track-set-solo-for-track-61-midi-cc-osc-only": 507,
5507 "track-set-solo-for-track-62-midi-cc-osc-only": 515,
5508 "track-set-solo-for-track-63-midi-cc-osc-only": 523,
5509 "track-set-solo-for-track-64-midi-cc-osc-only": 531,
5510 "track-set-solo-for-track-65-midi-cc-osc-only": 539,
5511 "track-set-solo-for-track-66-midi-cc-osc-only": 547,
5512 "track-set-solo-for-track-67-midi-cc-osc-only": 555,
5513 "track-set-solo-for-track-68-midi-cc-osc-only": 563,
5514 "track-set-solo-for-track-69-midi-cc-osc-only": 571,
5515 "track-set-solo-for-track-70-midi-cc-osc-only": 579,
5516 "track-set-solo-for-track-71-midi-cc-osc-only": 587,
5517 "track-set-solo-for-track-72-midi-cc-osc-only": 595,
5518 "track-set-solo-for-track-73-midi-cc-osc-only": 603,
5519 "track-set-solo-for-track-74-midi-cc-osc-only": 611,
5520 "track-set-solo-for-track-75-midi-cc-osc-only": 619,
5521 "track-set-solo-for-track-76-midi-cc-osc-only": 627,
5522 "track-set-solo-for-track-77-midi-cc-osc-only": 635,
5523 "track-set-solo-for-track-78-midi-cc-osc-only": 643,
5524 "track-set-solo-for-track-79-midi-cc-osc-only": 651,
5525 "track-set-solo-for-track-80-midi-cc-osc-only": 659,
5526 "track-set-solo-for-track-81-midi-cc-osc-only": 667,
5527 "track-set-solo-for-track-82-midi-cc-osc-only": 675,
5528 "track-set-solo-for-track-83-midi-cc-osc-only": 683,
5529 "track-set-solo-for-track-84-midi-cc-osc-only": 691,
5530 "track-set-solo-for-track-85-midi-cc-osc-only": 699,
5531 "track-set-solo-for-track-86-midi-cc-osc-only": 707,
5532 "track-set-solo-for-track-87-midi-cc-osc-only": 715,
5533 "track-set-solo-for-track-88-midi-cc-osc-only": 723,
5534 "track-set-solo-for-track-89-midi-cc-osc-only": 731,
5535 "track-set-solo-for-track-90-midi-cc-osc-only": 739,
5536 "track-set-solo-for-track-91-midi-cc-osc-only": 747,
5537 "track-set-solo-for-track-92-midi-cc-osc-only": 755,
5538 "track-set-solo-for-track-93-midi-cc-osc-only": 763,
5539 "track-set-solo-for-track-94-midi-cc-osc-only": 771,
5540 "track-set-solo-for-track-95-midi-cc-osc-only": 779,
5541 "track-set-solo-for-track-96-midi-cc-osc-only": 787,
5542 "track-set-solo-for-track-97-midi-cc-osc-only": 795,
5543 "track-set-solo-for-track-98-midi-cc-osc-only": 803,
5544 "track-set-solo-for-track-99-midi-cc-osc-only": 811,
5545 "track-set-stereo-width-or-right-channel-pan-for-last-touched-track-midi-cc-osc-only": 30101,
5546 "track-set-stereo-width-or-right-channel-pan-for-master-track-midi-cc-osc-only": 30001,
5547 "track-set-stereo-width-or-right-channel-pan-for-selected-tracks-midi-cc-osc-only": 30000,
5548 "track-set-stereo-width-or-right-channel-pan-for-track-01-midi-cc-osc-only": 30002,
5549 "track-set-stereo-width-or-right-channel-pan-for-track-02-midi-cc-osc-only": 30003,
5550 "track-set-stereo-width-or-right-channel-pan-for-track-03-midi-cc-osc-only": 30004,
5551 "track-set-stereo-width-or-right-channel-pan-for-track-04-midi-cc-osc-only": 30005,
5552 "track-set-stereo-width-or-right-channel-pan-for-track-05-midi-cc-osc-only": 30006,
5553 "track-set-stereo-width-or-right-channel-pan-for-track-06-midi-cc-osc-only": 30007,
5554 "track-set-stereo-width-or-right-channel-pan-for-track-07-midi-cc-osc-only": 30008,
5555 "track-set-stereo-width-or-right-channel-pan-for-track-08-midi-cc-osc-only": 30009,
5556 "track-set-stereo-width-or-right-channel-pan-for-track-09-midi-cc-osc-only": 30010,
5557 "track-set-stereo-width-or-right-channel-pan-for-track-10-midi-cc-osc-only": 30011,
5558 "track-set-stereo-width-or-right-channel-pan-for-track-11-midi-cc-osc-only": 30012,
5559 "track-set-stereo-width-or-right-channel-pan-for-track-12-midi-cc-osc-only": 30013,
5560 "track-set-stereo-width-or-right-channel-pan-for-track-13-midi-cc-osc-only": 30014,
5561 "track-set-stereo-width-or-right-channel-pan-for-track-14-midi-cc-osc-only": 30015,
5562 "track-set-stereo-width-or-right-channel-pan-for-track-15-midi-cc-osc-only": 30016,
5563 "track-set-stereo-width-or-right-channel-pan-for-track-16-midi-cc-osc-only": 30017,
5564 "track-set-stereo-width-or-right-channel-pan-for-track-17-midi-cc-osc-only": 30018,
5565 "track-set-stereo-width-or-right-channel-pan-for-track-18-midi-cc-osc-only": 30019,
5566 "track-set-stereo-width-or-right-channel-pan-for-track-19-midi-cc-osc-only": 30020,
5567 "track-set-stereo-width-or-right-channel-pan-for-track-20-midi-cc-osc-only": 30021,
5568 "track-set-stereo-width-or-right-channel-pan-for-track-21-midi-cc-osc-only": 30022,
5569 "track-set-stereo-width-or-right-channel-pan-for-track-22-midi-cc-osc-only": 30023,
5570 "track-set-stereo-width-or-right-channel-pan-for-track-23-midi-cc-osc-only": 30024,
5571 "track-set-stereo-width-or-right-channel-pan-for-track-24-midi-cc-osc-only": 30025,
5572 "track-set-stereo-width-or-right-channel-pan-for-track-25-midi-cc-osc-only": 30026,
5573 "track-set-stereo-width-or-right-channel-pan-for-track-26-midi-cc-osc-only": 30027,
5574 "track-set-stereo-width-or-right-channel-pan-for-track-27-midi-cc-osc-only": 30028,
5575 "track-set-stereo-width-or-right-channel-pan-for-track-28-midi-cc-osc-only": 30029,
5576 "track-set-stereo-width-or-right-channel-pan-for-track-29-midi-cc-osc-only": 30030,
5577 "track-set-stereo-width-or-right-channel-pan-for-track-30-midi-cc-osc-only": 30031,
5578 "track-set-stereo-width-or-right-channel-pan-for-track-31-midi-cc-osc-only": 30032,
5579 "track-set-stereo-width-or-right-channel-pan-for-track-32-midi-cc-osc-only": 30033,
5580 "track-set-stereo-width-or-right-channel-pan-for-track-33-midi-cc-osc-only": 30034,
5581 "track-set-stereo-width-or-right-channel-pan-for-track-34-midi-cc-osc-only": 30035,
5582 "track-set-stereo-width-or-right-channel-pan-for-track-35-midi-cc-osc-only": 30036,
5583 "track-set-stereo-width-or-right-channel-pan-for-track-36-midi-cc-osc-only": 30037,
5584 "track-set-stereo-width-or-right-channel-pan-for-track-37-midi-cc-osc-only": 30038,
5585 "track-set-stereo-width-or-right-channel-pan-for-track-38-midi-cc-osc-only": 30039,
5586 "track-set-stereo-width-or-right-channel-pan-for-track-39-midi-cc-osc-only": 30040,
5587 "track-set-stereo-width-or-right-channel-pan-for-track-40-midi-cc-osc-only": 30041,
5588 "track-set-stereo-width-or-right-channel-pan-for-track-41-midi-cc-osc-only": 30042,
5589 "track-set-stereo-width-or-right-channel-pan-for-track-42-midi-cc-osc-only": 30043,
5590 "track-set-stereo-width-or-right-channel-pan-for-track-43-midi-cc-osc-only": 30044,
5591 "track-set-stereo-width-or-right-channel-pan-for-track-44-midi-cc-osc-only": 30045,
5592 "track-set-stereo-width-or-right-channel-pan-for-track-45-midi-cc-osc-only": 30046,
5593 "track-set-stereo-width-or-right-channel-pan-for-track-46-midi-cc-osc-only": 30047,
5594 "track-set-stereo-width-or-right-channel-pan-for-track-47-midi-cc-osc-only": 30048,
5595 "track-set-stereo-width-or-right-channel-pan-for-track-48-midi-cc-osc-only": 30049,
5596 "track-set-stereo-width-or-right-channel-pan-for-track-49-midi-cc-osc-only": 30050,
5597 "track-set-stereo-width-or-right-channel-pan-for-track-50-midi-cc-osc-only": 30051,
5598 "track-set-stereo-width-or-right-channel-pan-for-track-51-midi-cc-osc-only": 30052,
5599 "track-set-stereo-width-or-right-channel-pan-for-track-52-midi-cc-osc-only": 30053,
5600 "track-set-stereo-width-or-right-channel-pan-for-track-53-midi-cc-osc-only": 30054,
5601 "track-set-stereo-width-or-right-channel-pan-for-track-54-midi-cc-osc-only": 30055,
5602 "track-set-stereo-width-or-right-channel-pan-for-track-55-midi-cc-osc-only": 30056,
5603 "track-set-stereo-width-or-right-channel-pan-for-track-56-midi-cc-osc-only": 30057,
5604 "track-set-stereo-width-or-right-channel-pan-for-track-57-midi-cc-osc-only": 30058,
5605 "track-set-stereo-width-or-right-channel-pan-for-track-58-midi-cc-osc-only": 30059,
5606 "track-set-stereo-width-or-right-channel-pan-for-track-59-midi-cc-osc-only": 30060,
5607 "track-set-stereo-width-or-right-channel-pan-for-track-60-midi-cc-osc-only": 30061,
5608 "track-set-stereo-width-or-right-channel-pan-for-track-61-midi-cc-osc-only": 30062,
5609 "track-set-stereo-width-or-right-channel-pan-for-track-62-midi-cc-osc-only": 30063,
5610 "track-set-stereo-width-or-right-channel-pan-for-track-63-midi-cc-osc-only": 30064,
5611 "track-set-stereo-width-or-right-channel-pan-for-track-64-midi-cc-osc-only": 30065,
5612 "track-set-stereo-width-or-right-channel-pan-for-track-65-midi-cc-osc-only": 30066,
5613 "track-set-stereo-width-or-right-channel-pan-for-track-66-midi-cc-osc-only": 30067,
5614 "track-set-stereo-width-or-right-channel-pan-for-track-67-midi-cc-osc-only": 30068,
5615 "track-set-stereo-width-or-right-channel-pan-for-track-68-midi-cc-osc-only": 30069,
5616 "track-set-stereo-width-or-right-channel-pan-for-track-69-midi-cc-osc-only": 30070,
5617 "track-set-stereo-width-or-right-channel-pan-for-track-70-midi-cc-osc-only": 30071,
5618 "track-set-stereo-width-or-right-channel-pan-for-track-71-midi-cc-osc-only": 30072,
5619 "track-set-stereo-width-or-right-channel-pan-for-track-72-midi-cc-osc-only": 30073,
5620 "track-set-stereo-width-or-right-channel-pan-for-track-73-midi-cc-osc-only": 30074,
5621 "track-set-stereo-width-or-right-channel-pan-for-track-74-midi-cc-osc-only": 30075,
5622 "track-set-stereo-width-or-right-channel-pan-for-track-75-midi-cc-osc-only": 30076,
5623 "track-set-stereo-width-or-right-channel-pan-for-track-76-midi-cc-osc-only": 30077,
5624 "track-set-stereo-width-or-right-channel-pan-for-track-77-midi-cc-osc-only": 30078,
5625 "track-set-stereo-width-or-right-channel-pan-for-track-78-midi-cc-osc-only": 30079,
5626 "track-set-stereo-width-or-right-channel-pan-for-track-79-midi-cc-osc-only": 30080,
5627 "track-set-stereo-width-or-right-channel-pan-for-track-80-midi-cc-osc-only": 30081,
5628 "track-set-stereo-width-or-right-channel-pan-for-track-81-midi-cc-osc-only": 30082,
5629 "track-set-stereo-width-or-right-channel-pan-for-track-82-midi-cc-osc-only": 30083,
5630 "track-set-stereo-width-or-right-channel-pan-for-track-83-midi-cc-osc-only": 30084,
5631 "track-set-stereo-width-or-right-channel-pan-for-track-84-midi-cc-osc-only": 30085,
5632 "track-set-stereo-width-or-right-channel-pan-for-track-85-midi-cc-osc-only": 30086,
5633 "track-set-stereo-width-or-right-channel-pan-for-track-86-midi-cc-osc-only": 30087,
5634 "track-set-stereo-width-or-right-channel-pan-for-track-87-midi-cc-osc-only": 30088,
5635 "track-set-stereo-width-or-right-channel-pan-for-track-88-midi-cc-osc-only": 30089,
5636 "track-set-stereo-width-or-right-channel-pan-for-track-89-midi-cc-osc-only": 30090,
5637 "track-set-stereo-width-or-right-channel-pan-for-track-90-midi-cc-osc-only": 30091,
5638 "track-set-stereo-width-or-right-channel-pan-for-track-91-midi-cc-osc-only": 30092,
5639 "track-set-stereo-width-or-right-channel-pan-for-track-92-midi-cc-osc-only": 30093,
5640 "track-set-stereo-width-or-right-channel-pan-for-track-93-midi-cc-osc-only": 30094,
5641 "track-set-stereo-width-or-right-channel-pan-for-track-94-midi-cc-osc-only": 30095,
5642 "track-set-stereo-width-or-right-channel-pan-for-track-95-midi-cc-osc-only": 30096,
5643 "track-set-stereo-width-or-right-channel-pan-for-track-96-midi-cc-osc-only": 30097,
5644 "track-set-stereo-width-or-right-channel-pan-for-track-97-midi-cc-osc-only": 30098,
5645 "track-set-stereo-width-or-right-channel-pan-for-track-98-midi-cc-osc-only": 30099,
5646 "track-set-stereo-width-or-right-channel-pan-for-track-99-midi-cc-osc-only": 30100,
5647 "track-set-to-custom-color": 40357,
5648 "track-set-to-default-color": 40359,
5649 "track-set-to-one-random-color": 40360,
5650 "track-set-to-random-colors": 40358,
5651 "track-set-track-grouping-parameters": 40772,
5652 "track-set-track-icon": 40899,
5653 "track-set-track-record-mode-to-input": 40496,
5654 "track-set-track-record-mode-to-midi-latch-replace": 41727,
5655 "track-set-track-record-mode-to-midi-output": 40500,
5656 "track-set-track-record-mode-to-midi-overdub": 40503,
5657 "track-set-track-record-mode-to-midi-replace": 40504,
5658 "track-set-track-record-mode-to-midi-touch-replace": 40852,
5659 "track-set-track-record-mode-to-none-monitoring-only": 40498,
5660 "track-set-track-record-mode-to-output-full-multichannel": 40895,
5661 "track-set-track-record-mode-to-output-full-multichannel-compensated": 40896,
5662 "track-set-track-record-mode-to-output-mono": 40501,
5663 "track-set-track-record-mode-to-output-mono-latency-compensated": 40502,
5664 "track-set-track-record-mode-to-output-stereo": 40497,
5665 "track-set-track-record-mode-to-output-stereo-latency-compensated": 40499,
5666 "track-set-track-record-monitor-to-auto-tape": 40494,
5667 "track-set-track-record-monitor-to-off": 40492,
5668 "track-set-track-record-monitor-to-on": 40493,
5669 "track-set-track-record-output-mode-to-post-fader": 42225,
5670 "track-set-track-record-output-mode-to-post-fx-pre-fader": 42227,
5671 "track-set-track-record-output-mode-to-pre-fx": 42226,
5672 "track-set-track-solo-defeat": 41197,
5673 "track-set-volume-for-last-touched-track-midi-cc-osc-only": 812,
5674 "track-set-volume-for-master-track-midi-cc-osc-only": 12,
5675 "track-set-volume-for-selected-tracks-midi-cc-osc-only": 4,
5676 "track-set-volume-for-track-01-midi-cc-osc-only": 20,
5677 "track-set-volume-for-track-02-midi-cc-osc-only": 28,
5678 "track-set-volume-for-track-03-midi-cc-osc-only": 36,
5679 "track-set-volume-for-track-04-midi-cc-osc-only": 44,
5680 "track-set-volume-for-track-05-midi-cc-osc-only": 52,
5681 "track-set-volume-for-track-06-midi-cc-osc-only": 60,
5682 "track-set-volume-for-track-07-midi-cc-osc-only": 68,
5683 "track-set-volume-for-track-08-midi-cc-osc-only": 76,
5684 "track-set-volume-for-track-09-midi-cc-osc-only": 84,
5685 "track-set-volume-for-track-10-midi-cc-osc-only": 92,
5686 "track-set-volume-for-track-11-midi-cc-osc-only": 100,
5687 "track-set-volume-for-track-12-midi-cc-osc-only": 108,
5688 "track-set-volume-for-track-13-midi-cc-osc-only": 116,
5689 "track-set-volume-for-track-14-midi-cc-osc-only": 124,
5690 "track-set-volume-for-track-15-midi-cc-osc-only": 132,
5691 "track-set-volume-for-track-16-midi-cc-osc-only": 140,
5692 "track-set-volume-for-track-17-midi-cc-osc-only": 148,
5693 "track-set-volume-for-track-18-midi-cc-osc-only": 156,
5694 "track-set-volume-for-track-19-midi-cc-osc-only": 164,
5695 "track-set-volume-for-track-20-midi-cc-osc-only": 172,
5696 "track-set-volume-for-track-21-midi-cc-osc-only": 180,
5697 "track-set-volume-for-track-22-midi-cc-osc-only": 188,
5698 "track-set-volume-for-track-23-midi-cc-osc-only": 196,
5699 "track-set-volume-for-track-24-midi-cc-osc-only": 204,
5700 "track-set-volume-for-track-25-midi-cc-osc-only": 212,
5701 "track-set-volume-for-track-26-midi-cc-osc-only": 220,
5702 "track-set-volume-for-track-27-midi-cc-osc-only": 228,
5703 "track-set-volume-for-track-28-midi-cc-osc-only": 236,
5704 "track-set-volume-for-track-29-midi-cc-osc-only": 244,
5705 "track-set-volume-for-track-30-midi-cc-osc-only": 252,
5706 "track-set-volume-for-track-31-midi-cc-osc-only": 260,
5707 "track-set-volume-for-track-32-midi-cc-osc-only": 268,
5708 "track-set-volume-for-track-33-midi-cc-osc-only": 276,
5709 "track-set-volume-for-track-34-midi-cc-osc-only": 284,
5710 "track-set-volume-for-track-35-midi-cc-osc-only": 292,
5711 "track-set-volume-for-track-36-midi-cc-osc-only": 300,
5712 "track-set-volume-for-track-37-midi-cc-osc-only": 308,
5713 "track-set-volume-for-track-38-midi-cc-osc-only": 316,
5714 "track-set-volume-for-track-39-midi-cc-osc-only": 324,
5715 "track-set-volume-for-track-40-midi-cc-osc-only": 332,
5716 "track-set-volume-for-track-41-midi-cc-osc-only": 340,
5717 "track-set-volume-for-track-42-midi-cc-osc-only": 348,
5718 "track-set-volume-for-track-43-midi-cc-osc-only": 356,
5719 "track-set-volume-for-track-44-midi-cc-osc-only": 364,
5720 "track-set-volume-for-track-45-midi-cc-osc-only": 372,
5721 "track-set-volume-for-track-46-midi-cc-osc-only": 380,
5722 "track-set-volume-for-track-47-midi-cc-osc-only": 388,
5723 "track-set-volume-for-track-48-midi-cc-osc-only": 396,
5724 "track-set-volume-for-track-49-midi-cc-osc-only": 404,
5725 "track-set-volume-for-track-50-midi-cc-osc-only": 412,
5726 "track-set-volume-for-track-51-midi-cc-osc-only": 420,
5727 "track-set-volume-for-track-52-midi-cc-osc-only": 428,
5728 "track-set-volume-for-track-53-midi-cc-osc-only": 436,
5729 "track-set-volume-for-track-54-midi-cc-osc-only": 444,
5730 "track-set-volume-for-track-55-midi-cc-osc-only": 452,
5731 "track-set-volume-for-track-56-midi-cc-osc-only": 460,
5732 "track-set-volume-for-track-57-midi-cc-osc-only": 468,
5733 "track-set-volume-for-track-58-midi-cc-osc-only": 476,
5734 "track-set-volume-for-track-59-midi-cc-osc-only": 484,
5735 "track-set-volume-for-track-60-midi-cc-osc-only": 492,
5736 "track-set-volume-for-track-61-midi-cc-osc-only": 500,
5737 "track-set-volume-for-track-62-midi-cc-osc-only": 508,
5738 "track-set-volume-for-track-63-midi-cc-osc-only": 516,
5739 "track-set-volume-for-track-64-midi-cc-osc-only": 524,
5740 "track-set-volume-for-track-65-midi-cc-osc-only": 532,
5741 "track-set-volume-for-track-66-midi-cc-osc-only": 540,
5742 "track-set-volume-for-track-67-midi-cc-osc-only": 548,
5743 "track-set-volume-for-track-68-midi-cc-osc-only": 556,
5744 "track-set-volume-for-track-69-midi-cc-osc-only": 564,
5745 "track-set-volume-for-track-70-midi-cc-osc-only": 572,
5746 "track-set-volume-for-track-71-midi-cc-osc-only": 580,
5747 "track-set-volume-for-track-72-midi-cc-osc-only": 588,
5748 "track-set-volume-for-track-73-midi-cc-osc-only": 596,
5749 "track-set-volume-for-track-74-midi-cc-osc-only": 604,
5750 "track-set-volume-for-track-75-midi-cc-osc-only": 612,
5751 "track-set-volume-for-track-76-midi-cc-osc-only": 620,
5752 "track-set-volume-for-track-77-midi-cc-osc-only": 628,
5753 "track-set-volume-for-track-78-midi-cc-osc-only": 636,
5754 "track-set-volume-for-track-79-midi-cc-osc-only": 644,
5755 "track-set-volume-for-track-80-midi-cc-osc-only": 652,
5756 "track-set-volume-for-track-81-midi-cc-osc-only": 660,
5757 "track-set-volume-for-track-82-midi-cc-osc-only": 668,
5758 "track-set-volume-for-track-83-midi-cc-osc-only": 676,
5759 "track-set-volume-for-track-84-midi-cc-osc-only": 684,
5760 "track-set-volume-for-track-85-midi-cc-osc-only": 692,
5761 "track-set-volume-for-track-86-midi-cc-osc-only": 700,
5762 "track-set-volume-for-track-87-midi-cc-osc-only": 708,
5763 "track-set-volume-for-track-88-midi-cc-osc-only": 716,
5764 "track-set-volume-for-track-89-midi-cc-osc-only": 724,
5765 "track-set-volume-for-track-90-midi-cc-osc-only": 732,
5766 "track-set-volume-for-track-91-midi-cc-osc-only": 740,
5767 "track-set-volume-for-track-92-midi-cc-osc-only": 748,
5768 "track-set-volume-for-track-93-midi-cc-osc-only": 756,
5769 "track-set-volume-for-track-94-midi-cc-osc-only": 764,
5770 "track-set-volume-for-track-95-midi-cc-osc-only": 772,
5771 "track-set-volume-for-track-96-midi-cc-osc-only": 780,
5772 "track-set-volume-for-track-97-midi-cc-osc-only": 788,
5773 "track-set-volume-for-track-98-midi-cc-osc-only": 796,
5774 "track-set-volume-for-track-99-midi-cc-osc-only": 804,
5775 "track-show-hide-all-pinned-tracks-in-tcp": 43575,
5776 "track-show-hide-all-pinned-tracks-in-tcp-ignore-master": 43576,
5777 "track-show-hide-children-of-selected-folder-tracks-in-mixer": 41665,
5778 "track-show-hide-children-of-selected-folder-tracks-in-tcp": 42696,
5779 "track-show-only-one-lane-for-all-fixed-lane-tracks-in-the-project-that-have-only-one-lane-playing": 42961,
5780 "track-solo-tracks": 40728,
5781 "track-solo-unsolo-tracks": 40281,
5782 "track-swap-volume-envelope-and-trim-envelope": 42021,
5783 "track-toggle-all-track-grouping-enabled": 40771,
5784 "track-toggle-allow-editing-media-items-while-comping": 42597,
5785 "track-toggle-allow-editing-media-items-while-comping-for-track-at-mouse": 42598,
5786 "track-toggle-automatic-record-arm-when-track-selected": 40736,
5787 "track-toggle-automatically-creating-comp-areas-for-new-recording-while-comping": 42675,
5788 "track-toggle-automatically-delete-empty-fixed-lanes-at-bottom-of-track": 42659,
5789 "track-toggle-comping": 42645,
5790 "track-toggle-comping-for-track-at-mouse": 42646,
5791 "track-toggle-full-multichannel-metering": 41726,
5792 "track-toggle-fx-bypass-for-current-last-touched-track": 40298,
5793 "track-toggle-fx-bypass-for-last-touched-track": 816,
5794 "track-toggle-fx-bypass-for-master-track": 16,
5795 "track-toggle-fx-bypass-for-selected-tracks": 8,
5796 "track-toggle-fx-bypass-for-track-01": 24,
5797 "track-toggle-fx-bypass-for-track-02": 32,
5798 "track-toggle-fx-bypass-for-track-03": 40,
5799 "track-toggle-fx-bypass-for-track-04": 48,
5800 "track-toggle-fx-bypass-for-track-05": 56,
5801 "track-toggle-fx-bypass-for-track-06": 64,
5802 "track-toggle-fx-bypass-for-track-07": 72,
5803 "track-toggle-fx-bypass-for-track-08": 80,
5804 "track-toggle-fx-bypass-for-track-09": 88,
5805 "track-toggle-fx-bypass-for-track-10": 96,
5806 "track-toggle-fx-bypass-for-track-11": 104,
5807 "track-toggle-fx-bypass-for-track-12": 112,
5808 "track-toggle-fx-bypass-for-track-13": 120,
5809 "track-toggle-fx-bypass-for-track-14": 128,
5810 "track-toggle-fx-bypass-for-track-15": 136,
5811 "track-toggle-fx-bypass-for-track-16": 144,
5812 "track-toggle-fx-bypass-for-track-17": 152,
5813 "track-toggle-fx-bypass-for-track-18": 160,
5814 "track-toggle-fx-bypass-for-track-19": 168,
5815 "track-toggle-fx-bypass-for-track-20": 176,
5816 "track-toggle-fx-bypass-for-track-21": 184,
5817 "track-toggle-fx-bypass-for-track-22": 192,
5818 "track-toggle-fx-bypass-for-track-23": 200,
5819 "track-toggle-fx-bypass-for-track-24": 208,
5820 "track-toggle-fx-bypass-for-track-25": 216,
5821 "track-toggle-fx-bypass-for-track-26": 224,
5822 "track-toggle-fx-bypass-for-track-27": 232,
5823 "track-toggle-fx-bypass-for-track-28": 240,
5824 "track-toggle-fx-bypass-for-track-29": 248,
5825 "track-toggle-fx-bypass-for-track-30": 256,
5826 "track-toggle-fx-bypass-for-track-31": 264,
5827 "track-toggle-fx-bypass-for-track-32": 272,
5828 "track-toggle-fx-bypass-for-track-33": 280,
5829 "track-toggle-fx-bypass-for-track-34": 288,
5830 "track-toggle-fx-bypass-for-track-35": 296,
5831 "track-toggle-fx-bypass-for-track-36": 304,
5832 "track-toggle-fx-bypass-for-track-37": 312,
5833 "track-toggle-fx-bypass-for-track-38": 320,
5834 "track-toggle-fx-bypass-for-track-39": 328,
5835 "track-toggle-fx-bypass-for-track-40": 336,
5836 "track-toggle-fx-bypass-for-track-41": 344,
5837 "track-toggle-fx-bypass-for-track-42": 352,
5838 "track-toggle-fx-bypass-for-track-43": 360,
5839 "track-toggle-fx-bypass-for-track-44": 368,
5840 "track-toggle-fx-bypass-for-track-45": 376,
5841 "track-toggle-fx-bypass-for-track-46": 384,
5842 "track-toggle-fx-bypass-for-track-47": 392,
5843 "track-toggle-fx-bypass-for-track-48": 400,
5844 "track-toggle-fx-bypass-for-track-49": 408,
5845 "track-toggle-fx-bypass-for-track-50": 416,
5846 "track-toggle-fx-bypass-for-track-51": 424,
5847 "track-toggle-fx-bypass-for-track-52": 432,
5848 "track-toggle-fx-bypass-for-track-53": 440,
5849 "track-toggle-fx-bypass-for-track-54": 448,
5850 "track-toggle-fx-bypass-for-track-55": 456,
5851 "track-toggle-fx-bypass-for-track-56": 464,
5852 "track-toggle-fx-bypass-for-track-57": 472,
5853 "track-toggle-fx-bypass-for-track-58": 480,
5854 "track-toggle-fx-bypass-for-track-59": 488,
5855 "track-toggle-fx-bypass-for-track-60": 496,
5856 "track-toggle-fx-bypass-for-track-61": 504,
5857 "track-toggle-fx-bypass-for-track-62": 512,
5858 "track-toggle-fx-bypass-for-track-63": 520,
5859 "track-toggle-fx-bypass-for-track-64": 528,
5860 "track-toggle-fx-bypass-for-track-65": 536,
5861 "track-toggle-fx-bypass-for-track-66": 544,
5862 "track-toggle-fx-bypass-for-track-67": 552,
5863 "track-toggle-fx-bypass-for-track-68": 560,
5864 "track-toggle-fx-bypass-for-track-69": 568,
5865 "track-toggle-fx-bypass-for-track-70": 576,
5866 "track-toggle-fx-bypass-for-track-71": 584,
5867 "track-toggle-fx-bypass-for-track-72": 592,
5868 "track-toggle-fx-bypass-for-track-73": 600,
5869 "track-toggle-fx-bypass-for-track-74": 608,
5870 "track-toggle-fx-bypass-for-track-75": 616,
5871 "track-toggle-fx-bypass-for-track-76": 624,
5872 "track-toggle-fx-bypass-for-track-77": 632,
5873 "track-toggle-fx-bypass-for-track-78": 640,
5874 "track-toggle-fx-bypass-for-track-79": 648,
5875 "track-toggle-fx-bypass-for-track-80": 656,
5876 "track-toggle-fx-bypass-for-track-81": 664,
5877 "track-toggle-fx-bypass-for-track-82": 672,
5878 "track-toggle-fx-bypass-for-track-83": 680,
5879 "track-toggle-fx-bypass-for-track-84": 688,
5880 "track-toggle-fx-bypass-for-track-85": 696,
5881 "track-toggle-fx-bypass-for-track-86": 704,
5882 "track-toggle-fx-bypass-for-track-87": 712,
5883 "track-toggle-fx-bypass-for-track-88": 720,
5884 "track-toggle-fx-bypass-for-track-89": 728,
5885 "track-toggle-fx-bypass-for-track-90": 736,
5886 "track-toggle-fx-bypass-for-track-91": 744,
5887 "track-toggle-fx-bypass-for-track-92": 752,
5888 "track-toggle-fx-bypass-for-track-93": 760,
5889 "track-toggle-fx-bypass-for-track-94": 768,
5890 "track-toggle-fx-bypass-for-track-95": 776,
5891 "track-toggle-fx-bypass-for-track-96": 784,
5892 "track-toggle-fx-bypass-for-track-97": 792,
5893 "track-toggle-fx-bypass-for-track-98": 800,
5894 "track-toggle-fx-bypass-for-track-99": 808,
5895 "track-toggle-fx-bypass-on-all-tracks": 40344,
5896 "track-toggle-link-unlink-track-volume-pan-controls-to-midi-volume-pan-on-all-channels": 41556,
5897 "track-toggle-lock-unlock-track-controls": 41314,
5898 "track-toggle-midi-input-quantize-for-all-tracks": 42034,
5899 "track-toggle-midi-input-quantize-for-last-touched-track": 42035,
5900 "track-toggle-midi-input-quantize-for-selected-tracks": 42033,
5901 "track-toggle-mute-for-last-touched-track": 814,
5902 "track-toggle-mute-for-master-track": 14,
5903 "track-toggle-mute-for-selected-tracks": 6,
5904 "track-toggle-mute-for-track-01": 22,
5905 "track-toggle-mute-for-track-02": 30,
5906 "track-toggle-mute-for-track-03": 38,
5907 "track-toggle-mute-for-track-04": 46,
5908 "track-toggle-mute-for-track-05": 54,
5909 "track-toggle-mute-for-track-06": 62,
5910 "track-toggle-mute-for-track-07": 70,
5911 "track-toggle-mute-for-track-08": 78,
5912 "track-toggle-mute-for-track-09": 86,
5913 "track-toggle-mute-for-track-10": 94,
5914 "track-toggle-mute-for-track-11": 102,
5915 "track-toggle-mute-for-track-12": 110,
5916 "track-toggle-mute-for-track-13": 118,
5917 "track-toggle-mute-for-track-14": 126,
5918 "track-toggle-mute-for-track-15": 134,
5919 "track-toggle-mute-for-track-16": 142,
5920 "track-toggle-mute-for-track-17": 150,
5921 "track-toggle-mute-for-track-18": 158,
5922 "track-toggle-mute-for-track-19": 166,
5923 "track-toggle-mute-for-track-20": 174,
5924 "track-toggle-mute-for-track-21": 182,
5925 "track-toggle-mute-for-track-22": 190,
5926 "track-toggle-mute-for-track-23": 198,
5927 "track-toggle-mute-for-track-24": 206,
5928 "track-toggle-mute-for-track-25": 214,
5929 "track-toggle-mute-for-track-26": 222,
5930 "track-toggle-mute-for-track-27": 230,
5931 "track-toggle-mute-for-track-28": 238,
5932 "track-toggle-mute-for-track-29": 246,
5933 "track-toggle-mute-for-track-30": 254,
5934 "track-toggle-mute-for-track-31": 262,
5935 "track-toggle-mute-for-track-32": 270,
5936 "track-toggle-mute-for-track-33": 278,
5937 "track-toggle-mute-for-track-34": 286,
5938 "track-toggle-mute-for-track-35": 294,
5939 "track-toggle-mute-for-track-36": 302,
5940 "track-toggle-mute-for-track-37": 310,
5941 "track-toggle-mute-for-track-38": 318,
5942 "track-toggle-mute-for-track-39": 326,
5943 "track-toggle-mute-for-track-40": 334,
5944 "track-toggle-mute-for-track-41": 342,
5945 "track-toggle-mute-for-track-42": 350,
5946 "track-toggle-mute-for-track-43": 358,
5947 "track-toggle-mute-for-track-44": 366,
5948 "track-toggle-mute-for-track-45": 374,
5949 "track-toggle-mute-for-track-46": 382,
5950 "track-toggle-mute-for-track-47": 390,
5951 "track-toggle-mute-for-track-48": 398,
5952 "track-toggle-mute-for-track-49": 406,
5953 "track-toggle-mute-for-track-50": 414,
5954 "track-toggle-mute-for-track-51": 422,
5955 "track-toggle-mute-for-track-52": 430,
5956 "track-toggle-mute-for-track-53": 438,
5957 "track-toggle-mute-for-track-54": 446,
5958 "track-toggle-mute-for-track-55": 454,
5959 "track-toggle-mute-for-track-56": 462,
5960 "track-toggle-mute-for-track-57": 470,
5961 "track-toggle-mute-for-track-58": 478,
5962 "track-toggle-mute-for-track-59": 486,
5963 "track-toggle-mute-for-track-60": 494,
5964 "track-toggle-mute-for-track-61": 502,
5965 "track-toggle-mute-for-track-62": 510,
5966 "track-toggle-mute-for-track-63": 518,
5967 "track-toggle-mute-for-track-64": 526,
5968 "track-toggle-mute-for-track-65": 534,
5969 "track-toggle-mute-for-track-66": 542,
5970 "track-toggle-mute-for-track-67": 550,
5971 "track-toggle-mute-for-track-68": 558,
5972 "track-toggle-mute-for-track-69": 566,
5973 "track-toggle-mute-for-track-70": 574,
5974 "track-toggle-mute-for-track-71": 582,
5975 "track-toggle-mute-for-track-72": 590,
5976 "track-toggle-mute-for-track-73": 598,
5977 "track-toggle-mute-for-track-74": 606,
5978 "track-toggle-mute-for-track-75": 614,
5979 "track-toggle-mute-for-track-76": 622,
5980 "track-toggle-mute-for-track-77": 630,
5981 "track-toggle-mute-for-track-78": 638,
5982 "track-toggle-mute-for-track-79": 646,
5983 "track-toggle-mute-for-track-80": 654,
5984 "track-toggle-mute-for-track-81": 662,
5985 "track-toggle-mute-for-track-82": 670,
5986 "track-toggle-mute-for-track-83": 678,
5987 "track-toggle-mute-for-track-84": 686,
5988 "track-toggle-mute-for-track-85": 694,
5989 "track-toggle-mute-for-track-86": 702,
5990 "track-toggle-mute-for-track-87": 710,
5991 "track-toggle-mute-for-track-88": 718,
5992 "track-toggle-mute-for-track-89": 726,
5993 "track-toggle-mute-for-track-90": 734,
5994 "track-toggle-mute-for-track-91": 742,
5995 "track-toggle-mute-for-track-92": 750,
5996 "track-toggle-mute-for-track-93": 758,
5997 "track-toggle-mute-for-track-94": 766,
5998 "track-toggle-mute-for-track-95": 774,
5999 "track-toggle-mute-for-track-96": 782,
6000 "track-toggle-mute-for-track-97": 790,
6001 "track-toggle-mute-for-track-98": 798,
6002 "track-toggle-mute-for-track-99": 806,
6003 "track-toggle-preserve-pdc-delayed-monitoring-in-recorded-items": 41919,
6004 "track-toggle-record-arm-for-last-touched-track": 817,
6005 "track-toggle-record-arm-for-selected-tracks": 9,
6006 "track-toggle-record-arm-for-track-01": 25,
6007 "track-toggle-record-arm-for-track-02": 33,
6008 "track-toggle-record-arm-for-track-03": 41,
6009 "track-toggle-record-arm-for-track-04": 49,
6010 "track-toggle-record-arm-for-track-05": 57,
6011 "track-toggle-record-arm-for-track-06": 65,
6012 "track-toggle-record-arm-for-track-07": 73,
6013 "track-toggle-record-arm-for-track-08": 81,
6014 "track-toggle-record-arm-for-track-09": 89,
6015 "track-toggle-record-arm-for-track-10": 97,
6016 "track-toggle-record-arm-for-track-11": 105,
6017 "track-toggle-record-arm-for-track-12": 113,
6018 "track-toggle-record-arm-for-track-13": 121,
6019 "track-toggle-record-arm-for-track-14": 129,
6020 "track-toggle-record-arm-for-track-15": 137,
6021 "track-toggle-record-arm-for-track-16": 145,
6022 "track-toggle-record-arm-for-track-17": 153,
6023 "track-toggle-record-arm-for-track-18": 161,
6024 "track-toggle-record-arm-for-track-19": 169,
6025 "track-toggle-record-arm-for-track-20": 177,
6026 "track-toggle-record-arm-for-track-21": 185,
6027 "track-toggle-record-arm-for-track-22": 193,
6028 "track-toggle-record-arm-for-track-23": 201,
6029 "track-toggle-record-arm-for-track-24": 209,
6030 "track-toggle-record-arm-for-track-25": 217,
6031 "track-toggle-record-arm-for-track-26": 225,
6032 "track-toggle-record-arm-for-track-27": 233,
6033 "track-toggle-record-arm-for-track-28": 241,
6034 "track-toggle-record-arm-for-track-29": 249,
6035 "track-toggle-record-arm-for-track-30": 257,
6036 "track-toggle-record-arm-for-track-31": 265,
6037 "track-toggle-record-arm-for-track-32": 273,
6038 "track-toggle-record-arm-for-track-33": 281,
6039 "track-toggle-record-arm-for-track-34": 289,
6040 "track-toggle-record-arm-for-track-35": 297,
6041 "track-toggle-record-arm-for-track-36": 305,
6042 "track-toggle-record-arm-for-track-37": 313,
6043 "track-toggle-record-arm-for-track-38": 321,
6044 "track-toggle-record-arm-for-track-39": 329,
6045 "track-toggle-record-arm-for-track-40": 337,
6046 "track-toggle-record-arm-for-track-41": 345,
6047 "track-toggle-record-arm-for-track-42": 353,
6048 "track-toggle-record-arm-for-track-43": 361,
6049 "track-toggle-record-arm-for-track-44": 369,
6050 "track-toggle-record-arm-for-track-45": 377,
6051 "track-toggle-record-arm-for-track-46": 385,
6052 "track-toggle-record-arm-for-track-47": 393,
6053 "track-toggle-record-arm-for-track-48": 401,
6054 "track-toggle-record-arm-for-track-49": 409,
6055 "track-toggle-record-arm-for-track-50": 417,
6056 "track-toggle-record-arm-for-track-51": 425,
6057 "track-toggle-record-arm-for-track-52": 433,
6058 "track-toggle-record-arm-for-track-53": 441,
6059 "track-toggle-record-arm-for-track-54": 449,
6060 "track-toggle-record-arm-for-track-55": 457,
6061 "track-toggle-record-arm-for-track-56": 465,
6062 "track-toggle-record-arm-for-track-57": 473,
6063 "track-toggle-record-arm-for-track-58": 481,
6064 "track-toggle-record-arm-for-track-59": 489,
6065 "track-toggle-record-arm-for-track-60": 497,
6066 "track-toggle-record-arm-for-track-61": 505,
6067 "track-toggle-record-arm-for-track-62": 513,
6068 "track-toggle-record-arm-for-track-63": 521,
6069 "track-toggle-record-arm-for-track-64": 529,
6070 "track-toggle-record-arm-for-track-65": 537,
6071 "track-toggle-record-arm-for-track-66": 545,
6072 "track-toggle-record-arm-for-track-67": 553,
6073 "track-toggle-record-arm-for-track-68": 561,
6074 "track-toggle-record-arm-for-track-69": 569,
6075 "track-toggle-record-arm-for-track-70": 577,
6076 "track-toggle-record-arm-for-track-71": 585,
6077 "track-toggle-record-arm-for-track-72": 593,
6078 "track-toggle-record-arm-for-track-73": 601,
6079 "track-toggle-record-arm-for-track-74": 609,
6080 "track-toggle-record-arm-for-track-75": 617,
6081 "track-toggle-record-arm-for-track-76": 625,
6082 "track-toggle-record-arm-for-track-77": 633,
6083 "track-toggle-record-arm-for-track-78": 641,
6084 "track-toggle-record-arm-for-track-79": 649,
6085 "track-toggle-record-arm-for-track-80": 657,
6086 "track-toggle-record-arm-for-track-81": 665,
6087 "track-toggle-record-arm-for-track-82": 673,
6088 "track-toggle-record-arm-for-track-83": 681,
6089 "track-toggle-record-arm-for-track-84": 689,
6090 "track-toggle-record-arm-for-track-85": 697,
6091 "track-toggle-record-arm-for-track-86": 705,
6092 "track-toggle-record-arm-for-track-87": 713,
6093 "track-toggle-record-arm-for-track-88": 721,
6094 "track-toggle-record-arm-for-track-89": 729,
6095 "track-toggle-record-arm-for-track-90": 737,
6096 "track-toggle-record-arm-for-track-91": 745,
6097 "track-toggle-record-arm-for-track-92": 753,
6098 "track-toggle-record-arm-for-track-93": 761,
6099 "track-toggle-record-arm-for-track-94": 769,
6100 "track-toggle-record-arm-for-track-95": 777,
6101 "track-toggle-record-arm-for-track-96": 785,
6102 "track-toggle-record-arm-for-track-97": 793,
6103 "track-toggle-record-arm-for-track-98": 801,
6104 "track-toggle-record-arm-for-track-99": 809,
6105 "track-toggle-record-arming-for-current-last-touched-track": 40294,
6106 "track-toggle-show-hide-in-mixer": 40250,
6107 "track-toggle-show-hide-in-tcp": 40853,
6108 "track-toggle-solo-for-last-touched-track": 815,
6109 "track-toggle-solo-for-master-track": 15,
6110 "track-toggle-solo-for-selected-tracks": 7,
6111 "track-toggle-solo-for-track-01": 23,
6112 "track-toggle-solo-for-track-02": 31,
6113 "track-toggle-solo-for-track-03": 39,
6114 "track-toggle-solo-for-track-04": 47,
6115 "track-toggle-solo-for-track-05": 55,
6116 "track-toggle-solo-for-track-06": 63,
6117 "track-toggle-solo-for-track-07": 71,
6118 "track-toggle-solo-for-track-08": 79,
6119 "track-toggle-solo-for-track-09": 87,
6120 "track-toggle-solo-for-track-10": 95,
6121 "track-toggle-solo-for-track-11": 103,
6122 "track-toggle-solo-for-track-12": 111,
6123 "track-toggle-solo-for-track-13": 119,
6124 "track-toggle-solo-for-track-14": 127,
6125 "track-toggle-solo-for-track-15": 135,
6126 "track-toggle-solo-for-track-16": 143,
6127 "track-toggle-solo-for-track-17": 151,
6128 "track-toggle-solo-for-track-18": 159,
6129 "track-toggle-solo-for-track-19": 167,
6130 "track-toggle-solo-for-track-20": 175,
6131 "track-toggle-solo-for-track-21": 183,
6132 "track-toggle-solo-for-track-22": 191,
6133 "track-toggle-solo-for-track-23": 199,
6134 "track-toggle-solo-for-track-24": 207,
6135 "track-toggle-solo-for-track-25": 215,
6136 "track-toggle-solo-for-track-26": 223,
6137 "track-toggle-solo-for-track-27": 231,
6138 "track-toggle-solo-for-track-28": 239,
6139 "track-toggle-solo-for-track-29": 247,
6140 "track-toggle-solo-for-track-30": 255,
6141 "track-toggle-solo-for-track-31": 263,
6142 "track-toggle-solo-for-track-32": 271,
6143 "track-toggle-solo-for-track-33": 279,
6144 "track-toggle-solo-for-track-34": 287,
6145 "track-toggle-solo-for-track-35": 295,
6146 "track-toggle-solo-for-track-36": 303,
6147 "track-toggle-solo-for-track-37": 311,
6148 "track-toggle-solo-for-track-38": 319,
6149 "track-toggle-solo-for-track-39": 327,
6150 "track-toggle-solo-for-track-40": 335,
6151 "track-toggle-solo-for-track-41": 343,
6152 "track-toggle-solo-for-track-42": 351,
6153 "track-toggle-solo-for-track-43": 359,
6154 "track-toggle-solo-for-track-44": 367,
6155 "track-toggle-solo-for-track-45": 375,
6156 "track-toggle-solo-for-track-46": 383,
6157 "track-toggle-solo-for-track-47": 391,
6158 "track-toggle-solo-for-track-48": 399,
6159 "track-toggle-solo-for-track-49": 407,
6160 "track-toggle-solo-for-track-50": 415,
6161 "track-toggle-solo-for-track-51": 423,
6162 "track-toggle-solo-for-track-52": 431,
6163 "track-toggle-solo-for-track-53": 439,
6164 "track-toggle-solo-for-track-54": 447,
6165 "track-toggle-solo-for-track-55": 455,
6166 "track-toggle-solo-for-track-56": 463,
6167 "track-toggle-solo-for-track-57": 471,
6168 "track-toggle-solo-for-track-58": 479,
6169 "track-toggle-solo-for-track-59": 487,
6170 "track-toggle-solo-for-track-60": 495,
6171 "track-toggle-solo-for-track-61": 503,
6172 "track-toggle-solo-for-track-62": 511,
6173 "track-toggle-solo-for-track-63": 519,
6174 "track-toggle-solo-for-track-64": 527,
6175 "track-toggle-solo-for-track-65": 535,
6176 "track-toggle-solo-for-track-66": 543,
6177 "track-toggle-solo-for-track-67": 551,
6178 "track-toggle-solo-for-track-68": 559,
6179 "track-toggle-solo-for-track-69": 567,
6180 "track-toggle-solo-for-track-70": 575,
6181 "track-toggle-solo-for-track-71": 583,
6182 "track-toggle-solo-for-track-72": 591,
6183 "track-toggle-solo-for-track-73": 599,
6184 "track-toggle-solo-for-track-74": 607,
6185 "track-toggle-solo-for-track-75": 615,
6186 "track-toggle-solo-for-track-76": 623,
6187 "track-toggle-solo-for-track-77": 631,
6188 "track-toggle-solo-for-track-78": 639,
6189 "track-toggle-solo-for-track-79": 647,
6190 "track-toggle-solo-for-track-80": 655,
6191 "track-toggle-solo-for-track-81": 663,
6192 "track-toggle-solo-for-track-82": 671,
6193 "track-toggle-solo-for-track-83": 679,
6194 "track-toggle-solo-for-track-84": 687,
6195 "track-toggle-solo-for-track-85": 695,
6196 "track-toggle-solo-for-track-86": 703,
6197 "track-toggle-solo-for-track-87": 711,
6198 "track-toggle-solo-for-track-88": 719,
6199 "track-toggle-solo-for-track-89": 727,
6200 "track-toggle-solo-for-track-90": 735,
6201 "track-toggle-solo-for-track-91": 743,
6202 "track-toggle-solo-for-track-92": 751,
6203 "track-toggle-solo-for-track-93": 759,
6204 "track-toggle-solo-for-track-94": 767,
6205 "track-toggle-solo-for-track-95": 775,
6206 "track-toggle-solo-for-track-96": 783,
6207 "track-toggle-solo-for-track-97": 791,
6208 "track-toggle-solo-for-track-98": 799,
6209 "track-toggle-solo-for-track-99": 807,
6210 "track-toggle-track-metering": 41744,
6211 "track-toggle-track-mute-envelope-active": 40866,
6212 "track-toggle-track-mute-envelope-visible": 40867,
6213 "track-toggle-track-pan-envelope-active": 40053,
6214 "track-toggle-track-pan-envelope-visible": 40407,
6215 "track-toggle-track-pre-fx-pan-envelope-active": 40051,
6216 "track-toggle-track-pre-fx-pan-envelope-visible": 40409,
6217 "track-toggle-track-pre-fx-volume-envelope-active": 40050,
6218 "track-toggle-track-pre-fx-volume-envelope-visible": 40408,
6219 "track-toggle-track-solo-defeat": 41199,
6220 "track-toggle-track-trim-envelope-visible": 42020,
6221 "track-toggle-track-volume-envelope-active": 40052,
6222 "track-toggle-track-volume-envelope-visible": 40406,
6223 "track-turn-off-automatic-track-grouping": 42585,
6224 "track-unarm-all-tracks-for-recording": 40491,
6225 "track-unbypass-fx-on-all-tracks": 40343,
6226 "track-unfreeze-tracks-restore-previously-saved-items-and-fx": 41644,
6227 "track-unlock-track-controls": 41313,
6228 "track-unmute-all-tracks": 40339,
6229 "track-unmute-tracks": 40731,
6230 "track-unselect-clear-selection-of-all-tracks": 40297,
6231 "track-unset-preserve-pdc-delayed-monitoring-in-recorded-items": 41920,
6232 "track-unset-track-solo-defeat": 41198,
6233 "track-unset-track-solo-defeat-all-tracks": 40770,
6234 "track-unsolo-all-tracks": 40340,
6235 "track-unsolo-tracks": 40729,
6236 "track-vertical-scroll-selected-tracks-into-view": 40913,
6237 "track-view-envelopes-for-current-last-touched-track": 40292,
6238 "track-view-envelopes-for-current-last-touched-track-at-mouse-cursor": 41975,
6239 "track-view-fx-chain-for-current-last-touched-track": 40291,
6240 "track-view-fx-chain-for-master-track": 40846,
6241 "track-view-input-fx-chain-for-current-last-touched-track": 40844,
6242 "track-view-routing-and-i-o-for-current-last-touched-track": 40293,
6243 "track-view-routing-and-i-o-for-master-track": 42235,
6244 "track-view-track-recording-settings-midi-quantize-file-format-path-for-last-touched-track": 40604,
6245 "tracks-copy-items-on-currently-playing-lanes-on-selected-fixed-lane-tracks-to-one-new-track-per-lane": 42694,
6246 "tracks-explode-items-on-selected-fixed-lane-tracks-to-one-new-track-per-lane": 42695,
6247 "tracks-explode-selected-items-on-fixed-lane-tracks-to-one-new-track-per-lane": 42639,
6248 "tracks-implode-selected-items-across-tracks-to-one-fixed-lane-track": 42596,
6249 "transient-detection-sensitivity-adjust-midi-cc-mousewheel-only": 967,
6250 "transient-detection-sensitivity-decrease": 41537,
6251 "transient-detection-sensitivity-increase": 41536,
6252 "transient-detection-sensitivity-threshold-adjust": 41208,
6253 "transient-detection-threshold-adjust-midi-cc-mousewheel-only": 968,
6254 "transient-detection-threshold-decrease": 40219,
6255 "transient-detection-threshold-increase": 40218,
6256 "transport-apply-playrate-to-current-bpm": 40672,
6257 "transport-apply-playrate-to-current-bpm-no-reset-playrate": 40526,
6258 "transport-center-transport-controls": 40533,
6259 "transport-decrease-playrate-by-0-6-percent-10-cents": 40525,
6260 "transport-decrease-playrate-by-6-percent-one-semitone": 40523,
6261 "transport-fast-forward-a-little-bit": 40085,
6262 "transport-flash-transport-yellow-on-possible-audio-device-underrun": 42305,
6263 "transport-go-to-end-of-project": 40043,
6264 "transport-go-to-start-of-project": 40042,
6265 "transport-increase-playrate-by-0-6-percent-10-cents": 40524,
6266 "transport-increase-playrate-by-6-percent-one-semitone": 40522,
6267 "transport-pause": 1008,
6268 "transport-play": 1007,
6269 "transport-play-pause": 40073,
6270 "transport-play-skip-time-selection": 40317,
6271 "transport-play-stop": 40044,
6272 "transport-play-stop-move-edit-cursor-on-stop": 40328,
6273 "transport-record": 1013,
6274 "transport-rewind-a-little-bit": 40084,
6275 "transport-scrub-jog-fine-control-midi-cc-relative-only": 974,
6276 "transport-scrub-jog-midi-cc-relative-absolute-only": 992,
6277 "transport-secondary-time-unit-absolute-frames": 42371,
6278 "transport-secondary-time-unit-hours-minutes-seconds-frames": 42370,
6279 "transport-secondary-time-unit-measures-beats": 42792,
6280 "transport-secondary-time-unit-minutes-seconds": 42367,
6281 "transport-secondary-time-unit-none": 42366,
6282 "transport-secondary-time-unit-samples": 42369,
6283 "transport-secondary-time-unit-seconds": 42368,
6284 "transport-set-playrate-to-1-0": 40521,
6285 "transport-show-play-state-as-text": 40532,
6286 "transport-show-playrate-control": 40531,
6287 "transport-show-time-signature": 40680,
6288 "transport-show-transport-docked-above-ruler": 41604,
6289 "transport-show-transport-docked-below-arrange": 41603,
6290 "transport-show-transport-docked-to-bottom-of-main-window": 41605,
6291 "transport-show-transport-docked-to-top-of-main-window": 41606,
6292 "transport-show-transport-in-docker": 41608,
6293 "transport-start-stop-recording-after-2-beats": 40067,
6294 "transport-start-stop-recording-at-edit-cursor": 40046,
6295 "transport-start-stop-recording-at-next-beat": 40045,
6296 "transport-start-stop-recording-at-next-measure": 40003,
6297 "transport-start-stop-recording-at-next-project-marker": 40056,
6298 "transport-stop": 1016,
6299 "transport-stop-delete-all-recorded-media": 40668,
6300 "transport-stop-save-all-recorded-media": 40667,
6301 "transport-tap-tempo": 1134,
6302 "transport-time-unit-absolute-frames": 41972,
6303 "transport-time-unit-hours-minutes-seconds-frames": 40414,
6304 "transport-time-unit-measures-beats": 40411,
6305 "transport-time-unit-measures-beats-minutes-seconds": 40534,
6306 "transport-time-unit-minutes-seconds": 40410,
6307 "transport-time-unit-samples": 40413,
6308 "transport-time-unit-seconds": 40412,
6309 "transport-time-unit-to-ruler": 40379,
6310 "transport-toggle-preserve-pitch-in-audio-items-when-changing-master-playrate": 40671,
6311 "transport-toggle-repeat": 1068,
6312 "transport-toggle-stop-playback-at-end-of-loop-if-repeat-is-disabled": 41834,
6313 "transport-toggle-transport-docked-to-main-window": 40260,
6314 "transport-toggle-transport-home-end-marker-navigation": 40868,
6315 "unselect-clear-selection-of-all-tracks-items-envelope-points": 40769,
6316 "video-clear-video-cache-re-render-frames": 50123,
6317 "video-fullscreen": 50122,
6318 "video-show-hide-video-window": 50125,
6319 "view-adjust-horizontal-scroll-midi-cc-osc-only-relative-recommended": 997,
6320 "view-adjust-horizontal-zoom-midi-cc-osc-only": 998,
6321 "view-adjust-selected-track-heights-a-little-bit-midi-cc-relative-mousewheel": 970,
6322 "view-adjust-selected-track-heights-midi-cc-osc-only": 971,
6323 "view-adjust-selected-track-heights-midi-cc-relative-mousewheel": 972,
6324 "view-adjust-vertical-scroll-midi-cc-osc-only": 995,
6325 "view-adjust-vertical-zoom-midi-cc-osc-only": 994,
6326 "view-attach-unattach-docker-to-from-main-window": 40313,
6327 "view-clear-all-peak-indicators": 40527,
6328 "view-continuous-scrolling-during-playback": 41817,
6329 "view-cycle-track-zoom-between-minimum-default-and-maximum-height-even-if-over-100-percent-of-arrange-view": 42701,
6330 "view-cycle-track-zoom-between-minimum-default-and-maximum-height-limit-to-100-percent-of-arrange-view": 42698,
6331 "view-decrease-selected-track-heights": 41326,
6332 "view-decrease-selected-track-heights-a-little-bit": 41328,
6333 "view-expand-selected-track-height-minimize-others": 40723,
6334 "view-go-to-edit-cursor": 40151,
6335 "view-go-to-play-cursor-position": 40150,
6336 "view-go-to-track-midi-cc-osc-only": 993,
6337 "view-hide-item-labels": 40708,
6338 "view-if-displayed-toggle-mouse-position-indicator-vertical-line-respects-toolbar-snap-button": 43203,
6339 "view-increase-selected-track-heights": 41325,
6340 "view-increase-selected-track-heights-a-little-bit": 41327,
6341 "view-jump-go-to-time-window": 40069,
6342 "view-minimize-all-tracks": 40727,
6343 "view-move-cursor-left-8-pixels": 41666,
6344 "view-move-cursor-left-by-grid-division": 43614,
6345 "view-move-cursor-left-one-pixel": 40104,
6346 "view-move-cursor-left-to-grid-division": 40646,
6347 "view-move-cursor-right-8-pixels": 41667,
6348 "view-move-cursor-right-by-grid-division": 43615,
6349 "view-move-cursor-right-one-pixel": 40105,
6350 "view-move-cursor-right-to-grid-division": 40647,
6351 "view-move-edit-cursor-midi-cc-osc-only-relative-recommended": 996,
6352 "view-move-edit-cursor-to-mouse-cursor": 40513,
6353 "view-move-edit-cursor-to-mouse-cursor-no-snapping": 40514,
6354 "view-move-edit-cursor-to-play-cursor": 40434,
6355 "view-restore-next-zoom-level": 40875,
6356 "view-restore-next-zoom-scroll-position": 40762,
6357 "view-restore-previous-zoom-level": 40869,
6358 "view-restore-previous-zoom-scroll-position": 40848,
6359 "view-scale-finder-window": 40301,
6360 "view-scroll-horizontally-midi-cc-relative-mousewheel": 988,
6361 "view-scroll-horizontally-reversed-midi-cc-relative-mousewheel": 977,
6362 "view-scroll-vertically-midi-cc-relative-mousewheel": 989,
6363 "view-scroll-vertically-reversed-midi-cc-relative-mousewheel": 978,
6364 "view-scroll-view-down": 40139,
6365 "view-scroll-view-horizontally-one-page-midi-cc-relative-mousewheel": 981,
6366 "view-scroll-view-horizontally-one-page-reversed-midi-cc-relative-mousewheel": 975,
6367 "view-scroll-view-left": 40140,
6368 "view-scroll-view-right": 40141,
6369 "view-scroll-view-up": 40138,
6370 "view-scroll-view-vertically-one-page-midi-cc-relative-mousewheel": 976,
6371 "view-scroll-view-vertically-one-page-reversed-midi-cc-relative-mousewheel": 982,
6372 "view-secondary-time-unit-for-ruler-absolute-frames": 42365,
6373 "view-secondary-time-unit-for-ruler-hours-minutes-seconds-frames": 42364,
6374 "view-secondary-time-unit-for-ruler-minutes-seconds": 42361,
6375 "view-secondary-time-unit-for-ruler-minutes-seconds-minimal": 43705,
6376 "view-secondary-time-unit-for-ruler-none": 42360,
6377 "view-secondary-time-unit-for-ruler-samples": 42363,
6378 "view-secondary-time-unit-for-ruler-seconds": 42362,
6379 "view-set-horizontal-zoom-to-default-project-setting": 41190,
6380 "view-show-big-clock-plus-window": 40378,
6381 "view-show-crossfade-editor-window": 41827,
6382 "view-show-docker": 40279,
6383 "view-show-envelope-manager-window": 42678,
6384 "view-show-fx-browser-window": 40271,
6385 "view-show-item-labels": 40703,
6386 "view-show-monitoring-fx-chain": 41882,
6387 "view-show-navigator-window": 40268,
6388 "view-show-peaks-display-settings": 42074,
6389 "view-show-performance-meter-window": 40240,
6390 "view-show-project-bay-window": 41157,
6391 "view-show-project-bay-window-2": 41628,
6392 "view-show-project-bay-window-3": 41629,
6393 "view-show-project-bay-window-4": 41630,
6394 "view-show-project-bay-window-5": 41631,
6395 "view-show-project-bay-window-6": 41632,
6396 "view-show-project-bay-window-7": 41633,
6397 "view-show-project-bay-window-8": 41634,
6398 "view-show-region-marker-manager-window": 40326,
6399 "view-show-region-render-matrix-window": 41888,
6400 "view-show-routing-matrix-window": 40251,
6401 "view-show-screensets-layouts-window": 40422,
6402 "view-show-tcp-on-right-side-of-arrange": 42373,
6403 "view-show-track-freeze-details": 41654,
6404 "view-show-track-group-manager-window": 40327,
6405 "view-show-track-grouping-matrix-window": 40768,
6406 "view-show-track-manager-window": 40906,
6407 "view-show-track-wiring-diagram": 42031,
6408 "view-show-undo-history-window": 40072,
6409 "view-show-virtual-midi-keyboard": 40377,
6410 "view-time-unit-for-ruler-absolute-frames": 41973,
6411 "view-time-unit-for-ruler-hours-minutes-seconds-frames": 40370,
6412 "view-time-unit-for-ruler-measures-beats": 40367,
6413 "view-time-unit-for-ruler-measures-beats-minimal": 41916,
6414 "view-time-unit-for-ruler-measures-beats-minimal-minutes-seconds": 41918,
6415 "view-time-unit-for-ruler-measures-beats-minutes-seconds": 40366,
6416 "view-time-unit-for-ruler-measures-fractions": 43205,
6417 "view-time-unit-for-ruler-minutes-seconds": 40365,
6418 "view-time-unit-for-ruler-minutes-seconds-minimal": 43204,
6419 "view-time-unit-for-ruler-samples": 40369,
6420 "view-time-unit-for-ruler-seconds": 40368,
6421 "view-toggle-auto-view-scroll-during-playback": 40036,
6422 "view-toggle-auto-view-scroll-while-recording": 40262,
6423 "view-toggle-display-mouse-position-indicator-vertical-line-in-arrange-view": 43194,
6424 "view-toggle-displaying-labels-above-within-media-items": 40258,
6425 "view-toggle-master-track-in-separate-docked-window": 41609,
6426 "view-toggle-master-track-visible": 40075,
6427 "view-toggle-mixer-visible": 40078,
6428 "view-toggle-show-hide-item-labels": 40651,
6429 "view-toggle-show-hide-media-item-timebase-buttons": 43642,
6430 "view-toggle-show-hide-media-item-timebase-buttons-if-overridden-for-the-track-or-item": 43643,
6431 "view-toggle-show-media-cues-in-items": 40691,
6432 "view-toggle-show-midi-editor-windows": 40716,
6433 "view-toggle-show-tcp-area": 43185,
6434 "view-toggle-to-alternate-tcp-area-width-alternate-is-zero-by-default": 43188,
6435 "view-toggle-track-zoom-to-default-height": 42697,
6436 "view-toggle-track-zoom-to-default-height-ignore-pinned-tracks": 43678,
6437 "view-toggle-track-zoom-to-maximum-height-even-if-over-100-percent-of-arrange-view": 42700,
6438 "view-toggle-track-zoom-to-maximum-height-limit-to-100-percent-of-arrange-view": 40113,
6439 "view-toggle-track-zoom-to-minimum-height": 40110,
6440 "view-toggle-track-zoom-to-minimum-height-ignore-pinned-tracks": 43677,
6441 "view-toggle-transport-visible-play-record-stop": 40259,
6442 "view-toggle-zoom-to-selected-items": 41622,
6443 "view-zoom-horizontally-midi-cc-relative-mousewheel": 990,
6444 "view-zoom-horizontally-reversed-midi-cc-relative-mousewheel": 979,
6445 "view-zoom-in-horizontal": 1012,
6446 "view-zoom-in-vertical": 40111,
6447 "view-zoom-out-horizontal": 1011,
6448 "view-zoom-out-project": 40295,
6449 "view-zoom-out-vertical": 40112,
6450 "view-zoom-time-selection": 40031,
6451 "view-zoom-vertically-midi-cc-relative-mousewheel": 1000,
6452 "view-zoom-vertically-reversed-midi-cc-relative-mousewheel": 1001,
6453 "view-zoom-vertically-reversed-snap-to-theme-defined-sizes-midi-cc-relative-mousewheel": 980,
6454 "view-zoom-vertically-snap-to-theme-defined-sizes-midi-cc-relative-mousewheel": 991,
6455 "virtual-midi-keyboard-send-all-input-to-vkb": 40637,
6456 "xenakios-sws-apply-track-fx-to-items-and-reset-volume": 53300,
6457 "xenakios-sws-apply-track-fx-to-items-mono-and-reset-volume": 53301,
6458 "xenakios-sws-auto-rename-selected-takes": 53271,
6459 "xenakios-sws-bypass-fx-of-selected-tracks": 53328,
6460 "xenakios-sws-choose-files-for-random-insert": 53254,
6461 "xenakios-sws-choose-new-source-file-for-selected-takes": 53273,
6462 "xenakios-sws-command-parameters": 53491,
6463 "xenakios-sws-create-markers-from-selected-items-name-by-take-source-file-name": 53452,
6464 "xenakios-sws-deprecated-create-new-tracks": 53441,
6465 "xenakios-sws-deprecated-delete-active-take-of-item-and-send-source-media-to-recycle-bin": 53380,
6466 "xenakios-sws-deprecated-delete-active-take-of-item-and-take-source-media-immediately": 53379,
6467 "xenakios-sws-deprecated-delete-selected-item-and-send-active-take-s-source-media-to-recycle-bin": 53378,
6468 "xenakios-sws-deprecated-delete-selected-item-and-source-media-immediately": 53377,
6469 "xenakios-sws-deprecated-load-project-template-01": 53459,
6470 "xenakios-sws-deprecated-load-project-template-02": 53460,
6471 "xenakios-sws-deprecated-load-project-template-03": 53461,
6472 "xenakios-sws-deprecated-load-project-template-04": 53462,
6473 "xenakios-sws-deprecated-load-project-template-05": 53463,
6474 "xenakios-sws-deprecated-load-project-template-06": 53464,
6475 "xenakios-sws-deprecated-load-project-template-07": 53465,
6476 "xenakios-sws-deprecated-load-project-template-08": 53466,
6477 "xenakios-sws-deprecated-load-project-template-09": 53467,
6478 "xenakios-sws-deprecated-load-project-template-10": 53468,
6479 "xenakios-sws-deprecated-load-track-template-01": 53394,
6480 "xenakios-sws-deprecated-load-track-template-02": 53395,
6481 "xenakios-sws-deprecated-load-track-template-03": 53396,
6482 "xenakios-sws-deprecated-load-track-template-04": 53397,
6483 "xenakios-sws-deprecated-load-track-template-05": 53398,
6484 "xenakios-sws-deprecated-load-track-template-06": 53399,
6485 "xenakios-sws-deprecated-load-track-template-07": 53400,
6486 "xenakios-sws-deprecated-load-track-template-08": 53401,
6487 "xenakios-sws-deprecated-load-track-template-09": 53402,
6488 "xenakios-sws-deprecated-load-track-template-10": 53403,
6489 "xenakios-sws-deprecated-search-takes": 53356,
6490 "xenakios-sws-deprecated-toggle-stop-playback-at-end-of-loop": 53480,
6491 "xenakios-sws-disk-space-calculator": 53490,
6492 "xenakios-sws-dismantle-selected-folder": 53337,
6493 "xenakios-sws-erase-from-item-beat-based": 53355,
6494 "xenakios-sws-erase-from-item-time-based": 53354,
6495 "xenakios-sws-explode-selected-items-to-new-tracks-keeping-positions": 53393,
6496 "xenakios-sws-find-missing-media-for-project-s-takes": 53389,
6497 "xenakios-sws-give-tracks-default-label": 53408,
6498 "xenakios-sws-implode-items-to-takes-and-pan-symmetrically": 53298,
6499 "xenakios-sws-implode-selected-items-in-place": 53417,
6500 "xenakios-sws-insert-media-file-from-clipboard-deprecated": 53353,
6501 "xenakios-sws-insert-new-track-at-the-top-of-track-list": 53407,
6502 "xenakios-sws-insert-prefix-to-track-labels": 53409,
6503 "xenakios-sws-insert-random-file": 53255,
6504 "xenakios-sws-insert-random-file-at-time-selection": 53412,
6505 "xenakios-sws-insert-random-file-at-time-selection-randomize-offset": 53414,
6506 "xenakios-sws-insert-random-file-randomize-length": 53411,
6507 "xenakios-sws-insert-random-file-randomize-start-offset": 53413,
6508 "xenakios-sws-insert-shuffled-random-file": 53256,
6509 "xenakios-sws-insert-suffix-to-track-labels": 53410,
6510 "xenakios-sws-invert-item-selection": 53234,
6511 "xenakios-sws-item-property-interpolator": 53422,
6512 "xenakios-sws-jump-edit-cursor-by-random-amount-exp-distribution": 53346,
6513 "xenakios-sws-launch-external-tool-1": 53344,
6514 "xenakios-sws-launch-external-tool-2": 53345,
6515 "xenakios-sws-loop-and-play-selected-items": 53347,
6516 "xenakios-sws-maximize-selected-tracks-fx-panel-height-in-mixer": 53342,
6517 "xenakios-sws-minimize-selected-tracks-send-and-fx-panel-height-in-mixer": 53341,
6518 "xenakios-sws-minimize-selected-tracks-send-panel-height-in-mixer": 53340,
6519 "xenakios-sws-move-cursor-left-10-pixels": 53358,
6520 "xenakios-sws-move-cursor-left-10-pixels-creating-time-selection": 53359,
6521 "xenakios-sws-move-cursor-left-configured-pixels": 53369,
6522 "xenakios-sws-move-cursor-left-configured-pixels-creating-time-selection": 53371,
6523 "xenakios-sws-move-cursor-left-configured-seconds": 53373,
6524 "xenakios-sws-move-cursor-right-10-pixels": 53357,
6525 "xenakios-sws-move-cursor-right-10-pixels-creating-time-selection": 53360,
6526 "xenakios-sws-move-cursor-right-configured-pixels": 53368,
6527 "xenakios-sws-move-cursor-right-configured-pixels-creating-time-selection": 53370,
6528 "xenakios-sws-move-cursor-right-configured-seconds": 53374,
6529 "xenakios-sws-move-cursor-to-next-transient-minus-default-fade-time": 53302,
6530 "xenakios-sws-move-cursor-to-previous-transient-minus-default-fade-time": 53303,
6531 "xenakios-sws-move-edit-cursor-32nd-note-left": 53486,
6532 "xenakios-sws-move-edit-cursor-32nd-note-right": 53487,
6533 "xenakios-sws-move-edit-cursor-64th-note-left": 53488,
6534 "xenakios-sws-move-edit-cursor-64th-note-right": 53489,
6535 "xenakios-sws-move-selected-items-left-by-item-length": 53265,
6536 "xenakios-sws-move-selected-items-to-edit-cursor": 53264,
6537 "xenakios-sws-normalize-selected-takes-to-db-value": 53458,
6538 "xenakios-sws-nudge-active-take-volume-down": 53286,
6539 "xenakios-sws-nudge-active-take-volume-up": 53287,
6540 "xenakios-sws-nudge-item-contents-1-sample-left": 53433,
6541 "xenakios-sws-nudge-item-contents-1-sample-right": 53434,
6542 "xenakios-sws-nudge-item-pitch-down": 53280,
6543 "xenakios-sws-nudge-item-pitch-down-b": 53282,
6544 "xenakios-sws-nudge-item-pitch-down-resampled-a": 53277,
6545 "xenakios-sws-nudge-item-pitch-down-resampled-b": 53279,
6546 "xenakios-sws-nudge-item-pitch-up": 53281,
6547 "xenakios-sws-nudge-item-pitch-up-b": 53283,
6548 "xenakios-sws-nudge-item-pitch-up-resampled-a": 53276,
6549 "xenakios-sws-nudge-item-pitch-up-resampled-b": 53278,
6550 "xenakios-sws-nudge-item-positions-left-beat-based": 53260,
6551 "xenakios-sws-nudge-item-positions-left-time-based": 53258,
6552 "xenakios-sws-nudge-item-positions-right-beat-based": 53261,
6553 "xenakios-sws-nudge-item-positions-right-time-based": 53259,
6554 "xenakios-sws-nudge-item-volume-down": 53284,
6555 "xenakios-sws-nudge-item-volume-up": 53285,
6556 "xenakios-sws-nudge-master-volume-1-db-down": 53471,
6557 "xenakios-sws-nudge-master-volume-1-db-up": 53470,
6558 "xenakios-sws-nudge-section-loop-length-longer": 53423,
6559 "xenakios-sws-nudge-section-loop-length-shorter": 53424,
6560 "xenakios-sws-nudge-section-loop-overlap-longer": 53427,
6561 "xenakios-sws-nudge-section-loop-overlap-shorter": 53428,
6562 "xenakios-sws-nudge-section-loop-start-earlier": 53426,
6563 "xenakios-sws-nudge-section-loop-start-later": 53425,
6564 "xenakios-sws-nudge-volume-of-selected-tracks-down": 53474,
6565 "xenakios-sws-nudge-volume-of-selected-tracks-up": 53473,
6566 "xenakios-sws-open-associated-reaper-project-of-item": 53351,
6567 "xenakios-sws-open-audio-take-in-external-editor-3": 53420,
6568 "xenakios-sws-open-audio-take-in-external-editor-4": 53421,
6569 "xenakios-sws-open-reaper-project-in-item-bwav-info-autosearch-for-rpp-if-necessary": 53404,
6570 "xenakios-sws-pan-selected-tracks-randomly": 53308,
6571 "xenakios-sws-pan-selected-tracks-symmetrically-left-to-right": 53306,
6572 "xenakios-sws-pan-selected-tracks-symmetrically-right-to-left": 53307,
6573 "xenakios-sws-pan-selected-tracks-to-center": 53309,
6574 "xenakios-sws-pan-selected-tracks-to-left": 53310,
6575 "xenakios-sws-pan-selected-tracks-to-right": 53311,
6576 "xenakios-sws-pan-takes-of-item-symmetrically": 53297,
6577 "xenakios-sws-play-selected-items-once": 53348,
6578 "xenakios-sws-preview-selected-media-item": 53362,
6579 "xenakios-sws-preview-selected-media-item-at-track-fader-volume": 53364,
6580 "xenakios-sws-preview-selected-media-item-at-track-fader-volume-toggle": 53365,
6581 "xenakios-sws-preview-selected-media-item-through-track": 53366,
6582 "xenakios-sws-preview-selected-media-item-through-track-toggle": 53367,
6583 "xenakios-sws-preview-selected-media-item-toggle": 53363,
6584 "xenakios-sws-randomize-item-positions": 53257,
6585 "xenakios-sws-recall-edit-cursor-position": 53376,
6586 "xenakios-sws-recall-render-speed": 53438,
6587 "xenakios-sws-recall-selected-takes": 53252,
6588 "xenakios-sws-recall-selected-tracks-heights": 53333,
6589 "xenakios-sws-remap-item-positions": 53372,
6590 "xenakios-sws-remove-muted-items": 53469,
6591 "xenakios-sws-remove-time-selection-leave-loop-selection": 53415,
6592 "xenakios-sws-rename-project-markers-with-ascending-numbers": 53481,
6593 "xenakios-sws-rename-selected-takes-deprecated": 53270,
6594 "xenakios-sws-rename-selected-takes-with-bwav-description": 53272,
6595 "xenakios-sws-rename-selected-tracks": 53339,
6596 "xenakios-sws-rename-take-source-files-no-undo": 53453,
6597 "xenakios-sws-rename-takes": 53455,
6598 "xenakios-sws-rename-takes-and-source-files-no-undo": 53454,
6599 "xenakios-sws-rename-takes-with-same-name": 53456,
6600 "xenakios-sws-render-item-to-new-take-with-tail": 53350,
6601 "xenakios-sws-render-receives-of-selected-track-as-stems": 53442,
6602 "xenakios-sws-repeat-paste": 53253,
6603 "xenakios-sws-reposition-selected-items": 53269,
6604 "xenakios-sws-resample-pitch-shift-item-one-semitone-down": 53274,
6605 "xenakios-sws-resample-pitch-shift-item-one-semitone-up": 53275,
6606 "xenakios-sws-reset-active-take-volume-to-0-0-db": 53289,
6607 "xenakios-sws-reset-item-length-and-media-offset": 53475,
6608 "xenakios-sws-reset-item-volume-to-0-0-db": 53288,
6609 "xenakios-sws-reset-volume-and-pan-of-selected-tracks": 53304,
6610 "xenakios-sws-reverse-order-of-selected-items": 53449,
6611 "xenakios-sws-save-item-as-audio-file": 53457,
6612 "xenakios-sws-save-project-markers-as-text": 53343,
6613 "xenakios-sws-scale-item-positions-lengths-by-percentage": 53268,
6614 "xenakios-sws-scroll-track-view-down-page": 53429,
6615 "xenakios-sws-scroll-track-view-to-end": 53432,
6616 "xenakios-sws-scroll-track-view-to-home": 53431,
6617 "xenakios-sws-scroll-track-view-up-page": 53430,
6618 "xenakios-sws-select-first-items-of-selected-tracks": 53239,
6619 "xenakios-sws-select-first-of-selected-tracks": 53405,
6620 "xenakios-sws-select-first-take-in-selected-items": 53247,
6621 "xenakios-sws-select-items-to-end-of-track": 53238,
6622 "xenakios-sws-select-items-to-start-of-track": 53237,
6623 "xenakios-sws-select-items-under-edit-cursor-on-selected-tracks": 53439,
6624 "xenakios-sws-select-last-of-selected-tracks": 53406,
6625 "xenakios-sws-select-last-take-in-selected-items": 53248,
6626 "xenakios-sws-select-next-tracks": 53324,
6627 "xenakios-sws-select-next-tracks-keeping-current-selection": 53326,
6628 "xenakios-sws-select-previous-tracks": 53325,
6629 "xenakios-sws-select-previous-tracks-keeping-current-selection": 53327,
6630 "xenakios-sws-select-takes-in-selected-items-shuffled-random": 53249,
6631 "xenakios-sws-select-takes-of-selected-items-cyclically": 53250,
6632 "xenakios-sws-select-tracks-with-buss-in-name": 53335,
6633 "xenakios-sws-select-tracks-with-no-items": 53334,
6634 "xenakios-sws-set-fades-of-selected-items-to-0-0": 53294,
6635 "xenakios-sws-set-fades-of-selected-items-to-configuration-a": 53295,
6636 "xenakios-sws-set-fades-of-selected-items-to-configuration-b": 53296,
6637 "xenakios-sws-set-fades-of-selected-items-to-configuration-c": 53482,
6638 "xenakios-sws-set-fades-of-selected-items-to-configuration-d": 53483,
6639 "xenakios-sws-set-fades-of-selected-items-to-configuration-e": 53484,
6640 "xenakios-sws-set-fades-of-selected-items-to-configuration-f": 53485,
6641 "xenakios-sws-set-item-pitch-based-on-item-playrate": 53388,
6642 "xenakios-sws-set-item-playrate-based-on-item-pitch-and-reset-pitch": 53387,
6643 "xenakios-sws-set-item-rate-to-1-0-and-pitch-to-0-0": 53290,
6644 "xenakios-sws-set-master-volume-to-0-db": 53472,
6645 "xenakios-sws-set-next-fade-in-shape-for-items": 53381,
6646 "xenakios-sws-set-next-fade-out-shape-for-items": 53383,
6647 "xenakios-sws-set-previous-fade-in-shape-for-items": 53382,
6648 "xenakios-sws-set-previous-fade-out-shape-for-items": 53384,
6649 "xenakios-sws-set-render-speed-to-not-limited": 53436,
6650 "xenakios-sws-set-render-speed-to-realtime": 53435,
6651 "xenakios-sws-set-selected-track-as-reference-track": 53477,
6652 "xenakios-sws-set-selected-tracks-as-folder": 53338,
6653 "xenakios-sws-set-selected-tracks-heights-to-a": 53330,
6654 "xenakios-sws-set-selected-tracks-heights-to-b": 53331,
6655 "xenakios-sws-set-selected-tracks-record-armed": 53322,
6656 "xenakios-sws-set-selected-tracks-record-unarmed": 53323,
6657 "xenakios-sws-set-volume-and-pan-of-selected-takes": 53291,
6658 "xenakios-sws-set-volume-of-selected-items": 53292,
6659 "xenakios-sws-set-volume-of-selected-tracks-to-0-0-db": 53305,
6660 "xenakios-sws-shift-all-points-in-selected-envelope-to-left-by-1-second": 53479,
6661 "xenakios-sws-shift-all-points-in-selected-envelope-to-right-by-1-second": 53478,
6662 "xenakios-sws-show-hide-floating-item-track-info": 53390,
6663 "xenakios-sws-shuffle-order-of-selected-items": 53451,
6664 "xenakios-sws-shuffle-order-of-selected-items-keep-relative-positions": 53450,
6665 "xenakios-sws-skip-select-items-from-selected-items": 53236,
6666 "xenakios-sws-skip-select-items-in-selected-tracks": 53235,
6667 "xenakios-sws-split-items-at-transients": 53299,
6668 "xenakios-sws-spread-selected-items-over-4-tracks": 53391,
6669 "xenakios-sws-spread-selected-items-over-tracks": 53392,
6670 "xenakios-sws-stop-current-media-item-take-preview": 53361,
6671 "xenakios-sws-store-current-selected-takes": 53251,
6672 "xenakios-sws-store-edit-cursor-position": 53375,
6673 "xenakios-sws-store-render-speed": 53437,
6674 "xenakios-sws-store-selected-tracks-heights": 53332,
6675 "xenakios-sws-swing-item-positions": 53443,
6676 "xenakios-sws-switch-item-contents-to-first-cue": 53244,
6677 "xenakios-sws-switch-item-contents-to-next-cue": 53240,
6678 "xenakios-sws-switch-item-contents-to-next-cue-preserve-item-length": 53241,
6679 "xenakios-sws-switch-item-contents-to-previous-cue": 53242,
6680 "xenakios-sws-switch-item-contents-to-previous-cue-preserve-item-length": 53243,
6681 "xenakios-sws-switch-item-contents-to-random-cue": 53245,
6682 "xenakios-sws-switch-item-contents-to-random-cue-preserve-item-length": 53246,
6683 "xenakios-sws-switch-item-source-file-to-next-in-folder": 53444,
6684 "xenakios-sws-switch-item-source-file-to-next-rpp-in-folder": 53447,
6685 "xenakios-sws-switch-item-source-file-to-previous-in-folder": 53445,
6686 "xenakios-sws-switch-item-source-file-to-previous-rpp-in-folder": 53448,
6687 "xenakios-sws-switch-item-source-file-to-random-in-folder": 53446,
6688 "xenakios-sws-take-mixer": 53352,
6689 "xenakios-sws-time-selection-adaptive-delete": 53440,
6690 "xenakios-sws-toggle-reference-track": 53476,
6691 "xenakios-sws-toggle-ripple-edit-all-tracks-on-off": 53419,
6692 "xenakios-sws-toggle-ripple-edit-one-track-on-off": 53418,
6693 "xenakios-sws-toggle-selected-items-selected-randomly": 53349,
6694 "xenakios-sws-toggle-selected-takes-normalized-unity-gain": 53293,
6695 "xenakios-sws-toggle-selected-tracks-height-a-b": 53416,
6696 "xenakios-sws-trim-untrim-item-left-edge-to-edit-cursor": 53266,
6697 "xenakios-sws-trim-untrim-item-right-edge-to-edit-cursor": 53267,
6698 "xenakios-sws-unbypass-fx-of-selected-tracks": 53329,
6699 "xenakios-sws-unselect-tracks-with-buss-in-name": 53336,
6700} as const satisfies Record<string, number>;
6701
6702export type ReaperActionId = keyof typeof REAPER_ACTIONS;
src/Reaper/enumerate_actions.lua deleted-69
...@@ -1,69 +0,0 @@
1local output_path = OUTPUT_PATH or os.getenv("REAPER_ACTIONS_OUTPUT_PATH")
2if not output_path or output_path == "" then
3 error("REAPER_ACTIONS_OUTPUT_PATH is not set")
4end
5
6local section = reaper.SectionFromUniqueID(0)
7if not section then
8 error("Failed to resolve the main action section")
9end
10
11local function json_escape(value)
12 return value:gsub('[%z\1-\31\\"]', function(char)
13 if char == "\\" then
14 return "\\\\"
15 end
16 if char == "\"" then
17 return "\\\""
18 end
19 if char == "\b" then
20 return "\\b"
21 end
22 if char == "\f" then
23 return "\\f"
24 end
25 if char == "\n" then
26 return "\\n"
27 end
28 if char == "\r" then
29 return "\\r"
30 end
31 if char == "\t" then
32 return "\\t"
33 end
34
35 return string.format("\\u%04x", char:byte())
36 end)
37end
38
39local handle = assert(io.open(output_path, "wb"))
40handle:write("[\n")
41
42local first = true
43local index = 0
44
45while true do
46 local command_id, name = reaper.kbd_enumerateActions(section, index)
47 if command_id == 0 then
48 break
49 end
50
51 if name and name ~= "" then
52 if not first then
53 handle:write(",\n")
54 end
55 first = false
56 handle:write(
57 string.format(
58 ' {"commandId":%d,"name":"%s"}',
59 command_id,
60 json_escape(name)
61 )
62 )
63 end
64
65 index = index + 1
66end
67
68handle:write("\n]\n")
69handle:close()
src/Reaper/generate-actions.ts deleted-176
...@@ -1,176 +0,0 @@
1import { execFile } from "node:child_process";
2import { access, mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
3import { tmpdir } from "node:os";
4import { dirname, join, resolve } from "node:path";
5import process from "node:process";
6import { fileURLToPath } from "node:url";
7import { promisify } from "node:util";
8
9type RawReaperAction = {
10 commandId: number;
11 name: string;
12};
13
14type GeneratedReaperAction = RawReaperAction & {
15 actionId: string;
16};
17
18const DEFAULT_REAPER_BIN = "/Applications/REAPER.app/Contents/MacOS/REAPER";
19const srcDir = dirname(fileURLToPath(import.meta.url));
20const repoRoot = resolve(srcDir, "..", "..");
21const enumeratorPath = join(
22 repoRoot,
23 "config",
24 "scripts",
25 "reaper",
26 "enumerate_actions.lua",
27);
28const outputPath = join(srcDir, "actions.ts");
29const execFileAsync = promisify(execFile);
30
31async function main() {
32 const tempDir = await mkdtemp(join(tmpdir(), "reaper-actions-"));
33 const dumpPath = join(tempDir, "actions.json");
34 const runnerPath = join(tempDir, "run_enumerator.lua");
35
36 try {
37 await writeFile(runnerPath, buildRunnerScript(dumpPath), "utf8");
38 await execFileAsync(
39 process.env.REAPER_BIN ?? DEFAULT_REAPER_BIN,
40 ["-nonewinst", runnerPath],
41 );
42 await waitForFile(dumpPath);
43
44 const rawActions = parseRawActions(
45 JSON.parse(await readFile(dumpPath, "utf8")) as unknown,
46 );
47 const actions = buildActionMap(rawActions);
48 await mkdir(dirname(outputPath), { recursive: true });
49 await writeFile(outputPath, renderActionsFile(actions), "utf8");
50
51 console.info(
52 `Generated ${actions.length} REAPER actions in src/Reaper/actions.ts`,
53 );
54 } finally {
55 await rm(tempDir, { force: true, recursive: true });
56 }
57}
58
59function buildRunnerScript(dumpPath: string) {
60 return [
61 `OUTPUT_PATH = ${JSON.stringify(dumpPath)}`,
62 `dofile(${JSON.stringify(enumeratorPath)})`,
63 "",
64 ].join("\n");
65}
66
67function parseRawActions(value: unknown): RawReaperAction[] {
68 if (!Array.isArray(value)) {
69 throw new Error("Expected REAPER action dump to be an array.");
70 }
71
72 return value.flatMap((entry) => {
73 if (
74 !entry
75 || typeof entry !== "object"
76 || !("commandId" in entry)
77 || !("name" in entry)
78 ) {
79 return [];
80 }
81
82 const commandId = Number(entry.commandId);
83 const name = String(entry.name ?? "").trim();
84 if (!Number.isInteger(commandId) || name === "") {
85 return [];
86 }
87
88 return [{ commandId, name }];
89 });
90}
91
92function buildActionMap(actions: ReadonlyArray<RawReaperAction>) {
93 const usedIds = new Set<string>();
94
95 return [...actions]
96 .map((action) => ({
97 ...action,
98 actionId: createUniqueActionId(action.name, action.commandId, usedIds),
99 }))
100 .sort((left, right) => left.actionId.localeCompare(right.actionId));
101}
102
103function createUniqueActionId(
104 name: string,
105 commandId: number,
106 usedIds: Set<string>,
107) {
108 const baseId = toKebabCase(name) || `action-${commandId}`;
109 let actionId = baseId;
110 let duplicateIndex = 2;
111
112 while (usedIds.has(actionId)) {
113 actionId = `${baseId}-${commandId}`;
114 if (!usedIds.has(actionId)) {
115 break;
116 }
117 actionId = `${baseId}-${commandId}-${duplicateIndex}`;
118 duplicateIndex += 1;
119 }
120
121 usedIds.add(actionId);
122 return actionId;
123}
124
125function toKebabCase(value: string) {
126 return value
127 .normalize("NFKD")
128 .replace(/[\u0300-\u036f]/gu, "")
129 .toLowerCase()
130 .replace(/&/gu, " and ")
131 .replace(/\+/gu, " plus ")
132 .replace(/#/gu, " number ")
133 .replace(/%/gu, " percent ")
134 .replace(/[^a-z0-9]+/gu, "-")
135 .replace(/^-+|-+$/gu, "")
136 .replace(/-{2,}/gu, "-");
137}
138
139function renderActionsFile(actions: ReadonlyArray<GeneratedReaperAction>) {
140 const lines = [
141 "// Generated by src/Reaper/generate-actions.ts",
142 "// Source: REAPER main action section via kbd_enumerateActions()/kbd_getTextFromCmd().",
143 "",
144 "export const REAPER_ACTIONS = {",
145 ...actions.map((action) => ` ${JSON.stringify(action.actionId)}: ${action.commandId},`),
146 "} as const satisfies Record<string, number>;",
147 "",
148 "export type ReaperActionId = keyof typeof REAPER_ACTIONS;",
149 "",
150 ];
151
152 return lines.join("\n");
153}
154
155async function waitForFile(filePath: string, timeoutMs = 30_000) {
156 const startedAt = Date.now();
157
158 while (Date.now() - startedAt < timeoutMs) {
159 try {
160 await access(filePath);
161 return;
162 } catch {}
163
164 await sleep(200);
165 }
166
167 throw new Error(`Timed out waiting for ${filePath}`);
168}
169
170function sleep(ms: number) {
171 return new Promise<void>((resolve) => {
172 setTimeout(resolve, ms);
173 });
174}
175
176await main();
src/Recorder/build.sh deleted-69
...@@ -1,69 +0,0 @@
1#!/usr/bin/env bash
2# Build the Clover Recorder capture engine and wrap it in a minimal .app bundle.
3#
4# macOS only grants and reliably lists *app bundles* (stable bundle id) under
5# Privacy → Screen Recording, so even the headless capture core ships as an app.
6#
7# Usage: ./build.sh (run on the Mac that will do the recording)
8set -euo pipefail
9
10HERE="$(cd "$(dirname "$0")" && pwd)"
11ENGINE="$HERE/engine"
12DIST="$HERE/dist"
13APP="$DIST/Clover Recorder.app"
14BUNDLE_ID="org.clover.recorder"
15VERSION="0.1.0"
16
17echo "==> swift build (release)"
18( cd "$ENGINE" && swift build -c release )
19BIN="$ENGINE/.build/release/recorder"
20
21echo "==> assembling $APP"
22rm -rf "$APP"
23mkdir -p "$APP/Contents/MacOS"
24cp "$BIN" "$APP/Contents/MacOS/recorder"
25
26echo "==> compiling uvc-powerline helper"
27clang -O2 -o "$APP/Contents/MacOS/uvc-powerline" "$HERE/uvc/uvc-powerline.c" \
28 -framework IOKit -framework CoreFoundation
29
30echo "==> bundling dictation scripts"
31mkdir -p "$APP/Contents/Resources"
32cp "$HERE"/dictation/*.py "$APP/Contents/Resources/"
33
34cat > "$APP/Contents/Info.plist" <<EOF
35<?xml version="1.0" encoding="UTF-8"?>
36<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
37<plist version="1.0"><dict>
38 <key>CFBundleIdentifier</key><string>$BUNDLE_ID</string>
39 <key>CFBundleName</key><string>Clover Recorder</string>
40 <key>CFBundleExecutable</key><string>recorder</string>
41 <key>CFBundlePackageType</key><string>APPL</string>
42 <key>CFBundleShortVersionString</key><string>$VERSION</string>
43 <key>CFBundleVersion</key><string>$VERSION</string>
44 <key>LSMinimumSystemVersion</key><string>14.0</string>
45 <key>LSUIElement</key><true/>
46 <key>NSMicrophoneUsageDescription</key><string>Clover Recorder records your microphone for journaling and improv sessions.</string>
47 <key>NSCameraUsageDescription</key><string>Clover Recorder records your webcam for journaling and improv sessions.</string>
48</dict></plist>
49EOF
50
51# Sign with the stable self-signed identity from the dedicated Clover keychain
52# (see setup-signing.sh). This keeps the same designated requirement across
53# rebuilds, so the Screen Recording grant survives. Falls back to ad-hoc.
54CN="Clover Code Signing"
55KC="$HOME/Library/Keychains/clover-signing.keychain-db"
56KCPW="${CLOVER_KEYCHAIN_PW:-clover}"
57
58if [[ -f "$KC" ]] && security find-identity -p codesigning "$KC" 2>/dev/null | grep -q "$CN"; then
59 security unlock-keychain -p "$KCPW" "$KC" 2>/dev/null || true
60 echo "==> codesign with stable identity '$CN'"
61 codesign --force --keychain "$KC" --sign "$CN" --timestamp=none "$APP"
62else
63 echo "==> codesign ad-hoc (run setup-signing.sh once for a stable identity)"
64 codesign --force --sign - "$APP"
65fi
66
67codesign -dv "$APP" 2>&1 | sed -n '1,4p' || true
68echo "==> built: $APP"
69echo " binary: $APP/Contents/MacOS/recorder"
src/Recorder/dictation/diarize.py deleted-37
...@@ -1,37 +0,0 @@
1#!/usr/bin/env python3
2"""Speaker diarization (pyannote) — prints turn boundaries as JSON.
3
4Runs in the dedicated ~/.clover-diarize venv (pinned deps). session_transcript
5calls this to get precise "who spoke when" boundaries; the main venv then names
6the speakers (ECAPA) and splits the forced-aligned words at the turn edges.
7
8 python diarize.py <audio> -> [{"start":..,"end":..,"speaker":"SPEAKER_00"}, ...]
9"""
10import json
11import subprocess
12import sys
13import warnings
14
15warnings.filterwarnings("ignore")
16
17import numpy as np
18import torch
19from pyannote.audio import Pipeline
20
21audio = sys.argv[1]
22pipe = Pipeline.from_pretrained("pyannote/speaker-diarization-3.1")
23if torch.backends.mps.is_available():
24 pipe.to(torch.device("mps"))
25
26raw = subprocess.run(
27 ["ffmpeg", "-nostdin", "-i", audio, "-f", "f32le", "-ac", "1", "-ar", "16000", "-"],
28 capture_output=True,
29).stdout
30wav = torch.from_numpy(np.frombuffer(raw, np.float32).copy()).unsqueeze(0)
31
32dia = pipe({"waveform": wav, "sample_rate": 16000})
33turns = [
34 {"start": float(t.start), "end": float(t.end), "speaker": spk}
35 for t, _, spk in dia.itertracks(yield_label=True)
36]
37print(json.dumps(turns))
src/Recorder/dictation/enroll.py deleted-15
...@@ -1,15 +0,0 @@
1#!/usr/bin/env python3
2"""Enroll a named voice into the library from a recording.
3
4 python enroll.py <audio> [name] # default name: You
5"""
6import os
7import sys
8
9sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
10from speaker_id import embed_file, upsert_voice
11
12audio = sys.argv[1]
13name = sys.argv[2] if len(sys.argv) > 2 else "You"
14upsert_voice(name, embed_file(audio))
15print(f"enrolled {name}")
src/Recorder/dictation/forced_align.py deleted-70
...@@ -1,70 +0,0 @@
1"""Word-level forced alignment (torchaudio MMS_FA, the modern wav2vec2-CTC
2approach WhisperX popularized) — precise per-word start/end times, no token.
3
4Aligned per Whisper segment (small, fast) and offset back to absolute time.
5"""
6import re
7import warnings
8
9warnings.filterwarnings("ignore")
10
11import numpy as np
12import torch
13from torchaudio.pipelines import MMS_FA as B
14
15_model = _tok = _aligner = None
16
17
18def _load():
19 global _model, _tok, _aligner
20 if _model is None:
21 _model, _tok, _aligner = B.get_model(), B.get_tokenizer(), B.get_aligner()
22 return _model, _tok, _aligner
23
24
25def _norm(word):
26 return re.sub(r"[^a-z']", "", word.lower())
27
28
29def align_segment(data, sr, start, end, text):
30 """Return [{word, start, end}] for the words in this segment, in absolute
31 seconds. Words that can't be aligned (pure numbers/symbols) get times
32 interpolated from their neighbours."""
33 a, b = int(start * sr), int(end * sr)
34 seg = data[a:b]
35 raw = text.split()
36 if len(seg) < int(0.2 * sr) or not raw:
37 return [{"word": w, "start": start, "end": end} for w in raw]
38
39 norm = [_norm(w) for w in raw]
40 idx = [i for i, n in enumerate(norm) if n]
41 if not idx:
42 return [{"word": w, "start": start, "end": end} for w in raw]
43
44 model, tok, aligner = _load()
45 wav = torch.from_numpy(np.ascontiguousarray(seg)).unsqueeze(0)
46 with torch.inference_mode():
47 emit, _ = model(wav)
48 try:
49 spans = aligner(emit[0], tok([norm[i] for i in idx]))
50 except Exception:
51 return [{"word": w, "start": start, "end": end} for w in raw]
52
53 ratio = wav.shape[1] / emit.shape[1] / sr
54 times = {}
55 for k, i in enumerate(idx):
56 s = spans[k]
57 times[i] = (round(start + s[0].start * ratio, 3), round(start + s[-1].end * ratio, 3))
58
59 out = [{"word": w, "start": None, "end": None} for w in raw]
60 for i, t in times.items():
61 out[i]["start"], out[i]["end"] = t
62 # Interpolate unaligned words from neighbours.
63 last_end = start
64 for i, o in enumerate(out):
65 if o["start"] is None:
66 o["start"] = last_end
67 nxt = next((out[j]["start"] for j in range(i + 1, len(out)) if out[j]["start"]), end)
68 o["end"] = nxt
69 last_end = o["end"]
70 return out
src/Recorder/dictation/relabel.py deleted-49
...@@ -1,49 +0,0 @@
1#!/usr/bin/env python3
2"""Apply speaker names to a session: add newly-named voices to the library and
3re-render transcript.md — no re-transcription.
4
5 python relabel.py <session_dir> <mapping.json>
6
7mapping.json maps the auto-labels to names, e.g. {"Speaker 2": "Maya"}.
8"""
9import json
10import os
11import sys
12
13sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
14import speaker_id as sid
15from transcript_render import render
16
17session_dir, mapping_path = sys.argv[1], sys.argv[2]
18mapping = json.load(open(mapping_path))
19
20transcript = json.load(open(os.path.join(session_dir, "transcript.json")))
21spk_path = os.path.join(session_dir, "speakers.json")
22speakers = json.load(open(spk_path)) if os.path.exists(spk_path) else {"speakers": []}
23
24# Teach the library each newly-named unknown voice.
25for sp in speakers.get("speakers", []):
26 new = mapping.get(sp["label"])
27 if new and new != sp["label"] and sp.get("unknown") and sp.get("centroid"):
28 sid.upsert_voice(new, sp["centroid"])
29
30# Re-map labels and re-render.
31markers = []
32mp = os.path.join(session_dir, "markers.json")
33if os.path.exists(mp):
34 for mk in json.load(open(mp)).get("markers", []):
35 markers.append((float(mk.get("offsetSeconds", 0)), mk.get("text")))
36markers.sort(key=lambda x: x[0])
37
38segments = transcript["segments"]
39for s in segments:
40 s["label"] = mapping.get(s.get("label"), s.get("label"))
41render(transcript.get("title", "Session"), segments,
42 [s.get("label") for s in segments], markers,
43 os.path.join(session_dir, "transcript.md"))
44
45json.dump(transcript, open(os.path.join(session_dir, "transcript.json"), "w"))
46for sp in speakers.get("speakers", []):
47 sp["label"] = mapping.get(sp["label"], sp["label"])
48json.dump(speakers, open(spk_path, "w"))
49print("relabeled")
src/Recorder/dictation/session_transcript.py deleted-235
...@@ -1,235 +0,0 @@
1#!/usr/bin/env python3
2"""Transcribe a session's mic audio into a Markdown transcript with speakers.
3
4 python session_transcript.py <audio> <markers.json|none> <out.md> <title>
5
6Each segment is matched against the enrolled voice library (You + named guests);
7unmatched voices are clustered into distinct Speaker 2/3/… Writes:
8 - <out.md> meeting-minutes transcript, paragraphs labelled by speaker
9 - transcript.json segments + labels (for fast re-labelling after naming)
10 - speakers.json detected speakers + a sample clip + centroid (for the UI)
11"""
12import json
13import os
14import subprocess
15import sys
16
17import numpy as np
18import mlx_whisper
19
20sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
21import speaker_id as sid
22from transcript_render import PAUSE_SPLIT, render
23
24DIARIZE_PY = os.path.expanduser("~/.clover-diarize/.venv/bin/python")
25
26
27def assign_speakers(segments, data, sr):
28 """Cluster segments into voices, then match each *cluster* to the library —
29 far more robust than per-segment matching, so one person stays one speaker.
30
31 Returns (labels, speakers): labels[i] is the speaker for segment i; speakers
32 is per-cluster metadata for the review UI."""
33 from scipy.cluster.hierarchy import fcluster, linkage
34
35 library = [(v["name"], np.asarray(v["centroid"], dtype=np.float32)) for v in sid.load_library()]
36 embs = [sid.embed_segment(data, sr, float(s["start"]), float(s["end"])) for s in segments]
37 valid = [i for i, e in enumerate(embs) if e is not None]
38 if not valid:
39 return [None] * len(segments), []
40
41 # Agglomerative clustering on cosine distance (average linkage).
42 if len(valid) == 1:
43 cids = [1]
44 else:
45 Z = linkage(np.stack([embs[i] for i in valid]), method="average", metric="cosine")
46 cids = fcluster(Z, t=sid.CLUSTER_DIST, criterion="distance")
47 # Cap runaway fragmentation: if acoustic distance alone invents too many
48 # voices, collapse to at most MAX_SPEAKERS by cutting the tree higher.
49 if len(set(cids)) > sid.MAX_SPEAKERS:
50 cids = fcluster(Z, t=sid.MAX_SPEAKERS, criterion="maxclust")
51
52 members = {}
53 for pos, cid in enumerate(cids):
54 members.setdefault(int(cid), []).append(valid[pos])
55
56 # Label each cluster: known name if its centroid matches the library, else
57 # Speaker N (numbered by first appearance).
58 cluster_label, cluster_centroid, unknown_n = {}, {}, 2
59 for cid, idxs in sorted(members.items(), key=lambda kv: min(kv[1])):
60 cen = np.mean([embs[i] for i in idxs], axis=0)
61 cen /= np.linalg.norm(cen)
62 cluster_centroid[cid] = cen
63 name, sim = None, -1.0
64 for nm, c in library:
65 d = float(cen @ c)
66 if d > sim:
67 sim, name = d, nm
68 if sim >= sid.MATCH_THRESHOLD:
69 cluster_label[cid] = name
70 else:
71 cluster_label[cid] = f"Speaker {unknown_n}"
72 unknown_n += 1
73
74 labels = [None] * len(segments)
75 for cid, idxs in members.items():
76 for i in idxs:
77 labels[i] = cluster_label[cid]
78 prev = None # short (un-embedded) segments inherit the previous speaker
79 for i in range(len(labels)):
80 if labels[i] is None:
81 labels[i] = prev
82 else:
83 prev = labels[i]
84
85 speakers = []
86 for cid, idxs in sorted(members.items(), key=lambda kv: min(kv[1])):
87 lab = cluster_label[cid]
88 unknown = lab.startswith("Speaker ")
89 s = segments[idxs[0]]
90 speakers.append({
91 "label": lab, "unknown": unknown,
92 "sample": {"start": s["start"], "end": s["end"]},
93 "centroid": cluster_centroid[cid].tolist() if unknown else None,
94 })
95 return labels, speakers
96
97
98def run_diarization(audio):
99 """Precise speaker turns via the pyannote venv, or None if unavailable."""
100 if not os.path.exists(DIARIZE_PY):
101 return None
102 script = os.path.join(os.path.dirname(os.path.abspath(__file__)), "diarize.py")
103 try:
104 out = subprocess.run([DIARIZE_PY, script, audio], capture_output=True, text=True, timeout=3600)
105 turns = json.loads(out.stdout.strip().splitlines()[-1])
106 return turns or None
107 except Exception:
108 return None
109
110
111def name_speakers(turns, data, sr):
112 """Name each diarized speaker against the voice library (ECAPA)."""
113 library = [(v["name"], np.asarray(v["centroid"], dtype=np.float32)) for v in sid.load_library()]
114 by_spk, sample = {}, {}
115 for tr in turns:
116 e = sid.embed_segment(data, sr, tr["start"], tr["end"])
117 if e is not None:
118 by_spk.setdefault(tr["speaker"], []).append(e)
119 sample.setdefault(tr["speaker"], tr)
120
121 names, meta, unknown_n = {}, [], 2
122 for spk in sorted(sample, key=lambda s: sample[s]["start"]):
123 cen = None
124 if by_spk.get(spk):
125 cen = np.mean(by_spk[spk], axis=0)
126 cen /= np.linalg.norm(cen)
127 name, sim = None, -1.0
128 if cen is not None:
129 for nm, c in library:
130 d = float(cen @ c)
131 if d > sim:
132 sim, name = d, nm
133 if cen is not None and sim >= sid.MATCH_THRESHOLD:
134 names[spk], unknown = name, False
135 else:
136 names[spk], unknown = f"Speaker {unknown_n}", True
137 unknown_n += 1
138 meta.append({
139 "label": names[spk], "unknown": unknown,
140 "sample": {"start": sample[spk]["start"], "end": sample[spk]["end"]},
141 "centroid": cen.tolist() if (unknown and cen is not None) else None,
142 })
143 return names, meta
144
145
146def _speaker_at(turns, t):
147 for tr in turns:
148 if tr["start"] <= t < tr["end"]:
149 return tr["speaker"]
150 return min(turns, key=lambda tr: min(abs(tr["start"] - t), abs(tr["end"] - t)))["speaker"]
151
152
153def build_from_diarization(segments, turns, data, sr):
154 """Split forced-aligned words at diarization boundaries (precise) and label
155 each by the named speaker. Returns (final_segments, labels, speakers)."""
156 names, meta = name_speakers(turns, data, sr)
157 words = [dict(w) for s in segments for w in s.get("words", [])]
158 if not words:
159 return None
160
161 final = []
162 for w in words:
163 name = names.get(_speaker_at(turns, (float(w["start"]) + float(w["end"])) / 2))
164 if (final and final[-1]["label"] == name
165 and float(w["start"]) - final[-1]["end"] <= PAUSE_SPLIT):
166 final[-1]["end"] = float(w["end"])
167 final[-1]["text"] += " " + w["word"]
168 final[-1]["words"].append(w)
169 else:
170 final.append({
171 "start": float(w["start"]), "end": float(w["end"]),
172 "text": w["word"], "label": name, "words": [w],
173 })
174 return final, [s["label"] for s in final], meta
175
176
177def main():
178 audio, markers_path, out_path, title = sys.argv[1:5]
179 # "solo" (default): one speaker, no diarization — right for journaling and
180 # improv, where clustering just shatters your voice into fake speakers.
181 # "multi": diarize + name, for the occasional session with other people.
182 mode = sys.argv[5] if len(sys.argv) > 5 else "solo"
183 folder = os.path.dirname(out_path)
184
185 segments = mlx_whisper.transcribe(
186 audio, path_or_hf_repo="mlx-community/whisper-large-v3-turbo"
187 ).get("segments", [])
188 segments = [{"start": s["start"], "end": s["end"], "text": s["text"]} for s in segments]
189
190 data, sr = sid.load_audio(audio)
191 try:
192 import forced_align as fa
193
194 for s in segments:
195 s["words"] = fa.align_segment(data, sr, float(s["start"]), float(s["end"]), s["text"])
196 except Exception:
197 for s in segments:
198 s["words"] = []
199
200 if mode != "multi":
201 # Solo: everyone is "You". render() hides the single label, and the
202 # non-unknown speaker keeps the Speakers-review popup from firing.
203 labels = ["You"] * len(segments)
204 final_segments = [dict(s, label="You") for s in segments]
205 speakers = [{"label": "You", "unknown": False,
206 "sample": {"start": 0.0, "end": 0.0}, "centroid": None}]
207 else:
208 # Precise mode (pyannote) when available, else cluster-then-match fallback.
209 diarized = None
210 turns = run_diarization(audio)
211 if turns:
212 diarized = build_from_diarization(segments, turns, data, sr)
213 if diarized:
214 final_segments, labels, speakers = diarized
215 else:
216 try:
217 labels, speakers = assign_speakers(segments, data, sr)
218 except Exception:
219 labels, speakers = [None] * len(segments), []
220 final_segments = [dict(s, label=l) for s, l in zip(segments, labels)]
221
222 markers = []
223 if markers_path and markers_path != "none" and os.path.exists(markers_path):
224 for mk in json.load(open(markers_path)).get("markers", []):
225 markers.append((float(mk.get("offsetSeconds", 0)), mk.get("text")))
226 markers.sort(key=lambda x: x[0])
227
228 render(title, final_segments, labels, markers, out_path)
229 json.dump({"title": title, "segments": final_segments},
230 open(os.path.join(folder, "transcript.json"), "w"))
231 json.dump({"speakers": speakers}, open(os.path.join(folder, "speakers.json"), "w"))
232
233
234if __name__ == "__main__":
235 main()
src/Recorder/dictation/speaker_id.py deleted-114
...@@ -1,114 +0,0 @@
1"""Speaker embeddings via SpeechBrain ECAPA-TDNN (no HF token needed).
2
3Used to tell the user's voice from others in a session: enroll a voiceprint
4once, then score each transcript segment against it by cosine similarity.
5"""
6import json
7import os
8import subprocess
9import warnings
10
11warnings.filterwarnings("ignore")
12
13import numpy as np
14import torch
15import torchaudio
16
17LIBRARY = os.path.expanduser("~/.clover-whisper/voices.json")
18# Cosine above MATCH_THRESHOLD => a cluster is that library voice. CLUSTER_DIST
19# is the cosine *distance* below which segments merge into one speaker (so your
20# own voice stays a single cluster instead of fragmenting). Tunable.
21MATCH_THRESHOLD = 0.45
22CLUSTER_DIST = 0.55
23# Hard cap on distinct voices the fallback clustering may invent. Without it,
24# short/noisy segments and (in improv) character voices fragment one person into
25# dozens of "speakers". Real sessions here have a handful of people at most.
26MAX_SPEAKERS = 6
27
28_model = None
29
30
31def load_library():
32 if os.path.exists(LIBRARY):
33 return json.load(open(LIBRARY)).get("voices", [])
34 return []
35
36
37def save_library(voices):
38 os.makedirs(os.path.dirname(LIBRARY), exist_ok=True)
39 json.dump({"voices": voices}, open(LIBRARY, "w"))
40
41
42def upsert_voice(name, centroid, count=1):
43 """Add a named voice, or blend into an existing one (running average)."""
44 centroid = np.asarray(centroid, dtype=np.float32)
45 voices = load_library()
46 for v in voices:
47 if v["name"] == name:
48 c, n = np.asarray(v["centroid"], dtype=np.float32), v.get("count", 1)
49 blended = (c * n + centroid * count) / (n + count)
50 blended /= np.linalg.norm(blended)
51 v["centroid"], v["count"] = blended.tolist(), n + count
52 save_library(voices)
53 return
54 voices.append({"name": name, "centroid": centroid.tolist(), "count": count})
55 save_library(voices)
56
57
58def model():
59 global _model
60 if _model is None:
61 from speechbrain.inference.speaker import EncoderClassifier
62
63 _model = EncoderClassifier.from_hparams(
64 source="speechbrain/spkrec-ecapa-voxceleb", run_opts={"device": "cpu"}
65 )
66 return _model
67
68
69def _ffmpeg():
70 user = os.environ.get("USER", "")
71 for c in (
72 f"/etc/profiles/per-user/{user}/bin/ffmpeg",
73 "/run/current-system/sw/bin/ffmpeg",
74 "/opt/homebrew/bin/ffmpeg",
75 "/usr/local/bin/ffmpeg",
76 ):
77 if os.path.exists(c):
78 return c
79 return "ffmpeg"
80
81
82def load_audio(path, target_sr=16000):
83 """Decode any format (m4a/wav/aiff/…) to mono float32 via ffmpeg."""
84 out = subprocess.run(
85 [_ffmpeg(), "-nostdin", "-i", path, "-f", "f32le", "-ac", "1", "-ar", str(target_sr), "-"],
86 capture_output=True,
87 ).stdout
88 return np.frombuffer(out, dtype=np.float32).copy(), target_sr
89
90
91def embed_array(data, sr):
92 sig = torch.from_numpy(np.ascontiguousarray(data)).unsqueeze(0)
93 if sr != 16000:
94 sig = torchaudio.functional.resample(sig, sr, 16000)
95 e = model().encode_batch(sig).squeeze()
96 e = e / e.norm()
97 return e.detach().cpu().numpy()
98
99
100def embed_file(path):
101 data, sr = load_audio(path)
102 return embed_array(data, sr)
103
104
105def embed_segment(data, sr, start, end):
106 a, b = int(start * sr), int(end * sr)
107 seg = data[a:b]
108 if len(seg) < int(0.8 * sr): # too short to be reliable
109 return None
110 return embed_array(seg, sr)
111
112
113def cosine(a, b):
114 return float(np.dot(a, b))
src/Recorder/dictation/transcribe.py deleted-20
...@@ -1,20 +0,0 @@
1#!/usr/bin/env python3
2"""Transcribe an audio file with MLX Whisper (large-v3-turbo) and print the text.
3
4Used by Clover Recorder's marker overlay for voice notes. Runs against the venv
5created by setup-dictation.sh (~/.clover-whisper/.venv).
6
7 python transcribe.py <audio-file>
8"""
9import sys
10
11import mlx_whisper
12
13if len(sys.argv) < 2:
14 sys.exit("usage: transcribe.py <audio-file>")
15
16result = mlx_whisper.transcribe(
17 sys.argv[1],
18 path_or_hf_repo="mlx-community/whisper-large-v3-turbo",
19)
20print(result["text"].strip())
src/Recorder/dictation/transcript_render.py deleted-47
...@@ -1,47 +0,0 @@
1"""Render a Markdown transcript from segments + speaker labels + markers.
2
3Shared by session_transcript.py (first pass) and relabel.py (after naming).
4"""
5PAUSE_SPLIT = 2.0 # seconds of silence that starts a new paragraph
6
7
8def fmt(seconds):
9 s = int(seconds)
10 h, m, sec = s // 3600, (s % 3600) // 60, s % 60
11 return f"{h}:{m:02d}:{sec:02d}" if h else f"{m:02d}:{sec:02d}"
12
13
14def render(title, segments, labels, markers, out_path):
15 lines = [f"# {title}", ""]
16 para, p_start, p_spk, prev_end, mi = [], 0.0, None, 0.0, 0
17 # Only show speaker names when there's actually more than one speaker.
18 show_speakers = len({l for l in labels if l}) > 1
19
20 def flush():
21 if para:
22 who = f"{p_spk} · " if (show_speakers and p_spk) else ""
23 lines.append(f"**{who}[{fmt(p_start)}]** " + " ".join(para).strip())
24 lines.append("")
25 para.clear()
26
27 def emit_marker(t, text):
28 flush()
29 lines.append(f"**{text.strip()}**" if text and text.strip() else f"**◆ [{fmt(t)}]**")
30 lines.append("")
31
32 for seg, spk in zip(segments, labels):
33 start = float(seg["start"])
34 while mi < len(markers) and markers[mi][0] <= start:
35 emit_marker(*markers[mi])
36 mi += 1
37 if para and (start - prev_end > PAUSE_SPLIT or spk != p_spk):
38 flush()
39 if not para:
40 p_start, p_spk = start, spk
41 para.append(seg["text"].strip())
42 prev_end = float(seg["end"])
43 flush()
44 while mi < len(markers):
45 emit_marker(*markers[mi])
46 mi += 1
47 open(out_path, "w").write("\n".join(lines) + "\n")
src/Recorder/engine/Package.swift deleted-22
...@@ -1,22 +0,0 @@
1// swift-tools-version: 6.0
2import PackageDescription
3
4// Capture core for Clover Recorder.
5//
6// A small ScreenCaptureKit + AVFoundation command-line engine that records any
7// combination of displays, the system-audio mix, the microphone, and (later) a
8// webcam into one session folder, each stream as its own file, all timestamped
9// against the shared mach host clock so the streams can be re-aligned exactly.
10//
11// No external dependencies, so it builds fully offline.
12let package = Package(
13 name: "recorder",
14 platforms: [.macOS(.v14)],
15 targets: [
16 .executableTarget(
17 name: "recorder",
18 path: "Sources/recorder",
19 swiftSettings: [.swiftLanguageMode(.v5)]
20 )
21 ]
22)
src/Recorder/engine/Sources/recorder/CameraPreview.swift deleted-46
...@@ -1,46 +0,0 @@
1import AVFoundation
2import AppKit
3import SwiftUI
4
5/// Live camera preview for the popover, backed by an AVCaptureVideoPreviewLayer.
6struct CameraPreview: NSViewRepresentable {
7 let session: AVCaptureSession?
8
9 func makeNSView(context: Context) -> PreviewNSView { PreviewNSView() }
10
11 func updateNSView(_ nsView: PreviewNSView, context: Context) {
12 if nsView.previewLayer.session !== session {
13 nsView.previewLayer.session = session
14 }
15 }
16}
17
18/// Detachable camera window — stays live during recording for mid-stream framing.
19struct CameraPopoutView: View {
20 @ObservedObject var controller: AppController
21 var body: some View {
22 CameraPreview(session: controller.previewSession)
23 .frame(maxWidth: .infinity, maxHeight: .infinity)
24 .background(Color.black)
25 }
26}
27
28final class PreviewNSView: NSView {
29 let previewLayer = AVCaptureVideoPreviewLayer()
30
31 override init(frame frameRect: NSRect) {
32 super.init(frame: frameRect)
33 wantsLayer = true
34 layer = CALayer()
35 layer?.backgroundColor = NSColor.black.cgColor
36 previewLayer.videoGravity = .resizeAspect
37 layer?.addSublayer(previewLayer)
38 }
39
40 required init?(coder: NSCoder) { fatalError("init(coder:) unused") }
41
42 override func layout() {
43 super.layout()
44 previewLayer.frame = bounds
45 }
46}
src/Recorder/engine/Sources/recorder/CaptureEngine.swift deleted-444
...@@ -1,444 +0,0 @@
1import AVFoundation
2import CoreGraphics
3import CoreMedia
4import Foundation
5import ScreenCaptureKit
6
7struct RecordConfig {
8 var outDir: URL
9 var label: String
10 var displayIDs: [CGDirectDisplayID] // empty = all
11 var systemAudio: Bool
12 var micUID: String?
13 var cameraUID: String?
14 var fps: Int
15 var maxWidth: Int // clamp longest side (HiDPI backing buffers are huge)
16 var bitsPerPixel: Double
17 var duration: Double? // auto-stop after N seconds; nil = until signal
18 var logPath: String?
19 var safeDir: URL? // internal-SSD home for audio + rollover parts (nil = outDir)
20 var cameraHeight: Int = 1080 // 720 / 1080 / …
21 var cameraFps: Int = 30
22}
23
24final class CaptureEngine {
25 private let cfg: RecordConfig
26 private var sinks: [RecordingStream] = []
27 private var cfrWriters: [CFRVideoWriter] = []
28 private var streams: [SCStream] = []
29 private var scOutputs: [SCOutput] = []
30 private var captureSessions: [AVCaptureSession] = []
31 private var avOutputs: [AVOutput] = []
32 private var sessionObservers: [NSObjectProtocol] = []
33
34 private var createdEpoch: Double = 0
35 private var hostClockAtStart: Double = 0
36
37 /// Problem reports for the UI (device silent, disk vanished, stream lost…).
38 /// Each distinct condition fires once. Called on a background queue.
39 var onEvent: ((String) -> Void)?
40 private var watchdog: DispatchSourceTimer?
41 private var reportedEvents = Set<String>()
42 private let reportLock = NSLock()
43
44 /// Where audio and rescued streams live: the most reliable disk we have.
45 private var safeRoot: URL { cfg.safeDir ?? cfg.outDir }
46
47 init(_ cfg: RecordConfig) { self.cfg = cfg }
48
49 func start() async throws {
50 if let logPath = cfg.logPath { openLogFile(logPath) }
51 try FileManager.default.createDirectory(at: cfg.outDir, withIntermediateDirectories: true)
52 try? FileManager.default.createDirectory(at: safeRoot, withIntermediateDirectories: true)
53 createdEpoch = Date().timeIntervalSince1970
54 hostClockAtStart = hostSeconds()
55
56 let content = try await SCShareableContent.excludingDesktopWindows(
57 false, onScreenWindowsOnly: false)
58 let allDisplays = content.displays.sorted { $0.frame.origin.x < $1.frame.origin.x }
59
60 let chosen: [SCDisplay]
61 if cfg.displayIDs.isEmpty {
62 chosen = allDisplays
63 } else {
64 chosen = cfg.displayIDs.compactMap { id in allDisplays.first { $0.displayID == id } }
65 }
66 if chosen.isEmpty { throw RecorderError("no matching displays to capture") }
67
68 // Capture system audio piggy-backed on the first display's stream.
69 var systemAudioWriter: StreamWriter?
70 if cfg.systemAudio {
71 systemAudioWriter = try makeAudioWriter(name: "desktop", kind: "system-audio")
72 sinks.append(systemAudioWriter!)
73 }
74
75 for (index, display) in chosen.enumerated() {
76 let name = "screen-\(index + 1)"
77 let (outW, outH) = outputSize(for: display)
78
79 let writer = try CFRVideoWriter(
80 url: cfg.outDir.appendingPathComponent("\(name).mov"),
81 name: name, width: outW, height: outH, fps: cfg.fps, bitrate: screenBitrate(outW, outH),
82 fallbackDir: safeRoot)
83 writer.displayID = display.displayID
84 sinks.append(writer)
85 cfrWriters.append(writer)
86
87 let cfgSC = SCStreamConfiguration()
88 cfgSC.minimumFrameInterval = CMTime(value: 1, timescale: CMTimeScale(cfg.fps))
89 cfgSC.queueDepth = 8
90 cfgSC.showsCursor = true
91 cfgSC.pixelFormat = kCVPixelFormatType_420YpCbCr8BiPlanarVideoRange
92 cfgSC.width = outW
93 cfgSC.height = outH
94
95 let attachAudioHere = (index == 0) && (systemAudioWriter != nil)
96 if attachAudioHere {
97 cfgSC.capturesAudio = true
98 cfgSC.sampleRate = 48_000
99 cfgSC.channelCount = 2
100 }
101
102 let filter = SCContentFilter(display: display, excludingWindows: [])
103 let output = SCOutput(
104 label: name,
105 onScreen: { [weak writer] sb in writer?.update(sb) },
106 onAudio: attachAudioHere
107 ? { [weak systemAudioWriter] sb in systemAudioWriter?.append(sb) } : nil)
108 output.onStreamError = { [weak self] message in
109 self?.report("scstop-\(name)", message + " (stop and restart the session)")
110 }
111 scOutputs.append(output)
112
113 let stream = SCStream(filter: filter, configuration: cfgSC, delegate: output)
114 try stream.addStreamOutput(
115 output, type: .screen,
116 sampleHandlerQueue: DispatchQueue(label: "clover.sc.screen.\(name)"))
117 if attachAudioHere {
118 try stream.addStreamOutput(
119 output, type: .audio,
120 sampleHandlerQueue: DispatchQueue(label: "clover.sc.audio"))
121 }
122 streams.append(stream)
123 }
124
125 if let micUID = cfg.micUID {
126 try startAVCapture(audioUID: micUID)
127 }
128 if let cameraUID = cfg.cameraUID {
129 try startAVCapture(videoUID: cameraUID)
130 }
131
132 for stream in streams {
133 try await stream.startCapture()
134 }
135 // Begin emitting constant-rate frames now that capture is live.
136 for writer in cfrWriters {
137 writer.start()
138 }
139 startWatchdog()
140 logInfo(
141 "recording \(chosen.count) screen(s)"
142 + (cfg.systemAudio ? " + desktop" : "")
143 + (cfg.micUID != nil ? " + mic" : "")
144 + (cfg.cameraUID != nil ? " + camera" : ""))
145 }
146
147 @discardableResult
148 func stop() async -> [StreamManifest] {
149 watchdog?.cancel()
150 watchdog = nil
151 for observer in sessionObservers { NotificationCenter.default.removeObserver(observer) }
152 sessionObservers.removeAll()
153 for output in scOutputs {
154 logInfo(
155 "SC \(output.label): screen seen \(output.screenSeen) complete \(output.screenComplete)"
156 + (output.audioSeen > 0 ? " audio \(output.audioSeen)" : ""))
157 }
158 for stream in streams {
159 try? await stream.stopCapture()
160 }
161 for session in captureSessions {
162 session.stopRunning()
163 }
164 // Give in-flight buffers a moment to drain before finalizing.
165 try? await Task.sleep(nanoseconds: 200_000_000)
166 for sink in sinks {
167 await sink.finish()
168 }
169 return writeManifest()
170 }
171
172 // MARK: Watchdog
173
174 /// Every couple of seconds: is every stream still producing data, and is the
175 /// scratch volume still there? Problems surface immediately (so a dead mic is
176 /// caught seconds in, not discovered after a 4-hour session) and disk loss
177 /// triggers a live rollover onto the internal SSD.
178 private func startWatchdog() {
179 let startedAt = hostSeconds()
180 let t = DispatchSource.makeTimerSource(queue: DispatchQueue(label: "clover.watchdog"))
181 t.schedule(deadline: .now() + 2, repeating: 2)
182 t.setEventHandler { [weak self] in self?.checkHealth(startedAt: startedAt) }
183 watchdog = t
184 t.resume()
185 }
186
187 private func report(_ key: String, _ message: String) {
188 reportLock.lock()
189 let fresh = reportedEvents.insert(key).inserted
190 reportLock.unlock()
191 guard fresh else { return }
192 logErr(message)
193 onEvent?(message)
194 }
195
196 private func checkHealth(startedAt: Double) {
197 // Scratch volume gone? Move every writer still pointing at it onto the
198 // safe (internal) disk before more data piles up in doomed buffers.
199 if let safe = cfg.safeDir, safe.path != cfg.outDir.path,
200 !FileManager.default.fileExists(atPath: cfg.outDir.path)
201 {
202 report(
203 "outdir-gone",
204 "Recording drive disappeared — video continues on the internal SSD. "
205 + "Earlier video is recoverable from the drive once it's reconnected.")
206 for sink in sinks { sink.rollover(to: safe) }
207 }
208
209 let now = hostSeconds()
210 for sink in sinks {
211 let h = sink.health()
212 if h.dead {
213 report(
214 "dead-\(h.name)",
215 "\(h.name): recording stopped after repeated write failures — "
216 + "stop the session and check the disks.")
217 } else if !h.started, h.lastAppendHost.isNaN, now - startedAt > 10 {
218 report(
219 "silent-\(h.name)",
220 "\(h.name) has produced no data — check the device "
221 + "(it may be disconnected or in use by another app).")
222 } else if h.started, !h.lastAppendHost.isNaN, now - h.lastAppendHost > 8 {
223 report(
224 "stall-\(h.name)",
225 "\(h.name) stopped producing data — check the device and disks.")
226 }
227 }
228 }
229
230 // MARK: writers
231
232 private func screenBitrate(_ width: Int, _ height: Int) -> Int {
233 max(2_000_000, Int(Double(width * height * cfg.fps) * cfg.bitsPerPixel))
234 }
235
236 private func cameraPreset(forHeight height: Int) -> (AVCaptureSession.Preset, Int, Int) {
237 switch height {
238 case ...480: return (.vga640x480, 640, 480)
239 case 481...720: return (.hd1280x720, 1280, 720)
240 default: return (.hd1920x1080, 1920, 1080)
241 }
242 }
243
244 /// Best-effort frame-rate lock, clamped to what the camera supports (old
245 /// webcams are often 30-only, so 60 just stays 30).
246 private func configureCameraFrameRate(_ device: AVCaptureDevice) {
247 guard let range = device.activeFormat.videoSupportedFrameRateRanges.first else { return }
248 do {
249 try device.lockForConfiguration()
250 let wanted = CMTime(value: 1, timescale: Int32(cfg.cameraFps))
251 let dur = CMTimeMaximum(range.minFrameDuration, CMTimeMinimum(wanted, range.maxFrameDuration))
252 device.activeVideoMinFrameDuration = dur
253 device.activeVideoMaxFrameDuration = dur
254 device.unlockForConfiguration()
255 } catch {
256 logErr("camera fps: \(error.localizedDescription)")
257 }
258 }
259
260 private func makeVideoWriter(name: String, kind: String, width: Int, height: Int) throws
261 -> StreamWriter
262 {
263 let settings: [String: Any] = [
264 AVVideoCodecKey: AVVideoCodecType.hevc,
265 AVVideoWidthKey: width,
266 AVVideoHeightKey: height,
267 AVVideoCompressionPropertiesKey: [
268 AVVideoAverageBitRateKey: screenBitrate(width, height),
269 AVVideoExpectedSourceFrameRateKey: cfg.fps,
270 AVVideoMaxKeyFrameIntervalKey: cfg.fps * 2,
271 ],
272 ]
273 return try StreamWriter(
274 url: cfg.outDir.appendingPathComponent("\(name).mov"),
275 name: name, kind: kind, fileType: .mov, settings: settings, mediaType: .video,
276 fallbackDir: safeRoot)
277 }
278
279 private func makeAudioWriter(name: String, kind: String) throws -> StreamWriter {
280 // AAC in .m4a: ~256 kbps stereo, transparent for voice/desktop and tiny next
281 // to the uncompressed PCM we used to write. Audio is the irreplaceable
282 // stream and costs ~115 MB/hour, so it records to the safe (internal) disk
283 // rather than the removable scratch drive.
284 let settings: [String: Any] = [
285 AVFormatIDKey: kAudioFormatMPEG4AAC,
286 AVSampleRateKey: 48_000,
287 AVNumberOfChannelsKey: 2,
288 AVEncoderBitRateKey: 256_000,
289 ]
290 return try StreamWriter(
291 url: safeRoot.appendingPathComponent("\(name).m4a"),
292 name: name, kind: kind, fileType: .m4a, settings: settings, mediaType: .audio,
293 fallbackDir: safeRoot)
294 }
295
296 // MARK: AVCapture (mic + camera)
297
298 private func startAVCapture(audioUID: String? = nil, videoUID: String? = nil) throws {
299 let session = AVCaptureSession()
300 session.beginConfiguration()
301
302 if let audioUID {
303 guard let device = Devices.audioDevice(matching: audioUID) else {
304 throw RecorderError("audio device not found: \(audioUID)")
305 }
306 let input = try AVCaptureDeviceInput(device: device)
307 guard session.canAddInput(input) else { throw RecorderError("cannot add mic input") }
308 session.addInput(input)
309
310 let writer = try makeAudioWriter(name: "mic", kind: "mic")
311 writer.deviceUID = device.uniqueID
312 sinks.append(writer)
313
314 let out = AVCaptureAudioDataOutput()
315 let delegate = AVOutput { [weak writer] sb in writer?.append(sb) }
316 avOutputs.append(delegate)
317 out.setSampleBufferDelegate(delegate, queue: DispatchQueue(label: "clover.av.mic"))
318 guard session.canAddOutput(out) else { throw RecorderError("cannot add mic output") }
319 session.addOutput(out)
320 }
321
322 if let videoUID {
323 guard let device = Devices.videoDevice(matching: videoUID) else {
324 throw RecorderError("camera not found: \(videoUID)")
325 }
326 let input = try AVCaptureDeviceInput(device: device)
327 guard session.canAddInput(input) else { throw RecorderError("cannot add camera input") }
328 session.addInput(input)
329
330 // Match the writer to the preset's true dimensions so a 4:3 mode (480p =
331 // 640×480) isn't pillarboxed into 16:9.
332 let (preset, camW, camH) = cameraPreset(forHeight: cfg.cameraHeight)
333 if session.canSetSessionPreset(preset) { session.sessionPreset = preset }
334 configureCameraFrameRate(device)
335
336 let camWriter = try makeVideoWriter(name: "cam", kind: "camera", width: camW, height: camH)
337 camWriter.deviceUID = device.uniqueID
338 sinks.append(camWriter)
339
340 let out = AVCaptureVideoDataOutput()
341 let delegate = AVOutput { [weak camWriter] sb in camWriter?.append(sb) }
342 avOutputs.append(delegate)
343 out.setSampleBufferDelegate(delegate, queue: DispatchQueue(label: "clover.av.cam"))
344 guard session.canAddOutput(out) else { throw RecorderError("cannot add camera output") }
345 session.addOutput(out)
346 }
347
348 session.commitConfiguration()
349 // Runtime errors (device wedged, media services reset) otherwise vanish
350 // silently — the watchdog would notice the stall, but this names the cause.
351 let observer = NotificationCenter.default.addObserver(
352 forName: .AVCaptureSessionRuntimeError, object: session, queue: nil
353 ) { [weak self] note in
354 let reason =
355 (note.userInfo?[AVCaptureSessionErrorKey] as? NSError)?.localizedDescription ?? "unknown"
356 self?.report("avsession-\(reason)", "Mic/camera capture error: \(reason)")
357 }
358 sessionObservers.append(observer)
359 session.startRunning()
360 captureSessions.append(session)
361 }
362
363 // MARK: helpers
364
365 private func outputSize(for display: SCDisplay) -> (Int, Int) {
366 // Prefer the true framebuffer pixel size; fall back to the points frame.
367 var pxW = display.width
368 var pxH = display.height
369 if let mode = CGDisplayCopyDisplayMode(display.displayID) {
370 pxW = mode.pixelWidth
371 pxH = mode.pixelHeight
372 }
373 let longest = max(pxW, pxH)
374 guard longest > cfg.maxWidth, cfg.maxWidth > 0 else { return (even(pxW), even(pxH)) }
375 let scale = Double(cfg.maxWidth) / Double(longest)
376 return (even(Int(Double(pxW) * scale)), even(Int(Double(pxH) * scale)))
377 }
378
379 private func even(_ v: Int) -> Int { v - (v % 2) }
380
381 @discardableResult
382 private func writeManifest() -> [StreamManifest] {
383 let all = sinks.flatMap { $0.manifests() }
384 let tStart = all.map { $0.firstSampleHostSeconds }.filter { !$0.isNaN }.min()
385 ?? hostClockAtStart
386
387 var streamManifests = all.map { m -> StreamManifest in
388 var m = m
389 m.offsetSeconds = m.firstSampleHostSeconds.isNaN ? 0 : (m.firstSampleHostSeconds - tStart)
390 return m
391 }
392 streamManifests.sort { $0.offsetSeconds < $1.offsetSeconds }
393
394 let manifest = SessionManifest(
395 recorderVersion: recorderVersion,
396 label: cfg.label,
397 createdEpoch: createdEpoch,
398 hostClockAtStart: hostClockAtStart,
399 tStartHostSeconds: tStart,
400 streams: streamManifests)
401
402 // Write to both roots (they may be different volumes); losing one disk
403 // must not cost the alignment data for the surviving streams.
404 var targets = [cfg.outDir]
405 if safeRoot.path != cfg.outDir.path { targets.append(safeRoot) }
406 var wrote = false
407 do {
408 let encoder = JSONEncoder()
409 encoder.outputFormatting = [.prettyPrinted, .sortedKeys]
410 let data = try encoder.encode(manifest)
411 for dir in targets {
412 do {
413 try data.write(to: dir.appendingPathComponent("sync.json"))
414 wrote = true
415 } catch {
416 logErr("failed to write manifest to \(dir.path): \(error)")
417 }
418 }
419 } catch {
420 logErr("failed to encode manifest: \(error)")
421 }
422 if !wrote { report("manifest", "Couldn't save sync.json — stream alignment data was lost.") }
423
424 // Human-readable summary to stderr.
425 logInfo("session: \(cfg.outDir.path)")
426 for m in streamManifests {
427 let size = fileSize(cfg.outDir.appendingPathComponent(m.file))
428 logInfo(
429 String(
430 format: " %-13@ %6.2fs %5d frames drop %-3d rep %-4d off %+0.3fs %@",
431 m.name as NSString, m.durationSeconds, m.frames, m.dropped, m.repeated,
432 m.offsetSeconds, size as NSString))
433 }
434 return streamManifests
435 }
436
437 private func fileSize(_ url: URL) -> String {
438 guard let attrs = try? FileManager.default.attributesOfItem(atPath: url.path),
439 let bytes = attrs[.size] as? Int64
440 else { return "—" }
441 let mb = Double(bytes) / 1_048_576
442 return String(format: "%.1f MB", mb)
443 }
444}
src/Recorder/engine/Sources/recorder/MarkerOverlay.swift deleted-232
...@@ -1,232 +0,0 @@
1import AVFoundation
2import AppKit
3import Carbon.HIToolbox
4import SwiftUI
5
6// MARK: - Global hotkey
7
8/// A system-wide hotkey via the Carbon Event Manager. Unlike an NSEvent global
9/// monitor this needs no Accessibility/Input-Monitoring grant, and it consumes
10/// the key so nothing else sees it. Fires on the main thread.
11private func hotKeyEventHandler(
12 _ next: EventHandlerCallRef?, _ event: EventRef?, _ userData: UnsafeMutableRawPointer?
13) -> OSStatus {
14 guard let userData else { return noErr }
15 Unmanaged<HotKey>.fromOpaque(userData).takeUnretainedValue().onPress()
16 return noErr
17}
18
19final class HotKey {
20 let onPress: () -> Void
21 private var hotKeyRef: EventHotKeyRef?
22 private var eventHandler: EventHandlerRef?
23
24 init?(keyCode: UInt32, modifiers: UInt32 = 0, onPress: @escaping () -> Void) {
25 self.onPress = onPress
26 var spec = EventTypeSpec(
27 eventClass: OSType(kEventClassKeyboard), eventKind: UInt32(kEventHotKeyPressed))
28 guard
29 InstallEventHandler(
30 GetApplicationEventTarget(), hotKeyEventHandler, 1, &spec,
31 Unmanaged.passUnretained(self).toOpaque(), &eventHandler) == noErr
32 else { return nil }
33
34 let id = EventHotKeyID(signature: OSType(0x434C_5652), id: 1) // 'CLVR'
35 guard
36 RegisterEventHotKey(keyCode, modifiers, id, GetApplicationEventTarget(), 0, &hotKeyRef)
37 == noErr
38 else { return nil }
39 }
40
41 deinit {
42 if let hotKeyRef { UnregisterEventHotKey(hotKeyRef) }
43 if let eventHandler { RemoveEventHandler(eventHandler) }
44 }
45}
46
47// MARK: - Marker model
48
49struct Marker: Codable {
50 let hostSeconds: Double // mach host clock — align against sync.json
51 let offsetSeconds: Double // from session start, for humans
52 let text: String?
53 let wallClock: String
54}
55
56// MARK: - Overlay panel
57
58/// Borderless floating panel that can take key focus so you can type into it.
59final class MarkerPanel: NSPanel {
60 override var canBecomeKey: Bool { true }
61}
62
63// MARK: - Identify overlay
64
65/// Big number flashed on a physical display so you can see which is screen 1/2.
66struct IdentifyView: View {
67 let number: Int
68 var body: some View {
69 Text("\(number)")
70 .font(.system(size: 150, weight: .bold, design: .rounded))
71 .foregroundStyle(.white)
72 .frame(width: 230, height: 230)
73 .background(.blue.opacity(0.85), in: RoundedRectangle(cornerRadius: 30))
74 }
75}
76
77// MARK: - Voice dictation (local Whisper)
78
79/// Records the mic while the marker overlay is open and transcribes it on demand
80/// via on-device MLX Whisper (large-v3-turbo) — ~1.5 s per note on this machine.
81@MainActor
82final class DictationModel: ObservableObject {
83 enum Phase { case idle, listening, transcribing }
84
85 @Published var text = ""
86 @Published var phase: Phase = .idle
87
88 private var recorder: AVAudioRecorder?
89 private let audioURL = FileManager.default.temporaryDirectory
90 .appendingPathComponent("clover-marker.wav")
91
92 static let pythonPath = NSHomeDirectory() + "/.clover-whisper/.venv/bin/python"
93 static var scriptPath: String {
94 Bundle.main.url(forResource: "transcribe", withExtension: "py")?.path ?? ""
95 }
96 var available: Bool {
97 FileManager.default.isExecutableFile(atPath: Self.pythonPath) && !Self.scriptPath.isEmpty
98 }
99
100 func startListening() {
101 guard available else { return }
102 let settings: [String: Any] = [
103 AVFormatIDKey: kAudioFormatLinearPCM,
104 AVSampleRateKey: 16_000,
105 AVNumberOfChannelsKey: 1,
106 AVLinearPCMBitDepthKey: 16,
107 AVLinearPCMIsFloatKey: false,
108 AVLinearPCMIsBigEndianKey: false,
109 ]
110 do {
111 let rec = try AVAudioRecorder(url: audioURL, settings: settings)
112 rec.record()
113 recorder = rec
114 phase = .listening
115 } catch {
116 phase = .idle
117 }
118 }
119
120 func stopAndTranscribe() async {
121 guard phase == .listening else { return }
122 recorder?.stop()
123 recorder = nil
124 phase = .transcribing
125 let result = await Self.run(Self.pythonPath, [Self.scriptPath, audioURL.path])
126 if !result.isEmpty { text = result }
127 phase = .idle
128 }
129
130 func cancel() {
131 recorder?.stop()
132 recorder = nil
133 phase = .idle
134 }
135
136 private static func run(_ path: String, _ args: [String]) async -> String {
137 await withCheckedContinuation { (cont: CheckedContinuation<String, Never>) in
138 DispatchQueue.global(qos: .userInitiated).async {
139 let p = Process()
140 p.executableURL = URL(fileURLWithPath: path)
141 p.arguments = args
142 p.environment = cloverToolEnvironment()
143 let pipe = Pipe()
144 p.standardOutput = pipe
145 p.standardError = Pipe()
146 do { try p.run() } catch {
147 cont.resume(returning: "")
148 return
149 }
150 let data = pipe.fileHandleForReading.readDataToEndOfFile()
151 p.waitUntilExit()
152 cont.resume(
153 returning: String(decoding: data, as: UTF8.self)
154 .trimmingCharacters(in: .whitespacesAndNewlines))
155 }
156 }
157 }
158}
159
160struct MarkerOverlayView: View {
161 let offsetText: String
162 let onSubmit: (String) -> Void
163 let onCancel: () -> Void
164
165 @StateObject private var dictation = DictationModel()
166 @FocusState private var focused: Bool
167
168 var body: some View {
169 HStack(spacing: 10) {
170 Image(systemName: icon).foregroundStyle(iconColor).font(.title3)
171 Text(offsetText)
172 .font(.system(.callout, design: .monospaced)).foregroundStyle(.secondary)
173 TextField(placeholder, text: $dictation.text)
174 .textFieldStyle(.plain)
175 .focused($focused)
176 .onSubmit { handleEnter() }
177 .frame(width: 320)
178 // Typing while listening switches to manual entry.
179 .onChange(of: dictation.text) { _, newValue in
180 if dictation.phase == .listening && !newValue.isEmpty { dictation.cancel() }
181 }
182 }
183 .padding(.horizontal, 16)
184 .padding(.vertical, 12)
185 .background(.regularMaterial, in: RoundedRectangle(cornerRadius: 14))
186 .overlay(RoundedRectangle(cornerRadius: 14).strokeBorder(iconColor.opacity(0.5), lineWidth: 1))
187 .onAppear {
188 focused = true
189 dictation.startListening()
190 }
191 .onKeyPress(.escape) {
192 dictation.cancel()
193 onCancel()
194 return .handled
195 }
196 }
197
198 private func handleEnter() {
199 switch dictation.phase {
200 case .listening:
201 Task { await dictation.stopAndTranscribe() }
202 case .transcribing:
203 break
204 case .idle:
205 onSubmit(dictation.text)
206 }
207 }
208
209 private var icon: String {
210 switch dictation.phase {
211 case .listening: return "mic.fill"
212 case .transcribing: return "waveform"
213 case .idle: return "mappin.circle.fill"
214 }
215 }
216 private var iconColor: Color {
217 switch dictation.phase {
218 case .listening: return .red
219 case .transcribing: return .blue
220 case .idle: return .orange
221 }
222 }
223 private var placeholder: String {
224 switch dictation.phase {
225 case .listening: return "Listening… speak, then Enter to transcribe (or just type)"
226 case .transcribing: return "Transcribing…"
227 case .idle:
228 return dictation.available
229 ? "Enter to drop · Esc to cancel" : "Marker note (optional) — Enter to drop"
230 }
231 }
232}
src/Recorder/engine/Sources/recorder/MenubarApp.swift deleted-1569
...@@ -1,1569 +0,0 @@
1import AppKit
2import AVFoundation
3import Carbon.HIToolbox
4import CoreMedia
5import Foundation
6import SwiftUI
7import UserNotifications
8
9// MARK: - Entry
10
11/// Environment with the Nix/Homebrew bins on PATH so spawned Python tools can
12/// find ffmpeg (a GUI app launched via Finder/`open` has a bare PATH otherwise).
13func cloverToolEnvironment() -> [String: String] {
14 var env = ProcessInfo.processInfo.environment
15 let dirs = [
16 "/etc/profiles/per-user/\(NSUserName())/bin", "/run/current-system/sw/bin",
17 "/opt/homebrew/bin", "/usr/local/bin",
18 ]
19 env["PATH"] = dirs.joined(separator: ":") + ":" + (env["PATH"] ?? "/usr/bin:/bin")
20 return env
21}
22
23@MainActor
24func runMenubar() {
25 let app = NSApplication.shared
26 let controller = AppController()
27 app.delegate = controller
28 app.setActivationPolicy(.accessory) // menubar only, no Dock icon
29 app.run()
30}
31
32// MARK: - Controller
33
34@MainActor
35final class AppController: NSObject, NSApplicationDelegate, NSPopoverDelegate, NSWindowDelegate,
36 ObservableObject
37{
38 // Settings (persisted)
39 @Published var destination: String = UserDefaults.standard.string(forKey: "destination") ?? "Sessions"
40 {
41 didSet {
42 // Sessions default to recording REAPER; Journal defaults to not. You can
43 // still override the checkbox afterwards.
44 guard destination != oldValue else { return }
45 reaperMidi = (destination == "Sessions")
46 }
47 }
48 @Published var includeDesktop = UserDefaults.standard.object(forKey: "desktop") as? Bool ?? true
49 @Published var includeMic = UserDefaults.standard.object(forKey: "mic") as? Bool ?? true
50 @Published var reaperMidi = UserDefaults.standard.object(forKey: "reaper") as? Bool ?? false
51 // Off by default: solo journaling/improv gets one clean voice. On only for the
52 // occasional session with other people (diarize + name via the Speakers review).
53 @Published var detectSpeakers = UserDefaults.standard.object(forKey: "detectSpeakers") as? Bool ?? false
54 @Published var reverseScreens = UserDefaults.standard.object(forKey: "reverseScreens") as? Bool ?? false
55 @Published var enabledDisplays: Set<UInt32> = []
56
57 @Published var includeCamera = UserDefaults.standard.object(forKey: "camera") as? Bool ?? false {
58 didSet { updatePreview() }
59 }
60 @Published var cameraUID: String? = UserDefaults.standard.string(forKey: "cameraUID") {
61 didSet { if oldValue != cameraUID { restartPreview() } }
62 }
63 @Published var cameraHeight = UserDefaults.standard.object(forKey: "cameraHeight") as? Int ?? 1080
64 {
65 didSet { if oldValue != cameraHeight { restartPreview() } } // re-apply aspect/format
66 }
67 @Published var cameraFps = UserDefaults.standard.object(forKey: "cameraFps") as? Int ?? 30
68 @Published var cameraAntiFlickerHz: Int =
69 UserDefaults.standard.object(forKey: "antiFlicker") as? Int ?? 60
70 {
71 didSet { if includeCamera { applyAntiFlicker() } }
72 }
73 @Published var cameras: [DeviceInfo] = []
74 @Published var previewSession: AVCaptureSession?
75 /// Actual width/height ratio the camera delivers, observed from its active
76 /// format — the preview frame uses this so the feed never letterboxes.
77 @Published var observedCameraAspect: CGFloat?
78 var hasCamera: Bool { !cameras.isEmpty }
79
80 /// Displays in screen-number order (left→right, or reversed if you flip it).
81 var orderedDisplays: [DisplayInfo] {
82 reverseScreens ? displays.reversed() : displays
83 }
84
85 // Discovered hardware
86 @Published var displays: [DisplayInfo] = []
87 @Published var audioInputs: [DeviceInfo] = [] // real mics only (webcam mic filtered out)
88 @Published var micUID: String? = UserDefaults.standard.string(forKey: "micUID") {
89 didSet { if !settingMicProgrammatically { micExplicit = true } }
90 }
91 /// True once the user picks a mic by hand — lets refreshDevices re-default away
92 /// from undesirable mics (webcam/Continuity) without clobbering a deliberate pick.
93 private var micExplicit = UserDefaults.standard.bool(forKey: "micExplicit")
94 private var settingMicProgrammatically = false
95 @Published var permissionOK = true
96 var hasMic: Bool { !audioInputs.isEmpty }
97
98 // Live state
99 @Published var isRecording = false
100 @Published var elapsed: TimeInterval = 0
101 @Published var markerCount = 0
102 @Published var lastSession: String?
103 @Published var compressProgress: Double? // 0…1 while re-encoding, else nil
104 @Published var errorMessage: String? {
105 didSet { updateIcon() } // problem state shows in the menubar, not just here
106 }
107 @Published var voiceEnrolled = FileManager.default.fileExists(
108 atPath: NSHomeDirectory() + "/.clover-whisper/voices.json")
109 @Published var enrolling = false
110 @Published var enrollSecondsLeft = 0
111 private var lastSessionURL: URL?
112 private var enrollRecorder: AVAudioRecorder?
113 private var enrollTimer: Timer?
114 private var speakersWindow: NSWindow?
115 private var cameraPopout: NSWindow?
116
117 private var statusItem: NSStatusItem?
118 private var popover: NSPopover?
119 private var engine: CaptureEngine?
120 private var starting = false
121 private var startHost: Double = 0
122 private var tickTimer: Timer?
123 private var currentSessionDir: URL?
124 private var currentSafeDir: URL?
125 private var zenithDir: URL?
126 private var postProcessing = false
127 private var reconciling = false
128 private var reconcileTimer: Timer?
129 private var hotKey: HotKey?
130 private var markers: [Marker] = []
131 private var markerPanel: MarkerPanel?
132 private var identifyWindows: [NSWindow] = []
133
134 // Fast local scratch for video; the NAS archive is the final home. Audio and
135 // anything rescued mid-session live under recoveryRoot on the internal SSD,
136 // which can't disconnect the way an external volume can.
137 let tempRoot = URL(fileURLWithPath: "/Volumes/Documents/Temp")
138 let recoveryRoot = URL(fileURLWithPath: NSHomeDirectory() + "/Movies/Clover Recovery")
139 let archiveRoot = URL(fileURLWithPath: "/Volumes/clover/Archive")
140 let reaperTemplate = URL(fileURLWithPath: "/Volumes/Documents/Recorder Template.rpp")
141 @Published var statusText: String?
142
143 func applicationDidFinishLaunching(_ notification: Notification) {
144 let item = NSStatusBar.system.statusItem(withLength: NSStatusItem.variableLength)
145 item.button?.image = NSImage(
146 systemSymbolName: "record.circle", accessibilityDescription: "Clover Recorder")
147 item.button?.action = #selector(togglePopover)
148 item.button?.target = self
149 statusItem = item
150
151 let pop = NSPopover()
152 pop.behavior = .transient
153 pop.delegate = self
154 pop.contentViewController = NSHostingController(rootView: ContentView(controller: self))
155 popover = pop
156
157 // F14 anywhere drops a session marker (Carbon hotkey — no extra permission).
158 hotKey = HotKey(keyCode: UInt32(kVK_F14)) { [weak self] in
159 MainActor.assumeIsolated { self?.markerPressed() }
160 }
161
162 setupNotifications()
163 Task { await refreshDevices() }
164
165 // Sessions stranded locally (zenith was down, a crash, a failed archive)
166 // are retried at launch and periodically while idle.
167 Task { await reconcilePending() }
168 reconcileTimer = Timer.scheduledTimer(withTimeInterval: 900, repeats: true) { [weak self] _ in
169 Task { @MainActor in await self?.reconcilePending() }
170 }
171 }
172
173 // MARK: Problem reporting
174
175 /// One path for anything going wrong: inline message in the popover, an
176 /// orange warning in the menubar, and a system notification (recordings run
177 /// unattended — a problem must not wait for the popover to be opened).
178 func reportProblem(_ message: String) {
179 errorMessage = message
180 notify(message)
181 }
182
183 private func setupNotifications() {
184 guard Bundle.main.bundleIdentifier != nil else { return }
185 UNUserNotificationCenter.current().requestAuthorization(options: [.alert, .sound]) { _, _ in }
186 }
187
188 private func notify(_ body: String, title: String = "Clover Recorder") {
189 guard Bundle.main.bundleIdentifier != nil else { return }
190 let content = UNMutableNotificationContent()
191 content.title = title
192 content.body = body
193 UNUserNotificationCenter.current().add(
194 UNNotificationRequest(identifier: UUID().uuidString, content: content, trigger: nil))
195 }
196
197 // MARK: Markers
198
199 func markerPressed() {
200 guard isRecording, let dir = currentSessionDir, markerPanel == nil else { return }
201 let host = hostSeconds()
202 showMarkerOverlay(host: host, offset: host - startHost, dir: dir)
203 }
204
205 private func showMarkerOverlay(host: Double, offset: Double, dir: URL) {
206 let panel = MarkerPanel(
207 contentRect: NSRect(x: 0, y: 0, width: 420, height: 56),
208 styleMask: [.borderless], backing: .buffered, defer: false)
209 panel.level = .floating
210 panel.isOpaque = false
211 panel.backgroundColor = .clear
212 panel.hasShadow = true
213 panel.isMovableByWindowBackground = true
214 panel.contentViewController = NSHostingController(
215 rootView: MarkerOverlayView(
216 offsetText: clockString(offset),
217 onSubmit: { [weak self] text in
218 self?.commitMarker(host: host, offset: offset, text: text, dir: dir)
219 },
220 onCancel: { [weak self] in self?.dismissMarkerOverlay() }))
221 if let screen = NSScreen.main {
222 let f = screen.frame
223 panel.setFrameOrigin(NSPoint(x: f.midX - panel.frame.width / 2, y: f.maxY - 220))
224 }
225 markerPanel = panel
226 NSApp.activate(ignoringOtherApps: true)
227 panel.makeKeyAndOrderFront(nil)
228 }
229
230 private func commitMarker(host: Double, offset: Double, text: String, dir: URL) {
231 let trimmed = text.trimmingCharacters(in: .whitespacesAndNewlines)
232 markers.append(
233 Marker(
234 hostSeconds: host, offsetSeconds: offset,
235 text: trimmed.isEmpty ? nil : trimmed,
236 wallClock: ISO8601DateFormatter().string(from: Date())))
237 markerCount = markers.count
238 writeMarkers(to: dir)
239 dismissMarkerOverlay()
240 }
241
242 private func dismissMarkerOverlay() {
243 markerPanel?.orderOut(nil)
244 markerPanel = nil
245 }
246
247 private func writeMarkers(to dir: URL) {
248 let encoder = JSONEncoder()
249 encoder.outputFormatting = [.prettyPrinted]
250 guard let data = try? encoder.encode(MarkersFile(markers: markers)) else { return }
251 // Written to the scratch AND the internal recovery dir, so markers survive
252 // either volume disappearing mid-session.
253 var targets = [dir]
254 if let safe = currentSafeDir, safe != dir { targets.append(safe) }
255 var ok = false
256 for t in targets {
257 if (try? data.write(to: t.appendingPathComponent("markers.json"))) != nil { ok = true }
258 }
259 if !ok { reportProblem("Couldn't save markers — check the recording disks.") }
260 }
261
262 private func clockString(_ t: TimeInterval) -> String {
263 let s = Int(t)
264 return String(format: "%02d:%02d", s / 60, s % 60)
265 }
266
267 @objc private func togglePopover() {
268 guard let button = statusItem?.button, let popover else { return }
269 if popover.isShown {
270 popover.performClose(nil)
271 } else {
272 Task {
273 await refreshDevices() // re-check displays + mic + cameras each time it opens
274 updatePreview()
275 }
276 popover.show(relativeTo: button.bounds, of: button, preferredEdge: .minY)
277 popover.contentViewController?.view.window?.makeKey()
278 }
279 }
280
281 func refreshDevices() async {
282 do {
283 let devices = try await Devices.discover()
284 displays = devices.displays
285 cameras = devices.cameras
286 audioInputs = devices.audioInputs // all selectable
287
288 // Never DEFAULT to a webcam mic (shares a camera's name) or a Continuity
289 // (iPhone/iPad) mic — but leave them pickable. The engine flags Continuity
290 // mics via transport type; also treat any mic named like a camera as one.
291 let camNames = Set(devices.cameras.map { $0.name })
292 func deprioritized(_ d: DeviceInfo) -> Bool { d.continuity || camNames.contains(d.name) }
293 // Re-default if unset, gone, or auto-pointing at a deprioritized device. A
294 // mic the user picked by hand is left alone even if it's a webcam/Continuity.
295 let current = audioInputs.first { $0.uid == micUID }
296 if current == nil || (!micExplicit && deprioritized(current!)) {
297 settingMicProgrammatically = true
298 micUID = (audioInputs.first { !deprioritized($0) } ?? audioInputs.first)?.uid
299 settingMicProgrammatically = false
300 }
301 if cameraUID == nil || !cameras.contains(where: { $0.uid == cameraUID }) {
302 cameraUID = (cameras.first { !$0.continuity } ?? cameras.first)?.uid
303 }
304 permissionOK = true
305 // Display IDs change across sleep/wake, so drop stale selections and fall
306 // back to all current displays if nothing valid remains.
307 let currentIDs = Set(devices.displays.map { $0.id })
308 enabledDisplays.formIntersection(currentIDs)
309 if enabledDisplays.isEmpty { enabledDisplays = currentIDs }
310 } catch {
311 permissionOK = false
312 errorMessage = "Screen Recording permission needed."
313 }
314 }
315
316 func setDisplay(_ id: UInt32, on: Bool) {
317 if on { enabledDisplays.insert(id) } else { enabledDisplays.remove(id) }
318 }
319
320 /// Flash the screen number on each physical display, in recorder order, so you
321 /// can see which monitor is screen-1 / screen-2.
322 func identifyScreens() {
323 dismissIdentify()
324 for (idx, d) in orderedDisplays.enumerated() {
325 guard let screen = NSScreen.screens.first(where: { screenNumber($0) == d.id }) else {
326 continue
327 }
328 let size = NSSize(width: 230, height: 230)
329 let frame = NSRect(
330 x: screen.frame.midX - size.width / 2, y: screen.frame.midY - size.height / 2,
331 width: size.width, height: size.height)
332 let win = NSPanel(
333 contentRect: frame, styleMask: [.borderless, .nonactivatingPanel], backing: .buffered,
334 defer: false)
335 win.level = .screenSaver
336 win.isOpaque = false
337 win.backgroundColor = .clear
338 win.hasShadow = false
339 win.ignoresMouseEvents = true
340 win.collectionBehavior = [.canJoinAllSpaces, .stationary]
341 win.contentViewController = NSHostingController(rootView: IdentifyView(number: idx + 1))
342 win.orderFrontRegardless()
343 identifyWindows.append(win)
344 }
345 DispatchQueue.main.asyncAfter(deadline: .now() + 2.5) { [weak self] in self?.dismissIdentify() }
346 }
347
348 private func dismissIdentify() {
349 identifyWindows.forEach { $0.orderOut(nil) }
350 identifyWindows.removeAll()
351 }
352
353 private func screenNumber(_ screen: NSScreen) -> UInt32? {
354 (screen.deviceDescription[NSDeviceDescriptionKey("NSScreenNumber")] as? NSNumber)?.uint32Value
355 }
356
357 // MARK: Camera preview
358
359 /// Run the preview whenever the camera's on and somewhere is showing it (the
360 /// popover or the pop-out window) — including during recording (macOS shares
361 /// the camera across sessions).
362 func updatePreview() {
363 let want = includeCamera && hasCamera && (popover?.isShown == true || cameraPopout != nil)
364 if want && previewSession == nil {
365 startPreviewSession()
366 } else if !want && previewSession != nil {
367 stopPreview()
368 }
369 }
370
371 func restartPreview() {
372 if previewSession != nil {
373 stopPreview()
374 updatePreview()
375 }
376 }
377
378 /// Preset matching the selected recording quality, so the preview's aspect/FOV
379 /// equals the output's (480p = 4:3, 720p/1080p = 16:9).
380 func cameraPreset() -> AVCaptureSession.Preset {
381 switch cameraHeight {
382 case ...480: return .vga640x480
383 case 481...720: return .hd1280x720
384 default: return .hd1920x1080
385 }
386 }
387
388 /// Real aspect once we've seen a frame's format; otherwise the preset's guess.
389 var cameraAspect: CGFloat {
390 observedCameraAspect ?? (cameraHeight <= 480 ? 4.0 / 3.0 : 16.0 / 9.0)
391 }
392
393 private func startPreviewSession() {
394 guard let uid = cameraUID, let device = Devices.videoDevice(matching: uid),
395 let input = try? AVCaptureDeviceInput(device: device)
396 else { return }
397 let session = AVCaptureSession()
398 session.sessionPreset = cameraPreset()
399 guard session.canAddInput(input) else { return }
400 session.addInput(input)
401 // Size the preview to what the camera actually outputs, not the preset's
402 // nominal aspect — many webcams ignore a 4:3 preset and stay 16:9.
403 let dims = CMVideoFormatDescriptionGetDimensions(device.activeFormat.formatDescription)
404 observedCameraAspect =
405 dims.width > 0 && dims.height > 0 ? CGFloat(dims.width) / CGFloat(dims.height) : nil
406 previewSession = session
407 DispatchQueue.global(qos: .userInitiated).async { session.startRunning() }
408 DispatchQueue.main.asyncAfter(deadline: .now() + 0.6) { [weak self] in self?.applyAntiFlicker() }
409 }
410
411 func stopPreview() {
412 if let session = previewSession {
413 DispatchQueue.global(qos: .userInitiated).async { session.stopRunning() }
414 }
415 previewSession = nil
416 }
417
418 func toggleCameraPopout() {
419 if let win = cameraPopout {
420 win.close() // windowWillClose clears the ref and re-evaluates the preview
421 return
422 }
423 let win = NSWindow(
424 contentRect: NSRect(x: 0, y: 0, width: 480, height: 480 / cameraAspect),
425 styleMask: [.titled, .closable, .resizable], backing: .buffered, defer: false)
426 win.title = "Camera"
427 win.contentAspectRatio = NSSize(width: cameraAspect, height: 1)
428 win.contentViewController = NSHostingController(rootView: CameraPopoutView(controller: self))
429 win.isReleasedWhenClosed = false
430 win.level = .floating
431 win.delegate = self
432 win.center()
433 cameraPopout = win
434 updatePreview()
435 NSApp.activate(ignoringOtherApps: true)
436 win.makeKeyAndOrderFront(nil)
437 }
438
439 func windowWillClose(_ notification: Notification) {
440 if (notification.object as? NSWindow) === cameraPopout {
441 cameraPopout = nil
442 updatePreview()
443 }
444 }
445
446 /// Push the UVC Power Line Frequency to the camera (kills mains flicker). Best
447 /// applied once the camera is already streaming; safe to call repeatedly.
448 func applyAntiFlicker() {
449 let helper = Bundle.main.bundleURL.appendingPathComponent("Contents/MacOS/uvc-powerline")
450 guard FileManager.default.isExecutableFile(atPath: helper.path) else { return }
451 let arg: String
452 switch cameraAntiFlickerHz {
453 case 50: arg = "1"
454 case 60: arg = "2"
455 default: arg = "0"
456 }
457 let process = Process()
458 process.executableURL = helper
459 process.arguments = [arg]
460 DispatchQueue.global(qos: .utility).async { try? process.run() }
461 }
462
463 func popoverDidClose(_ notification: Notification) {
464 updatePreview() // keep running if the pop-out window is up
465 }
466
467 private func persist() {
468 let d = UserDefaults.standard
469 d.set(destination, forKey: "destination")
470 d.set(includeDesktop, forKey: "desktop")
471 d.set(includeMic, forKey: "mic")
472 d.set(micUID, forKey: "micUID")
473 d.set(micExplicit, forKey: "micExplicit")
474 d.set(reaperMidi, forKey: "reaper")
475 d.set(detectSpeakers, forKey: "detectSpeakers")
476 d.set(reverseScreens, forKey: "reverseScreens")
477 d.set(includeCamera, forKey: "camera")
478 d.set(cameraUID, forKey: "cameraUID")
479 d.set(cameraHeight, forKey: "cameraHeight")
480 d.set(cameraFps, forKey: "cameraFps")
481 d.set(cameraAntiFlickerHz, forKey: "antiFlicker")
482 }
483
484 // MARK: recording
485
486 func start() {
487 guard !isRecording, !starting else { return }
488 starting = true
489 persist()
490 errorMessage = nil
491 statusText = "Checking zenith…"
492 Task {
493 // zenith being down must never block a recording — archive later instead.
494 let zenithOK = await self.ensureZenithMounted()
495 if !zenithOK {
496 self.notify("zenith isn't mounted — recording locally; it will archive when zenith is back.")
497 }
498 await self.refreshDevices() // display IDs shift across sleep/wake
499 await self.beginRecording(zenithOK: zenithOK)
500 self.starting = false
501 }
502 }
503
504 private func beginRecording(zenithOK: Bool) async {
505 let name = resolveSessionName()
506 // Big video goes to the scratch drive; if that's missing, everything
507 // records to the internal recovery dir rather than blocking the session.
508 var temp = tempRoot.appendingPathComponent(name)
509 let safe = recoveryRoot.appendingPathComponent(name)
510 do {
511 try FileManager.default.createDirectory(at: temp, withIntermediateDirectories: true)
512 } catch {
513 temp = safe
514 reportProblem("Scratch drive unavailable — recording to the internal SSD instead.")
515 }
516 do {
517 try FileManager.default.createDirectory(at: safe, withIntermediateDirectories: true)
518 } catch {
519 statusText = nil
520 reportProblem("Can't create \(safe.path) (\(error.localizedDescription)) — not recording.")
521 return
522 }
523
524 // The final NAS home — created now so REAPER can record straight into it.
525 var zenithReady: URL? = nil
526 if zenithOK {
527 let zenith = zenithSessionDir(name)
528 do {
529 try FileManager.default.createDirectory(at: zenith, withIntermediateDirectories: true)
530 zenithReady = zenith
531 } catch {
532 reportProblem(
533 "Archive folder unavailable (\(error.localizedDescription)); will keep a local copy.")
534 }
535 }
536 writeSessionStamp(to: temp, name: name)
537 if safe != temp { writeSessionStamp(to: safe, name: name) }
538
539 // Record only currently-available displays, in screen-number order so the
540 // files are deterministic (screen-1, screen-2, …) rather than Set order.
541 let current = Set(displays.map { $0.id })
542 var enabled = enabledDisplays.intersection(current)
543 if enabled.isEmpty { enabled = current }
544 let displayIDs = orderedDisplays.map { $0.id }.filter { enabled.contains($0) }
545
546 let cfg = RecordConfig(
547 outDir: temp,
548 label: name,
549 displayIDs: displayIDs,
550 systemAudio: includeDesktop,
551 micUID: (includeMic && hasMic) ? micUID : nil,
552 cameraUID: (includeCamera && hasCamera) ? cameraUID : nil,
553 fps: 30,
554 maxWidth: 3840,
555 bitsPerPixel: 0.04,
556 duration: nil,
557 logPath: safe.appendingPathComponent("recorder.log").path,
558 safeDir: safe,
559 cameraHeight: cameraHeight, cameraFps: cameraFps)
560
561 // Start capture FIRST; only wire up REAPER + UI once it's confirmed live, so
562 // a failure cleans up instead of leaving empty session folders / a stray
563 // REAPER project behind.
564 let engine = CaptureEngine(cfg)
565 engine.onEvent = { [weak self] message in
566 Task { @MainActor in self?.reportProblem(message) }
567 }
568 statusText = "Starting…"
569 do {
570 try await engine.start()
571 } catch {
572 statusText = nil
573 reportProblem(error.localizedDescription)
574 if "\(error)".contains("declined") { permissionOK = false }
575 try? FileManager.default.removeItem(at: temp)
576 try? FileManager.default.removeItem(at: safe)
577 if let z = zenithReady { try? FileManager.default.removeItem(at: z) }
578 return
579 }
580
581 self.engine = engine
582 currentSessionDir = temp
583 currentSafeDir = safe
584 zenithDir = zenithReady
585 if reaperMidi { setupReaper(name: name) }
586 if includeCamera && hasCamera {
587 DispatchQueue.main.asyncAfter(deadline: .now() + 0.8) { [weak self] in self?.applyAntiFlicker() }
588 }
589 markers = []
590 markerCount = 0
591 isRecording = true
592 elapsed = 0
593 startHost = hostSeconds()
594 statusText = "Recording…"
595 updateIcon()
596 startTick()
597 }
598
599 func stop() {
600 guard isRecording, let engine else { return }
601 isRecording = false
602 stopTick()
603 dismissMarkerOverlay()
604 updateIcon()
605 statusText = "Finalizing…"
606 let temp = currentSessionDir
607 let safe = currentSafeDir
608 let zenith = zenithDir
609 currentSessionDir = nil
610 currentSafeDir = nil
611 zenithDir = nil
612 postProcessing = true
613 Task {
614 defer { self.postProcessing = false }
615 let manifests = await engine.stop()
616 self.engine = nil
617 guard let temp else { return }
618 let name = temp.lastPathComponent
619
620 // A stream with zero frames means a device silently recorded nothing
621 // (e.g. a mic that never delivered a sample) — say so loudly.
622 let empty = manifests.filter { $0.frames == 0 }.map { $0.name }
623 if !empty.isEmpty {
624 self.reportProblem(
625 "No data was recorded from: \(empty.joined(separator: ", ")). "
626 + "Check the device before the next session.")
627 }
628
629 // The session may span two local dirs (scratch video + internal audio),
630 // and either may have vanished mid-recording.
631 var dirs = [temp]
632 if let safe, safe != temp { dirs.append(safe) }
633 dirs = dirs.filter { FileManager.default.fileExists(atPath: $0.path) }
634 guard !dirs.isEmpty else {
635 self.statusText = nil
636 self.reportProblem("Session \(name): no local files survived — nothing to archive.")
637 return
638 }
639
640 self.statusText = "Compressing video…"
641 for dir in dirs { await self.compress(dir) }
642
643 let mounted = await self.ensureZenithMounted()
644 var target = zenith
645 if target == nil, mounted {
646 // zenith wasn't there at start but is now — archive after all.
647 let z = self.zenithSessionDir(name)
648 if (try? FileManager.default.createDirectory(at: z, withIntermediateDirectories: true))
649 != nil
650 {
651 target = z
652 }
653 }
654 guard mounted, let zenith = target else {
655 self.keepLocal(dirs: dirs, name: name, why: "zenith is offline")
656 return
657 }
658
659 self.statusText = "Archiving to zenith…"
660 var allOK = true
661 for dir in dirs {
662 if !(await self.archiveVerified(from: dir, to: zenith)) { allOK = false }
663 }
664 if allOK {
665 for dir in dirs { try? FileManager.default.removeItem(at: dir) }
666 self.writeSequence(in: zenith)
667 self.lastSession = zenith.lastPathComponent
668 self.lastSessionURL = zenith
669 // Transcript runs after archiving (into the final folder) so "Saved"
670 // isn't held up by a long transcription.
671 self.statusText = "Transcribing session…"
672 await self.transcribeSession(zenith)
673 self.statusText = nil
674 if self.unknownSpeakers(in: zenith) { self.openSpeakersReview() }
675 } else {
676 self.keepLocal(dirs: dirs, name: name, why: "copying to zenith kept failing")
677 }
678 }
679 }
680
681 /// Archive failed: consolidate the session into one local folder and leave it
682 /// for the reconciler, which retries whenever zenith comes back.
683 private func keepLocal(dirs: [URL], name: String, why: String) {
684 let home = dirs[0]
685 for dir in dirs.dropFirst() { mergeDir(dir, into: home) }
686 lastSession = name
687 lastSessionURL = home
688 statusText = "Saved locally — will archive when zenith returns"
689 reportProblem(
690 "Session \(name) saved locally (\(why)). It will archive automatically once "
691 + "zenith is reachable; files: \(home.path)")
692 }
693
694 private func mergeDir(_ src: URL, into dst: URL) {
695 guard
696 let items = try? FileManager.default.contentsOfDirectory(
697 at: src, includingPropertiesForKeys: nil)
698 else { return }
699 // These exist in both dirs by design; either copy is fine.
700 let duplicates: Set<String> = ["session.json", "sync.json", "markers.json", "recorder.log"]
701 var allMoved = true
702 for item in items {
703 let to = dst.appendingPathComponent(item.lastPathComponent)
704 if FileManager.default.fileExists(atPath: to.path) {
705 if duplicates.contains(item.lastPathComponent) {
706 try? FileManager.default.removeItem(at: item)
707 } else {
708 allMoved = false
709 }
710 continue
711 }
712 do { try FileManager.default.moveItem(at: item, to: to) } catch { allMoved = false }
713 }
714 if allMoved { try? FileManager.default.removeItem(at: src) }
715 }
716
717 func toggle() { isRecording ? stop() : start() }
718
719 private func startTick() {
720 tickTimer = Timer.scheduledTimer(withTimeInterval: 1, repeats: true) { [weak self] _ in
721 Task { @MainActor in self?.elapsed = hostSeconds() - (self?.startHost ?? 0) }
722 }
723 }
724 private func stopTick() {
725 tickTimer?.invalidate()
726 tickTimer = nil
727 }
728
729 private func updateIcon() {
730 let name: String
731 if errorMessage != nil {
732 name = isRecording ? "stop.circle.fill" : "exclamationmark.triangle.fill"
733 } else {
734 name = isRecording ? "stop.circle.fill" : "record.circle"
735 }
736 let image = NSImage(systemSymbolName: name, accessibilityDescription: "Clover Recorder")
737 if errorMessage != nil {
738 image?.isTemplate = false
739 statusItem?.button?.contentTintColor = .systemOrange
740 } else if isRecording {
741 image?.isTemplate = false
742 statusItem?.button?.contentTintColor = .systemRed
743 } else {
744 statusItem?.button?.contentTintColor = nil
745 }
746 statusItem?.button?.image = image
747 }
748
749 /// Session reference is the start time, `YYYY-MM-DD_HH.MM`. If one already
750 /// exists for this minute (locally or on the NAS), bump forward a minute until
751 /// it's unique.
752 private func resolveSessionName() -> String {
753 var date = Date()
754 for _ in 0..<240 {
755 let name = stamp("yyyy-MM-dd_HH.mm", date)
756 let taken =
757 FileManager.default.fileExists(atPath: tempRoot.appendingPathComponent(name).path)
758 || FileManager.default.fileExists(atPath: recoveryRoot.appendingPathComponent(name).path)
759 || (zenithMounted() && FileManager.default.fileExists(atPath: zenithSessionDir(name).path))
760 if !taken { return name }
761 date = date.addingTimeInterval(60)
762 }
763 return stamp("yyyy-MM-dd_HH.mm.ss") // fallback, should never hit
764 }
765
766 /// Dropped into every local session dir so a stranded session can be routed
767 /// to its archive home later (the folder name alone doesn't say Sessions vs
768 /// Journal). Its presence is also what marks a folder as reconcilable.
769 private struct SessionStamp: Codable {
770 let name: String
771 let destination: String
772 let created: String
773 }
774
775 private func writeSessionStamp(to dir: URL, name: String) {
776 let stamp = SessionStamp(
777 name: name, destination: destination,
778 created: ISO8601DateFormatter().string(from: Date()))
779 if let data = try? JSONEncoder().encode(stamp) {
780 try? data.write(to: dir.appendingPathComponent("session.json"))
781 }
782 }
783
784 func copyReference() {
785 guard let ref = lastSession else { return }
786 NSPasteboard.general.clearContents()
787 NSPasteboard.general.setString(ref, forType: .string)
788 statusText = "Copied \(ref)"
789 }
790
791 private func zenithSessionDir(_ name: String, destination: String? = nil) -> URL {
792 // Year comes from the session name, not the clock — a session archived (or
793 // reconciled) after midnight or months later still lands in its own year.
794 archiveRoot.appendingPathComponent(String(name.prefix(4)))
795 .appendingPathComponent(destination ?? self.destination)
796 .appendingPathComponent(name)
797 }
798
799 // MARK: REAPER
800
801 private func setupReaper(name: String) {
802 guard let zenith = zenithDir else {
803 errorMessage = "Can't set up REAPER without the archive folder."
804 return
805 }
806 guard FileManager.default.fileExists(atPath: reaperTemplate.path) else {
807 errorMessage = "REAPER template not found at \(reaperTemplate.path)"
808 return
809 }
810 let reaperDir = zenith.appendingPathComponent("reaper")
811 let project = reaperDir.appendingPathComponent("\(name).rpp")
812 do {
813 try FileManager.default.createDirectory(at: reaperDir, withIntermediateDirectories: true)
814 try FileManager.default.copyItem(at: reaperTemplate, to: project)
815 } catch {
816 errorMessage = "REAPER project setup failed: \(error.localizedDescription)"
817 return
818 }
819 // Open it; you drive transport. REAPER records its media next to the project
820 // (the final NAS location), so nothing needs repathing afterwards.
821 let open = Process()
822 open.executableURL = URL(fileURLWithPath: "/usr/bin/open")
823 open.arguments = ["-a", "REAPER", project.path]
824 try? open.run()
825 }
826
827 // MARK: Archive
828
829 /// rsync a local session dir into its NAS home with retries, then verify
830 /// every file actually arrived (same size) before the caller deletes
831 /// anything. Never deletes the source itself.
832 private func archiveVerified(from dir: URL, to zenith: URL) async -> Bool {
833 for attempt in 1...3 {
834 if attempt > 1 {
835 statusText = "Archiving to zenith… (retry \(attempt))"
836 _ = await ensureZenithMounted()
837 try? await Task.sleep(nanoseconds: UInt64(attempt) * 2_000_000_000)
838 }
839 let status = await runProcess(
840 "/usr/bin/rsync",
841 ["-a", "--partial", "--timeout=120", dir.path + "/", zenith.path + "/"])
842 if status == 0, verifyCopied(from: dir, to: zenith) { return true }
843 }
844 return false
845 }
846
847 /// Every regular file under `dir` exists on zenith with the same byte size.
848 /// rsync's exit code alone once let a 0-byte file pass as "archived".
849 private func verifyCopied(from dir: URL, to zenith: URL) -> Bool {
850 let fm = FileManager.default
851 guard let walker = fm.enumerator(at: dir, includingPropertiesForKeys: [.isRegularFileKey])
852 else { return false }
853 for case let file as URL in walker {
854 guard (try? file.resourceValues(forKeys: [.isRegularFileKey]))?.isRegularFile == true else {
855 continue
856 }
857 let rel = String(file.path.dropFirst(dir.path.count))
858 let src = (try? fm.attributesOfItem(atPath: file.path))?[.size] as? Int64
859 let dst = (try? fm.attributesOfItem(atPath: zenith.path + rel))?[.size] as? Int64
860 if src == nil || dst == nil || src != dst { return false }
861 }
862 return true
863 }
864
865 /// Run a command off the main actor, returning its exit status (-1 if it
866 /// couldn't launch).
867 private func runProcess(_ path: String, _ args: [String]) async -> Int32 {
868 await withCheckedContinuation { (cont: CheckedContinuation<Int32, Never>) in
869 DispatchQueue.global(qos: .utility).async {
870 let p = Process()
871 p.executableURL = URL(fileURLWithPath: path)
872 p.arguments = args
873 p.standardOutput = Pipe()
874 p.standardError = Pipe()
875 do {
876 try p.run()
877 p.waitUntilExit()
878 } catch {
879 cont.resume(returning: -1)
880 return
881 }
882 cont.resume(returning: p.terminationStatus)
883 }
884 }
885 }
886
887 // MARK: Sequencer project (.sq)
888
889 /// Write `<session>/<session>.sq` — a Clover Sequencer project (see the
890 /// top-level `writeSequenceFile`).
891 private func writeSequence(in dir: URL) {
892 if let err = writeSequenceFile(in: dir) { logErr(err) }
893 }
894
895 // MARK: Stranded-session reconciler
896
897 /// Archive any local session folders left behind by a crash, an offline
898 /// zenith, or a failed copy. Runs at launch and every 15 minutes while idle;
899 /// only folders carrying a session.json stamp are touched.
900 private func reconcilePending() async {
901 guard !isRecording, !postProcessing, !reconciling else { return }
902 reconciling = true
903 defer { reconciling = false }
904
905 var pending: [URL] = []
906 for base in [tempRoot, recoveryRoot] {
907 let dirs =
908 (try? FileManager.default.contentsOfDirectory(at: base, includingPropertiesForKeys: nil))
909 ?? []
910 pending += dirs.filter {
911 FileManager.default.fileExists(atPath: $0.appendingPathComponent("session.json").path)
912 }
913 }
914 guard !pending.isEmpty else { return }
915 guard await ensureZenithMounted() else { return } // try again next pass
916
917 for dir in pending {
918 guard !isRecording, !postProcessing else { return }
919 guard let data = try? Data(contentsOf: dir.appendingPathComponent("session.json")),
920 let stamp = try? JSONDecoder().decode(SessionStamp.self, from: data)
921 else { continue }
922
923 // No sync.json means the recorder died mid-session: remux the fragmented
924 // media so everything downstream can read it.
925 let crashed = !FileManager.default.fileExists(
926 atPath: dir.appendingPathComponent("sync.json").path)
927 if crashed { await finalizeCrashedMedia(in: dir) }
928
929 let zenith = zenithSessionDir(stamp.name, destination: stamp.destination)
930 try? FileManager.default.createDirectory(at: zenith, withIntermediateDirectories: true)
931 statusText = "Archiving recovered session \(stamp.name)…"
932 if await archiveVerified(from: dir, to: zenith) {
933 try? FileManager.default.removeItem(at: dir)
934 writeSequence(in: zenith)
935 statusText = nil
936 notify(
937 "Recovered session \(stamp.name)\(crashed ? " (interrupted)" : "") archived to zenith.")
938 if !FileManager.default.fileExists(
939 atPath: zenith.appendingPathComponent("transcript.md").path)
940 {
941 await transcribeSession(zenith)
942 }
943 } else {
944 statusText = nil
945 reportProblem(
946 "Couldn't archive recovered session \(stamp.name) — files remain at \(dir.path)")
947 return // zenith is flaky; retry the rest next pass instead of hammering
948 }
949 }
950 }
951
952 /// A crash leaves media ending in movie fragments with no final index; a
953 /// stream-copy remux rebuilds one so any player/tool can read the file.
954 private func finalizeCrashedMedia(in dir: URL) async {
955 guard let ffmpeg = ffmpegPath() else { return }
956 let media =
957 ((try? FileManager.default.contentsOfDirectory(at: dir, includingPropertiesForKeys: nil))
958 ?? [])
959 .filter { ["mov", "m4a"].contains($0.pathExtension) }
960 for file in media {
961 let fixed = file.deletingPathExtension().appendingPathExtension("fixed")
962 .appendingPathExtension(file.pathExtension)
963 let status = await runProcess(
964 ffmpeg,
965 ["-y", "-nostdin", "-loglevel", "error", "-i", file.path, "-c", "copy", fixed.path])
966 let size =
967 ((try? FileManager.default.attributesOfItem(atPath: fixed.path))?[.size] as? Int64) ?? 0
968 if status == 0, size > 1024 {
969 try? FileManager.default.removeItem(at: file)
970 try? FileManager.default.moveItem(at: fixed, to: file)
971 } else {
972 try? FileManager.default.removeItem(at: fixed)
973 }
974 }
975 }
976
977 // MARK: Compression
978
979 /// Re-encode the session's screen videos with x265 (CRF) before archiving.
980 /// Realtime hardware capture trades size for speed; this offline pass (the
981 /// Studio is idle between sessions) shrinks them several-fold while preserving
982 /// exact frame timing, so alignment is unaffected. Falls back to the original
983 /// if ffmpeg is missing or the re-encode looks wrong.
984 private func compress(_ dir: URL) async {
985 guard let ffmpeg = ffmpegPath() else { return }
986 let movs =
987 ((try? FileManager.default.contentsOfDirectory(at: dir, includingPropertiesForKeys: nil))
988 ?? [])
989 .filter { $0.pathExtension == "mov" }
990 .sorted { $0.lastPathComponent < $1.lastPathComponent }
991 if movs.isEmpty { return }
992
993 let durations = streamDurations(in: dir)
994 let total = movs.reduce(0.0) { $0 + (durations[$1.lastPathComponent] ?? 0) }
995 var done = 0.0
996 compressProgress = total > 0 ? 0 : nil
997
998 for mov in movs {
999 let dur = durations[mov.lastPathComponent] ?? 0
1000 let out = mov.deletingPathExtension().appendingPathExtension("x265.mov")
1001 // Light denoise on the webcam only (sensor noise is costly to encode);
1002 // screens are clean and would just lose text crispness.
1003 let filter = mov.lastPathComponent == "cam.mov" ? "hqdn3d=1.5:1.5:6:6" : nil
1004 let ok = await encode(ffmpeg, input: mov, output: out, filter: filter) { secs in
1005 if total > 0 { self.compressProgress = min(1, (done + min(secs, dur)) / total) }
1006 }
1007 done += dur
1008 if total > 0 { compressProgress = min(1, done / total) }
1009
1010 let attrs = try? FileManager.default.attributesOfItem(atPath: out.path)
1011 let outSize = (attrs?[.size] as? Int64) ?? 0
1012 if ok, outSize > 1024 {
1013 try? FileManager.default.removeItem(at: mov)
1014 try? FileManager.default.moveItem(at: out, to: mov)
1015 } else {
1016 try? FileManager.default.removeItem(at: out) // keep the original
1017 }
1018 }
1019 compressProgress = nil
1020 }
1021
1022 /// Per-stream durations from sync.json, so the progress bar knows the total.
1023 private func streamDurations(in dir: URL) -> [String: Double] {
1024 guard let data = try? Data(contentsOf: dir.appendingPathComponent("sync.json")),
1025 let obj = try? JSONSerialization.jsonObject(with: data) as? [String: Any],
1026 let streams = obj["streams"] as? [[String: Any]]
1027 else { return [:] }
1028 var map: [String: Double] = [:]
1029 for s in streams {
1030 if let file = s["file"] as? String, let d = s["durationSeconds"] as? Double { map[file] = d }
1031 }
1032 return map
1033 }
1034
1035 /// Transcribe the session's mic audio into transcript.md (meeting-minutes
1036 /// style, markers interleaved). Skips if there's no mic audio; anything else
1037 /// going wrong is reported, not swallowed.
1038 private func transcribeSession(_ dir: URL) async {
1039 let python = NSHomeDirectory() + "/.clover-whisper/.venv/bin/python"
1040 let mic = dir.appendingPathComponent("mic.m4a")
1041 guard FileManager.default.fileExists(atPath: mic.path) else { return }
1042 guard FileManager.default.isExecutableFile(atPath: python),
1043 let script = Bundle.main.url(forResource: "session_transcript", withExtension: "py")?.path
1044 else {
1045 reportProblem("Transcription skipped — whisper env missing (run setup-dictation.sh).")
1046 return
1047 }
1048 let markers = dir.appendingPathComponent("markers.json")
1049 let markersArg = FileManager.default.fileExists(atPath: markers.path) ? markers.path : "none"
1050 let out = dir.appendingPathComponent("transcript.md").path
1051 let mode = detectSpeakers ? "multi" : "solo"
1052 let result = await runTool(python, [script, mic.path, markersArg, out, dir.lastPathComponent, mode])
1053 if result.status != 0 {
1054 reportProblem(
1055 "Transcription failed for \(dir.lastPathComponent): "
1056 + (result.errorTail.isEmpty ? "exit \(result.status)" : result.errorTail))
1057 }
1058 }
1059
1060 /// Run a command to completion off the main actor, capturing its exit status
1061 /// and the tail of stderr so failures can be reported instead of vanishing.
1062 @discardableResult
1063 private func runTool(_ path: String, _ args: [String]) async -> (status: Int32, errorTail: String)
1064 {
1065 await withCheckedContinuation {
1066 (cont: CheckedContinuation<(status: Int32, errorTail: String), Never>) in
1067 DispatchQueue.global(qos: .utility).async {
1068 let p = Process()
1069 p.executableURL = URL(fileURLWithPath: path)
1070 p.arguments = args
1071 p.environment = cloverToolEnvironment()
1072 p.standardOutput = Pipe()
1073 let err = Pipe()
1074 p.standardError = err
1075 var tail = Data()
1076 err.fileHandleForReading.readabilityHandler = { fh in
1077 tail.append(fh.availableData)
1078 if tail.count > 8192 { tail = tail.suffix(4096) }
1079 }
1080 do {
1081 try p.run()
1082 p.waitUntilExit()
1083 } catch {
1084 err.fileHandleForReading.readabilityHandler = nil
1085 cont.resume(returning: (-1, error.localizedDescription))
1086 return
1087 }
1088 err.fileHandleForReading.readabilityHandler = nil
1089 if let rest = try? err.fileHandleForReading.readToEnd() { tail.append(rest) }
1090 let text = String(decoding: tail, as: UTF8.self)
1091 .trimmingCharacters(in: .whitespacesAndNewlines)
1092 cont.resume(returning: (p.terminationStatus, String(text.suffix(300))))
1093 }
1094 }
1095 }
1096
1097 private func ffmpegPath() -> String? {
1098 let candidates = [
1099 "/etc/profiles/per-user/\(NSUserName())/bin/ffmpeg",
1100 "/run/current-system/sw/bin/ffmpeg",
1101 "/opt/homebrew/bin/ffmpeg",
1102 "/usr/local/bin/ffmpeg",
1103 ]
1104 return candidates.first { FileManager.default.isExecutableFile(atPath: $0) }
1105 }
1106
1107 /// Run one x265 encode, reporting encoded-seconds via ffmpeg's `-progress`.
1108 private func encode(
1109 _ ffmpeg: String, input: URL, output: URL, filter: String? = nil,
1110 onProgress: @escaping (Double) -> Void
1111 ) async -> Bool {
1112 await withCheckedContinuation { (cont: CheckedContinuation<Bool, Never>) in
1113 DispatchQueue.global(qos: .utility).async {
1114 let p = Process()
1115 p.executableURL = URL(fileURLWithPath: ffmpeg)
1116 var args = [
1117 "-y", "-nostats", "-loglevel", "error", "-i", input.path,
1118 "-an", "-c:v", "libx265", "-crf", "24", "-preset", "fast",
1119 "-tag:v", "hvc1", "-fps_mode", "passthrough", "-progress", "pipe:1",
1120 ]
1121 if let filter { args += ["-vf", filter] }
1122 args.append(output.path)
1123 p.arguments = args
1124 let pipe = Pipe()
1125 p.standardOutput = pipe
1126 pipe.fileHandleForReading.readabilityHandler = { fh in
1127 let text = String(decoding: fh.availableData, as: UTF8.self)
1128 for line in text.split(separator: "\n") where line.hasPrefix("out_time_us=") {
1129 if let us = Double(line.dropFirst("out_time_us=".count)) {
1130 DispatchQueue.main.async { onProgress(us / 1_000_000) }
1131 }
1132 }
1133 }
1134 do {
1135 try p.run()
1136 p.waitUntilExit()
1137 } catch {
1138 cont.resume(returning: false)
1139 return
1140 }
1141 pipe.fileHandleForReading.readabilityHandler = nil
1142 cont.resume(returning: p.terminationStatus == 0)
1143 }
1144 }
1145 }
1146
1147 // MARK: Speaker review
1148
1149 var lastSessionHasSpeakers: Bool {
1150 guard let dir = lastSessionURL else { return false }
1151 // Only surface review when there's actually an unnamed voice — solo sessions
1152 // write a single known "You" speaker, which needs no review.
1153 return unknownSpeakers(in: dir)
1154 }
1155
1156 private func unknownSpeakers(in dir: URL) -> Bool {
1157 guard let data = try? Data(contentsOf: dir.appendingPathComponent("speakers.json")),
1158 let file = try? JSONDecoder().decode(SpeakersFile.self, from: data)
1159 else { return false }
1160 return file.speakers.contains { $0.unknown }
1161 }
1162
1163 func openSpeakersReview() {
1164 guard let dir = lastSessionURL,
1165 let data = try? Data(contentsOf: dir.appendingPathComponent("speakers.json")),
1166 let file = try? JSONDecoder().decode(SpeakersFile.self, from: data),
1167 !file.speakers.isEmpty
1168 else { return }
1169
1170 let model = SpeakersModel(
1171 speakers: file.speakers, libraryNames: libraryNames(),
1172 audioURL: dir.appendingPathComponent("mic.m4a"))
1173 let view = SpeakersView(
1174 model: model,
1175 onSave: { [weak self] mapping in self?.applySpeakerNames(dir: dir, mapping: mapping) },
1176 onCancel: { [weak self] in self?.closeSpeakersWindow() })
1177
1178 let win = NSWindow(
1179 contentRect: NSRect(x: 0, y: 0, width: 380, height: 300),
1180 styleMask: [.titled, .closable], backing: .buffered, defer: false)
1181 win.title = "Speakers · \(dir.lastPathComponent)"
1182 win.contentViewController = NSHostingController(rootView: view)
1183 win.isReleasedWhenClosed = false
1184 win.center()
1185 speakersWindow = win
1186 NSApp.activate(ignoringOtherApps: true)
1187 win.makeKeyAndOrderFront(nil)
1188 }
1189
1190 private func closeSpeakersWindow() {
1191 speakersWindow?.close()
1192 speakersWindow = nil
1193 }
1194
1195 private func libraryNames() -> [String] {
1196 let lib = NSHomeDirectory() + "/.clover-whisper/voices.json"
1197 guard let data = try? Data(contentsOf: URL(fileURLWithPath: lib)),
1198 let obj = try? JSONSerialization.jsonObject(with: data) as? [String: Any],
1199 let voices = obj["voices"] as? [[String: Any]]
1200 else { return [] }
1201 return voices.compactMap { $0["name"] as? String }
1202 }
1203
1204 private func applySpeakerNames(dir: URL, mapping: [String: String]) {
1205 closeSpeakersWindow()
1206 guard !mapping.isEmpty,
1207 let script = Bundle.main.url(forResource: "relabel", withExtension: "py")?.path
1208 else { return }
1209 let python = NSHomeDirectory() + "/.clover-whisper/.venv/bin/python"
1210 let mapURL = dir.appendingPathComponent("_speaker_mapping.json")
1211 guard let mdata = try? JSONSerialization.data(withJSONObject: mapping) else { return }
1212 try? mdata.write(to: mapURL)
1213 statusText = "Updating speakers…"
1214 Task {
1215 let result = await runTool(python, [script, dir.path, mapURL.path])
1216 try? FileManager.default.removeItem(at: mapURL)
1217 if result.status == 0 {
1218 self.statusText = "Speakers updated ✓"
1219 } else {
1220 self.statusText = nil
1221 self.reportProblem(
1222 "Speaker update failed: "
1223 + (result.errorTail.isEmpty ? "exit \(result.status)" : result.errorTail))
1224 }
1225 }
1226 }
1227
1228 // MARK: Voice enrollment
1229
1230 /// Record ~12 s of your voice and store a voiceprint so transcripts can tell
1231 /// you from other speakers.
1232 func enrollVoice() {
1233 guard !enrolling, !isRecording else { return }
1234 let python = NSHomeDirectory() + "/.clover-whisper/.venv/bin/python"
1235 guard FileManager.default.isExecutableFile(atPath: python) else {
1236 errorMessage = "Dictation env not set up (run setup-dictation.sh)."
1237 return
1238 }
1239 let url = FileManager.default.temporaryDirectory.appendingPathComponent("clover-enroll.wav")
1240 let settings: [String: Any] = [
1241 AVFormatIDKey: kAudioFormatLinearPCM, AVSampleRateKey: 16_000,
1242 AVNumberOfChannelsKey: 1, AVLinearPCMBitDepthKey: 16,
1243 AVLinearPCMIsFloatKey: false, AVLinearPCMIsBigEndianKey: false,
1244 ]
1245 do {
1246 let rec = try AVAudioRecorder(url: url, settings: settings)
1247 rec.record()
1248 enrollRecorder = rec
1249 } catch {
1250 errorMessage = "Couldn't open the mic for enrollment."
1251 return
1252 }
1253 errorMessage = nil
1254 statusText = nil
1255 enrolling = true
1256 enrollSecondsLeft = 12
1257 enrollTimer = Timer.scheduledTimer(withTimeInterval: 1, repeats: true) { [weak self] _ in
1258 Task { @MainActor in
1259 guard let self else { return }
1260 self.enrollSecondsLeft -= 1
1261 if self.enrollSecondsLeft <= 0 { self.finishEnroll(url: url, python: python) }
1262 }
1263 }
1264 }
1265
1266 private func finishEnroll(url: URL, python: String) {
1267 enrollTimer?.invalidate()
1268 enrollTimer = nil
1269 enrollRecorder?.stop()
1270 enrollRecorder = nil
1271 enrolling = false
1272 statusText = "Processing voice…"
1273 guard let script = Bundle.main.url(forResource: "enroll", withExtension: "py")?.path else {
1274 return
1275 }
1276 let library = NSHomeDirectory() + "/.clover-whisper/voices.json"
1277 Task {
1278 await runTool(python, [script, url.path, "Clover"])
1279 self.voiceEnrolled = FileManager.default.fileExists(atPath: library)
1280 self.statusText = self.voiceEnrolled ? "Voice enrolled ✓" : "Enrollment failed"
1281 }
1282 }
1283
1284 // MARK: Reveal / copy folder
1285
1286 func openFolder() {
1287 guard let url = lastSessionURL else { return }
1288 NSWorkspace.shared.open(url)
1289 }
1290
1291 func copyPath() {
1292 guard let url = lastSessionURL else { return }
1293 NSPasteboard.general.clearContents()
1294 NSPasteboard.general.setString(url.path, forType: .string)
1295 statusText = "Copied path"
1296 }
1297
1298 // MARK: zenith mount
1299
1300 private func zenithMounted() -> Bool {
1301 let vols = FileManager.default.mountedVolumeURLs(
1302 includingResourceValuesForKeys: nil, options: [.skipHiddenVolumes]) ?? []
1303 return vols.contains { $0.path == "/Volumes/clover" }
1304 && FileManager.default.fileExists(atPath: archiveRoot.path)
1305 }
1306
1307 /// Ensure the zenith SMB share is mounted, attempting to mount it with saved
1308 /// keychain credentials and polling briefly if it wasn't.
1309 private func ensureZenithMounted() async -> Bool {
1310 if zenithMounted() { return true }
1311 await withCheckedContinuation { (cont: CheckedContinuation<Void, Never>) in
1312 DispatchQueue.global(qos: .utility).async {
1313 let p = Process()
1314 p.executableURL = URL(fileURLWithPath: "/usr/bin/osascript")
1315 p.arguments = ["-e", "mount volume \"smb://clo@zenith.local/clover\""]
1316 try? p.run()
1317 p.waitUntilExit()
1318 cont.resume()
1319 }
1320 }
1321 for _ in 0..<12 {
1322 if zenithMounted() { return true }
1323 try? await Task.sleep(nanoseconds: 500_000_000)
1324 }
1325 return zenithMounted()
1326 }
1327
1328 private func stamp(_ format: String, _ date: Date = Date()) -> String {
1329 let f = DateFormatter()
1330 f.dateFormat = format
1331 return f.string(from: date)
1332 }
1333}
1334
1335// MARK: - View
1336
1337struct ContentView: View {
1338 @ObservedObject var controller: AppController
1339
1340 var body: some View {
1341 VStack(alignment: .leading, spacing: 12) {
1342 HStack {
1343 Text("Clover Recorder").font(.headline)
1344 Spacer()
1345 if controller.isRecording {
1346 Text(timeString(controller.elapsed))
1347 .font(.system(.body, design: .monospaced)).foregroundStyle(.red)
1348 }
1349 }
1350
1351 if controller.isRecording {
1352 Label(
1353 "F14 to drop a marker" + (controller.markerCount > 0 ? " · \(controller.markerCount)" : ""),
1354 systemImage: "mappin.and.ellipse"
1355 ).font(.caption2).foregroundStyle(.secondary)
1356 }
1357
1358 if !controller.permissionOK {
1359 Label("Screen Recording permission needed", systemImage: "exclamationmark.triangle")
1360 .font(.caption).foregroundStyle(.orange)
1361 }
1362
1363 Picker("", selection: $controller.destination) {
1364 Text("Sessions").tag("Sessions")
1365 Text("Journal").tag("Journal")
1366 }
1367 .pickerStyle(.segmented)
1368 .disabled(controller.isRecording)
1369
1370 Divider()
1371
1372 VStack(alignment: .leading, spacing: 6) {
1373 Text("Capture").font(.caption).foregroundStyle(.secondary)
1374 ForEach(Array(controller.orderedDisplays.enumerated()), id: \.element.id) { idx, d in
1375 Toggle(
1376 displayLabel(idx + 1, d),
1377 isOn: Binding(
1378 get: { controller.enabledDisplays.contains(d.id) },
1379 set: { controller.setDisplay(d.id, on: $0) })
1380 ).disabled(controller.isRecording)
1381 }
1382 if controller.displays.count > 1 {
1383 HStack {
1384 Toggle("Reverse screen order", isOn: $controller.reverseScreens)
1385 .disabled(controller.isRecording)
1386 Spacer()
1387 Button("Identify") { controller.identifyScreens() }
1388 .font(.caption)
1389 }
1390 }
1391 Toggle("Desktop audio", isOn: $controller.includeDesktop).disabled(controller.isRecording)
1392 deviceRow(
1393 "Microphone", isOn: $controller.includeMic, selection: $controller.micUID,
1394 options: controller.audioInputs, available: controller.hasMic)
1395 deviceRow(
1396 "Camera", isOn: $controller.includeCamera, selection: $controller.cameraUID,
1397 options: controller.cameras, available: controller.hasCamera)
1398 Toggle("REAPER (MIDI)", isOn: $controller.reaperMidi).disabled(controller.isRecording)
1399 Toggle("Detect multiple speakers", isOn: $controller.detectSpeakers)
1400 .disabled(controller.isRecording)
1401 .help("Off: transcribe as one voice. On: separate and name speakers (for sessions with other people).")
1402 }
1403 .toggleStyle(.checkbox)
1404
1405 if controller.includeCamera && controller.hasCamera {
1406 VStack(alignment: .leading, spacing: 6) {
1407 CameraPreview(session: controller.previewSession)
1408 .frame(width: 290, height: 290 / controller.cameraAspect)
1409 .background(Color.black)
1410 .clipShape(RoundedRectangle(cornerRadius: 8))
1411 HStack(spacing: 10) {
1412 Picker("Quality", selection: $controller.cameraHeight) {
1413 Text("1080p").tag(1080)
1414 Text("720p").tag(720)
1415 Text("480p").tag(480)
1416 }
1417 .disabled(controller.isRecording)
1418 Picker("FPS", selection: $controller.cameraFps) {
1419 Text("30").tag(30)
1420 Text("24").tag(24)
1421 Text("15").tag(15)
1422 Text("60").tag(60)
1423 }
1424 .disabled(controller.isRecording)
1425 Spacer()
1426 Button { controller.toggleCameraPopout() } label: {
1427 Image(systemName: "rectangle.on.rectangle")
1428 }
1429 .help("Pop out camera (stays up while recording)")
1430 }
1431 .font(.caption)
1432
1433 Picker("Anti-flicker", selection: $controller.cameraAntiFlickerHz) {
1434 Text("Off").tag(0)
1435 Text("60 Hz").tag(60)
1436 Text("50 Hz").tag(50)
1437 }
1438 .pickerStyle(.segmented)
1439 .font(.caption)
1440 }
1441 }
1442
1443 HStack(spacing: 6) {
1444 Image(
1445 systemName: controller.voiceEnrolled
1446 ? "person.fill.checkmark" : "person.crop.circle.badge.plus"
1447 )
1448 .foregroundStyle(controller.voiceEnrolled ? .green : .secondary)
1449 if controller.enrolling {
1450 Text("Recording voice… \(controller.enrollSecondsLeft)s — keep talking")
1451 .foregroundStyle(.red)
1452 } else {
1453 Text(controller.voiceEnrolled ? "Voice enrolled" : "Enroll voice for speaker labels")
1454 .foregroundStyle(.secondary)
1455 Spacer()
1456 Button(controller.voiceEnrolled ? "Re-enroll" : "Enroll") { controller.enrollVoice() }
1457 .disabled(controller.isRecording)
1458 }
1459 }
1460 .font(.caption)
1461
1462 Spacer()
1463
1464 if let p = controller.compressProgress {
1465 VStack(alignment: .leading, spacing: 3) {
1466 Text("Compressing video… \(Int(p * 100))%")
1467 .font(.caption).foregroundStyle(.secondary)
1468 ProgressView(value: p)
1469 }
1470 } else if let status = controller.statusText {
1471 Text(status).font(.caption).foregroundStyle(.secondary).lineLimit(1)
1472 }
1473
1474 if let last = controller.lastSession, !controller.isRecording {
1475 HStack(spacing: 8) {
1476 Button(action: { controller.copyReference() }) {
1477 HStack(spacing: 4) {
1478 Image(systemName: "checkmark.circle.fill").foregroundStyle(.green)
1479 Text(last).font(.system(.caption, design: .monospaced))
1480 }
1481 }
1482 .buttonStyle(.borderless)
1483 .help("Copy session reference")
1484 Spacer()
1485 if controller.lastSessionHasSpeakers {
1486 Button("Speakers") { controller.openSpeakersReview() }
1487 }
1488 Button("Open") { controller.openFolder() }
1489 Button("Copy path") { controller.copyPath() }
1490 }
1491 .font(.caption)
1492 }
1493
1494 if let err = controller.errorMessage {
1495 HStack(alignment: .top, spacing: 6) {
1496 Text(err).font(.caption).foregroundStyle(.red).lineLimit(4)
1497 Spacer()
1498 Button {
1499 controller.errorMessage = nil
1500 } label: {
1501 Image(systemName: "xmark.circle.fill").foregroundStyle(.secondary)
1502 }
1503 .buttonStyle(.borderless)
1504 .help("Dismiss")
1505 }
1506 }
1507
1508 VStack(spacing: 4) {
1509 Button(action: { controller.toggle() }) {
1510 HStack(spacing: 6) {
1511 if !controller.isRecording && !controller.hasMic {
1512 Image(systemName: "exclamationmark.triangle.fill")
1513 }
1514 Text(controller.isRecording ? "Stop" : "Start Recording")
1515 }
1516 .frame(maxWidth: .infinity)
1517 }
1518 .controlSize(.large)
1519 .tint(controller.isRecording ? .red : (controller.hasMic ? .accentColor : .yellow))
1520 .disabled(!controller.permissionOK && !controller.isRecording)
1521
1522 if !controller.isRecording && !controller.hasMic {
1523 Text("Will record without microphone")
1524 .font(.caption2).foregroundStyle(.yellow)
1525 }
1526 }
1527
1528 HStack {
1529 Button("Quit") { NSApp.terminate(nil) }.font(.caption)
1530 Spacer()
1531 }
1532 }
1533 .padding(14)
1534 .frame(width: 320)
1535 }
1536
1537 /// A capture toggle with an inline device dropdown, so all the checkboxes line
1538 /// up in a column.
1539 @ViewBuilder
1540 private func deviceRow(
1541 _ label: String, isOn: Binding<Bool>, selection: Binding<String?>,
1542 options: [DeviceInfo], available: Bool
1543 ) -> some View {
1544 HStack(spacing: 6) {
1545 Toggle(label, isOn: isOn).disabled(controller.isRecording || !available)
1546 Spacer()
1547 if isOn.wrappedValue && available && options.count > 1 {
1548 Picker("", selection: selection) {
1549 ForEach(options, id: \.uid) { Text($0.name).tag(Optional($0.uid)) }
1550 }
1551 .labelsHidden().font(.caption).frame(maxWidth: 150).disabled(controller.isRecording)
1552 }
1553 }
1554 }
1555
1556 private func displayLabel(_ number: Int, _ d: DisplayInfo) -> String {
1557 let xs = controller.displays.map { $0.x }
1558 var pos = ""
1559 if controller.displays.count > 1 {
1560 if d.x == xs.min() { pos = " · left" } else if d.x == xs.max() { pos = " · right" }
1561 }
1562 return "Screen \(number)\(pos) — \(d.width)×\(d.height)"
1563 }
1564
1565 private func timeString(_ t: TimeInterval) -> String {
1566 let s = Int(t)
1567 return String(format: "%02d:%02d", s / 60, s % 60)
1568 }
1569}
src/Recorder/engine/Sources/recorder/Recorder.swift deleted-733
...@@ -1,733 +0,0 @@
1import AVFoundation
2import CoreGraphics
3import CoreMedia
4import Foundation
5import ScreenCaptureKit
6
7let recorderVersion = "0.1.0"
8
9// MARK: - Logging
10
11// Optional log file so diagnostics survive launch methods that discard
12// stdout/stderr (e.g. `open` / LaunchServices).
13var logFile: FileHandle?
14
15func openLogFile(_ path: String) {
16 FileManager.default.createFile(atPath: path, contents: nil)
17 logFile = FileHandle(forWritingAtPath: path)
18}
19
20private func emit(_ message: String) {
21 let line = "[recorder] " + message + "\n"
22 FileHandle.standardError.write(Data(line.utf8))
23 if let logFile {
24 // Throwing variant: the legacy write() raises an ObjC exception if the log's
25 // volume vanishes mid-recording, which would kill the whole process.
26 try? logFile.write(contentsOf: Data(line.utf8))
27 }
28}
29
30func logErr(_ message: String) { emit(message) }
31func logInfo(_ message: String) { emit(message) }
32
33// MARK: - Host clock
34
35/// Seconds on the mach host clock — the same clock ScreenCaptureKit and
36/// AVCapture stamp their sample buffers with, so values are directly comparable
37/// across every stream.
38func hostSeconds() -> Double {
39 CMTimeGetSeconds(CMClockGetTime(CMClockGetHostTimeClock()))
40}
41
42// MARK: - Device discovery
43
44struct DisplayInfo: Codable {
45 let id: UInt32
46 let width: Int
47 let height: Int
48 let x: Int
49 let y: Int
50}
51
52struct DeviceInfo: Codable {
53 let uid: String
54 let name: String
55 var continuity: Bool = false // iPhone/iPad Continuity device
56}
57
58struct DiscoveredDevices: Codable {
59 let displays: [DisplayInfo]
60 let cameras: [DeviceInfo]
61 let audioInputs: [DeviceInfo]
62}
63
64enum Devices {
65 static func discover() async throws -> DiscoveredDevices {
66 let content = try await SCShareableContent.excludingDesktopWindows(
67 false, onScreenWindowsOnly: false)
68
69 let displays =
70 content.displays
71 .sorted { $0.frame.origin.x < $1.frame.origin.x }
72 .map {
73 DisplayInfo(
74 id: $0.displayID,
75 width: Int($0.frame.width),
76 height: Int($0.frame.height),
77 x: Int($0.frame.origin.x),
78 y: Int($0.frame.origin.y))
79 }
80
81 let cameras = AVCaptureDevice.DiscoverySession(
82 deviceTypes: [.builtInWideAngleCamera, .external, .continuityCamera],
83 mediaType: .video, position: .unspecified
84 ).devices.map {
85 DeviceInfo(
86 uid: $0.uniqueID, name: $0.localizedName, continuity: $0.deviceType == .continuityCamera)
87 }
88
89 // Base names of Continuity cameras (e.g. "small phone, for small girl") so we
90 // can flag the matching iPhone/iPad mic even when its camera isn't active.
91 let continuityCamBases = cameras
92 .filter { $0.continuity }
93 .map { $0.name.replacingOccurrences(of: " Camera", with: "") }
94 .filter { !$0.isEmpty }
95 let audioInputs = AVCaptureDevice.DiscoverySession(
96 deviceTypes: [.microphone, .external],
97 mediaType: .audio, position: .unspecified
98 ).devices.map { dev -> DeviceInfo in
99 // Continuity Capture audio transport types: 'ccwd' (wired) / 'ccwl' (wireless).
100 let tt = dev.transportType
101 let byTransport = tt == 0x6363_7764 || tt == 0x6363_776C
102 let byName = continuityCamBases.contains { dev.localizedName.hasPrefix($0) }
103 return DeviceInfo(
104 uid: dev.uniqueID, name: dev.localizedName, continuity: byTransport || byName)
105 }
106
107 return DiscoveredDevices(displays: displays, cameras: cameras, audioInputs: audioInputs)
108 }
109
110 static func audioDevice(matching wanted: String) -> AVCaptureDevice? {
111 let devices = AVCaptureDevice.DiscoverySession(
112 deviceTypes: [.microphone, .external],
113 mediaType: .audio, position: .unspecified
114 ).devices
115 if wanted == "default" {
116 return AVCaptureDevice.default(for: .audio) ?? devices.first
117 }
118 return devices.first { $0.uniqueID == wanted || $0.localizedName == wanted }
119 }
120
121 static func videoDevice(matching wanted: String) -> AVCaptureDevice? {
122 let devices = AVCaptureDevice.DiscoverySession(
123 deviceTypes: [.builtInWideAngleCamera, .external, .continuityCamera],
124 mediaType: .video, position: .unspecified
125 ).devices
126 if wanted == "default" {
127 return AVCaptureDevice.default(for: .video) ?? devices.first
128 }
129 return devices.first { $0.uniqueID == wanted || $0.localizedName == wanted }
130 }
131}
132
133// MARK: - Manifest
134
135struct StreamManifest: Codable {
136 let name: String
137 let file: String
138 let kind: String
139 var displayID: UInt32?
140 var deviceUID: String?
141 var width: Int?
142 var height: Int?
143 var fps: Int?
144 let firstSampleHostSeconds: Double
145 let lastSampleHostSeconds: Double
146 let durationSeconds: Double
147 let frames: Int
148 let dropped: Int // real drops (input not ready / append failed)
149 let repeated: Int // CFR frames re-emitted to hold the rate on a static screen
150 var offsetSeconds: Double
151}
152
153struct SessionManifest: Codable {
154 let recorderVersion: String
155 let label: String
156 let createdEpoch: Double
157 let hostClockAtStart: Double
158 let tStartHostSeconds: Double
159 let streams: [StreamManifest]
160}
161
162// MARK: - Stream sink protocol
163
164/// Point-in-time view of a stream for the engine's watchdog.
165struct StreamHealth {
166 let name: String
167 let started: Bool // has produced at least one sample in the current part
168 let dead: Bool // rollover exhausted; the stream is permanently lost
169 let lastAppendHost: Double // host seconds of the last successful append (NaN if none)
170}
171
172/// How often movie fragments are flushed. A crash, power cut, or vanishing
173/// volume loses at most this much media instead of the entire (index-less) file.
174let fragmentSeconds = 5.0
175
176/// Give up on a stream after this many rollover attempts — a target that keeps
177/// failing writers instantly would otherwise spray part-files forever.
178let maxParts = 5
179
180/// Anything that records one stream to one file (or, after failures, a series
181/// of part files) and reports where each sat on the shared host clock, so the
182/// engine can align and summarize them uniformly.
183protocol RecordingStream: AnyObject {
184 func finish() async
185 /// One manifest entry per written part, in order.
186 func manifests() -> [StreamManifest]
187 func health() -> StreamHealth
188 /// Abandon the current file and continue into a fresh part inside `dir`
189 /// (used when the volume under the current file disappears). No-op if the
190 /// current file already lives there.
191 func rollover(to dir: URL)
192}
193
194// MARK: - Stream writer (passthrough: audio + camera)
195
196/// Owns one AVAssetWriter + input and turns a flow of CMSampleBuffers into one
197/// file, lazily starting the writer session on the first buffer and recording
198/// that buffer's host-clock timestamp for later alignment.
199final class StreamWriter: RecordingStream {
200 let name: String
201 let kind: String
202 private(set) var url: URL
203 var displayID: UInt32?
204 var deviceUID: String?
205 var width: Int?
206 var height: Int?
207 var fps: Int?
208
209 private let fileType: AVFileType
210 private let settings: [String: Any]
211 private let mediaType: AVMediaType
212 private var writer: AVAssetWriter
213 private var input: AVAssetWriterInput
214 private let lock = NSLock()
215
216 private var partFirstPTS: Double = .nan
217 private var partLastPTS: Double = .nan
218 private var partFrames = 0
219 private var partDropped = 0
220 private var lastAppendHost: Double = .nan
221 private var started = false
222 private var dead = false
223 private var partIndex = 1
224 private var doneParts: [StreamManifest] = []
225 private var rolloverDir: URL?
226
227 init(
228 url: URL, name: String, kind: String, fileType: AVFileType,
229 settings: [String: Any], mediaType: AVMediaType, fallbackDir: URL? = nil
230 ) throws {
231 self.url = url
232 self.name = name
233 self.kind = kind
234 self.fileType = fileType
235 self.settings = settings
236 self.mediaType = mediaType
237 self.rolloverDir = fallbackDir
238 (self.writer, self.input) = try Self.makeWriter(
239 url: url, fileType: fileType, settings: settings, mediaType: mediaType, name: name)
240 }
241
242 private static func makeWriter(
243 url: URL, fileType: AVFileType, settings: [String: Any], mediaType: AVMediaType, name: String
244 ) throws -> (AVAssetWriter, AVAssetWriterInput) {
245 let w = try AVAssetWriter(outputURL: url, fileType: fileType)
246 // Periodic fragments keep the file readable up to the last flush even if
247 // we never get to finalize it (crash, power loss, disk disconnect).
248 w.movieFragmentInterval = CMTime(seconds: fragmentSeconds, preferredTimescale: 600)
249 let i = AVAssetWriterInput(mediaType: mediaType, outputSettings: settings)
250 i.expectsMediaDataInRealTime = true
251 guard w.canAdd(i) else {
252 throw RecorderError("cannot add \(mediaType.rawValue) input for \(name)")
253 }
254 w.add(i)
255 return (w, i)
256 }
257
258 func append(_ sb: CMSampleBuffer) {
259 lock.lock()
260 defer { lock.unlock() }
261 if dead { return }
262
263 let pts = CMTimeGetSeconds(CMSampleBufferGetPresentationTimeStamp(sb))
264
265 if !started {
266 guard writer.startWriting() else {
267 logErr("\(name): startWriting failed: \(writer.error?.localizedDescription ?? "?")")
268 rolloverLocked()
269 return
270 }
271 writer.startSession(atSourceTime: CMSampleBufferGetPresentationTimeStamp(sb))
272 partFirstPTS = pts
273 started = true
274 }
275
276 if input.isReadyForMoreMediaData {
277 if input.append(sb) {
278 partFrames += 1
279 partLastPTS = pts
280 lastAppendHost = hostSeconds()
281 } else {
282 partDropped += 1
283 if writer.status == .failed {
284 logErr("\(name): append failed: \(writer.error?.localizedDescription ?? "?")")
285 rolloverLocked()
286 }
287 }
288 } else {
289 partDropped += 1
290 }
291 }
292
293 /// Abandon the current writer and continue into a fresh part file. The old
294 /// file keeps whatever fragments reached disk (recoverable when the volume
295 /// returns). Caller must hold `lock`.
296 private func rolloverLocked() {
297 if started { doneParts.append(partManifestLocked()) }
298 if writer.status == .writing {
299 // Proactive roll (volume vanished from under a healthy writer): try to
300 // finalize in the background; we don't wait on a possibly-dead disk.
301 input.markAsFinished()
302 writer.finishWriting {}
303 }
304 started = false
305 partFirstPTS = .nan
306 partLastPTS = .nan
307 partFrames = 0
308 partDropped = 0
309 partIndex += 1
310 guard partIndex <= maxParts, let dir = rolloverDir else {
311 dead = true
312 logErr("\(name): stream lost (no rollover target or too many failures)")
313 return
314 }
315 let next = dir.appendingPathComponent("\(name)-\(partIndex).\(url.pathExtension)")
316 do {
317 try FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true)
318 (writer, input) = try Self.makeWriter(
319 url: next, fileType: fileType, settings: settings, mediaType: mediaType, name: name)
320 url = next
321 logErr("\(name): rolled over to \(next.path)")
322 } catch {
323 dead = true
324 logErr("\(name): rollover failed (\(error.localizedDescription)) — stream lost")
325 }
326 }
327
328 func rollover(to dir: URL) {
329 lock.lock()
330 defer { lock.unlock() }
331 guard !dead, !url.deletingLastPathComponent().path.hasPrefix(dir.path) else { return }
332 rolloverDir = dir
333 rolloverLocked()
334 }
335
336 func health() -> StreamHealth {
337 lock.lock()
338 defer { lock.unlock() }
339 return StreamHealth(name: name, started: started, dead: dead, lastAppendHost: lastAppendHost)
340 }
341
342 func finish() async {
343 lock.lock()
344 let w = writer
345 let i = input
346 let finalize = started && w.status == .writing
347 if finalize { i.markAsFinished() }
348 lock.unlock()
349
350 guard finalize else {
351 if doneParts.isEmpty { logErr("\(name): no samples captured, nothing written") }
352 return
353 }
354 await withCheckedContinuation { (cont: CheckedContinuation<Void, Never>) in
355 w.finishWriting { cont.resume() }
356 }
357 if w.status == .failed {
358 logErr("\(name): finishWriting failed: \(w.error?.localizedDescription ?? "?")")
359 }
360 }
361
362 /// Caller must hold `lock`.
363 private func partManifestLocked() -> StreamManifest {
364 StreamManifest(
365 name: partIndex == 1 ? name : "\(name)-\(partIndex)",
366 file: url.lastPathComponent,
367 kind: kind,
368 displayID: displayID,
369 deviceUID: deviceUID,
370 width: width,
371 height: height,
372 fps: fps,
373 firstSampleHostSeconds: partFirstPTS,
374 lastSampleHostSeconds: partLastPTS,
375 durationSeconds: (partFirstPTS.isNaN || partLastPTS.isNaN) ? 0 : (partLastPTS - partFirstPTS),
376 frames: partFrames,
377 dropped: partDropped,
378 repeated: 0,
379 offsetSeconds: 0)
380 }
381
382 func manifests() -> [StreamManifest] {
383 lock.lock()
384 defer { lock.unlock() }
385 var all = doneParts
386 if started || all.isEmpty { all.append(partManifestLocked()) }
387 return all
388 }
389}
390
391struct RecorderError: Error, CustomStringConvertible, LocalizedError {
392 let description: String
393 init(_ description: String) { self.description = description }
394 var errorDescription: String? { description }
395}
396
397// MARK: - Constant-frame-rate video writer (screens)
398
399/// ScreenCaptureKit only delivers a frame when the screen changes, so a raw
400/// passthrough yields a variable, sparse frame rate whose duration ends at the
401/// last change rather than at the stop time — which would desync against audio.
402///
403/// This writer decouples capture from encoding: SCOutput hands every fresh
404/// frame to `update(_:)`, and an independent timer emits the most-recent frame
405/// at the target rate, timestamped on the host clock. The result stays dense,
406/// its duration matches wall-clock, and it never drifts against the audio.
407final class CFRVideoWriter: RecordingStream {
408 let name: String
409 let kind = "screen"
410 private(set) var url: URL
411 var displayID: UInt32?
412 let width: Int
413 let height: Int
414 let fps: Int
415
416 private let settings: [String: Any]
417 private var writer: AVAssetWriter
418 private var input: AVAssetWriterInput
419 private var adaptor: AVAssetWriterInputPixelBufferAdaptor
420 private let queue = DispatchQueue(label: "clover.cfr")
421 private let lock = NSLock()
422
423 private var latest: CVPixelBuffer?
424 private var lastEmitted: CVPixelBuffer?
425 private var timer: DispatchSourceTimer?
426
427 private var partFirstPTS = Double.nan
428 private var lastPTS = Double.nan
429 private var lastEmitHost = Double.nan
430 private var frames = 0
431 private var repeated = 0
432 private var dropped = 0
433 private var started = false
434 private var dead = false
435 private var partIndex = 1
436 private var doneParts: [StreamManifest] = []
437 private var rolloverDir: URL?
438
439 /// Longest gap between emitted frames while the screen is static. Real changes
440 /// emit immediately at full rate; this just keeps the timeline progressing so
441 /// a frozen screen costs ~1 fps instead of 30.
442 private let keepAliveSeconds = 1.0
443
444 init(url: URL, name: String, width: Int, height: Int, fps: Int, bitrate: Int, fallbackDir: URL? = nil)
445 throws
446 {
447 self.url = url
448 self.name = name
449 self.width = width
450 self.height = height
451 self.fps = fps
452 self.rolloverDir = fallbackDir
453
454 // Screen content is highly compressible: a long keyframe interval lets a
455 // nearly-static screen cost almost nothing (the periodic keyframes were the
456 // bulk of the size before), and frame reordering (B-frames) tightens it
457 // further. Average bitrate is just a ceiling for busy moments.
458 self.settings = [
459 AVVideoCodecKey: AVVideoCodecType.hevc,
460 AVVideoWidthKey: width,
461 AVVideoHeightKey: height,
462 AVVideoCompressionPropertiesKey: [
463 AVVideoAverageBitRateKey: bitrate,
464 AVVideoExpectedSourceFrameRateKey: fps,
465 AVVideoMaxKeyFrameIntervalKey: fps * 10,
466 AVVideoMaxKeyFrameIntervalDurationKey: 10.0,
467 AVVideoAllowFrameReorderingKey: true,
468 ],
469 ]
470 (self.writer, self.input, self.adaptor) = try Self.makeWriter(
471 url: url, settings: settings, width: width, height: height, name: name)
472 }
473
474 private static func makeWriter(
475 url: URL, settings: [String: Any], width: Int, height: Int, name: String
476 ) throws -> (AVAssetWriter, AVAssetWriterInput, AVAssetWriterInputPixelBufferAdaptor) {
477 let w = try AVAssetWriter(outputURL: url, fileType: .mov)
478 // Fragments bound the loss from a crash/power cut/vanishing disk to seconds.
479 w.movieFragmentInterval = CMTime(seconds: fragmentSeconds, preferredTimescale: 600)
480 let i = AVAssetWriterInput(mediaType: .video, outputSettings: settings)
481 i.expectsMediaDataInRealTime = true
482 let a = AVAssetWriterInputPixelBufferAdaptor(
483 assetWriterInput: i,
484 sourcePixelBufferAttributes: [
485 kCVPixelBufferPixelFormatTypeKey as String: kCVPixelFormatType_420YpCbCr8BiPlanarVideoRange,
486 kCVPixelBufferWidthKey as String: width,
487 kCVPixelBufferHeightKey as String: height,
488 ])
489 guard w.canAdd(i) else { throw RecorderError("cannot add video input for \(name)") }
490 w.add(i)
491 return (w, i, a)
492 }
493
494 /// Latest frame from ScreenCaptureKit; retained until replaced (SCK won't
495 /// recycle a buffer we still hold, so no copy is needed).
496 func update(_ sb: CMSampleBuffer) {
497 guard let pb = CMSampleBufferGetImageBuffer(sb) else { return }
498 lock.lock()
499 latest = pb
500 lock.unlock()
501 }
502
503 func start() {
504 let interval = 1.0 / Double(fps)
505 let t = DispatchSource.makeTimerSource(queue: queue)
506 t.schedule(deadline: .now() + interval, repeating: interval, leeway: .milliseconds(2))
507 t.setEventHandler { [weak self] in self?.tick() }
508 timer = t
509 t.resume()
510 }
511
512 private func tick() {
513 lock.lock()
514 let pb = latest
515 lock.unlock()
516 guard let pb, !dead else { return }
517
518 let now = hostSeconds()
519 let changed = pb !== lastEmitted
520
521 // Once running, drop identical frames unless it's time for a keep-alive.
522 if started, !changed, !lastEmitHost.isNaN, now - lastEmitHost < keepAliveSeconds {
523 return
524 }
525
526 if !started {
527 guard writer.startWriting() else {
528 logErr("\(name): startWriting failed: \(writer.error?.localizedDescription ?? "?")")
529 rolloverOnQueue()
530 return
531 }
532 writer.startSession(atSourceTime: CMTime(seconds: now, preferredTimescale: 1_000_000))
533 partFirstPTS = now
534 started = true
535 }
536 guard input.isReadyForMoreMediaData else {
537 dropped += 1
538 return
539 }
540 let time = CMTime(seconds: now, preferredTimescale: 1_000_000)
541 if adaptor.append(pb, withPresentationTime: time) {
542 frames += 1
543 lastPTS = now
544 lastEmitHost = now
545 if !changed { repeated += 1 }
546 lastEmitted = pb
547 } else {
548 dropped += 1
549 if writer.status == .failed {
550 logErr("\(name): append failed: \(writer.error?.localizedDescription ?? "?")")
551 rolloverOnQueue()
552 }
553 }
554 }
555
556 /// Abandon the current writer and continue into a fresh part file. Must run
557 /// on `queue`. The retained `latest` frame lives in memory, so the new part
558 /// picks up on the very next tick.
559 private func rolloverOnQueue() {
560 if started { doneParts.append(partManifestOnQueue()) }
561 if writer.status == .writing {
562 input.markAsFinished()
563 writer.finishWriting {} // fire-and-forget: the volume may be gone
564 }
565 started = false
566 partFirstPTS = .nan
567 lastPTS = .nan
568 frames = 0
569 repeated = 0
570 dropped = 0
571 lastEmitted = nil // force the next tick to emit a frame immediately
572 partIndex += 1
573 guard partIndex <= maxParts, let dir = rolloverDir else {
574 dead = true
575 logErr("\(name): stream lost (no rollover target or too many failures)")
576 return
577 }
578 let next = dir.appendingPathComponent("\(name)-\(partIndex).mov")
579 do {
580 try FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true)
581 (writer, input, adaptor) = try Self.makeWriter(
582 url: next, settings: settings, width: width, height: height, name: name)
583 url = next
584 logErr("\(name): rolled over to \(next.path)")
585 } catch {
586 dead = true
587 logErr("\(name): rollover failed (\(error.localizedDescription)) — stream lost")
588 }
589 }
590
591 func rollover(to dir: URL) {
592 queue.async { [weak self] in
593 guard let self, !self.dead,
594 !self.url.deletingLastPathComponent().path.hasPrefix(dir.path)
595 else { return }
596 self.rolloverDir = dir
597 self.rolloverOnQueue()
598 }
599 }
600
601 func health() -> StreamHealth {
602 queue.sync {
603 StreamHealth(name: name, started: started, dead: dead, lastAppendHost: lastEmitHost)
604 }
605 }
606
607 func finish() async {
608 timer?.cancel()
609 timer = nil
610 // Drain the timer queue so no tick races with finalization.
611 await withCheckedContinuation { (cont: CheckedContinuation<Void, Never>) in
612 queue.async { cont.resume() }
613 }
614 guard started, writer.status == .writing else {
615 if doneParts.isEmpty { logErr("\(name): no frames captured, nothing written") }
616 return
617 }
618 input.markAsFinished()
619 await withCheckedContinuation { (cont: CheckedContinuation<Void, Never>) in
620 writer.finishWriting { cont.resume() }
621 }
622 if writer.status == .failed {
623 logErr("\(name): finishWriting failed: \(writer.error?.localizedDescription ?? "?")")
624 }
625 }
626
627 /// Must run on `queue` (or after it is drained).
628 private func partManifestOnQueue() -> StreamManifest {
629 StreamManifest(
630 name: partIndex == 1 ? name : "\(name)-\(partIndex)",
631 file: url.lastPathComponent,
632 kind: kind,
633 displayID: displayID,
634 deviceUID: nil,
635 width: width,
636 height: height,
637 fps: fps,
638 firstSampleHostSeconds: partFirstPTS,
639 lastSampleHostSeconds: lastPTS,
640 durationSeconds: (partFirstPTS.isNaN || lastPTS.isNaN) ? 0 : (lastPTS - partFirstPTS),
641 frames: frames,
642 dropped: dropped,
643 repeated: repeated,
644 offsetSeconds: 0)
645 }
646
647 func manifests() -> [StreamManifest] {
648 queue.sync {
649 var all = doneParts
650 if started || all.isEmpty { all.append(partManifestOnQueue()) }
651 return all
652 }
653 }
654}
655
656// MARK: - Sample delegates
657
658final class SCOutput: NSObject, SCStreamOutput, SCStreamDelegate {
659 let label: String
660 var onStreamError: ((String) -> Void)?
661 private let onScreen: (CMSampleBuffer) -> Void
662 private let onAudio: ((CMSampleBuffer) -> Void)?
663
664 private let counterLock = NSLock()
665 private(set) var screenSeen = 0
666 private(set) var screenComplete = 0
667 private(set) var audioSeen = 0
668
669 init(
670 label: String, onScreen: @escaping (CMSampleBuffer) -> Void,
671 onAudio: ((CMSampleBuffer) -> Void)?
672 ) {
673 self.label = label
674 self.onScreen = onScreen
675 self.onAudio = onAudio
676 }
677
678 func stream(
679 _ stream: SCStream, didOutputSampleBuffer sampleBuffer: CMSampleBuffer,
680 of type: SCStreamOutputType
681 ) {
682 switch type {
683 case .screen:
684 counterLock.lock()
685 screenSeen += 1
686 counterLock.unlock()
687 guard CMSampleBufferGetImageBuffer(sampleBuffer) != nil, Self.isComplete(sampleBuffer) else {
688 return
689 }
690 counterLock.lock()
691 screenComplete += 1
692 counterLock.unlock()
693 onScreen(sampleBuffer)
694 case .audio:
695 counterLock.lock()
696 audioSeen += 1
697 counterLock.unlock()
698 onAudio?(sampleBuffer)
699 default:
700 break
701 }
702 }
703
704 func stream(_ stream: SCStream, didStopWithError error: Error) {
705 logErr("SCStream stopped with error: \(error.localizedDescription)")
706 onStreamError?("\(label): screen capture stopped — \(error.localizedDescription)")
707 }
708
709 static func isComplete(_ sb: CMSampleBuffer) -> Bool {
710 guard
711 let arr = CMSampleBufferGetSampleAttachmentsArray(sb, createIfNecessary: false)
712 as? [[SCStreamFrameInfo: Any]],
713 let info = arr.first,
714 let raw = info[.status] as? Int,
715 let status = SCFrameStatus(rawValue: raw)
716 else { return false }
717 return status == .complete
718 }
719}
720
721final class AVOutput: NSObject, AVCaptureVideoDataOutputSampleBufferDelegate,
722 AVCaptureAudioDataOutputSampleBufferDelegate
723{
724 private let cb: (CMSampleBuffer) -> Void
725 init(_ cb: @escaping (CMSampleBuffer) -> Void) { self.cb = cb }
726
727 func captureOutput(
728 _ output: AVCaptureOutput, didOutput sampleBuffer: CMSampleBuffer,
729 from connection: AVCaptureConnection
730 ) {
731 cb(sampleBuffer)
732 }
733}
src/Recorder/engine/Sources/recorder/SequenceExport.swift deleted-189
...@@ -1,189 +0,0 @@
1import CryptoKit
2import Foundation
3
4// Emits a Clover Sequencer project (`.sq`) from a finished session folder — a
5// convenience duplicate of sync.json + markers.json that lays every recorded
6// stream on its own time-aligned track so a session opens for review in one
7// double-click. The Sequencer's on-disk model (sequencer/Model.swift) decodes
8// tolerantly, so emitting just the fields below is enough; the rest default.
9// Times are seconds throughout, matching the Sequencer model.
10
11struct MarkersFile: Codable { let markers: [Marker] }
12
13// v2 envelope: { formatVersion, project, view }. `view` is portable UI state we
14// leave at defaults (empty object → the Sequencer's ViewState defaults).
15private struct SeqDocument: Codable {
16 let formatVersion: Int
17 let project: SeqProject
18 let view: SeqView
19}
20private struct SeqView: Codable {}
21private struct SeqProject: Codable {
22 let fps: Double
23 let boardWidth: Int
24 let boardHeight: Int
25 let media: [SeqMedia]
26 let tracks: [SeqTrack]
27 let clips: [SeqClip]
28 let markers: [SeqMarker]
29 let preferredTakes: [String]
30}
31private struct SeqMedia: Codable {
32 let id: String
33 let path: String
34 let duration: Double
35 let fps: Double
36 let width: Int
37 let height: Int
38 let hasAudio: Bool
39 let isAudio: Bool
40 let cacheKey: String
41}
42// A track is just a hue now — its index in `tracks` is its number.
43private struct SeqTrack: Codable {
44 let hue: Double
45}
46private struct SeqClip: Codable {
47 let id: String
48 let kind: String
49 let mediaId: String
50 let track: String // TrackRef wire form: "v0", "v1", … (video lane index)
51 let start: Double
52 let srcIn: Double
53 let duration: Double
54 let speed: Double
55 let muted: Bool
56 let fadeIn: Double
57 let fadeOut: Double
58 let linkId: String
59 let newShot: Bool
60}
61private struct SeqMarker: Codable {
62 let id: String
63 let time: Double
64 let label: String
65}
66
67private func isAudioKind(_ k: String) -> Bool { k == "mic" || k == "system-audio" }
68
69/// Sequencer's content-addressed cache key — MUST match MediaPipeline.cacheKey
70/// (sequencer/MediaPipeline.swift): SHA256("path|size|mtime").hex.prefix(16).
71/// On project load Sequencer trusts this stored key rather than recomputing, so
72/// a wrong/empty value collides every clip onto one media's proxy. Computed from
73/// the same file the app will read, so it also lands as a cache hit.
74private func sequencerCacheKey(for url: URL) -> String {
75 let attrs = try? FileManager.default.attributesOfItem(atPath: url.path)
76 let size = (attrs?[.size] as? NSNumber)?.int64Value ?? 0
77 let mtime = (attrs?[.modificationDate] as? Date)?.timeIntervalSince1970 ?? 0
78 let seed = "\(url.path)|\(size)|\(Int(mtime))"
79 let digest = SHA256.hash(data: Data(seed.utf8))
80 return String(digest.map { String(format: "%02x", $0) }.joined().prefix(16))
81}
82
83/// Write `<dir>/<dirname>.sq`. Returns nil on success (or a silent skip when
84/// there's no sync.json — e.g. a crashed session that was never finalized), or
85/// an error message string on a real write failure.
86@discardableResult
87func writeSequenceFile(in dir: URL) -> String? {
88 let out = dir.appendingPathComponent("\(dir.lastPathComponent).sq")
89 guard let data = try? Data(contentsOf: dir.appendingPathComponent("sync.json")),
90 let manifest = try? JSONDecoder().decode(SessionManifest.self, from: data)
91 else { return nil }
92
93 // A 0-frame stream is a dead device (e.g. the 0-byte mic) — skip it rather
94 // than add unplayable media.
95 let streams = manifest.streams.filter { $0.frames > 0 && $0.durationSeconds > 0.01 }
96 guard !streams.isEmpty else { return nil }
97
98 // Rollover parts of one source share a displayID (screens) or deviceUID
99 // (mic/camera), so grouping by that keeps a part on its source's track rather
100 // than spawning a new lane. Desktop audio has neither and is the only one of
101 // its kind, so the kind alone is a stable key.
102 func laneKey(_ s: StreamManifest) -> String {
103 if let d = s.displayID { return "d\(d)" }
104 if let u = s.deviceUID { return "u\(u)" }
105 return s.kind
106 }
107 // Lanes ordered camera → screens → mic → desktop, so the viewer grid reads
108 // top-down the way you'd expect.
109 func priority(_ k: String) -> Int {
110 switch k {
111 case "camera": return 0
112 case "screen": return 1
113 case "mic": return 2
114 default: return 3 // system-audio
115 }
116 }
117
118 var laneOrder: [String] = []
119 var laneStreams: [String: [StreamManifest]] = [:]
120 for s in streams {
121 let key = laneKey(s)
122 if laneStreams[key] == nil { laneOrder.append(key) }
123 laneStreams[key, default: []].append(s)
124 }
125 laneOrder.sort { a, b in
126 let ka = laneStreams[a]![0], kb = laneStreams[b]![0]
127 if priority(ka.kind) != priority(kb.kind) { return priority(ka.kind) < priority(kb.kind) }
128 return ka.name < kb.name
129 }
130
131 let linkId = UUID().uuidString // whole session moves together (multicam)
132 var media: [SeqMedia] = []
133 var tracks: [SeqTrack] = []
134 var clips: [SeqClip] = []
135
136 for (index, key) in laneOrder.enumerated() {
137 // Golden-ratio hue spacing (matches ProjectModel.defaultHue) — adjacent
138 // lanes stay visibly distinct.
139 let hue = (Double(index) * 0.6180339887498949).truncatingRemainder(dividingBy: 1)
140 tracks.append(SeqTrack(hue: hue))
141 let trackRef = "v\(index)" // clips reference the lane by index
142 for s in laneStreams[key]! {
143 let audio = isAudioKind(s.kind)
144 let mediaId = UUID().uuidString
145 let fileURL = dir.appendingPathComponent(s.file)
146 media.append(
147 SeqMedia(
148 id: mediaId, path: fileURL.path,
149 duration: s.durationSeconds, fps: Double(s.fps ?? 30),
150 width: s.width ?? 0, height: s.height ?? 0,
151 hasAudio: audio, isAudio: audio, cacheKey: sequencerCacheKey(for: fileURL)))
152 clips.append(
153 SeqClip(
154 id: UUID().uuidString, kind: audio ? "audio" : "video", mediaId: mediaId,
155 track: trackRef, start: max(0, s.offsetSeconds), srcIn: 0,
156 duration: s.durationSeconds, speed: 1,
157 muted: s.kind == "system-audio", // desktop off by default; mic is the voice
158 fadeIn: 0, fadeOut: 0, linkId: linkId, newShot: false))
159 }
160 }
161
162 // Markers: hostSeconds is directly comparable to sync's tStartHostSeconds,
163 // placing each note at its exact spot on the same timeline as the clips.
164 var markers: [SeqMarker] = []
165 if let mdata = try? Data(contentsOf: dir.appendingPathComponent("markers.json")),
166 let file = try? JSONDecoder().decode(MarkersFile.self, from: mdata)
167 {
168 for m in file.markers {
169 markers.append(
170 SeqMarker(
171 id: UUID().uuidString, time: max(0, m.hostSeconds - manifest.tStartHostSeconds),
172 label: m.text ?? ""))
173 }
174 }
175
176 let fps = streams.first { !isAudioKind($0.kind) }?.fps ?? 30
177 let project = SeqProject(
178 fps: Double(fps), boardWidth: 1920, boardHeight: 1080, media: media, tracks: tracks,
179 clips: clips, markers: markers, preferredTakes: [])
180 let doc = SeqDocument(formatVersion: 2, project: project, view: SeqView())
181 do {
182 let encoder = JSONEncoder()
183 encoder.outputFormatting = [.prettyPrinted, .sortedKeys]
184 try encoder.encode(doc).write(to: out)
185 return nil
186 } catch {
187 return "failed to write \(out.lastPathComponent): \(error.localizedDescription)"
188 }
189}
src/Recorder/engine/Sources/recorder/SpeakersReview.swift deleted-124
...@@ -1,124 +0,0 @@
1import AVFoundation
2import AppKit
3import SwiftUI
4
5// MARK: - speakers.json model
6
7struct SpeakerInfo: Decodable {
8 let label: String
9 let unknown: Bool
10 let sample: Sample
11 struct Sample: Decodable {
12 let start: Double
13 let end: Double
14 }
15}
16
17struct SpeakersFile: Decodable {
18 let speakers: [SpeakerInfo]
19}
20
21// MARK: - Review model
22
23@MainActor
24final class SpeakersModel: ObservableObject {
25 struct Row: Identifiable {
26 let id = UUID()
27 let original: String
28 let unknown: Bool
29 let start: Double
30 let end: Double
31 var name: String
32 }
33
34 @Published var rows: [Row]
35 let libraryNames: [String]
36 let audioURL: URL
37
38 private var player: AVAudioPlayer?
39 private var stopWork: DispatchWorkItem?
40
41 init(speakers: [SpeakerInfo], libraryNames: [String], audioURL: URL) {
42 rows = speakers.map {
43 Row(original: $0.label, unknown: $0.unknown, start: $0.sample.start, end: $0.sample.end,
44 name: $0.label)
45 }
46 self.libraryNames = libraryNames
47 self.audioURL = audioURL
48 }
49
50 func play(_ row: Row) {
51 stopWork?.cancel()
52 guard let p = try? AVAudioPlayer(contentsOf: audioURL) else { return }
53 p.prepareToPlay()
54 p.currentTime = row.start
55 p.play()
56 player = p
57 let work = DispatchWorkItem { [weak self] in self?.player?.stop() }
58 stopWork = work
59 DispatchQueue.main.asyncAfter(deadline: .now() + max(0.6, row.end - row.start), execute: work)
60 }
61
62 /// Only the speakers whose name was actually changed.
63 func mapping() -> [String: String] {
64 var m: [String: String] = [:]
65 for r in rows {
66 let n = r.name.trimmingCharacters(in: .whitespaces)
67 if !n.isEmpty && n != r.original { m[r.original] = n }
68 }
69 return m
70 }
71}
72
73// MARK: - Review view
74
75struct SpeakersView: View {
76 @ObservedObject var model: SpeakersModel
77 let onSave: ([String: String]) -> Void
78 let onCancel: () -> Void
79
80 var body: some View {
81 VStack(alignment: .leading, spacing: 12) {
82 Text("Who's speaking?").font(.headline)
83 Text("Name each voice — anyone you name is remembered and auto-labelled next time.")
84 .font(.caption).foregroundStyle(.secondary)
85
86 ForEach($model.rows) { $row in
87 HStack(spacing: 10) {
88 Button { model.play(row) } label: {
89 Image(systemName: "play.circle.fill").font(.title2)
90 }
91 .buttonStyle(.borderless)
92 .help("Hear this voice")
93
94 VStack(alignment: .leading, spacing: 1) {
95 Text(row.original).font(.caption2).foregroundStyle(.secondary)
96 TextField("Name", text: $row.name)
97 .textFieldStyle(.roundedBorder).frame(width: 200)
98 }
99
100 if !model.libraryNames.isEmpty {
101 Menu {
102 ForEach(model.libraryNames, id: \.self) { name in
103 Button(name) { row.name = name }
104 }
105 } label: {
106 Image(systemName: "person.crop.circle")
107 }
108 .menuStyle(.borderlessButton).frame(width: 28)
109 .help("Pick a known person")
110 }
111 }
112 }
113
114 HStack {
115 Button("Cancel") { onCancel() }
116 Spacer()
117 Button("Save") { onSave(model.mapping()) }.keyboardShortcut(.defaultAction)
118 }
119 .padding(.top, 6)
120 }
121 .padding(16)
122 .frame(width: 380)
123 }
124}
src/Recorder/engine/Sources/recorder/main.swift deleted-188
...@@ -1,188 +0,0 @@
1import AVFoundation
2import CoreGraphics
3import Dispatch
4import Foundation
5
6// Clover Recorder capture core.
7//
8// recorder list
9// recorder record --out DIR [options]
10//
11// record options:
12// --label NAME session label (default "session")
13// --screen ID capture this display (repeatable; default: all)
14// --system-audio capture the system-audio mix into desktop.m4a
15// --mic UID|default capture this audio input into mic.m4a
16// --camera UID|default capture this camera into cam.mov
17// --fps N frame rate (default 30)
18// --max-width N clamp the longest screen side, px (default 3840; 0 = native)
19// --bpp F HEVC bits per pixel-frame (default 0.05)
20// --duration S auto-stop after S seconds (otherwise runs until SIGINT/SIGTERM)
21// --safe DIR internal-SSD dir for audio + rescued streams (default: --out DIR)
22
23func fail(_ message: String) -> Never {
24 logErr(message)
25 exit(1)
26}
27
28func runList() {
29 let sem = DispatchSemaphore(value: 0)
30 var exitCode: Int32 = 0
31 Task {
32 do {
33 let devices = try await Devices.discover()
34 let encoder = JSONEncoder()
35 encoder.outputFormatting = [.prettyPrinted, .sortedKeys]
36 let data = try encoder.encode(devices)
37 FileHandle.standardOutput.write(data)
38 FileHandle.standardOutput.write(Data("\n".utf8))
39 } catch {
40 logErr("list failed: \(error.localizedDescription)")
41 logErr("(Screen Recording permission may not be granted to this binary yet.)")
42 exitCode = 1
43 }
44 sem.signal()
45 }
46 sem.wait()
47 exit(exitCode)
48}
49
50func parseRecordConfig(_ args: [String]) -> RecordConfig {
51 var outDir: URL?
52 var label = "session"
53 var displayIDs: [CGDirectDisplayID] = []
54 var systemAudio = false
55 var micUID: String?
56 var cameraUID: String?
57 var fps = 30
58 var maxWidth = 3840
59 var bpp = 0.05
60 var duration: Double?
61 var logPath: String?
62 var safeDir: URL?
63 var cameraHeight = 1080
64 var cameraFps = 30
65
66 var i = 0
67 func next(_ flag: String) -> String {
68 i += 1
69 guard i < args.count else { fail("missing value for \(flag)") }
70 return args[i]
71 }
72
73 while i < args.count {
74 let arg = args[i]
75 switch arg {
76 case "--out": outDir = URL(fileURLWithPath: next(arg))
77 case "--label": label = next(arg)
78 case "--screen":
79 guard let id = UInt32(next(arg)) else { fail("--screen expects a numeric display id") }
80 displayIDs.append(id)
81 case "--system-audio": systemAudio = true
82 case "--mic": micUID = next(arg)
83 case "--camera": cameraUID = next(arg)
84 case "--fps": fps = Int(next(arg)) ?? 30
85 case "--max-width": maxWidth = Int(next(arg)) ?? 3840
86 case "--bpp": bpp = Double(next(arg)) ?? 0.05
87 case "--duration": duration = Double(next(arg))
88 case "--log": logPath = next(arg)
89 case "--safe": safeDir = URL(fileURLWithPath: next(arg))
90 case "--camera-height": cameraHeight = Int(next(arg)) ?? 1080
91 case "--camera-fps": cameraFps = Int(next(arg)) ?? 30
92 default: fail("unknown option: \(arg)")
93 }
94 i += 1
95 }
96
97 guard let outDir else { fail("--out DIR is required") }
98 return RecordConfig(
99 outDir: outDir, label: label, displayIDs: displayIDs, systemAudio: systemAudio,
100 micUID: micUID, cameraUID: cameraUID, fps: fps, maxWidth: maxWidth, bitsPerPixel: bpp,
101 duration: duration, logPath: logPath, safeDir: safeDir,
102 cameraHeight: cameraHeight, cameraFps: cameraFps)
103}
104
105func runRecord(_ args: [String]) {
106 let cfg = parseRecordConfig(args)
107 if let logPath = cfg.logPath { openLogFile(logPath) }
108 logInfo("recorder \(recorderVersion) starting: \(cfg.outDir.path)")
109 let engine = CaptureEngine(cfg)
110
111 // Never block the main thread: ScreenCaptureKit delivers start/stop
112 // completions on the main queue, so we keep main free via dispatchMain()
113 // and drive everything from background queues / Tasks.
114 let stopGuard = StopOnce()
115 func triggerStop() {
116 guard stopGuard.begin() else { return }
117 logInfo("stopping…")
118 Task {
119 await engine.stop()
120 exit(0)
121 }
122 }
123
124 Task {
125 do {
126 try await engine.start()
127 } catch {
128 logErr("start failed: \(error.localizedDescription)")
129 exit(1)
130 }
131 }
132
133 // Stop on SIGINT / SIGTERM, or after --duration.
134 let sigQueue = DispatchQueue(label: "clover.signals")
135 var sources: [DispatchSourceSignal] = []
136 for sig in [SIGINT, SIGTERM] {
137 signal(sig, SIG_IGN)
138 let src = DispatchSource.makeSignalSource(signal: sig, queue: sigQueue)
139 src.setEventHandler { triggerStop() }
140 src.resume()
141 sources.append(src)
142 }
143 if let duration = cfg.duration {
144 sigQueue.asyncAfter(deadline: .now() + duration) { triggerStop() }
145 }
146 signalSources = sources // keep alive
147
148 dispatchMain()
149}
150
151/// One-shot guard so duration + signal can't both run finalize.
152final class StopOnce {
153 private let lock = NSLock()
154 private var started = false
155 func begin() -> Bool {
156 lock.lock()
157 defer { lock.unlock() }
158 if started { return false }
159 started = true
160 return true
161 }
162}
163
164var signalSources: [DispatchSourceSignal] = []
165
166// Entry point. With no args (or `menubar`) it runs as a menubar app; with
167// `record`/`list` it runs headless for SSH/scripting. Same signed binary either
168// way, so the one Screen Recording grant covers both.
169let argv = Array(CommandLine.arguments.dropFirst())
170switch argv.first {
171case "list": runList()
172case "record": runRecord(Array(argv.dropFirst()))
173case "sequence":
174 // recorder sequence <session-dir> — (re)generate <name>.sq from sync.json.
175 guard argv.count > 1 else { fail("usage: recorder sequence <session-dir>") }
176 let dir = URL(fileURLWithPath: argv[1])
177 if let err = writeSequenceFile(in: dir) { fail(err) }
178 if FileManager.default.fileExists(
179 atPath: dir.appendingPathComponent("\(dir.lastPathComponent).sq").path)
180 {
181 logInfo("wrote \(dir.lastPathComponent).sq")
182 } else {
183 logInfo("skipped: no usable sync.json in \(dir.path)")
184 }
185 exit(0)
186case nil, "menubar": MainActor.assumeIsolated { runMenubar() }
187default: fail("unknown command: \(argv.first ?? "")")
188}
src/Recorder/setup-diarization.sh deleted-31
...@@ -1,31 +0,0 @@
1#!/usr/bin/env bash
2# Optional "precise mode": pyannote speaker diarization for exact turn
3# boundaries (so mid-sentence interjections are attributed correctly).
4#
5# It lives in its OWN venv because pyannote 3.x needs older torch/torchaudio/
6# huggingface_hub than the main transcription venv — isolating it avoids
7# breaking Whisper/forced-alignment. session_transcript.py auto-detects this
8# venv and uses it; without it, it falls back to cluster-then-match labeling.
9#
10# Prerequisites (one-time, free):
11# 1. A HuggingFace token — log in once so it's cached:
12# ~/.clover-diarize/.venv/bin/huggingface-cli login (or set HF_TOKEN)
13# 2. Accept the model terms (click "Agree") at:
14# https://huggingface.co/pyannote/speaker-diarization-3.1
15# https://huggingface.co/pyannote/segmentation-3.0
16#
17# bash ~/dev/creative-control/src/Recorder/setup-diarization.sh
18set -euo pipefail
19
20DIR="$HOME/.clover-diarize"
21echo "==> creating diarization venv at $DIR"
22mkdir -p "$DIR"
23cd "$DIR"
24uv venv
25# Pinned, mutually-compatible set (torch 2.4 keeps numpy 2; torchaudio 2.4 still
26# exposes AudioMetaData; hf_hub 0.25 still has use_auth_token).
27uv pip install \
28 "pyannote.audio==3.3.2" "torch==2.4.1" "torchaudio==2.4.1" \
29 "huggingface_hub==0.25.2" matplotlib
30
31echo "✅ precise mode ready (ensure the token is logged in and model terms accepted)"
src/Recorder/setup-dictation.sh deleted-26
...@@ -1,26 +0,0 @@
1#!/usr/bin/env bash
2# One-time: set up local Whisper for the marker overlay's voice dictation.
3#
4# Creates a venv at ~/.clover-whisper with MLX Whisper (Apple-Silicon optimized)
5# and pre-downloads large-v3-turbo (~1.5 GB). Transcription then runs fully
6# on-device in ~1.5 s per note on this machine.
7#
8# bash ~/dev/creative-control/src/Recorder/setup-dictation.sh
9set -euo pipefail
10
11DIR="$HOME/.clover-whisper"
12echo "==> creating venv at $DIR"
13mkdir -p "$DIR"
14cd "$DIR"
15uv venv
16# mlx-whisper: transcription · speechbrain/torchaudio/scipy: speaker ID + clustering
17uv pip install mlx-whisper speechbrain torchaudio scipy
18
19echo "==> pre-downloading whisper-large-v3-turbo (one-time)"
20say -o /tmp/clover-dictation-warm.aiff "Clover dictation is ready." 2>/dev/null || true
21"$DIR/.venv/bin/python" - /tmp/clover-dictation-warm.aiff <<'PY' || true
22import sys, mlx_whisper
23mlx_whisper.transcribe(sys.argv[1], path_or_hf_repo="mlx-community/whisper-large-v3-turbo")
24PY
25
26echo "✅ dictation ready ($DIR/.venv/bin/python)"
src/Recorder/setup-signing.sh deleted-82
...@@ -1,82 +0,0 @@
1#!/usr/bin/env bash
2# One-time: create a stable self-signed code-signing identity in a dedicated
3# keychain so the Screen Recording grant survives rebuilds.
4#
5# Why a dedicated keychain (not login): it can be created, unlocked, and
6# imported into entirely over SSH with a known password — no GUI, no touching
7# your login keychain. TCC keys the Screen Recording grant on the app's
8# *designated requirement* (bundle id + cert leaf), which stays identical across
9# rebuilds, so you grant once and never get re-prompted.
10#
11# The cert is self-signed and untrusted; that's fine — Gatekeeper is bypassed
12# for locally-built, non-quarantined apps, and TCC matching doesn't need trust.
13#
14# Safe to re-run; it's idempotent. The keychain password is local-only and has
15# nothing to do with your macOS login password.
16set -euo pipefail
17
18CN="Clover Code Signing"
19KC="$HOME/Library/Keychains/clover-signing.keychain-db"
20KCPW="${CLOVER_KEYCHAIN_PW:-clover}"
21P12="$HOME/.clover-code-signing.p12" # backup so the identity survives keychain loss
22
23ensure_searchlist() {
24 local existing
25 existing=$(security list-keychains -d user | sed -e 's/^ *//' -e 's/"//g')
26 case "$existing" in
27 *clover-signing*) ;;
28 *) security list-keychains -d user -s "$KC" $existing ;;
29 esac
30}
31
32if [[ -f "$KC" ]] && security find-identity -p codesigning "$KC" 2>/dev/null | grep -q "$CN"; then
33 security unlock-keychain -p "$KCPW" "$KC" 2>/dev/null || true
34 ensure_searchlist
35 echo "✅ '$CN' already present in $KC"
36 exit 0
37fi
38
39TMP="$(mktemp -d)"
40trap 'rm -rf "$TMP"' EXIT
41
42if [[ -f "$P12" ]]; then
43 echo "==> reusing saved identity from $P12 (keeps the same TCC requirement)"
44 cp "$P12" "$TMP/cs.p12"
45else
46 echo "==> generating new self-signed code-signing certificate"
47 cat > "$TMP/cs.conf" <<EOF
48[ req ]
49distinguished_name = dn
50x509_extensions = v3
51prompt = no
52[ dn ]
53CN = $CN
54[ v3 ]
55keyUsage = critical, digitalSignature
56extendedKeyUsage = critical, codeSigning
57basicConstraints = critical, CA:false
58EOF
59 openssl req -x509 -newkey rsa:2048 -keyout "$TMP/cs.key" -out "$TMP/cs.crt" \
60 -days 3650 -nodes -config "$TMP/cs.conf" >/dev/null 2>&1
61 openssl pkcs12 -export -inkey "$TMP/cs.key" -in "$TMP/cs.crt" -out "$TMP/cs.p12" \
62 -passout pass:clover -name "$CN" >/dev/null 2>&1
63 cp "$TMP/cs.p12" "$P12"
64 chmod 600 "$P12"
65fi
66
67echo "==> (re)creating dedicated keychain $KC"
68security delete-keychain "$KC" 2>/dev/null || true
69security create-keychain -p "$KCPW" "$KC"
70security set-keychain-settings "$KC" # no auto-lock timeout
71security unlock-keychain -p "$KCPW" "$KC"
72security import "$TMP/cs.p12" -k "$KC" -P clover -A -T /usr/bin/codesign
73security set-key-partition-list -S apple-tool:,apple:,unsigned: -s -k "$KCPW" "$KC" >/dev/null 2>&1 || true
74ensure_searchlist
75
76echo
77if security find-identity -p codesigning "$KC" | grep -q "$CN"; then
78 echo "✅ '$CN' ready in $KC. build.sh will sign with it automatically."
79else
80 echo "⚠️ identity not found after setup — check the output above."
81 exit 1
82fi
src/Recorder/uvc/uvc-powerline.c deleted-80
...@@ -1,80 +0,0 @@
1// Set a UVC webcam's "Power Line Frequency" (anti-flicker) control over USB.
2//
3// macOS's camera API (AVFoundation) doesn't expose this control, but the camera
4// accepts a UVC SET_CUR on its default control pipe via IOKit even while the
5// system UVC driver is streaming — which cancels mains-flicker banding in
6// hardware (verified: the scrolling bars disappear the instant it's set).
7//
8// uvc-powerline <value> [vidHex] [pidHex]
9// value: 0 = off, 1 = 50 Hz, 2 = 60 Hz
10//
11// Defaults to the ZS CAMERA (VID 0x328f / PID 0x0072). Exits 0 if the control
12// was accepted by at least one processing-unit entity.
13#include <CoreFoundation/CoreFoundation.h>
14#include <IOKit/IOCFPlugIn.h>
15#include <IOKit/IOKitLib.h>
16#include <IOKit/usb/IOUSBLib.h>
17#include <stdio.h>
18#include <stdlib.h>
19
20#define UVC_SET_CUR 0x01
21#define PU_POWER_LINE_FREQUENCY_CONTROL 0x05
22
23int main(int argc, char** argv) {
24 int value = (argc > 1) ? atoi(argv[1]) : 2;
25 SInt32 vid = (argc > 2) ? (SInt32)strtol(argv[2], NULL, 16) : 0x328f;
26 SInt32 pid = (argc > 3) ? (SInt32)strtol(argv[3], NULL, 16) : 0x0072;
27
28 CFMutableDictionaryRef match = IOServiceMatching(kIOUSBDeviceClassName);
29 CFNumberRef vr = CFNumberCreate(NULL, kCFNumberSInt32Type, &vid);
30 CFNumberRef pr = CFNumberCreate(NULL, kCFNumberSInt32Type, &pid);
31 CFDictionarySetValue(match, CFSTR(kUSBVendorID), vr);
32 CFDictionarySetValue(match, CFSTR(kUSBProductID), pr);
33 CFRelease(vr);
34 CFRelease(pr);
35
36 io_service_t svc = IOServiceGetMatchingService(kIOMainPortDefault, match);
37 if (!svc) {
38 fprintf(stderr, "uvc-powerline: camera %04x:%04x not found\n", vid, pid);
39 return 1;
40 }
41
42 IOCFPlugInInterface** plug = NULL;
43 SInt32 score;
44 IOCreatePlugInInterfaceForService(
45 svc, kIOUSBDeviceUserClientTypeID, kIOCFPlugInInterfaceID, &plug, &score);
46 IOObjectRelease(svc);
47 if (!plug) return 1;
48
49 IOUSBDeviceInterface** dev = NULL;
50 (*plug)->QueryInterface(plug, CFUUIDGetUUIDBytes(kIOUSBDeviceInterfaceID), (LPVOID*)&dev);
51 (*plug)->Release(plug);
52 if (!dev) return 1;
53
54 IOReturn r = (*dev)->USBDeviceOpen(dev);
55 if (r != kIOReturnSuccess) {
56 r = (*dev)->USBDeviceOpenSeize(dev);
57 if (r != kIOReturnSuccess) {
58 fprintf(stderr, "uvc-powerline: cannot open device (0x%x)\n", r);
59 (*dev)->Release(dev);
60 return 2;
61 }
62 }
63
64 UInt8 val = (UInt8)value;
65 int ok = 0;
66 for (int entity = 1; entity <= 6; entity++) {
67 IOUSBDevRequest req;
68 req.bmRequestType = USBmakebmRequestType(kUSBOut, kUSBClass, kUSBInterface);
69 req.bRequest = UVC_SET_CUR;
70 req.wValue = (PU_POWER_LINE_FREQUENCY_CONTROL << 8);
71 req.wIndex = (entity << 8) | 0; // VideoControl interface 0
72 req.wLength = 1;
73 req.pData = &val;
74 if ((*dev)->DeviceRequest(dev, &req) == kIOReturnSuccess) ok = 1;
75 }
76
77 (*dev)->USBDeviceClose(dev);
78 (*dev)->Release(dev);
79 return ok ? 0 : 3;
80}
src/SpeedEditor.ts deleted-994
...@@ -1,994 +0,0 @@
1import { Events } from "@clo/lib/Events.ts";
2import * as log from "@clo/lib/log.ts";
3import { defer } from "@clo/lib/ts.ts";
4import type { Dispose, Timer } from "@clo/lib/ts.ts";
5import type { Mac } from "./Mac";
6
7const SPEED_EDITOR_VENDOR_ID = 0x1edb;
8const SPEED_EDITOR_PRODUCT_ID = 0xda0e;
9
10const keyIds = [
11 "smartInsert",
12 "append",
13 "rippleOverwrite",
14 "closeUp",
15 "placeOnTop",
16 "sourceOverwrite",
17 "in",
18 "out",
19 "trimIn",
20 "trimOut",
21 "roll",
22 "slipSource",
23 "slipDestination",
24 "transitionDuration",
25 "cut",
26 "dissolve",
27 "smoothCut",
28 "source",
29 "timeline",
30 "shuttle",
31 "jog",
32 "scroll",
33 "escape",
34 "syncBin",
35 "audioLevel",
36 "fullView",
37 "transition",
38 "split",
39 "snap",
40 "rippleDelete",
41 "cam1",
42 "cam2",
43 "cam3",
44 "cam4",
45 "cam5",
46 "cam6",
47 "cam7",
48 "cam8",
49 "cam9",
50 "liveOverwrite",
51 "videoOnly",
52 "audioOnly",
53 "stopPlay",
54] as const;
55
56const ledIds = [
57 "closeUp",
58 "cut",
59 "dissolve",
60 "smoothCut",
61 "transition",
62 "snap",
63 "cam7",
64 "cam8",
65 "cam9",
66 "liveOverwrite",
67 "cam4",
68 "cam5",
69 "cam6",
70 "videoOnly",
71 "cam1",
72 "cam2",
73 "cam3",
74 "audioOnly",
75] as const;
76
77const jogLeds = ["jog", "shuttle", "scroll"] as const;
78
79const jogModes = [
80 "relative",
81 "relative-normalized",
82 "absolute-continuous",
83 "absolute-deadzero",
84] as const;
85
86/**
87 * Node.js Bindings for DaVinci Resolve Speed Editor.
88 */
89export class SpeedEditor extends Events<SpeedEditor.EventMap> {
90 static keys = keyIds;
91 static leds = ledIds;
92 static jogLeds = jogLeds;
93 static jogModes = jogModes;
94
95 #options: Required<SpeedEditor.Options>;
96 #device: import("node-hid").HID | null = null;
97 #deviceInfo: SpeedEditor.DeviceInfo | null = null;
98 #ready = false;
99 #closed = false;
100 #stopHotplugMonitoring: (() => void) | null = null;
101 #refreshing: Promise<void> | null = null;
102 #refreshRequested = false;
103 #leds = new Set<SpeedEditor.Led>();
104 #jogLeds = new Set<SpeedEditor.JogLed>();
105 #jogMode: SpeedEditor.JogMode = "relative";
106 #normalizedJogActive = false;
107 #normalizedJogBufferedDistance = 0;
108 #normalizedJogBufferedValue = 0;
109 #normalizedJogResetTimer: Timer | null = null;
110 #activeKeys = new Set<SpeedEditor.Key>();
111 #pendingKeypressTimers = new Map<SpeedEditor.Key, Timer>();
112 #doublePressActiveKeys = new Set<SpeedEditor.Key>();
113 #doublePressListeners = new Map<SpeedEditor.Key, Set<() => void>>();
114
115 private constructor(options: SpeedEditor.Options = {}) {
116 super();
117 this.#options = {
118 vendorId: options.vendorId ?? SPEED_EDITOR_VENDOR_ID,
119 productId: options.productId ?? SPEED_EDITOR_PRODUCT_ID,
120 path: options.path ?? null,
121 nonExclusive: options.nonExclusive ?? false,
122 };
123 }
124
125 static async open(options: SpeedEditor.Options = {}) {
126 const editor = new SpeedEditor(options);
127 await editor.#start();
128 return editor;
129 }
130
131 static async listDevices(
132 options: Pick<SpeedEditor.Options, "vendorId" | "productId"> = {},
133 ): Promise<SpeedEditor.DeviceInfo[]> {
134 const { devices } = await import("node-hid");
135 const vendorId = options.vendorId ?? SPEED_EDITOR_VENDOR_ID;
136 const productId = options.productId ?? SPEED_EDITOR_PRODUCT_ID;
137
138 return devices(vendorId, productId).map((device) => ({ ...device }));
139 }
140
141 #authenticate(device: import("node-hid").HID): number {
142 return authenticateDevice(device);
143 }
144
145 setLeds(states: Iterable<SpeedEditor.Led>) {
146 this.#leds = new Set(states);
147 if (this.#device) {
148 writeLedState(this.#device, this.#leds);
149 }
150 }
151
152 setJogLeds(states: Iterable<SpeedEditor.JogLed>) {
153 this.#jogLeds = new Set(states);
154 if (this.#device) {
155 writeJogLedState(this.#device, this.#jogLeds);
156 }
157 }
158
159 setJogMode(mode: SpeedEditor.JogMode) {
160 if (mode !== this.#jogMode) {
161 this.#resetNormalizedJogTracking();
162 }
163 this.#jogMode = mode;
164 if (this.#device) {
165 writeJogMode(this.#device, mode);
166 }
167 }
168
169 leds = new Proxy({} as Record<SpeedEditor.Led, boolean>, {
170 get: (_, key) => {
171 return this.#leds.has(key as SpeedEditor.Led);
172 },
173 set: (_, key, value) => {
174 if (value) this.#leds.add(key as SpeedEditor.Led);
175 else this.#leds.delete(key as SpeedEditor.Led);
176 this.setLeds(Array.from(this.#leds));
177 return true;
178 },
179 });
180
181 jogLeds = new Proxy({} as Record<SpeedEditor.JogLed, boolean>, {
182 get: (_, key) => {
183 return this.#jogLeds.has(key as SpeedEditor.JogLed);
184 },
185 set: (_, key, value) => {
186 return true;
187 },
188 });
189
190 keys = new Proxy({} as Record<SpeedEditor.Key, boolean>, {
191 get: (_, key) => {
192 return this.#activeKeys.has(key as SpeedEditor.Key);
193 },
194 set: (_, key, value) => {
195 return true;
196 },
197 });
198
199 get jogMode(): SpeedEditor.JogMode {
200 return this.#jogMode;
201 }
202
203 get connected(): boolean {
204 return this.#ready;
205 }
206
207 get deviceInfo(): SpeedEditor.DeviceInfo | null {
208 return this.#deviceInfo && { ...this.#deviceInfo };
209 }
210
211 onPress(key: SpeedEditor.Key, listener: () => void): Dispose {
212 return this.on("keypress", (code) => {
213 if (key === code) listener();
214 });
215 }
216
217 onDoublePress(key: SpeedEditor.Key, listener: () => void): Dispose {
218 const listeners = this.#doublePressListeners.get(key)
219 ?? new Set<() => void>();
220 listeners.add(listener);
221 this.#doublePressListeners.set(key, listeners);
222
223 return defer(() => {
224 const current = this.#doublePressListeners.get(key);
225 if (!current) return;
226 current.delete(listener);
227 if (current.size === 0) {
228 this.#doublePressListeners.delete(key);
229 }
230 });
231 }
232
233 close() {
234 if (this.#closed) return;
235 this.#closed = true;
236 this.#ready = false;
237 this.#refreshRequested = false;
238 this.#stopHotplugMonitoring?.();
239 this.#stopHotplugMonitoring = null;
240 this.#disconnect(false);
241 this.emit("close");
242 }
243
244 async #start() {
245 await this.#startHotplugMonitoring();
246 await this.#refreshConnection();
247 }
248
249 async #startHotplugMonitoring() {
250 const { usb } = await import("usb");
251 const onHotplug = (device: import("usb").Device) => {
252 if (!this.#matchesHotplugDevice(device)) {
253 return;
254 }
255 void this.#refreshConnection();
256 };
257
258 usb.on("attach", onHotplug);
259 usb.on("detach", onHotplug);
260 usb.unrefHotplugEvents();
261
262 this.#stopHotplugMonitoring = () => {
263 usb.off("attach", onHotplug);
264 usb.off("detach", onHotplug);
265 };
266 }
267
268 #matchesHotplugDevice(device: import("usb").Device) {
269 const descriptor = device.deviceDescriptor;
270 return descriptor.idVendor === this.#options.vendorId
271 && descriptor.idProduct === this.#options.productId;
272 }
273
274 async #refreshConnection() {
275 if (this.#closed) {
276 return;
277 }
278
279 this.#refreshRequested = true;
280 if (this.#refreshing) {
281 await this.#refreshing;
282 return;
283 }
284
285 this.#refreshing = (async () => {
286 while (this.#refreshRequested && !this.#closed) {
287 this.#refreshRequested = false;
288
289 try {
290 const devices = await SpeedEditor.listDevices({
291 vendorId: this.#options.vendorId,
292 productId: this.#options.productId,
293 });
294 await this.#reconcileConnection(devices);
295 } catch (error) {
296 this.#emitAsyncError(error);
297 }
298 }
299 })().finally(() => {
300 this.#refreshing = null;
301 });
302
303 await this.#refreshing;
304 }
305
306 async #reconcileConnection(devices: ReadonlyArray<SpeedEditor.DeviceInfo>) {
307 if (this.#closed) {
308 return;
309 }
310
311 const currentKey = getDeviceKey(this.#deviceInfo);
312 const currentStillPresent = currentKey !== null
313 && devices.some((device) => getDeviceKey(device) === currentKey);
314
315 if (this.#device && !currentStillPresent) {
316 this.#disconnect(true);
317 }
318
319 if (this.#device) {
320 return;
321 }
322
323 const nextDevice = this.#selectDevice(devices);
324 if (!nextDevice) {
325 return;
326 }
327
328 await this.#connect(nextDevice);
329 }
330
331 #selectDevice(devices: ReadonlyArray<SpeedEditor.DeviceInfo>) {
332 if (this.#options.path) {
333 return devices.find((device) => device.path === this.#options.path)
334 ?? null;
335 }
336
337 return devices[0] ?? null;
338 }
339
340 async #connect(
341 deviceInfo: SpeedEditor.DeviceInfo,
342 ): Promise<SpeedEditor.DeviceInfo | null> {
343 const { HID } = await import("node-hid");
344 let device: import("node-hid").HID | null = null;
345
346 try {
347 device = this.#options.path
348 ? new HID(this.#options.path, {
349 nonExclusive: this.#options.nonExclusive,
350 })
351 : new HID(deviceInfo.vendorId, deviceInfo.productId, {
352 nonExclusive: this.#options.nonExclusive,
353 });
354
355 this.#device = device;
356 this.#authenticate(device);
357 if (this.#closed || this.#device !== device) {
358 closeDeviceHandle(device);
359 return null;
360 }
361
362 device.on("data", (report) => {
363 if (this.#device === device) {
364 this.#handleReport(report);
365 }
366 });
367 device.on("error", (error) => {
368 if (this.#device === device) {
369 this.#handleDeviceError(error);
370 }
371 });
372
373 writeLedState(device, this.#leds);
374 writeJogLedState(device, this.#jogLeds);
375 writeJogMode(device, this.#jogMode);
376
377 const info = copyDeviceInfo(device.getDeviceInfo());
378 this.#deviceInfo = info;
379 this.#ready = true;
380 this.emit("connect", info);
381 return info;
382 } catch (error) {
383 if (this.#device === device) {
384 this.#disconnect(false);
385 } else if (device) {
386 closeDeviceHandle(device);
387 }
388
389 if (!isRecoverableDeviceError(error)) {
390 this.#emitAsyncError(error);
391 }
392 return null;
393 }
394 }
395
396 #handleReport(report: number[] | Buffer) {
397 const bytes = Uint8Array.from(report);
398 const reportId = bytes[0];
399
400 if (reportId === 0x03) {
401 const modeCode = bytes[1];
402 const mode = JOG_MODE_BY_CODE.get(modeCode);
403 if (!mode) return;
404 const rawValue = getInt32LE(bytes, 2);
405 const value = mode === "relative" ? rawValue / 360 : rawValue;
406 if (mode === "relative" && this.#jogMode === "relative-normalized") {
407 this.#handleNormalizedRelativeJog(value);
408 return;
409 }
410 this.emit("jog", { mode, value });
411 return;
412 }
413
414 if (reportId === 0x04) {
415 const keys: SpeedEditor.Key[] = [];
416 for (let index = 0; index < 6; index += 1) {
417 const code = getUint16LE(bytes, 1 + index * 2);
418 if (code === 0) {
419 continue;
420 }
421 const key = KEY_BY_CODE.get(code);
422 if (key) keys.push(key);
423 }
424 this.#applyKeyState(keys);
425 this.emit("key", keys);
426 return;
427 }
428
429 if (reportId === 0x07) {
430 const percent = bytes[2] ?? 0;
431 this.emit("battery", {
432 charging: (bytes[1] ?? 0) === 1,
433 level: Math.max(0, Math.min(percent, 100)) / 100,
434 percent,
435 });
436 return;
437 }
438 }
439
440 #handleDeviceError(error: unknown) {
441 this.#disconnect(true);
442 if (!isRecoverableDeviceError(error)) {
443 this.#emitAsyncError(error);
444 }
445 void this.#refreshConnection();
446 }
447
448 #emitAsyncError(error: unknown) {
449 queueMicrotask(() => {
450 this.emit("error", error);
451 });
452 }
453
454 #disconnect(emitEvent: boolean) {
455 const device = this.#device;
456 const info = this.#deviceInfo && { ...this.#deviceInfo };
457
458 this.#device = null;
459 this.#deviceInfo = null;
460 this.#ready = false;
461 if (device) {
462 closeDeviceHandle(device);
463 }
464 this.#resetNormalizedJogTracking();
465 this.#resetKeyTracking();
466
467 if (emitEvent && info) {
468 this.emit("disconnect", info);
469 }
470 }
471
472 #handleNormalizedRelativeJog(value: number) {
473 if (value === 0) {
474 return;
475 }
476
477 if (this.#normalizedJogActive) {
478 this.#scheduleNormalizedJogReset();
479 this.emit("jog", { mode: "relative-normalized", value });
480 return;
481 }
482
483 if (!this.#normalizedJogResetTimer) {
484 this.#scheduleNormalizedJogReset();
485 }
486 this.#normalizedJogBufferedValue += value;
487 this.#normalizedJogBufferedDistance += Math.abs(value);
488 if (this.#normalizedJogBufferedDistance < NORMALIZED_JOG_THRESHOLD) {
489 return;
490 }
491
492 this.#normalizedJogActive = true;
493 const bufferedValue = this.#normalizedJogBufferedValue;
494 this.#normalizedJogBufferedValue = 0;
495 this.#normalizedJogBufferedDistance = 0;
496 this.#scheduleNormalizedJogReset();
497 this.emit("jog", { mode: "relative-normalized", value: bufferedValue });
498 }
499
500 #scheduleNormalizedJogReset() {
501 if (this.#normalizedJogResetTimer) {
502 clearTimeout(this.#normalizedJogResetTimer);
503 }
504
505 this.#normalizedJogResetTimer = setTimeout(() => {
506 this.#normalizedJogResetTimer = null;
507 this.#normalizedJogActive = false;
508 this.#normalizedJogBufferedDistance = 0;
509 this.#normalizedJogBufferedValue = 0;
510 }, NORMALIZED_JOG_IDLE_RESET_MS);
511 }
512
513 #resetNormalizedJogTracking() {
514 if (this.#normalizedJogResetTimer) {
515 clearTimeout(this.#normalizedJogResetTimer);
516 this.#normalizedJogResetTimer = null;
517 }
518 this.#normalizedJogActive = false;
519 this.#normalizedJogBufferedDistance = 0;
520 this.#normalizedJogBufferedValue = 0;
521 }
522
523 #applyKeyState(keys: ReadonlyArray<SpeedEditor.Key>) {
524 const nextKeys = new Set(keys);
525 const releasedKeys: SpeedEditor.Key[] = [];
526 const pressedKeys: SpeedEditor.Key[] = [];
527
528 for (const key of this.#activeKeys) {
529 if (!nextKeys.has(key)) {
530 releasedKeys.push(key);
531 }
532 }
533
534 for (const key of nextKeys) {
535 if (!this.#activeKeys.has(key)) {
536 pressedKeys.push(key);
537 }
538 }
539
540 this.#activeKeys = nextKeys;
541
542 for (const key of releasedKeys) {
543 this.emit("keyup", key);
544 this.#handleKeyRelease(key);
545 }
546
547 for (const key of pressedKeys) {
548 this.emit("keydown", key);
549 this.#handleKeyPressStart(key);
550 }
551 }
552
553 #handleKeyPressStart(key: SpeedEditor.Key) {
554 if (!this.#isDoublePressTracked(key)) {
555 this.emit("keypress", key);
556 return;
557 }
558
559 const pendingKeypress = this.#pendingKeypressTimers.get(key);
560 if (!pendingKeypress) {
561 return;
562 }
563
564 clearTimeout(pendingKeypress);
565 this.#pendingKeypressTimers.delete(key);
566 this.#doublePressActiveKeys.add(key);
567
568 const listeners = this.#doublePressListeners.get(key);
569 if (!listeners) {
570 return;
571 }
572 for (const listener of listeners) {
573 listener();
574 }
575 }
576
577 #handleKeyRelease(key: SpeedEditor.Key) {
578 if (!this.#isDoublePressTracked(key)) {
579 return;
580 }
581
582 if (this.#doublePressActiveKeys.delete(key)) {
583 return;
584 }
585
586 const pendingKeypress = this.#pendingKeypressTimers.get(key);
587 if (pendingKeypress) {
588 clearTimeout(pendingKeypress);
589 }
590
591 this.#pendingKeypressTimers.set(
592 key,
593 setTimeout(() => {
594 this.#pendingKeypressTimers.delete(key);
595 this.emit("keypress", key);
596 }, DOUBLE_PRESS_WINDOW_MS),
597 );
598 }
599
600 #isDoublePressTracked(key: SpeedEditor.Key) {
601 return (this.#doublePressListeners.get(key)?.size ?? 0) > 0;
602 }
603
604 #resetKeyTracking() {
605 for (const timer of this.#pendingKeypressTimers.values()) {
606 clearTimeout(timer);
607 }
608 this.#pendingKeypressTimers.clear();
609 this.#doublePressActiveKeys.clear();
610 this.#activeKeys.clear();
611 }
612
613 static camNumbersToNumpad(
614 speededitor: Pick<SpeedEditor, "onPress">,
615 mac: Pick<Mac, "pressKey">,
616 ) {
617 speededitor.onPress("cam1", () => mac.pressKey("numpad1"));
618 speededitor.onPress("cam2", () => mac.pressKey("numpad2"));
619 speededitor.onPress("cam3", () => mac.pressKey("numpad3"));
620 speededitor.onPress("cam4", () => mac.pressKey("numpad4"));
621 speededitor.onPress("cam5", () => mac.pressKey("numpad5"));
622 speededitor.onPress("cam6", () => mac.pressKey("numpad6"));
623 speededitor.onPress("cam7", () => mac.pressKey("numpad7"));
624 speededitor.onPress("cam8", () => mac.pressKey("numpad8"));
625 speededitor.onPress("cam9", () => mac.pressKey("numpad9"));
626 speededitor.onPress("liveOverwrite", () => mac.pressKey("numpad0"));
627 }
628}
629
630export declare namespace SpeedEditor {
631 export type JogMode = typeof jogModes[number];
632 export type Key = typeof keyIds[number];
633 export type Led = typeof ledIds[number];
634 export type JogLed = typeof jogLeds[number];
635 export type JogKey = Extract<Key, JogLed>;
636 export type DeviceInfo = import("node-hid").Device;
637
638 export interface Options {
639 vendorId?: number;
640 productId?: number;
641 path?: string | null;
642 nonExclusive?: boolean;
643 }
644
645 export type EventMap = {
646 "close": [];
647 "connect": [deviceInfo: DeviceInfo];
648 "disconnect": [deviceInfo: DeviceInfo];
649 "error": [error: unknown];
650 "jog": [event: { mode: JogMode; value?: number }];
651 "key": [activeKeys: ReadonlyArray<Key>];
652 "keydown": [key: Key];
653 "keyup": [key: Key];
654 "keypress": [key: Key];
655 "battery": [event: {
656 charging: boolean;
657 /** Zero to one. */
658 level: number;
659 /** Zero to one hundred. */
660 percent: number;
661 }];
662 };
663}
664
665const UINT64_MASK = 0xffff_ffff_ffff_ffffn;
666const AUTH_MASK = 0xa79a63f585d37bf0n;
667const AUTH_EVEN_TABLE = [
668 0x3ae1206f97c10bc8n,
669 0x2a9ab32bebf244c6n,
670 0x20a6f8b8df9adf0an,
671 0xaf80ece52cfc1719n,
672 0xec2ee2f7414fd151n,
673 0xb055adfd73344a15n,
674 0xa63d2e3059001187n,
675 0x751bf623f42e0dden,
676] as const;
677const AUTH_ODD_TABLE = [
678 0x3e22b34f502e7fden,
679 0x24656b981875ab1cn,
680 0xa17f3456df7bf8c3n,
681 0x6df72e1941aef698n,
682 0x72226f011e66ab94n,
683 0x3831a3c606296b42n,
684 0xfd7ff81881332c89n,
685 0x61a3f6474ff236c6n,
686] as const;
687
688const KEY_BY_CODE = new Map<number, SpeedEditor.Key>([
689 [0x01, "smartInsert"],
690 [0x02, "append"],
691 [0x03, "rippleOverwrite"],
692 [0x04, "closeUp"],
693 [0x05, "placeOnTop"],
694 [0x06, "sourceOverwrite"],
695 [0x07, "in"],
696 [0x08, "out"],
697 [0x09, "trimIn"],
698 [0x0a, "trimOut"],
699 [0x0b, "roll"],
700 [0x0c, "slipSource"],
701 [0x0d, "slipDestination"],
702 [0x0e, "transitionDuration"],
703 [0x0f, "cut"],
704 [0x10, "dissolve"],
705 [0x11, "smoothCut"],
706 [0x1a, "source"],
707 [0x1b, "timeline"],
708 [0x1c, "shuttle"],
709 [0x1d, "jog"],
710 [0x1e, "scroll"],
711 [0x1f, "syncBin"],
712 [0x22, "transition"],
713 [0x25, "videoOnly"],
714 [0x26, "audioOnly"],
715 [0x2b, "rippleDelete"],
716 [0x2c, "audioLevel"],
717 [0x2d, "fullView"],
718 [0x2e, "snap"],
719 [0x2f, "split"],
720 [0x30, "liveOverwrite"],
721 [0x31, "escape"],
722 [0x33, "cam1"],
723 [0x34, "cam2"],
724 [0x35, "cam3"],
725 [0x36, "cam4"],
726 [0x37, "cam5"],
727 [0x38, "cam6"],
728 [0x39, "cam7"],
729 [0x3a, "cam8"],
730 [0x3b, "cam9"],
731 [0x3c, "stopPlay"],
732]);
733
734const LED_BIT_BY_LED = new Map<SpeedEditor.Led, number>([
735 ["closeUp", 1 << 0],
736 ["cut", 1 << 1],
737 ["dissolve", 1 << 2],
738 ["smoothCut", 1 << 3],
739 ["transition", 1 << 4],
740 ["snap", 1 << 5],
741 ["cam7", 1 << 6],
742 ["cam8", 1 << 7],
743 ["cam9", 1 << 8],
744 ["liveOverwrite", 1 << 9],
745 ["cam4", 1 << 10],
746 ["cam5", 1 << 11],
747 ["cam6", 1 << 12],
748 ["videoOnly", 1 << 13],
749 ["cam1", 1 << 14],
750 ["cam2", 1 << 15],
751 ["cam3", 1 << 16],
752 ["audioOnly", 1 << 17],
753]);
754
755const JOG_LED_BIT_BY_LED = new Map<SpeedEditor.JogLed, number>([
756 ["jog", 1 << 0],
757 ["shuttle", 1 << 1],
758 ["scroll", 1 << 2],
759]);
760
761const JOG_MODE_CODE_BY_MODE = new Map<SpeedEditor.JogMode, number>([
762 ["relative", 2],
763 ["relative-normalized", 2],
764 ["absolute-continuous", 1],
765 ["absolute-deadzero", 3],
766]);
767
768const JOG_MODE_BY_CODE = new Map<number, SpeedEditor.JogMode>([
769 [0, "relative"],
770 [1, "absolute-continuous"],
771 [2, "relative"],
772 [3, "absolute-deadzero"],
773]);
774
775const DOUBLE_PRESS_WINDOW_MS = 100;
776const NORMALIZED_JOG_THRESHOLD = 5;
777const NORMALIZED_JOG_IDLE_RESET_MS = 1_000;
778
779function authenticateDevice(device: import("node-hid").HID) {
780 sendFeatureReport(device, [
781 0x06,
782 0x00,
783 0x00,
784 0x00,
785 0x00,
786 0x00,
787 0x00,
788 0x00,
789 0x00,
790 0x00,
791 ]);
792
793 const challengeReport = getFeatureReport(device, 6, 10);
794 assertFeatureStage(challengeReport, 0x00, "get_kbd_challenge");
795 const challenge = getUint64LE(challengeReport, 2);
796
797 sendFeatureReport(device, [
798 0x06,
799 0x01,
800 0x00,
801 0x00,
802 0x00,
803 0x00,
804 0x00,
805 0x00,
806 0x00,
807 0x00,
808 ]);
809
810 const responseReport = getFeatureReport(device, 6, 10);
811 assertFeatureStage(responseReport, 0x02, "get_kbd_response");
812
813 sendFeatureReport(device, [
814 0x06,
815 0x03,
816 ...toLittleEndianBytes(bmdKeyboardAuth(challenge), 8),
817 ]);
818
819 const statusReport = getFeatureReport(device, 6, 10);
820 assertFeatureStage(statusReport, 0x04, "get_kbd_status");
821
822 return getUint16LE(statusReport, 2);
823}
824
825function sendFeatureReport(device: import("node-hid").HID, values: number[]) {
826 const written = device.sendFeatureReport(values);
827 if (written <= 0) {
828 throw new Error("Failed to send Speed Editor feature report");
829 }
830}
831
832function getFeatureReport(
833 device: import("node-hid").HID,
834 reportId: number,
835 length: number,
836) {
837 return Uint8Array.from(device.getFeatureReport(reportId, length));
838}
839
840function assertFeatureStage(report: Uint8Array, stage: number, name: string) {
841 if (report[0] !== 0x06 || report[1] !== stage) {
842 throw new Error(`Failed authentication ${name}`);
843 }
844}
845
846function writeLedState(
847 device: import("node-hid").HID,
848 leds: Iterable<SpeedEditor.Led>,
849) {
850 let bitfield = 0;
851 for (const led of leds) {
852 bitfield |= LED_BIT_BY_LED.get(led) ?? 0;
853 }
854 const bytes = [0x02, ...toLittleEndianBytes(BigInt(bitfield >>> 0), 4)];
855 device.write(bytes);
856}
857
858function writeJogLedState(
859 device: import("node-hid").HID,
860 leds: Iterable<SpeedEditor.JogLed>,
861) {
862 let bitfield = 0;
863 for (const led of leds) {
864 bitfield |= JOG_LED_BIT_BY_LED.get(led) ?? 0;
865 }
866 device.write([0x04, bitfield]);
867}
868
869function writeJogMode(
870 device: import("node-hid").HID,
871 mode: SpeedEditor.JogMode,
872) {
873 const code = JOG_MODE_CODE_BY_MODE.get(mode);
874 if (code === undefined) {
875 throw new Error(`Unsupported jog mode: ${mode}`);
876 }
877 device.write([0x03, code, 0x00, 0x00, 0x00, 0x00, 0xff]);
878}
879
880function copyDeviceInfo(
881 device: import("node-hid").Device,
882): SpeedEditor.DeviceInfo {
883 return { ...device };
884}
885
886function formatDeviceLogLabel(device: SpeedEditor.DeviceInfo) {
887 const name = device.product ?? device.manufacturer ?? "Speed Editor";
888 const details = [
889 device.serialNumber && `serial=${device.serialNumber}`,
890 device.path && `path=${device.path}`,
891 ].filter(Boolean);
892
893 if (details.length === 0) {
894 return name;
895 }
896
897 return `${name} (${details.join(", ")})`;
898}
899
900function closeDeviceHandle(device: import("node-hid").HID) {
901 device.removeAllListeners("data");
902 device.removeAllListeners("error");
903 try {
904 device.close();
905 } catch {
906 // Ignore close races when the device disappears while reconnecting.
907 }
908}
909
910function getDeviceKey(device: SpeedEditor.DeviceInfo | null) {
911 if (!device) {
912 return null;
913 }
914
915 return device.path
916 ?? [
917 device.vendorId,
918 device.productId,
919 device.serialNumber ?? "",
920 device.interface,
921 device.release,
922 device.usagePage ?? "",
923 device.usage ?? "",
924 ].join(":");
925}
926
927function isRecoverableDeviceError(error: unknown) {
928 if (!(error instanceof Error)) {
929 return false;
930 }
931
932 const message = error.message.toLowerCase();
933 return [
934 "cannot open device",
935 "cannot access closed device",
936 "cannot write to closed device",
937 "cannot write to hid device",
938 "could not read data from device",
939 "could not get feature report from device",
940 "could not send feature report to device",
941 "unable to get device info",
942 "device not found",
943 "no such device",
944 ].some((pattern) => message.includes(pattern));
945}
946
947function rol8(value: bigint) {
948 return ((value << 56n) | (value >> 8n)) & UINT64_MASK;
949}
950
951function rol8n(value: bigint, count: bigint) {
952 let next = value;
953 for (let index = 0n; index < count; index += 1n) {
954 next = rol8(next);
955 }
956 return next;
957}
958
959function bmdKeyboardAuth(challenge: bigint) {
960 const index = Number(challenge & 0x7n);
961 let value = rol8n(challenge, BigInt(index));
962
963 if ((value & 0x1n) === BigInt((0x78 >> index) & 0x1)) {
964 return value ^ (rol8(value) & AUTH_MASK) ^ AUTH_EVEN_TABLE[index];
965 }
966
967 value = value ^ rol8(value);
968 return value ^ (rol8(value) & AUTH_MASK) ^ AUTH_ODD_TABLE[index];
969}
970
971function getUint16LE(bytes: Uint8Array, offset: number) {
972 return (bytes[offset] ?? 0) | ((bytes[offset + 1] ?? 0) << 8);
973}
974
975function getInt32LE(bytes: Uint8Array, offset: number) {
976 return new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength)
977 .getInt32(offset, true);
978}
979
980function getUint64LE(bytes: Uint8Array, offset: number) {
981 let value = 0n;
982 for (let index = 0; index < 8; index += 1) {
983 value |= BigInt(bytes[offset + index] ?? 0) << (BigInt(index) * 8n);
984 }
985 return value;
986}
987
988function toLittleEndianBytes(value: bigint, width: number) {
989 const bytes: number[] = [];
990 for (let index = 0; index < width; index += 1) {
991 bytes.push(Number((value >> (BigInt(index) * 8n)) & 0xffn));
992 }
993 return bytes;
994}
src/config.ts deleted-224
...@@ -1,224 +0,0 @@
1import { Events } from "@clo/lib/Events.ts";
2import * as console from "@clo/lib/log.ts";
3import type { Dispose } from "@clo/lib/ts.ts";
4import * as fs from "node:fs/promises";
5import * as path from "node:path";
6import * as url from "node:url";
7import { Dialpad } from "./Dialpad.ts";
8import { Keypad } from "./Keypad.ts";
9import { KeypadSurface } from "./KeypadUI.ts";
10import { Mac } from "./Mac.ts";
11
12export interface Configure {
13 /** 9 LCD keys + back/forward. Events/screens are only active when the app is focused. */
14 keypad: KeypadSurface;
15 /** One dial, one knob, four buttons. Events are only active when the app is focused. */
16 dialpad: AppDialpad;
17 /** Not intended to listen for events. */
18 mac: Mac;
19 /** Control the application itself */
20 app: Events<AppEvents>;
21}
22
23export interface AppConfig {
24 bundle: Mac.BundleId;
25 configure: (x: Configure) => void;
26}
27
28interface AppInstance {
29 bundle: Mac.BundleId;
30 keypad: KeypadSurface;
31 dialpad: AppDialpad;
32 app: Events<AppEvents>;
33}
34
35interface AppDefinition {
36 bundle: Mac.BundleId;
37 load: () => Promise<AppConfig>;
38}
39
40export type AppEvents = {
41 "focus": [];
42 "blur": [];
43};
44
45export function forApp(
46 bundle: Mac.BundleId,
47 configure: AppConfig["configure"],
48): AppConfig {
49 return { bundle, configure };
50}
51
52/** Per-app proxy that only forwards Dialpad events while its app is focused. */
53export class AppDialpad extends Events<Dialpad.EventMap> {
54 #source: Dialpad;
55 #enabled = false;
56
57 constructor(source: Dialpad) {
58 super();
59 this.#source = source;
60 source.onAny((event, args) => {
61 if (this.#enabled) {
62 this.emit(
63 event,
64 ...args as Dialpad.EventMap[keyof Dialpad.EventMap],
65 );
66 }
67 });
68 }
69
70 get enabled() {
71 return this.#enabled;
72 }
73
74 set enabled(enabled: boolean) {
75 this.#enabled = enabled;
76 }
77
78 onPress(button: Dialpad.Button, listener: () => void): Dispose {
79 return this.on("keypress", (code) => {
80 if (button === code) listener();
81 });
82 }
83}
84
85async function loadConfigs(configDir: string) {
86 const entries = await fs.readdir(configDir, { withFileTypes: true });
87 const modules = entries
88 .filter((entry) => entry.isFile())
89 .filter((entry) => path.extname(entry.name) === ".ts")
90 .sort((left, right) => left.name.localeCompare(right.name));
91
92 const definitions: AppDefinition[] = [];
93
94 for (const entry of modules) {
95 const filePath = path.join(configDir, entry.name);
96 const moduleUrl = url.pathToFileURL(filePath).href;
97 let configPromise: Promise<AppConfig> | null = null;
98 const load = () => {
99 configPromise ??= loadConfig(moduleUrl);
100 return configPromise;
101 };
102
103 const source = await fs.readFile(filePath, "utf8");
104 const bundle = readBundleId(source) ?? (await load()).bundle;
105 definitions.push({ bundle, load });
106 }
107
108 return definitions;
109}
110
111export async function runConfigs(dir: string) {
112 const definitions = await loadConfigs(dir);
113
114 const [mac, keypad, dialpad] = await Promise.all([
115 Mac.open(),
116 Keypad.open(),
117 Dialpad.open(),
118 ]);
119
120 if (!keypad.connected) {
121 console.warn("MX Creative Keypad is not connected (connect it over USB-C)");
122 }
123 keypad.on("connect", () => console.info("Keypad connected"));
124 keypad.on("disconnect", () => console.warn("Keypad lost connection"));
125 if (!dialpad.connected) {
126 console.warn("MX Creative Dialpad is not connected");
127 }
128 dialpad.on("connect", () => console.info("Dialpad connected"));
129 dialpad.on("disconnect", () => console.warn("Dialpad lost connection"));
130
131 const instances = new Map<Mac.BundleId, AppInstance>();
132 const pendingInstances = new Map<Mac.BundleId, Promise<AppInstance | null>>();
133 let currentInstance: AppInstance | null = null;
134 let switchVersion = 0;
135
136 async function getInstance(
137 bundle: Mac.BundleId,
138 ): Promise<AppInstance | null> {
139 const existing = instances.get(bundle);
140 if (existing) return existing;
141
142 const pending = pendingInstances.get(bundle);
143 if (pending) return pending;
144
145 const definition = definitions.find((x) => x.bundle === bundle);
146 if (!definition) return null;
147
148 const next = definition.load().then((config) => {
149 if (config.bundle !== bundle) {
150 throw new Error(
151 `Config bundle mismatch: expected "${bundle}", got "${config.bundle}"`,
152 );
153 }
154
155 const instance: AppInstance = {
156 bundle,
157 keypad: new KeypadSurface(keypad),
158 dialpad: new AppDialpad(dialpad),
159 app: new Events<AppEvents>(),
160 };
161 config.configure({
162 keypad: instance.keypad,
163 dialpad: instance.dialpad,
164 mac,
165 app: instance.app,
166 });
167
168 instances.set(bundle, instance);
169 return instance;
170 }).finally(() => {
171 pendingInstances.delete(bundle);
172 });
173
174 pendingInstances.set(bundle, next);
175 return next;
176 }
177
178 async function switchApp(bundle: Mac.BundleId) {
179 const version = ++switchVersion;
180
181 if (currentInstance) {
182 currentInstance.keypad.setActive(false);
183 currentInstance.dialpad.enabled = false;
184 currentInstance.app.emit("blur");
185 currentInstance = null;
186 }
187
188 const nextInstance = await getInstance(bundle);
189 if (version !== switchVersion) return;
190
191 currentInstance = nextInstance;
192
193 if (currentInstance) {
194 currentInstance.keypad.setActive(true);
195 currentInstance.dialpad.enabled = true;
196 currentInstance.app.emit("focus");
197 } else {
198 keypad.reset();
199 }
200 }
201
202 mac.on("app-change", (id) => {
203 void switchApp(id).catch((error) => {
204 queueMicrotask(() => {
205 throw error;
206 });
207 });
208 });
209 if (mac.currentApp) await switchApp(mac.currentApp);
210}
211
212async function loadConfig(moduleUrl: string): Promise<AppConfig> {
213 const imported = await import(moduleUrl);
214 const config = imported.default as AppConfig | undefined;
215 if (!config) {
216 throw new Error(`Config module ${moduleUrl} has no default export`);
217 }
218 return config;
219}
220
221function readBundleId(source: string): Mac.BundleId | null {
222 const match = source.match(/\bforApp\(\s*(["'`])([^"'`]+)\1/);
223 return (match?.[2] as Mac.BundleId | undefined) ?? null;
224}
src/icons.ts deleted-387
...@@ -1,387 +0,0 @@
1// Build-free key "faces" for the MX Creative Keypad: a fluent icon builder that
2// renders to a key-sized SVG document. Three sources — Lucide (stroke icons,
3// typed from `lucide-static`), Material Design Icons (filled icons, typed from
4// `mdi-ts`), and plain text — with chainable `.bg()`/`.fg()`/`.size()`.
5//
6// lucide("Play") // a white play glyph on the default bg
7// mdi("metronome").fg("red") // an MDI metronome, tinted red
8// txt("BPM").bg("#101010") // a centered text label
9//
10// The result's `.svg` is what KeypadUI rasterizes to JPEG. Everything here is
11// pure string building — no build step, no runtime SVG parsing beyond a trim.
12import type { MdiIcon } from "mdi-ts";
13import * as lucideIcons from "lucide-static";
14import { readFileSync } from "node:fs";
15import { createRequire } from "node:module";
16import { dirname, join } from "node:path";
17
18/** The pixel size of a single key face (square) — the SVG viewBox. */
19const KEY_SIZE = 118;
20
21const DEFAULT_BG = "#1b1b1b";
22const DEFAULT_FG = "#ffffff";
23const DEFAULT_ICON_SIZE = 56;
24const DEFAULT_TEXT_SIZE = 34;
25
26// The "unassigned" face: a near-black key with a small grey dot.
27const BLANK_BG = "#141414";
28const BLANK_DOT = "#555";
29
30/** Named palette. Extend freely — any unknown name falls through as a raw color. */
31const colorMap = {
32 black: "#000000",
33 white: "#ffffff",
34 grey: "#8a8a8a",
35 gray: "#8a8a8a",
36 red: "#ff5a36",
37 orange: "#ff9f43",
38 amber: "#ffbf47",
39 yellow: "#ffd23f",
40 lime: "#b6f36b",
41 green: "#7cfc9b",
42 teal: "#2dd4bf",
43 cyan: "#3ad6e8",
44 blue: "#4aa8ff",
45 indigo: "#6c7bff",
46 violet: "#9b6bff",
47 purple: "#b47cff",
48 magenta: "#ff5ccd",
49 pink: "#ff6bd6",
50} as const;
51
52/** Hex string, a name from the palette, or any other CSS color. */
53export type Color = `#${string}` | keyof typeof colorMap | (string & {});
54
55function resolveColor(color: Color): string {
56 return (colorMap as Record<string, string>)[color] ?? color;
57}
58
59/** Anything with SVG markup for a single key. */
60export interface Svg {
61 readonly svg: string;
62}
63
64/** Lucide icon names (PascalCase), typed from `lucide-static`. */
65export type LucideIconName = keyof typeof lucideIcons;
66
67/** MDI icon names (kebab-case, without the `mdi-` prefix), typed from `mdi-ts`. */
68export type MdiIconName = `${MdiIcon}` extends `mdi-${infer Name}` ? Name : never;
69
70/** One line of a {@link stack}: bare text, or text with its own size/color. */
71export type StackLine =
72 | string
73 | number
74 | { text: string | number; size?: number; color?: Color };
75
76interface StackSpec {
77 text: string;
78 size?: number;
79 color?: Color;
80}
81
82type IconSource =
83 | { readonly kind: "lucide"; readonly name: string }
84 | { readonly kind: "mdi"; readonly name: string }
85 | { readonly kind: "text"; readonly text: string }
86 | { readonly kind: "stack"; readonly lines: readonly StackSpec[] }
87 | { readonly kind: "timesig"; readonly numerator: string; readonly denominator: string }
88 | { readonly kind: "blank" };
89
90/**
91 * An immutable, lazily-rendered key face. `.bg()`/`.fg()`/`.size()` each return
92 * a new `Icon`, so definitions compose without mutating shared instances.
93 */
94export class Icon implements Svg {
95 readonly #source: IconSource;
96 readonly #bg: Color;
97 readonly #fg: Color;
98 readonly #size: number | null;
99 #rendered: string | null = null;
100
101 constructor(
102 source: IconSource,
103 bg: Color = DEFAULT_BG,
104 fg: Color = DEFAULT_FG,
105 size: number | null = null,
106 ) {
107 this.#source = source;
108 this.#bg = bg;
109 this.#fg = fg;
110 this.#size = size;
111 }
112
113 /** A copy with a different background color. */
114 bg(color: Color): Icon {
115 return new Icon(this.#source, color, this.#fg, this.#size);
116 }
117
118 /** A copy with a different foreground (stroke/fill/text) color. */
119 fg(color: Color): Icon {
120 return new Icon(this.#source, this.#bg, color, this.#size);
121 }
122
123 /** A copy with a different glyph size in key pixels (icon or text height). */
124 size(px: number): Icon {
125 return new Icon(this.#source, this.#bg, this.#fg, px);
126 }
127
128 /** The full key-sized SVG document. Rendered once, then cached. */
129 get svg(): string {
130 return this.#rendered ??= wrapSvg(this.#inner(), resolveColor(this.#bg));
131 }
132
133 #inner(): string {
134 const fg = resolveColor(this.#fg);
135 switch (this.#source.kind) {
136 case "lucide":
137 return glyph(lucideInner(this.#source.name), this.#glyphSize(), {
138 fill: "none",
139 stroke: fg,
140 extra:
141 `stroke-width="2" stroke-linecap="round" stroke-linejoin="round"`,
142 });
143 case "mdi":
144 return glyph(mdiInner(this.#source.name), this.#glyphSize(), {
145 fill: fg,
146 stroke: "none",
147 });
148 case "text":
149 return text(this.#source.text, fg, this.#size ?? DEFAULT_TEXT_SIZE);
150 case "stack":
151 return stackInner(this.#source.lines, fg);
152 case "timesig":
153 return timeSignatureInner(
154 this.#source.numerator,
155 this.#source.denominator,
156 fg,
157 );
158 case "blank":
159 return `<circle cx="${KEY_SIZE / 2}" cy="${KEY_SIZE / 2}" r="7" ` +
160 `fill="${BLANK_DOT}"/>`;
161 }
162 }
163
164 #glyphSize(): number {
165 return this.#size ?? DEFAULT_ICON_SIZE;
166 }
167}
168
169/** A Lucide (stroke) icon. `key` is the PascalCase name, e.g. `"AlarmClock"`. */
170export function lucide(key: LucideIconName): Icon {
171 return new Icon({ kind: "lucide", name: key });
172}
173
174/** A Material Design (filled) icon. `key` is the kebab name, e.g. `"metronome"`. */
175export function mdi(key: MdiIconName): Icon {
176 return new Icon({ kind: "mdi", name: key });
177}
178
179/** A short, centered text label. */
180export function txt(label: string): Icon {
181 return new Icon({ kind: "text", text: label });
182}
183
184/**
185 * A vertical stack of text lines, e.g. `stack(130, "BPM")` — the first line is
186 * emphasized (larger), the rest are secondary. Pass `{ text, size, color }` to
187 * override a line. Great for live readouts: `() => stack(bpm(), "BPM")`.
188 */
189export function stack(...lines: StackLine[]): Icon {
190 return new Icon({ kind: "stack", lines: lines.map(normalizeStackLine) });
191}
192
193function normalizeStackLine(line: StackLine): StackSpec {
194 if (typeof line === "string" || typeof line === "number") {
195 return { text: String(line) };
196 }
197 return { text: String(line.text), size: line.size, color: line.color };
198}
199
200/**
201 * A musical time signature: serif numerals stacked over a set of staff lines.
202 * `timeSignature("4/4")` or `timeSignature(6, 8)`.
203 */
204export function timeSignature(signature: string): Icon;
205export function timeSignature(
206 numerator: number | string,
207 denominator: number | string,
208): Icon;
209export function timeSignature(
210 a: number | string,
211 b?: number | string,
212): Icon {
213 let numerator: string;
214 let denominator: string;
215 if (b === undefined) {
216 const [top, bottom] = String(a).split("/");
217 numerator = (top ?? "4").trim();
218 denominator = (bottom ?? "4").trim();
219 } else {
220 numerator = String(a);
221 denominator = String(b);
222 }
223 return new Icon({ kind: "timesig", numerator, denominator });
224}
225
226/** The "unassigned" face: a near-black key with a small grey dot. */
227export const blank: Icon = new Icon({ kind: "blank" }, BLANK_BG);
228
229// ---------------------------------------------------------------------------
230// SVG building — icons live in a 24x24 viewBox; scale + center them in the key.
231// ---------------------------------------------------------------------------
232
233/** Wrap inner markup in a full key-sized document with a solid background. */
234function wrapSvg(inner: string, bg: string): string {
235 return `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 ${KEY_SIZE} ${KEY_SIZE}">` +
236 `<rect width="${KEY_SIZE}" height="${KEY_SIZE}" fill="${bg}"/>${inner}</svg>`;
237}
238
239interface GlyphStyle {
240 fill: string;
241 stroke: string;
242 extra?: string;
243}
244
245/** Place a 24x24 glyph, scaled to `size` and centered, with the given paint. */
246function glyph(inner: string, size: number, style: GlyphStyle): string {
247 const offset = (KEY_SIZE - size) / 2;
248 const scale = size / 24;
249 const extra = style.extra ? ` ${style.extra}` : "";
250 return `<g transform="translate(${offset} ${offset}) scale(${scale})" ` +
251 `fill="${style.fill}" stroke="${style.stroke}"${extra}>${inner}</g>`;
252}
253
254const SANS_FONT = "Helvetica, Arial, sans-serif";
255const SERIF_FONT = "Georgia, 'Times New Roman', Times, serif";
256
257interface TextStyle {
258 family?: string;
259 weight?: number;
260}
261
262/** A single centered `<text>` at (x, y), sized in key pixels. */
263function textAt(
264 x: number,
265 y: number,
266 label: string,
267 fg: string,
268 size: number,
269 style: TextStyle = {},
270): string {
271 const family = style.family ?? SANS_FONT;
272 const weight = style.weight ?? 600;
273 return `<text x="${x}" y="${y}" fill="${fg}" font-family="${family}" ` +
274 `font-size="${size}" font-weight="${weight}" text-anchor="middle" ` +
275 `dominant-baseline="central">${escapeXml(label)}</text>`;
276}
277
278function text(label: string, fg: string, size: number): string {
279 return textAt(KEY_SIZE / 2, KEY_SIZE / 2, label, fg, size);
280}
281
282const STACK_PRIMARY = 42;
283const STACK_SECONDARY = 22;
284const STACK_GAP = 6;
285
286/** Lay text lines out vertically, centered as a group. */
287function stackInner(lines: readonly StackSpec[], fg: string): string {
288 const sizes = lines.map((line, index) =>
289 line.size ?? (index === 0 ? STACK_PRIMARY : STACK_SECONDARY)
290 );
291 const height = sizes.reduce((sum, size) => sum + size, 0) +
292 STACK_GAP * Math.max(0, lines.length - 1);
293 let top = (KEY_SIZE - height) / 2;
294
295 return lines
296 .map((line, index) => {
297 const size = sizes[index];
298 const centerY = top + size / 2;
299 top += size + STACK_GAP;
300 const color = line.color ? resolveColor(line.color) : fg;
301 return textAt(KEY_SIZE / 2, centerY, line.text, color, size);
302 })
303 .join("");
304}
305
306// Four horizontal staff lines with the serif numerals stacked across them —
307// numerator in the upper half, denominator in the lower, like real sheet music.
308const STAFF_LINES = 4;
309const STAFF_GAP = 12;
310const STAFF_INSET = 16;
311const TIMESIG_GLYPH = 40;
312
313function timeSignatureInner(
314 numerator: string,
315 denominator: string,
316 fg: string,
317): string {
318 const span = STAFF_GAP * (STAFF_LINES - 1);
319 const top = (KEY_SIZE - span) / 2;
320 let staff = "";
321 for (let i = 0; i < STAFF_LINES; i += 1) {
322 const y = top + i * STAFF_GAP;
323 staff += `<line x1="${STAFF_INSET}" y1="${y}" x2="${KEY_SIZE - STAFF_INSET}" ` +
324 `y2="${y}" stroke="${fg}" stroke-width="1.5" opacity="0.4"/>`;
325 }
326 const style: TextStyle = { family: SERIF_FONT, weight: 700 };
327 const numeral = TIMESIG_GLYPH;
328 const glyphs =
329 textAt(KEY_SIZE / 2, KEY_SIZE / 2 - 16, numerator, fg, numeral, style) +
330 textAt(KEY_SIZE / 2, KEY_SIZE / 2 + 16, denominator, fg, numeral, style);
331 return staff + glyphs;
332}
333
334function escapeXml(value: string): string {
335 return value.replace(/[<>&"']/g, (char) =>
336 char === "<"
337 ? "&lt;"
338 : char === ">"
339 ? "&gt;"
340 : char === "&"
341 ? "&amp;"
342 : char === '"'
343 ? "&quot;"
344 : "&apos;");
345}
346
347/** Strip the outer `<svg>` wrapper (and any leading comment) from icon markup. */
348function extractInner(markup: string): string {
349 return markup
350 .replace(/^[\s\S]*?<svg[^>]*>/, "")
351 .replace(/<\/svg>[\s\S]*$/, "")
352 .trim();
353}
354
355const require = createRequire(import.meta.url);
356const MDI_DIR = join(dirname(require.resolve("@mdi/svg/package.json")), "svg");
357
358const lucideCache = new Map<string, string>();
359const mdiCache = new Map<string, string>();
360
361function lucideInner(name: string): string {
362 let inner = lucideCache.get(name);
363 if (inner === undefined) {
364 const raw = (lucideIcons as Record<string, unknown>)[name];
365 if (typeof raw !== "string") {
366 throw new Error(`Unknown Lucide icon: "${name}"`);
367 }
368 inner = extractInner(raw);
369 lucideCache.set(name, inner);
370 }
371 return inner;
372}
373
374function mdiInner(name: string): string {
375 let inner = mdiCache.get(name);
376 if (inner === undefined) {
377 let raw: string;
378 try {
379 raw = readFileSync(join(MDI_DIR, `${name}.svg`), "utf8");
380 } catch {
381 throw new Error(`Unknown MDI icon: "${name}"`);
382 }
383 inner = extractInner(raw);
384 mdiCache.set(name, inner);
385 }
386 return inner;
387}
src/main.ts deleted-3
...@@ -1,3 +0,0 @@
1import { runConfigs } from "./config.ts";
2
3await runConfigs("config");
src/signals.ts deleted-104
...@@ -1,104 +0,0 @@
1// Minimal fine-grained reactive signals — no dependencies. A `signal` holds a
2// value; reading it inside a tracking scope (an `effect` or `computed`)
3// subscribes that scope, and `set` re-runs the scopes that read it. Just enough
4// reactivity to let keypad faces re-render themselves when their data changes,
5// keeping app configs declarative:
6//
7// const bpm = signal(120);
8// reaper.on("transport", (t) => bpm.set(Math.round(t.tempo)));
9// keypad.key("up-right", () => stack(bpm(), "BPM"), increaseTempo);
10//
11// Reads during a scope are tracked as dependencies and cleared on each re-run,
12// so conditional reads don't leave stale subscriptions behind.
13
14export type Cleanup = () => void;
15
16interface Reaction {
17 run: () => void;
18 deps: Set<Set<Reaction>>;
19}
20
21let activeReaction: Reaction | null = null;
22
23export interface ReadonlySignal<T> {
24 (): T;
25 /** Read without subscribing the current scope. */
26 peek(): T;
27}
28
29export interface Signal<T> extends ReadonlySignal<T> {
30 set(value: T): void;
31 update(fn: (previous: T) => T): void;
32}
33
34/** A reactive value. Call it to read (and subscribe); `.set()` to write. */
35export function signal<T>(initial: T): Signal<T> {
36 let value = initial;
37 const subscribers = new Set<Reaction>();
38
39 const read = (() => {
40 if (activeReaction) {
41 subscribers.add(activeReaction);
42 activeReaction.deps.add(subscribers);
43 }
44 return value;
45 }) as Signal<T>;
46
47 read.peek = () => value;
48 read.set = (next: T) => {
49 if (Object.is(next, value)) return;
50 value = next;
51 // Copy first: a reaction may resubscribe (or unsubscribe) while running.
52 for (const reaction of [...subscribers]) reaction.run();
53 };
54 read.update = (fn) => read.set(fn(value));
55
56 return read;
57}
58
59function runReaction(reaction: Reaction, body: () => void) {
60 // Drop the previous run's subscriptions so stale dependencies don't linger.
61 for (const dep of reaction.deps) dep.delete(reaction);
62 reaction.deps.clear();
63 const previous = activeReaction;
64 activeReaction = reaction;
65 try {
66 body();
67 } finally {
68 activeReaction = previous;
69 }
70}
71
72/** Run `fn` now, and again whenever a signal it read changes. Returns a disposer. */
73export function effect(fn: () => void): Cleanup {
74 const reaction: Reaction = {
75 run: () => runReaction(reaction, fn),
76 deps: new Set(),
77 };
78 reaction.run();
79 return () => {
80 for (const dep of reaction.deps) dep.delete(reaction);
81 reaction.deps.clear();
82 };
83}
84
85/** A memoized derived value that recomputes when its dependencies change. */
86export function computed<T>(compute: () => T): ReadonlySignal<T> {
87 const holder = signal<T>(undefined as T);
88 let started = false;
89 const reaction: Reaction = {
90 run: () => runReaction(reaction, () => holder.set(compute())),
91 deps: new Set(),
92 };
93
94 const read = (() => {
95 if (!started) {
96 started = true;
97 reaction.run();
98 }
99 return holder();
100 }) as ReadonlySignal<T>;
101 read.peek = () => holder.peek();
102
103 return read;
104}