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
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -95,3 +95,4 @@ out/

lib/
/dist/
.history
2 changes: 2 additions & 0 deletions .vscode/settings.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
{
}
22,917 changes: 22,917 additions & 0 deletions package-lock.json

Large diffs are not rendered by default.

3 changes: 2 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@
"main": ".vite/build/main.js",
"scripts": {
"download": "zx ./scripts/download.mjs",
"start": "yarn run download && electron-forge start",
"start": "chcp 65001 && yarn run download && electron-forge start",
"package": "yarn run download && electron-forge package",
"make": "yarn run download && electron-forge make",
"publish": "yarn run download && electron-forge publish",
Expand Down Expand Up @@ -124,6 +124,7 @@
"moment": "^2.30.1",
"monaco-editor": "^0.45.0",
"next-themes": "^0.3.0",
"node-edge-tts": "^1.2.9",
"node-sql-parser": "^4.17.0",
"object-hash": "^3.0.0",
"openai": "^4.32.1",
Expand Down
5 changes: 5 additions & 0 deletions src/app.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ import toast, { Toaster as HotToaster } from 'react-hot-toast';
import { syncStatus } from '@/fronted/hooks/useSystem';
import Transcript from '@/fronted/pages/transcript/Transcript';
import OpenAiSetting from '@/fronted/pages/setting/OpenAiSetting';
import {TtsSetting} from '@/fronted/pages/setting/TtsSetting';
import Split from '@/fronted/pages/split/Split';
import GlobalShortCut from '@/fronted/components/short-cut/GlobalShortCut';
import DownloadVideo from '@/fronted/pages/DownloadVideo';
Expand Down Expand Up @@ -107,6 +108,10 @@ const App = () => {
path="appearance"
element={<Eb><AppearanceSetting /></Eb>}
/>
<Route
path="tts"
element={<Eb><TtsSetting /></Eb>}
/>
</Route>
</Route>
</Route>
Expand Down
5 changes: 5 additions & 0 deletions src/backend/controllers/AiFuncController.ts
Original file line number Diff line number Diff line change
Expand Up @@ -83,6 +83,10 @@ export default class AiFuncController implements Controller {
return UrlUtil.dp(await TtsService.tts(string));
}

public async getEdgeTtsVoices({ forceRefresh = false }: { forceRefresh?: boolean } = {}) {
return TtsService.getEdgeTtsVoices(forceRefresh);
}

public async chat({ msgs }: { msgs: CoreMessage[] }): Promise<number> {
const taskId = await this.dpTaskService.create();
this.chatService.chat(taskId, msgs).then();
Expand Down Expand Up @@ -125,6 +129,7 @@ export default class AiFuncController implements Controller {
registerRoute('ai-func/explain-select-with-context', (p) => this.explainSelectWithContext(p));
registerRoute('ai-func/explain-select', (p) => this.explainSelect(p));
registerRoute('ai-func/translate-with-context', (p) => this.translateWithContext(p));
registerRoute('tts/edge-tts/voices', () => this.getEdgeTtsVoices());
}
}

36 changes: 35 additions & 1 deletion src/backend/controllers/StorageController.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { SettingKey } from '@/common/types/store_schema';
import { AiProviderConfig, SettingKey } from '@/common/types/store_schema';
import registerRoute from '@/common/api/register';
import Controller from '@/backend/interfaces/controller';
import { inject, injectable } from 'inversify';
Expand Down Expand Up @@ -28,11 +28,45 @@ export default class StorageController implements Controller {
return this.locationService.listCollectionPaths();
}

public async getAiProviderConfigs(): Promise<AiProviderConfig[]> {
return this.settingService.getAiProviderConfigs();
}

public async addAiProviderConfig(config: AiProviderConfig): Promise<AiProviderConfig[]> {
return this.settingService.addAiProviderConfig(config);
}

public async updateAiProviderConfig(config: AiProviderConfig): Promise<AiProviderConfig[]> {
return this.settingService.updateAiProviderConfig(config);
}

public async deleteAiProviderConfig(id: string): Promise<AiProviderConfig[]> {
return this.settingService.deleteAiProviderConfig(id);
}

public async setActiveAiProvider(id: string): Promise<void> {
return this.settingService.setActiveAiProvider(id);
}

public async getActiveAiProvider(): Promise<string | null> {
return this.settingService.getActiveAiProvider();
}

public async updateAllAiProviderConfigs(configs: AiProviderConfig[]): Promise<void> {
return this.settingService.updateAllAiProviderConfigs(configs);
}

registerRoutes(): void {
registerRoute('storage/put', (p) => this.storeSet(p));
registerRoute('storage/get', (p) => this.storeGet(p));
registerRoute('storage/cache/size', (p) => this.queryCacheSize());
registerRoute('storage/collection/paths', () => this.listCollectionPaths());
registerRoute('setting/ai-providers/get', () => this.getAiProviderConfigs());
registerRoute('setting/ai-providers/add', (p) => this.addAiProviderConfig(p));
registerRoute('setting/ai-providers/update', (p) => this.updateAiProviderConfig(p));
registerRoute('setting/ai-providers/delete', (p) => this.deleteAiProviderConfig(p));
registerRoute('setting/ai-providers/set-active', (p) => this.setActiveAiProvider(p));
registerRoute('setting/ai-providers/get-active', () => this.getActiveAiProvider());
registerRoute('setting/ai-providers/update-all', (p) => this.updateAllAiProviderConfigs(p));
}
}
46 changes: 46 additions & 0 deletions src/backend/controllers/SubtitleController.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,8 +16,54 @@ export default class SubtitleController implements Controller {
return this.subtitleService.parseSrt(path);
}

public async extractWords(path: string): Promise<Record<string, { index: number; text: string }[]>> {
const srtData = await this.subtitleService.parseSrt(path);
if (!srtData || !srtData.sentences) {
return {};
}

const wordMap: Record<string, { index: number; text: string }[]> = {};

const stopWords = new Set([
'a', 'an', 'the', 'i', 'me', 'my', 'myself', 'we', 'our', 'ours', 'ourselves', 'you', 'your', 'yours', 'he', 'him', 'his',
'she', 'her', 'hers', 'it', 'its', 'they', 'them', 'their', 'what', 'which', 'who', 'whom', 'this', 'that', 'these',
'those', 'am', 'is', 'are', 'was', 'were', 'be', 'been', 'being', 'have', 'has', 'had', 'having', 'do', 'does', 'did',
'doing', 'a', 'an', 'the', 'and', 'but', 'if', 'or', 'because', 'as', 'until', 'while', 'of', 'at', 'by', 'for',
'with', 'about', 'against', 'between', 'into', 'through', 'during', 'before', 'after', 'above', 'below', 'to',
'from', 'up', 'down', 'in', 'out', 'on', 'off', 'over', 'under', 'again', 'further', 'then', 'once', 'here',
'there', 'when', 'where', 'why', 'how', 'all', 'any', 'both', 'each', 'few', 'more', 'most', 'other', 'some',
'such', 'no', 'nor', 'not', 'only', 'own', 'same', 'so', 'than', 'too', 'very', 's', 't', 'can', 'will', 'just',
'don', 'should', 'now', 'd', 'll', 'm', 'o', 're', 've', 'y'
]);

for (const sentence of srtData.sentences) {
if (!sentence.struct || !sentence.text) continue;

const sentenceSnippet = { index: sentence.index, text: sentence.text };

for (const block of sentence.struct.blocks) {
for (const part of block.blockParts) {
if (part.isWord) {
const word = part.content.toLowerCase();
if (word && !stopWords.has(word)) {
if (!wordMap[word]) {
wordMap[word] = [];
}
// Avoid adding duplicate sentence for the same word
if (!wordMap[word].some(s => s.index === sentence.index)) {
wordMap[word].push(sentenceSnippet);
}
}
}
}
}
}
return wordMap;
}

registerRoutes(): void {
registerRoute('subtitle/srt/parse-to-sentences', (p) => this.parseSrt(p));
registerRoute('subtitle/extract-words', (p) => this.extractWords(p));
}
}

4 changes: 4 additions & 0 deletions src/backend/ioc/inversify.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -64,11 +64,15 @@ import { OpenAiService } from '@/backend/services/OpenAiService';
import AiProviderService from '@/backend/services/AiProviderService';


import dpLog from '@/backend/ioc/logger';


const container = new Container();
// Clients
container.bind<ClientProviderService<YouDaoClient>>(TYPES.YouDaoClientProvider).to(YouDaoProvider).inSingletonScope();
container.bind<ClientProviderService<TencentClient>>(TYPES.TencentClientProvider).to(TencentProvider).inSingletonScope();
container.bind<AiProviderService>(TYPES.AiProviderService).to(AiProviderServiceImpl).inSingletonScope();
container.bind(TYPES.Logger).toConstantValue(dpLog);
// Controllers
container.bind<Controller>(TYPES.Controller).to(FavoriteClipsController).inSingletonScope();
container.bind<Controller>(TYPES.Controller).to(DownloadVideoController).inSingletonScope();
Expand Down
1 change: 1 addition & 0 deletions src/backend/ioc/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ const TYPES = {
YouDaoClientProvider: Symbol('YouDaoClientProvider'),
TencentClientProvider: Symbol('TencentClientProvider'),
AiProviderService: Symbol('AiProviderService'),
Logger: Symbol('Logger'),
};

export default TYPES;
4 changes: 3 additions & 1 deletion src/backend/services/CacheService.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,9 @@
import { SrtSentence } from '@/common/types/SentenceC';
import { YdRes } from '@/common/types/YdRes';

export type CacheType ={
export type CacheType = {
'cache:srt': SrtSentence;
'cache:translation': YdRes;
}

export default interface CacheService {
Expand Down
10 changes: 9 additions & 1 deletion src/backend/services/SettingService.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,14 @@
import { SettingKey } from '@/common/types/store_schema';
import { AiProviderConfig, SettingKey } from '@/common/types/store_schema';

export default interface SettingService {
set(key: SettingKey, value: string): Promise<void>;
get(key: SettingKey): Promise<string>;

getAiProviderConfigs(): Promise<AiProviderConfig[]>;
addAiProviderConfig(config: AiProviderConfig): Promise<AiProviderConfig[]>;
updateAiProviderConfig(config: AiProviderConfig): Promise<AiProviderConfig[]>;
deleteAiProviderConfig(id: string): Promise<AiProviderConfig[]>;
setActiveAiProvider(id: string): Promise<void>;
getActiveAiProvider(): Promise<string | null>;
updateAllAiProviderConfigs(configs: AiProviderConfig[]): Promise<void>;
}
120 changes: 96 additions & 24 deletions src/backend/services/TtsService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import * as os from 'node:os';
import fs from 'fs';
import dpLog from '@/backend/ioc/logger';
import {WaitRateLimit} from "@/common/utils/RateLimiter";
import { EdgeTTS } from 'node-edge-tts';

class TtsService {
static joinUrl = (base: string, path2: string) => {
Expand All @@ -14,36 +15,107 @@ class TtsService {

@WaitRateLimit('tts')
public static async tts(str: string) {
if (StrUtil.isBlank(storeGet('apiKeys.openAi.key')) || StrUtil.isBlank(storeGet('apiKeys.openAi.endpoint'))) {
throw new Error('OpenAI API key or endpoint is not set');
const provider = storeGet('tts.provider') || 'edge-tts';

if (provider === 'openai') {
if (StrUtil.isBlank(storeGet('apiKeys.openAi.key')) || StrUtil.isBlank(storeGet('apiKeys.openAi.endpoint'))) {
throw new Error('OpenAI API key or endpoint is not set');
}
const url = this.joinUrl(storeGet('apiKeys.openAi.endpoint'), '/v1/audio/speech');
const headers = {
'Authorization': `Bearer ${storeGet('apiKeys.openAi.key')}`,
'User-Agent': 'Apifox/1.0.0 (https://apifox.com)',
'Content-Type': 'application/json',
responseType: 'arraybuffer'
};
const data = {
'model': 'tts-1',
'input': str,
'voice': 'alloy',
'response_format': 'mp3'
};

try {
const response = await axios.post(url, data, { headers, responseType: 'arraybuffer' });
const tempDir = path.join(os.tmpdir(), 'dp/tts');
if (!fs.existsSync(tempDir)) {
fs.mkdirSync(tempDir, { recursive: true });
}
const filename = str.replace(/[^a-zA-Z0-9]/g, '_') + '_openai.mp3';
const outputPath = path.join(tempDir, filename);
fs.writeFileSync(outputPath, Buffer.from(response.data), 'binary');
return outputPath;
} catch (error) {
dpLog.error(error);
throw new Error('Failed to generate TTS');
}
} else if (provider === 'edge-tts') {
try {
const voice = storeGet('tts.edgeTts.voice') || 'en-US-JennyNeural';
const tempDir = path.join(os.tmpdir(), 'dp/tts');
if (!fs.existsSync(tempDir)) {
fs.mkdirSync(tempDir, { recursive: true });
}
const filename = str.replace(/[^a-zA-Z0-9]/g, '_') + '_edge.mp3';
const outputPath = path.join(tempDir, filename);

const tts = new EdgeTTS({ voice });
await tts.ttsPromise(str, outputPath);

return outputPath;
} catch (error) {
dpLog.error(error);
throw new Error('Failed to generate TTS with Edge-TTS');
}
} else {
throw new Error('Unknown TTS provider');
}
}

public static async getEdgeTtsVoices(forceRefresh: boolean = false): Promise<any[]> {
const STORAGE_KEY = 'edgeTtsVoices';
const TIMESTAMP_KEY = 'edgeTtsVoicesTimestamp';
const CACHE_DURATION = 24 * 60 * 60 * 1000;

const cachedData = storeGet(STORAGE_KEY);
const cachedTimestamp = parseInt(storeGet(TIMESTAMP_KEY) || '0');
const now = Date.now();

if (!forceRefresh && cachedData && cachedTimestamp && (now - cachedTimestamp) < CACHE_DURATION) {
dpLog.info('Using cached Edge-TTS voices');
return JSON.parse(cachedData);
}
const url = this.joinUrl(storeGet('apiKeys.openAi.endpoint'), '/v1/audio/speech');
const headers = {
'Authorization': `Bearer ${storeGet('apiKeys.openAi.key')}`,
'User-Agent': 'Apifox/1.0.0 (https://apifox.com)',
'Content-Type': 'application/json',
responseType: 'arraybuffer'
};
const data = {
'model': 'tts-1',
'input': str,
'voice': 'alloy',
'response_format': 'mp3'
};

try {
const response = await axios.post(url, data, { headers, responseType: 'arraybuffer' });
const tempDir = path.join(os.tmpdir(), 'dp/tts');
if (!fs.existsSync(tempDir)) {
fs.mkdirSync(tempDir, { recursive: true });
dpLog.info(forceRefresh ? 'Force refreshing Edge-TTS voices' : 'Fetching Edge-TTS voices');
const url = 'https://speech.platform.bing.com/consumer/speech/synthesize/readaloud/voices/list?trustedclienttoken=6A5AA1D4EAFF4E9FB37E23D68491D6F4';
const response = await axios.get(url);
const voices = response.data;

if (voices && voices.length > 0) {
const fs = require('fs');
const path = require('path');

const userDataPath = require('electron').app.getPath('userData');
const voicesFile = path.join(userDataPath, 'edge-tts-voices.json');
const timestampFile = path.join(userDataPath, 'edge-tts-voices-timestamp.txt');

fs.writeFileSync(voicesFile, JSON.stringify(voices), 'utf-8');
fs.writeFileSync(timestampFile, now.toString(), 'utf-8');

dpLog.info(`Saved ${voices.length} Edge-TTS voices to cache`);
}
const filename = str.replace(/[^a-zA-Z0-9]/g, '_') + '.mp3';
const outputPath = path.join(tempDir, filename);
fs.writeFileSync(outputPath, Buffer.from(response.data), 'binary');
return outputPath;

return voices;
} catch (error) {
dpLog.error(error);
throw new Error('Failed to generate TTS');

if (!forceRefresh && cachedData) {
dpLog.info('Using cached data due to fetch error');
return JSON.parse(cachedData);
}

throw new Error('Failed to get Edge-TTS voices');
}
}

Expand Down
Loading