# How Do I Replace One Line in a Hit the Road Dude AI Clip With My Own Voice? A Tested ffmpeg Punch-In Recipe

> Source: <https://dev.to/lpgit/how-do-i-replace-one-line-in-a-hit-the-road-dude-ai-clip-with-my-own-voice-a-tested-ffmpeg-29ei>
> Published: 2026-11-05 01:33:08+00:00

*November 5, 2026. I build [hittheroaddude.app](https://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](https://hittheroaddude.app/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](https://hittheroaddude.app/blog/window-exchange/)). A punch-in must not move any of them. That gives four rules:

`-c:v copy`. No re-encode, no shifted frames.` concat` doesn't complain and players don't resample oddly.
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.

``` bash
#!/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:.3f})")
    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:.3f}s, window is {win:.3f}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:.3f}s (input {dur:.3f}s), replaced {a.start:.2f}-{a.end:.2f}s")

if __name__ == "__main__":
    main()
```

The filter graph is the whole idea, so here it is in words:

`asplit` makes two copies of the original audio.`atrim=0:START`, then `afade=t=out` over the last 15 ms.`atrim=END:DURATION`, then `afade=t=in` over the first 15 ms.`apad` (infinite silence after it), then `atrim=0:WINDOW` so it is exactly the window length, with fades at both ends.`concat=n=3:v=0:a=1` glues head, middle and tail back together.
`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.

Run it:

```
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
```

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

``` bash
#!/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:.3f}s out={len(r)/sr:.3f}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:]):.3f}")
print(f"window   corr(out, orig)  = {corr(r[s+g:e-g], o[s+g:e-g]):.3f}")
print(f"outside  corr(out, orig)  = {corr(np.r_[r[:s-g], r[e+g:]], np.r_[o[:s-g], o[e+g:]]):.3f}")
```

On my test clip:

``` bash
$ 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
```

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:

`--gain-db` (for example `--gain-db -3`) and listen again. Don't normalize the whole clip for this; you'd change the lines you kept.
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](https://hittheroaddude.app/pricing/)). The punch-in works the same on either resolution, because it never touches the picture.

`punch_in.py clip.mp4 mine.wav START END out.mp4`: video copied, audio replaced only in the window, same length, faded seams.`verify.py`: about 1.0 inside the window against your recording, about 1.0 outside it against the original.
