8 Commits

Author SHA1 Message Date
an-lee
050c577620 bump v0.1.0-alpha.3 2024-01-15 22:13:11 +08:00
an-lee
8d7a3e37ce build darwin-arm64 in ci 2024-01-15 21:55:15 +08:00
an-lee
23feb06d20 Fix: UI (#119)
* fix ui
2024-01-15 18:00:35 +08:00
an-lee
b545ea2362 Feat: save waveform as file (#118)
* package rpm

* cache waveform data as file in library

* clear waveform data in db

* fix some css
2024-01-15 16:57:44 +08:00
an-lee
187038c42e Feat: scan ffmpeg command (#116)
* package rpm

* fix model url

* scan/check ffmpeg command

* handle undefined

* add reset settings button

* add ffmpeg install instrunction for mac

* improve landing steps
2024-01-15 14:12:22 +08:00
luckrnx09
6cc9cb9da2 Fix: remove unnecessary chars 2024-01-15 10:54:58 +08:00
xiaolai
3cf168f098 typo. 2024-01-15 09:17:28 +08:00
xiaolai
2ceb122acf file order changed. 2024-01-15 08:36:57 +08:00
57 changed files with 700 additions and 193 deletions

View File

@@ -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

View File

@@ -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. 貌似多余:其实连哑巴英语都并不那么坏

View File

@@ -7,7 +7,7 @@
"config": "tailwind.config.js",
"css": "src/index.css",
"baseColor": "zinc",
"cssVariables": true
"cssVariables": false
},
"aliases": {
"components": "src/renderer/components",

View File

@@ -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: [
{

View File

@@ -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",

View File

@@ -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.`,
},
];

View File

@@ -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",

View File

@@ -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 模型",

View File

@@ -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();

View File

@@ -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\\",
],
};

View File

@@ -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);

View 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);
});
}
}

View File

@@ -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();

View File

@@ -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);
},
}
});

View File

@@ -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>

View File

@@ -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>
)}

View File

@@ -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("-", "")

View File

@@ -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]">

View File

@@ -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>

View File

@@ -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

View File

@@ -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);
});
}, []);

View File

@@ -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" />

View File

@@ -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]}

View File

@@ -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]">

View File

@@ -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>

View File

@@ -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>

View File

@@ -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)}
>

View File

@@ -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>

View File

@@ -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" />

View File

@@ -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)}

View File

@@ -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>
);
};

View File

@@ -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) => (

View File

@@ -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
)}

View File

@@ -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

View File

@@ -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>
)}

View File

@@ -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);
};

View File

@@ -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"

View File

@@ -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("-", "")

View File

@@ -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 />}

View File

@@ -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>
);
};

View File

@@ -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;

View File

@@ -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
View File

@@ -0,0 +1,6 @@
type WaveFormDataType = {
peaks: number[];
sampleRate: number;
duration: number;
frequencies: number[];
};

View File

@@ -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 @@
然而,成年人相对于婴幼儿的确面临一些额外的困难。
婴幼儿学任何东西都是没有 “方法论” 的,他们只会也只能 “生学硬练”。成年人的问题在于,多年的 “学习经验”,已经形成了一个属于自己的 “方法库”,只要是不符合已有库存的方法,就会感到极不适应,潜意识里认为它效率不够高 —— 毕竟,“方法库” 里的东西都是千挑万选才值得收藏的…… 然后,早就忘了当年最简单直接粗暴有效的方式,“生学硬练”,甚至认为那是最低级的,所以早就弃用了,到了甚至干脆想不起来了的地步。
婴幼儿学任何东西都是没有 “方法论” 的,他们只会也只能 “生学硬练”。成年人的问题在于,多年的 “学习经验”,已经形成了一个属于自己的 “方法库”,只要是不符合已有库存的方法,就会感到极不适应,潜意识里认为它效率不够高 —— 毕竟,“方法库” 里的东西都是千挑万选才值得收藏的…… 然后,早就忘了当年最简单直接粗暴有效的方式,“生学硬练”,甚至认为那是最低级的,所以早就弃用了,到了甚至干脆想不起来了的地步,非常可惜
年龄的增加,与之相伴增加的往往并不是能力和耐心,而是 “基于无数次失败而累积起来的浮躁”,越来越心急,越来越沉不住气,越来越想要找到某个 “神奇的方法” 可以 “瞬间解决所有问题” —— 一切的 “速成” 都有巨大的市场,就是这个原因。想要 “速成” 的人,就好像是很多炒股的人变成了赌徒一样,本钱越来越少,损失越来越大,在越来越大的 “回本” 压力下,开始使用 “越来越大的杠杆”,直至 “爆仓”……

View 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òng
> * 窗明几净
> * 连累lěi
> * 模板
> * 心宽体胖pán
换言之,“大家几乎都错” 的时候 “你也错了”,还真可以算是无所谓,但,“大家都没错”,但 “只有你自己错了”,就像对更可怕。
再比如,英文中有一对元音,短元音 /ɪ/ 和长元音 /iː/,听起来差不多,但它们之间有细微的差异。长元音 /iː/ 的发音方式和中文拼音中的 ī、í、ǐ、ì 是一样的;但,英文的短元音 /ɪ/,其实是 /e/ 和 /i/ 之间的音,不是 “长元音 /iː/ 的更短版本”…… 于是,以中文为母语的人讲英文的时候,尤其是那种单词末尾有短元音 /ɪ/ 的词汇,都会发成 /i/,而不是 /ɪ/ —— 这就是所谓的 “常见错误”。可实际上,问题并不大,因为 “反正大家都这样”……
但是facade也写作façade被我读成 `'fækeɪt`,或者 specific被我读成 `spesɪfɪk` —— 那就是我自己的 “个性化错误” 了…… 这种错误总是相对更显眼,甚至干脆感觉上 “更致命”,你想想看是不是这样?
所以,总体上来看,**“纠错” 这事儿,还真只能主要靠自己做。因为从数量上来看,需要自己解决的就是更多,把这些任务放到别人身上,非常不合理。从这个角度望过去,“一厢情愿地希望所有错误都有人帮忙解决”,其实是软弱的表现而已。没办法,就得自己来。
当然,最初的时候,所有人都面临同样的可怕问题:“并不知道自己错了” —— 也因为如此总是重复犯错。但,熟练的 “自学者” 凭经验知道,这并不是 “永恒” 的现象。
起初的时候,一定要有足量的 “生学硬练” —— 因为它是 “起步” 最快最靠谱的方式。一旦起步了,“生学硬练” 足够多了,总是有 “进步”…… 所有的 “进步”,不仅是 “更熟练” 而已,总是还带着 “细微的改良”,所以,有 “进步” 经验的人,对 “改良” 的观察和感受是相对更为强烈的 —— 这就是 “自我纠正” 的基础。于是,经过一段时间之后,就会发现自己开始逐步拥有越来越强的 “自我纠正能力”。
**“自我纠正能力” 只能是 “积累” 出来的,并且,貌似也只能 “靠自己积累”** —— 然而,它恰恰是一个人 “自学能力” 是否足够强的最关键指标。如果这个能力差,甚至这个能力缺席,那么,“自学” 实话说就完全无从谈起了。
“**帮别人纠错**” 也是个好办法。这么做的好处在于,很多 “个性化错误” 不是别人可以想象到的,但是,反过来,其他人的 “个性化错误”,也有可能是自己 “正在犯”,甚至 “将要犯” 的错误。你可能会奇怪,“我怎么可能感受到别人正在犯我自己正在犯的错误?” 你的理由是,“因为我自己正在犯那个错误,说明我自己不知道那是错误”。别笑,人就是这样,自己正在犯的错误自己并不知道,但是别人犯了同样的错误,却可以瞬间指出。“帮别人纠错” 好处在这个微妙的细节上有着更好的体现 —— 因为它能帮你解决很多你没想到要解决的问题。
不过,“帮别人纠错”,有个细节格外值得注意,那就是:
> **要提前征得同意。**
如果,对方心里默认你是老师,即相当于是早就同意了 “你随时帮他指出错误” —— 可若非如此,“被指出错误” 是非常令人苦恼的事情,难道你自己不这么觉得吗?但,我这个建议的出发点并不是因为这个,我是为了自己避免掉进 “固守型人格陷阱”。因为 “随意指出他人错误”,会不由自主地引发自己的优越感,进而更加迷恋 “横向比较”,甚至,往往冒着 “被讨厌的风险” 也要忍不住那么做,实在是太可怜了。
除非确认对方乐于接受被人指出错误,对方乐于改进以便进步,否则的话,对方犯错就犯错了吧,尤其是 “语音上的错误”,那是语言文字传递信息过程中最不重要的点而已。比如,有人不小心把 “女红” 读成了 nǔ hóng而不是 nǔ gōng又怎样呢你已经知道对方说的是什么了不就可以了嘛人家读错那么长时间也没影响人家的生活啊更没影响你的生活。
因此,“结伴学习” 总是非常有效 —— 不是一群人,而是仅仅两三个人。人数太多,就可能非常混乱。但,两三个人相互之间做好约定,在没有任何心理负担的情况下相互帮忙纠错,纠错效率极高的同时纠错覆盖面也足够广。又由于之前已经有相互的约定,所以,一切的 “纠错” 都很心情愉快,以进步为导向。
另外一个同样值得重视的细节是:
> **一次只纠一个错。**
错误常常不是 “知道了就能直接完全改正” 的,尤其在学外语的时候,尤其是语音上的习惯,尤其是成年人在语音上的习惯,常常是 “即便知道了也需要很多很多的练习” 才能彻底纠正。换言之,纠正一个错误可能真的需要很长时间。另外,“纠错” 是个格外耗费 “脑力” 的事情,一方面要 “做”,一方面还要用 “元认知” 观察自己 “做得过程”,在 “纠错” 的时候,还要 “重新协调” 各个器官……
所以,一次被纠错太多,不一定是好事,因为 “真的招架不住”。
“自我纠错能力” 的积累,在任何领域都一样,学什么都一样,都是 “从无到有”,“从零散到系统” 的过程。所以,必须为自己准备一个 “纠错记录”,而后不断补充,不断整理。
你可能会误以为 “那些错误已经被纠正了,那么,那些 ‘已经纠正的错误’ 又何必在花时间精力那么麻烦地记录下来?” 做好记录、不断补充、不断整理的时间精力绝对不会浪费,恰恰相反,是很好的投资 —— 因为这个过程,实际上是我们的大脑在逐步建立全面完善且清晰准确的 “自我认知” 的过程,“人贵自知”,而所谓的 “自知” 真不是随随便便就能获得的。这个过程中培养的、积累的、锻炼的,就是 “自省” 的能力。
还有些时候,“发现了错误” 之后,竟然找不到什么方法可以解决…… 没关系,先记下来,“只要心心念念就总有出路”。在 “启动任务” 整个 “作业” 中,最后有一条:
> 在这个过程中,遇到的困难(解决的、未解决的),以及想到的可能的解决方案,练习过程中的感悟。(一个文本文件)
这是必须养成的习惯,记录问题,无论解决的还是未解决的。对于那些 “尚未解决” 的问题,尤其要经常 “回顾”,把它们 “刻” 在脑子里。老师教学生说,“要带着问题读书”,因为这样才能 “找到答案”。我的看法是,“要带着问题生活”,因为答案不一定只在书里,在任何地方都有可能。但,找到答案的前提,还真就只能是 “脑子里带着问题”,不是吗?

View File

@@ -1,2 +0,0 @@
## 自我纠正

View File

@@ -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/