Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
27 commits
Select commit Hold shift + click to select a range
6c239b4
feat(meet): port captions branch to suite
BreadGenie Jun 21, 2026
c944ed9
Merge upstream/develop into feat-captions
BreadGenie Jun 28, 2026
c1c44f5
Merge remote-tracking branch 'upstream/develop' into feat-captions
BreadGenie Jul 5, 2026
4a70a0a
feat(meet): polish captions and use Nemotron STT
BreadGenie Jul 5, 2026
9af9bd6
fix(meet): remove stale STT merge artifacts
BreadGenie Jul 5, 2026
fb8f9df
fix(meet): make captions scrollable with history
BreadGenie Jul 6, 2026
6992e0c
feat(meet): stream STT audio over room-scoped WebSockets
BreadGenie Jul 6, 2026
80df056
feat(meet): run Nemotron STT through NeMo
BreadGenie Jul 6, 2026
89a7090
Merge remote-tracking branch 'upstream/develop' into feat-captions
BreadGenie Jul 19, 2026
b180424
refactor: undo drive changes
BreadGenie Jul 19, 2026
784e700
fix(meet): restore unrelated develop changes
BreadGenie Jul 19, 2026
b278330
Merge remote-tracking branch 'upstream/develop' into feat-captions
BreadGenie Aug 4, 2026
aea9eab
ci(meet): publish Nemotron STT image
BreadGenie Aug 4, 2026
d85b0fc
fix(ci): build STT image for fork PRs
BreadGenie Aug 4, 2026
420ab22
feat(meet): add OpenAI-compatible streaming STT
BreadGenie Aug 8, 2026
f27913a
Merge remote-tracking branch 'upstream/develop' into feat-captions
BreadGenie Aug 8, 2026
857e332
feat(meet): use OpenAI Realtime for captions
BreadGenie Aug 8, 2026
6dfe7bc
Merge remote-tracking branch 'upstream/develop' into feat-captions
BreadGenie Aug 10, 2026
2d8f39e
chore(meet): add opt-in STT audio capture
BreadGenie Aug 10, 2026
f37dfc2
fix(meet): preserve STT resampler state across chunks
BreadGenie Aug 10, 2026
842c683
fix(meet): improve final caption accuracy
BreadGenie Aug 11, 2026
f0c744e
Merge remote-tracking branch 'upstream/develop' into feat-captions
BreadGenie Aug 11, 2026
7f073d4
fix(meet): isolate STT tests from Frappe discovery
BreadGenie Aug 12, 2026
70c826c
ci(meet): publish STT image from develop only
BreadGenie Aug 12, 2026
404b974
fix(meet): scope and recover live captions
BreadGenie Aug 12, 2026
2ed52ba
fix(meet): recover STT after later outages
BreadGenie Aug 12, 2026
4ed7895
fix(meet): harden caption recovery
BreadGenie Aug 12, 2026
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
66 changes: 66 additions & 0 deletions .github/workflows/meet-stt-build.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
name: Build and Push Meet STT Image

on:
push:
branches:
- develop
paths:
- "suite/meet/sfu-server/stt-server/**"
- ".github/workflows/meet-stt-build.yml"
workflow_dispatch:
inputs:
tag:
description: "Image tag"
required: false
default: "manual"

concurrency:
group: meet-stt-image-${{ github.ref }}
cancel-in-progress: true

env:
REGISTRY: ghcr.io
IMAGE_NAME: ${{ github.repository_owner }}/suite/nemotron-stt

jobs:
build-and-push:
runs-on: ubuntu-latest
permissions:
contents: read
packages: write

steps:
- name: Checkout code
uses: actions/checkout@v4

- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3

- name: Log in to GHCR
uses: docker/login-action@v3
with:
registry: ${{ env.REGISTRY }}
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}

- name: Extract metadata
id: meta
uses: docker/metadata-action@v5
with:
images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
tags: |
type=ref,event=branch
type=sha,prefix=
type=raw,value=${{ github.event.inputs.tag }},enable=${{ github.event_name == 'workflow_dispatch' }}

- name: Build and push image
uses: docker/build-push-action@v6
with:
context: suite/meet/sfu-server/stt-server
file: suite/meet/sfu-server/stt-server/Dockerfile
platforms: linux/amd64
push: true
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
cache-from: type=gha
cache-to: type=gha,mode=max
144 changes: 144 additions & 0 deletions frontend/src/apps/meet/components/CaptionOverlay.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,144 @@
<template>
<Transition
enter-active-class="transition-all duration-300 ease-out"
enter-from-class="opacity-0 translate-y-2"
enter-to-class="opacity-100 translate-y-0"
leave-active-class="transition-all duration-200 ease-in"
leave-from-class="opacity-100 translate-y-0"
leave-to-class="opacity-0 translate-y-2"
>
<div
v-show="isCaptionsEnabled && lines.length > 0"
class="pointer-events-none z-[40] flex shrink-0 justify-center px-3 pb-2 pt-1 sm:px-6"
>
<div
class="pointer-events-auto relative max-h-[min(20vh,12rem)] w-full max-w-[min(92vw,56rem)] overflow-hidden rounded-lg"
>
<div
v-show="canScrollUp"
class="pointer-events-none absolute inset-x-0 top-0 z-10 h-8 bg-gradient-to-b from-black/90 via-black/55 to-transparent"
/>
<div
ref="scrollContainer"
class="caption-scrollbar-hidden flex max-h-[min(20vh,12rem)] flex-col items-start gap-1 overflow-y-auto overscroll-contain px-1 py-1"
@scroll="updateScrollShadows"
>
<div
v-for="line in visibleLines"
:key="line.id"
:class="[
'flex max-w-full items-start gap-2 rounded-md px-3 py-1.5 text-left text-sm font-medium leading-snug text-white shadow-lg sm:text-base',
{ 'opacity-60 italic': line.text === '...' },
]"
style="
background-color: rgba(0, 0, 0, 0.65);
text-shadow: 0 1px 2px rgba(0, 0, 0, 0.9);
overflow-wrap: anywhere;
"
>
<Avatar
size="sm"
:image="line.avatar"
:label="line.participantName"
class="mt-0.5 shrink-0 ring-1 ring-white/20"
/>
<div class="min-w-0">
<div class="text-xs font-semibold leading-tight text-white/75">
{{ line.participantName }}
</div>
<div>{{ line.text }}</div>
</div>
</div>
</div>
<div
v-show="canScrollDown"
class="pointer-events-none absolute inset-x-0 bottom-0 z-10 h-8 bg-gradient-to-t from-black/90 via-black/55 to-transparent"
/>
</div>
</div>
</Transition>
</template>

<script setup>
import { Avatar } from "frappe-ui";
import { computed, nextTick, ref, watch } from "vue";

const props = defineProps({
isCaptionsEnabled: Boolean,
lines: {
type: Array,
default: () => [],
},
participants: {
type: Object,
default: () => ({}),
},
currentUser: {
type: Object,
default: null,
},
});

const getParticipant = (participantId) => {
if (props.currentUser?.user_id === participantId) {
return {
name: props.currentUser.full_name || props.currentUser.name || "You",
avatar: props.currentUser.avatar || "",
};
}

const participant = props.participants?.[participantId];
return {
name: participant?.user_name || participant?.full_name || participantId,
avatar: participant?.avatar || "",
};
};

const scrollContainer = ref(null);
const canScrollUp = ref(false);
const canScrollDown = ref(false);
const shouldStickToBottom = ref(true);

const visibleLines = computed(() =>
props.lines.map((line) => {
const participant = getParticipant(line.participantId);
return {
...line,
participantName: participant.name || line.participantName,
avatar: participant.avatar,
};
}),
);

const updateScrollShadows = () => {
const el = scrollContainer.value;
if (!el) return;
canScrollUp.value = el.scrollTop > 1;
canScrollDown.value = el.scrollTop + el.clientHeight < el.scrollHeight - 1;
shouldStickToBottom.value = !canScrollDown.value;
};

watch(
() => props.lines.length,
() => {
const stickToBottom = shouldStickToBottom.value;
nextTick(() => {
const el = scrollContainer.value;
if (el && stickToBottom) el.scrollTop = el.scrollHeight;
updateScrollShadows();
});
},
);

watch(visibleLines, () => nextTick(updateScrollShadows), { immediate: true });
</script>

<style scoped>
.caption-scrollbar-hidden {
scrollbar-width: none;
}

.caption-scrollbar-hidden::-webkit-scrollbar {
display: none;
}
</style>
18 changes: 18 additions & 0 deletions frontend/src/apps/meet/components/MeetingToolbar.vue
Original file line number Diff line number Diff line change
Expand Up @@ -164,6 +164,8 @@ import {
watch,
} from "vue";
import LucideBug from "~icons/lucide/bug";
import LucideCaptions from "~icons/lucide/captions";
import LucideCaptionsOff from "~icons/lucide/captions-off";
import { useE2EEState } from "../composables/useE2EEState";
import { useMeetingDoc } from "../composables/useMeetingDoc";
import { usePlatform } from "../composables/usePlatform";
Expand Down Expand Up @@ -212,6 +214,7 @@ const props = defineProps<{
statsVisible?: boolean;
cameraPermissionGranted?: boolean;
microphonePermissionGranted?: boolean;
isCaptionsEnabled?: boolean;
canManageRecording?: boolean;
recordingStatus?: string;
recordingLoading?: boolean;
Expand All @@ -226,6 +229,7 @@ const emit = defineEmits<{
"toggle-screen-share": [];
"toggle-fullscreen": [];
"toggle-raise-hand": [];
"toggle-captions": [];
"report-problem": [];
"toggle-stats": [];
"end-call": [];
Expand Down Expand Up @@ -264,6 +268,20 @@ const moreOptions = computed(() => [
},
]
: []),
...(!isE2EEContextReady.value
? [
{
icon: props.isCaptionsEnabled ? LucideCaptionsOff : LucideCaptions,
label: props.isCaptionsEnabled
? "Disable captions"
: "Enable captions",
onClick: () => {
emit("toggle-captions");
resetHideTimer();
},
},
]
: []),
{
icon: "lucide-settings",
label: "Settings",
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import { SFURequestError } from "../../utils/SFUClient";
import { restoreCaptionSubscription } from "../useCaptions";

function createSfuClient({ connected = true, e2ee = false } = {}) {
return {
isConnected: vi.fn(() => connected),
isE2EERequired: vi.fn(() => e2ee),
sendRequest: vi.fn().mockResolvedValue(undefined),
};
}

describe("restoreCaptionSubscription", () => {
beforeEach(() => {
vi.restoreAllMocks();
});

it("restores an enabled caption subscription", async () => {
const sfuClient = createSfuClient();

const restored = await restoreCaptionSubscription(sfuClient as never, true);

expect(sfuClient.sendRequest).toHaveBeenCalledWith("stt:toggle", {
enabled: true,
});
expect(restored).toBe(true);
});

it.each([
["captions are disabled", false, true, false],
["signaling is disconnected", true, false, false],
["E2EE is required", true, true, true],
])("does not restore when %s", async (_reason, enabled, connected, e2ee) => {
const sfuClient = createSfuClient({ connected, e2ee });

const restored = await restoreCaptionSubscription(
sfuClient as never,
enabled,
);

expect(sfuClient.sendRequest).not.toHaveBeenCalled();
expect(restored).toBe(false);
});

it("reports a failed restoration", async () => {
const error = new Error("request failed");
const sfuClient = createSfuClient();
sfuClient.sendRequest.mockRejectedValue(error);
vi.spyOn(console, "error").mockImplementation(() => {});

const restored = await restoreCaptionSubscription(sfuClient as never, true);

expect(restored).toBe(false);
expect(console.error).toHaveBeenCalledWith(
"Failed to restore captions after reconnect:",
error,
);
});

it("preserves the local preference when the result is ambiguous", async () => {
const sfuClient = createSfuClient();
sfuClient.sendRequest.mockRejectedValue(
new SFURequestError("TIMEOUT", "request timed out"),
);
vi.spyOn(console, "error").mockImplementation(() => {});

const shouldRemainEnabled = await restoreCaptionSubscription(
sfuClient as never,
true,
);

expect(shouldRemainEnabled).toBe(true);
});
});
Loading
Loading