├── .mise.toml ├── .npmrc ├── src ├── env.d.ts ├── BackendContext.tsx ├── knobs.ts ├── pages │ ├── sender.astro │ ├── view.astro │ └── index.astro ├── components │ ├── TranscriptViewerKnobs.ts │ ├── LogViewer.tsx │ ├── TranscriptViewer.css │ ├── TranscriptViewer.tsx │ └── AudioSender.tsx ├── logbus.ts └── layouts │ └── Layout.astro ├── .vscode ├── extensions.json └── launch.json ├── tsconfig.json ├── astro.config.mjs ├── backend ├── scripts │ ├── TEST_getItems.ts │ ├── createRoom.ts │ ├── whisperTranslator.ts │ ├── server.ts │ ├── partialTranscriber.ts │ └── batchTranscriber.ts └── src │ ├── room.ts │ ├── publicBroadcast.ts │ ├── db.ts │ ├── client.ts │ ├── pubsub.ts │ ├── utterance.ts │ ├── itemOperations.ts │ └── persistence.ts ├── .gitignore ├── public └── favicon.svg ├── package.json └── README.md /.mise.toml: -------------------------------------------------------------------------------- 1 | [tools] 2 | node = "22.10.0" 3 | -------------------------------------------------------------------------------- /.npmrc: -------------------------------------------------------------------------------- 1 | @jsr:registry=https://npm.jsr.io 2 | -------------------------------------------------------------------------------- /src/env.d.ts: -------------------------------------------------------------------------------- 1 | /// -------------------------------------------------------------------------------- /.vscode/extensions.json: -------------------------------------------------------------------------------- 1 | { 2 | "recommendations": ["astro-build.astro-vscode"], 3 | "unwantedRecommendations": [] 4 | } 5 | -------------------------------------------------------------------------------- /src/BackendContext.tsx: -------------------------------------------------------------------------------- 1 | export interface BackendContext { 2 | backend: string; 3 | room: string; 4 | key?: string; 5 | } 6 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "astro/tsconfigs/strict", 3 | "compilerOptions": { 4 | "jsx": "react-jsx", 5 | "jsxImportSource": "react" 6 | } 7 | } -------------------------------------------------------------------------------- /astro.config.mjs: -------------------------------------------------------------------------------- 1 | import { defineConfig } from 'astro/config'; 2 | 3 | import react from "@astrojs/react"; 4 | 5 | // https://astro.build/config 6 | export default defineConfig({ 7 | integrations: [react()] 8 | }); -------------------------------------------------------------------------------- /backend/scripts/TEST_getItems.ts: -------------------------------------------------------------------------------- 1 | import { getItems } from "../src/itemOperations"; 2 | import { Room } from "../src/room"; 3 | 4 | console.log(await getItems(new Room("019296a2-3c00-7b5c-8913-6cfad0b97093"))); 5 | -------------------------------------------------------------------------------- /backend/src/room.ts: -------------------------------------------------------------------------------- 1 | export class Room { 2 | constructor(public name: string) {} 3 | get audioTopic() { 4 | return `${this.name}/audio`; 5 | } 6 | get publicTopic() { 7 | return `${this.name}/public`; 8 | } 9 | } 10 | -------------------------------------------------------------------------------- /.vscode/launch.json: -------------------------------------------------------------------------------- 1 | { 2 | "version": "0.2.0", 3 | "configurations": [ 4 | { 5 | "command": "./node_modules/.bin/astro dev", 6 | "name": "Development server", 7 | "request": "launch", 8 | "type": "node-terminal" 9 | } 10 | ] 11 | } 12 | -------------------------------------------------------------------------------- /src/knobs.ts: -------------------------------------------------------------------------------- 1 | import { atom } from "nanostores"; 2 | 3 | export const $minimumPeak = atom(2.5); 4 | export const $activationThreshold = atom(0.25); 5 | export const $deactivationThreshold = atom(0.2); 6 | export const $maxLength = atom(10); 7 | export const $decayEasing = atom(1.25); 8 | -------------------------------------------------------------------------------- /src/pages/sender.astro: -------------------------------------------------------------------------------- 1 | --- 2 | import { AudioSender } from "../components/AudioSender"; 3 | import Layout from "../layouts/Layout.astro"; 4 | --- 5 | 6 | 7 |
8 | 9 |
10 |
11 | -------------------------------------------------------------------------------- /src/pages/view.astro: -------------------------------------------------------------------------------- 1 | --- 2 | import { TranscriptViewer } from "../components/TranscriptViewer"; 3 | import Layout from "../layouts/Layout.astro"; 4 | --- 5 | 6 | 7 |
8 | 9 |
10 |
11 | -------------------------------------------------------------------------------- /src/pages/index.astro: -------------------------------------------------------------------------------- 1 | --- 2 | import Layout from "../layouts/Layout.astro"; 3 | --- 4 | 5 | 6 |
7 |

Welcome to Live Speech frontend...

8 |

9 | For more information, check out the{ 10 | " " 11 | }https://github.com/dtinth/live-speech 12 |

13 |
14 |
15 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # build output 2 | dist/ 3 | 4 | # generated types 5 | .astro/ 6 | 7 | # dependencies 8 | node_modules/ 9 | 10 | # logs 11 | npm-debug.log* 12 | yarn-debug.log* 13 | yarn-error.log* 14 | pnpm-debug.log* 15 | 16 | # environment variables 17 | .env 18 | .env.production 19 | 20 | # macOS-specific files 21 | .DS_Store 22 | 23 | # jetbrains setting folder 24 | .idea/ 25 | pb_data/ 26 | .data 27 | outputs -------------------------------------------------------------------------------- /backend/src/publicBroadcast.ts: -------------------------------------------------------------------------------- 1 | import { uuidv7 } from "uuidv7"; 2 | import { db } from "./db"; 3 | import { pubsub } from "./pubsub"; 4 | import type { Room } from "./room"; 5 | 6 | export function publicBroadcast(room: Room, method: string, params: any) { 7 | pubsub.publish(room.publicTopic, method, params); 8 | db.roomLogs(room).set(uuidv7(), { 9 | time: new Date().toISOString(), 10 | method, 11 | params, 12 | }); 13 | } 14 | -------------------------------------------------------------------------------- /src/components/TranscriptViewerKnobs.ts: -------------------------------------------------------------------------------- 1 | import { atom } from "nanostores"; 2 | 3 | export const $autoScroll = atom(true); 4 | export const $autoCorrects = atom("โมนัด=>monad"); 5 | 6 | // Save $autoCorrects to sessionStorage 7 | $autoCorrects.subscribe((value) => { 8 | sessionStorage.setItem("autoCorrects", value); 9 | }); 10 | if (sessionStorage.getItem("autoCorrects")) { 11 | $autoCorrects.set(sessionStorage.getItem("autoCorrects")!); 12 | } 13 | -------------------------------------------------------------------------------- /src/logbus.ts: -------------------------------------------------------------------------------- 1 | const logListeners = new Set(); 2 | 3 | export interface LogListener { 4 | onLog(message: string): void; 5 | } 6 | 7 | export function addLogListener(listener: LogListener) { 8 | logListeners.add(listener); 9 | return () => { 10 | logListeners.delete(listener); 11 | }; 12 | } 13 | 14 | export function log(message: string) { 15 | for (const listener of logListeners) { 16 | listener.onLog(message); 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /public/favicon.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 9 | 10 | -------------------------------------------------------------------------------- /backend/src/db.ts: -------------------------------------------------------------------------------- 1 | import { mkdirSync } from "fs"; 2 | import { Partition, Persistence } from "./persistence"; 3 | import type { Room } from "./room"; 4 | 5 | mkdirSync(".data", { recursive: true }); 6 | const persistence = new Persistence(".data/database.sqlite"); 7 | 8 | export const db = { 9 | get audio(): Partition { 10 | return persistence.getPartition("audio"); 11 | }, 12 | get rooms(): Partition { 13 | return persistence.getPartition("rooms"); 14 | }, 15 | roomItems(room: Room): Partition { 16 | return persistence.getPartition(`room_${room.name}`); 17 | }, 18 | roomPartials(room: Room): Partition { 19 | return persistence.getPartition(`partials_${room.name}`); 20 | }, 21 | roomLogs(room: Room): Partition { 22 | return persistence.getPartition(`logs_${room.name}`); 23 | }, 24 | }; 25 | -------------------------------------------------------------------------------- /src/layouts/Layout.astro: -------------------------------------------------------------------------------- 1 | --- 2 | interface Props { 3 | title: string; 4 | } 5 | 6 | const { title } = Astro.props; 7 | --- 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | {title} 18 | 24 | 25 | 26 | 27 | 28 | 29 | -------------------------------------------------------------------------------- /backend/scripts/createRoom.ts: -------------------------------------------------------------------------------- 1 | import chalk from "chalk"; 2 | import { adminApi } from "../src/client"; 3 | 4 | const roomInfo = await adminApi<{ 5 | roomId: string; 6 | roomKey: string; 7 | }>("/admin/rooms", { method: "POST" }); 8 | 9 | const webUrl = process.env["FRONTEND_URL_BASE"]; 10 | const backendUrl = process.env["SERVER_URL_BASE"]; 11 | 12 | console.log(JSON.stringify(roomInfo, null, 2)); 13 | 14 | console.log(` 15 | ${chalk.yellow.bold("Viewer URL:")} 16 | ${webUrl}/view?backend=${backendUrl}&room=${roomInfo.roomId} 17 | 18 | ${chalk.yellow.bold("Editor URL:")} 19 | ${webUrl}/view?backend=${backendUrl}&room=${roomInfo.roomId}&key=${ 20 | roomInfo.roomKey 21 | } 22 | 23 | ${chalk.yellow.bold("Audio Sender URL:")} 24 | ${webUrl}/sender?backend=${backendUrl}&room=${roomInfo.roomId}&key=${ 25 | roomInfo.roomKey 26 | } 27 | 28 | ${chalk.bold("env:")} 29 | SERVER_URL_BASE=${backendUrl} 30 | ROOM_ID=${roomInfo.roomId} 31 | ROOM_KEY=${roomInfo.roomKey} 32 | `); 33 | -------------------------------------------------------------------------------- /backend/src/client.ts: -------------------------------------------------------------------------------- 1 | import { ofetch } from "ofetch"; 2 | 3 | export const publicApi = ofetch.create({ 4 | baseURL: process.env["SERVER_URL_BASE"], 5 | }); 6 | 7 | export const adminApi = publicApi.create({ 8 | headers: { 9 | authorization: `Bearer ${process.env["SERVICE_TOKEN"]}`, 10 | }, 11 | }); 12 | 13 | export function getRoomConfig(): RoomConfig { 14 | const roomId = process.env["ROOM_ID"]; 15 | const roomKey = process.env["ROOM_KEY"]; 16 | 17 | if (!roomId) { 18 | throw new Error("Missing ROOM_ID"); 19 | } 20 | if (!roomKey) { 21 | throw new Error("Missing ROOM_KEY"); 22 | } 23 | 24 | return { roomId, roomKey }; 25 | } 26 | 27 | export type RoomConfig = { roomId: string; roomKey: string }; 28 | 29 | export function createRoomApi({ roomId, roomKey }: RoomConfig) { 30 | return publicApi.create({ 31 | headers: { 32 | authorization: `Bearer ${roomKey}`, 33 | }, 34 | baseURL: `${process.env["SERVER_URL_BASE"]}/rooms/${roomId}`, 35 | }); 36 | } 37 | -------------------------------------------------------------------------------- /backend/src/pubsub.ts: -------------------------------------------------------------------------------- 1 | type Listener = (message: string) => void; 2 | 3 | class PubSub { 4 | private listenerSetMap = new Map>(); 5 | 6 | getListenerSet(channel: string): Set { 7 | if (!this.listenerSetMap.has(channel)) { 8 | this.listenerSetMap.set(channel, new Set()); 9 | } 10 | return this.listenerSetMap.get(channel)!; 11 | } 12 | 13 | subscribe(channel: string, listener: Listener): () => void { 14 | const listeners = this.getListenerSet(channel); 15 | listeners.add(listener); 16 | return () => { 17 | listeners.delete(listener); 18 | }; 19 | } 20 | 21 | publish(channel: string, method: string, params: any): void { 22 | const payload = JSON.stringify({ method, params }); 23 | const listeners = this.getListenerSet(channel); 24 | for (const listener of listeners) { 25 | try { 26 | listener(payload); 27 | } catch (error) { 28 | console.error(error); 29 | } 30 | } 31 | } 32 | } 33 | 34 | export const pubsub = new PubSub(); 35 | -------------------------------------------------------------------------------- /backend/src/utterance.ts: -------------------------------------------------------------------------------- 1 | import { uuidv7 } from "uuidv7"; 2 | import { db } from "./db"; 3 | import { updateItem } from "./itemOperations"; 4 | import { pubsub } from "./pubsub"; 5 | import type { Room } from "./room"; 6 | 7 | export class Utterance { 8 | id = uuidv7(); 9 | start = new Date().toISOString(); 10 | buffers: Buffer[] = []; 11 | 12 | constructor(public room: Room, localTime: string) { 13 | pubsub.publish(room.audioTopic, "audio_start", { id: this.id }); 14 | updateItem(room, this.id, { start: this.start, startLocalTime: localTime }); 15 | } 16 | addAudio(base64: string) { 17 | this.buffers.push(Buffer.from(base64, "base64")); 18 | pubsub.publish(this.room.audioTopic, "audio_data", { id: this.id, base64 }); 19 | } 20 | async finish() { 21 | pubsub.publish(this.room.audioTopic, "audio_finish", { id: this.id }); 22 | const buffer = Buffer.concat(this.buffers); 23 | await db.audio.set(this.id, buffer.toString("base64")); 24 | await updateItem(this.room, this.id, { 25 | finish: new Date().toISOString(), 26 | length: buffer.length, 27 | }); 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /backend/src/itemOperations.ts: -------------------------------------------------------------------------------- 1 | import { db } from "./db"; 2 | import { publicBroadcast } from "./publicBroadcast"; 3 | import { Room } from "./room"; 4 | 5 | export async function getItems(room: Room) { 6 | const output = []; 7 | for await (const [id, data] of db.roomItems(room)) { 8 | if (typeof data !== "object") { 9 | console.error("Invalid item", id); 10 | } else { 11 | output.push({ id, ...data }); 12 | } 13 | } 14 | return output; 15 | } 16 | 17 | export async function getItem(room: Room, id: string) { 18 | const item = await db.roomItems(room).get(id); 19 | return item ? { ...item, id } : null; 20 | } 21 | 22 | export async function updateItem(room: Room, id: string, changes: any) { 23 | const existingItem = (await db.roomItems(room).get(id)) || {}; 24 | const newValue = { 25 | ...existingItem, 26 | ...changes, 27 | changes: [ 28 | ...(existingItem?.changes ?? []), 29 | { payload: changes, time: new Date().toISOString() }, 30 | ], 31 | }; 32 | await db.roomItems(room).set(id, newValue); 33 | publicBroadcast(room, "updated", { ...newValue, id }); 34 | return newValue; 35 | } 36 | -------------------------------------------------------------------------------- /src/components/LogViewer.tsx: -------------------------------------------------------------------------------- 1 | import { useEffect, useRef, useState } from "react"; 2 | import { addLogListener } from "../logbus"; 3 | 4 | export function LogViewer() { 5 | const ref = useRef(null); 6 | const [autoScroll, setAutoScroll] = useState(true); 7 | const autoScrollRef = useRef(autoScroll); 8 | useEffect(() => { 9 | autoScrollRef.current = autoScroll; 10 | }, [autoScroll]); 11 | useEffect(() => { 12 | return addLogListener({ 13 | onLog(message) { 14 | if (ref.current && autoScrollRef.current) { 15 | ref.current.value += message + "\n"; 16 | ref.current.scrollTop = ref.current.scrollHeight; 17 | } 18 | }, 19 | }); 20 | }, []); 21 | return ( 22 |
23 |