How Do I Replace One Line in a Hit the Road Dude AI Clip With My Own Voice? A Tested ffmpeg Punch-In Recipe
November 5, 2026. I build hittheroaddude.app , a small paid studio that remakes the Hit the Road Dude pickup-window clip from one photo. The script below was tested on synthetic clips made with ffmpeg's testsrc2 and flite . No generated clips are shown or claimed here. A question I keep seeing: "Ca

November 5, 2026. I build hittheroaddude.app, a small paid studio that remakes the Hit the Road Dude pickup-window clip from one photo. The script below was tested on synthetic clips made with ffmpeg's testsrc2 and flite. No generated clips are shown or claimed here.
A question I keep seeing: "Can I put my own voice on the 'State?' line?" People make a remake of the State Property clip, like the picture, and want one line to sound like them. Or they want their friend to say "hit the road, dude" in their own voice.
You can't do that on my site. The voice is part of the saved preset. The FAQ says "The voice is newly generated for your clip. This studio does not put the original recording in the file and does not lip-sync you to it" ( FAQ ). Once you have the MP4, though, it's your file, and replacing a single line is a small, well-defined audio edit. In audio engineering it's called a punch-in: you keep everything before and after a window and record over the window.
This post is the punch-in as a ~70-line Python script around one ffmpeg filter graph, plus a check that proves it replaced what you think it replaced.
The Hit the Road Dude joke is timing. The four beats run in order: the claim about state property, a puzzled "State?", the correction to private property, then the send-off ( how the exchange runs ). A punch-in must not move any of them. That gives four rules:
You need a start and an end time in seconds. Open the clip in any editor that shows a waveform (Audacity, Reaper, even a phone editor), find "State?", and note where the word starts and where the next line begins. Give yourself a little room on both sides. In my synthetic test clip, "State?" starts at 4.5 s, so I used the window 4.4–6.0 s.
Record your line as a WAV. Any length is fine, but try to say it in about the same time as the original word, because the script won't stretch you to fit. It only pads or cuts.
#!/usr/bin/env python3 """ Replace one line of a clip ' s audio with your own recording (a " punch-in " ). The video stream is copied untouched. The audio before START and after END is kept; the window START..END is replaced by your recording, which is trimmed or padded with silence to fit exactly, with short fades at both seams so there are no clicks.
The output has the same duration as the input. usage: punch_in.py CLIP.mp4 MINE.wav START END OUT.mp4 [--fade-ms 15] [--gain-db 0] """ import argparse, json, subprocess, sys def probe ( path ): out = subprocess. run ([ " ffprobe ", " -v ", " error ", " -show_entries ", " format=duration:stream=codec_type,sample_rate,channels ", " -of ", " json ", path ], capture_output = True, text = True, check = True ). stdout j = json. loads ( out ) a = next (( s for s in j [ " streams " ] if s [ " codec_type " ] == " audio " ), None ) return float ( j [ " format " ][ " duration " ]), a def main (): ap = argparse. ArgumentParser () ap. add_argument ( " clip " ); ap. add_argument ( " mine " ) ap. add_argument ( " start ", type = float ); ap. add_argument ( " end ", type = float ) ap. add_argument ( " out " ) ap. add_argument ( " --fade-ms ", type = float, default = 15.0 ) ap. add_argument ( " --gain-db ", type = float, default = 0.0 ) a = ap. parse_args () dur, astream = probe ( a. clip ) if astream is None: sys. exit ( " clip has no audio stream " ) if not ( 0 <= a. start < a. end <= dur ): sys. exit ( f " window { a. start } - { a. end } is outside the clip (0- { dur:. 3 f } ) " ) sr = int ( astream [ " sample_rate " ]); ch = int ( astream [ " channels " ]) mine_dur, _ = probe ( a. mine ) win = a. end - a. start if mine_dur > win: print ( f " note: recording is { mine_dur:.
3 f } s, window is { win:. 3 f } s; the tail is cut ", file = sys. stderr ) f = a. fade_ms / 1000.0 fmt = f " aresample= { sr },aformat=sample_fmts=fltp:channel_layouts= { ' mono ' if ch == 1 else ' stereo ' } " graph = "; ". join ([ f " [0:a] { fmt },asplit=2[o1][o2] ", # head: 0..start, fade out at the seam f " [o1]atrim=0: { a. start },asetpts=N/SR/TB,afade=t=out:st= { max ( a. start - f, 0 ) }:d= { f } [head] ", # tail: end..dur, fade in at the seam f " [o2]atrim= { a. end }: { dur },asetpts=N/SR/TB,afade=t=in:st=0:d= { f } [tail] ", # your line: fit exactly into the window f " [1:a] { fmt },volume= { a. gain_db } dB,apad,atrim=0: { win },asetpts=N/SR/TB, " f " afade=t=in:st=0:d= { f },afade=t=out:st= { win - f }:d= { f } [mid] ", " [head][mid][tail]concat=n=3:v=0:a=1[aout] ", ]) cmd = [ " ffmpeg ", " -hide_banner ", " -loglevel ", " error ", " -y ", " -i ", a. clip, " -i ", a. mine, " -filter_complex ", graph, " -map ", " 0:v ", " -map ", " [aout] ", " -c:v ", " copy ", " -c:a ", " aac ", " -b:a ", " 160k ", " -t ", f " { dur } ", " -movflags ", " +faststart ", a. out ] subprocess. run ( cmd, check = True ) new_dur, _ = probe ( a. out ) print ( f " wrote { a. out }: { new_dur:. 3 f } s (input { dur:.
3 f } s), replaced { a. start:. 2 f } - { a. end:. 2 f } s " ) if __name__ == " __main__ ": main () Enter fullscreen mode Exit fullscreen mode The filter graph is the whole idea, so here it is in words:
asetpts=N/SR/TB after each trim resets timestamps so each piece starts at zero. Don't skip it. atrim keeps the original timestamps, so the tail starts at 6.0 s and the fade times (written relative to zero) land in the wrong place. When I removed the three asetpts calls as a test, ffmpeg still reported success, but the decoded audio track was only 9.0 s long. That's why verify.py also compares lengths.
python3 punch_in.py clip.mp4 mine.wav 4.4 6.0 out.mp4 # wrote out.mp4: 15.000s (input 15.000s), replaced 4.40-6.00s Enter fullscreen mode Exit fullscreen mode If your recording is longer than the window, you get a note on stderr ( recording is 2.150s, window is 1.600s; the tail is cut ) and the output is still 15.000 s.
Listening is the real test, but a number helps when you're doing this to several clips. verify.py decodes the original, your recording and the output to 16 kHz mono floats and computes a normalized correlation in three places, skipping 50 ms around each seam (that's where the fades are):
#!/usr/bin/env python3 """ Check a punch-in: inside the window the output should match MINE, outside it the original. """ import subprocess, sys import numpy as np def pcm ( path, sr = 16000 ): raw = subprocess. run ([ " ffmpeg ", " -v ", " error ", " -i ", path, " -ac ", " 1 ", " -ar ", str ( sr ), " -f ", " f32le ", " - " ], capture_output = True, check = True ). stdout return np. frombuffer ( raw, dtype = np. float32 ) def corr ( x, y ): n = min ( len ( x ), len ( y )); x, y = x [: n ], y [: n ] d = np. linalg. norm ( x ) * np. linalg. norm ( y ) return float ( np. dot ( x, y ) / d ) if d else 0.0 orig, mine, out = sys. argv [ 1 ], sys. argv [ 2 ], sys. argv [ 3 ] start, end = float ( sys. argv [ 4 ]), float ( sys. argv [ 5 ]); sr = 16000 o, m, r = pcm ( orig ), pcm ( mine ), pcm ( out ) s, e, g = int ( start * sr ), int ( end * sr ), int ( 0.05 * sr ) # skip 50 ms around seams print ( f " lengths orig= { len ( o ) / sr:. 3 f } s out= { len ( r ) / sr:.
3 f } s " + ( "" if abs ( len ( o ) - len ( r )) <= sr // 100 else " <-- LENGTH MISMATCH " )) print ( f " window corr(out, mine) = { corr ( r [ s + g: e - g ], m [ g: ]):. 3 f } " ) print ( f " window corr(out, orig) = { corr ( r [ s + g: e - g ], o [ s + g: e - g ]):. 3 f } " ) print ( f " outside corr(out, orig) = { corr ( np. r_ [ r [: s - g ], r [ e + g: ]], np. r_ [ o [: s - g ], o [ e + g: ]]):.
3 f } " ) Enter fullscreen mode Exit fullscreen mode On my test clip:
$ python3 verify.py clip.mp4 mine.wav out.mp4 4.4 6.0 lengths orig=15.019s out=15.019s window corr(out, mine) = 1.000 window corr(out, orig) = 0.016 outside corr(out, orig) = 1.000 Enter fullscreen mode Exit fullscreen mode That's what you want: inside the window the output is your recording and not the original, and outside it the original is intact. (The decoded length reads 15.019 s for both because of AAC encoder padding. The container duration is 15.000 s for both.) ffprobe confirms the video stream is still the original H.264, copied.
The script gets the edit right. Making it sound right is a different job:
If you're making the clip on my studio: you upload one clear photo of one adult or one pet, pick 480p ($9.99, 6 credits from a pack) or 720p ($12.99, 14 credits), and every clip is 15 seconds, vertical 9:16, with sound ( pricing ). The punch-in works the same on either resolution, because it never touches the picture.