feat: add configurable completion notification sound
This commit is contained in:
commit
f39875dc21
1 file changed
+266
@@ -0,0 +1,266 @@
|
|||||||
|
import {
|
||||||
|
Button,
|
||||||
|
host,
|
||||||
|
Popover,
|
||||||
|
PopoverContent,
|
||||||
|
PopoverTrigger,
|
||||||
|
STATUSBAR_AREAS,
|
||||||
|
Switch,
|
||||||
|
Tip
|
||||||
|
} from '@hermes/plugin-sdk'
|
||||||
|
import { useState } from 'react'
|
||||||
|
import { jsx, jsxs } from 'react/jsx-runtime'
|
||||||
|
|
||||||
|
const ID = 'completion-notification-sound'
|
||||||
|
const STORAGE_VOLUME = 'volume'
|
||||||
|
const STORAGE_ENABLED = 'enabled'
|
||||||
|
const STORAGE_DURATION = 'duration'
|
||||||
|
const DEFAULT_VOLUME = 0.35
|
||||||
|
const MIN_VOLUME = 0
|
||||||
|
const MAX_VOLUME = 1
|
||||||
|
const DEFAULT_DURATION = 0.35
|
||||||
|
const MIN_DURATION = 0.1
|
||||||
|
const MAX_DURATION = 5
|
||||||
|
const EVENT_DEDUPE_MS = 1200
|
||||||
|
|
||||||
|
let storage = null
|
||||||
|
let audioContext = null
|
||||||
|
let lastCompletionAt = 0
|
||||||
|
let lastEventKey = ''
|
||||||
|
let eventDisposer = null
|
||||||
|
|
||||||
|
function readVolume() {
|
||||||
|
try {
|
||||||
|
const value = Number(storage?.get(STORAGE_VOLUME, DEFAULT_VOLUME))
|
||||||
|
return Number.isFinite(value) ? Math.max(MIN_VOLUME, Math.min(MAX_VOLUME, value)) : DEFAULT_VOLUME
|
||||||
|
} catch {
|
||||||
|
return DEFAULT_VOLUME
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function readEnabled() {
|
||||||
|
try {
|
||||||
|
return storage?.get(STORAGE_ENABLED, true) !== false
|
||||||
|
} catch {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function readDuration() {
|
||||||
|
try {
|
||||||
|
const value = Number(storage?.get(STORAGE_DURATION, DEFAULT_DURATION))
|
||||||
|
return Number.isFinite(value) ? Math.max(MIN_DURATION, Math.min(MAX_DURATION, value)) : DEFAULT_DURATION
|
||||||
|
} catch {
|
||||||
|
return DEFAULT_DURATION
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function getAudioContext() {
|
||||||
|
if (typeof window === 'undefined') return null
|
||||||
|
const AudioContextClass = window.AudioContext || window.webkitAudioContext
|
||||||
|
if (!AudioContextClass) return null
|
||||||
|
if (!audioContext || audioContext.state === 'closed') audioContext = new AudioContextClass()
|
||||||
|
if (audioContext.state === 'suspended') void audioContext.resume().catch(() => {})
|
||||||
|
return audioContext
|
||||||
|
}
|
||||||
|
|
||||||
|
function playTone({ preview = false } = {}) {
|
||||||
|
const ctx = getAudioContext()
|
||||||
|
if (!ctx) return false
|
||||||
|
const volume = readVolume()
|
||||||
|
if (volume <= 0) return false
|
||||||
|
|
||||||
|
const now = ctx.currentTime
|
||||||
|
const master = ctx.createGain()
|
||||||
|
master.gain.setValueAtTime(Math.min(1, volume) * 0.14, now)
|
||||||
|
master.connect(ctx.destination)
|
||||||
|
|
||||||
|
const duration = readDuration()
|
||||||
|
const melody = [
|
||||||
|
659.25, 783.99, 1046.5, 987.77,
|
||||||
|
880, 783.99, 659.25, 523.25
|
||||||
|
]
|
||||||
|
const noteCount = Math.max(2, Math.min(melody.length, Math.round(2 + (duration - MIN_DURATION) * 1.35)))
|
||||||
|
const interval = duration / noteCount
|
||||||
|
const notes = melody.slice(0, noteCount).map((frequency, index) => ({
|
||||||
|
frequency,
|
||||||
|
start: index * interval,
|
||||||
|
duration: Math.max(0.06, interval * 0.82)
|
||||||
|
}))
|
||||||
|
for (const note of notes) {
|
||||||
|
const osc = ctx.createOscillator()
|
||||||
|
const gain = ctx.createGain()
|
||||||
|
osc.type = 'triangle'
|
||||||
|
osc.frequency.setValueAtTime(note.frequency, now + note.start)
|
||||||
|
gain.gain.setValueAtTime(0.0001, now + note.start)
|
||||||
|
gain.gain.exponentialRampToValueAtTime(0.8, now + note.start + 0.018)
|
||||||
|
gain.gain.exponentialRampToValueAtTime(0.0001, now + note.start + note.duration)
|
||||||
|
osc.connect(gain)
|
||||||
|
gain.connect(master)
|
||||||
|
osc.start(now + note.start)
|
||||||
|
osc.stop(now + note.start + note.duration + 0.025)
|
||||||
|
}
|
||||||
|
master.gain.setTargetAtTime(0.0001, now + duration + 0.08, 0.08)
|
||||||
|
if (preview) void ctx.resume().catch(() => {})
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
function SettingsPage({ onEnabledChange } = {}) {
|
||||||
|
const volume = readVolume()
|
||||||
|
const enabled = readEnabled()
|
||||||
|
const volumePct = Math.round(volume * 100)
|
||||||
|
const duration = readDuration()
|
||||||
|
const durationLabel = duration.toFixed(1) + ' сек.'
|
||||||
|
|
||||||
|
const updateVolume = event => {
|
||||||
|
const value = Number(event.currentTarget.value)
|
||||||
|
const next = Number.isFinite(value) ? Math.max(MIN_VOLUME, Math.min(MAX_VOLUME, value)) : DEFAULT_VOLUME
|
||||||
|
try { storage?.set(STORAGE_VOLUME, next) } catch { /* keep current session setting */ }
|
||||||
|
const output = event.currentTarget.parentElement?.querySelector('[data-volume-value]')
|
||||||
|
if (output) output.textContent = next + '%'
|
||||||
|
}
|
||||||
|
const updateDuration = event => {
|
||||||
|
const value = Number(event.currentTarget.value)
|
||||||
|
const next = Number.isFinite(value) ? Math.max(MIN_DURATION, Math.min(MAX_DURATION, value)) : DEFAULT_DURATION
|
||||||
|
try { storage?.set(STORAGE_DURATION, next) } catch { /* keep current session setting */ }
|
||||||
|
const output = event.currentTarget.parentElement?.querySelector('[data-duration-value]')
|
||||||
|
if (output) output.textContent = next.toFixed(1) + ' сек.'
|
||||||
|
}
|
||||||
|
|
||||||
|
const updateEnabled = event => {
|
||||||
|
try { storage?.set(STORAGE_ENABLED, event.currentTarget.checked) } catch { /* keep current session setting */ }
|
||||||
|
if (onEnabledChange) onEnabledChange(event.currentTarget.checked)
|
||||||
|
}
|
||||||
|
|
||||||
|
return jsxs('div', {
|
||||||
|
className: 'flex w-80 flex-col gap-4 p-4 text-sm',
|
||||||
|
children: [
|
||||||
|
jsxs('div', { children: [
|
||||||
|
jsx('h2', { className: 'text-lg font-semibold text-(--ui-text-primary)', children: 'Звук завершения' }),
|
||||||
|
jsx('p', { className: 'mt-1 text-(--ui-text-tertiary)', children: 'Свой сигнал при завершении ответа агента. Работает только в открытом Hermes Desktop.' })
|
||||||
|
] }),
|
||||||
|
jsxs('label', { className: 'flex items-center justify-between gap-4', children: [
|
||||||
|
jsxs('span', { children: [
|
||||||
|
jsx('span', { className: 'block font-medium text-(--ui-text-primary)', children: 'Воспроизводить звук' }),
|
||||||
|
jsx('span', { className: 'text-(--ui-text-tertiary)', children: 'Включи, если отключил штатный Completion Sound.' })
|
||||||
|
] }),
|
||||||
|
jsx(Switch, {
|
||||||
|
checked: enabled,
|
||||||
|
onCheckedChange: checked => updateEnabled({ currentTarget: { checked } }),
|
||||||
|
'aria-label': 'Воспроизводить звук завершения'
|
||||||
|
})
|
||||||
|
] }),
|
||||||
|
jsxs('label', { className: 'flex flex-col gap-2', children: [
|
||||||
|
jsxs('span', { className: 'flex items-center justify-between', children: [
|
||||||
|
jsx('span', { className: 'font-medium text-(--ui-text-primary)', children: 'Громкость' }),
|
||||||
|
jsx('span', { className: 'tabular-nums text-(--ui-text-secondary)', 'data-volume-value': true, children: volumePct + '%' })
|
||||||
|
] }),
|
||||||
|
jsx('input', { type: 'range', min: MIN_VOLUME, max: MAX_VOLUME, step: 0.01, defaultValue: volume, onInput: updateVolume, 'aria-label': 'Громкость звука завершения' }),
|
||||||
|
jsx('span', { className: 'text-xs text-(--ui-text-quaternary)', children: '0–100%' })
|
||||||
|
] }),
|
||||||
|
jsxs('label', { className: 'flex flex-col gap-2', children: [
|
||||||
|
jsxs('span', { className: 'flex items-center justify-between', children: [
|
||||||
|
jsx('span', { className: 'font-medium text-(--ui-text-primary)', children: 'Длительность' }),
|
||||||
|
jsx('span', { className: 'tabular-nums text-(--ui-text-secondary)', 'data-duration-value': true, children: durationLabel })
|
||||||
|
] }),
|
||||||
|
jsx('input', { type: 'range', min: MIN_DURATION, max: MAX_DURATION, step: 0.1, defaultValue: duration, onInput: updateDuration, 'aria-label': 'Длительность звука завершения' }),
|
||||||
|
jsx('span', { className: 'text-xs text-(--ui-text-quaternary)', children: '0,1–5,0 сек.' })
|
||||||
|
] }),
|
||||||
|
jsx(Button, {
|
||||||
|
type: 'button',
|
||||||
|
size: 'sm',
|
||||||
|
variant: 'outline',
|
||||||
|
className: 'self-start',
|
||||||
|
onClick: () => {
|
||||||
|
if (!playTone({ preview: true })) host.notify({ kind: 'warning', message: 'Web Audio недоступен или громкость установлена на 0.' })
|
||||||
|
},
|
||||||
|
children: 'Прослушать'
|
||||||
|
})
|
||||||
|
]
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function SoundStatusbar() {
|
||||||
|
const [enabled, setEnabled] = useState(readEnabled())
|
||||||
|
const icon = enabled
|
||||||
|
? '♫'
|
||||||
|
: jsx('span', {
|
||||||
|
className: 'relative inline-flex h-4 w-4 items-center justify-center',
|
||||||
|
children: [
|
||||||
|
jsx('span', { children: '♫' }),
|
||||||
|
jsx('span', {
|
||||||
|
className: 'pointer-events-none absolute -left-0.5 -top-0.5 text-[1.25em] leading-none',
|
||||||
|
style: { transform: 'rotate(-22deg)' },
|
||||||
|
children: '╱'
|
||||||
|
})
|
||||||
|
]
|
||||||
|
})
|
||||||
|
const label = enabled ? 'Кастомный звук включён' : 'Кастомный звук выключен'
|
||||||
|
|
||||||
|
return jsx(Popover, {
|
||||||
|
children: [
|
||||||
|
jsx(Tip, {
|
||||||
|
label: label,
|
||||||
|
children: jsx(PopoverTrigger, {
|
||||||
|
asChild: true,
|
||||||
|
children: jsx('button', {
|
||||||
|
type: 'button',
|
||||||
|
'aria-label': label,
|
||||||
|
className: 'inline-flex h-full items-center px-1.5 text-sm text-(--ui-text-tertiary) transition-colors hover:bg-(--chrome-action-hover) hover:text-foreground',
|
||||||
|
onClick: () => {},
|
||||||
|
children: icon
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}),
|
||||||
|
jsx(PopoverContent, {
|
||||||
|
align: 'end',
|
||||||
|
side: 'top',
|
||||||
|
className: 'w-auto p-0',
|
||||||
|
children: jsx(SettingsPage, { onEnabledChange: setEnabled })
|
||||||
|
})
|
||||||
|
]
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export default {
|
||||||
|
id: ID,
|
||||||
|
name: 'Completion Sound Volume',
|
||||||
|
defaultEnabled: true,
|
||||||
|
register(ctx) {
|
||||||
|
storage = ctx.storage
|
||||||
|
try {
|
||||||
|
const savedVolume = Number(storage.get(STORAGE_VOLUME, DEFAULT_VOLUME))
|
||||||
|
if (!Number.isFinite(savedVolume) || savedVolume < MIN_VOLUME || savedVolume > MAX_VOLUME) storage.set(STORAGE_VOLUME, DEFAULT_VOLUME)
|
||||||
|
const savedDuration = Number(storage.get(STORAGE_DURATION, DEFAULT_DURATION))
|
||||||
|
if (!Number.isFinite(savedDuration) || savedDuration < MIN_DURATION || savedDuration > MAX_DURATION) storage.set(STORAGE_DURATION, DEFAULT_DURATION)
|
||||||
|
} catch { /* storage is optional; defaults remain usable */ }
|
||||||
|
|
||||||
|
eventDisposer = host.onEvent('message.complete', event => {
|
||||||
|
if (!readEnabled()) return
|
||||||
|
const payload = event?.payload && typeof event.payload === 'object' ? event.payload : {}
|
||||||
|
const key = String(event?.session_id || payload.session_id || '') + ':' + String(payload.message_id || payload.id || '')
|
||||||
|
const now = Date.now()
|
||||||
|
if ((key !== ':' && key === lastEventKey) || now - lastCompletionAt < EVENT_DEDUPE_MS) return
|
||||||
|
lastEventKey = key
|
||||||
|
lastCompletionAt = now
|
||||||
|
playTone()
|
||||||
|
})
|
||||||
|
|
||||||
|
ctx.register({
|
||||||
|
id: 'statusbar-settings',
|
||||||
|
area: STATUSBAR_AREAS.right,
|
||||||
|
order: 125,
|
||||||
|
render: () => jsx(SoundStatusbar, {})
|
||||||
|
})
|
||||||
|
|
||||||
|
ctx.onDispose(() => {
|
||||||
|
if (eventDisposer) eventDisposer()
|
||||||
|
eventDisposer = null
|
||||||
|
storage = null
|
||||||
|
if (audioContext && audioContext.state !== 'closed') void audioContext.close().catch(() => {})
|
||||||
|
audioContext = null
|
||||||
|
lastEventKey = ''
|
||||||
|
lastCompletionAt = 0
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in new issue
Block a user