Kevin Lewis

Faking Human Typing in Demo Videos on macOS

[2026-03-21]

There’s a tell in almost every product demo recording: the text arrives in a paste-perfect instant, or hammers out at robotically uniform speed. It signals “scripted” immediately, which undercuts the illusion of a casual, competent operator working in real time.

The fix is to not type at all — and instead have your machine fake it convincingly. This post walks through a small Hammerspoon script that fires real system-level keystrokes with variable timing, realistic typos, multiline support, and a clipboard hotkey that lets you type anything without hardcoding it.

What Hammerspoon Does Here

Hammerspoon is a macOS automation tool that exposes system APIs to Lua scripts. The key function here is hs.eventtap.keyStroke({modifiers}, key), which fires an actual key event at the OS level — the same signal as a physical keypress. Whatever window has focus receives it. No clipboard paste, no accessibility shortcut, no tell.

The Shift Character Map

local shiftChars = {
["@"] = "2", ["#"] = "3", ["$"] = "4", ["%"] = "5",
["^"] = "6", ["&"] = "7", ["*"] = "8", ["("] = "9",
[")"] = "0", ["!"] = "1", ["_"] = "-", ["+"] = "=",
["{"] = "[", ["}"] = "]", ["|"] = "\\", [":"] = ";",
['"'] = "'", ["<"] = ",", [">"] = ".", ["?"] = "/",
["~"] = "`"
}

hs.eventtap.keyStroke takes a key name, not a character. You can’t pass "@" — you have to fire Shift+2. This table maps every shifted symbol to its base key. Uppercase letters are handled separately with Shift+<lowercase> since they follow a predictable pattern.

Timing

local function realisticDelay(char)
local base = math.random(30000, 70000)
if char:match("[,;]") then return base + math.random(40000, 80000) end
if char:match("[%.%?]") then return base + math.random(80000, 160000) end
if math.random(8) == 1 then return math.random(20000, 35000) end
return base
end

Delays are in microseconds, fed to hs.timer.usleep. The design:

  • Base: 30–70ms per character. Sits around 14–33 WPM per keystroke — a competent but not exceptional typist.
  • Punctuation pauses. Commas and semicolons add 40–80ms on top of base. Sentence-enders add 80–160ms. Most people slow down finishing a clause.
  • Burst mode. One in eight characters fires at 20–35ms — faster than base. Mid-word muscle memory produces exactly this: brief runs of speed between slower character decisions.

The resulting distribution isn’t uniform. It has a realistic mix of fast, normal, and slow keystrokes without any deliberate pattern.

Typo Injection

local function maybeTypo(char)
if math.random(60) ~= 1 or char == " " or shiftChars[char] then return end
local neighbors = {
a="sq", b="vn", c="xv", d="sf", e="wr", f="dg", g="fh",
h="gj", i="uo", j="hk", k="jl", l="k", m="n", n="bm",
o="ip", p="o", q="wa", r="et", s="ad", t="ry", u="yi",
v="cb", w="qe", x="zc", y="ut", z="x"
}
local nearby = neighbors[char:lower()]
if not nearby or #nearby == 0 then return end
local idx = math.random(#nearby)
local wrongChar = nearby:sub(idx, idx)
if wrongChar == "" then return end
hs.eventtap.keyStroke({}, wrongChar, 0)
hs.timer.usleep(math.random(60000, 120000))
hs.eventtap.keyStroke({}, "delete", 0)
hs.timer.usleep(math.random(30000, 60000))
end

A typo fires roughly 1 in 60 characters — about once every 12 words. Frequent enough to read as human, infrequent enough not to be annoying.

The neighbor map encodes physical QWERTY adjacency. A typo on e hits either w or r — the keys immediately beside it. That’s the detail that makes it convincing: real fat-finger errors land on adjacent keys, not random ones.

The sequence is: wrong key, 60–120ms pause (recognition delay), delete, 30–60ms pause (recovery). The pre-delete pause is longer than the post-delete pause because noticing a mistake takes a beat; correcting it doesn’t.

Spaces and shifted characters are excluded. A space typo produces a double-space, which reads as sloppy rather than human. Shifted characters would require extra modifier logic that isn’t worth the complexity.

The Main Loop

local function typeString(text)
for i = 1, #text do
local char = text:sub(i,i)
if char == "\n" then
hs.eventtap.keyStroke({}, "return", 0)
hs.timer.usleep(math.random(200000, 400000))
else
maybeTypo(char)
if char == " " then
hs.eventtap.keyStroke({}, "space", 0)
elseif shiftChars[char] then
hs.eventtap.keyStroke({"shift"}, shiftChars[char], 0)
elseif char:match("%u") then
hs.eventtap.keyStroke({"shift"}, char:lower(), 0)
else
hs.eventtap.keyStroke({}, char, 0)
end
hs.timer.usleep(realisticDelay(char))
end
end
end

Newlines are handled first, before any other character logic. They fire return and sleep 200–400ms — a longer pause than any punctuation delay. That gap represents the beat before starting a new thought, not just finishing a sentence. Typos are skipped on \n since there’s no plausible adjacent-key error for the Return key.

For everything else, maybeTypo runs before the correct key fires. That’s the right order: you hit the wrong key, catch it, fix it, then continue with the intended character.

Hotkeys

The modifier combo ctrl+alt+shift+cmd — commonly called “Hyper” — is effectively uncapturable by accident. No system shortcut or standard app binding uses all four at once, and this means you get an extra layer of keyboard shortcuts by using it. I use Raycast to remap my caps lock to a hyperkey.

Canned prompts on number keys are good for scripted demos where the same sequence runs every time:

hs.hotkey.bind({"ctrl", "alt", "shift", "cmd"}, "1", function()
typeString("This is a string that will be typed out.")
end)

Hyper + 0 reads from the clipboard, which is the more useful binding day-to-day:

hs.hotkey.bind({"ctrl", "alt", "shift", "cmd"}, "0", function()
local text = hs.pasteboard.getContents()
if text and #text > 0 then
typeString(text)
end
end)

Copy any text — a multi-paragraph prompt, a follow-up question, a customized template — then hit Hyper+Space. It runs through typeString with all the same timing and typo behavior. Multiline clipboard content works automatically since \n is handled in the loop.

What This Actually Solves

The timing and typo simulation are means to an end. The real win is removing live-typing from the demo entirely. You write your prompts in advance, get the wording right, then trigger them on camera without pressure. No actual mistakes, no stumbling over words, no uncanny valley of perfect uniformity. The script handles looking human while you focus on the rest of the recording.

Full Script

-- realistic-typing.lua
-- Simulates human typing for demo recordings via Hammerspoon.
-- Hyper = ctrl+alt+shift+cmd
local shiftChars = {
["@"] = "2", ["#"] = "3", ["$"] = "4", ["%"] = "5",
["^"] = "6", ["&"] = "7", ["*"] = "8", ["("] = "9",
[")"] = "0", ["!"] = "1", ["_"] = "-", ["+"] = "=",
["{"] = "[", ["}"] = "]", ["|"] = "\\", [":"] = ";",
['"'] = "'", ["<"] = ",", [">"] = ".", ["?"] = "/",
["~"] = "`"
}
local function realisticDelay(char)
local base = math.random(30000, 70000)
if char:match("[,;]") then return base + math.random(40000, 80000) end
if char:match("[%.%?]") then return base + math.random(80000, 160000) end
if math.random(8) == 1 then return math.random(20000, 35000) end
return base
end
local function maybeTypo(char)
if math.random(60) ~= 1 or char == " " or shiftChars[char] then return end
local neighbors = {
a="sq", b="vn", c="xv", d="sf", e="wr", f="dg", g="fh",
h="gj", i="uo", j="hk", k="jl", l="k", m="n", n="bm",
o="ip", p="o", q="wa", r="et", s="ad", t="ry", u="yi",
v="cb", w="qe", x="zc", y="ut", z="x"
}
local nearby = neighbors[char:lower()]
if not nearby or #nearby == 0 then return end
local idx = math.random(#nearby)
local wrongChar = nearby:sub(idx, idx)
if wrongChar == "" then return end
hs.eventtap.keyStroke({}, wrongChar, 0)
hs.timer.usleep(math.random(60000, 120000))
hs.eventtap.keyStroke({}, "delete", 0)
hs.timer.usleep(math.random(30000, 60000))
end
local function typeString(text)
for i = 1, #text do
local char = text:sub(i,i)
if char == "\n" then
hs.eventtap.keyStroke({}, "return", 0)
hs.timer.usleep(math.random(200000, 400000))
else
maybeTypo(char)
if char == " " then
hs.eventtap.keyStroke({}, "space", 0)
elseif shiftChars[char] then
hs.eventtap.keyStroke({"shift"}, shiftChars[char], 0)
elseif char:match("%u") then
hs.eventtap.keyStroke({"shift"}, char:lower(), 0)
else
hs.eventtap.keyStroke({}, char, 0)
end
hs.timer.usleep(realisticDelay(char))
end
end
end
-- Hyper+Space: type whatever is currently on the clipboard
hs.hotkey.bind({"ctrl", "alt", "shift", "cmd"}, "0", function()
local text = hs.pasteboard.getContents()
if text and #text > 0 then
typeString(text)
end
end)
-- Canned demo prompts
hs.hotkey.bind({"ctrl", "alt", "shift", "cmd"}, "1", function()
typeString("Snippet 1")
end)
hs.hotkey.bind({"ctrl", "alt", "shift", "cmd"}, "2", function()
typeString("Snippet 2")
end)