Created
June 6, 2026 15:06
-
-
Save mumblequatch/5316e8235bae40c8dd5c8f35297ebca9 to your computer and use it in GitHub Desktop.
Reaper Lua: remove dog-clicker double-clicks and ripple-close the gap (clip+isolation detection)
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| --[[ | |
| declick_dog_clicker.lua | |
| Removes dog-clicker "double-clicks" from selected media item(s) and | |
| ripple-closes the gap left behind, butting the surrounding audio together. | |
| A dog clicker makes two sharp transients per actuation (press + release), | |
| ~100-250 ms apart. They record far hotter than dialogue (here they clip at | |
| full scale while speech peaks ~0.3). This script: | |
| 1. Reads each selected item's audio. | |
| 2. Finds transients above CLICK_THRESH (the clicks). | |
| 3. Merges nearby clicks into one "click", extends each through its | |
| attack/ring-out, then groups clicks within PAIR_GAP into one event | |
| (the press+release double-click). | |
| 4. Splits each event out as its own segment, deletes it, and re-lays the | |
| surviving segments contiguously from the item's original start so the | |
| removed span is rippled shut. Natural pauses are preserved; only the | |
| clicker spans disappear. Small edge fades avoid edit clicks at joins. | |
| Operates IN PLACE on selected items. Duplicate to your EDIT track first if | |
| you want to keep the RAW untouched. Undoable as one step. | |
| Tuning (linear amplitude, 0..1, and seconds): | |
| CLICK_THRESH must sit between dialogue peaks and clicker peaks. | |
| 0.5 (-6 dB) works when the clicker clips and speech stays | |
| below ~0.35. Lower it if clicks are quieter; raise it if | |
| loud plosives get caught. | |
| FLOOR level below which audio counts as "silence" when extending | |
| a click through its ring-out. | |
| PAIR_GAP max gap between the two clicks of one actuation to treat | |
| them as a single event. Raise if a clicker's two clicks are | |
| farther apart; keep well below the gap between actuations. | |
| ]]-- | |
| ---------------------------------------------------------------------- | |
| -- CONFIG | |
| ---------------------------------------------------------------------- | |
| local DRY_RUN = false -- true = report detected events only, no editing. | |
| -- Set false to actually cut + ripple. | |
| -- The clicker clips at digital full scale (1.0); dialogue, however loud, | |
| -- does not. So we detect CLIPPING, not "loudness" — that's what separates | |
| -- clicks from hot speech. CLICK_THRESH sits just under 1.0. | |
| local CLICK_THRESH = 0.98 -- a sample at/above this counts as a click (0..1) | |
| -- A dog clicker fires twice per actuation (press + release), ~100-250 ms | |
| -- apart. Click runs within DOUBLE_GAP are merged into one event so the whole | |
| -- double-click — and the gap between the two clicks — is removed together. | |
| local DOUBLE_GAP = 0.30 -- merge clicks within this into one event (s) | |
| local MAX_EVENT = 0.50 -- ignore "events" wider than this (not a click) (s) | |
| -- Isolation guard: a real clicker marker is an isolated impulse clicked in a | |
| -- gap between phrases, so the audio just outside it is quiet. A sample that | |
| -- clips in the MIDDLE of speech (a loud plosive) is not a marker. We require | |
| -- both sides of the event to be below ISO_PEAK; otherwise we skip it. This is | |
| -- what stops clipped dialogue transients from being mistaken for clicks. | |
| local ISO_GUARD = true -- set false to disable the isolation test | |
| local GUARD_WIN = 0.15 -- how far either side to look for neighbours (s) | |
| local ISO_PEAK = 0.20 -- neighbour peak above this = not isolated (0..1) | |
| -- Fixed time removed on EITHER SIDE of the detected click span. No envelope | |
| -- chasing — just trim a clean window and ripple it shut. | |
| local PRE_PAD = 0.040 -- remove this much before the click span (s) | |
| local POST_PAD = 0.040 -- remove this much after the click span (s) | |
| local EDGE_FADE = 0.005 -- fade in/out at each join to avoid clicks (s) | |
| local ANALYZE_SR = 48000 -- sample rate to analyze at | |
| ---------------------------------------------------------------------- | |
| local r = reaper | |
| -- Detect clicker events in one item. Returns a list of {s=,e=} cut regions | |
| -- in PROJECT time (seconds), sorted ascending. | |
| local function detect_events(item) | |
| local take = r.GetActiveTake(item) | |
| if not take or r.TakeIsMIDI(take) then return {} end | |
| local acc = r.CreateTakeAudioAccessor(take) | |
| local pos = r.GetMediaItemInfo_Value(item, "D_POSITION") | |
| local len = r.GetMediaItemInfo_Value(item, "D_LENGTH") | |
| local t0 = pos | |
| local t1 = pos + len | |
| local sr = ANALYZE_SR | |
| local nch = 1 | |
| local nsamp = math.floor((t1 - t0) * sr) | |
| if nsamp <= 0 then r.DestroyAudioAccessor(acc); return {} end | |
| -- Read in chunks to keep buffers reasonable. env is a plain Lua table | |
| -- (new_array has a size cap a long recording would blow past); only the | |
| -- accessor read buffer needs to be a reaper array. | |
| local CHUNK = 65536 -- samples per accessor read | |
| local env = {} -- per-sample abs envelope (mono-collapsed) | |
| local gpeak = 0 -- global peak (for the report / sanity) | |
| local got = 0 | |
| local buf = r.new_array(CHUNK) | |
| while got < nsamp do | |
| local want = math.min(CHUNK, nsamp - got) | |
| buf.clear() | |
| local st = t0 + got / sr | |
| r.GetAudioAccessorSamples(acc, sr, nch, st, want, buf) | |
| -- index the reaper array directly; .table()'s offset semantics are | |
| -- unreliable and were yielding garbage values above threshold. | |
| for i = 1, want do | |
| local v = buf[i]; if v < 0 then v = -v end | |
| env[got + i] = v | |
| if v > gpeak then gpeak = v end | |
| end | |
| got = got + want | |
| end | |
| r.DestroyAudioAccessor(acc) | |
| EVENT_GPEAK = gpeak | |
| -- 1. clipped runs (samples at/above CLICK_THRESH = the clicks) | |
| local clicks = {} | |
| local i = 1 | |
| while i <= nsamp do | |
| if env[i] >= CLICK_THRESH then | |
| local j = i | |
| while j <= nsamp and env[j] >= CLICK_THRESH do j = j + 1 end | |
| clicks[#clicks + 1] = { i, j - 1 } | |
| i = j | |
| else | |
| i = i + 1 | |
| end | |
| end | |
| if #clicks == 0 then return {} end | |
| -- 2. merge clicks within DOUBLE_GAP into one event (the press+release pair) | |
| local dg = math.floor(DOUBLE_GAP * sr) | |
| local events = { { clicks[1][1], clicks[1][2] } } | |
| for k = 2, #clicks do | |
| local c = clicks[k] | |
| if c[1] - events[#events][2] <= dg then | |
| events[#events][2] = c[2] | |
| else | |
| events[#events + 1] = { c[1], c[2] } | |
| end | |
| end | |
| -- 3. fixed window either side; drop anything too wide to be a click | |
| local pre = math.floor(PRE_PAD * sr) | |
| local post = math.floor(POST_PAD * sr) | |
| local maxw = MAX_EVENT * sr | |
| local guard = math.floor(GUARD_WIN * sr) | |
| local regions = {} | |
| for _, ev in ipairs(events) do | |
| if (ev[2] - ev[1]) <= maxw then | |
| local a = math.max(1, ev[1] - pre) | |
| local b = math.min(nsamp, ev[2] + post) | |
| -- isolation: peak of the audio just outside the cut, both sides | |
| local nbr = 0 | |
| for s = math.max(1, a - guard), a - 1 do if env[s] > nbr then nbr = env[s] end end | |
| for s = b + 1, math.min(nsamp, b + guard) do if env[s] > nbr then nbr = env[s] end end | |
| local pk = 0 | |
| for s = ev[1], ev[2] do if env[s] > pk then pk = env[s] end end | |
| if (not ISO_GUARD) or nbr < ISO_PEAK then | |
| regions[#regions + 1] = | |
| { s = t0 + (a - 1) / sr, e = t0 + b / sr, peak = pk, nbr = nbr } | |
| end | |
| end | |
| end | |
| return regions | |
| end | |
| -- Split one item into its keep/cut segments, delete cuts, re-lay keepers | |
| -- contiguously from the item's original start. Returns count removed. | |
| local function process_item(item, regions) | |
| if #regions == 0 then return 0 end | |
| local track = r.GetMediaItem_Track(item) | |
| local orig_pos = r.GetMediaItemInfo_Value(item, "D_POSITION") | |
| local orig_end = orig_pos + r.GetMediaItemInfo_Value(item, "D_LENGTH") | |
| -- interior boundaries, tagged so we know which spans are clicker cuts | |
| local bounds = {} | |
| for _, rg in ipairs(regions) do | |
| if rg.s > orig_pos + 1e-9 then bounds[#bounds + 1] = rg.s end | |
| if rg.e < orig_end - 1e-9 then bounds[#bounds + 1] = rg.e end | |
| end | |
| table.sort(bounds) | |
| -- split left-to-right, collecting ordered segments with their start time | |
| local segs = { { it = item, start = orig_pos } } | |
| local cur = item | |
| for _, b in ipairs(bounds) do | |
| local right = r.SplitMediaItem(cur, b) | |
| if right then | |
| segs[#segs + 1] = { it = right, start = b } | |
| cur = right | |
| end | |
| end | |
| -- decide keep vs cut: a segment is a cut if its start lies inside a region | |
| local function in_cut(t) | |
| for _, rg in ipairs(regions) do | |
| if t >= rg.s - 1e-6 and t < rg.e - 1e-6 then return true end | |
| end | |
| return false | |
| end | |
| local removed = 0 | |
| local keepers = {} | |
| for _, sg in ipairs(segs) do | |
| if in_cut(sg.start) then | |
| removed = removed + r.GetMediaItemInfo_Value(sg.it, "D_LENGTH") | |
| r.DeleteTrackMediaItem(track, sg.it) | |
| else | |
| keepers[#keepers + 1] = sg.it | |
| end | |
| end | |
| -- re-lay keepers contiguously from orig_pos, with small edge fades | |
| local cursor = orig_pos | |
| for idx, it in ipairs(keepers) do | |
| r.SetMediaItemInfo_Value(it, "D_POSITION", cursor) | |
| if idx > 1 then | |
| r.SetMediaItemInfo_Value(it, "D_FADEINLEN", EDGE_FADE) | |
| end | |
| if idx < #keepers then | |
| r.SetMediaItemInfo_Value(it, "D_FADEOUTLEN", EDGE_FADE) | |
| end | |
| cursor = cursor + r.GetMediaItemInfo_Value(it, "D_LENGTH") | |
| end | |
| return removed | |
| end | |
| ---------------------------------------------------------------------- | |
| -- MAIN | |
| ---------------------------------------------------------------------- | |
| local n_items = r.CountSelectedMediaItems(0) | |
| if n_items == 0 then | |
| r.ShowMessageBox("Select the item(s) containing the clicker recording first.", | |
| "De-click: nothing selected", 0) | |
| return | |
| end | |
| -- snapshot selected items (detect before any splits change pointers) | |
| local jobs = {} | |
| local total_events = 0 | |
| r.ShowConsoleMsg("") -- focus console | |
| for i = 0, n_items - 1 do | |
| local it = r.GetSelectedMediaItem(0, i) | |
| local regions = detect_events(it) | |
| jobs[#jobs + 1] = { item = it, regions = regions } | |
| total_events = total_events + #regions | |
| r.ShowConsoleMsg(string.format( | |
| "Item %d: peak %.3f, threshold %.2f, %d event(s) detected:\n", | |
| i + 1, EVENT_GPEAK or 0, CLICK_THRESH, #regions)) | |
| for k, rg in ipairs(regions) do | |
| r.ShowConsoleMsg(string.format( | |
| " %2d: %.3f -> %.3f s (%.0f ms) peak %.3f neighbour %.3f\n", | |
| k, rg.s, rg.e, (rg.e - rg.s) * 1000, rg.peak or 0, rg.nbr or 0)) | |
| end | |
| end | |
| if DRY_RUN then | |
| r.ShowConsoleMsg(string.format( | |
| "\nDRY RUN — nothing edited. %d total event(s).\n" .. | |
| "If that count/those times look right, set DRY_RUN = false at the top " .. | |
| "and run again to cut + ripple-close.\n", total_events)) | |
| return | |
| end | |
| r.Undo_BeginBlock() | |
| r.PreventUIRefresh(1) | |
| local total_removed = 0 | |
| for _, job in ipairs(jobs) do | |
| total_removed = total_removed + process_item(job.item, job.regions) | |
| end | |
| r.PreventUIRefresh(-1) | |
| r.UpdateArrange() | |
| r.Undo_EndBlock("Remove dog-clicker double-clicks and close gaps", -1) | |
| r.ShowConsoleMsg(string.format( | |
| "\nDe-click done: removed %d clicker event(s), %.0f ms total.\n", | |
| total_events, total_removed * 1000)) |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment