frontend: Adds live captions component

Introduces a live captions feature using LiveKit's transcription service.

This adds a new component that displays real-time transcriptions of the meeting audio in. It manages caption lifecycles, handles both interim and final transcriptions, and
provides reactive signals for UI updates.
This commit is contained in:
CSantosM 2026-01-21 12:45:06 +01:00
parent 073f0dc640
commit 5cdc49d90c
15 changed files with 936 additions and 50 deletions

View File

@ -1,3 +1,4 @@
export * from './meeting-captions/meeting-captions.component';
export * from './meeting-custom-layout/meeting-custom-layout.component';
export * from './meeting-invite-panel/meeting-invite-panel.component';
export * from './meeting-participant-item/meeting-participant-item.component';

View File

@ -0,0 +1,23 @@
<div class="captions-container">
<div class="captions-wrapper" [class.single-caption]="captions().length === 1">
@for (caption of captions(); track trackByCaption($index, caption)) {
<div
[ngClass]="getCaptionClasses(caption)"
[attr.data-caption-id]="caption.id"
role="region"
aria-live="polite"
[attr.aria-label]="'Caption from ' + caption.participantName"
>
<!-- Scrollable container for caption text -->
<div class="caption-text-container" #captionTextContainer>
<div class="caption-text" [class.caption-text-interim]="!caption.isFinal">
<span class="caption-speaker" [style.color]="caption.participantColor">
{{ caption.participantName }}:
</span>
{{ caption.text }}
</div>
</div>
</div>
}
</div>
</div>

View File

@ -0,0 +1,202 @@
@use '../../../../../../../../src/assets/styles/design-tokens';
// =============================================================================
// CAPTIONS CONTAINER - Responsive to panel width changes
// =============================================================================
.captions-container {
pointer-events: none;
justify-content: center;
padding: 0px;
width: 100%;
height: var(--ov-footer-height, 250px) !important;
// Transition for smooth width changes when panel opens/closes
transition: padding 200ms ease-in-out;
@include design-tokens.ov-tablet-down {
bottom: 100px;
}
@include design-tokens.ov-mobile-down {
bottom: 90px;
}
}
// =============================================================================
// CAPTIONS WRAPPER
// =============================================================================
.captions-wrapper {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center; // Center vertically when single caption
gap: 8px;
padding: 20px;
max-width: calc(100% - 40px);
width: calc(100% - 40px);
height: 100%;
margin: auto;
// CRITICAL: Fixed max-height to prevent overflow
// This is the total available space for ALL captions combined
max-height: 230px;
overflow: hidden; // Never allow scroll or overflow
// Wide screens: Limit width to prevent very long text lines
// Reduces eye movement required to follow captions
@media (min-width: 1400px) {
max-width: 900px;
padding: 20px 60px;
}
@media (min-width: 1800px) {
max-width: 1000px;
padding: 20px 100px;
}
@include design-tokens.ov-tablet-down {
max-width: calc(100% - 40px);
padding: 15px;
}
@include design-tokens.ov-mobile-down {
padding: 10px;
}
// When only one caption, it should fill available space and center
&.single-caption {
justify-content: center;
.caption-item {
flex: 1;
display: flex;
align-items: center;
justify-content: center;
}
}
}
.caption-item {
pointer-events: auto;
display: inline-block;
text-align: center;
max-width: 100%;
padding: 0;
font-family: var(--ov-meet-font-family);
transition:
opacity 200ms ease-in-out,
transform 200ms ease-in-out;
&.caption-entering {
opacity: 0;
transform: translateY(10px);
}
&.caption-active {
opacity: 1;
transform: translateY(0);
}
&.caption-leaving {
opacity: 0;
transform: translateY(-10px);
}
&.caption-interim .caption-text {
opacity: 0.85;
}
}
// =============================================================================
// CAPTION TEXT CONTAINER - Scrollable wrapper with fixed height
// =============================================================================
.caption-text-container {
// CRITICAL: Fixed max-height with scroll to contain long monologues
max-height: 140px;
overflow-y: auto;
overflow-x: hidden;
// Hide scrollbar but keep functionality (desktop only)
scrollbar-width: none; // Firefox
-ms-overflow-style: none; // IE/Edge
&::-webkit-scrollbar {
display: none; // Chrome/Safari
}
// Responsive max-heights
@include design-tokens.ov-tablet-down {
max-height: 100px;
}
@include design-tokens.ov-mobile-down {
max-height: 100%;
// CRITICAL: Disable touch scroll on mobile to prevent accidental scrolling
overflow-y: hidden;
touch-action: none;
-webkit-overflow-scrolling: auto;
}
}
// =============================================================================
// CAPTION SPEAKER NAME - Inline bold text (not separate element)
// =============================================================================
.caption-speaker {
display: inline;
font-weight: 400;
font-size: 16px;
margin-right: 6px;
font-style: italic;
// Inherits all other styles from .caption-text parent
}
.caption-text {
display: inline;
font-size: 20px;
font-weight: 500;
line-height: 1.5;
color: #ffffff;
background-color: rgba(0, 0, 0, 0.7);
padding: 4px 8px;
border-radius: 4px;
-webkit-box-decoration-break: clone;
box-decoration-break: clone;
text-shadow:
0 1px 3px rgba(0, 0, 0, 0.9),
0 0 8px rgba(0, 0, 0, 0.5);
// Ensure text wraps properly within the scrollable container
word-wrap: break-word;
overflow-wrap: break-word;
word-break: break-word;
@include design-tokens.ov-tablet-down {
font-size: 18px;
padding: 3px 7px;
}
@include design-tokens.ov-mobile-down {
font-size: 16px;
padding: 3px 6px;
line-height: 1.4;
}
}
// =============================================================================
// REDUCED MOTION SUPPORT
// =============================================================================
@media (prefers-reduced-motion: reduce) {
.caption-item {
transition: opacity 50ms ease-in-out;
&.caption-entering,
&.caption-leaving {
transform: none;
}
}
}

View File

@ -0,0 +1,106 @@
import { CommonModule } from '@angular/common';
import { Component, effect, ElementRef, input, QueryList, signal, untracked, ViewChildren } from '@angular/core';
import { Caption } from '../../models/captions.model';
@Component({
selector: 'ov-meeting-captions',
imports: [CommonModule],
templateUrl: './meeting-captions.component.html',
styleUrl: './meeting-captions.component.scss'
})
export class MeetingCaptionsComponent {
// Reactive caption data from service
captions = input<Caption[]>([]);
// Track animation state for each caption
protected readonly captionAnimationState = signal<Map<string, 'entering' | 'active' | 'leaving'>>(new Map());
// ViewChildren to access caption text containers for auto-scroll
@ViewChildren('captionTextContainer')
captionTextContainers!: QueryList<ElementRef<HTMLDivElement>>;
constructor() {
// Monitor caption changes and update animation states
effect(() => {
const currentCaptions = this.captions();
// Use untracked to read current state without subscribing to it
const animationStates = new Map(untracked(() => this.captionAnimationState()));
// Mark new captions as entering
currentCaptions.forEach((caption) => {
if (!animationStates.has(caption.id)) {
animationStates.set(caption.id, 'entering');
// Transition to active after a brief delay
setTimeout(() => {
// Use untracked to avoid triggering the effect again
const states = new Map(untracked(() => this.captionAnimationState()));
states.set(caption.id, 'active');
this.captionAnimationState.set(states);
}, 50);
}
});
// Remove states for captions that no longer exist
const currentIds = new Set(currentCaptions.map((c) => c.id));
animationStates.forEach((_, id) => {
if (!currentIds.has(id)) {
animationStates.delete(id);
}
});
this.captionAnimationState.set(animationStates);
// Auto-scroll to bottom of each caption after captions update
this.scrollCaptionsToBottom();
});
}
/**
* Gets the CSS classes for a caption based on its state.
*
* @param caption The caption item
* @returns CSS class string
*/
protected getCaptionClasses(caption: Caption): string {
const classes: string[] = ['caption-item'];
// Add final/interim class
classes.push(caption.isFinal ? 'caption-final' : 'caption-interim');
// Add animation state class
const animationState = this.captionAnimationState().get(caption.id);
if (animationState) {
classes.push(`caption-${animationState}`);
}
return classes.join(' ');
}
/**
* Tracks captions by their ID for optimal Angular rendering.
*
* @param index Item index
* @param caption Caption item
* @returns Unique identifier
*/
protected trackByCaption(index: number, caption: Caption): string {
return caption.id;
}
/**
* Scrolls all caption text containers to the bottom to show the most recent text.
* Called automatically when captions are updated.
*/
private scrollCaptionsToBottom(): void {
// Use setTimeout to ensure DOM has updated
setTimeout(() => {
if (this.captionTextContainers) {
this.captionTextContainers.forEach((container: ElementRef<HTMLDivElement>) => {
const element = container.nativeElement;
element.scrollTop = element.scrollHeight;
});
}
}, 20);
}
}

View File

@ -1,40 +1,49 @@
@if (meetingContextService.lkRoom()) {
<ov-layout [ovRemoteParticipants]="visibleRemoteParticipants()">
@if (shouldShowLinkOverlay()) {
<ng-container *ovLayoutAdditionalElements>
<div id="share-link-overlay" class="main-share-meeting-link-container fade-in-delayed OV_big">
<ov-share-meeting-link
class="main-share-meeting-link"
[title]="linkOverlayConfig.title"
[subtitle]="linkOverlayConfig.subtitle"
[titleSize]="linkOverlayConfig.titleSize"
[titleWeight]="linkOverlayConfig.titleWeight"
[meetingUrl]="meetingUrl()"
(copyClicked)="onCopyMeetingLinkClicked()"
></ov-share-meeting-link>
</div>
</ng-container>
} @else if (isSmartMosaicActive() && hiddenParticipantsCount() > 0) {
<!-- Use bottom slot to position indicator at the end -->
<ng-container *ovLayoutAdditionalElements="'bottom'">
<div
[ngClass]="{
'OV_top-bar': showTopBarHiddenParticipantsIndicator(),
OV_last: !showTopBarHiddenParticipantsIndicator()
}"
>
<ov-hidden-participants-indicator
(clicked)="toggleParticipantsPanel()"
[count]="hiddenParticipantsCount()"
[mode]="showTopBarHiddenParticipantsIndicator() ? 'topbar' : 'standard'"
[hiddenParticipantNames]="hiddenParticipantNames()"
/>
</div>
</ng-container>
}
<div class="main-container" [ngClass]="{ withFooter: shouldShowCaptions() }">
<ov-layout
[ovRemoteParticipants]="visibleRemoteParticipants()"
[ngClass]="{ withFooter: shouldShowCaptions() }"
>
@if (shouldShowLinkOverlay()) {
<ng-container *ovLayoutAdditionalElements>
<div id="share-link-overlay" class="main-share-meeting-link-container fade-in-delayed OV_big">
<ov-share-meeting-link
class="main-share-meeting-link"
[title]="linkOverlayConfig.title"
[subtitle]="linkOverlayConfig.subtitle"
[titleSize]="linkOverlayConfig.titleSize"
[titleWeight]="linkOverlayConfig.titleWeight"
[meetingUrl]="meetingUrl()"
(copyClicked)="onCopyMeetingLinkClicked()"
></ov-share-meeting-link>
</div>
</ng-container>
} @else if (isSmartMosaicActive() && hiddenParticipantsCount() > 0) {
<!-- Use bottom slot to position indicator at the end -->
<ng-container *ovLayoutAdditionalElements="'bottom'">
<div
[ngClass]="{
'OV_top-bar': showTopBarHiddenParticipantsIndicator(),
OV_last: !showTopBarHiddenParticipantsIndicator()
}"
>
<ov-hidden-participants-indicator
(clicked)="toggleParticipantsPanel()"
[count]="hiddenParticipantsCount()"
[mode]="showTopBarHiddenParticipantsIndicator() ? 'topbar' : 'standard'"
[hiddenParticipantNames]="hiddenParticipantNames()"
/>
</div>
</ng-container>
}
<ng-template #stream let-track>
<ov-stream [track]="track"></ov-stream>
</ng-template>
</ov-layout>
<ng-template #stream let-track>
<ov-stream [track]="track"></ov-stream>
</ng-template>
</ov-layout>
</div>
<!-- Live Captions Component -->
@if (shouldShowCaptions()) {
<ov-meeting-captions [captions]="captions()"></ov-meeting-captions>
}
}

View File

@ -1,5 +1,9 @@
@use '../../../../../../../../src/assets/styles/design-tokens';
.main-container {
height: 100%;
}
.remote-participant {
height: -webkit-fill-available;
height: -moz-available;
@ -8,9 +12,11 @@
.container {
height: 100%;
}
.withCaptions {
height: calc(100% - var(--ov-captions-height, 250px)) !important;
.withFooter {
height: calc(100% - var(--ov-footer-height, 250px)) !important;
}
.withMargin {
margin: 0px 5px;
max-height: calc(100% - 5px) !important;

View File

@ -1,9 +1,21 @@
import { CommonModule } from '@angular/common';
import { Component, computed, effect, inject, signal, untracked } from '@angular/core';
import { ILogger, LoggerService, OpenViduComponentsUiModule, PanelService, PanelType, ParticipantModel } from 'openvidu-components-angular';
import { HiddenParticipantsIndicatorComponent, ShareMeetingLinkComponent } from '../../components';
import { CustomParticipantModel } from '../../models';
import { MeetingContextService, MeetingLayoutService, MeetingService } from '../../services';
import {
ILogger,
LoggerService,
OpenViduComponentsUiModule,
PanelService,
PanelType,
ParticipantModel
} from 'openvidu-components-angular';
import { HiddenParticipantsIndicatorComponent } from '../../components/hidden-participants-indicator/hidden-participants-indicator.component';
import { ShareMeetingLinkComponent } from '../../components/share-meeting-link/share-meeting-link.component';
import { CustomParticipantModel } from '../../models/custom-participant.model';
import { MeetingCaptionsService } from '../../services/meeting-captions.service';
import { MeetingContextService } from '../../services/meeting-context.service';
import { MeetingLayoutService } from '../../services/meeting-layout.service';
import { MeetingService } from '../../services/meeting.service';
import { MeetingCaptionsComponent } from '../meeting-captions/meeting-captions.component';
@Component({
selector: 'ov-meeting-custom-layout',
@ -11,7 +23,8 @@ import { MeetingContextService, MeetingLayoutService, MeetingService } from '../
CommonModule,
OpenViduComponentsUiModule,
ShareMeetingLinkComponent,
HiddenParticipantsIndicatorComponent
HiddenParticipantsIndicatorComponent,
MeetingCaptionsComponent
],
templateUrl: './meeting-custom-layout.component.html',
styleUrl: './meeting-custom-layout.component.scss'
@ -22,6 +35,7 @@ export class MeetingCustomLayoutComponent {
protected readonly meetingContextService = inject(MeetingContextService);
protected readonly meetingService = inject(MeetingService);
protected readonly panelService = inject(PanelService);
protected readonly captionsService = inject(MeetingCaptionsService);
protected readonly linkOverlayConfig = {
title: 'Start collaborating',
subtitle: 'Share this link to bring others into the meeting',
@ -36,6 +50,10 @@ export class MeetingCustomLayoutComponent {
return this.meetingContextService.canModerateRoom() && hasNoRemotes;
});
protected readonly shouldShowCaptions = computed(() => this.captionsService.areCaptionsEnabled());
protected readonly captions = computed(() => this.captionsService.captions());
protected readonly isLayoutSwitchingAllowed = this.meetingContextService.allowLayoutSwitching;
private displayedParticipantIds: string[] = [];

View File

@ -1,3 +1,27 @@
<!-- Captions button -->
@if (isMobile()) {
<!-- On mobile, the captions button will be inside a menu -->
<button id="captions-button" mat-menu-item (click)="onCaptionsClick()" [disableRipple]="true">
<mat-icon class="material-symbols-outlined">{{
areCaptionsEnabled() ? 'subtitles_off' : 'subtitles'
}}</mat-icon>
<span class="button-text">
{{ areCaptionsEnabled() ? 'Disable live captions' : 'Enable live captions' }}
</span>
</button>
} @else {
<button
id="captions-button"
[ngClass]="areCaptionsEnabled() ? 'active' : ''"
mat-icon-button
(click)="onCaptionsClick()"
[disableRipple]="true"
[matTooltip]="areCaptionsEnabled() ? 'Disable live captions' : 'Enable live captions'"
>
<mat-icon>{{ areCaptionsEnabled() ? 'subtitles_off' : 'subtitles' }}</mat-icon>
</button>
}
<!-- Copy Link Button -->
@if (showCopyLinkButton()) {
@if (isMobile()) {

View File

@ -1 +1,5 @@
// Additional toolbar buttons styling
#captions-button.active {
background-color: var(--ov-accent-action-color);
}

View File

@ -5,6 +5,7 @@ import { MatIconModule } from '@angular/material/icon';
import { MatMenuModule } from '@angular/material/menu';
import { MatTooltipModule } from '@angular/material/tooltip';
import { LoggerService } from 'openvidu-components-angular';
import { MeetingCaptionsService } from '../../services/meeting-captions.service';
import { MeetingContextService } from '../../services/meeting-context.service';
import { MeetingService } from '../../services/meeting.service';
@ -22,6 +23,7 @@ export class MeetingToolbarExtraButtonsComponent {
protected meetingContextService = inject(MeetingContextService);
protected meetingService = inject(MeetingService);
protected loggerService = inject(LoggerService);
protected captionService = inject(MeetingCaptionsService);
protected log = this.loggerService.get('OpenVidu Meet - MeetingToolbarExtraButtons');
protected readonly copyLinkTooltip = 'Copy the meeting link';
protected readonly copyLinkText = 'Copy meeting link';
@ -29,16 +31,14 @@ export class MeetingToolbarExtraButtonsComponent {
/**
* Whether to show the copy link button
*/
protected showCopyLinkButton = computed(() => {
return this.meetingContextService.canModerateRoom();
});
protected showCopyLinkButton = computed(() => this.meetingContextService.canModerateRoom());
/**
* Whether the device is mobile (affects button style)
*/
protected isMobile = computed(() => {
return this.meetingContextService.isMobile();
});
protected isMobile = computed(() => this.meetingContextService.isMobile());
protected areCaptionsEnabled = computed(() => this.captionService.areCaptionsEnabled());
onCopyLinkClick(): void {
const room = this.meetingContextService.meetRoom();
@ -49,4 +49,8 @@ export class MeetingToolbarExtraButtonsComponent {
this.meetingService.copyMeetingSpeakerLink(room);
}
onCaptionsClick(): void {
this.captionService.areCaptionsEnabled() ? this.captionService.disable() : this.captionService.enable();
}
}

View File

@ -0,0 +1,73 @@
/**
* Represents a single caption entry with participant information
*/
export interface Caption {
/**
* Unique identifier for the caption
*/
id: string;
/**
* Participant's identity (unique identifier)
*/
participantIdentity: string;
/**
* Participant's display name
*/
participantName: string;
/**
* Participant's color profile for visual representation
*/
participantColor: string;
/**
* The transcribed text content
*/
text: string;
/**
* Whether this is a final transcription or interim
*/
isFinal: boolean;
/**
* The track ID being transcribed
*/
trackId: string;
/**
* Timestamp when the caption was created
*/
timestamp: number;
}
/**
* Configuration options for the captions display
*/
export interface CaptionsConfig {
/**
* Maximum number of captions to display simultaneously
* @default 3
*/
maxVisibleCaptions?: number;
/**
* Time in milliseconds before a final caption auto-expires
* @default 5000
*/
finalCaptionDuration?: number;
/**
* Time in milliseconds before an interim caption auto-expires
* @default 3000
*/
interimCaptionDuration?: number;
/**
* Whether to show interim transcriptions (partial results)
* @default true
*/
showInterimTranscriptions?: boolean;
}

View File

@ -1,3 +1,5 @@
export * from './captions.model';
export * from './custom-participant.model';
export * from './layout.model';
export * from './lobby.model';

View File

@ -20,6 +20,7 @@ import { NotificationService } from '../../../../shared/services/notification.se
import { RoomMemberService } from '../../../rooms/services/room-member.service';
import { MeetingLobbyComponent } from '../../components/meeting-lobby/meeting-lobby.component';
import { MeetingParticipantItemComponent } from '../../customization/meeting-participant-item/meeting-participant-item.component';
import { MeetingCaptionsService } from '../../services/meeting-captions.service';
import { MeetingContextService } from '../../services/meeting-context.service';
import { MeetingEventHandlerService } from '../../services/meeting-event-handler.service';
import { MeetingLobbyService } from '../../services/meeting-lobby.service';
@ -76,6 +77,7 @@ export class MeetingComponent implements OnInit {
protected lobbyService = inject(MeetingLobbyService);
protected meetingContextService = inject(MeetingContextService);
protected eventHandlerService = inject(MeetingEventHandlerService);
protected captionsService = inject(MeetingCaptionsService);
protected destroy$ = new Subject<void>();
// === LOBBY PHASE COMPUTED SIGNALS (when showLobby = true) ===
@ -150,6 +152,9 @@ export class MeetingComponent implements OnInit {
// Clear meeting context when component is destroyed
this.meetingContextService.clearContext();
// Cleanup captions service
this.captionsService.destroy();
}
// async onRoomConnected() {
@ -177,6 +182,14 @@ export class MeetingComponent implements OnInit {
// Store LiveKit room in context
this.meetingContextService.setLkRoom(lkRoom);
// Initialize captions service
this.captionsService.initialize(lkRoom, {
maxVisibleCaptions: 3,
finalCaptionDuration: 5000,
interimCaptionDuration: 3000,
showInterimTranscriptions: true
});
// Setup LK room event listeners
this.eventHandlerService.setupRoomListeners(lkRoom);
}

View File

@ -1,3 +1,4 @@
export * from './meeting-captions.service';
export * from './meeting-context.service';
export * from './meeting-event-handler.service';
export * from './meeting-layout.service';

View File

@ -0,0 +1,400 @@
import { Injectable, inject, signal } from '@angular/core';
import { ILogger, LoggerService, ParticipantService, Room, TextStreamReader } from 'openvidu-components-angular';
import { Caption, CaptionsConfig } from '../models/captions.model';
import { CustomParticipantModel } from '../models/custom-participant.model';
/**
* Service responsible for managing live transcription captions.
*
* This service:
* - Registers text stream handlers for LiveKit transcriptions
* - Manages caption lifecycle (creation, updates, expiration)
* - Handles both interim and final transcriptions
* - Provides reactive signals for UI consumption
*
* Follows the single responsibility principle by focusing solely on caption management.
*/
@Injectable({
providedIn: 'root'
})
export class MeetingCaptionsService {
private readonly loggerService = inject(LoggerService);
private readonly logger: ILogger;
private readonly participantService = inject(ParticipantService);
// Configuration with defaults
private readonly defaultConfig: Required<CaptionsConfig> = {
maxVisibleCaptions: 3,
finalCaptionDuration: 5000,
interimCaptionDuration: 3000,
showInterimTranscriptions: true
};
private config: Required<CaptionsConfig> = { ...this.defaultConfig };
// Store room reference for dynamic subscription
private room: Room | null = null;
// Reactive state
private readonly _captions = signal<Caption[]>([]);
private readonly _isEnabled = signal<boolean>(false);
// Public readonly signals
readonly captions = this._captions.asReadonly();
readonly areCaptionsEnabled = this._isEnabled.asReadonly();
// Map to track expiration timeouts
private expirationTimeouts = new Map<string, ReturnType<typeof setTimeout>>();
// Map to track interim captions by participant and track
private interimCaptionMap = new Map<string, string>(); // key: `${participantIdentity}-${trackId}`
constructor() {
this.logger = this.loggerService.get('OpenVidu Meet - MeetingCaptionsService');
}
/**
* Initializes the captions service by registering text stream handlers.
*
* @param room The LiveKit Room instance
* @param config Optional configuration for caption behavior
*/
initialize(room: Room, config?: CaptionsConfig): void {
if (!room) {
this.logger.e('Cannot initialize captions: room is undefined');
return;
}
// Store room reference
this.room = room;
// Merge provided config with defaults
this.config = { ...this.defaultConfig, ...config };
this.logger.d('Meeting Captions service initialized (ready to subscribe)');
}
/**
* Enables captions by registering the transcription handler.
* This is called when the user activates captions.
*/
enable(): void {
if (!this.room) {
this.logger.e('Cannot enable captions: room is not initialized');
return;
}
if (this._isEnabled()) {
this.logger.d('Captions already enabled');
return;
}
// Register the LiveKit transcription handler
this.room.registerTextStreamHandler('lk.transcription', this.handleTranscription.bind(this));
this._isEnabled.set(true);
this.logger.d('Captions enabled');
}
/**
* Disables captions by clearing all captions and stopping transcription.
* This is called when the user deactivates captions.
*/
disable(): void {
if (!this._isEnabled()) {
this.logger.d('Captions already disabled');
return;
}
// Clear all active captions
this.clearAllCaptions();
this._isEnabled.set(false);
this.room?.unregisterTextStreamHandler('lk.transcription');
this.logger.d('Captions disabled');
}
/**
* Cleans up all captions and timers.
*/
destroy(): void {
this.clearAllCaptions();
this.room = null;
this._isEnabled.set(false);
this.logger.d('Meeting Captions service destroyed');
}
/**
* Manually clears all active captions.
*/
clearAllCaptions(): void {
// Clear all expiration timers
this.expirationTimeouts.forEach((timeout) => clearTimeout(timeout));
this.expirationTimeouts.clear();
this.interimCaptionMap.clear();
// Clear captions
this._captions.set([]);
this.logger.d('All captions cleared');
}
/**
* Handles incoming transcription data.
*
* @param data Transcription data from LiveKit
*/
private async handleTranscription(
reader: TextStreamReader,
{ identity: participantIdentity }: { identity: string }
): Promise<void> {
try {
const text = await reader.readAll();
const isFinal = reader.info.attributes?.['lk.transcription_final'] === 'true';
const trackId = reader.info.attributes?.['lk.transcribed_track_id'] || '';
if (!text || text.trim() === '') {
return;
}
// Get full participant model from ParticipantService
const participant = this.participantService.getParticipantByIdentity(
participantIdentity
) as CustomParticipantModel;
if (!participant) {
this.logger.e(`Participant with identity ${participantIdentity} not found for transcription`);
return;
}
// Generate a unique key for this participant's track
const key = `${participantIdentity}-${trackId}`;
if (isFinal) {
// Handle final transcription
this.handleFinalTranscription(key, participant, text, trackId);
} else {
// Handle interim transcription (if enabled)
if (this.config.showInterimTranscriptions) {
this.handleInterimTranscription(key, participant, text, trackId);
}
}
} catch (error) {
this.logger.e('Error reading transcription stream:', error);
}
}
/**
* Handles final transcription by creating or updating a caption.
*
* @param key Unique key for the participant's track
* @param participantIdentity Participant identity
* @param participantName Participant display name
* @param text Transcribed text
* @param trackId Track ID being transcribed
*/
private handleFinalTranscription(
key: string,
participant: CustomParticipantModel,
text: string,
trackId: string
): void {
const currentCaptions = this._captions();
const displayName = participant?.name || participant?.identity;
// Check if there's an interim caption for this key
const interimCaptionId = this.interimCaptionMap.get(key);
if (interimCaptionId) {
// Update existing interim caption to final
const updatedCaptions = currentCaptions.map((caption) => {
if (caption.id === interimCaptionId) {
// Clear old expiration timer
this.clearExpirationTimer(caption.id);
// Return updated caption
return {
...caption,
text,
isFinal: true,
timestamp: Date.now(),
participantName: participant.name || participant.identity,
participantColor: participant.colorProfile
};
}
return caption;
});
this._captions.set(updatedCaptions);
// Set new expiration timer
this.setExpirationTimer(interimCaptionId, this.config.finalCaptionDuration);
// Remove from interim map
this.interimCaptionMap.delete(key);
} else {
// Create new final caption
this.addNewCaption(participant, text, trackId, true);
}
this.logger.d(`Final transcription for ${displayName}: "${text}"`);
}
/**
* Handles interim transcription by creating or updating a caption.
*
* @param key Unique key for the participant's track
* @param participantIdentity Participant identity
* @param participantName Participant display name
* @param text Transcribed text
* @param trackId Track ID being transcribed
*/
private handleInterimTranscription(
key: string,
participant: CustomParticipantModel,
text: string,
trackId: string
): void {
const currentCaptions = this._captions();
const participantName = participant.name || participant.identity;
// Check if there's already an interim caption for this key
const existingInterimId = this.interimCaptionMap.get(key);
if (existingInterimId) {
// Update existing interim caption
const updatedCaptions = currentCaptions.map((caption) => {
if (caption.id === existingInterimId) {
// Clear old expiration timer
this.clearExpirationTimer(caption.id);
// Return updated caption
return {
...caption,
text,
timestamp: Date.now(),
participantName: participant.name || participant.identity,
participantColor: participant.colorProfile
};
}
return caption;
});
this._captions.set(updatedCaptions);
// Reset expiration timer
this.setExpirationTimer(existingInterimId, this.config.interimCaptionDuration);
} else {
// Create new interim caption
const captionId = this.addNewCaption(participant, text, trackId, false);
// Track this interim caption
this.interimCaptionMap.set(key, captionId);
}
this.logger.d(`Interim transcription for ${participantName}: "${text}"`);
}
/**
* Adds a new caption to the list.
*
* @param participantIdentity Participant identity
* @param participantName Participant display name
* @param text Transcribed text
* @param trackId Track ID being transcribed
* @param isFinal Whether this is a final transcription
* @returns The ID of the created caption
*/
private addNewCaption(
participant: CustomParticipantModel,
text: string,
trackId: string,
isFinal: boolean
): string {
const caption: Caption = {
id: this.generateCaptionId(),
participantIdentity: participant.identity,
participantName: participant.name || participant.identity,
participantColor: participant.colorProfile,
text,
isFinal,
trackId,
timestamp: Date.now()
};
const currentCaptions = this._captions();
// Add new caption and limit total number
const updatedCaptions = [...currentCaptions, caption].slice(-this.config.maxVisibleCaptions);
this._captions.set(updatedCaptions);
// Set expiration timer
const duration = isFinal ? this.config.finalCaptionDuration : this.config.interimCaptionDuration;
this.setExpirationTimer(caption.id, duration);
return caption.id;
}
/**
* Sets an expiration timer for a caption.
*
* @param captionId Caption ID
* @param duration Duration in milliseconds
*/
private setExpirationTimer(captionId: string, duration: number): void {
// Clear existing timer if any
this.clearExpirationTimer(captionId);
// Set new timer
const timeout = setTimeout(() => {
this.removeCaption(captionId);
}, duration);
this.expirationTimeouts.set(captionId, timeout);
}
/**
* Clears the expiration timer for a caption.
*
* @param captionId Caption ID
*/
private clearExpirationTimer(captionId: string): void {
const timeout = this.expirationTimeouts.get(captionId);
if (timeout) {
clearTimeout(timeout);
this.expirationTimeouts.delete(captionId);
}
}
/**
* Removes a caption from the list.
*
* @param captionId Caption ID to remove
*/
private removeCaption(captionId: string): void {
this.clearExpirationTimer(captionId);
const currentCaptions = this._captions();
const updatedCaptions = currentCaptions.filter((caption) => caption.id !== captionId);
this._captions.set(updatedCaptions);
// Clean up interim map if necessary
for (const [key, id] of this.interimCaptionMap.entries()) {
if (id === captionId) {
this.interimCaptionMap.delete(key);
break;
}
}
this.logger.d(`Caption ${captionId} removed`);
}
/**
* Generates a unique caption ID.
*
* @returns Unique caption ID
*/
private generateCaptionId(): string {
return `caption-${Date.now()}-${Math.random().toString(36).substring(2, 9)}`;
}
}