-- @description Dyn + EQ Fix -- @version 0.13.0 -- @author Kraakwerk -- @about -- Floating item gain rider and EQ-fix tool for selected audio items. -- Writes visible take volume envelope points inside the item/take. local SCRIPT_NAME = "Dyn + EQ Fix" local SCRIPT_VERSION = "0.13.0" local EXT_SECTION = "Kraakwerk_Dyn_EQ_Fix" local function msg(text) reaper.ShowMessageBox(text, SCRIPT_NAME, 0) end local function check_runtime_requirements() local required_reaper_functions = { "CountEnvelopePoints", "CountSelectedMediaItems", "CreateTakeAudioAccessor", "DeleteEnvelopePointEx", "DeleteEnvelopePointRange", "DestroyAudioAccessor", "EnumerateFiles", "Envelope_SortPoints", "GetActiveTake", "GetAudioAccessorEndTime", "GetAudioAccessorSamples", "GetAudioAccessorStartTime", "GetEnvelopeStateChunk", "GetExtState", "GetMediaItemInfo_Value", "GetMediaItemTakeInfo_Value", "GetMediaItemTake_Source", "GetResourcePath", "GetSelectedMediaItem", "GetSet_LoopTimeRange", "GetTakeEnvelopeByName", "GetUserInputs", "InsertEnvelopePoint", "Main_OnCommand", "OnPlayButton", "PreventUIRefresh", "ScaleToEnvelopeMode", "SetEditCurPos", "SetEnvelopeStateChunk", "SetExtState", "SetMediaItemSelected", "ShowMessageBox", "TakeFX_AddByName", "TakeFX_Delete", "TakeFX_GetCount", "TakeFX_GetEnvelope", "TakeFX_GetFXName", "TakeFX_GetFormattedParamValue", "TakeFX_GetParamNormalized", "TakeFX_SetNamedConfigParm", "TakeFX_SetParamNormalized", "TakeIsMIDI", "Undo_BeginBlock", "Undo_EndBlock", "UpdateArrange", "defer", "format_timestr_pos", "new_array", "time_precise", } local missing = {} for _, name in ipairs(required_reaper_functions) do if type(reaper[name]) ~= "function" then missing[#missing + 1] = "reaper." .. name end end if type(gfx) ~= "table" then missing[#missing + 1] = "gfx" else for _, name in ipairs({ "init", "setfont", "set", "rect", "drawstr", "measurestr", "circle", "update", "getchar" }) do if type(gfx[name]) ~= "function" then missing[#missing + 1] = "gfx." .. name end end end if #missing > 0 then msg("This REAPER installation is missing required ReaScript interface functions:\n\n" .. table.concat(missing, "\n") .. "\n\nInstall/update REAPER and load this script from REAPER's Action List.") return false end return true end if not check_runtime_requirements() then return end local function db_to_gain(db) return 10 ^ (db / 20) end local function gain_to_db(gain) if gain <= 0 then return -150 end return 20 * (math.log(gain) / math.log(10)) end local function clamp(value, low, high) if value < low then return low end if value > high then return high end return value end local function next_power_of_two(value) local power = 1 while power < value do power = power * 2 end return power end local function join_path(...) local path = table.concat({ ... }, "/") return path:gsub("//+", "/") end local function ensure_dir(path) reaper.RecursiveCreateDirectory(path, 0) end local function sanitize_filename(name) name = tostring(name or ""):gsub("^%s+", ""):gsub("%s+$", "") name = name:gsub("[/:\\%*%?\"<>|]", "-") name = name:gsub("%s+", " ") if name == "" then name = "Preset" end return name end local function read_number(key, default_value) local value = tonumber(reaper.GetExtState(EXT_SECTION, key)) if value == nil then return default_value end return value end local function read_bool(key, default_value) local value = reaper.GetExtState(EXT_SECTION, key) if value == "" then return default_value end return value == "1" end local function write_setting(key, value) if type(value) == "boolean" then reaper.SetExtState(EXT_SECTION, key, value and "1" or "0", true) else reaper.SetExtState(EXT_SECTION, key, tostring(value), true) end end local settings = { target_db = read_number("target_db", -18), detect_threshold_db = read_number("detect_threshold_db", -50), write_threshold_db = read_number("write_threshold_db", 0.3), max_down_db = read_number("max_down_db", 12), max_up_db = read_number("max_up_db", 6), max_change_db = read_number("max_change_db", 2), smooth_pct = read_number("smooth_pct", 45), peak_ceiling_db = read_number("peak_ceiling_db", -8), peak_control_pct = read_number("peak_control_pct", 35), lookahead_ms = read_number("lookahead_ms", 40), window_ms = read_number("window_ms", 50), point_spacing_ms = read_number("point_spacing_ms", 100), check_sensitivity = read_number("check_sensitivity", 35), check_min_duration_ms = read_number("check_min_duration_ms", 80), check_mode = read_number("check_mode", 1), user_low_hz = read_number("user_low_hz", 5000), user_high_hz = read_number("user_high_hz", 10000), eq_max_dip_db = read_number("eq_max_dip_db", 9), eq_dip_multiplier = read_number("eq_dip_multiplier", 1), eq_lookahead_ms = read_number("eq_lookahead_ms", 15), eq_attack_ms = read_number("eq_attack_ms", 20), eq_release_ms = read_number("eq_release_ms", 90), eq_band_type = read_number("eq_band_type", 0), eq_follow_frequency = read_number("eq_follow_frequency", 1), clean_existing = read_bool("clean_existing", true), } local save_check_mode_settings local load_check_mode_settings local volume_setting_keys = { "target_db", "detect_threshold_db", "write_threshold_db", "max_down_db", "max_up_db", "max_change_db", "smooth_pct", "peak_ceiling_db", "peak_control_pct", "lookahead_ms", "window_ms", "point_spacing_ms", } local volume_default_presets = { { name = "Natural", values = { target_db = -18, detect_threshold_db = -50, write_threshold_db = 0.3, max_down_db = 12, max_up_db = 6, max_change_db = 2, smooth_pct = 45, peak_ceiling_db = -8, peak_control_pct = 35, lookahead_ms = 40, window_ms = 50, point_spacing_ms = 100, }, }, { name = "Gentle", values = { target_db = -18, detect_threshold_db = -48, write_threshold_db = 0.5, max_down_db = 8, max_up_db = 3, max_change_db = 1.2, smooth_pct = 65, peak_ceiling_db = -7, peak_control_pct = 20, lookahead_ms = 45, window_ms = 70, point_spacing_ms = 120, }, }, { name = "Firm", values = { target_db = -18, detect_threshold_db = -54, write_threshold_db = 0.2, max_down_db = 16, max_up_db = 8, max_change_db = 3, smooth_pct = 35, peak_ceiling_db = -9, peak_control_pct = 45, lookahead_ms = 45, window_ms = 40, point_spacing_ms = 80, }, }, { name = "Peak safe", values = { target_db = -18, detect_threshold_db = -54, write_threshold_db = 0.2, max_down_db = 18, max_up_db = 4, max_change_db = 4, smooth_pct = 30, peak_ceiling_db = -10, peak_control_pct = 75, lookahead_ms = 55, window_ms = 30, point_spacing_ms = 60, }, }, } local function save_settings() for key, value in pairs(settings) do write_setting(key, value) end if save_check_mode_settings then save_check_mode_settings() end end local function validate_settings() if settings.window_ms <= 0 or settings.point_spacing_ms <= 0 then return false, "Analysis window and point spacing must be greater than 0." end if settings.max_up_db < 0 or settings.max_down_db < 0 or settings.max_change_db < 0 or settings.lookahead_ms < 0 then return false, "Maximum gain values must be positive." end if settings.write_threshold_db < 0 then return false, "Write threshold must be positive." end if settings.peak_control_pct < 0 then return false, "Peak control must be positive." end return true, nil end local function get_sample_rate() local project_sr = reaper.GetSetProjectInfo(0, "PROJECT_SRATE", 0, false) if project_sr and project_sr > 0 then return math.floor(project_sr + 0.5) end local ok_device, device_sr = reaper.GetAudioDeviceInfo("SRATE") local sr = ok_device and tonumber(device_sr) or nil if sr and sr > 0 then return math.floor(sr + 0.5) end return 44100 end local function get_source_channels(take) local source = reaper.GetMediaItemTake_Source(take) if not source then return 1 end local channels = reaper.GetMediaSourceNumChannels(source) if not channels or channels < 1 then return 1 end return math.min(channels, 2) end local function ensure_take_volume_envelope(item, take) local env = reaper.GetTakeEnvelopeByName(take, "Volume") if env then return env end local selected_items = {} local selected_count = reaper.CountSelectedMediaItems(0) for i = 0, selected_count - 1 do selected_items[#selected_items + 1] = reaper.GetSelectedMediaItem(0, i) end reaper.SelectAllMediaItems(0, false) reaper.SetMediaItemSelected(item, true) reaper.Main_OnCommand(40693, 0) -- Take: Toggle take volume envelope reaper.SelectAllMediaItems(0, false) for _, selected_item in ipairs(selected_items) do reaper.SetMediaItemSelected(selected_item, true) end return reaper.GetTakeEnvelopeByName(take, "Volume") end local function make_envelope_visible(env) local ok_chunk, chunk = reaper.GetEnvelopeStateChunk(env, "", false) if not ok_chunk then return end chunk = chunk:gsub("ACT [^\n]*", "ACT 1 -1") chunk = chunk:gsub("VIS [^\n]*", "VIS 1 1 1") reaper.SetEnvelopeStateChunk(env, chunk, false) end local function hide_envelope(env) local ok_chunk, chunk = reaper.GetEnvelopeStateChunk(env, "", false) if not ok_chunk then return end chunk = chunk:gsub("ACT [^\n]*", "ACT 0 -1") chunk = chunk:gsub("VIS [^\n]*", "VIS 0 0 0") reaper.SetEnvelopeStateChunk(env, chunk, false) end local function set_fx_envelope_default(env, value) local ok_chunk, chunk = reaper.GetEnvelopeStateChunk(env, "", false) if not ok_chunk then return end local replacement = "%1" .. string.format("%.6f", value) chunk = chunk:gsub("( peak then peak = abs_sample end sum = sum + sample * sample end local rms = math.sqrt(sum / sample_count) local level_db = gain_to_db(rms) if level_db >= settings.detect_threshold_db then gain_db = settings.target_db - level_db gain_db = clamp(gain_db, -settings.max_down_db, settings.max_up_db) end local peak_control = clamp(settings.peak_control_pct, 0, 100) / 100 if peak_control > 0 and peak > 0 then local peak_db = gain_to_db(peak) if peak_db > settings.peak_ceiling_db then local peak_gain_db = (settings.peak_ceiling_db - peak_db) * peak_control gain_db = math.min(gain_db, peak_gain_db) gain_db = clamp(gain_db, -settings.max_down_db, settings.max_up_db) end end end raw_points[#raw_points + 1] = { time = t, gain_db = gain_db } t = t + step_seconds end reaper.DestroyAudioAccessor(accessor) local smoothed_points = smooth_gain_series(raw_points) local points = {} local previous_gain_db = nil local last_written_gain_db = nil local lookahead_seconds = settings.lookahead_ms / 1000 for _, point in ipairs(smoothed_points) do local gain_db = point.gain_db if previous_gain_db ~= nil then gain_db = clamp( gain_db, previous_gain_db - settings.max_change_db, previous_gain_db + settings.max_change_db ) end if not last_written_gain_db or math.abs(gain_db - last_written_gain_db) >= settings.write_threshold_db then local write_time = clamp(point.time - lookahead_seconds, 0, analyze_len) if #points > 0 and write_time <= points[#points].time + 0.000001 then points[#points].gain_db = gain_db else points[#points + 1] = { time = write_time, gain_db = gain_db } end last_written_gain_db = gain_db end previous_gain_db = gain_db end if #points == 0 or points[#points].time < analyze_len then points[#points + 1] = { time = analyze_len, gain_db = previous_gain_db } end return points, nil end local function measure_item_rms(item, take, sample_rate) local accessor = reaper.CreateTakeAudioAccessor(take) if not accessor then return nil, nil, "audio accessor could not be created" end local item_len = reaper.GetMediaItemInfo_Value(item, "D_LENGTH") local start_time = reaper.GetAudioAccessorStartTime(accessor) local end_time = reaper.GetAudioAccessorEndTime(accessor) local analyze_len = math.min(item_len, math.max(0, end_time - start_time)) if analyze_len <= 0 then reaper.DestroyAudioAccessor(accessor) return nil, nil, "no analyzable audio" end local channels = get_source_channels(take) local block_seconds = 0.05 local block_samples = math.max(1, math.floor(sample_rate * block_seconds)) local buffer = reaper.new_array(block_samples * channels) local total_sum = 0 local total_count = 0 local peak = 0 local t = 0 while t < analyze_len do local seconds_left = analyze_len - t local samples_to_read = math.max(1, math.min(block_samples, math.floor(sample_rate * seconds_left))) buffer.clear() local ret = reaper.GetAudioAccessorSamples(accessor, sample_rate, channels, start_time + t, samples_to_read, buffer) if ret == 1 then local sample_count = samples_to_read * channels for i = 1, sample_count do local sample = buffer[i] local abs_sample = math.abs(sample) if abs_sample > peak then peak = abs_sample end total_sum = total_sum + sample * sample end total_count = total_count + sample_count end t = t + (samples_to_read / sample_rate) end reaper.DestroyAudioAccessor(accessor) if total_count == 0 then return nil, nil, "no usable audio signal found" end return gain_to_db(math.sqrt(total_sum / total_count)), gain_to_db(peak), nil end local function check_selected_rms() local selected_items = get_selected_items() if #selected_items == 0 then return nil, "Select one or more audio items first." end local sample_rate = get_sample_rate() local total_power = 0 local valid = 0 local peak_db = -150 local skipped = {} for _, item in ipairs(selected_items) do local take = item and reaper.GetActiveTake(item) if not take then skipped[#skipped + 1] = "item without active take" elseif reaper.TakeIsMIDI(take) then skipped[#skipped + 1] = "MIDI item skipped" else local rms_db, item_peak_db, err = measure_item_rms(item, take, sample_rate) if rms_db then total_power = total_power + db_to_gain(rms_db) * db_to_gain(rms_db) peak_db = math.max(peak_db, item_peak_db or -150) valid = valid + 1 elseif err then skipped[#skipped + 1] = err end end end if valid == 0 then return nil, "No usable audio signal found." end local rms_db = gain_to_db(math.sqrt(total_power / valid)) local result = string.format("Selected RMS: %.1f dBFS Peak: %.1f dBFS", rms_db, peak_db) if #skipped > 0 then result = result .. " Skipped: " .. table.concat(skipped, ", ") end return rms_db, peak_db, result end local function clean_selected_items() local selected_items = get_selected_items() if #selected_items == 0 then return "Select one or more audio items first." end local cleaned = 0 local skipped = {} reaper.Undo_BeginBlock() reaper.PreventUIRefresh(1) for _, item in ipairs(selected_items) do local take = item and reaper.GetActiveTake(item) if not take then skipped[#skipped + 1] = "item without active take" elseif reaper.TakeIsMIDI(take) then skipped[#skipped + 1] = "MIDI item skipped" else local env = reaper.GetTakeEnvelopeByName(take, "Volume") if env then clear_envelope_points(env) make_envelope_visible(env) cleaned = cleaned + 1 else skipped[#skipped + 1] = "item without volume envelope" end end end reaper.PreventUIRefresh(-1) reaper.UpdateArrange() reaper.Undo_EndBlock("Dyn + EQ Fix clean item volume automation", -1) local result = "Clean: " .. cleaned .. " item(s)." if #skipped > 0 then result = result .. " Skipped: " .. table.concat(skipped, ", ") end return result end local function apply_to_selected_items() local ok, err = validate_settings() if not ok then return err end save_settings() local selected_items = get_selected_items() if #selected_items == 0 then return "Select one or more audio items first." end local sample_rate = get_sample_rate() local processed = 0 local skipped = {} reaper.Undo_BeginBlock() reaper.PreventUIRefresh(1) for _, item in ipairs(selected_items) do local take = item and reaper.GetActiveTake(item) if not take then skipped[#skipped + 1] = "item without active take" elseif reaper.TakeIsMIDI(take) then skipped[#skipped + 1] = "MIDI item skipped" else local env = ensure_take_volume_envelope(item, take) if not env then skipped[#skipped + 1] = "volume envelope could not be created" else make_envelope_visible(env) local points, point_err = analyze_item(item, take, sample_rate) if not points then skipped[#skipped + 1] = point_err else local scaling_mode = reaper.GetEnvelopeScalingMode(env) if settings.clean_existing then clear_envelope_points(env) end for _, point in ipairs(points) do local raw_value = reaper.ScaleToEnvelopeMode(scaling_mode, db_to_gain(point.gain_db)) reaper.InsertEnvelopePoint(env, point.time, raw_value, 0, 0, false, true) end reaper.Envelope_SortPoints(env) processed = processed + 1 end end end end reaper.PreventUIRefresh(-1) reaper.UpdateArrange() reaper.Undo_EndBlock(SCRIPT_NAME, -1) local result = "Apply: " .. processed .. " item(s)." if #skipped > 0 then result = result .. " Skipped: " .. table.concat(skipped, ", ") end return result end local check_modes = { { key = "plosive", label = "Plosive", low = 20, high = 160, reference_low = 250, reference_high = 3000, base_threshold = 0.52, min_level_db = -56, eq_band = 1 }, { key = "boom", label = "Boom", low = 120, high = 300, reference_low = 500, reference_high = 2500, base_threshold = 0.50, min_level_db = -54, eq_band = 2 }, { key = "harsh", label = "Harsh", low = 2500, high = 5000, reference_low = 500, reference_high = 2000, base_threshold = 0.45, min_level_db = -52, eq_band = 3 }, { key = "s", label = "S", low = 5000, high = 10000, reference_low = 1000, reference_high = 4500, base_threshold = 0.46, min_level_db = -52, eq_band = 4 }, { key = "user", label = "User", low = 5000, high = 10000, reference_low = 300, reference_high = 3500, base_threshold = 0.46, min_level_db = -58, eq_band = 4, user = true }, } local check_setting_keys = { "check_sensitivity", "check_min_duration_ms", "user_low_hz", "user_high_hz", "eq_max_dip_db", "eq_dip_multiplier", "eq_lookahead_ms", "eq_attack_ms", "eq_release_ms", "eq_band_type", "eq_follow_frequency", } local check_mode_defaults = { s = { check_sensitivity = 35, check_min_duration_ms = 80, user_low_hz = 5000, user_high_hz = 10000, eq_max_dip_db = 9, eq_dip_multiplier = 1, eq_lookahead_ms = 15, eq_attack_ms = 20, eq_release_ms = 90, eq_band_type = 1, eq_follow_frequency = 1 }, harsh = { check_sensitivity = 35, check_min_duration_ms = 45, user_low_hz = 2500, user_high_hz = 5000, eq_max_dip_db = 6, eq_dip_multiplier = 1, eq_lookahead_ms = 5, eq_attack_ms = 8, eq_release_ms = 80, eq_band_type = 8, eq_follow_frequency = 1 }, plosive = { check_sensitivity = 35, check_min_duration_ms = 35, user_low_hz = 20, user_high_hz = 160, eq_max_dip_db = 10, eq_dip_multiplier = 1, eq_lookahead_ms = 8, eq_attack_ms = 5, eq_release_ms = 140, eq_band_type = 0, eq_follow_frequency = 1 }, boom = { check_sensitivity = 35, check_min_duration_ms = 120, user_low_hz = 120, user_high_hz = 300, eq_max_dip_db = 6, eq_dip_multiplier = 1, eq_lookahead_ms = 10, eq_attack_ms = 30, eq_release_ms = 220, eq_band_type = 8, eq_follow_frequency = 1 }, user = { check_sensitivity = 35, check_min_duration_ms = 80, user_low_hz = 5000, user_high_hz = 10000, eq_max_dip_db = 9, eq_dip_multiplier = 1, eq_lookahead_ms = 15, eq_attack_ms = 20, eq_release_ms = 90, eq_band_type = 8, eq_follow_frequency = 1 }, } local eq_band_types = { { label = "Bell", value = 8 }, { label = "Low", value = 0 }, { label = "High", value = 1 }, } local function find_check_mode_index_by_key(mode_key) for index, mode in ipairs(check_modes) do if mode.key == mode_key then return index end end return nil end local function get_check_mode_key(index) local mode = check_modes[index or math.floor(settings.check_mode or 1)] or check_modes[1] return mode.key end local PRESET_ROOT = join_path(reaper.GetResourcePath(), "Scripts", "Dyn EQ Fix", "Detect Presets") local VOLUME_PRESET_ROOT = join_path(reaper.GetResourcePath(), "Scripts", "Dyn EQ Fix", "Volume Presets") local function check_mode_setting_key(mode_key, setting_key) return "check_" .. mode_key .. "_" .. setting_key end local function get_volume_preset_name() local name = reaper.GetExtState(EXT_SECTION, "volume_preset_name") if name == "" then name = "Natural" end return sanitize_filename(name) end local function set_volume_preset_name(name) reaper.SetExtState(EXT_SECTION, "volume_preset_name", sanitize_filename(name), true) end local function get_volume_preset_path(name) return join_path(VOLUME_PRESET_ROOT, sanitize_filename(name) .. ".ini") end local function find_default_volume_preset(name) local target = sanitize_filename(name):lower() for _, preset in ipairs(volume_default_presets) do if preset.name:lower() == target then return preset end end return nil end local function list_volume_presets() local seen = {} local presets = {} for _, preset in ipairs(volume_default_presets) do presets[#presets + 1] = preset.name seen[preset.name:lower()] = true end local index = 0 while true do local file = reaper.EnumerateFiles(VOLUME_PRESET_ROOT, index) if not file then break end local name = file:match("(.+)%.ini$") if name and not seen[name:lower()] then presets[#presets + 1] = name seen[name:lower()] = true end index = index + 1 end table.sort(presets, function(a, b) return a:lower() < b:lower() end) return presets end local function prompt_volume_preset_name() local current = get_volume_preset_name() local ok, name = reaper.GetUserInputs("Dyn + EQ Fix volume preset", 1, "Preset name:", current) if not ok then return nil end name = sanitize_filename(name) set_volume_preset_name(name) return name end local function apply_volume_preset_values(values) for _, key in ipairs(volume_setting_keys) do if values[key] ~= nil then settings[key] = tonumber(values[key]) or settings[key] end end save_settings() end local function load_volume_preset() local name = get_volume_preset_name() local default_preset = find_default_volume_preset(name) if default_preset then apply_volume_preset_values(default_preset.values) return "Loaded volume preset: " .. name end local file = io.open(get_volume_preset_path(name), "r") if not file then return "Volume preset not found: " .. name end local values = {} for line in file:lines() do local key, value = line:match("^([%w_]+)=(.*)$") if key and value then values[key] = value end end file:close() apply_volume_preset_values(values) return "Loaded volume preset: " .. name end local function save_volume_preset() local name = get_volume_preset_name() ensure_dir(VOLUME_PRESET_ROOT) local file = io.open(get_volume_preset_path(name), "w") if not file then return "Could not save volume preset." end for _, key in ipairs(volume_setting_keys) do file:write(key, "=", tostring(settings[key]), "\n") end file:close() return "Saved volume preset: " .. name end local function step_volume_preset(direction) local presets = list_volume_presets() if #presets == 0 then set_volume_preset_name("Natural") return "No volume presets found." end local current = get_volume_preset_name() local current_index = 1 for i, name in ipairs(presets) do if name == current then current_index = i break end end local next_index = ((current_index - 1 + direction) % #presets) + 1 set_volume_preset_name(presets[next_index]) return "Volume preset: " .. presets[next_index] end local function migrate_legacy_band_type(value) value = math.floor(tonumber(value) or 8) if value == 0 then return 8 end -- old Bell label if value == 1 then return 0 end -- old Low label if value == 2 then return 1 end -- old High label return value end save_check_mode_settings = function() local mode_key = get_check_mode_key() write_setting("check_mode_key", mode_key) write_setting(check_mode_setting_key(mode_key, "eq_band_type_schema"), 2) for _, key in ipairs(check_setting_keys) do write_setting(check_mode_setting_key(mode_key, key), settings[key]) end end load_check_mode_settings = function(index) local mode_key = get_check_mode_key(index) local defaults = check_mode_defaults[mode_key] or check_mode_defaults.s local band_type_schema = read_number(check_mode_setting_key(mode_key, "eq_band_type_schema"), 1) for _, key in ipairs(check_setting_keys) do settings[key] = read_number(check_mode_setting_key(mode_key, key), defaults[key] or settings[key]) end if band_type_schema < 2 then settings.eq_band_type = migrate_legacy_band_type(settings.eq_band_type) end end local function get_preset_dir(mode_key) return join_path(PRESET_ROOT, mode_key) end local function get_current_preset_name(mode_key) local name = reaper.GetExtState(EXT_SECTION, "preset_" .. mode_key .. "_name") if name == "" then name = "Default" end return sanitize_filename(name) end local function set_current_preset_name(mode_key, name) reaper.SetExtState(EXT_SECTION, "preset_" .. mode_key .. "_name", sanitize_filename(name), true) end local function prompt_current_preset_name() local mode_key = get_check_mode_key() local current = get_current_preset_name(mode_key) local ok, name = reaper.GetUserInputs("Dyn + EQ Fix preset", 1, "Preset name:", current) if not ok then return nil end name = sanitize_filename(name) set_current_preset_name(mode_key, name) return name end local function get_preset_path(mode_key, name) return join_path(get_preset_dir(mode_key), sanitize_filename(name) .. ".ini") end local function list_mode_presets(mode_key) local dir = get_preset_dir(mode_key) local presets = {} local index = 0 while true do local file = reaper.EnumerateFiles(dir, index) if not file then break end local name = file:match("(.+)%.ini$") if name then presets[#presets + 1] = name end index = index + 1 end table.sort(presets, function(a, b) return a:lower() < b:lower() end) return presets end local function save_current_preset() local mode_key = get_check_mode_key() local name = get_current_preset_name(mode_key) local dir = get_preset_dir(mode_key) ensure_dir(dir) local file = io.open(get_preset_path(mode_key, name), "w") if not file then return "Could not save preset." end file:write("mode=", mode_key, "\n") file:write("eq_band_type_schema=2\n") for _, key in ipairs(check_setting_keys) do file:write(key, "=", tostring(settings[key]), "\n") end file:close() set_current_preset_name(mode_key, name) return "Saved preset: " .. name end local function load_current_preset() local mode_key = get_check_mode_key() local name = get_current_preset_name(mode_key) local file = io.open(get_preset_path(mode_key, name), "r") if not file then return "Preset not found: " .. name end local preset_values = {} for line in file:lines() do local key, value = line:match("^([%w_]+)=(.*)$") if key and value then preset_values[key] = value end end file:close() local band_type_schema = tonumber(preset_values.eq_band_type_schema) or 1 for key, value in pairs(preset_values) do if key ~= "mode" and key ~= "eq_band_type_schema" then for _, allowed_key in ipairs(check_setting_keys) do if key == allowed_key then settings[key] = tonumber(value) or settings[key] break end end end end if band_type_schema < 2 then settings.eq_band_type = migrate_legacy_band_type(settings.eq_band_type) end save_check_mode_settings() return "Loaded preset: " .. name end local function delete_current_preset() local mode_key = get_check_mode_key() local name = get_current_preset_name(mode_key) local ok = os.remove(get_preset_path(mode_key, name)) local presets = list_mode_presets(mode_key) set_current_preset_name(mode_key, presets[1] or "Default") if ok then return "Deleted preset: " .. name end return "Preset not found: " .. name end local function step_preset(direction) local mode_key = get_check_mode_key() local presets = list_mode_presets(mode_key) if #presets == 0 then set_current_preset_name(mode_key, "Default") return "No presets for " .. mode_key .. "." end local current = get_current_preset_name(mode_key) local current_index = 1 for i, name in ipairs(presets) do if name == current then current_index = i break end end local next_index = ((current_index - 1 + direction) % #presets) + 1 set_current_preset_name(mode_key, presets[next_index]) return "Preset: " .. presets[next_index] end if reaper.GetExtState(EXT_SECTION, "headless") ~= "1" then local saved_check_mode_key = reaper.GetExtState(EXT_SECTION, "check_mode_key") local saved_check_mode_index = find_check_mode_index_by_key(saved_check_mode_key) if saved_check_mode_index then settings.check_mode = saved_check_mode_index elseif math.floor(settings.check_mode or 1) == 1 then settings.check_mode = find_check_mode_index_by_key("s") or 4 end end local function get_check_mode() local index = math.floor(settings.check_mode or 1) if index < 1 or index > #check_modes then index = 1 end settings.check_mode = index local source = check_modes[index] local mode = {} for key, value in pairs(source) do mode[key] = value end if mode.user then local low = clamp(settings.user_low_hz, 20, 18000) local high = clamp(settings.user_high_hz, 20, 18000) if high < low + 10 then high = low + 10 end mode.low = low mode.high = high end return mode, index end local function get_check_threshold(mode) local sensitivity = clamp(settings.check_sensitivity, 0, 100) return mode.base_threshold * (1.45 - (sensitivity / 120)) end local function merge_check_event(events, new_event) local previous = events[#events] if previous and new_event.start_time - previous.end_time <= 0.06 then previous.end_time = new_event.end_time if new_event.score > previous.score then previous.time = new_event.time previous.score = new_event.score previous.level_db = new_event.level_db end else events[#events + 1] = new_event end end local function analyze_take_events(item, take, sample_rate, mode) local accessor = reaper.CreateTakeAudioAccessor(take) if not accessor then return nil, "audio accessor could not be created" end local item_len = reaper.GetMediaItemInfo_Value(item, "D_LENGTH") local start_time = reaper.GetAudioAccessorStartTime(accessor) local end_time = reaper.GetAudioAccessorEndTime(accessor) local analyze_len = math.min(item_len, math.max(0, end_time - start_time)) if analyze_len <= 0 then reaper.DestroyAudioAccessor(accessor) return nil, "no analyzable audio" end local channels = get_source_channels(take) local fft_size = clamp(next_power_of_two(math.floor(sample_rate * 0.035)), 1024, 4096) local hop_seconds = 0.015 local read_buffer = reaper.new_array(fft_size * channels) local fft_buffer = reaper.new_array(fft_size) local frame_count = 0 local threshold = get_check_threshold(mode) local events = {} local t = 0 while t <= analyze_len do read_buffer.clear() fft_buffer.clear() local ret = reaper.GetAudioAccessorSamples(accessor, sample_rate, channels, start_time + t, fft_size, read_buffer) if ret == 1 then local frame_rms_sum = 0 for i = 1, fft_size do local mono = 0 for ch = 0, channels - 1 do mono = mono + read_buffer[((i - 1) * channels) + ch + 1] end mono = mono / channels local window = 0.5 - 0.5 * math.cos((2 * math.pi * (i - 1)) / (fft_size - 1)) fft_buffer[i] = mono * window frame_rms_sum = frame_rms_sum + mono * mono end local frame_rms = math.sqrt(frame_rms_sum / fft_size) frame_count = frame_count + 1 local level_db = gain_to_db(frame_rms) if level_db >= mode.min_level_db then fft_buffer.fft_real(fft_size, true) local frame_total = 0 local target_power = 0 local reference_power = 0 for bin = 1, (fft_size / 2) - 1 do local freq = (bin * sample_rate) / fft_size local re = fft_buffer[(bin * 2) + 1] or 0 local im = fft_buffer[(bin * 2) + 2] or 0 local power = (re * re) + (im * im) if freq >= 20 and freq <= 12000 then frame_total = frame_total + power if freq >= mode.low and freq <= mode.high then target_power = target_power + power elseif freq >= mode.reference_low and freq <= mode.reference_high then reference_power = reference_power + power end end end if frame_total > 0 and reference_power > 0 then local target_ratio = target_power / frame_total local contrast = target_power / (target_power + reference_power) local score = (target_ratio * 0.65) + (contrast * 0.35) if score >= threshold then merge_check_event(events, { time = t, start_time = t, end_time = t + hop_seconds, score = score, level_db = level_db, }) end end end end t = t + hop_seconds end reaper.DestroyAudioAccessor(accessor) if frame_count == 0 then return nil, "no usable audio signal found" end local filtered_events = {} local min_duration = settings.check_min_duration_ms / 1000 for _, event in ipairs(events) do event.duration = math.max(0, event.end_time - event.start_time) if event.duration >= min_duration then filtered_events[#filtered_events + 1] = event end end return filtered_events, nil end local function check_selected_items() local selected_items = get_selected_items() if #selected_items == 0 then return { lines = { "Select one or more audio items first." }, events = {}, } end local sample_rate = get_sample_rate() local mode = get_check_mode() local found = {} local skipped = {} local item_number = 0 for _, item in ipairs(selected_items) do local take = item and reaper.GetActiveTake(item) if not take then skipped[#skipped + 1] = "item without active take" elseif reaper.TakeIsMIDI(take) then skipped[#skipped + 1] = "MIDI item" else item_number = item_number + 1 local item_position = reaper.GetMediaItemInfo_Value(item, "D_POSITION") local events, err = analyze_take_events(item, take, sample_rate, mode) if not events then skipped[#skipped + 1] = err else for _, event in ipairs(events) do found[#found + 1] = { item_number = item_number, item = item, take = take, item_time = event.time, item_start_time = event.start_time, item_end_time = event.end_time, project_time = item_position + event.time, duration = event.duration, score = event.score, level_db = event.level_db, selected = false, } end end end end table.sort(found, function(a, b) return a.project_time < b.project_time end) local lines = {} lines[#lines + 1] = string.format("Detect: %s Sensitivity: %.0f%% Min: %.0fms", mode.label, settings.check_sensitivity, settings.check_min_duration_ms) lines[#lines + 1] = string.format("Found: %d moment(s)", #found) local max_lines = math.min(#found, 10) for i = 1, max_lines do local event = found[i] lines[#lines + 1] = string.format( "%s item %d %.0fms score %.0f%% level %.1f dB", reaper.format_timestr_pos(event.project_time, "", -1), event.item_number, event.duration * 1000, clamp(event.score, 0, 1) * 100, event.level_db ) end if #found > max_lines then lines[#lines + 1] = "... " .. (#found - max_lines) .. " more" end if #skipped > 0 then lines[#lines + 1] = "Skipped: " .. table.concat(skipped, ", ") end return { lines = lines, events = found, } end local function parse_formatted_number(text) local number = text:match("[-+]?%d+%.?%d*") return tonumber(number) end local function param_norm_for_formatted_value(take, fx, param, target, unit_hint) local low = 0 local high = 1 local best = 0.5 for _ = 1, 24 do local mid = (low + high) / 2 reaper.TakeFX_SetParamNormalized(take, fx, param, mid) local _, formatted = reaper.TakeFX_GetFormattedParamValue(take, fx, param, "") local value = parse_formatted_number(formatted) or 0 local lower = formatted:lower() if unit_hint == "hz" and lower:find("khz", 1, true) then value = value * 1000 end best = mid if value < target then low = mid else high = mid end end return best end local function reaeq_gain_param_norm_from_db(db) if math.abs(db) < 0.000001 then return 0.5 end return clamp(0.5 + (db * 0.02076084), 0, 1) end local function reaeq_gain_env_value_from_db(db) if math.abs(db) < 0.000001 then return 0.25 end return clamp(0.25 + (db * 0.02076084), 0, 1) end local function get_reaeq_band_params(band) local freq_param = (band - 1) * 3 return freq_param, freq_param + 1, freq_param + 2 end local DYN_EQ_FIX_FX_NAME = "Dyn + EQ Fix EQ Dip" local function take_fx_name(take, fx) local _, name = reaper.TakeFX_GetFXName(take, fx, "") return name or "" end local function is_reaeq_name(name) return name:lower():find("reaeq", 1, true) ~= nil end local function find_dynassist_reaeq(take, gain_param) local fx_count = reaper.TakeFX_GetCount(take) for fx = 0, fx_count - 1 do local name = take_fx_name(take, fx) if name:find(DYN_EQ_FIX_FX_NAME, 1, true) then return fx end end for fx = 0, fx_count - 1 do local name = take_fx_name(take, fx) if is_reaeq_name(name) and reaper.TakeFX_GetEnvelope(take, fx, gain_param, false) then return fx end end return nil end local function remove_extra_dynassist_reaeqs(take, keep_fx, gain_param) for fx = reaper.TakeFX_GetCount(take) - 1, keep_fx + 1, -1 do local name = take_fx_name(take, fx) if is_reaeq_name(name) and reaper.TakeFX_GetEnvelope(take, fx, gain_param, false) then reaper.TakeFX_Delete(take, fx) end end end local function get_eq_band_type_value() local value = math.floor(settings.eq_band_type or 0) for _, band_type in ipairs(eq_band_types) do if band_type.value == value then return value end end return 0 end local function get_eq_band_type_label() local value = get_eq_band_type_value() for _, band_type in ipairs(eq_band_types) do if band_type.value == value then return band_type.label end end return "Bell" end local function ensure_dynassist_reaeq(take, band, gain_param) local fx = find_dynassist_reaeq(take, gain_param) if fx then remove_extra_dynassist_reaeqs(take, fx, gain_param) else fx = reaper.TakeFX_AddByName(take, "ReaEQ (Cockos)", 1) end if fx < 0 then fx = reaper.TakeFX_AddByName(take, "ReaEQ", 1) end if fx < 0 then return nil end local config_band = math.max(0, band - 1) reaper.TakeFX_SetNamedConfigParm(take, fx, "renamed_name", DYN_EQ_FIX_FX_NAME) reaper.TakeFX_SetNamedConfigParm(take, fx, "BANDTYPE" .. config_band, tostring(get_eq_band_type_value())) reaper.TakeFX_SetNamedConfigParm(take, fx, "BANDENABLED" .. config_band, "1") return fx end local function delete_current_mode_envelope() local mode = get_check_mode() local eq_band = mode.eq_band or 4 local _, gain_param = get_reaeq_band_params(eq_band) local selected_items = get_selected_items() if #selected_items == 0 then return "Select one or more audio items first." end local cleared = 0 local skipped = 0 reaper.Undo_BeginBlock() reaper.PreventUIRefresh(1) for _, item in ipairs(selected_items) do local take = item and reaper.GetActiveTake(item) local fx = take and find_dynassist_reaeq(take, gain_param) local env = fx and reaper.TakeFX_GetEnvelope(take, fx, gain_param, false) if env then clear_envelope_points(env) hide_envelope(env) reaper.TakeFX_SetParamNormalized(take, fx, gain_param, reaeq_gain_param_norm_from_db(0)) cleared = cleared + 1 else skipped = skipped + 1 end end reaper.PreventUIRefresh(-1) reaper.UpdateArrange() reaper.Undo_EndBlock("Dyn + EQ Fix delete " .. mode.label .. " envelope", -1) local result = string.format("Deleted %s envelope band %d: %d item(s).", mode.label, eq_band, cleared) if skipped > 0 then result = result .. " Missing: " .. skipped end return result end local function apply_eq_dip_to_events(events, keep_existing_points) local selected_events = {} for _, event in ipairs(events) do if event.selected then selected_events[#selected_events + 1] = event end end if #events == 0 then return "No current detect results. Run Detect first." end if #selected_events == 0 then return "No moments selected. Use All or select individual moments." end local mode = get_check_mode() local center_hz = math.sqrt(mode.low * mode.high) local max_dip = clamp(settings.eq_max_dip_db, 0.1, 18) local threshold = get_check_threshold(mode) local processed = 0 local failed = 0 local cache = {} reaper.Undo_BeginBlock() reaper.PreventUIRefresh(1) for _, event in ipairs(selected_events) do local take = event.take if take then local key = tostring(take) local entry = cache[key] if not entry then local eq_band = mode.eq_band or 4 local freq_param, gain_param, width_param = get_reaeq_band_params(eq_band) local fx = ensure_dynassist_reaeq(take, eq_band, gain_param) if fx then local zero_param_norm = reaeq_gain_param_norm_from_db(0) local zero_env_value = reaeq_gain_env_value_from_db(0) if settings.eq_follow_frequency == 1 then local freq_norm = param_norm_for_formatted_value(take, fx, freq_param, center_hz, "hz") reaper.TakeFX_SetParamNormalized(take, fx, freq_param, freq_norm) end reaper.TakeFX_SetParamNormalized(take, fx, width_param, 0.45) reaper.TakeFX_SetParamNormalized(take, fx, gain_param, zero_param_norm) local env = reaper.TakeFX_GetEnvelope(take, fx, gain_param, true) if env then make_envelope_visible(env) set_fx_envelope_default(env, zero_env_value) end entry = { fx = fx, env = env, zero_env_value = zero_env_value, gain_param = gain_param, prepared = false } cache[key] = entry end end if entry and entry.env then if not entry.prepared then if not keep_existing_points then clear_envelope_points(entry.env) end if reaper.CountEnvelopePoints(entry.env) == 0 then reaper.InsertEnvelopePoint(entry.env, 0, entry.zero_env_value, 1, 0, false, true) end entry.prepared = true end local over_threshold = clamp((event.score - threshold) / math.max(0.001, 1 - threshold), 0, 1) local strength = clamp(0.18 + (over_threshold ^ 1.45) * 0.82, 0.12, 1) local multiplier = clamp(settings.eq_dip_multiplier or 1, 0.1, 10) local dip_db = -math.min(max_dip, max_dip * strength * multiplier) local dip_env_value = reaeq_gain_env_value_from_db(dip_db) local lookahead = settings.eq_lookahead_ms / 1000 local attack = math.max(0.001, settings.eq_attack_ms / 1000) local release = math.max(0.001, settings.eq_release_ms / 1000) local end_time = event.item_end_time local attack_start_time = math.max(0, event.item_start_time - lookahead) local dip_start_time = math.min(end_time, attack_start_time + attack) local post_time = event.item_end_time + release reaper.DeleteEnvelopePointRange(entry.env, attack_start_time - 0.001, post_time + 0.001) reaper.InsertEnvelopePoint(entry.env, attack_start_time, entry.zero_env_value, 0, 0, false, true) reaper.InsertEnvelopePoint(entry.env, dip_start_time, dip_env_value, 0, 0, false, true) if end_time > dip_start_time + 0.000001 then reaper.InsertEnvelopePoint(entry.env, end_time, dip_env_value, 0, 0, false, true) end reaper.InsertEnvelopePoint(entry.env, post_time, entry.zero_env_value, 0, 0, false, true) reaper.Envelope_SortPoints(entry.env) processed = processed + 1 else failed = failed + 1 end else failed = failed + 1 end end reaper.PreventUIRefresh(-1) reaper.UpdateArrange() reaper.Undo_EndBlock("Dyn + EQ Fix apply EQ dip", -1) local result = "EQ dip: " .. processed .. " moment(s)." if failed > 0 then result = result .. " Failed: " .. failed end return result end local function run_headless() local mode = reaper.GetExtState(EXT_SECTION, "headless_mode") local result if mode == "check" or mode == "analyze" then result = table.concat(check_selected_items().lines, "\n") elseif mode == "eq" then local check = check_selected_items() for _, event in ipairs(check.events) do event.selected = true end result = table.concat(check.lines, "\n") .. "\n" .. apply_eq_dip_to_events(check.events) elseif mode == "delete_eq" then result = delete_current_mode_envelope() elseif mode == "save_preset" then result = save_current_preset() elseif mode == "load_preset" then result = load_current_preset() elseif mode == "volume_rms" then local _, _, rms_result = check_selected_rms() result = rms_result elseif mode == "save_volume_preset" then result = save_volume_preset() elseif mode == "load_volume_preset" then result = load_volume_preset() else result = apply_to_selected_items() end reaper.SetExtState(EXT_SECTION, "headless", "", false) reaper.SetExtState(EXT_SECTION, "headless_mode", "", false) reaper.SetExtState(EXT_SECTION, "last_result", result, false) end if reaper.GetExtState(EXT_SECTION, "headless") == "1" then run_headless() return end load_check_mode_settings(settings.check_mode) local ui = { mouse_down = false, previous_mouse_down = false, last_mouse_wheel = 0, active_slider = nil, tab = "volume", last_result = "Select item(s), adjust settings, click Apply.", check_lines = { "Choose Plosive, Boom, Harsh, S, or User.", "Click Detect.", "This does not write anything yet." }, check_events = {}, check_scroll = 0, check_scroll_drag = false, check_scroll_drag_offset = 0, detect_pending = false, scan_active = false, help_text = "", last_level_text = "", audition_restore_at = nil, audition_restore_take = nil, audition_restore_fx = nil, audition_restore_enabled = nil, check_cache = {}, check_cache_key = nil, mode_bypass = nil, } local restore_mode_bypass local function get_item_guid(item) local ok, guid = reaper.GetSetMediaItemInfo_String(item, "GUID", "", false) if ok and guid and guid ~= "" then return guid end return tostring(item) end local function get_item_cache_signature(item) local take = item and reaper.GetActiveTake(item) local position = reaper.GetMediaItemInfo_Value(item, "D_POSITION") local length = reaper.GetMediaItemInfo_Value(item, "D_LENGTH") local offset = take and reaper.GetMediaItemTakeInfo_Value(take, "D_STARTOFFS") or 0 return table.concat({ get_item_guid(item), string.format("pos=%.6f", position or 0), string.format("len=%.6f", length or 0), string.format("offs=%.6f", offset or 0), }, ":") end local function get_detect_cache_key() local selected_items = get_selected_items() if #selected_items == 0 then return nil end local mode = get_check_mode() local parts = { mode.key, string.format("sens=%.3f", settings.check_sensitivity or 0), string.format("min=%.3f", settings.check_min_duration_ms or 0), } if mode.user then parts[#parts + 1] = string.format("low=%.3f", settings.user_low_hz or 0) parts[#parts + 1] = string.format("high=%.3f", settings.user_high_hz or 0) end for _, item in ipairs(selected_items) do parts[#parts + 1] = get_item_cache_signature(item) end return table.concat(parts, "|") end local function store_check_cache() local key = get_detect_cache_key() if not key then return end ui.check_cache[key] = { lines = ui.check_lines, events = ui.check_events, scroll = ui.check_scroll, } ui.check_cache_key = key end local function load_check_cache_for_selection() if ui.scan_active then return end local key = get_detect_cache_key() if key == ui.check_cache_key then return end ui.check_cache_key = key ui.check_scroll = 0 if not key then ui.check_events = {} ui.check_lines = { "No item selected.", "Select item(s) and click Detect." } return end local cached = ui.check_cache[key] if cached then ui.check_lines = cached.lines or { "Cached result." } ui.check_events = cached.events or {} ui.check_scroll = cached.scroll or 0 ui.last_level_text = "Loaded remembered Detect result for selected item." else local mode = get_check_mode() ui.check_events = {} ui.check_lines = { "Mode: " .. mode.label, "Click Detect." } end end local sliders = { { key = "target_db", label = "Target RMS", min = -36, max = -6, suffix = " dBFS", help = "The average level the rider tries to hold. Check RMS first if you want to keep the current balance." }, { key = "detect_threshold_db", label = "Detect threshold", min = -80, max = -20, suffix = " dBFS", help = "Audio below this level is ignored, so silence and room tone do not create automation." }, { key = "write_threshold_db", label = "Write threshold", min = 0, max = 3, suffix = " dB", help = "Smaller values write more detail. Larger values write fewer, calmer points." }, { key = "max_down_db", label = "Max down", min = 0, max = 24, suffix = " dB", help = "The most gain reduction the volume rider may write." }, { key = "max_up_db", label = "Max up", min = 0, max = 18, suffix = " dB", help = "The most gain boost the volume rider may write." }, { key = "max_change_db", label = "Max change", min = 0.1, max = 8, suffix = " dB/point", help = "Limits how fast the written volume can move between points." }, { key = "smooth_pct", label = "Smooth", min = 0, max = 100, suffix = "%", help = "Higher values make the envelope smoother and less detailed." }, { key = "peak_ceiling_db", label = "Peak ceiling", min = -24, max = 0, suffix = " dBFS", help = "Peaks above this level can trigger extra reduction." }, { key = "peak_control_pct", label = "Peak control", min = 0, max = 100, suffix = "%", help = "How strongly peaks are pulled down. 0% keeps the old RMS-only behavior." }, { key = "lookahead_ms", label = "Lookahead", min = 0, max = 200, suffix = " ms", help = "Writes the change earlier than the measured audio, useful for first words and peaks." }, { key = "window_ms", label = "Analysis window", min = 10, max = 250, suffix = " ms", help = "Shorter windows catch quick changes. Longer windows are steadier." }, { key = "point_spacing_ms", label = "Point spacing", min = 20, max = 500, suffix = " ms", help = "Distance between possible envelope points. Smaller values are more detailed." }, } local function set_color(r, g, b, a) gfx.set(r / 255, g / 255, b / 255, a or 1) end local function rect_contains(x, y, w, h) return gfx.mouse_x >= x and gfx.mouse_x <= x + w and gfx.mouse_y >= y and gfx.mouse_y <= y + h end local function set_tooltip(text) if text and text ~= "" then ui.help_text = text end end local function draw_text(text, x, y, r, g, b) set_color(r or 230, g or 230, b or 230) gfx.x = x gfx.y = y gfx.drawstr(text) end local function format_value(value, suffix) if math.abs(value) >= 10 then return string.format("%.0f%s", value, suffix) end return string.format("%.1f%s", value, suffix) end local function fit_text(text, max_w) if gfx.measurestr(text) <= max_w then return text end local suffix = "..." while #text > 0 and gfx.measurestr(text .. suffix) > max_w do text = text:sub(1, -2) end return text .. suffix end local function draw_slider(slider, x, y, w) local key = slider.key local value = clamp(settings[key], slider.min, slider.max) local normalized = (value - slider.min) / (slider.max - slider.min) local track_y = y + 24 local handle_x = x + normalized * w local hot = rect_contains(x - 4, track_y - 10, w + 8, 20) if hot then set_tooltip(slider.help) end if ui.mouse_down and (ui.active_slider == key or (not ui.active_slider and hot)) then ui.active_slider = key normalized = clamp((gfx.mouse_x - x) / w, 0, 1) settings[key] = slider.min + normalized * (slider.max - slider.min) value = settings[key] handle_x = x + normalized * w end draw_text(slider.label, x, y, 215, 215, 215) draw_text(format_value(value, slider.suffix), x + w - 78, y, 180, 205, 255) set_color(58, 63, 72) gfx.rect(x, track_y, w, 4, true) set_color(86, 147, 255) gfx.rect(x, track_y, handle_x - x, 4, true) set_color(hot and 250 or 230, hot and 250 or 230, hot and 250 or 230) gfx.circle(handle_x, track_y + 2, 7, true) end local function draw_compact_slider(slider, x, y, w) local key = slider.key local value = clamp(settings[key], slider.min, slider.max) local normalized = (value - slider.min) / (slider.max - slider.min) local track_y = y + 22 local handle_x = x + normalized * w local hot = rect_contains(x - 4, track_y - 10, w + 8, 20) if hot then set_tooltip(slider.help) end if ui.mouse_down and (ui.active_slider == key or (not ui.active_slider and hot)) then ui.active_slider = key normalized = clamp((gfx.mouse_x - x) / w, 0, 1) settings[key] = slider.min + normalized * (slider.max - slider.min) value = settings[key] handle_x = x + normalized * w end draw_text(slider.label, x, y, 215, 215, 215) draw_text(format_value(value, slider.suffix), x + w - 42, y, 180, 205, 255) set_color(58, 63, 72) gfx.rect(x, track_y, w, 4, true) set_color(86, 147, 255) gfx.rect(x, track_y, handle_x - x, 4, true) set_color(hot and 250 or 230, hot and 250 or 230, hot and 250 or 230) gfx.circle(handle_x, track_y + 2, 6, true) end local function draw_button(label, x, y, w, h, r, g, b, help) local hot = rect_contains(x, y, w, h) if hot then set_tooltip(help) end set_color(hot and math.min(r + 18, 255) or r, hot and math.min(g + 18, 255) or g, hot and math.min(b + 18, 255) or b) gfx.rect(x, y, w, h, true) set_color(18, 20, 24) gfx.rect(x, y, w, h, false) set_color(255, 255, 255) gfx.x = x + (w - gfx.measurestr(label)) / 2 gfx.y = y + 8 gfx.drawstr(label) return hot and ui.mouse_down and not ui.previous_mouse_down end local function draw_checkbox(label, x, y, help) local hot = rect_contains(x, y, 260, 22) if hot then set_tooltip(help) end if hot and ui.mouse_down and not ui.previous_mouse_down then settings.clean_existing = not settings.clean_existing save_settings() end set_color(45, 50, 58) gfx.rect(x, y, 18, 18, true) set_color(105, 112, 124) gfx.rect(x, y, 18, 18, false) if settings.clean_existing then set_color(91, 193, 118) gfx.rect(x + 4, y + 4, 10, 10, true) end draw_text(label, x + 28, y + 1, hot and 255 or 225, hot and 255 or 225, hot and 255 or 225) end local function draw_setting_checkbox(label, key, x, y, w, help) local hot = rect_contains(x, y, w or 260, 22) if hot then set_tooltip(help) end if hot and ui.mouse_down and not ui.previous_mouse_down then settings[key] = settings[key] == 1 and 0 or 1 save_check_mode_settings() end set_color(45, 50, 58) gfx.rect(x, y, 18, 18, true) set_color(105, 112, 124) gfx.rect(x, y, 18, 18, false) if settings[key] == 1 then set_color(91, 193, 118) gfx.rect(x + 4, y + 4, 10, 10, true) end draw_text(label, x + 28, y + 1, hot and 255 or 225, hot and 255 or 225, hot and 255 or 225) end local function draw_tab(label, key, x, y, w) local active = ui.tab == key local hot = rect_contains(x, y, w, 28) set_color(active and 72 or (hot and 52 or 42), active and 139 or (hot and 58 or 46), active and 255 or (hot and 66 or 54)) gfx.rect(x, y, w, 28, true) set_color(18, 20, 24) gfx.rect(x, y, w, 28, false) draw_text(label, x + 14, y + 7, active and 255 or 215, active and 255 or 215, active and 255 or 215) if hot and ui.mouse_down and not ui.previous_mouse_down then ui.tab = key end end local function draw_status_box(text, x, y, w, h) set_color(42, 46, 54) gfx.rect(x, y, w, h, true) draw_text(text, x + 10, y + 14, 218, 225, 235) end local function draw_help_bar() local text = ui.help_text if not text or text == "" then text = ui.last_level_text end if not text or text == "" then text = "Hover a control for help." end set_color(38, 42, 50) gfx.rect(18, 82, 380, 34, true) set_color(56, 62, 74) gfx.rect(18, 82, 380, 34, false) draw_text(fit_text(text, 356), 30, 92, 190, 208, 232) end local function draw_volume_preset_controls(x, y) local preset_name = get_volume_preset_name() draw_text("Preset: " .. preset_name, x, y, 218, 225, 235) local prev_clicked = draw_button("<", x, y + 22, 30, 26, 72, 139, 255, "Previous volume preset.") local next_clicked = draw_button(">", x + 36, y + 22, 30, 26, 72, 139, 255, "Next volume preset.") local name_clicked = draw_button("Name", x + 76, y + 22, 54, 26, 72, 139, 255, "Name the volume preset you want to save or load.") local load_clicked = draw_button("Load", x + 138, y + 22, 54, 26, 91, 193, 118, "Load the selected volume preset.") local save_clicked = draw_button("Save", x + 200, y + 22, 54, 26, 72, 139, 255, "Save the current volume settings as a preset.") local rms_clicked = draw_button("Check RMS + Peak", x + 262, y + 22, 112, 26, 91, 193, 118, "Measure the selected item RMS and peak without writing automation.") if prev_clicked then ui.last_result = step_volume_preset(-1) elseif next_clicked then ui.last_result = step_volume_preset(1) elseif name_clicked then local name = prompt_volume_preset_name() if name then ui.last_result = "Volume preset name: " .. name end elseif load_clicked then ui.last_result = load_volume_preset() elseif save_clicked then ui.last_result = save_volume_preset() elseif rms_clicked then local rms_db, peak_db, result = check_selected_rms() if rms_db then ui.last_level_text = string.format("Measured RMS: %.1f dBFS Peak: %.1f dBFS", rms_db, peak_db or -150) end ui.last_result = result end end local function draw_scanning_popup() if not gfx or not gfx.w or gfx.w <= 0 then return end local panel_w = 340 local panel_h = 70 local panel_x = math.floor((gfx.w - panel_w) / 2) local panel_y = math.floor((gfx.h - panel_h) / 2) set_color(18, 20, 24, 0.96) gfx.rect(panel_x, panel_y, panel_w, panel_h, true) set_color(72, 139, 255) gfx.rect(panel_x, panel_y, panel_w, panel_h, false) draw_text("Scanning...", panel_x + 22, panel_y + 18, 245, 245, 245) draw_text("Please wait.", panel_x + 22, panel_y + 42, 178, 195, 215) end local function draw_volume_tab() draw_volume_preset_controls(24, 126) local y = 214 for _, slider in ipairs(sliders) do draw_slider(slider, 24, y, 360) y = y + 38 end draw_checkbox("Clean existing points before apply", 24, y + 2, "Remove existing take volume points before writing new ones.") local apply_clicked = draw_button("Apply to selected", 24, y + 36, 170, 34, 72, 139, 255, "Write take volume automation to the selected item(s).") local clean_clicked = draw_button("Clean selected", 214, y + 36, 150, 34, 91, 193, 118, "Remove take volume envelope points from the selected item(s).") draw_status_box(ui.last_result, 18, y + 88, 380, 44) if apply_clicked then ui.last_result = apply_to_selected_items() elseif clean_clicked then ui.last_result = clean_selected_items() end end local function draw_mode_button(mode, index, x, y, w) local active = settings.check_mode == index local hot = rect_contains(x, y, w, 30) set_color(active and 72 or (hot and 52 or 42), active and 139 or (hot and 58 or 46), active and 255 or (hot and 66 or 54)) gfx.rect(x, y, w, 30, true) set_color(18, 20, 24) gfx.rect(x, y, w, 30, false) draw_text(mode.label, x + 12, y + 8, active and 255 or 225, active and 255 or 225, active and 255 or 225) if hot and ui.mouse_down and not ui.previous_mouse_down then save_check_mode_settings() if ui.mode_bypass then restore_mode_bypass() end settings.check_mode = index load_check_mode_settings(index) ui.check_cache_key = nil ui.check_scroll = 0 save_settings() end end local function draw_band_type_selector(x, y, w) draw_text("Band type", x, y, 178, 195, 215) local button_w = math.floor((w - 12) / #eq_band_types) local button_y = y + 20 local current = get_eq_band_type_value() for i, band_type in ipairs(eq_band_types) do local bx = x + ((i - 1) * (button_w + 6)) local active = current == band_type.value local hot = rect_contains(bx, button_y, button_w, 28) set_color(active and 72 or (hot and 52 or 42), active and 139 or (hot and 58 or 46), active and 255 or (hot and 66 or 54)) gfx.rect(bx, button_y, button_w, 28, true) set_color(18, 20, 24) gfx.rect(bx, button_y, button_w, 28, false) draw_text(band_type.label, bx + 10, button_y + 7, active and 255 or 225, active and 255 or 225, active and 255 or 225) if hot and ui.mouse_down and not ui.previous_mouse_down then settings.eq_band_type = band_type.value save_check_mode_settings() end end end local function draw_preset_controls(mode, x, y, w) local mode_key = mode.key local preset_name = get_current_preset_name(mode_key) draw_text("Preset: " .. preset_name, x, y, 218, 225, 235) local prev_clicked = draw_button("<", x, y + 24, 30, 28, 72, 139, 255) local next_clicked = draw_button(">", x + 36, y + 24, 30, 28, 72, 139, 255) local name_clicked = draw_button("Name", x + 76, y + 24, 54, 28, 72, 139, 255) local load_clicked = draw_button("Load", x + 138, y + 24, 54, 28, 91, 193, 118) local save_preset_clicked = draw_button("Save", x + 200, y + 24, 54, 28, 72, 139, 255) local delete_preset_clicked = draw_button("Delete", x + 262, y + 24, 64, 28, 236, 104, 94) if prev_clicked then ui.check_lines = { step_preset(-1), "Mode: " .. mode.label } elseif next_clicked then ui.check_lines = { step_preset(1), "Mode: " .. mode.label } elseif name_clicked then local name = prompt_current_preset_name() if name then ui.check_lines = { "Preset name: " .. name, "Mode: " .. mode.label } end elseif load_clicked then ui.check_lines = { load_current_preset(), "Mode: " .. mode.label } elseif save_preset_clicked then ui.check_lines = { save_current_preset(), "Mode: " .. mode.label } elseif delete_preset_clicked then ui.check_lines = { delete_current_preset(), "Mode: " .. mode.label } end end local function jump_to_event(event) reaper.SetEditCurPos(event.project_time, true, false) end local function restore_audition_fx() if ui.audition_restore_take and ui.audition_restore_fx then take_fx_set_enabled(ui.audition_restore_take, ui.audition_restore_fx, ui.audition_restore_enabled) end ui.audition_restore_at = nil ui.audition_restore_take = nil ui.audition_restore_fx = nil ui.audition_restore_enabled = nil end local function audition_event(event, with_fix) restore_audition_fx() local lookahead = math.max(0, settings.eq_lookahead_ms or 0) / 1000 local pre_roll = math.max(0.55, lookahead + 0.25) local post_roll = 0.85 local start_time = math.max(0, event.project_time - pre_roll) local end_time = event.project_time + math.max(event.duration or 0, 0.05) + post_roll local play_seconds = math.max(0.2, end_time - start_time) local mode = get_check_mode() local _, gain_param = get_reaeq_band_params(mode.eq_band or 4) local fx = event.take and find_dynassist_reaeq(event.take, gain_param) if fx then ui.audition_restore_take = event.take ui.audition_restore_fx = fx ui.audition_restore_enabled = take_fx_get_enabled(event.take, fx) ui.audition_restore_at = reaper.time_precise() + play_seconds + 0.15 take_fx_set_enabled(event.take, fx, with_fix) end reaper.GetSet_LoopTimeRange(true, false, start_time, end_time, false) reaper.SetEditCurPos(start_time, true, false) reaper.OnPlayButton() ui.last_level_text = string.format("Audition %s: %.0f ms before, %.0f ms after.", with_fix and "fix" or "original", pre_roll * 1000, post_roll * 1000) end local function apply_single_event(event) local was_selected = event.selected event.selected = true local result = apply_eq_dip_to_events({ event }, true) event.selected = was_selected ui.check_lines = { result, "Single moment updated." } ui.last_level_text = result end restore_mode_bypass = function() if not ui.mode_bypass then return nil end local restored = 0 for _, entry in ipairs(ui.mode_bypass.entries or {}) do if entry.env and entry.chunk then reaper.SetEnvelopeStateChunk(entry.env, entry.chunk, false) if entry.take and entry.fx and entry.gain_param and entry.param_value then reaper.TakeFX_SetParamNormalized(entry.take, entry.fx, entry.gain_param, entry.param_value) end restored = restored + 1 end end local label = ui.mode_bypass.label or "mode" ui.mode_bypass = nil reaper.UpdateArrange() return string.format("Restored %s fix: %d envelope(s).", label, restored) end local function toggle_mode_bypass() local mode = get_check_mode() if ui.mode_bypass then return restore_mode_bypass() end local selected_items = get_selected_items() if #selected_items == 0 then return "Select one or more audio items first." end local _, gain_param = get_reaeq_band_params(mode.eq_band or 4) local entries = {} for _, item in ipairs(selected_items) do local take = item and reaper.GetActiveTake(item) local fx = take and find_dynassist_reaeq(take, gain_param) local env = fx and reaper.TakeFX_GetEnvelope(take, fx, gain_param, false) if env then local ok, chunk = reaper.GetEnvelopeStateChunk(env, "", false) if ok then entries[#entries + 1] = { env = env, chunk = chunk, take = take, fx = fx, gain_param = gain_param, param_value = reaper.TakeFX_GetParamNormalized(take, fx, gain_param), } local bypass_chunk = chunk:gsub("ACT [^\n]*", "ACT 0 -1", 1) reaper.SetEnvelopeStateChunk(env, bypass_chunk, false) reaper.TakeFX_SetParamNormalized(take, fx, gain_param, reaeq_gain_param_norm_from_db(0)) end end end if #entries == 0 then return "No " .. mode.label .. " fix envelope found on selected item(s)." end ui.mode_bypass = { mode_key = mode.key, label = mode.label, entries = entries, } reaper.UpdateArrange() return string.format("Bypassed %s fix: %d envelope(s).", mode.label, #entries) end local function draw_event_row(event, visible_index, x, y, w) local hot = rect_contains(x, y - 4, w, 24) if hot then set_color(54, 60, 70) gfx.rect(x - 4, y - 6, w + 8, 26, true) end local toggle_hot = rect_contains(x, y - 5, 18, 22) set_color(45, 50, 58) gfx.rect(x, y - 3, 16, 16, true) set_color(105, 112, 124) gfx.rect(x, y - 3, 16, 16, false) if event.selected then set_color(91, 193, 118) gfx.rect(x + 4, y + 1, 8, 8, true) end local audition_x = x + w - 178 local row_text = string.format("%02d %s %.0fms %.0f%%", visible_index, reaper.format_timestr_pos(event.project_time, "", -1), event.duration * 1000, clamp(event.score, 0, 1) * 100) draw_text(fit_text(row_text, audition_x - x - 34), x + 24, y, 218, 225, 235) local orig_hot = rect_contains(audition_x, y - 6, 38, 24) set_color(orig_hot and 111 or 91, orig_hot and 213 or 193, orig_hot and 138 or 118) gfx.rect(audition_x, y - 6, 38, 24, true) draw_text("Orig", audition_x + 7, y, 255, 255, 255) if orig_hot then set_tooltip("Audition this moment without the Dyn + EQ Fix dip.") end local fix_x = audition_x + 42 local fix_hot = rect_contains(fix_x, y - 6, 32, 24) set_color(fix_hot and 111 or 91, fix_hot and 213 or 193, fix_hot and 138 or 118) gfx.rect(fix_x, y - 6, 32, 24, true) draw_text("Fix", fix_x + 7, y, 255, 255, 255) if fix_hot then set_tooltip("Audition this moment with the Dyn + EQ Fix dip active.") end local apply_x = fix_x + 36 local apply_hot = rect_contains(apply_x, y - 6, 54, 24) set_color(apply_hot and 255 or 236, apply_hot and 124 or 104, apply_hot and 114 or 94) gfx.rect(apply_x, y - 6, 54, 24, true) draw_text("Apply", apply_x + 9, y, 255, 255, 255) if apply_hot then set_tooltip("Update only this moment with the current EQ dip settings.") end local jump_x = x + w - 54 local jump_hot = rect_contains(jump_x, y - 6, 50, 24) set_color(jump_hot and 92 or 72, jump_hot and 159 or 139, 255) gfx.rect(jump_x, y - 6, 50, 24, true) draw_text("Jump", jump_x + 8, y, 255, 255, 255) if jump_hot then set_tooltip("Move the edit cursor to this detected moment.") end if toggle_hot and ui.mouse_down and not ui.previous_mouse_down then event.selected = not event.selected elseif orig_hot and ui.mouse_down and not ui.previous_mouse_down then audition_event(event, false) elseif fix_hot and ui.mouse_down and not ui.previous_mouse_down then audition_event(event, true) elseif apply_hot and ui.mouse_down and not ui.previous_mouse_down then apply_single_event(event) elseif jump_hot and ui.mouse_down and not ui.previous_mouse_down then jump_to_event(event) elseif hot and ui.mouse_down and not ui.previous_mouse_down then jump_to_event(event) end end local draw_ui local function start_detect_scan() if ui.scan_active then return end ui.scan_active = true ui.detect_pending = true ui.check_lines = { "Scanning...", "Please wait." } ui.check_events = {} ui.check_scroll = 0 end local function step_detect_scan() if not ui.detect_pending then return end ui.detect_pending = false draw_ui() gfx.update() local ok, result = pcall(check_selected_items) if not ok then ui.check_lines = { "Detect failed.", tostring(result) } ui.check_events = {} else ui.check_lines = result and result.lines or { "Detect finished.", "No result returned." } ui.check_events = result and result.events or {} ui.check_scroll = 0 store_check_cache() end ui.check_scroll = 0 ui.scan_active = false end local function draw_check_tab() draw_text("Choose the problem to detect.", 24, 126, 225, 225, 225) load_check_cache_for_selection() local mode_y = 160 for i, mode in ipairs(check_modes) do draw_mode_button(mode, i, 24 + ((i - 1) * 74), mode_y, 68) end local mode = get_check_mode() draw_preset_controls(mode, 24, 200, 360) local control_y = 262 if mode.user then draw_slider({ key = "user_low_hz", label = "User low", min = 20, max = 18000, suffix = " Hz", help = "Lowest frequency included in User detection." }, 24, control_y, 360) draw_slider({ key = "user_high_hz", label = "User high", min = 30, max = 18000, suffix = " Hz", help = "Highest frequency included in User detection." }, 24, control_y + 38, 360) control_y = control_y + 76 end draw_slider({ key = "check_sensitivity", label = "Sensitivity", min = 0, max = 100, suffix = "%", help = "Higher values find more moments. Lower values are stricter." }, 24, control_y, 360) draw_slider({ key = "check_min_duration_ms", label = "Min duration", min = 15, max = 250, suffix = " ms", help = "Ignores moments shorter than this, useful for avoiding T sounds when detecting S sounds." }, 24, control_y + 38, 360) draw_text("EQ envelope", 24, control_y + 78, 178, 195, 215) draw_compact_slider({ key = "eq_max_dip_db", label = "Max dip", min = 1, max = 18, suffix = "dB", help = "Maximum EQ reduction for the strongest detected moments." }, 24, control_y + 100, 166) draw_compact_slider({ key = "eq_dip_multiplier", label = "Multiplier", min = 0.25, max = 5, suffix = "x", help = "Multiplies the detected dip amount, while Max EQ dip stays the limit." }, 214, control_y + 100, 166) draw_compact_slider({ key = "eq_lookahead_ms", label = "Look", min = 0, max = 120, suffix = "ms", help = "Starts the EQ envelope before the detected moment." }, 24, control_y + 142, 104) draw_compact_slider({ key = "eq_attack_ms", label = "Atk", min = 1, max = 200, suffix = "ms", help = "Time from no dip to full dip." }, 148, control_y + 142, 104) draw_compact_slider({ key = "eq_release_ms", label = "Rel", min = 5, max = 500, suffix = "ms", help = "Time back to no dip after the detected moment ends." }, 272, control_y + 142, 104) draw_setting_checkbox("Follow freq", "eq_follow_frequency", 24, control_y + 202, 150, "Move the EQ band frequency to the detector range before writing the envelope.") draw_band_type_selector(190, control_y + 182, 194) local button_y = control_y + 238 local check_clicked = draw_button("Detect", 24, button_y, 118, 34, 72, 139, 255, "Scan the selected item(s) for this problem.") local select_all_clicked = draw_button("All", 150, button_y, 42, 34, 91, 193, 118, "Select all detected moments.") local select_none_clicked = draw_button("None", 200, button_y, 54, 34, 91, 193, 118, "Deselect all detected moments.") local eq_clicked = draw_button("EQ dip", 262, button_y, 70, 34, 236, 104, 94, "Write EQ gain automation for the selected moments.") local save_clicked = draw_button("Save", 340, button_y, 58, 34, 72, 139, 255, "Save the current Detect settings for this mode.") local delete_clicked = draw_button("Delete " .. mode.label .. " env band " .. (mode.eq_band or 4), 24, button_y + 42, 190, 30, 236, 104, 94, "Remove the EQ envelope for this mode and band.") local bypass_label = ui.mode_bypass and "Restore fix" or "Bypass fix" local bypass_clicked = draw_button(bypass_label, 224, button_y + 42, 86, 30, 91, 193, 118, "Temporarily bypass or restore the current mode fix on the selected item(s).") draw_text("Band " .. (mode.eq_band or 4) .. " " .. get_eq_band_type_label(), 318, button_y + 50, 178, 195, 215) if check_clicked then start_detect_scan() elseif select_all_clicked then for _, event in ipairs(ui.check_events) do event.selected = true end elseif select_none_clicked then for _, event in ipairs(ui.check_events) do event.selected = false end elseif eq_clicked then ui.check_lines = { apply_eq_dip_to_events(ui.check_events), ui.check_lines[2] or "" } elseif delete_clicked then ui.check_lines = { delete_current_mode_envelope(), "Band " .. (mode.eq_band or 4) .. " / " .. mode.label } elseif bypass_clicked then local result = toggle_mode_bypass() ui.check_lines = { result, "Mode: " .. mode.label } ui.last_level_text = result elseif save_clicked then save_check_mode_settings() save_settings() ui.check_lines = { "Detect settings saved for " .. mode.label .. ".", "Mode: " .. mode.label } end local y = button_y + 90 local list_x = 18 local list_y = y - 14 local list_w = 380 local list_h = math.max(160, gfx.h - list_y - 28) set_color(42, 46, 54) gfx.rect(list_x, list_y, list_w, list_h, true) if #ui.check_events == 0 then for _, line in ipairs(ui.check_lines) do draw_text(line, 28, y, 218, 225, 235) y = y + 24 if y > 558 then break end end return end draw_text(ui.check_lines[1] or "", 28, y, 218, 225, 235) y = y + 24 draw_text(ui.check_lines[2] or "", 28, y, 218, 225, 235) y = y + 30 local wheel_delta = gfx.mouse_wheel - ui.last_mouse_wheel if wheel_delta ~= 0 and rect_contains(list_x, list_y, list_w, list_h) then ui.check_scroll = ui.check_scroll - (wheel_delta / 120) end local max_visible = math.max(4, math.floor((list_h - 72) / 26)) local max_scroll = math.max(0, #ui.check_events - max_visible) ui.check_scroll = clamp(math.floor(ui.check_scroll + 0.5), 0, max_scroll) local first = ui.check_scroll + 1 local last = math.min(#ui.check_events, first + max_visible - 1) for i = first, last do draw_event_row(ui.check_events[i], i, 28, y, 334) y = y + 26 end local scrollbar_x = list_x + list_w - 12 local scrollbar_y = list_y + 10 local scrollbar_h = list_h - 42 set_color(58, 63, 72) gfx.rect(scrollbar_x, scrollbar_y, 7, scrollbar_h, true) if max_scroll > 0 then local thumb_h = math.max(24, scrollbar_h * (max_visible / #ui.check_events)) local thumb_y = scrollbar_y + ((scrollbar_h - thumb_h) * (ui.check_scroll / max_scroll)) local thumb_hot = rect_contains(scrollbar_x - 5, thumb_y, 17, thumb_h) local track_hot = rect_contains(scrollbar_x - 5, scrollbar_y, 17, scrollbar_h) if ui.mouse_down and not ui.previous_mouse_down and thumb_hot then ui.check_scroll_drag = true ui.check_scroll_drag_offset = gfx.mouse_y - thumb_y elseif ui.mouse_down and not ui.previous_mouse_down and track_hot then local target_ratio = clamp((gfx.mouse_y - scrollbar_y - (thumb_h / 2)) / math.max(1, scrollbar_h - thumb_h), 0, 1) ui.check_scroll = clamp(math.floor((target_ratio * max_scroll) + 0.5), 0, max_scroll) thumb_y = scrollbar_y + ((scrollbar_h - thumb_h) * (ui.check_scroll / max_scroll)) ui.check_scroll_drag = true ui.check_scroll_drag_offset = thumb_h / 2 end if ui.check_scroll_drag and ui.mouse_down then local target_ratio = clamp((gfx.mouse_y - ui.check_scroll_drag_offset - scrollbar_y) / math.max(1, scrollbar_h - thumb_h), 0, 1) ui.check_scroll = clamp(math.floor((target_ratio * max_scroll) + 0.5), 0, max_scroll) thumb_y = scrollbar_y + ((scrollbar_h - thumb_h) * (ui.check_scroll / max_scroll)) end set_color(120, 145, 190) gfx.rect(scrollbar_x - 2, thumb_y, 11, thumb_h, true) draw_text(string.format("%d-%d / %d", first, last, #ui.check_events), 300, list_y + list_h - 22, 178, 195, 215) else set_color(88, 96, 110) gfx.rect(scrollbar_x - 2, scrollbar_y, 11, scrollbar_h, true) end end draw_ui = function() ui.help_text = "" gfx.setfont(1, "Arial", 14) set_color(28, 31, 36) gfx.rect(0, 0, gfx.w, gfx.h, true) draw_text("Dyn + EQ Fix", 18, 14, 245, 245, 245) draw_text("v" .. SCRIPT_VERSION, 18, 34, 150, 170, 195) local selected_count = reaper.CountSelectedMediaItems(0) draw_text("Selected: " .. selected_count, 318, 16, 170, 190, 215) draw_tab("Volume", "volume", 18, 48, 120) draw_tab("Detect and Fix", "check", 144, 48, 132) if ui.tab == "check" then draw_check_tab() else draw_volume_tab() end draw_help_bar() if ui.scan_active then draw_scanning_popup() end end local function loop() ui.previous_mouse_down = ui.mouse_down ui.mouse_down = (gfx.mouse_cap & 1) == 1 if ui.audition_restore_at and reaper.time_precise() >= ui.audition_restore_at then restore_audition_fx() end if not ui.mouse_down then ui.active_slider = nil ui.check_scroll_drag = false end step_detect_scan() draw_ui() ui.last_mouse_wheel = gfx.mouse_wheel gfx.update() if gfx.getchar() >= 0 then reaper.defer(loop) else restore_mode_bypass() restore_audition_fx() save_settings() end end gfx.init(SCRIPT_NAME, 420, 860) loop()