Compare commits
8 Commits
v0.1.0-alp
...
v0.1.0-alp
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
050c577620 | ||
|
|
8d7a3e37ce | ||
|
|
23feb06d20 | ||
|
|
b545ea2362 | ||
|
|
187038c42e | ||
|
|
6cc9cb9da2 | ||
|
|
3cf168f098 | ||
|
|
2ceb122acf |
4
.github/workflows/release-enjoy-app.yml
vendored
4
.github/workflows/release-enjoy-app.yml
vendored
@@ -18,3 +18,7 @@ jobs:
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.PUBLISH_TOKEN }}
|
||||
run: yarn publish:enjoy
|
||||
- if: matrix.os == 'macos-latest'
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.PUBLISH_TOKEN }}
|
||||
run: yarn run publish --arch=arm64
|
||||
|
||||
@@ -395,7 +395,7 @@ One of the reasons why many parents want to send their children to separate scho
|
||||
* 名词单复数形式错误:错误地使用了名词的数,包括:使用了不可数名词的 “复数” 形式,使用了集合名词的 “复数” 形式,在应该使用复数的地方使用了单数名词(或反之)等。
|
||||
* 单数可数名词未受限定:句子中出现的单数可数名词之前没有使用限定词,包括冠词、不定代词、指示代词、名词或代词所有格、数词与某些形容词性的物主代词。
|
||||
* 词性错误:在选择词汇的过程中忽略了英文词性的特性,仅按照含义来使用词汇,从而发生了词性使用错误的现象。
|
||||
* 修饰关系错误:违反了词汇修饰的规则,采用了不恰当的修饰关系。包括用* 形容词修饰动词、形容词修饰形容词,副词修饰名词等。
|
||||
* 修饰关系错误:违反了词汇修饰的规则,采用了不恰当的修饰关系。包括用形容词修饰动词、形容词修饰形容词,副词修饰名词等。
|
||||
* 搭配错误:句子中出现了不合适的词汇修饰、限制、说明现象,或者错误地使用了固有的词汇搭配形式。
|
||||
* 词序错误:未使用正确的、符合习惯的表述语序来对内容进行陈述。其中包括修饰词顺序错误,该倒装时没有倒装等。
|
||||
* 非谓语动词使用错误:错误地使用了现在分词、过去分词、或动词的不定式。其中包括:
|
||||
@@ -445,9 +445,9 @@ Style: Toward Clarity and Grace by Joseph M. Williams
|
||||
|
||||
几乎所有真正有效的学习手段都是简单、廉价、往往并不直接但却真正有效的。复述,就是这样的有效手段。
|
||||
|
||||
每个文化中的每个人在这方面都一样 —— 终其一生绝大多数情况下都在复述别人说过的话。首先语言文字很难纯粹 “原创”,其次绝大多数情况下确实也没有 必要 “独一无二”。更为重要的是,第二语言学习者的目标绝大多数情况下不是为了从事诗人、小说家之类的职业,而是希望多掌握一门用来承载信息沟通交流的工 具 —— 这种情况下 “复述” 几乎占据了第二语言应用的全部。
|
||||
每个文化中的每个人在这方面都一样 —— 终其一生绝大多数情况下都在复述别人说过的话。首先语言文字很难纯粹 “原创”,其次绝大多数情况下确实也没有必要 “独一无二”。更为重要的是,第二语言学习者的目标绝大多数情况下不是为了从事诗人、小说家之类的职业,而是希望多掌握一门用来承载信息沟通交流的工具 —— 这种情况下 “复述” 几乎占据了第二语言应用的全部。
|
||||
|
||||
这还真的并不是那么 “显而易见” 的事实。ETS 在设计并举办 TOEFL 考试几十年之后才 “恍然大悟” 地在新托福考试中大面积添加了 “复述能力” 的考 量:TOEFL 作文部分中有综合测试,要求考生先读一篇文章,然后再听一篇与刚刚读过的文章相关的讲座,而后复述讲座内容以及讲座内容是如何与阅读文章内 容相联系的;口语部分中有先听再说,先读再说,听与读之后再说 —— 无一不是在考量考生的 “复述能力”。
|
||||
这还真的并不是那么 “显而易见” 的事实。ETS 在设计并举办 TOEFL 考试几十年之后才 “恍然大悟” 地在新托福考试中大面积添加了 “复述能力” 的考量:TOEFL 作文部分中有综合测试,要求考生先读一篇文章,然后再听一篇与刚刚读过的文章相关的讲座,而后复述讲座内容以及讲座内容是如何与阅读文章内容相联系的;口语部分中有先听再说,先读再说,听与读之后再说 —— 无一不是在考量考生的 “复述能力”。
|
||||
|
||||
## 9. 貌似多余:其实连哑巴英语都并不那么坏
|
||||
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
"config": "tailwind.config.js",
|
||||
"css": "src/index.css",
|
||||
"baseColor": "zinc",
|
||||
"cssVariables": true
|
||||
"cssVariables": false
|
||||
},
|
||||
"aliases": {
|
||||
"components": "src/renderer/components",
|
||||
|
||||
@@ -2,6 +2,7 @@ import type { ForgeConfig } from "@electron-forge/shared-types";
|
||||
import { MakerSquirrel } from "@electron-forge/maker-squirrel";
|
||||
import { MakerZIP } from "@electron-forge/maker-zip";
|
||||
import { MakerDeb } from "@electron-forge/maker-deb";
|
||||
import { MakerRpm } from "@electron-forge/maker-rpm";
|
||||
import { VitePlugin } from "@electron-forge/plugin-vite";
|
||||
import { dirname } from "node:path";
|
||||
import { Walker, DepType, type Module } from "flora-colossus";
|
||||
@@ -44,6 +45,14 @@ const config: ForgeConfig = {
|
||||
mimeType: ["x-scheme-handler/enjoy"],
|
||||
},
|
||||
}),
|
||||
new MakerRpm({
|
||||
options: {
|
||||
name: "enjoy",
|
||||
productName: "Enjoy",
|
||||
icon: "./assets/icon.png",
|
||||
mimeType: ["x-scheme-handler/enjoy"],
|
||||
},
|
||||
}),
|
||||
],
|
||||
publishers: [
|
||||
{
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
"private": true,
|
||||
"name": "enjoy",
|
||||
"productName": "Enjoy",
|
||||
"version": "0.1.0-alpha.2",
|
||||
"version": "0.1.0-alpha.3",
|
||||
"description": "Enjoy desktop app",
|
||||
"main": ".vite/build/main.js",
|
||||
"types": "./src/types.d.ts",
|
||||
|
||||
@@ -32,9 +32,9 @@ export const WHISPER_MODELS_OPTIONS = [
|
||||
},
|
||||
{
|
||||
type: "large",
|
||||
name: "ggml-large.bin",
|
||||
name: "ggml-large-v3.bin",
|
||||
size: "3.09 GB",
|
||||
url: "https://huggingface.co/ggerganov/whisper.cpp/blob/main/ggml-large-v3.bin",
|
||||
url: "https://huggingface.co/ggerganov/whisper.cpp/resolve/main/ggml-large-v3.bin",
|
||||
},
|
||||
];
|
||||
|
||||
@@ -46,35 +46,3 @@ export const PROCESS_TIMEOUT = 1000 * 60 * 15;
|
||||
|
||||
export const AI_GATEWAY_ENDPOINT =
|
||||
"https://gateway.ai.cloudflare.com/v1/11d43ab275eb7e1b271ba4089ecc3864/enjoy";
|
||||
|
||||
export const CONVERSATION_PRESET_SCENARIOS: {
|
||||
scenario: string;
|
||||
autoSpeech: boolean;
|
||||
prompt: string;
|
||||
}[] = [
|
||||
{
|
||||
scenario: "translation",
|
||||
autoSpeech: false,
|
||||
prompt: `Act as a translation machine that converts any language input I provide into fluent, idiomatic American English. If the input is already in English, refine it to sound like native American English.
|
||||
|
||||
Suggestions:
|
||||
|
||||
Ensure that the translation maintains the original meaning and tone of the input as much as possible.
|
||||
|
||||
In case of English inputs, focus on enhancing clarity, grammar, and style to match American English standards.
|
||||
|
||||
Return the translation only, no other words needed.
|
||||
`,
|
||||
},
|
||||
{
|
||||
scenario: "vocal_coach",
|
||||
autoSpeech: true,
|
||||
prompt: `As an AI English vocal coach with an American accent, engage in a conversation with me to help improve my spoken English skills. Use the appropriate tone and expressions that a native American English speaker would use, keeping in mind that your responses will be converted to audio.
|
||||
|
||||
Suggestions:
|
||||
|
||||
Use common American idioms and phrases to give a more authentic experience of American English.
|
||||
Provide corrections and suggestions for improvement in a supportive and encouraging manner.
|
||||
Use a variety of sentence structures and vocabulary to expose me to different aspects of the language.`,
|
||||
},
|
||||
];
|
||||
|
||||
@@ -190,9 +190,20 @@
|
||||
"AIModel": "AI Model",
|
||||
"chooseAIModelToDownload": "Choose AI Model to download",
|
||||
"ffmpegCheck": "FFmpeg Check",
|
||||
"check": "Check",
|
||||
"ffmpegCommandIsWorking": "FFmpeg command is working",
|
||||
"ffmpegCommandIsNotWorking": "FFmpeg command is not working",
|
||||
"scan": "Scan",
|
||||
"checkIfFfmpegIsInstalled": "Check if FFmpeg is installed",
|
||||
"ffmpegInstalled": "FFmpeg is installed",
|
||||
"ffmpegNotInstalled": "FFmpeg is not installed.",
|
||||
"ffmpegFoundAt": "FFmpeg found at {{path}}",
|
||||
"ffmpegNotFound": "FFmpeg not found",
|
||||
"ffmpegInstallSteps": "FFmpeg Install Steps",
|
||||
"Install": "Install",
|
||||
"runTheFollowingCommandInTerminal": "Run the following command in terminal",
|
||||
"click": "Click",
|
||||
"willAutomaticallyFindFFmpeg": "Enjoy will automatically find FFmpeg",
|
||||
"tryingToFindValidFFmepgInTheseDirectories": "Trying to find valid FFmpeg in these directories: {{dirs}}",
|
||||
"invalidFfmpegPath": "Invalid FFmpeg path",
|
||||
"usingInstalledFFmpeg": "Using installed FFmpeg",
|
||||
"usingDownloadedFFmpeg": "Using downloaded FFmpeg",
|
||||
"downloadFfmpeg": "Download FFmpeg",
|
||||
@@ -208,7 +219,10 @@
|
||||
"reset": "Reset",
|
||||
"resetAll": "Reset All",
|
||||
"resetAllConfirmation": "It will remove all of your personal data, are you sure?",
|
||||
"resetSettings": "Reset Settings",
|
||||
"resetSettingsConfirmation": "It will reset all of your settings, are you sure? The library will not be affected.",
|
||||
"logoutAndRemoveAllPersonalData": "Logout and remove all personal data",
|
||||
"logoutAndRemoveAllPersonalSettings": "Logout and remove all personal settings",
|
||||
"hotkeys": "Hotkeys",
|
||||
"quitApp": "Quit APP",
|
||||
"openPreferences": "Open preferences",
|
||||
@@ -267,8 +281,11 @@
|
||||
"recordingActivity": "recording activity",
|
||||
"recordingDetail": "Recording detail",
|
||||
"noRecordingActivities": "no recording activities",
|
||||
"basicSettingsShort": "Basic",
|
||||
"basicSettings": "Basic settings",
|
||||
"advancedSettingsShort": "Advanced",
|
||||
"advancedSettings": "Advanced settings",
|
||||
"advanced": "Advanced",
|
||||
"language": "Language",
|
||||
"sttAiModel": "STT AI model",
|
||||
"relaunchIsNeededAfterChanged": "Relaunch is needed after changed",
|
||||
|
||||
@@ -190,9 +190,20 @@
|
||||
"AIModel": "AI 模型",
|
||||
"chooseAIModelToDownload": "选择 AI 模型下载",
|
||||
"ffmpegCheck": "FFmpeg 检查",
|
||||
"check": "检查",
|
||||
"ffmpegCommandIsWorking": "FFmpeg 命令正常工作",
|
||||
"ffmpegCommandIsNotWorking": "FFmpeg 命令无法正常工作",
|
||||
"scan": "查找",
|
||||
"checkIfFfmpegIsInstalled": "检查 FFmpeg 是否已正确安装",
|
||||
"ffmpegInstalled": "FFmpeg 已经安装",
|
||||
"ffmpegNotInstalled": "FFmpeg 未安装,软件部分功能依赖于 FFmpeg。",
|
||||
"ffmpegFoundAt": "检测到 FFmpeg 命令: {{path}}",
|
||||
"ffmpegNotFound": "未检测到可用的 FFmpeg 命令",
|
||||
"ffmpegInstallSteps": "FFmpeg 安装步骤",
|
||||
"Install": "安装",
|
||||
"runTheFollowingCommandInTerminal": "在终端中运行以下命令",
|
||||
"click": "点击",
|
||||
"willAutomaticallyFindFFmpeg": "Enjoy 将自动检测 FFmpeg 命令",
|
||||
"tryingToFindValidFFmepgInTheseDirectories": "正在尝试在以下目录中查找有效的 FFmpeg 命令: {{dirs}}",
|
||||
"invalidFfmpegPath": "无效的 FFmpeg 路径",
|
||||
"usingInstalledFFmpeg": "使用已安装的 FFmpeg",
|
||||
"usingDownloadedFFmpeg": "使用下载的 FFmpeg",
|
||||
"downloadFfmpeg": "下载 FFmpeg",
|
||||
@@ -208,7 +219,10 @@
|
||||
"reset": "重置",
|
||||
"resetAll": "重置所有",
|
||||
"resetAllConfirmation": "这将删除您的所有个人数据, 您确定要重置吗?",
|
||||
"resetSettings": "重置设置选项",
|
||||
"resetSettingsConfirmation": "您确定要重置个人设置选项吗?资料库不会受影响。",
|
||||
"logoutAndRemoveAllPersonalData": "退出登录并删除所有个人数据",
|
||||
"logoutAndRemoveAllPersonalSettings": "退出登录并删除所有个人设置选项",
|
||||
"hotkeys": "快捷键",
|
||||
"quitApp": "退出应用",
|
||||
"openPreferences": "打开设置",
|
||||
@@ -267,7 +281,9 @@
|
||||
"recordingActivity": "练习活动",
|
||||
"recordingDetail": "录音详情",
|
||||
"noRecordingActivities": "没有练习活动",
|
||||
"basicSettingsShort": "基本设置",
|
||||
"basicSettings": "基本设置",
|
||||
"advancedSettingsShort": "高级设置",
|
||||
"advancedSettings": "高级设置",
|
||||
"language": "语言",
|
||||
"sttAiModel": "语音转文本 AI 模型",
|
||||
|
||||
@@ -51,9 +51,6 @@ db.connect = async () => {
|
||||
|
||||
db.connection = sequelize;
|
||||
|
||||
// vacuum the database
|
||||
await sequelize.query("VACUUM");
|
||||
|
||||
const umzug = new Umzug({
|
||||
migrations: { glob: __dirname + "/migrations/*.js" },
|
||||
context: sequelize.getQueryInterface(),
|
||||
@@ -68,6 +65,23 @@ db.connect = async () => {
|
||||
await sequelize.sync();
|
||||
await sequelize.authenticate();
|
||||
|
||||
// TODO:
|
||||
// clear the large waveform data in DB.
|
||||
// Remove this in next release
|
||||
const caches = await CacheObject.findAll({
|
||||
attributes: ["id", "key"],
|
||||
});
|
||||
const cacheIds: string[] = [];
|
||||
caches.forEach((cache) => {
|
||||
if (cache.key.startsWith("waveform")) {
|
||||
cacheIds.push(cache.id);
|
||||
}
|
||||
});
|
||||
await CacheObject.destroy({ where: { id: cacheIds } });
|
||||
|
||||
// vacuum the database
|
||||
await sequelize.query("VACUUM");
|
||||
|
||||
// register handlers
|
||||
audiosHandler.register();
|
||||
cacheObjectsHandler.register();
|
||||
|
||||
@@ -7,14 +7,20 @@ import fs from "fs-extra";
|
||||
import AdmZip from "adm-zip";
|
||||
import downloader from "@main/downloader";
|
||||
import storage from "@main/storage";
|
||||
import readdirp from "readdirp";
|
||||
import { t } from "i18next";
|
||||
|
||||
const logger = log.scope("ffmepg");
|
||||
const logger = log.scope("ffmpeg");
|
||||
export default class FfmpegWrapper {
|
||||
public ffmpeg: Ffmpeg.FfmpegCommand;
|
||||
public config: any;
|
||||
|
||||
constructor() {
|
||||
this.config = settings.ffmpegConfig();
|
||||
constructor(config?: {
|
||||
ffmpegPath: string;
|
||||
ffprobePath: string;
|
||||
commandExists?: boolean;
|
||||
}) {
|
||||
this.config = config || settings.ffmpegConfig();
|
||||
|
||||
if (this.config.commandExists) {
|
||||
logger.info("Using system ffmpeg");
|
||||
@@ -28,7 +34,7 @@ export default class FfmpegWrapper {
|
||||
}
|
||||
}
|
||||
|
||||
checkCommand() {
|
||||
checkCommand(): Promise<boolean> {
|
||||
return new Promise((resolve, _reject) => {
|
||||
this.ffmpeg.getAvailableFormats((err, formats) => {
|
||||
if (err) {
|
||||
@@ -319,17 +325,106 @@ export class FfmpegDownloader {
|
||||
if (valid) {
|
||||
event.sender.send("on-notification", {
|
||||
type: "success",
|
||||
message: `FFmpeg command valid, you're ready to go.`,
|
||||
message: t("ffmpegCommandIsWorking"),
|
||||
});
|
||||
} else {
|
||||
logger.error("FFmpeg command not valid", ffmpeg.config);
|
||||
event.sender.send("on-notification", {
|
||||
type: "warning",
|
||||
message: `FFmpeg command not valid, please check the log for detail.`,
|
||||
message: t("ffmpegCommandIsNotWorking"),
|
||||
});
|
||||
}
|
||||
|
||||
return valid;
|
||||
});
|
||||
|
||||
ipcMain.handle("ffmpeg-discover-command", async (event) => {
|
||||
try {
|
||||
return await discoverFfmpeg();
|
||||
} catch (err) {
|
||||
logger.error(err);
|
||||
event.sender.send("on-notification", {
|
||||
type: "error",
|
||||
message: `FFmpeg discover failed: ${err.message}`,
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export const discoverFfmpeg = async () => {
|
||||
const platform = process.platform;
|
||||
let ffmpegPath: string;
|
||||
let ffprobePath: string;
|
||||
const libraryFfmpegPath = path.join(settings.libraryPath(), "ffmpeg");
|
||||
const scanDirs = [...COMMAND_SCAN_DIR[platform], libraryFfmpegPath];
|
||||
|
||||
await Promise.all(
|
||||
scanDirs.map(async (dir: string) => {
|
||||
if (!fs.existsSync(dir)) return;
|
||||
|
||||
dir = path.resolve(dir);
|
||||
log.info("FFmpeg scanning: " + dir);
|
||||
|
||||
const fileStream = readdirp(dir, {
|
||||
depth: 3,
|
||||
});
|
||||
|
||||
for await (const entry of fileStream) {
|
||||
const appName = entry.basename
|
||||
.replace(".app", "")
|
||||
.replace(".exe", "")
|
||||
.toLowerCase();
|
||||
|
||||
if (appName === "ffmpeg") {
|
||||
logger.info("Found ffmpeg: ", entry.fullPath);
|
||||
ffmpegPath = entry.fullPath;
|
||||
}
|
||||
|
||||
if (appName === "ffprobe") {
|
||||
logger.info("Found ffprobe: ", entry.fullPath);
|
||||
ffprobePath = entry.fullPath;
|
||||
}
|
||||
|
||||
if (ffmpegPath && ffprobePath) break;
|
||||
}
|
||||
})
|
||||
);
|
||||
|
||||
let valid = false;
|
||||
if (ffmpegPath && ffprobePath) {
|
||||
const ffmepg = new FfmpegWrapper({ ffmpegPath, ffprobePath });
|
||||
valid = await ffmepg.checkCommand();
|
||||
}
|
||||
|
||||
if (valid) {
|
||||
settings.setSync("ffmpeg", {
|
||||
ffmpegPath,
|
||||
ffprobePath,
|
||||
});
|
||||
} else {
|
||||
ffmpegPath = undefined;
|
||||
ffprobePath = undefined;
|
||||
settings.setSync("ffmpeg", null);
|
||||
}
|
||||
|
||||
return {
|
||||
ffmpegPath,
|
||||
ffprobePath,
|
||||
scanDirs,
|
||||
};
|
||||
};
|
||||
|
||||
export const COMMAND_SCAN_DIR: { [key: string]: string[] } = {
|
||||
darwin: [
|
||||
"/Applications",
|
||||
process.env.HOME + "/Applications",
|
||||
"/opt/homebrew/bin",
|
||||
],
|
||||
linux: ["/usr/bin", "/usr/local/bin", "/snap/bin"],
|
||||
win32: [
|
||||
process.env.SystemDrive + "\\Program Files\\",
|
||||
process.env.SystemDrive + "\\Program Files (x86)\\",
|
||||
process.env.LOCALAPPDATA + "\\Apps\\2.0\\",
|
||||
],
|
||||
};
|
||||
|
||||
@@ -96,32 +96,19 @@ const userDataPath = () => {
|
||||
};
|
||||
|
||||
const ffmpegConfig = () => {
|
||||
const _ffmpegPath = path.join(
|
||||
libraryPath(),
|
||||
"ffmpeg",
|
||||
os.platform() === "win32" ? "ffmpeg.exe" : "ffmpeg"
|
||||
);
|
||||
const _ffprobePath = path.join(
|
||||
libraryPath(),
|
||||
"ffmpeg",
|
||||
os.platform() === "win32" ? "ffprobe.exe" : "ffprobe"
|
||||
);
|
||||
|
||||
const ffmpegPath = fs.existsSync(_ffmpegPath) ? _ffmpegPath : "";
|
||||
const ffprobePath = fs.existsSync(_ffprobePath) ? _ffprobePath : "";
|
||||
const ffmpegPath = settings.getSync("ffmpeg.ffmpegPath");
|
||||
const ffprobePath = settings.getSync("ffmpeg.ffprobePath");
|
||||
|
||||
const _commandExists =
|
||||
commandExists.sync("ffmpeg") && commandExists.sync("ffprobe");
|
||||
|
||||
const ready = Boolean(_commandExists || (ffmpegPath && ffprobePath));
|
||||
|
||||
const config = {
|
||||
os: os.platform(),
|
||||
arch: os.arch(),
|
||||
commandExists: _commandExists,
|
||||
ffmpegPath,
|
||||
ffprobePath,
|
||||
ready,
|
||||
ready: Boolean(_commandExists || (ffmpegPath && ffprobePath)),
|
||||
};
|
||||
|
||||
logger.info("ffmpeg config", config);
|
||||
|
||||
38
enjoy/src/main/waveform.ts
Normal file
38
enjoy/src/main/waveform.ts
Normal file
@@ -0,0 +1,38 @@
|
||||
import { ipcMain } from "electron";
|
||||
import settings from "@main/settings";
|
||||
import path from "path";
|
||||
import fs from "fs-extra";
|
||||
|
||||
export class Waveform {
|
||||
public dir = path.join(settings.libraryPath(), "waveforms");
|
||||
|
||||
constructor() {
|
||||
fs.ensureDirSync(this.dir);
|
||||
}
|
||||
|
||||
find(id: string) {
|
||||
const file = path.join(this.dir, id + ".waveform.json");
|
||||
|
||||
if (fs.existsSync(file)) {
|
||||
return fs.readJsonSync(file);
|
||||
} else {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
save(id: string, data: WaveFormDataType) {
|
||||
const file = path.join(this.dir, id + ".waveform.json");
|
||||
|
||||
fs.writeJsonSync(file, data);
|
||||
}
|
||||
|
||||
registerIpcHandlers() {
|
||||
ipcMain.handle("waveforms-find", async (_event, id) => {
|
||||
return this.find(id);
|
||||
});
|
||||
|
||||
ipcMain.handle("waveforms-save", (_event, id, data) => {
|
||||
return this.save(id, data);
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -18,6 +18,7 @@ import log from "electron-log/main";
|
||||
import { WEB_API_URL } from "@/constants";
|
||||
import { AudibleProvider, TedProvider } from "@main/providers";
|
||||
import { FfmpegDownloader } from "@main/ffmpeg";
|
||||
import { Waveform } from "./waveform";
|
||||
|
||||
log.initialize({ preload: true });
|
||||
const logger = log.scope("window");
|
||||
@@ -25,6 +26,7 @@ const logger = log.scope("window");
|
||||
const audibleProvider = new AudibleProvider();
|
||||
const tedProvider = new TedProvider();
|
||||
const ffmpegDownloader = new FfmpegDownloader();
|
||||
const waveform = new Waveform();
|
||||
|
||||
const main = {
|
||||
win: null as BrowserWindow | null,
|
||||
@@ -46,6 +48,9 @@ main.init = () => {
|
||||
// Whisper
|
||||
whisper.registerIpcHandlers();
|
||||
|
||||
// Waveform
|
||||
waveform.registerIpcHandlers();
|
||||
|
||||
// Downloader
|
||||
downloader.registerIpcHandlers();
|
||||
|
||||
@@ -216,6 +221,14 @@ main.init = () => {
|
||||
// App options
|
||||
ipcMain.handle("app-reset", () => {
|
||||
fs.removeSync(settings.userDataPath());
|
||||
fs.removeSync(settings.file());
|
||||
|
||||
app.relaunch();
|
||||
app.exit();
|
||||
});
|
||||
|
||||
ipcMain.handle("app-reset-settings", () => {
|
||||
fs.removeSync(settings.file());
|
||||
|
||||
app.relaunch();
|
||||
app.exit();
|
||||
|
||||
@@ -8,6 +8,9 @@ contextBridge.exposeInMainWorld("__ENJOY_APP__", {
|
||||
reset: () => {
|
||||
ipcRenderer.invoke("app-reset");
|
||||
},
|
||||
resetSettings: () => {
|
||||
ipcRenderer.invoke("app-reset-settings");
|
||||
},
|
||||
relaunch: () => {
|
||||
ipcRenderer.invoke("app-relaunch");
|
||||
},
|
||||
@@ -143,12 +146,15 @@ contextBridge.exposeInMainWorld("__ENJOY_APP__", {
|
||||
getFfmpegConfig: () => {
|
||||
return ipcRenderer.invoke("settings-get-ffmpeg-config");
|
||||
},
|
||||
setFfmpegConfig: (config: FfmpegConfigType) => {
|
||||
return ipcRenderer.invoke("settings-set-ffmpeg-config", config);
|
||||
},
|
||||
getLanguage: (language: string) => {
|
||||
return ipcRenderer.invoke("settings-get-language", language);
|
||||
},
|
||||
switchLanguage: (language: string) => {
|
||||
return ipcRenderer.invoke("settings-switch-language", language);
|
||||
}
|
||||
},
|
||||
},
|
||||
path: {
|
||||
join: (...paths: string[]) => {
|
||||
@@ -344,6 +350,9 @@ contextBridge.exposeInMainWorld("__ENJOY_APP__", {
|
||||
download: () => {
|
||||
return ipcRenderer.invoke("ffmpeg-download");
|
||||
},
|
||||
discover: () => {
|
||||
return ipcRenderer.invoke("ffmpeg-discover-command");
|
||||
},
|
||||
check: () => {
|
||||
return ipcRenderer.invoke("ffmpeg-check-command");
|
||||
},
|
||||
@@ -390,4 +399,12 @@ contextBridge.exposeInMainWorld("__ENJOY_APP__", {
|
||||
return ipcRenderer.invoke("transcriptions-update", id, params);
|
||||
},
|
||||
},
|
||||
waveforms: {
|
||||
find: (id: string) => {
|
||||
return ipcRenderer.invoke("waveforms-find", id);
|
||||
},
|
||||
save: (id: string, data: WaveFormDataType) => {
|
||||
return ipcRenderer.invoke("waveforms-save", id, data);
|
||||
},
|
||||
}
|
||||
});
|
||||
|
||||
@@ -53,7 +53,7 @@ function App() {
|
||||
<AISettingsProvider>
|
||||
<DbProvider>
|
||||
<RouterProvider router={router} />
|
||||
<Toaster richColors />
|
||||
<Toaster richColors closeButton position="top-center" />
|
||||
<Tooltip id="global-tooltip" />
|
||||
</DbProvider>
|
||||
</AISettingsProvider>
|
||||
|
||||
@@ -132,7 +132,7 @@ export const AudioDetail = (props: { id?: string; md5?: string }) => {
|
||||
mediaId={audio.id}
|
||||
mediaType="Audio"
|
||||
mediaUrl={audio.src}
|
||||
waveformCacheKey={`waveform-audio-${audio.md5}`}
|
||||
mediaMd5={audio.md5}
|
||||
transcription={transcription}
|
||||
currentTime={currentTime}
|
||||
setCurrentTime={setCurrentTime}
|
||||
@@ -207,7 +207,7 @@ export const AudioDetail = (props: { id?: string; md5?: string }) => {
|
||||
</AlertDialog>
|
||||
|
||||
{!initialized && (
|
||||
<div className="top-0 w-full h-full absolute z-30 bg-white/10 flex items-center justify-center">
|
||||
<div className="top-0 w-full h-full absolute z-30 bg-background/10 flex items-center justify-center">
|
||||
<LoaderIcon className="text-muted-foreground animate-spin w-8 h-8" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -48,7 +48,7 @@ export const ConversationsShortcut = (props: {
|
||||
<div
|
||||
key={conversation.id}
|
||||
onClick={() => ask(conversation)}
|
||||
className="bg-white text-primary rounded-full w-full mb-2 py-2 px-4 hover:bg-primary hover:text-white cursor-pointer flex items-center border"
|
||||
className="bg-background text-primary rounded-full w-full mb-2 py-2 px-4 hover:bg-primary hover:text-white cursor-pointer flex items-center border"
|
||||
style={{
|
||||
borderLeftColor: `#${conversation.id
|
||||
.replaceAll("-", "")
|
||||
|
||||
@@ -89,7 +89,7 @@ export const SpeechPlayer = (props: {
|
||||
</div>
|
||||
<div
|
||||
ref={ref}
|
||||
className="bg-white rounded-lg grid grid-cols-9 items-center relative pl-2 h-[100px]"
|
||||
className="bg-background rounded-lg grid grid-cols-9 items-center relative pl-2 h-[100px]"
|
||||
>
|
||||
{!initialized && (
|
||||
<div className="col-span-9 flex flex-col justify-around h-[80px]">
|
||||
|
||||
@@ -1,13 +1,19 @@
|
||||
import { t } from "i18next";
|
||||
import { useContext, useEffect, useState } from "react";
|
||||
import { Button, Progress } from "@renderer/components/ui";
|
||||
import { Button, Progress, toast } from "@renderer/components/ui";
|
||||
import { AppSettingsProviderContext } from "@renderer/context";
|
||||
import { CheckCircle2Icon, XCircleIcon, LoaderIcon } from "lucide-react";
|
||||
import Markdown from "react-markdown";
|
||||
|
||||
export const FfmpegCheck = () => {
|
||||
const { ffmpegConfig, setFfmegConfig, EnjoyApp } = useContext(
|
||||
AppSettingsProviderContext
|
||||
);
|
||||
const [scanResult, setScanResult] = useState<{
|
||||
ffmpegPath: string;
|
||||
ffprobePath: string;
|
||||
scanDirs: string[];
|
||||
}>();
|
||||
const [downloading, setDownloading] = useState(false);
|
||||
const [progress, setProgress] = useState(0);
|
||||
|
||||
@@ -17,15 +23,25 @@ export const FfmpegCheck = () => {
|
||||
});
|
||||
};
|
||||
|
||||
const discoverFfmpeg = () => {
|
||||
EnjoyApp.ffmpeg.discover().then((config) => {
|
||||
setScanResult(config);
|
||||
if (config.ffmpegPath && config.ffprobePath) {
|
||||
toast.success(t("ffmpegFound"));
|
||||
refreshFfmpegConfig();
|
||||
} else {
|
||||
toast.error(t("ffmpegNotFound"));
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
const downloadFfmpeg = () => {
|
||||
listenToDownloadState();
|
||||
setDownloading(true);
|
||||
EnjoyApp.ffmpeg
|
||||
.download()
|
||||
.then((config) => {
|
||||
if (config) {
|
||||
setFfmegConfig(config);
|
||||
}
|
||||
.then(() => {
|
||||
refreshFfmpegConfig();
|
||||
})
|
||||
.finally(() => {
|
||||
setDownloading(false);
|
||||
@@ -44,11 +60,11 @@ export const FfmpegCheck = () => {
|
||||
}, [ffmpegConfig?.ready]);
|
||||
|
||||
useEffect(() => {
|
||||
refreshFfmpegConfig();
|
||||
discoverFfmpeg();
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className="w-full max-w-sm px-6">
|
||||
<div className="w-full max-w-screen-md mx-auto px-6">
|
||||
{ffmpegConfig?.ready ? (
|
||||
<>
|
||||
<div className="flex justify-center items-center mb-8">
|
||||
@@ -58,7 +74,7 @@ export const FfmpegCheck = () => {
|
||||
<CheckCircle2Icon className="text-green-500 w-10 h-10 mb-4" />
|
||||
</div>
|
||||
<div className="text-center text-sm opacity-70">
|
||||
{t("ffmpegInstalled")}
|
||||
{t("ffmpegFoundAt", { path: ffmpegConfig.ffmpegPath })}
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
@@ -69,24 +85,87 @@ export const FfmpegCheck = () => {
|
||||
<div className="flex justify-center mb-4">
|
||||
<XCircleIcon className="text-red-500 w-10 h-10" />
|
||||
</div>
|
||||
<div className="text-center text-sm opacity-70 mb-4">
|
||||
{t("ffmpegNotInstalled")}
|
||||
<div className="mb-4">
|
||||
<div className="text-center text-sm mb-2">
|
||||
{t("ffmpegNotFound")}
|
||||
</div>
|
||||
|
||||
{scanResult && (
|
||||
<div className="text-center text-xs text-muted-foreground mb-2">
|
||||
{t("tryingToFindValidFFmepgInTheseDirectories", {
|
||||
dirs: scanResult.scanDirs.join(", "),
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center justify-center mb-4">
|
||||
<Button
|
||||
disabled={downloading}
|
||||
className=""
|
||||
onClick={downloadFfmpeg}
|
||||
>
|
||||
{downloading && <LoaderIcon className="animate-spin mr-2" />}
|
||||
{t("downloadFfmpeg")}
|
||||
|
||||
<div className="flex items-center justify-center space-x-4 mb-4">
|
||||
<Button onClick={discoverFfmpeg} variant="default">
|
||||
{t("scan")}
|
||||
</Button>
|
||||
|
||||
{ffmpegConfig.os === "win32" && (
|
||||
<Button
|
||||
variant="secondary"
|
||||
disabled={downloading}
|
||||
onClick={downloadFfmpeg}
|
||||
>
|
||||
{downloading && <LoaderIcon className="animate-spin mr-2" />}
|
||||
{t("download")}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{downloading && (
|
||||
<div className="w-full">
|
||||
<Progress value={progress} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{ffmpegConfig.os === "darwin" && (
|
||||
<div className="my-6 select-text prose mx-auto border rounded-lg p-4">
|
||||
<h3 className="text-center">{t("ffmpegInstallSteps")}</h3>
|
||||
<h4>
|
||||
1. {t("install")}{" "}
|
||||
<a
|
||||
className="cursor-pointer text-blue-500 hover:underline"
|
||||
onClick={() => {
|
||||
EnjoyApp.shell.openExternal("https://brew.sh/");
|
||||
}}
|
||||
>
|
||||
Homebrew
|
||||
</a>
|
||||
</h4>
|
||||
<p>{t("runTheFollowingCommandInTerminal")} </p>
|
||||
<pre>
|
||||
<code>
|
||||
/bin/bash -c "$(curl -fsSL
|
||||
https://raw.githubusercontent.com/Homebrew/install/HEAD/install.sh)"
|
||||
</code>
|
||||
</pre>
|
||||
|
||||
<h4>2. {t("install")} FFmpeg</h4>
|
||||
|
||||
<p>{t("runTheFollowingCommandInTerminal")} </p>
|
||||
<pre>
|
||||
<code>brew install ffmpeg</code>
|
||||
</pre>
|
||||
|
||||
<h4>3. {t("scan")} FFmpeg</h4>
|
||||
<p>
|
||||
{t("click")}
|
||||
<Button
|
||||
onClick={discoverFfmpeg}
|
||||
variant="default"
|
||||
size="sm"
|
||||
className="mx-2"
|
||||
>
|
||||
{t("scan")}
|
||||
</Button>
|
||||
, {t("willAutomaticallyFindFFmpeg")}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -1,10 +1,13 @@
|
||||
import { Button, toast } from "@renderer/components/ui";
|
||||
import { Button, toast, Separator } from "@renderer/components/ui";
|
||||
import { useContext, useEffect } from "react";
|
||||
import { AppSettingsProviderContext } from "@renderer/context";
|
||||
import { t } from "i18next";
|
||||
import { UserSettings, LanguageSettings } from "@renderer/components";
|
||||
|
||||
export const LoginForm = () => {
|
||||
const { EnjoyApp, login, webApi } = useContext(AppSettingsProviderContext);
|
||||
const { EnjoyApp, login, webApi, user } = useContext(
|
||||
AppSettingsProviderContext
|
||||
);
|
||||
|
||||
const handleMixinLogin = () => {
|
||||
const url = `${webApi.baseUrl}/sessions/new?provider=mixin`;
|
||||
@@ -59,6 +62,16 @@ export const LoginForm = () => {
|
||||
};
|
||||
}, [webApi]);
|
||||
|
||||
if (user) {
|
||||
return (
|
||||
<div className="px-4 py-2 border rounded-lg w-full max-w-sm">
|
||||
<UserSettings />
|
||||
<Separator />
|
||||
<LanguageSettings />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="w-full max-w-sm px-6 flex flex-col space-y-4">
|
||||
<Button
|
||||
|
||||
@@ -34,7 +34,7 @@ export const MediaPlayer = (props: {
|
||||
mediaId: string;
|
||||
mediaType: "Audio" | "Video";
|
||||
mediaUrl: string;
|
||||
waveformCacheKey: string;
|
||||
mediaMd5?: string;
|
||||
transcription: TranscriptionType;
|
||||
// player controls
|
||||
currentTime: number;
|
||||
@@ -67,7 +67,7 @@ export const MediaPlayer = (props: {
|
||||
mediaId,
|
||||
mediaType,
|
||||
mediaUrl,
|
||||
waveformCacheKey,
|
||||
mediaMd5,
|
||||
transcription,
|
||||
height = 200,
|
||||
currentTime,
|
||||
@@ -94,12 +94,7 @@ export const MediaPlayer = (props: {
|
||||
if (!mediaUrl) return;
|
||||
|
||||
const [wavesurfer, setWavesurfer] = useState(null);
|
||||
const [waveform, setWaveForm] = useState<{
|
||||
peaks: number[];
|
||||
duration: number;
|
||||
frequencies: number[];
|
||||
sampleRate: number;
|
||||
}>(null);
|
||||
const [waveform, setWaveForm] = useState<WaveFormDataType>(null);
|
||||
const containerRef = useRef<HTMLDivElement>();
|
||||
const [mediaProvider, setMediaProvider] = useState<
|
||||
HTMLAudioElement | HTMLVideoElement
|
||||
@@ -181,7 +176,7 @@ export const MediaPlayer = (props: {
|
||||
|
||||
const renderPitchContour = (region: RegionType) => {
|
||||
if (!region) return;
|
||||
if (!waveform.frequencies.length) return;
|
||||
if (!waveform?.frequencies?.length) return;
|
||||
if (!wavesurfer) return;
|
||||
|
||||
const duration = wavesurfer.getDuration();
|
||||
@@ -280,7 +275,6 @@ export const MediaPlayer = (props: {
|
||||
const ws = WaveSurfer.create({
|
||||
container: containerRef.current,
|
||||
height,
|
||||
url: mediaUrl,
|
||||
waveColor: "#ddd",
|
||||
progressColor: "rgba(0, 0, 0, 0.25)",
|
||||
cursorColor: "#dc143c",
|
||||
@@ -324,6 +318,7 @@ export const MediaPlayer = (props: {
|
||||
const subscriptions = [
|
||||
wavesurfer.on("play", () => setIsPlaying(true)),
|
||||
wavesurfer.on("pause", () => setIsPlaying(false)),
|
||||
wavesurfer.on("loading", (percent: number) => console.log(percent)),
|
||||
wavesurfer.on("timeupdate", (time: number) => setCurrentTime(time)),
|
||||
wavesurfer.on("decode", () => {
|
||||
if (waveform?.frequencies) return;
|
||||
@@ -340,7 +335,7 @@ export const MediaPlayer = (props: {
|
||||
sampleRate,
|
||||
frequencies: _frequencies,
|
||||
};
|
||||
EnjoyApp.cacheObjects.set(waveformCacheKey, _waveform);
|
||||
EnjoyApp.waveforms.save(mediaMd5, _waveform);
|
||||
setWaveForm(_waveform);
|
||||
}),
|
||||
wavesurfer.on("ready", () => {
|
||||
@@ -479,10 +474,8 @@ export const MediaPlayer = (props: {
|
||||
}, [wavesurfer, isPlaying]);
|
||||
|
||||
useEffect(() => {
|
||||
EnjoyApp.cacheObjects.get(waveformCacheKey).then((cached) => {
|
||||
if (!cached) return;
|
||||
|
||||
setWaveForm(cached);
|
||||
EnjoyApp.waveforms.find(mediaMd5).then((waveform) => {
|
||||
setWaveForm(waveform);
|
||||
});
|
||||
}, []);
|
||||
|
||||
|
||||
@@ -90,11 +90,11 @@ export const AssistantMessageComponent = (props: {
|
||||
id={`message-${message.id}`}
|
||||
className="flex items-end space-x-2 pr-10"
|
||||
>
|
||||
<Avatar className="w-8 h-8 bg-white avatar">
|
||||
<Avatar className="w-8 h-8 bg-background avatar">
|
||||
<AvatarImage></AvatarImage>
|
||||
<AvatarFallback className="bg-white">AI</AvatarFallback>
|
||||
<AvatarFallback className="bg-background">AI</AvatarFallback>
|
||||
</Avatar>
|
||||
<div className="flex flex-col gap-2 px-4 py-2 bg-white border rounded-lg shadow-sm w-full">
|
||||
<div className="flex flex-col gap-2 px-4 py-2 bg-background border rounded-lg shadow-sm w-full">
|
||||
{configuration?.autoSpeech && speeching ? (
|
||||
<div className="p-4">
|
||||
<LoaderIcon className="w-8 h-8 animate-spin" />
|
||||
|
||||
@@ -32,6 +32,7 @@ import {
|
||||
} from "lucide-react";
|
||||
import { useCopyToClipboard } from "@uidotdev/usehooks";
|
||||
import { t } from "i18next";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import Markdown from "react-markdown";
|
||||
|
||||
export const UserMessageComponent = (props: {
|
||||
@@ -45,6 +46,7 @@ export const UserMessageComponent = (props: {
|
||||
const { user, webApi } = useContext(AppSettingsProviderContext);
|
||||
const [_, copyToClipboard] = useCopyToClipboard();
|
||||
const [copied, setCopied] = useState<boolean>(false);
|
||||
const navigate = useNavigate();
|
||||
|
||||
const handleShare = async () => {
|
||||
if (message.role === "user") {
|
||||
@@ -57,7 +59,18 @@ export const UserMessageComponent = (props: {
|
||||
},
|
||||
})
|
||||
.then(() => {
|
||||
toast(t("sharedSuccessfully"), { description: t("sharedPrompt") });
|
||||
toast.success(t("sharedSuccessfully"), {
|
||||
description: t("sharedPrompt"),
|
||||
action: {
|
||||
label: t("view"),
|
||||
onClick: () => {
|
||||
navigate("/community");
|
||||
},
|
||||
},
|
||||
actionButtonStyle: {
|
||||
backgroundColor: "var(--primary)",
|
||||
},
|
||||
});
|
||||
})
|
||||
.catch((err) => {
|
||||
toast.error(t("shareFailed"), { description: err.message });
|
||||
@@ -155,7 +168,7 @@ export const UserMessageComponent = (props: {
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
|
||||
<Avatar className="w-8 h-8 bg-white">
|
||||
<Avatar className="w-8 h-8 bg-background">
|
||||
<AvatarImage src={user.avatarUrl} />
|
||||
<AvatarFallback className="bg-primary text-white capitalize">
|
||||
{user.name[0]}
|
||||
|
||||
@@ -165,7 +165,7 @@ const WavesurferPlayer = (props: {
|
||||
|
||||
<div
|
||||
ref={ref}
|
||||
className="bg-white rounded-lg grid grid-cols-9 items-center relative h-[80px]"
|
||||
className="bg-background rounded-lg grid grid-cols-9 items-center relative h-[80px]"
|
||||
>
|
||||
{!initialized && (
|
||||
<div className="col-span-9 flex flex-col justify-around h-[80px]">
|
||||
|
||||
@@ -20,7 +20,7 @@ export const PostCard = (props: {
|
||||
const { user } = useContext(AppSettingsProviderContext);
|
||||
|
||||
return (
|
||||
<div className="rounded p-4 bg-white space-y-3">
|
||||
<div className="rounded p-4 bg-background space-y-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center space-x-2">
|
||||
<Avatar>
|
||||
|
||||
@@ -10,6 +10,35 @@ export const AdvancedSettings = () => {
|
||||
{t("advancedSettings")}
|
||||
</div>
|
||||
|
||||
<div className="flex items-start justify-between py-4">
|
||||
<div className="">
|
||||
<div className="mb-2">{t("resetSettings")}</div>
|
||||
<div className="text-sm text-muted-foreground mb-2">
|
||||
{t("logoutAndRemoveAllPersonalSettings")}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="">
|
||||
<div className="mb-2 flex justify-end">
|
||||
<ResetAllButton>
|
||||
<Button
|
||||
variant="secondary"
|
||||
className="text-destructive"
|
||||
size="sm"
|
||||
>
|
||||
{t("resetSettings")}
|
||||
</Button>
|
||||
</ResetAllButton>
|
||||
</div>
|
||||
<div className="text-xs text-muted-foreground">
|
||||
<InfoIcon className="mr-1 w-3 h-3 inline" />
|
||||
<span>{t("relaunchIsNeededAfterChanged")}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Separator />
|
||||
|
||||
<div className="flex items-start justify-between py-4">
|
||||
<div className="">
|
||||
<div className="mb-2">{t("resetAll")}</div>
|
||||
@@ -26,7 +55,7 @@ export const AdvancedSettings = () => {
|
||||
className="text-destructive"
|
||||
size="sm"
|
||||
>
|
||||
{t("reset")}
|
||||
{t("resetAll")}
|
||||
</Button>
|
||||
</ResetAllButton>
|
||||
</div>
|
||||
|
||||
@@ -36,7 +36,7 @@ import {
|
||||
} from "@renderer/context";
|
||||
import { useContext, useState, useRef, useEffect } from "react";
|
||||
import { redirect } from "react-router-dom";
|
||||
import { InfoIcon } from "lucide-react";
|
||||
import { InfoIcon, EditIcon } from "lucide-react";
|
||||
|
||||
export const BasicSettings = () => {
|
||||
return (
|
||||
@@ -60,7 +60,7 @@ export const BasicSettings = () => {
|
||||
);
|
||||
};
|
||||
|
||||
const UserSettings = () => {
|
||||
export const UserSettings = () => {
|
||||
const { user, logout } = useContext(AppSettingsProviderContext);
|
||||
|
||||
if (!user) return null;
|
||||
@@ -113,7 +113,7 @@ const UserSettings = () => {
|
||||
);
|
||||
};
|
||||
|
||||
const LanguageSettings = () => {
|
||||
export const LanguageSettings = () => {
|
||||
const { language, switchLanguage } = useContext(AppSettingsProviderContext);
|
||||
|
||||
return (
|
||||
@@ -188,7 +188,11 @@ const LibraryPathSettings = () => {
|
||||
<Button variant="secondary" size="sm" onClick={openLibraryPath}>
|
||||
{t("open")}
|
||||
</Button>
|
||||
<Button variant="default" size="sm" onClick={handleChooseLibraryPath}>
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
onClick={handleChooseLibraryPath}
|
||||
>
|
||||
{t("edit")}
|
||||
</Button>
|
||||
</div>
|
||||
@@ -202,49 +206,103 @@ const LibraryPathSettings = () => {
|
||||
};
|
||||
|
||||
const FfmpegSettings = () => {
|
||||
const { libraryPath, EnjoyApp } = useContext(AppSettingsProviderContext);
|
||||
const [config, setConfig] = useState<FfmpegConfigType>();
|
||||
const { EnjoyApp, setFfmegConfig, ffmpegConfig } = useContext(
|
||||
AppSettingsProviderContext
|
||||
);
|
||||
const [editing, setEditing] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
EnjoyApp.settings.getFfmpegConfig().then((_config) => {
|
||||
setConfig(_config);
|
||||
const refreshFfmpegConfig = async () => {
|
||||
EnjoyApp.settings.getFfmpegConfig().then((config) => {
|
||||
setFfmegConfig(config);
|
||||
});
|
||||
}, []);
|
||||
};
|
||||
|
||||
const handleChooseFfmpeg = async () => {
|
||||
const filePaths = await EnjoyApp.dialog.showOpenDialog({
|
||||
properties: ["openFile"],
|
||||
});
|
||||
|
||||
const path = filePaths?.[0];
|
||||
if (!path) return;
|
||||
|
||||
if (path.includes("ffmpeg")) {
|
||||
EnjoyApp.settings.setFfmpegConfig({
|
||||
...ffmpegConfig,
|
||||
ffmpegPath: path,
|
||||
});
|
||||
refreshFfmpegConfig();
|
||||
} else if (path.includes("ffprobe")) {
|
||||
EnjoyApp.settings.setFfmpegConfig({
|
||||
...ffmpegConfig,
|
||||
ffprobePath: path,
|
||||
});
|
||||
refreshFfmpegConfig();
|
||||
} else {
|
||||
toast.error(t("invalidFfmpegPath"));
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex items-start justify-between py-4">
|
||||
<div className="">
|
||||
<div className="mb-2">FFmpeg</div>
|
||||
<div className="text-sm text-muted-foreground mb-2">
|
||||
{config?.commandExists && t("usingInstalledFFmpeg")}
|
||||
{!config?.commandExists &&
|
||||
`${t("usingDownloadedFFmpeg")}: ${config?.ffmpegPath}`}
|
||||
<>
|
||||
<div className="flex items-start justify-between py-4">
|
||||
<div className="">
|
||||
<div className="mb-2">FFmpeg</div>
|
||||
<div className="flex items-center space-x-4">
|
||||
<span className=" text-sm text-muted-foreground">
|
||||
<b>ffmpeg</b>: {ffmpegConfig?.ffmpegPath || ""}
|
||||
</span>
|
||||
{editing && (
|
||||
<Button onClick={handleChooseFfmpeg} variant="ghost" size="icon">
|
||||
<EditIcon className="w-4 h-4 text-muted-foreground" />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center space-x-4">
|
||||
<span className=" text-sm text-muted-foreground">
|
||||
<b>ffprobe</b>: {ffmpegConfig?.ffprobePath || ""}
|
||||
</span>
|
||||
{editing && (
|
||||
<Button onClick={handleChooseFfmpeg} variant="ghost" size="icon">
|
||||
<EditIcon className="w-4 h-4 text-muted-foreground" />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="">
|
||||
<div className="flex items-center justify-end space-x-2 mb-2">
|
||||
{config?.ffmpegPath && (
|
||||
<div className="">
|
||||
<div className="flex items-center justify-end space-x-2 mb-2">
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
EnjoyApp.shell.openPath(libraryPath);
|
||||
EnjoyApp.ffmpeg
|
||||
.discover()
|
||||
.then(({ ffmpegPath, ffprobePath }) => {
|
||||
if (ffmpegPath && ffprobePath) {
|
||||
toast.success(
|
||||
t("ffmpegFoundAt", {
|
||||
path: ffmpegPath + ", " + ffprobePath,
|
||||
})
|
||||
);
|
||||
} else {
|
||||
toast.warning(t("ffmpegNotFound"));
|
||||
}
|
||||
refreshFfmpegConfig();
|
||||
});
|
||||
}}
|
||||
>
|
||||
{t("open")}
|
||||
{t("scan")}
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
EnjoyApp.ffmpeg.check();
|
||||
}}
|
||||
>
|
||||
{t("check")}
|
||||
</Button>
|
||||
<Button
|
||||
variant={editing ? "outline" : "secondary"}
|
||||
size="sm"
|
||||
onClick={() => setEditing(!editing)}
|
||||
>
|
||||
{editing ? t("cancel") : t("edit")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -262,7 +320,9 @@ const WhisperSettings = () => {
|
||||
|
||||
<Dialog>
|
||||
<DialogTrigger asChild>
|
||||
<Button size="sm">{t("edit")}</Button>
|
||||
<Button variant="secondary" size="sm">
|
||||
{t("edit")}
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
<DialogContent>
|
||||
<DialogHeader>{t("sttAiModel")}</DialogHeader>
|
||||
@@ -339,7 +399,7 @@ const OpenaiSettings = () => {
|
||||
</div>
|
||||
<div className="">
|
||||
<Button
|
||||
variant={editing ? "secondary" : "default"}
|
||||
variant={editing ? "outline" : "secondary"}
|
||||
size="sm"
|
||||
onClick={() => setEditing(!editing)}
|
||||
>
|
||||
@@ -403,7 +463,7 @@ const GoogleGenerativeAiSettings = () => {
|
||||
</div>
|
||||
<div className="">
|
||||
<Button
|
||||
variant={editing ? "secondary" : "default"}
|
||||
variant={editing ? "outline" : "secondary"}
|
||||
size="sm"
|
||||
onClick={() => setEditing(!editing)}
|
||||
>
|
||||
|
||||
@@ -1,18 +1,23 @@
|
||||
import { t } from "i18next";
|
||||
import { Button, ScrollArea } from "@renderer/components/ui";
|
||||
import { BasicSettings, AdvancedSettings, About, Hotkeys } from "@renderer/components";
|
||||
import {
|
||||
BasicSettings,
|
||||
AdvancedSettings,
|
||||
About,
|
||||
Hotkeys,
|
||||
} from "@renderer/components";
|
||||
import { useState } from "react";
|
||||
|
||||
export const Preferences = () => {
|
||||
const TABS = [
|
||||
{
|
||||
value: "basic",
|
||||
label: t("basicSettings"),
|
||||
label: t("basicSettingsShort"),
|
||||
component: () => <BasicSettings />,
|
||||
},
|
||||
{
|
||||
value: "advanced",
|
||||
label: t("advancedSettings"),
|
||||
label: t("advancedSettingsShort"),
|
||||
component: () => <AdvancedSettings />,
|
||||
},
|
||||
{
|
||||
@@ -30,8 +35,8 @@ export const Preferences = () => {
|
||||
const [activeTab, setActiveTab] = useState<string>("basic");
|
||||
|
||||
return (
|
||||
<div className="grid grid-cols-5">
|
||||
<ScrollArea className="col-span-1 h-full bg-muted/50 p-4">
|
||||
<div className="grid grid-cols-5 overflow-hidden h-full">
|
||||
<ScrollArea className="h-full col-span-1 bg-muted/50 p-4">
|
||||
<div className="py-2 text-muted-foreground mb-4">
|
||||
{t("sidebar.preferences")}
|
||||
</div>
|
||||
@@ -50,7 +55,7 @@ export const Preferences = () => {
|
||||
</Button>
|
||||
))}
|
||||
</ScrollArea>
|
||||
<ScrollArea className="col-span-4 p-6">
|
||||
<ScrollArea className="h-full col-span-4 py-6 px-10">
|
||||
{TABS.find((tab) => tab.value === activeTab)?.component()}
|
||||
</ScrollArea>
|
||||
</div>
|
||||
|
||||
@@ -96,7 +96,7 @@ export const PronunciationAssessmentScoreResult = (props: {
|
||||
</div>
|
||||
|
||||
{!pronunciationScore && (
|
||||
<div className="w-full h-full absolute z-30 bg-white/10 flex items-center justify-center">
|
||||
<div className="w-full h-full absolute z-30 bg-background/10 flex items-center justify-center">
|
||||
<Button size="lg" disabled={assessing} onClick={onAssess}>
|
||||
{assessing && (
|
||||
<LoaderIcon className="w-4 h-4 animate-spin inline mr-2" />
|
||||
|
||||
@@ -70,7 +70,7 @@ export const RecordingCard = (props: {
|
||||
return (
|
||||
<div id={id} className="flex items-center justify-end px-4 transition-all">
|
||||
<div className="w-full">
|
||||
<div className="bg-white rounded-lg py-2 px-4 relative mb-1">
|
||||
<div className="bg-background rounded-lg py-2 px-4 relative mb-1">
|
||||
<div className="flex items-center justify-end space-x-2">
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{secondsToTimestamp(recording.duration / 1000)}
|
||||
|
||||
@@ -44,3 +44,35 @@ export const ResetAllButton = (props: { children: React.ReactNode }) => {
|
||||
</AlertDialog>
|
||||
);
|
||||
};
|
||||
|
||||
export const ResetSettingsButton = (props: { children: React.ReactNode }) => {
|
||||
const { EnjoyApp } = useContext(AppSettingsProviderContext);
|
||||
|
||||
const reset = () => {
|
||||
EnjoyApp.app.resetSettings();
|
||||
};
|
||||
|
||||
return (
|
||||
<AlertDialog>
|
||||
<AlertDialogTrigger asChild>{props.children}</AlertDialogTrigger>
|
||||
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>{t("resetSettings")}</AlertDialogTitle>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogDescription>
|
||||
{t("resetSettingsConfirmation")}
|
||||
</AlertDialogDescription>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>{t("cancel")}</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
className="bg-destructive hover:bg-destructive-hover"
|
||||
onClick={reset}
|
||||
>
|
||||
{t("resetSettings")}
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -13,7 +13,7 @@ import { debounce , uniq } from "lodash";
|
||||
import Mark from "mark.js";
|
||||
|
||||
export const StoryViewer = (props: {
|
||||
story: StoryType & Partial<CreateStoryParamsType>;
|
||||
story: Partial<StoryType> & Partial<CreateStoryParamsType>;
|
||||
marked?: boolean;
|
||||
meanings?: MeaningType[];
|
||||
setMeanings: (meanings: MeaningType[]) => void;
|
||||
@@ -96,7 +96,7 @@ export const StoryViewer = (props: {
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="w-full max-w-2xl xl:max-w-3xl mx-auto sticky bg-white top-0 z-30 px-4 py-2 border-b">
|
||||
<div className="w-full max-w-2xl xl:max-w-3xl mx-auto sticky bg-background top-0 z-30 px-4 py-2 border-b">
|
||||
<div className="w-full flex items-center space-x-4">
|
||||
<Button
|
||||
variant="ghost"
|
||||
@@ -130,10 +130,10 @@ export const StoryViewer = (props: {
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="bg-white py-6 px-8 max-w-2xl xl:max-w-3xl mx-auto relative shadow-lg">
|
||||
<div className="bg-background py-6 px-8 max-w-2xl xl:max-w-3xl mx-auto relative shadow-lg">
|
||||
<article
|
||||
ref={ref}
|
||||
className="relative select-text prose prose-lg xl:prose-xl font-serif text-lg"
|
||||
className="relative select-text prose dark:prose-invert prose-lg xl:prose-xl font-serif text-lg"
|
||||
>
|
||||
<h2>
|
||||
{story.title.split(" ").map((word, i) => (
|
||||
|
||||
@@ -41,8 +41,8 @@ export const ToolbarButton = (props: {
|
||||
className={cn(
|
||||
`rounded-full p-3 h-12 w-12 ${
|
||||
toggled
|
||||
? "bg-primary text-white"
|
||||
: "bg-white text-muted-foreground hover:text-white "
|
||||
? "bg-primary dark:bg-background text-background dark:text-foreground"
|
||||
: "bg-background dark:bg-muted text-muted-foreground hover:text-background "
|
||||
}`,
|
||||
className
|
||||
)}
|
||||
|
||||
@@ -6,7 +6,7 @@ import { Toaster as Sonner, toast } from "sonner";
|
||||
type ToasterProps = React.ComponentProps<typeof Sonner>;
|
||||
|
||||
const Toaster = ({ ...props }: ToasterProps) => {
|
||||
const { theme = "system" } = useTheme();
|
||||
const { theme = "light" } = useTheme();
|
||||
|
||||
return (
|
||||
<Sonner
|
||||
|
||||
@@ -81,7 +81,9 @@ export const VideoDetail = (props: { id?: string; md5?: string }) => {
|
||||
targetId: video.id,
|
||||
})
|
||||
.then(() => {
|
||||
toast.success(t("sharedSuccessfully"), { description: t("sharedVideo") });
|
||||
toast.success(t("sharedSuccessfully"), {
|
||||
description: t("sharedVideo"),
|
||||
});
|
||||
})
|
||||
.catch((err) => {
|
||||
toast.error(t("shareFailed"), { description: err.message });
|
||||
@@ -136,7 +138,7 @@ export const VideoDetail = (props: { id?: string; md5?: string }) => {
|
||||
mediaId={video.id}
|
||||
mediaType="Video"
|
||||
mediaUrl={video.src}
|
||||
waveformCacheKey={`waveform-video-${video.md5}`}
|
||||
mediaMd5={video.md5}
|
||||
transcription={transcription}
|
||||
currentTime={currentTime}
|
||||
setCurrentTime={setCurrentTime}
|
||||
@@ -216,7 +218,7 @@ export const VideoDetail = (props: { id?: string; md5?: string }) => {
|
||||
</AlertDialog>
|
||||
|
||||
{!initialized && (
|
||||
<div className="top-0 w-full h-full absolute z-30 bg-white/10 flex items-center justify-center">
|
||||
<div className="top-0 w-full h-full absolute z-30 bg-background/10 flex items-center justify-center">
|
||||
<LoaderIcon className="text-muted-foreground animate-spin w-8 h-8" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -78,7 +78,6 @@ export const AppSettingsProvider = ({
|
||||
|
||||
const fetchLanguage = async () => {
|
||||
const language = await EnjoyApp.settings.getLanguage();
|
||||
console.log(language);
|
||||
setLanguage(language as "en" | "zh-CN");
|
||||
i18n.changeLanguage(language);
|
||||
};
|
||||
|
||||
@@ -271,7 +271,7 @@ export default () => {
|
||||
</ScrollArea>
|
||||
|
||||
<div className="px-4 absolute w-full bottom-0 left-0 h-14 bg-muted z-50">
|
||||
<div className="focus-within:bg-white px-4 py-2 flex items-center space-x-4 rounded-lg border">
|
||||
<div className="focus-within:bg-background px-4 py-2 flex items-center space-x-4 rounded-lg border">
|
||||
<Textarea
|
||||
rows={1}
|
||||
ref={inputRef}
|
||||
@@ -279,7 +279,7 @@ export default () => {
|
||||
value={content}
|
||||
onChange={(e) => setConent(e.target.value)}
|
||||
placeholder={t("pressEnterToSend")}
|
||||
className="px-0 py-0 shadow-none border-none focus-visible:outline-0 focus-visible:ring-0 border-none bg-muted focus:bg-white min-h-[1.25rem] max-h-[3.5rem] !overflow-x-hidden"
|
||||
className="px-0 py-0 shadow-none border-none focus-visible:outline-0 focus-visible:ring-0 border-none bg-muted focus:bg-background min-h-[1.25rem] max-h-[3.5rem] !overflow-x-hidden"
|
||||
/>
|
||||
<Button
|
||||
type="submit"
|
||||
|
||||
@@ -86,7 +86,7 @@ export default () => {
|
||||
{conversations.map((conversation) => (
|
||||
<Link key={conversation.id} to={`/conversations/${conversation.id}`}>
|
||||
<div
|
||||
className="bg-white text-primary rounded-full w-full mb-2 p-4 hover:bg-primary hover:text-white cursor-pointer flex items-center"
|
||||
className="bg-background text-muted-foreground rounded-full w-full mb-2 p-4 hover:bg-primary hover:text-muted cursor-pointer flex items-center"
|
||||
style={{
|
||||
borderLeftColor: `#${conversation.id
|
||||
.replaceAll("-", "")
|
||||
|
||||
@@ -6,7 +6,6 @@ import {
|
||||
LoginForm,
|
||||
ChooseLibraryPathInput,
|
||||
WhisperModelOptionsPanel,
|
||||
UserCard,
|
||||
FfmpegCheck,
|
||||
} from "@renderer/components";
|
||||
import { AppSettingsProviderContext } from "@renderer/context";
|
||||
@@ -93,7 +92,7 @@ export default () => {
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex-1 flex justify-center items-center">
|
||||
{currentStep == 1 && (user ? <UserCard user={user} /> : <LoginForm />)}
|
||||
{currentStep == 1 && <LoginForm />}
|
||||
{currentStep == 2 && <ChooseLibraryPathInput />}
|
||||
{currentStep == 3 && <WhisperModelOptionsPanel />}
|
||||
{currentStep == 4 && <FfmpegCheck />}
|
||||
|
||||
@@ -38,7 +38,8 @@ export default () => {
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="h-[100vh] max-w-screen-md mx-auto px-4 py-6">
|
||||
<div className="h-[100vh] bg-muted">
|
||||
<div className="max-w-screen-md mx-auto px-4 py-6">
|
||||
<div className="flex space-x-1 items-center mb-4">
|
||||
<Button variant="ghost" size="icon" onClick={() => navigate(-1)}>
|
||||
<ChevronLeftIcon className="w-5 h-5" />
|
||||
@@ -62,7 +63,7 @@ export default () => {
|
||||
>
|
||||
<ChevronLeftIcon className="w-5 h-5" />
|
||||
</Button>
|
||||
<div className="flex-1 h-5/6 border p-6 rounded-xl shadow-lg">
|
||||
<div className="bg-background flex-1 h-5/6 border p-6 rounded-xl shadow-lg">
|
||||
<MeaningMemorizingCard meaning={meanings[currentIndex]} />
|
||||
</div>
|
||||
<Button
|
||||
@@ -83,5 +84,6 @@ export default () => {
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
5
enjoy/src/types.d.ts
vendored
5
enjoy/src/types.d.ts
vendored
@@ -75,11 +75,13 @@ type TransactionStateType = {
|
||||
record?: AudioType | UserType | RecordingType;
|
||||
};
|
||||
|
||||
|
||||
type FfmpegConfigType = {
|
||||
os: string;
|
||||
arch: string;
|
||||
commandExists: boolean;
|
||||
ffmpegPath?: string;
|
||||
ffprobePath?: string;
|
||||
scanDirs: string[];
|
||||
ready: boolean;
|
||||
};
|
||||
|
||||
@@ -110,7 +112,6 @@ type PagyResponseType = {
|
||||
next: number | null;
|
||||
};
|
||||
|
||||
|
||||
type AudibleBookType = {
|
||||
title: string;
|
||||
subtitle: string;
|
||||
|
||||
12
enjoy/src/types/enjoy-app.d.ts
vendored
12
enjoy/src/types/enjoy-app.d.ts
vendored
@@ -1,6 +1,7 @@
|
||||
type EnjoyAppType = {
|
||||
app: {
|
||||
reset: () => Promise<void>;
|
||||
resetSettings: () => Promise<void>;
|
||||
relaunch: () => Promise<void>;
|
||||
reload: () => Promise<void>;
|
||||
isPackaged: () => Promise<boolean>;
|
||||
@@ -75,7 +76,7 @@ type EnjoyAppType = {
|
||||
LlmProviderType
|
||||
) => Promise<void>;
|
||||
getFfmpegConfig: () => Promise<FfmpegConfigType>;
|
||||
setFfmpegConfig: () => Promise<void>;
|
||||
setFfmpegConfig: (config: FfmpegConfigType) => Promise<void>;
|
||||
getLanguage: () => Promise<string>;
|
||||
switchLanguage: (language: string) => Promise<void>;
|
||||
};
|
||||
@@ -180,6 +181,11 @@ type EnjoyAppType = {
|
||||
ffmpeg: {
|
||||
download: () => Promise<FfmpegConfigType>;
|
||||
check: () => Promise<boolean>;
|
||||
discover: () => Promise<{
|
||||
ffmpegPath: string;
|
||||
ffprobePath: string;
|
||||
scanDirs: string[];
|
||||
}>;
|
||||
};
|
||||
download: {
|
||||
onState: (callback: (event, state) => void) => void;
|
||||
@@ -199,4 +205,8 @@ type EnjoyAppType = {
|
||||
process: (params: any) => Promise<void>;
|
||||
update: (id: string, params: any) => Promise<void>;
|
||||
};
|
||||
waveforms: {
|
||||
find: (id: string) => Promise<WaveFormDataType>;
|
||||
save: (id: string, data: WaveFormDataType) => Promise<void>;
|
||||
};
|
||||
};
|
||||
|
||||
6
enjoy/src/types/waveform.d.ts
vendored
Normal file
6
enjoy/src/types/waveform.d.ts
vendored
Normal file
@@ -0,0 +1,6 @@
|
||||
type WaveFormDataType = {
|
||||
peaks: number[];
|
||||
sampleRate: number;
|
||||
duration: number;
|
||||
frequencies: number[];
|
||||
};
|
||||
@@ -10,9 +10,9 @@
|
||||
|
||||
除此之外,还有另外一个最关键的理由:
|
||||
|
||||
> 语言学习的最有效方法本来就是 “**生学硬练**”。
|
||||
> “语言学习” 的最有效方法本来就是 “**生学硬练**”。
|
||||
|
||||
除此之外,其他的了不起都只不过是 “辅助” 而已。
|
||||
除此之外,其他的了不起都只不过是 “辅助手段” 而已。
|
||||
|
||||
人类天生就具备 “开口说话” 的能力 —— 由天下最精密的大脑支持,协调多个同样非常精密的器官,比如嘴唇、舌头、小舍、咽喉,再配合气流的震动,能发出各式各样的声音…… 并且,所有人都一样,都天生具备 “多语” 的能力。
|
||||
|
||||
@@ -20,23 +20,23 @@
|
||||
|
||||
每个新生儿都有能力通过尝试和摸索,能够学会地球上存在的任何 “音素” —— 即,“声音要素”,无论是 “辅音” 还是 “元音”。他们所谓的 “掌握自己生活环境中所需要的音素”,就是在大脑里创建并强化相应的神经元间连接。
|
||||
|
||||
而对于自己的生活环境里并不存在的 “音素”,他们的大脑对之完全忽略,“并未创建相应的链接”,更谈不上 “强化”。比如,中文不需要的 “舌尖颤音”,对韩文来说就是必需;在德语、俄语中常见的 “小舍颤音” 就是英文是不需要的。
|
||||
而对于自己的生活环境里并不存在的 “音素”,他们的大脑对之完全忽略,“并未创建相应的连接”,更谈不上 “强化”。比如,中文不需要的 “舌尖颤音”,对韩文来说就是必需;在德语、俄语中常见的 “小舍颤音” 就是英文是不需要的。
|
||||
|
||||
甚至,成年后,为了效率,在听觉上,干脆屏蔽掉那些 “用不着的音素”。于是,中国人听不出韩语的 /ᄅ/ 和中文的 /r/ 之间的区别,韩国人听不出英文中 /f/ 和 /p/ 的区别,上海人很可能说的是 “皮肤”(pí hū),福建人可能说的是 “牛逼”(líu bī)…… 都是因为成年之后的 “听觉屏蔽” 造成的。
|
||||
|
||||
但,这并不意味着说,“成年之后” 就不能学了。脑科学家们的研究结果是,大脑具备极强的可塑性,“新建神经元连接” 不仅可能,并且事实上难度并不高 —— **只要肯练**。所谓 “练” 的意思是说,“短时间内足量重复”,仅此而已。
|
||||
但,这并不意味着说,“成年之后” 就不能学了,“听觉屏蔽” 之后就永远再也打不开了。脑科学家们的研究结果是,大脑具备极强的可塑性,“新建神经元连接” 不仅可能,并且事实上难度并不高 —— **只要肯练**。所谓 “练” 的意思是说,“短时间内足量重复”,仅此而已。
|
||||
|
||||
怎么 “练”?“硬练” —— 跟小朋友咿呀学语一样,“生学硬练”。
|
||||
|
||||
在学校里,我们 “学” 外语的方法很奇怪,与 “自然习得” 完全相悖,甚至处处相反。比如,小朋友五六岁,还没上学的时候,并不一定会写字,也不一定会拼音,更别提什么语法,但,已经能说很多很多话了,不是吗?总是有人问,“我零基础可以吗?” 他们所谓的基础,是字母、音标、语法…… 请问,小朋友学说话,靠这些吗?
|
||||
在学校里,我们 “学外语” 的方法很奇怪,与 “自然习得” 完全相悖,甚至处处相反。比如,小朋友五六岁,还没上学的时候,并不一定会写字,也不一定会拼音,更别提什么语法,但,已经能说很多很多话了,不是吗?总是有人问,“我零基础可以吗?” 他们所谓的基础,是字母、音标、语法…… 请问,小朋友学说话,靠这些吗?
|
||||
|
||||
更早的时候,小朋友大约会在几个月左右,就有可能发出一些奇怪的声音 —— 他们一直在 “尝试”,却暂时无法准确协调各个器官。等他们嘟嘟囔囔开始说话的时候,先能相对准确地发出的声音是基本的元音,而后才是各种辅音 —— 但,直到 3 岁左右,已经能说很多话的时候,他们还做不到 “**吐字清晰**”……
|
||||
更早的时候,小朋友大约会在几个月左右,就有可能发出一些奇怪的声音 —— 他们一直在 “尝试”,却暂时无法准确协调各个器官。等他们嘟嘟囔囔开始说话的时候,先能相对准确地发出的声音是基本的元音,而后才是各种辅音,辅音总是说不准,比如,让他们说 “竹子针”,他们说出来的是 “dú d dēn” —— 直到 3 岁左右,已经能说很多话的时候,他们还做不到 “**吐字清晰**”……
|
||||
|
||||
在多语环境里,三五岁的小朋友,其实不知道也不在意听到的说出的究竟哪个是英语,哪个是中文,哪个是日语,哪个是韩语或者西班牙语,他们只是知道那是对方发出的声音,辅助着各种 “非语言要素”,比如表情动作等等,去猜测理解那些声音的意思,而后再通过多次尝试之后,自己也能发出差不多的声音,即,我们以为的 “**说话**” 而已。
|
||||
|
||||
他们太小,脑子里的概念不可能完善,大人没有任何办法通过 “方法论” 去教他们 “发音”。没有任何一个家长会这样告诉自家的幼儿:
|
||||
|
||||
> 你在说 /θ/ 和 /ð/ 的时候,舌尖要略微露出来一点,放在两排牙齿之间,而后通过气流震动发出他们的声音。/θ/ 是清辅音,而 /ð/ 是浊辅音……
|
||||
> 你在说 /θ/ 和 /ð/ 的时候,舌尖要略微露出来一点,放在两排牙齿之间,而后通过气流震动发出它们的声音。/θ/ 是清辅音,而 /ð/ 是浊辅音……
|
||||
|
||||
再比如,我是朝鲜族,从小生活在多语环境中,因为朝鲜语是母语之一,所以,我会发出 “小舍颤音”。我家老二,小名 “都都”,我叫他的时候,常常用小舍颤音说他的名字,算是图个乐子。
|
||||
|
||||
@@ -50,7 +50,7 @@
|
||||
|
||||
在新疆,所有人在很小的时候就 “学” 回了 “晃脖子”,那是一种其他民族的人少有能做出的动作。你去问他们,到底应该怎么晃脖子,他们说不清楚,就算偶尔有人说得很清楚,你也学不会…… 真的 “学不会” 吗?肯定能学会,至于 “方法论” 么,其实没必要。跟 “动耳朵” 一样,“多试试” 就可以了。很少有人能 “动鼻尖”,生活中,也没有什么这样的 “需求”…… 但,两个版本的《家有仙妻》的主演,无论是电视剧版里的伊丽莎白·蒙哥马利还是电影里的妮可·基德曼都学会了这个动作 —— 怎么学会的?“生学硬练” —— 至于 “方法论” 么,就算告诉你也没用。
|
||||
|
||||
即便是到了很多所谓 “高级的领域” 或者 “高级的阶段”,也还是一样的。有人能手把手教,当然很好。但总有一些是身边没有人会的,那怎么办?看书,然后自己学、自己练。有没有可能 “连书都找不到” 呢?当然。没有人可以在身边手把手教,书里也找不到,那怎么办?到最后永远都能仰仗的,再一次只能是 “生学硬练”…… 所以,无论子前段起步,还是后段高阶,主要靠的,只能是 “生学硬练”,别无他法。
|
||||
即便是到了很多所谓 “高级的领域” 或者 “高级的阶段”,也还是一样的。有人能手把手教,当然很好。但总有一些是身边没有人会的,那怎么办?看书,然后自己学、自己练。有没有可能 “连书都找不到” 呢?当然。没有人可以在身边手把手教,书里也找不到,那怎么办?到最后永远都能仰仗的,再一次只能是 “生学硬练”…… 所以,无论是s前段起步,还是后段高阶,主要靠的,只能是 “生学硬练”,别无他法。
|
||||
|
||||
曾经,有科学家提出 “语言学习关键期” 的说法,声称 “过了一定的岁数,错过了 ‘语言学习关键期’,就不可能再学好语言了…… 更别说学另外一门外语了”。这个 “假说” 现在早已经被确定是无稽之谈 —— 虽然民间还有很多人 “尚未来得及更新观念”。
|
||||
|
||||
@@ -58,7 +58,7 @@
|
||||
|
||||
然而,成年人相对于婴幼儿的确面临一些额外的困难。
|
||||
|
||||
婴幼儿学任何东西都是没有 “方法论” 的,他们只会也只能 “生学硬练”。成年人的问题在于,多年的 “学习经验”,已经形成了一个属于自己的 “方法库”,只要是不符合已有库存的方法,就会感到极不适应,潜意识里认为它效率不够高 —— 毕竟,“方法库” 里的东西都是千挑万选才值得收藏的…… 然后,早就忘了当年最简单直接粗暴有效的方式,“生学硬练”,甚至认为那是最低级的,所以早就弃用了,到了甚至干脆想不起来了的地步。
|
||||
婴幼儿学任何东西都是没有 “方法论” 的,他们只会也只能 “生学硬练”。成年人的问题在于,多年的 “学习经验”,已经形成了一个属于自己的 “方法库”,只要是不符合已有库存的方法,就会感到极不适应,潜意识里认为它效率不够高 —— 毕竟,“方法库” 里的东西都是千挑万选才值得收藏的…… 然后,早就忘了当年最简单直接粗暴有效的方式,“生学硬练”,甚至认为那是最低级的,所以早就弃用了,到了甚至干脆想不起来了的地步,非常可惜。
|
||||
|
||||
年龄的增加,与之相伴增加的往往并不是能力和耐心,而是 “基于无数次失败而累积起来的浮躁”,越来越心急,越来越沉不住气,越来越想要找到某个 “神奇的方法” 可以 “瞬间解决所有问题” —— 一切的 “速成” 都有巨大的市场,就是这个原因。想要 “速成” 的人,就好像是很多炒股的人变成了赌徒一样,本钱越来越少,损失越来越大,在越来越大的 “回本” 压力下,开始使用 “越来越大的杠杆”,直至 “爆仓”……
|
||||
|
||||
81
new-edition-drafts/06-自我纠正.md
Normal file
81
new-edition-drafts/06-自我纠正.md
Normal file
@@ -0,0 +1,81 @@
|
||||
## 自我纠正
|
||||
|
||||
“自学” 过程中,最头痛的一个难点是,“我怎么知道我做得对不对?好不好?” 有时候,当别人指出错误的时候,我们也难免疑惑 “你怎么就知道你是正确的”?
|
||||
|
||||
对于 “思考” 进行 “思考”,有个专门的词汇,叫做 “**元认知**”(Meta-recoginition)—— 这是谁都会,但谁都需要想尽一切办法强化的 “认知能力”,其方式倒既简单又统一:“不断反思”。这样的方法不仅古老,并且东西方都一样,孔子说,“吾日三省吾身”,苏格拉底说,“不自省的生活不值得过。”(“An unexamined life is not worth living.)
|
||||
|
||||
想象一下就知道了,学会 “**跳出来像旁观者一样观察自己**” 的确不容易。
|
||||
|
||||
“录音机” 甚至 “摄影机” 解决了很大的问题。有很多之前无法被注意到的细节,都会被 “如实详尽” 地记录下来,以供随后的分析。马,尤其是奔跑的马,是人类格外喜欢的绘画对象之一。[1878 年](https://en.wikipedia.org/wiki/The_Horse_in_Motion)之前,人们画奔跑的马,四条马腿的位置通常都是错的…… 若是没有摄像机的话,人类的眼睛会被视觉缓存所干扰,乃至于误以为马在奔跑的时候四腿的位置是另外一个样子。
|
||||
|
||||
我父亲那一代人,通过使用 “收音机” 降低了学外语的难度,对他们来说,那可算是 “突然之间可以随意听到外语”…… 到我这一代,70 后,“录音机” 开始起作用,不仅可以 “听到外语”,还可以 “特定的部分反复听”;甚至,可以录下自己的声音,而后通过反复检查改进自己。对 90 和 00 后来说,“互联网” 和它所分发的内容,无论是图文、音频还是视频,都起了巨大的作用,不仅各类内容的 “量” 大到不可想象的地步,关键在于,大部分内容竟然还是 “免费” 的…… 并且,“互联网” 还自带 “搜索引擎”,到最后,无论什么内容都有可能找到。
|
||||
|
||||
人们 “说外语” 的时候,不再表达生硬且单一,在不断丰富的同时,开始无限接近以前可望不可及的所谓 “地道”。与此同时,录音,早就在电脑上了,甚至用不着早些年那么昂贵的 “磁带”,语音分析工具也逐步丰富起来,人群之中,“说外语毫无口音” 的人开始多起来…… 到了我离开新东方的时候,2008 年前后,很多中学生考个托福满分,已经见怪不怪了。
|
||||
|
||||
2023 年,基于 “大语言模型” 的 “人工智能” 爆发。“突然之间”,几乎所有一切都不再是问题了。你想说什么,让人工智能帮你重新组织,不用担心它返回来的文字版本是否有语法错误,不用担心措辞是否足够地道,不用提前担心自己的词汇量够不够,不用担心它返回来的语音版本发音是否准确 —— 突然之间,完全没什么可担心的了……
|
||||
|
||||
—— 只剩下一件事:自己练得是否足够多,乃至于最终真的能做得足够好?
|
||||
|
||||
也许,再过一段时间,“人工智能” 也能在 “语音纠正” 方面做到更为有效,更为惊人 —— 只不过,起码目前还不太行。微软有个语音评分的系统,试用过之后觉得效果一般。但,我们也的确有理由在这方面不再等待也不用指望 “人工智能”……
|
||||
|
||||
因为,**“纠正” 这事儿,从来都应该首先是 “自我纠正”** —— 这是 “自学” 的 “基础能力”,也是 “自学者” 的 “基本功”。想要掌握 “自学能力”,就是要学会 “跳出来像旁观者一样观察自己” —— 有了这个能力,再加上 “一年内至少投入一千小时的注意力”,无论学什么,都算得上是如虎添翼,所向披靡。
|
||||
|
||||
人们在学习过程中犯错的时候,所谓 “具备共性” 的 “常见错误” 再多也不如 “**个性化错误**” 多 —— 并且,每个人所面对的 “个性化错误” 不仅更多,还千奇百怪,且常常难以归纳分类,更为关键的是,“个性化错误” 才是更重要的错误,才是更需要纠正的错误……
|
||||
|
||||
有时候,范点 “常见错误” 并不奇怪,甚至无所谓。即便是在母语使用者群体里,“常见错误” 也非常普遍。拿中文举例个例子,以下有多少字你一直以来都读错了?
|
||||
|
||||
> * 氛围、气氛(fēn)
|
||||
> * 强劲有力(jīng)
|
||||
> * 订正(dìng)
|
||||
> * 胴体(dòng)
|
||||
> * 契诃夫(hē)
|
||||
> * 一哄而散(hòng)
|
||||
> * 窗明几净(jī)
|
||||
> * 连累(lěi)
|
||||
> * 模板(mú)
|
||||
> * 心宽体胖(pán)
|
||||
|
||||
换言之,“大家几乎都错” 的时候 “你也错了”,还真可以算是无所谓,但,“大家都没错”,但 “只有你自己错了”,就像对更可怕。
|
||||
|
||||
再比如,英文中有一对元音,短元音 /ɪ/ 和长元音 /iː/,听起来差不多,但它们之间有细微的差异。长元音 /iː/ 的发音方式和中文拼音中的 ī、í、ǐ、ì 是一样的;但,英文的短元音 /ɪ/,其实是 /e/ 和 /i/ 之间的音,不是 “长元音 /iː/ 的更短版本”…… 于是,以中文为母语的人讲英文的时候,尤其是那种单词末尾有短元音 /ɪ/ 的词汇,都会发成 /i/,而不是 /ɪ/ —— 这就是所谓的 “常见错误”。可实际上,问题并不大,因为 “反正大家都这样”……
|
||||
|
||||
但是,facade(也写作:façade)被我读成 `'fækeɪt`,或者 specific,被我读成 `spesɪfɪk` —— 那就是我自己的 “个性化错误” 了…… 这种错误总是相对更显眼,甚至干脆感觉上 “更致命”,你想想看是不是这样?
|
||||
|
||||
所以,总体上来看,**“纠错” 这事儿,还真只能主要靠自己做。因为从数量上来看,需要自己解决的就是更多,把这些任务放到别人身上,非常不合理。从这个角度望过去,“一厢情愿地希望所有错误都有人帮忙解决”,其实是软弱的表现而已。没办法,就得自己来。
|
||||
|
||||
当然,最初的时候,所有人都面临同样的可怕问题:“并不知道自己错了” —— 也因为如此总是重复犯错。但,熟练的 “自学者” 凭经验知道,这并不是 “永恒” 的现象。
|
||||
|
||||
起初的时候,一定要有足量的 “生学硬练” —— 因为它是 “起步” 最快最靠谱的方式。一旦起步了,“生学硬练” 足够多了,总是有 “进步”…… 所有的 “进步”,不仅是 “更熟练” 而已,总是还带着 “细微的改良”,所以,有 “进步” 经验的人,对 “改良” 的观察和感受是相对更为强烈的 —— 这就是 “自我纠正” 的基础。于是,经过一段时间之后,就会发现自己开始逐步拥有越来越强的 “自我纠正能力”。
|
||||
|
||||
**“自我纠正能力” 只能是 “积累” 出来的,并且,貌似也只能 “靠自己积累”** —— 然而,它恰恰是一个人 “自学能力” 是否足够强的最关键指标。如果这个能力差,甚至这个能力缺席,那么,“自学” 实话说就完全无从谈起了。
|
||||
|
||||
“**帮别人纠错**” 也是个好办法。这么做的好处在于,很多 “个性化错误” 不是别人可以想象到的,但是,反过来,其他人的 “个性化错误”,也有可能是自己 “正在犯”,甚至 “将要犯” 的错误。你可能会奇怪,“我怎么可能感受到别人正在犯我自己正在犯的错误?” 你的理由是,“因为我自己正在犯那个错误,说明我自己不知道那是错误”。别笑,人就是这样,自己正在犯的错误自己并不知道,但是别人犯了同样的错误,却可以瞬间指出。“帮别人纠错” 好处在这个微妙的细节上有着更好的体现 —— 因为它能帮你解决很多你没想到要解决的问题。
|
||||
|
||||
不过,“帮别人纠错”,有个细节格外值得注意,那就是:
|
||||
|
||||
> **要提前征得同意。**
|
||||
|
||||
如果,对方心里默认你是老师,即相当于是早就同意了 “你随时帮他指出错误” —— 可若非如此,“被指出错误” 是非常令人苦恼的事情,难道你自己不这么觉得吗?但,我这个建议的出发点并不是因为这个,我是为了自己避免掉进 “固守型人格陷阱”。因为 “随意指出他人错误”,会不由自主地引发自己的优越感,进而更加迷恋 “横向比较”,甚至,往往冒着 “被讨厌的风险” 也要忍不住那么做,实在是太可怜了。
|
||||
|
||||
除非确认对方乐于接受被人指出错误,对方乐于改进以便进步,否则的话,对方犯错就犯错了吧,尤其是 “语音上的错误”,那是语言文字传递信息过程中最不重要的点而已。比如,有人不小心把 “女红” 读成了 nǔ hóng,而不是 nǔ gōng,又怎样呢?你已经知道对方说的是什么了,不就可以了嘛?人家读错那么长时间,也没影响人家的生活啊?!更没影响你的生活。
|
||||
|
||||
因此,“结伴学习” 总是非常有效 —— 不是一群人,而是仅仅两三个人。人数太多,就可能非常混乱。但,两三个人相互之间做好约定,在没有任何心理负担的情况下相互帮忙纠错,纠错效率极高的同时纠错覆盖面也足够广。又由于之前已经有相互的约定,所以,一切的 “纠错” 都很心情愉快,以进步为导向。
|
||||
|
||||
另外一个同样值得重视的细节是:
|
||||
|
||||
> **一次只纠一个错。**
|
||||
|
||||
错误常常不是 “知道了就能直接完全改正” 的,尤其在学外语的时候,尤其是语音上的习惯,尤其是成年人在语音上的习惯,常常是 “即便知道了也需要很多很多的练习” 才能彻底纠正。换言之,纠正一个错误可能真的需要很长时间。另外,“纠错” 是个格外耗费 “脑力” 的事情,一方面要 “做”,一方面还要用 “元认知” 观察自己 “做得过程”,在 “纠错” 的时候,还要 “重新协调” 各个器官……
|
||||
|
||||
所以,一次被纠错太多,不一定是好事,因为 “真的招架不住”。
|
||||
|
||||
“自我纠错能力” 的积累,在任何领域都一样,学什么都一样,都是 “从无到有”,“从零散到系统” 的过程。所以,必须为自己准备一个 “纠错记录”,而后不断补充,不断整理。
|
||||
|
||||
你可能会误以为 “那些错误已经被纠正了,那么,那些 ‘已经纠正的错误’ 又何必在花时间精力那么麻烦地记录下来?” 做好记录、不断补充、不断整理的时间精力绝对不会浪费,恰恰相反,是很好的投资 —— 因为这个过程,实际上是我们的大脑在逐步建立全面完善且清晰准确的 “自我认知” 的过程,“人贵自知”,而所谓的 “自知” 真不是随随便便就能获得的。这个过程中培养的、积累的、锻炼的,就是 “自省” 的能力。
|
||||
|
||||
还有些时候,“发现了错误” 之后,竟然找不到什么方法可以解决…… 没关系,先记下来,“只要心心念念就总有出路”。在 “启动任务” 整个 “作业” 中,最后有一条:
|
||||
|
||||
> 在这个过程中,遇到的困难(解决的、未解决的),以及想到的可能的解决方案,练习过程中的感悟。(一个文本文件)
|
||||
|
||||
这是必须养成的习惯,记录问题,无论解决的还是未解决的。对于那些 “尚未解决” 的问题,尤其要经常 “回顾”,把它们 “刻” 在脑子里。老师教学生说,“要带着问题读书”,因为这样才能 “找到答案”。我的看法是,“要带着问题生活”,因为答案不一定只在书里,在任何地方都有可能。但,找到答案的前提,还真就只能是 “脑子里带着问题”,不是吗?
|
||||
|
||||
@@ -1,2 +0,0 @@
|
||||
## 自我纠正
|
||||
|
||||
BIN
new-edition-drafts/video/infinite-zoom-art-movie-poster.mp4
Normal file
BIN
new-edition-drafts/video/infinite-zoom-art-movie-poster.mp4
Normal file
Binary file not shown.
@@ -180,3 +180,10 @@ AI 是最好的老师,我们是最强的助教。
|
||||
就这样,把事情的启动能量一转换, 根本不需要什么21天的表格,很快他就形成了新的习惯。
|
||||
|
||||
这个“启动能量20秒规则”实际上是安抚了我们的情绪脑,让它觉得压力不大,事情可控,很快就行动起来了。
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
Infinite Zoom Art, movie posters, https://www.reddit.com/r/moviecritic/comments/195b90j/can_you_name_all_the_films_depicted_in_this/
|
||||
|
||||
|
||||
Reference in New Issue
Block a user