├── .github
└── workflows
│ ├── build.yml
│ └── pack.yml
├── .gitignore
├── .pkg-cache
└── v3.4
│ └── built-v14.20.0-win-x64
├── .prettierrc.json
├── .vscode
└── launch.json
├── LICENSE
├── README.md
├── assets
├── Ave#0.png
├── Ave#1.png
├── Ave#2.png
├── ave.ico
├── echo.ico
├── echo.png
├── measure.png
├── moon.png
├── snow-rotate.png
├── snow.png
└── sun.png
├── ave.config.ts
├── docs
└── images
│ ├── echo-usage.png
│ └── logo.png
├── package-lock.json
├── package.json
├── pkg.config.json
├── rollup.config.js
├── src
├── app.tsx
├── asr
│ ├── asr.ts
│ ├── base.ts
│ ├── index.ts
│ └── postasr.ts
├── common
│ └── index.ts
├── config
│ └── index.ts
├── layout
│ └── index.ts
├── nlp
│ ├── base.ts
│ ├── helsinki-nlp.ts
│ └── index.ts
├── resource
│ └── index.ts
├── server
│ └── index.ts
└── shadow
│ ├── common.ts
│ ├── display.ts
│ ├── index.ts
│ ├── measure.ts
│ └── translate.ts
├── tsconfig.json
└── web-ui
├── .gitignore
├── LICENSE
├── README.md
├── package-lock.json
├── package.json
├── public
├── favicon.ico
├── index.html
├── logo.png
├── manifest.json
└── robots.txt
├── src
├── App.css
├── App.test.tsx
├── App.tsx
├── index.css
├── index.tsx
├── lib
│ └── index.ts
├── logo.svg
├── pages
│ ├── home.module.css
│ └── home.tsx
├── react-app-env.d.ts
├── reportWebVitals.ts
└── setupTests.ts
└── tsconfig.json
/.github/workflows/build.yml:
--------------------------------------------------------------------------------
1 | name: build
2 |
3 | on: [push]
4 |
5 | jobs:
6 | build:
7 | runs-on: windows-latest
8 |
9 | strategy:
10 | matrix:
11 | node-version: [14.x]
12 |
13 | steps:
14 | - uses: actions/checkout@v1
15 | - name: Use Node.js ${{ matrix.node-version }}
16 | uses: actions/setup-node@v1
17 | with:
18 | node-version: ${{ matrix.node-version }}
19 | - name: npm install, build
20 | run: |
21 | npm ci
22 | npm run build
23 | env:
24 | CI: true
25 |
--------------------------------------------------------------------------------
/.github/workflows/pack.yml:
--------------------------------------------------------------------------------
1 | name: pack
2 |
3 | on: [push]
4 |
5 | jobs:
6 | pack:
7 | runs-on: windows-latest
8 |
9 | strategy:
10 | matrix:
11 | node-version: [14.x]
12 |
13 | steps:
14 | - uses: actions/checkout@v1
15 | - name: Use Node.js ${{ matrix.node-version }}
16 | uses: actions/setup-node@v1
17 | with:
18 | node-version: ${{ matrix.node-version }}
19 | - name: npm install and pack app
20 | run: |
21 | npm ci
22 | npm run release
23 | env:
24 | CI: true
25 | - uses: actions/upload-artifact@v3
26 | with:
27 | name: echo
28 | path: "bin"
29 | if-no-files-found: error
30 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | node_modules
2 | build
3 | bin
4 | # .pkg-cache
5 | dist
6 | /*.traineddata
7 |
8 | asr-server
9 | asr-server-*
10 | nlp-server
11 | nlp-server-*
12 | nlp-gpu-server
13 | nlp-gpu-server-*
14 | web-ui.png
15 | web-ui-*.png
16 | /config.json
17 | subtitle
18 | perf
19 | log
20 | *.exe
21 |
22 | echo-web-ui
23 | echo-web-ui-*
--------------------------------------------------------------------------------
/.pkg-cache/v3.4/built-v14.20.0-win-x64:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/rerender2021/echo/349597a46017c524bc7217b7ed17e78527593b14/.pkg-cache/v3.4/built-v14.20.0-win-x64
--------------------------------------------------------------------------------
/.prettierrc.json:
--------------------------------------------------------------------------------
1 | {
2 | "arrowParens": "always",
3 | "bracketSpacing": true,
4 | "printWidth": 10000000,
5 | "proseWrap": "never",
6 | "useTabs": true,
7 | "tabWidth": 4
8 | }
9 |
--------------------------------------------------------------------------------
/.vscode/launch.json:
--------------------------------------------------------------------------------
1 | {
2 | "configurations": [
3 | {
4 | "name": "Launch",
5 | "type": "node",
6 | "request": "launch",
7 | "args": [
8 | "./src/app.tsx"
9 | ],
10 | "runtimeArgs": [
11 | "--nolazy",
12 | "-r",
13 | "ts-node/register"
14 | ],
15 | "sourceMaps": true,
16 | "cwd": "${workspaceRoot}",
17 | "protocol": "inspector"
18 | }
19 | ]
20 | }
--------------------------------------------------------------------------------
/LICENSE:
--------------------------------------------------------------------------------
1 | MIT License
2 |
3 | Copyright (c) 2022 qber-soft
4 |
5 | Permission is hereby granted, free of charge, to any person obtaining a copy
6 | of this software and associated documentation files (the "Software"), to deal
7 | in the Software without restriction, including without limitation the rights
8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9 | copies of the Software, and to permit persons to whom the Software is
10 | furnished to do so, subject to the following conditions:
11 |
12 | The above copyright notice and this permission notice shall be included in all
13 | copies or substantial portions of the Software.
14 |
15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21 | SOFTWARE.
22 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 | [](https://github.com/rerender2021/echo/actions/workflows/build.yml) [](https://github.com/rerender2021/echo/actions/workflows/pack.yml)
8 |
9 |
10 |
11 | # 简介
12 |
13 | 回声 (Echo) 是一个简单的翻译器,原理:
14 |
15 | - 使用语音识别,获得文字用于翻译。目前支持离线情况下,英文翻译成中文。
16 | - GUI 部分则是使用 [Ave React](https://qber-soft.github.io/Ave-React-Docs/) 开发的。
17 |
18 | 
19 |
20 | 演示视频见:
21 |
22 | - v1.0.0: [回声:实时英语语音翻译](https://www.bilibili.com/video/BV11L411d7HE/)
23 |
24 | - v1.1.0: [回声更新:支持使用GPU & 长句分解](https://www.bilibili.com/video/BV1Qa4y1M7jV/)
25 |
26 | - v1.2.0: [回声更新:支持历史字幕 & 自助问题排查](https://www.bilibili.com/video/BV1XN411g7tF/)
27 |
28 | # 使用说明
29 |
30 | - 软件首页:https://rerender2021.github.io/products/echo/
31 |
32 | # 开发者向
33 |
34 | ## 本地开发
35 |
36 | ```bash
37 | > npm install
38 | > npm run dev
39 | ```
40 |
41 | 开发过程中需要确保本机启动了语音识别服务器和翻译服务器。
42 |
43 | - 语音识别服务器:[ASR-API 1.1.0](https://github.com/rerender2021/ASR-API/releases/download/1.1.0/asr-server-v1.1.0.zip)
44 | - 翻译服务器:[NLP-API 1.0.1](https://github.com/rerender2021/NLP-API/releases/download/1.0.1/NLP-API-v1.0.1.zip)
45 |
46 | 下载它们,并解压到项目下,确保项目目录结构如下:
47 |
48 | ```
49 | - nlp-server
50 | - NLP-API.exe
51 | - ...
52 | - asr-server-v1.1.0
53 | - ASR-API.exe
54 | - ...
55 | - src
56 | - ...
57 | - package.json
58 | ```
59 |
60 | 如需使用GPU:
61 |
62 | - GPU翻译服务器:下载链接中的2个压缩分卷并解压缩(文件太大,只能分卷压缩上传)
63 | - [NLP-GPU-API 1.0.0](https://github.com/rerender2021/NLP-GPU-API/releases/tag/1.0.0)
64 |
65 |
66 | 下载后,解压到项目下,确保项目目录结构如下:
67 |
68 | ```
69 | - nlp-gpu-server
70 | - NLP-GPU-API.exe
71 | - ...
72 | - asr-server-v1.1.0
73 | - ASR-API.exe
74 | - ...
75 | - src
76 | - ...
77 | - package.json
78 | ```
79 |
80 | ## 功能扩展
81 |
82 | 运行过程中,语音识别和翻译会请求本地接口,因此,不使用以上离线服务器,而是自己起一个服务器对接在线 API,也可正常使用。
83 |
84 | 相关接口和数据结构约定见代码:
85 |
86 | - 语音识别: [./src/asr/asr.ts](./src/asr/asr.ts)
87 | - 翻译: [./src/nlp/helsinki-nlp.ts](./src/nlp/helsinki-nlp.ts)
88 |
89 | ## 打包发布
90 |
91 | - 生成 exe
92 |
93 | ```bash
94 | > npm run release
95 | ```
96 |
97 | # 开源协议
98 |
99 | [MIT](./LICENSE)
100 |
101 | # 赞赏
102 |
103 | `:)` 如果此软件值得赞赏,可以请作者看小说,一元足足可看八章呢。
104 |
105 |
106 |
107 |
108 |
--------------------------------------------------------------------------------
/assets/Ave#0.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/rerender2021/echo/349597a46017c524bc7217b7ed17e78527593b14/assets/Ave#0.png
--------------------------------------------------------------------------------
/assets/Ave#1.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/rerender2021/echo/349597a46017c524bc7217b7ed17e78527593b14/assets/Ave#1.png
--------------------------------------------------------------------------------
/assets/Ave#2.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/rerender2021/echo/349597a46017c524bc7217b7ed17e78527593b14/assets/Ave#2.png
--------------------------------------------------------------------------------
/assets/ave.ico:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/rerender2021/echo/349597a46017c524bc7217b7ed17e78527593b14/assets/ave.ico
--------------------------------------------------------------------------------
/assets/echo.ico:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/rerender2021/echo/349597a46017c524bc7217b7ed17e78527593b14/assets/echo.ico
--------------------------------------------------------------------------------
/assets/echo.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/rerender2021/echo/349597a46017c524bc7217b7ed17e78527593b14/assets/echo.png
--------------------------------------------------------------------------------
/assets/measure.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/rerender2021/echo/349597a46017c524bc7217b7ed17e78527593b14/assets/measure.png
--------------------------------------------------------------------------------
/assets/moon.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/rerender2021/echo/349597a46017c524bc7217b7ed17e78527593b14/assets/moon.png
--------------------------------------------------------------------------------
/assets/snow-rotate.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/rerender2021/echo/349597a46017c524bc7217b7ed17e78527593b14/assets/snow-rotate.png
--------------------------------------------------------------------------------
/assets/snow.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/rerender2021/echo/349597a46017c524bc7217b7ed17e78527593b14/assets/snow.png
--------------------------------------------------------------------------------
/assets/sun.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/rerender2021/echo/349597a46017c524bc7217b7ed17e78527593b14/assets/sun.png
--------------------------------------------------------------------------------
/ave.config.ts:
--------------------------------------------------------------------------------
1 | import { IPackConfig } from "ave-pack";
2 |
3 | const config: IPackConfig = {
4 | build: {
5 | projectRoot: __dirname,
6 | target: "node14-win-x64",
7 | input: "./dist/_/_/app.js",
8 | output: "./bin/echo.exe",
9 | // set DEBUG_PKG=1
10 | debug: false,
11 | edit: false
12 | },
13 | resource: {
14 | icon: "./assets/echo.ico",
15 | productVersion: "1.0.0",
16 | productName: "Echo",
17 | fileVersion: "1.0.0",
18 | companyName: "QberSoft",
19 | fileDescription: "A simple asr translator powered by avernakis react.",
20 | LegalCopyright: `© ${new Date().getFullYear()} QberSoft Copyright.`,
21 | },
22 | };
23 |
24 | export default config;
25 |
--------------------------------------------------------------------------------
/docs/images/echo-usage.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/rerender2021/echo/349597a46017c524bc7217b7ed17e78527593b14/docs/images/echo-usage.png
--------------------------------------------------------------------------------
/docs/images/logo.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/rerender2021/echo/349597a46017c524bc7217b7ed17e78527593b14/docs/images/logo.png
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "echo",
3 | "version": "1.0.0",
4 | "description": "A simple asr translator powered by avernakis react.",
5 | "main": "dist/_/_/app.js",
6 | "private": "true",
7 | "scripts": {
8 | "dev": "tsnd --respawn ./src/app.tsx",
9 | "dev:once": "ts-node ./src/app.tsx",
10 | "prebuild": "del-cli ./build && del-cli ./dist",
11 | "build": "tsc",
12 | "postbuild": "npm run copy && npm run bundle",
13 | "copy": "copyfiles -f ./node_modules/ave-ui/lib/* ./dist/lib && copyfiles ./assets/* ./dist",
14 | "bundle": "rollup --config rollup.config.js",
15 | "prerelease": "npm run build",
16 | "release": "ave-pack pack"
17 | },
18 | "author": "ivjtk; rerender2021",
19 | "license": "MIT",
20 | "devDependencies": {
21 | "@rollup/plugin-commonjs": "^23.0.2",
22 | "@rollup/plugin-json": "^5.0.1",
23 | "@rollup/plugin-node-resolve": "^15.0.1",
24 | "@types/debounce": "^1.2.1",
25 | "@types/express": "^4.17.21",
26 | "@types/node": "^17.0.21",
27 | "@types/react": "^17.0.0",
28 | "ave-pack": "^0.9.4",
29 | "copyfiles": "^2.4.1",
30 | "del-cli": "^4.0.1",
31 | "rollup": "^2.78.0",
32 | "ts-node-dev": "^1.1.8",
33 | "typescript": "^4.6.2"
34 | },
35 | "dependencies": {
36 | "ave-react": "^0.1.4",
37 | "axios": "^1.3.2",
38 | "debounce": "^1.2.1",
39 | "express": "^4.18.2",
40 | "react": "^17.0.0",
41 | "sentence-splitter": "^4.2.0",
42 | "socket.io": "^4.7.2"
43 | }
44 | }
45 |
--------------------------------------------------------------------------------
/pkg.config.json:
--------------------------------------------------------------------------------
1 | {
2 | "assets": [
3 | "dist\\assets\\**\\*",
4 | "dist\\lib\\*.node",
5 | "dist\\_\\_\\build\\*"
6 | ],
7 | "scripts": [
8 | ]
9 | }
--------------------------------------------------------------------------------
/rollup.config.js:
--------------------------------------------------------------------------------
1 | import { nodeResolve } from '@rollup/plugin-node-resolve';
2 | import commonjs from '@rollup/plugin-commonjs';
3 | import json from '@rollup/plugin-json';
4 |
5 | export default [{
6 | input: 'build/src/app.js',
7 | output: {
8 | file: 'dist/_/_/app.js',
9 | format: 'cjs'
10 | },
11 | plugins: [json(), nodeResolve(), commonjs({ ignoreDynamicRequires: true })],
12 | }]
--------------------------------------------------------------------------------
/src/app.tsx:
--------------------------------------------------------------------------------
1 | import React, { useCallback, useEffect, useMemo, useState } from "react";
2 | import { Image, AveRenderer, Grid, Window, getAppContext, IIconResource, IWindowComponentProps, Button, CheckBox, ICheckBoxComponentProps, ScrollBar, Label, IScrollBarComponentProps, Hyperlink } from "ave-react";
3 | import { App, ThemePredefined_Dark, CheckValue } from "ave-ui";
4 | import { VoskAsrEngine } from "./asr";
5 | import { HelsinkiNlpEngine } from "./nlp";
6 | import { containerLayout, controlLayout } from "./layout";
7 | import { iconResource } from "./resource";
8 | import { onMeasure, onTranslate, safe, shadowRelated } from "./shadow";
9 | import { AsrConfig, getWebUiConfig, NlpConfig } from "./config";
10 | import axios from "axios";
11 | import { emitFlushEvent, isInitError, shutdown, startEchoWebUI } from "./server";
12 | import { assetsPath, runtimeAssetsPath } from "./common";
13 |
14 | function onInit(app: App) {
15 | const context = getAppContext();
16 | context.setIconResource(iconResource as unknown as IIconResource);
17 | }
18 |
19 | function initTheme() {
20 | const context = getAppContext();
21 | const themeImage = context.getThemeImage();
22 | const themeDark = new ThemePredefined_Dark();
23 | themeDark.SetStyle(themeImage, 0);
24 | }
25 |
26 | enum ButtonText {
27 | Measure = "设置字幕区",
28 | Recognize = "语音识别",
29 | BreakLongText = "长句分解",
30 | SetTopMost = "字幕置顶",
31 | SubtitleEn = "英文字幕",
32 | SubtitleZh = "中文字幕",
33 | }
34 |
35 | export function Echo() {
36 | const asrEngine = useMemo(
37 | () =>
38 | new VoskAsrEngine({
39 | ...AsrConfig,
40 | }),
41 | []
42 | );
43 | const nlpEngine = useMemo(
44 | () =>
45 | new HelsinkiNlpEngine({
46 | ...NlpConfig,
47 | }),
48 | []
49 | );
50 | const onClose = useCallback(() => {
51 | asrEngine.destroy();
52 | nlpEngine.destroy();
53 | shutdown();
54 | }, []);
55 |
56 | const onSetTopMost = useCallback((sender) => {
57 | let shouldTopMost = true;
58 |
59 | const checkValue = sender.GetValue();
60 | if (checkValue === CheckValue.Unchecked) {
61 | shouldTopMost = false;
62 | } else if (checkValue === CheckValue.Checked) {
63 | shouldTopMost = true;
64 | }
65 |
66 | shadowRelated.displayWindow?.SetTopMost(shouldTopMost);
67 | if (!shadowRelated.displayWindow) {
68 | shadowRelated.defaultTopMost = shouldTopMost;
69 | }
70 | }, []);
71 |
72 | const onSetRecognize = useCallback((sender) => {
73 | shadowRelated.subtitleQueue = [];
74 |
75 | let shouldRecognize = false;
76 |
77 | const checkValue = sender.GetValue();
78 | if (checkValue === CheckValue.Unchecked) {
79 | shouldRecognize = false;
80 | emitFlushEvent();
81 | shadowRelated.onUpdateTranslationResult({ en: "", zh: "" });
82 | } else if (checkValue === CheckValue.Checked) {
83 | shouldRecognize = true;
84 | }
85 |
86 | shadowRelated.shouldRecognize = shouldRecognize;
87 | }, []);
88 |
89 | const onSetBreakLongText = useCallback((sender) => {
90 | let value = false;
91 |
92 | const checkValue = sender.GetValue();
93 | if (checkValue === CheckValue.Unchecked) {
94 | value = false;
95 | } else if (checkValue === CheckValue.Checked) {
96 | value = true;
97 | }
98 |
99 | shadowRelated.shouldBreakLongText = value;
100 | }, []);
101 |
102 | const onSetDisplaySubtitle = useCallback((sender) => {
103 | const checkValue = sender.GetValue();
104 | const text = sender.GetText();
105 | const isChecked = checkValue === CheckValue.Checked;
106 | if (text === ButtonText.SubtitleEn) {
107 | shadowRelated.subtitleConfig.en = isChecked;
108 | } else if (text === ButtonText.SubtitleZh) {
109 | shadowRelated.subtitleConfig.zh = isChecked;
110 | }
111 | shadowRelated.onUpdateTranslationConfig();
112 | }, []);
113 |
114 | const [fontSize, setFontSize] = useState(16);
115 | const onSetFontSize = useCallback((sender) => {
116 | const fontSize = sender.GetValue();
117 | shadowRelated.onUpdateFontSize(fontSize);
118 | setFontSize(fontSize);
119 | }, []);
120 |
121 | const [title, setTitle] = useState("Echo");
122 | const [asrReady, setAsrReady] = useState(false);
123 | const [isError, setIsError] = useState(false);
124 |
125 | useEffect(() => {
126 | initTheme();
127 | asrEngine
128 | .init()
129 | .then(
130 | safe(() => {
131 | setAsrReady(true);
132 | setIsError(isInitError());
133 | })
134 | )
135 | .catch((error) => {
136 | console.error(error?.message);
137 | setIsError(true);
138 | });
139 | nlpEngine
140 | .init()
141 | .then(
142 | safe(async () => {
143 | const port = NlpConfig.nlpPort;
144 | const response = await axios.get(`http://localhost:${port}/gpu`);
145 | if (response.data.gpu === "True") {
146 | console.log("great! use gpu");
147 | setTitle("Echo (GPU)");
148 | } else {
149 | console.log("gpu is not available");
150 | }
151 | setIsError(isInitError());
152 | })
153 | )
154 | .catch((error) => {
155 | console.error(error?.message);
156 | setIsError(true);
157 | });
158 | onTranslate(asrEngine, nlpEngine);
159 | }, []);
160 |
161 | const webUiLink = `http://localhost:${getWebUiConfig().port}`;
162 |
163 | const defaultHomeIconPath = assetsPath("snow.png");
164 | const defaultHomeRotateIconPath = assetsPath("snow-rotate.png");
165 | const customHomeIconPath = runtimeAssetsPath("./web-ui.png");
166 | const customHomeRotateIconPath = runtimeAssetsPath("./web-ui-hover.png");
167 | console.log("icon path", {
168 | customHomeIconPath,
169 | customHomeRotateIconPath
170 | });
171 | const [imgSrc, setImgSrc] = useState(customHomeIconPath ?? defaultHomeIconPath);
172 | const onEnterImage = () => {
173 | setImgSrc(customHomeRotateIconPath ?? defaultHomeRotateIconPath);
174 | };
175 | const onLeaveImage = () => {
176 | setImgSrc(customHomeIconPath ?? defaultHomeIconPath);
177 | };
178 | const gotoWebUi = () => {
179 | // https://stackoverflow.com/a/49013356
180 | const url = webUiLink;
181 | const start = "start";
182 | require("child_process").exec(start + " " + url);
183 | };
184 |
185 | return (
186 |
187 |
188 |
189 |
190 |
191 |
192 | {asrReady && !isError ? (
193 | <>
194 |
195 |
196 |
197 |
198 |
199 |
200 |
201 |
202 |
203 |
204 |
205 |
206 |
207 |
208 |
209 |
210 |
211 |
212 |
213 |
214 |
215 |
216 |
217 |
218 |
219 |
220 |
221 | >
222 | ) : asrReady && isError ? (
223 |
224 | `} onClick={gotoWebUi} />
225 |
226 | ) : (
227 |
228 |
229 |
230 | )}
231 |
232 |
233 |
234 | );
235 | }
236 |
237 | AveRenderer.render();
238 |
239 | startEchoWebUI();
240 |
--------------------------------------------------------------------------------
/src/asr/asr.ts:
--------------------------------------------------------------------------------
1 | import axios from "axios";
2 | import path from "path";
3 | import fs from "fs";
4 | import childProcess from "child_process";
5 | import { IAsrEngine, IAsrEngineOptions, ISentence } from "./base";
6 | import { emptySentence, shadowRelated } from "../shadow";
7 | import { postasr } from "./postasr";
8 | import { inspectLog, ErrorEvent } from "../server";
9 |
10 | enum AsrVersion {
11 | v100,
12 | v110,
13 | v120,
14 | }
15 |
16 | export class VoskAsrEngine implements IAsrEngine {
17 | private options: IAsrEngineOptions;
18 | private asr: childProcess.ChildProcessWithoutNullStreams;
19 | private version: AsrVersion;
20 |
21 | constructor(options: IAsrEngineOptions) {
22 | this.options = options;
23 | this.version = AsrVersion.v100;
24 | }
25 |
26 | getAsrPath() {
27 | const port = this.options.asrPort;
28 | const voskPort = this.options.asrSocketPort;
29 |
30 | const v120 = path.resolve(process.cwd(), "asr-server-v1.2.0");
31 | if (fs.existsSync(v120)) {
32 | this.version = AsrVersion.v120;
33 | console.log("use asr-server-v1.2.0");
34 | return { asrDir: v120, exePath: path.resolve(v120, "./ASR-API.exe"), args: [`--port=${port}`, `--vosk-port=${voskPort}`] };
35 | }
36 |
37 | const v110 = path.resolve(process.cwd(), "asr-server-v1.1.0");
38 | if (fs.existsSync(v110)) {
39 | this.version = AsrVersion.v110;
40 | console.log("use asr-server-v1.1.0");
41 | return { asrDir: v110, exePath: path.resolve(v110, "./ASR-API.exe") };
42 | }
43 |
44 | const v100 = path.resolve(process.cwd(), "asr-server");
45 | if (fs.existsSync(v100)) {
46 | console.log("use asr-server-v1.0.0");
47 | return { asrDir: v100, exePath: path.resolve(v100, "./ASR-API.exe") };
48 | }
49 |
50 | return { asrDir: "", exePath: "" };
51 | }
52 |
53 | async init() {
54 | console.log("try to init vosk asr engine");
55 | const { asrDir, exePath, args = [] } = this.getAsrPath();
56 | if (asrDir && exePath) {
57 | return new Promise((resolve, reject) => {
58 | console.log("asrDir exists, start asr server", asrDir);
59 |
60 | const asr = childProcess.spawn(exePath, args, { windowsHide: true, detached: false /** hide console */ });
61 | this.asr = asr;
62 | asr.stdout.on("data", (data) => {
63 | const isError = inspectLog(data?.toString());
64 | if(isError) {
65 | reject(false);
66 | }
67 | console.log(`stdout: ${data}`);
68 | if (data.includes("has been started")) {
69 | console.log("asr server started");
70 | resolve(true);
71 | }
72 | });
73 |
74 | asr.stderr.on("data", (data) => {
75 | const isError = inspectLog(data?.toString());
76 | if(isError) {
77 | reject(false);
78 | }
79 | console.error(`stderr: ${data}`);
80 | });
81 |
82 | asr.on("close", (code) => {
83 | console.log(`asr server exit: ${code}`);
84 | reject(false);
85 | });
86 | });
87 | } else {
88 | console.log(ErrorEvent.AsrServerNotExist.log);
89 | inspectLog(ErrorEvent.AsrServerNotExist.log);
90 | }
91 | }
92 |
93 | async destroy() {
94 | if (this.asr) {
95 | console.log("exit asr server process");
96 | this.asr.kill();
97 | process.kill(this.asr?.pid);
98 | process.exit();
99 | }
100 | }
101 |
102 | private async asrApi(): Promise {
103 | const port = this.options.asrPort;
104 |
105 | if (this.version === AsrVersion.v100) {
106 | const response = await axios.post(`http://localhost:${port}/asr`, {}, { timeout: 2000 });
107 | const result = response?.data?.result;
108 | const data = JSON.parse(result || "{}");
109 | const asrText = data.partial || "";
110 | return asrText;
111 | } else {
112 | const response = await axios.post(`http://localhost:${port}/asr_queue`, {}, { timeout: 1000 });
113 | const result = response?.data?.result;
114 | const data = JSON.parse(result[result.length - 1] || "{}");
115 | const asrText = data.partial || "";
116 | return asrText;
117 | }
118 | }
119 |
120 | async getAsrResult(): Promise {
121 | let asrResult = "";
122 | try {
123 | asrResult = await this.asrApi();
124 | } catch (error) {
125 | console.log(`asr failed: ${error.message}`);
126 | } finally {
127 | return asrResult;
128 | }
129 | }
130 |
131 | async recognize(): Promise {
132 | let sentence: ISentence = emptySentence;
133 | try {
134 | const asrText = await this.getAsrResult();
135 |
136 | if (shadowRelated.shouldBreakLongText) {
137 | const result = await postasr(asrText);
138 | sentence = { text: result, asr: asrText };
139 | } else {
140 | sentence = { text: asrText, asr: asrText };
141 | }
142 | } catch (error) {
143 | console.log(`asr failed: ${error.message}`);
144 | this.options?.onError(error.message);
145 | } finally {
146 | return sentence;
147 | }
148 | }
149 | }
150 |
--------------------------------------------------------------------------------
/src/asr/base.ts:
--------------------------------------------------------------------------------
1 | export interface ISentence {
2 | text: string;
3 | asr: string
4 | }
5 |
6 | export interface IAsrEngineOptions {
7 | timeout: number
8 | asrPort: number
9 | asrSocketPort: number
10 | onRecognize?: OnRecognize;
11 | onError?: OnError;
12 | }
13 |
14 | export interface IAsrEngineConstructor {
15 | new (options: IAsrEngineOptions): IAsrEngine;
16 | }
17 |
18 | export interface IAsrEngine {
19 | recognize(): Promise;
20 | init(): void;
21 | destroy(): void;
22 | }
23 |
24 | export type OnRecognize = (progress: number) => void;
25 | export type OnError = (message: string) => void;
26 |
--------------------------------------------------------------------------------
/src/asr/index.ts:
--------------------------------------------------------------------------------
1 | export * from "./asr";
2 | export * from "./base";
--------------------------------------------------------------------------------
/src/asr/postasr.ts:
--------------------------------------------------------------------------------
1 | import axios from "axios";
2 | import { split } from "sentence-splitter";
3 | import { AsrConfig } from "../config";
4 |
5 | class SessionManager {
6 | private prevTextLength: number = Number.MAX_SAFE_INTEGER;
7 | isNewSession(asrText: string) {
8 | const result = asrText.length < this.prevTextLength;
9 | return result;
10 | }
11 |
12 | update(asrText: string) {
13 | this.prevTextLength = asrText.length;
14 | }
15 | }
16 |
17 | const sessionManager = new SessionManager();
18 | const maxTextLength = 100;
19 | const longTextLength = 60;
20 | let tokenIndex = 0;
21 |
22 | async function getTextToPunct(asrText: string) {
23 | if (asrText.length >= maxTextLength) {
24 | const port = AsrConfig.asrPort;
25 | const punctResponse = await axios.post(`http://localhost:${port}/punct`, { text: asrText }, { timeout: 1000 });
26 | const withPunct = punctResponse?.data?.text || "";
27 | return withPunct;
28 | } else {
29 | return asrText;
30 | }
31 | }
32 |
33 | function getSubarray(array: any[], from: number, to: number) {
34 | return array.slice(from, to + 1);
35 | }
36 |
37 | function getSentences(withPunct: string) {
38 | const raw = split(withPunct)
39 | ?.map((each) => each?.raw?.trim())
40 | .filter((each) => Boolean(each));
41 | const sentences = [];
42 | raw.forEach((each) => {
43 | if (each.length >= longTextLength) {
44 | sentences.push(...each.split(",").map((each) => each?.trim()));
45 | } else {
46 | sentences.push(each);
47 | }
48 | });
49 | return sentences;
50 | }
51 |
52 | async function punctText(text: string) {
53 | const withPunct = await getTextToPunct(text);
54 | const sentences = getSentences(withPunct);
55 |
56 | // prettier-ignore
57 | const toIgnore = sentences.length === 1 ? [] : (
58 | sentences.length >= 3 ?
59 | getSubarray(sentences, 0, sentences.length - 2) :
60 | [sentences[0]]
61 | );
62 |
63 | let offset = 0;
64 | toIgnore.forEach((each) => {
65 | offset += each.split(" ").length;
66 | });
67 |
68 | const allToken = text.split(" ");
69 | const theRest = getSubarray(allToken, offset, allToken.length - 1);
70 | const lastUnstable = theRest.join(" ");
71 | return { lastUnstable, offset, sentences };
72 | }
73 |
74 | export async function postasr(asrText: string) {
75 | try {
76 | if (asrText) {
77 | const isNewSession = sessionManager.isNewSession(asrText);
78 | sessionManager.update(asrText);
79 | if (isNewSession) {
80 | tokenIndex = 0;
81 | }
82 |
83 | let result = asrText;
84 |
85 | if (asrText.length >= maxTextLength) {
86 | if (tokenIndex === 0) {
87 | const { lastUnstable, offset } = await punctText(asrText);
88 | tokenIndex = offset;
89 | result = lastUnstable;
90 | } else {
91 | const allToken = asrText.split(" ");
92 | const theRest = getSubarray(allToken, tokenIndex, allToken.length - 1);
93 | const currentUnstable = theRest.join(" ");
94 | result = currentUnstable;
95 |
96 | // still long text, punct it again
97 | if (currentUnstable.length >= maxTextLength) {
98 | const { lastUnstable, offset } = await punctText(currentUnstable);
99 | tokenIndex += offset;
100 | result = lastUnstable;
101 | }
102 | }
103 | }
104 | return result;
105 | }
106 | } catch (error) {
107 | console.log(`postasr failed: ${error?.message}`);
108 | }
109 | }
110 |
--------------------------------------------------------------------------------
/src/common/index.ts:
--------------------------------------------------------------------------------
1 | import path from "path";
2 | import fs from "fs";
3 |
4 | export function assetsPath(name: string) {
5 | const root = path.resolve(__dirname, "../../assets");
6 | return path.resolve(root, `./${name}`);
7 | }
8 |
9 | export function runtimeAssetsPath(name: string) {
10 | const root = process.cwd();
11 | const filePath = path.resolve(root, `./${name}`);
12 | return fs.existsSync(filePath) ? filePath : undefined;
13 | }
--------------------------------------------------------------------------------
/src/config/index.ts:
--------------------------------------------------------------------------------
1 | import path from "path";
2 | import fs from "fs";
3 | import { IAsrEngineOptions } from "../asr/base";
4 | import { INlpEngineOptions } from "../nlp/base";
5 |
6 | const defaultConfig = {
7 | /** timeout for asr and translate api call*/
8 | timeout: 3500,
9 | asrPort: 8200,
10 | asrSocketPort: 8210,
11 | nlpPort: 8100,
12 | webUiPort: 8350
13 | };
14 |
15 | export function getConfig() {
16 | const configPath = path.resolve(process.cwd(), "./config.json");
17 | if (!fs.existsSync(configPath)) {
18 | console.log(`config not exist at ${configPath}, create it!`);
19 | fs.writeFileSync(configPath, JSON.stringify(defaultConfig, null, 4), "utf-8");
20 | }
21 |
22 | try {
23 | const configJson = JSON.parse(fs.readFileSync(configPath, "utf-8"));
24 | console.log(`parse config succeed, use it`);
25 | return configJson;
26 | } catch (error) {
27 | console.log(`parse config failed, ${error?.message}, use default config`);
28 | return defaultConfig;
29 | }
30 | }
31 |
32 | export function getWebUiConfig() {
33 | const config = getConfig();
34 | return {
35 | port: config?.webUiPort ?? defaultConfig.webUiPort
36 | }
37 | }
38 |
39 | function getAsrConfig(): IAsrEngineOptions {
40 | const config = getConfig();
41 | return {
42 | timeout: config?.timeout || defaultConfig.timeout,
43 | asrPort: config?.asrPort || defaultConfig.asrPort,
44 | asrSocketPort: config?.asrSocketPort || defaultConfig.asrSocketPort
45 | };
46 | }
47 |
48 | export const AsrConfig: IAsrEngineOptions = getAsrConfig();
49 |
50 | function getNlpConfig(): INlpEngineOptions {
51 | const config = getConfig();
52 | return {
53 | timeout: config?.timeout || defaultConfig.timeout,
54 | nlpPort: config?.nlpPort || defaultConfig.nlpPort
55 | };
56 | }
57 |
58 | export const NlpConfig: INlpEngineOptions = getNlpConfig();
--------------------------------------------------------------------------------
/src/layout/index.ts:
--------------------------------------------------------------------------------
1 | export const containerLayout = {
2 | columns: `16dpx 1 16dpx`,
3 | rows: `1`,
4 | areas: {
5 | control: { row: 0, column: 1 },
6 | },
7 | };
8 |
9 | export const controlLayout = {
10 | columns: `1 1 1 1 1`,
11 | rows: `16dpx 32dpx 16dpx 32dpx 16dpx 32dpx 16dpx 32dpx 16dpx 32dpx 16dpx 16dpx 4dpx 1 32dpx 4dpx 64dpx 8dpx`,
12 | areas: {
13 | measure: { row: 1, column: 0, columnSpan: 5 },
14 | recognize: { row: 3, column: 0, columnSpan: 5 },
15 | breakLongText: { row: 5, column: 0, columnSpan: 2 },
16 | topmost: { row: 7, column: 0, columnSpan: 2 },
17 | zh: { row: 9, column: 0, columnSpan: 2 },
18 | en: { row: 9, column: 2, columnSpan: 2 },
19 | fontSizeLabel: { row: 11, column: 0 },
20 | fontSize: { row: 11, column: 1, columnSpan: 3 },
21 | fontSizeValue: { row: 11, column: 4 },
22 | snow: { row: 16, column: 4 },
23 | },
24 | };
--------------------------------------------------------------------------------
/src/nlp/base.ts:
--------------------------------------------------------------------------------
1 | export interface ITranslateResult {
2 | text: string;
3 | }
4 |
5 | export interface INlpEngineOptions {
6 | timeout: number
7 | nlpPort: number
8 | onTranslate?: OnTranslate;
9 | onError?: OnError;
10 | }
11 |
12 | export interface INlpEngineConstructor {
13 | new (options: INlpEngineOptions): INlpEngine;
14 | }
15 |
16 | export interface INlpEngine {
17 | translate(text: string): Promise;
18 | init(): void;
19 | destroy(): void;
20 | }
21 |
22 | export type OnTranslate = (text: string) => string;
23 | export type OnError = (message: string) => void;
24 |
--------------------------------------------------------------------------------
/src/nlp/helsinki-nlp.ts:
--------------------------------------------------------------------------------
1 | import fs from "fs";
2 | import path from "path";
3 | import axios from "axios";
4 | import childProcess from "child_process";
5 | import { INlpEngine, INlpEngineOptions, ITranslateResult } from "./base";
6 | import { ErrorEvent, inspectLog } from "../server";
7 |
8 | export class HelsinkiNlpEngine implements INlpEngine {
9 | private options: INlpEngineOptions;
10 | private nlp: childProcess.ChildProcessWithoutNullStreams;
11 | private cache: Record;
12 | constructor(options: INlpEngineOptions) {
13 | this.options = options;
14 | this.cache = {};
15 | }
16 |
17 | getNlpPath() {
18 | const gpu = path.resolve(process.cwd(), "nlp-gpu-server");
19 | if (fs.existsSync(gpu)) {
20 | console.log("nlp-gpu-server exists! use it");
21 | return { nlpDir: gpu, exePath: path.resolve(gpu, "./NLP-GPU-API.exe") };
22 | }
23 |
24 | const cpu = path.resolve(process.cwd(), "nlp-server");
25 | if (fs.existsSync(cpu)) {
26 | console.log("use nlp-server");
27 | return { nlpDir: cpu, exePath: path.resolve(cpu, "./NLP-API.exe") };
28 | }
29 |
30 | return { nlpDir: "", exePath: "" };
31 | }
32 |
33 | async init() {
34 | console.log("try to init nlp engine");
35 | const { nlpDir, exePath } = this.getNlpPath();
36 | if (nlpDir && exePath) {
37 | return new Promise((resolve, reject) => {
38 | console.log("nlpDir exists, start nlp server", nlpDir);
39 |
40 | const port = this.options.nlpPort;
41 | const nlp = childProcess.spawn(exePath, [`--lang-from=en`, `--lang-to=zh`, `--model-dir=.\\model`, `--port=${port}`], { windowsHide: true, detached: false /** hide console */ });
42 | this.nlp = nlp;
43 | nlp.stdout.on("data", (data) => {
44 | console.log(`stdout: ${data}`);
45 | const isError = inspectLog(data?.toString());
46 | if(isError) {
47 | reject(false);
48 | }
49 | if (data.includes("has been started")) {
50 | console.log("nlp server started");
51 | resolve(true);
52 | }
53 | });
54 |
55 | nlp.stderr.on("data", (data) => {
56 | const isError = inspectLog(data?.toString());
57 | if(isError) {
58 | reject(false);
59 | }
60 | console.error(`stderr: ${data}`);
61 | });
62 |
63 | nlp.on("close", (code) => {
64 | console.log(`nlp server exit: ${code}`);
65 | reject(false);
66 | });
67 | });
68 | } else {
69 | console.log(ErrorEvent.NlpServerNotExist.log);
70 | inspectLog(ErrorEvent.NlpServerNotExist.log);
71 | }
72 | }
73 |
74 | async destroy() {
75 | if (this.nlp) {
76 | console.log("exit nlp server process");
77 | this.nlp.kill();
78 | process.kill(this.nlp?.pid);
79 | process.exit();
80 | }
81 | }
82 |
83 | async translate(text: string): Promise {
84 | try {
85 | if (this.cache[text]) {
86 | return { text: this.cache[text] };
87 | }
88 | const timeout = this.options.timeout;
89 | const port = this.options.nlpPort;
90 | const translated = await axios.post(
91 | `http://localhost:${port}/translate`,
92 | {
93 | text,
94 | },
95 | { timeout }
96 | );
97 | const result = translated.data.result[0].translation_text;
98 | this.cache[text] = result;
99 | return { text: result };
100 | } catch (error) {
101 | console.log(`translate failed: ${error.message}`);
102 | return { text: "" };
103 | }
104 | }
105 | }
106 |
--------------------------------------------------------------------------------
/src/nlp/index.ts:
--------------------------------------------------------------------------------
1 | export * from "./Helsinki-nlp";
--------------------------------------------------------------------------------
/src/resource/index.ts:
--------------------------------------------------------------------------------
1 | import { assetsPath } from "../common";
2 |
3 | const iconResource = {
4 | size: [16],
5 | path: {
6 | windowIcon: [assetsPath("echo.png")],
7 | "measure": [assetsPath("measure.png")],
8 | "theme-light": [assetsPath("sun.png")],
9 | "theme-dark": [assetsPath("moon.png")]
10 | },
11 | } as const;
12 |
13 | export { iconResource };
14 |
15 | export type IconResourceMapType = Record;
16 |
--------------------------------------------------------------------------------
/src/server/index.ts:
--------------------------------------------------------------------------------
1 | import express from "express";
2 | import path from "path";
3 | import { getWebUiConfig } from "../config";
4 | import { Server } from "socket.io";
5 | import http from "http";
6 |
7 | const app = express();
8 | const server = http.createServer(app);
9 | const io = new Server(server, {
10 | cors: {
11 | origin: "*",
12 | },
13 | });
14 |
15 | const sockets = new Map();
16 |
17 | type SubtitleType = { zh: string; en: string };
18 | const cachedSubtitles: SubtitleType[] = [];
19 |
20 | type ErrorEventType = { log: string; message: string; link?: string };
21 | const cachedErrorEvent: ErrorEventType[] = [];
22 | const emitedError = new Set();
23 | const logHistory: string[] = [];
24 |
25 | export function isInitError() {
26 | return emitedError.size !== 0;
27 | }
28 |
29 | export const ErrorEvent = {
30 | NlpServerNotExist: {
31 | log: "[ERROR] nlp server not exist",
32 | message: "没有找到 NLP 服务器, 请检查目录结构。",
33 | link: "https://rerender2021.github.io/products/echo/#%E4%B8%8B%E8%BD%BD%E5%AE%89%E8%A3%85",
34 | },
35 | AsrServerNotExist: {
36 | log: "[ERROR] asr server not exist",
37 | message: "没有找到语音服务器, 请检查目录结构。",
38 | link: "https://rerender2021.github.io/products/echo/#%E4%B8%8B%E8%BD%BD%E5%AE%89%E8%A3%85",
39 | },
40 | ChineseInPath: {
41 | log: "[ERROR] chinese found in path",
42 | message: "请检查软件路径是否包含中文, 若包含, 需修改为英文。",
43 | },
44 | AsrNotWork: {
45 | log: "[ERROR] asr config error",
46 | message: "语音服务器启动失败, 请检查立体声混音相关配置。",
47 | link: "https://rerender2021.github.io/products/echo/#%E5%B8%B8%E8%A7%81%E9%97%AE%E9%A2%98",
48 | },
49 | PortUsed: {
50 | log: "[ERROR] port used",
51 | message: "端口被占用, 需解除端口占用后再运行。",
52 | link: "https://www.runoob.com/w3cnote/windows-finds-port-usage.html",
53 | },
54 | };
55 |
56 | export function inspectLog(log: string) {
57 | // console.log("inspect log", { log });
58 | logHistory.push(log);
59 | if (log === ErrorEvent.NlpServerNotExist.log) {
60 | emitErorrEvent(ErrorEvent.NlpServerNotExist);
61 | return true;
62 | } else if (log === ErrorEvent.AsrServerNotExist.log) {
63 | emitErorrEvent(ErrorEvent.AsrServerNotExist);
64 | return true;
65 | } else if (log.includes("WinError 1225") || log.includes("character maps to ")) {
66 | emitErorrEvent(ErrorEvent.ChineseInPath);
67 | return true;
68 | } else if (log.includes("websockets.server:connection open")) {
69 | const asrDone = logHistory.find((each) => each.includes("VoskAPI") && each.includes("Done"));
70 | if (!asrDone) {
71 | emitErorrEvent(ErrorEvent.AsrNotWork);
72 | return true;
73 | }
74 | } else if (log.includes("error while attempting to bind on address")) {
75 | const port = log?.split("127.0.0.1', ")?.[1]?.substring(0, 4);
76 | if (port) {
77 | emitErorrEvent({
78 | log: `${port} ${ErrorEvent.PortUsed.log}`,
79 | message: `${port} ${ErrorEvent.PortUsed.message}`,
80 | link: ErrorEvent.PortUsed.link,
81 | });
82 | return true;
83 | }
84 | }
85 |
86 | return false;
87 | }
88 |
89 | function emitErorrEvent(event: ErrorEventType) {
90 | if (sockets.size === 0) {
91 | cachedErrorEvent.push(event);
92 | console.log("[EMIT] cache error event", { event });
93 | } else {
94 | // emit cached
95 | if (cachedErrorEvent.length !== 0) {
96 | emitCachedErrorEvent();
97 | }
98 |
99 | // emit current
100 | emitEchoError(event);
101 |
102 | // send log history
103 | io.emit("log-history", { logHistory });
104 | }
105 | }
106 |
107 | function emitEchoError(event: ErrorEventType) {
108 | if (!emitedError.has(event.log)) {
109 | io.emit("echo-error", event);
110 | console.log("[EMIT] emit error event", { event });
111 | emitedError.add(event.log);
112 | }
113 | }
114 |
115 | function emitCachedErrorEvent() {
116 | console.log("[EMIT] emit cached error event");
117 | cachedErrorEvent.forEach((event) => {
118 | emitEchoError(event);
119 | });
120 | cachedErrorEvent.splice(0, cachedErrorEvent.length);
121 | }
122 |
123 | // https://socket.io/get-started/chat#integrating-socketio
124 | export function emitSubtitleEvent(subtitle: SubtitleType) {
125 | if (sockets.size === 0) {
126 | cachedSubtitles.push(subtitle);
127 | } else {
128 | if (cachedSubtitles.length !== 0) {
129 | emitCachedSubtitleEvent();
130 | }
131 | io.emit("subtitle", subtitle);
132 | }
133 | }
134 |
135 | function emitCachedSubtitleEvent() {
136 | cachedSubtitles.forEach((subtitle) => {
137 | io.emit("subtitle", subtitle);
138 | });
139 | cachedSubtitles.splice(0, cachedSubtitles.length);
140 | }
141 |
142 | export function emitFlushEvent() {
143 | io.emit("flush");
144 | }
145 |
146 | export function shutdown() {
147 | server.close();
148 | io.close();
149 | process.exit();
150 | }
151 |
152 | export function startEchoWebUI() {
153 | const root = path.resolve(process.cwd(), "./echo-web-ui-v1.2.0");
154 | app.use(express.static(root));
155 |
156 | const { port } = getWebUiConfig();
157 |
158 | app.get("/", (req, res) => {
159 | res.send("Hello Echo!");
160 | });
161 |
162 | io.on("connection", (socket) => {
163 | console.log("a client connected");
164 | sockets.set(socket, {
165 | connected: true,
166 | });
167 |
168 | if (cachedErrorEvent.length !== 0) {
169 | emitCachedErrorEvent();
170 | }
171 |
172 | if (cachedSubtitles.length !== 0) {
173 | emitCachedSubtitleEvent();
174 | }
175 |
176 | socket.on("disconnect", (reason) => {
177 | sockets.delete(socket);
178 | });
179 | });
180 |
181 | process.on('exit', function (){
182 | shutdown();
183 | console.log('process end!');
184 | });
185 |
186 | try {
187 | server.listen(port, () => {
188 | console.log(`echo web ui server listening on port ${port}`);
189 | });
190 |
191 | server.on('error', (error) => {
192 | shutdown();
193 | console.error(error);
194 | });
195 |
196 | } catch (error) {
197 | console.error(error);
198 | }
199 |
200 | }
201 |
--------------------------------------------------------------------------------
/src/shadow/common.ts:
--------------------------------------------------------------------------------
1 | import { getAppContext } from "ave-react";
2 | import { IGridControl, Vec2, Grid as NativeGrid, Window as NativeWindow } from "ave-ui";
3 | import { ISentence } from "../asr";
4 |
5 | export interface ISubtitle {
6 | zh: string;
7 | en: string;
8 | }
9 |
10 | export interface ISubtitleConfig {
11 | zh: boolean;
12 | en: boolean;
13 | }
14 |
15 | export type ShadowRelatedType = {
16 | prevSentence: ISentence;
17 | prevTranslation: string;
18 | shouldTranslate: boolean;
19 | shouldRecognize: boolean;
20 | shouldBreakLongText: boolean;
21 | subtitleQueue: Array;
22 | subtitleConfig: ISubtitleConfig;
23 | measureWindow: NativeWindow;
24 | selected: IGridControl;
25 | start: Vec2;
26 | end: Vec2;
27 | current: Vec2;
28 | selectedArea: {
29 | start: Vec2;
30 | end: Vec2;
31 | };
32 | displayWindow: NativeWindow;
33 | defaultTopMost: boolean;
34 | selectedAreaIsEmpty(): boolean;
35 | onUpdateTranslationResult: (subtitle: ISubtitle) => void;
36 | onUpdateTranslationConfig: () => void;
37 | onUpdateFontSize: (size: number) => void;
38 | };
39 |
40 | export const emptySentence: ISentence = { text: "", asr: "" };
41 |
42 | export const shadowRelated: ShadowRelatedType = {
43 | prevSentence: emptySentence,
44 | prevTranslation: "",
45 | shouldTranslate: false,
46 | shouldRecognize: false,
47 | shouldBreakLongText: false,
48 | subtitleQueue: [],
49 | subtitleConfig: {
50 | en: true,
51 | zh: true,
52 | },
53 | measureWindow: null,
54 | selected: null,
55 | start: null,
56 | end: null,
57 | current: null,
58 | selectedArea: {
59 | start: new Vec2(0, 0),
60 | end: new Vec2(0, 0),
61 | },
62 | displayWindow: null,
63 | defaultTopMost: true,
64 | selectedAreaIsEmpty(this: ShadowRelatedType) {
65 | return this.selectedArea.start.x === 0 && this.selectedArea.start.y === 0 && this.selectedArea.end.x === 0 && this.selectedArea.end.x === 0;
66 | },
67 | onUpdateTranslationResult: () => {},
68 | onUpdateTranslationConfig: () => {},
69 | onUpdateFontSize: () => {},
70 | };
71 |
72 | globalThis.shadowRelated = shadowRelated;
73 |
74 | export function safe(callback: Function) {
75 | return (...args: any[]) => {
76 | try {
77 | return callback(...args);
78 | } catch (error) {
79 | console.error(error);
80 | }
81 | };
82 | }
83 |
84 | export function getPrimaryMonitor() {
85 | const context = getAppContext();
86 | const window = context.getWindow();
87 | const platform = window.GetPlatform();
88 | const monitors = platform.MonitorEnumerate();
89 | const primary = monitors.find((each) => each.Primary);
90 | return primary;
91 | }
92 |
93 | export async function sleep(time: number) {
94 | return new Promise((resolve) => setTimeout(resolve, time));
95 | }
96 |
--------------------------------------------------------------------------------
/src/shadow/display.ts:
--------------------------------------------------------------------------------
1 | import { safe, shadowRelated } from "./common";
2 | import { WindowFramePart, DpiMargin, RichLabelTextColor, Byo2Font, AlignType, RichLabel as NativeRichLabel, DpiSize, DockMode, Vec2, Vec4, Grid as NativeGrid, Window as NativeWindow, WindowFlag, WindowCreation } from "ave-ui";
3 | import { emitSubtitleEvent } from "../server";
4 |
5 | export const onDisplay = safe(async function () {
6 | if (!shadowRelated.displayWindow) {
7 | console.log("display window not initialized, init it");
8 | const cp = new WindowCreation();
9 | cp.Flag |= WindowFlag.Layered;
10 | cp.Title = "Display";
11 |
12 | const width = shadowRelated.selectedArea.end.x - shadowRelated.selectedArea.start.x;
13 | const height = shadowRelated.selectedArea.end.y - shadowRelated.selectedArea.start.y;
14 |
15 | cp.Layout.Size = new Vec2(width || 300, height || 120);
16 | cp.Layout.Position = new Vec2(shadowRelated.selectedArea.start.x, shadowRelated.selectedArea.start.y);
17 |
18 | shadowRelated.displayWindow = new NativeWindow(cp);
19 | }
20 |
21 | if (!shadowRelated.displayWindow.IsWindowCreated()) {
22 | console.log("display window not created, create it");
23 |
24 | shadowRelated.displayWindow.OnCreateContent(
25 | safe(() => {
26 | console.log("display window create content callback");
27 |
28 | shadowRelated.displayWindow.SetBackground(false);
29 | shadowRelated.displayWindow.SetTopMost(shadowRelated.defaultTopMost);
30 |
31 | const frame = shadowRelated.displayWindow.GetFrame();
32 | frame.SetCaptionVisible(false);
33 | frame.OnNcHitTest(
34 | safe((sender, pos, part) => {
35 | if (part == WindowFramePart.Client) return WindowFramePart.Caption;
36 | return part;
37 | })
38 | );
39 |
40 | const container = new NativeGrid(shadowRelated.displayWindow);
41 | {
42 | const content = new NativeGrid(shadowRelated.displayWindow);
43 | const color = new Vec4(0, 0, 0, 255);
44 | content.SetBackColor(color);
45 | content.SetOpacity(0.5);
46 | container.ControlAdd(content).SetDock(DockMode.Fill);
47 |
48 | function createSubtitle() {
49 | const fd = shadowRelated.displayWindow.GetTheme().GetFont();
50 | fd.Size = 16;
51 | const fontDef = new Byo2Font(shadowRelated.displayWindow, fd);
52 |
53 | const textColor = new RichLabelTextColor();
54 | textColor.Text.Color = new Vec4(255, 255, 255, 255);
55 |
56 | const label = new NativeRichLabel(shadowRelated.displayWindow);
57 | label.FmSetDefaultFont(fontDef);
58 | label.FmSetDefaultTextColor(textColor);
59 |
60 | label.SetAlignHorz(AlignType.Near);
61 | label.SetAlignVert(AlignType.Center);
62 | return label;
63 | }
64 |
65 | // TODO: crash when use ""
66 | const en = createSubtitle();
67 | const zh = createSubtitle();
68 | en.SetText(" ");
69 | zh.SetText(" ");
70 |
71 | const subtitle = new NativeGrid(shadowRelated.displayWindow);
72 | subtitle.RowAddSlice(...[1]);
73 | subtitle.RowAddDpx(...[2]);
74 | subtitle.RowAddSlice(...[1]);
75 | subtitle.ColAddSlice(...[1]);
76 |
77 | const margin = new DpiMargin(
78 | DpiSize.FromPixelScaled(50), // margin left
79 | DpiSize.FromPixelScaled(5), // margin top
80 | DpiSize.FromPixelScaled(50), // margin right
81 | DpiSize.FromPixelScaled(5) // margin bottom
82 | );
83 | const enGrid = subtitle.ControlAdd(en).SetGrid(0, 0).SetMargin(margin);
84 | const zhGrid = subtitle.ControlAdd(zh).SetGrid(0, 2).SetMargin(margin);
85 | container.ControlAdd(subtitle).SetGrid(0, 0);
86 |
87 | shadowRelated.onUpdateFontSize = safe((size: number) => {
88 | const fd = shadowRelated.displayWindow.GetTheme().GetFont();
89 | fd.Size = size;
90 | const fontDef = new Byo2Font(shadowRelated.displayWindow, fd);
91 |
92 | en.FmSetDefaultFont(fontDef);
93 | zh.FmSetDefaultFont(fontDef);
94 | shadowRelated.displayWindow.Redraw();
95 | });
96 | shadowRelated.onUpdateTranslationResult = safe((subtitle: { zh: string; en: string }) => {
97 | emitSubtitleEvent(subtitle);
98 | en.SetText(subtitle.en || " ");
99 | zh.SetText(subtitle.zh || " ");
100 | console.log("update subtitle", { subtitle });
101 | shadowRelated.displayWindow.Redraw();
102 | });
103 |
104 | shadowRelated.onUpdateTranslationConfig = safe(() => {
105 | const config = shadowRelated.subtitleConfig;
106 | if (config.en && !config.zh) {
107 | enGrid.SetGrid(0, 0, 1, 3);
108 | en.SetOpacity(1);
109 | zh.SetOpacity(0);
110 | } else if (!config.en && config.zh) {
111 | zhGrid.SetGrid(0, 0, 1, 3);
112 | en.SetOpacity(0);
113 | zh.SetOpacity(1);
114 | } else if (!config.en && !config.zh) {
115 | en.SetOpacity(0);
116 | enGrid.SetGrid(0, 0);
117 | zh.SetOpacity(0);
118 | zhGrid.SetGrid(0, 2);
119 | } else if (config.en && config.zh) {
120 | en.SetOpacity(1);
121 | enGrid.SetGrid(0, 0);
122 | zh.SetOpacity(1);
123 | zhGrid.SetGrid(0, 2);
124 | }
125 |
126 | shadowRelated.displayWindow.Redraw();
127 | });
128 | }
129 |
130 | shadowRelated.displayWindow.SetSize(new Vec2(shadowRelated.selectedArea.end.x - shadowRelated.selectedArea.start.x, shadowRelated.selectedArea.end.y - shadowRelated.selectedArea.start.y));
131 | shadowRelated.displayWindow.SetPosition(new Vec2(shadowRelated.selectedArea.start.x, shadowRelated.selectedArea.start.y));
132 | shadowRelated.displayWindow.SetContent(container);
133 |
134 | console.log("display window set content");
135 | return true;
136 | })
137 | );
138 |
139 | const result = shadowRelated.displayWindow.CreateWindow(shadowRelated.measureWindow);
140 | console.log("display window create result", result);
141 | }
142 |
143 | if (shadowRelated.displayWindow.IsWindowCreated()) {
144 | console.log("display window reset pos");
145 |
146 | shadowRelated.displayWindow.SetSize(new Vec2(shadowRelated.selectedArea.end.x - shadowRelated.selectedArea.start.x, shadowRelated.selectedArea.end.y - shadowRelated.selectedArea.start.y));
147 | shadowRelated.displayWindow.SetPosition(new Vec2(shadowRelated.selectedArea.start.x, shadowRelated.selectedArea.start.y));
148 |
149 | console.log("display window created, activate it");
150 |
151 | shadowRelated.displayWindow.SetVisible(true);
152 | shadowRelated.displayWindow.Activate();
153 |
154 | console.log("activate display window done");
155 | }
156 | });
157 |
--------------------------------------------------------------------------------
/src/shadow/index.ts:
--------------------------------------------------------------------------------
1 | export * from "./common";
2 | export * from "./measure";
3 | export * from "./translate";
--------------------------------------------------------------------------------
/src/shadow/measure.ts:
--------------------------------------------------------------------------------
1 | import { DpiSize_2, DpiSize, CursorType, DockMode, Vec2, Vec4, Grid as NativeGrid, Window as NativeWindow, WindowFlag, WindowCreation } from "ave-ui";
2 | import { sleep, getPrimaryMonitor, safe, shadowRelated } from "./common";
3 | import { onDisplay } from "./display";
4 |
5 | export const onReset = safe(async function () {
6 | if (!shadowRelated.displayWindow) {
7 | return;
8 | }
9 |
10 | const rect = shadowRelated.displayWindow.GetRect();
11 | shadowRelated.selectedArea.start = rect.Position.Clone();
12 | shadowRelated.selectedArea.end = new Vec2(rect.Position.x + rect.Size.x, rect.Position.y + rect.Size.y);
13 | });
14 |
15 | export const onMeasure = safe(async function () {
16 | if (!shadowRelated.measureWindow) {
17 | console.log("measure window not initialized, init it");
18 | const cp = new WindowCreation();
19 | cp.Flag |= WindowFlag.Layered;
20 | cp.Flag |= WindowFlag.Indicator;
21 | cp.Title = "Measure";
22 |
23 | cp.Layout.Position = new Vec2(0, 0);
24 | const primary = getPrimaryMonitor();
25 | cp.Layout.Size = primary.AreaFull.Size;
26 |
27 | shadowRelated.measureWindow = new NativeWindow(cp);
28 | }
29 |
30 | if (!shadowRelated.measureWindow.IsWindowCreated()) {
31 | console.log("measure window not created, create it");
32 |
33 | shadowRelated.measureWindow.OnCreateContent(
34 | safe(() => {
35 | console.log("measure window create content callback");
36 |
37 | shadowRelated.measureWindow.SetBackground(false);
38 | shadowRelated.measureWindow.OnPointerCursor(() => CursorType.Cross);
39 |
40 | const container = new NativeGrid(shadowRelated.measureWindow);
41 | {
42 | const content = new NativeGrid(shadowRelated.measureWindow);
43 | const color = new Vec4(0, 0, 0, 255);
44 | content.SetBackColor(color);
45 | content.SetOpacity(0.7);
46 | container.ControlAdd(content).SetDock(DockMode.Fill);
47 |
48 | let moveRectGrid: NativeGrid = null;
49 |
50 | container.OnPointerPress(
51 | safe((sender: NativeWindow, mp) => {
52 | if (shadowRelated.selected) {
53 | return;
54 | }
55 |
56 | shadowRelated.start = mp.Position.Clone();
57 | shadowRelated.end = null;
58 | shadowRelated.current = null;
59 |
60 | moveRectGrid = new NativeGrid(shadowRelated.measureWindow);
61 | moveRectGrid.SetBackColor(new Vec4(255, 255, 255, 255));
62 | moveRectGrid.SetOpacity(0.5);
63 |
64 | shadowRelated.selected = container.ControlAdd(moveRectGrid);
65 | moveRectGrid.SetVisible(false);
66 | shadowRelated.selected.SetPos(new DpiSize_2(DpiSize.FromPixel(shadowRelated.start.x), DpiSize.FromPixel(shadowRelated.start.y)));
67 | shadowRelated.selected.SetSize(new DpiSize_2(DpiSize.FromPixel(1), DpiSize.FromPixel(1)));
68 | })
69 | );
70 |
71 | container.OnPointerRelease(
72 | safe(async (sender: NativeWindow, mp) => {
73 | shadowRelated.end = mp.Position.Clone();
74 |
75 | //
76 | shadowRelated.selected.SetPos(new DpiSize_2(DpiSize.FromPixel(0), DpiSize.FromPixel(0)));
77 | shadowRelated.selected.SetSize(new DpiSize_2(DpiSize.FromPixel(0), DpiSize.FromPixel(0)));
78 |
79 | moveRectGrid.SetBackColor(new Vec4(255, 0, 0, 255));
80 | container.ControlRemove(moveRectGrid);
81 |
82 | moveRectGrid = null;
83 | shadowRelated.selected = null;
84 |
85 | shadowRelated.measureWindow.Redraw();
86 | await sleep(100);
87 | shadowRelated.measureWindow.SetVisible(false);
88 |
89 | shadowRelated.selectedArea.start = new Vec2(Math.min(shadowRelated.start.x, shadowRelated.end.x), Math.min(shadowRelated.start.y, shadowRelated.end.y));
90 | shadowRelated.selectedArea.end = new Vec2(Math.max(shadowRelated.start.x, shadowRelated.end.x), Math.max(shadowRelated.start.y, shadowRelated.end.y));
91 |
92 | //
93 | shadowRelated.start = null;
94 | shadowRelated.end = null;
95 | shadowRelated.current = null;
96 |
97 | //
98 | console.log("measure reuslt selected area", shadowRelated.selectedArea);
99 |
100 | //
101 | onDisplay();
102 | })
103 | );
104 |
105 | container.OnPointerMove(
106 | safe((sender: NativeWindow, mp) => {
107 | shadowRelated.current = mp.Position.Clone();
108 | // console.log("mesaure move", shadowRelated.current.x, shadowRelated.current.y);
109 |
110 | if (moveRectGrid && shadowRelated.start && shadowRelated.current.x !== shadowRelated.start.x && shadowRelated.current.y !== shadowRelated.start.y) {
111 | moveRectGrid.SetVisible(true);
112 | } else if (moveRectGrid) {
113 | moveRectGrid.SetVisible(false);
114 | }
115 |
116 | if (shadowRelated.start && shadowRelated.selected) {
117 | const x = Math.min(shadowRelated.start.x, shadowRelated.current.x);
118 | const y = Math.min(shadowRelated.start.y, shadowRelated.current.y);
119 | const width = Math.abs(shadowRelated.current.x - shadowRelated.start.x);
120 | const height = Math.abs(shadowRelated.current.y - shadowRelated.start.y);
121 | if (width > 1 && height > 1) {
122 | // console.log("draw measure area", x, y, width, height);
123 | shadowRelated.selected.SetPos(new DpiSize_2(DpiSize.FromPixel(x), DpiSize.FromPixel(y)));
124 | shadowRelated.selected.SetSize(new DpiSize_2(DpiSize.FromPixel(width), DpiSize.FromPixel(height)));
125 | }
126 | }
127 | })
128 | );
129 | }
130 |
131 | console.log("measure window set content");
132 | shadowRelated.measureWindow.SetContent(container);
133 | return true;
134 | })
135 | );
136 | const result = shadowRelated.measureWindow.CreateWindow();
137 | console.log("measure window create result", result);
138 | }
139 |
140 | if (shadowRelated.measureWindow.IsWindowCreated()) {
141 | console.log("measure window created, activate it");
142 |
143 | shadowRelated.displayWindow?.SetVisible(false);
144 |
145 | shadowRelated.measureWindow.SetVisible(true);
146 | shadowRelated.measureWindow.Activate();
147 |
148 | console.log("activate measure window done");
149 | }
150 | });
151 |
--------------------------------------------------------------------------------
/src/shadow/translate.ts:
--------------------------------------------------------------------------------
1 | import { sleep, shadowRelated } from "./common";
2 | import { IAsrEngine } from "../asr/base";
3 | import { INlpEngine } from "../nlp/base";
4 |
5 | export const onTranslate = async function (asrEngine: IAsrEngine, nlpEngine: INlpEngine) {
6 | _onRecognize(asrEngine);
7 | _onTranslate(nlpEngine);
8 | _onUpdateSubtitle();
9 | };
10 |
11 | let prevLength = Number.MAX_SAFE_INTEGER;
12 | let lastUpdateTime = Date.now();
13 | const subtitleDelay = 1000;
14 |
15 | const _onUpdateSubtitle = async function () {
16 | try {
17 | if (!shadowRelated.shouldRecognize) {
18 | shadowRelated.subtitleQueue = [];
19 | return;
20 | }
21 | const current = shadowRelated.subtitleQueue.shift();
22 | if (current) {
23 | const now = Date.now();
24 | if (current.en.length < prevLength) {
25 | // length change, a new subtitle found!
26 | const dt = now - lastUpdateTime;
27 | if (dt <= subtitleDelay) {
28 | await sleep(Math.abs(subtitleDelay - dt));
29 | }
30 | }
31 | shadowRelated.onUpdateTranslationResult(current);
32 | prevLength = current.en.length || 0;
33 | lastUpdateTime = Date.now();
34 | }
35 | } catch (error) {
36 | console.error("recognize failed", error);
37 | } finally {
38 | setTimeout(() => {
39 | _onUpdateSubtitle();
40 | }, 0);
41 | }
42 | };
43 |
44 | const _onRecognize = async function (asrEngine: IAsrEngine) {
45 | try {
46 | if (!shadowRelated.shouldRecognize) {
47 | return;
48 | }
49 | const asrStart = Date.now();
50 | const sentence = await asrEngine.recognize();
51 | const asrEnd = Date.now();
52 |
53 | if (sentence.text && sentence.text !== shadowRelated.prevSentence.text) {
54 | console.log(`asr end in ${asrEnd - asrStart}ms`);
55 | shadowRelated.prevSentence = sentence;
56 | shadowRelated.shouldTranslate = true;
57 | }
58 | } catch (error) {
59 | console.error("recognize failed", error);
60 | } finally {
61 | setTimeout(() => {
62 | _onRecognize(asrEngine);
63 | }, 100);
64 | }
65 | };
66 | const _onTranslate = async function (nlpEngine: INlpEngine) {
67 | try {
68 | if (!shadowRelated.shouldRecognize) {
69 | return;
70 | }
71 |
72 | if (!shadowRelated.subtitleConfig.zh) {
73 | const { text, ...rest } = shadowRelated.prevSentence;
74 | shadowRelated.subtitleQueue.push({ zh: "", en: text, ...rest });
75 | return;
76 | }
77 |
78 | if (shadowRelated.shouldTranslate) {
79 | shadowRelated.shouldTranslate = false;
80 | console.log("will translate");
81 | const translateStart = Date.now();
82 | const { text: enText, ...rest } = shadowRelated.prevSentence;
83 | const input = enText;
84 | const { text } = await nlpEngine.translate(input);
85 | shadowRelated.prevTranslation = text;
86 | const translateEnd = Date.now();
87 | console.log(`translate end in ${translateEnd - translateStart}ms`, { enText: input, text });
88 | shadowRelated.subtitleQueue.push({ zh: shadowRelated.prevTranslation, en: input, ...rest });
89 | }
90 | } catch (error) {
91 | console.error("translate failed", error);
92 | } finally {
93 | setTimeout(() => {
94 | _onTranslate(nlpEngine);
95 | }, 500);
96 | }
97 | };
98 |
--------------------------------------------------------------------------------
/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "compilerOptions": {
3 | "target": "es6",
4 | "module": "commonjs",
5 | "moduleResolution": "node",
6 | "outDir": "build/src",
7 | "jsx": "react",
8 | "esModuleInterop" : true,
9 | },
10 | "include": [
11 | "./src"
12 | ]
13 | }
--------------------------------------------------------------------------------
/web-ui/.gitignore:
--------------------------------------------------------------------------------
1 | # See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
2 |
3 | # dependencies
4 | /node_modules
5 | /.pnp
6 | .pnp.js
7 |
8 | # testing
9 | /coverage
10 |
11 | # production
12 | /build
13 |
14 | # misc
15 | .DS_Store
16 | .env.local
17 | .env.development.local
18 | .env.test.local
19 | .env.production.local
20 |
21 | npm-debug.log*
22 | yarn-debug.log*
23 | yarn-error.log*
24 |
--------------------------------------------------------------------------------
/web-ui/LICENSE:
--------------------------------------------------------------------------------
1 | MIT License
2 |
3 | Copyright (c) 2023 rerender2021
4 |
5 | Permission is hereby granted, free of charge, to any person obtaining a copy
6 | of this software and associated documentation files (the "Software"), to deal
7 | in the Software without restriction, including without limitation the rights
8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9 | copies of the Software, and to permit persons to whom the Software is
10 | furnished to do so, subject to the following conditions:
11 |
12 | The above copyright notice and this permission notice shall be included in all
13 | copies or substantial portions of the Software.
14 |
15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21 | SOFTWARE.
22 |
--------------------------------------------------------------------------------
/web-ui/README.md:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/rerender2021/echo/349597a46017c524bc7217b7ed17e78527593b14/web-ui/README.md
--------------------------------------------------------------------------------
/web-ui/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "echo-web-ui",
3 | "version": "0.1.0",
4 | "private": true,
5 | "dependencies": {
6 | "@monaco-editor/react": "^4.6.0",
7 | "@testing-library/jest-dom": "^5.17.0",
8 | "@testing-library/react": "^13.4.0",
9 | "@testing-library/user-event": "^13.5.0",
10 | "@types/jest": "^27.5.2",
11 | "@types/node": "^16.18.61",
12 | "@types/react": "^18.2.37",
13 | "@types/react-dom": "^18.2.15",
14 | "antd": "^5.1.0",
15 | "axios": "^1.4.0",
16 | "monaco-editor": "^0.44.0",
17 | "react": "^18.2.0",
18 | "react-dom": "^18.2.0",
19 | "react-scripts": "5.0.1",
20 | "socket.io-client": "^4.7.2",
21 | "typescript": "^4.9.5",
22 | "web-vitals": "^2.1.4"
23 | },
24 | "scripts": {
25 | "start": "react-scripts start",
26 | "build": "cross-env GENERATE_SOURCEMAP=false react-scripts build",
27 | "test": "react-scripts test",
28 | "eject": "react-scripts eject"
29 | },
30 | "eslintConfig": {
31 | "extends": [
32 | "react-app",
33 | "react-app/jest"
34 | ]
35 | },
36 | "browserslist": {
37 | "production": [
38 | ">0.2%",
39 | "not dead",
40 | "not op_mini all"
41 | ],
42 | "development": [
43 | "last 1 chrome version",
44 | "last 1 firefox version",
45 | "last 1 safari version"
46 | ]
47 | },
48 | "devDependencies": {
49 | "cross-env": "^7.0.3"
50 | }
51 | }
52 |
--------------------------------------------------------------------------------
/web-ui/public/favicon.ico:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/rerender2021/echo/349597a46017c524bc7217b7ed17e78527593b14/web-ui/public/favicon.ico
--------------------------------------------------------------------------------
/web-ui/public/index.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
8 |
12 |
13 |
17 |
18 |
27 | Echo
28 |
29 |
30 |
31 |
32 |
42 |
43 |
44 |
--------------------------------------------------------------------------------
/web-ui/public/logo.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/rerender2021/echo/349597a46017c524bc7217b7ed17e78527593b14/web-ui/public/logo.png
--------------------------------------------------------------------------------
/web-ui/public/manifest.json:
--------------------------------------------------------------------------------
1 | {
2 | "short_name": "Echo",
3 | "name": "Echo",
4 | "icons": [
5 | {
6 | "src": "favicon.ico",
7 | "sizes": "64x64 32x32 24x24 16x16",
8 | "type": "image/x-icon"
9 | }
10 | ],
11 | "start_url": ".",
12 | "display": "standalone",
13 | "theme_color": "#000000",
14 | "background_color": "#ffffff"
15 | }
16 |
--------------------------------------------------------------------------------
/web-ui/public/robots.txt:
--------------------------------------------------------------------------------
1 | # https://www.robotstxt.org/robotstxt.html
2 | User-agent: *
3 | Disallow:
4 |
--------------------------------------------------------------------------------
/web-ui/src/App.css:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/rerender2021/echo/349597a46017c524bc7217b7ed17e78527593b14/web-ui/src/App.css
--------------------------------------------------------------------------------
/web-ui/src/App.test.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import { render, screen } from '@testing-library/react';
3 | import App from './App';
4 |
5 | test('renders learn react link', () => {
6 | render();
7 | const linkElement = screen.getByText(/learn react/i);
8 | expect(linkElement).toBeInTheDocument();
9 | });
10 |
--------------------------------------------------------------------------------
/web-ui/src/App.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import './App.css';
3 | import Home from './pages/home';
4 |
5 | function App() {
6 | return (
7 |
8 | );
9 | }
10 |
11 | export default App;
12 |
--------------------------------------------------------------------------------
/web-ui/src/index.css:
--------------------------------------------------------------------------------
1 | body {
2 | margin: 0;
3 | font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen',
4 | 'Ubuntu', 'Cantarell', 'Fira Sans', 'Droid Sans', 'Helvetica Neue',
5 | sans-serif;
6 | -webkit-font-smoothing: antialiased;
7 | -moz-osx-font-smoothing: grayscale;
8 | }
9 |
10 | code {
11 | font-family: source-code-pro, Menlo, Monaco, Consolas, 'Courier New',
12 | monospace;
13 | }
14 |
--------------------------------------------------------------------------------
/web-ui/src/index.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import ReactDOM from 'react-dom/client';
3 | import './index.css';
4 | import App from './App';
5 | // import reportWebVitals from './reportWebVitals';
6 |
7 | const root = ReactDOM.createRoot(
8 | document.getElementById('root') as HTMLElement
9 | );
10 | root.render(
11 | //
12 |
13 | //
14 | );
15 |
16 | // If you want to start measuring performance in your app, pass a function
17 | // to log results (for example: reportWebVitals(console.log))
18 | // or send to an analytics endpoint. Learn more: https://bit.ly/CRA-vitals
19 | // reportWebVitals();
20 |
--------------------------------------------------------------------------------
/web-ui/src/lib/index.ts:
--------------------------------------------------------------------------------
1 | import { io } from "socket.io-client";
2 |
3 | // https://socket.io/how-to/use-with-react
4 | export const socket = io("http://localhost:8350", {
5 | autoConnect: true,
6 | });
7 |
--------------------------------------------------------------------------------
/web-ui/src/logo.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/web-ui/src/pages/home.module.css:
--------------------------------------------------------------------------------
1 | .container {
2 | display: flex;
3 | flex-direction: column;
4 | align-items: center;
5 | margin-bottom: 20px;
6 | }
7 |
8 | .header {
9 | width: 100%;
10 | height: 64px;
11 | box-shadow: inset 0 -1px 0 #E3E5E7;
12 | display: flex;
13 | align-items: center;
14 | }
15 |
16 | .errors {
17 | width: 80%;
18 | margin-top: 20px;
19 | }
20 |
21 | .logo {
22 | height: 48px;
23 | margin-left: 6px;
24 | }
25 |
26 | .subtitleList {
27 | margin-top: 20px;
28 | width: 80%;
29 | height: 600px;
30 | }
--------------------------------------------------------------------------------
/web-ui/src/pages/home.tsx:
--------------------------------------------------------------------------------
1 | import { useEffect, useRef, useState } from "react";
2 | import { socket } from "../lib";
3 | import style from "./home.module.css";
4 | import * as monaco from "monaco-editor";
5 | import { loader, Editor } from "@monaco-editor/react";
6 | import { Alert, Button, Card, Space } from "antd";
7 |
8 | loader.config({ monaco });
9 |
10 | type ErrorEventType = { log: string; message: string; link?: string };
11 |
12 | export default function Home() {
13 | const refEditor = useRef(null);
14 | const refSubtitleList = useRef([]);
15 | const refErrors = useRef([]);
16 | const refIsConnectError = useRef(false);
17 | const refLogHistory = useRef([]);
18 | const [updateKey, setUpdateKey] = useState(Date.now());
19 |
20 | useEffect(() => {
21 | const editor = refEditor.current as any;
22 | if(refErrors.current.length === 0) {
23 | editor?.revealLine(editor.getModel().getLineCount() + 10);
24 | }
25 | }, [updateKey]);
26 |
27 | useEffect(() => {
28 | socket.on("connect", () => {
29 | console.log("connect");
30 | refIsConnectError.current = false;
31 | setUpdateKey(Date.now());
32 | });
33 |
34 | socket.on("connect_error", (error) => {
35 | console.error("connect error", { error });
36 | refIsConnectError.current = true;
37 | refErrors.current = [];
38 | setUpdateKey(Date.now());
39 | });
40 |
41 | const update = (text: string) => {
42 | if (
43 | refSubtitleList.current[refSubtitleList.current.length - 1] !== text
44 | ) {
45 | const newSubtitleList = [...refSubtitleList.current, text];
46 | refSubtitleList.current = newSubtitleList;
47 | setUpdateKey(Date.now());
48 | }
49 | };
50 |
51 | let count = Number.MAX_SAFE_INTEGER;
52 | let prevSubtitle = { zh: "", en: "" };
53 | let timer: any;
54 | socket.on("subtitle", (value: { zh: string; en: string }) => {
55 | const newCount = value?.en?.length ?? 0;
56 | const isDecreasing = newCount < count;
57 | console.log("check", { isDecreasing, newCount, count });
58 | if (isDecreasing && prevSubtitle.en !== value.en) {
59 | if (prevSubtitle?.en || prevSubtitle?.zh) {
60 | console.log("new subtitle", { value });
61 | update(`${prevSubtitle?.en}\n${prevSubtitle?.zh}`);
62 | clearTimeout(timer);
63 | }
64 | }
65 | count = newCount;
66 | prevSubtitle = value;
67 | });
68 |
69 | socket.on("flush", () => {
70 | update(`${prevSubtitle?.en}\n${prevSubtitle?.zh}`);
71 | });
72 |
73 | socket.on("echo-error", (value: ErrorEventType) => {
74 | console.error("echo error", { value });
75 | refErrors.current = [...refErrors.current, value];
76 | setUpdateKey(Date.now());
77 | });
78 |
79 | socket.on("log-history", (value: { logHistory: string[] }) => {
80 | console.error("log history", { value });
81 | refLogHistory.current = [...value.logHistory];
82 | setUpdateKey(Date.now());
83 | });
84 |
85 | }, []);
86 |
87 | function handleEditorDidMount(editor: any) {
88 | refEditor.current = editor;
89 | }
90 |
91 | const editorValue = refErrors.current.length === 0 ? refSubtitleList.current.join("\n\n"): refLogHistory.current.join("\n\n");
92 |
93 | return (
94 |
95 |
100 |
101 |
106 | {refIsConnectError.current && (
107 |
112 | )}
113 | {refErrors.current.map((each) => {
114 | return (
115 |
119 | {each.message}
120 | {each?.link && (
121 |
124 | )}
125 | >
126 | }
127 | type="error"
128 | showIcon
129 | />
130 | );
131 | })}
132 |
133 |
134 |
135 |
144 |
145 |
146 | );
147 | }
148 |
--------------------------------------------------------------------------------
/web-ui/src/react-app-env.d.ts:
--------------------------------------------------------------------------------
1 | ///
2 |
--------------------------------------------------------------------------------
/web-ui/src/reportWebVitals.ts:
--------------------------------------------------------------------------------
1 | import { ReportHandler } from 'web-vitals';
2 |
3 | const reportWebVitals = (onPerfEntry?: ReportHandler) => {
4 | if (onPerfEntry && onPerfEntry instanceof Function) {
5 | import('web-vitals').then(({ getCLS, getFID, getFCP, getLCP, getTTFB }) => {
6 | getCLS(onPerfEntry);
7 | getFID(onPerfEntry);
8 | getFCP(onPerfEntry);
9 | getLCP(onPerfEntry);
10 | getTTFB(onPerfEntry);
11 | });
12 | }
13 | };
14 |
15 | export default reportWebVitals;
16 |
--------------------------------------------------------------------------------
/web-ui/src/setupTests.ts:
--------------------------------------------------------------------------------
1 | // jest-dom adds custom jest matchers for asserting on DOM nodes.
2 | // allows you to do things like:
3 | // expect(element).toHaveTextContent(/react/i)
4 | // learn more: https://github.com/testing-library/jest-dom
5 | import '@testing-library/jest-dom';
6 |
--------------------------------------------------------------------------------
/web-ui/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "compilerOptions": {
3 | "target": "es5",
4 | "lib": [
5 | "dom",
6 | "dom.iterable",
7 | "esnext"
8 | ],
9 | "allowJs": true,
10 | "skipLibCheck": true,
11 | "esModuleInterop": true,
12 | "allowSyntheticDefaultImports": true,
13 | "strict": true,
14 | "forceConsistentCasingInFileNames": true,
15 | "noFallthroughCasesInSwitch": true,
16 | "module": "esnext",
17 | "moduleResolution": "node",
18 | "resolveJsonModule": true,
19 | "isolatedModules": true,
20 | "noEmit": true,
21 | "jsx": "react-jsx"
22 | },
23 | "include": [
24 | "src"
25 | ]
26 | }
27 |
--------------------------------------------------------------------------------