0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

コードを1行も読まずに、Ankiデッキ生成ツールを作らせた記録

0
Last updated at Posted at 2026-09-17

はじめに:この記事の立ち位置
私はこのツールのコードを読んでいません。

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. 「動いた」と「使える」は別
    エラーが出ないことと、目的を果たすことは違います。

必ず実物を1つ触ってください。 色の問題は、ファイルを見ているだけでは分かりませんでした。

  1. 一度に全部頼まない
    4回に分けたので、どの段階でも一度の言い直しで直りました。

まとめて頼むと、思っていたのと違ったときに切り分けられません。 コードを読めないので、自分では調べられないからです。

  1. 入力の形を選ぶ方が、指示を工夫するより効く
    穴埋め形式の件がそれです。プロンプトをいくら丁寧にしても、 欲しい形の実物を1枚渡す方が確実でした。

  2. 読まないと、入っているものに気づけない
    StableNote のような、頼んでいないが重要な処理が入っていました。

これは読める人が読めば5秒で分かることです。 読まない選択をすると、そこは丸ごと見えなくなります。

私はそれを承知で読んでいませんが、そういうトレードオフがあるとは書いておきます。

おわりに
コードが読めないから自動化できない、ということはもう無いと思います。

一方で、読めないことで見えなくなる範囲もあります。 両方本当です。

この記事が、同じことを試す人の参考になれば嬉しいです。

写真1枚から完成までの指示文と、動くファイル一式は note にまとめています。

→ AIに指示していないのに、穴埋めカードが出てきた理由
 https://note.com/dokulab/n/na0ec6150aea6

0
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?