DEV Community

Cover image for Replaying real-time telemetry through a live rendering pipeline, without touching the components
Jaya Sai Kishan Chapparam
Jaya Sai Kishan Chapparam

Posted on

Replaying real-time telemetry through a live rendering pipeline, without touching the components

I have a set of React components that render live telemetry: an attitude indicator, a moving map, tapes and gauges, a scrolling event log. They take a data source, subscribe to it, and paint whatever numbers arrive. That works for a live feed. The obvious next thing you want is replay: load a recorded session, scrub a timeline, watch the same instruments play it back.

The naive version of this is a trap, and it took me a wrong turn to see why. My first instinct was that replay is a data problem, load the samples, push them into the components in order, done. It compiled, it ran, and the charts were empty. Not broken, not erroring. Empty. The instruments that show a single current value worked fine. The time-series charts sat blank while correct data flowed into them. That empty chart is the whole story of this post, because the reason it's empty is the reason replay is more interesting than it looks.

The components are watching a clock you forgot about

Here's the data source interface these components consume. It's small on purpose:

interface TelemetryValue {
  timestamp: number; // wall-clock, unix ms
  value: number;
  channel?: string;
}

interface AltaraDataSource {
  subscribe(callback: (value: TelemetryValue) => void): () => void;
  getHistory(): TelemetryValue[];
  readonly status: ConnectionStatus;
  destroy(): void;
}
Enter fullscreen mode Exit fullscreen mode

A live source stamps each sample with Date.now() as it arrives. A time-series chart, reasonably, assumes that's what timestamps mean: it anchors its x-axis to Date.now() and draws a moving window of the last few seconds, discarding anything older than windowMs because that's off the left edge of the view.

Now replay a session recorded an hour ago. Every sample carries its original timestamp, an hour in the past. The chart buffers them correctly, then asks "is this within the last few seconds of now?", the answer is no for every single sample, and it draws nothing. The data is all there. It's just an hour to the left of the visible window, forever. The single-value instruments were fine precisely because they don't care about a time axis; they show the latest number regardless of when it happened. The charts care, and they were right to.

So replay isn't a data problem. It's a time problem. The recording lives in one timeline and the components live in another, and something has to translate.

Two clocks, kept deliberately apart

The design that falls out of this is to run two timebases at once and never confuse them.

There's recording time: milliseconds from the start of the session. Sample t: 0 is the first sample, t: 59903 is the last. This is the timeline the scrubber moves along, and it's the only clock the transport UI ever touches. When you drag to the middle of a one-minute recording, you're at recording time 30000, and that number means the same thing every time regardless of when you press play.

And there's wall-clock time: what the components see on TelemetryValue.timestamp. This has to track real Date.now(), because that's the assumption baked into every component.

The job of the replay source is to map one onto the other at the moment it emits. A sample sitting in the recording at t gets re-stamped, on its way out, to a wall-clock time computed from where the playhead is right now:

wallclock = anchorWall + (t - anchorT) / speed
Enter fullscreen mode Exit fullscreen mode

anchorT is the recording time the playhead sat at when playback last started or changed, and anchorWall is the real clock reading at that same instant. So the recording's timeline is pinned to the live clock at one point, and everything else is offset from that pin. Feed that to the chart and its Date.now() anchor now agrees with the samples: they land inside the visible window, and the chart paints exactly as it does live. The component didn't change. It doesn't know it's being replayed. It's watching wall-clock like it always did; the source just made the recording lie convincingly about what time it is.

Speed control drops out of this for free. Dividing the offset by speed compresses or stretches recording time against wall-clock, so at 2x, two seconds of recording map to one second of wall-clock, and the chart scrolls twice as fast without knowing why. The transform is rebased on every play, pause, seek, and speed change, so each of those just re-pins the two clocks and playback continues from the new anchor rather than retroactively rewriting history.

The inverse shows up when you pause. Pausing has to answer "where exactly is the playhead now?", which is the wall-clock-to-recording direction of the same transform:

playhead = anchorT + (now - anchorWall) * speed
Enter fullscreen mode Exit fullscreen mode

Note the operations flip: emitting divides by speed, locating the playhead multiplies by it. They're inverses, and mixing them up gives you a playhead that drifts off at the square of the speed, which is exactly the kind of bug that looks fine at 1x and falls apart the moment you test 2x.

The pause that quietly drifts

One consequence of the two clocks is worth pulling out, because it's the kind of thing that passes every quick test and then rots. getHistory() exists so a freshly mounted chart can seed itself with the recent past instead of starting blank. For replay it returns the window of recording time ending at the playhead, re-stamped to wall-clock like everything else.

But consider a paused replay. The playhead is frozen. Wall-clock is not. If the re-stamp anchor was set when you hit pause and you then leave it paused for thirty seconds, the transform keeps mapping the frozen playhead onto a wall-clock that's marched thirty seconds forward, so the history window slides further into the past the longer you sit there. A chart that mounts during a long pause seeds itself with data that's now off-screen, and you're back to the empty chart, this time only sometimes, only after a pause, which is a much worse bug to find. The fix is that getHistory() re-anchors when paused before it computes the window, so "now" is always the moment you asked, not the moment you stopped:

getHistory(): TelemetryValue[] {
  if (!this._playing) this.rebase();
  // ... window ending at the playhead, re-stamped to wall-clock
}
Enter fullscreen mode Exit fullscreen mode

Small thing. But it's the difference between replay that works and replay that works until someone pauses to look at something.

Seeking, and the components that fight it

Playing forward is one direction. Scrubbing is both, and backward is where the components split into two kinds.

The single-value instruments, the attitude indicator, the gauges, the tapes, hold the latest sample in a ref and repaint from it. Seeking those is trivial: emit a snapshot of the most recent value on each channel at the new playhead, and they snap to that state instantly, even while paused. No history, nothing to unwind, they're stateless with respect to time.

The accumulating components are the problem. A time-series chart and the map's GPS track both append into an internal buffer as samples arrive, and that buffer never clears itself, because in a live feed it never should. Scrub backward and you've moved the playhead into the past while the chart still holds all the future samples it already drew. It won't un-draw them. The buffer is monotonic and time only went one way as far as it's concerned.

You could add a "clear and reseed" method to those components. I didn't, because it's a method that exists only to serve replay, on components whose entire value is that they don't know replay exists. Instead the replay source classifies each seek as forward or backward, and the view remounts the accumulating components on a backward seek using a React key:

<TimeSeries key={`ts-${chartEpoch}`}  />
<LiveMap    key={`map-${chartEpoch}`}  />
Enter fullscreen mode Exit fullscreen mode

chartEpoch is one integer that bumps only when a seek goes backward. React tears the component down and rebuilds it, fresh buffer, and it reseeds from getHistory(), which returns the correct playhead-windowed slice. The single-value instruments aren't keyed and never remount; they just take the snapshot.

Two details make this more than a one-liner. The remount is only half the fix: a freshly mounted chart is empty, and it's getHistory() returning the right window that refills it. Remount without the history seed just gives you a blank chart very efficiently. And looping is a backward seek in disguise, when playback hits the end and wraps to the start, that wrap runs through the same seek-to-zero path, so it bumps chartEpoch and remounts the charts exactly as a manual scrub-to-start would. That fell out of the design for free, which is usually the sign the abstraction is sitting in the right place.

Where the recording comes from

The format is deliberately dull, because dull is what makes it interchangeable with live data. Each numeric sample is {t, c, v}, recording-relative time, channel, value, sorted by t so the playback cursor is a single forward scan and seeking is a binary search. Event-log entries ride a parallel track keyed by the same t, since a log line is text and severity, not a number, and doesn't fit the numeric channel model. That's the whole file:

{
  "version": 1,
  "durationMs": 59903,
  "channels": ["roll", "pitch", "heading", "airspeed", "altitude", "battery", ...],
  "samples": [ { "t": 0, "c": "roll", "v": 0 }, { "t": 1, "c": "pitch", "v": 5.156 } ],
  "events":  [ { "t": 1500, "severity": "info", "message": "MAVLink heartbeat — FCU connected" } ]
}
Enter fullscreen mode Exit fullscreen mode

Being honest about the demo: the session I ship is synthetic, not a capture off real hardware. It's generated from the same mock formulas the live demo tabs use, serialized to disk so the replay tab has something with a fixed duration to scrub. I mention it not as a disclaimer but because it's the point of the whole design, the replay source doesn't know or care whether the samples came from a generator or a real drone. The format is exactly what a live source's getHistory() already returns, so a genuine capture, tap a live source, log every emitted sample, drops in and plays back identically. Replay never learns where the data came from, which is the property that makes recording a separate concern I haven't had to build yet.

What actually made this work

The thing I'd take away from this, if I were reading it, is that replay looked like a storage-and-scheduling problem and turned out to be a coordinate-systems problem. The components were watching wall-clock the whole time, and the only real work was making a recording's timeline masquerade as wall-clock convincingly enough that nothing downstream noticed, one affine transform, applied at emit, inverted at pause. Everything else, the snapshot on seek, the key-remount for accumulating charts, the re-anchor on pause, is a consequence of taking that one idea seriously and chasing where the two clocks could drift apart.

None of the components changed. That was the constraint I set and it's the part I'm happiest with, because a replay layer that requires every instrument to grow a "replay mode" is a replay layer that rots the first time you add an instrument. Making the source carry the whole burden means live and recorded are genuinely the same code path, and the instruments stay honest about the only thing they were ever watching, the clock.

The full ReplayDataSource is here, the components are MIT on npm as @altara/core and friends, and there's a live demo with the replay tab if you want to scrub it and try to make a chart go blank.

Top comments (0)