diff --git a/packages/studio-base/src/i18n/en/panels.ts b/packages/studio-base/src/i18n/en/panels.ts
index 8d255d16ca..4e7124db62 100644
--- a/packages/studio-base/src/i18n/en/panels.ts
+++ b/packages/studio-base/src/i18n/en/panels.ts
@@ -34,6 +34,9 @@ export const panels = {
ROSDiagnosticSummaryDescription: "Display a summary of all ROS DiagnosticArray messages.",
stateTransitions: "State Transitions",
stateTransitionsDescription: "Track when values change over time.",
+ studioPlaybackPerformance: "Studio - Playback Performance",
+ studioPlaybackPerformanceDescription:
+ "Display playback and data-streaming performance statistics.",
tab: "Tab",
tabDescription: "Group panels together in a tabbed interface.",
table: "Table",
diff --git a/packages/studio-base/src/panels/PlaybackPerformance/index.stories.tsx b/packages/studio-base/src/panels/PlaybackPerformance/index.stories.tsx
new file mode 100644
index 0000000000..0659486181
--- /dev/null
+++ b/packages/studio-base/src/panels/PlaybackPerformance/index.stories.tsx
@@ -0,0 +1,32 @@
+// This Source Code Form is subject to the terms of the Mozilla Public
+// License, v2.0. If a copy of the MPL was not distributed with this
+// file, You can obtain one at http://mozilla.org/MPL/2.0/
+//
+// This file incorporates work covered by the following copyright and
+// permission notice:
+//
+// Copyright 2018-2021 Cruise LLC
+//
+// This source code is licensed under the Apache License, Version 2.0,
+// found at http://www.apache.org/licenses/LICENSE-2.0
+// You may not use this file except in compliance with the License.
+
+import { StoryObj } from "@storybook/react";
+
+import PanelSetup from "@foxglove/studio-base/stories/PanelSetup";
+
+import PlaybackPerformance from "./index";
+
+export default {
+ title: "panels/PlaybackPerformance",
+};
+
+export const SimpleExample: StoryObj = {
+ render: () => {
+ return (
+
+
+
+ );
+ },
+};
diff --git a/packages/studio-base/src/panels/PlaybackPerformance/index.tsx b/packages/studio-base/src/panels/PlaybackPerformance/index.tsx
new file mode 100644
index 0000000000..7465fb7836
--- /dev/null
+++ b/packages/studio-base/src/panels/PlaybackPerformance/index.tsx
@@ -0,0 +1,163 @@
+// This Source Code Form is subject to the terms of the Mozilla Public
+// License, v2.0. If a copy of the MPL was not distributed with this
+// file, You can obtain one at http://mozilla.org/MPL/2.0/
+//
+// This file incorporates work covered by the following copyright and
+// permission notice:
+//
+// Copyright 2018-2021 Cruise LLC
+//
+// This source code is licensed under the Apache License, Version 2.0,
+// found at http://www.apache.org/licenses/LICENSE-2.0
+// You may not use this file except in compliance with the License.
+
+import { Typography } from "@mui/material";
+import * as _ from "lodash-es";
+import { ReactElement } from "react";
+
+import { subtract as subtractTimes, toSec } from "@foxglove/rostime";
+import { Immutable } from "@foxglove/studio";
+import { useMessagePipeline } from "@foxglove/studio-base/components/MessagePipeline";
+import Panel from "@foxglove/studio-base/components/Panel";
+import PanelToolbar from "@foxglove/studio-base/components/PanelToolbar";
+import { Sparkline, SparklinePoint } from "@foxglove/studio-base/components/Sparkline";
+import Stack from "@foxglove/studio-base/components/Stack";
+import { PlayerStateActiveData } from "@foxglove/studio-base/players/types";
+
+const TIME_RANGE = 5000;
+
+type PlaybackPerformanceItemProps = {
+ points: SparklinePoint[];
+ maximum: number;
+ decimalPlaces: number;
+ label: React.ReactNode;
+};
+
+function PlaybackPerformanceItem(props: PlaybackPerformanceItemProps): ReactElement {
+ return (
+
+
+
+
+ {(_.last(props.points) ?? { value: 0 }).value.toFixed(props.decimalPlaces)}
+ {props.label}
+
+
+ {(_.sumBy(props.points, "value") / props.points.length).toFixed(props.decimalPlaces)} avg
+
+
+
+ );
+}
+
+type UnconnectedPlaybackPerformanceProps = Immutable<{
+ timestamp: number;
+ activeData?: PlayerStateActiveData;
+}>;
+
+function UnconnectedPlaybackPerformance({
+ timestamp,
+ activeData,
+}: UnconnectedPlaybackPerformanceProps): JSX.Element {
+ const playbackInfo =
+ React.useRef>();
+ const lastPlaybackInfo = playbackInfo.current;
+ if (activeData && (!playbackInfo.current || playbackInfo.current.activeData !== activeData)) {
+ playbackInfo.current = { timestamp, activeData };
+ }
+
+ const perfPoints = React.useRef<{
+ speed: SparklinePoint[];
+ framerate: SparklinePoint[];
+ bagTimeMs: SparklinePoint[];
+ megabitsPerSecond: SparklinePoint[];
+ }>({
+ speed: [],
+ framerate: [],
+ bagTimeMs: [],
+ megabitsPerSecond: [],
+ });
+
+ if (
+ activeData &&
+ playbackInfo.current &&
+ lastPlaybackInfo &&
+ lastPlaybackInfo.activeData !== activeData
+ ) {
+ const renderTimeMs = timestamp - lastPlaybackInfo.timestamp;
+ if (
+ lastPlaybackInfo.activeData.isPlaying &&
+ activeData.isPlaying &&
+ lastPlaybackInfo.activeData.lastSeekTime === activeData.lastSeekTime &&
+ lastPlaybackInfo.activeData.currentTime !== activeData.currentTime
+ ) {
+ const elapsedPlayerTime =
+ toSec(subtractTimes(activeData.currentTime, lastPlaybackInfo.activeData.currentTime)) *
+ 1000;
+ perfPoints.current.speed.push({ value: elapsedPlayerTime / renderTimeMs, timestamp });
+ perfPoints.current.framerate.push({ value: 1000 / renderTimeMs, timestamp });
+ perfPoints.current.bagTimeMs.push({ value: elapsedPlayerTime, timestamp });
+ }
+ const newBytesReceived =
+ activeData.totalBytesReceived - lastPlaybackInfo.activeData.totalBytesReceived;
+ const newMegabitsReceived = (8 * newBytesReceived) / 1e6;
+ const megabitsPerSecond = newMegabitsReceived / (renderTimeMs / 1000);
+ perfPoints.current.megabitsPerSecond.push({ value: megabitsPerSecond, timestamp });
+ for (const points of Object.values(perfPoints.current)) {
+ while (points[0] && points[0].timestamp < timestamp - TIME_RANGE) {
+ points.shift();
+ }
+ }
+ }
+
+ return (
+
+
+
+ × realtime>}
+ />
+
+
+
+
+
+ );
+}
+
+function PlaybackPerformance() {
+ const timestamp = Date.now();
+ const activeData = useMessagePipeline(
+ React.useCallback(({ playerState }) => playerState.activeData, []),
+ );
+ return ;
+}
+
+PlaybackPerformance.panelType = "PlaybackPerformance";
+PlaybackPerformance.defaultConfig = {};
+
+export default Panel(PlaybackPerformance);
diff --git a/packages/studio-base/src/panels/index.ts b/packages/studio-base/src/panels/index.ts
index 468f259704..d6cba45104 100644
--- a/packages/studio-base/src/panels/index.ts
+++ b/packages/studio-base/src/panels/index.ts
@@ -172,4 +172,10 @@ export const getBuiltin: (t: TFunction<"panels">) => PanelInfo[] = (t) => [
module: async () => await import("./Tab"),
hasCustomToolbar: true,
},
+ {
+ title: t("studioPlaybackPerformance"),
+ type: "PlaybackPerformance",
+ description: t("studioPlaybackPerformanceDescription"),
+ module: async () => await import("./PlaybackPerformance"),
+ },
];