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 -a.} 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(); /** * Returns the set of translated audio source names currently present in the conference. Translated sources * follow the {@code .} 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} The translated source names present in the conference. */ export function getTranslatedSourceNames(state: IReduxState): Set { const tracks = state['features/base/tracks']; if (tracks === _lastTracks) { return _lastTranslatedSourceNames; } const translated = new Set(); 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); }