mcpField
W
web-audio-api

web-audio-api

github.com/martinholovsky開発48398 回インストール47 週間前に更新
リポジトリ

web-audio-api について

JARVIS AI Assistantで音声フィードバック、音声処理、効果音を実装するためのWeb Audio APIスキルです。開発者向けに、コンテキスト管理、可視化、空間オーディオ、マイク入力を扱い、ユーザー操作後の起動とリソース解放を重視します。

主な機能

  1. 01AudioContextのライフサイクル管理
  2. 02HUDのビープ音と効果音の作成
  3. 03音声可視化と空間オーディオの実装
  4. 04マイク入力と権限エラーへの対応
  5. 05AudioWorkletとバッファ再利用による最適化

活用シーン

  1. 01HUDにビープ音やアラートを追加
  2. 02音声入力の処理とデータの可視化
  3. 033D HUD要素に合わせた音の配置
  4. 04音声コンポーネントのテストとクリーンアップ

インストール

コマンドをコピーしてターミナルで実行します。

$npx skills add https://github.com/martinholovsky/claude-skills-generator --skill web-audio-api

インストール後、Claude Code で/web-audio-apiを入力して読み込まれたか確認します。

同梱ファイルreferences/advanced-patterns.md

詳細

Web Audio API スキル

1. 概要

このスキルでは、JARVIS AI アシスタントでオーディオフィードバック、音声処理、効果音を作成するための Web Audio API の専門知識を提供します。

リスクレベル:低 — セキュリティ上の影響範囲が最小限のオーディオ処理です。

主な用途:

  • HUD のオーディオフィードバック(ビープ音、アラート)
  • 音声入力の処理
  • 3D HUD 要素の空間オーディオ
  • リアルタイムのオーディオ可視化
  • テキスト読み上げの統合

2. 主な責務

2.1 基本原則
  1. TDD を最優先:すべてのオーディオコンポーネントで、実装前にテストを作成します。
  2. パフォーマンスを考慮:オーディオのレイテンシを最小限に抑え、60fps 向けに最適化します。
  3. ユーザー操作が必須:ユーザーの操作後にオーディオコンテキストを開始する必要があります。
  4. リソースをクリーンアップ:アンマウント時にオーディオコンテキストを閉じ、ノードの接続を解除します。
  5. 処理には AudioWorklet を使用:負荷の高い DSP 処理には AudioWorklet を使用します。
  6. アクセシビリティ:オーディオフィードバックの視覚的な代替手段を用意します。
  7. 音量制御:システムとユーザーの音量設定を尊重します。
  8. エラー処理:オーディオの権限が拒否された場合も、適切に処理します。

3. 技術スタックとバージョン

3.1 ブラウザーのサポート状況
ブラウザーAudioContextAudioWorklet
Chrome35+66+
Firefox25+76+
Safari14.1+14.1+
3.2 TypeScript の型
// types/audio.ts
interface AudioFeedbackOptions {
  frequency: number
  duration: number
  type: OscillatorType
  volume: number
}

interface SpatialAudioPosition {
  x: number
  y: number
  z: number
}

4. 実装パターン

4.1 オーディオコンテキストの管理
// composables/useAudioContext.ts
export function useAudioContext() {
  const audioContext = ref<AudioContext | null>(null)
  const isInitialized = ref(false)

  async function initialize() {
    if (audioContext.value) return
    audioContext.value = new AudioContext()
    if (audioContext.value.state === 'suspended') await audioContext.value.resume()
    isInitialized.value = true
  }

  onUnmounted(() => {
    audioContext.value?.close()
    audioContext.value = null
  })

  return { audioContext: readonly(audioContext), isInitialized: readonly(isInitialized), initialize }
}
4.2 HUD のビープ音フィードバック
// composables/useHUDSounds.ts
export function useHUDSounds() {
  const { audioContext, initialize } = useAudioContext()

  async function playBeep(options: Partial<AudioFeedbackOptions> = {}) {
    await initialize()
    const ctx = audioContext.value
    if (!ctx) return

    const { frequency = 440, duration = 0.1, type = 'sine', volume = 0.3 } = options
    const safeVolume = Math.max(0, Math.min(1, volume))

    const oscillator = ctx.createOscillator()
    const gainNode = ctx.createGain()
    oscillator.type = type
    oscillator.frequency.value = frequency
    gainNode.gain.value = safeVolume
    gainNode.gain.exponentialRampToValueAtTime(0.001, ctx.currentTime + duration)

    oscillator.connect(gainNode).connect(ctx.destination)
    oscillator.start()
    oscillator.stop(ctx.currentTime + duration)
  }

  const sounds = {
    confirm: () => playBeep({ frequency: 880, duration: 0.1, volume: 0.2 }),
    alert: () => playBeep({ frequency: 440, duration: 0.3, type: 'square', volume: 0.4 }),
    error: () => playBeep({ frequency: 220, duration: 0.5, type: 'sawtooth', volume: 0.3 }),
    click: () => playBeep({ frequency: 1000, duration: 0.05, volume: 0.1 })
  }
  return { playBeep, sounds }
}
4.3 オーディオの可視化
// composables/useAudioVisualization.ts
export function useAudioVisualization() {
  const { audioContext, initialize } = useAudioContext()
  let analyser: AnalyserNode | null = null
  let dataArray: Uint8Array | null = null

  async function setupAnalyser(source: AudioNode) {
    await initialize()
    const ctx = audioContext.value
    if (!ctx) return
    analyser = ctx.createAnalyser()
    analyser.fftSize = 256
    dataArray = new Uint8Array(analyser.frequencyBinCount)
    source.connect(analyser)
  }

  function getFrequencyData(): Uint8Array | null {
    if (!analyser || !dataArray) return null
    analyser.getByteFrequencyData(dataArray)
    return dataArray
  }

  return { setupAnalyser, getFrequencyData }
}
4.4 3D HUD の空間オーディオ
// composables/useSpatialAudio.ts
export function useSpatialAudio() {
  const { audioContext, initialize } = useAudioContext()
  let panner: PannerNode | null = null

  async function createSpatialSource(position: SpatialAudioPosition) {
    await initialize()
    const ctx = audioContext.value
    if (!ctx) return null
    panner = ctx.createPanner()
    panner.panningModel = 'HRTF'
    panner.distanceModel = 'inverse'
    setPosition(position)
    return panner
  }

  function setPosition(pos: SpatialAudioPosition) {
    if (!panner) return
    panner.positionX.value = pos.x
    panner.positionY.value = pos.y
    panner.positionZ.value = pos.z
  }

  return { createSpatialSource, setPosition }
}
4.5 マイク入力
// composables/useMicrophone.ts
export function useMicrophone() {
  const { audioContext, initialize } = useAudioContext()
  const stream = ref<MediaStream | null>(null)
  const isListening = ref(false)
  const error = ref<string | null>(null)

  async function startListening() {
    try {
      await initialize()
      stream.value = await navigator.mediaDevices.getUserMedia({
        audio: { echoCancellation: true, noiseSuppression: true, autoGainControl: true }
      })
      isListening.value = true
      return stream.value
    } catch (err) {
      error.value = err instanceof Error ? err.message : 'Microphone access denied'
      return null
    }
  }

  function stopListening() {
    stream.value?.getTracks().forEach(track => track.stop())
    stream.value = null
    isListening.value = false
  }

  onUnmounted(() => stopListening())

  return { stream: readonly(stream), isListening: readonly(isListening), error: readonly(error), startListening, stopListening }
}

5. 実装ワークフロー(TDD)

ステップ 1:失敗するテストを先に作成する
// tests/composables/useHUDSounds.test.ts
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { useHUDSounds } from '~/composables/useHUDSounds'

// Mock AudioContext nodes
const mockOscillator = { connect: vi.fn(), start: vi.fn(), stop: vi.fn(), frequency: { value: 440 } }
const mockGainNode = { connect: vi.fn(), gain: { value: 1, exponentialRampToValueAtTime: vi.fn() } }
const mockAudioContext = {
  state: 'running', currentTime: 0, destination: {},
  createOscillator: vi.fn(() => mockOscillator),
  createGain: vi.fn(() => mockGainNode),
  resume: vi.fn(), close: vi.fn()
}
vi.stubGlobal('AudioContext', vi.fn(() => mockAudioContext))

describe('useHUDSounds', () => {
  beforeEach(() => vi.clearAllMocks())

  it('creates oscillator with correct frequency', async () => {
    const { playBeep } = useHUDSounds()
    await playBeep({ frequency: 880 })
    expect(mockOscillator.frequency.value).toBe(880)
  })

  it('clamps volume to valid range', async () => {
    const { playBeep } = useHUDSounds()
    await playBeep({ volume: 2.5 })
    expect(mockGainNode.gain.value).toBeLessThanOrEqual(1)
  })

  it('connects nodes in correct order', async () => {
    const { playBeep } = useHUDSounds()
    await playBeep()
    expect(mockOscillator.connect).toHaveBeenCalledWith(mockGainNode)
    expect(mockGainNode.connect).toHaveBeenCalledWith(mockAudioContext.destination)
  })
})
ステップ 2:テストを通すための最小限の実装を行う
// composables/useHUDSounds.ts
export function useHUDSounds() {
  // Implementation from section 4.2
  // Only add features that tests require
}
ステップ 3:パターンに従ってリファクタリングする

テストが通った後、次のようにリファクタリングします。

  • 共通のオーディオコンテキスト処理を抽出する
  • 適切な TypeScript の型を追加する
  • アンマウント時のクリーンアップを実装する
ステップ 4:完全な検証を実行する
# Run all audio-related tests
npm test -- --grep "audio|sound|HUD"

# Check types
npm run typecheck

# Verify no memory leaks in browser
npm run dev  # Test manually with DevTools Memory tab

6. パフォーマンスパターン

6.1 処理に AudioWorklet を使用する
// ✅ Good: Use AudioWorklet for DSP (runs on audio thread)
class NoiseGateProcessor extends AudioWorkletProcessor {
  process(inputs: Float32Array[][], outputs: Float32Array[][]) {
    for (let ch = 0; ch < inputs[0].length; ch++) {
      for (let i = 0; i < inputs[0][ch].length; i++) {
        outputs[0][ch][i] = Math.abs(inputs[0][ch][i]) > 0.01 ? inputs[0][ch][i] : 0
      }
    }
    return true
  }
}
registerProcessor('noise-gate', NoiseGateProcessor)

// ❌ Bad: ScriptProcessorNode (deprecated, blocks main thread)
6.2 バッファープーリング
// ✅ Good: Reuse audio buffers
class AudioBufferPool {
  private pool: AudioBuffer[] = []
  constructor(ctx: AudioContext, size: number, length: number) {
    for (let i = 0; i < size; i++) {
      this.pool.push(ctx.createBuffer(2, length, ctx.sampleRate))
    }
  }
  acquire(): AudioBuffer | undefined { return this.pool.pop() }
  release(buffer: AudioBuffer) {
    for (let ch = 0; ch < buffer.numberOfChannels; ch++) {
      buffer.getChannelData(ch).fill(0)
    }
    this.pool.push(buffer)
  }
}

// ❌ Bad: Create new buffer each time
const buffer = ctx.createBuffer(2, 44100, 44100) // Allocates memory each call
6.3 オフラインレンダリング
// ✅ Good: Pre-render complex sounds
async function prerenderSound(): Promise<AudioBuffer> {
  const offlineCtx = new OfflineAudioContext(2, 44100, 44100)
  const osc = offlineCtx.createOscillator()
  const gain = offlineCtx.createGain()
  osc.connect(gain).connect(offlineCtx.destination)
  gain.gain.setValueAtTime(0, 0)
  gain.gain.linearRampToValueAtTime(1, 0.01)
  gain.gain.exponentialRampToValueAtTime(0.001, 1)
  osc.start(); osc.stop(1)
  return offlineCtx.startRendering()
}

// ❌ Bad: Generate complex sounds in real-time (multiple oscillators computed live)
6.4 ノードグラフの最適化
// ✅ Good: Reuse master gain node
const masterGain = ctx.createGain()
masterGain.connect(ctx.destination)
function playSound(buffer: AudioBuffer) {
  const source = ctx.createBufferSource()
  source.buffer = buffer
  source.connect(masterGain)
  source.start()
}

// ❌ Bad: Create full chain for each sound (gain + compressor per play)
6.5 メモリー管理
// ✅ Good: Disconnect and cleanup nodes
function playOneShot(buffer: AudioBuffer) {
  const source = ctx.createBufferSource()
  source.buffer = buffer
  source.connect(masterGain)
  source.onended = () => source.disconnect()
  source.start()
}

// ✅ Good: Limit concurrent sounds (max 8)
class SoundManager {
  private activeSources = new Set<AudioBufferSourceNode>()
  play(buffer: AudioBuffer) {
    if (this.activeSources.size >= 8) this.activeSources.values().next().value?.stop()
    const source = ctx.createBufferSource()
    source.buffer = buffer
    source.connect(masterGain)
    source.onended = () => { source.disconnect(); this.activeSources.delete(source) }
    this.activeSources.add(source)
    source.start()
  }
}

// ❌ Bad: Never cleanup - nodes stay in memory after playback
const source = ctx.createBufferSource()
source.connect(ctx.destination)
source.start()

7. 品質基準

// ✅ Always require user gesture
button.addEventListener('click', async () => {
  await audioContext.resume()
  playSound()
})

// ✅ Respect user preferences
if (usePreferencesStore().preferences.soundEnabled) playBeep()

// ✅ Handle permission denial gracefully
try {
  await navigator.mediaDevices.getUserMedia({ audio: true })
} catch (err) {
  if (err.name === 'NotAllowedError') {
    showVisualFeedback('Microphone access required')
  }
}

8. テストと品質

describe('HUD Sounds', () => {
  it('validates volume bounds', async () => {
    const { playBeep } = useHUDSounds()
    await playBeep({ volume: 2 })  // Clamped to 1
    await playBeep({ volume: -1 }) // Clamped to 0
  })
})

9. よくある間違いとアンチパターン

9.1 重大なアンチパターン
// ❌ Auto-play without user gesture - BLOCKED
onMounted(() => playSound())

// ✅ After user interaction
const handleClick = async () => { await audioContext.resume(); playSound() }

// ❌ Memory leak - no cleanup
const audioContext = new AudioContext()

// ✅ Proper cleanup
onUnmounted(() => audioContext.close())

// ❌ New context per sound - performance killer
function playSound() { const ctx = new AudioContext() }

// ✅ Reuse context
const ctx = new AudioContext()
function playSound() { /* reuse ctx */ }

10. 実装前チェックリスト

フェーズ 1:コードを書く前
  • オーディオノードの作成と接続のテストを作成した
  • 音量のクランプと検証のテストを作成した
  • パフォーマンス要件(レイテンシ、同時に再生するサウンド数)を特定した
  • DSP に AudioWorklet が必要か確認した。必要な場合は Worklet ファイルを作成した
  • 想定される使用量に合わせてバッファープールのサイズを計算した
フェーズ 2:実装中
  • AudioContext の初期化にユーザー操作を必須とした
  • オーディオコンテキストを再利用するようにした(サウンドごとに作成しない)
  • onended コールバックでノードの接続を解除するようにした
  • 音量の範囲を検証するようにした(0~1 の範囲)
  • マイクの権限を適切に処理するようにした
  • エラー状態で視覚的なフィードバックを提供するようにした
フェーズ 3:コミット前
  • すべてのオーディオテストが成功する:npm test -- --grep "audio"
  • 型チェックが成功する:npm run typecheck
  • メモリーリークがない(DevTools の Memory タブでテスト済み)
  • コンポーネントのアンマウント時にオーディオコンテキストを閉じる
  • アクセシビリティのための視覚的な代替手段を用意した
  • ユーザー設定でサウンドを無効にできる
  • 音量がシステム設定を尊重する

11. まとめ

JARVIS で Web Audio API を使用する場合は、ユーザー操作後に初期化し、アンマウント時にクリーンアップし、権限拒否を処理して、視覚的な代替手段を用意します。詳しくは references/advanced-patterns.md を参照してください。

コメント