| | import * as THREE from 'three'; |
| | import {OrbitControls} from 'three/examples/jsm/controls/OrbitControls.js'; |
| |
|
| | import ThreeMeshUI, {Block, Text} from 'three-mesh-ui'; |
| |
|
| | import FontJSON from '../assets/RobotoMono-Regular-msdf.json?url'; |
| | import FontImage from '../assets/RobotoMono-Regular.png'; |
| | import {TranslationSentences} from '../types/StreamingTypes'; |
| | import supportedCharSet from './supportedCharSet'; |
| |
|
| | |
| | declare module 'three-mesh-ui' { |
| | interface Block { |
| | add(any: any); |
| | set(props: BlockOptions); |
| | position: { |
| | x: number; |
| | y: number; |
| | z: number; |
| | set: (x: number, y: number, z: number) => void; |
| | }; |
| | } |
| | interface Text { |
| | set(props: {content: string}); |
| | } |
| | } |
| |
|
| | |
| | const INITIAL_PROMPT = 'Listening...\n'; |
| | const NUM_LINES = 3; |
| | const CHARS_PER_LINE = 37; |
| | const CHARS_PER_SECOND = 15; |
| |
|
| | const MAX_WIDTH = 0.89; |
| | const CHAR_WIDTH = 0.0233; |
| | const Y_COORD_START = -0.38; |
| | const Z_COORD = -1.3; |
| | const LINE_HEIGHT = 0.062; |
| | const BLOCK_SPACING = 0.02; |
| | const FONT_SIZE = 0.038; |
| |
|
| | |
| | const SCROLL_Y_DELTA = 0.01; |
| |
|
| | |
| | const OFFSET = 0.01; |
| | const OFFSET_WIDTH = OFFSET * 3; |
| |
|
| | |
| | const CURSOR_BLINK_INTERVAL_MS = 500; |
| |
|
| | type TranscriptState = { |
| | translationText: string; |
| | textBlocksProps: TextBlockProps[]; |
| | lastTranslationStringIndex: number; |
| | lastTranslationLineStartIndex: number; |
| | transcriptLines: string[]; |
| | lastUpdateTime: number; |
| | }; |
| |
|
| | type TextBlockProps = { |
| | content: string; |
| | |
| | targetY: number; |
| | |
| | currentY: number; |
| | textOpacity: number; |
| | backgroundOpacity: number; |
| | index: number; |
| | isBottomLine: boolean; |
| | }; |
| |
|
| | function initialTextBlockProps(count: number): TextBlockProps[] { |
| | return Array.from({length: count}).map(() => { |
| | |
| |
|
| | return { |
| | |
| | targetY: Y_COORD_START, |
| | currentY: Y_COORD_START, |
| | index: 0, |
| | textOpacity: 0, |
| | backgroundOpacity: 0, |
| | width: MAX_WIDTH, |
| | height: LINE_HEIGHT, |
| | content: '', |
| | isBottomLine: true, |
| | }; |
| | }); |
| | } |
| |
|
| | function initialState(): TranscriptState { |
| | return { |
| | translationText: '', |
| | textBlocksProps: initialTextBlockProps(NUM_LINES), |
| | lastTranslationStringIndex: 0, |
| | lastTranslationLineStartIndex: 0, |
| | transcriptLines: [], |
| | lastUpdateTime: new Date().getTime(), |
| | }; |
| | } |
| |
|
| | let transcriptState: TranscriptState = initialState(); |
| |
|
| | let scene: THREE.Scene | null; |
| | let camera: THREE.PerspectiveCamera | null; |
| | let renderer: THREE.WebGLRenderer | null; |
| | let controls: THREE.OrbitControls | null; |
| |
|
| | let cursorBlinkOn: boolean = false; |
| |
|
| | setInterval(() => { |
| | cursorBlinkOn = !cursorBlinkOn; |
| | }, CURSOR_BLINK_INTERVAL_MS); |
| |
|
| | type TextBlock = { |
| | textBlockOuterContainer: Block; |
| | textBlockInnerContainer: Block; |
| | text: Text; |
| | }; |
| | const textBlocks: TextBlock[] = []; |
| |
|
| | export function getRenderer(): THREE.WebGLRenderer | null { |
| | return renderer; |
| | } |
| |
|
| | export function init( |
| | width: number, |
| | height: number, |
| | parentElement: HTMLDivElement | null, |
| | ): THREE.WebGLRenderer { |
| | scene = new THREE.Scene(); |
| | scene.background = new THREE.Color(0x505050); |
| |
|
| | camera = new THREE.PerspectiveCamera(60, width / height, 0.1, 1000); |
| | camera.position.z = 1; |
| |
|
| | renderer = new THREE.WebGLRenderer({ |
| | antialias: true, |
| | }); |
| | renderer.setPixelRatio(window.devicePixelRatio); |
| | renderer.setSize(width, height); |
| | renderer.xr.enabled = true; |
| |
|
| | renderer.xr.setReferenceSpaceType('local'); |
| |
|
| | parentElement?.appendChild(renderer.domElement); |
| |
|
| | controls = new OrbitControls(camera, renderer.domElement); |
| | controls.update(); |
| |
|
| | scene.add(camera); |
| |
|
| | textBlocks.push( |
| | ...initialTextBlockProps(NUM_LINES).map((props) => makeTextBlock(props)), |
| | ); |
| |
|
| | renderer.setAnimationLoop(loop); |
| | return renderer; |
| | } |
| |
|
| | export function updatetranslationText( |
| | translationSentences: TranslationSentences, |
| | ): void { |
| | const newText = INITIAL_PROMPT + translationSentences.join('\n'); |
| | if (transcriptState.translationText === newText) { |
| | return; |
| | } |
| | transcriptState.translationText = newText; |
| | } |
| |
|
| | export function resetState(): void { |
| | transcriptState = initialState(); |
| | } |
| |
|
| | function makeTextBlock({ |
| | content, |
| | backgroundOpacity, |
| | }: TextBlockProps): TextBlock { |
| | const width = MAX_WIDTH; |
| | const height = LINE_HEIGHT; |
| |
|
| | const fontProps = { |
| | fontSize: FONT_SIZE, |
| | textAlign: 'left', |
| | |
| | |
| | |
| | |
| | fontFamily: FontJSON, |
| | fontTexture: FontImage, |
| | }; |
| |
|
| | const textBlockOuterContainer = new Block({ |
| | backgroundOpacity, |
| | width: width + OFFSET_WIDTH, |
| | height: height, |
| | borderRadius: 0, |
| | ...fontProps, |
| | }); |
| |
|
| | const text = new Text({content}); |
| | const textBlockInnerContainer = new Block({ |
| | padding: 0, |
| | backgroundOpacity: 0, |
| | width, |
| | height, |
| | }); |
| |
|
| | |
| | camera.add(textBlockOuterContainer); |
| | textBlockOuterContainer.add(textBlockInnerContainer); |
| | textBlockInnerContainer.add(text); |
| |
|
| | return { |
| | textBlockOuterContainer, |
| | textBlockInnerContainer, |
| | text, |
| | }; |
| | } |
| |
|
| | |
| | function updateTextBlock( |
| | id: number, |
| | {content, targetY, currentY, backgroundOpacity, isBottomLine}: TextBlockProps, |
| | ): void { |
| | const {textBlockOuterContainer, textBlockInnerContainer, text} = |
| | textBlocks[id]; |
| |
|
| | const {lastTranslationStringIndex, translationText} = transcriptState; |
| |
|
| | |
| | const numChars = content.length; |
| |
|
| | if ( |
| | isBottomLine && |
| | cursorBlinkOn && |
| | lastTranslationStringIndex >= translationText.length |
| | ) { |
| | content = content + '|'; |
| | } |
| |
|
| | |
| | const width = |
| | (numChars + (isBottomLine ? 1.1 : 0) + (numChars < 10 ? 1 : 0)) * |
| | CHAR_WIDTH; |
| | const height = LINE_HEIGHT; |
| |
|
| | |
| | const xPosition = width / 2 - MAX_WIDTH / 2 + OFFSET_WIDTH; |
| | textBlockOuterContainer?.set({ |
| | backgroundOpacity, |
| | width: width + 2 * OFFSET_WIDTH, |
| | height: height + OFFSET / 3, |
| | borderRadius: 0, |
| | }); |
| |
|
| | |
| | const y = isBottomLine |
| | ? targetY |
| | : Math.min(currentY + SCROLL_Y_DELTA, targetY); |
| | transcriptState.textBlocksProps[id].currentY = y; |
| |
|
| | textBlockOuterContainer.position.set(-OFFSET_WIDTH + xPosition, y, Z_COORD); |
| | textBlockInnerContainer.set({ |
| | padding: 0, |
| | backgroundOpacity: 0, |
| | width, |
| | height, |
| | }); |
| | text.set({content}); |
| | } |
| |
|
| | |
| | function chunkTranslationTextIntoLines( |
| | translationText: string, |
| | nextTranslationStringIndex: number, |
| | ): string[] { |
| | |
| | const newSentences = translationText |
| | .substring(0, nextTranslationStringIndex) |
| | .split('\n'); |
| | const transcriptLines = ['']; |
| | newSentences.forEach((newSentence, sentenceIdx) => { |
| | const words = newSentence.split(/\s+/); |
| | words.forEach((word) => { |
| | const filteredWord = [...word] |
| | .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('') |
| | |
| | .replace('<unk>', ''); |
| |
|
| | const lastLineSoFar = transcriptLines[0]; |
| | const charCount = lastLineSoFar.length + filteredWord.length + 1; |
| |
|
| | if (charCount <= CHARS_PER_LINE) { |
| | transcriptLines[0] = lastLineSoFar + ' ' + filteredWord; |
| | } else { |
| | transcriptLines.unshift(filteredWord); |
| | } |
| | }); |
| |
|
| | if (sentenceIdx < newSentences.length - 1) { |
| | transcriptLines.unshift('\n'); |
| | transcriptLines.unshift(''); |
| | } |
| | }); |
| | return transcriptLines; |
| | } |
| |
|
| | |
| | function updateTextBlocksProps(): void { |
| | const {translationText, lastTranslationStringIndex, lastUpdateTime} = |
| | transcriptState; |
| |
|
| | const currentTime = new Date().getTime(); |
| | const charsToRender = Math.round( |
| | ((currentTime - lastUpdateTime) * CHARS_PER_SECOND) / 1000, |
| | ); |
| |
|
| | if (charsToRender < 1) { |
| | |
| | return; |
| | } |
| |
|
| | const nextTranslationStringIndex = Math.min( |
| | lastTranslationStringIndex + charsToRender, |
| | translationText.length, |
| | ); |
| | if (nextTranslationStringIndex === lastTranslationStringIndex) { |
| | |
| | transcriptState.lastUpdateTime = currentTime; |
| | return; |
| | } |
| |
|
| | |
| | const transcriptLines = chunkTranslationTextIntoLines( |
| | translationText, |
| | nextTranslationStringIndex, |
| | ); |
| | transcriptState.transcriptLines = transcriptLines; |
| | transcriptState.lastTranslationStringIndex = nextTranslationStringIndex; |
| |
|
| | |
| | const newTextBlocksProps: TextBlockProps[] = []; |
| | |
| | |
| | let y = Y_COORD_START; |
| | transcriptLines.forEach((line, i) => { |
| | if (newTextBlocksProps.length == NUM_LINES) { |
| | return; |
| | } |
| |
|
| | if (line === '\n') { |
| | y += BLOCK_SPACING; |
| | return; |
| | } |
| |
|
| | const isBottomLine = newTextBlocksProps.length === 0; |
| |
|
| | const textOpacity = 1 - 0.1 * newTextBlocksProps.length; |
| |
|
| | const previousProps = transcriptState.textBlocksProps.find( |
| | (props) => props.index === i, |
| | ); |
| | const props = { |
| | targetY: y + LINE_HEIGHT / 2, |
| | currentY: isBottomLine ? y : previousProps?.currentY || y, |
| | index: i, |
| | textOpacity, |
| | backgroundOpacity: 1, |
| | content: line, |
| | isBottomLine, |
| | }; |
| | newTextBlocksProps.push(props); |
| |
|
| | y += LINE_HEIGHT; |
| | }); |
| |
|
| | transcriptState.textBlocksProps = newTextBlocksProps; |
| | transcriptState.lastUpdateTime = currentTime; |
| | } |
| |
|
| | |
| | function loop() { |
| | updateTextBlocksProps(); |
| |
|
| | transcriptState.textBlocksProps.map((props, i) => updateTextBlock(i, props)); |
| |
|
| | ThreeMeshUI.update(); |
| |
|
| | controls.update(); |
| | renderer.render(scene, camera); |
| | } |
| |
|