Ankiカードの再生速度を変える方法──実装で7回詰まったポイントとコードを全公開【PART4】

以前作った、PART3でUS・UK・AUのアクセントボタンをAnkiカードに実装して、「これで英語シャドーイングが捗る!」と満足していたんですが、音声が長めに自動で入るようになってますが再生速度を変えたいなぁとおもいました。 ただでさえ社会人で仕事から帰って家のこともやる中で英語学習時間作るの大変なので、こういう時短はどんどん取り入れるべきだ!と、つくってみました。

「1.0×だとゆっくりすぎる。ちょっと速く聴きたい」

「さっきの単語もう一回。5秒巻き戻したい」

速度ボタンとプログレスバーを追加して、音声の速度や聴きたいポイントにすぐ飛ばせるようにもしてみたい。

AIに聞いて実装してもらうまでに、結構つまりポイントも多かったのでその全記録を書きます。詰まったポイントのコードも含めて全部公開するので、同じことをやりたい方はコピペで使えると思います。

スポンサーリンク

シャドーイングを速度変えて聴きたかった

PART1〜3でシャドーイング教材を自動生成してAnkiカードに貯めていく仕組みを作りました。n8nとEdge TTSを使ってUS・UK・AUの3アクセントの音声を自動生成して、カード表面のボタンを押すとそのアクセントで聴けるというやつです。

3アクセントボタン実装──US・UK・AUをAnkiカードで聴き分ける仕組みを作った話【PART3】

このPART3のカードで日々シャドーイングをやっていたんですが、一つ不満があって。速度が1.0×固定なんです。

まぁいま、Open AIのフリーの音声TTS(日制限はありだった気が)つかってそのまま出力してるのでナチュラルな自然言語のスピードなんですが。

シャドーイングって、なんかも音声聴きたい部分があったり、なんて言ってるかわからないところリピートしたり、なれてきたら少し速めで繰り返すと良い気がして。一緒に発音するときは遅めでスタートして、だんだん早くしていくみたいな。でもAnkiのデフォルトの音声をカードの裏側で速度を変える方法がなかったんです。あとプログレスバーもないので、「今のフレーズもう一回聴きたい」と思っても最初から再生し直すしかなかったんです。5秒だけ戻せれば全然違うのに。

ということで今回追加しようとしたのは:

  • 速度ボタン(1.0×・1.25×・1.5×)
  • プログレスバー(シーク・-5秒巻き戻し・時間表示)

CLAUDE君に任せればすぐちゃっちゃっとしてくれると思っていたら、なぜかどんどん深みにはまっていきました。

7回詰まったポイント全部書きます

詰まりポイント①:速度ボタンを押しても速度が変わらない

まず速度ボタンを3つ追加して、クリックしたら audio.playbackRate = 1.5 みたいに変えるコードを書きました。ボタンは表示される。押せる。でも速度が変わらない。。。

原因は変数のスコープでした。PART3では音声オブジェクトを var aud = new Audio(url) というローカル変数で持っていたんですが、速度ボタンのクリックハンドラは別の関数の中なので aud に届かなかったんです。

「あ、そういうことか笑」とCLAUDE君気づいてすぐ修正しようとしたんですが、この修正の過程でさらに大きな罠にはまることになります。

詰まりポイント②:[sound: ってスクリプトに書いたらJavaScriptが壊れた

音声ファイル名を取り出すために、こんな正規表現をスクリプトに書きました。

var match = val.match(/\[sound:([^\]]+)\]/);

JavaScriptとしては正しいコードだそうです。でもAnkiのテンプレートに貼り付けた瞬間、音声が一切鳴らなくなって、再生ボタン自体も消えました。 ( ゚д゚)

Ankiのテンプレートエンジンは、HTML全体(<script> の中も含む)で [sound: というパターンを探して、リプレイボタンのHTMLに自動置換してしまうんです。

正規表現リテラルの中に書いても、文字列 '[sound:' として書いても、コメントに // [sound: と書いても、全部置換の対象になります。

つまりCLAUDE君が書いたJavaScriptのコードがAnkiに書き換えられて、でたらめなHTMLになって、シンタックスエラーが起きてスクリプト全体がクラッシュしていたわけです。

解決策:文字列を分割して書いて、実行時に結合する

// 直接書くとAnkiに置換されてしまう(NG)
// var SND_PFX = '[sound:';
// 分割して書く(OK)
var SND_PFX = '[sou' + 'nd:';
function extractFn(val) {
  var i = val.indexOf(SND_PFX);
  if (i < 0) { return null; }
  var j = val.indexOf(']', i + SND_PFX.length);
  if (j < 0) { return null; }
  return val.substring(i + SND_PFX.length, j);
}

Ankiのテンプレート内では [sound: という文字列を一切書かないことが大事です。これ、知らないとほんとうに詰まり続けると思います。JavaScriptのコードを書いているのに、Ankiが勝手に書き換えるという発想はなかなか出てこないですよね。。。(;´Д`A

詰まりポイント③:再起動したら音声が出なくなった

いったん動いたと思ったら、Ankiを再起動したあとに音声が出なくなりました。原因はポート番号でした。Ankiがメディアファイルを配信するローカルHTTPサーバーは、再起動するたびにポート番号が変わるんです。

解決策window.location.origin を使う。

var url = window.location.origin + '/' + encodeURIComponent(fn);
var audio = new Audio(url);

詰まりポイント④:デバッグ用のdivが「…」のまま何も変わらない

Ankiのテンプレート内ではトップレベルのスクリプトが不安定 で、document.getElementByIdnull を返すことがあって、.textContent を呼んだ瞬間TypeErrorが出てスクリプト全体がクラッシュしていたんです。エラーは画面に出ない。

解決策:すべてのコードをIIFEで包む

(function() {
  var dbg = document.getElementById('debug');
  if (dbg) { dbg.textContent = 'JS動作確認'; }
})();

詰まりポイント⑤:AnkiConnectがnoteIdを返さない

guiCurrentCardnoteId を返さない、という仕様をCLAUDE君わかっていなかったんですが、実は noteId 経由で二段階取得する必要なかったんです。d.result.fields.AudioUS.value を直接見ると、生の [sound:ファイル名.mp3] という文字列が入っています。

詰まりポイント⑥:CORS問題(ちょっと細かい話)

AnkiConnectへのfetchで Content-Type: application/json を使っていたんですが、ブラウザはこのContent-Typeのときプリフライトリクエストを先に送る仕様になっていて、AnkiConnectがそれに対応していないと失敗することがある。解決策Content-Type: text/plain にする。

fetch('http://127.0.0.1:8765', {
  method: 'POST',
  headers: { 'Content-Type': 'text/plain' },  // ← これが安全
  body: '{"action":"guiCurrentCard","version":6}'
})

詰まりポイント⑦:カードをめくったら音声が止まらなかった

これが最後にして最もやっかいなバグでした。USの音声を流しながらカードをめくると音声が流れっぱなし。裏面で別のアクセントボタンを押したら2つ同時に鳴り出す。

Ankiの裏面テンプレートで {{FrontSide}} と書くと、表面のHTMLがそのまま埋め込まれてスクリプトも再実行されます。でも裏面IIFEから表面IIFEのローカル変数 aud にはアクセスできない。

解決策:音声オブジェクトを window._ankiAud というグローバル変数に持たせる。

window._ankiAud = new Audio(url);
function stopAll() {
  if (window._ankiAud) {
    window._ankiAud.pause();
    window._ankiAud = null;
  }
  document.querySelectorAll('.accent-btn').forEach(function(b) {
    b.classList.remove('playing', 'paused');
  });
}

裏面のIIFEが実行されたとき、最初に stopAll() を呼べば表面で流れていた音声も止まります。これで7回分の詰まりポイントが全部解決しました!!♪( ´▽`)

完成コードをまるごと公開します

同じことをやりたい方向けに、動作確認済みのコードをそのまま貼ります。コピペで使えると思います。

前提として、AnkiConnect(Ankiのアドオン・ID:2055492159)をインストールした状態で使ってください。フィールド名は AudioUSAudioUKAudioAU を使っている想定です。

フロントテンプレート(カード表面)

<div style="display:none">{{AudioUS}}{{AudioUK}}{{AudioAU}}</div>
<div class="accent-btns">
  <div class="accent-btn" id="btn-us"><span class="flag">🇺🇸</span><span class="lbl">US</span></div>
  <div class="accent-btn" id="btn-uk"><span class="flag">🇬🇧</span><span class="lbl">UK</span></div>
  <div class="accent-btn" id="btn-au"><span class="flag">🇦🇺</span><span class="lbl">AU</span></div>
</div>
<div id="anki-player" style="display:none">
  <div class="prow">
    <span id="cur-time">0:00</span>
    <button id="rew-btn">⏮ -5s</button>
    <span id="tot-time">0:00</span>
  </div>
  <input type="range" id="prog-bar" value="0" min="0" max="100" step="0.1">
</div>
<div class="spd-btns">
  <button class="spd-btn" data-speed="1">1.0×</button>
  <button class="spd-btn" data-speed="1.25">1.25×</button>
  <button class="spd-btn" data-speed="1.5">1.5×</button>
</div>
<style>
.accent-btns { display:flex; gap:12px; justify-content:center; margin:14px 0 8px; }
.accent-btn { display:flex; flex-direction:column; align-items:center; gap:4px; padding:12px 20px; border:2px solid #d0d0d0; border-radius:16px; background:#f8f8f8; min-width:80px; cursor:pointer; user-select:none; transition:border-color 0.15s, background 0.15s; }
.accent-btn.playing { border-color:#4a90d9; background:#e8f0fe; }
.accent-btn.paused  { border-color:#aaa; background:#f0f0f0; opacity:0.8; }
.flag { font-size:2.2em; }
.lbl  { font-size:1em; font-weight:700; color:#444; }
#anki-player { background:#f5f8ff; border-radius:12px; padding:10px 16px; margin:6px 0 10px; }
.prow { display:flex; align-items:center; justify-content:space-between; font-size:12px; color:#666; margin-bottom:6px; }
#rew-btn { padding:3px 12px; border:1px solid #4a90d9; border-radius:99px; background:white; color:#4a90d9; font-size:12px; cursor:pointer; }
#prog-bar { width:100%; accent-color:#4a90d9; cursor:pointer; }
.spd-btns { display:flex; gap:8px; justify-content:center; margin:4px 0 12px; }
.spd-btn { padding:5px 14px; border:2px solid #4a90d9; border-radius:99px; background:white; color:#4a90d9; font-size:13px; font-weight:700; cursor:pointer; }
.spd-btn.active { background:#4a90d9; color:white; }
</style>
<script>
(function() {
  var files = {};
  var curAcc = null;
  var spd = 1.0;
  var dragging = false;
  var saved = sessionStorage.getItem('anki_spd');
  if (saved) { spd = parseFloat(saved); }
  document.querySelectorAll('.spd-btn').forEach(function(b) {
    if (parseFloat(b.dataset.speed) === spd) { b.classList.add('active'); }
  });
  var SND_PFX = '[sou' + 'nd:';
  function extractFn(val) {
    var i = val.indexOf(SND_PFX);
    if (i < 0) { return null; }
    var j = val.indexOf(']', i + SND_PFX.length);
    if (j < 0) { return null; }
    return val.substring(i + SND_PFX.length, j);
  }
  function fmt(s) {
    s = Math.floor(s || 0);
    return Math.floor(s / 60) + ':' + (s % 60 < 10 ? '0' : '') + (s % 60);
  }
  function updateProg() {
    if (dragging) { return; }
    var a = window._ankiAud;
    if (!a || !a.duration) { return; }
    var bar = document.getElementById('prog-bar');
    if (bar) { bar.value = (a.currentTime / a.duration) * 100; }
    var c = document.getElementById('cur-time');
    if (c) { c.textContent = fmt(a.currentTime); }
    var t = document.getElementById('tot-time');
    if (t) { t.textContent = fmt(a.duration); }
  }
  function stopAll() {
    if (window._ankiAud) { window._ankiAud.pause(); }
    window._ankiAud = null;
    document.querySelectorAll('.accent-btn').forEach(function(b) {
      b.classList.remove('playing');
      b.classList.remove('paused');
    });
  }
  function playAcc(acc, btn) {
    var a = window._ankiAud;
    if (acc === curAcc && a) {
      if (a.paused) {
        a.play();
        btn.classList.remove('paused');
        btn.classList.add('playing');
      } else {
        a.pause();
        btn.classList.remove('playing');
        btn.classList.add('paused');
      }
      return;
    }
    stopAll();
    var fn = files[acc];
    if (!fn) { return; }
    curAcc = acc;
    var na = new Audio(window.location.origin + '/' + encodeURIComponent(fn));
    na.playbackRate = spd;
    na.addEventListener('timeupdate', updateProg);
    na.addEventListener('loadedmetadata', updateProg);
    na.addEventListener('ended', function() { btn.classList.remove('playing'); });
    na.play();
    window._ankiAud = na;
    btn.classList.add('playing');
    var player = document.getElementById('anki-player');
    if (player) { player.style.display = ''; }
  }
  ['us', 'uk', 'au'].forEach(function(acc) {
    var btn = document.getElementById('btn-' + acc);
    if (btn) { btn.onclick = function() { playAcc(acc, btn); }; }
  });
  var bar = document.getElementById('prog-bar');
  if (bar) {
    bar.addEventListener('mousedown',  function() { dragging = true; });
    bar.addEventListener('touchstart', function() { dragging = true; });
    bar.addEventListener('mouseup',    function() { dragging = false; });
    bar.addEventListener('touchend',   function() { dragging = false; });
    bar.addEventListener('input', function() {
      var a = window._ankiAud;
      if (a && a.duration) { a.currentTime = a.duration * parseFloat(this.value) / 100; }
    });
  }
  var rew = document.getElementById('rew-btn');
  if (rew) {
    rew.onclick = function() {
      var a = window._ankiAud;
      if (!a) { return; }
      var t = a.currentTime - 5;
      a.currentTime = t < 0 ? 0 : t;
    };
  }
  document.querySelectorAll('.spd-btn').forEach(function(b) {
    b.onclick = function() {
      spd = parseFloat(b.dataset.speed);
      sessionStorage.setItem('anki_spd', String(spd));
      document.querySelectorAll('.spd-btn').forEach(function(x) { x.classList.remove('active'); });
      b.classList.add('active');
      var a = window._ankiAud;
      if (a) { a.playbackRate = spd; }
    };
  });
  fetch('http://127.0.0.1:8765', {
    method: 'POST',
    headers: { 'Content-Type': 'text/plain' },
    body: '{"action":"guiCurrentCard","version":6}'
  }).then(function(r) { return r.json(); })
  .then(function(d) {
    if (!d.result || !d.result.fields) { return; }
    var f = d.result.fields;
    if (f.AudioUS) { files.us = extractFn(f.AudioUS.value); }
    if (f.AudioUK) { files.uk = extractFn(f.AudioUK.value); }
    if (f.AudioAU) { files.au = extractFn(f.AudioAU.value); }
  }).catch(function() {});
})();
</script>

バックテンプレート(カード裏面)

{{FrontSide}}
<hr class="divider">
<div class="section"><div class="label">📝 シャドーイングテキスト</div><div class="shadow-text">{{Text}}</div></div>
<div class="section"><div class="label">🇯🇵 日本語訳</div><div class="translation">{{Translation}}</div></div>
<div class="section"><div class="label">🔑 重要表現</div>{{Expressions}}</div>
<div class="section"><div class="label">📚 関連語彙</div>{{Vocabulary}}</div>
<div class="section"><div class="label">🔗 出典</div><a class="source-link" href="{{URL}}">{{Title}}</a></div>

まとめ

7回詰まりましたが、なんとか完成しました。

一番の収穫は「Ankiのテンプレートエンジンは [sound: というパターンをスクリプトの中まで全置換してしまう」という仕様を知れたことです。これを知らないと、正しいJavaScriptを書いているはずなのに謎のエラーで永遠に詰まり続けます。

同じことをやりたい方、コードごとコピペして使ってみてください。フィールド名を自分のノートタイプに合わせるだけで動くと思います。

興味が出たらぜひ試してみてください。♪( ´▽`)


今日学んだ英語表現(B2〜C1)

① troubleshoot(= 問題の原因を探って解決する)

I spent a few hours trying to troubleshoot why the audio wasn’t playing at all.

エラーを特定して直していくプロセス全体を指す言葉。「デバッグ」より広い意味で使えて、技術系の文章でよく出てくる表現。

② under the hood(= 内部的に・裏側で)

Under the hood, Anki replaces certain text patterns before rendering the template.

外からは見えない内部の動きを指す比喩表現。車のボンネット(フード)の下、というイメージ。

③ trial and error(= 試行錯誤)

I finally got it working through trial and error after about two hours.

一つ試して失敗したら別の方法を試す、という繰り返しのプロセス。


3アクセントボタン実装──US・UK・AUをAnkiカードで聴き分ける仕組みを作った話【PART3】
n8nで英語シャドーイング教材を自動生成してAnkiにストックする仕組みを作る──TTS3回詰まり編【PART2】
n8nで英語シャドーイング教材を自動生成してAnkiにストックする仕組みを作る【PART1】

コメントを残す

メールアドレスが公開されることはありません。 が付いている欄は必須項目です