Spaces:
				
			
			
	
			
			
		Runtime error
		
	
	
	
			
			
	
	
	
	
		
		
		Runtime error
		
	| import {useCallback, useEffect, useRef, useState} from 'react'; | |
| import { | |
| Canvas, | |
| createPortal, | |
| extend, | |
| useFrame, | |
| useThree, | |
| } from '@react-three/fiber'; | |
| import ThreeMeshUI from 'three-mesh-ui'; | |
| import {ARButton, XR, Hands, XREvent} from '@react-three/xr'; | |
| import {TextGeometry} from 'three/examples/jsm/geometries/TextGeometry.js'; | |
| import {TranslationSentences} from '../types/StreamingTypes'; | |
| import Button from './Button'; | |
| import {RoomState} from '../types/RoomState'; | |
| import ThreeMeshUIText, {ThreeMeshUITextType} from './ThreeMeshUIText'; | |
| import {BLACK, WHITE} from './Colors'; | |
| /** | |
| * Using `?url` at the end of this import tells vite this is a static asset, and | |
| * provides us a URL to the hashed version of the file when the project is built. | |
| * See: https://vitejs.dev/guide/assets.html#explicit-url-imports | |
| */ | |
| import robotoFontFamilyJson from '../assets/RobotoMono-Regular-msdf.json?url'; | |
| import robotoFontTexture from '../assets/RobotoMono-Regular.png'; | |
| import {getURLParams} from '../URLParams'; | |
| import TextBlocks, {CHARS_PER_LINE} from './TextBlocks'; | |
| import {BufferedSpeechPlayer} from '../createBufferedSpeechPlayer'; | |
| import {CURSOR_BLINK_INTERVAL_MS} from '../cursorBlinkInterval'; | |
| // Adds on react JSX for add-on libraries to react-three-fiber | |
| extend(ThreeMeshUI); | |
| extend({TextGeometry}); | |
| async function fetchSupportedCharSet(): Promise<Set<string>> { | |
| try { | |
| const response = await fetch(robotoFontFamilyJson); | |
| const fontFamily = await response.json(); | |
| return new Set(fontFamily.info.charset); | |
| } catch (e) { | |
| console.error('Failed to fetch supported XR charset', e); | |
| return new Set(); | |
| } | |
| } | |
| let supportedCharSet = new Set(); | |
| fetchSupportedCharSet().then((result) => (supportedCharSet = result)); | |
| // This component wraps any children so it is positioned relative to the camera, rather than from the origin | |
| function CameraLinkedObject({children}) { | |
| const camera = useThree((state) => state.camera); | |
| return createPortal(<>{children}</>, camera); | |
| } | |
| function ThreeMeshUIComponents({ | |
| translationSentences, | |
| skipARIntro, | |
| roomState, | |
| animateTextDisplay, | |
| }: XRConfigProps & {skipARIntro: boolean}) { | |
| // The "loop" for re-rendering required for threemeshUI | |
| useFrame(() => { | |
| ThreeMeshUI.update(); | |
| }); | |
| const [started, setStarted] = useState<boolean>(skipARIntro); | |
| return ( | |
| <> | |
| <CameraLinkedObject> | |
| {getURLParams().ARTranscriptionType === 'single_block' ? ( | |
| <TranscriptPanelSingleBlock | |
| started={started} | |
| animateTextDisplay={animateTextDisplay} | |
| roomState={roomState} | |
| translationSentences={translationSentences} | |
| /> | |
| ) : ( | |
| <TranscriptPanelBlocks | |
| animateTextDisplay={animateTextDisplay} | |
| translationSentences={translationSentences} | |
| /> | |
| )} | |
| {skipARIntro ? null : ( | |
| <IntroPanel started={started} setStarted={setStarted} /> | |
| )} | |
| </CameraLinkedObject> | |
| </> | |
| ); | |
| } | |
| // Original UI that just uses a single block to render 6 lines in a panel | |
| function TranscriptPanelSingleBlock({ | |
| animateTextDisplay, | |
| started, | |
| translationSentences, | |
| roomState, | |
| }: { | |
| animateTextDisplay: boolean; | |
| started: boolean; | |
| translationSentences: TranslationSentences; | |
| roomState: RoomState | null; | |
| }) { | |
| const textRef = useRef<ThreeMeshUITextType>(); | |
| const [didReceiveTranslationSentences, setDidReceiveTranslationSentences] = | |
| useState(false); | |
| const hasActiveTranscoders = (roomState?.activeTranscoders ?? 0) > 0; | |
| const [cursorBlinkOn, setCursorBlinkOn] = useState(false); | |
| // Normally we don't setState in render, but here we need to for computed state, and this if statement assures it won't loop infinitely | |
| if (!didReceiveTranslationSentences && translationSentences.length > 0) { | |
| setDidReceiveTranslationSentences(true); | |
| } | |
| const width = 1; | |
| const height = 0.3; | |
| const fontSize = 0.03; | |
| useEffect(() => { | |
| if (animateTextDisplay && hasActiveTranscoders) { | |
| const interval = setInterval(() => { | |
| setCursorBlinkOn((prev) => !prev); | |
| }, CURSOR_BLINK_INTERVAL_MS); | |
| return () => clearInterval(interval); | |
| } else { | |
| setCursorBlinkOn(false); | |
| } | |
| }, [animateTextDisplay, hasActiveTranscoders]); | |
| useEffect(() => { | |
| if (textRef.current != null) { | |
| const initialPrompt = | |
| 'Welcome to the presentation. We are excited to share with you the work we have been doing... Our model can now translate languages in less than 2 second latency.'; | |
| // These are rough ratios based on spot checking | |
| const maxLines = 6; | |
| const charsPerLine = 55; | |
| const transcriptSentences: string[] = didReceiveTranslationSentences | |
| ? translationSentences | |
| : [initialPrompt]; | |
| // The transcript is an array of sentences. For each sentence we break this down into an array of words per line. | |
| // This is needed so we can "scroll" through without changing the order of words in the transcript | |
| const linesToDisplay = transcriptSentences.flatMap((sentence, idx) => { | |
| const blinkingCursor = | |
| cursorBlinkOn && idx === transcriptSentences.length - 1 ? '|' : ' '; | |
| const words = sentence.concat(blinkingCursor).split(/\s+/); | |
| // Here we break each sentence up with newlines so all words per line fit within the panel | |
| return words.reduce( | |
| (wordChunks, currentWord) => { | |
| const filteredWord = [...currentWord] | |
| .filter((c) => { | |
| if (supportedCharSet.has(c)) { | |
| return true; | |
| } | |
| console.error( | |
| `Unsupported char ${c} - make sure this is supported in the font family msdf file`, | |
| ); | |
| return false; | |
| }) | |
| .join(''); | |
| const lastLineSoFar = wordChunks[wordChunks.length - 1]; | |
| const charCount = lastLineSoFar.length + filteredWord.length + 1; | |
| if (charCount <= charsPerLine) { | |
| wordChunks[wordChunks.length - 1] = | |
| lastLineSoFar + ' ' + filteredWord; | |
| } else { | |
| wordChunks.push(filteredWord); | |
| } | |
| return wordChunks; | |
| }, | |
| [''], | |
| ); | |
| }); | |
| // Only keep the last maxLines so new text keeps scrolling up from the bottom | |
| linesToDisplay.splice(0, linesToDisplay.length - maxLines); | |
| textRef.current.set({content: linesToDisplay.join('\n')}); | |
| } | |
| }, [ | |
| translationSentences, | |
| textRef, | |
| didReceiveTranslationSentences, | |
| cursorBlinkOn, | |
| ]); | |
| const opacity = started ? 1 : 0; | |
| return ( | |
| <block | |
| args={[{padding: 0.05, backgroundOpacity: opacity}]} | |
| position={[0, -0.4, -1.3]}> | |
| <block | |
| args={[ | |
| { | |
| width, | |
| height, | |
| fontSize, | |
| textAlign: 'left', | |
| backgroundOpacity: opacity, | |
| // TODO: support more language charsets | |
| // This renders using MSDF format supported in WebGL. Renderable characters are defined in the "charset" json | |
| // Currently supports most default keyboard inputs but this would exclude many non latin charset based languages. | |
| // You can use https://msdf-bmfont.donmccurdy.com/ for easily generating these files | |
| // fontFamily: '/src/assets/Roboto-msdf.json', | |
| // fontTexture: '/src/assets/Roboto-msdf.png' | |
| fontFamily: robotoFontFamilyJson, | |
| fontTexture: robotoFontTexture, | |
| }, | |
| ]}> | |
| <ThreeMeshUIText | |
| ref={textRef} | |
| content={'Transcript'} | |
| fontOpacity={opacity} | |
| /> | |
| </block> | |
| </block> | |
| ); | |
| } | |
| // Splits up the lines into separate blocks to treat each one separately. | |
| // This allows changing of opacity, animating per line, changing height / width per line etc | |
| function TranscriptPanelBlocks({ | |
| animateTextDisplay, | |
| translationSentences, | |
| }: { | |
| animateTextDisplay: boolean; | |
| translationSentences: TranslationSentences; | |
| }) { | |
| const [didReceiveTranslationSentences, setDidReceiveTranslationSentences] = | |
| // Currently causing issues with displaying dummy text, skip over | |
| useState(false); | |
| // Normally we don't setState in render, but here we need to for computed state, and this if statement assures it won't loop infinitely | |
| if (!didReceiveTranslationSentences && translationSentences.length > 0) { | |
| setDidReceiveTranslationSentences(true); | |
| } | |
| const initialPrompt = 'Listening...'; | |
| const transcriptSentences: string[] = didReceiveTranslationSentences | |
| ? translationSentences | |
| : [initialPrompt]; | |
| // The transcript is an array of sentences. For each sentence we break this down into an array of words per line. | |
| // This is needed so we can "scroll" through without changing the order of words in the transcript | |
| const sentenceLines = transcriptSentences.map((sentence) => { | |
| const words = sentence.split(/\s+/); | |
| // Here we break each sentence up with newlines so all words per line fit within the panel | |
| return words.reduce( | |
| (wordChunks, currentWord) => { | |
| const filteredWord = [...currentWord] | |
| .filter((c) => { | |
| if (supportedCharSet.has(c)) { | |
| return true; | |
| } | |
| console.error( | |
| `Unsupported char ${c} - make sure this is supported in the font family msdf file`, | |
| ); | |
| return false; | |
| }) | |
| .join(''); | |
| const lastLineSoFar = wordChunks[wordChunks.length - 1]; | |
| const charCount = lastLineSoFar.length + filteredWord.length + 1; | |
| if (charCount <= CHARS_PER_LINE) { | |
| wordChunks[wordChunks.length - 1] = | |
| lastLineSoFar + ' ' + filteredWord; | |
| } else { | |
| wordChunks.push(filteredWord); | |
| } | |
| return wordChunks; | |
| }, | |
| [''], | |
| ); | |
| }); | |
| return ( | |
| <TextBlocks sentences={sentenceLines} blinkCursor={animateTextDisplay} /> | |
| ); | |
| } | |
| function IntroPanel({started, setStarted}) { | |
| const width = 0.5; | |
| const height = 0.4; | |
| const padding = 0.03; | |
| // Kind of hacky but making the panel disappear by moving it completely off the camera view. | |
| // If we try to remove elements we end up throwing and stopping the experience | |
| // opacity=0 also runs into weird bugs where not everything is invisible | |
| const xCoordinate = started ? 1000000 : 0; | |
| const commonArgs = { | |
| backgroundColor: WHITE, | |
| width, | |
| height, | |
| padding, | |
| backgroundOpacity: 1, | |
| textAlign: 'center', | |
| fontFamily: robotoFontFamilyJson, | |
| fontTexture: robotoFontTexture, | |
| }; | |
| return ( | |
| <> | |
| <block | |
| args={[ | |
| { | |
| ...commonArgs, | |
| fontSize: 0.02, | |
| }, | |
| ]} | |
| position={[xCoordinate, -0.1, -0.5]}> | |
| <ThreeMeshUIText | |
| content="FAIR Seamless Streaming Demo" | |
| fontColor={BLACK} | |
| /> | |
| </block> | |
| <block | |
| args={[ | |
| { | |
| ...commonArgs, | |
| fontSize: 0.016, | |
| backgroundOpacity: 0, | |
| }, | |
| ]} | |
| position={[xCoordinate, -0.15, -0.5001]}> | |
| <ThreeMeshUIText | |
| fontColor={BLACK} | |
| content="Welcome to the Seamless team streaming demo experience! In this demo, you would experience AI powered text and audio translation in real time." | |
| /> | |
| </block> | |
| <block | |
| args={[ | |
| { | |
| width: 0.1, | |
| height: 0.1, | |
| backgroundOpacity: 1, | |
| backgroundColor: BLACK, | |
| }, | |
| ]} | |
| position={[xCoordinate, -0.23, -0.5002]}> | |
| <Button | |
| onClick={() => setStarted(true)} | |
| content={'Start Experience'} | |
| width={0.2} | |
| height={0.035} | |
| fontSize={0.015} | |
| padding={0.01} | |
| borderRadius={0.01} | |
| /> | |
| </block> | |
| </> | |
| ); | |
| } | |
| export type XRConfigProps = { | |
| animateTextDisplay: boolean; | |
| bufferedSpeechPlayer: BufferedSpeechPlayer; | |
| translationSentences: TranslationSentences; | |
| roomState: RoomState | null; | |
| roomID: string | null; | |
| startStreaming: () => Promise<void>; | |
| stopStreaming: () => Promise<void>; | |
| debugParam: boolean | null; | |
| }; | |
| export default function XRConfig(props: XRConfigProps) { | |
| const {bufferedSpeechPlayer, debugParam} = props; | |
| const skipARIntro = getURLParams().skipARIntro; | |
| const defaultDimensions = {width: 500, height: 500}; | |
| const [dimensions, setDimensions] = useState( | |
| debugParam ? defaultDimensions : {width: 0, height: 0}, | |
| ); | |
| const {width, height} = dimensions; | |
| // Make sure to reset buffer when headset is taken off / on so we don't get an endless stream | |
| // of audio. The oculus actually runs for some time after the headset is taken off. | |
| const resetBuffers = useCallback( | |
| (event: XREvent<XRSessionEvent>) => { | |
| const session = event.target; | |
| if (!(session instanceof XRSession)) { | |
| return; | |
| } | |
| switch (session.visibilityState) { | |
| case 'visible': | |
| bufferedSpeechPlayer.start(); | |
| break; | |
| case 'hidden': | |
| bufferedSpeechPlayer.stop(); | |
| break; | |
| } | |
| }, | |
| [bufferedSpeechPlayer], | |
| ); | |
| return ( | |
| <div style={{height, width, margin: '0 auto', border: '1px solid #ccc'}}> | |
| {/* This is the button that triggers AR flow if available via a button */} | |
| <ARButton | |
| onError={(e) => console.error(e)} | |
| onClick={() => setDimensions(defaultDimensions)} | |
| style={{ | |
| position: 'absolute', | |
| bottom: '24px', | |
| left: '50%', | |
| transform: 'translateX(-50%)', | |
| padding: '12px 24px', | |
| border: '1px solid white', | |
| borderRadius: '4px', | |
| backgroundColor: '#465a69', | |
| color: 'white', | |
| font: 'normal 0.8125rem sans-serif', | |
| outline: 'none', | |
| zIndex: 99999, | |
| cursor: 'pointer', | |
| }} | |
| /> | |
| {/* Canvas to draw if in browser but if in AR mode displays in pass through mode */} | |
| {/* The camera here just works in 2D mode. In AR mode it starts at at origin */} | |
| {/* <Canvas camera={{position: [0, 0, 1], fov: 60}}> */} | |
| <Canvas camera={{position: [0, 0, 0.001], fov: 60}}> | |
| <color attach="background" args={['grey']} /> | |
| <XR referenceSpace="local" onVisibilityChange={resetBuffers}> | |
| {/* | |
| Uncomment this for controllers to show up | |
| <Controllers /> | |
| */} | |
| <Hands /> | |
| {/* | |
| Uncomment this for moving with controllers | |
| <MovementController /> | |
| */} | |
| {/* | |
| Uncomment this for turning the view in non-vr mode | |
| <OrbitControls | |
| autoRotateSpeed={0.85} | |
| zoomSpeed={1} | |
| minPolarAngle={Math.PI / 2.5} | |
| maxPolarAngle={Math.PI / 2.55} | |
| /> | |
| */} | |
| <ThreeMeshUIComponents {...props} skipARIntro={skipARIntro} /> | |
| {/* Just for testing */} | |
| {/* <RandomComponents /> */} | |
| </XR> | |
| </Canvas> | |
| </div> | |
| ); | |
| } | |