import scanSuccessSoundUrl from "@/assets/sound/scan-success.mp3"; import registrationSuccessSoundUrl from "@/assets/sound/registration-success.mp3"; export interface SoundSetting { scanSuccess: boolean; registerSuccess: boolean; volume: number; } type SoundType = "scanSuccess" | "registerSuccess"; const SOUND_STORAGE_KEY = "gao-customer-h5:sound"; const soundUrlMap: Record = { scanSuccess: scanSuccessSoundUrl, registerSuccess: registrationSuccessSoundUrl }; // Windows 某些设备链路对极短提示音的起音处理不稳定,直接播放会偏小。 // 这里先补一段极低音量的预热头,把输出链路“叫醒”后再接原音效。 const SCAN_SUCCESS_WARMUP_HEAD_MS = 120; const SCAN_SUCCESS_WARMUP_GAIN = .08; function createDefaultSoundSetting(): SoundSetting { return { scanSuccess: true, registerSuccess: true, volume: 70 }; } function readStoredSoundSetting(): SoundSetting { const rawValue = localStorage.getItem(SOUND_STORAGE_KEY); if (!rawValue) { return createDefaultSoundSetting(); } try { const parsedValue = JSON.parse(rawValue) as Partial; const volume = Number(parsedValue.volume); return { scanSuccess: parsedValue.scanSuccess !== false, registerSuccess: parsedValue.registerSuccess !== false, volume: Number.isFinite(volume) ? Math.min(100, Math.max(0, volume)) : 70 }; } catch { return createDefaultSoundSetting(); } } export const useSettingStore = defineStore("setting", () => { const sound = ref(readStoredSoundSetting()); const soundPlayerMap = shallowRef | null>(null); const audioContext = shallowRef(null); const scanSuccessBuffer = shallowRef(null); const activeScanSuccessSource = shallowRef(null); const soundUnlocked = ref(false); function setSound(value: Partial): void { sound.value = { scanSuccess: value.scanSuccess ?? sound.value.scanSuccess, registerSuccess: value.registerSuccess ?? sound.value.registerSuccess, volume: Math.min(100, Math.max(0, value.volume ?? sound.value.volume)) }; localStorage.setItem(SOUND_STORAGE_KEY, JSON.stringify(sound.value)); applySoundVolume(); } function loadSoundPlayers(): void { if (soundPlayerMap.value) { return; } soundPlayerMap.value = { scanSuccess: createSoundPlayer(soundUrlMap.scanSuccess), registerSuccess: createSoundPlayer(soundUrlMap.registerSuccess) }; applySoundVolume(); } function getAudioContext(): AudioContext { if (audioContext.value) { return audioContext.value; } audioContext.value = new window.AudioContext(); return audioContext.value; } function createSoundPlayer(url: string): HTMLAudioElement { const player = new Audio(url); player.preload = "auto"; player.load(); return player; } function applySoundVolume(): void { if (!soundPlayerMap.value) { return; } const volume = sound.value.volume / 100; Object.values(soundPlayerMap.value).forEach((player) => { player.volume = volume; }); } async function unlockSoundPlayers(): Promise { loadSoundPlayers(); if (soundUnlocked.value || !soundPlayerMap.value) { return soundUnlocked.value; } try { await getAudioContext().resume(); for (const player of Object.values(soundPlayerMap.value)) { player.muted = true; player.volume = 0; player.currentTime = 0; await player.play(); player.pause(); player.currentTime = 0; player.muted = false; } applySoundVolume(); soundUnlocked.value = true; return true; } catch { Object.values(soundPlayerMap.value).forEach((player) => { player.muted = false; }); applySoundVolume(); return false; } } async function loadScanSuccessBuffer(): Promise { if (scanSuccessBuffer.value) { return scanSuccessBuffer.value; } const response = await fetch(soundUrlMap.scanSuccess); const arrayBuffer = await response.arrayBuffer(); scanSuccessBuffer.value = await getAudioContext().decodeAudioData(arrayBuffer.slice(0)); return scanSuccessBuffer.value; } async function playScanSuccessBufferSound(): Promise { const context = getAudioContext(); if (context.state === "suspended") { await context.resume(); } activeScanSuccessSource.value?.stop(); activeScanSuccessSource.value?.disconnect(); const source = context.createBufferSource(); const gainNode = context.createGain(); source.buffer = await buildScanSuccessPlaybackBuffer(); gainNode.gain.value = sound.value.volume / 100; source.connect(gainNode); gainNode.connect(context.destination); source.onended = () => { if (activeScanSuccessSource.value === source) { activeScanSuccessSource.value = null; } source.disconnect(); gainNode.disconnect(); }; activeScanSuccessSource.value = source; source.start(0); } async function buildScanSuccessPlaybackBuffer(): Promise { const sourceBuffer = await loadScanSuccessBuffer(); const context = getAudioContext(); const warmupFrameCount = Math.max(1, Math.round(context.sampleRate * SCAN_SUCCESS_WARMUP_HEAD_MS / 1000)); const outputBuffer = context.createBuffer( sourceBuffer.numberOfChannels, sourceBuffer.length + warmupFrameCount, sourceBuffer.sampleRate ); for (let channelIndex = 0; channelIndex < sourceBuffer.numberOfChannels; channelIndex += 1) { const outputData = outputBuffer.getChannelData(channelIndex); const sourceData = sourceBuffer.getChannelData(channelIndex); const warmupSourceLength = Math.min(sourceData.length, warmupFrameCount); // 预热头不能是纯静音;纯静音对这类 Windows 问题无效。 for (let frameIndex = 0; frameIndex < warmupFrameCount; frameIndex += 1) { const sourceIndex = frameIndex % warmupSourceLength; outputData[frameIndex] = sourceData[sourceIndex] * SCAN_SUCCESS_WARMUP_GAIN; } outputData.set(sourceData, warmupFrameCount); } return outputBuffer; } async function playSound(type: SoundType): Promise { if (type === "scanSuccess") { await playScanSuccessBufferSound(); return; } loadSoundPlayers(); if (!sound.value[type] || !soundPlayerMap.value) { return; } const player = soundPlayerMap.value[type]; player.pause(); player.muted = false; player.volume = sound.value.volume / 100; player.currentTime = 0; await player.play(); } async function playScanSuccessSound(): Promise { await playSound("scanSuccess"); } async function playRegisterSuccessSound(): Promise { await playSound("registerSuccess"); } return { sound, setSound, loadSoundPlayers, unlockSoundPlayers, playScanSuccessSound, playRegisterSuccessSound }; });