Skip to content

Instantly share code, notes, and snippets.

@mumblequatch
Created June 6, 2026 15:06
Show Gist options
  • Select an option

  • Save mumblequatch/5316e8235bae40c8dd5c8f35297ebca9 to your computer and use it in GitHub Desktop.

Select an option

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)
--[[
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