diff --git a/.gitignore b/.gitignore index d948d39f1ce887b5fbf07128678840a352a27e15..ceb1bcdef8506f59f3022fba0a3fe4ca645eaef9 100644 --- a/.gitignore +++ b/.gitignore @@ -1,2 +1,5 @@ node_modules .build +dist +*.app +.DS_Store diff --git a/bin/exr_flip_z.py b/bin/exr_flip_z.py deleted file mode 100755 index 497f0375ec70c8ef2158bbbc468fc02fe8d5f64f..0000000000000000000000000000000000000000 --- a/bin/exr_flip_z.py +++ /dev/null @@ -1,66 +0,0 @@ -# Fusion uses negative Z values for the depth buffer, while Blender denotes -# this with positive values. For "Depth Merge" and other nodes to work -# correctly, Blender's output must be flipped. I am unaware of how to do -# this in Blender itself, hence this simple post processor. -import OpenEXR -import Imath -import numpy as np -import argparse -import os - -Z_FLIPPED_METADATA_KEY = "zBufferFlipped" - -def invert_z_buffer(exr_input_path): - exr_file = OpenEXR.InputFile(exr_input_path) - - header = exr_file.header() - channels = header['channels']; - part_names = channels.keys() - - if Z_FLIPPED_METADATA_KEY in header: - print(f"Skipping {exr_input_path}") - exr_file.close() - return - - processed_parts = {} - - for part_name in part_names: - pixel_type = header['channels'][part_name].type - if pixel_type == Imath.PixelType(Imath.PixelType.HALF): - dtype = np.float16 - elif pixel_type == Imath.PixelType(Imath.PixelType.FLOAT): - dtype = np.float32 - else: - raise ValueError(f"Unsupported pixel type {pixel_type} for channel {part_name}.") - - channel_data = exr_file.channel(part_name, pixel_type) - channel_data_array = np.frombuffer(channel_data, dtype=dtype) - - if "Depth.Z" in part_name: - channel_data_array = -channel_data_array - - processed_parts[part_name] = channel_data_array.tobytes() - - header[Z_FLIPPED_METADATA_KEY] = 1 - - exr_output = OpenEXR.OutputFile(exr_input_path, header) - - exr_output.writePixels(processed_parts) - - exr_file.close() - exr_output.close() - - print(f"Processed: {exr_input_path}") - -if __name__ == "__main__": - parser = argparse.ArgumentParser(description="Invert the Z-buffer in multipart EXR files.") - - parser.add_argument('exr_files', nargs='+', help="List of EXR files to process.") - - args = parser.parse_args() - - for exr_file in args.exr_files: - if os.path.exists(exr_file): - invert_z_buffer(exr_file) - else: - print(f"File not found: {exr_file}") diff --git a/bin/import_quicktime_to_fusion.py b/bin/import_quicktime_to_fusion.py deleted file mode 100755 index 11c4b37b72b0a97f38d7a4a97b1807bba922a10f..0000000000000000000000000000000000000000 --- a/bin/import_quicktime_to_fusion.py +++ /dev/null @@ -1,142 +0,0 @@ -#!/usr/bin/env python3 -import subprocess -import os -import sys -import re -import glob -import pyperclip -import time - -def run_command(cmd, shell=False): - """Run a command and return its output, exit on failure""" - try: - if shell: - result = subprocess.run(cmd, shell=True, check=True, text=True, capture_output=True) - else: - result = subprocess.run(cmd, check=True, text=True, capture_output=True) - return result.stdout.strip() - except subprocess.CalledProcessError as e: - print(f"Error executing command: {cmd}") - print(f"Error message: {e.stderr}") - sys.exit(1) - except Exception as e: - print(f"Unexpected error running command: {e}") - sys.exit(1) - -def find_file(base_name): - """Find a file with the given base name in the specified directory structure""" - search_path = "/Volumes/Project/*/Film/**/*" - - try: - # Expand the glob pattern to find all matching files - matching_files = [] - for project_dir in glob.glob("/Volumes/Project/*/"): - for root, dirs, files in os.walk(os.path.join(project_dir, "Film")): - # Skip hidden directories - dirs[:] = [d for d in dirs if not d.startswith('.')] - for file in files: - if file == base_name and not file.startswith('.'): - matching_files.append(os.path.join(root, file)) - - if not matching_files: - print(f"Error: Could not find file '{base_name}' in {search_path}") - sys.exit(1) - elif len(matching_files) > 1: - print(f"Warning: Found multiple matches for '{base_name}'. Using the first one.") - - return matching_files[0] - except Exception as e: - print(f"Error searching for file: {e}") - sys.exit(1) - -def activate_app(app_name): - """Activate an application by name""" - try: - script = f'tell application "{app_name}" to activate' - subprocess.run(["osascript", "-e", script], check=True) - except Exception as e: - print(f"Error activating {app_name}: {e}") - sys.exit(1) - -def main(): - # Step 1: Run Apple Script to get frame and name from QuickTime Player - print("Step 1: Getting frame and name from QuickTime Player...") - applescript = ''' - tell application "QuickTime Player" to tell document 1 - set t to current time - step forward - set k to current time - set r to 1 / (k - t) - step backward - return "" & (round (r * t) rounding down) & ":" & name - end tell - ''' - - try: - result = subprocess.run(["osascript", "-e", applescript], - check=True, text=True, capture_output=True) - frame_and_name = result.stdout.strip() - - if not frame_and_name or ":" not in frame_and_name: - print("Error: AppleScript did not return expected output") - sys.exit(1) - - target_frame, name = frame_and_name.split(":", 1) - target_frame = int(target_frame) - - print(f"Target frame: {target_frame}") - print(f"File name: {name}") - except Exception as e: - print(f"Error running AppleScript: {e}") - sys.exit(1) - - # Step 2: Find the file on disk - print("\nStep 2: Finding file on disk...") - file_path = find_file(name) - print(f"Found file at: {file_path}") - - # Step 3: Run Fusion script to get current frame - print("\nStep 3: Getting current frame from Fusion...") - fusion_script_cmd = "'/Applications/Blackmagic Fusion 19/Fusion.app/Contents/Libraries/fuscript' -x 'print(\"[[\"..Fusion().CurrentComp.CurrentTime..\"]]\")'" - fusion_output = run_command(fusion_script_cmd, shell=True) - - # Extract the frame number from the output - match = re.search(r'\[\[(\d+)\]\]', fusion_output) - if not match: - print(f"Error: Could not parse frame number from Fusion output: {fusion_output}") - sys.exit(1) - - current_frame = int(match.group(1)) - print(f"Current frame: {current_frame}") - - # Step 4: Compute TRIM_IN and EXTEND_FIRST - print("\nStep 4: Computing TRIM_IN and EXTEND_FIRST...") - trim_in = 0 - extend_first = 0 - - if target_frame > current_frame: - trim_in = target_frame - current_frame - print(f"Target frame is AFTER current frame. Setting TRIM_IN to {trim_in}") - else: - extend_first = current_frame - target_frame - print(f"Target frame is BEFORE current frame. Setting EXTEND_FIRST to {extend_first}") - - # Step 5: Create the Fusion loader text and copy to clipboard - print("\nStep 5: Creating Fusion loader text and copying to clipboard...") - 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"}}''' - - try: - pyperclip.copy(fusion_text) - print("Text copied to clipboard:") - print(fusion_text) - except Exception as e: - print(f"Error copying to clipboard: {e}") - sys.exit(1) - - # Finally, activate Fusion but don't paste - print("\nActivating Fusion...") - activate_app("Fusion") - print("Script completed successfully!") - -if __name__ == "__main__": - main() diff --git a/config/reaper.ts b/config/reaper.ts deleted file mode 100644 index 10b7fb2a968af33542b35aa825cedbdc691f979e..0000000000000000000000000000000000000000 --- a/config/reaper.ts +++ /dev/null @@ -1,71 +0,0 @@ -import * as config from "#config"; -import { lucide, mdi, txt } from "@clo/creative-control/icons"; -import { Reaper } from "@clo/creative-control/Reaper"; - -export default config.forApp( - "com.cockos.reaper", - ({ keypad, dialpad, mac }) => { - const reaper = new Reaper(); - - const addInstrument = keypad.menu((menu) => { - menu.key("up-left", txt("KK"), () => { - mac.toast("Add Komplete Kontrol"); - reaper.runScript("insert_komplete_kontrol_track"); - keypad.back(); - }); - menu.key("up", txt("AD2"), () => { - mac.toast("Add Addictive Drums"); - reaper.runScript("insert_addictive_drums_track"); - keypad.back(); - }); - menu.key("up-right", txt("Blank"), () => { - mac.toast("Blank Track"); - reaper.runScript("insert_blank_track"); - keypad.back(); - }); - }); - - const recording = keypad.overlay((overlay) => { - overlay.key("down-left", lucide("Save").fg("green"), () => { - reaper.runAction("transport-stop-save-all-recorded-media"); - }); - overlay.key("down", lucide("Redo").fg("green"), () => { - reaper.runAction("transport-stop-save-all-recorded-media"); - reaper.runAction("transport-record"); - }); - overlay.key("down-right", lucide("Trash").fg("#ff6b6b"), () => { - reaper.runAction("transport-stop-delete-all-recorded-media"); - }); - }); - - keypad.key("up-left", mdi("metronome"), () => { - reaper.runAction("options-toggle-metronome"); - }); - keypad.key("up", lucide("Clock"), () => reaper.runAction("file-project-settings")); - keypad.key("up-right", txt("BPM"), () => { - reaper.runAction("tempo-increase-current-project-tempo-01-bpm"); - }); - keypad.key("left", lucide("Plus"), () => { - addInstrument.open(); - }); - keypad.key("down-left", lucide("Mic").fg("red"), () => { - reaper.runAction("transport-record"); - }); - keypad.key("down-right", lucide("Play"), () => { - reaper.runAction("transport-play-stop"); - }); - - reaper.on("transport", (transport) => { - recording.active = transport.recording; - }); - - dialpad.on("rotate", (delta) => { - console.log({delta}) - reaper.runAction( - delta > 0 - ? "tempo-increase-current-project-tempo-0-1-bpm" - : "tempo-decrease-current-project-tempo-0-1-bpm", - ); - }); - }, -); diff --git a/config/reaper/insert_track.lua b/config/reaper/insert_track.lua deleted file mode 100644 index a33c4101daf12eeda697be2c05582ae807179c3a..0000000000000000000000000000000000000000 --- a/config/reaper/insert_track.lua +++ /dev/null @@ -1,90 +0,0 @@ -local function get_insert_index() - local selected = reaper.GetSelectedTrack(0, 0) - if not selected then - return reaper.CountTracks(0) - end - - local track_number = reaper.GetMediaTrackInfo_Value(selected, "IP_TRACKNUMBER") - return math.floor(track_number) -end - -local function focus_new_track(track) - reaper.SetOnlyTrackSelected(track) - reaper.SetMixerScroll(track) - reaper.Main_OnCommand(40913, 0) -- Vertical scroll selected tracks into view. -end - -local function set_track_name(track, name) - reaper.GetSetMediaTrackInfo_String(track, "P_NAME", name, true) -end - -local function get_action_name(options) - if options.action_name and options.action_name ~= "" then - return options.action_name - end - - if options.track_name and options.track_name ~= "" then - return "Add " .. options.track_name - end - - return "Add track" -end - -local function insert_track(options) - local action_name = get_action_name(options) - local fx_name = options.fx_name - local needs_midi_input = options.needs_midi_input - - if needs_midi_input == nil then - needs_midi_input = fx_name ~= nil and fx_name ~= "" - end - - reaper.Undo_BeginBlock() - reaper.PreventUIRefresh(1) - - local insert_index = get_insert_index() - reaper.InsertTrackAtIndex(insert_index, true) - - local track = reaper.GetTrack(0, insert_index) - local fx_index = -1 - local requested_fx = fx_name ~= nil and fx_name ~= "" - - if track then - reaper.SetMediaTrackInfo_Value(track, "I_RECMON", 1) - - if needs_midi_input then - reaper.SetMediaTrackInfo_Value(track, "I_RECINPUT", 4096 + 63) - end - - if options.track_name and options.track_name ~= "" then - set_track_name(track, options.track_name) - end - - if requested_fx then - fx_index = reaper.TrackFX_AddByName(track, fx_name, false, -1000) - if fx_index >= 0 then - reaper.TrackFX_Show(track, fx_index, 1) - end - end - - focus_new_track(track) - reaper.TrackList_AdjustWindows(false) - reaper.UpdateArrange() - end - - reaper.PreventUIRefresh(-1) - - if requested_fx and fx_index < 0 then - reaper.Undo_EndBlock(action_name .. " (FX not found)", -1) - reaper.ShowMessageBox( - 'Could not find FX named "' .. fx_name .. '". The track was still created.', - action_name, - 0 - ) - return - end - - reaper.Undo_EndBlock(action_name, -1) -end - -return insert_track diff --git a/config/reaper/register_scripts.lua b/config/reaper/register_scripts.lua deleted file mode 100644 index 44e0701056d9e44983c90ce1b930597dce9f32a3..0000000000000000000000000000000000000000 --- a/config/reaper/register_scripts.lua +++ /dev/null @@ -1,70 +0,0 @@ -local function current_script_dir() - local source = debug.getinfo(1, "S").source - local script_path = source:match("^@(.+)$") - return script_path:match("^(.*)[/\\].-$") -end - -local function list_action_scripts(path) - local files = {} - local index = 0 - - while true do - local file_name = reaper.EnumerateFiles(path, index) - if file_name == nil then - break - end - - if file_name:match("%.lua$") then - files[#files + 1] = file_name - end - - index = index + 1 - end - - table.sort(files) - return files -end - -local function add_script(path, commit) - local command_id = reaper.AddRemoveReaScript(true, 0, path, commit) - if command_id == 0 then - error("failed to register " .. path) - end - - local named = reaper.ReverseNamedCommandLookup(command_id) - if named ~= nil and named ~= "" then - return "_" .. named - end - - return tostring(command_id) -end - -local function remove_script(path, commit) - reaper.AddRemoveReaScript(false, 0, path, commit) -end - -local function ext_state_key_for_file(file_name) - local stem = file_name:match("^(.*)%.lua$") - if stem == nil or stem == "" then - error("invalid action file name: " .. file_name) - end - - return stem .. "_command_id" -end - -local script_dir = current_script_dir() -local actions_dir = script_dir .. "/scripts" -local files = list_action_scripts(actions_dir) - -if #files == 0 then - error("no action scripts found in " .. actions_dir) -end - -for _, file_name in ipairs(files) do - remove_script(actions_dir .. "/" .. file_name, false) -end - -for index, file_name in ipairs(files) do - local command_id = add_script(actions_dir .. "/" .. file_name, index == #files) - reaper.SetExtState("meow", ext_state_key_for_file(file_name), command_id, true) -end diff --git a/config/reaper/scripts/clover_feedback.lua b/config/reaper/scripts/clover_feedback.lua deleted file mode 100644 index 32e218ba7f84baed9b2017ad6ca95586dfd5e3a8..0000000000000000000000000000000000000000 --- a/config/reaper/scripts/clover_feedback.lua +++ /dev/null @@ -1,54 +0,0 @@ --- Clover live feedback: continuously write the project's tempo and time --- signature to state.json (next to this script) so the Clover Node process can --- watch the file and update the keypad. Re-schedules itself via reaper.defer, --- writing only when a value actually changes. --- --- REAPER's OSC has a tempo token but no time-signature feedback, so we read both --- here in one place (reaper.TimeMap_GetTimeSigAtTime returns num, denom, tempo). - --- Avoid stacking multiple defer loops if the script gets launched again (e.g. a --- Clover restart while REAPER keeps running). -if reaper.GetExtState("clover", "feedback") == "1" then - return -end -reaper.SetExtState("clover", "feedback", "1", false) -reaper.atexit(function() - reaper.SetExtState("clover", "feedback", "", false) -end) - -local source = debug.getinfo(1, "S").source -local script_path = source:match("^@(.+)$") -local script_dir = script_path:match("^(.*)[/\\].-$") -local sep = package.config:sub(1, 1) -local state_path = script_dir .. sep .. "state.json" - -local last = nil - -local function snapshot() - local position - if reaper.GetPlayState() > 0 then - position = reaper.GetPlayPosition() - else - position = reaper.GetCursorPosition() - end - - local num, denom, tempo = reaper.TimeMap_GetTimeSigAtTime(0, position) - num = math.floor(num + 0.5) - denom = math.floor(denom + 0.5) - return string.format('{"tempo":%.3f,"timesig":"%d/%d"}', tempo, num, denom) -end - -local function poll() - local snap = snapshot() - if snap ~= last then - last = snap - local file = io.open(state_path, "w") - if file then - file:write(snap) - file:close() - end - end - reaper.defer(poll) -end - -poll() diff --git a/config/reaper/scripts/generate_recorder_template.lua b/config/reaper/scripts/generate_recorder_template.lua deleted file mode 100644 index 044a47b4ce65ba0fb723f8cd81fd2104f0340be9..0000000000000000000000000000000000000000 --- a/config/reaper/scripts/generate_recorder_template.lua +++ /dev/null @@ -1,34 +0,0 @@ --- Generate the Clover Recorder session template. --- --- Creates a fresh project with a single record-armed MIDI track listening to --- "All MIDI Inputs / All Channels" (so any keyboard works), then saves it to --- the template path. The recorder copies this per session. Open it in REAPER --- afterwards to add your instrument / tweak settings and re-save — it's yours. --- --- Run via: REAPER -nonewinst generate_recorder_template.lua - -local template_path = "/Volumes/Documents/Recorder Template.rpp" - --- Work in a fresh project tab so we never disturb whatever is already open. -reaper.Main_OnCommand(40859, 0) -- New project tab - -reaper.InsertTrackAtIndex(0, false) -local track = reaper.GetTrack(0, 0) -reaper.GetSetMediaTrackInfo_String(track, "P_NAME", "Improv MIDI", true) -reaper.SetMediaTrackInfo_Value(track, "I_RECARM", 1) --- MIDI record input: 4096 + (device<<5) + channel; device 62 = all MIDI inputs, --- channel 0 = omni (all channels). See REAPER API docs for I_RECINPUT. -reaper.SetMediaTrackInfo_Value(track, "I_RECINPUT", 4096 + (62 << 5)) -reaper.SetMediaTrackInfo_Value(track, "I_RECMON", 1) -- input monitoring on - -reaper.Main_SaveProjectEx(0, template_path, 0) - -local readback = reaper.GetMediaTrackInfo_Value(track, "I_RECINPUT") -local log = io.open("/tmp/reaper-template.log", "w") -if log then - log:write(string.format("saved=%s I_RECINPUT=%d\n", template_path, readback)) - log:close() -end - --- Close the template tab; leave REAPER as it was. -reaper.Main_OnCommand(40860, 0) -- Close current project tab diff --git a/config/reaper/scripts/insert_addictive_drums_track.lua b/config/reaper/scripts/insert_addictive_drums_track.lua deleted file mode 100644 index 34822ffdd091c85ba9838e56e996cfe5ba907e65..0000000000000000000000000000000000000000 --- a/config/reaper/scripts/insert_addictive_drums_track.lua +++ /dev/null @@ -1,11 +0,0 @@ -local source = debug.getinfo(1, "S").source -local script_path = source:match("^@(.+)$") -local script_dir = script_path:match("^(.*)[/\\].-$") -local reaper_dir = script_dir:match("^(.*)[/\\].-$") - -local insert_track = dofile(reaper_dir .. "/insert_track.lua") - -insert_track({ - fx_name = "AUi: Addictive Drums 2 (XLN Audio)", - track_name = "Addictive Drums 2", -}) diff --git a/config/reaper/scripts/insert_blank_track.lua b/config/reaper/scripts/insert_blank_track.lua deleted file mode 100644 index 7cd3811d90736addbdd69c1f247b910f084b52f5..0000000000000000000000000000000000000000 --- a/config/reaper/scripts/insert_blank_track.lua +++ /dev/null @@ -1,11 +0,0 @@ -local source = debug.getinfo(1, "S").source -local script_path = source:match("^@(.+)$") -local script_dir = script_path:match("^(.*)[/\\].-$") -local reaper_dir = script_dir:match("^(.*)[/\\].-$") - -local insert_track = dofile(reaper_dir .. "/insert_track.lua") - -insert_track({ - action_name = "Add blank track", - needs_midi_input = false, -}) diff --git a/config/reaper/scripts/insert_komplete_kontrol_track.lua b/config/reaper/scripts/insert_komplete_kontrol_track.lua deleted file mode 100644 index 414028daa9a333d4d3c0bc71283100b6f3f3e719..0000000000000000000000000000000000000000 --- a/config/reaper/scripts/insert_komplete_kontrol_track.lua +++ /dev/null @@ -1,11 +0,0 @@ -local source = debug.getinfo(1, "S").source -local script_path = source:match("^@(.+)$") -local script_dir = script_path:match("^(.*)[/\\].-$") -local reaper_dir = script_dir:match("^(.*)[/\\].-$") - -local insert_track = dofile(reaper_dir .. "/insert_track.lua") - -insert_track({ - fx_name = "AUi: Komplete Kontrol (Native Instruments)", - track_name = "Komplete Kontrol", -}) diff --git a/control/config/reaper.ts b/control/config/reaper.ts new file mode 100644 index 0000000000000000000000000000000000000000..fe308bec47c8b9d533a4d8f6d8b09a34faf03528 --- /dev/null +++ b/control/config/reaper.ts @@ -0,0 +1,82 @@ +import * as config from "#config"; +import { lucide, mdi, stack, timeSignature, txt } from "@clo/clover-control/icons"; +import { Reaper } from "@clo/clover-control/Reaper"; +import { signal } from "@clo/clover-control/signals"; + +export default config.forApp( + "com.cockos.reaper", + ({ keypad, dialpad, mac }) => { + const reaper = new Reaper(); + + // Live project state — faces that read these re-render themselves on change. + const bpm = signal(120); + const timeSig = signal("4/4"); + reaper.on("transport", (t) => { + bpm.set(Math.round(t.tempo)); + timeSig.set(t.timeSignature); + }); + + const addInstrument = keypad.menu((menu) => { + menu.key("up-left", txt("KK"), () => { + mac.toast("Add Komplete Kontrol"); + reaper.runScript("insert_komplete_kontrol_track"); + keypad.back(); + }); + menu.key("up", txt("AD2"), () => { + mac.toast("Add Addictive Drums"); + reaper.runScript("insert_addictive_drums_track"); + keypad.back(); + }); + menu.key("up-right", txt("Blank"), () => { + mac.toast("Blank Track"); + reaper.runScript("insert_blank_track"); + keypad.back(); + }); + }); + + const recording = keypad.overlay((overlay) => { + overlay.key("down-left", lucide("Save").fg("green"), () => { + reaper.runAction("transport-stop-save-all-recorded-media"); + }); + overlay.key("down", lucide("Redo").fg("green"), () => { + reaper.runAction("transport-stop-save-all-recorded-media"); + reaper.runAction("transport-record"); + }); + overlay.key("down-right", lucide("Trash").fg("#ff6b6b"), () => { + reaper.runAction("transport-stop-delete-all-recorded-media"); + }); + }); + + keypad.key("up-left", mdi("metronome"), () => { + reaper.runAction("options-toggle-metronome"); + }); + keypad.key("up", () => timeSignature(timeSig()), () => { + reaper.runAction("file-project-settings"); + }); + keypad.key("up-right", () => stack(bpm(), "BPM"), () => { + reaper.runAction("tempo-increase-current-project-tempo-01-bpm"); + }); + keypad.key("left", lucide("Plus"), () => { + addInstrument.open(); + }); + keypad.key("down-left", lucide("Mic").fg("red"), () => { + reaper.runAction("transport-record"); + }); + keypad.key("down-right", lucide("Play"), () => { + reaper.runAction("transport-play-stop"); + }); + + reaper.on("transport", (transport) => { + recording.active = transport.recording; + }); + + dialpad.on("rotate", (delta) => { + console.log({delta}) + reaper.runAction( + delta > 0 + ? "tempo-increase-current-project-tempo-0-1-bpm" + : "tempo-decrease-current-project-tempo-0-1-bpm", + ); + }); + }, +); diff --git a/control/config/reaper/insert_track.lua b/control/config/reaper/insert_track.lua new file mode 100644 index 0000000000000000000000000000000000000000..a33c4101daf12eeda697be2c05582ae807179c3a --- /dev/null +++ b/control/config/reaper/insert_track.lua @@ -0,0 +1,90 @@ +local function get_insert_index() + local selected = reaper.GetSelectedTrack(0, 0) + if not selected then + return reaper.CountTracks(0) + end + + local track_number = reaper.GetMediaTrackInfo_Value(selected, "IP_TRACKNUMBER") + return math.floor(track_number) +end + +local function focus_new_track(track) + reaper.SetOnlyTrackSelected(track) + reaper.SetMixerScroll(track) + reaper.Main_OnCommand(40913, 0) -- Vertical scroll selected tracks into view. +end + +local function set_track_name(track, name) + reaper.GetSetMediaTrackInfo_String(track, "P_NAME", name, true) +end + +local function get_action_name(options) + if options.action_name and options.action_name ~= "" then + return options.action_name + end + + if options.track_name and options.track_name ~= "" then + return "Add " .. options.track_name + end + + return "Add track" +end + +local function insert_track(options) + local action_name = get_action_name(options) + local fx_name = options.fx_name + local needs_midi_input = options.needs_midi_input + + if needs_midi_input == nil then + needs_midi_input = fx_name ~= nil and fx_name ~= "" + end + + reaper.Undo_BeginBlock() + reaper.PreventUIRefresh(1) + + local insert_index = get_insert_index() + reaper.InsertTrackAtIndex(insert_index, true) + + local track = reaper.GetTrack(0, insert_index) + local fx_index = -1 + local requested_fx = fx_name ~= nil and fx_name ~= "" + + if track then + reaper.SetMediaTrackInfo_Value(track, "I_RECMON", 1) + + if needs_midi_input then + reaper.SetMediaTrackInfo_Value(track, "I_RECINPUT", 4096 + 63) + end + + if options.track_name and options.track_name ~= "" then + set_track_name(track, options.track_name) + end + + if requested_fx then + fx_index = reaper.TrackFX_AddByName(track, fx_name, false, -1000) + if fx_index >= 0 then + reaper.TrackFX_Show(track, fx_index, 1) + end + end + + focus_new_track(track) + reaper.TrackList_AdjustWindows(false) + reaper.UpdateArrange() + end + + reaper.PreventUIRefresh(-1) + + if requested_fx and fx_index < 0 then + reaper.Undo_EndBlock(action_name .. " (FX not found)", -1) + reaper.ShowMessageBox( + 'Could not find FX named "' .. fx_name .. '". The track was still created.', + action_name, + 0 + ) + return + end + + reaper.Undo_EndBlock(action_name, -1) +end + +return insert_track diff --git a/control/config/reaper/register_scripts.lua b/control/config/reaper/register_scripts.lua new file mode 100644 index 0000000000000000000000000000000000000000..44e0701056d9e44983c90ce1b930597dce9f32a3 --- /dev/null +++ b/control/config/reaper/register_scripts.lua @@ -0,0 +1,70 @@ +local function current_script_dir() + local source = debug.getinfo(1, "S").source + local script_path = source:match("^@(.+)$") + return script_path:match("^(.*)[/\\].-$") +end + +local function list_action_scripts(path) + local files = {} + local index = 0 + + while true do + local file_name = reaper.EnumerateFiles(path, index) + if file_name == nil then + break + end + + if file_name:match("%.lua$") then + files[#files + 1] = file_name + end + + index = index + 1 + end + + table.sort(files) + return files +end + +local function add_script(path, commit) + local command_id = reaper.AddRemoveReaScript(true, 0, path, commit) + if command_id == 0 then + error("failed to register " .. path) + end + + local named = reaper.ReverseNamedCommandLookup(command_id) + if named ~= nil and named ~= "" then + return "_" .. named + end + + return tostring(command_id) +end + +local function remove_script(path, commit) + reaper.AddRemoveReaScript(false, 0, path, commit) +end + +local function ext_state_key_for_file(file_name) + local stem = file_name:match("^(.*)%.lua$") + if stem == nil or stem == "" then + error("invalid action file name: " .. file_name) + end + + return stem .. "_command_id" +end + +local script_dir = current_script_dir() +local actions_dir = script_dir .. "/scripts" +local files = list_action_scripts(actions_dir) + +if #files == 0 then + error("no action scripts found in " .. actions_dir) +end + +for _, file_name in ipairs(files) do + remove_script(actions_dir .. "/" .. file_name, false) +end + +for index, file_name in ipairs(files) do + local command_id = add_script(actions_dir .. "/" .. file_name, index == #files) + reaper.SetExtState("meow", ext_state_key_for_file(file_name), command_id, true) +end diff --git a/control/config/reaper/scripts/clover_feedback.lua b/control/config/reaper/scripts/clover_feedback.lua new file mode 100644 index 0000000000000000000000000000000000000000..32e218ba7f84baed9b2017ad6ca95586dfd5e3a8 --- /dev/null +++ b/control/config/reaper/scripts/clover_feedback.lua @@ -0,0 +1,54 @@ +-- Clover live feedback: continuously write the project's tempo and time +-- signature to state.json (next to this script) so the Clover Node process can +-- watch the file and update the keypad. Re-schedules itself via reaper.defer, +-- writing only when a value actually changes. +-- +-- REAPER's OSC has a tempo token but no time-signature feedback, so we read both +-- here in one place (reaper.TimeMap_GetTimeSigAtTime returns num, denom, tempo). + +-- Avoid stacking multiple defer loops if the script gets launched again (e.g. a +-- Clover restart while REAPER keeps running). +if reaper.GetExtState("clover", "feedback") == "1" then + return +end +reaper.SetExtState("clover", "feedback", "1", false) +reaper.atexit(function() + reaper.SetExtState("clover", "feedback", "", false) +end) + +local source = debug.getinfo(1, "S").source +local script_path = source:match("^@(.+)$") +local script_dir = script_path:match("^(.*)[/\\].-$") +local sep = package.config:sub(1, 1) +local state_path = script_dir .. sep .. "state.json" + +local last = nil + +local function snapshot() + local position + if reaper.GetPlayState() > 0 then + position = reaper.GetPlayPosition() + else + position = reaper.GetCursorPosition() + end + + local num, denom, tempo = reaper.TimeMap_GetTimeSigAtTime(0, position) + num = math.floor(num + 0.5) + denom = math.floor(denom + 0.5) + return string.format('{"tempo":%.3f,"timesig":"%d/%d"}', tempo, num, denom) +end + +local function poll() + local snap = snapshot() + if snap ~= last then + last = snap + local file = io.open(state_path, "w") + if file then + file:write(snap) + file:close() + end + end + reaper.defer(poll) +end + +poll() diff --git a/control/config/reaper/scripts/generate_recorder_template.lua b/control/config/reaper/scripts/generate_recorder_template.lua new file mode 100644 index 0000000000000000000000000000000000000000..044a47b4ce65ba0fb723f8cd81fd2104f0340be9 --- /dev/null +++ b/control/config/reaper/scripts/generate_recorder_template.lua @@ -0,0 +1,34 @@ +-- Generate the Clover Recorder session template. +-- +-- Creates a fresh project with a single record-armed MIDI track listening to +-- "All MIDI Inputs / All Channels" (so any keyboard works), then saves it to +-- the template path. The recorder copies this per session. Open it in REAPER +-- afterwards to add your instrument / tweak settings and re-save — it's yours. +-- +-- Run via: REAPER -nonewinst generate_recorder_template.lua + +local template_path = "/Volumes/Documents/Recorder Template.rpp" + +-- Work in a fresh project tab so we never disturb whatever is already open. +reaper.Main_OnCommand(40859, 0) -- New project tab + +reaper.InsertTrackAtIndex(0, false) +local track = reaper.GetTrack(0, 0) +reaper.GetSetMediaTrackInfo_String(track, "P_NAME", "Improv MIDI", true) +reaper.SetMediaTrackInfo_Value(track, "I_RECARM", 1) +-- MIDI record input: 4096 + (device<<5) + channel; device 62 = all MIDI inputs, +-- channel 0 = omni (all channels). See REAPER API docs for I_RECINPUT. +reaper.SetMediaTrackInfo_Value(track, "I_RECINPUT", 4096 + (62 << 5)) +reaper.SetMediaTrackInfo_Value(track, "I_RECMON", 1) -- input monitoring on + +reaper.Main_SaveProjectEx(0, template_path, 0) + +local readback = reaper.GetMediaTrackInfo_Value(track, "I_RECINPUT") +local log = io.open("/tmp/reaper-template.log", "w") +if log then + log:write(string.format("saved=%s I_RECINPUT=%d\n", template_path, readback)) + log:close() +end + +-- Close the template tab; leave REAPER as it was. +reaper.Main_OnCommand(40860, 0) -- Close current project tab diff --git a/control/config/reaper/scripts/insert_addictive_drums_track.lua b/control/config/reaper/scripts/insert_addictive_drums_track.lua new file mode 100644 index 0000000000000000000000000000000000000000..34822ffdd091c85ba9838e56e996cfe5ba907e65 --- /dev/null +++ b/control/config/reaper/scripts/insert_addictive_drums_track.lua @@ -0,0 +1,11 @@ +local source = debug.getinfo(1, "S").source +local script_path = source:match("^@(.+)$") +local script_dir = script_path:match("^(.*)[/\\].-$") +local reaper_dir = script_dir:match("^(.*)[/\\].-$") + +local insert_track = dofile(reaper_dir .. "/insert_track.lua") + +insert_track({ + fx_name = "AUi: Addictive Drums 2 (XLN Audio)", + track_name = "Addictive Drums 2", +}) diff --git a/control/config/reaper/scripts/insert_blank_track.lua b/control/config/reaper/scripts/insert_blank_track.lua new file mode 100644 index 0000000000000000000000000000000000000000..7cd3811d90736addbdd69c1f247b910f084b52f5 --- /dev/null +++ b/control/config/reaper/scripts/insert_blank_track.lua @@ -0,0 +1,11 @@ +local source = debug.getinfo(1, "S").source +local script_path = source:match("^@(.+)$") +local script_dir = script_path:match("^(.*)[/\\].-$") +local reaper_dir = script_dir:match("^(.*)[/\\].-$") + +local insert_track = dofile(reaper_dir .. "/insert_track.lua") + +insert_track({ + action_name = "Add blank track", + needs_midi_input = false, +}) diff --git a/control/config/reaper/scripts/insert_komplete_kontrol_track.lua b/control/config/reaper/scripts/insert_komplete_kontrol_track.lua new file mode 100644 index 0000000000000000000000000000000000000000..414028daa9a333d4d3c0bc71283100b6f3f3e719 --- /dev/null +++ b/control/config/reaper/scripts/insert_komplete_kontrol_track.lua @@ -0,0 +1,11 @@ +local source = debug.getinfo(1, "S").source +local script_path = source:match("^@(.+)$") +local script_dir = script_path:match("^(.*)[/\\].-$") +local reaper_dir = script_dir:match("^(.*)[/\\].-$") + +local insert_track = dofile(reaper_dir .. "/insert_track.lua") + +insert_track({ + fx_name = "AUi: Komplete Kontrol (Native Instruments)", + track_name = "Komplete Kontrol", +}) diff --git a/control/docs/speed-editor.jpg b/control/docs/speed-editor.jpg new file mode 100644 index 0000000000000000000000000000000000000000..1fa69cc0395e7c58a57b03d6396f8d9bb34d5966 Binary files /dev/null and b/control/docs/speed-editor.jpg differ diff --git a/control/examples/dialpad.ts b/control/examples/dialpad.ts new file mode 100644 index 0000000000000000000000000000000000000000..cdb87099700d5bdc7f1903a919e88d407ee33438 --- /dev/null +++ b/control/examples/dialpad.ts @@ -0,0 +1,14 @@ +// Listen to the dialpad. NOTE: until the OS-seize phase, turning the dial also +// scrolls macOS and the buttons act as mouse buttons. +import { Dialpad } from "../src/Dialpad.ts"; + +const dialpad = await Dialpad.open(); +console.info(dialpad.connected ? "Dialpad connected" : "Waiting for dialpad…"); + +dialpad.on("connect", () => console.info("connect")); +dialpad.on("disconnect", () => console.info("disconnect")); +dialpad.on("rotate", (delta) => console.info("rotate", delta)); +dialpad.on("spin", (delta) => console.info("spin", delta)); +dialpad.on("keydown", (button) => console.info("down", button)); +dialpad.on("keyup", (button) => console.info("up", button)); +dialpad.onPress("circle", () => console.info("circle pressed!")); diff --git a/control/examples/enumerate-hid.ts b/control/examples/enumerate-hid.ts new file mode 100644 index 0000000000000000000000000000000000000000..1ece855995c7d8e4d164cf44a41c60485814cd09 --- /dev/null +++ b/control/examples/enumerate-hid.ts @@ -0,0 +1,43 @@ +// Phase 0: list HID devices so we can identify the MX Creative Console halves. +// Keypad is expected as Elgato (0x0fd9); the Bluetooth dialpad is likely +// Logitech (0x046d) speaking HID++. +const { devices } = await import("node-hid"); + +const VENDOR_NAMES: Record = { + 0x046d: "Logitech", + 0x0fd9: "Elgato", + 0x05ac: "Apple", +}; + +const hex = (n: number | undefined, width = 4) => "0x" + (n ?? 0).toString(16).padStart(width, "0"); + +const all = devices(); + +const format = (d: import("node-hid").Device) => + [ + `${hex(d.vendorId)}:${hex(d.productId)}`, + (VENDOR_NAMES[d.vendorId] ?? "?").padEnd(8), + `usage=${hex(d.usagePage)}/${hex(d.usage)}`, + `iface=${d.interface}`, + `| ${d.manufacturer ?? ""} ${d.product ?? ""}`.trim(), + d.path ? `\n path=${d.path}` : "", + ].join(" "); + +const interesting = all.filter( + (d) => d.vendorId === 0x046d || d.vendorId === 0x0fd9, +); + +console.info(`Total HID devices: ${all.length}`); +console.info(`\n=== Logitech (0x046d) + Elgato (0x0fd9) ===`); +if (interesting.length === 0) { + console.info(" (none found — dialpad may not surface as a HID device)"); +} else { + for (const d of interesting) console.info(" " + format(d)); +} + +console.info(`\n=== All vendors present ===`); +const byVendor = new Map(); +for (const d of all) byVendor.set(d.vendorId, (byVendor.get(d.vendorId) ?? 0) + 1); +for (const [vid, count] of [...byVendor].sort((a, b) => b[1] - a[1])) { + console.info(` ${hex(vid)} ${(VENDOR_NAMES[vid] ?? "").padEnd(8)} ${count}`); +} diff --git a/control/examples/event-listener.ts b/control/examples/event-listener.ts new file mode 100644 index 0000000000000000000000000000000000000000..1d55fdbab0c1a1906dd910f34d256abbc9c073a7 --- /dev/null +++ b/control/examples/event-listener.ts @@ -0,0 +1,24 @@ +import { Mac } from "../src/Mac.ts"; +import { SpeedEditor } from "../src/SpeedEditor.ts"; + +const editor = await SpeedEditor.open(); +const mac = await Mac.open(); + +editor.on("jog", (ev) => { + console.info(ev); +}); +editor.on("keypress", (key) => { + console.info(`Press ${key}`); + + if (key === "smartInsert") { + mac.focusApp("com.google.Chrome"); + } +}); +editor.onDoublePress("transition", () => { + console.info("(double press) \"Title\""); +}); + +console.info("init"); +mac.on("app-change", (bundle) => { + console.info(`Switch to ${bundle}`); +}); diff --git a/control/examples/face-preview.ts b/control/examples/face-preview.ts new file mode 100644 index 0000000000000000000000000000000000000000..dbbbd80fa9ce1ba5f253ccec785e2ab320191082 --- /dev/null +++ b/control/examples/face-preview.ts @@ -0,0 +1,26 @@ +// Render key faces to PNGs (no hardware needed) so you can eyeball them before +// pushing to the device. Writes to /tmp/face-preview/. +import { mkdirSync, writeFileSync } from "node:fs"; +import sharp from "sharp"; +import { blank, lucide, mdi, txt } from "../src/icons.ts"; + +const faces: Record = { + blank: blank.svg, + metronome: mdi("metronome").svg, + "time-sig": lucide("Clock").svg, + bpm: txt("BPM").svg, + "add-instrument": lucide("Plus").svg, + record: lucide("Disc").fg("red").svg, + play: lucide("Play").svg, + save: lucide("Save").fg("green").svg, +}; + +const outDir = "/tmp/face-preview"; +mkdirSync(outDir, { recursive: true }); + +for (const [name, face] of Object.entries(faces)) { + const png = await sharp(Buffer.from(face)).resize(118, 118).png().toBuffer(); + const file = `${outDir}/${name}.png`; + writeFileSync(file, png); + console.info(`${name.padEnd(16)} ${png.length} bytes ${file}`); +} diff --git a/control/examples/hid-sniff.ts b/control/examples/hid-sniff.ts new file mode 100644 index 0000000000000000000000000000000000000000..4e3dbe8450a62de87ce624bd804f59d5edc145a1 --- /dev/null +++ b/control/examples/hid-sniff.ts @@ -0,0 +1,67 @@ +// Phase 0 sniffer: open an MX Creative Console half by product-name match and +// log every HID input report. Operate the control you want to map and watch the +// report id + bytes. Logitech HID++ events arrive as report id 0x10 (short, 7B) +// or 0x11 (long, 20B). Default mouse/consumer reports use other ids. +// +// node examples/hid-sniff.ts [name-substring] [seconds] +// node examples/hid-sniff.ts dialpad 25 +const { devices, HID } = await import("node-hid"); + +const match = (process.argv[2] ?? "dialpad").toLowerCase(); +const durationSec = Number(process.argv[3] ?? 0); + +const hex = (n: number, w = 2) => n.toString(16).padStart(w, "0"); + +const dev = devices().find( + (d) => (d.product ?? "").toLowerCase().includes(match) && d.path, +); +if (!dev?.path) { + console.error( + `No HID device matching "${match}". Run examples/enumerate-hid.ts to list.`, + ); + process.exit(1); +} + +console.info( + `Opening ${dev.product} 0x${hex(dev.vendorId, 4)}:0x${hex(dev.productId, 4)}\n path=${dev.path}`, +); + +let device: import("node-hid").HID; +try { + device = new HID(dev.path); +} catch (error) { + console.error( + "Failed to open device. On macOS, grant the terminal/node Input Monitoring\n" + + "(System Settings > Privacy & Security > Input Monitoring), then retry.\n", + error, + ); + process.exit(1); +} + +const t0 = Date.now(); +let count = 0; +device.on("data", (buf: Buffer) => { + const bytes = [...buf]; + const id = bytes[0]; + const kind = id === 0x11 + ? "hid++ long " + : id === 0x10 + ? "hid++ short" + : "report "; + const ms = String(Date.now() - t0).padStart(6); + console.info( + `+${ms}ms ${kind} id=0x${hex(id)} ${bytes.map((b) => hex(b)).join(" ")}`, + ); + count += 1; +}); +device.on("error", (error) => console.error("device error:", error)); + +console.info("Listening — operate the dial / knob / buttons. Ctrl-C to stop.\n"); + +if (durationSec > 0) { + setTimeout(() => { + console.info(`\nCaptured ${count} reports in ${durationSec}s. Closing.`); + device.close(); + process.exit(0); + }, durationSec * 1000); +} diff --git a/control/examples/keypad-demo.ts b/control/examples/keypad-demo.ts new file mode 100644 index 0000000000000000000000000000000000000000..b790811df910e0b3eab32a01759ee7d1045ed991 --- /dev/null +++ b/control/examples/keypad-demo.ts @@ -0,0 +1,30 @@ +// Push the Reaper root layout to the physical keypad as one atomic panel write +// and exit (faces persist on the device). Validates the panel image protocol. +import { Keypad } from "../src/Keypad.ts"; +import { composePanel, type Face } from "../src/KeypadUI.ts"; +import { blank, lucide, txt } from "../src/icons.ts"; + +const keypad = await Keypad.open(); +if (!keypad.connected) { + console.error("Keypad not connected."); + process.exit(1); +} + +keypad.setBrightness(0.85); + +// Faces in grid order: up-left, up, up-right, left, center, right, down-*. +const faces: Face[] = [ + lucide("AlarmClock"), + lucide("Clock"), + txt("BPM"), + lucide("Plus"), + blank, + blank, + lucide("Disc").fg("red"), + blank, + lucide("Play"), +]; + +keypad.setPanel(await composePanel(faces)); +console.info("Pushed the panel to the keypad — look at the device."); +process.exit(0); diff --git a/control/examples/toast.ts b/control/examples/toast.ts new file mode 100644 index 0000000000000000000000000000000000000000..8c62eb714ddd77192fe9c60dd4e5a356ce1c0bdd --- /dev/null +++ b/control/examples/toast.ts @@ -0,0 +1,25 @@ +import { Mac } from "../src/Mac.ts"; + +const mac = await Mac.open(); +mac.on("error", (error) => { + console.error(error); +}); + +mac.toast("Insert AD2 Track", { + detail: "Loading Addictive Drums 2...", + durationMs: 900, +}); + +await new Promise((resolve) => { + setTimeout(resolve, 1300); +}); + +mac.toast("Insert Blank Track", { + durationMs: 800, +}); + +await new Promise((resolve) => { + setTimeout(resolve, 1200); +}); + +mac.close(); diff --git a/control/package.json b/control/package.json new file mode 100644 index 0000000000000000000000000000000000000000..aa2321932b2924df405f03e55772d434fa70ec70 --- /dev/null +++ b/control/package.json @@ -0,0 +1,41 @@ +{ + "name": "@clo/clover-control", + "version": "1.0.0", + "type": "module", + "license": "ISC", + "packageManager": "pnpm@10.26.1", + "scripts": { + "start": "node --watch src/main.ts", + "generate:reaper-actions": "node src/Reaper/generate-actions.ts" + }, + "dependencies": { + "@clo/lib": "jsr:^3.0.0", + "@mdi/svg": "^7.4.47", + "@types/node": "^25.5.0", + "lucide-static": "^1.21.0", + "mdi-ts": "^1.0.3", + "node-hid": "^3.3.0", + "sharp": "^0.35.2", + "usb": "^2.17.0" + }, + "imports": { + "#config": "./src/config.ts" + }, + "exports": { + "./Mac": "./src/Mac.ts", + "./SpeedEditor": "./src/SpeedEditor.ts", + "./Keypad": "./src/Keypad.ts", + "./KeypadUI": "./src/KeypadUI.ts", + "./icons": "./src/icons.ts", + "./signals": "./src/signals.ts", + "./Dialpad": "./src/Dialpad.ts", + "./Reaper": "./src/Reaper.ts", + "./Reaper/actions": "./src/Reaper/actions.ts" + }, + "pnpm": { + "onlyBuiltDependencies": [ + "node-hid", + "usb" + ] + } +} diff --git a/control/pnpm-lock.yaml b/control/pnpm-lock.yaml new file mode 100644 index 0000000000000000000000000000000000000000..1f4858b7330531ca337f1d83f1f8b92e7902df7e --- /dev/null +++ b/control/pnpm-lock.yaml @@ -0,0 +1,563 @@ +lockfileVersion: '9.0' + +settings: + autoInstallPeers: true + excludeLinksFromLockfile: false + +importers: + + .: + dependencies: + '@clo/lib': + specifier: jsr:^3.0.0 + version: '@jsr/clo__lib@3.0.0' + '@mdi/svg': + specifier: ^7.4.47 + version: 7.4.47 + '@types/node': + specifier: ^25.5.0 + version: 25.5.0 + lucide-static: + specifier: ^1.21.0 + version: 1.21.0 + mdi-ts: + specifier: ^1.0.3 + version: 1.0.3 + node-hid: + specifier: ^3.3.0 + version: 3.3.0 + sharp: + specifier: ^0.35.2 + version: 0.35.2 + usb: + specifier: ^2.17.0 + version: 2.17.0 + +packages: + + '@emnapi/runtime@1.11.1': + resolution: {integrity: sha512-vgj7R3y3Wgx24IQaGPA/R6YFXLHVMOZ0uVEyIQPaWs+rd1AzfEMXlAC22FYwO1XkKR6NPsq7mUandH8oIRdZFw==} + + '@img/colour@1.1.0': + resolution: {integrity: sha512-Td76q7j57o/tLVdgS746cYARfSyxk8iEfRxewL9h4OMzYhbW4TAcppl0mT4eyqXddh6L/jwoM75mo7ixa/pCeQ==} + engines: {node: '>=18'} + + '@img/sharp-darwin-arm64@0.35.2': + resolution: {integrity: sha512-eEieHsMksAW4IiO5NzauESRl2D2qz3J/kwUxUrSfV06A93eEaRfMpHXyUb1mAqrR7i8U9A0GRqE9pjn6u1Jjpg==} + engines: {node: '>=20.9.0'} + cpu: [arm64] + os: [darwin] + + '@img/sharp-darwin-x64@0.35.2': + resolution: {integrity: sha512-BaktuGPCeHJMARpodR8jK4uKiZrPAy9WrfQW0sdI37clracq8Bp01AYS3SZgi5FS/y5twa9t4+LIuuxQjqRrWw==} + engines: {node: '>=20.9.0'} + cpu: [x64] + os: [darwin] + + '@img/sharp-freebsd-wasm32@0.35.2': + resolution: {integrity: sha512-YoAxdnd8hPUkvLHd3bWY+YA8nw3xM/RyRopYucNsWHVSan8NLVM3X2volsfoRDcXdUJPg6tXahSd7HXPK7lRnw==} + engines: {node: '>=20.9.0'} + os: [freebsd] + + '@img/sharp-libvips-darwin-arm64@1.3.1': + resolution: {integrity: sha512-4V/M3roRMTYjiwZY9IOVQOE8OyeCxFAkYmyZDrZl51uOKjibm3oeEJ4WAmLxutAfzFbC9jqUiPs2gbnGflH+7g==} + cpu: [arm64] + os: [darwin] + + '@img/sharp-libvips-darwin-x64@1.3.1': + resolution: {integrity: sha512-c0/DxItpJv2+dGhgycJBBgotdqruGYDvA79drdh0MD1dFpy7JzJ/PlXwi1H4rFf0eTy8tgbI91aHDnZIceY3jQ==} + cpu: [x64] + os: [darwin] + + '@img/sharp-libvips-linux-arm64@1.3.1': + resolution: {integrity: sha512-JznefmcK9j1JKPz8AkQDh89kjojubyfOasWBPKfzMIhPwsgDy9evpE/naJTXXXmghS1iFwR8u/kTwh/I2/+GCw==} + cpu: [arm64] + os: [linux] + + '@img/sharp-libvips-linux-arm@1.3.1': + resolution: {integrity: sha512-aGGy9aWzXgHBG7HNyQPWorZthlp7+x6fDRoPAQbGO3ThcttuTyKIx3NuSHb6zb4gBNq6/yNn9f1cy9nFKS/Vmg==} + cpu: [arm] + os: [linux] + + '@img/sharp-libvips-linux-ppc64@1.3.1': + resolution: {integrity: sha512-1EkwGNCZk6iWNCMWqrvdJ+r1j0PT1zIz60CNPhYnJlK/zyeWqlsPZIe+ocBVqPF8k/Ssee/NCk+tE9Ryrko6ng==} + cpu: [ppc64] + os: [linux] + + '@img/sharp-libvips-linux-riscv64@1.3.1': + resolution: {integrity: sha512-Ilays+w2bXdnxzxtQdmXR62u8o8GYa3eL4+Gr+1KiE4xperMZUslRaVPJwwPkzlHEjGfXAfRVAa/7CYCtSqsBw==} + cpu: [riscv64] + os: [linux] + + '@img/sharp-libvips-linux-s390x@1.3.1': + resolution: {integrity: sha512-VfBwVHQTbRoj4XlpA/KLZ7ltgMpz+4WSejFzQ+GnoImjo1PtEJ59QB2qR1xQEeRPYIkNrPIm2L4cICMvz4C2ew==} + cpu: [s390x] + os: [linux] + + '@img/sharp-libvips-linux-x64@1.3.1': + resolution: {integrity: sha512-+c8ukgwU62DS54nCAjw7keOfHUkmr0B5QHEdcOqRnodF/MNXJbVI8Eopoj4B/0H8Asr65I+A4Amrn7a85/md6A==} + cpu: [x64] + os: [linux] + + '@img/sharp-libvips-linuxmusl-arm64@1.3.1': + resolution: {integrity: sha512-qlKb/pwbkAi1WMsJrYHk7CuDrd12s27U2QnRhFYUoJNrRCmkosMTttuRFat/DDB3IlDm5qE1TJgZ4JDnHX8Ldw==} + cpu: [arm64] + os: [linux] + + '@img/sharp-libvips-linuxmusl-x64@1.3.1': + resolution: {integrity: sha512-yO21HwoUVLN8Qa+/SBjQLMYwBWAVJjeGPNe+hc0OUeMeifEtJqu5a1c4HayE1nNpDih9y3/KkoltfkDodmKAlg==} + cpu: [x64] + os: [linux] + + '@img/sharp-linux-arm64@0.35.2': + resolution: {integrity: sha512-af12Pnd0ZGu2HfP8NayB0kk6eC/lrfbQE6HlR4jD+34wdJ1Vw9TF6TMn6ZvffT+WgqVsl0hRbmNvz2u/23VmwA==} + engines: {node: '>=20.9.0'} + cpu: [arm64] + os: [linux] + + '@img/sharp-linux-arm@0.35.2': + resolution: {integrity: sha512-SE4kzF2mepn6z+6E7L6lsV8FzuLL6IPQdyX8ZiwROAG/G8td+hP/m7FsFPwidtrF19gvajuC9l6TxAVcsA4S7A==} + engines: {node: '>=20.9.0'} + cpu: [arm] + os: [linux] + + '@img/sharp-linux-ppc64@0.35.2': + resolution: {integrity: sha512-hYSBm7zcNtDCozCxQHYZJiu63b/bXsgRZuOxCIBZsStMM9Vap47iFHdbX4kCvQsblPB/k+clhELpdQJHQLSHvg==} + engines: {node: '>=20.9.0'} + cpu: [ppc64] + os: [linux] + + '@img/sharp-linux-riscv64@0.35.2': + resolution: {integrity: sha512-qQt0Kc13+Hoan/Awq/qMSQw3L+RI1NCRPgD5cUJ/1WSSmIoysLOc72jlRM3E0OHN9Yr313jgeQ2T+zW+F03QFA==} + engines: {node: '>=20.9.0'} + cpu: [riscv64] + os: [linux] + + '@img/sharp-linux-s390x@0.35.2': + resolution: {integrity: sha512-E4fLLfRPzDLlEeDaTzI98OFLcv++WL5ChLLMwPoVd0CIoZQqupBSNbOisPL5am9XsbQ9T84+iiMpUvbFtkunbA==} + engines: {node: '>=20.9.0'} + cpu: [s390x] + os: [linux] + + '@img/sharp-linux-x64@0.35.2': + resolution: {integrity: sha512-gi0zFJJRLswfCZmHtJdikXPOc5u7qamSOS3NHedLqLd4W8Q0NqjdBr6TTRIgsfFjqfTsHFgdfvJ9LwqSgcHiAA==} + engines: {node: '>=20.9.0'} + cpu: [x64] + os: [linux] + + '@img/sharp-linuxmusl-arm64@0.35.2': + resolution: {integrity: sha512-siWbOW1u6HFnFLrp0waKyW7VEf7jYvcDWdrXEFa8AkdAQgEvuu5Fz8/Y70w9EeqAdwDtfU012BhEHHaDqvQNzg==} + engines: {node: '>=20.9.0'} + cpu: [arm64] + os: [linux] + + '@img/sharp-linuxmusl-x64@0.35.2': + resolution: {integrity: sha512-YBqMMcjDi4QGYiSn4vNOYBhmlC4z5AXqkOUUqI2e0AFA4urNv4ESgOgwNl3K+4etQhha0twXlzeF20bbULm9Yg==} + engines: {node: '>=20.9.0'} + cpu: [x64] + os: [linux] + + '@img/sharp-wasm32@0.35.2': + resolution: {integrity: sha512-Mrv4JQNYVQ94xH+jzZ9r+gowleN8mv2FTgKT+PI6bx5C0G8TdNYndu161pg2i7uoBwxy2ImPMHrJOM2LZef7Bw==} + engines: {node: '>=20.9.0'} + + '@img/sharp-webcontainers-wasm32@0.35.2': + resolution: {integrity: sha512-QNV27pxs9wpApEiCfvHM1RDoP1w1+2KrUWWDPEhEwg+latvOrfuhWrHWZKwdSFwU6jh3myjw/yOCRsUIuOft3g==} + engines: {node: '>=20.9.0'} + cpu: [wasm32] + + '@img/sharp-win32-arm64@0.35.2': + resolution: {integrity: sha512-BiVRYc/t6/Vl3e1hBx0hugG4oN9Pydf4fgMSpxTQJmwGUg/YoXTWHiFeRymHfCZzifxu4F4rpk/I67D0LQ20wQ==} + engines: {node: '>=20.9.0'} + cpu: [arm64] + os: [win32] + + '@img/sharp-win32-ia32@0.35.2': + resolution: {integrity: sha512-YYEhx9PImCC7T0tI8JDMi4DB9LwLCXCU5OWNYEXAxh5Q1ShKkyC6byxzoBJ3gEFDnH2lQckWuDe70G7mB2XJog==} + engines: {node: ^20.9.0} + cpu: [ia32] + os: [win32] + + '@img/sharp-win32-x64@0.35.2': + resolution: {integrity: sha512-imoOyBcoM/iiUr4J6VPpCNjPnjvP/Gks95898yB8YqoGGYmHYbOyCuNv9FMhFgtaiHFGbHW8bxKqRV6VjtXThQ==} + engines: {node: '>=20.9.0'} + cpu: [x64] + os: [win32] + + '@jsr/clo__lib@3.0.0': + resolution: {integrity: sha512-oseZwHCAcXNPbqnGZ37l7+wAoj6ikIXE1VM0s6eD6fz4DcgM030Slf0T7Lgtn7fIdas5hlfx4JF54TR+vo4THw==, tarball: https://npm.jsr.io/~/11/@jsr/clo__lib/3.0.0.tgz} + + '@mdi/svg@7.4.47': + resolution: {integrity: sha512-WQ2gDll12T9WD34fdRFgQVgO8bag3gavrAgJ0frN4phlwdJARpE6gO1YvLEMJR0KKgoc+/Ea/A0Pp11I00xBvw==} + + '@types/node@25.5.0': + resolution: {integrity: sha512-jp2P3tQMSxWugkCUKLRPVUpGaL5MVFwF8RDuSRztfwgN1wmqJeMSbKlnEtQqU8UrhTmzEmZdu2I6v2dpp7XIxw==} + + '@types/w3c-web-usb@1.0.13': + resolution: {integrity: sha512-N2nSl3Xsx8mRHZBvMSdNGtzMyeleTvtlEw+ujujgXalPqOjIA6UtrqcB6OzyUjkTbDm3J7P1RNK1lgoO7jxtsw==} + + ansi-regex@5.0.1: + resolution: {integrity: sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==} + engines: {node: '>=8'} + + ansi-styles@4.3.0: + resolution: {integrity: sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==} + engines: {node: '>=8'} + + cliui@8.0.1: + resolution: {integrity: sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==} + engines: {node: '>=12'} + + color-convert@2.0.1: + resolution: {integrity: sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==} + engines: {node: '>=7.0.0'} + + color-name@1.1.4: + resolution: {integrity: sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==} + + detect-libc@2.1.2: + resolution: {integrity: sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==} + engines: {node: '>=8'} + + emoji-regex@8.0.0: + resolution: {integrity: sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==} + + escalade@3.2.0: + resolution: {integrity: sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==} + engines: {node: '>=6'} + + get-caller-file@2.0.5: + resolution: {integrity: sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==} + engines: {node: 6.* || 8.* || >= 10.*} + + is-fullwidth-code-point@3.0.0: + resolution: {integrity: sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==} + engines: {node: '>=8'} + + linq@4.0.3: + resolution: {integrity: sha512-dP0w2ERJXfVUk6VmmAK+Tz/SxFHwyY7VM6Mrq4fnJmeQf9JNEYFH6qJfV6Qn0N91mfwz2GEE/4S+RDkmDNyUJw==} + + lucide-static@1.21.0: + resolution: {integrity: sha512-6248z2/4sEyKkYAPPUYxOPiB2RCfMmLdMHuoOhsTFnoD40ixAoHmTVhOPux8ADa1NTBmzpEKF7WNePm+Ms503Q==} + + mdi-ts@1.0.3: + resolution: {integrity: sha512-wtVNYoCkvyYuTJ8osV5af6jfsE5o+UT1sAnaPVjAZiIXHVFpP2l66JzdAdNClnLfCk7g5wu8cWHrr/9ru1V8vQ==} + + node-addon-api@3.2.1: + resolution: {integrity: sha512-mmcei9JghVNDYydghQmeDX8KoAm0FAiYyIcUt/N4nhyAipB17pllZQDOJD2fotxABnt4Mdz+dKTO7eftLg4d0A==} + + node-addon-api@8.6.0: + resolution: {integrity: sha512-gBVjCaqDlRUk0EwoPNKzIr9KkS9041G/q31IBShPs1Xz6UTA+EXdZADbzqAJQrpDRq71CIMnOP5VMut3SL0z5Q==} + engines: {node: ^18 || ^20 || >= 21} + + node-gyp-build@4.8.4: + resolution: {integrity: sha512-LA4ZjwlnUblHVgq0oBF3Jl/6h/Nvs5fzBLwdEF4nuxnFdsfajde4WfxtJr3CaiH+F6ewcIB/q4jQ4UzPyid+CQ==} + hasBin: true + + node-hid@3.3.0: + resolution: {integrity: sha512-j+dFgJLRAE0nufQKXk3IfS6T6YuHhCgMvz4TrG0sgtb6DSCdYpfJ1etcdmeCmPQjUgO+yo32ktVrRliNs/+fmg==} + engines: {node: '>=10.16'} + hasBin: true + + pkg-prebuilds@1.0.0: + resolution: {integrity: sha512-D9wlkXZCmjxj2kBHTw3fGSyjoahr33breGBoJcoezpi7ouYS59DJVOHMZ+dgqacSrZiJo4qtkXxLQTE+BqXJmQ==} + engines: {node: '>= 14.15.0'} + hasBin: true + + require-directory@2.1.1: + resolution: {integrity: sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==} + engines: {node: '>=0.10.0'} + + semver@7.8.5: + resolution: {integrity: sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==} + engines: {node: '>=10'} + hasBin: true + + sharp@0.35.2: + resolution: {integrity: sha512-FVtFjtBCMiJS6yb5CX7Sop45WFMpeGw6oRKuJnXYgf/f1ms/D7LE/ZUSNxnW7rZ/dbslQWYkoqFHGPaDBtaK4w==} + engines: {node: '>=20.9.0'} + + string-width@4.2.3: + resolution: {integrity: sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==} + engines: {node: '>=8'} + + strip-ansi@6.0.1: + resolution: {integrity: sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==} + engines: {node: '>=8'} + + tslib@2.8.1: + resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==} + + undici-types@7.18.2: + resolution: {integrity: sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w==} + + usb@2.17.0: + resolution: {integrity: sha512-UuFgrlglgDn5ll6d5l7kl3nDb2Yx43qLUGcDq+7UNLZLtbNug0HZBb2Xodhgx2JZB1LqvU+dOGqLEeYUeZqsHg==} + engines: {node: '>=12.22.0 <13.0 || >=14.17.0'} + + wrap-ansi@7.0.0: + resolution: {integrity: sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==} + engines: {node: '>=10'} + + y18n@5.0.8: + resolution: {integrity: sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==} + engines: {node: '>=10'} + + yargs-parser@21.1.1: + resolution: {integrity: sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==} + engines: {node: '>=12'} + + yargs@17.7.2: + resolution: {integrity: sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w==} + engines: {node: '>=12'} + +snapshots: + + '@emnapi/runtime@1.11.1': + dependencies: + tslib: 2.8.1 + optional: true + + '@img/colour@1.1.0': {} + + '@img/sharp-darwin-arm64@0.35.2': + optionalDependencies: + '@img/sharp-libvips-darwin-arm64': 1.3.1 + optional: true + + '@img/sharp-darwin-x64@0.35.2': + optionalDependencies: + '@img/sharp-libvips-darwin-x64': 1.3.1 + optional: true + + '@img/sharp-freebsd-wasm32@0.35.2': + dependencies: + '@img/sharp-wasm32': 0.35.2 + optional: true + + '@img/sharp-libvips-darwin-arm64@1.3.1': + optional: true + + '@img/sharp-libvips-darwin-x64@1.3.1': + optional: true + + '@img/sharp-libvips-linux-arm64@1.3.1': + optional: true + + '@img/sharp-libvips-linux-arm@1.3.1': + optional: true + + '@img/sharp-libvips-linux-ppc64@1.3.1': + optional: true + + '@img/sharp-libvips-linux-riscv64@1.3.1': + optional: true + + '@img/sharp-libvips-linux-s390x@1.3.1': + optional: true + + '@img/sharp-libvips-linux-x64@1.3.1': + optional: true + + '@img/sharp-libvips-linuxmusl-arm64@1.3.1': + optional: true + + '@img/sharp-libvips-linuxmusl-x64@1.3.1': + optional: true + + '@img/sharp-linux-arm64@0.35.2': + optionalDependencies: + '@img/sharp-libvips-linux-arm64': 1.3.1 + optional: true + + '@img/sharp-linux-arm@0.35.2': + optionalDependencies: + '@img/sharp-libvips-linux-arm': 1.3.1 + optional: true + + '@img/sharp-linux-ppc64@0.35.2': + optionalDependencies: + '@img/sharp-libvips-linux-ppc64': 1.3.1 + optional: true + + '@img/sharp-linux-riscv64@0.35.2': + optionalDependencies: + '@img/sharp-libvips-linux-riscv64': 1.3.1 + optional: true + + '@img/sharp-linux-s390x@0.35.2': + optionalDependencies: + '@img/sharp-libvips-linux-s390x': 1.3.1 + optional: true + + '@img/sharp-linux-x64@0.35.2': + optionalDependencies: + '@img/sharp-libvips-linux-x64': 1.3.1 + optional: true + + '@img/sharp-linuxmusl-arm64@0.35.2': + optionalDependencies: + '@img/sharp-libvips-linuxmusl-arm64': 1.3.1 + optional: true + + '@img/sharp-linuxmusl-x64@0.35.2': + optionalDependencies: + '@img/sharp-libvips-linuxmusl-x64': 1.3.1 + optional: true + + '@img/sharp-wasm32@0.35.2': + dependencies: + '@emnapi/runtime': 1.11.1 + optional: true + + '@img/sharp-webcontainers-wasm32@0.35.2': + dependencies: + '@img/sharp-wasm32': 0.35.2 + optional: true + + '@img/sharp-win32-arm64@0.35.2': + optional: true + + '@img/sharp-win32-ia32@0.35.2': + optional: true + + '@img/sharp-win32-x64@0.35.2': + optional: true + + '@jsr/clo__lib@3.0.0': {} + + '@mdi/svg@7.4.47': {} + + '@types/node@25.5.0': + dependencies: + undici-types: 7.18.2 + + '@types/w3c-web-usb@1.0.13': {} + + ansi-regex@5.0.1: {} + + ansi-styles@4.3.0: + dependencies: + color-convert: 2.0.1 + + cliui@8.0.1: + dependencies: + string-width: 4.2.3 + strip-ansi: 6.0.1 + wrap-ansi: 7.0.0 + + color-convert@2.0.1: + dependencies: + color-name: 1.1.4 + + color-name@1.1.4: {} + + detect-libc@2.1.2: {} + + emoji-regex@8.0.0: {} + + escalade@3.2.0: {} + + get-caller-file@2.0.5: {} + + is-fullwidth-code-point@3.0.0: {} + + linq@4.0.3: {} + + lucide-static@1.21.0: {} + + mdi-ts@1.0.3: + dependencies: + linq: 4.0.3 + + node-addon-api@3.2.1: {} + + node-addon-api@8.6.0: {} + + node-gyp-build@4.8.4: {} + + node-hid@3.3.0: + dependencies: + node-addon-api: 3.2.1 + pkg-prebuilds: 1.0.0 + + pkg-prebuilds@1.0.0: + dependencies: + yargs: 17.7.2 + + require-directory@2.1.1: {} + + semver@7.8.5: {} + + sharp@0.35.2: + dependencies: + '@img/colour': 1.1.0 + detect-libc: 2.1.2 + semver: 7.8.5 + optionalDependencies: + '@img/sharp-darwin-arm64': 0.35.2 + '@img/sharp-darwin-x64': 0.35.2 + '@img/sharp-freebsd-wasm32': 0.35.2 + '@img/sharp-libvips-darwin-arm64': 1.3.1 + '@img/sharp-libvips-darwin-x64': 1.3.1 + '@img/sharp-libvips-linux-arm': 1.3.1 + '@img/sharp-libvips-linux-arm64': 1.3.1 + '@img/sharp-libvips-linux-ppc64': 1.3.1 + '@img/sharp-libvips-linux-riscv64': 1.3.1 + '@img/sharp-libvips-linux-s390x': 1.3.1 + '@img/sharp-libvips-linux-x64': 1.3.1 + '@img/sharp-libvips-linuxmusl-arm64': 1.3.1 + '@img/sharp-libvips-linuxmusl-x64': 1.3.1 + '@img/sharp-linux-arm': 0.35.2 + '@img/sharp-linux-arm64': 0.35.2 + '@img/sharp-linux-ppc64': 0.35.2 + '@img/sharp-linux-riscv64': 0.35.2 + '@img/sharp-linux-s390x': 0.35.2 + '@img/sharp-linux-x64': 0.35.2 + '@img/sharp-linuxmusl-arm64': 0.35.2 + '@img/sharp-linuxmusl-x64': 0.35.2 + '@img/sharp-webcontainers-wasm32': 0.35.2 + '@img/sharp-win32-arm64': 0.35.2 + '@img/sharp-win32-ia32': 0.35.2 + '@img/sharp-win32-x64': 0.35.2 + + string-width@4.2.3: + dependencies: + emoji-regex: 8.0.0 + is-fullwidth-code-point: 3.0.0 + strip-ansi: 6.0.1 + + strip-ansi@6.0.1: + dependencies: + ansi-regex: 5.0.1 + + tslib@2.8.1: + optional: true + + undici-types@7.18.2: {} + + usb@2.17.0: + dependencies: + '@types/w3c-web-usb': 1.0.13 + node-addon-api: 8.6.0 + node-gyp-build: 4.8.4 + + wrap-ansi@7.0.0: + dependencies: + ansi-styles: 4.3.0 + string-width: 4.2.3 + strip-ansi: 6.0.1 + + y18n@5.0.8: {} + + yargs-parser@21.1.1: {} + + yargs@17.7.2: + dependencies: + cliui: 8.0.1 + escalade: 3.2.0 + get-caller-file: 2.0.5 + require-directory: 2.1.1 + string-width: 4.2.3 + y18n: 5.0.8 + yargs-parser: 21.1.1 diff --git a/control/pnpm-workspace.yaml b/control/pnpm-workspace.yaml new file mode 100644 index 0000000000000000000000000000000000000000..538282ada29c55163e5ec29fe05f31f008a61b84 --- /dev/null +++ b/control/pnpm-workspace.yaml @@ -0,0 +1,2 @@ +onlyBuiltDependencies: + - node-hid diff --git a/control/readme.md b/control/readme.md new file mode 100644 index 0000000000000000000000000000000000000000..290e622c206328884fa1fb18859924d20f6d2cdb --- /dev/null +++ b/control/readme.md @@ -0,0 +1,116 @@ +# Clover Control + +This is a set of tools to let additional hardware devices integrate with +creative applications on a Mac device. Additionally, this repo contains a lot of +my own tools and scripts I use in the Music/Video creative processes. + +In addition to the primary keybinding system and personal software +configurations, this project can be used as a library to use the control +primitives directly (either to build your own hardware integrations, or to +control the software). This can be done by installing this repo as a `pnpm` git +dependency in your project. + +**NOTE**: These tools only work on macOS. I don't have interest in maintaining +other configurations. + +## Hardware + +### DaVinci Resolve Speed Editor + +A $200 dual-hardware system. + +### DaVinci Resolve Speed Editor + +A $300 bundle containing Fusion Studio and the control surface, it's a great +deal. There is a large, high resolution knob, as well as many keys with some +having lights. This repo includes an SDK to reprogram it to be useful in any +program. **Note**: It is unused as of 2026-06-24. + +![DaVinci Resolve Speed Editor](./docs/speed-editor.jpg) + +## Software + +TODO: + +- Fusion +- Blender? +- Krita? +- The Finder +- QuickTime player + +### macOS (`Mac.ts`) + +Bind to the Mac desktop interface. + +```ts +const mac = await Mac.open(); + +mac.on("app-change", (bundle) => { + console.info("Current App: " + bundle); +}); +``` + +### REAPER + +With the help of an OSC extension, REAPER can be controlled with TypeScript. + +```ts +import { Reaper } from "@clo/clover-control/Reaper"; + +const reaper = new Reaper(); +reaper.on("transport", (transport) => { + console.info( + transport.recording + ? "You are recording" + : transport.playing + ? "Playing" + : "Stopped", + ); +}); +``` + +Setup: + +- Start up Clover Control / `new Reaper()` +- Navigate to REAPER Settings (`Cmd+,`) +- Click `Control/OSC/web` on the left side panel +- Press `Add` + - Control surface mode: `OSC (Open Sound Control)` + - Device name: `Clover Automation` + - Pattern config: `CloverAutomation` + - Mode: `Configure device IP+local port` + - Device port: `58001` + - Device IP: `127.0.0.1` + - Local listen port: `58000` + - Local IP: (default) + - Allow binding messages to REAPER actions and FX learn + +## Config Format + +The main entrypoint loads config files from `./config`, which each apply to one +application. In this example, it configures Reaper to integrate with the Speed +Editor. The provided instance of hardware devices are wrapper objects that apply +the binds only when the program is active. This way, there aren't situations +with multiple readers conflicting. + +```ts +import * as config from "#config"; +import { Reaper } from "@clo/clover-control/Reaper"; + +export default config.forApp("com.cockos.reaper", ({ speededitor, mac }) => { + const reaper = new Reaper(); + + // Sync state to LEDs + reaper.on("transport", (transport) => { + speededitor.leds.audioOnly = transport.recording; + }); + + // Keyboard Actions + speededitor.onPress("stopPlay", () => { + reaper.runAction("transport-play-stop"); + }); + speededitor.onPress("audioOnly", () => { + reaper.runAction("transport-record"); + }); +}); +``` diff --git a/control/src/Dialpad.ts b/control/src/Dialpad.ts new file mode 100644 index 0000000000000000000000000000000000000000..5b4ef3e86f1b7c4a3e701abb9196306c860115cd --- /dev/null +++ b/control/src/Dialpad.ts @@ -0,0 +1,208 @@ +import { Events } from "@clo/lib/Events.ts"; +import type { Dispose } from "@clo/lib/ts.ts"; + +// The MX Creative Console Dialpad pairs over Bluetooth as a Logitech HID++ +// device. It does NOT need HID++ feature access: it streams a single 8-byte +// input report (id 0x02) that decodes cleanly. Discovered by sniffing — see +// examples/hid-sniff.ts. +// +// 02 buttons -- -- -- -- spin rotate +// b1 b6 b7 +// +// `rotate` (main dial) and `spin` (knob) are signed int8 deltas; `buttons` is a +// bitmask. NOTE: until the OS-seize phase, macOS also consumes these reports +// (the dial scrolls, the buttons act as mouse buttons). +const LOGITECH_VENDOR_ID = 0x046d; +const DIALPAD_PRODUCT_ID = 0xbc00; + +const REPORT_ID = 0x02; +const BUTTON_BYTE = 1; +const SPIN_BYTE = 6; +const ROTATE_BYTE = 7; + +const buttonIds = ["circle", "triangle", "square", "cross"] as const; + +const BUTTON_BIT_BY_ID = new Map([ + ["square", 0x08], + ["cross", 0x10], + ["circle", 0x20], + ["triangle", 0x40], +]); + +const RECONNECT_INTERVAL_MS = 1000; + +/** + * Node.js bindings for the Logitech MX Creative Console Dialpad (Bluetooth). + */ +export class Dialpad extends Events { + static buttons = buttonIds; + + #options: Required; + #device: import("node-hid").HID | null = null; + #closed = false; + #ready = false; + #reconnectTimer: ReturnType | null = null; + #activeButtons = new Set(); + #lastButtonMask = 0; + + private constructor(options: Dialpad.Options = {}) { + super(); + this.#options = { + vendorId: options.vendorId ?? LOGITECH_VENDOR_ID, + productId: options.productId ?? DIALPAD_PRODUCT_ID, + path: options.path ?? null, + }; + } + + static async open(options: Dialpad.Options = {}) { + const dialpad = new Dialpad(options); + await dialpad.#start(); + return dialpad; + } + + get connected(): boolean { + return this.#ready; + } + + onPress(button: Dialpad.Button, listener: () => void): Dispose { + return this.on("keypress", (code) => { + if (button === code) listener(); + }); + } + + close() { + if (this.#closed) return; + this.#closed = true; + if (this.#reconnectTimer) clearInterval(this.#reconnectTimer); + this.#reconnectTimer = null; + this.#disconnect(false); + this.emit("close"); + } + + async #start() { + await this.#connect(); + // Bluetooth devices don't raise `usb` hotplug events, so poll instead. + this.#reconnectTimer = setInterval(() => { + if (!this.#device && !this.#closed) void this.#connect(); + }, RECONNECT_INTERVAL_MS); + this.#reconnectTimer.unref?.(); + } + + async #connect() { + if (this.#device || this.#closed) return; + + const { devices, HID } = await import("node-hid"); + const match = devices().find( + (device) => + device.vendorId === this.#options.vendorId + && device.productId === this.#options.productId + && (this.#options.path ? device.path === this.#options.path : true) + && Boolean(device.path), + ); + if (!match?.path) return; + + try { + const device = new HID(match.path); + this.#device = device; + device.on("data", (report) => { + if (this.#device === device) this.#handleReport(report); + }); + device.on("error", (error) => { + if (this.#device === device) this.#handleDeviceError(error); + }); + this.#ready = true; + this.emit("connect"); + } catch { + this.#device = null; + // Will retry on the next poll tick. + } + } + + #handleReport(report: Buffer | number[]) { + const bytes = Uint8Array.from(report); + if (bytes[0] !== REPORT_ID) return; + + const rotate = toInt8(bytes[ROTATE_BYTE] ?? 0); + if (rotate !== 0) this.emit("rotate", rotate); + + const spin = toInt8(bytes[SPIN_BYTE] ?? 0); + if (spin !== 0) this.emit("spin", spin); + + const mask = bytes[BUTTON_BYTE] ?? 0; + if (mask !== this.#lastButtonMask) { + this.#lastButtonMask = mask; + this.#applyButtonState(mask); + } + } + + #applyButtonState(mask: number) { + const next = new Set(); + for (const [button, bit] of BUTTON_BIT_BY_ID) { + if (mask & bit) next.add(button); + } + + for (const button of this.#activeButtons) { + if (!next.has(button)) this.emit("keyup", button); + } + for (const button of next) { + if (!this.#activeButtons.has(button)) { + this.emit("keydown", button); + this.emit("keypress", button); + } + } + + this.#activeButtons = next; + this.emit("key", [...next]); + } + + #handleDeviceError(_error: unknown) { + this.#disconnect(true); + } + + #disconnect(emitEvent: boolean) { + const device = this.#device; + this.#device = null; + this.#ready = false; + this.#activeButtons.clear(); + this.#lastButtonMask = 0; + if (device) { + device.removeAllListeners("data"); + device.removeAllListeners("error"); + try { + device.close(); + } catch { + // Ignore close races when the device disappears mid-reconnect. + } + } + if (emitEvent) this.emit("disconnect"); + } +} + +function toInt8(byte: number): number { + return byte > 127 ? byte - 256 : byte; +} + +export declare namespace Dialpad { + export type Button = typeof buttonIds[number]; + + export interface Options { + vendorId?: number; + productId?: number; + path?: string | null; + } + + export type EventMap = { + "connect": []; + "disconnect": []; + "close": []; + "error": [error: unknown]; + /** Main dial delta (signed, clockwise positive). */ + "rotate": [delta: number]; + /** Up/down knob delta (signed, up positive). */ + "spin": [delta: number]; + "key": [activeButtons: ReadonlyArray