1# This Source Code Form is subject to the terms of the Mozilla Public
2# License, v. 2.0. If a copy of the MPL was not distributed with this
3# file, You can obtain one at https://mozilla.org/MPL/2.0/.
4#
5# This file incorporates work covered by the following copyright and
6# permission notice:
7#
8# Copyright (c) Jeremy Lainé.
9# All rights reserved.
10#
11# Redistribution and use in source and binary forms, with or without
12# modification, are permitted provided that the following conditions are met:
13#
14# * Redistributions of source code must retain the above copyright notice,
15# this list of conditions and the following disclaimer.
16# * Redistributions in binary form must reproduce the above copyright notice,
17# this list of conditions and the following disclaimer in the documentation
18# and/or other materials provided with the distribution.
19# * Neither the name of aiortc nor the names of its contributors may
20# be used to endorse or promote products derived from this software without
21# specific prior written permission.
22#
23# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND
24# ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED
25# WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
26# DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE
27# FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
28# DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR
29# SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER
30# CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY,
31# OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
32# OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
33
34import collections
35from typing import Optional
36
37from ... import audio_config
38from ..mediastreams import convert_timebase
39from .base import Encoder, EncodedPacket
40from .opus import TIME_BASE, OpusEncoder
41
42# RFC 2198 field limits: 14-bit timestamp offset, 10-bit block length.
43MAX_TIMESTAMP_OFFSET = 0x3FFF
44MAX_BLOCK_LENGTH = 0x3FF
45
46# Opus payload type nominally encapsulated by RED when the fmtp is missing.
47DEFAULT_BLOCK_PT = 96
48
49# Default number of prior frames carried as redundancy (RFC 2198 distance).
50DEFAULT_DISTANCE = 2
51MAX_DISTANCE = 4
52
53
54def red_block_payload_type(
55 parameters: Optional[dict], default: int = DEFAULT_BLOCK_PT
56) -> int:
57 """Extract the inner payload type from a red codec fmtp such as ``96/96``."""
58 for key in parameters or {}:
59 head = str(key).split("/", 1)[0].strip()
60 if head.isdigit():
61 return int(head)
62 return default
63
64
65def _build_red(
66 history: list[tuple[bytes, int]], primary: bytes, primary_ts: int, block_pt: int
67) -> bytes:
68 """Assemble one RFC 2198 payload: redundant blocks (oldest first) + primary.
69
70 Redundant blocks whose age or size overflow the 14/10-bit header fields are
71 skipped; an empty history yields a valid primary-only payload.
72 """
73 headers = bytearray()
74 bodies = bytearray()
75 for payload, ts in history:
76 offset = primary_ts - ts
77 if offset < 1 or offset > MAX_TIMESTAMP_OFFSET:
78 continue
79 length = len(payload)
80 if length > MAX_BLOCK_LENGTH:
81 continue
82 # F bit set marks a non-final (redundant) 4-byte block header.
83 headers.append(0x80 | (block_pt & 0x7F))
84 combined = (offset << 10) | length
85 headers.append((combined >> 16) & 0xFF)
86 headers.append((combined >> 8) & 0xFF)
87 headers.append(combined & 0xFF)
88 bodies += payload
89 # F bit clear marks the final (primary) 1-byte block header.
90 headers.append(block_pt & 0x7F)
91 bodies += primary
92 return bytes(headers + bodies)
93
94
95class RedOpusEncoder(Encoder):
96 """Wrap an :class:`OpusEncoder`, framing each payload as RFC 2198 RED.
97
98 The current payload is the primary block; up to ``distance`` recent payloads
99 ride along as redundancy so a single lost packet can be recovered by peers.
100 """
101
102 def __init__(self, block_pt: int = DEFAULT_BLOCK_PT, distance: Optional[int] = None):
103 self.inner = OpusEncoder()
104 self.block_pt = block_pt & 0x7F
105 if distance is None:
106 # The server publishes its resolved redundancy depth to audio_config
107 # (encoders are constructed per connection by the codec factory,
108 # which passes no distance).
109 configured = audio_config.red_distance
110 distance = DEFAULT_DISTANCE if configured is None else configured
111 self.distance = max(0, min(MAX_DISTANCE, distance))
112 self.history: "collections.deque[tuple[bytes, int]]" = collections.deque(
113 maxlen=self.distance
114 )
115
116 def pack(self, packet: EncodedPacket) -> tuple[list[bytes], int, bool]:
117 timestamp = convert_timebase(packet.pts, packet.time_base, TIME_BASE)
118 primary = bytes(packet.data)
119 red = _build_red(list(self.history), primary, timestamp, self.block_pt)
120 self.history.append((primary, timestamp))
121 return [red], timestamp, False