mirror of
https://github.com/xmanrui/OpenClaw-bot-review.git
synced 2026-07-27 22:25:52 +00:00
164 lines
4.5 KiB
TypeScript
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
|
|
}
|