Files
reader-star/apps/admin/src/utils/audio.ts
rjl b71a758474 feat(user): 重构用户端竞赛流程并添加新页面
- 将原单页竞赛流程拆分为独立路由页面(首页、封面、规则、抽题、答题、图解、组别、队伍)
- 新增 Pinia store 管理竞赛状态、计时器和音频控制
- 添加 SCSS 变量文件定义主题色
- 更新路由配置和类型定义以支持新页面结构
- 重构 QuestionRenderer 组件优化样式
- 添加图标依赖并更新国际化配置
- 移动图片资源到用户目录并清理旧文件
2026-02-06 17:45:44 +08:00

122 lines
3.7 KiB
TypeScript

export class AudioController {
private audioContext: AudioContext | null = null
private soundFiles: Record<string, string> = {}
private audioCache: Record<string, HTMLAudioElement> = {}
constructor(soundFiles: Record<string, string> = {}) {
this.soundFiles = soundFiles
}
private initAudioContext() {
if (!this.audioContext) {
this.audioContext = new (window.AudioContext || (window as any).webkitAudioContext)()
}
}
/**
* 播放指定类型的音效
* 优先使用预设的音频文件,如果没有配置或加载失败,则使用 Web Audio API 合成音效
*/
async play(type: 'start' | 'tick' | 'flip') {
// 尝试播放音频文件
if (this.soundFiles[type]) {
try {
await this.playFile(type)
return
}
catch (error) {
console.warn(`Failed to play audio file for ${type}, falling back to synth.`, error)
}
}
// 回退到合成音效
this.playSynth(type)
}
private playFile(type: string): Promise<void> {
return new Promise((resolve, reject) => {
const url = this.soundFiles[type]
if (!url) {
reject(new Error('No file url'))
return
}
// 使用缓存的 Audio 对象
if (!this.audioCache[type]) {
this.audioCache[type] = new Audio(url)
}
const audio = this.audioCache[type]
audio.currentTime = 0
audio.play()
.then(() => resolve())
.catch(e => reject(e))
})
}
private playSynth(type: 'start' | 'tick' | 'flip') {
this.initAudioContext()
if (!this.audioContext)
return
const ctxTime = this.audioContext.currentTime
const gain = this.audioContext.createGain()
gain.connect(this.audioContext.destination)
if (type === 'start') {
const osc = this.audioContext.createOscillator()
osc.connect(gain)
// 开始音效:高音短促提示
osc.type = 'sine'
osc.frequency.setValueAtTime(880, ctxTime)
osc.frequency.exponentialRampToValueAtTime(440, ctxTime + 0.3)
gain.gain.setValueAtTime(0.5, ctxTime)
gain.gain.exponentialRampToValueAtTime(0.01, ctxTime + 0.3)
osc.start(ctxTime)
osc.stop(ctxTime + 0.3)
}
else if (type === 'tick') {
const osc = this.audioContext.createOscillator()
osc.connect(gain)
// 倒计时音效:急促的滴答声
osc.type = 'triangle'
osc.frequency.setValueAtTime(600, ctxTime)
gain.gain.setValueAtTime(0.3, ctxTime)
gain.gain.exponentialRampToValueAtTime(0.01, ctxTime + 0.1)
osc.start(ctxTime)
osc.stop(ctxTime + 0.1)
}
else if (type === 'flip') {
// 翻书音效:使用白噪声模拟纸张摩擦
const bufferSize = this.audioContext.sampleRate * 0.5 // 0.5秒缓冲
const buffer = this.audioContext.createBuffer(1, bufferSize, this.audioContext.sampleRate)
const data = buffer.getChannelData(0)
// 生成白噪声
for (let i = 0; i < bufferSize; i++) {
data[i] = Math.random() * 2 - 1
}
const noise = this.audioContext.createBufferSource()
noise.buffer = buffer
// 滤波器:低通滤波器,模拟纸张的闷声
const filter = this.audioContext.createBiquadFilter()
filter.type = 'lowpass'
filter.frequency.setValueAtTime(400, ctxTime)
filter.frequency.exponentialRampToValueAtTime(3000, ctxTime + 0.1) // 频率快速扫过,模拟快速翻动
noise.connect(filter)
filter.connect(gain)
// 音量包络:快速淡入淡出
gain.gain.setValueAtTime(0, ctxTime)
gain.gain.linearRampToValueAtTime(0.8, ctxTime + 0.05)
gain.gain.exponentialRampToValueAtTime(0.01, ctxTime + 0.3)
noise.start(ctxTime)
noise.stop(ctxTime + 0.3)
}
}
}