mirror of
https://github.com/fluxerapp/fluxer
synced 2026-10-07 19:22:14 +09:00
fix(voice): rank h264 baseline first and gate opus stereo (#2596)
This commit is contained in:
@@ -7,114 +7,65 @@
|
||||
|
||||
## Fluxer modifications
|
||||
|
||||
Changes applied on top of the upstream v2.17.2 source. Previously maintained as
|
||||
a pnpm patch at `patches/[email protected]`; now maintained as regular
|
||||
source edits in this package.
|
||||
Changes applied on top of the upstream v2.17.2 source. These were previously a pnpm patch at `patches/[email protected]` and are now plain source edits in this package.
|
||||
|
||||
1. **AV1 E2EE support** (`src/e2ee/worker/av1Crypto.ts`, `FrameCryptor.ts`, `e2ee.worker.ts`)
|
||||
OBU-level AV1 encryption and decryption for end-to-end encrypted voice/video.
|
||||
|
||||
OBU-level AV1 encryption and decryption for end-to-end encrypted voice and video.
|
||||
|
||||
2. **UpdateTrackContext message** (`src/e2ee/types.ts`, worker dispatch)
|
||||
Replaced `updateCodec` with richer `updateTrackContext` carrying participant
|
||||
identity and track ID, preventing codec mismatch on track reuse.
|
||||
|
||||
`updateCodec` replaced with `updateTrackContext`, which carries participant identity and track id so a reused track cannot pick up the wrong codec.
|
||||
|
||||
3. **E2EEManager state tracking** (`src/e2ee/E2eeManager.ts`)
|
||||
Added `getE2EETransformState()` / `setE2EETransformState()` for proper
|
||||
transform lifecycle management.
|
||||
|
||||
4. **Screenshare scalability mode** (`src/room/participant/LocalParticipant.ts`)
|
||||
Preserve caller-supplied `scalabilityMode` for screen shares instead of forcing
|
||||
`L3T3_KEY`, so VP9/AV1 screen shares can use the browser's compatible default
|
||||
unless Fluxer explicitly requests an SVC layer layout.
|
||||
`getE2EETransformState()` and `setE2EETransformState()` for transform lifecycle management.
|
||||
|
||||
4. **Screen share scalability mode** (`src/room/participant/LocalParticipant.ts`)
|
||||
|
||||
A caller-supplied `scalabilityMode` is preserved for screen shares instead of being forced to `L3T3_KEY`, so VP9 and AV1 screen shares use the browser default unless Fluxer asks for a specific SVC layout.
|
||||
|
||||
5. **E2EE frame layout guards** (`src/e2ee/worker/FrameCryptor.ts`)
|
||||
Validate encrypted frame trailer, IV, tag, and clear-prefix bounds before
|
||||
constructing typed-array views, and drop malformed encrypted frames without
|
||||
tearing down the transform stream.
|
||||
|
||||
Encrypted frame trailer, IV, tag and clear-prefix bounds are validated before any typed-array view is constructed, and malformed frames are dropped without tearing down the transform stream.
|
||||
|
||||
6. **Encrypted backup codec publishing** (`src/room/participant/LocalParticipant.ts`, `src/e2ee/E2eeManager.ts`)
|
||||
Allows backup codec tracks to be advertised and published while E2EE is
|
||||
enabled, and attaches sender transforms to backup codec senders using their
|
||||
cloned media track ID and codec.
|
||||
|
||||
Backup codec tracks can be advertised and published while E2EE is on, with sender transforms attached using the cloned media track id and codec.
|
||||
|
||||
7. **Publisher codec preferences** (`src/room/RTCEngine.ts`)
|
||||
Applies `RTCRtpTransceiver.setCodecPreferences()` to publisher transceivers
|
||||
so browser SDP follows the selected primary or backup codec, and prefers
|
||||
H.264 profiles that use Chromium's external/hardware encoder before the
|
||||
OpenH264 software profile.
|
||||
|
||||
`setCodecPreferences()` is applied to publisher transceivers so the browser's SDP follows the selected primary or backup codec. H.264 profiles rank Baseline `42001f` first, then Constrained Baseline `42e01f`, then everything else. Main, High and Constrained High rank last on purpose.
|
||||
|
||||
livekit-server registers H.264 High `640032` on the publisher peer connection but filters it off the subscriber peer connection, and its `CodecParametersFuzzySearch` falls back to a mime-only match. A High publication therefore reaches subscribers under their `42e01f` payload type and decodes to nothing on a Constrained-Baseline-only decoder such as Firefox's OpenH264 GMP. `42001f` is the one profile Chromium's accelerated encoder factory advertises that such a decoder can still handle, because Chromium's VAAPI encoder and OpenH264 both write a Constrained Baseline SPS for `H264PROFILE_BASELINE`.
|
||||
|
||||
The trade is that livekit-server does not register `42001f` either, so Windows and macOS negotiate `42e01f`, which Chromium's accelerated encoder factory does not advertise there (`kPlatformH264CbpEncoding` is off by default on Windows, and `IsH264ConstrainedBaselineProfileAvailableForAcceleratedEncoder` returns false on Apple). Those publishers fall back to software H.264, which is what an unpatched browser does anyway. Linux, ChromeOS and Android keep hardware encoding.
|
||||
|
||||
8. **Media publishing defaults** (`src/room/defaults.ts`, `src/room/utils.ts`, `src/room/track/options.ts`)
|
||||
Falls back to H.264, then VP9, VP8, AV1, and HEVC/H.265 according to actual
|
||||
sender capabilities, pairs advanced codecs with H.264 backup simulcast, and
|
||||
uses maintain-resolution screen-share defaults with a 4K60-ready bitrate cap.
|
||||
The order puts AV1 and HEVC last because both are opt-in in Fluxer, so a
|
||||
fallback inside `publishTrack` must not land on a codec the user did not
|
||||
enable. Fluxer picks the codec itself before publishing, so this list only
|
||||
applies when the client overrides the request, such as the reconnect
|
||||
republish that runs outside Fluxer's own flows.
|
||||
|
||||
Codec fallback follows actual sender capabilities in the order H.264, VP9, VP8, AV1, HEVC. Advanced codecs are paired with an H.264 backup simulcast, and screen shares default to maintain-resolution with a 4K60-ready bitrate cap. AV1 and HEVC come last because both are opt-in in Fluxer, so a fallback inside `publishTrack` must not land on a codec the user did not enable. Fluxer picks the codec itself before publishing, so this order only applies when the client overrides the request, such as the reconnect republish that runs outside Fluxer's own flows.
|
||||
|
||||
9. **High-fidelity Opus SDP munging** (`src/room/PCTransport.ts`)
|
||||
Forces Opus RED/FEC, stereo signaling, 10 ms packet time, no DTX, and a
|
||||
510 kbps maximum average bitrate in local offers and remote answers.
|
||||
|
||||
Local offers and remote answers are munged to force Opus RED and FEC, 10 ms packet time, no DTX, and a 510 kbps maximum average bitrate. Stereo signalling stays opt-in. `stereo=1` and `sprop-stereo=1` are added only for publications whose `TrackBitrateInfo.stereo` is set (studio mode above the stereo bitrate threshold, and screen-share audio) and for the subscriber mids the server advertised as stereo, so a mono microphone is not encoded and decoded as a two-channel stream.
|
||||
|
||||
10. **Remote audio volume restore at exactly zero** (`src/room/track/RemoteAudioTrack.ts`)
|
||||
`attach()`, `connectWebAudio()` and `getVolume()` guarded the remembered
|
||||
`elementVolume` with a truthiness check, so a track deliberately held at `0`
|
||||
came back at full volume whenever it was re-attached or its Web Audio graph
|
||||
was rebuilt. All three guards now test `!== undefined`. Note that remote
|
||||
gains above `1.0` are only legal because `setVolume()` takes the Web Audio
|
||||
`gainNode` branch; the `el.volume` branch would throw `IndexSizeError`.
|
||||
`webAudioMix` must stay unconditional.
|
||||
|
||||
`attach()`, `connectWebAudio()` and `getVolume()` guarded the remembered `elementVolume` with a truthiness check, so a track deliberately held at `0` came back at full volume whenever it was re-attached or its Web Audio graph was rebuilt. All three now test `!== undefined`. Remote gains above `1.0` are only legal because `setVolume()` takes the Web Audio `gainNode` branch, as the `el.volume` branch would throw `IndexSizeError`, so `webAudioMix` must stay unconditional.
|
||||
|
||||
11. **Processor teardown before source stop** (`src/room/track/LocalTrack.ts`)
|
||||
`stop()` called `super.stop()` first, killing the source `MediaStreamTrack`
|
||||
and closing the readable feeding a track processor before `processor.destroy()`
|
||||
ran. A camera-effect worker therefore saw input EOF before its owner's stop
|
||||
command and reported an operational failure during an ordinary camera-off.
|
||||
The processor is now captured, detached, and its teardown initiated before
|
||||
`super.stop()`.
|
||||
|
||||
12. **Transactional source and processor swaps** (`src/room/track/LocalTrack.ts`,
|
||||
`LocalVideoTrack.ts`, `LocalAudioTrack.ts`)
|
||||
`setMediaStreamTrack()` applied the new source, restarted the processor and
|
||||
re-armed the sender with no unwind path, so a failure anywhere in the middle
|
||||
left a half-applied track: listeners moved, elements detached, sender pointing
|
||||
at a dead track. It now takes `SetMediaStreamTrackOptions`
|
||||
(`force`, `deferEndedListener`, `preservePreviousTrack`) and, on failure,
|
||||
restores the previous source, constraints, `enabled` state, listeners,
|
||||
processor and sender, throwing `TrackInvalidError` when the previous source is
|
||||
no longer `live` because an ended track cannot be restored. Both errors are
|
||||
surfaced together as an `AggregateError` when the unwind itself fails.
|
||||
`stageTrackReplacement()` / `commitStagedTrackReplacement()` expose a two-phase
|
||||
swap: the candidate becomes the active source with its `ended` listener
|
||||
deferred and the previous source preserved, and only the commit adopts the
|
||||
`ended` listener and clears the staged identity, so a caller can validate its
|
||||
publication before the swap is observable. `replaceTrack()` and `restart()`
|
||||
guard the `providedByUser` flip behind a `replacementCommitted` flag.
|
||||
`restart()` still detaches and stops the previous source before calling
|
||||
`getUserMedia()`, as upstream does, because Safari ends a freshly acquired
|
||||
track with a capture failure while the old track for the same device is
|
||||
live. `setSimulcastTrackSender()` routes an already-installed processor's
|
||||
`processedTrack` to a newly registered secondary sender so a backup codec
|
||||
never publishes raw frames while the primary is processed.
|
||||
Processor install and teardown in all three classes roll the processed/raw
|
||||
sender track back, including `LocalVideoTrack`'s secondary simulcast senders,
|
||||
and aggregate every cleanup failure instead of discarding it.
|
||||
`stop()` called `super.stop()` first, killing the source `MediaStreamTrack` and closing the readable that feeds a track processor before `processor.destroy()` ran. A camera-effect worker therefore saw input EOF before its owner's stop command and reported an operational failure during an ordinary camera-off. The processor is now captured, detached and torn down before `super.stop()`.
|
||||
|
||||
13. **Start bitrate for every video codec** (`src/room/PCTransport.ts`,
|
||||
`src/room/participant/LocalParticipant.ts`, `src/room/participant/publishUtils.ts`)
|
||||
`x-google-start-bitrate` was reachable only by AV1 and VP9, gated twice: the
|
||||
publish path registered a track bitrate only for SVC codecs, and the offer
|
||||
munging returned early for everything else. H264, H265 and VP8 therefore
|
||||
opened at the Chromium default and had to ramp, which showed up as a 3000 kbps
|
||||
screen share encoding at 346 kbps twenty seconds in. The bitrate is now
|
||||
registered for every video codec from the highest encoding
|
||||
(`maxEncodingBitrate()`, so a simulcast ladder contributes its top layer), and
|
||||
the offer munging applies the start bitrate whenever a max bitrate is known.
|
||||
The dependency descriptor extension stays SVC-only.
|
||||
`appendStartBitrateToFmtp()` holds the fmtp edit so it can be tested, and
|
||||
`setTrackCodecBitrate()` now replaces an entry for the same cid or transceiver
|
||||
instead of appending, since `trackBitrates` is never cleared.
|
||||
12. **Transactional source and processor swaps** (`src/room/track/LocalTrack.ts`, `LocalVideoTrack.ts`, `LocalAudioTrack.ts`)
|
||||
|
||||
`setMediaStreamTrack()` applied the new source, restarted the processor and re-armed the sender with no unwind path, so a failure part-way through left a half-applied track with listeners moved, elements detached and the sender pointing at a dead track. It now takes `SetMediaStreamTrackOptions` (`force`, `deferEndedListener`, `preservePreviousTrack`) and, on failure, restores the previous source, constraints, `enabled` state, listeners, processor and sender. It throws `TrackInvalidError` when the previous source is no longer `live`, because an ended track cannot be restored, and surfaces both failures as an `AggregateError` when the unwind itself fails.
|
||||
|
||||
`stageTrackReplacement()` and `commitStagedTrackReplacement()` add a two-phase swap. The candidate becomes the active source with its `ended` listener deferred and the previous source preserved, and only the commit adopts the `ended` listener and clears the staged identity, so a caller can validate its publication before the swap is observable. `replaceTrack()` and `restart()` guard the `providedByUser` flip behind a `replacementCommitted` flag. `restart()` still detaches and stops the previous source before calling `getUserMedia()`, as upstream does, because Safari ends a freshly acquired track with a capture failure while the old track for the same device is still live. `setSimulcastTrackSender()` routes an installed processor's `processedTrack` to a newly registered secondary sender so a backup codec never publishes raw frames while the primary is processed. Processor install and teardown in all three classes roll the processed and raw sender track back, including `LocalVideoTrack`'s secondary simulcast senders, and aggregate every cleanup failure instead of discarding it.
|
||||
|
||||
13. **Start bitrate for every video codec** (`src/room/PCTransport.ts`, `src/room/participant/LocalParticipant.ts`, `src/room/participant/publishUtils.ts`)
|
||||
|
||||
`x-google-start-bitrate` was reachable only by AV1 and VP9 because it was gated twice. The publish path registered a track bitrate only for SVC codecs, and the offer munging returned early for everything else. H.264, H.265 and VP8 therefore opened at the Chromium default and had to ramp, which showed up as a 3000 kbps screen share encoding at 346 kbps twenty seconds in. The bitrate is now registered for every video codec from the highest encoding (`maxEncodingBitrate()`, so a simulcast ladder contributes its top layer), and the offer munging applies it whenever a max bitrate is known. The dependency descriptor extension stays SVC-only. `appendStartBitrateToFmtp()` holds the fmtp edit so it can be tested, and `setTrackCodecBitrate()` replaces an entry for the same cid or transceiver instead of appending, since `trackBitrates` is never cleared.
|
||||
|
||||
## Updating from upstream
|
||||
|
||||
|
||||
@@ -3,12 +3,43 @@
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
import type {MediaDescription} from 'sdp-transform';
|
||||
import {describe, expect, it} from 'vitest';
|
||||
import {appendStartBitrateToFmtp} from './PCTransport.ts';
|
||||
import type {TrackBitrateInfo} from './PCTransport.ts';
|
||||
import {appendStartBitrateToFmtp, collectStereoMids, ensureAudioNackAndStereo, ensureOpusFmtp} from './PCTransport.ts';
|
||||
|
||||
function mediaWithFmtp(entries: Array<{payload: number; config: string}>): MediaDescription {
|
||||
return {fmtp: entries} as unknown as MediaDescription;
|
||||
}
|
||||
|
||||
function opusMedia(config: string, mid = '0'): MediaDescription {
|
||||
return {
|
||||
type: 'audio',
|
||||
mid,
|
||||
port: 9,
|
||||
protocol: 'UDP/TLS/RTP/SAVPF',
|
||||
rtp: [{payload: 109, codec: 'opus', rate: 48000, encoding: 2}],
|
||||
fmtp: [{payload: 109, config}],
|
||||
} as unknown as MediaDescription;
|
||||
}
|
||||
|
||||
function offerMedia(mid: string, trackId: string): MediaDescription {
|
||||
const media = opusMedia('useinbandfec=1', mid);
|
||||
media.msid = `- ${trackId}`;
|
||||
return media;
|
||||
}
|
||||
|
||||
function audioBitrateInfo(mid: string | null, trackId: string, stereo: boolean): TrackBitrateInfo {
|
||||
return {
|
||||
transceiver: {mid, sender: {track: {id: trackId}}} as unknown as RTCRtpTransceiver,
|
||||
codec: 'opus',
|
||||
maxbr: 320,
|
||||
stereo,
|
||||
};
|
||||
}
|
||||
|
||||
function opusConfig(media: MediaDescription): string {
|
||||
return media.fmtp.find((fmtp) => fmtp.payload === 109)?.config ?? '';
|
||||
}
|
||||
|
||||
describe('appendStartBitrateToFmtp', () => {
|
||||
it('appends the start bitrate to a non-SVC codec fmtp line', () => {
|
||||
const media = mediaWithFmtp([
|
||||
@@ -44,3 +75,72 @@ describe('appendStartBitrateToFmtp', () => {
|
||||
expect(media.fmtp[0]?.config).toBe('profile-level-id=42e01f');
|
||||
});
|
||||
});
|
||||
|
||||
describe('ensureOpusFmtp', () => {
|
||||
it('does not force stereo on a mono publication', () => {
|
||||
const media = opusMedia('maxplaybackrate=48000;stereo=0;useinbandfec=1');
|
||||
ensureOpusFmtp(media, 48000, false);
|
||||
const config = opusConfig(media);
|
||||
expect(config).toContain('minptime=10');
|
||||
expect(config).toContain('useinbandfec=1');
|
||||
expect(config).toContain('usedtx=0');
|
||||
expect(config).toContain('maxaveragebitrate=48000');
|
||||
expect(config).not.toContain('stereo=1');
|
||||
});
|
||||
|
||||
it('keeps stereo for a stereo publication', () => {
|
||||
const media = opusMedia('maxplaybackrate=48000;useinbandfec=1');
|
||||
ensureOpusFmtp(media, 320000, true);
|
||||
const config = opusConfig(media);
|
||||
expect(config).toContain('stereo=1');
|
||||
expect(config).toContain('sprop-stereo=1');
|
||||
expect(config).toContain('maxaveragebitrate=320000');
|
||||
});
|
||||
|
||||
it('preserves a stereo parameter the server negotiated', () => {
|
||||
const media = opusMedia('minptime=10;stereo=1');
|
||||
ensureOpusFmtp(media, 48000, false);
|
||||
expect(opusConfig(media)).toContain('stereo=1');
|
||||
});
|
||||
});
|
||||
|
||||
describe('ensureAudioNackAndStereo', () => {
|
||||
it('only stamps stereo on the listed mids', () => {
|
||||
const mono = opusMedia('useinbandfec=1', '0');
|
||||
ensureAudioNackAndStereo(mono as never, ['1'], []);
|
||||
expect(opusConfig(mono)).not.toContain('stereo=1');
|
||||
|
||||
const stereo = opusMedia('useinbandfec=1', '1');
|
||||
ensureAudioNackAndStereo(stereo as never, ['1'], []);
|
||||
expect(opusConfig(stereo)).toContain('stereo=1');
|
||||
expect(opusConfig(stereo)).toContain('sprop-stereo=1');
|
||||
});
|
||||
});
|
||||
|
||||
describe('collectStereoMids', () => {
|
||||
it('matches the offer media section by msid before the transceiver has a mid', () => {
|
||||
const media = [offerMedia('0', 'mic-track'), offerMedia('1', 'screenshare-track')];
|
||||
expect(collectStereoMids([audioBitrateInfo(null, 'screenshare-track', true)], media)).toEqual(['1']);
|
||||
});
|
||||
|
||||
it('stamps stereo on the first offer for a new stereo publication', () => {
|
||||
const media = [offerMedia('0', 'mic-track'), offerMedia('1', 'screenshare-track')];
|
||||
const stereoMids = collectStereoMids([audioBitrateInfo(null, 'screenshare-track', true)], media);
|
||||
for (const m of media) {
|
||||
ensureAudioNackAndStereo(m as never, stereoMids, []);
|
||||
}
|
||||
expect(opusConfig(media[0]!)).not.toContain('stereo=1');
|
||||
expect(opusConfig(media[1]!)).toContain('stereo=1');
|
||||
expect(opusConfig(media[1]!)).toContain('sprop-stereo=1');
|
||||
});
|
||||
|
||||
it('uses the assigned mid once renegotiation has one', () => {
|
||||
const media = [offerMedia('0', 'mic-track'), offerMedia('1', 'screenshare-track')];
|
||||
expect(collectStereoMids([audioBitrateInfo('1', 'screenshare-track', true)], media)).toEqual(['1']);
|
||||
});
|
||||
|
||||
it('leaves mono publications out', () => {
|
||||
const media = [offerMedia('0', 'mic-track')];
|
||||
expect(collectStereoMids([audioBitrateInfo(null, 'mic-track', false)], media)).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -11,11 +11,12 @@ import {NegotiationError, UnexpectedConnectionState} from './errors.ts';
|
||||
import type {LoggerOptions} from './types.ts';
|
||||
import {ddExtensionURI, isFireFox, isSafari, isSVCCodec} from './utils.ts';
|
||||
|
||||
interface TrackBitrateInfo {
|
||||
export interface TrackBitrateInfo {
|
||||
cid?: string;
|
||||
transceiver?: RTCRtpTransceiver;
|
||||
codec: string;
|
||||
maxbr: number;
|
||||
stereo?: boolean;
|
||||
}
|
||||
|
||||
const startBitrateFraction = 0.7;
|
||||
@@ -25,8 +26,6 @@ const requiredOpusFmtpParameters = {
|
||||
minptime: '10',
|
||||
useinbandfec: '1',
|
||||
usedtx: '0',
|
||||
stereo: '1',
|
||||
'sprop-stereo': '1',
|
||||
};
|
||||
const debounceInterval = 20;
|
||||
export const PCEvents = {
|
||||
@@ -156,20 +155,18 @@ export default class PCTransport extends EventEmitter {
|
||||
sdpParsed.media.forEach((media) => {
|
||||
const mid = getMidString(media.mid!);
|
||||
if (media.type === 'audio') {
|
||||
ensureOpusFmtp(media);
|
||||
this.trackBitrates.some((trackbr): boolean => {
|
||||
if (!trackbr.transceiver || mid !== trackbr.transceiver.mid) {
|
||||
return false;
|
||||
}
|
||||
const codecPayload = getCodecPayload(media, trackbr.codec);
|
||||
if (codecPayload === 0) {
|
||||
return true;
|
||||
}
|
||||
if (trackbr.codec.toLowerCase() === 'opus') {
|
||||
ensureOpusFmtp(media, trackbr.maxbr > 0 ? trackbr.maxbr * 1000 : opusMaxAverageBitrateBps);
|
||||
}
|
||||
return true;
|
||||
});
|
||||
const trackbr = this.trackBitrates.find(
|
||||
(br) => br.transceiver !== undefined && mid === br.transceiver.mid && br.codec.toLowerCase() === 'opus',
|
||||
);
|
||||
if (trackbr && getCodecPayload(media, trackbr.codec) !== 0) {
|
||||
ensureOpusFmtp(
|
||||
media,
|
||||
trackbr.maxbr > 0 ? trackbr.maxbr * 1000 : opusMaxAverageBitrateBps,
|
||||
trackbr.stereo === true,
|
||||
);
|
||||
} else {
|
||||
ensureOpusFmtp(media);
|
||||
}
|
||||
}
|
||||
});
|
||||
mungedSDP = write(sdpParsed);
|
||||
@@ -240,10 +237,11 @@ export default class PCTransport extends EventEmitter {
|
||||
const offer = await this.pc.createOffer(options);
|
||||
this.log.debug('original offer', {sdp: offer.sdp, ...this.logContext});
|
||||
const sdpParsed = parse(offer.sdp ?? '');
|
||||
const stereoMids = collectStereoMids(this.trackBitrates, sdpParsed.media);
|
||||
sdpParsed.media.forEach((media) => {
|
||||
ensureIPAddrMatchVersion(media);
|
||||
if (media.type === 'audio') {
|
||||
ensureAudioNackAndStereo(media, ['all'], []);
|
||||
ensureAudioNackAndStereo(media, stereoMids, []);
|
||||
} else if (media.type === 'video') {
|
||||
this.trackBitrates.some((trackbr): boolean => {
|
||||
if (!media.msid || !trackbr.cid || !media.msid.includes(trackbr.cid)) {
|
||||
@@ -587,7 +585,11 @@ function ensureAudioRedFmtp(media: MediaDescription, opusPayload: number): void
|
||||
}
|
||||
}
|
||||
|
||||
function ensureOpusFmtp(media: MediaDescription, maxAverageBitrateBps: number = opusMaxAverageBitrateBps): number {
|
||||
export function ensureOpusFmtp(
|
||||
media: MediaDescription,
|
||||
maxAverageBitrateBps: number = opusMaxAverageBitrateBps,
|
||||
stereo = false,
|
||||
): number {
|
||||
const opusPayload = getCodecPayload(media, 'opus');
|
||||
if (opusPayload <= 0) return 0;
|
||||
media.ptime = opusPacketTimeMs;
|
||||
@@ -596,6 +598,10 @@ function ensureOpusFmtp(media: MediaDescription, maxAverageBitrateBps: number =
|
||||
for (const [key, value] of Object.entries(requiredOpusFmtpParameters)) {
|
||||
config = setFmtpParameter(config, key, value);
|
||||
}
|
||||
if (stereo) {
|
||||
config = setFmtpParameter(config, 'stereo', '1');
|
||||
config = setFmtpParameter(config, 'sprop-stereo', '1');
|
||||
}
|
||||
if (maxAverageBitrateBps > 0) {
|
||||
config = setFmtpParameter(config, 'maxaveragebitrate', String(maxAverageBitrateBps));
|
||||
}
|
||||
@@ -604,18 +610,18 @@ function ensureOpusFmtp(media: MediaDescription, maxAverageBitrateBps: number =
|
||||
return opusPayload;
|
||||
}
|
||||
|
||||
function ensureAudioNackAndStereo(
|
||||
export function ensureAudioNackAndStereo(
|
||||
media: {
|
||||
type: string;
|
||||
port: number;
|
||||
protocol: string;
|
||||
payloads?: string | undefined;
|
||||
} & MediaDescription,
|
||||
_stereoMids: Array<string>,
|
||||
stereoMids: Array<string>,
|
||||
nackMids: Array<string>,
|
||||
) {
|
||||
const mid = getMidString(media.mid!);
|
||||
const opusPayload = ensureOpusFmtp(media);
|
||||
const opusPayload = ensureOpusFmtp(media, opusMaxAverageBitrateBps, stereoMids.includes(mid));
|
||||
if (opusPayload > 0) {
|
||||
if (!media.rtcpFb) {
|
||||
media.rtcpFb = [];
|
||||
@@ -629,6 +635,32 @@ function ensureAudioNackAndStereo(
|
||||
}
|
||||
}
|
||||
|
||||
export function collectStereoMids(
|
||||
trackBitrates: Array<TrackBitrateInfo>,
|
||||
media: Array<MediaDescription>,
|
||||
): Array<string> {
|
||||
const stereoMids: Array<string> = [];
|
||||
for (const trackbr of trackBitrates) {
|
||||
if (trackbr.stereo !== true || !trackbr.transceiver) {
|
||||
continue;
|
||||
}
|
||||
if (trackbr.transceiver.mid) {
|
||||
stereoMids.push(getMidString(trackbr.transceiver.mid));
|
||||
continue;
|
||||
}
|
||||
const trackId = trackbr.transceiver.sender.track?.id;
|
||||
if (trackId === undefined) {
|
||||
continue;
|
||||
}
|
||||
for (const m of media) {
|
||||
if (m.type === 'audio' && m.mid !== undefined && m.msid?.includes(trackId)) {
|
||||
stereoMids.push(getMidString(m.mid));
|
||||
}
|
||||
}
|
||||
}
|
||||
return stereoMids;
|
||||
}
|
||||
|
||||
function extractStereoAndNackAudioFromOffer(offer: RTCSessionDescriptionInit): {
|
||||
stereoMids: Array<string>;
|
||||
nackMids: Array<string>;
|
||||
|
||||
@@ -27,7 +27,59 @@ describe('selectPublisherCodecPreferences', () => {
|
||||
const highProfile = codec('video/H264', 'level-asymmetry-allowed=1;packetization-mode=1;profile-level-id=64001f');
|
||||
const rtx = codec('video/rtx');
|
||||
const preferences = selectPublisherCodecPreferences('h264', [openH264, rtx, externalBaseline, highProfile]);
|
||||
expect(preferences).toEqual([externalBaseline, highProfile, openH264, rtx]);
|
||||
expect(preferences).toEqual([externalBaseline, openH264, highProfile, rtx]);
|
||||
});
|
||||
|
||||
it('ranks Constrained Baseline above Main, High and Constrained High', () => {
|
||||
const constrainedBaseline = codec(
|
||||
'video/H264',
|
||||
'level-asymmetry-allowed=1;packetization-mode=1;profile-level-id=42e01f',
|
||||
);
|
||||
const mainProfile = codec('video/H264', 'level-asymmetry-allowed=1;packetization-mode=1;profile-level-id=4d001f');
|
||||
const highProfileLevel31 = codec(
|
||||
'video/H264',
|
||||
'level-asymmetry-allowed=1;packetization-mode=1;profile-level-id=64001f',
|
||||
);
|
||||
const highProfileLevel51 = codec(
|
||||
'video/H264',
|
||||
'level-asymmetry-allowed=1;packetization-mode=1;profile-level-id=640033',
|
||||
);
|
||||
const constrainedHigh = codec(
|
||||
'video/H264',
|
||||
'level-asymmetry-allowed=1;packetization-mode=1;profile-level-id=640c1f',
|
||||
);
|
||||
const preferences = selectPublisherCodecPreferences('h264', [
|
||||
mainProfile,
|
||||
highProfileLevel31,
|
||||
highProfileLevel51,
|
||||
constrainedHigh,
|
||||
constrainedBaseline,
|
||||
]);
|
||||
expect(preferences).toEqual([
|
||||
constrainedBaseline,
|
||||
mainProfile,
|
||||
highProfileLevel31,
|
||||
highProfileLevel51,
|
||||
constrainedHigh,
|
||||
]);
|
||||
});
|
||||
|
||||
it('keeps Constrained Baseline packetization-mode=1 ahead of Constrained Baseline packetization-mode=0 and High', () => {
|
||||
const constrainedBaselineMode0 = codec(
|
||||
'video/H264',
|
||||
'level-asymmetry-allowed=1;packetization-mode=0;profile-level-id=42e01f',
|
||||
);
|
||||
const constrainedBaselineMode1 = codec(
|
||||
'video/H264',
|
||||
'level-asymmetry-allowed=1;packetization-mode=1;profile-level-id=42e01f',
|
||||
);
|
||||
const highProfile = codec('video/H264', 'level-asymmetry-allowed=1;packetization-mode=1;profile-level-id=640033');
|
||||
const preferences = selectPublisherCodecPreferences('h264', [
|
||||
highProfile,
|
||||
constrainedBaselineMode0,
|
||||
constrainedBaselineMode1,
|
||||
]);
|
||||
expect(preferences).toEqual([constrainedBaselineMode1, constrainedBaselineMode0, highProfile]);
|
||||
});
|
||||
|
||||
it('keeps non-H.264 codecs in browser capability order and appends RTX', () => {
|
||||
|
||||
@@ -96,7 +96,7 @@ const videoCodecMimeTypes: Record<VideoCodec, Array<string>> = {
|
||||
vp8: ['video/vp8'],
|
||||
};
|
||||
const h264OpenH264ProfileLevelId = '42e01f';
|
||||
const h264PreferredHardwareProfileLevelIds = new Set(['42001f', '4d001f', '64001f']);
|
||||
const h264PreferredHardwareProfileLevelIds = new Set(['42001f']);
|
||||
type RtpCodecCapability = RTCRtpCapabilities['codecs'][number] & {sdpFmtpLine?: string};
|
||||
|
||||
enum PCState {
|
||||
@@ -1656,8 +1656,8 @@ function getH264PublisherCodecScore(codec: RtpCodecCapability): number {
|
||||
const packetizationMode = getFmtpParameter(codec.sdpFmtpLine, 'packetization-mode');
|
||||
const packetizationScore = packetizationMode === '1' ? 0 : 1;
|
||||
if (profileLevelId && h264PreferredHardwareProfileLevelIds.has(profileLevelId)) return packetizationScore;
|
||||
if (profileLevelId && profileLevelId !== h264OpenH264ProfileLevelId) return 10 + packetizationScore;
|
||||
if (profileLevelId === h264OpenH264ProfileLevelId) return 20 + packetizationScore;
|
||||
if (profileLevelId === h264OpenH264ProfileLevelId) return 10 + packetizationScore;
|
||||
if (profileLevelId) return 20 + packetizationScore;
|
||||
return 30 + packetizationScore;
|
||||
}
|
||||
|
||||
|
||||
@@ -1040,6 +1040,7 @@ export default class LocalParticipant extends Participant {
|
||||
transceiver: trackTransceiver,
|
||||
codec: 'opus',
|
||||
maxbr: encodings[0]?.maxBitrate ? encodings[0].maxBitrate / 1000 : 0,
|
||||
stereo: isStereo,
|
||||
});
|
||||
}
|
||||
} else if (track.codec) {
|
||||
|
||||
Reference in New Issue
Block a user