Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -13,4 +13,5 @@ subtitle
perf
log
*.exe
error.log
error.log
heard.config.json
56 changes: 35 additions & 21 deletions src/app.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,13 +3,13 @@ import { AveRenderer, Grid, Window, getAppContext, IIconResource, IWindowCompone
import { App, ThemePredefined_Dark } from "ave-ui";
import { containerLayout, controlLayout } from "./layout";
import { iconResource } from "./resource";
import { safe } from "./common";
import { safe, sleep } from "./common";
import axios from "axios";
import { ProgressType, Whisper } from "./whisper";
import { useDragDrop } from "./hooks";
import path from "path";
import fs from "fs";
import { getModelList, useModel } from "./config";
import { writeSubtitleFile } from "./utils.ts";

function onInit(app: App) {
const context = getAppContext();
Expand All @@ -23,9 +23,10 @@ function initTheme() {
themeDark.SetStyle(themeImage, 0);
}

enum ButtonText {
enum Text {
GenerateSubtitle = "生成字幕",
OpenFile = "选择文件"
OpenFile = "选择文件",
ProgressLabel = "进度",
}

const modelOptions = getModelList().map((each, index) => {
Expand All @@ -47,11 +48,18 @@ export function Heard() {
const [title, setTitle] = useState("Heard");
const [whisperReady, setwhisperReady] = useState(false);
const [src, setSrc] = useState<string>("");
const [srcDesc, setSrcDesc] = useState<string>("");
const [srcList, setSrcList] = useState<Array<string>>([""]);
const promptRef = useRef<string>("");
useDragDrop((path) => setSrc(path));
useDragDrop((pathList) => {
setSrcList(pathList);
setSrcDesc(pathList.length === 1 ? pathList[0] : `${pathList.length} files`);
setProgressLabelText(`${Text.ProgressLabel}: 0/${pathList.length}`);
});

const [progress, setProgress] = useState<ProgressType>(ProgressType.None);
const [progressText, setProgressText] = useState<string>("");
const [progressLabelText, setProgressLabelText] = useState<string>(Text.ProgressLabel);
const [defaultSelectedKey, setDefaultSelectedKey] = useState<string>("1");
const [hintText, setHintText] = useState<string>("初始化中...");

Expand All @@ -64,7 +72,9 @@ export function Heard() {
console.log(`open file: ${filePath}`);

if (filePath) {
setSrc(filePath);
setSrcList([filePath]);
setSrcDesc(filePath);
setProgressLabelText(Text.ProgressLabel);
}
}),
[]
Expand All @@ -75,19 +85,23 @@ export function Heard() {
if (progress !== ProgressType.None) {
return;
}
whisper.transcribe(src, promptRef.current).then(
safe((response) => {

for (let i = 0; i < srcList.length; ++i) {
try {
const src = srcList[i];
setSrc(src);
setProgressLabelText(`${Text.ProgressLabel}: ${i + 1}/${srcList.length}: ${src}`);
const response = await whisper.transcribe(src, promptRef.current);
const data = response.data;
const dirName = path.dirname(src);
const fileName = path.basename(src);
console.log(`save subtitle json`, { dirName, fileName });

const outPath = path.resolve(dirName, `${fileName}.subtitle.json`);
fs.writeFileSync(outPath, JSON.stringify(data, null, 4), "utf8");
})
);

writeSubtitleFile(data, src);
await sleep(1500);
} catch (error) {
console.error(error?.message);
}
}
}),
[src]
[srcList]
);

const startWhisper = safe(() => {
Expand Down Expand Up @@ -174,10 +188,10 @@ export function Heard() {
<Grid style={{ layout: containerLayout }}>
<Grid style={{ area: containerLayout.areas.control, layout: controlLayout }}>
<Grid style={{ area: controlLayout.areas.openFile }}>
<Button text={ButtonText.OpenFile} langKey="OpenFile" iconInfo={{ name: "open-file" }} onClick={onOpenFile}></Button>
<Button text={Text.OpenFile} langKey="OpenFile" iconInfo={{ name: "open-file" }} onClick={onOpenFile}></Button>
</Grid>
<Grid style={{ area: controlLayout.areas.filePath, margin: "12dpx 0 0 0" }}>
<TextBox readonly border={false} text={src}></TextBox>
<TextBox readonly border={false} text={srcDesc}></TextBox>
</Grid>
{whisperReady ? (
<>
Expand All @@ -191,10 +205,10 @@ export function Heard() {
<TextBox ime onChange={onChangePrompt}></TextBox>
</Grid>
<Grid style={{ area: controlLayout.areas.generate, margin: "12dpx 0 0 0" }}>
<Button enable={progress === ProgressType.None} text={ButtonText.GenerateSubtitle} iconInfo={{ name: "cc", size: 16 }} onClick={onTranscribe}></Button>
<Button enable={progress === ProgressType.None} text={Text.GenerateSubtitle} iconInfo={{ name: "cc", size: 16 }} onClick={onTranscribe}></Button>
</Grid>
<Grid style={{ area: controlLayout.areas.progressLabel }}>
<TextBox readonly border={false} text="进度"></TextBox>
<TextBox readonly border={false} text={progressLabelText}></TextBox>
</Grid>
<Grid style={{ area: controlLayout.areas.progress }}>
<TextBox readonly border={false} text={progressText}></TextBox>
Expand Down
7 changes: 4 additions & 3 deletions src/common/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,14 +5,15 @@ export function assetsPath(name: string) {
return path.resolve(root, `./${name}`);
}

export function safe(callback: Function) {
return (...args: any[]) => {
export function safe<T extends Function>(callback: T): T {
const f = (...args: any[]) => {
try {
return callback(...args);
} catch (error) {
console.error(error);
}
};
}
return f as unknown as T;
}

export async function sleep(time: number) {
Expand Down
34 changes: 34 additions & 0 deletions src/config/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,3 +28,37 @@ export function getModelList() {
const globalConfig = {
model: "",
};

// --- user config
export function getSubtitleFormat() {
try {
const config = getConfig();
const format = config?.format ?? defaultConfig.format;
console.log(`current subtitle format: ${format}`);
return format;
} catch (error) {
console.error("get subtitle format failed", { error });
return defaultConfig.format;
}
}

const defaultConfig = {
format: "srt",
};

function getConfig() {
const configPath = path.resolve(process.cwd(), "./heard.config.json");
if (!fs.existsSync(configPath)) {
console.log(`config not exist at ${configPath}, create it!`);
fs.writeFileSync(configPath, JSON.stringify(defaultConfig, null, 4), "utf-8");
}

try {
const configJson = JSON.parse(fs.readFileSync(configPath, "utf-8"));
console.log(`parse config succeed, use it`);
return configJson;
} catch (error) {
console.log(`parse config failed, ${error?.message}, use default config`);
return defaultConfig;
}
}
31 changes: 18 additions & 13 deletions src/hooks/useDragDrop.ts
Original file line number Diff line number Diff line change
@@ -1,27 +1,32 @@
import { getAppContext } from "ave-react";
import { useEffect, useState } from "react";
import { DragDropImage, DropBehavior } from "ave-ui";
import { safe } from "../common";

export function useDragDrop(onPathChange?: (path: string) => void) {
const [path, setPath] = useState("");
export function useDragDrop(onPathChange?: (pathList: Array<string>) => void) {
const [pathList, setPathList] = useState([""]);

useEffect(() => {
const context = getAppContext();
const window = context.getWindow();

window.OnDragMove((sender, dc) => {
if (1 === dc.FileGetCount()) {
dc.SetDropTip(DragDropImage.Copy, "打开此文件");
window.OnDragMove(
safe((sender, dc) => {
const fileCount = dc.FileGetCount();
dc.SetDropTip(DragDropImage.Copy, fileCount === 1 ? "选择此文件" : `选择文件x${fileCount}`);
dc.SetDropBehavior(DropBehavior.Copy);
}
});
})
);

window.OnDragDrop((sender, dc) => {
const filePath = dc.FileGet()[0];
setPath(path);
onPathChange?.(filePath ?? "");
});
window.OnDragDrop(
safe((sender, dc) => {
const filePaths = dc.FileGet();
console.log(`on drap drop, file paths:`, { filePaths });
setPathList(filePaths);
onPathChange?.(filePaths ?? [""]);
})
);
}, []);

return { path };
return { pathList };
}
2 changes: 1 addition & 1 deletion src/layout/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,7 @@ export const controlLayout = {
selectModel: { row: 5, column: 0, columnSpan: 3 },
generate: { row: 5, column: 3, columnSpan: 5 },

progressLabel: { row: 7, column: 0, columnSpan: 4 },
progressLabel: { row: 7, column: 0, columnSpan: 8 },
progress: { row: 9, column: 0, columnSpan: 8 }
},
};
54 changes: 54 additions & 0 deletions src/utils.ts/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
import path from "path";
import fs from "fs";
import { getSubtitleFormat } from "../config";
import { safe } from "../common";

interface ISubtitleData {
result: {
text: string;
language: string;
segments: ISubtitleSegment[];
};
}

interface ISubtitleSegment {
id: number;
seek: number;
start: number;
end: number;
text: string;
tokens: number[];
temperature: number;
avg_logprob: number;
compression_ratio: number;
no_speech_prob: number;
}

export const writeSubtitleFile = safe((data: ISubtitleData, src: string) => {
const dirName = path.dirname(src);
const fileName = path.basename(src);
const outPath = path.resolve(dirName, `${fileName}.subtitle.json`);
fs.writeFileSync(outPath, JSON.stringify(data, null, 4), "utf8");

const parsed = path.parse(fileName); // en.wav
const baseName = parsed.name; // en
const extension = parsed.ext; // .wav
console.log(`save subtitle json`, { dirName, fileName, baseName, extension });

const format = getSubtitleFormat();
if (format === "txt") {
const txtPath = path.resolve(dirName, `${baseName}.txt`);
const txtContent = getTxtContent(data);
fs.writeFileSync(txtPath, txtContent, "utf8");
console.log(`write txt subtitle succeed`);

const srtPath = path.resolve(dirName, `${baseName}.srt`);
if (fs.existsSync(srtPath)) {
fs.unlinkSync(srtPath);
}
}
});

function getTxtContent(data: ISubtitleData) {
return data.result.segments.map((each) => each.text).join("\n");
}
2 changes: 1 addition & 1 deletion src/whisper/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -56,7 +56,7 @@ export class Whisper {
const exePath = path.resolve(dir, "./Whisper-API.exe");
if (fs.existsSync(dir) && fs.existsSync(exePath)) {
return new Promise((resolve, reject) => {
console.log("asrDir exists, start asr server", dir);
console.log("whisper dir exists, start whisper server", dir);

const name = getModel();
console.log("start whisper server with model: ", name);
Expand Down