@vpalmisano/webrtcperf 4.4.10 → 4.4.12

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@vpalmisano/webrtcperf",
3
- "version": "4.4.10",
3
+ "version": "4.4.12",
4
4
  "repository": {
5
5
  "type": "git",
6
6
  "url": "https://github.com/vpalmisano/webrtcperf.git"
@@ -67,6 +67,7 @@
67
67
  "express-basic-auth": "^1.2.1",
68
68
  "fast-stats": "^0.0.7",
69
69
  "form-data": "^4.0.4",
70
+ "googleapis": "^160.0.0",
70
71
  "ipaddr.js": "^2.2.0",
71
72
  "json5": "^2.2.3",
72
73
  "lorem-ipsum": "^2.0.8",
@@ -129,7 +130,8 @@
129
130
  },
130
131
  "optionalDependencies": {
131
132
  "chart.js": "^4.5.0",
132
- "skia-canvas": "^2.0.2",
133
+ "chartjs-chart-error-bars": "^4.4.4",
134
+ "skia-canvas": "^3.0.7",
133
135
  "zeromq": "^6.5.0"
134
136
  }
135
137
  }
package/src/index.ts CHANGED
@@ -9,3 +9,4 @@ export * from './stats'
9
9
  export * from './utils'
10
10
  export * from './vmaf'
11
11
  export * from './scenarios'
12
+ export * from './plot'
package/src/plot.ts ADDED
@@ -0,0 +1,85 @@
1
+ import { logger } from './utils'
2
+
3
+ const log = logger('webrtcperf:plot')
4
+
5
+ export type PlotData = {
6
+ x: (string | number)[]
7
+ y: number[]
8
+ label: string
9
+ }
10
+
11
+ export type PlotOptions = {
12
+ type?: string
13
+ title?: string
14
+ filePath?: string
15
+ min?: number
16
+ max?: number
17
+ }
18
+
19
+ export async function plot(data: PlotData, options: PlotOptions) {
20
+ const {
21
+ CategoryScale,
22
+ Chart,
23
+ LinearScale,
24
+ LineController,
25
+ BarController,
26
+ LineElement,
27
+ BarElement,
28
+ PointElement,
29
+ Legend,
30
+ Title,
31
+ // eslint-disable-next-line @typescript-eslint/no-require-imports
32
+ } = require('chart.js')
33
+ // eslint-disable-next-line @typescript-eslint/no-require-imports
34
+ const ErrorBarsPlugin = require('chartjs-chart-error-bars')
35
+ Chart.register(
36
+ CategoryScale,
37
+ LineController,
38
+ LineElement,
39
+ BarController,
40
+ LinearScale,
41
+ BarElement,
42
+ PointElement,
43
+ Legend,
44
+ Title,
45
+ ErrorBarsPlugin,
46
+ )
47
+ // eslint-disable-next-line @typescript-eslint/no-require-imports
48
+ const { Canvas } = require('skia-canvas')
49
+
50
+ log.debug('plot')
51
+
52
+ const canvas = new Canvas(1280, 720)
53
+ const chart = new Chart(canvas, {
54
+ type: options.type || 'line',
55
+ data: {
56
+ labels: data.x,
57
+ datasets: [
58
+ {
59
+ label: data.label,
60
+ data: data.y,
61
+ fill: false,
62
+ borderColor: 'rgb(0, 0, 0)',
63
+ borderWidth: 1,
64
+ pointRadius: 0,
65
+ },
66
+ ],
67
+ },
68
+ options: {
69
+ plugins: {
70
+ title: {
71
+ display: !!options.title,
72
+ text: options.title || '',
73
+ },
74
+ },
75
+ scales: {
76
+ y: {
77
+ min: options.min,
78
+ max: options.max,
79
+ },
80
+ },
81
+ },
82
+ })
83
+ await canvas.toFile(options.filePath || 'plot.png', { format: 'png', matte: 'white' })
84
+ chart.destroy()
85
+ }
package/src/scenarios.ts CHANGED
@@ -3,8 +3,19 @@ import path from 'path'
3
3
  import { FastStats } from './stats'
4
4
  import { ThrottleConfig, ThrottleRule } from '@vpalmisano/throttler'
5
5
  import { Config } from './config'
6
+ import { Auth, google } from 'googleapis'
7
+ import { logger } from './utils'
8
+ import { sprintf } from 'sprintf-js'
6
9
 
10
+ const log = logger('webrtcperf:scenarios')
11
+
12
+ /**
13
+ * It parses a CSV stats file and returns an array of objects representing each row.
14
+ * @param filePath The path to the CSV stats file.
15
+ * @returns An array of objects where each object represents a row in the CSV file with keys as column headers.
16
+ */
7
17
  export async function parseStatsFile(filePath: string) {
18
+ log.debug(`parseStatsFile: ${filePath}`)
8
19
  const fileData = await fs.promises.readFile(filePath, 'utf-8')
9
20
  const lines = fileData.split('\n')
10
21
  const headers = lines[0].split(',')
@@ -22,6 +33,23 @@ export async function parseStatsFile(filePath: string) {
22
33
  return data
23
34
  }
24
35
 
36
+ export type StatsSummary = {
37
+ timestamp: number
38
+ id: string
39
+ scenario: string
40
+ videoRecvBitratePerPixel: FastStats
41
+ videoRecvFps: FastStats
42
+ videoSentFps: FastStats
43
+ }
44
+
45
+ /**
46
+ * It aggregates the stats summary from multiple test runs in a directory.
47
+ * @param options.dirPath Directory path containing test run subdirectories. Default is 'logs'.
48
+ * @param options.senderParticipantName Participant name of the sender. Default is 'Participant-000001'.
49
+ * @param options.receiverParticipantName Participant name of the receiver. Default is 'Participant-000000'.
50
+ * @param options.nameParser Function to parse test directory names. Default splits by '_' and extracts id and scenario.
51
+ * @returns Array of aggregated stats including timestamp, id, scenario, videoRecvBitratePerPixel, videoRecvFps, and videoSentFps.
52
+ */
25
53
  export async function aggregateStatsSummary({
26
54
  dirPath = 'logs',
27
55
  senderParticipantName = 'Participant-000001',
@@ -31,14 +59,8 @@ export async function aggregateStatsSummary({
31
59
  return { id, scenario }
32
60
  },
33
61
  }) {
34
- const stats = [] as {
35
- timestamp: number
36
- id: string
37
- scenario: string
38
- videoRecvBitratePerPixel: FastStats
39
- videoRecvFps: FastStats
40
- videoSentFps: FastStats
41
- }[]
62
+ log.debug(`aggregateStatsSummary: ${dirPath}`)
63
+ const stats: StatsSummary[] = []
42
64
  const results = await fs.promises.readdir(dirPath)
43
65
  for (const test of results) {
44
66
  const filePath = path.join(dirPath, test, 'detailed-stats-summary.csv')
@@ -76,11 +98,86 @@ export async function aggregateStatsSummary({
76
98
  return stats.sort((a, b) => a.timestamp - b.timestamp)
77
99
  }
78
100
 
101
+ /**
102
+ * It uploads the aggregated stats to a Google Sheet.
103
+ * A valid Google service account credentials file must be specified
104
+ * in the `GOOGLE_CREDENTIALS_PATH` environment variable.
105
+ * @param stats The aggregated stats to upload.
106
+ * @param spreadsheetId The ID of the Google Spreadsheet.
107
+ * @param table The name of the table (sheet) within the spreadsheet. Default is 'data'.
108
+ */
109
+ export async function uploadStatsToGoogleSheet(stats: StatsSummary[], spreadsheetId: string, table = 'data') {
110
+ log.debug(`uploadResultsToGoogleSheet spreadsheetId: ${spreadsheetId} table: ${table}`)
111
+ if (!process.env.GOOGLE_CREDENTIALS_PATH) throw new Error('GOOGLE_CREDENTIALS_PATH environment variable is not set')
112
+ if (!fs.existsSync(process.env.GOOGLE_CREDENTIALS_PATH))
113
+ throw new Error(`Google credentials file not found: ${process.env.GOOGLE_CREDENTIALS_PATH}`)
114
+ if (!stats.length) return
115
+ const auth = new Auth.GoogleAuth({
116
+ keyFile: process.env.GOOGLE_CREDENTIALS_PATH,
117
+ scopes: ['https://www.googleapis.com/auth/spreadsheets'],
118
+ })
119
+ const sheets = google.sheets({ version: 'v4', auth })
120
+ // Update headers.
121
+ const headers = ['datetime', 'id', 'scenario', 'videoRecvBitratePerPixel', 'videoRecvFps']
122
+ await sheets.spreadsheets.values.update({
123
+ spreadsheetId,
124
+ range: `${table}!A1:E1`,
125
+ valueInputOption: 'USER_ENTERED',
126
+ requestBody: { majorDimension: 'ROWS', values: [headers] },
127
+ })
128
+ // Append values.
129
+ const values = [] as string[][]
130
+ stats.forEach(s => {
131
+ const { timestamp, id, scenario, videoRecvBitratePerPixel, videoRecvFps } = s
132
+ if (!videoRecvBitratePerPixel.length) return
133
+ const datetime = new Date(timestamp).toLocaleString('en-US', {
134
+ timeZone: 'UTC',
135
+ hourCycle: 'h23',
136
+ })
137
+ values.push([
138
+ datetime,
139
+ id,
140
+ formatThrottleRule(parseThrottleRule(scenario), true),
141
+ videoRecvBitratePerPixel.percentile(95).toFixed(3),
142
+ videoRecvFps.percentile(95).toFixed(3),
143
+ ])
144
+ })
145
+ if (values.length) {
146
+ await sheets.spreadsheets.values.append({
147
+ spreadsheetId,
148
+ range: `${table}!A:E`,
149
+ valueInputOption: 'USER_ENTERED',
150
+ insertDataOption: 'INSERT_ROWS',
151
+ requestBody: { majorDimension: 'ROWS', values },
152
+ })
153
+ }
154
+ }
155
+
79
156
  export type ThrottleDirection = 'up' | 'down' | 'bidi'
80
157
 
81
- export function formatThrottleRule(throttleRule: ThrottleRule, direction: ThrottleDirection) {
82
- const { rate, loss, delay } = throttleRule
83
- return `${direction}-r${rate}-l${loss}-d${delay}`
158
+ export function formatBitrate(bitrate: number | undefined, prefix = ' ') {
159
+ if (bitrate === undefined) return ''
160
+ let suffix = 'Kbps'
161
+ if (bitrate >= 1000) {
162
+ bitrate /= 1000
163
+ suffix = 'Mbps'
164
+ }
165
+ return `${prefix}${sprintf('%5.4g', bitrate)}${suffix}`
166
+ }
167
+
168
+ export function formatLoss(loss: number | undefined, prefix = ' ') {
169
+ return loss !== undefined ? `${prefix}${loss.toFixed(0).padStart(2, ' ')}%` : ''
170
+ }
171
+
172
+ export function formatDelay(delay: number | undefined, prefix = ' ') {
173
+ return delay !== undefined ? `${prefix}${delay.toFixed(0).padStart(3, ' ')}ms` : ''
174
+ }
175
+
176
+ export function formatThrottleRule(throttleRule: ThrottleRule & { direction: ThrottleDirection }, human = false) {
177
+ const { rate, loss, delay, direction } = throttleRule
178
+ return human
179
+ ? `${direction.padEnd(4, ' ')}${formatBitrate(rate)}${formatLoss(loss)}${formatDelay(delay)}`
180
+ : `${direction}-r${rate}-l${loss}-d${delay}`
84
181
  }
85
182
 
86
183
  export function parseThrottleRule(throttleDesc: string) {
@@ -93,7 +190,25 @@ export function parseThrottleRule(throttleDesc: string) {
93
190
  return { direction, rate, loss, delay }
94
191
  }
95
192
 
96
- export async function simpleTestWithRateLossDelay(
193
+ /**
194
+ * It generates a test configuration with a scenario including 2 participants.
195
+ * The first participant sends video and the second receives it.
196
+ * Both participants send and receive audio.
197
+ * The network conditions are applied according to the specified direction to the sender (`up`),
198
+ * the receiver (`down`) or both (`bidi`).
199
+ * The test is repeated the specified number of times.
200
+ * The output is an array of partial configuration objects that can be used to run the tests
201
+ * with the main application, after merging it with a configuration that includes
202
+ * the destination url (mandatory) and other optional parameters.
203
+ * @param id The unique identifier for the test scenario.
204
+ * @param options.rate The target bandwidth in kbps.
205
+ * @param options.loss The packet loss percentage.
206
+ * @param options.delay The network delay in milliseconds.
207
+ * @param options.direction The direction of the network throttling: 'up', 'down', or 'bidi'.
208
+ * @param repeat The number of times to repeat the test scenario. Default is 1.
209
+ * @returns An array of partial configuration objects for each test scenario.
210
+ */
211
+ export async function twoParticipantsWithRateLossDelay(
97
212
  id: string,
98
213
  { rate, loss, delay, direction }: { rate: number; loss: number; delay: number; direction: ThrottleDirection },
99
214
  repeat: 1,
@@ -112,7 +227,7 @@ export async function simpleTestWithRateLossDelay(
112
227
  { rate, loss, delay, queue, at: 30 },
113
228
  ]
114
229
  }
115
- const throttleDesc = formatThrottleRule({ rate, loss, delay }, direction)
230
+ const throttleDesc = formatThrottleRule({ rate, loss, delay, direction })
116
231
  const now = Date.now()
117
232
  const ret: Partial<Config>[] = []
118
233
  for (let i = 0; i < repeat; i++) {