はじめに:この記事の立ち位置
私はこのツールのコードを読んでいません。
AIに頼んで作らせ、出てきたものを実行し、思っていたのと違うところを言い直しただけです。 結果として、TOEIC単語カード1,376枚とハングル358枚が、音声つきで手元にあります。
技術解説ではありません。「コードを読まない人間がAIに開発を任せると、どこで詰まるか」の記録です。
そのぶん、出てきたコードは全文載せます。 動作確認もしています。 中身の良し悪しは、読んでいる方が判断してください。
何を作らせたか
単語帳のページを撮った写真から、Anki(単語カードアプリ)に取り込めるファイルを生成するツールです。
単語帳の写真 → AI → .apkg → Anki
出来上がったものの仕様:
CSVから .apkg を生成(genanki)
全カードに発音音声を自動生成(edge-tts・無料・APIキー不要)
音声の前後の無音を自動カット(ffmpeg)
例文の穴埋め形式(頭文字+文字数のヒント付き)
番号ごとに子デッキへ自動分割
実際にやったこと:4回の指示
1回目 写真を渡す
これらの写真を、Ankiというアプリのデッキというものを作ってください。
一発で動きました。 エラーも出ず、取り込めるファイルが出てきました。
正直、ここは拍子抜けするほど簡単でした。
でも、使えませんでした
開いて最初に思ったのは「字が読めない」でした。
背景と同じ色で、文字が書かれていました。
動いてはいる。ファイルもできている。でも使えない。
ここが、この記事で一番言いたいところです。
AIは、一発で動くものを作ります。 でも、一発で「自分が使えるもの」は作りません。
コードを読めないと、この2つを混同します。 「動いた=できた」と思って、中を見ずに次へ進みかけました。
実際に1枚めくって、初めて分かりました。
2回目 色
背景とは違う色で文字を書いてください。
これで読めるようになりました。
3回目 音声
英単語の発音の音声をつけてください。
1,376枚すべてに音声がつきました。
4回目 分割
デッキを100ずつ分けて生成してください。
1,376枚が1つの塊だと、どこまでやったか分からず続きません。
指示していないのに、穴埋め形式になっていた
書きながら気づいたことがあります。
私のカードは例文の中の単語が空欄になっている穴埋め形式ですが、 穴埋めにしてくれとは一度も言っていません。
理由は単純で、渡した単語帳の写真が、元から穴埋め形式だったからです。
AIはその形をそのまま再現していました。
ここから言えること
渡すものの形が、そのまま出来上がりの形になります。
「どういうものが欲しいか」を言葉で説明するより、 「そういう形のもの」を渡す方が速い。
言葉で「例文の中の単語を隠して、頭文字と文字数が分かるように」と説明するのは大変です。 そういうページを撮って渡せば、それで済みます。
プロンプトを工夫するより、入力を選ぶ方が効きました。
出てきたコード
読んでいませんが、全文載せます。動作確認はしています。
確認した環境: Windows 11 / Python 3.13 / genanki / edge-tts 7.2.8
10語で .apkg 生成(82KB・音声10件同梱)
55語で子デッキ2分割
再実行で音声を作り直さない(ハッシュ命名 + 存在チェック)
音声生成は60語で5.6秒(1,376語換算で約2.1分)、無音カットは60件1.6秒(約40秒)
tts.py(音声生成)
import asyncio, hashlib, os
import edge_tts
AUDIO_DIR = "audio"
VOICE_EN = "en-US-AriaNeural"
CONCURRENCY = 8
def audio_filename(text, voice=VOICE_EN):
key = f"{voice}::{text}".encode("utf-8")
return hashlib.md5(key).hexdigest() + ".mp3"
def sound_tag(text, voice=VOICE_EN):
return f"[sound:{audio_filename(text, voice)}]"
async def _one(text, voice, sem):
path = os.path.join(AUDIO_DIR, audio_filename(text, voice))
if os.path.exists(path):
return
async with sem:
await edge_tts.Communicate(text, voice).save(path)
def generate(texts, voice=VOICE_EN):
uniq = sorted({t for t in texts if t and t.strip()})
os.makedirs(AUDIO_DIR, exist_ok=True)
async def run():
sem = asyncio.Semaphore(CONCURRENCY)
await asyncio.gather(*(_one(t, voice, sem) for t in uniq))
asyncio.run(run())
trim_silence.py(無音カット)
import os, shutil, subprocess
from concurrent.futures import ThreadPoolExecutor
FFMPEG = shutil.which("ffmpeg") or "ffmpeg"
THRESHOLD, KEEP = "-45dB", "0.05"
_FILTER = (
f"silenceremove=start_periods=1:start_silence={KEEP}:start_threshold={THRESHOLD},"
"areverse,"
f"silenceremove=start_periods=1:start_silence={KEEP}:start_threshold={THRESHOLD},"
"areverse"
)
def _trim_one(path):
marker = path + ".trimmed"
if os.path.exists(marker):
return False
tmp = path + ".tmp.mp3"
r = subprocess.run([FFMPEG,"-y","-loglevel","error","-i",path,"-af",_FILTER,tmp],
capture_output=True)
if r.returncode != 0 or not os.path.exists(tmp):
if os.path.exists(tmp): os.remove(tmp)
return False
os.replace(tmp, path)
open(marker, "w").close()
return True
def trim_all(audio_dir="audio", workers=4):
if not os.path.isdir(audio_dir):
return
targets = [os.path.join(audio_dir,n) for n in os.listdir(audio_dir) if n.endswith(".mp3")]
with ThreadPoolExecutor(max_workers=workers) as ex:
list(ex.map(_trim_one, targets))
silenceremove は先頭しか切れないので、 **「先頭を切る → 逆再生 → また先頭を切る → 戻す」**で両端を処理しています。
処理済みの印を1つ置いて、二度切らないようにしてあります(切るたびに再エンコードで劣化するため)。
build_deck.py(本体・抜粋)
MODEL_ID = 1607392319
DECK_ID_BASE = 2059400110
class StableNote(genanki.Note):
@property
def guid(self):
return genanki.guid_for(self.fields[0])
この StableNote が、あとから効きました。
genanki は既定で、カードの識別子(guid)を全フィールドのハッシュから作ります。 つまり訳語を1文字直すと guid が変わり、Anki は別のカードとして扱います。 元のカードは消え、復習履歴がそこで途切れます。
先頭に置いた不変のキー(英単語)だけから guid を作れば、 中身を直しても同じカードの更新として取り込まれます。
私はこれを頼んだ覚えがありません。AIが先回りして入れていました。 コードを読まない人間には、こういう「入っていて助かったもの」に気づく機会がありません。
その他
Excel が保存した CSV は先頭に不可視文字(BOM)が入るので utf-8-sig
with open(CSV_PATH, encoding="utf-8-sig", newline="") as f:
rows = [r for r in csv.DictReader(f) if r.get("word")]
これが無いと [sound:] タグだけ入って無音になる
package = genanki.Package(list(decks.values()))
package.media_files = tts.media_files(words)
コードを読まないまま開発を任せて、分かったこと
- 「動いた」と「使える」は別
エラーが出ないことと、目的を果たすことは違います。
必ず実物を1つ触ってください。 色の問題は、ファイルを見ているだけでは分かりませんでした。
- 一度に全部頼まない
4回に分けたので、どの段階でも一度の言い直しで直りました。
まとめて頼むと、思っていたのと違ったときに切り分けられません。 コードを読めないので、自分では調べられないからです。
-
入力の形を選ぶ方が、指示を工夫するより効く
穴埋め形式の件がそれです。プロンプトをいくら丁寧にしても、 欲しい形の実物を1枚渡す方が確実でした。 -
読まないと、入っているものに気づけない
StableNote のような、頼んでいないが重要な処理が入っていました。
これは読める人が読めば5秒で分かることです。 読まない選択をすると、そこは丸ごと見えなくなります。
私はそれを承知で読んでいませんが、そういうトレードオフがあるとは書いておきます。
おわりに
コードが読めないから自動化できない、ということはもう無いと思います。
一方で、読めないことで見えなくなる範囲もあります。 両方本当です。
この記事が、同じことを試す人の参考になれば嬉しいです。
写真1枚から完成までの指示文と、動くファイル一式は note にまとめています。
→ AIに指示していないのに、穴埋めカードが出てきた理由
https://note.com/dokulab/n/na0ec6150aea6