snapshot of https://github.com/jitsi/jitsi-meet.git @ fd1749dd28f4138aa80234fc7f311b19aaffa0e7 (2026-09-15)
This commit is contained in:
301
react/features/audio-translation/functions.ts
Normal file
301
react/features/audio-translation/functions.ts
Normal file
@@ -0,0 +1,301 @@
|
||||
import { IReduxState } from '../app/types';
|
||||
import { AUDIO_TRANSLATION_ENABLED } from '../base/flags/constants';
|
||||
import { getFeatureFlag } from '../base/flags/functions';
|
||||
import { MEET_FEATURES } from '../base/jwt/constants';
|
||||
import { isJwtFeatureEnabled } from '../base/jwt/functions';
|
||||
import { isLocalParticipantModerator } from '../base/participants/functions';
|
||||
import { ITrack } from '../base/tracks/types';
|
||||
|
||||
import { DUCKED_ORIGINAL_VOLUME, TranslationTreatment } from './constants';
|
||||
|
||||
/**
|
||||
* Whether audio translation is enabled for the room. Driven by the {@code audioTranslation} RoomMetadata flag,
|
||||
* which a moderator can toggle; absent or any value other than an explicit {@code false} means enabled.
|
||||
*
|
||||
* @param {IReduxState} state - The redux state.
|
||||
* @returns {boolean}
|
||||
*/
|
||||
export function isAudioTranslationRoomEnabled(state: IReduxState): boolean {
|
||||
return state['features/base/conference'].metadata?.audioTranslation?.enabled !== false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether the given participant is currently translating the local participant's audio, per the directed
|
||||
* {@code translationListeners} list pushed by the audio-translation component.
|
||||
*
|
||||
* @param {IReduxState} state - The redux state.
|
||||
* @param {string} participantId - The participant (endpoint) id to check.
|
||||
* @returns {boolean}
|
||||
*/
|
||||
export function isParticipantAudioTranslationActive(state: IReduxState, participantId: string): boolean {
|
||||
return state['features/audio-translation'].translationListeners.includes(participantId);
|
||||
}
|
||||
|
||||
/**
|
||||
* The target language encoded in a translated source name — the substring after the last {@code .}.
|
||||
*
|
||||
* @param {string} sourceName - The translated source name.
|
||||
* @returns {string}
|
||||
*/
|
||||
export function getSourceLanguage(sourceName: string): string {
|
||||
const dotIndex = sourceName.lastIndexOf('.');
|
||||
|
||||
return dotIndex === -1 ? '' : sourceName.substring(dotIndex + 1);
|
||||
}
|
||||
|
||||
/**
|
||||
* The owner endpoint id encoded in a translated source name — the substring before the first {@code -}.
|
||||
* Translated sources follow the {@code <endpointId>-a<idx>.<lang>} convention (endpoint ids are dash/dot-free).
|
||||
* Returns an empty string for a source without a dash.
|
||||
*
|
||||
* @param {string} sourceName - The translated source name.
|
||||
* @returns {string}
|
||||
*/
|
||||
export function getSourceOwnerEndpointId(sourceName: string): string {
|
||||
const dashIndex = sourceName.indexOf('-');
|
||||
|
||||
return dashIndex === -1 ? '' : sourceName.substring(0, dashIndex);
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether we are hearing the given participant translated. The bridge floods sending changes to every
|
||||
* endpoint, so {@code receivingSources} is every translated source in flight anywhere, not just ours; a
|
||||
* source counts as ours only when its language matches the one we selected for this participant.
|
||||
*
|
||||
* @param {IReduxState} state - The redux state.
|
||||
* @param {string} participantId - The participant (endpoint) id to check.
|
||||
* @returns {boolean}
|
||||
*/
|
||||
export function isReceivingTranslationFrom(state: IReduxState, participantId: string): boolean {
|
||||
const language = getEffectiveTranslationLanguage(state, participantId);
|
||||
|
||||
// The bridge announces sending changes to every endpoint, so a source being in flight does not mean we
|
||||
// subscribed to it; require our own selection for this speaker to match the source's language.
|
||||
if (!language) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return state['features/audio-translation'].receivingSources
|
||||
.some(sourceName => getSourceOwnerEndpointId(sourceName) === participantId
|
||||
&& sourceName.endsWith(`.${language}`));
|
||||
}
|
||||
|
||||
/**
|
||||
* The audio-translation status treatment for a participant, combining whether translation is enabled for the
|
||||
* local user ({@link isParticipantAudioTranslationActive}) and whether translated audio is being received
|
||||
* ({@link isReceivingTranslationFrom}).
|
||||
*
|
||||
* @param {IReduxState} state - The redux state.
|
||||
* @param {string} participantId - The participant (endpoint) id to check.
|
||||
* @returns {TranslationTreatment}
|
||||
*/
|
||||
export function getTranslationTreatment(state: IReduxState, participantId: string): TranslationTreatment {
|
||||
const enabled = isParticipantAudioTranslationActive(state, participantId);
|
||||
const receiving = isReceivingTranslationFrom(state, participantId);
|
||||
|
||||
if (enabled && receiving) {
|
||||
return TranslationTreatment.BOTH;
|
||||
}
|
||||
if (enabled) {
|
||||
return TranslationTreatment.ENABLED;
|
||||
}
|
||||
if (receiving) {
|
||||
return TranslationTreatment.RECEIVING;
|
||||
}
|
||||
|
||||
return TranslationTreatment.NONE;
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether audio translation is currently active anywhere in the meeting: a remote participant is translating
|
||||
* the local user, translated audio is being received, or the local user has translation on.
|
||||
*
|
||||
* @param {IReduxState} state - The redux state.
|
||||
* @returns {boolean}
|
||||
*/
|
||||
export function isAudioTranslationActiveInMeeting(state: IReduxState): boolean {
|
||||
const { language, participantLanguages, receivingSources, translationListeners }
|
||||
= state['features/audio-translation'];
|
||||
|
||||
return translationListeners.length > 0
|
||||
|| receivingSources.length > 0
|
||||
|| Boolean(language)
|
||||
|| Object.values(participantLanguages).some(lang => lang !== null);
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether the local user may manage audio translation for the room (i.e. toggle the room-wide flag). Mirrors the
|
||||
* prosody {@code live-translation} permission: the token's explicit value wins, otherwise moderators have it.
|
||||
*
|
||||
* @param {IReduxState} state - The redux state.
|
||||
* @returns {boolean}
|
||||
*/
|
||||
export function canManageAudioTranslation(state: IReduxState): boolean {
|
||||
return isJwtFeatureEnabled(state, MEET_FEATURES.LIVE_TRANSLATION, isLocalParticipantModerator(state));
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether the audio-translation UI should be available to the local user: the feature must be deployed
|
||||
* ({@code config.audioTranslation.enabled}) and either the user can manage it or it is enabled for the room.
|
||||
*
|
||||
* @param {IReduxState} state - The redux state.
|
||||
* @returns {boolean}
|
||||
*/
|
||||
export function isAudioTranslationAvailable(state: IReduxState): boolean {
|
||||
// SDK embedder kill switch; defaults true, so web (no flags state) is unaffected.
|
||||
if (!getFeatureFlag(state, AUDIO_TRANSLATION_ENABLED, true)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!state['features/base/config'].audioTranslation?.enabled) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Per-room availability via RoomMetadata; only an explicit false hides it.
|
||||
if (state['features/base/conference'].metadata?.audioTranslationAvailable === false) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return canManageAudioTranslation(state) || isAudioTranslationRoomEnabled(state);
|
||||
}
|
||||
|
||||
/**
|
||||
* Memoization for {@link getTranslatedSourceNames}: the tracks array is replaced on every tracks update, so
|
||||
* the set is rebuilt only when that reference changes and is shared by all callers in between.
|
||||
*/
|
||||
let _lastTracks: ITrack[] | undefined;
|
||||
let _lastTranslatedSourceNames = new Set<string>();
|
||||
|
||||
/**
|
||||
* Returns the set of translated audio source names currently present in the conference. Translated sources
|
||||
* follow the {@code <sourceName>.<language>} convention, so any source name containing a dot is one. Memoized
|
||||
* on the tracks array reference, letting callers do an O(1) lookup instead of scanning every track.
|
||||
*
|
||||
* @param {IReduxState} state - The redux state.
|
||||
* @returns {Set<string>} The translated source names present in the conference.
|
||||
*/
|
||||
export function getTranslatedSourceNames(state: IReduxState): Set<string> {
|
||||
const tracks = state['features/base/tracks'];
|
||||
|
||||
if (tracks === _lastTracks) {
|
||||
return _lastTranslatedSourceNames;
|
||||
}
|
||||
|
||||
const translated = new Set<string>();
|
||||
|
||||
for (const track of tracks) {
|
||||
const sourceName = track.jitsiTrack?.getSourceName?.();
|
||||
|
||||
if (typeof sourceName === 'string' && sourceName.includes('.')) {
|
||||
translated.add(sourceName);
|
||||
}
|
||||
}
|
||||
|
||||
_lastTracks = tracks;
|
||||
_lastTranslatedSourceNames = translated;
|
||||
|
||||
return translated;
|
||||
}
|
||||
|
||||
/**
|
||||
* The per-participant override language if set, else the conference-wide default.
|
||||
*
|
||||
* @param {IReduxState} state - The redux state.
|
||||
* @param {string} [participantId] - The speaker's participant id.
|
||||
* @returns {string | null} The target language, or null.
|
||||
*/
|
||||
export function getEffectiveTranslationLanguage(state: IReduxState, participantId?: string): string | null {
|
||||
const { language, participantLanguages } = state['features/audio-translation'];
|
||||
|
||||
return participantId && participantId in participantLanguages
|
||||
? participantLanguages[participantId]
|
||||
: language;
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether to duck a speaker's original: a language is set and its translated counterpart is present.
|
||||
*
|
||||
* @param {IReduxState} state - The redux state.
|
||||
* @param {string} [sourceName] - The original audio source name.
|
||||
* @param {string} [participantId] - The speaker's participant id.
|
||||
* @returns {boolean}
|
||||
*/
|
||||
export function shouldDuckOriginalAudio(state: IReduxState, sourceName?: string, participantId?: string): boolean {
|
||||
const language = getEffectiveTranslationLanguage(state, participantId);
|
||||
|
||||
if (!language || typeof sourceName !== 'string' || sourceName.endsWith(`.${language}`)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return getTranslatedSourceNames(state).has(`${sourceName}.${language}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* The ducked volume (0..1) from config.audioTranslation.duckedVolume; invalid values fall back to default.
|
||||
*
|
||||
* @param {IReduxState} state - The redux state.
|
||||
* @returns {number}
|
||||
*/
|
||||
export function getDuckedVolume(state: IReduxState): number {
|
||||
const configured = state['features/base/config'].audioTranslation?.duckedVolume;
|
||||
|
||||
return typeof configured === 'number' && configured >= 0 && configured <= 1
|
||||
? configured
|
||||
: DUCKED_ORIGINAL_VOLUME;
|
||||
}
|
||||
|
||||
/**
|
||||
* The ducked volume, capped at the user's chosen volume so ducking never makes a quieted speaker louder.
|
||||
*
|
||||
* @param {IReduxState} state - The redux state.
|
||||
* @param {string} [participantId] - The speaker's participant id.
|
||||
* @returns {number}
|
||||
*/
|
||||
export function getDuckedVolumeForParticipant(state: IReduxState, participantId?: string): number {
|
||||
const duckedVolume = getDuckedVolume(state);
|
||||
const userVolume = participantId ? state['features/filmstrip'].participantsVolume[participantId] : undefined;
|
||||
|
||||
return typeof userVolume === 'number' ? Math.min(userVolume, duckedVolume) : duckedVolume;
|
||||
}
|
||||
|
||||
/**
|
||||
* The number of participants still hearing the given speaker's translated audio, as published by that
|
||||
* speaker. 0 when nothing is pending or the speaker's client does not publish the count.
|
||||
*
|
||||
* @param {IReduxState} state - The redux state.
|
||||
* @param {string} participantId - The speaker's participant id.
|
||||
* @returns {number}
|
||||
*/
|
||||
export function getTranslationDeliveryPendingCount(state: IReduxState, participantId: string): number {
|
||||
const counts = state['features/base/conference'].metadata?.audioTranslationListenerCounts?.[participantId];
|
||||
|
||||
if (!counts) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
// Sum the subscribers of only the languages the bridge is still sending for this speaker: the in-flight
|
||||
// language count alone undercounts (one source serves every listener of that language) and the speaker's
|
||||
// subscriber total overcounts (a language whose stream already finished).
|
||||
return state['features/audio-translation'].receivingSources.reduce((total, sourceName) =>
|
||||
getSourceOwnerEndpointId(sourceName) === participantId
|
||||
? total + (counts[getSourceLanguage(sourceName)] ?? 0)
|
||||
: total, 0);
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether translated audio for the given speaker is still being delivered, so others should wait before
|
||||
* speaking. Falls back to any in-flight translated source owned by the speaker, so the ring still shows when
|
||||
* the speaker's client does not publish a count.
|
||||
*
|
||||
* @param {IReduxState} state - The redux state.
|
||||
* @param {string} participantId - The speaker's participant id.
|
||||
* @returns {boolean}
|
||||
*/
|
||||
export function isTranslationDeliveryPending(state: IReduxState, participantId: string): boolean {
|
||||
if (getTranslationDeliveryPendingCount(state, participantId) > 0) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return state['features/audio-translation'].receivingSources
|
||||
.some(sourceName => getSourceOwnerEndpointId(sourceName) === participantId);
|
||||
}
|
||||
Reference in New Issue
Block a user