@@ -155,7 +157,9 @@ export default function Lobby() {
// we navigate here with javascript instead of an a
// tag because we don't want it to be possible to join
// the room without the JS having loaded
- navigate('room')
+ navigate(
+ 'room' + (params.size > 0 ? '?' + params.toString() : '')
+ )
}}
disabled={!session?.sessionId}
>
diff --git a/app/routes/_room.$roomName.room.tsx b/app/routes/_room.$roomName.room.tsx
index 840b7d34..f26680db 100644
--- a/app/routes/_room.$roomName.room.tsx
+++ b/app/routes/_room.$roomName.room.tsx
@@ -1,10 +1,16 @@
import type { LoaderFunctionArgs } from '@remix-run/cloudflare'
import { json } from '@remix-run/cloudflare'
-import { useLoaderData, useNavigate, useParams } from '@remix-run/react'
+import {
+ useLoaderData,
+ useNavigate,
+ useParams,
+ useSearchParams,
+} from '@remix-run/react'
import { nanoid } from 'nanoid'
import { Fragment, useCallback, useEffect, useMemo, useState } from 'react'
import { Flipper } from 'react-flip-toolkit'
import { useMeasure, useMount, useWindowSize } from 'react-use'
+import { AiButton } from '~/components/AiButton'
import { Button } from '~/components/Button'
import { CameraButton } from '~/components/CameraButton'
import { CopyButton } from '~/components/CopyButton'
@@ -30,7 +36,6 @@ import { useUserJoinLeaveToasts } from '~/hooks/useUserJoinLeaveToasts'
import { calculateLayout } from '~/utils/calculateLayout'
import getUsername from '~/utils/getUsername.server'
import isNonNullable from '~/utils/isNonNullable'
-import { mode } from '~/utils/mode'
export const loader = async ({ request, context }: LoaderFunctionArgs) => {
const username = await getUsername(request)
@@ -44,6 +49,9 @@ export const loader = async ({ request, context }: LoaderFunctionArgs) => {
),
mode: context.mode,
hasDb: Boolean(context.env.DB),
+ hasAiCredentials: Boolean(
+ context.env.OPENAI_API_TOKEN && context.env.OPENAI_MODEL_ENDPOINT
+ ),
})
}
@@ -111,10 +119,12 @@ export default function Room() {
const navigate = useNavigate()
const { roomName } = useParams()
const { mode, bugReportsEnabled } = useLoaderData
()
+ const [search] = useSearchParams()
useEffect(() => {
- if (!joined && mode !== 'development') navigate(`/${roomName}`)
- }, [joined, mode, navigate, roomName])
+ if (!joined && mode !== 'development')
+ navigate(`/${roomName}${search.size > 0 ? '?' + search.toString() : ''}`)
+ }, [joined, mode, navigate, roomName, search])
if (!joined && mode !== 'development') return null
@@ -126,7 +136,7 @@ export default function Room() {
}
function JoinedRoom({ bugReportsEnabled }: { bugReportsEnabled: boolean }) {
- const { hasDb } = useLoaderData()
+ const { hasDb, hasAiCredentials } = useLoaderData()
const {
userMedia,
peer,
@@ -157,7 +167,7 @@ function JoinedRoom({ bugReportsEnabled }: { bugReportsEnabled: boolean }) {
const speaking = useIsSpeaking(userMedia.audioStreamTrack)
useMount(() => {
- if (otherUsers.length > 5 || mode === 'development') {
+ if (otherUsers.length > 5) {
userMedia.turnMicOff()
}
})
@@ -320,6 +330,7 @@ function JoinedRoom({ bugReportsEnabled }: { bugReportsEnabled: boolean }) {
+ {hasAiCredentials &&
}
diff --git a/app/routes/new.tsx b/app/routes/new.tsx
index fa4776b8..38c42602 100644
--- a/app/routes/new.tsx
+++ b/app/routes/new.tsx
@@ -1,9 +1,12 @@
-import { redirect } from '@remix-run/cloudflare'
+import { redirect, type LoaderFunctionArgs } from '@remix-run/cloudflare'
import { nanoid } from 'nanoid'
-export const loader = async () => {
+export const loader = async ({ request }: LoaderFunctionArgs) => {
+ const params = new URL(request.url).searchParams
// we use this path if someone clicks the link
// to create a new room before the js has loaded
const roomName = nanoid(8)
- return redirect('/' + roomName)
+ return redirect(
+ '/' + roomName + (params.size > 0 ? '?' + params.toString() : '')
+ )
}
diff --git a/app/types/Env.ts b/app/types/Env.ts
index 20a452c5..84929a1b 100644
--- a/app/types/Env.ts
+++ b/app/types/Env.ts
@@ -1,19 +1,20 @@
export type Env = {
+ rooms: DurableObjectNamespace
+ CALLS_APP_ID: string
+ CALLS_APP_SECRET: string
USER_DIRECTORY_URL?: string
FEEDBACK_URL?: string
FEEDBACK_QUEUE?: Queue
FEEDBACK_STORAGE?: KVNamespace
- CALLS_APP_ID: string
- CALLS_APP_SECRET: string
TURN_SERVICE_ID?: string
TURN_SERVICE_TOKEN?: string
TRACE_LINK?: string
API_EXTRA_PARAMS?: string
- // limiters: DurableObjectNamespace
- rooms: DurableObjectNamespace
MAX_WEBCAM_FRAMERATE?: string
MAX_WEBCAM_BITRATE?: string
MAX_WEBCAM_QUALITY_LEVEL?: string
MAX_API_HISTORY?: string
DB?: D1Database
+ OPENAI_API_TOKEN?: string
+ OPENAI_MODEL_ENDPOINT?: string
}
diff --git a/app/types/Messages.ts b/app/types/Messages.ts
index 8358fdab..df6d4dd9 100644
--- a/app/types/Messages.ts
+++ b/app/types/Messages.ts
@@ -1,3 +1,5 @@
+import type { TrackObject } from '~/utils/callsTypes'
+
export type User = {
id: string
name: string
@@ -19,6 +21,12 @@ export type User = {
export type RoomState = {
meetingId?: string
users: User[]
+ ai: {
+ enabled: boolean
+ controllingUser?: string
+ error?: string
+ connectionPending?: boolean
+ }
}
export type ServerMessage =
@@ -41,6 +49,10 @@ export type ServerMessage =
| {
type: 'partyserver-pong'
}
+ | {
+ type: 'aiSdp'
+ sdp: string
+ }
export type ClientMessage =
| {
@@ -65,3 +77,18 @@ export type ClientMessage =
| {
type: 'heartbeat'
}
+ | {
+ type: 'enableAi'
+ instructions?: string
+ voice?: string
+ }
+ | {
+ type: 'disableAi'
+ }
+ | {
+ type: 'requestAiControl'
+ track: TrackObject
+ }
+ | {
+ type: 'relenquishAiControl'
+ }
diff --git a/app/utils/openai.server.ts b/app/utils/openai.server.ts
new file mode 100644
index 00000000..0d41fa24
--- /dev/null
+++ b/app/utils/openai.server.ts
@@ -0,0 +1,140 @@
+export interface SessionDescription {
+ sdp: string
+ type: string
+}
+
+interface NewSessionResponse {
+ sessionId: string
+}
+
+interface NewTrackResponse {
+ trackName: string
+ mid: string
+ errorCode?: string
+ errorDescription?: string
+}
+
+interface NewTracksResponse {
+ tracks: NewTrackResponse[]
+ sessionDescription?: SessionDescription
+ errorCode?: string
+ errorDescription?: string
+}
+
+export class CallsSession {
+ sessionId: string
+ headers: any
+ endpoint: string
+ constructor(sessionId: string, headers: any, endpoint: string) {
+ this.sessionId = sessionId
+ this.headers = headers
+ this.endpoint = endpoint
+ }
+ async NewTracks(body: any): Promise
{
+ const newTracksURL = new URL(
+ `${this.endpoint}/sessions/${this.sessionId}/tracks/new?streamDebug&forceTracing=true`
+ )
+ const newTracksResponse = (await fetch(newTracksURL.href, {
+ method: 'POST',
+ headers: this.headers,
+ body: JSON.stringify(body),
+ }).then(async (res) => {
+ console.log(await res.clone().text())
+ return res.json()
+ })) as NewTracksResponse
+ return newTracksResponse
+ }
+ async Renegotiate(sdp: SessionDescription) {
+ const renegotiateBody = {
+ sessionDescription: sdp,
+ }
+ const renegotiateURL = new URL(
+ `${this.endpoint}/sessions/${this.sessionId}/renegotiate?streamDebug&forceTracing=true`
+ )
+ return fetch(renegotiateURL.href, {
+ method: 'PUT',
+ headers: this.headers,
+ body: JSON.stringify(renegotiateBody),
+ })
+ }
+}
+
+const baseURL = 'https://rtc.live.cloudflare.com/apps'
+
+export async function CallsNewSession(
+ appID: string,
+ appToken: string,
+ thirdparty: boolean = false
+): Promise {
+ const headers = {
+ Authorization: `Bearer ${appToken}`,
+ 'Content-Type': 'application/json',
+ }
+ const endpoint = `${baseURL}/${appID}`
+ const newSessionURL = new URL(
+ `${endpoint}/sessions/new?streamDebug&forceTracing=true`
+ )
+ if (thirdparty) {
+ newSessionURL.searchParams.set('thirdparty', 'true')
+ }
+
+ console.log(`Request to: ${newSessionURL.href}`)
+ const sessionResponse = (await fetch(newSessionURL.href, {
+ method: 'POST',
+ headers: headers,
+ })
+ .then(async (res) => {
+ console.log(await res.clone().text())
+ return res
+ })
+
+ .then((res) => res.json())) as NewSessionResponse
+ return new CallsSession(sessionResponse.sessionId, headers, endpoint)
+}
+
+export function checkNewTracksResponse(
+ newTracksResponse: NewTracksResponse,
+ sdpExpected: boolean = false
+): asserts newTracksResponse is {
+ tracks: NewTrackResponse[]
+ sessionDescription: SessionDescription
+} {
+ if (newTracksResponse.errorCode) {
+ throw newTracksResponse.errorDescription
+ }
+ if (newTracksResponse.tracks[0].errorDescription) {
+ throw newTracksResponse.tracks[0].errorDescription
+ }
+ if (sdpExpected && newTracksResponse.sessionDescription == null) {
+ throw 'empty sdp from Calls for session A'
+ }
+}
+
+export async function requestOpenAIService(
+ offer: SessionDescription,
+ openAiKey: string,
+ openAiModelEndpoint: string,
+ // env: Env,
+ searchParams?: URLSearchParams
+): Promise {
+ // const originalRequestURL = new URL(originalRequest.url)
+ console.log(`Request to: ${openAiModelEndpoint}`)
+ const endpointURL = new URL(openAiModelEndpoint)
+ endpointURL.search = searchParams?.toString() ?? ''
+ const response = await fetch(endpointURL.href, {
+ method: 'POST',
+ body: offer.sdp,
+ headers: {
+ Authorization: `Bearer ${openAiKey}`,
+ 'Content-Type': 'application/sdp',
+ },
+ })
+
+ if (response.status >= 400) {
+ const errMessage = await response.text()
+ console.error('Error from OpenAI: ', errMessage)
+ throw new Error(errMessage)
+ }
+ const answerSDP = await response.text()
+ return { type: 'answer', sdp: answerSDP } as SessionDescription
+}
diff --git a/app/utils/playSound/playSound.ts b/app/utils/playSound/playSound.ts
index 48e9eb31..0fb6dba9 100644
--- a/app/utils/playSound/playSound.ts
+++ b/app/utils/playSound/playSound.ts
@@ -1,4 +1,5 @@
import invariant from 'tiny-invariant'
+import aiReady from './sounds/AIReady.mp3'
import join from './sounds/Join.mp3'
import leave from './sounds/Leave.mp3'
import raiseHand from './sounds/RaiseHand.mp3'
@@ -24,12 +25,14 @@ const sounds = {
leave,
join,
raiseHand,
+ aiReady,
}
const volumeMap = {
join: 0.2,
leave: 0.2,
raiseHand: 0.1,
+ aiReady: 0.1,
} satisfies Record
export async function playSound(sound: keyof typeof sounds) {
diff --git a/app/utils/playSound/sounds/AIReady.mp3 b/app/utils/playSound/sounds/AIReady.mp3
new file mode 100644
index 00000000..eeddc233
Binary files /dev/null and b/app/utils/playSound/sounds/AIReady.mp3 differ
diff --git a/app/utils/rxjs/inaudibleAudioTrack$.ts b/app/utils/rxjs/inaudibleAudioTrack$.ts
new file mode 100644
index 00000000..6f91065d
--- /dev/null
+++ b/app/utils/rxjs/inaudibleAudioTrack$.ts
@@ -0,0 +1,29 @@
+import { Observable } from 'rxjs'
+
+export const inaudibleAudioTrack$ = new Observable(
+ (subscriber) => {
+ const audioContext = new window.AudioContext()
+
+ const oscillator = audioContext.createOscillator()
+ oscillator.type = 'triangle'
+ oscillator.frequency.setValueAtTime(20, audioContext.currentTime)
+
+ const gainNode = audioContext.createGain()
+ gainNode.gain.setValueAtTime(0.02, audioContext.currentTime)
+
+ oscillator.connect(gainNode)
+
+ const destination = audioContext.createMediaStreamDestination()
+ gainNode.connect(destination)
+
+ oscillator.start()
+
+ const track = destination.stream.getAudioTracks()[0]
+
+ subscriber.next(track)
+ return () => {
+ track.stop()
+ audioContext.close()
+ }
+ }
+)
diff --git a/app/utils/rxjs/mutedAudioTrack$.ts b/app/utils/rxjs/mutedAudioTrack$.ts
new file mode 100644
index 00000000..493bb885
--- /dev/null
+++ b/app/utils/rxjs/mutedAudioTrack$.ts
@@ -0,0 +1,14 @@
+import { Observable } from 'rxjs'
+
+export const mutedAudioTrack$ = new Observable(
+ (subscriber) => {
+ const audioContext = new window.AudioContext()
+ const destination = audioContext.createMediaStreamDestination()
+ const track = destination.stream.getAudioTracks()[0]
+ subscriber.next(track)
+ return () => {
+ track.stop()
+ audioContext.close()
+ }
+ }
+)