1# Copyright 2015 The Chromium Authors. All rights reserved.
2# Use of this source code is governed by a BSD-style license that can be
3# found in the LICENSE file.
4
5import logging
6
7from telemetry.util import statistics
8
9DISPLAY_HERTZ = 60.0
10VSYNC_DURATION = 1e6 / DISPLAY_HERTZ
11# When to consider a frame frozen (in VSYNC units): meaning 1 initial
12# frame + 5 repeats of that frame.
13FROZEN_THRESHOLD = 6
14# Severity factor.
15SEVERITY = 3
16
17IDEAL_RENDER_INSTANT = 'Ideal Render Instant'
18ACTUAL_RENDER_BEGIN = 'Actual Render Begin'
19ACTUAL_RENDER_END = 'Actual Render End'
20SERIAL = 'Serial'
21
22
23class TimeStats(object):
24  """Stats container for webrtc rendering metrics."""
25
26  def __init__(self, drift_time=None, mean_drift_time=None,
27    std_dev_drift_time=None, percent_badly_out_of_sync=None,
28    percent_out_of_sync=None, smoothness_score=None, freezing_score=None,
29    rendering_length_error=None, fps=None, frame_distribution=None):
30    self.drift_time = drift_time
31    self.mean_drift_time = mean_drift_time
32    self.std_dev_drift_time = std_dev_drift_time
33    self.percent_badly_out_of_sync = percent_badly_out_of_sync
34    self.percent_out_of_sync = percent_out_of_sync
35    self.smoothness_score = smoothness_score
36    self.freezing_score = freezing_score
37    self.rendering_length_error = rendering_length_error
38    self.fps = fps
39    self.frame_distribution = frame_distribution
40    self.invalid_data = False
41
42
43
44class WebMediaPlayerMsRenderingStats(object):
45  """Analyzes events of WebMediaPlayerMs type."""
46
47  def __init__(self, events):
48    """Save relevant events according to their stream."""
49    self.stream_to_events = self._MapEventsToStream(events)
50
51  def _IsEventValid(self, event):
52    """Check that the needed arguments are present in event.
53
54    Args:
55      event: event to check.
56
57    Returns:
58      True is event is valid, false otherwise."""
59    if not event.args:
60      return False
61    mandatory = [ACTUAL_RENDER_BEGIN, ACTUAL_RENDER_END,
62        IDEAL_RENDER_INSTANT, SERIAL]
63    for parameter in mandatory:
64      if not parameter in event.args:
65        return False
66    return True
67
68  def _MapEventsToStream(self, events):
69    """Build a dictionary of events indexed by stream.
70
71    The events of interest have a 'Serial' argument which represents the
72    stream ID. The 'Serial' argument identifies the local or remote nature of
73    the stream with a least significant bit  of 0 or 1 as well as the hash
74    value of the video track's URL. So stream::=hash(0|1} . The method will
75    then list the events of the same stream in a frame_distribution on stream
76    id. Practically speaking remote streams have an odd stream id and local
77    streams have a even stream id.
78    Args:
79      events: Telemetry WebMediaPlayerMs events.
80
81    Returns:
82      A dict of stream IDs mapped to events on that stream.
83    """
84    stream_to_events = {}
85    for event in events:
86      if not self._IsEventValid(event):
87        # This is not a render event, skip it.
88        continue
89      stream = event.args[SERIAL]
90      events_for_stream = stream_to_events.setdefault(stream, [])
91      events_for_stream.append(event)
92
93    return stream_to_events
94
95  def _GetCadence(self, relevant_events):
96    """Calculate the apparent cadence of the rendering.
97
98    In this paragraph I will be using regex notation. What is intended by the
99    word cadence is a sort of extended instantaneous 'Cadence' (thus not
100    necessarily periodic). Just as an example, a normal 'Cadence' could be
101    something like [2 3] which means possibly an observed frame persistence
102    progression of [{2 3}+] for an ideal 20FPS video source. So what we are
103    calculating here is the list of frame persistence, kind of a
104    'Proto-Cadence', but cadence is shorter so we abuse the word.
105
106    Args:
107      relevant_events: list of Telemetry events.
108
109    Returns:
110      a list of frame persistence values.
111    """
112    cadence = []
113    frame_persistence = 0
114    old_ideal_render = 0
115    for event in relevant_events:
116      if not self._IsEventValid(event):
117        # This event is not a render event so skip it.
118        continue
119      if event.args[IDEAL_RENDER_INSTANT] == old_ideal_render:
120        frame_persistence += 1
121      else:
122        cadence.append(frame_persistence)
123        frame_persistence = 1
124        old_ideal_render = event.args[IDEAL_RENDER_INSTANT]
125    cadence.append(frame_persistence)
126    cadence.pop(0)
127    return cadence
128
129  def _GetSourceToOutputDistribution(self, cadence):
130    """Create distribution for the cadence frame display values.
131
132    If the overall display distribution is A1:A2:..:An, this will tell us how
133    many times a frame stays displayed during Ak*VSYNC_DURATION, also known as
134    'source to output' distribution. Or in other terms:
135    a distribution B::= let C be the cadence, B[k]=p with k in Unique(C)
136    and p=Card(k in C).
137
138    Args:
139      cadence: list of frame persistence values.
140
141    Returns:
142      a dictionary containing the distribution
143    """
144    frame_distribution = {}
145    for ticks in cadence:
146      ticks_so_far = frame_distribution.setdefault(ticks, 0)
147      frame_distribution[ticks] = ticks_so_far + 1
148    return frame_distribution
149
150  def _GetFpsFromCadence(self, frame_distribution):
151    """Calculate the apparent FPS from frame distribution.
152
153    Knowing the display frequency and the frame distribution, it is possible to
154    calculate the video apparent frame rate as played by WebMediaPlayerMs
155    module.
156
157    Args:
158      frame_distribution: the source to output distribution.
159
160    Returns:
161      the video apparent frame rate.
162    """
163    number_frames = sum(frame_distribution.values())
164    number_vsyncs = sum([ticks * frame_distribution[ticks]
165       for ticks in frame_distribution])
166    mean_ratio = float(number_vsyncs) / number_frames
167    return DISPLAY_HERTZ / mean_ratio
168
169  def _GetFrozenFramesReports(self, frame_distribution):
170    """Find evidence of frozen frames in distribution.
171
172    For simplicity we count as freezing the frames that appear at least five
173    times in a row counted from 'Ideal Render Instant' perspective. So let's
174    say for 1 source frame, we rendered 6 frames, then we consider 5 of these
175    rendered frames as frozen. But we mitigate this by saying anything under
176    5 frozen frames will not be counted as frozen.
177
178    Args:
179      frame_distribution: the source to output distribution.
180
181    Returns:
182      a list of dicts whose keys are ('frozen_frames', 'occurrences').
183    """
184    frozen_frames = []
185    frozen_frame_vsyncs = [ticks for ticks in frame_distribution if ticks >=
186        FROZEN_THRESHOLD]
187    for frozen_frames_vsync in frozen_frame_vsyncs:
188      logging.debug('%s frames not updated after %s vsyncs',
189          frame_distribution[frozen_frames_vsync], frozen_frames_vsync)
190      frozen_frames.append(
191          {'frozen_frames': frozen_frames_vsync - 1,
192           'occurrences': frame_distribution[frozen_frames_vsync]})
193    return frozen_frames
194
195  def _FrozenPenaltyWeight(self, number_frozen_frames):
196    """Returns the weighted penalty for a number of frozen frames.
197
198    As mentioned earlier, we count for frozen anything above 6 vsync display
199    duration for the same 'Initial Render Instant', which is five frozen
200    frames.
201
202    Args:
203      number_frozen_frames: number of frozen frames.
204
205    Returns:
206      the penalty weight (int) for that number of frozen frames.
207    """
208
209    penalty = {
210      0: 0,
211      1: 0,
212      2: 0,
213      3: 0,
214      4: 0,
215      5: 1,
216      6: 5,
217      7: 15,
218      8: 25
219    }
220    weight = penalty.get(number_frozen_frames, 8 * (number_frozen_frames - 4))
221    return weight
222
223  def _IsRemoteStream(self, stream):
224    """Check if stream is remote."""
225    return stream % 2
226
227  def _GetDrifTimeStats(self, relevant_events, cadence):
228    """Get the drift time statistics.
229
230    This method will calculate drift_time stats, that is to say :
231    drift_time::= list(actual render begin - ideal render).
232    rendering_length error::= the rendering length error.
233
234    Args:
235      relevant_events: events to get drift times stats from.
236      cadence: list of frame persistence values.
237
238    Returns:
239      a tuple of (drift_time, rendering_length_error).
240    """
241    drift_time = []
242    old_ideal_render = 0
243    discrepancy = []
244    index = 0
245    for event in relevant_events:
246      current_ideal_render = event.args[IDEAL_RENDER_INSTANT]
247      if current_ideal_render == old_ideal_render:
248        # Skip to next event because we're looking for a source frame.
249        continue
250      actual_render_begin = event.args[ACTUAL_RENDER_BEGIN]
251      drift_time.append(actual_render_begin - current_ideal_render)
252      discrepancy.append(abs(current_ideal_render - old_ideal_render
253          - VSYNC_DURATION * cadence[index]))
254      old_ideal_render = current_ideal_render
255      index += 1
256    discrepancy.pop(0)
257    last_ideal_render = relevant_events[-1].args[IDEAL_RENDER_INSTANT]
258    first_ideal_render = relevant_events[0].args[IDEAL_RENDER_INSTANT]
259    rendering_length_error = 100.0 * (sum([x for x in discrepancy]) /
260        (last_ideal_render - first_ideal_render))
261
262    return drift_time, rendering_length_error
263
264  def _GetSmoothnessStats(self, norm_drift_time):
265    """Get the smoothness stats from the normalized drift time.
266
267    This method will calculate the smoothness score, along with the percentage
268    of frames badly out of sync and the percentage of frames out of sync. To be
269    considered badly out of sync, a frame has to have missed rendering by at
270    least 2*VSYNC_DURATION. To be considered out of sync, a frame has to have
271    missed rendering by at least one VSYNC_DURATION.
272    The smoothness score is a measure of how out of sync the frames are.
273
274    Args:
275      norm_drift_time: normalized drift time.
276
277    Returns:
278      a tuple of (percent_badly_oos, percent_out_of_sync, smoothness_score)
279    """
280    # How many times is a frame later/earlier than T=2*VSYNC_DURATION. Time is
281    # in microseconds.
282    frames_severely_out_of_sync = len(
283        [x for x in norm_drift_time if abs(x) > 2 * VSYNC_DURATION])
284    percent_badly_oos = (
285        100.0 * frames_severely_out_of_sync / len(norm_drift_time))
286
287    # How many times is a frame later/earlier than VSYNC_DURATION.
288    frames_out_of_sync = len(
289        [x for x in norm_drift_time if abs(x) > VSYNC_DURATION])
290    percent_out_of_sync = (
291        100.0 * frames_out_of_sync / len(norm_drift_time))
292
293    frames_oos_only_once = frames_out_of_sync - frames_severely_out_of_sync
294
295    # Calculate smoothness metric. From the formula, we can see that smoothness
296    # score can be negative.
297    smoothness_score = 100.0 - 100.0 * (frames_oos_only_once +
298        SEVERITY * frames_severely_out_of_sync) / len(norm_drift_time)
299
300    # Minimum smoothness_score value allowed is zero.
301    if smoothness_score < 0:
302      smoothness_score = 0
303
304    return (percent_badly_oos, percent_out_of_sync, smoothness_score)
305
306  def _GetFreezingScore(self, frame_distribution):
307    """Get the freezing score."""
308
309    # The freezing score is based on the source to output distribution.
310    number_vsyncs = sum([n * frame_distribution[n]
311        for n in frame_distribution])
312    frozen_frames = self._GetFrozenFramesReports(frame_distribution)
313
314    # Calculate freezing metric.
315    # Freezing metric can be negative if things are really bad. In that case we
316    # change it to zero as minimum valud.
317    freezing_score = 100.0
318    for frozen_report in frozen_frames:
319      weight = self._FrozenPenaltyWeight(frozen_report['frozen_frames'])
320      freezing_score -= (
321          100.0 * frozen_report['occurrences'] / number_vsyncs * weight)
322    if freezing_score < 0:
323      freezing_score = 0
324
325    return freezing_score
326
327  def GetTimeStats(self):
328    """Calculate time stamp stats for all remote stream events."""
329    stats = {}
330    for stream, relevant_events in self.stream_to_events.iteritems():
331      if len(relevant_events) == 1:
332        logging.debug('Found a stream=%s with just one event', stream)
333        continue
334      if not self._IsRemoteStream(stream):
335        logging.info('Skipping processing of local stream: %s', stream)
336        continue
337
338      cadence = self._GetCadence(relevant_events)
339      if not cadence:
340        stats = TimeStats()
341        stats.invalid_data = True
342        return stats
343
344      frame_distribution = self._GetSourceToOutputDistribution(cadence)
345      fps = self._GetFpsFromCadence(frame_distribution)
346
347      drift_time_stats = self._GetDrifTimeStats(relevant_events, cadence)
348      (drift_time, rendering_length_error) = drift_time_stats
349
350      # Drift time normalization.
351      mean_drift_time = statistics.ArithmeticMean(drift_time)
352      norm_drift_time = [abs(x - mean_drift_time) for x in drift_time]
353
354      smoothness_stats = self._GetSmoothnessStats(norm_drift_time)
355      (percent_badly_oos, percent_out_of_sync,
356          smoothness_score) = smoothness_stats
357
358      freezing_score = self._GetFreezingScore(frame_distribution)
359
360      stats = TimeStats(drift_time=drift_time,
361          percent_badly_out_of_sync=percent_badly_oos,
362          percent_out_of_sync=percent_out_of_sync,
363          smoothness_score=smoothness_score, freezing_score=freezing_score,
364          rendering_length_error=rendering_length_error, fps=fps,
365          frame_distribution=frame_distribution)
366    return stats
367