The Mirror Nobody Would Build
No screen-mirroring app on earth flips a live desktop horizontally, so I built a whole Windows-to-Android streamer to get one working teleprompter, and then I couldn't stop.
I’m on a call. Someone’s sharing their screen, someone else is talking, and I’m reading a line of notes off a second display, angled behind the camera, bounced through a piece of glass. That’s a teleprompter, the same trick news anchors have used for seventy years, just improvised with a spare screen instead of a proper rig. It works, on one condition: whatever’s on that screen has to be mirrored. Flipped left-to-right. Because a sheet of glass doesn’t know or care that you meant to read the text normally: it just reflects what’s there, backwards, and if the source isn’t already flipped, so is everything you see through it. People become mirror images of themselves. Point left and you’re pointing right.
So: mirror the desktop, horizontally, and stream it to a second screen. That’s the whole ask. I assumed some remote-desktop app, somewhere, had a checkbox for this.
Nobody mirrors a live desktop
They don’t. Not one.
I went through the obvious list first: UltraVNC, RealVNC, TeamViewer, AnyDesk. None of them have a flip option, full stop. Not hidden in a menu, not behind a paid tier. UltraVNC’s own forum treats it as something the GPU driver should handle, not the app, which is true, and also useless, because Windows doesn’t expose a horizontal-flip setting either. There’s a PowerToys feature request asking for exactly this, filed against Microsoft’s own toolkit for fixing gaps in Windows. It was closed. “Windows does not offer screen flipping, only rotation. Please file this on Feedback Hub.” That’s Microsoft’s own maintainers, on Microsoft’s own tool for patching Windows, telling you Windows can’t do it.
I checked scrcpy too, since it’s the tool most people reach for to
mirror an Android screen onto a PC. I needed the opposite direction,
but if anyone had built flip support it’d be there. Two open GitHub
issues, both unresolved. One of them
describes my exact situation, a tablet mirrored via scrcpy for a
teleprompter, and gets no fix. Scrcpy did eventually ship an
--orientation flag, but that’s rotation: 0/90/180/270 degrees. A
mirror image isn’t a rotated image. Rotate a mirrored face 180 degrees
and it’s still a mirrored face, just upside down too.
Then I went looking at the hardware side, since maybe the professional teleprompter industry had already solved this and I just didn’t know the right search terms. Sort of. Prompter People sell software called PowerFlip, but it only reverses PowerPoint slides, nothing else. The actual hardware rigs from Ikan and Datavideo skip software entirely: they ship physically reversing monitors, wired directly to a laptop, where the flip happens in the monitor’s own electronics before the image ever reaches the glass. It’s a real, working solution for a laptop plugged into a monitor with a cable, but it says nothing about what I actually needed: my real desktop, mirrored, live, the way a second monitor shows it, not a script loaded into some app’s own window. Whatever’s actually open. Notes, a shared slide, a video call, whatever I’m looking at changes by the minute, and none of it lives inside a single dedicated app.
That’s the part that took me longest to actually name. This was never a “read a script” problem. Dedicated teleprompter apps like PromptSmart or BIGVU solve that: you type or paste your script in ahead of time, and the app scrolls it. What I wanted was simpler, and it turns out much harder: a second monitor. My actual desktop, whatever’s open on it, mirrored live, the same way a second monitor plugged into the wall would show it, just wireless and flipped. If the flip isn’t happening at the source, upstream of everything, then it isn’t a real second monitor. It’s just another app with its own limited window, and as far as I could find, genuinely nobody sells the thing I actually wanted.
I even checked whether the video-switcher hardware I already own could do it. An ATEM Mini Extreme sits between my camera and everything else I stream. Blackmagic’s own forum confirms the Mini and Mini Extreme can’t flip an HDMI input either. The flip has to happen before the signal reaches any of this gear, or not at all.
So: no software, on either platform, from any vendor, does this. I stopped looking and started building.
The shape of the thing
The plan was almost embarrassingly simple on paper: a small Windows program grabs a chunk of the desktop, encodes it, and streams it over the LAN to an Android app that decodes it, flips it, and shows it full-screen on a tablet propped behind the camera. A weekend project, I figured.
It did not stay a weekend project, because the moment I had a working video pipe between my PC and a tablet, I couldn’t leave it alone. If I already had low-latency video going one way, why not touch and a keyboard going back the other way, now the tablet’s a real second input device, not just a mirror. If input’s going back, why not the tablet’s own microphone, streamed into the PC as if it were a proper USB mic. If the mic’s already open, why not hold a key and dictate straight into whatever window has focus, from the couch. Each answer was “why not,” and within a couple of days I had a Windows host and an Android client talking over a protocol I’d designed myself: one byte for the message type, four bytes for its length, then whatever payload fits: JSON where a message is rare and cheap to parse, raw bytes where it’s a touch event or a chunk of audio and every microsecond of encoding time matters. Versioned once, additive only, forever. I never wanted a v2 that couldn’t talk to a v1 client.
The video side turned out to be the easy part: a virtual display driver, hardware HEVC encoding on the PC’s GPU, and a decoder on the Android side, tuned until the latency was low enough that touching the tablet felt like touching the actual desktop. The interesting bugs were everywhere else.
The clicking microphone
The tablet-as-microphone feature worked, mostly, within a day. Audio streamed from the tablet’s mic to the PC, out through a virtual audio cable, into whatever call or recording software was listening. Mostly. Underneath “mostly” was a five-layer bug I didn’t fully understand until I’d fixed the wrong thing four times.
The first version had no jitter buffer at all. Audio arrived from the network in bursts, not a steady drip, and anything that expects a steady drip (which is everything) stuttered. I built an adaptive cushion, the same idea VoIP software has used for decades: hold a small buffer of audio in reserve, grow it when the network gets rough, shrink it back down during calm stretches. That fixed the stutter and introduced a new, quieter problem: a feedback loop. My steering logic, the part that speeds up or slows down playback slightly to keep the buffer at its target size, was measuring the wrong thing, and it settled into an oscillation that clicked at a perfectly regular interval. Not random noise. A metronome.
Chasing that took me into the Android side. After building a validated
ladder of audio sources and confirming zero underruns in the host’s own
logs, while the recording still had holes in it, I discovered that the
silence wasn’t a buffering problem at all. Samsung’s
VOICE_COMMUNICATION audio source ducks the microphone the instant it
thinks you’re in a phone call, which streaming audio apparently looks
like. Switching sources fixed the holes and revealed the next layer:
VOICE_RECOGNITION on that particular phone ran the signal through
processing that turned speech into something between static and a wind
tunnel. The fix was a validated fallback ladder that tries a source, and moves
on the moment recording state doesn’t confirm, with every audio effect
explicitly disabled, since one of Samsung’s own DSP chains turned out to
be the thing mangling the signal in the first place.
By this point playback was polling for new audio every hundred milliseconds or so, and that’s where the last real gap lived: a sub-20-millisecond starvation the polling loop was structurally blind to. I rewrote the render path to be event-driven: Windows tells the sink exactly when it needs the next slice of audio, and every single slice gets filled, real audio if there’s any waiting, and if not, a raised-cosine fade to silence instead of a hard cut:
constexpr int kFade = kRate / 100; // 10ms raised-cosine ramps
int gainPhase = 0; // 0 = silent .. kFade = unity
auto gain = [&](int p) { return 0.5 * (1.0 - cos(kPi * p / kFade)); };
A hard cut to silence is a step function, and a step function’s energy spreads across every frequency, and that’s what a click is, acoustically. A raised cosine confines the transition below what’s audible. Once every period was guaranteed full, whether with real audio or a proper fade, the recordings were clean. Almost.
A few clicks remained, spaced with a regularity that felt mechanical
rather than random, and I’d run out of theories. So I stopped guessing
and recorded the output, a plain WAV file, thirty seconds, into a
Python script. Derivative-outlier detection found ten times the local
noise floor at each click, exactly evenly spaced, which ruled out
anything network-related; jitter doesn’t arrive on a schedule. Envelope
cross-correlation lined the clicks up against the render clock instead
of the network clock. That pointed straight at one place: the virtual
audio cable itself. Its render side was running at 48kHz. Its capture
side was running at 44.1kHz. Nothing was resampling between them, so the
cable was silently discarding samples on a steady cadence to keep up,
splicing the waveform at fixed intervals, which is exactly the click
pattern the forensics had found. I patched the capture device’s format
directly through IPolicyConfig::SetDeviceFormat, the only write that
actually stuck (anything gentler got silently reverted by Windows’ own
audio endpoint builder), and the clicks stopped. All of them.
Five real bugs, and only the last one was where the noise was actually coming from. The other four were real too. They just weren’t this one.
The crashing shell
Weeks later, Explorer started crashing. Repeatedly, unpredictably, worst
during swipes near the screen’s edges. Windows’ own event log showed the
same signature every single time: an access violation in
twinui.pcshell.dll, at the exact same offset, eight times in a row.
That kind of consistency usually means one specific, deterministic
trigger: not flaky hardware, not a race condition, something a machine
does the same way every time you ask it to.
My first theory was touch itself: injected gestures landing near the screen’s border, tripping Windows’ edge-swipe recognizer. I widened the dead zone, added a guard so wheel events could never be posted into a shell-owned window, and shipped it. Explorer kept crashing.
So I built the instrument that could actually answer the question instead of guessing again: a rolling log of every touch frame the host injected, and a sentinel thread watching Explorer’s process ID once a second. The moment Explorer restarted, the host would dump the last five seconds of exactly what had been injected before it died. Two crashes later, both dumps showed the same thing: nothing unusual in the touch stream. A tap. A quick two-finger gesture. Nothing that should crash a shell.
But both dumps also showed the same log line about three seconds
earlier, every time: “dismissed Windows touch keyboard.” That was my
own code, a feature that auto-raised my own on-screen keyboard by
detecting when Windows tried to invoke its touch keyboard, and killing
that process outright so mine could take over. twinui.pcshell.dll
turned out to be the exact component managing that keyboard’s window
from inside Explorer. When the process it’s watching gets killed out
from under it, it doesn’t fail gracefully. It crashes the whole shell,
a few seconds later, at a fixed offset, every time.
It wasn’t touch. It wasn’t an edge case. It was my own feature,
terminating a process the operating system didn’t expect to die. The
fix was almost insultingly simple once I knew what to look for: ask the
keyboard’s window to close politely, WM_SYSCOMMAND with SC_CLOSE,
instead of ending its process. Zero crashes since.
I’d spent a week fixing the wrong thing because the wrong thing was a plausible story. The right thing needed evidence I hadn’t collected yet. That’s twice now, on this project. The bug was somewhere I hadn’t thought to look, and the fix was building a way to actually see it instead of reasoning about it harder.
What it does now
The mirror still works. That’s the whole reason any of this exists. Flip it horizontally, prop the tablet behind the camera, and it’s a proper wireless teleprompter with a beam-splitter glass in front of it, showing my actual desktop exactly the way a second monitor would, just mirrored: whatever I open, whatever I’m reading, however I’m actually using the machine that minute.
Everything else grew off that one working feature. A custom on-screen keyboard raises itself the moment I tap into a text field on the streamed desktop, no more fighting Windows’ own touch keyboard for space. Holding a key on that keyboard starts recording; sliding up locks it hands-free so I don’t have to keep a finger down mid-sentence; a second tap ends the take and types the transcript straight into whatever window has focus, through a local Whisper model rather than any cloud API. A small dot on the interface breathes red with my voice level while the mic’s live, and turns blue while a dictation clip is recording. One glance tells me whether audio’s actually flowing, which used to be a genuine mystery during the recording saga above. Two fingers scroll a window the way a mouse wheel would, because it turns out most terminal emulators never implemented touch panning in the first place, and a double-tap-and-drag does the same trick for text selection, since terminals don’t implement touch selection either.
None of it is speculative. Nineteen unit tests (deck geometry, wire protocol framing, dictation gesture state) gate every build before it ships to either of my phones. If a change breaks the thing that stops a key deck’s glyphs from overflowing their caps, the build simply doesn’t happen.
I’ve livestreamed before, in a rougher form, without any of this: you can see it here. A new tablet is landing soon, and the plan is to try PromptCast for that properly: same mirror, same low-latency pipe, pointed at an actual audience instead of a meeting.
What building it taught me
The two real bugs in this story, the clicking cable, the crashing shell, had almost the same shape underneath. In both cases I had a plausible theory, fixed what the theory predicted, and the symptom didn’t go away, because the theory was wrong and nothing I’d done had tested it against the actual evidence. The fix, both times, was the same: stop reasoning about the system from the outside, and build something that could show me what was actually happening inside it: a waveform to analyze, a ring buffer of exactly what got injected and when. The bug is usually somewhere you haven’t looked yet, and the fastest way to find it is rarely to think harder. It’s to build the instrument that can prove it.
The smaller lesson is about scope. I didn’t set out to build a Windows-to-Android streaming protocol, a custom keyboard, a tablet-as-microphone pipeline, or a dictation system. I set out to flip a screen. Every feature after that existed because I already had the one piece of infrastructure (a low-latency pipe between two devices) that made the next thing cheap to try. That’s the actual argument for building your own tools instead of settling for whatever’s on the market: not that the market is always wrong, but that once you own the whole pipe, “why not” stops being a question you have to justify.
How this actually got built
The lesson above, about building the instrument instead of accepting a plausible theory, is worth being honest about where it actually came from, because I didn’t write the code that found either root cause.
Claude Fable 5 did nearly all of the implementation on this project: the protocol, the encoder and decoder, the key deck, the audio pipeline, and the forensic tooling itself. My part was direction and testing, on real hardware, on real calls, and it turns out that’s a real job.
It also didn’t take weeks. The initial protocol, the video pipe, touch, the microphone pipeline, and dictation came together in a couple of days, a pace I could not have set alone. That’s not a small part of this story. The reason “why not” kept being the right answer, feature after feature, is that the cost of trying the next thing had dropped to almost nothing.
Concretely: when early microphone debugging went in circles chasing theories about the wrong phone, it was a single correction, “I’m currently actually testing on the fold,” that reset the whole investigation onto the right device. When four plausible fixes in a row hadn’t touched the actual clicks, my direction wasn’t another fix to try. It was to stay scientifically grounded and analyze a waveform I’d record myself. That sentence is what actually found the cable’s clock mismatch. When Explorer kept crashing after a week of plausible-but-wrong patches, my direction again wasn’t a fix. It was that we needed real logging into why it kept happening, not another guess. That’s what led to the forensic ring buffer that eventually caught my own feature killing a process the shell needed alive.
Both breakthroughs in this story came from the same place: not a better theory, but refusing to accept a theory at all until there was evidence to check it against. I didn’t write either fix. What I did, twice, in two unrelated parts of this system, was recognize exactly when to stop guessing and ask for proof instead. If that’s the real skill behind a project like this, I’m fine being the one who brought it.