Files
OpenClaw-bot-review/lib/pixel-office/notificationSound.ts
T

164 lines
4.5 KiB
TypeScript

import {
NOTIFICATION_NOTE_1_HZ,
NOTIFICATION_NOTE_2_HZ,
NOTIFICATION_NOTE_1_START_SEC,
NOTIFICATION_NOTE_2_START_SEC,
NOTIFICATION_NOTE_DURATION_SEC,
NOTIFICATION_VOLUME,
} from './constants'
let soundEnabled = true
let audioCtx: AudioContext | null = null
let bgmAudio: HTMLAudioElement | null = null
let bgmGestureRetryBound = false
let bgmTracks: string[] = []
let bgmLastIndex = -1
let bgmTracksLoaded = false
const BGM_VOLUME = 0.28
async function loadTracks(): Promise<void> {
if (bgmTracksLoaded) return
bgmTracksLoaded = true
try {
const res = await fetch('/api/pixel-office/tracks')
const data = await res.json()
if (Array.isArray(data.tracks) && data.tracks.length > 0) {
bgmTracks = data.tracks
}
} catch {
// fallback: keep empty, pickNextTrack handles it
}
}
function pickNextTrack(): string {
if (bgmTracks.length === 0) return '/assets/pixel-office/pixel-adventure.mp3'
if (bgmTracks.length === 1) return bgmTracks[0]
let idx: number
do { idx = Math.floor(Math.random() * bgmTracks.length) } while (idx === bgmLastIndex)
bgmLastIndex = idx
return bgmTracks[idx]
}
export function setSoundEnabled(enabled: boolean): void {
soundEnabled = enabled
if (!enabled) stopBackgroundMusic()
}
export function isSoundEnabled(): boolean {
return soundEnabled
}
function playNote(ctx: AudioContext, freq: number, startOffset: number): void {
const t = ctx.currentTime + startOffset
const osc = ctx.createOscillator()
const gain = ctx.createGain()
osc.type = 'sine'
osc.frequency.setValueAtTime(freq, t)
gain.gain.setValueAtTime(NOTIFICATION_VOLUME, t)
gain.gain.exponentialRampToValueAtTime(0.001, t + NOTIFICATION_NOTE_DURATION_SEC)
osc.connect(gain)
gain.connect(ctx.destination)
osc.start(t)
osc.stop(t + NOTIFICATION_NOTE_DURATION_SEC)
}
function getBgmAudio(): HTMLAudioElement | null {
if (typeof window === 'undefined') return null
if (!bgmAudio) {
bgmAudio = new Audio(pickNextTrack())
bgmAudio.loop = false
bgmAudio.preload = 'auto'
bgmAudio.volume = BGM_VOLUME
bgmAudio.addEventListener('ended', () => {
if (!soundEnabled || !bgmAudio) return
bgmAudio.src = pickNextTrack()
bgmAudio.load()
bgmAudio.play().catch(() => {})
})
}
return bgmAudio
}
function bindBgmGestureRetry(): void {
if (typeof window === 'undefined' || bgmGestureRetryBound) return
bgmGestureRetryBound = true
const cleanup = () => {
if (typeof window === 'undefined' || !bgmGestureRetryBound) return
bgmGestureRetryBound = false
window.removeEventListener('pointerdown', resumeOnGesture)
window.removeEventListener('touchstart', resumeOnGesture)
window.removeEventListener('keydown', resumeOnGesture)
}
const resumeOnGesture = () => {
if (!soundEnabled) { cleanup(); return }
const audio = getBgmAudio()
if (!audio) { cleanup(); return }
audio.play().then(() => { cleanup() }).catch(() => {})
}
window.addEventListener('pointerdown', resumeOnGesture, { passive: true })
window.addEventListener('touchstart', resumeOnGesture, { passive: true })
window.addEventListener('keydown', resumeOnGesture)
}
export async function playDoneSound(): Promise<void> {
if (!soundEnabled) return
try {
if (!audioCtx) audioCtx = new AudioContext()
if (audioCtx.state === 'suspended') await audioCtx.resume()
playNote(audioCtx, NOTIFICATION_NOTE_1_HZ, NOTIFICATION_NOTE_1_START_SEC)
playNote(audioCtx, NOTIFICATION_NOTE_2_HZ, NOTIFICATION_NOTE_2_START_SEC)
} catch {
// Audio may not be available
}
}
export function unlockAudio(): void {
try {
if (!audioCtx) audioCtx = new AudioContext()
if (audioCtx.state === 'suspended') audioCtx.resume()
} catch {
// ignore
}
}
export async function playBackgroundMusic(): Promise<void> {
if (!soundEnabled) return
await loadTracks()
try {
const audio = getBgmAudio()
if (!audio) return
// If tracks loaded after audio element was created, update src to a proper random track
if (bgmTracks.length > 0 && audio.src.includes('pixel-adventure') && bgmTracks.length > 1) {
audio.src = pickNextTrack()
audio.load()
}
audio.muted = false
audio.loop = false
audio.volume = BGM_VOLUME
await audio.play()
} catch {
bindBgmGestureRetry()
}
}
export function skipToNextTrack(): void {
if (!bgmAudio) return
bgmAudio.src = pickNextTrack()
bgmAudio.load()
if (soundEnabled) bgmAudio.play().catch(() => {})
}
export function stopBackgroundMusic(): void {
if (!bgmAudio) return
bgmAudio.pause()
bgmAudio.currentTime = 0
}