diff --git a/app/components-react/shared/PerformanceMetrics.tsx b/app/components-react/shared/PerformanceMetrics.tsx
index 345f439ac85e..c916df0c5a4a 100644
--- a/app/components-react/shared/PerformanceMetrics.tsx
+++ b/app/components-react/shared/PerformanceMetrics.tsx
@@ -7,9 +7,9 @@ import styles from './PerformanceMetrics.m.less';
import { $t } from '../../services/i18n';
import { useRealmObject } from 'components-react/hooks/realm';
import { IPinnedStatistics } from 'services/customization';
+import { getPerformanceMetricMetadata } from './performance-metrics';
type TPerformanceMetricsMode = 'full' | 'limited';
-const METRICS = ['cpu', 'fps', 'droppedFrames', 'bandwidth'] as (keyof IPinnedStatistics)[];
function pinTooltip(mode: TPerformanceMetricsMode, stat: string) {
return mode === 'full' ? $t('Click to add %{stat} info to your footer', { stat }) : '';
@@ -29,7 +29,7 @@ export default memo(function PerformanceMetrics(props: {
mode: TPerformanceMetricsMode;
className?: string;
}) {
- const { CustomizationService, PerformanceService } = Services;
+ const { CustomizationService, PerformanceService, StreamingService } = Services;
const pinnedStats = useRealmObject(CustomizationService.state.pinnedStatistics);
@@ -40,27 +40,29 @@ export default memo(function PerformanceMetrics(props: {
droppedFrames: PerformanceService.views.droppedFrames,
percentDropped: PerformanceService.views.percentDropped,
bandwidth: PerformanceService.views.bandwidth,
+ bandwidthByDisplay: PerformanceService.views.bandwidthByDisplay,
+ isDualOutputMode: StreamingService.views.isDualOutputMode,
}),
false,
);
const metadata = useMemo(
- () => ({
- cpu: { value: `${v.cpuPercent}%`, label: $t('CPU'), icon: 'icon-cpu' },
- fps: { value: v.frameRate, label: 'FPS', icon: 'icon-fps' },
- droppedFrames: {
- value: `${v.droppedFrames} (${v.percentDropped}%)`,
- label: $t('Dropped Frames'),
- icon: 'icon-dropped-frames',
- },
- bandwidth: { value: v.bandwidth, label: 'kb/s', icon: 'icon-bitrate' },
- }),
- [v.bandwidth, v.cpuPercent, v.droppedFrames, v.frameRate, v.percentDropped],
+ () => getPerformanceMetricMetadata(v),
+ [
+ v.bandwidth,
+ v.bandwidthByDisplay.horizontal,
+ v.bandwidthByDisplay.vertical,
+ v.cpuPercent,
+ v.droppedFrames,
+ v.frameRate,
+ v.isDualOutputMode,
+ v.percentDropped,
+ ],
);
const shownCells = useMemo(
- () => METRICS.filter(val => props.mode === 'full' || pinnedStats[val]),
- [props.mode, pinnedStats],
+ () => metadata.filter(metric => props.mode === 'full' || pinnedStats[metric.attribute]),
+ [metadata, props.mode, pinnedStats],
);
const updatePinnedStats = useCallback(
@@ -80,17 +82,16 @@ export default memo(function PerformanceMetrics(props: {
props.className,
)}
>
- {shownCells.map((attribute: keyof IPinnedStatistics) => {
- const data = metadata[attribute];
+ {shownCells.map(data => {
return (
);
diff --git a/app/components-react/shared/performance-metrics.ts b/app/components-react/shared/performance-metrics.ts
new file mode 100644
index 000000000000..7dd288014800
--- /dev/null
+++ b/app/components-react/shared/performance-metrics.ts
@@ -0,0 +1,71 @@
+import { IPinnedStatistics } from 'services/customization';
+import { $t } from 'services/i18n';
+
+export interface IPerformanceMetricValues {
+ cpuPercent: string;
+ frameRate: string;
+ droppedFrames: number;
+ percentDropped: string;
+ bandwidth: string;
+ bandwidthByDisplay: {
+ horizontal: string;
+ vertical: string;
+ };
+ isDualOutputMode: boolean;
+}
+
+export interface IPerformanceMetricMetadata {
+ key: string;
+ attribute: keyof IPinnedStatistics;
+ value: string | number;
+ label: string;
+ icon: string;
+}
+
+export function getPerformanceMetricMetadata(
+ values: IPerformanceMetricValues,
+): IPerformanceMetricMetadata[] {
+ const metrics: IPerformanceMetricMetadata[] = [
+ {
+ key: 'cpu',
+ attribute: 'cpu',
+ value: `${values.cpuPercent}%`,
+ label: $t('CPU'),
+ icon: 'icon-cpu',
+ },
+ {
+ key: 'fps',
+ attribute: 'fps',
+ value: values.frameRate,
+ label: 'FPS',
+ icon: 'icon-fps',
+ },
+ {
+ key: 'droppedFrames',
+ attribute: 'droppedFrames',
+ value: `${values.droppedFrames} (${values.percentDropped}%)`,
+ label: $t('Dropped Frames'),
+ icon: 'icon-dropped-frames',
+ },
+ ];
+
+ if (values.isDualOutputMode) {
+ metrics.push({
+ key: 'bandwidth',
+ attribute: 'bandwidth',
+ value: `H: ${values.bandwidthByDisplay.horizontal} V: ${values.bandwidthByDisplay.vertical}`,
+ label: 'kb/s',
+ icon: 'icon-bitrate',
+ });
+ } else {
+ metrics.push({
+ key: 'bandwidth',
+ attribute: 'bandwidth',
+ value: values.bandwidth,
+ label: 'kb/s',
+ icon: 'icon-bitrate',
+ });
+ }
+
+ return metrics;
+}
diff --git a/app/services/performance.ts b/app/services/performance.ts
index efb497b77c65..2b9fbed1fc37 100644
--- a/app/services/performance.ts
+++ b/app/services/performance.ts
@@ -16,6 +16,10 @@ import { StreamingService, EStreamingState } from 'services/streaming';
import { VideoSettingsService } from 'services/settings-v2/video';
import { DualOutputService } from 'services/dual-output';
import { UsageStatisticsService } from './usage-statistics';
+import {
+ createEmptyDisplayStats,
+ TStreamingPerformanceDisplayStatsByDisplay,
+} from './streaming/streaming-statistics';
interface IPerformanceState {
CPU: number;
@@ -28,6 +32,7 @@ interface IPerformanceState {
numberEncodedFrames: number;
numberRenderedFrames: number;
streamingBandwidth: number;
+ streamingBandwidthByDisplay: TStreamingPerformanceDisplayStatsByDisplay;
frameRate: number;
}
@@ -87,6 +92,13 @@ class PerformanceServiceViews extends ViewHandler {
return (this.state.streamingBandwidth ?? 0).toFixed(0);
}
+ get bandwidthByDisplay() {
+ return {
+ horizontal: (this.state.streamingBandwidthByDisplay?.horizontal.kbitsPerSec ?? 0).toFixed(0),
+ vertical: (this.state.streamingBandwidthByDisplay?.vertical.kbitsPerSec ?? 0).toFixed(0),
+ };
+ }
+
get streamQuality() {
if (
this.state.percentageDroppedFrames > 50 ||
@@ -127,6 +139,7 @@ export class PerformanceService extends StatefulService {
numberEncodedFrames: 0,
numberRenderedFrames: 0,
streamingBandwidth: 0,
+ streamingBandwidthByDisplay: createEmptyDisplayStats(),
frameRate: 0,
};
@@ -203,6 +216,7 @@ export class PerformanceService extends StatefulService {
numberDroppedFrames: streamingStats.droppedFrames,
percentageDroppedFrames,
streamingBandwidth: streamingStats.kbitsPerSec,
+ streamingBandwidthByDisplay: streamingStats.byDisplay ?? createEmptyDisplayStats(),
// averageTimeToRenderFrame: obs.Global.averageFrameRenderTime,
// diskSpaceAvailable: obs.Global.diskSpaceAvailable,
// memoryUsage: obs.Global.memoryUsage,
diff --git a/app/services/streaming/streaming-statistics.ts b/app/services/streaming/streaming-statistics.ts
new file mode 100644
index 000000000000..558db9df82da
--- /dev/null
+++ b/app/services/streaming/streaming-statistics.ts
@@ -0,0 +1,88 @@
+export type TStreamingPerformanceStatsDisplay = 'horizontal' | 'vertical';
+
+export interface IStreamingPerformanceDisplayStats {
+ kbitsPerSec: number;
+ dataOutput: number;
+}
+
+export type TStreamingPerformanceDisplayStatsByDisplay = Record<
+ TStreamingPerformanceStatsDisplay,
+ IStreamingPerformanceDisplayStats
+>;
+
+export interface IStreamingPerformanceStatsInstance {
+ display?: TStreamingPerformanceStatsDisplay;
+ displayStats?: TStreamingPerformanceDisplayStatsByDisplay;
+ droppedFrames: number;
+ totalFrames: number;
+ kbitsPerSec: number;
+ dataOutput: number;
+}
+
+export interface IStreamingPerformanceStats {
+ droppedFrames: number;
+ totalFrames: number;
+ kbitsPerSec: number;
+ dataOutput: number;
+ byDisplay?: TStreamingPerformanceDisplayStatsByDisplay;
+}
+
+export interface IStreamingPerformanceStatsOptions {
+ calculateByDisplay?: boolean;
+}
+
+export function createEmptyDisplayStats(): TStreamingPerformanceDisplayStatsByDisplay {
+ return {
+ horizontal: { kbitsPerSec: 0, dataOutput: 0 },
+ vertical: { kbitsPerSec: 0, dataOutput: 0 },
+ };
+}
+
+function addDisplayStats(
+ target: TStreamingPerformanceDisplayStatsByDisplay,
+ display: TStreamingPerformanceStatsDisplay,
+ stats: IStreamingPerformanceDisplayStats,
+) {
+ target[display].kbitsPerSec += stats.kbitsPerSec;
+ target[display].dataOutput += stats.dataOutput;
+}
+
+export function calculateStreamingPerformanceStats(
+ instances: IStreamingPerformanceStatsInstance[],
+ options: IStreamingPerformanceStatsOptions = {},
+): IStreamingPerformanceStats {
+ let droppedFrames = 0;
+ let totalFrames = 0;
+ let kbitsPerSec = 0;
+ let dataOutput = 0;
+ const byDisplay = options.calculateByDisplay ? createEmptyDisplayStats() : undefined;
+
+ instances.forEach(instance => {
+ droppedFrames += instance.droppedFrames;
+ totalFrames += instance.totalFrames;
+ kbitsPerSec += instance.kbitsPerSec;
+ dataOutput += instance.dataOutput;
+
+ if (byDisplay && instance.displayStats) {
+ addDisplayStats(byDisplay, 'horizontal', instance.displayStats.horizontal);
+ addDisplayStats(byDisplay, 'vertical', instance.displayStats.vertical);
+ } else if (byDisplay && instance.display) {
+ addDisplayStats(byDisplay, instance.display, {
+ kbitsPerSec: instance.kbitsPerSec,
+ dataOutput: instance.dataOutput,
+ });
+ }
+ });
+
+ if (instances.length > 1) {
+ kbitsPerSec = Math.round(kbitsPerSec / instances.length);
+ }
+
+ return {
+ droppedFrames,
+ totalFrames,
+ kbitsPerSec,
+ dataOutput,
+ ...(byDisplay ? { byDisplay } : {}),
+ };
+}
diff --git a/app/services/streaming/streaming.ts b/app/services/streaming/streaming.ts
index a3ae26e74dc5..ff6cd29dc21d 100644
--- a/app/services/streaming/streaming.ts
+++ b/app/services/streaming/streaming.ts
@@ -95,6 +95,11 @@ import { EOBSOutputType, EOBSOutputSignal, IOBSOutputSignalInfo } from 'services
import { SignalsService } from 'services/signals-manager';
import { TSocketEvent } from 'services/websocket';
import { HighlighterService } from 'services/highlighter';
+import {
+ calculateStreamingPerformanceStats,
+ TStreamingPerformanceDisplayStatsByDisplay,
+ IStreamingPerformanceStatsInstance,
+} from './streaming-statistics';
type TOBSOutputType = 'streaming' | 'recording' | 'replayBuffer';
type TOutputContext = TDisplayType | 'enhancedBroadcasting' | 'stream' | 'streamSecond';
@@ -188,7 +193,6 @@ export class StreamingService
streamingStateChange = new Subject();
powerSaveId: number;
- private numInstances: number = 0;
private resolveStartStreaming: Function = () => {};
private rejectStartStreaming: Function = () => {};
@@ -2662,9 +2666,6 @@ export class StreamingService
} else {
await this.handleStartSingleOutputStream(info.signal, context, nextState, time);
}
- // Memoize number of streaming instances for performance metrics calculation
- this.numInstances++;
-
// Updating state for the UI is handled in the above functions
return;
} else if (info.signal === EOBSOutputSignal.Activate) {
@@ -3271,6 +3272,14 @@ export class StreamingService
return 'additionalVideo' in instance;
}
+ private getEnhancedBroadcastingDisplayStats(
+ instance: IEnhancedBroadcastingSimpleStreaming | IEnhancedBroadcastingAdvancedStreaming,
+ ): TStreamingPerformanceDisplayStatsByDisplay | undefined {
+ return ((instance as unknown) as {
+ displayStats?: TStreamingPerformanceDisplayStatsByDisplay;
+ }).displayStats;
+ }
+
getStreamingInstance(): ISimpleStreaming | IAdvancedStreaming | null {
return (
this.contexts.horizontal?.streaming ??
@@ -3514,38 +3523,30 @@ export class StreamingService
* PERFORMANCE STATISTICS
*/
get streamingPerformanceStats() {
- let droppedFrames = 0;
- let totalFrames = 0;
- let kbitsPerSec = 0;
- let dataOutput = 0;
+ const isDualOutputMode = this.views.isDualOutputMode;
+ const instances: IStreamingPerformanceStatsInstance[] = [];
for (const contextName of Object.keys(this.contexts) as TOutputContext[]) {
const instance = this.contexts[contextName].streaming;
if (!instance) continue;
- // Twitch enhanced broadcasting and dual format encodes three additional resolutions on the frontend
- // so we need to average these for accurate display bitrate. Note: Enhanced broadcasting will always be
- // around 4500 kbitsPerSec bitrate requirements for the resolutions.
- if (this.isEnhancedBroadcastingStreaming(instance)) {
- droppedFrames += instance.droppedFrames;
- totalFrames += instance.totalFrames;
- kbitsPerSec += Math.round(instance.kbitsPerSec / 3);
- dataOutput += instance.dataOutput;
- } else {
- droppedFrames += instance.droppedFrames;
- totalFrames += instance.totalFrames;
- kbitsPerSec += instance.kbitsPerSec;
- dataOutput += instance.dataOutput;
- }
- }
-
- // TODO: Add UI to show bitrate by display but for now average the all instances, which is more accurate
- // than only showing the horizontal display's bitrate in dual output mode
+ const display =
+ isDualOutputMode && this.isDisplayContext(contextName) ? contextName : undefined;
+ const displayStats =
+ isDualOutputMode && this.isEnhancedBroadcastingStreaming(instance)
+ ? this.getEnhancedBroadcastingDisplayStats(instance)
+ : undefined;
- if (this.numInstances > 1) {
- kbitsPerSec = Math.round(kbitsPerSec / this.numInstances);
+ instances.push({
+ display,
+ displayStats,
+ droppedFrames: instance.droppedFrames,
+ totalFrames: instance.totalFrames,
+ kbitsPerSec: instance.kbitsPerSec,
+ dataOutput: instance.dataOutput,
+ });
}
- return { droppedFrames, totalFrames, kbitsPerSec, dataOutput };
+ return calculateStreamingPerformanceStats(instances, { calculateByDisplay: isDualOutputMode });
}
get recordingPerformanceStats() {
@@ -4001,8 +4002,6 @@ export class StreamingService
this.contexts[contextName].streaming?.stop(true);
}
-
- this.numInstances = 0;
}
/**
diff --git a/scripts/repositories.json b/scripts/repositories.json
index 74596505e8cd..bf52d73b5eeb 100644
--- a/scripts/repositories.json
+++ b/scripts/repositories.json
@@ -4,7 +4,7 @@
"name": "obs-studio-node",
"url": "https://s3-us-west-2.amazonaws.com/obsstudionodes3.streamlabs.com/",
"archive": "osn-[VERSION]-release-[OS][ARCH].tar.gz",
- "version": "0.26.29b3",
+ "version": "per-display-bitrate-stats-1",
"win64": true,
"osx": true
},
diff --git a/test/regular/components-react/performance-metrics.ts b/test/regular/components-react/performance-metrics.ts
new file mode 100644
index 000000000000..a95928bd496f
--- /dev/null
+++ b/test/regular/components-react/performance-metrics.ts
@@ -0,0 +1,56 @@
+import test from 'ava';
+import * as Module from 'module';
+
+const moduleLoader = Module as any;
+const originalLoad = moduleLoader._load;
+
+moduleLoader._load = function (this: unknown, request: string, ...args: unknown[]) {
+ if (request === 'services/i18n') return { $t: (key: string) => key };
+ return originalLoad.call(this, request, ...args);
+};
+
+const { getPerformanceMetricMetadata } = require('../../../app/components-react/shared/performance-metrics') as typeof import('../../../app/components-react/shared/performance-metrics');
+
+moduleLoader._load = originalLoad;
+
+test('Performance metric metadata uses one combined bandwidth metric outside dual output mode', t => {
+ const metrics = getPerformanceMetricMetadata({
+ cpuPercent: '8.1',
+ frameRate: '30.00',
+ droppedFrames: 124,
+ percentDropped: '1.9',
+ bandwidth: '8290',
+ bandwidthByDisplay: { horizontal: '5000', vertical: '3290' },
+ isDualOutputMode: false,
+ });
+
+ t.deepEqual(
+ metrics.filter(metric => metric.attribute === 'bandwidth'),
+ [{ key: 'bandwidth', attribute: 'bandwidth', value: '8290', label: 'kb/s', icon: 'icon-bitrate' }],
+ );
+});
+
+test('Performance metric metadata shows horizontal and vertical bandwidth in dual output mode', t => {
+ const metrics = getPerformanceMetricMetadata({
+ cpuPercent: '8.1',
+ frameRate: '30.00',
+ droppedFrames: 124,
+ percentDropped: '1.9',
+ bandwidth: '8290',
+ bandwidthByDisplay: { horizontal: '5000', vertical: '3290' },
+ isDualOutputMode: true,
+ });
+
+ t.deepEqual(
+ metrics.filter(metric => metric.attribute === 'bandwidth'),
+ [
+ {
+ key: 'bandwidth',
+ attribute: 'bandwidth',
+ value: 'H: 5000 V: 3290',
+ label: 'kb/s',
+ icon: 'icon-bitrate',
+ },
+ ],
+ );
+});
diff --git a/test/regular/platform-apps-source-url.ts b/test/regular/platform-apps-source-url.ts
index 170468dc6ad3..3406d819f46c 100644
--- a/test/regular/platform-apps-source-url.ts
+++ b/test/regular/platform-apps-source-url.ts
@@ -1,7 +1,7 @@
import test from 'ava';
import { stringifyAppSourceSettings } from '../../app/services/platform-apps/source-url';
-test('stringifyAppSourceSettings serializes object settings as JSON', t => {
+test('StringifyAppSourceSettings serializes object settings as JSON', t => {
const settings = {
LONG_ACCESSTOKEN: 'token',
intervals: '15',
@@ -13,19 +13,19 @@ test('stringifyAppSourceSettings serializes object settings as JSON', t => {
);
});
-test('stringifyAppSourceSettings preserves string settings', t => {
+test('StringifyAppSourceSettings preserves string settings', t => {
const settings = '{"LONG_ACCESSTOKEN":"token","intervals":"15"}';
t.is(stringifyAppSourceSettings(settings), settings);
});
-test('stringifyAppSourceSettings returns empty string for empty settings', t => {
+test('StringifyAppSourceSettings returns empty string for empty settings', t => {
t.is(stringifyAppSourceSettings(undefined), '');
t.is(stringifyAppSourceSettings(null), '');
t.is(stringifyAppSourceSettings(''), '');
});
-test('stringifyAppSourceSettings returns empty string for non-serializable settings', t => {
+test('StringifyAppSourceSettings returns empty string for non-serializable settings', t => {
const settings: Record = {};
settings.self = settings;
diff --git a/test/regular/streaming/streaming-statistics.ts b/test/regular/streaming/streaming-statistics.ts
new file mode 100644
index 000000000000..b54eebbd894d
--- /dev/null
+++ b/test/regular/streaming/streaming-statistics.ts
@@ -0,0 +1,104 @@
+import test from 'ava';
+import { calculateStreamingPerformanceStats } from '../../../app/services/streaming/streaming-statistics';
+
+test('Streaming stats use the current live instance count for bitrate averaging', t => {
+ const stats = calculateStreamingPerformanceStats([
+ {
+ droppedFrames: 0,
+ totalFrames: 300,
+ kbitsPerSec: 6000,
+ dataOutput: 1024,
+ },
+ ]);
+
+ t.is(stats.kbitsPerSec, 6000);
+});
+
+test('Streaming stats average bitrate across active regular streaming instances without display stats', t => {
+ const stats = calculateStreamingPerformanceStats([
+ {
+ display: 'horizontal',
+ droppedFrames: 1,
+ totalFrames: 100,
+ kbitsPerSec: 6000,
+ dataOutput: 1024,
+ },
+ {
+ display: 'vertical',
+ droppedFrames: 2,
+ totalFrames: 200,
+ kbitsPerSec: 4000,
+ dataOutput: 2048,
+ },
+ ]);
+
+ t.deepEqual(stats, {
+ droppedFrames: 3,
+ totalFrames: 300,
+ kbitsPerSec: 5000,
+ dataOutput: 3072,
+ });
+});
+
+test('Streaming stats keep enhanced broadcasting aggregate bitrate', t => {
+ const stats = calculateStreamingPerformanceStats([
+ {
+ droppedFrames: 0,
+ totalFrames: 300,
+ kbitsPerSec: 13500,
+ dataOutput: 1024,
+ },
+ ]);
+
+ t.is(stats.kbitsPerSec, 13500);
+});
+
+test('Streaming stats report regular streaming bitrate by display', t => {
+ const stats = calculateStreamingPerformanceStats(
+ [
+ {
+ display: 'horizontal',
+ droppedFrames: 1,
+ totalFrames: 100,
+ kbitsPerSec: 6000,
+ dataOutput: 1024,
+ },
+ {
+ display: 'vertical',
+ droppedFrames: 2,
+ totalFrames: 200,
+ kbitsPerSec: 4000,
+ dataOutput: 2048,
+ },
+ ],
+ { calculateByDisplay: true },
+ );
+
+ t.deepEqual(stats.byDisplay, {
+ horizontal: { kbitsPerSec: 6000, dataOutput: 1024 },
+ vertical: { kbitsPerSec: 4000, dataOutput: 2048 },
+ });
+});
+
+test('Streaming stats report enhanced broadcasting bitrate by display', t => {
+ const stats = calculateStreamingPerformanceStats(
+ [
+ {
+ droppedFrames: 0,
+ totalFrames: 300,
+ kbitsPerSec: 13500,
+ dataOutput: 4096,
+ displayStats: {
+ horizontal: { kbitsPerSec: 5000, dataOutput: 1500 },
+ vertical: { kbitsPerSec: 2500, dataOutput: 700 },
+ },
+ },
+ ],
+ { calculateByDisplay: true },
+ );
+
+ t.deepEqual(stats.byDisplay, {
+ horizontal: { kbitsPerSec: 5000, dataOutput: 1500 },
+ vertical: { kbitsPerSec: 2500, dataOutput: 700 },
+ });
+});