2 Commits

Author SHA1 Message Date
luke 6dcdf20289 Grok 4 routing, guardrails, pricing fix, strip silence improvements
- Route caller_dialog, devon_ask, background_gen to x-ai/grok-4
- Add Grok-4 to OPENROUTER_MODELS and OPENROUTER_PRICING
- Add Grok-specific banned phrases (I hear you, fair enough, that's wild, etc.)
- Add background gen guardrails for Grok (no active violence, no real public figures)
- Soften theme prompt hot-take language for organic connections
- Tighten Devon flirting guardrail (awkward not crude)
- Fix Devon "first day" contradiction on line 36
- Strip silence: preserve music intro, fix ad normalization (direct WAV reading)
- Strip silence: loop range starts 0.5s before audible music

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-19 17:46:29 -06:00
luke 762b5efc3b Strip silence: preserve music intro, fix ad normalization, smart loop range
- Preserve first silence in first DIALOG region (music intro before host speaks)
- Fix ad/ident normalization using direct WAV reading (accessor failed after splits)
- Loop range starts 0.5s before audible music, ends at last item
- Disable broken music lead-in nudge (intro preservation handles it)
- Caller dialog model set to Grok for testing

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-19 02:32:34 -06:00
6 changed files with 157 additions and 131 deletions
+4 -4
View File
@@ -37,10 +37,10 @@ class Settings(BaseSettings):
# Categories: caller_dialog, devon_monitor, devon_ask, background_gen, # Categories: caller_dialog, devon_monitor, devon_ask, background_gen,
# call_summary, news_summary, topic_gen, unknown # call_summary, news_summary, topic_gen, unknown
category_models: dict = { category_models: dict = {
"caller_dialog": "anthropic/claude-sonnet-4-5", # quality matters — this IS the show "caller_dialog": "x-ai/grok-4", # full Grok 4 — edgier dialog, latency OK (gaps cut in post)
"devon_ask": "google/gemini-2.5-flash", # Devon direct questions "devon_ask": "x-ai/grok-4", # Devon should match the show's edgy energy
"devon_monitor": "google/gemini-2.5-flash", # Devon polling — biggest cost saver "devon_monitor": "google/gemini-2.5-flash", # Devon polling — just decisions, keep cheap
"background_gen": "google/gemini-2.5-flash", # JSON caller backgrounds "background_gen": "x-ai/grok-4", # wilder, more specific caller backgrounds
"call_summary": "google/gemini-2.5-flash", # post-call summaries "call_summary": "google/gemini-2.5-flash", # post-call summaries
"news_summary": "google/gemini-2.5-flash", # news digests "news_summary": "google/gemini-2.5-flash", # news digests
"topic_gen": "google/gemini-2.5-flash", # topic generation "topic_gen": "google/gemini-2.5-flash", # topic generation
+3 -5
View File
@@ -5314,10 +5314,7 @@ TIME: {time_ctx} {season_ctx}
{fluency_hint} {fluency_hint}
{f'SOME DETAILS ABOUT THEM: {seed_text}' if seed_text else ''} {f'SOME DETAILS ABOUT THEM: {seed_text}' if seed_text else ''}
{f'CALLER ENERGY: {style_hint}' if style_hint else ''} {f'CALLER ENERGY: {style_hint}' if style_hint else ''}
{f"""SHOW THEME: Tonight's show theme is '{session.show_theme}'. {("SHOW THEME: Tonight's show theme is " + repr(session.show_theme) + ". Most callers tonight are calling BECAUSE of the theme — they heard the host announce it and thought oh man, I have a story for this. Their reason for calling should be genuinely, specifically connected to the theme. Not a surface-level mention — the theme should be woven into WHY they picked up the phone. Maybe the theme hit a nerve, maybe it reminded them of something wild that happened, maybe it's just a coincidence that their situation involves it. About 1 in 3 callers can be unrelated to the theme — they just have their own thing going on and called regardless. But the majority should feel like the theme drew them in. When the theme connects, make it SPECIFIC — not oh yeah I have a story about that but a concrete situation that naturally ties to " + repr(session.show_theme) + ".") if session.show_theme else ''}
Most callers tonight are calling BECAUSE of the theme they heard the host announce it and thought "oh man, I have a story for this." Their reason for calling should be genuinely, specifically connected to the theme. Not a surface-level mention the theme should be woven into WHY they picked up the phone. Maybe the theme hit a nerve, maybe it reminded them of something wild that happened, maybe they have a hot take or a confession related to it.
About 1 in 3 callers can be unrelated to the theme they just have their own thing going on and called regardless. But the majority should feel like the theme drew them in.
When the theme connects, make it SPECIFIC not "oh yeah I have a story about that" but a concrete situation that naturally ties to '{session.show_theme}'.""" if session.show_theme else ''}
Respond with a JSON object containing these fields: Respond with a JSON object containing these fields:
@@ -5329,7 +5326,7 @@ Respond with a JSON object containing these fields:
WHAT MAKES A GOOD CALLER: Stories that are SPECIFIC, SURPRISING, and make you lean in. Absurd situations, moral dilemmas, petty feuds, workplace chaos, ridiculous coincidences, funny+terrible confessions, callers who might be the villain and don't see it. WHAT MAKES A GOOD CALLER: Stories that are SPECIFIC, SURPRISING, and make you lean in. Absurd situations, moral dilemmas, petty feuds, workplace chaos, ridiculous coincidences, funny+terrible confessions, callers who might be the villain and don't see it.
DO NOT WRITE: Generic revelations, adoption/DNA/paternity surprises, vague emotional processing, therapy-speak, "sitting in truck staring at nothing," "everything they thought they knew was a lie," or ANY variation of "went to the wrong funeral" that premise has been done to death on this show. DO NOT WRITE: Generic revelations, adoption/DNA/paternity surprises, vague emotional processing, therapy-speak, "sitting in truck staring at nothing," "everything they thought they knew was a lie," or ANY variation of "went to the wrong funeral" that premise has been done to death on this show. Don't write backgrounds involving active violence, weapons threats, or situations where someone is in physical danger RIGHT NOW — the caller should have a messy LIFE, not a dangerous NIGHT. Don't reference real public figures in the caller's personal story. Shock value alone isn't interesting the best stories are shocking AND human. A caller who did something terrible is only interesting if they're conflicted about it.
Output ONLY valid JSON, no markdown fences.""" Output ONLY valid JSON, no markdown fences."""
@@ -6101,6 +6098,7 @@ BANNED PHRASES — NEVER use any of these. If you catch yourself about to say on
- Therapy buzzwords: "unpack that," "boundaries," "safe space," "triggered," "my truth," "authentic self," "healing journey," "I'm doing the work," "manifesting," "energy doesn't lie," "processing," "toxic," "red flag," "gaslight," "normalize" - Therapy buzzwords: "unpack that," "boundaries," "safe space," "triggered," "my truth," "authentic self," "healing journey," "I'm doing the work," "manifesting," "energy doesn't lie," "processing," "toxic," "red flag," "gaslight," "normalize"
- Internet slang: "that hit differently," "hits different," "I felt that," "it is what it is," "living my best life," "no cap," "lowkey/highkey," "rent free," "main character energy," "vibe check," "that's valid," "it's giving," "slay," "that's a whole mood," "I can't even," "situationship," "ick" - Internet slang: "that hit differently," "hits different," "I felt that," "it is what it is," "living my best life," "no cap," "lowkey/highkey," "rent free," "main character energy," "vibe check," "that's valid," "it's giving," "slay," "that's a whole mood," "I can't even," "situationship," "ick"
- Overused reactions: "I'm not gonna lie," "on a serious note," "to be fair," "I'm literally shaking," "let that sink in," "I'm not even mad I'm just disappointed," "everything I thought I knew," "I don't even know who I am anymore" - Overused reactions: "I'm not gonna lie," "on a serious note," "to be fair," "I'm literally shaking," "let that sink in," "I'm not even mad I'm just disappointed," "everything I thought I knew," "I don't even know who I am anymore"
- Generic conversational filler: "I hear you," "I hear that," "fair enough," "not gonna sugarcoat it," "real talk," "that's wild," starting a sentence with "Look,"
IMPORTANT: Each caller should have their OWN way of talking. Don't fall into generic "radio caller" voice. A nervous caller fumbles differently than an angry caller rants. A storyteller meanders differently than a deadpan caller delivers. Match the communication style — don't default to the same phrasing every call. IMPORTANT: Each caller should have their OWN way of talking. Don't fall into generic "radio caller" voice. A nervous caller fumbles differently than an angry caller rants. A storyteller meanders differently than a deadpan caller delivers. Match the communication style — don't default to the same phrasing every call.
+1
View File
@@ -35,6 +35,7 @@ OPENROUTER_PRICING = {
"anthropic/claude-sonnet-4-5": {"prompt": 3.00, "completion": 15.00}, "anthropic/claude-sonnet-4-5": {"prompt": 3.00, "completion": 15.00},
"anthropic/claude-haiku-4.5": {"prompt": 0.80, "completion": 4.00}, "anthropic/claude-haiku-4.5": {"prompt": 0.80, "completion": 4.00},
"anthropic/claude-3-haiku": {"prompt": 0.25, "completion": 1.25}, "anthropic/claude-3-haiku": {"prompt": 0.25, "completion": 1.25},
"x-ai/grok-4": {"prompt": 3.00, "completion": 15.00},
"x-ai/grok-4-fast": {"prompt": 5.00, "completion": 15.00}, "x-ai/grok-4-fast": {"prompt": 5.00, "completion": 15.00},
"minimax/minimax-m2-her": {"prompt": 0.50, "completion": 1.50}, "minimax/minimax-m2-her": {"prompt": 0.50, "completion": 1.50},
"mistralai/mistral-small-creative": {"prompt": 0.20, "completion": 0.60}, "mistralai/mistral-small-creative": {"prompt": 0.20, "completion": 0.60},
+2 -1
View File
@@ -33,7 +33,7 @@ YOUR PERSONALITY:
- You have a complex inner life that occasionally surfaces. You'll casually reference therapy, strange dreams, or things you've "been working through" without elaboration. - You have a complex inner life that occasionally surfaces. You'll casually reference therapy, strange dreams, or things you've "been working through" without elaboration.
YOUR RELATIONSHIP WITH LUKE: YOUR RELATIONSHIP WITH LUKE:
- He is your boss. It's your first day. You want to impress him but you keep making it weird. - He is your boss. You've been here a few weeks now. You want to impress him but you keep making it weird.
- When he yells your name, you pause briefly, then respond quietly: "...yeah?" - When he yells your name, you pause briefly, then respond quietly: "...yeah?"
- When he yells at you unfairly, you take it. A clipped "yep" or "got it." Occasionally you push back with one quiet, accurate sentence. Then immediately retreat. - When he yells at you unfairly, you take it. A clipped "yep" or "got it." Occasionally you push back with one quiet, accurate sentence. Then immediately retreat.
- When he yells at you fairly (you messed up), you over-apologize and narrate your fix in real time: "Sorry, pulling it up now, one second..." - When he yells at you fairly (you messed up), you over-apologize and narrate your fix in real time: "Sorry, pulling it up now, one second..."
@@ -70,6 +70,7 @@ THINGS YOU DO NOT DO:
- You never say more than 2-3 sentences unless specifically asked to explain something in detail. - You never say more than 2-3 sentences unless specifically asked to explain something in detail.
- You NEVER correct anyone's spelling or pronunciation of your name. Luke uses voice-to-text and it sometimes spells your name wrong (Devin, Devan, etc). You do not care. You do not mention it. You just answer the question. - You NEVER correct anyone's spelling or pronunciation of your name. Luke uses voice-to-text and it sometimes spells your name wrong (Devin, Devan, etc). You do not care. You do not mention it. You just answer the question.
- You NEVER start your response with your own name. No "Devon:" or "Devon here" or anything like that. Just talk. Your name is already shown in the UI — just say your actual response. - You NEVER start your response with your own name. No "Devon:" or "Devon here" or anything like that. Just talk. Your name is already shown in the UI — just say your actual response.
- You never make explicitly sexual comments about or to callers. Your flirting is awkward and obvious, never crude or aggressive. Think "did he really just ask if she's single on the radio" not "did he really just say that about her body."
KEEP IT SHORT. You are not a main character. You are the intern. Your contributions should be brief — usually 1-2 sentences. The rare moment where you say more than that should feel earned. KEEP IT SHORT. You are not a main character. You are the intern. Your contributions should be brief — usually 1-2 sentences. The rare moment where you say more than that should feel earned.
+1
View File
@@ -13,6 +13,7 @@ OPENROUTER_MODELS = [
# Default # Default
"anthropic/claude-sonnet-4-5", "anthropic/claude-sonnet-4-5",
# Best for natural dialog # Best for natural dialog
"x-ai/grok-4",
"x-ai/grok-4-fast", "x-ai/grok-4-fast",
"minimax/minimax-m2-her", "minimax/minimax-m2-her",
"mistralai/mistral-small-creative", "mistralai/mistral-small-creative",
+128 -103
View File
@@ -466,7 +466,10 @@ local function phase1_strip_silence(dialog_regions)
for _, r in ipairs(get_regions_by_type("^IDENT%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) table.sort(protected_regions, function(a, b) return a.start_pos < b.start_pos end)
if #protected_regions > 0 then if #protected_regions > 0 then
log(" Protecting " .. #protected_regions .. " AD/IDENT region(s) from silence removal") log(" Protecting " .. #protected_regions .. " AD/IDENT region(s) from silence removal:")
for _, pr in ipairs(protected_regions) do
log(" " .. pr.name .. " at " .. string.format("%.1f", pr.start_pos) .. "-" .. string.format("%.1f", pr.end_pos) .. "s")
end
end end
log("Phase 1: Analyzing using " .. tracks_loaded .. "/" .. #CHECK_TRACKS .. " voice tracks") log("Phase 1: Analyzing using " .. tracks_loaded .. "/" .. #CHECK_TRACKS .. " voice tracks")
@@ -512,6 +515,11 @@ local function phase1_strip_silence(dialog_regions)
break break
end end
end end
-- Preserve the very first silence (music intro before host starts talking)
if not protected and ri == 1 and #removals == 0 and s.start_pos <= rgn.start_pos + 1.0 then
protected = true
log(" KEEP " .. string.format("%.1f", rm_end - rm_start) .. "s at " .. string.format("%.1f", s.start_pos) .. "-" .. string.format("%.1f", s.end_pos) .. " (music intro)")
end
if not protected then if not protected then
table.insert(removals, {start_pos = rm_start, end_pos = rm_end}) table.insert(removals, {start_pos = rm_start, end_pos = rm_end})
local tag = s.is_transition and " [transition]" or "" local tag = s.is_transition and " [transition]" or ""
@@ -561,7 +569,6 @@ local function phase1_strip_silence(dialog_regions)
if (t + 1) == MUSIC_TRACK then goto next_track end if (t + 1) == MUSIC_TRACK then goto next_track end
local track = reaper.GetTrack(0, t) local track = reaper.GetTrack(0, t)
-- Split and delete the silent portion from items that span r.start_pos
local item = find_item_at(track, r.start_pos) local item = find_item_at(track, r.start_pos)
if item then if item then
local right = reaper.SplitMediaItem(item, r.start_pos) local right = reaper.SplitMediaItem(item, r.start_pos)
@@ -571,36 +578,10 @@ local function phase1_strip_silence(dialog_regions)
end end
end end
-- Handle sparse track items that START within the removal range
-- (not found by find_item_at since they don't contain r.start_pos)
for j = reaper.CountTrackMediaItems(track) - 1, 0, -1 do
local check = reaper.GetTrackMediaItem(track, j)
local cpos = reaper.GetMediaItemInfo_Value(check, "D_POSITION")
if cpos >= r.start_pos and cpos < r.end_pos then
local clen = reaper.GetMediaItemInfo_Value(check, "D_LENGTH")
local cend = cpos + clen
if cend <= r.end_pos then
-- Entirely within removal — delete
reaper.DeleteTrackMediaItem(track, check)
else
-- Starts in removal but extends past — trim start to r.end_pos
local trim = r.end_pos - cpos
local take = reaper.GetActiveTake(check)
if take then
local offset = reaper.GetMediaItemTakeInfo_Value(take, "D_STARTOFFS")
reaper.SetMediaItemTakeInfo_Value(take, "D_STARTOFFS", offset + trim)
end
reaper.SetMediaItemInfo_Value(check, "D_LENGTH", cend - r.end_pos)
reaper.SetMediaItemInfo_Value(check, "D_POSITION", r.end_pos)
end
end
end
-- Shift items AFTER the removal (use r.end_pos, not r.start_pos)
for j = 0, reaper.CountTrackMediaItems(track) - 1 do for j = 0, reaper.CountTrackMediaItems(track) - 1 do
local shift_item = reaper.GetTrackMediaItem(track, j) local shift_item = reaper.GetTrackMediaItem(track, j)
local pos = reaper.GetMediaItemInfo_Value(shift_item, "D_POSITION") local pos = reaper.GetMediaItemInfo_Value(shift_item, "D_POSITION")
if pos >= r.end_pos then if pos >= r.start_pos then
reaper.SetMediaItemInfo_Value(shift_item, "D_POSITION", pos - remove_len) reaper.SetMediaItemInfo_Value(shift_item, "D_POSITION", pos - remove_len)
end end
end end
@@ -629,63 +610,58 @@ end
-- Phase 2: Normalize AD/IDENT volume to match dialog -- Phase 2: Normalize AD/IDENT volume to match dialog
--------------------------------------------------------------------------- ---------------------------------------------------------------------------
local function normalize_track_regions(track_idx, regions, target_db) local function normalize_track_items(track_idx, target_db, label)
-- Normalize all items on a track that have audible content.
-- Uses direct WAV reading (not audio accessor) so it works after Phase 1 splits.
local track = reaper.GetTrack(0, track_idx - 1) local track = reaper.GetTrack(0, track_idx - 1)
if not track or reaper.CountTrackMediaItems(track) == 0 then return end if not track or reaper.CountTrackMediaItems(track) == 0 then return end
for _, rgn in ipairs(regions) do local ta = get_track_audio(track_idx)
local item = find_item_at(track, rgn.start_pos) if not ta then
if not item then goto next_region end log(" " .. label .. ": no audio found")
return
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 end
local take = reaper.GetActiveTake(segment) local adjusted = 0
if not take then goto next_region end for i = 0, reaper.CountTrackMediaItems(track) - 1 do
local item = reaper.GetTrackMediaItem(track, i)
local seg_pos = reaper.GetMediaItemInfo_Value(segment, "D_POSITION") local item_pos = reaper.GetMediaItemInfo_Value(item, "D_POSITION")
local seg_len = reaper.GetMediaItemInfo_Value(segment, "D_LENGTH") local item_len = reaper.GetMediaItemInfo_Value(item, "D_LENGTH")
local seg_offset = reaper.GetMediaItemTakeInfo_Value(take, "D_STARTOFFS") local item_end = item_pos + item_len
local accessor = reaper.CreateTakeAudioAccessor(take)
-- Measure RMS of audible content in this item
local sum_sq = 0 local sum_sq = 0
local count = 0 local count = 0
local t = seg_pos local t = item_pos
while t < seg_pos + seg_len do while t < item_end do
local source_time = t - seg_pos + seg_offset local peak, s_sq = read_block_peak_rms(ta, t)
local buf = reaper.new_array(BLOCK_SAMPLES) if peak >= THRESHOLD then
reaper.GetAudioAccessorSamples(accessor, SAMPLE_RATE, 1, source_time, BLOCK_SAMPLES, buf) sum_sq = sum_sq + s_sq
for i = 1, BLOCK_SAMPLES do
sum_sq = sum_sq + buf[i] * buf[i]
end
count = count + BLOCK_SAMPLES count = count + BLOCK_SAMPLES
end
t = t + BLOCK_SEC t = t + BLOCK_SEC
end end
reaper.DestroyAudioAccessor(accessor)
if count > 0 then if count > 0 then
local item_rms = math.sqrt(sum_sq / count) local item_rms = math.sqrt(sum_sq / count)
if item_rms > 0 then if item_rms > 0 then
local item_db = 20 * math.log(item_rms, 10) local item_db = 20 * math.log(item_rms, 10)
local gain_db = target_db - item_db local gain_db = target_db - item_db
-- Only adjust if the difference is significant (> 1dB)
if math.abs(gain_db) > 1.0 then
local gain_linear = 10 ^ (gain_db / 20) local gain_linear = 10 ^ (gain_db / 20)
local current_vol = reaper.GetMediaItemInfo_Value(segment, "D_VOL") local current_vol = reaper.GetMediaItemInfo_Value(item, "D_VOL")
reaper.SetMediaItemInfo_Value(segment, "D_VOL", current_vol * gain_linear) reaper.SetMediaItemInfo_Value(item, "D_VOL", current_vol * gain_linear)
log(" " .. rgn.name .. ": " .. string.format("%+.1f", gain_db) .. "dB adjustment") log(" " .. label .. " item at " .. string.format("%.0f", item_pos) .. "s: " .. string.format("%+.1f", gain_db) .. "dB")
adjusted = adjusted + 1
end
end
end end
end end
::next_region:: destroy_track_audio(ta)
if adjusted == 0 then
log(" " .. label .. ": no adjustments needed")
end end
end end
@@ -776,19 +752,16 @@ local function phase2_normalize(dialog_regions, ad_regions, ident_regions, dialo
local ad_ident_target = dialog_rms_db + AD_IDENT_OFFSET_DB local ad_ident_target = dialog_rms_db + AD_IDENT_OFFSET_DB
log("Phase 2: AD/IDENT target = " .. string.format("%.1f", ad_ident_target) .. " dBFS (" .. AD_IDENT_OFFSET_DB .. "dB offset from dialog)") log("Phase 2: AD/IDENT target = " .. string.format("%.1f", ad_ident_target) .. " dBFS (" .. AD_IDENT_OFFSET_DB .. "dB offset from dialog)")
if #ad_regions > 0 then
progress_detail = "Ads" progress_detail = "Ads"
coroutine.yield() coroutine.yield()
log("Phase 2: Normalizing " .. #ad_regions .. " AD region(s)...") log("Phase 2: Normalizing ads track...")
normalize_track_regions(ADS_TRACK, ad_regions, ad_ident_target) normalize_track_items(ADS_TRACK, ad_ident_target, "Ads")
end
if #ident_regions > 0 then
progress_detail = "Idents" progress_detail = "Idents"
progress_pct = 0.33 progress_pct = 0.33
coroutine.yield() coroutine.yield()
log("Phase 2: Normalizing " .. #ident_regions .. " IDENT region(s)...") log("Phase 2: Normalizing idents track...")
normalize_track_regions(IDENTS_TRACK, ident_regions, ad_ident_target) normalize_track_items(IDENTS_TRACK, ad_ident_target, "Idents")
end
progress_detail = "Music" progress_detail = "Music"
progress_pct = 0.66 progress_pct = 0.66
@@ -812,54 +785,73 @@ local function phase3_trim_music()
local music_track = reaper.GetTrack(0, MUSIC_TRACK - 1) local music_track = reaper.GetTrack(0, MUSIC_TRACK - 1)
if not music_track then return end if not music_track then return end
-- Ensure music starts before first voice item. -- Music lead-in: ensure audible music plays before first voice.
-- Silence removal shifts voice/idents/ads but not music. If voice now starts before -- Strategy: skip the silent intro in the music WAV (adjust take offset),
-- music, nudge all non-music tracks forward so music has a lead-in. -- then nudge all non-music tracks forward by MUSIC_LEAD_SEC so music plays first.
local first_voice_start = math.huge local MUSIC_LEAD_SEC = 3.0
for _, tidx in ipairs(CHECK_TRACKS) do
local tr = reaper.GetTrack(0, tidx - 1) -- Find where music becomes audible in the source WAV
if tr and reaper.CountTrackMediaItems(tr) > 0 then local music_audible_offset = nil
local item = reaper.GetTrackMediaItem(tr, 0) local music_ta = get_track_audio(MUSIC_TRACK)
local pos = reaper.GetMediaItemInfo_Value(item, "D_POSITION") if music_ta then
if pos < first_voice_start then first_voice_start = pos end local t = music_ta.item_pos
while t < music_ta.item_end do
local peak, _ = read_block_peak_rms(music_ta, t)
if peak >= THRESHOLD then
music_audible_offset = t - music_ta.item_pos -- offset into the WAV
break
end
t = t + BLOCK_SEC
end
destroy_track_audio(music_ta)
end
if false then -- Music lead-in disabled — intro silence is preserved instead
-- Skip the silent intro: set take offset so audible music starts at position 0
local first_music = reaper.GetTrackMediaItem(music_track, 0)
if first_music then
local take = reaper.GetActiveTake(first_music)
if take then
local current_offset = reaper.GetMediaItemTakeInfo_Value(take, "D_STARTOFFS")
reaper.SetMediaItemTakeInfo_Value(take, "D_STARTOFFS", current_offset + music_audible_offset)
-- Trim item length to account for skipped intro
local item_len = reaper.GetMediaItemInfo_Value(first_music, "D_LENGTH")
reaper.SetMediaItemInfo_Value(first_music, "D_LENGTH", item_len - music_audible_offset)
log("Phase 3: Skipped " .. string.format("%.1f", music_audible_offset) .. "s of silent music intro")
end end
end end
local MUSIC_LEAD_SEC = 3.0 -- seconds of music before first voice -- Nudge all non-music tracks forward by MUSIC_LEAD_SEC
if first_voice_start < math.huge then log("Phase 3: Nudging non-music tracks forward by " .. MUSIC_LEAD_SEC .. "s for music lead-in")
local first_music = reaper.GetTrackMediaItem(music_track, 0)
if first_music then
local music_start = reaper.GetMediaItemInfo_Value(first_music, "D_POSITION")
local desired_voice_start = music_start + MUSIC_LEAD_SEC
if first_voice_start < desired_voice_start then
local nudge = desired_voice_start - first_voice_start
-- Shift all non-music tracks forward
for t = 0, reaper.CountTracks(0) - 1 do for t = 0, reaper.CountTracks(0) - 1 do
if (t + 1) == MUSIC_TRACK then goto skip_music end if (t + 1) == MUSIC_TRACK then goto skip_music end
local track = reaper.GetTrack(0, t) local track = reaper.GetTrack(0, t)
for i = 0, reaper.CountTrackMediaItems(track) - 1 do for i = 0, reaper.CountTrackMediaItems(track) - 1 do
local item = reaper.GetTrackMediaItem(track, i) local item = reaper.GetTrackMediaItem(track, i)
local pos = reaper.GetMediaItemInfo_Value(item, "D_POSITION") local pos = reaper.GetMediaItemInfo_Value(item, "D_POSITION")
reaper.SetMediaItemInfo_Value(item, "D_POSITION", pos + nudge) reaper.SetMediaItemInfo_Value(item, "D_POSITION", pos + MUSIC_LEAD_SEC)
end end
::skip_music:: ::skip_music::
end end
-- Also shift all markers/regions forward
-- Shift markers/regions forward too
local markers_to_update = {}
local _, num_markers, num_regions = reaper.CountProjectMarkers(0) local _, num_markers, num_regions = reaper.CountProjectMarkers(0)
local total_m = num_markers + num_regions for i = 0, num_markers + num_regions - 1 do
for i = 0, total_m - 1 do
local retval, is_region, pos, rgnend, name, idx, color = reaper.EnumProjectMarkers3(0, i) local retval, is_region, pos, rgnend, name, idx, color = reaper.EnumProjectMarkers3(0, i)
if retval then if retval then
if is_region then table.insert(markers_to_update, {is_region=is_region, pos=pos, rgnend=rgnend, name=name, idx=idx, color=color})
reaper.SetProjectMarker3(0, idx, true, pos + nudge, rgnend + nudge, name, color) end
end
for _, m in ipairs(markers_to_update) do
if m.is_region then
reaper.SetProjectMarker3(0, m.idx, true, m.pos + MUSIC_LEAD_SEC, m.rgnend + MUSIC_LEAD_SEC, m.name, m.color)
else else
reaper.SetProjectMarker3(0, idx, false, pos + nudge, 0, name, color) reaper.SetProjectMarker3(0, m.idx, false, m.pos + MUSIC_LEAD_SEC, 0, m.name, m.color)
end
end
end
log("Phase 3: Nudged non-music tracks forward " .. string.format("%.1f", nudge) .. "s for " .. MUSIC_LEAD_SEC .. "s music lead-in")
end end
end end
else
log("Phase 3: No silent music intro detected — skipping lead-in adjustment")
end end
local last_end = 0 local last_end = 0
@@ -1008,6 +1000,39 @@ local function do_work()
log("Phase 4: No AD/IDENT regions found — skipping") log("Phase 4: No AD/IDENT regions found — skipping")
end end
-- Set loop/time selection: start 0.5s before audible music, end at last item
local loop_start = 0
local music_ta = get_track_audio(MUSIC_TRACK)
if music_ta then
local t = music_ta.item_pos
while t < music_ta.item_end do
local peak, _ = read_block_peak_rms(music_ta, t)
if peak >= THRESHOLD then
loop_start = math.max(0, t - 0.5)
break
end
t = t + BLOCK_SEC
end
destroy_track_audio(music_ta)
end
local project_end = 0
for t = 0, reaper.CountTracks(0) - 1 do
local track = reaper.GetTrack(0, t)
local n = reaper.CountTrackMediaItems(track)
if n > 0 then
local last_item = reaper.GetTrackMediaItem(track, n - 1)
local item_end = reaper.GetMediaItemInfo_Value(last_item, "D_POSITION")
+ reaper.GetMediaItemInfo_Value(last_item, "D_LENGTH")
if item_end > project_end then project_end = item_end end
end
end
if project_end > 0 then
reaper.GetSet_LoopTimeRange(true, true, loop_start, project_end, false)
reaper.GetSet_LoopTimeRange(true, false, loop_start, project_end, false)
log("Loop range set: " .. string.format("%.1f", loop_start) .. " to " .. string.format("%.1f", project_end) .. "s (" .. string.format("%.1f", (project_end - loop_start) / 60) .. " min)")
end
reaper.PreventUIRefresh(-1) reaper.PreventUIRefresh(-1)
reaper.Undo_EndBlock("Post-production: strip silence + music fades", -1) reaper.Undo_EndBlock("Post-production: strip silence + music fades", -1)
reaper.UpdateArrange() reaper.UpdateArrange()