生成AIによる自重トレーニングサポートサイトの作成

# はじめに

リモートワークや在宅時間が増える中、自重トレーニングに興味を持つ人が増えています。ただ、トレーニング経験がない人にとって、どのようなトレーニングをすればいいのかわからないという声も多いのではないでしょうか。

そこで、今回はAIが自重トレーニングのメニューを提案するサービスを開発しました。

当該サイト：

https://mdown.ai/content/be5c14ae-cf80-4ffe-a356-4c2b5f51fdf2

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/417525/6c70e7f8-5f95-dca4-30e6-98bbb1084b24.png)

# 作成背景

リモートワーク中に座りっぱなしになることが増えた経験から、手軽に体を動かせるツールを作りたいと考えました。そこで、以下を目標にしてサービスを開発しました。

1. **誰でも簡単に使えること**
    - トレーニング初心者で直感的に理解できるインターフェースを目指しました。
1. **モチベーションを維持できる工夫**
    - 鍛えたい部位をテキストで入力し、AIが自動でトレーニングメニューを提案することで、目新しいトレーニングを提案し、モチベーションを維持できるようにしました。
1. **タイマー&サウンド機能**
    - トレーニング時間を設定し、タイマーが終了すると音が鳴る機能を実装しました。トレーニングの内容によってはPCを見れないこともあるため、音で知らせることで、トレーニングを途中でやめることがないようにしました。

# 使用した技術

## Markdown AI

生成AIとの対話の箇所をMarkdown AIを使って実装しました。簡単にWebサイトに埋め込むことができるため非常に便利だと感じました。

また、Markdown AIへはMarkdownだけではなく、HTML、JavaScript、CSSを利用することができるため、そちらも活用しました。

## HTML/CSS/JavaScript

タイマー機能や音声機能を実装するためにJavaScriptを利用しました。

<details><summary>詳細なコード</summary>

```html
<div style="text-align:center; margin: 20px 0;">

  <h2>トレーニングタイマー</h2>

  <!-- 時間設定フォーム -->
  <div style="margin-bottom: 20px; text-align: center;">
    <label for="exercise-time" style="font-size: 1rem;">運動時間（秒）:</label>
    <input id="exercise-time" type="number" value="60" style="padding: 5px; font-size: 1rem; width: 60px;">
    <label for="rest-time" style="font-size: 1rem; margin-left: 10px;">休憩時間（秒）:</label>
    <input id="rest-time" type="number" value="15" style="padding: 5px; font-size: 1rem; width: 60px;">
    <label for="num-cycles" style="font-size: 1rem; margin-left: 10px;">サイクル数:</label>
    <input id="num-cycles" type="number" value="10" style="padding: 5px; font-size: 1rem; width: 60px;">
    <button id="set-timer-btn" style="padding: 5px 10px; font-size: 1rem; cursor: pointer; margin-left: 10px;">設定</button>
  </div>

  <div style="display: inline-block; padding: 20px; border: 2px solid #4CAF50; border-radius: 12px; background-color: #f9f9f9;">
    <div id="phase-display" style="font-size: 1.5rem; margin-bottom: 10px; color: #4CAF50;">
      準備中
    </div>
    <div id="timer-display" style="font-size: 3rem; margin-bottom: 20px;">
      00:00
    </div>
    <button id="start-btn" style="padding: 10px 20px; font-size: 1rem; margin: 0 5px; cursor: pointer;">▶</button>
    <button id="stop-btn" style="padding: 10px 20px; font-size: 1rem; margin: 0 5px; cursor: pointer;">■</button>
    <button id="reset-btn" style="padding: 10px 20px; font-size: 1rem; margin: 0 5px; cursor: pointer;">🔄</button>
  </div>
</div>

<script>
  let timerInterval = null;
  let isExercisePhase = true;
  let currentCycle = 1;
  let beepPlayed = false; // ビープ音が再生されたかを追跡するフラグ

  const phaseDisplay = document.getElementById('phase-display');
  const timerDisplay = document.getElementById('timer-display');
  const startBtn = document.getElementById('start-btn');
  const stopBtn = document.getElementById('stop-btn');
  const resetBtn = document.getElementById('reset-btn');

  const exerciseTimeInput = document.getElementById('exercise-time');
  const restTimeInput = document.getElementById('rest-time');
  const setTimerBtn = document.getElementById('set-timer-btn');

  let exerciseTimeDefault = parseInt(exerciseTimeInput.value, 10);
  let restTimeDefault = parseInt(restTimeInput.value, 10);

  let remainingTime = exerciseTimeDefault;
  let totalCycles = parseInt(document.getElementById('num-cycles').value, 10);

  setTimerBtn.addEventListener('click', () => {
    let isValid = true;

    // 運動時間チェック
    if (parseInt(exerciseTimeInput.value, 10) < 5 || isNaN(parseInt(exerciseTimeInput.value, 10))) {
      exerciseTimeInput.style.borderColor = "red";
      exerciseTimeInput.style.color = "red";
      isValid = false;
    } else {
      exerciseTimeInput.style.borderColor = "";
      exerciseTimeInput.style.color = "";
    }

    // 休憩時間チェック
    if (parseInt(restTimeInput.value, 10) < 5 || isNaN(parseInt(restTimeInput.value, 10))) {
      restTimeInput.style.borderColor = "red";
      restTimeInput.style.color = "red";
      isValid = false;
    } else {
      restTimeInput.style.borderColor = "";
      restTimeInput.style.color = "";
    }

    // サイクル数チェック
    if (parseInt(document.getElementById('num-cycles').value, 10) < 1 || isNaN(parseInt(document.getElementById('num-cycles').value, 10))) {
      document.getElementById('num-cycles').style.borderColor = "red";
      document.getElementById('num-cycles').style.color = "red";
      isValid = false;
    } else {
      document.getElementById('num-cycles').style.borderColor = "";
      document.getElementById('num-cycles').style.color = "";
    }

    // 入力が有効な場合のみタイマーを設定
    if (isValid) {
      exerciseTimeDefault = Math.max(parseInt(exerciseTimeInput.value, 10), 5);
      restTimeDefault = Math.max(parseInt(restTimeInput.value, 10), 5);
      totalCycles = Math.max(parseInt(document.getElementById('num-cycles').value, 10), 1);

      resetTimer();
      updateTimerDisplay(exerciseTimeDefault);
      console.log('Timer set:', exerciseTimeDefault, restTimeDefault, totalCycles);
    } else {
      alert("全ての値が規定範囲内であることを確認してください。\n- 運動時間: 最低5秒\n- 休憩時間: 最低5秒\n- サイクル数: 最低1回");
    }
  });

  // タイマー表示を更新する関数
  function updateTimerDisplay(timeInSeconds) {
    const minutes = String(Math.floor(timeInSeconds / 60)).padStart(2, '0');
    const seconds = String(timeInSeconds % 60).padStart(2, '0');
    timerDisplay.textContent = `${minutes}:${seconds}`;
  }

  // フェーズ表示を更新する関数
  function updatePhaseDisplay() {
    if (isExercisePhase) {
      phaseDisplay.textContent = `運動中 (${currentCycle}/${totalCycles})`;
      phaseDisplay.style.color = '#4CAF50'; // 緑色
    } else {
      phaseDisplay.textContent = '休憩中';
      phaseDisplay.style.color = '#FF9800'; // オレンジ色
    }
  }

  // フェーズを切り替える関数
  function switchPhase() {
    playEndBeep(); // フェーズ終了時にエンドビープを再生

    if (isExercisePhase) {
      isExercisePhase = false;
      remainingTime = restTimeDefault; // 休憩セッション
    } else {
      isExercisePhase = true;
      currentCycle++;
      if (currentCycle > totalCycles) {
        clearInterval(timerInterval);
        timerInterval = null;
        alert('トレーニング終了です！');
        phaseDisplay.textContent = '終了';
        timerDisplay.textContent = '00:00';
        return;
      }
      remainingTime = exerciseTimeDefault; // 運動セッション
    }
    beepPlayed = false; // フェーズが切り替わったのでビープフラグをリセット
    updatePhaseDisplay();
    updateTimerDisplay(remainingTime);
  }

  let audioCtx = null; // AudioContextをグローバルで保持

  // AudioContextを初期化または再開
  function initializeAudioContext() {
    if (!audioCtx) {
      audioCtx = new (window.AudioContext || window.webkitAudioContext)();
    }
    if (audioCtx.state === 'suspended') {
      audioCtx.resume(); // AudioContextが停止状態の場合に再開
    }
  }

  function playTone(frequency, duration, type = 'sine', delay = 0) {
    initializeAudioContext(); // AudioContextを初期化または再開
    const oscillator = audioCtx.createOscillator();
    const gainNode = audioCtx.createGain();

    oscillator.type = type; // 波形の種類を指定
    oscillator.frequency.setValueAtTime(frequency, audioCtx.currentTime + delay / 1000); // 周波数設定
    gainNode.gain.setValueAtTime(0.15, audioCtx.currentTime);
    gainNode.gain.linearRampToValueAtTime(0, audioCtx.currentTime + delay / 1000 + duration / 1000); // 音量をゼロに戻す

    oscillator.connect(gainNode);
    gainNode.connect(audioCtx.destination);

    oscillator.start(audioCtx.currentTime + delay / 1000);
    oscillator.stop(audioCtx.currentTime + delay / 1000 + duration / 1000);
  }

  function playCountdownBeeps() {
    const frequencies = [500, 500, 500, 500, 500]; // 周波数
    const duration = 700; // 各音の長さ（ms）
    const interval = 1000; // 音の間隔（ms）

    frequencies.forEach((freq, index) => {
      setTimeout(() => playTone(freq, duration), index * interval);
    });
  }

  function playEndBeep() {
    const frequency = 600; // エンドビープの周波数
    const duration = 1600; // エンドビープの長さ（ms）
    playTone(frequency, duration, 'sine');
  }

  // タイマーを開始する関数
  function startTimer() {
    if (timerInterval) return; // 既に動いていたら何もしない

    timerInterval = setInterval(() => {
      if (remainingTime > 0) {
        remainingTime--;
        updateTimerDisplay(remainingTime);

        // 残り5秒になったらカウントダウンビープを再生
        if (remainingTime === 4 && !beepPlayed) {
          playCountdownBeeps();
          beepPlayed = true; // ビープ音が再生されたことを記録
        }
      } else {
        switchPhase();
      }
    }, 1000);
  }

  // タイマーを停止する関数
  function stopTimer() {
    if (timerInterval) {
      clearInterval(timerInterval);
      timerInterval = null;
    }
  }

  // タイマーをリセットする関数
  function resetTimer() {
    stopTimer();
    isExercisePhase = true;
    remainingTime = exerciseTimeDefault;
    currentCycle = 1;
    beepPlayed = false;
    updatePhaseDisplay();
    updateTimerDisplay(remainingTime);
  }

  // ボタンクリック時に初期化と再生を行う
  startBtn.addEventListener('click', () => {
    initializeAudioContext();
    startTimer();
  });
  stopBtn.addEventListener('click', stopTimer);
  resetBtn.addEventListener('click', resetTimer);

  // 初期表示更新
  updatePhaseDisplay();
  updateTimerDisplay(remainingTime);
</script>

```

</details>


# 製作中に直面した課題と解決策

## 1. AIの出力内容のパース

AIの応答をJSON形式にするようにプロンプトを設定し、出力内容をパースすることで、AIの出力内容を整形しました。ただ、場合によってはうまく行かない可能性があるためエラーハンドリングを行いました。

<details><summary>詳細なコード</summary>

```html

<div style="text-align:center; margin: 20px 0;">

  <!-- AIメニュー決定ボタン -->
  <div style="font-size: 0.8rem; color: #666; margin-bottom: 10px;">
    ※外部のAIサービスを利用するため、送信するデータには注意してください。個人情報や機密情報を含めないようにしてください。
  </div>
  <div style="font-size: 0.8rem; color: #666; margin-top: 10px;">
    ※また、下記の内容はAIが考案したものであり、実際の効果や適切さを保証するものではありません。無理のない範囲で行ってください。
  </div>
  <br/>
  <div style="margin-bottom: 10px;">
    <input id="target-area" type="text" style="padding: 5px; font-size: 1rem; width: 300px;" placeholder="例: 腹筋（未入力の場合は「全体」）">
  </div>

  <button id="ai-menu-btn" style="padding: 10px 20px; font-size: 1rem; cursor: pointer;">
    AIにメニューを決めてもらう
  </button>
</div>

  <!-- AIが考案したメニューを表示するエリア -->
  <div id="ai-menu-result" style="margin-top: 20px; font-size: 1rem; line-height: 1.5;"></div>
</div>

<script>
  const aiMenuBtn = document.getElementById("ai-menu-btn");
  const aiMenuResult = document.getElementById("ai-menu-result");
  aiMenuBtn.addEventListener("click", async () => {
    aiMenuResult.textContent = "メニューを考案中...";
    const targetArea = document.getElementById("target-area").value || "全体";

    try {
      const serverAi = new ServerAI();
      const prompt = `
        10分で行える自重トレーニングメニューを提案してください。鍛えたい箇所は「${targetArea}」です。以下のJSON形式で出力してください。

        {
          "menu": [
            {
              "name": "エクササイズ名",
              "how_to": "エクササイズのやり方",
              "where": "主に鍛えられる部位。どこを意識すると効果的かなど",
            }
          ],
          "notes": "筋肉トレーニングに関する豆知識。（少しだけ、このサイトを訪れた人がニコッとするようなコメントを入れてみましょう）"
        }

        **必ず上記の形式で返答してください。他のテキストや説明は不要です。JSON形式以外のデータは返さないでください。\`\`\`jsonなどの冒頭も不要です**
      `;

      const answer = await serverAi.getAnswerText(
        "8Y89Wq9NfrRoCZ78p3KPHV",
        "",
        prompt
      );

      try {
        // JSON形式としてパースを試みる
        const parsed = JSON.parse(answer);

        if (parsed.menu && Array.isArray(parsed.menu)) {
          // メニューをテーブル形式で表示
          aiMenuResult.innerHTML = `
            <h3 style="text-align: center;">自重トレーニングメニュー　（${targetArea}）</h3>
            <table style="width: 100%; border-collapse: collapse; margin: 20px 0;">
              <thead>
                <tr style="background-color: #f4f4f4;">
                  <th style="border: 1px solid #ccc; padding: 8px; text-align: left;">エクササイズ名</th>
                  <th style="border: 1px solid #ccc; padding: 8px; text-align: left;">説明</th>
                  <th style="border: 1px solid #ccc; padding: 8px; text-align: left;">鍛えられる部位</th>
                  <th style="border: 1px solid #ccc; padding: 8px; text-align: left;">参考動画（YouTube）</th>
                </tr>
              </thead>
              <tbody>
                ${parsed.menu
                  .map(
                    (item) => `
                      <tr>
                        <td style="border: 1px solid #ccc; padding: 8px;">${item.name}</td>
                        <td style="border: 1px solid #ccc; padding: 8px;">${item.how_to}</td>
                        <td style="border: 1px solid #ccc; padding: 8px;">${item.where}</td>
                        <td style="border: 1px solid #ccc; padding: 8px;"><a href="https://www.youtube.com/results?search_query=筋肉トレーニング+${item.name}" target="_blank">動画を見る</a></td>
                      </tr>
                    `
                  )
                  .join("")}
              </tbody>
            </table>
            <div style="margin-top: 10px; font-size: 0.9rem; color: #555;">
              <strong>筋トレ豆知識:</strong> ${parsed.notes || "今日も一日頑張りましょう！"}
            </div>
          `;
        } else {
          throw new Error("Unexpected data structure");
        }
      } catch (parseError) {
        console.warn("Failed to parse AI response as JSON:", parseError);
        aiMenuResult.textContent = "整形された形でメニューを取得できませんでした。\n" + answer;
      }
    } catch (error) {
      console.error("Error fetching AI menu:", error);
      aiMenuResult.textContent = "エラーが発生しました。もう一度お試しください。";
    }
  });
</script>

```

</details>


## 2. 快適な音声機能の実装

[AudioContext](https://developer.mozilla.org/ja/docs/Web/API/AudioContext)を使うことで、外部の音声ファイルを使わずに音声機能を実装しました。ただ、調節をなんどか行い妥協点ではあると思いますが、聞き心地はとてもよいものにはなりませんでした。Markdown AIは音声ファイルの埋め込みは、私の調べた限りでは2024/12/28現在はサポートされていないため、この方法で実装しました。

## 3. Markdown AIではテストが難しい

Markdown AIはローカル環境でのテストが難しいため、実際にWebサイトに埋め込んで動作確認を行いました。その際、コンソールログを出力することで、デバッグを行いました。

# まとめと感想

Markdown AIを使って自重トレーニングメニューを提案するサービスを開発しました。

少し開発に苦労した部分もありましたが、生成AIとインタラクティブにやり取りができるサービスをここまで自由度が高く、かつ簡単に作れることに驚きました。今後もMarkdown AIを使って、さまざまなサービスを開発していきたいと思います。

# 謝辞

本記事のアイデアは大学時代のサークルの友人のアイデアを元にしています。

ありがとうございました。