UI cleanup, Devon overhaul, bug fixes, publish ep36

- Fix Devon double messages, add conversation persistence, voice-to-Devon when no caller
- Devon personality: weird/lovable intern on first day, handles name misspellings
- Fix caller gender/avatar mismatch (avatar seed includes gender)
- Reserve Sebastian voice for Silas, ban "eating at me" phrase harder
- Callers now hear Devon's commentary in conversation context
- CSS cleanup: expand compressed blocks, remove inline styles, fix Devon color to warm tawny
- Reaper silence threshold 7s → 6s
- Publish episode 36

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-03-14 16:42:21 -06:00
parent 6d4e490283
commit 3329cf9ac2
11 changed files with 2300 additions and 187 deletions

View File

@@ -0,0 +1,920 @@
-- Post-Production Script for REAPER
-- Phase 1: Strip long silences from DIALOG regions (all tracks except music)
-- Phase 2: Normalize AD/IDENT/music volume to match dialog
-- Phase 3: Trim music to length of longest voice track with fade-out
-- Phase 4: Mute music during AD/IDENT regions with fade in/out
---------------------------------------------------------------------------
-- SETTINGS
---------------------------------------------------------------------------
local SILENCE_DB = -30 -- dBFS — anything below this is "silence"
local MIN_SILENCE_SEC = 6.0 -- only remove silences longer than this
local MIN_VOICE_SEC = 0.3 -- ignore non-silent bursts shorter than this (filters transients)
local KEEP_PAD_SEC = 0.5 -- leave this much silence on each side of a cut
local BLOCK_SEC = 0.1 -- analysis block size (100ms)
local SAMPLE_RATE = 48000
local CHECK_TRACKS = {1, 2, 3} -- 1-indexed: Host, Live Caller, AI Caller
local IDENTS_TRACK = 5 -- 1-indexed: Idents track
local ADS_TRACK = 6 -- 1-indexed: Ads track
local MUSIC_TRACK = 7 -- 1-indexed: Music track
local MUSIC_FADE_SEC = 2.0 -- fade duration for music in/out around ads/idents
local YIELD_INTERVAL = 200 -- yield to REAPER every N blocks (~20s of audio)
---------------------------------------------------------------------------
local BLOCK_SAMPLES = math.floor(SAMPLE_RATE * BLOCK_SEC)
local THRESHOLD = 10 ^ (SILENCE_DB / 20)
local MIN_VOICE_BLOCKS = math.ceil(MIN_VOICE_SEC / BLOCK_SEC)
local function log(msg)
reaper.ShowConsoleMsg("[PostProd] " .. msg .. "\n")
end
---------------------------------------------------------------------------
-- Progress window (gfx)
---------------------------------------------------------------------------
local progress_phase = ""
local progress_pct = 0
local progress_detail = ""
local function progress_init()
gfx.init("Post-Production", 420, 60)
gfx.setfont(1, "Arial", 14)
end
local function progress_draw()
if gfx.getchar() < 0 then return false end
gfx.set(0.12, 0.12, 0.12)
gfx.rect(0, 0, 420, 60, true)
-- Label
gfx.set(1, 1, 1)
gfx.x = 10; gfx.y = 8
gfx.drawstr(progress_phase)
gfx.x = 300; gfx.y = 8
gfx.drawstr(progress_detail)
-- Bar background
gfx.set(0.25, 0.25, 0.25)
gfx.rect(10, 32, 400, 18, true)
-- Bar fill
gfx.set(0.2, 0.7, 0.3)
local fill = math.min(math.floor(400 * progress_pct), 400)
if fill > 0 then gfx.rect(10, 32, fill, 18, true) end
gfx.update()
return true
end
local function progress_close()
gfx.quit()
end
---------------------------------------------------------------------------
-- Region helpers
---------------------------------------------------------------------------
local function get_regions_by_type(type_pattern)
local regions = {}
local _, num_markers, num_regions = reaper.CountProjectMarkers(0)
local total = num_markers + num_regions
for i = 0, total - 1 do
local retval, is_region, pos, rgnend, name, idx = reaper.EnumProjectMarkers(i)
if is_region and name and name:match(type_pattern) then
table.insert(regions, {start_pos = pos, end_pos = rgnend, name = name})
end
end
table.sort(regions, function(a, b) return a.start_pos < b.start_pos end)
return regions
end
local function merge_regions(regions)
if #regions <= 1 then return regions end
table.sort(regions, function(a, b) return a.start_pos < b.start_pos end)
local merged = {{start_pos = regions[1].start_pos, end_pos = regions[1].end_pos, name = "MERGED 1"}}
for i = 2, #regions do
local prev = merged[#merged]
if regions[i].start_pos <= prev.end_pos then
prev.end_pos = math.max(prev.end_pos, regions[i].end_pos)
else
table.insert(merged, {start_pos = regions[i].start_pos, end_pos = regions[i].end_pos, name = "MERGED " .. (#merged + 1)})
end
end
return merged
end
local function shift_regions(removals)
local _, num_markers, num_regions = reaper.CountProjectMarkers(0)
local total_markers = num_markers + num_regions
local markers = {}
for i = 0, total_markers - 1 do
local retval, is_region, pos, rgnend, name, idx, color = reaper.EnumProjectMarkers3(0, i)
if retval then
table.insert(markers, {is_region=is_region, pos=pos, rgnend=rgnend, name=name, idx=idx, color=color})
end
end
for _, m in ipairs(markers) do
local pos_shift = 0
for _, r in ipairs(removals) do
if r.end_pos <= m.pos then
pos_shift = pos_shift + (r.end_pos - r.start_pos)
elseif r.start_pos < m.pos then
pos_shift = pos_shift + (m.pos - r.start_pos)
end
end
m.new_pos = m.pos - pos_shift
if m.is_region then
local end_shift = 0
for _, r in ipairs(removals) do
if r.end_pos <= m.rgnend then
end_shift = end_shift + (r.end_pos - r.start_pos)
elseif r.start_pos < m.rgnend then
end_shift = end_shift + (m.rgnend - r.start_pos)
end
end
m.new_end = m.rgnend - end_shift
end
end
for _, m in ipairs(markers) do
if m.is_region then
reaper.SetProjectMarker3(0, m.idx, true, m.new_pos, m.new_end, m.name, m.color)
else
reaper.SetProjectMarker3(0, m.idx, false, m.new_pos, 0, m.name, m.color)
end
end
end
local function find_item_at(track, pos)
for i = 0, reaper.CountTrackMediaItems(track) - 1 do
local item = reaper.GetTrackMediaItem(track, i)
local item_start = reaper.GetMediaItemInfo_Value(item, "D_POSITION")
local item_len = reaper.GetMediaItemInfo_Value(item, "D_LENGTH")
if pos >= item_start and pos < item_start + item_len then
return item
end
end
return nil
end
---------------------------------------------------------------------------
-- Phase 1: Silence detection and removal
---------------------------------------------------------------------------
-- Read audio directly from WAV files (bypasses REAPER accessor — immune to undo issues)
local function parse_wav_header(filepath)
local f = io.open(filepath, "rb")
if not f then return nil end
local riff = f:read(4)
if riff ~= "RIFF" then f:close(); return nil end
f:read(4) -- file size
if f:read(4) ~= "WAVE" then f:close(); return nil end
local fmt_info = nil
while true do
local id = f:read(4)
if not id then f:close(); return nil end
local size = string.unpack("<I4", f:read(4))
if id == "fmt " then
local audio_fmt = string.unpack("<I2", f:read(2))
local channels = string.unpack("<I2", f:read(2))
local sr = string.unpack("<I4", f:read(4))
f:read(4) -- byte rate
f:read(2) -- block align
local bps = string.unpack("<I2", f:read(2))
if size > 16 then f:read(size - 16) end
fmt_info = {audio_fmt = audio_fmt, channels = channels, sample_rate = sr, bps = bps}
elseif id == "data" then
if not fmt_info then f:close(); return nil end
local data_offset = f:seek()
f:close()
fmt_info.data_offset = data_offset
fmt_info.data_size = size
fmt_info.filepath = filepath
fmt_info.bytes_per_sample = fmt_info.bps / 8
fmt_info.frame_size = fmt_info.channels * fmt_info.bytes_per_sample
return fmt_info
else
f:read(size)
end
end
end
local function get_track_audio(track_idx_1based)
local track = reaper.GetTrack(0, track_idx_1based - 1)
if not track or reaper.CountTrackMediaItems(track) == 0 then return nil end
local segments = {}
for i = 0, reaper.CountTrackMediaItems(track) - 1 do
local item = reaper.GetTrackMediaItem(track, i)
local take = reaper.GetActiveTake(item)
if take then
local source = reaper.GetMediaItemTake_Source(take)
local filepath = reaper.GetMediaSourceFileName(source)
local wav = parse_wav_header(filepath)
if wav then
local item_pos = reaper.GetMediaItemInfo_Value(item, "D_POSITION")
local item_len = reaper.GetMediaItemInfo_Value(item, "D_LENGTH")
local take_offset = reaper.GetMediaItemTakeInfo_Value(take, "D_STARTOFFS")
local fh = io.open(filepath, "rb")
if fh then
table.insert(segments, {
fh = fh,
wav = wav,
item_pos = item_pos,
item_end = item_pos + item_len,
take_offset = take_offset,
})
end
else
log(" WARNING: Could not parse WAV header for: " .. filepath)
end
end
end
if #segments == 0 then return nil end
-- Sort by position so binary-style lookup is possible
table.sort(segments, function(a, b) return a.item_pos < b.item_pos end)
return {
segments = segments,
item_pos = segments[1].item_pos,
item_end = segments[#segments].item_end,
}
end
local function destroy_track_audio(ta)
for _, seg in ipairs(ta.segments) do
if seg.fh then seg.fh:close(); seg.fh = nil end
end
end
local function read_block_peak_rms_segment(seg, project_time)
local source_time = project_time - seg.item_pos + seg.take_offset
if source_time < 0 then return 0, 0 end
local wav = seg.wav
local sample_offset = math.floor(source_time * wav.sample_rate)
local byte_offset = wav.data_offset + sample_offset * wav.frame_size
local bytes_needed = BLOCK_SAMPLES * wav.frame_size
if byte_offset + bytes_needed > wav.data_offset + wav.data_size then
return 0, 0
end
seg.fh:seek("set", byte_offset)
local raw = seg.fh:read(bytes_needed)
if not raw or #raw < bytes_needed then return 0, 0 end
local peak = 0
local sum_sq = 0
local bps = wav.bytes_per_sample
for i = 0, BLOCK_SAMPLES - 1 do
local offset = i * wav.frame_size
local v = 0
if wav.audio_fmt == 3 then
v = string.unpack("<f", raw, offset + 1)
elseif bps == 3 then
local b1, b2, b3 = string.byte(raw, offset + 1, offset + 3)
local val = b1 + b2 * 256 + b3 * 65536
if val >= 8388608 then val = val - 16777216 end
v = val / 8388608.0
elseif bps == 2 then
v = string.unpack("<i2", raw, offset + 1) / 32768.0
elseif bps == 4 and wav.audio_fmt == 1 then
v = string.unpack("<i4", raw, offset + 1) / 2147483648.0
end
sum_sq = sum_sq + v * v
local av = math.abs(v)
if av > peak then peak = av end
end
return peak, sum_sq
end
local function read_block_peak_rms(ta, project_time)
-- Find the segment that contains this project time
for _, seg in ipairs(ta.segments) do
if project_time >= seg.item_pos and project_time < seg.item_end then
return read_block_peak_rms_segment(seg, project_time)
end
end
return 0, 0
end
-- find_silences: detects silences and accumulates RMS data
-- Yields periodically via coroutine for UI responsiveness
-- progress_fn(t): called before each yield with current position
local function find_silences(region, track_audios, rms_acc, progress_fn)
local silences = {}
local in_silence = false
local silence_start = 0
local voice_run = 0
local t = region.start_pos
local total_blocks = 0
local silent_blocks = 0
local yield_count = 0
while t < region.end_pos do
local best_peak = 0
local best_sum = 0
for _, ta in ipairs(track_audios) do
local peak, sum_sq = read_block_peak_rms(ta, t)
if peak > best_peak then
best_peak = peak
best_sum = sum_sq
end
end
local all_silent = best_peak < THRESHOLD
total_blocks = total_blocks + 1
if all_silent then silent_blocks = silent_blocks + 1 end
if not all_silent and rms_acc then
rms_acc.sum_sq = rms_acc.sum_sq + best_sum
rms_acc.count = rms_acc.count + BLOCK_SAMPLES
end
if in_silence then
if all_silent then
voice_run = 0
else
voice_run = voice_run + 1
if voice_run >= MIN_VOICE_BLOCKS then
local voice_start = t - (voice_run - 1) * BLOCK_SEC
local dur = voice_start - silence_start
if dur >= MIN_SILENCE_SEC then
table.insert(silences, {start_pos = silence_start, end_pos = voice_start, duration = dur})
end
in_silence = false
voice_run = 0
end
end
else
if all_silent then
in_silence = true
silence_start = t
voice_run = 0
end
end
t = t + BLOCK_SEC
-- Yield periodically so REAPER stays responsive
yield_count = yield_count + 1
if yield_count >= YIELD_INTERVAL then
yield_count = 0
if progress_fn then progress_fn(t) end
coroutine.yield()
end
end
if in_silence then
local dur = region.end_pos - silence_start
if dur >= MIN_SILENCE_SEC then
table.insert(silences, {start_pos = silence_start, end_pos = region.end_pos, duration = dur})
end
end
return silences, total_blocks, silent_blocks
end
local function phase1_strip_silence(dialog_regions)
dialog_regions = merge_regions(dialog_regions)
log("Phase 1: " .. #dialog_regions .. " merged DIALOG region(s)")
local track_audios = {}
local tracks_loaded = 0
for _, tidx in ipairs(CHECK_TRACKS) do
local ta = get_track_audio(tidx)
if ta then
table.insert(track_audios, ta)
tracks_loaded = tracks_loaded + 1
local first_wav = ta.segments[1].wav
local fmt = first_wav.audio_fmt == 3 and "float" or (first_wav.bps .. "bit")
log(" Track " .. tidx .. ": " .. #ta.segments .. " item(s), " .. fmt .. " " .. first_wav.sample_rate .. "Hz (pos=" .. string.format("%.1f", ta.item_pos) .. " end=" .. string.format("%.1f", ta.item_end) .. ")")
else
log(" WARNING: Track " .. tidx .. " has no audio items — silence detection will NOT check this track")
end
end
if tracks_loaded == 0 then
log("Phase 1: No audio found on voice tracks — skipping")
return false, 0
end
if tracks_loaded < #CHECK_TRACKS then
log(" *** Only " .. tracks_loaded .. "/" .. #CHECK_TRACKS .. " voice tracks have audio — silence may be over-detected ***")
end
-- Load AD/IDENT regions so we can protect them from silence removal
local protected_regions = {}
for _, r in ipairs(get_regions_by_type("^AD%s+%d+$")) do table.insert(protected_regions, r) end
for _, r in ipairs(get_regions_by_type("^IDENT%s+%d+$")) do table.insert(protected_regions, r) end
table.sort(protected_regions, function(a, b) return a.start_pos < b.start_pos end)
if #protected_regions > 0 then
log(" Protecting " .. #protected_regions .. " AD/IDENT region(s) from silence removal")
end
log("Phase 1: Analyzing using " .. tracks_loaded .. "/" .. #CHECK_TRACKS .. " voice tracks")
log(" threshold=" .. SILENCE_DB .. "dB, min_silence=" .. MIN_SILENCE_SEC .. "s, pad=" .. KEEP_PAD_SEC .. "s")
-- Calculate total duration for progress tracking
local total_duration = 0
for _, rgn in ipairs(dialog_regions) do
total_duration = total_duration + (rgn.end_pos - rgn.start_pos)
end
local processed_duration = 0
local rms_acc = {sum_sq = 0, count = 0}
local removals = {}
local total_blocks = 0
local silent_blocks = 0
for ri, rgn in ipairs(dialog_regions) do
local rgn_dur = rgn.end_pos - rgn.start_pos
local function update_progress(t)
local rgn_progress = (t - rgn.start_pos) / rgn_dur
progress_pct = (processed_duration + rgn_progress * rgn_dur) / total_duration
progress_phase = "Phase 1: Scanning"
progress_detail = string.format("Region %d/%d", ri, #dialog_regions)
end
local silences, rgn_total, rgn_silent = find_silences(rgn, track_audios, rms_acc, update_progress)
processed_duration = processed_duration + rgn_dur
total_blocks = total_blocks + rgn_total
silent_blocks = silent_blocks + rgn_silent
log(" " .. rgn.name .. ": " .. rgn_total .. " blocks, " .. rgn_silent .. " silent (" .. string.format("%.0f", rgn_silent/math.max(rgn_total,1)*100) .. "%)")
for _, s in ipairs(silences) do
local rm_start = s.start_pos + KEEP_PAD_SEC
local rm_end = s.end_pos - KEEP_PAD_SEC
if rm_end > rm_start + 0.05 then
-- Check if this silence overlaps with any AD/IDENT region
local protected = false
for _, pr in ipairs(protected_regions) do
if rm_start < pr.end_pos and rm_end > pr.start_pos then
protected = true
log(" SKIP " .. string.format("%.1f", rm_end - rm_start) .. "s at " .. string.format("%.1f", s.start_pos) .. "-" .. string.format("%.1f", s.end_pos) .. " (overlaps " .. pr.name .. ")")
break
end
end
if not protected then
table.insert(removals, {start_pos = rm_start, end_pos = rm_end})
log(" remove " .. string.format("%.1f", rm_end - rm_start) .. "s at " .. string.format("%.1f", s.start_pos) .. "-" .. string.format("%.1f", s.end_pos))
end
end
end
end
for _, ta in ipairs(track_audios) do
destroy_track_audio(ta)
end
log("Phase 1: Total " .. total_blocks .. " blocks, " .. silent_blocks .. " silent (" .. string.format("%.0f", silent_blocks/math.max(total_blocks,1)*100) .. "%)")
local dialog_rms_db = nil
if rms_acc.count > 0 then
local rms = math.sqrt(rms_acc.sum_sq / rms_acc.count)
if rms > 0 then dialog_rms_db = 20 * math.log(rms, 10) end
end
if #removals == 0 then
log("Phase 1: No long silences found")
return true, dialog_rms_db
end
local total_removed = 0
for _, r in ipairs(removals) do
total_removed = total_removed + (r.end_pos - r.start_pos)
end
local msg = string.format(
"Phase 1: Found %d silence(s) totaling %.1fs to remove.\n\nProceed?",
#removals, total_removed
)
if reaper.ShowMessageBox(msg, "Strip Silence", 1) ~= 1 then return false end
-- Modification phase — prevent UI refresh for performance, but yield for progress
progress_phase = "Phase 1: Removing"
reaper.PreventUIRefresh(1)
for i = #removals, 1, -1 do
local r = removals[i]
local remove_len = r.end_pos - r.start_pos
for t = 0, reaper.CountTracks(0) - 1 do
if (t + 1) == MUSIC_TRACK then goto next_track end
local track = reaper.GetTrack(0, t)
local item = find_item_at(track, r.start_pos)
if item then
local right = reaper.SplitMediaItem(item, r.start_pos)
if right then
reaper.SplitMediaItem(right, r.end_pos)
reaper.DeleteTrackMediaItem(track, right)
end
end
for j = 0, reaper.CountTrackMediaItems(track) - 1 do
local shift_item = reaper.GetTrackMediaItem(track, j)
local pos = reaper.GetMediaItemInfo_Value(shift_item, "D_POSITION")
if pos >= r.start_pos then
reaper.SetMediaItemInfo_Value(shift_item, "D_POSITION", pos - remove_len)
end
end
::next_track::
end
-- Yield every 5 removals to update progress
if i % 5 == 0 then
progress_pct = (#removals - i) / #removals
progress_detail = string.format("%d/%d cuts", #removals - i, #removals)
reaper.PreventUIRefresh(-1)
coroutine.yield()
reaper.PreventUIRefresh(1)
end
end
reaper.PreventUIRefresh(-1)
shift_regions(removals)
log("Phase 1: Removed " .. #removals .. " silence(s), " .. string.format("%.1f", total_removed) .. "s total")
return true, dialog_rms_db
end
---------------------------------------------------------------------------
-- Phase 2: Normalize AD/IDENT volume to match dialog
---------------------------------------------------------------------------
local function normalize_track_regions(track_idx, regions, target_db)
local track = reaper.GetTrack(0, track_idx - 1)
if not track or reaper.CountTrackMediaItems(track) == 0 then return end
for _, rgn in ipairs(regions) do
local item = find_item_at(track, rgn.start_pos)
if not item then goto next_region end
local item_start = reaper.GetMediaItemInfo_Value(item, "D_POSITION")
local segment = item
if item_start < rgn.start_pos - 0.01 then
segment = reaper.SplitMediaItem(item, rgn.start_pos)
if not segment then goto next_region end
end
local seg_end = reaper.GetMediaItemInfo_Value(segment, "D_POSITION")
+ reaper.GetMediaItemInfo_Value(segment, "D_LENGTH")
if rgn.end_pos < seg_end - 0.01 then
reaper.SplitMediaItem(segment, rgn.end_pos)
end
local take = reaper.GetActiveTake(segment)
if not take then goto next_region end
local seg_pos = reaper.GetMediaItemInfo_Value(segment, "D_POSITION")
local seg_len = reaper.GetMediaItemInfo_Value(segment, "D_LENGTH")
local seg_offset = reaper.GetMediaItemTakeInfo_Value(take, "D_STARTOFFS")
local accessor = reaper.CreateTakeAudioAccessor(take)
local sum_sq = 0
local count = 0
local t = seg_pos
while t < seg_pos + seg_len do
local source_time = t - seg_pos + seg_offset
local buf = reaper.new_array(BLOCK_SAMPLES)
reaper.GetAudioAccessorSamples(accessor, SAMPLE_RATE, 1, source_time, BLOCK_SAMPLES, buf)
for i = 1, BLOCK_SAMPLES do
sum_sq = sum_sq + buf[i] * buf[i]
end
count = count + BLOCK_SAMPLES
t = t + BLOCK_SEC
end
reaper.DestroyAudioAccessor(accessor)
if count > 0 then
local item_rms = math.sqrt(sum_sq / count)
if item_rms > 0 then
local item_db = 20 * math.log(item_rms, 10)
local gain_db = target_db - item_db
local gain_linear = 10 ^ (gain_db / 20)
local current_vol = reaper.GetMediaItemInfo_Value(segment, "D_VOL")
reaper.SetMediaItemInfo_Value(segment, "D_VOL", current_vol * gain_linear)
log(" " .. rgn.name .. ": " .. string.format("%+.1f", gain_db) .. "dB adjustment")
end
end
::next_region::
end
end
local function normalize_music_track(dialog_regions, target_db)
local track = reaper.GetTrack(0, MUSIC_TRACK - 1)
if not track or reaper.CountTrackMediaItems(track) == 0 then return end
local sum_sq = 0
local count = 0
for _, rgn in ipairs(dialog_regions) do
for i = 0, reaper.CountTrackMediaItems(track) - 1 do
local item = reaper.GetTrackMediaItem(track, i)
local take = reaper.GetActiveTake(item)
if not take then goto next_item end
local item_pos = reaper.GetMediaItemInfo_Value(item, "D_POSITION")
local item_len = reaper.GetMediaItemInfo_Value(item, "D_LENGTH")
local item_end = item_pos + item_len
local take_offset = reaper.GetMediaItemTakeInfo_Value(take, "D_STARTOFFS")
local mstart = math.max(item_pos, rgn.start_pos)
local mend = math.min(item_end, rgn.end_pos)
if mstart >= mend then goto next_item end
local accessor = reaper.CreateTakeAudioAccessor(take)
local t = mstart
while t < mend do
local source_time = t - item_pos + take_offset
local buf = reaper.new_array(BLOCK_SAMPLES)
reaper.GetAudioAccessorSamples(accessor, SAMPLE_RATE, 1, source_time, BLOCK_SAMPLES, buf)
local peak = 0
local block_sum = 0
for j = 1, BLOCK_SAMPLES do
local v = buf[j]
block_sum = block_sum + v * v
local av = math.abs(v)
if av > peak then peak = av end
end
if peak >= THRESHOLD then
sum_sq = sum_sq + block_sum
count = count + BLOCK_SAMPLES
end
t = t + BLOCK_SEC
end
reaper.DestroyAudioAccessor(accessor)
::next_item::
end
end
if count == 0 then
log(" Music: no audio detected — skipping")
return
end
local music_rms = math.sqrt(sum_sq / count)
if music_rms > 0 then
local music_db = 20 * math.log(music_rms, 10)
local gain_db = target_db - music_db
local gain_linear = 10 ^ (gain_db / 20)
for i = 0, reaper.CountTrackMediaItems(track) - 1 do
local item = reaper.GetTrackMediaItem(track, i)
local current_vol = reaper.GetMediaItemInfo_Value(item, "D_VOL")
reaper.SetMediaItemInfo_Value(item, "D_VOL", current_vol * gain_linear)
end
log(" Music: " .. string.format("%+.1f", gain_db) .. "dB adjustment")
end
end
local function phase2_normalize(dialog_regions, ad_regions, ident_regions, dialog_rms_db)
progress_phase = "Phase 2: Normalizing"
progress_pct = 0
progress_detail = ""
coroutine.yield()
if not dialog_rms_db then
log("Phase 2: Could not measure dialog loudness — skipping")
return
end
log("Phase 2: Dialog RMS = " .. string.format("%.1f", dialog_rms_db) .. " dBFS")
local dialog_db = dialog_rms_db
if #ad_regions > 0 then
progress_detail = "Ads"
coroutine.yield()
log("Phase 2: Normalizing " .. #ad_regions .. " AD region(s)...")
normalize_track_regions(ADS_TRACK, ad_regions, dialog_db)
end
if #ident_regions > 0 then
progress_detail = "Idents"
progress_pct = 0.33
coroutine.yield()
log("Phase 2: Normalizing " .. #ident_regions .. " IDENT region(s)...")
normalize_track_regions(IDENTS_TRACK, ident_regions, dialog_db)
end
progress_detail = "Music"
progress_pct = 0.66
coroutine.yield()
log("Phase 2: Normalizing music track...")
normalize_music_track(dialog_regions, dialog_db)
progress_pct = 1.0
end
---------------------------------------------------------------------------
-- Phase 3: Trim music to voice length
-- Phase 4: Mute music during AD/IDENT regions with fades
---------------------------------------------------------------------------
local function phase3_trim_music()
progress_phase = "Phase 3: Trimming music"
progress_pct = 0
progress_detail = ""
coroutine.yield()
local music_track = reaper.GetTrack(0, MUSIC_TRACK - 1)
if not music_track then return end
local last_end = 0
for _, tidx in ipairs(CHECK_TRACKS) do
local tr = reaper.GetTrack(0, tidx - 1)
if tr then
local n = reaper.CountTrackMediaItems(tr)
if n > 0 then
local item = reaper.GetTrackMediaItem(tr, n - 1)
local item_end = reaper.GetMediaItemInfo_Value(item, "D_POSITION")
+ reaper.GetMediaItemInfo_Value(item, "D_LENGTH")
if item_end > last_end then last_end = item_end end
end
end
end
if last_end == 0 then return end
local item = find_item_at(music_track, last_end - 0.01)
if not item then
local n = reaper.CountTrackMediaItems(music_track)
if n > 0 then
item = reaper.GetTrackMediaItem(music_track, n - 1)
end
end
if not item then
log("Phase 3: No music item to trim")
return
end
local item_start = reaper.GetMediaItemInfo_Value(item, "D_POSITION")
local item_end = item_start + reaper.GetMediaItemInfo_Value(item, "D_LENGTH")
if last_end < item_end then
reaper.SetMediaItemInfo_Value(item, "D_LENGTH", last_end - item_start)
reaper.SetMediaItemInfo_Value(item, "D_FADEOUTLEN", MUSIC_FADE_SEC)
log("Phase 3: Trimmed music at " .. string.format("%.1f", last_end) .. "s with " .. MUSIC_FADE_SEC .. "s fade-out")
local i = reaper.CountTrackMediaItems(music_track) - 1
while i >= 0 do
local check = reaper.GetTrackMediaItem(music_track, i)
local check_start = reaper.GetMediaItemInfo_Value(check, "D_POSITION")
if check_start >= last_end then
reaper.DeleteTrackMediaItem(music_track, check)
end
i = i - 1
end
else
log("Phase 3: Music already ends before last voice audio — adding fade-out")
reaper.SetMediaItemInfo_Value(item, "D_FADEOUTLEN", MUSIC_FADE_SEC)
end
progress_pct = 1.0
end
local function phase4_music_fades(ad_ident_regions)
progress_phase = "Phase 4: Music fades"
progress_pct = 0
progress_detail = ""
coroutine.yield()
local music_track = reaper.GetTrack(0, MUSIC_TRACK - 1)
if not music_track or reaper.CountTrackMediaItems(music_track) == 0 then
log("Phase 4: No music track/items found — skipping")
return
end
log("Phase 4: Processing " .. #ad_ident_regions .. " AD/IDENT region(s)...")
for ri, rgn in ipairs(ad_ident_regions) do
local fade_point = rgn.start_pos - MUSIC_FADE_SEC
local item = find_item_at(music_track, math.max(fade_point, 0))
if not item then
item = find_item_at(music_track, rgn.start_pos)
end
if not item then
log(" " .. rgn.name .. ": no music item found — skipping")
goto continue
end
local item_start = reaper.GetMediaItemInfo_Value(item, "D_POSITION")
local split_pos = math.max(fade_point, item_start + 0.01)
local mid = reaper.SplitMediaItem(item, split_pos)
if mid then
reaper.SetMediaItemInfo_Value(item, "D_FADEOUTLEN", MUSIC_FADE_SEC)
local after = reaper.SplitMediaItem(mid, rgn.end_pos)
reaper.SetMediaItemInfo_Value(mid, "B_MUTE", 1)
if after then
reaper.SetMediaItemInfo_Value(after, "D_FADEINLEN", MUSIC_FADE_SEC)
end
log(" " .. rgn.name .. ": muted music, fade out/in (" .. MUSIC_FADE_SEC .. "s)")
end
progress_pct = ri / #ad_ident_regions
progress_detail = string.format("%d/%d", ri, #ad_ident_regions)
::continue::
end
end
---------------------------------------------------------------------------
-- Main (coroutine-based for UI responsiveness)
---------------------------------------------------------------------------
local function do_work()
local dialog_regions = get_regions_by_type("^DIALOG%s+%d+$")
if #dialog_regions == 0 then
reaper.ShowMessageBox("No DIALOG regions found.", "Post-Production", 0)
return
end
reaper.Undo_BeginBlock()
-- Phase 1: Strip silence (analysis yields for progress, removal uses PreventUIRefresh)
local ok, dialog_rms_db = phase1_strip_silence(dialog_regions)
if not ok then
reaper.Undo_EndBlock("Post-production: cancelled", -1)
log("Cancelled.")
return
end
-- Re-read regions after ripple edits
dialog_regions = get_regions_by_type("^DIALOG%s+%d+$")
local ad_regions = get_regions_by_type("^AD%s+%d+$")
local ident_regions = get_regions_by_type("^IDENT%s+%d+$")
local ad_ident_regions = {}
for _, r in ipairs(ad_regions) do table.insert(ad_ident_regions, r) end
for _, r in ipairs(ident_regions) do table.insert(ad_ident_regions, r) end
table.sort(ad_ident_regions, function(a, b) return a.start_pos < b.start_pos end)
reaper.PreventUIRefresh(1)
-- Phase 2: Normalize
if #ad_regions > 0 or #ident_regions > 0 then
phase2_normalize(dialog_regions, ad_regions, ident_regions, dialog_rms_db)
else
log("Phase 2: No AD/IDENT regions found — skipping")
end
-- Phase 3: Trim music
phase3_trim_music()
-- Phase 4: Music fades
if #ad_ident_regions > 0 then
phase4_music_fades(ad_ident_regions)
else
log("Phase 4: No AD/IDENT regions found — skipping")
end
reaper.PreventUIRefresh(-1)
reaper.Undo_EndBlock("Post-production: strip silence + music fades", -1)
reaper.UpdateArrange()
log("All phases complete!")
end
-- Coroutine runner with progress window
local work_co
local function work_loop()
if not work_co or coroutine.status(work_co) == "dead" then
progress_phase = "Done!"
progress_pct = 1.0
progress_detail = ""
progress_draw()
progress_close()
return
end
progress_draw()
local ok, err = coroutine.resume(work_co)
if not ok then
progress_close()
log("ERROR: " .. tostring(err))
reaper.PreventUIRefresh(-1)
reaper.Undo_EndBlock("Post-production: error", -1)
return
end
if coroutine.status(work_co) ~= "dead" then
reaper.defer(work_loop)
else
progress_phase = "Done!"
progress_pct = 1.0
progress_detail = ""
progress_draw()
progress_close()
end
end
progress_init()
work_co = coroutine.create(do_work)
reaper.defer(work_loop)