|
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); |
|
} |
|
|