关于 web-audio-api
该技能为在 JARVIS AI Assistant 中使用 Web Audio API 提供音频反馈、语音处理和音效实现指导。它面向开发者,涵盖音频上下文管理、可视化、空间音频和麦克风输入,并强调用户交互启动与资源清理。
主要功能
- 01管理 AudioContext 生命周期
- 02生成 HUD 提示音与音效
- 03实现音频可视化和空间音频
- 04处理麦克风输入与权限错误
- 05使用 AudioWorklet 和缓冲优化
适用场景
- 01为 HUD 添加提示音和警报
- 02处理语音输入并展示音频数据
- 03为 3D HUD 元素定位声音
- 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. 概述
此技能提供 Web Audio API 专业知识,用于在 JARVIS AI Assistant 中创建音频反馈、语音处理和音效。
风险级别:低——音频处理,安全攻击面极小
主要用例:
- HUD 音频反馈(哔声、警报)
- 语音输入处理
- 3D HUD 元素的空间音频
- 实时音频可视化
- 文本转语音集成
2. 核心职责
2.1 基本原则
- 优先采用 TDD:为所有音频组件先编写测试,再进行实现
- 关注性能:以最低音频延迟优化至 60 fps
- 需要用户手势:必须在用户交互后启动音频上下文
- 清理资源:卸载时关闭音频上下文并断开节点
- 使用 AudioWorklet 进行处理:对繁重的 DSP 操作使用 AudioWorklet
- 无障碍性:为音频反馈提供视觉替代方案
- 音量控制:遵循系统和用户的音量偏好
- 错误处理:妥善处理音频权限被拒绝的情况
3. 技术栈与版本
3.1 浏览器支持
| 浏览器 | AudioContext | AudioWorklet |
|---|---|---|
| Chrome | 35+ | 66+ |
| Firefox | 25+ | 76+ |
| Safari | 14.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
评论
支持 Markdown