feat(web): 增强消息提醒声音与语音朗读
CI / Frontend (typecheck + lint + build) (push) Failing after 18m50s
CI / Backend (go vet + test) (push) Failing after 18m50s

This commit is contained in:
yml2213
2026-10-29 21:53:06 +08:00
parent e6837a4878
commit 3d04175ef3
7 changed files with 1140 additions and 61 deletions
+230
View File
@@ -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()
}
+137
View File
@@ -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
}