-- @description Singtall Vocal Align
-- @version 1.0
-- @author Singtall Studio
-- @website
--   Singtall Vocal Align https://singtallstudio.com/vocal-align.html
--   Singtall Studio https://singtallstudio.com
-- @changelog
--   First release.
-- @about
--   Lines stacked vocals up with the lead: the start of every note, and the end of
--   every held note, by cutting the items and sliding the pieces, the way it is
--   done by hand. The audio is never stretched.
--
--   1. Name the tracks (see below), select all the vocal tracks, run the script.
--   2. Check the report and press OK. One undo puts everything back.
--
--   NAMING FOR BEST RESULTS
--   The names tell the script who is who. Takes of the same part share a name and
--   get a number; different parts get different names:
--
--     Lead Vox 1, Lead Vox 2, Lead Vox 3     the lead and its doubles
--     Backing 1, Backing 2 ... Backing 6     backing vocals
--     High 1, High 2 / Low 1, Low 2          harmonies, one name per part (best)
--     Rap 1, Rap 2                           a second lead singer
--
--   - Number 1 is the main take of its part; the others line up to it. Put your
--     best take on 1.
--   - "Lead" (or "Main") in a name marks the lead. Without it, the lead is the
--     track numbered 1, and if several are, the loudest one panned nearest centre.
--   - Different names for different parts. If several parts share one name
--     (Backing 1-6), the script still sorts them out by listening, but separate
--     names (High 1/2, Low 1/2) are more reliable.
--   - Muted tracks are never processed: not used as a reference, never changed.
--     Mute the takes you don't use, even if they're selected.
--   - Render tuning or alignment plugins first: the script hears the audio
--     before plugins.
--
--   WHAT IT DOES
--   Every vocal is matched to the lead syllable by syllable: the lead's doubles
--   (same name) dead on it, backing vocals 20 ms behind it so they sit back.
--   Where a part doesn't follow the lead, its takes are matched to its own number 1.
--   An unnumbered pair that disagrees is left alone. A note held past the lead (or
--   past its own number 1) fades out exactly where that one stops, and a break the
--   lead sings is cut into a double that sang straight through it. Before anything
--   changes, plain copies of the tracks go into a muted "Vocal Align originals"
--   folder at the bottom of the project.

local SCRIPT_NAME = "Singtall Vocal Align"
local VERSION     = "1.0"
local WEBSITE     = "singtallstudio.com"

local CFG = {
  rate        = 8000,   -- analysis sample rate (voice features need up to ~4 kHz)
  hopMs       = 10,     -- one analysis frame every 10 ms
  win         = 200,    -- 25 ms analysis window (samples)
  nfft        = 256,
  nMel        = 20, loHz = 80, hiHz = 3800, nCep = 12,
  singDb      = 30,     -- a track is singing when within this of its loud level
  leadDb      = 25,     -- the lead is singing when within this of its loud level
  gapMs       = 200,    -- lead silences shorter than this stay inside a phrase
  minPhraseMs = 300,
  marginMs    = 300,    -- each phrase is matched with this much extra either side
  bandMs      = 250,    -- notes are matched up to this far apart
  followCost  = 0.42,   -- matching cost below this = "sings the same part"
  minShiftMs  = 20,     -- notes closer than this are left alone
  backingDelayMs = 20,  -- backing vocals land this far behind the lead, not dead on it
                        -- (the lead's own doubles, same name as the lead, stay tight)
  maxShiftMs  = 250,    -- notes further off than this are not trusted, left alone
  onsetRiseDb = 6,      -- a new note: level jumps this much over the last 50 ms
  onsetGapMs  = 120,
  heldTolMs   = 60,     -- holding this much past the reference counts as held
  maxHoldMs   = 2500,   -- longer than this is a different line, not a held note
  fadeMs      = 80,     -- fade-out of a held note, ending where the reference stops
  breakMinMs  = 30,     -- a break in the reference's note is at least this long...
  breakDropDb = 15,     -- ...and this far under its level just before
  throughDb   = 10,     -- a vocal that dips less than this across the break sang through it
  breakFadeMs = 30,     -- fade-out into a break in the middle of a note
  breakFadeInMs = 15,   -- fade back in where the reference comes back
  xfadeMs     = 10,     -- crossfade where a moved piece meets its neighbour
}

local TEST = SINGTALL_VOCALALIGN_TEST   -- set by the test rig only

local LOG_PATH = (({ reaper.get_action_context() })[2] or ""):match("^(.*[\\/])") or (reaper.GetResourcePath() .. "/Scripts/")
LOG_PATH = LOG_PATH .. "Singtall Vocal Align log.txt"
local logFile = io.open(LOG_PATH, "w")
local function log(s)
  if logFile then logFile:write(os.date("%H:%M:%S  ") .. s .. "\n"); logFile:flush() end
end

local HOP = CFG.hopMs / 1000
local HOPS = math.floor(CFG.rate * HOP + 0.5)

---------------------------------------------------------------------------
-- Helpers
---------------------------------------------------------------------------
local function trackName(tr) local _, n = reaper.GetTrackName(tr); return n end

local function percentile(t, p, i0, i1)
  local s = {}
  for i = i0 or 1, i1 or #t do s[#s + 1] = t[i] end
  if #s == 0 then return -200 end
  table.sort(s)
  return s[math.max(1, math.min(#s, math.floor(p * #s + 0.5)))]
end

local function median(t) return percentile(t, 0.5) end

local function itemsExtent(tr)
  local s, e = math.huge, -math.huge
  for i = 0, reaper.CountTrackMediaItems(tr) - 1 do
    local it = reaper.GetTrackMediaItem(tr, i)
    local p = reaper.GetMediaItemInfo_Value(it, "D_POSITION")
    local l = reaper.GetMediaItemInfo_Value(it, "D_LENGTH")
    if p < s then s = p end
    if p + l > e then e = p + l end
  end
  return s, e
end

-- "Best 1" -> "best", 1 ; "mid back" -> "mid back", nil
local function splitName(name)
  local base, num = name:match("^(.-)[%s_%-]*(%d+)%s*$")
  if not base or base == "" then return name:lower():gsub("^%s+", ""):gsub("%s+$", ""), nil end
  return base:lower():gsub("^%s+", ""):gsub("%s+$", ""), tonumber(num)
end

---------------------------------------------------------------------------
-- Audio
---------------------------------------------------------------------------
local readBuf, readBufSize
local function readMono(acc, t0, n)
  if not readBuf or readBufSize < n * 2 then readBufSize = n * 2; readBuf = reaper.new_array(readBufSize) end
  readBuf.clear()
  reaper.GetAudioAccessorSamples(acc, CFG.rate, 2, t0, n, readBuf)
  local t, out = readBuf.table(1, n * 2), {}
  for i = 1, n do out[i] = 0.5 * (t[2 * i - 1] + t[2 * i]) end
  return out
end

-- Level (dB) of every 10 ms frame over rs..rs+nF frames.
local function readEnv(m, rs, nF)
  local env, f = {}, 0
  local per = 100
  while f < nF do
    local k = math.min(per, nF - f)
    local s = readMono(m.acc, rs + f * HOP, k * HOPS)
    for q = 0, k - 1 do
      local e = 0
      for i = q * HOPS + 1, (q + 1) * HOPS do e = e + s[i] * s[i] end
      f = f + 1
      env[f] = 10 * math.log(e / HOPS + 1e-12, 10)
    end
  end
  return env
end

---------------------------------------------------------------------------
-- Voice features: mel-cepstrum per frame (vowel colour, not pitch, so a harmony
-- still matches the melody it follows)
---------------------------------------------------------------------------
local N = CFG.nfft
local fftRev, cosT, sinT = {}, {}, {}
do
  local bits = math.floor(math.log(N, 2) + 0.5)
  for i = 0, N - 1 do
    local r, x = 0, i
    for _ = 1, bits do r = r * 2 + (x % 2); x = x // 2 end
    fftRev[i + 1] = r + 1
  end
  for k = 0, N // 2 - 1 do cosT[k + 1] = math.cos(2 * math.pi * k / N); sinT[k + 1] = math.sin(2 * math.pi * k / N) end
end
local function fft(re, im)
  for i = 1, N do
    local j = fftRev[i]
    if j > i then re[i], re[j] = re[j], re[i]; im[i], im[j] = im[j], im[i] end
  end
  local size = 2
  while size <= N do
    local half, step = size // 2, N // size
    for start = 1, N, size do
      for k = 0, half - 1 do
        local wr, wi = cosT[k * step + 1], -sinT[k * step + 1]
        local a = start + k; local b = a + half
        local br, bi = re[b], im[b]
        local tr = wr * br - wi * bi; local ti = wr * bi + wi * br
        local ar, ai = re[a], im[a]
        re[b], im[b] = ar - tr, ai - ti
        re[a], im[a] = ar + tr, ai + ti
      end
    end
    size = size * 2
  end
end

local hann, melFB, dctM = {}, {}, {}
do
  for i = 1, CFG.win do hann[i] = 0.5 - 0.5 * math.cos(2 * math.pi * (i - 1) / (CFG.win - 1)) end
  local function mel(f) return 2595 * math.log(1 + f / 700, 10) end
  local function imel(m) return 700 * (10 ^ (m / 2595) - 1) end
  local lo, hi = mel(CFG.loHz), mel(CFG.hiHz)
  local hz = {}
  for b = 0, CFG.nMel + 1 do hz[b] = imel(lo + (hi - lo) * b / (CFG.nMel + 1)) end
  for b = 1, CFG.nMel do
    local w = {}
    for k = 0, N // 2 do
      local f = k * CFG.rate / N
      local v = math.min((f - hz[b - 1]) / (hz[b] - hz[b - 1]), (hz[b + 1] - f) / (hz[b + 1] - hz[b]))
      if v > 0 then w[#w + 1] = { k + 1, v } end
    end
    melFB[b] = w
  end
  for c = 1, CFG.nCep do
    dctM[c] = {}
    for b = 1, CFG.nMel do dctM[c][b] = math.cos(math.pi * c * (b - 0.5) / CFG.nMel) end
  end
end

-- Features of frames f0..f1 (1-based frame numbers on the common timeline):
-- unit-length cepstral vectors, normalised over the region.
local function features(m, rs, f0, f1)
  local n = f1 - f0 + 1
  local s = readMono(m.acc, rs + (f0 - 1) * HOP - (CFG.win - HOPS) / 2 / CFG.rate, (n - 1) * HOPS + CFG.win)
  local C = {}
  local re, im = {}, {}
  for q = 1, n do
    local o = (q - 1) * HOPS
    for i = 1, N do
      if i <= CFG.win then re[i] = s[o + i] * hann[i] else re[i] = 0 end
      im[i] = 0
    end
    fft(re, im)
    local lm = {}
    for b = 1, CFG.nMel do
      local e = 1e-10
      for _, w in ipairs(melFB[b]) do local k = w[1]; e = e + (re[k] * re[k] + im[k] * im[k]) * w[2] end
      lm[b] = math.log(e)
    end
    local c = {}
    for k = 1, CFG.nCep do
      local v = 0
      for b = 1, CFG.nMel do v = v + dctM[k][b] * lm[b] end
      c[k] = v
    end
    C[q] = c
  end
  for k = 1, CFG.nCep do
    local mu, sd = 0, 0
    for q = 1, n do mu = mu + C[q][k] end
    mu = mu / n
    for q = 1, n do sd = sd + (C[q][k] - mu) ^ 2 end
    sd = math.sqrt(sd / n) + 1e-9
    for q = 1, n do C[q][k] = (C[q][k] - mu) / sd end
  end
  for q = 1, n do
    local c, l = C[q], 0
    for k = 1, CFG.nCep do l = l + c[k] * c[k] end
    l = math.sqrt(l) + 1e-9
    for k = 1, CFG.nCep do c[k] = c[k] / l end
  end
  return C
end

-- Dynamic time warping within a diagonal band. Returns the path as two arrays
-- (frames of A, frames of B) and the mean cosine distance along it.
local function dtw(A, B, band)
  local n, m = #A, #B
  local INF = 1e18
  local W = m + 1
  local cost, back = {}, {}
  for i = 0, n do
    local lo, hi = math.max(0, i - band), math.min(m, i + band)
    for j = lo, hi do cost[i * W + j] = INF end
  end
  cost[0] = 0
  for i = 1, n do
    local a = A[i]
    for j = math.max(1, i - band), math.min(m, i + band) do
      local b = B[j]
      local d = 1
      for k = 1, CFG.nCep do d = d - a[k] * b[k] end
      local c0 = cost[(i - 1) * W + j - 1] or INF
      local c1 = cost[(i - 1) * W + j] or INF
      local c2 = cost[i * W + j - 1] or INF
      local best, k = c0, 0
      if c1 < best then best, k = c1, 1 end
      if c2 < best then best, k = c2, 2 end
      cost[i * W + j] = d + best
      back[i * W + j] = k
    end
  end
  local PI, PJ = {}, {}
  local i, j, total = n, m, 0
  while i > 0 and j > 0 do
    PI[#PI + 1], PJ[#PJ + 1] = i, j
    local a, b = A[i], B[j]
    local d = 1
    for k = 1, CFG.nCep do d = d - a[k] * b[k] end
    total = total + d
    local k = back[i * W + j]
    if k == 0 then i, j = i - 1, j - 1 elseif k == 1 then i = i - 1 else j = j - 1 end
  end
  local L = #PI
  local ri, rj = {}, {}
  for q = 1, L do ri[q], rj[q] = PI[L + 1 - q], PJ[L + 1 - q] end
  return ri, rj, total / math.max(1, L)
end

---------------------------------------------------------------------------
-- Activity
---------------------------------------------------------------------------
local function runs(on, nF, gapF, minF)
  local out, i = {}, 1
  while i <= nF do
    if on[i] then
      local j, last = i, i
      while j <= nF do
        if on[j] then last = j; j = j + 1
        elseif j - last <= gapF then j = j + 1
        else break end
      end
      if last - i + 1 >= minF then out[#out + 1] = { i, last } end
      i = j
    else i = i + 1 end
  end
  return out
end

-- New notes in frames f0..f1 of env: a rise of onsetRiseDb over the last 50 ms.
local function onsets(env, f0, f1, floor)
  local out, last = {}, -1e9
  local gap = math.floor(CFG.onsetGapMs / CFG.hopMs + 0.5)
  for j = math.max(6, f0), f1 do
    if env[j] > floor and j - last >= gap then
      local lo = math.huge
      for q = j - 5, j - 1 do if env[q] < lo then lo = env[q] end end
      if env[j] - lo >= CFG.onsetRiseDb then out[#out + 1] = j; last = j end
    end
  end
  return out
end

-- Where a note sounding at frame f stops: its level falls 20 dB under what it was
-- in the 50-250 ms before, for 50 ms. nil if it doesn't stop before fMax.
local function noteEnd(env, f, fMax)
  local ref = percentile(env, 0.5, math.max(1, f - 25), math.max(1, f - 5))
  for j = f - 10, fMax do
    local quiet = true
    for q = j, math.min(#env, j + 4) do if env[q] >= ref - 20 then quiet = false; break end end
    if quiet then return j, ref end
  end
  return nil, ref
end

---------------------------------------------------------------------------
-- Matching one vocal to a reference over one stretch
---------------------------------------------------------------------------
-- ops per track: segs = { {s, e, shift} } in the track's own time, trims = { {cut, del, fade} }
local function addSeg(m, s, e, shift) m.ops.segs[#m.ops.segs + 1] = { s = s, e = e, shift = shift } end

-- Align m to ref over frames f0..f1. breaks = where the reference goes quiet (see
-- findBreaks). Returns the matching cost, and whether it was aligned.
local function alignStretch(ctx, ref, m, f0, f1, breaks, moveNotes, delay)
  ref.cache = ref.cache or {}
  local key = f0 .. ":" .. f1
  local A = ref.cache[key]
  if not A then A = features(ref, ctx.rs, f0, f1); ref.cache[key] = A end
  local B = features(m, ctx.rs, f0, f1)
  local band = math.floor(CFG.bandMs / CFG.hopMs + 0.5)
  local PI, PJ, cost = dtw(A, B, band)
  if cost >= CFG.followCost then return cost, false end

  -- How far each of m's frames must move to land on the reference (frames).
  local sum, cnt = {}, {}
  for q = 1, #PI do
    local j = PJ[q]
    sum[j] = (sum[j] or 0) + (PI[q] - j); cnt[j] = (cnt[j] or 0) + 1
  end
  local function offsetAt(j0, j1)
    local v = {}
    for j = j0, j1 do if cnt[j] then v[#v + 1] = sum[j] / cnt[j] end end
    if #v == 0 then return 0 end
    return median(v)
  end

  local env = m.env
  local floor = m.loud - CFG.singDb
  local nRel = f1 - f0 + 1
  local pieces = {}   -- { startFrame (region-relative), shiftFrames }
  if moveNotes then
    local ons = onsets(env, f0, f1, floor)
    for _, jAbs in ipairs(ons) do
      local j = jAbs - f0 + 1
      -- cut in the dip just before the note
      local cut, lo = j, math.huge
      for q = math.max(1, j - 8), j do
        if env[q + f0 - 1] < lo then lo = env[q + f0 - 1]; cut = q end
      end
      -- where the note should land: on the reference, or `delay` frames behind it
      local sh = offsetAt(j, math.min(nRel, j + 8)) + (delay or 0)
      local ms = sh * CFG.hopMs
      if math.abs(ms) < CFG.minShiftMs or math.abs(ms) > CFG.maxShiftMs then sh = 0 end
      pieces[#pieces + 1] = { at = cut, sh = sh }
    end
  end
  -- Merge pieces that move by the same amount; drop moves of zero at the edges.
  local merged = {}
  for _, p in ipairs(pieces) do
    local last = merged[#merged]
    if last and math.abs(last.sh - p.sh) * CFG.hopMs < 10 then
      -- same move: keep one piece
    elseif not last or p.at > last.at then
      merged[#merged + 1] = p
    end
  end
  local tOf = function(rel) return ctx.rs + (rel + f0 - 2) * HOP end
  for k, p in ipairs(merged) do
    local nxt = merged[k + 1]
    local s, e = tOf(p.at), nxt and tOf(nxt.at) or tOf(nRel + 1)
    if p.sh ~= 0 and e > s then
      addSeg(m, s, e, p.sh * HOP)
      m.stats.moved = m.stats.moved + 1
      local ms = math.abs(p.sh * CFG.hopMs)
      if ms > m.stats.largest then m.stats.largest = ms end
    end
  end

  -- Every place the reference goes quiet (the end of the phrase, or a break in the
  -- middle of a note) while m is still sounding the same note:
  --   m's note ends during the quiet -> fade it out where the reference stopped;
  --   m holds straight through until the reference comes back -> cut the same
  --   break into m: fade out where the reference stops, fade back in where it returns.
  local tol = math.floor(CFG.heldTolMs / CFG.hopMs + 0.5)
  local maxHold = math.floor(CFG.maxHoldMs / CFG.hopMs)
  for _, br in ipairs(breaks or {}) do
    local sh = 0
    for _, p in ipairs(merged) do if p.at + f0 - 1 <= br.s then sh = p.sh end end
    local bs = math.floor(br.s - sh + 0.5)                  -- where the reference goes quiet, in m's time
    local be = br.e and math.floor(br.e - sh + 0.5)          -- where it comes back (nil: it doesn't)
    if not br.isEnd then
      -- A break inside the reference's note: if m's level doesn't dip at all across
      -- it, m sang straight through, so it gets the same break.
      if be and be > bs and bs > 16 and be <= #env then
        local pre = percentile(env, 0.5, bs - 15, bs - 1)
        local lo = math.huge
        for q = bs, be - 1 do if env[q] < lo then lo = env[q] end end
        if pre > floor and lo > pre - CFG.throughDb then
          m.ops.trims[#m.ops.trims + 1] = { cut = ctx.rs + (bs - 1) * HOP, del = ctx.rs + (be - 1) * HOP,
                                            fade = CFG.breakFadeMs / 1000, fadeIn = CFG.breakFadeInMs / 1000 }
          m.stats.breaks = m.stats.breaks + 1
        end
      end
    elseif env[bs + tol] and env[bs + tol] > floor then
      local limit = math.min(#env - 5, bs + maxHold, be or math.huge)
      local stop = noteEnd(env, bs + tol, limit)
      local newNote = #onsets(env, bs - 5, (stop or limit) - 4, floor) > 0
      if not newNote then
        local fade = (br.isEnd and CFG.fadeMs or CFG.breakFadeMs) / 1000
        if stop then
          m.ops.trims[#m.ops.trims + 1] = { cut = ctx.rs + (bs - 1) * HOP, del = ctx.rs + (stop + 3) * HOP, fade = fade }
          m.stats.held = m.stats.held + 1
        elseif be and be - bs <= maxHold then
          m.ops.trims[#m.ops.trims + 1] = { cut = ctx.rs + (bs - 1) * HOP, del = ctx.rs + (be - 1) * HOP, fade = fade,
                                            fadeIn = CFG.breakFadeInMs / 1000 }
          m.stats.breaks = m.stats.breaks + 1
        end
      end
    end
  end
  return cost, true
end

-- The reference's quiet spots in frames a..b: runs of at least breakMinMs where it
-- drops breakDropDb under its own level just before. The end of the phrase is
-- always one (isEnd), running to `nextStart` (nil if nothing follows).
local function findBreaks(ref, a, b, nextStart)
  local env, out = ref.env, {}
  local minF = math.floor(CFG.breakMinMs / CFG.hopMs + 0.5)
  local i = a + 15
  while i < b do
    local before = percentile(env, 0.5, i - 15, i - 1)
    if env[i] < before - CFG.breakDropDb and env[i] < ref.loud - CFG.leadDb then
      local j = i
      while j <= b and env[j] < before - CFG.breakDropDb do j = j + 1 end
      if j - i >= minF and j <= b then out[#out + 1] = { s = i, e = j } end
      i = j + 1
    else
      i = i + 1
    end
  end
  out[#out + 1] = { s = b + 1, e = nextStart, isEnd = true }
  return out
end

---------------------------------------------------------------------------
-- Editing
---------------------------------------------------------------------------
local function applyOps(m)
  local tr, segs, trims = m.track, m.ops.segs, m.ops.trims
  if #segs == 0 and #trims == 0 then return end
  local cuts = {}
  for _, s in ipairs(segs) do cuts[#cuts + 1] = s.s; cuts[#cuts + 1] = s.e end
  for _, t in ipairs(trims) do cuts[#cuts + 1] = t.cut; cuts[#cuts + 1] = t.del end
  table.sort(cuts)

  local function shiftAt(t)
    for _, s in ipairs(segs) do if t >= s.s and t < s.e then return s.shift end end
    return 0
  end
  local function trimAt(t)
    for _, x in ipairs(trims) do if t >= x.cut and t < x.del then return x end end
    return nil
  end

  -- Split every item at every cut; remember which original item each part came from.
  local originals = {}
  for i = 0, reaper.CountTrackMediaItems(tr) - 1 do originals[#originals + 1] = reaper.GetTrackMediaItem(tr, i) end
  local families = {}
  for _, item in ipairs(originals) do
    local fam, cur = { item }, item
    local ip = reaper.GetMediaItemInfo_Value(item, "D_POSITION")
    local ie = ip + reaper.GetMediaItemInfo_Value(item, "D_LENGTH")
    for _, t in ipairs(cuts) do
      if t > ip + 0.001 and t < ie - 0.001 then
        local right = reaper.SplitMediaItem(cur, t)
        if right then fam[#fam + 1] = right; cur = right end
      end
    end
    families[#families + 1] = fam
  end

  for _, fam in ipairs(families) do
    local kept = {}
    for _, part in ipairs(fam) do
      local ps = reaper.GetMediaItemInfo_Value(part, "D_POSITION")
      local pe = ps + reaper.GetMediaItemInfo_Value(part, "D_LENGTH")
      local mid = 0.5 * (ps + pe)
      local tr0 = trimAt(mid)
      if tr0 then
        reaper.DeleteTrackMediaItem(tr, part)
      else
        local sh = shiftAt(mid)
        -- a part that ends at a held-note cut fades out to that point
        for _, x in ipairs(trims) do
          if math.abs(pe - x.cut) < 0.002 then
            reaper.SetMediaItemInfo_Value(part, "D_FADEOUTLEN", math.min(x.fade, 0.5 * (pe - ps)))
          end
          -- and one that starts where the reference comes back after a break fades in
          if x.fadeIn and math.abs(ps - x.del) < 0.002 then
            reaper.SetMediaItemInfo_Value(part, "D_FADEINLEN", math.min(x.fadeIn, 0.5 * (pe - ps)))
          end
        end
        if sh ~= 0 then reaper.SetMediaItemInfo_Value(part, "D_POSITION", ps + sh) end
        kept[#kept + 1] = { item = part, moved = sh ~= 0 }
      end
    end
    -- Neighbouring parts of one original item: crossfade where they now overlap.
    for k = 1, #kept - 1 do
      local a, b = kept[k], kept[k + 1]
      if a.moved or b.moved then
        local ap = reaper.GetMediaItemInfo_Value(a.item, "D_POSITION")
        local ae = ap + reaper.GetMediaItemInfo_Value(a.item, "D_LENGTH")
        local bp = reaper.GetMediaItemInfo_Value(b.item, "D_POSITION")
        local x = CFG.xfadeMs / 1000
        if ae > bp then
          local newLen = math.max(0.005, bp + x - ap)
          reaper.SetMediaItemInfo_Value(a.item, "D_LENGTH", newLen)
        end
        reaper.SetMediaItemInfo_Value(a.item, "D_FADEOUTLEN",
          math.max(reaper.GetMediaItemInfo_Value(a.item, "D_FADEOUTLEN"), x))
        reaper.SetMediaItemInfo_Value(b.item, "D_FADEINLEN",
          math.max(reaper.GetMediaItemInfo_Value(b.item, "D_FADEINLEN"), x))
      end
    end
  end
end

-- Plain copies of the tracks as they were, muted, in a folder at the bottom.
local FOLDER_NAME = "Vocal Align originals"
local function backupTracks(list)
  local had = {}
  for i = 0, reaper.CountSelectedTracks(0) - 1 do had[#had + 1] = reaper.GetSelectedTrack(0, i) end
  reaper.Main_OnCommand(40297, 0)
  for _, tr in ipairs(list) do reaper.SetTrackSelected(tr, true) end
  reaper.Main_OnCommand(40062, 0)                      -- Track: Duplicate tracks
  local copies = {}
  for i = 0, reaper.CountSelectedTracks(0) - 1 do copies[#copies + 1] = reaper.GetSelectedTrack(0, i) end
  local name, n, taken = FOLDER_NAME, 1, {}
  for i = 0, reaper.CountTracks(0) - 1 do taken[trackName(reaper.GetTrack(0, i))] = true end
  while taken[name] do n = n + 1; name = FOLDER_NAME .. " " .. n end
  local last = reaper.CountTracks(0)
  local prev = last > 0 and reaper.GetTrack(0, last - 1)
  reaper.InsertTrackAtIndex(last, false)
  local folder = reaper.GetTrack(0, last)
  reaper.GetSetMediaTrackInfo_String(folder, "P_NAME", name, true)
  if prev and reaper.GetMediaTrackInfo_Value(prev, "I_FOLDERDEPTH") > 0 then
    reaper.SetMediaTrackInfo_Value(prev, "I_FOLDERDEPTH", 0)
  end
  reaper.Main_OnCommand(40297, 0)
  for _, c in ipairs(copies) do reaper.SetTrackSelected(c, true) end
  reaper.ReorderSelectedTracks(reaper.CountTracks(0), 1)
  for _, c in ipairs(copies) do
    reaper.GetSetMediaTrackInfo_String(c, "P_NAME", trackName(c) .. " (before Vocal Align)", true)
    reaper.SetMediaTrackInfo_Value(c, "B_MUTE", 1)
    for fx = reaper.TrackFX_GetCount(c) - 1, 0, -1 do reaper.TrackFX_Delete(c, fx) end
  end
  reaper.SetMediaTrackInfo_Value(folder, "B_MUTE", 1)
  reaper.SetMediaTrackInfo_Value(folder, "I_FOLDERCOMPACT", 2)
  reaper.Main_OnCommand(40297, 0)
  for _, tr in ipairs(had) do reaper.SetTrackSelected(tr, true) end
  return name
end

---------------------------------------------------------------------------
-- UI
---------------------------------------------------------------------------
local function msg(text, kind)
  log("message box (" .. (kind == 1 and "OK/Cancel" or "OK") .. "): " .. text:gsub("\n", " | "):sub(1, 600))
  if TEST then TEST.log = (TEST.log or "") .. text .. "\n"; return 1 end
  local t0 = reaper.time_precise()
  local r = reaper.ShowMessageBox(text, SCRIPT_NAME, kind or 0)
  log(string.format("  box returned %s after %.1f s (1 = OK, 2 = Cancel)", tostring(r), reaper.time_precise() - t0))
  return r
end

---------------------------------------------------------------------------
-- Main
---------------------------------------------------------------------------
local function main()
  local tracks = {}
  if TEST and TEST.trackNumbers then
    for _, n in ipairs(TEST.trackNumbers) do tracks[#tracks + 1] = reaper.GetTrack(0, n - 1) end
  else
    for i = 0, reaper.CountSelectedTracks(0) - 1 do tracks[#tracks + 1] = reaper.GetSelectedTrack(0, i) end
  end
  local mics, muted = {}, {}
  for _, tr in ipairs(tracks) do
    -- A muted track is a take that was thrown out: never a reference, never changed.
    if reaper.GetMediaTrackInfo_Value(tr, "B_MUTE") == 1 then
      muted[#muted + 1] = trackName(tr)
    elseif reaper.CountTrackMediaItems(tr) > 0 then
      local base, num = splitName(trackName(tr))
      mics[#mics + 1] = { track = tr, name = trackName(tr), base = base, num = num,
                          vol = reaper.GetMediaTrackInfo_Value(tr, "D_VOL"),
                          pan = reaper.GetMediaTrackInfo_Value(tr, "D_PAN"),
                          ops = { segs = {}, trims = {} }, stats = { moved = 0, largest = 0, held = 0, breaks = 0 } }
    end
  end
  local names = {}
  for _, m in ipairs(mics) do names[#names + 1] = m.name end
  log(#mics .. " vocal tracks with items: " .. table.concat(names, ", ")
      .. (#muted > 0 and ("; skipped muted: " .. table.concat(muted, ", ")) or ""))
  if #mics < 2 then
    msg("Select the vocal tracks first (Ctrl+click their names): the lead, its doubles and any backing vocals.\n\n"
        .. #mics .. " selected track(s) have items and aren't muted.")
    return
  end

  -- The lead:
  --   1. a track with "lead" or "main" in its name (number 1 first if several);
  --   2. else a track numbered 1 in a group of same-named tracks (Best 1 with Best 2,
  --      Best 3), the loudest and most centred one if several groups have a 1;
  --   3. else the loudest, most centred track.
  local groups = {}
  for _, m in ipairs(mics) do groups[m.base] = groups[m.base] or {}; table.insert(groups[m.base], m) end
  local function score(m) return 20 * math.log(m.vol + 1e-9, 10) - 20 * math.abs(m.pan) end
  local function better(a, b)          -- is a a better lead than b?
    if not b then return true end
    if (a.num == 1) ~= (b.num == 1) then return a.num == 1 end
    return score(a) > score(b)
  end
  local lead, how
  for _, m in ipairs(mics) do
    local n = " " .. m.name:lower():gsub("[%p_]", " ") .. " "
    if (n:find("%slead%s") or n:find("%smain%s")) and better(m, lead) then lead = m; how = "named lead" end
  end
  if not lead then
    local ones = 0
    for _, m in ipairs(mics) do
      if m.num == 1 and #groups[m.base] >= 2 then
        ones = ones + 1
        if not lead or score(m) > score(lead) then lead = m end
      end
    end
    if lead then
      how = ones > 1 and "numbered 1; loudest and most centred of " .. ones or "numbered 1"
    end
  end
  if not lead then
    for _, m in ipairs(mics) do if not lead or score(m) > score(lead) then lead = m end end
    how = "loudest and most centred"
  end
  lead.isLead = true
  lead.how = how
  log("lead: " .. lead.name .. " (" .. how .. ")")

  -- Levels of every track on one timeline.
  local rs, re = math.huge, -math.huge
  for _, m in ipairs(mics) do
    local s, e = itemsExtent(m.track)
    if s < rs then rs = s end
    if e > re then re = e end
  end
  local nF = math.floor((re - rs) / HOP)
  local ctx = { rs = rs, nF = nF }
  for _, m in ipairs(mics) do
    m.acc = reaper.CreateTrackAudioAccessor(m.track)
    m.env = readEnv(m, rs, nF)
    local loudOnes = {}
    for i = 1, nF do if m.env[i] > -100 then loudOnes[#loudOnes + 1] = m.env[i] end end
    m.loud = #loudOnes > 0 and percentile(loudOnes, 0.95) or -20
    m.handled = {}
  end
  log(string.format("read %d tracks, %.0f s", #mics, re - rs))

  -- The lead's phrases, each with its own stretch of timeline (split halfway
  -- between phrases, at most marginMs either side).
  local on = {}
  for i = 1, nF do on[i] = lead.env[i] > lead.loud - CFG.leadDb end
  local phr = runs(on, nF, math.floor(CFG.gapMs / CFG.hopMs), math.floor(CFG.minPhraseMs / CFG.hopMs))
  local margin = math.floor(CFG.marginMs / CFG.hopMs)
  for k, p in ipairs(phr) do
    local prevEnd = k > 1 and phr[k - 1][2] or -1e9
    local nextStart = k < #phr and phr[k + 1][1] or 1e9
    p.f0 = math.max(1, p[1] - margin, math.floor((prevEnd + p[1]) / 2) + 1)
    p.f1 = math.min(nF, p[2] + margin, math.floor((p[2] + nextStart) / 2))
  end
  local nBreaks = 0
  for k, p in ipairs(phr) do
    p.breaks = findBreaks(lead, p[1], p[2], k < #phr and phr[k + 1][1] or nil)
    nBreaks = nBreaks + #p.breaks - 1
  end
  log(#phr .. " lead phrases, " .. nBreaks .. " breaks inside them")

  -- 1. Everything against the lead, phrase by phrase.
  for _, m in ipairs(mics) do
    if not m.isLead then
      for _, p in ipairs(phr) do
        local singing = 0
        for i = p.f0, p.f1 do if m.env[i] > m.loud - CFG.singDb then singing = singing + 1 end end
        if singing >= 0.3 * (p.f1 - p.f0 + 1) then
          local delay = (m.base ~= lead.base) and CFG.backingDelayMs / CFG.hopMs or 0
          local _, ok = alignStretch(ctx, lead, m, p.f0, p.f1, p.breaks, true, delay)
          if ok then m.handled[#m.handled + 1] = { p.f0, p.f1 }; m.stats.withLead = (m.stats.withLead or 0) + 1 end
        end
      end
    end
  end

  -- 2. What doesn't follow the lead: within each pile of same-named tracks.
  local function handledShare(m, f0, f1)
    local c = 0
    for _, h in ipairs(m.handled) do
      local a, b = math.max(f0, h[1]), math.min(f1, h[2])
      if b > a then c = c + (b - a) end
    end
    return c / math.max(1, f1 - f0)
  end
  for base, g in pairs(groups) do
    local pile = {}
    for _, m in ipairs(g) do if not m.isLead then pile[#pile + 1] = m end end
    if #pile >= 2 then
      local act = {}
      for i = 1, nF do
        act[i] = false
        for _, m in ipairs(pile) do if m.env[i] > m.loud - CFG.singDb then act[i] = true; break end end
      end
      for _, r in ipairs(runs(act, nF, 30, 30)) do
        local f0, f1 = math.max(1, r[1] - 10), math.min(nF, r[2] + 30)
        local todo = {}
        for _, m in ipairs(pile) do if handledShare(m, f0, f1) < 0.5 then todo[#todo + 1] = m end end
        if #todo >= 2 then
          -- each member's own end of singing in this stretch
          for _, m in ipairs(todo) do
            m.stretchEnd = nil
            for i = f1, f0, -1 do if m.env[i] > m.loud - CFG.singDb then m.stretchEnd = i; break end end
          end
          -- The pile's reference is its lowest-numbered member (Back Vox 1: often a
          -- second lead singer), else, with 3 or more, the member closest to all the
          -- others. Members that don't match the reference sing a different part
          -- (a harmony), so they are sorted out among themselves the same way. A pair
          -- with no number has no third voice to say who's late: only held notes
          -- are fixed, the one that holds on longer fading out where the other stops.
          local function settle(group)
            if #group < 2 then return end
            local ref
            for _, m in ipairs(group) do if m.num and (not ref or m.num < ref.num) then ref = m end end
            if not ref and #group >= 3 then
              local bestCost
              for _, a in ipairs(group) do
                local total = 0
                local Fa = features(a, rs, f0, f1)
                for _, b in ipairs(group) do
                  if b ~= a then local _, _, c = dtw(Fa, features(b, rs, f0, f1), math.floor(CFG.bandMs / CFG.hopMs)); total = total + c end
                end
                if not bestCost or total < bestCost then ref, bestCost = a, total end
              end
            end
            if ref then
              local rest = {}
              for _, m in ipairs(group) do
                if m ~= ref then
                  local brk = ref.stretchEnd and findBreaks(ref, r[1], ref.stretchEnd, nil) or {}
                  local _, ok = alignStretch(ctx, ref, m, f0, f1, brk, true)
                  if ok then m.stats.withPile = (m.stats.withPile or 0) + 1 else rest[#rest + 1] = m end
                end
              end
              settle(rest)
            else
              local a, b = group[1], group[2]
              if a.stretchEnd and b.stretchEnd then
                local early, late = a, b
                if b.stretchEnd < a.stretchEnd then early, late = b, a end
                local _, ok = alignStretch(ctx, early, late, f0, f1, { { s = early.stretchEnd + 1, isEnd = true } }, false)
                if ok then late.stats.withPile = (late.stats.withPile or 0) + 1 end
              end
            end
          end
          settle(todo)
        end
      end
    end
  end
  for _, m in ipairs(mics) do reaper.DestroyAudioAccessor(m.acc); m.acc = nil end

  -- Report
  local lines = { "Lead: " .. lead.name .. "   (" .. lead.how .. ", " .. #phr .. " phrases)",
                  string.format("Its doubles line up on it; backing vocals land %d ms behind it.", CFG.backingDelayMs) }
  if lead.how ~= "named lead" and lead.how ~= "numbered 1" then
    lines[#lines + 1] = "Wrong track? Cancel, put \"Lead\" in the lead vocal's name, and run it again."
  end
  if #muted > 0 then
    lines[#lines + 1] = "Skipped (muted): " .. table.concat(muted, ", ")
  end
  lines[#lines + 1] = ""
  local changing = {}
  for _, m in ipairs(mics) do
    if not m.isLead then
      local s = m.stats
      local what = {}
      if s.moved > 0 then what[#what + 1] = string.format("move %d note%s (up to %d ms)", s.moved, s.moved == 1 and "" or "s", s.largest) end
      if s.held > 0 then what[#what + 1] = string.format("fade %d held note%s", s.held, s.held == 1 and "" or "s") end
      if s.breaks > 0 then what[#what + 1] = string.format("cut %d break%s to match", s.breaks, s.breaks == 1 and "" or "s") end
      local how = (s.withLead and s.withPile) and "follows the lead and its pile"
               or (s.withLead and "follows the lead") or (s.withPile and "matched within its pile")
               or "its own part, left alone"
      lines[#lines + 1] = string.format("%s:  %s   [%s]", m.name, #what > 0 and table.concat(what, ", ") or "already in time", how)
      if #what > 0 then changing[#changing + 1] = m end
    end
  end
  lines[#lines + 1] = ""
  if TEST then TEST.mics = mics; TEST.lead = lead; TEST.phrases = phr end
  if #changing == 0 then
    lines[#lines + 1] = "Nothing needs changing."
    msg(table.concat(lines, "\n"))
    return
  end
  lines[#lines + 1] = "Press OK to cut and move the notes. Copies of these tracks as they are now go"
  lines[#lines + 1] = "into a muted \"" .. FOLDER_NAME .. "\" folder at the bottom. One undo puts everything back."
  lines[#lines + 1] = "\nFree from Singtall Studio - " .. WEBSITE
  local ok = msg(table.concat(lines, "\n"), 1)
  if ok ~= 1 or (TEST and not TEST.apply) then return end

  reaper.Undo_BeginBlock()
  reaper.PreventUIRefresh(1)
  local list = {}
  for _, m in ipairs(changing) do list[#list + 1] = m.track end
  local folder = backupTracks(list)
  for _, m in ipairs(changing) do applyOps(m) end
  reaper.PreventUIRefresh(-1)
  reaper.TrackList_AdjustWindows(false)
  reaper.UpdateArrange()
  reaper.Undo_EndBlock(SCRIPT_NAME, -1)
  log("changes applied; backups in '" .. folder .. "'")
  msg(string.format("Done: changed %d track(s).\n\nThe tracks as they were are in the muted \"%s\" folder at the bottom.\nCtrl+Z puts everything back.", #changing, folder))
end

log(SCRIPT_NAME .. " " .. VERSION .. " started")
local ok, err = xpcall(main, debug.traceback)
if not ok then
  log("ERROR: " .. tostring(err))
  if TEST then error(err) end
  reaper.ShowMessageBox("Something went wrong:\n\n" .. tostring(err) .. "\n\nDetails were saved to:\n" .. LOG_PATH,
                        SCRIPT_NAME, 0)
end
log("finished")
if logFile then logFile:close() end
