feat(web): 增强消息提醒声音与语音朗读
This commit is contained in:
@@ -0,0 +1,230 @@
|
||||
import type { CustomSoundMeta, SoundPresetId } from './notificationSettings'
|
||||
|
||||
const DB_NAME = 'kefu_notification_audio'
|
||||
const DB_VERSION = 1
|
||||
const STORE_NAME = 'sounds'
|
||||
const CUSTOM_SOUND_KEY = 'custom'
|
||||
|
||||
interface StoredSoundRecord extends CustomSoundMeta {
|
||||
blob: Blob
|
||||
}
|
||||
|
||||
const AUDIO_MIME_ALLOWLIST = [
|
||||
'audio/mpeg',
|
||||
'audio/mp3',
|
||||
'audio/wav',
|
||||
'audio/x-wav',
|
||||
'audio/ogg',
|
||||
'audio/webm',
|
||||
'audio/mp4',
|
||||
'audio/aac',
|
||||
]
|
||||
|
||||
const AUDIO_EXTENSION_RE = /\.(mp3|wav|ogg|oga|m4a|aac|webm)$/i
|
||||
|
||||
function openAudioDB(): Promise<IDBDatabase> {
|
||||
return new Promise((resolve, reject) => {
|
||||
if (typeof indexedDB === 'undefined') {
|
||||
reject(new Error('当前浏览器不支持本地音频存储'))
|
||||
return
|
||||
}
|
||||
const request = indexedDB.open(DB_NAME, DB_VERSION)
|
||||
request.onupgradeneeded = () => {
|
||||
const db = request.result
|
||||
if (!db.objectStoreNames.contains(STORE_NAME)) db.createObjectStore(STORE_NAME)
|
||||
}
|
||||
request.onsuccess = () => resolve(request.result)
|
||||
request.onerror = () => reject(request.error || new Error('打开本地音频存储失败'))
|
||||
})
|
||||
}
|
||||
|
||||
export function isSupportedAudioFile(file: File): boolean {
|
||||
const mime = String(file.type || '').toLowerCase()
|
||||
return AUDIO_MIME_ALLOWLIST.includes(mime) || AUDIO_EXTENSION_RE.test(file.name)
|
||||
}
|
||||
|
||||
export function readAudioDuration(file: Blob): Promise<number> {
|
||||
return new Promise(resolve => {
|
||||
if (typeof window === 'undefined') {
|
||||
resolve(0)
|
||||
return
|
||||
}
|
||||
const url = URL.createObjectURL(file)
|
||||
const audio = new Audio()
|
||||
let settled = false
|
||||
const finish = (duration: number) => {
|
||||
if (settled) return
|
||||
settled = true
|
||||
window.clearTimeout(timer)
|
||||
audio.removeAttribute('src')
|
||||
URL.revokeObjectURL(url)
|
||||
resolve(Number.isFinite(duration) && duration > 0 ? duration : 0)
|
||||
}
|
||||
const timer = window.setTimeout(() => finish(0), 5000)
|
||||
audio.preload = 'metadata'
|
||||
audio.onloadedmetadata = () => finish(audio.duration)
|
||||
audio.onerror = () => finish(0)
|
||||
audio.src = url
|
||||
})
|
||||
}
|
||||
|
||||
export async function saveCustomSound(blob: Blob, meta: CustomSoundMeta): Promise<void> {
|
||||
const db = await openAudioDB()
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
const tx = db.transaction(STORE_NAME, 'readwrite')
|
||||
tx.objectStore(STORE_NAME).put({ ...meta, blob } satisfies StoredSoundRecord, CUSTOM_SOUND_KEY)
|
||||
tx.oncomplete = () => resolve()
|
||||
tx.onerror = () => reject(tx.error || new Error('保存自定义音频失败'))
|
||||
tx.onabort = () => reject(tx.error || new Error('保存自定义音频被中止'))
|
||||
})
|
||||
db.close()
|
||||
}
|
||||
|
||||
export async function loadCustomSound(): Promise<{ meta: CustomSoundMeta; blob: Blob } | null> {
|
||||
if (typeof indexedDB === 'undefined') return null
|
||||
const db = await openAudioDB()
|
||||
const record = await new Promise<StoredSoundRecord | null>((resolve, reject) => {
|
||||
const tx = db.transaction(STORE_NAME, 'readonly')
|
||||
const request = tx.objectStore(STORE_NAME).get(CUSTOM_SOUND_KEY)
|
||||
request.onsuccess = () => resolve((request.result as StoredSoundRecord | undefined) || null)
|
||||
request.onerror = () => reject(request.error || new Error('读取自定义音频失败'))
|
||||
})
|
||||
db.close()
|
||||
if (!record?.blob) return null
|
||||
return {
|
||||
meta: {
|
||||
id: record.id,
|
||||
name: record.name,
|
||||
mime: record.mime,
|
||||
size: record.size,
|
||||
duration: record.duration,
|
||||
savedAt: record.savedAt,
|
||||
},
|
||||
blob: record.blob,
|
||||
}
|
||||
}
|
||||
|
||||
export async function clearCustomSound(): Promise<void> {
|
||||
if (typeof indexedDB === 'undefined') return
|
||||
const db = await openAudioDB()
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
const tx = db.transaction(STORE_NAME, 'readwrite')
|
||||
tx.objectStore(STORE_NAME).delete(CUSTOM_SOUND_KEY)
|
||||
tx.oncomplete = () => resolve()
|
||||
tx.onerror = () => reject(tx.error || new Error('删除自定义音频失败'))
|
||||
})
|
||||
db.close()
|
||||
}
|
||||
|
||||
export async function decodeAudioBlob(context: AudioContext, blob: Blob): Promise<AudioBuffer> {
|
||||
const data = await blob.arrayBuffer()
|
||||
return context.decodeAudioData(data)
|
||||
}
|
||||
|
||||
interface Tone {
|
||||
frequency: number
|
||||
start: number
|
||||
duration: number
|
||||
type: OscillatorType
|
||||
gain: number
|
||||
slideTo?: number
|
||||
}
|
||||
|
||||
const PRESET_PATTERNS: Record<SoundPresetId, Tone[]> = {
|
||||
chime: [
|
||||
{ frequency: 880, start: 0, duration: 0.16, type: 'sine', gain: 0.26 },
|
||||
{ frequency: 660, start: 0.12, duration: 0.22, type: 'sine', gain: 0.2 },
|
||||
],
|
||||
bell: [
|
||||
{ frequency: 1046.5, start: 0, duration: 0.34, type: 'triangle', gain: 0.2 },
|
||||
{ frequency: 1568, start: 0.02, duration: 0.48, type: 'sine', gain: 0.08 },
|
||||
{ frequency: 784, start: 0.08, duration: 0.4, type: 'sine', gain: 0.07 },
|
||||
],
|
||||
pop: [
|
||||
{ frequency: 520, start: 0, duration: 0.09, type: 'sine', gain: 0.3, slideTo: 760 },
|
||||
{ frequency: 1040, start: 0.06, duration: 0.1, type: 'sine', gain: 0.12 },
|
||||
],
|
||||
urgent: [
|
||||
{ frequency: 880, start: 0, duration: 0.11, type: 'triangle', gain: 0.22 },
|
||||
{ frequency: 880, start: 0.17, duration: 0.11, type: 'triangle', gain: 0.22 },
|
||||
{ frequency: 1174.7, start: 0.34, duration: 0.25, type: 'triangle', gain: 0.26 },
|
||||
],
|
||||
}
|
||||
|
||||
export function playPresetSound(context: AudioContext, preset: SoundPresetId, volume: number): OscillatorNode[] {
|
||||
const safeVolume = Math.min(1, Math.max(0, volume))
|
||||
const base = context.currentTime + 0.01
|
||||
const oscillators: OscillatorNode[] = []
|
||||
for (const tone of PRESET_PATTERNS[preset] || PRESET_PATTERNS.chime) {
|
||||
const start = base + tone.start
|
||||
const end = start + tone.duration
|
||||
const oscillator = context.createOscillator()
|
||||
const gain = context.createGain()
|
||||
oscillator.type = tone.type
|
||||
oscillator.frequency.setValueAtTime(tone.frequency, start)
|
||||
if (tone.slideTo) oscillator.frequency.exponentialRampToValueAtTime(tone.slideTo, end)
|
||||
const peak = Math.max(0.0001, tone.gain * safeVolume)
|
||||
gain.gain.setValueAtTime(0.0001, start)
|
||||
gain.gain.exponentialRampToValueAtTime(peak, start + 0.012)
|
||||
gain.gain.exponentialRampToValueAtTime(0.0001, end)
|
||||
oscillator.connect(gain)
|
||||
gain.connect(context.destination)
|
||||
oscillator.start(start)
|
||||
oscillator.stop(end + 0.03)
|
||||
oscillators.push(oscillator)
|
||||
}
|
||||
return oscillators
|
||||
}
|
||||
|
||||
export function playAudioBuffer(context: AudioContext, buffer: AudioBuffer, volume: number): AudioBufferSourceNode {
|
||||
const source = context.createBufferSource()
|
||||
const gain = context.createGain()
|
||||
source.buffer = buffer
|
||||
gain.gain.value = Math.min(1, Math.max(0, volume))
|
||||
source.connect(gain)
|
||||
gain.connect(context.destination)
|
||||
source.start()
|
||||
return source
|
||||
}
|
||||
|
||||
export function isSpeechSupported(): boolean {
|
||||
return typeof window !== 'undefined' && 'speechSynthesis' in window && 'SpeechSynthesisUtterance' in window
|
||||
}
|
||||
|
||||
export function listSpeechVoices(): SpeechSynthesisVoice[] {
|
||||
if (!isSpeechSupported()) return []
|
||||
return window.speechSynthesis.getVoices().slice().sort((a, b) => {
|
||||
const aZh = a.lang.toLowerCase().startsWith('zh') ? 0 : 1
|
||||
const bZh = b.lang.toLowerCase().startsWith('zh') ? 0 : 1
|
||||
if (aZh !== bZh) return aZh - bZh
|
||||
return a.name.localeCompare(b.name)
|
||||
})
|
||||
}
|
||||
|
||||
export interface SpeakOptions {
|
||||
voiceURI?: string
|
||||
rate?: number
|
||||
volume?: number
|
||||
lang?: string
|
||||
}
|
||||
|
||||
export function speakText(text: string, options: SpeakOptions = {}): boolean {
|
||||
const content = text.trim()
|
||||
if (!content || !isSpeechSupported()) return false
|
||||
const synth = window.speechSynthesis
|
||||
synth.cancel()
|
||||
const utterance = new SpeechSynthesisUtterance(content)
|
||||
utterance.lang = options.lang || 'zh-CN'
|
||||
utterance.rate = Math.min(1.6, Math.max(0.6, options.rate ?? 1))
|
||||
utterance.volume = Math.min(1, Math.max(0, options.volume ?? 0.8))
|
||||
const voice = options.voiceURI
|
||||
? synth.getVoices().find(item => item.voiceURI === options.voiceURI)
|
||||
: undefined
|
||||
if (voice) utterance.voice = voice
|
||||
synth.speak(utterance)
|
||||
return true
|
||||
}
|
||||
|
||||
export function cancelSpeech(): void {
|
||||
if (isSpeechSupported()) window.speechSynthesis.cancel()
|
||||
}
|
||||
@@ -0,0 +1,137 @@
|
||||
export type SoundPresetId = 'chime' | 'bell' | 'pop' | 'urgent'
|
||||
export type SoundSource = 'preset' | 'custom'
|
||||
export type SpeakMode = 'brief' | 'summary'
|
||||
|
||||
export interface CustomSoundMeta {
|
||||
id: string
|
||||
name: string
|
||||
mime: string
|
||||
size: number
|
||||
duration: number
|
||||
savedAt: number
|
||||
}
|
||||
|
||||
export interface NotificationPreferences {
|
||||
version: 1
|
||||
soundEnabled: boolean
|
||||
desktopEnabled: boolean
|
||||
soundSource: SoundSource
|
||||
preset: SoundPresetId
|
||||
volume: number
|
||||
customSound: CustomSoundMeta | null
|
||||
speakEnabled: boolean
|
||||
speakMode: SpeakMode
|
||||
speakRate: number
|
||||
voiceURI: string
|
||||
}
|
||||
|
||||
export const SOUND_PRESETS: { value: SoundPresetId; label: string; desc: string }[] = [
|
||||
{ value: 'chime', label: '清脆叮咚', desc: '双音提示,适合日常消息' },
|
||||
{ value: 'bell', label: '轻铃', desc: '柔和铃声,适合安静环境' },
|
||||
{ value: 'pop', label: '气泡', desc: '短促轻提示,不打扰' },
|
||||
{ value: 'urgent', label: '紧急提醒', desc: '连续强提示,适合重要会话' },
|
||||
]
|
||||
|
||||
export const SPEAK_MODES: { value: SpeakMode; label: string; desc: string }[] = [
|
||||
{ value: 'brief', label: '仅提示有新消息', desc: '例如:访客发来一条新消息' },
|
||||
{ value: 'summary', label: '名称 + 内容摘要', desc: '例如:访客说,请问什么时候发货' },
|
||||
]
|
||||
|
||||
const STORAGE_KEY = 'kefu_notification_prefs_v2'
|
||||
const LEGACY_SOUND_KEY = 'kefu_notification_sound'
|
||||
const LEGACY_DESKTOP_KEY = 'kefu_notification_desktop'
|
||||
|
||||
export const MAX_CUSTOM_SOUND_BYTES = 2 * 1024 * 1024
|
||||
export const MAX_CUSTOM_SOUND_SECONDS = 15
|
||||
|
||||
function clamp(value: number, min: number, max: number): number {
|
||||
return Math.min(max, Math.max(min, value))
|
||||
}
|
||||
|
||||
function readLegacyBoolean(key: string, fallback: boolean): boolean {
|
||||
try {
|
||||
const raw = localStorage.getItem(key)
|
||||
return raw == null ? fallback : raw === 'true'
|
||||
} catch {
|
||||
return fallback
|
||||
}
|
||||
}
|
||||
|
||||
function normalizePreset(value: unknown): SoundPresetId {
|
||||
return SOUND_PRESETS.some(item => item.value === value) ? value as SoundPresetId : 'chime'
|
||||
}
|
||||
|
||||
function normalizeCustomSound(value: unknown): CustomSoundMeta | null {
|
||||
if (!value || typeof value !== 'object') return null
|
||||
const raw = value as Partial<CustomSoundMeta>
|
||||
if (typeof raw.id !== 'string' || !raw.id || typeof raw.name !== 'string' || !raw.name) return null
|
||||
return {
|
||||
id: raw.id,
|
||||
name: raw.name.slice(0, 120),
|
||||
mime: typeof raw.mime === 'string' ? raw.mime : '',
|
||||
size: Number.isFinite(raw.size) ? Number(raw.size) : 0,
|
||||
duration: Number.isFinite(raw.duration) ? Number(raw.duration) : 0,
|
||||
savedAt: Number.isFinite(raw.savedAt) ? Number(raw.savedAt) : Date.now(),
|
||||
}
|
||||
}
|
||||
|
||||
export function defaultNotificationPreferences(): NotificationPreferences {
|
||||
return {
|
||||
version: 1,
|
||||
soundEnabled: true,
|
||||
desktopEnabled: true,
|
||||
soundSource: 'preset',
|
||||
preset: 'chime',
|
||||
volume: 0.7,
|
||||
customSound: null,
|
||||
speakEnabled: false,
|
||||
speakMode: 'brief',
|
||||
speakRate: 1,
|
||||
voiceURI: '',
|
||||
}
|
||||
}
|
||||
|
||||
export function loadNotificationPreferences(): NotificationPreferences {
|
||||
const fallback = defaultNotificationPreferences()
|
||||
fallback.soundEnabled = readLegacyBoolean(LEGACY_SOUND_KEY, fallback.soundEnabled)
|
||||
fallback.desktopEnabled = readLegacyBoolean(LEGACY_DESKTOP_KEY, fallback.desktopEnabled)
|
||||
try {
|
||||
const raw = localStorage.getItem(STORAGE_KEY)
|
||||
if (!raw) return fallback
|
||||
const saved = JSON.parse(raw) as Partial<NotificationPreferences>
|
||||
const customSound = normalizeCustomSound(saved.customSound)
|
||||
const soundSource: SoundSource = saved.soundSource === 'custom' && customSound ? 'custom' : 'preset'
|
||||
return {
|
||||
version: 1,
|
||||
soundEnabled: typeof saved.soundEnabled === 'boolean' ? saved.soundEnabled : fallback.soundEnabled,
|
||||
desktopEnabled: typeof saved.desktopEnabled === 'boolean' ? saved.desktopEnabled : fallback.desktopEnabled,
|
||||
soundSource,
|
||||
preset: normalizePreset(saved.preset),
|
||||
volume: typeof saved.volume === 'number' ? clamp(saved.volume, 0, 1) : fallback.volume,
|
||||
customSound,
|
||||
speakEnabled: typeof saved.speakEnabled === 'boolean' ? saved.speakEnabled : fallback.speakEnabled,
|
||||
speakMode: saved.speakMode === 'summary' ? 'summary' : 'brief',
|
||||
speakRate: typeof saved.speakRate === 'number' ? clamp(saved.speakRate, 0.6, 1.6) : fallback.speakRate,
|
||||
voiceURI: typeof saved.voiceURI === 'string' ? saved.voiceURI.slice(0, 300) : '',
|
||||
}
|
||||
} catch {
|
||||
return fallback
|
||||
}
|
||||
}
|
||||
|
||||
export function saveNotificationPreferences(prefs: NotificationPreferences): void {
|
||||
try {
|
||||
const serialized = JSON.stringify(prefs)
|
||||
if (localStorage.getItem(STORAGE_KEY) !== serialized) {
|
||||
localStorage.setItem(STORAGE_KEY, serialized)
|
||||
}
|
||||
localStorage.setItem(LEGACY_SOUND_KEY, String(prefs.soundEnabled))
|
||||
localStorage.setItem(LEGACY_DESKTOP_KEY, String(prefs.desktopEnabled))
|
||||
} catch {
|
||||
// localStorage may be unavailable in private browsing / restricted contexts.
|
||||
}
|
||||
}
|
||||
|
||||
export function notificationPreferencesStorageKey(): string {
|
||||
return STORAGE_KEY
|
||||
}
|
||||
Reference in New Issue
Block a user