From f39875dc2132082ed808da4c6a9793af57dd15cb Mon Sep 17 00:00:00 2001 From: admin Date: Tue, 6 Oct 2026 11:51:19 +0000 Subject: [PATCH] feat: add configurable completion notification sound --- plugin.js | 266 ++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 266 insertions(+) create mode 100644 plugin.js diff --git a/plugin.js b/plugin.js new file mode 100644 index 0000000..713eb00 --- /dev/null +++ b/plugin.js @@ -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 + }) + } +}