Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

modules: add RFC 2198 audio redundancy encoder #2067

Merged
merged 8 commits into from
Jul 20, 2022
Merged
Show file tree
Hide file tree
Changes from 7 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
128 changes: 128 additions & 0 deletions modules/red/red.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,128 @@
const MAX_TIMESTAMP = 0x100000000;

/**
* An encoder for RFC 2198 redundancy using WebRTC Insertable Streams.
*/
export class RFC2198Encoder {
/**
* @param {Number} targetRedundancy the desired amount of redundancy.
*/
constructor(targetRedundancy = 1) {
this.targetRedundancy = targetRedundancy;
this.frameBuffer = new Array(targetRedundancy);
this.payloadType = undefined;
}

/**
* Set the desired level of redudancy. 4 means "four redundant frames plus current frame.
* It is possible to reduce this to 0 to minimize the overhead to one byte.
* @param {Number} targetRedundancy the desired amount of redundancy.
*/
setRedundancy(targetRedundancy) {
const currentBuffer = this.frameBuffer;

if (targetRedundancy > this.targetRedundancy) {
this.frameBuffer = new Array(targetRedundancy);
for (let i = 0; i < currentBuffer.length; i++) {
this.frameBuffer[i + targetRedundancy - this.targetRedundancy] = currentBuffer[i];
}
} else if (targetRedundancy < this.targetRedundancy) {
this.frameBuffer = new Array(targetRedundancy);
for (let i = 0; i < this.frameBuffer.length; i++) {
this.frameBuffer[i] = currentBuffer[i + this.targetRedundancy - targetRedundancy];
}
}
this.targetRedundancy = targetRedundancy;
}

/**
* Set the "inner opus payload type". This is typically our RED payload type that we tell
* the other side as our opus payload type. Can be queried from the sender using getParameters()
* after setting the answer.
* @param {Number} payloadType the payload type to use for opus.
*/
setOpusPayloadType(payloadType) {
fippo marked this conversation as resolved.
Show resolved Hide resolved
this.payloadType = payloadType;
fippo marked this conversation as resolved.
Show resolved Hide resolved
}

/**
* This is the actual transform to add redundancy to a raw opus frame.
* @param {RTCEncodedAudioFrame} encodedFrame - Encoded audio frame.
* @param {TransformStreamDefaultController} controller - TransportStreamController.
*/
addRedundancy(encodedFrame, controller) {
// TODO: should this ensure encodedFrame.type being not set and
// encodedFrame.getMetadata().payloadType being the same as before?
/*
* From https://datatracker.ietf.org/doc/html/rfc2198#section-3:
0 1 2 3
0 1 2 3 4 5 6 7 8 9 0 1 2 3 4 5 6 7 8 9 0 1 2 3 4 5 6 7 8 9 0 1
+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
|F| block PT | timestamp offset | block length |
+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
0 1 2 3 4 5 6 7
+-+-+-+-+-+-+-+-+
|0| Block PT |
+-+-+-+-+-+-+-+-+
*/
const data = new Uint8Array(encodedFrame.data);

const newFrame = data.slice(0);

newFrame.timestamp = encodedFrame.timestamp;

let allFrames = this.frameBuffer.filter(x => Boolean(x)).concat(newFrame);

// TODO: determine how much we can fit into the available size (which we need to assume as 1190 bytes or so)
let needLength = 1 + newFrame.length;

for (let i = allFrames.length - 2; i >= 0; i--) {
const frame = allFrames[i];


// TODO: timestamp wraparound?
if ((allFrames[i + 1].timestamp - frame.timestamp + MAX_TIMESTAMP) % MAX_TIMESTAMP >= 16384) {
allFrames = allFrames.slice(i + 1);
break;
}
needLength += 4 + frame.length;
}

const newData = new Uint8Array(needLength);
const newView = new DataView(newData.buffer);

// Construct the header.
let frameOffset = 0;

for (let i = 0; i < allFrames.length - 1; i++) {
const frame = allFrames[i];

// Ensure correct behaviour on wraparound.
const tOffset = (encodedFrame.timestamp - frame.timestamp + MAX_TIMESTAMP) % MAX_TIMESTAMP;

newView.setUint8(frameOffset, this.payloadType | 0x80); // eslint-disable-line no-bitwise
nils-ohlmeier marked this conversation as resolved.
Show resolved Hide resolved
// eslint-disable-next-line no-bitwise
newView.setUint16(frameOffset + 1, (tOffset << 2) ^ (frame.byteLength >> 8));
newView.setUint8(frameOffset + 3, frame.byteLength & 0xff); // eslint-disable-line no-bitwise
frameOffset += 4;
}

// Last block header.
newView.setUint8(frameOffset++, this.payloadType);

// Construct the frame.
for (let i = 0; i < allFrames.length; i++) {
const frame = allFrames[i];

newData.set(frame, frameOffset);
frameOffset += frame.byteLength;
}
encodedFrame.data = newData.buffer;

this.frameBuffer.push(newFrame);
this.frameBuffer.shift();
fippo marked this conversation as resolved.
Show resolved Hide resolved

controller.enqueue(encodedFrame);
}
}

205 changes: 205 additions & 0 deletions modules/red/red.spec.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,205 @@
import { RFC2198Encoder } from './red.js';

describe('RED', () => {
let encoder;
const opusPayloadType = 111;

beforeEach(() => {
encoder = new RFC2198Encoder();
encoder.setOpusPayloadType(opusPayloadType);
});

describe('addRedundancy with a redundancy of 1', () => {
beforeEach(() => {
encoder.setRedundancy(1);
});
it('adds redundancy on the first packet', () => {
const spy = jasmine.createSpy();

encoder.addRedundancy({
data: new Uint8Array([ 0x00 ]),
nils-ohlmeier marked this conversation as resolved.
Show resolved Hide resolved
timestamp: 0
}, { enqueue: spy });
expect(spy.calls.count()).toEqual(1);
expect(spy.calls.argsFor(0)[0].data).toEqual(new Uint8Array([ 0x6f, 0x00 ]).buffer);
});

it('adds redundancy on the first and second packet', () => {
const spy = jasmine.createSpy();

encoder.addRedundancy({
data: new Uint8Array([ 0xde ]),
timestamp: 0
}, { enqueue: spy });
encoder.addRedundancy({
data: new Uint8Array([ 0xad, 0xbe ]),
timestamp: 960
}, { enqueue: spy });

expect(spy.calls.count()).toEqual(2);
expect(spy.calls.argsFor(0)[0].data).toEqual(new Uint8Array([ 0x6f, 0xde ]).buffer);
expect(spy.calls.argsFor(1)[0].data).toEqual(new Uint8Array([
0xef, 0x0f, 0x00, 0x01, 0x6f, 0xde, 0xad, 0xbe ]).buffer);
});

it('does not add redundancy for the first packet on the third packet', () => {
const spy = jasmine.createSpy();

encoder.addRedundancy({
data: new Uint8Array([ 0xde ]),
timestamp: 0
}, { enqueue: spy });
encoder.addRedundancy({
data: new Uint8Array([ 0xad, 0xbe ]),
timestamp: 960
}, { enqueue: spy });
encoder.addRedundancy({
data: new Uint8Array([ 0xef, 0xff, 0xff ]),
timestamp: 1920
}, { enqueue: spy });

expect(spy.calls.count()).toEqual(3);
expect(spy.calls.argsFor(0)[0].data).toEqual(new Uint8Array([ 0x6f, 0xde ]).buffer);
expect(spy.calls.argsFor(1)[0].data).toEqual(new Uint8Array([
0xef, 0x0f, 0x00, 0x01, 0x6f, 0xde, 0xad, 0xbe ]).buffer);
expect(spy.calls.argsFor(2)[0].data).toEqual(new Uint8Array([
0xef, 0x0f, 0x00, 0x02, 0x6f, 0xad, 0xbe, 0xef, 0xff, 0xff ]).buffer);
});

it('does not add redundancy for DTX packets with a 400ms timestamp gap', () => {
const spy = jasmine.createSpy();

encoder.addRedundancy({
data: new Uint8Array([ 0xde ]),
timestamp: 0
}, { enqueue: spy });
encoder.addRedundancy({
data: new Uint8Array([ 0xad, 0xbe ]),
timestamp: 19200
}, { enqueue: spy });
encoder.addRedundancy({
data: new Uint8Array([ 0xef, 0xff, 0xff ]),
timestamp: 20160
}, { enqueue: spy });
expect(spy.calls.count()).toEqual(3);
expect(spy.calls.argsFor(0)[0].data).toEqual(new Uint8Array([ 0x6f, 0xde ]).buffer);
expect(spy.calls.argsFor(1)[0].data).toEqual(new Uint8Array([ 0x6f, 0xad, 0xbe ]).buffer);
expect(spy.calls.argsFor(2)[0].data).toEqual(new Uint8Array([
0xef, 0x0f, 0x00, 0x02, 0x6f, 0xad, 0xbe, 0xef, 0xff, 0xff ]).buffer);
});
});

describe('addRedundancy with a redundancy of 2', () => {
beforeEach(() => {
encoder.setRedundancy(2);
});
it('adds redundancy on the first, second and third packet', () => {
const spy = jasmine.createSpy();

encoder.addRedundancy({
data: new Uint8Array([ 0xde ]),
timestamp: 0
}, { enqueue: spy });
encoder.addRedundancy({
data: new Uint8Array([ 0xad, 0xbe ]),
timestamp: 960
}, { enqueue: spy });
encoder.addRedundancy({
data: new Uint8Array([ 0xef, 0xff, 0xff ]),
timestamp: 1920
}, { enqueue: spy });

expect(spy.calls.count()).toEqual(3);
expect(spy.calls.argsFor(0)[0].data).toEqual(new Uint8Array([ 0x6f, 0xde ]).buffer);
expect(spy.calls.argsFor(1)[0].data).toEqual(new Uint8Array([
0xef, 0x0f, 0x00, 0x01, 0x6f, 0xde, 0xad, 0xbe ]).buffer);
expect(spy.calls.argsFor(2)[0].data).toEqual(new Uint8Array([
0xef, 0x1e, 0x00, 0x01, 0xef, 0x0f, 0x00, 0x02, 0x6f, 0xde, 0xad, 0xbe, 0xef, 0xff, 0xff ]).buffer);
});

it('does not add redundancy for the first packet on the fourth packet', () => {
const spy = jasmine.createSpy();

encoder.addRedundancy({
data: new Uint8Array([ 0xde ]),
timestamp: 0
}, { enqueue: spy });
encoder.addRedundancy({
data: new Uint8Array([ 0xad, 0xbe ]),
timestamp: 960
}, { enqueue: spy });
encoder.addRedundancy({
data: new Uint8Array([ 0xef, 0xff, 0xff ]),
timestamp: 1920
}, { enqueue: spy });
encoder.addRedundancy({
data: new Uint8Array([ 0xfa, 0x1f, 0xfa, 0x1f ]),
timestamp: 2880
}, { enqueue: spy });

expect(spy.calls.count()).toEqual(4);
expect(spy.calls.argsFor(0)[0].data).toEqual(new Uint8Array([ 0x6f, 0xde ]).buffer);
expect(spy.calls.argsFor(1)[0].data).toEqual(new Uint8Array([
0xef, 0x0f, 0x00, 0x01, 0x6f, 0xde, 0xad, 0xbe ]).buffer);
expect(spy.calls.argsFor(2)[0].data).toEqual(new Uint8Array([
0xef, 0x1e, 0x00, 0x01, 0xef, 0x0f, 0x00, 0x02, 0x6f, 0xde, 0xad, 0xbe, 0xef, 0xff, 0xff ]).buffer);
expect(spy.calls.argsFor(3)[0].data).toEqual(new Uint8Array([
0xef, 0x1e, 0x00, 0x02, 0xef, 0x0f, 0x00, 0x03, 0x6f,
0xad, 0xbe, 0xef, 0xff, 0xff, 0xfa, 0x1f, 0xfa, 0x1f ]).buffer);
});
});

describe('setRedundancy', () => {
it('reduces the redundancy', () => {
const spy = jasmine.createSpy();

encoder.setRedundancy(2);
encoder.addRedundancy({
data: new Uint8Array([ 0xde ]),
timestamp: 0
}, { enqueue: spy });
encoder.addRedundancy({
data: new Uint8Array([ 0xad, 0xbe ]),
timestamp: 960
}, { enqueue: spy });
encoder.setRedundancy(1);
encoder.addRedundancy({
data: new Uint8Array([ 0xef, 0xff, 0xff ]),
timestamp: 1920
}, { enqueue: spy });

expect(spy.calls.count()).toEqual(3);
expect(spy.calls.argsFor(0)[0].data).toEqual(new Uint8Array([ 0x6f, 0xde ]).buffer);
expect(spy.calls.argsFor(1)[0].data).toEqual(new Uint8Array([
0xef, 0x0f, 0x00, 0x01, 0x6f, 0xde, 0xad, 0xbe ]).buffer);
expect(spy.calls.argsFor(2)[0].data).toEqual(new Uint8Array([
0xef, 0x0f, 0x00, 0x02, 0x6f, 0xad, 0xbe, 0xef, 0xff, 0xff ]).buffer);
});

it('increases the redundancy', () => {
const spy = jasmine.createSpy();

encoder.addRedundancy({
data: new Uint8Array([ 0xde ]),
timestamp: 0
}, { enqueue: spy });
encoder.setRedundancy(2);
encoder.addRedundancy({
data: new Uint8Array([ 0xad, 0xbe ]),
timestamp: 960
}, { enqueue: spy });
encoder.addRedundancy({
data: new Uint8Array([ 0xef, 0xff, 0xff ]),
timestamp: 1920
}, { enqueue: spy });

expect(spy.calls.count()).toEqual(3);
expect(spy.calls.argsFor(0)[0].data).toEqual(new Uint8Array([ 0x6f, 0xde ]).buffer);
expect(spy.calls.argsFor(1)[0].data).toEqual(new Uint8Array([
0xef, 0x0f, 0x00, 0x01, 0x6f, 0xde, 0xad, 0xbe ]).buffer);
expect(spy.calls.argsFor(2)[0].data).toEqual(new Uint8Array([
0xef, 0x1e, 0x00, 0x01, 0xef, 0x0f, 0x00, 0x02,
0x6f, 0xde, 0xad, 0xbe, 0xef, 0xff, 0xff ]).buffer);
});
});
});