TypeScriptで作る “重複しない” シャッフル再生ロジック
ランダムなのに偏りゼロ?CV再生ロジックを作ってみた話
TypeScriptで作る “重複しない” シャッフル再生ロジック
CV(キャラクターボイス)をランダムに再生する機能を JavaScript で実装していたところ、「無駄な 2 重ランダム」 が潜んでいました。
リストをシャッフルしランダムな並びのリストを作成。
↓
その配列から毎回ランダムで取り出す。
とかいう意味のない処理。。。
かなり端折りましたが問題の部分はこんな感じのコードでした、、、
const cvList = ["1.wav", "2.wav", "3.wav"];
const shuffledList = shuffleArray(cvList); // 最初に1回だけシャッフル
// 再生ボタンが押されるたびに呼ばれる関数
function playCV() {
// 毎回ランダムに選択
const randomIndex = Math.floor(Math.random() * shuffledList.length);
const cv = shuffledList[randomIndex];
console.log(cv); // "1.wav" の次にまた "1.wav" が来てしまうことがある
}
JSは全く悪くなく私のクソ設計が原因なのですがね、、、
これでは意図した動作にならないため、設計を修正します。
ちょうどTSの記事コンテストが開催されていたのでTSに書き直しました。
目標:一巡するまでは重複させない
- 最初に 1 回だけシャッフル
- 配列を 順番 に消費 → 全て使い切ったら再シャッフル
これをTSで実装していきたいと思います。
実装した TypeScript コード
1. 汎用シャッフル関数
/** 任意の配列をフィッシャーイェーツ法でシャッフル */
function shuffleArray<T>(arr: T[]): T[] {
const a = [...arr];
for (let i = a.length - 1; i > 0; i--) {
const j = Math.floor(Math.random() * (i + 1));
[a[i], a[j]] = [a[j], a[i]];
}
return a;
}
2. 配列を "順番に" で取り出させる
class CycleRandom<T> {
private pool: T[]; // 今使う順番が入った配列
private idx = 0; // 次に取る位置
constructor(private readonly source: T[]) {
this.pool = shuffleArray(source); // 初回だけシャッフル
}
// 全部使い切ったら作り直す
next(): T {
if (this.idx >= this.pool.length) {
this.pool = shuffleArray(this.source); // 再シャッフル
this.idx = 0;
}
return this.pool[this.idx++];
}
}
これで連続再生が発生する可能性はほぼなくなりました。
旧シャッフルリストの最後の要素と、再シャッフルされた新しいリストの最初の要素が偶然同じだった場合、結果的に連続して再生されてしまいますが、、、普通のシャッフルと比べると発生確率はかなり低くすることができています。
3. こんなふうにアプリで使った
技術というより “実際にどう活かしたか” の紹介です。
冒頭にCVがなんちゃらみたいなお話しをしましたがこのような個人開発のアプリで利用しています。
アプリ概要(ざっくり)
- 右側の タイマーエリア でポモドーロを回し、学習時間を Firestore に保存。
- 過去 30 日分の作業時間をグラフ化。時間応じて🐣が成長していきます。
- 左側の Tasks / Today エリア では Google Tasks & Calendar API と連携して予定とタスクを表示・編集。
- そして最大の特徴が CV での応援。作業開始・休憩開始などイベントごとに声が流れます!
-名前を読んで頂いた物もありますがその日の気分で切り替えができるように。(その日の気分とかの問題で)
え?なんで名前呼びのCVがあるかって?それは個人依頼みたいな感じで収録をお願いしたからです。。。
ここにはあんまり触れないでください←
せっかく大量のボイスを用意したのに 同じファイルが連続 したら萎える……。そこで今回の CycleRandom
を投入しました。また名前呼びをして欲しい時として欲しくない時があるので
そのフィルターも仕込みたくアレンジも行ってみています。
実際のコード断片
//0.先程のコードベースの関数
class CycleRandom<T> {
private pool: T[];
private idx = 0;
constructor(private readonly source: T[]) {
this.pool = shuffleArray(source);
}
next(): T {
if (this.idx >= this.pool.length) {
this.shuffleNow();
}
return this.pool[this.idx++];
}
shuffleNow() {
this.pool = shuffleArray(this.source);
this.idx = 0;
}
}
//1. ファイル元の配列(実際はもっと多めです。。)
const pomodoroStartCVs = ["start_1.wav", "start_2.wav", "start_3.wav"];
const pomodoroStartNameCVs = ["start_name_1.wav", "start_name_2.wav"];
//2. ON/OFF を切り替えるチェックボックスの状態ON/OFF取得
const toggleName = document.getElementById("toggleName") as HTMLInputElement;
//3. 一巡シャッフル関数をセットアップ
const pomodoroShuffler = new CycleRandom([
...pomodoroStartCVs,
...pomodoroStartNameCVs,
]);
// 4. 再生関数(名前呼び OFF のときはフィルタさせる)
function playPomodoroStart() {
let cv: string | null = null;
const maxRetry = 3;
for (let retry = 0; retry < maxRetry; retry++) {
for (let i = 0; i < pomodoroStartCVs.length + pomodoroStartNameCVs.length; i++) {
const candidate = pomodoroShuffler.next();
const isNameCall = pomodoroStartNameCVs.includes(candidate);
if (toggleName.checked || !isNameCall) {
cv = candidate;
break;
}
}
if (cv) break;
// 対象が見つからなかったらシャッフルし直し。
pomodoroShuffler.shuffleNow(); //
}
if (cv) {
new Audio(`voice/pomodoroStart/${cv}`).play();
} else {
console.warn("条件に合うCVが見つかりませんでした");
}
}
このコードについて
この関数では、CycleRandom
クラスを使いつつ、名前呼びのON/OFF状態に応じて再生するファイルを選択しています。
-
pomodoroShuffler.next()
で次の候補を取得。 - 「名前呼びOFF」の状態で、名前呼びボイスが候補になった場合は、それをスキップして次の候補を探す。
- この処理を、条件に合う物が見つかるかリストを一周するまで繰り返し。
- もしリストを一周しても(名前呼びON/OFFによって)再生できる物が見つからなかった場合は、
shuffleNow()
でリストを再シャッフルし、もう一度探し直す。 - この再試行の処理を最大3回までとして無限ループを防止。
注意・このコードは抜粋です。
名前ありのCVが見つからない場合のエラーハンドリングのみを記載していますが
名前なしのCVが見つからない場合の対応も必要です。実装していますがここでは省略します。
効果は?
- 重複ゼロ:ボイスが連続で被らなくなり、聴いていて飽きない。
-
メンテ楽々:
start_*.wav
を増やすだけで勝手にシャッフル対象に追加。
「好きな声で応援してもらうとやる気出るよね!」という私利私欲の塊みたいなあれですがこれでかなりメンテが良くなったと思います!
一部のコードや解説はAI生成です。
Discussion