文字起こしのAPI料金を劇的カット!ブラウザ完結の無料音声倍速ツール「AudioShift」

このコンテンツは生成AIによって作成されています。

こんにちは!今回は、音声データを扱うすべての方、特に「文字起こしAI(API)のコストを少しでも抑えたい」とお考えのエンジニアやクリエイターの皆様に向けて、非常に便利な無料ツール「AudioShift」をご紹介します。

AudioShiftは、ブラウザ上だけで音声ファイルの再生速度を自由に変更し、保存できるWebアプリケーションです。

「なぜわざわざ音声を倍速にするツールを作ったのか?」——そこには、現代のAI活用における切実な「コスト問題」を解決するためのハックがありました。本記事では、このツールの魅力と、それを支えるフロントエンド技術について詳しく解説します。

開発のきっかけ:文字起こしAPIの「従量課金」をどう出し抜くか?

OpenAIの「Whisper」をはじめとする高精度な音声認識(文字起こし)APIは、議事録の自動作成や動画の字幕付けなど、今や欠かせない技術です。しかし、多くのAPIは「処理した音声ファイルの再生時間」によって料金が変動する従量課金制を採用しています。

つまり、1時間の会議音声をそのままAPIに投げれば、1時間分の料金が発生します。日常的に大量の音声を処理するサービスや業務では、このコストが馬鹿になりません。

そこで目をつけたのが「音声をあらかじめ倍速にしてからAPIに投げる」というアプローチです。

実は、現在の優秀な音声認識AIは、2〜3倍速程度に早回しされた音声であっても、文字起こしの精度にほとんど影響が出ないという特性があります(参考:GIGAZINE)。 つまり、1時間の音声を2倍速にして30分のファイルに圧縮してからAPIに送信すれば、精度を保ったままAPI料金を半額に抑えることができるのです。3倍速にすればコストはなんと3分の1になります。

もちろん、音声の無音部分や不要な雑談部分などを事前にトリミングしてカットしておくこともコスト削減には有用です。そうしたトリミング処理とこの「倍速化」を組み合わせることで、不要なAPI呼び出しを極限まで減らし、より強力なコスト削減効果を生み出すことができます

この「コスト削減ハック」を誰でも手軽に、かつ安全に実行できるようにするために開発されたのが、今回ご紹介する「AudioShift」です。

AudioShiftの3つの大きな魅力

AudioShiftは、単に音声を倍速にするだけでなく、実務で使いやすいように設計されています。

1. サーバー不要!完全オフライン処理で高いセキュリティ

会議の録音や未公開のインタビュー音源など、文字起こしが必要なデータは機密性が高いものがほとんどです。 AudioShiftは、すべての処理をクライアントサイド(お使いのブラウザ内)で完結させています。音声データが外部サーバーにアップロードされることは一切ないため、情報漏洩のリスクがなく、セキュアな環境で安心してご利用いただけます。

2. 直感的なUIと柔軟な速度調整

ダークモードを基調としたモダンで美しいインターフェースを採用。ファイルのドラッグ&ドロップに対応しており、操作に迷うことはありません。 変換速度はスライダーを使って0.25倍から4.00倍まで細かく設定可能。よく使う「1.5倍」や「2.0倍」といったプリセットボタンも用意されています。

3. 多様なフォーマット対応とMP3/WAV出力

入力はMP3, WAV, OGG, M4A, FLACなど主要なフォーマットに対応しています。 出力に関しては、劣化のない高品質な「WAV」と、ファイルサイズを抑えられる「MP3」を選択可能。MP3の場合は、96kbpsから320kbpsまでビットレートの指定もできるため、APIの制限(ファイルサイズ上限など)に合わせた柔軟な書き出しが可能です。

開発者向け:AudioShiftを支える技術的ポイント

エンジニアの方向けに、このツールがどのような技術スタックで構成されているのか、少しだけ裏側をご紹介します。

驚くべきことに、このツールはReactやVue.jsといったモダンなフレームワークや、バックエンドのNode.js / Python等を一切使用していません。 index.html 1ファイルにHTML/CSS/Vanilla JSが記述された、非常にミニマルな構成です。

Web Audio API (OfflineAudioContext) の活用

ブラウザ上での音声処理のコアとなっているのが、標準実装されている Web Audio API です。 通常の AudioContext ではなく OfflineAudioContext を使用することで、リアルタイム再生ではなく、バックグラウンドでの高速なレンダリング(リサンプリングと速度変更)を実現しています。

// 実際のコードの抜粋イメージ
const offlineCtx = new OfflineAudioContext(numChannels, outLength, sampleRate);
const source = offlineCtx.createBufferSource();
source.buffer = originalBuffer;
source.playbackRate.value = speed; // ここで速度を変更
source.connect(offlineCtx.destination);
source.start(0);
const renderedBuffer = await offlineCtx.startRendering();

クライアントサイドでのMP3エンコード

ブラウザの標準機能だけでは、処理した AudioBuffer をWAVとして書き出すことは比較的容易(バイナリデータを手動で組み立てる)ですが、MP3へのエンコードはサポートされていません。 そこで、純粋なJavaScriptで実装されたMP3エンコーダーである lamejs をCDN経由で読み込み、ブラウザ内で高速にMP3変換を行っています。

まとめ:AudioShiftでスマートな文字起こしライフを!

AudioShift は、「文字起こしAPIのコストを下げたい」という明確な課題解決のために生まれ、それを「ブラウザ完結(Web Audio API)」というアプローチで安全かつスマートに実現したツールです。

議事録作成のコストに悩んでいる方、開発中のサービスでWhisper APIの料金を最適化したいエンジニアの方は、ぜひ一度このツールを利用して「倍速文字起こし」の威力を体験してみてください。

ブラウザでファイルを開くだけで、あなたの業務効率とコストパフォーマンスが劇的に向上するはずです!

完成版のコードはこちら

<!DOCTYPE html>
<html lang="ja">
<head>
  <meta charset="UTF-8" />
  <meta name="viewport" content="width=device-width, initial-scale=1.0" />
  <title>AudioShift - 音声速度変換アプリ</title>
  <meta name="description" content="音声ファイルをブラウザ上で任意の速度に変換してダウンロードできる無料ツールです。MP3・WAVのフォーマットに対応。" />
  <link rel="preconnect" href="https://fonts.googleapis.com" />
  <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
  <link href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&display=swap" rel="stylesheet" />
  <!-- MP3エンコードライブラリ (lamejs) -->
  <script src="https://cdnjs.cloudflare.com/ajax/libs/lamejs/1.2.1/lame.min.js"></script>
  <style>
    /* ===========================
       CSS カスタムプロパティ (デザイントークン)
       =========================== */
    :root {
      --bg-primary:      #0d0d14;
      --bg-secondary:    #13131f;
      --bg-card:         rgba(255, 255, 255, 0.04);
      --bg-card-hover:   rgba(255, 255, 255, 0.07);
      --border:          rgba(255, 255, 255, 0.08);
      --border-active:   rgba(139, 92, 246, 0.5);

      --accent-1:        #8b5cf6; /* バイオレット */
      --accent-2:        #6366f1; /* インディゴ */
      --accent-3:        #ec4899; /* ピンク */
      --gradient-main:   linear-gradient(135deg, var(--accent-1), var(--accent-2));
      --gradient-hot:    linear-gradient(135deg, var(--accent-1), var(--accent-3));

      --text-primary:    #f0f0f8;
      --text-secondary:  #9090b8;
      --text-muted:      #5050778;

      --success:         #34d399;
      --warning:         #fbbf24;
      --danger:          #f87171;

      --radius-sm:       8px;
      --radius-md:       14px;
      --radius-lg:       20px;
      --radius-xl:       28px;

      --shadow-glow:     0 0 40px rgba(139, 92, 246, 0.2);
      --shadow-card:     0 4px 24px rgba(0, 0, 0, 0.4);
      --transition:      0.25s cubic-bezier(0.4, 0, 0.2, 1);
    }

    /* ===========================
       リセット & ベース
       =========================== */
    *, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }

    body {
      font-family: 'Inter', sans-serif;
      background-color: var(--bg-primary);
      color: var(--text-primary);
      min-height: 100vh;
      display: flex;
      flex-direction: column;
      align-items: center;
      padding: 24px 16px 60px;
      overflow-x: hidden;
    }

    /* 背景グラデーション装飾 */
    body::before {
      content: '';
      position: fixed;
      top: -200px;
      left: 50%;
      transform: translateX(-50%);
      width: 800px;
      height: 800px;
      background: radial-gradient(circle, rgba(139,92,246,0.12) 0%, transparent 70%);
      pointer-events: none;
      z-index: 0;
    }

    /* ===========================
       ヘッダー
       =========================== */
    header {
      width: 100%;
      max-width: 740px;
      text-align: center;
      margin-bottom: 48px;
      position: relative;
      z-index: 1;
    }

    .logo {
      display: inline-flex;
      align-items: center;
      gap: 12px;
      margin-bottom: 16px;
    }

    .logo-icon {
      width: 48px;
      height: 48px;
      border-radius: var(--radius-md);
      background: var(--gradient-main);
      display: flex;
      align-items: center;
      justify-content: center;
      font-size: 22px;
      box-shadow: var(--shadow-glow);
    }

    .logo-text {
      font-size: 28px;
      font-weight: 700;
      background: var(--gradient-main);
      -webkit-background-clip: text;
      -webkit-text-fill-color: transparent;
      background-clip: text;
    }

    .tagline {
      font-size: 15px;
      color: var(--text-secondary);
      line-height: 1.6;
    }

    /* ===========================
       カードコンポーネント
       =========================== */
    .card {
      width: 100%;
      max-width: 740px;
      background: var(--bg-card);
      backdrop-filter: blur(16px);
      -webkit-backdrop-filter: blur(16px);
      border: 1px solid var(--border);
      border-radius: var(--radius-xl);
      padding: 32px;
      margin-bottom: 20px;
      box-shadow: var(--shadow-card);
      position: relative;
      z-index: 1;
      transition: border-color var(--transition);
    }

    .card:hover { border-color: rgba(139, 92, 246, 0.2); }

    .card-title {
      font-size: 13px;
      font-weight: 600;
      color: var(--text-secondary);
      text-transform: uppercase;
      letter-spacing: 0.08em;
      margin-bottom: 20px;
      display: flex;
      align-items: center;
      gap: 8px;
    }

    .card-title::before {
      content: '';
      display: block;
      width: 3px;
      height: 14px;
      border-radius: 2px;
      background: var(--gradient-main);
    }

    /* ===========================
       ドロップゾーン
       =========================== */
    #drop-zone {
      width: 100%;
      border: 2px dashed var(--border);
      border-radius: var(--radius-lg);
      padding: 48px 24px;
      text-align: center;
      cursor: pointer;
      transition: all var(--transition);
      position: relative;
    }

    #drop-zone:hover,
    #drop-zone.drag-over {
      border-color: var(--accent-1);
      background: rgba(139, 92, 246, 0.06);
    }

    #drop-zone.has-file {
      border-style: solid;
      border-color: var(--accent-1);
      background: rgba(139, 92, 246, 0.04);
      padding: 28px 24px;
    }

    .drop-icon {
      font-size: 48px;
      margin-bottom: 16px;
      display: block;
      transition: transform var(--transition);
    }

    #drop-zone:hover .drop-icon { transform: translateY(-4px); }

    .drop-label {
      font-size: 16px;
      font-weight: 500;
      color: var(--text-primary);
      margin-bottom: 8px;
    }

    .drop-sublabel {
      font-size: 13px;
      color: var(--text-secondary);
    }

    .drop-sublabel span {
      color: var(--accent-1);
      text-decoration: underline;
      cursor: pointer;
    }

    /* ファイル情報表示 */
    #file-info {
      display: none;
      align-items: center;
      gap: 16px;
      text-align: left;
    }

    #file-info.visible { display: flex; }

    .file-icon-wrap {
      width: 52px;
      height: 52px;
      border-radius: var(--radius-md);
      background: var(--gradient-main);
      display: flex;
      align-items: center;
      justify-content: center;
      font-size: 24px;
      flex-shrink: 0;
    }

    .file-meta { flex: 1; min-width: 0; }

    #file-name {
      font-size: 15px;
      font-weight: 600;
      color: var(--text-primary);
      white-space: nowrap;
      overflow: hidden;
      text-overflow: ellipsis;
    }

    #file-detail {
      font-size: 13px;
      color: var(--text-secondary);
      margin-top: 4px;
    }

    .btn-clear {
      width: 32px;
      height: 32px;
      border-radius: 50%;
      border: 1px solid var(--border);
      background: transparent;
      color: var(--text-secondary);
      cursor: pointer;
      display: flex;
      align-items: center;
      justify-content: center;
      font-size: 16px;
      transition: all var(--transition);
      flex-shrink: 0;
    }

    .btn-clear:hover {
      background: rgba(248, 113, 113, 0.15);
      border-color: var(--danger);
      color: var(--danger);
    }

    /* 隠しファイル入力 */
    #file-input { display: none; }

    /* ===========================
       速度コントロール
       =========================== */
    .speed-section { opacity: 0.3; pointer-events: none; transition: opacity var(--transition); }
    .speed-section.active { opacity: 1; pointer-events: all; }

    .speed-display {
      display: flex;
      align-items: baseline;
      justify-content: center;
      gap: 4px;
      margin-bottom: 28px;
    }

    #speed-value {
      font-size: 72px;
      font-weight: 700;
      line-height: 1;
      background: var(--gradient-main);
      -webkit-background-clip: text;
      -webkit-text-fill-color: transparent;
      background-clip: text;
      transition: all 0.1s;
      min-width: 180px;
      text-align: center;
    }

    .speed-unit {
      font-size: 28px;
      font-weight: 500;
      color: var(--text-secondary);
    }

    /* スライダー */
    .slider-wrap { position: relative; padding: 0 4px; }

    #speed-slider {
      -webkit-appearance: none;
      width: 100%;
      height: 6px;
      border-radius: 3px;
      outline: none;
      cursor: pointer;
      background: linear-gradient(
        to right,
        var(--accent-1) 0%,
        var(--accent-1) var(--fill, 0%),
        rgba(255,255,255,0.1) var(--fill, 0%),
        rgba(255,255,255,0.1) 100%
      );
      transition: background 0.05s;
    }

    #speed-slider::-webkit-slider-thumb {
      -webkit-appearance: none;
      width: 22px;
      height: 22px;
      border-radius: 50%;
      background: var(--accent-1);
      box-shadow: 0 0 0 4px rgba(139,92,246,0.25), 0 2px 8px rgba(0,0,0,0.5);
      transition: transform var(--transition), box-shadow var(--transition);
    }

    #speed-slider::-webkit-slider-thumb:hover { transform: scale(1.15); }
    #speed-slider:active::-webkit-slider-thumb {
      transform: scale(1.2);
      box-shadow: 0 0 0 6px rgba(139,92,246,0.3), 0 2px 8px rgba(0,0,0,0.5);
    }

    #speed-slider::-moz-range-thumb {
      width: 22px;
      height: 22px;
      border-radius: 50%;
      border: none;
      background: var(--accent-1);
      box-shadow: 0 0 0 4px rgba(139,92,246,0.25);
      cursor: pointer;
    }

    /* 目盛りラベル */
    .slider-ticks {
      display: flex;
      justify-content: space-between;
      margin-top: 12px;
      padding: 0 2px;
    }

    .slider-tick {
      font-size: 11px;
      color: var(--text-secondary);
      text-align: center;
      cursor: pointer;
      transition: color var(--transition);
    }

    .slider-tick:hover { color: var(--accent-1); }
    .slider-tick.current { color: var(--accent-1); font-weight: 600; }

    /* プリセットボタン */
    .preset-grid {
      display: flex;
      flex-wrap: wrap;
      gap: 8px;
      margin-top: 20px;
    }

    .btn-preset {
      padding: 7px 16px;
      border-radius: var(--radius-sm);
      border: 1px solid var(--border);
      background: transparent;
      color: var(--text-secondary);
      font-size: 13px;
      font-weight: 500;
      font-family: 'Inter', sans-serif;
      cursor: pointer;
      transition: all var(--transition);
    }

    .btn-preset:hover,
    .btn-preset.active {
      border-color: var(--accent-1);
      color: var(--accent-1);
      background: rgba(139,92,246,0.1);
    }

    /* ===========================
       オーディオプレイヤー
       =========================== */
    .player-row {
      display: flex;
      flex-direction: column;
      gap: 12px;
    }

    .player-item {
      display: flex;
      align-items: center;
      gap: 12px;
      background: rgba(255,255,255,0.03);
      border: 1px solid var(--border);
      border-radius: var(--radius-md);
      padding: 14px 16px;
      transition: border-color var(--transition);
    }

    .player-item:hover { border-color: rgba(139,92,246,0.3); }

    .player-label {
      font-size: 12px;
      font-weight: 600;
      color: var(--text-secondary);
      text-transform: uppercase;
      letter-spacing: 0.05em;
      min-width: 56px;
      flex-shrink: 0;
    }

    .player-label.converted { color: var(--accent-1); }

    audio {
      flex: 1;
      height: 32px;
      border-radius: var(--radius-sm);
      min-width: 0;
    }

    /* Chromeのaudioスタイル調整 */
    audio::-webkit-media-controls-panel {
      background: rgba(255,255,255,0.05);
    }

    /* ===========================
       フォーマット選択
       =========================== */
    .format-row {
      display: flex;
      gap: 8px;
      margin-bottom: 20px;
      flex-wrap: wrap;
      align-items: center;
    }

    .format-label {
      font-size: 13px;
      color: var(--text-secondary);
      font-weight: 500;
      margin-right: 4px;
    }

    .btn-format {
      padding: 6px 18px;
      border-radius: 100px;
      border: 1px solid var(--border);
      background: transparent;
      color: var(--text-secondary);
      font-size: 13px;
      font-weight: 600;
      font-family: 'Inter', sans-serif;
      cursor: pointer;
      transition: all var(--transition);
    }

    .btn-format.active {
      background: var(--gradient-main);
      border-color: transparent;
      color: #fff;
      box-shadow: 0 2px 12px rgba(139,92,246,0.35);
    }

    .btn-format:not(.active):hover {
      border-color: var(--accent-1);
      color: var(--accent-1);
    }

    /* ビットレート選択セレクト */
    .bitrate-select {
      padding: 6px 12px;
      border-radius: 100px;
      border: 1px solid var(--border);
      background: rgba(255,255,255,0.04);
      color: var(--text-primary);
      font-size: 13px;
      font-family: 'Inter', sans-serif;
      cursor: pointer;
      outline: none;
      transition: all var(--transition);
      display: none; /* MP3選択時のみ表示 */
    }

    .bitrate-select.visible { display: block; }
    .bitrate-select:hover { border-color: var(--accent-1); }
    .bitrate-select option { background: #1a1a2e; }

    /* ===========================
       変換・ダウンロードボタン
       =========================== */
    .action-row {
      display: flex;
      gap: 12px;
      flex-wrap: wrap;
    }

    .btn {
      display: inline-flex;
      align-items: center;
      justify-content: center;
      gap: 8px;
      padding: 14px 28px;
      border-radius: var(--radius-md);
      border: none;
      font-family: 'Inter', sans-serif;
      font-size: 15px;
      font-weight: 600;
      cursor: pointer;
      transition: all var(--transition);
      position: relative;
      overflow: hidden;
    }

    .btn:disabled {
      opacity: 0.4;
      cursor: not-allowed;
    }

    .btn-primary {
      background: var(--gradient-main);
      color: #fff;
      flex: 1;
      box-shadow: 0 4px 20px rgba(139,92,246,0.35);
    }

    .btn-primary:not(:disabled):hover {
      transform: translateY(-2px);
      box-shadow: 0 8px 30px rgba(139,92,246,0.5);
    }

    .btn-primary:not(:disabled):active { transform: translateY(0); }

    .btn-secondary {
      background: rgba(255,255,255,0.06);
      color: var(--text-primary);
      border: 1px solid var(--border);
      flex: 1;
    }

    .btn-secondary:not(:disabled):hover {
      background: rgba(255,255,255,0.1);
      border-color: rgba(255,255,255,0.2);
      transform: translateY(-2px);
    }

    /* ===========================
       プログレスバー
       =========================== */
    #progress-wrap {
      display: none;
      flex-direction: column;
      gap: 10px;
    }

    #progress-wrap.visible { display: flex; }

    .progress-label {
      font-size: 13px;
      color: var(--text-secondary);
      display: flex;
      justify-content: space-between;
    }

    .progress-bar-bg {
      width: 100%;
      height: 6px;
      border-radius: 3px;
      background: rgba(255,255,255,0.08);
      overflow: hidden;
    }

    #progress-bar {
      height: 100%;
      border-radius: 3px;
      background: var(--gradient-main);
      width: 0%;
      transition: width 0.3s ease;
    }

    /* ===========================
       ステータスバッジ
       =========================== */
    .status-badge {
      display: inline-flex;
      align-items: center;
      gap: 6px;
      padding: 4px 12px;
      border-radius: 100px;
      font-size: 12px;
      font-weight: 600;
    }

    .status-badge.success {
      background: rgba(52,211,153,0.12);
      color: var(--success);
      border: 1px solid rgba(52,211,153,0.25);
    }

    .status-badge.error {
      background: rgba(248,113,113,0.12);
      color: var(--danger);
      border: 1px solid rgba(248,113,113,0.25);
    }

    .dot {
      width: 6px;
      height: 6px;
      border-radius: 50%;
      background: currentColor;
    }

    /* ===========================
       フッター
       =========================== */
    footer {
      margin-top: 40px;
      text-align: center;
      font-size: 12px;
      color: var(--text-secondary);
      position: relative;
      z-index: 1;
      opacity: 0.6;
    }

    /* ===========================
       スピナーアニメーション
       =========================== */
    @keyframes spin {
      to { transform: rotate(360deg); }
    }

    .spinner {
      width: 18px;
      height: 18px;
      border: 2px solid rgba(255,255,255,0.3);
      border-top-color: #fff;
      border-radius: 50%;
      animation: spin 0.7s linear infinite;
    }

    /* ===========================
       フェードインアニメーション
       =========================== */
    @keyframes fadeUp {
      from { opacity: 0; transform: translateY(16px); }
      to   { opacity: 1; transform: translateY(0); }
    }

    .card { animation: fadeUp 0.4s ease both; }
    .card:nth-child(2) { animation-delay: 0.05s; }
    .card:nth-child(3) { animation-delay: 0.10s; }
    .card:nth-child(4) { animation-delay: 0.15s; }

    /* ===========================
       レスポンシブ
       =========================== */
    @media (max-width: 520px) {
      .card { padding: 24px 18px; }
      #speed-value { font-size: 52px; }
      .action-row { flex-direction: column; }
    }
  </style>
</head>
<body>

  <!-- ヘッダー -->
  <header>
    <div class="logo">
      <div class="logo-icon">🎵</div>
      <span class="logo-text">AudioShift</span>
    </div>
    <p class="tagline">
      音声ファイルをブラウザだけで速度変換。サーバー不要・無料・プライバシー安全。
    </p>
  </header>

  <!-- ① ファイルアップロード -->
  <section class="card" aria-label="ファイルアップロード">
    <div class="card-title">STEP 1  ファイルを選択</div>
    <div id="drop-zone" role="button" tabindex="0" aria-label="音声ファイルをドロップまたはクリックして選択">
      <!-- ドロップ前の表示 -->
      <div id="drop-placeholder">
        <span class="drop-icon">🎧</span>
        <p class="drop-label">ここに音声ファイルをドロップ</p>
        <p class="drop-sublabel">または <span id="browse-link">クリックして選択</span></p>
        <p class="drop-sublabel" style="margin-top:8px;">MP3 / WAV / OGG / M4A / FLAC / WEBM など対応</p>
      </div>
      <!-- ファイル選択後の表示 -->
      <div id="file-info" role="status">
        <div class="file-icon-wrap">🎵</div>
        <div class="file-meta">
          <div id="file-name">---</div>
          <div id="file-detail">---</div>
        </div>
        <button class="btn-clear" id="btn-clear" title="ファイルをクリア" aria-label="ファイルをクリア">✕</button>
      </div>
    </div>
    <input type="file" id="file-input" accept="audio/*" aria-label="音声ファイル選択" />
  </section>

  <!-- ② 速度設定 -->
  <section class="card">
    <div class="card-title">STEP 2  変換速度を設定</div>
    <div class="speed-section" id="speed-section">
      <div class="speed-display">
        <span id="speed-value">1.00</span>
        <span class="speed-unit">×</span>
      </div>
      <div class="slider-wrap">
        <input
          type="range"
          id="speed-slider"
          min="0.25"
          max="4.00"
          step="0.05"
          value="1.00"
          aria-label="再生速度スライダー"
        />
        <div class="slider-ticks" id="slider-ticks"></div>
      </div>
      <div class="preset-grid" id="preset-grid"></div>
    </div>
  </section>

  <!-- ③ 変換・ダウンロード -->
  <section class="card">
    <div class="card-title">STEP 3  変換してダウンロード</div>

    <!-- プレビュープレイヤー -->
    <div class="player-row" id="player-row" style="margin-bottom:20px; display:none;">
      <div class="player-item">
        <span class="player-label">元音声</span>
        <audio id="audio-original" controls></audio>
      </div>
      <div class="player-item">
        <span class="player-label converted">変換後</span>
        <audio id="audio-converted" controls></audio>
      </div>
    </div>

    <!-- プログレスバー -->
    <div id="progress-wrap">
      <div class="progress-label">
        <span id="progress-text">変換中...</span>
        <span id="progress-pct">0%</span>
      </div>
      <div class="progress-bar-bg">
        <div id="progress-bar"></div>
      </div>
    </div>

    <!-- フォーマット選択 -->
    <div class="format-row">
      <span class="format-label">形式:</span>
      <button class="btn-format active" id="fmt-wav" data-format="wav">WAV</button>
      <button class="btn-format" id="fmt-mp3" data-format="mp3">MP3</button>
      <select class="bitrate-select" id="bitrate-select" title="MP3 ビットレート">
        <option value="320">320 kbps</option>
        <option value="192" selected>192 kbps</option>
        <option value="128">128 kbps</option>
        <option value="96">96 kbps</option>
      </select>
    </div>

    <!-- アクションボタン -->
    <div class="action-row" id="action-row">
      <button class="btn btn-primary" id="btn-convert" disabled>
        ⚡ 変換する
      </button>
      <button class="btn btn-secondary" id="btn-download" disabled>
        ⬇ ダウンロード
      </button>
    </div>

    <!-- ステータス -->
    <div id="status-area" style="margin-top:14px;"></div>
  </section>

  <footer>
    <p>音声データはすべてローカルで処理されます。サーバーには送信されません。</p>
  </footer>

  <script>
    /* ===================================================
       AudioShift メインスクリプト
       Web Audio API を使用してブラウザ上で音声速度変換を実行する
       =================================================== */

    // --- DOM 参照 ---
    const dropZone       = document.getElementById('drop-zone');
    const dropPlaceholder= document.getElementById('drop-placeholder');
    const fileInfo       = document.getElementById('file-info');
    const fileInput      = document.getElementById('file-input');
    const browseLink     = document.getElementById('browse-link');
    const btnClear       = document.getElementById('btn-clear');
    const fileNameEl     = document.getElementById('file-name');
    const fileDetailEl   = document.getElementById('file-detail');
    const speedSection   = document.getElementById('speed-section');
    const speedSlider    = document.getElementById('speed-slider');
    const speedValueEl   = document.getElementById('speed-value');
    const sliderTicksEl  = document.getElementById('slider-ticks');
    const presetGrid     = document.getElementById('preset-grid');
    const playerRow      = document.getElementById('player-row');
    const audioOriginal  = document.getElementById('audio-original');
    const audioConverted = document.getElementById('audio-converted');
    const btnConvert     = document.getElementById('btn-convert');
    const btnDownload    = document.getElementById('btn-download');
    const progressWrap   = document.getElementById('progress-wrap');
    const progressBar    = document.getElementById('progress-bar');
    const progressText   = document.getElementById('progress-text');
    const progressPct    = document.getElementById('progress-pct');
    const statusArea     = document.getElementById('status-area');
    const actionRow      = document.getElementById('action-row');
    const bitrateSelect  = document.getElementById('bitrate-select');

    // --- 選択中の出力フォーマット ('wav' または 'mp3') ---
    let outputFormat = 'wav';

    // --- アプリ状態 ---
    let originalBuffer   = null; // デコード済みの元音声 AudioBuffer
    let convertedBlob    = null; // 変換後の WAV Blob
    let originalFile     = null; // アップロード元の File オブジェクト
    let originalObjectURL= null; // 元音声の Object URL
    let convertedURL     = null; // 変換後音声の Object URL

    // --- スピードプリセット定義 ---
    const PRESETS = [
      { label: '0.5×',  value: 0.50 },
      { label: '0.75×', value: 0.75 },
      { label: '1.0×',  value: 1.00 },
      { label: '1.25×', value: 1.25 },
      { label: '1.5×',  value: 1.50 },
      { label: '1.75×', value: 1.75 },
      { label: '2.0×',  value: 2.00 },
      { label: '3.0×',  value: 3.00 },
      { label: '4.0×',  value: 4.00 },
    ];

    // --- スライダー目盛り定義 ---
    const TICKS = [
      { label: '0.25×', value: 0.25 },
      { label: '1×',    value: 1.00 },
      { label: '2×',    value: 2.00 },
      { label: '3×',    value: 3.00 },
      { label: '4×',    value: 4.00 },
    ];

    /* =============================
       初期化
       ============================= */
    function init() {
      buildPresets();
      buildTicks();
      setupDropZone();
      setupSlider();
      setupButtons();
      setupFormatSelector();
    }

    /* =============================
       プリセットボタンを生成する
       ============================= */
    function buildPresets() {
      PRESETS.forEach(preset => {
        const btn = document.createElement('button');
        btn.className = 'btn-preset';
        btn.textContent = preset.label;
        btn.dataset.value = preset.value;
        btn.addEventListener('click', () => {
          speedSlider.value = preset.value;
          onSliderChange();
        });
        presetGrid.appendChild(btn);
      });
    }

    /* =============================
       スライダー目盛りを生成する
       ============================= */
    function buildTicks() {
      TICKS.forEach(tick => {
        const span = document.createElement('span');
        span.className = 'slider-tick';
        span.textContent = tick.label;
        span.addEventListener('click', () => {
          speedSlider.value = tick.value;
          onSliderChange();
        });
        sliderTicksEl.appendChild(span);
      });
    }

    /* =============================
       ドロップゾーン関連イベントを設定する
       ============================= */
    function setupDropZone() {
      // クリックでファイルダイアログを開く
      dropZone.addEventListener('click', (e) => {
        if (e.target !== btnClear) fileInput.click();
      });

      browseLink.addEventListener('click', (e) => {
        e.stopPropagation();
        fileInput.click();
      });

      // キーボードアクセシビリティ
      dropZone.addEventListener('keydown', (e) => {
        if (e.key === 'Enter' || e.key === ' ') fileInput.click();
      });

      // ドラッグ&ドロップ
      dropZone.addEventListener('dragover', (e) => {
        e.preventDefault();
        dropZone.classList.add('drag-over');
      });

      dropZone.addEventListener('dragleave', () => {
        dropZone.classList.remove('drag-over');
      });

      dropZone.addEventListener('drop', (e) => {
        e.preventDefault();
        dropZone.classList.remove('drag-over');
        const file = e.dataTransfer.files[0];
        if (file) handleFile(file);
      });

      // ファイル選択ダイアログ
      fileInput.addEventListener('change', () => {
        if (fileInput.files[0]) handleFile(fileInput.files[0]);
      });

      // クリアボタン
      btnClear.addEventListener('click', (e) => {
        e.stopPropagation();
        clearFile();
      });
    }

    /* =============================
       スライダーイベントを設定する
       ============================= */
    function setupSlider() {
      speedSlider.addEventListener('input', onSliderChange);
      onSliderChange(); // 初期値を反映
    }

    /* スライダー変化時の処理 */
    function onSliderChange() {
      const value = parseFloat(speedSlider.value);
      speedValueEl.textContent = value.toFixed(2);

      // スライダーの塗り部分の割合を計算して CSS 変数にセット
      const min  = parseFloat(speedSlider.min);
      const max  = parseFloat(speedSlider.max);
      const fill = ((value - min) / (max - min)) * 100;
      speedSlider.style.setProperty('--fill', `${fill}%`);

      // プリセットボタンのアクティブ状態を更新
      document.querySelectorAll('.btn-preset').forEach(btn => {
        btn.classList.toggle('active', parseFloat(btn.dataset.value) === value);
      });

      // 変換済みデータを無効化 (速度が変わったため再変換が必要)
      if (convertedBlob) {
        convertedBlob = null;
        btnDownload.disabled = true;
        audioConverted.src = '';
        playerRow.style.display = 'none';
        clearStatus();
      }
    }

    /* =============================
       変換・ダウンロードボタンを設定する
       ============================= */
    function setupButtons() {
      btnConvert.addEventListener('click', convertAudio);
      btnDownload.addEventListener('click', downloadAudio);
    }

    /* =============================
       フォーマット選択ボタンを設定する
       ============================= */
    function setupFormatSelector() {
      document.querySelectorAll('.btn-format').forEach(btn => {
        btn.addEventListener('click', () => {
          outputFormat = btn.dataset.format;

          // アクティブ状態を切り替え
          document.querySelectorAll('.btn-format').forEach(b => b.classList.remove('active'));
          btn.classList.add('active');

          // MP3 選択時のみビットレートセレクトを表示
          bitrateSelect.classList.toggle('visible', outputFormat === 'mp3');

          // フォーマットが変わったら再変換が必要なためリセット
          if (convertedBlob) {
            convertedBlob = null;
            btnDownload.disabled = true;
            audioConverted.src = '';
            clearStatus();
          }
        });
      });
    }

    /* =============================
       ファイルを読み込む
       ============================= */
    function handleFile(file) {
      // 音声ファイルのみ受け付ける
      if (!file.type.startsWith('audio/')) {
        showStatus('音声ファイルを選択してください。', 'error');
        return;
      }

      originalFile = file;
      clearStatus();
      clearConverted();

      // ファイル名と詳細情報を表示
      fileNameEl.textContent = file.name;
      fileDetailEl.textContent = `${formatBytes(file.size)} · ${file.type || '不明'}`;

      // ドロップゾーンの表示を切り替え
      dropPlaceholder.style.display = 'none';
      fileInfo.classList.add('visible');
      dropZone.classList.add('has-file');

      // 元音声プレイヤーにセット
      if (originalObjectURL) URL.revokeObjectURL(originalObjectURL);
      originalObjectURL = URL.createObjectURL(file);
      audioOriginal.src = originalObjectURL;

      // AudioBuffer としてデコード
      const reader = new FileReader();
      reader.onload = async (e) => {
        try {
          const ctx = new AudioContext();
          originalBuffer = await ctx.decodeAudioData(e.target.result);
          await ctx.close();

          // ファイル詳細に再生時間を追加表示
          const dur = formatDuration(originalBuffer.duration);
          fileDetailEl.textContent = `${formatBytes(file.size)} · ${dur} · ${originalBuffer.sampleRate} Hz`;

          // 速度セクションと変換ボタンを有効化
          speedSection.classList.add('active');
          btnConvert.disabled = false;
          playerRow.style.display = 'flex';

        } catch (err) {
          showStatus(`デコード失敗: ${err.message}`, 'error');
          console.error(err);
        }
      };
      reader.readAsArrayBuffer(file);
    }

    /* =============================
       音声を速度変換する (コア処理)
       ============================= */
    async function convertAudio() {
      if (!originalBuffer) return;

      const speed       = parseFloat(speedSlider.value);
      const sampleRate  = originalBuffer.sampleRate;
      const numChannels = originalBuffer.numberOfChannels;

      // 変換後の長さを計算 (速度が2倍なら長さは1/2)
      const outDuration = originalBuffer.duration / speed;
      const outLength   = Math.ceil(outDuration * sampleRate);

      // UI を変換中モードに切り替え
      setConverting(true);
      showProgress(0, '変換中...');

      try {
        // OfflineAudioContext でオフラインレンダリング
        const offlineCtx = new OfflineAudioContext(numChannels, outLength, sampleRate);

        // AudioBufferSourceNode に元音声をセットして速度を指定
        const source = offlineCtx.createBufferSource();
        source.buffer       = originalBuffer;
        source.playbackRate.value = speed;
        source.connect(offlineCtx.destination);
        source.start(0);

        // レンダリング進捗を監視するポーリング
        const startTime = performance.now();
        const expected  = outDuration * 1000; // ms

        const pollInterval = setInterval(() => {
          const elapsed = performance.now() - startTime;
          const pct     = Math.min(95, (elapsed / expected) * 100);
          showProgress(pct, '変換中...');
        }, 100);

        // オフラインレンダリングを実行 (非同期)
        const renderedBuffer = await offlineCtx.startRendering();
        clearInterval(pollInterval);

        // 選択フォーマットに応じてエンコード
        if (outputFormat === 'mp3') {
          showProgress(98, 'MP3エンコード中...');
          const bitrate = parseInt(bitrateSelect.value, 10);
          convertedBlob = audioBufferToMp3Blob(renderedBuffer, bitrate);
        } else {
          showProgress(98, 'WAVエンコード中...');
          convertedBlob = audioBufferToWavBlob(renderedBuffer);
        }
        showProgress(100, '完了');

        // 変換後プレイヤーに表示
        if (convertedURL) URL.revokeObjectURL(convertedURL);
        convertedURL = URL.createObjectURL(convertedBlob);
        audioConverted.src = convertedURL;
        playerRow.style.display = 'flex';

        // ダウンロードボタンを有効化
        btnDownload.disabled = false;
        const ext = outputFormat.toUpperCase();
        showStatus(`変換完了 — ${speed}× · ${ext} (${formatDuration(renderedBuffer.duration)})`, 'success');

      } catch (err) {
        showStatus(`変換中にエラーが発生しました: ${err.message}`, 'error');
        console.error(err);
      } finally {
        setConverting(false);
      }
    }

    /* =============================
       AudioBuffer を WAV Blob にエンコードする
       PCM 16bit リトルエンディアン形式
       ============================= */
    function audioBufferToWavBlob(buffer) {
      const numChannels = buffer.numberOfChannels;
      const sampleRate  = buffer.sampleRate;
      const numSamples  = buffer.length;
      const bytesPerSample = 2; // 16bit = 2 bytes
      const dataSize    = numChannels * numSamples * bytesPerSample;
      const headerSize  = 44;
      const totalSize   = headerSize + dataSize;

      const arrayBuf = new ArrayBuffer(totalSize);
      const view     = new DataView(arrayBuf);

      // WAV ヘッダーを書き込む
      writeString(view, 0,  'RIFF');
      view.setUint32(4,  totalSize - 8, true);
      writeString(view, 8,  'WAVE');
      writeString(view, 12, 'fmt ');
      view.setUint32(16, 16, true);            // fmt チャンクサイズ
      view.setUint16(20, 1,  true);            // PCM フォーマット
      view.setUint16(22, numChannels, true);
      view.setUint32(24, sampleRate,  true);
      view.setUint32(28, sampleRate * numChannels * bytesPerSample, true); // バイトレート
      view.setUint16(32, numChannels * bytesPerSample, true); // ブロックサイズ
      view.setUint16(34, 16, true);            // ビット深度
      writeString(view, 36, 'data');
      view.setUint32(40, dataSize, true);

      // PCM サンプルデータを書き込む (インターリーブ形式)
      const channels = [];
      for (let c = 0; c < numChannels; c++) {
        channels.push(buffer.getChannelData(c));
      }

      let offset = 44;
      for (let i = 0; i < numSamples; i++) {
        for (let c = 0; c < numChannels; c++) {
          // -1.0〜1.0 の float を 16bit int にクランプしてセット
          const sample = Math.max(-1, Math.min(1, channels[c][i]));
          view.setInt16(offset, sample < 0 ? sample * 0x8000 : sample * 0x7FFF, true);
          offset += 2;
        }
      }

      return new Blob([arrayBuf], { type: 'audio/wav' });
    }

    /* WAV ヘッダー用の文字列書き込みヘルパー */
    function writeString(view, offset, str) {
      for (let i = 0; i < str.length; i++) {
        view.setUint8(offset + i, str.charCodeAt(i));
      }
    }

    /* =============================
       AudioBuffer を MP3 Blob にエンコードする (lamejs 使用)
       ============================= */
    function audioBufferToMp3Blob(buffer, kbps = 192) {
      const numChannels = buffer.numberOfChannels;
      const sampleRate  = buffer.sampleRate;
      const numSamples  = buffer.length;

      // lamejs は Int16Array のサンプルを受け取る
      // モノラルの場合はチャンネル 0 のみ、ステレオは左右を分離して渡す
      const toInt16 = (floatArr) => {
        const int16 = new Int16Array(floatArr.length);
        for (let i = 0; i < floatArr.length; i++) {
          const s = Math.max(-1, Math.min(1, floatArr[i]));
          int16[i] = s < 0 ? s * 0x8000 : s * 0x7FFF;
        }
        return int16;
      };

      const leftData  = toInt16(buffer.getChannelData(0));
      // モノラル音源はステレオとして右チャンネルも左と同じにする
      const rightData = numChannels > 1
        ? toInt16(buffer.getChannelData(1))
        : leftData;

      // MP3 エンコーダーを初期化
      const mp3enc  = new lamejs.Mp3Encoder(2, sampleRate, kbps);
      const chunks  = [];
      const BLOCK   = 1152; // lamejs が要求するフレームサイズ

      for (let i = 0; i < numSamples; i += BLOCK) {
        const leftChunk  = leftData.subarray(i, i + BLOCK);
        const rightChunk = rightData.subarray(i, i + BLOCK);
        const encoded    = mp3enc.encodeBuffer(leftChunk, rightChunk);
        if (encoded.length > 0) chunks.push(new Uint8Array(encoded));
      }

      // エンコーダーをフラッシュして残りデータを取得
      const flushed = mp3enc.flush();
      if (flushed.length > 0) chunks.push(new Uint8Array(flushed));

      return new Blob(chunks, { type: 'audio/mp3' });
    }

    /* =============================
       変換後の音声ファイルをダウンロードする
       フォーマットに応じて拡張子を変える
       ============================= */
    function downloadAudio() {
      if (!convertedBlob || !originalFile) return;

      const speed    = parseFloat(speedSlider.value).toFixed(2).replace('.', '_');
      const baseName = originalFile.name.replace(/\.[^.]+$/, ''); // 拡張子を除いたファイル名
      const ext      = outputFormat === 'mp3' ? 'mp3' : 'wav';
      const fileName = `${baseName}_${speed}x.${ext}`;

      const a = document.createElement('a');
      a.href     = URL.createObjectURL(convertedBlob);
      a.download = fileName;
      a.click();
      URL.revokeObjectURL(a.href);
    }

    /* =============================
       ファイルをクリアしてリセットする
       ============================= */
    function clearFile() {
      originalFile   = null;
      originalBuffer = null;
      fileInput.value = '';

      // ドロップゾーンを元に戻す
      dropPlaceholder.style.display = '';
      fileInfo.classList.remove('visible');
      dropZone.classList.remove('has-file');

      // プレイヤーをリセット
      if (originalObjectURL) { URL.revokeObjectURL(originalObjectURL); originalObjectURL = null; }
      audioOriginal.src = '';
      playerRow.style.display = 'none';

      // 速度セクションと変換ボタンを無効化
      speedSection.classList.remove('active');
      btnConvert.disabled = true;

      clearConverted();
      clearStatus();
    }

    /* 変換結果のみクリアする */
    function clearConverted() {
      convertedBlob = null;
      btnDownload.disabled = true;
      if (convertedURL) { URL.revokeObjectURL(convertedURL); convertedURL = null; }
      audioConverted.src = '';
      progressWrap.classList.remove('visible');
    }

    /* =============================
       UI ヘルパー
       ============================= */

    /* 変換中の UI 状態を切り替える */
    function setConverting(isConverting) {
      btnConvert.disabled = isConverting;
      // 変換中のみダウンロードを無効化し、完了後は convertedBlob の有無で判定する
      if (isConverting) {
        btnDownload.disabled = true;
      } else {
        btnDownload.disabled = !convertedBlob;
      }
      progressWrap.classList.toggle('visible', isConverting);
      actionRow.style.opacity = isConverting ? '0.6' : '1';

      if (isConverting) {
        btnConvert.innerHTML = '<div class="spinner"></div> 変換中...';
      } else {
        btnConvert.innerHTML = '⚡ 変換する';
        btnConvert.disabled = !originalBuffer;
        setTimeout(() => { progressWrap.classList.remove('visible'); }, 1500);
      }
    }

    /* プログレスバーを更新する */
    function showProgress(pct, label) {
      progressBar.style.width  = `${pct}%`;
      progressText.textContent = label;
      progressPct.textContent  = `${Math.round(pct)}%`;
    }

    /* ステータスバッジを表示する */
    function showStatus(msg, type) {
      statusArea.innerHTML = `
        <span class="status-badge ${type}">
          <span class="dot"></span>${msg}
        </span>`;
    }

    /* ステータスをクリアする */
    function clearStatus() { statusArea.innerHTML = ''; }

    /* バイト数を人間が読みやすい形式に変換する */
    function formatBytes(bytes) {
      if (bytes < 1024)       return `${bytes} B`;
      if (bytes < 1024 ** 2)  return `${(bytes / 1024).toFixed(1)} KB`;
      return `${(bytes / 1024 ** 2).toFixed(1)} MB`;
    }

    /* 秒数を mm:ss 形式に変換する */
    function formatDuration(seconds) {
      const m = Math.floor(seconds / 60);
      const s = Math.floor(seconds % 60);
      return `${m}:${String(s).padStart(2, '0')}`;
    }

    // --- 初期化実行 ---
    init();
  </script>
</body>
</html>