为什么制作闪卡, 而不是直接用各种单词书记忆单词?

制作闪卡, 最终是为了基于 间隔复习 的原理, 来可靠地记忆, 提升记忆的熟练度和准确度.

在音频App相关短句的记忆上, 间隔复习 给予了我完全不敢想象的远超预期的结果. 随着记忆的熟练度的提升, 句感, 节奏, 活用程度, 都有了一个质的飞跃.

我现在有一种信念: 没有 间隔复习, 再有效果的 学习 也无法真正发挥作用.或者更直接点说: 不复习, 等于没有学; 不间隔复习, 等于没有复习.

显而易见, 常规的纸质版单词书, 最大的弊端就是: 无法高效地 间隔复习. 纵然可以通过以小组为单位, 自己用 Excel 来复习. 但是当数量来到 级别时, 更精细地复习, 方能尽可能提高复习的效率.

当然, 另一个原因就是: 现有的日语词汇闪卡, 和我预期的都有一定的差距. 或者说: 我对他们的质量不放心. 我没有太多时间浪费. 我做一件事, 就需要适当保证事情的效果, 能在一个合理的预期区间波动.

我预期的日语词汇卡组,长什么样?

  1. 发音, 要能正确反应日语的音变. 如: ここ 罗马音标是 koko, 但是真实的发音是 kogo.

  2. 声音, 要尽可能自然些, 不要有太明显的机器腔调.

  3. 要聚焦于发音的记忆. 作为汉语母语者, 需要充分发挥汉字可以自由阅读的优势, 只着重记忆发音即可. 我没有统计, 但是我遇到的常用汉字, 相当一部分都和汉语意思相同或者有一定关联.

  4. 相对全面. 覆盖 N5 ~ N1. 我没有心情去分批去学. 要搞, 就一次性把词汇问题搞完. 而且这些都还是基础词汇. 计算机的专业词汇, 还需要额外整理和学习.

  5. 某种形式的 电子化, 方便我二次开发. 因为我要导入我自己的 闪卡系统中. 我不太喜欢 Anki 的那个交互. 以前吐槽过.不再赘述.

效果预览:

preview_menu

preview_answer

基本思路:

  • 一.收集基础词汇信息: 基于网络资源, 整理出需要掌握的 N5 ~ N1 词汇.

  • 二.音频制作: 基于 Google Translate的API,制作音频.

  • 三.数据处理: 将数据格式调整为兼容我现有闪卡系统的格式.

  • 四.闪卡系统改造: 支持多数据源.

一.收集词汇

可能的方式:

  • 抓包 App 的网络请求.

  • Web浏览器 Devtool 抓包请求.

  • 抓取页面 HTML, 手动解析.

数据源的选择:

尽量选择相对可靠的数据源.最好是自己体验过的, 较为熟悉的.否认来回返工, 就有点得不偿失了.

我的单词卡组制作, 参考了 fluentu.考虑到版权等问题, 不宜公开传播相应数据.所以, 我不打算具体进行技术层面的讨论, 也不会分享相关的数据.

二.音频制作

主要是基于 Google Translate 的半公开的 API.算是一个讨论较多的技术方案;但是 Google 并没有公开的开放 API. Google 也提供了 Text To Speech 的云服务,但是效果不行, 无法可靠地处理日语音变. 原因未知, 但是 Google Translate 生成的音频, 无论音质还是对音变的处理, 都是相当可靠和准确.

我分享一个简单的脚本. 实际用的时候, 肯定要适当改造下, 改为批量请求:

curl -o ./audio/ここに.mp3 https://translate.google.com/translate_tts?ie=UTF-8&client=tw-ob&tl=ja&q=%E3%81%93%E3%81%93%E3%81%AB

google_download

google_download_2

尝试修复: 短音频网页上, 可能无法播放.

音频过短时, 部分播放器和浏览器, 可能无法成功播放该音频.所以, 我在原始的音频上, 又追加了一个简单的 空白段. 我以前就用过这个策略, 这次更进一步, 简单封装为了一个 python 脚本:

from pydub import AudioSegment
from pydub.playback import play
import sys

# 原因: 太短的音频, 播放时会有截断.所有音频前|后需要各添加一定时长的 静音.
# 需要安装音频扩展
# apt-get install ffmpeg

def fixMp3File(rawMp3File, fixedMp3file):
    # 加载你的音频文件
    audio = AudioSegment.from_file(rawMp3File, format="mp3")

    # 创建一个长度为500毫秒的静音
    silence = AudioSegment.silent(duration=500)  # duration in milliseconds

    # 在音频前添加静音
    audio = silence + audio

    # 保存新的音频文件
    audio.export(fixedMp3file, format="mp3")

if __name__ == "__main__":
    rawMp3File = sys.argv[1]
    fixedMp3file = sys.argv[2]
    
    fixMp3File(rawMp3File, fixedMp3file)

这里为什么选择用 python 呢? 主要是 pydub 这个库, 用着实在是太方便了!!!

audio_fix

三.数据处理为统一的格式.

这一步, 个性化就很强了. 取决于你正在使用的闪卡系统.

我贴一个 Card 级别的数据结构吧:

{
    "cardId": "3a194b1a7faed27465349ab1ff73d97464d41db818eb67571e2e472fff163388",
    "content": {
        "questionCue": "今 : (noun) now; this moment; just now",
        "questionMedia": "",
        "answerCue": "今 : (noun) now; this moment; just now",
        "answerTransliteration": "いま",
        "answerMedia": "./日本語の言葉/media/今.mp3"
    },
    "lastStudy": null,
    "stage": null
}

可以很明显地看出来, 我在最初设计卡组的数据结构时, 就尽量去除了 卡组自身的一些特性, 尽量让数据结构具有通性. 这样后续添加新卡组时, 只需要将数据统一处理为这种格式即可, 后续的业务逻辑, 几乎不用修改.

另外就是, 汉字,发音等完全一致的, 就判定为同一个词汇, 在制作卡片时就直接过滤掉了.

.forEach(card => {
    const { cardId } = card;
    if (!wordState.card[cardId]) { // 大约有200个完全重复的词汇.此处会直接去除.
        wordState.card[cardId] = card;
        deck.cards.push(cardId);
    }
})

四.闪卡系统改造

主要是要提供对多闪卡数据源的支持.当初, 只是作为一个快速使用的Demo, 没有太多考虑各种衍生需求.

虽然这个略显简陋的闪卡系统, 给我提供了巨大的帮助, 但是我还是不想花费太大精力.

这次依然本着能凑合就凑合着先用的想法, 用了一个简易的方案, 来区分不同的数据源.主要涉及以下修改

1.不同的网络路径, 对应不同的闪卡集合.

let stateCategory = window.location.pathname.replace(/\//g,'');

目前已经支持的卡组:

  • /pismer: 音频App相关短句.
  • /kotoba: 日语JLPT词组.

2.基于分类,切换使用不同的数据源.

import pimsleurState from '../../assets/data/pimsleur_state.json'
import kotobaState from '../../assets/data/kotoba_state.json'

const defaultStateCategory = "pimsleur";
const supportStateCategory = {
    "pimsleur": pimsleurState,
    "kotoba": kotobaState
};

// ref: https://stackoverflow.com/a/68001349
let stateCategory = window.location.pathname.replace(/\//g, '');
if (!supportStateCategory[stateCategory]) {
    stateCategory = defaultStateCategory
}

const baseState = supportStateCategory[stateCategory];
  1. 本地存储前先压缩, 以解决 Storage exceeded the quota

这次日语单词的卡组, 体积有点太大.导致无法调用 localStorage 来存储.直接调用会报错.上限大小应该是 10M 左右.

chunk-GZ55BCQ2.js?v=0b51cb47:3750 Uncaught DOMException: Failed to execute 'setItem' on 'Storage': Setting the value of 'state_cards_kotoba' exceeded the quota

查看 localStorage 的大小, 可以在 devtools 控制台里执行以下 JS 代码:

var _lsTotal=0,_xLen,_x;for(_x in localStorage){ if(!localStorage.hasOwnProperty(_x)){continue;} _xLen= ((localStorage[_x].length + _x.length)* 2);_lsTotal+=_xLen; console.log(_x.substr(0,50)+" = "+ (_xLen/1024).toFixed(2)+" KB")};console.log("Total = " + (_lsTotal / 1024).toFixed(2) + " KB");

我刚开始想用 IndexedDB, 因为 Youtube 的浏览器上的视频下载就是存储到 IndexedDB 里的.但是, 总觉得有点繁杂.

yt_download

还是用了个短平快的方案, 直接把字符串先压缩以下, 体积大约缩小到 12% 左右, 就可以正常存储了.改造后的 localCacheCardState 方法如下:

function localCacheCardState(cardState) {
    // 必须提前将 cardState 转为字符串. 
    // 因为 cardState 有可能是一个 proxy 出来的对象.详见 Redux State相关材料.
    const stateString = JSON.stringify(cardState);

    // 需要异步延迟缓存.
    // compress 方法,大约要耗时在300ms左右; 不异步,会导致切换卡组时,有卡顿感.
    setTimeout(() => {
        const compressedStr = LZString.compress(stateString);
        localStorage.setItem(cacheKey, compressedStr);
    }, 100);
}

主要用到是 LZString 库. 它的主页还有一个快速体验的 Demo.

压缩后的效果, 还是挺惊人的.不过时间消耗在 300ms 左右, 会卡UI,所以延迟在后台调用.当然,所谓的 后台 其实是后一个 eventloop 里.

compressed_after

小感: 确立方案时, 多做加法; 实施阶段, 多做减法

在怎样制作 JLPT 卡片, 其实我有很多脑洞想法, 比如: 用 ChatGPT 给中日含义相近的汉字自动打分等. 有些想法, 最终被实践了下来; 有些想法, 则被快速摒弃.

我有现实的时间压力. JLPT 是 7月份举行, 我必须尽可能快的开始词汇记忆问题.所以我时间的编码时间, 只有2天左右.

所以, 我必须搞清楚,到底最核心的地方在什么地方. 核心的地方, 需要分配更多的地方. 不是很重要的地方, 暂时先凑合着.

显而易见, 数据集的汉字和声音, 是核心的.即: 数据的收集是最核心的. 至于如何呈现, 其实我考虑过最坏的打算: 把 短句卡组的 代码copy一份, 直接修改让 词汇卡组用. 不过原来设计短句卡组时, 适当剥离了 数据和UI. UI真正用的是我们常用的 ViewModel, 这就导致我可以较快地让 短句卡组系统 较容易地支持 词汇卡组.

实施阶段, 必须要多做减法. 先把业务跑通. 因为在业务跑通前, 你是无法预知究竟哪个环节会是真正的时间或者技术瓶颈. 而自己那些天马行空的想法, 往往针对的是已知的可能的瓶颈.

简言之:

  • 做之前, 多考虑不同的方案和策略, 争取为已知的问题, 寻找更优的解决方案.

  • 具体实施的过车中, 尽量做减法, 非必要的优化先缓缓. 争取尽快先把业务能整体跑起来, 确认没有某些未知的 技术瓶颈.

关于 ChatGPT 的使用. 这次没有用 ChatGPT 来动态评定 日语词汇的频率和难度等信息,主要是因为 ChatGPT 的输出不稳定. 如果是少量词汇, 我可以手动校对; 但是对于 8000 多个词汇, 我可能没有太多时间和能力去校对.毕竟许多词汇, 我并不认识. 所以这个时候, 是用 ChatGPT 动态重新给词汇定级, 还是延用已有的 JLPT 分级方案, 显然后者更稳妥些.